[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:
ciregenz
2026-05-11 14:54:07 -07:00
parent 3417a2f7fb
commit 573f4a1ed4
12 changed files with 385 additions and 113 deletions
+8 -2
View File
@@ -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
@@ -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/
+8 -2
View File
@@ -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:
+31 -3
View File
@@ -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>
+6
View File
@@ -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):
+90 -11
View File
@@ -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 {
+28 -26
View File
@@ -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();
+25 -8
View File
@@ -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>
+4
View File
@@ -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 {
+2
View File
@@ -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;