@lemoncat7/dsh-knowledge 2.8.1 → 2.9.1

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.
Files changed (43) hide show
  1. package/README.md +10 -0
  2. package/lib/api.d.ts.map +1 -1
  3. package/lib/api.js +16 -2
  4. package/lib/api.js.map +1 -1
  5. package/lib/client.d.ts.map +1 -1
  6. package/lib/client.js +36 -35
  7. package/lib/client.js.map +2 -2
  8. package/lib/documents/markdown.d.ts +1 -0
  9. package/lib/documents/markdown.d.ts.map +1 -1
  10. package/lib/documents/markdown.js +1 -0
  11. package/lib/documents/markdown.js.map +1 -1
  12. package/lib/domain.d.ts +4 -0
  13. package/lib/domain.d.ts.map +1 -1
  14. package/lib/domain.js +12 -0
  15. package/lib/domain.js.map +1 -1
  16. package/lib/local-provider.d.ts +1 -0
  17. package/lib/local-provider.d.ts.map +1 -1
  18. package/lib/local-provider.js +37 -7
  19. package/lib/local-provider.js.map +1 -1
  20. package/lib/provider.d.ts +1 -0
  21. package/lib/provider.d.ts.map +1 -1
  22. package/lib/remote-provider.d.ts +1 -0
  23. package/lib/remote-provider.d.ts.map +1 -1
  24. package/lib/remote-provider.js +3 -0
  25. package/lib/remote-provider.js.map +1 -1
  26. package/lib/storage/migrations.d.ts.map +1 -1
  27. package/lib/storage/migrations.js +16 -1
  28. package/lib/storage/migrations.js.map +1 -1
  29. package/lib/web.js +1 -1
  30. package/lib/web.js.map +1 -1
  31. package/lib/writeback/control.d.ts.map +1 -1
  32. package/lib/writeback/control.js +4 -1
  33. package/lib/writeback/control.js.map +1 -1
  34. package/lib/writeback/queue.d.ts +1 -1
  35. package/lib/writeback/queue.d.ts.map +1 -1
  36. package/lib/writeback/queue.js +7 -2
  37. package/lib/writeback/queue.js.map +1 -1
  38. package/package.json +1 -1
  39. package/web/app.js +29 -17
  40. package/web/base-groups.js +99 -0
  41. package/web/styles.css +62 -2
  42. package/web/ui-primitives.js +1 -0
  43. package/web/writeback-workspace.js +10 -2
@@ -0,0 +1,99 @@
1
+ /** Display-only grouping. Knowledge contents, mounts and recall are untouched. */
2
+ const nameCollator = new Intl.Collator('zh-CN', { numeric: true, sensitivity: 'base' })
3
+ export const knowledgeBasePathLabel = base => `${base.group || '未分组'} / ${base.name}`
4
+ export function sortKnowledgeBasesByGroup(bases) {
5
+ return [...bases].sort((a, b) => {
6
+ const left = a.group || '', right = b.group || ''
7
+ if (Boolean(left) !== Boolean(right)) return left ? -1 : 1
8
+ return nameCollator.compare(left, right) || nameCollator.compare(a.name, b.name) || String(a.id).localeCompare(String(b.id))
9
+ })
10
+ }
11
+
12
+ export function groupKnowledgeBases(bases) {
13
+ const groups = new Map()
14
+ for (const base of bases) {
15
+ const name = base.group || ''
16
+ if (!groups.has(name)) groups.set(name, [])
17
+ groups.get(name).push(base)
18
+ }
19
+ return [...groups].sort(([a], [b]) => !a ? 1 : !b ? -1 : a.localeCompare(b, 'zh-CN'))
20
+ }
21
+
22
+ export function createBaseGroups({ element, actionButton, interfaceIcon, openSheet, formField, api, getBases, refresh, showToast, storageKey }) {
23
+ let collapsed = new Set()
24
+ try {
25
+ const saved = JSON.parse(localStorage.getItem(storageKey) || '[]')
26
+ if (Array.isArray(saved)) collapsed = new Set(saved.filter(value => typeof value === 'string'))
27
+ } catch {}
28
+ const remember = () => { try { localStorage.setItem(storageKey, JSON.stringify([...collapsed])) } catch {} }
29
+
30
+ function render(bases, renderCard, searching = false, scope = 'active') {
31
+ return element('div', { class: 'base-groups' }, groupKnowledgeBases(bases).map(([name, members]) => {
32
+ const key = JSON.stringify([scope, name])
33
+ let expanded = searching || !collapsed.has(key)
34
+ const body = element('div', { class: 'base-grid base-group-body', id: `base-group-${scope}-${encodeURIComponent(name)}` })
35
+ const toggle = element('button', {
36
+ type: 'button', class: 'base-group-toggle', 'aria-expanded': String(expanded), 'aria-controls': body.id,
37
+ onClick: () => {
38
+ expanded = !expanded
39
+ if (expanded) collapsed.delete(key); else collapsed.add(key)
40
+ remember(); update()
41
+ },
42
+ }, interfaceIcon('chevron-right', 'base-group-chevron'),
43
+ element('span', { class: 'base-group-name', title: name || '未分组' }, name || '未分组'),
44
+ element('span', { class: 'summary-count' }, members.length))
45
+ const update = () => {
46
+ toggle.setAttribute('aria-expanded', String(expanded))
47
+ body.hidden = !expanded
48
+ // Closed groups do not mount card DOM or animation observers.
49
+ body.replaceChildren(...(expanded ? members.map(renderCard) : []))
50
+ }
51
+ update()
52
+ return element('section', { class: 'base-group', 'aria-label': name || '未分组' },
53
+ element('div', { class: 'base-group-heading' }, toggle,
54
+ name ? actionButton('重命名', () => edit(name), 'ghost small', { 'aria-label': `重命名分组 ${name}` }) : null), body)
55
+ }))
56
+ }
57
+
58
+ function edit(current) {
59
+ const renaming = typeof current === 'string'
60
+ const bases = getBases()
61
+ const name = formField('分组名称', 'input', current || '', { id: 'knowledge-group-name', required: true, maxlength: 64, placeholder: '例如:家里助手、工作、个人' })
62
+ name.wrapper.querySelector('label').htmlFor = name.input.id
63
+ const selection = new Map()
64
+ const error = element('p', { role: 'alert', class: 'base-group-error' })
65
+ const form = element('form', { class: 'base-group-form', onSubmit: event => event.preventDefault() }, name.wrapper)
66
+ if (!renaming) {
67
+ form.append(element('p', {}, '选择要归入这个分组的知识库;原有内容和挂载保持不变。'))
68
+ form.append(element('div', { class: 'base-group-picker' }, bases.map(base => {
69
+ const checkbox = element('input', { type: 'checkbox', value: base.id })
70
+ selection.set(base.id, checkbox)
71
+ return element('label', { class: 'check-option' }, checkbox,
72
+ element('span', {}, element('strong', {}, base.name), element('small', {}, `${base.group || '未分组'}${base.status === 'archived' ? ' · 已归档' : ''}`)))
73
+ })))
74
+ }
75
+ form.append(error)
76
+ openSheet({
77
+ title: renaming ? '重命名分组' : '新建分组',
78
+ description: '每个知识库属于一个分组;没有知识库的分组自动消失。',
79
+ body: form, primaryLabel: renaming ? '保存分组' : '创建分组',
80
+ onPrimary: async () => {
81
+ name.input.setCustomValidity(name.input.value.trim() ? '' : '请输入分组名称')
82
+ if (!form.reportValidity()) return false
83
+ const ids = renaming ? bases.filter(base => base.group === current).map(base => base.id)
84
+ : [...selection].filter(([, input]) => input.checked).map(([id]) => id)
85
+ error.textContent = ''
86
+ if (!ids.length) { error.textContent = '请至少选择一个知识库'; return false }
87
+ await api('knowledge-bases/group', { method: 'POST', body: { ids, group: name.input.value.trim() } })
88
+ collapsed.delete(JSON.stringify(['active', name.input.value.trim()]))
89
+ remember()
90
+ await refresh()
91
+ showToast(renaming ? '分组已更新;同名分组会合并显示。' : '知识库已分组。')
92
+ return true
93
+ },
94
+ })
95
+ name.input.addEventListener('input', () => name.input.setCustomValidity(''))
96
+ name.input.focus()
97
+ }
98
+ return { render, edit }
99
+ }
package/web/styles.css CHANGED
@@ -722,6 +722,32 @@ html,
722
722
  .knowledge-base-toolbar { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; }
723
723
  .base-search { max-width: 560px; }
724
724
  .base-result-summary { margin-left: auto; color: var(--text-tertiary); font-size: 12px; white-space: nowrap; }
725
+ .base-heading-actions { display: flex; flex-wrap: wrap; gap: 8px; }
726
+ .base-groups { display: grid; gap: 18px; min-width: 0; }
727
+ .base-group { min-width: 0; }
728
+ .base-group-heading { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; border-bottom: 1px solid var(--separator); }
729
+ .base-group-toggle { display: flex; flex: 1; align-items: center; gap: 10px; min-width: 0; min-height: 44px; padding: 8px 4px; border: 0; border-radius: var(--radius-small); background: transparent; color: var(--text); cursor: pointer; text-align: left; font: inherit; font-weight: 600; }
730
+ .base-group-toggle:hover { background: var(--material-control-hover); }
731
+ .base-group-toggle:focus-visible { outline: 2px solid var(--text-secondary); outline-offset: 2px; }
732
+ .base-group-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
733
+ .base-group-chevron { flex: none; width: 16px; height: 16px; transition: transform 180ms ease; }
734
+ .base-group-toggle[aria-expanded="true"] .base-group-chevron { transform: rotate(90deg); }
735
+ .base-group-body[hidden] { display: none; }
736
+ .base-group-form { display: grid; gap: 16px; min-width: 0; }
737
+ .base-group-form p { margin: 0; color: var(--text-secondary); }
738
+ .base-group-form .base-group-error { color: var(--danger); }
739
+ .base-group-error:empty { display: none; }
740
+ .mount-list-title .mount-base-path { min-width: 0; white-space: normal; overflow-wrap: anywhere; }
741
+ .base-group-picker { display: grid; gap: 8px; max-height: min(45dvh, 380px); overflow-y: auto; }
742
+ .base-group-picker .check-option { min-width: 0; min-height: 44px; }
743
+ .base-group-picker .check-option span { min-width: 0; overflow-wrap: anywhere; }
744
+ @media (prefers-reduced-motion: reduce) { .base-group-chevron { transition: none; } }
745
+ @media (max-width: 640px) {
746
+ .library-management > .section-heading { align-items: stretch; flex-direction: column; }
747
+ .base-heading-actions { width: 100%; }
748
+ .base-group-heading > .button { min-height: 44px; }
749
+ .knowledge-base-toolbar .base-search { flex: 0 0 auto; }
750
+ }
725
751
  .base-grid {
726
752
  display: grid;
727
753
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 390px), 1fr));
@@ -778,7 +804,7 @@ html,
778
804
  .mount-manager-toolbar > .select { width: auto; min-width: 130px; }
779
805
  .mount-target-line { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; border-bottom: 1px solid var(--border); padding: 9px 14px; background: var(--surface-soft); color: var(--text-secondary); font-size: 12px; }
780
806
  .mount-target-line code { overflow: hidden; color: var(--text-tertiary); font: inherit; text-overflow: ellipsis; white-space: nowrap; }
781
- .mount-table { max-height: min(54vh, 620px); overflow: auto; }
807
+ .mount-table { max-height: min(54vh, 620px); overflow: auto; contain: layout paint; }
782
808
  .mount-list-row { min-width: 0; display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 11px; border-bottom: 1px solid var(--border); padding: 12px 14px; transition: background .15s ease; }
783
809
  .mount-list-row:last-child { border-bottom: 0; }
784
810
  .mount-list-row:hover { background: var(--surface-hover); }
@@ -1785,7 +1811,7 @@ html,
1785
1811
  .library-detail-actions { align-self: center; }
1786
1812
  .library-detail .note-workspace { height: calc(100dvh - 260px); }
1787
1813
  .knowledge-base-toolbar { align-items: stretch; flex-direction: column; gap: 8px; }
1788
- .base-search { max-width: none; }
1814
+ .knowledge-base-toolbar .base-search { max-width: none; flex: 0 0 auto; }
1789
1815
  .base-result-summary { margin-left: 0; }
1790
1816
  .skeleton-grid { grid-template-columns: 1fr; }
1791
1817
  .skeleton-block { height: 96px; }
@@ -2035,6 +2061,40 @@ html,
2035
2061
  .dialog-body { padding-top: 14px; }
2036
2062
  }
2037
2063
 
2064
+ /* Management pages own one bounded content scroller. The translucent header
2065
+ stays outside it, so rows never paint underneath the title. */
2066
+ .app-shell:is([data-view="bases"], [data-view="writeback"]) .main {
2067
+ height: calc(100dvh - var(--chrome-inset) * 2);
2068
+ min-height: 0;
2069
+ display: flex;
2070
+ flex-direction: column;
2071
+ overflow: hidden;
2072
+ }
2073
+ .app-shell:is([data-view="bases"], [data-view="writeback"]) .topbar {
2074
+ position: relative;
2075
+ flex: none;
2076
+ }
2077
+ .app-shell:is([data-view="bases"], [data-view="writeback"]) .page {
2078
+ flex: 1 1 auto;
2079
+ min-height: 0;
2080
+ contain: layout paint;
2081
+ overflow-x: hidden;
2082
+ overflow-y: auto;
2083
+ overscroll-behavior-y: contain;
2084
+ padding-bottom: max(20px, env(safe-area-inset-bottom));
2085
+ }
2086
+ .app-shell:is([data-view="bases"], [data-view="writeback"]) .view-stage {
2087
+ min-height: 0;
2088
+ }
2089
+ .writeback-workspace .button[aria-pressed="true"] {
2090
+ background: var(--material-control);
2091
+ box-shadow: var(--shadow-control);
2092
+ color: var(--text);
2093
+ }
2094
+ @media (max-width: 1120px), (hover: none) and (pointer: coarse) and (max-width: 1400px) {
2095
+ .app-shell:is([data-view="bases"], [data-view="writeback"]) .main { height: 100dvh; }
2096
+ }
2097
+
2038
2098
  @media (prefers-reduced-motion: reduce) {
2039
2099
  *, *::before, *::after { scroll-behavior: auto !important; animation: none !important; transition: none !important; }
2040
2100
  }
@@ -47,6 +47,7 @@ export function paneToggleButton(pane, visible, onClick, label) {
47
47
  export function interfaceIcon(name, className = 'interface-icon') {
48
48
  const paths = {
49
49
  close: 'M6 6l12 12M18 6L6 18',
50
+ 'chevron-right': 'M9 5l7 7-7 7',
50
51
  menu: 'M4 6h16M4 12h16M4 18h16',
51
52
  search: 'M10.8 4.5a6.3 6.3 0 1 0 0 12.6 6.3 6.3 0 0 0 0-12.6Zm4.6 11 4.1 4',
52
53
  more: 'M5 12h.01M12 12h.01M19 12h.01',
@@ -3,6 +3,12 @@ import { subscribeWritebackChanges } from './writeback-live.js'
3
3
  export function createWritebackWorkspace({ element, actionButton, openConfirm, sessionId = '' }) {
4
4
  const labels = { queued: '排队中', running: '回写中', failed: '失败', completed: '已完成', cancelled: '已取消' }
5
5
  let disposed = false, timer, request, offset = 0, busy = false, previous = '', dirty = false
6
+ let failedOnly = false
7
+ const failedButton = actionButton('只看失败', () => {
8
+ failedOnly = !failedOnly
9
+ failedButton.setAttribute('aria-pressed', String(failedOnly))
10
+ offset = 0; previous = ''; void refresh()
11
+ }, 'small', { 'aria-pressed': 'false' })
6
12
  const message = element('p', { role: 'status' })
7
13
  const list = element('div', { class: 'writeback-job-list' })
8
14
  const page = element('span', {})
@@ -12,6 +18,7 @@ export function createWritebackWorkspace({ element, actionButton, openConfirm, s
12
18
  const root = element('section', { class: 'writeback-workspace' },
13
19
  element('p', {}, '本机回写队列。同一会话按顺序执行;网络中断会自动重试,连续失败可手动重试或取消。取消不会撤销已经写入的内容。'),
14
20
  element('div', { class: 'writeback-toolbar' }, filter, actionButton('筛选', () => { sessionId = filter.value.trim(); offset = 0; previous = ''; void refresh() }, 'small'), actionButton('刷新', () => { void refresh() }, 'ghost small')),
21
+ element('div', { class: 'writeback-toolbar' }, failedButton),
15
22
  message, list, element('div', { class: 'writeback-toolbar' }, previousButton, page, nextButton))
16
23
  async function fetchJson(params, method = 'GET', signal) {
17
24
  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) })
@@ -53,11 +60,12 @@ export function createWritebackWorkspace({ element, actionButton, openConfirm, s
53
60
  const controller = new AbortController()
54
61
  request = controller
55
62
  try {
56
- const data = await fetchJson(new URLSearchParams({ sessionId, offset: String(offset) }), 'GET', controller.signal)
63
+ const data = await fetchJson(new URLSearchParams({ sessionId, offset: String(offset), ...(failedOnly ? { status: 'failed' } : {}) }), 'GET', controller.signal)
57
64
  if (disposed || request !== controller) return
65
+ if (offset > 0 && offset >= data.total) { offset = Math.max(0, Math.floor((data.total - 1) / 50) * 50); void refresh(preserveMessage); return }
58
66
  const fingerprint = JSON.stringify(data)
59
67
  if (fingerprint !== previous) {
60
- list.replaceChildren(...(data.items.length ? data.items.map(renderJob) : [element('p', {}, '没有回写任务')]))
68
+ list.replaceChildren(...(data.items.length ? data.items.map(renderJob) : [element('p', {}, failedOnly ? '当前范围没有失败的回写任务' : '没有回写任务')]))
61
69
  previous = fingerprint
62
70
  }
63
71
  page.textContent = `共 ${data.total} 条 · 第 ${Math.floor(offset / 50) + 1} 页`