digital-human-monitor-v2/lib/monitor-data.ts

501 lines
13 KiB
TypeScript

import {
ConversationRole,
MessageDirection,
MessageImportance,
MessageStatus,
ToolCallStatus,
UsageEventType,
} from "@prisma/client";
import { sendIncomingMessagePush } from "@/lib/push";
import { prisma } from "@/lib/prisma";
type RegisterDeviceInput = {
deviceUuid: string;
displayName?: string;
appVersion?: string;
};
type UsageEventInput = {
deviceUuid: string;
eventType: UsageEventType;
detailJson?: string;
};
type ConversationTurnInput = {
deviceUuid: string;
sessionId?: string;
turns: Array<{
role: ConversationRole;
content: string;
metaJson?: string;
createdAt?: string;
}>;
};
type ToolCallInput = {
deviceUuid: string;
toolName: string;
arguments: Record<string, unknown>;
outputText?: string;
status: ToolCallStatus;
callId?: string;
sessionId?: string;
};
const PUBLIC_BASE_URL = (
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
).replace(/\/+$/, "");
const DEVICE_UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function normalizeDeviceUuid(rawValue: string) {
return rawValue.trim().toLowerCase();
}
export function createBindUrl(deviceUuid: string) {
return `${PUBLIC_BASE_URL}/bind?deviceUuid=${encodeURIComponent(
normalizeDeviceUuid(deviceUuid),
)}`;
}
export function extractDeviceUuid(rawValue: string) {
const trimmedValue = rawValue.trim();
if (!trimmedValue) {
return null;
}
try {
const parsedUrl = new URL(trimmedValue);
const queryDeviceUuid =
parsedUrl.searchParams.get("deviceUuid") ||
parsedUrl.searchParams.get("device");
if (queryDeviceUuid && DEVICE_UUID_PATTERN.test(queryDeviceUuid)) {
return normalizeDeviceUuid(queryDeviceUuid);
}
const lastSegment = parsedUrl.pathname.split("/").filter(Boolean).at(-1);
if (lastSegment && DEVICE_UUID_PATTERN.test(lastSegment)) {
return normalizeDeviceUuid(lastSegment);
}
} catch {
if (DEVICE_UUID_PATTERN.test(trimmedValue)) {
return normalizeDeviceUuid(trimmedValue);
}
}
return null;
}
export async function ensureDeviceRegistration(input: RegisterDeviceInput) {
const deviceUuid = normalizeDeviceUuid(input.deviceUuid);
return prisma.elderDevice.upsert({
where: { deviceUuid },
update: {
displayName: input.displayName?.trim() || undefined,
appVersion: input.appVersion?.trim() || undefined,
lastSeenAt: new Date(),
},
create: {
deviceUuid,
displayName: input.displayName?.trim() || undefined,
appVersion: input.appVersion?.trim() || undefined,
lastSeenAt: new Date(),
},
});
}
export async function bindDeviceToCaregiver(
caregiverId: string,
rawDeviceValue: string,
) {
const deviceUuid = extractDeviceUuid(rawDeviceValue);
if (!deviceUuid) {
throw new Error("设备码格式不正确,请重新扫码或粘贴。");
}
const device = await ensureDeviceRegistration({ deviceUuid });
await prisma.deviceBinding.upsert({
where: {
caregiverId_elderDeviceId: {
caregiverId,
elderDeviceId: device.id,
},
},
update: {},
create: {
caregiverId,
elderDeviceId: device.id,
},
});
return device;
}
function parseImportance(rawImportance?: string | null) {
const normalizedImportance = rawImportance?.trim().toUpperCase();
switch (normalizedImportance) {
case MessageImportance.LOW:
return MessageImportance.LOW;
case MessageImportance.HIGH:
return MessageImportance.HIGH;
case MessageImportance.URGENT:
return MessageImportance.URGENT;
default:
return MessageImportance.NORMAL;
}
}
function buildFamilyMessageSummary(
messages: Array<{ content: string }>,
unreadCount: number,
) {
if (messages.length === 0) {
return "家里人暂时还没有新的留言。";
}
const preview = messages
.slice(0, 3)
.map((message, index) => `${index + 1}. ${message.content}`)
.join(" ");
if (unreadCount > 0) {
return `家里人给您留了${unreadCount}条新话:${preview}`;
}
return `家里人最近说:${preview}`;
}
export async function listFamilyMessagesForDevice(
rawDeviceValue: string,
take = 12,
) {
const deviceUuid = extractDeviceUuid(rawDeviceValue);
if (!deviceUuid) {
throw new Error("设备码格式不正确。");
}
const device = await ensureDeviceRegistration({ deviceUuid });
const messages = await prisma.familyMessage.findMany({
where: {
elderDeviceId: device.id,
direction: MessageDirection.FAMILY_TO_ELDER,
},
orderBy: { createdAt: "desc" },
take,
});
const unreadCount = await prisma.familyMessage.count({
where: {
elderDeviceId: device.id,
direction: MessageDirection.FAMILY_TO_ELDER,
readAt: null,
},
});
await prisma.familyMessage.updateMany({
where: {
elderDeviceId: device.id,
direction: MessageDirection.FAMILY_TO_ELDER,
deliveredAt: null,
},
data: {
deliveredAt: new Date(),
status: MessageStatus.DELIVERED,
},
});
const summarySource =
messages.filter((message) => message.readAt === null).length > 0
? messages.filter((message) => message.readAt === null)
: messages;
return {
device,
unreadCount,
summaryText: buildFamilyMessageSummary(summarySource, unreadCount),
messages,
};
}
export async function markFamilyMessagesAsRead(
rawDeviceValue: string,
messageIds?: string[],
) {
const deviceUuid = extractDeviceUuid(rawDeviceValue);
if (!deviceUuid) {
throw new Error("设备码格式不正确。");
}
const device = await ensureDeviceRegistration({ deviceUuid });
const now = new Date();
await prisma.familyMessage.updateMany({
where: {
elderDeviceId: device.id,
direction: MessageDirection.FAMILY_TO_ELDER,
readAt: null,
id: messageIds?.length ? { in: messageIds } : undefined,
},
data: {
readAt: now,
deliveredAt: now,
status: MessageStatus.READ,
},
});
}
export async function createFamilyMessageFromCaregiver(input: {
caregiverId: string;
rawDeviceValue: string;
content: string;
importance?: string;
}) {
const deviceUuid = extractDeviceUuid(input.rawDeviceValue);
if (!deviceUuid) {
throw new Error("设备码格式不正确,请重新扫码或粘贴。");
}
const binding = await prisma.deviceBinding.findFirst({
where: {
caregiverId: input.caregiverId,
elderDevice: {
deviceUuid,
},
},
include: {
elderDevice: true,
},
});
if (!binding) {
throw new Error("这位老人还没有绑定到当前家属账号。");
}
return prisma.familyMessage.create({
data: {
elderDeviceId: binding.elderDeviceId,
caregiverId: input.caregiverId,
direction: MessageDirection.FAMILY_TO_ELDER,
content: input.content.trim(),
importance: parseImportance(input.importance),
status: MessageStatus.PENDING,
source: "FAMILY_PANEL",
},
});
}
export async function recordUsageEvent(input: UsageEventInput) {
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
return prisma.usageEvent.create({
data: {
elderDeviceId: device.id,
eventType: input.eventType,
detailJson: input.detailJson || undefined,
},
});
}
export async function recordConversationTurns(input: ConversationTurnInput) {
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
const validTurns = input.turns
.map((turn) => ({
role: turn.role,
content: turn.content.trim(),
metaJson: turn.metaJson?.trim() || null,
createdAt: turn.createdAt ? new Date(turn.createdAt) : new Date(),
}))
.filter((turn) => turn.content.length > 0);
if (validTurns.length === 0) {
return { count: 0 };
}
const result = await prisma.conversationTurn.createMany({
data: validTurns.map((turn) => ({
elderDeviceId: device.id,
sessionId: input.sessionId?.trim() || null,
role: turn.role,
content: turn.content,
metaJson: turn.metaJson,
createdAt: turn.createdAt,
})),
});
return { count: result.count };
}
export async function recordToolCall(input: ToolCallInput) {
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
const result = await prisma.$transaction(async (tx) => {
const log = await tx.toolCallLog.create({
data: {
elderDeviceId: device.id,
sessionId: input.sessionId?.trim() || null,
callId: input.callId?.trim() || null,
toolName: input.toolName.trim(),
argumentsJson: JSON.stringify(input.arguments),
outputText: input.outputText?.trim() || null,
status: input.status,
},
});
await tx.usageEvent.create({
data: {
elderDeviceId: device.id,
eventType: UsageEventType.TOOL_CALLED,
detailJson: JSON.stringify({
toolName: input.toolName,
callId: input.callId || null,
status: input.status,
}),
},
});
const messageText =
typeof input.arguments.message === "string"
? input.arguments.message.trim()
: "";
let createdMessagePublicId: number | null = null;
if (
input.status === ToolCallStatus.SUCCESS &&
input.toolName === "leave_message_for_family" &&
messageText
) {
const createdMessage = await tx.familyMessage.create({
data: {
elderDeviceId: device.id,
direction: MessageDirection.ELDER_TO_FAMILY,
content: messageText,
recipientRelation:
typeof input.arguments.recipientRelation === "string"
? input.arguments.recipientRelation.trim()
: null,
importance: parseImportance(
typeof input.arguments.importance === "string"
? input.arguments.importance
: null,
),
status: MessageStatus.DELIVERED,
deliveredAt: new Date(),
source: "MODEL_TOOL",
},
});
createdMessagePublicId = createdMessage.publicId;
}
return {
log,
createdMessagePublicId,
};
});
if (result.createdMessagePublicId) {
void sendIncomingMessagePush(result.createdMessagePublicId).catch(() => {
return undefined;
});
}
return result.log;
}
export async function getCaregiverDashboard(caregiverId: string) {
const caregiver = await prisma.caregiverAccount.findUnique({
where: { id: caregiverId },
include: {
bindings: {
orderBy: { createdAt: "desc" },
include: {
elderDevice: {
include: {
messages: {
orderBy: { createdAt: "desc" },
take: 8,
},
usageEvents: {
orderBy: { createdAt: "desc" },
take: 6,
},
conversationTurns: {
orderBy: { createdAt: "desc" },
take: 10,
},
toolCalls: {
orderBy: { createdAt: "desc" },
take: 4,
},
_count: {
select: {
messages: true,
usageEvents: true,
conversationTurns: true,
toolCalls: true,
},
},
},
},
},
},
},
});
if (!caregiver) {
throw new Error("当前家属账号不存在。");
}
const elderDeviceIds = caregiver.bindings.map((binding) => binding.elderDeviceId);
const familyUnreadCounts = elderDeviceIds.length
? await prisma.familyMessage.groupBy({
by: ["elderDeviceId"],
_count: { _all: true },
where: {
elderDeviceId: { in: elderDeviceIds },
direction: MessageDirection.FAMILY_TO_ELDER,
readAt: null,
},
})
: [];
const elderUnreadCounts = elderDeviceIds.length
? await prisma.familyMessage.groupBy({
by: ["elderDeviceId"],
_count: { _all: true },
where: {
elderDeviceId: { in: elderDeviceIds },
direction: MessageDirection.ELDER_TO_FAMILY,
readAt: null,
},
})
: [];
const familyUnreadMap = new Map(
familyUnreadCounts.map((item) => [item.elderDeviceId, item._count._all]),
);
const elderUnreadMap = new Map(
elderUnreadCounts.map((item) => [item.elderDeviceId, item._count._all]),
);
return {
caregiver,
devices: caregiver.bindings.map((binding) => ({
...binding,
familyUnreadCount: familyUnreadMap.get(binding.elderDeviceId) || 0,
elderUnreadCount: elderUnreadMap.get(binding.elderDeviceId) || 0,
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
})),
};
}