mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] webapp-template standardization (React + Vite + FastAPI new-app starter) + swarm-debug built-in skill + stale-session recovery
This commit is contained in:
@@ -1,221 +1,306 @@
|
||||
# App Builder — Platform Reference
|
||||
|
||||
You are building an **App**: a self-contained web app rendered inside an
|
||||
Electron `<webview>` (so it behaves like a real browser tab — cross-origin
|
||||
`fetch`, popups, mic/camera, etc. all work). The workspace you're working
|
||||
in is the source of truth — every file you write here is served directly
|
||||
to the live preview.
|
||||
You are building an **App** inside OpenSwarm. The workspace you're working
|
||||
in is a **React 18 + TypeScript + Vite** project (with an optional FastAPI
|
||||
backend you can opt into on demand). It's served live to a webview, so it
|
||||
behaves like a real browser tab — cross-origin `fetch`, popups, mic/camera,
|
||||
clipboard, anything a normal web page does.
|
||||
|
||||
You are **NOT** writing a single HTML file or vanilla JS. Match the
|
||||
codebase's patterns.
|
||||
|
||||
---
|
||||
|
||||
## File conventions
|
||||
|
||||
| File | Required | Purpose |
|
||||
|------|----------|---------|
|
||||
| `index.html` | **Yes** | Entry point. Must be a complete HTML document. This is the ONLY file the preview loads — never rename it. |
|
||||
| `meta.json` | **Yes** | `{"name":"…","description":"…"}` — displayed in the UI header. Always write this. |
|
||||
| `backend.py` | Optional | Long-running HTTP server. See "Backend" below. |
|
||||
| Everything else | Optional | JS, CSS, images, subdirectories — referenced from `index.html` via relative paths. |
|
||||
|
||||
### ⚠️ Do NOT
|
||||
|
||||
- Name the main HTML file anything other than `index.html` — the platform
|
||||
will not find it and the preview will be blank.
|
||||
- Use `document.write()` — it breaks the injected data globals.
|
||||
- Treat `backend.py` like a one-shot helper. It's a real HTTP server (see below).
|
||||
|
||||
---
|
||||
|
||||
## Injected globals
|
||||
|
||||
Before `index.html` loads, the platform injects:
|
||||
|
||||
```javascript
|
||||
window.OUTPUT_INPUT // Object — optional structured input (may be {})
|
||||
window.OUTPUT_BACKEND_URL // string | null — base URL of the running backend.py, e.g. "http://127.0.0.1:54213"
|
||||
```
|
||||
|
||||
`OUTPUT_BACKEND_URL` is `null` when the app has no `backend.py` (pure-frontend
|
||||
app). When it's set, `fetch(window.OUTPUT_BACKEND_URL + '/your-route')` hits
|
||||
the persistent backend.
|
||||
|
||||
---
|
||||
|
||||
## backend.py — persistent HTTP server
|
||||
|
||||
`backend.py` runs as a **long-lived subprocess** for the lifetime of the
|
||||
app being open in the editor. It is **NOT a one-shot helper** that runs
|
||||
once before render — it's a real backend server that responds to
|
||||
frontend `fetch()` calls.
|
||||
|
||||
The platform auto-allocates a free port and exposes it via the env var
|
||||
`PORT`. Your `backend.py` MUST bind to that port. Any standard Python
|
||||
HTTP framework works (FastAPI, Flask, raw `http.server`).
|
||||
|
||||
Minimal FastAPI example:
|
||||
|
||||
```python
|
||||
# backend.py
|
||||
import os
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app = FastAPI()
|
||||
# The frontend is served from http://localhost:8324 (different origin
|
||||
# than this backend on http://127.0.0.1:$PORT), so CORS must allow it.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.get("/items")
|
||||
def list_items():
|
||||
return {"items": ["alpha", "beta", "gamma"]}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="127.0.0.1", port=int(os.environ["PORT"]))
|
||||
```
|
||||
|
||||
Then in `index.html`:
|
||||
|
||||
```javascript
|
||||
const res = await fetch(window.OUTPUT_BACKEND_URL + '/items');
|
||||
const data = await res.json();
|
||||
```
|
||||
|
||||
Stdout/stderr from `backend.py` stream live into the App Builder's
|
||||
**Terminal** tab (prefixed `[BACKEND]`), so `print()` is your debugger.
|
||||
|
||||
---
|
||||
|
||||
## Multi-file projects
|
||||
|
||||
Split code across files for organization. All files are served from the
|
||||
workspace root, so relative imports work naturally:
|
||||
## Workspace layout
|
||||
|
||||
```
|
||||
workspace/
|
||||
├── index.html
|
||||
├── meta.json
|
||||
├── backend.py (optional)
|
||||
├── styles/
|
||||
│ └── main.css
|
||||
├── components/
|
||||
│ └── Chart.js
|
||||
└── utils/
|
||||
└── helpers.js
|
||||
├── .env # FRONTEND_PORT, BACKEND_PORT (NONE by default)
|
||||
├── .env.example # Mirror of .env (LLM-consistency — edit both
|
||||
│ # when you change either)
|
||||
├── run.sh # OpenSwarm's runtime spawns this; you don't
|
||||
├── backend_init.sh # Run this when you need a backend (see below)
|
||||
├── SKILL.md # This document
|
||||
└── frontend/
|
||||
├── package.json # React 18, MUI v7, Redux Toolkit, Framer
|
||||
│ # Motion, react-router v7
|
||||
├── vite.config.ts # Vite config — DO NOT edit unless you know why
|
||||
├── tsconfig.json # `@/*` → `src/*` path alias
|
||||
├── index.html
|
||||
└── src/
|
||||
├── index.tsx # ReactDOM entry; mounts <Main />
|
||||
├── app/
|
||||
│ ├── Main.tsx # Redux + Theme + BrowserRouter + AppShell
|
||||
│ └── components/
|
||||
│ └── Layout/
|
||||
│ ├── AppShell.tsx # Sidebar + scrollable content
|
||||
│ └── Sidebar.tsx # Nav, theme toggle
|
||||
├── pages/ # FILE-BASED ROUTING — see below
|
||||
│ ├── index.tsx # /
|
||||
│ └── health.tsx # /health
|
||||
└── shared/
|
||||
├── hooks.ts # useAppDispatch, useAppSelector
|
||||
├── state/
|
||||
│ ├── store.ts # Redux store config
|
||||
│ ├── tempStateSlice.ts # Sample slice — replace or extend
|
||||
│ └── API_ENDPOINTS.ts # ALL backend URL constants
|
||||
└── styles/
|
||||
└── ThemeContext.tsx # Design tokens — USE THESE
|
||||
```
|
||||
|
||||
Reference from `index.html`:
|
||||
If a backend is enabled (after `bash backend_init.sh`), you'll also have:
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="./styles/main.css">
|
||||
<script type="module" src="./components/Chart.js"></script>
|
||||
```
|
||||
|
||||
ES module imports between JS files:
|
||||
|
||||
```javascript
|
||||
// components/Chart.js
|
||||
import { formatNumber } from '../utils/helpers.js';
|
||||
└── backend/
|
||||
├── pyproject.toml # FastAPI + typeguard (+ swarm_debug)
|
||||
├── main.py # FastAPI app entry — registers SubApps
|
||||
├── apps/ # Each feature is a SubApp
|
||||
│ └── health/
|
||||
│ └── health.py # GET /api/health/check
|
||||
└── config/Apps.py # SubApp / MainApp plugin framework
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using React
|
||||
## File-based routing
|
||||
|
||||
React 18 is available via esm.sh CDN — no build step needed:
|
||||
`vite-plugin-pages` auto-registers every `.tsx` file under `frontend/src/pages/`
|
||||
as a route. **You don't touch any router config.** Just create the file.
|
||||
|
||||
```html
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react": "https://esm.sh/react@18",
|
||||
"react-dom/client": "https://esm.sh/react-dom@18/client"
|
||||
}
|
||||
- `src/pages/index.tsx` → `/`
|
||||
- `src/pages/about.tsx` → `/about`
|
||||
- `src/pages/users/index.tsx` → `/users`
|
||||
- `src/pages/users/[id].tsx` → `/users/:id` (dynamic segment)
|
||||
- `src/pages/users/$id.tsx` → `/users/:id` (alternate dynamic syntax,
|
||||
same plugin)
|
||||
|
||||
Each page is a default-exported React component:
|
||||
|
||||
```tsx
|
||||
// src/pages/about.tsx
|
||||
export default function About() {
|
||||
return <Box sx={{ p: 4 }}>About this app</Box>;
|
||||
}
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script type="module">
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
```
|
||||
|
||||
function App() {
|
||||
const input = window.OUTPUT_INPUT || {};
|
||||
return React.createElement('div', null,
|
||||
React.createElement('h1', null, input.title || 'Hello')
|
||||
Add a sidebar link via `frontend/src/app/components/Layout/Sidebar.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## Styling — MUST use the design token system
|
||||
|
||||
The template ships a complete design system at `frontend/src/shared/styles/ThemeContext.tsx`.
|
||||
Use tokens via the `useClaudeTokens()` hook (or whatever the template exposes — check the file).
|
||||
**Don't hand-roll hex colors or pixel values.**
|
||||
|
||||
Patterns:
|
||||
|
||||
```tsx
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
export default function Card() {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
<Box sx={{
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 2,
|
||||
p: 3,
|
||||
}}>
|
||||
<Typography variant="h2" sx={{ color: c.text.primary }}>
|
||||
Hello
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
React.createElement(App)
|
||||
);
|
||||
</script>
|
||||
```
|
||||
|
||||
Other CDN libraries work too — use `https://esm.sh/` or `https://cdn.jsdelivr.net/npm/` for any npm package.
|
||||
- **Use MUI components** (`Box`, `Typography`, `Button`, `IconButton`, `Tooltip`, `Stack`, etc.) — never write raw `<div>` for layout.
|
||||
- **Use the `sx` prop** for styles, not separate CSS files.
|
||||
- **Don't add Tailwind**, Bootstrap, or any other CSS framework.
|
||||
|
||||
Check `frontend/DESIGN.md` for the complete design system spec.
|
||||
|
||||
---
|
||||
|
||||
## Design guidelines
|
||||
## State management — Redux Toolkit
|
||||
|
||||
- **Dark theme by default** — use dark backgrounds (#0f1117, #1a1d27) with
|
||||
light text (#e2e8f0) unless the user requests otherwise.
|
||||
- **Modern aesthetics** — rounded corners (8-12px), subtle borders, box shadows,
|
||||
smooth transitions (0.15-0.3s ease).
|
||||
- **Responsive** — use flexbox/grid, test at different sizes.
|
||||
- **Typography** — system font stack for UI, monospace for code/data.
|
||||
- **Color accents** — use a single accent color with variations for hover/active states.
|
||||
- **Spacing** — consistent padding (12-20px), adequate whitespace between sections.
|
||||
- **Interactivity** — hover effects, focus states, loading indicators where appropriate.
|
||||
Store is at `frontend/src/shared/state/store.ts`. Add new slices following
|
||||
the `tempStateSlice.ts` pattern (createSlice, named action creators, register
|
||||
the reducer in the store).
|
||||
|
||||
```tsx
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
|
||||
function MyComponent() {
|
||||
const items = useAppSelector(s => s.myFeature.items);
|
||||
const dispatch = useAppDispatch();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
For server data, use plain async thunks (`createAsyncThunk`) or fetch
|
||||
directly inside `useEffect` — no react-query in the template (yet).
|
||||
|
||||
---
|
||||
|
||||
## Complete minimal example
|
||||
## Backend — opt-in, never roll your own
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>My App</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #0f1117;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.card {
|
||||
background: #1a1d27;
|
||||
border: 1px solid #2e3248;
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
}
|
||||
h1 { font-size: 1.5rem; margin-bottom: 8px; }
|
||||
p { color: #8892a4; line-height: 1.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1 id="title">Loading…</h1>
|
||||
<p id="desc"></p>
|
||||
</div>
|
||||
<script>
|
||||
const input = window.OUTPUT_INPUT || {};
|
||||
document.getElementById('title').textContent = input.title || 'Untitled';
|
||||
document.getElementById('desc').textContent = input.description || 'No description provided.';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
The workspace **starts without a backend**. If your app needs server-side
|
||||
code (API endpoints, secrets, server-managed state):
|
||||
|
||||
```bash
|
||||
bash backend_init.sh
|
||||
```
|
||||
|
||||
This script COPIES the canonical backend scaffold (FastAPI + SubApp pattern
|
||||
+ swarm-debug pre-installed) into your workspace, allocates a free port,
|
||||
and flips `BACKEND_PORT` in both `.env` and `.env.example`. Then **hard-
|
||||
reload the preview** (right-click the reload button) so the runtime
|
||||
restarts and brings the backend up.
|
||||
|
||||
**You MUST NOT roll your own backend.** Do not:
|
||||
- Hand-write a `backend/main.py` from scratch.
|
||||
- Use Flask, Django, or any framework other than the FastAPI scaffold
|
||||
the script gives you.
|
||||
- Install your own venv or `pip install` manually.
|
||||
- Edit `backend/run.sh` or the SubApp framework.
|
||||
|
||||
Adding a new endpoint is just adding a new SubApp:
|
||||
|
||||
```python
|
||||
# backend/apps/jobs/jobs.py
|
||||
from contextlib import asynccontextmanager
|
||||
from backend.config.Apps import SubApp
|
||||
from swarm_debug import debug
|
||||
|
||||
@asynccontextmanager
|
||||
async def jobs_lifespan():
|
||||
debug("jobs SubApp lifespan starting")
|
||||
yield
|
||||
|
||||
jobs = SubApp("jobs", jobs_lifespan)
|
||||
|
||||
@jobs.router.get("/list")
|
||||
async def list_jobs():
|
||||
return {"jobs": [...]}
|
||||
```
|
||||
|
||||
Then register it in `backend/main.py`:
|
||||
|
||||
```python
|
||||
from backend.apps.jobs.jobs import jobs
|
||||
main_app = MainApp([health, jobs])
|
||||
```
|
||||
|
||||
Routes are auto-prefixed: `jobs.router.get("/list")` becomes
|
||||
`GET /api/jobs/list` — accessible from the frontend at `fetch('/api/jobs/list')`.
|
||||
|
||||
---
|
||||
|
||||
## Frontend ↔ Backend wiring
|
||||
|
||||
Vite proxies `/api/*` calls from the frontend to the workspace's own
|
||||
backend (on `BACKEND_PORT`). **Always call `/api/...` from frontend code**
|
||||
— never hardcode `localhost:<port>`. The proxy is configured in
|
||||
`vite.config.ts` and reads `BACKEND_PORT` from `.env` automatically.
|
||||
|
||||
```tsx
|
||||
// frontend/src/pages/jobs.tsx
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function Jobs() {
|
||||
const [jobs, setJobs] = useState([]);
|
||||
useEffect(() => {
|
||||
fetch('/api/jobs/list')
|
||||
.then(r => r.json())
|
||||
.then(data => setJobs(data.jobs));
|
||||
}, []);
|
||||
return <>{/* render jobs */}</>;
|
||||
}
|
||||
```
|
||||
|
||||
Keep ALL backend URL paths in `frontend/src/shared/state/API_ENDPOINTS.ts`
|
||||
so refactors are one-file edits:
|
||||
|
||||
```ts
|
||||
export const JOBS_LIST = '/api/jobs/list';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging — use `swarm_debug`, not `print()`
|
||||
|
||||
The backend has `swarm_debug` pre-installed. It's a colored frame-aware
|
||||
logger that lands in the App Builder's **Terminal** tab under `[BACKEND]`.
|
||||
|
||||
```python
|
||||
from swarm_debug import debug
|
||||
|
||||
debug(value) # [endpoint_name] : value = ...
|
||||
debug(a, b, c) # logs all three with labels
|
||||
debug(err) # red + ❌ if variable is an exception
|
||||
```
|
||||
|
||||
See the **swarm-debug Logger** built-in skill (Skills page) for the full
|
||||
reference. `print()` works too but lacks the variable-name inference and
|
||||
colorization.
|
||||
|
||||
Frontend `console.log/warn/error` calls land in the Terminal pane under
|
||||
`[FRONTEND]` via the App Builder's webview-preload bridge. Same chronological
|
||||
stream as `[BACKEND]` lines, so you can correlate cause and effect across
|
||||
the two halves of your stack.
|
||||
|
||||
---
|
||||
|
||||
## Adding npm packages
|
||||
|
||||
Just `npm install <package>` in the workspace's `frontend/` directory.
|
||||
Vite picks it up on the next HMR cycle.
|
||||
|
||||
```bash
|
||||
cd frontend && npm install lodash @types/lodash
|
||||
```
|
||||
|
||||
Then import normally — Vite resolves it.
|
||||
|
||||
Common deps already in the template:
|
||||
- `@mui/material`, `@mui/icons-material` — use these for any UI primitive
|
||||
- `@reduxjs/toolkit`, `react-redux`
|
||||
- `framer-motion` — for animations
|
||||
- `react-router-dom@7`
|
||||
- `vite-plugin-pages` — file-based routing (already configured)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Don't
|
||||
|
||||
- **Don't rename `index.html` or `run.sh`** — the runtime needs both at fixed paths.
|
||||
- **Don't edit `vite.config.ts`** unless you know exactly why. The `/api` proxy and `vite-plugin-pages` config are load-bearing.
|
||||
- **Don't write a standalone HTML file** at the workspace root. There's no longer a `serve/index.html` endpoint for new-mode workspaces — the webview points at Vite's dev server.
|
||||
- **Don't hand-roll a backend**. Use `bash backend_init.sh`.
|
||||
- **Don't bypass MUI** with raw `<div>` + custom CSS. Use `Box`, `Stack`, `sx`.
|
||||
- **Don't hardcode `localhost:<port>`**. Use relative `/api/...` paths so the Vite proxy handles routing.
|
||||
|
||||
---
|
||||
|
||||
## Workflow tips
|
||||
|
||||
- **Edits are auto-saved**. As soon as you write a file via the Edit/Write tool, it's on disk. Vite HMR re-renders the preview within ~100ms.
|
||||
- **Hard Reload (right-click the reload button)** restarts the runtime — useful after you `bash backend_init.sh` or change `.env` values.
|
||||
- **`meta.json`** at workspace root is shown in the OpenSwarm Apps page UI. Update its `name` and `description` when the app's purpose changes.
|
||||
|
||||
---
|
||||
|
||||
## Quick start checklist
|
||||
|
||||
When making a new app from scratch:
|
||||
|
||||
1. Replace `frontend/src/pages/index.tsx` with your home page.
|
||||
2. Add additional pages under `frontend/src/pages/`.
|
||||
3. Add a sidebar nav entry in `frontend/src/app/components/Layout/Sidebar.tsx`.
|
||||
4. Style with `useClaudeTokens()` and MUI's `sx`.
|
||||
5. If you need a backend: `bash backend_init.sh`, then add a SubApp under `backend/apps/<name>/`.
|
||||
6. Update `meta.json` with the app's name + description.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from typing import Optional, Any
|
||||
from typing import Literal, Optional, Any
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
|
||||
@@ -140,6 +140,16 @@ class WorkspaceSeedRequest(BaseModel):
|
||||
workspace_id: str
|
||||
files: Optional[dict[str, str]] = None
|
||||
meta: Optional[dict[str, Any]] = None
|
||||
# "webapp_template" (default) → seed the vendored
|
||||
# openswarm-ai/webapp-template snapshot (React + Vite + TS frontend
|
||||
# with optional FastAPI backend), allocate a free FRONTEND_PORT,
|
||||
# leave BACKEND_PORT=NONE. Runtime spawns `bash run.sh`; preview
|
||||
# pane points at `http://localhost:{FRONTEND_PORT}/`.
|
||||
# "flat" → legacy single-`index.html` workspace, kept for explicit
|
||||
# opt-in (migration helper, regression tests). Workspaces predating
|
||||
# this flip continue to work in old-mode automatically since the
|
||||
# runtime detects mode via the presence of `run.sh`.
|
||||
template_mode: Literal["flat", "webapp_template"] = "webapp_template"
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
||||
@@ -16,7 +16,11 @@ from backend.apps.outputs.models import (
|
||||
VibeCodeRequest, WorkspaceSeedRequest,
|
||||
)
|
||||
from backend.apps.outputs.executor import execute_backend_code
|
||||
from backend.apps.outputs.view_builder_templates import VIEW_TEMPLATE_FILES, load_app_builder_skill
|
||||
from backend.apps.outputs.view_builder_templates import (
|
||||
VIEW_TEMPLATE_FILES,
|
||||
load_app_builder_skill,
|
||||
seed_webapp_template_workspace,
|
||||
)
|
||||
from backend.apps.settings.settings import load_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -311,10 +315,53 @@ async def read_workspace(workspace_id: str):
|
||||
|
||||
@outputs.router.post("/workspace/seed")
|
||||
async def seed_workspace(body: WorkspaceSeedRequest):
|
||||
"""Create a workspace folder and optionally pre-seed it with files."""
|
||||
"""Create a workspace folder and pre-seed it.
|
||||
|
||||
Two seeding modes:
|
||||
|
||||
- **`template_mode="flat"`** (current default): writes the legacy
|
||||
VIEW_TEMPLATE_FILES (single index.html + meta.json + schema.json).
|
||||
Used by every workspace created so far. Runtime spawns
|
||||
`python -u backend.py` (if present) and the preview pane fetches
|
||||
from `/api/outputs/workspace/{ws}/serve/...`.
|
||||
|
||||
- **`template_mode="webapp_template"`**: copies the vendored
|
||||
openswarm-ai/webapp-template snapshot (React + Vite + TS frontend
|
||||
with an optional FastAPI backend) into the workspace, allocates a
|
||||
free FRONTEND_PORT and writes it into both `.env` and
|
||||
`.env.example`. BACKEND_PORT stays NONE — the agent opts in with
|
||||
`bash backend_init.sh`. Runtime spawn flips to `bash run.sh` and
|
||||
the preview pane points at `http://localhost:{FRONTEND_PORT}/`.
|
||||
`body.files` is ignored in this mode; the snapshot is the source
|
||||
of truth.
|
||||
"""
|
||||
folder = os.path.join(WORKSPACE_DIR, body.workspace_id)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
|
||||
# An explicit non-empty `files` payload means the caller has flat-mode
|
||||
# content to write (a saved legacy Output being reseeded). Don't
|
||||
# clobber that with the React template even if template_mode is the
|
||||
# new default — the migration helper has its own path for that.
|
||||
effective_mode = body.template_mode
|
||||
if body.files:
|
||||
effective_mode = "flat"
|
||||
|
||||
if effective_mode == "webapp_template":
|
||||
# Defer to the helper — copytree, env scaffolding, install paths.
|
||||
from backend.apps.outputs.runtime import _find_free_port
|
||||
frontend_port = _find_free_port()
|
||||
seed_webapp_template_workspace(folder, frontend_port)
|
||||
# SKILL.md still goes in workspace root — agent reads it for
|
||||
# context. Live content (user-editable via Skills page) is
|
||||
# injected into the system prompt regardless.
|
||||
with open(os.path.join(folder, "SKILL.md"), "w") as f:
|
||||
f.write(load_app_builder_skill())
|
||||
if body.meta:
|
||||
with open(os.path.join(folder, "meta.json"), "w") as f:
|
||||
json.dump(body.meta, f, indent=2)
|
||||
return {"path": os.path.abspath(folder), "template_mode": "webapp_template", "frontend_port": frontend_port}
|
||||
|
||||
# Legacy flat path — unchanged.
|
||||
if body.files:
|
||||
for rel_path, content in body.files.items():
|
||||
full_path = os.path.normpath(os.path.join(folder, rel_path))
|
||||
@@ -342,7 +389,7 @@ async def seed_workspace(body: WorkspaceSeedRequest):
|
||||
with open(os.path.join(folder, "meta.json"), "w") as f:
|
||||
json.dump(body.meta, f, indent=2)
|
||||
|
||||
return {"path": os.path.abspath(folder)}
|
||||
return {"path": os.path.abspath(folder), "template_mode": "flat"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -356,12 +403,29 @@ def _runtime_status_payload(workspace_id: str) -> dict:
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
rt = runtime_manager.get(workspace_id)
|
||||
if not rt:
|
||||
return {"running": False, "port": None, "has_backend_file": False, "backend_url": None}
|
||||
return {
|
||||
"running": False,
|
||||
"port": None,
|
||||
"has_backend_file": False,
|
||||
"backend_url": None,
|
||||
"frontend_port": None,
|
||||
"frontend_url": None,
|
||||
"is_new_mode": False,
|
||||
}
|
||||
return {
|
||||
"running": rt.running,
|
||||
"port": rt.port,
|
||||
"has_backend_file": rt.has_backend_file,
|
||||
# For old-mode: backend.py serves; backend_url is its port. For
|
||||
# new-mode: backend.py is optional (gated by BACKEND_PORT!=NONE);
|
||||
# only populated if the agent ran bash backend_init.sh.
|
||||
"backend_url": f"http://127.0.0.1:{rt.port}" if rt.running and rt.port else None,
|
||||
# New-mode only: where the Vite dev server is reachable.
|
||||
# Old-mode workspaces report null and the editor falls back to
|
||||
# the legacy /api/outputs/workspace/{ws}/serve/... path.
|
||||
"frontend_port": rt.frontend_port,
|
||||
"frontend_url": rt.frontend_url if rt.running else None,
|
||||
"is_new_mode": rt.is_new_mode,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+251
-35
@@ -35,6 +35,16 @@ _LOG_BUFFER_LINES = 2000
|
||||
# workspace tear-down forever.
|
||||
_TERMINATE_GRACE_SECONDS = 3
|
||||
|
||||
# How long we'll wait for Vite (or whatever frontend server bash run.sh
|
||||
# spawns) to bind on FRONTEND_PORT before giving up and reporting the
|
||||
# frontend as "not ready." Covers cold-start `npm install` (~60-90s on
|
||||
# typical hardware for the template's dependency set) plus the Vite
|
||||
# bind itself. After this we keep the runtime running — the user can
|
||||
# check the Terminal pane to see what went wrong — but stop blocking
|
||||
# the preview pane on a port that may never come up.
|
||||
_FRONTEND_BIND_TIMEOUT_SECONDS = 180
|
||||
_FRONTEND_BIND_POLL_INTERVAL = 0.5
|
||||
|
||||
|
||||
def _find_free_port() -> int:
|
||||
"""Ask the kernel for an unused localhost port. There's a tiny race
|
||||
@@ -46,6 +56,48 @@ def _find_free_port() -> int:
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _is_new_mode(workspace_path: str) -> bool:
|
||||
"""A workspace is "new-mode" (webapp-template scaffold) if it has a
|
||||
`run.sh` at its root. Old-mode workspaces are flat `index.html`-only
|
||||
apps that pre-date the template swap — they're served by OpenSwarm's
|
||||
own `/api/outputs/workspace/{ws}/serve/...` FastAPI route and have an
|
||||
optional `backend.py` we spawn directly.
|
||||
|
||||
Single-file probe so the check is cheap to call on every runtime
|
||||
start, status query, and serve request."""
|
||||
return os.path.isfile(os.path.join(workspace_path, "run.sh"))
|
||||
|
||||
|
||||
def _read_env_value(env_path: str, key: str) -> Optional[str]:
|
||||
"""Parse one value out of a workspace's `.env` without the cost of a
|
||||
full subprocess-source. Strips quotes + trailing comments. Returns
|
||||
None if the file or key is missing."""
|
||||
if not os.path.exists(env_path):
|
||||
return None
|
||||
try:
|
||||
with open(env_path, encoding="utf-8") as f:
|
||||
for raw in f:
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
k, _, v = line.partition("=")
|
||||
if k.strip() != key:
|
||||
continue
|
||||
v = v.strip()
|
||||
# Strip an inline `# comment`. Naive — bash semantics are
|
||||
# more permissive, but values we write don't contain `#`.
|
||||
if "#" in v:
|
||||
v = v.split("#", 1)[0].rstrip()
|
||||
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
|
||||
v = v[1:-1]
|
||||
return v
|
||||
except Exception:
|
||||
logger.exception("failed reading %s from %s", key, env_path)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogLine:
|
||||
stream: str # "stdout" | "stderr" | "runtime" (internal status lines)
|
||||
@@ -70,13 +122,25 @@ class AppRuntime:
|
||||
def __init__(self, workspace_id: str, workspace_path: str):
|
||||
self.workspace_id = workspace_id
|
||||
self.workspace_path = workspace_path
|
||||
# Old-mode: `port` is the backend.py port. New-mode: `port` is
|
||||
# the workspace's optional FastAPI backend (only set if
|
||||
# BACKEND_PORT!=NONE) and `frontend_port` is the Vite dev
|
||||
# server port. Both Nones until start() decides what's there.
|
||||
self.port: Optional[int] = None
|
||||
self.frontend_port: Optional[int] = None
|
||||
# New-mode only: flips True once something is actually listening
|
||||
# on frontend_port (we kick off a background poll task in
|
||||
# _start_new_mode). frontend_url returns null until this flips,
|
||||
# so the preview pane doesn't try to navigate to an unbound port
|
||||
# and show a "Site can't be reached" error mid-npm-install.
|
||||
self._frontend_ready: bool = False
|
||||
self.process: Optional[asyncio.subprocess.Process] = None
|
||||
self.log_buffer: deque[LogLine] = deque(maxlen=_LOG_BUFFER_LINES)
|
||||
self._subscribers: set[LogSubscriber] = set()
|
||||
self._stdout_task: Optional[asyncio.Task] = None
|
||||
self._stderr_task: Optional[asyncio.Task] = None
|
||||
self._wait_task: Optional[asyncio.Task] = None
|
||||
self._frontend_ready_task: Optional[asyncio.Task] = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
@@ -87,51 +151,198 @@ class AppRuntime:
|
||||
def has_backend_file(self) -> bool:
|
||||
return os.path.exists(os.path.join(self.workspace_path, "backend.py"))
|
||||
|
||||
@property
|
||||
def is_new_mode(self) -> bool:
|
||||
return _is_new_mode(self.workspace_path)
|
||||
|
||||
@property
|
||||
def frontend_url(self) -> Optional[str]:
|
||||
# Gated on `_frontend_ready` (set by the background bind-poll
|
||||
# task in _start_new_mode) so the preview pane only switches
|
||||
# over once Vite is actually accepting connections. Without
|
||||
# this, the editor flashes a "Site can't be reached" error
|
||||
# while `npm install` is running.
|
||||
if self.frontend_port and self._frontend_ready:
|
||||
return f"http://127.0.0.1:{self.frontend_port}/"
|
||||
return None
|
||||
|
||||
async def start(self) -> bool:
|
||||
"""Spawn backend.py if it exists. Returns True if a process is
|
||||
running after this call. False means the workspace has no
|
||||
backend.py (legitimate — pure-frontend apps) or the spawn failed
|
||||
(an error line is emitted to the log buffer in that case)."""
|
||||
"""Spawn the workspace's runtime. Branches on mode:
|
||||
|
||||
- **New-mode** (`run.sh` at workspace root): spawn `bash run.sh`,
|
||||
which reads `.env` for FRONTEND_PORT / BACKEND_PORT and boots
|
||||
Vite (+ optional FastAPI). We just pre-read the env so the
|
||||
status payload + preview-URL branching has them available
|
||||
without waiting for the subprocess to print anything.
|
||||
|
||||
- **Old-mode** (no `run.sh`): spawn `python -u backend.py` if
|
||||
present, with `PORT` env var. This is the legacy path —
|
||||
unchanged so flat-index.html apps keep working.
|
||||
|
||||
Returns True if a process is running after this call. False is
|
||||
legitimate for old-mode workspaces with no backend.py (pure
|
||||
frontend served by `/api/outputs/.../serve/`); the runtime still
|
||||
exists so the Terminal pane can host `[FRONTEND]` lines.
|
||||
"""
|
||||
async with self._lock:
|
||||
if self.running:
|
||||
return True
|
||||
if not self.has_backend_file:
|
||||
self.port = None
|
||||
return False
|
||||
self.port = _find_free_port()
|
||||
# Strip the install token before handing env to user code.
|
||||
# Backend.py can hit our REST API back via its own creds if
|
||||
# it really needs to, but it shouldn't inherit the host
|
||||
# process's token by default.
|
||||
env = {k: v for k, v in os.environ.items() if k != "OPENSWARM_AUTH_TOKEN"}
|
||||
env["PORT"] = str(self.port)
|
||||
env["BACKEND_PORT"] = str(self.port) # alias — both common names work
|
||||
|
||||
if self.is_new_mode:
|
||||
return await self._start_new_mode()
|
||||
return await self._start_old_mode()
|
||||
|
||||
async def _start_new_mode(self) -> bool:
|
||||
env_path = os.path.join(self.workspace_path, ".env")
|
||||
fp_raw = _read_env_value(env_path, "FRONTEND_PORT")
|
||||
bp_raw = _read_env_value(env_path, "BACKEND_PORT")
|
||||
# FRONTEND_PORT is allocated by seed_workspace; should always be
|
||||
# a number. If missing, log + fall back to a fresh allocation —
|
||||
# rare edge case (workspace seeded by an older OpenSwarm).
|
||||
try:
|
||||
self.frontend_port = int(fp_raw) if fp_raw else _find_free_port()
|
||||
except ValueError:
|
||||
self.frontend_port = _find_free_port()
|
||||
# BACKEND_PORT may be the literal string "NONE" (frontend-only
|
||||
# app — the common case) or a number once `backend_init.sh` has
|
||||
# run. Only populate self.port when there's a real backend.
|
||||
if bp_raw and bp_raw != "NONE":
|
||||
try:
|
||||
# -u forces unbuffered stdout/stderr so the Terminal pane
|
||||
# sees lines in real time, not whenever Python decides to
|
||||
# flush its block buffer.
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
sys.executable, "-u", "backend.py",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=self.workspace_path,
|
||||
env=env,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("failed to start backend for %s", self.workspace_id)
|
||||
self._broadcast(LogLine("runtime", f"[runtime] failed to start: {e}"))
|
||||
self.port = int(bp_raw)
|
||||
except ValueError:
|
||||
self.port = None
|
||||
self.process = None
|
||||
return False
|
||||
self._broadcast(LogLine("runtime", f"[runtime] backend started on port {self.port} (pid {self.process.pid})"))
|
||||
self._stdout_task = asyncio.create_task(self._pipe_stream(self.process.stdout, "stdout"))
|
||||
self._stderr_task = asyncio.create_task(self._pipe_stream(self.process.stderr, "stderr"))
|
||||
self._wait_task = asyncio.create_task(self._await_exit())
|
||||
return True
|
||||
else:
|
||||
self.port = None
|
||||
|
||||
env = self._spawn_env_base()
|
||||
# bash run.sh reads .env itself; we don't need to set
|
||||
# FRONTEND_PORT / BACKEND_PORT here. We DO export the install
|
||||
# paths so the template's `backend/run.sh` can find our
|
||||
# debugger to satisfy its `from swarm_debug import debug`.
|
||||
# (Also written into .env at seed time, but env-var path is
|
||||
# the more reliable read site for subshells.)
|
||||
# NOTE: keep these in sync with seed_webapp_template_workspace.
|
||||
from backend.apps.outputs.view_builder_templates import (
|
||||
_DEBUGGER_PATH,
|
||||
_TEMPLATE_BACKEND_PATH,
|
||||
)
|
||||
env["OPENSWARM_DEBUGGER_PATH"] = _DEBUGGER_PATH
|
||||
env["OPENSWARM_TEMPLATE_BACKEND_PATH"] = _TEMPLATE_BACKEND_PATH
|
||||
|
||||
try:
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
"bash", "run.sh",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=self.workspace_path,
|
||||
env=env,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("failed to start new-mode runtime for %s", self.workspace_id)
|
||||
self._broadcast(LogLine("runtime", f"[runtime] failed to start: {e}"))
|
||||
self.frontend_port = None
|
||||
self.port = None
|
||||
self.process = None
|
||||
return False
|
||||
backend_note = f" + backend on {self.port}" if self.port else ""
|
||||
self._broadcast(LogLine("runtime", f"[runtime] bash run.sh started — frontend on {self.frontend_port}{backend_note} (pid {self.process.pid})"))
|
||||
self._stdout_task = asyncio.create_task(self._pipe_stream(self.process.stdout, "stdout"))
|
||||
self._stderr_task = asyncio.create_task(self._pipe_stream(self.process.stderr, "stderr"))
|
||||
self._wait_task = asyncio.create_task(self._await_exit())
|
||||
# Kick off the port-bind poller so frontend_url flips on once
|
||||
# Vite is actually accepting connections.
|
||||
self._frontend_ready = False
|
||||
self._frontend_ready_task = asyncio.create_task(self._await_frontend_bind())
|
||||
return True
|
||||
|
||||
async def _await_frontend_bind(self) -> None:
|
||||
"""Poll `frontend_port` every _FRONTEND_BIND_POLL_INTERVAL until
|
||||
something binds (Vite dev server) or we hit the timeout. Emits a
|
||||
`[runtime]` log line on success/failure so the Terminal pane
|
||||
shows the transition; flips `_frontend_ready` which the
|
||||
`frontend_url` property reads."""
|
||||
if not self.frontend_port:
|
||||
return
|
||||
port = self.frontend_port
|
||||
deadline = asyncio.get_event_loop().time() + _FRONTEND_BIND_TIMEOUT_SECONDS
|
||||
while asyncio.get_event_loop().time() < deadline:
|
||||
# Stop polling if the process died — pointless to keep
|
||||
# checking a port nothing will bind.
|
||||
if self.process is None or self.process.returncode is not None:
|
||||
return
|
||||
try:
|
||||
# asyncio.open_connection is the non-blocking equivalent
|
||||
# of socket.create_connection. 0.5s connect timeout to
|
||||
# avoid hanging if the host's TCP stack is under load.
|
||||
fut = asyncio.open_connection("127.0.0.1", port)
|
||||
reader, writer = await asyncio.wait_for(fut, timeout=0.5)
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
self._frontend_ready = True
|
||||
self._broadcast(LogLine(
|
||||
"runtime",
|
||||
f"[runtime] frontend ready at http://127.0.0.1:{port}/",
|
||||
))
|
||||
return
|
||||
except (OSError, asyncio.TimeoutError):
|
||||
pass
|
||||
await asyncio.sleep(_FRONTEND_BIND_POLL_INTERVAL)
|
||||
# Timed out — keep the runtime up (Terminal might show useful
|
||||
# errors) but surface why the preview never appeared.
|
||||
self._broadcast(LogLine(
|
||||
"runtime",
|
||||
f"[runtime] frontend did NOT bind on port {port} after "
|
||||
f"{_FRONTEND_BIND_TIMEOUT_SECONDS}s — check the Terminal "
|
||||
f"for npm/vite errors.",
|
||||
))
|
||||
|
||||
async def _start_old_mode(self) -> bool:
|
||||
if not self.has_backend_file:
|
||||
self.port = None
|
||||
return False
|
||||
self.port = _find_free_port()
|
||||
env = self._spawn_env_base()
|
||||
env["PORT"] = str(self.port)
|
||||
env["BACKEND_PORT"] = str(self.port) # alias — both common names work
|
||||
try:
|
||||
# -u forces unbuffered stdout/stderr so the Terminal pane
|
||||
# sees lines in real time, not whenever Python decides to
|
||||
# flush its block buffer.
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
sys.executable, "-u", "backend.py",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=self.workspace_path,
|
||||
env=env,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("failed to start backend for %s", self.workspace_id)
|
||||
self._broadcast(LogLine("runtime", f"[runtime] failed to start: {e}"))
|
||||
self.port = None
|
||||
self.process = None
|
||||
return False
|
||||
self._broadcast(LogLine("runtime", f"[runtime] backend started on port {self.port} (pid {self.process.pid})"))
|
||||
self._stdout_task = asyncio.create_task(self._pipe_stream(self.process.stdout, "stdout"))
|
||||
self._stderr_task = asyncio.create_task(self._pipe_stream(self.process.stderr, "stderr"))
|
||||
self._wait_task = asyncio.create_task(self._await_exit())
|
||||
return True
|
||||
|
||||
def _spawn_env_base(self) -> dict[str, str]:
|
||||
"""Inherited env minus the install token. Backend.py can hit our
|
||||
REST API back via its own creds if it really needs to, but it
|
||||
shouldn't inherit the host process's token by default."""
|
||||
return {k: v for k, v in os.environ.items() if k != "OPENSWARM_AUTH_TOKEN"}
|
||||
|
||||
async def stop(self) -> None:
|
||||
async with self._lock:
|
||||
if not self.process or self.process.returncode is not None:
|
||||
# Still cancel the bind poller in case stop() races a
|
||||
# never-launched runtime — defensive no-op otherwise.
|
||||
if self._frontend_ready_task and not self._frontend_ready_task.done():
|
||||
self._frontend_ready_task.cancel()
|
||||
return
|
||||
try:
|
||||
self.process.terminate()
|
||||
@@ -142,6 +353,11 @@ class AppRuntime:
|
||||
await self.process.wait()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
# Cancel the bind poller so it stops scanning a port that's
|
||||
# gone away, and reset the readiness flag.
|
||||
if self._frontend_ready_task and not self._frontend_ready_task.done():
|
||||
self._frontend_ready_task.cancel()
|
||||
self._frontend_ready = False
|
||||
|
||||
async def restart(self) -> bool:
|
||||
await self.stop()
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# swarm-debug — OpenSwarm's logger for App backends
|
||||
|
||||
`swarm_debug` (also importable as `debug` for legacy reasons) is OpenSwarm's
|
||||
opinionated `print()` replacement for the App Builder's backend code. It
|
||||
prints colored, indented, frame-aware log lines that read at a glance and
|
||||
land in the App Builder's **Terminal** tab under the `[BACKEND]` prefix.
|
||||
|
||||
It's pre-installed in every App Builder workspace that has a backend (i.e.
|
||||
after `bash backend_init.sh`). Use it instead of `print()`.
|
||||
|
||||
---
|
||||
|
||||
## Basic usage
|
||||
|
||||
```python
|
||||
from swarm_debug import debug
|
||||
|
||||
debug("hello") # [endpoint_name] : hello
|
||||
debug({"user_id": 42, "ok": True}) # [endpoint_name] : {'user_id': 42, 'ok': True}
|
||||
debug(some_dataframe) # auto-truncated to 3000 chars
|
||||
```
|
||||
|
||||
The function reads the **calling line of source code** to extract the
|
||||
variable name(s), so:
|
||||
|
||||
```python
|
||||
result = compute_thing(input_data)
|
||||
debug(result)
|
||||
# prints: [my_endpoint] : result = {'sum': 42, 'rows': [...]}
|
||||
```
|
||||
|
||||
Variable name is inferred from the AST of the line that called `debug()`. If
|
||||
you pass a literal (string, number, raw dict), it just prints the value.
|
||||
|
||||
---
|
||||
|
||||
## Multiple args
|
||||
|
||||
Pass several values in one call — each is labeled separately:
|
||||
|
||||
```python
|
||||
debug(user_id, request.body, response.status_code)
|
||||
# [endpoint_name] : user_id = 42
|
||||
# [endpoint_name] : request.body = {...}
|
||||
# [endpoint_name] : response.status_code = 200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Calling from inside a class
|
||||
|
||||
`swarm_debug` introspects the caller's frame, so methods on a class print
|
||||
under `ClassName.method` instead of just `method`:
|
||||
|
||||
```python
|
||||
class JobService:
|
||||
def fetch(self, query):
|
||||
debug(query)
|
||||
# [JobService.fetch] : query = "machine learning engineer"
|
||||
```
|
||||
|
||||
Indentation also scales with the call's lexical depth so nested-loop
|
||||
debugging stays readable:
|
||||
|
||||
```python
|
||||
for batch in batches:
|
||||
debug(batch.id) # |\t[fn] : batch.id = 1
|
||||
for item in batch.items:
|
||||
debug(item) # |\t |-- [fn] : item = {...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error highlighting
|
||||
|
||||
Pass an `Exception` (or a name containing `err`/`error`) and `swarm_debug`
|
||||
flips the color to red + prefixes a ❌ emoji so the error stands out in the
|
||||
Terminal pane:
|
||||
|
||||
```python
|
||||
try:
|
||||
risky_thing()
|
||||
except Exception as err:
|
||||
debug(err)
|
||||
# ❌ [endpoint_name] : err = ValueError("bad input")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Long values are truncated
|
||||
|
||||
By default `swarm_debug` cuts values longer than 3000 characters with a
|
||||
`…\n…` separator in the middle. Override per-call:
|
||||
|
||||
```python
|
||||
debug(huge_payload, override_max_chars=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modes (custom log levels)
|
||||
|
||||
`debug` accepts a `mode` kwarg that maps to a configurable log channel.
|
||||
Default is `'debug'`. The Terminal pane shows all modes; if you want to
|
||||
hide a category, configure it in `Debugleton` (see `debugger_backend/`).
|
||||
|
||||
```python
|
||||
debug(payload, mode='info')
|
||||
debug(suspicious_input, mode='warning')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When to use `print()` instead
|
||||
|
||||
Don't. `swarm_debug.debug()` IS the answer for backend logging. Reasons:
|
||||
|
||||
- `print()` doesn't show the variable name or class/function context.
|
||||
- `print()` doesn't truncate huge payloads.
|
||||
- `print()` doesn't color-code errors.
|
||||
- `print()` writes raw stdout, which is harder to scan in the Terminal
|
||||
pane when the agent is also running and producing output.
|
||||
|
||||
`print()` is fine for human-only one-off scripts the agent runs via Bash —
|
||||
not for endpoint code.
|
||||
|
||||
---
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- **Don't call `debug()` inside a hot loop** without a guard — every call
|
||||
reads the source file to extract variable names, which gets expensive
|
||||
at 10k+ iterations.
|
||||
- **Don't pass functions / class objects** expecting useful output —
|
||||
you'll get something like `<function compute at 0x10a...>`. Call the
|
||||
function or stringify intentionally.
|
||||
- **Don't rely on `debug()` for production logging.** It's a development
|
||||
aid. For structured server logs that survive past the App Builder
|
||||
session, use `logging` from the standard library.
|
||||
|
||||
---
|
||||
|
||||
## How it lands in the Terminal tab
|
||||
|
||||
Every line `debug()` prints goes to stdout/stderr of your backend
|
||||
subprocess. The App Builder's runtime captures both streams, prefixes each
|
||||
line with `[BACKEND]`, and streams them via WebSocket into the Terminal
|
||||
pane in real time. Frontend `console.log` calls in the running app land in
|
||||
the same Terminal pane prefixed `[FRONTEND]`. Use this to correlate cause
|
||||
and effect across the two halves of your stack.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Want to… | Do this |
|
||||
|---|---|
|
||||
| Log a value with auto-inferred name | `debug(value)` |
|
||||
| Log several values in one call | `debug(a, b, c)` |
|
||||
| Log an exception with red coloring | `debug(err)` (variable name must contain "err" or "error", or pass an `Exception` instance) |
|
||||
| Avoid truncation | `debug(value, override_max_chars=True)` |
|
||||
| Use a different log channel | `debug(value, mode='info')` |
|
||||
| Same thing, legacy import | `import debug; debug(value)` (function and module share the name — see swarm_debug.py shim) |
|
||||
@@ -1,12 +1,25 @@
|
||||
"""Default template files seeded into new App Builder workspaces."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
|
||||
# Absolute path to the bundled skill source. Surfaced as a constant so the
|
||||
# skills subsystem can register it as a built-in skill (copy into
|
||||
# ~/.claude/skills/ on first boot) without re-deriving the path.
|
||||
APP_BUILDER_SKILL_SOURCE_PATH = os.path.join(os.path.dirname(__file__), "app_builder_skill.md")
|
||||
|
||||
# Second built-in skill: documentation for `swarm-debug`, the colored
|
||||
# frame-aware logger pre-installed in every webapp-template workspace's
|
||||
# backend. Registered the same way as the App Builder skill.
|
||||
SWARM_DEBUG_SKILL_SOURCE_PATH = os.path.join(os.path.dirname(__file__), "swarm_debug_skill.md")
|
||||
|
||||
# Root of the vendored openswarm-ai/webapp-template snapshot. seed_workspace
|
||||
# copytrees this into new-mode workspaces (excluding backend/, which gets
|
||||
# brought in on-demand by the workspace's own backend_init.sh). See
|
||||
# scripts/fetch-webapp-template.sh for the snapshot fetch + patches.
|
||||
WEBAPP_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "webapp_template")
|
||||
|
||||
# Bundled default — used as the read-once fallback if the user-editable
|
||||
# copy at ~/.claude/skills/app_builder_skill.md has been removed despite
|
||||
# the built-in flag (defensive; shouldn't happen in normal use).
|
||||
@@ -100,3 +113,91 @@ VIEW_TEMPLATE_FILES = {
|
||||
"schema.json": VIEW_TEMPLATE_SCHEMA,
|
||||
"meta.json": VIEW_TEMPLATE_META,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# webapp_template (new-mode) seed helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ignore_backend(src: str, names: list[str]) -> list[str]:
|
||||
"""copytree filter — when copying the template root, drop only the
|
||||
top-level `backend/` directory. Subdirectories named `backend` deeper
|
||||
in the tree (none today, but defensively scoped) are unaffected."""
|
||||
if os.path.abspath(src) == os.path.abspath(WEBAPP_TEMPLATE_DIR):
|
||||
return [n for n in names if n == "backend"]
|
||||
return []
|
||||
|
||||
|
||||
_DEBUGGER_PATH = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "..", "debugger")
|
||||
)
|
||||
_TEMPLATE_BACKEND_PATH = os.path.abspath(os.path.join(WEBAPP_TEMPLATE_DIR, "backend"))
|
||||
|
||||
|
||||
def _patch_env_port(env_path: str, key: str, value: str) -> None:
|
||||
"""Idempotent in-place rewrite: `KEY=...` → `KEY=value`. Appends if
|
||||
the key isn't present. Preserves surrounding lines untouched."""
|
||||
if not os.path.exists(env_path):
|
||||
return
|
||||
with open(env_path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
pat = re.compile(rf"^{re.escape(key)}=.*$", re.MULTILINE)
|
||||
new_line = f"{key}={value}"
|
||||
if pat.search(text):
|
||||
text = pat.sub(new_line, text)
|
||||
else:
|
||||
if text and not text.endswith("\n"):
|
||||
text += "\n"
|
||||
text += new_line + "\n"
|
||||
with open(env_path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
def seed_webapp_template_workspace(workspace_dir: str, frontend_port: int) -> None:
|
||||
"""Copy the vendored webapp-template snapshot into `workspace_dir`,
|
||||
excluding the master template's `backend/` (brought in on-demand by
|
||||
the workspace's own `backend_init.sh`). Then:
|
||||
|
||||
1. Copy `.env.example` → `.env` verbatim (preserves the upstream
|
||||
defaults `FRONTEND_PORT=4949` and `BACKEND_PORT=NONE`).
|
||||
2. Sed both `.env` and `.env.example` to set `FRONTEND_PORT=<port>`.
|
||||
BACKEND_PORT stays NONE in both (per spec — the agent flips it
|
||||
via backend_init.sh when it needs a backend).
|
||||
3. Append two install-specific paths to `.env` ONLY (NOT
|
||||
`.env.example` — these are absolute paths on the current
|
||||
machine, not template defaults):
|
||||
OPENSWARM_TEMPLATE_BACKEND_PATH=<abs path to master template's backend/>
|
||||
OPENSWARM_DEBUGGER_PATH=<abs path to OpenSwarm's debugger/ package>
|
||||
The first is read by `backend_init.sh`; the second is read by
|
||||
the template's `backend/run.sh` to install our local debugger
|
||||
before `pip install -e .`.
|
||||
|
||||
Idempotent within reason — re-running over an existing workspace
|
||||
overwrites template files and re-asserts the env values.
|
||||
"""
|
||||
os.makedirs(workspace_dir, exist_ok=True)
|
||||
shutil.copytree(
|
||||
WEBAPP_TEMPLATE_DIR,
|
||||
workspace_dir,
|
||||
ignore=_ignore_backend,
|
||||
dirs_exist_ok=True,
|
||||
)
|
||||
env_path = os.path.join(workspace_dir, ".env")
|
||||
env_example_path = os.path.join(workspace_dir, ".env.example")
|
||||
src_example = os.path.join(WEBAPP_TEMPLATE_DIR, ".env.example")
|
||||
if os.path.exists(src_example):
|
||||
shutil.copyfile(src_example, env_path)
|
||||
|
||||
_patch_env_port(env_path, "FRONTEND_PORT", str(frontend_port))
|
||||
_patch_env_port(env_example_path, "FRONTEND_PORT", str(frontend_port))
|
||||
|
||||
# Install-specific paths — .env only.
|
||||
_patch_env_port(env_path, "OPENSWARM_TEMPLATE_BACKEND_PATH", _TEMPLATE_BACKEND_PATH)
|
||||
_patch_env_port(env_path, "OPENSWARM_DEBUGGER_PATH", _DEBUGGER_PATH)
|
||||
|
||||
# Make the shipped scripts executable. tarball/git extracts may strip
|
||||
# the +x bit depending on how the snapshot was vendored.
|
||||
for script in ("run.sh", "backend_init.sh", "frontend/run.sh"):
|
||||
p = os.path.join(workspace_dir, script)
|
||||
if os.path.exists(p):
|
||||
os.chmod(p, 0o755)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
BACKEND_PORT=NONE # backend port (if set to NONE, the backend will not be run by the run.sh or referenced in the frontend)
|
||||
FRONTEND_PORT=4949
|
||||
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
.env
|
||||
node_modules/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
dist/
|
||||
build/
|
||||
@@ -0,0 +1,23 @@
|
||||
# ignore all py cache files
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.pyw
|
||||
*.pyz
|
||||
*.pywz
|
||||
*.pyzw
|
||||
*.pyzwz
|
||||
*.pyzwzw
|
||||
|
||||
# ignore everything in apps/db/snips (except .gitignore)
|
||||
apps/db/snips/*
|
||||
!apps/db/snips/.gitkeep
|
||||
|
||||
# ignore .venv
|
||||
.venv/
|
||||
|
||||
# ignore build artifacts
|
||||
*.egg-info/
|
||||
build/
|
||||
openswarm_backend.egg-info/
|
||||
@@ -0,0 +1,33 @@
|
||||
from backend.config.Apps import SubApp
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from typeguard import typechecked
|
||||
from fastapi import status
|
||||
from swarm_debug import debug
|
||||
|
||||
@asynccontextmanager
|
||||
async def health_lifespan():
|
||||
debug("health_lifespan START")
|
||||
yield
|
||||
debug("health_lifespan END")
|
||||
|
||||
health = SubApp("health", health_lifespan)
|
||||
|
||||
######################################
|
||||
# Health Check Endpoints #
|
||||
######################################
|
||||
|
||||
@health.router.get("/check")
|
||||
@typechecked
|
||||
async def check() -> PlainTextResponse:
|
||||
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(
|
||||
content="OK",
|
||||
status_code=status.HTTP_200_OK,
|
||||
headers={
|
||||
"Content-Type": "text/plain",
|
||||
"Content-Length": "2"
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
from fastapi import FastAPI, APIRouter
|
||||
from uuid import uuid4
|
||||
from typing import List
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Callable
|
||||
from swarm_debug import debug
|
||||
import os
|
||||
|
||||
class SubApp:
|
||||
def __init__(self, name:str, lifespan:Callable):
|
||||
debug("SubApp.__init__ START: %s", name)
|
||||
self.id = uuid4()
|
||||
self.name = name
|
||||
self.prefix = f"/api/{name}"
|
||||
self.lifespan = lifespan
|
||||
self.router = APIRouter()
|
||||
debug("SubApp.__init__ 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")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with AsyncExitStack() as stack:
|
||||
for sub_app in sub_apps:
|
||||
debug("Starting lifespan for sub_app: %s", sub_app.name)
|
||||
await stack.enter_async_context(sub_app.lifespan())
|
||||
debug(f"Check out the API docs at: http://127.0.0.1:{os.environ.get('BACKEND_PORT', 8324)}/docs")
|
||||
yield
|
||||
|
||||
self.app = FastAPI(lifespan=lifespan)
|
||||
|
||||
for sub_app in sub_apps:
|
||||
self.app.include_router(
|
||||
sub_app.router,
|
||||
prefix=sub_app.prefix,
|
||||
tags=[sub_app.name]
|
||||
)
|
||||
debug("END")
|
||||
@@ -0,0 +1,20 @@
|
||||
from backend.config.Apps import MainApp
|
||||
from backend.apps.health.health import health
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
main_app = MainApp([health])
|
||||
app = main_app.app
|
||||
|
||||
# Add CORS middleware - allow all origins for development
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import uvicorn
|
||||
uvicorn.run("backend.main:app", host="0.0.0.0", port=int(os.environ.get("BACKEND_PORT", 8324)), reload=True)
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
name = "openswarm-backend"
|
||||
version = "0.1.0"
|
||||
description = "OpenSwarm web app backend — FastAPI + SubApp plugin pattern"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi[standard]",
|
||||
"typeguard==4.4.2",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
packages = []
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
# The comment above is shebang, DO NOT REMOVE
|
||||
RUN_BACKEND_ABSPATH="$(readlink -f "${BASH_SOURCE[0]}")"
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# echo "In macOS server sed START"
|
||||
# echo "SERVER_ABSPATH: $SERVER_ABSPATH"
|
||||
sed -i '' 's/\r//g' "$RUN_BACKEND_ABSPATH"
|
||||
# echo "In macOS server sed END"
|
||||
else
|
||||
# echo "NOT in macOS server START"
|
||||
# echo "SERVER_ABSPATH: $SERVER_ABSPATH"
|
||||
sed -i 's/\r//g' "$RUN_BACKEND_ABSPATH"
|
||||
# echo "NOT in macOS server START"
|
||||
fi
|
||||
chmod +x "$RUN_BACKEND_ABSPATH"
|
||||
|
||||
if [[ "${BACKEND_PORT}" == "NONE" ]]; then
|
||||
echo "BACKEND_PORT=NONE — backend disabled. Exiting."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BACKEND_DIR_ABSPATH="$(dirname "$RUN_BACKEND_ABSPATH")"
|
||||
|
||||
# --- Find a working Python 3 ---
|
||||
PYTHON=""
|
||||
for candidate in python3.13 python3.12 python3.11 python3.10 python3; do
|
||||
if command -v "$candidate" &>/dev/null && "$candidate" -c "print('ok')" &>/dev/null; then
|
||||
PYTHON="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "$PYTHON" ]]; then
|
||||
echo "Error: No working Python 3 found."
|
||||
exit 1
|
||||
fi
|
||||
echo "Using Python: $PYTHON ($($PYTHON --version 2>&1))"
|
||||
|
||||
# --- Create virtual environment if it doesn't exist ---
|
||||
VENV_DIR="$BACKEND_DIR_ABSPATH/.venv"
|
||||
if [[ ! -d "$VENV_DIR" ]]; then
|
||||
echo "Creating virtual environment..."
|
||||
"$PYTHON" -m venv "$VENV_DIR"
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo "Error: Failed to create virtual environment."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
# --- Install Python dependencies ---
|
||||
echo "Installing dependencies..."
|
||||
cd "$BACKEND_DIR_ABSPATH"
|
||||
if [[ -n "${OPENSWARM_DEBUGGER_PATH:-}" && -d "$OPENSWARM_DEBUGGER_PATH" ]]; then
|
||||
echo "Installing OpenSwarm debugger (swarm_debug) from $OPENSWARM_DEBUGGER_PATH"
|
||||
pip install -e "$OPENSWARM_DEBUGGER_PATH"
|
||||
fi
|
||||
pip install -e .
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo "Error: Failed to install Python dependencies."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Start the backend server ---
|
||||
echo "Starting backend server on http://0.0.0.0:${BACKEND_PORT:-8324} ..."
|
||||
cd "$BACKEND_DIR_ABSPATH/.."
|
||||
python -m uvicorn backend.main:app --host 0.0.0.0 --port "${BACKEND_PORT:-8324}" --reload
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# Enable a FastAPI backend for this App.
|
||||
#
|
||||
# Idempotent. The workspace is seeded frontend-only (no backend/ dir,
|
||||
# BACKEND_PORT=NONE). Run this script when your App needs server-side
|
||||
# code — it copies the master template's backend/ into the workspace
|
||||
# and flips BACKEND_PORT in both .env files to a free port.
|
||||
#
|
||||
# After running this, hard-reload the preview (right-click the reload
|
||||
# button in the App Builder) so the runtime restarts with the new
|
||||
# BACKEND_PORT and `bash run.sh` brings the backend up.
|
||||
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$HERE"
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
echo "ERROR: .env not found at $HERE — is this the workspace root?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Source .env so we know the current BACKEND_PORT and the path to the
|
||||
# master template's backend/ (written by OpenSwarm at seed time).
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
if [[ "${BACKEND_PORT:-NONE}" != "NONE" ]]; then
|
||||
echo "Backend already enabled on port $BACKEND_PORT — nothing to do." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -d ./backend ]]; then
|
||||
echo "ERROR: ./backend/ already exists but BACKEND_PORT=NONE — your" >&2
|
||||
echo " workspace is in an inconsistent state. Either delete" >&2
|
||||
echo " ./backend/ and re-run, or set BACKEND_PORT manually." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve master template backend/ path. OPENSWARM_TEMPLATE_BACKEND_PATH
|
||||
# is written into .env at seed time; OPENSWARM_DEBUGGER_PATH the same.
|
||||
if [[ -z "${OPENSWARM_TEMPLATE_BACKEND_PATH:-}" ]]; then
|
||||
echo "ERROR: OPENSWARM_TEMPLATE_BACKEND_PATH not set in .env. This" >&2
|
||||
echo " workspace was seeded by an older OpenSwarm; ask the" >&2
|
||||
echo " App Builder to recreate it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$OPENSWARM_TEMPLATE_BACKEND_PATH" ]]; then
|
||||
echo "ERROR: master template backend dir not found at" >&2
|
||||
echo " $OPENSWARM_TEMPLATE_BACKEND_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Copying backend/ from $OPENSWARM_TEMPLATE_BACKEND_PATH..."
|
||||
cp -R "$OPENSWARM_TEMPLATE_BACKEND_PATH" ./backend
|
||||
chmod +x ./backend/run.sh
|
||||
|
||||
# Pick a free port. SO_REUSEADDR=0 means the kernel won't immediately
|
||||
# recycle, so the small race between bind+close and the backend
|
||||
# re-binding is harmless in practice.
|
||||
PORT="$(python3 -c "import socket
|
||||
s = socket.socket()
|
||||
s.bind(('127.0.0.1', 0))
|
||||
print(s.getsockname()[1])
|
||||
s.close()")"
|
||||
|
||||
# sed-flip both .env and .env.example so an LLM reading either gets the
|
||||
# same answer. macOS sed needs the '' arg for in-place edits.
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' "s/^BACKEND_PORT=NONE/BACKEND_PORT=$PORT/" .env
|
||||
sed -i '' "s/^BACKEND_PORT=NONE/BACKEND_PORT=$PORT/" .env.example
|
||||
else
|
||||
sed -i "s/^BACKEND_PORT=NONE/BACKEND_PORT=$PORT/" .env
|
||||
sed -i "s/^BACKEND_PORT=NONE/BACKEND_PORT=$PORT/" .env.example
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Backend enabled on port $PORT."
|
||||
echo "Hard-reload the preview (right-click the reload button in"
|
||||
echo "the App Builder) to bring it up."
|
||||
@@ -0,0 +1,104 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.tgz
|
||||
*.tar.gz
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE and Editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Chrome Extension specific
|
||||
*.crx
|
||||
*.pem
|
||||
*.zip
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
|
||||
# Storybook build outputs
|
||||
.out
|
||||
.storybook-out
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# webpack generated files
|
||||
*.hot-update.js
|
||||
*.hot-update.json
|
||||
@@ -0,0 +1,462 @@
|
||||
---
|
||||
name: themed-ui-design
|
||||
description: Use when building, designing, or modifying any frontend UI component, page, or interface for the OpenSwarm app. Covers both general design excellence principles AND the specific OpenSwarm theme system, tokens, and component conventions. Trigger this for any React/MUI component work, UI design tasks, layout creation, styling questions, or when the user asks to build something visual.
|
||||
---
|
||||
|
||||
# Themed UI Design
|
||||
|
||||
Build distinctive, production-grade frontend interfaces for the OpenSwarm app that are visually striking AND perfectly aligned with the app's warm, editorial design system.
|
||||
|
||||
This skill combines two concerns:
|
||||
1. **Design Excellence** — Bold aesthetic thinking, anti-slop principles, creative typography/color/motion
|
||||
2. **Theme Compliance** — OpenSwarm's specific tokens, MUI conventions, and component patterns
|
||||
|
||||
Both matter equally. A component that follows the token system but looks generic has failed. A component that looks stunning but ignores the theme system has also failed.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Design Thinking (Do This First)
|
||||
|
||||
Before writing any code, answer these questions:
|
||||
|
||||
- **Purpose**: What problem does this interface solve? Who interacts with it?
|
||||
- **Tone within the brand**: The OpenSwarm aesthetic is warm, editorial, and refined — but within that envelope there's range. Is this component playful? Dense and utilitarian? Spacious and luxurious? Dramatic?
|
||||
- **Differentiation**: What makes this component memorable? What's the one detail someone would notice and appreciate?
|
||||
- **Hierarchy**: What's the single most important thing on screen? Everything else should defer to it.
|
||||
|
||||
**CRITICAL**: The OpenSwarm brand is "sophisticated productivity tool meets premium design magazine meets dev IDE." Every component should feel like it belongs in that world — warm, organic, quietly confident. But within that world, make bold choices. Asymmetric layouts. Unexpected spacing. Elegant motion. The goal is *intentional* design, not safe design.
|
||||
|
||||
### Anti-Slop Checklist
|
||||
|
||||
NEVER produce generic AI-generated aesthetics:
|
||||
- ❌ Cookie-cutter card grids with no visual hierarchy
|
||||
- ❌ Predictable, evenly-spaced layouts with no rhythm
|
||||
- ❌ Animations that exist for no reason (bouncing icons, gratuitous fades)
|
||||
- ❌ Every element getting equal visual weight
|
||||
- ❌ Defaulting to the most obvious layout for every problem
|
||||
|
||||
ALWAYS pursue:
|
||||
- ✅ Clear visual hierarchy — one thing dominates, others support
|
||||
- ✅ Intentional spacing rhythm — not everything needs equal gaps
|
||||
- ✅ Motion that communicates meaning (entrance = "I'm new", hover = "I'm interactive")
|
||||
- ✅ Typography that creates atmosphere, not just displays text
|
||||
- ✅ At least one unexpected detail that rewards attention
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Tech Stack (Non-Negotiable)
|
||||
|
||||
| Concern | Tool | Notes |
|
||||
|---------|------|-------|
|
||||
| Framework | React 18 + TypeScript | Functional components only |
|
||||
| UI Library | **MUI (Material UI) v7** | Use MUI components, not raw HTML |
|
||||
| Styling | **MUI `sx` prop** | NO CSS files, NO styled-components, NO inline `style={}` |
|
||||
| State | Redux Toolkit | `useAppDispatch()` / `useAppSelector()` typed hooks |
|
||||
| Theming | `useClaudeTokens()` hook | NEVER hardcode colors |
|
||||
| Animation | Framer Motion | For complex entrance/drag/spring animations |
|
||||
| Icons | `@mui/icons-material` | Import individually, not barrel |
|
||||
| Routing | react-router-dom v7 | `useNavigate`, `NavLink` |
|
||||
| Markdown | react-markdown + remark-gfm | For rendered markdown |
|
||||
| Path alias | `@/` → `src/` | Use `@/shared/...`, `@/app/...` |
|
||||
|
||||
---
|
||||
|
||||
## Part 3: The Token System
|
||||
|
||||
Access tokens via hook — **never hardcode colors**:
|
||||
```tsx
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
const c = useClaudeTokens();
|
||||
```
|
||||
|
||||
### 3.1 Color Palette
|
||||
|
||||
| Token | Light | Dark | Usage |
|
||||
|-------|-------|------|-------|
|
||||
| `c.bg.page` | `#F5F5F0` warm cream | `#1a1918` deep charcoal | Full-page background |
|
||||
| `c.bg.surface` | `#FFFFFF` | `#262624` | Cards, panels, dialogs |
|
||||
| `c.bg.elevated` | `#FAF9F5` | `#30302E` | Hover states, raised elements |
|
||||
| `c.bg.secondary` | `#F5F4ED` | `#1f1e1b` | Sidebar, secondary panels |
|
||||
| `c.bg.inverse` | `#141413` | `#FAF9F5` | Tooltips, inverted elements |
|
||||
| `c.text.primary` | `#1a1a18` | `#FAF9F5` | Headings, body text |
|
||||
| `c.text.secondary` | `#3D3D3A` | `#C2C0B6` | Secondary labels |
|
||||
| `c.text.tertiary` | `#73726C` | `#9C9A92` | Placeholders, captions |
|
||||
| `c.text.muted` | `#6b6a68` | `#85837C` | De-emphasized text |
|
||||
| `c.text.ghost` | `rgba(115,114,108,0.5)` | `rgba(156,154,146,0.5)` | Timestamps, hints |
|
||||
| `c.accent.primary` | `#ae5630` burnt orange | `#c4633a` | Primary buttons, links, active indicators |
|
||||
| `c.accent.hover` | `#c4633a` | `#d47548` | Hover on accent |
|
||||
| `c.accent.pressed` | `#924828` | `#ae5630` | Active/pressed state |
|
||||
| `c.user.bubble` | `#DDD9CE` | `#393937` | User chat bubbles |
|
||||
|
||||
### 3.2 Borders
|
||||
|
||||
Borders are extremely subtle — transparency, not solid colors:
|
||||
```tsx
|
||||
border: `1px solid ${c.border.subtle}` // 6-8% opacity — default for cards
|
||||
border: `1px solid ${c.border.medium}` // 8-12% opacity — dividers
|
||||
border: `1px solid ${c.border.strong}` // 15-20% opacity — hover, emphasis
|
||||
border: `0.5px solid ${c.border.medium}` // Hairline dividers
|
||||
```
|
||||
|
||||
### 3.3 Shadows
|
||||
|
||||
Very soft, low-contrast. No heavy drop shadows:
|
||||
```tsx
|
||||
c.shadow.sm // "0 1px 3px rgba(0,0,0,0.04)" — cards at rest
|
||||
c.shadow.md // "0 0.25rem 1.25rem rgba(0,0,0,0.035)" — hover elevation
|
||||
c.shadow.lg // "0 0.5rem 2rem rgba(0,0,0,0.08)" — dialogs, drag
|
||||
```
|
||||
|
||||
### 3.4 Border Radius
|
||||
|
||||
```tsx
|
||||
c.radius.xs // 4px — chips, badges
|
||||
c.radius.sm // 6px — input fields
|
||||
c.radius.md // 8px — chips, tags
|
||||
c.radius.lg // 10px — buttons, list items
|
||||
c.radius.xl // 12px — cards, panels
|
||||
c.radius.full // 9999px — pills, avatars
|
||||
```
|
||||
|
||||
### 3.5 Typography
|
||||
|
||||
**Serif-first** — this is intentional and core to the brand identity:
|
||||
```
|
||||
Font family: "Anthropic Sans", ui-serif, Georgia, Cambria, "Times New Roman", Times, serif
|
||||
Mono font: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace
|
||||
```
|
||||
|
||||
| Use Case | Size | Weight |
|
||||
|----------|------|--------|
|
||||
| Page heading | `h6` or manual | 700 |
|
||||
| Section heading | `0.95rem` | 600 |
|
||||
| Body text | `0.875rem` | 400 |
|
||||
| Small label | `0.8rem` | 400 |
|
||||
| Caption/timestamp | `0.75rem` / `0.65rem` | 400 |
|
||||
| Button text | inherited | 500 |
|
||||
|
||||
**Important**: `textTransform: 'none'` on ALL buttons. No uppercase anywhere.
|
||||
|
||||
### 3.6 Transitions
|
||||
|
||||
Signature easing for interactive elements:
|
||||
```tsx
|
||||
transition: c.transition // "all 300ms cubic-bezier(0.165, 0.85, 0.45, 1)"
|
||||
```
|
||||
For micro-interactions:
|
||||
```tsx
|
||||
transition: 'opacity 0.15s'
|
||||
transition: 'all 0.2s ease'
|
||||
```
|
||||
|
||||
### 3.7 Accent-Tinted Backgrounds
|
||||
|
||||
The signature technique for active/selected states — accent color at very low opacity:
|
||||
```tsx
|
||||
bgcolor: `${c.accent.primary}0F` // ~6% — active state
|
||||
bgcolor: `${c.accent.primary}08` // ~3% — hover state
|
||||
bgcolor: `${c.accent.primary}0A` // ~4% — subtle hover
|
||||
bgcolor: `${c.accent.primary}0C` // ~5% — hover on active
|
||||
bgcolor: `${c.accent.primary}18` // ~9% — decorative fill
|
||||
```
|
||||
|
||||
### 3.8 Spacing (MUI units = 8px)
|
||||
|
||||
| `sx` value | Pixels | Usage |
|
||||
|------------|--------|-------|
|
||||
| `p: 0.25` | 2px | Tiny icon padding |
|
||||
| `gap: 0.5` | 4px | Tight button groups |
|
||||
| `p: 0.75` | 6px | Compact list items |
|
||||
| `gap: 1` / `p: 1` | 8px | Standard small gap |
|
||||
| `gap: 1.5` | 12px | Logo + text |
|
||||
| `p: 2` | 16px | Card content |
|
||||
| `p: 2.5` | 20px | Header/section |
|
||||
|
||||
---
|
||||
|
||||
## Part 4: Component Structure
|
||||
|
||||
Every component follows this skeleton:
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import SomeIcon from '@mui/icons-material/SomeIcon';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
interface Props {
|
||||
// Explicit interface, not inline types
|
||||
}
|
||||
|
||||
const MyComponent: React.FC<Props> = ({ prop1, prop2 }) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
return (
|
||||
<Box sx={{ /* styling */ }}>
|
||||
{/* content */}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyComponent;
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- `export default` the component
|
||||
- Destructure props in the signature
|
||||
- `const c = useClaudeTokens()` as the first line
|
||||
- `Box` for containers (not `div`), `Typography` for text (not `p`/`span`/`h1`)
|
||||
- All styling via `sx={{}}` — never `style={{}}`
|
||||
- Import MUI components from individual paths, not barrel
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Pattern Library
|
||||
|
||||
### 5.1 Card
|
||||
```tsx
|
||||
<Box sx={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
bgcolor: c.bg.surface,
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: c.border.strong,
|
||||
boxShadow: c.shadow.md,
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
'&:hover .card-actions': { opacity: 1 },
|
||||
}}>
|
||||
```
|
||||
|
||||
### 5.2 Hover-Reveal Actions
|
||||
```tsx
|
||||
<Box className="card-actions" sx={{
|
||||
position: 'absolute',
|
||||
top: 8, right: 8,
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.15s',
|
||||
}}>
|
||||
<Tooltip title="Run">
|
||||
<IconButton size="small" sx={{
|
||||
bgcolor: c.bg.surface,
|
||||
color: c.accent.primary,
|
||||
boxShadow: c.shadow.sm,
|
||||
'&:hover': { bgcolor: c.bg.elevated },
|
||||
}}>
|
||||
<PlayArrowIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### 5.3 Sidebar Nav Item
|
||||
```tsx
|
||||
<ListItemButton sx={{
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
bgcolor: isActive ? `${c.accent.primary}0F` : 'transparent',
|
||||
'&:hover': { bgcolor: `${c.accent.primary}08` },
|
||||
}}>
|
||||
<ListItemIcon sx={{
|
||||
color: isActive ? c.text.primary : c.text.tertiary,
|
||||
minWidth: 40,
|
||||
}}>
|
||||
<SomeIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Label" sx={{
|
||||
'& .MuiListItemText-primary': {
|
||||
color: isActive ? c.text.primary : c.text.muted,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
},
|
||||
}} />
|
||||
</ListItemButton>
|
||||
```
|
||||
|
||||
### 5.4 Icon Buttons (Always in Tooltip)
|
||||
```tsx
|
||||
<Tooltip title="Settings">
|
||||
<IconButton size="small" sx={{
|
||||
color: c.text.tertiary,
|
||||
'&:hover': { color: c.accent.primary, bgcolor: `${c.accent.primary}0A` },
|
||||
transition: c.transition,
|
||||
}}>
|
||||
<SettingsIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
### 5.5 Dialog/Modal
|
||||
```tsx
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 4,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
boxShadow: c.shadow.lg,
|
||||
maxWidth: 600,
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
backdrop: { sx: { backdropFilter: 'blur(4px)' } },
|
||||
}}
|
||||
>
|
||||
```
|
||||
|
||||
### 5.6 Status Chips
|
||||
```tsx
|
||||
<Chip label="Active" size="small" sx={{
|
||||
bgcolor: c.status.successBg,
|
||||
color: c.status.success,
|
||||
fontWeight: 500,
|
||||
fontSize: '0.75rem',
|
||||
}} />
|
||||
```
|
||||
|
||||
### 5.7 Destructive Actions
|
||||
```tsx
|
||||
<IconButton sx={{
|
||||
color: c.text.ghost,
|
||||
'&:hover': { color: c.status.error },
|
||||
}}>
|
||||
<DeleteOutlineIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
```
|
||||
|
||||
### 5.8 Custom Scrollbar
|
||||
```tsx
|
||||
sx={{
|
||||
overflow: 'auto',
|
||||
'&::-webkit-scrollbar': { width: 4 },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 2 },
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${c.border.medium} transparent`,
|
||||
}}
|
||||
```
|
||||
|
||||
### 5.9 Text Truncation
|
||||
```tsx
|
||||
// Single line:
|
||||
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
|
||||
// Multi-line clamp (2 lines):
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 6: Animation Conventions
|
||||
|
||||
### Framer Motion (entrance, drag, springs)
|
||||
```tsx
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.3 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 28, mass: 0.6 }}
|
||||
/>
|
||||
```
|
||||
|
||||
### CSS Keyframes (via sx)
|
||||
```tsx
|
||||
// Pulsing dot:
|
||||
sx={{
|
||||
'@keyframes pulse': {
|
||||
'0%, 100%': { opacity: 0.4, transform: 'scale(0.8)' },
|
||||
'50%': { opacity: 1, transform: 'scale(1.2)' },
|
||||
},
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
}}
|
||||
```
|
||||
|
||||
### Motion Philosophy
|
||||
- **Entrance**: Staggered reveals with `animation-delay` create delight
|
||||
- **Hover**: Cards lift (`translateY(-2px)`), borders sharpen, shadows deepen
|
||||
- **Active**: `transform: 'scale(0.98)'` on press
|
||||
- **Reveal**: Actions fade in on parent hover (opacity 0 → 1)
|
||||
- **Scroll**: Use scroll-triggered animations sparingly but memorably
|
||||
- One well-orchestrated page load > scattered micro-interactions
|
||||
|
||||
---
|
||||
|
||||
## Part 7: Hard Rules (Don'ts)
|
||||
|
||||
- ❌ Hardcode any color value — use `c.xxx` tokens
|
||||
- ❌ CSS files, CSS modules, styled-components, emotion `css` prop
|
||||
- ❌ Raw HTML (`div`, `span`, `p`, `button`) — use MUI equivalents
|
||||
- ❌ `style={{}}` — use `sx={{}}`
|
||||
- ❌ Uppercase text transforms on buttons or labels
|
||||
- ❌ Heavy/solid borders — always transparent/opacity-based
|
||||
- ❌ Bright/saturated colors outside the token palette
|
||||
- ❌ Sharp corners (0 radius) on interactive elements
|
||||
- ❌ Icon buttons without `<Tooltip>` wrapper
|
||||
- ❌ Barrel imports from `@mui/material` — import each component from its path
|
||||
- ❌ `React.memo` unless measured performance need
|
||||
- ❌ Separate type files — keep interfaces colocated unless shared
|
||||
|
||||
---
|
||||
|
||||
## Part 8: File & Folder Conventions
|
||||
|
||||
Routes use **file-based routing** via `vite-plugin-pages`. Any `.tsx` file in `src/pages/` automatically becomes a route (e.g. `src/pages/health.tsx` → `/health`, `src/pages/index.tsx` → `/`).
|
||||
|
||||
```
|
||||
src/pages/ # File-based routes (auto-registered)
|
||||
index.tsx # Home page → /
|
||||
health.tsx # Health page → /health
|
||||
settings.tsx # Example → /settings
|
||||
|
||||
src/app/
|
||||
Main.tsx # Root: providers + BrowserRouter + AppShell
|
||||
components/
|
||||
Layout/
|
||||
AppShell.tsx # Sidebar + content area shell
|
||||
Sidebar.tsx # Navigation rail, theme toggle
|
||||
SharedComponent.tsx # Truly shared/reusable
|
||||
|
||||
src/shared/
|
||||
hooks.ts # useAppDispatch, useAppSelector
|
||||
state/ # Redux slices
|
||||
styles/ # Theme tokens, context
|
||||
modals/ # Modal components
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 9: Quality Checklist
|
||||
|
||||
Before delivering any component, verify:
|
||||
|
||||
1. ☐ `useClaudeTokens()` is the first hook call
|
||||
2. ☐ Zero hardcoded colors — every color references a token
|
||||
3. ☐ All styling via `sx={{}}` — no `style`, no CSS files
|
||||
4. ☐ Every `IconButton` wrapped in `Tooltip`
|
||||
5. ☐ `textTransform: 'none'` on all buttons
|
||||
6. ☐ Borders use `c.border.*` tokens (opacity-based)
|
||||
7. ☐ Interactive elements have `transition: c.transition`
|
||||
8. ☐ Active states use accent-tinted backgrounds (`${c.accent.primary}0F`)
|
||||
9. ☐ Cards have subtle border + shadow + hover lift pattern
|
||||
10. ☐ Component has clear visual hierarchy — not everything equal weight
|
||||
11. ☐ At least one thoughtful design detail that elevates beyond generic
|
||||
12. ☐ Dark mode works correctly (tokens handle this automatically if used properly)
|
||||
13. ☐ MUI imports are from individual paths, not barrel
|
||||
14. ☐ `export default` at the bottom
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<title>OpenSwarm - Web App Template</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "open-swarm-app-template",
|
||||
"version": "1.0.0",
|
||||
"description": "SaaS skeleton frontend built with React",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.9",
|
||||
"@mui/material": "^7.3.9",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"framer-motion": "^12.36.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pages": "^0.33.3",
|
||||
"vite-plugin-terminal": "^1.4.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 320 KiB |
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# The comment above is shebang, DO NOT REMOVE
|
||||
RUN_FRONTEND_ABSPATH="$(readlink -f "${BASH_SOURCE[0]}")"
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# echo "In macOS server sed START"
|
||||
# echo "SERVER_ABSPATH: $SERVER_ABSPATH"
|
||||
sed -i '' 's/\r//g' "$RUN_FRONTEND_ABSPATH"
|
||||
# echo "In macOS server sed END"
|
||||
else
|
||||
# echo "NOT in macOS server START"
|
||||
# echo "SERVER_ABSPATH: $SERVER_ABSPATH"
|
||||
sed -i 's/\r//g' "$RUN_FRONTEND_ABSPATH"
|
||||
# echo "NOT in macOS server START"
|
||||
fi
|
||||
chmod +x "$RUN_FRONTEND_ABSPATH"
|
||||
|
||||
FRONTEND_DIR_ABSPATH="$(dirname "$RUN_FRONTEND_ABSPATH")"
|
||||
|
||||
echo "Installing dependencies..."
|
||||
cd "$FRONTEND_DIR_ABSPATH"
|
||||
npm install
|
||||
|
||||
echo "Building with development mode..."
|
||||
npm run dev
|
||||
|
||||
# exit back to the dir that we were in before
|
||||
cd -
|
||||
@@ -0,0 +1,27 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter, useRoutes } from 'react-router-dom';
|
||||
import routes from '~react-pages';
|
||||
import { store } from '../shared/state/store';
|
||||
import ClaudeThemeProvider from '@/shared/styles/ThemeContext';
|
||||
import AppShell from '@/app/components/Layout/AppShell';
|
||||
|
||||
const Pages: React.FC = () => {
|
||||
return <Suspense fallback={null}>{useRoutes(routes)}</Suspense>;
|
||||
};
|
||||
|
||||
const Main: React.FC = () => {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<ClaudeThemeProvider>
|
||||
<BrowserRouter>
|
||||
<AppShell>
|
||||
<Pages />
|
||||
</AppShell>
|
||||
</BrowserRouter>
|
||||
</ClaudeThemeProvider>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Main;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Sidebar from './Sidebar';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const AppShell: React.FC<AppShellProps> = ({ children }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed((prev) => !prev)} />
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
bgcolor: c.bg.page,
|
||||
transition: c.transition,
|
||||
'&::-webkit-scrollbar': { width: 4 },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 2 },
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${c.border.medium} transparent`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppShell;
|
||||
@@ -0,0 +1,190 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||
import LightModeIcon from '@mui/icons-material/LightMode';
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: '/', label: 'Home', icon: HomeIcon },
|
||||
{ path: '/health', label: 'Health', icon: FavoriteIcon },
|
||||
];
|
||||
|
||||
const SIDEBAR_WIDTH_EXPANDED = 240;
|
||||
const SIDEBAR_WIDTH_COLLAPSED = 64;
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ collapsed, onToggle }) => {
|
||||
const c = useClaudeTokens();
|
||||
const { mode, toggleMode } = useThemeMode();
|
||||
const location = useLocation();
|
||||
|
||||
const width = collapsed ? SIDEBAR_WIDTH_COLLAPSED : SIDEBAR_WIDTH_EXPANDED;
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{
|
||||
width,
|
||||
minWidth: width,
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
bgcolor: c.bg.secondary,
|
||||
borderRight: `1px solid ${c.border.subtle}`,
|
||||
transition: c.transition,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={onToggle}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
px: collapsed ? 0 : 2.5,
|
||||
py: 2.5,
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
minHeight: 64,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/logo.png"
|
||||
alt="OpenSwarm"
|
||||
sx={{ width: 28, height: 28, objectFit: 'contain', flexShrink: 0 }}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '0.95rem',
|
||||
color: c.text.primary,
|
||||
fontFamily: c.font.serif,
|
||||
letterSpacing: '-0.01em',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
OpenSwarm
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<List sx={{ flex: 1, px: collapsed ? 1 : 1.5, pt: 0.5 }}>
|
||||
{NAV_ITEMS.map(({ path, label, icon: Icon }) => {
|
||||
const isActive =
|
||||
path === '/' ? location.pathname === '/' : location.pathname.startsWith(path);
|
||||
|
||||
const button = (
|
||||
<ListItemButton
|
||||
key={path}
|
||||
component={NavLink}
|
||||
to={path}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 0.5,
|
||||
py: 0.75,
|
||||
px: collapsed ? 0 : undefined,
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
bgcolor: isActive ? `${c.accent.primary}0F` : 'transparent',
|
||||
'&:hover': { bgcolor: `${c.accent.primary}08` },
|
||||
transition: c.transition,
|
||||
}}
|
||||
>
|
||||
<ListItemIcon
|
||||
sx={{
|
||||
color: isActive ? c.accent.primary : c.text.tertiary,
|
||||
minWidth: collapsed ? 'auto' : 36,
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Icon sx={{ fontSize: 20 }} />
|
||||
</ListItemIcon>
|
||||
{!collapsed && (
|
||||
<ListItemText
|
||||
primary={label}
|
||||
sx={{
|
||||
'& .MuiListItemText-primary': {
|
||||
color: isActive ? c.text.primary : c.text.muted,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
fontFamily: c.font.serif,
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
);
|
||||
|
||||
return collapsed ? (
|
||||
<Tooltip key={path} title={label} placement="right">
|
||||
{button}
|
||||
</Tooltip>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: collapsed ? 1 : 2.5,
|
||||
py: 2,
|
||||
borderTop: `1px solid ${c.border.subtle}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Tooltip title={mode === 'light' ? 'Dark mode' : 'Light mode'} placement={collapsed ? 'right' : 'top'}>
|
||||
<IconButton
|
||||
onClick={toggleMode}
|
||||
size="small"
|
||||
sx={{
|
||||
color: c.text.tertiary,
|
||||
'&:hover': { color: c.accent.primary, bgcolor: `${c.accent.primary}0A` },
|
||||
transition: c.transition,
|
||||
}}
|
||||
>
|
||||
{mode === 'light' ? (
|
||||
<DarkModeIcon sx={{ fontSize: 18 }} />
|
||||
) : (
|
||||
<LightModeIcon sx={{ fontSize: 18 }} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{!collapsed && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
color: c.text.ghost,
|
||||
fontFamily: c.font.mono,
|
||||
ml: 1,
|
||||
}}
|
||||
>
|
||||
{mode === 'light' ? 'Light' : 'Dark'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export { SIDEBAR_WIDTH_EXPANDED, SIDEBAR_WIDTH_COLLAPSED };
|
||||
export default Sidebar;
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import Main from './app/Main';
|
||||
|
||||
console.log('[App] Bootstrapping React app');
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
console.error('[App] FATAL: #root element not found in DOM');
|
||||
} else {
|
||||
createRoot(rootEl).render(<Main />);
|
||||
console.log('[App] React root mounted');
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||
import CloudOffIcon from '@mui/icons-material/CloudOff';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { HEALTH_CHECK_URL } from '@/shared/state/API_ENDPOINTS';
|
||||
|
||||
const BACKEND_ENABLED = process.env.BACKEND_ENABLED;
|
||||
|
||||
type HealthStatus = 'idle' | 'loading' | 'ok' | 'error';
|
||||
|
||||
interface HealthResult {
|
||||
status: HealthStatus;
|
||||
message: string;
|
||||
latencyMs: number | null;
|
||||
}
|
||||
|
||||
const Health: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const [result, setResult] = useState<HealthResult>({
|
||||
status: 'idle',
|
||||
message: '',
|
||||
latencyMs: null,
|
||||
});
|
||||
|
||||
const pingHealth = useCallback(async () => {
|
||||
setResult({ status: 'loading', message: '', latencyMs: null });
|
||||
const start = performance.now();
|
||||
try {
|
||||
const res = await fetch(HEALTH_CHECK_URL);
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
const text = await res.text();
|
||||
if (res.ok) {
|
||||
setResult({ status: 'ok', message: text, latencyMs: elapsed });
|
||||
} else {
|
||||
setResult({ status: 'error', message: `${res.status} — ${text}`, latencyMs: elapsed });
|
||||
}
|
||||
} catch (err) {
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
const msg = err instanceof Error ? err.message : 'Network error';
|
||||
setResult({ status: 'error', message: msg, latencyMs: elapsed });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const statusColor =
|
||||
result.status === 'ok'
|
||||
? c.status.success
|
||||
: result.status === 'error'
|
||||
? c.status.error
|
||||
: c.text.tertiary;
|
||||
|
||||
const statusBg =
|
||||
result.status === 'ok'
|
||||
? c.status.successBg
|
||||
: result.status === 'error'
|
||||
? c.status.errorBg
|
||||
: 'transparent';
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100%',
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 30, mass: 0.8 }}
|
||||
>
|
||||
<Box sx={{ maxWidth: 440, width: '100%', textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: c.text.primary,
|
||||
mb: 0.5,
|
||||
fontFamily: c.font.serif,
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
Health
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
color: c.text.secondary,
|
||||
mb: 4,
|
||||
fontFamily: c.font.serif,
|
||||
}}
|
||||
>
|
||||
{BACKEND_ENABLED ? 'Backend health check' : 'Frontend-only mode'}
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: `${c.radius.xl}px`,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
bgcolor: c.bg.surface,
|
||||
boxShadow: c.shadow.sm,
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: c.border.strong,
|
||||
boxShadow: c.shadow.md,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{BACKEND_ENABLED ? (
|
||||
<>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<FavoriteIcon
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: c.accent.primary,
|
||||
...(result.status === 'loading' && {
|
||||
'@keyframes pulse': {
|
||||
'0%, 100%': { opacity: 0.4, transform: 'scale(0.8)' },
|
||||
'50%': { opacity: 1, transform: 'scale(1.2)' },
|
||||
},
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
color: c.text.secondary,
|
||||
fontFamily: c.font.serif,
|
||||
}}
|
||||
>
|
||||
Service Status
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
onClick={pingHealth}
|
||||
disabled={result.status === 'loading'}
|
||||
variant="contained"
|
||||
disableElevation
|
||||
sx={{
|
||||
bgcolor: c.accent.primary,
|
||||
color: '#fff',
|
||||
fontWeight: 500,
|
||||
fontFamily: c.font.serif,
|
||||
fontSize: '0.875rem',
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
px: 3,
|
||||
py: 1,
|
||||
textTransform: 'none',
|
||||
transition: c.transition,
|
||||
'&:hover': { bgcolor: c.accent.hover },
|
||||
'&:active': {
|
||||
bgcolor: c.accent.pressed,
|
||||
transform: 'scale(0.98)',
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
bgcolor: c.accent.primary,
|
||||
opacity: 0.6,
|
||||
color: '#fff',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{result.status === 'loading' ? (
|
||||
<CircularProgress size={18} sx={{ color: '#fff', mr: 1 }} />
|
||||
) : null}
|
||||
{result.status === 'loading' ? 'Pinging...' : 'Ping Health'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{result.status !== 'idle' && result.status !== 'loading' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{ duration: 0.25, ease: [0.165, 0.85, 0.45, 1] }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
borderTop: `0.5px solid ${c.border.medium}`,
|
||||
px: 3,
|
||||
py: 2,
|
||||
bgcolor: statusBg,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 500,
|
||||
color: statusColor,
|
||||
fontFamily: c.font.mono,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
{result.status === 'ok' ? 'Healthy' : 'Unreachable'}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
color: c.text.muted,
|
||||
fontFamily: c.font.mono,
|
||||
}}
|
||||
>
|
||||
Response: {result.message}
|
||||
{result.latencyMs !== null && ` · ${result.latencyMs}ms`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<CloudOffIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
color: c.text.secondary,
|
||||
fontFamily: c.font.serif,
|
||||
}}
|
||||
>
|
||||
No Backend Configured
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
color: c.text.muted,
|
||||
fontFamily: c.font.mono,
|
||||
}}
|
||||
>
|
||||
Initialize the backend and set
|
||||
<br />
|
||||
the BACKEND_PORT in .env.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</motion.div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Health;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100%',
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 30, mass: 0.8 }}
|
||||
>
|
||||
<Box sx={{ maxWidth: 440, width: '100%', textAlign: 'center' }}>
|
||||
<Box
|
||||
component="img"
|
||||
src="/logo.png"
|
||||
alt="OpenSwarm"
|
||||
sx={{ width: 48, height: 48, objectFit: 'contain', mb: 1 }}
|
||||
/>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: c.text.primary,
|
||||
mb: 0.5,
|
||||
fontFamily: c.font.serif,
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
OpenSwarm
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
color: c.text.secondary,
|
||||
fontFamily: c.font.serif,
|
||||
}}
|
||||
>
|
||||
Web app template — ready to build.
|
||||
</Typography>
|
||||
</Box>
|
||||
</motion.div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from './state/store';
|
||||
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
||||
export const useAppSelector = useSelector.withTypes<RootState>();
|
||||
@@ -0,0 +1,4 @@
|
||||
const API_URL = '/api';
|
||||
|
||||
// HEALTH - Endpoints
|
||||
export const HEALTH_CHECK_URL = API_URL + '/health/check';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import tempStateReducer from './tempStateSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
tempState: tempStateReducer,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[Store] Redux store created with slices:', Object.keys(store.getState()));
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
@@ -0,0 +1,30 @@
|
||||
// store/tempStateSlice.ts
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
interface TempState {
|
||||
temp_state: string | null;
|
||||
}
|
||||
|
||||
const initialState: TempState = {
|
||||
temp_state: 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setTempState,
|
||||
resetTempState,
|
||||
} = tempStateSlice.actions;
|
||||
|
||||
export default tempStateSlice.reducer;
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, { createContext, useContext, useMemo, useState, useCallback } from 'react';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
|
||||
type Mode = 'light' | 'dark';
|
||||
|
||||
const FONT_SERIF = '"Anthropic Sans", ui-serif, Georgia, Cambria, "Times New Roman", Times, serif';
|
||||
const FONT_MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace';
|
||||
|
||||
const lightTokens = {
|
||||
bg: {
|
||||
page: '#F5F5F0',
|
||||
surface: '#FFFFFF',
|
||||
elevated: '#FAF9F5',
|
||||
secondary: '#F5F4ED',
|
||||
inverse: '#141413',
|
||||
},
|
||||
text: {
|
||||
primary: '#1a1a18',
|
||||
secondary: '#3D3D3A',
|
||||
tertiary: '#73726C',
|
||||
muted: '#6b6a68',
|
||||
ghost: 'rgba(115,114,108,0.5)',
|
||||
},
|
||||
accent: {
|
||||
primary: '#ae5630',
|
||||
hover: '#c4633a',
|
||||
pressed: '#924828',
|
||||
},
|
||||
user: { bubble: '#DDD9CE' },
|
||||
border: {
|
||||
subtle: 'rgba(0,0,0,0.07)',
|
||||
medium: 'rgba(0,0,0,0.10)',
|
||||
strong: 'rgba(0,0,0,0.18)',
|
||||
},
|
||||
shadow: {
|
||||
sm: '0 1px 3px rgba(0,0,0,0.04)',
|
||||
md: '0 0.25rem 1.25rem rgba(0,0,0,0.035)',
|
||||
lg: '0 0.5rem 2rem rgba(0,0,0,0.08)',
|
||||
},
|
||||
status: {
|
||||
success: '#2e7d32',
|
||||
successBg: 'rgba(46,125,50,0.08)',
|
||||
error: '#c62828',
|
||||
errorBg: 'rgba(198,40,40,0.08)',
|
||||
},
|
||||
};
|
||||
|
||||
const darkTokens = {
|
||||
bg: {
|
||||
page: '#1a1918',
|
||||
surface: '#262624',
|
||||
elevated: '#30302E',
|
||||
secondary: '#1f1e1b',
|
||||
inverse: '#FAF9F5',
|
||||
},
|
||||
text: {
|
||||
primary: '#FAF9F5',
|
||||
secondary: '#C2C0B6',
|
||||
tertiary: '#9C9A92',
|
||||
muted: '#85837C',
|
||||
ghost: 'rgba(156,154,146,0.5)',
|
||||
},
|
||||
accent: {
|
||||
primary: '#c4633a',
|
||||
hover: '#d47548',
|
||||
pressed: '#ae5630',
|
||||
},
|
||||
user: { bubble: '#393937' },
|
||||
border: {
|
||||
subtle: 'rgba(255,255,255,0.07)',
|
||||
medium: 'rgba(255,255,255,0.10)',
|
||||
strong: 'rgba(255,255,255,0.18)',
|
||||
},
|
||||
shadow: {
|
||||
sm: '0 1px 3px rgba(0,0,0,0.12)',
|
||||
md: '0 0.25rem 1.25rem rgba(0,0,0,0.15)',
|
||||
lg: '0 0.5rem 2rem rgba(0,0,0,0.25)',
|
||||
},
|
||||
status: {
|
||||
success: '#66bb6a',
|
||||
successBg: 'rgba(102,187,106,0.12)',
|
||||
error: '#ef5350',
|
||||
errorBg: 'rgba(239,83,80,0.12)',
|
||||
},
|
||||
};
|
||||
|
||||
const sharedTokens = {
|
||||
radius: {
|
||||
xs: 4,
|
||||
sm: 6,
|
||||
md: 8,
|
||||
lg: 10,
|
||||
xl: 12,
|
||||
full: 9999,
|
||||
},
|
||||
font: {
|
||||
serif: FONT_SERIF,
|
||||
mono: FONT_MONO,
|
||||
},
|
||||
transition: 'all 300ms cubic-bezier(0.165, 0.85, 0.45, 1)',
|
||||
};
|
||||
|
||||
export type ClaudeTokens = typeof lightTokens & typeof sharedTokens;
|
||||
|
||||
function buildTokens(mode: Mode): ClaudeTokens {
|
||||
const modeTokens = mode === 'light' ? lightTokens : darkTokens;
|
||||
return { ...modeTokens, ...sharedTokens };
|
||||
}
|
||||
|
||||
interface ThemeModeContextValue {
|
||||
mode: Mode;
|
||||
toggleMode: () => void;
|
||||
}
|
||||
|
||||
const ThemeModeContext = createContext<ThemeModeContextValue>({
|
||||
mode: 'light',
|
||||
toggleMode: () => {},
|
||||
});
|
||||
|
||||
const TokensContext = createContext<ClaudeTokens>(buildTokens('light'));
|
||||
|
||||
export function useThemeMode() {
|
||||
return useContext(ThemeModeContext);
|
||||
}
|
||||
|
||||
export function useClaudeTokens(): ClaudeTokens {
|
||||
return useContext(TokensContext);
|
||||
}
|
||||
|
||||
interface ClaudeThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ClaudeThemeProvider: React.FC<ClaudeThemeProviderProps> = ({ children }) => {
|
||||
const [mode, setMode] = useState<Mode>('light');
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
setMode((prev) => {
|
||||
const next = prev === 'light' ? 'dark' : 'light';
|
||||
console.log(`[Theme] Toggled ${prev} → ${next}`);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const tokens = useMemo(() => buildTokens(mode), [mode]);
|
||||
|
||||
const muiTheme = useMemo(
|
||||
() =>
|
||||
createTheme({
|
||||
palette: { mode },
|
||||
typography: {
|
||||
fontFamily: FONT_SERIF,
|
||||
button: { textTransform: 'none' as const },
|
||||
},
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
body: {
|
||||
backgroundColor: tokens.bg.page,
|
||||
color: tokens.text.primary,
|
||||
transition: 'background-color 300ms ease, color 300ms ease',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
[mode, tokens],
|
||||
);
|
||||
|
||||
const modeValue = useMemo(() => ({ mode, toggleMode }), [mode, toggleMode]);
|
||||
|
||||
return (
|
||||
<ThemeModeContext.Provider value={modeValue}>
|
||||
<TokensContext.Provider value={tokens}>
|
||||
<ThemeProvider theme={muiTheme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</TokensContext.Provider>
|
||||
</ThemeModeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClaudeThemeProvider;
|
||||
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pages/client-react" />
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import Pages from 'vite-plugin-pages';
|
||||
import terminal from 'vite-plugin-terminal';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const backendPort = process.env.BACKEND_PORT;
|
||||
const backendEnabled = backendPort && backendPort !== 'NONE';
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
react(),
|
||||
Pages({ dirs: 'src/pages', extensions: ['tsx'] }),
|
||||
terminal({ console: 'terminal', output: ['terminal', 'console'] }),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'process.env.BACKEND_ENABLED': JSON.stringify(backendEnabled ? 'true' : ''),
|
||||
},
|
||||
server: {
|
||||
port: Number(process.env.FRONTEND_PORT) || 3000,
|
||||
open: true,
|
||||
proxy: backendEnabled
|
||||
? {
|
||||
'/api': {
|
||||
target: `http://localhost:${backendPort || 8324}`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
});
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/bin/bash
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if [[ -f "$ROOT_DIR/.env" ]]; then
|
||||
set -a
|
||||
source "$ROOT_DIR/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "Shutting down all processes..."
|
||||
kill 0 2>/dev/null
|
||||
wait 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ "${BACKEND_PORT}" == "NONE" ]]; then
|
||||
echo "BACKEND_PORT=NONE — running frontend only (no backend)."
|
||||
echo ""
|
||||
|
||||
bash "$ROOT_DIR/frontend/run.sh" 2>&1 | awk '{printf "\033[32m[frontend]\033[0m %s\n", $0; fflush()}' &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
while true; do
|
||||
if ! kill -0 $FRONTEND_PID 2>/dev/null; then
|
||||
echo ""
|
||||
echo "ERROR: Frontend process exited. Tearing down..."
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
else
|
||||
BACKEND_URL="http://localhost:${BACKEND_PORT:-8324}/api/health/check"
|
||||
MAX_WAIT=60
|
||||
|
||||
echo "Starting backend..."
|
||||
echo ""
|
||||
|
||||
bash "$ROOT_DIR/backend/run.sh" 2>&1 | awk '{printf "\033[34m[backend]\033[0m %s\n", $0; fflush()}' &
|
||||
BACKEND_PID=$!
|
||||
|
||||
echo "Waiting for backend to be ready..."
|
||||
elapsed=0
|
||||
while [ $elapsed -lt $MAX_WAIT ]; do
|
||||
if ! kill -0 $BACKEND_PID 2>/dev/null; then
|
||||
echo "ERROR: Backend process died before becoming ready. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
if curl -s -o /dev/null -w "%{http_code}" "$BACKEND_URL" 2>/dev/null | grep -q "200"; then
|
||||
echo ""
|
||||
echo "Backend is ready! Starting frontend..."
|
||||
echo ""
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
|
||||
if [ $elapsed -ge $MAX_WAIT ]; then
|
||||
echo "ERROR: Backend failed to start within ${MAX_WAIT}s. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bash "$ROOT_DIR/frontend/run.sh" 2>&1 | awk '{printf "\033[32m[frontend]\033[0m %s\n", $0; fflush()}' &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
while true; do
|
||||
if ! kill -0 $BACKEND_PID 2>/dev/null; then
|
||||
echo ""
|
||||
echo "ERROR: Backend process exited. Tearing down..."
|
||||
exit 1
|
||||
fi
|
||||
if ! kill -0 $FRONTEND_PID 2>/dev/null; then
|
||||
echo ""
|
||||
echo "ERROR: Frontend process exited. Tearing down..."
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
@@ -37,7 +37,10 @@ def _built_in_skill_registry() -> list[dict]:
|
||||
# Imported lazily so this module stays cheap to import from
|
||||
# everywhere (the skills outputs module pulls in pydantic+fastapi
|
||||
# transitively and we don't want a cycle).
|
||||
from backend.apps.outputs.view_builder_templates import APP_BUILDER_SKILL_SOURCE_PATH
|
||||
from backend.apps.outputs.view_builder_templates import (
|
||||
APP_BUILDER_SKILL_SOURCE_PATH,
|
||||
SWARM_DEBUG_SKILL_SOURCE_PATH,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": "app_builder_skill",
|
||||
@@ -51,6 +54,19 @@ def _built_in_skill_registry() -> list[dict]:
|
||||
"command": "app-builder-skill",
|
||||
"source_path": APP_BUILDER_SKILL_SOURCE_PATH,
|
||||
},
|
||||
{
|
||||
"id": "swarm_debug_skill",
|
||||
"name": "swarm-debug Logger",
|
||||
"description": (
|
||||
"How to use `swarm_debug.debug()` in an App backend — the "
|
||||
"colored frame-aware logger that lands in the App Builder's "
|
||||
"Terminal pane under [BACKEND]. Edit to teach your debugging "
|
||||
"conventions to the App Builder agent. Built-in: editable, "
|
||||
"not deletable."
|
||||
),
|
||||
"command": "swarm-debug-skill",
|
||||
"source_path": SWARM_DEBUG_SKILL_SOURCE_PATH,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
+23
-5
@@ -288,18 +288,28 @@ async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str):
|
||||
pass
|
||||
|
||||
unsubscribe = rt.subscribe(_on_line)
|
||||
try:
|
||||
# Initial status frame so the client knows port/running state
|
||||
# without a second HTTP round-trip.
|
||||
await websocket.send_text(json.dumps({
|
||||
|
||||
def _build_status_frame() -> dict:
|
||||
return {
|
||||
"event": "runtime:status",
|
||||
"workspace_id": workspace_id,
|
||||
"data": {
|
||||
"running": rt.running,
|
||||
"port": rt.port,
|
||||
"backend_url": f"http://127.0.0.1:{rt.port}" if rt.running and rt.port else None,
|
||||
"frontend_port": rt.frontend_port,
|
||||
"frontend_url": rt.frontend_url if rt.running else None,
|
||||
"is_new_mode": rt.is_new_mode,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
try:
|
||||
# Initial status frame so the client knows port/running state
|
||||
# without a second HTTP round-trip. `frontend_url` is the
|
||||
# new-mode preview pointer (Vite dev server); `backend_url` is
|
||||
# the workspace's optional FastAPI backend (old-mode backend.py
|
||||
# OR new-mode post-backend_init.sh).
|
||||
await websocket.send_text(json.dumps(_build_status_frame()))
|
||||
while True:
|
||||
stream, text = await queue.get()
|
||||
await websocket.send_text(json.dumps({
|
||||
@@ -307,6 +317,14 @@ async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str):
|
||||
"workspace_id": workspace_id,
|
||||
"data": {"stream": stream, "text": text},
|
||||
}))
|
||||
# Runtime-level events (start, frontend-ready, exit) flow
|
||||
# through the same log channel with stream="runtime". When
|
||||
# the client sees one, it usually wants the fresh status —
|
||||
# bind-ready in particular flips frontend_url from null
|
||||
# to the Vite URL and the preview pane has to know to
|
||||
# switch over. Re-push status after every runtime line.
|
||||
if stream == "runtime":
|
||||
await websocket.send_text(json.dumps(_build_status_frame()))
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
|
||||
+5
-1
@@ -1,8 +1,12 @@
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
# `py_modules` exposes BOTH `debug` (legacy import name used by OpenSwarm's
|
||||
# own backend) and `swarm_debug` (the import name the webapp-template
|
||||
# scaffold uses, matching the published-package convention `swarm-debug`).
|
||||
# The `swarm_debug` module is a thin re-export of `debug` — see swarm_debug.py.
|
||||
setup(
|
||||
name="debug",
|
||||
version="0.1",
|
||||
packages=find_packages(),
|
||||
py_modules=["debug"]
|
||||
py_modules=["debug", "swarm_debug"]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Module alias — exposes the `debug()` function under the `swarm_debug`
|
||||
name so code that does `from swarm_debug import debug` resolves to the
|
||||
same OpenSwarm-bundled package that the legacy `import debug` path
|
||||
already serves.
|
||||
|
||||
`debug.py` ends with `sys.modules[__name__] = debug`, which replaces the
|
||||
module object with the bare function. That trick lets OpenSwarm's own
|
||||
code write `import debug; debug(x)` (the imported name binds to the
|
||||
function directly), but it means `from debug import debug` doesn't work
|
||||
(you can't attribute-walk a function). This shim captures the function
|
||||
via `import debug` (which now binds to the function thanks to the
|
||||
sys.modules swap) and re-exports it as a normal module attribute, so
|
||||
the more conventional `from swarm_debug import debug` pattern works.
|
||||
"""
|
||||
|
||||
import debug as _debug # noqa: F401 — `_debug` is actually the function
|
||||
|
||||
# Re-export as a module attribute so `from swarm_debug import debug` resolves.
|
||||
debug = _debug
|
||||
|
||||
__all__ = ["debug"]
|
||||
@@ -102,9 +102,17 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 14, y: 14 }, side = 'rig
|
||||
// drawn to the LEFT of the cursor (and its tail anchors on the
|
||||
// bubble's right edge). Default preference is right.
|
||||
let flipX = side === 'left';
|
||||
// `side: 'left'` AND cursor in the lower half → also flip
|
||||
// vertically so the bubble sits ABOVE-LEFT of the cursor rather
|
||||
// than beside its target row. This matters for the chat-input
|
||||
// cluster (cursor-circle / clip / mic) where a below-left bubble
|
||||
// would extend horizontally across the input field's placeholder
|
||||
// text. Above-left stacks the bubble above the input box where
|
||||
// there's clear canvas. Top-half cursors (dashboard toolbar) stay
|
||||
// below-left so the bubble doesn't fly off into the title bar.
|
||||
let flipY = side === 'left' && y > vh / 2;
|
||||
let nx = flipX ? x - w - offset.x : x + offset.x;
|
||||
let ny = y + offset.y;
|
||||
let flipY = false;
|
||||
let ny = flipY ? y - h - offset.y : y + offset.y;
|
||||
|
||||
// Viewport clip: if the preferred side would overflow, flip to the
|
||||
// other side. The flip wins over the preference so the bubble stays
|
||||
@@ -116,9 +124,12 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 14, y: 14 }, side = 'rig
|
||||
nx = x + offset.x;
|
||||
flipX = false;
|
||||
}
|
||||
if (ny + h + SAFE_PAD > vh) {
|
||||
if (!flipY && ny + h + SAFE_PAD > vh) {
|
||||
ny = y - h - offset.y;
|
||||
flipY = true;
|
||||
} else if (flipY && ny < SAFE_PAD) {
|
||||
ny = y + offset.y;
|
||||
flipY = false;
|
||||
}
|
||||
nx = Math.max(SAFE_PAD, Math.min(nx, vw - w - SAFE_PAD));
|
||||
ny = Math.max(SAFE_PAD, Math.min(ny, vh - h - SAFE_PAD));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useMemo, useEffect, useRef, useCallback, PointerEvent as ReactPointerEvent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import TextField from '@mui/material/TextField';
|
||||
@@ -340,17 +341,59 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
|
||||
// agent made) and skip createDraftSession (would orphan the live session).
|
||||
// Just resolve the workspace path and tell AgentChat which session to bind to.
|
||||
if (output?.session_id && output?.workspace_id) {
|
||||
// Resolve the workspace path first (best-effort; chat works without it).
|
||||
let resolvedWorkspacePath: string | null = null;
|
||||
try {
|
||||
const res = await fetch(`${WORKSPACE_API}/${output.workspace_id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.path) setWorkspacePath(data.path);
|
||||
if (data.path) {
|
||||
resolvedWorkspacePath = data.path;
|
||||
setWorkspacePath(data.path);
|
||||
}
|
||||
}
|
||||
} catch { /* path is best-effort; chat still works without it */ }
|
||||
// Pull the latest session state from the backend so the chat catches up
|
||||
// on anything the agent did while the user was on another tab.
|
||||
dispatch(fetchSession(output.session_id));
|
||||
setInitialDraftId(output.session_id);
|
||||
} catch { /* path is best-effort */ }
|
||||
|
||||
// Verify the persisted session still exists on the backend before
|
||||
// binding to it. The id can become stale (backend data wiped,
|
||||
// sessions cleared, different OpenSwarm install) — without this
|
||||
// check we'd hand AgentChat a non-existent id and the chat pane
|
||||
// would be stuck on "Initializing agent…" forever. On 200 we
|
||||
// bind; on 404 we fall through to seed a fresh draft session
|
||||
// attached to the same workspace (preserves app code on disk;
|
||||
// only the chat history is lost — acceptable trade).
|
||||
let sessionStillExists = false;
|
||||
try {
|
||||
const sr = await fetch(`${API_BASE}/agents/sessions/${output.session_id}`);
|
||||
sessionStillExists = sr.ok;
|
||||
} catch { /* network blip — treat as missing */ }
|
||||
|
||||
if (sessionStillExists) {
|
||||
// Pull the latest session state from the backend so the chat
|
||||
// catches up on anything the agent did while the user was on
|
||||
// another tab.
|
||||
dispatch(fetchSession(output.session_id));
|
||||
setInitialDraftId(output.session_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stale linkage. Clear it from the Output so future opens skip
|
||||
// the 404 round-trip, then fall through to createDraftSession
|
||||
// with the same workspace.
|
||||
if (output.id) {
|
||||
try {
|
||||
await dispatch(updateOutput({ id: output.id, session_id: null })).unwrap();
|
||||
} catch { /* best-effort cleanup */ }
|
||||
}
|
||||
const action = dispatch(createDraftSession({
|
||||
mode: 'view-builder',
|
||||
setActive: false,
|
||||
targetDirectory: resolvedWorkspacePath || undefined,
|
||||
model: defaultModel || undefined,
|
||||
provider: resolvedProvider,
|
||||
thinkingLevel: defaultThinkingLevel || undefined,
|
||||
}));
|
||||
setInitialDraftId(action.payload.draftId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -661,19 +704,34 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
|
||||
}, [workspaceId]);
|
||||
|
||||
// Persistent backend lifecycle. Once we know the workspaceId:
|
||||
// 1. POST /runtime/start so the workspace's backend.py (if present)
|
||||
// gets spawned and an `OUTPUT_BACKEND_URL` lands in the preview's
|
||||
// injected script.
|
||||
// 2. Open the runtime WS to stream [BACKEND] stdout/stderr into the
|
||||
// Terminal pane.
|
||||
// 1. POST /runtime/start so the workspace's runtime (bash run.sh
|
||||
// for new-mode webapp-template workspaces; python backend.py for
|
||||
// legacy flat workspaces) gets spawned.
|
||||
// 2. Open the runtime WS to stream [BACKEND]/[RUNTIME] stdout/stderr
|
||||
// into the Terminal pane AND surface the frontend_url for new-
|
||||
// mode workspaces (so the preview pane can point at Vite's dev
|
||||
// server instead of our legacy /serve/ endpoint).
|
||||
// 3. On unmount, POST /runtime/stop. Multiple editors on the same
|
||||
// workspace share the runtime (ref-counted server-side); detach
|
||||
// is a no-op until the last subscriber leaves.
|
||||
const runtimeWsRef = useRef<WebSocket | null>(null);
|
||||
// Where the preview pane should point. New-mode workspaces report a
|
||||
// frontend_url via runtime:status; until it arrives (or for old-mode
|
||||
// workspaces that never set it), we fall back to the legacy
|
||||
// /api/outputs/workspace/{ws}/serve/ endpoint below.
|
||||
const [frontendUrl, setFrontendUrl] = useState<string | null>(null);
|
||||
// Track new-mode separately so the preview pane can show a
|
||||
// "Installing dependencies…" placeholder while Vite is still booting
|
||||
// instead of trying to load the legacy /serve/index.html path (which
|
||||
// 404s — new-mode workspaces have no `index.html` at root, only
|
||||
// `frontend/index.html` reachable via Vite).
|
||||
const [isNewModeRuntime, setIsNewModeRuntime] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!workspaceId) return;
|
||||
let cancelled = false;
|
||||
let ws: WebSocket | null = null;
|
||||
setFrontendUrl(null); // reset when workspace changes
|
||||
setIsNewModeRuntime(false);
|
||||
|
||||
const auth = getAuthToken();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
@@ -695,7 +753,18 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.event === 'runtime:log') {
|
||||
if (msg.event === 'runtime:status') {
|
||||
// New-mode runtimes report a frontend_url here as soon as
|
||||
// Vite has actually bound (the runtime poll-gates it);
|
||||
// old-mode workspaces report null and the preview pane
|
||||
// stays on the legacy /serve/ path. We also track
|
||||
// is_new_mode separately so the placeholder pane shows
|
||||
// "Installing dependencies…" instead of the 404'd /serve
|
||||
// path while Vite is mid-npm-install.
|
||||
const fu = msg.data?.frontend_url ?? null;
|
||||
setFrontendUrl(fu || null);
|
||||
setIsNewModeRuntime(!!msg.data?.is_new_mode);
|
||||
} else if (msg.event === 'runtime:log') {
|
||||
const stream = msg.data?.stream || 'stdout';
|
||||
const text = msg.data?.text || '';
|
||||
if (stream === 'runtime') {
|
||||
@@ -713,6 +782,8 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
|
||||
cancelled = true;
|
||||
try { ws?.close(); } catch (_) {}
|
||||
runtimeWsRef.current = null;
|
||||
setFrontendUrl(null);
|
||||
setIsNewModeRuntime(false);
|
||||
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/stop`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
@@ -720,9 +791,17 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
|
||||
};
|
||||
}, [workspaceId, appendTerminalLine]);
|
||||
|
||||
const workspaceServeUrl = workspaceId
|
||||
? `${SERVE_BASE}/workspace/${workspaceId}/serve/index.html`
|
||||
: undefined;
|
||||
// Preview URL: prefer the new-mode Vite dev server when the runtime
|
||||
// reports one; otherwise fall back to the legacy serve endpoint.
|
||||
// For new-mode workspaces where Vite hasn't bound yet (npm install
|
||||
// still running), `workspaceServeUrl` is undefined and the preview
|
||||
// pane renders the "Installing dependencies…" placeholder below
|
||||
// instead of falling back to the legacy /serve/ path (which 404s —
|
||||
// new-mode workspaces have no `index.html` at root).
|
||||
const showInstallPlaceholder = isNewModeRuntime && !frontendUrl;
|
||||
const workspaceServeUrl = showInstallPlaceholder
|
||||
? undefined
|
||||
: (frontendUrl ?? (workspaceId ? `${SERVE_BASE}/workspace/${workspaceId}/serve/index.html` : undefined));
|
||||
|
||||
const filePaths = useMemo(() => Object.keys(files).filter(p => p !== 'meta.json' && p !== 'SKILL.md').sort(), [files]);
|
||||
const fileTree = useMemo(() => buildFileTree(filePaths), [filePaths]);
|
||||
@@ -994,14 +1073,41 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
|
||||
{/* Tab content */}
|
||||
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||
{activeTab === TAB_PREVIEW && (
|
||||
<ViewPreview
|
||||
ref={previewRef}
|
||||
serveUrl={workspaceServeUrl}
|
||||
frontendCode={!workspaceServeUrl ? (files['index.html'] ?? '') : undefined}
|
||||
inputData={testInput}
|
||||
backendResult={null}
|
||||
onConsoleMessage={handleWebviewConsole}
|
||||
/>
|
||||
showInstallPlaceholder ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1.5,
|
||||
bgcolor: c.bg.surface,
|
||||
p: 3,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={28} sx={{ color: c.accent.primary }} />
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '0.95rem', fontWeight: 600 }}>
|
||||
Installing dependencies…
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.82rem', maxWidth: 420, lineHeight: 1.5 }}>
|
||||
Cold start can take 60–90 seconds. Check the <b>Terminal</b> tab to follow{' '}
|
||||
npm install + Vite startup output. The preview will appear here automatically{' '}
|
||||
once Vite is ready.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<ViewPreview
|
||||
ref={previewRef}
|
||||
serveUrl={workspaceServeUrl}
|
||||
frontendCode={!workspaceServeUrl ? (files['index.html'] ?? '') : undefined}
|
||||
inputData={testInput}
|
||||
backendResult={null}
|
||||
onConsoleMessage={handleWebviewConsole}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === TAB_CODE && (
|
||||
<Box sx={{ display: 'flex', height: '100%' }}>
|
||||
|
||||
@@ -355,8 +355,14 @@ rsync -a \
|
||||
--exclude='*.pyc' --exclude='.venv' \
|
||||
--exclude='data/tools' \
|
||||
--exclude='tests' --exclude='**/tests' \
|
||||
--exclude='.env' --exclude='.env.*' --exclude='**/.env' --exclude='**/.env.*' \
|
||||
--exclude='/.env' --exclude='/.env.*' \
|
||||
"$PROJECT_ROOT/backend/" "$STAGING_DIR/backend/"
|
||||
# Note: .env exclude is anchored to the backend/ source root (`/.env` /
|
||||
# `/.env.*`), not recursive. The vendored webapp-template snapshot at
|
||||
# backend/apps/outputs/webapp_template/.env.example MUST be shipped so
|
||||
# new App workspaces can seed from it; recursive `**/.env*` excludes
|
||||
# would strip it. The top-level backend/.env is still excluded (it's
|
||||
# (re)generated at the production .env step below).
|
||||
|
||||
# Production .env: OAuth helper base URL + Google client_id and client_secret.
|
||||
# v1.0.29 moved the *OAuth flow* (auth-code exchange + refresh) to the Fly
|
||||
|
||||
Executable
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env bash
|
||||
# Re-vendor openswarm-ai/webapp-template into backend/apps/outputs/webapp_template/.
|
||||
#
|
||||
# Idempotent — wipes the existing vendored dir and re-clones at the pinned ref.
|
||||
# Strips files we don't want shipped (LICENSE, README.md, .gitignore — we
|
||||
# author our own minimal .gitignore inside the snapshot). Applies our two
|
||||
# patches:
|
||||
# 1. backend/run.sh: pip-install $OPENSWARM_DEBUGGER_PATH if set, before
|
||||
# the existing `pip install -e .` — resolves the `swarm-debug` dep
|
||||
# from OpenSwarm's bundled debugger/ package instead of PyPI (where
|
||||
# it doesn't exist).
|
||||
# 2. Add our own backend_init.sh at the snapshot root.
|
||||
#
|
||||
# Update REF to bump the pinned snapshot. CI / a future test could compare
|
||||
# `git rev-parse HEAD` of a fresh clone against REF and fail on drift.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="openswarm-ai/webapp-template"
|
||||
REF="main"
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DEST="$ROOT/backend/apps/outputs/webapp_template"
|
||||
TMP="$(mktemp -d)"
|
||||
|
||||
cleanup() { rm -rf "$TMP"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[fetch-webapp-template] cloning $REPO@$REF into $TMP"
|
||||
git clone --depth 1 --branch "$REF" "https://github.com/$REPO.git" "$TMP/clone" >/dev/null
|
||||
|
||||
# Wipe the vendored dir cleanly so deleted upstream files actually leave.
|
||||
rm -rf "$DEST"
|
||||
mkdir -p "$DEST"
|
||||
|
||||
# Copy everything except files we don't ship in OpenSwarm.
|
||||
( cd "$TMP/clone" && rm -rf .git LICENSE README.md .gitignore )
|
||||
cp -R "$TMP/clone/." "$DEST/"
|
||||
|
||||
# Patch 1: backend/run.sh installs OpenSwarm's local debugger/ before the
|
||||
# template's own `pip install -e .` so `from swarm_debug import debug` in
|
||||
# the template's backend code resolves to our bundled package (the PyPI
|
||||
# `swarm-debug` doesn't exist — our local package registers as `debug`
|
||||
# and exposes both `debug` and `swarm_debug` module names via setup.py
|
||||
# py_modules).
|
||||
RUN_SH="$DEST/backend/run.sh"
|
||||
if ! grep -q "OPENSWARM_DEBUGGER_PATH" "$RUN_SH"; then
|
||||
# Insert the install line just before `pip install -e .`. macOS sed
|
||||
# vs GNU sed: use a portable awk inline rewrite.
|
||||
awk '
|
||||
/pip install -e \./ && !inserted {
|
||||
print "if [[ -n \"${OPENSWARM_DEBUGGER_PATH:-}\" && -d \"$OPENSWARM_DEBUGGER_PATH\" ]]; then"
|
||||
print " echo \"Installing OpenSwarm debugger (swarm_debug) from $OPENSWARM_DEBUGGER_PATH\""
|
||||
print " pip install -e \"$OPENSWARM_DEBUGGER_PATH\""
|
||||
print "fi"
|
||||
inserted = 1
|
||||
}
|
||||
{ print }
|
||||
' "$RUN_SH" > "$RUN_SH.tmp" && mv "$RUN_SH.tmp" "$RUN_SH"
|
||||
chmod +x "$RUN_SH"
|
||||
fi
|
||||
|
||||
# Patch 1b: drop `"swarm-debug"` from the template's backend/pyproject.toml
|
||||
# dependencies. The OpenSwarm debugger gets installed separately via Patch
|
||||
# 1's `pip install -e $OPENSWARM_DEBUGGER_PATH`. Leaving the dep listed
|
||||
# would make pip 404 against PyPI (no such package).
|
||||
PYPROJECT="$DEST/backend/pyproject.toml"
|
||||
awk '
|
||||
/^[[:space:]]*"swarm-debug",?[[:space:]]*$/ { next }
|
||||
{ print }
|
||||
' "$PYPROJECT" > "$PYPROJECT.tmp" && mv "$PYPROJECT.tmp" "$PYPROJECT"
|
||||
|
||||
# Patch 2: ship a minimal .gitignore inside the snapshot so per-app
|
||||
# workspaces don't accidentally commit node_modules / .env / venv.
|
||||
cat > "$DEST/.gitignore" <<'EOF'
|
||||
.DS_Store
|
||||
.env
|
||||
node_modules/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
dist/
|
||||
build/
|
||||
EOF
|
||||
|
||||
# Patch 3: backend_init.sh — copied verbatim into every new workspace.
|
||||
# We author this ourselves (not upstream) because the user spec says the
|
||||
# agent runs it to *bring in* the backend dir on demand; the initial seed
|
||||
# leaves backend/ out.
|
||||
cat > "$DEST/backend_init.sh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
# Enable a FastAPI backend for this App.
|
||||
#
|
||||
# Idempotent. The workspace is seeded frontend-only (no backend/ dir,
|
||||
# BACKEND_PORT=NONE). Run this script when your App needs server-side
|
||||
# code — it copies the master template's backend/ into the workspace
|
||||
# and flips BACKEND_PORT in both .env files to a free port.
|
||||
#
|
||||
# After running this, hard-reload the preview (right-click the reload
|
||||
# button in the App Builder) so the runtime restarts with the new
|
||||
# BACKEND_PORT and `bash run.sh` brings the backend up.
|
||||
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$HERE"
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
echo "ERROR: .env not found at $HERE — is this the workspace root?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Source .env so we know the current BACKEND_PORT and the path to the
|
||||
# master template's backend/ (written by OpenSwarm at seed time).
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
if [[ "${BACKEND_PORT:-NONE}" != "NONE" ]]; then
|
||||
echo "Backend already enabled on port $BACKEND_PORT — nothing to do." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -d ./backend ]]; then
|
||||
echo "ERROR: ./backend/ already exists but BACKEND_PORT=NONE — your" >&2
|
||||
echo " workspace is in an inconsistent state. Either delete" >&2
|
||||
echo " ./backend/ and re-run, or set BACKEND_PORT manually." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve master template backend/ path. OPENSWARM_TEMPLATE_BACKEND_PATH
|
||||
# is written into .env at seed time; OPENSWARM_DEBUGGER_PATH the same.
|
||||
if [[ -z "${OPENSWARM_TEMPLATE_BACKEND_PATH:-}" ]]; then
|
||||
echo "ERROR: OPENSWARM_TEMPLATE_BACKEND_PATH not set in .env. This" >&2
|
||||
echo " workspace was seeded by an older OpenSwarm; ask the" >&2
|
||||
echo " App Builder to recreate it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$OPENSWARM_TEMPLATE_BACKEND_PATH" ]]; then
|
||||
echo "ERROR: master template backend dir not found at" >&2
|
||||
echo " $OPENSWARM_TEMPLATE_BACKEND_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Copying backend/ from $OPENSWARM_TEMPLATE_BACKEND_PATH..."
|
||||
cp -R "$OPENSWARM_TEMPLATE_BACKEND_PATH" ./backend
|
||||
chmod +x ./backend/run.sh
|
||||
|
||||
# Pick a free port. SO_REUSEADDR=0 means the kernel won't immediately
|
||||
# recycle, so the small race between bind+close and the backend
|
||||
# re-binding is harmless in practice.
|
||||
PORT="$(python3 -c "import socket
|
||||
s = socket.socket()
|
||||
s.bind(('127.0.0.1', 0))
|
||||
print(s.getsockname()[1])
|
||||
s.close()")"
|
||||
|
||||
# sed-flip both .env and .env.example so an LLM reading either gets the
|
||||
# same answer. macOS sed needs the '' arg for in-place edits.
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' "s/^BACKEND_PORT=NONE/BACKEND_PORT=$PORT/" .env
|
||||
sed -i '' "s/^BACKEND_PORT=NONE/BACKEND_PORT=$PORT/" .env.example
|
||||
else
|
||||
sed -i "s/^BACKEND_PORT=NONE/BACKEND_PORT=$PORT/" .env
|
||||
sed -i "s/^BACKEND_PORT=NONE/BACKEND_PORT=$PORT/" .env.example
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Backend enabled on port $PORT."
|
||||
echo "Hard-reload the preview (right-click the reload button in"
|
||||
echo "the App Builder) to bring it up."
|
||||
EOF
|
||||
chmod +x "$DEST/backend_init.sh"
|
||||
|
||||
echo ""
|
||||
echo "[fetch-webapp-template] vendored snapshot at $DEST"
|
||||
echo "[fetch-webapp-template] pinned ref: $REF"
|
||||
echo "[fetch-webapp-template] file count: $(find "$DEST" -type f | wc -l | tr -d ' ')"
|
||||
Reference in New Issue
Block a user