diff --git a/backend/apps/onboarding/menu.py b/backend/apps/onboarding/menu.py new file mode 100644 index 00000000..7892c70f --- /dev/null +++ b/backend/apps/onboarding/menu.py @@ -0,0 +1,153 @@ +"""Build the hero's two-level menu: 4 general categories x 4 starters tailored to this user. + +Runs as its own cheap aux call BESIDE the main prep call, so a failure here can never cost +the reveal, and every failure path fills from scan-grounded + static rows, so the menu the +dashboard hero drills into is always complete. +""" + +import json +from typing import List, Optional + +from typeguard import typechecked + +from backend.apps.agents.core.aux_llm import aux_max_tokens_for, safe_resp_text +from backend.apps.onboarding.models import ScanResult +from backend.apps.onboarding.parse_helpers import build_starters, load_json_object, normalize_json_text +from backend.apps.settings.models import AppSettings, PersonalizedMenu, PersonalizedStarter + +MENU_CATEGORIES = ("computer", "research", "web", "build") +MENU_SIZE = 4 + +P_MENU_SYSTEM = ( + "You write starter tasks for OpenSwarm, a desktop AI agent platform that can organize local files, " + "browse the web in a real browser, build small apps, and run agents in parallel. Given facts about the " + "user (their machine scan, picked apps, and usage_summary, a profile distilled from their real AI " + "conversations, the STRONGEST signal), respond with STRICT JSON only: " + '{"computer": [{"title": string, "prompt": string}], "research": [{"title": string, "prompt": string}], ' + '"web": [{"title": string, "prompt": string}], "build": [{"title": string, "prompt": string}]}. ' + "EXACTLY 4 items per category, 16 total, every one tailored to THIS person. " + "computer: tasks on THIS machine's real files and folders (use their actual folder names and file kinds " + "from the scan); each READS real files and writes ONE new artifact, and must NEVER modify or delete an " + "existing file. " + "research: live web research on topics THIS user actually cares about (from usage_summary); each demands " + "current information with dated sources and produces an actual answer or comparison, never a plan. " + "web: the agent OPENS a named real public website and does a genuinely multi-step task there (navigate, " + "search, click through, compare, report); read-only public browsing, never log in, buy, post, or act on " + "the user's behalf. " + "build: small working tools for this person's real needs; each prompt starts with 'Build me', is fully " + "client-side and self-contained with deterministic logic only (math, parsing, formatting, charts), and " + "must NEVER call an AI model or any network API. " + "Span the person's DISTINCT life threads: within each category at most ONE item may touch their work or " + "company; the rest come from their other real threads (sport, food, hobbies, curiosities, practical " + "needs) actually present in the input. " + "Titles are 2-5 plain words saying what the task does, never clever or punny. Prompts are concrete, " + "immediately runnable instructions; never invent facts not in the input. " + "Never use em-dashes or en-dashes. No markdown, no commentary, JSON only." +) + +P_STATIC_MENU: dict[str, List[PersonalizedStarter]] = { + "computer": [ + PersonalizedStarter(title="Clean up Downloads", prompt="Sort my Downloads folder into tidy subfolders. Show me the plan before moving anything."), + PersonalizedStarter(title="Find my biggest files", prompt="Scan my home folder for the largest files and folders and write me a reviewable page listing them with sizes. Do not delete anything."), + PersonalizedStarter(title="Index my documents", prompt="Look through my Documents folder and build one searchable index page listing files by name and date so I can find things fast. Write only the new page."), + PersonalizedStarter(title="Find duplicate files", prompt="Look for duplicate files across my Downloads and Desktop and write a report listing them side by side. Never delete anything without my review."), + ], + "research": [ + PersonalizedStarter(title="Compare before I buy", prompt="Ask me what I'm shopping for, then research current options and give me a tight comparison table with dated sources."), + PersonalizedStarter(title="What's new in AI", prompt="Search the web for the most useful AI tools and model releases from the past month and summarize the ones worth my time, with dated sources."), + PersonalizedStarter(title="Settle a question", prompt="Ask me one question I've been meaning to look into, then research it properly and give me a current, sourced answer."), + PersonalizedStarter(title="Plan a weekend trip", prompt="Ask me where I'd like to go, then research a realistic 3-day itinerary with current prices and opening hours, and turn it into a printable page."), + ], + "web": [ + PersonalizedStarter(title="Find a table tonight", prompt="Open OpenTable and find three well-reviewed restaurants near me with availability tonight, compare them, and report back."), + PersonalizedStarter(title="Watch a flight price", prompt="Ask me for a route, then open Google Flights, search it, compare dates and airlines across a few pages, and report the best current options."), + PersonalizedStarter(title="Best rated near me", prompt="Open Google Maps, search for the best-rated coffee shops nearby, read through reviews on a few of them, and tell me which one to try and why."), + PersonalizedStarter(title="Catch me up on tech", prompt="Open Hacker News, read through today's top discussions, and give me the three most interesting threads with what people are actually saying."), + ], + "build": [ + PersonalizedStarter(title="Habit tracker", prompt="Build me a simple habit tracker app I can use right now."), + PersonalizedStarter(title="Bill splitter", prompt="Build me a tool where I enter a bill total, tip, and the people involved, and it computes exactly who owes what."), + PersonalizedStarter(title="CSV instant charts", prompt="Build me a tool where I paste CSV data and it instantly renders clean charts I can screenshot."), + PersonalizedStarter(title="Countdown to a date", prompt="Build me a countdown page for a date that matters to me; ask me the date and label, then make it look great."), + ], +} + + +@typechecked +def p_scan_grounded_rows(category: str, scan: Optional[ScanResult]) -> List[PersonalizedStarter]: + """No-LLM personalization: ground a couple of rows per category in the real scan.""" + if scan is None: + return [] + out: List[PersonalizedStarter] = [] + if category == "computer": + shots = next((f for f in scan.folders if f.screenshot_count > 2), None) + if shots: + out.append(PersonalizedStarter( + title="Frame my screenshots", + prompt=f"Find the screenshot images in my {shots.name} folder and build one browsable gallery web page showing them as a neat scrollable grid. Write only the new page; never move or delete the originals.", + )) + if scan.git_repo_count > 0: + out.append(PersonalizedStarter( + title="Recap my projects", + prompt="Look at the code projects on my computer, read each one's README and recent activity, and write me one short page summarizing what each project is and where it stands. Write only the summary; change nothing.", + )) + if category == "research" and scan.signal_apps: + out.append(PersonalizedStarter( + title=f"{scan.signal_apps[0]} tips", + prompt=f"Search the web right now for the most useful current tips, shortcuts, and workflows for {scan.signal_apps[0]}, and give me a tight summary with dated sources.", + )) + return out + + +@typechecked +def fill_menu(parsed: Optional[PersonalizedMenu], scan: Optional[ScanResult]) -> PersonalizedMenu: + """Guarantee 4 rows per category: LLM rows first, then scan-grounded, then static.""" + menu = PersonalizedMenu() + for category in MENU_CATEGORIES: + rows: List[PersonalizedStarter] = list(getattr(parsed, category)) if parsed is not None else [] + for extra in [*p_scan_grounded_rows(category, scan), *P_STATIC_MENU[category]]: + if len(rows) >= MENU_SIZE: + break + if all(extra.title != existing.title for existing in rows): + rows.append(extra) + setattr(menu, category, rows[:MENU_SIZE]) + return menu + + +@typechecked +def parse_menu(text: str) -> Optional[PersonalizedMenu]: + data = load_json_object(normalize_json_text(text)) + if not data: + return None + menu = PersonalizedMenu() + got_any = False + for category in MENU_CATEGORIES: + rows = data.get(category) + starters = build_starters(rows) if isinstance(rows, list) else [] + if starters: + got_any = True + setattr(menu, category, starters[:MENU_SIZE]) + return menu if got_any else None + + +@typechecked +async def build_menu(settings: AppSettings, facts: dict, scan: Optional[ScanResult]) -> PersonalizedMenu: + """One cheap aux call -> 16 tailored starters; any failure fills from scan + static rows.""" + parsed: Optional[PersonalizedMenu] = None + try: + from backend.apps.agents.providers.registry import resolve_aux_model + from backend.apps.settings.credentials import get_anthropic_client_for_model + + aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku") + client = get_anthropic_client_for_model(settings, aux_model) + resp = await client.messages.create( + model=aux_model, + max_tokens=aux_max_tokens_for(aux_model, base=1800), + system=P_MENU_SYSTEM, + messages=[{"role": "user", "content": json.dumps(facts)}], + timeout=45.0, + ) + parsed = parse_menu(safe_resp_text(resp)) + except Exception: + parsed = None + return fill_menu(parsed, scan) diff --git a/backend/apps/onboarding/models.py b/backend/apps/onboarding/models.py index 7336f1bc..68f1268d 100644 --- a/backend/apps/onboarding/models.py +++ b/backend/apps/onboarding/models.py @@ -4,7 +4,7 @@ from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field -from backend.apps.settings.models import PersonalizedAutomation, PersonalizedStarter +from backend.apps.settings.models import PersonalizedAutomation, PersonalizedMenu, PersonalizedStarter class ProviderIdentity(BaseModel): @@ -73,3 +73,5 @@ class PrepResponse(BaseModel): browser_prompt: str = "" browser_reason: str = "" automations: List[PersonalizedAutomation] = Field(default_factory=list) + # The hero's two-level menu (4 categories x 4 tailored starters); None only if prep never ran. + menu: Optional[PersonalizedMenu] = None diff --git a/backend/apps/onboarding/parse_helpers.py b/backend/apps/onboarding/parse_helpers.py new file mode 100644 index 00000000..123880cb --- /dev/null +++ b/backend/apps/onboarding/parse_helpers.py @@ -0,0 +1,75 @@ +"""Shared JSON/text salvage helpers for the onboarding aux-call parsers (prep + menu).""" + +import json +import re +from typing import Dict, List + +from typeguard import typechecked + +from backend.apps.settings.models import PersonalizedStarter + +P_CURLY_QUOTES: Dict[str, str] = {"“": '"', "”": '"', "‘": "'", "’": "'"} + + +@typechecked +def normalize_json_text(text: str) -> str: + for bad, good in P_CURLY_QUOTES.items(): + text = text.replace(bad, good) + return text + + +@typechecked +def strip_trailing_commas(s: str) -> str: + return re.sub(r",(\s*[}\]])", r"\1", s) + + +@typechecked +def strip_dashes(s: str) -> str: + """The house style bans em/en dashes and the model slips them into the greeting anyway, so + guarantee it in code: turn a dash-clause into a comma-clause, then tidy any doubled punctuation.""" + s = s.replace(" — ", ", ").replace("—", ", ").replace(" – ", ", ").replace("–", ", ") + s = re.sub(r"\s+([,.;:])", r"\1", s) + s = re.sub(r",\s*,", ", ", s) + s = re.sub(r"\s{2,}", " ", s) + return s.strip() + + +@typechecked +def load_json_object(text: str) -> dict: + """Best-effort load of the outermost JSON object: strict first, then a + trailing-comma repair. Returns {} if neither parses (salvage handles the rest).""" + match = re.search(r"\{.*\}", text, re.DOTALL) + if not match: + return {} + for candidate in (match.group(0), strip_trailing_commas(match.group(0))): + try: + parsed = json.loads(candidate) + if isinstance(parsed, dict): + return parsed + except Exception: + continue + return {} + + +@typechecked +def salvage_flat_objects(text: str) -> List[dict]: + """Pull every complete flat {..} object out of a truncated/malformed blob so a + cut-off response still yields the starters it did finish (partial > generic).""" + out: List[dict] = [] + for m in re.finditer(r"\{[^{}]*\}", text): + try: + obj = json.loads(strip_trailing_commas(m.group(0))) + except Exception: + continue + if isinstance(obj, dict): + out.append(obj) + return out + + +@typechecked +def build_starters(rows: List[dict]) -> List[PersonalizedStarter]: + return [ + PersonalizedStarter(title=strip_dashes(str(s.get("title", ""))), prompt=strip_dashes(str(s.get("prompt", ""))), reason=strip_dashes(str(s.get("reason", "")))) + for s in rows + if isinstance(s, dict) and str(s.get("title", "")).strip() and str(s.get("prompt", "")).strip() and "cadence" not in s + ] diff --git a/backend/apps/onboarding/prep.py b/backend/apps/onboarding/prep.py index 4ab3ffdb..92ad21a9 100644 --- a/backend/apps/onboarding/prep.py +++ b/backend/apps/onboarding/prep.py @@ -4,6 +4,7 @@ One cheap aux call on whatever lane the user just connected; every failure path returns the static fallback so the reveal can never be an error card. """ +import asyncio import json import re from typing import List, Optional @@ -11,7 +12,9 @@ from typing import List, Optional from typeguard import typechecked from backend.apps.agents.core.aux_llm import aux_max_tokens_for, safe_resp_text +from backend.apps.onboarding.menu import build_menu from backend.apps.onboarding.models import PrepRequest, PrepResponse, ScanResult +from backend.apps.onboarding.parse_helpers import build_starters, load_json_object, normalize_json_text, salvage_flat_objects, strip_dashes from backend.apps.settings.models import AppSettings, PersonalizedAutomation, PersonalizedStarter VALID_CADENCE = {"daily", "weekday", "weekly"} @@ -134,73 +137,6 @@ P_SYSTEM = ( ) -P_CURLY_QUOTES = {"“": '"', "”": '"', "‘": "'", "’": "'"} - - -@typechecked -def p_normalize_json_text(text: str) -> str: - for bad, good in P_CURLY_QUOTES.items(): - text = text.replace(bad, good) - return text - - -@typechecked -def p_strip_trailing_commas(s: str) -> str: - return re.sub(r",(\s*[}\]])", r"\1", s) - - -@typechecked -def p_strip_dashes(s: str) -> str: - """The house style bans em/en dashes and the model slips them into the greeting anyway, so - guarantee it in code: turn a dash-clause into a comma-clause, then tidy any doubled punctuation.""" - s = s.replace(" — ", ", ").replace("—", ", ").replace(" – ", ", ").replace("–", ", ") - s = re.sub(r"\s+([,.;:])", r"\1", s) - s = re.sub(r",\s*,", ", ", s) - s = re.sub(r"\s{2,}", " ", s) - return s.strip() - - -@typechecked -def p_load_object(text: str) -> dict: - """Best-effort load of the outermost JSON object: strict first, then a - trailing-comma repair. Returns {} if neither parses (salvage handles the rest).""" - match = re.search(r"\{.*\}", text, re.DOTALL) - if not match: - return {} - for candidate in (match.group(0), p_strip_trailing_commas(match.group(0))): - try: - parsed = json.loads(candidate) - if isinstance(parsed, dict): - return parsed - except Exception: - continue - return {} - - -@typechecked -def p_salvage_flat_objects(text: str) -> List[dict]: - """Pull every complete flat {..} object out of a truncated/malformed blob so a - cut-off response still yields the starters it did finish (partial > generic).""" - out: List[dict] = [] - for m in re.finditer(r"\{[^{}]*\}", text): - try: - obj = json.loads(p_strip_trailing_commas(m.group(0))) - except Exception: - continue - if isinstance(obj, dict): - out.append(obj) - return out - - -@typechecked -def p_build_starters(rows: List[dict]) -> List[PersonalizedStarter]: - return [ - PersonalizedStarter(title=p_strip_dashes(str(s.get("title", ""))), prompt=p_strip_dashes(str(s.get("prompt", ""))), reason=p_strip_dashes(str(s.get("reason", "")))) - for s in rows - if isinstance(s, dict) and str(s.get("title", "")).strip() and str(s.get("prompt", "")).strip() and "cadence" not in s - ] - - @typechecked def p_extract_string_field(text: str, name: str) -> str: """Pull a top-level "name": "value" string straight out of the raw blob, for the fields that @@ -213,8 +149,8 @@ def p_extract_string_field(text: str, name: str) -> str: def p_build_automations(rows: List[dict]) -> List[PersonalizedAutomation]: return [ PersonalizedAutomation( - title=p_strip_dashes(str(a.get("title", ""))), - prompt=p_strip_dashes(str(a.get("prompt", ""))), + title=strip_dashes(str(a.get("title", ""))), + prompt=strip_dashes(str(a.get("prompt", ""))), cadence=(str(a.get("cadence", "weekly")).strip().lower() if str(a.get("cadence", "")).strip().lower() in VALID_CADENCE else "weekly"), ) for a in rows @@ -224,9 +160,9 @@ def p_build_automations(rows: List[dict]) -> List[PersonalizedAutomation]: @typechecked def parse_prep(text: str) -> Optional[PrepResponse]: - text = p_normalize_json_text(text) - data = p_load_object(text) - starters = p_build_starters(data.get("starters") if isinstance(data.get("starters"), list) else []) + text = normalize_json_text(text) + data = load_json_object(text) + starters = build_starters(data.get("starters") if isinstance(data.get("starters"), list) else []) automations = p_build_automations(data.get("automations") if isinstance(data.get("automations"), list) else []) headline = str(data.get("headline", "")).strip() greeting = str(data.get("greeting", "")).strip() @@ -243,9 +179,9 @@ def parse_prep(text: str) -> Optional[PrepResponse]: # Truncation / trailing comma / smart quotes broke the strict load: salvage the complete pieces # rather than throwing the whole personalized reveal away for one bad character. if not starters or not automations: - objs = p_salvage_flat_objects(text) + objs = salvage_flat_objects(text) if not starters: - starters = p_build_starters([o for o in objs if "cadence" not in o]) + starters = build_starters([o for o in objs if "cadence" not in o]) if not automations: automations = p_build_automations([o for o in objs if "cadence" in o]) # Top-level string fields don't live in the flat objects above, so recover them by name when the @@ -276,18 +212,18 @@ def parse_prep(text: str) -> Optional[PrepResponse]: if not starters: return None return PrepResponse( - headline=p_strip_dashes(headline), - greeting=p_strip_dashes(greeting), + headline=strip_dashes(headline), + greeting=strip_dashes(greeting), starters=starters[:4], - app_title=p_strip_dashes(app_title), - app_prompt=p_strip_dashes(app_prompt), - app_reason=p_strip_dashes(app_reason), - research_title=p_strip_dashes(research_title), - research_prompt=p_strip_dashes(research_prompt), - research_reason=p_strip_dashes(research_reason), - browser_title=p_strip_dashes(browser_title), - browser_prompt=p_strip_dashes(browser_prompt), - browser_reason=p_strip_dashes(browser_reason), + app_title=strip_dashes(app_title), + app_prompt=strip_dashes(app_prompt), + app_reason=strip_dashes(app_reason), + research_title=strip_dashes(research_title), + research_prompt=strip_dashes(research_prompt), + research_reason=strip_dashes(research_reason), + browser_title=strip_dashes(browser_title), + browser_prompt=strip_dashes(browser_prompt), + browser_reason=strip_dashes(browser_reason), automations=automations[:3], ) @@ -411,7 +347,7 @@ async def p_distill_profile(settings: AppSettings, usage_text: str) -> str: messages=[{"role": "user", "content": usage_text[:P_PROFILE_INPUT_CAP]}], timeout=45.0, ) - return p_strip_dashes(safe_resp_text(resp).strip()) + return strip_dashes(safe_resp_text(resp).strip()) except Exception: return "" @@ -432,6 +368,8 @@ async def build_prep(settings: AppSettings, request: PrepRequest) -> PrepRespons profile = await p_distill_profile(settings, usage) if profile: facts["usage_summary"] = profile + # The hero's 4x4 drill-in menu rides its own parallel aux call; build_menu never raises. + menu_task = asyncio.create_task(build_menu(settings, facts, request.scan)) try: from backend.apps.agents.providers.registry import resolve_aux_model from backend.apps.settings.credentials import get_anthropic_client_for_model @@ -451,9 +389,12 @@ async def build_prep(settings: AppSettings, request: PrepRequest) -> PrepRespons ) parsed = parse_prep(safe_resp_text(resp)) if parsed is not None: + parsed.menu = await menu_task return parsed except Exception: pass # Aux unusable (empty gemini/codex response on 0.3.60, provider down, no anthropic lane): still # ground the reveal in the real scan rather than shipping generic stubs. - return p_scan_grounded_fallback(request) + fallback = p_scan_grounded_fallback(request) + fallback.menu = await menu_task + return fallback diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 0b3cf6dc..97023155 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -39,6 +39,8 @@ class AppSettings(BaseModel): default_max_turns: Optional[int] = None default_thinking_level: Literal["off", "low", "medium", "high", "auto"] = "auto" zoom_sensitivity: float = 50.0 + # Root font-size multiplier (0.9/1/1.1/1.2 from Settings > Interface); the whole rem type scale rides it. + ui_font_scale: float = 1.0 theme: str = "light" # Shared across App Builder workspaces (each runs its own vite port / localStorage origin); null = follow system. app_template_theme_override: Optional[Literal["light", "dark"]] = None @@ -72,6 +74,8 @@ class AppSettings(BaseModel): personalized_headline: Optional[str] = None personalized_starters: list["PersonalizedStarter"] = Field(default_factory=list) personalized_automations: list["PersonalizedAutomation"] = Field(default_factory=list) + # The hero's two-level menu: 4 general categories, each holding 4 starters tailored to this user. + personalized_menu: Optional["PersonalizedMenu"] = None # Distilled from the user's provider chat history the first time they open ChatGPT/Claude in-app; re-feeds prep to sharpen suggestions. personalized_usage_summary: Optional[str] = None # Suppresses preflight suggestion modal entries the user dismissed; keyed by ToolDefinition.name, value ISO timestamp. @@ -124,3 +128,10 @@ class PersonalizedAutomation(BaseModel): prompt: str # 'daily' | 'weekday' | 'weekly'; the frontend maps this to a workflow schedule. cadence: str = "weekly" + + +class PersonalizedMenu(BaseModel): + computer: list[PersonalizedStarter] = Field(default_factory=list) + research: list[PersonalizedStarter] = Field(default_factory=list) + web: list[PersonalizedStarter] = Field(default_factory=list) + build: list[PersonalizedStarter] = Field(default_factory=list) diff --git a/backend/tests/test_onboarding.py b/backend/tests/test_onboarding.py index 1a9ee6b8..8efec570 100644 --- a/backend/tests/test_onboarding.py +++ b/backend/tests/test_onboarding.py @@ -270,3 +270,60 @@ async def test_build_prep_fails_open_without_provider(monkeypatch): result = await build_prep(AppSettings(), PrepRequest(scan=ScanResult(), picked_apps=["notion"])) assert result.greeting == "" assert result.starters == FALLBACK_STARTERS + + +# --------------------------------------------------------------------------- hero menu --------------------------------------------------------------------------- + +def test_parse_menu_strict_and_partial(): + from backend.apps.onboarding.menu import parse_menu + + full = json.dumps({ + "computer": [{"title": "Frame shots", "prompt": "do it"}], + "research": [{"title": "Vector DBs", "prompt": "compare"}], + "web": [], + "build": [{"title": "Jump log", "prompt": "Build me a jump log"}], + }) + menu = parse_menu(full) + assert menu is not None + assert menu.computer[0].title == "Frame shots" + assert menu.web == [] + assert parse_menu("not json at all") is None + assert parse_menu(json.dumps({"computer": [], "research": [], "web": [], "build": []})) is None + + +def test_fill_menu_always_serves_four_per_category(): + from backend.apps.onboarding.menu import MENU_CATEGORIES, fill_menu, parse_menu + + partial = parse_menu(json.dumps({ + "computer": [{"title": "Frame shots", "prompt": "do it"}], + "research": [], "web": [], "build": [], + })) + scan = ScanResult(signal_apps=["Figma"], folders=[], git_repo_count=2) + menu = fill_menu(partial, scan) + for category in MENU_CATEGORIES: + rows = getattr(menu, category) + assert len(rows) == 4, category + assert len({r.title for r in rows}) == 4, category + # LLM rows lead, scan-grounded rows fill before statics. + assert menu.computer[0].title == "Frame shots" + assert any(r.title == "Recap my projects" for r in menu.computer) + assert menu.research[0].title == "Figma tips" + # No scan at all still fills from statics. + empty = fill_menu(None, None) + for category in MENU_CATEGORIES: + assert len(getattr(empty, category)) == 4 + + +@pytest.mark.asyncio +async def test_build_prep_attaches_menu_even_on_fallback(monkeypatch): + from backend.apps.onboarding import prep as prep_mod + + async def boom(*a, **k): + raise RuntimeError("no aux") + + monkeypatch.setattr("backend.apps.agents.providers.registry.resolve_aux_model", boom) + out = await build_prep(AppSettings(), PrepRequest(scan=ScanResult())) + assert out.menu is not None + from backend.apps.onboarding.menu import MENU_CATEGORIES + for category in MENU_CATEGORIES: + assert len(getattr(out.menu, category)) == 4 diff --git a/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts b/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts index dd4fbbc7..188444d0 100644 --- a/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts +++ b/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts @@ -1,5 +1,5 @@ import { API_BASE } from '@/shared/config'; -import type { PersonalizedStarter, PersonalizedAutomation } from '@/shared/state/settingsSlice'; +import type { PersonalizedStarter, PersonalizedAutomation, PersonalizedMenu } from '@/shared/state/settingsSlice'; export interface ProviderIdentity { provider: string; @@ -37,6 +37,7 @@ export interface PrepResponse { browser_prompt: string; browser_reason: string; automations: PersonalizedAutomation[]; + menu?: PersonalizedMenu | null; } export async function fetchIdentity(): Promise { diff --git a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts index 0536ccc4..a5319492 100644 --- a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts +++ b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts @@ -98,6 +98,7 @@ export function useOnboardingV3Pipeline() { personalized_greeting: prep?.greeting?.trim() || null, personalized_headline: prep?.headline?.trim() || null, personalized_starters: prep?.starters ?? [], + personalized_menu: prep?.menu ?? null, personalized_automations: prep?.automations ?? [], })); dispatch(stageReveal({ diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx index 7a73d3c6..93bf2374 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx @@ -1,8 +1,8 @@ import React from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import { motion } from 'framer-motion'; -import { Search, Hammer, Globe, CalendarClock, FolderGit2, Sparkles, ArrowUp, Image as ImageIcon } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Search, Hammer, Globe, CalendarClock, FolderGit2, Sparkles, ArrowUp, ArrowLeft, ChevronRight, Image as ImageIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { useAppSelector } from '@/shared/hooks'; @@ -10,15 +10,11 @@ import { hasModelConnected, hasFreeTrialActive, } from '@/app/components/Onboarding/steps/skipPredicates'; +import { HERO_CATEGORIES, heroMenuFor, type HeroCategoryId } from './heroMenu'; -// Empty canvas, styled after ChatGPT / Claude / Manus: a short question, a centered composer as the HERO, then a few TAILORED, icon-led suggestions (the onboarding scan wrote them), never abstract category buttons. Font sizes come from the shared type scale so it reads clean. -type Suggestion = { title: string; prompt: string }; - -const FALLBACK_SUGGESTIONS: Suggestion[] = [ - { title: 'Research something and give me a clear comparison', prompt: 'Research a topic I care about and give me a tight, current comparison with dated sources. Ask me the topic first if you need to.' }, - { title: 'Build me a small app I can use right now', prompt: 'Build me a simple, useful app I can use right now, and drop it on my canvas.' }, - { title: 'Send an agent to find something on the web', prompt: 'Open a real website and do a multi-step task for me, then report what you found.' }, -]; +// Empty canvas, styled after ChatGPT / Claude / Manus: a short question, a centered composer as the +// HERO, then a two-level menu: 4 GENERAL things OpenSwarm can do, each drilling into 4 SPECIFIC +// starters tailored to this user (onboarding prep wrote them). Font sizes ride the shared type scale. // Give each suggestion a leading icon inferred from what it does, so the list reads like real actions (the way ChatGPT tags suggestions with app icons) instead of a wall of identical rows. function iconForStarter(text: string): LucideIcon { @@ -71,9 +67,12 @@ const DashboardEmptyState: React.FC<{ const mode = useAppSelector((s) => s.settings.data.default_mode); const canRun = useAppSelector((s) => hasFreeTrialActive(s) || hasModelConnected(s)); const personalized = useAppSelector((s) => s.settings.data.personalized_starters ?? []); + const personalizedMenu = useAppSelector((s) => s.settings.data.personalized_menu ?? null); const userName = useAppSelector((s) => s.settings.data.user_name ?? null); const [text, setText] = React.useState(''); const [launching, setLaunching] = React.useState(false); + const [openCat, setOpenCat] = React.useState(null); + const menu = React.useMemo(() => heroMenuFor(personalizedMenu, personalized), [personalizedMenu, personalized]); const firstName = (userName ?? '').trim().split(/\s+/)[0] || null; const headline = firstName ? `What should we get done, ${firstName}?` : 'What do you want done?'; const ghostLines = React.useMemo( @@ -89,9 +88,7 @@ const DashboardEmptyState: React.FC<{ if (onStarter) onStarter(p); }; - const suggestions: Suggestion[] = personalized.length > 0 - ? personalized.slice(0, 4).map((s) => ({ title: s.title, prompt: s.prompt })) - : FALLBACK_SUGGESTIONS; + const openCategory = openCat ? HERO_CATEGORIES.find((cat) => cat.id === openCat) ?? null : null; return ( - {/* Tailored, icon-led suggestions. */} - - {suggestions.map((s, i) => { - const Ic = iconForStarter(`${s.title} ${s.prompt}`); - return ( + {/* Two levels: 4 general things it can do, each opening 4 starters tailored to this user. */} + + {openCategory === null ? ( + + {HERO_CATEGORIES.map((cat, i) => ( + setOpenCat(cat.id)} + disabled={launching} + initial={{ opacity: 0, y: 4 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.25, delay: 0.05 + i * 0.05 }} + sx={{ + display: 'flex', alignItems: 'center', gap: 1.5, textAlign: 'left', width: '100%', + px: 1.75, py: 1.25, borderRadius: '12px', + border: `1px solid transparent`, background: 'transparent', + color: c.text.secondary, fontFamily: 'inherit', fontSize: c.font.size.base, + cursor: launching ? 'default' : 'pointer', + transition: 'background 150ms, border-color 150ms', + '&:hover': launching ? {} : { background: c.bg.surface, borderColor: c.border.subtle, '& .osw-hero-chev': { opacity: 1, transform: 'none' } }, + }} + > + + {cat.label} + + + ))} + + ) : ( + launch(s.prompt)} - disabled={launching} - initial={{ opacity: 0, y: 4 }} - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.25, delay: 0.05 + i * 0.05 }} + component="button" + onClick={() => setOpenCat(null)} sx={{ - display: 'flex', alignItems: 'center', gap: 1.5, textAlign: 'left', width: '100%', - px: 1.75, py: 1.25, borderRadius: '12px', - border: `1px solid transparent`, background: 'transparent', - color: c.text.secondary, fontFamily: 'inherit', fontSize: c.font.size.base, - cursor: launching ? 'default' : 'pointer', - transition: 'background 150ms, border-color 150ms', - '&:hover': launching ? {} : { background: c.bg.surface, borderColor: c.border.subtle }, + display: 'inline-flex', alignItems: 'center', gap: 0.75, alignSelf: 'flex-start', + px: 1.75, py: 0.5, border: 'none', background: 'transparent', + color: c.text.ghost, fontFamily: 'inherit', fontSize: c.font.size.sm, + cursor: 'pointer', '&:hover': { color: c.text.secondary }, }} > - - {s.title} + {openCategory.label} - ); - })} - + {menu[openCategory.id].map((s, i) => { + const Ic = iconForStarter(`${s.title} ${s.prompt}`); + return ( + launch(s.prompt)} + disabled={launching} + initial={{ opacity: 0, y: 4 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.22, delay: 0.03 + i * 0.045 }} + sx={{ + display: 'flex', alignItems: 'center', gap: 1.5, textAlign: 'left', width: '100%', + px: 1.75, py: 1.25, borderRadius: '12px', + border: `1px solid transparent`, background: 'transparent', + color: c.text.secondary, fontFamily: 'inherit', fontSize: c.font.size.base, + cursor: launching ? 'default' : 'pointer', + transition: 'background 150ms, border-color 150ms', + '&:hover': launching ? {} : { background: c.bg.surface, borderColor: c.border.subtle }, + }} + > + + {s.title} + + ); + })} + + )} + ) : ( diff --git a/frontend/src/app/pages/Dashboard/canvas/heroMenu.ts b/frontend/src/app/pages/Dashboard/canvas/heroMenu.ts new file mode 100644 index 00000000..e30bb939 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/canvas/heroMenu.ts @@ -0,0 +1,80 @@ +import { Search, Hammer, Globe, Laptop } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import type { PersonalizedMenu, PersonalizedStarter } from '@/shared/state/settingsSlice'; + +// The hero's two levels: 4 GENERAL categories (what OpenSwarm can do), each opening 4 SPECIFIC +// starters tailored to this user by onboarding prep. When prep never wrote a menu (older installs, +// prep failure), we bucket whatever personalized starters exist and fill the rest from statics, +// so the drill-in is always complete. + +export type HeroCategoryId = 'computer' | 'research' | 'web' | 'build'; + +export interface HeroCategory { + id: HeroCategoryId; + label: string; + Icon: LucideIcon; +} + +export const HERO_CATEGORIES: HeroCategory[] = [ + { id: 'computer', label: 'Do something on my computer', Icon: Laptop }, + { id: 'research', label: 'Research something for me', Icon: Search }, + { id: 'web', label: 'Send an agent to the web', Icon: Globe }, + { id: 'build', label: 'Build me a tiny app', Icon: Hammer }, +]; + +const MENU_SIZE = 4; + +const FALLBACK_MENU: PersonalizedMenu = { + computer: [ + { title: 'Clean up Downloads', prompt: 'Sort my Downloads folder into tidy subfolders. Show me the plan before moving anything.' }, + { title: 'Find my biggest files', prompt: 'Scan my home folder for the largest files and folders and write me a reviewable page listing them with sizes. Do not delete anything.' }, + { title: 'Index my documents', prompt: 'Look through my Documents folder and build one searchable index page listing files by name and date so I can find things fast. Write only the new page.' }, + { title: 'Find duplicate files', prompt: 'Look for duplicate files across my Downloads and Desktop and write a report listing them side by side. Never delete anything without my review.' }, + ], + research: [ + { title: 'Compare before I buy', prompt: "Ask me what I'm shopping for, then research current options and give me a tight comparison table with dated sources." }, + { title: "What's new in AI", prompt: 'Search the web for the most useful AI tools and model releases from the past month and summarize the ones worth my time, with dated sources.' }, + { title: 'Settle a question', prompt: "Ask me one question I've been meaning to look into, then research it properly and give me a current, sourced answer." }, + { title: 'Plan a weekend trip', prompt: "Ask me where I'd like to go, then research a realistic 3-day itinerary with current prices and opening hours, and turn it into a printable page." }, + ], + web: [ + { title: 'Find a table tonight', prompt: 'Open OpenTable and find three well-reviewed restaurants near me with availability tonight, compare them, and report back.' }, + { title: 'Watch a flight price', prompt: 'Ask me for a route, then open Google Flights, search it, compare dates and airlines across a few pages, and report the best current options.' }, + { title: 'Best rated near me', prompt: 'Open Google Maps, search for the best-rated coffee shops nearby, read through reviews on a few of them, and tell me which one to try and why.' }, + { title: 'Catch me up on tech', prompt: "Open Hacker News, read through today's top discussions, and give me the three most interesting threads with what people are actually saying." }, + ], + build: [ + { title: 'Habit tracker', prompt: 'Build me a simple habit tracker app I can use right now.' }, + { title: 'Bill splitter', prompt: 'Build me a tool where I enter a bill total, tip, and the people involved, and it computes exactly who owes what.' }, + { title: 'CSV instant charts', prompt: 'Build me a tool where I paste CSV data and it instantly renders clean charts I can screenshot.' }, + { title: 'Countdown to a date', prompt: 'Build me a countdown page for a date that matters to me; ask me the date and label, then make it look great.' }, + ], +}; + +function classifyStarter(s: PersonalizedStarter): HeroCategoryId { + const t = `${s.title} ${s.prompt}`.toLowerCase(); + if (/build me|small app|tiny app|tool that|tracker/.test(t)) return 'build'; + if (/open [a-z]|browse|website|browser|opentable|maps|flights/.test(t)) return 'web'; + if (/folder|files|downloads|desktop|documents|screenshot|my mac|my computer|repo/.test(t)) return 'computer'; + return 'research'; +} + +export function heroMenuFor(menu: PersonalizedMenu | null | undefined, starters: PersonalizedStarter[]): PersonalizedMenu { + const out: PersonalizedMenu = { computer: [], research: [], web: [], build: [] }; + if (menu) { + for (const cat of HERO_CATEGORIES) out[cat.id] = [...(menu[cat.id] ?? [])]; + } else { + for (const s of starters) { + const cat = classifyStarter(s); + if (out[cat].length < MENU_SIZE) out[cat].push(s); + } + } + for (const cat of HERO_CATEGORIES) { + for (const extra of FALLBACK_MENU[cat.id]) { + if (out[cat.id].length >= MENU_SIZE) break; + if (out[cat.id].every((row) => row.title !== extra.title)) out[cat.id].push(extra); + } + out[cat.id] = out[cat.id].slice(0, MENU_SIZE); + } + return out; +} diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index a971475b..88c0114d 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -91,6 +91,8 @@ export interface AppSettings { personalized_headline?: string | null; personalized_starters?: PersonalizedStarter[]; personalized_automations?: PersonalizedAutomation[]; + /** The hero's two-level menu: 4 general categories, each with 4 starters tailored to this user. */ + personalized_menu?: PersonalizedMenu | null; personalized_usage_summary?: string | null; } @@ -106,6 +108,13 @@ export interface PersonalizedAutomation { cadence: 'daily' | 'weekday' | 'weekly'; } +export interface PersonalizedMenu { + computer: PersonalizedStarter[]; + research: PersonalizedStarter[]; + web: PersonalizedStarter[]; + build: PersonalizedStarter[]; +} + export interface ActivateSubscriptionPayload { token: string; plan?: string | null;