@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
|
@@ -40,8 +40,9 @@ vi.mock('@allbluecn/database', () => ({
|
|
|
40
40
|
update: vi.fn(),
|
|
41
41
|
count: vi.fn(),
|
|
42
42
|
deleteMany: vi.fn(),
|
|
43
|
+
delete: vi.fn(),
|
|
43
44
|
},
|
|
44
|
-
teamJoinRequest: { updateMany: vi.fn(), findMany: vi.fn() },
|
|
45
|
+
teamJoinRequest: { updateMany: vi.fn(), findMany: vi.fn(), findUnique: vi.fn(), delete: vi.fn() },
|
|
45
46
|
$transaction: vi.fn(),
|
|
46
47
|
},
|
|
47
48
|
}))
|
|
@@ -68,16 +69,18 @@ import {
|
|
|
68
69
|
refreshTeamInvitationLinkFn,
|
|
69
70
|
listTeamInvitationsFn,
|
|
70
71
|
clearExpiredInvitationsFn,
|
|
72
|
+
deleteTeamInvitationFn,
|
|
73
|
+
deleteTeamJoinRequestFn,
|
|
71
74
|
} from '../team'
|
|
72
75
|
|
|
73
76
|
describe('refreshTeamInvitationLinkFn', () => {
|
|
74
77
|
beforeEach(() => vi.clearAllMocks())
|
|
75
78
|
|
|
76
|
-
it('
|
|
79
|
+
it('生成新链接,旧链接置 revoked 保留申请历史', async () => {
|
|
77
80
|
vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
|
|
78
81
|
const tx = {
|
|
79
82
|
teamInvitation: {
|
|
80
|
-
|
|
83
|
+
updateMany: vi.fn().mockResolvedValue({}),
|
|
81
84
|
create: vi.fn().mockResolvedValue({ id: 'inv-new', token: 'abc123' }),
|
|
82
85
|
},
|
|
83
86
|
teamJoinRequest: {
|
|
@@ -97,7 +100,7 @@ describe('refreshTeamInvitationLinkFn', () => {
|
|
|
97
100
|
vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
|
|
98
101
|
const tx = {
|
|
99
102
|
teamInvitation: {
|
|
100
|
-
|
|
103
|
+
updateMany: vi.fn().mockResolvedValue({}),
|
|
101
104
|
create: vi.fn().mockResolvedValue({ id: 'inv-new', token: 'xyz' }),
|
|
102
105
|
},
|
|
103
106
|
teamJoinRequest: {
|
|
@@ -114,7 +117,7 @@ describe('refreshTeamInvitationLinkFn', () => {
|
|
|
114
117
|
vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
|
|
115
118
|
const tx = {
|
|
116
119
|
teamInvitation: {
|
|
117
|
-
|
|
120
|
+
updateMany: vi.fn().mockResolvedValue({}),
|
|
118
121
|
create: vi.fn().mockResolvedValue({ id: 'inv-new', token: 'tok' }),
|
|
119
122
|
},
|
|
120
123
|
teamJoinRequest: {
|
|
@@ -199,15 +202,78 @@ describe('createTeamInvitationFn link 邀请', () => {
|
|
|
199
202
|
describe('clearExpiredInvitationsFn', () => {
|
|
200
203
|
beforeEach(() => vi.clearAllMocks())
|
|
201
204
|
|
|
202
|
-
it('
|
|
205
|
+
it('清除所有过期邮箱邀请(链接行保留以维持申请历史)', async () => {
|
|
203
206
|
vi.mocked(prisma.teamInvitation.deleteMany).mockResolvedValue({ count: 2 } as never)
|
|
204
207
|
const result = await clearExpiredInvitationsFn({ data: {} })
|
|
205
208
|
expect(result.success).toBe(true)
|
|
206
209
|
expect(result.count).toBe(2)
|
|
207
210
|
expect(prisma.teamInvitation.deleteMany).toHaveBeenCalledWith(
|
|
208
211
|
expect.objectContaining({
|
|
209
|
-
where: expect.objectContaining({ status: 'expired' }),
|
|
212
|
+
where: expect.objectContaining({ status: 'expired', channel: 'email' }),
|
|
210
213
|
}),
|
|
211
214
|
)
|
|
212
215
|
})
|
|
213
216
|
})
|
|
217
|
+
|
|
218
|
+
describe('deleteTeamInvitationFn', () => {
|
|
219
|
+
beforeEach(() => vi.clearAllMocks())
|
|
220
|
+
|
|
221
|
+
it('删除自己的 email 邀请记录', async () => {
|
|
222
|
+
vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({
|
|
223
|
+
id: 'inv1', ownerId: 'owner1', channel: 'email', email: 'x@y.com',
|
|
224
|
+
} as never)
|
|
225
|
+
vi.mocked(prisma.teamInvitation.delete).mockResolvedValue({} as never)
|
|
226
|
+
|
|
227
|
+
const result = await deleteTeamInvitationFn({ data: { invitationId: 'inv1' } })
|
|
228
|
+
|
|
229
|
+
expect(result.success).toBe(true)
|
|
230
|
+
expect(prisma.teamInvitation.delete).toHaveBeenCalledWith({ where: { id: 'inv1' } })
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('link 渠道邀请禁止删除(防级联删申请)', async () => {
|
|
234
|
+
vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({
|
|
235
|
+
id: 'inv2', ownerId: 'owner1', channel: 'link',
|
|
236
|
+
} as never)
|
|
237
|
+
|
|
238
|
+
await expect(
|
|
239
|
+
deleteTeamInvitationFn({ data: { invitationId: 'inv2' } }),
|
|
240
|
+
).rejects.toMatchObject({ message: expect.stringContaining('不可删除') })
|
|
241
|
+
expect(prisma.teamInvitation.delete).not.toHaveBeenCalled()
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('他人邀请不可删除', async () => {
|
|
245
|
+
vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({
|
|
246
|
+
id: 'inv3', ownerId: 'someone-else', channel: 'email',
|
|
247
|
+
} as never)
|
|
248
|
+
|
|
249
|
+
await expect(
|
|
250
|
+
deleteTeamInvitationFn({ data: { invitationId: 'inv3' } }),
|
|
251
|
+
).rejects.toMatchObject({ message: '邀请不存在' })
|
|
252
|
+
expect(prisma.teamInvitation.delete).not.toHaveBeenCalled()
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
describe('deleteTeamJoinRequestFn', () => {
|
|
257
|
+
beforeEach(() => vi.clearAllMocks())
|
|
258
|
+
|
|
259
|
+
it('删除自己的申请记录', async () => {
|
|
260
|
+
vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({
|
|
261
|
+
id: 'jr1', ownerId: 'owner1', email: 'a@b.com', status: 'rejected',
|
|
262
|
+
} as never)
|
|
263
|
+
vi.mocked(prisma.teamJoinRequest.delete).mockResolvedValue({} as never)
|
|
264
|
+
|
|
265
|
+
const result = await deleteTeamJoinRequestFn({ data: { requestId: 'jr1' } })
|
|
266
|
+
|
|
267
|
+
expect(result.success).toBe(true)
|
|
268
|
+
expect(prisma.teamJoinRequest.delete).toHaveBeenCalledWith({ where: { id: 'jr1' } })
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('他人申请不可删除', async () => {
|
|
272
|
+
vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(null as never)
|
|
273
|
+
|
|
274
|
+
await expect(
|
|
275
|
+
deleteTeamJoinRequestFn({ data: { requestId: 'jrX' } }),
|
|
276
|
+
).rejects.toMatchObject({ message: '申请不存在' })
|
|
277
|
+
expect(prisma.teamJoinRequest.delete).not.toHaveBeenCalled()
|
|
278
|
+
})
|
|
279
|
+
})
|
|
@@ -246,9 +246,9 @@ describe('getTeamInvitationByTokenFn(link 渠道)', () => {
|
|
|
246
246
|
describe('refreshTeamInvitationLinkFn', () => {
|
|
247
247
|
beforeEach(() => vi.clearAllMocks())
|
|
248
248
|
|
|
249
|
-
it('刷新 → 旧链接 pending 申请全部 invalidated
|
|
249
|
+
it('刷新 → 旧链接 pending 申请全部 invalidated,旧链接置 revoked 保留历史,新链接创建', async () => {
|
|
250
250
|
vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 1 } as never)
|
|
251
|
-
vi.mocked(prisma.teamInvitation.
|
|
251
|
+
vi.mocked(prisma.teamInvitation.updateMany).mockResolvedValue({ count: 1 } as never)
|
|
252
252
|
vi.mocked(prisma.teamInvitation.create).mockResolvedValue({ id: 'inv2', token: 'NEW' } as never)
|
|
253
253
|
|
|
254
254
|
const res = await refreshTeamInvitationLinkFn({ data: { expiresInDays: 7 } })
|
|
@@ -258,9 +258,11 @@ describe('refreshTeamInvitationLinkFn', () => {
|
|
|
258
258
|
where: expect.objectContaining({ ownerId: 'u2', status: 'pending' }),
|
|
259
259
|
data: expect.objectContaining({ status: 'invalidated' }),
|
|
260
260
|
}))
|
|
261
|
-
expect(prisma.teamInvitation.
|
|
261
|
+
expect(prisma.teamInvitation.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
262
262
|
where: { ownerId: 'u2', channel: 'link', status: { in: ['pending', 'pending_approval'] } },
|
|
263
|
+
data: { status: 'revoked' },
|
|
263
264
|
}))
|
|
265
|
+
expect(prisma.teamInvitation.deleteMany).not.toHaveBeenCalled()
|
|
264
266
|
expect(prisma.teamInvitation.create).toHaveBeenCalled()
|
|
265
267
|
})
|
|
266
268
|
})
|
|
@@ -0,0 +1,104 @@
|
|
|
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 { prisma } from "@allbluecn/database"
|
|
19
|
+
import { generateEmbedding, chunkText, resolveDefaultProviderConfig } from "@allbluecn/ai-gateway"
|
|
20
|
+
import { insertEmbedding, deleteEmbeddings } from "@/lib/pgvector"
|
|
21
|
+
|
|
22
|
+
export const KB_EMBEDDINGS_TABLE = "knowledge_embeddings"
|
|
23
|
+
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
24
|
+
const PLACEHOLDER_KEYS = new Set(["sk-your-api-key-here", "", "sk-or-your-api-key-here"])
|
|
25
|
+
|
|
26
|
+
// embeddings 走 OpenAI 兼容的 /embeddings 端点,仅这两个 adapterKind 可用;
|
|
27
|
+
// 其余(anthropic/gemini/groq/grok/ollama)无兼容路径,回落到 env 兜底。
|
|
28
|
+
const EMBEDDING_CAPABLE_ADAPTERS = new Set(["openai", "openrouter"])
|
|
29
|
+
|
|
30
|
+
export function extractText(content: unknown): string {
|
|
31
|
+
if (!content || typeof content !== "object") return ""
|
|
32
|
+
const obj = content as Record<string, unknown>
|
|
33
|
+
if (typeof obj.markdown === "string" && obj.markdown) return obj.markdown
|
|
34
|
+
if (typeof obj.html === "string" && obj.html) return obj.html.replace(/<[^>]*>/g, "")
|
|
35
|
+
return ""
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function resolveAiConfig(kbId: string): Promise<{ apiKey?: string; baseUrl?: string }> {
|
|
39
|
+
const kb = await prisma.knowledgeBase.findUnique({
|
|
40
|
+
where: { id: kbId },
|
|
41
|
+
select: { userId: true },
|
|
42
|
+
})
|
|
43
|
+
if (!kb) return {}
|
|
44
|
+
|
|
45
|
+
// 新表优先:用户在 /settings/ai 配置的 key(AiProviderConfig.apiKey 加密存储,
|
|
46
|
+
// resolveDefaultProviderConfig 内部已解密)。旧实现只读 User.aiApiKey,
|
|
47
|
+
// 只配了新表的用户重索引必然报 "not configured"。
|
|
48
|
+
const cfg = await resolveDefaultProviderConfig(kb.userId)
|
|
49
|
+
if (cfg && EMBEDDING_CAPABLE_ADAPTERS.has(cfg.adapterKind)) {
|
|
50
|
+
const provider = await prisma.aiProvider.findUnique({
|
|
51
|
+
where: { providerId: cfg.providerId },
|
|
52
|
+
select: { defaultBaseUrl: true },
|
|
53
|
+
})
|
|
54
|
+
const baseUrl = cfg.baseUrl || provider?.defaultBaseUrl
|
|
55
|
+
// resolveDefaultProviderConfig 已跳过空 apiKey,此处 ?? undefined 仅作类型窄化
|
|
56
|
+
return { apiKey: cfg.apiKey ?? undefined, baseUrl: baseUrl ?? undefined }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 旧字段兜底(迁移期)
|
|
60
|
+
const user = await prisma.user.findUnique({
|
|
61
|
+
where: { id: kb.userId },
|
|
62
|
+
select: { aiApiKey: true, aiProvider: true },
|
|
63
|
+
})
|
|
64
|
+
if (!user || !user.aiApiKey || PLACEHOLDER_KEYS.has(user.aiApiKey)) return {}
|
|
65
|
+
|
|
66
|
+
const result: { apiKey: string; baseUrl?: string } = { apiKey: user.aiApiKey }
|
|
67
|
+
if (user.aiProvider === "openrouter") {
|
|
68
|
+
result.baseUrl = OPENROUTER_BASE_URL
|
|
69
|
+
}
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function indexDocumentEmbeddings(
|
|
74
|
+
doc: { id: string; kbId: string; title: string; content: unknown },
|
|
75
|
+
aiOpts?: { apiKey?: string; baseUrl?: string }
|
|
76
|
+
): Promise<number> {
|
|
77
|
+
const text = extractText(doc.content) || doc.title
|
|
78
|
+
if (!text) return 0
|
|
79
|
+
|
|
80
|
+
const chunks = chunkText(text)
|
|
81
|
+
await deleteEmbeddings(KB_EMBEDDINGS_TABLE, "documentId", doc.id)
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
84
|
+
const embedding = await generateEmbedding(chunks[i], aiOpts)
|
|
85
|
+
await insertEmbedding(KB_EMBEDDINGS_TABLE, `${doc.id}-${i}`, chunks[i], embedding, {
|
|
86
|
+
kbId: doc.kbId,
|
|
87
|
+
documentId: doc.id,
|
|
88
|
+
chunkIndex: i,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return chunks.length
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function indexDocumentById(documentId: string): Promise<number> {
|
|
96
|
+
const doc = await prisma.knowledgeDocument.findUnique({
|
|
97
|
+
where: { id: documentId },
|
|
98
|
+
select: { id: true, kbId: true, title: true, content: true },
|
|
99
|
+
})
|
|
100
|
+
if (!doc) return 0
|
|
101
|
+
|
|
102
|
+
const aiConfig = await resolveAiConfig(doc.kbId)
|
|
103
|
+
return indexDocumentEmbeddings(doc, aiConfig)
|
|
104
|
+
}
|
|
@@ -20,73 +20,12 @@ import { getRequest } from "@tanstack/react-start/server"
|
|
|
20
20
|
import { z } from "zod"
|
|
21
21
|
import { prisma } from "@allbluecn/database"
|
|
22
22
|
import { requireAuth } from "@/server/auth-helpers"
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
export function extractText(content: unknown): string {
|
|
31
|
-
if (!content || typeof content !== "object") return ""
|
|
32
|
-
const obj = content as Record<string, unknown>
|
|
33
|
-
if (typeof obj.markdown === "string" && obj.markdown) return obj.markdown
|
|
34
|
-
if (typeof obj.html === "string" && obj.html) return obj.html.replace(/<[^>]*>/g, "")
|
|
35
|
-
return ""
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export async function resolveAiConfig(kbId: string): Promise<{ apiKey?: string; baseUrl?: string }> {
|
|
39
|
-
const kb = await prisma.knowledgeBase.findUnique({
|
|
40
|
-
where: { id: kbId },
|
|
41
|
-
select: { userId: true },
|
|
42
|
-
})
|
|
43
|
-
if (!kb) return {}
|
|
44
|
-
|
|
45
|
-
const user = await prisma.user.findUnique({
|
|
46
|
-
where: { id: kb.userId },
|
|
47
|
-
select: { aiApiKey: true, aiProvider: true },
|
|
48
|
-
})
|
|
49
|
-
if (!user || !user.aiApiKey || PLACEHOLDER_KEYS.has(user.aiApiKey)) return {}
|
|
50
|
-
|
|
51
|
-
const result: { apiKey: string; baseUrl?: string } = { apiKey: user.aiApiKey }
|
|
52
|
-
if (user.aiProvider === "openrouter") {
|
|
53
|
-
result.baseUrl = OPENROUTER_BASE_URL
|
|
54
|
-
}
|
|
55
|
-
return result
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export async function indexDocumentEmbeddings(
|
|
59
|
-
doc: { id: string; kbId: string; title: string; content: unknown },
|
|
60
|
-
aiOpts?: { apiKey?: string; baseUrl?: string }
|
|
61
|
-
): Promise<number> {
|
|
62
|
-
const text = extractText(doc.content) || doc.title
|
|
63
|
-
if (!text) return 0
|
|
64
|
-
|
|
65
|
-
const chunks = chunkText(text)
|
|
66
|
-
await deleteEmbeddings(KB_EMBEDDINGS_TABLE, "documentId", doc.id)
|
|
67
|
-
|
|
68
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
69
|
-
const embedding = await generateEmbedding(chunks[i], aiOpts)
|
|
70
|
-
await insertEmbedding(KB_EMBEDDINGS_TABLE, `${doc.id}-${i}`, chunks[i], embedding, {
|
|
71
|
-
kbId: doc.kbId,
|
|
72
|
-
documentId: doc.id,
|
|
73
|
-
chunkIndex: i,
|
|
74
|
-
})
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return chunks.length
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export async function indexDocumentById(documentId: string): Promise<number> {
|
|
81
|
-
const doc = await prisma.knowledgeDocument.findUnique({
|
|
82
|
-
where: { id: documentId },
|
|
83
|
-
select: { id: true, kbId: true, title: true, content: true },
|
|
84
|
-
})
|
|
85
|
-
if (!doc) return 0
|
|
86
|
-
|
|
87
|
-
const aiConfig = await resolveAiConfig(doc.kbId)
|
|
88
|
-
return indexDocumentEmbeddings(doc, aiConfig)
|
|
89
|
-
}
|
|
23
|
+
import { deleteEmbeddings } from "@/lib/pgvector"
|
|
24
|
+
import {
|
|
25
|
+
KB_EMBEDDINGS_TABLE,
|
|
26
|
+
indexDocumentEmbeddings,
|
|
27
|
+
resolveAiConfig,
|
|
28
|
+
} from "./knowledge-index-utils"
|
|
90
29
|
|
|
91
30
|
export const indexKnowledgeDocumentFn = createServerFn({ method: "POST" })
|
|
92
31
|
.validator(z.object({ documentId: z.string() }))
|
|
@@ -20,7 +20,7 @@ import { getRequest } from '@tanstack/react-start/server'
|
|
|
20
20
|
import { z } from 'zod'
|
|
21
21
|
import { prisma } from '@allbluecn/database'
|
|
22
22
|
import { requireAuth } from '@/server/auth-helpers'
|
|
23
|
-
import { indexDocumentById } from '@/server/serverFns/knowledge-index'
|
|
23
|
+
import { indexDocumentById } from '@/server/serverFns/knowledge-index-utils'
|
|
24
24
|
import { deleteEmbeddings } from '@/lib/pgvector'
|
|
25
25
|
import { requirePermission } from '@/server/permissions'
|
|
26
26
|
import { auditLog } from '@/server/audit'
|
|
@@ -306,7 +306,13 @@ export const updateDocumentFn = createServerFn({ method: 'POST' })
|
|
|
306
306
|
})
|
|
307
307
|
|
|
308
308
|
if (data.content !== undefined) {
|
|
309
|
-
|
|
309
|
+
// fire-and-forget:索引失败不阻塞文档保存,但不能静默——
|
|
310
|
+
// 否则无 AI key / 端点错误时知识库检索悄悄失效且无从排查。
|
|
311
|
+
indexDocumentById(data.id).catch((e) => {
|
|
312
|
+
console.warn(
|
|
313
|
+
`知识库向量索引失败 documentId=${data.id}: ${e instanceof Error ? e.message : String(e)}`,
|
|
314
|
+
)
|
|
315
|
+
})
|
|
310
316
|
}
|
|
311
317
|
|
|
312
318
|
await auditLog({ workspaceId: '', userId, resourceType: 'document', resourceId: data.id, action: 'document.update' })
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import { createServerFn } from '@tanstack/react-start'
|
|
2
|
-
import { getRequest } from '@tanstack/react-start/server'
|
|
2
|
+
import { getRequest, setCookie } from '@tanstack/react-start/server'
|
|
3
3
|
import { detectLocale } from '@/lib/i18n/detect'
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* 检测用户语言并固化为 lng cookie:
|
|
7
|
+
* 首次访问(无 cookie)时,SSR 依据 querystring/accept-language 检测出的语言
|
|
8
|
+
* 写入 cookie,保证客户端 i18n(仅读 querystring/cookie)与服务端同源一致,
|
|
9
|
+
* 避免 hydration mismatch。
|
|
10
|
+
*/
|
|
5
11
|
export const getLocaleFn = createServerFn({ method: 'GET' }).handler(async () => {
|
|
6
12
|
const request = getRequest()
|
|
7
|
-
|
|
8
|
-
|
|
13
|
+
const locale = detectLocale(request, null)
|
|
14
|
+
setCookie('lng', locale, {
|
|
15
|
+
path: '/',
|
|
16
|
+
maxAge: 60 * 60 * 24 * 365,
|
|
17
|
+
sameSite: 'lax',
|
|
18
|
+
})
|
|
19
|
+
return locale
|
|
20
|
+
})
|
|
@@ -6,10 +6,11 @@ import { prisma } from '@allbluecn/database'
|
|
|
6
6
|
import { requireAuth } from '@/server/auth-helpers'
|
|
7
7
|
import {
|
|
8
8
|
NOTIFICATION_CATEGORIES,
|
|
9
|
+
NOTIFICATION_FREQUENCIES,
|
|
9
10
|
type NotificationCategory,
|
|
10
|
-
type CategoryPreferences,
|
|
11
11
|
type NotificationItem,
|
|
12
12
|
type NotificationListResult,
|
|
13
|
+
type CategoryFrequencies,
|
|
13
14
|
} from '@allbluecn/shared'
|
|
14
15
|
import {
|
|
15
16
|
getOrCreatePreferences,
|
|
@@ -96,6 +97,16 @@ export const deleteNotificationFn = createServerFn({ method: 'POST' })
|
|
|
96
97
|
return { success: true }
|
|
97
98
|
})
|
|
98
99
|
|
|
100
|
+
export const clearNotificationsFn = createServerFn({ method: 'POST' })
|
|
101
|
+
.validator(z.object({}).optional())
|
|
102
|
+
.handler(async () => {
|
|
103
|
+
const request = getRequest()
|
|
104
|
+
const session = await requireAuth(request)
|
|
105
|
+
const userId = session.user!.id!
|
|
106
|
+
const result = await prisma.notification.deleteMany({ where: { userId } })
|
|
107
|
+
return { success: true, count: result.count }
|
|
108
|
+
})
|
|
109
|
+
|
|
99
110
|
export const getUnreadCountFn = createServerFn({ method: 'GET' })
|
|
100
111
|
.validator(z.object({}).optional())
|
|
101
112
|
.handler(async () => {
|
|
@@ -117,7 +128,7 @@ export const getNotificationPreferencesFn = createServerFn({ method: 'GET' })
|
|
|
117
128
|
})
|
|
118
129
|
|
|
119
130
|
const preferencesSchema = z.object({
|
|
120
|
-
categories: z.record(z.string(), z.
|
|
131
|
+
categories: z.record(z.string(), z.enum(NOTIFICATION_FREQUENCIES)),
|
|
121
132
|
dndEnabled: z.boolean(),
|
|
122
133
|
dndStart: z.string().regex(/^\d{2}:\d{2}$/),
|
|
123
134
|
dndEnd: z.string().regex(/^\d{2}:\d{2}$/),
|
|
@@ -131,9 +142,12 @@ export const updateNotificationPreferencesFn = createServerFn({
|
|
|
131
142
|
const request = getRequest()
|
|
132
143
|
const session = await requireAuth(request)
|
|
133
144
|
const userId = session.user!.id!
|
|
134
|
-
const categories = { ...DEFAULT_CATEGORIES } as
|
|
145
|
+
const categories = { ...DEFAULT_CATEGORIES } as CategoryFrequencies
|
|
135
146
|
for (const c of NOTIFICATION_CATEGORIES) {
|
|
136
|
-
|
|
147
|
+
const v = data.categories[c]
|
|
148
|
+
if ((NOTIFICATION_FREQUENCIES as readonly string[]).includes(v)) {
|
|
149
|
+
categories[c] = v as CategoryFrequencies[NotificationCategory]
|
|
150
|
+
}
|
|
137
151
|
}
|
|
138
152
|
await prisma.notificationPreference.upsert({
|
|
139
153
|
where: { userId },
|
|
@@ -0,0 +1,239 @@
|
|
|
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
|
+
const wrapped = async (args: any) => {
|
|
30
|
+
let data = args?.data;
|
|
31
|
+
if (this._validator && data !== undefined) {
|
|
32
|
+
data = this._validator.parse ? this._validator.parse(data) : data;
|
|
33
|
+
}
|
|
34
|
+
return fn({ data });
|
|
35
|
+
};
|
|
36
|
+
return wrapped;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
return api;
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
vi.mock("@tanstack/react-start/server", () => ({
|
|
44
|
+
getRequest: () => ({}),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const mocks = vi.hoisted(() => ({
|
|
48
|
+
depFindMany: vi.fn(),
|
|
49
|
+
depFindFirst: vi.fn(),
|
|
50
|
+
depCreate: vi.fn(),
|
|
51
|
+
depDelete: vi.fn(),
|
|
52
|
+
storyFindMany: vi.fn(),
|
|
53
|
+
currentSession: vi.fn(),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
vi.mock("@allbluecn/database", () => {
|
|
57
|
+
const prisma = {
|
|
58
|
+
storyDependency: {
|
|
59
|
+
findMany: mocks.depFindMany,
|
|
60
|
+
findFirst: mocks.depFindFirst,
|
|
61
|
+
create: mocks.depCreate,
|
|
62
|
+
delete: mocks.depDelete,
|
|
63
|
+
},
|
|
64
|
+
story: { findMany: mocks.storyFindMany },
|
|
65
|
+
};
|
|
66
|
+
return { prisma, Prisma: {} };
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
vi.mock("@/server/auth-helpers", () => ({
|
|
70
|
+
requireAuth: mocks.currentSession,
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
import {
|
|
74
|
+
addDependenciesFn,
|
|
75
|
+
removeDependencyFn,
|
|
76
|
+
getDependenciesFn,
|
|
77
|
+
} from "../story-dependencies";
|
|
78
|
+
|
|
79
|
+
const STORY_ROW = (id: string) => ({
|
|
80
|
+
id,
|
|
81
|
+
identifier: `PROJ-${id}`,
|
|
82
|
+
sequenceId: 1,
|
|
83
|
+
name: `story ${id}`,
|
|
84
|
+
description: null,
|
|
85
|
+
state: "backlog",
|
|
86
|
+
priority: "medium",
|
|
87
|
+
archived: false,
|
|
88
|
+
startDate: null,
|
|
89
|
+
targetDate: null,
|
|
90
|
+
boardOrder: 0,
|
|
91
|
+
projectId: "p1",
|
|
92
|
+
parentId: null,
|
|
93
|
+
parent: null,
|
|
94
|
+
assignee: null,
|
|
95
|
+
createdBy: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null },
|
|
96
|
+
updatedBy: null,
|
|
97
|
+
labels: [],
|
|
98
|
+
createdAt: new Date("2026-01-01T00:00:00Z"),
|
|
99
|
+
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
vi.clearAllMocks();
|
|
104
|
+
mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
|
|
105
|
+
// 默认 DB 无既有依赖边(handler 开头一次性取全边建邻接表,必须返回数组)
|
|
106
|
+
mocks.depFindMany.mockResolvedValue([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("addDependenciesFn", () => {
|
|
110
|
+
it("自依赖拒绝并计入 failed", async () => {
|
|
111
|
+
const res = await addDependenciesFn({
|
|
112
|
+
data: { items: [{ sourceId: "a", targetId: "a", type: "BLOCKS" }] },
|
|
113
|
+
});
|
|
114
|
+
expect(res.created).toHaveLength(0);
|
|
115
|
+
expect(res.failed[0].targetId).toBe("a");
|
|
116
|
+
expect(mocks.depCreate).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("pair 级判重:同 pair 任意方向任意类型已存在则拒绝", async () => {
|
|
120
|
+
mocks.depFindFirst.mockResolvedValue({ id: "d0" }); // 命中已有边
|
|
121
|
+
const res = await addDependenciesFn({
|
|
122
|
+
data: {
|
|
123
|
+
items: [
|
|
124
|
+
{ sourceId: "a", targetId: "b", type: "STARTS_BEFORE" }, // 正向查 (a,b,*)
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
expect(res.failed[0].targetId).toBe("b");
|
|
129
|
+
expect(mocks.depCreate).not.toHaveBeenCalled();
|
|
130
|
+
// 守住双向查询:实现退化为单方向(漏掉反向 OR)时本断言必须失败
|
|
131
|
+
expect(mocks.depFindFirst).toHaveBeenCalledWith(
|
|
132
|
+
expect.objectContaining({
|
|
133
|
+
where: expect.objectContaining({
|
|
134
|
+
OR: expect.arrayContaining([
|
|
135
|
+
{ sourceId: "a", targetId: "b" },
|
|
136
|
+
{ sourceId: "b", targetId: "a" },
|
|
137
|
+
]),
|
|
138
|
+
}),
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("循环依赖拒绝:target 可达 source(跨类型混合环)", async () => {
|
|
144
|
+
mocks.depFindFirst.mockResolvedValue(null); // 无重复
|
|
145
|
+
// 图:b -> a 已存在(b BLOCKS a);插入 a -> b 成环
|
|
146
|
+
// handler 开头一次性取全边,返回全图唯一一条边
|
|
147
|
+
mocks.depFindMany.mockResolvedValue([{ sourceId: "b", targetId: "a" }]);
|
|
148
|
+
const res = await addDependenciesFn({
|
|
149
|
+
data: { items: [{ sourceId: "a", targetId: "b", type: "BLOCKS" }] },
|
|
150
|
+
});
|
|
151
|
+
expect(res.failed[0].targetId).toBe("b");
|
|
152
|
+
expect(mocks.depCreate).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("批内互指环:第二条被逐条拦截", async () => {
|
|
156
|
+
mocks.depFindFirst.mockResolvedValue(null);
|
|
157
|
+
mocks.depFindMany.mockResolvedValue([]);
|
|
158
|
+
mocks.depCreate.mockImplementation(({ data }: any) =>
|
|
159
|
+
Promise.resolve({ id: `d-${data.sourceId}-${data.targetId}`, ...data }),
|
|
160
|
+
);
|
|
161
|
+
// 第一条插入 a->b 后,第二条 b->a 的环检测需看到它:
|
|
162
|
+
// 实现必须把本批已插入边并入检测图(depFindMany 之后的内存集合)
|
|
163
|
+
// 这里 mock depFindMany 每次返回空(DB 无边),实现需自维护本批边
|
|
164
|
+
const res = await addDependenciesFn({
|
|
165
|
+
data: {
|
|
166
|
+
items: [
|
|
167
|
+
{ sourceId: "a", targetId: "b", type: "BLOCKS" },
|
|
168
|
+
{ sourceId: "b", targetId: "a", type: "STARTS_BEFORE" },
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
expect(res.created).toHaveLength(1);
|
|
173
|
+
expect(res.failed[0].targetId).toBe("a");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("合法边创建成功", async () => {
|
|
177
|
+
mocks.depFindFirst.mockResolvedValue(null);
|
|
178
|
+
mocks.depFindMany.mockResolvedValue([]);
|
|
179
|
+
mocks.depCreate.mockResolvedValue({ id: "d1" });
|
|
180
|
+
const res = await addDependenciesFn({
|
|
181
|
+
data: { items: [{ sourceId: "a", targetId: "b", type: "BLOCKS" }] },
|
|
182
|
+
});
|
|
183
|
+
expect(res.created).toHaveLength(1);
|
|
184
|
+
expect(mocks.depCreate).toHaveBeenCalledOnce();
|
|
185
|
+
// 性能契约:环检测的图只查一次(取全边),且不带任何 where 过滤
|
|
186
|
+
expect(mocks.depFindMany).toHaveBeenCalledTimes(1);
|
|
187
|
+
expect(mocks.depFindMany).toHaveBeenCalledWith({
|
|
188
|
+
select: { sourceId: true, targetId: true },
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("validator 拒绝旧枚举值:BLOCKED_BY 不再合法", async () => {
|
|
193
|
+
await expect(
|
|
194
|
+
addDependenciesFn({
|
|
195
|
+
data: { items: [{ sourceId: "a", targetId: "b", type: "BLOCKED_BY" as any }] },
|
|
196
|
+
}),
|
|
197
|
+
).rejects.toThrow();
|
|
198
|
+
expect(mocks.depCreate).not.toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("removeDependencyFn", () => {
|
|
203
|
+
it("按 depId 删除", async () => {
|
|
204
|
+
mocks.depDelete.mockResolvedValue({ id: "d1" });
|
|
205
|
+
const res = await removeDependencyFn({ data: { depId: "d1" } });
|
|
206
|
+
expect(res.id).toBe("d1");
|
|
207
|
+
expect(mocks.depDelete).toHaveBeenCalledWith({ where: { id: "d1" } });
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("getDependenciesFn", () => {
|
|
212
|
+
it("双视角分组:dependsOn 取 source,dependents 取 target", async () => {
|
|
213
|
+
mocks.depFindMany.mockResolvedValue([
|
|
214
|
+
{ id: "d1", sourceId: "s1", targetId: "me", type: "BLOCKS" },
|
|
215
|
+
{ id: "d2", sourceId: "me", targetId: "t1", type: "STARTS_BEFORE" },
|
|
216
|
+
]);
|
|
217
|
+
mocks.storyFindMany.mockImplementation(({ where }: any) => {
|
|
218
|
+
const rows = [STORY_ROW("s1"), STORY_ROW("t1"), STORY_ROW("me")];
|
|
219
|
+
return Promise.resolve(rows.filter((r) => where.id.in.includes(r.id)));
|
|
220
|
+
});
|
|
221
|
+
const res = await getDependenciesFn({ data: { storyId: "me" } });
|
|
222
|
+
expect(res.dependsOn).toHaveLength(1);
|
|
223
|
+
expect(res.dependsOn[0].id).toBe("s1");
|
|
224
|
+
expect(res.dependsOn[0].depId).toBe("d1");
|
|
225
|
+
expect(res.dependsOn[0].depType).toBe("BLOCKS");
|
|
226
|
+
expect(res.dependents).toHaveLength(1);
|
|
227
|
+
expect(res.dependents[0].id).toBe("t1");
|
|
228
|
+
expect(res.dependents[0].depType).toBe("STARTS_BEFORE");
|
|
229
|
+
// 日期已 ISO 化(castStoryUsers)
|
|
230
|
+
expect(res.dependsOn[0].createdAt).toBe("2026-01-01T00:00:00.000Z");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("无边时返回空分组且不查需求表", async () => {
|
|
234
|
+
mocks.depFindMany.mockResolvedValue([]);
|
|
235
|
+
const res = await getDependenciesFn({ data: { storyId: "me" } });
|
|
236
|
+
expect(res).toEqual({ dependsOn: [], dependents: [] });
|
|
237
|
+
expect(mocks.storyFindMany).not.toHaveBeenCalled();
|
|
238
|
+
});
|
|
239
|
+
});
|