@lemoncat7/dsh-knowledge 2.6.0 → 2.7.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 +29 -1
- package/lib/api.d.ts.map +1 -1
- package/lib/api.js +15 -3
- package/lib/api.js.map +1 -1
- package/lib/client.d.ts +3 -0
- package/lib/client.d.ts.map +1 -1
- package/lib/client.js +102 -7
- package/lib/client.js.map +4 -4
- package/lib/extraction.d.ts.map +1 -1
- package/lib/extraction.js +1 -0
- package/lib/extraction.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +14 -1
- package/lib/index.js.map +1 -1
- package/lib/notes/share-import.d.ts +2 -0
- package/lib/notes/share-import.d.ts.map +1 -1
- package/lib/notes/share-import.js +40 -5
- package/lib/notes/share-import.js.map +1 -1
- package/lib/web.js +1 -1
- package/lib/web.js.map +1 -1
- package/lib/writeback/control.d.ts +5 -0
- package/lib/writeback/control.d.ts.map +1 -0
- package/lib/writeback/control.js +39 -0
- package/lib/writeback/control.js.map +1 -0
- package/lib/writeback/live-control.d.ts +5 -0
- package/lib/writeback/live-control.d.ts.map +1 -0
- package/lib/writeback/live-control.js +63 -0
- package/lib/writeback/live-control.js.map +1 -0
- package/lib/writeback/queue.d.ts +27 -1
- package/lib/writeback/queue.d.ts.map +1 -1
- package/lib/writeback/queue.js +97 -7
- package/lib/writeback/queue.js.map +1 -1
- package/lib/writeback/status-client.d.ts +3 -0
- package/lib/writeback/status-client.d.ts.map +1 -1
- package/lib/writeback/status-client.js +30 -4
- package/lib/writeback/status-client.js.map +1 -1
- package/package.json +1 -1
- package/web/api-client.js +2 -1
- package/web/app.js +42 -15
- package/web/styles.css +13 -8
- package/web/ui-primitives.js +1 -0
- package/web/writeback-live.d.ts +1 -0
- package/web/writeback-live.js +52 -0
- package/web/writeback-workspace.js +76 -0
package/web/app.js
CHANGED
|
@@ -17,6 +17,8 @@ const { installHostThemeBridge } = themeModule
|
|
|
17
17
|
const { actionButton, badge, createToastPresenter, element, interfaceIcon, paneToggleButton } = uiModule
|
|
18
18
|
const { renderMenu: renderDocumentMenu, closeMenus: closeDocumentMenus } = documentActionsModule.createDocumentMenuPresenter(uiModule)
|
|
19
19
|
const readModelCatalog = modelCatalogModule.createModelCatalogLoader()
|
|
20
|
+
const { createWritebackWorkspace } = await import(moduleUrl('writeback-workspace'))
|
|
21
|
+
let writebackWorkspace
|
|
20
22
|
const TOKEN_KEY = 'dsh-knowledge.session-token'
|
|
21
23
|
const TYPES = ['preference', 'fact', 'decision', 'procedure', 'lesson']
|
|
22
24
|
const TYPE_LABELS = { preference: '偏好', fact: '事实', decision: '决策', procedure: '流程', lesson: '经验' }
|
|
@@ -32,7 +34,7 @@ const NOTE_MAX_FILE_SIZE = 64 * 1024 * 1024
|
|
|
32
34
|
const pageParams = new URLSearchParams(location.search)
|
|
33
35
|
const initialKnowledgeBaseId = pageParams.get('knowledgeBaseId')?.trim() || ''
|
|
34
36
|
const initialDocumentId = pageParams.get('documentId')?.trim() || ''
|
|
35
|
-
const initialView = pageParams.get('view')
|
|
37
|
+
const initialView = ['notes', 'writeback'].includes(pageParams.get('view')) ? pageParams.get('view') : 'entries'
|
|
36
38
|
const initialNoteId = pageParams.get('noteId')?.trim() || ''
|
|
37
39
|
const mountContext = {
|
|
38
40
|
sessionId: pageParams.get('sessionId')?.trim() || '',
|
|
@@ -950,6 +952,8 @@ async function loadTokens(signal) {
|
|
|
950
952
|
}
|
|
951
953
|
|
|
952
954
|
function renderShell() {
|
|
955
|
+
writebackWorkspace?.dispose()
|
|
956
|
+
writebackWorkspace = undefined
|
|
953
957
|
closeDocumentMenus()
|
|
954
958
|
releaseNoteEditors()
|
|
955
959
|
captureScrollPosition()
|
|
@@ -960,10 +964,11 @@ function renderShell() {
|
|
|
960
964
|
notes: ['笔记文档', '像本地目录一样整理笔记和资料,并按需关联到知识文档'],
|
|
961
965
|
shares: ['已分享', '管理只读分享链接,也可以导入别人分享的笔记和目录'],
|
|
962
966
|
candidates: ['待审核', '确认 AI 提取结果后再写入知识文档'],
|
|
967
|
+
writeback: ['回写任务', '查看本机队列,处理失败和阻塞的回写'],
|
|
963
968
|
tokens: ['访问管理', '管理其他客户端连接中央知识库的权限'],
|
|
964
969
|
}
|
|
965
970
|
const [title, subtitle] = titles[state.view]
|
|
966
|
-
const viewIndexes = { overview: '00', notes: '01', shares: '02', entries: '03', candidates: '04',
|
|
971
|
+
const viewIndexes = { overview: '00', notes: '01', shares: '02', entries: '03', candidates: '04', writeback: '05', bases: '06', tokens: '07' }
|
|
967
972
|
const shell = element('div', {
|
|
968
973
|
class: 'app-shell', 'data-menu-open': String(state.menuOpen),
|
|
969
974
|
'data-view': state.view, 'data-loading': String(state.loading),
|
|
@@ -1146,7 +1151,7 @@ function renderSidebar() {
|
|
|
1146
1151
|
const pending = state.stats?.candidates.pending
|
|
1147
1152
|
const navGroups = [
|
|
1148
1153
|
['笔记工作区', [['notes', '笔记文档'], ['shares', '已分享']]],
|
|
1149
|
-
['知识工作区', [['entries', '知识文档'], ['candidates', '待审核'], ['bases', '知识库与挂载']]],
|
|
1154
|
+
['知识工作区', [['entries', '知识文档'], ['candidates', '待审核'], ['writeback', '回写任务'], ['bases', '知识库与挂载']]],
|
|
1150
1155
|
['连接', [['tokens', '访问管理']].filter(([id]) => id !== 'tokens' || !state.service.remote)],
|
|
1151
1156
|
].filter(([, items]) => items.length)
|
|
1152
1157
|
let navIndex = 0
|
|
@@ -1172,6 +1177,10 @@ function renderSidebar() {
|
|
|
1172
1177
|
function renderCurrentView() {
|
|
1173
1178
|
if (state.loading) return loadingView(state.view, state.loadingPhase, state.loadingProgress)
|
|
1174
1179
|
if (state.error) return errorView(state.error, () => navigate(state.view))
|
|
1180
|
+
if (state.view === 'writeback') {
|
|
1181
|
+
writebackWorkspace = createWritebackWorkspace({ element, actionButton, openConfirm, sessionId: mountContext.sessionId })
|
|
1182
|
+
return writebackWorkspace.root
|
|
1183
|
+
}
|
|
1175
1184
|
if (state.view === 'overview') return renderOverview()
|
|
1176
1185
|
if (state.view === 'bases') return renderKnowledgeBases()
|
|
1177
1186
|
if (state.view === 'entries') return renderEntries()
|
|
@@ -2181,8 +2190,9 @@ function openImportNoteShare() {
|
|
|
2181
2190
|
destination.input.disabled = true
|
|
2182
2191
|
const inspectButton = actionButton('读取分享', () => { void inspect() }, 'small')
|
|
2183
2192
|
const fieldError = element('p', { class: 'share-import-field-error', role: 'alert', hidden: true })
|
|
2193
|
+
const privateConfirmation = element('div', { class: 'share-import-note', hidden: true, role: 'status' })
|
|
2184
2194
|
const preview = element('div', { class: 'share-import-preview', 'data-state': 'empty', 'aria-live': 'polite' },
|
|
2185
|
-
element('span', { class: 'share-import-preview-mark', 'aria-hidden': 'true' }),
|
|
2195
|
+
element('span', { class: 'share-import-preview-mark', 'aria-hidden': 'true' }, interfaceIcon('share-import')),
|
|
2186
2196
|
element('div', {}, element('strong', {}, '等待读取分享'), element('p', {}, '读取后会显示类型、文件数量与大小;导入前不会写入任何内容。')),
|
|
2187
2197
|
)
|
|
2188
2198
|
const destinationHint = element('span', { class: 'field-hint' }, '正在读取你的笔记目录…')
|
|
@@ -2191,11 +2201,13 @@ function openImportNoteShare() {
|
|
|
2191
2201
|
urlField.wrapper.replaceChildren(element('label', {}, '分享链接'), inputRow, fieldError)
|
|
2192
2202
|
const form = element('form', { class: 'share-import-form', onSubmit: event => { event.preventDefault(); void inspect() } },
|
|
2193
2203
|
urlField.wrapper,
|
|
2204
|
+
privateConfirmation,
|
|
2194
2205
|
preview,
|
|
2195
2206
|
destination.wrapper,
|
|
2196
2207
|
element('p', { class: 'share-import-note' }, '导入会创建一份独立副本;之后对方更新或停止分享,都不会改动你已经导入的内容。'),
|
|
2197
2208
|
)
|
|
2198
2209
|
let inspectedUrl = ''
|
|
2210
|
+
let confirmedPrivateUrl = ''
|
|
2199
2211
|
|
|
2200
2212
|
const showFieldError = message => {
|
|
2201
2213
|
fieldError.textContent = message
|
|
@@ -2215,33 +2227,54 @@ function openImportNoteShare() {
|
|
|
2215
2227
|
)
|
|
2216
2228
|
}
|
|
2217
2229
|
const inspect = async () => {
|
|
2230
|
+
if (inspectButton.disabled) return false
|
|
2218
2231
|
if (!urlField.input.reportValidity()) return false
|
|
2232
|
+
const requestedUrl = urlField.input.value.trim()
|
|
2233
|
+
urlField.input.disabled = true
|
|
2234
|
+
privateConfirmation.hidden = true
|
|
2219
2235
|
inspectButton.disabled = true
|
|
2220
2236
|
inspectButton.setAttribute('aria-busy', 'true')
|
|
2221
2237
|
inspectButton.textContent = '正在读取…'
|
|
2222
2238
|
showFieldError('')
|
|
2223
2239
|
preview.dataset.state = 'loading'
|
|
2224
2240
|
try {
|
|
2225
|
-
const result = await api('notes/import-share/inspect', { method: 'POST', body: { url:
|
|
2226
|
-
inspectedUrl =
|
|
2241
|
+
const result = await api('notes/import-share/inspect', { method: 'POST', body: { url: requestedUrl, confirmPrivateShare: confirmedPrivateUrl === requestedUrl } })
|
|
2242
|
+
inspectedUrl = requestedUrl
|
|
2227
2243
|
renderPreview(result.manifest)
|
|
2228
2244
|
return !result.manifest.truncated
|
|
2229
2245
|
} catch (error) {
|
|
2230
2246
|
inspectedUrl = ''
|
|
2231
2247
|
preview.dataset.state = 'error'
|
|
2232
2248
|
preview.replaceChildren(
|
|
2233
|
-
element('span', { class: 'share-import-preview-mark', 'aria-hidden': 'true' }),
|
|
2249
|
+
element('span', { class: 'share-import-preview-mark', 'aria-hidden': 'true' }, interfaceIcon('share-import')),
|
|
2234
2250
|
element('div', {}, element('strong', {}, '无法读取这个分享'), element('p', {}, '请检查链接是否完整、分享是否仍有效。')),
|
|
2235
2251
|
)
|
|
2236
2252
|
showFieldError(friendlyError(error))
|
|
2253
|
+
if (error.code === 'PRIVATE_SHARE_CONFIRMATION_REQUIRED' && error.origin === new URL(requestedUrl).origin) {
|
|
2254
|
+
confirmedPrivateUrl = ''
|
|
2255
|
+
showFieldError('')
|
|
2256
|
+
preview.dataset.state = 'empty'
|
|
2257
|
+
preview.replaceChildren(
|
|
2258
|
+
element('span', { class: 'share-import-preview-mark', 'aria-hidden': 'true' }, interfaceIcon('share-import')),
|
|
2259
|
+
element('div', {}, element('strong', {}, '等待内网访问确认'), element('p', {}, '确认后读取分享清单,不会立即导入。')),
|
|
2260
|
+
)
|
|
2261
|
+
privateConfirmation.replaceChildren(
|
|
2262
|
+
element('p', {}, `目标 ${error.origin} 解析到内网地址。确认后,DSH 服务端将访问此链接的分享接口;请仅允许你信任的来源。授权仅用于本次读取和导入,不会保存到白名单。`),
|
|
2263
|
+
actionButton('允许本次内网分享并读取', () => { confirmedPrivateUrl = requestedUrl; void inspect() }, 'small'),
|
|
2264
|
+
)
|
|
2265
|
+
privateConfirmation.hidden = false
|
|
2266
|
+
}
|
|
2237
2267
|
return false
|
|
2238
2268
|
} finally {
|
|
2239
2269
|
inspectButton.disabled = false
|
|
2270
|
+
urlField.input.disabled = false
|
|
2240
2271
|
inspectButton.removeAttribute('aria-busy')
|
|
2241
2272
|
inspectButton.textContent = '读取分享'
|
|
2242
2273
|
}
|
|
2243
2274
|
}
|
|
2244
2275
|
urlField.input.addEventListener('input', () => {
|
|
2276
|
+
confirmedPrivateUrl = ''
|
|
2277
|
+
privateConfirmation.hidden = true
|
|
2245
2278
|
if (urlField.input.value.trim() !== inspectedUrl) {
|
|
2246
2279
|
inspectedUrl = ''
|
|
2247
2280
|
preview.dataset.state = 'empty'
|
|
@@ -2265,7 +2298,7 @@ function openImportNoteShare() {
|
|
|
2265
2298
|
showFieldError('')
|
|
2266
2299
|
try {
|
|
2267
2300
|
const result = await api('notes/import-share', {
|
|
2268
|
-
method: 'POST', body: { url: requestedUrl, parentId: destination.input.value || null },
|
|
2301
|
+
method: 'POST', body: { url: requestedUrl, parentId: destination.input.value || null, confirmPrivateShare: confirmedPrivateUrl === requestedUrl },
|
|
2269
2302
|
})
|
|
2270
2303
|
state.notes.children.clear()
|
|
2271
2304
|
state.notes.loadedFolders.clear()
|
|
@@ -2747,8 +2780,7 @@ function noteInfoItem(label, value, title = '', key = '') {
|
|
|
2747
2780
|
}
|
|
2748
2781
|
|
|
2749
2782
|
function renderNoteIcon(node, large = false) {
|
|
2750
|
-
|
|
2751
|
-
return element('span', { class: `note-node-icon is-${node.kind}${large ? ' is-large' : ''}`, 'data-media-kind': noteMediaKind(node), 'aria-hidden': 'true' }, label)
|
|
2783
|
+
return element('span', { class: `note-node-icon ${node.kind === 'folder' ? 'tree-folder-icon' : 'tree-document-icon'}${large ? ' is-large' : ''}`, 'data-media-kind': noteMediaKind(node), 'aria-hidden': 'true' })
|
|
2752
2784
|
}
|
|
2753
2785
|
|
|
2754
2786
|
function noteKindLabel(node) {
|
|
@@ -3559,11 +3591,6 @@ function noteMediaKind(node) {
|
|
|
3559
3591
|
return 'file'
|
|
3560
3592
|
}
|
|
3561
3593
|
|
|
3562
|
-
function noteExtension(name) {
|
|
3563
|
-
const extension = name.includes('.') ? name.split('.').pop() : 'FILE'
|
|
3564
|
-
return extension.slice(0, 4).toLocaleUpperCase()
|
|
3565
|
-
}
|
|
3566
|
-
|
|
3567
3594
|
function shortNoteId(id) {
|
|
3568
3595
|
return `${id.slice(0, 11)}…${id.slice(-5)}`
|
|
3569
3596
|
}
|
package/web/styles.css
CHANGED
|
@@ -955,12 +955,8 @@ html,
|
|
|
955
955
|
.notes-search-row strong, .notes-search-row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
956
956
|
.notes-search-row strong { font-size: 11px; }
|
|
957
957
|
.notes-search-row small { margin-top: 1px; color: var(--text-tertiary); font-size: 10px; }
|
|
958
|
-
.note-node-icon {
|
|
959
|
-
.note-node-icon.is-
|
|
960
|
-
.note-node-icon.is-folder::before { content: ""; position: absolute; top: -4px; left: 1px; width: 9px; height: 4px; border: 1px solid var(--border-strong); border-bottom: 0; border-radius: 3px 3px 0 0; background: inherit; }
|
|
961
|
-
.note-node-icon.is-document, .note-node-icon[data-media-kind="text"] { border-color: color-mix(in srgb, var(--accent) 30%, var(--border-strong)); color: var(--accent); }
|
|
962
|
-
.note-node-icon.is-large { width: 38px; height: 46px; border-radius: 6px; padding-bottom: 5px; font-size: 7px; }
|
|
963
|
-
.note-node-icon.is-folder.is-large { width: 44px; height: 32px; }
|
|
958
|
+
.note-node-icon.is-large { width: 38px; height: 42px; }
|
|
959
|
+
.note-node-icon.tree-folder-icon.is-large { width: 40px; height: 35px; }
|
|
964
960
|
.notes-content { min-width: 0; min-height: 0; overflow: auto; background: transparent; }
|
|
965
961
|
.notes-content-header { position: sticky; top: 0; z-index: 2; border-bottom: 1px solid var(--separator); padding: 11px 22px 13px; background: transparent; -webkit-backdrop-filter: none; backdrop-filter: none; }
|
|
966
962
|
.notes-breadcrumb { min-width: 0; display: flex; align-items: center; gap: 5px; overflow: hidden; color: var(--text-tertiary); font-size: 10px; }
|
|
@@ -1028,8 +1024,8 @@ html,
|
|
|
1028
1024
|
.share-import-preview strong, .share-import-preview p { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1029
1025
|
.share-import-preview strong { color: var(--text); font-size: 12px; }
|
|
1030
1026
|
.share-import-preview p { margin: 4px 0 0; color: var(--text-tertiary); font-size: 10px; }
|
|
1031
|
-
.share-import-preview-mark {
|
|
1032
|
-
.share-import-preview-mark
|
|
1027
|
+
.share-import-preview-mark { display: grid; place-items: center; width: 42px; height: 42px; border: 1px solid var(--glass-border); border-radius: 12px; background: var(--surface-soft); color: var(--text-secondary); }
|
|
1028
|
+
.share-import-preview-mark > .interface-icon { width: 24px; height: 24px; }
|
|
1033
1029
|
.share-import-note { margin: -4px 0 0; border-left: 2px solid var(--border-strong); padding: 5px 0 5px 10px; color: var(--text-tertiary); font-size: 10px; line-height: 1.6; }
|
|
1034
1030
|
.dialog.share-import-dialog { width: min(560px, calc(100vw - 32px)); }
|
|
1035
1031
|
.notes-content.is-document, .notes-content.is-file { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; }
|
|
@@ -2032,3 +2028,12 @@ html,
|
|
|
2032
2028
|
@media (forced-colors: active) {
|
|
2033
2029
|
.status-dot, .history-dot { forced-color-adjust: none; }
|
|
2034
2030
|
}
|
|
2031
|
+
/* Durable outbox: reuse workspace controls and surfaces, no animated cards. */
|
|
2032
|
+
.writeback-workspace { display: grid; gap: 12px; min-width: 0; }
|
|
2033
|
+
.writeback-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
|
2034
|
+
.writeback-toolbar input { flex: 1 1 220px; min-width: 0; }
|
|
2035
|
+
.writeback-job-list { display: grid; gap: 12px; }
|
|
2036
|
+
.writeback-job { padding: 12px; border: 1px solid var(--border); border-radius: 12px; min-width: 0; }
|
|
2037
|
+
.writeback-job p { margin: 8px 0; overflow-wrap: anywhere; }
|
|
2038
|
+
.writeback-job-key { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 8px; }
|
|
2039
|
+
.writeback-job pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
package/web/ui-primitives.js
CHANGED
|
@@ -54,6 +54,7 @@ export function interfaceIcon(name, className = 'interface-icon') {
|
|
|
54
54
|
outline: 'M8 6h11M8 12h11M8 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01',
|
|
55
55
|
history: 'M4 4v5h5M4.8 8.2A8 8 0 1 1 4 13M12 7.5V12l3 2',
|
|
56
56
|
download: 'M12 3v11M8 10l4 4 4-4M5 20h14',
|
|
57
|
+
'share-import': 'M13 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-2M13 3v5h5l-5-5M21 12H10m4-4-4 4 4 4',
|
|
57
58
|
link: 'M9.5 14.5l5-5M8.5 17H6a5 5 0 0 1 0-10h3M15.5 7H18a5 5 0 0 1 0 10h-3',
|
|
58
59
|
rename: 'M4 20l4.2-1 10.4-10.4a2.1 2.1 0 0 0-3-3L5.2 16zM14.5 6.5l3 3',
|
|
59
60
|
move: 'M3 7h6l2 2h10v11H3zM12 4h8m-3-3 3 3-3 3',
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function subscribeWritebackChanges(client: 'conversation-web' | 'management-web', listener: () => void): () => void
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Shared by visible conversation turns; the iframe owns its own single channel.
|
|
2
|
+
const groups = new Map()
|
|
3
|
+
export function subscribeWritebackChanges(client, listener) {
|
|
4
|
+
let group = groups.get(client)
|
|
5
|
+
if (!group) {
|
|
6
|
+
const listeners = new Set()
|
|
7
|
+
let stopped = false, request, timer, revision = '', failures = 0
|
|
8
|
+
function emit() { for (const fn of listeners) fn() }
|
|
9
|
+
async function poll() {
|
|
10
|
+
if (stopped || document.hidden) return
|
|
11
|
+
const controller = new AbortController()
|
|
12
|
+
request = controller
|
|
13
|
+
try {
|
|
14
|
+
const response = await fetch(`/knowledge-control/v1/writeback-changes?since=${encodeURIComponent(revision)}`, {
|
|
15
|
+
credentials: 'same-origin', cache: 'no-store', headers: { 'x-dsh-knowledge-client': client },
|
|
16
|
+
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(25000)]),
|
|
17
|
+
})
|
|
18
|
+
if (!response.ok) throw new Error('notification transport unavailable')
|
|
19
|
+
const data = await response.json()
|
|
20
|
+
if (typeof data.revision !== 'string') throw new Error('invalid revision')
|
|
21
|
+
if (stopped || controller.signal.aborted) return
|
|
22
|
+
const changed = revision !== data.revision || failures > 0
|
|
23
|
+
revision = data.revision
|
|
24
|
+
failures = 0
|
|
25
|
+
if (changed) emit()
|
|
26
|
+
} catch { if (!controller.signal.aborted) failures++ }
|
|
27
|
+
finally {
|
|
28
|
+
if (request === controller) {
|
|
29
|
+
request = undefined
|
|
30
|
+
if (!stopped && !document.hidden) timer = setTimeout(poll, failures ? Math.min(30000, 1000 * 2 ** Math.min(failures, 5)) : 0)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function resume() {
|
|
35
|
+
clearTimeout(timer)
|
|
36
|
+
request?.abort()
|
|
37
|
+
request = undefined
|
|
38
|
+
if (!document.hidden && !stopped) { emit(); void poll() }
|
|
39
|
+
}
|
|
40
|
+
document.addEventListener('visibilitychange', resume)
|
|
41
|
+
window.addEventListener('online', resume)
|
|
42
|
+
group = { listeners, close() { stopped = true; clearTimeout(timer); request?.abort(); document.removeEventListener('visibilitychange', resume); window.removeEventListener('online', resume) } }
|
|
43
|
+
groups.set(client, group)
|
|
44
|
+
// Let the first caller attach before the initial revision arrives.
|
|
45
|
+
timer = setTimeout(poll, 0)
|
|
46
|
+
}
|
|
47
|
+
group.listeners.add(listener)
|
|
48
|
+
return () => {
|
|
49
|
+
group.listeners.delete(listener)
|
|
50
|
+
if (!group.listeners.size) { group.close(); groups.delete(client) }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { subscribeWritebackChanges } from './writeback-live.js'
|
|
2
|
+
/** Local outbox UI. Poll only this view, never reload the knowledge workspace. */
|
|
3
|
+
export function createWritebackWorkspace({ element, actionButton, openConfirm, sessionId = '' }) {
|
|
4
|
+
const labels = { queued: '排队中', running: '回写中', failed: '失败', completed: '已完成', cancelled: '已取消' }
|
|
5
|
+
let disposed = false, timer, request, offset = 0, busy = false, previous = '', dirty = false
|
|
6
|
+
const message = element('p', { role: 'status' })
|
|
7
|
+
const list = element('div', { class: 'writeback-job-list' })
|
|
8
|
+
const page = element('span', {})
|
|
9
|
+
const filter = element('input', { class: 'input', value: sessionId, placeholder: '按会话编号筛选(可留空)', 'aria-label': '会话编号' })
|
|
10
|
+
const previousButton = actionButton('上一页', () => { offset = Math.max(0, offset - 50); previous = ''; void refresh() }, 'ghost small')
|
|
11
|
+
const nextButton = actionButton('下一页', () => { offset += 50; previous = ''; void refresh() }, 'ghost small')
|
|
12
|
+
const root = element('section', { class: 'writeback-workspace' },
|
|
13
|
+
element('p', {}, '本机回写队列。同一会话按顺序执行;网络中断会自动重试,连续失败可手动重试或取消。取消不会撤销已经写入的内容。'),
|
|
14
|
+
element('div', { class: 'writeback-toolbar' }, filter, actionButton('筛选', () => { sessionId = filter.value.trim(); offset = 0; previous = ''; void refresh() }, 'small'), actionButton('刷新', () => { void refresh() }, 'ghost small')),
|
|
15
|
+
message, list, element('div', { class: 'writeback-toolbar' }, previousButton, page, nextButton))
|
|
16
|
+
async function fetchJson(params, method = 'GET', signal) {
|
|
17
|
+
const response = await fetch(`/knowledge-control/v1/writeback-jobs?${params}`, { method, credentials: 'same-origin', headers: { 'x-dsh-knowledge-client': 'management-web' }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15000)]) : AbortSignal.timeout(15000) })
|
|
18
|
+
const data = await response.json().catch(() => ({}))
|
|
19
|
+
if (!response.ok) throw new Error(data.error || `读取回写队列失败(${response.status})`)
|
|
20
|
+
return data
|
|
21
|
+
}
|
|
22
|
+
async function operate(job, action) {
|
|
23
|
+
if (busy || disposed) return
|
|
24
|
+
busy = true
|
|
25
|
+
request?.abort()
|
|
26
|
+
message.textContent = action === 'cancel' ? '正在请求取消…' : '正在提交重试…'
|
|
27
|
+
try {
|
|
28
|
+
const result = await fetchJson(new URLSearchParams({ action, sourceKey: job.sourceKey }), 'POST')
|
|
29
|
+
message.textContent = result.summary
|
|
30
|
+
previous = ''
|
|
31
|
+
} catch (error) { message.textContent = error.message }
|
|
32
|
+
finally { busy = false; if (!disposed) void refresh(true) }
|
|
33
|
+
}
|
|
34
|
+
function renderJob(job) {
|
|
35
|
+
const actions = element('div', { class: 'writeback-toolbar' })
|
|
36
|
+
if (job.status === 'failed') actions.append(actionButton('重试', () => operate(job, 'retry'), 'small'))
|
|
37
|
+
if (['queued', 'running', 'failed'].includes(job.status)) actions.append(actionButton(job.cancelRequested ? '取消中…' : '取消回写', () => openConfirm({ title: '取消这条回写?', message: '取消后不再重试,并允许后续轮次继续。已经写入的内容不会撤销。', confirmLabel: '取消回写', danger: true, onConfirm: () => operate(job, 'cancel') }), 'ghost small', { disabled: job.cancelRequested }))
|
|
38
|
+
return element('article', { class: 'writeback-job' },
|
|
39
|
+
element('div', { class: 'writeback-toolbar' }, element('strong', {}, labels[job.status] || job.status), element('span', {}, `已尝试 ${job.attempts} 次`), actions),
|
|
40
|
+
element('div', { class: 'writeback-job-key', title: job.sourceKey }, job.sourceKey),
|
|
41
|
+
job.createdAt ? element('p', {}, '创建于 ', element('time', { datetime: new Date(job.createdAt).toISOString() }, new Date(job.createdAt).toLocaleString())) : element('p', {}, '历史任务 · 创建时间未记录'),
|
|
42
|
+
element('p', {}, job.summary),
|
|
43
|
+
job.blockedBy ? element('p', {}, `阻塞任务:${job.blockedBy}`) : null,
|
|
44
|
+
job.nextAttemptAt ? element('p', {}, `下次自动重试:${new Date(job.nextAttemptAt).toLocaleString()}`) : null,
|
|
45
|
+
job.error ? element('details', {}, element('summary', {}, '失败原因'), element('pre', {}, job.error)) : null,
|
|
46
|
+
...(job.destinations || []).map(destination => element('p', {}, `${destination.knowledgeBaseName} / ${destination.documentTitle || destination.documentId || ''}`)))
|
|
47
|
+
}
|
|
48
|
+
async function refresh(preserveMessage = false) {
|
|
49
|
+
clearTimeout(timer)
|
|
50
|
+
if (disposed || document.hidden || busy) return
|
|
51
|
+
dirty = false
|
|
52
|
+
request?.abort()
|
|
53
|
+
const controller = new AbortController()
|
|
54
|
+
request = controller
|
|
55
|
+
try {
|
|
56
|
+
const data = await fetchJson(new URLSearchParams({ sessionId, offset: String(offset) }), 'GET', controller.signal)
|
|
57
|
+
if (disposed || request !== controller) return
|
|
58
|
+
const fingerprint = JSON.stringify(data)
|
|
59
|
+
if (fingerprint !== previous) {
|
|
60
|
+
list.replaceChildren(...(data.items.length ? data.items.map(renderJob) : [element('p', {}, '没有回写任务')]))
|
|
61
|
+
previous = fingerprint
|
|
62
|
+
}
|
|
63
|
+
page.textContent = `共 ${data.total} 条 · 第 ${Math.floor(offset / 50) + 1} 页`
|
|
64
|
+
previousButton.disabled = offset === 0
|
|
65
|
+
nextButton.disabled = offset + 50 >= data.total
|
|
66
|
+
if (!preserveMessage) message.textContent = '状态自动更新'
|
|
67
|
+
} catch (error) { if (!controller.signal.aborted && !disposed) message.textContent = `${error.message};将自动重连` }
|
|
68
|
+
finally { if (request === controller) { request = undefined; if (!disposed) timer = setTimeout(() => refresh(), dirty ? 0 : 5000) } }
|
|
69
|
+
}
|
|
70
|
+
function visibility() { if (document.hidden) { clearTimeout(timer); request?.abort() } else void refresh() }
|
|
71
|
+
document.addEventListener('visibilitychange', visibility)
|
|
72
|
+
window.addEventListener('online', visibility)
|
|
73
|
+
const unsubscribeChanges = subscribeWritebackChanges('management-web', () => { if (request || busy) dirty = true; else void refresh() })
|
|
74
|
+
void refresh()
|
|
75
|
+
return { root, dispose() { disposed = true; clearTimeout(timer); request?.abort(); unsubscribeChanges(); document.removeEventListener('visibilitychange', visibility); window.removeEventListener('online', visibility) } }
|
|
76
|
+
}
|