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
|
<SessionView
|
||||||
receipts={filtered}
|
receipts={filtered}
|
||||||
onSelectReceipt={setSelectedReceipt}
|
onSelectReceipt={setSelectedReceipt}
|
||||||
|
isLoading={receiptsLoading}
|
||||||
|
error={receiptsError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { 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 (
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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 [];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user