@dsh-libing/knowledge-base 0.1.0 → 0.1.3

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 (47) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +8 -2
  3. package/lib/client-types/client/JobDetails.d.ts +16 -0
  4. package/lib/client-types/client/JobsPanel.d.ts +6 -1
  5. package/lib/client-types/client/QualityReport.d.ts +3 -2
  6. package/lib/client-types/client/api.d.ts +83 -52
  7. package/lib/client-types/client/locales.d.ts +2 -0
  8. package/lib/client-types/client/useKnowledge.d.ts +30 -15
  9. package/lib/client-types/contract.d.ts +83 -52
  10. package/lib/client-types/data.d.ts +11 -0
  11. package/lib/client-types/settings.d.ts +4 -0
  12. package/lib/client-types/shared.d.ts +17 -0
  13. package/lib/client.js +40 -40
  14. package/lib/contract.d.ts +33 -2
  15. package/lib/contract.js +2 -1
  16. package/lib/data.d.ts +11 -0
  17. package/lib/data.js +2 -0
  18. package/lib/figures/image-client.d.ts +5 -0
  19. package/lib/figures/image-client.js +58 -0
  20. package/lib/figures/image-gate.d.ts +12 -0
  21. package/lib/figures/image-gate.js +39 -0
  22. package/lib/figures/image-protocol.d.ts +19 -0
  23. package/lib/figures/image-protocol.js +9 -0
  24. package/lib/figures/image-worker.d.ts +1 -0
  25. package/lib/figures/image-worker.js +60 -0
  26. package/lib/figures/process.d.ts +1 -1
  27. package/lib/figures/process.js +6 -35
  28. package/lib/indexes.js +9 -2
  29. package/lib/job-storage.d.ts +264 -0
  30. package/lib/job-storage.js +115 -0
  31. package/lib/jobs.d.ts +782 -1489
  32. package/lib/jobs.js +47 -23
  33. package/lib/models.d.ts +1 -1
  34. package/lib/models.js +8 -7
  35. package/lib/retrieval.js +8 -4
  36. package/lib/revision-cache.d.ts +24 -0
  37. package/lib/revision-cache.js +100 -0
  38. package/lib/routes.js +10 -7
  39. package/lib/runtime.d.ts +4 -0
  40. package/lib/runtime.js +6 -1
  41. package/lib/settings.d.ts +4 -0
  42. package/lib/settings.js +5 -2
  43. package/lib/shared.d.ts +17 -0
  44. package/lib/shared.js +3 -0
  45. package/lib/store.d.ts +101 -5
  46. package/lib/store.js +54 -20
  47. package/package.json +1 -1
package/lib/jobs.js CHANGED
@@ -14,6 +14,7 @@ import { convertPdf } from "./document/pdf.js";
14
14
  import { PdfSession, pageRange } from "./document/pdf-session.js";
15
15
  import { PDF_PARSER_VERSION } from "./document/pdf-layout.js";
16
16
  import { BudgetError } from "./models.js";
17
+ import { loadJob, saveCheckpoint, saveReport, readReport, recoverUsage, summarizeReport, usagePage } from "./job-storage.js";
17
18
  const RESUMABLE = new Set(['failed', 'needs-review', 'paused-budget', 'cancelled']);
18
19
  export class Jobs {
19
20
  store;
@@ -50,33 +51,51 @@ export class Jobs {
50
51
  }
51
52
  const visible = new Set((await this.store.list()).map(k => k.kbId));
52
53
  for (const dir of await this.store.jobDirectories()) {
53
- const record = await readJson(join(dir, 'job.json'), jobSchema, null);
54
+ const record = await loadJob(dir);
54
55
  if (!record || !visible.has(record.kbId))
55
56
  continue;
57
+ if (record.jobId !== basename(dir))
58
+ throw new Error('任务目录与身份不一致');
56
59
  const job = this.attach(record, dir);
57
60
  this.jobs.set(job.jobId, job);
61
+ if (job.pendingCalls.length)
62
+ await recoverUsage(job);
58
63
  if (ACTIVE.has(job.state)) {
59
64
  job.state = 'queued';
60
65
  job.recoveredAt = now();
61
66
  await job.checkpoint();
62
67
  }
63
68
  }
69
+ await this.store.cleanUploads();
64
70
  this.pump();
65
71
  }
66
72
  attach(record, directory) {
67
73
  let tail = Promise.resolve();
68
- return { ...record, directory, cacheDir: join(this.store.root, 'cache'), checkpoint: async () => {
69
- const snapshot = jobSchema.parse(JSON.parse(JSON.stringify(this.jobs.get(record.jobId) || record)));
70
- const pending = tail.then(() => atomicWrite(join(directory, 'job.json'), snapshot));
74
+ const job = { ...record, directory, cacheDir: join(this.store.root, 'cache'), checkpoint: async () => {
75
+ const { report: _report, usage, ...value } = job;
76
+ const snapshot = jobSchema.parse({ ...value, usage: { ...usage, records: [] } });
77
+ const pending = tail.then(() => saveCheckpoint(directory, snapshot));
71
78
  tail = pending.catch(() => { });
72
79
  await pending;
73
80
  } };
81
+ return job;
74
82
  }
75
83
  public(job) {
76
- const { checkpoint: _checkpoint, directory: _directory, cacheDir: _cacheDir, schema: _schema, overlays: _overlays, ...value } = job;
84
+ const { checkpoint: _checkpoint, directory: _directory, cacheDir: _cacheDir, schema: _schema, overlays: _overlays, report: _report, ...value } = job;
77
85
  return { ...value, outputPath: value.outputPath || null };
78
86
  }
79
- list(kbId) { return [...this.jobs.values()].filter(j => !kbId || j.kbId === kbId).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).map(j => this.public(j)); }
87
+ list(kbId, offset = 0, limit = 20) {
88
+ if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 50)
89
+ throw new Error('任务分页参数无效');
90
+ const all = [...this.jobs.values()].filter(j => !kbId || j.kbId === kbId).sort((a, b) => Number(ACTIVE.has(b.state)) - Number(ACTIVE.has(a.state)) || b.createdAt.localeCompare(a.createdAt) || b.jobId.localeCompare(a.jobId));
91
+ const page = all.slice(offset, offset + limit).map(job => {
92
+ const { report: _report, events: _events, failures: _failures, ...value } = this.public(job);
93
+ return { ...value, events: [], failures: [], usage: { ...value.usage, records: [] } };
94
+ });
95
+ return { jobs: page, jobTotal: all.length, jobOffset: offset, activeJobs: [...this.jobs.values()].filter(j => ACTIVE.has(j.state)).length };
96
+ }
97
+ async details(jobId, includeReport = true) { const job = this.get(jobId), { report: _report, ...value } = this.public(job); return { ...value, ...includeReport ? { report: await readReport(job) } : {} }; }
98
+ async usage(jobId, offset, limit, signal) { return usagePage(this.get(jobId), offset, limit, signal); }
80
99
  get(jobId) {
81
100
  const job = this.jobs.get(id(jobId, 'jobId'));
82
101
  if (!job)
@@ -198,7 +217,7 @@ export class Jobs {
198
217
  const { nodes, chunks } = createChunks(document, info, job.config);
199
218
  for (const chunk of chunks)
200
219
  chunk.summary = clip(chunk.segments.filter(s => ['paragraph', 'list', 'note'].includes(s.type)).map(s => s.text).join(' ').replace(/\s+/g, ' ') || chunk.title, job.config.summaryMaxChars);
201
- manifest = { formatVersion: 1, revision: required(job.revision, '草稿版本'), buildKey: job.buildKey, createdAt: job.createdAt, config: { chunkTargetChars: job.config.chunkTargetChars }, catalogs: {}, entities: {}, report: job.report ?? { revision: required(job.revision), sourceHash: required(job.sourceHash), issues: document.warnings, documents: 1, blocks: document.blocks.length, figures: document.assets.length, ignoredFigures: 0, unreviewedFigures: document.assets.length, chunks: chunks.length, withinTarget: chunks.filter(c => c.fullChars <= job.config.chunkTargetChars).length, oversized: [], usage: job.usage, targetChars: job.config.chunkTargetChars }, coverageIncomplete: true, documents: [{ ...info, nodes, chunks, assets: document.assets, figures: document.blocks.filter(b => b.type === 'figure').map(b => required(b.figure)) }] };
220
+ manifest = { formatVersion: 1, revision: required(job.revision, '草稿版本'), buildKey: job.buildKey, createdAt: job.createdAt, config: { chunkTargetChars: job.config.chunkTargetChars }, catalogs: {}, entities: {}, report: await readReport(job) ?? { revision: required(job.revision), sourceHash: required(job.sourceHash), issues: document.warnings, documents: 1, blocks: document.blocks.length, figures: document.assets.length, ignoredFigures: 0, unreviewedFigures: document.assets.length, chunks: chunks.length, withinTarget: chunks.filter(c => c.fullChars <= job.config.chunkTargetChars).length, oversized: [], usage: job.usage, targetChars: job.config.chunkTargetChars }, coverageIncomplete: true, documents: [{ ...info, nodes, chunks, assets: document.assets, figures: document.blocks.filter(b => b.type === 'figure').map(b => required(b.figure)) }] };
202
221
  }
203
222
  return { root, manifest, revision: required(job.revision), kbId: job.kbId, jobId };
204
223
  }
@@ -249,20 +268,25 @@ export class Jobs {
249
268
  const { catalogs, entities } = await writeIndexes(stage, docs, job.config, job.revision);
250
269
  await this.phase(job, 'validating');
251
270
  checkAbort(signal);
252
- for (const doc of docs)
271
+ const indexedChunks = new Set(Object.values(catalogs).flatMap(c => c.pages.flatMap(p => p.entries.flatMap(e => e.chunkId ? [e.chunkId] : []))));
272
+ for (const doc of docs) {
273
+ const assetIds = new Set(doc.assets.map(a => a.assetId));
253
274
  for (const chunk of doc.chunks) {
254
275
  if (hash(await readFile(await safePath(stage, chunk.path), 'utf8')) !== chunk.contentHash)
255
276
  throw new Error('生成片段哈希不一致');
256
- if (!Object.values(catalogs).some(c => c.pages.some(p => p.entries.some(e => e.chunkId === chunk.chunkId))))
277
+ if (!indexedChunks.has(chunk.chunkId))
257
278
  throw new Error('索引遗漏片段');
258
279
  for (const assetId of chunk.assetRefs)
259
- if (!doc.assets.some(a => a.assetId === assetId))
280
+ if (!assetIds.has(assetId))
260
281
  throw new Error('片段引用不存在的图片');
261
282
  }
283
+ }
262
284
  const report = { revision: job.revision, sourceHash: required(job.sourceHash, '原件哈希'), issues, documents: docs.length, blocks: document.blocks.length, figures: figureResults.length, ignoredFigures: figureResults.filter(f => f.status === 'ignored').length, unreviewedFigures: figureResults.filter(f => !f.humanReviewed && f.status !== 'decoration').length, chunks: chunks.length, withinTarget: chunks.filter(c => c.fullChars <= job.config.chunkTargetChars).length, oversized: chunks.filter(c => c.oversizedReason).map(c => ({ chunkId: c.chunkId, chars: c.fullChars, reason: required(c.oversizedReason) })), usage: job.usage, targetChars: job.config.chunkTargetChars, ...document.sourceFormat === 'pdf' ? { pdf: { pageCount: required(document.pageCount), selectedPages: required(document.selectedPages), outlineCount: document.audit.outlineCount, matchedBookmarks: document.audit.matchedBookmarks, ocrPages: document.audit.pages.filter(p => p.ocr).length, pages: document.audit.pages } } : {} };
263
285
  await atomicWrite(join(stage, 'reports/quality.json'), report);
264
286
  await atomicWrite(join(stage, 'reports/quality.md'), `# 构建质量报告\n\n文档 ${report.documents};本次片段 ${report.chunks};目标内 ${report.withinTarget};图片 ${report.figures};已忽略 ${report.ignoredFigures};未人工核验 ${report.unreviewedFigures}。\n\n` + issues.map(i => `- ${i.blocking ? '需要处理' : '提示'}:${i.message}`).join('\n') + '\n');
265
- job.report = report;
287
+ await saveReport(job.directory, report);
288
+ delete job.report;
289
+ job.reportSummary = summarizeReport(report);
266
290
  await job.checkpoint();
267
291
  const documents = docs.map(d => ({ ...d, chunks: d.chunks.map(({ content: _content, body: _body, ...chunk }) => chunk) }));
268
292
  const manifest = { formatVersion: 1, revision: job.revision, buildKey: job.buildKey, createdAt: now(), documents, catalogs, entities, coverageIncomplete: docs.some(d => d.coverageIncomplete), config: { chunkTargetChars: job.config.chunkTargetChars }, report };
@@ -292,7 +316,7 @@ export class Jobs {
292
316
  async actionUnlocked(jobId, action, options = {}) {
293
317
  const job = this.get(jobId);
294
318
  if (action === 'status')
295
- return this.public(job);
319
+ return this.details(jobId);
296
320
  if (action === 'cancel') {
297
321
  if (job.state === 'ready')
298
322
  throw new Error('已发布任务不能取消');
@@ -307,18 +331,18 @@ export class Jobs {
307
331
  else if (action === 'retry') {
308
332
  if (!RESUMABLE.has(job.state) || this.controllers.has(jobId))
309
333
  throw new Error('任务尚未退出或不能重试');
334
+ if (options.maxModelCalls !== undefined && (!Number.isSafeInteger(options.maxModelCalls) || options.maxModelCalls < 1 || options.maxModelCalls > this.config.maxModelCallsLimit))
335
+ throw new Error('模型调用预算超过宿主上限或无效');
336
+ if (options.maxModelTokens !== undefined && (!Number.isSafeInteger(options.maxModelTokens) || options.maxModelTokens < 1 || options.maxModelTokens > this.config.maxModelTokensLimit))
337
+ throw new Error('模型 token 预算超过宿主上限或无效');
338
+ job.config.maxModelCallsLimit = this.config.maxModelCallsLimit;
339
+ job.config.maxModelTokensLimit = this.config.maxModelTokensLimit;
310
340
  if (options.acceptPartial === true)
311
341
  job.config.acceptPartial = true;
312
- if (options.maxModelCalls !== undefined) {
313
- if (!Number.isSafeInteger(options.maxModelCalls) || options.maxModelCalls < 1)
314
- throw new Error('模型调用预算无效');
342
+ if (options.maxModelCalls !== undefined)
315
343
  job.config.maxModelCalls = options.maxModelCalls;
316
- }
317
- if (options.maxModelTokens !== undefined) {
318
- if (!Number.isSafeInteger(options.maxModelTokens) || options.maxModelTokens < 1)
319
- throw new Error('模型 token 预算无效');
344
+ if (options.maxModelTokens !== undefined)
320
345
  job.config.maxModelTokens = options.maxModelTokens;
321
- }
322
346
  job.cancelRequested = false;
323
347
  job.error = null;
324
348
  await this.phase(job, 'queued');
@@ -373,7 +397,7 @@ export class Jobs {
373
397
  return this.mutate(async () => {
374
398
  const kb = await this.store.restore(kbId);
375
399
  for (const dir of await this.store.jobDirectories()) {
376
- const record = await readJson(join(dir, 'job.json'), jobSchema, null);
400
+ const record = await loadJob(dir);
377
401
  if (record?.kbId === kb.kbId)
378
402
  this.jobs.set(record.jobId, this.attach(record, dir));
379
403
  }
@@ -395,7 +419,7 @@ export class Jobs {
395
419
  const jobIds = related.map(j => j.jobId);
396
420
  if (!kb.purge)
397
421
  for (const dir of await this.store.jobDirectories()) {
398
- const record = await readJson(await safePath(dir, 'job.json', false), jobSchema, null);
422
+ const record = await loadJob(dir);
399
423
  if (record?.kbId !== kb.kbId)
400
424
  continue;
401
425
  const key = id(basename(dir), 'jobId');
@@ -417,7 +441,7 @@ export class Jobs {
417
441
  else
418
442
  await new Promise(r => setTimeout(r, 0));
419
443
  }
420
- return this.public(this.get(jobId));
444
+ return this.details(jobId);
421
445
  }
422
446
  stop() {
423
447
  this.stopped = true;
package/lib/models.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
2
  import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
3
- import { z } from 'zod';
3
+ import type { z } from 'zod';
4
4
  import type { Settings } from './config.ts';
5
5
  import type { Job, ModelRoute } from './data.ts';
6
6
  export type PreparedImage = {
package/lib/models.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { SessionId } from '@deepseek-ai/dsh-session';
2
- import { z } from 'zod';
3
2
  import { atomicWrite, checkAbort, chars, message, now, readJson, tokensUsed } from "./util.js";
4
3
  import { join } from 'node:path';
5
4
  export class BudgetError extends Error {
@@ -27,9 +26,11 @@ export class Models {
27
26
  const route = job.routes[kind];
28
27
  if (!route)
29
28
  throw new Error(`${kind} 模型不可用`);
30
- if (job.usage.calls >= job.config.maxModelCalls || job.usage.tokens >= job.config.maxModelTokens)
29
+ if (job.usage.calls >= Math.min(job.config.maxModelCalls, this.config.maxModelCallsLimit) || job.usage.tokens >= Math.min(job.config.maxModelTokens, this.config.maxModelTokensLimit))
31
30
  throw new BudgetError('已达到本次构建模型调用/实际 token 预算;调整预算后重试可继续');
32
- const number = ++job.usage.calls, file = join(job.directory, 'calls', `${String(number).padStart(6, '0')}.json`);
31
+ const number = ++job.usage.calls;
32
+ job.pendingCalls.push(number);
33
+ const file = join(job.directory, 'calls', `${String(number).padStart(6, '0')}.json`);
33
34
  const controller = new AbortController(), timeout = setTimeout(() => controller.abort(new Error('模型调用超时')), job.config.modelTimeoutMs);
34
35
  const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
35
36
  const request = { kind, route, prompt, system, images, startedAt: now(), maxTokens: job.config.modelMaxOutputTokens };
@@ -41,8 +42,8 @@ export class Models {
41
42
  job.usage.tokens += tokensUsed(usage);
42
43
  if (usage) {
43
44
  job.usage.reportedCalls++;
44
- job.usage.records.push(z.json().parse(JSON.parse(JSON.stringify(usage))));
45
45
  }
46
+ job.pendingCalls = job.pendingCalls.filter(n => n !== number);
46
47
  };
47
48
  try {
48
49
  await job.checkpoint();
@@ -68,14 +69,14 @@ export class Models {
68
69
  throw new Error(finish && 'failure' in finish ? finish.failure.message : `模型未正常完成:${finish?.kind ?? '缺少结束事件'}`);
69
70
  if (!response.trim())
70
71
  throw new Error('模型返回空内容');
71
- account();
72
72
  await atomicWrite(file, { request, state: 'done', response, usage, finishedAt: now() });
73
+ account();
73
74
  await job.checkpoint();
74
75
  return response;
75
76
  }
76
77
  catch (error) {
77
- account();
78
78
  await atomicWrite(file, { request, state: 'failed', response, usage, error: message(error), finishedAt: now() });
79
+ account();
79
80
  await job.checkpoint();
80
81
  throw error;
81
82
  }
@@ -101,7 +102,7 @@ export class Models {
101
102
  if (job.config.skipImages)
102
103
  return;
103
104
  const info = await this.resolve(job.routes.vision, 'vision', signal);
104
- const { Jimp } = await import('jimp'), data = await new Jimp({ width: 32, height: 32, color: 0xffffffff }).getBuffer('image/png');
105
+ const data = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAQ0lEQVR4AcXBAQEAAAiAIPP/5+qFMPsISUxiEpOYxCQmMYlJTGISk5jEJCYxiUlMYhKTmMQkJjGJSUxiEpOYxCQmsQNNiAQ8n/3A9AAAAABJRU5ErkJggg==', 'base64');
105
106
  const attachment = await this.ctx.attachments.saveImage({ data, mediaType: 'image/png', name: 'kb-vision-preflight.png' });
106
107
  const response = await this.call(job, 'vision', '这是图片接口连通性检查。能读取图像时只返回 READY。', [{ attachment, coordinates: { kind: 'preflight' } }], signal, '检查是否实际收到图片,简短回答。');
107
108
  if (response.trim() !== 'READY')
package/lib/retrieval.js CHANGED
@@ -88,7 +88,7 @@ export class Retrieval {
88
88
  }
89
89
  return bounded(value, this.config.catalogMaxChars);
90
90
  }
91
- const kb = await this.store.kb(args.kbId), snapshot = await this.store.revision(kb.kbId, args.revision);
91
+ const { kb, snapshot } = await this.store.open(args.kbId, args.revision);
92
92
  const catalogId = args.docId ? `${id(args.docId)}-${args.nodeId ? id(args.nodeId) : 'root'}` : 'root';
93
93
  const catalog = snapshot.manifest.catalogs[catalogId];
94
94
  if (!catalog)
@@ -102,13 +102,13 @@ export class Retrieval {
102
102
  return bounded(value, this.config.catalogMaxChars + 1000);
103
103
  }
104
104
  async search(args) {
105
- const kb = await this.store.kb(args.kbId), snapshot = await this.store.revision(kb.kbId, args.revision);
105
+ const { kb, snapshot } = await this.store.open(args.kbId, args.revision);
106
106
  const terms = queryTerms(args.query, args.keywords), scored = [];
107
107
  for (const doc of snapshot.manifest.documents) {
108
108
  if (args.docId && doc.docId !== args.docId)
109
109
  continue;
110
110
  for (const chunk of doc.chunks) {
111
- const content = await this.store.chunkContent(snapshot, chunk), body = content.toLowerCase();
111
+ const { content, lower: body } = await this.store.chunkView(snapshot, chunk);
112
112
  const hits = terms.filter(t => body.includes(t)), title = chunk.headingPath.join('/').toLowerCase(), keys = (chunk.keywords || []).map(k => k.toLowerCase());
113
113
  if (!hits.length)
114
114
  continue;
@@ -122,7 +122,9 @@ export class Retrieval {
122
122
  throw new Error('limit 必须为 1–30');
123
123
  const value = { kbId: kb.kbId, revision: snapshot.revision, query: args.query, terms, candidates: [], evidence: [], warnings: [], truncated: scored.length > limit, totalCandidates: scored.length, continuation: [] };
124
124
  const top = scored.slice(0, limit);
125
- for (const { doc, chunk, score, hits, content } of top) {
125
+ for (const { doc, chunk, score, hits } of top) {
126
+ // Cached scoring is navigation only; returned evidence is always reread and hash-verified.
127
+ const content = await this.store.chunkContent(snapshot, chunk);
126
128
  const candidate = { chunkId: chunk.chunkId, score, matchedTerms: hits, title: chunk.title, summary: chunk.summary, fullChars: chunk.fullChars, reviewStatus: chunk.reviewStatus, source: source(required(doc), chunk, snapshot.revision) };
127
129
  value.candidates.push(candidate);
128
130
  const remaining = this.config.evidenceMaxChars - chars(JSON.stringify(value)) - 800;
@@ -145,10 +147,12 @@ export class Retrieval {
145
147
  break;
146
148
  }
147
149
  }
150
+ await this.store.kb(kb.kbId); // Recheck visibility after potentially long scoring/evidence reads.
148
151
  return bounded(value, this.config.evidenceMaxChars);
149
152
  }
150
153
  async read(args) {
151
154
  const kb = await this.store.kb(args.kbId), snapshot = await this.store.readChunk(kb.kbId, args.revision, args.chunkId);
155
+ await this.store.kb(kb.kbId);
152
156
  return this.readSnapshot(kb.kbId, snapshot, args);
153
157
  }
154
158
  readSnapshot(kbId, snapshot, args) {
@@ -0,0 +1,24 @@
1
+ import type { Manifest } from './data.ts';
2
+ /** Bounded hot revision payloads. Every lookup checks file identity; evidence reads bypass reuse. */
3
+ export declare class RevisionCache {
4
+ readonly maxBytes: number;
5
+ readonly maxEntries: number;
6
+ private entries;
7
+ bytes: number;
8
+ reads: number;
9
+ hits: number;
10
+ parses: number;
11
+ get size(): number;
12
+ constructor(maxBytes?: number, maxEntries?: number);
13
+ clear(): void;
14
+ private drop;
15
+ private put;
16
+ private stamp;
17
+ private lookup;
18
+ private read;
19
+ manifest(file: string): Promise<Manifest>;
20
+ chunk(file: string, digest: string, fresh?: boolean): Promise<{
21
+ content: string;
22
+ lower: string;
23
+ }>;
24
+ }
@@ -0,0 +1,100 @@
1
+ import { stat, readFile } from 'node:fs/promises';
2
+ import { manifestSchema } from "./data.js";
3
+ import { hash } from "./util.js";
4
+ function freeze(value) { if (value && typeof value === 'object') {
5
+ for (const child of Object.values(value))
6
+ freeze(child);
7
+ Object.freeze(value);
8
+ } return value; }
9
+ /** Bounded hot revision payloads. Every lookup checks file identity; evidence reads bypass reuse. */
10
+ export class RevisionCache {
11
+ maxBytes;
12
+ maxEntries;
13
+ entries = new Map();
14
+ bytes = 0;
15
+ reads = 0;
16
+ hits = 0;
17
+ parses = 0;
18
+ get size() { return this.entries.size; }
19
+ constructor(maxBytes = 32 * 1024 * 1024, maxEntries = 256) {
20
+ this.maxBytes = maxBytes;
21
+ this.maxEntries = maxEntries;
22
+ }
23
+ clear() { this.entries.clear(); this.bytes = 0; }
24
+ drop(file) { const old = this.entries.get(file); if (old)
25
+ this.bytes -= old.cost; this.entries.delete(file); }
26
+ put(file, entry) {
27
+ this.drop(file);
28
+ if (entry.cost > this.maxBytes || this.maxEntries < 1)
29
+ return;
30
+ while (this.entries.size >= this.maxEntries || this.bytes + entry.cost > this.maxBytes) {
31
+ const first = this.entries.keys().next().value;
32
+ if (first === undefined)
33
+ break;
34
+ this.drop(first);
35
+ }
36
+ this.entries.set(file, entry);
37
+ this.bytes += entry.cost;
38
+ }
39
+ async stamp(file) {
40
+ const info = await stat(file, { bigint: true });
41
+ if (!info.isFile())
42
+ throw new Error('版本文件必须是普通文件');
43
+ return `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
44
+ }
45
+ async lookup(file) {
46
+ let stamp;
47
+ try {
48
+ stamp = await this.stamp(file);
49
+ }
50
+ catch (error) {
51
+ this.drop(file);
52
+ throw error;
53
+ }
54
+ const entry = this.entries.get(file);
55
+ if (entry?.stamp === stamp) {
56
+ this.drop(file);
57
+ this.entries.set(file, entry);
58
+ this.bytes += entry.cost;
59
+ return { stamp, entry };
60
+ }
61
+ this.drop(file);
62
+ return { stamp, entry: undefined };
63
+ }
64
+ async read(file, stamp) {
65
+ this.reads++;
66
+ const content = await readFile(file, 'utf8');
67
+ if (await this.stamp(file) !== stamp) {
68
+ this.drop(file);
69
+ throw new Error('版本文件读取期间变化,请重试');
70
+ }
71
+ return content;
72
+ }
73
+ async manifest(file) {
74
+ const { stamp, entry } = await this.lookup(file);
75
+ if (entry?.kind === 'manifest') {
76
+ this.hits++;
77
+ return entry.value;
78
+ }
79
+ const raw = await this.read(file, stamp);
80
+ this.parses++;
81
+ const value = freeze(manifestSchema.parse(JSON.parse(raw)));
82
+ this.put(file, { kind: 'manifest', stamp, value, cost: raw.length * 8 + 512 });
83
+ return value;
84
+ }
85
+ async chunk(file, digest, fresh = false) {
86
+ const { stamp, entry } = await this.lookup(file);
87
+ if (!fresh && entry?.kind === 'chunk' && entry.digest === digest) {
88
+ this.hits++;
89
+ return { content: entry.value, lower: entry.lower };
90
+ }
91
+ const content = await this.read(file, stamp);
92
+ if (hash(content) !== digest) {
93
+ this.drop(file);
94
+ throw new Error('片段内容与清单不一致,请重建');
95
+ }
96
+ const lower = content.toLowerCase();
97
+ this.put(file, { kind: 'chunk', stamp, value: content, lower, digest, cost: (content.length + lower.length) * 2 + 512 });
98
+ return { content, lower };
99
+ }
100
+ }
package/lib/routes.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { z } from 'zod';
4
- import { API, browserBuildSchema, jobActionSchema, catalogQuerySchema, readQuerySchema, searchSchema, assetQuerySchema, revisionQuerySchema } from "./shared.js";
4
+ import { API, jobListQuerySchema, jobDetailsQuerySchema, jobUsageQuerySchema, browserBuildSchema, jobActionSchema, catalogQuerySchema, readQuerySchema, searchSchema, assetQuerySchema, revisionQuerySchema } from "./shared.js";
5
5
  import { figureDataSchema, overlaySchema } from "./data.js";
6
6
  import { inspectPdf } from "./document/pdf-session.js";
7
7
  import { hash, safePath, message, required, fileBytes, checkAbort } from "./util.js";
@@ -69,16 +69,19 @@ export function routes(runtime, ctx) {
69
69
  throw new Error('PDF 原件不存在于所选版本中');
70
70
  return binary((await store.rawSource(snapshot.kbId, doc)).data, 'application/pdf');
71
71
  };
72
- const image = async (asset, file) => {
72
+ const image = async (asset, file, verified) => {
73
73
  if (!['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/tiff'].includes(asset.mime))
74
74
  throw new Error('不支持浏览此图片格式,请提供 PNG/JPEG 预览');
75
- const data = await fileBytes(file, config.maxUncompressedBytes);
76
- if (`a-${hash(data).slice(0, 20)}` !== asset.assetId)
75
+ const data = verified ?? await fileBytes(file, config.maxUncompressedBytes);
76
+ if (!verified && `a-${hash(data).slice(0, 20)}` !== asset.assetId)
77
77
  throw new Error('图片内容与清单不一致');
78
78
  return binary(data, asset.mime);
79
79
  };
80
80
  const tree = (snapshot, kbId) => ({ kbId, revision: snapshot.revision, coverageIncomplete: snapshot.manifest.coverageIncomplete, report: snapshot.manifest.report, documents: snapshot.manifest.documents.map(doc => ({ ...doc, chunks: doc.chunks.map(({ segments: _segments, anchors: _anchors, ...chunk }) => chunk) })) });
81
- add('list', 'GET', async (request) => { z.strictObject({}).parse(query(request)); return ({ hostVersion: HOST_VERSION, kbs: await Promise.all((await store.list()).map(async (kb) => ({ ...kb, ...await store.inspect(kb.kbId) }))), deletedKbs: await store.deleted(), activities: await store.activities(), jobs: jobs.list(), dataRoot: store.root, config: { chunkTargetChars: config.chunkTargetChars, visionProvider: config.visionProvider, visionModel: config.visionModel, maxInputBytes: config.maxInputBytes } }); });
81
+ add('list', 'GET', async (request) => { const args = jobListQuerySchema.parse(query(request)); if (args.jobsKbId)
82
+ await store.kb(args.jobsKbId); return ({ hostVersion: HOST_VERSION, ...await store.overview(), ...jobs.list(args.jobsKbId, args.jobOffset, args.jobLimit), dataRoot: store.root, config: { chunkTargetChars: config.chunkTargetChars, visionProvider: config.visionProvider, visionModel: config.visionModel, maxInputBytes: config.maxInputBytes } }); });
83
+ add('job-details', 'GET', async (request) => { const args = jobDetailsQuerySchema.parse(query(request)); return jobs.details(args.jobId, args.section === 'report'); });
84
+ add('job-usage', 'GET', async (request) => { const args = jobUsageQuerySchema.parse(query(request)); return jobs.usage(args.jobId, args.offset, args.limit, request.signal); });
82
85
  add('create', 'POST', async (request) => store.create((await body(request, z.strictObject({ name }))).name));
83
86
  add('delete', 'POST', async (request) => { const args = await body(request, managementArgs); return jobs.trash(args.kbId, args.confirmName); });
84
87
  add('restore', 'POST', async (request) => jobs.restore((await body(request, libraryArgs)).kbId));
@@ -107,10 +110,10 @@ export function routes(runtime, ctx) {
107
110
  });
108
111
  add('draft-asset', 'GET', async (request) => { const args = draftAssetQuery.parse(query(request)), snapshot = await jobs.preview(args.jobId), doc = required(snapshot.manifest.documents.find(d => d.docId === args.docId), '草稿文档'), asset = required(doc.assets.find(a => a.assetId === args.assetId), '草稿图片'); return image(asset, await safePath(snapshot.root, join('sources', doc.docId, asset.path))); });
109
112
  add('catalog', 'GET', async (request) => retrieval.catalog(catalogQuerySchema.parse(query(request))));
110
- add('tree', 'GET', async (request) => { const args = revisionQuerySchema.parse(query(request)), kb = await store.kb(args.kbId), snapshot = await store.revision(kb.kbId, args.revision), overlays = await store.overlays(kb.kbId), value = tree(snapshot, kb.kbId); return { ...value, location: await store.location(kb.kbId, snapshot.revision), documents: value.documents.map(doc => ({ ...doc, figures: doc.figures.map(figure => ({ ...figure, expectedHash: hash(overlays[figure.overlayKey] || null) })) })) }; });
113
+ add('tree', 'GET', async (request) => { const args = revisionQuerySchema.parse(query(request)), { kb, snapshot } = await store.open(args.kbId, args.revision), overlays = await store.overlays(kb.kbId), value = tree(snapshot, kb.kbId); return { ...value, location: await store.snapshotLocation(kb.kbId, snapshot), documents: value.documents.map(doc => ({ ...doc, figures: doc.figures.map(figure => ({ ...figure, expectedHash: hash(overlays[figure.overlayKey] || null) })) })) }; });
111
114
  add('read', 'GET', async (request) => retrieval.read(readQuerySchema.parse(query(request))));
112
115
  add('search', 'POST', async (request) => retrieval.search(await body(request, searchSchema)));
113
- add('asset', 'GET', async (request) => { const args = assetQuerySchema.parse(query(request)), kb = await store.kb(args.kbId), asset = await store.asset(kb.kbId, args.revision, args.docId, args.assetId); return image(asset.asset, asset.file); });
116
+ add('asset', 'GET', async (request) => { const args = assetQuerySchema.parse(query(request)), kb = await store.kb(args.kbId), asset = await store.asset(kb.kbId, args.revision, args.docId, args.assetId); return image(asset.asset, asset.file, asset.data); });
114
117
  add('schema', 'GET', async (request) => { const kb = await store.kb(libraryArgs.parse(query(request)).kbId), content = await store.schema(kb.kbId); return { content, hash: hash(content) }; });
115
118
  add('schema', 'POST', async (request) => { const args = await body(request, libraryArgs.extend({ content: z.string().max(20000), expectedHash: digest })), kb = await store.kb(args.kbId); return store.setSchema(kb.kbId, args.content, args.expectedHash); });
116
119
  add('review', 'POST', async (request) => {
package/lib/runtime.d.ts CHANGED
@@ -18,8 +18,12 @@ export declare function createRuntime(ctx: Context, raw?: Config): {
18
18
  modelTimeoutMs: number;
19
19
  modelAttempts: number;
20
20
  visionConcurrency: number;
21
+ maxModelCallsLimit: number;
22
+ maxModelTokensLimit: number;
23
+ uploadRetentionDays: number;
21
24
  maxModelCalls: number;
22
25
  maxModelTokens: number;
26
+ imageTimeoutMs: number;
23
27
  visionMaxEdge: number;
24
28
  visionTileSize: number;
25
29
  visionMaxTiles: number;
package/lib/runtime.js CHANGED
@@ -32,7 +32,12 @@ export function createRuntime(ctx, raw = {}) {
32
32
  }
33
33
  finally {
34
34
  await Promise.allSettled(pending);
35
- await jobs.close();
35
+ try {
36
+ await jobs.close();
37
+ }
38
+ finally {
39
+ store.revisionCache.clear();
40
+ }
36
41
  }
37
42
  })();
38
43
  return closing;
package/lib/settings.d.ts CHANGED
@@ -11,8 +11,12 @@ export declare const settingsSchema: z.ZodObject<{
11
11
  modelTimeoutMs: z.ZodDefault<z.ZodNumber>;
12
12
  modelAttempts: z.ZodDefault<z.ZodNumber>;
13
13
  visionConcurrency: z.ZodDefault<z.ZodNumber>;
14
+ maxModelCallsLimit: z.ZodDefault<z.ZodNumber>;
15
+ maxModelTokensLimit: z.ZodDefault<z.ZodNumber>;
16
+ uploadRetentionDays: z.ZodDefault<z.ZodNumber>;
14
17
  maxModelCalls: z.ZodDefault<z.ZodNumber>;
15
18
  maxModelTokens: z.ZodDefault<z.ZodNumber>;
19
+ imageTimeoutMs: z.ZodDefault<z.ZodNumber>;
16
20
  visionMaxEdge: z.ZodDefault<z.ZodNumber>;
17
21
  visionTileSize: z.ZodDefault<z.ZodNumber>;
18
22
  visionMaxTiles: z.ZodDefault<z.ZodNumber>;
package/lib/settings.js CHANGED
@@ -6,13 +6,16 @@ export const settingsSchema = z.strictObject({
6
6
  catalogMaxChars: positive.min(512).default(6000), candidateLimit: positive.max(30).default(6),
7
7
  maxInputBytes: positive.default(64 * 1024 * 1024), maxUncompressedBytes: positive.default(256 * 1024 * 1024), maxArchiveEntries: positive.default(10000),
8
8
  modelTimeoutMs: positive.default(90000), modelAttempts: positive.default(3), visionConcurrency: positive.max(8).default(2),
9
- maxModelCalls: positive.default(1000), maxModelTokens: positive.default(2000000), visionMaxEdge: positive.default(2048),
10
- visionTileSize: positive.default(1536), visionMaxTiles: positive.default(12), modelMaxOutputTokens: positive.default(4096),
9
+ maxModelCallsLimit: positive.max(100000).default(10000), maxModelTokensLimit: positive.max(1000000000).default(20000000), uploadRetentionDays: positive.max(365).default(7),
10
+ maxModelCalls: positive.max(100000).default(1000), maxModelTokens: positive.max(1000000000).default(2000000), imageTimeoutMs: positive.max(120000).default(60000), visionMaxEdge: positive.max(4096).default(2048),
11
+ visionTileSize: positive.max(4096).default(1536), visionMaxTiles: positive.max(64).default(12), modelMaxOutputTokens: positive.default(4096),
11
12
  summaryMaxChars: positive.default(160), figureMaxChars: positive.default(6000), pdfMaxPages: positive.default(2000),
12
13
  pdfMaxPagePixels: positive.default(12000000), pdfPageTimeoutMs: positive.default(60000),
13
14
  libreOfficePath: z.string().default(''), visionProvider: z.string().default(''), visionModel: z.string().default(''),
14
15
  summaryProvider: z.string().default(''), summaryModel: z.string().default(''), skipImages: z.boolean().default(false), acceptPartial: z.boolean().default(false),
15
16
  }).superRefine((value, context) => {
17
+ if (value.maxModelCalls > value.maxModelCallsLimit || value.maxModelTokens > value.maxModelTokensLimit)
18
+ context.addIssue({ code: 'custom', message: '默认任务预算不能超过宿主预算上限' });
16
19
  for (const kind of ['vision', 'summary'])
17
20
  if (Boolean(value[`${kind}Provider`]) !== Boolean(value[`${kind}Model`]))
18
21
  context.addIssue({ code: 'custom', message: `${kind}Provider / ${kind}Model 必须成对配置` });
package/lib/shared.d.ts CHANGED
@@ -93,3 +93,20 @@ export declare const revisionQuerySchema: z.ZodObject<{
93
93
  kbId: z.ZodString;
94
94
  revision: z.ZodOptional<z.ZodString>;
95
95
  }, z.core.$strict>;
96
+ export declare const jobListQuerySchema: z.ZodObject<{
97
+ jobsKbId: z.ZodOptional<z.ZodString>;
98
+ jobOffset: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
99
+ jobLimit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
100
+ }, z.core.$strict>;
101
+ export declare const jobDetailsQuerySchema: z.ZodObject<{
102
+ jobId: z.ZodString;
103
+ section: z.ZodDefault<z.ZodEnum<{
104
+ report: "report";
105
+ details: "details";
106
+ }>>;
107
+ }, z.core.$strict>;
108
+ export declare const jobUsageQuerySchema: z.ZodObject<{
109
+ jobId: z.ZodString;
110
+ offset: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
111
+ limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
112
+ }, z.core.$strict>;
package/lib/shared.js CHANGED
@@ -15,3 +15,6 @@ export const searchSchema = z.strictObject({ kbId: selector, revision: id.option
15
15
  export const readQuerySchema = z.strictObject({ kbId: selector, revision: id.optional(), chunkId: id, query: z.string().max(2000).optional(), keywords: keywordsSchema.optional(), segmentIndex: z.coerce.number().int().nonnegative().optional(), offset: z.coerce.number().int().nonnegative().optional() });
16
16
  export const assetQuerySchema = z.strictObject({ kbId: selector, revision: id.optional(), docId: id, assetId: id });
17
17
  export const revisionQuerySchema = z.strictObject({ kbId: selector, revision: id.optional() });
18
+ export const jobListQuerySchema = z.strictObject({ jobsKbId: id.optional(), jobOffset: z.coerce.number().int().nonnegative().max(1000000).default(0), jobLimit: z.coerce.number().int().min(1).max(50).default(20) });
19
+ export const jobDetailsQuerySchema = z.strictObject({ jobId: id, section: z.enum(['details', 'report']).default('details') });
20
+ export const jobUsageQuerySchema = jobDetailsQuerySchema.omit({ section: true }).extend({ offset: z.coerce.number().int().nonnegative().max(1000000).default(0), limit: z.coerce.number().int().min(1).max(50).default(20) });