initial commit

This commit is contained in:
Aadesh Kheria 2026-04-14 23:46:57 -07:00
parent afd84bce0e
commit b0a329f396
22 changed files with 1617 additions and 149 deletions

View File

@ -1,3 +1,4 @@
import { logAiQuery } from "@/lib/ai/ai-query-logger";
import { logMcpCall } from "@/lib/ai/mcp-logger";
import { selectModel } from "@/lib/ai/models";
import { getFullSystemPrompt } from "@/lib/ai/prompts";
@ -10,9 +11,37 @@ import { SmartResponse } from "@/route-handlers/smart-response";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
import { yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { captureError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { Json } from "@stackframe/stack-shared/dist/utils/json";
import { generateText, ModelMessage, stepCountIs, streamText } from "ai";
import type { OpenRouterUsageAccounting } from "@openrouter/ai-sdk-provider";
import { generateText, ModelMessage, stepCountIs, streamText, type StepResult, type ToolSet } from "ai";
type ProviderMetadata = { openrouter?: { usage?: OpenRouterUsageAccounting } };
function extractOpenRouterCost(meta: unknown): number | undefined {
return (meta as ProviderMetadata | null | undefined)?.openrouter?.usage?.cost;
}
function extractCachedTokens(meta: unknown): number | undefined {
return (meta as ProviderMetadata | null | undefined)?.openrouter?.usage?.promptTokensDetails?.cachedTokens;
}
function buildStepsJson(steps: ReadonlyArray<StepResult<ToolSet>>): string {
return JSON.stringify(steps.map((step, i) => ({
step: i,
text: step.text || undefined,
toolCalls: step.toolCalls.map(tc => ({
toolName: tc.toolName,
toolCallId: tc.toolCallId,
args: tc.input,
})),
toolResults: step.toolResults.map(tr => ({
toolName: tr.toolName,
toolCallId: tr.toolCallId,
result: tr.output,
})),
})));
}
export const POST = createSmartRouteHandler({
metadata: {
@ -35,7 +64,6 @@ export const POST = createSmartRouteHandler({
const isAuthenticated = fullReq.auth != null;
const { quality, speed, systemPrompt: systemPromptId, tools: toolNames, messages, projectId } = body;
// Verify user has access to the target project
if (projectId != null) {
if (fullReq.auth?.project.id !== "internal") {
throw new StatusError(StatusError.Forbidden, "You do not have access to this project");
@ -60,31 +88,113 @@ export const POST = createSmartRouteHandler({
const toolsArg = Object.keys(tools).length > 0 ? tools : undefined;
const stepLimit = toolsArg == null ? 1 : isDocsOrSearch ? 50 : 5;
const correlationId = crypto.randomUUID();
const conversationIdForLog = body.mcpCallMetadata
? body.mcpCallMetadata.conversationId ?? crypto.randomUUID()
: undefined;
const commonLogFields = {
correlationId,
mode,
systemPromptId,
quality,
speed,
modelId: String(model.modelId),
isAuthenticated,
projectId: projectId ?? undefined,
userId: fullReq.auth?.user?.id,
requestedToolsJson: JSON.stringify(toolNames),
messagesJson: JSON.stringify(messages),
mcpCorrelationId: body.mcpCallMetadata ? correlationId : undefined,
conversationId: conversationIdForLog,
};
const startedAt = Date.now();
const USER_FACING_ERROR_MESSAGE = "The AI service is temporarily unavailable. Please try again later.";
function logError(err: unknown) {
captureError("ai-query-upstream", err);
runAsynchronouslyAndWaitUntil(logAiQuery({
...commonLogFields,
stepsJson: "[]",
finalText: "",
inputTokens: undefined,
outputTokens: undefined,
cachedInputTokens: undefined,
costUsd: undefined,
stepCount: 0,
durationMs: BigInt(Date.now() - startedAt),
errorMessage: err instanceof Error ? err.message : String(err),
}));
}
const isAnthropic = model.modelId.startsWith("anthropic/");
const systemMessage: ModelMessage = {
role: "system",
content: systemPrompt,
...(isAnthropic && {
providerOptions: { openrouter: { cacheControl: { type: "ephemeral" } } },
}),
};
const cachedMessages: ModelMessage[] = [systemMessage, ...(messages as ModelMessage[])];
const openrouterProviderOptions = {
usage: { include: true },
extraBody: {
stream_options: { include_usage: true },
},
} as const;
if (mode === "stream") {
const result = streamText({
model,
system: systemPrompt,
messages: messages as ModelMessage[],
messages: cachedMessages,
tools: toolsArg,
stopWhen: stepCountIs(stepLimit),
providerOptions: {
openrouter: openrouterProviderOptions,
},
onFinish: ({ text, steps, usage, providerMetadata }) => {
runAsynchronouslyAndWaitUntil(logAiQuery({
...commonLogFields,
stepsJson: buildStepsJson(steps),
finalText: text,
inputTokens: usage.inputTokens ?? undefined,
outputTokens: usage.outputTokens ?? undefined,
cachedInputTokens: extractCachedTokens(providerMetadata),
costUsd: extractOpenRouterCost(providerMetadata),
stepCount: steps.length,
durationMs: BigInt(Date.now() - startedAt),
errorMessage: undefined,
}));
},
onError: ({ error }) => logError(error),
});
return {
statusCode: 200,
bodyType: "response" as const,
body: result.toUIMessageStreamResponse(),
body: result.toUIMessageStreamResponse({
onError: () => USER_FACING_ERROR_MESSAGE,
}),
};
} else {
const startedAt = Date.now();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 120_000);
const result = await generateText({
model,
system: systemPrompt,
messages: messages as ModelMessage[],
tools: toolsArg,
abortSignal: controller.signal,
stopWhen: stepCountIs(stepLimit),
}).finally(() => clearTimeout(timeoutId));
let result: Awaited<ReturnType<typeof generateText>>;
try {
result = await generateText({
model,
messages: cachedMessages,
tools: toolsArg,
abortSignal: controller.signal,
stopWhen: stepCountIs(stepLimit),
providerOptions: {
openrouter: openrouterProviderOptions,
},
}).finally(() => clearTimeout(timeoutId));
} catch (err) {
logError(err);
throw new StatusError(StatusError.BadGateway, USER_FACING_ERROR_MESSAGE);
}
const contentBlocks: Array<
| { type: "text", text: string }
@ -123,10 +233,22 @@ export const POST = createSmartRouteHandler({
});
});
runAsynchronouslyAndWaitUntil(logAiQuery({
...commonLogFields,
stepsJson: buildStepsJson(result.steps),
finalText: result.text,
inputTokens: result.usage.inputTokens ?? undefined,
outputTokens: result.usage.outputTokens ?? undefined,
cachedInputTokens: extractCachedTokens(result.providerMetadata),
costUsd: extractOpenRouterCost(result.providerMetadata),
stepCount: result.steps.length,
durationMs: BigInt(Date.now() - startedAt),
errorMessage: undefined,
}));
let responseConversationId: string | undefined;
if (body.mcpCallMetadata != null) {
const correlationId = crypto.randomUUID();
const conversationId = body.mcpCallMetadata.conversationId ?? crypto.randomUUID();
if (body.mcpCallMetadata != null && conversationIdForLog != null) {
const conversationId = conversationIdForLog;
responseConversationId = conversationId;
const firstUserMessage = messages.find(m => m.role === "user");
const question = typeof firstUserMessage?.content === "string"

View File

@ -0,0 +1,12 @@
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { getConnection } from "./mcp-logger";
import type { LogAiQueryParams } from "./spacetimedb-bindings/types/reducers";
export type AiQueryLogEntry = Omit<LogAiQueryParams, "token">;
export async function logAiQuery(entry: AiQueryLogEntry): Promise<void> {
const conn = await getConnection();
if (!conn) return;
const token = getEnvVariable("STACK_MCP_LOG_TOKEN");
await conn.reducers.logAiQuery({ token, ...entry });
}

View File

@ -23,7 +23,7 @@ export async function getConnection(): Promise<DbConnection | null> {
.onApplied(() => {
resolve(connInstance);
})
.subscribe("SELECT * FROM mcp_call_log");
.subscribe(["SELECT * FROM mcp_call_log", "SELECT * FROM ai_query_log"]);
})
.onConnectError((_: unknown, err: Error) => {
captureError("mcp-logger", err);

View File

@ -0,0 +1,38 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default __t.row({
id: __t.u64().primaryKey(),
correlationId: __t.string().name("correlation_id"),
createdAt: __t.timestamp().name("created_at"),
mode: __t.string(),
systemPromptId: __t.string().name("system_prompt_id"),
quality: __t.string(),
speed: __t.string(),
modelId: __t.string().name("model_id"),
isAuthenticated: __t.bool().name("is_authenticated"),
projectId: __t.option(__t.string()).name("project_id"),
userId: __t.option(__t.string()).name("user_id"),
requestedToolsJson: __t.string().name("requested_tools_json"),
messagesJson: __t.string().name("messages_json"),
stepsJson: __t.string().name("steps_json"),
finalText: __t.string().name("final_text"),
inputTokens: __t.option(__t.u32()).name("input_tokens"),
outputTokens: __t.option(__t.u32()).name("output_tokens"),
cachedInputTokens: __t.option(__t.u32()).name("cached_input_tokens"),
costUsd: __t.option(__t.f64()).name("cost_usd"),
stepCount: __t.u32().name("step_count"),
durationMs: __t.u64().name("duration_ms"),
errorMessage: __t.option(__t.string()).name("error_message"),
mcpCorrelationId: __t.option(__t.string()).name("mcp_correlation_id"),
conversationId: __t.option(__t.string()).name("conversation_id"),
});

View File

@ -36,6 +36,7 @@ import {
// Import all reducer arg schemas
import AddManualQaReducer from "./add_manual_qa_reducer";
import DeleteQaEntryReducer from "./delete_qa_entry_reducer";
import LogAiQueryReducer from "./log_ai_query_reducer";
import LogMcpCallReducer from "./log_mcp_call_reducer";
import MarkHumanReviewedReducer from "./mark_human_reviewed_reducer";
import UpdateHumanCorrectionReducer from "./update_human_correction_reducer";
@ -44,12 +45,24 @@ import UpdateMcpQaReviewReducer from "./update_mcp_qa_review_reducer";
// Import all procedure arg schemas
// Import all table schema definitions
import AiQueryLogRow from "./ai_query_log_table";
import McpCallLogRow from "./mcp_call_log_table";
/** Type-only namespace exports for generated type groups. */
/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */
const tablesSchema = __schema({
aiQueryLog: __table({
name: 'ai_query_log',
indexes: [
{ accessor: 'id', name: 'ai_query_log_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
],
constraints: [
{ name: 'ai_query_log_id_key', constraint: 'unique', columns: ['id'] },
],
}, AiQueryLogRow),
mcpCallLog: __table({
name: 'mcp_call_log',
indexes: [
@ -67,6 +80,7 @@ const tablesSchema = __schema({
const reducersSchema = __reducers(
__reducerSchema("add_manual_qa", AddManualQaReducer),
__reducerSchema("delete_qa_entry", DeleteQaEntryReducer),
__reducerSchema("log_ai_query", LogAiQueryReducer),
__reducerSchema("log_mcp_call", LogMcpCallReducer),
__reducerSchema("mark_human_reviewed", MarkHumanReviewedReducer),
__reducerSchema("update_human_correction", UpdateHumanCorrectionReducer),

View File

@ -0,0 +1,37 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
token: __t.string(),
correlationId: __t.string(),
mode: __t.string(),
systemPromptId: __t.string(),
quality: __t.string(),
speed: __t.string(),
modelId: __t.string(),
isAuthenticated: __t.bool(),
projectId: __t.option(__t.string()),
userId: __t.option(__t.string()),
requestedToolsJson: __t.string(),
messagesJson: __t.string(),
stepsJson: __t.string(),
finalText: __t.string(),
inputTokens: __t.option(__t.u32()),
outputTokens: __t.option(__t.u32()),
cachedInputTokens: __t.option(__t.u32()),
costUsd: __t.option(__t.f64()),
stepCount: __t.u32(),
durationMs: __t.u64(),
errorMessage: __t.option(__t.string()),
mcpCorrelationId: __t.option(__t.string()),
conversationId: __t.option(__t.string()),
};

View File

@ -10,6 +10,34 @@ import {
type Infer as __Infer,
} from "spacetimedb";
export const AiQueryLog = __t.object("AiQueryLog", {
id: __t.u64(),
correlationId: __t.string(),
createdAt: __t.timestamp(),
mode: __t.string(),
systemPromptId: __t.string(),
quality: __t.string(),
speed: __t.string(),
modelId: __t.string(),
isAuthenticated: __t.bool(),
projectId: __t.option(__t.string()),
userId: __t.option(__t.string()),
requestedToolsJson: __t.string(),
messagesJson: __t.string(),
stepsJson: __t.string(),
finalText: __t.string(),
inputTokens: __t.option(__t.u32()),
outputTokens: __t.option(__t.u32()),
cachedInputTokens: __t.option(__t.u32()),
costUsd: __t.option(__t.f64()),
stepCount: __t.u32(),
durationMs: __t.u64(),
errorMessage: __t.option(__t.string()),
mcpCorrelationId: __t.option(__t.string()),
conversationId: __t.option(__t.string()),
});
export type AiQueryLog = __Infer<typeof AiQueryLog>;
export const McpCallLog = __t.object("McpCallLog", {
id: __t.u64(),
correlationId: __t.string(),

View File

@ -8,6 +8,7 @@ import { type Infer as __Infer } from "spacetimedb";
// Import all reducer arg schemas
import AddManualQaReducer from "../add_manual_qa_reducer";
import DeleteQaEntryReducer from "../delete_qa_entry_reducer";
import LogAiQueryReducer from "../log_ai_query_reducer";
import LogMcpCallReducer from "../log_mcp_call_reducer";
import MarkHumanReviewedReducer from "../mark_human_reviewed_reducer";
import UpdateHumanCorrectionReducer from "../update_human_correction_reducer";
@ -15,6 +16,7 @@ import UpdateMcpQaReviewReducer from "../update_mcp_qa_review_reducer";
export type AddManualQaParams = __Infer<typeof AddManualQaReducer>;
export type DeleteQaEntryParams = __Infer<typeof DeleteQaEntryReducer>;
export type LogAiQueryParams = __Infer<typeof LogAiQueryReducer>;
export type LogMcpCallParams = __Infer<typeof LogMcpCallReducer>;
export type MarkHumanReviewedParams = __Infer<typeof MarkHumanReviewedReducer>;
export type UpdateHumanCorrectionParams = __Infer<typeof UpdateHumanCorrectionReducer>;

View File

@ -1,6 +1,16 @@
import { BUNDLED_DASHBOARD_UI_TYPES, BUNDLED_TYPE_DEFINITIONS } from "@/generated/bundled-type-definitions";
import { ALL_APPS_FRONTEND, type AppId, getItemPath, hasNavigationItems } from "@/lib/apps-frontend";
import { buildStackAuthHeaders, type CurrentUser } from "@/lib/api-headers";
import type { CurrentUser } from "@/lib/api-headers";
export type DashboardMessagePart = {
type: "text",
text: string,
providerOptions?: Record<string, unknown>,
};
export type DashboardMessage = {
role: string,
content: string | DashboardMessagePart[],
};
/**
* Builds a formatted list of available dashboard routes based on enabled apps.
@ -17,7 +27,7 @@ export function buildAvailableRoutes(enabledAppIds: AppId[]): string {
routes.push({ path: "/project-settings", label: "Project Settings" });
// Dynamic routes from enabled apps
for (const appId of enabledAppIds) {
for (const appId of [...enabledAppIds].sort()) {
const appFrontend = ALL_APPS_FRONTEND[appId as keyof typeof ALL_APPS_FRONTEND];
if (!hasNavigationItems(appFrontend)) {
continue;
@ -35,74 +45,8 @@ export function buildAvailableRoutes(enabledAppIds: AppId[]): string {
return `\nAVAILABLE DASHBOARD ROUTES (use ONLY these with window.dashboardNavigate):\n${routeList}\nDo NOT use any paths not listed above.`;
}
export async function selectRelevantFiles(
prompt: string,
backendBaseUrl: string,
currentUser?: CurrentUser,
): Promise<string[]> {
const availableFiles = BUNDLED_TYPE_DEFINITIONS.map((f: { path: string }) => f.path);
const systemPromptText = `You are a code assistant helping to generate dashboard code for Stack Auth.
Your task is to select which Stack SDK type definition files you'll need to generate the requested dashboard.
IMPORTANT GUIDELINES:
- DO NOT be conservative in file selection - when in doubt, INCLUDE the file
- If a file might be relevant to the dashboard, SELECT IT
- For user/team dashboards: select users and/or teams files
- For project info: select projects files
- Always select server-app.ts as it contains the main SDK interface
- It's better to include extra files than to miss necessary types
Available files:
${availableFiles.map(f => `- ${f}`).join('\n')}
Respond with ONLY a JSON object: { "selectedFiles": ["file1.ts", "file2.ts"] }
No markdown, no explanation just the JSON.`;
try {
const authHeaders = await buildStackAuthHeaders(currentUser);
const response = await fetch(`${backendBaseUrl}/api/latest/ai/query/generate`, {
method: "POST",
headers: { "content-type": "application/json", ...authHeaders },
body: JSON.stringify({
quality: "dumb",
speed: "fast",
systemPrompt: "command-center-ask-ai",
tools: [],
messages: [{
role: "user",
content: `${systemPromptText}\n\nDashboard request: "${prompt}"\n\nWhich type definition files do you need? When uncertain, err on the side of INCLUDING more files rather than fewer.`,
}],
}),
});
const result = await response.json() as { content?: Array<{ type: string, text?: string }> };
const content = Array.isArray(result.content) ? result.content : [];
const textBlock = content.find((b) => b.type === "text");
const responseText = textBlock?.text;
if (!responseText) {
return availableFiles;
}
const jsonMatch = responseText.match(/\{[\s\S]*"selectedFiles"[\s\S]*\}/);
if (!jsonMatch) {
return availableFiles;
}
const parsed = JSON.parse(jsonMatch[0]) as { selectedFiles?: string[] };
if (!Array.isArray(parsed.selectedFiles) || parsed.selectedFiles.length === 0) {
return availableFiles;
}
const selected = parsed.selectedFiles.filter((f) => availableFiles.includes(f));
return selected;
} catch (e) {
console.log("[selectRelevantFiles] failed, returning all files:", e);
return availableFiles;
}
function getAllTypeDefinitionFiles(): string[] {
return BUNDLED_TYPE_DEFINITIONS.map((f: { path: string }) => f.path);
}
function stripComments(source: string): string {
@ -134,54 +78,56 @@ ${fileContents.join('\n')}
`.trim();
}
function extractUserPromptText(messages: Array<{ role: string, content: unknown }>): string {
const lastUserMessage = [...messages].reverse().find(m => m.role === "user");
if (typeof lastUserMessage?.content === "string") {
return lastUserMessage.content;
}
if (Array.isArray(lastUserMessage?.content)) {
const textPart = (lastUserMessage.content as Array<{ type: string, text?: string }>).find(c => c.type === "text");
return textPart?.text ?? "dashboard";
}
return "dashboard";
}
export async function buildDashboardMessages(
backendBaseUrl: string,
currentUser: CurrentUser | undefined,
messages: Array<{ role: string, content: unknown }>,
export function buildDashboardMessages(
_backendBaseUrl: string,
_currentUser: CurrentUser | undefined,
_messages: Array<{ role: string, content: unknown }>,
currentSource?: string,
enabledAppIds?: AppId[],
): Promise<Array<{ role: string, content: string }>> {
const promptForFileSelection = extractUserPromptText(messages);
const selectedFiles = await selectRelevantFiles(promptForFileSelection, backendBaseUrl, currentUser);
const typeDefinitions = loadSelectedTypeDefinitions(selectedFiles);
): Promise<DashboardMessage[]> {
const typeDefinitions = loadSelectedTypeDefinitions(getAllTypeDefinitionFiles());
const availableRoutes = enabledAppIds ? buildAvailableRoutes(enabledAppIds) : "";
const contextMessages: Array<{ role: string, content: string }> = [];
const cachedText = `Here are the type definitions for the Stack SDK:\n${typeDefinitions}\n\nHere are the dashboard UI component types:\n${BUNDLED_DASHBOARD_UI_TYPES}`;
const contextMessages: DashboardMessage[] = [];
contextMessages.push({
role: "user",
content: [
{
type: "text",
text: cachedText,
providerOptions: {
openrouter: { cacheControl: { type: "ephemeral" } },
anthropic: { cacheControl: { type: "ephemeral" } },
},
},
],
});
contextMessages.push({
role: "assistant",
content: "I have the SDK reference material and UI component types. What dashboard would you like me to create or edit?",
});
const tailParts: string[] = [];
if (availableRoutes) {
tailParts.push(availableRoutes.trimStart());
}
if (currentSource != null && currentSource.length > 0) {
tailParts.push(`Here is the current dashboard source code:\n\`\`\`tsx\n${currentSource}\n\`\`\``);
}
if (tailParts.length > 0) {
contextMessages.push({
role: "user",
content: `Here is the current dashboard source code:\n\`\`\`tsx\n${currentSource}\n\`\`\`\n\nHere are the type definitions:\n${typeDefinitions}\n\nHere are the dashboard UI component types:\n${BUNDLED_DASHBOARD_UI_TYPES}${availableRoutes}`,
content: tailParts.join("\n\n"),
});
contextMessages.push({
role: "assistant",
content: "I understand the current dashboard code, type definitions, available UI components, and available routes. What changes would you like to make?",
});
} else {
contextMessages.push({
role: "user",
content: `Here are the type definitions for the Stack SDK:\n${typeDefinitions}\n\nHere are the dashboard UI component types:\n${BUNDLED_DASHBOARD_UI_TYPES}${availableRoutes}`,
});
contextMessages.push({
role: "assistant",
content: "I have the type definitions, available UI components, and available routes. What dashboard would you like me to create?",
content: "Got it. What changes would you like me to make?",
});
}
return contextMessages;
return Promise.resolve(contextMessages);
}
export { BUNDLED_DASHBOARD_UI_TYPES };

View File

@ -43,7 +43,37 @@ const mcpCallLog = table(
}
);
const spacetimedb = schema({ mcpCallLog });
const aiQueryLog = table(
{ name: 'ai_query_log', public: true },
{
id: t.u64().primaryKey().autoInc(),
correlationId: t.string(),
createdAt: t.timestamp(),
mode: t.string(),
systemPromptId: t.string(),
quality: t.string(),
speed: t.string(),
modelId: t.string(),
isAuthenticated: t.bool(),
projectId: t.string().optional(),
userId: t.string().optional(),
requestedToolsJson: t.string(),
messagesJson: t.string(),
stepsJson: t.string(),
finalText: t.string(),
inputTokens: t.u32().optional(),
outputTokens: t.u32().optional(),
cachedInputTokens: t.u32().optional(),
costUsd: t.f64().optional(),
stepCount: t.u32(),
durationMs: t.u64(),
errorMessage: t.string().optional(),
mcpCorrelationId: t.string().optional(),
conversationId: t.string().optional(),
}
);
const spacetimedb = schema({ mcpCallLog, aiQueryLog });
export default spacetimedb;
export const log_mcp_call = spacetimedb.reducer(
@ -237,4 +267,63 @@ export const delete_qa_entry = spacetimedb.reducer(
}
);
export const log_ai_query = spacetimedb.reducer(
{
token: t.string(),
correlationId: t.string(),
mode: t.string(),
systemPromptId: t.string(),
quality: t.string(),
speed: t.string(),
modelId: t.string(),
isAuthenticated: t.bool(),
projectId: t.string().optional(),
userId: t.string().optional(),
requestedToolsJson: t.string(),
messagesJson: t.string(),
stepsJson: t.string(),
finalText: t.string(),
inputTokens: t.u32().optional(),
outputTokens: t.u32().optional(),
cachedInputTokens: t.u32().optional(),
costUsd: t.f64().optional(),
stepCount: t.u32(),
durationMs: t.u64(),
errorMessage: t.string().optional(),
mcpCorrelationId: t.string().optional(),
conversationId: t.string().optional(),
},
(ctx, args) => {
if (args.token !== EXPECTED_LOG_TOKEN) {
throw new SenderError('Invalid log token');
}
ctx.db.aiQueryLog.insert({
id: 0n,
correlationId: args.correlationId,
createdAt: ctx.timestamp,
mode: args.mode,
systemPromptId: args.systemPromptId,
quality: args.quality,
speed: args.speed,
modelId: args.modelId,
isAuthenticated: args.isAuthenticated,
projectId: args.projectId,
userId: args.userId,
requestedToolsJson: args.requestedToolsJson,
messagesJson: args.messagesJson,
stepsJson: args.stepsJson,
finalText: args.finalText,
inputTokens: args.inputTokens,
outputTokens: args.outputTokens,
cachedInputTokens: args.cachedInputTokens,
costUsd: args.costUsd,
stepCount: args.stepCount,
durationMs: args.durationMs,
errorMessage: args.errorMessage,
mcpCorrelationId: args.mcpCorrelationId,
conversationId: args.conversationId,
} as Parameters<typeof ctx.db.aiQueryLog.insert>[0]);
}
);
export const init = spacetimedb.init(_ctx => {});

View File

@ -6,18 +6,22 @@ import { Analytics } from "../components/Analytics";
import { CallLogDetail } from "../components/CallLogDetail";
import { CallLogList } from "../components/CallLogList";
import { KnowledgeBase } from "../components/KnowledgeBase";
import { useMcpCallLogs } from "../hooks/useSpacetimeDB";
import { Usage } from "../components/Usage";
import { UsageDetail } from "../components/UsageDetail";
import { useAiQueryLogs, useMcpCallLogs } from "../hooks/useSpacetimeDB";
import { makeMcpReviewApi } from "../lib/mcp-review-api";
import type { McpCallLogRow } from "../types";
import type { AiQueryLogRow, McpCallLogRow } from "../types";
type Tab = "calls" | "knowledge" | "analytics";
type Tab = "calls" | "knowledge" | "analytics" | "usage";
export default function App() {
const user = useUser({ or: process.env.NODE_ENV === "development" ? "redirect" : "return-null" });
const [selectedRow, setSelectedRow] = useState<McpCallLogRow | null>(null);
const [selectedUsageRow, setSelectedUsageRow] = useState<AiQueryLogRow | null>(null);
const [showAddQa, setShowAddQa] = useState(false);
const [tab, setTab] = useState<Tab>("calls");
const { rows, connectionState } = useMcpCallLogs();
const { rows: usageRows, connectionState: usageConnectionState } = useAiQueryLogs();
if (!user) {
return (
@ -115,6 +119,18 @@ export default function App() {
>
Analytics
</button>
<button
onClick={() => {
setTab("usage");
setSelectedRow(null);
}}
className={clsx(
"px-3 py-1 text-xs font-medium rounded-md transition-colors",
tab === "usage" ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"
)}
>
Unified AI Endpoint Analytics
</button>
</div>
</div>
<div className="flex items-center gap-3">
@ -193,6 +209,27 @@ export default function App() {
<Analytics rows={rows} />
</main>
)}
{tab === "usage" && (
<div className="flex">
<main className="flex-1 p-6 max-w-6xl mx-auto">
<Usage
rows={usageRows}
connectionState={usageConnectionState}
onSelect={setSelectedUsageRow}
selectedId={selectedUsageRow?.id}
/>
</main>
{selectedUsageRow && (
<aside className="w-[480px] border-l border-gray-200 bg-white overflow-hidden h-[calc(100vh-57px)]">
<UsageDetail
row={usageRows.find(r => r.id === selectedUsageRow.id) ?? selectedUsageRow}
onClose={() => setSelectedUsageRow(null)}
/>
</aside>
)}
</div>
)}
</div>
);
}

View File

@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { formatDistanceToNow, format } from "date-fns";
import type { McpCallLogRow } from "../types";
import { toDate } from "../utils";
@ -12,6 +12,8 @@ type SortField = "time" | "tool" | "steps" | "duration" | "qa" | "status";
type SortDir = "asc" | "desc";
type StatusFilter = "all" | "ok" | "error";
type QaFilter = "all" | "pending" | "pass" | "warn" | "fail" | "error" | "needs-review" | "human-reviewed" | "not-reviewed";
const PAGE_SIZES = [25, 50, 100, 500] as const;
type PageSize = typeof PAGE_SIZES[number];
function getSortValue(row: McpCallLogRow, field: SortField): number | string {
switch (field) {
@ -56,6 +58,8 @@ export function CallLogList({
}, [rows]);
const [toolFilter, setToolFilter] = useState<string>("all");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState<PageSize>(50);
const filteredAndSorted = useMemo(() => {
let result = rows;
@ -115,6 +119,14 @@ export function CallLogList({
return result;
}, [rows, textFilter, toolFilter, statusFilter, qaFilter, sortField, sortDir]);
const pageCount = Math.max(1, Math.ceil(filteredAndSorted.length / pageSize));
const currentPage = Math.min(page, pageCount - 1);
const pageRows = filteredAndSorted.slice(currentPage * pageSize, (currentPage + 1) * pageSize);
useEffect(() => {
setPage(0);
}, [textFilter, toolFilter, statusFilter, qaFilter, sortField, sortDir, pageSize]);
if (connectionState === "connecting") {
return <div className="text-gray-500 text-sm p-4">Connecting to SpacetimeDB...</div>;
}
@ -241,7 +253,7 @@ export function CallLogList({
</tr>
</thead>
<tbody>
{filteredAndSorted.map((row) => (
{pageRows.map((row) => (
<tr
key={String(row.id)}
onClick={() => onSelect(row)}
@ -307,6 +319,45 @@ export function CallLogList({
))}
</tbody>
</table>
<div className="flex items-center justify-between px-4 py-2 border-t border-gray-200 text-xs text-gray-600 bg-gray-50">
<div className="flex items-center gap-2">
<span className="text-[10px] text-gray-400 uppercase tracking-wider">Page size</span>
{PAGE_SIZES.map(s => (
<button
key={s}
onClick={() => setPageSize(s)}
className={clsx(
"px-2 py-0.5 text-xs rounded",
pageSize === s ? "bg-blue-600 text-white" : "bg-white border border-gray-200 text-gray-600 hover:bg-gray-100"
)}
>
{s}
</button>
))}
</div>
<div className="flex items-center gap-2">
<span className="text-gray-500">
{filteredAndSorted.length === 0
? "No results"
: `${currentPage * pageSize + 1}${Math.min((currentPage + 1) * pageSize, filteredAndSorted.length)} of ${filteredAndSorted.length}`}
</span>
<button
onClick={() => setPage(Math.max(0, currentPage - 1))}
disabled={currentPage === 0}
className="px-2 py-0.5 text-xs rounded bg-white border border-gray-200 text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:hover:bg-white"
>
Prev
</button>
<span className="text-gray-500 font-mono">{currentPage + 1} / {pageCount}</span>
<button
onClick={() => setPage(Math.min(pageCount - 1, currentPage + 1))}
disabled={currentPage >= pageCount - 1}
className="px-2 py-0.5 text-xs rounded bg-white border border-gray-200 text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:hover:bg-white"
>
Next
</button>
</div>
</div>
</div>
)}
</div>

View File

@ -40,7 +40,7 @@ function CopyButton({ text }: { text: string }) {
);
}
function ToolCallCard({ call, accent = "purple" }: { call: { toolName: string; args: unknown; result: unknown }; accent?: "purple" | "indigo" }) {
export function ToolCallCard({ call, accent = "purple" }: { call: { toolName: string; args: unknown; result: unknown }; accent?: "purple" | "indigo" }) {
const [expanded, setExpanded] = useState(false);
const colors = accent === "indigo"
? { dot: "text-indigo-500", name: "text-indigo-700", bg: "bg-indigo-50", ring: "ring-indigo-200", hover: "hover:bg-indigo-100" }
@ -82,7 +82,7 @@ function ToolCallCard({ call, accent = "purple" }: { call: { toolName: string; a
);
}
function UserBubble({ text }: { text: string }) {
export function UserBubble({ text }: { text: string }) {
return (
<div className="flex gap-2.5 justify-end">
<div className="rounded-xl px-3.5 py-2 max-w-[80%] bg-blue-50 text-gray-900">
@ -95,7 +95,7 @@ function UserBubble({ text }: { text: string }) {
);
}
function AssistantBubble({ content, toolCalls }: { content: string; toolCalls: ToolCall[] }) {
export function AssistantBubble({ content, toolCalls }: { content: string; toolCalls: ToolCall[] }) {
return (
<div className="flex gap-2.5 justify-start">
<div className="shrink-0 w-6 h-6 mt-0.5 rounded-full bg-purple-100 flex items-center justify-center">

View File

@ -0,0 +1,720 @@
import { useEffect, useMemo, useState } from "react";
import { clsx } from "clsx";
import type { AiQueryLogRow } from "../types";
import { toDate } from "../utils";
type TimeRange = "24h" | "7d" | "30d" | "all";
type AuthFilter = "all" | "authed" | "anon";
type ModeFilter = "all" | "stream" | "generate";
type StatusFilter = "all" | "ok" | "error";
type SortKey = "createdAt" | "systemPromptId" | "modelId" | "mode" | "inputTokens" | "outputTokens" | "cachedInputTokens" | "costUsd" | "durationMs" | "status";
type SortDir = "asc" | "desc";
const PAGE_SIZES = [25, 50, 100, 500] as const;
type PageSize = typeof PAGE_SIZES[number];
type Props = {
rows: AiQueryLogRow[],
connectionState: "connecting" | "connected" | "disconnected" | "error",
onSelect: (row: AiQueryLogRow) => void,
selectedId?: bigint,
};
const ALL_SYSTEM_PROMPTS = [
"command-center-ask-ai",
"docs-ask-ai",
"wysiwyg-edit",
"email-wysiwyg-editor",
"email-assistant-template",
"email-assistant-theme",
"email-assistant-draft",
"create-dashboard",
"run-query",
"rewrite-template-source",
];
export function Usage({ rows, connectionState, onSelect, selectedId }: Props) {
const [timeRange, setTimeRange] = useState<TimeRange>("7d");
const [systemPromptFilter, setSystemPromptFilter] = useState<Set<string>>(new Set());
const [modelFilter, setModelFilter] = useState<Set<string>>(new Set());
const [modeFilter, setModeFilter] = useState<ModeFilter>("all");
const [authFilter, setAuthFilter] = useState<AuthFilter>("all");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [search, setSearch] = useState("");
const [sortKey, setSortKey] = useState<SortKey>("createdAt");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState<PageSize>(50);
const now = Date.now();
const rangeStart = useMemo(() => {
switch (timeRange) {
case "24h": {
return now - 24 * 60 * 60 * 1000;
}
case "7d": {
return now - 7 * 24 * 60 * 60 * 1000;
}
case "30d": {
return now - 30 * 24 * 60 * 60 * 1000;
}
case "all": {
return 0;
}
}
}, [timeRange, now]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return rows.filter(r => {
const ts = toDate(r.createdAt).getTime();
if (ts < rangeStart) return false;
if (systemPromptFilter.size > 0 && !systemPromptFilter.has(r.systemPromptId)) return false;
if (modelFilter.size > 0 && !modelFilter.has(r.modelId)) return false;
if (modeFilter !== "all" && r.mode !== modeFilter) return false;
if (authFilter === "authed" && !r.isAuthenticated) return false;
if (authFilter === "anon" && r.isAuthenticated) return false;
const isError = r.errorMessage != null && r.errorMessage !== "";
if (statusFilter === "ok" && isError) return false;
if (statusFilter === "error" && !isError) return false;
if (q) {
const hay = `${r.finalText} ${r.messagesJson}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}, [rows, rangeStart, systemPromptFilter, modelFilter, modeFilter, authFilter, statusFilter, search]);
const sorted = useMemo(() => {
const copy = [...filtered];
const mult = sortDir === "asc" ? 1 : -1;
copy.sort((a, b) => {
let av: number | string = 0;
let bv: number | string = 0;
switch (sortKey) {
case "createdAt": {
av = toDate(a.createdAt).getTime();
bv = toDate(b.createdAt).getTime();
break;
}
case "systemPromptId": {
av = a.systemPromptId;
bv = b.systemPromptId;
break;
}
case "modelId": {
av = a.modelId;
bv = b.modelId;
break;
}
case "mode": {
av = a.mode;
bv = b.mode;
break;
}
case "inputTokens": {
av = a.inputTokens ?? -1;
bv = b.inputTokens ?? -1;
break;
}
case "outputTokens": {
av = a.outputTokens ?? -1;
bv = b.outputTokens ?? -1;
break;
}
case "cachedInputTokens": {
av = a.cachedInputTokens ?? -1;
bv = b.cachedInputTokens ?? -1;
break;
}
case "costUsd": {
av = a.costUsd ?? -1;
bv = b.costUsd ?? -1;
break;
}
case "durationMs": {
av = Number(a.durationMs);
bv = Number(b.durationMs);
break;
}
case "status": {
av = (a.errorMessage != null && a.errorMessage !== "") ? 1 : 0;
bv = (b.errorMessage != null && b.errorMessage !== "") ? 1 : 0;
break;
}
}
if (av < bv) return -1 * mult;
if (av > bv) return 1 * mult;
return 0;
});
return copy;
}, [filtered, sortKey, sortDir]);
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
const currentPage = Math.min(page, pageCount - 1);
const pageRows = sorted.slice(currentPage * pageSize, (currentPage + 1) * pageSize);
function toggleSort(key: SortKey) {
if (sortKey === key) {
setSortDir(sortDir === "asc" ? "desc" : "asc");
} else {
setSortKey(key);
setSortDir(key === "createdAt" ? "desc" : "asc");
}
setPage(0);
}
useEffect(() => {
setPage(0);
}, [timeRange, systemPromptFilter, modelFilter, modeFilter, authFilter, statusFilter, search, pageSize]);
const stats = useMemo(() => {
const totalCalls = filtered.length;
const errorCalls = filtered.filter(r => r.errorMessage != null && r.errorMessage !== "").length;
const inputTokens = filtered.reduce((a, r) => a + (r.inputTokens ?? 0), 0);
const outputTokens = filtered.reduce((a, r) => a + (r.outputTokens ?? 0), 0);
const cachedInputTokens = filtered.reduce((a, r) => a + (r.cachedInputTokens ?? 0), 0);
const totalCost = filtered.reduce((a, r) => a + (r.costUsd ?? 0), 0);
const durations = filtered.map(r => Number(r.durationMs)).filter(d => d > 0).sort((a, b) => a - b);
const avgDuration = durations.length > 0 ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0;
const p95Duration = durations.length > 0 ? durations[Math.min(Math.floor(durations.length * 0.95), durations.length - 1)] : 0;
// Time-bucketed series
const spanMs = now - rangeStart;
const bucketMs = spanMs <= 24 * 60 * 60 * 1000 ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000;
const bucketLabelFmt: Intl.DateTimeFormatOptions = bucketMs === 60 * 60 * 1000
? { hour: "numeric" }
: { month: "short", day: "numeric" };
const bucketCount = Math.min(48, Math.max(1, Math.ceil(spanMs / bucketMs)));
const bucketStart = now - bucketCount * bucketMs;
const timeBuckets: Array<{ label: string, start: number, calls: number, inputTokens: number, outputTokens: number, cachedInputTokens: number }> = [];
for (let i = 0; i < bucketCount; i++) {
const start = bucketStart + i * bucketMs;
timeBuckets.push({
label: new Date(start).toLocaleString("en-US", bucketLabelFmt),
start,
calls: 0,
inputTokens: 0,
outputTokens: 0,
cachedInputTokens: 0,
});
}
for (const r of filtered) {
const ts = toDate(r.createdAt).getTime();
const idx = Math.floor((ts - bucketStart) / bucketMs);
if (idx >= 0 && idx < bucketCount) {
timeBuckets[idx].calls++;
timeBuckets[idx].inputTokens += r.inputTokens ?? 0;
timeBuckets[idx].outputTokens += r.outputTokens ?? 0;
timeBuckets[idx].cachedInputTokens += r.cachedInputTokens ?? 0;
}
}
const maxCalls = Math.max(...timeBuckets.map(b => b.calls), 1);
const maxTokenTotal = Math.max(...timeBuckets.map(b => b.inputTokens + b.outputTokens), 1);
const maxInputTokens = Math.max(...timeBuckets.map(b => b.inputTokens), 1);
// Distributions
const sysPromptCounts = new Map<string, number>();
const modelCounts = new Map<string, number>();
const toolCounts = new Map<string, number>();
for (const r of filtered) {
sysPromptCounts.set(r.systemPromptId, (sysPromptCounts.get(r.systemPromptId) ?? 0) + 1);
modelCounts.set(r.modelId, (modelCounts.get(r.modelId) ?? 0) + 1);
try {
const tools = JSON.parse(r.requestedToolsJson) as string[];
for (const t of tools) toolCounts.set(t, (toolCounts.get(t) ?? 0) + 1);
} catch { /* skip */ }
}
const sysPromptDist = Array.from(sysPromptCounts.entries()).sort((a, b) => b[1] - a[1]);
const modelDist = Array.from(modelCounts.entries()).sort((a, b) => b[1] - a[1]);
const toolDist = Array.from(toolCounts.entries()).sort((a, b) => b[1] - a[1]);
// Cache Hit % per systemPromptId
const cacheBySystemPrompt = new Map<string, { input: number, cached: number, calls: number }>();
for (const r of filtered) {
const existing = cacheBySystemPrompt.get(r.systemPromptId) ?? { input: 0, cached: 0, calls: 0 };
existing.input += r.inputTokens ?? 0;
existing.cached += r.cachedInputTokens ?? 0;
existing.calls += 1;
cacheBySystemPrompt.set(r.systemPromptId, existing);
}
const cacheHitBySystemPrompt = Array.from(cacheBySystemPrompt.entries())
.map(([id, v]) => ({
id,
calls: v.calls,
hitPct: v.input > 0 ? Math.round((v.cached / v.input) * 100) : 0,
cached: v.cached,
input: v.input,
}))
.sort((a, b) => b.input - a.input);
// Latency histogram
const latencyBuckets = [
{ label: "<500ms", max: 500, count: 0 },
{ label: "500ms2s", max: 2000, count: 0 },
{ label: "210s", max: 10000, count: 0 },
{ label: "1030s", max: 30000, count: 0 },
{ label: ">30s", max: Infinity, count: 0 },
];
for (const d of durations) {
const b = latencyBuckets.find(b => d < b.max);
if (b) b.count++;
}
const maxLatencyBucket = Math.max(...latencyBuckets.map(b => b.count), 1);
return {
totalCalls, errorCalls, inputTokens, outputTokens, cachedInputTokens, totalCost,
avgDuration, p95Duration,
timeBuckets, maxCalls, maxTokenTotal, maxInputTokens,
sysPromptDist, modelDist, toolDist,
cacheHitBySystemPrompt,
latencyBuckets, maxLatencyBucket,
};
}, [filtered, rangeStart, now]);
const allSystemPrompts = useMemo(() => {
const seen = new Set<string>(ALL_SYSTEM_PROMPTS);
for (const r of rows) seen.add(r.systemPromptId);
return Array.from(seen).sort();
}, [rows]);
const allModels = useMemo(() => {
const seen = new Set<string>();
for (const r of rows) seen.add(r.modelId);
return Array.from(seen).sort();
}, [rows]);
function toggle(set: Set<string>, val: string, setter: (s: Set<string>) => void) {
const next = new Set(set);
if (next.has(val)) next.delete(val);
else next.add(val);
setter(next);
}
return (
<div className="space-y-4">
{/* Filter bar */}
<div className="bg-white border border-gray-200 rounded-lg p-3 space-y-2 sticky top-0 z-10">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] uppercase text-gray-400 font-medium tracking-wider">Range</span>
{(["24h", "7d", "30d", "all"] as TimeRange[]).map(r => (
<button
key={r}
onClick={() => setTimeRange(r)}
className={clsx(
"px-2 py-0.5 text-xs rounded",
timeRange === r ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
{r}
</button>
))}
<span className="mx-2 w-px h-4 bg-gray-200" />
<span className="text-[10px] uppercase text-gray-400 font-medium tracking-wider">Mode</span>
{(["all", "stream", "generate"] as ModeFilter[]).map(m => (
<button
key={m}
onClick={() => setModeFilter(m)}
className={clsx(
"px-2 py-0.5 text-xs rounded",
modeFilter === m ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
{m}
</button>
))}
<span className="mx-2 w-px h-4 bg-gray-200" />
<span className="text-[10px] uppercase text-gray-400 font-medium tracking-wider">Auth</span>
{(["all", "authed", "anon"] as AuthFilter[]).map(a => (
<button
key={a}
onClick={() => setAuthFilter(a)}
className={clsx(
"px-2 py-0.5 text-xs rounded",
authFilter === a ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
{a}
</button>
))}
<span className="mx-2 w-px h-4 bg-gray-200" />
<span className="text-[10px] uppercase text-gray-400 font-medium tracking-wider">Status</span>
{(["all", "ok", "error"] as StatusFilter[]).map(s => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={clsx(
"px-2 py-0.5 text-xs rounded",
statusFilter === s ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
{s}
</button>
))}
<span className="mx-2 w-px h-4 bg-gray-200" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search messages / response"
className="px-2 py-1 text-xs border border-gray-200 rounded w-64"
/>
<span className="ml-auto text-[10px] text-gray-400">
{connectionState === "connected" ? `${filtered.length} / ${rows.length} calls` : connectionState}
</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] uppercase text-gray-400 font-medium tracking-wider">System prompt</span>
{allSystemPrompts.map(sp => (
<button
key={sp}
onClick={() => toggle(systemPromptFilter, sp, setSystemPromptFilter)}
className={clsx(
"px-2 py-0.5 text-[11px] rounded font-mono",
systemPromptFilter.has(sp) ? "bg-purple-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
{sp}
</button>
))}
</div>
{allModels.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] uppercase text-gray-400 font-medium tracking-wider">Model</span>
{allModels.map(m => (
<button
key={m}
onClick={() => toggle(modelFilter, m, setModelFilter)}
className={clsx(
"px-2 py-0.5 text-[11px] rounded font-mono",
modelFilter.has(m) ? "bg-indigo-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
{m}
</button>
))}
</div>
)}
</div>
{/* Metric cards */}
<div className="grid grid-cols-8 gap-3">
<MetricCard label="Total Calls" value={stats.totalCalls.toLocaleString()} />
<MetricCard label="Errors" value={stats.errorCalls.toLocaleString()} valueClass={stats.errorCalls > 0 ? "text-red-600" : undefined} />
<MetricCard label="Input Tokens" value={stats.inputTokens.toLocaleString()} />
<MetricCard label="Output Tokens" value={stats.outputTokens.toLocaleString()} />
<MetricCard
label="Cache Hit %"
value={stats.inputTokens > 0 ? `${Math.round((stats.cachedInputTokens / stats.inputTokens) * 100)}%` : "—"}
valueClass={stats.inputTokens > 0 && stats.cachedInputTokens / stats.inputTokens > 0.5 ? "text-green-600" : undefined}
/>
<MetricCard label="Total Cost" value={formatUsd(stats.totalCost)} />
<MetricCard label="Avg Duration" value={`${stats.avgDuration.toLocaleString()}ms`} />
<MetricCard label="p95 Duration" value={`${stats.p95Duration.toLocaleString()}ms`} />
</div>
{/* Time-series charts */}
<div className="grid grid-cols-2 gap-3">
<Card title="Calls Over Time">
<div className="flex items-end gap-0.5 h-32">
{stats.timeBuckets.map((b, i) => (
<div key={i} className="flex-1 flex flex-col items-center" title={`${b.label}: ${b.calls}`}>
<div className="w-full flex-1 flex items-end">
<div className="w-full bg-blue-400 rounded-t" style={{ height: `${(b.calls / stats.maxCalls) * 100}%` }} />
</div>
</div>
))}
</div>
<div className="flex justify-between text-[9px] text-gray-400 mt-1">
<span>{stats.timeBuckets[0]?.label}</span>
<span>{stats.timeBuckets[stats.timeBuckets.length - 1]?.label}</span>
</div>
</Card>
<Card title="Token Volume (input + output)">
<div className="flex items-end gap-0.5 h-32">
{stats.timeBuckets.map((b, i) => {
const total = b.inputTokens + b.outputTokens;
const outPct = total > 0 ? (b.outputTokens / total) * 100 : 0;
return (
<div
key={i}
className="flex-1 flex flex-col items-stretch justify-end"
title={`${b.label}: in ${b.inputTokens} / out ${b.outputTokens}`}
style={{ height: `${(total / stats.maxTokenTotal) * 100}%` }}
>
<div className="bg-emerald-400" style={{ height: `${outPct}%` }} />
<div className="bg-cyan-400 rounded-b" style={{ height: `${100 - outPct}%` }} />
</div>
);
})}
</div>
<div className="flex gap-3 text-[9px] text-gray-400 mt-1">
<span><span className="inline-block w-2 h-2 bg-cyan-400 mr-1 align-middle" />input</span>
<span><span className="inline-block w-2 h-2 bg-emerald-400 mr-1 align-middle" />output</span>
</div>
</Card>
<Card title="Cached vs Fresh Input Tokens">
<div className="flex items-end gap-0.5 h-32">
{stats.timeBuckets.map((b, i) => {
const cachedPct = b.inputTokens > 0 ? (b.cachedInputTokens / b.inputTokens) * 100 : 0;
return (
<div
key={i}
className="flex-1 flex flex-col items-stretch justify-end"
title={`${b.label}: ${b.cachedInputTokens.toLocaleString()} cached / ${b.inputTokens.toLocaleString()} total`}
style={{ height: `${(b.inputTokens / stats.maxInputTokens) * 100}%` }}
>
<div className="bg-gray-300" style={{ height: `${100 - cachedPct}%` }} />
<div className="bg-green-400 rounded-b" style={{ height: `${cachedPct}%` }} />
</div>
);
})}
</div>
<div className="flex gap-3 text-[9px] text-gray-400 mt-1">
<span><span className="inline-block w-2 h-2 bg-gray-300 mr-1 align-middle" />fresh</span>
<span><span className="inline-block w-2 h-2 bg-green-400 mr-1 align-middle" />cached</span>
</div>
</Card>
<Card title="Cache Hit % by System Prompt">
{stats.cacheHitBySystemPrompt.length === 0 ? (
<p className="text-sm text-gray-400">No data</p>
) : (
<div className="space-y-1.5">
{stats.cacheHitBySystemPrompt.map(entry => (
<div key={entry.id} className="flex items-center gap-2">
<span className="text-[11px] text-gray-600 font-mono w-40 truncate" title={entry.id}>{entry.id}</span>
<div className="flex-1 h-4 bg-gray-100 rounded overflow-hidden">
<div
className={clsx(
"h-full rounded",
entry.hitPct >= 50 ? "bg-green-500" : entry.hitPct >= 20 ? "bg-yellow-400" : "bg-red-400"
)}
style={{ width: `${entry.hitPct}%` }}
/>
</div>
<span className="text-[11px] text-gray-600 w-10 text-right font-mono">{entry.hitPct}%</span>
<span className="text-[10px] text-gray-400 w-12 text-right font-mono">{entry.calls} calls</span>
</div>
))}
</div>
)}
</Card>
<Card title="By System Prompt">
<DistributionBars items={stats.sysPromptDist} color="bg-purple-400" />
</Card>
<Card title="By Model">
<DistributionBars items={stats.modelDist} color="bg-indigo-400" />
</Card>
<Card title="Tool Usage (from request)">
<DistributionBars items={stats.toolDist} color="bg-orange-400" />
</Card>
<Card title="Latency Distribution">
<div className="space-y-2">
{stats.latencyBuckets.map(b => (
<div key={b.label} className="flex items-center gap-2">
<span className="text-xs text-gray-500 w-20">{b.label}</span>
<div className="flex-1 h-5 bg-gray-100 rounded overflow-hidden">
<div className="h-full bg-pink-400 rounded" style={{ width: `${(b.count / stats.maxLatencyBucket) * 100}%` }} />
</div>
<span className="text-xs text-gray-600 w-8 text-right">{b.count}</span>
</div>
))}
</div>
</Card>
</div>
{/* Call list */}
<Card title={`Calls (${filtered.length})`}>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="text-[10px] uppercase text-gray-400 font-medium tracking-wider">
<tr className="border-b border-gray-200">
<SortHeader align="left" active={sortKey === "createdAt"} dir={sortDir} onClick={() => toggleSort("createdAt")}>Time</SortHeader>
<SortHeader align="left" active={sortKey === "systemPromptId"} dir={sortDir} onClick={() => toggleSort("systemPromptId")}>System Prompt</SortHeader>
<SortHeader align="left" active={sortKey === "modelId"} dir={sortDir} onClick={() => toggleSort("modelId")}>Model</SortHeader>
<SortHeader align="left" active={sortKey === "mode"} dir={sortDir} onClick={() => toggleSort("mode")}>Mode</SortHeader>
<SortHeader align="right" active={sortKey === "inputTokens"} dir={sortDir} onClick={() => toggleSort("inputTokens")}>In tok</SortHeader>
<SortHeader align="right" active={sortKey === "outputTokens"} dir={sortDir} onClick={() => toggleSort("outputTokens")}>Out tok</SortHeader>
<SortHeader align="right" active={sortKey === "cachedInputTokens"} dir={sortDir} onClick={() => toggleSort("cachedInputTokens")}>Cached</SortHeader>
<SortHeader align="right" active={sortKey === "costUsd"} dir={sortDir} onClick={() => toggleSort("costUsd")}>Cost</SortHeader>
<SortHeader align="right" active={sortKey === "durationMs"} dir={sortDir} onClick={() => toggleSort("durationMs")}>Duration</SortHeader>
<SortHeader align="left" active={sortKey === "status"} dir={sortDir} onClick={() => toggleSort("status")}>Status</SortHeader>
</tr>
</thead>
<tbody>
{pageRows.map(row => {
const isError = row.errorMessage != null && row.errorMessage !== "";
return (
<tr
key={String(row.id)}
onClick={() => onSelect(row)}
className={clsx(
"border-b border-gray-100 cursor-pointer hover:bg-blue-50",
selectedId === row.id && "bg-blue-50"
)}
>
<td className="py-2 pr-3 text-gray-500 font-mono">
{toDate(row.createdAt).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}
</td>
<td className="py-2 pr-3">
<span className="inline-flex px-1.5 py-0.5 rounded bg-purple-100 text-purple-800 font-mono text-[10px]">
{row.systemPromptId}
</span>
{row.mcpCorrelationId != null && (
<span className="ml-1 inline-flex px-1 py-0.5 rounded bg-amber-100 text-amber-800 text-[9px]">MCP</span>
)}
{!row.isAuthenticated && (
<span className="ml-1 inline-flex px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-[9px]">anon</span>
)}
</td>
<td className="py-2 pr-3 text-gray-600 font-mono truncate max-w-[200px]">{row.modelId}</td>
<td className="py-2 pr-3 text-gray-500">{row.mode}</td>
<td className="py-2 pr-3 text-right font-mono text-gray-600">{row.inputTokens?.toLocaleString() ?? "—"}</td>
<td className="py-2 pr-3 text-right font-mono text-gray-600">{row.outputTokens?.toLocaleString() ?? "—"}</td>
<td className="py-2 pr-3 text-right font-mono text-gray-600">
{row.cachedInputTokens != null && row.cachedInputTokens > 0 ? (
<span className="text-green-600">{row.cachedInputTokens.toLocaleString()}</span>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="py-2 pr-3 text-right font-mono text-gray-600">{row.costUsd != null ? formatUsd(row.costUsd) : "—"}</td>
<td className="py-2 pr-3 text-right font-mono text-gray-600">{Number(row.durationMs).toLocaleString()}ms</td>
<td className="py-2 pr-3">
{isError ? (
<span className="inline-flex px-1.5 py-0.5 rounded bg-red-100 text-red-700 text-[10px]">error</span>
) : (
<span className="inline-flex px-1.5 py-0.5 rounded bg-green-100 text-green-700 text-[10px]">ok</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="flex items-center justify-between mt-3 text-xs text-gray-600">
<div className="flex items-center gap-2">
<span className="text-[10px] text-gray-400 uppercase tracking-wider">Page size</span>
{PAGE_SIZES.map(s => (
<button
key={s}
onClick={() => setPageSize(s)}
className={clsx(
"px-2 py-0.5 text-xs rounded",
pageSize === s ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
{s}
</button>
))}
</div>
<div className="flex items-center gap-2">
<span className="text-gray-500">
{sorted.length === 0
? "No results"
: `${currentPage * pageSize + 1}${Math.min((currentPage + 1) * pageSize, sorted.length)} of ${sorted.length}`}
</span>
<button
onClick={() => setPage(Math.max(0, currentPage - 1))}
disabled={currentPage === 0}
className="px-2 py-0.5 text-xs rounded bg-gray-100 text-gray-600 hover:bg-gray-200 disabled:opacity-40 disabled:hover:bg-gray-100"
>
Prev
</button>
<span className="text-gray-500 font-mono">{currentPage + 1} / {pageCount}</span>
<button
onClick={() => setPage(Math.min(pageCount - 1, currentPage + 1))}
disabled={currentPage >= pageCount - 1}
className="px-2 py-0.5 text-xs rounded bg-gray-100 text-gray-600 hover:bg-gray-200 disabled:opacity-40 disabled:hover:bg-gray-100"
>
Next
</button>
</div>
</div>
</div>
</Card>
</div>
);
}
function SortHeader({
children,
align,
active,
dir,
onClick,
}: {
children: React.ReactNode,
align: "left" | "right",
active: boolean,
dir: SortDir,
onClick: () => void,
}) {
return (
<th className={clsx("py-2 pr-3", align === "left" ? "text-left" : "text-right")}>
<button
onClick={onClick}
className={clsx(
"inline-flex items-center gap-1 hover:text-gray-700",
active ? "text-gray-700" : "text-gray-400"
)}
>
<span>{children}</span>
<span className="text-[8px]">
{active ? (dir === "asc" ? "▲" : "▼") : "↕"}
</span>
</button>
</th>
);
}
function formatUsd(value: number): string {
if (value === 0) return "$0";
if (value < 0.01) return `$${value.toFixed(4)}`;
if (value < 1) return `$${value.toFixed(3)}`;
return `$${value.toFixed(2)}`;
}
function MetricCard({ label, value, valueClass }: { label: string, value: string, valueClass?: string }) {
return (
<div className="bg-white border border-gray-200 rounded-lg p-3">
<p className="text-[10px] uppercase text-gray-400 font-medium tracking-wider mb-1">{label}</p>
<p className={clsx("text-xl font-bold", valueClass ?? "text-gray-900")}>{value}</p>
</div>
);
}
function Card({ title, children }: { title: string, children: React.ReactNode }) {
return (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h3 className="text-xs font-semibold text-gray-600 uppercase tracking-wider mb-3">{title}</h3>
{children}
</div>
);
}
function DistributionBars({ items, color }: { items: Array<[string, number]>, color: string }) {
if (items.length === 0) {
return <p className="text-sm text-gray-400">No data</p>;
}
const max = Math.max(...items.map(i => i[1]), 1);
return (
<div className="space-y-1.5">
{items.map(([label, count]) => (
<div key={label} className="flex items-center gap-2">
<span className="text-[11px] text-gray-600 font-mono w-40 truncate">{label}</span>
<div className="flex-1 h-4 bg-gray-100 rounded overflow-hidden">
<div className={clsx("h-full rounded", color)} style={{ width: `${(count / max) * 100}%` }} />
</div>
<span className="text-[11px] text-gray-600 w-8 text-right">{count}</span>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,214 @@
import { useMemo } from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { AiQueryLogRow } from "../types";
import { toDate } from "../utils";
import { AssistantBubble, ToolCallCard, UserBubble } from "./ConversationReplay";
import { markdownComponents } from "./markdown-components";
type MessageIn = {
role: "user" | "assistant" | "tool",
content: unknown,
};
type StepEntry = {
step: number,
text?: string,
toolCalls?: Array<{ toolName: string, toolCallId: string, args: unknown }>,
toolResults?: Array<{ toolName: string, toolCallId: string, result: unknown }>,
};
function messageContentToText(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map(part => {
if (typeof part === "string") return part;
if (part && typeof part === "object" && "text" in part) return String((part as { text: unknown }).text ?? "");
return JSON.stringify(part);
})
.join("");
}
if (content == null) return "";
return JSON.stringify(content);
}
export function UsageDetail({ row, onClose }: { row: AiQueryLogRow, onClose: () => void }) {
const messages: MessageIn[] = useMemo(() => {
try {
return JSON.parse(row.messagesJson) as MessageIn[];
} catch {
return [];
}
}, [row.messagesJson]);
const steps: StepEntry[] = useMemo(() => {
try {
return JSON.parse(row.stepsJson) as StepEntry[];
} catch {
return [];
}
}, [row.stepsJson]);
const requestedTools: string[] = useMemo(() => {
try {
return JSON.parse(row.requestedToolsJson) as string[];
} catch {
return [];
}
}, [row.requestedToolsJson]);
const assistantBubbles = steps.map((s, i) => {
const toolCalls = (s.toolCalls ?? []).map((tc, idx) => {
const matched = s.toolResults?.find(r => r.toolCallId === tc.toolCallId) ?? s.toolResults?.[idx];
return {
type: "tool-call",
toolName: tc.toolName,
toolCallId: tc.toolCallId,
args: tc.args,
result: matched?.result ?? null,
};
});
return { key: i, text: s.text ?? "", toolCalls };
});
const isError = row.errorMessage != null && row.errorMessage !== "";
return (
<div className="flex flex-col h-full bg-white">
<div className="px-5 py-3 border-b border-gray-200 flex items-center justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="inline-flex px-1.5 py-0.5 rounded bg-purple-100 text-purple-800 font-mono text-[10px]">
{row.systemPromptId}
</span>
<span className="inline-flex px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-800 font-mono text-[10px]">
{row.modelId}
</span>
<span className="inline-flex px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 text-[10px]">
{row.mode}
</span>
{isError && (
<span className="inline-flex px-1.5 py-0.5 rounded bg-red-100 text-red-700 text-[10px]">error</span>
)}
{row.mcpCorrelationId != null && (
<span className="inline-flex px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 text-[10px]">MCP</span>
)}
</div>
<p className="text-[11px] text-gray-400 font-mono mt-1">
{toDate(row.createdAt).toLocaleString()}
{" · "}{Number(row.durationMs).toLocaleString()}ms
{" · "}in {row.inputTokens?.toLocaleString() ?? "?"} tok
{row.cachedInputTokens != null && row.cachedInputTokens > 0 && (
<> (cached {row.cachedInputTokens.toLocaleString()})</>
)}
{" · "}out {row.outputTokens?.toLocaleString() ?? "?"} tok
{row.costUsd != null && <>{" · "}{row.costUsd < 0.01 ? `$${row.costUsd.toFixed(4)}` : `$${row.costUsd.toFixed(3)}`}</>}
</p>
</div>
<button
onClick={onClose}
className="shrink-0 ml-2 text-gray-400 hover:text-gray-600 text-sm"
>
</button>
</div>
<div className="flex-1 overflow-y-auto">
{isError && (
<div className="mx-4 mt-4 rounded-lg bg-red-50 ring-1 ring-red-200 p-3">
<p className="text-[10px] uppercase text-red-500 font-medium tracking-wider mb-1">Error</p>
<pre className="text-xs text-red-800 whitespace-pre-wrap break-words font-mono">{row.errorMessage}</pre>
</div>
)}
{/* Metadata panel */}
<div className="m-4 bg-gray-50 rounded-lg p-3 space-y-1 text-xs">
<MetaRow label="Quality / Speed" value={`${row.quality} / ${row.speed}`} />
<MetaRow label="Authed" value={row.isAuthenticated ? "yes" : "no"} />
{row.projectId && <MetaRow label="Project" value={row.projectId} />}
{row.userId && <MetaRow label="User" value={row.userId} />}
{row.conversationId && <MetaRow label="Conversation" value={row.conversationId} />}
{row.mcpCorrelationId && <MetaRow label="MCP Correlation" value={row.mcpCorrelationId} />}
<MetaRow label="Steps" value={String(row.stepCount)} />
<MetaRow label="Tools requested" value={requestedTools.length > 0 ? requestedTools.join(", ") : "—"} />
</div>
{/* Conversation replay */}
<div className="px-4 pb-6 space-y-3">
<h3 className="text-[10px] uppercase text-gray-400 font-medium tracking-wider">Input Messages</h3>
{messages.length === 0 && (
<p className="text-xs text-gray-400">No input messages.</p>
)}
{messages.map((m, i) => {
const text = messageContentToText(m.content);
if (m.role === "user") {
return <UserBubble key={`in-${i}`} text={text} />;
}
if (m.role === "assistant") {
return <AssistantBubble key={`in-${i}`} content={text} toolCalls={[]} />;
}
return (
<div key={`in-${i}`} className="flex gap-2.5 justify-start">
<div className="shrink-0 w-6 h-6 mt-0.5 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 text-[10px] font-bold">T</span>
</div>
<div className="rounded-xl px-3.5 py-2 bg-gray-50 max-w-[80%]">
<pre className="text-[11px] font-mono text-gray-600 whitespace-pre-wrap break-all">{text}</pre>
</div>
</div>
);
})}
<h3 className="text-[10px] uppercase text-gray-400 font-medium tracking-wider pt-2">Assistant Steps</h3>
{assistantBubbles.length === 0 && (
<p className="text-xs text-gray-400">No assistant output recorded.</p>
)}
{assistantBubbles.map(bubble => (
<div key={bubble.key} className="space-y-1.5">
{bubble.toolCalls.length > 0 && (
<div className="space-y-1.5">
{bubble.toolCalls.map((call, i) => (
<ToolCallCard key={call.toolCallId || String(i)} call={call} />
))}
</div>
)}
{bubble.text && (
<div className="flex gap-2.5 justify-start">
<div className="shrink-0 w-6 h-6 mt-0.5 rounded-full bg-purple-100 flex items-center justify-center">
<span className="text-purple-500 text-xs font-bold">AI</span>
</div>
<div className="min-w-0 max-w-[calc(100%-2rem)] rounded-xl px-3.5 py-2 bg-gray-50">
<Markdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{bubble.text}
</Markdown>
</div>
</div>
)}
</div>
))}
{row.finalText && assistantBubbles.length === 0 && (
<>
<h3 className="text-[10px] uppercase text-gray-400 font-medium tracking-wider pt-2">Final Response</h3>
<div className="rounded-xl px-3.5 py-2 bg-blue-50">
<Markdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{row.finalText}
</Markdown>
</div>
</>
)}
</div>
</div>
</div>
);
}
function MetaRow({ label, value }: { label: string, value: string }) {
return (
<div className="flex gap-2">
<span className="text-[10px] uppercase text-gray-400 font-medium tracking-wider w-32 shrink-0">{label}</span>
<span className="text-gray-700 font-mono break-all">{value}</span>
</div>
);
}

View File

@ -1,6 +1,6 @@
import { useEffect, useState, useRef } from "react";
import { DbConnection, type EventContext, type SubscriptionEventContext } from "../module_bindings";
import type { McpCallLogRow } from "../types";
import type { AiQueryLogRow, McpCallLogRow } from "../types";
const IS_DEV = process.env.NODE_ENV === "development";
const PLACEHOLDER = "REPLACE_ME";
@ -20,8 +20,17 @@ const RETRY_DELAY_MS = 2000;
type ConnectionState = "connecting" | "connected" | "disconnected" | "error";
export function useMcpCallLogs() {
const [rows, setRows] = useState<McpCallLogRow[]>([]);
type TableBinding<Row extends { id: bigint }> = {
tableName: string,
iter: (ctx: SubscriptionEventContext) => Iterable<Row>,
onInsert: (conn: DbConnection, cb: (row: Row) => void) => void,
onDelete: (conn: DbConnection, cb: (row: Row) => void) => void,
};
function useTableSubscription<Row extends { id: bigint }>(
binding: TableBinding<Row>,
) {
const [rows, setRows] = useState<Row[]>([]);
const [connectionState, setConnectionState] = useState<ConnectionState>("connecting");
const connRef = useRef<DbConnection | null>(null);
@ -29,14 +38,15 @@ export function useMcpCallLogs() {
let cancelled = false;
let retryCount = 0;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const query = `SELECT * FROM ${binding.tableName}`;
console.log("[SpacetimeDB] Connecting to", HOST, "db:", DB_NAME);
console.log("[SpacetimeDB]", query, "connecting to", HOST, "db:", DB_NAME);
function retry() {
if (cancelled) return;
retryCount++;
if (retryCount > MAX_RETRIES) {
console.error("[SpacetimeDB] Max retries reached");
console.error("[SpacetimeDB] Max retries reached for", query);
setConnectionState("error");
return;
}
@ -56,7 +66,6 @@ export function useMcpCallLogs() {
.withToken(localStorage.getItem(TOKEN_KEY) || undefined)
.onConnect((connInstance: DbConnection, _identity: unknown, token: string) => {
if (cancelled) return;
console.log("[SpacetimeDB] Connected successfully");
retryCount = 0;
localStorage.setItem(TOKEN_KEY, token);
connRef.current = connInstance;
@ -64,18 +73,18 @@ export function useMcpCallLogs() {
connInstance.subscriptionBuilder()
.onApplied((ctx: SubscriptionEventContext) => {
if (cancelled) return;
const initialRows: McpCallLogRow[] = [];
for (const row of ctx.db.mcpCallLog.iter()) {
initialRows.push(row);
const initial: Row[] = [];
for (const row of binding.iter(ctx)) {
initial.push(row);
}
initialRows.sort((a, b) => Number(b.id - a.id));
console.log("[SpacetimeDB] Loaded", initialRows.length, "rows");
setRows(initialRows);
initial.sort((a, b) => Number(b.id - a.id));
console.log(`[SpacetimeDB] ${query} loaded ${initial.length} rows`);
setRows(initial);
setConnectionState("connected");
})
.subscribe(`SELECT * FROM mcp_call_log`);
.subscribe(query);
connInstance.db.mcpCallLog.onInsert((_ctx: EventContext, row: McpCallLogRow) => {
binding.onInsert(connInstance, (row) => {
if (cancelled) return;
setRows(prev => {
const existing = prev.findIndex(r => r.id === row.id);
@ -88,7 +97,7 @@ export function useMcpCallLogs() {
});
});
connInstance.db.mcpCallLog.onDelete((_ctx: EventContext, row: McpCallLogRow) => {
binding.onDelete(connInstance, (row) => {
if (cancelled) return;
setRows(prev => prev.filter(r => r.id !== row.id));
});
@ -120,7 +129,37 @@ export function useMcpCallLogs() {
connRef.current = null;
}
};
}, []);
}, [binding]);
return { rows, connectionState };
}
const mcpBinding: TableBinding<McpCallLogRow> = {
tableName: "mcp_call_log",
iter: (ctx) => ctx.db.mcpCallLog.iter(),
onInsert: (conn, cb) => {
conn.db.mcpCallLog.onInsert((_ctx: EventContext, row: McpCallLogRow) => cb(row));
},
onDelete: (conn, cb) => {
conn.db.mcpCallLog.onDelete((_ctx: EventContext, row: McpCallLogRow) => cb(row));
},
};
const aiQueryBinding: TableBinding<AiQueryLogRow> = {
tableName: "ai_query_log",
iter: (ctx) => ctx.db.aiQueryLog.iter(),
onInsert: (conn, cb) => {
conn.db.aiQueryLog.onInsert((_ctx: EventContext, row: AiQueryLogRow) => cb(row));
},
onDelete: (conn, cb) => {
conn.db.aiQueryLog.onDelete((_ctx: EventContext, row: AiQueryLogRow) => cb(row));
},
};
export function useMcpCallLogs() {
return useTableSubscription(mcpBinding);
}
export function useAiQueryLogs() {
return useTableSubscription(aiQueryBinding);
}

View File

@ -0,0 +1,38 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default __t.row({
id: __t.u64().primaryKey(),
correlationId: __t.string().name("correlation_id"),
createdAt: __t.timestamp().name("created_at"),
mode: __t.string(),
systemPromptId: __t.string().name("system_prompt_id"),
quality: __t.string(),
speed: __t.string(),
modelId: __t.string().name("model_id"),
isAuthenticated: __t.bool().name("is_authenticated"),
projectId: __t.option(__t.string()).name("project_id"),
userId: __t.option(__t.string()).name("user_id"),
requestedToolsJson: __t.string().name("requested_tools_json"),
messagesJson: __t.string().name("messages_json"),
stepsJson: __t.string().name("steps_json"),
finalText: __t.string().name("final_text"),
inputTokens: __t.option(__t.u32()).name("input_tokens"),
outputTokens: __t.option(__t.u32()).name("output_tokens"),
cachedInputTokens: __t.option(__t.u32()).name("cached_input_tokens"),
costUsd: __t.option(__t.f64()).name("cost_usd"),
stepCount: __t.u32().name("step_count"),
durationMs: __t.u64().name("duration_ms"),
errorMessage: __t.option(__t.string()).name("error_message"),
mcpCorrelationId: __t.option(__t.string()).name("mcp_correlation_id"),
conversationId: __t.option(__t.string()).name("conversation_id"),
});

View File

@ -36,6 +36,7 @@ import {
// Import all reducer arg schemas
import AddManualQaReducer from "./add_manual_qa_reducer";
import DeleteQaEntryReducer from "./delete_qa_entry_reducer";
import LogAiQueryReducer from "./log_ai_query_reducer";
import LogMcpCallReducer from "./log_mcp_call_reducer";
import MarkHumanReviewedReducer from "./mark_human_reviewed_reducer";
import UpdateHumanCorrectionReducer from "./update_human_correction_reducer";
@ -44,12 +45,24 @@ import UpdateMcpQaReviewReducer from "./update_mcp_qa_review_reducer";
// Import all procedure arg schemas
// Import all table schema definitions
import AiQueryLogRow from "./ai_query_log_table";
import McpCallLogRow from "./mcp_call_log_table";
/** Type-only namespace exports for generated type groups. */
/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */
const tablesSchema = __schema({
aiQueryLog: __table({
name: 'ai_query_log',
indexes: [
{ accessor: 'id', name: 'ai_query_log_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
],
constraints: [
{ name: 'ai_query_log_id_key', constraint: 'unique', columns: ['id'] },
],
}, AiQueryLogRow),
mcpCallLog: __table({
name: 'mcp_call_log',
indexes: [
@ -67,6 +80,7 @@ const tablesSchema = __schema({
const reducersSchema = __reducers(
__reducerSchema("add_manual_qa", AddManualQaReducer),
__reducerSchema("delete_qa_entry", DeleteQaEntryReducer),
__reducerSchema("log_ai_query", LogAiQueryReducer),
__reducerSchema("log_mcp_call", LogMcpCallReducer),
__reducerSchema("mark_human_reviewed", MarkHumanReviewedReducer),
__reducerSchema("update_human_correction", UpdateHumanCorrectionReducer),

View File

@ -0,0 +1,37 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
token: __t.string(),
correlationId: __t.string(),
mode: __t.string(),
systemPromptId: __t.string(),
quality: __t.string(),
speed: __t.string(),
modelId: __t.string(),
isAuthenticated: __t.bool(),
projectId: __t.option(__t.string()),
userId: __t.option(__t.string()),
requestedToolsJson: __t.string(),
messagesJson: __t.string(),
stepsJson: __t.string(),
finalText: __t.string(),
inputTokens: __t.option(__t.u32()),
outputTokens: __t.option(__t.u32()),
cachedInputTokens: __t.option(__t.u32()),
costUsd: __t.option(__t.f64()),
stepCount: __t.u32(),
durationMs: __t.u64(),
errorMessage: __t.option(__t.string()),
mcpCorrelationId: __t.option(__t.string()),
conversationId: __t.option(__t.string()),
};

View File

@ -10,6 +10,34 @@ import {
type Infer as __Infer,
} from "spacetimedb";
export const AiQueryLog = __t.object("AiQueryLog", {
id: __t.u64(),
correlationId: __t.string(),
createdAt: __t.timestamp(),
mode: __t.string(),
systemPromptId: __t.string(),
quality: __t.string(),
speed: __t.string(),
modelId: __t.string(),
isAuthenticated: __t.bool(),
projectId: __t.option(__t.string()),
userId: __t.option(__t.string()),
requestedToolsJson: __t.string(),
messagesJson: __t.string(),
stepsJson: __t.string(),
finalText: __t.string(),
inputTokens: __t.option(__t.u32()),
outputTokens: __t.option(__t.u32()),
cachedInputTokens: __t.option(__t.u32()),
costUsd: __t.option(__t.f64()),
stepCount: __t.u32(),
durationMs: __t.u64(),
errorMessage: __t.option(__t.string()),
mcpCorrelationId: __t.option(__t.string()),
conversationId: __t.option(__t.string()),
});
export type AiQueryLog = __Infer<typeof AiQueryLog>;
export const McpCallLog = __t.object("McpCallLog", {
id: __t.u64(),
correlationId: __t.string(),

View File

@ -8,6 +8,7 @@ import { type Infer as __Infer } from "spacetimedb";
// Import all reducer arg schemas
import AddManualQaReducer from "../add_manual_qa_reducer";
import DeleteQaEntryReducer from "../delete_qa_entry_reducer";
import LogAiQueryReducer from "../log_ai_query_reducer";
import LogMcpCallReducer from "../log_mcp_call_reducer";
import MarkHumanReviewedReducer from "../mark_human_reviewed_reducer";
import UpdateHumanCorrectionReducer from "../update_human_correction_reducer";
@ -15,6 +16,7 @@ import UpdateMcpQaReviewReducer from "../update_mcp_qa_review_reducer";
export type AddManualQaParams = __Infer<typeof AddManualQaReducer>;
export type DeleteQaEntryParams = __Infer<typeof DeleteQaEntryReducer>;
export type LogAiQueryParams = __Infer<typeof LogAiQueryReducer>;
export type LogMcpCallParams = __Infer<typeof LogMcpCallReducer>;
export type MarkHumanReviewedParams = __Infer<typeof MarkHumanReviewedReducer>;
export type UpdateHumanCorrectionParams = __Infer<typeof UpdateHumanCorrectionReducer>;

View File

@ -1 +1 @@
export type { McpCallLog as McpCallLogRow } from "./module_bindings/types";
export type { AiQueryLog as AiQueryLogRow, McpCallLog as McpCallLogRow } from "./module_bindings/types";