@allbluecn/web-app 0.4.21 → 0.4.22
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/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/rag.ts +9 -2
- package/src/server/ai-ports.ts +1 -1
- package/src/server/knowledge-public/github-sync.ts +1 -1
- package/src/server/serverFns/__tests__/knowledge-index.test.ts +178 -0
- 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/components/knowledge/knowledge-settings-panel.tsx +0 -148
- package/src/lib/openai-client.ts +0 -62
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allbluecn/web-app",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.22",
|
|
4
4
|
"license": "AGPL-3.0-or-later",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -34,31 +34,24 @@
|
|
|
34
34
|
"@radix-ui/react-popover": "^1.1.15",
|
|
35
35
|
"@radix-ui/react-slot": "^1.2.4",
|
|
36
36
|
"@radix-ui/react-visually-hidden": "^1.2.11",
|
|
37
|
-
"@tanstack/ai": "^0.
|
|
38
|
-
"@tanstack/ai-
|
|
39
|
-
"@tanstack/ai-
|
|
40
|
-
"@tanstack/
|
|
41
|
-
"@tanstack/ai-groq": "^0.5.3",
|
|
42
|
-
"@tanstack/ai-ollama": "^0.8.16",
|
|
43
|
-
"@tanstack/ai-openai": "^0.17.1",
|
|
44
|
-
"@tanstack/ai-openrouter": "^0.15.10",
|
|
45
|
-
"@tanstack/ai-persistence": "^0.1.0",
|
|
46
|
-
"@tanstack/ai-react": "^0.18.1",
|
|
47
|
-
"@tanstack/db": "0.6.17",
|
|
37
|
+
"@tanstack/ai": "^0.52.0",
|
|
38
|
+
"@tanstack/ai-persistence": "^0.5.4",
|
|
39
|
+
"@tanstack/ai-react": "^0.22.4",
|
|
40
|
+
"@tanstack/db": "0.8.6",
|
|
48
41
|
"@tanstack/hotkeys": "^0.8.0",
|
|
49
|
-
"@tanstack/pacer": "^0.
|
|
50
|
-
"@tanstack/query-db-collection": "1.2.
|
|
51
|
-
"@tanstack/react-db": "0.
|
|
52
|
-
"@tanstack/react-form": "2.0.0-alpha.
|
|
42
|
+
"@tanstack/pacer": "^0.22.0",
|
|
43
|
+
"@tanstack/query-db-collection": "1.2.11",
|
|
44
|
+
"@tanstack/react-db": "0.3.6",
|
|
45
|
+
"@tanstack/react-form": "2.0.0-alpha.2",
|
|
53
46
|
"@tanstack/react-hotkeys": "^0.10.0",
|
|
54
|
-
"@tanstack/react-pacer": "^0.
|
|
55
|
-
"@tanstack/react-query": "^5.
|
|
56
|
-
"@tanstack/react-router": "1.170.
|
|
57
|
-
"@tanstack/react-router-ssr-query": "^1.167.
|
|
58
|
-
"@tanstack/react-start": "^1.168.
|
|
59
|
-
"@tanstack/react-store": "^0.11.
|
|
60
|
-
"@tanstack/react-table": "^9.
|
|
61
|
-
"@tanstack/react-virtual": "^3.14.
|
|
47
|
+
"@tanstack/react-pacer": "^0.23.0",
|
|
48
|
+
"@tanstack/react-query": "^5.102.8",
|
|
49
|
+
"@tanstack/react-router": "1.170.32",
|
|
50
|
+
"@tanstack/react-router-ssr-query": "^1.167.2",
|
|
51
|
+
"@tanstack/react-start": "^1.168.49",
|
|
52
|
+
"@tanstack/react-store": "^0.11.1",
|
|
53
|
+
"@tanstack/react-table": "^9.2.3",
|
|
54
|
+
"@tanstack/react-virtual": "^3.14.10",
|
|
62
55
|
"@tanstack/store": "^0.11.1",
|
|
63
56
|
"@tiptap/core": "^3.27.4",
|
|
64
57
|
"@tiptap/extension-code-block-lowlight": "^3.27.4",
|
|
@@ -115,7 +108,6 @@
|
|
|
115
108
|
"next-themes": "^0.4.6",
|
|
116
109
|
"nitro": "3.0.260603-beta",
|
|
117
110
|
"ogl": "^1.0.11",
|
|
118
|
-
"openai": "^6.39.1",
|
|
119
111
|
"prisma": "^7.8.0",
|
|
120
112
|
"radix-ui": "^1.4.3",
|
|
121
113
|
"react": "19.2.4",
|
|
@@ -135,14 +127,14 @@
|
|
|
135
127
|
"three": "^0.180.0",
|
|
136
128
|
"tw-animate-css": "^1.4.0",
|
|
137
129
|
"zod": "^4.4.3",
|
|
138
|
-
"@allbluecn/ai-gateway": "0.4.14",
|
|
139
|
-
"@allbluecn/kernel": "0.3.0",
|
|
140
130
|
"@allbluecn/database": "0.4.14",
|
|
141
|
-
"@allbluecn/
|
|
142
|
-
"@allbluecn/
|
|
143
|
-
"@allbluecn/
|
|
144
|
-
"@allbluecn/
|
|
145
|
-
"@allbluecn/
|
|
131
|
+
"@allbluecn/kernel": "0.3.1",
|
|
132
|
+
"@allbluecn/shared": "0.4.15",
|
|
133
|
+
"@allbluecn/ai-gateway": "0.4.15",
|
|
134
|
+
"@allbluecn/tiptap": "0.4.15",
|
|
135
|
+
"@allbluecn/ui": "0.4.15",
|
|
136
|
+
"@allbluecn/plugins-core": "0.4.14",
|
|
137
|
+
"@allbluecn/prototype": "0.1.1"
|
|
146
138
|
},
|
|
147
139
|
"devDependencies": {
|
|
148
140
|
"@babel/parser": "^8.0.4",
|
|
@@ -152,7 +144,7 @@
|
|
|
152
144
|
"@playwright/mcp": "^0.0.75",
|
|
153
145
|
"@playwright/test": "^1.60.0",
|
|
154
146
|
"@tailwindcss/vite": "^4.3.2",
|
|
155
|
-
"@tanstack/react-query-devtools": "5.
|
|
147
|
+
"@tanstack/react-query-devtools": "5.102.8",
|
|
156
148
|
"@tanstack/react-router-devtools": "1.167.1",
|
|
157
149
|
"@testing-library/jest-dom": "^6.9.1",
|
|
158
150
|
"@testing-library/react": "^16.3.2",
|
|
@@ -195,7 +195,7 @@ function CommandPaletteList({
|
|
|
195
195
|
key={cmd.id}
|
|
196
196
|
index={idx}
|
|
197
197
|
onSelect={() => onRun(cmd)}
|
|
198
|
-
shortcut={cmd.shortcut ? resolveShortcut(cmd.shortcut) : undefined}
|
|
198
|
+
shortcut={cmd.shortcut ?? cmd.chord ? resolveShortcut(cmd.shortcut ?? cmd.chord!) : undefined}
|
|
199
199
|
icon={Icon && <Icon className="size-4 shrink-0" />}
|
|
200
200
|
>
|
|
201
201
|
<span className="truncate">{cmd.title}</span>
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { useState, useEffect, useRef } from 'react'
|
|
19
19
|
import { useNavigate } from '@tanstack/react-router'
|
|
20
|
-
import { Settings, Globe, Lock, Trash2, Columns2, Minimize2, ArrowLeftRight, Maximize2, FileText, Grid3X3, Network, Table, PanelLeftOpen, PanelLeftClose, Plus, Check, ListIndentIncrease, ListIndentDecrease, Pencil } from 'lucide-react'
|
|
20
|
+
import { Settings, Globe, Lock, Trash2, RefreshCw, Columns2, Minimize2, ArrowLeftRight, Maximize2, FileText, Grid3X3, Network, Table, PanelLeftOpen, PanelLeftClose, Plus, Check, ListIndentIncrease, ListIndentDecrease, Pencil } from 'lucide-react'
|
|
21
21
|
import { Input } from '@/components/ui/input'
|
|
22
22
|
import { Badge } from '@/components/ui/badge'
|
|
23
23
|
import {
|
|
@@ -67,6 +67,8 @@ import { KnowledgeTagInput } from './knowledge-tag-input'
|
|
|
67
67
|
import { useEditorWidth } from './editor/editor-width-context'
|
|
68
68
|
import type { EditorWidth } from './editor/editor-width-context'
|
|
69
69
|
import { deleteKnowledgeFn, updateKnowledgeFn } from '@/server/serverFns/knowledge'
|
|
70
|
+
import { reindexKnowledgeBaseFn } from '@/server/serverFns/knowledge-index'
|
|
71
|
+
import { toast } from '@/components/ui/sonner'
|
|
70
72
|
import { KnowledgeFormDialog } from '@/components/knowledge/dialog/knowledge-form-dialog'
|
|
71
73
|
import { cn } from '@/lib/utils'
|
|
72
74
|
import { useTranslation } from 'react-i18next'
|
|
@@ -109,6 +111,7 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
|
|
|
109
111
|
const [deleteConfirm, setDeleteConfirm] = useState('')
|
|
110
112
|
const [editOpen, setEditOpen] = useState(false)
|
|
111
113
|
const [deleting, setDeleting] = useState(false)
|
|
114
|
+
const [reindexing, setReindexing] = useState(false)
|
|
112
115
|
const { effectiveWidth, setWidth } = useEditorWidth()
|
|
113
116
|
const [createPopover, setCreatePopover] = useState(false)
|
|
114
117
|
const [createType, setCreateType] = useState('doc')
|
|
@@ -166,6 +169,21 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
|
|
|
166
169
|
setDeleting(false)
|
|
167
170
|
}
|
|
168
171
|
|
|
172
|
+
const handleReindex = async () => {
|
|
173
|
+
setReindexing(true)
|
|
174
|
+
try {
|
|
175
|
+
const { indexed, documentCount } = await reindexKnowledgeBaseFn({ data: { kbId: kb.id } })
|
|
176
|
+
if (documentCount === 0) {
|
|
177
|
+
toast.success(t('toolbar.noDocs'))
|
|
178
|
+
} else {
|
|
179
|
+
toast.success(t('toolbar.reindexDone', { indexed, documentCount }))
|
|
180
|
+
}
|
|
181
|
+
} catch (e) {
|
|
182
|
+
toast.error(`${t('toolbar.reindexFailed')}:${e instanceof Error ? e.message : String(e)}`)
|
|
183
|
+
}
|
|
184
|
+
setReindexing(false)
|
|
185
|
+
}
|
|
186
|
+
|
|
169
187
|
return (
|
|
170
188
|
<div className="flex items-center gap-4 px-4 py-2 border-b bg-background shrink-0">
|
|
171
189
|
<Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={onToggleTree}>
|
|
@@ -466,6 +484,16 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
|
|
|
466
484
|
</DropdownMenuSub>
|
|
467
485
|
|
|
468
486
|
|
|
487
|
+
<DropdownMenuSeparator />
|
|
488
|
+
|
|
489
|
+
<DropdownMenuItem
|
|
490
|
+
onSelect={(e) => { e.preventDefault(); if (!reindexing) handleReindex() }}
|
|
491
|
+
disabled={reindexing}
|
|
492
|
+
>
|
|
493
|
+
<RefreshCw className={`size-3.5 mr-2 ${reindexing ? 'animate-spin' : ''}`} />
|
|
494
|
+
{reindexing ? t('toolbar.reindexing') : t('toolbar.rebuildIndex')}
|
|
495
|
+
</DropdownMenuItem>
|
|
496
|
+
|
|
469
497
|
<DropdownMenuSeparator />
|
|
470
498
|
|
|
471
499
|
<DropdownMenuItem
|
|
@@ -24,10 +24,13 @@ const mockChunkText = vi.hoisted(() => vi.fn((text: string) => {
|
|
|
24
24
|
}))
|
|
25
25
|
const mockInsertEmbedding = vi.hoisted(() => vi.fn())
|
|
26
26
|
const mockSearchByVector = vi.hoisted(() => vi.fn())
|
|
27
|
+
const mockRerankCandidates = vi.hoisted(() => vi.fn().mockResolvedValue(null))
|
|
27
28
|
|
|
28
29
|
vi.mock('@allbluecn/ai-gateway', () => ({
|
|
29
30
|
generateEmbedding: mockGenerateEmbedding,
|
|
30
31
|
chunkText: mockChunkText,
|
|
32
|
+
rerankCandidates: mockRerankCandidates,
|
|
33
|
+
RECALL_MULTIPLIER: 4,
|
|
31
34
|
}))
|
|
32
35
|
|
|
33
36
|
vi.mock('@/lib/pgvector', () => ({
|
|
@@ -89,12 +92,44 @@ describe('ragSearch', () => {
|
|
|
89
92
|
expect(results).toHaveLength(1)
|
|
90
93
|
})
|
|
91
94
|
|
|
92
|
-
it('passes custom table and
|
|
95
|
+
it('passes custom table and widens recall by RECALL_MULTIPLIER for rerank', async () => {
|
|
93
96
|
mockSearchByVector.mockResolvedValue([])
|
|
94
97
|
|
|
95
98
|
await ragSearch('q', 'plugin_embeddings', 3)
|
|
96
99
|
|
|
97
100
|
expect(mockSearchByVector.mock.calls[0][1]).toBe('plugin_embeddings')
|
|
98
|
-
expect(mockSearchByVector.mock.calls[0][2]).toBe(
|
|
101
|
+
expect(mockSearchByVector.mock.calls[0][2]).toBe(12)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('falls back to vector order when rerank is unavailable', async () => {
|
|
105
|
+
mockRerankCandidates.mockResolvedValue(null)
|
|
106
|
+
mockSearchByVector.mockResolvedValue([
|
|
107
|
+
{ id: '1', content: 'a', similarity: 0.9 },
|
|
108
|
+
{ id: '2', content: 'b', similarity: 0.8 },
|
|
109
|
+
])
|
|
110
|
+
|
|
111
|
+
const results = await ragSearch('q', 't', 10)
|
|
112
|
+
|
|
113
|
+
expect(mockRerankCandidates).toHaveBeenCalledWith('q', [
|
|
114
|
+
{ id: '1', content: 'a', similarity: 0.9 },
|
|
115
|
+
{ id: '2', content: 'b', similarity: 0.8 },
|
|
116
|
+
], 10, undefined)
|
|
117
|
+
expect(results.map(r => r.id)).toEqual(['1', '2'])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('uses reranked order and truncates to limit', async () => {
|
|
121
|
+
mockSearchByVector.mockResolvedValue([
|
|
122
|
+
{ id: '1', content: 'a', similarity: 0.9 },
|
|
123
|
+
{ id: '2', content: 'b', similarity: 0.8 },
|
|
124
|
+
{ id: '3', content: 'c', similarity: 0.7 },
|
|
125
|
+
])
|
|
126
|
+
mockRerankCandidates.mockResolvedValue([
|
|
127
|
+
{ id: '3', content: 'c', similarity: 0.7 },
|
|
128
|
+
{ id: '1', content: 'a', similarity: 0.9 },
|
|
129
|
+
])
|
|
130
|
+
|
|
131
|
+
const results = await ragSearch('q', 't', 2)
|
|
132
|
+
|
|
133
|
+
expect(results.map(r => r.id)).toEqual(['3', '1'])
|
|
99
134
|
})
|
|
100
135
|
})
|
|
@@ -9,64 +9,64 @@ export const SDK_GROUPS: SdkGroup[] = [
|
|
|
9
9
|
{
|
|
10
10
|
"name": "@tanstack/react-start",
|
|
11
11
|
"displayName": "TanStack Start",
|
|
12
|
-
"version": "v1.168.
|
|
13
|
-
"lastUpdate": "Aug
|
|
12
|
+
"version": "v1.168.49",
|
|
13
|
+
"lastUpdate": "Aug 29, 2026",
|
|
14
14
|
"source": "apps/web/package.json#dependencies",
|
|
15
15
|
"docsUrl": "https://tanstack.com/start/latest"
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
"name": "@tanstack/react-router",
|
|
19
19
|
"displayName": "TanStack Router",
|
|
20
|
-
"version": "v1.170.
|
|
21
|
-
"lastUpdate": "Aug
|
|
20
|
+
"version": "v1.170.32",
|
|
21
|
+
"lastUpdate": "Aug 29, 2026",
|
|
22
22
|
"source": "apps/web/package.json#dependencies",
|
|
23
23
|
"docsUrl": "https://tanstack.com/router/latest"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"name": "@tanstack/react-query",
|
|
27
27
|
"displayName": "TanStack Query",
|
|
28
|
-
"version": "v5.
|
|
29
|
-
"lastUpdate": "Aug
|
|
28
|
+
"version": "v5.102.8",
|
|
29
|
+
"lastUpdate": "Aug 29, 2026",
|
|
30
30
|
"source": "apps/web/package.json#dependencies",
|
|
31
31
|
"docsUrl": "https://tanstack.com/query/latest"
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
34
|
"name": "@tanstack/ai",
|
|
35
35
|
"displayName": "TanStack AI",
|
|
36
|
-
"version": "v0.
|
|
37
|
-
"lastUpdate": "Aug
|
|
36
|
+
"version": "v0.52.0",
|
|
37
|
+
"lastUpdate": "Aug 29, 2026",
|
|
38
38
|
"source": "apps/web/package.json#dependencies",
|
|
39
39
|
"docsUrl": "https://tanstack.com/ai/latest"
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
42
|
"name": "@tanstack/react-table",
|
|
43
43
|
"displayName": "TanStack Table",
|
|
44
|
-
"version": "v9.
|
|
45
|
-
"lastUpdate": "Aug
|
|
44
|
+
"version": "v9.2.3",
|
|
45
|
+
"lastUpdate": "Aug 29, 2026",
|
|
46
46
|
"source": "apps/web/package.json#dependencies",
|
|
47
47
|
"docsUrl": "https://tanstack.com/table/latest"
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
"name": "@tanstack/react-form",
|
|
51
51
|
"displayName": "TanStack Form",
|
|
52
|
-
"version": "v2.0.0-alpha.
|
|
53
|
-
"lastUpdate": "Aug
|
|
52
|
+
"version": "v2.0.0-alpha.2",
|
|
53
|
+
"lastUpdate": "Aug 29, 2026",
|
|
54
54
|
"source": "apps/web/package.json#dependencies",
|
|
55
55
|
"docsUrl": "https://tanstack.com/form/latest"
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
"name": "@tanstack/charts",
|
|
59
59
|
"displayName": "TanStack Charts",
|
|
60
|
-
"version": "v0.
|
|
61
|
-
"lastUpdate": "Aug
|
|
60
|
+
"version": "v0.16.0",
|
|
61
|
+
"lastUpdate": "Aug 29, 2026",
|
|
62
62
|
"source": "apps/admin/package.json#dependencies",
|
|
63
63
|
"docsUrl": "https://tanstack.com/charts/latest"
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
66
|
"name": "@tanstack/react-pacer",
|
|
67
67
|
"displayName": "TanStack Pacer",
|
|
68
|
-
"version": "v0.
|
|
69
|
-
"lastUpdate": "Aug
|
|
68
|
+
"version": "v0.23.0",
|
|
69
|
+
"lastUpdate": "Aug 29, 2026",
|
|
70
70
|
"source": "apps/web/package.json#dependencies",
|
|
71
71
|
"docsUrl": "https://tanstack.com/pacer/latest"
|
|
72
72
|
},
|
|
@@ -74,15 +74,15 @@ export const SDK_GROUPS: SdkGroup[] = [
|
|
|
74
74
|
"name": "@tanstack/react-hotkeys",
|
|
75
75
|
"displayName": "TanStack Hotkeys",
|
|
76
76
|
"version": "v0.10.0",
|
|
77
|
-
"lastUpdate": "Aug
|
|
77
|
+
"lastUpdate": "Aug 29, 2026",
|
|
78
78
|
"source": "apps/web/package.json#dependencies",
|
|
79
79
|
"docsUrl": "https://tanstack.com/hotkeys/latest"
|
|
80
80
|
},
|
|
81
81
|
{
|
|
82
82
|
"name": "@tanstack/react-virtual",
|
|
83
83
|
"displayName": "TanStack Virtual",
|
|
84
|
-
"version": "v3.14.
|
|
85
|
-
"lastUpdate": "Aug
|
|
84
|
+
"version": "v3.14.10",
|
|
85
|
+
"lastUpdate": "Aug 29, 2026",
|
|
86
86
|
"source": "apps/admin/package.json#dependencies",
|
|
87
87
|
"docsUrl": "https://tanstack.com/virtual/latest"
|
|
88
88
|
}
|
|
@@ -91,19 +91,19 @@ export const SDK_GROUPS: SdkGroup[] = [
|
|
|
91
91
|
{
|
|
92
92
|
"id": "allblue",
|
|
93
93
|
"label": "allblue-sdk",
|
|
94
|
-
"icon": "
|
|
94
|
+
"icon": "📦",
|
|
95
95
|
"items": [
|
|
96
96
|
{
|
|
97
97
|
"name": "@allbluecn/web-app",
|
|
98
|
-
"version": "v0.4.
|
|
99
|
-
"lastUpdate": "Aug
|
|
98
|
+
"version": "v0.4.22",
|
|
99
|
+
"lastUpdate": "Aug 29, 2026",
|
|
100
100
|
"source": "apps/web/package.json",
|
|
101
101
|
"docsUrl": "https://github.com/allbluecn/allblue"
|
|
102
102
|
},
|
|
103
103
|
{
|
|
104
104
|
"name": "@allbluecn/kernel",
|
|
105
|
-
"version": "v0.3.
|
|
106
|
-
"lastUpdate": "Aug
|
|
105
|
+
"version": "v0.3.1",
|
|
106
|
+
"lastUpdate": "Aug 29, 2026",
|
|
107
107
|
"source": "packages/kernel/package.json",
|
|
108
108
|
"docsUrl": "https://github.com/allbluecn/allblue"
|
|
109
109
|
}
|
|
@@ -117,42 +117,42 @@ export const SDK_GROUPS: SdkGroup[] = [
|
|
|
117
117
|
{
|
|
118
118
|
"name": "Vite",
|
|
119
119
|
"version": "v8.1.5",
|
|
120
|
-
"lastUpdate": "Aug
|
|
120
|
+
"lastUpdate": "Aug 29, 2026",
|
|
121
121
|
"source": "apps/web/package.json#devDependencies",
|
|
122
122
|
"docsUrl": "https://vite.dev/"
|
|
123
123
|
},
|
|
124
124
|
{
|
|
125
125
|
"name": "Node.js",
|
|
126
126
|
"version": "v24.19.0",
|
|
127
|
-
"lastUpdate": "Aug
|
|
127
|
+
"lastUpdate": "Aug 29, 2026",
|
|
128
128
|
"source": ".nvmrc",
|
|
129
129
|
"docsUrl": "https://nodejs.org/"
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
"name": "Prisma",
|
|
133
133
|
"version": "v7.8.0",
|
|
134
|
-
"lastUpdate": "Aug
|
|
134
|
+
"lastUpdate": "Aug 29, 2026",
|
|
135
135
|
"source": "packages/database/package.json#devDependencies",
|
|
136
136
|
"docsUrl": "https://www.prisma.io/docs"
|
|
137
137
|
},
|
|
138
138
|
{
|
|
139
139
|
"name": "shadcn UI",
|
|
140
140
|
"version": "v1.2.4",
|
|
141
|
-
"lastUpdate": "Aug
|
|
141
|
+
"lastUpdate": "Aug 29, 2026",
|
|
142
142
|
"source": "apps/web/package.json#dependencies",
|
|
143
143
|
"docsUrl": "https://ui.shadcn.com/"
|
|
144
144
|
},
|
|
145
145
|
{
|
|
146
146
|
"name": "TipTap",
|
|
147
147
|
"version": "v3.27.4",
|
|
148
|
-
"lastUpdate": "Aug
|
|
148
|
+
"lastUpdate": "Aug 29, 2026",
|
|
149
149
|
"source": "packages/shared/package.json#devDependencies",
|
|
150
150
|
"docsUrl": "https://tiptap.dev/"
|
|
151
151
|
},
|
|
152
152
|
{
|
|
153
153
|
"name": "Lucide React",
|
|
154
154
|
"version": "v1.24.0",
|
|
155
|
-
"lastUpdate": "Aug
|
|
155
|
+
"lastUpdate": "Aug 29, 2026",
|
|
156
156
|
"source": "packages/ui/package.json#dependencies",
|
|
157
157
|
"docsUrl": "https://lucide.dev/"
|
|
158
158
|
}
|
|
@@ -38,7 +38,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
|
|
|
38
38
|
{ id: "create-prd", titleKey: "command.createPrd", icon: "file", group: "action", action: { kind: "callback", id: "create-prd" }, keywords: ["新建", "创建", "PRD", "xj"], shortcut: ["N", "P"] },
|
|
39
39
|
{ id: "create-conversation", titleKey: "command.createConversation", icon: "message", group: "action", action: { kind: "callback", id: "create-conversation" }, keywords: ["新建对话", "新对话", "xjdh"], shortcut: ["N", "C"] },
|
|
40
40
|
{ id: "create-story", titleKey: "command.createStory", icon: "clipboardList", group: "action", action: { kind: "callback", id: "create-story" }, keywords: ["新建需求", "xjxq"], shortcut: ["N", "S"] },
|
|
41
|
-
{ id: "toggle-theme", titleKey: "command.toggleTheme", icon: "sunMoon", group: "action", action: { kind: "callback", id: "toggle-theme" }, keywords: ["切换主题", "暗黑", "主题", "qhzt"],
|
|
41
|
+
{ id: "toggle-theme", titleKey: "command.toggleTheme", icon: "sunMoon", group: "action", action: { kind: "callback", id: "toggle-theme" }, keywords: ["切换主题", "暗黑", "主题", "qhzt"], chord: ["Mod", "Shift", "L"] },
|
|
42
42
|
{ id: "sign-out", titleKey: "command.signOut", icon: "logOut", group: "action", action: { kind: "callback", id: "sign-out" }, keywords: ["退出登录", "登出", "tcdl"] },
|
|
43
43
|
] as const
|
|
44
44
|
|
package/src/lib/rag.ts
CHANGED
|
@@ -15,7 +15,12 @@
|
|
|
15
15
|
// You should have received a copy of the GNU Affero General Public License
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
generateEmbedding,
|
|
20
|
+
chunkText,
|
|
21
|
+
rerankCandidates,
|
|
22
|
+
RECALL_MULTIPLIER,
|
|
23
|
+
} from "@allbluecn/ai-gateway"
|
|
19
24
|
import { insertEmbedding, searchByVector } from "./pgvector"
|
|
20
25
|
|
|
21
26
|
export async function indexDocument(
|
|
@@ -43,5 +48,7 @@ export async function ragSearch(
|
|
|
43
48
|
whereValue?: string
|
|
44
49
|
): Promise<{ id: string; content: string; similarity: number }[]> {
|
|
45
50
|
const embedding = await generateEmbedding(query, aiOpts)
|
|
46
|
-
|
|
51
|
+
const rows = await searchByVector(embedding, table, limit * RECALL_MULTIPLIER, whereColumn, whereValue)
|
|
52
|
+
const reranked = await rerankCandidates(query, rows, limit, aiOpts)
|
|
53
|
+
return (reranked ?? rows).slice(0, limit)
|
|
47
54
|
}
|
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,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
|
+
})
|
|
@@ -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,148 +0,0 @@
|
|
|
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 { useState } from 'react'
|
|
19
|
-
import { useNavigate } from '@tanstack/react-router'
|
|
20
|
-
import { Globe, Lock, Trash2 } from 'lucide-react'
|
|
21
|
-
import {
|
|
22
|
-
Dialog,
|
|
23
|
-
DialogContent,
|
|
24
|
-
DialogDescription,
|
|
25
|
-
DialogFooter,
|
|
26
|
-
DialogHeader,
|
|
27
|
-
DialogTitle,
|
|
28
|
-
} from '@/components/ui/dialog'
|
|
29
|
-
import { Button } from '@/components/ui/button'
|
|
30
|
-
import { Label } from '@/components/ui/label'
|
|
31
|
-
import {
|
|
32
|
-
Select,
|
|
33
|
-
SelectContent,
|
|
34
|
-
SelectItem,
|
|
35
|
-
SelectTrigger,
|
|
36
|
-
SelectValue,
|
|
37
|
-
} from '@/components/ui/select'
|
|
38
|
-
import { deleteKnowledgeFn, updateKnowledgeFn } from '@/server/serverFns/knowledge'
|
|
39
|
-
import { useTranslation } from 'react-i18next'
|
|
40
|
-
|
|
41
|
-
interface KbData {
|
|
42
|
-
id: string
|
|
43
|
-
name: string
|
|
44
|
-
visibility?: string
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
interface KnowledgeSettingsPanelProps {
|
|
48
|
-
open: boolean
|
|
49
|
-
onOpenChange: (open: boolean) => void
|
|
50
|
-
kb: KbData
|
|
51
|
-
onUpdate?: () => void
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function KnowledgeSettingsPanel({ open, onOpenChange, kb, onUpdate }: KnowledgeSettingsPanelProps) {
|
|
55
|
-
const { t } = useTranslation('knowledge')
|
|
56
|
-
const navigate = useNavigate()
|
|
57
|
-
const [visibility, setVisibility] = useState((kb as any).visibility || 'private')
|
|
58
|
-
const [deleting, setDeleting] = useState(false)
|
|
59
|
-
const [saving, setSaving] = useState(false)
|
|
60
|
-
|
|
61
|
-
const handleVisibilityChange = async (value: string) => {
|
|
62
|
-
const v = value as 'private' | 'team'
|
|
63
|
-
setVisibility(v)
|
|
64
|
-
setSaving(true)
|
|
65
|
-
try {
|
|
66
|
-
await updateKnowledgeFn({ data: { id: kb.id, visibility: v } })
|
|
67
|
-
onUpdate?.()
|
|
68
|
-
} catch (e) {
|
|
69
|
-
console.error('Update visibility failed:', e instanceof Error ? e.message : String(e))
|
|
70
|
-
}
|
|
71
|
-
setSaving(false)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const handleDelete = async () => {
|
|
75
|
-
setDeleting(true)
|
|
76
|
-
try {
|
|
77
|
-
await deleteKnowledgeFn({ data: { id: kb.id } })
|
|
78
|
-
onOpenChange(false)
|
|
79
|
-
navigate({ to: '/knowledge' })
|
|
80
|
-
} catch (e) {
|
|
81
|
-
console.error('Delete failed:', e instanceof Error ? e.message : String(e))
|
|
82
|
-
}
|
|
83
|
-
setDeleting(false)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return (
|
|
87
|
-
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
88
|
-
<DialogContent className="sm:max-w-md">
|
|
89
|
-
<DialogHeader>
|
|
90
|
-
<DialogTitle>{t('kbSettings.title')}</DialogTitle>
|
|
91
|
-
<DialogDescription>{t('kbSettings.subtitle', { name: kb.name })}</DialogDescription>
|
|
92
|
-
</DialogHeader>
|
|
93
|
-
|
|
94
|
-
<div className="space-y-6 py-4">
|
|
95
|
-
<div className="space-y-2">
|
|
96
|
-
<Label>{t('kbSettings.ownership')}</Label>
|
|
97
|
-
<Select value={visibility} onValueChange={handleVisibilityChange} disabled={saving}>
|
|
98
|
-
<SelectTrigger>
|
|
99
|
-
<SelectValue />
|
|
100
|
-
</SelectTrigger>
|
|
101
|
-
<SelectContent>
|
|
102
|
-
<SelectItem value="private">
|
|
103
|
-
<div className="flex items-center gap-2">
|
|
104
|
-
<Lock className="size-4" />
|
|
105
|
-
{t('kbSettings.private')}
|
|
106
|
-
</div>
|
|
107
|
-
</SelectItem>
|
|
108
|
-
<SelectItem value="team">
|
|
109
|
-
<div className="flex items-center gap-2">
|
|
110
|
-
<Globe className="size-4" />
|
|
111
|
-
{t('kbSettings.team')}
|
|
112
|
-
</div>
|
|
113
|
-
</SelectItem>
|
|
114
|
-
</SelectContent>
|
|
115
|
-
</Select>
|
|
116
|
-
<p className="text-xs text-muted-foreground">
|
|
117
|
-
{t('kbSettings.teamHint')}
|
|
118
|
-
</p>
|
|
119
|
-
</div>
|
|
120
|
-
|
|
121
|
-
<div className="border-t pt-4">
|
|
122
|
-
<div className="space-y-2">
|
|
123
|
-
<Label className="text-destructive">{t('kbSettings.danger')}</Label>
|
|
124
|
-
<p className="text-xs text-muted-foreground">
|
|
125
|
-
{t('kbSettings.deleteHint')}
|
|
126
|
-
</p>
|
|
127
|
-
<Button
|
|
128
|
-
variant="destructive"
|
|
129
|
-
size="sm"
|
|
130
|
-
onClick={handleDelete}
|
|
131
|
-
disabled={deleting}
|
|
132
|
-
>
|
|
133
|
-
<Trash2 className="size-4 mr-1" />
|
|
134
|
-
{deleting ? t('kbSettings.deleting') : t('kbSettings.delete')}
|
|
135
|
-
</Button>
|
|
136
|
-
</div>
|
|
137
|
-
</div>
|
|
138
|
-
</div>
|
|
139
|
-
|
|
140
|
-
<DialogFooter>
|
|
141
|
-
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
142
|
-
{t('kbSettings.close')}
|
|
143
|
-
</Button>
|
|
144
|
-
</DialogFooter>
|
|
145
|
-
</DialogContent>
|
|
146
|
-
</Dialog>
|
|
147
|
-
)
|
|
148
|
-
}
|
package/src/lib/openai-client.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
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 OpenAI from "openai"
|
|
19
|
-
|
|
20
|
-
const PLACEHOLDER_KEYS = new Set(["sk-your-api-key-here", ""])
|
|
21
|
-
|
|
22
|
-
function resolveApiKey(): string | null {
|
|
23
|
-
const openai = process.env.OPENAI_API_KEY
|
|
24
|
-
if (openai && !PLACEHOLDER_KEYS.has(openai)) return openai
|
|
25
|
-
|
|
26
|
-
const openrouter = process.env.OPENROUTER_API_KEY
|
|
27
|
-
if (openrouter && !PLACEHOLDER_KEYS.has(openrouter)) return openrouter
|
|
28
|
-
|
|
29
|
-
return null
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function resolveBaseURL(): string | undefined {
|
|
33
|
-
return process.env.AI_BASE_URL || undefined
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const globalForOpenAI = globalThis as unknown as {
|
|
37
|
-
openAIClient: OpenAI | undefined
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function getOpenAIClient(opts?: { apiKey?: string; baseUrl?: string }): OpenAI {
|
|
41
|
-
const { apiKey, baseUrl } = opts || {}
|
|
42
|
-
|
|
43
|
-
if (apiKey) {
|
|
44
|
-
const url = baseUrl || resolveBaseURL()
|
|
45
|
-
return new OpenAI({ apiKey, ...(url ? { baseURL: url } : {}) })
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (globalForOpenAI.openAIClient) return globalForOpenAI.openAIClient
|
|
49
|
-
|
|
50
|
-
const envKey = resolveApiKey()
|
|
51
|
-
if (!envKey) {
|
|
52
|
-
throw new Error("OPENAI_API_KEY or OPENROUTER_API_KEY not configured")
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const url = resolveBaseURL()
|
|
56
|
-
globalForOpenAI.openAIClient = new OpenAI({ apiKey: envKey, ...(url ? { baseURL: url } : {}) })
|
|
57
|
-
return globalForOpenAI.openAIClient
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function hasOpenAIKey(): boolean {
|
|
61
|
-
return resolveApiKey() !== null
|
|
62
|
-
}
|