mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 20:27:44 +02:00
[eric] App Builder skill as editable built-in (Skills page, undeletable) + onboarding step 1 unsticks after Claude Max / ChatGPT Pro / Gemini /
custom-provider connect (subscription state moved to Redux)
This commit is contained in:
@@ -1482,8 +1482,14 @@ class AgentManager:
|
||||
)
|
||||
|
||||
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>"
|
||||
# Read the LIVE skill content rather than a frozen-at-import
|
||||
# constant. The skill is registered as a built-in skill at
|
||||
# ~/.claude/skills/app_builder_skill.md (see
|
||||
# backend/apps/skills/skills.py); user edits in the Skills
|
||||
# page land there and propagate to the agent's prompt on
|
||||
# the next turn without a restart.
|
||||
from backend.apps.outputs.view_builder_templates import load_app_builder_skill
|
||||
skill_block = f"<app_builder_reference>\n{load_app_builder_skill()}\n</app_builder_reference>"
|
||||
composed_prompt = f"{composed_prompt}\n\n{skill_block}" if composed_prompt else skill_block
|
||||
|
||||
# Per-turn estimate of framework overhead (subtracted from displayed
|
||||
|
||||
+54
-56
@@ -1,8 +1,10 @@
|
||||
# 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -10,10 +12,9 @@ here is served directly to the live preview.
|
||||
|
||||
| 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. |
|
||||
| `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. |
|
||||
| `schema.json` | Recommended | JSON Schema defining the input form (the "Test Input" tab). |
|
||||
| `backend.py` | Optional | Server-side Python executed before rendering. |
|
||||
| `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
|
||||
@@ -21,75 +22,72 @@ here is served directly to the live preview.
|
||||
- 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.
|
||||
- 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 two globals:
|
||||
Before `index.html` loads, the platform injects:
|
||||
|
||||
```javascript
|
||||
window.OUTPUT_INPUT // Object — structured input from the schema form
|
||||
window.OUTPUT_BACKEND_RESULT // Object | null — result from backend.py execution
|
||||
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"
|
||||
```
|
||||
|
||||
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
|
||||
});
|
||||
```
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
## schema.json format
|
||||
## backend.py — persistent HTTP server
|
||||
|
||||
Standard JSON Schema. The platform renders a form from this automatically.
|
||||
`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.
|
||||
|
||||
```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"]
|
||||
}
|
||||
```
|
||||
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`).
|
||||
|
||||
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.
|
||||
Minimal FastAPI example:
|
||||
|
||||
```python
|
||||
# input_data is pre-populated from the schema form
|
||||
import json
|
||||
# backend.py
|
||||
import os
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
result = {
|
||||
"processed_items": [item.upper() for item in input_data.get("items", [])],
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
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"]))
|
||||
```
|
||||
|
||||
The `result` dict becomes `window.OUTPUT_BACKEND_RESULT` in the frontend.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -102,7 +100,7 @@ workspace root, so relative imports work naturally:
|
||||
workspace/
|
||||
├── index.html
|
||||
├── meta.json
|
||||
├── schema.json
|
||||
├── backend.py (optional)
|
||||
├── styles/
|
||||
│ └── main.css
|
||||
├── components/
|
||||
@@ -16,7 +16,7 @@ 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_BUILDER_SKILL, VIEW_TEMPLATE_FILES
|
||||
from backend.apps.outputs.view_builder_templates import VIEW_TEMPLATE_FILES, load_app_builder_skill
|
||||
from backend.apps.settings.settings import load_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -329,8 +329,14 @@ async def seed_workspace(body: WorkspaceSeedRequest):
|
||||
with open(full_path, "w") as f:
|
||||
f.write(content)
|
||||
|
||||
# Seed the workspace's SKILL.md with the LIVE skill content so an
|
||||
# agent that Reads SKILL.md sees the same text the Skills page shows.
|
||||
# Snapshot at workspace creation; subsequent edits don't rewrite
|
||||
# already-seeded workspaces (the system-prompt injection in
|
||||
# agent_manager reads live, so the agent always has the latest
|
||||
# rules regardless of this on-disk copy).
|
||||
with open(os.path.join(folder, "SKILL.md"), "w") as f:
|
||||
f.write(VIEW_BUILDER_SKILL)
|
||||
f.write(load_app_builder_skill())
|
||||
|
||||
if body.meta:
|
||||
with open(os.path.join(folder, "meta.json"), "w") as f:
|
||||
|
||||
@@ -2,10 +2,38 @@
|
||||
|
||||
import os
|
||||
|
||||
_SKILL_PATH = os.path.join(os.path.dirname(__file__), "view_builder_skill.md")
|
||||
# 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")
|
||||
|
||||
with open(_SKILL_PATH, encoding="utf-8") as _f:
|
||||
VIEW_BUILDER_SKILL = _f.read()
|
||||
# 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).
|
||||
with open(APP_BUILDER_SKILL_SOURCE_PATH, encoding="utf-8") as _f:
|
||||
APP_BUILDER_SKILL_DEFAULT = _f.read()
|
||||
|
||||
|
||||
def load_app_builder_skill() -> str:
|
||||
"""Return the live App Builder skill content. Prefers the
|
||||
user-editable copy at ~/.claude/skills/app_builder_skill.md (so a
|
||||
user's edit on the Skills page takes effect on the very next App
|
||||
Builder agent turn — no restart, no copy-on-edit dance). Falls back
|
||||
to the bundled default if the user file is somehow gone."""
|
||||
user_path = os.path.expanduser("~/.claude/skills/app_builder_skill.md")
|
||||
if os.path.exists(user_path):
|
||||
try:
|
||||
with open(user_path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
pass
|
||||
return APP_BUILDER_SKILL_DEFAULT
|
||||
|
||||
|
||||
# Backward-compat alias. Older callers import VIEW_BUILDER_SKILL directly —
|
||||
# point them at the same content as the user-editable version so a "frozen
|
||||
# at import" stale copy can't drift from what the skills page shows.
|
||||
VIEW_BUILDER_SKILL = APP_BUILDER_SKILL_DEFAULT
|
||||
|
||||
VIEW_TEMPLATE_INDEX = """\
|
||||
<!DOCTYPE html>
|
||||
|
||||
@@ -10,6 +10,12 @@ class Skill(BaseModel):
|
||||
content: str
|
||||
file_path: str = ""
|
||||
command: str = ""
|
||||
# Skills that OpenSwarm ships as part of the platform (e.g. the App
|
||||
# Builder reference) get this flag set. The UI hides the delete
|
||||
# button for them and the DELETE endpoint refuses with 409. Content
|
||||
# is still editable — the whole point is that users can tune how
|
||||
# the platform-internal agents behave.
|
||||
built_in: bool = False
|
||||
|
||||
|
||||
class SkillCreate(BaseModel):
|
||||
|
||||
@@ -16,16 +16,6 @@ INDEX_PATH = os.path.join(SKILLS_DIR, ".skills_index.json")
|
||||
from backend.config.paths import SKILLS_WORKSPACE_DIR
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def skills_lifespan():
|
||||
os.makedirs(SKILLS_DIR, exist_ok=True)
|
||||
os.makedirs(SKILLS_WORKSPACE_DIR, exist_ok=True)
|
||||
yield
|
||||
|
||||
|
||||
skills = SubApp("skills", skills_lifespan)
|
||||
|
||||
|
||||
def _load_index() -> dict[str, dict]:
|
||||
if os.path.exists(INDEX_PATH):
|
||||
with open(INDEX_PATH) as f:
|
||||
@@ -38,6 +28,85 @@ def _save_index(index: dict[str, dict]):
|
||||
json.dump(index, f, indent=2)
|
||||
|
||||
|
||||
# Built-in skills shipped with OpenSwarm itself. Each entry describes a
|
||||
# skill file we copy into ~/.claude/skills/ on first boot and tag with
|
||||
# `built_in: true` in the index. Users can edit the content (their
|
||||
# changes flow through to the matching agent's prompt on the next turn),
|
||||
# but they can't delete the file — the DELETE endpoint refuses with 409.
|
||||
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
|
||||
return [
|
||||
{
|
||||
"id": "app_builder_skill",
|
||||
"name": "App Builder",
|
||||
"description": (
|
||||
"Reference doc the App Builder agent reads on every turn. "
|
||||
"Edit this to change how every App Builder agent behaves — "
|
||||
"your edits take effect on the next turn, no restart. "
|
||||
"Built-in: can be edited but not deleted."
|
||||
),
|
||||
"command": "app-builder-skill",
|
||||
"source_path": APP_BUILDER_SKILL_SOURCE_PATH,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _seed_built_in_skills() -> None:
|
||||
"""Copy each built-in skill into SKILLS_DIR if not already present, and
|
||||
ensure the index has the `built_in: true` flag so the UI and DELETE
|
||||
endpoint know to treat it specially. Idempotent — safe to call on
|
||||
every boot. Doesn't overwrite the file once it exists (so user edits
|
||||
are preserved across restarts and upgrades)."""
|
||||
index = _load_index()
|
||||
dirty = False
|
||||
for entry in _built_in_skill_registry():
|
||||
skill_id = entry["id"]
|
||||
fpath = os.path.join(SKILLS_DIR, f"{skill_id}.md")
|
||||
if not os.path.exists(fpath):
|
||||
try:
|
||||
with open(entry["source_path"], encoding="utf-8") as src:
|
||||
content = src.read()
|
||||
with open(fpath, "w", encoding="utf-8") as dst:
|
||||
dst.write(content)
|
||||
except FileNotFoundError:
|
||||
logger.warning("built-in skill source missing: %s", entry["source_path"])
|
||||
continue
|
||||
# Refresh index metadata. Existing user-changed name/description
|
||||
# in the index stays, but built_in always gets re-asserted in case
|
||||
# the index was created before this mechanism existed.
|
||||
meta = dict(index.get(skill_id, {}))
|
||||
meta.setdefault("name", entry["name"])
|
||||
meta.setdefault("description", entry["description"])
|
||||
meta.setdefault("command", entry["command"])
|
||||
if not meta.get("built_in"):
|
||||
meta["built_in"] = True
|
||||
dirty = True
|
||||
if index.get(skill_id) != meta:
|
||||
index[skill_id] = meta
|
||||
dirty = True
|
||||
if dirty:
|
||||
_save_index(index)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def skills_lifespan():
|
||||
os.makedirs(SKILLS_DIR, exist_ok=True)
|
||||
os.makedirs(SKILLS_WORKSPACE_DIR, exist_ok=True)
|
||||
try:
|
||||
_seed_built_in_skills()
|
||||
except Exception:
|
||||
# Don't block app startup on a skill-seed failure — the worst
|
||||
# case is the user has to manually paste the skill in once.
|
||||
logger.exception("failed to seed built-in skills")
|
||||
yield
|
||||
|
||||
|
||||
skills = SubApp("skills", skills_lifespan)
|
||||
|
||||
|
||||
def _sync_skills() -> list[Skill]:
|
||||
"""Sync skills from the filesystem, updating the index."""
|
||||
index = _load_index()
|
||||
@@ -59,6 +128,7 @@ def _sync_skills() -> list[Skill]:
|
||||
content=content,
|
||||
file_path=fpath,
|
||||
command=meta.get("command", fname.replace(".md", "")),
|
||||
built_in=bool(meta.get("built_in", False)),
|
||||
)
|
||||
result.append(skill)
|
||||
|
||||
@@ -204,10 +274,19 @@ async def update_skill(skill_id: str, body: SkillUpdate):
|
||||
|
||||
@skills.router.delete("/{skill_id}")
|
||||
async def delete_skill(skill_id: str):
|
||||
index = _load_index()
|
||||
if index.get(skill_id, {}).get("built_in"):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"'{skill_id}' is a built-in skill and can't be deleted "
|
||||
"(edit its content instead — your edits take effect on "
|
||||
"the next agent turn)."
|
||||
),
|
||||
)
|
||||
fpath = os.path.join(SKILLS_DIR, f"{skill_id}.md")
|
||||
if os.path.exists(fpath):
|
||||
os.remove(fpath)
|
||||
index = _load_index()
|
||||
index.pop(skill_id, None)
|
||||
_save_index(index)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -4,17 +4,37 @@
|
||||
// user already did the thing.
|
||||
|
||||
import type { RootState } from '@/shared/state/store';
|
||||
import {
|
||||
hasAnyActiveSubscription,
|
||||
} from '@/shared/state/subscriptionsSlice';
|
||||
|
||||
export function hasModelConnected(s: RootState): boolean {
|
||||
const d = s.settings.data as any;
|
||||
if (!d) return false;
|
||||
// Path 1: OpenSwarm Pro cloud bearer.
|
||||
if (d.connection_mode === 'openswarm-pro' && d.openswarm_bearer_token) return true;
|
||||
return Boolean(
|
||||
// Path 2: first-party API keys typed into Settings → Models.
|
||||
if (
|
||||
d.anthropic_api_key ||
|
||||
d.openai_api_key ||
|
||||
d.google_api_key ||
|
||||
d.openrouter_api_key,
|
||||
);
|
||||
d.openai_api_key ||
|
||||
d.google_api_key ||
|
||||
d.openrouter_api_key
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Path 3: custom OpenAI-compatible providers (LM Studio, Ollama, etc.).
|
||||
// Match the validity rule the Settings page uses to render the provider
|
||||
// row: name + base_url present. The api_key field is intentionally
|
||||
// optional — local OpenAI-compatible servers don't require one.
|
||||
const customs = (d.custom_providers || []) as any[];
|
||||
if (customs.some((cp) => cp?.name?.trim() && cp?.base_url?.trim())) {
|
||||
return true;
|
||||
}
|
||||
// Path 4: external OAuth subscriptions (Claude Max, ChatGPT, etc.). The
|
||||
// tokens live in 9Router-managed storage and are surfaced to the frontend
|
||||
// only via the subscriptionsSlice mirror of /agents/subscriptions/status.
|
||||
if (hasAnyActiveSubscription(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasAnyToolEnabled(s: RootState): boolean {
|
||||
|
||||
@@ -46,6 +46,11 @@ import { onboardingBus } from '@/app/components/Onboarding/eventBus';
|
||||
import { resetTour } from '@/app/components/Onboarding/OnboardingProgressSlice';
|
||||
import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config';
|
||||
import { fetchModels } from '@/shared/state/modelsSlice';
|
||||
import {
|
||||
fetchSubscriptionStatus,
|
||||
setSubscriptionStatus,
|
||||
selectSubscriptionConnections,
|
||||
} from '@/shared/state/subscriptionsSlice';
|
||||
import { setChecking, setUpdateError, setInstalling } from '@/shared/state/updateSlice';
|
||||
import { fetchModes } from '@/shared/state/modesSlice';
|
||||
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
|
||||
@@ -601,31 +606,28 @@ const OpenSwarmProCard: React.FC = () => {
|
||||
const SubscriptionCards: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const [status, setStatus] = useState<any>(null);
|
||||
// `status` and the polymorphic-shape `connections` array now live in the
|
||||
// subscriptionsSlice. The onboarding gate (hasModelConnected in
|
||||
// skipPredicates.ts) reads the same slice, so OAuth-driven connections
|
||||
// unstick step 1 the moment they land — previously this card kept the
|
||||
// status in local useState, which the onboarding predicate could never
|
||||
// observe.
|
||||
const status = useAppSelector((s) => s.subscriptions.status);
|
||||
const connections = useAppSelector(selectSubscriptionConnections);
|
||||
const [connecting, setConnecting] = useState<string | null>(null);
|
||||
const [disconnecting, setDisconnecting] = useState<string | null>(null);
|
||||
const [userCode, setUserCode] = useState('');
|
||||
const [pollTimer, setPollTimer] = useState<any>(null);
|
||||
|
||||
// `preserveTransient` keeps a previously-seen `running: true` state when a
|
||||
// refresh comes back with `running: false`. The backend's is_running() probe
|
||||
// has a short sync timeout that can be exceeded while 9Router is streaming
|
||||
// inference, producing false negatives that would otherwise flip these
|
||||
// cards into a "Starting subscription service..." spinner mid-session.
|
||||
const fetchStatus = useCallback(async (opts?: { preserveTransient?: boolean }) => {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/agents/subscriptions/status`);
|
||||
const data = await r.json();
|
||||
setStatus((prev: any) => {
|
||||
if (opts?.preserveTransient && prev?.running && !data?.running) return prev;
|
||||
return data;
|
||||
});
|
||||
return data;
|
||||
} catch {
|
||||
setStatus((prev: any) => prev ?? { running: false, providers: [], models: [] });
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
// Thin wrapper around the slice thunk — returns the resolved status so
|
||||
// call sites that inspect the payload (e.g. the initial-load retry loop
|
||||
// checking `data?.running`) keep working unchanged.
|
||||
const fetchStatus = useCallback(
|
||||
async (opts?: { preserveTransient?: boolean }) => {
|
||||
return dispatch(fetchSubscriptionStatus(opts)).unwrap();
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
// Refresh the chat model picker whenever subscription connection state
|
||||
// changes — GET /agents/models intersects BUILTIN_MODELS with 9Router's
|
||||
@@ -650,11 +652,11 @@ const SubscriptionCards: React.FC = () => {
|
||||
return () => { cancelled = true; clearInterval(interval); };
|
||||
}, [fetchStatus]);
|
||||
|
||||
const isConnected = (providerId: string) => {
|
||||
if (!status?.providers) return false;
|
||||
const connections = status.providers?.connections || (Array.isArray(status.providers) ? status.providers : []);
|
||||
return connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'));
|
||||
};
|
||||
const isConnected = (providerId: string) =>
|
||||
connections.some(
|
||||
(p: any) =>
|
||||
p.provider === providerId && (p.isActive || p.testStatus === 'active'),
|
||||
);
|
||||
|
||||
const handleConnect = async (providerId: string) => {
|
||||
// Cancel any previous attempt first
|
||||
@@ -961,7 +963,7 @@ const SubscriptionCards: React.FC = () => {
|
||||
if (cancelled) return;
|
||||
const conns = d?.providers?.connections || [];
|
||||
if (conns.some((p: any) => p.provider === connecting && (p.isActive || p.testStatus === 'active'))) {
|
||||
setStatus(d);
|
||||
dispatch(setSubscriptionStatus(d));
|
||||
setConnecting(null);
|
||||
setUserCode('');
|
||||
refreshPickerModels();
|
||||
|
||||
@@ -614,20 +614,37 @@ const Skills: React.FC = () => {
|
||||
<Box sx={{ p: 4, pb: 3, maxWidth: 1100, display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
|
||||
{/* Header row: name + actions */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5, flexShrink: 0 }}>
|
||||
<Typography sx={{ fontSize: '1.4rem', fontWeight: 700, color: c.text.primary, fontFamily: c.font.sans }}>
|
||||
{selectedLocal.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ fontSize: '1.4rem', fontWeight: 700, color: c.text.primary, fontFamily: c.font.sans }}>
|
||||
{selectedLocal.name}
|
||||
</Typography>
|
||||
{selectedLocal.built_in && (
|
||||
<Chip
|
||||
label="Built-in"
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: 'rgba(174,86,48,0.12)',
|
||||
color: c.accent.primary,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.7rem',
|
||||
height: 20,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => openEdit(selectedLocal)} sx={{ color: c.text.tertiary, '&:hover': { color: c.accent.primary } }}>
|
||||
<EditIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" onClick={() => handleDelete(selectedLocal.id)} sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error } }}>
|
||||
<DeleteIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{!selectedLocal.built_in && (
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" onClick={() => handleDelete(selectedLocal.id)} sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error } }}>
|
||||
<DeleteIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ export interface Skill {
|
||||
content: string;
|
||||
file_path: string;
|
||||
command: string;
|
||||
// Set true for skills OpenSwarm ships with the platform (currently
|
||||
// app_builder_skill). UI hides the delete button; backend DELETE
|
||||
// returns 409. Content is still editable.
|
||||
built_in?: boolean;
|
||||
}
|
||||
|
||||
interface SkillsState {
|
||||
|
||||
@@ -13,6 +13,7 @@ import dashboardsReducer from './dashboardsSlice';
|
||||
import updateReducer from './updateSlice';
|
||||
import modelsReducer from './modelsSlice';
|
||||
import interactionReducer from './interactionSlice';
|
||||
import subscriptionsReducer from './subscriptionsSlice';
|
||||
import onboardingProgressReducer from '@/app/components/Onboarding/OnboardingProgressSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
@@ -31,6 +32,7 @@ export const store = configureStore({
|
||||
update: updateReducer,
|
||||
models: modelsReducer,
|
||||
interaction: interactionReducer,
|
||||
subscriptions: subscriptionsReducer,
|
||||
onboardingProgress: onboardingProgressReducer,
|
||||
},
|
||||
// Disable Redux Toolkit's dev-mode invariant middleware (serializable +
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
export interface SubscriptionConnection {
|
||||
provider: string;
|
||||
isActive?: boolean;
|
||||
testStatus?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface SubscriptionStatus {
|
||||
running: boolean;
|
||||
providers?:
|
||||
| { connections?: SubscriptionConnection[] }
|
||||
| SubscriptionConnection[];
|
||||
models?: any[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface SubscriptionsState {
|
||||
status: SubscriptionStatus | null;
|
||||
}
|
||||
|
||||
// Minimal slice-shape — used by selectors so the slice doesn't import
|
||||
// from store.ts (would create a circular dependency with the configured
|
||||
// store, even type-only).
|
||||
type WithSubscriptions = { subscriptions: SubscriptionsState };
|
||||
|
||||
const initialState: SubscriptionsState = {
|
||||
status: null,
|
||||
};
|
||||
|
||||
// Mirrors `GET /agents/subscriptions/status` into Redux so the onboarding
|
||||
// gate (and any other consumer) can react to OAuth-driven subscription
|
||||
// connections — the actual tokens live in 9Router-managed storage, not in
|
||||
// settings.data, so this slice is the only frontend signal that an
|
||||
// "external subscription" has been hooked up.
|
||||
//
|
||||
// `preserveTransient` keeps a previously-seen `running: true` state when a
|
||||
// refresh comes back with `running: false`. The backend's `is_running()`
|
||||
// probe has a short sync timeout that can be exceeded while 9Router is
|
||||
// streaming inference, producing false negatives that would otherwise
|
||||
// flip the Settings cards into a "Starting subscription service..."
|
||||
// spinner mid-session.
|
||||
export const fetchSubscriptionStatus = createAsyncThunk(
|
||||
'subscriptions/fetchStatus',
|
||||
async (opts: { preserveTransient?: boolean } | undefined, { getState }) => {
|
||||
const prev = (getState() as WithSubscriptions).subscriptions.status;
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/agents/subscriptions/status`);
|
||||
const data = (await r.json()) as SubscriptionStatus;
|
||||
if (opts?.preserveTransient && prev?.running && !data?.running) return prev;
|
||||
return data;
|
||||
} catch {
|
||||
return prev ?? ({ running: false, providers: [], models: [] } as SubscriptionStatus);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const subscriptionsSlice = createSlice({
|
||||
name: 'subscriptions',
|
||||
initialState,
|
||||
reducers: {
|
||||
setSubscriptionStatus(state, action: PayloadAction<SubscriptionStatus | null>) {
|
||||
state.status = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(fetchSubscriptionStatus.fulfilled, (state, action) => {
|
||||
state.status = action.payload;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { setSubscriptionStatus } = subscriptionsSlice.actions;
|
||||
|
||||
// Pulls the connections array out of the polymorphic `providers` shape
|
||||
// (`{ connections: [...] }` for the modern response, bare array for the
|
||||
// legacy one). Returns [] for the loading state.
|
||||
export function selectSubscriptionConnections(
|
||||
state: WithSubscriptions,
|
||||
): SubscriptionConnection[] {
|
||||
const providers = state.subscriptions.status?.providers;
|
||||
if (!providers) return [];
|
||||
if (Array.isArray(providers)) return providers;
|
||||
return providers.connections ?? [];
|
||||
}
|
||||
|
||||
export function isProviderConnected(
|
||||
state: WithSubscriptions,
|
||||
providerId: string,
|
||||
): boolean {
|
||||
return selectSubscriptionConnections(state).some(
|
||||
(p) => p.provider === providerId && (p.isActive || p.testStatus === 'active'),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasAnyActiveSubscription(state: WithSubscriptions): boolean {
|
||||
return selectSubscriptionConnections(state).some(
|
||||
(p) => p.isActive || p.testStatus === 'active',
|
||||
);
|
||||
}
|
||||
|
||||
export default subscriptionsSlice.reducer;
|
||||
Reference in New Issue
Block a user