From 96451de87e3863363b2eceed0dbbb5598154368c Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 18:29:15 -0700 Subject: [PATCH] [eric] help: ground the help chat in real product knowledge instead of a hardcoded prompt --- backend/apps/help/bundle.py | 15 +- backend/apps/help/help_topics.py | 269 ++++++++++++++++++ backend/apps/help/knowledge.py | 187 ++++++++++++ backend/apps/help/known_issues.py | 61 ++++ backend/apps/help/prompt_rules.py | 61 ++++ backend/tests/test_help_knowledge.py | 110 +++++++ .../pages/Dashboard/desktop/HelpAskBox.tsx | 61 ++++ .../app/pages/Dashboard/desktop/HelpPanel.tsx | 55 +--- .../pages/Dashboard/desktop/helpKnowledge.ts | 43 +++ .../Dashboard/desktop/helpSearch.test.ts | 86 ++++++ .../app/pages/Dashboard/desktop/helpSearch.ts | 94 ++++++ 11 files changed, 998 insertions(+), 44 deletions(-) create mode 100644 backend/apps/help/help_topics.py create mode 100644 backend/apps/help/knowledge.py create mode 100644 backend/apps/help/known_issues.py create mode 100644 backend/apps/help/prompt_rules.py create mode 100644 backend/tests/test_help_knowledge.py create mode 100644 frontend/src/app/pages/Dashboard/desktop/HelpAskBox.tsx create mode 100644 frontend/src/app/pages/Dashboard/desktop/helpKnowledge.ts create mode 100644 frontend/src/app/pages/Dashboard/desktop/helpSearch.test.ts create mode 100644 frontend/src/app/pages/Dashboard/desktop/helpSearch.ts diff --git a/backend/apps/help/bundle.py b/backend/apps/help/bundle.py index f4f4ee99..13037c6f 100644 --- a/backend/apps/help/bundle.py +++ b/backend/apps/help/bundle.py @@ -1,8 +1,8 @@ -"""Diagnostic bundle for bug reports: one folder a user can drag into a GitHub issue. +"""Routes for the Help panel: the product-knowledge feed, and the diagnostic bundle for bug reports. -Everything is assembled LOCALLY and only revealed in the file manager; nothing uploads -anywhere by itself. Contents are deliberately allowlisted (identity, versions, feature -booleans, provider KINDS, counts, log tail) so no secret or API key can ever ride along. +The bundle is assembled LOCALLY and only revealed in the file manager; nothing uploads anywhere by +itself. Contents are deliberately allowlisted (identity, versions, feature booleans, provider KINDS, +counts, log tail) so no secret or API key can ever ride along. """ import base64 @@ -18,6 +18,7 @@ from typing import AsyncIterator, List from pydantic import BaseModel, ConfigDict, Field from typeguard import typechecked +from backend.apps.help.knowledge import HelpKnowledgeResponse, build_knowledge_response from backend.config.Apps import SubApp from backend.config.paths import DATA_ROOT, SESSIONS_DIR @@ -138,6 +139,12 @@ def p_build_report(req: BundleRequest) -> str: return "\n".join(lines) +@help_app.router.get("/knowledge") +@typechecked +async def get_help_knowledge() -> HelpKnowledgeResponse: + return build_knowledge_response() + + @help_app.router.post("/bundle") @typechecked async def build_bundle(body: BundleRequest) -> dict: diff --git a/backend/apps/help/help_topics.py b/backend/apps/help/help_topics.py new file mode 100644 index 00000000..76866f2d --- /dev/null +++ b/backend/apps/help/help_topics.py @@ -0,0 +1,269 @@ +"""Curated product knowledge for the in-app help chat. + +Every fact here is written from the CODE, not from the docs site, and ships inside the same +build as the surfaces it describes, so help can never describe a version the user isn't running. +Each topic is self-contained on purpose: the help panel searches these offline with no model at +all, and the chat gets them as a cached system-prompt block. + +When you move or rename a surface, update the topic in the SAME change. A help topic that lies is +worse than no help topic. +""" + +from typing import List + +from pydantic import BaseModel, ConfigDict + + +class HelpTopic(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + id: str + title: str + # Where the thing physically is on screen. Empty when the topic is a concept, not a surface. + where: str + body: str + keywords: List[str] + + +HELP_TOPICS: List[HelpTopic] = [ + HelpTopic( + id="canvas", + title="The dashboard canvas", + where="The whole main window.", + body=( + "Each dashboard is an infinite canvas holding cards: agent chats, browsers, apps you " + "built, and workflows. Scroll or pinch to zoom, hold Space and drag to pan, drag a card " + "by its header to move it, and drag on empty canvas to marquee-select several." + ), + keywords=["canvas", "dashboard", "zoom", "pan", "move", "cards", "select"], + ), + HelpTopic( + id="dock", + title="The dock", + where="A dark vertical rail down the left edge of the window.", + body=( + "Every open card gets a colored tile at the top of the dock; click one to jump to that " + "card, right-click it for its menu. Below the divider sit four actions in this order: " + "New browser, Workflows, then Settings and Applications." + ), + keywords=["dock", "rail", "sidebar", "left", "tiles", "icons"], + ), + HelpTopic( + id="applications", + title="Applications, your app library", + where="The grid icon at the very bottom of the left dock.", + body=( + "Opens a window listing every app you have built or imported, with thumbnails. Click one " + "to drop it onto the current dashboard as a live card. A search box appears once you have " + "more than eight apps. It lists YOUR apps, not the programs installed on your computer." + ), + keywords=["applications", "apps", "library", "grid", "built", "outputs"], + ), + HelpTopic( + id="new-chat", + title="Starting a new chat", + where="The 'Ask me anything...' pill at the bottom center of the canvas.", + body=( + "Type in the pill and press Enter to spawn an agent card. The pill is hidden while a " + "dashboard is completely empty, so the keyboard shortcut is the reliable way in; it works " + "from anywhere. The new card lands beside whatever is selected, otherwise in view." + ), + keywords=["new chat", "agent", "spawn", "pill", "ask", "start", "composer"], + ), + HelpTopic( + id="window-controls", + title="Card window controls and tiling", + where="The three traffic-light dots in the top-left corner of any card.", + body=( + "Red closes, yellow minimizes, green toggles full screen. HOVER the green dot instead of " + "clicking it and a macOS-style tiling menu opens with two groups: Fill and Halves (fill, " + "left, right, top, bottom) and Quarters. Clicking green again restores a tiled card." + ), + keywords=["traffic lights", "fullscreen", "tile", "halves", "quarters", "minimize", "close", "green"], + ), + HelpTopic( + id="minimized", + title="Minimized cards", + where="A stack of small thumbnails on the right edge of the canvas.", + body=( + "Minimizing a card with the yellow dot parks it in the right-edge stack instead of closing " + "it. Click a thumbnail to put the card back on the canvas exactly where it was." + ), + keywords=["minimize", "minimized", "restore", "stack", "thumbnail", "yellow"], + ), + HelpTopic( + id="spaces", + title="Dashboards, which behave like Spaces", + where="Rest the cursor on the very top edge of the window to reveal the spaces bar.", + body=( + "Dashboards are separate canvases, like macOS Spaces. The top-edge bar switches between " + "them, + adds one, and right-clicking a space renames or removes it. There is also a " + "keyboard shortcut for the previous and next dashboard." + ), + keywords=["spaces", "dashboards", "switch", "top edge", "workspace", "add"], + ), + HelpTopic( + id="history", + title="Chat history", + where="The island at the top center of the dashboard.", + body=( + "History lists past chats across ALL dashboards, not just the current one. Picking a chat " + "reopens it as a card. It is on the top island, not in the dock." + ), + keywords=["history", "past chats", "previous", "reopen", "recent", "island"], + ), + HelpTopic( + id="search", + title="Search everything", + where="The global search palette, opened with the search shortcut.", + body=( + "One palette searches chats, apps, and commands across every dashboard. There is a " + "separate find that searches only the cards on the current canvas, and inside a browser " + "card that same find searches the web page instead." + ), + keywords=["search", "find", "palette", "command", "lookup", "cmd k"], + ), + HelpTopic( + id="workflows", + title="Workflows and scheduled tasks", + where="The repeating-calendar icon in the left dock, above Settings.", + body=( + "A workflow is a sequence of agent steps that runs on a schedule, for example every " + "weekday at 9am. Open the Workflows window from the dock to create one, set its steps and " + "schedule, run it immediately with Run now, or pause it. You can also just ask an agent in " + "chat to schedule something and it will build the workflow for you." + ), + keywords=["workflow", "schedule", "scheduled", "task", "cron", "recurring", "automation", "daily"], + ), + HelpTopic( + id="apps", + title="Building apps", + where="Ask any agent chat, or the + menu on the canvas.", + body=( + "Ask an agent for a tool, dashboard, or game and it calls CreateApp, which seeds a " + "workspace and puts a live preview card on the dashboard, then writes the code. To change " + "an app, select its card and tell the agent what to change; the preview reloads itself." + ), + keywords=["app", "build", "create", "app builder", "preview", "vite", "code"], + ), + HelpTopic( + id="publish", + title="Publishing an app to the web", + where="The share or publish control on a built app's card.", + body=( + "An app can be published to a public {slug}.openswarm.host URL. Publishing first scans the " + "code for anything sensitive and shows the findings before it uploads. Published apps can " + "be unpublished again from the same place." + ), + keywords=["publish", "share", "host", "openswarm.host", "deploy", "public", "link"], + ), + HelpTopic( + id="browser", + title="Browser cards", + where="The globe icon in the left dock.", + body=( + "A browser card is a real browser on the canvas, with tabs, its own zoom, find-in-page, and " + "a persistent login session that survives quitting the app. Agents can drive these cards " + "for you, and you stay signed in to the sites you use." + ), + keywords=["browser", "web", "tabs", "globe", "login", "website", "internet"], + ), + HelpTopic( + id="browser-agent", + title="Agents that use the browser", + where="Happens automatically inside a chat.", + body=( + "When a task needs a real website, the agent delegates to a browser sub-agent, which opens " + "a browser card and works in it while you watch. It is the last resort: agents prefer " + "connected tools and plain web search first, because those are faster and more reliable." + ), + keywords=["browser agent", "sub-agent", "automation", "click", "website", "delegate"], + ), + HelpTopic( + id="tools", + title="Tools and MCP integrations", + where="Settings, then Tools.", + body=( + "Integrations (Gmail, Notion, Slack and so on) are MCP servers. They are deliberately NOT " + "active by default: an agent has to find one with MCPSearch and then activate it with " + "MCPActivate, which asks for your approval first. Nothing gets tool access silently." + ), + keywords=["tools", "mcp", "integration", "connect", "gmail", "notion", "slack", "approval", "activate"], + ), + HelpTopic( + id="approvals", + title="Approvals", + where="In the chat card, as a prompt from the agent.", + body=( + "Before an agent does something that needs your say-so it stops and asks in the chat. You " + "approve or deny, and you can tell it to remember the choice so it stops asking for that " + "same action." + ), + keywords=["approval", "permission", "approve", "deny", "hitl", "ask", "confirm"], + ), + HelpTopic( + id="skills", + title="Skills", + where="Settings, then Skills.", + body=( + "A skill is a reusable set of instructions that teaches agents how to do a specific task. " + "You can write one, install one from the registry, or import one, and agents pull in a " + "skill on demand when it is relevant instead of carrying all of them every turn." + ), + keywords=["skill", "skills", "instructions", "registry", "teach", "import"], + ), + HelpTopic( + id="dictation", + title="Voice dictation", + where="The mic button on the composer, or the dictation shortcut.", + body=( + "Dictation transcribes speech locally on your machine and drops the words wherever your " + "cursor is. The first use downloads a voice model, so it is slower once and fast after. " + "Hold-to-talk versus click-to-toggle is a setting under General." + ), + keywords=["dictation", "voice", "mic", "speak", "transcribe", "talk", "whisper"], + ), + HelpTopic( + id="models", + title="Connecting a model", + where="Settings, then Models.", + body=( + "You can connect a Claude, ChatGPT, or Gemini subscription, paste an API key, or use " + "OpenSwarm's own paid plan. Brand-new installs get a small free trial that runs on a " + "shared pool. If a connection dies, this is the page with the Reconnect button." + ), + keywords=["model", "models", "connect", "api key", "subscription", "claude", "gpt", "gemini", "provider", "reconnect", "free trial"], + ), + HelpTopic( + id="settings", + title="Settings", + where="The gear icon in the left dock; it opens as a card on the canvas.", + body=( + "Sections are Account; then App: General, Appearance, Privacy, Advanced; then " + "Capabilities: Models, Skills, Tools, Commands, Usage. Theme, accent color, and text size " + "live under Appearance. Agent defaults and shortcuts live under General." + ), + keywords=["settings", "preferences", "gear", "config", "theme", "account", "appearance", "privacy", "advanced", "usage"], + ), + HelpTopic( + id="swarm-file", + title="Sharing with .swarm files", + where="Share on the thing you want to export; drag a .swarm file onto the app to import.", + body=( + "Apps, skills, and workflows export to a single .swarm file you can send someone. Importing " + "one adds it to your library. The export is scanned so credentials never travel inside it." + ), + keywords=["swarm", "export", "import", "share", "file", "backup", "send"], + ), + HelpTopic( + id="report-bug", + title="Reporting a bug", + where="The Help pill, top right, then Report a bug.", + body=( + "It writes a diagnostics folder locally (versions, platform, recent log tail, with secrets " + "stripped), reveals that folder in your file manager, and opens a prefilled GitHub issue to " + "drag the files into. Nothing uploads on its own." + ), + keywords=["bug", "report", "issue", "broken", "diagnostics", "github", "feedback", "crash"], + ), +] diff --git a/backend/apps/help/knowledge.py b/backend/apps/help/knowledge.py new file mode 100644 index 00000000..78db0d59 --- /dev/null +++ b/backend/apps/help/knowledge.py @@ -0,0 +1,187 @@ +"""Assembles what the in-app help chat knows: shipped product facts plus live facts about THIS +install (version, platform, the user's actual shortcuts, whether a model is even connected). + +Shipped facts can't drift from the build. Live facts are the part a shipped file can never get +right, and they are exactly where a hardcoded help prompt goes stale first. + +The result is handed to the chat as its system prompt, which lands in the provider's cached +prefix (see RunOptions), so the knowledge is paid for once per session rather than per turn. +""" + +import platform +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict +from typeguard import typechecked + +from backend.apps.help.help_topics import HELP_TOPICS, HelpTopic +from backend.apps.help.known_issues import KNOWN_ISSUES, HelpKnownIssue +from backend.apps.help.prompt_rules import GROUNDING_RULES, ROLE + +IS_MAC = platform.system() == "Darwin" + +# Off mac these handlers fire on Ctrl (the code reads metaKey||ctrlKey), so "Meta" must READ as Ctrl. +P_MAC_GLYPH = {"meta": "⌘", "ctrl": "⌃", "alt": "⌥", "shift": "⇧"} +P_OTHER_NAME = {"meta": "Ctrl", "ctrl": "Ctrl", "alt": "Alt", "shift": "Shift"} + +# Same "Meta+l" parts format the settings fields use, so one renderer covers stock and configured alike. +P_FIXED_SHORTCUTS: List[tuple] = [ + ("Meta+k", "Search everything, across all dashboards"), + ("Meta+f", "Find cards on this canvas; inside a browser card it finds text on the page"), + ("Meta+Shift+t", "Reopen the card you just closed"), + ("Meta+a", "Select every card"), + ("Meta+c", "Copy the selected cards"), + ("Meta+v", "Paste the copied cards"), + ("Delete", "Delete the selected cards"), + ("Enter", "Expand or collapse the selected chat"), + ("Meta+Alt+Left", "Previous dashboard"), + ("Meta+Alt+Right", "Next dashboard"), + ("Meta+=", "Zoom in; inside a browser card it zooms the page"), + ("Meta+-", "Zoom out; inside a browser card it zooms the page"), + ("Meta+0", "Reset zoom"), + ("Ctrl+Tab", "Next tab in the focused browser card"), + ("Space", "Hold and drag to pan the canvas"), + ("Arrows", "Move between cards"), + ("Escape", "Close the open panel or menu"), +] + + +class HelpShortcut(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + keys: str + action: str + + +class HelpKnowledgeResponse(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + system_prompt: str + topics: List[HelpTopic] + known_issues: List[HelpKnownIssue] + shortcuts: List[HelpShortcut] + app_version: str + + +@typechecked +def render_combo(combo: str) -> str: + """'Meta+Shift+d' -> '⇧⌘D' on mac, 'Ctrl+Shift+D' elsewhere.""" + parts = [p for p in combo.split("+") if p] + out: List[str] = [] + for part in parts: + low = part.lower() + if IS_MAC: + out.append(P_MAC_GLYPH.get(low, part.upper() if len(part) == 1 else part)) + else: + out.append(P_OTHER_NAME.get(low, part.upper() if len(part) == 1 else part)) + return ("" if IS_MAC else "+").join(out) + + +@typechecked +def dictation_default_combo() -> str: + return "Meta+Shift+d" if IS_MAC else "Ctrl+Shift+d" + + +@typechecked +def build_shortcuts(new_agent_combo: str, dictation_combo: Optional[str]) -> List[HelpShortcut]: + live = [ + HelpShortcut(keys=render_combo(new_agent_combo or "Meta+l"), action="Open the new-chat composer"), + HelpShortcut(keys=render_combo(dictation_combo or dictation_default_combo()), action="Start or stop voice dictation"), + ] + return live + [HelpShortcut(keys=render_combo(c), action=a) for c, a in P_FIXED_SHORTCUTS] + + +@typechecked +def p_provider_state() -> str: + """One honest line about whether this install can actually run a model.""" + from backend.apps.settings.store import load_settings + + s = load_settings() + mode = getattr(s, "connection_mode", "own_key") + if mode == "free-trial": + return "on the free trial (a shared capacity pool)" + if mode == "openswarm-pro": + return "on an OpenSwarm paid plan" + keyed = any( + getattr(s, f, None) + for f in ("anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key") + ) + subbed = any( + getattr(s, f, None) + for f in ("claude_subscription_token", "openai_subscription_token", "gemini_subscription_token") + ) + if subbed: + return "on a connected provider subscription" + if keyed: + return "on their own API key" + return "with NO model connected yet, so most agent work will fail until they connect one in Settings > Models" + + +@typechecked +def p_topics_block() -> str: + lines: List[str] = [] + for t in HELP_TOPICS: + where = f" WHERE: {t.where}" if t.where else "" + lines.append(f"- [{t.id}] {t.title}.{where} {t.body}") + return "\n".join(lines) + + +@typechecked +def p_issues_block() -> str: + lines: List[str] = [] + for i in KNOWN_ISSUES: + fix = f" Workaround: {i.workaround}" if i.workaround else "" + lines.append(f"- [{i.id}] ({i.status}) {i.title}. {i.detail}{fix}") + return "\n".join(lines) + + +@typechecked +def build_system_prompt(shortcuts: List[HelpShortcut], app_version: str) -> str: + shortcut_lines = "\n".join(f"- {s.keys}: {s.action}" for s in shortcuts) + os_name = "macOS" if IS_MAC else platform.system() + return "\n".join( + [ + ROLE, + "", + "", + f"OpenSwarm version {app_version} on {os_name}. This user is {p_provider_state()}.", + "These are live facts about the machine you are talking to; trust them over anything you recall.", + "", + "", + "", + "Verified against this exact build. This is your ground truth for where things are.", + p_topics_block(), + "", + "", + "", + "This user's real shortcuts, already written for their platform. Quote them exactly.", + shortcut_lines, + "", + "", + "", + "The complete list of issues shipped with this build. You cannot see live bug reports.", + p_issues_block(), + "", + "", + GROUNDING_RULES, + ] + ) + + +@typechecked +def build_knowledge_response() -> HelpKnowledgeResponse: + from backend.apps.service.version import APP_VERSION + from backend.apps.settings.store import load_settings + + s = load_settings() + shortcuts = build_shortcuts( + getattr(s, "new_agent_shortcut", "Meta+l") or "Meta+l", + getattr(s, "dictation_shortcut", None), + ) + return HelpKnowledgeResponse( + system_prompt=build_system_prompt(shortcuts, APP_VERSION), + topics=HELP_TOPICS, + known_issues=KNOWN_ISSUES, + shortcuts=shortcuts, + app_version=APP_VERSION, + ) diff --git a/backend/apps/help/known_issues.py b/backend/apps/help/known_issues.py new file mode 100644 index 00000000..aa52f6b4 --- /dev/null +++ b/backend/apps/help/known_issues.py @@ -0,0 +1,61 @@ +"""The curated known-issues list that ships with this build. + +Deliberately NOT the live GitHub issue queue: that queue is engineering-facing (path guards, token +budgets) and phrased in nothing like the words a user would use for their symptom, so matching a +user's complaint against it produces false confidence. It is also network-dependent and publicly +writable, which is a prompt-injection surface for no gain. + +So: a short list of real, user-visible symptoms, each verified. The help chat is told this list is +complete and that it has no live view of the tracker, so it can never invent a bug status. +""" + +from typing import List, Literal, Optional + +from pydantic import BaseModel, ConfigDict + + +class HelpKnownIssue(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + id: str + title: str + status: Literal["known", "mitigated", "fixed"] + detail: str + workaround: Optional[str] = None + + +KNOWN_ISSUES: List[HelpKnownIssue] = [ + HelpKnownIssue( + id="free-trial-capacity", + title="Free-trial runs fail with a capacity or busy message", + status="mitigated", + detail=( + "The free trial runs on one shared pool of capacity, so under load a run can come back " + "saying it is out of capacity. The app now waits and retries automatically instead of " + "erroring straight away, but a sustained busy period still ends in that message." + ), + workaround="Connecting your own subscription or API key under Settings, then Models, avoids the shared pool entirely.", + ), + HelpKnownIssue( + id="windows-cli-quarantine", + title="Windows: 'Claude Code not found' after installing", + status="mitigated", + detail=( + "Some Windows antivirus products quarantine the command-line binary that ships inside the " + "app, which makes every run fail with a not-found error. Newer builds ship that binary " + "code-signed, and the app now detects the case and shows repair steps instead of a raw error." + ), + workaround="Restore the file from your antivirus quarantine, or reinstall OpenSwarm. Your chats are kept either way.", + ), + HelpKnownIssue( + id="dashboard-switch-logout", + title="Switching dashboards can sign you out of a site in a browser card", + status="known", + detail=( + "Some sites keep their login in per-tab storage that only lives as long as the page is " + "mounted. Panning away from a card and back preserves it, because that state is captured " + "and restored, but switching to another dashboard and back can still lose it." + ), + workaround="Keep browser cards you are signed into on the dashboard you are working in, or sign in again after switching.", + ), +] diff --git a/backend/apps/help/prompt_rules.py b/backend/apps/help/prompt_rules.py new file mode 100644 index 00000000..9608a24e --- /dev/null +++ b/backend/apps/help/prompt_rules.py @@ -0,0 +1,61 @@ +"""The behavior contract for the help chat, kept apart from the facts it reasons over. + +The single property that matters here is refusal: a help assistant that invents a menu item is +worse than one that says it doesn't know, because the user burns real time hunting for a button +that was never built. Everything below exists to make "I don't know" the cheap answer. +""" + +ROLE = ( + "You are OpenSwarm's help assistant, a support chat built into the app itself.\n" + "You help people use OpenSwarm: where things are, how to do them, and what went wrong.\n" + "You are talking to someone with the app open in front of them right now." +) + +GROUNDING_RULES = "\n".join( + [ + "", + "GROUNDING. The blocks above are your only source of truth about OpenSwarm. They were written", + "from this build's code, so they beat anything you remember about this or any similar app.", + "- Answer from those blocks. Name the surface you are drawing on, in plain prose, so the user", + " can go look at it: 'the dock, on the left edge' rather than an unsourced instruction.", + "- Quote shortcuts EXACTLY as they appear in . Never guess a key combination, and", + " never convert between platforms yourself; the list is already correct for this machine.", + "- Never invent a button, menu item, tab, setting, or page name. If you cannot name the exact", + " surface from the blocks above, you do not know where it is, and you must say so.", + "- The [bracketed ids] are internal labels for your own lookup. Never print one; the user has", + " never seen them and they read as a glitch.", + "", + "SAYING YOU DON'T KNOW. This is a correct, expected answer, not a failure.", + "- If something is not covered above, say plainly that you don't know or that OpenSwarm does", + " not appear to have it, in one sentence, with no hedging and no invented alternative.", + "- Then point at something real: the docs (Help pill, then Docs and shortcuts), the Discord", + " (Help pill, then Talk to the team), or Report a bug for something broken.", + "- If the user asks for a feature that is genuinely absent, say it is absent and offer to help", + " them file it as a feature request. Do not describe a workflow that does not exist.", + "- Never dress a guess as an answer. A wrong click path costs more than an honest 'not sure'.", + "", + "BUGS AND KNOWN ISSUES. Be exact about what you can and cannot see.", + "- is the complete list that shipped with this build. You have no live view of", + " the bug tracker, so you cannot confirm or deny anything outside that list.", + "- If it matches a listed issue, say so and give the status and workaround verbatim.", + "- If it does not match, say you cannot tell whether it is a known bug, then walk them to the", + " Help pill and Report a bug, which packages diagnostics automatically.", + "- Never invent a bug status, a fix version, or an ETA.", + "", + "TROUBLESHOOTING. Diagnose from before theorizing. If no model is connected,", + "that is the answer to most 'it failed' questions. Ask for the exact error text when you need", + "it rather than guessing which of several causes applies.", + "", + "SCOPE AND STYLE.", + "- Stay a help chat. Never start unrelated agent work from here; if they want real work done,", + " tell them to start a normal chat from the canvas composer.", + "- You may read their settings to answer a question about their setup. Ask before changing any.", + "- The docs site can lag the installed version. If docs and the blocks above disagree, the", + " blocks win, and say so.", + "- Be brief. Give the click path or the key, not an essay. Two or three sentences is usually the", + " whole answer. No preamble, no restating the question.", + "- Never mention your own tools, or narrate one failing. If a tool won't cooperate, just answer", + " in plain text as if you had never reached for it.", + "", + ] +) diff --git a/backend/tests/test_help_knowledge.py b/backend/tests/test_help_knowledge.py new file mode 100644 index 00000000..ee8ad24f --- /dev/null +++ b/backend/tests/test_help_knowledge.py @@ -0,0 +1,110 @@ +"""The help chat's knowledge feed. The invariants here are all about honesty: the facts must be +present, the shortcuts must be the user's real ones, and the prompt must forbid guessing. + +A help assistant that invents a menu item is the failure mode this whole feature exists to prevent, +so the grounding rules are pinned by test rather than trusted to survive a future prompt edit. +""" + +from fastapi.testclient import TestClient + +from backend.apps.help.help_topics import HELP_TOPICS +from backend.apps.help.knowledge import ( + build_knowledge_response, + build_shortcuts, + build_system_prompt, + render_combo, +) +from backend.apps.help.known_issues import KNOWN_ISSUES +from backend.main import app + + +def test_knowledge_endpoint_serves_topics_and_issues(): + import backend.auth as auth_mod + + with TestClient(app, headers={"Authorization": f"Bearer {auth_mod.TOKEN}"}) as client: + res = client.get("/api/help/knowledge") + assert res.status_code == 200 + body = res.json() + assert len(body["topics"]) == len(HELP_TOPICS) + assert len(body["known_issues"]) == len(KNOWN_ISSUES) + assert body["system_prompt"] + assert body["app_version"] + + +def test_every_topic_is_self_contained(): + """Kapa's rule: a chunk retrieved alone still has to make sense alone.""" + for topic in HELP_TOPICS: + assert topic.id and topic.title and topic.body + assert topic.keywords, f"{topic.id} has no keywords, so local search can never find it" + assert len(topic.body) > 40, f"{topic.id} body is too thin to answer anything" + + +def test_topic_ids_are_unique(): + ids = [t.id for t in HELP_TOPICS] + assert len(ids) == len(set(ids)) + + +def test_known_issues_never_carry_a_fix_date_or_eta(): + """Bug status is the easiest thing to lie about, so the data itself must not invite it.""" + for issue in KNOWN_ISSUES: + assert issue.status in ("known", "mitigated", "fixed") + assert issue.detail + for banned in ("ETA", "next release", "soon", "will be fixed"): + assert banned.lower() not in issue.detail.lower() + + +def test_render_combo_matches_the_platform(): + from backend.apps.help.knowledge import IS_MAC + + if IS_MAC: + assert render_combo("Meta+l") == "⌘L" + assert render_combo("Meta+Shift+d") == "⌘⇧D" + else: + assert render_combo("Meta+l") == "Ctrl+L" + assert render_combo("Meta+Shift+d") == "Ctrl+Shift+D" + + +def test_shortcuts_use_the_users_configured_combo_not_the_default(): + shortcuts = build_shortcuts("Meta+Shift+j", "Alt+m") + new_chat = next(s for s in shortcuts if s.action == "Open the new-chat composer") + dictation = next(s for s in shortcuts if "dictation" in s.action) + assert new_chat.keys == render_combo("Meta+Shift+j") + assert dictation.keys == render_combo("Alt+m") + + +def test_dictation_falls_back_to_the_platform_default_when_unset(): + from backend.apps.help.knowledge import dictation_default_combo + + shortcuts = build_shortcuts("Meta+l", None) + dictation = next(s for s in shortcuts if "dictation" in s.action) + assert dictation.keys == render_combo(dictation_default_combo()) + + +def test_prompt_forbids_guessing_and_permits_not_knowing(): + prompt = build_knowledge_response().system_prompt + assert "Never invent a button" in prompt + assert "Never invent a bug status" in prompt + assert "you don't know" in prompt.lower() + # The refusal has to be framed as acceptable, or the model treats it as a last resort. + assert "not a failure" in prompt + + +def test_prompt_states_it_cannot_see_live_bug_reports(): + prompt = build_knowledge_response().system_prompt + assert "cannot see live bug reports" in prompt + assert "no live view of" in prompt + + +def test_prompt_carries_every_topic_and_issue(): + prompt = build_system_prompt(build_shortcuts("Meta+l", None), "9.9.9") + for topic in HELP_TOPICS: + assert f"[{topic.id}]" in prompt + for issue in KNOWN_ISSUES: + assert f"[{issue.id}]" in prompt + assert "9.9.9" in prompt + + +def test_prompt_stays_within_a_sane_token_budget(): + """It rides the cached prefix, but an unbounded knowledge blob still costs a cache write.""" + prompt = build_knowledge_response().system_prompt + assert len(prompt) < 24_000, "help knowledge is growing past its budget; tighten the topics" diff --git a/frontend/src/app/pages/Dashboard/desktop/HelpAskBox.tsx b/frontend/src/app/pages/Dashboard/desktop/HelpAskBox.tsx new file mode 100644 index 00000000..20c6d384 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/HelpAskBox.tsx @@ -0,0 +1,61 @@ +import React, { useMemo } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded'; +import ArrowUpwardRoundedIcon from '@mui/icons-material/ArrowUpwardRounded'; +import { searchHelp, type HelpKnowledge } from './helpSearch'; + +interface HelpAskBoxProps { + value: string; + knowledge: HelpKnowledge | null; + onChange: (v: string) => void; + onAsk: () => void; +} + +/** + * Search first, chat second (the Raycast/Linear shape): the top questions are answered from facts + * that shipped with the build, instantly, with no model call and no network. That also makes this + * the one part of Help that still works when the user's provider is the thing that's broken. + */ +const HelpAskBox: React.FC = ({ value, knowledge, onChange, onAsk }) => { + const matches = useMemo(() => searchHelp(knowledge, value), [knowledge, value]); + + return ( + + + + ) => onChange(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') onAsk(); e.stopPropagation(); }} + placeholder="Ask OpenSwarm anything..." + sx={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', color: 'rgba(255,255,255,0.92)', fontFamily: 'inherit', fontSize: '0.8125rem', '&::placeholder': { color: 'rgba(255,255,255,0.4)' } }} + /> + {value.trim() && ( + + + + )} + + {matches.length > 0 && ( + + + From the docs + + {matches.map((m) => ( + + {m.title} + {m.detail} + + ))} + + Press Enter to ask the help chat instead. + + + )} + + ); +}; + +export default HelpAskBox; diff --git a/frontend/src/app/pages/Dashboard/desktop/HelpPanel.tsx b/frontend/src/app/pages/Dashboard/desktop/HelpPanel.tsx index 62aa072f..afcb5600 100644 --- a/frontend/src/app/pages/Dashboard/desktop/HelpPanel.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/HelpPanel.tsx @@ -1,9 +1,8 @@ -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import { AnimatePresence, motion } from 'framer-motion'; -import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded'; import BugReportOutlinedIcon from '@mui/icons-material/BugReportOutlined'; import LightbulbOutlinedIcon from '@mui/icons-material/LightbulbOutlined'; import MenuBookOutlinedIcon from '@mui/icons-material/MenuBookOutlined'; @@ -12,32 +11,15 @@ import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded'; import ArrowOutwardRoundedIcon from '@mui/icons-material/ArrowOutwardRounded'; import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; -import ArrowUpwardRoundedIcon from '@mui/icons-material/ArrowUpwardRounded'; +import HelpAskBox from './HelpAskBox'; +import { FALLBACK_HELP_PROMPT, loadHelpKnowledge } from './helpKnowledge'; +import type { HelpKnowledge } from './helpSearch'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { createDraftSession, launchAndSendFirstMessage, type AgentConfig } from '@/shared/state/agentsSlice'; import { addBrowserCard } from '@/shared/state/dashboardLayoutSlice'; import { getLastDashboardId } from '@/shared/lastDashboardId'; import { API_BASE } from '@/shared/config'; -// The Ask chat is a DEDICATED help chat, not a general agent: it knows the app's surfaces and stays -// in support mode instead of launching into open-ended work. -const HELP_SYSTEM_PROMPT = [ - "You are OpenSwarm's help assistant, a dedicated support chat inside the app.", - 'Answer questions about using OpenSwarm clearly and briefly, with the exact clicks or keys.', - 'What you know about the app:', - '- Dashboards work like macOS Spaces: resting the cursor on the very top edge of the window reveals the spaces bar to switch dashboards or add one with +.', - '- The canvas holds cards: agent chats, browsers, built apps, and workflows. Cards have mac-style traffic lights; the green dot goes full screen, hovering it offers halves, quarters, and thirds.', - '- The dark dock on the left creates chats, browsers, and workflows, and opens History, Settings, and Apps.', - '- Cmd+K searches everything. Dictation: hold the mic in the Help pill (or the mic key) to talk; the words land where the cursor is.', - '- Settings (gear in the dock): Account, General (agent defaults), Appearance (theme, accent colors, text size), Privacy, Advanced, plus Models (connect Claude, ChatGPT, or Gemini subscriptions or API keys), Skills, Tools, Commands, and Usage.', - '- Workflows run agents on a schedule; open them from the dock calendar icon.', - 'Behavior rules:', - '- Stay a help chat. Never start unrelated agent work or long tasks from here.', - '- You may read settings with the settings tools to answer questions about their setup; ask before changing anything.', - '- If something sounds like a bug, point them to Help then Report a bug, which packages diagnostics automatically.', - '- If they want real work done, tell them to start a regular chat from the dock or by typing on the canvas.', -].join('\n'); - const REPO_ISSUES_URL = 'https://github.com/openswarm-ai/openswarm/issues/new'; const DOCS_URL = 'https://docs.openswarm.com'; const DISCORD_URL = 'https://discord.com/channels/1486442924391796896/1486442927554170892'; @@ -78,17 +60,25 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => { const [reportText, setReportText] = useState(''); const [files, setFiles] = useState([]); const [sending, setSending] = useState(false); + const [knowledge, setKnowledge] = useState(null); const fileInputRef = useRef(null); + // Fetched on open so the instant answers are ready before the user finishes typing; cached across opens. + useEffect(() => { + let live = true; + void loadHelpKnowledge().then((k) => { if (live) setKnowledge(k); }); + return () => { live = false; }; + }, []); + const startChat = useCallback((prompt: string): void => { const p = prompt.trim(); if (!p) return; const dashboardId = getLastDashboardId() ?? undefined; - const config: AgentConfig = { name: 'Help', model, mode: 'agent', dashboard_id: dashboardId, system_prompt: HELP_SYSTEM_PROMPT }; + const config: AgentConfig = { name: 'Help', model, mode: 'agent', dashboard_id: dashboardId, system_prompt: knowledge?.system_prompt || FALLBACK_HELP_PROMPT }; const draftId = dispatch(createDraftSession({ mode: 'agent', model, dashboardId: dashboardId ?? '', setActive: true })).payload.draftId; void dispatch(launchAndSendFirstMessage({ draftId, config, prompt: p, mode: 'agent', model, expand: true })); onClose(); - }, [dispatch, model, onClose]); + }, [dispatch, model, onClose, knowledge]); const submitReport = useCallback(async (kind: 'bug' | 'idea'): Promise => { if (sending) return; @@ -167,22 +157,7 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => { How can we help? - - - ) => setAsk(e.target.value)} - onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') startChat(ask); e.stopPropagation(); }} - placeholder="Ask OpenSwarm anything..." - sx={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', color: 'rgba(255,255,255,0.92)', fontFamily: 'inherit', fontSize: '0.8125rem', '&::placeholder': { color: 'rgba(255,255,255,0.4)' } }} - /> - {ask.trim() && ( - startChat(ask)} sx={{ display: 'flex', border: 'none', background: 'transparent', color: 'rgba(255,255,255,0.8)', cursor: 'pointer', p: 0 }}> - - - )} - + startChat(ask)} /> { setReportText(''); setFiles([]); setPane('bug'); }} sx={rowSx}> diff --git a/frontend/src/app/pages/Dashboard/desktop/helpKnowledge.ts b/frontend/src/app/pages/Dashboard/desktop/helpKnowledge.ts new file mode 100644 index 00000000..e43295aa --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/helpKnowledge.ts @@ -0,0 +1,43 @@ +/** + * Product knowledge for the Help panel. The backend assembles it from facts that ship with the + * build plus live facts about this install, so it can't drift the way a hardcoded prompt does. + * + * Two consumers, one fetch: the Ask box searches topics locally (instant, no model, works with no + * provider connected) and the help chat gets the assembled system prompt. + */ +import { API_BASE } from '@/shared/config'; +import type { HelpKnowledge } from './helpSearch'; + +// If the backend can't answer, the chat still has to know it is a help chat and still has to refuse +// to guess. Deliberately thin: the real facts live in the build, and claiming them from memory here +// is exactly the staleness this feature exists to kill. +export const FALLBACK_HELP_PROMPT = [ + "You are OpenSwarm's help assistant, a support chat inside the app.", + 'Your product knowledge feed is unavailable right now, so you are working without verified facts', + 'about this build. Do not guess where things are, do not invent buttons, menus, or shortcuts, and', + 'do not claim anything about known bugs.', + 'Say plainly what you cannot verify, then point the user at the Help pill (top right) for Docs and', + 'shortcuts, Talk to the team, or Report a bug. Keep answers to a couple of sentences.', +].join('\n'); + +let cached: HelpKnowledge | null = null; +let inFlight: Promise | null = null; + +export async function loadHelpKnowledge(): Promise { + if (cached) return cached; + if (inFlight) return inFlight; + inFlight = (async () => { + try { + const res = await fetch(`${API_BASE}/help/knowledge`); + if (!res.ok) return null; + cached = (await res.json()) as HelpKnowledge; + return cached; + } catch { + return null; + } finally { + inFlight = null; + } + })(); + return inFlight; +} + diff --git a/frontend/src/app/pages/Dashboard/desktop/helpSearch.test.ts b/frontend/src/app/pages/Dashboard/desktop/helpSearch.test.ts new file mode 100644 index 00000000..ca5c9a8c --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/helpSearch.test.ts @@ -0,0 +1,86 @@ +/** + * Run: node --test frontend/src/app/pages/Dashboard/desktop/helpSearch.test.ts + * + * The offline answer path. Every case below is a ranking bug caught live in the real panel: a + * help answer that is confidently the WRONG surface is worse than no answer, so they stay pinned. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { searchHelp, type HelpKnowledge } from './helpSearch.ts'; + +const KNOWLEDGE: HelpKnowledge = { + system_prompt: 'x', + app_version: '1.6.0', + known_issues: [], + shortcuts: [ + { keys: '⌘L', action: 'Open the new-chat composer' }, + { keys: '⌘⇧D', action: 'Start or stop voice dictation' }, + { keys: '⌘K', action: 'Search everything, across all dashboards' }, + { keys: '⌘F', action: 'Find cards on this canvas; inside a browser card it finds text on the page' }, + { keys: '⌘⇧T', action: 'Reopen the card you just closed' }, + ], + topics: [ + { + id: 'applications', + title: 'Applications, your app library', + where: 'The grid icon at the very bottom of the left dock.', + body: 'Lists every app you have built or imported.', + keywords: ['applications', 'apps', 'library', 'grid'], + }, + { + id: 'apps', + title: 'Building apps', + where: 'Ask any agent chat.', + body: 'Ask an agent for a tool and it calls CreateApp.', + keywords: ['app', 'build', 'create'], + }, + { + id: 'workflows', + title: 'Workflows and scheduled tasks', + where: 'The repeating-calendar icon in the left dock.', + body: 'Runs agent steps on a schedule, for example every weekday at 9am.', + keywords: ['workflow', 'schedule', 'task', 'recurring'], + }, + { + id: 'settings', + title: 'Settings', + where: 'The gear icon in the left dock.', + body: 'Theme, accent color, and text size live under Appearance.', + keywords: ['settings', 'theme', 'appearance'], + }, + ], +}; + +const top = (q: string): string => searchHelp(KNOWLEDGE, q)[0]?.title ?? '(none)'; + +test('"where are my apps" leads with the app library, not the app builder', () => { + assert.equal(top('where are my apps'), 'Applications, your app library'); +}); + +test('a shortcut question leads with the right key', () => { + assert.equal(top('reopen a closed card'), '⌘⇧T'); +}); + +test('stopwords never pull in a shortcut: "the" must not match "Open the new-chat composer"', () => { + assert.equal(top('change the theme'), 'Settings'); + assert.ok(!searchHelp(KNOWLEDGE, 'change the theme').some((m) => m.title === '⌘L')); +}); + +test('partial words never match: "every" must not hit "Search everything"', () => { + assert.equal(top('schedule a task every weekday'), 'Workflows and scheduled tasks'); + assert.ok(!searchHelp(KNOWLEDGE, 'schedule a task every weekday').some((m) => m.title === '⌘K')); +}); + +test('plurals still match their singular keyword', () => { + assert.equal(top('cards'), '⌘F'); +}); + +test('no knowledge, an empty query, or pure stopwords return nothing rather than noise', () => { + assert.deepEqual(searchHelp(null, 'apps'), []); + assert.deepEqual(searchHelp(KNOWLEDGE, ''), []); + assert.deepEqual(searchHelp(KNOWLEDGE, 'how do i'), []); +}); + +test('a question we have no answer for returns nothing, never a bad guess', () => { + assert.deepEqual(searchHelp(KNOWLEDGE, 'kubernetes ingress'), []); +}); diff --git a/frontend/src/app/pages/Dashboard/desktop/helpSearch.ts b/frontend/src/app/pages/Dashboard/desktop/helpSearch.ts new file mode 100644 index 00000000..523770d7 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/helpSearch.ts @@ -0,0 +1,94 @@ +/** + * Ranking for the Help panel's offline answers: pure functions over the knowledge payload, no + * imports, so the app, the tests, and any future surface all score a query identically. + */ + +export interface HelpTopic { + id: string; + title: string; + where: string; + body: string; + keywords: string[]; +} + +export interface HelpKnownIssue { + id: string; + title: string; + status: 'known' | 'mitigated' | 'fixed'; + detail: string; + workaround?: string | null; +} + +export interface HelpShortcut { + keys: string; + action: string; +} + +export interface HelpKnowledge { + system_prompt: string; + topics: HelpTopic[]; + known_issues: HelpKnownIssue[]; + shortcuts: HelpShortcut[]; + app_version: string; +} + +export interface HelpMatch { + id: string; + title: string; + detail: string; +} + +// Words that match everything and so mean nothing; without them "change the theme" hits every entry. +const STOPWORDS = new Set([ + 'the', 'a', 'an', 'and', 'or', 'of', 'in', 'on', 'for', 'to', 'is', 'are', 'was', 'be', 'it', + 'my', 'me', 'i', 'you', 'your', 'do', 'does', 'did', 'how', 'what', 'where', 'when', 'why', + 'can', 'get', 'got', 'with', 'from', 'that', 'this', 'any', 'all', 'use', 'using', +]); + +function stem(word: string): string { + return word.length > 3 && word.endsWith('s') ? word.slice(0, -1) : word; +} + +// Whole-word matching on stems, so "card" still finds "cards" but "every" stops matching "everything". +function wordSet(text: string): Set { + const out = new Set(); + for (const w of text.toLowerCase().split(/[^a-z0-9]+/)) { + if (w) out.add(stem(w)); + } + return out; +} + +function hits(words: Set, terms: string[]): number { + let n = 0; + for (const t of terms) if (words.has(t)) n += 1; + return n; +} + +/** Instant local answers: no model, no network, still works when no provider is connected. */ +export function searchHelp(knowledge: HelpKnowledge | null, query: string, limit = 3): HelpMatch[] { + if (!knowledge || query.trim().length < 2) return []; + const terms = [...wordSet(query)].filter((t) => t.length > 1 && !STOPWORDS.has(t)); + if (terms.length === 0) return []; + + const scored: Array<{ score: number; match: HelpMatch }> = []; + for (const topic of knowledge.topics) { + const score = + hits(wordSet(topic.title), terms) * 6 + + hits(wordSet(topic.keywords.join(' ')), terms) * 4 + + hits(wordSet(`${topic.body} ${topic.where}`), terms); + if (score > 0) { + scored.push({ + score, + match: { id: topic.id, title: topic.title, detail: topic.where ? `${topic.where} ${topic.body}` : topic.body }, + }); + } + } + // Shortcuts rank in the same list rather than jumping the queue, so a key only leads when the + // question is actually about that key. + for (const s of knowledge.shortcuts) { + const score = hits(wordSet(s.action), terms) * 5; + if (score > 0) scored.push({ score, match: { id: `shortcut-${s.keys}`, title: s.keys, detail: s.action } }); + } + + return scored.sort((a, b) => b.score - a.score).slice(0, limit).map((s) => s.match); +}