@allbluecn/web-app 0.4.20 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.4.20",
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.43.0",
38
- "@tanstack/ai-anthropic": "^0.16.3",
39
- "@tanstack/ai-gemini": "^0.20.1",
40
- "@tanstack/ai-grok": "^0.14.9",
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.21.1",
50
- "@tanstack/query-db-collection": "1.2.1",
51
- "@tanstack/react-db": "0.1.95",
52
- "@tanstack/react-form": "2.0.0-alpha.0",
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.22.1",
55
- "@tanstack/react-query": "^5.101.2",
56
- "@tanstack/react-router": "1.170.29",
57
- "@tanstack/react-router-ssr-query": "^1.167.1",
58
- "@tanstack/react-start": "^1.168.46",
59
- "@tanstack/react-store": "^0.11.0",
60
- "@tanstack/react-table": "^9.1.0",
61
- "@tanstack/react-virtual": "^3.14.9",
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/kernel": "0.3.0",
139
- "@allbluecn/ai-gateway": "0.4.14",
140
130
  "@allbluecn/database": "0.4.14",
141
- "@allbluecn/plugins-core": "0.4.13",
142
- "@allbluecn/prototype": "0.1.1",
143
- "@allbluecn/shared": "0.4.14",
144
- "@allbluecn/ui": "0.4.14",
145
- "@allbluecn/tiptap": "0.4.13"
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.101.4",
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",
@@ -23,7 +23,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
23
23
  import { Route } from '@/routes/_nav/changelog/route'
24
24
  import type { DeveloperLogEntry } from '@allbluecn/shared'
25
25
  import { SDK_GROUPS } from '@/lib/changelog/sdk-versions'
26
- import { APP_FULL_VERSION } from '@/lib/app-version'
26
+ import { APP_FULL_VERSION, APP_UPDATE_DATE_DISPLAY } from '@/lib/app-version'
27
27
 
28
28
  const TAG_KEYS = ['all', 'feat', 'fix', 'improvement', 'design', 'refactor', 'breaking', 'docs', 'security', 'perf']
29
29
 
@@ -140,9 +140,12 @@ function ChangelogSidebar() {
140
140
  </div>
141
141
 
142
142
  <div className="pt-4 border-border/50">
143
- <code className="text-[10px] font-mono text-primary/80 bg-primary/5 px-2 py-0.5 rounded-full">
143
+ <span className="text-[10px] font-mono text-primary/80 bg-primary/5 px-2 py-0.5 rounded-full">
144
144
  {APP_FULL_VERSION}
145
- </code>
145
+ <span className="text-[10px] font-mono text-muted-foreground bg-transparent px-1.5">
146
+ {APP_UPDATE_DATE_DISPLAY}
147
+ </span>
148
+ </span>
146
149
  </div>
147
150
  </aside>
148
151
  )
@@ -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 limit', async () => {
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(3)
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
  })
@@ -19,5 +19,9 @@
19
19
  declare const __APP_VERSION__: string
20
20
  const APP_VERSION: string = __APP_VERSION__
21
21
 
22
+ declare const __APP_UPDATE_DATE__: string
23
+ const APP_UPDATE_DATE: string = __APP_UPDATE_DATE__
24
+
22
25
  export const APP_VERSION_DISPLAY = `v${APP_VERSION}`
23
26
  export const APP_FULL_VERSION = `AllBlue v${APP_VERSION}`
27
+ export const APP_UPDATE_DATE_DISPLAY = `更新于${APP_UPDATE_DATE}`
@@ -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.46",
13
- "lastUpdate": "Aug 28, 2026",
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.29",
21
- "lastUpdate": "Aug 28, 2026",
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.101.2",
29
- "lastUpdate": "Aug 28, 2026",
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.43.0",
37
- "lastUpdate": "Aug 28, 2026",
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.1.0",
45
- "lastUpdate": "Aug 28, 2026",
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.0",
53
- "lastUpdate": "Aug 28, 2026",
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.9.0",
61
- "lastUpdate": "Aug 28, 2026",
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.22.1",
69
- "lastUpdate": "Aug 28, 2026",
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 28, 2026",
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.9",
85
- "lastUpdate": "Aug 28, 2026",
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.20",
99
- "lastUpdate": "Aug 28, 2026",
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.0",
106
- "lastUpdate": "Aug 28, 2026",
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 28, 2026",
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 28, 2026",
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 28, 2026",
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 28, 2026",
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 28, 2026",
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 28, 2026",
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"], shortcut: ["Mod", "Shift", "L"] },
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
 
@@ -28,5 +28,8 @@ export interface CommandDefinition {
28
28
  group: CommandGroup
29
29
  action: CommandAction
30
30
  keywords?: string[]
31
+ /** 序列键(如 ["G","D"]),注册为 useHotkeySequences */
31
32
  shortcut?: string[]
33
+ /** 组合键(如 ["Mod","Shift","L"]),仅用于面板展示 */
34
+ chord?: string[]
32
35
  }
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 { generateEmbedding, chunkText } from "@allbluecn/ai-gateway"
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
- return searchByVector(embedding, table, limit, whereColumn, whereValue)
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
  }
@@ -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 { generateEmbedding, chunkText } from "@allbluecn/ai-gateway"
24
- import { insertEmbedding, deleteEmbeddings } from "@/lib/pgvector"
25
-
26
- const KB_EMBEDDINGS_TABLE = "knowledge_embeddings"
27
- const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
28
- const PLACEHOLDER_KEYS = new Set(["sk-your-api-key-here", "", "sk-or-your-api-key-here"])
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
- indexDocumentById(data.id).catch(() => {})
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' })
package/vite.shared.ts CHANGED
@@ -31,6 +31,7 @@
31
31
  import { defineConfig, type PluginOption } from 'vite'
32
32
  import path from 'node:path'
33
33
  import { readFileSync } from 'node:fs'
34
+ import { execFileSync } from 'node:child_process'
34
35
 
35
36
  // 从 apps/web/package.json 读取版本号,构建时注入到客户端/SSR
36
37
  function getWebAppVersion(rootDir: string): string {
@@ -42,6 +43,21 @@ function getWebAppVersion(rootDir: string): string {
42
43
  }
43
44
  }
44
45
 
46
+ // 获取最新 git commit 日期,构建时注入到客户端/SSR
47
+ // AllBlue 与 Pro 仓库各自构建,自然得到不同的更新日期
48
+ function getAppUpdateDate(rootDir: string): string {
49
+ try {
50
+ const date = execFileSync('git', ['log', '-1', '--format=%cI'], {
51
+ cwd: rootDir,
52
+ encoding: 'utf-8',
53
+ stdio: ['ignore', 'pipe', 'ignore'],
54
+ }).trim()
55
+ return date.slice(0, 10)
56
+ } catch {
57
+ return '1970-01-01'
58
+ }
59
+ }
60
+
45
61
  function prismaDirnamePolyfill(rootDir: string): PluginOption {
46
62
  return {
47
63
  name: 'prisma-dirname-polyfill',
@@ -89,6 +105,7 @@ export interface WebViteConfigOptions {
89
105
 
90
106
  export function createWebViteConfig(opts: WebViteConfigOptions) {
91
107
  const webVersion = getWebAppVersion(opts.rootDir)
108
+ const appUpdateDate = getAppUpdateDate(opts.rootDir)
92
109
  return defineConfig(async () => {
93
110
  const deployTarget = process.env.DEPLOY_TARGET
94
111
  const plugins: PluginOption[] = [...opts.basePlugins, tiptapAliasPlugin(opts.rootDir)]
@@ -115,6 +132,7 @@ export function createWebViteConfig(opts: WebViteConfigOptions) {
115
132
  process.env.DEPLOY_TARGET ?? 'web',
116
133
  ),
117
134
  '__APP_VERSION__': JSON.stringify(webVersion),
135
+ '__APP_UPDATE_DATE__': JSON.stringify(appUpdateDate),
118
136
  },
119
137
  server: {
120
138
  port: 3000,
@@ -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
- }
@@ -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
- }