feat: add notification system (temporarily disabled) (#13301)

This commit is contained in:
YuTengjing 2026-03-26 21:16:38 +08:00 committed by GitHub
parent 3f148005e4
commit 9f36fe95ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 797 additions and 10 deletions

View 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"
}

View file

@ -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",

View 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": "视频已生成"
}

View file

@ -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": "网络代理",

View file

@ -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,

View file

@ -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';

View 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,
},
},
},
};

View 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;
}
}

View file

@ -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(),

View 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;
}

View file

@ -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({

View file

@ -0,0 +1,3 @@
const Notification = () => null;
export default Notification;

View 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> {}

View 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> {}

View file

@ -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>

View file

@ -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,

View 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;

View file

@ -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',

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,2 @@
export const UNREAD_COUNT_KEY = 'inbox-unread-count';
export const FETCH_KEY = 'inbox-notifications';

View file

@ -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;

View file

@ -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 />
</>
);

View file

@ -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,

View file

@ -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'),
}),

View file

@ -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({

View file

@ -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 },

View file

@ -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,

View 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;

View 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();

View file

@ -56,6 +56,7 @@ export enum SettingsTabs {
Image = 'image',
LLM = 'llm',
Memory = 'memory',
Notification = 'notification',
// business
Plans = 'plans',
Profile = 'profile',