@allbluecn/web-app 0.4.21 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +25 -33
- package/src/components/command/command-palette.tsx +1 -1
- package/src/components/knowledge/knowledge-toolbar.tsx +29 -1
- package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
- package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
- package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
- package/src/components/notifications/settings-notifications.tsx +198 -0
- package/src/components/notifications/team-notifications.tsx +343 -0
- package/src/components/settings/personal-team/team/team-client.tsx +51 -12
- package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
- package/src/components/shared/wave-physics-loader.tsx +155 -0
- package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
- package/src/components/story/constants.ts +114 -51
- package/src/components/story/detail/story-detail-body.tsx +13 -3
- package/src/components/story/detail/story-detail-toolbar/children.tsx +49 -274
- package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +465 -0
- package/src/components/story/detail/story-detail-toolbar.tsx +84 -3
- package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
- package/src/components/story/display/use-paged-story-groups.ts +370 -28
- package/src/components/story/inline-editors/story-select-dialog.tsx +88 -12
- package/src/components/story/peek-panel.tsx +8 -1
- package/src/components/story/relations/story-ref-row.tsx +319 -0
- package/src/components/story/story-list-view.tsx +3 -1
- package/src/components/story/story-panel-header.tsx +4 -1
- package/src/components/story/types.ts +17 -18
- package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
- package/src/components/story/views/story-list/index.tsx +16 -5
- package/src/components/story/views/story-list/row.tsx +5 -4
- package/src/components/ui/separator.tsx +1 -1
- package/src/hooks/use-notifications.ts +4 -1
- package/src/lib/__tests__/datetime.test.ts +16 -1
- package/src/lib/__tests__/rag.test.ts +37 -2
- package/src/lib/changelog/sdk-versions.ts +30 -30
- package/src/lib/commands/registry.ts +1 -1
- package/src/lib/commands/types.ts +3 -0
- package/src/lib/datetime.ts +1 -0
- package/src/lib/rag.ts +9 -2
- package/src/routeTree.gen.ts +23 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
- package/src/routes/_nav/notifications/route.tsx +30 -0
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
- package/src/routes/api/notifications/stream.ts +4 -0
- package/src/server/ai-ports.ts +1 -1
- package/src/server/knowledge-public/github-sync.ts +1 -1
- package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
- package/src/server/notifications/notification-service.ts +137 -27
- package/src/server/serverFns/__tests__/knowledge-index.test.ts +178 -0
- package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
- package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
- package/src/server/serverFns/knowledge-index-utils.ts +104 -0
- package/src/server/serverFns/knowledge-index.ts +6 -67
- package/src/server/serverFns/knowledge.ts +8 -2
- package/src/server/serverFns/locale.ts +15 -3
- package/src/server/serverFns/notifications.ts +18 -4
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +239 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
- package/src/server/serverFns/story/index.ts +1 -2
- package/src/server/serverFns/story/story-dependencies.ts +107 -163
- package/src/server/serverFns/story/story-groups.ts +23 -3
- package/src/server/serverFns/story/story-transfer.ts +5 -3
- package/src/server/serverFns/team.ts +51 -2
- package/src/components/knowledge/knowledge-settings-panel.tsx +0 -148
- package/src/components/notifications/notification-list.tsx +0 -232
- package/src/components/notifications/notification-preferences.tsx +0 -156
- package/src/components/story/relation-badge.tsx +0 -57
- package/src/components/story/relations/dependency-panel.tsx +0 -190
- package/src/components/story/relations/hierarchy-panel.tsx +0 -180
- package/src/components/story/relations/tab.tsx +0 -212
- package/src/config/sidebar-notifications.ts +0 -50
- package/src/lib/openai-client.ts +0 -62
- package/src/plugins/.gen-manifest.json +0 -13
- package/src/plugins/modules.gen.ts +0 -20
- package/src/plugins/plugins.gen.ts +0 -20
- package/src/plugins/ui.gen.ts +0 -8
- package/src/routes/_nav/inspiration/route.tsx +0 -8
- package/src/routes/_nav/prd/$id/route.tsx +0 -9
- package/src/routes/_nav/prd/index.tsx +0 -9
- package/src/routes/_nav/prd/route.tsx +0 -8
- package/src/routes/_nav/tags/$id/route.tsx +0 -8
- package/src/routes/_nav/tags/index.tsx +0 -8
- package/src/routes/_nav/tags/route.tsx +0 -8
- package/src/routes/_nav/tags/settings/route.tsx +0 -9
- package/src/routes/share/prd/$prdId/route.tsx +0 -10
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { createLazyFileRoute } from '@tanstack/react-router'
|
|
19
19
|
import { useTranslation } from 'react-i18next'
|
|
20
|
-
import {
|
|
20
|
+
import { SettingsNotifications } from '@/components/notifications/settings-notifications'
|
|
21
21
|
|
|
22
22
|
export const Route = createLazyFileRoute('/_nav/settings/notifications')({
|
|
23
23
|
component: NotificationsSettingsPage,
|
|
@@ -25,13 +25,14 @@ export const Route = createLazyFileRoute('/_nav/settings/notifications')({
|
|
|
25
25
|
|
|
26
26
|
function NotificationsSettingsPage() {
|
|
27
27
|
const { t } = useTranslation('notifications')
|
|
28
|
+
|
|
28
29
|
return (
|
|
29
30
|
<div className="flex flex-col gap-6 p-6">
|
|
30
|
-
<div
|
|
31
|
-
<
|
|
32
|
-
<p className="text-sm text-muted-foreground">{t('
|
|
31
|
+
<div>
|
|
32
|
+
<h2 className="text-lg font-semibold">{t('settings.title')}</h2>
|
|
33
|
+
<p className="text-sm text-muted-foreground">{t('settings.description')}</p>
|
|
33
34
|
</div>
|
|
34
|
-
<
|
|
35
|
+
<SettingsNotifications />
|
|
35
36
|
</div>
|
|
36
37
|
)
|
|
37
38
|
}
|
|
@@ -21,6 +21,7 @@ import type { StreamChunk } from '@tanstack/ai'
|
|
|
21
21
|
import { prisma } from '@allbluecn/database'
|
|
22
22
|
import { getCurrentSession } from '@/lib/session'
|
|
23
23
|
import { notificationBus } from '@/server/notifications/event-bus'
|
|
24
|
+
import { deliverDueDigests } from '@/server/notifications/notification-service'
|
|
24
25
|
import type { NotificationSseValue } from '@allbluecn/shared'
|
|
25
26
|
|
|
26
27
|
export const Route = createFileRoute('/api/notifications/stream')({
|
|
@@ -60,6 +61,9 @@ export const Route = createFileRoute('/api/notifications/stream')({
|
|
|
60
61
|
value: { kind: 'unread_snapshot', count },
|
|
61
62
|
} as unknown as StreamChunk
|
|
62
63
|
|
|
64
|
+
// 连接建立时检查到期 digest 摘要(fire-and-forget,摘要经 bus 实时推送)
|
|
65
|
+
void deliverDueDigests(userId)
|
|
66
|
+
|
|
63
67
|
while (!abortController.signal.aborted) {
|
|
64
68
|
if (queue.length === 0) {
|
|
65
69
|
await new Promise<void>((resolve) => {
|
package/src/server/ai-ports.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { deleteEmbeddings } from "@/lib/pgvector"
|
|
|
26
26
|
import {
|
|
27
27
|
indexDocumentEmbeddings as knowledgeIndexDocumentEmbeddings,
|
|
28
28
|
resolveAiConfig as resolveKnowledgeAiConfigRaw,
|
|
29
|
-
} from "@/server/serverFns/knowledge-index"
|
|
29
|
+
} from "@/server/serverFns/knowledge-index-utils"
|
|
30
30
|
import type { AiConfig } from "@allbluecn/ai-gateway"
|
|
31
31
|
import { registerGatewayPorts, registerProServiceResolver } from "@allbluecn/ai-gateway"
|
|
32
32
|
import { getProService } from "@/server/pro-services"
|
|
@@ -36,7 +36,7 @@ const HEADERS: HeadersInit = {
|
|
|
36
36
|
function isAllowedGitHubUrl(url: string): boolean {
|
|
37
37
|
try {
|
|
38
38
|
const parsed = new URL(url)
|
|
39
|
-
return ['github.com', 'www.github.com'].includes(parsed.hostname)
|
|
39
|
+
return ['github.com', 'www.github.com', 'api.github.com'].includes(parsed.hostname)
|
|
40
40
|
&& ['https:', 'http:'].includes(parsed.protocol)
|
|
41
41
|
} catch {
|
|
42
42
|
return false
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
vi.mock('@allbluecn/database', () => ({
|
|
5
|
+
prisma: {
|
|
6
|
+
notificationPreference: {
|
|
7
|
+
findUnique: vi.fn(),
|
|
8
|
+
create: vi.fn(),
|
|
9
|
+
},
|
|
10
|
+
notification: {
|
|
11
|
+
create: vi.fn(),
|
|
12
|
+
findFirst: vi.fn(),
|
|
13
|
+
findMany: vi.fn(),
|
|
14
|
+
count: vi.fn(),
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
Prisma: {},
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
vi.mock('@/server/notifications/event-bus', () => ({
|
|
21
|
+
notificationBus: {
|
|
22
|
+
emit: vi.fn(),
|
|
23
|
+
subscribe: vi.fn(),
|
|
24
|
+
},
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
import { prisma } from '@allbluecn/database'
|
|
28
|
+
import { notificationBus } from '@/server/notifications/event-bus'
|
|
29
|
+
import { createNotification, deliverDueDigests } from '../notification-service'
|
|
30
|
+
|
|
31
|
+
const mockedFindUnique = prisma.notificationPreference.findUnique as ReturnType<typeof vi.fn>
|
|
32
|
+
const mockedCreatePref = prisma.notificationPreference.create as ReturnType<typeof vi.fn>
|
|
33
|
+
const mockedNotifCreate = prisma.notification.create as ReturnType<typeof vi.fn>
|
|
34
|
+
const mockedNotifFindFirst = prisma.notification.findFirst as ReturnType<typeof vi.fn>
|
|
35
|
+
const mockedNotifFindMany = prisma.notification.findMany as ReturnType<typeof vi.fn>
|
|
36
|
+
const mockedNotifCount = prisma.notification.count as ReturnType<typeof vi.fn>
|
|
37
|
+
const mockedEmit = notificationBus.emit as ReturnType<typeof vi.fn>
|
|
38
|
+
|
|
39
|
+
const realtimePrefs = {
|
|
40
|
+
userId: 'u1',
|
|
41
|
+
categories: {
|
|
42
|
+
team: 'realtime',
|
|
43
|
+
collaboration: 'realtime',
|
|
44
|
+
system: 'realtime',
|
|
45
|
+
billing: 'realtime',
|
|
46
|
+
security: 'realtime',
|
|
47
|
+
},
|
|
48
|
+
dndEnabled: false,
|
|
49
|
+
dndStart: '22:00',
|
|
50
|
+
dndEnd: '08:00',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const createdNotification = {
|
|
54
|
+
id: 'n1',
|
|
55
|
+
category: 'team',
|
|
56
|
+
type: 'invite',
|
|
57
|
+
title: 't',
|
|
58
|
+
body: null,
|
|
59
|
+
link: null,
|
|
60
|
+
readAt: null,
|
|
61
|
+
createdAt: new Date('2026-08-29T10:00:00Z'),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
vi.clearAllMocks()
|
|
66
|
+
mockedFindUnique.mockResolvedValue(realtimePrefs)
|
|
67
|
+
mockedCreatePref.mockResolvedValue(null)
|
|
68
|
+
mockedNotifCreate.mockResolvedValue(createdNotification)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('createNotification frequency 判定', () => {
|
|
72
|
+
it('off 类别完全不投递(不入库)', async () => {
|
|
73
|
+
mockedFindUnique.mockResolvedValue({
|
|
74
|
+
...realtimePrefs,
|
|
75
|
+
categories: { ...realtimePrefs.categories, team: 'off' },
|
|
76
|
+
})
|
|
77
|
+
await createNotification({ userId: 'u1', category: 'team', type: 'invite', title: 't' })
|
|
78
|
+
expect(mockedNotifCreate).not.toHaveBeenCalled()
|
|
79
|
+
expect(mockedEmit).not.toHaveBeenCalled()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('realtime 类别入库并实时推送', async () => {
|
|
83
|
+
await createNotification({ userId: 'u1', category: 'team', type: 'invite', title: 't' })
|
|
84
|
+
expect(mockedNotifCreate).toHaveBeenCalledTimes(1)
|
|
85
|
+
expect(mockedEmit).toHaveBeenCalledTimes(1)
|
|
86
|
+
expect(mockedEmit).toHaveBeenCalledWith('u1', {
|
|
87
|
+
kind: 'notification',
|
|
88
|
+
notification: expect.objectContaining({ id: 'n1' }),
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('digest-daily 类别仅入库不推送', async () => {
|
|
93
|
+
mockedFindUnique.mockResolvedValue({
|
|
94
|
+
...realtimePrefs,
|
|
95
|
+
categories: { ...realtimePrefs.categories, team: 'digest-daily' },
|
|
96
|
+
})
|
|
97
|
+
await createNotification({ userId: 'u1', category: 'team', type: 'invite', title: 't' })
|
|
98
|
+
expect(mockedNotifCreate).toHaveBeenCalledTimes(1)
|
|
99
|
+
expect(mockedEmit).not.toHaveBeenCalled()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('force 绕过 off 判定并强制推送', async () => {
|
|
103
|
+
mockedFindUnique.mockResolvedValue({
|
|
104
|
+
...realtimePrefs,
|
|
105
|
+
categories: { ...realtimePrefs.categories, team: 'off' },
|
|
106
|
+
})
|
|
107
|
+
await createNotification({ userId: 'u1', category: 'team', type: 'digest.daily', title: 't', force: true })
|
|
108
|
+
expect(mockedNotifCreate).toHaveBeenCalledTimes(1)
|
|
109
|
+
expect(mockedEmit).toHaveBeenCalledTimes(1)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('deliverDueDigests', () => {
|
|
114
|
+
const digestPrefs = {
|
|
115
|
+
userId: 'u1',
|
|
116
|
+
categories: {
|
|
117
|
+
team: 'digest-daily',
|
|
118
|
+
collaboration: 'realtime',
|
|
119
|
+
system: 'off',
|
|
120
|
+
billing: 'digest-weekly',
|
|
121
|
+
security: 'realtime',
|
|
122
|
+
},
|
|
123
|
+
dndEnabled: false,
|
|
124
|
+
dndStart: '22:00',
|
|
125
|
+
dndEnd: '08:00',
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
it('到期且有积压时生成摘要并实时推送', async () => {
|
|
129
|
+
mockedFindUnique.mockResolvedValue(digestPrefs)
|
|
130
|
+
mockedNotifFindFirst.mockResolvedValue({
|
|
131
|
+
createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000),
|
|
132
|
+
})
|
|
133
|
+
mockedNotifCount.mockResolvedValue(4)
|
|
134
|
+
mockedNotifFindMany.mockResolvedValue([{ title: 'a' }, { title: 'b' }, { title: 'c' }])
|
|
135
|
+
mockedNotifCreate.mockResolvedValue({ ...createdNotification, type: 'digest.daily' })
|
|
136
|
+
|
|
137
|
+
await deliverDueDigests('u1')
|
|
138
|
+
|
|
139
|
+
// team(daily) 到期生成摘要并推送;billing(weekly) 距水位线 25h < 7d 未到期
|
|
140
|
+
expect(mockedEmit).toHaveBeenCalledTimes(1)
|
|
141
|
+
const teamDigest = mockedNotifCreate.mock.calls.find(
|
|
142
|
+
(c) => c[0]?.data?.type === 'digest.daily'
|
|
143
|
+
)
|
|
144
|
+
expect(teamDigest).toBeDefined()
|
|
145
|
+
expect(teamDigest![0].data.title).toContain('今日团队协作有 4 条新通知')
|
|
146
|
+
expect(teamDigest![0].data.body).toContain('等 4 条通知')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('未到期(24h 内已有摘要)跳过', async () => {
|
|
150
|
+
mockedFindUnique.mockResolvedValue(digestPrefs)
|
|
151
|
+
mockedNotifFindFirst.mockResolvedValue({ createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000) })
|
|
152
|
+
|
|
153
|
+
await deliverDueDigests('u1')
|
|
154
|
+
|
|
155
|
+
expect(mockedNotifCount).not.toHaveBeenCalled()
|
|
156
|
+
expect(mockedNotifCreate).not.toHaveBeenCalled()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('到期但无积压通知跳过', async () => {
|
|
160
|
+
mockedFindUnique.mockResolvedValue(digestPrefs)
|
|
161
|
+
mockedNotifFindFirst.mockResolvedValue({
|
|
162
|
+
createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000),
|
|
163
|
+
})
|
|
164
|
+
mockedNotifCount.mockResolvedValue(0)
|
|
165
|
+
|
|
166
|
+
await deliverDueDigests('u1')
|
|
167
|
+
|
|
168
|
+
expect(mockedNotifCreate).not.toHaveBeenCalled()
|
|
169
|
+
expect(mockedEmit).not.toHaveBeenCalled()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('weekly 频率 7 天内已有摘要跳过', async () => {
|
|
173
|
+
mockedFindUnique.mockResolvedValue({
|
|
174
|
+
...digestPrefs,
|
|
175
|
+
categories: { ...digestPrefs.categories, billing: 'digest-weekly', team: 'realtime' },
|
|
176
|
+
})
|
|
177
|
+
mockedNotifFindFirst.mockResolvedValue({ createdAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000) })
|
|
178
|
+
|
|
179
|
+
await deliverDueDigests('u1')
|
|
180
|
+
|
|
181
|
+
expect(mockedNotifCount).not.toHaveBeenCalled()
|
|
182
|
+
expect(mockedNotifCreate).not.toHaveBeenCalled()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('无 digest 类别直接返回', async () => {
|
|
186
|
+
await deliverDueDigests('u1')
|
|
187
|
+
expect(mockedNotifFindFirst).not.toHaveBeenCalled()
|
|
188
|
+
})
|
|
189
|
+
})
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
import { prisma } from '@allbluecn/database'
|
|
3
3
|
import type {
|
|
4
|
-
|
|
4
|
+
CategoryFrequencies,
|
|
5
5
|
NotificationCategory,
|
|
6
6
|
NotificationItem,
|
|
7
7
|
NotificationPreferences,
|
|
8
8
|
} from '@allbluecn/shared'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
isInDndWindow,
|
|
11
|
+
normalizeCategoryFrequencies,
|
|
12
|
+
DEFAULT_CATEGORIES,
|
|
13
|
+
} from '@allbluecn/shared'
|
|
10
14
|
import { notificationBus } from './event-bus'
|
|
11
15
|
|
|
12
|
-
export { DEFAULT_CATEGORIES, isInDndWindow } from '@allbluecn/shared'
|
|
16
|
+
export { DEFAULT_CATEGORIES, isInDndWindow, normalizeCategoryFrequencies } from '@allbluecn/shared'
|
|
13
17
|
|
|
14
18
|
export interface CreateNotificationInput {
|
|
15
19
|
userId: string
|
|
@@ -18,13 +22,36 @@ export interface CreateNotificationInput {
|
|
|
18
22
|
title: string
|
|
19
23
|
body?: string
|
|
20
24
|
link?: string
|
|
25
|
+
/** 绕过 frequency 判定并强制实时推送(仅 digest 摘要生成使用) */
|
|
26
|
+
force?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 服务端直出的类别中文名(与 zh locale tabs 保持一致) */
|
|
30
|
+
const CATEGORY_LABELS: Record<NotificationCategory, string> = {
|
|
31
|
+
team: '团队协作',
|
|
32
|
+
collaboration: '内容协作',
|
|
33
|
+
system: '系统公告',
|
|
34
|
+
billing: '账单订阅',
|
|
35
|
+
security: '安全告警',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DIGEST_TYPE: Record<'digest-daily' | 'digest-weekly', string> = {
|
|
39
|
+
'digest-daily': 'digest.daily',
|
|
40
|
+
'digest-weekly': 'digest.weekly',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DIGEST_MIN_INTERVAL_MS: Record<'digest-daily' | 'digest-weekly', number> = {
|
|
44
|
+
'digest-daily': 24 * 60 * 60 * 1000,
|
|
45
|
+
'digest-weekly': 7 * 24 * 60 * 60 * 1000,
|
|
21
46
|
}
|
|
22
47
|
|
|
48
|
+
const DIGEST_SUMMARY_ITEMS = 3
|
|
49
|
+
|
|
23
50
|
export async function getOrCreatePreferences(userId: string): Promise<NotificationPreferences> {
|
|
24
51
|
const existing = await prisma.notificationPreference.findUnique({ where: { userId } })
|
|
25
52
|
if (existing) {
|
|
26
53
|
return {
|
|
27
|
-
categories:
|
|
54
|
+
categories: normalizeCategoryFrequencies(existing.categories),
|
|
28
55
|
dndEnabled: existing.dndEnabled,
|
|
29
56
|
dndStart: existing.dndStart,
|
|
30
57
|
dndEnd: existing.dndEnd,
|
|
@@ -52,35 +79,118 @@ export async function getOrCreatePreferences(userId: string): Promise<Notificati
|
|
|
52
79
|
return defaults
|
|
53
80
|
}
|
|
54
81
|
|
|
55
|
-
|
|
82
|
+
async function insertNotification(
|
|
83
|
+
input: CreateNotificationInput
|
|
84
|
+
): Promise<NotificationItem | null> {
|
|
85
|
+
const created = await prisma.notification.create({
|
|
86
|
+
data: {
|
|
87
|
+
userId: input.userId,
|
|
88
|
+
category: input.category,
|
|
89
|
+
type: input.type,
|
|
90
|
+
title: input.title,
|
|
91
|
+
body: input.body,
|
|
92
|
+
link: input.link,
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
return {
|
|
96
|
+
id: created.id,
|
|
97
|
+
category: created.category as NotificationCategory,
|
|
98
|
+
type: created.type,
|
|
99
|
+
title: created.title,
|
|
100
|
+
body: created.body,
|
|
101
|
+
link: created.link,
|
|
102
|
+
readAt: created.readAt ? created.readAt.toISOString() : null,
|
|
103
|
+
createdAt: created.createdAt.toISOString(),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* fire-and-forget:任何失败只记日志不抛出,不阻断业务主流程。
|
|
109
|
+
* frequency 判定:off 不投递;realtime 入库 + 实时推送;digest-* 仅入库(由 deliverDueDigests 汇总推送)。
|
|
110
|
+
*/
|
|
56
111
|
export async function createNotification(input: CreateNotificationInput): Promise<void> {
|
|
57
112
|
try {
|
|
58
113
|
const prefs = await getOrCreatePreferences(input.userId)
|
|
59
|
-
|
|
114
|
+
const frequency = prefs.categories[input.category]
|
|
115
|
+
if (!input.force && frequency === 'off') return
|
|
60
116
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
userId: input.userId,
|
|
64
|
-
category: input.category,
|
|
65
|
-
type: input.type,
|
|
66
|
-
title: input.title,
|
|
67
|
-
body: input.body,
|
|
68
|
-
link: input.link,
|
|
69
|
-
},
|
|
70
|
-
})
|
|
117
|
+
const item = await insertNotification(input)
|
|
118
|
+
if (!item) return
|
|
71
119
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
category: created.category as NotificationCategory,
|
|
75
|
-
type: created.type,
|
|
76
|
-
title: created.title,
|
|
77
|
-
body: created.body,
|
|
78
|
-
link: created.link,
|
|
79
|
-
readAt: created.readAt ? created.readAt.toISOString() : null,
|
|
80
|
-
createdAt: created.createdAt.toISOString(),
|
|
120
|
+
if (input.force || frequency === 'realtime') {
|
|
121
|
+
notificationBus.emit(input.userId, { kind: 'notification', notification: item })
|
|
81
122
|
}
|
|
82
|
-
notificationBus.emit(input.userId, { kind: 'notification', notification: item })
|
|
83
123
|
} catch (e) {
|
|
84
124
|
console.error('[notifications] create failed:', e instanceof Error ? e.message : 'unknown')
|
|
85
125
|
}
|
|
86
|
-
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function formatDigestBody(titles: string[], total: number): string {
|
|
129
|
+
const lines = titles.slice(0, DIGEST_SUMMARY_ITEMS).map((t) => `· ${t}`)
|
|
130
|
+
if (total > DIGEST_SUMMARY_ITEMS) {
|
|
131
|
+
lines.push(`等 ${total} 条通知`)
|
|
132
|
+
}
|
|
133
|
+
return lines.join('\n')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 检查并投递到期的 digest(摘要)通知。在用户建立 SSE 连接时调用(fire-and-forget):
|
|
138
|
+
* 以同类别最近一条同频率摘要的 createdAt 为水位线,积压通知数 > 0 且距上次摘要超过
|
|
139
|
+
* 频率周期时,按类别生成一条摘要通知并实时推送。
|
|
140
|
+
*/
|
|
141
|
+
export async function deliverDueDigests(userId: string): Promise<void> {
|
|
142
|
+
try {
|
|
143
|
+
const prefs = await getOrCreatePreferences(userId)
|
|
144
|
+
const digestCats = (Object.keys(prefs.categories) as NotificationCategory[]).filter(
|
|
145
|
+
(c) => prefs.categories[c] === 'digest-daily' || prefs.categories[c] === 'digest-weekly'
|
|
146
|
+
)
|
|
147
|
+
for (const category of digestCats) {
|
|
148
|
+
const frequency = prefs.categories[category] as 'digest-daily' | 'digest-weekly'
|
|
149
|
+
const type = DIGEST_TYPE[frequency]
|
|
150
|
+
|
|
151
|
+
const lastDigest = await prisma.notification.findFirst({
|
|
152
|
+
where: { userId, category, type },
|
|
153
|
+
orderBy: { createdAt: 'desc' },
|
|
154
|
+
select: { createdAt: true },
|
|
155
|
+
})
|
|
156
|
+
if (lastDigest && Date.now() - lastDigest.createdAt.getTime() < DIGEST_MIN_INTERVAL_MS[frequency]) {
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const since = lastDigest?.createdAt ?? new Date(0)
|
|
161
|
+
const pendingCount = await prisma.notification.count({
|
|
162
|
+
where: {
|
|
163
|
+
userId,
|
|
164
|
+
category,
|
|
165
|
+
type: { not: { startsWith: 'digest.' } },
|
|
166
|
+
createdAt: { gt: since },
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
if (pendingCount === 0) continue
|
|
170
|
+
|
|
171
|
+
const pending = await prisma.notification.findMany({
|
|
172
|
+
where: {
|
|
173
|
+
userId,
|
|
174
|
+
category,
|
|
175
|
+
type: { not: { startsWith: 'digest.' } },
|
|
176
|
+
createdAt: { gt: since },
|
|
177
|
+
},
|
|
178
|
+
orderBy: { createdAt: 'desc' },
|
|
179
|
+
select: { title: true },
|
|
180
|
+
take: DIGEST_SUMMARY_ITEMS,
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const periodLabel = frequency === 'digest-daily' ? '今日' : '本周'
|
|
184
|
+
await createNotification({
|
|
185
|
+
userId,
|
|
186
|
+
category,
|
|
187
|
+
type,
|
|
188
|
+
title: `${periodLabel}${CATEGORY_LABELS[category]}有 ${pendingCount} 条新通知`,
|
|
189
|
+
body: formatDigestBody(pending.map((n) => n.title), pendingCount),
|
|
190
|
+
force: true,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
} catch (e) {
|
|
194
|
+
console.error('[notifications] digest failed:', e instanceof Error ? e.message : 'unknown')
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
19
|
+
|
|
20
|
+
vi.mock('@tanstack/react-start', () => ({
|
|
21
|
+
createServerFn: (config: any) => {
|
|
22
|
+
const api: any = {
|
|
23
|
+
_validator: undefined,
|
|
24
|
+
validator(schema: any) {
|
|
25
|
+
this._validator = schema
|
|
26
|
+
return this
|
|
27
|
+
},
|
|
28
|
+
handler(fn: Function) {
|
|
29
|
+
return async (args: any) => fn({ data: args?.data })
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
return api
|
|
33
|
+
},
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
vi.mock('@tanstack/react-start/server', () => ({
|
|
37
|
+
getRequest: vi.fn(),
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
const m = vi.hoisted(() => ({
|
|
41
|
+
knowledgeBase: { findUnique: vi.fn(), findFirst: vi.fn() },
|
|
42
|
+
user: { findUnique: vi.fn() },
|
|
43
|
+
aiProvider: { findUnique: vi.fn() },
|
|
44
|
+
resolveDefaultProviderConfig: vi.fn(),
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
vi.mock('@allbluecn/database', () => ({
|
|
48
|
+
prisma: {
|
|
49
|
+
knowledgeBase: m.knowledgeBase,
|
|
50
|
+
knowledgeDocument: { findMany: vi.fn() },
|
|
51
|
+
user: m.user,
|
|
52
|
+
aiProvider: m.aiProvider,
|
|
53
|
+
},
|
|
54
|
+
}))
|
|
55
|
+
|
|
56
|
+
vi.mock('@allbluecn/ai-gateway', () => ({
|
|
57
|
+
generateEmbedding: vi.fn(),
|
|
58
|
+
chunkText: vi.fn((t: string) => [t]),
|
|
59
|
+
resolveDefaultProviderConfig: (...args: unknown[]) => m.resolveDefaultProviderConfig(...args),
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
vi.mock('@/server/auth-helpers', () => ({
|
|
63
|
+
requireAuth: vi.fn(),
|
|
64
|
+
}))
|
|
65
|
+
|
|
66
|
+
vi.mock('@/lib/pgvector', () => ({
|
|
67
|
+
insertEmbedding: vi.fn(),
|
|
68
|
+
deleteEmbeddings: vi.fn(),
|
|
69
|
+
}))
|
|
70
|
+
|
|
71
|
+
import { extractText, resolveAiConfig } from '../knowledge-index-utils'
|
|
72
|
+
|
|
73
|
+
const KB = { id: 'kb-1', userId: 'user-1' }
|
|
74
|
+
|
|
75
|
+
describe('resolveAiConfig', () => {
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
vi.clearAllMocks()
|
|
78
|
+
m.knowledgeBase.findUnique.mockResolvedValue(KB)
|
|
79
|
+
m.aiProvider.findUnique.mockResolvedValue(null)
|
|
80
|
+
m.resolveDefaultProviderConfig.mockResolvedValue(null)
|
|
81
|
+
m.user.findUnique.mockResolvedValue(null)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('新表配置优先:adapterKind 支持 embedding 且 baseUrl 为空时补 AiProvider.defaultBaseUrl', async () => {
|
|
85
|
+
m.resolveDefaultProviderConfig.mockResolvedValue({
|
|
86
|
+
providerId: 'openrouter',
|
|
87
|
+
adapterKind: 'openrouter',
|
|
88
|
+
apiKey: 'sk-or-user-key',
|
|
89
|
+
baseUrl: null,
|
|
90
|
+
model: 'openai/gpt-4o-mini',
|
|
91
|
+
})
|
|
92
|
+
m.aiProvider.findUnique.mockResolvedValue({
|
|
93
|
+
defaultBaseUrl: 'https://openrouter.ai/api/v1',
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
await expect(resolveAiConfig('kb-1')).resolves.toEqual({
|
|
97
|
+
apiKey: 'sk-or-user-key',
|
|
98
|
+
baseUrl: 'https://openrouter.ai/api/v1',
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('新表配置的 baseUrl 非空时直接采用,不回落 defaultBaseUrl', async () => {
|
|
103
|
+
m.resolveDefaultProviderConfig.mockResolvedValue({
|
|
104
|
+
providerId: 'openai',
|
|
105
|
+
adapterKind: 'openai',
|
|
106
|
+
apiKey: 'sk-user-key',
|
|
107
|
+
baseUrl: 'https://my-proxy.example.com/v1',
|
|
108
|
+
model: 'gpt-4o',
|
|
109
|
+
})
|
|
110
|
+
m.aiProvider.findUnique.mockResolvedValue({
|
|
111
|
+
defaultBaseUrl: 'https://api.openai.com/v1',
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
await expect(resolveAiConfig('kb-1')).resolves.toEqual({
|
|
115
|
+
apiKey: 'sk-user-key',
|
|
116
|
+
baseUrl: 'https://my-proxy.example.com/v1',
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('adapterKind 不支持 embedding 时回落到旧 User.aiApiKey', async () => {
|
|
121
|
+
m.resolveDefaultProviderConfig.mockResolvedValue({
|
|
122
|
+
providerId: 'anthropic',
|
|
123
|
+
adapterKind: 'anthropic',
|
|
124
|
+
apiKey: 'sk-ant-user-key',
|
|
125
|
+
baseUrl: 'https://api.anthropic.com',
|
|
126
|
+
model: 'claude-sonnet-4-5',
|
|
127
|
+
})
|
|
128
|
+
m.user.findUnique.mockResolvedValue({
|
|
129
|
+
aiApiKey: 'sk-or-legacy',
|
|
130
|
+
aiProvider: 'openrouter',
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
await expect(resolveAiConfig('kb-1')).resolves.toEqual({
|
|
134
|
+
apiKey: 'sk-or-legacy',
|
|
135
|
+
baseUrl: 'https://openrouter.ai/api/v1',
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('新表无配置且旧字段有效(openrouter)时补 OpenRouter 端点', async () => {
|
|
140
|
+
m.user.findUnique.mockResolvedValue({
|
|
141
|
+
aiApiKey: 'sk-or-legacy',
|
|
142
|
+
aiProvider: 'openrouter',
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
await expect(resolveAiConfig('kb-1')).resolves.toEqual({
|
|
146
|
+
apiKey: 'sk-or-legacy',
|
|
147
|
+
baseUrl: 'https://openrouter.ai/api/v1',
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('旧字段是占位符时返回空对象(交给 generateEmbedding 走 env 兜底)', async () => {
|
|
152
|
+
m.user.findUnique.mockResolvedValue({
|
|
153
|
+
aiApiKey: 'sk-or-your-api-key-here',
|
|
154
|
+
aiProvider: 'openrouter',
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
await expect(resolveAiConfig('kb-1')).resolves.toEqual({})
|
|
158
|
+
// 新表无有效配置 → 旧字段是占位符 → 不再查 AiProvider 补端点
|
|
159
|
+
expect(m.aiProvider.findUnique).not.toHaveBeenCalled()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('知识库不存在时返回空对象且不查询任何配置', async () => {
|
|
163
|
+
m.knowledgeBase.findUnique.mockResolvedValue(null)
|
|
164
|
+
|
|
165
|
+
await expect(resolveAiConfig('missing')).resolves.toEqual({})
|
|
166
|
+
expect(m.resolveDefaultProviderConfig).not.toHaveBeenCalled()
|
|
167
|
+
expect(m.user.findUnique).not.toHaveBeenCalled()
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('extractText', () => {
|
|
172
|
+
it('优先返回 markdown,其次剥离 html 标签', () => {
|
|
173
|
+
expect(extractText({ markdown: '# 标题' })).toBe('# 标题')
|
|
174
|
+
expect(extractText({ html: '<p>正文</p>' })).toBe('正文')
|
|
175
|
+
expect(extractText({})).toBe('')
|
|
176
|
+
expect(extractText(null)).toBe('')
|
|
177
|
+
})
|
|
178
|
+
})
|