dsh-plugin-litsearch 0.1.0 → 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.
- package/README.md +35 -13
- package/client/client.js +4397 -1953
- package/lib/host/events.d.ts +3 -0
- package/lib/host/events.js +25 -1
- package/lib/host/paths.d.ts +44 -0
- package/lib/host/paths.js +129 -0
- package/lib/host/pdf/root.d.ts +11 -0
- package/lib/host/pdf/root.js +27 -0
- package/lib/host/pdf/store.d.ts +13 -5
- package/lib/host/pdf/store.js +62 -17
- package/lib/host/router.js +35 -11
- package/lib/host/tasks/filter.d.ts +5 -1
- package/lib/host/tasks/filter.js +49 -4
- package/lib/host/tasks/runner.js +1 -8
- package/lib/host/tasks/service.d.ts +34 -15
- package/lib/host/tasks/service.js +180 -53
- package/lib/host/tasks/stats.js +6 -4
- package/lib/host/tasks/store.d.ts +24 -4
- package/lib/host/tasks/store.js +64 -6
- package/lib/host/tools.d.ts +13 -6
- package/lib/host/tools.js +73 -29
- package/lib/host/workspace.d.ts +20 -0
- package/lib/host/workspace.js +32 -0
- package/lib/index.d.ts +17 -8
- package/lib/index.js +33 -11
- package/lib/search/aggregate.js +1 -0
- package/lib/search/config.d.ts +17 -8
- package/lib/search/config.js +16 -8
- package/lib/search/identity.js +1 -0
- package/lib/search/providers/googlescholar.d.ts +12 -0
- package/lib/search/providers/googlescholar.js +154 -0
- package/lib/search/providers/index.js +2 -0
- package/lib/search/query-adapter.js +3 -0
- package/lib/search/rate-limit.js +2 -1
- package/lib/search/types.d.ts +27 -5
- package/lib/search/types.js +21 -1
- package/package.json +1 -1
package/lib/host/tasks/runner.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { DEFAULT_TASK_MAX_ROUNDS, MAX_LIMIT, getSettings } from '../../search/config.js';
|
|
9
9
|
import { searchSources } from '../../search/aggregate.js';
|
|
10
10
|
import { mergeHits } from '../../search/identity.js';
|
|
11
|
+
import { matchesLanguage } from './filter.js';
|
|
11
12
|
import { finishRound, startRound } from './rounds.js';
|
|
12
13
|
import { countScreening } from './states.js';
|
|
13
14
|
class RunCanceled extends Error {
|
|
@@ -198,11 +199,3 @@ function matchesConditions(paper, conditions) {
|
|
|
198
199
|
}
|
|
199
200
|
return true;
|
|
200
201
|
}
|
|
201
|
-
function matchesLanguage(paper, language) {
|
|
202
|
-
if (language === 'any')
|
|
203
|
-
return true;
|
|
204
|
-
const value = (paper.language ?? '').toLowerCase();
|
|
205
|
-
if (language === 'chi')
|
|
206
|
-
return value.startsWith('chi') || value.startsWith('zh');
|
|
207
|
-
return value.startsWith('eng') || value.startsWith('en');
|
|
208
|
-
}
|
|
@@ -25,8 +25,9 @@ export interface CreateTaskInput {
|
|
|
25
25
|
types?: string[];
|
|
26
26
|
sort?: SortMode;
|
|
27
27
|
origin: TaskOrigin;
|
|
28
|
-
sessionId?: string;
|
|
29
28
|
fromTaskId?: string;
|
|
29
|
+
/** 发起方所在会话的工作目录;下载默认落在这里。 */
|
|
30
|
+
workspace?: string;
|
|
30
31
|
runNow?: boolean;
|
|
31
32
|
}
|
|
32
33
|
export interface TaskPatch {
|
|
@@ -62,17 +63,25 @@ export declare class TaskService {
|
|
|
62
63
|
private readonly events;
|
|
63
64
|
private readonly runner;
|
|
64
65
|
/**
|
|
65
|
-
*
|
|
66
|
+
* 全局「当前任务」:最后一次使用或打开的任务。
|
|
66
67
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* 的多次检索默认落在同一个任务里,从而从根上避免重复开 tab。
|
|
70
|
-
* 跨会话误并的风险由 TTL、工具返回里自报任务名、以及模型可显式
|
|
71
|
-
* `newTask` / `taskId` 覆盖来缓解。
|
|
68
|
+
* 不按对话、也不按时间过期——同一主题的后续检索必须在原任务里加一轮,
|
|
69
|
+
* 跨对话、宿主重启都成立。只有用户显式新建任务或点开别的任务时才会改变。
|
|
72
70
|
*/
|
|
73
71
|
private currentTaskId;
|
|
74
72
|
private currentTaskAt;
|
|
75
73
|
constructor();
|
|
74
|
+
/**
|
|
75
|
+
* 先加载任务索引,再恢复「当前任务」快照。
|
|
76
|
+
*
|
|
77
|
+
* 快照缺失或指向已删除任务时,自举为最近更新的任务,避免下一轮检索
|
|
78
|
+
* 又新建一个同名任务。
|
|
79
|
+
*/
|
|
80
|
+
private restore;
|
|
81
|
+
/** 按设置的总量上限修剪 PDF 去重缓存;失败只告警,不阻断启动。 */
|
|
82
|
+
private trimPdfCache;
|
|
83
|
+
/** 没有可用快照时,把最近更新的任务设为当前任务并落盘。 */
|
|
84
|
+
private bootstrapCurrentTask;
|
|
76
85
|
get globalRev(): number;
|
|
77
86
|
get root(): string;
|
|
78
87
|
isRunning(id: string): boolean;
|
|
@@ -83,12 +92,22 @@ export declare class TaskService {
|
|
|
83
92
|
getDetail(id: string): Promise<TaskDetail>;
|
|
84
93
|
/** 任务内轮次历史(时间升序、深拷贝)。 */
|
|
85
94
|
getRounds(id: string): Promise<SearchRound[]>;
|
|
86
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* 当前任务:最近一次使用或打开的任务。
|
|
97
|
+
*
|
|
98
|
+
* 没有时间窗口、也不区分对话;只要任务还在就一直有效。任务被全部删除后
|
|
99
|
+
* 返回 undefined,由调用方决定是否新建。
|
|
100
|
+
*/
|
|
87
101
|
currentTask(): Promise<SearchTask | undefined>;
|
|
88
|
-
/**
|
|
102
|
+
/** 记住最近使用的任务(下一次模型检索默认落在这里)。 */
|
|
89
103
|
setCurrentTask(id: string): void;
|
|
104
|
+
/** 请求客户端打开/聚焦某任务 tab(面板被关掉时重新打开)。 */
|
|
105
|
+
focusTask(id: string, title: string, sessionId?: string): void;
|
|
106
|
+
private persistCurrent;
|
|
90
107
|
/** 用户从任务列表点开某任务 = 进入该任务,之后的模型检索默认落在这里。 */
|
|
91
|
-
activate(id: string): Promise<SearchTask>;
|
|
108
|
+
activate(id: string, workspace?: string): Promise<SearchTask>;
|
|
109
|
+
/** 记下任务最近一次所在的工作目录;只改变下载落点,不改变 rev。 */
|
|
110
|
+
private applyWorkspace;
|
|
92
111
|
/** 等待该任务当前这一轮结束;返回是否确实已空闲。 */
|
|
93
112
|
waitUntilIdle(id: string, timeoutMs: number, signal?: AbortSignal): Promise<boolean>;
|
|
94
113
|
create(input: CreateTaskInput): Promise<SearchTask>;
|
|
@@ -99,7 +118,7 @@ export declare class TaskService {
|
|
|
99
118
|
*
|
|
100
119
|
* 任务已缓存时同步登记运行态,调用方紧接着调 `waitUntilIdle` 也不会误判为空闲。
|
|
101
120
|
*/
|
|
102
|
-
run(id: string, mode?: 'new' | 'more', origin?: TaskOrigin): Promise<SearchTask>;
|
|
121
|
+
run(id: string, mode?: 'new' | 'more', origin?: TaskOrigin, workspace?: string): Promise<SearchTask>;
|
|
103
122
|
/**
|
|
104
123
|
* 在同一任务内开新一轮:全量替换检索条件后执行,**不新建任务、不新开 tab**。
|
|
105
124
|
*
|
|
@@ -124,9 +143,9 @@ export declare class TaskService {
|
|
|
124
143
|
/** 设置页清理:任务文件与 PDF 缓存。 */
|
|
125
144
|
cleanup(scope: CleanupScope): Promise<CleanupResult>;
|
|
126
145
|
/** 下载单篇 PDF;失败时写入失败状态,若有 EPMC 全文则降级为 html-only。 */
|
|
127
|
-
downloadPdf(id: string, paperId: string): Promise<PdfDownloadResult>;
|
|
146
|
+
downloadPdf(id: string, paperId: string, workspace?: string): Promise<PdfDownloadResult>;
|
|
128
147
|
/** PDF 预览:优先用已落地文件;没有则临时抓取一次但不落盘。 */
|
|
129
|
-
previewPdf(id: string, paperId: string): Promise<{
|
|
148
|
+
previewPdf(id: string, paperId: string, workspace?: string): Promise<{
|
|
130
149
|
buffer: Buffer;
|
|
131
150
|
filename: string;
|
|
132
151
|
}>;
|
|
@@ -139,9 +158,9 @@ export declare class TaskService {
|
|
|
139
158
|
/** 测试 Zotero connector 是否可达,并返回当前选中的分类。 */
|
|
140
159
|
zoteroProbe(): Promise<ZoteroProbeResult>;
|
|
141
160
|
/** 把选中论文同步到 Zotero(含本地 PDF 附件)。 */
|
|
142
|
-
zoteroSync(id: string, paperIds?: readonly string[]): Promise<ZoteroSyncResult>;
|
|
161
|
+
zoteroSync(id: string, paperIds?: readonly string[], workspace?: string): Promise<ZoteroSyncResult>;
|
|
143
162
|
/** 下载目录可写性测试(设置页)。 */
|
|
144
|
-
storageProbe(): Promise<StorageProbeResult>;
|
|
163
|
+
storageProbe(workspace?: string): Promise<StorageProbeResult>;
|
|
145
164
|
private ensureLocalPdf;
|
|
146
165
|
private setPdfState;
|
|
147
166
|
/** 单源诊断:用固定短查询测一次连通性与延迟(不受冷却影响)。 */
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* 模型工具与面板 HTTP 路由都只调用这里,保证两边操作同一份状态。
|
|
6
6
|
*/
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
-
import {
|
|
10
|
+
import { MAX_LIMIT, PLUGIN_VERSION, getSettings } from '../../search/config.js';
|
|
11
11
|
import { SearchError } from '../../search/errors.js';
|
|
12
12
|
import { cooldownUntil, sourceHealthList } from '../../search/health.js';
|
|
13
13
|
import { PROVIDER_INFOS } from '../../search/providers/index.js';
|
|
@@ -17,8 +17,9 @@ import { fetchPubtatorEntities } from '../../search/pubtator-entities.js';
|
|
|
17
17
|
import { SOURCE_IDS } from '../../search/types.js';
|
|
18
18
|
import { TaskEvents } from '../events.js';
|
|
19
19
|
import { buildExport } from '../export/index.js';
|
|
20
|
-
import { cleanupPdfCache, epmcFullTextAvailable, persistPdf, probePdfRoot, readLocalPdf, readableFilename, resolvePdf, resolvePdfRoot, } from '../pdf/store.js';
|
|
20
|
+
import { cleanupPdfCache, epmcFullTextAvailable, persistPdf, probePdfRoot, prunePdfCache, readLocalPdf, readSavedPdf, readableFilename, resolvePdf, resolvePdfRoot, } from '../pdf/store.js';
|
|
21
21
|
import { ZoteroConnector } from '../zotero/connector.js';
|
|
22
|
+
import { normalizeWorkspace } from '../workspace.js';
|
|
22
23
|
import { compareTasks } from './compare.js';
|
|
23
24
|
import { filterPapers } from './filter.js';
|
|
24
25
|
import { TaskRunner } from './runner.js';
|
|
@@ -37,25 +38,78 @@ export class TaskService {
|
|
|
37
38
|
events = new TaskEvents();
|
|
38
39
|
runner = new TaskRunner();
|
|
39
40
|
/**
|
|
40
|
-
*
|
|
41
|
+
* 全局「当前任务」:最后一次使用或打开的任务。
|
|
41
42
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* 的多次检索默认落在同一个任务里,从而从根上避免重复开 tab。
|
|
45
|
-
* 跨会话误并的风险由 TTL、工具返回里自报任务名、以及模型可显式
|
|
46
|
-
* `newTask` / `taskId` 覆盖来缓解。
|
|
43
|
+
* 不按对话、也不按时间过期——同一主题的后续检索必须在原任务里加一轮,
|
|
44
|
+
* 跨对话、宿主重启都成立。只有用户显式新建任务或点开别的任务时才会改变。
|
|
47
45
|
*/
|
|
48
46
|
currentTaskId;
|
|
49
47
|
currentTaskAt = 0;
|
|
50
48
|
constructor() {
|
|
51
|
-
this.ready = this.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
this.ready = this.restore();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 先加载任务索引,再恢复「当前任务」快照。
|
|
53
|
+
*
|
|
54
|
+
* 快照缺失或指向已删除任务时,自举为最近更新的任务,避免下一轮检索
|
|
55
|
+
* 又新建一个同名任务。
|
|
56
|
+
*/
|
|
57
|
+
async restore() {
|
|
58
|
+
try {
|
|
59
|
+
await this.store.load({
|
|
60
|
+
retentionDays: getSettings().taskRetentionDays,
|
|
61
|
+
retentionCount: getSettings().taskRetentionCount,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
57
65
|
console.warn('[dsh-plugin-litsearch] 任务目录加载失败:', error);
|
|
58
|
-
}
|
|
66
|
+
}
|
|
67
|
+
// 与任务自动清理同批执行:按总量上限修剪 PDF 去重缓存(0 表示关闭)。
|
|
68
|
+
await this.trimPdfCache();
|
|
69
|
+
let restored = false;
|
|
70
|
+
try {
|
|
71
|
+
const snapshot = await this.store.loadCurrent();
|
|
72
|
+
if (snapshot?.current !== undefined &&
|
|
73
|
+
this.store.summary(snapshot.current.taskId) !== undefined) {
|
|
74
|
+
this.currentTaskId = snapshot.current.taskId;
|
|
75
|
+
this.currentTaskAt = snapshot.current.at;
|
|
76
|
+
restored = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
console.warn('[dsh-plugin-litsearch] 当前任务快照加载失败:', error);
|
|
81
|
+
}
|
|
82
|
+
if (!restored)
|
|
83
|
+
this.bootstrapCurrentTask();
|
|
84
|
+
}
|
|
85
|
+
/** 按设置的总量上限修剪 PDF 去重缓存;失败只告警,不阻断启动。 */
|
|
86
|
+
async trimPdfCache() {
|
|
87
|
+
const maxMb = getSettings().pdfCacheMaxMb;
|
|
88
|
+
if (maxMb <= 0)
|
|
89
|
+
return;
|
|
90
|
+
try {
|
|
91
|
+
const outcome = await prunePdfCache(this.store.root, maxMb * 1024 * 1024);
|
|
92
|
+
if (outcome.removed > 0) {
|
|
93
|
+
console.info(`[dsh-plugin-litsearch] PDF 缓存已修剪:删除 ${outcome.removed} 个文件,释放 ${Math.round(outcome.freedBytes / 1024 / 1024)} MB(上限 ${maxMb} MB)`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
console.warn('[dsh-plugin-litsearch] PDF 缓存修剪失败:', error);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** 没有可用快照时,把最近更新的任务设为当前任务并落盘。 */
|
|
101
|
+
bootstrapCurrentTask() {
|
|
102
|
+
const latest = this.store.list()[0];
|
|
103
|
+
if (latest === undefined) {
|
|
104
|
+
// 任务已全部删除:清掉快照里的旧 id,避免 current.json 指向不存在的任务。
|
|
105
|
+
this.currentTaskId = undefined;
|
|
106
|
+
this.currentTaskAt = 0;
|
|
107
|
+
this.persistCurrent();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.currentTaskId = latest.id;
|
|
111
|
+
this.currentTaskAt = Date.parse(latest.updatedAt) || Date.now();
|
|
112
|
+
this.persistCurrent();
|
|
59
113
|
}
|
|
60
114
|
get globalRev() {
|
|
61
115
|
return this.events.currentRev;
|
|
@@ -73,7 +127,11 @@ export class TaskService {
|
|
|
73
127
|
globalRev: this.events.currentRev,
|
|
74
128
|
sources: [...PROVIDER_INFOS],
|
|
75
129
|
sourceHealth: sourceHealthList(),
|
|
76
|
-
keys: {
|
|
130
|
+
keys: {
|
|
131
|
+
ncbi: settings.ncbiApiKey !== '',
|
|
132
|
+
s2: settings.s2ApiKey !== '',
|
|
133
|
+
serpapi: settings.serpApiKey !== '',
|
|
134
|
+
},
|
|
77
135
|
settings: {
|
|
78
136
|
defaultSources: [...settings.defaultSources],
|
|
79
137
|
defaultPageSize: settings.defaultPageSize,
|
|
@@ -83,7 +141,7 @@ export class TaskService {
|
|
|
83
141
|
maxTaskTabs: settings.maxTaskTabs,
|
|
84
142
|
},
|
|
85
143
|
fullText: {
|
|
86
|
-
pdfRoot: resolvePdfRoot(),
|
|
144
|
+
pdfRoot: settings.pdfRoot.trim() === '' ? '' : resolvePdfRoot(),
|
|
87
145
|
pdfMaxMb: settings.pdfMaxMb,
|
|
88
146
|
autoDownload: settings.autoDownloadPdf,
|
|
89
147
|
epmcFulltext: settings.enableEpmcFulltext,
|
|
@@ -132,32 +190,58 @@ export class TaskService {
|
|
|
132
190
|
async getRounds(id) {
|
|
133
191
|
return roundsOf(await this.get(id)).map(cloneRound);
|
|
134
192
|
}
|
|
135
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* 当前任务:最近一次使用或打开的任务。
|
|
195
|
+
*
|
|
196
|
+
* 没有时间窗口、也不区分对话;只要任务还在就一直有效。任务被全部删除后
|
|
197
|
+
* 返回 undefined,由调用方决定是否新建。
|
|
198
|
+
*/
|
|
136
199
|
async currentTask() {
|
|
200
|
+
await this.ready;
|
|
137
201
|
if (this.currentTaskId === undefined)
|
|
138
202
|
return undefined;
|
|
139
|
-
if (Date.now() - this.currentTaskAt > CURRENT_TASK_TTL_MS) {
|
|
140
|
-
this.currentTaskId = undefined;
|
|
141
|
-
return undefined;
|
|
142
|
-
}
|
|
143
203
|
const task = await this.tryGet(this.currentTaskId);
|
|
144
|
-
if (task
|
|
145
|
-
|
|
204
|
+
if (task !== undefined)
|
|
205
|
+
return task;
|
|
206
|
+
this.currentTaskId = undefined;
|
|
207
|
+
this.bootstrapCurrentTask();
|
|
208
|
+
if (this.currentTaskId === undefined)
|
|
146
209
|
return undefined;
|
|
147
|
-
|
|
148
|
-
return task;
|
|
210
|
+
return this.tryGet(this.currentTaskId);
|
|
149
211
|
}
|
|
150
|
-
/**
|
|
212
|
+
/** 记住最近使用的任务(下一次模型检索默认落在这里)。 */
|
|
151
213
|
setCurrentTask(id) {
|
|
152
214
|
this.currentTaskId = id;
|
|
153
215
|
this.currentTaskAt = Date.now();
|
|
216
|
+
this.persistCurrent();
|
|
217
|
+
}
|
|
218
|
+
/** 请求客户端打开/聚焦某任务 tab(面板被关掉时重新打开)。 */
|
|
219
|
+
focusTask(id, title, sessionId) {
|
|
220
|
+
this.events.requestFocus(id, title, sessionId);
|
|
221
|
+
}
|
|
222
|
+
persistCurrent() {
|
|
223
|
+
void this.store.saveCurrent({
|
|
224
|
+
version: 1,
|
|
225
|
+
...(this.currentTaskId === undefined
|
|
226
|
+
? {}
|
|
227
|
+
: { current: { taskId: this.currentTaskId, at: this.currentTaskAt } }),
|
|
228
|
+
});
|
|
154
229
|
}
|
|
155
230
|
/** 用户从任务列表点开某任务 = 进入该任务,之后的模型检索默认落在这里。 */
|
|
156
|
-
async activate(id) {
|
|
231
|
+
async activate(id, workspace) {
|
|
157
232
|
const task = await this.get(id);
|
|
233
|
+
await this.applyWorkspace(task, workspace);
|
|
158
234
|
this.setCurrentTask(task.id);
|
|
159
235
|
return task;
|
|
160
236
|
}
|
|
237
|
+
/** 记下任务最近一次所在的工作目录;只改变下载落点,不改变 rev。 */
|
|
238
|
+
async applyWorkspace(task, workspace) {
|
|
239
|
+
const next = normalizeWorkspace(workspace);
|
|
240
|
+
if (next === undefined || next === task.workspace)
|
|
241
|
+
return;
|
|
242
|
+
task.workspace = next;
|
|
243
|
+
await this.store.save(task);
|
|
244
|
+
}
|
|
161
245
|
/** 等待该任务当前这一轮结束;返回是否确实已空闲。 */
|
|
162
246
|
async waitUntilIdle(id, timeoutMs, signal) {
|
|
163
247
|
if (!this.runner.isRunning(id))
|
|
@@ -197,6 +281,7 @@ export class TaskService {
|
|
|
197
281
|
const conditions = this.readConditions(input, settings);
|
|
198
282
|
const title = readTitle(input.title, conditions);
|
|
199
283
|
const inherited = input.fromTaskId === undefined ? undefined : await this.get(input.fromTaskId);
|
|
284
|
+
const workspace = normalizeWorkspace(input.workspace);
|
|
200
285
|
const now = new Date().toISOString();
|
|
201
286
|
const task = {
|
|
202
287
|
id: `t${Date.now().toString(36)}${randomUUID().slice(0, 6)}`,
|
|
@@ -219,11 +304,13 @@ export class TaskService {
|
|
|
219
304
|
result: { papers: [], sources: [] },
|
|
220
305
|
rounds: [],
|
|
221
306
|
states: inherited === undefined ? {} : inheritStates(inherited),
|
|
222
|
-
...(
|
|
307
|
+
...(workspace === undefined ? {} : { workspace }),
|
|
223
308
|
...(input.fromTaskId === undefined ? {} : { fromTaskId: input.fromTaskId }),
|
|
224
309
|
};
|
|
225
310
|
this.store.put(task);
|
|
226
311
|
await this.store.save(task);
|
|
312
|
+
// 新建的任务就是「当前任务」:后续检索默认在它里面加轮次。
|
|
313
|
+
this.setCurrentTask(task.id);
|
|
227
314
|
this.events.publish(toSummary(task));
|
|
228
315
|
if (input.runNow !== false) {
|
|
229
316
|
void this.begin(task, 'new').catch((error) => {
|
|
@@ -271,28 +358,34 @@ export class TaskService {
|
|
|
271
358
|
}
|
|
272
359
|
async remove(id) {
|
|
273
360
|
const task = await this.get(id);
|
|
274
|
-
|
|
361
|
+
// 先等这一轮收尾(finally 里还会落一次盘),再删文件,避免任务被写回。
|
|
362
|
+
if (this.runner.cancel(task.id))
|
|
363
|
+
await this.runner.wait(task.id);
|
|
275
364
|
await this.store.remove(task.id);
|
|
365
|
+
// 模型工具导出到 <storage>/out/<taskId>/ 的文件随任务一起清掉。
|
|
366
|
+
await rm(join(this.store.root, 'out', task.id), { recursive: true, force: true });
|
|
276
367
|
this.events.remove(task.id);
|
|
277
|
-
if (this.currentTaskId
|
|
278
|
-
|
|
368
|
+
if (this.currentTaskId !== task.id)
|
|
369
|
+
return;
|
|
370
|
+
this.currentTaskId = undefined;
|
|
371
|
+
this.bootstrapCurrentTask();
|
|
279
372
|
}
|
|
280
373
|
/**
|
|
281
374
|
* 启动一次执行(任务内新一轮);返回的 Promise 在运行结束后 resolve 为最新任务。
|
|
282
375
|
*
|
|
283
376
|
* 任务已缓存时同步登记运行态,调用方紧接着调 `waitUntilIdle` 也不会误判为空闲。
|
|
284
377
|
*/
|
|
285
|
-
run(id, mode = 'new', origin = 'panel') {
|
|
378
|
+
run(id, mode = 'new', origin = 'panel', workspace) {
|
|
286
379
|
const cached = this.store.cached(id);
|
|
287
380
|
if (cached !== undefined) {
|
|
288
381
|
try {
|
|
289
|
-
return this.begin(cached, mode, origin);
|
|
382
|
+
return this.begin(cached, mode, origin, workspace);
|
|
290
383
|
}
|
|
291
384
|
catch (error) {
|
|
292
385
|
return Promise.reject(error);
|
|
293
386
|
}
|
|
294
387
|
}
|
|
295
|
-
return this.get(id).then((task) => this.begin(task, mode, origin));
|
|
388
|
+
return this.get(id).then((task) => this.begin(task, mode, origin, workspace));
|
|
296
389
|
}
|
|
297
390
|
/**
|
|
298
391
|
* 在同一任务内开新一轮:全量替换检索条件后执行,**不新建任务、不新开 tab**。
|
|
@@ -309,6 +402,9 @@ export class TaskService {
|
|
|
309
402
|
if (nextTitle !== '')
|
|
310
403
|
task.title = nextTitle.slice(0, MAX_TITLE);
|
|
311
404
|
task.conditions = this.readConditions(input, getSettings());
|
|
405
|
+
const workspace = normalizeWorkspace(input.workspace);
|
|
406
|
+
if (workspace !== undefined)
|
|
407
|
+
task.workspace = workspace;
|
|
312
408
|
task.rev += 1;
|
|
313
409
|
task.updatedAt = new Date().toISOString();
|
|
314
410
|
await this.store.save(task);
|
|
@@ -424,9 +520,12 @@ export class TaskService {
|
|
|
424
520
|
};
|
|
425
521
|
}
|
|
426
522
|
/** 下载单篇 PDF;失败时写入失败状态,若有 EPMC 全文则降级为 html-only。 */
|
|
427
|
-
async downloadPdf(id, paperId) {
|
|
523
|
+
async downloadPdf(id, paperId, workspace) {
|
|
428
524
|
const task = await this.get(id);
|
|
429
525
|
const paper = findPaper(task, paperId);
|
|
526
|
+
const target = normalizeWorkspace(workspace) ?? normalizeWorkspace(task.workspace);
|
|
527
|
+
if (target !== undefined)
|
|
528
|
+
task.workspace = target;
|
|
430
529
|
const settings = getSettings();
|
|
431
530
|
const resolution = await resolvePdf(paper);
|
|
432
531
|
if (!resolution.ok || resolution.buffer === undefined) {
|
|
@@ -443,7 +542,7 @@ export class TaskService {
|
|
|
443
542
|
};
|
|
444
543
|
return result;
|
|
445
544
|
}
|
|
446
|
-
const saved = await persistPdf(resolution.buffer, this.store.root, paper);
|
|
545
|
+
const saved = await persistPdf(resolution.buffer, this.store.root, paper, target);
|
|
447
546
|
await this.setPdfState(task, paperId, {
|
|
448
547
|
pdfStatus: 'downloaded',
|
|
449
548
|
pdfFile: saved.filePath,
|
|
@@ -460,11 +559,14 @@ export class TaskService {
|
|
|
460
559
|
return result;
|
|
461
560
|
}
|
|
462
561
|
/** PDF 预览:优先用已落地文件;没有则临时抓取一次但不落盘。 */
|
|
463
|
-
async previewPdf(id, paperId) {
|
|
562
|
+
async previewPdf(id, paperId, workspace) {
|
|
464
563
|
const task = await this.get(id);
|
|
465
564
|
const paper = findPaper(task, paperId);
|
|
466
565
|
const filename = readableFilename(paper);
|
|
467
|
-
const
|
|
566
|
+
const target = normalizeWorkspace(workspace) ?? normalizeWorkspace(task.workspace);
|
|
567
|
+
const recorded = task.states[paperId]?.pdfFile;
|
|
568
|
+
const saved = recorded === undefined ? null : await readSavedPdf(recorded);
|
|
569
|
+
const local = saved ?? (await readLocalPdf(paper, target));
|
|
468
570
|
if (local !== null)
|
|
469
571
|
return { buffer: local, filename };
|
|
470
572
|
const resolution = await resolvePdf(paper);
|
|
@@ -533,11 +635,12 @@ export class TaskService {
|
|
|
533
635
|
}
|
|
534
636
|
}
|
|
535
637
|
/** 把选中论文同步到 Zotero(含本地 PDF 附件)。 */
|
|
536
|
-
async zoteroSync(id, paperIds) {
|
|
638
|
+
async zoteroSync(id, paperIds, workspace) {
|
|
537
639
|
const settings = getSettings();
|
|
538
640
|
if (!settings.zoteroEnabled)
|
|
539
641
|
throw new SearchError('bad_request', 'Zotero 同步已在设置中关闭');
|
|
540
642
|
const task = await this.get(id);
|
|
643
|
+
const target = normalizeWorkspace(workspace) ?? normalizeWorkspace(task.workspace);
|
|
541
644
|
const wanted = paperIds === undefined || paperIds.length === 0
|
|
542
645
|
? task.result.papers.slice(0, 50)
|
|
543
646
|
: paperIds.map((paperId) => findPaper(task, paperId));
|
|
@@ -552,8 +655,11 @@ export class TaskService {
|
|
|
552
655
|
const attachPdf = settings.zoteroAttachPdf && !readOnlyAttachments;
|
|
553
656
|
const inputs = [];
|
|
554
657
|
for (const paper of wanted) {
|
|
555
|
-
const
|
|
556
|
-
const
|
|
658
|
+
const state = task.states[paper.paperId];
|
|
659
|
+
const tags = state?.tags ?? [];
|
|
660
|
+
const pdfPath = attachPdf
|
|
661
|
+
? await this.ensureLocalPdf(paper, target, state?.pdfFile)
|
|
662
|
+
: undefined;
|
|
557
663
|
inputs.push({ paper, tags, ...(pdfPath === undefined ? {} : { pdfPath }) });
|
|
558
664
|
}
|
|
559
665
|
try {
|
|
@@ -575,8 +681,8 @@ export class TaskService {
|
|
|
575
681
|
}
|
|
576
682
|
}
|
|
577
683
|
/** 下载目录可写性测试(设置页)。 */
|
|
578
|
-
async storageProbe() {
|
|
579
|
-
const outcome = await probePdfRoot();
|
|
684
|
+
async storageProbe(workspace) {
|
|
685
|
+
const outcome = await probePdfRoot(workspace);
|
|
580
686
|
if (outcome.writable)
|
|
581
687
|
return { ok: true, root: outcome.root, writable: true };
|
|
582
688
|
return {
|
|
@@ -586,17 +692,25 @@ export class TaskService {
|
|
|
586
692
|
...(outcome.error === undefined ? {} : { error: outcome.error }),
|
|
587
693
|
};
|
|
588
694
|
}
|
|
589
|
-
async ensureLocalPdf(paper) {
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
695
|
+
async ensureLocalPdf(paper, workspace, recordedPath) {
|
|
696
|
+
if (recordedPath !== undefined) {
|
|
697
|
+
const recorded = await readSavedPdf(recordedPath);
|
|
698
|
+
if (recorded !== null)
|
|
699
|
+
return recordedPath;
|
|
700
|
+
}
|
|
701
|
+
const localPath = join(resolvePdfRoot(workspace), readableFilename(paper));
|
|
702
|
+
if ((await readSavedPdf(localPath)) !== null)
|
|
703
|
+
return localPath;
|
|
593
704
|
const resolution = await resolvePdf(paper).catch(() => null);
|
|
594
705
|
if (resolution === null || !resolution.ok || resolution.buffer === undefined)
|
|
595
706
|
return undefined;
|
|
596
|
-
const saved = await persistPdf(resolution.buffer, this.store.root, paper);
|
|
707
|
+
const saved = await persistPdf(resolution.buffer, this.store.root, paper, workspace);
|
|
597
708
|
return saved.filePath;
|
|
598
709
|
}
|
|
599
710
|
async setPdfState(task, paperId, patch) {
|
|
711
|
+
// 下载进行中任务被删除:不再写回,也不向客户端广播已删除的任务。
|
|
712
|
+
if (this.store.summary(task.id) === undefined)
|
|
713
|
+
return;
|
|
600
714
|
const current = task.states[paperId] ?? emptyState();
|
|
601
715
|
const next = { ...current, pdfStatus: patch.pdfStatus };
|
|
602
716
|
if (patch.pdfError === undefined)
|
|
@@ -681,10 +795,15 @@ export class TaskService {
|
|
|
681
795
|
this.runner.cancelAll();
|
|
682
796
|
void this.store.flush();
|
|
683
797
|
}
|
|
684
|
-
begin(task, mode, origin = task.origin) {
|
|
798
|
+
begin(task, mode, origin = task.origin, workspace) {
|
|
685
799
|
if (this.runner.isRunning(task.id)) {
|
|
686
800
|
throw new SearchError('conflict', '任务正在运行');
|
|
687
801
|
}
|
|
802
|
+
const nextWorkspace = normalizeWorkspace(workspace);
|
|
803
|
+
if (nextWorkspace !== undefined && nextWorkspace !== task.workspace) {
|
|
804
|
+
task.workspace = nextWorkspace;
|
|
805
|
+
void this.store.save(task);
|
|
806
|
+
}
|
|
688
807
|
if (mode === 'more') {
|
|
689
808
|
throw new SearchError('bad_request', '续页加载将在后续版本提供,请先重跑任务');
|
|
690
809
|
}
|
|
@@ -693,7 +812,12 @@ export class TaskService {
|
|
|
693
812
|
throw new SearchError('bad_request', '没有启用的检索式');
|
|
694
813
|
const done = this.runner.start(task, {
|
|
695
814
|
persist: (current) => this.store.save(current),
|
|
696
|
-
publish: (current) =>
|
|
815
|
+
publish: (current) => {
|
|
816
|
+
// 删除后这一轮收尾仍会 publish,跳过已删除任务,避免客户端又去打开它。
|
|
817
|
+
if (this.store.summary(current.id) !== undefined) {
|
|
818
|
+
this.events.publish(toSummary(current));
|
|
819
|
+
}
|
|
820
|
+
},
|
|
697
821
|
}, origin);
|
|
698
822
|
return done.then(() => {
|
|
699
823
|
if (getSettings().autoDownloadPdf) {
|
|
@@ -710,6 +834,9 @@ export class TaskService {
|
|
|
710
834
|
.filter((paper) => paper.pdfUrl !== '' || paper.ids.pmcid !== '' || paper.ids.arxivId !== '')
|
|
711
835
|
.slice(0, 10);
|
|
712
836
|
for (const paper of targets) {
|
|
837
|
+
// 任务中途被删除时停止后续下载,避免继续往用户下载目录写文件。
|
|
838
|
+
if (this.store.summary(task.id) === undefined)
|
|
839
|
+
return;
|
|
713
840
|
if (task.states[paper.paperId]?.pdfStatus === 'downloaded')
|
|
714
841
|
continue;
|
|
715
842
|
const resolution = await resolvePdf(paper).catch(() => null);
|
|
@@ -725,7 +852,7 @@ export class TaskService {
|
|
|
725
852
|
}
|
|
726
853
|
continue;
|
|
727
854
|
}
|
|
728
|
-
const saved = await persistPdf(resolution.buffer, this.store.root, paper);
|
|
855
|
+
const saved = await persistPdf(resolution.buffer, this.store.root, paper, task.workspace);
|
|
729
856
|
await this.setPdfState(task, paper.paperId, {
|
|
730
857
|
pdfStatus: 'downloaded',
|
|
731
858
|
pdfFile: saved.filePath,
|
package/lib/host/tasks/stats.js
CHANGED
|
@@ -10,6 +10,8 @@ const CACHE_TTL_MS = 30_000;
|
|
|
10
10
|
const CACHE_MAX = 16;
|
|
11
11
|
const TOP_LIST = 15;
|
|
12
12
|
const TOP_PAPERS = 10;
|
|
13
|
+
/** 出处 / 作者候选值的上限:筛选下拉直接用这份清单,按命中数取前 500。 */
|
|
14
|
+
const FACET_LIST = 500;
|
|
13
15
|
const cache = new Map();
|
|
14
16
|
export function computeStats(task, filter, scope, base) {
|
|
15
17
|
const key = `${task.id}:${task.rev}:${scope}:${filterKey(filter)}:${base?.rev ?? ''}`;
|
|
@@ -53,8 +55,8 @@ function buildReport(task, filter, scope, base) {
|
|
|
53
55
|
byYear: buildYearStats(task, papers),
|
|
54
56
|
byType: countBy(task, papers, (paper) => paper.type),
|
|
55
57
|
byLanguage: countBy(task, papers, (paper) => paper.language ?? ''),
|
|
56
|
-
topVenues: topBy(task, papers, (paper) => (paper.venue === '' ? [] : [paper.venue])),
|
|
57
|
-
topAuthors: topBy(task, papers, (paper) => paper.authors),
|
|
58
|
+
topVenues: topBy(task, papers, (paper) => (paper.venue === '' ? [] : [paper.venue]), FACET_LIST),
|
|
59
|
+
topAuthors: topBy(task, papers, (paper) => paper.authors, FACET_LIST),
|
|
58
60
|
metrics: buildMetrics(papers, base),
|
|
59
61
|
screeningProgress: { included, excluded, pending: unread },
|
|
60
62
|
unavailableFullText: {
|
|
@@ -175,7 +177,7 @@ function countBy(task, papers, keyOf) {
|
|
|
175
177
|
.map(([key, entry]) => ({ key, ...entry }))
|
|
176
178
|
.sort((a, b) => b.count - a.count);
|
|
177
179
|
}
|
|
178
|
-
function topBy(task, papers, keysOf) {
|
|
180
|
+
function topBy(task, papers, keysOf, limit) {
|
|
179
181
|
const counts = new Map();
|
|
180
182
|
for (const paper of papers) {
|
|
181
183
|
const included = screeningOf(task, paper) === 'included';
|
|
@@ -193,7 +195,7 @@ function topBy(task, papers, keysOf) {
|
|
|
193
195
|
return [...counts.entries()]
|
|
194
196
|
.map(([key, entry]) => ({ key, ...entry }))
|
|
195
197
|
.sort((a, b) => b.count - a.count)
|
|
196
|
-
.slice(0,
|
|
198
|
+
.slice(0, limit);
|
|
197
199
|
}
|
|
198
200
|
function buildMetrics(papers, base) {
|
|
199
201
|
const total = papers.length;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 任务持久化:`<root>/tasks/<id>.json` + `tasks/index.json`。
|
|
3
3
|
*
|
|
4
|
+
* - 数据根目录为 `$DSH_HOME/storages/dsh-plugin-litsearch`(见 `host/paths.ts`),
|
|
5
|
+
* 加载时若新目录为空而旧目录有任务,会一次性复制过来;显式传入 root 时不迁移;
|
|
4
6
|
* - 写盘全部串行执行,先写临时文件再 rename,避免半截 JSON;
|
|
5
7
|
* - 内存里只保留摘要,任务正文按需从磁盘读;
|
|
6
8
|
* - 落盘时摘要截到 {@link MAX_ABSTRACT_CHARS} 字符,任务数按保留策略裁剪。
|
|
@@ -10,14 +12,27 @@ export interface RetentionOptions {
|
|
|
10
12
|
retentionDays: number;
|
|
11
13
|
retentionCount: number;
|
|
12
14
|
}
|
|
15
|
+
/** 「当前任务」快照:最近一次使用或打开的任务。 */
|
|
16
|
+
export interface CurrentTaskSnapshot {
|
|
17
|
+
version: number;
|
|
18
|
+
current?: {
|
|
19
|
+
taskId: string;
|
|
20
|
+
at: number;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
13
23
|
export declare class TaskStore {
|
|
14
|
-
|
|
15
|
-
private
|
|
16
|
-
private
|
|
24
|
+
root: string;
|
|
25
|
+
private tasksDir;
|
|
26
|
+
private indexFile;
|
|
27
|
+
private currentFile;
|
|
17
28
|
private readonly summaries;
|
|
18
29
|
private readonly cache;
|
|
19
30
|
private chain;
|
|
20
31
|
private retention;
|
|
32
|
+
/** 只有使用默认(平台统一)目录时才做旧数据迁移;显式 root 多用于测试。 */
|
|
33
|
+
private readonly migrateOnLoad;
|
|
34
|
+
/** 迁移过来的任务文件必须重建索引,避免沿用旧目录里那份空 index.json。 */
|
|
35
|
+
private forceRebuildIndex;
|
|
21
36
|
constructor(root?: string);
|
|
22
37
|
load(retention: RetentionOptions): Promise<void>;
|
|
23
38
|
list(): TaskSummary[];
|
|
@@ -29,11 +44,16 @@ export declare class TaskStore {
|
|
|
29
44
|
save(task: SearchTask): Promise<void>;
|
|
30
45
|
remove(id: string): Promise<void>;
|
|
31
46
|
flush(): Promise<void>;
|
|
32
|
-
/**
|
|
47
|
+
/** 读取「当前任务」快照;文件缺失或损坏时返回 null(由调用方自举)。 */
|
|
48
|
+
loadCurrent(): Promise<CurrentTaskSnapshot | null>;
|
|
49
|
+
saveCurrent(snapshot: CurrentTaskSnapshot): Promise<void>;
|
|
50
|
+
/** 手动清理:删除所有未保留任务,返回条数与释放的字节数。 */
|
|
33
51
|
cleanupUnpinned(): Promise<{
|
|
34
52
|
removed: number;
|
|
35
53
|
freedBytes: number;
|
|
36
54
|
}>;
|
|
55
|
+
/** 迁移回退时切回旧目录读取,所有派生路径(含设置页展示)同步更新。 */
|
|
56
|
+
private rebind;
|
|
37
57
|
private taskFile;
|
|
38
58
|
private readIndex;
|
|
39
59
|
/** 索引缺失/损坏时按任务文件重建(只读头部,不长期占内存)。 */
|