mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 13:17:40 +02:00
[eric] help: ground the help chat in real product knowledge instead of a hardcoded prompt
This commit is contained in:
@@ -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