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
receipts={filtered}
onSelectReceipt={setSelectedReceipt}
isLoading={receiptsLoading}
error={receiptsError}
/>
)}
</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 type { ActionReceipt } from "../types";
import { cn, formatRelativeTime } from "../utils";
import OnboardingCard from "./OnboardingCard";
interface Props {
receipts: ActionReceipt[];
@ -29,30 +30,8 @@ export default function ReceiptList({
onLoadMore,
loadingMore = false,
}: 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) {
return (
<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 <OnboardingCard isLoading={isLoading} error={error} />;
}
return (

View File

@ -2,10 +2,13 @@ import { useMemo, useState } from "react";
import { ChevronDown, CheckCircle, XCircle, AlertTriangle } from "lucide-react";
import type { ActionReceipt } from "../types";
import { cn, formatRelativeTime } from "../utils";
import OnboardingCard from "./OnboardingCard";
interface Props {
receipts: ActionReceipt[];
onSelectReceipt: (r: ActionReceipt) => void;
isLoading: boolean;
error: unknown;
}
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]);
if (groups.length === 0) {
return (
<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 <OnboardingCard isLoading={isLoading} error={error} />;
}
return (

View File

@ -5,6 +5,8 @@ import { createReceiptStore } from "./src/receipt-store.js";
import { registerBoltbotApi } from "./src/api.js";
import { registerDashboardRoutes } from "./src/dashboard-serve.js";
const STATS_TIMEOUT_MS = 5000;
export default {
id: "boltbot",
name: "Boltbot — Audit Dashboard",
@ -18,5 +20,31 @@ export default {
registerBoltbotApi(api, store);
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 };
});
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", () => {
it("plugin has correct id and name", async () => {
const mod = await import("../../index.js");
@ -42,18 +53,16 @@ describe("boltbot plugin integration", () => {
registerProvider: vi.fn((p: any) => registered.providers.push(p)),
on: vi.fn((name: string, handler: any) => registered.hooks.push({ name, handler })),
registerHttpRoute: vi.fn((r: any) => registered.routes.push(r)),
registerCommand: vi.fn(),
};
plugin.register(mockApi as any);
// No provider in audit-only mode
expect(registered.providers).toHaveLength(0);
// after_tool_call hook registered
expect(registered.hooks).toHaveLength(1);
expect(registered.hooks[0].name).toBe("after_tool_call");
// 4 HTTP routes: receipts, receipt, stats, dashboard
expect(registered.routes).toHaveLength(4);
const paths = registered.routes.map((r: any) => r.path);
expect(paths).toContain("/boltbot/receipts");
@ -71,6 +80,7 @@ describe("boltbot plugin integration", () => {
registerProvider: vi.fn(),
on: vi.fn((_name: string, handler: any) => { hookHandler = handler; }),
registerHttpRoute: vi.fn(),
registerCommand: vi.fn(),
};
plugin.register(mockApi as any);
@ -92,6 +102,7 @@ describe("boltbot plugin integration", () => {
registerProvider: vi.fn(),
on: vi.fn((_name: string, handler: any) => { hookHandler = handler; }),
registerHttpRoute: vi.fn(),
registerCommand: vi.fn(),
};
plugin.register(mockApi as any);
@ -102,4 +113,30 @@ describe("boltbot plugin integration", () => {
{ 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 { homedir } from "node:os";
import { join } from "node:path";
import { mkdirSync } from "node:fs";
import type { ActionReceipt, ReceiptStore } from "../receipt-store.js";
export class LocalReceiptStore implements ReceiptStore {
private db: Database.Database;
constructor(dbPath = "boltbot-receipts.db") {
this.db = new Database(dbPath);
static defaultDbPath(): string {
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(`
CREATE TABLE IF NOT EXISTS receipts (
id TEXT PRIMARY KEY,

View File

@ -12,6 +12,7 @@ export function logGatewayStartup(params: {
tlsEnabled?: boolean;
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
isNixMode: boolean;
loadedPluginIds?: string[];
}) {
const { provider: agentProvider, model: agentModel } = resolveConfiguredModelRef({
cfg: params.cfg,
@ -37,4 +38,11 @@ export function logGatewayStartup(params: {
if (params.isNixMode) {
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,
log,
isNixMode,
loadedPluginIds: pluginRegistry.plugins.filter((p) => p.status === "loaded").map((p) => p.id),
});
scheduleGatewayUpdateCheck({ cfg: cfgAtStart, log, isNixMode });
const tailscaleCleanup = await startGatewayTailscaleExposure({

View File

@ -13,7 +13,7 @@ export type NormalizedPluginsConfig = {
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[] => {
if (!Array.isArray(value)) return [];