[eric] help: full panel (Discord, what's new, attachments) + local diagnostics bundle revealed for the GitHub issue

This commit is contained in:
ciregenz
2026-07-24 13:04:16 -07:00
parent e7084cc085
commit c1edad623c
5 changed files with 299 additions and 32 deletions
+162
View File
@@ -0,0 +1,162 @@
"""Diagnostic bundle for bug reports: one folder a user can drag into a GitHub issue.
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.
"""
import base64
import json
import os
import platform
import re
import sys
import time
from contextlib import asynccontextmanager
from typing import AsyncIterator, List, Optional
from pydantic import BaseModel, ConfigDict, Field
from typeguard import typechecked
from backend.config.Apps import SubApp
from backend.config.paths import DATA_ROOT, SESSIONS_DIR
@asynccontextmanager
async def help_lifespan() -> AsyncIterator[None]:
yield
help_app = SubApp("help", help_lifespan)
DIAG_DIR = os.path.join(DATA_ROOT, "diagnostics")
LOG_TAIL_LINES = 200
MAX_ATTACHMENTS = 6
MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024
# Key-shaped strings never belong in a shareable report, even from free-text log lines.
P_SECRET_RE = re.compile(r"(sk-[A-Za-z0-9\-]{8,}|Bearer\s+\S+|api[_-]?key[\"']?\s*[:=]\s*\S+)", re.IGNORECASE)
class BundleAttachment(BaseModel):
model_config = ConfigDict(validate_assignment=True)
name: str
data_b64: str
class BundleRequest(BaseModel):
model_config = ConfigDict(validate_assignment=True)
kind: str = "bug"
description: str = ""
attachments: List[BundleAttachment] = Field(default_factory=list)
@typechecked
def p_safe_name(name: str) -> str:
base = os.path.basename(name or "attachment")
return re.sub(r"[^A-Za-z0-9._-]", "_", base)[:80] or "attachment"
@typechecked
def p_scrub(text: str) -> str:
return P_SECRET_RE.sub("[redacted]", text)
@typechecked
def p_log_tail() -> str:
"""Last lines of the backend log when packaged (Electron writes backend.log next to the data
root); dev runs log to the terminal, so a missing file just yields an honest note."""
candidates = [
os.path.join(os.path.dirname(DATA_ROOT), "backend.log"),
os.path.join(DATA_ROOT, "backend.log"),
]
for p in candidates:
try:
if os.path.isfile(p):
with open(p, "r", errors="replace") as fh:
lines = fh.readlines()[-LOG_TAIL_LINES:]
return p_scrub("".join(lines))
except Exception:
continue
return "(no backend.log found; dev runs log to the terminal)"
@typechecked
def p_count_dir(path: str) -> int:
try:
return len(os.listdir(path))
except Exception:
return 0
@typechecked
def p_build_report(req: BundleRequest) -> str:
from backend.apps.settings.store import load_settings
s = load_settings()
provider_kinds: List[str] = []
if getattr(s, "anthropic_api_key", None):
provider_kinds.append("anthropic-key")
if getattr(s, "openai_api_key", None):
provider_kinds.append("openai-key")
if getattr(s, "free_trial_token", None):
provider_kinds.append("free-trial")
facts = {
"kind": req.kind,
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"app_version": os.environ.get("OPENSWARM_APP_VERSION", "dev"),
"platform": f"{platform.system()} {platform.release()} ({platform.machine()})",
"python": sys.version.split()[0],
"packaged": os.environ.get("OPENSWARM_PACKAGED") == "1",
"installation_id": getattr(s, "installation_id", None),
"user_email": getattr(s, "user_email", None),
"signin_method": getattr(s, "signin_method", None),
"default_model": getattr(s, "default_model", None),
"connection_mode": getattr(s, "connection_mode", None),
"provider_kinds": provider_kinds,
"session_count": p_count_dir(SESSIONS_DIR),
"theme": getattr(s, "theme", None),
}
lines = [
f"# OpenSwarm {('bug report' if req.kind == 'bug' else 'feature request')}",
"",
"## What the user reported",
req.description.strip() or "(no description)",
"",
"## Environment",
"```json",
json.dumps(facts, indent=2, default=str),
"```",
"",
"## Recent backend log",
"```",
p_log_tail(),
"```",
"",
]
return "\n".join(lines)
@help_app.router.post("/bundle")
@typechecked
async def build_bundle(body: BundleRequest) -> dict:
stamp = time.strftime("%Y%m%d-%H%M%S")
folder = os.path.join(DIAG_DIR, f"report-{stamp}")
os.makedirs(folder, exist_ok=True)
report_path = os.path.join(folder, "diagnostic-report.md")
with open(report_path, "w") as fh:
fh.write(p_build_report(body))
saved: List[str] = []
for att in body.attachments[:MAX_ATTACHMENTS]:
try:
raw = base64.b64decode(att.data_b64)
if len(raw) > MAX_ATTACHMENT_BYTES:
continue
dest = os.path.join(folder, p_safe_name(att.name))
with open(dest, "wb") as fh:
fh.write(raw)
saved.append(os.path.basename(dest))
except Exception:
continue
return {"folder": folder, "report": report_path, "attachments": saved}
+2 -1
View File
@@ -44,6 +44,7 @@ from backend.apps.auth.router import auth
from backend.apps.web.web import web
from backend.apps.onboarding.onboarding import onboarding
from backend.apps.voice.polish import voice
from backend.apps.help.bundle import help_app
from backend.apps.agents.proxy.anthropic_proxy import anthropic_proxy
from backend.apps.agents.core.openai_passthrough import openai_passthrough
from backend.apps.workflows.workflows import workflows
@@ -51,7 +52,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi import WebSocket, WebSocketDisconnect
import json
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, onboarding, voice, anthropic_proxy, workflows, openai_passthrough])
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, onboarding, voice, help_app, anthropic_proxy, workflows, openai_passthrough])
app = main_app.app
# Generate per-install auth token BEFORE we bind the HTTP port. By the time any request lands, the token file exists. See backend/auth.py.
+13
View File
@@ -2974,6 +2974,19 @@ ipcMain.handle('get-webview-preload-path', () => {
return `file://${path.join(__dirname, 'webview-preload.js')}`;
});
// Reveal a diagnostics bundle in the file manager so the user can drag it into a GitHub issue.
// Scoped HARD to the backend's diagnostics dir: this must never become an arbitrary-path opener.
ipcMain.handle('help:reveal-bundle', (event, folderPath) => {
try {
const p = path.resolve(String(folderPath || ''));
if (!p.includes(`${path.sep}diagnostics${path.sep}`) || !fs.existsSync(p)) return { ok: false };
shell.showItemInFolder(p);
return { ok: true };
} catch (_) {
return { ok: false };
}
});
// Wipe ONLY the browser-card partition (cookies/cache/localStorage/IndexedDB), never the app's defaultSession. Surfaced as Settings -> Data & Privacy -> Clear browsing data.
ipcMain.handle('browser:clear-data', async () => {
const ses = session.fromPartition(BROWSER_PARTITION);
+2
View File
@@ -73,6 +73,8 @@ contextBridge.exposeInMainWorld('openswarm', {
ipcRenderer.on('voice:toggle', listener);
return () => ipcRenderer.removeListener('voice:toggle', listener);
},
// Reveal a diagnostics folder in Finder/Explorer (path validated in main; diagnostics dir only).
revealBundle: (folderPath) => ipcRenderer.invoke('help:reveal-bundle', folderPath),
// Main-process hold relay (before-input-event): fires down/up for the combo regardless of DOM focus.
onVoiceHold: (onDown, onUp) => {
const down = () => onDown();
@@ -1,11 +1,14 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, 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';
import ForumOutlinedIcon from '@mui/icons-material/ForumOutlined';
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';
@@ -13,9 +16,17 @@ import ArrowUpwardRoundedIcon from '@mui/icons-material/ArrowUpwardRounded';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { createDraftSession, launchAndSendFirstMessage, type AgentConfig } from '@/shared/state/agentsSlice';
import { getLastDashboardId } from '@/shared/lastDashboardId';
import { API_BASE } from '@/shared/config';
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';
const WHATS_NEW: Array<{ text: string }> = [
{ text: 'Dictation lands where your cursor is, with AI cleanup' },
{ text: 'Sign in keeps your setup tied to your account' },
{ text: 'Text size setting + a cleaner canvas composer' },
];
function openExternal(url: string): void {
const api = (window as unknown as { openswarm?: { openExternal?: (u: string) => void } }).openswarm;
@@ -23,12 +34,21 @@ function openExternal(url: string): void {
else window.open(url, '_blank');
}
async function fileToB64(f: File): Promise<string> {
const buf = await f.arrayBuffer();
let bin = '';
const bytes = new Uint8Array(buf);
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
return btoa(bin);
}
type Pane = 'root' | 'bug' | 'idea';
// The Help panel: Linear/Raycast-style popover off the Help pill. Ask leads (starts a real chat with
// the question), then report-a-bug / request-a-feature (open a PREFILLED GitHub issue: version + OS
// attached in the body, no secret and no backend needed, and the user can watch the thread for team
// replies), then docs. Closes on outside click or Esc via the parent.
// The Help panel: Linear/Raycast-style popover off the Help pill. Ask leads (starts a real chat),
// then report-a-bug / request-a-feature: the local backend assembles a diagnostics bundle (redacted
// env facts + log tail + the user's screenshots) into one folder, we reveal it in the file manager,
// and open a PREFILLED GitHub issue for them to drag the folder's files into. Nothing uploads by
// itself and no secret can ride along (the bundle is allowlisted server-side). Then community + docs.
const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const dispatch = useAppDispatch();
const model = useAppSelector((s) => s.settings.data.default_model);
@@ -36,6 +56,9 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const [pane, setPane] = useState<Pane>('root');
const [ask, setAsk] = useState('');
const [reportText, setReportText] = useState('');
const [files, setFiles] = useState<File[]>([]);
const [sending, setSending] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const startChat = useCallback((prompt: string): void => {
const p = prompt.trim();
@@ -47,22 +70,46 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
onClose();
}, [dispatch, model, onClose]);
const fileOnGitHub = useCallback((kind: 'bug' | 'idea'): void => {
const body = [
reportText.trim(),
'',
'---',
`App version: ${appVersion ?? 'dev'}`,
`Platform: ${navigator.platform}`,
].join('\n');
const params = new URLSearchParams({
title: reportText.trim().split('\n')[0].slice(0, 80) || (kind === 'bug' ? 'Bug report' : 'Feature request'),
labels: kind === 'bug' ? 'bug' : 'enhancement',
body,
});
openExternal(`${REPO_ISSUES_URL}?${params.toString()}`);
onClose();
}, [reportText, appVersion, onClose]);
const submitReport = useCallback(async (kind: 'bug' | 'idea'): Promise<void> => {
if (sending) return;
setSending(true);
try {
// 1. Local diagnostics bundle (report.md + the user's files), revealed for drag-into-the-issue.
const attachments = await Promise.all(files.slice(0, 6).map(async (f) => ({ name: f.name, data_b64: await fileToB64(f) })));
const res = await fetch(`${API_BASE}/help/bundle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind, description: reportText, attachments }),
});
let folder: string | null = null;
if (res.ok) {
const data = (await res.json()) as { folder?: string };
folder = data.folder ?? null;
}
// 2. Prefilled GitHub issue; the body points at the revealed bundle so nothing gets lost.
const bodyLines = [
reportText.trim(),
'',
'---',
`App version: ${appVersion ?? 'dev'}`,
`Platform: ${navigator.platform}`,
folder ? 'Diagnostics: drag the files from the folder OpenSwarm just revealed into this issue.' : '',
].filter(Boolean);
const params = new URLSearchParams({
title: reportText.trim().split('\n')[0].slice(0, 80) || (kind === 'bug' ? 'Bug report' : 'Feature request'),
labels: kind === 'bug' ? 'bug' : 'enhancement',
body: bodyLines.join('\n'),
});
openExternal(`${REPO_ISSUES_URL}?${params.toString()}`);
if (folder) {
const api = (window as unknown as { openswarm?: { revealBundle?: (p: string) => Promise<unknown> } }).openswarm;
void api?.revealBundle?.(folder);
}
onClose();
} finally {
setSending(false);
}
}, [sending, files, reportText, appVersion, onClose]);
const rowSx = {
display: 'flex', alignItems: 'center', gap: 1.25, width: '100%',
@@ -86,7 +133,7 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
exit={{ opacity: 0, y: -6, scale: 0.98 }}
transition={{ type: 'spring', stiffness: 420, damping: 30 }}
style={{
position: 'absolute', top: 'calc(100% + 8px)', right: 0, width: 316, zIndex: 1500,
position: 'absolute', top: 'calc(100% + 8px)', right: 0, width: 324, zIndex: 1500,
background: 'rgba(22,17,26,0.94)',
backdropFilter: 'blur(24px) saturate(150%)', WebkitBackdropFilter: 'blur(24px) saturate(150%)',
border: '1px solid rgba(255,255,255,0.1)', borderRadius: '14px',
@@ -116,15 +163,15 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
</Box>
)}
</Box>
<Box component="button" onClick={() => { setReportText(''); setPane('bug'); }} sx={rowSx}>
<Box component="button" onClick={() => { setReportText(''); setFiles([]); setPane('bug'); }} sx={rowSx}>
<BugReportOutlinedIcon sx={iconSx} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500 }}>Report a bug</Typography>
<Typography sx={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.45)' }}>Version attached automatically</Typography>
<Typography sx={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.45)' }}>Diagnostics packaged automatically</Typography>
</Box>
<ChevronRightRoundedIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.4)' }} />
</Box>
<Box component="button" onClick={() => { setReportText(''); setPane('idea'); }} sx={rowSx}>
<Box component="button" onClick={() => { setReportText(''); setFiles([]); setPane('idea'); }} sx={rowSx}>
<LightbulbOutlinedIcon sx={iconSx} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500 }}>Request a feature</Typography>
@@ -132,11 +179,31 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
</Box>
<ChevronRightRoundedIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.4)' }} />
</Box>
<Box component="button" onClick={() => { openExternal(DISCORD_URL); onClose(); }} sx={rowSx}>
<ForumOutlinedIcon sx={iconSx} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500 }}>Talk to the team</Typography>
<Typography sx={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.45)' }}>Join the Discord</Typography>
</Box>
<ArrowOutwardRoundedIcon sx={{ fontSize: 14, color: 'rgba(255,255,255,0.4)' }} />
</Box>
<Box component="button" onClick={() => { openExternal(DOCS_URL); onClose(); }} sx={rowSx}>
<MenuBookOutlinedIcon sx={iconSx} />
<Typography sx={{ flex: 1, fontSize: '0.8125rem', fontWeight: 500 }}>Docs &amp; shortcuts</Typography>
<ArrowOutwardRoundedIcon sx={{ fontSize: 14, color: 'rgba(255,255,255,0.4)' }} />
</Box>
<Box sx={{ height: '1px', background: 'rgba(255,255,255,0.09)', mx: 1.25, my: 0.75 }} />
<Box sx={{ px: 1.25, pb: 0.75 }}>
<Typography sx={{ fontSize: '0.625rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.4)', mb: 0.5 }}>
What's new{appVersion ? ` · v${appVersion}` : ''}
</Typography>
{WHATS_NEW.map((n) => (
<Box key={n.text} sx={{ display: 'flex', gap: 0.9, alignItems: 'flex-start', my: 0.4 }}>
<Box sx={{ width: 5, height: 5, borderRadius: '50%', background: '#4fdf9f', mt: '6px', flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.65)', lineHeight: 1.45 }}>{n.text}</Typography>
</Box>
))}
</Box>
</Box>
) : (
<Box sx={{ p: 1.5 }}>
@@ -153,22 +220,44 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
placeholder={pane === 'bug' ? 'What went wrong?' : "What's missing?"}
sx={fieldSx}
/>
<Typography sx={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.45)', mt: 0.75 }}>
Opens a prefilled GitHub issue; watch it for replies from the team.
</Typography>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.txt,.log,.json,.md,.pdf"
hidden
onChange={(e) => { if (e.target.files) setFiles((prev) => [...prev, ...Array.from(e.target.files!)].slice(0, 6)); e.target.value = ''; }}
/>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.5, mt: 0.75 }}>
<Box component="button" onClick={() => fileInputRef.current?.click()} sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, border: '1px dashed rgba(255,255,255,0.25)', background: 'transparent', color: 'rgba(255,255,255,0.65)', borderRadius: '8px', px: 1, py: 0.4, fontSize: '0.6875rem', cursor: 'pointer', fontFamily: 'inherit' }}>
<AttachFileRoundedIcon sx={{ fontSize: 12 }} /> Add screenshots or files
</Box>
{files.map((f, i) => (
<Box key={`${f.name}-${i}`} component="button" onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))} title="Remove" sx={{ border: '1px solid rgba(255,255,255,0.16)', background: 'rgba(255,255,255,0.07)', color: 'rgba(255,255,255,0.75)', borderRadius: '8px', px: 0.9, py: 0.4, fontSize: '0.6875rem', cursor: 'pointer', fontFamily: 'inherit', maxWidth: 130, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{f.name}
</Box>
))}
</Box>
{pane === 'bug' && (
<Typography sx={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.45)', mt: 0.75, lineHeight: 1.45 }}>
We'll package a diagnostic report (your email, app version, recent activity, no keys or secrets) with your files, reveal it in Finder, and open a prefilled GitHub issue to drop it into.
</Typography>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1.25 }}>
<Box component="button" onClick={onClose} sx={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,0.55)', fontSize: '0.8125rem', cursor: 'pointer', fontFamily: 'inherit', px: 1, py: 0.5 }}>Cancel</Box>
<Box
component="button"
onClick={() => fileOnGitHub(pane === 'bug' ? 'bug' : 'idea')}
disabled={!reportText.trim()}
onClick={() => { void submitReport(pane === 'bug' ? 'bug' : 'idea'); }}
disabled={!reportText.trim() || sending}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.75,
border: 'none', borderRadius: '9px', px: 1.5, py: 0.6, fontFamily: 'inherit',
fontSize: '0.8125rem', fontWeight: 600, cursor: reportText.trim() ? 'pointer' : 'default',
fontSize: '0.8125rem', fontWeight: 600, cursor: reportText.trim() && !sending ? 'pointer' : 'default',
background: reportText.trim() ? 'rgba(255,255,255,0.92)' : 'rgba(255,255,255,0.15)',
color: reportText.trim() ? '#1c1b19' : 'rgba(255,255,255,0.4)',
}}
>
{sending && <CircularProgress size={12} thickness={6} sx={{ color: '#1c1b19' }} />}
Continue on GitHub
</Box>
</Box>