@lemoncat7/dsh-knowledge 1.1.5 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -4
- package/docs/architecture.md +32 -6
- package/docs/requirements.md +10 -0
- package/lib/api.d.ts +2 -0
- package/lib/api.d.ts.map +1 -1
- package/lib/api.js +235 -13
- package/lib/api.js.map +1 -1
- package/lib/atomic-file.d.ts +16 -0
- package/lib/atomic-file.d.ts.map +1 -0
- package/lib/atomic-file.js +92 -0
- package/lib/atomic-file.js.map +1 -0
- package/lib/client.d.ts.map +1 -1
- package/lib/client.js +38 -35
- package/lib/client.js.map +2 -2
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +2 -5
- package/lib/config.js.map +1 -1
- package/lib/connection.d.ts.map +1 -1
- package/lib/connection.js +5 -15
- package/lib/connection.js.map +1 -1
- package/lib/content-safety.d.ts +12 -0
- package/lib/content-safety.d.ts.map +1 -0
- package/lib/content-safety.js +38 -0
- package/lib/content-safety.js.map +1 -0
- package/lib/documents/projection-queue.d.ts +10 -0
- package/lib/documents/projection-queue.d.ts.map +1 -0
- package/lib/documents/projection-queue.js +21 -0
- package/lib/documents/projection-queue.js.map +1 -0
- package/lib/documents/store.d.ts +4 -2
- package/lib/documents/store.d.ts.map +1 -1
- package/lib/documents/store.js +54 -73
- package/lib/documents/store.js.map +1 -1
- package/lib/domain.d.ts +13 -0
- package/lib/domain.d.ts.map +1 -1
- package/lib/domain.js.map +1 -1
- package/lib/extraction.d.ts.map +1 -1
- package/lib/extraction.js +18 -4
- package/lib/extraction.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +91 -32
- package/lib/index.js.map +1 -1
- package/lib/knowledge-merge.d.ts +8 -0
- package/lib/knowledge-merge.d.ts.map +1 -0
- package/lib/knowledge-merge.js +19 -0
- package/lib/knowledge-merge.js.map +1 -0
- package/lib/local-provider.d.ts +26 -1
- package/lib/local-provider.d.ts.map +1 -1
- package/lib/local-provider.js +402 -63
- package/lib/local-provider.js.map +1 -1
- package/lib/management-proxy.d.ts.map +1 -1
- package/lib/management-proxy.js +22 -13
- package/lib/management-proxy.js.map +1 -1
- package/lib/note-reference-handle.d.ts +15 -0
- package/lib/note-reference-handle.d.ts.map +1 -0
- package/lib/note-reference-handle.js +54 -0
- package/lib/note-reference-handle.js.map +1 -0
- package/lib/note-reference-tools.d.ts +6 -0
- package/lib/note-reference-tools.d.ts.map +1 -0
- package/lib/note-reference-tools.js +119 -0
- package/lib/note-reference-tools.js.map +1 -0
- package/lib/note-tools.d.ts +6 -0
- package/lib/note-tools.d.ts.map +1 -0
- package/lib/note-tools.js +283 -0
- package/lib/note-tools.js.map +1 -0
- package/lib/notes/domain.d.ts +44 -0
- package/lib/notes/domain.d.ts.map +1 -0
- package/lib/notes/domain.js +38 -0
- package/lib/notes/domain.js.map +1 -0
- package/lib/notes/store.d.ts +39 -0
- package/lib/notes/store.d.ts.map +1 -0
- package/lib/notes/store.js +407 -0
- package/lib/notes/store.js.map +1 -0
- package/lib/provider.d.ts +17 -0
- package/lib/provider.d.ts.map +1 -1
- package/lib/remote-provider.d.ts +19 -0
- package/lib/remote-provider.d.ts.map +1 -1
- package/lib/remote-provider.js +172 -14
- package/lib/remote-provider.js.map +1 -1
- package/lib/remote-url.d.ts +2 -0
- package/lib/remote-url.d.ts.map +1 -0
- package/lib/remote-url.js +14 -0
- package/lib/remote-url.js.map +1 -0
- package/lib/runtime.d.ts +1 -0
- package/lib/runtime.d.ts.map +1 -1
- package/lib/runtime.js.map +1 -1
- package/lib/service-settings.d.ts.map +1 -1
- package/lib/service-settings.js +3 -10
- package/lib/service-settings.js.map +1 -1
- package/lib/theme-bridge.js +12 -12
- package/lib/theme-bridge.js.map +1 -1
- package/lib/tool-authorization.d.ts +14 -0
- package/lib/tool-authorization.d.ts.map +1 -0
- package/lib/tool-authorization.js +124 -0
- package/lib/tool-authorization.js.map +1 -0
- package/lib/tool-input.d.ts +6 -0
- package/lib/tool-input.d.ts.map +1 -0
- package/lib/tool-input.js +27 -0
- package/lib/tool-input.js.map +1 -0
- package/lib/tools.d.ts +2 -1
- package/lib/tools.d.ts.map +1 -1
- package/lib/tools.js +70 -177
- package/lib/tools.js.map +1 -1
- package/lib/tracking.d.ts +29 -0
- package/lib/tracking.d.ts.map +1 -0
- package/lib/tracking.js +141 -0
- package/lib/tracking.js.map +1 -0
- package/lib/web-change-review.d.ts +34 -0
- package/lib/web-change-review.d.ts.map +1 -0
- package/lib/web-change-review.js +147 -0
- package/lib/web-change-review.js.map +1 -0
- package/lib/web-markdown-preview.d.ts.map +1 -1
- package/lib/web-markdown-preview.js +10 -0
- package/lib/web-markdown-preview.js.map +1 -1
- package/lib/web-note-editor.d.ts +44 -0
- package/lib/web-note-editor.d.ts.map +1 -0
- package/lib/web-note-editor.js +118 -0
- package/lib/web-note-editor.js.map +1 -0
- package/lib/web.d.ts.map +1 -1
- package/lib/web.js +3 -1
- package/lib/web.js.map +1 -1
- package/package.json +12 -1
- package/web/app.js +1888 -154
- package/web/change-review.js +5 -0
- package/web/index.html +2 -0
- package/web/markdown-preview.js +8 -8
- package/web/note-editor.js +220 -0
- package/web/styles.css +599 -170
package/web/app.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const API_BASE = document.querySelector('meta[name="dsh-knowledge-api"]')?.content || '/knowledge-api/v1'
|
|
2
2
|
const AUTH_MODE = document.querySelector('meta[name="dsh-knowledge-auth-mode"]')?.content || 'bearer'
|
|
3
|
+
const WEB_PATH = document.querySelector('meta[name="dsh-knowledge-web"]')?.content || '/knowledge'
|
|
4
|
+
const ASSET_VERSION = document.querySelector('meta[name="dsh-knowledge-asset-version"]')?.content || ''
|
|
3
5
|
const HOST_THEME_MESSAGE = '@lemoncat7/dsh-knowledge/host-theme'
|
|
4
6
|
const HOST_THEME_READY_MESSAGE = '@lemoncat7/dsh-knowledge/host-theme-ready'
|
|
5
7
|
const HOST_THEME_PROTOCOL_VERSION = 1
|
|
@@ -20,6 +22,7 @@ const WRITE_MODE_LABELS = { none: '仅召回', audit: '审核写入', direct: '
|
|
|
20
22
|
const EVIDENCE_LABELS = { explicit: '用户明确', verified: '结果已验证', inferred: '模型推断' }
|
|
21
23
|
const DOCUMENT_STATE_LABELS = { open: '进行中', resolved: '已解决', complete: '已收集完成' }
|
|
22
24
|
const DOCUMENT_LAYOUT_KEY = 'dsh-knowledge.document-layout'
|
|
25
|
+
const NOTE_MAX_FILE_SIZE = 64 * 1024 * 1024
|
|
23
26
|
const pageParams = new URLSearchParams(location.search)
|
|
24
27
|
const mountContext = {
|
|
25
28
|
sessionId: pageParams.get('sessionId')?.trim() || '',
|
|
@@ -32,7 +35,9 @@ const savedDocumentLayout = readDocumentLayout()
|
|
|
32
35
|
function createDocumentViewState(overrides = {}) {
|
|
33
36
|
return {
|
|
34
37
|
knowledgeBaseId: '', documentId: '', query: '', mode: 'preview', treeOpen: false,
|
|
35
|
-
expandedBases: new Set(),
|
|
38
|
+
expandedBases: new Set(), documentPages: new Map(),
|
|
39
|
+
searchResults: [], searchNextCursor: '', searchTotal: 0, searchLoading: false, searchError: '', searchRequest: 0,
|
|
40
|
+
editor: null, editorLoading: false, loadingDocumentId: '', ...overrides,
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
|
|
@@ -75,13 +80,35 @@ const state = {
|
|
|
75
80
|
modelCatalog: null,
|
|
76
81
|
settingsSaving: false,
|
|
77
82
|
tokens: [],
|
|
83
|
+
notes: {
|
|
84
|
+
children: new Map(), loadedFolders: new Set(), expandedFolders: new Set(),
|
|
85
|
+
selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [],
|
|
86
|
+
content: '', draft: '', dirty: false, assetUrl: '', query: '', searchResults: [],
|
|
87
|
+
transfer: null, loadingNodeId: '',
|
|
88
|
+
},
|
|
78
89
|
service: { publicApiEnabled: false, publicApiPrefix: '/knowledge-api/v1', remote: false },
|
|
79
90
|
scrollPositions: new Map(),
|
|
80
91
|
loading: false,
|
|
92
|
+
loadingPhase: '',
|
|
93
|
+
loadingProgress: 0,
|
|
81
94
|
error: '',
|
|
82
95
|
}
|
|
83
96
|
|
|
84
97
|
let scrollRestoreFrame = 0
|
|
98
|
+
let navigationController = null
|
|
99
|
+
let navigationRequest = 0
|
|
100
|
+
let navigationSave = Promise.resolve(true)
|
|
101
|
+
let libraryDetailRequest = 0
|
|
102
|
+
let documentSearchTimer = 0
|
|
103
|
+
let noteSearchTimer = 0
|
|
104
|
+
let markdownEditorHandle = null
|
|
105
|
+
let noteEditorLoader = null
|
|
106
|
+
let markdownEditorMountRequest = 0
|
|
107
|
+
let plainTextEditorHandle = null
|
|
108
|
+
let plainTextEditorMountRequest = 0
|
|
109
|
+
let noteTransferFrame = 0
|
|
110
|
+
let noteTransferSequence = 0
|
|
111
|
+
let noteSelectionRequest = 0
|
|
85
112
|
|
|
86
113
|
function installHostThemeBridge() {
|
|
87
114
|
if (window.parent === window) return Promise.resolve()
|
|
@@ -242,9 +269,12 @@ function badge(label, variant = '') {
|
|
|
242
269
|
}
|
|
243
270
|
|
|
244
271
|
function showToast(message, kind = '') {
|
|
245
|
-
const toast = element('div', { class: `toast ${kind}`.trim(), role: kind === 'error' ? 'alert' : 'status' },
|
|
272
|
+
const toast = element('div', { class: `toast ${kind}`.trim(), role: kind === 'error' ? 'alert' : 'status' },
|
|
273
|
+
element('span', {}, message),
|
|
274
|
+
kind === 'error' ? actionButton('关闭', () => toast.remove(), 'ghost small toast-close', { 'aria-label': '关闭错误提示' }) : null,
|
|
275
|
+
)
|
|
246
276
|
toastRegion.append(toast)
|
|
247
|
-
window.setTimeout(() => toast.remove(), 4200)
|
|
277
|
+
if (kind !== 'error') window.setTimeout(() => toast.remove(), 4200)
|
|
248
278
|
}
|
|
249
279
|
|
|
250
280
|
async function api(path, options = {}) {
|
|
@@ -270,11 +300,83 @@ async function api(path, options = {}) {
|
|
|
270
300
|
return payload
|
|
271
301
|
}
|
|
272
302
|
|
|
303
|
+
async function binaryRequest(path, options = {}) {
|
|
304
|
+
const headers = { accept: options.accept || 'application/json' }
|
|
305
|
+
if (AUTH_MODE === 'same-origin') headers['x-dsh-knowledge-client'] = 'management-web'
|
|
306
|
+
if (state.token) headers.authorization = `Bearer ${state.token}`
|
|
307
|
+
if (options.contentType) headers['content-type'] = options.contentType
|
|
308
|
+
const response = await fetch(`${API_BASE}/${path.replace(/^\/+/, '')}`, {
|
|
309
|
+
method: options.method || 'GET',
|
|
310
|
+
headers,
|
|
311
|
+
body: options.body,
|
|
312
|
+
signal: options.signal,
|
|
313
|
+
})
|
|
314
|
+
if (options.responseType === 'blob') {
|
|
315
|
+
if (!response.ok) throw await responseError(response)
|
|
316
|
+
return response.blob()
|
|
317
|
+
}
|
|
318
|
+
const text = await response.text()
|
|
319
|
+
let payload
|
|
320
|
+
if (text) {
|
|
321
|
+
try { payload = JSON.parse(text) } catch { throw new Error('服务返回了无法识别的数据') }
|
|
322
|
+
}
|
|
323
|
+
if (!response.ok) {
|
|
324
|
+
const error = new Error(payload?.error || `请求失败(HTTP ${response.status})`)
|
|
325
|
+
error.status = response.status
|
|
326
|
+
throw error
|
|
327
|
+
}
|
|
328
|
+
return payload
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function binaryUploadRequest(path, body, options = {}) {
|
|
332
|
+
return new Promise((resolve, reject) => {
|
|
333
|
+
const request = new XMLHttpRequest()
|
|
334
|
+
request.open(options.method || 'POST', `${API_BASE}/${path.replace(/^\/+/, '')}`)
|
|
335
|
+
request.responseType = 'text'
|
|
336
|
+
request.setRequestHeader('accept', 'application/json')
|
|
337
|
+
if (AUTH_MODE === 'same-origin') request.setRequestHeader('x-dsh-knowledge-client', 'management-web')
|
|
338
|
+
if (state.token) request.setRequestHeader('authorization', `Bearer ${state.token}`)
|
|
339
|
+
if (options.contentType) request.setRequestHeader('content-type', options.contentType)
|
|
340
|
+
request.upload.addEventListener('progress', event => {
|
|
341
|
+
if (event.lengthComputable) options.onProgress?.(event.loaded, event.total)
|
|
342
|
+
})
|
|
343
|
+
request.addEventListener('load', () => {
|
|
344
|
+
let payload
|
|
345
|
+
if (request.responseText) {
|
|
346
|
+
try { payload = JSON.parse(request.responseText) }
|
|
347
|
+
catch { return reject(new Error('服务返回了无法识别的数据')) }
|
|
348
|
+
}
|
|
349
|
+
if (request.status < 200 || request.status >= 300) {
|
|
350
|
+
const error = new Error(payload?.error || `请求失败(HTTP ${request.status})`)
|
|
351
|
+
error.status = request.status
|
|
352
|
+
reject(error)
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
resolve(payload)
|
|
356
|
+
})
|
|
357
|
+
request.addEventListener('error', () => reject(new Error('上传连接中断,请检查网络后重试')))
|
|
358
|
+
request.addEventListener('abort', () => reject(new DOMException('上传已取消', 'AbortError')))
|
|
359
|
+
request.send(body)
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function responseError(response) {
|
|
364
|
+
let message = `请求失败(HTTP ${response.status})`
|
|
365
|
+
try {
|
|
366
|
+
const payload = await response.json()
|
|
367
|
+
if (payload?.error) message = payload.error
|
|
368
|
+
} catch {}
|
|
369
|
+
return Object.assign(new Error(message), { status: response.status })
|
|
370
|
+
}
|
|
371
|
+
|
|
273
372
|
async function boot() {
|
|
274
373
|
if (AUTH_MODE === 'same-origin') {
|
|
275
374
|
state.token = ''
|
|
276
|
-
|
|
277
|
-
|
|
375
|
+
await Promise.all([
|
|
376
|
+
api('service').then(service => { state.service = service }).catch(() => {}),
|
|
377
|
+
navigate('entries'),
|
|
378
|
+
])
|
|
379
|
+
renderShell()
|
|
278
380
|
return
|
|
279
381
|
}
|
|
280
382
|
if (!state.token) {
|
|
@@ -335,13 +437,22 @@ function renderLogin(message = '') {
|
|
|
335
437
|
}
|
|
336
438
|
|
|
337
439
|
function signOut() {
|
|
440
|
+
releaseNoteEditors()
|
|
441
|
+
releaseNoteAsset()
|
|
338
442
|
sessionStorage.removeItem(TOKEN_KEY)
|
|
339
443
|
Object.assign(state, { token: '', stats: null, overview: null, knowledgeBases: [], mounts: [], resolvedMounts: [], entries: [], documents: [], candidates: [], candidateTargets: new Map(), settings: { writebackPolicy: 'conservative', updatedAt: '' }, tokens: [] })
|
|
444
|
+
state.notes = { children: new Map(), loadedFolders: new Set(), expandedFolders: new Set(), selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [], content: '', draft: '', dirty: false, assetUrl: '', query: '', searchResults: [], transfer: null, loadingNodeId: '' }
|
|
340
445
|
if (AUTH_MODE === 'same-origin') void boot()
|
|
341
446
|
else renderLogin()
|
|
342
447
|
}
|
|
343
448
|
|
|
344
449
|
async function navigate(view) {
|
|
450
|
+
const request = ++navigationRequest
|
|
451
|
+
navigationSave = navigationSave.then(() => saveBeforeNavigation(), () => saveBeforeNavigation())
|
|
452
|
+
if (!await navigationSave || request !== navigationRequest) return
|
|
453
|
+
navigationController?.abort()
|
|
454
|
+
const controller = new AbortController()
|
|
455
|
+
navigationController = controller
|
|
345
456
|
const previousView = state.view
|
|
346
457
|
if (view === 'bases' && previousView !== 'bases' && state.knowledgeBaseView === 'detail') {
|
|
347
458
|
state.knowledgeBaseView = 'libraries'
|
|
@@ -349,38 +460,78 @@ async function navigate(view) {
|
|
|
349
460
|
state.view = view
|
|
350
461
|
state.menuOpen = false
|
|
351
462
|
state.loading = true
|
|
463
|
+
state.loadingPhase = loadingPhaseForView(view)
|
|
464
|
+
state.loadingProgress = .12
|
|
352
465
|
state.error = ''
|
|
353
466
|
renderShell()
|
|
354
467
|
try {
|
|
355
|
-
if (view === 'overview') await loadOverview()
|
|
356
|
-
if (view === 'bases') await loadKnowledgeBasesPage()
|
|
357
|
-
if (view === 'entries') await loadDocuments()
|
|
358
|
-
if (view === 'candidates') await loadCandidates()
|
|
359
|
-
if (view === '
|
|
468
|
+
if (view === 'overview') await loadOverview(controller.signal)
|
|
469
|
+
if (view === 'bases') await loadKnowledgeBasesPage(controller.signal)
|
|
470
|
+
if (view === 'entries') await loadDocuments(controller.signal, (label, progress) => updateLoadingPhase(request, label, progress))
|
|
471
|
+
if (view === 'candidates') await loadCandidates(controller.signal)
|
|
472
|
+
if (view === 'notes') await loadNotes(controller.signal, (label, progress) => updateLoadingPhase(request, label, progress))
|
|
473
|
+
if (view === 'tokens') await loadTokens(controller.signal)
|
|
360
474
|
} catch (error) {
|
|
475
|
+
if (controller.signal.aborted) return
|
|
361
476
|
if (error.status === 401 && AUTH_MODE === 'bearer') return signOut()
|
|
362
477
|
state.error = friendlyError(error)
|
|
363
478
|
} finally {
|
|
479
|
+
if (navigationController !== controller) return
|
|
364
480
|
state.loading = false
|
|
481
|
+
state.loadingPhase = ''
|
|
482
|
+
state.loadingProgress = 1
|
|
365
483
|
renderShell()
|
|
366
484
|
}
|
|
367
485
|
}
|
|
368
486
|
|
|
369
|
-
|
|
370
|
-
|
|
487
|
+
function loadingPhaseForView(view) {
|
|
488
|
+
return ({
|
|
489
|
+
overview: '正在汇总知识活动', bases: '正在读取知识库配置', entries: '正在读取知识目录',
|
|
490
|
+
notes: '正在读取笔记目录', candidates: '正在准备审核队列', tokens: '正在读取访问权限',
|
|
491
|
+
})[view] || '正在准备工作区'
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function updateLoadingPhase(request, label, progress) {
|
|
495
|
+
if (request !== navigationRequest || !state.loading) return
|
|
496
|
+
state.loadingPhase = label
|
|
497
|
+
state.loadingProgress = Math.max(0, Math.min(1, progress))
|
|
498
|
+
renderShell()
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function saveBeforeNavigation() {
|
|
502
|
+
if (state.view === 'notes' && state.notes.dirty) return saveNoteDocument()
|
|
503
|
+
return saveBeforeLeavingDocument()
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function saveBeforeLeavingDocument() {
|
|
507
|
+
const workspace = activeDocumentWorkspace()
|
|
508
|
+
const editor = workspace?.view.editor
|
|
509
|
+
if (!workspace || !editor?.dirty) return true
|
|
510
|
+
const emptyDraft = editor.isNew && !editor.title.trim() && !editor.body.trim()
|
|
511
|
+
if (emptyDraft) {
|
|
512
|
+
workspace.view.editor = null
|
|
513
|
+
workspace.view.documentId = ''
|
|
514
|
+
return true
|
|
515
|
+
}
|
|
516
|
+
return saveDocumentEditor(workspace)
|
|
371
517
|
}
|
|
372
518
|
|
|
373
|
-
async function
|
|
374
|
-
|
|
519
|
+
async function refreshStats(signal) {
|
|
520
|
+
state.stats = await api('stats', { signal })
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function ensureKnowledgeBases(force = false, signal) {
|
|
524
|
+
if (force || state.knowledgeBases.length === 0) state.knowledgeBases = await api('knowledge-bases', { signal })
|
|
375
525
|
return state.knowledgeBases
|
|
376
526
|
}
|
|
377
527
|
|
|
378
|
-
async function loadKnowledgeBasesPage() {
|
|
379
|
-
const
|
|
528
|
+
async function loadKnowledgeBasesPage(signal) {
|
|
529
|
+
const options = { signal }
|
|
530
|
+
const requests = [api('knowledge-bases', options), api('mounts', options), api('settings', options), api('service', options)]
|
|
380
531
|
if (state.mountContext.sessionId) {
|
|
381
532
|
const params = new URLSearchParams({ sessionId: state.mountContext.sessionId })
|
|
382
533
|
if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
|
|
383
|
-
requests.push(api(`mounts/resolve?${params}
|
|
534
|
+
requests.push(api(`mounts/resolve?${params}`, options))
|
|
384
535
|
}
|
|
385
536
|
const [bases, mounts, settings, service, resolved = []] = await Promise.all(requests)
|
|
386
537
|
state.knowledgeBases = bases
|
|
@@ -388,15 +539,16 @@ async function loadKnowledgeBasesPage() {
|
|
|
388
539
|
state.settings = settings
|
|
389
540
|
state.service = service
|
|
390
541
|
state.resolvedMounts = resolved
|
|
391
|
-
await refreshStats()
|
|
542
|
+
await refreshStats(signal)
|
|
392
543
|
}
|
|
393
544
|
|
|
394
|
-
async function loadOverview() {
|
|
545
|
+
async function loadOverview(signal) {
|
|
546
|
+
const options = { signal }
|
|
395
547
|
const [stats, recent, pending, bases] = await Promise.all([
|
|
396
|
-
api('stats'),
|
|
397
|
-
api('entries?status=active&limit=6'),
|
|
398
|
-
api('candidates?status=pending&limit=5'),
|
|
399
|
-
api('knowledge-bases'),
|
|
548
|
+
api('stats', options),
|
|
549
|
+
api('entries?status=active&limit=6', options),
|
|
550
|
+
api('candidates?status=pending&limit=5', options),
|
|
551
|
+
api('knowledge-bases', options),
|
|
400
552
|
])
|
|
401
553
|
state.stats = stats
|
|
402
554
|
state.knowledgeBases = bases
|
|
@@ -427,18 +579,26 @@ async function loadEntries(cursor = '') {
|
|
|
427
579
|
if (!state.stats) await refreshStats()
|
|
428
580
|
}
|
|
429
581
|
|
|
430
|
-
async function loadDocuments() {
|
|
431
|
-
|
|
582
|
+
async function loadDocuments(signal, onPhase = () => {}) {
|
|
583
|
+
onPhase('正在读取可用知识库', .24)
|
|
584
|
+
const options = { signal }
|
|
585
|
+
const requests = [api('knowledge-bases', options)]
|
|
432
586
|
if (state.mountContext.sessionId) {
|
|
433
587
|
const params = new URLSearchParams({ sessionId: state.mountContext.sessionId })
|
|
434
588
|
if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
|
|
435
|
-
requests.push(api(`mounts/resolve?${params}
|
|
589
|
+
requests.push(api(`mounts/resolve?${params}`, options))
|
|
436
590
|
}
|
|
437
|
-
const [
|
|
591
|
+
const [results, stats] = await Promise.all([
|
|
592
|
+
Promise.all(requests),
|
|
593
|
+
state.stats ? Promise.resolve(state.stats) : api('stats', options),
|
|
594
|
+
])
|
|
595
|
+
const [bases, resolved = []] = results
|
|
438
596
|
state.knowledgeBases = bases
|
|
439
597
|
state.resolvedMounts = resolved
|
|
598
|
+
state.stats = stats
|
|
599
|
+
onPhase('正在建立文档索引', .56)
|
|
440
600
|
const visibleBaseIds = new Set(documentKnowledgeBases(bases).map(base => base.id))
|
|
441
|
-
state.documents = documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId))
|
|
601
|
+
state.documents = state.documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId))
|
|
442
602
|
const workspace = sessionDocumentWorkspace()
|
|
443
603
|
const view = workspace.view
|
|
444
604
|
const availableBaseIds = visibleBaseIds
|
|
@@ -447,10 +607,30 @@ async function loadDocuments() {
|
|
|
447
607
|
? state.entryFilters.knowledgeBaseId
|
|
448
608
|
: documentKnowledgeBases(bases)[0]?.id || ''
|
|
449
609
|
}
|
|
610
|
+
if (view.knowledgeBaseId) {
|
|
611
|
+
view.expandedBases.add(view.knowledgeBaseId)
|
|
612
|
+
await loadDocumentPage(workspace, view.knowledgeBaseId, { signal })
|
|
613
|
+
}
|
|
450
614
|
selectDefaultDocument(workspace)
|
|
451
|
-
if (view.documentId)
|
|
615
|
+
if (view.documentId) {
|
|
616
|
+
onPhase('正在打开最近文档', .82)
|
|
617
|
+
await loadDocumentEditor(workspace, view.documentId, signal)
|
|
618
|
+
}
|
|
452
619
|
else view.editor = null
|
|
453
|
-
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function loadNotes(signal, onPhase = () => {}) {
|
|
623
|
+
onPhase('正在读取顶层目录', .36)
|
|
624
|
+
state.notes.children.set('root', await api('notes?limit=500', { signal }))
|
|
625
|
+
state.notes.loadedFolders.add('root')
|
|
626
|
+
if (state.notes.selectedId) {
|
|
627
|
+
onPhase('正在恢复上次打开的笔记', .74)
|
|
628
|
+
const selected = state.notes.selectedNode?.id === state.notes.selectedId
|
|
629
|
+
? state.notes.selectedNode
|
|
630
|
+
: await api(`notes/${encodeURIComponent(state.notes.selectedId)}`, { signal }).catch(() => null)
|
|
631
|
+
if (!selected) clearNoteSelection()
|
|
632
|
+
else state.notes.selectedNode = selected
|
|
633
|
+
}
|
|
454
634
|
}
|
|
455
635
|
|
|
456
636
|
function documentKnowledgeBases(bases = state.knowledgeBases) {
|
|
@@ -483,6 +663,139 @@ function setDocumentWorkspaceDocuments(workspace, documents) {
|
|
|
483
663
|
else state.documents = documents
|
|
484
664
|
}
|
|
485
665
|
|
|
666
|
+
function documentPageState(workspace, knowledgeBaseId) {
|
|
667
|
+
const pages = workspace.view.documentPages
|
|
668
|
+
if (!pages.has(knowledgeBaseId)) {
|
|
669
|
+
pages.set(knowledgeBaseId, { loaded: false, loading: false, nextCursor: '', total: 0, error: '' })
|
|
670
|
+
}
|
|
671
|
+
return pages.get(knowledgeBaseId)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function mergeDocumentSummaries(workspace, items, knowledgeBaseId, reset) {
|
|
675
|
+
const current = documentWorkspaceDocuments(workspace)
|
|
676
|
+
const retained = reset ? current.filter(document => document.knowledgeBaseId !== knowledgeBaseId) : current
|
|
677
|
+
const merged = new Map(retained.map(document => [document.id, document]))
|
|
678
|
+
for (const item of items) merged.set(item.id, item)
|
|
679
|
+
setDocumentWorkspaceDocuments(workspace, [...merged.values()])
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function loadDocumentPage(workspace, knowledgeBaseId, options = {}) {
|
|
683
|
+
if (!knowledgeBaseId) return
|
|
684
|
+
const page = documentPageState(workspace, knowledgeBaseId)
|
|
685
|
+
if (page.loading || (!options.reset && !options.append && page.loaded)) return
|
|
686
|
+
if (options.append && !page.nextCursor) return
|
|
687
|
+
page.loading = true
|
|
688
|
+
page.error = ''
|
|
689
|
+
const cursor = options.append ? page.nextCursor : ''
|
|
690
|
+
try {
|
|
691
|
+
const params = new URLSearchParams({ knowledgeBaseId, limit: '60' })
|
|
692
|
+
if (cursor) params.set('cursor', cursor)
|
|
693
|
+
const result = await api(`document-index?${params}`, { signal: options.signal })
|
|
694
|
+
mergeDocumentSummaries(workspace, result.items, knowledgeBaseId, !options.append)
|
|
695
|
+
page.loaded = true
|
|
696
|
+
page.nextCursor = result.nextCursor || ''
|
|
697
|
+
page.total = result.total
|
|
698
|
+
} catch (error) {
|
|
699
|
+
if (error.name === 'AbortError') throw error
|
|
700
|
+
page.error = friendlyError(error)
|
|
701
|
+
if (options.throwOnError) throw error
|
|
702
|
+
} finally {
|
|
703
|
+
page.loading = false
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function toggleDocumentBase(workspace, baseId) {
|
|
708
|
+
const view = workspace.view
|
|
709
|
+
view.knowledgeBaseId = baseId
|
|
710
|
+
if (view.expandedBases.has(baseId)) {
|
|
711
|
+
view.expandedBases.delete(baseId)
|
|
712
|
+
renderShell()
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
view.expandedBases.add(baseId)
|
|
716
|
+
const page = documentPageState(workspace, baseId)
|
|
717
|
+
if (!page.loaded) page.loading = true
|
|
718
|
+
renderShell()
|
|
719
|
+
if (!page.loaded) {
|
|
720
|
+
page.loading = false
|
|
721
|
+
await loadDocumentPage(workspace, baseId)
|
|
722
|
+
renderShell()
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function resetDocumentSearch(view) {
|
|
727
|
+
view.searchRequest += 1
|
|
728
|
+
view.searchResults = []
|
|
729
|
+
view.searchNextCursor = ''
|
|
730
|
+
view.searchTotal = 0
|
|
731
|
+
view.searchLoading = false
|
|
732
|
+
view.searchError = ''
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function scheduleDocumentSearch(workspace) {
|
|
736
|
+
window.clearTimeout(documentSearchTimer)
|
|
737
|
+
const view = workspace.view
|
|
738
|
+
if (!view.query.trim()) {
|
|
739
|
+
resetDocumentSearch(view)
|
|
740
|
+
renderShell()
|
|
741
|
+
return
|
|
742
|
+
}
|
|
743
|
+
view.searchLoading = true
|
|
744
|
+
view.searchError = ''
|
|
745
|
+
renderShell()
|
|
746
|
+
documentSearchTimer = window.setTimeout(() => { void loadDocumentSearch(workspace, true) }, 220)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function renderDocumentSearchState(workspace) {
|
|
750
|
+
renderShell()
|
|
751
|
+
window.requestAnimationFrame(() => {
|
|
752
|
+
const input = document.querySelector(`.note-tree-search[data-document-scope="${workspace.kind}"]`)
|
|
753
|
+
input?.focus()
|
|
754
|
+
input?.setSelectionRange(input.value.length, input.value.length)
|
|
755
|
+
})
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function loadDocumentSearch(workspace, reset = false) {
|
|
759
|
+
const view = workspace.view
|
|
760
|
+
const query = view.query.trim()
|
|
761
|
+
if (!query) return resetDocumentSearch(view)
|
|
762
|
+
const request = ++view.searchRequest
|
|
763
|
+
view.searchLoading = true
|
|
764
|
+
view.searchError = ''
|
|
765
|
+
const params = new URLSearchParams({ q: query, limit: '80' })
|
|
766
|
+
const bases = documentWorkspaceBases(workspace)
|
|
767
|
+
if (bases.length === 0) {
|
|
768
|
+
resetDocumentSearch(view)
|
|
769
|
+
return renderShell()
|
|
770
|
+
}
|
|
771
|
+
if (workspace.kind === 'library') {
|
|
772
|
+
params.append('knowledgeBaseId', state.libraryDetail.knowledgeBaseId)
|
|
773
|
+
} else if (state.mountContext.sessionId) {
|
|
774
|
+
params.set('sessionId', state.mountContext.sessionId)
|
|
775
|
+
if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
|
|
776
|
+
} else {
|
|
777
|
+
params.set('active', '1')
|
|
778
|
+
}
|
|
779
|
+
if (!reset && view.searchNextCursor) params.set('cursor', view.searchNextCursor)
|
|
780
|
+
try {
|
|
781
|
+
const result = await api(`document-index?${params}`)
|
|
782
|
+
if (request !== view.searchRequest || query !== view.query.trim()) return
|
|
783
|
+
const merged = new Map((reset ? [] : view.searchResults).map(document => [document.id, document]))
|
|
784
|
+
for (const item of result.items) merged.set(item.id, item)
|
|
785
|
+
view.searchResults = [...merged.values()]
|
|
786
|
+
view.searchNextCursor = result.nextCursor || ''
|
|
787
|
+
view.searchTotal = result.total
|
|
788
|
+
} catch (error) {
|
|
789
|
+
if (request !== view.searchRequest) return
|
|
790
|
+
view.searchError = friendlyError(error)
|
|
791
|
+
} finally {
|
|
792
|
+
if (request === view.searchRequest) {
|
|
793
|
+
view.searchLoading = false
|
|
794
|
+
renderDocumentSearchState(workspace)
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
486
799
|
function documentWorkspaceBases(workspace) {
|
|
487
800
|
if (workspace.kind === 'library') {
|
|
488
801
|
const base = state.knowledgeBases.find(item => item.id === state.libraryDetail.knowledgeBaseId)
|
|
@@ -496,14 +809,21 @@ function documentWorkspaceReadOnly(workspace) {
|
|
|
496
809
|
}
|
|
497
810
|
|
|
498
811
|
async function reloadDocumentWorkspace(workspace) {
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
if (workspace.kind === 'library')
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
812
|
+
const baseId = workspace.kind === 'library' ? state.libraryDetail.knowledgeBaseId : workspace.view.knowledgeBaseId
|
|
813
|
+
if (!baseId) return true
|
|
814
|
+
await loadDocumentPage(workspace, baseId, { reset: true, throwOnError: true })
|
|
815
|
+
if (workspace.kind === 'library' && state.libraryDetail.knowledgeBaseId !== baseId) return false
|
|
816
|
+
const selectedId = workspace.view.documentId
|
|
817
|
+
if (selectedId && !documentWorkspaceDocuments(workspace).some(document => document.id === selectedId)) {
|
|
818
|
+
const document = await api(`documents/${encodeURIComponent(selectedId)}`)
|
|
819
|
+
mergeDocumentSummaries(workspace, [documentSummary(document)], baseId, false)
|
|
506
820
|
}
|
|
821
|
+
return true
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function documentSummary(document) {
|
|
825
|
+
const { content: _content, ...summary } = document
|
|
826
|
+
return summary
|
|
507
827
|
}
|
|
508
828
|
|
|
509
829
|
function selectDefaultDocument(workspace) {
|
|
@@ -515,16 +835,24 @@ function selectDefaultDocument(workspace) {
|
|
|
515
835
|
if (view.knowledgeBaseId) view.expandedBases.add(view.knowledgeBaseId)
|
|
516
836
|
}
|
|
517
837
|
|
|
518
|
-
async function loadDocumentEditor(workspace, id) {
|
|
519
|
-
const
|
|
838
|
+
async function loadDocumentEditor(workspace, id, signal) {
|
|
839
|
+
const revision = (workspace.view.loadRevision || 0) + 1
|
|
840
|
+
workspace.view.loadRevision = revision
|
|
841
|
+
const [entry, noteReferences] = await Promise.all([
|
|
842
|
+
api(`entries/${encodeURIComponent(id)}`, { signal }),
|
|
843
|
+
api(`entries/${encodeURIComponent(id)}/note-references`, { signal }),
|
|
844
|
+
])
|
|
845
|
+
if (workspace.view.loadRevision !== revision || workspace.view.documentId !== id) return false
|
|
520
846
|
workspace.view.mode = 'preview'
|
|
521
847
|
workspace.view.editor = {
|
|
522
848
|
...entry,
|
|
849
|
+
noteReferences,
|
|
523
850
|
tagsText: entry.tags.join(', '),
|
|
524
851
|
dirty: false,
|
|
525
852
|
isNew: false,
|
|
526
853
|
saveState: '已保存',
|
|
527
854
|
}
|
|
855
|
+
return true
|
|
528
856
|
}
|
|
529
857
|
|
|
530
858
|
function createBlankDocument(workspace, baseId) {
|
|
@@ -538,7 +866,7 @@ function createBlankDocument(workspace, baseId) {
|
|
|
538
866
|
view.expandedBases.add(base.id)
|
|
539
867
|
view.editor = {
|
|
540
868
|
id: '', knowledgeBaseId: base.id, title: '', body: '', type: 'fact', tags: [], tagsText: '',
|
|
541
|
-
scope: { kind: 'global' }, confidence: .8, dirty: true, isNew: true, saveState: '新文档',
|
|
869
|
+
scope: { kind: 'global' }, confidence: .8, noteReferences: [], dirty: true, isNew: true, saveState: '新文档',
|
|
542
870
|
}
|
|
543
871
|
renderShell()
|
|
544
872
|
document.querySelector('.note-title-input')?.focus()
|
|
@@ -557,8 +885,21 @@ async function selectDocument(workspace, id) {
|
|
|
557
885
|
if (editor?.dirty && !emptyDraft && !await saveDocumentEditor(workspace)) return
|
|
558
886
|
workspace.view.documentId = id
|
|
559
887
|
workspace.view.treeOpen = false
|
|
560
|
-
|
|
888
|
+
workspace.view.editor = null
|
|
889
|
+
workspace.view.editorLoading = true
|
|
890
|
+
workspace.view.loadingDocumentId = id
|
|
561
891
|
renderShell()
|
|
892
|
+
try {
|
|
893
|
+
await loadDocumentEditor(workspace, id)
|
|
894
|
+
} catch (error) {
|
|
895
|
+
if (workspace.view.documentId === id) showToast(friendlyError(error), 'error')
|
|
896
|
+
} finally {
|
|
897
|
+
if (workspace.view.loadingDocumentId === id) {
|
|
898
|
+
workspace.view.editorLoading = false
|
|
899
|
+
workspace.view.loadingDocumentId = ''
|
|
900
|
+
renderShell()
|
|
901
|
+
}
|
|
902
|
+
}
|
|
562
903
|
}
|
|
563
904
|
|
|
564
905
|
function editorDraft(editor) {
|
|
@@ -615,38 +956,44 @@ function updateEditorSaveState(label) {
|
|
|
615
956
|
if (node) node.textContent = label
|
|
616
957
|
}
|
|
617
958
|
|
|
618
|
-
async function loadCandidates() {
|
|
959
|
+
async function loadCandidates(signal) {
|
|
619
960
|
const [candidates] = await Promise.all([
|
|
620
|
-
api(`candidates?status=${state.candidateStatus}&limit=100
|
|
621
|
-
ensureKnowledgeBases(),
|
|
961
|
+
api(`candidates?status=${state.candidateStatus}&limit=100`, { signal }),
|
|
962
|
+
ensureKnowledgeBases(false, signal),
|
|
622
963
|
])
|
|
623
964
|
state.candidates = candidates
|
|
624
965
|
const targetIds = [...new Set(candidates.map(candidate => candidate.targetId).filter(Boolean))]
|
|
625
966
|
const targets = await Promise.all(targetIds.map(async id => {
|
|
626
|
-
try { return [id, await api(`entries/${encodeURIComponent(id)}
|
|
627
|
-
catch {
|
|
967
|
+
try { return [id, await api(`entries/${encodeURIComponent(id)}`, { signal })] }
|
|
968
|
+
catch (error) {
|
|
969
|
+
if (signal?.aborted) throw error
|
|
970
|
+
return [id, null]
|
|
971
|
+
}
|
|
628
972
|
}))
|
|
629
973
|
state.candidateTargets = new Map(targets)
|
|
630
|
-
if (!state.stats) await refreshStats()
|
|
974
|
+
if (!state.stats) await refreshStats(signal)
|
|
631
975
|
}
|
|
632
976
|
|
|
633
|
-
async function loadTokens() {
|
|
634
|
-
const [tokens, service] = await Promise.all([api('tokens'), api('service')])
|
|
977
|
+
async function loadTokens(signal) {
|
|
978
|
+
const [tokens, service] = await Promise.all([api('tokens', { signal }), api('service', { signal })])
|
|
635
979
|
state.tokens = tokens
|
|
636
980
|
state.service = service
|
|
637
|
-
if (!state.stats) await refreshStats()
|
|
981
|
+
if (!state.stats) await refreshStats(signal)
|
|
638
982
|
}
|
|
639
983
|
|
|
640
984
|
function renderShell() {
|
|
985
|
+
releaseNoteEditors()
|
|
641
986
|
captureScrollPosition()
|
|
642
987
|
const titles = {
|
|
643
988
|
overview: ['概览', '知识库运行状态与最近活动'],
|
|
644
989
|
bases: ['知识库与挂载', '管理知识目录,并限定项目与会话的召回和写入范围'],
|
|
645
990
|
entries: ['知识文档', '在知识目录中阅读、整理和维护 Markdown 文档'],
|
|
991
|
+
notes: ['笔记文档', '像本地目录一样整理笔记和资料,并按需关联到知识文档'],
|
|
646
992
|
candidates: ['待审核', '确认 AI 提取结果后再写入知识文档'],
|
|
647
993
|
tokens: ['访问管理', '管理其他客户端连接中央知识库的权限'],
|
|
648
994
|
}
|
|
649
995
|
const [title, subtitle] = titles[state.view]
|
|
996
|
+
const viewIndexes = { overview: '00', notes: '01', entries: '02', candidates: '03', bases: '04', tokens: '05' }
|
|
650
997
|
const shell = element('div', {
|
|
651
998
|
class: 'app-shell', 'data-menu-open': String(state.menuOpen),
|
|
652
999
|
'data-view': state.view, 'data-loading': String(state.loading),
|
|
@@ -660,7 +1007,10 @@ function renderShell() {
|
|
|
660
1007
|
element('main', { class: 'main' },
|
|
661
1008
|
element('header', { class: 'topbar' },
|
|
662
1009
|
element('div', { class: 'topbar-title' },
|
|
663
|
-
actionButton('☰', () => { state.menuOpen = !state.menuOpen; renderShell() }, 'ghost mobile-menu', {
|
|
1010
|
+
actionButton('☰', () => { state.menuOpen = !state.menuOpen; renderShell() }, 'ghost mobile-menu', {
|
|
1011
|
+
'aria-label': state.menuOpen ? '关闭导航菜单' : '打开导航菜单',
|
|
1012
|
+
'aria-expanded': String(state.menuOpen),
|
|
1013
|
+
}),
|
|
664
1014
|
paneToggleButton('main', !state.documentView.sidebarHidden, () => setSidebarHidden(!state.documentView.sidebarHidden), '主导航栏'),
|
|
665
1015
|
activeDocumentWorkspace() ? paneToggleButton('library', activeDocumentWorkspace().view.treeOpen, () => {
|
|
666
1016
|
const workspace = activeDocumentWorkspace()
|
|
@@ -668,10 +1018,17 @@ function renderShell() {
|
|
|
668
1018
|
workspace.view.treeOpen = !workspace.view.treeOpen
|
|
669
1019
|
renderShell()
|
|
670
1020
|
}, '知识目录') : null,
|
|
671
|
-
element('div', {
|
|
1021
|
+
element('div', { class: 'topbar-heading' },
|
|
1022
|
+
element('span', { class: 'topbar-kicker' }, `KNOWLEDGE / ${viewIndexes[state.view] || '00'}`),
|
|
1023
|
+
element('h1', {}, title),
|
|
1024
|
+
element('p', {}, subtitle)),
|
|
672
1025
|
),
|
|
1026
|
+
state.loading ? element('div', {
|
|
1027
|
+
class: 'route-progress', role: 'progressbar', 'aria-label': state.loadingPhase || '正在加载',
|
|
1028
|
+
'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(Math.round(state.loadingProgress * 100)),
|
|
1029
|
+
}, element('span', { style: `--route-progress: ${state.loadingProgress}` })) : null,
|
|
673
1030
|
),
|
|
674
|
-
element('div', { class: 'page' }, renderCurrentView()),
|
|
1031
|
+
element('div', { class: 'page' }, element('div', { class: 'view-stage', 'data-view-stage': state.view }, renderCurrentView())),
|
|
675
1032
|
),
|
|
676
1033
|
)
|
|
677
1034
|
if (state.menuOpen) shell.addEventListener('click', (event) => {
|
|
@@ -749,11 +1106,14 @@ function setSidebarHidden(hidden) {
|
|
|
749
1106
|
function renderSidebar() {
|
|
750
1107
|
const pending = state.stats?.candidates.pending
|
|
751
1108
|
const navGroups = [
|
|
752
|
-
['
|
|
1109
|
+
['笔记工作区', [['notes', '笔记文档']]],
|
|
1110
|
+
['知识工作区', [['entries', '知识文档'], ['candidates', '待审核'], ['bases', '知识库与挂载']]],
|
|
753
1111
|
['连接', [['tokens', '访问管理']].filter(([id]) => id !== 'tokens' || !state.service.remote)],
|
|
754
1112
|
].filter(([, items]) => items.length)
|
|
1113
|
+
let navIndex = 0
|
|
755
1114
|
return element('aside', { class: 'sidebar', 'aria-label': '知识库导航' },
|
|
756
1115
|
element('div', { class: 'brand' },
|
|
1116
|
+
element('span', { class: 'brand-emblem', 'aria-hidden': 'true' }, element('span', {})),
|
|
757
1117
|
element('div', { class: 'brand-copy' }, element('span', {}, 'DSH Knowledge'), element('strong', {}, '知识库')),
|
|
758
1118
|
),
|
|
759
1119
|
element('nav', { class: 'nav' }, navGroups.map(([group, items]) => element('div', { class: 'nav-group' },
|
|
@@ -761,7 +1121,7 @@ function renderSidebar() {
|
|
|
761
1121
|
items.map(([id, label]) => element('button', {
|
|
762
1122
|
type: 'button', class: 'nav-button', 'aria-current': state.view === id ? 'page' : undefined,
|
|
763
1123
|
onClick: () => navigate(id),
|
|
764
|
-
}, element('span', { class: 'nav-label' }, label),
|
|
1124
|
+
}, element('span', { class: 'nav-index', 'aria-hidden': 'true' }, String(++navIndex).padStart(2, '0')), element('span', { class: 'nav-label' }, label),
|
|
765
1125
|
id === 'candidates' && pending ? element('span', { class: 'nav-count', 'aria-label': `${pending} 条待审核` }, pending) : null))))),
|
|
766
1126
|
element('div', { class: 'sidebar-footer' },
|
|
767
1127
|
element('div', { class: 'connection' }, element('span', { class: 'status-dot', 'aria-hidden': 'true' }), state.service.remote ? '中央知识库已连接' : '本地知识库已连接'),
|
|
@@ -771,26 +1131,38 @@ function renderSidebar() {
|
|
|
771
1131
|
}
|
|
772
1132
|
|
|
773
1133
|
function renderCurrentView() {
|
|
774
|
-
if (state.loading) return loadingView()
|
|
1134
|
+
if (state.loading) return loadingView(state.view, state.loadingPhase, state.loadingProgress)
|
|
775
1135
|
if (state.error) return errorView(state.error, () => navigate(state.view))
|
|
776
1136
|
if (state.view === 'overview') return renderOverview()
|
|
777
1137
|
if (state.view === 'bases') return renderKnowledgeBases()
|
|
778
1138
|
if (state.view === 'entries') return renderEntries()
|
|
1139
|
+
if (state.view === 'notes') return renderNotes()
|
|
779
1140
|
if (state.view === 'candidates') return renderCandidates()
|
|
780
1141
|
return renderTokens()
|
|
781
1142
|
}
|
|
782
1143
|
|
|
783
|
-
function loadingView() {
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
element('div', { class: 'skeleton-line skeleton-title', 'aria-hidden': 'true' }),
|
|
787
|
-
element('
|
|
788
|
-
element('div', { class: 'skeleton-grid', 'aria-hidden': 'true' },
|
|
789
|
-
element('div', { class: 'skeleton-block' }),
|
|
790
|
-
element('div', { class: 'skeleton-block' }),
|
|
791
|
-
element('div', { class: 'skeleton-block' }),
|
|
792
|
-
),
|
|
1144
|
+
function loadingView(view = state.view, phase = '正在加载', progress = 0) {
|
|
1145
|
+
const label = phase || loadingPhaseForView(view)
|
|
1146
|
+
const header = element('div', { class: 'skeleton-heading' },
|
|
1147
|
+
element('div', {}, element('div', { class: 'skeleton-line skeleton-title', 'aria-hidden': 'true' }), element('div', { class: 'skeleton-line skeleton-copy', 'aria-hidden': 'true' })),
|
|
1148
|
+
element('span', { class: 'skeleton-phase' }, label),
|
|
793
1149
|
)
|
|
1150
|
+
const content = view === 'entries'
|
|
1151
|
+
? element('div', { class: 'skeleton-workspace skeleton-workspace-documents', 'aria-hidden': 'true' },
|
|
1152
|
+
element('div', { class: 'skeleton-pane skeleton-pane-tree' }, ...Array.from({ length: 7 }, (_, index) => element('div', { class: `skeleton-line skeleton-tree-row is-${index % 3}` }))),
|
|
1153
|
+
element('div', { class: 'skeleton-pane skeleton-pane-paper' }, element('div', { class: 'skeleton-line skeleton-paper-title' }), ...Array.from({ length: 8 }, (_, index) => element('div', { class: `skeleton-line skeleton-paper-row is-${index % 4}` }))),
|
|
1154
|
+
)
|
|
1155
|
+
: view === 'notes'
|
|
1156
|
+
? element('div', { class: 'skeleton-workspace skeleton-workspace-notes', 'aria-hidden': 'true' },
|
|
1157
|
+
element('div', { class: 'skeleton-pane skeleton-pane-tree' }, ...Array.from({ length: 8 }, (_, index) => element('div', { class: `skeleton-line skeleton-tree-row is-${index % 3}` }))),
|
|
1158
|
+
element('div', { class: 'skeleton-pane skeleton-pane-list' }, ...Array.from({ length: 6 }, () => element('div', { class: 'skeleton-list-row' }, element('span', { class: 'skeleton-block' }), element('span', { class: 'skeleton-line' })))),
|
|
1159
|
+
)
|
|
1160
|
+
: element('div', { class: 'skeleton-grid', 'aria-hidden': 'true' },
|
|
1161
|
+
element('div', { class: 'skeleton-block' }), element('div', { class: 'skeleton-block' }), element('div', { class: 'skeleton-block' }))
|
|
1162
|
+
return element('div', {
|
|
1163
|
+
class: `loading-skeleton loading-skeleton--${view}`, role: 'status', 'aria-label': label,
|
|
1164
|
+
style: `--loading-progress: ${progress}`,
|
|
1165
|
+
}, element('span', { class: 'visually-hidden' }, label), header, content)
|
|
794
1166
|
}
|
|
795
1167
|
|
|
796
1168
|
function errorView(message, retry) {
|
|
@@ -901,6 +1273,7 @@ function renderKnowledgeBases() {
|
|
|
901
1273
|
|
|
902
1274
|
async function openKnowledgeBaseDocuments(base) {
|
|
903
1275
|
captureScrollPosition()
|
|
1276
|
+
const request = ++libraryDetailRequest
|
|
904
1277
|
const detail = state.libraryDetail
|
|
905
1278
|
const changingBase = detail.knowledgeBaseId !== base.id
|
|
906
1279
|
detail.knowledgeBaseId = base.id
|
|
@@ -916,13 +1289,15 @@ async function openKnowledgeBaseDocuments(base) {
|
|
|
916
1289
|
renderShell()
|
|
917
1290
|
const workspace = libraryDocumentWorkspace()
|
|
918
1291
|
try {
|
|
919
|
-
await reloadDocumentWorkspace(workspace)
|
|
1292
|
+
if (!await reloadDocumentWorkspace(workspace) || request !== libraryDetailRequest) return
|
|
920
1293
|
selectDefaultDocument(workspace)
|
|
921
1294
|
if (workspace.view.documentId) await loadDocumentEditor(workspace, workspace.view.documentId)
|
|
922
1295
|
else workspace.view.editor = null
|
|
923
1296
|
} catch (error) {
|
|
1297
|
+
if (request !== libraryDetailRequest) return
|
|
924
1298
|
detail.error = friendlyError(error)
|
|
925
1299
|
} finally {
|
|
1300
|
+
if (request !== libraryDetailRequest) return
|
|
926
1301
|
state.loading = false
|
|
927
1302
|
renderShell()
|
|
928
1303
|
window.requestAnimationFrame(() => document.querySelector('.library-detail-back')?.focus())
|
|
@@ -934,6 +1309,7 @@ async function closeKnowledgeBaseDetail() {
|
|
|
934
1309
|
const editor = workspace.view.editor
|
|
935
1310
|
const emptyDraft = editor?.isNew && !editor.title.trim() && !editor.body.trim()
|
|
936
1311
|
if (editor?.dirty && !emptyDraft && !await saveDocumentEditor(workspace)) return
|
|
1312
|
+
libraryDetailRequest += 1
|
|
937
1313
|
const baseId = state.libraryDetail.knowledgeBaseId
|
|
938
1314
|
state.knowledgeBaseView = 'libraries'
|
|
939
1315
|
state.libraryDetail.error = ''
|
|
@@ -998,6 +1374,16 @@ function contextPill(label, value) {
|
|
|
998
1374
|
return element('div', { class: 'context-pill' }, element('strong', {}, label), element('span', { title: value }, value))
|
|
999
1375
|
}
|
|
1000
1376
|
|
|
1377
|
+
function writebackRouteLabel(base) {
|
|
1378
|
+
if (state.service.writebackProvider && state.service.writebackModel) {
|
|
1379
|
+
return `本机覆盖 · ${state.service.writebackProvider} / ${state.service.writebackModel}`
|
|
1380
|
+
}
|
|
1381
|
+
if (base?.writebackProvider && base?.writebackModel) {
|
|
1382
|
+
return `知识库专用 · ${base.writebackProvider} / ${base.writebackModel}`
|
|
1383
|
+
}
|
|
1384
|
+
return '跟随当前会话模型'
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1001
1387
|
function renderKnowledgeBaseCard(base) {
|
|
1002
1388
|
const archived = base.status === 'archived'
|
|
1003
1389
|
const visibleTags = base.defaultTags.slice(0, 4)
|
|
@@ -1017,7 +1403,7 @@ function renderKnowledgeBaseCard(base) {
|
|
|
1017
1403
|
hiddenTagCount ? element('span', { class: 'tag' }, `+${hiddenTagCount}`) : null),
|
|
1018
1404
|
element('div', { class: 'base-card-meta' },
|
|
1019
1405
|
element('span', {}, element('strong', {}, '回写策略'), base.writebackPolicy === 'proactive' ? '主动' : '严谨'),
|
|
1020
|
-
element('span', {}, element('strong', {}, '回写模型'),
|
|
1406
|
+
element('span', {}, element('strong', {}, '回写模型'), writebackRouteLabel(base)),
|
|
1021
1407
|
base.extractionInstructions ? element('span', {}, element('strong', {}, '提取规则'), '已设置') : null,
|
|
1022
1408
|
),
|
|
1023
1409
|
element('div', { class: 'base-card-actions' },
|
|
@@ -1133,9 +1519,7 @@ function renderMountListRow(base, view, targetKind, targetId) {
|
|
|
1133
1519
|
renderShell()
|
|
1134
1520
|
},
|
|
1135
1521
|
})
|
|
1136
|
-
const modelLabel =
|
|
1137
|
-
? `本机 · ${state.service.writebackProvider} / ${state.service.writebackModel}`
|
|
1138
|
-
: '跟随当前会话模型'
|
|
1522
|
+
const modelLabel = writebackRouteLabel(base)
|
|
1139
1523
|
return element('article', { class: `mount-list-row${selected ? ' is-selected' : ''}`, role: 'listitem' },
|
|
1140
1524
|
element('label', { class: 'mount-select' }, checkbox, element('span', { class: 'visually-hidden' }, `选择 ${base.name}`)),
|
|
1141
1525
|
element('div', { class: 'mount-list-main' },
|
|
@@ -1192,53 +1576,77 @@ function renderDocumentWorkspace(workspace, options = {}) {
|
|
|
1192
1576
|
'data-document-scope': workspace.kind,
|
|
1193
1577
|
onInput: (event) => {
|
|
1194
1578
|
view.query = event.target.value
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1579
|
+
scheduleDocumentSearch(workspace)
|
|
1580
|
+
window.requestAnimationFrame(() => {
|
|
1581
|
+
const input = document.querySelector(`.note-tree-search[data-document-scope="${workspace.kind}"]`)
|
|
1582
|
+
input?.focus()
|
|
1583
|
+
input?.setSelectionRange(input.value.length, input.value.length)
|
|
1584
|
+
})
|
|
1199
1585
|
},
|
|
1200
1586
|
})
|
|
1201
|
-
const
|
|
1587
|
+
const visibleBases = query
|
|
1588
|
+
? activeBases.filter(base => view.searchResults.some(document => document.knowledgeBaseId === base.id))
|
|
1589
|
+
: activeBases
|
|
1590
|
+
const tree = visibleBases.map(base => {
|
|
1202
1591
|
const expanded = view.expandedBases.has(base.id) || Boolean(query)
|
|
1203
|
-
const
|
|
1204
|
-
|
|
1592
|
+
const page = documentPageState(workspace, base.id)
|
|
1593
|
+
const documents = (query ? view.searchResults : workspaceDocuments).filter(document => document.knowledgeBaseId === base.id)
|
|
1205
1594
|
return element('section', { class: 'note-tree-group', 'data-expanded': String(expanded) },
|
|
1206
1595
|
element('button', {
|
|
1207
1596
|
type: 'button', class: 'note-tree-base', 'aria-expanded': String(expanded),
|
|
1208
1597
|
onClick: () => {
|
|
1209
|
-
view.knowledgeBaseId = base.id
|
|
1210
|
-
|
|
1211
|
-
else view.expandedBases.add(base.id)
|
|
1212
|
-
renderShell()
|
|
1598
|
+
if (query) { view.knowledgeBaseId = base.id; return renderShell() }
|
|
1599
|
+
void toggleDocumentBase(workspace, base.id)
|
|
1213
1600
|
},
|
|
1214
1601
|
},
|
|
1215
1602
|
element('span', { class: 'tree-disclosure', 'aria-hidden': 'true' }),
|
|
1216
1603
|
element('span', { class: 'tree-folder-icon', 'aria-hidden': 'true' }),
|
|
1217
1604
|
element('span', { class: 'tree-base-name' }, base.name),
|
|
1218
|
-
element('span', { class: 'tree-count' }, documents.length)),
|
|
1605
|
+
element('span', { class: 'tree-count' }, query ? documents.length : page.loaded ? page.total : '—')),
|
|
1219
1606
|
expanded ? element('div', { class: 'note-tree-documents', role: 'group', 'aria-label': `${base.name}文档` },
|
|
1607
|
+
!query && page.loading && documents.length === 0 ? element('div', { class: 'note-tree-status', role: 'status' }, '正在读取目录…') : null,
|
|
1608
|
+
!query && page.error ? element('div', { class: 'note-tree-status is-error' },
|
|
1609
|
+
element('span', {}, page.error),
|
|
1610
|
+
actionButton('重试', () => { void loadDocumentPage(workspace, base.id, { reset: true }).then(renderShell) }, 'ghost small')) : null,
|
|
1220
1611
|
documents.map(document => element('button', {
|
|
1221
1612
|
type: 'button', class: 'note-tree-document', 'aria-current': document.id === view.documentId ? 'page' : undefined,
|
|
1222
|
-
onClick: () => { void selectDocument(workspace, document.id) },
|
|
1613
|
+
onClick: () => { view.knowledgeBaseId = base.id; void selectDocument(workspace, document.id) },
|
|
1223
1614
|
}, element('span', { class: 'tree-document-icon', 'aria-hidden': 'true' }), element('span', { class: 'tree-document-copy' },
|
|
1224
1615
|
element('strong', {}, document.title), element('small', {}, document.relPath)),
|
|
1225
1616
|
document.documentState !== 'open' ? badge(DOCUMENT_STATE_LABELS[document.documentState] || '已结束', 'success') : null)),
|
|
1617
|
+
!query && page.nextCursor ? element('button', {
|
|
1618
|
+
type: 'button', class: 'note-tree-more', disabled: page.loading,
|
|
1619
|
+
onClick: () => { void loadDocumentPage(workspace, base.id, { append: true }).then(renderShell) },
|
|
1620
|
+
}, page.loading ? '正在加载…' : `继续加载(已显示 ${documents.length} / ${page.total})`) : null,
|
|
1621
|
+
!query && page.loaded && documents.length === 0 ? element('div', { class: 'note-tree-status' }, '这个知识库还没有文档。') : null,
|
|
1226
1622
|
!query && !readOnly ? element('button', { type: 'button', class: 'note-tree-new', onClick: () => { void startBlankDocument(workspace, base.id) } },
|
|
1227
1623
|
element('span', { 'aria-hidden': 'true' }, '+'), '新建文档') : null,
|
|
1228
1624
|
) : null,
|
|
1229
1625
|
)
|
|
1230
1626
|
})
|
|
1627
|
+
const treeContent = query
|
|
1628
|
+
? [
|
|
1629
|
+
view.searchLoading && view.searchResults.length === 0 ? element('div', { class: 'note-tree-status', role: 'status' }, '正在搜索文档…') : null,
|
|
1630
|
+
view.searchError ? element('div', { class: 'note-tree-status is-error' }, view.searchError) : null,
|
|
1631
|
+
...tree,
|
|
1632
|
+
!view.searchLoading && !view.searchError && view.searchResults.length === 0 ? element('div', { class: 'note-tree-empty' }, '没有匹配的文档') : null,
|
|
1633
|
+
view.searchNextCursor ? element('button', {
|
|
1634
|
+
type: 'button', class: 'note-tree-more search-more', disabled: view.searchLoading,
|
|
1635
|
+
onClick: () => { void loadDocumentSearch(workspace, false) },
|
|
1636
|
+
}, view.searchLoading ? '正在加载…' : `继续加载搜索结果(${view.searchResults.length} / ${view.searchTotal})`) : null,
|
|
1637
|
+
]
|
|
1638
|
+
: tree.length ? tree : [element('div', { class: 'note-tree-empty' }, workspace.kind === 'session' && state.mountContext.sessionId ? '当前会话未挂载知识库' : '还没有知识库')]
|
|
1231
1639
|
return element('section', {
|
|
1232
1640
|
class: `note-workspace note-workspace--${workspace.kind}`, 'aria-labelledby': `${workspace.kind}-documents-heading`,
|
|
1233
1641
|
'data-tree-open': String(view.treeOpen),
|
|
1234
1642
|
},
|
|
1235
1643
|
element('aside', { class: 'note-tree-panel', 'aria-label': '知识目录' },
|
|
1236
1644
|
element('header', { class: 'note-tree-header' },
|
|
1237
|
-
element('div', {}, element('h2', { id: `${workspace.kind}-documents-heading` }, options.heading || '知识目录'), element('span', {}, `${workspaceDocuments.length}
|
|
1645
|
+
element('div', {}, element('h2', { id: `${workspace.kind}-documents-heading` }, options.heading || '知识目录'), element('span', {}, query ? `${view.searchTotal} 条搜索结果` : `${workspaceDocuments.length} 篇已加载`)),
|
|
1238
1646
|
!readOnly ? actionButton('+', () => { void startBlankDocument(workspace, view.knowledgeBaseId || activeBases[0]?.id) }, 'ghost note-add-button', { 'aria-label': '新建文档', title: '新建文档' }) : null,
|
|
1239
1647
|
),
|
|
1240
1648
|
element('div', { class: 'note-tree-search-wrap' }, interfaceIcon('search', 'search-symbol'), search),
|
|
1241
|
-
element('nav', { class: 'note-tree', 'data-scroll-key': `${workspace.kind}-note-tree` },
|
|
1649
|
+
element('nav', { class: 'note-tree', 'data-scroll-key': `${workspace.kind}-note-tree` }, treeContent),
|
|
1242
1650
|
workspace.kind === 'session' ? element('footer', { class: 'note-tree-footer' }, actionButton('新建知识库', () => openKnowledgeBaseEditor(), 'ghost small')) : null,
|
|
1243
1651
|
),
|
|
1244
1652
|
view.treeOpen ? element('button', {
|
|
@@ -1252,6 +1660,7 @@ function renderDocumentWorkspace(workspace, options = {}) {
|
|
|
1252
1660
|
function renderNoteEditor(workspace, editor, base) {
|
|
1253
1661
|
const view = workspace.view
|
|
1254
1662
|
const readOnly = documentWorkspaceReadOnly(workspace)
|
|
1663
|
+
if (view.editorLoading) return renderDocumentEditorLoading(base)
|
|
1255
1664
|
if (!editor) return element('main', { class: 'note-editor note-editor-empty' },
|
|
1256
1665
|
element('div', { class: 'note-empty-content' },
|
|
1257
1666
|
element('span', { class: 'empty-document-mark', 'aria-hidden': 'true' }),
|
|
@@ -1275,28 +1684,29 @@ function renderNoteEditor(workspace, editor, base) {
|
|
|
1275
1684
|
class: 'note-title-input', value: editor.title, maxlength: 200, placeholder: '无标题文档', 'aria-label': '文档标题',
|
|
1276
1685
|
onInput: event => update('title', event.target.value), onKeyDown: saveShortcut,
|
|
1277
1686
|
})
|
|
1278
|
-
const body = element('textarea', {
|
|
1279
|
-
class: 'note-body-editor', maxlength: 50000, placeholder: '从这里开始记录…', 'aria-label': '文档正文',
|
|
1280
|
-
onInput: event => { update('body', event.target.value); resizeDocumentEditor(event.target) }, onKeyDown: saveShortcut,
|
|
1281
|
-
})
|
|
1282
|
-
body.value = editor.body
|
|
1283
1687
|
const finalized = !editor.isNew && editor.documentState !== 'open'
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1688
|
+
const editable = !finalized && !readOnly
|
|
1689
|
+
const body = editable
|
|
1690
|
+
? element('div', { class: 'notes-live-editor knowledge-live-editor', role: 'status', 'aria-label': '正在打开知识文档正文' },
|
|
1691
|
+
element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
|
|
1692
|
+
: renderMarkdownPreview(editor.body)
|
|
1693
|
+
if (editable) mountMarkdownEditor(body, {
|
|
1694
|
+
markdown: editor.body,
|
|
1695
|
+
label: '编辑知识文档正文',
|
|
1696
|
+
onChange: value => update('body', value),
|
|
1697
|
+
onSave: () => { void saveDocumentEditor(workspace).then(saved => { if (saved) renderShell() }) },
|
|
1698
|
+
})
|
|
1286
1699
|
return element('main', { class: 'note-editor', 'aria-label': '文档编辑器' },
|
|
1287
1700
|
element('header', { class: 'note-editor-toolbar' },
|
|
1288
1701
|
element('div', { class: 'note-breadcrumb' },
|
|
1289
1702
|
element('span', {}, base?.name || '知识库'),
|
|
1290
1703
|
element('span', { 'aria-hidden': 'true' }, '/'),
|
|
1291
|
-
element('strong', {}, editor.isNew ? '新文档' : documentWorkspaceDocuments(workspace).find(item => item.id === editor.id)?.relPath || editor.title)
|
|
1704
|
+
element('strong', {}, editor.isNew ? '新文档' : documentWorkspaceDocuments(workspace).find(item => item.id === editor.id)?.relPath || editor.title),
|
|
1705
|
+
element('span', { class: 'note-toolbar-meta' },
|
|
1706
|
+
element('span', {}, editor.isNew ? '尚未保存' : `更新于 ${formatDate(editor.updatedAt)}`),
|
|
1707
|
+
element('span', {}, editor.scope.kind === 'global' ? '全局知识' : `项目 ${editor.scope.id}`))),
|
|
1292
1708
|
element('div', { class: 'note-editor-actions' },
|
|
1293
|
-
finalized ? badge(DOCUMENT_STATE_LABELS[editor.documentState] || '已结束', 'success') : readOnly ? badge('只读') :
|
|
1294
|
-
['edit', '编辑'],
|
|
1295
|
-
['preview', '预览'],
|
|
1296
|
-
].map(([value, label]) => element('button', {
|
|
1297
|
-
type: 'button', role: 'tab', 'aria-selected': String(mode === value),
|
|
1298
|
-
onClick: () => switchDocumentMode(workspace, value),
|
|
1299
|
-
}, label))),
|
|
1709
|
+
finalized ? badge(DOCUMENT_STATE_LABELS[editor.documentState] || '已结束', 'success') : readOnly ? badge('只读') : null,
|
|
1300
1710
|
element('span', { class: 'editor-save-status', role: 'status' }, editor.saveState),
|
|
1301
1711
|
finalized && !readOnly ? actionButton('重新打开', () => reopenDocument(workspace, editor), 'small') : null,
|
|
1302
1712
|
!readOnly && !editor.isNew && !finalized ? actionButton('标记结束', () => openFinalizeDocument(workspace, editor), 'small') : null,
|
|
@@ -1313,14 +1723,12 @@ function renderNoteEditor(workspace, editor, base) {
|
|
|
1313
1723
|
editor.finalizationNote ? element('p', {}, editor.finalizationNote) : null,
|
|
1314
1724
|
editor.finalizedAt ? element('small', {}, `结束于 ${formatDate(editor.finalizedAt)}`) : null,
|
|
1315
1725
|
) : null,
|
|
1316
|
-
element('article', { class: `note-paper is-${
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
element('span', {}, editor.isNew ? '尚未保存' : `更新于 ${formatDate(editor.updatedAt)}`),
|
|
1320
|
-
element('span', {}, editor.scope.kind === 'global' ? '全局知识' : `项目 · ${editor.scope.id}`)),
|
|
1321
|
-
mode === 'edit' ? body : renderMarkdownPreview(editor.body),
|
|
1726
|
+
element('article', { class: `note-paper is-${editable ? 'edit' : 'preview'}`, 'data-document-mode': editable ? 'edit' : 'preview' },
|
|
1727
|
+
editable ? title : element('h1', { class: 'note-preview-title' }, editor.title || '无标题文档'),
|
|
1728
|
+
body,
|
|
1322
1729
|
),
|
|
1323
1730
|
),
|
|
1731
|
+
renderDocumentReferenceBar(workspace, editor, editable),
|
|
1324
1732
|
element('footer', { class: 'note-inspector' },
|
|
1325
1733
|
finalized || readOnly ? element('span', { class: 'note-format-hint' }, readOnly ? '知识库已归档 · 只读' : '只读封存 · 重新打开后才能编辑') : element('label', {}, element('span', {}, '类型'), element('select', {
|
|
1326
1734
|
class: 'note-meta-select', onChange: event => update('type', event.target.value),
|
|
@@ -1328,16 +1736,109 @@ function renderNoteEditor(workspace, editor, base) {
|
|
|
1328
1736
|
finalized || readOnly ? null : element('label', { class: 'note-tags-field' }, element('span', {}, '标签'), element('input', {
|
|
1329
1737
|
value: editor.tagsText, placeholder: '用逗号分隔', onInput: event => update('tagsText', event.target.value),
|
|
1330
1738
|
})),
|
|
1331
|
-
|
|
1739
|
+
editable ? element('span', { class: 'note-format-hint' }, 'Markdown · Ctrl/⌘ S 保存') : null,
|
|
1332
1740
|
),
|
|
1333
1741
|
)
|
|
1334
1742
|
}
|
|
1335
1743
|
|
|
1336
|
-
function
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1744
|
+
function renderDocumentEditorLoading(base) {
|
|
1745
|
+
return element('main', { class: 'note-editor note-editor-loading', role: 'status', 'aria-label': '正在打开知识文档' },
|
|
1746
|
+
element('header', { class: 'note-editor-toolbar' },
|
|
1747
|
+
element('div', { class: 'note-breadcrumb' }, element('span', {}, base?.name || '知识库'), element('span', { 'aria-hidden': 'true' }, '/'), element('strong', {}, '正在打开文档')),
|
|
1748
|
+
element('span', { class: 'editor-load-label' }, '按需加载正文与关联笔记'),
|
|
1749
|
+
),
|
|
1750
|
+
element('div', { class: 'note-editor-scroll' }, element('article', { class: 'note-paper editor-paper-skeleton', 'aria-hidden': 'true' },
|
|
1751
|
+
element('div', { class: 'skeleton-line skeleton-paper-title' }),
|
|
1752
|
+
...Array.from({ length: 9 }, (_, index) => element('div', { class: `skeleton-line skeleton-paper-row is-${index % 4}` })),
|
|
1753
|
+
)),
|
|
1754
|
+
)
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
function renderDocumentReferenceBar(workspace, editor, editable) {
|
|
1758
|
+
const references = editor.noteReferences || []
|
|
1759
|
+
return element('section', { class: 'document-reference-bar', 'aria-label': '关联笔记' },
|
|
1760
|
+
element('div', { class: 'document-reference-heading' },
|
|
1761
|
+
element('span', { class: 'document-reference-mark', 'aria-hidden': 'true' }),
|
|
1762
|
+
element('strong', {}, '关联笔记'),
|
|
1763
|
+
element('small', {}, references.length ? `${references.length} 项` : '未关联')),
|
|
1764
|
+
element('div', { class: 'document-reference-list' }, references.length
|
|
1765
|
+
? references.map(reference => element('span', { class: 'document-reference-chip' },
|
|
1766
|
+
element('button', {
|
|
1767
|
+
type: 'button', class: 'document-reference-open', title: `打开 ${reference.note.name}`,
|
|
1768
|
+
onClick: () => { void openNoteReference(reference.note.id) },
|
|
1769
|
+
}, renderNoteIcon(reference.note), element('span', {}, reference.note.name)),
|
|
1770
|
+
editable ? element('button', {
|
|
1771
|
+
type: 'button', class: 'document-reference-remove', 'aria-label': `移除 ${reference.note.name} 的关联`, title: '移除关联',
|
|
1772
|
+
onClick: () => { void removeDocumentNoteReference(workspace, editor, reference) },
|
|
1773
|
+
}, '×') : null,
|
|
1774
|
+
))
|
|
1775
|
+
: element('span', { class: 'document-reference-empty' }, editor.isNew ? '保存文档后可以关联笔记或资料文件' : '这篇知识还没有关联笔记')),
|
|
1776
|
+
editable && !editor.isNew ? actionButton('+ 添加', () => { void openDocumentNoteReferencePicker(workspace, editor) }, 'ghost small document-reference-add') : null,
|
|
1777
|
+
)
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
async function openDocumentNoteReferencePicker(workspace, editor) {
|
|
1781
|
+
const search = element('input', {
|
|
1782
|
+
class: 'input', type: 'search', placeholder: '搜索笔记文档或文件', 'aria-label': '搜索可关联的笔记',
|
|
1783
|
+
})
|
|
1784
|
+
const results = element('div', { class: 'note-picker-results', 'aria-live': 'polite' })
|
|
1785
|
+
let modal
|
|
1786
|
+
let request = 0
|
|
1787
|
+
const paint = async () => {
|
|
1788
|
+
const current = ++request
|
|
1789
|
+
const query = search.value.trim()
|
|
1790
|
+
if (!query) {
|
|
1791
|
+
results.replaceChildren(element('div', { class: 'note-picker-empty' }, '输入名称以搜索可关联的笔记文档和文件。'))
|
|
1792
|
+
return
|
|
1793
|
+
}
|
|
1794
|
+
results.replaceChildren(element('div', { class: 'note-picker-empty' }, '正在搜索…'))
|
|
1795
|
+
try {
|
|
1796
|
+
const linked = new Set((editor.noteReferences || []).map(reference => reference.note.id))
|
|
1797
|
+
const visible = (await api(`notes?q=${encodeURIComponent(query)}&limit=100`)).filter(node => node.kind !== 'folder' && !linked.has(node.id))
|
|
1798
|
+
if (current !== request) return
|
|
1799
|
+
results.replaceChildren(...(visible.length
|
|
1800
|
+
? visible.map(node => element('button', {
|
|
1801
|
+
type: 'button', class: 'note-picker-row',
|
|
1802
|
+
onClick: async event => {
|
|
1803
|
+
event.currentTarget.disabled = true
|
|
1804
|
+
try {
|
|
1805
|
+
const reference = await api(`entries/${encodeURIComponent(editor.id)}/note-references`, { method: 'POST', body: { noteId: node.id } })
|
|
1806
|
+
editor.noteReferences = [...(editor.noteReferences || []), reference]
|
|
1807
|
+
modal.close(true)
|
|
1808
|
+
renderShell()
|
|
1809
|
+
showToast('已关联笔记。')
|
|
1810
|
+
} catch (error) {
|
|
1811
|
+
event.currentTarget.disabled = false
|
|
1812
|
+
showToast(friendlyError(error), 'error')
|
|
1813
|
+
}
|
|
1814
|
+
},
|
|
1815
|
+
},
|
|
1816
|
+
renderNoteIcon(node),
|
|
1817
|
+
element('span', {}, element('strong', {}, node.name), element('small', {}, `${node.kind === 'document' ? '笔记文档' : formatBytes(node.size)} ${shortNoteId(node.id)}`))))
|
|
1818
|
+
: [element('div', { class: 'note-picker-empty' }, '没有匹配的笔记文档或文件。')]))
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
if (current !== request) return
|
|
1821
|
+
results.replaceChildren(element('div', { class: 'note-picker-empty is-error' }, friendlyError(error)))
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
let timer = 0
|
|
1825
|
+
search.addEventListener('input', () => { window.clearTimeout(timer); timer = window.setTimeout(() => void paint(), 180) })
|
|
1826
|
+
modal = openSheet({
|
|
1827
|
+
title: '添加关联笔记',
|
|
1828
|
+
description: '关联保存在文档正文之外;笔记移动或改名后仍然有效。',
|
|
1829
|
+
body: element('div', { class: 'note-picker' }, search, results),
|
|
1830
|
+
cancelLabel: '取消',
|
|
1831
|
+
})
|
|
1832
|
+
search.focus()
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
async function removeDocumentNoteReference(workspace, editor, reference) {
|
|
1836
|
+
try {
|
|
1837
|
+
await api(`entries/${encodeURIComponent(editor.id)}/note-references/${encodeURIComponent(reference.note.id)}`, { method: 'DELETE' })
|
|
1838
|
+
editor.noteReferences = (editor.noteReferences || []).filter(item => item.note.id !== reference.note.id)
|
|
1839
|
+
if (workspace.view.editor === editor) renderShell()
|
|
1840
|
+
showToast('已移除关联。')
|
|
1841
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
1341
1842
|
}
|
|
1342
1843
|
|
|
1343
1844
|
function renderMarkdownPreview(markdown) {
|
|
@@ -1352,12 +1853,30 @@ function renderMarkdownPreview(markdown) {
|
|
|
1352
1853
|
table.replaceWith(scroller)
|
|
1353
1854
|
scroller.append(table)
|
|
1354
1855
|
})
|
|
1856
|
+
preview.querySelectorAll('[data-note-id]').forEach(link => {
|
|
1857
|
+
link.setAttribute('role', 'button')
|
|
1858
|
+
link.setAttribute('tabindex', '0')
|
|
1859
|
+
link.setAttribute('title', '打开笔记文档')
|
|
1860
|
+
const open = event => {
|
|
1861
|
+
if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') return
|
|
1862
|
+
event.preventDefault()
|
|
1863
|
+
void openNoteReference(link.dataset.noteId)
|
|
1864
|
+
}
|
|
1865
|
+
link.addEventListener('click', open)
|
|
1866
|
+
link.addEventListener('keydown', open)
|
|
1867
|
+
})
|
|
1355
1868
|
return preview
|
|
1356
1869
|
}
|
|
1357
1870
|
|
|
1358
|
-
function
|
|
1359
|
-
|
|
1360
|
-
|
|
1871
|
+
async function openNoteReference(id) {
|
|
1872
|
+
try {
|
|
1873
|
+
if (!await saveBeforeLeavingDocument()) return
|
|
1874
|
+
const node = await api(`notes/${encodeURIComponent(id)}`)
|
|
1875
|
+
state.view = 'notes'
|
|
1876
|
+
state.loading = false
|
|
1877
|
+
await selectNoteNode(node)
|
|
1878
|
+
renderShell()
|
|
1879
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
1361
1880
|
}
|
|
1362
1881
|
|
|
1363
1882
|
function openFinalizeDocument(workspace, editor) {
|
|
@@ -1427,6 +1946,1068 @@ function confirmDeleteDocument(workspace, editor) {
|
|
|
1427
1946
|
})
|
|
1428
1947
|
}
|
|
1429
1948
|
|
|
1949
|
+
function renderNotes() {
|
|
1950
|
+
const fileInput = element('input', {
|
|
1951
|
+
class: 'visually-hidden', type: 'file', multiple: true, tabindex: '-1',
|
|
1952
|
+
onChange: event => { void uploadNoteFiles([...event.target.files], state.notes.currentFolderId); event.target.value = '' },
|
|
1953
|
+
})
|
|
1954
|
+
const rootNodes = state.notes.children.get('root') || []
|
|
1955
|
+
const tree = state.notes.query.trim()
|
|
1956
|
+
? renderNoteSearchResults()
|
|
1957
|
+
: renderNoteTreeBranch(null)
|
|
1958
|
+
const workspace = element('section', {
|
|
1959
|
+
class: 'notes-workspace', 'data-drop-active': 'false',
|
|
1960
|
+
onDragEnter: noteWorkspaceDragEnter, onDragOver: noteWorkspaceDragEnter,
|
|
1961
|
+
onDragLeave: noteWorkspaceDragLeave,
|
|
1962
|
+
onDrop: event => {
|
|
1963
|
+
clearNoteDragState()
|
|
1964
|
+
if (!hasDragType(event, 'Files')) return
|
|
1965
|
+
event.preventDefault()
|
|
1966
|
+
void importDroppedNotes(event.dataTransfer, state.notes.currentFolderId)
|
|
1967
|
+
},
|
|
1968
|
+
},
|
|
1969
|
+
element('aside', { class: 'notes-browser', 'aria-label': '笔记目录' },
|
|
1970
|
+
element('header', { class: 'notes-browser-header' },
|
|
1971
|
+
element('div', {}, element('h2', {}, '目录'), element('span', {}, `${rootNodes.length} 个根目录项目`)),
|
|
1972
|
+
element('div', { class: 'notes-browser-actions' },
|
|
1973
|
+
actionButton('新建', () => openCreateNoteDocument(), 'primary small'),
|
|
1974
|
+
actionButton('目录', () => openCreateNoteFolder(), 'small'),
|
|
1975
|
+
actionButton('导入', () => fileInput.click(), 'ghost small'),
|
|
1976
|
+
fileInput,
|
|
1977
|
+
),
|
|
1978
|
+
),
|
|
1979
|
+
element('div', { class: 'notes-search' }, element('input', {
|
|
1980
|
+
class: 'input', type: 'search', value: state.notes.query, placeholder: '搜索笔记文档', 'aria-label': '搜索笔记文档',
|
|
1981
|
+
onInput: event => scheduleNoteSearch(event.target.value),
|
|
1982
|
+
})),
|
|
1983
|
+
element('div', {
|
|
1984
|
+
class: 'notes-tree', role: 'tree', 'data-scroll-key': 'notes-tree',
|
|
1985
|
+
onDragOver: event => {
|
|
1986
|
+
if (hasDragType(event, 'application/x-dsh-note-id') || hasDragType(event, 'Files')) event.preventDefault()
|
|
1987
|
+
},
|
|
1988
|
+
onDrop: event => { if (event.target === event.currentTarget) void dropNoteNode(event, null) },
|
|
1989
|
+
}, tree),
|
|
1990
|
+
),
|
|
1991
|
+
renderNoteContent(),
|
|
1992
|
+
element('div', { class: 'notes-drop-overlay', 'aria-hidden': 'true' },
|
|
1993
|
+
element('strong', {}, '放开以导入当前目录'),
|
|
1994
|
+
element('span', {}, '支持文件和完整目录,原有目录层级会被保留。')),
|
|
1995
|
+
renderNoteTransfer(),
|
|
1996
|
+
)
|
|
1997
|
+
return element('section', { class: 'notes-page', 'aria-label': '笔记工作区' }, workspace)
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
function renderNoteTransfer() {
|
|
2001
|
+
const transfer = state.notes.transfer
|
|
2002
|
+
if (!transfer) return null
|
|
2003
|
+
const dismissible = transfer.phase === 'complete' || transfer.phase === 'error'
|
|
2004
|
+
return element('section', {
|
|
2005
|
+
class: 'notes-transfer', 'data-note-transfer': transfer.id, 'data-state': transfer.phase,
|
|
2006
|
+
role: transfer.phase === 'error' ? 'alert' : 'status', 'aria-live': 'polite',
|
|
2007
|
+
},
|
|
2008
|
+
element('div', { class: 'notes-transfer-heading' },
|
|
2009
|
+
element('span', { class: 'notes-transfer-mark', 'aria-hidden': 'true' }),
|
|
2010
|
+
element('strong', { class: 'notes-transfer-title' }, noteTransferTitle(transfer)),
|
|
2011
|
+
dismissible ? actionButton('关闭', dismissNoteTransfer, 'ghost tiny notes-transfer-close', { 'aria-label': '关闭导入进度' }) : null,
|
|
2012
|
+
),
|
|
2013
|
+
element('div', { class: 'notes-transfer-detail', title: transfer.currentName || '' }, noteTransferDetail(transfer)),
|
|
2014
|
+
element('div', {
|
|
2015
|
+
class: 'notes-transfer-track', role: 'progressbar',
|
|
2016
|
+
'aria-label': '笔记导入进度', 'aria-valuemin': '0', 'aria-valuemax': '100',
|
|
2017
|
+
'aria-valuenow': transfer.phase === 'scanning' ? undefined : String(noteTransferPercent(transfer)),
|
|
2018
|
+
}, element('span', { class: 'notes-transfer-fill' })),
|
|
2019
|
+
element('div', { class: 'notes-transfer-meta' },
|
|
2020
|
+
element('span', { class: 'notes-transfer-count' }, noteTransferCount(transfer)),
|
|
2021
|
+
element('span', { class: 'notes-transfer-percent' }, transfer.phase === 'scanning' ? '正在整理' : `${noteTransferPercent(transfer)}%`),
|
|
2022
|
+
),
|
|
2023
|
+
)
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function renderNoteTreeBranch(parentId, depth = 0) {
|
|
2027
|
+
const key = parentId || 'root'
|
|
2028
|
+
const nodes = state.notes.children.get(key) || []
|
|
2029
|
+
if (!nodes.length && depth === 0) {
|
|
2030
|
+
return element('div', { class: 'notes-tree-empty' }, element('strong', {}, '还没有笔记'), element('span', {}, '新建目录或文档开始整理。'))
|
|
2031
|
+
}
|
|
2032
|
+
return element('div', { class: 'notes-tree-branch', role: depth ? 'group' : undefined }, nodes.map(node => {
|
|
2033
|
+
const selected = state.notes.selectedId === node.id
|
|
2034
|
+
const expanded = node.kind === 'folder' && state.notes.expandedFolders.has(node.id)
|
|
2035
|
+
const row = element('div', {
|
|
2036
|
+
class: 'notes-tree-item', 'data-kind': node.kind, 'data-selected': String(selected),
|
|
2037
|
+
'data-note-id': node.id,
|
|
2038
|
+
draggable: 'true',
|
|
2039
|
+
onDragStart: event => {
|
|
2040
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
2041
|
+
event.dataTransfer.setData('application/x-dsh-note-id', node.id)
|
|
2042
|
+
event.currentTarget.dataset.dragging = 'true'
|
|
2043
|
+
},
|
|
2044
|
+
onDragEnd: () => clearNoteDragState(),
|
|
2045
|
+
onDragEnter: event => activateNoteFolderDropTarget(event, node),
|
|
2046
|
+
onDragOver: event => activateNoteFolderDropTarget(event, node),
|
|
2047
|
+
onDragLeave: event => { if (!event.currentTarget.contains(event.relatedTarget)) event.currentTarget.dataset.dropTarget = 'false' },
|
|
2048
|
+
onDrop: event => { if (node.kind === 'folder') void dropNoteNode(event, node.id) },
|
|
2049
|
+
},
|
|
2050
|
+
element('button', {
|
|
2051
|
+
type: 'button', class: 'notes-tree-row', role: 'treeitem',
|
|
2052
|
+
'aria-selected': String(selected), 'aria-expanded': node.kind === 'folder' ? String(expanded) : undefined,
|
|
2053
|
+
onClick: () => { void selectNoteNode(node, { toggleFolder: node.kind === 'folder' }) },
|
|
2054
|
+
},
|
|
2055
|
+
node.kind === 'folder' ? element('span', { class: 'notes-tree-chevron', 'aria-hidden': 'true' }) : element('span', { class: 'notes-tree-spacer' }),
|
|
2056
|
+
renderNoteIcon(node),
|
|
2057
|
+
element('span', { class: 'notes-tree-name', title: node.name }, node.name),
|
|
2058
|
+
),
|
|
2059
|
+
element('div', { class: 'notes-row-actions' },
|
|
2060
|
+
actionButton('重命名', event => { event.stopPropagation(); openRenameNoteNode(node) }, 'ghost tiny'),
|
|
2061
|
+
actionButton('复制', event => { event.stopPropagation(); void copyNoteNode(node) }, 'ghost tiny'),
|
|
2062
|
+
actionButton('删除', event => { event.stopPropagation(); void confirmDeleteNoteNode(node) }, 'ghost tiny danger-text'),
|
|
2063
|
+
),
|
|
2064
|
+
)
|
|
2065
|
+
const children = expanded
|
|
2066
|
+
? state.notes.loadedFolders.has(node.id)
|
|
2067
|
+
? renderNoteTreeBranch(node.id, depth + 1)
|
|
2068
|
+
: element('div', { class: 'notes-tree-loading', role: 'status' }, '正在读取…')
|
|
2069
|
+
: null
|
|
2070
|
+
return element('div', { class: 'notes-tree-node' }, row, children)
|
|
2071
|
+
}))
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
function renderNoteSearchResults() {
|
|
2075
|
+
if (!state.notes.searchResults.length) return element('div', { class: 'notes-tree-empty' }, '没有匹配的笔记文档。')
|
|
2076
|
+
return element('div', { class: 'notes-search-results' }, state.notes.searchResults.map(node => element('button', {
|
|
2077
|
+
type: 'button', class: 'notes-search-row', onClick: () => { void selectNoteNode(node) },
|
|
2078
|
+
}, renderNoteIcon(node), element('span', {}, element('strong', {}, node.name), element('small', {}, noteKindLabel(node))))))
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
function renderNoteContent() {
|
|
2082
|
+
const node = state.notes.selectedNode
|
|
2083
|
+
if (!node) return renderNoteFolderContent(null)
|
|
2084
|
+
if (state.notes.loadingNodeId === node.id) return renderNoteSelectionLoading(node)
|
|
2085
|
+
if (node.kind === 'folder') return renderNoteFolderContent(node)
|
|
2086
|
+
if (node.kind === 'document') return renderNoteDocument(node)
|
|
2087
|
+
return renderNoteFile(node)
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function renderNoteSelectionLoading(node) {
|
|
2091
|
+
return element('main', { class: `notes-content notes-selection-loading is-${node.kind}`, role: 'status', 'aria-label': `正在打开 ${node.name}` },
|
|
2092
|
+
element('header', { class: 'notes-content-header' },
|
|
2093
|
+
element('div', { class: 'notes-breadcrumb' }, element('span', {}, '笔记文档'), element('span', { 'aria-hidden': 'true' }, '/'), element('strong', {}, node.name)),
|
|
2094
|
+
element('div', { class: 'notes-content-title' }, element('div', {}, element('h2', {}, node.name), element('p', {}, node.kind === 'folder' ? '正在读取目录内容' : '正在按需读取文档内容'))),
|
|
2095
|
+
),
|
|
2096
|
+
element('div', { class: 'notes-selection-skeleton', 'aria-hidden': 'true' },
|
|
2097
|
+
element('div', { class: 'skeleton-line skeleton-paper-title' }),
|
|
2098
|
+
...Array.from({ length: node.kind === 'folder' ? 6 : 9 }, (_, index) => element('div', { class: `skeleton-line skeleton-paper-row is-${index % 4}` })),
|
|
2099
|
+
),
|
|
2100
|
+
)
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function renderNoteFolderContent(folder) {
|
|
2104
|
+
const parentId = folder?.id || null
|
|
2105
|
+
const children = state.notes.children.get(parentId || 'root') || []
|
|
2106
|
+
return element('main', {
|
|
2107
|
+
class: 'notes-content is-folder',
|
|
2108
|
+
onDragOver: event => event.preventDefault(),
|
|
2109
|
+
onDrop: event => void dropNoteNode(event, parentId),
|
|
2110
|
+
},
|
|
2111
|
+
renderNoteContentHeader(folder),
|
|
2112
|
+
children.length
|
|
2113
|
+
? element('div', { class: 'notes-file-list', role: 'list' }, children.map(node => element('div', {
|
|
2114
|
+
class: 'notes-file-row', role: 'listitem', draggable: 'true',
|
|
2115
|
+
onDragStart: event => { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('application/x-dsh-note-id', node.id) },
|
|
2116
|
+
onDblClick: () => { void selectNoteNode(node, { toggleFolder: node.kind === 'folder' }) },
|
|
2117
|
+
},
|
|
2118
|
+
element('button', { type: 'button', class: 'notes-file-main', onClick: () => { void selectNoteNode(node) } },
|
|
2119
|
+
renderNoteIcon(node, true),
|
|
2120
|
+
element('span', {}, element('strong', {}, node.name), element('small', {}, noteKindLabel(node))),
|
|
2121
|
+
),
|
|
2122
|
+
element('time', { datetime: node.updatedAt }, formatDate(node.updatedAt)),
|
|
2123
|
+
element('span', { class: 'notes-file-size' }, node.kind === 'folder' ? '' : formatBytes(node.size)),
|
|
2124
|
+
element('div', { class: 'notes-file-actions' },
|
|
2125
|
+
actionButton('重命名', () => openRenameNoteNode(node), 'ghost tiny'),
|
|
2126
|
+
actionButton('复制', () => { void copyNoteNode(node) }, 'ghost tiny'),
|
|
2127
|
+
actionButton('删除', () => { void confirmDeleteNoteNode(node) }, 'ghost tiny danger-text'),
|
|
2128
|
+
),
|
|
2129
|
+
)))
|
|
2130
|
+
: element('div', { class: 'notes-folder-empty' },
|
|
2131
|
+
element('span', { class: 'notes-empty-folder-mark', 'aria-hidden': 'true' }),
|
|
2132
|
+
element('h3', {}, '这个目录还是空的'),
|
|
2133
|
+
element('p', {}, '可以新建笔记文档、建立子目录,或把本地文件拖到这里。'),
|
|
2134
|
+
element('div', {}, actionButton('新建文档', () => openCreateNoteDocument(parentId), 'primary small'), actionButton('新建目录', () => openCreateNoteFolder(parentId), 'small')),
|
|
2135
|
+
),
|
|
2136
|
+
)
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
function renderNoteContentHeader(folder) {
|
|
2140
|
+
const path = folder ? [...state.notes.breadcrumbs] : []
|
|
2141
|
+
return element('header', { class: 'notes-content-header' },
|
|
2142
|
+
element('nav', { class: 'notes-breadcrumb', 'aria-label': '当前位置' },
|
|
2143
|
+
element('button', { type: 'button', onClick: () => { void openNoteRoot() } }, '笔记文档'),
|
|
2144
|
+
path.map(node => element('span', {}, element('span', { 'aria-hidden': 'true' }, '/'), element('button', { type: 'button', onClick: () => { void selectNoteNode(node) } }, node.name))),
|
|
2145
|
+
),
|
|
2146
|
+
element('div', { class: 'notes-content-title' },
|
|
2147
|
+
element('div', {}, element('h2', {}, folder?.name || '全部笔记'), element('p', {}, folder ? `${(state.notes.children.get(folder.id) || []).length} 个项目` : '你的独立笔记与资料目录')),
|
|
2148
|
+
element('div', { class: 'notes-content-actions' },
|
|
2149
|
+
actionButton('新建文档', () => openCreateNoteDocument(folder?.id || null), 'primary small'),
|
|
2150
|
+
actionButton('新建目录', () => openCreateNoteFolder(folder?.id || null), 'small'),
|
|
2151
|
+
),
|
|
2152
|
+
),
|
|
2153
|
+
)
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
function renderNoteDocument(node) {
|
|
2157
|
+
return renderEditableNote(node)
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
function renderEditableNote(node) {
|
|
2161
|
+
const markdown = isMarkdownNote(node)
|
|
2162
|
+
const title = editableNoteTitle(node)
|
|
2163
|
+
const editor = markdown
|
|
2164
|
+
? element('div', { class: 'notes-live-editor', role: 'status', 'aria-label': `正在打开 ${node.name}` },
|
|
2165
|
+
element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
|
|
2166
|
+
: createPlainTextNoteEditor(node)
|
|
2167
|
+
if (markdown) mountMarkdownNoteEditor(editor, node)
|
|
2168
|
+
else mountPlainTextNoteEditor(editor, node)
|
|
2169
|
+
return element('main', { class: `notes-content is-document${markdown ? '' : ' has-line-numbers'}` },
|
|
2170
|
+
renderNoteFileToolbar(node, { editable: true }),
|
|
2171
|
+
element('h1', {
|
|
2172
|
+
class: 'notes-document-title', contenteditable: 'plaintext-only', spellcheck: 'false',
|
|
2173
|
+
'aria-label': `修改 ${node.name} 的标题`, title: '点击修改标题',
|
|
2174
|
+
onBlur: event => { void saveEditableNoteTitle(event.currentTarget, node) },
|
|
2175
|
+
onKeyDown: event => {
|
|
2176
|
+
if (event.key === 'Enter') { event.preventDefault(); event.currentTarget.blur() }
|
|
2177
|
+
if (event.key === 'Escape') { event.preventDefault(); event.currentTarget.textContent = title; event.currentTarget.blur() }
|
|
2178
|
+
},
|
|
2179
|
+
}, title),
|
|
2180
|
+
editor,
|
|
2181
|
+
)
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
function editableNoteTitle(node) {
|
|
2185
|
+
return node.kind === 'document' ? node.name.replace(/\.md$/i, '') : node.name
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
async function saveEditableNoteTitle(editor, node) {
|
|
2189
|
+
const title = readPlainTextEditor(editor).replace(/\s+/g, ' ').trim()
|
|
2190
|
+
const name = node.kind === 'document' && title ? `${title.replace(/\.md$/i, '')}.md` : title
|
|
2191
|
+
if (!name) {
|
|
2192
|
+
editor.textContent = editableNoteTitle(node)
|
|
2193
|
+
showToast('标题不能为空。', 'error')
|
|
2194
|
+
return
|
|
2195
|
+
}
|
|
2196
|
+
if (name === node.name) {
|
|
2197
|
+
editor.textContent = editableNoteTitle(node)
|
|
2198
|
+
return
|
|
2199
|
+
}
|
|
2200
|
+
editor.setAttribute('contenteditable', 'false')
|
|
2201
|
+
try {
|
|
2202
|
+
const updated = await api(`notes/${encodeURIComponent(node.id)}`, { method: 'PATCH', body: { name } })
|
|
2203
|
+
if (state.notes.selectedNode?.id === node.id) state.notes.selectedNode = updated
|
|
2204
|
+
editor.textContent = editableNoteTitle(updated)
|
|
2205
|
+
const breadcrumb = document.querySelector('.notes-document-toolbar .notes-breadcrumb strong')
|
|
2206
|
+
if (breadcrumb) breadcrumb.textContent = updated.name
|
|
2207
|
+
await loadNoteChildren(updated.parentId, true).catch(() => {})
|
|
2208
|
+
const treeName = document.querySelector(`.notes-tree-item[data-note-id="${updated.id}"] .notes-tree-name`)
|
|
2209
|
+
if (treeName) treeName.textContent = updated.name
|
|
2210
|
+
showToast('标题已更新。')
|
|
2211
|
+
} catch (error) {
|
|
2212
|
+
editor.textContent = editableNoteTitle(node)
|
|
2213
|
+
showToast(friendlyError(error), 'error')
|
|
2214
|
+
} finally {
|
|
2215
|
+
if (editor.isConnected) editor.setAttribute('contenteditable', 'plaintext-only')
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
function createPlainTextNoteEditor(node) {
|
|
2220
|
+
return element('div', { class: 'notes-plain-editor', role: 'status', 'aria-label': `正在打开 ${node.name}` },
|
|
2221
|
+
element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
function readPlainTextEditor(editor) {
|
|
2225
|
+
return editor.innerText.replace(/\r\n?/g, '\n')
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
function updateNoteDraft(value) {
|
|
2229
|
+
state.notes.draft = value
|
|
2230
|
+
state.notes.dirty = state.notes.draft !== state.notes.content
|
|
2231
|
+
syncNoteEditorChrome()
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
function mountMarkdownNoteEditor(host, node) {
|
|
2235
|
+
mountMarkdownEditor(host, {
|
|
2236
|
+
markdown: state.notes.draft,
|
|
2237
|
+
label: `编辑 ${node.name}`,
|
|
2238
|
+
isCurrent: () => state.notes.selectedNode?.id === node.id,
|
|
2239
|
+
onChange: updateNoteDraft,
|
|
2240
|
+
onSave: () => { void saveNoteDocument() },
|
|
2241
|
+
})
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
function mountPlainTextNoteEditor(host, node) {
|
|
2245
|
+
const request = ++plainTextEditorMountRequest
|
|
2246
|
+
window.requestAnimationFrame(async () => {
|
|
2247
|
+
if (!host.isConnected || state.notes.selectedNode?.id !== node.id || request !== plainTextEditorMountRequest) return
|
|
2248
|
+
try {
|
|
2249
|
+
const runtime = await loadMarkdownNoteEditor()
|
|
2250
|
+
if (!host.isConnected || state.notes.selectedNode?.id !== node.id || request !== plainTextEditorMountRequest) return
|
|
2251
|
+
host.replaceChildren()
|
|
2252
|
+
host.removeAttribute('role')
|
|
2253
|
+
plainTextEditorHandle = runtime.createPlainTextEditor({
|
|
2254
|
+
host,
|
|
2255
|
+
text: state.notes.draft,
|
|
2256
|
+
label: `编辑 ${node.name}`,
|
|
2257
|
+
onChange: updateNoteDraft,
|
|
2258
|
+
onSave: () => { void saveNoteDocument() },
|
|
2259
|
+
})
|
|
2260
|
+
} catch (error) {
|
|
2261
|
+
if (!host.isConnected || request !== plainTextEditorMountRequest) return
|
|
2262
|
+
host.replaceChildren(element('div', { class: 'notes-editor-error', role: 'alert' },
|
|
2263
|
+
element('strong', {}, '无法打开纯文本编辑器'),
|
|
2264
|
+
element('span', {}, friendlyError(error)),
|
|
2265
|
+
actionButton('重试', () => { noteEditorLoader = null; host.replaceChildren(); mountPlainTextNoteEditor(host, node) }, 'small')))
|
|
2266
|
+
}
|
|
2267
|
+
})
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
function mountMarkdownEditor(host, options) {
|
|
2271
|
+
const request = ++markdownEditorMountRequest
|
|
2272
|
+
window.requestAnimationFrame(async () => {
|
|
2273
|
+
if (!host.isConnected || options.isCurrent?.() === false || request !== markdownEditorMountRequest) return
|
|
2274
|
+
try {
|
|
2275
|
+
const runtime = await loadMarkdownNoteEditor()
|
|
2276
|
+
if (!host.isConnected || options.isCurrent?.() === false || request !== markdownEditorMountRequest) return
|
|
2277
|
+
host.replaceChildren()
|
|
2278
|
+
host.removeAttribute('role')
|
|
2279
|
+
markdownEditorHandle = runtime.createMarkdownEditor({
|
|
2280
|
+
host,
|
|
2281
|
+
markdown: options.markdown,
|
|
2282
|
+
label: options.label,
|
|
2283
|
+
onChange: options.onChange,
|
|
2284
|
+
onSave: options.onSave,
|
|
2285
|
+
})
|
|
2286
|
+
} catch (error) {
|
|
2287
|
+
if (!host.isConnected || request !== markdownEditorMountRequest) return
|
|
2288
|
+
host.replaceChildren(element('div', { class: 'notes-editor-error', role: 'alert' },
|
|
2289
|
+
element('strong', {}, '无法打开 Markdown 编辑器'),
|
|
2290
|
+
element('span', {}, friendlyError(error)),
|
|
2291
|
+
actionButton('重试', () => { noteEditorLoader = null; host.replaceChildren(); mountMarkdownEditor(host, options) }, 'small')))
|
|
2292
|
+
}
|
|
2293
|
+
})
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
function loadMarkdownNoteEditor() {
|
|
2297
|
+
if (window.DshKnowledgeNoteEditor?.createMarkdownEditor) return Promise.resolve(window.DshKnowledgeNoteEditor)
|
|
2298
|
+
if (noteEditorLoader) return noteEditorLoader
|
|
2299
|
+
noteEditorLoader = new Promise((resolve, reject) => {
|
|
2300
|
+
const script = document.createElement('script')
|
|
2301
|
+
const suffix = ASSET_VERSION ? `?v=${encodeURIComponent(ASSET_VERSION)}` : ''
|
|
2302
|
+
script.src = `${WEB_PATH.replace(/\/$/, '')}/note-editor.js${suffix}`
|
|
2303
|
+
script.async = true
|
|
2304
|
+
script.onload = () => window.DshKnowledgeNoteEditor?.createMarkdownEditor
|
|
2305
|
+
? resolve(window.DshKnowledgeNoteEditor)
|
|
2306
|
+
: reject(new Error('Markdown 编辑器模块无效'))
|
|
2307
|
+
script.onerror = () => { script.remove(); reject(new Error('Markdown 编辑器加载失败')) }
|
|
2308
|
+
document.head.append(script)
|
|
2309
|
+
}).catch(error => {
|
|
2310
|
+
noteEditorLoader = null
|
|
2311
|
+
throw error
|
|
2312
|
+
})
|
|
2313
|
+
return noteEditorLoader
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
function releaseNoteEditors() {
|
|
2317
|
+
markdownEditorMountRequest += 1
|
|
2318
|
+
plainTextEditorMountRequest += 1
|
|
2319
|
+
markdownEditorHandle?.destroy()
|
|
2320
|
+
plainTextEditorHandle?.destroy()
|
|
2321
|
+
markdownEditorHandle = null
|
|
2322
|
+
plainTextEditorHandle = null
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
function syncNoteEditorChrome() {
|
|
2326
|
+
const status = document.querySelector('[data-note-save-state]')
|
|
2327
|
+
if (status) {
|
|
2328
|
+
status.textContent = state.notes.dirty ? '未保存' : '已保存'
|
|
2329
|
+
status.dataset.dirty = String(state.notes.dirty)
|
|
2330
|
+
}
|
|
2331
|
+
const save = document.querySelector('[data-note-save]')
|
|
2332
|
+
if (save) save.disabled = !state.notes.dirty
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
function renderNoteDocumentBreadcrumb(node) {
|
|
2336
|
+
return element('nav', { class: 'notes-breadcrumb', 'aria-label': '当前位置' },
|
|
2337
|
+
element('button', { type: 'button', onClick: () => { void openNoteRoot() } }, '笔记文档'),
|
|
2338
|
+
state.notes.breadcrumbs.map(parent => element('span', {}, element('span', { 'aria-hidden': 'true' }, '/'), element('button', { type: 'button', onClick: () => { void selectNoteNode(parent) } }, parent.name))),
|
|
2339
|
+
element('span', {}, element('span', { 'aria-hidden': 'true' }, '/'), element('strong', {}, node.name)),
|
|
2340
|
+
)
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
function renderNoteFile(node) {
|
|
2344
|
+
if (node.editable) return renderEditableNote(node)
|
|
2345
|
+
const previewKind = noteFilePreviewKind(node)
|
|
2346
|
+
let preview
|
|
2347
|
+
if (previewKind === 'image' && state.notes.assetUrl) {
|
|
2348
|
+
preview = element('img', { class: 'notes-inline-image', src: state.notes.assetUrl, alt: node.name })
|
|
2349
|
+
} else if (previewKind === 'pdf' && state.notes.assetUrl) {
|
|
2350
|
+
preview = element('iframe', { class: 'notes-inline-frame', src: state.notes.assetUrl, title: node.name })
|
|
2351
|
+
} else {
|
|
2352
|
+
preview = element('div', { class: 'notes-inline-unavailable' },
|
|
2353
|
+
renderNoteIcon(node, true),
|
|
2354
|
+
element('h2', {}, node.name),
|
|
2355
|
+
element('p', {}, '该格式不能在浏览器中直接编辑,可以下载后使用本地应用打开。'),
|
|
2356
|
+
actionButton('下载文件', () => { void downloadNoteFile(node) }, 'primary small'),
|
|
2357
|
+
)
|
|
2358
|
+
}
|
|
2359
|
+
return element('main', { class: 'notes-content is-file' },
|
|
2360
|
+
renderNoteFileToolbar(node),
|
|
2361
|
+
element('section', { class: `notes-inline-viewer is-${previewKind}`, 'aria-label': `${node.name}内容` }, preview),
|
|
2362
|
+
)
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
function renderNoteFileToolbar(node, options = {}) {
|
|
2366
|
+
return element('header', { class: 'notes-document-toolbar' },
|
|
2367
|
+
element('div', { class: 'notes-toolbar-leading' },
|
|
2368
|
+
renderNoteDocumentBreadcrumb(node),
|
|
2369
|
+
element('div', { class: 'notes-file-info', 'aria-label': '文件信息' },
|
|
2370
|
+
noteInfoItem('编号', shortNoteId(node.id), node.id),
|
|
2371
|
+
noteInfoItem('类型', node.kind === 'document' ? 'Markdown' : node.mediaType || '未知'),
|
|
2372
|
+
noteInfoItem('大小', formatBytes(node.size), '', 'size'),
|
|
2373
|
+
noteInfoItem('更新', formatDate(node.updatedAt), '', 'updated'),
|
|
2374
|
+
),
|
|
2375
|
+
),
|
|
2376
|
+
element('div', { class: 'notes-document-actions' },
|
|
2377
|
+
options.editable ? element('span', { class: 'notes-save-state', role: 'status', 'data-note-save-state': '', 'data-dirty': String(state.notes.dirty) }, state.notes.dirty ? '未保存' : '已保存') : null,
|
|
2378
|
+
node.kind === 'file' ? actionButton('下载', () => { void downloadNoteFile(node) }, 'ghost small') : null,
|
|
2379
|
+
actionButton('复制引用', () => { void copyNoteReference(node) }, 'ghost small'),
|
|
2380
|
+
actionButton('重命名', () => openRenameNoteNode(node), 'ghost small'),
|
|
2381
|
+
options.editable ? actionButton('保存', () => { void saveNoteDocument() }, 'primary small', { 'data-note-save': '', disabled: !state.notes.dirty }) : null,
|
|
2382
|
+
),
|
|
2383
|
+
)
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
function noteInfoItem(label, value, title = '', key = '') {
|
|
2387
|
+
return element('span', { class: 'notes-file-info-item', title: title || value, ...(key ? { 'data-note-info': key } : {}) }, element('strong', {}, label), element('span', {}, value))
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
function renderNoteIcon(node, large = false) {
|
|
2391
|
+
const label = node.kind === 'folder' ? '' : noteExtension(node.name)
|
|
2392
|
+
return element('span', { class: `note-node-icon is-${node.kind}${large ? ' is-large' : ''}`, 'data-media-kind': noteMediaKind(node), 'aria-hidden': 'true' }, label)
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
function noteKindLabel(node) {
|
|
2396
|
+
if (node.kind === 'folder') return '目录'
|
|
2397
|
+
if (node.kind === 'document') return 'Markdown 笔记'
|
|
2398
|
+
return `${node.mediaType || '文件'} ${formatBytes(node.size)}`
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
function isMarkdownNote(node) {
|
|
2402
|
+
return node.kind === 'document' || node.mediaType === 'text/markdown' || /\.(md|markdown)$/i.test(node.name)
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
function noteFilePreviewKind(node) {
|
|
2406
|
+
if (node.mediaType?.startsWith('image/')) return 'image'
|
|
2407
|
+
if (node.mediaType === 'application/pdf' || /\.pdf$/i.test(node.name)) return 'pdf'
|
|
2408
|
+
return 'unavailable'
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
async function selectNoteNode(node, options = {}) {
|
|
2412
|
+
if (state.notes.dirty && !await saveNoteDocument()) return
|
|
2413
|
+
const request = ++noteSelectionRequest
|
|
2414
|
+
releaseNoteAsset()
|
|
2415
|
+
state.notes.selectedId = node.id
|
|
2416
|
+
state.notes.selectedNode = node
|
|
2417
|
+
Object.assign(state.notes, { content: '', draft: '', dirty: false })
|
|
2418
|
+
state.notes.loadingNodeId = node.id
|
|
2419
|
+
renderShell()
|
|
2420
|
+
try {
|
|
2421
|
+
const breadcrumbs = await loadNoteBreadcrumbs(node)
|
|
2422
|
+
if (request !== noteSelectionRequest || state.notes.selectedId !== node.id) return
|
|
2423
|
+
state.notes.breadcrumbs = breadcrumbs
|
|
2424
|
+
if (node.kind === 'folder') {
|
|
2425
|
+
Object.assign(state.notes, { content: '', draft: '', dirty: false })
|
|
2426
|
+
state.notes.currentFolderId = node.id
|
|
2427
|
+
if (options.toggleFolder) {
|
|
2428
|
+
if (state.notes.expandedFolders.has(node.id)) state.notes.expandedFolders.delete(node.id)
|
|
2429
|
+
else state.notes.expandedFolders.add(node.id)
|
|
2430
|
+
}
|
|
2431
|
+
await loadNoteChildren(node.id)
|
|
2432
|
+
} else if (node.editable) {
|
|
2433
|
+
const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, { responseType: 'blob', accept: node.mediaType || 'text/plain' })
|
|
2434
|
+
const content = await blob.text()
|
|
2435
|
+
if (request !== noteSelectionRequest || state.notes.selectedId !== node.id) return
|
|
2436
|
+
state.notes.content = content
|
|
2437
|
+
state.notes.draft = content
|
|
2438
|
+
state.notes.dirty = false
|
|
2439
|
+
state.notes.currentFolderId = node.parentId
|
|
2440
|
+
} else {
|
|
2441
|
+
state.notes.content = ''
|
|
2442
|
+
state.notes.draft = ''
|
|
2443
|
+
state.notes.dirty = false
|
|
2444
|
+
state.notes.currentFolderId = node.parentId
|
|
2445
|
+
if (noteFilePreviewKind(node) !== 'unavailable') {
|
|
2446
|
+
const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, { responseType: 'blob', accept: node.mediaType || 'application/octet-stream' })
|
|
2447
|
+
if (request !== noteSelectionRequest || state.notes.selectedId !== node.id) return
|
|
2448
|
+
state.notes.assetUrl = URL.createObjectURL(blob)
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
} catch (error) {
|
|
2452
|
+
if (request === noteSelectionRequest) showToast(friendlyError(error), 'error')
|
|
2453
|
+
} finally {
|
|
2454
|
+
if (request === noteSelectionRequest && state.notes.loadingNodeId === node.id) {
|
|
2455
|
+
state.notes.loadingNodeId = ''
|
|
2456
|
+
renderShell()
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
async function loadNoteChildren(parentId, force = false) {
|
|
2462
|
+
const key = parentId || 'root'
|
|
2463
|
+
if (!force && state.notes.loadedFolders.has(key)) return state.notes.children.get(key) || []
|
|
2464
|
+
const params = new URLSearchParams({ limit: '500' })
|
|
2465
|
+
if (parentId) params.set('parentId', parentId)
|
|
2466
|
+
const nodes = await api(`notes?${params}`)
|
|
2467
|
+
state.notes.children.set(key, nodes)
|
|
2468
|
+
state.notes.loadedFolders.add(key)
|
|
2469
|
+
return nodes
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
async function loadNoteBreadcrumbs(node) {
|
|
2473
|
+
const parents = []
|
|
2474
|
+
let parentId = node.parentId
|
|
2475
|
+
while (parentId) {
|
|
2476
|
+
const parent = findCachedNote(parentId) || await api(`notes/${encodeURIComponent(parentId)}`)
|
|
2477
|
+
parents.unshift(parent)
|
|
2478
|
+
parentId = parent.parentId
|
|
2479
|
+
}
|
|
2480
|
+
return node.kind === 'folder' ? [...parents, node] : parents
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function findCachedNote(id) {
|
|
2484
|
+
for (const nodes of state.notes.children.values()) {
|
|
2485
|
+
const found = nodes.find(node => node.id === id)
|
|
2486
|
+
if (found) return found
|
|
2487
|
+
}
|
|
2488
|
+
return state.notes.selectedNode?.id === id ? state.notes.selectedNode : null
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
async function openNoteRoot() {
|
|
2492
|
+
if (state.notes.dirty && !await saveNoteDocument()) return
|
|
2493
|
+
noteSelectionRequest += 1
|
|
2494
|
+
clearNoteSelection()
|
|
2495
|
+
await loadNoteChildren(null)
|
|
2496
|
+
renderShell()
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
function clearNoteSelection() {
|
|
2500
|
+
releaseNoteAsset()
|
|
2501
|
+
Object.assign(state.notes, { selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [], content: '', draft: '', dirty: false, loadingNodeId: '' })
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
function releaseNoteAsset() {
|
|
2505
|
+
if (!state.notes?.assetUrl) return
|
|
2506
|
+
URL.revokeObjectURL(state.notes.assetUrl)
|
|
2507
|
+
state.notes.assetUrl = ''
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
function scheduleNoteSearch(value) {
|
|
2511
|
+
state.notes.query = value
|
|
2512
|
+
window.clearTimeout(noteSearchTimer)
|
|
2513
|
+
noteSearchTimer = window.setTimeout(async () => {
|
|
2514
|
+
try {
|
|
2515
|
+
state.notes.searchResults = value.trim() ? await api(`notes?q=${encodeURIComponent(value.trim())}&limit=200`) : []
|
|
2516
|
+
renderShell()
|
|
2517
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
2518
|
+
}, 180)
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
function openCreateNoteFolder(parentId = state.notes.currentFolderId) {
|
|
2522
|
+
const field = formField('目录名称', 'text', '', { maxlength: 255, placeholder: '例如:项目资料' })
|
|
2523
|
+
const form = element('form', { class: 'form-grid' }, field.wrapper)
|
|
2524
|
+
openSheet({
|
|
2525
|
+
title: '新建目录', description: '目录可以继续包含子目录、笔记文档和任意文件。', body: form,
|
|
2526
|
+
primaryLabel: '创建目录', onPrimary: async () => {
|
|
2527
|
+
const node = await api('notes/folders', { method: 'POST', body: { name: field.input.value, parentId } })
|
|
2528
|
+
await loadNoteChildren(parentId, true)
|
|
2529
|
+
if (parentId) state.notes.expandedFolders.add(parentId)
|
|
2530
|
+
await selectNoteNode(node)
|
|
2531
|
+
return true
|
|
2532
|
+
},
|
|
2533
|
+
})
|
|
2534
|
+
field.input.focus()
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
function openCreateNoteDocument(parentId = state.notes.currentFolderId) {
|
|
2538
|
+
const field = formField('文档名称', 'text', '', { maxlength: 255, placeholder: '例如:部署记录' })
|
|
2539
|
+
const form = element('form', { class: 'form-grid' }, field.wrapper)
|
|
2540
|
+
openSheet({
|
|
2541
|
+
title: '新建笔记文档', description: '文档使用 Markdown 编辑,默认不会被 AI 检索或回写。', body: form,
|
|
2542
|
+
primaryLabel: '创建并编辑', onPrimary: async () => {
|
|
2543
|
+
const node = await api('notes/documents', { method: 'POST', body: { name: field.input.value, parentId } })
|
|
2544
|
+
await loadNoteChildren(parentId, true)
|
|
2545
|
+
if (parentId) state.notes.expandedFolders.add(parentId)
|
|
2546
|
+
await selectNoteNode(node)
|
|
2547
|
+
return true
|
|
2548
|
+
},
|
|
2549
|
+
})
|
|
2550
|
+
field.input.focus()
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
function openRenameNoteNode(node) {
|
|
2554
|
+
const field = formField('名称', 'text', node.name, { maxlength: 255 })
|
|
2555
|
+
openSheet({
|
|
2556
|
+
title: '重命名', description: '知识文档中的 @ 引用使用稳定编号,不会因改名失效。', body: element('form', { class: 'form-grid' }, field.wrapper),
|
|
2557
|
+
primaryLabel: '保存名称', onPrimary: async () => {
|
|
2558
|
+
const updated = await api(`notes/${encodeURIComponent(node.id)}`, { method: 'PATCH', body: { name: field.input.value } })
|
|
2559
|
+
await loadNoteChildren(node.parentId, true)
|
|
2560
|
+
if (state.notes.selectedId === node.id) await selectNoteNode(updated)
|
|
2561
|
+
else renderShell()
|
|
2562
|
+
showToast('名称已更新。')
|
|
2563
|
+
return true
|
|
2564
|
+
},
|
|
2565
|
+
})
|
|
2566
|
+
field.input.select()
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
async function copyNoteNode(node) {
|
|
2570
|
+
try {
|
|
2571
|
+
const copy = await api(`notes/${encodeURIComponent(node.id)}/copy`, { method: 'POST', body: {} })
|
|
2572
|
+
await loadNoteChildren(node.parentId, true)
|
|
2573
|
+
renderShell()
|
|
2574
|
+
showToast(`已创建“${copy.name}”。`)
|
|
2575
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
async function dropNoteNode(event, parentId) {
|
|
2579
|
+
event.preventDefault()
|
|
2580
|
+
event.stopPropagation()
|
|
2581
|
+
clearNoteDragState()
|
|
2582
|
+
const id = event.dataTransfer.getData('application/x-dsh-note-id')
|
|
2583
|
+
if (!id) {
|
|
2584
|
+
if (hasDragType(event, 'Files')) await importDroppedNotes(event.dataTransfer, parentId)
|
|
2585
|
+
return
|
|
2586
|
+
}
|
|
2587
|
+
const node = findCachedNote(id)
|
|
2588
|
+
if (!node || id === parentId || node.parentId === parentId) return
|
|
2589
|
+
try {
|
|
2590
|
+
await api(`notes/${encodeURIComponent(id)}`, { method: 'PATCH', body: { parentId } })
|
|
2591
|
+
await Promise.all([loadNoteChildren(node.parentId, true), loadNoteChildren(parentId, true)])
|
|
2592
|
+
if (state.notes.selectedId === id) state.notes.selectedNode = await api(`notes/${encodeURIComponent(id)}`)
|
|
2593
|
+
renderShell()
|
|
2594
|
+
showToast('已移动到目标目录。')
|
|
2595
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
async function uploadNoteFiles(files, parentId) {
|
|
2599
|
+
const summary = createNoteImportSummary()
|
|
2600
|
+
const plans = files.flatMap(file => createNoteFilePlan(file, summary))
|
|
2601
|
+
if (!plans.length) return showToast('没有可上传的文件,单文件上限为 64 MiB。', 'error')
|
|
2602
|
+
const transfer = beginNoteTransfer('uploading')
|
|
2603
|
+
if (!transfer) return
|
|
2604
|
+
prepareNoteTransfer(transfer, summary)
|
|
2605
|
+
try {
|
|
2606
|
+
await importNotePlans(plans, parentId, transfer)
|
|
2607
|
+
await loadNoteChildren(parentId, true)
|
|
2608
|
+
renderShell()
|
|
2609
|
+
completeNoteTransfer(transfer)
|
|
2610
|
+
showNoteImportResult(summary)
|
|
2611
|
+
} catch (error) {
|
|
2612
|
+
if (transfer.completedItems) await loadNoteChildren(parentId, true).catch(() => {})
|
|
2613
|
+
renderShell()
|
|
2614
|
+
failNoteTransfer(transfer, error)
|
|
2615
|
+
showToast(friendlyError(error), 'error')
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
async function importDroppedNotes(dataTransfer, parentId) {
|
|
2620
|
+
const payload = captureNoteDropPayload(dataTransfer)
|
|
2621
|
+
if (!payload.entries.length) return uploadNoteFiles(payload.files, parentId)
|
|
2622
|
+
const transfer = beginNoteTransfer('scanning')
|
|
2623
|
+
if (!transfer) return
|
|
2624
|
+
const summary = createNoteImportSummary()
|
|
2625
|
+
try {
|
|
2626
|
+
const plans = []
|
|
2627
|
+
for (const entry of payload.entries) {
|
|
2628
|
+
const plan = await collectNoteEntry(entry, summary, transfer)
|
|
2629
|
+
if (plan) plans.push(plan)
|
|
2630
|
+
}
|
|
2631
|
+
if (!plans.length) {
|
|
2632
|
+
dismissNoteTransfer()
|
|
2633
|
+
showToast('没有可上传的内容,单文件上限为 64 MiB。', 'error')
|
|
2634
|
+
return
|
|
2635
|
+
}
|
|
2636
|
+
prepareNoteTransfer(transfer, summary)
|
|
2637
|
+
await importNotePlans(plans, parentId, transfer)
|
|
2638
|
+
await loadNoteChildren(parentId, true)
|
|
2639
|
+
if (parentId) state.notes.expandedFolders.add(parentId)
|
|
2640
|
+
renderShell()
|
|
2641
|
+
completeNoteTransfer(transfer)
|
|
2642
|
+
showNoteImportResult(summary)
|
|
2643
|
+
} catch (error) {
|
|
2644
|
+
await loadNoteChildren(parentId, true).catch(() => {})
|
|
2645
|
+
renderShell()
|
|
2646
|
+
failNoteTransfer(transfer, error)
|
|
2647
|
+
showToast(friendlyError(error), 'error')
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
function createNoteImportSummary() {
|
|
2652
|
+
return { files: 0, folders: 0, skipped: 0, bytes: 0 }
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
function createNoteFilePlan(file, summary) {
|
|
2656
|
+
if (file.size > NOTE_MAX_FILE_SIZE) {
|
|
2657
|
+
summary.skipped += 1
|
|
2658
|
+
return []
|
|
2659
|
+
}
|
|
2660
|
+
summary.files += 1
|
|
2661
|
+
summary.bytes += file.size
|
|
2662
|
+
return [{ kind: 'file', name: file.name, file }]
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
function captureNoteDropPayload(dataTransfer) {
|
|
2666
|
+
const files = Array.from(dataTransfer?.files || [])
|
|
2667
|
+
const entries = Array.from(dataTransfer?.items || []).flatMap(item => {
|
|
2668
|
+
if (item.kind !== 'file' || typeof item.webkitGetAsEntry !== 'function') return []
|
|
2669
|
+
try {
|
|
2670
|
+
const entry = item.webkitGetAsEntry()
|
|
2671
|
+
return entry ? [entry] : []
|
|
2672
|
+
} catch {
|
|
2673
|
+
return []
|
|
2674
|
+
}
|
|
2675
|
+
})
|
|
2676
|
+
return { entries, files }
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
async function collectNoteEntry(entry, summary, transfer) {
|
|
2680
|
+
transfer.currentName = entry.name || ''
|
|
2681
|
+
transfer.scannedItems += 1
|
|
2682
|
+
scheduleNoteTransferSync()
|
|
2683
|
+
if (entry.isDirectory) {
|
|
2684
|
+
summary.folders += 1
|
|
2685
|
+
const children = []
|
|
2686
|
+
for (const child of await readNoteDirectoryEntries(entry)) {
|
|
2687
|
+
const plan = await collectNoteEntry(child, summary, transfer)
|
|
2688
|
+
if (plan) children.push(plan)
|
|
2689
|
+
}
|
|
2690
|
+
return { kind: 'folder', name: entry.name, children }
|
|
2691
|
+
}
|
|
2692
|
+
if (!entry.isFile) return null
|
|
2693
|
+
const file = await readNoteFileEntry(entry)
|
|
2694
|
+
return createNoteFilePlan(file, summary)[0] || null
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
async function importNotePlans(plans, parentId, transfer) {
|
|
2698
|
+
for (const plan of plans) {
|
|
2699
|
+
transfer.currentName = plan.name
|
|
2700
|
+
scheduleNoteTransferSync()
|
|
2701
|
+
if (plan.kind === 'folder') {
|
|
2702
|
+
const folder = await api('notes/folders', { method: 'POST', body: { name: plan.name, parentId } })
|
|
2703
|
+
transfer.completedItems += 1
|
|
2704
|
+
scheduleNoteTransferSync()
|
|
2705
|
+
await importNotePlans(plan.children, folder.id, transfer)
|
|
2706
|
+
continue
|
|
2707
|
+
}
|
|
2708
|
+
const settledBytes = transfer.loadedBytes
|
|
2709
|
+
await uploadNoteFile(plan.file, parentId, loaded => {
|
|
2710
|
+
transfer.loadedBytes = settledBytes + Math.min(loaded, plan.file.size)
|
|
2711
|
+
scheduleNoteTransferSync()
|
|
2712
|
+
})
|
|
2713
|
+
transfer.loadedBytes = settledBytes + plan.file.size
|
|
2714
|
+
transfer.completedItems += 1
|
|
2715
|
+
scheduleNoteTransferSync()
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
async function readNoteDirectoryEntries(entry) {
|
|
2720
|
+
const reader = entry.createReader()
|
|
2721
|
+
const entries = []
|
|
2722
|
+
while (true) {
|
|
2723
|
+
const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject))
|
|
2724
|
+
if (!batch.length) return entries
|
|
2725
|
+
entries.push(...batch)
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
function readNoteFileEntry(entry) {
|
|
2730
|
+
return new Promise((resolve, reject) => entry.file(resolve, reject))
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
async function uploadNoteFile(file, parentId, onProgress) {
|
|
2734
|
+
const params = new URLSearchParams({ name: file.name })
|
|
2735
|
+
if (parentId) params.set('parentId', parentId)
|
|
2736
|
+
return binaryUploadRequest(`notes/files?${params}`, file, {
|
|
2737
|
+
method: 'POST', contentType: file.type || 'application/octet-stream', onProgress,
|
|
2738
|
+
})
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
function beginNoteTransfer(phase) {
|
|
2742
|
+
if (state.notes.transfer && ['scanning', 'uploading'].includes(state.notes.transfer.phase)) {
|
|
2743
|
+
showToast('已有导入任务正在进行,请稍候。', 'error')
|
|
2744
|
+
return null
|
|
2745
|
+
}
|
|
2746
|
+
const transfer = {
|
|
2747
|
+
id: String(++noteTransferSequence), phase, currentName: '', error: '',
|
|
2748
|
+
scannedItems: 0, completedItems: 0, totalItems: 0,
|
|
2749
|
+
loadedBytes: 0, totalBytes: 0, files: 0, folders: 0, skipped: 0,
|
|
2750
|
+
}
|
|
2751
|
+
state.notes.transfer = transfer
|
|
2752
|
+
refreshNoteTransferPanel()
|
|
2753
|
+
return transfer
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
function prepareNoteTransfer(transfer, summary) {
|
|
2757
|
+
transfer.phase = 'uploading'
|
|
2758
|
+
transfer.currentName = ''
|
|
2759
|
+
transfer.totalItems = summary.files + summary.folders
|
|
2760
|
+
transfer.totalBytes = summary.bytes
|
|
2761
|
+
transfer.files = summary.files
|
|
2762
|
+
transfer.folders = summary.folders
|
|
2763
|
+
transfer.skipped = summary.skipped
|
|
2764
|
+
scheduleNoteTransferSync()
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
function completeNoteTransfer(transfer) {
|
|
2768
|
+
if (state.notes.transfer !== transfer) return
|
|
2769
|
+
transfer.phase = 'complete'
|
|
2770
|
+
transfer.currentName = ''
|
|
2771
|
+
transfer.completedItems = transfer.totalItems
|
|
2772
|
+
transfer.loadedBytes = transfer.totalBytes
|
|
2773
|
+
refreshNoteTransferPanel()
|
|
2774
|
+
window.setTimeout(() => {
|
|
2775
|
+
if (state.notes.transfer === transfer && transfer.phase === 'complete') dismissNoteTransfer()
|
|
2776
|
+
}, 2600)
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
function failNoteTransfer(transfer, error) {
|
|
2780
|
+
if (state.notes.transfer !== transfer) return
|
|
2781
|
+
transfer.phase = 'error'
|
|
2782
|
+
transfer.error = friendlyError(error)
|
|
2783
|
+
refreshNoteTransferPanel()
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
function dismissNoteTransfer() {
|
|
2787
|
+
state.notes.transfer = null
|
|
2788
|
+
document.querySelector('.notes-transfer')?.remove()
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
function refreshNoteTransferPanel() {
|
|
2792
|
+
const current = document.querySelector('.notes-transfer')
|
|
2793
|
+
const panel = renderNoteTransfer()
|
|
2794
|
+
if (!panel) {
|
|
2795
|
+
current?.remove()
|
|
2796
|
+
return
|
|
2797
|
+
}
|
|
2798
|
+
if (current) current.replaceWith(panel)
|
|
2799
|
+
else document.querySelector('.notes-workspace')?.append(panel)
|
|
2800
|
+
syncNoteTransferPanel()
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
function scheduleNoteTransferSync() {
|
|
2804
|
+
if (noteTransferFrame) return
|
|
2805
|
+
noteTransferFrame = requestAnimationFrame(() => {
|
|
2806
|
+
noteTransferFrame = 0
|
|
2807
|
+
syncNoteTransferPanel()
|
|
2808
|
+
})
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
function syncNoteTransferPanel() {
|
|
2812
|
+
const transfer = state.notes.transfer
|
|
2813
|
+
const panel = document.querySelector('.notes-transfer')
|
|
2814
|
+
if (!transfer || !panel || panel.dataset.noteTransfer !== transfer.id) return
|
|
2815
|
+
const percent = noteTransferPercent(transfer)
|
|
2816
|
+
panel.dataset.state = transfer.phase
|
|
2817
|
+
panel.style.setProperty('--notes-transfer-progress', String(percent / 100))
|
|
2818
|
+
const title = panel.querySelector('.notes-transfer-title')
|
|
2819
|
+
const detail = panel.querySelector('.notes-transfer-detail')
|
|
2820
|
+
const count = panel.querySelector('.notes-transfer-count')
|
|
2821
|
+
const percentLabel = panel.querySelector('.notes-transfer-percent')
|
|
2822
|
+
const progress = panel.querySelector('.notes-transfer-track')
|
|
2823
|
+
if (title) title.textContent = noteTransferTitle(transfer)
|
|
2824
|
+
if (detail) {
|
|
2825
|
+
detail.textContent = noteTransferDetail(transfer)
|
|
2826
|
+
detail.title = transfer.currentName || ''
|
|
2827
|
+
}
|
|
2828
|
+
if (count) count.textContent = noteTransferCount(transfer)
|
|
2829
|
+
if (percentLabel) percentLabel.textContent = transfer.phase === 'scanning' ? '正在整理' : `${percent}%`
|
|
2830
|
+
if (progress) {
|
|
2831
|
+
if (transfer.phase === 'scanning') progress.removeAttribute('aria-valuenow')
|
|
2832
|
+
else progress.setAttribute('aria-valuenow', String(percent))
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
function noteTransferPercent(transfer) {
|
|
2837
|
+
if (transfer.phase === 'complete') return 100
|
|
2838
|
+
const value = transfer.totalBytes > 0
|
|
2839
|
+
? transfer.loadedBytes / transfer.totalBytes
|
|
2840
|
+
: transfer.totalItems > 0 ? transfer.completedItems / transfer.totalItems : 0
|
|
2841
|
+
return Math.min(transfer.phase === 'uploading' ? 99 : 100, Math.max(0, Math.round(value * 100)))
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
function noteTransferTitle(transfer) {
|
|
2845
|
+
if (transfer.phase === 'scanning') return '正在整理导入内容'
|
|
2846
|
+
if (transfer.phase === 'complete') return '导入完成'
|
|
2847
|
+
if (transfer.phase === 'error') return '导入未完成'
|
|
2848
|
+
return '正在导入笔记'
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
function noteTransferDetail(transfer) {
|
|
2852
|
+
if (transfer.phase === 'error') return transfer.error || '导入过程中发生错误'
|
|
2853
|
+
if (transfer.phase === 'complete') return `${transfer.files} 个文件、${transfer.folders} 个目录已写入`
|
|
2854
|
+
if (transfer.phase === 'scanning') return transfer.currentName || '正在读取文件和目录…'
|
|
2855
|
+
return transfer.currentName || '准备写入…'
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
function noteTransferCount(transfer) {
|
|
2859
|
+
if (transfer.phase === 'scanning') return `已读取 ${transfer.scannedItems} 项`
|
|
2860
|
+
const count = `${transfer.completedItems} / ${transfer.totalItems} 项`
|
|
2861
|
+
const bytes = transfer.totalBytes ? ` · ${formatBytes(transfer.loadedBytes)} / ${formatBytes(transfer.totalBytes)}` : ''
|
|
2862
|
+
const skipped = transfer.skipped ? ` · 跳过 ${transfer.skipped}` : ''
|
|
2863
|
+
return `${count}${bytes}${skipped}`
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
function showNoteImportResult(summary) {
|
|
2867
|
+
const added = [summary.folders ? `${summary.folders} 个目录` : '', summary.files ? `${summary.files} 个文件` : ''].filter(Boolean).join('、')
|
|
2868
|
+
const skipped = summary.skipped ? `,跳过 ${summary.skipped} 个超过 64 MiB 的文件` : ''
|
|
2869
|
+
showToast(`已添加${added ? ` ${added}` : ''}${skipped}。`)
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
async function saveNoteDocument() {
|
|
2873
|
+
const node = state.notes.selectedNode
|
|
2874
|
+
if (!node || !node.editable || !state.notes.dirty) return true
|
|
2875
|
+
try {
|
|
2876
|
+
const updated = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, {
|
|
2877
|
+
method: 'PUT', body: new Blob([state.notes.draft], { type: node.mediaType || 'text/plain' }), contentType: node.mediaType || 'text/plain',
|
|
2878
|
+
})
|
|
2879
|
+
state.notes.selectedNode = updated
|
|
2880
|
+
state.notes.content = state.notes.draft
|
|
2881
|
+
state.notes.dirty = false
|
|
2882
|
+
await loadNoteChildren(node.parentId, true)
|
|
2883
|
+
const size = document.querySelector('[data-note-info="size"] > span')
|
|
2884
|
+
const updatedAt = document.querySelector('[data-note-info="updated"] > span')
|
|
2885
|
+
if (size) size.textContent = formatBytes(updated.size)
|
|
2886
|
+
if (updatedAt) updatedAt.textContent = formatDate(updated.updatedAt)
|
|
2887
|
+
syncNoteEditorChrome()
|
|
2888
|
+
showToast('文件已保存。')
|
|
2889
|
+
return true
|
|
2890
|
+
} catch (error) {
|
|
2891
|
+
showToast(friendlyError(error), 'error')
|
|
2892
|
+
return false
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
async function downloadNoteFile(node) {
|
|
2897
|
+
try {
|
|
2898
|
+
const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content?download=1`, { responseType: 'blob', accept: node.mediaType || 'application/octet-stream' })
|
|
2899
|
+
const url = URL.createObjectURL(blob)
|
|
2900
|
+
const anchor = element('a', { href: url, download: node.name })
|
|
2901
|
+
document.body.append(anchor); anchor.click(); anchor.remove()
|
|
2902
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
2903
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
async function copyNoteReference(node) {
|
|
2907
|
+
try { await navigator.clipboard.writeText(noteReference(node)); showToast('笔记引用已复制。') }
|
|
2908
|
+
catch { showToast('复制失败,请手动复制文档编号。', 'error') }
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
async function confirmDeleteNoteNode(node) {
|
|
2912
|
+
try {
|
|
2913
|
+
const references = await api(`notes/${encodeURIComponent(node.id)}/references`)
|
|
2914
|
+
if (references.length) {
|
|
2915
|
+
const names = references.slice(0, 4).map(item => `“${item.documentTitle}”`).join('、')
|
|
2916
|
+
return openModal({
|
|
2917
|
+
title: '仍被知识文档引用', description: node.name,
|
|
2918
|
+
body: element('p', {}, `${names}${references.length > 4 ? `等 ${references.length} 篇文档` : ''}仍在引用这里的内容。请先移除引用,再删除。`),
|
|
2919
|
+
cancelLabel: '知道了',
|
|
2920
|
+
})
|
|
2921
|
+
}
|
|
2922
|
+
openConfirm({
|
|
2923
|
+
title: `删除“${node.name}”?`,
|
|
2924
|
+
message: node.kind === 'folder' ? '目录及其全部子目录和文件会被永久删除。' : '该文档会被永久删除,此操作无法撤销。',
|
|
2925
|
+
confirmLabel: '永久删除', danger: true,
|
|
2926
|
+
onConfirm: async () => {
|
|
2927
|
+
await api(`notes/${encodeURIComponent(node.id)}`, { method: 'DELETE' })
|
|
2928
|
+
await loadNoteChildren(node.parentId, true)
|
|
2929
|
+
if (state.notes.selectedId === node.id || state.notes.breadcrumbs.some(parent => parent.id === node.id)) clearNoteSelection()
|
|
2930
|
+
renderShell(); showToast('已删除。')
|
|
2931
|
+
},
|
|
2932
|
+
})
|
|
2933
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
function noteWorkspaceDragEnter(event) {
|
|
2937
|
+
if (!hasDragType(event, 'Files')) return
|
|
2938
|
+
event.preventDefault()
|
|
2939
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
|
|
2940
|
+
event.currentTarget.dataset.dropActive = 'true'
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
function noteWorkspaceDragLeave(event) {
|
|
2944
|
+
if (event.currentTarget.contains(event.relatedTarget)) return
|
|
2945
|
+
clearNoteDragState()
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
function activateNoteFolderDropTarget(event, node) {
|
|
2949
|
+
if (node.kind !== 'folder') return
|
|
2950
|
+
const hasFiles = hasDragType(event, 'Files')
|
|
2951
|
+
if (!hasFiles && !hasDragType(event, 'application/x-dsh-note-id')) return
|
|
2952
|
+
event.preventDefault()
|
|
2953
|
+
event.stopPropagation()
|
|
2954
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = hasFiles ? 'copy' : 'move'
|
|
2955
|
+
const workspace = document.querySelector('.notes-workspace')
|
|
2956
|
+
if (workspace) workspace.dataset.dropActive = 'false'
|
|
2957
|
+
document.querySelectorAll('.notes-tree-item[data-drop-target="true"]').forEach(target => {
|
|
2958
|
+
if (target !== event.currentTarget) target.dataset.dropTarget = 'false'
|
|
2959
|
+
})
|
|
2960
|
+
event.currentTarget.dataset.dropTarget = 'true'
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
function hasDragType(event, type) {
|
|
2964
|
+
return Array.from(event.dataTransfer?.types || []).includes(type)
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
function clearNoteDragState() {
|
|
2968
|
+
const workspace = document.querySelector('.notes-workspace')
|
|
2969
|
+
if (workspace) workspace.dataset.dropActive = 'false'
|
|
2970
|
+
document.querySelectorAll('.notes-tree-item[data-drop-target="true"]').forEach(node => { node.dataset.dropTarget = 'false' })
|
|
2971
|
+
document.querySelectorAll('.notes-tree-item[data-dragging="true"]').forEach(node => { node.dataset.dragging = 'false' })
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
function installNoteDragRecovery() {
|
|
2975
|
+
window.addEventListener('drop', clearNoteDragState, true)
|
|
2976
|
+
window.addEventListener('dragend', clearNoteDragState, true)
|
|
2977
|
+
window.addEventListener('blur', clearNoteDragState)
|
|
2978
|
+
document.addEventListener('dragleave', event => {
|
|
2979
|
+
if (event.relatedTarget === null) clearNoteDragState()
|
|
2980
|
+
}, true)
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
function noteReference(node) {
|
|
2984
|
+
const label = node.name.replaceAll('\\', '\\\\').replaceAll('[', '\\[').replaceAll(']', '\\]')
|
|
2985
|
+
return `@[${label}](note://${node.id})`
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
function noteMediaKind(node) {
|
|
2989
|
+
if (node.kind === 'folder') return 'folder'
|
|
2990
|
+
if (node.kind === 'document' || node.mediaType?.startsWith('text/')) return 'text'
|
|
2991
|
+
if (node.mediaType?.startsWith('image/')) return 'image'
|
|
2992
|
+
if (node.mediaType === 'application/pdf') return 'pdf'
|
|
2993
|
+
return 'file'
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
function noteExtension(name) {
|
|
2997
|
+
const extension = name.includes('.') ? name.split('.').pop() : 'FILE'
|
|
2998
|
+
return extension.slice(0, 4).toLocaleUpperCase()
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
function shortNoteId(id) {
|
|
3002
|
+
return `${id.slice(0, 11)}…${id.slice(-5)}`
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
function formatBytes(value) {
|
|
3006
|
+
if (value < 1024) return `${value} B`
|
|
3007
|
+
if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KiB`
|
|
3008
|
+
return `${(value / 1024 / 1024).toFixed(value < 10 * 1024 * 1024 ? 1 : 0)} MiB`
|
|
3009
|
+
}
|
|
3010
|
+
|
|
1430
3011
|
function renderCandidates() {
|
|
1431
3012
|
const statuses = [['pending', '待审核'], ['approved', '已通过'], ['rejected', '已拒绝']]
|
|
1432
3013
|
return element('section', { 'aria-labelledby': 'candidates-heading' },
|
|
@@ -1444,12 +3025,9 @@ function renderCandidates() {
|
|
|
1444
3025
|
|
|
1445
3026
|
function renderCandidateCard(candidate) {
|
|
1446
3027
|
const pending = candidate.status === 'pending'
|
|
1447
|
-
const
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
const targetDocument = candidate.action === 'create'
|
|
1451
|
-
? `新文档“${candidate.draft.title}”`
|
|
1452
|
-
: `文档“${candidate.draft.title}”`
|
|
3028
|
+
const target = candidate.targetId ? state.candidateTargets.get(candidate.targetId) : null
|
|
3029
|
+
const targetAvailable = candidate.action === 'create' || Boolean(target)
|
|
3030
|
+
const action = candidatePrimaryAction(candidate)
|
|
1453
3031
|
return element('article', { class: 'candidate' },
|
|
1454
3032
|
element('div', { class: 'candidate-header' },
|
|
1455
3033
|
element('div', {},
|
|
@@ -1462,31 +3040,122 @@ function renderCandidateCard(candidate) {
|
|
|
1462
3040
|
),
|
|
1463
3041
|
badge(STATUS_LABELS[candidate.status], candidate.status === 'approved' ? 'success' : candidate.status === 'rejected' ? 'danger' : 'warning'),
|
|
1464
3042
|
),
|
|
1465
|
-
element('div', { class:
|
|
1466
|
-
candidate
|
|
1467
|
-
? element('div', { class: 'candidate-comparison' },
|
|
1468
|
-
element('section', {}, element('strong', {}, '当前知识'), element('pre', {}, conflictTarget?.body || '目标文档不可用,请刷新后重试。')),
|
|
1469
|
-
element('section', {}, element('strong', {}, '候选内容'), element('pre', {}, candidate.draft.body)),
|
|
1470
|
-
)
|
|
1471
|
-
: element('p', { class: 'candidate-content' }, candidate.draft.body),
|
|
3043
|
+
element('div', { class: 'candidate-body' },
|
|
3044
|
+
renderCandidateDiff(candidate, target),
|
|
1472
3045
|
element('div', { class: 'candidate-reason' },
|
|
1473
|
-
element('
|
|
1474
|
-
|
|
1475
|
-
|
|
3046
|
+
element('section', {},
|
|
3047
|
+
element('strong', {}, '写入位置'),
|
|
3048
|
+
element('span', { class: 'candidate-target' }, candidate.action === 'create'
|
|
3049
|
+
? `创建“${candidate.draft.title}”`
|
|
3050
|
+
: target ? `合并到“${target.title}”` : `目标 ${candidate.targetId || '不可用'}`),
|
|
3051
|
+
),
|
|
3052
|
+
renderCandidateMetadataChanges(candidate, target),
|
|
3053
|
+
element('section', {},
|
|
3054
|
+
element('strong', {}, '模型判断依据'),
|
|
3055
|
+
element('p', {}, candidate.reason || '未提供判断说明'),
|
|
3056
|
+
),
|
|
3057
|
+
candidate.reviewNote ? element('section', {},
|
|
3058
|
+
element('strong', {}, '审核备注'),
|
|
3059
|
+
element('p', {}, candidate.reviewNote),
|
|
3060
|
+
) : null,
|
|
3061
|
+
),
|
|
1476
3062
|
),
|
|
1477
3063
|
element('div', { class: 'candidate-footer' },
|
|
1478
3064
|
element('small', {}, `${scopeLabel(candidate.draft.scope)} · 置信度 ${Math.round(candidate.draft.confidence * 100)}%${candidate.targetId ? ` · 目标 ${candidate.targetId}` : ''} · ${formatDate(candidate.createdAt)}`),
|
|
1479
|
-
pending ? element('div', {},
|
|
3065
|
+
pending ? element('div', { class: 'candidate-actions' },
|
|
1480
3066
|
actionButton('拒绝', () => reviewCandidate(candidate, 'reject'), 'danger small'),
|
|
1481
|
-
actionButton(
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
3067
|
+
actionButton(action.editLabel, () => openEntryEditor(undefined, candidate), 'small', {
|
|
3068
|
+
disabled: !targetAvailable,
|
|
3069
|
+
title: targetAvailable ? action.editLabel : '目标文档不可用,无法安全编辑合并结果',
|
|
3070
|
+
}),
|
|
3071
|
+
actionButton(action.label, () => reviewCandidate(candidate, 'approve', action.resolution), 'primary small', {
|
|
3072
|
+
disabled: !targetAvailable,
|
|
3073
|
+
title: targetAvailable ? action.label : '目标文档不可用,刷新后再审核',
|
|
3074
|
+
}),
|
|
1485
3075
|
) : null,
|
|
1486
3076
|
),
|
|
1487
3077
|
)
|
|
1488
3078
|
}
|
|
1489
3079
|
|
|
3080
|
+
function renderCandidateDiff(candidate, target) {
|
|
3081
|
+
if (candidate.action !== 'create' && !target) {
|
|
3082
|
+
return element('section', { class: 'candidate-change candidate-change-unavailable', role: 'alert' },
|
|
3083
|
+
element('strong', {}, '无法生成变更预览'),
|
|
3084
|
+
element('p', {}, '目标文档暂时不可用。为避免盲目覆盖,刷新并确认当前版本后再审核。'),
|
|
3085
|
+
)
|
|
3086
|
+
}
|
|
3087
|
+
const review = window.DshKnowledgeReview.createReviewChange(candidate.action, target?.body || '', candidate.draft.body)
|
|
3088
|
+
const title = candidate.action === 'create' ? '新文档内容' : candidate.action === 'conflict' ? '冲突合并预览' : '文档合并预览'
|
|
3089
|
+
const targetLabel = candidate.action === 'create'
|
|
3090
|
+
? candidate.draft.title
|
|
3091
|
+
: `${target.title} · 当前版本 ${target.version}`
|
|
3092
|
+
return element('section', { class: `candidate-change is-${candidate.action}`, 'aria-label': title },
|
|
3093
|
+
element('div', { class: 'candidate-change-heading' },
|
|
3094
|
+
element('div', {}, element('strong', {}, title), element('small', {}, targetLabel)),
|
|
3095
|
+
element('div', { class: 'diff-summary', 'aria-label': `新增 ${review.diff.additions} 行,删除 ${review.diff.deletions} 行,未变更 ${review.diff.unchanged} 行` },
|
|
3096
|
+
element('span', { class: 'diff-stat additions' }, `+${review.diff.additions}`),
|
|
3097
|
+
element('span', { class: 'diff-stat deletions' }, `-${review.diff.deletions}`),
|
|
3098
|
+
element('span', { class: 'diff-stat unchanged' }, `${review.diff.unchanged} 未变`),
|
|
3099
|
+
),
|
|
3100
|
+
),
|
|
3101
|
+
review.diff.simplified ? element('div', { class: 'diff-notice' }, '文档变更较大,已使用简化差异视图。合并内容不受影响。') : null,
|
|
3102
|
+
element('div', { class: 'diff-viewer', role: 'table', 'aria-label': `${title}逐行差异` },
|
|
3103
|
+
element('div', { class: 'diff-column-headings', role: 'row' },
|
|
3104
|
+
element('span', { role: 'columnheader' }, '旧'),
|
|
3105
|
+
element('span', { role: 'columnheader' }, '新'),
|
|
3106
|
+
element('span', { 'aria-hidden': 'true' }),
|
|
3107
|
+
element('span', { role: 'columnheader' }, '正文'),
|
|
3108
|
+
),
|
|
3109
|
+
review.displayLines.length
|
|
3110
|
+
? review.displayLines.map(renderDiffLine)
|
|
3111
|
+
: element('div', { class: 'diff-empty' }, '正文为空,没有可写入的差异。'),
|
|
3112
|
+
),
|
|
3113
|
+
)
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
function renderDiffLine(line) {
|
|
3117
|
+
if (line.kind === 'omitted') {
|
|
3118
|
+
return element('div', { class: 'diff-line is-omitted', role: 'row', 'aria-label': `${line.count} 行未变更,已折叠` },
|
|
3119
|
+
element('span', { class: 'diff-line-number', 'aria-hidden': 'true' }),
|
|
3120
|
+
element('span', { class: 'diff-line-number', 'aria-hidden': 'true' }),
|
|
3121
|
+
element('span', { class: 'diff-marker', 'aria-hidden': 'true' }, '…'),
|
|
3122
|
+
element('span', { class: 'diff-omitted-copy' }, `${line.count} 行未变更`),
|
|
3123
|
+
)
|
|
3124
|
+
}
|
|
3125
|
+
const marker = line.kind === 'add' ? '+' : line.kind === 'remove' ? '-' : ' '
|
|
3126
|
+
const label = line.kind === 'add'
|
|
3127
|
+
? `新增第 ${line.newLine} 行`
|
|
3128
|
+
: line.kind === 'remove' ? `删除原第 ${line.oldLine} 行` : `未变更行 ${line.oldLine}`
|
|
3129
|
+
return element('div', { class: `diff-line is-${line.kind}`, role: 'row', 'aria-label': label },
|
|
3130
|
+
element('span', { class: 'diff-line-number', role: 'cell' }, line.oldLine || ''),
|
|
3131
|
+
element('span', { class: 'diff-line-number', role: 'cell' }, line.newLine || ''),
|
|
3132
|
+
element('span', { class: 'diff-marker', role: 'cell', 'aria-hidden': 'true' }, marker),
|
|
3133
|
+
element('code', { class: 'diff-code', role: 'cell' }, line.text || ' '),
|
|
3134
|
+
)
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
function renderCandidateMetadataChanges(candidate, target) {
|
|
3138
|
+
if (!target || candidate.action === 'create') return null
|
|
3139
|
+
const changes = []
|
|
3140
|
+
if (target.title !== candidate.draft.title) changes.push(`标题:${target.title} → ${candidate.draft.title}`)
|
|
3141
|
+
const addedTags = candidate.draft.tags.filter(tag => !target.tags.includes(tag))
|
|
3142
|
+
if (addedTags.length) changes.push(`新增标签:${addedTags.join('、')}`)
|
|
3143
|
+
if (candidate.draft.confidence > target.confidence) {
|
|
3144
|
+
changes.push(`置信度:${Math.round(target.confidence * 100)}% → ${Math.round(candidate.draft.confidence * 100)}%`)
|
|
3145
|
+
}
|
|
3146
|
+
if (!changes.length) return null
|
|
3147
|
+
return element('section', { class: 'candidate-metadata' },
|
|
3148
|
+
element('strong', {}, '属性变化'),
|
|
3149
|
+
element('ul', {}, changes.map(change => element('li', {}, change))),
|
|
3150
|
+
)
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
function candidatePrimaryAction(candidate) {
|
|
3154
|
+
if (candidate.action === 'create') return { label: '写入新文档', editLabel: '编辑后写入' }
|
|
3155
|
+
if (candidate.action === 'conflict') return { label: '确认合并', editLabel: '编辑合并内容', resolution: 'merge' }
|
|
3156
|
+
return { label: '合并到文档', editLabel: '编辑后合并' }
|
|
3157
|
+
}
|
|
3158
|
+
|
|
1490
3159
|
function renderTokens() {
|
|
1491
3160
|
const apiAddress = new URL(state.service.publicApiPrefix, location.origin).href.replace(/\/$/, '')
|
|
1492
3161
|
return element('section', { 'aria-labelledby': 'tokens-heading' },
|
|
@@ -1569,7 +3238,8 @@ function parseTags(value) {
|
|
|
1569
3238
|
return [...new Set(value.split(/[,,]/).map(tag => tag.trim().toLowerCase()).filter(Boolean))]
|
|
1570
3239
|
}
|
|
1571
3240
|
|
|
1572
|
-
function openKnowledgeBaseEditor(base) {
|
|
3241
|
+
async function openKnowledgeBaseEditor(base) {
|
|
3242
|
+
await loadModelCatalog()
|
|
1573
3243
|
const source = base || { name: '', description: '', defaultTags: [], extractionInstructions: '', writebackPolicy: 'conservative' }
|
|
1574
3244
|
const form = element('form', { class: 'form-grid' })
|
|
1575
3245
|
const name = formField('名称', 'input', source.name, { required: true, maxlength: 100, placeholder: '例如:项目规范' })
|
|
@@ -1580,9 +3250,26 @@ function openKnowledgeBaseEditor(base) {
|
|
|
1580
3250
|
{ value: 'conservative', label: '严谨(高置信度直写)' },
|
|
1581
3251
|
{ value: 'proactive', label: '主动(更积极沉淀)' },
|
|
1582
3252
|
], source.writebackPolicy || 'conservative')
|
|
3253
|
+
const dedicatedModel = element('input', {
|
|
3254
|
+
type: 'checkbox',
|
|
3255
|
+
checked: Boolean(source.writebackProvider && source.writebackModel),
|
|
3256
|
+
})
|
|
3257
|
+
const route = modelRouteFields(source.writebackProvider || '', source.writebackModel || '')
|
|
3258
|
+
const routeToggle = element('label', { class: 'check-option span-2' }, dedicatedModel,
|
|
3259
|
+
element('span', {},
|
|
3260
|
+
element('strong', {}, '这个知识库使用专用回写模型'),
|
|
3261
|
+
element('small', {}, '未设置时先使用本机覆盖;本机也未设置时跟随当前会话模型。'),
|
|
3262
|
+
))
|
|
3263
|
+
const syncRouteAvailability = () => {
|
|
3264
|
+
route.provider.input.disabled = route.model.input.disabled = !dedicatedModel.checked
|
|
3265
|
+
route.provider.input.required = route.model.input.required = dedicatedModel.checked
|
|
3266
|
+
}
|
|
3267
|
+
dedicatedModel.addEventListener('change', syncRouteAvailability)
|
|
3268
|
+
syncRouteAvailability()
|
|
1583
3269
|
for (const field of [name, description, tags, instructions]) field.wrapper.classList.add('span-2')
|
|
1584
3270
|
form.append(
|
|
1585
3271
|
name.wrapper, description.wrapper, tags.wrapper, instructions.wrapper, policy.wrapper,
|
|
3272
|
+
routeToggle, route.provider.wrapper, route.model.wrapper,
|
|
1586
3273
|
)
|
|
1587
3274
|
openSheet({
|
|
1588
3275
|
title: base ? '编辑知识库' : '创建知识库',
|
|
@@ -1597,6 +3284,10 @@ function openKnowledgeBaseEditor(base) {
|
|
|
1597
3284
|
defaultTags: parseTags(tags.input.value),
|
|
1598
3285
|
extractionInstructions: instructions.input.value.trim(),
|
|
1599
3286
|
writebackPolicy: policy.input.value,
|
|
3287
|
+
...(dedicatedModel.checked ? {
|
|
3288
|
+
writebackProvider: route.provider.input.value,
|
|
3289
|
+
writebackModel: route.model.input.value,
|
|
3290
|
+
} : {}),
|
|
1600
3291
|
}
|
|
1601
3292
|
if (base) await api(`knowledge-bases/${encodeURIComponent(base.id)}`, { method: 'PUT', body: { draft } })
|
|
1602
3293
|
else await api('knowledge-bases', { method: 'POST', body: { draft } })
|
|
@@ -1830,7 +3521,11 @@ function confirmDeleteKnowledgeBase(base) {
|
|
|
1830
3521
|
|
|
1831
3522
|
function openEntryEditor(entry, candidate) {
|
|
1832
3523
|
const activeWorkspace = activeDocumentWorkspace()
|
|
1833
|
-
const
|
|
3524
|
+
const candidateTarget = candidate?.targetId ? state.candidateTargets.get(candidate.targetId) : null
|
|
3525
|
+
const candidateReview = candidate && (candidate.action === 'create' || candidateTarget)
|
|
3526
|
+
? window.DshKnowledgeReview.createReviewChange(candidate.action, candidateTarget?.body || '', candidate.draft.body)
|
|
3527
|
+
: null
|
|
3528
|
+
const source = candidate ? { ...candidate.draft, body: candidateReview?.after || candidate.draft.body } : entry || {
|
|
1834
3529
|
knowledgeBaseId: activeWorkspace?.view.knowledgeBaseId || undefined,
|
|
1835
3530
|
title: '', body: '', type: 'fact', tags: [], scope: { kind: 'global' }, confidence: .8,
|
|
1836
3531
|
}
|
|
@@ -1863,8 +3558,16 @@ function openEntryEditor(entry, candidate) {
|
|
|
1863
3558
|
)
|
|
1864
3559
|
body.wrapper.classList.add('span-2')
|
|
1865
3560
|
tags.wrapper.classList.add('span-2')
|
|
1866
|
-
const modeTitle = candidate
|
|
1867
|
-
|
|
3561
|
+
const modeTitle = candidate
|
|
3562
|
+
? candidate.action === 'create' ? '编辑新文档' : '编辑合并内容'
|
|
3563
|
+
: entry ? '编辑知识文档' : '新建知识文档'
|
|
3564
|
+
const candidateDescription = candidate?.action === 'create'
|
|
3565
|
+
? '确认后将创建文档并立即参与后续召回。'
|
|
3566
|
+
: '正文已载入完整合并预览;保存前可以处理重复内容或冲突。'
|
|
3567
|
+
const primaryLabel = candidate
|
|
3568
|
+
? candidate.action === 'create' ? '写入新文档' : '保存并合并'
|
|
3569
|
+
: '保存文档'
|
|
3570
|
+
const modal = openSheet({ title: modeTitle, description: candidate ? candidateDescription : '文档保存后会立即参与后续召回。', body: form, primaryLabel, onPrimary: async () => {
|
|
1868
3571
|
if (!form.reportValidity()) return false
|
|
1869
3572
|
const draft = {
|
|
1870
3573
|
knowledgeBaseId: knowledgeBase.input.value,
|
|
@@ -1879,7 +3582,7 @@ function openEntryEditor(entry, candidate) {
|
|
|
1879
3582
|
})
|
|
1880
3583
|
else if (entry) await api(`entries/${encodeURIComponent(entry.id)}`, { method: 'PUT', body: { draft } })
|
|
1881
3584
|
else await api('entries', { method: 'POST', body: { draft } })
|
|
1882
|
-
showToast(candidate ? '
|
|
3585
|
+
showToast(candidate ? candidate.action === 'create' ? '新文档已写入。' : '候选内容已合并。' : '知识已保存。')
|
|
1883
3586
|
state.stats = null
|
|
1884
3587
|
await navigate(candidate ? 'candidates' : state.view)
|
|
1885
3588
|
return true
|
|
@@ -1902,7 +3605,9 @@ function selectField(label, options, value) {
|
|
|
1902
3605
|
async function loadModelCatalog() {
|
|
1903
3606
|
if (state.modelCatalog !== null) return state.modelCatalog
|
|
1904
3607
|
try {
|
|
1905
|
-
const response = await fetch('/knowledge-control/v1/models', {
|
|
3608
|
+
const response = await fetch('/knowledge-control/v1/models', {
|
|
3609
|
+
headers: { accept: 'application/json', 'x-dsh-knowledge-client': 'management-web' },
|
|
3610
|
+
})
|
|
1906
3611
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
1907
3612
|
const payload = await response.json()
|
|
1908
3613
|
state.modelCatalog = Array.isArray(payload.providers) ? payload.providers : []
|
|
@@ -1936,10 +3641,19 @@ function modelRouteFields(currentProvider, currentModel) {
|
|
|
1936
3641
|
|
|
1937
3642
|
async function reviewCandidate(candidate, decision, resolution) {
|
|
1938
3643
|
const approve = decision === 'approve'
|
|
3644
|
+
const action = candidatePrimaryAction(candidate)
|
|
3645
|
+
const title = approve ? `${action.label}?` : '拒绝这条候选?'
|
|
3646
|
+
const message = !approve
|
|
3647
|
+
? '拒绝后会保留审核记录,但不会进入知识库。'
|
|
3648
|
+
: candidate.action === 'create'
|
|
3649
|
+
? '确认后将创建新文档,并立即参与后续对话召回。'
|
|
3650
|
+
: candidate.action === 'conflict'
|
|
3651
|
+
? '确认后将保留当前文档内容,并把候选内容合入同一文档。'
|
|
3652
|
+
: '确认后将按预览结果合入目标文档;若审核期间文档发生变化,会转为冲突项。'
|
|
1939
3653
|
openConfirm({
|
|
1940
|
-
title
|
|
1941
|
-
message
|
|
1942
|
-
confirmLabel: approve ?
|
|
3654
|
+
title,
|
|
3655
|
+
message,
|
|
3656
|
+
confirmLabel: approve ? action.label : '确认拒绝', danger: !approve,
|
|
1943
3657
|
onConfirm: async () => {
|
|
1944
3658
|
const reviewed = await api(`candidates/${encodeURIComponent(candidate.id)}/review`, {
|
|
1945
3659
|
method: 'POST', body: { decision, ...(resolution ? { resolution } : {}) },
|
|
@@ -1950,7 +3664,7 @@ async function reviewCandidate(candidate, decision, resolution) {
|
|
|
1950
3664
|
await navigate('candidates')
|
|
1951
3665
|
return
|
|
1952
3666
|
}
|
|
1953
|
-
showToast(approve ? '
|
|
3667
|
+
showToast(approve ? candidate.action === 'create' ? '新文档已写入。' : '候选内容已合并。' : '候选已拒绝。')
|
|
1954
3668
|
state.stats = null
|
|
1955
3669
|
await navigate('candidates')
|
|
1956
3670
|
},
|
|
@@ -1994,7 +3708,7 @@ function openTokenCreator() {
|
|
|
1994
3708
|
return { permission, input, node: element('label', { class: 'check-option' }, input, permission) }
|
|
1995
3709
|
})
|
|
1996
3710
|
const body = element('form', { class: 'form-grid' }, name.wrapper,
|
|
1997
|
-
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。')))
|
|
3711
|
+
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。write 是当前中央服务的全局写权限,同时允许直接写入、管理知识库、挂载和笔记;仅在确实需要时授予。')))
|
|
1998
3712
|
name.wrapper.classList.add('span-2')
|
|
1999
3713
|
openModal({ title: '创建客户端令牌', description: '原始令牌只会在创建成功后显示一次。', body, primaryLabel: '创建令牌', onPrimary: async () => {
|
|
2000
3714
|
if (!body.reportValidity()) return false
|
|
@@ -2043,20 +3757,28 @@ function openSheet(options) {
|
|
|
2043
3757
|
return openModal({ ...options, presentation: 'sheet' })
|
|
2044
3758
|
}
|
|
2045
3759
|
|
|
2046
|
-
function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消', presentation = 'modal' }) {
|
|
3760
|
+
function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消', presentation = 'modal', onClose }) {
|
|
2047
3761
|
const previouslyFocused = document.activeElement
|
|
2048
3762
|
const isSheet = presentation === 'sheet'
|
|
2049
3763
|
const backdrop = element('div', { class: `dialog-backdrop${isSheet ? ' sheet-backdrop' : ''}` })
|
|
2050
3764
|
const dialog = element('section', { class: `dialog${isSheet ? ' sheet' : ''} ${primaryLabel ? '' : 'narrow'}`.trim(), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dialog-title' })
|
|
2051
3765
|
let busy = false
|
|
2052
|
-
|
|
2053
|
-
|
|
3766
|
+
let formDirty = false
|
|
3767
|
+
let closed = false
|
|
3768
|
+
const close = (explicit = false) => {
|
|
3769
|
+
if (busy || closed) return
|
|
3770
|
+
if (!explicit && formDirty) {
|
|
3771
|
+
showToast('表单有未保存的修改,请先保存,或使用“取消”放弃修改。', 'error')
|
|
3772
|
+
return
|
|
3773
|
+
}
|
|
2054
3774
|
document.removeEventListener('keydown', onKeyDown)
|
|
3775
|
+
closed = true
|
|
2055
3776
|
backdrop.remove()
|
|
3777
|
+
onClose?.()
|
|
2056
3778
|
if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus()
|
|
2057
3779
|
}
|
|
2058
|
-
const closeButton = actionButton('×', close, 'ghost', { 'aria-label': '关闭对话框' })
|
|
2059
|
-
const cancel = actionButton(cancelLabel, close)
|
|
3780
|
+
const closeButton = actionButton('×', () => close(), 'ghost', { 'aria-label': '关闭对话框' })
|
|
3781
|
+
const cancel = actionButton(cancelLabel, () => close(true))
|
|
2060
3782
|
const primary = primaryLabel ? actionButton(primaryLabel, async () => {
|
|
2061
3783
|
if (busy) return
|
|
2062
3784
|
busy = true
|
|
@@ -2067,7 +3789,7 @@ function openModal({ title, description = '', body, primaryLabel, primaryVariant
|
|
|
2067
3789
|
try {
|
|
2068
3790
|
const shouldClose = await onPrimary()
|
|
2069
3791
|
busy = false
|
|
2070
|
-
if (shouldClose !== false) close()
|
|
3792
|
+
if (shouldClose !== false) close(true)
|
|
2071
3793
|
else { primary.disabled = false; cancel.disabled = false; primary.textContent = original }
|
|
2072
3794
|
} catch (error) {
|
|
2073
3795
|
busy = false
|
|
@@ -2088,6 +3810,10 @@ function openModal({ title, description = '', body, primaryLabel, primaryVariant
|
|
|
2088
3810
|
)
|
|
2089
3811
|
backdrop.append(dialog)
|
|
2090
3812
|
backdrop.addEventListener('mousedown', (event) => { if (event.target === backdrop) close() })
|
|
3813
|
+
if (body.matches?.('form') || body.querySelector?.('form')) {
|
|
3814
|
+
body.addEventListener('input', () => { formDirty = true })
|
|
3815
|
+
body.addEventListener('change', () => { formDirty = true })
|
|
3816
|
+
}
|
|
2091
3817
|
document.body.append(backdrop)
|
|
2092
3818
|
document.addEventListener('keydown', onKeyDown)
|
|
2093
3819
|
window.setTimeout(() => (dialog.querySelector('input, textarea, select, button') || dialog).focus(), 0)
|
|
@@ -2118,4 +3844,12 @@ function friendlyError(error) {
|
|
|
2118
3844
|
return error.message || '操作失败,请稍后重试。'
|
|
2119
3845
|
}
|
|
2120
3846
|
|
|
3847
|
+
window.addEventListener('beforeunload', event => {
|
|
3848
|
+
const editor = activeDocumentWorkspace()?.view.editor
|
|
3849
|
+
if (!editor?.dirty && !state.notes.dirty) return
|
|
3850
|
+
event.preventDefault()
|
|
3851
|
+
event.returnValue = ''
|
|
3852
|
+
})
|
|
3853
|
+
|
|
3854
|
+
installNoteDragRecovery()
|
|
2121
3855
|
void installHostThemeBridge().then(() => boot())
|