@lemoncat7/dsh-knowledge 0.6.0-alpha.6
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/LICENSE +21 -0
- package/README.md +184 -0
- package/cordis.patch.yml +12 -0
- package/docs/architecture.md +107 -0
- package/docs/document-knowledge-design.zh-CN.md +329 -0
- package/docs/requirements.md +86 -0
- package/lib/api.d.ts +4 -0
- package/lib/api.d.ts.map +1 -0
- package/lib/api.js +497 -0
- package/lib/api.js.map +1 -0
- package/lib/client.js +420 -0
- package/lib/client.js.map +7 -0
- package/lib/config.d.ts +38 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +84 -0
- package/lib/config.js.map +1 -0
- package/lib/connection.d.ts +15 -0
- package/lib/connection.d.ts.map +1 -0
- package/lib/connection.js +95 -0
- package/lib/connection.js.map +1 -0
- package/lib/control.d.ts +26 -0
- package/lib/control.d.ts.map +1 -0
- package/lib/control.js +149 -0
- package/lib/control.js.map +1 -0
- package/lib/domain.d.ts +207 -0
- package/lib/domain.d.ts.map +1 -0
- package/lib/domain.js +122 -0
- package/lib/domain.js.map +1 -0
- package/lib/extraction.d.ts +27 -0
- package/lib/extraction.d.ts.map +1 -0
- package/lib/extraction.js +367 -0
- package/lib/extraction.js.map +1 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +198 -0
- package/lib/index.js.map +1 -0
- package/lib/local-provider.d.ts +60 -0
- package/lib/local-provider.d.ts.map +1 -0
- package/lib/local-provider.js +1096 -0
- package/lib/local-provider.js.map +1 -0
- package/lib/provider-router.d.ts +19 -0
- package/lib/provider-router.d.ts.map +1 -0
- package/lib/provider-router.js +83 -0
- package/lib/provider-router.js.map +1 -0
- package/lib/provider.d.ts +38 -0
- package/lib/provider.d.ts.map +1 -0
- package/lib/provider.js +2 -0
- package/lib/provider.js.map +1 -0
- package/lib/recall.d.ts +8 -0
- package/lib/recall.d.ts.map +1 -0
- package/lib/recall.js +76 -0
- package/lib/recall.js.map +1 -0
- package/lib/remote-provider.d.ts +51 -0
- package/lib/remote-provider.d.ts.map +1 -0
- package/lib/remote-provider.js +216 -0
- package/lib/remote-provider.js.map +1 -0
- package/lib/retrieval.d.ts +35 -0
- package/lib/retrieval.d.ts.map +1 -0
- package/lib/retrieval.js +206 -0
- package/lib/retrieval.js.map +1 -0
- package/lib/runtime.d.ts +147 -0
- package/lib/runtime.d.ts.map +1 -0
- package/lib/runtime.js +26 -0
- package/lib/runtime.js.map +1 -0
- package/lib/tools.d.ts +6 -0
- package/lib/tools.d.ts.map +1 -0
- package/lib/tools.js +99 -0
- package/lib/tools.js.map +1 -0
- package/lib/web.d.ts +3 -0
- package/lib/web.d.ts.map +1 -0
- package/lib/web.js +71 -0
- package/lib/web.js.map +1 -0
- package/package.json +79 -0
- package/web/app.js +1686 -0
- package/web/index.html +19 -0
- package/web/styles.css +786 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,1686 @@
|
|
|
1
|
+
const API_BASE = document.querySelector('meta[name="dsh-knowledge-api"]')?.content || '/knowledge-api/v1'
|
|
2
|
+
const TOKEN_KEY = 'dsh-knowledge.session-token'
|
|
3
|
+
const TYPES = ['preference', 'fact', 'decision', 'procedure', 'lesson']
|
|
4
|
+
const TYPE_LABELS = { preference: '偏好', fact: '事实', decision: '决策', procedure: '流程', lesson: '经验' }
|
|
5
|
+
const ACTION_LABELS = { create: '新增', update: '更新', conflict: '冲突' }
|
|
6
|
+
const STATUS_LABELS = { active: '生效中', archived: '已归档', pending: '待审核', approved: '已通过', rejected: '已拒绝' }
|
|
7
|
+
const CHANGE_LABELS = { create: '创建', update: '更新', archive: '归档', restore: '恢复' }
|
|
8
|
+
const WRITE_MODE_LABELS = { none: '仅召回', audit: '审核写入', direct: '直接写入' }
|
|
9
|
+
const TYPE_DOCUMENTS = {
|
|
10
|
+
preference: 'preferences.md', fact: 'facts.md', decision: 'decisions.md', procedure: 'procedures.md', lesson: 'lessons.md',
|
|
11
|
+
}
|
|
12
|
+
const DOCUMENT_LAYOUT_KEY = 'dsh-knowledge.document-layout'
|
|
13
|
+
const pageParams = new URLSearchParams(location.search)
|
|
14
|
+
const mountContext = {
|
|
15
|
+
sessionId: pageParams.get('sessionId')?.trim() || '',
|
|
16
|
+
projectId: pageParams.get('projectId')?.trim() || '',
|
|
17
|
+
}
|
|
18
|
+
const app = document.querySelector('#app')
|
|
19
|
+
const toastRegion = document.querySelector('#toast-region')
|
|
20
|
+
const savedDocumentLayout = readDocumentLayout()
|
|
21
|
+
|
|
22
|
+
const state = {
|
|
23
|
+
token: sessionStorage.getItem(TOKEN_KEY) || '',
|
|
24
|
+
view: 'overview',
|
|
25
|
+
menuOpen: false,
|
|
26
|
+
stats: null,
|
|
27
|
+
overview: null,
|
|
28
|
+
knowledgeBases: [],
|
|
29
|
+
mounts: [],
|
|
30
|
+
resolvedMounts: [],
|
|
31
|
+
mountContext,
|
|
32
|
+
mountManager: {
|
|
33
|
+
targetKind: mountContext.sessionId ? 'session' : 'project',
|
|
34
|
+
query: '',
|
|
35
|
+
filter: 'all',
|
|
36
|
+
selectedIds: new Set(),
|
|
37
|
+
},
|
|
38
|
+
entries: [],
|
|
39
|
+
nextCursor: null,
|
|
40
|
+
entryFilters: { query: '', type: '', status: 'active', projectId: '', knowledgeBaseId: '' },
|
|
41
|
+
documents: [],
|
|
42
|
+
documentView: {
|
|
43
|
+
knowledgeBaseId: '', documentId: '', query: '', mode: 'documents',
|
|
44
|
+
sidebarHidden: savedDocumentLayout.sidebarHidden,
|
|
45
|
+
libraryHidden: savedDocumentLayout.libraryHidden,
|
|
46
|
+
documentListHidden: savedDocumentLayout.documentListHidden,
|
|
47
|
+
sidebarWidth: savedDocumentLayout.sidebarWidth,
|
|
48
|
+
libraryWidth: savedDocumentLayout.libraryWidth,
|
|
49
|
+
documentListWidth: savedDocumentLayout.documentListWidth,
|
|
50
|
+
},
|
|
51
|
+
candidates: [],
|
|
52
|
+
candidateStatus: 'pending',
|
|
53
|
+
tokens: [],
|
|
54
|
+
loading: false,
|
|
55
|
+
error: '',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readDocumentLayout() {
|
|
59
|
+
const fallback = {
|
|
60
|
+
sidebarHidden: false, libraryHidden: false, documentListHidden: false,
|
|
61
|
+
sidebarWidth: 236, libraryWidth: 220, documentListWidth: 280,
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const value = JSON.parse(localStorage.getItem(DOCUMENT_LAYOUT_KEY) || '{}')
|
|
65
|
+
return {
|
|
66
|
+
sidebarHidden: value.sidebarHidden === true,
|
|
67
|
+
libraryHidden: value.libraryHidden === true,
|
|
68
|
+
documentListHidden: value.documentListHidden === true,
|
|
69
|
+
sidebarWidth: clampNumber(value.sidebarWidth, 190, 340, fallback.sidebarWidth),
|
|
70
|
+
libraryWidth: clampNumber(value.libraryWidth, 170, 360, fallback.libraryWidth),
|
|
71
|
+
documentListWidth: clampNumber(value.documentListWidth, 210, 480, fallback.documentListWidth),
|
|
72
|
+
}
|
|
73
|
+
} catch { return fallback }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function saveDocumentLayout() {
|
|
77
|
+
try {
|
|
78
|
+
localStorage.setItem(DOCUMENT_LAYOUT_KEY, JSON.stringify({
|
|
79
|
+
sidebarHidden: state.documentView.sidebarHidden,
|
|
80
|
+
libraryHidden: state.documentView.libraryHidden,
|
|
81
|
+
documentListHidden: state.documentView.documentListHidden,
|
|
82
|
+
sidebarWidth: state.documentView.sidebarWidth,
|
|
83
|
+
libraryWidth: state.documentView.libraryWidth,
|
|
84
|
+
documentListWidth: state.documentView.documentListWidth,
|
|
85
|
+
}))
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function clampNumber(value, minimum, maximum, fallback) {
|
|
90
|
+
const number = Number(value)
|
|
91
|
+
return Number.isFinite(number) ? Math.min(maximum, Math.max(minimum, Math.round(number))) : fallback
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function element(tag, attributes = {}, ...children) {
|
|
95
|
+
const node = document.createElement(tag)
|
|
96
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
97
|
+
if (value === undefined || value === null || value === false) continue
|
|
98
|
+
if (key === 'class') node.className = value
|
|
99
|
+
else if (key === 'text') node.textContent = value
|
|
100
|
+
else if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2).toLowerCase(), value)
|
|
101
|
+
else if (key === 'checked' || key === 'selected' || key === 'disabled') node[key] = Boolean(value)
|
|
102
|
+
else node.setAttribute(key, String(value))
|
|
103
|
+
}
|
|
104
|
+
for (const child of children.flat(Infinity)) {
|
|
105
|
+
if (child === undefined || child === null || child === false) continue
|
|
106
|
+
node.append(child instanceof Node ? child : document.createTextNode(String(child)))
|
|
107
|
+
}
|
|
108
|
+
return node
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function actionButton(label, onClick, variant = '', attributes = {}) {
|
|
112
|
+
return element('button', { type: 'button', class: `button ${variant}`.trim(), onClick, ...attributes }, label)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function paneToggleButton(pane, visible, onClick, label) {
|
|
116
|
+
const action = `${visible ? '隐藏' : '显示'}${label}`
|
|
117
|
+
return element('button', {
|
|
118
|
+
type: 'button', class: 'pane-toggle-button', 'data-pane': pane,
|
|
119
|
+
'aria-label': action, 'aria-pressed': String(visible), title: action, onClick,
|
|
120
|
+
}, element('span', { class: `pane-icon pane-icon-${pane}`, 'aria-hidden': 'true' }))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function badge(label, variant = '') {
|
|
124
|
+
return element('span', { class: `badge ${variant}`.trim() }, label)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function showToast(message, kind = '') {
|
|
128
|
+
const toast = element('div', { class: `toast ${kind}`.trim(), role: kind === 'error' ? 'alert' : 'status' }, message)
|
|
129
|
+
toastRegion.append(toast)
|
|
130
|
+
window.setTimeout(() => toast.remove(), 4200)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function api(path, options = {}) {
|
|
134
|
+
const headers = { accept: 'application/json', ...(options.body === undefined ? {} : { 'content-type': 'application/json' }) }
|
|
135
|
+
if (state.token) headers.authorization = `Bearer ${state.token}`
|
|
136
|
+
const response = await fetch(`${API_BASE}/${path.replace(/^\/+/, '')}`, {
|
|
137
|
+
method: options.method || 'GET',
|
|
138
|
+
headers,
|
|
139
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
140
|
+
signal: options.signal,
|
|
141
|
+
})
|
|
142
|
+
const text = await response.text()
|
|
143
|
+
let payload
|
|
144
|
+
if (text) {
|
|
145
|
+
try { payload = JSON.parse(text) } catch { throw new Error('服务返回了无法识别的数据') }
|
|
146
|
+
}
|
|
147
|
+
if (!response.ok) {
|
|
148
|
+
const error = new Error(payload?.error || `请求失败(HTTP ${response.status})`)
|
|
149
|
+
error.status = response.status
|
|
150
|
+
throw error
|
|
151
|
+
}
|
|
152
|
+
return payload
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function boot() {
|
|
156
|
+
if (!state.token) {
|
|
157
|
+
renderLogin()
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
await api('entries?limit=1')
|
|
162
|
+
await navigate('overview')
|
|
163
|
+
} catch (error) {
|
|
164
|
+
sessionStorage.removeItem(TOKEN_KEY)
|
|
165
|
+
state.token = ''
|
|
166
|
+
renderLogin(error.status === 401 ? '令牌无效或已被撤销,请重新输入。' : '暂时无法连接知识库。')
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function renderLogin(message = '') {
|
|
171
|
+
const tokenInput = element('input', {
|
|
172
|
+
class: 'input', type: 'password', name: 'token', autocomplete: 'current-password',
|
|
173
|
+
placeholder: '输入管理员或只读访问令牌', required: true, autofocus: true,
|
|
174
|
+
})
|
|
175
|
+
const error = element('p', { class: 'login-error', role: 'alert' }, message)
|
|
176
|
+
const submit = actionButton('连接知识库', () => form.requestSubmit(), 'primary')
|
|
177
|
+
const form = element('form', {
|
|
178
|
+
onSubmit: async (event) => {
|
|
179
|
+
event.preventDefault()
|
|
180
|
+
const token = tokenInput.value.trim()
|
|
181
|
+
if (!token) return
|
|
182
|
+
submit.disabled = true
|
|
183
|
+
submit.textContent = '正在连接…'
|
|
184
|
+
error.textContent = ''
|
|
185
|
+
state.token = token
|
|
186
|
+
try {
|
|
187
|
+
await api('entries?limit=1')
|
|
188
|
+
sessionStorage.setItem(TOKEN_KEY, token)
|
|
189
|
+
await navigate('overview')
|
|
190
|
+
} catch (requestError) {
|
|
191
|
+
state.token = ''
|
|
192
|
+
error.textContent = requestError.status === 401 ? '令牌无效或已被撤销。' : '连接失败,请检查服务状态后重试。'
|
|
193
|
+
submit.disabled = false
|
|
194
|
+
submit.textContent = '连接知识库'
|
|
195
|
+
tokenInput.focus()
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
element('div', { class: 'field' },
|
|
200
|
+
element('label', { for: 'login-token' }, '访问令牌'),
|
|
201
|
+
Object.assign(tokenInput, { id: 'login-token' }),
|
|
202
|
+
element('span', { class: 'field-hint' }, '令牌仅保存在当前浏览器标签页,关闭后自动清除。'),
|
|
203
|
+
), error, submit)
|
|
204
|
+
|
|
205
|
+
app.replaceChildren(element('main', { class: 'login-page' }, element('section', { class: 'login-card', 'aria-labelledby': 'login-title' },
|
|
206
|
+
element('div', { class: 'brand-mark', 'aria-hidden': 'true' }, 'K'),
|
|
207
|
+
element('h1', { id: 'login-title' }, 'DSH 知识库'),
|
|
208
|
+
element('p', {}, '管理对话中沉淀的长期知识与 AI 提取候选。'),
|
|
209
|
+
form,
|
|
210
|
+
)))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function signOut() {
|
|
214
|
+
sessionStorage.removeItem(TOKEN_KEY)
|
|
215
|
+
Object.assign(state, { token: '', stats: null, overview: null, knowledgeBases: [], mounts: [], resolvedMounts: [], entries: [], documents: [], candidates: [], tokens: [] })
|
|
216
|
+
renderLogin()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function navigate(view) {
|
|
220
|
+
state.view = view
|
|
221
|
+
state.menuOpen = false
|
|
222
|
+
state.loading = true
|
|
223
|
+
state.error = ''
|
|
224
|
+
renderShell()
|
|
225
|
+
try {
|
|
226
|
+
if (view === 'overview') await loadOverview()
|
|
227
|
+
if (view === 'bases') await loadKnowledgeBasesPage()
|
|
228
|
+
if (view === 'entries') await loadDocuments()
|
|
229
|
+
if (view === 'candidates') await loadCandidates()
|
|
230
|
+
if (view === 'tokens') await loadTokens()
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (error.status === 401) return signOut()
|
|
233
|
+
state.error = friendlyError(error)
|
|
234
|
+
} finally {
|
|
235
|
+
state.loading = false
|
|
236
|
+
renderShell()
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function refreshStats() {
|
|
241
|
+
state.stats = await api('stats')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function ensureKnowledgeBases(force = false) {
|
|
245
|
+
if (force || state.knowledgeBases.length === 0) state.knowledgeBases = await api('knowledge-bases')
|
|
246
|
+
return state.knowledgeBases
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function loadKnowledgeBasesPage() {
|
|
250
|
+
const requests = [api('knowledge-bases'), api('mounts')]
|
|
251
|
+
if (state.mountContext.sessionId) {
|
|
252
|
+
const params = new URLSearchParams({ sessionId: state.mountContext.sessionId })
|
|
253
|
+
if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
|
|
254
|
+
requests.push(api(`mounts/resolve?${params}`))
|
|
255
|
+
}
|
|
256
|
+
const [bases, mounts, resolved = []] = await Promise.all(requests)
|
|
257
|
+
state.knowledgeBases = bases
|
|
258
|
+
state.mounts = mounts
|
|
259
|
+
state.resolvedMounts = resolved
|
|
260
|
+
await refreshStats()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function loadOverview() {
|
|
264
|
+
const [stats, recent, pending, bases] = await Promise.all([
|
|
265
|
+
api('stats'),
|
|
266
|
+
api('entries?status=active&limit=6'),
|
|
267
|
+
api('candidates?status=pending&limit=5'),
|
|
268
|
+
api('knowledge-bases'),
|
|
269
|
+
])
|
|
270
|
+
state.stats = stats
|
|
271
|
+
state.knowledgeBases = bases
|
|
272
|
+
state.overview = { recent: recent.items, pending }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function loadEntries(cursor = '') {
|
|
276
|
+
await ensureKnowledgeBases()
|
|
277
|
+
const filters = state.entryFilters
|
|
278
|
+
let result
|
|
279
|
+
if (filters.query.trim() && filters.status === 'active' && !cursor) {
|
|
280
|
+
const params = new URLSearchParams({ q: filters.query.trim(), limit: '100' })
|
|
281
|
+
if (filters.projectId.trim()) params.set('projectId', filters.projectId.trim())
|
|
282
|
+
if (filters.type) params.append('type', filters.type)
|
|
283
|
+
if (filters.knowledgeBaseId) params.append('knowledgeBaseId', filters.knowledgeBaseId)
|
|
284
|
+
const hits = await api(`search?${params}`)
|
|
285
|
+
result = { items: hits.map(hit => hit.entry) }
|
|
286
|
+
} else {
|
|
287
|
+
const params = new URLSearchParams({ limit: '50', status: filters.status })
|
|
288
|
+
if (filters.type) params.set('type', filters.type)
|
|
289
|
+
if (filters.projectId.trim()) params.set('projectId', filters.projectId.trim())
|
|
290
|
+
if (filters.knowledgeBaseId) params.set('knowledgeBaseId', filters.knowledgeBaseId)
|
|
291
|
+
if (cursor) params.set('cursor', cursor)
|
|
292
|
+
result = await api(`entries?${params}`)
|
|
293
|
+
}
|
|
294
|
+
state.entries = cursor ? [...state.entries, ...result.items] : result.items
|
|
295
|
+
state.nextCursor = result.nextCursor || null
|
|
296
|
+
if (!state.stats) await refreshStats()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function loadDocuments() {
|
|
300
|
+
const [bases, documents] = await Promise.all([api('knowledge-bases'), api('documents')])
|
|
301
|
+
state.knowledgeBases = bases
|
|
302
|
+
state.documents = documents
|
|
303
|
+
const view = state.documentView
|
|
304
|
+
const availableBaseIds = new Set(bases.map(base => base.id))
|
|
305
|
+
if (!view.knowledgeBaseId || !availableBaseIds.has(view.knowledgeBaseId)) {
|
|
306
|
+
view.knowledgeBaseId = state.entryFilters.knowledgeBaseId && availableBaseIds.has(state.entryFilters.knowledgeBaseId)
|
|
307
|
+
? state.entryFilters.knowledgeBaseId
|
|
308
|
+
: bases.find(base => base.status === 'active')?.id || bases[0]?.id || ''
|
|
309
|
+
}
|
|
310
|
+
selectDefaultDocument()
|
|
311
|
+
if (!state.stats) await refreshStats()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function selectDefaultDocument() {
|
|
315
|
+
const view = state.documentView
|
|
316
|
+
const documents = state.documents.filter(document => document.knowledgeBaseId === view.knowledgeBaseId)
|
|
317
|
+
if (!documents.some(document => document.id === view.documentId)) {
|
|
318
|
+
view.documentId = documents.find(document => document.relPath === 'README.md')?.id || documents[0]?.id || ''
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function loadCandidates() {
|
|
323
|
+
const [candidates] = await Promise.all([
|
|
324
|
+
api(`candidates?status=${state.candidateStatus}&limit=100`),
|
|
325
|
+
ensureKnowledgeBases(),
|
|
326
|
+
])
|
|
327
|
+
state.candidates = candidates
|
|
328
|
+
if (!state.stats) await refreshStats()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function loadTokens() {
|
|
332
|
+
state.tokens = await api('tokens')
|
|
333
|
+
if (!state.stats) await refreshStats()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function renderShell() {
|
|
337
|
+
const titles = {
|
|
338
|
+
overview: ['概览', '知识库运行状态与最近活动'],
|
|
339
|
+
bases: ['知识库', '创建知识库,并限定项目与会话的召回和写入范围'],
|
|
340
|
+
entries: ['文档', '按知识库浏览自动整理的 Markdown 文档'],
|
|
341
|
+
candidates: ['审核', '确认 AI 提取结果后再写入知识库'],
|
|
342
|
+
tokens: ['访问管理', '管理其他客户端连接中央知识库的权限'],
|
|
343
|
+
}
|
|
344
|
+
const [title, subtitle] = titles[state.view]
|
|
345
|
+
const shell = element('div', {
|
|
346
|
+
class: 'app-shell', 'data-menu-open': String(state.menuOpen),
|
|
347
|
+
'data-sidebar-hidden': String(state.documentView.sidebarHidden),
|
|
348
|
+
style: `--sidebar-width: ${state.documentView.sidebarWidth}px`,
|
|
349
|
+
},
|
|
350
|
+
renderSidebar(),
|
|
351
|
+
renderAppSidebarResizer(),
|
|
352
|
+
element('main', { class: 'main' },
|
|
353
|
+
element('header', { class: 'topbar' },
|
|
354
|
+
element('div', { class: 'topbar-title' },
|
|
355
|
+
actionButton('☰', () => { state.menuOpen = !state.menuOpen; renderShell() }, 'ghost mobile-menu', { 'aria-label': '打开导航菜单' }),
|
|
356
|
+
paneToggleButton('main', !state.documentView.sidebarHidden, () => setSidebarHidden(!state.documentView.sidebarHidden), '主导航栏'),
|
|
357
|
+
element('div', {}, element('h1', {}, title), element('p', {}, subtitle)),
|
|
358
|
+
),
|
|
359
|
+
),
|
|
360
|
+
element('div', { class: 'page' }, renderCurrentView()),
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
if (state.menuOpen) shell.addEventListener('click', (event) => {
|
|
364
|
+
if (event.target === shell) { state.menuOpen = false; renderShell() }
|
|
365
|
+
})
|
|
366
|
+
app.replaceChildren(shell)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function renderAppSidebarResizer() {
|
|
370
|
+
const minimum = 190
|
|
371
|
+
const maximum = 340
|
|
372
|
+
const value = state.documentView.sidebarWidth
|
|
373
|
+
return element('div', {
|
|
374
|
+
class: 'app-sidebar-resizer', role: 'separator', tabindex: '0',
|
|
375
|
+
title: '拖动调整主导航栏宽度',
|
|
376
|
+
'aria-label': '调整主导航栏宽度', 'aria-orientation': 'vertical',
|
|
377
|
+
'aria-valuemin': minimum, 'aria-valuemax': maximum, 'aria-valuenow': value,
|
|
378
|
+
onPointerDown: event => startSidebarResize(event, minimum, maximum),
|
|
379
|
+
onKeyDown: event => resizeSidebarWithKeyboard(event, minimum, maximum),
|
|
380
|
+
}, element('span', { 'aria-hidden': 'true' }, '⋮'))
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function startSidebarResize(event, minimum, maximum) {
|
|
384
|
+
if (event.button !== 0) return
|
|
385
|
+
event.preventDefault()
|
|
386
|
+
const handle = event.currentTarget
|
|
387
|
+
const shell = handle.closest('.app-shell')
|
|
388
|
+
if (!shell) return
|
|
389
|
+
const startX = event.clientX
|
|
390
|
+
const startWidth = state.documentView.sidebarWidth
|
|
391
|
+
handle.setPointerCapture?.(event.pointerId)
|
|
392
|
+
handle.classList.add('is-dragging')
|
|
393
|
+
document.body.classList.add('is-resizing-columns')
|
|
394
|
+
const move = moveEvent => {
|
|
395
|
+
const width = clampNumber(startWidth + moveEvent.clientX - startX, minimum, maximum, startWidth)
|
|
396
|
+
setSidebarWidth(width, shell, handle)
|
|
397
|
+
}
|
|
398
|
+
const finish = () => {
|
|
399
|
+
handle.classList.remove('is-dragging')
|
|
400
|
+
document.body.classList.remove('is-resizing-columns')
|
|
401
|
+
handle.removeEventListener('pointermove', move)
|
|
402
|
+
handle.removeEventListener('pointerup', finish)
|
|
403
|
+
handle.removeEventListener('pointercancel', finish)
|
|
404
|
+
saveDocumentLayout()
|
|
405
|
+
}
|
|
406
|
+
handle.addEventListener('pointermove', move)
|
|
407
|
+
handle.addEventListener('pointerup', finish)
|
|
408
|
+
handle.addEventListener('pointercancel', finish)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function resizeSidebarWithKeyboard(event, minimum, maximum) {
|
|
412
|
+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
|
|
413
|
+
event.preventDefault()
|
|
414
|
+
const current = state.documentView.sidebarWidth
|
|
415
|
+
const width = event.key === 'Home' ? minimum
|
|
416
|
+
: event.key === 'End' ? maximum
|
|
417
|
+
: clampNumber(current + (event.key === 'ArrowRight' ? 16 : -16), minimum, maximum, current)
|
|
418
|
+
setSidebarWidth(width, event.currentTarget.closest('.app-shell'), event.currentTarget)
|
|
419
|
+
saveDocumentLayout()
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function setSidebarWidth(width, shell, handle) {
|
|
423
|
+
state.documentView.sidebarWidth = width
|
|
424
|
+
shell?.style.setProperty('--sidebar-width', `${width}px`)
|
|
425
|
+
handle?.setAttribute('aria-valuenow', String(width))
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function setSidebarHidden(hidden) {
|
|
429
|
+
state.documentView.sidebarHidden = hidden
|
|
430
|
+
saveDocumentLayout()
|
|
431
|
+
renderShell()
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function renderSidebar() {
|
|
435
|
+
const pending = state.stats?.candidates.pending
|
|
436
|
+
const navItems = [
|
|
437
|
+
['overview', '概览', '◫'], ['bases', '知识库', '▦'], ['entries', '文档', '◇'], ['candidates', '审核', '✓'], ['tokens', '访问管理', '⌁'],
|
|
438
|
+
]
|
|
439
|
+
return element('aside', { class: 'sidebar', 'aria-label': '知识库导航' },
|
|
440
|
+
element('div', { class: 'brand' },
|
|
441
|
+
element('div', { class: 'brand-mark', 'aria-hidden': 'true' }, 'K'),
|
|
442
|
+
element('div', { class: 'brand-copy' }, element('strong', {}, 'DSH Knowledge'), element('span', {}, '管理控制台')),
|
|
443
|
+
),
|
|
444
|
+
element('nav', { class: 'nav' }, navItems.map(([id, label, icon]) => element('button', {
|
|
445
|
+
type: 'button', class: 'nav-button', 'aria-current': state.view === id ? 'page' : undefined,
|
|
446
|
+
onClick: () => navigate(id),
|
|
447
|
+
}, element('span', { class: 'nav-icon', 'aria-hidden': 'true' }, icon), label,
|
|
448
|
+
id === 'candidates' && pending ? element('span', { class: 'nav-count', 'aria-label': `${pending} 条待审核` }, pending) : null))),
|
|
449
|
+
element('div', { class: 'sidebar-footer' },
|
|
450
|
+
element('div', { class: 'connection' }, element('span', { class: 'status-dot', 'aria-hidden': 'true' }), '知识库已连接'),
|
|
451
|
+
actionButton('退出当前会话', signOut, 'ghost small'),
|
|
452
|
+
),
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function renderCurrentView() {
|
|
457
|
+
if (state.loading) return loadingView()
|
|
458
|
+
if (state.error) return errorView(state.error, () => navigate(state.view))
|
|
459
|
+
if (state.view === 'overview') return renderOverview()
|
|
460
|
+
if (state.view === 'bases') return renderKnowledgeBases()
|
|
461
|
+
if (state.view === 'entries') return renderEntries()
|
|
462
|
+
if (state.view === 'candidates') return renderCandidates()
|
|
463
|
+
return renderTokens()
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function loadingView() {
|
|
467
|
+
return element('div', { class: 'loading', role: 'status' }, element('div', {}, element('div', { class: 'spinner', 'aria-hidden': 'true' }), element('span', { class: 'visually-hidden' }, '正在加载')))
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function errorView(message, retry) {
|
|
471
|
+
return element('div', { class: 'error-state', role: 'alert' }, element('p', {}, message), actionButton('重试', retry, 'small'))
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function renderOverview() {
|
|
475
|
+
const stats = state.stats
|
|
476
|
+
const overview = state.overview
|
|
477
|
+
if (!stats || !overview) return loadingView()
|
|
478
|
+
const metrics = [
|
|
479
|
+
['生效知识', stats.entries.active, `${stats.entries.archived} 条已归档`],
|
|
480
|
+
['待审核候选', stats.candidates.pending, `${stats.candidates.approved} 条已通过`],
|
|
481
|
+
['提取任务', stats.extractionJobs.total, `${stats.extractionJobs.failed} 个失败`],
|
|
482
|
+
['知识类型', Object.values(stats.entries.byType).filter(Boolean).length, '共 5 种分类'],
|
|
483
|
+
]
|
|
484
|
+
return element('div', {},
|
|
485
|
+
element('section', { class: 'metrics', 'aria-label': '知识库指标' }, metrics.map(([label, value, detail]) => element('article', { class: 'metric' },
|
|
486
|
+
element('div', { class: 'metric-label' }, label),
|
|
487
|
+
element('strong', { class: 'metric-value' }, value),
|
|
488
|
+
element('div', { class: 'metric-detail' }, detail),
|
|
489
|
+
))),
|
|
490
|
+
element('div', { class: 'dashboard-grid' },
|
|
491
|
+
element('section', { class: 'panel', 'aria-labelledby': 'recent-title' },
|
|
492
|
+
element('div', { class: 'panel-header' }, element('h2', { id: 'recent-title' }, '最近更新'), actionButton('查看全部', () => navigate('entries'), 'ghost small')),
|
|
493
|
+
element('div', { class: 'panel-body' }, overview.recent.length ? element('div', { class: 'list' }, overview.recent.map(renderCompactEntry)) : compactEmpty('还没有已生效知识')),
|
|
494
|
+
),
|
|
495
|
+
element('section', { class: 'panel', 'aria-labelledby': 'pending-title' },
|
|
496
|
+
element('div', { class: 'panel-header' }, element('h2', { id: 'pending-title' }, '等待确认'), actionButton('进入审核', () => navigate('candidates'), 'ghost small')),
|
|
497
|
+
element('div', { class: 'panel-body' }, overview.pending.length ? element('div', { class: 'list' }, overview.pending.map(renderCompactCandidate)) : compactEmpty('当前没有待审核候选')),
|
|
498
|
+
),
|
|
499
|
+
),
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function renderKnowledgeBases() {
|
|
504
|
+
const activeBases = state.knowledgeBases.filter(base => base.status === 'active')
|
|
505
|
+
const archivedBases = state.knowledgeBases.filter(base => base.status === 'archived')
|
|
506
|
+
const contextAvailable = Boolean(state.mountContext.projectId || state.mountContext.sessionId)
|
|
507
|
+
return element('div', { class: 'bases-page' },
|
|
508
|
+
element('section', { 'aria-labelledby': 'bases-heading' },
|
|
509
|
+
element('div', { class: 'section-heading' },
|
|
510
|
+
element('div', {}, element('h2', { id: 'bases-heading' }, `知识库 · ${activeBases.length}`), element('p', {}, '每个知识库拥有独立的默认标签和提取规则。')),
|
|
511
|
+
actionButton('+ 创建知识库', () => openKnowledgeBaseEditor(), 'primary'),
|
|
512
|
+
),
|
|
513
|
+
activeBases.length
|
|
514
|
+
? element('div', { class: 'base-grid' }, activeBases.map(renderKnowledgeBaseCard))
|
|
515
|
+
: emptyState('还没有可用知识库', '先创建一个知识库,再挂载到项目或会话。', '创建知识库', () => openKnowledgeBaseEditor()),
|
|
516
|
+
archivedBases.length ? element('details', { class: 'archived-bases' },
|
|
517
|
+
element('summary', {}, `已归档知识库 · ${archivedBases.length}`),
|
|
518
|
+
element('div', { class: 'base-grid' }, archivedBases.map(renderKnowledgeBaseCard)),
|
|
519
|
+
) : null,
|
|
520
|
+
),
|
|
521
|
+
element('section', { class: 'mount-section', 'aria-labelledby': 'mounts-heading' },
|
|
522
|
+
element('div', { class: 'section-heading' }, element('div', {},
|
|
523
|
+
element('h2', { id: 'mounts-heading' }, '当前项目与会话挂载'),
|
|
524
|
+
element('p', {}, '会话默认继承项目设置;创建会话覆盖后,可独立调整或关闭。'),
|
|
525
|
+
)),
|
|
526
|
+
contextAvailable
|
|
527
|
+
? element('div', { class: 'mount-context' },
|
|
528
|
+
state.mountContext.projectId ? contextPill('项目', state.mountContext.projectId) : contextPill('项目', '当前页面未提供'),
|
|
529
|
+
state.mountContext.sessionId ? contextPill('会话', state.mountContext.sessionId) : contextPill('会话', '当前页面未提供'),
|
|
530
|
+
)
|
|
531
|
+
: element('div', { class: 'context-warning' }, '请从 DSH 当前会话的左侧“知识库”入口打开,才能管理当前项目和会话的挂载。'),
|
|
532
|
+
contextAvailable && activeBases.length
|
|
533
|
+
? renderMountManager(activeBases)
|
|
534
|
+
: null,
|
|
535
|
+
),
|
|
536
|
+
)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function contextPill(label, value) {
|
|
540
|
+
return element('div', { class: 'context-pill' }, element('strong', {}, label), element('span', { title: value }, value))
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function renderKnowledgeBaseCard(base) {
|
|
544
|
+
const archived = base.status === 'archived'
|
|
545
|
+
return element('article', { class: `base-card${archived ? ' is-archived' : ''}` },
|
|
546
|
+
element('div', { class: 'base-card-header' },
|
|
547
|
+
element('div', {}, element('h3', {}, base.name), element('small', {}, base.id === 'default' ? '系统默认库' : `ID · ${base.id}`)),
|
|
548
|
+
badge(archived ? '已归档' : '可用', archived ? '' : 'success'),
|
|
549
|
+
),
|
|
550
|
+
element('div', { class: 'base-description' }, element('strong', {}, '回写匹配描述'), element('p', {}, base.description || '未设置:按通用知识库处理')),
|
|
551
|
+
base.defaultTags.length
|
|
552
|
+
? element('div', { class: 'tag-row' }, base.defaultTags.map(tag => element('span', { class: 'tag' }, `#${tag}`)))
|
|
553
|
+
: element('span', { class: 'field-hint' }, '无默认标签'),
|
|
554
|
+
base.extractionInstructions
|
|
555
|
+
? element('div', { class: 'base-instructions' }, element('strong', {}, '提取要求'), element('span', {}, base.extractionInstructions))
|
|
556
|
+
: null,
|
|
557
|
+
element('div', { class: 'base-model' },
|
|
558
|
+
element('strong', {}, '回写模型'),
|
|
559
|
+
badge(base.writebackProvider && base.writebackModel ? `${base.writebackProvider} / ${base.writebackModel}` : '跟随当前会话'),
|
|
560
|
+
),
|
|
561
|
+
element('div', { class: 'base-card-actions' },
|
|
562
|
+
actionButton('查看知识', () => {
|
|
563
|
+
state.entryFilters.knowledgeBaseId = base.id
|
|
564
|
+
state.documentView.knowledgeBaseId = base.id
|
|
565
|
+
state.documentView.documentId = ''
|
|
566
|
+
state.documentView.mode = 'documents'
|
|
567
|
+
void navigate('entries')
|
|
568
|
+
}, 'ghost small'),
|
|
569
|
+
archived ? actionButton('恢复', () => confirmRestoreKnowledgeBase(base), 'small') : actionButton('编辑', () => openKnowledgeBaseEditor(base), 'small'),
|
|
570
|
+
archived ? actionButton('永久删除', () => confirmDeleteKnowledgeBase(base), 'danger small') : null,
|
|
571
|
+
!archived && base.id !== 'default' ? actionButton('归档', () => confirmArchiveKnowledgeBase(base), 'danger small') : null,
|
|
572
|
+
),
|
|
573
|
+
)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function findExplicitMount(baseId, targetKind, targetId) {
|
|
577
|
+
return state.mounts.find(mount => mount.knowledgeBaseId === baseId && mount.targetKind === targetKind && mount.targetId === targetId)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function mountView(base, targetKind, targetId) {
|
|
581
|
+
const explicit = findExplicitMount(base.id, targetKind, targetId)
|
|
582
|
+
const inherited = targetKind === 'session' && !explicit && state.mountContext.projectId
|
|
583
|
+
? findExplicitMount(base.id, 'project', state.mountContext.projectId)
|
|
584
|
+
: undefined
|
|
585
|
+
const source = explicit || (inherited?.enabled ? inherited : undefined)
|
|
586
|
+
let statusKey
|
|
587
|
+
let statusLabel
|
|
588
|
+
let statusVariant = ''
|
|
589
|
+
let detail
|
|
590
|
+
if (explicit && !explicit.enabled) {
|
|
591
|
+
statusKey = 'disabled'
|
|
592
|
+
statusLabel = '已关闭'
|
|
593
|
+
statusVariant = 'danger'
|
|
594
|
+
detail = '显式禁用,不会继承项目设置'
|
|
595
|
+
} else if (source) {
|
|
596
|
+
statusKey = explicit ? 'mounted' : 'inherited'
|
|
597
|
+
statusLabel = explicit ? '已挂载' : '继承项目'
|
|
598
|
+
statusVariant = explicit ? 'success' : 'accent'
|
|
599
|
+
detail = `${source.recallEnabled ? '召回开启' : '召回关闭'} · ${WRITE_MODE_LABELS[source.writeMode]}`
|
|
600
|
+
} else {
|
|
601
|
+
statusKey = 'unmounted'
|
|
602
|
+
statusLabel = '未挂载'
|
|
603
|
+
detail = '不召回、不提取、不回写'
|
|
604
|
+
}
|
|
605
|
+
return { explicit, inherited, source, statusKey, statusLabel, statusVariant, detail }
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function renderMountManager(activeBases) {
|
|
609
|
+
const manager = state.mountManager
|
|
610
|
+
const availableKinds = [
|
|
611
|
+
state.mountContext.projectId ? ['project', '项目'] : null,
|
|
612
|
+
state.mountContext.sessionId ? ['session', '会话'] : null,
|
|
613
|
+
].filter(Boolean)
|
|
614
|
+
if (!availableKinds.some(([kind]) => kind === manager.targetKind)) manager.targetKind = availableKinds[0][0]
|
|
615
|
+
const targetId = manager.targetKind === 'project' ? state.mountContext.projectId : state.mountContext.sessionId
|
|
616
|
+
const query = manager.query.trim().toLowerCase()
|
|
617
|
+
const rows = activeBases.map(base => ({ base, view: mountView(base, manager.targetKind, targetId) }))
|
|
618
|
+
const visibleRows = rows.filter(({ base, view }) => {
|
|
619
|
+
const model = base.writebackProvider && base.writebackModel ? `${base.writebackProvider} ${base.writebackModel}` : '跟随会话'
|
|
620
|
+
const searchable = `${base.name} ${base.description} ${base.defaultTags.join(' ')} ${model}`.toLowerCase()
|
|
621
|
+
return (!query || searchable.includes(query)) && (manager.filter === 'all' || manager.filter === view.statusKey)
|
|
622
|
+
})
|
|
623
|
+
const visibleIds = visibleRows.map(({ base }) => base.id)
|
|
624
|
+
const selectedCount = manager.selectedIds.size
|
|
625
|
+
const searchInput = element('input', {
|
|
626
|
+
class: 'input', type: 'search', value: manager.query,
|
|
627
|
+
placeholder: '搜索名称、描述、标签或模型', 'aria-label': '搜索可挂载知识库',
|
|
628
|
+
onInput: (event) => { manager.query = event.target.value; renderShell() },
|
|
629
|
+
})
|
|
630
|
+
const filter = selectControl('筛选挂载状态', [
|
|
631
|
+
{ value: 'all', label: '全部状态' },
|
|
632
|
+
{ value: 'mounted', label: '已挂载' },
|
|
633
|
+
{ value: 'unmounted', label: '未挂载' },
|
|
634
|
+
...(manager.targetKind === 'session' ? [{ value: 'inherited', label: '继承项目' }] : []),
|
|
635
|
+
{ value: 'disabled', label: '已关闭' },
|
|
636
|
+
], manager.filter, (value) => { manager.filter = value; renderShell() })
|
|
637
|
+
return element('div', { class: 'mount-manager' },
|
|
638
|
+
element('div', { class: 'mount-manager-toolbar' },
|
|
639
|
+
element('div', { class: 'tabs', role: 'tablist', 'aria-label': '挂载目标' }, availableKinds.map(([kind, label]) => element('button', {
|
|
640
|
+
type: 'button', role: 'tab', class: 'tab', 'aria-selected': String(manager.targetKind === kind),
|
|
641
|
+
onClick: () => {
|
|
642
|
+
manager.targetKind = kind
|
|
643
|
+
manager.filter = 'all'
|
|
644
|
+
manager.selectedIds.clear()
|
|
645
|
+
renderShell()
|
|
646
|
+
},
|
|
647
|
+
}, label))),
|
|
648
|
+
element('div', { class: 'search-box mount-search' }, element('span', { class: 'search-symbol', 'aria-hidden': 'true' }, '⌕'), searchInput),
|
|
649
|
+
filter,
|
|
650
|
+
),
|
|
651
|
+
element('div', { class: 'mount-target-line' },
|
|
652
|
+
element('span', {}, manager.targetKind === 'project' ? '当前项目' : '当前会话'),
|
|
653
|
+
element('code', { title: targetId }, targetId),
|
|
654
|
+
element('span', { class: 'field-hint' }, `显示 ${visibleRows.length} / ${activeBases.length}`),
|
|
655
|
+
),
|
|
656
|
+
visibleRows.length
|
|
657
|
+
? element('div', { class: 'mount-table', role: 'list', 'aria-label': '知识库挂载列表' }, visibleRows.map(({ base, view }) => renderMountListRow(base, view, manager.targetKind, targetId)))
|
|
658
|
+
: emptyState('没有匹配的知识库', '调整搜索词或筛选条件。'),
|
|
659
|
+
element('div', { class: 'mount-bulk-bar', 'aria-live': 'polite' },
|
|
660
|
+
element('strong', {}, `已选择 ${selectedCount} 个`),
|
|
661
|
+
element('div', { class: 'mount-bulk-actions' },
|
|
662
|
+
actionButton('选择当前结果', () => { visibleIds.forEach(id => manager.selectedIds.add(id)); renderShell() }, 'ghost small', { disabled: visibleIds.length === 0 }),
|
|
663
|
+
actionButton('清除选择', () => { manager.selectedIds.clear(); renderShell() }, 'ghost small', { disabled: selectedCount === 0 }),
|
|
664
|
+
actionButton(manager.targetKind === 'session' ? '恢复继承' : '取消挂载', () => void bulkRemoveMounts(), 'danger small', { disabled: selectedCount === 0 }),
|
|
665
|
+
actionButton('批量挂载', () => openBulkMountEditor(), 'primary small', { disabled: selectedCount === 0 }),
|
|
666
|
+
),
|
|
667
|
+
),
|
|
668
|
+
)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function renderMountListRow(base, view, targetKind, targetId) {
|
|
672
|
+
const selected = state.mountManager.selectedIds.has(base.id)
|
|
673
|
+
const checkbox = element('input', {
|
|
674
|
+
type: 'checkbox', checked: selected, 'aria-label': `选择 ${base.name}`,
|
|
675
|
+
onChange: (event) => {
|
|
676
|
+
if (event.target.checked) state.mountManager.selectedIds.add(base.id)
|
|
677
|
+
else state.mountManager.selectedIds.delete(base.id)
|
|
678
|
+
renderShell()
|
|
679
|
+
},
|
|
680
|
+
})
|
|
681
|
+
const modelLabel = base.writebackProvider && base.writebackModel
|
|
682
|
+
? `${base.writebackProvider} / ${base.writebackModel}`
|
|
683
|
+
: '跟随当前会话模型'
|
|
684
|
+
return element('article', { class: `mount-list-row${selected ? ' is-selected' : ''}`, role: 'listitem' },
|
|
685
|
+
element('label', { class: 'mount-select' }, checkbox, element('span', { class: 'visually-hidden' }, `选择 ${base.name}`)),
|
|
686
|
+
element('div', { class: 'mount-list-main' },
|
|
687
|
+
element('div', { class: 'mount-list-title' }, element('strong', {}, base.name), badge(view.statusLabel, view.statusVariant), badge(modelLabel)),
|
|
688
|
+
element('p', {}, base.description || '通用知识库'),
|
|
689
|
+
element('div', { class: 'mount-list-meta' }, view.detail,
|
|
690
|
+
view.source?.includeTags.length ? element('span', {}, ` · 包含 #${view.source.includeTags.join(' #')}`) : null,
|
|
691
|
+
view.source?.excludeTags.length ? element('span', {}, ` · 排除 #${view.source.excludeTags.join(' #')}`) : null,
|
|
692
|
+
),
|
|
693
|
+
),
|
|
694
|
+
actionButton(view.explicit ? '设置' : view.inherited?.enabled ? '覆盖' : '挂载', () => openMountEditor(base, targetKind, targetId, view.explicit, view.inherited), 'small'),
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function renderCompactEntry(entry) {
|
|
699
|
+
return element('article', { class: 'list-row' },
|
|
700
|
+
element('div', { class: 'list-main' },
|
|
701
|
+
element('div', { class: 'list-title' }, element('strong', {}, entry.title), badge(TYPE_LABELS[entry.type])),
|
|
702
|
+
element('p', { class: 'list-summary' }, entry.body),
|
|
703
|
+
element('div', { class: 'list-meta' }, knowledgeBaseName(entry.knowledgeBaseId), scopeLabel(entry.scope), formatDate(entry.updatedAt)),
|
|
704
|
+
),
|
|
705
|
+
actionButton('编辑', () => openEntryEditor(entry), 'ghost small'),
|
|
706
|
+
)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function renderCompactCandidate(candidate) {
|
|
710
|
+
return element('article', { class: 'list-row' },
|
|
711
|
+
element('div', { class: 'list-main' },
|
|
712
|
+
element('div', { class: 'list-title' }, element('strong', {}, candidate.draft.title), badge(ACTION_LABELS[candidate.action], candidate.action === 'conflict' ? 'warning' : 'accent')),
|
|
713
|
+
element('p', { class: 'list-summary' }, candidate.reason || candidate.draft.body),
|
|
714
|
+
element('div', { class: 'list-meta' }, knowledgeBaseName(candidate.draft.knowledgeBaseId), TYPE_LABELS[candidate.draft.type], formatDate(candidate.createdAt)),
|
|
715
|
+
),
|
|
716
|
+
actionButton('审核', () => navigate('candidates'), 'ghost small'),
|
|
717
|
+
)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function compactEmpty(message) {
|
|
721
|
+
return element('div', { class: 'loading' }, message)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function renderEntries() {
|
|
725
|
+
if (state.documentView.mode === 'entries') return renderLegacyEntries()
|
|
726
|
+
const view = state.documentView
|
|
727
|
+
const query = view.query.trim().toLocaleLowerCase()
|
|
728
|
+
const activeBases = state.knowledgeBases.filter(base => base.status === 'active')
|
|
729
|
+
const selectedBase = state.knowledgeBases.find(base => base.id === view.knowledgeBaseId)
|
|
730
|
+
const allBaseDocuments = state.documents.filter(document => document.knowledgeBaseId === view.knowledgeBaseId)
|
|
731
|
+
const visibleDocuments = allBaseDocuments.filter(document => !query || [document.title, document.relPath, document.content]
|
|
732
|
+
.some(value => value.toLocaleLowerCase().includes(query)))
|
|
733
|
+
const selectedDocument = state.documents.find(document => document.id === view.documentId)
|
|
734
|
+
const search = element('input', {
|
|
735
|
+
class: 'input', type: 'search', value: view.query, placeholder: '搜索文档标题、路径或正文', 'aria-label': '搜索知识库文档',
|
|
736
|
+
onInput: (event) => {
|
|
737
|
+
view.query = event.target.value
|
|
738
|
+
const stillVisible = state.documents.some(document => document.id === view.documentId
|
|
739
|
+
&& document.knowledgeBaseId === view.knowledgeBaseId
|
|
740
|
+
&& (!view.query.trim() || [document.title, document.relPath, document.content].some(value => value.toLocaleLowerCase().includes(view.query.trim().toLocaleLowerCase()))))
|
|
741
|
+
if (!stillVisible) view.documentId = ''
|
|
742
|
+
renderShell()
|
|
743
|
+
document.querySelector('.document-global-search input')?.focus()
|
|
744
|
+
},
|
|
745
|
+
})
|
|
746
|
+
if (!view.documentId && visibleDocuments.length) view.documentId = visibleDocuments[0].id
|
|
747
|
+
const currentDocument = state.documents.find(document => document.id === view.documentId) || selectedDocument
|
|
748
|
+
|
|
749
|
+
return element('section', { class: 'document-page', 'aria-labelledby': 'documents-heading' },
|
|
750
|
+
element('div', { class: 'document-page-toolbar' },
|
|
751
|
+
element('div', { class: 'search-box document-global-search' }, element('span', { class: 'search-symbol', 'aria-hidden': 'true' }, '⌕'), search),
|
|
752
|
+
element('select', {
|
|
753
|
+
class: 'select compact-library-picker', 'aria-label': '选择知识库', value: view.knowledgeBaseId,
|
|
754
|
+
onChange: (event) => { view.knowledgeBaseId = event.target.value; view.documentId = ''; selectDefaultDocument(); renderShell() },
|
|
755
|
+
}, activeBases.map(base => element('option', { value: base.id, selected: base.id === view.knowledgeBaseId }, base.name))),
|
|
756
|
+
element('div', { class: 'tabs', role: 'tablist', 'aria-label': '知识视图' },
|
|
757
|
+
documentViewTab('文档', 'documents'), documentViewTab('条目管理', 'entries')),
|
|
758
|
+
element('div', { class: 'pane-toggle-group document-pane-toggles', role: 'group', 'aria-label': '文档边栏显示' },
|
|
759
|
+
paneToggleButton('library', !view.libraryHidden, () => setDocumentColumnHidden('library', !view.libraryHidden), '知识库二级栏'),
|
|
760
|
+
paneToggleButton('document', !view.documentListHidden, () => setDocumentColumnHidden('documentList', !view.documentListHidden), '文档列表栏'),
|
|
761
|
+
),
|
|
762
|
+
actionButton('调整栏宽', openLayoutEditor, 'ghost small', { 'aria-label': '调整主导航、知识库和文档栏宽度' }),
|
|
763
|
+
actionButton('+ 新建知识', () => openEntryEditor(), 'primary'),
|
|
764
|
+
),
|
|
765
|
+
element('div', {
|
|
766
|
+
class: 'document-browser',
|
|
767
|
+
'data-library-hidden': String(view.libraryHidden),
|
|
768
|
+
'data-document-list-hidden': String(view.documentListHidden),
|
|
769
|
+
style: `--library-width:${view.libraryWidth}px;--document-list-width:${view.documentListWidth}px`,
|
|
770
|
+
},
|
|
771
|
+
element('aside', { class: 'knowledge-library-column', 'aria-label': '知识库列表' },
|
|
772
|
+
element('header', { class: 'column-header' },
|
|
773
|
+
element('div', {}, element('h2', { id: 'documents-heading' }, '知识库'), element('span', {}, `${activeBases.length} 个可用`)),
|
|
774
|
+
),
|
|
775
|
+
activeBases.length ? element('div', { class: 'library-list', role: 'listbox', tabindex: '0', onKeyDown: event => moveDocumentSelection(event, 'base') },
|
|
776
|
+
activeBases.map(base => {
|
|
777
|
+
const documentCount = state.documents.filter(document => document.knowledgeBaseId === base.id).length
|
|
778
|
+
const selected = base.id === view.knowledgeBaseId
|
|
779
|
+
return element('button', {
|
|
780
|
+
type: 'button', class: 'library-row', role: 'option', 'aria-selected': String(selected),
|
|
781
|
+
onClick: () => { view.knowledgeBaseId = base.id; view.documentId = ''; selectDefaultDocument(); renderShell() },
|
|
782
|
+
}, element('span', { class: 'library-dot', 'aria-hidden': 'true' }), element('span', { class: 'library-row-copy' },
|
|
783
|
+
element('strong', {}, base.name), element('small', {}, base.description || '通用知识库')),
|
|
784
|
+
element('span', { class: 'library-count', 'aria-label': `${documentCount} 篇文档` }, documentCount))
|
|
785
|
+
})) : compactEmpty('还没有知识库'),
|
|
786
|
+
element('footer', { class: 'column-footer' }, actionButton('+ 新建知识库', () => openKnowledgeBaseEditor(), 'ghost small')),
|
|
787
|
+
),
|
|
788
|
+
renderColumnResizer('library', '调整知识库栏宽度'),
|
|
789
|
+
element('aside', { class: 'document-list-column', 'aria-label': '文档列表' },
|
|
790
|
+
element('header', { class: 'column-header' },
|
|
791
|
+
element('div', {}, element('h2', {}, selectedBase?.name || '文档'), element('span', {}, query ? `找到 ${visibleDocuments.length} 篇` : `${allBaseDocuments.length} 篇文档`)),
|
|
792
|
+
),
|
|
793
|
+
visibleDocuments.length ? element('div', { class: 'document-list', role: 'listbox', tabindex: '0', onKeyDown: event => moveDocumentSelection(event, 'document') },
|
|
794
|
+
visibleDocuments.map(document => element('button', {
|
|
795
|
+
type: 'button', class: 'document-row', role: 'option', 'aria-selected': String(document.id === view.documentId),
|
|
796
|
+
onClick: () => { view.documentId = document.id; renderShell() },
|
|
797
|
+
}, element('span', { class: 'document-icon', 'aria-hidden': 'true' }, document.relPath === 'README.md' ? '▣' : '≡'),
|
|
798
|
+
element('span', { class: 'document-row-copy' }, element('strong', {}, document.title), element('small', {}, document.relPath)),
|
|
799
|
+
element('span', { class: 'document-entry-count' }, document.entryCount))))
|
|
800
|
+
: element('div', { class: 'document-empty' }, query ? '没有匹配的文档' : '这个知识库还没有文档'),
|
|
801
|
+
),
|
|
802
|
+
renderColumnResizer('documentList', '调整文档栏宽度'),
|
|
803
|
+
renderDocumentReader(currentDocument, selectedBase),
|
|
804
|
+
),
|
|
805
|
+
)
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function setDocumentColumnHidden(column, hidden) {
|
|
809
|
+
if (column === 'library') state.documentView.libraryHidden = hidden
|
|
810
|
+
else state.documentView.documentListHidden = hidden
|
|
811
|
+
saveDocumentLayout()
|
|
812
|
+
renderShell()
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function renderColumnResizer(column, label) {
|
|
816
|
+
const isLibrary = column === 'library'
|
|
817
|
+
const minimum = isLibrary ? 170 : 210
|
|
818
|
+
const maximum = isLibrary ? 360 : 480
|
|
819
|
+
const value = isLibrary ? state.documentView.libraryWidth : state.documentView.documentListWidth
|
|
820
|
+
return element('div', {
|
|
821
|
+
class: 'column-resizer', 'data-column': column, role: 'separator', tabindex: '0',
|
|
822
|
+
title: `${label};可拖动或使用左右方向键`,
|
|
823
|
+
'aria-label': label, 'aria-orientation': 'vertical',
|
|
824
|
+
'aria-valuemin': minimum, 'aria-valuemax': maximum, 'aria-valuenow': value,
|
|
825
|
+
onPointerDown: event => startColumnResize(event, column, minimum, maximum),
|
|
826
|
+
onKeyDown: event => resizeColumnWithKeyboard(event, column, minimum, maximum),
|
|
827
|
+
}, element('span', { 'aria-hidden': 'true' }, '⋮'))
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function openLayoutEditor() {
|
|
831
|
+
const fields = [
|
|
832
|
+
layoutRangeField('主导航栏', state.documentView.sidebarWidth, 190, 340),
|
|
833
|
+
layoutRangeField('知识库二级栏', state.documentView.libraryWidth, 170, 360),
|
|
834
|
+
layoutRangeField('文档列表栏', state.documentView.documentListWidth, 210, 480),
|
|
835
|
+
]
|
|
836
|
+
const visibility = [
|
|
837
|
+
layoutVisibilityOption('显示主导航栏', !state.documentView.sidebarHidden),
|
|
838
|
+
layoutVisibilityOption('显示知识库二级栏', !state.documentView.libraryHidden),
|
|
839
|
+
layoutVisibilityOption('显示文档列表栏', !state.documentView.documentListHidden),
|
|
840
|
+
]
|
|
841
|
+
const reset = actionButton('恢复默认布局', () => {
|
|
842
|
+
const defaults = [236, 220, 280]
|
|
843
|
+
fields.forEach((field, index) => field.setValue(defaults[index]))
|
|
844
|
+
visibility.forEach(option => { option.input.checked = true })
|
|
845
|
+
}, 'ghost small')
|
|
846
|
+
const body = element('div', { class: 'layout-editor' },
|
|
847
|
+
element('div', { class: 'layout-visibility', 'aria-label': '边栏显示设置' }, visibility.map(option => option.wrapper)),
|
|
848
|
+
fields.map(field => field.wrapper),
|
|
849
|
+
element('div', { class: 'layout-editor-note' },
|
|
850
|
+
element('span', {}, '也可以直接拖动栏与栏之间的三点分隔线。窗口过窄时会自动切换为紧凑布局。'),
|
|
851
|
+
reset,
|
|
852
|
+
),
|
|
853
|
+
)
|
|
854
|
+
openModal({
|
|
855
|
+
title: '调整边栏宽度',
|
|
856
|
+
description: '分别设置管理导航、知识库二级栏和文档列表栏。',
|
|
857
|
+
body,
|
|
858
|
+
primaryLabel: '应用布局',
|
|
859
|
+
onPrimary: async () => {
|
|
860
|
+
state.documentView.sidebarHidden = !visibility[0].input.checked
|
|
861
|
+
state.documentView.libraryHidden = !visibility[1].input.checked
|
|
862
|
+
state.documentView.documentListHidden = !visibility[2].input.checked
|
|
863
|
+
state.documentView.sidebarWidth = fields[0].value()
|
|
864
|
+
state.documentView.libraryWidth = fields[1].value()
|
|
865
|
+
state.documentView.documentListWidth = fields[2].value()
|
|
866
|
+
saveDocumentLayout()
|
|
867
|
+
renderShell()
|
|
868
|
+
showToast('边栏宽度已保存。')
|
|
869
|
+
return true
|
|
870
|
+
},
|
|
871
|
+
})
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function layoutVisibilityOption(label, checked) {
|
|
875
|
+
const input = element('input', { type: 'checkbox', checked })
|
|
876
|
+
return {
|
|
877
|
+
input,
|
|
878
|
+
wrapper: element('label', { class: 'check-option' }, input, element('span', {}, element('strong', {}, label))),
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function layoutRangeField(label, value, minimum, maximum) {
|
|
883
|
+
const output = element('output', { class: 'range-value' }, `${value}px`)
|
|
884
|
+
const input = element('input', {
|
|
885
|
+
type: 'range', min: minimum, max: maximum, step: '10', value,
|
|
886
|
+
'aria-label': label,
|
|
887
|
+
onInput: event => { output.textContent = `${event.target.value}px` },
|
|
888
|
+
})
|
|
889
|
+
return {
|
|
890
|
+
wrapper: element('div', { class: 'field' },
|
|
891
|
+
element('div', { class: 'layout-range-label' }, element('label', {}, label), output),
|
|
892
|
+
element('div', { class: 'range-row' }, input),
|
|
893
|
+
),
|
|
894
|
+
value: () => Number(input.value),
|
|
895
|
+
setValue: next => { input.value = String(next); output.textContent = `${next}px` },
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function startColumnResize(event, column, minimum, maximum) {
|
|
900
|
+
if (event.button !== 0) return
|
|
901
|
+
event.preventDefault()
|
|
902
|
+
const handle = event.currentTarget
|
|
903
|
+
const browser = handle.closest('.document-browser')
|
|
904
|
+
if (!browser) return
|
|
905
|
+
const startX = event.clientX
|
|
906
|
+
const startWidth = column === 'library' ? state.documentView.libraryWidth : state.documentView.documentListWidth
|
|
907
|
+
handle.setPointerCapture?.(event.pointerId)
|
|
908
|
+
handle.classList.add('is-dragging')
|
|
909
|
+
document.body.classList.add('is-resizing-columns')
|
|
910
|
+
const move = moveEvent => {
|
|
911
|
+
const width = clampNumber(startWidth + moveEvent.clientX - startX, minimum, maximum, startWidth)
|
|
912
|
+
setColumnWidth(column, width, browser, handle)
|
|
913
|
+
}
|
|
914
|
+
const finish = () => {
|
|
915
|
+
handle.classList.remove('is-dragging')
|
|
916
|
+
document.body.classList.remove('is-resizing-columns')
|
|
917
|
+
handle.removeEventListener('pointermove', move)
|
|
918
|
+
handle.removeEventListener('pointerup', finish)
|
|
919
|
+
handle.removeEventListener('pointercancel', finish)
|
|
920
|
+
saveDocumentLayout()
|
|
921
|
+
}
|
|
922
|
+
handle.addEventListener('pointermove', move)
|
|
923
|
+
handle.addEventListener('pointerup', finish)
|
|
924
|
+
handle.addEventListener('pointercancel', finish)
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function resizeColumnWithKeyboard(event, column, minimum, maximum) {
|
|
928
|
+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
|
|
929
|
+
event.preventDefault()
|
|
930
|
+
const current = column === 'library' ? state.documentView.libraryWidth : state.documentView.documentListWidth
|
|
931
|
+
const width = event.key === 'Home' ? minimum
|
|
932
|
+
: event.key === 'End' ? maximum
|
|
933
|
+
: clampNumber(current + (event.key === 'ArrowRight' ? 16 : -16), minimum, maximum, current)
|
|
934
|
+
const browser = event.currentTarget.closest('.document-browser')
|
|
935
|
+
setColumnWidth(column, width, browser, event.currentTarget)
|
|
936
|
+
saveDocumentLayout()
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function setColumnWidth(column, width, browser, handle) {
|
|
940
|
+
if (column === 'library') {
|
|
941
|
+
state.documentView.libraryWidth = width
|
|
942
|
+
browser?.style.setProperty('--library-width', `${width}px`)
|
|
943
|
+
} else {
|
|
944
|
+
state.documentView.documentListWidth = width
|
|
945
|
+
browser?.style.setProperty('--document-list-width', `${width}px`)
|
|
946
|
+
}
|
|
947
|
+
handle?.setAttribute('aria-valuenow', String(width))
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function documentViewTab(label, mode) {
|
|
951
|
+
return element('button', {
|
|
952
|
+
type: 'button', role: 'tab', class: 'tab', 'aria-selected': String(state.documentView.mode === mode),
|
|
953
|
+
onClick: async () => {
|
|
954
|
+
state.documentView.mode = mode
|
|
955
|
+
if (mode === 'entries') await loadEntries()
|
|
956
|
+
else await loadDocuments()
|
|
957
|
+
renderShell()
|
|
958
|
+
},
|
|
959
|
+
}, label)
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function moveDocumentSelection(event, kind) {
|
|
963
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Enter') return
|
|
964
|
+
const view = state.documentView
|
|
965
|
+
const values = kind === 'base'
|
|
966
|
+
? state.knowledgeBases.filter(base => base.status === 'active').map(base => base.id)
|
|
967
|
+
: state.documents.filter(document => document.knowledgeBaseId === view.knowledgeBaseId).map(document => document.id)
|
|
968
|
+
const current = kind === 'base' ? view.knowledgeBaseId : view.documentId
|
|
969
|
+
if (event.key === 'Enter') return
|
|
970
|
+
event.preventDefault()
|
|
971
|
+
const index = Math.max(0, values.indexOf(current))
|
|
972
|
+
const next = event.key === 'ArrowDown' ? Math.min(values.length - 1, index + 1) : Math.max(0, index - 1)
|
|
973
|
+
if (!values[next]) return
|
|
974
|
+
if (kind === 'base') { view.knowledgeBaseId = values[next]; view.documentId = ''; selectDefaultDocument() }
|
|
975
|
+
else view.documentId = values[next]
|
|
976
|
+
renderShell()
|
|
977
|
+
document.querySelector(kind === 'base' ? '.library-list' : '.document-list')?.focus()
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function renderDocumentReader(document, base) {
|
|
981
|
+
if (!document) return element('main', { class: 'document-reader document-reader-empty' },
|
|
982
|
+
element('div', {}, element('strong', {}, '选择一篇文档'), element('p', {}, '文档内容会在这里显示。')))
|
|
983
|
+
return element('main', { class: 'document-reader', 'aria-labelledby': 'document-reader-title' },
|
|
984
|
+
element('header', { class: 'reader-toolbar' },
|
|
985
|
+
element('div', { class: 'reader-title' }, element('span', {}, base?.name || document.knowledgeBaseId), element('strong', { id: 'document-reader-title' }, document.relPath)),
|
|
986
|
+
element('select', {
|
|
987
|
+
class: 'select mobile-document-picker', 'aria-label': '选择文档',
|
|
988
|
+
onChange: (event) => { state.documentView.documentId = event.target.value; renderShell() },
|
|
989
|
+
}, state.documents.filter(item => item.knowledgeBaseId === document.knowledgeBaseId)
|
|
990
|
+
.map(item => element('option', { value: item.id, selected: item.id === document.id }, item.relPath))),
|
|
991
|
+
element('div', { class: 'reader-actions' }, badge(`${document.entryCount} 条知识`), actionButton('添加内容', () => openEntryEditor(), 'small')),
|
|
992
|
+
),
|
|
993
|
+
element('article', { class: 'markdown-document' }, renderMarkdown(document.content)),
|
|
994
|
+
element('footer', { class: 'reader-footer' },
|
|
995
|
+
element('span', {}, `自动整理 · ${formatDate(document.updatedAt)}`),
|
|
996
|
+
element('span', {}, `SHA-256 · ${document.contentHash.slice(0, 8)}`),
|
|
997
|
+
),
|
|
998
|
+
)
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function renderMarkdown(markdown) {
|
|
1002
|
+
const nodes = []
|
|
1003
|
+
const lines = markdown.replaceAll('\r\n', '\n').split('\n')
|
|
1004
|
+
let paragraph = []
|
|
1005
|
+
let list = []
|
|
1006
|
+
let code = []
|
|
1007
|
+
let inCode = false
|
|
1008
|
+
const flushParagraph = () => {
|
|
1009
|
+
if (paragraph.length) nodes.push(element('p', {}, paragraph.join(' ')))
|
|
1010
|
+
paragraph = []
|
|
1011
|
+
}
|
|
1012
|
+
const flushList = () => {
|
|
1013
|
+
if (list.length) nodes.push(element('ul', {}, list.map(item => element('li', {}, item))))
|
|
1014
|
+
list = []
|
|
1015
|
+
}
|
|
1016
|
+
const flushCode = () => {
|
|
1017
|
+
if (code.length) nodes.push(element('pre', {}, element('code', {}, code.join('\n'))))
|
|
1018
|
+
code = []
|
|
1019
|
+
}
|
|
1020
|
+
for (const raw of lines) {
|
|
1021
|
+
const line = raw.trimEnd()
|
|
1022
|
+
if (line.startsWith('```')) {
|
|
1023
|
+
flushParagraph(); flushList()
|
|
1024
|
+
if (inCode) flushCode()
|
|
1025
|
+
inCode = !inCode
|
|
1026
|
+
continue
|
|
1027
|
+
}
|
|
1028
|
+
if (inCode) { code.push(raw); continue }
|
|
1029
|
+
const heading = /^(#{1,4})\s+(.+)$/.exec(line)
|
|
1030
|
+
const bullet = /^[-*]\s+(.+)$/.exec(line)
|
|
1031
|
+
if (heading) {
|
|
1032
|
+
flushParagraph(); flushList()
|
|
1033
|
+
nodes.push(element(`h${heading[1].length}`, {}, heading[2]))
|
|
1034
|
+
} else if (line.startsWith('> ')) {
|
|
1035
|
+
flushParagraph(); flushList()
|
|
1036
|
+
nodes.push(element('blockquote', {}, line.slice(2)))
|
|
1037
|
+
} else if (bullet) {
|
|
1038
|
+
flushParagraph(); list.push(bullet[1])
|
|
1039
|
+
} else if (!line.trim()) {
|
|
1040
|
+
flushParagraph(); flushList()
|
|
1041
|
+
} else if (/^<small>.*<\/small>$/.test(line.trim())) {
|
|
1042
|
+
flushParagraph(); flushList()
|
|
1043
|
+
nodes.push(element('small', { class: 'markdown-metadata' }, line.trim().replace(/^<small>|<\/small>$/g, '')))
|
|
1044
|
+
} else paragraph.push(line.trim())
|
|
1045
|
+
}
|
|
1046
|
+
flushParagraph(); flushList(); flushCode()
|
|
1047
|
+
return nodes
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function renderLegacyEntries() {
|
|
1051
|
+
const queryInput = element('input', { class: 'input', type: 'search', value: state.entryFilters.query, placeholder: '搜索标题、正文或标签', 'aria-label': '搜索知识' })
|
|
1052
|
+
const projectInput = element('input', { class: 'input', value: state.entryFilters.projectId, placeholder: '项目路径(可选)', 'aria-label': '按项目路径筛选' })
|
|
1053
|
+
const applyFilters = async () => {
|
|
1054
|
+
state.entryFilters.query = queryInput.value
|
|
1055
|
+
state.entryFilters.projectId = projectInput.value
|
|
1056
|
+
state.loading = true
|
|
1057
|
+
renderShell()
|
|
1058
|
+
try { await loadEntries() } catch (error) { state.error = friendlyError(error) } finally { state.loading = false; renderShell() }
|
|
1059
|
+
}
|
|
1060
|
+
const toolbar = element('form', { class: 'toolbar', onSubmit: (event) => { event.preventDefault(); void applyFilters() } },
|
|
1061
|
+
element('div', { class: 'search-box' }, element('span', { class: 'search-symbol', 'aria-hidden': 'true' }, '⌕'), queryInput),
|
|
1062
|
+
selectControl('状态', [{ value: 'active', label: '生效中' }, { value: 'archived', label: '已归档' }], state.entryFilters.status, (value) => { state.entryFilters.status = value; void applyFilters() }),
|
|
1063
|
+
selectControl('类型', [{ value: '', label: '全部类型' }, ...TYPES.map(type => ({ value: type, label: TYPE_LABELS[type] }))], state.entryFilters.type, (value) => { state.entryFilters.type = value; void applyFilters() }),
|
|
1064
|
+
selectControl('知识库', [{ value: '', label: '全部知识库' }, ...state.knowledgeBases.map(base => ({ value: base.id, label: base.name }))], state.entryFilters.knowledgeBaseId, (value) => { state.entryFilters.knowledgeBaseId = value; void applyFilters() }),
|
|
1065
|
+
projectInput,
|
|
1066
|
+
actionButton('搜索', () => {}, 'primary', { type: 'submit' }),
|
|
1067
|
+
)
|
|
1068
|
+
return element('section', { 'aria-labelledby': 'entries-heading' },
|
|
1069
|
+
element('div', { class: 'section-heading' }, element('div', {}, element('h2', { id: 'entries-heading' }, `${STATUS_LABELS[state.entryFilters.status]} · ${state.entries.length}`), element('p', {}, '搜索范围会包含全局知识和指定项目知识'))),
|
|
1070
|
+
toolbar,
|
|
1071
|
+
state.entries.length
|
|
1072
|
+
? element('div', { class: 'card-grid' }, state.entries.map(renderEntryCard))
|
|
1073
|
+
: emptyState('没有匹配的知识', '调整筛选条件,或者手动创建第一条知识。', '新建知识', () => openEntryEditor()),
|
|
1074
|
+
state.nextCursor ? element('div', { class: 'loading' }, actionButton('加载更多', async () => {
|
|
1075
|
+
const cursor = state.nextCursor
|
|
1076
|
+
state.nextCursor = null
|
|
1077
|
+
try { await loadEntries(cursor); renderShell() } catch (error) { showToast(friendlyError(error), 'error') }
|
|
1078
|
+
})) : null,
|
|
1079
|
+
)
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function renderEntryCard(entry) {
|
|
1083
|
+
const archived = entry.status === 'archived'
|
|
1084
|
+
return element('article', { class: 'knowledge-card' },
|
|
1085
|
+
element('div', { class: 'card-top' },
|
|
1086
|
+
element('div', {}, badge(knowledgeBaseName(entry.knowledgeBaseId)), ' ', badge(TYPE_LABELS[entry.type], 'accent'), ' ', archived ? badge('已归档') : badge('生效中', 'success')),
|
|
1087
|
+
element('span', { class: 'field-hint' }, `v${entry.version}`),
|
|
1088
|
+
),
|
|
1089
|
+
element('h3', {}, entry.title),
|
|
1090
|
+
element('p', {}, entry.body),
|
|
1091
|
+
entry.tags.length ? element('div', { class: 'tag-row', 'aria-label': '标签' }, entry.tags.map(tag => element('span', { class: 'tag' }, `#${tag}`))) : null,
|
|
1092
|
+
element('div', { class: 'card-footer' },
|
|
1093
|
+
element('span', { class: 'field-hint' }, `${scopeLabel(entry.scope)} · ${formatDate(entry.updatedAt)}`),
|
|
1094
|
+
element('div', { class: 'card-actions' },
|
|
1095
|
+
actionButton('历史', () => openHistory(entry), 'ghost small'),
|
|
1096
|
+
actionButton(archived ? '编辑并恢复' : '编辑', () => openEntryEditor(entry), 'ghost small'),
|
|
1097
|
+
!archived ? actionButton('归档', () => confirmArchive(entry), 'ghost small') : actionButton('彻底删除', () => confirmDelete(entry), 'danger small'),
|
|
1098
|
+
),
|
|
1099
|
+
),
|
|
1100
|
+
)
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function renderCandidates() {
|
|
1104
|
+
const statuses = [['pending', '待审核'], ['approved', '已通过'], ['rejected', '已拒绝']]
|
|
1105
|
+
return element('section', { 'aria-labelledby': 'candidates-heading' },
|
|
1106
|
+
element('div', { class: 'section-heading' },
|
|
1107
|
+
element('div', {}, element('h2', { id: 'candidates-heading' }, 'AI 提取候选'), element('p', {}, '审核写入的结果与冲突项会在这里等待确认;直接写入的普通结果会自动生效。')),
|
|
1108
|
+
element('div', { class: 'tabs', role: 'tablist', 'aria-label': '候选状态' }, statuses.map(([value, label]) => element('button', {
|
|
1109
|
+
type: 'button', role: 'tab', class: 'tab', 'aria-selected': String(state.candidateStatus === value),
|
|
1110
|
+
onClick: async () => { state.candidateStatus = value; await navigate('candidates') },
|
|
1111
|
+
}, label, state.stats ? ` ${state.stats.candidates[value]}` : ''))),
|
|
1112
|
+
),
|
|
1113
|
+
state.candidates.length ? element('div', { class: 'candidate-list' }, state.candidates.map(renderCandidateCard))
|
|
1114
|
+
: emptyState(`没有${STATUS_LABELS[state.candidateStatus]}候选`, state.candidateStatus === 'pending' ? '新的对话结束后,插件会在后台判断是否产生候选。' : '切换其他状态查看历史记录。'),
|
|
1115
|
+
)
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function renderCandidateCard(candidate) {
|
|
1119
|
+
const pending = candidate.status === 'pending'
|
|
1120
|
+
const targetDocument = TYPE_DOCUMENTS[candidate.draft.type] || 'README.md'
|
|
1121
|
+
return element('article', { class: 'candidate' },
|
|
1122
|
+
element('div', { class: 'candidate-header' },
|
|
1123
|
+
element('div', {},
|
|
1124
|
+
element('div', {}, badge(knowledgeBaseName(candidate.draft.knowledgeBaseId)), ' ', badge(ACTION_LABELS[candidate.action], candidate.action === 'conflict' ? 'warning' : 'accent'), ' ', badge(TYPE_LABELS[candidate.draft.type])),
|
|
1125
|
+
element('h3', {}, candidate.draft.title),
|
|
1126
|
+
),
|
|
1127
|
+
badge(STATUS_LABELS[candidate.status], candidate.status === 'approved' ? 'success' : candidate.status === 'rejected' ? 'danger' : 'warning'),
|
|
1128
|
+
),
|
|
1129
|
+
element('div', { class: 'candidate-body' },
|
|
1130
|
+
element('p', { class: 'candidate-content' }, candidate.draft.body),
|
|
1131
|
+
element('div', { class: 'candidate-reason' },
|
|
1132
|
+
element('strong', {}, '文档变更'),
|
|
1133
|
+
element('span', { class: 'candidate-target' }, `将${candidate.action === 'create' ? '追加' : '更新'}到 ${targetDocument}`),
|
|
1134
|
+
element('strong', {}, '模型判断依据'), candidate.reason || '未提供判断说明'),
|
|
1135
|
+
),
|
|
1136
|
+
element('div', { class: 'candidate-footer' },
|
|
1137
|
+
element('small', {}, `${scopeLabel(candidate.draft.scope)} · 置信度 ${Math.round(candidate.draft.confidence * 100)}%${candidate.targetId ? ` · 目标 ${candidate.targetId}` : ''} · ${formatDate(candidate.createdAt)}`),
|
|
1138
|
+
pending ? element('div', {},
|
|
1139
|
+
actionButton('拒绝', () => reviewCandidate(candidate, 'reject'), 'danger small'),
|
|
1140
|
+
actionButton('编辑并通过', () => openEntryEditor(undefined, candidate), 'small'),
|
|
1141
|
+
actionButton('通过', () => reviewCandidate(candidate, 'approve'), 'primary small'),
|
|
1142
|
+
) : null,
|
|
1143
|
+
),
|
|
1144
|
+
)
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function renderTokens() {
|
|
1148
|
+
return element('section', { 'aria-labelledby': 'tokens-heading' },
|
|
1149
|
+
element('div', { class: 'section-heading' },
|
|
1150
|
+
element('div', {}, element('h2', { id: 'tokens-heading' }, '客户端访问令牌'), element('p', {}, '给其他 DSH 客户端分配最小必要权限;原始令牌只显示一次。')),
|
|
1151
|
+
actionButton('+ 创建令牌', openTokenCreator, 'primary'),
|
|
1152
|
+
),
|
|
1153
|
+
element('div', { class: 'panel' }, element('div', { class: 'panel-body' },
|
|
1154
|
+
state.tokens.length ? element('div', { class: 'token-list' }, state.tokens.map(renderTokenRow)) : compactEmpty('还没有访问令牌'),
|
|
1155
|
+
)),
|
|
1156
|
+
)
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function renderTokenRow(token) {
|
|
1160
|
+
const revoked = Boolean(token.revokedAt)
|
|
1161
|
+
return element('article', { class: 'token-row' },
|
|
1162
|
+
element('div', {},
|
|
1163
|
+
element('strong', {}, token.name, ' ', revoked ? badge('已撤销', 'danger') : badge('有效', 'success')),
|
|
1164
|
+
element('div', { class: 'permissions' }, token.permissions.map(permission => badge(permission))),
|
|
1165
|
+
element('small', {}, `创建于 ${formatDate(token.createdAt)}${token.lastUsedAt ? ` · 最近使用 ${formatDate(token.lastUsedAt)}` : ' · 尚未使用'}`),
|
|
1166
|
+
),
|
|
1167
|
+
revoked ? null : actionButton('撤销', () => confirmRevokeToken(token), 'danger small'),
|
|
1168
|
+
)
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function emptyState(title, description, actionLabel, action) {
|
|
1172
|
+
return element('div', { class: 'empty-state' }, element('div', {}, element('strong', {}, title), element('p', {}, description), actionLabel ? actionButton(actionLabel, action, 'primary') : null))
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function selectControl(label, options, current, onChange) {
|
|
1176
|
+
const select = element('select', { class: 'select', 'aria-label': label, onChange: (event) => onChange(event.target.value) },
|
|
1177
|
+
options.map(option => element('option', { value: option.value, selected: option.value === current }, option.label)))
|
|
1178
|
+
return select
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function knowledgeBaseName(id) {
|
|
1182
|
+
return state.knowledgeBases.find(base => base.id === id)?.name || id || '默认知识库'
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function parseTags(value) {
|
|
1186
|
+
return [...new Set(value.split(/[,,]/).map(tag => tag.trim().toLowerCase()).filter(Boolean))]
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function openKnowledgeBaseEditor(base) {
|
|
1190
|
+
const source = base || { name: '', description: '', defaultTags: [], extractionInstructions: '' }
|
|
1191
|
+
const form = element('form', { class: 'form-grid' })
|
|
1192
|
+
const name = formField('名称', 'input', source.name, { required: true, maxlength: 100, placeholder: '例如:项目规范' })
|
|
1193
|
+
const description = formField('回写匹配描述', 'textarea', source.description, { maxlength: 2000, placeholder: '描述什么样的对话才属于这个库。例如:只记录 dsh-knowledge 项目的架构决策和部署规范' })
|
|
1194
|
+
const tags = formField('默认标签', 'input', source.defaultTags.join(', '), { placeholder: 'project-rule, backend' })
|
|
1195
|
+
const instructions = formField('提取要求', 'textarea', source.extractionInstructions, { maxlength: 4000, placeholder: '例如:只收录已确认、可跨会话复用的项目约定' })
|
|
1196
|
+
const customModel = element('input', { type: 'checkbox', checked: Boolean(source.writebackProvider && source.writebackModel) })
|
|
1197
|
+
const provider = formField('模型提供方(Provider)', 'input', source.writebackProvider || '', { maxlength: 100, placeholder: '例如:cli' })
|
|
1198
|
+
const model = formField('模型名称(Model)', 'input', source.writebackModel || '', { maxlength: 200, placeholder: '例如:kimi-k2.7-code' })
|
|
1199
|
+
const modelHint = element('div', { class: 'field-hint span-2' }, '默认跟随当前会话模型。指定后,仅这个知识库使用专用模型回写。')
|
|
1200
|
+
const updateModelFields = () => {
|
|
1201
|
+
provider.input.disabled = !customModel.checked
|
|
1202
|
+
model.input.disabled = !customModel.checked
|
|
1203
|
+
provider.input.required = customModel.checked
|
|
1204
|
+
model.input.required = customModel.checked
|
|
1205
|
+
}
|
|
1206
|
+
customModel.addEventListener('change', updateModelFields)
|
|
1207
|
+
updateModelFields()
|
|
1208
|
+
for (const field of [name, description, tags, instructions]) field.wrapper.classList.add('span-2')
|
|
1209
|
+
form.append(
|
|
1210
|
+
name.wrapper, description.wrapper, tags.wrapper, instructions.wrapper,
|
|
1211
|
+
element('label', { class: 'check-option span-2' }, customModel, element('span', {},
|
|
1212
|
+
element('strong', {}, '为这个知识库指定回写模型'),
|
|
1213
|
+
element('small', {}, '关闭时自动使用当前会话正在使用的模型。'),
|
|
1214
|
+
)),
|
|
1215
|
+
provider.wrapper, model.wrapper, modelHint,
|
|
1216
|
+
)
|
|
1217
|
+
openModal({
|
|
1218
|
+
title: base ? '编辑知识库' : '创建知识库',
|
|
1219
|
+
description: '匹配描述先判断对话是否属于该库;提取要求再规定应该收录什么。',
|
|
1220
|
+
body: form,
|
|
1221
|
+
primaryLabel: base ? '保存修改' : '创建',
|
|
1222
|
+
onPrimary: async () => {
|
|
1223
|
+
if (!form.reportValidity()) return false
|
|
1224
|
+
const draft = {
|
|
1225
|
+
name: name.input.value.trim(),
|
|
1226
|
+
description: description.input.value.trim(),
|
|
1227
|
+
defaultTags: parseTags(tags.input.value),
|
|
1228
|
+
extractionInstructions: instructions.input.value.trim(),
|
|
1229
|
+
writebackProvider: customModel.checked ? provider.input.value.trim() : '',
|
|
1230
|
+
writebackModel: customModel.checked ? model.input.value.trim() : '',
|
|
1231
|
+
}
|
|
1232
|
+
if (base) await api(`knowledge-bases/${encodeURIComponent(base.id)}`, { method: 'PUT', body: { draft } })
|
|
1233
|
+
else await api('knowledge-bases', { method: 'POST', body: { draft } })
|
|
1234
|
+
showToast(base ? '知识库已更新。' : '知识库已创建,现在可以挂载到项目或会话。')
|
|
1235
|
+
await navigate('bases')
|
|
1236
|
+
return true
|
|
1237
|
+
},
|
|
1238
|
+
})
|
|
1239
|
+
name.input.focus()
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function openBulkMountEditor() {
|
|
1243
|
+
const manager = state.mountManager
|
|
1244
|
+
const targetId = manager.targetKind === 'project' ? state.mountContext.projectId : state.mountContext.sessionId
|
|
1245
|
+
const bases = state.knowledgeBases.filter(base => base.status === 'active' && manager.selectedIds.has(base.id))
|
|
1246
|
+
if (!targetId || bases.length === 0) return
|
|
1247
|
+
const recall = element('input', { type: 'checkbox', checked: true })
|
|
1248
|
+
const writeMode = selectField('写入方式', [
|
|
1249
|
+
{ value: 'none', label: '仅召回(不提取、不回写)' },
|
|
1250
|
+
{ value: 'audit', label: '审核写入(推荐)' },
|
|
1251
|
+
{ value: 'direct', label: '直接写入(普通结果自动生效;冲突仍待审)' },
|
|
1252
|
+
], 'audit')
|
|
1253
|
+
const includeTags = formField('必须包含的标签', 'input', '', { placeholder: '可选,逗号分隔' })
|
|
1254
|
+
const excludeTags = formField('排除标签', 'input', '', { placeholder: '可选,逗号分隔' })
|
|
1255
|
+
const form = element('form', { class: 'form-grid' },
|
|
1256
|
+
element('div', { class: 'selection-summary span-2' },
|
|
1257
|
+
element('strong', {}, `将挂载 ${bases.length} 个知识库`),
|
|
1258
|
+
element('p', {}, bases.slice(0, 8).map(base => base.name).join('、'), bases.length > 8 ? ` 等 ${bases.length} 个` : ''),
|
|
1259
|
+
),
|
|
1260
|
+
element('label', { class: 'check-option' }, recall, element('span', {}, element('strong', {}, '开启召回'), element('small', {}, '回答前检索所选知识库。'))),
|
|
1261
|
+
writeMode.wrapper,
|
|
1262
|
+
includeTags.wrapper,
|
|
1263
|
+
excludeTags.wrapper,
|
|
1264
|
+
)
|
|
1265
|
+
openModal({
|
|
1266
|
+
title: `批量挂载到${manager.targetKind === 'project' ? '项目' : '会话'}`,
|
|
1267
|
+
description: targetId,
|
|
1268
|
+
body: form,
|
|
1269
|
+
primaryLabel: `挂载 ${bases.length} 个`,
|
|
1270
|
+
onPrimary: async () => {
|
|
1271
|
+
const include = parseTags(includeTags.input.value)
|
|
1272
|
+
const exclude = parseTags(excludeTags.input.value)
|
|
1273
|
+
if (include.some(tag => exclude.includes(tag))) {
|
|
1274
|
+
showToast('同一标签不能同时包含和排除。', 'error')
|
|
1275
|
+
return false
|
|
1276
|
+
}
|
|
1277
|
+
await api('mounts/bulk', { method: 'POST', body: {
|
|
1278
|
+
upserts: bases.map(base => ({
|
|
1279
|
+
targetKind: manager.targetKind,
|
|
1280
|
+
targetId,
|
|
1281
|
+
knowledgeBaseId: base.id,
|
|
1282
|
+
enabled: true,
|
|
1283
|
+
recallEnabled: recall.checked,
|
|
1284
|
+
writeMode: writeMode.input.value,
|
|
1285
|
+
includeTags: include,
|
|
1286
|
+
excludeTags: exclude,
|
|
1287
|
+
extractionInstructions: '',
|
|
1288
|
+
})),
|
|
1289
|
+
deleteIds: [],
|
|
1290
|
+
} })
|
|
1291
|
+
manager.selectedIds.clear()
|
|
1292
|
+
showToast(`已批量挂载 ${bases.length} 个知识库。`)
|
|
1293
|
+
await navigate('bases')
|
|
1294
|
+
return true
|
|
1295
|
+
},
|
|
1296
|
+
})
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
async function bulkRemoveMounts() {
|
|
1300
|
+
const manager = state.mountManager
|
|
1301
|
+
const targetId = manager.targetKind === 'project' ? state.mountContext.projectId : state.mountContext.sessionId
|
|
1302
|
+
const explicit = state.mounts.filter(mount =>
|
|
1303
|
+
mount.targetKind === manager.targetKind
|
|
1304
|
+
&& mount.targetId === targetId
|
|
1305
|
+
&& manager.selectedIds.has(mount.knowledgeBaseId))
|
|
1306
|
+
if (explicit.length === 0) {
|
|
1307
|
+
showToast(manager.targetKind === 'session' ? '所选知识库当前没有会话覆盖,无需恢复。' : '所选知识库当前没有项目挂载。')
|
|
1308
|
+
return
|
|
1309
|
+
}
|
|
1310
|
+
try {
|
|
1311
|
+
await api('mounts/bulk', { method: 'POST', body: { upserts: [], deleteIds: explicit.map(mount => mount.id) } })
|
|
1312
|
+
manager.selectedIds.clear()
|
|
1313
|
+
showToast(manager.targetKind === 'session' ? `已恢复 ${explicit.length} 个知识库的项目继承。` : `已取消 ${explicit.length} 个项目挂载。`)
|
|
1314
|
+
await navigate('bases')
|
|
1315
|
+
} catch (error) {
|
|
1316
|
+
showToast(friendlyError(error), 'error')
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function openMountEditor(base, targetKind, targetId, explicit, inherited) {
|
|
1321
|
+
const source = explicit || inherited || {
|
|
1322
|
+
enabled: true, recallEnabled: true, writeMode: 'audit', includeTags: [], excludeTags: [], extractionInstructions: '',
|
|
1323
|
+
}
|
|
1324
|
+
const enabled = element('input', { type: 'checkbox', checked: source.enabled })
|
|
1325
|
+
const recall = element('input', { type: 'checkbox', checked: source.recallEnabled })
|
|
1326
|
+
const writeMode = selectField('写入方式', [
|
|
1327
|
+
{ value: 'none', label: '仅召回(不提取、不回写)' },
|
|
1328
|
+
{ value: 'audit', label: '审核写入(先进待审核)' },
|
|
1329
|
+
{ value: 'direct', label: '直接写入(普通结果自动生效;冲突仍待审)' },
|
|
1330
|
+
], source.writeMode)
|
|
1331
|
+
const includeTags = formField('必须包含的标签', 'input', source.includeTags.join(', '), { placeholder: '例如:project-rule' })
|
|
1332
|
+
const excludeTags = formField('排除标签', 'input', source.excludeTags.join(', '), { placeholder: '例如:personal, temporary' })
|
|
1333
|
+
const instructions = formField('本挂载的额外提取要求', 'textarea', source.extractionInstructions, { maxlength: 4000, placeholder: '可以比知识库默认规则更严格' })
|
|
1334
|
+
const form = element('form', { class: 'form-grid' },
|
|
1335
|
+
element('label', { class: 'check-option span-2' }, enabled, element('span', {}, element('strong', {}, '启用这个挂载'), element('small', {}, '关闭后,当前范围不召回、不提取、不回写。'))),
|
|
1336
|
+
element('label', { class: 'check-option' }, recall, element('span', {}, element('strong', {}, '开启召回'), element('small', {}, '回答前只检索这个库。'))),
|
|
1337
|
+
writeMode.wrapper,
|
|
1338
|
+
includeTags.wrapper,
|
|
1339
|
+
excludeTags.wrapper,
|
|
1340
|
+
instructions.wrapper,
|
|
1341
|
+
)
|
|
1342
|
+
instructions.wrapper.classList.add('span-2')
|
|
1343
|
+
if (targetKind === 'session' && !explicit && inherited) {
|
|
1344
|
+
form.prepend(element('div', { class: 'context-warning span-2' }, '此会话目前继承项目配置。保存后会创建独立的会话覆盖。'))
|
|
1345
|
+
}
|
|
1346
|
+
if (explicit) {
|
|
1347
|
+
form.append(element('div', { class: 'mount-delete span-2' },
|
|
1348
|
+
element('span', { class: 'field-hint' }, targetKind === 'session' ? '删除会话覆盖后,将恢复继承项目配置。' : '删除后,该项目不再挂载此库。'),
|
|
1349
|
+
actionButton('删除当前配置', async () => {
|
|
1350
|
+
try {
|
|
1351
|
+
await api(`mounts/${encodeURIComponent(explicit.id)}`, { method: 'DELETE' })
|
|
1352
|
+
document.querySelector('.dialog-backdrop')?.remove()
|
|
1353
|
+
showToast(targetKind === 'session' ? '会话覆盖已删除,已恢复项目继承。' : '项目挂载已删除。')
|
|
1354
|
+
await navigate('bases')
|
|
1355
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
1356
|
+
}, 'danger small'),
|
|
1357
|
+
))
|
|
1358
|
+
}
|
|
1359
|
+
const updateAvailability = () => {
|
|
1360
|
+
for (const input of [recall, writeMode.input, includeTags.input, excludeTags.input, instructions.input]) input.disabled = !enabled.checked
|
|
1361
|
+
}
|
|
1362
|
+
enabled.addEventListener('change', updateAvailability)
|
|
1363
|
+
updateAvailability()
|
|
1364
|
+
openModal({
|
|
1365
|
+
title: `${targetKind === 'project' ? '项目' : '会话'}挂载 · ${base.name}`,
|
|
1366
|
+
description: targetId,
|
|
1367
|
+
body: form,
|
|
1368
|
+
primaryLabel: '保存挂载',
|
|
1369
|
+
onPrimary: async () => {
|
|
1370
|
+
const include = parseTags(includeTags.input.value)
|
|
1371
|
+
const exclude = parseTags(excludeTags.input.value)
|
|
1372
|
+
if (include.some(tag => exclude.includes(tag))) {
|
|
1373
|
+
showToast('同一标签不能同时包含和排除。', 'error')
|
|
1374
|
+
return false
|
|
1375
|
+
}
|
|
1376
|
+
await api('mounts', { method: 'POST', body: { draft: {
|
|
1377
|
+
targetKind, targetId, knowledgeBaseId: base.id,
|
|
1378
|
+
enabled: enabled.checked,
|
|
1379
|
+
recallEnabled: enabled.checked && recall.checked,
|
|
1380
|
+
writeMode: enabled.checked ? writeMode.input.value : 'none',
|
|
1381
|
+
includeTags: include,
|
|
1382
|
+
excludeTags: exclude,
|
|
1383
|
+
extractionInstructions: instructions.input.value.trim(),
|
|
1384
|
+
} } })
|
|
1385
|
+
showToast(enabled.checked ? '挂载设置已保存。' : '已在当前范围关闭这个知识库。')
|
|
1386
|
+
await navigate('bases')
|
|
1387
|
+
return true
|
|
1388
|
+
},
|
|
1389
|
+
})
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function confirmArchiveKnowledgeBase(base) {
|
|
1393
|
+
openConfirm({
|
|
1394
|
+
title: `归档“${base.name}”?`,
|
|
1395
|
+
message: '归档后所有挂载会自动关闭,其中的知识仍保留,但不再参与召回与回写。',
|
|
1396
|
+
confirmLabel: '确认归档', danger: true,
|
|
1397
|
+
onConfirm: async () => {
|
|
1398
|
+
await api(`knowledge-bases/${encodeURIComponent(base.id)}/archive`, { method: 'POST' })
|
|
1399
|
+
showToast('知识库已归档,相关挂载已关闭。')
|
|
1400
|
+
await navigate('bases')
|
|
1401
|
+
},
|
|
1402
|
+
})
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
function confirmRestoreKnowledgeBase(base) {
|
|
1406
|
+
openConfirm({
|
|
1407
|
+
title: `恢复“${base.name}”?`,
|
|
1408
|
+
message: '恢复后知识库可再次挂载;之前归档时关闭的挂载不会自动重开。',
|
|
1409
|
+
confirmLabel: '确认恢复', danger: false,
|
|
1410
|
+
onConfirm: async () => {
|
|
1411
|
+
await api(`knowledge-bases/${encodeURIComponent(base.id)}/restore`, { method: 'POST' })
|
|
1412
|
+
showToast('知识库已恢复,可以重新配置挂载。')
|
|
1413
|
+
await navigate('bases')
|
|
1414
|
+
},
|
|
1415
|
+
})
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
function confirmDeleteKnowledgeBase(base) {
|
|
1419
|
+
const confirmation = formField('输入知识库名称确认', 'input', '', {
|
|
1420
|
+
required: true, autocomplete: 'off', placeholder: base.name,
|
|
1421
|
+
'aria-describedby': 'delete-base-warning',
|
|
1422
|
+
})
|
|
1423
|
+
const form = element('form', {},
|
|
1424
|
+
element('div', { id: 'delete-base-warning', class: 'context-warning' },
|
|
1425
|
+
'此操作无法撤销。知识库中的全部知识、版本历史、候选、挂载和生成文档都会永久删除。'),
|
|
1426
|
+
confirmation.wrapper,
|
|
1427
|
+
)
|
|
1428
|
+
confirmation.wrapper.classList.add('delete-base-confirmation')
|
|
1429
|
+
openModal({
|
|
1430
|
+
title: `永久删除“${base.name}”?`,
|
|
1431
|
+
description: '只有已归档知识库可以永久删除。',
|
|
1432
|
+
body: form,
|
|
1433
|
+
primaryLabel: '永久删除',
|
|
1434
|
+
primaryVariant: 'danger',
|
|
1435
|
+
onPrimary: async () => {
|
|
1436
|
+
if (!form.reportValidity()) return false
|
|
1437
|
+
if (confirmation.input.value.trim() !== base.name) {
|
|
1438
|
+
showToast('输入的知识库名称不匹配。', 'error')
|
|
1439
|
+
confirmation.input.select()
|
|
1440
|
+
return false
|
|
1441
|
+
}
|
|
1442
|
+
await api(`knowledge-bases/${encodeURIComponent(base.id)}`, { method: 'DELETE' })
|
|
1443
|
+
if (state.documentView.knowledgeBaseId === base.id) {
|
|
1444
|
+
state.documentView.knowledgeBaseId = ''
|
|
1445
|
+
state.documentView.documentId = ''
|
|
1446
|
+
}
|
|
1447
|
+
if (state.entryFilters.knowledgeBaseId === base.id) state.entryFilters.knowledgeBaseId = ''
|
|
1448
|
+
showToast('知识库及其全部关联数据已永久删除。')
|
|
1449
|
+
await navigate('bases')
|
|
1450
|
+
return true
|
|
1451
|
+
},
|
|
1452
|
+
})
|
|
1453
|
+
confirmation.input.focus()
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function openEntryEditor(entry, candidate) {
|
|
1457
|
+
const source = candidate?.draft || entry || {
|
|
1458
|
+
knowledgeBaseId: state.documentView.knowledgeBaseId || undefined,
|
|
1459
|
+
title: '', body: '', type: 'fact', tags: [], scope: { kind: 'global' }, confidence: .8,
|
|
1460
|
+
}
|
|
1461
|
+
const form = element('form', { class: 'form-grid' })
|
|
1462
|
+
const title = formField('标题', 'input', source.title, { required: true, maxlength: 200, placeholder: '一句话说明这条知识' })
|
|
1463
|
+
const body = formField('正文', 'textarea', source.body, { required: true, maxlength: 50000, placeholder: '写下可在未来对话中复用的内容' })
|
|
1464
|
+
const type = selectField('类型', TYPES.map(value => ({ value, label: TYPE_LABELS[value] })), source.type)
|
|
1465
|
+
const activeBases = state.knowledgeBases.filter(base => base.status === 'active')
|
|
1466
|
+
const selectedBaseId = source.knowledgeBaseId || activeBases[0]?.id || 'default'
|
|
1467
|
+
const knowledgeBase = selectField('所属知识库', activeBases.map(base => ({ value: base.id, label: base.name })), selectedBaseId)
|
|
1468
|
+
const scope = selectField('范围', [{ value: 'global', label: '全局' }, { value: 'project', label: '项目' }], source.scope.kind)
|
|
1469
|
+
const project = formField('项目 ID / 路径', 'input', source.scope.kind === 'project' ? source.scope.id : '', { placeholder: '/workspace/project' })
|
|
1470
|
+
const tags = formField('标签', 'input', source.tags.join(', '), { placeholder: 'docker, deployment' })
|
|
1471
|
+
const confidenceInput = element('input', { type: 'range', min: 0, max: 1, step: .01, value: source.confidence })
|
|
1472
|
+
const confidenceValue = element('span', { class: 'range-value' }, `${Math.round(source.confidence * 100)}%`)
|
|
1473
|
+
confidenceInput.addEventListener('input', () => { confidenceValue.textContent = `${Math.round(Number(confidenceInput.value) * 100)}%` })
|
|
1474
|
+
const scopeProjectField = project.wrapper
|
|
1475
|
+
const updateScope = () => { scopeProjectField.hidden = scope.input.value !== 'project'; project.input.required = scope.input.value === 'project' }
|
|
1476
|
+
scope.input.addEventListener('change', updateScope)
|
|
1477
|
+
updateScope()
|
|
1478
|
+
form.append(
|
|
1479
|
+
title.wrapper,
|
|
1480
|
+
type.wrapper,
|
|
1481
|
+
knowledgeBase.wrapper,
|
|
1482
|
+
scope.wrapper,
|
|
1483
|
+
scopeProjectField,
|
|
1484
|
+
element('div', { class: 'field span-2' }, element('label', {}, '置信度'), element('div', { class: 'range-row' }, confidenceInput, confidenceValue)),
|
|
1485
|
+
body.wrapper,
|
|
1486
|
+
tags.wrapper,
|
|
1487
|
+
)
|
|
1488
|
+
body.wrapper.classList.add('span-2')
|
|
1489
|
+
tags.wrapper.classList.add('span-2')
|
|
1490
|
+
const modeTitle = candidate ? '编辑并通过候选' : entry ? '编辑知识' : '新建知识'
|
|
1491
|
+
const modal = openModal({ title: modeTitle, description: candidate ? '保存后,这条候选会立即通过并写入知识库。' : '知识保存后会立即参与后续召回。', body: form, primaryLabel: candidate ? '通过并保存' : '保存', onPrimary: async () => {
|
|
1492
|
+
if (!form.reportValidity()) return false
|
|
1493
|
+
const draft = {
|
|
1494
|
+
knowledgeBaseId: knowledgeBase.input.value,
|
|
1495
|
+
title: title.input.value.trim(), body: body.input.value.trim(), type: type.input.value,
|
|
1496
|
+
tags: parseTags(tags.input.value),
|
|
1497
|
+
scope: scope.input.value === 'global' ? { kind: 'global' } : { kind: 'project', id: project.input.value.trim() },
|
|
1498
|
+
confidence: Number(confidenceInput.value),
|
|
1499
|
+
...(source.source ? { source: source.source } : {}),
|
|
1500
|
+
}
|
|
1501
|
+
if (candidate) await api(`candidates/${encodeURIComponent(candidate.id)}/review`, { method: 'POST', body: { decision: 'approve', draft } })
|
|
1502
|
+
else if (entry) await api(`entries/${encodeURIComponent(entry.id)}`, { method: 'PUT', body: { draft } })
|
|
1503
|
+
else await api('entries', { method: 'POST', body: { draft } })
|
|
1504
|
+
showToast(candidate ? '候选已通过并写入知识库。' : '知识已保存。')
|
|
1505
|
+
state.stats = null
|
|
1506
|
+
await navigate(candidate ? 'candidates' : state.view)
|
|
1507
|
+
return true
|
|
1508
|
+
} })
|
|
1509
|
+
title.input.focus()
|
|
1510
|
+
return modal
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function formField(label, kind, value, attributes = {}) {
|
|
1514
|
+
const input = element(kind === 'textarea' ? 'textarea' : 'input', { class: kind === 'textarea' ? 'textarea' : 'input', value, ...attributes })
|
|
1515
|
+
if (kind === 'textarea') input.value = value
|
|
1516
|
+
return { input, wrapper: element('div', { class: 'field' }, element('label', {}, label), input) }
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
function selectField(label, options, value) {
|
|
1520
|
+
const input = element('select', { class: 'select' }, options.map(option => element('option', { value: option.value, selected: option.value === value }, option.label)))
|
|
1521
|
+
return { input, wrapper: element('div', { class: 'field' }, element('label', {}, label), input) }
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
async function reviewCandidate(candidate, decision) {
|
|
1525
|
+
const approve = decision === 'approve'
|
|
1526
|
+
openConfirm({
|
|
1527
|
+
title: approve ? '通过这条候选?' : '拒绝这条候选?',
|
|
1528
|
+
message: approve ? '通过后内容会立即写入知识库,并参与后续对话召回。' : '拒绝后会保留审核记录,但不会进入知识库。',
|
|
1529
|
+
confirmLabel: approve ? '确认通过' : '确认拒绝', danger: !approve,
|
|
1530
|
+
onConfirm: async () => {
|
|
1531
|
+
await api(`candidates/${encodeURIComponent(candidate.id)}/review`, { method: 'POST', body: { decision } })
|
|
1532
|
+
showToast(approve ? '候选已通过。' : '候选已拒绝。')
|
|
1533
|
+
state.stats = null
|
|
1534
|
+
await navigate('candidates')
|
|
1535
|
+
},
|
|
1536
|
+
})
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function confirmArchive(entry) {
|
|
1540
|
+
openConfirm({ title: '归档这条知识?', message: '归档后它不会再参与召回,版本历史仍会保留。', confirmLabel: '确认归档', danger: true, onConfirm: async () => {
|
|
1541
|
+
await api(`entries/${encodeURIComponent(entry.id)}/archive`, { method: 'POST' })
|
|
1542
|
+
showToast('知识已归档。')
|
|
1543
|
+
state.stats = null
|
|
1544
|
+
await navigate('entries')
|
|
1545
|
+
} })
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function confirmDelete(entry) {
|
|
1549
|
+
openConfirm({ title: '彻底删除这条知识?', message: '知识正文和全部版本历史都会永久删除,此操作无法撤销。', confirmLabel: '永久删除', danger: true, onConfirm: async () => {
|
|
1550
|
+
await api(`entries/${encodeURIComponent(entry.id)}`, { method: 'DELETE' })
|
|
1551
|
+
showToast('知识已彻底删除。')
|
|
1552
|
+
state.stats = null
|
|
1553
|
+
await navigate('entries')
|
|
1554
|
+
} })
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
async function openHistory(entry) {
|
|
1558
|
+
try {
|
|
1559
|
+
const versions = await api(`entries/${encodeURIComponent(entry.id)}/versions`)
|
|
1560
|
+
const content = element('div', { class: 'history' }, versions.map(version => element('article', { class: 'history-item' },
|
|
1561
|
+
element('div', { class: 'history-line' }, element('span', { class: 'history-dot', 'aria-hidden': 'true' })),
|
|
1562
|
+
element('div', { class: 'history-content' }, element('strong', {}, `v${version.version} · ${CHANGE_LABELS[version.changeKind]}`), element('small', {}, formatDate(version.createdAt)), element('p', {}, version.snapshot.body)),
|
|
1563
|
+
)))
|
|
1564
|
+
openModal({ title: `${entry.title} · 版本历史`, description: '每次修改都会保留不可变快照。', body: content, cancelLabel: '关闭' })
|
|
1565
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function openTokenCreator() {
|
|
1569
|
+
const name = formField('令牌名称', 'input', '', { required: true, maxlength: 100, placeholder: '例如:办公室电脑' })
|
|
1570
|
+
const permissions = ['read', 'propose', 'write', 'admin']
|
|
1571
|
+
const checkboxes = permissions.map((permission, index) => {
|
|
1572
|
+
const input = element('input', { type: 'checkbox', value: permission, checked: index === 0 })
|
|
1573
|
+
return { permission, input, node: element('label', { class: 'check-option' }, input, permission) }
|
|
1574
|
+
})
|
|
1575
|
+
const body = element('form', { class: 'form-grid' }, name.wrapper,
|
|
1576
|
+
element('div', { class: 'field span-2' }, element('label', {}, '权限'), element('div', { class: 'check-grid' }, checkboxes.map(item => item.node)), element('span', { class: 'field-hint' }, '普通客户端建议只授予 read + propose。')))
|
|
1577
|
+
name.wrapper.classList.add('span-2')
|
|
1578
|
+
openModal({ title: '创建客户端令牌', description: '原始令牌只会在创建成功后显示一次。', body, primaryLabel: '创建令牌', onPrimary: async () => {
|
|
1579
|
+
if (!body.reportValidity()) return false
|
|
1580
|
+
const selected = checkboxes.filter(item => item.input.checked).map(item => item.permission)
|
|
1581
|
+
if (!selected.length) { showToast('请至少选择一项权限。', 'error'); return false }
|
|
1582
|
+
const created = await api('tokens', { method: 'POST', body: { name: name.input.value.trim(), permissions: selected } })
|
|
1583
|
+
await loadTokens()
|
|
1584
|
+
renderShell()
|
|
1585
|
+
window.setTimeout(() => showSecret(created.token), 0)
|
|
1586
|
+
return true
|
|
1587
|
+
} })
|
|
1588
|
+
name.input.focus()
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
function showSecret(token) {
|
|
1592
|
+
const code = element('code', {}, token)
|
|
1593
|
+
const content = element('div', { class: 'secret-box' }, element('strong', {}, '请立即复制并妥善保存'), element('p', {}, '关闭此窗口后,服务端无法再次显示原始令牌。'), element('div', { class: 'secret-value' }, code, actionButton('复制', async () => {
|
|
1594
|
+
try { await navigator.clipboard.writeText(token); showToast('令牌已复制。') } catch { showToast('复制失败,请手动选择令牌。', 'error') }
|
|
1595
|
+
}, 'small')))
|
|
1596
|
+
openModal({ title: '令牌创建成功', body: content, cancelLabel: '我已保存' })
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function confirmRevokeToken(token) {
|
|
1600
|
+
openConfirm({ title: `撤销“${token.name}”?`, message: '使用此令牌的客户端会立即失去访问权限,此操作不可撤销。', confirmLabel: '确认撤销', danger: true, onConfirm: async () => {
|
|
1601
|
+
await api(`tokens/${encodeURIComponent(token.id)}`, { method: 'DELETE' })
|
|
1602
|
+
showToast('令牌已撤销。')
|
|
1603
|
+
await loadTokens()
|
|
1604
|
+
renderShell()
|
|
1605
|
+
} })
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function openConfirm({ title, message, confirmLabel, danger, onConfirm }) {
|
|
1609
|
+
return openModal({ title, body: element('p', {}, message), primaryLabel: confirmLabel, primaryVariant: danger ? 'danger' : 'primary', onPrimary: async () => { await onConfirm(); return true } })
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消' }) {
|
|
1613
|
+
const previouslyFocused = document.activeElement
|
|
1614
|
+
const backdrop = element('div', { class: 'dialog-backdrop' })
|
|
1615
|
+
const dialog = element('section', { class: `dialog ${primaryLabel ? '' : 'narrow'}`.trim(), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dialog-title' })
|
|
1616
|
+
let busy = false
|
|
1617
|
+
const close = () => {
|
|
1618
|
+
if (busy) return
|
|
1619
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
1620
|
+
backdrop.remove()
|
|
1621
|
+
if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus()
|
|
1622
|
+
}
|
|
1623
|
+
const closeButton = actionButton('×', close, 'ghost', { 'aria-label': '关闭对话框' })
|
|
1624
|
+
const cancel = actionButton(cancelLabel, close)
|
|
1625
|
+
const primary = primaryLabel ? actionButton(primaryLabel, async () => {
|
|
1626
|
+
if (busy) return
|
|
1627
|
+
busy = true
|
|
1628
|
+
primary.disabled = true
|
|
1629
|
+
cancel.disabled = true
|
|
1630
|
+
const original = primary.textContent
|
|
1631
|
+
primary.textContent = '正在处理…'
|
|
1632
|
+
try {
|
|
1633
|
+
const shouldClose = await onPrimary()
|
|
1634
|
+
busy = false
|
|
1635
|
+
if (shouldClose !== false) close()
|
|
1636
|
+
else { primary.disabled = false; cancel.disabled = false; primary.textContent = original }
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
busy = false
|
|
1639
|
+
primary.disabled = false
|
|
1640
|
+
cancel.disabled = false
|
|
1641
|
+
primary.textContent = original
|
|
1642
|
+
showToast(friendlyError(error), 'error')
|
|
1643
|
+
}
|
|
1644
|
+
}, primaryVariant) : null
|
|
1645
|
+
const onKeyDown = (event) => {
|
|
1646
|
+
if (event.key === 'Escape') { event.preventDefault(); close() }
|
|
1647
|
+
if (event.key === 'Tab') trapFocus(event, dialog)
|
|
1648
|
+
}
|
|
1649
|
+
dialog.append(
|
|
1650
|
+
element('header', { class: 'dialog-header' }, element('div', {}, element('h2', { id: 'dialog-title' }, title), description ? element('p', {}, description) : null), closeButton),
|
|
1651
|
+
element('div', { class: 'dialog-body' }, body),
|
|
1652
|
+
element('footer', { class: 'dialog-footer' }, cancel, primary),
|
|
1653
|
+
)
|
|
1654
|
+
backdrop.append(dialog)
|
|
1655
|
+
backdrop.addEventListener('mousedown', (event) => { if (event.target === backdrop) close() })
|
|
1656
|
+
document.body.append(backdrop)
|
|
1657
|
+
document.addEventListener('keydown', onKeyDown)
|
|
1658
|
+
window.setTimeout(() => (dialog.querySelector('input, textarea, select, button') || dialog).focus(), 0)
|
|
1659
|
+
return { close, dialog }
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function trapFocus(event, container) {
|
|
1663
|
+
const focusable = [...container.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])')]
|
|
1664
|
+
if (!focusable.length) return
|
|
1665
|
+
const first = focusable[0]
|
|
1666
|
+
const last = focusable.at(-1)
|
|
1667
|
+
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
|
|
1668
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function formatDate(value) {
|
|
1672
|
+
try { return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)) } catch { return value }
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
function scopeLabel(scope) {
|
|
1676
|
+
return scope.kind === 'global' ? '全局' : `项目 · ${scope.id}`
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function friendlyError(error) {
|
|
1680
|
+
if (error.status === 403) return '当前令牌没有执行此操作所需的权限。'
|
|
1681
|
+
if (error.status === 409) return error.message || '内容已发生变化,请刷新后重试。'
|
|
1682
|
+
if (error.name === 'AbortError') return '请求已取消。'
|
|
1683
|
+
return error.message || '操作失败,请稍后重试。'
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
void boot()
|