@lemoncat7/dsh-knowledge 2.9.6 → 2.10.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.
Files changed (63) hide show
  1. package/README.md +7 -1
  2. package/docs/document-groups.md +22 -0
  3. package/docs/releases/2.10.0.md +17 -0
  4. package/lib/api.d.ts.map +1 -1
  5. package/lib/api.js +22 -1
  6. package/lib/api.js.map +1 -1
  7. package/lib/client.js +33 -10
  8. package/lib/client.js.map +3 -3
  9. package/lib/document-group-tools.d.ts +6 -0
  10. package/lib/document-group-tools.d.ts.map +1 -0
  11. package/lib/document-group-tools.js +96 -0
  12. package/lib/document-group-tools.js.map +1 -0
  13. package/lib/document-groups.d.ts +8 -0
  14. package/lib/document-groups.d.ts.map +1 -0
  15. package/lib/document-groups.js +18 -0
  16. package/lib/document-groups.js.map +1 -0
  17. package/lib/documents/markdown.d.ts +1 -0
  18. package/lib/documents/markdown.d.ts.map +1 -1
  19. package/lib/documents/markdown.js +2 -0
  20. package/lib/documents/markdown.js.map +1 -1
  21. package/lib/domain.d.ts +6 -0
  22. package/lib/domain.d.ts.map +1 -1
  23. package/lib/domain.js +2 -0
  24. package/lib/domain.js.map +1 -1
  25. package/lib/extraction.d.ts.map +1 -1
  26. package/lib/extraction.js +16 -4
  27. package/lib/extraction.js.map +1 -1
  28. package/lib/knowledge-activity-panel.d.ts.map +1 -1
  29. package/lib/knowledge-activity-panel.js +23 -1
  30. package/lib/knowledge-activity-panel.js.map +1 -1
  31. package/lib/local-provider.d.ts +6 -0
  32. package/lib/local-provider.d.ts.map +1 -1
  33. package/lib/local-provider.js +94 -19
  34. package/lib/local-provider.js.map +1 -1
  35. package/lib/note-excerpt.d.ts +1 -0
  36. package/lib/note-excerpt.d.ts.map +1 -1
  37. package/lib/note-excerpt.js.map +1 -1
  38. package/lib/provider.d.ts +3 -0
  39. package/lib/provider.d.ts.map +1 -1
  40. package/lib/remote-provider.d.ts +2 -0
  41. package/lib/remote-provider.d.ts.map +1 -1
  42. package/lib/remote-provider.js +6 -0
  43. package/lib/remote-provider.js.map +1 -1
  44. package/lib/retrieval.d.ts +1 -0
  45. package/lib/retrieval.d.ts.map +1 -1
  46. package/lib/retrieval.js +2 -1
  47. package/lib/retrieval.js.map +1 -1
  48. package/lib/storage/migrations.d.ts.map +1 -1
  49. package/lib/storage/migrations.js +18 -1
  50. package/lib/storage/migrations.js.map +1 -1
  51. package/lib/tools.d.ts.map +1 -1
  52. package/lib/tools.js +3 -0
  53. package/lib/tools.js.map +1 -1
  54. package/lib/tracking.d.ts.map +1 -1
  55. package/lib/tracking.js +5 -0
  56. package/lib/tracking.js.map +1 -1
  57. package/lib/web.js +1 -1
  58. package/lib/web.js.map +1 -1
  59. package/package.json +1 -1
  60. package/web/app.js +88 -12
  61. package/web/document-groups.js +99 -0
  62. package/web/note-excerpt.js +13 -3
  63. package/web/styles.css +24 -0
package/web/app.js CHANGED
@@ -3,6 +3,7 @@ const AUTH_MODE = document.querySelector('meta[name="dsh-knowledge-auth-mode"]')
3
3
  const WEB_PATH = document.querySelector('meta[name="dsh-knowledge-web"]')?.content || '/knowledge'
4
4
  const ASSET_VERSION = document.querySelector('meta[name="dsh-knowledge-asset-version"]')?.content || ''
5
5
  const moduleUrl = name => `./${name}.js${ASSET_VERSION ? `?v=${encodeURIComponent(ASSET_VERSION)}` : ''}`
6
+ const { createDocumentGroupField, renderDocumentGroups, openDocumentGroupOrganizer } = await import(moduleUrl('document-groups'))
6
7
  const [apiModule, themeModule, uiModule, modelCatalogModule, dialogModule, selectModule, documentActionsModule] = await Promise.all([
7
8
  import(moduleUrl('api-client')),
8
9
  import(moduleUrl('host-theme')),
@@ -55,6 +56,7 @@ function createDocumentViewState(overrides = {}) {
55
56
  return {
56
57
  knowledgeBaseId: '', documentId: '', query: '', mode: 'preview', treeOpen: false,
57
58
  expandedBases: new Set(), documentPages: new Map(),
59
+ collapsedGroups: new Set(),
58
60
  searchResults: [], searchNextCursor: '', searchTotal: 0, searchLoading: false, searchError: '', searchRequest: 0,
59
61
  editor: null, editorLoading: false, loadingDocumentId: '', ...overrides,
60
62
  }
@@ -736,7 +738,7 @@ async function loadDocumentEditor(workspace, id, signal) {
736
738
  return true
737
739
  }
738
740
 
739
- function createBlankDocument(workspace, baseId) {
741
+ function createBlankDocument(workspace, baseId, group) {
740
742
  const base = state.knowledgeBases.find(item => item.id === baseId && item.status === 'active')
741
743
  if (!base) return showToast('请先选择一个可用知识库。', 'error')
742
744
  const view = workspace.view
@@ -747,17 +749,28 @@ function createBlankDocument(workspace, baseId) {
747
749
  view.expandedBases.add(base.id)
748
750
  view.editor = {
749
751
  id: '', knowledgeBaseId: base.id, title: '', body: '', type: 'fact', tags: [], tagsText: '',
752
+ group,
750
753
  scope: { kind: 'global' }, confidence: .8, noteReferences: [], dirty: true, isNew: true, saveState: '新文档',
751
754
  }
752
755
  renderShell()
753
756
  document.querySelector('.note-title-input')?.focus()
754
757
  }
755
758
 
756
- async function startBlankDocument(workspace, baseId) {
759
+ async function startBlankDocument(workspace, baseId, group) {
757
760
  const editor = workspace.view.editor
758
761
  const emptyDraft = editor?.isNew && !editor.title.trim() && !editor.body.trim()
759
762
  if (editor?.dirty && !emptyDraft && !await saveDocumentEditor(workspace)) return
760
- createBlankDocument(workspace, baseId)
763
+ if (!baseId) return showToast('请先选择知识库。', 'error')
764
+ if (group) return createBlankDocument(workspace, baseId, group)
765
+ const field = createDocumentGroupField({ element, api, baseId, required: true })
766
+ openSheet({ title: '新建知识文档', description: '先选择文档分组,优先复用已有分组。', body: field.wrapper, primaryLabel: '开始编写', onPrimary: async () => {
767
+ await field.ready
768
+ let name
769
+ try { name = field.validate() } catch { return false }
770
+ if (!name || name === '未分组') throw new Error('请选择已有分组,或填写新分组名称。')
771
+ createBlankDocument(workspace, baseId, name)
772
+ return true
773
+ } })
761
774
  }
762
775
 
763
776
  async function selectDocument(workspace, id) {
@@ -786,6 +799,7 @@ async function selectDocument(workspace, id) {
786
799
  function editorDraft(editor) {
787
800
  return {
788
801
  knowledgeBaseId: editor.knowledgeBaseId,
802
+ ...(editor.group === undefined ? {} : { group: editor.group }),
789
803
  title: editor.title.trim(),
790
804
  body: editor.body.trim(),
791
805
  type: editor.type,
@@ -809,6 +823,10 @@ async function saveDocumentEditor(workspace = activeDocumentWorkspace()) {
809
823
  showToast('标题和正文填写完整后才能保存。', 'error')
810
824
  return false
811
825
  }
826
+ if (editor.isNew && (!editor.group?.trim() || editor.group === '未分组')) {
827
+ showToast('新建知识文档必须指定分组。', 'error')
828
+ return false
829
+ }
812
830
  editor.saveState = '正在保存…'
813
831
  editor.saving = true
814
832
  updateEditorSaveState(editor.saveState)
@@ -1635,13 +1653,45 @@ function renderEntries() {
1635
1653
  return renderDocumentWorkspace(sessionDocumentWorkspace(), { heading: '知识目录' })
1636
1654
  }
1637
1655
 
1656
+ async function applyDocumentGroup(workspace, baseId, ids, group) {
1657
+ if (workspace.view.editor?.dirty && !await saveDocumentEditor(workspace)) throw new Error('请先保存当前文档,再调整分组。')
1658
+ await api('document-groups', { method: 'POST', body: { knowledgeBaseId: baseId, ids, group } })
1659
+ await reloadDocumentWorkspace(workspace)
1660
+ if (workspace.view.documentId) await loadDocumentEditor(workspace, workspace.view.documentId)
1661
+ workspace.view.collapsedGroups.delete(JSON.stringify([baseId, group]))
1662
+ renderShell()
1663
+ showToast('文档分组已更新。')
1664
+ }
1665
+
1666
+ async function dropDocumentIntoGroup(event, workspace, baseId, group) {
1667
+ event.preventDefault()
1668
+ const drag = knowledgeDocumentDrag
1669
+ clearKnowledgeDocumentDragState()
1670
+ if (!drag || drag.sourceBaseId !== baseId) return showToast('分组内拖动仅用于同一知识库;跨库请拖到知识库名称。', 'error')
1671
+ try { await applyDocumentGroup(workspace, baseId, [drag.documentId], group) }
1672
+ catch (error) { showToast(friendlyError(error), 'error') }
1673
+ }
1674
+
1675
+ function openDocumentGroupEditor(workspace, editor) {
1676
+ const field = createDocumentGroupField({ element, api, baseId: editor.knowledgeBaseId, value: editor.group || '', required: editor.isNew })
1677
+ openSheet({ title: '文档分组', description: '修改分组不会移动文件或更改文档引用。', body: field.wrapper, primaryLabel: '保存分组', onPrimary: async () => {
1678
+ await field.ready
1679
+ field.validate()
1680
+ if (editor.isNew) {
1681
+ if (!field.value() || field.value() === '未分组') throw new Error('新文档必须选择有效分组。')
1682
+ editor.group = field.value(); editor.dirty = true; renderShell()
1683
+ } else await applyDocumentGroup(workspace, editor.knowledgeBaseId, [editor.id], field.value())
1684
+ return true
1685
+ } })
1686
+ }
1687
+
1638
1688
  function renderDocumentWorkspace(workspace, options = {}) {
1639
1689
  const view = workspace.view
1640
1690
  const query = view.query.trim().toLocaleLowerCase()
1641
1691
  const activeBases = documentWorkspaceBases(workspace)
1642
1692
  const workspaceDocuments = documentWorkspaceDocuments(workspace)
1643
1693
  const readOnly = documentWorkspaceReadOnly(workspace)
1644
- const canOrganize = !readOnly && activeBases.length > 1
1694
+ const canOrganize = !readOnly
1645
1695
  const selectedBase = activeBases.find(base => base.id === view.knowledgeBaseId)
1646
1696
  const search = element('input', {
1647
1697
  class: 'note-tree-search', type: 'search', value: view.query, placeholder: '搜索文档', 'aria-label': '搜索知识库文档',
@@ -1687,11 +1737,11 @@ function renderDocumentWorkspace(workspace, options = {}) {
1687
1737
  !query && page.error ? element('div', { class: 'note-tree-status is-error' },
1688
1738
  element('span', {}, page.error),
1689
1739
  actionButton('重试', () => { void loadDocumentPage(workspace, base.id, { reset: true }).then(renderShell) }, 'ghost small')) : null,
1690
- documents.map(document => element('button', {
1740
+ renderDocumentGroups({ element, documents, baseId: base.id, collapsed: view.collapsedGroups, searching: Boolean(query), onNew: readOnly ? undefined : group => { void startBlankDocument(workspace, base.id, group) }, onDrop: readOnly ? undefined : (event, group) => { void dropDocumentIntoGroup(event, workspace, base.id, group) }, renderRow: document => element('button', {
1691
1741
  type: 'button', class: 'note-tree-document', 'aria-current': document.id === view.documentId ? 'page' : undefined,
1692
1742
  draggable: canOrganize && movingDocumentId !== document.id ? 'true' : undefined,
1693
1743
  'aria-busy': movingDocumentId === document.id ? 'true' : undefined,
1694
- title: canOrganize ? `${document.title} · 拖到其他知识库以移动` : document.title,
1744
+ title: canOrganize ? `${document.title} · 可拖到分组或其他知识库` : document.title,
1695
1745
  'data-document-id': document.id,
1696
1746
  onDragStart: event => {
1697
1747
  knowledgeDocumentDrag = { documentId: document.id, sourceBaseId: base.id, workspaceKind: workspace.kind }
@@ -1703,7 +1753,7 @@ function renderDocumentWorkspace(workspace, options = {}) {
1703
1753
  onClick: () => { view.knowledgeBaseId = base.id; void selectDocument(workspace, document.id) },
1704
1754
  }, element('span', { class: 'tree-document-icon', 'aria-hidden': 'true' }), element('span', { class: 'tree-document-copy' },
1705
1755
  element('strong', { title: document.title }, document.title), element('small', { title: document.relPath }, document.relPath)),
1706
- document.documentState !== 'open' ? badge(DOCUMENT_STATE_LABELS[document.documentState] || '已结束', 'success') : null)),
1756
+ document.documentState !== 'open' ? badge(DOCUMENT_STATE_LABELS[document.documentState] || '已结束', 'success') : null) }),
1707
1757
  !query && page.nextCursor ? element('button', {
1708
1758
  type: 'button', class: 'note-tree-more', disabled: page.loading,
1709
1759
  onClick: () => { void loadDocumentPage(workspace, base.id, { append: true }).then(renderShell) },
@@ -1735,7 +1785,8 @@ function renderDocumentWorkspace(workspace, options = {}) {
1735
1785
  element('div', {}, element('h2', { id: `${workspace.kind}-documents-heading` }, options.heading || '知识目录'), element('span', {}, query ? `${view.searchTotal} 条搜索结果` : `${workspaceDocuments.length} 篇已加载`)),
1736
1786
  !readOnly ? actionButton('+', () => { void startBlankDocument(workspace, view.knowledgeBaseId || activeBases[0]?.id) }, 'ghost note-add-button', { 'aria-label': '新建文档', title: '新建文档' }) : null,
1737
1787
  ),
1738
- element('div', { class: 'note-tree-search-wrap' }, interfaceIcon('search', 'search-symbol'), search),
1788
+ element('div', {}, element('div', { class: 'note-tree-search-wrap' }, interfaceIcon('search', 'search-symbol'), search),
1789
+ !readOnly && view.knowledgeBaseId ? actionButton('整理分组', () => { const baseId = view.knowledgeBaseId; void openDocumentGroupOrganizer({ element, api, openSheet, baseId, onApply: (ids, group) => applyDocumentGroup(workspace, baseId, ids, group) }) }, 'ghost small document-groups-manage') : null),
1739
1790
  element('nav', { class: 'note-tree', 'data-scroll-key': `${workspace.kind}-note-tree` }, treeContent),
1740
1791
  workspace.kind === 'session' ? element('footer', { class: 'note-tree-footer' }, actionButton('新建知识库', () => openKnowledgeBaseEditor(), 'ghost small')) : null,
1741
1792
  ),
@@ -1826,6 +1877,7 @@ function renderNoteEditor(workspace, editor, base) {
1826
1877
  ),
1827
1878
  renderDocumentReferenceBar(workspace, editor, editable),
1828
1879
  element('footer', { class: 'note-inspector' },
1880
+ readOnly ? element('span', { class: 'note-format-hint' }, `分组:${editor.group || '未分组'}`) : actionButton(`分组:${editor.group || '未分组'}`, () => openDocumentGroupEditor(workspace, editor), 'ghost small document-group-control', { title: editor.group || '未分组', 'aria-label': '调整文档分组' }),
1829
1881
  finalized || readOnly ? element('span', { class: 'note-format-hint' }, readOnly ? '知识库已归档 · 只读' : '只读封存 · 重新打开后才能编辑') : element('label', {}, element('span', {}, '类型'), element('select', {
1830
1882
  class: 'note-meta-select', onChange: event => update('type', event.target.value),
1831
1883
  }, TYPES.map(type => element('option', { value: type, selected: type === editor.type }, TYPE_LABELS[type])))),
@@ -3677,8 +3729,9 @@ function renderCandidates() {
3677
3729
  function renderCandidateCard(candidate) {
3678
3730
  const pending = candidate.status === 'pending'
3679
3731
  const target = candidate.targetId ? state.candidateTargets.get(candidate.targetId) : null
3680
- const targetAvailable = candidate.action === 'create' || Boolean(target && (candidate.change?.kind !== 'finalize'
3681
- || target.documentState === 'open' && target.version === candidate.change.baseVersion))
3732
+ const targetAvailable = candidate.action === 'create' || Boolean(target && (candidate.change?.kind === 'group'
3733
+ ? target.version === candidate.change.baseVersion
3734
+ : candidate.change?.kind !== 'finalize' || target.documentState === 'open' && target.version === candidate.change.baseVersion))
3682
3735
  const action = candidatePrimaryAction(candidate)
3683
3736
  return element('article', { class: 'candidate' },
3684
3737
  element('div', { class: 'candidate-header' },
@@ -3700,7 +3753,7 @@ function renderCandidateCard(candidate) {
3700
3753
  element('strong', {}, '写入位置'),
3701
3754
  element('span', { class: 'candidate-target' }, candidate.action === 'create'
3702
3755
  ? `创建“${candidate.draft.title}”`
3703
- : target ? `${candidate.change?.kind === 'finalize' ? '结束整篇' : candidate.change?.kind === 'revise' ? '修订' : '补充到'}“${target.title}”` : `目标 ${candidate.targetId || '不可用'}`),
3756
+ : target ? `${candidate.change?.kind === 'group' ? '调整分组:' : candidate.change?.kind === 'finalize' ? '结束整篇' : candidate.change?.kind === 'revise' ? '修订' : '补充到'}“${target.title}”` : `目标 ${candidate.targetId || '不可用'}`),
3704
3757
  ),
3705
3758
  renderCandidateMetadataChanges(candidate, target),
3706
3759
  element('section', {},
@@ -3717,7 +3770,7 @@ function renderCandidateCard(candidate) {
3717
3770
  element('small', {}, `${scopeLabel(candidate.draft.scope)} · 置信度 ${Math.round(candidate.draft.confidence * 100)}%${candidate.targetId ? ` · 目标 ${candidate.targetId}` : ''} · ${formatDate(candidate.createdAt)}`),
3718
3771
  pending ? element('div', { class: 'candidate-actions' },
3719
3772
  actionButton('拒绝', () => reviewCandidate(candidate, 'reject'), 'danger small'),
3720
- candidate.change?.kind === 'finalize' ? null : actionButton(action.editLabel, () => openEntryEditor(undefined, candidate), 'small', {
3773
+ ['finalize', 'group'].includes(candidate.change?.kind) ? null : actionButton(action.editLabel, () => openEntryEditor(undefined, candidate), 'small', {
3721
3774
  disabled: !targetAvailable,
3722
3775
  title: targetAvailable ? action.editLabel : '目标文档不可用,无法安全编辑合并结果',
3723
3776
  }),
@@ -3731,6 +3784,14 @@ function renderCandidateCard(candidate) {
3731
3784
  }
3732
3785
 
3733
3786
  function renderCandidateDiff(candidate, target) {
3787
+ if (candidate.change?.kind === 'group') {
3788
+ return element('section', { class: 'candidate-change' },
3789
+ element('strong', {}, '调整文档分组'),
3790
+ element('p', {}, `${target?.group || '未分组'} → ${candidate.change.group}`),
3791
+ element('p', {}, '只修改分组,正文、标签、路径和完成状态保持不变。'),
3792
+ target && target.version !== candidate.change.baseVersion ? element('p', { role: 'alert' }, '文档版本已变化,请拒绝后重新提交分组调整。') : null,
3793
+ )
3794
+ }
3734
3795
  if (candidate.change?.kind === 'finalize') {
3735
3796
  return element('section', { class: 'candidate-change', 'aria-label': '文档结束预览' },
3736
3797
  element('strong', {}, `${target?.title || candidate.draft.title} · ${candidate.change.state === 'resolved' ? '标记已解决' : '标记收集完成'}`),
@@ -3818,6 +3879,7 @@ function renderCandidateMetadataChanges(candidate, target) {
3818
3879
  }
3819
3880
 
3820
3881
  function candidatePrimaryAction(candidate) {
3882
+ if (candidate.change?.kind === 'group') return { label: '确认调整分组', manualOnly: candidate.action === 'conflict' }
3821
3883
  if (candidate.change?.kind === 'finalize') return { label: candidate.change.state === 'resolved' ? '确认标记已解决' : '确认收集完成', manualOnly: candidate.action === 'conflict' }
3822
3884
  if (candidate.action === 'create') return { label: '写入新文档', editLabel: '编辑后写入' }
3823
3885
  if (candidate.action === 'conflict' && candidate.change?.kind !== 'revise') {
@@ -4216,6 +4278,13 @@ function openEntryEditor(entry, candidate) {
4216
4278
  const activeBases = state.knowledgeBases.filter(base => base.status === 'active')
4217
4279
  const selectedBaseId = source.knowledgeBaseId || activeBases[0]?.id || 'default'
4218
4280
  const knowledgeBase = selectField('所属知识库', activeBases.map(base => ({ value: base.id, label: base.name })), selectedBaseId)
4281
+ const requiresGroup = !entry && (!candidate || candidate.action === 'create')
4282
+ let groupField = createDocumentGroupField({ element, api, baseId: selectedBaseId, value: candidateTarget?.group ?? source.group ?? '', required: requiresGroup })
4283
+ const groupSlot = element('div', { class: 'span-2' }, groupField.wrapper)
4284
+ knowledgeBase.input.addEventListener('change', () => {
4285
+ groupField = createDocumentGroupField({ element, api, baseId: knowledgeBase.input.value, value: groupField.value(), required: requiresGroup })
4286
+ groupSlot.replaceChildren(groupField.wrapper)
4287
+ })
4219
4288
  const scope = selectField('范围', [{ value: 'global', label: '全局' }, { value: 'project', label: '项目' }], source.scope.kind)
4220
4289
  const project = formField('项目 ID / 路径', 'input', source.scope.kind === 'project' ? source.scope.id : '', { placeholder: '/workspace/project' })
4221
4290
  const tags = formField('标签', 'input', source.tags.join(', '), { placeholder: 'docker, deployment' })
@@ -4230,6 +4299,7 @@ function openEntryEditor(entry, candidate) {
4230
4299
  title.wrapper,
4231
4300
  type.wrapper,
4232
4301
  knowledgeBase.wrapper,
4302
+ groupSlot,
4233
4303
  scope.wrapper,
4234
4304
  scopeProjectField,
4235
4305
  element('div', { class: 'field span-2' }, element('label', {}, '置信度'), element('div', { class: 'range-row' }, confidenceInput, confidenceValue)),
@@ -4249,8 +4319,12 @@ function openEntryEditor(entry, candidate) {
4249
4319
  : '保存文档'
4250
4320
  const modal = openSheet({ title: modeTitle, description: candidate ? candidateDescription : '文档保存后会立即参与后续召回。', body: form, primaryLabel, onPrimary: async () => {
4251
4321
  if (!form.reportValidity()) return false
4322
+ await groupField.ready
4323
+ groupField.validate()
4324
+ if (requiresGroup && (!groupField.value() || groupField.value() === '未分组')) throw new Error('新文档必须选择分组。')
4252
4325
  const draft = {
4253
4326
  knowledgeBaseId: knowledgeBase.input.value,
4327
+ group: groupField.value(),
4254
4328
  title: title.input.value.trim(), body: body.input.value.trim(), type: type.input.value,
4255
4329
  tags: parseTags(tags.input.value),
4256
4330
  scope: scope.input.value === 'global' ? { kind: 'global' } : { kind: 'project', id: project.input.value.trim() },
@@ -4322,6 +4396,8 @@ async function reviewCandidate(candidate, decision, resolution) {
4322
4396
  const title = approve ? `${action.label}?` : '拒绝这条候选?'
4323
4397
  const message = !approve
4324
4398
  ? '拒绝后会保留审核记录,但不会进入知识库。'
4399
+ : candidate.change?.kind === 'group'
4400
+ ? `确认后仅将文档归入“${candidate.change.group}”,不修改正文或完成状态。`
4325
4401
  : candidate.change?.kind === 'finalize'
4326
4402
  ? '确认后整篇文档将结束并停止回写,正文保持不变。如需继续补充,可在文档中重新打开;版本变化时本次确认不会生效。'
4327
4403
  : candidate.action === 'create'
@@ -0,0 +1,99 @@
1
+ /** Document classification controls. No document content or path mutations. */
2
+ export function createDocumentGroupField({ element, api, baseId, value = '', required = false, onChange = () => {} }) {
3
+ const id = `document-group-${crypto.randomUUID()}`
4
+ const newValue = `new-${crypto.randomUUID()}`
5
+ const status = element('small', { class: 'muted', role: 'status' }, '正在读取已有分组…')
6
+ const select = element('select', { id, class: 'select', 'aria-label': '文档分组' }, element('option', { value: '' }, '选择已有分组'))
7
+ const input = element('input', { type: 'text', class: 'input', maxlength: 64, value, placeholder: '例如:部署运维、项目约定', 'aria-label': '新分组名称' })
8
+ input.hidden = true
9
+ let groups = [], current = value
10
+ const notify = () => {
11
+ current = select.value === newValue ? input.value.trim() : select.value
12
+ status.textContent = '优先选择已有分组;没有合适的再新建。'
13
+ status.setAttribute('role', 'status')
14
+ wrapper.querySelectorAll('[aria-invalid]').forEach(control => control.removeAttribute('aria-invalid'))
15
+ onChange(current)
16
+ }
17
+ select.addEventListener('change', () => { input.hidden = select.value !== newValue; notify(); if (!input.hidden) input.focus() })
18
+ input.addEventListener('input', notify)
19
+ const wrapper = element('div', { class: 'document-group-field' }, element('label', { for: id }, `文档分组${required ? ' *' : ''}`), select, input, status)
20
+ const ready = api(`document-groups?${new URLSearchParams({ knowledgeBaseId: baseId })}`).then(result => {
21
+ groups = result.filter(item => item.name)
22
+ select.replaceChildren(element('option', { value: '' }, required ? '请选择分组' : '未分组'),
23
+ ...groups.map(item => element('option', { value: item.name }, `${item.name}(${item.count})`)), element('option', { value: newValue }, '+ 新建分组…'))
24
+ const matched = groups.find(item => item.name.normalize('NFKC').toLocaleLowerCase() === value.normalize('NFKC').trim().toLocaleLowerCase())
25
+ select.value = matched?.name || (value ? newValue : '')
26
+ if (matched) current = matched.name
27
+ input.hidden = select.value !== newValue
28
+ status.textContent = '优先选择已有分组;没有合适的再新建。'
29
+ return groups
30
+ }).catch(error => {
31
+ status.textContent = `读取分组失败:${error.message}。请重新打开后重试。`
32
+ select.disabled = true
33
+ throw error
34
+ })
35
+ // Callers may await ready before saving. Avoid unhandled rejections in inline editors.
36
+ void ready.catch(() => {})
37
+ const validate = () => {
38
+ const name = current.normalize('NFKC').trim().replace(/\s+/gu, ' ')
39
+ const error = required && (!name || name === '未分组') ? '请选择已有分组,或填写新分组名称。'
40
+ : name.length > 64 || /[\u0000-\u001f\u007f]/u.test(current) ? '分组限 64 字,不能包含控制字符。' : ''
41
+ if (error) {
42
+ status.textContent = error; status.setAttribute('role', 'alert')
43
+ const control = input.hidden ? wrapper.querySelector('[role="combobox"]') || select : input
44
+ control.setAttribute('aria-invalid', 'true'); control.focus()
45
+ throw new Error(error)
46
+ }
47
+ return name
48
+ }
49
+ return { wrapper, ready, value: () => current, validate }
50
+ }
51
+
52
+ export function renderDocumentGroups({ element, documents, baseId, collapsed, searching, renderRow, onDrop, onNew }) {
53
+ const groups = new Map()
54
+ for (const doc of documents) { const name = doc.group || ''; if (!groups.has(name)) groups.set(name, []); groups.get(name).push(doc) }
55
+ return [...groups].sort(([a], [b]) => !a ? 1 : !b ? -1 : a.localeCompare(b, 'zh-CN')).map(([name, members]) => {
56
+ const key = JSON.stringify([baseId, name])
57
+ let expanded = searching || !collapsed.has(key)
58
+ const body = element('div', { class: 'document-group-body', role: 'group', 'aria-label': `${name || '未分组'}文档` })
59
+ const toggle = element('button', { type: 'button', class: 'document-group-toggle', 'aria-expanded': String(expanded), onClick: () => {
60
+ expanded = !expanded; if (expanded) collapsed.delete(key); else collapsed.add(key); paint()
61
+ } }, element('span', { class: 'tree-disclosure', 'aria-hidden': 'true' }), element('span', { class: 'tree-folder-icon', 'aria-hidden': 'true' }),
62
+ element('span', { class: 'document-group-name', title: name || '未分组' }, name || '未分组'), element('small', { title: '当前已加载的文档数' }, members.length))
63
+ const paint = () => { toggle.setAttribute('aria-expanded', String(expanded)); body.hidden = !expanded; body.replaceChildren(...(expanded ? members.map(renderRow) : [])) }
64
+ paint()
65
+ return element('section', { class: 'document-tree-group', 'data-document-group': name, onDragOver: event => {
66
+ if (!onDrop || !event.dataTransfer.types.includes('application/x-dsh-knowledge-document-id')) return
67
+ event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = 'move'
68
+ }, onDrop: event => { if (onDrop) { event.stopPropagation(); onDrop(event, name) } } },
69
+ element('div', { class: 'document-group-heading' }, toggle, name && onNew ? element('button', { type: 'button', class: 'button ghost small document-group-add', 'aria-label': `在${name}新建文档`, title: '在此分组新建文档', onClick: () => onNew(name) }, '+') : null), body)
70
+ })
71
+ }
72
+
73
+ export async function openDocumentGroupOrganizer({ element, api, openSheet, baseId, onApply }) {
74
+ const field = createDocumentGroupField({ element, api, baseId })
75
+ const selected = new Set()
76
+ let loaded = false
77
+ const status = element('small', { class: 'muted', role: 'status' }, '正在读取文档…')
78
+ const list = element('div', { class: 'document-group-selection' })
79
+ const controller = new AbortController()
80
+ const body = element('div', { class: 'document-group-field' }, field.wrapper, status, list)
81
+ const dialog = openSheet({ title: '整理文档分组', description: '选择文档并调整分组。选择“未分组”只移除归类,不会删除文档。', body, primaryLabel: '应用分组',
82
+ onClose: () => controller.abort(),
83
+ onPrimary: async () => { await field.ready; if (!loaded) throw new Error('请等待文档加载完成;加载失败请重新打开。'); if (!selected.size) throw new Error('请至少选择一篇文档。'); await onApply([...selected], field.validate()); return true },
84
+ })
85
+ try {
86
+ let cursor, count = 0
87
+ do {
88
+ const page = await api(`document-index?${new URLSearchParams({ knowledgeBaseId: baseId, limit: '100', ...(cursor ? { cursor } : {}) })}`, { signal: controller.signal })
89
+ for (const doc of page.items) {
90
+ const check = element('input', { type: 'checkbox', 'aria-label': doc.title, onChange: event => { if (event.target.checked) selected.add(doc.id); else selected.delete(doc.id) } })
91
+ list.append(element('label', { class: 'document-group-choice' }, check, element('span', {}, element('strong', { title: doc.title }, doc.title), element('small', {}, doc.group || '未分组'))))
92
+ }
93
+ count += page.items.length; cursor = page.nextCursor
94
+ status.textContent = `已加载 ${count} / ${page.total} 篇;每次最多整理 500 篇。`
95
+ } while (cursor && count < 500)
96
+ loaded = true
97
+ } catch (error) { if (!controller.signal.aborted) status.textContent = `读取失败:${error.message}` }
98
+ return dialog
99
+ }
@@ -1,4 +1,5 @@
1
1
  /** Selection is captured before opening the dialog; no editor ownership here. */
2
+ import { createDocumentGroupField } from './document-groups.js'
2
3
  export async function openNoteExcerpt({ node, text, api, element, openSheet, showToast, friendlyError, formField, selectField, knowledgeBasePathLabel }) {
3
4
  if (!text.trim() || text.length > 50000) throw new Error('每次请选择 1 到 50000 字的笔记内容。')
4
5
  const bases = (await api('knowledge-bases')).filter(base => base.status === 'active')
@@ -13,9 +14,15 @@ export async function openNoteExcerpt({ node, text, api, element, openSheet, sho
13
14
  field.wrapper.classList.add('span-2')
14
15
  }
15
16
  title.wrapper.hidden = true
17
+ const groupSlot = element('div', { class: 'span-2', hidden: true })
18
+ let groupField
19
+ const loadGroup = () => {
20
+ groupField = createDocumentGroupField({ element, api, baseId: base.input.value, required: true, onChange: () => { requestId = crypto.randomUUID(); sync() } })
21
+ groupSlot.replaceChildren(groupField.wrapper)
22
+ }
16
23
  const results = element('div', { class: 'note-picker-results span-2', 'aria-live': 'polite' })
17
24
  const status = element('p', { class: 'muted span-2', role: 'status' }, '请选择目标文档')
18
- const fields = element('fieldset', { class: 'form-grid note-excerpt-fields' }, base.wrapper, mode.wrapper, title.wrapper, search.wrapper, results, status)
25
+ const fields = element('fieldset', { class: 'form-grid note-excerpt-fields' }, base.wrapper, mode.wrapper, title.wrapper, groupSlot, search.wrapper, results, status)
19
26
  // Only bound the visual preview; submission always retains the entire selection.
20
27
  const preview = text.length > 1200 ? `${text.slice(0, 1200)}\n…(预览已省略,完整选文仍会添加)` : text
21
28
  const form = element('form', {}, fields, element('blockquote', { class: 'note-excerpt-preview' }, preview))
@@ -29,12 +36,13 @@ export async function openNoteExcerpt({ node, text, api, element, openSheet, sho
29
36
  onPrimary: async () => {
30
37
  if (mode.input.value === 'existing' && !selected) throw new Error('请先选择一个可编辑的知识文档。')
31
38
  if (mode.input.value === 'new' && !title.input.value.trim()) throw new Error('请填写新文档标题。')
39
+ if (mode.input.value === 'new') { await groupField?.ready; groupField?.validate(); if (!groupField?.value() || groupField.value() === '未分组') throw new Error('请为新文档选择分组。') }
32
40
  pending = true
33
41
  fields.disabled = true
34
42
  try {
35
43
  const entry = await api('note-excerpts', { method: 'POST', body: {
36
44
  requestId, noteId: node.id, text, knowledgeBaseId: base.input.value,
37
- ...(mode.input.value === 'existing' ? { documentId: selected.id, expectedVersion: selected.version } : { title: title.input.value.trim() }),
45
+ ...(mode.input.value === 'existing' ? { documentId: selected.id, expectedVersion: selected.version } : { title: title.input.value.trim(), group: groupField.value() }),
38
46
  } })
39
47
  showToast(`已添加到「${entry.title}」,并关联来源笔记。`)
40
48
  return true
@@ -99,10 +107,12 @@ export async function openNoteExcerpt({ node, text, api, element, openSheet, sho
99
107
  }
100
108
  }
101
109
  }
102
- base.input.addEventListener('change', () => { requestId = crypto.randomUUID(); if (mode.input.value === 'existing') void load(); else sync() })
110
+ base.input.addEventListener('change', () => { requestId = crypto.randomUUID(); if (mode.input.value === 'existing') void load(); else { loadGroup(); sync() } })
103
111
  mode.input.addEventListener('change', () => {
104
112
  clearTimeout(timer); sequence++; requestId = crypto.randomUUID(); selected = undefined
105
113
  const creating = mode.input.value === 'new'
114
+ groupSlot.hidden = !creating
115
+ if (creating) loadGroup()
106
116
  title.wrapper.hidden = !creating; search.wrapper.hidden = creating; results.hidden = creating
107
117
  status.textContent = creating ? '将新建文档并自动引用来源笔记。' : '请选择目标文档'
108
118
  sync(); if (!creating) void load()
package/web/styles.css CHANGED
@@ -2117,3 +2117,27 @@ html,
2117
2117
  .writeback-job p { margin: 8px 0; overflow-wrap: anywhere; }
2118
2118
  .writeback-job-key { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 8px; }
2119
2119
  .writeback-job pre { white-space: pre-wrap; overflow-wrap: anywhere; }
2120
+ /* Document groups reuse the existing tree/editor material; no extra surface. */
2121
+ .document-tree-group { min-width: 0; }
2122
+ .document-group-heading { display: flex; align-items: center; gap: 2px; }
2123
+ .document-group-toggle { display: flex; align-items: center; gap: 7px; flex: 1; min-width: 0; padding: 9px 8px; border: 0; border-radius: 8px; background: transparent; color: var(--text); font: inherit; text-align: left; cursor: pointer; }
2124
+ .document-group-toggle:hover { background: var(--surface-hover); }
2125
+ .document-group-toggle[aria-expanded="true"] .tree-disclosure { transform: rotate(45deg) translate(-1px, -1px); }
2126
+ .document-group-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 600; }
2127
+ .document-group-toggle small { color: var(--text-tertiary); font-size: 11px; font-variant-numeric: tabular-nums; }
2128
+ .document-group-body { padding-left: 12px; }
2129
+ .document-group-body[hidden], .document-group-field input[hidden] { display: none; }
2130
+ .document-group-add { min-width: 30px; min-height: 30px; padding: 0; }
2131
+ .document-group-field { display: grid; gap: 10px; min-width: 0; }
2132
+ .document-group-field label { font-size: 13px; font-weight: 600; }
2133
+ .document-group-field input, .document-group-field select { width: 100%; min-width: 0; }
2134
+ .document-group-control { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2135
+ .document-groups-manage { margin: 0 12px 8px; align-self: flex-start; }
2136
+ .document-group-selection { max-height: 40vh; overflow: auto; display: grid; gap: 4px; }
2137
+ .document-group-choice { display: flex; align-items: center; gap: 10px; padding: 9px 6px; cursor: pointer; border-radius: 8px; }
2138
+ .document-group-choice:hover { background: var(--surface-hover); }
2139
+ .document-group-choice input { width: 16px; height: 16px; flex: 0 0 16px; }
2140
+ .document-group-choice span { display: grid; gap: 3px; min-width: 0; }
2141
+ .document-group-choice strong { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2142
+ .document-group-choice small { color: var(--text-tertiary); font-weight: 400; }
2143
+ @media (max-width: 760px) { .document-group-toggle { min-height: 40px; } .document-group-control { max-width: 150px; } }