优化路径,添加消息推送
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
import { MessageDirection, MessageStatus } from "@prisma/client";
|
||||
|
||||
import { createBindUrl, getCaregiverDashboard } from "@/lib/monitor-data";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const deviceSummarySelect = {
|
||||
id: true,
|
||||
deviceUuid: true,
|
||||
displayName: true,
|
||||
appVersion: true,
|
||||
lastSeenAt: true,
|
||||
} as const;
|
||||
|
||||
export async function getCaregiverOverview(caregiverId: string) {
|
||||
const dashboard = await getCaregiverDashboard(caregiverId);
|
||||
|
||||
const [recentIncomingMessages, recentOutgoingMessages] = await prisma.$transaction([
|
||||
prisma.familyMessage.findMany({
|
||||
where: {
|
||||
direction: MessageDirection.ELDER_TO_FAMILY,
|
||||
elderDevice: {
|
||||
bindings: {
|
||||
some: { caregiverId },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
elderDevice: {
|
||||
select: deviceSummarySelect,
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 4,
|
||||
}),
|
||||
prisma.familyMessage.findMany({
|
||||
where: {
|
||||
direction: MessageDirection.FAMILY_TO_ELDER,
|
||||
caregiverId,
|
||||
},
|
||||
include: {
|
||||
elderDevice: {
|
||||
select: deviceSummarySelect,
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 4,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
dashboard,
|
||||
recentIncomingMessages,
|
||||
recentOutgoingMessages,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCaregiverMessages(caregiverId: string) {
|
||||
const [messages, unreadIncomingCount, unreadOutgoingCount] = await prisma.$transaction([
|
||||
prisma.familyMessage.findMany({
|
||||
where: {
|
||||
elderDevice: {
|
||||
bindings: {
|
||||
some: { caregiverId },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
elderDevice: {
|
||||
select: deviceSummarySelect,
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }, { publicId: "desc" }],
|
||||
take: 120,
|
||||
}),
|
||||
prisma.familyMessage.count({
|
||||
where: {
|
||||
direction: MessageDirection.ELDER_TO_FAMILY,
|
||||
elderDevice: {
|
||||
bindings: {
|
||||
some: { caregiverId },
|
||||
},
|
||||
},
|
||||
readAt: null,
|
||||
},
|
||||
}),
|
||||
prisma.familyMessage.count({
|
||||
where: {
|
||||
direction: MessageDirection.FAMILY_TO_ELDER,
|
||||
caregiverId,
|
||||
readAt: null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
unreadIncomingCount,
|
||||
unreadOutgoingCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCaregiverMessageDetail(
|
||||
caregiverId: string,
|
||||
publicId: number,
|
||||
) {
|
||||
let message = await prisma.familyMessage.findFirst({
|
||||
where: {
|
||||
publicId,
|
||||
elderDevice: {
|
||||
bindings: {
|
||||
some: { caregiverId },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
elderDevice: {
|
||||
select: deviceSummarySelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (message.direction === MessageDirection.ELDER_TO_FAMILY && !message.readAt) {
|
||||
const now = new Date();
|
||||
message = await prisma.familyMessage.update({
|
||||
where: { id: message.id },
|
||||
data: {
|
||||
readAt: now,
|
||||
deliveredAt: message.deliveredAt ?? now,
|
||||
status: MessageStatus.READ,
|
||||
},
|
||||
include: {
|
||||
elderDevice: {
|
||||
select: deviceSummarySelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export async function getCaregiverDevices(caregiverId: string) {
|
||||
const dashboard = await getCaregiverDashboard(caregiverId);
|
||||
|
||||
return dashboard.devices;
|
||||
}
|
||||
|
||||
export async function getCaregiverDeviceDetail(
|
||||
caregiverId: string,
|
||||
deviceUuid: string,
|
||||
) {
|
||||
const binding = await prisma.deviceBinding.findFirst({
|
||||
where: {
|
||||
caregiverId,
|
||||
elderDevice: {
|
||||
deviceUuid,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
elderDevice: {
|
||||
include: {
|
||||
messages: {
|
||||
orderBy: [{ createdAt: "desc" }, { publicId: "desc" }],
|
||||
take: 14,
|
||||
},
|
||||
usageEvents: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
},
|
||||
conversationTurns: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 12,
|
||||
},
|
||||
toolCalls: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8,
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
messages: true,
|
||||
usageEvents: true,
|
||||
conversationTurns: true,
|
||||
toolCalls: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [familyUnreadCount, elderUnreadCount] = await prisma.$transaction([
|
||||
prisma.familyMessage.count({
|
||||
where: {
|
||||
elderDeviceId: binding.elderDeviceId,
|
||||
direction: MessageDirection.FAMILY_TO_ELDER,
|
||||
readAt: null,
|
||||
},
|
||||
}),
|
||||
prisma.familyMessage.count({
|
||||
where: {
|
||||
elderDeviceId: binding.elderDeviceId,
|
||||
direction: MessageDirection.ELDER_TO_FAMILY,
|
||||
readAt: null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
...binding,
|
||||
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
|
||||
familyUnreadCount,
|
||||
elderUnreadCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCaregiverActivity(caregiverId: string) {
|
||||
const deviceBindings = await prisma.deviceBinding.findMany({
|
||||
where: { caregiverId },
|
||||
select: {
|
||||
elderDeviceId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const elderDeviceIds = deviceBindings.map((binding) => binding.elderDeviceId);
|
||||
|
||||
if (elderDeviceIds.length === 0) {
|
||||
return {
|
||||
usageEvents: [],
|
||||
conversationTurns: [],
|
||||
toolCalls: [],
|
||||
};
|
||||
}
|
||||
|
||||
const [usageEvents, conversationTurns, toolCalls] = await prisma.$transaction([
|
||||
prisma.usageEvent.findMany({
|
||||
where: {
|
||||
elderDeviceId: { in: elderDeviceIds },
|
||||
},
|
||||
include: {
|
||||
elderDevice: {
|
||||
select: deviceSummarySelect,
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 24,
|
||||
}),
|
||||
prisma.conversationTurn.findMany({
|
||||
where: {
|
||||
elderDeviceId: { in: elderDeviceIds },
|
||||
},
|
||||
include: {
|
||||
elderDevice: {
|
||||
select: deviceSummarySelect,
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 24,
|
||||
}),
|
||||
prisma.toolCallLog.findMany({
|
||||
where: {
|
||||
elderDeviceId: { in: elderDeviceIds },
|
||||
},
|
||||
include: {
|
||||
elderDevice: {
|
||||
select: deviceSummarySelect,
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 24,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
usageEvents,
|
||||
conversationTurns,
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
+18
-3
@@ -7,6 +7,7 @@ import {
|
||||
UsageEventType,
|
||||
} from "@prisma/client";
|
||||
|
||||
import { sendIncomingMessagePush } from "@/lib/push";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type RegisterDeviceInput = {
|
||||
@@ -338,7 +339,7 @@ export async function recordConversationTurns(input: ConversationTurnInput) {
|
||||
export async function recordToolCall(input: ToolCallInput) {
|
||||
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const log = await tx.toolCallLog.create({
|
||||
data: {
|
||||
elderDeviceId: device.id,
|
||||
@@ -367,13 +368,14 @@ export async function recordToolCall(input: ToolCallInput) {
|
||||
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
|
||||
) {
|
||||
await tx.familyMessage.create({
|
||||
const createdMessage = await tx.familyMessage.create({
|
||||
data: {
|
||||
elderDeviceId: device.id,
|
||||
direction: MessageDirection.ELDER_TO_FAMILY,
|
||||
@@ -392,10 +394,23 @@ export async function recordToolCall(input: ToolCallInput) {
|
||||
source: "MODEL_TOOL",
|
||||
},
|
||||
});
|
||||
|
||||
createdMessagePublicId = createdMessage.publicId;
|
||||
}
|
||||
|
||||
return log;
|
||||
return {
|
||||
log,
|
||||
createdMessagePublicId,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.createdMessagePublicId) {
|
||||
void sendIncomingMessagePush(result.createdMessagePublicId).catch(() => {
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
return result.log;
|
||||
}
|
||||
|
||||
export async function getCaregiverDashboard(caregiverId: string) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import {
|
||||
buildCaregiverSessionBootstrapPath,
|
||||
getCaregiverSession,
|
||||
} from "@/lib/session";
|
||||
|
||||
export async function requireCaregiverSession(redirectTo: string) {
|
||||
const caregiver = await getCaregiverSession();
|
||||
|
||||
if (!caregiver) {
|
||||
redirect(buildCaregiverSessionBootstrapPath(redirectTo));
|
||||
}
|
||||
|
||||
return caregiver;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
ConversationRole,
|
||||
MessageDirection,
|
||||
MessageImportance,
|
||||
MessageStatus,
|
||||
UsageEventType,
|
||||
} from "@prisma/client";
|
||||
|
||||
export function formatDateTime(value: Date | string | null | undefined) {
|
||||
if (!value) {
|
||||
return "暂时还没有记录";
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function getImportanceLabel(level: MessageImportance | string) {
|
||||
switch (level) {
|
||||
case MessageImportance.LOW:
|
||||
return "温和提醒";
|
||||
case MessageImportance.HIGH:
|
||||
return "希望尽快看到";
|
||||
case MessageImportance.URGENT:
|
||||
return "需要尽快联系";
|
||||
default:
|
||||
return "日常问候";
|
||||
}
|
||||
}
|
||||
|
||||
export function getUsageLabel(eventType: UsageEventType | string) {
|
||||
switch (eventType) {
|
||||
case UsageEventType.APP_OPEN:
|
||||
return "打开了老人端应用";
|
||||
case UsageEventType.SETTINGS_OPENED:
|
||||
return "打开了家人连接页";
|
||||
case UsageEventType.AI_SESSION_STARTED:
|
||||
return "开始了一次陪伴对话";
|
||||
case UsageEventType.AI_SESSION_ENDED:
|
||||
return "结束了一次陪伴对话";
|
||||
case UsageEventType.CAMERA_ENABLED:
|
||||
return "打开了镜头模式";
|
||||
case UsageEventType.CAMERA_DISABLED:
|
||||
return "关闭了镜头模式";
|
||||
case UsageEventType.TOOL_CALLED:
|
||||
return "模型调用了一次桥接工具";
|
||||
default:
|
||||
return eventType;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConversationRoleLabel(role: ConversationRole | string) {
|
||||
switch (role) {
|
||||
case ConversationRole.USER:
|
||||
return "老人说";
|
||||
case ConversationRole.TOOL:
|
||||
return "工具结果";
|
||||
default:
|
||||
return "数字人回复";
|
||||
}
|
||||
}
|
||||
|
||||
export function getMessageDirectionLabel(direction: MessageDirection | string) {
|
||||
return direction === MessageDirection.ELDER_TO_FAMILY
|
||||
? "老人给家里"
|
||||
: "家属给老人";
|
||||
}
|
||||
|
||||
export function getMessageStatusLabel(input: {
|
||||
direction: MessageDirection | string;
|
||||
status: MessageStatus | string;
|
||||
readAt?: Date | string | null;
|
||||
deliveredAt?: Date | string | null;
|
||||
}) {
|
||||
if (input.direction === MessageDirection.ELDER_TO_FAMILY) {
|
||||
return input.readAt ? "家属已打开" : "等待家属查看";
|
||||
}
|
||||
|
||||
if (input.readAt) {
|
||||
return "老人已看过";
|
||||
}
|
||||
|
||||
if (input.deliveredAt) {
|
||||
return "已送到老人设备";
|
||||
}
|
||||
|
||||
if (input.status === MessageStatus.PENDING) {
|
||||
return "等待老人设备拉取";
|
||||
}
|
||||
|
||||
return "等待老人查看";
|
||||
}
|
||||
|
||||
export function truncateText(content: string, maxLength = 88) {
|
||||
if (content.length <= maxLength) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return `${content.slice(0, maxLength).trimEnd()}...`;
|
||||
}
|
||||
|
||||
export function getMessageHref(publicId: number) {
|
||||
return `/messages/${publicId}`;
|
||||
}
|
||||
|
||||
export function getDeviceName(displayName?: string | null) {
|
||||
return displayName?.trim() || "未命名陪伴设备";
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
import { MessageDirection } from "@prisma/client";
|
||||
import webpush from "web-push";
|
||||
|
||||
import { getDeviceName, truncateText } from "@/lib/panel-format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type SerializablePushSubscription = {
|
||||
endpoint: string;
|
||||
expirationTime?: number | null;
|
||||
keys?: {
|
||||
p256dh?: string;
|
||||
auth?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const APP_BASE_URL = (
|
||||
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
let vapidReady = false;
|
||||
|
||||
function hasPushConfiguration() {
|
||||
return Boolean(
|
||||
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY &&
|
||||
process.env.VAPID_PRIVATE_KEY &&
|
||||
process.env.VAPID_SUBJECT,
|
||||
);
|
||||
}
|
||||
|
||||
function ensureVapidDetails() {
|
||||
if (!hasPushConfiguration()) {
|
||||
throw new Error("VAPID 配置尚未完成,暂时不能发送推送通知。");
|
||||
}
|
||||
|
||||
if (vapidReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
webpush.setVapidDetails(
|
||||
process.env.VAPID_SUBJECT!,
|
||||
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
|
||||
process.env.VAPID_PRIVATE_KEY!,
|
||||
);
|
||||
vapidReady = true;
|
||||
}
|
||||
|
||||
function normalizeSubscription(input: SerializablePushSubscription) {
|
||||
const endpoint = input.endpoint?.trim();
|
||||
const p256dh = input.keys?.p256dh?.trim();
|
||||
const auth = input.keys?.auth?.trim();
|
||||
|
||||
if (!endpoint || !p256dh || !auth) {
|
||||
throw new Error("推送订阅信息不完整。");
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
p256dh,
|
||||
auth,
|
||||
expirationTime:
|
||||
typeof input.expirationTime === "number"
|
||||
? new Date(input.expirationTime)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function absoluteUrl(path: string) {
|
||||
if (/^https?:\/\//i.test(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return `${APP_BASE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
export function getPushPublicKey() {
|
||||
return process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
|
||||
}
|
||||
|
||||
export function isPushAvailable() {
|
||||
return hasPushConfiguration();
|
||||
}
|
||||
|
||||
export async function savePushSubscription(input: {
|
||||
caregiverId: string;
|
||||
subscription: SerializablePushSubscription;
|
||||
userAgent?: string | null;
|
||||
}) {
|
||||
const subscription = normalizeSubscription(input.subscription);
|
||||
|
||||
return prisma.pushSubscription.upsert({
|
||||
where: { endpoint: subscription.endpoint },
|
||||
update: {
|
||||
caregiverId: input.caregiverId,
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
expirationTime: subscription.expirationTime,
|
||||
userAgent: input.userAgent?.trim() || null,
|
||||
},
|
||||
create: {
|
||||
caregiverId: input.caregiverId,
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
expirationTime: subscription.expirationTime,
|
||||
userAgent: input.userAgent?.trim() || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function removePushSubscription(input: {
|
||||
caregiverId: string;
|
||||
endpoint: string;
|
||||
}) {
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: {
|
||||
caregiverId: input.caregiverId,
|
||||
endpoint: input.endpoint.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendIncomingMessagePush(publicId: number) {
|
||||
if (!hasPushConfiguration()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = await prisma.familyMessage.findUnique({
|
||||
where: { publicId },
|
||||
select: {
|
||||
publicId: true,
|
||||
content: true,
|
||||
direction: true,
|
||||
elderDeviceId: true,
|
||||
elderDevice: {
|
||||
select: {
|
||||
deviceUuid: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!message || message.direction !== MessageDirection.ELDER_TO_FAMILY) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: {
|
||||
caregiver: {
|
||||
bindings: {
|
||||
some: {
|
||||
elderDeviceId: message.elderDeviceId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureVapidDetails();
|
||||
|
||||
const deviceName = getDeviceName(message.elderDevice.displayName);
|
||||
const payload = JSON.stringify({
|
||||
title: `${deviceName} 发来一条新留言`,
|
||||
body: truncateText(message.content, 72),
|
||||
url: absoluteUrl(`/messages/${message.publicId}`),
|
||||
icon: absoluteUrl("/pwa/icon-192x192.png"),
|
||||
badge: absoluteUrl("/pwa/badge-96x96.png"),
|
||||
tag: `message-${message.publicId}`,
|
||||
});
|
||||
|
||||
await Promise.allSettled(
|
||||
subscriptions.map(async (subscription) => {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: subscription.endpoint,
|
||||
expirationTime: subscription.expirationTime?.getTime() || null,
|
||||
keys: {
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
},
|
||||
payload,
|
||||
{
|
||||
TTL: 60 * 30,
|
||||
urgency: "high",
|
||||
topic: `msg-${message.publicId}`,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const statusCode =
|
||||
error && typeof error === "object" && "statusCode" in error
|
||||
? Number((error as { statusCode?: number }).statusCode)
|
||||
: 0;
|
||||
|
||||
if (statusCode === 404 || statusCode === 410) {
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: { endpoint: subscription.endpoint },
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user