[eric] help: ground the help chat in real product knowledge instead of a hardcoded prompt

This commit is contained in:
ciregenz
2026-07-30 18:29:15 -07:00
parent 2385a348d5
commit 96451de87e
11 changed files with 998 additions and 44 deletions
+11 -4
View File
@@ -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:
+269
View File
@@ -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"],
),
]
+187
View File
@@ -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,
"",
"<this_install>",
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.",
"</this_install>",
"",
"<surfaces>",
"Verified against this exact build. This is your ground truth for where things are.",
p_topics_block(),
"</surfaces>",
"",
"<shortcuts>",
"This user's real shortcuts, already written for their platform. Quote them exactly.",
shortcut_lines,
"</shortcuts>",
"",
"<known_issues>",
"The complete list of issues shipped with this build. You cannot see live bug reports.",
p_issues_block(),
"</known_issues>",
"",
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,
)
+61
View File
@@ -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.",
),
]
+61
View File
@@ -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(
[
"<rules>",
"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 <shortcuts>. 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.",
"- <known_issues> 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 <this_install> 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.",
"</rules>",
]
)
+110
View File
@@ -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"
@@ -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<HelpAskBoxProps> = ({ value, knowledge, onChange, onAsk }) => {
const matches = useMemo(() => searchHelp(knowledge, value), [knowledge, value]);
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mx: 1, my: 0.75, px: 1.25, py: 0.75, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.12)', borderRadius: '10px' }}>
<AutoAwesomeRoundedIcon sx={{ fontSize: 15, color: 'rgba(255,255,255,0.5)' }} />
<Box
component="input"
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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() && (
<Box component="button" onClick={onAsk} sx={{ display: 'flex', border: 'none', background: 'transparent', color: 'rgba(255,255,255,0.8)', cursor: 'pointer', p: 0 }}>
<ArrowUpwardRoundedIcon sx={{ fontSize: 16 }} />
</Box>
)}
</Box>
{matches.length > 0 && (
<Box sx={{ mx: 1, mb: 0.75, px: 1.25, py: 0.75, background: 'rgba(255,255,255,0.035)', border: '1px solid rgba(255,255,255,0.07)', borderRadius: '10px' }}>
<Typography sx={{ fontSize: '0.625rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.38)', mb: 0.5 }}>
From the docs
</Typography>
{matches.map((m) => (
<Box key={m.id} sx={{ mb: 0.75, '&:last-of-type': { mb: 0 } }}>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: 'rgba(255,255,255,0.88)' }}>{m.title}</Typography>
<Typography sx={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.6)', lineHeight: 1.45 }}>{m.detail}</Typography>
</Box>
))}
<Typography sx={{ fontSize: '0.625rem', color: 'rgba(255,255,255,0.35)', mt: 0.75 }}>
Press Enter to ask the help chat instead.
</Typography>
</Box>
)}
</Box>
);
};
export default HelpAskBox;
@@ -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<File[]>([]);
const [sending, setSending] = useState(false);
const [knowledge, setKnowledge] = useState<HelpKnowledge | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(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<void> => {
if (sending) return;
@@ -167,22 +157,7 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
<Box sx={{ px: 1.25, pt: 0.75, pb: 0.5 }}>
<Typography sx={{ fontSize: '0.875rem', fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}>How can we help?</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mx: 1, my: 0.75, px: 1.25, py: 0.75, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.12)', borderRadius: '10px' }}>
<AutoAwesomeRoundedIcon sx={{ fontSize: 15, color: 'rgba(255,255,255,0.5)' }} />
<Box
component="input"
value={ask}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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() && (
<Box component="button" onClick={() => startChat(ask)} sx={{ display: 'flex', border: 'none', background: 'transparent', color: 'rgba(255,255,255,0.8)', cursor: 'pointer', p: 0 }}>
<ArrowUpwardRoundedIcon sx={{ fontSize: 16 }} />
</Box>
)}
</Box>
<HelpAskBox value={ask} knowledge={knowledge} onChange={setAsk} onAsk={() => startChat(ask)} />
<Box component="button" onClick={() => { setReportText(''); setFiles([]); setPane('bug'); }} sx={rowSx}>
<BugReportOutlinedIcon sx={iconSx} />
<Box sx={{ flex: 1, minWidth: 0 }}>
@@ -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<HelpKnowledge | null> | null = null;
export async function loadHelpKnowledge(): Promise<HelpKnowledge | null> {
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;
}
@@ -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'), []);
});
@@ -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<string> {
const out = new Set<string>();
for (const w of text.toLowerCase().split(/[^a-z0-9]+/)) {
if (w) out.add(stem(w));
}
return out;
}
function hits(words: Set<string>, 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);
}