diff --git a/backend/apps/help/bundle.py b/backend/apps/help/bundle.py new file mode 100644 index 00000000..4c3d4747 --- /dev/null +++ b/backend/apps/help/bundle.py @@ -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} diff --git a/backend/main.py b/backend/main.py index e278b203..97367cd1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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. diff --git a/electron/main.js b/electron/main.js index fab4460c..79e809ad 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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); diff --git a/electron/preload.js b/electron/preload.js index 4c1e5916..91a36f7a 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -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(); diff --git a/frontend/src/app/pages/Dashboard/desktop/HelpPanel.tsx b/frontend/src/app/pages/Dashboard/desktop/HelpPanel.tsx index d4b88608..5b2bc3a7 100644 --- a/frontend/src/app/pages/Dashboard/desktop/HelpPanel.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/HelpPanel.tsx @@ -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 { + 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('root'); const [ask, setAsk] = useState(''); const [reportText, setReportText] = useState(''); + const [files, setFiles] = useState([]); + const [sending, setSending] = useState(false); + const fileInputRef = useRef(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 => { + 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 } }).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 }) => { )} - { setReportText(''); setPane('bug'); }} sx={rowSx}> + { setReportText(''); setFiles([]); setPane('bug'); }} sx={rowSx}> Report a bug - Version attached automatically + Diagnostics packaged automatically - { setReportText(''); setPane('idea'); }} sx={rowSx}> + { setReportText(''); setFiles([]); setPane('idea'); }} sx={rowSx}> Request a feature @@ -132,11 +179,31 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => { + { openExternal(DISCORD_URL); onClose(); }} sx={rowSx}> + + + Talk to the team + Join the Discord + + + { openExternal(DOCS_URL); onClose(); }} sx={rowSx}> Docs & shortcuts + + + + What's new{appVersion ? ` ยท v${appVersion}` : ''} + + {WHATS_NEW.map((n) => ( + + + {n.text} + + ))} + ) : ( @@ -153,22 +220,44 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => { placeholder={pane === 'bug' ? 'What went wrong?' : "What's missing?"} sx={fieldSx} /> - - Opens a prefilled GitHub issue; watch it for replies from the team. - + { if (e.target.files) setFiles((prev) => [...prev, ...Array.from(e.target.files!)].slice(0, 6)); e.target.value = ''; }} + /> + + 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' }}> + Add screenshots or files + + {files.map((f, i) => ( + 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} + + ))} + + {pane === 'bug' && ( + + 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. + + )} Cancel 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 && } Continue on GitHub