feat(boltbot): zero-friction onboarding — bundled, startup log, /audit, empty state

This commit is contained in:
duy 2026-01-29 17:25:47 -08:00
parent 9e6e431c59
commit be30d2f088
10 changed files with 133 additions and 35 deletions

View File

@ -169,6 +169,8 @@ export default function App() {
<SessionView <SessionView
receipts={filtered} receipts={filtered}
onSelectReceipt={setSelectedReceipt} onSelectReceipt={setSelectedReceipt}
isLoading={receiptsLoading}
error={receiptsError}
/> />
)} )}
</div> </div>

View File

@ -0,0 +1,35 @@
import { CheckCircle, Loader2, XCircle } from "lucide-react";
interface Props {
isLoading: boolean;
error: unknown;
}
export default function OnboardingCard({ isLoading, error }: Props) {
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-16 text-neutral-400" aria-live="polite">
<Loader2 className="w-8 h-8 animate-spin mb-4" aria-hidden="true" />
<p className="text-sm">Connecting to Boltbot...</p>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-16" aria-live="polite">
<XCircle className="w-8 h-8 text-red-400 mb-4" aria-hidden="true" />
<p className="text-sm text-red-400 font-medium mb-1">Connection failed</p>
<p className="text-xs text-neutral-400">Make sure the gateway is running</p>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center py-16" aria-live="polite">
<CheckCircle className="w-8 h-8 text-emerald-400 mb-4" aria-hidden="true" />
<p className="text-sm text-emerald-400 font-medium mb-1">Boltbot is running</p>
<p className="text-xs text-neutral-400">Waiting for agent activity send a message to your agent through any channel</p>
</div>
);
}

View File

@ -1,6 +1,7 @@
import { CheckCircle, XCircle, AlertTriangle } from "lucide-react"; import { CheckCircle, XCircle, AlertTriangle } from "lucide-react";
import type { ActionReceipt } from "../types"; import type { ActionReceipt } from "../types";
import { cn, formatRelativeTime } from "../utils"; import { cn, formatRelativeTime } from "../utils";
import OnboardingCard from "./OnboardingCard";
interface Props { interface Props {
receipts: ActionReceipt[]; receipts: ActionReceipt[];
@ -29,30 +30,8 @@ export default function ReceiptList({
onLoadMore, onLoadMore,
loadingMore = false, loadingMore = false,
}: Props) { }: Props) {
if (error) {
return (
<div className="text-red-400 text-sm p-4">
Failed to load receipts: {error instanceof Error ? error.message : "Unknown error"}
</div>
);
}
if (isLoading && receipts.length === 0) {
return (
<div className="flex flex-col gap-2" aria-live="polite">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-10 animate-pulse bg-neutral-800 rounded-lg" />
))}
</div>
);
}
if (receipts.length === 0) { if (receipts.length === 0) {
return ( return <OnboardingCard isLoading={isLoading} error={error} />;
<div className="text-neutral-400 text-sm text-center py-12">
No actions recorded yet. Receipts appear here when your agent uses tools.
</div>
);
} }
return ( return (

View File

@ -2,10 +2,13 @@ import { useMemo, useState } from "react";
import { ChevronDown, CheckCircle, XCircle, AlertTriangle } from "lucide-react"; import { ChevronDown, CheckCircle, XCircle, AlertTriangle } from "lucide-react";
import type { ActionReceipt } from "../types"; import type { ActionReceipt } from "../types";
import { cn, formatRelativeTime } from "../utils"; import { cn, formatRelativeTime } from "../utils";
import OnboardingCard from "./OnboardingCard";
interface Props { interface Props {
receipts: ActionReceipt[]; receipts: ActionReceipt[];
onSelectReceipt: (r: ActionReceipt) => void; onSelectReceipt: (r: ActionReceipt) => void;
isLoading: boolean;
error: unknown;
} }
const tierBadge: Record<string, string> = { const tierBadge: Record<string, string> = {
@ -156,15 +159,11 @@ function SessionCard({
); );
} }
export default function SessionView({ receipts, onSelectReceipt }: Props) { export default function SessionView({ receipts, onSelectReceipt, isLoading, error }: Props) {
const groups = useMemo(() => groupBySession(receipts), [receipts]); const groups = useMemo(() => groupBySession(receipts), [receipts]);
if (groups.length === 0) { if (groups.length === 0) {
return ( return <OnboardingCard isLoading={isLoading} error={error} />;
<div className="text-neutral-400 text-sm text-center py-12">
No sessions recorded yet. Sessions appear here when your agent processes conversations.
</div>
);
} }
return ( return (

View File

@ -5,6 +5,8 @@ import { createReceiptStore } from "./src/receipt-store.js";
import { registerBoltbotApi } from "./src/api.js"; import { registerBoltbotApi } from "./src/api.js";
import { registerDashboardRoutes } from "./src/dashboard-serve.js"; import { registerDashboardRoutes } from "./src/dashboard-serve.js";
const STATS_TIMEOUT_MS = 5000;
export default { export default {
id: "boltbot", id: "boltbot",
name: "Boltbot — Audit Dashboard", name: "Boltbot — Audit Dashboard",
@ -18,5 +20,31 @@ export default {
registerBoltbotApi(api, store); registerBoltbotApi(api, store);
registerDashboardRoutes(api); registerDashboardRoutes(api);
const dashboardUrl = process.env.BOLTBOT_DASHBOARD_URL || "/boltbot/dashboard/";
api.registerCommand({
name: "audit",
description: "Show audit dashboard stats",
requireAuth: true,
acceptsArgs: false,
handler: async () => {
try {
const stats = await Promise.race([
store.stats(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timeout")), STATS_TIMEOUT_MS),
),
]);
return {
text: `Boltbot Audit Dashboard\n${stats.total} actions · ${stats.anomalyCount} anomalies\n${dashboardUrl}`,
};
} catch {
return {
text: `Boltbot Audit Dashboard\nStats unavailable — check gateway logs\n${dashboardUrl}`,
};
}
},
});
}, },
}; };

View File

@ -20,6 +20,17 @@ vi.mock("better-sqlite3", () => {
return { default: MockDatabase }; return { default: MockDatabase };
}); });
function createMockApi() {
let registeredCommand: any;
const mockApi = {
registerProvider: vi.fn(),
on: vi.fn(),
registerHttpRoute: vi.fn(),
registerCommand: vi.fn((cmd: any) => { registeredCommand = cmd; }),
};
return { mockApi, getCommand: () => registeredCommand };
}
describe("boltbot plugin integration", () => { describe("boltbot plugin integration", () => {
it("plugin has correct id and name", async () => { it("plugin has correct id and name", async () => {
const mod = await import("../../index.js"); const mod = await import("../../index.js");
@ -42,18 +53,16 @@ describe("boltbot plugin integration", () => {
registerProvider: vi.fn((p: any) => registered.providers.push(p)), registerProvider: vi.fn((p: any) => registered.providers.push(p)),
on: vi.fn((name: string, handler: any) => registered.hooks.push({ name, handler })), on: vi.fn((name: string, handler: any) => registered.hooks.push({ name, handler })),
registerHttpRoute: vi.fn((r: any) => registered.routes.push(r)), registerHttpRoute: vi.fn((r: any) => registered.routes.push(r)),
registerCommand: vi.fn(),
}; };
plugin.register(mockApi as any); plugin.register(mockApi as any);
// No provider in audit-only mode
expect(registered.providers).toHaveLength(0); expect(registered.providers).toHaveLength(0);
// after_tool_call hook registered
expect(registered.hooks).toHaveLength(1); expect(registered.hooks).toHaveLength(1);
expect(registered.hooks[0].name).toBe("after_tool_call"); expect(registered.hooks[0].name).toBe("after_tool_call");
// 4 HTTP routes: receipts, receipt, stats, dashboard
expect(registered.routes).toHaveLength(4); expect(registered.routes).toHaveLength(4);
const paths = registered.routes.map((r: any) => r.path); const paths = registered.routes.map((r: any) => r.path);
expect(paths).toContain("/boltbot/receipts"); expect(paths).toContain("/boltbot/receipts");
@ -71,6 +80,7 @@ describe("boltbot plugin integration", () => {
registerProvider: vi.fn(), registerProvider: vi.fn(),
on: vi.fn((_name: string, handler: any) => { hookHandler = handler; }), on: vi.fn((_name: string, handler: any) => { hookHandler = handler; }),
registerHttpRoute: vi.fn(), registerHttpRoute: vi.fn(),
registerCommand: vi.fn(),
}; };
plugin.register(mockApi as any); plugin.register(mockApi as any);
@ -92,6 +102,7 @@ describe("boltbot plugin integration", () => {
registerProvider: vi.fn(), registerProvider: vi.fn(),
on: vi.fn((_name: string, handler: any) => { hookHandler = handler; }), on: vi.fn((_name: string, handler: any) => { hookHandler = handler; }),
registerHttpRoute: vi.fn(), registerHttpRoute: vi.fn(),
registerCommand: vi.fn(),
}; };
plugin.register(mockApi as any); plugin.register(mockApi as any);
@ -102,4 +113,30 @@ describe("boltbot plugin integration", () => {
{ sessionKey: "test-sess", toolName: "web_search" }, { sessionKey: "test-sess", toolName: "web_search" },
); );
}); });
it("registers /audit command", async () => {
const mod = await import("../../index.js");
const plugin = mod.default;
const { mockApi, getCommand } = createMockApi();
plugin.register(mockApi as any);
const cmd = getCommand();
expect(cmd).toBeDefined();
expect(cmd.name).toBe("audit");
expect(cmd.requireAuth).toBe(true);
});
it("/audit command returns stats", async () => {
const mod = await import("../../index.js");
const plugin = mod.default;
const { mockApi, getCommand } = createMockApi();
plugin.register(mockApi as any);
const result = await getCommand().handler({});
expect(result.text).toContain("Boltbot Audit Dashboard");
expect(result.text).toContain("actions");
expect(result.text).toContain("anomalies");
});
}); });

View File

@ -1,11 +1,20 @@
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { homedir } from "node:os";
import { join } from "node:path";
import { mkdirSync } from "node:fs";
import type { ActionReceipt, ReceiptStore } from "../receipt-store.js"; import type { ActionReceipt, ReceiptStore } from "../receipt-store.js";
export class LocalReceiptStore implements ReceiptStore { export class LocalReceiptStore implements ReceiptStore {
private db: Database.Database; private db: Database.Database;
constructor(dbPath = "boltbot-receipts.db") { static defaultDbPath(): string {
this.db = new Database(dbPath); const dir = join(homedir(), ".clawdbot", "data");
mkdirSync(dir, { recursive: true });
return join(dir, "boltbot-receipts.db");
}
constructor(dbPath?: string) {
this.db = new Database(dbPath ?? LocalReceiptStore.defaultDbPath());
this.db.exec(` this.db.exec(`
CREATE TABLE IF NOT EXISTS receipts ( CREATE TABLE IF NOT EXISTS receipts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,

View File

@ -12,6 +12,7 @@ export function logGatewayStartup(params: {
tlsEnabled?: boolean; tlsEnabled?: boolean;
log: { info: (msg: string, meta?: Record<string, unknown>) => void }; log: { info: (msg: string, meta?: Record<string, unknown>) => void };
isNixMode: boolean; isNixMode: boolean;
loadedPluginIds?: string[];
}) { }) {
const { provider: agentProvider, model: agentModel } = resolveConfiguredModelRef({ const { provider: agentProvider, model: agentModel } = resolveConfiguredModelRef({
cfg: params.cfg, cfg: params.cfg,
@ -37,4 +38,11 @@ export function logGatewayStartup(params: {
if (params.isNixMode) { if (params.isNixMode) {
params.log.info("gateway: running in Nix mode (config managed externally)"); params.log.info("gateway: running in Nix mode (config managed externally)");
} }
if (params.loadedPluginIds?.includes("boltbot")) {
const httpScheme = params.tlsEnabled ? "https" : "http";
const dashboardUrl = `${httpScheme}://${formatHost(primaryHost)}:${params.port}/boltbot/dashboard/`;
params.log.info(`boltbot dashboard: ${dashboardUrl}`, {
consoleMessage: `boltbot dashboard: ${chalk.cyan(dashboardUrl)}`,
});
}
} }

View File

@ -482,6 +482,7 @@ export async function startGatewayServer(
tlsEnabled: gatewayTls.enabled, tlsEnabled: gatewayTls.enabled,
log, log,
isNixMode, isNixMode,
loadedPluginIds: pluginRegistry.plugins.filter((p) => p.status === "loaded").map((p) => p.id),
}); });
scheduleGatewayUpdateCheck({ cfg: cfgAtStart, log, isNixMode }); scheduleGatewayUpdateCheck({ cfg: cfgAtStart, log, isNixMode });
const tailscaleCleanup = await startGatewayTailscaleExposure({ const tailscaleCleanup = await startGatewayTailscaleExposure({

View File

@ -13,7 +13,7 @@ export type NormalizedPluginsConfig = {
entries: Record<string, { enabled?: boolean; config?: unknown }>; entries: Record<string, { enabled?: boolean; config?: unknown }>;
}; };
export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>(); export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>(["boltbot"]);
const normalizeList = (value: unknown): string[] => { const normalizeList = (value: unknown): string[] => {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];