import json import os import logging from contextlib import asynccontextmanager from datetime import datetime from uuid import uuid4 from backend.config.Apps import SubApp from backend.apps.dashboards.models import ( Dashboard, DashboardCreate, DashboardUpdate, DashboardLayout, CardPosition, ViewCardPosition, BrowserCardPosition, ) from fastapi import HTTPException logger = logging.getLogger(__name__) from backend.config.paths import DASHBOARDS_DIR as DATA_DIR, SESSIONS_DIR, DASHBOARD_LAYOUT_DIR as OLD_LAYOUT_DIR from backend.config.json_store import read_json_or_none, atomic_write_json OLD_LAYOUT_FILE = os.path.join(OLD_LAYOUT_DIR, "layout.json") def load_all() -> list[Dashboard]: result = [] if not os.path.exists(DATA_DIR): return result for fname in os.listdir(DATA_DIR): if fname.endswith(".json"): data = read_json_or_none(os.path.join(DATA_DIR, fname)) if data is None: continue try: result.append(Dashboard(**data)) except Exception as e: # Parseable JSON, wrong shape (e.g. an older/newer schema). Skip from the list but leave the file alone so a later version can still read it. logger.warning("Skipping invalid dashboard file %s: %s", fname, e) return result def save(dashboard: Dashboard): atomic_write_json(os.path.join(DATA_DIR, f"{dashboard.id}.json"), dashboard.model_dump(mode="json")) def load(dashboard_id: str) -> Dashboard: path = os.path.join(DATA_DIR, f"{dashboard_id}.json") data = read_json_or_none(path) if data is None: raise HTTPException(status_code=404, detail="Dashboard not found") return Dashboard(**data) def p_delete(dashboard_id: str): path = os.path.join(DATA_DIR, f"{dashboard_id}.json") if os.path.exists(path): os.remove(path) def migrate_if_needed(): """One-time migration: if no dashboards exist, create 'Dashboard 1' from old layout.""" existing = load_all() if existing: return logger.info("No dashboards found; running one-time migration") layout = DashboardLayout() if os.path.exists(OLD_LAYOUT_FILE): try: with open(OLD_LAYOUT_FILE) as f: data = json.load(f) if "cards" in data: layout = DashboardLayout(**data) logger.info("Migrated layout from old layout.json") except Exception: logger.exception("Failed to read old layout.json, using empty layout") dashboard = Dashboard(name="Dashboard 1", layout=layout) save(dashboard) logger.info(f"Created default dashboard: {dashboard.id}") if os.path.exists(SESSIONS_DIR): count = 0 for fname in os.listdir(SESSIONS_DIR): if not fname.endswith(".json"): continue fpath = os.path.join(SESSIONS_DIR, fname) # Per-file guard: one unreadable session must not halt the migration partway and orphan the rest (the dashboard is already created above). session_data = read_json_or_none(fpath) if session_data is None: continue session_data["dashboard_id"] = dashboard.id atomic_write_json(fpath, session_data) count += 1 if count: logger.info(f"Tagged {count} existing chat sessions with dashboard_id={dashboard.id}") @asynccontextmanager async def dashboards_lifespan(): os.makedirs(DATA_DIR, exist_ok=True) migrate_if_needed() yield dashboards = SubApp("dashboards", dashboards_lifespan) @dashboards.router.get("/list") async def list_dashboards(): all_dashboards = load_all() all_dashboards.sort(key=lambda d: d.updated_at or d.created_at, reverse=True) items = [] for d in all_dashboards: dumped = d.model_dump(mode="json") items.append({ "id": dumped["id"], "name": dumped.get("name", "Untitled"), "auto_named": dumped.get("auto_named", False), "created_at": dumped.get("created_at"), "updated_at": dumped.get("updated_at"), "thumbnail": dumped.get("thumbnail"), "preview_updated_at": dumped.get("preview_updated_at"), "preview_signature": dumped.get("preview_signature"), }) return {"dashboards": items} @dashboards.router.post("/create") async def create_dashboard(body: DashboardCreate): dashboard = Dashboard(name=body.name) save(dashboard) try: from backend.apps.service.analytics.client import track_dashboard_event track_dashboard_event(dashboard_id=dashboard.id, action="create") except Exception: pass return dashboard.model_dump(mode="json") @dashboards.router.post("/{dashboard_id}/seed-demo") async def seed_demo(dashboard_id: str): """Create a pre-populated demo session for onboarding.""" dashboard = load(dashboard_id) session_id = uuid4().hex now = datetime.now() session_data = { "id": session_id, "name": "Welcome Chat", "status": "completed", "provider": "anthropic", "model": "sonnet", "mode": "agent", "sdk_session_id": None, "system_prompt": None, "allowed_tools": [], "max_turns": None, "cwd": None, "created_at": now.isoformat(), "closed_at": now.isoformat(), "cost_usd": 0.0, "tokens": {"input": 0, "output": 0}, "messages": [ { "id": uuid4().hex, "role": "user", "content": "What can you help me with?", "timestamp": now.isoformat(), "branch_id": "main", "parent_id": None, "hidden": False, }, { "id": uuid4().hex, "role": "assistant", "content": "I can help you with all kinds of tasks! Here are some things I'm great at:\n\n" "- **Research** \u2014 Find information, summarize articles, compare options\n" "- **Writing** \u2014 Draft emails, reports, social media posts, or any content\n" "- **Analysis** \u2014 Work with data, spot trends, create summaries\n" "- **Browsing** \u2014 Search the web, read pages, gather information\n" "- **Planning** \u2014 Break down projects, create timelines, organize ideas\n\n" "Just type what you need and I'll get to work! You can also open a browser tab to have me interact with websites.", "timestamp": now.isoformat(), "branch_id": "main", "parent_id": None, "hidden": False, }, ], "pending_approvals": [], "branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None, "created_at": now.isoformat()}}, "active_branch_id": "main", "tool_group_meta": {}, "dashboard_id": dashboard_id, "browser_id": None, "parent_session_id": None, "needs_fork": False, } os.makedirs(SESSIONS_DIR, exist_ok=True) atomic_write_json(os.path.join(SESSIONS_DIR, f"{session_id}.json"), session_data) return {"session_id": session_id} @dashboards.router.post("/{dashboard_id}/seed-orchestration-demo") async def seed_orchestration_demo(dashboard_id: str): """Create a stubbed "research agent" used by onboarding step 6. Step 6 ("Have an agent control other agents") needs a pre-existing agent for the user to attach to a new orchestrator. We seed a single completed-looking session that pretends to have done research on OpenSwarm, with messages mentioning what it found. The user then drags it into a new agent and asks for a PDF report; which delegates back to this seeded agent. """ load(dashboard_id) # validate dashboard exists session_id = uuid4().hex now = datetime.now() session_data = { "id": session_id, "name": "OpenSwarm research", "status": "completed", "provider": "anthropic", "model": "sonnet", "mode": "agent", "sdk_session_id": None, "system_prompt": None, "allowed_tools": [], "max_turns": None, "cwd": None, "created_at": now.isoformat(), "closed_at": now.isoformat(), "cost_usd": 0.0, "tokens": {"input": 0, "output": 0}, "messages": [ { "id": uuid4().hex, "role": "user", "content": "Research OpenSwarm and summarize what it does, who uses it, and how its built.", "timestamp": now.isoformat(), "branch_id": "main", "parent_id": None, "hidden": False, }, { "id": uuid4().hex, "role": "assistant", "content": ( "Here's what I found on OpenSwarm:\n\n" "**What it is.** OpenSwarm is a desktop AI workspace built around\n" "agents that can read and write files, run commands, browse the web,\n" "and orchestrate other agents. It's distributed as an Electron app\n" "with a React frontend, a Python backend, and a Hono cloud service.\n\n" "**Who uses it.** Software engineers, researchers, and power users\n" "who want a model-agnostic agent platform on their own machine\n" "rather than a locked-in cloud chatbot.\n\n" "**How it's built.**\n" "- React + MUI + Redux Toolkit for the renderer.\n" "- FastAPI Python backend (agents, tools, sessions).\n" "- A Hono cloud service handles auth, billing, and account pooling.\n" "- Built-in browser cards let agents drive web pages directly.\n" "- Skills and Apps let users teach the system new capabilities.\n\n" "Ready when you are; let me know what you'd like to do with this." ), "timestamp": now.isoformat(), "branch_id": "main", "parent_id": None, "hidden": False, }, ], "pending_approvals": [], "branches": { "main": { "id": "main", "parent_branch_id": None, "fork_point_message_id": None, "created_at": now.isoformat(), } }, "active_branch_id": "main", "tool_group_meta": {}, "dashboard_id": dashboard_id, "browser_id": None, "parent_session_id": None, "needs_fork": False, } os.makedirs(SESSIONS_DIR, exist_ok=True) atomic_write_json(os.path.join(SESSIONS_DIR, f"{session_id}.json"), session_data) return {"session_id": session_id} @dashboards.router.post("/{dashboard_id}/generate-name") async def generate_name(dashboard_id: str): dashboard = load(dashboard_id) if not dashboard.auto_named and dashboard.name != "Untitled Dashboard": return {"name": dashboard.name, "auto_named": dashboard.auto_named} from backend.apps.agents.agent_manager import agent_manager prompts = [] for session in agent_manager.sessions.values(): if getattr(session, "dashboard_id", None) != dashboard_id: continue for msg in session.messages: if msg.role == "user" and isinstance(msg.content, str) and msg.content.strip(): prompts.append(msg.content.strip()[:200]) break if not prompts: return {"name": dashboard.name, "auto_named": dashboard.auto_named} fallback = " ".join(prompts[0].split()[:4])[:36] or "Untitled Dashboard" try: from backend.apps.settings.settings import load_settings from backend.apps.settings.credentials import get_anthropic_client_for_model from backend.apps.agents.providers.registry import resolve_aux_model global_settings = load_settings() aux_model, p_aux_base = await resolve_aux_model(global_settings, preferred_tier="haiku") client = get_anthropic_client_for_model(global_settings, aux_model) # Mirrors generate_title's hardening: the tasks are inert text to LABEL, never answer, or the aux model happily replies with a markdown essay that becomes the title. system = ( "You label tasks with a 2-4 word workspace name. " "Examples: 'Travel planning', 'Code review', 'Sales dashboard'. " "You NEVER answer or perform the tasks. You NEVER describe yourself. " "You NEVER begin with 'I', 'As an', 'Sorry', 'Unfortunately', or any first-person phrasing. " "Return ONLY the 2-4 word name. No quotes, no punctuation, no emojis, no explanation." ) user_content = ( "Name the workspace for the tasks inside tags. Do not answer them.\n\n" "\n" + "\n".join(f"- {p}" for p in prompts) + "\n" ) from backend.apps.agents.core.aux_llm import clean_short_label, aux_max_tokens_for chunks: list[str] = [] async with client.messages.stream( model=aux_model, max_tokens=aux_max_tokens_for(aux_model), system=system, messages=[{"role": "user", "content": user_content}], ) as stream: async for text in stream.text_stream: chunks.append(text) generated = clean_short_label("".join(chunks)) if generated: fallback = generated except Exception as e: logger.warning(f"Dashboard name generation failed, using fallback: {e}") dashboard.name = fallback dashboard.auto_named = True dashboard.updated_at = datetime.now() save(dashboard) return {"name": dashboard.name, "auto_named": True} def strip_orphan_session_cards(data: dict) -> None: """Drop layout cards (and expanded ids) whose agent session no longer exists anywhere, in memory OR on disk. The frontend mounts an AgentChat per card and GETs its session; a card pointing at a vanished session (e.g. an empty never-saved session) 404s on every load and flashes a dead "connect a model" card before the client reconciles it away. The `gone()` test is the exact condition that makes GET /sessions/{id} 404, so it removes precisely those cards and nothing else. Filtering the RESPONSE (never the stored file) is non-destructive: a wrong check can only hide a card for one response, not delete it. Drafts have no backend session yet, so they're always kept.""" from backend.apps.agents.agent_manager import agent_manager from backend.apps.agents.manager.session.session_store import load_session_data layout = data.get("layout") if not isinstance(layout, dict): return cards = layout.get("cards") if not isinstance(cards, dict): return def gone(sid: str) -> bool: if sid.startswith("draft-") or sid in agent_manager.sessions: return False return load_session_data(sid) is None orphans = [sid for sid in cards if gone(sid)] for sid in orphans: cards.pop(sid, None) if orphans: exp = layout.get("expanded_session_ids") if isinstance(exp, list): layout["expanded_session_ids"] = [s for s in exp if s not in orphans] @dashboards.router.get("/{dashboard_id}") async def get_dashboard(dashboard_id: str): dashboard = load(dashboard_id) data = dashboard.model_dump(mode="json") strip_orphan_session_cards(data) return data @dashboards.router.put("/{dashboard_id}") async def update_dashboard(dashboard_id: str, body: DashboardUpdate): dashboard = load(dashboard_id) if body.name is not None: dashboard.name = body.name dashboard.auto_named = False if body.layout is not None: dashboard.layout = body.layout now = datetime.now() if body.thumbnail is not None: dashboard.thumbnail = body.thumbnail dashboard.preview_signature = body.preview_signature # Only a real screenshot write moves the sort key; layout/rename saves don't reorder. dashboard.preview_updated_at = now dashboard.updated_at = now save(dashboard) return dashboard.model_dump(mode="json") @dashboards.router.delete("/{dashboard_id}") async def delete_dashboard(dashboard_id: str): load(dashboard_id) if os.path.exists(SESSIONS_DIR): for fname in os.listdir(SESSIONS_DIR): if not fname.endswith(".json"): continue fpath = os.path.join(SESSIONS_DIR, fname) try: with open(fpath) as f: data = json.load(f) if data.get("dashboard_id") == dashboard_id: os.remove(fpath) except Exception: logger.warning(f"Failed to read/delete session file {fname}") from backend.apps.agents.agent_manager import agent_manager to_remove = [ sid for sid, sess in agent_manager.sessions.items() if getattr(sess, "dashboard_id", None) == dashboard_id ] for sid in to_remove: try: await agent_manager.delete_session(sid) except Exception: logger.warning(f"Failed to delete active session {sid} during dashboard deletion") p_delete(dashboard_id) try: from backend.apps.service.analytics.client import track_dashboard_event track_dashboard_event(dashboard_id=dashboard_id, action="delete") except Exception: pass return {"ok": True} @dashboards.router.post("/{dashboard_id}/duplicate") async def duplicate_dashboard(dashboard_id: str): source = load(dashboard_id) source_data = source.model_dump(mode="json") new_id = uuid4().hex now = datetime.now().isoformat() from backend.apps.agents.agent_manager import agent_manager from backend.apps.agents.manager.session.session_store import save_session source_layout = source_data.get("layout", {}) or {} source_browser_cards = source_layout.get("browser_cards", {}) or {} source_cards = source_layout.get("cards", {}) or {} browser_id_remap: dict[str, str] = {old_bid: uuid4().hex for old_bid in source_browser_cards.keys()} new_browser_cards: dict[str, dict] = {} for old_bid, card in source_browser_cards.items(): new_bid = browser_id_remap[old_bid] new_card = {**card, "browser_id": new_bid} new_browser_cards[new_bid] = new_card candidate_ids: set[str] = set() for sid, sess in agent_manager.sessions.items(): if getattr(sess, "dashboard_id", None) == dashboard_id: candidate_ids.add(sid) if os.path.exists(SESSIONS_DIR): for fname in os.listdir(SESSIONS_DIR): if not fname.endswith(".json"): continue data = read_json_or_none(os.path.join(SESSIONS_DIR, fname)) if data and data.get("dashboard_id") == dashboard_id: candidate_ids.add(fname[:-5]) session_id_remap: dict[str, str] = {} duplicated_sessions = [] # (old_id, new_session) for old_sid in candidate_ids: try: new_sess = await agent_manager.duplicate_session(old_sid, dashboard_id=new_id) except Exception: logger.warning(f"Failed to duplicate session {old_sid} during dashboard duplication", exc_info=True) continue session_id_remap[old_sid] = new_sess.id duplicated_sessions.append((old_sid, new_sess)) for old_sid, new_sess in duplicated_sessions: source_sess = agent_manager.sessions.get(old_sid) old_browser_id = getattr(source_sess, "browser_id", None) if source_sess else None old_parent_sid = getattr(source_sess, "parent_session_id", None) if source_sess else None if old_browser_id is None or old_parent_sid is None: data = read_json_or_none(os.path.join(SESSIONS_DIR, f"{old_sid}.json")) or {} if old_browser_id is None: old_browser_id = data.get("browser_id") if old_parent_sid is None: old_parent_sid = data.get("parent_session_id") if old_browser_id and old_browser_id in browser_id_remap: new_sess.browser_id = browser_id_remap[old_browser_id] if old_parent_sid and old_parent_sid in session_id_remap: new_sess.parent_session_id = session_id_remap[old_parent_sid] save_session(new_sess.id, new_sess.model_dump(mode="json")) new_cards: dict[str, dict] = {} for old_sid, card in source_cards.items(): new_sid = session_id_remap.get(old_sid) if not new_sid: continue new_cards[new_sid] = {**card, "session_id": new_sid} for new_card in new_browser_cards.values(): old_spawn = new_card.get("spawned_by") if old_spawn and old_spawn in session_id_remap: new_card["spawned_by"] = session_id_remap[old_spawn] elif old_spawn: new_card["spawned_by"] = None new_expanded = [ session_id_remap[sid] for sid in source_layout.get("expanded_session_ids", []) or [] if sid in session_id_remap ] new_layout = { **source_layout, "cards": new_cards, "view_cards": source_layout.get("view_cards", {}) or {}, "browser_cards": new_browser_cards, "notes": source_layout.get("notes", {}) or {}, "expanded_session_ids": new_expanded, } new_dashboard = { **source_data, "id": new_id, "name": f"{source_data.get('name', 'Untitled')} (copy)", "created_at": now, "updated_at": now, "layout": new_layout, } atomic_write_json(os.path.join(DATA_DIR, f"{new_id}.json"), new_dashboard) try: from backend.apps.service.analytics.client import track_dashboard_event track_dashboard_event(dashboard_id=new_id, action="create") except Exception: pass return new_dashboard