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,97 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { splitSegments } from './extract.js';
3
+ export const imageHash = (value) => createHash('sha256').update(value).digest('hex');
4
+ export const imageFilePattern = /^img_[a-f0-9-]{36}\.(png|jpg|webp|gif)$/;
5
+ export function validateImageManifest(value) {
6
+ const data = value;
7
+ if (!data || data.schemaVersion !== 1 || typeof data.model !== 'string' || !data.model || data.model.length > 300 || !Number.isInteger(data.limit) || data.limit < 1 || data.limit > 10 || !Number.isInteger(data.excluded) || data.excluded < 0 || !Array.isArray(data.records) || data.records.length > data.limit)
8
+ throw new Error('网页图片记录损坏');
9
+ const seen = new Set();
10
+ for (const record of data.records) {
11
+ if (!record || !/^img_[a-f0-9-]{36}$/.test(record.id) || seen.has(record.id) || typeof record.url !== 'string' || record.url.length > 8192 || !/^https?:\/\//.test(record.url) || typeof record.alt !== 'string' || record.alt.length > 500 || !['pending', 'downloading', 'recognizing', 'completed', 'failed', 'skipped'].includes(record.status) || !Number.isInteger(record.attempts) || record.attempts < 0 || record.attempts > 3)
12
+ throw new Error('网页图片条目损坏');
13
+ seen.add(record.id);
14
+ if (record.fileName !== undefined && (!imageFilePattern.test(record.fileName) || !record.fileName.startsWith(record.id + '.') || !/^[a-f0-9]{64}$/.test(record.checksum || '') || !['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(record.mediaType || '')))
15
+ throw new Error('网页原图记录无效');
16
+ for (const [key, max] of [['text', 8000], ['description', 3000], ['uncertainty', 1000], ['error', 2000]])
17
+ if (record[key] !== undefined && (typeof record[key] !== 'string' || record[key].length > max))
18
+ throw new Error('图片识别文字记录无效');
19
+ if (record.status === 'completed' && (!record.fileName || (!record.text?.trim() && !record.description?.trim())))
20
+ throw new Error('图片识别结果不完整');
21
+ }
22
+ return data;
23
+ }
24
+ export function imageSupplement(data) {
25
+ const sections = [];
26
+ const segments = [];
27
+ for (const [index, image] of (data?.records || []).entries()) {
28
+ if (image.status !== 'completed')
29
+ continue;
30
+ const section = `## 图片 ${index + 1} 识别补充(模型输出,请核对)\n\n来源图片:${image.url}\n模型:${data.model}${image.alt ? `\n原图说明:${image.alt}` : ''}\n\n${image.text ? `### 图中文字\n${image.text}\n\n` : ''}${image.description ? `### 图示解读(模型推断)\n${image.description}\n\n` : ''}${image.uncertainty ? `### 不确定与待核实\n${image.uncertainty}` : '请结合原图核验文字、数字与适用条件。'}`;
31
+ sections.push(section);
32
+ segments.push(...splitSegments(section).map((segment, i) => ({ ...segment, id: `v_${image.id.slice(4).replaceAll('-', '')}_${i + 1}` })));
33
+ }
34
+ return { text: sections.join('\n\n'), segments };
35
+ }
36
+ /** Sniff raster bytes and dimensions without decoding scripts or unbounded compressed content. */
37
+ export function inspectImage(bytes) {
38
+ if (!bytes.length || bytes.length > 6 * 1024 * 1024)
39
+ throw new Error('图片不能为空且最多6MB');
40
+ let type;
41
+ let width = 0;
42
+ let height = 0;
43
+ if (bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) && bytes.toString('ascii', 12, 16) === 'IHDR') {
44
+ type = 'png';
45
+ width = bytes.readUInt32BE(16);
46
+ height = bytes.readUInt32BE(20);
47
+ }
48
+ else if (bytes.length >= 10 && /^GIF8[79]a$/.test(bytes.toString('ascii', 0, 6))) {
49
+ type = 'gif';
50
+ width = bytes.readUInt16LE(6);
51
+ height = bytes.readUInt16LE(8);
52
+ }
53
+ else if (bytes.length >= 12 && bytes[0] === 255 && bytes[1] === 216) {
54
+ for (let offset = 2; offset + 8 < bytes.length;) {
55
+ if (bytes[offset++] !== 255)
56
+ continue;
57
+ const marker = bytes[offset++];
58
+ if (marker === 0xd9 || marker === 0xda)
59
+ break;
60
+ if (marker === 0xff || marker === 0x00 || (marker >= 0xd0 && marker <= 0xd8))
61
+ continue;
62
+ const length = bytes.readUInt16BE(offset);
63
+ if (length < 2 || offset + length > bytes.length)
64
+ break;
65
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
66
+ type = 'jpg';
67
+ height = bytes.readUInt16BE(offset + 3);
68
+ width = bytes.readUInt16BE(offset + 5);
69
+ break;
70
+ }
71
+ offset += length;
72
+ }
73
+ }
74
+ else if (bytes.length >= 30 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') {
75
+ const chunk = bytes.toString('ascii', 12, 16);
76
+ if (chunk === 'VP8X') {
77
+ width = bytes.readUIntLE(24, 3) + 1;
78
+ height = bytes.readUIntLE(27, 3) + 1;
79
+ }
80
+ else if (chunk === 'VP8 ' && bytes.subarray(23, 26).equals(Buffer.from([157, 1, 42]))) {
81
+ width = bytes.readUInt16LE(26) & 0x3fff;
82
+ height = bytes.readUInt16LE(28) & 0x3fff;
83
+ }
84
+ else if (chunk === 'VP8L' && bytes[20] === 0x2f) {
85
+ const packed = bytes.readUInt32LE(21);
86
+ width = (packed & 0x3fff) + 1;
87
+ height = ((packed >>> 14) & 0x3fff) + 1;
88
+ }
89
+ if (width && height)
90
+ type = 'webp';
91
+ }
92
+ if (!type || !width || !height)
93
+ throw new Error('不是有效的PNG、JPEG、WebP或GIF图片(不支持SVG/HTML)');
94
+ if (width > 20_000 || height > 20_000 || width * height > 24_000_000)
95
+ throw new Error('图片尺寸超过限制(最长边2万像素、总计2400万像素)');
96
+ return { extension: type, mediaType: `image/${type === 'jpg' ? 'jpeg' : type}`, width, height };
97
+ }
@@ -0,0 +1,20 @@
1
+ export interface PurgeTicket {
2
+ id: string;
3
+ source: string;
4
+ staged: string;
5
+ manifest: string;
6
+ }
7
+ /** Durable, confirmed cleanup work. Only owned material directories are removable. */
8
+ export declare class PurgeStore {
9
+ private root;
10
+ readonly directory: string;
11
+ constructor(root: string);
12
+ private contained;
13
+ private tree;
14
+ private ticket;
15
+ validate(id: string, source: string): void;
16
+ list(): PurgeTicket[];
17
+ stage(id: string, source: string): PurgeTicket;
18
+ rollback(ticket: PurgeTicket): void;
19
+ finish(ticket: PurgeTicket): void;
20
+ }
@@ -0,0 +1,106 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ const validId = (id) => typeof id === 'string' && /^r_[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(id);
4
+ /** Durable, confirmed cleanup work. Only owned material directories are removable. */
5
+ export class PurgeStore {
6
+ root;
7
+ directory;
8
+ constructor(root) {
9
+ this.root = root;
10
+ this.directory = join(root, '.system', 'purge');
11
+ }
12
+ contained(path) {
13
+ const rel = relative(this.root, resolve(path));
14
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
15
+ throw new Error('清理路径超出资料库');
16
+ let current = this.root;
17
+ if (lstatSync(current).isSymbolicLink())
18
+ throw new Error('清理不支持符号链接');
19
+ for (const part of rel.split(sep)) {
20
+ current = join(current, part);
21
+ try {
22
+ if (lstatSync(current).isSymbolicLink())
23
+ throw new Error('清理不支持符号链接');
24
+ }
25
+ catch (error) {
26
+ if (error.code !== 'ENOENT')
27
+ throw error;
28
+ }
29
+ }
30
+ }
31
+ tree(path) {
32
+ this.contained(path);
33
+ const info = lstatSync(path);
34
+ if (info.isSymbolicLink())
35
+ throw new Error('清理不支持符号链接');
36
+ if (info.isDirectory())
37
+ for (const entry of readdirSync(path))
38
+ this.tree(join(path, entry));
39
+ }
40
+ ticket(id, source) {
41
+ if (!validId(id))
42
+ throw new Error('清理资料 ID 无效');
43
+ source = resolve(source);
44
+ const rel = relative(join(this.root, 'materials'), source);
45
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) || basename(source) !== id)
46
+ throw new Error('清理目标不是资料目录');
47
+ const ticket = { id, source, staged: join(this.directory, id), manifest: join(this.directory, `${id}.json`) };
48
+ for (const path of [ticket.source, ticket.staged, ticket.manifest])
49
+ this.contained(path);
50
+ return ticket;
51
+ }
52
+ validate(id, source) { this.tree(this.ticket(id, source).source); }
53
+ list() {
54
+ this.contained(this.directory);
55
+ if (!existsSync(this.directory))
56
+ return [];
57
+ return readdirSync(this.directory).filter(name => name.endsWith('.json')).sort().map(name => {
58
+ const manifest = join(this.directory, name);
59
+ this.contained(manifest);
60
+ const data = JSON.parse(readFileSync(manifest, 'utf8'));
61
+ if (data.schemaVersion !== 1 || !validId(data.id) || name !== `${data.id}.json` || typeof data.source !== 'string' || isAbsolute(data.source))
62
+ throw new Error('待清理记录损坏');
63
+ return this.ticket(data.id, resolve(this.root, data.source));
64
+ });
65
+ }
66
+ stage(id, source) {
67
+ const ticket = this.ticket(id, source);
68
+ this.tree(ticket.source);
69
+ if (existsSync(ticket.staged))
70
+ throw new Error('该资料仍有待完成的文件清理');
71
+ mkdirSync(this.directory, { recursive: true });
72
+ writeFileSync(ticket.manifest, JSON.stringify({ schemaVersion: 1, id, source: relative(this.root, source) }), { flag: 'wx' });
73
+ try {
74
+ renameSync(ticket.source, ticket.staged);
75
+ }
76
+ catch (error) {
77
+ unlinkSync(ticket.manifest);
78
+ throw error;
79
+ }
80
+ return ticket;
81
+ }
82
+ rollback(ticket) {
83
+ const checked = this.ticket(ticket.id, ticket.source);
84
+ if (existsSync(checked.source))
85
+ throw new Error('原目录已有内容,无法回退文件清理');
86
+ this.tree(checked.staged);
87
+ renameSync(checked.staged, checked.source);
88
+ unlinkSync(checked.manifest);
89
+ }
90
+ finish(ticket) {
91
+ const checked = this.ticket(ticket.id, ticket.source);
92
+ if (existsSync(checked.source)) {
93
+ if (existsSync(checked.staged))
94
+ throw new Error('清理目录与原目录冲突,文件已保留');
95
+ // The process stopped before the move; no original file may be deleted.
96
+ unlinkSync(checked.manifest);
97
+ return;
98
+ }
99
+ if (existsSync(checked.staged)) {
100
+ this.tree(checked.staged);
101
+ // Both the absolute boundary and every descendant were checked above.
102
+ rmSync(checked.staged, { recursive: true, maxRetries: 2, retryDelay: 50 });
103
+ }
104
+ unlinkSync(checked.manifest);
105
+ }
106
+ }
@@ -0,0 +1,18 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker';
3
+ import { Library } from './core/library.js';
4
+ import type { FetchPolicy } from './core/fetch.js';
5
+ export type { DirectoryListing };
6
+ export interface DirectorySettings {
7
+ status(): {
8
+ canChange: boolean;
9
+ canChangeNetwork: boolean;
10
+ picker: 'native' | 'browse' | null;
11
+ allowProxyFakeIp: boolean;
12
+ };
13
+ pick(signal: AbortSignal): Promise<string | null>;
14
+ list(path: string | undefined, signal: AbortSignal): Promise<DirectoryListing>;
15
+ change(root: string, expectedRoot: string): Promise<string>;
16
+ setProxyMode(enabled: boolean, expected: boolean, expectedRoot: string): Promise<boolean>;
17
+ }
18
+ export declare function createDirectorySettings(ctx: Context, library: Library, policy?: FetchPolicy): DirectorySettings;
@@ -0,0 +1,92 @@
1
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
2
+ import { Library } from './core/library.js';
3
+ export function createDirectorySettings(ctx, library, policy = {}) {
4
+ let changing = false;
5
+ let picking = false;
6
+ const editorEntry = () => {
7
+ const editor = ctx.get('configEditor');
8
+ const entry = editor?.entries().find(entry => entry.fiber === ctx.fiber);
9
+ return editor && entry ? { editor, entry } : undefined;
10
+ };
11
+ const capability = () => ctx.get('directoryPicker')?.capability();
12
+ return {
13
+ status() {
14
+ const kind = capability()?.kind;
15
+ const editable = !!editorEntry();
16
+ return { canChange: editable, canChangeNetwork: editable, picker: kind === 'native' || kind === 'browse' ? kind : null, allowProxyFakeIp: policy.allowProxyFakeIp === true };
17
+ },
18
+ async pick(signal) {
19
+ const picker = capability();
20
+ if (picker?.kind !== 'native')
21
+ throw new Error('当前环境不支持系统目录选择,请浏览目录或填写绝对路径');
22
+ if (picking)
23
+ throw new Error('目录选择窗口已打开');
24
+ picking = true;
25
+ try {
26
+ return await picker.pick(signal);
27
+ }
28
+ finally {
29
+ picking = false;
30
+ }
31
+ },
32
+ async list(path, signal) {
33
+ const picker = capability();
34
+ if (picker?.kind !== 'browse')
35
+ throw new Error('当前环境不支持目录浏览,请使用系统目录选择');
36
+ return picker.list(path, signal);
37
+ },
38
+ async change(root, expectedRoot) {
39
+ if (changing)
40
+ throw new Error('正在切换资料库,请稍候');
41
+ if (expectedRoot !== library.root)
42
+ throw new Error('资料库目录已改变,请刷新设置后重试');
43
+ if (!root.trim() || !isAbsolute(root) || (process.platform === 'win32' && !/^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(root)))
44
+ throw new Error('请选择或填写完整的资料库绝对路径');
45
+ const next = resolve(root);
46
+ if (next === library.root)
47
+ return library.root;
48
+ const rel = relative(library.root, next);
49
+ const back = relative(next, library.root);
50
+ const inside = (value) => !!value && !isAbsolute(value) && value !== '..' && !value.startsWith(`..${sep}`);
51
+ if (inside(rel) || inside(back))
52
+ throw new Error('新旧资料库不能互相包含,请选择独立目录');
53
+ const target = editorEntry();
54
+ if (!target)
55
+ throw new Error('当前环境无法保存插件配置,请在 Harness 中修改资料库目录');
56
+ changing = true;
57
+ try {
58
+ // Check write access, the index and the single-writer lock before reloading.
59
+ const probe = new Library(next);
60
+ probe.close();
61
+ await target.editor.edit(target.entry, current => ({ ...current, root: next }));
62
+ return next;
63
+ }
64
+ finally {
65
+ changing = false;
66
+ }
67
+ },
68
+ async setProxyMode(enabled, expected, expectedRoot) {
69
+ if (changing)
70
+ throw new Error('正在保存资料库设置,请稍候');
71
+ if (expectedRoot !== library.root || expected !== (policy.allowProxyFakeIp === true))
72
+ throw new Error('资料库设置已改变,请重新打开设置后重试');
73
+ const target = editorEntry();
74
+ if (!target)
75
+ throw new Error('当前环境无法保存插件配置,请在 Harness 中修改采集设置');
76
+ if (enabled === expected)
77
+ return enabled;
78
+ changing = true;
79
+ try {
80
+ await target.editor.edit(target.entry, current => {
81
+ if ((current.allowProxyFakeIp === true) !== expected)
82
+ throw new Error('代理兼容设置已改变,请重新打开设置后重试');
83
+ return { ...current, allowProxyFakeIp: enabled };
84
+ });
85
+ return enabled;
86
+ }
87
+ finally {
88
+ changing = false;
89
+ }
90
+ },
91
+ };
92
+ }
package/dist/http.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { Library } from './core/library.js';
3
3
  import type { ImportQueue } from './core/jobs.js';
4
+ import type { DirectorySettings } from './directory-settings.js';
5
+ import type { KnowledgeQueue } from './core/knowledge.js';
4
6
  export declare const API_PATH = "/knowledge-library/api";
5
- export declare function createHandler(library: Library, queue: ImportQueue, getOrigin: () => string): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
7
+ export declare function createHandler(library: Library, queue: ImportQueue, getOrigin: () => string, directories?: DirectorySettings, knowledge?: KnowledgeQueue): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
6
8
  export declare function serveAsset(req: IncomingMessage, res: ServerResponse): void;
package/dist/http.js CHANGED
@@ -31,7 +31,7 @@ function fields(data) {
31
31
  return { title: data.title, category: data.category, collection: data.collection, tags: data.tags, sourceNote: data.sourceNote, sourceUrl: data.sourceUrl };
32
32
  }
33
33
  const localAddress = (address = '') => address === '::1' || address === '127.0.0.1' || address === '::ffff:127.0.0.1';
34
- export function createHandler(library, queue, getOrigin) {
34
+ export function createHandler(library, queue, getOrigin, directories, knowledge) {
35
35
  return async (req, res) => {
36
36
  try {
37
37
  const origin = getOrigin();
@@ -46,18 +46,46 @@ export function createHandler(library, queue, getOrigin) {
46
46
  const url = new URL(req.url || '/', origin);
47
47
  const path = url.pathname.slice(API_PATH.length);
48
48
  if (req.method === 'GET') {
49
+ if (path === '/settings') {
50
+ send(res, 200, { root: library.root, ...(directories?.status() ?? { canChange: false, canChangeNetwork: false, picker: null, allowProxyFakeIp: false }) });
51
+ return;
52
+ }
53
+ if (path === '/settings/images') {
54
+ send(res, 200, queue.imageCapture ? await queue.imageCapture.status() : { supported: false, available: false, enabled: false, maxImages: 6, version: 0, reason: '请重启服务以启用网页图片识别' });
55
+ return;
56
+ }
49
57
  if (path === '/stats') {
50
58
  send(res, 200, library.stats());
51
59
  return;
52
60
  }
61
+ if (path === '/trash') {
62
+ send(res, 200, library.trashSnapshot());
63
+ return;
64
+ }
53
65
  if (path === '/materials') {
54
- send(res, 200, library.search({ ...Object.fromEntries(url.searchParams), limit: Number(url.searchParams.get('limit')) || 40, offset: Number(url.searchParams.get('offset')) || 0 }));
66
+ send(res, 200, library.search({ ...Object.fromEntries(url.searchParams), trashed: url.searchParams.get('trashed') === '1', limit: Number(url.searchParams.get('limit')) || 40, offset: Number(url.searchParams.get('offset')) || 0 }));
55
67
  return;
56
68
  }
57
69
  if (path === '/jobs') {
58
70
  send(res, 200, queue.list());
59
71
  return;
60
72
  }
73
+ if (path === '/knowledge') {
74
+ send(res, 200, knowledge?.overview() ?? { supported: false, available: false, reason: '当前服务未启用知识整理,请重启升级后的资料库服务', jobs: [], states: {} });
75
+ return;
76
+ }
77
+ const knowledgeJob = path.match(/^\/knowledge\/jobs\/(knowledge_[a-f0-9-]+)$/);
78
+ if (knowledgeJob && knowledge) {
79
+ send(res, 200, knowledge.get(knowledgeJob[1]));
80
+ return;
81
+ }
82
+ const pageImage = path.match(/^\/materials\/(r_[a-f0-9-]+)\/images\/(img_[a-f0-9-]+)$/);
83
+ if (pageImage) {
84
+ const original = library.pageImageFile(pageImage[1], pageImage[2]);
85
+ res.writeHead(200, { 'Content-Type': original.mediaType, 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-store', 'Content-Security-Policy': "default-src 'none'; sandbox" });
86
+ res.end(original.bytes);
87
+ return;
88
+ }
61
89
  const match = path.match(/^\/materials\/(r_[a-f0-9-]+)(?:\/(export|original))?$/);
62
90
  if (match) {
63
91
  if (match[2] === 'export') {
@@ -71,16 +99,129 @@ export function createHandler(library, queue, getOrigin) {
71
99
  res.end(original.bytes);
72
100
  return;
73
101
  }
74
- send(res, 200, library.get(match[1]));
102
+ const item = library.get(match[1]);
103
+ send(res, 200, { ...item, missingSources: item.citations.filter(c => !library.has(c.materialId)).map(c => c.materialId) });
75
104
  return;
76
105
  }
77
106
  }
78
- if (req.method === 'POST' || req.method === 'PATCH') {
107
+ if (req.method === 'POST' || req.method === 'PATCH' || req.method === 'DELETE') {
79
108
  if (req.headers['x-knowledge-library'] !== '1') {
80
109
  send(res, 403, { error: '缺少同源请求标记' });
81
110
  return;
82
111
  }
83
112
  const data = await body(req);
113
+ if (path === '/settings/images') {
114
+ if (!queue.imageCapture)
115
+ throw new Error('请重启服务以启用图片识别');
116
+ send(res, 200, await queue.imageCapture.save(data.settings, data.version, data.expectedRoot, data.expectedModel, data.confirmed));
117
+ return;
118
+ }
119
+ const imageRetry = path.match(/^\/materials\/(r_[a-f0-9-]+)\/images\/retry$/);
120
+ const imageCancel = path.match(/^\/jobs\/(job_[a-f0-9-]+)\/images\/cancel$/);
121
+ if (imageRetry || imageCancel) {
122
+ if (req.method !== 'POST' || data.expectedRoot !== library.root)
123
+ throw new Error('资料库目录已改变,请刷新后操作');
124
+ if (imageRetry) {
125
+ if (data.confirmed !== true)
126
+ throw new Error('请核对图片重试范围和模型费用');
127
+ send(res, 202, await queue.retryImagesForMaterial(imageRetry[1], data.expectedModel, data.imageIds));
128
+ }
129
+ else
130
+ send(res, 200, queue.cancelImages(imageCancel[1]));
131
+ return;
132
+ }
133
+ if (path.startsWith('/knowledge/')) {
134
+ if (!knowledge)
135
+ throw new Error('当前服务未启用知识整理,请重启资料库服务');
136
+ if (data.expectedRoot !== library.root)
137
+ throw new Error('资料库目录已改变,请刷新后再操作');
138
+ if (path === '/knowledge/jobs' && req.method === 'POST') {
139
+ send(res, 202, knowledge.add(data.sourceIds, data.topic, data.mode));
140
+ return;
141
+ }
142
+ const job = path.match(/^\/knowledge\/jobs\/(knowledge_[a-f0-9-]+)(?:\/(cancel|discard|confirm|plan|confirm-plan|rollback-plan))?$/);
143
+ if (job) {
144
+ if (req.method === 'PATCH' && job[2] === 'plan') {
145
+ send(res, 200, knowledge.editPlan(job[1], data.plan, data.version));
146
+ return;
147
+ }
148
+ if (req.method === 'POST' && job[2] === 'confirm-plan') {
149
+ if (data.confirmed !== true)
150
+ throw new Error('请确认分组与分类修改后再生成草稿');
151
+ send(res, 200, knowledge.confirmPlan(job[1], data.version, data.applyClassification, data.expectedModel));
152
+ return;
153
+ }
154
+ if (req.method === 'POST' && job[2] === 'rollback-plan') {
155
+ if (data.confirmed !== true)
156
+ throw new Error('请确认回退未完成的分类修改');
157
+ send(res, 200, knowledge.rollbackPlan(job[1]));
158
+ return;
159
+ }
160
+ if (req.method === 'PATCH' && !job[2]) {
161
+ send(res, 200, knowledge.edit(job[1], data.draft, data.version, data.integration));
162
+ return;
163
+ }
164
+ if (req.method === 'POST' && job[2] === 'cancel') {
165
+ send(res, 200, knowledge.cancel(job[1]));
166
+ return;
167
+ }
168
+ if (req.method === 'POST' && job[2] === 'discard') {
169
+ send(res, 200, knowledge.discard(job[1]));
170
+ return;
171
+ }
172
+ if (req.method === 'POST' && job[2] === 'confirm') {
173
+ if (data.confirmed !== true)
174
+ throw new Error('请确认保存知识笔记');
175
+ const current = knowledge.get(job[1]);
176
+ if (current.integration?.choice && current.integration.choice !== 'new' && data.integrationChoice !== current.integration.choice)
177
+ throw new Error('请核对增量处理方式后确认,旧版页面请刷新');
178
+ send(res, 200, knowledge.confirm(job[1], data.version, data.category));
179
+ return;
180
+ }
181
+ }
182
+ }
183
+ if (req.method === 'POST' && path === '/trash/empty') {
184
+ if (data.expectedRoot !== library.root)
185
+ throw new Error('资料库目录已改变,请刷新后再操作');
186
+ if (data.confirmed !== true || typeof data.token !== 'string')
187
+ throw new Error('请先确认永久清空回收站');
188
+ send(res, 200, library.emptyTrash(data.token));
189
+ return;
190
+ }
191
+ if (req.method === 'POST' && ['/directory/pick', '/directory/list', '/settings/root', '/settings/network'].includes(path)) {
192
+ if (!directories)
193
+ throw new Error('请在 Harness 中修改资料库设置');
194
+ if (path === '/settings/network') {
195
+ if (typeof data.allowProxyFakeIp !== 'boolean' || typeof data.expected !== 'boolean' || typeof data.expectedRoot !== 'string')
196
+ throw new Error('代理兼容设置必须是布尔值,并提供当前资料库目录');
197
+ const allowProxyFakeIp = await directories.setProxyMode(data.allowProxyFakeIp, data.expected, data.expectedRoot);
198
+ send(res, 200, { allowProxyFakeIp });
199
+ return;
200
+ }
201
+ if (path === '/settings/root') {
202
+ if (typeof data.root !== 'string' || typeof data.expectedRoot !== 'string')
203
+ throw new Error('资料库目录必须是字符串');
204
+ const root = await directories.change(data.root, data.expectedRoot);
205
+ send(res, 200, { root });
206
+ return;
207
+ }
208
+ const abort = new AbortController();
209
+ const disconnected = () => abort.abort();
210
+ res.once('close', disconnected);
211
+ try {
212
+ if (path === '/directory/pick')
213
+ send(res, 200, { path: await directories.pick(abort.signal) });
214
+ else {
215
+ if (data.path !== undefined && typeof data.path !== 'string')
216
+ throw new Error('目录路径必须是字符串');
217
+ send(res, 200, await directories.list(data.path, abort.signal));
218
+ }
219
+ }
220
+ finally {
221
+ res.off('close', disconnected);
222
+ }
223
+ return;
224
+ }
84
225
  if (req.method === 'POST' && path === '/capture') {
85
226
  const captureFields = fields(data);
86
227
  if (data.type === 'url' && typeof data.content === 'string') {
@@ -109,6 +250,13 @@ export function createHandler(library, queue, getOrigin) {
109
250
  send(res, 200, library.update(match[1], fields(data)));
110
251
  return;
111
252
  }
253
+ const restore = path.match(/^\/materials\/(r_[a-f0-9-]+)\/restore$/);
254
+ if ((req.method === 'DELETE' && match) || (req.method === 'POST' && restore)) {
255
+ if (data.expectedRoot !== library.root)
256
+ throw new Error('资料库目录已改变,请刷新后再操作');
257
+ send(res, 200, match ? library.trash(match[1]) : library.restore(restore[1]));
258
+ return;
259
+ }
112
260
  const retry = path.match(/^\/jobs\/(job_[a-f0-9-]+)\/retry$/);
113
261
  if (req.method === 'POST' && retry) {
114
262
  send(res, 200, queue.retry(retry[1]));
package/dist/index.js CHANGED
@@ -5,7 +5,11 @@ import { Library } from './core/library.js';
5
5
  import { ImportQueue } from './core/jobs.js';
6
6
  import { createTools } from './tools.js';
7
7
  import { API_PATH, createHandler } from './http.js';
8
- import { fetchPublicPage } from './core/fetch.js';
8
+ import { fetchPublicImage, fetchPublicPage } from './core/fetch.js';
9
+ import { ImageCapture } from './core/image-capture.js';
10
+ import { createDirectorySettings } from './directory-settings.js';
11
+ import { KnowledgeQueue } from './core/knowledge.js';
12
+ import { harnessKnowledgeModel } from './knowledge-model.js';
9
13
  export const name = 'knowledge-library';
10
14
  export const inject = ['tools'];
11
15
  export const Config = z.object({
@@ -15,21 +19,32 @@ export const Config = z.object({
15
19
  export function apply(ctx, config) {
16
20
  const library = new Library(config.root);
17
21
  let queue;
22
+ let knowledge;
23
+ const model = harnessKnowledgeModel(ctx);
18
24
  try {
19
- queue = new ImportQueue(library, (url, signal) => fetchPublicPage(url, signal, { allowProxyFakeIp: config.allowProxyFakeIp }));
25
+ knowledge = new KnowledgeQueue(library, model);
20
26
  }
21
27
  catch (error) {
22
28
  library.close();
23
29
  throw error;
24
30
  }
25
- ctx.effect(() => async () => { await queue.close(); library.close(); });
26
- for (const tool of createTools(library, queue))
31
+ try {
32
+ const images = new ImageCapture(library, model, (url, signal, _policy, referer) => fetchPublicImage(url, signal, { allowProxyFakeIp: config.allowProxyFakeIp }, referer));
33
+ queue = new ImportQueue(library, (url, signal) => fetchPublicPage(url, signal, { allowProxyFakeIp: config.allowProxyFakeIp }), images);
34
+ }
35
+ catch (error) {
36
+ void knowledge.close();
37
+ library.close();
38
+ throw error;
39
+ }
40
+ ctx.effect(() => async () => { await Promise.all([queue.close(), knowledge.close()]); library.close(); });
41
+ for (const tool of createTools(library, queue, knowledge))
27
42
  ctx.tools.register(tool);
28
43
  ctx.inject(['webServer'], child => {
29
- child.webServer.register({
44
+ child.effect(() => child.webServer.register({
30
45
  kind: 'prefix', path: API_PATH,
31
- handler: createHandler(library, queue, () => child.webServer.getOrigin()),
32
- });
46
+ handler: createHandler(library, queue, () => child.webServer.getOrigin(), createDirectorySettings(ctx, library, config), knowledge),
47
+ }));
33
48
  });
34
49
  ctx.logger.info(`资料库已加载:${library.root}`);
35
50
  }
@@ -0,0 +1,30 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { ImageMediaType } from '@deepseek-ai/dsh-attachment';
3
+ export interface ModelRequest {
4
+ system: string;
5
+ prompt: string;
6
+ signal: AbortSignal;
7
+ expectedModel?: string;
8
+ images?: Array<{
9
+ data: Uint8Array;
10
+ mediaType: ImageMediaType;
11
+ name?: string;
12
+ }>;
13
+ }
14
+ export interface VisionStatus {
15
+ available: boolean;
16
+ model?: string;
17
+ reason?: string;
18
+ }
19
+ export interface KnowledgeModel {
20
+ status(): {
21
+ available: boolean;
22
+ model?: string;
23
+ reason?: string;
24
+ };
25
+ generate(request: ModelRequest): Promise<string>;
26
+ visionStatus?(): Promise<VisionStatus>;
27
+ }
28
+ export declare function harnessKnowledgeModel(ctx: Context): KnowledgeModel;
29
+ /** Preview credentials stay on the server and are never returned through HTTP. */
30
+ export declare function previewKnowledgeModel(env?: NodeJS.ProcessEnv): KnowledgeModel;