diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 78a4a7e5..a8307969 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -711,6 +711,11 @@ class AgentManager: global_settings = load_settings() composed_prompt = self._compose_system_prompt(global_settings.default_system_prompt, mode_sys_prompt, session.system_prompt, connected_tools_ctx, outputs_ctx, browser_ctx) + if session.mode == "view-builder": + from backend.apps.outputs.view_builder_templates import VIEW_BUILDER_SKILL + skill_block = f"\n{VIEW_BUILDER_SKILL}\n" + composed_prompt = f"{composed_prompt}\n\n{skill_block}" if composed_prompt else skill_block + mcp_servers = await self._build_mcp_servers(session.allowed_tools) _browser_delegation_tools = ["CreateBrowserAgent", "BrowserAgent", "BrowserAgents"] diff --git a/backend/apps/modes/models.py b/backend/apps/modes/models.py index 03c201fb..0afc742c 100644 --- a/backend/apps/modes/models.py +++ b/backend/apps/modes/models.py @@ -76,43 +76,28 @@ BUILTIN_MODES: list[Mode] = [ ), Mode( id="view-builder", - name="View Builder", - description="Create and iterate on reusable View artifacts.", + name="App Builder", + description="Create and iterate on reusable App artifacts.", system_prompt=( - "You are helping the user build a reusable View — a self-contained " - "web app rendered in an iframe.\n\n" - "Your working directory is a dedicated workspace folder for this view. " - "You can create any file structure you need using the Write tool.\n\n" - "## Required files\n\n" - "1. **index.html** — The entry point. A complete HTML document. " - "React 18 is available via esm.sh CDN imports:\n" - ' \n' - " The structured input data is available at `window.OUTPUT_INPUT` (object) " - "and any server-side result at `window.OUTPUT_BACKEND_RESULT`.\n\n" - "2. **schema.json** — A JSON Schema object defining the structured input " - "the view accepts. Example:\n" - ' {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}\n\n' - "3. **meta.json** — Metadata for this view. Always write this file with " - "a short name and one-sentence description. Example:\n" - ' {"name":"Sales Dashboard","description":"Interactive dashboard showing sales metrics"}\n\n' - "## Optional files\n\n" - "- **backend.py** — Python code that receives `input_data` as " - "a global dict and must assign its result to a global `result` dict.\n" - "- **Any additional files** — You can create subdirectories and split code " - "across multiple files. For example:\n" - " - `components/Chart.js` — Reusable components\n" - " - `utils/helpers.js` — Utility functions\n" - " - `styles/main.css` — Stylesheets\n\n" - "Files are served from the workspace, so relative imports work naturally:\n" - ' ``\n' - ' ``\n' - " `import { helper } from './utils/helpers.js'` (in ES modules)\n\n" - "## Guidelines\n\n" - "Write files immediately when you have code ready. The user can see " - "a live preview that auto-refreshes from these files. Always write the " - "complete file content (do not use Edit for partial patches on first creation). " - "For complex views, split code into separate files to keep things organized." + "You are an App Builder — an AI assistant that creates self-contained " + "web apps rendered in an iframe preview.\n\n" + "Your working directory is a dedicated workspace folder pre-seeded with " + "template files. Read the existing files before making changes.\n\n" + "## Critical rules\n\n" + "- The entry point MUST be named `index.html`. Never rename it or create " + "a different HTML file as the main entry point.\n" + "- Write files immediately when you have code ready — the user sees a " + "live preview that auto-refreshes from these files.\n" + "- Always write the complete file content on first creation (do not use " + "Edit for partial patches on new files).\n" + "- For complex apps, split code into separate files (JS, CSS, etc.) " + "and reference them from index.html with relative paths.\n" + "- Always update meta.json with a short name and one-sentence description.\n" + "- Build beautiful, polished UIs with modern design — dark themes, smooth " + "transitions, proper spacing, and responsive layouts.\n\n" + "Read the SKILL.md reference in your workspace for the full technical " + "specification of the App platform (available globals, file conventions, " + "schema format, backend.py usage, and examples)." ), tools=None, default_next_mode=None, diff --git a/backend/apps/modes/modes.py b/backend/apps/modes/modes.py index 6d393143..37aabd90 100644 --- a/backend/apps/modes/modes.py +++ b/backend/apps/modes/modes.py @@ -59,7 +59,8 @@ def load_mode(mode_id: str) -> Mode | None: @modes.router.get("/list") async def list_modes(): - return {"modes": [m.model_dump() for m in _load_all()]} + builtin_defaults = {m.id: m.model_dump() for m in BUILTIN_MODES} + return {"modes": [m.model_dump() for m in _load_all()], "builtin_defaults": builtin_defaults} @modes.router.get("/{mode_id}") @@ -93,6 +94,16 @@ async def update_mode(mode_id: str, body: ModeUpdate): return {"ok": True, "mode": mode.model_dump()} +@modes.router.post("/{mode_id}/reset") +async def reset_mode(mode_id: str): + """Reset a built-in mode to its hardcoded defaults.""" + builtin = next((m for m in BUILTIN_MODES if m.id == mode_id), None) + if not builtin: + raise HTTPException(status_code=400, detail="Only built-in modes can be reset") + _save(builtin) + return {"ok": True, "mode": builtin.model_dump()} + + @modes.router.delete("/{mode_id}") async def delete_mode(mode_id: str): mode = _load(mode_id) diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 206e29e8..22b3bdf7 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -15,6 +15,7 @@ from backend.apps.outputs.models import ( WorkspaceSeedRequest, ) from backend.apps.outputs.executor import execute_backend_code +from backend.apps.outputs.view_builder_templates import VIEW_BUILDER_SKILL, VIEW_TEMPLATE_FILES from backend.apps.settings.settings import load_settings logger = logging.getLogger(__name__) @@ -235,6 +236,14 @@ async def seed_workspace(body: WorkspaceSeedRequest): os.makedirs(os.path.dirname(full_path), exist_ok=True) with open(full_path, "w") as f: f.write(content) + else: + for rel_path, content in VIEW_TEMPLATE_FILES.items(): + full_path = os.path.join(folder, rel_path) + with open(full_path, "w") as f: + f.write(content) + + with open(os.path.join(folder, "SKILL.md"), "w") as f: + f.write(VIEW_BUILDER_SKILL) if body.meta: with open(os.path.join(folder, "meta.json"), "w") as f: diff --git a/backend/apps/outputs/view_builder_skill.md b/backend/apps/outputs/view_builder_skill.md new file mode 100644 index 00000000..1e78377d --- /dev/null +++ b/backend/apps/outputs/view_builder_skill.md @@ -0,0 +1,223 @@ +# App Builder — Platform Reference + +You are building an **App**: a self-contained web app served in an iframe. +The workspace you're working in is the source of truth — every file you write +here is served directly to the live preview. + +--- + +## File conventions + +| File | Required | Purpose | +|------|----------|---------| +| `index.html` | **Yes** | Entry point. Must be a complete HTML document. This is the ONLY file the preview iframe loads — never rename it. | +| `meta.json` | **Yes** | `{"name":"…","description":"…"}` — displayed in the UI header. Always write this. | +| `schema.json` | Recommended | JSON Schema defining the input form (the "Test Input" tab). | +| `backend.py` | Optional | Server-side Python executed before rendering. | +| 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. +- Assume any external server or API is available unless the user provides one. + +--- + +## Injected globals + +Before `index.html` loads, the platform injects two globals: + +```javascript +window.OUTPUT_INPUT // Object — structured input from the schema form +window.OUTPUT_BACKEND_RESULT // Object | null — result from backend.py execution +``` + +These are available immediately in any ` +``` + +ES module imports between JS files: + +```javascript +// components/Chart.js +import { formatNumber } from '../utils/helpers.js'; +``` + +--- + +## Using React + +React 18 is available via esm.sh CDN — no build step needed: + +```html + +
+ +``` + +Other CDN libraries work too — use `https://esm.sh/` or `https://cdn.jsdelivr.net/npm/` for any npm package. + +--- + +## Design guidelines + +- **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. + +--- + +## Complete minimal example + +```html + + + + + + My App + + + +
+

Loading…

+

+
+ + + +``` diff --git a/backend/apps/outputs/view_builder_templates.py b/backend/apps/outputs/view_builder_templates.py new file mode 100644 index 00000000..0aada357 --- /dev/null +++ b/backend/apps/outputs/view_builder_templates.py @@ -0,0 +1,74 @@ +"""Default template files seeded into new App Builder workspaces.""" + +import os + +_SKILL_PATH = os.path.join(os.path.dirname(__file__), "view_builder_skill.md") + +with open(_SKILL_PATH) as _f: + VIEW_BUILDER_SKILL = _f.read() + +VIEW_TEMPLATE_INDEX = """\ + + + + + + App + + + +
+

Ready

+

Describe what you want to build and the agent will update this app.

+
+ + + +""" + +VIEW_TEMPLATE_SCHEMA = """\ +{ + "type": "object", + "properties": {}, + "required": [] +} +""" + +VIEW_TEMPLATE_META = """\ +{ + "name": "", + "description": "" +} +""" + +VIEW_TEMPLATE_FILES = { + "index.html": VIEW_TEMPLATE_INDEX, + "schema.json": VIEW_TEMPLATE_SCHEMA, + "meta.json": VIEW_TEMPLATE_META, +} diff --git a/electron/package-lock.json b/electron/package-lock.json index a9cbe488..f3ebd483 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "openswarm", - "version": "1.0.8", + "version": "1.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openswarm", - "version": "1.0.8", + "version": "1.0.9", "hasInstallScript": true, "dependencies": { "electron-updater": "^6.3.0", diff --git a/electron/package.json b/electron/package.json index 35450928..53e18f84 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.0.8", + "version": "1.0.10", "description": "OpenSwarm — AI Agent Orchestrator", "main": "main.js", "scripts": { diff --git a/frontend/src/app/pages/Dashboard/AgentCard.tsx b/frontend/src/app/pages/Dashboard/AgentCard.tsx index f4bae893..751d09ea 100644 --- a/frontend/src/app/pages/Dashboard/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/AgentCard.tsx @@ -404,11 +404,6 @@ const AgentCard: React.FC = ({ } }; - const handleCollapse = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - dispatch(collapseSession(session.id)); - }; useEffect(() => { if (session.status === 'running' || session.status === 'waiting_approval') { @@ -750,37 +745,20 @@ const AgentCard: React.FC = ({ onPointerDown={(e) => e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }} > - {expanded ? ( - - e.stopPropagation()} - sx={{ - color: c.text.ghost, - p: 0.5, - '&:hover': { color: c.text.secondary, bgcolor: c.bg.secondary }, - }} - > - - - - ) : ( - - e.stopPropagation()} - sx={{ - color: c.text.ghost, - p: 0.5, - '&:hover': { color: c.status.error, bgcolor: `${c.status.errorBg}` }, - }} - > - - - - )} + + e.stopPropagation()} + sx={{ + color: c.text.ghost, + p: 0.5, + '&:hover': { color: c.status.error, bgcolor: `${c.status.errorBg}` }, + }} + > + + + diff --git a/frontend/src/app/pages/Modes/Modes.tsx b/frontend/src/app/pages/Modes/Modes.tsx index e7e75627..2b024b81 100644 --- a/frontend/src/app/pages/Modes/Modes.tsx +++ b/frontend/src/app/pages/Modes/Modes.tsx @@ -27,6 +27,7 @@ import DeleteIcon from '@mui/icons-material/Delete'; import TuneIcon from '@mui/icons-material/Tune'; import LockIcon from '@mui/icons-material/Lock'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import RestoreIcon from '@mui/icons-material/Restore'; import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined'; import MapOutlinedIcon from '@mui/icons-material/MapOutlined'; @@ -37,6 +38,7 @@ import { createMode, updateMode, deleteMode, + resetMode, Mode, } from '@/shared/state/modesSlice'; import { fetchBuiltinTools, fetchTools } from '@/shared/state/toolsSlice'; @@ -105,7 +107,7 @@ const ALL_BUILTIN_TOOL_NAMES = ['Read', 'Edit', 'Write', 'Bash', 'Glob', 'Grep', const Modes: React.FC = () => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); - const { items, loading } = useAppSelector((s) => s.modes); + const { items, builtinDefaults, loading } = useAppSelector((s) => s.modes); const toolItems = useAppSelector((s) => s.tools.items); const modes = useMemo(() => Object.values(items), [items]); @@ -174,6 +176,45 @@ const Modes: React.FC = () => { await dispatch(deleteMode(id)); }; + const editingIsBuiltin = editingId ? items[editingId]?.is_builtin ?? false : false; + + const hasDiverged = useMemo(() => { + if (!editingId || !editingIsBuiltin) return false; + const defaults = builtinDefaults[editingId]; + if (!defaults) return false; + const current = items[editingId]; + if (!current) return false; + return ( + current.name !== defaults.name || + current.description !== defaults.description || + (current.system_prompt ?? '') !== (defaults.system_prompt ?? '') || + JSON.stringify(current.tools) !== JSON.stringify(defaults.tools) || + (current.default_next_mode ?? '') !== (defaults.default_next_mode ?? '') || + current.icon !== defaults.icon || + current.color !== defaults.color || + (current.default_folder ?? '') !== (defaults.default_folder ?? '') + ); + }, [editingId, editingIsBuiltin, items, builtinDefaults]); + + const handleReset = async () => { + if (!editingId) return; + const action = await dispatch(resetMode(editingId)); + if (resetMode.fulfilled.match(action)) { + const m = action.payload; + setForm({ + name: m.name, + description: m.description, + system_prompt: m.system_prompt ?? '', + tools: m.tools ?? [], + toolsEnabled: m.tools !== null, + default_next_mode: m.default_next_mode ?? '', + icon: m.icon, + color: m.color, + default_folder: m.default_folder ?? '', + }); + } + }; + const otherModes = modes.filter((m) => m.id !== editingId); return ( @@ -505,23 +546,46 @@ const Modes: React.FC = () => { - - - + + + {editingIsBuiltin && ( + + + + + + )} + + + + + diff --git a/frontend/src/app/pages/Views/ViewEditor.tsx b/frontend/src/app/pages/Views/ViewEditor.tsx index 8a9c2ac8..75ea08c7 100644 --- a/frontend/src/app/pages/Views/ViewEditor.tsx +++ b/frontend/src/app/pages/Views/ViewEditor.tsx @@ -381,7 +381,7 @@ interface FileTreeItemProps { c: ReturnType; } -const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json']); +const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json', 'SKILL.md']); const FileTreeItem: React.FC = ({ node, depth, activeFile, onSelect, onDelete, c }) => { const [open, setOpen] = useState(true); @@ -732,6 +732,7 @@ const ViewEditor: React.FC = ({ output, onClose }) => { const outputFiles = { ...files }; delete outputFiles['meta.json']; delete outputFiles['schema.json']; + delete outputFiles['SKILL.md']; return { name: name || 'Untitled App', @@ -948,7 +949,7 @@ const ViewEditor: React.FC = ({ output, onClose }) => { ? `${SERVE_BASE}/workspace/${workspaceId}/serve/index.html` : undefined; - const filePaths = useMemo(() => Object.keys(files).filter(p => p !== 'meta.json').sort(), [files]); + const filePaths = useMemo(() => Object.keys(files).filter(p => p !== 'meta.json' && p !== 'SKILL.md').sort(), [files]); const fileTree = useMemo(() => buildFileTree(filePaths), [filePaths]); const updateFile = useCallback((path: string, content: string) => { diff --git a/frontend/src/shared/state/modesSlice.ts b/frontend/src/shared/state/modesSlice.ts index e002ff10..9a294d64 100644 --- a/frontend/src/shared/state/modesSlice.ts +++ b/frontend/src/shared/state/modesSlice.ts @@ -18,18 +18,19 @@ export interface Mode { interface ModesState { items: Record; + builtinDefaults: Record; loading: boolean; loaded: boolean; } -const initialState: ModesState = { items: {}, loading: false, loaded: false }; +const initialState: ModesState = { items: {}, builtinDefaults: {}, loading: false, loaded: false }; export const fetchModes = createAsyncThunk( 'modes/fetch', async () => { const res = await fetch(`${MODES_API}/list`); const data = await res.json(); - return data.modes as Mode[]; + return { modes: data.modes as Mode[], builtinDefaults: (data.builtin_defaults ?? {}) as Record }; }, { condition: (_, { getState }) => !(getState() as { modes: ModesState }).modes.loading }, ); @@ -60,6 +61,15 @@ export const updateMode = createAsyncThunk( } ); +export const resetMode = createAsyncThunk( + 'modes/reset', + async (id: string) => { + const res = await fetch(`${MODES_API}/${id}/reset`, { method: 'POST' }); + const data = await res.json(); + return data.mode as Mode; + } +); + export const deleteMode = createAsyncThunk('modes/delete', async (id: string) => { await fetch(`${MODES_API}/${id}`, { method: 'DELETE' }); return id; @@ -76,11 +86,13 @@ const modesSlice = createSlice({ state.loading = false; state.loaded = true; state.items = {}; - for (const m of action.payload) state.items[m.id] = m; + for (const m of action.payload.modes) state.items[m.id] = m; + state.builtinDefaults = action.payload.builtinDefaults; }) .addCase(fetchModes.rejected, (state) => { state.loading = false; state.loaded = true; }) .addCase(createMode.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; }) .addCase(updateMode.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; }) + .addCase(resetMode.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; }) .addCase(deleteMode.fulfilled, (state, action) => { delete state.items[action.payload]; }); }, });