mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] help: ground the help chat in real product knowledge instead of a hardcoded prompt
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"],
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.",
|
||||
),
|
||||
]
|
||||
@@ -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>",
|
||||
]
|
||||
)
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user