dsh-knowledge-library 0.1.1 → 0.3.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 (53) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +133 -14
  3. package/dist/client.js +1654 -304
  4. package/dist/core/extract.d.ts +5 -0
  5. package/dist/core/extract.js +78 -8
  6. package/dist/core/fetch.d.ts +5 -0
  7. package/dist/core/fetch.js +102 -25
  8. package/dist/core/grouping.d.ts +6 -0
  9. package/dist/core/grouping.js +71 -0
  10. package/dist/core/image-capture.d.ts +33 -0
  11. package/dist/core/image-capture.js +196 -0
  12. package/dist/core/incremental.d.ts +14 -0
  13. package/dist/core/incremental.js +52 -0
  14. package/dist/core/jobs.d.ts +21 -1
  15. package/dist/core/jobs.js +101 -12
  16. package/dist/core/knowledge.d.ts +37 -0
  17. package/dist/core/knowledge.js +619 -0
  18. package/dist/core/library.d.ts +36 -2
  19. package/dist/core/library.js +363 -17
  20. package/dist/core/page-access.d.ts +6 -0
  21. package/dist/core/page-access.js +38 -0
  22. package/dist/core/page-images.d.ts +15 -0
  23. package/dist/core/page-images.js +97 -0
  24. package/dist/core/purge.d.ts +20 -0
  25. package/dist/core/purge.js +106 -0
  26. package/dist/directory-settings.d.ts +18 -0
  27. package/dist/directory-settings.js +92 -0
  28. package/dist/http.d.ts +3 -1
  29. package/dist/http.js +152 -4
  30. package/dist/index.js +22 -7
  31. package/dist/knowledge-model.d.ts +30 -0
  32. package/dist/knowledge-model.js +194 -0
  33. package/dist/preview-settings.d.ts +6 -0
  34. package/dist/preview-settings.js +42 -0
  35. package/dist/public/app.css +145 -2
  36. package/dist/public/app.js +10 -6
  37. package/dist/standalone.js +12 -4
  38. package/dist/tools.d.ts +2 -1
  39. package/dist/tools.js +10 -1
  40. package/dist/types.d.ts +199 -1
  41. package/dist/ui/App.js +248 -47
  42. package/dist/ui/Dialog.d.ts +7 -0
  43. package/dist/ui/Dialog.js +32 -0
  44. package/dist/ui/Grouping.d.ts +12 -0
  45. package/dist/ui/Grouping.js +93 -0
  46. package/dist/ui/ImageCapture.d.ts +18 -0
  47. package/dist/ui/ImageCapture.js +76 -0
  48. package/dist/ui/Knowledge.d.ts +26 -0
  49. package/dist/ui/Knowledge.js +106 -0
  50. package/dist/ui/api.d.ts +7 -0
  51. package/dist/ui/api.js +16 -0
  52. package/dist/ui/icons.js +2 -0
  53. package/package.json +5 -1
@@ -0,0 +1,52 @@
1
+ import { createHash } from 'node:crypto';
2
+ export const noteFingerprint = (note) => createHash('sha256').update(JSON.stringify([note.content, note.segments, note.title, note.category, note.tags, note.citations, note.revision, note.updatedAt, note.deletedAt, note.deletionId])).digest('hex');
3
+ const tokens = (text) => [...new Set((text.toLowerCase().match(/[a-z0-9_]{2,}|[\p{Script=Han}]+/gu) || []).flatMap(term => /^[\p{Script=Han}]/u.test(term) ? Array.from({ length: Math.max(0, term.length - 1) }, (_, index) => term.slice(index, index + 2)) : [term]))].slice(0, 500);
4
+ /** Local lexical recall, followed by full-body model comparison; no silent body truncation. */
5
+ export function recallNotes(library, draft, topic, sources) {
6
+ const rows = library.db.prepare('SELECT id,meta,content FROM materials ORDER BY updated_at DESC').all();
7
+ const query = tokens([topic, draft.title, ...draft.tags, ...sources.flatMap(s => [s.title, ...s.tags]), draft.content].join(' '));
8
+ const notes = rows.map(row => ({ ...row, metadata: JSON.parse(row.meta) })).filter(row => row.metadata.kind === 'note' && !row.metadata.deletedAt);
9
+ const ranked = notes.map(row => {
10
+ const title = `${row.metadata.title} ${row.metadata.tags.join(' ')}`.toLowerCase();
11
+ const body = row.content.toLowerCase();
12
+ return { ...row, score: query.reduce((sum, term) => sum + (title.includes(term) ? 4 : body.includes(term) ? 1 : 0), 0) };
13
+ }).filter(row => row.score > 0).sort((a, b) => b.score - a.score);
14
+ let size = 0;
15
+ let oversized = 0;
16
+ const candidates = [];
17
+ for (const row of ranked) {
18
+ if (row.content.length > 50_000) {
19
+ oversized++;
20
+ continue;
21
+ }
22
+ if (candidates.length >= 5 || size + row.content.length > 60_000)
23
+ continue;
24
+ const note = library.get(row.id);
25
+ candidates.push(note);
26
+ size += note.content.length;
27
+ }
28
+ return { candidates, search: { scanned: notes.length, matched: ranked.length, compared: candidates.length, oversized } };
29
+ }
30
+ export function integrationValue(value, candidates, search) {
31
+ const data = value;
32
+ const bounded = (v, max, label, empty = false) => {
33
+ if (typeof v !== 'string' || v.length > max || (!empty && !v.trim()))
34
+ throw new Error(`增量整理的${label}无效`);
35
+ return v.trim();
36
+ };
37
+ const list = (v) => { if (!Array.isArray(v) || v.length > 12)
38
+ throw new Error('增量对比列表格式无效'); return v.map(item => bounded(item, 1000, '对比说明')); };
39
+ if (!data || !['new', 'append', 'duplicate'].includes(data.action))
40
+ throw new Error('增量整理建议格式无效');
41
+ const recommendation = data.action;
42
+ const target = recommendation === 'new' ? undefined : candidates.find(n => n.id === data.noteId);
43
+ if (recommendation !== 'new' && !target)
44
+ throw new Error('增量整理指定了未检索的笔记');
45
+ const content = bounded(data.content, 50_000, '新增正文', recommendation !== 'append');
46
+ if (recommendation !== 'append' && content)
47
+ throw new Error('新建或重复建议不应包含追加正文');
48
+ if (target && content && target.content.includes(content))
49
+ throw new Error('建议追加的正文已存在,请重新核对');
50
+ return { recommendation, choice: recommendation, reason: bounded(data.reason, 2000, '建议理由'), overlaps: list(data.overlaps), additions: list(data.additions), conflicts: list(data.conflicts), content, search,
51
+ ...(target ? { target: { id: target.id, title: target.title, category: target.category, revision: target.revision, fingerprint: noteFingerprint(target), content: target.content } } : {}) };
52
+ }
@@ -1,16 +1,21 @@
1
1
  import type { CaptureFields, ImportJob } from '../types.js';
2
2
  import { Library } from './library.js';
3
3
  import { fetchPublicPage } from './fetch.js';
4
+ import type { ImageCapture } from './image-capture.js';
4
5
  export declare class ImportQueue {
5
6
  private library;
6
7
  private fetchPage;
8
+ readonly imageCapture?: ImageCapture | undefined;
7
9
  private jobs;
8
10
  private file;
9
11
  private running;
10
12
  private stopped;
11
13
  private abort;
12
14
  private active?;
13
- constructor(library: Library, fetchPage?: typeof fetchPublicPage);
15
+ private imageAbort?;
16
+ private activeJob?;
17
+ private retrying;
18
+ constructor(library: Library, fetchPage?: typeof fetchPublicPage, imageCapture?: ImageCapture | undefined);
14
19
  list(): {
15
20
  id: string;
16
21
  url: string;
@@ -20,9 +25,24 @@ export declare class ImportQueue {
20
25
  attempts: number;
21
26
  materialId?: string;
22
27
  error?: string;
28
+ errorCode?: "PAGE_REQUIRES_BROWSER";
29
+ progress?: string;
30
+ imageCapture?: {
31
+ model: string;
32
+ maxImages: number;
33
+ };
34
+ imageSummary?: {
35
+ total: number;
36
+ completed: number;
37
+ failed: number;
38
+ skipped: number;
39
+ };
23
40
  }[];
24
41
  add(url: string, fields?: CaptureFields): ImportJob;
25
42
  retry(id: string): ImportJob;
43
+ retryImages(id: string, expectedModel: unknown, expectedIds: unknown): Promise<ImportJob>;
44
+ retryImagesForMaterial(materialId: string, expectedModel: unknown, expectedIds: unknown): Promise<ImportJob>;
45
+ cancelImages(id: string): ImportJob;
26
46
  private persist;
27
47
  private kick;
28
48
  idle(): Promise<void>;
package/dist/core/jobs.js CHANGED
@@ -3,32 +3,60 @@ import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { atomicJson } from './library.js';
5
5
  import { fetchPublicPage, validateUrl } from './fetch.js';
6
+ import { PageAccessError } from './page-access.js';
6
7
  export class ImportQueue {
7
8
  library;
8
9
  fetchPage;
10
+ imageCapture;
9
11
  jobs;
10
12
  file;
11
13
  running = false;
12
14
  stopped = false;
13
15
  abort = new AbortController();
14
16
  active;
15
- constructor(library, fetchPage = fetchPublicPage) {
17
+ imageAbort;
18
+ activeJob;
19
+ retrying = new Set();
20
+ constructor(library, fetchPage = fetchPublicPage, imageCapture) {
16
21
  this.library = library;
17
22
  this.fetchPage = fetchPage;
23
+ this.imageCapture = imageCapture;
18
24
  this.file = join(library.root, '.system', 'jobs.json');
19
25
  this.jobs = existsSync(this.file) ? JSON.parse(readFileSync(this.file, 'utf8')) : [];
20
26
  if (!Array.isArray(this.jobs))
21
27
  throw new Error('任务记录损坏,请备份并检查 .system/jobs.json');
22
- for (const job of this.jobs)
23
- if (job.status === 'running') {
28
+ for (const job of this.jobs) {
29
+ let unfinishedImages = false;
30
+ if (job.materialId && job.imageCapture && library.has(job.materialId) && imageCapture) {
31
+ // Recover even if a storage error completed the outer job before the image
32
+ // state could be written, or the process stopped immediately after body save.
33
+ if (!library.pageImages(job.materialId) && ['queued', 'running'].includes(job.status)) {
34
+ const page = library.get(job.materialId);
35
+ imageCapture.initialize(job.materialId, library.original(job.materialId).bytes.toString('utf8'), page.sourceUrl || job.url, job.imageCapture);
36
+ }
37
+ unfinishedImages = !!library.pageImages(job.materialId)?.records.some(image => ['pending', 'downloading', 'recognizing'].includes(image.status));
38
+ }
39
+ if (job.materialId && job.imageCapture && (['queued', 'running'].includes(job.status) || unfinishedImages)) {
40
+ if (library.has(job.materialId)) {
41
+ imageCapture?.interrupt(job.materialId);
42
+ job.imageSummary = imageCapture?.summary(job.materialId);
43
+ }
44
+ job.status = 'completed';
45
+ job.progress = '正文已保存,图片处理被中断';
46
+ job.error = '图片需主动重试,未自动重复模型请求';
47
+ }
48
+ else if (job.status === 'running') {
24
49
  job.status = job.attempts >= 3 ? 'failed' : 'queued';
25
50
  job.error = '上次处理被中断,等待恢复';
26
51
  }
52
+ }
27
53
  this.persist();
28
54
  this.kick();
29
55
  }
30
56
  list() { return this.jobs.slice().reverse().slice(0, 100).map(({ fields: _fields, ...job }) => job); }
31
57
  add(url, fields = {}) {
58
+ if (this.stopped)
59
+ throw new Error('网页采集服务已关闭');
32
60
  const normalized = validateUrl(url).href;
33
61
  const existing = this.jobs.find(j => j.url === normalized && ['queued', 'running'].includes(j.status));
34
62
  if (existing)
@@ -36,7 +64,7 @@ export class ImportQueue {
36
64
  if (this.jobs.filter(j => ['queued', 'running'].includes(j.status)).length >= 50)
37
65
  throw new Error('最多同时排队 50 个网页');
38
66
  const timestamp = new Date().toISOString();
39
- const job = { id: `job_${randomUUID()}`, url: normalized, fields, status: 'queued', createdAt: timestamp, updatedAt: timestamp, attempts: 0 };
67
+ const job = { id: `job_${randomUUID()}`, url: normalized, fields, status: 'queued', createdAt: timestamp, updatedAt: timestamp, attempts: 0, imageCapture: this.imageCapture?.options() };
40
68
  this.jobs.push(job);
41
69
  this.persist();
42
70
  this.kick();
@@ -48,11 +76,55 @@ export class ImportQueue {
48
76
  throw new Error('只能重试失败的任务');
49
77
  job.status = 'queued';
50
78
  job.error = undefined;
79
+ job.errorCode = undefined;
51
80
  job.attempts = 0;
52
81
  this.persist();
53
82
  this.kick();
54
83
  return structuredClone(job);
55
84
  }
85
+ async retryImages(id, expectedModel, expectedIds) {
86
+ const job = this.jobs.find(j => j.id === id);
87
+ if (!job?.materialId || !job.imageCapture || !this.imageCapture || job.status !== 'completed' || this.retrying.has(id))
88
+ throw new Error('图片任务尚未结束或正在重试');
89
+ this.retrying.add(id);
90
+ try {
91
+ const data = this.library.pageImages(job.materialId);
92
+ const eligible = data?.records.filter(image => image.status === 'failed' && image.attempts < 3).map(image => image.id).sort();
93
+ if (!Array.isArray(expectedIds) || JSON.stringify([...expectedIds].sort()) !== JSON.stringify(eligible))
94
+ throw new Error('失败图片已改变,请重新确认重试范围');
95
+ await this.imageCapture.prepareRetry(job.materialId, expectedModel);
96
+ job.status = 'queued';
97
+ job.progress = '等待重试失败图片';
98
+ delete job.error;
99
+ this.persist();
100
+ this.kick();
101
+ return structuredClone(job);
102
+ }
103
+ finally {
104
+ this.retrying.delete(id);
105
+ }
106
+ }
107
+ retryImagesForMaterial(materialId, expectedModel, expectedIds) {
108
+ const job = this.jobs.slice().reverse().find(job => job.materialId === materialId && job.imageCapture);
109
+ if (!job)
110
+ throw new Error('未找到该网页的图片任务');
111
+ return this.retryImages(job.id, expectedModel, expectedIds);
112
+ }
113
+ cancelImages(id) {
114
+ const job = this.jobs.find(j => j.id === id);
115
+ if (!job?.materialId || !job.imageCapture || !['queued', 'running'].includes(job.status))
116
+ throw new Error('当前没有可停止的图片处理');
117
+ if (this.activeJob === id)
118
+ this.imageAbort?.abort();
119
+ else {
120
+ this.imageCapture?.interrupt(job.materialId);
121
+ job.imageSummary = this.imageCapture?.summary(job.materialId);
122
+ job.status = 'completed';
123
+ }
124
+ job.progress = '正文已保存,图片处理已停止';
125
+ this.persist();
126
+ return structuredClone(job);
127
+ }
56
128
  persist() { atomicJson(this.file, this.jobs); }
57
129
  kick() {
58
130
  if (this.running || this.stopped)
@@ -67,21 +139,38 @@ export class ImportQueue {
67
139
  job.attempts++;
68
140
  job.updatedAt = new Date().toISOString();
69
141
  this.persist();
142
+ this.activeJob = job.id;
70
143
  try {
71
- const page = await this.fetchPage(job.url, this.abort.signal);
72
- if (this.stopped)
73
- break;
74
- const result = this.library.captureHtml(page.html, page.url, job.fields);
75
- job.materialId = result.material.id;
144
+ if (!job.materialId) {
145
+ const page = await this.fetchPage(job.url, this.abort.signal);
146
+ if (this.stopped)
147
+ break;
148
+ const result = this.library.captureHtml(page.html, page.url, job.fields);
149
+ job.materialId = result.material.id;
150
+ job.progress = '网页正文已保存';
151
+ this.persist();
152
+ if (job.imageCapture && this.imageCapture)
153
+ this.imageCapture.initialize(job.materialId, page.html, page.url, job.imageCapture);
154
+ }
155
+ if (job.imageCapture && this.imageCapture) {
156
+ this.imageAbort = new AbortController();
157
+ await this.imageCapture.run(job.materialId, AbortSignal.any([this.abort.signal, this.imageAbort.signal]), message => { job.progress = message; job.imageSummary = this.imageCapture.summary(job.materialId); this.persist(); });
158
+ job.imageSummary = this.imageCapture.summary(job.materialId);
159
+ }
76
160
  job.status = 'completed';
77
- job.error = undefined;
161
+ job.error = job.imageSummary?.failed ? `${job.imageSummary.failed} 张图片未完成,可核对后重试;正文已保留` : undefined;
162
+ job.errorCode = undefined;
163
+ job.progress = this.imageAbort?.signal.aborted || this.abort.signal.aborted ? '正文已保存,图片处理已停止' : job.imageCapture ? '采集已完成,正文与已识别图片文字保存在本地' : '网页正文已保存';
78
164
  }
79
165
  catch (error) {
80
- job.status = this.stopped ? 'queued' : 'failed';
166
+ job.status = job.materialId ? 'completed' : this.stopped ? 'queued' : 'failed';
81
167
  job.error = error.message;
168
+ job.errorCode = error instanceof PageAccessError ? error.code : undefined;
82
169
  }
83
170
  job.updatedAt = new Date().toISOString();
84
171
  this.persist();
172
+ this.imageAbort = undefined;
173
+ this.activeJob = undefined;
85
174
  }
86
175
  }).finally(() => {
87
176
  for (const job of this.jobs)
@@ -92,5 +181,5 @@ export class ImportQueue {
92
181
  });
93
182
  }
94
183
  async idle() { await this.active; }
95
- async close() { this.stopped = true; this.abort.abort(); await this.active; }
184
+ async close() { this.stopped = true; this.imageCapture?.close(); this.abort.abort(); await this.active; }
96
185
  }
@@ -0,0 +1,37 @@
1
+ import { Library } from './library.js';
2
+ import type { KnowledgeJob, KnowledgeOverview } from '../types.js';
3
+ import type { KnowledgeModel } from '../knowledge-model.js';
4
+ export declare class KnowledgeQueue {
5
+ private library;
6
+ private model;
7
+ private jobs;
8
+ private file;
9
+ private stopped;
10
+ private running?;
11
+ private abort?;
12
+ constructor(library: Library, model: KnowledgeModel);
13
+ overview(): KnowledgeOverview;
14
+ get(id: string): KnowledgeJob;
15
+ private find;
16
+ private persist;
17
+ private sources;
18
+ private checkedSources;
19
+ add(ids: unknown, topic?: unknown, requestedMode?: unknown): KnowledgeJob;
20
+ private newJob;
21
+ cancel(id: string): KnowledgeJob;
22
+ discard(id: string): KnowledgeJob;
23
+ editPlan(id: string, value: unknown, version: unknown): KnowledgeJob;
24
+ confirmPlan(id: string, version: unknown, apply?: unknown, expectedModel?: unknown): KnowledgeJob;
25
+ private runClassification;
26
+ rollbackPlan(id: string): KnowledgeJob;
27
+ edit(id: string, value: unknown, version: unknown, integration?: unknown): KnowledgeJob;
28
+ confirm(id: string, version: unknown, category?: unknown): import("../types.js").CaptureResult;
29
+ private progress;
30
+ private call;
31
+ private evidence;
32
+ private analyze;
33
+ private generate;
34
+ private kick;
35
+ idle(): Promise<void>;
36
+ close(): Promise<void>;
37
+ }