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.
@@ -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
- * 工具执行上下文没有会话 id(已核实 @deepseek-ai/dsh-tools 的
68
- * ToolExecutionInput),只能用「最近使用的任务 + TTL」做降级:同一主题
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
- /** 当前任务(TTL 内且仍存在);模型不指定 taskId 时默认复用它。 */
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 { CURRENT_TASK_TTL_MS, MAX_LIMIT, PLUGIN_VERSION, getSettings, } from '../../search/config.js';
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
- * 工具执行上下文没有会话 id(已核实 @deepseek-ai/dsh-tools 的
43
- * ToolExecutionInput),只能用「最近使用的任务 + TTL」做降级:同一主题
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.store
52
- .load({
53
- retentionDays: getSettings().taskRetentionDays,
54
- retentionCount: getSettings().taskRetentionCount,
55
- })
56
- .catch((error) => {
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: { ncbi: settings.ncbiApiKey !== '', s2: settings.s2ApiKey !== '' },
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
- /** 当前任务(TTL 内且仍存在);模型不指定 taskId 时默认复用它。 */
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 === undefined) {
145
- this.currentTaskId = undefined;
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
- ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }),
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
- this.runner.cancel(id);
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 === task.id)
278
- this.currentTaskId = undefined;
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 local = await readLocalPdf(paper);
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 tags = task.states[paper.paperId]?.tags ?? [];
556
- const pdfPath = attachPdf ? await this.ensureLocalPdf(paper) : undefined;
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
- const local = await readLocalPdf(paper);
591
- if (local !== null)
592
- return join(resolvePdfRoot(), readableFilename(paper));
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) => this.events.publish(toSummary(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,
@@ -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, TOP_LIST);
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
- readonly root: string;
15
- private readonly tasksDir;
16
- private readonly indexFile;
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
  /** 索引缺失/损坏时按任务文件重建(只读头部,不长期占内存)。 */