mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
initial commit
This commit is contained in:
parent
afd84bce0e
commit
b0a329f396
@ -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"
|
||||
|
||||
12
apps/backend/src/lib/ai/ai-query-logger.ts
Normal file
12
apps/backend/src/lib/ai/ai-query-logger.ts
Normal 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 });
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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"),
|
||||
});
|
||||
@ -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),
|
||||
|
||||
@ -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()),
|
||||
};
|
||||
@ -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(),
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 => {});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
720
apps/internal-tool/src/components/Usage.tsx
Normal file
720
apps/internal-tool/src/components/Usage.tsx
Normal 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: "500ms–2s", max: 2000, count: 0 },
|
||||
{ label: "2–10s", max: 10000, count: 0 },
|
||||
{ label: "10–30s", 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>
|
||||
);
|
||||
}
|
||||
214
apps/internal-tool/src/components/UsageDetail.tsx
Normal file
214
apps/internal-tool/src/components/UsageDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
38
apps/internal-tool/src/module_bindings/ai_query_log_table.ts
Normal file
38
apps/internal-tool/src/module_bindings/ai_query_log_table.ts
Normal 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"),
|
||||
});
|
||||
@ -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),
|
||||
|
||||
@ -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()),
|
||||
};
|
||||
@ -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(),
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -1 +1 @@
|
||||
export type { McpCallLog as McpCallLogRow } from "./module_bindings/types";
|
||||
export type { AiQueryLog as AiQueryLogRow, McpCallLog as McpCallLogRow } from "./module_bindings/types";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user