dsh-knowledge-library 0.1.0 → 0.2.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 (47) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +123 -18
  3. package/dist/client.js +1699 -374
  4. package/dist/core/extract.d.ts +1 -0
  5. package/dist/core/extract.js +62 -5
  6. package/dist/core/fetch.js +82 -11
  7. package/dist/core/grouping.d.ts +6 -0
  8. package/dist/core/grouping.js +71 -0
  9. package/dist/core/incremental.d.ts +14 -0
  10. package/dist/core/incremental.js +52 -0
  11. package/dist/core/jobs.d.ts +1 -0
  12. package/dist/core/jobs.js +4 -0
  13. package/dist/core/knowledge.d.ts +37 -0
  14. package/dist/core/knowledge.js +617 -0
  15. package/dist/core/library.d.ts +20 -2
  16. package/dist/core/library.js +271 -12
  17. package/dist/core/page-access.d.ts +6 -0
  18. package/dist/core/page-access.js +38 -0
  19. package/dist/core/purge.d.ts +20 -0
  20. package/dist/core/purge.js +106 -0
  21. package/dist/directory-settings.d.ts +18 -0
  22. package/dist/directory-settings.js +92 -0
  23. package/dist/http.d.ts +3 -1
  24. package/dist/http.js +121 -4
  25. package/dist/index.js +17 -5
  26. package/dist/knowledge-model.d.ts +18 -0
  27. package/dist/knowledge-model.js +116 -0
  28. package/dist/preview-settings.d.ts +6 -0
  29. package/dist/preview-settings.js +42 -0
  30. package/dist/public/app.css +385 -182
  31. package/dist/public/app.js +11 -7
  32. package/dist/public/index.html +1 -1
  33. package/dist/standalone.js +8 -3
  34. package/dist/tools.d.ts +2 -1
  35. package/dist/tools.js +10 -1
  36. package/dist/types.d.ts +143 -1
  37. package/dist/ui/App.js +244 -47
  38. package/dist/ui/Dialog.d.ts +7 -0
  39. package/dist/ui/Dialog.js +32 -0
  40. package/dist/ui/Grouping.d.ts +12 -0
  41. package/dist/ui/Grouping.js +93 -0
  42. package/dist/ui/Knowledge.d.ts +26 -0
  43. package/dist/ui/Knowledge.js +106 -0
  44. package/dist/ui/api.d.ts +7 -0
  45. package/dist/ui/api.js +16 -0
  46. package/dist/ui/icons.js +2 -0
  47. package/package.json +4 -1
@@ -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,35 @@ 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
+ }
49
53
  if (path === '/stats') {
50
54
  send(res, 200, library.stats());
51
55
  return;
52
56
  }
57
+ if (path === '/trash') {
58
+ send(res, 200, library.trashSnapshot());
59
+ return;
60
+ }
53
61
  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 }));
62
+ 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
63
  return;
56
64
  }
57
65
  if (path === '/jobs') {
58
66
  send(res, 200, queue.list());
59
67
  return;
60
68
  }
69
+ if (path === '/knowledge') {
70
+ send(res, 200, knowledge?.overview() ?? { supported: false, available: false, reason: '当前服务未启用知识整理,请重启升级后的资料库服务', jobs: [], states: {} });
71
+ return;
72
+ }
73
+ const knowledgeJob = path.match(/^\/knowledge\/jobs\/(knowledge_[a-f0-9-]+)$/);
74
+ if (knowledgeJob && knowledge) {
75
+ send(res, 200, knowledge.get(knowledgeJob[1]));
76
+ return;
77
+ }
61
78
  const match = path.match(/^\/materials\/(r_[a-f0-9-]+)(?:\/(export|original))?$/);
62
79
  if (match) {
63
80
  if (match[2] === 'export') {
@@ -71,16 +88,109 @@ export function createHandler(library, queue, getOrigin) {
71
88
  res.end(original.bytes);
72
89
  return;
73
90
  }
74
- send(res, 200, library.get(match[1]));
91
+ const item = library.get(match[1]);
92
+ send(res, 200, { ...item, missingSources: item.citations.filter(c => !library.has(c.materialId)).map(c => c.materialId) });
75
93
  return;
76
94
  }
77
95
  }
78
- if (req.method === 'POST' || req.method === 'PATCH') {
96
+ if (req.method === 'POST' || req.method === 'PATCH' || req.method === 'DELETE') {
79
97
  if (req.headers['x-knowledge-library'] !== '1') {
80
98
  send(res, 403, { error: '缺少同源请求标记' });
81
99
  return;
82
100
  }
83
101
  const data = await body(req);
102
+ if (path.startsWith('/knowledge/')) {
103
+ if (!knowledge)
104
+ throw new Error('当前服务未启用知识整理,请重启资料库服务');
105
+ if (data.expectedRoot !== library.root)
106
+ throw new Error('资料库目录已改变,请刷新后再操作');
107
+ if (path === '/knowledge/jobs' && req.method === 'POST') {
108
+ send(res, 202, knowledge.add(data.sourceIds, data.topic, data.mode));
109
+ return;
110
+ }
111
+ const job = path.match(/^\/knowledge\/jobs\/(knowledge_[a-f0-9-]+)(?:\/(cancel|discard|confirm|plan|confirm-plan|rollback-plan))?$/);
112
+ if (job) {
113
+ if (req.method === 'PATCH' && job[2] === 'plan') {
114
+ send(res, 200, knowledge.editPlan(job[1], data.plan, data.version));
115
+ return;
116
+ }
117
+ if (req.method === 'POST' && job[2] === 'confirm-plan') {
118
+ if (data.confirmed !== true)
119
+ throw new Error('请确认分组与分类修改后再生成草稿');
120
+ send(res, 200, knowledge.confirmPlan(job[1], data.version, data.applyClassification, data.expectedModel));
121
+ return;
122
+ }
123
+ if (req.method === 'POST' && job[2] === 'rollback-plan') {
124
+ if (data.confirmed !== true)
125
+ throw new Error('请确认回退未完成的分类修改');
126
+ send(res, 200, knowledge.rollbackPlan(job[1]));
127
+ return;
128
+ }
129
+ if (req.method === 'PATCH' && !job[2]) {
130
+ send(res, 200, knowledge.edit(job[1], data.draft, data.version, data.integration));
131
+ return;
132
+ }
133
+ if (req.method === 'POST' && job[2] === 'cancel') {
134
+ send(res, 200, knowledge.cancel(job[1]));
135
+ return;
136
+ }
137
+ if (req.method === 'POST' && job[2] === 'discard') {
138
+ send(res, 200, knowledge.discard(job[1]));
139
+ return;
140
+ }
141
+ if (req.method === 'POST' && job[2] === 'confirm') {
142
+ if (data.confirmed !== true)
143
+ throw new Error('请确认保存知识笔记');
144
+ const current = knowledge.get(job[1]);
145
+ if (current.integration?.choice && current.integration.choice !== 'new' && data.integrationChoice !== current.integration.choice)
146
+ throw new Error('请核对增量处理方式后确认,旧版页面请刷新');
147
+ send(res, 200, knowledge.confirm(job[1], data.version, data.category));
148
+ return;
149
+ }
150
+ }
151
+ }
152
+ if (req.method === 'POST' && path === '/trash/empty') {
153
+ if (data.expectedRoot !== library.root)
154
+ throw new Error('资料库目录已改变,请刷新后再操作');
155
+ if (data.confirmed !== true || typeof data.token !== 'string')
156
+ throw new Error('请先确认永久清空回收站');
157
+ send(res, 200, library.emptyTrash(data.token));
158
+ return;
159
+ }
160
+ if (req.method === 'POST' && ['/directory/pick', '/directory/list', '/settings/root', '/settings/network'].includes(path)) {
161
+ if (!directories)
162
+ throw new Error('请在 Harness 中修改资料库设置');
163
+ if (path === '/settings/network') {
164
+ if (typeof data.allowProxyFakeIp !== 'boolean' || typeof data.expected !== 'boolean' || typeof data.expectedRoot !== 'string')
165
+ throw new Error('代理兼容设置必须是布尔值,并提供当前资料库目录');
166
+ const allowProxyFakeIp = await directories.setProxyMode(data.allowProxyFakeIp, data.expected, data.expectedRoot);
167
+ send(res, 200, { allowProxyFakeIp });
168
+ return;
169
+ }
170
+ if (path === '/settings/root') {
171
+ if (typeof data.root !== 'string' || typeof data.expectedRoot !== 'string')
172
+ throw new Error('资料库目录必须是字符串');
173
+ const root = await directories.change(data.root, data.expectedRoot);
174
+ send(res, 200, { root });
175
+ return;
176
+ }
177
+ const abort = new AbortController();
178
+ const disconnected = () => abort.abort();
179
+ res.once('close', disconnected);
180
+ try {
181
+ if (path === '/directory/pick')
182
+ send(res, 200, { path: await directories.pick(abort.signal) });
183
+ else {
184
+ if (data.path !== undefined && typeof data.path !== 'string')
185
+ throw new Error('目录路径必须是字符串');
186
+ send(res, 200, await directories.list(data.path, abort.signal));
187
+ }
188
+ }
189
+ finally {
190
+ res.off('close', disconnected);
191
+ }
192
+ return;
193
+ }
84
194
  if (req.method === 'POST' && path === '/capture') {
85
195
  const captureFields = fields(data);
86
196
  if (data.type === 'url' && typeof data.content === 'string') {
@@ -109,6 +219,13 @@ export function createHandler(library, queue, getOrigin) {
109
219
  send(res, 200, library.update(match[1], fields(data)));
110
220
  return;
111
221
  }
222
+ const restore = path.match(/^\/materials\/(r_[a-f0-9-]+)\/restore$/);
223
+ if ((req.method === 'DELETE' && match) || (req.method === 'POST' && restore)) {
224
+ if (data.expectedRoot !== library.root)
225
+ throw new Error('资料库目录已改变,请刷新后再操作');
226
+ send(res, 200, match ? library.trash(match[1]) : library.restore(restore[1]));
227
+ return;
228
+ }
112
229
  const retry = path.match(/^\/jobs\/(job_[a-f0-9-]+)\/retry$/);
113
230
  if (req.method === 'POST' && retry) {
114
231
  send(res, 200, queue.retry(retry[1]));
package/dist/index.js CHANGED
@@ -6,6 +6,9 @@ import { ImportQueue } from './core/jobs.js';
6
6
  import { createTools } from './tools.js';
7
7
  import { API_PATH, createHandler } from './http.js';
8
8
  import { fetchPublicPage } from './core/fetch.js';
9
+ import { createDirectorySettings } from './directory-settings.js';
10
+ import { KnowledgeQueue } from './core/knowledge.js';
11
+ import { harnessKnowledgeModel } from './knowledge-model.js';
9
12
  export const name = 'knowledge-library';
10
13
  export const inject = ['tools'];
11
14
  export const Config = z.object({
@@ -15,21 +18,30 @@ export const Config = z.object({
15
18
  export function apply(ctx, config) {
16
19
  const library = new Library(config.root);
17
20
  let queue;
21
+ let knowledge;
22
+ try {
23
+ knowledge = new KnowledgeQueue(library, harnessKnowledgeModel(ctx));
24
+ }
25
+ catch (error) {
26
+ library.close();
27
+ throw error;
28
+ }
18
29
  try {
19
30
  queue = new ImportQueue(library, (url, signal) => fetchPublicPage(url, signal, { allowProxyFakeIp: config.allowProxyFakeIp }));
20
31
  }
21
32
  catch (error) {
33
+ void knowledge.close();
22
34
  library.close();
23
35
  throw error;
24
36
  }
25
- ctx.effect(() => async () => { await queue.close(); library.close(); });
26
- for (const tool of createTools(library, queue))
37
+ ctx.effect(() => async () => { await Promise.all([queue.close(), knowledge.close()]); library.close(); });
38
+ for (const tool of createTools(library, queue, knowledge))
27
39
  ctx.tools.register(tool);
28
40
  ctx.inject(['webServer'], child => {
29
- child.webServer.register({
41
+ child.effect(() => child.webServer.register({
30
42
  kind: 'prefix', path: API_PATH,
31
- handler: createHandler(library, queue, () => child.webServer.getOrigin()),
32
- });
43
+ handler: createHandler(library, queue, () => child.webServer.getOrigin(), createDirectorySettings(ctx, library, config), knowledge),
44
+ }));
33
45
  });
34
46
  ctx.logger.info(`资料库已加载:${library.root}`);
35
47
  }
@@ -0,0 +1,18 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ export interface ModelRequest {
3
+ system: string;
4
+ prompt: string;
5
+ maxTokens: number;
6
+ signal: AbortSignal;
7
+ }
8
+ export interface KnowledgeModel {
9
+ status(): {
10
+ available: boolean;
11
+ model?: string;
12
+ reason?: string;
13
+ };
14
+ generate(request: ModelRequest): Promise<string>;
15
+ }
16
+ export declare function harnessKnowledgeModel(ctx: Context): KnowledgeModel;
17
+ /** Preview credentials stay on the server and are never returned through HTTP. */
18
+ export declare function previewKnowledgeModel(env?: NodeJS.ProcessEnv): KnowledgeModel;
@@ -0,0 +1,116 @@
1
+ const unavailable = '请先在 Harness 设置中配置默认模型;独立预览可通过环境变量配置模型。';
2
+ class ModelResponseError extends Error {
3
+ }
4
+ export function harnessKnowledgeModel(ctx) {
5
+ function services() {
6
+ const llm = ctx.get('llm');
7
+ const defaults = ctx.get('agentDefaultModel');
8
+ const selection = defaults?.currentSelection();
9
+ if (!llm || !selection?.provider || !selection.model || !llm.listProviders().some(p => p.id === selection.provider))
10
+ throw new Error(unavailable);
11
+ return { llm, selection };
12
+ }
13
+ return {
14
+ status() {
15
+ try {
16
+ const { selection } = services();
17
+ return { available: true, model: `${selection.provider} / ${selection.model}` };
18
+ }
19
+ catch {
20
+ return { available: false, reason: unavailable };
21
+ }
22
+ },
23
+ async generate({ system, prompt, maxTokens, signal }) {
24
+ const { llm, selection } = services();
25
+ signal.throwIfAborted();
26
+ let text = '';
27
+ let finished = false;
28
+ try {
29
+ for await (const chunk of llm.stream({ ...selection, system, messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }], maxTokens, signal })) {
30
+ signal.throwIfAborted();
31
+ // Complete blocks are authoritative; deltas may include reasoning or retries.
32
+ if (chunk.type === 'block-end' && chunk.block.type === 'text')
33
+ text += chunk.block.text;
34
+ if (text.length > 60_000)
35
+ throw new ModelResponseError('模型输出过长,请缩小整理范围');
36
+ if (chunk.type === 'finish') {
37
+ if (chunk.reason.kind !== 'stop')
38
+ throw new ModelResponseError(chunk.reason.kind === 'max-tokens' ? '模型输出达到长度上限,请缩小整理范围后重试' : '模型调用未完成,请检查 Harness 模型设置后重试');
39
+ finished = true;
40
+ }
41
+ }
42
+ }
43
+ catch (error) {
44
+ signal.throwIfAborted();
45
+ if (error instanceof ModelResponseError)
46
+ throw error;
47
+ throw new Error('模型调用失败,请检查 Harness 模型配置或网络后重试');
48
+ }
49
+ signal.throwIfAborted();
50
+ if (!finished || !text.trim())
51
+ throw new Error('模型没有返回完整正文,请重试');
52
+ return text;
53
+ },
54
+ };
55
+ }
56
+ /** Preview credentials stay on the server and are never returned through HTTP. */
57
+ export function previewKnowledgeModel(env = process.env) {
58
+ const key = env.KNOWLEDGE_LIBRARY_MODEL_API_KEY || env.DEEPSEEK_API_KEY;
59
+ const model = env.KNOWLEDGE_LIBRARY_MODEL || 'deepseek-chat';
60
+ const base = env.KNOWLEDGE_LIBRARY_MODEL_BASE_URL || 'https://api.deepseek.com';
61
+ return {
62
+ status: () => key ? { available: true, model } : { available: false, reason: unavailable },
63
+ async generate({ system, prompt, maxTokens, signal }) {
64
+ if (!key)
65
+ throw new Error(unavailable);
66
+ let response;
67
+ try {
68
+ const url = new URL(`${base.replace(/\/$/, '')}/chat/completions`);
69
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password)
70
+ throw new Error();
71
+ response = await fetch(url, { method: 'POST', redirect: 'error', signal,
72
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
73
+ body: JSON.stringify({ model, stream: false, max_tokens: maxTokens, messages: [{ role: 'system', content: system }, { role: 'user', content: prompt }] }) });
74
+ }
75
+ catch {
76
+ signal.throwIfAborted();
77
+ throw new Error('模型连接失败,请检查服务地址和网络后重试');
78
+ }
79
+ if (!response.ok) {
80
+ await response.body?.cancel();
81
+ throw new Error(`模型调用失败(HTTP ${response.status}),请检查模型配置或额度`);
82
+ }
83
+ // Bound bytes before JSON parsing, including chunked responses.
84
+ const reader = response.body?.getReader();
85
+ if (!reader)
86
+ throw new Error('模型响应为空');
87
+ const chunks = [];
88
+ let length = 0;
89
+ try {
90
+ while (true) {
91
+ const part = await reader.read();
92
+ if (part.done)
93
+ break;
94
+ length += part.value.length;
95
+ if (length > 512_000)
96
+ throw new Error('模型响应过大,请缩小整理范围');
97
+ chunks.push(part.value);
98
+ }
99
+ }
100
+ finally {
101
+ await reader.cancel().catch(() => { });
102
+ }
103
+ let data;
104
+ try {
105
+ data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
106
+ }
107
+ catch {
108
+ throw new Error('模型响应不是有效 JSON');
109
+ }
110
+ const choice = data.choices?.[0];
111
+ if (choice?.finish_reason !== 'stop' || typeof choice.message?.content !== 'string' || !choice.message.content.trim())
112
+ throw new Error('模型未返回完整正文,可能达到输出上限,请缩小范围后重试');
113
+ return choice.message.content;
114
+ },
115
+ };
116
+ }
@@ -0,0 +1,6 @@
1
+ import type { DirectorySettings } from './directory-settings.js';
2
+ import { type Library } from './core/library.js';
3
+ import type { FetchPolicy } from './core/fetch.js';
4
+ export declare function createPreviewSettings(library: Library, defaults?: FetchPolicy): DirectorySettings & {
5
+ fetchPolicy(): FetchPolicy;
6
+ };
@@ -0,0 +1,42 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { atomicJson } from './core/library.js';
4
+ export function createPreviewSettings(library, defaults = {}) {
5
+ const file = join(library.root, '.system', 'preview-settings.json');
6
+ let allowProxyFakeIp = defaults.allowProxyFakeIp === true;
7
+ if (existsSync(file)) {
8
+ let stored;
9
+ try {
10
+ stored = JSON.parse(readFileSync(file, 'utf8'));
11
+ }
12
+ catch {
13
+ throw new Error('独立预览设置读取失败,请检查 .system/preview-settings.json');
14
+ }
15
+ if (stored?.schemaVersion !== 1 || typeof stored.allowProxyFakeIp !== 'boolean')
16
+ throw new Error('独立预览设置格式不正确,请检查 .system/preview-settings.json');
17
+ allowProxyFakeIp = stored.allowProxyFakeIp;
18
+ }
19
+ const unsupported = async () => { throw new Error('独立预览请通过启动配置指定资料库目录'); };
20
+ return {
21
+ status: () => ({ canChange: false, canChangeNetwork: true, picker: null, allowProxyFakeIp }),
22
+ // Each request keeps its own policy even if settings change during a redirect.
23
+ fetchPolicy: () => ({ allowProxyFakeIp }),
24
+ pick: unsupported,
25
+ list: unsupported,
26
+ change: unsupported,
27
+ async setProxyMode(enabled, expected, expectedRoot) {
28
+ if (expectedRoot !== library.root || expected !== allowProxyFakeIp)
29
+ throw new Error('资料库设置已改变,请重新打开设置后重试');
30
+ if (enabled === allowProxyFakeIp)
31
+ return enabled;
32
+ try {
33
+ atomicJson(file, { schemaVersion: 1, allowProxyFakeIp: enabled });
34
+ }
35
+ catch {
36
+ throw new Error('采集设置保存失败,请检查资料库目录的写入权限;原设置未改变');
37
+ }
38
+ allowProxyFakeIp = enabled;
39
+ return enabled;
40
+ },
41
+ };
42
+ }