@lemoncat7/dsh-knowledge 2.9.5 → 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 (80) hide show
  1. package/README.md +8 -2
  2. package/docs/architecture.md +6 -0
  3. package/docs/document-groups.md +22 -0
  4. package/docs/releases/2.10.0.md +17 -0
  5. package/docs/releases/2.9.6.md +12 -0
  6. package/lib/api.d.ts.map +1 -1
  7. package/lib/api.js +36 -1
  8. package/lib/api.js.map +1 -1
  9. package/lib/client.js +34 -11
  10. package/lib/client.js.map +3 -3
  11. package/lib/document-group-tools.d.ts +6 -0
  12. package/lib/document-group-tools.d.ts.map +1 -0
  13. package/lib/document-group-tools.js +96 -0
  14. package/lib/document-group-tools.js.map +1 -0
  15. package/lib/document-groups.d.ts +8 -0
  16. package/lib/document-groups.d.ts.map +1 -0
  17. package/lib/document-groups.js +18 -0
  18. package/lib/document-groups.js.map +1 -0
  19. package/lib/documents/markdown.d.ts +1 -0
  20. package/lib/documents/markdown.d.ts.map +1 -1
  21. package/lib/documents/markdown.js +2 -0
  22. package/lib/documents/markdown.js.map +1 -1
  23. package/lib/domain.d.ts +6 -0
  24. package/lib/domain.d.ts.map +1 -1
  25. package/lib/domain.js +2 -0
  26. package/lib/domain.js.map +1 -1
  27. package/lib/extraction.d.ts.map +1 -1
  28. package/lib/extraction.js +16 -4
  29. package/lib/extraction.js.map +1 -1
  30. package/lib/knowledge-activity-panel.d.ts.map +1 -1
  31. package/lib/knowledge-activity-panel.js +23 -1
  32. package/lib/knowledge-activity-panel.js.map +1 -1
  33. package/lib/local-provider.d.ts +9 -0
  34. package/lib/local-provider.d.ts.map +1 -1
  35. package/lib/local-provider.js +143 -18
  36. package/lib/local-provider.js.map +1 -1
  37. package/lib/note-excerpt.d.ts +13 -0
  38. package/lib/note-excerpt.d.ts.map +1 -0
  39. package/lib/note-excerpt.js +9 -0
  40. package/lib/note-excerpt.js.map +1 -0
  41. package/lib/provider.d.ts +3 -0
  42. package/lib/provider.d.ts.map +1 -1
  43. package/lib/remote-provider.d.ts +2 -0
  44. package/lib/remote-provider.d.ts.map +1 -1
  45. package/lib/remote-provider.js +6 -0
  46. package/lib/remote-provider.js.map +1 -1
  47. package/lib/retrieval.d.ts +1 -0
  48. package/lib/retrieval.d.ts.map +1 -1
  49. package/lib/retrieval.js +2 -1
  50. package/lib/retrieval.js.map +1 -1
  51. package/lib/storage/migrations.d.ts.map +1 -1
  52. package/lib/storage/migrations.js +18 -1
  53. package/lib/storage/migrations.js.map +1 -1
  54. package/lib/tools.d.ts.map +1 -1
  55. package/lib/tools.js +3 -0
  56. package/lib/tools.js.map +1 -1
  57. package/lib/tracking.d.ts.map +1 -1
  58. package/lib/tracking.js +5 -0
  59. package/lib/tracking.js.map +1 -1
  60. package/lib/web-note-editor-chrome.d.ts +1 -0
  61. package/lib/web-note-editor-chrome.d.ts.map +1 -1
  62. package/lib/web-note-editor-chrome.js +1 -0
  63. package/lib/web-note-editor-chrome.js.map +1 -1
  64. package/lib/web-note-editor-selection.d.ts +1 -0
  65. package/lib/web-note-editor-selection.d.ts.map +1 -1
  66. package/lib/web-note-editor-selection.js +21 -1
  67. package/lib/web-note-editor-selection.js.map +1 -1
  68. package/lib/web-note-editor.d.ts +2 -0
  69. package/lib/web-note-editor.d.ts.map +1 -1
  70. package/lib/web-note-editor.js +12 -0
  71. package/lib/web-note-editor.js.map +1 -1
  72. package/lib/web.d.ts.map +1 -1
  73. package/lib/web.js +2 -1
  74. package/lib/web.js.map +1 -1
  75. package/package.json +1 -1
  76. package/web/app.js +101 -12
  77. package/web/document-groups.js +99 -0
  78. package/web/note-editor.js +57 -56
  79. package/web/note-excerpt.js +128 -0
  80. package/web/styles.css +30 -0
@@ -4,6 +4,7 @@ import { mkdirSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { DatabaseSync } from 'node:sqlite';
6
6
  import { migrateKnowledgeDatabase } from './storage/migrations.js';
7
+ import { noteExcerptMarkdown } from './note-excerpt.js';
7
8
  import { contentHash, DEFAULT_KNOWLEDGE_BASE_ID, newId, normalizeDraft, normalizeKnowledgeBaseDraft, normalizeKnowledgeBaseGroup, normalizeKnowledgeMountDraft, normalizeKnowledgeSettings, nowIso, } from './domain.js';
8
9
  import { markdownHash, renderKnowledgeMarkdown } from './documents/markdown.js';
9
10
  import { knowledgeDocumentPath } from './documents/path.js';
@@ -12,13 +13,14 @@ import { enqueueDocumentProjection } from './documents/projection-queue.js';
12
13
  import { applyKnowledgeTextEdits, mergeKnowledgeBodies } from './knowledge-merge.js';
13
14
  import { normalizeFinalizationChange } from './document-lifecycle.js';
14
15
  import { NoteStore } from './notes/store.js';
16
+ import { normalizeDocumentGroup, matchDocumentGroup } from './document-groups.js';
15
17
  const ENTRY_COLUMNS = `
16
18
  id, knowledge_base_id, title, body, type, tags_json, scope_kind, scope_id, confidence,
17
- status, document_state, finalized_at, finalization_note, version, source_json, created_at, updated_at
19
+ status, document_group, document_state, finalized_at, finalization_note, version, source_json, created_at, updated_at
18
20
  `;
19
21
  const JOINED_ENTRY_COLUMNS = `
20
22
  e.id AS id, e.knowledge_base_id AS knowledge_base_id, e.title AS title, e.body AS body, e.type AS type,
21
- e.tags_json AS tags_json, e.scope_kind AS scope_kind, e.scope_id AS scope_id,
23
+ e.tags_json AS tags_json, e.document_group AS document_group, e.scope_kind AS scope_kind, e.scope_id AS scope_id,
22
24
  e.confidence AS confidence, e.status AS status, e.document_state AS document_state,
23
25
  e.finalized_at AS finalized_at, e.finalization_note AS finalization_note, e.version AS version,
24
26
  e.source_json AS source_json, e.created_at AS created_at, e.updated_at AS updated_at
@@ -44,6 +46,7 @@ export class LocalKnowledgeProvider {
44
46
  this.db = new DatabaseSync(path);
45
47
  this.db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;');
46
48
  migrateKnowledgeDatabase(this.db, this.notes);
49
+ this.db.exec('CREATE TABLE IF NOT EXISTS note_excerpt_receipts (request_id TEXT PRIMARY KEY, input_hash TEXT NOT NULL, knowledge_id TEXT NOT NULL);');
47
50
  this.db.exec('CREATE TABLE IF NOT EXISTS writeback_receipts (source_key TEXT NOT NULL, proposal_hash TEXT NOT NULL, result_json TEXT NOT NULL, PRIMARY KEY(source_key, proposal_hash));');
48
51
  this.documentsReady = this.enqueueDocumentSync(() => this.syncAllDocuments());
49
52
  }
@@ -295,7 +298,7 @@ export class LocalKnowledgeProvider {
295
298
  const rows = this.db.prepare(`
296
299
  WITH document_index AS (
297
300
  SELECT
298
- id, knowledge_base_id, rel_path, title, entry_count, content_hash,
301
+ id, knowledge_base_id, rel_path, title, entry_count, content_hash, document_group,
299
302
  document_state, finalized_at, finalization_note, created_at, updated_at,
300
303
  CASE WHEN rel_path='README.md' THEN 0 ELSE 1 END AS sort_rank
301
304
  FROM knowledge_documents${sourceWhere}
@@ -323,6 +326,50 @@ export class LocalKnowledgeProvider {
323
326
  const row = this.db.prepare('SELECT * FROM knowledge_documents WHERE id=?').get(id);
324
327
  return row === undefined ? undefined : rowToDocument(row);
325
328
  }
329
+ async listDocumentGroups(knowledgeBaseId) {
330
+ this.assertOpen();
331
+ if (!await this.getKnowledgeBase(knowledgeBaseId))
332
+ throw notFound('knowledge base', knowledgeBaseId);
333
+ return this.documentGroups(knowledgeBaseId);
334
+ }
335
+ documentGroups(knowledgeBaseId) {
336
+ return this.db.prepare("SELECT document_group,COUNT(*) AS count FROM knowledge_entries WHERE knowledge_base_id=? AND status='active' GROUP BY document_group ORDER BY document_group COLLATE NOCASE").all(knowledgeBaseId)
337
+ .map(row => ({ name: String(row.document_group), count: Number(row.count) }));
338
+ }
339
+ resolveDocumentGroup(baseId, name) {
340
+ return matchDocumentGroup(name, this.documentGroups(baseId).map(group => group.name));
341
+ }
342
+ async assignDocumentGroup(knowledgeBaseId, ids, group) {
343
+ this.assertOpen();
344
+ await this.documentsReady;
345
+ const name = normalizeDocumentGroup(group);
346
+ if (!Array.isArray(ids) || ids.length < 1 || ids.length > 500 || ids.some(id => typeof id !== 'string'))
347
+ throw inputError('请选择 1 到 500 篇文档');
348
+ const entries = this.transaction(() => {
349
+ if (!this.db.prepare("SELECT id FROM knowledge_bases WHERE id=? AND status='active'").get(knowledgeBaseId))
350
+ throw notFound('active knowledge base', knowledgeBaseId);
351
+ const canonical = this.resolveDocumentGroup(knowledgeBaseId, name);
352
+ const selected = [...new Set(ids)].map(id => {
353
+ const row = this.db.prepare(`SELECT ${ENTRY_COLUMNS} FROM knowledge_entries WHERE id=?`).get(id);
354
+ if (!row || row.knowledge_base_id !== knowledgeBaseId || row.status !== 'active')
355
+ throw conflict('文档已移动、归档或删除,请刷新分组后重试');
356
+ return rowToEntry(row);
357
+ });
358
+ return selected.map(current => this.setEntryGroup(current, canonical));
359
+ });
360
+ for (const entry of entries)
361
+ await this.syncKnowledgeEntryQueued(entry.id);
362
+ return entries;
363
+ }
364
+ setEntryGroup(current, group) {
365
+ const canonical = this.resolveDocumentGroup(current.knowledgeBaseId, normalizeDocumentGroup(group));
366
+ if ((current.group ?? '') === canonical)
367
+ return current;
368
+ const updated = { ...current, group: canonical, version: current.version + 1, updatedAt: nowIso() };
369
+ this.db.prepare('UPDATE knowledge_entries SET document_group=?,version=?,updated_at=? WHERE id=?').run(canonical, updated.version, updated.updatedAt, updated.id);
370
+ this.writeVersion(updated, 'update');
371
+ return updated;
372
+ }
326
373
  async listMounts(targetKind, targetId) {
327
374
  this.assertOpen();
328
375
  const where = [];
@@ -613,8 +660,57 @@ export class LocalKnowledgeProvider {
613
660
  await this.syncKnowledgeEntryQueued(entry.id);
614
661
  return entry;
615
662
  }
663
+ /** Content, reference, and retry receipt share one SQLite transaction. */
664
+ async excerptNote(input) {
665
+ this.assertOpen();
666
+ await this.documentsReady;
667
+ if (!/^[a-zA-Z0-9_-]{16,100}$/.test(input.requestId))
668
+ throw inputError('无效的摘录请求标识');
669
+ if (!/^note_[a-f0-9]{32}$/.test(input.noteId))
670
+ throw inputError('无效的来源笔记');
671
+ if (!input.text.trim() || input.text.length > 50_000)
672
+ throw inputError('请选择 1 到 50000 字的笔记内容');
673
+ const body = noteExcerptMarkdown(input.noteId, input.text);
674
+ const hash = createHash('sha256').update(JSON.stringify(input)).digest('hex');
675
+ const entry = this.transaction(() => {
676
+ const receipt = this.db.prepare('SELECT input_hash,knowledge_id FROM note_excerpt_receipts WHERE request_id=?').get(input.requestId);
677
+ if (receipt) {
678
+ if (receipt.input_hash !== hash)
679
+ throw conflict('本次摘录请求已提交,请重新打开摘录窗口');
680
+ const previous = this.entriesByIds([String(receipt.knowledge_id)])[0];
681
+ if (!previous)
682
+ throw conflict('已摘录的知识文档已删除,不会重复创建');
683
+ return previous;
684
+ }
685
+ const note = this.notes.get(input.noteId);
686
+ if (!note || note.kind === 'folder')
687
+ throw inputError('来源笔记已删除或不可用');
688
+ let result;
689
+ if (input.documentId) {
690
+ const current = this.entriesByIds([input.documentId])[0];
691
+ if (!current || current.knowledgeBaseId !== input.knowledgeBaseId)
692
+ throw conflict('目标知识文档不存在或已移动');
693
+ if (current.status !== 'active' || current.documentState !== 'open')
694
+ throw conflict('目标文档已归档或定稿,请选择可编辑的文档');
695
+ if (current.version !== input.expectedVersion)
696
+ throw conflict('目标文档已更新,请重新选择后确认摘录');
697
+ if (!this.db.prepare("SELECT id FROM knowledge_bases WHERE id=? AND status='active'").get(current.knowledgeBaseId))
698
+ throw conflict('目标知识库已归档');
699
+ result = this.updateEntry(current.id, { ...current, body: `${current.body.trimEnd()}\n\n${body}`.trim() }, 'update');
700
+ }
701
+ else {
702
+ result = this.insertEntry({ knowledgeBaseId: input.knowledgeBaseId, ...(input.group === undefined ? {} : { group: input.group }), title: input.title?.trim() || note.name.replace(/\.md$/i, ''), body, type: 'fact', tags: [], scope: { kind: 'global' }, confidence: 0.8 });
703
+ }
704
+ this.db.prepare('INSERT OR IGNORE INTO knowledge_note_references(knowledge_id,note_id,source,source_session_id,created_at) VALUES(?,?,?,NULL,?)').run(result.id, note.id, 'user', nowIso());
705
+ this.db.prepare('INSERT INTO note_excerpt_receipts(request_id,input_hash,knowledge_id) VALUES(?,?,?)').run(input.requestId, hash, result.id);
706
+ return result;
707
+ });
708
+ await this.syncKnowledgeEntryQueued(entry.id);
709
+ return entry;
710
+ }
616
711
  insertEntry(input) {
617
712
  const draft = normalizeDraft(input);
713
+ draft.group = this.resolveDocumentGroup(draft.knowledgeBaseId, normalizeDocumentGroup(input.group, true));
618
714
  if (this.db.prepare("SELECT id FROM knowledge_bases WHERE id=? AND status='active'").get(draft.knowledgeBaseId) === undefined) {
619
715
  throw notFound('active knowledge base', draft.knowledgeBaseId);
620
716
  }
@@ -627,9 +723,9 @@ export class LocalKnowledgeProvider {
627
723
  this.db.prepare(`
628
724
  INSERT INTO knowledge_entries (
629
725
  id,knowledge_base_id,title,body,type,tags_json,scope_kind,scope_id,confidence,status,
630
- document_state,finalized_at,finalization_note,version,content_hash,source_json,created_at,updated_at
631
- ) VALUES (?,?,?,?,?,?,?,?,?,?,'open',NULL,NULL,?,?,?,?,?)
632
- `).run(id, draft.knowledgeBaseId, draft.title, draft.body, draft.type, JSON.stringify(draft.tags), draft.scope.kind, draft.scope.kind === 'project' ? draft.scope.id : null, draft.confidence, 'active', 1, contentHash(draft), draft.source === undefined ? null : JSON.stringify(draft.source), timestamp, timestamp);
726
+ document_state,finalized_at,finalization_note,version,content_hash,source_json,created_at,updated_at,document_group
727
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,'open',NULL,NULL,?,?,?,?,?,?)
728
+ `).run(id, draft.knowledgeBaseId, draft.title, draft.body, draft.type, JSON.stringify(draft.tags), draft.scope.kind, draft.scope.kind === 'project' ? draft.scope.id : null, draft.confidence, 'active', 1, contentHash(draft), draft.source === undefined ? null : JSON.stringify(draft.source), timestamp, timestamp, draft.group);
633
729
  this.writeVersion(entry, 'create');
634
730
  this.upsertFts(entry);
635
731
  return entry;
@@ -763,6 +859,7 @@ export class LocalKnowledgeProvider {
763
859
  if (current.documentState !== 'open')
764
860
  throw finalizedConflict(current);
765
861
  const draft = normalizeDraft(input);
862
+ draft.group = input.group === undefined ? current.group ?? '' : this.resolveDocumentGroup(draft.knowledgeBaseId, normalizeDocumentGroup(input.group));
766
863
  if (this.db.prepare("SELECT id FROM knowledge_bases WHERE id=? AND status='active'").get(draft.knowledgeBaseId) === undefined) {
767
864
  throw notFound('active knowledge base', draft.knowledgeBaseId);
768
865
  }
@@ -779,9 +876,9 @@ export class LocalKnowledgeProvider {
779
876
  this.db.prepare(`
780
877
  UPDATE knowledge_entries SET
781
878
  knowledge_base_id=?,title=?,body=?,type=?,tags_json=?,scope_kind=?,scope_id=?,confidence=?,status='active',
782
- version=?,content_hash=?,source_json=?,updated_at=?
879
+ version=?,content_hash=?,source_json=?,updated_at=?,document_group=?
783
880
  WHERE id=?
784
- `).run(draft.knowledgeBaseId, draft.title, draft.body, draft.type, JSON.stringify(draft.tags), draft.scope.kind, draft.scope.kind === 'project' ? draft.scope.id : null, draft.confidence, entry.version, contentHash(draft), draft.source === undefined ? null : JSON.stringify(draft.source), timestamp, id);
881
+ `).run(draft.knowledgeBaseId, draft.title, draft.body, draft.type, JSON.stringify(draft.tags), draft.scope.kind, draft.scope.kind === 'project' ? draft.scope.id : null, draft.confidence, entry.version, contentHash(draft), draft.source === undefined ? null : JSON.stringify(draft.source), timestamp, draft.group, id);
785
882
  this.writeVersion(entry, changeKind);
786
883
  this.upsertFts(entry);
787
884
  return entry;
@@ -1041,9 +1138,11 @@ export class LocalKnowledgeProvider {
1041
1138
  }
1042
1139
  const entry = resolution.proposal.action === 'create'
1043
1140
  ? this.insertEntry(resolution.proposal.draft)
1044
- : resolution.proposal.change?.kind === 'finalize'
1045
- ? this.finalizeEntry(resolution.proposal.targetId, resolution.proposal.change.state, resolution.proposal.change.note)
1046
- : this.updateEntry(resolution.proposal.targetId, resolution.proposal.draft, 'update');
1141
+ : resolution.proposal.change?.kind === 'group'
1142
+ ? this.setEntryGroup(this.activeEntry(resolution.proposal.targetId), resolution.proposal.change.group)
1143
+ : resolution.proposal.change?.kind === 'finalize'
1144
+ ? this.finalizeEntry(resolution.proposal.targetId, resolution.proposal.change.state, resolution.proposal.change.note)
1145
+ : this.updateEntry(resolution.proposal.targetId, resolution.proposal.draft, 'update');
1047
1146
  const reviewedAt = nowIso();
1048
1147
  this.db.prepare('UPDATE knowledge_candidates SET status=?, reviewed_at=?, review_note=? WHERE id=?')
1049
1148
  .run('approved', reviewedAt, resolution.outcome === 'merged'
@@ -1095,7 +1194,7 @@ export class LocalKnowledgeProvider {
1095
1194
  return { outcome: 'conflict', proposal };
1096
1195
  if (proposal.action === 'update') {
1097
1196
  const target = this.activeEntry(proposal.targetId);
1098
- if (target.documentState !== 'open')
1197
+ if (target.documentState !== 'open' && proposal.change?.kind !== 'group')
1099
1198
  return { outcome: 'finalized', entry: target };
1100
1199
  if (target.knowledgeBaseId !== proposal.draft.knowledgeBaseId) {
1101
1200
  throw conflict('direct-write update cannot move knowledge between knowledge bases');
@@ -1108,7 +1207,7 @@ export class LocalKnowledgeProvider {
1108
1207
  return { outcome: 'conflict', proposal: { ...proposal, action: 'conflict' } };
1109
1208
  }
1110
1209
  const draft = applied.draft;
1111
- if (proposal.change?.kind !== 'finalize' && contentHash(draft) === contentHash(target))
1210
+ if (proposal.change?.kind === 'group' ? (draft.group ?? '') === (target.group ?? '') : proposal.change?.kind !== 'finalize' && contentHash(draft) === contentHash(target))
1112
1211
  return { outcome: 'duplicate', entry: target };
1113
1212
  return { outcome: 'merged', proposal: { ...proposal, action: 'update', draft } };
1114
1213
  }
@@ -1188,7 +1287,17 @@ export class LocalKnowledgeProvider {
1188
1287
  throw conflict(`candidate ${id} was already ${candidate.status}`);
1189
1288
  let draft = decision.draft === undefined ? candidate.draft : normalizeDraft(decision.draft);
1190
1289
  if (decision.decision === 'approve') {
1191
- if (candidate.change?.kind === 'finalize') {
1290
+ if (candidate.change?.kind === 'group') {
1291
+ if (decision.draft !== undefined)
1292
+ throw conflict('分组候选不能替换正文,请拒绝后重新提交');
1293
+ const target = this.activeEntry(candidate.targetId);
1294
+ assertExpectedReviewVersion(target, decision.expectedVersion);
1295
+ const applied = applyCandidateToTarget(target, candidate, false);
1296
+ if (!applied.ok)
1297
+ throw conflict(applied.reason);
1298
+ touchedEntryId = this.setEntryGroup(target, candidate.change.group).id;
1299
+ }
1300
+ else if (candidate.change?.kind === 'finalize') {
1192
1301
  if (decision.draft !== undefined)
1193
1302
  throw conflict('结束候选不能替换正文,请拒绝后重新提交');
1194
1303
  const target = this.activeEntry(candidate.targetId);
@@ -1457,6 +1566,7 @@ export class LocalKnowledgeProvider {
1457
1566
  body: entry.body,
1458
1567
  type: entry.type,
1459
1568
  tags: entry.tags,
1569
+ group: entry.group ?? '',
1460
1570
  scope: entry.scope,
1461
1571
  confidence: entry.confidence,
1462
1572
  ...entry.source === undefined ? {} : { source: entry.source },
@@ -1541,14 +1651,14 @@ export class LocalKnowledgeProvider {
1541
1651
  this.db.prepare(`
1542
1652
  INSERT INTO knowledge_documents(
1543
1653
  id,knowledge_base_id,rel_path,title,content,entry_count,content_hash,
1544
- document_state,finalized_at,finalization_note,created_at,updated_at
1545
- ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
1654
+ document_state,finalized_at,finalization_note,created_at,updated_at,document_group
1655
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
1546
1656
  ON CONFLICT(id) DO UPDATE SET
1547
1657
  knowledge_base_id=excluded.knowledge_base_id,rel_path=excluded.rel_path,
1548
1658
  title=excluded.title,content=excluded.content,entry_count=excluded.entry_count,
1549
1659
  content_hash=excluded.content_hash,document_state=excluded.document_state,
1550
1660
  finalized_at=excluded.finalized_at,finalization_note=excluded.finalization_note,
1551
- updated_at=excluded.updated_at
1661
+ updated_at=excluded.updated_at,document_group=excluded.document_group
1552
1662
  WHERE knowledge_documents.knowledge_base_id<>excluded.knowledge_base_id
1553
1663
  OR knowledge_documents.content_hash<>excluded.content_hash
1554
1664
  OR knowledge_documents.rel_path<>excluded.rel_path
@@ -1557,7 +1667,8 @@ export class LocalKnowledgeProvider {
1557
1667
  OR knowledge_documents.document_state<>excluded.document_state
1558
1668
  OR knowledge_documents.finalized_at IS NOT excluded.finalized_at
1559
1669
  OR knowledge_documents.finalization_note IS NOT excluded.finalization_note
1560
- `).run(entry.id, entry.knowledgeBaseId, relPath, entry.title, renderEntryContent(entry), 1, projectedHash, entry.documentState, entry.finalizedAt ?? null, entry.finalizationNote ?? null, entry.createdAt, entry.updatedAt);
1670
+ OR knowledge_documents.document_group<>excluded.document_group
1671
+ `).run(entry.id, entry.knowledgeBaseId, relPath, entry.title, renderEntryContent(entry), 1, projectedHash, entry.documentState, entry.finalizedAt ?? null, entry.finalizationNote ?? null, entry.createdAt, entry.updatedAt, entry.group ?? '');
1561
1672
  }
1562
1673
  async syncKnowledgeDocuments(knowledgeBaseId) {
1563
1674
  const baseRow = this.db.prepare('SELECT * FROM knowledge_bases WHERE id=?').get(knowledgeBaseId);
@@ -1610,6 +1721,7 @@ function renderEntryMarkdown(entry) {
1610
1721
  id: entry.id,
1611
1722
  type: entry.type,
1612
1723
  tags: entry.tags,
1724
+ group: entry.group ?? '',
1613
1725
  scope: entry.scope,
1614
1726
  confidence: entry.confidence,
1615
1727
  status: entry.status,
@@ -1632,6 +1744,7 @@ function rowToEntry(row) {
1632
1744
  return {
1633
1745
  id: String(row.id),
1634
1746
  knowledgeBaseId: row.knowledge_base_id == null ? DEFAULT_KNOWLEDGE_BASE_ID : String(row.knowledge_base_id),
1747
+ group: String(row.document_group ?? ''),
1635
1748
  title: String(row.title),
1636
1749
  body: String(row.body),
1637
1750
  type: String(row.type),
@@ -1654,6 +1767,7 @@ function rowToDocument(row) {
1654
1767
  return {
1655
1768
  id: String(row.id),
1656
1769
  knowledgeBaseId: String(row.knowledge_base_id),
1770
+ group: String(row.document_group ?? ''),
1657
1771
  relPath: String(row.rel_path),
1658
1772
  title: String(row.title),
1659
1773
  content: String(row.content),
@@ -1670,6 +1784,7 @@ function rowToDocumentSummary(row) {
1670
1784
  return {
1671
1785
  id: String(row.id),
1672
1786
  knowledgeBaseId: String(row.knowledge_base_id),
1787
+ group: String(row.document_group ?? ''),
1673
1788
  relPath: String(row.rel_path),
1674
1789
  title: String(row.title),
1675
1790
  entryCount: Number(row.entry_count),
@@ -1741,6 +1856,11 @@ function normalizeCandidateChange(input) {
1741
1856
  return undefined;
1742
1857
  if (input.kind === 'append')
1743
1858
  return { kind: 'append' };
1859
+ if (input.kind === 'group') {
1860
+ if (!Number.isSafeInteger(input.baseVersion) || input.baseVersion < 1)
1861
+ throw new Error('分组 baseVersion 必须为正整数');
1862
+ return { kind: 'group', baseVersion: input.baseVersion, group: normalizeDocumentGroup(input.group, true) };
1863
+ }
1744
1864
  if (input.kind === 'finalize')
1745
1865
  return normalizeFinalizationChange(input);
1746
1866
  if (input.kind !== 'revise')
@@ -1783,6 +1903,11 @@ function mergeKnowledgeDraft(current, incoming, preferIncomingTitle) {
1783
1903
  });
1784
1904
  }
1785
1905
  function applyCandidateToTarget(current, proposal, preferIncomingTitle) {
1906
+ if (proposal.change?.kind === 'group') {
1907
+ if (current.version !== proposal.change.baseVersion || current.knowledgeBaseId !== proposal.draft.knowledgeBaseId)
1908
+ return { ok: false, reason: '分组候选已过期,请重新读取文档后提交' };
1909
+ return { ok: true, draft: { ...current, group: proposal.change.group } };
1910
+ }
1786
1911
  if (proposal.change?.kind === 'finalize') {
1787
1912
  if (current.documentState !== 'open' || current.version !== proposal.change.baseVersion || contentHash(current) !== proposal.change.baseHash
1788
1913
  || current.knowledgeBaseId !== proposal.draft.knowledgeBaseId || contentHash(current) !== contentHash(proposal.draft)) {