增加性能统计
This commit is contained in:
parent
20ae332706
commit
6bb80d7c8c
@ -2,8 +2,9 @@ import Link from "next/link";
|
|||||||
import { ArrowUpRight, Clock3, Smartphone, Sparkles } from "lucide-react";
|
import { ArrowUpRight, Clock3, Smartphone, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
import { DashboardCharts } from "@/components/admin/dashboard-charts";
|
import { DashboardCharts } from "@/components/admin/dashboard-charts";
|
||||||
|
import type { DashboardData } from "@/lib/analytics/contracts";
|
||||||
import { getDashboardData, parseDashboardQuery } from "@/lib/analytics/server";
|
import { getDashboardData, parseDashboardQuery } from "@/lib/analytics/server";
|
||||||
import { formatDateTime, formatNumber } from "@/lib/utils";
|
import { formatDateTime, formatDurationMs, formatNumber } from "@/lib/utils";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@ -14,6 +15,41 @@ const ranges = [
|
|||||||
{ label: "90 天", value: "90" },
|
{ label: "90 天", value: "90" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
type RecentEventItem = DashboardData["recentEvents"][number];
|
||||||
|
type ConversationItem = DashboardData["recentConversations"][number];
|
||||||
|
|
||||||
|
function describeConversationMode(mode: string | null) {
|
||||||
|
if (mode === "clone") {
|
||||||
|
return "专属音色模式";
|
||||||
|
}
|
||||||
|
if (mode === "standard") {
|
||||||
|
return "标准模式";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEventNote(event: RecentEventItem) {
|
||||||
|
const notes = [
|
||||||
|
event.durationMs && event.durationMs > 0 ? `耗时 ${formatDurationMs(event.durationMs)}` : null,
|
||||||
|
event.value?.trim() || null,
|
||||||
|
event.elementText?.trim() || null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return notes[0] ?? "无额外说明";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConversationNote(item: ConversationItem) {
|
||||||
|
const notes = [
|
||||||
|
item.turnIndex ? `第 ${item.turnIndex} 轮` : null,
|
||||||
|
describeConversationMode(item.mode),
|
||||||
|
item.durationMs && item.durationMs > 0
|
||||||
|
? `${item.role === "用户发言" ? "识别完成" : "文本完成"} ${formatDurationMs(item.durationMs)}`
|
||||||
|
: null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return notes.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
type AdminPageProps = {
|
type AdminPageProps = {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
range?: string;
|
range?: string;
|
||||||
@ -44,7 +80,7 @@ export default async function AdminPage({ searchParams }: AdminPageProps) {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="max-w-3xl text-base leading-8 text-[var(--ink-2)]">
|
<p className="max-w-3xl text-base leading-8 text-[var(--ink-2)]">
|
||||||
这里持续汇总老年陪伴助手在 Android 应用中的使用情况,方便你查看哪些功能最常被打开、哪些设备最近还在使用,以及哪里出现了中断或失败。
|
这里持续汇总老年陪伴助手在 Android 应用中的使用情况,除了常用功能和异常提醒,也会单独整理回复耗时、连接速度和最近对话片段,方便你直接判断体验是不是顺畅。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -93,6 +129,27 @@ export default async function AdminPage({ searchParams }: AdminPageProps) {
|
|||||||
|
|
||||||
<DashboardCharts activityTrend={data.activityTrend} topEvents={data.topEvents} />
|
<DashboardCharts activityTrend={data.activityTrend} topEvents={data.topEvents} />
|
||||||
|
|
||||||
|
<section className="soft-card p-6">
|
||||||
|
<div className="mb-5 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">性能概览</p>
|
||||||
|
<h2 className="text-2xl font-semibold text-[var(--ink-1)]">陪伴流程现在快不快</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--ink-3)]">只看真实耗时,不看空洞的成功失败计数</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{data.performanceMetrics.map((metric) => (
|
||||||
|
<article key={metric.label} className="rounded-[24px] border border-[var(--line-soft)] bg-white/80 px-5 py-5 shadow-[var(--shadow-card)]">
|
||||||
|
<p className="mb-2 text-sm text-[var(--ink-3)]">{metric.label}</p>
|
||||||
|
<strong className="text-3xl font-semibold text-[var(--ink-1)]">{formatDurationMs(metric.valueMs)}</strong>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-[var(--ink-2)]">{metric.hint}</p>
|
||||||
|
<p className="mt-2 text-xs text-[var(--ink-3)]">{metric.sampleCount > 0 ? `基于 ${formatNumber(metric.sampleCount)} 次记录` : "暂无样本"}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
<section className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||||
<article className="soft-card p-6">
|
<article className="soft-card p-6">
|
||||||
<div className="mb-5 flex items-center justify-between gap-4">
|
<div className="mb-5 flex items-center justify-between gap-4">
|
||||||
@ -155,6 +212,38 @@ export default async function AdminPage({ searchParams }: AdminPageProps) {
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="soft-card p-6">
|
||||||
|
<div className="mb-5 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">最近对话</p>
|
||||||
|
<h2 className="text-2xl font-semibold text-[var(--ink-1)]">刚刚聊了些什么</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--ink-3)]">聊天记录会自动上传,这里只展示最新片段</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{data.recentConversations.length > 0 ? (
|
||||||
|
data.recentConversations.map((item) => (
|
||||||
|
<article key={item.id} className="rounded-[24px] border border-[var(--line-soft)] bg-white/80 px-5 py-5 shadow-[var(--shadow-card)]">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<span className="inline-flex rounded-full bg-[var(--surface-2)] px-3 py-1 text-sm font-medium text-[var(--ink-1)]">
|
||||||
|
{item.role}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--ink-3)]">{formatDateTime(item.occurredAt)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-base leading-8 text-[var(--ink-1)]">{item.text}</p>
|
||||||
|
<p className="mt-3 text-sm text-[var(--ink-3)]">{buildConversationNote(item) || "已自动归档到聊天记录"}</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--ink-3)]">设备会话 {item.sessionKey.slice(0, 18)}...</p>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[24px] border border-dashed border-[var(--line-soft)] bg-white/60 px-5 py-8 text-sm text-[var(--ink-3)] lg:col-span-2">
|
||||||
|
当前时间范围内还没有自动上传的聊天片段。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="soft-card p-6">
|
<section className="soft-card p-6">
|
||||||
<div className="mb-5 flex items-center justify-between gap-4">
|
<div className="mb-5 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@ -189,7 +278,7 @@ export default async function AdminPage({ searchParams }: AdminPageProps) {
|
|||||||
<p className="mt-1 text-xs text-[var(--ink-3)]">{event.component ?? "无额外标签"}</p>
|
<p className="mt-1 text-xs text-[var(--ink-3)]">{event.component ?? "无额外标签"}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="rounded-r-[20px] px-4 py-4">
|
<td className="rounded-r-[20px] px-4 py-4">
|
||||||
<p>{event.elementText ?? "无额外说明"}</p>
|
<p>{buildEventNote(event)}</p>
|
||||||
<p className="mt-1 text-xs text-[var(--ink-3)]">设备会话 {event.sessionKey.slice(0, 18)}...</p>
|
<p className="mt-1 text-xs text-[var(--ink-3)]">设备会话 {event.sessionKey.slice(0, 18)}...</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -65,6 +65,13 @@ export interface DashboardStat {
|
|||||||
hint: string;
|
hint: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardPerformanceMetric {
|
||||||
|
label: string;
|
||||||
|
valueMs: number | null;
|
||||||
|
sampleCount: number;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardTrendPoint {
|
export interface DashboardTrendPoint {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
total: number;
|
total: number;
|
||||||
@ -85,10 +92,23 @@ export interface DashboardRecentEvent {
|
|||||||
pathname: string | null;
|
pathname: string | null;
|
||||||
component: string | null;
|
component: string | null;
|
||||||
elementText: string | null;
|
elementText: string | null;
|
||||||
|
value: string | null;
|
||||||
|
durationMs: number | null;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
metadata: JsonValue | null;
|
metadata: JsonValue | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardConversationItem {
|
||||||
|
id: string;
|
||||||
|
sessionKey: string;
|
||||||
|
role: string;
|
||||||
|
text: string;
|
||||||
|
mode: string | null;
|
||||||
|
turnIndex: number | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
occurredAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardRecentSession {
|
export interface DashboardRecentSession {
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
platform: AnalyticsPlatform;
|
platform: AnalyticsPlatform;
|
||||||
@ -101,10 +121,12 @@ export interface DashboardRecentSession {
|
|||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
stats: DashboardStat[];
|
stats: DashboardStat[];
|
||||||
|
performanceMetrics: DashboardPerformanceMetric[];
|
||||||
activityTrend: DashboardTrendPoint[];
|
activityTrend: DashboardTrendPoint[];
|
||||||
topEvents: DashboardTopItem[];
|
topEvents: DashboardTopItem[];
|
||||||
topPaths: DashboardTopItem[];
|
topPaths: DashboardTopItem[];
|
||||||
recentEvents: DashboardRecentEvent[];
|
recentEvents: DashboardRecentEvent[];
|
||||||
|
recentConversations: DashboardConversationItem[];
|
||||||
recentSessions: DashboardRecentSession[];
|
recentSessions: DashboardRecentSession[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
const EVENT_LABELS: Record<string, string> = {
|
const EVENT_LABELS: Record<string, string> = {
|
||||||
screen_view: "打开页面",
|
screen_view: "打开页面",
|
||||||
|
digital_human_init_ready: "数字人准备完成",
|
||||||
voice_clone_entry_tap: "进入音色克隆",
|
voice_clone_entry_tap: "进入音色克隆",
|
||||||
avatar_selected: "选择数字人形象",
|
avatar_selected: "选择数字人形象",
|
||||||
call_page_opened: "进入陪伴通话",
|
call_page_opened: "进入陪伴通话",
|
||||||
@ -30,7 +31,7 @@ const EVENT_LABELS: Record<string, string> = {
|
|||||||
ai_conversation_error: "陪伴通话异常",
|
ai_conversation_error: "陪伴通话异常",
|
||||||
user_transcript_received: "收到用户语音文本",
|
user_transcript_received: "收到用户语音文本",
|
||||||
assistant_transcript_completed: "陪伴回复已生成",
|
assistant_transcript_completed: "陪伴回复已生成",
|
||||||
assistant_response_done: "陪伴回复结束",
|
assistant_response_done: "整轮回复完成",
|
||||||
ai_conversation_stopped: "陪伴通话已结束",
|
ai_conversation_stopped: "陪伴通话已结束",
|
||||||
clone_tts_started: "开始播放专属音色",
|
clone_tts_started: "开始播放专属音色",
|
||||||
clone_tts_finished: "专属音色播放完成",
|
clone_tts_finished: "专属音色播放完成",
|
||||||
@ -38,6 +39,9 @@ const EVENT_LABELS: Record<string, string> = {
|
|||||||
camera_enabled: "打开摄像头",
|
camera_enabled: "打开摄像头",
|
||||||
camera_disabled: "关闭摄像头",
|
camera_disabled: "关闭摄像头",
|
||||||
camera_capture_error: "摄像头采集失败",
|
camera_capture_error: "摄像头采集失败",
|
||||||
|
camera_first_frame_ready: "摄像头画面就绪",
|
||||||
|
user_message_uploaded: "用户聊天记录已上传",
|
||||||
|
assistant_message_uploaded: "助手聊天记录已上传",
|
||||||
};
|
};
|
||||||
|
|
||||||
const PATH_LABELS: Record<string, string> = {
|
const PATH_LABELS: Record<string, string> = {
|
||||||
@ -79,9 +83,24 @@ export function describeAnalyticsType(type: string) {
|
|||||||
return "页面浏览";
|
return "页面浏览";
|
||||||
case "interaction":
|
case "interaction":
|
||||||
return "操作记录";
|
return "操作记录";
|
||||||
|
case "performance":
|
||||||
|
return "性能指标";
|
||||||
|
case "conversation":
|
||||||
|
return "聊天记录";
|
||||||
case "error":
|
case "error":
|
||||||
return "异常提醒";
|
return "异常提醒";
|
||||||
default:
|
default:
|
||||||
return humanizeFallback(type);
|
return humanizeFallback(type);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeConversationRole(role: string | null | undefined) {
|
||||||
|
switch ((role ?? "").toLowerCase()) {
|
||||||
|
case "assistant":
|
||||||
|
return "助手回复";
|
||||||
|
case "user":
|
||||||
|
return "用户发言";
|
||||||
|
default:
|
||||||
|
return "对话记录";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -12,6 +12,7 @@ import type {
|
|||||||
} from "@/lib/analytics/contracts";
|
} from "@/lib/analytics/contracts";
|
||||||
import { ANALYTICS_RANGE_OPTIONS } from "@/lib/analytics/contracts";
|
import { ANALYTICS_RANGE_OPTIONS } from "@/lib/analytics/contracts";
|
||||||
import {
|
import {
|
||||||
|
describeConversationRole,
|
||||||
describeAnalyticsEventName,
|
describeAnalyticsEventName,
|
||||||
describeAnalyticsPath,
|
describeAnalyticsPath,
|
||||||
describeAnalyticsType,
|
describeAnalyticsType,
|
||||||
@ -165,6 +166,14 @@ function countResult(rows: Array<{ total: bigint | number | string | null | unde
|
|||||||
return Number(total ?? 0);
|
return Number(total ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function averageResult(rows: Array<{ average: number | null; total: bigint | number | string | null | undefined }>) {
|
||||||
|
const average = rows[0]?.average;
|
||||||
|
return {
|
||||||
|
average: average == null ? null : Number(average),
|
||||||
|
total: Number(rows[0]?.total ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatBucket(bucket: Date, days: number) {
|
function formatBucket(bucket: Date, days: number) {
|
||||||
return new Intl.DateTimeFormat("zh-CN", {
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
@ -188,6 +197,43 @@ function buildDeviceLabel(session: {
|
|||||||
return browser || "未标记设备";
|
return browser || "未标记设备";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readMetadataObject(metadata: unknown) {
|
||||||
|
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata as Record<string, JsonValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMetadataString(metadata: unknown, key: string) {
|
||||||
|
const object = readMetadataObject(metadata);
|
||||||
|
const value = object?.[key];
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMetadataNumber(metadata: unknown, key: string) {
|
||||||
|
const object = readMetadataObject(metadata);
|
||||||
|
const value = object?.[key];
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactText(value: string, max = 220) {
|
||||||
|
const normalized = value.trim().replace(/\s+/g, " ");
|
||||||
|
if (!normalized) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.length > max ? `${normalized.slice(0, max)}...` : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseDashboardQuery(raw: { range?: string }): DashboardQuery {
|
export function parseDashboardQuery(raw: { range?: string }): DashboardQuery {
|
||||||
const parsedDays = Number(raw.range ?? 7);
|
const parsedDays = Number(raw.range ?? 7);
|
||||||
const days = ANALYTICS_RANGE_OPTIONS.includes(parsedDays as (typeof ANALYTICS_RANGE_OPTIONS)[number])
|
const days = ANALYTICS_RANGE_OPTIONS.includes(parsedDays as (typeof ANALYTICS_RANGE_OPTIONS)[number])
|
||||||
@ -303,6 +349,7 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
|
|||||||
const rangeStart = new Date(now.getTime() - query.days * 24 * 60 * 60 * 1000);
|
const rangeStart = new Date(now.getTime() - query.days * 24 * 60 * 60 * 1000);
|
||||||
const sessionFilter = buildRangeFilter("lastSeenAt", rangeStart);
|
const sessionFilter = buildRangeFilter("lastSeenAt", rangeStart);
|
||||||
const eventFilter = buildRangeFilter("occurredAt", rangeStart);
|
const eventFilter = buildRangeFilter("occurredAt", rangeStart);
|
||||||
|
const usageEventFilter = `${eventFilter} AND lower("type") NOT IN ('conversation', 'performance')`;
|
||||||
const sessionWhere = {
|
const sessionWhere = {
|
||||||
lastSeenAt: { gte: rangeStart },
|
lastSeenAt: { gte: rangeStart },
|
||||||
platform: "ANDROID" as const,
|
platform: "ANDROID" as const,
|
||||||
@ -315,13 +362,18 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
|
|||||||
const [
|
const [
|
||||||
sessionCountRows,
|
sessionCountRows,
|
||||||
visitorCountRows,
|
visitorCountRows,
|
||||||
eventCountRows,
|
|
||||||
errorCountRows,
|
errorCountRows,
|
||||||
|
responseDurationRows,
|
||||||
|
connectDurationRows,
|
||||||
|
assistantTextDurationRows,
|
||||||
|
assistantFirstAudioRows,
|
||||||
|
cloneCreateDurationRows,
|
||||||
activityTrendRows,
|
activityTrendRows,
|
||||||
topEventRows,
|
topEventRows,
|
||||||
topPathRows,
|
topPathRows,
|
||||||
recentSessions,
|
recentSessions,
|
||||||
recentEvents,
|
recentEvents,
|
||||||
|
recentConversations,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
|
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
|
||||||
SELECT COUNT(*)::bigint AS total
|
SELECT COUNT(*)::bigint AS total
|
||||||
@ -333,11 +385,6 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
|
|||||||
FROM "AnalyticsSession"
|
FROM "AnalyticsSession"
|
||||||
WHERE ${sessionFilter}
|
WHERE ${sessionFilter}
|
||||||
`),
|
`),
|
||||||
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
|
|
||||||
SELECT COUNT(*)::bigint AS total
|
|
||||||
FROM "AnalyticsEvent"
|
|
||||||
WHERE ${eventFilter}
|
|
||||||
`),
|
|
||||||
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
|
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
|
||||||
SELECT COUNT(*)::bigint AS total
|
SELECT COUNT(*)::bigint AS total
|
||||||
FROM "AnalyticsEvent"
|
FROM "AnalyticsEvent"
|
||||||
@ -348,11 +395,50 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
|
|||||||
OR lower("name") LIKE '%fail%'
|
OR lower("name") LIKE '%fail%'
|
||||||
)
|
)
|
||||||
`),
|
`),
|
||||||
|
prisma.$queryRawUnsafe<Array<{ average: number | null; total: bigint }>>(`
|
||||||
|
SELECT AVG("durationMs")::double precision AS average,
|
||||||
|
COUNT(*)::bigint AS total
|
||||||
|
FROM "AnalyticsEvent"
|
||||||
|
WHERE ${eventFilter}
|
||||||
|
AND "name" = 'assistant_response_done'
|
||||||
|
AND "durationMs" IS NOT NULL
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<Array<{ average: number | null; total: bigint }>>(`
|
||||||
|
SELECT AVG("durationMs")::double precision AS average,
|
||||||
|
COUNT(*)::bigint AS total
|
||||||
|
FROM "AnalyticsEvent"
|
||||||
|
WHERE ${eventFilter}
|
||||||
|
AND "name" = 'ai_conversation_connected'
|
||||||
|
AND "durationMs" IS NOT NULL
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<Array<{ average: number | null; total: bigint }>>(`
|
||||||
|
SELECT AVG("durationMs")::double precision AS average,
|
||||||
|
COUNT(*)::bigint AS total
|
||||||
|
FROM "AnalyticsEvent"
|
||||||
|
WHERE ${eventFilter}
|
||||||
|
AND "name" = 'assistant_message_uploaded'
|
||||||
|
AND "durationMs" IS NOT NULL
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<Array<{ average: number | null; total: bigint }>>(`
|
||||||
|
SELECT AVG(NULLIF("metadata"->>'firstAudioLatencyMs', '')::double precision) AS average,
|
||||||
|
COUNT(*) FILTER (WHERE NULLIF("metadata"->>'firstAudioLatencyMs', '') IS NOT NULL)::bigint AS total
|
||||||
|
FROM "AnalyticsEvent"
|
||||||
|
WHERE ${eventFilter}
|
||||||
|
AND "name" = 'assistant_response_done'
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<Array<{ average: number | null; total: bigint }>>(`
|
||||||
|
SELECT AVG("durationMs")::double precision AS average,
|
||||||
|
COUNT(*)::bigint AS total
|
||||||
|
FROM "AnalyticsEvent"
|
||||||
|
WHERE ${eventFilter}
|
||||||
|
AND "name" = 'voice_clone_create_succeeded'
|
||||||
|
AND "durationMs" IS NOT NULL
|
||||||
|
`),
|
||||||
prisma.$queryRawUnsafe<Array<{ bucket: Date; total: bigint }>>(`
|
prisma.$queryRawUnsafe<Array<{ bucket: Date; total: bigint }>>(`
|
||||||
SELECT date_trunc('${query.days <= 2 ? "hour" : "day"}', "occurredAt") AS bucket,
|
SELECT date_trunc('${query.days <= 2 ? "hour" : "day"}', "occurredAt") AS bucket,
|
||||||
COUNT(*)::bigint AS total
|
COUNT(*)::bigint AS total
|
||||||
FROM "AnalyticsEvent"
|
FROM "AnalyticsEvent"
|
||||||
WHERE ${eventFilter}
|
WHERE ${usageEventFilter}
|
||||||
GROUP BY 1
|
GROUP BY 1
|
||||||
ORDER BY 1 ASC
|
ORDER BY 1 ASC
|
||||||
`),
|
`),
|
||||||
@ -361,7 +447,7 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
|
|||||||
MIN("type") AS detail,
|
MIN("type") AS detail,
|
||||||
COUNT(*)::bigint AS total
|
COUNT(*)::bigint AS total
|
||||||
FROM "AnalyticsEvent"
|
FROM "AnalyticsEvent"
|
||||||
WHERE ${eventFilter}
|
WHERE ${usageEventFilter}
|
||||||
GROUP BY "name"
|
GROUP BY "name"
|
||||||
ORDER BY total DESC
|
ORDER BY total DESC
|
||||||
LIMIT 8
|
LIMIT 8
|
||||||
@ -370,7 +456,7 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
|
|||||||
SELECT COALESCE(NULLIF("pathname", ''), '(未标记页面)') AS label,
|
SELECT COALESCE(NULLIF("pathname", ''), '(未标记页面)') AS label,
|
||||||
COUNT(*)::bigint AS total
|
COUNT(*)::bigint AS total
|
||||||
FROM "AnalyticsEvent"
|
FROM "AnalyticsEvent"
|
||||||
WHERE ${eventFilter}
|
WHERE ${usageEventFilter}
|
||||||
GROUP BY 1
|
GROUP BY 1
|
||||||
ORDER BY total DESC
|
ORDER BY total DESC
|
||||||
LIMIT 8
|
LIMIT 8
|
||||||
@ -393,7 +479,10 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.analyticsEvent.findMany({
|
prisma.analyticsEvent.findMany({
|
||||||
where: eventWhere,
|
where: {
|
||||||
|
...eventWhere,
|
||||||
|
type: { not: "conversation" },
|
||||||
|
},
|
||||||
orderBy: { occurredAt: "desc" },
|
orderBy: { occurredAt: "desc" },
|
||||||
take: 18,
|
take: 18,
|
||||||
select: {
|
select: {
|
||||||
@ -405,6 +494,26 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
|
|||||||
pathname: true,
|
pathname: true,
|
||||||
component: true,
|
component: true,
|
||||||
elementText: true,
|
elementText: true,
|
||||||
|
value: true,
|
||||||
|
durationMs: true,
|
||||||
|
occurredAt: true,
|
||||||
|
metadata: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.analyticsEvent.findMany({
|
||||||
|
where: {
|
||||||
|
...eventWhere,
|
||||||
|
type: "conversation",
|
||||||
|
},
|
||||||
|
orderBy: { occurredAt: "desc" },
|
||||||
|
take: 12,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
sessionKey: true,
|
||||||
|
name: true,
|
||||||
|
elementText: true,
|
||||||
|
value: true,
|
||||||
|
durationMs: true,
|
||||||
occurredAt: true,
|
occurredAt: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
},
|
},
|
||||||
@ -413,8 +522,12 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
|
|||||||
|
|
||||||
const sessionCount = countResult(sessionCountRows);
|
const sessionCount = countResult(sessionCountRows);
|
||||||
const uniqueVisitors = countResult(visitorCountRows);
|
const uniqueVisitors = countResult(visitorCountRows);
|
||||||
const eventCount = countResult(eventCountRows);
|
|
||||||
const errorCount = countResult(errorCountRows);
|
const errorCount = countResult(errorCountRows);
|
||||||
|
const responseDuration = averageResult(responseDurationRows);
|
||||||
|
const connectDuration = averageResult(connectDurationRows);
|
||||||
|
const assistantTextDuration = averageResult(assistantTextDurationRows);
|
||||||
|
const assistantFirstAudio = averageResult(assistantFirstAudioRows);
|
||||||
|
const cloneCreateDuration = averageResult(cloneCreateDurationRows);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stats: [
|
stats: [
|
||||||
@ -429,9 +542,12 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
|
|||||||
hint: "按安装标识或会话编号去重后的实际使用主体",
|
hint: "按安装标识或会话编号去重后的实际使用主体",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "互动记录",
|
label: "平均回复耗时(ms)",
|
||||||
value: eventCount,
|
value: responseDuration.average == null ? 0 : Math.round(responseDuration.average),
|
||||||
hint: sessionCount > 0 ? `平均每台设备 ${(eventCount / sessionCount).toFixed(1)} 条` : "当前还没有数据",
|
hint:
|
||||||
|
responseDuration.total > 0
|
||||||
|
? `基于 ${responseDuration.total} 次已完成回复,从老人说完到整轮回复结束`
|
||||||
|
: "还没有可计算的回复耗时",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "异常提醒",
|
label: "异常提醒",
|
||||||
@ -439,6 +555,32 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
|
|||||||
hint: "连接、播放、录音和资源准备失败都会在这里体现",
|
hint: "连接、播放、录音和资源准备失败都会在这里体现",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
performanceMetrics: [
|
||||||
|
{
|
||||||
|
label: "连接成功",
|
||||||
|
valueMs: connectDuration.average,
|
||||||
|
sampleCount: connectDuration.total,
|
||||||
|
hint: "从点击开始对话到建立实时连接",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "文字回复完成",
|
||||||
|
valueMs: assistantTextDuration.average,
|
||||||
|
sampleCount: assistantTextDuration.total,
|
||||||
|
hint: "从老人停顿到拿到完整文字回复",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "开始出声",
|
||||||
|
valueMs: assistantFirstAudio.average,
|
||||||
|
sampleCount: assistantFirstAudio.total,
|
||||||
|
hint: "标准陪伴通话从停顿到第一段声音到达",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "专属音色生成",
|
||||||
|
valueMs: cloneCreateDuration.average,
|
||||||
|
sampleCount: cloneCreateDuration.total,
|
||||||
|
hint: "从提交录音到拿到可用专属音色",
|
||||||
|
},
|
||||||
|
],
|
||||||
activityTrend: activityTrendRows.map((row: { bucket: Date; total: bigint | number | string }) => ({
|
activityTrend: activityTrendRows.map((row: { bucket: Date; total: bigint | number | string }) => ({
|
||||||
bucket: formatBucket(row.bucket, query.days),
|
bucket: formatBucket(row.bucket, query.days),
|
||||||
total: Number(row.total),
|
total: Number(row.total),
|
||||||
@ -461,6 +603,8 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
|
|||||||
pathname: string | null;
|
pathname: string | null;
|
||||||
component: string | null;
|
component: string | null;
|
||||||
elementText: string | null;
|
elementText: string | null;
|
||||||
|
value: string | null;
|
||||||
|
durationMs: number | null;
|
||||||
occurredAt: Date;
|
occurredAt: Date;
|
||||||
metadata: unknown;
|
metadata: unknown;
|
||||||
}) => ({
|
}) => ({
|
||||||
@ -472,9 +616,37 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
|
|||||||
pathname: describeAnalyticsPath(event.pathname),
|
pathname: describeAnalyticsPath(event.pathname),
|
||||||
component: event.component,
|
component: event.component,
|
||||||
elementText: event.elementText,
|
elementText: event.elementText,
|
||||||
|
value: event.value,
|
||||||
|
durationMs: event.durationMs,
|
||||||
occurredAt: event.occurredAt.toISOString(),
|
occurredAt: event.occurredAt.toISOString(),
|
||||||
metadata: (event.metadata ?? null) as DashboardData["recentEvents"][number]["metadata"],
|
metadata: (event.metadata ?? null) as DashboardData["recentEvents"][number]["metadata"],
|
||||||
})),
|
})),
|
||||||
|
recentConversations: recentConversations.map((event: {
|
||||||
|
id: string;
|
||||||
|
sessionKey: string;
|
||||||
|
name: string;
|
||||||
|
elementText: string | null;
|
||||||
|
value: string | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
occurredAt: Date;
|
||||||
|
metadata: unknown;
|
||||||
|
}) => {
|
||||||
|
const role = readMetadataString(event.metadata, "role") ?? (event.name.startsWith("assistant") ? "assistant" : "user");
|
||||||
|
const fullText = readMetadataString(event.metadata, "text") ?? event.elementText ?? event.value ?? "";
|
||||||
|
const mode = readMetadataString(event.metadata, "mode");
|
||||||
|
const turnIndex = readMetadataNumber(event.metadata, "turnIndex");
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
sessionKey: event.sessionKey,
|
||||||
|
role: describeConversationRole(role),
|
||||||
|
text: compactText(fullText),
|
||||||
|
mode,
|
||||||
|
turnIndex: turnIndex == null ? null : Math.round(turnIndex),
|
||||||
|
durationMs: event.durationMs,
|
||||||
|
occurredAt: event.occurredAt.toISOString(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
recentSessions: recentSessions.map((session: {
|
recentSessions: recentSessions.map((session: {
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
platform: AnalyticsPlatform;
|
platform: AnalyticsPlatform;
|
||||||
@ -501,12 +673,13 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
|
|||||||
export async function getPublicAnalyticsSnapshot(): Promise<PublicAnalyticsSnapshot> {
|
export async function getPublicAnalyticsSnapshot(): Promise<PublicAnalyticsSnapshot> {
|
||||||
const rangeStart = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
const rangeStart = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
const eventFilter = buildRangeFilter("occurredAt", rangeStart);
|
const eventFilter = buildRangeFilter("occurredAt", rangeStart);
|
||||||
|
const usageEventFilter = `${eventFilter} AND lower("type") NOT IN ('conversation', 'performance')`;
|
||||||
const sessionFilter = buildRangeFilter("lastSeenAt", rangeStart);
|
const sessionFilter = buildRangeFilter("lastSeenAt", rangeStart);
|
||||||
const [eventRows, sessionRows, topActionRows] = await Promise.all([
|
const [eventRows, sessionRows, topActionRows] = await Promise.all([
|
||||||
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
|
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
|
||||||
SELECT COUNT(*)::bigint AS total
|
SELECT COUNT(*)::bigint AS total
|
||||||
FROM "AnalyticsEvent"
|
FROM "AnalyticsEvent"
|
||||||
WHERE ${eventFilter}
|
WHERE ${usageEventFilter}
|
||||||
`),
|
`),
|
||||||
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
|
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
|
||||||
SELECT COUNT(*)::bigint AS total
|
SELECT COUNT(*)::bigint AS total
|
||||||
@ -516,7 +689,7 @@ export async function getPublicAnalyticsSnapshot(): Promise<PublicAnalyticsSnaps
|
|||||||
prisma.$queryRawUnsafe<Array<{ label: string; total: bigint }>>(`
|
prisma.$queryRawUnsafe<Array<{ label: string; total: bigint }>>(`
|
||||||
SELECT "name" AS label, COUNT(*)::bigint AS total
|
SELECT "name" AS label, COUNT(*)::bigint AS total
|
||||||
FROM "AnalyticsEvent"
|
FROM "AnalyticsEvent"
|
||||||
WHERE ${eventFilter}
|
WHERE ${usageEventFilter}
|
||||||
GROUP BY 1
|
GROUP BY 1
|
||||||
ORDER BY total DESC
|
ORDER BY total DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
|||||||
20
lib/utils.ts
20
lib/utils.ts
@ -15,4 +15,24 @@ export function formatDateTime(value: string | Date) {
|
|||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDurationMs(value: number | null | undefined) {
|
||||||
|
if (value == null || !Number.isFinite(value)) {
|
||||||
|
return "暂无";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 1000) {
|
||||||
|
return `${Math.round(value)} ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = value / 1000;
|
||||||
|
if (seconds < 60) {
|
||||||
|
const rounded = seconds >= 10 ? seconds.toFixed(1) : seconds.toFixed(2);
|
||||||
|
return `${rounded.replace(/\.0+$/, "")} s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainSeconds = Math.round(seconds % 60);
|
||||||
|
return `${minutes} 分 ${remainSeconds} 秒`;
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user