dsh-plugin-litsearch 0.3.5 → 0.4.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.
- package/README.md +89 -14
- package/client/client.js +1192 -108
- package/lib/host/events.d.ts +9 -1
- package/lib/host/events.js +50 -3
- package/lib/host/router.js +66 -1
- package/lib/host/tasks/download.d.ts +104 -0
- package/lib/host/tasks/download.js +274 -0
- package/lib/host/tasks/filter.d.ts +8 -1
- package/lib/host/tasks/filter.js +14 -0
- package/lib/host/tasks/service.d.ts +34 -3
- package/lib/host/tasks/service.js +130 -17
- package/lib/host/tools.d.ts +4 -0
- package/lib/host/tools.js +808 -9
- package/lib/host/zotero/connector.d.ts +19 -10
- package/lib/host/zotero/connector.js +142 -39
- package/lib/host/zotero/match.d.ts +27 -0
- package/lib/host/zotero/match.js +59 -0
- package/lib/search/config.d.ts +0 -2
- package/lib/search/config.js +0 -2
- package/lib/search/types.d.ts +136 -1
- package/lib/search/types.js +7 -0
- package/package.json +1 -1
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
* 只允许回环地址:connector 是桌面端本地服务,绝不把用户数据发到外部主机。
|
|
5
5
|
* 任何失败都转成可读的一句话,由调用方汇总成「未运行 / 条目保存失败 / 附件失败」。
|
|
6
6
|
*/
|
|
7
|
-
import type { Paper, ZoteroProbeResult, ZoteroSyncResult } from '../../search/types.js';
|
|
7
|
+
import type { Paper, ZoteroProbeResult, ZoteroSyncResult, ZoteroTargetsResult } from '../../search/types.js';
|
|
8
8
|
export interface ZoteroConnectorOptions {
|
|
9
9
|
baseUrl: string;
|
|
10
10
|
attachPdf: boolean;
|
|
11
11
|
tag: string;
|
|
12
|
+
/** connector 的 treeViewID(如 `C15`);缺省沿用 Zotero 当前选中的目录。 */
|
|
13
|
+
targetId?: string;
|
|
14
|
+
/** 目标目录名,仅用于失败提示。 */
|
|
15
|
+
targetName?: string;
|
|
12
16
|
}
|
|
13
17
|
export interface ZoteroSyncInput {
|
|
14
18
|
paper: Paper;
|
|
@@ -20,8 +24,13 @@ export declare class ZoteroConnector {
|
|
|
20
24
|
private readonly baseUrl;
|
|
21
25
|
constructor(baseUrl: string);
|
|
22
26
|
probe(): Promise<ZoteroProbeResult>;
|
|
23
|
-
/**
|
|
24
|
-
|
|
27
|
+
/** 所有可写入的库与分类,供同步弹窗选择目标。 */
|
|
28
|
+
listTargets(): Promise<ZoteroTargetsResult>;
|
|
29
|
+
/**
|
|
30
|
+
* `/connector/getSelectedCollection` 只接受 POST,返回当前目标与全部可写目标。
|
|
31
|
+
* 注意 GET 会直接 400,因此不能沿用 ping 的方式。
|
|
32
|
+
*/
|
|
33
|
+
private fetchTargetInfo;
|
|
25
34
|
/**
|
|
26
35
|
* 保存条目并可选上传本地 PDF。
|
|
27
36
|
*
|
|
@@ -29,6 +38,13 @@ export declare class ZoteroConnector {
|
|
|
29
38
|
* messages,条目仍算保存成功。
|
|
30
39
|
*/
|
|
31
40
|
sync(inputs: readonly ZoteroSyncInput[], options: ZoteroConnectorOptions): Promise<ZoteroSyncResult>;
|
|
41
|
+
/**
|
|
42
|
+
* 把本会话已保存的条目移动到指定目标。
|
|
43
|
+
*
|
|
44
|
+
* `tags` 传空数组:saveItems 用 `forceTagType: 1` 写入的标签是自动标签,
|
|
45
|
+
* updateSession 会保留自动标签;传空数组可避免同名标签重复写入。
|
|
46
|
+
*/
|
|
47
|
+
private moveToTarget;
|
|
32
48
|
/**
|
|
33
49
|
* 保存条目。Zotero 返回 201 空响应,条目 id 由请求里的 `id` 字段决定,
|
|
34
50
|
* 后续 `saveAttachment` 用它作为 parentItemID,并复用同一 sessionID。
|
|
@@ -37,10 +53,3 @@ export declare class ZoteroConnector {
|
|
|
37
53
|
private saveAttachment;
|
|
38
54
|
private request;
|
|
39
55
|
}
|
|
40
|
-
interface SelectedCollection {
|
|
41
|
-
id: number | string;
|
|
42
|
-
name: string;
|
|
43
|
-
libraryID?: number;
|
|
44
|
-
filesEditable?: boolean;
|
|
45
|
-
}
|
|
46
|
-
export {};
|
|
@@ -44,7 +44,8 @@ export class ZoteroConnector {
|
|
|
44
44
|
return { ok: false, error: `connector 返回 HTTP ${response.status}` };
|
|
45
45
|
}
|
|
46
46
|
const version = response.headers.get('x-zotero-version') ?? undefined;
|
|
47
|
-
const
|
|
47
|
+
const info = await this.fetchTargetInfo().catch(() => null);
|
|
48
|
+
const collection = info === null ? null : selectedOf(info);
|
|
48
49
|
const result = { ok: true };
|
|
49
50
|
if (version !== undefined)
|
|
50
51
|
result.version = version;
|
|
@@ -64,29 +65,43 @@ export class ZoteroConnector {
|
|
|
64
65
|
return { ok: false, error: describe(error) };
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
|
-
/**
|
|
68
|
-
async
|
|
68
|
+
/** 所有可写入的库与分类,供同步弹窗选择目标。 */
|
|
69
|
+
async listTargets() {
|
|
70
|
+
try {
|
|
71
|
+
const info = await this.fetchTargetInfo();
|
|
72
|
+
const targets = [];
|
|
73
|
+
for (const raw of info.targets ?? []) {
|
|
74
|
+
const target = toTarget(raw);
|
|
75
|
+
if (target !== null)
|
|
76
|
+
targets.push(target);
|
|
77
|
+
}
|
|
78
|
+
const selected = selectedOf(info);
|
|
79
|
+
const result = { ok: true, targets };
|
|
80
|
+
if (selected !== null)
|
|
81
|
+
result.current = { id: selected.treeViewID, name: selected.name };
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
return { ok: false, targets: [], error: describe(error) };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* `/connector/getSelectedCollection` 只接受 POST,返回当前目标与全部可写目标。
|
|
90
|
+
* 注意 GET 会直接 400,因此不能沿用 ping 的方式。
|
|
91
|
+
*/
|
|
92
|
+
async fetchTargetInfo() {
|
|
69
93
|
const response = await this.request('/connector/getSelectedCollection', {
|
|
70
|
-
method: '
|
|
94
|
+
method: 'POST',
|
|
71
95
|
timeoutMs: PING_TIMEOUT_MS,
|
|
96
|
+
headers: { 'content-type': 'application/json' },
|
|
97
|
+
body: '{}',
|
|
72
98
|
});
|
|
73
99
|
if (!response.ok)
|
|
74
|
-
|
|
100
|
+
throw new Error(`connector 返回 HTTP ${response.status}`);
|
|
75
101
|
const body = (await response.json().catch(() => null));
|
|
76
|
-
if (body === null ||
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const id = record['id'];
|
|
80
|
-
const name = record['name'];
|
|
81
|
-
if ((typeof id !== 'number' && typeof id !== 'string') || typeof name !== 'string')
|
|
82
|
-
return null;
|
|
83
|
-
const collection = { id, name };
|
|
84
|
-
if (typeof record['libraryID'] === 'number')
|
|
85
|
-
collection.libraryID = record['libraryID'];
|
|
86
|
-
if (typeof record['filesEditable'] === 'boolean') {
|
|
87
|
-
collection.filesEditable = record['filesEditable'];
|
|
88
|
-
}
|
|
89
|
-
return collection;
|
|
102
|
+
if (body === null || typeof body !== 'object')
|
|
103
|
+
throw new Error('connector 返回空响应');
|
|
104
|
+
return body;
|
|
90
105
|
}
|
|
91
106
|
/**
|
|
92
107
|
* 保存条目并可选上传本地 PDF。
|
|
@@ -95,49 +110,93 @@ export class ZoteroConnector {
|
|
|
95
110
|
* messages,条目仍算保存成功。
|
|
96
111
|
*/
|
|
97
112
|
async sync(inputs, options) {
|
|
98
|
-
if (inputs.length === 0)
|
|
99
|
-
return { ok: true, saved: 0, attached: 0, failed: 0, messages: [] };
|
|
100
|
-
if (inputs.length > MAX_SYNC_ITEMS) {
|
|
101
|
-
throw new Error(`一次最多同步 ${MAX_SYNC_ITEMS} 篇`);
|
|
102
|
-
}
|
|
103
113
|
const result = {
|
|
104
114
|
ok: true,
|
|
105
115
|
saved: 0,
|
|
106
116
|
attached: 0,
|
|
107
117
|
failed: 0,
|
|
108
118
|
messages: [],
|
|
119
|
+
items: [],
|
|
109
120
|
};
|
|
121
|
+
if (inputs.length === 0)
|
|
122
|
+
return result;
|
|
123
|
+
if (inputs.length > MAX_SYNC_ITEMS) {
|
|
124
|
+
throw new Error(`一次最多同步 ${MAX_SYNC_ITEMS} 篇`);
|
|
125
|
+
}
|
|
110
126
|
for (const input of inputs) {
|
|
111
127
|
const sessionID = newSessionID();
|
|
112
128
|
const item = toZoteroItem(input.paper, input.tags, options.tag);
|
|
113
129
|
try {
|
|
114
130
|
await this.saveItem(item, sessionID);
|
|
115
|
-
result.saved += 1;
|
|
116
131
|
}
|
|
117
132
|
catch (error) {
|
|
133
|
+
const reason = describe(error);
|
|
118
134
|
result.failed += 1;
|
|
119
|
-
result.messages.push(`${shortTitle(input.paper.title)}:条目保存失败(${
|
|
135
|
+
result.messages.push(`${shortTitle(input.paper.title)}:条目保存失败(${reason})`);
|
|
136
|
+
result.items.push({ paperId: input.paper.paperId, ok: false, attached: false, error: reason });
|
|
120
137
|
continue;
|
|
121
138
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
139
|
+
// 条目先落到 Zotero 当前选中的目录,再用 updateSession 搬到用户选的目录。
|
|
140
|
+
if (options.targetId !== undefined) {
|
|
141
|
+
try {
|
|
142
|
+
await this.moveToTarget(sessionID, options.targetId);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
const targetName = options.targetName ?? options.targetId;
|
|
146
|
+
const reason = `未能移入「${targetName}」(${describe(error)})`;
|
|
147
|
+
result.failed += 1;
|
|
148
|
+
result.messages.push(`${shortTitle(input.paper.title)}:条目已保存,但${reason}`);
|
|
149
|
+
result.items.push({
|
|
150
|
+
paperId: input.paper.paperId,
|
|
151
|
+
ok: false,
|
|
152
|
+
attached: false,
|
|
153
|
+
error: reason,
|
|
154
|
+
});
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
133
157
|
}
|
|
134
|
-
|
|
135
|
-
|
|
158
|
+
result.saved += 1;
|
|
159
|
+
let attached = false;
|
|
160
|
+
if (options.attachPdf && input.pdfPath !== undefined) {
|
|
161
|
+
const parentId = typeof item['id'] === 'string' ? item['id'] : null;
|
|
162
|
+
if (parentId === null) {
|
|
163
|
+
result.messages.push(`${shortTitle(input.paper.title)}:条目已保存,但缺少条目 id,未上传 PDF`);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
try {
|
|
167
|
+
const buffer = await readFile(input.pdfPath);
|
|
168
|
+
await this.saveAttachment(parentId, sessionID, input.paper, buffer);
|
|
169
|
+
result.attached += 1;
|
|
170
|
+
attached = true;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
result.messages.push(`${shortTitle(input.paper.title)}:PDF 附件失败(${describe(error)})`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
136
176
|
}
|
|
177
|
+
result.items.push({ paperId: input.paper.paperId, ok: true, attached });
|
|
137
178
|
}
|
|
138
179
|
result.ok = result.failed === 0;
|
|
139
180
|
return result;
|
|
140
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* 把本会话已保存的条目移动到指定目标。
|
|
184
|
+
*
|
|
185
|
+
* `tags` 传空数组:saveItems 用 `forceTagType: 1` 写入的标签是自动标签,
|
|
186
|
+
* updateSession 会保留自动标签;传空数组可避免同名标签重复写入。
|
|
187
|
+
*/
|
|
188
|
+
async moveToTarget(sessionID, target) {
|
|
189
|
+
const response = await this.request('/connector/updateSession', {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
timeoutMs: SAVE_TIMEOUT_MS,
|
|
192
|
+
headers: { 'content-type': 'application/json' },
|
|
193
|
+
body: JSON.stringify({ sessionID, target, tags: [] }),
|
|
194
|
+
});
|
|
195
|
+
if (!response.ok) {
|
|
196
|
+
const detail = (await response.text().catch(() => '')).slice(0, 120);
|
|
197
|
+
throw new Error(`connector 返回 HTTP ${response.status}${detail === '' ? '' : `(${detail})`}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
141
200
|
/**
|
|
142
201
|
* 保存条目。Zotero 返回 201 空响应,条目 id 由请求里的 `id` 字段决定,
|
|
143
202
|
* 后续 `saveAttachment` 用它作为 parentItemID,并复用同一 sessionID。
|
|
@@ -201,6 +260,50 @@ export class ZoteroConnector {
|
|
|
201
260
|
}
|
|
202
261
|
}
|
|
203
262
|
}
|
|
263
|
+
/** 当前选中目标。未选中分类时 `id` 为 null,用 libraryID 组成 `L<n>`。 */
|
|
264
|
+
function selectedOf(info) {
|
|
265
|
+
if (typeof info.name !== 'string')
|
|
266
|
+
return null;
|
|
267
|
+
const libraryID = typeof info.libraryID === 'number' ? info.libraryID : undefined;
|
|
268
|
+
let treeViewID;
|
|
269
|
+
if (typeof info.id === 'number')
|
|
270
|
+
treeViewID = `C${info.id}`;
|
|
271
|
+
else if (typeof info.id === 'string' && isTargetId(info.id))
|
|
272
|
+
treeViewID = info.id;
|
|
273
|
+
else if (libraryID !== undefined)
|
|
274
|
+
treeViewID = `L${libraryID}`;
|
|
275
|
+
else
|
|
276
|
+
return null;
|
|
277
|
+
const selected = {
|
|
278
|
+
id: typeof info.id === 'number' || typeof info.id === 'string' ? info.id : treeViewID,
|
|
279
|
+
name: info.name,
|
|
280
|
+
treeViewID,
|
|
281
|
+
};
|
|
282
|
+
if (libraryID !== undefined)
|
|
283
|
+
selected.libraryID = libraryID;
|
|
284
|
+
if (typeof info.filesEditable === 'boolean')
|
|
285
|
+
selected.filesEditable = info.filesEditable;
|
|
286
|
+
return selected;
|
|
287
|
+
}
|
|
288
|
+
function toTarget(raw) {
|
|
289
|
+
if (typeof raw.id !== 'string' || typeof raw.name !== 'string')
|
|
290
|
+
return null;
|
|
291
|
+
if (!isTargetId(raw.id))
|
|
292
|
+
return null;
|
|
293
|
+
const target = {
|
|
294
|
+
id: raw.id,
|
|
295
|
+
name: raw.name,
|
|
296
|
+
level: typeof raw.level === 'number' ? raw.level : 0,
|
|
297
|
+
};
|
|
298
|
+
if (typeof raw.filesEditable === 'boolean')
|
|
299
|
+
target.filesEditable = raw.filesEditable;
|
|
300
|
+
if (raw.recent === true)
|
|
301
|
+
target.recent = true;
|
|
302
|
+
return target;
|
|
303
|
+
}
|
|
304
|
+
function isTargetId(value) {
|
|
305
|
+
return /^[LC]\d+$/u.test(value);
|
|
306
|
+
}
|
|
204
307
|
function toZoteroItem(paper, tags, defaultTag) {
|
|
205
308
|
const item = {
|
|
206
309
|
id: paper.paperId,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把模型从对话里听到的「存到 xx 目录」匹配到 Zotero connector 的真实目标。
|
|
3
|
+
*
|
|
4
|
+
* 纯函数、只做类型导入:`src` 源码可被 `node --test` 直接加载,Host 构建
|
|
5
|
+
* 产物仍走 `.js`。匹配失败或歧义时绝不猜一个目录就写入数据 —— 同步会真的
|
|
6
|
+
* 往 Zotero 里建条目,猜错代价太高。
|
|
7
|
+
*/
|
|
8
|
+
import type { ZoteroTarget } from '../../search/types.js';
|
|
9
|
+
export type ZoteroMatch = {
|
|
10
|
+
kind: 'matched';
|
|
11
|
+
target: ZoteroTarget;
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'ambiguous';
|
|
14
|
+
candidates: ZoteroTarget[];
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'none';
|
|
17
|
+
candidates: ZoteroTarget[];
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* 匹配顺序:id 精确 → 名称精确(忽略大小写与空白)→ 名称包含。
|
|
21
|
+
*
|
|
22
|
+
* 任何一步命中多个都算歧义,返回候选让模型在对话里向用户确认;只有唯一
|
|
23
|
+
* 命中才返回 `matched`,由工具带上 `autoStart` 让右侧栏直接开跑。
|
|
24
|
+
*/
|
|
25
|
+
export declare function matchZoteroTarget(targets: readonly ZoteroTarget[], query: string): ZoteroMatch;
|
|
26
|
+
/** 给模型的可用目录清单(工具返回文本里让用户/模型确认用)。 */
|
|
27
|
+
export declare function describeZoteroTargets(targets: readonly ZoteroTarget[], limit?: number): string;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把模型从对话里听到的「存到 xx 目录」匹配到 Zotero connector 的真实目标。
|
|
3
|
+
*
|
|
4
|
+
* 纯函数、只做类型导入:`src` 源码可被 `node --test` 直接加载,Host 构建
|
|
5
|
+
* 产物仍走 `.js`。匹配失败或歧义时绝不猜一个目录就写入数据 —— 同步会真的
|
|
6
|
+
* 往 Zotero 里建条目,猜错代价太高。
|
|
7
|
+
*/
|
|
8
|
+
const MAX_CANDIDATES = 8;
|
|
9
|
+
/** 归一化:去首尾空白、合并连续空白(含全角空格)、忽略大小写。 */
|
|
10
|
+
function normalize(value) {
|
|
11
|
+
return value.trim().replace(/\s+/gu, ' ').toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
function candidatesOf(targets) {
|
|
14
|
+
// 目录多时优先给用户常看的:最近使用过的排前面,其余保持 connector 原序。
|
|
15
|
+
return [
|
|
16
|
+
...targets.filter((target) => target.recent === true),
|
|
17
|
+
...targets.filter((target) => target.recent !== true),
|
|
18
|
+
].slice(0, MAX_CANDIDATES);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 匹配顺序:id 精确 → 名称精确(忽略大小写与空白)→ 名称包含。
|
|
22
|
+
*
|
|
23
|
+
* 任何一步命中多个都算歧义,返回候选让模型在对话里向用户确认;只有唯一
|
|
24
|
+
* 命中才返回 `matched`,由工具带上 `autoStart` 让右侧栏直接开跑。
|
|
25
|
+
*/
|
|
26
|
+
export function matchZoteroTarget(targets, query) {
|
|
27
|
+
const needle = normalize(query);
|
|
28
|
+
if (needle === '')
|
|
29
|
+
return { kind: 'none', candidates: candidatesOf(targets) };
|
|
30
|
+
const byId = targets.filter((target) => target.id.toLowerCase() === needle);
|
|
31
|
+
if (byId.length === 1)
|
|
32
|
+
return { kind: 'matched', target: byId[0] };
|
|
33
|
+
if (byId.length > 1)
|
|
34
|
+
return { kind: 'ambiguous', candidates: candidatesOf(byId) };
|
|
35
|
+
const byName = targets.filter((target) => normalize(target.name) === needle);
|
|
36
|
+
if (byName.length === 1)
|
|
37
|
+
return { kind: 'matched', target: byName[0] };
|
|
38
|
+
if (byName.length > 1)
|
|
39
|
+
return { kind: 'ambiguous', candidates: candidatesOf(byName) };
|
|
40
|
+
const partial = targets.filter((target) => normalize(target.name).includes(needle));
|
|
41
|
+
if (partial.length === 1)
|
|
42
|
+
return { kind: 'matched', target: partial[0] };
|
|
43
|
+
if (partial.length > 1)
|
|
44
|
+
return { kind: 'ambiguous', candidates: candidatesOf(partial) };
|
|
45
|
+
return { kind: 'none', candidates: candidatesOf(targets) };
|
|
46
|
+
}
|
|
47
|
+
/** 给模型的可用目录清单(工具返回文本里让用户/模型确认用)。 */
|
|
48
|
+
export function describeZoteroTargets(targets, limit = 12) {
|
|
49
|
+
if (targets.length === 0)
|
|
50
|
+
return '(Zotero 里没有可用目录)';
|
|
51
|
+
const lines = targets.slice(0, limit).map((target) => {
|
|
52
|
+
const indent = ' '.repeat(Math.max(0, target.level));
|
|
53
|
+
const suffix = target.filesEditable === false ? '(不可写附件)' : '';
|
|
54
|
+
return `- ${indent}${target.name}${suffix}(id: ${target.id})`;
|
|
55
|
+
});
|
|
56
|
+
if (targets.length > limit)
|
|
57
|
+
lines.push(`- ……还有 ${targets.length - limit} 个目录`);
|
|
58
|
+
return lines.join('\n');
|
|
59
|
+
}
|
package/lib/search/config.d.ts
CHANGED
package/lib/search/config.js
CHANGED
package/lib/search/types.d.ts
CHANGED
|
@@ -163,6 +163,8 @@ export type PdfStatus = 'unknown' | 'available' | 'downloaded' | 'missing' | 'ht
|
|
|
163
163
|
export type PdfFailureReason = 'no-candidate' | 'blocked' | 'paywalled' | 'not-pdf' | 'failed';
|
|
164
164
|
/** 是否需要引导用户到浏览器手动保存。 */
|
|
165
165
|
export declare function isManualPdfFailure(reason: PdfFailureReason | undefined): boolean;
|
|
166
|
+
/** 文献 → Zotero 的同步状态;缺省表示尚未同步。 */
|
|
167
|
+
export type ZoteroSyncStatus = 'synced' | 'failed';
|
|
166
168
|
/** 论文级人工筛选状态。 */
|
|
167
169
|
export interface PaperState {
|
|
168
170
|
screening: Screening;
|
|
@@ -172,6 +174,12 @@ export interface PaperState {
|
|
|
172
174
|
pdfError?: string;
|
|
173
175
|
/** 已下载到用户目录的绝对路径。 */
|
|
174
176
|
pdfFile?: string;
|
|
177
|
+
/** 最近一次 Zotero 同步结果;缺省为未同步。 */
|
|
178
|
+
zoteroStatus?: ZoteroSyncStatus;
|
|
179
|
+
/** 同步到的 Zotero 目录名,便于列表直接展示。 */
|
|
180
|
+
zoteroCollection?: string;
|
|
181
|
+
/** 同步失败原因(面向用户的一句话)。 */
|
|
182
|
+
zoteroError?: string;
|
|
175
183
|
}
|
|
176
184
|
export interface TaskGroup {
|
|
177
185
|
id: string;
|
|
@@ -427,6 +435,32 @@ export interface ZoteroProbeResult {
|
|
|
427
435
|
};
|
|
428
436
|
error?: string;
|
|
429
437
|
}
|
|
438
|
+
/** Zotero 可写入的目标(库或分类),id 为 connector 的 treeViewID。 */
|
|
439
|
+
export interface ZoteroTarget {
|
|
440
|
+
id: string;
|
|
441
|
+
name: string;
|
|
442
|
+
level: number;
|
|
443
|
+
filesEditable?: boolean;
|
|
444
|
+
recent?: boolean;
|
|
445
|
+
}
|
|
446
|
+
/** Zotero 目标目录列表。 */
|
|
447
|
+
export interface ZoteroTargetsResult {
|
|
448
|
+
ok: boolean;
|
|
449
|
+
targets: ZoteroTarget[];
|
|
450
|
+
/** 当前 Zotero 里选中的目标。 */
|
|
451
|
+
current?: {
|
|
452
|
+
id: string;
|
|
453
|
+
name: string;
|
|
454
|
+
};
|
|
455
|
+
error?: string;
|
|
456
|
+
}
|
|
457
|
+
/** 单篇同步结果。 */
|
|
458
|
+
export interface ZoteroSyncItemResult {
|
|
459
|
+
paperId: string;
|
|
460
|
+
ok: boolean;
|
|
461
|
+
attached: boolean;
|
|
462
|
+
error?: string;
|
|
463
|
+
}
|
|
430
464
|
/** Zotero 批量同步结果。 */
|
|
431
465
|
export interface ZoteroSyncResult {
|
|
432
466
|
ok: boolean;
|
|
@@ -434,6 +468,7 @@ export interface ZoteroSyncResult {
|
|
|
434
468
|
attached: number;
|
|
435
469
|
failed: number;
|
|
436
470
|
messages: string[];
|
|
471
|
+
items: ZoteroSyncItemResult[];
|
|
437
472
|
}
|
|
438
473
|
/** 下载目录可写性测试结果。 */
|
|
439
474
|
export interface StorageProbeResult {
|
|
@@ -541,12 +576,113 @@ export interface TaskFocus {
|
|
|
541
576
|
/** 发起检索的会话;客户端只在该会话可见时打开,避免抢占其他对话的面板。 */
|
|
542
577
|
sessionId?: string;
|
|
543
578
|
}
|
|
579
|
+
/**
|
|
580
|
+
* 结果列表每页篇数。
|
|
581
|
+
*
|
|
582
|
+
* 面板分页与模型侧「第 N 页」共用这一个常量:它是**界面翻页**的粒度,
|
|
583
|
+
* 与任务条件的 `pageSize`(每源条数,可调 1–50)不是一回事,两者不可混用。
|
|
584
|
+
*/
|
|
585
|
+
export declare const RESULT_PAGE_SIZE = 20;
|
|
586
|
+
/** 模型侧下载范围;工具把自然语言收敛成这 7 种之一。 */
|
|
587
|
+
export type DownloadScope = 'ids' | 'currentPage' | 'currentView' | 'pages' | 'all' | 'group' | 'filters';
|
|
588
|
+
/**
|
|
589
|
+
* 按页取数时页码的基准列表。
|
|
590
|
+
*
|
|
591
|
+
* - `view`(默认):先按面板视图(筛选 + 排序)切页,再在页内套模型附加条件。
|
|
592
|
+
* 「第二页里有 PDF 的文献」就是这个口径:用户看到的是第 2 页那 20 条。
|
|
593
|
+
* - `filtered`:先把附加条件合并进筛选再切页。
|
|
594
|
+
* 「有 PDF 的文献的第 2 页」用这个。
|
|
595
|
+
*/
|
|
596
|
+
export type PageBase = 'view' | 'filtered';
|
|
597
|
+
/**
|
|
598
|
+
* 客户端当前视图快照。
|
|
599
|
+
*
|
|
600
|
+
* 模型工具跑在 Host 侧,看不到右侧栏翻到第几页、用了什么筛选。面板按变化
|
|
601
|
+
* 节流上报这份快照,Host 才能把「当前页」「当前筛选全部」算准。仅存内存,
|
|
602
|
+
* 宿主重启即失效(与事件日志 rev 一致)。
|
|
603
|
+
*/
|
|
604
|
+
export interface ViewSnapshot {
|
|
605
|
+
sessionId: string;
|
|
606
|
+
taskId: string;
|
|
607
|
+
/** 上报时间(epoch ms);Host 只接受 30 分钟内的快照。 */
|
|
608
|
+
at: number;
|
|
609
|
+
pageIndex: number;
|
|
610
|
+
pageSize: number;
|
|
611
|
+
/** 客户端看到的结果总数;给出「共 N 页」时用它,缺省则由 Host 按任务重算。 */
|
|
612
|
+
total?: number;
|
|
613
|
+
/** 当前结果排序;快照缺 paperIds 时用它重算当前页。 */
|
|
614
|
+
sort?: SortMode;
|
|
615
|
+
/** 当前生效的筛选;键名沿用 ResultsQuery,值已去除 undefined / 空串。 */
|
|
616
|
+
filters: Record<string, string | number | boolean>;
|
|
617
|
+
/** 当前页按用户所见顺序的 paperId。 */
|
|
618
|
+
pagePaperIds: string[];
|
|
619
|
+
/** 跨页累计勾选的 paperId。 */
|
|
620
|
+
selectedIds: string[];
|
|
621
|
+
/** 客户端看到的任务 rev;每下载一篇都会自增,故只做宽松校验。 */
|
|
622
|
+
rev: number;
|
|
623
|
+
}
|
|
624
|
+
/** 一条待下载文献的展示元数据;由 Host 生成,Client 无需再查详情。 */
|
|
625
|
+
export interface DownloadCommandItem {
|
|
626
|
+
paperId: string;
|
|
627
|
+
title: string;
|
|
628
|
+
source: string;
|
|
629
|
+
/** `期刊 · 年份` 之类的副行,可缺省。 */
|
|
630
|
+
meta?: string;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* 模型发起的批量下载指令。
|
|
634
|
+
*
|
|
635
|
+
* 范围在 Host 侧已解析、逐个校验完毕;右侧栏只按 paperIds 执行,不解析
|
|
636
|
+
* 任何条件,也不反查别的任务。弹窗靠 scopeLabel / reason / items 让用户
|
|
637
|
+
* 看清「大模型正在下载哪些文献」。
|
|
638
|
+
*/
|
|
639
|
+
export interface DownloadCommand {
|
|
640
|
+
commandId: string;
|
|
641
|
+
taskId: string;
|
|
642
|
+
sessionId?: string;
|
|
643
|
+
at: number;
|
|
644
|
+
origin: 'model';
|
|
645
|
+
/** 用户原话 / 模型对范围的解释,弹窗里展示。 */
|
|
646
|
+
reason: string;
|
|
647
|
+
/** Host 生成的中文范围说明(含篇数),Client 纯展示。 */
|
|
648
|
+
scopeLabel: string;
|
|
649
|
+
paperIds: string[];
|
|
650
|
+
items: DownloadCommandItem[];
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* 模型发起的批量存入 Zotero 指令。
|
|
654
|
+
*
|
|
655
|
+
* 与下载指令共用同一套范围解析与 `items` 展示元数据;多出来的只有目标目录:
|
|
656
|
+
* 用户在对话里点名了目录且 Host 匹配成功时带上 `target` 并 `autoStart`,
|
|
657
|
+
* 否则弹窗加载好文献后由用户自己选目录再点「同步」。
|
|
658
|
+
*/
|
|
659
|
+
export interface ZoteroCommand {
|
|
660
|
+
commandId: string;
|
|
661
|
+
taskId: string;
|
|
662
|
+
sessionId?: string;
|
|
663
|
+
at: number;
|
|
664
|
+
origin: 'model';
|
|
665
|
+
/** 用户原话 / 模型对范围的解释,弹窗里展示。 */
|
|
666
|
+
reason: string;
|
|
667
|
+
/** Host 生成的中文范围说明(含篇数),Client 纯展示。 */
|
|
668
|
+
scopeLabel: string;
|
|
669
|
+
paperIds: string[];
|
|
670
|
+
items: DownloadCommandItem[];
|
|
671
|
+
/** 对话里点名、且已在 Zotero 目录列表里核实过的目标目录。 */
|
|
672
|
+
target?: ZoteroTarget;
|
|
673
|
+
/** true = 目录已确定,弹窗加载完立即自动同步;false = 等用户选目录。 */
|
|
674
|
+
autoStart: boolean;
|
|
675
|
+
}
|
|
544
676
|
/** `/events` 的响应;`reset` 表示客户端的 sinceRev 比服务端新(宿主重启)。 */
|
|
545
677
|
export interface EventsPage {
|
|
546
678
|
rev: number;
|
|
547
679
|
reset: boolean;
|
|
548
680
|
tasks: TaskSummary[];
|
|
549
681
|
focus?: TaskFocus;
|
|
682
|
+
/** 增量下发的模型下载指令;数组而非单值,避免两次轮询间连发时丢指令。 */
|
|
683
|
+
downloads?: DownloadCommand[];
|
|
684
|
+
/** 增量下发的模型 Zotero 同步指令,与 downloads 并列、互不影响。 */
|
|
685
|
+
zotero?: ZoteroCommand[];
|
|
550
686
|
}
|
|
551
687
|
/** `/health` 的响应:数据源能力 + 客户端需要的默认设置(不含密钥)。 */
|
|
552
688
|
export interface HealthInfo {
|
|
@@ -582,7 +718,6 @@ export interface HealthInfo {
|
|
|
582
718
|
};
|
|
583
719
|
/** 任务文件存放目录(设置页只读展示)。 */
|
|
584
720
|
storageDir: string;
|
|
585
|
-
version: string;
|
|
586
721
|
}
|
|
587
722
|
/** Host HTTP 端点统一信封。 */
|
|
588
723
|
export type ApiEnvelope<T> = {
|
package/lib/search/types.js
CHANGED
|
@@ -37,3 +37,10 @@ export const TASK_LANGUAGES = [
|
|
|
37
37
|
export function isManualPdfFailure(reason) {
|
|
38
38
|
return reason !== undefined && reason !== 'failed';
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* 结果列表每页篇数。
|
|
42
|
+
*
|
|
43
|
+
* 面板分页与模型侧「第 N 页」共用这一个常量:它是**界面翻页**的粒度,
|
|
44
|
+
* 与任务条件的 `pageSize`(每源条数,可调 1–50)不是一回事,两者不可混用。
|
|
45
|
+
*/
|
|
46
|
+
export const RESULT_PAGE_SIZE = 20;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-litsearch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Search free scholarly literature sources from DSH. The model can search OpenAlex, Crossref and more with native tools, while you can run the same searches in a sidebar panel and send any paper into the conversation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|