[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
@@ -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);
}