mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[Haik]: ckpt, updated readmes
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Getting Started
|
||||
# Contributing
|
||||
|
||||
A step-by-step guide to get Open Swarm running locally — from clone to launch.
|
||||
A guide to setting up Open Swarm for local development and contributing to the project.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,10 +15,8 @@ Make sure the following are installed on your machine before proceeding:
|
||||
| **Node.js** | 18+ | `node --version` |
|
||||
| **npm** | 9+ (ships with Node) | `npm --version` |
|
||||
|
||||
### Installing prerequisites
|
||||
|
||||
<details>
|
||||
<summary><strong>Node.js (via nvm)</strong></summary>
|
||||
<summary><strong>Installing Node.js via nvm</strong></summary>
|
||||
|
||||
```bash
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
@@ -34,54 +32,61 @@ nvm use 22
|
||||
## 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<your-org>/self-swarm.git
|
||||
cd self-swarm
|
||||
git clone https://github.com/openswarm-ai/openswarm.git
|
||||
cd openswarm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend setup (Configure environment variables)
|
||||
## 2. Configure environment variables
|
||||
|
||||
Copy the example environment file and fill in your values:
|
||||
Copy the example environment file:
|
||||
|
||||
```bash
|
||||
cp backend/.env.example backend/.env
|
||||
```
|
||||
|
||||
Edit `backend/.env` with your values:
|
||||
The Anthropic API key can be set in-app via the **Settings** page — no `.env` entry needed for that.
|
||||
|
||||
```env
|
||||
# Backend server port
|
||||
BACKEND_PORT=8324
|
||||
For other integrations, edit `backend/.env`:
|
||||
|
||||
# Google OAuth (optional — needed for Google Workspace tools)
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
```
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Run the application
|
||||
|
||||
You have two options: use the provided **run scripts** (recommended) or start each service manually.
|
||||
|
||||
### Option A: Run scripts (recommended)
|
||||
|
||||
These scripts handle virtual environments, dependency installation, and startup automatically.
|
||||
|
||||
**Backend** (starts FastAPI server):
|
||||
### Option A: All-in-one (recommended)
|
||||
|
||||
```bash
|
||||
./backend/run/dev.sh
|
||||
bash run/local.sh
|
||||
```
|
||||
|
||||
**Frontend** (in a separate terminal):
|
||||
This starts the backend (port 8324), frontend (port 3000), and Electron shell together. The script handles virtual environments and dependency installation automatically.
|
||||
|
||||
### Option B: Run services individually
|
||||
|
||||
**Backend** (in one terminal):
|
||||
|
||||
```bash
|
||||
./frontend/run/dev.sh
|
||||
bash backend/run.sh # API at http://localhost:8324 — docs at /docs
|
||||
```
|
||||
|
||||
### Option B: Manual startup
|
||||
**Frontend** (in another terminal):
|
||||
|
||||
```bash
|
||||
bash frontend/run.sh # App at http://localhost:3000
|
||||
```
|
||||
|
||||
### Option C: Manual startup
|
||||
|
||||
**Terminal 1 — Backend server:**
|
||||
|
||||
@@ -166,47 +171,74 @@ Your agents can now use Google Calendar, Gmail, Drive, etc. through MCP tools.
|
||||
## Project structure
|
||||
|
||||
```
|
||||
self-swarm/
|
||||
├── backend/
|
||||
│ ├── apps/ # FastAPI route modules
|
||||
│ │ ├── agents/ # Agent lifecycle, WebSocket, worktree management
|
||||
│ │ ├── templates/ # Prompt template CRUD
|
||||
│ │ ├── skills/ # Skills CRUD (synced to ~/.claude/skills/)
|
||||
│ │ ├── tools_lib/ # Tool definitions CRUD
|
||||
│ │ ├── modes/ # Agent mode configurations
|
||||
│ │ ├── settings/ # App settings (API keys, preferences)
|
||||
│ │ ├── outputs/ # Output management
|
||||
│ │ └── dashboards/ # Dashboard layout persistence
|
||||
│ ├── config/ # FastAPI app configuration
|
||||
│ ├── data/ # Persistent JSON file storage
|
||||
│ ├── run/ # Shell scripts for starting the backend
|
||||
│ ├── main.py # FastAPI entrypoint
|
||||
│ ├── requirements.txt # Python dependencies
|
||||
│ └── .env # Environment variables (not committed)
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── app/
|
||||
│ │ │ ├── components/ # AppShell, CommandPicker, modals
|
||||
│ │ │ └── pages/ # Dashboard, AgentChat, Templates, Skills, Tools, etc.
|
||||
│ │ └── shared/
|
||||
│ │ ├── state/ # Redux slices
|
||||
│ │ ├── ws/ # WebSocket manager
|
||||
│ │ └── hooks/ # Custom React hooks
|
||||
│ ├── public/ # Static assets
|
||||
│ ├── webpack.config.js # Webpack bundler config
|
||||
│ └── package.json # Node dependencies
|
||||
├── debugger/ # Optional debugging tool
|
||||
└── README.md
|
||||
backend/
|
||||
apps/
|
||||
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, Layout, shared UI
|
||||
pages/
|
||||
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, 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
|
||||
|
||||
run/
|
||||
utils/
|
||||
build-app.sh Desktop app packaging (electron-builder)
|
||||
build-python-env.sh Standalone Python 3.13 environment bundler
|
||||
local.sh Start backend, frontend, and Electron shell
|
||||
publish.sh Build and deploy to Firebase Hosting
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contribution workflow
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend won't start — `ModuleNotFoundError`
|
||||
Make sure you're running from the **project root** (not from `backend/`):
|
||||
```bash
|
||||
cd self-swarm
|
||||
cd openswarm
|
||||
python -m uvicorn backend.main:app --host 0.0.0.0 --port 8324 --reload
|
||||
```
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
|
||||
<a href="GETTING_STARTED.md"><img src="https://img.shields.io/badge/📖_Getting_Started-guide-orange.svg" alt="Getting Started"></a>
|
||||
<a href="CONTRIBUTING.md"><img src="https://img.shields.io/badge/📖_Contributing-guide-orange.svg" alt="Contributing"></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>
|
||||
@@ -67,7 +67,7 @@ Running agents in a terminal works fine for one task. But when you're juggling f
|
||||
|
||||
**Dark & Light Themes** — Full theme support with design tokens.
|
||||
|
||||
**Keyboard Shortcuts** — Navigate between agents, approve/deny requests, and switch pages without touching a mouse.
|
||||
**Keyboard Shortcuts** — Navigate between agents, approve/deny requests, and switch pages. Press `?` in-app to see all shortcuts.
|
||||
|
||||
<br>
|
||||
|
||||
@@ -91,12 +91,7 @@ bash run/local.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
|
||||
```
|
||||
See the **[Contributing Guide](CONTRIBUTING.md)** for detailed setup options, environment configuration, Google Workspace integration, and troubleshooting.
|
||||
|
||||
<br>
|
||||
|
||||
@@ -121,93 +116,6 @@ Electron Shell (desktop wrapper, auto-updater)
|
||||
|
||||
<br>
|
||||
|
||||
## Configuration
|
||||
|
||||
The Anthropic API key is configured in-app via the **Settings** page — no environment variable needed for normal usage.
|
||||
|
||||
For advanced configuration, copy `backend/.env.example` to `backend/.env`:
|
||||
|
||||
| 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) |
|
||||
|
||||
<br>
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `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 |
|
||||
|
||||
Type `/` in the chat input to invoke prompt templates and skills as slash commands.
|
||||
|
||||
<br>
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
apps/
|
||||
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, Layout, shared UI
|
||||
pages/
|
||||
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, 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
|
||||
|
||||
run/
|
||||
utils/
|
||||
build-app.sh Desktop app packaging (electron-builder)
|
||||
build-python-env.sh Standalone Python 3.13 environment bundler
|
||||
local.sh Start backend, frontend, and Electron shell
|
||||
publish.sh Build and deploy to Firebase Hosting
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
## Tech Stack
|
||||
|
||||
**Frontend** — React 18, TypeScript, Redux Toolkit, Material UI v7, CodeMirror 6, Framer Motion, React Router v7, Webpack 5
|
||||
@@ -222,14 +130,7 @@ run/
|
||||
|
||||
## 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.
|
||||
Contributions are welcome — see **[CONTRIBUTING.md](CONTRIBUTING.md)** for the full development setup, project structure, and contribution workflow.
|
||||
|
||||
<br>
|
||||
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
"""Standalone MCP client manager for agent sessions.
|
||||
|
||||
Replaces claude_agent_sdk's internal MCP server management.
|
||||
One MCPClientManager instance per agent session — manages connections
|
||||
to stdio/http/sse MCP servers, discovers tools, and routes tool calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from contextlib import AsyncExitStack
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from backend.apps.agents.providers.base import ToolSchema
|
||||
from backend.apps.common.mcp_utils import parse_sse_json as _parse_sse_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPConnection:
|
||||
"""A live connection to an MCP server."""
|
||||
server_name: str
|
||||
session: Any # mcp.ClientSession
|
||||
tools: list[ToolSchema] = field(default_factory=list)
|
||||
|
||||
|
||||
class MCPClientManager:
|
||||
"""Manages connections to MCP servers for a single agent session."""
|
||||
|
||||
def __init__(self):
|
||||
self._connections: dict[str, MCPConnection] = {}
|
||||
self._exit_stack = AsyncExitStack()
|
||||
self._started = False
|
||||
|
||||
async def __aenter__(self):
|
||||
await self._exit_stack.__aenter__()
|
||||
self._started = True
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
await self.disconnect_all()
|
||||
try:
|
||||
await self._exit_stack.__aexit__(*exc)
|
||||
except (BaseExceptionGroup, ExceptionGroup, Exception) as e:
|
||||
# MCP subprocess cleanup errors are non-fatal
|
||||
logger.warning(f"MCP cleanup error (non-fatal): {e}")
|
||||
self._started = False
|
||||
|
||||
async def connect(self, server_name: str, config: dict, timeout: float = 30.0) -> list[ToolSchema]:
|
||||
"""Connect to an MCP server and return its available tools.
|
||||
|
||||
The tools are returned with names prefixed as mcp__<server_name>__<tool_name>.
|
||||
"""
|
||||
transport = config.get("type", "stdio")
|
||||
try:
|
||||
if transport == "stdio":
|
||||
coro = self._connect_stdio(server_name, config)
|
||||
elif transport == "sse":
|
||||
coro = self._connect_sse(server_name, config)
|
||||
elif transport == "http":
|
||||
coro = self._connect_http(server_name, config)
|
||||
else:
|
||||
logger.warning(f"Unsupported MCP transport: {transport} for {server_name}")
|
||||
return []
|
||||
|
||||
conn = await asyncio.wait_for(coro, timeout=timeout)
|
||||
self._connections[server_name] = conn
|
||||
logger.info(f"MCP connected: {server_name} ({len(conn.tools)} tools)")
|
||||
return conn.tools
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"MCP server {server_name} connection timed out after {timeout}s")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to connect MCP server {server_name}: {e}")
|
||||
return []
|
||||
|
||||
async def _connect_stdio(self, server_name: str, config: dict) -> MCPConnection:
|
||||
"""Connect to a stdio MCP server (spawns a subprocess)."""
|
||||
from mcp import ClientSession
|
||||
from mcp.client.stdio import stdio_client, StdioServerParameters
|
||||
|
||||
command = config.get("command", "")
|
||||
args = config.get("args", [])
|
||||
env = config.get("env")
|
||||
|
||||
params = StdioServerParameters(
|
||||
command=command,
|
||||
args=args,
|
||||
env=env,
|
||||
)
|
||||
|
||||
transport = await self._exit_stack.enter_async_context(
|
||||
stdio_client(params)
|
||||
)
|
||||
read_stream, write_stream = transport
|
||||
session = await self._exit_stack.enter_async_context(
|
||||
ClientSession(read_stream, write_stream)
|
||||
)
|
||||
await session.initialize()
|
||||
|
||||
result = await session.list_tools()
|
||||
tools = [
|
||||
ToolSchema(
|
||||
name=f"mcp__{server_name}__{t.name}",
|
||||
description=t.description or "",
|
||||
input_schema=t.inputSchema if hasattr(t, "inputSchema") else (t.input_schema if hasattr(t, "input_schema") else {}),
|
||||
)
|
||||
for t in result.tools
|
||||
]
|
||||
|
||||
return MCPConnection(server_name=server_name, session=session, tools=tools)
|
||||
|
||||
async def _connect_sse(self, server_name: str, config: dict) -> MCPConnection:
|
||||
"""Connect to an SSE MCP server."""
|
||||
from mcp import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
|
||||
url = config.get("url", "")
|
||||
headers = config.get("headers")
|
||||
|
||||
transport = await self._exit_stack.enter_async_context(
|
||||
sse_client(url=url, headers=headers, timeout=30, sse_read_timeout=300)
|
||||
)
|
||||
read_stream, write_stream = transport
|
||||
session = await self._exit_stack.enter_async_context(
|
||||
ClientSession(read_stream, write_stream)
|
||||
)
|
||||
await session.initialize()
|
||||
|
||||
result = await session.list_tools()
|
||||
tools = [
|
||||
ToolSchema(
|
||||
name=f"mcp__{server_name}__{t.name}",
|
||||
description=t.description or "",
|
||||
input_schema=t.inputSchema if hasattr(t, "inputSchema") else (t.input_schema if hasattr(t, "input_schema") else {}),
|
||||
)
|
||||
for t in result.tools
|
||||
]
|
||||
|
||||
return MCPConnection(server_name=server_name, session=session, tools=tools)
|
||||
|
||||
async def _connect_http(self, server_name: str, config: dict) -> MCPConnection:
|
||||
"""Connect to a Streamable HTTP MCP server.
|
||||
|
||||
Falls back to SSE if streamable HTTP fails.
|
||||
"""
|
||||
url = config.get("url", "")
|
||||
headers = config.get("headers")
|
||||
|
||||
# Try streamable HTTP first, fall back to SSE
|
||||
try:
|
||||
return await self._connect_http_streamable(server_name, url, headers)
|
||||
except Exception as e:
|
||||
logger.info(f"Streamable HTTP failed for {server_name}, trying SSE: {e}")
|
||||
return await self._connect_sse(server_name, config)
|
||||
|
||||
async def _connect_http_streamable(
|
||||
self, server_name: str, url: str, headers: dict | None,
|
||||
) -> MCPConnection:
|
||||
"""Connect via Streamable HTTP (JSON-RPC POST)."""
|
||||
import httpx
|
||||
from mcp import ClientSession
|
||||
|
||||
# Use httpx for streamable HTTP — keep client alive in the exit stack
|
||||
client = await self._exit_stack.enter_async_context(
|
||||
httpx.AsyncClient(timeout=30.0)
|
||||
)
|
||||
|
||||
h = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
**(headers or {}),
|
||||
}
|
||||
|
||||
# Initialize
|
||||
init_resp = await client.post(url, headers=h, json={
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "self-swarm", "version": "0.1.0"},
|
||||
},
|
||||
})
|
||||
if init_resp.status_code not in (200, 201):
|
||||
raise ConnectionError(f"MCP initialize failed: {init_resp.status_code}")
|
||||
|
||||
session_id = init_resp.headers.get("mcp-session-id", "")
|
||||
if session_id:
|
||||
h["mcp-session-id"] = session_id
|
||||
|
||||
# Notify initialized
|
||||
await client.post(url, headers=h, json={
|
||||
"jsonrpc": "2.0", "method": "notifications/initialized",
|
||||
})
|
||||
|
||||
# List tools
|
||||
list_resp = await client.post(url, headers=h, json={
|
||||
"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {},
|
||||
})
|
||||
if list_resp.status_code not in (200, 201):
|
||||
raise ConnectionError(f"MCP tools/list failed: {list_resp.status_code}")
|
||||
|
||||
ct = list_resp.headers.get("content-type", "")
|
||||
if "text/event-stream" in ct:
|
||||
data = self._parse_sse_json(list_resp.text)
|
||||
else:
|
||||
data = list_resp.json()
|
||||
|
||||
if not data:
|
||||
raise ConnectionError("Empty response from MCP server")
|
||||
|
||||
tools_list = data.get("result", {}).get("tools", [])
|
||||
tools = [
|
||||
ToolSchema(
|
||||
name=f"mcp__{server_name}__{t.get('name', '')}",
|
||||
description=t.get("description", ""),
|
||||
input_schema=t.get("inputSchema", t.get("input_schema", {})),
|
||||
)
|
||||
for t in tools_list
|
||||
]
|
||||
|
||||
# Store the HTTP client info for call_tool
|
||||
conn = MCPConnection(server_name=server_name, session=None, tools=tools)
|
||||
conn._http_client = client # type: ignore[attr-defined]
|
||||
conn._http_url = url # type: ignore[attr-defined]
|
||||
conn._http_headers = h # type: ignore[attr-defined]
|
||||
conn._next_id = 3 # type: ignore[attr-defined]
|
||||
return conn
|
||||
|
||||
_parse_sse_json = staticmethod(_parse_sse_json)
|
||||
|
||||
async def call_tool(
|
||||
self, server_name: str, tool_name: str, arguments: dict,
|
||||
) -> list[dict]:
|
||||
"""Call a tool on a specific MCP server.
|
||||
|
||||
Args:
|
||||
server_name: The MCP server name (e.g. "google-workspace")
|
||||
tool_name: The bare tool name (without mcp__prefix)
|
||||
arguments: Tool input arguments
|
||||
|
||||
Returns:
|
||||
List of content blocks: [{"type": "text", "text": "..."}]
|
||||
"""
|
||||
conn = self._connections.get(server_name)
|
||||
if not conn:
|
||||
return [{"type": "text", "text": f"MCP server {server_name} not connected"}]
|
||||
|
||||
try:
|
||||
if conn.session is not None:
|
||||
# stdio or SSE — use MCP ClientSession
|
||||
result = await conn.session.call_tool(tool_name, arguments)
|
||||
return self._format_mcp_result(result)
|
||||
elif hasattr(conn, "_http_client"):
|
||||
# Streamable HTTP — use JSON-RPC
|
||||
return await self._call_tool_http(conn, tool_name, arguments)
|
||||
else:
|
||||
return [{"type": "text", "text": f"No session for MCP server {server_name}"}]
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"MCP tool call failed: {server_name}/{tool_name}: {e}")
|
||||
return [{"type": "text", "text": f"Error calling {tool_name}: {e}"}]
|
||||
|
||||
async def _call_tool_http(
|
||||
self, conn: MCPConnection, tool_name: str, arguments: dict,
|
||||
) -> list[dict]:
|
||||
"""Call a tool via Streamable HTTP."""
|
||||
client = conn._http_client # type: ignore[attr-defined]
|
||||
url = conn._http_url # type: ignore[attr-defined]
|
||||
headers = conn._http_headers # type: ignore[attr-defined]
|
||||
req_id = conn._next_id # type: ignore[attr-defined]
|
||||
conn._next_id = req_id + 1 # type: ignore[attr-defined]
|
||||
|
||||
resp = await client.post(url, headers=headers, json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"method": "tools/call",
|
||||
"params": {"name": tool_name, "arguments": arguments},
|
||||
}, timeout=300.0)
|
||||
|
||||
ct = resp.headers.get("content-type", "")
|
||||
if "text/event-stream" in ct:
|
||||
data = self._parse_sse_json(resp.text)
|
||||
else:
|
||||
data = resp.json()
|
||||
|
||||
if not data:
|
||||
return [{"type": "text", "text": "Empty response from MCP server"}]
|
||||
|
||||
if "error" in data:
|
||||
return [{"type": "text", "text": f"MCP error: {data['error']}"}]
|
||||
|
||||
result = data.get("result", {})
|
||||
content = result.get("content", [])
|
||||
return content if content else [{"type": "text", "text": json.dumps(result)}]
|
||||
|
||||
@staticmethod
|
||||
def _format_mcp_result(result: Any) -> list[dict]:
|
||||
"""Convert an MCP CallToolResult to content blocks."""
|
||||
if hasattr(result, "content"):
|
||||
blocks = []
|
||||
for item in result.content:
|
||||
if hasattr(item, "text"):
|
||||
blocks.append({"type": "text", "text": item.text})
|
||||
elif hasattr(item, "data"):
|
||||
blocks.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": getattr(item, "mimeType", "image/png"),
|
||||
"data": item.data,
|
||||
},
|
||||
})
|
||||
else:
|
||||
blocks.append({"type": "text", "text": str(item)})
|
||||
return blocks if blocks else [{"type": "text", "text": "Done."}]
|
||||
|
||||
return [{"type": "text", "text": str(result)}]
|
||||
|
||||
def get_all_tool_schemas(self) -> list[ToolSchema]:
|
||||
"""Return tool schemas from all connected MCP servers."""
|
||||
schemas = []
|
||||
for conn in self._connections.values():
|
||||
schemas.extend(conn.tools)
|
||||
return schemas
|
||||
|
||||
def parse_mcp_tool_name(self, full_name: str) -> tuple[str, str] | None:
|
||||
"""Parse mcp__<server>__<tool> into (server_name, tool_name).
|
||||
|
||||
Returns None if the name doesn't match the MCP naming convention.
|
||||
"""
|
||||
import re
|
||||
m = re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", full_name)
|
||||
if m:
|
||||
return m.group(1), m.group(2)
|
||||
return None
|
||||
|
||||
async def disconnect_all(self):
|
||||
"""Disconnect all MCP servers. Called on session end."""
|
||||
self._connections.clear()
|
||||
# The AsyncExitStack handles actual cleanup of transports/sessions
|
||||
@@ -1,260 +0,0 @@
|
||||
"""Anthropic provider adapter using the native Anthropic SDK."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import anthropic
|
||||
|
||||
from backend.apps.agents.providers.base import (
|
||||
BaseProvider, ContentBlock, ModelResponse, ProviderMessage,
|
||||
StreamEvent, ToolCall, ToolSchema,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MODEL_MAP = {
|
||||
"sonnet": "claude-sonnet-4-6",
|
||||
"opus": "claude-opus-4-6",
|
||||
"haiku": "claude-haiku-4-5",
|
||||
}
|
||||
|
||||
|
||||
class AnthropicProvider(BaseProvider):
|
||||
"""Provider adapter for Anthropic's Messages API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
auth_token: str | None = None,
|
||||
base_url: str | None = None,
|
||||
):
|
||||
kwargs: dict[str, Any] = {}
|
||||
if auth_token:
|
||||
kwargs["auth_token"] = auth_token
|
||||
elif api_key:
|
||||
kwargs["api_key"] = api_key
|
||||
if base_url:
|
||||
kwargs["base_url"] = base_url
|
||||
self.client = anthropic.AsyncAnthropic(**kwargs)
|
||||
|
||||
def get_model_id(self, short_name: str) -> str:
|
||||
return MODEL_MAP.get(short_name, short_name)
|
||||
|
||||
def clean_tool_schema(self, schema: ToolSchema) -> dict:
|
||||
return {
|
||||
"name": schema.name,
|
||||
"description": schema.description,
|
||||
"input_schema": schema.input_schema,
|
||||
}
|
||||
|
||||
def format_tool_result(self, tool_use_id: str, content: list[dict]) -> dict:
|
||||
return {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use_id,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
def format_user_message(self, content: Any) -> ProviderMessage:
|
||||
return ProviderMessage(role="user", content=content)
|
||||
|
||||
def format_assistant_message(self, response: ModelResponse) -> ProviderMessage:
|
||||
blocks = []
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
blocks.append({"type": "text", "text": block.text})
|
||||
elif block.type == "tool_use" and block.tool_call:
|
||||
blocks.append({
|
||||
"type": "tool_use",
|
||||
"id": block.tool_call.id,
|
||||
"name": block.tool_call.name,
|
||||
"input": block.tool_call.input,
|
||||
})
|
||||
return ProviderMessage(role="assistant", content=blocks)
|
||||
|
||||
def _build_messages(self, messages: list[ProviderMessage]) -> list[dict]:
|
||||
"""Convert ProviderMessages to Anthropic API format."""
|
||||
result = []
|
||||
for msg in messages:
|
||||
if msg.role == "tool_result":
|
||||
# Tool results: content is a list of tool_result dicts
|
||||
if isinstance(msg.content, list):
|
||||
result.append({"role": "user", "content": msg.content})
|
||||
else:
|
||||
result.append({"role": "user", "content": [msg.content]})
|
||||
elif msg.role == "assistant":
|
||||
result.append({"role": "assistant", "content": msg.content})
|
||||
elif msg.role == "user":
|
||||
result.append({"role": "user", "content": msg.content})
|
||||
return result
|
||||
|
||||
async def create_message(
|
||||
self,
|
||||
model: str,
|
||||
system: str | None,
|
||||
messages: list[ProviderMessage],
|
||||
tools: list[ToolSchema],
|
||||
max_tokens: int = 8192,
|
||||
) -> ModelResponse:
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": self.get_model_id(model),
|
||||
"max_tokens": max_tokens,
|
||||
"messages": self._build_messages(messages),
|
||||
}
|
||||
if system:
|
||||
kwargs["system"] = system
|
||||
if tools:
|
||||
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
|
||||
|
||||
resp = await self.client.messages.create(**kwargs)
|
||||
|
||||
content = []
|
||||
for block in resp.content:
|
||||
if block.type == "text":
|
||||
content.append(ContentBlock(type="text", text=block.text))
|
||||
elif block.type == "tool_use":
|
||||
content.append(ContentBlock(
|
||||
type="tool_use",
|
||||
tool_call=ToolCall(
|
||||
id=block.id,
|
||||
name=block.name,
|
||||
input=block.input,
|
||||
),
|
||||
))
|
||||
|
||||
return ModelResponse(
|
||||
content=content,
|
||||
stop_reason="tool_use" if resp.stop_reason == "tool_use" else "end_turn",
|
||||
usage={
|
||||
"input_tokens": resp.usage.input_tokens,
|
||||
"output_tokens": resp.usage.output_tokens,
|
||||
},
|
||||
)
|
||||
|
||||
async def stream_message(
|
||||
self,
|
||||
model: str,
|
||||
system: str | None,
|
||||
messages: list[ProviderMessage],
|
||||
tools: list[ToolSchema],
|
||||
max_tokens: int = 8192,
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": self.get_model_id(model),
|
||||
"max_tokens": max_tokens,
|
||||
"messages": self._build_messages(messages),
|
||||
}
|
||||
if system:
|
||||
kwargs["system"] = system
|
||||
if tools:
|
||||
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
|
||||
|
||||
# Use create() with stream=True for raw SSE events
|
||||
kwargs["stream"] = True
|
||||
raw_stream = await self.client.messages.create(**kwargs)
|
||||
|
||||
current_block_type: dict[int, str] = {}
|
||||
current_tool_name: dict[int, str] = {}
|
||||
current_tool_id: dict[int, str] = {}
|
||||
current_text: dict[int, str] = {}
|
||||
current_json: dict[int, str] = {}
|
||||
|
||||
async for event in raw_stream:
|
||||
event_type = getattr(event, "type", "")
|
||||
|
||||
if event_type == "content_block_start":
|
||||
index = event.index
|
||||
block = event.content_block
|
||||
block_type = block.type
|
||||
current_block_type[index] = block_type
|
||||
|
||||
if block_type == "text":
|
||||
current_text[index] = ""
|
||||
yield StreamEvent(
|
||||
type="content_block_start",
|
||||
index=index,
|
||||
block_type="text",
|
||||
)
|
||||
elif block_type == "tool_use":
|
||||
current_tool_name[index] = block.name
|
||||
current_tool_id[index] = block.id
|
||||
current_json[index] = ""
|
||||
yield StreamEvent(
|
||||
type="content_block_start",
|
||||
index=index,
|
||||
block_type="tool_use",
|
||||
tool_name=block.name,
|
||||
tool_id=block.id,
|
||||
)
|
||||
|
||||
elif event_type == "content_block_delta":
|
||||
index = event.index
|
||||
delta = event.delta
|
||||
delta_type = delta.type
|
||||
|
||||
if delta_type == "text_delta":
|
||||
current_text.setdefault(index, "")
|
||||
current_text[index] += delta.text
|
||||
yield StreamEvent(
|
||||
type="content_block_delta",
|
||||
index=index,
|
||||
delta_type="text_delta",
|
||||
text=delta.text,
|
||||
)
|
||||
elif delta_type == "input_json_delta":
|
||||
current_json.setdefault(index, "")
|
||||
current_json[index] += delta.partial_json
|
||||
yield StreamEvent(
|
||||
type="content_block_delta",
|
||||
index=index,
|
||||
delta_type="input_json_delta",
|
||||
text=delta.partial_json,
|
||||
)
|
||||
|
||||
elif event_type == "content_block_stop":
|
||||
yield StreamEvent(type="content_block_stop", index=event.index)
|
||||
|
||||
elif event_type == "message_delta":
|
||||
# Extract output token usage from the final delta
|
||||
usage_data = {}
|
||||
delta_usage = getattr(event, "usage", None)
|
||||
if delta_usage:
|
||||
output_tokens = getattr(delta_usage, "output_tokens", 0)
|
||||
if output_tokens:
|
||||
usage_data["output_tokens"] = output_tokens
|
||||
if usage_data:
|
||||
yield StreamEvent(type="usage", usage=usage_data)
|
||||
|
||||
elif event_type == "message_start":
|
||||
# Extract input token usage from the message start
|
||||
msg = getattr(event, "message", None)
|
||||
if msg:
|
||||
msg_usage = getattr(msg, "usage", None)
|
||||
if msg_usage:
|
||||
usage_data = {}
|
||||
input_tokens = getattr(msg_usage, "input_tokens", 0)
|
||||
output_tokens = getattr(msg_usage, "output_tokens", 0)
|
||||
if input_tokens:
|
||||
usage_data["input_tokens"] = input_tokens
|
||||
if output_tokens:
|
||||
usage_data["output_tokens"] = output_tokens
|
||||
if usage_data:
|
||||
yield StreamEvent(type="usage", usage=usage_data)
|
||||
|
||||
yield StreamEvent(type="message_stop")
|
||||
|
||||
async def stream_and_collect(
|
||||
self,
|
||||
model: str,
|
||||
system: str | None,
|
||||
messages: list[ProviderMessage],
|
||||
tools: list[ToolSchema],
|
||||
max_tokens: int = 8192,
|
||||
) -> tuple[AsyncIterator[StreamEvent], ModelResponse]:
|
||||
"""Helper: stream events and also return the full collected response.
|
||||
|
||||
Not used directly — the AgentLoop handles collection.
|
||||
"""
|
||||
raise NotImplementedError("Use stream_message() directly; AgentLoop collects.")
|
||||
@@ -1,135 +0,0 @@
|
||||
"""Provider-agnostic base classes for multi-model support.
|
||||
|
||||
All provider adapters (Anthropic, OpenAI, Gemini, OpenAI-compatible)
|
||||
implement BaseProvider, translating their native APIs into these
|
||||
common data structures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolSchema:
|
||||
"""Provider-agnostic tool definition."""
|
||||
name: str
|
||||
description: str
|
||||
input_schema: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCall:
|
||||
"""A tool invocation requested by the model."""
|
||||
id: str
|
||||
name: str
|
||||
input: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContentBlock:
|
||||
"""A block of content from the model response."""
|
||||
type: str # "text" | "tool_use"
|
||||
text: str = ""
|
||||
tool_call: ToolCall | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelResponse:
|
||||
"""Complete (non-streaming) response from a provider."""
|
||||
content: list[ContentBlock]
|
||||
stop_reason: str # "end_turn" | "tool_use" | "max_tokens"
|
||||
usage: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamEvent:
|
||||
"""A single streaming event, normalized across providers.
|
||||
|
||||
The event types match what the frontend already expects via WebSocket:
|
||||
content_block_start, content_block_delta, content_block_stop, message_stop.
|
||||
"""
|
||||
type: str
|
||||
index: int = 0
|
||||
block_type: str = "" # "text" | "tool_use"
|
||||
delta_type: str = "" # "text_delta" | "input_json_delta"
|
||||
text: str = ""
|
||||
tool_name: str = ""
|
||||
tool_id: str = ""
|
||||
usage: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderMessage:
|
||||
"""Provider-agnostic message for conversation history.
|
||||
|
||||
Each provider adapter converts these to/from its native format.
|
||||
"""
|
||||
role: str # "user" | "assistant" | "tool_result"
|
||||
content: Any # str, list[dict], or provider-specific content
|
||||
|
||||
|
||||
class BaseProvider(ABC):
|
||||
"""Abstract base for LLM provider adapters."""
|
||||
|
||||
@abstractmethod
|
||||
async def stream_message(
|
||||
self,
|
||||
model: str,
|
||||
system: str | None,
|
||||
messages: list[ProviderMessage],
|
||||
tools: list[ToolSchema],
|
||||
max_tokens: int = 8192,
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""Stream a model response, yielding normalized StreamEvents."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def create_message(
|
||||
self,
|
||||
model: str,
|
||||
system: str | None,
|
||||
messages: list[ProviderMessage],
|
||||
tools: list[ToolSchema],
|
||||
max_tokens: int = 8192,
|
||||
) -> ModelResponse:
|
||||
"""Non-streaming message creation."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def format_tool_result(
|
||||
self,
|
||||
tool_use_id: str,
|
||||
content: list[dict],
|
||||
) -> dict:
|
||||
"""Format a tool result in this provider's expected message format."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def format_user_message(self, content: Any) -> ProviderMessage:
|
||||
"""Wrap user content (str or multimodal blocks) into a ProviderMessage."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def format_assistant_message(self, response: ModelResponse) -> ProviderMessage:
|
||||
"""Convert a ModelResponse into a ProviderMessage for conversation history."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_model_id(self, short_name: str) -> str:
|
||||
"""Resolve a short model name to the full API model ID."""
|
||||
...
|
||||
|
||||
def clean_tool_schema(self, schema: ToolSchema) -> dict:
|
||||
"""Convert a ToolSchema to the provider's native tool format.
|
||||
|
||||
Default: Anthropic-style format. Override for providers that need
|
||||
different formats or schema cleaning (e.g. Gemini).
|
||||
"""
|
||||
return {
|
||||
"name": schema.name,
|
||||
"description": schema.description,
|
||||
"input_schema": schema.input_schema,
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
"""OpenAI-compatible provider adapter.
|
||||
|
||||
Works with ANY endpoint that speaks the OpenAI Chat Completions API:
|
||||
OpenAI, OpenRouter, Together, Groq, Fireworks, Mistral, Ollama, vLLM, etc.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncIterator
|
||||
from uuid import uuid4
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from backend.apps.agents.providers.base import (
|
||||
BaseProvider, ContentBlock, ModelResponse, ProviderMessage,
|
||||
StreamEvent, ToolCall, ToolSchema,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OpenAICompatProvider(BaseProvider):
|
||||
"""Provider adapter for any OpenAI-compatible API endpoint."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str = "",
|
||||
base_url: str | None = None,
|
||||
):
|
||||
kwargs: dict[str, Any] = {}
|
||||
# Always set api_key — use "none" as placeholder if empty (some endpoints don't need real keys)
|
||||
kwargs["api_key"] = api_key if api_key else "none"
|
||||
if base_url:
|
||||
kwargs["base_url"] = base_url
|
||||
self.client = AsyncOpenAI(**kwargs)
|
||||
|
||||
def get_model_id(self, short_name: str) -> str:
|
||||
# Pass through — user selects exact model ID
|
||||
return short_name
|
||||
|
||||
def clean_tool_schema(self, schema: ToolSchema) -> dict:
|
||||
"""Convert to OpenAI function calling format."""
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": schema.name,
|
||||
"description": schema.description,
|
||||
"parameters": schema.input_schema,
|
||||
},
|
||||
}
|
||||
|
||||
def format_tool_result(self, tool_use_id: str, content: list[dict]) -> dict:
|
||||
"""Format tool result as OpenAI expects."""
|
||||
# OpenAI wants a single string for tool results
|
||||
text_parts = []
|
||||
for block in content:
|
||||
if block.get("type") == "text":
|
||||
text_parts.append(block.get("text", ""))
|
||||
elif block.get("type") == "image":
|
||||
text_parts.append("[image]")
|
||||
else:
|
||||
text_parts.append(json.dumps(block))
|
||||
return {
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_use_id,
|
||||
"content": "\n".join(text_parts) if text_parts else "Done.",
|
||||
}
|
||||
|
||||
def format_user_message(self, content: Any) -> ProviderMessage:
|
||||
"""Convert user content to OpenAI format."""
|
||||
if isinstance(content, str):
|
||||
return ProviderMessage(role="user", content=content)
|
||||
# Multimodal content (text + images)
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
if block.get("type") == "text":
|
||||
parts.append({"type": "text", "text": block["text"]})
|
||||
elif block.get("type") == "image":
|
||||
source = block.get("source", {})
|
||||
media_type = source.get("media_type", "image/png")
|
||||
data = source.get("data", "")
|
||||
parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{media_type};base64,{data}"},
|
||||
})
|
||||
elif isinstance(block, str):
|
||||
parts.append({"type": "text", "text": block})
|
||||
return ProviderMessage(role="user", content=parts)
|
||||
return ProviderMessage(role="user", content=str(content))
|
||||
|
||||
def format_assistant_message(self, response: ModelResponse) -> ProviderMessage:
|
||||
"""Convert ModelResponse to OpenAI assistant message format."""
|
||||
text_parts = []
|
||||
tool_calls = []
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
text_parts.append(block.text)
|
||||
elif block.type == "tool_use" and block.tool_call:
|
||||
tool_calls.append({
|
||||
"id": block.tool_call.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": block.tool_call.name,
|
||||
"arguments": json.dumps(block.tool_call.input),
|
||||
},
|
||||
})
|
||||
msg: dict[str, Any] = {"role": "assistant"}
|
||||
if text_parts:
|
||||
msg["content"] = "\n".join(text_parts)
|
||||
else:
|
||||
msg["content"] = None
|
||||
if tool_calls:
|
||||
msg["tool_calls"] = tool_calls
|
||||
return ProviderMessage(role="assistant", content=msg)
|
||||
|
||||
def _build_messages(
|
||||
self,
|
||||
system: str | None,
|
||||
messages: list[ProviderMessage],
|
||||
) -> list[dict]:
|
||||
"""Convert ProviderMessages to OpenAI API format."""
|
||||
result = []
|
||||
if system:
|
||||
result.append({"role": "system", "content": system})
|
||||
|
||||
for msg in messages:
|
||||
if msg.role == "assistant":
|
||||
# Assistant messages are already in OpenAI format from format_assistant_message
|
||||
if isinstance(msg.content, dict) and "role" in msg.content:
|
||||
result.append(msg.content)
|
||||
else:
|
||||
# Raw content blocks from provider-agnostic format
|
||||
text_parts = []
|
||||
tool_calls = []
|
||||
if isinstance(msg.content, list):
|
||||
for block in msg.content:
|
||||
if isinstance(block, dict):
|
||||
if block.get("type") == "text":
|
||||
text_parts.append(block["text"])
|
||||
elif block.get("type") == "tool_use":
|
||||
tool_calls.append({
|
||||
"id": block.get("id", uuid4().hex),
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": block.get("name", ""),
|
||||
"arguments": json.dumps(block.get("input", {})),
|
||||
},
|
||||
})
|
||||
api_msg: dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": "\n".join(text_parts) if text_parts else None,
|
||||
}
|
||||
if tool_calls:
|
||||
api_msg["tool_calls"] = tool_calls
|
||||
result.append(api_msg)
|
||||
|
||||
elif msg.role == "tool_result":
|
||||
# Tool results: content is a list of tool result dicts
|
||||
if isinstance(msg.content, list):
|
||||
for tr in msg.content:
|
||||
if isinstance(tr, dict) and "tool_call_id" in tr:
|
||||
result.append(tr)
|
||||
elif isinstance(msg.content, dict) and "tool_call_id" in msg.content:
|
||||
result.append(msg.content)
|
||||
|
||||
elif msg.role == "user":
|
||||
result.append({"role": "user", "content": msg.content})
|
||||
|
||||
return result
|
||||
|
||||
async def create_message(
|
||||
self,
|
||||
model: str,
|
||||
system: str | None,
|
||||
messages: list[ProviderMessage],
|
||||
tools: list[ToolSchema],
|
||||
max_tokens: int = 8192,
|
||||
) -> ModelResponse:
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": self.get_model_id(model),
|
||||
"max_tokens": max_tokens,
|
||||
"messages": self._build_messages(system, messages),
|
||||
}
|
||||
if tools:
|
||||
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
|
||||
|
||||
resp = await self.client.chat.completions.create(**kwargs)
|
||||
choice = resp.choices[0]
|
||||
message = choice.message
|
||||
|
||||
content: list[ContentBlock] = []
|
||||
if message.content:
|
||||
content.append(ContentBlock(type="text", text=message.content))
|
||||
|
||||
if message.tool_calls:
|
||||
for tc in message.tool_calls:
|
||||
try:
|
||||
args = json.loads(tc.function.arguments)
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
content.append(ContentBlock(
|
||||
type="tool_use",
|
||||
tool_call=ToolCall(
|
||||
id=tc.id,
|
||||
name=tc.function.name,
|
||||
input=args,
|
||||
),
|
||||
))
|
||||
|
||||
stop = "end_turn"
|
||||
if choice.finish_reason == "tool_calls":
|
||||
stop = "tool_use"
|
||||
elif message.tool_calls:
|
||||
stop = "tool_use"
|
||||
|
||||
usage_dict = {}
|
||||
if resp.usage:
|
||||
usage_dict = {
|
||||
"input_tokens": resp.usage.prompt_tokens,
|
||||
"output_tokens": resp.usage.completion_tokens,
|
||||
}
|
||||
|
||||
return ModelResponse(content=content, stop_reason=stop, usage=usage_dict)
|
||||
|
||||
async def stream_message(
|
||||
self,
|
||||
model: str,
|
||||
system: str | None,
|
||||
messages: list[ProviderMessage],
|
||||
tools: list[ToolSchema],
|
||||
max_tokens: int = 8192,
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": self.get_model_id(model),
|
||||
"max_tokens": max_tokens,
|
||||
"messages": self._build_messages(system, messages),
|
||||
"stream": True,
|
||||
"stream_options": {"include_usage": True},
|
||||
}
|
||||
if tools:
|
||||
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
|
||||
|
||||
stream = await self.client.chat.completions.create(**kwargs)
|
||||
|
||||
# Track streaming state to emit normalized events
|
||||
text_started = False
|
||||
text_index = 0
|
||||
tool_indices: dict[int, dict] = {} # openai tool_call index -> {name, id, json_buf}
|
||||
next_block_index = 0
|
||||
|
||||
async for chunk in stream:
|
||||
if not chunk.choices:
|
||||
# Usage-only chunk at the end
|
||||
if chunk.usage:
|
||||
yield StreamEvent(type="usage", usage={
|
||||
"input_tokens": chunk.usage.prompt_tokens or 0,
|
||||
"output_tokens": chunk.usage.completion_tokens or 0,
|
||||
})
|
||||
continue
|
||||
|
||||
delta = chunk.choices[0].delta
|
||||
finish_reason = chunk.choices[0].finish_reason
|
||||
|
||||
# Text content
|
||||
if delta.content is not None:
|
||||
if not text_started:
|
||||
text_started = True
|
||||
text_index = next_block_index
|
||||
next_block_index += 1
|
||||
yield StreamEvent(
|
||||
type="content_block_start",
|
||||
index=text_index,
|
||||
block_type="text",
|
||||
)
|
||||
yield StreamEvent(
|
||||
type="content_block_delta",
|
||||
index=text_index,
|
||||
delta_type="text_delta",
|
||||
text=delta.content,
|
||||
)
|
||||
|
||||
# Tool calls
|
||||
if delta.tool_calls:
|
||||
for tc_delta in delta.tool_calls:
|
||||
tc_idx = tc_delta.index
|
||||
if tc_idx not in tool_indices:
|
||||
# New tool call starting
|
||||
if text_started:
|
||||
yield StreamEvent(type="content_block_stop", index=text_index)
|
||||
text_started = False
|
||||
|
||||
block_idx = next_block_index
|
||||
next_block_index += 1
|
||||
tool_indices[tc_idx] = {
|
||||
"block_index": block_idx,
|
||||
"id": tc_delta.id or uuid4().hex,
|
||||
"name": tc_delta.function.name if tc_delta.function else "",
|
||||
"json_buf": "",
|
||||
}
|
||||
yield StreamEvent(
|
||||
type="content_block_start",
|
||||
index=block_idx,
|
||||
block_type="tool_use",
|
||||
tool_name=tool_indices[tc_idx]["name"],
|
||||
tool_id=tool_indices[tc_idx]["id"],
|
||||
)
|
||||
|
||||
info = tool_indices[tc_idx]
|
||||
if tc_delta.function and tc_delta.function.name:
|
||||
info["name"] = tc_delta.function.name
|
||||
if tc_delta.function and tc_delta.function.arguments:
|
||||
info["json_buf"] += tc_delta.function.arguments
|
||||
yield StreamEvent(
|
||||
type="content_block_delta",
|
||||
index=info["block_index"],
|
||||
delta_type="input_json_delta",
|
||||
text=tc_delta.function.arguments,
|
||||
)
|
||||
|
||||
# Finish
|
||||
if finish_reason is not None:
|
||||
if text_started:
|
||||
yield StreamEvent(type="content_block_stop", index=text_index)
|
||||
for info in tool_indices.values():
|
||||
yield StreamEvent(type="content_block_stop", index=info["block_index"])
|
||||
yield StreamEvent(type="message_stop")
|
||||
@@ -1,277 +0,0 @@
|
||||
"""Provider factory and model registry.
|
||||
|
||||
Two-tier system:
|
||||
1. Built-in providers (Anthropic, OpenAI, Gemini) with curated model lists
|
||||
2. User-configured custom providers (any OpenAI-compatible endpoint)
|
||||
- Includes built-in OpenRouter integration for 300+ models
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from backend.apps.agents.providers.base import BaseProvider
|
||||
from backend.apps.common.model_registry import (
|
||||
get_builtin_models_by_provider,
|
||||
get_context_window as _registry_get_context_window,
|
||||
calculate_cost as _registry_calculate_cost,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.apps.settings.models import AppSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BUILTIN_MODELS = get_builtin_models_by_provider()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenRouter: built-in integration for 300+ models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
|
||||
_9router_cache: dict = {"available": None, "checked_at": 0}
|
||||
|
||||
|
||||
def _is_9router_available() -> bool:
|
||||
"""Check if 9Router is running on localhost:20128. Caches for 30 seconds."""
|
||||
import time as _time
|
||||
now = _time.time()
|
||||
if _9router_cache["available"] is not None and now - _9router_cache["checked_at"] < 30:
|
||||
return _9router_cache["available"]
|
||||
try:
|
||||
import httpx
|
||||
r = httpx.get("http://localhost:20128/v1/models", timeout=2.0)
|
||||
available = r.status_code == 200
|
||||
except Exception:
|
||||
available = False
|
||||
_9router_cache["available"] = available
|
||||
_9router_cache["checked_at"] = now
|
||||
return available
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_provider(
|
||||
provider_name: str,
|
||||
settings: AppSettings,
|
||||
provider_config: dict | None = None,
|
||||
) -> BaseProvider:
|
||||
"""Create a provider adapter.
|
||||
|
||||
Routes based on the 'api' field in BUILTIN_MODELS:
|
||||
- "anthropic" → native Anthropic SDK
|
||||
- "openai" → native OpenAI SDK (direct API)
|
||||
- "gemini" → native Google GenAI SDK
|
||||
- "openrouter" → OpenAI-compat via openrouter.ai (Meta, Mistral, DeepSeek, Qwen, xAI, etc.)
|
||||
Custom providers use OpenAI-compat with user's base_url.
|
||||
"""
|
||||
api_type = _get_api_type(provider_name)
|
||||
|
||||
# Check for 9Router first
|
||||
if provider_name in ("9Router", "9router"):
|
||||
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
|
||||
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
|
||||
|
||||
# Check for GitHub Copilot
|
||||
if provider_name in ("GitHub Copilot", "copilot"):
|
||||
from backend.apps.agents.providers.copilot import CopilotProvider
|
||||
copilot_token = getattr(settings, "copilot_token", None)
|
||||
if not copilot_token:
|
||||
raise ValueError("GitHub Copilot not connected. Sign in via Settings → Models.")
|
||||
# Auto-refresh if expired
|
||||
import time as _time
|
||||
expires = getattr(settings, "copilot_token_expires", None)
|
||||
if expires and _time.time() > expires - 120:
|
||||
github_token = getattr(settings, "copilot_github_token", None)
|
||||
if github_token:
|
||||
import asyncio
|
||||
from backend.apps.agents.copilot_auth import exchange_for_copilot_token
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
result = loop.run_until_complete(exchange_for_copilot_token(github_token))
|
||||
copilot_token = result["token"]
|
||||
settings.copilot_token = copilot_token
|
||||
settings.copilot_token_expires = result["expires_at"]
|
||||
from backend.apps.settings.settings import _save_settings
|
||||
_save_settings(settings)
|
||||
except Exception as e:
|
||||
logger.warning(f"Copilot token refresh failed: {e}")
|
||||
return CopilotProvider(copilot_token=copilot_token)
|
||||
|
||||
if api_type == "anthropic":
|
||||
from backend.apps.agents.providers.anthropic import AnthropicProvider
|
||||
if getattr(settings, "connection_mode", "own_key") == "managed":
|
||||
return AnthropicProvider(
|
||||
auth_token=getattr(settings, "openswarm_auth_token", None),
|
||||
base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.ai",
|
||||
)
|
||||
# Priority: API key → 9Router subscription
|
||||
if settings.anthropic_api_key:
|
||||
return AnthropicProvider(api_key=settings.anthropic_api_key)
|
||||
# No API key — try 9Router as fallback
|
||||
if _is_9router_available():
|
||||
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
|
||||
provider = OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
|
||||
# Override get_model_id to map our short names to 9Router's cc/ prefixed IDs
|
||||
_original_get_model = provider.get_model_id
|
||||
_9r_model_map = {
|
||||
"sonnet": "cc/claude-sonnet-4-6",
|
||||
"opus": "cc/claude-opus-4-6",
|
||||
"haiku": "cc/claude-haiku-4-5-20251001",
|
||||
}
|
||||
provider.get_model_id = lambda name: _9r_model_map.get(name, f"cc/{name}" if not name.startswith("cc/") else name)
|
||||
return provider
|
||||
raise ValueError("Anthropic API key not configured. Set it in Settings, or connect 9Router.")
|
||||
|
||||
if api_type == "openai":
|
||||
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
|
||||
if settings.openai_api_key:
|
||||
return OpenAICompatProvider(api_key=settings.openai_api_key, base_url="https://api.openai.com/v1")
|
||||
# No API key — try 9Router as fallback
|
||||
if _is_9router_available():
|
||||
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
|
||||
raise ValueError("OpenAI API key not configured. Set it in Settings, or connect 9Router.")
|
||||
|
||||
if api_type == "gemini":
|
||||
from backend.apps.agents.providers.gemini import GeminiProvider
|
||||
if settings.google_api_key:
|
||||
return GeminiProvider(api_key=settings.google_api_key)
|
||||
# No API key — try 9Router as fallback
|
||||
if _is_9router_available():
|
||||
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
|
||||
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
|
||||
raise ValueError("Google API key not configured. Set it in Settings, or connect 9Router.")
|
||||
|
||||
if api_type == "openrouter":
|
||||
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
|
||||
openrouter_key = getattr(settings, "openrouter_api_key", None)
|
||||
if openrouter_key:
|
||||
return OpenAICompatProvider(api_key=openrouter_key, base_url=OPENROUTER_BASE_URL)
|
||||
# No OpenRouter key — try 9Router as fallback
|
||||
if _is_9router_available():
|
||||
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
|
||||
raise ValueError(f"OpenRouter API key not configured for {provider_name}. Set it in Settings, or connect a subscription.")
|
||||
|
||||
# Custom provider — look up in settings.custom_providers
|
||||
if provider_config:
|
||||
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
|
||||
return OpenAICompatProvider(
|
||||
api_key=provider_config.get("api_key", ""),
|
||||
base_url=provider_config.get("base_url", ""),
|
||||
)
|
||||
|
||||
for cp in getattr(settings, "custom_providers", []):
|
||||
if cp.name == provider_name:
|
||||
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
|
||||
return OpenAICompatProvider(
|
||||
api_key=cp.api_key,
|
||||
base_url=cp.base_url,
|
||||
)
|
||||
|
||||
raise ValueError(f"Unknown provider: {provider_name}")
|
||||
|
||||
|
||||
def _get_api_type(provider_name: str) -> str:
|
||||
"""Get the API type for a provider from BUILTIN_MODELS.
|
||||
|
||||
Accepts both display names ('Anthropic') and lowercase API names ('anthropic').
|
||||
"""
|
||||
# Direct lookup first (display name like 'Anthropic', 'OpenAI', etc.)
|
||||
models = BUILTIN_MODELS.get(provider_name, [])
|
||||
if models:
|
||||
return models[0].get("api", "openrouter")
|
||||
|
||||
# Lowercase API name mapping
|
||||
_API_NAME_MAP = {
|
||||
"anthropic": "anthropic",
|
||||
"openai": "openai",
|
||||
"gemini": "gemini",
|
||||
"google": "gemini",
|
||||
"openrouter": "openrouter",
|
||||
}
|
||||
if provider_name.lower() in _API_NAME_MAP:
|
||||
return _API_NAME_MAP[provider_name.lower()]
|
||||
|
||||
# Case-insensitive lookup into BUILTIN_MODELS
|
||||
lower = provider_name.lower()
|
||||
for key, models in BUILTIN_MODELS.items():
|
||||
if key.lower() == lower:
|
||||
return models[0].get("api", "openrouter")
|
||||
|
||||
return "openrouter"
|
||||
|
||||
|
||||
def _has_credentials(provider_name: str, settings: AppSettings) -> bool:
|
||||
"""Check if a provider has credentials configured."""
|
||||
api_type = _get_api_type(provider_name)
|
||||
|
||||
if api_type == "anthropic":
|
||||
if getattr(settings, "connection_mode", "own_key") == "managed":
|
||||
return bool(getattr(settings, "openswarm_auth_token", None))
|
||||
return bool(settings.anthropic_api_key)
|
||||
if api_type == "openai":
|
||||
return bool(settings.openai_api_key)
|
||||
if api_type == "gemini":
|
||||
return bool(getattr(settings, "google_api_key", None))
|
||||
if api_type == "openrouter":
|
||||
return bool(getattr(settings, "openrouter_api_key", None))
|
||||
return False
|
||||
|
||||
|
||||
def get_available_models(settings: AppSettings) -> dict[str, list[dict]]:
|
||||
"""Return all models — always show everything, mark which have keys configured.
|
||||
|
||||
Like Cursor: show all models upfront, prompt for key when user tries to use one.
|
||||
Returns: {"provider_name": [{"value": ..., "label": ..., "context_window": ..., "configured": bool}, ...]}
|
||||
"""
|
||||
result: dict[str, list[dict]] = {}
|
||||
|
||||
# Built-in providers — always show all
|
||||
for provider_name, models in BUILTIN_MODELS.items():
|
||||
configured = _has_credentials(provider_name, settings)
|
||||
result[provider_name] = [
|
||||
{**m, "configured": configured}
|
||||
for m in models
|
||||
]
|
||||
|
||||
# Custom providers
|
||||
for cp in getattr(settings, "custom_providers", []):
|
||||
if cp.models:
|
||||
result[cp.name] = [
|
||||
{
|
||||
"value": m.get("value", m.get("id", "")),
|
||||
"label": m.get("label", m.get("value", m.get("id", ""))),
|
||||
"context_window": m.get("context_window", 128_000),
|
||||
"configured": True,
|
||||
}
|
||||
for m in cp.models
|
||||
]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_context_window(provider: str, model: str, settings: AppSettings | None = None) -> int:
|
||||
"""Look up context window for any model."""
|
||||
result = _registry_get_context_window(model)
|
||||
if result != 128_000:
|
||||
return result
|
||||
|
||||
if settings:
|
||||
for cp in getattr(settings, "custom_providers", []):
|
||||
for m in cp.models:
|
||||
if m.get("value") == model or m.get("id") == model:
|
||||
return m.get("context_window", 128_000)
|
||||
|
||||
return 128_000
|
||||
|
||||
|
||||
def calculate_cost(
|
||||
provider: str, model: str,
|
||||
input_tokens: int, output_tokens: int,
|
||||
) -> float:
|
||||
"""Calculate cost in USD from token counts."""
|
||||
return _registry_calculate_cost(provider, model, input_tokens, output_tokens)
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Base classes for builtin tool implementations."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolContext:
|
||||
"""Runtime context passed to every tool execution."""
|
||||
cwd: str
|
||||
session_id: str
|
||||
|
||||
|
||||
class BaseTool(ABC):
|
||||
"""Abstract base for all builtin tools.
|
||||
|
||||
Subclasses must set ``name`` and ``description`` as class attributes and
|
||||
implement ``get_schema`` (JSON Schema for tool input) and ``execute``.
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
|
||||
@abstractmethod
|
||||
def get_schema(self) -> dict:
|
||||
"""Return JSON Schema for this tool's input parameters."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
||||
"""Execute the tool.
|
||||
|
||||
Returns a list of content blocks, e.g.
|
||||
``[{"type": "text", "text": "..."}]``.
|
||||
"""
|
||||
...
|
||||
|
||||
def to_tool_schema(self):
|
||||
"""Convert to the provider-agnostic ``ToolSchema`` used everywhere."""
|
||||
from backend.apps.agents.providers.base import ToolSchema
|
||||
|
||||
return ToolSchema(
|
||||
name=self.name,
|
||||
description=self.description,
|
||||
input_schema=self.get_schema(),
|
||||
)
|
||||
@@ -1,476 +0,0 @@
|
||||
"""Filesystem tools: Read, Write, Edit, Glob, Grep."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.apps.agents.tools.base import BaseTool, ToolContext
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
|
||||
_MAX_OUTPUT_BYTES = 50 * 1024 # ~50 KB cap for grep output
|
||||
|
||||
|
||||
def _resolve(file_path: str, cwd: str) -> Path:
|
||||
"""Resolve *file_path* against *cwd* when it is relative."""
|
||||
p = Path(file_path)
|
||||
if not p.is_absolute():
|
||||
p = Path(cwd) / p
|
||||
return p.resolve()
|
||||
|
||||
|
||||
def _text_block(text: str) -> list[dict]:
|
||||
return [{"type": "text", "text": text}]
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# ReadTool
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ReadTool(BaseTool):
|
||||
name = "Read"
|
||||
description = (
|
||||
"Read a file from the filesystem. Returns lines with line numbers "
|
||||
"(cat -n style). For image files returns base64 content. Supports "
|
||||
"offset and limit parameters for reading portions of large files."
|
||||
)
|
||||
|
||||
def get_schema(self) -> dict:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Absolute or relative path to the file to read.",
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "1-based line number to start reading from.",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of lines to return (default 2000).",
|
||||
},
|
||||
},
|
||||
"required": ["file_path"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
||||
file_path = _resolve(input_data["file_path"], context.cwd)
|
||||
|
||||
if not file_path.exists():
|
||||
return _text_block(f"Error: file not found: {file_path}")
|
||||
|
||||
if not file_path.is_file():
|
||||
return _text_block(f"Error: not a regular file: {file_path}")
|
||||
|
||||
# Binary / image files → base64
|
||||
ext = file_path.suffix.lower()
|
||||
if ext in _IMAGE_EXTENSIONS:
|
||||
try:
|
||||
raw = file_path.read_bytes()
|
||||
b64 = base64.b64encode(raw).decode("ascii")
|
||||
media = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
||||
return [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media,
|
||||
"data": b64,
|
||||
},
|
||||
}
|
||||
]
|
||||
except Exception as exc:
|
||||
return _text_block(f"Error reading image {file_path}: {exc}")
|
||||
|
||||
# Text files
|
||||
offset = max(input_data.get("offset", 1), 1)
|
||||
limit = input_data.get("limit", 2000)
|
||||
if limit <= 0:
|
||||
limit = 2000
|
||||
|
||||
try:
|
||||
with open(file_path, "r", errors="replace") as fh:
|
||||
lines: list[str] = []
|
||||
for lineno, line in enumerate(fh, start=1):
|
||||
if lineno < offset:
|
||||
continue
|
||||
if len(lines) >= limit:
|
||||
break
|
||||
# cat -n style: right-justified line number + tab + content
|
||||
lines.append(f"{lineno:>6}\t{line.rstrip()}")
|
||||
if not lines:
|
||||
return _text_block(f"(file is empty or offset beyond end of file: {file_path})")
|
||||
return _text_block("\n".join(lines))
|
||||
except Exception as exc:
|
||||
return _text_block(f"Error reading {file_path}: {exc}")
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# WriteTool
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class WriteTool(BaseTool):
|
||||
name = "Write"
|
||||
description = (
|
||||
"Write content to a file. Creates parent directories if they do not "
|
||||
"exist. Overwrites the file if it already exists."
|
||||
)
|
||||
|
||||
def get_schema(self) -> dict:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Absolute or relative path to the file to write.",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The full content to write to the file.",
|
||||
},
|
||||
},
|
||||
"required": ["file_path", "content"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
||||
file_path = _resolve(input_data["file_path"], context.cwd)
|
||||
content: str = input_data["content"]
|
||||
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
return _text_block(f"Successfully wrote {len(content)} bytes to {file_path}")
|
||||
except Exception as exc:
|
||||
return _text_block(f"Error writing {file_path}: {exc}")
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# EditTool
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class EditTool(BaseTool):
|
||||
name = "Edit"
|
||||
description = (
|
||||
"Perform exact string replacements in a file. By default the "
|
||||
"old_string must appear exactly once (not unique → error). Pass "
|
||||
"replace_all=true to replace every occurrence."
|
||||
)
|
||||
|
||||
def get_schema(self) -> dict:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Absolute or relative path to the file to edit.",
|
||||
},
|
||||
"old_string": {
|
||||
"type": "string",
|
||||
"description": "The exact text to find in the file.",
|
||||
},
|
||||
"new_string": {
|
||||
"type": "string",
|
||||
"description": "The text to replace old_string with.",
|
||||
},
|
||||
"replace_all": {
|
||||
"type": "boolean",
|
||||
"description": "If true, replace all occurrences. Default false.",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["file_path", "old_string", "new_string"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
||||
file_path = _resolve(input_data["file_path"], context.cwd)
|
||||
old_string: str = input_data["old_string"]
|
||||
new_string: str = input_data["new_string"]
|
||||
replace_all: bool = input_data.get("replace_all", False)
|
||||
|
||||
if not file_path.exists():
|
||||
return _text_block(f"Error: file not found: {file_path}")
|
||||
if not file_path.is_file():
|
||||
return _text_block(f"Error: not a regular file: {file_path}")
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except Exception as exc:
|
||||
return _text_block(f"Error reading {file_path}: {exc}")
|
||||
|
||||
count = content.count(old_string)
|
||||
if count == 0:
|
||||
return _text_block(
|
||||
f"Error: old_string not found in {file_path}. "
|
||||
"Make sure the string matches exactly, including whitespace and indentation."
|
||||
)
|
||||
|
||||
if not replace_all and count > 1:
|
||||
return _text_block(
|
||||
f"Error: old_string appears {count} times in {file_path}. "
|
||||
"Provide more surrounding context to make the match unique, "
|
||||
"or set replace_all=true to replace every occurrence."
|
||||
)
|
||||
|
||||
if replace_all:
|
||||
new_content = content.replace(old_string, new_string)
|
||||
else:
|
||||
# Replace only the first (and only) occurrence
|
||||
new_content = content.replace(old_string, new_string, 1)
|
||||
|
||||
try:
|
||||
file_path.write_text(new_content, encoding="utf-8")
|
||||
except Exception as exc:
|
||||
return _text_block(f"Error writing {file_path}: {exc}")
|
||||
|
||||
replacements = count if replace_all else 1
|
||||
return _text_block(
|
||||
f"Successfully edited {file_path} ({replacements} replacement{'s' if replacements != 1 else ''})."
|
||||
)
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# GlobTool
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GlobTool(BaseTool):
|
||||
name = "Glob"
|
||||
description = (
|
||||
"Fast file pattern matching. Supports glob patterns like '**/*.py'. "
|
||||
"Returns matching file paths sorted by modification time (newest first)."
|
||||
)
|
||||
|
||||
def get_schema(self) -> dict:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern to match files (e.g. '**/*.py', 'src/**/*.ts').",
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory to search in. Defaults to the working directory.",
|
||||
},
|
||||
},
|
||||
"required": ["pattern"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
||||
pattern: str = input_data["pattern"]
|
||||
base = Path(input_data.get("path") or context.cwd)
|
||||
|
||||
if not base.is_dir():
|
||||
return _text_block(f"Error: directory not found: {base}")
|
||||
|
||||
try:
|
||||
matches: list[Path] = []
|
||||
for p in base.glob(pattern):
|
||||
if p.is_file():
|
||||
matches.append(p)
|
||||
if len(matches) >= 500:
|
||||
break
|
||||
|
||||
# Sort by modification time, newest first
|
||||
matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
if not matches:
|
||||
return _text_block(f"No files matched pattern '{pattern}' in {base}")
|
||||
|
||||
result = "\n".join(str(p) for p in matches)
|
||||
return _text_block(result)
|
||||
except Exception as exc:
|
||||
return _text_block(f"Error during glob '{pattern}' in {base}: {exc}")
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# GrepTool
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GrepTool(BaseTool):
|
||||
name = "Grep"
|
||||
description = (
|
||||
"Search file contents using regular expressions. Uses ripgrep (rg) "
|
||||
"when available, otherwise falls back to Python's re module. "
|
||||
"Supports output modes: files_with_matches, content, count."
|
||||
)
|
||||
|
||||
def get_schema(self) -> dict:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Regular expression pattern to search for.",
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File or directory to search in. Defaults to the working directory.",
|
||||
},
|
||||
"glob": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern to filter files (e.g. '*.py', '*.{ts,tsx}').",
|
||||
},
|
||||
"output_mode": {
|
||||
"type": "string",
|
||||
"enum": ["files_with_matches", "content", "count"],
|
||||
"description": "Output mode. Default: files_with_matches.",
|
||||
"default": "files_with_matches",
|
||||
},
|
||||
},
|
||||
"required": ["pattern"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
||||
pattern: str = input_data["pattern"]
|
||||
search_path: str = input_data.get("path") or context.cwd
|
||||
file_glob: str | None = input_data.get("glob")
|
||||
output_mode: str = input_data.get("output_mode", "files_with_matches")
|
||||
|
||||
# Try ripgrep first
|
||||
try:
|
||||
result = await self._run_rg(pattern, search_path, file_glob, output_mode)
|
||||
if result is not None:
|
||||
return result
|
||||
except FileNotFoundError:
|
||||
pass # rg not installed, fall through to Python fallback
|
||||
|
||||
# Python fallback
|
||||
return await self._python_grep(pattern, search_path, file_glob, output_mode)
|
||||
|
||||
async def _run_rg(
|
||||
self,
|
||||
pattern: str,
|
||||
search_path: str,
|
||||
file_glob: str | None,
|
||||
output_mode: str,
|
||||
) -> list[dict] | None:
|
||||
"""Run ripgrep and return results, or None if rg is not available."""
|
||||
cmd = ["rg", "--no-heading", "--color=never"]
|
||||
|
||||
if output_mode == "files_with_matches":
|
||||
cmd.append("--files-with-matches")
|
||||
elif output_mode == "count":
|
||||
cmd.append("--count")
|
||||
else:
|
||||
cmd.extend(["--line-number"])
|
||||
|
||||
if file_glob:
|
||||
cmd.extend(["--glob", file_glob])
|
||||
|
||||
cmd.append(pattern)
|
||||
cmd.append(search_path)
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
|
||||
except FileNotFoundError:
|
||||
raise # re-raise so caller knows rg is missing
|
||||
except asyncio.TimeoutError:
|
||||
return _text_block("Error: grep timed out after 30 seconds.")
|
||||
except Exception as exc:
|
||||
return _text_block(f"Error running ripgrep: {exc}")
|
||||
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
|
||||
if proc.returncode not in (0, 1):
|
||||
err = stderr.decode("utf-8", errors="replace").strip()
|
||||
if err:
|
||||
return _text_block(f"Grep error: {err}")
|
||||
|
||||
if not output.strip():
|
||||
return _text_block(f"No matches found for pattern '{pattern}'.")
|
||||
|
||||
# Truncate if too large
|
||||
if len(output) > _MAX_OUTPUT_BYTES:
|
||||
output = output[:_MAX_OUTPUT_BYTES] + "\n... (output truncated)"
|
||||
|
||||
return _text_block(output.rstrip())
|
||||
|
||||
async def _python_grep(
|
||||
self,
|
||||
pattern: str,
|
||||
search_path: str,
|
||||
file_glob: str | None,
|
||||
output_mode: str,
|
||||
) -> list[dict]:
|
||||
"""Pure-Python grep fallback using the re module."""
|
||||
try:
|
||||
regex = re.compile(pattern)
|
||||
except re.error as exc:
|
||||
return _text_block(f"Invalid regex pattern: {exc}")
|
||||
|
||||
base = Path(search_path)
|
||||
if base.is_file():
|
||||
files = [base]
|
||||
elif base.is_dir():
|
||||
glob_pat = file_glob or "**/*"
|
||||
files = [p for p in base.glob(glob_pat) if p.is_file()]
|
||||
else:
|
||||
return _text_block(f"Error: path not found: {search_path}")
|
||||
|
||||
lines_out: list[str] = []
|
||||
total_bytes = 0
|
||||
truncated = False
|
||||
|
||||
for fp in sorted(files):
|
||||
try:
|
||||
text = fp.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
file_matches: list[tuple[int, str]] = []
|
||||
for lineno, line in enumerate(text.splitlines(), start=1):
|
||||
if regex.search(line):
|
||||
file_matches.append((lineno, line))
|
||||
|
||||
if not file_matches:
|
||||
continue
|
||||
|
||||
if output_mode == "files_with_matches":
|
||||
entry = str(fp)
|
||||
elif output_mode == "count":
|
||||
entry = f"{fp}:{len(file_matches)}"
|
||||
else:
|
||||
parts = [f"{fp}:{ln}:{txt}" for ln, txt in file_matches]
|
||||
entry = "\n".join(parts)
|
||||
|
||||
total_bytes += len(entry)
|
||||
if total_bytes > _MAX_OUTPUT_BYTES:
|
||||
truncated = True
|
||||
break
|
||||
|
||||
lines_out.append(entry)
|
||||
|
||||
if not lines_out:
|
||||
return _text_block(f"No matches found for pattern '{pattern}'.")
|
||||
|
||||
result = "\n".join(lines_out)
|
||||
if truncated:
|
||||
result += "\n... (output truncated)"
|
||||
|
||||
return _text_block(result)
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Central tool registry.
|
||||
|
||||
Importing this module automatically registers all builtin tools.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.apps.agents.tools.base import BaseTool
|
||||
from backend.apps.agents.providers.base import ToolSchema
|
||||
|
||||
_TOOLS: dict[str, BaseTool] = {}
|
||||
|
||||
|
||||
def register_tool(tool: BaseTool) -> None:
|
||||
"""Register a tool instance by its name."""
|
||||
_TOOLS[tool.name] = tool
|
||||
|
||||
|
||||
def get_tool(name: str) -> BaseTool | None:
|
||||
"""Look up a registered tool by name. Returns None if not found."""
|
||||
return _TOOLS.get(name)
|
||||
|
||||
|
||||
def get_all_tools() -> list[BaseTool]:
|
||||
"""Return all registered tool instances."""
|
||||
return list(_TOOLS.values())
|
||||
|
||||
|
||||
def get_all_tool_schemas() -> list[ToolSchema]:
|
||||
"""Return provider-agnostic ToolSchema for every registered tool."""
|
||||
return [t.to_tool_schema() for t in _TOOLS.values()]
|
||||
|
||||
|
||||
def init_tools() -> None:
|
||||
"""Import and register all builtin tools."""
|
||||
from backend.apps.agents.tools.filesystem import (
|
||||
ReadTool,
|
||||
WriteTool,
|
||||
EditTool,
|
||||
GlobTool,
|
||||
GrepTool,
|
||||
)
|
||||
from backend.apps.agents.tools.system import BashTool, AskUserQuestionTool
|
||||
from backend.apps.agents.tools.web import WebSearchTool, WebFetchTool
|
||||
|
||||
for tool_cls in [
|
||||
ReadTool,
|
||||
WriteTool,
|
||||
EditTool,
|
||||
GlobTool,
|
||||
GrepTool,
|
||||
BashTool,
|
||||
AskUserQuestionTool,
|
||||
WebSearchTool,
|
||||
WebFetchTool,
|
||||
]:
|
||||
register_tool(tool_cls())
|
||||
|
||||
|
||||
# Auto-register on import
|
||||
init_tools()
|
||||
@@ -1,125 +0,0 @@
|
||||
"""System tools: Bash and AskUserQuestion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from backend.apps.agents.tools.base import BaseTool, ToolContext
|
||||
|
||||
_MAX_OUTPUT_BYTES = 100 * 1024 # ~100 KB cap
|
||||
|
||||
|
||||
class BashTool(BaseTool):
|
||||
name = "Bash"
|
||||
description = (
|
||||
"Execute a shell command and return its output. The command runs in "
|
||||
"the session's working directory. Supports an optional timeout "
|
||||
"(default 120 000 ms). Stdout and stderr are captured and returned."
|
||||
)
|
||||
|
||||
def get_schema(self) -> dict:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The shell command to execute.",
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Timeout in milliseconds (default 120000, max 600000).",
|
||||
"default": 120000,
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional human-readable description of what this command does.",
|
||||
},
|
||||
},
|
||||
"required": ["command"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
||||
command: str = input_data["command"]
|
||||
timeout_ms: int = min(input_data.get("timeout", 120000), 600000)
|
||||
timeout_s: float = timeout_ms / 1000.0
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=context.cwd,
|
||||
)
|
||||
except Exception as exc:
|
||||
return [{"type": "text", "text": f"Error starting command: {exc}"}]
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
|
||||
except asyncio.TimeoutError:
|
||||
# Attempt to kill the process
|
||||
try:
|
||||
proc.kill()
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=5)
|
||||
except Exception:
|
||||
stdout, stderr = b"", b""
|
||||
|
||||
partial = self._decode(stdout, stderr)
|
||||
msg = (
|
||||
f"Command timed out after {timeout_ms}ms.\n"
|
||||
f"Partial output:\n{partial}"
|
||||
)
|
||||
return [{"type": "text", "text": self._truncate(msg)}]
|
||||
except Exception as exc:
|
||||
return [{"type": "text", "text": f"Error executing command: {exc}"}]
|
||||
|
||||
output = self._decode(stdout, stderr)
|
||||
|
||||
if proc.returncode != 0:
|
||||
output = f"Exit code: {proc.returncode}\n{output}"
|
||||
|
||||
if not output.strip():
|
||||
output = f"(command completed with exit code {proc.returncode})"
|
||||
|
||||
return [{"type": "text", "text": self._truncate(output)}]
|
||||
|
||||
@staticmethod
|
||||
def _decode(stdout: bytes, stderr: bytes) -> str:
|
||||
parts: list[str] = []
|
||||
if stdout:
|
||||
parts.append(stdout.decode("utf-8", errors="replace"))
|
||||
if stderr:
|
||||
parts.append(stderr.decode("utf-8", errors="replace"))
|
||||
return "\n".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _truncate(text: str) -> str:
|
||||
if len(text) > _MAX_OUTPUT_BYTES:
|
||||
return text[:_MAX_OUTPUT_BYTES] + "\n... (output truncated)"
|
||||
return text
|
||||
|
||||
|
||||
class AskUserQuestionTool(BaseTool):
|
||||
name = "AskUserQuestion"
|
||||
description = (
|
||||
"Ask the user a clarifying question. The actual blocking/HITL "
|
||||
"interaction is handled by the agent loop's hitl_handler; this tool "
|
||||
"simply surfaces the question text."
|
||||
)
|
||||
|
||||
def get_schema(self) -> dict:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {
|
||||
"type": "string",
|
||||
"description": "The question to ask the user.",
|
||||
},
|
||||
},
|
||||
"required": ["question"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
||||
question: str = input_data.get("question", "")
|
||||
return [{"type": "text", "text": question}]
|
||||
@@ -1,214 +0,0 @@
|
||||
"""Web tools: WebSearch and WebFetch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.apps.agents.tools.base import BaseTool, ToolContext
|
||||
|
||||
_MAX_OUTPUT_BYTES = 100 * 1024 # ~100 KB
|
||||
_HTTP_TIMEOUT = 30 # seconds
|
||||
_USER_AGENT = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int = _MAX_OUTPUT_BYTES) -> str:
|
||||
if len(text) > limit:
|
||||
return text[:limit] + "\n... (output truncated)"
|
||||
return text
|
||||
|
||||
|
||||
def _strip_html(raw_html: str) -> str:
|
||||
"""Naive but effective HTML → plain-text conversion."""
|
||||
# Remove script/style blocks
|
||||
text = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", raw_html, flags=re.DOTALL | re.IGNORECASE)
|
||||
# Remove HTML tags
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
# Decode HTML entities
|
||||
text = html.unescape(text)
|
||||
# Collapse whitespace
|
||||
text = re.sub(r"[ \t]+", " ", text)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# WebSearchTool
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class WebSearchTool(BaseTool):
|
||||
name = "WebSearch"
|
||||
description = (
|
||||
"Search the web using DuckDuckGo and return titles, URLs, and "
|
||||
"snippets for the top results."
|
||||
)
|
||||
|
||||
def get_schema(self) -> dict:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query.",
|
||||
},
|
||||
"num_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results to return (default 5).",
|
||||
"default": 5,
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
||||
query: str = input_data["query"]
|
||||
num_results: int = input_data.get("num_results", 5)
|
||||
|
||||
try:
|
||||
results = await self._search_ddg(query, num_results)
|
||||
if not results:
|
||||
return [{"type": "text", "text": f"No search results found for: {query}"}]
|
||||
return [{"type": "text", "text": results}]
|
||||
except Exception as exc:
|
||||
return [{"type": "text", "text": f"Web search error: {exc}"}]
|
||||
|
||||
@staticmethod
|
||||
async def _search_ddg(query: str, num_results: int) -> str:
|
||||
"""Query DuckDuckGo HTML endpoint and parse results."""
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_HTTP_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": _USER_AGENT},
|
||||
) as client:
|
||||
resp = await client.post(
|
||||
"https://html.duckduckgo.com/html/",
|
||||
data={"q": query},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
body = resp.text
|
||||
|
||||
# Parse result blocks – DuckDuckGo wraps each result in
|
||||
# <div class="result ..."> ... </div>
|
||||
result_blocks = re.findall(
|
||||
r'<div[^>]*class="[^"]*result[^"]*"[^>]*>(.*?)</div>\s*(?=<div[^>]*class="[^"]*result|$)',
|
||||
body,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
entries: list[str] = []
|
||||
for block in result_blocks:
|
||||
if len(entries) >= num_results:
|
||||
break
|
||||
|
||||
# Title + URL — handle both class-before-href and href-before-class
|
||||
link_match = re.search(
|
||||
r'<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)</a>',
|
||||
block,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
if not link_match:
|
||||
# Try reversed attribute order
|
||||
link_match = re.search(
|
||||
r'<a[^>]*href="([^"]*)"[^>]*class="[^"]*result__a[^"]*"[^>]*>(.*?)</a>',
|
||||
block,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
if not link_match:
|
||||
continue
|
||||
|
||||
raw_url = html.unescape(link_match.group(1))
|
||||
title = _strip_html(link_match.group(2)).strip()
|
||||
|
||||
# Snippet
|
||||
snippet_match = re.search(
|
||||
r'<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</a>',
|
||||
block,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
snippet = _strip_html(snippet_match.group(1)).strip() if snippet_match else ""
|
||||
|
||||
# DuckDuckGo wraps URLs through a redirect; try to extract the real URL
|
||||
real_url_match = re.search(r"uddg=([^&]+)", raw_url)
|
||||
if real_url_match:
|
||||
from urllib.parse import unquote
|
||||
url = unquote(real_url_match.group(1))
|
||||
else:
|
||||
url = raw_url
|
||||
|
||||
entry = f"[{len(entries) + 1}] {title}\n {url}"
|
||||
if snippet:
|
||||
entry += f"\n {snippet}"
|
||||
entries.append(entry)
|
||||
|
||||
return "\n\n".join(entries)
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# WebFetchTool
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class WebFetchTool(BaseTool):
|
||||
name = "WebFetch"
|
||||
description = (
|
||||
"Fetch the contents of a URL and return the extracted text. "
|
||||
"HTML is stripped to plain text. Output is truncated to ~100 KB."
|
||||
)
|
||||
|
||||
def get_schema(self) -> dict:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The URL to fetch.",
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Optional prompt/context describing what information to look for.",
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
||||
url: str = input_data["url"]
|
||||
prompt: str | None = input_data.get("prompt")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_HTTP_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": _USER_AGENT},
|
||||
) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
return [{"type": "text", "text": f"HTTP error {exc.response.status_code} fetching {url}"}]
|
||||
except Exception as exc:
|
||||
return [{"type": "text", "text": f"Error fetching {url}: {exc}"}]
|
||||
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
|
||||
if "html" in content_type or resp.text.strip().startswith("<!"):
|
||||
text = _strip_html(resp.text)
|
||||
else:
|
||||
text = resp.text
|
||||
|
||||
text = _truncate(text)
|
||||
|
||||
header = f"Contents of {url}:"
|
||||
if prompt:
|
||||
header += f"\n(Looking for: {prompt})"
|
||||
|
||||
return [{"type": "text", "text": f"{header}\n\n{text}"}]
|
||||
@@ -1,37 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class AnalyticsEvent(BaseModel):
|
||||
id: Optional[int] = None
|
||||
timestamp: str
|
||||
event_type: str
|
||||
properties: dict
|
||||
session_id: Optional[str] = None
|
||||
dashboard_id: Optional[str] = None
|
||||
|
||||
|
||||
class UsageSummary(BaseModel):
|
||||
total_sessions: int = 0
|
||||
total_cost_usd: float = 0.0
|
||||
total_messages: int = 0
|
||||
total_tool_calls: int = 0
|
||||
avg_session_duration_seconds: float = 0.0
|
||||
session_completion_rate: float = 0.0
|
||||
approval_rate: float = 0.0
|
||||
models_used: dict[str, int] = {}
|
||||
modes_used: dict[str, int] = {}
|
||||
top_tools: list[list] = []
|
||||
|
||||
|
||||
class TimeSeriesPoint(BaseModel):
|
||||
date: str
|
||||
value: float
|
||||
|
||||
|
||||
class ExportPayload(BaseModel):
|
||||
export_version: str = "1.0"
|
||||
exported_at: str = ""
|
||||
app_version: str = "unknown"
|
||||
period: dict = {}
|
||||
summary: dict = {}
|
||||
@@ -7,7 +7,6 @@ Other modules should import from here instead of maintaining their own
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -51,7 +50,6 @@ ALL_MODELS: list[ModelDef] = [
|
||||
# fmt: on
|
||||
|
||||
_BY_VALUE: dict[str, ModelDef] = {m.value: m for m in ALL_MODELS}
|
||||
_BY_MODEL_ID: dict[str, ModelDef] = {m.model_id: m for m in ALL_MODELS}
|
||||
|
||||
|
||||
def resolve_model_id(short_name: str) -> str:
|
||||
@@ -63,49 +61,3 @@ def resolve_model_id(short_name: str) -> str:
|
||||
return m.model_id if m else short_name
|
||||
|
||||
|
||||
def get_cost_rates(provider: str, model: str) -> tuple[float, float] | None:
|
||||
"""Return ``(input_cost_per_1m, output_cost_per_1m)`` or ``None``."""
|
||||
m = _BY_VALUE.get(model)
|
||||
if m and m.provider.lower() == provider.lower():
|
||||
return (m.input_cost_per_1m, m.output_cost_per_1m)
|
||||
for md in ALL_MODELS:
|
||||
if md.value == model and md.provider.lower() == provider.lower():
|
||||
return (md.input_cost_per_1m, md.output_cost_per_1m)
|
||||
return None
|
||||
|
||||
|
||||
def calculate_cost(
|
||||
provider: str, model: str, input_tokens: int, output_tokens: int,
|
||||
) -> float:
|
||||
"""Calculate cost in USD from token counts."""
|
||||
rates = get_cost_rates(provider, model)
|
||||
if not rates:
|
||||
return 0.0
|
||||
input_rate, output_rate = rates
|
||||
return (input_tokens * input_rate + output_tokens * output_rate) / 1_000_000
|
||||
|
||||
|
||||
def get_context_window(model: str) -> int:
|
||||
"""Look up context window for a model by its short value name."""
|
||||
m = _BY_VALUE.get(model) or _BY_MODEL_ID.get(model)
|
||||
return m.context_window if m else 128_000
|
||||
|
||||
|
||||
def get_builtin_models_by_provider() -> dict[str, list[dict[str, Any]]]:
|
||||
"""Return built-in models grouped by provider, matching the legacy format.
|
||||
|
||||
Only includes the curated built-in models (Anthropic) — not
|
||||
OpenRouter-backed models which are exposed through custom providers.
|
||||
"""
|
||||
result: dict[str, list[dict[str, Any]]] = {}
|
||||
for m in ALL_MODELS:
|
||||
if m.api == "openrouter":
|
||||
continue
|
||||
result.setdefault(m.provider, []).append({
|
||||
"value": m.value,
|
||||
"label": m.label,
|
||||
"context_window": m.context_window,
|
||||
"model_id": m.model_id,
|
||||
"api": m.api,
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.apps.dashboard_layout.models import DashboardLayout, DashboardLayoutUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from backend.config.paths import DASHBOARD_LAYOUT_DIR as DATA_DIR
|
||||
|
||||
LAYOUT_FILE = os.path.join(DATA_DIR, "layout.json")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def dashboard_layout_lifespan():
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
yield
|
||||
|
||||
|
||||
dashboard_layout = SubApp("dashboard_layout", dashboard_layout_lifespan)
|
||||
|
||||
|
||||
def _default_layout() -> DashboardLayout:
|
||||
return DashboardLayout(cards={})
|
||||
|
||||
|
||||
def _load() -> DashboardLayout:
|
||||
if not os.path.exists(LAYOUT_FILE):
|
||||
return _default_layout()
|
||||
try:
|
||||
with open(LAYOUT_FILE) as f:
|
||||
data = json.load(f)
|
||||
if "columns" in data and "cards" not in data:
|
||||
logger.info("Detected old column-based layout format, resetting to empty canvas")
|
||||
return _default_layout()
|
||||
return DashboardLayout(**data)
|
||||
except Exception:
|
||||
logger.exception("Failed to load dashboard layout, returning default")
|
||||
return _default_layout()
|
||||
|
||||
|
||||
def _save(layout: DashboardLayout):
|
||||
with open(LAYOUT_FILE, "w") as f:
|
||||
json.dump(layout.model_dump(), f, indent=2)
|
||||
|
||||
|
||||
@dashboard_layout.router.get("")
|
||||
async def get_layout():
|
||||
layout = _load()
|
||||
return layout.model_dump()
|
||||
|
||||
|
||||
@dashboard_layout.router.put("")
|
||||
async def update_layout(body: DashboardLayoutUpdate):
|
||||
layout = DashboardLayout(cards=body.cards, view_cards=body.view_cards)
|
||||
_save(layout)
|
||||
return layout.model_dump()
|
||||
@@ -1,27 +0,0 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CardPosition(BaseModel):
|
||||
session_id: str
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
width: float = 420
|
||||
height: float = 280
|
||||
|
||||
|
||||
class ViewCardPosition(BaseModel):
|
||||
output_id: str
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
width: float = 480
|
||||
height: float = 360
|
||||
|
||||
|
||||
class DashboardLayout(BaseModel):
|
||||
cards: dict[str, CardPosition] = Field(default_factory=dict)
|
||||
view_cards: dict[str, ViewCardPosition] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DashboardLayoutUpdate(BaseModel):
|
||||
cards: dict[str, CardPosition]
|
||||
view_cards: dict[str, ViewCardPosition] = Field(default_factory=dict)
|
||||
@@ -2,14 +2,14 @@ from backend.config.Apps import SubApp
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from typeguard import typechecked
|
||||
import debug
|
||||
# import debug
|
||||
from fastapi import status, HTTPException
|
||||
|
||||
@asynccontextmanager
|
||||
async def health_lifespan():
|
||||
debug("START")
|
||||
# debug("START")
|
||||
yield
|
||||
debug("END")
|
||||
# debug("END")
|
||||
|
||||
health = SubApp("health", health_lifespan)
|
||||
|
||||
@@ -20,7 +20,7 @@ health = SubApp("health", health_lifespan)
|
||||
@health.router.get("/check")
|
||||
@typechecked
|
||||
async def check() -> PlainTextResponse:
|
||||
debug("Health check successful")
|
||||
# debug("Health check successful")
|
||||
# Use PlainTextResponse instead of JSONResponse for AWS ALB compatibility
|
||||
# ALB health checks can be sensitive to JSON responses and Content-Length headers
|
||||
return PlainTextResponse(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, APIRouter
|
||||
import debug
|
||||
# import debug
|
||||
from uuid import uuid4
|
||||
from typing import List
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -11,26 +11,26 @@ from typing import Callable
|
||||
|
||||
class SubApp:
|
||||
def __init__(self, name:str, lifespan:Callable):
|
||||
debug("START", name)
|
||||
# debug("START", name)
|
||||
self.id = uuid4()
|
||||
self.name = name
|
||||
self.prefix = f"/api/{name}"
|
||||
self.lifespan = lifespan
|
||||
self.router = APIRouter()
|
||||
debug("END")
|
||||
# debug("END")
|
||||
|
||||
def __str__(self):
|
||||
return f"SubApp(name={self.name}, prefix={self.prefix}, id={self.id})"
|
||||
|
||||
class MainApp:
|
||||
def __init__(self, sub_apps: List[SubApp]):
|
||||
debug("START")
|
||||
# debug("START")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with AsyncExitStack() as stack:
|
||||
for sub_app in sub_apps:
|
||||
debug(sub_app.name)
|
||||
# debug(sub_app.name)
|
||||
await stack.enter_async_context(sub_app.lifespan())
|
||||
_port = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
print(f"\nCheck out the API docs at: http://127.0.0.1:{_port}/docs\n")
|
||||
@@ -45,4 +45,4 @@ class MainApp:
|
||||
prefix=sub_app.prefix,
|
||||
tags=[sub_app.name]
|
||||
)
|
||||
debug("END")
|
||||
# debug("END")
|
||||
@@ -3,12 +3,9 @@ claude-agent-sdk
|
||||
jsonschema
|
||||
fastapi[standard]
|
||||
pydantic==2.10.5
|
||||
langchain-core==0.3.51
|
||||
langchain-openai==0.3.12
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.25.2
|
||||
typeguard==4.4.2
|
||||
python-dotenv==1.1.1
|
||||
Pillow
|
||||
posthog
|
||||
httpx>=0.27.0
|
||||
@@ -59,18 +59,6 @@ source "$VENV_DIR/bin/activate"
|
||||
# --- Upgrade pip if outdated ---
|
||||
pip3 install --upgrade pip --quiet
|
||||
|
||||
# --- Install custom debugger module if not already installed ---
|
||||
DEBUGGER_DIR_ABSPATH="$PROJECT_ROOT_ABSPATH/debugger"
|
||||
if ! pip3 show debug > /dev/null 2>&1; then
|
||||
echo "Installing debugger module..."
|
||||
cd "$DEBUGGER_DIR_ABSPATH"
|
||||
pip3 install -e .
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo "Failed to install debugger module."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Install Python dependencies ---
|
||||
echo "Installing dependencies..."
|
||||
cd "$BACKEND_DIR_ABSPATH"
|
||||
|
||||
+1
-2
@@ -161,8 +161,7 @@ async function startBackend() {
|
||||
process.resourcesPath, 'python-env', 'lib',
|
||||
'python3.13', 'site-packages'
|
||||
);
|
||||
const debuggerDir = getResourcePath('debugger');
|
||||
env.PYTHONPATH = [projectRoot, debuggerDir, pythonEnvSitePackages].join(':');
|
||||
env.PYTHONPATH = [projectRoot, pythonEnvSitePackages].join(':');
|
||||
}
|
||||
|
||||
console.log(`Starting backend: ${pythonPath} on port ${backendPort}`);
|
||||
|
||||
@@ -73,13 +73,6 @@
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "build-staging/debugger",
|
||||
"to": "debugger",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "python-env",
|
||||
"to": "python-env",
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"@mui/icons-material": "^7.3.9",
|
||||
"@mui/material": "^7.3.9",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"codemirror": "^6.0.2",
|
||||
"framer-motion": "^12.35.2",
|
||||
"html-to-image": "^1.11.13",
|
||||
@@ -29,7 +28,6 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { updateSettings } from '@/shared/state/settingsSlice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
const AnalyticsOptIn: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const settings = useAppSelector((s) => s.settings.data);
|
||||
const loaded = useAppSelector((s) => s.settings.loaded);
|
||||
|
||||
if (!loaded || settings.analytics_opt_in !== null) return null;
|
||||
|
||||
const handleChoice = (optIn: boolean) => {
|
||||
dispatch(updateSettings({ ...settings, analytics_opt_in: optIn }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1400,
|
||||
maxWidth: 480,
|
||||
width: '90%',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
borderRadius: 3,
|
||||
boxShadow: c.shadow.lg,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '0.9rem', fontWeight: 600, mb: 0.5 }}>
|
||||
Help improve OpenSwarm
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.8rem', lineHeight: 1.5, mb: 2 }}>
|
||||
Share anonymous usage statistics like session counts, feature usage, and model preferences.
|
||||
No conversations, file paths, or personal information — ever.
|
||||
You can change this anytime in Settings.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={() => handleChoice(false)}
|
||||
sx={{
|
||||
color: c.text.muted,
|
||||
textTransform: 'none',
|
||||
fontSize: '0.82rem',
|
||||
'&:hover': { bgcolor: `${c.text.tertiary}0A` },
|
||||
}}
|
||||
>
|
||||
No thanks
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => handleChoice(true)}
|
||||
sx={{
|
||||
bgcolor: c.accent.primary,
|
||||
'&:hover': { bgcolor: c.accent.pressed },
|
||||
textTransform: 'none',
|
||||
fontSize: '0.82rem',
|
||||
borderRadius: 1.5,
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
Share anonymous data
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsOptIn;
|
||||
@@ -13,22 +13,15 @@ export interface ClipboardCard {
|
||||
}
|
||||
|
||||
let clipboardCards: ClipboardCard[] = [];
|
||||
let clipboardTimestamp = 0;
|
||||
|
||||
export function setClipboardCards(cards: ClipboardCard[]): void {
|
||||
clipboardCards = cards;
|
||||
clipboardTimestamp = Date.now();
|
||||
}
|
||||
|
||||
export function getClipboardCards(): ClipboardCard[] {
|
||||
return clipboardCards;
|
||||
}
|
||||
|
||||
export function getClipboardTimestamp(): number {
|
||||
return clipboardTimestamp;
|
||||
}
|
||||
|
||||
export function clearClipboard(): void {
|
||||
clipboardCards = [];
|
||||
clipboardTimestamp = 0;
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
.under_construction_overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
// z-index: 1000;
|
||||
text-align: center;
|
||||
font-family: Arial, sans-serif;
|
||||
color: white;
|
||||
// box-sizing: border-box;
|
||||
}
|
||||
|
||||
.under_construction_overlay img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.under_construction_overlay h2 {
|
||||
font-size: 24px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.under_construction_overlay p {
|
||||
font-size: 18px;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
|
||||
import React from 'react';
|
||||
import styles from './UnderConstruction.module.scss'; // CSS for styling the overlay
|
||||
|
||||
const UnderConstruction = () => {
|
||||
return (
|
||||
<div className={styles.under_construction_overlay}>
|
||||
<img src="/hammer-icon.png" alt="Console Icon" />
|
||||
<h2>Under Construction</h2>
|
||||
<p>This feature is coming soon!</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { UnderConstruction };
|
||||
@@ -1,82 +0,0 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const ANALYTICS_API = `${API_BASE}/analytics`;
|
||||
|
||||
export interface UsageSummary {
|
||||
total_sessions: number;
|
||||
total_cost_usd: number;
|
||||
total_messages: number;
|
||||
total_tool_calls: number;
|
||||
avg_duration_seconds: number;
|
||||
avg_cost_per_session: number;
|
||||
completion_rate: number;
|
||||
models_used: Record<string, number>;
|
||||
providers_used: Record<string, number>;
|
||||
top_tools: Record<string, number>;
|
||||
status_breakdown: Record<string, number>;
|
||||
// 9Router enrichment
|
||||
total_prompt_tokens: number;
|
||||
total_completion_tokens: number;
|
||||
cost_by_model: Record<string, { cost: number; requests: number; prompt_tokens: number; completion_tokens: number }>;
|
||||
cost_by_provider: Record<string, { cost: number; requests: number }>;
|
||||
cost_source: ' 9router' | 'sdk' | 'none';
|
||||
nine_router_available: boolean;
|
||||
total_requests: number;
|
||||
}
|
||||
|
||||
export interface CostBreakdown {
|
||||
available: boolean;
|
||||
period: string;
|
||||
total_cost: number;
|
||||
total_requests: number;
|
||||
total_prompt_tokens: number;
|
||||
total_completion_tokens: number;
|
||||
by_model: Record<string, any>;
|
||||
by_provider: Record<string, any>;
|
||||
}
|
||||
|
||||
interface AnalyticsState {
|
||||
summary: UsageSummary | null;
|
||||
costBreakdown: CostBreakdown | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const initialState: AnalyticsState = {
|
||||
summary: null,
|
||||
costBreakdown: null,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
export const fetchAnalyticsSummary = createAsyncThunk('analytics/fetchSummary', async () => {
|
||||
const res = await fetch(`${ANALYTICS_API}/usage-summary`);
|
||||
return (await res.json()) as UsageSummary;
|
||||
});
|
||||
|
||||
export const fetchCostBreakdown = createAsyncThunk(
|
||||
'analytics/fetchCostBreakdown',
|
||||
async (period: string = '7d') => {
|
||||
const res = await fetch(`${ANALYTICS_API}/cost-breakdown?period=${period}`);
|
||||
return (await res.json()) as CostBreakdown;
|
||||
},
|
||||
);
|
||||
|
||||
const analyticsSlice = createSlice({
|
||||
name: 'analytics',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchAnalyticsSummary.pending, (state) => { state.loading = true; })
|
||||
.addCase(fetchAnalyticsSummary.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.summary = action.payload;
|
||||
})
|
||||
.addCase(fetchAnalyticsSummary.rejected, (state) => { state.loading = false; })
|
||||
.addCase(fetchCostBreakdown.fulfilled, (state, action) => {
|
||||
state.costBreakdown = action.payload;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default analyticsSlice.reducer;
|
||||
@@ -47,16 +47,6 @@ export function buildServeUrl(
|
||||
return `${SERVE_BASE}/${outputId}/serve/index.html?_d=${encodeURIComponent(encoded)}`;
|
||||
}
|
||||
|
||||
export function buildWorkspaceServeUrl(
|
||||
workspaceId: string,
|
||||
inputData: Record<string, any> = {},
|
||||
backendResult: Record<string, any> | null = null,
|
||||
): string {
|
||||
const dataPayload = JSON.stringify({ i: inputData, r: backendResult });
|
||||
const encoded = btoa(unescape(encodeURIComponent(dataPayload)));
|
||||
return `${SERVE_BASE}/workspace/${workspaceId}/serve/index.html?_d=${encodeURIComponent(encoded)}`;
|
||||
}
|
||||
|
||||
export interface OutputExecuteResult {
|
||||
output_id: string;
|
||||
output_name: string;
|
||||
|
||||
@@ -12,7 +12,6 @@ import outputsReducer from './outputsSlice';
|
||||
import dashboardLayoutReducer from './dashboardLayoutSlice';
|
||||
import dashboardsReducer from './dashboardsSlice';
|
||||
import updateReducer from './updateSlice';
|
||||
import analyticsReducer from './analyticsSlice';
|
||||
import modelsReducer from './modelsSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
@@ -30,7 +29,6 @@ export const store = configureStore({
|
||||
dashboardLayout: dashboardLayoutReducer,
|
||||
dashboards: dashboardsReducer,
|
||||
update: updateReducer,
|
||||
analytics: analyticsReducer,
|
||||
models: modelsReducer,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
// store/tempStateSlice.ts
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
interface TempState {
|
||||
temp_state: string | null;
|
||||
pendingBrowserUrl: string | null;
|
||||
pendingFocusAgentId: string | null;
|
||||
lastDashboardId: string | null;
|
||||
}
|
||||
|
||||
const initialState: TempState = {
|
||||
temp_state: null,
|
||||
pendingBrowserUrl: null,
|
||||
pendingFocusAgentId: null,
|
||||
lastDashboardId: null,
|
||||
};
|
||||
|
||||
const tempStateSlice = createSlice({
|
||||
name: 'tempState',
|
||||
initialState,
|
||||
reducers: {
|
||||
setTempState(state, action: PayloadAction<string | null>) {
|
||||
state.temp_state = action.payload;
|
||||
},
|
||||
resetTempState(state) {
|
||||
state.temp_state = null;
|
||||
},
|
||||
setPendingBrowserUrl(state, action: PayloadAction<string>) {
|
||||
state.pendingBrowserUrl = action.payload;
|
||||
},
|
||||
clearPendingBrowserUrl(state) {
|
||||
state.pendingBrowserUrl = null;
|
||||
},
|
||||
setLastDashboardId(state, action: PayloadAction<string>) {
|
||||
state.lastDashboardId = action.payload;
|
||||
},
|
||||
setPendingFocusAgentId(state, action: PayloadAction<string>) {
|
||||
state.pendingFocusAgentId = action.payload;
|
||||
},
|
||||
@@ -44,11 +30,8 @@ const tempStateSlice = createSlice({
|
||||
});
|
||||
|
||||
export const {
|
||||
setTempState,
|
||||
resetTempState,
|
||||
setPendingBrowserUrl,
|
||||
clearPendingBrowserUrl,
|
||||
setLastDashboardId,
|
||||
setPendingFocusAgentId,
|
||||
clearPendingFocusAgentId,
|
||||
} = tempStateSlice.actions;
|
||||
|
||||
@@ -73,19 +73,6 @@ export const deleteTemplate = createAsyncThunk('templates/delete', async (id: st
|
||||
return id;
|
||||
});
|
||||
|
||||
export const renderTemplate = createAsyncThunk(
|
||||
'templates/render',
|
||||
async ({ templateId, values }: { templateId: string; values: Record<string, any> }) => {
|
||||
const res = await fetch(`${TEMPLATES_API}/render`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template_id: templateId, values }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.rendered as string;
|
||||
}
|
||||
);
|
||||
|
||||
const templatesSlice = createSlice({
|
||||
name: 'templates',
|
||||
initialState,
|
||||
|
||||
@@ -57,11 +57,6 @@ const updateSlice = createSlice({
|
||||
state.status = 'error';
|
||||
state.error = action.payload;
|
||||
},
|
||||
resetUpdateStatus(state) {
|
||||
state.status = 'idle';
|
||||
state.error = null;
|
||||
state.downloadPercent = 0;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -73,7 +68,6 @@ export const {
|
||||
setDownloading,
|
||||
setUpdateDownloaded,
|
||||
setUpdateError,
|
||||
resetUpdateStatus,
|
||||
} = updateSlice.actions;
|
||||
|
||||
export default updateSlice.reducer;
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
@use '@/shared/styles/utils.module.scss' as utils;
|
||||
|
||||
$text-map: (
|
||||
'light-1': #F0F0F0,
|
||||
'light-2': #D9D9D9,
|
||||
'light-3': #BDBDBD,
|
||||
'light-4': #999999,
|
||||
);
|
||||
@function text($mode: 'light-1') {
|
||||
@return utils.get-style(
|
||||
$function-map: $text-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
|
||||
$background-color-map: (
|
||||
'dark-1': #232323,
|
||||
'dark-2': #151515,
|
||||
'dark-3': #0A0A0A,
|
||||
);
|
||||
@function background-color($mode: 'dark-1') {
|
||||
@return utils.get-style(
|
||||
$function-map: $background-color-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
|
||||
@mixin gradient-1() {
|
||||
$mask-color: rgba(0, 0, 0, 0.623);
|
||||
$gradient-color: rgba(62, 139, 241, 0.20);
|
||||
$background-color: #151515;
|
||||
background:
|
||||
linear-gradient(0deg, $mask-color 0%, $mask-color 100%),
|
||||
radial-gradient(102.16% 48.94% at 49.58% 47.09%, rgba(6, 14, 25, 0.00) 0%, rgba(62, 139, 241, 0.20) 100%),
|
||||
#151515;
|
||||
}
|
||||
|
||||
$glass-map: (
|
||||
'default': (
|
||||
border-radius: 10px,
|
||||
border: 1px solid rgba(255, 255, 255, 0.075),
|
||||
background: rgba(38, 38, 38, 0.184),
|
||||
background-blend-mode: luminosity,
|
||||
backdrop-filter: blur(50px),
|
||||
),
|
||||
'light-05': (
|
||||
border-radius: 10px,
|
||||
border: 1px solid rgba(255, 255, 255, 0.095),
|
||||
background: rgba(160, 160, 160, 0.048),
|
||||
background-blend-mode: luminosity,
|
||||
backdrop-filter: blur(50px),
|
||||
),
|
||||
'light-075': (
|
||||
border-radius: 10px,
|
||||
border: 1px solid rgba(255, 255, 255, 0.095),
|
||||
background: rgba(160, 160, 160, 0.075),
|
||||
background-blend-mode: luminosity,
|
||||
backdrop-filter: blur(50px),
|
||||
),
|
||||
'light-1': (
|
||||
border-radius: 10px,
|
||||
border: 1px solid rgba(255, 255, 255, 0.095),
|
||||
background: rgba(160, 160, 160, 0.154),
|
||||
background-blend-mode: luminosity,
|
||||
backdrop-filter: blur(50px),
|
||||
),
|
||||
'light-2': (
|
||||
border-radius: 10px,
|
||||
border: 1px solid rgba(255, 255, 255, 0.178),
|
||||
background: rgba(160, 160, 160, 0.46),
|
||||
background-blend-mode: luminosity,
|
||||
backdrop-filter: blur(50px),
|
||||
),
|
||||
'light-3': (
|
||||
border-radius: 10px,
|
||||
border: 1px solid rgba(255, 255, 255, 0.178),
|
||||
background: rgba(0, 0, 0, 0.247),
|
||||
background-blend-mode: luminosity,
|
||||
backdrop-filter: blur(50px),
|
||||
),
|
||||
);
|
||||
@mixin glass($mode: 'default') {
|
||||
@include utils.apply-style-map(
|
||||
$mixin-map: $glass-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
|
||||
$glow-map: (
|
||||
'default': (
|
||||
border: 1px solid #0099ff71,
|
||||
box-shadow: 0 0 24px #0099ff71,
|
||||
),
|
||||
'dark-1': (
|
||||
border: 1px solid #3e8cf13e,
|
||||
box-shadow: 0 0 24px #3e8cf12b,
|
||||
),
|
||||
'light-1': (
|
||||
border: 1px solid #ab19ff47,
|
||||
box-shadow: 0 0 44px #c259ff8a,
|
||||
),
|
||||
'source-1': (
|
||||
border: 1px solid #ff000071,
|
||||
box-shadow: 0 0 44px #ff000071,
|
||||
),
|
||||
);
|
||||
@mixin glow($mode: 'default') {
|
||||
@include utils.apply-style-map(
|
||||
$mixin-map: $glow-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
$accent-map: (
|
||||
'blue-1': #3E8BF1,
|
||||
'blue-2': #3e8cf1c7,
|
||||
'blue-3': #3e8cf15b,
|
||||
'blue-grey-1': #77a7e5c7,
|
||||
'red-1': #F56868,
|
||||
'red-2': #f568681a,
|
||||
);
|
||||
@function accent($mode: 'blue-1') {
|
||||
@return utils.get-style(
|
||||
$function-map: $accent-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export const getStyleValue = (className: string, property: string, defaultValue: string = "none"): string => {
|
||||
if (typeof document !== 'undefined') {
|
||||
const element = document.createElement("div");
|
||||
element.setAttribute("class", className);
|
||||
document.body.appendChild(element);
|
||||
const style = window.getComputedStyle(element);
|
||||
const value = style.getPropertyValue(property);
|
||||
document.body.removeChild(element);
|
||||
return value || defaultValue;
|
||||
}
|
||||
return defaultValue; // Return default value if not in a browser environment
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
@use '@/shared/styles/utils.module.scss' as utils;
|
||||
|
||||
$flex-map: (
|
||||
'vert': (
|
||||
flex-direction: column,
|
||||
),
|
||||
'horz': (
|
||||
flex-direction: row,
|
||||
),
|
||||
);
|
||||
@mixin flex($direction: 'vert') {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
@include utils.apply-style-map(
|
||||
$mixin-map: $flex-map,
|
||||
$mode: $direction
|
||||
);
|
||||
}
|
||||
|
||||
$flex-hug-map: (
|
||||
'default': (
|
||||
width: fit-content,
|
||||
height: fit-content,
|
||||
),
|
||||
'full-width': (
|
||||
width: 100%,
|
||||
),
|
||||
'full-height': (
|
||||
height: 100%,
|
||||
),
|
||||
);
|
||||
@mixin flex-hug($mode: 'default') {
|
||||
@include flex('horz');
|
||||
@include utils.apply-style-map(
|
||||
$mixin-map: $flex-hug-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
$scroll-map: (
|
||||
'hidden': (
|
||||
"&::-webkit-scrollbar": (
|
||||
display: none
|
||||
),
|
||||
-ms-overflow-style: none, /* IE and Edge */
|
||||
scrollbar-width: none, /* Firefox */
|
||||
),
|
||||
);
|
||||
@mixin scroll-bar($mode: 'hidden') {
|
||||
@include utils.apply-style-map(
|
||||
$mixin-map: $scroll-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
@use '@/shared/styles/color.module.scss' as g-color;
|
||||
@use '@/shared/styles/utils.module.scss' as utils;
|
||||
|
||||
|
||||
$font-map: (
|
||||
'default': 'Inter',
|
||||
'secondary': 'Times New Roman'
|
||||
);
|
||||
@function font($mode: 'default') {
|
||||
@return utils.get-style(
|
||||
$function-map: $font-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
|
||||
$size-map: (
|
||||
'small': 12px,
|
||||
'small-medium': 14px,
|
||||
'medium': 16px,
|
||||
'large': 20px,
|
||||
'title': 30px,
|
||||
);
|
||||
@function size($mode: 'default') {
|
||||
@return utils.get-style(
|
||||
$function-map: $size-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
|
||||
$weight-map: (
|
||||
'small': 400,
|
||||
'medium': 500,
|
||||
'large': 600,
|
||||
'title': 700,
|
||||
);
|
||||
@function weight($mode: 'default') {
|
||||
@return utils.get-style(
|
||||
$function-map: $weight-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
|
||||
$text-map: (
|
||||
'default': (
|
||||
font-family: 'Inter',
|
||||
font-size: 16px,
|
||||
font-weight: 400,
|
||||
color: g-color.text('light-1'),
|
||||
),
|
||||
'title': (
|
||||
font-family: 'Inter',
|
||||
font-size: 40px,
|
||||
font-weight: 700,
|
||||
color: g-color.accent('blue-1'),
|
||||
line-height: 100%,
|
||||
)
|
||||
);
|
||||
@mixin text($mode: 'default') {
|
||||
@include utils.apply-style-map(
|
||||
$mixin-map: $text-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
@use "sass:map";
|
||||
@use "sass:meta";
|
||||
|
||||
// NOTE: Example map input:
|
||||
// $text-map: (
|
||||
// 'default': (
|
||||
// font-family: 'Inter',
|
||||
// font-size: 16px,
|
||||
// font-weight: 400,
|
||||
// ),
|
||||
// 'secondary': (∂
|
||||
// font-family: 'Times New Roman',
|
||||
// font-size: 16px,
|
||||
// font-weight: 400,
|
||||
// )
|
||||
// );
|
||||
@function construct-styles($mixin-map, $mode) {
|
||||
$styles: map.get($mixin-map, $mode);
|
||||
@if $styles == null {
|
||||
$available-modes: map.keys($styles);
|
||||
@error "Invalid style mode: '#{$mode}' -> Available modes are: #{$available-modes}.";
|
||||
}
|
||||
@return $styles;
|
||||
}
|
||||
@mixin apply-style-map($mixin-map, $mode) {
|
||||
$styles: construct-styles($mixin-map, $mode);
|
||||
@each $property, $value in $styles {
|
||||
@if meta.type-of($value) == map {
|
||||
// This is a nested selector
|
||||
#{$property} {
|
||||
@each $nested-property, $nested-value in $value {
|
||||
#{$nested-property}: $nested-value;
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
// This is a normal property-value pair
|
||||
#{$property}: $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// NOTE: Example map input:
|
||||
// $font-map: (
|
||||
// 'default': 'Inter',
|
||||
// 'secondary': 'Times New Roman',
|
||||
// )
|
||||
// );
|
||||
@function construct-style($function-map, $mode) {
|
||||
// Check if the mode exists in the map
|
||||
$style-value: map.get($function-map, $mode);
|
||||
@if $style-value == null {
|
||||
$available-modes: map.keys($function-map);
|
||||
@error "Invalid style mode: '#{$mode}' -> Available modes are: #{$available-modes}.";
|
||||
}
|
||||
|
||||
// Return the style value
|
||||
@return $style-value;
|
||||
}
|
||||
@function get-style($function-map, $mode) {
|
||||
$style: construct-style($function-map, $mode);
|
||||
@return $style;
|
||||
}
|
||||
Vendored
-14
@@ -1,14 +0,0 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: { [key: string]: string };
|
||||
export default classes;
|
||||
}
|
||||
|
||||
declare module '*.module.scss' {
|
||||
const classes: { [key: string]: string };
|
||||
export default classes;
|
||||
}
|
||||
|
||||
declare module '*.module.sass' {
|
||||
const classes: { [key: string]: string };
|
||||
export default classes;
|
||||
}
|
||||
@@ -13,15 +13,10 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
+4
-10
@@ -4,8 +4,8 @@ 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)
|
||||
# bash run/utils/build-app.sh Local dev build (unsigned)
|
||||
# bash run/utils/build-app.sh --publish Production build (signed, notarized, published to GitHub Releases)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
@@ -67,7 +67,7 @@ fi
|
||||
echo ""
|
||||
|
||||
# Step 1: Build frontend
|
||||
echo "[1/4] Building frontend..."
|
||||
echo "[1/5] Building frontend..."
|
||||
cd "$PROJECT_ROOT/frontend"
|
||||
npm install
|
||||
npm run build
|
||||
@@ -80,7 +80,7 @@ echo "Frontend build complete."
|
||||
echo ""
|
||||
|
||||
# Step 2: Build Python environment
|
||||
echo "[2/4] Building Python environment..."
|
||||
echo "[2/5] Building Python environment..."
|
||||
bash "$SCRIPT_DIR/build-python-env.sh"
|
||||
|
||||
if [[ ! -d "$PROJECT_ROOT/electron/python-env" ]]; then
|
||||
@@ -123,12 +123,6 @@ rsync -a \
|
||||
--exclude='*.pyc' --exclude='.venv' \
|
||||
"$PROJECT_ROOT/backend/" "$STAGING_DIR/backend/"
|
||||
|
||||
rsync -a \
|
||||
--exclude='__pycache__' --exclude='**/__pycache__' \
|
||||
--exclude='*.pyc' --exclude='.venv' --exclude='**/.venv' \
|
||||
--exclude='**/node_modules' \
|
||||
"$PROJECT_ROOT/debugger/" "$STAGING_DIR/debugger/"
|
||||
|
||||
rsync -a "$PROJECT_ROOT/frontend/dist/" "$STAGING_DIR/frontend/"
|
||||
|
||||
# 9Router — copy the pre-built standalone directory
|
||||
|
||||
@@ -84,10 +84,6 @@ 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')"
|
||||
|
||||
Reference in New Issue
Block a user