feat(boltbot): zero-friction onboarding — bundled, startup log, /audit, empty state
This commit is contained in:
parent
9e6e431c59
commit
be30d2f088
@ -169,6 +169,8 @@ export default function App() {
|
||||
<SessionView
|
||||
receipts={filtered}
|
||||
onSelectReceipt={setSelectedReceipt}
|
||||
isLoading={receiptsLoading}
|
||||
error={receiptsError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 [];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user