mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
✨ feat: add notification system (temporarily disabled) (#13301)
This commit is contained in:
parent
3f148005e4
commit
9f36fe95ac
32 changed files with 797 additions and 10 deletions
12
locales/en-US/notification.json
Normal file
12
locales/en-US/notification.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"image_generation_completed": "Image \"{{prompt}}\" generated successfully",
|
||||
"image_generation_completed_title": "Image Generated",
|
||||
"inbox.archiveAll": "Archive all",
|
||||
"inbox.empty": "No notifications yet",
|
||||
"inbox.emptyUnread": "No unread notifications",
|
||||
"inbox.filterUnread": "Show unread only",
|
||||
"inbox.markAllRead": "Mark all as read",
|
||||
"inbox.title": "Notifications",
|
||||
"video_generation_completed": "Video \"{{prompt}}\" generated successfully",
|
||||
"video_generation_completed_title": "Video Generated"
|
||||
}
|
||||
|
|
@ -443,6 +443,12 @@
|
|||
"myAgents.status.published": "Published",
|
||||
"myAgents.status.unpublished": "Unpublished",
|
||||
"myAgents.title": "My Published Agents",
|
||||
"notification.email.desc": "Receive email notifications when important events occur",
|
||||
"notification.email.title": "Email Notifications",
|
||||
"notification.enabled": "Enabled",
|
||||
"notification.inbox.desc": "Show notifications in the in-app inbox",
|
||||
"notification.inbox.title": "Inbox Notifications",
|
||||
"notification.title": "Notification Channels",
|
||||
"plugin.addMCPPlugin": "Add MCP",
|
||||
"plugin.addTooltip": "Custom Skills",
|
||||
"plugin.clearDeprecated": "Remove Deprecated Skills",
|
||||
|
|
@ -807,6 +813,7 @@
|
|||
"tab.manualFill": "Manually Fill In",
|
||||
"tab.manualFill.desc": "Configure a custom MCP skill manually",
|
||||
"tab.memory": "Memory",
|
||||
"tab.notification": "Notifications",
|
||||
"tab.profile": "My Account",
|
||||
"tab.provider": "Provider",
|
||||
"tab.proxy": "Proxy",
|
||||
|
|
|
|||
12
locales/zh-CN/notification.json
Normal file
12
locales/zh-CN/notification.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"image_generation_completed": "图片 \"{{prompt}}\" 已生成完成",
|
||||
"image_generation_completed_title": "图片已生成",
|
||||
"inbox.archiveAll": "全部归档",
|
||||
"inbox.empty": "暂无通知",
|
||||
"inbox.emptyUnread": "没有未读通知",
|
||||
"inbox.filterUnread": "仅显示未读",
|
||||
"inbox.markAllRead": "全部标为已读",
|
||||
"inbox.title": "通知",
|
||||
"video_generation_completed": "视频 \"{{prompt}}\" 已生成完成",
|
||||
"video_generation_completed_title": "视频已生成"
|
||||
}
|
||||
|
|
@ -443,6 +443,12 @@
|
|||
"myAgents.status.published": "已上架",
|
||||
"myAgents.status.unpublished": "未上架",
|
||||
"myAgents.title": "我发布的助理",
|
||||
"notification.email.desc": "当重要事件发生时接收邮件通知",
|
||||
"notification.email.title": "邮件通知",
|
||||
"notification.enabled": "启用",
|
||||
"notification.inbox.desc": "在应用内收件箱中显示通知",
|
||||
"notification.inbox.title": "站内通知",
|
||||
"notification.title": "通知渠道",
|
||||
"plugin.addMCPPlugin": "添加 MCP",
|
||||
"plugin.addTooltip": "自定义技能",
|
||||
"plugin.clearDeprecated": "移除无效技能",
|
||||
|
|
@ -807,6 +813,7 @@
|
|||
"tab.manualFill": "自行填写内容",
|
||||
"tab.manualFill.desc": "手动配置自定义 MCP 技能",
|
||||
"tab.memory": "记忆设置",
|
||||
"tab.notification": "通知",
|
||||
"tab.profile": "我的账号",
|
||||
"tab.provider": "AI 服务商",
|
||||
"tab.proxy": "网络代理",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
DEFAULT_HOTKEY_CONFIG,
|
||||
DEFAULT_IMAGE_CONFIG,
|
||||
DEFAULT_MEMORY_SETTINGS,
|
||||
DEFAULT_NOTIFICATION_SETTINGS,
|
||||
DEFAULT_SYSTEM_AGENT_CONFIG,
|
||||
DEFAULT_TOOL_CONFIG,
|
||||
DEFAULT_TTS_CONFIG,
|
||||
|
|
@ -19,6 +20,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
|
|||
keyVaults: {},
|
||||
languageModel: DEFAULT_LLM_CONFIG,
|
||||
memory: DEFAULT_MEMORY_SETTINGS,
|
||||
notification: DEFAULT_NOTIFICATION_SETTINGS,
|
||||
systemAgent: DEFAULT_SYSTEM_AGENT_CONFIG,
|
||||
tool: DEFAULT_TOOL_CONFIG,
|
||||
tts: DEFAULT_TTS_CONFIG,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export * from './image';
|
|||
export * from './knowledge';
|
||||
export * from './llm';
|
||||
export * from './memory';
|
||||
export * from './notification';
|
||||
export * from './systemAgent';
|
||||
export * from './tool';
|
||||
export * from './tts';
|
||||
|
|
|
|||
22
packages/const/src/settings/notification.ts
Normal file
22
packages/const/src/settings/notification.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { NotificationSettings } from '@lobechat/types';
|
||||
|
||||
export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
|
||||
email: {
|
||||
enabled: true,
|
||||
items: {
|
||||
generation: {
|
||||
image_generation_completed: true,
|
||||
video_generation_completed: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
inbox: {
|
||||
enabled: true,
|
||||
items: {
|
||||
generation: {
|
||||
image_generation_completed: true,
|
||||
video_generation_completed: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
128
packages/database/src/models/notification.ts
Normal file
128
packages/database/src/models/notification.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { and, count, desc, eq, inArray, lt, or } from 'drizzle-orm';
|
||||
|
||||
import type { NewNotification, NewNotificationDelivery } from '../schemas/notification';
|
||||
import { notificationDeliveries, notifications } from '../schemas/notification';
|
||||
import type { LobeChatDatabase } from '../type';
|
||||
|
||||
export class NotificationModel {
|
||||
private readonly userId: string;
|
||||
private readonly db: LobeChatDatabase;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
async list(
|
||||
opts: { category?: string; cursor?: string; limit?: number; unreadOnly?: boolean } = {},
|
||||
) {
|
||||
const { cursor, limit = 20, category, unreadOnly } = opts;
|
||||
|
||||
const conditions = [eq(notifications.userId, this.userId), eq(notifications.isArchived, false)];
|
||||
|
||||
if (unreadOnly) {
|
||||
conditions.push(eq(notifications.isRead, false));
|
||||
}
|
||||
|
||||
if (category) {
|
||||
conditions.push(eq(notifications.category, category));
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
const cursorRow = await this.db
|
||||
.select({ createdAt: notifications.createdAt, id: notifications.id })
|
||||
.from(notifications)
|
||||
.where(and(eq(notifications.id, cursor), eq(notifications.userId, this.userId)))
|
||||
.limit(1);
|
||||
|
||||
if (cursorRow[0]) {
|
||||
// Composite cursor to handle identical createdAt timestamps
|
||||
const { createdAt: cursorTime, id: cursorId } = cursorRow[0];
|
||||
conditions.push(
|
||||
or(
|
||||
lt(notifications.createdAt, cursorTime),
|
||||
and(eq(notifications.createdAt, cursorTime), lt(notifications.id, cursorId)),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(notifications)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(notifications.createdAt), desc(notifications.id))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async getUnreadCount(): Promise<number> {
|
||||
const [result] = await this.db
|
||||
.select({ count: count() })
|
||||
.from(notifications)
|
||||
.where(
|
||||
and(
|
||||
eq(notifications.userId, this.userId),
|
||||
eq(notifications.isRead, false),
|
||||
eq(notifications.isArchived, false),
|
||||
),
|
||||
);
|
||||
|
||||
return result?.count ?? 0;
|
||||
}
|
||||
|
||||
async markAsRead(ids: string[]) {
|
||||
if (ids.length === 0) return;
|
||||
|
||||
return this.db
|
||||
.update(notifications)
|
||||
.set({ isRead: true, updatedAt: new Date() })
|
||||
.where(and(eq(notifications.userId, this.userId), inArray(notifications.id, ids)));
|
||||
}
|
||||
|
||||
async markAllAsRead() {
|
||||
return this.db
|
||||
.update(notifications)
|
||||
.set({ isRead: true, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(notifications.userId, this.userId),
|
||||
eq(notifications.isRead, false),
|
||||
eq(notifications.isArchived, false),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async archive(id: string) {
|
||||
return this.db
|
||||
.update(notifications)
|
||||
.set({ isArchived: true, updatedAt: new Date() })
|
||||
.where(and(eq(notifications.id, id), eq(notifications.userId, this.userId)));
|
||||
}
|
||||
|
||||
async archiveAll() {
|
||||
return this.db
|
||||
.update(notifications)
|
||||
.set({ isArchived: true, updatedAt: new Date() })
|
||||
.where(and(eq(notifications.userId, this.userId), eq(notifications.isArchived, false)));
|
||||
}
|
||||
|
||||
// ─── Write-side (used by NotificationService in cloud) ─────────
|
||||
|
||||
async create(data: Omit<NewNotification, 'userId'>) {
|
||||
const [result] = await this.db
|
||||
.insert(notifications)
|
||||
.values({ ...data, userId: this.userId })
|
||||
.onConflictDoNothing({
|
||||
target: [notifications.userId, notifications.dedupeKey],
|
||||
})
|
||||
.returning();
|
||||
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
async createDelivery(data: NewNotificationDelivery) {
|
||||
const [result] = await this.db.insert(notificationDeliveries).values(data).returning();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import type { UserKeyVaults } from './keyVaults';
|
|||
import type { MarketAuthTokens } from './market';
|
||||
import type { UserMemorySettings } from './memory';
|
||||
import type { UserModelProviderConfig } from './modelProvider';
|
||||
import type { NotificationSettings } from './notification';
|
||||
import type { UserSystemAgentConfig } from './systemAgent';
|
||||
import type { UserToolConfig } from './tool';
|
||||
import type { UserTTSConfig } from './tts';
|
||||
|
|
@ -22,6 +23,7 @@ export * from './keyVaults';
|
|||
export * from './market';
|
||||
export * from './memory';
|
||||
export * from './modelProvider';
|
||||
export * from './notification';
|
||||
export * from './sync';
|
||||
export * from './systemAgent';
|
||||
export * from './tool';
|
||||
|
|
@ -39,6 +41,7 @@ export interface UserSettings {
|
|||
languageModel: UserModelProviderConfig;
|
||||
market?: MarketAuthTokens;
|
||||
memory?: UserMemorySettings;
|
||||
notification?: NotificationSettings;
|
||||
systemAgent: UserSystemAgentConfig;
|
||||
tool: UserToolConfig;
|
||||
tts: UserTTSConfig;
|
||||
|
|
@ -58,6 +61,7 @@ export const UserSettingsSchema = z
|
|||
languageModel: z.any().optional(),
|
||||
market: z.any().optional(),
|
||||
memory: z.any().optional(),
|
||||
notification: z.any().optional(),
|
||||
systemAgent: z.any().optional(),
|
||||
tool: z.any().optional(),
|
||||
tts: z.any().optional(),
|
||||
|
|
|
|||
10
packages/types/src/user/settings/notification.ts
Normal file
10
packages/types/src/user/settings/notification.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export interface NotificationChannelSettings {
|
||||
enabled?: boolean;
|
||||
/** Per-type overrides grouped by category. Missing = use scenario default (true) */
|
||||
items?: Record<string, Record<string, boolean>>;
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
email?: NotificationChannelSettings;
|
||||
inbox?: NotificationChannelSettings;
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { type RuntimeVideoGenParams } from 'model-bank';
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { chargeAfterGenerate } from '@/business/server/video-generation/chargeAfterGenerate';
|
||||
import { notifyVideoCompleted } from '@/business/server/video-generation/notifyVideoCompleted';
|
||||
import { AsyncTaskModel } from '@/database/models/asyncTask';
|
||||
import { GenerationModel } from '@/database/models/generation';
|
||||
import { generationBatches } from '@/database/schemas';
|
||||
|
|
@ -201,6 +202,14 @@ export const POST = async (req: Request, { params }: { params: Promise<{ provide
|
|||
status: AsyncTaskStatus.Success,
|
||||
});
|
||||
|
||||
notifyVideoCompleted({
|
||||
generationBatchId: generation.generationBatchId!,
|
||||
model: resolvedModel,
|
||||
prompt: batch?.prompt ?? '',
|
||||
topicId: batch?.generationTopicId,
|
||||
userId: asyncTask.userId,
|
||||
}).catch((err) => console.error('[video-webhook] notification failed:', err));
|
||||
|
||||
// Charge after successful video generation
|
||||
try {
|
||||
await chargeAfterGenerate({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
const Notification = () => null;
|
||||
|
||||
export default Notification;
|
||||
11
src/business/server/image-generation/notifyImageCompleted.ts
Normal file
11
src/business/server/image-generation/notifyImageCompleted.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
interface NotifyImageCompletedParams {
|
||||
duration: number;
|
||||
generationBatchId: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
topicId?: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
export async function notifyImageCompleted(params: NotifyImageCompletedParams): Promise<void> {}
|
||||
10
src/business/server/video-generation/notifyVideoCompleted.ts
Normal file
10
src/business/server/video-generation/notifyVideoCompleted.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
interface NotifyVideoCompletedParams {
|
||||
generationBatchId: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
topicId?: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
export async function notifyVideoCompleted(params: NotifyVideoCompletedParams): Promise<void> {}
|
||||
|
|
@ -128,15 +128,7 @@ const SideBarHeaderLayout = memo<SideBarHeaderLayoutProps>(
|
|||
padding={6}
|
||||
>
|
||||
{leftContent}
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
gap={2}
|
||||
justify={'flex-end'}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Flexbox horizontal align={'center'} gap={2} justify={'flex-end'}>
|
||||
{showTogglePanelButton && <ToggleLeftPanelButton />}
|
||||
{right}
|
||||
</Flexbox>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import metadata from './metadata';
|
|||
import migration from './migration';
|
||||
import modelProvider from './modelProvider';
|
||||
import models from './models';
|
||||
import notification from './notification';
|
||||
import oauth from './oauth';
|
||||
import onboarding from './onboarding';
|
||||
import plugin from './plugin';
|
||||
|
|
@ -72,6 +73,7 @@ const resources = {
|
|||
migration,
|
||||
modelProvider,
|
||||
models,
|
||||
notification,
|
||||
oauth,
|
||||
onboarding,
|
||||
plugin,
|
||||
|
|
|
|||
12
src/locales/default/notification.ts
Normal file
12
src/locales/default/notification.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export default {
|
||||
'image_generation_completed': 'Image "{{prompt}}" generated successfully',
|
||||
'image_generation_completed_title': 'Image Generated',
|
||||
'inbox.archiveAll': 'Archive all',
|
||||
'inbox.empty': 'No notifications yet',
|
||||
'inbox.emptyUnread': 'No unread notifications',
|
||||
'inbox.filterUnread': 'Show unread only',
|
||||
'inbox.markAllRead': 'Mark all as read',
|
||||
'inbox.title': 'Notifications',
|
||||
'video_generation_completed': 'Video "{{prompt}}" generated successfully',
|
||||
'video_generation_completed_title': 'Video Generated',
|
||||
} as const;
|
||||
|
|
@ -450,6 +450,12 @@ export default {
|
|||
'memory.enabled.title': 'Enable Memory',
|
||||
'memory.title': 'Memory Settings',
|
||||
'message.success': 'Update successful',
|
||||
'notification.enabled': 'Enabled',
|
||||
'notification.email.desc': 'Receive email notifications when important events occur',
|
||||
'notification.email.title': 'Email Notifications',
|
||||
'notification.inbox.desc': 'Show notifications in the in-app inbox',
|
||||
'notification.inbox.title': 'Inbox Notifications',
|
||||
'notification.title': 'Notification Channels',
|
||||
'myAgents.actions.cancel': 'Cancel',
|
||||
'myAgents.actions.confirmDeprecate': 'Confirm Deprecate',
|
||||
'myAgents.actions.deprecate': 'Deprecate Permanently',
|
||||
|
|
@ -927,6 +933,7 @@ When I am ___, I need ___
|
|||
'tab.manualFill': 'Manually Fill In',
|
||||
'tab.manualFill.desc': 'Configure a custom MCP skill manually',
|
||||
'tab.memory': 'Memory',
|
||||
'tab.notification': 'Notifications',
|
||||
'tab.profile': 'My Account',
|
||||
'tab.provider': 'Provider',
|
||||
'tab.proxy': 'Proxy',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
'use client';
|
||||
|
||||
import { ActionIcon } from '@lobehub/ui';
|
||||
import { Badge } from 'antd';
|
||||
import { BellIcon } from 'lucide-react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens';
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { notificationService } from '@/services/notification';
|
||||
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
|
||||
import InboxDrawer from './InboxDrawer';
|
||||
import { UNREAD_COUNT_KEY } from './InboxDrawer/constants';
|
||||
|
||||
const InboxButton = memo(() => {
|
||||
const { t } = useTranslation('notification');
|
||||
const [open, setOpen] = useState(false);
|
||||
const enableBusinessFeatures = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures);
|
||||
|
||||
const { data: unreadCount = 0 } = useClientDataSWR<number>(
|
||||
enableBusinessFeatures ? UNREAD_COUNT_KEY : null,
|
||||
() => notificationService.getUnreadCount(),
|
||||
{ refreshInterval: 10_000 },
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
if (!enableBusinessFeatures) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Badge count={unreadCount} offset={[-4, 4]} size="small">
|
||||
<ActionIcon
|
||||
icon={BellIcon}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
title={t('inbox.title')}
|
||||
onClick={handleToggle}
|
||||
/>
|
||||
</Badge>
|
||||
<InboxDrawer open={open} onClose={handleClose} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default InboxButton;
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
'use client';
|
||||
|
||||
import { Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { BellOffIcon } from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { VList, type VListHandle } from 'virtua';
|
||||
|
||||
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
|
||||
import { notificationService } from '@/services/notification';
|
||||
|
||||
import { FETCH_KEY } from './constants';
|
||||
import NotificationItem from './NotificationItem';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
interface ContentProps {
|
||||
onArchive: (id: string) => void;
|
||||
onMarkAsRead: (id: string) => void;
|
||||
open: boolean;
|
||||
unreadOnly?: boolean;
|
||||
}
|
||||
|
||||
const Content = memo<ContentProps>(({ open, unreadOnly, onMarkAsRead, onArchive }) => {
|
||||
const { t } = useTranslation('notification');
|
||||
const virtuaRef = useRef<VListHandle>(null);
|
||||
|
||||
const getKey = useCallback(
|
||||
(pageIndex: number, previousPageData: any[] | null) => {
|
||||
if (!open) return null;
|
||||
if (previousPageData && previousPageData.length < PAGE_SIZE) return null;
|
||||
|
||||
if (pageIndex === 0) return [FETCH_KEY, undefined, unreadOnly] as const;
|
||||
|
||||
const lastItem = previousPageData?.at(-1);
|
||||
return [FETCH_KEY, lastItem?.id, unreadOnly] as const;
|
||||
},
|
||||
[open, unreadOnly],
|
||||
);
|
||||
|
||||
const {
|
||||
data: pages,
|
||||
isLoading,
|
||||
isValidating,
|
||||
setSize,
|
||||
} = useSWRInfinite(getKey, async ([, cursor, filterUnread]) => {
|
||||
return notificationService.list({
|
||||
cursor: cursor as string | undefined,
|
||||
limit: PAGE_SIZE,
|
||||
unreadOnly: filterUnread,
|
||||
});
|
||||
});
|
||||
|
||||
// Reset scroll position and pagination when filter changes
|
||||
useEffect(() => {
|
||||
setSize(1);
|
||||
virtuaRef.current?.scrollTo(0);
|
||||
}, [unreadOnly, setSize]);
|
||||
|
||||
const notifications = pages?.flat() ?? [];
|
||||
const hasMore = pages ? pages.at(-1)?.length === PAGE_SIZE : false;
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const ref = virtuaRef.current;
|
||||
if (!ref || !hasMore || isValidating) return;
|
||||
|
||||
const bottomVisibleIndex = ref.findItemIndex(ref.scrollOffset + ref.viewportSize);
|
||||
if (bottomVisibleIndex + 5 > notifications.length) {
|
||||
setSize((prev) => prev + 1);
|
||||
}
|
||||
}, [hasMore, isValidating, notifications.length, setSize]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Flexbox gap={1} paddingBlock={1} paddingInline={4}>
|
||||
<SkeletonList rows={5} />
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
if (notifications.length === 0) {
|
||||
return (
|
||||
<Flexbox align="center" gap={12} justify="center" paddingBlock={48}>
|
||||
<Icon color={cssVar.colorTextQuaternary} icon={BellOffIcon} size={40} />
|
||||
<Text type="secondary">{t(unreadOnly ? 'inbox.emptyUnread' : 'inbox.empty')}</Text>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VList ref={virtuaRef} style={{ height: '100%' }} onScroll={handleScroll}>
|
||||
{notifications.map((item) => (
|
||||
<Flexbox key={item.id} padding="4px 8px">
|
||||
<NotificationItem
|
||||
actionUrl={item.actionUrl}
|
||||
content={item.content}
|
||||
createdAt={item.createdAt}
|
||||
id={item.id}
|
||||
isRead={item.isRead}
|
||||
title={item.title}
|
||||
type={item.type}
|
||||
onArchive={onArchive}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
/>
|
||||
</Flexbox>
|
||||
))}
|
||||
{isValidating && (
|
||||
<Flexbox padding="4px 8px">
|
||||
<SkeletonList rows={2} />
|
||||
</Flexbox>
|
||||
)}
|
||||
</VList>
|
||||
);
|
||||
});
|
||||
|
||||
Content.displayName = 'InboxDrawerContent';
|
||||
|
||||
export default Content;
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
'use client';
|
||||
|
||||
import { ActionIcon, Block, Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import dayjs from 'dayjs';
|
||||
import { ArchiveIcon, BellIcon, ImageIcon, VideoIcon } from 'lucide-react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const ACTION_CLASS_NAME = 'notification-item-actions';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
container: css`
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
.${ACTION_CLASS_NAME} {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ${cssVar.motionEaseOut};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.${ACTION_CLASS_NAME} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
unreadDot: css`
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
|
||||
background: ${cssVar.colorPrimary};
|
||||
`,
|
||||
}));
|
||||
|
||||
const TYPE_ICON_MAP: Record<string, typeof BellIcon> = {
|
||||
image_generation_completed: ImageIcon,
|
||||
video_generation_completed: VideoIcon,
|
||||
};
|
||||
|
||||
interface NotificationItemProps {
|
||||
actionUrl?: string | null;
|
||||
content: string;
|
||||
createdAt: Date | string;
|
||||
id: string;
|
||||
isRead: boolean;
|
||||
onArchive: (id: string) => void;
|
||||
onMarkAsRead: (id: string) => void;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const NotificationItem = memo<NotificationItemProps>(
|
||||
({ id, type, title, content, createdAt, isRead, actionUrl, onMarkAsRead, onArchive }) => {
|
||||
const navigate = useNavigate();
|
||||
const TypeIcon = TYPE_ICON_MAP[type] || BellIcon;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!isRead) onMarkAsRead(id);
|
||||
if (actionUrl) navigate(actionUrl);
|
||||
}, [id, isRead, actionUrl, onMarkAsRead, navigate]);
|
||||
|
||||
const handleArchive = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onArchive(id);
|
||||
},
|
||||
[id, onArchive],
|
||||
);
|
||||
|
||||
return (
|
||||
<Block
|
||||
clickable
|
||||
className={styles.container}
|
||||
gap={4}
|
||||
paddingBlock={8}
|
||||
paddingInline={12}
|
||||
variant="borderless"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Flexbox horizontal align="flex-start" gap={8}>
|
||||
<Icon
|
||||
color={cssVar.colorTextDescription}
|
||||
icon={TypeIcon}
|
||||
size={18}
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
<Flexbox flex={1} gap={4} style={{ overflow: 'hidden' }}>
|
||||
<Flexbox horizontal align="center" gap={4} justify="space-between">
|
||||
<Flexbox horizontal align="center" flex={1} gap={6} style={{ overflow: 'hidden' }}>
|
||||
{!isRead && <span className={styles.unreadDot} />}
|
||||
<Text ellipsis style={{ fontWeight: isRead ? 400 : 600 }}>
|
||||
{title}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
<Flexbox horizontal align="center" gap={2} style={{ flexShrink: 0 }}>
|
||||
<span className={ACTION_CLASS_NAME}>
|
||||
<ActionIcon
|
||||
icon={ArchiveIcon}
|
||||
size={{ blockSize: 24, size: 14 }}
|
||||
onClick={handleArchive}
|
||||
/>
|
||||
</span>
|
||||
<Text fontSize={12} style={{ flexShrink: 0 }} type="secondary">
|
||||
{dayjs(createdAt).fromNow()}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
<Text ellipsis={{ rows: 3 }} fontSize={12} type="secondary">
|
||||
{content}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Block>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default NotificationItem;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export const UNREAD_COUNT_KEY = 'inbox-unread-count';
|
||||
export const FETCH_KEY = 'inbox-notifications';
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
'use client';
|
||||
|
||||
import { ActionIcon, Flexbox } from '@lobehub/ui';
|
||||
import { ArchiveIcon, CheckCheckIcon, ListFilterIcon } from 'lucide-react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens';
|
||||
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
|
||||
import SideBarDrawer from '@/features/NavPanel/SideBarDrawer';
|
||||
import dynamic from '@/libs/next/dynamic';
|
||||
import { mutate } from '@/libs/swr';
|
||||
import { notificationService } from '@/services/notification';
|
||||
|
||||
import { FETCH_KEY, UNREAD_COUNT_KEY } from './constants';
|
||||
|
||||
const Content = dynamic(() => import('./Content'), {
|
||||
loading: () => (
|
||||
<Flexbox gap={1} paddingBlock={1} paddingInline={4}>
|
||||
<SkeletonList rows={3} />
|
||||
</Flexbox>
|
||||
),
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface InboxDrawerProps {
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
const InboxDrawer = memo<InboxDrawerProps>(({ open, onClose }) => {
|
||||
const { t } = useTranslation('notification');
|
||||
const [unreadOnly, setUnreadOnly] = useState(false);
|
||||
|
||||
const refreshList = useCallback(() => {
|
||||
mutate((key: unknown) => Array.isArray(key) && key[0] === FETCH_KEY);
|
||||
mutate(UNREAD_COUNT_KEY);
|
||||
}, []);
|
||||
|
||||
const handleMarkAsRead = useCallback(
|
||||
async (id: string) => {
|
||||
await notificationService.markAsRead([id]);
|
||||
refreshList();
|
||||
},
|
||||
[refreshList],
|
||||
);
|
||||
|
||||
const handleArchive = useCallback(
|
||||
async (id: string) => {
|
||||
await notificationService.archive(id);
|
||||
refreshList();
|
||||
},
|
||||
[refreshList],
|
||||
);
|
||||
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
await notificationService.markAllAsRead();
|
||||
refreshList();
|
||||
}, [refreshList]);
|
||||
|
||||
const handleArchiveAll = useCallback(async () => {
|
||||
await notificationService.archiveAll();
|
||||
refreshList();
|
||||
}, [refreshList]);
|
||||
|
||||
const handleToggleFilter = useCallback(() => {
|
||||
setUnreadOnly((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SideBarDrawer
|
||||
open={open}
|
||||
title={t('inbox.title')}
|
||||
action={
|
||||
<>
|
||||
<ActionIcon
|
||||
icon={ArchiveIcon}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
title={t('inbox.archiveAll')}
|
||||
onClick={handleArchiveAll}
|
||||
/>
|
||||
<ActionIcon
|
||||
icon={CheckCheckIcon}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
title={t('inbox.markAllRead')}
|
||||
onClick={handleMarkAllAsRead}
|
||||
/>
|
||||
<ActionIcon
|
||||
active={unreadOnly}
|
||||
icon={ListFilterIcon}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
title={t('inbox.filterUnread')}
|
||||
onClick={handleToggleFilter}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Content
|
||||
open={open}
|
||||
unreadOnly={unreadOnly}
|
||||
onArchive={handleArchive}
|
||||
onMarkAsRead={handleMarkAsRead}
|
||||
/>
|
||||
</SideBarDrawer>
|
||||
);
|
||||
});
|
||||
|
||||
InboxDrawer.displayName = 'InboxDrawer';
|
||||
|
||||
export default InboxDrawer;
|
||||
|
|
@ -5,13 +5,23 @@ import { memo } from 'react';
|
|||
import SideBarHeaderLayout from '@/features/NavPanel/SideBarHeaderLayout';
|
||||
|
||||
import AddButton from './components/AddButton';
|
||||
import InboxButton from './components/InboxButton';
|
||||
import Nav from './components/Nav';
|
||||
import User from './components/User';
|
||||
|
||||
const Header = memo(() => {
|
||||
return (
|
||||
<>
|
||||
<SideBarHeaderLayout left={<User />} right={<AddButton />} showBack={false} />
|
||||
<SideBarHeaderLayout
|
||||
left={<User />}
|
||||
showBack={false}
|
||||
right={
|
||||
<>
|
||||
<InboxButton />
|
||||
<AddButton />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Nav />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Billing from '@/business/client/BusinessSettingPages/Billing';
|
||||
import Credits from '@/business/client/BusinessSettingPages/Credits';
|
||||
import Notification from '@/business/client/BusinessSettingPages/Notification';
|
||||
import Plans from '@/business/client/BusinessSettingPages/Plans';
|
||||
import Referral from '@/business/client/BusinessSettingPages/Referral';
|
||||
import Usage from '@/business/client/BusinessSettingPages/Usage';
|
||||
|
|
@ -28,6 +29,7 @@ export const componentMap = {
|
|||
[SettingsTabs.Provider]: Provider,
|
||||
[SettingsTabs.ServiceModel]: ServiceModel,
|
||||
[SettingsTabs.Memory]: Memory,
|
||||
[SettingsTabs.Notification]: Notification,
|
||||
[SettingsTabs.About]: About,
|
||||
[SettingsTabs.Hotkey]: Hotkey,
|
||||
[SettingsTabs.Proxy]: Proxy,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ export const componentMap = {
|
|||
[SettingsTabs.Memory]: dynamic(() => import('../memory'), {
|
||||
loading: loading('Settings > Memory'),
|
||||
}),
|
||||
[SettingsTabs.Notification]: dynamic(
|
||||
() => import('@/business/client/BusinessSettingPages/Notification'),
|
||||
{
|
||||
loading: loading('Settings > Notification'),
|
||||
},
|
||||
),
|
||||
[SettingsTabs.About]: dynamic(() => import('../about'), {
|
||||
loading: loading('Settings > About'),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { isDesktop } from '@lobechat/const';
|
|||
import { Avatar } from '@lobehub/ui';
|
||||
import { SkillsIcon } from '@lobehub/ui/icons';
|
||||
import {
|
||||
// BellIcon,
|
||||
Brain,
|
||||
BrainCircuit,
|
||||
ChartColumnBigIcon,
|
||||
|
|
@ -100,6 +101,12 @@ export const useCategory = () => {
|
|||
key: SettingsTabs.Hotkey,
|
||||
label: t('tab.hotkey'),
|
||||
},
|
||||
// TODO: temporarily disabled until notification UI is polished
|
||||
// enableBusinessFeatures && {
|
||||
// icon: BellIcon,
|
||||
// key: SettingsTabs.Notification,
|
||||
// label: t('tab.notification'),
|
||||
// },
|
||||
].filter(Boolean) as CategoryItem[];
|
||||
|
||||
groups.push({
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { type RuntimeImageGenParams } from 'model-bank';
|
|||
import { z } from 'zod';
|
||||
|
||||
import { chargeAfterGenerate } from '@/business/server/image-generation/chargeAfterGenerate';
|
||||
import { notifyImageCompleted } from '@/business/server/image-generation/notifyImageCompleted';
|
||||
import { createImageBusinessMiddleware } from '@/business/server/trpc-middlewares/async';
|
||||
import { AsyncTaskModel } from '@/database/models/asyncTask';
|
||||
import { FileModel } from '@/database/models/file';
|
||||
|
|
@ -358,6 +359,15 @@ export const imageRouter = router({
|
|||
status: AsyncTaskStatus.Success,
|
||||
});
|
||||
|
||||
notifyImageCompleted({
|
||||
duration,
|
||||
generationBatchId,
|
||||
model,
|
||||
prompt: params.prompt,
|
||||
topicId: generationTopicId,
|
||||
userId: ctx.userId,
|
||||
}).catch((err) => console.error('[image-async] notification failed:', err));
|
||||
|
||||
if (ENABLE_BUSINESS_FEATURES) {
|
||||
await chargeAfterGenerate({
|
||||
metrics: { latency: duration },
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import { knowledgeBaseRouter } from './knowledgeBase';
|
|||
import { marketRouter } from './market';
|
||||
import { messageRouter } from './message';
|
||||
import { notebookRouter } from './notebook';
|
||||
import { notificationRouter } from './notification';
|
||||
import { oauthDeviceFlowRouter } from './oauthDeviceFlow';
|
||||
import { pluginRouter } from './plugin';
|
||||
import { ragEvalRouter } from './ragEval';
|
||||
|
|
@ -94,6 +95,7 @@ export const lambdaRouter = router({
|
|||
market: marketRouter,
|
||||
message: messageRouter,
|
||||
notebook: notebookRouter,
|
||||
notification: notificationRouter,
|
||||
oauthDeviceFlow: oauthDeviceFlowRouter,
|
||||
plugin: pluginRouter,
|
||||
ragEval: ragEvalRouter,
|
||||
|
|
|
|||
54
src/server/routers/lambda/notification.ts
Normal file
54
src/server/routers/lambda/notification.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { NotificationModel } from '@/database/models/notification';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
|
||||
const notificationProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
|
||||
return opts.next({
|
||||
ctx: { notificationModel: new NotificationModel(ctx.serverDB, ctx.userId) },
|
||||
});
|
||||
});
|
||||
|
||||
export const notificationRouter = router({
|
||||
archive: notificationProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.notificationModel.archive(input.id);
|
||||
}),
|
||||
|
||||
archiveAll: notificationProcedure.mutation(async ({ ctx }) => {
|
||||
return ctx.notificationModel.archiveAll();
|
||||
}),
|
||||
|
||||
list: notificationProcedure
|
||||
.input(
|
||||
z.object({
|
||||
category: z.string().optional(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().min(1).max(50).default(20),
|
||||
unreadOnly: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.notificationModel.list(input);
|
||||
}),
|
||||
|
||||
markAllAsRead: notificationProcedure.mutation(async ({ ctx }) => {
|
||||
return ctx.notificationModel.markAllAsRead();
|
||||
}),
|
||||
|
||||
markAsRead: notificationProcedure
|
||||
.input(z.object({ ids: z.array(z.string()).min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.notificationModel.markAsRead(input.ids);
|
||||
}),
|
||||
|
||||
unreadCount: notificationProcedure.query(async ({ ctx }) => {
|
||||
return ctx.notificationModel.getUnreadCount();
|
||||
}),
|
||||
});
|
||||
|
||||
export type NotificationRouter = typeof notificationRouter;
|
||||
36
src/services/notification.ts
Normal file
36
src/services/notification.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { lambdaClient } from '@/libs/trpc/client';
|
||||
|
||||
class NotificationService {
|
||||
list = (
|
||||
params: {
|
||||
category?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
unreadOnly?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
return lambdaClient.notification.list.query(params);
|
||||
};
|
||||
|
||||
getUnreadCount = (): Promise<number> => {
|
||||
return lambdaClient.notification.unreadCount.query();
|
||||
};
|
||||
|
||||
markAsRead = (ids: string[]) => {
|
||||
return lambdaClient.notification.markAsRead.mutate({ ids });
|
||||
};
|
||||
|
||||
markAllAsRead = () => {
|
||||
return lambdaClient.notification.markAllAsRead.mutate();
|
||||
};
|
||||
|
||||
archive = (id: string) => {
|
||||
return lambdaClient.notification.archive.mutate({ id });
|
||||
};
|
||||
|
||||
archiveAll = () => {
|
||||
return lambdaClient.notification.archiveAll.mutate();
|
||||
};
|
||||
}
|
||||
|
||||
export const notificationService = new NotificationService();
|
||||
|
|
@ -56,6 +56,7 @@ export enum SettingsTabs {
|
|||
Image = 'image',
|
||||
LLM = 'llm',
|
||||
Memory = 'memory',
|
||||
Notification = 'notification',
|
||||
// business
|
||||
Plans = 'plans',
|
||||
Profile = 'profile',
|
||||
|
|
|
|||
Loading…
Reference in a new issue