[Haik]: ckpt, view builder saving issue fixed via templates, system prompts, and skills. Also added a reset to default for modes.

This commit is contained in:
haikdc
2026-03-19 01:58:20 -07:00
parent 9943ccafd5
commit 6dcfb19b17
12 changed files with 461 additions and 99 deletions
+5
View File
@@ -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"<app_builder_reference>\n{VIEW_BUILDER_SKILL}\n</app_builder_reference>"
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"]
+21 -36
View File
@@ -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"
' <script type="importmap">{"imports":{"react":"https://esm.sh/react@18",'
'"react-dom/client":"https://esm.sh/react-dom@18/client"}}</script>\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"
' `<script type="module" src="./components/Chart.js"></script>`\n'
' `<link rel="stylesheet" href="./styles/main.css">`\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,
+12 -1
View File
@@ -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)
+9
View File
@@ -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:
+223
View File
@@ -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 `<script>` tag. You can also listen for
live updates when the user changes input:
```javascript
window.addEventListener('output-data-ready', () => {
const input = window.OUTPUT_INPUT;
const result = window.OUTPUT_BACKEND_RESULT;
// re-render with new data
});
```
---
## schema.json format
Standard JSON Schema. The platform renders a form from this automatically.
```json
{
"type": "object",
"properties": {
"title": { "type": "string", "default": "My Dashboard" },
"count": { "type": "number", "default": 10 },
"enabled": { "type": "boolean", "default": true },
"items": {
"type": "array",
"items": { "type": "string" },
"default": ["alpha", "beta"]
}
},
"required": ["title"]
}
```
Supported types: `string`, `number`, `integer`, `boolean`, `array`, `object`.
Use `"default"` values so the preview works without manual input.
---
## backend.py
Optional server-side Python that runs before the frontend renders.
It receives a global `input_data` dict (the schema form values) and must
assign its result to a global `result` dict.
```python
# input_data is pre-populated from the schema form
import json
result = {
"processed_items": [item.upper() for item in input_data.get("items", [])],
"timestamp": "2024-01-01T00:00:00Z",
}
```
The `result` dict becomes `window.OUTPUT_BACKEND_RESULT` in the frontend.
---
## Multi-file projects
Split code across files for organization. All files are served from the
workspace root, so relative imports work naturally:
```
workspace/
├── index.html
├── meta.json
├── schema.json
├── styles/
│ └── main.css
├── components/
│ └── Chart.js
└── utils/
└── helpers.js
```
Reference from `index.html`:
```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';
```
---
## Using React
React 18 is available via esm.sh CDN — no build step needed:
```html
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18",
"react-dom/client": "https://esm.sh/react-dom@18/client"
}
}
</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')
);
}
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.
---
## 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
<!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>
```
@@ -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 = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>App</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f1117;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.container {
background: #1a1d27;
border: 1px solid #2e3248;
border-radius: 12px;
padding: 32px;
max-width: 600px;
width: 100%;
text-align: center;
}
h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; }
p { color: #8892a4; font-size: 0.95rem; line-height: 1.6; }
</style>
</head>
<body>
<div class="container">
<h1 id="title">Ready</h1>
<p id="desc">Describe what you want to build and the agent will update this app.</p>
</div>
<script>
const input = window.OUTPUT_INPUT || {};
const result = window.OUTPUT_BACKEND_RESULT || null;
</script>
</body>
</html>
"""
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,
}
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openswarm",
"version": "1.0.8",
"version": "1.0.10",
"description": "OpenSwarm — AI Agent Orchestrator",
"main": "main.js",
"scripts": {
+14 -36
View File
@@ -404,11 +404,6 @@ const AgentCard: React.FC<Props> = ({
}
};
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<Props> = ({
onPointerDown={(e) => e.stopPropagation()}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }}
>
{expanded ? (
<Tooltip title="Collapse">
<IconButton
size="small"
onClick={handleCollapse}
onMouseDown={(e) => e.stopPropagation()}
sx={{
color: c.text.ghost,
p: 0.5,
'&:hover': { color: c.text.secondary, bgcolor: c.bg.secondary },
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
) : (
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
<IconButton
size="small"
onClick={handleRemove}
onMouseDown={(e) => e.stopPropagation()}
sx={{
color: c.text.ghost,
p: 0.5,
'&:hover': { color: c.status.error, bgcolor: `${c.status.errorBg}` },
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
<IconButton
size="small"
onClick={handleRemove}
onMouseDown={(e) => e.stopPropagation()}
sx={{
color: c.text.ghost,
p: 0.5,
'&:hover': { color: c.status.error, bgcolor: `${c.status.errorBg}` },
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Box>
</Box>
+82 -18
View File
@@ -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 = () => {
</FormControl>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setDialogOpen(false)} sx={{ color: c.text.tertiary, textTransform: 'none' }}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={!form.name}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
borderRadius: 2,
}}
>
{editingId ? 'Save Changes' : 'Create Mode'}
</Button>
<DialogActions sx={{ px: 3, pb: 2, justifyContent: 'space-between' }}>
<Box>
{editingIsBuiltin && (
<Tooltip title={hasDiverged ? 'Restore this mode to its original built-in defaults' : 'Mode matches built-in defaults'}>
<span>
<Button
startIcon={<RestoreIcon sx={{ fontSize: 16 }} />}
onClick={handleReset}
disabled={!hasDiverged}
sx={{
color: hasDiverged ? c.text.muted : c.text.ghost,
textTransform: 'none',
fontSize: '0.82rem',
'&:hover': hasDiverged ? { color: c.status.error, bgcolor: `${c.status.error}10` } : {},
}}
>
Reset to Default
</Button>
</span>
</Tooltip>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button onClick={() => setDialogOpen(false)} sx={{ color: c.text.tertiary, textTransform: 'none' }}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={!form.name}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
borderRadius: 2,
}}
>
{editingId ? 'Save Changes' : 'Create Mode'}
</Button>
</Box>
</DialogActions>
</Dialog>
+3 -2
View File
@@ -381,7 +381,7 @@ interface FileTreeItemProps {
c: ReturnType<typeof useClaudeTokens>;
}
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<FileTreeItemProps> = ({ node, depth, activeFile, onSelect, onDelete, c }) => {
const [open, setOpen] = useState(true);
@@ -732,6 +732,7 @@ const ViewEditor: React.FC<Props> = ({ 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<Props> = ({ 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) => {
+15 -3
View File
@@ -18,18 +18,19 @@ export interface Mode {
interface ModesState {
items: Record<string, Mode>;
builtinDefaults: Record<string, Mode>;
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<string, Mode> };
},
{ 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]; });
},
});