@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.
- package/README.md +7 -1
- package/docs/document-groups.md +22 -0
- package/docs/releases/2.10.0.md +17 -0
- package/lib/api.d.ts.map +1 -1
- package/lib/api.js +22 -1
- package/lib/api.js.map +1 -1
- package/lib/client.js +33 -10
- package/lib/client.js.map +3 -3
- package/lib/document-group-tools.d.ts +6 -0
- package/lib/document-group-tools.d.ts.map +1 -0
- package/lib/document-group-tools.js +96 -0
- package/lib/document-group-tools.js.map +1 -0
- package/lib/document-groups.d.ts +8 -0
- package/lib/document-groups.d.ts.map +1 -0
- package/lib/document-groups.js +18 -0
- package/lib/document-groups.js.map +1 -0
- package/lib/documents/markdown.d.ts +1 -0
- package/lib/documents/markdown.d.ts.map +1 -1
- package/lib/documents/markdown.js +2 -0
- package/lib/documents/markdown.js.map +1 -1
- package/lib/domain.d.ts +6 -0
- package/lib/domain.d.ts.map +1 -1
- package/lib/domain.js +2 -0
- package/lib/domain.js.map +1 -1
- package/lib/extraction.d.ts.map +1 -1
- package/lib/extraction.js +16 -4
- package/lib/extraction.js.map +1 -1
- package/lib/knowledge-activity-panel.d.ts.map +1 -1
- package/lib/knowledge-activity-panel.js +23 -1
- package/lib/knowledge-activity-panel.js.map +1 -1
- package/lib/local-provider.d.ts +6 -0
- package/lib/local-provider.d.ts.map +1 -1
- package/lib/local-provider.js +94 -19
- package/lib/local-provider.js.map +1 -1
- package/lib/note-excerpt.d.ts +1 -0
- package/lib/note-excerpt.d.ts.map +1 -1
- package/lib/note-excerpt.js.map +1 -1
- package/lib/provider.d.ts +3 -0
- package/lib/provider.d.ts.map +1 -1
- package/lib/remote-provider.d.ts +2 -0
- package/lib/remote-provider.d.ts.map +1 -1
- package/lib/remote-provider.js +6 -0
- package/lib/remote-provider.js.map +1 -1
- package/lib/retrieval.d.ts +1 -0
- package/lib/retrieval.d.ts.map +1 -1
- package/lib/retrieval.js +2 -1
- package/lib/retrieval.js.map +1 -1
- package/lib/storage/migrations.d.ts.map +1 -1
- package/lib/storage/migrations.js +18 -1
- package/lib/storage/migrations.js.map +1 -1
- package/lib/tools.d.ts.map +1 -1
- package/lib/tools.js +3 -0
- package/lib/tools.js.map +1 -1
- package/lib/tracking.d.ts.map +1 -1
- package/lib/tracking.js +5 -0
- package/lib/tracking.js.map +1 -1
- package/lib/web.js +1 -1
- package/lib/web.js.map +1 -1
- package/package.json +1 -1
- package/web/app.js +88 -12
- package/web/document-groups.js +99 -0
- package/web/note-excerpt.js +13 -3
- 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
|
-
|
|
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
|
|
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.
|
|
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} ·
|
|
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
|
|
3681
|
-
|
|
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
|
|
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
|
+
}
|
package/web/note-excerpt.js
CHANGED
|
@@ -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; } }
|