dsh-plugin-litsearch 0.2.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.
@@ -9,8 +9,9 @@
9
9
  * - 不覆盖新目录里已有的文件,重复启动不会反复迁移;
10
10
  * - 复制失败或校验不通过时回退到旧目录读取,保证任务标注不丢。
11
11
  *
12
- * PDF 的可读文件由设置项 `pdfRoot` 决定(默认 `~/Downloads/dsh-litsearch`),
13
- * 不受本模块影响;只有插件内部的去重缓存 `<root>/pdfs` 随数据目录一起搬迁。
12
+ * PDF 的可读文件由设置项 `pdfRoot` 决定(默认落在当前会话工作目录下的
13
+ * `dsh-litsearch/`),不受本模块影响;只有插件内部的去重缓存 `<root>/pdfs`
14
+ * 随数据目录一起搬迁。
14
15
  */
15
16
  export declare const PLUGIN_ID = "dsh-plugin-litsearch";
16
17
  /** 历史版本使用的目录名(`$DSH_HOME` 下的一级目录)。 */
package/lib/host/paths.js CHANGED
@@ -9,8 +9,9 @@
9
9
  * - 不覆盖新目录里已有的文件,重复启动不会反复迁移;
10
10
  * - 复制失败或校验不通过时回退到旧目录读取,保证任务标注不丢。
11
11
  *
12
- * PDF 的可读文件由设置项 `pdfRoot` 决定(默认 `~/Downloads/dsh-litsearch`),
13
- * 不受本模块影响;只有插件内部的去重缓存 `<root>/pdfs` 随数据目录一起搬迁。
12
+ * PDF 的可读文件由设置项 `pdfRoot` 决定(默认落在当前会话工作目录下的
13
+ * `dsh-litsearch/`),不受本模块影响;只有插件内部的去重缓存 `<root>/pdfs`
14
+ * 随数据目录一起搬迁。
14
15
  */
15
16
  import { copyFile, mkdir, readdir, rm, stat } from 'node:fs/promises';
16
17
  import { homedir } from 'node:os';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * PDF 下载目录的纯路径计算。
3
+ *
4
+ * 优先级:显式配置 `pdfRoot` → 会话工作目录下的 `dsh-litsearch/` →
5
+ * 历史默认 `~/Downloads/dsh-litsearch`。保持无相对导入,便于单测直接加载。
6
+ */
7
+ /** 工作目录(或 Downloads)下的固定子目录名。 */
8
+ export declare const PDF_SUBDIR = "dsh-litsearch";
9
+ /** 展开 `~` / `~/...`;其它路径原样返回。 */
10
+ export declare function expandHomePath(path: string, home?: string): string;
11
+ export declare function resolvePdfRootPath(configured: string, workspace: string | undefined, home?: string): string;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * PDF 下载目录的纯路径计算。
3
+ *
4
+ * 优先级:显式配置 `pdfRoot` → 会话工作目录下的 `dsh-litsearch/` →
5
+ * 历史默认 `~/Downloads/dsh-litsearch`。保持无相对导入,便于单测直接加载。
6
+ */
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ /** 工作目录(或 Downloads)下的固定子目录名。 */
10
+ export const PDF_SUBDIR = 'dsh-litsearch';
11
+ /** 展开 `~` / `~/...`;其它路径原样返回。 */
12
+ export function expandHomePath(path, home = homedir()) {
13
+ if (path === '~')
14
+ return home;
15
+ if (path.startsWith('~/'))
16
+ return join(home, path.slice(2));
17
+ return path;
18
+ }
19
+ export function resolvePdfRootPath(configured, workspace, home = homedir()) {
20
+ const custom = configured.trim();
21
+ if (custom !== '')
22
+ return expandHomePath(custom, home);
23
+ const base = workspace?.trim() ?? '';
24
+ if (base !== '')
25
+ return join(base, PDF_SUBDIR);
26
+ return join(home, 'Downloads', PDF_SUBDIR);
27
+ }
@@ -18,8 +18,8 @@ export interface PdfResolution {
18
18
  url?: string;
19
19
  error?: string;
20
20
  }
21
- /** 设置里的根目录;空串回落到 ~/Downloads/dsh-litsearch。 */
22
- export declare function resolvePdfRoot(): string;
21
+ /** 设置里的根目录;未配置时落在会话工作目录下的 dsh-litsearch/。 */
22
+ export declare function resolvePdfRoot(workspace?: string): string;
23
23
  export declare function pdfCacheDir(storageRoot: string): string;
24
24
  /** 尝试所有候选并返回第一个合法 PDF;失败时给出可展示的状态与原因。 */
25
25
  export declare function resolvePdf(paper: Paper, signal?: AbortSignal): Promise<PdfResolution>;
@@ -31,7 +31,7 @@ export interface FetchPdfOptions {
31
31
  /** 下载并校验单个 PDF;内部处理重定向,每一步都重新校验目标地址。 */
32
32
  export declare function fetchPdf(url: string, options?: FetchPdfOptions): Promise<Buffer>;
33
33
  /** 写入去重缓存与用户目录;返回两份路径。 */
34
- export declare function persistPdf(buffer: Buffer, storageRoot: string, paper: Paper): Promise<{
34
+ export declare function persistPdf(buffer: Buffer, storageRoot: string, paper: Paper, workspace?: string): Promise<{
35
35
  cachePath: string;
36
36
  filePath: string;
37
37
  filename: string;
@@ -41,14 +41,22 @@ export declare function readSavedPdf(filePath: string): Promise<Buffer | null>;
41
41
  /** `<第一作者><年份>_<短标题>_<PMID|DOI>.pdf`,去掉路径分隔符。 */
42
42
  export declare function readableFilename(paper: Paper): string;
43
43
  /** 预览时读取已保存的可读文件;没有则返回 null(由调用方决定是否联网)。 */
44
- export declare function readLocalPdf(paper: Paper): Promise<Buffer | null>;
44
+ export declare function readLocalPdf(paper: Paper, workspace?: string): Promise<Buffer | null>;
45
45
  /** 清理 PDF 缓存目录。 */
46
46
  export declare function cleanupPdfCache(storageRoot: string): Promise<{
47
47
  removed: number;
48
48
  freedBytes: number;
49
49
  }>;
50
+ /**
51
+ * 按总量上限修剪去重缓存:超出时从最旧(mtime)开始删,直到不超过上限。
52
+ * `maxBytes <= 0` 表示关闭自动清理。
53
+ */
54
+ export declare function prunePdfCache(storageRoot: string, maxBytes: number): Promise<{
55
+ removed: number;
56
+ freedBytes: number;
57
+ }>;
50
58
  /** 测试下载目录是否可写(设置页)。 */
51
- export declare function probePdfRoot(): Promise<{
59
+ export declare function probePdfRoot(workspace?: string): Promise<{
52
60
  root: string;
53
61
  writable: boolean;
54
62
  error?: string;
@@ -7,11 +7,11 @@
7
7
  import { createHash } from 'node:crypto';
8
8
  import { lookup } from 'node:dns/promises';
9
9
  import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
10
- import { homedir } from 'node:os';
11
10
  import { isIP } from 'node:net';
12
11
  import { join } from 'node:path';
13
12
  import { getSettings } from '../../search/config.js';
14
13
  import { epmcXmlUrl, pdfCandidates, unpaywallPdfCandidates } from '../../search/oa.js';
14
+ import { resolvePdfRootPath } from './root.js';
15
15
  const MAX_REDIRECTS = 5;
16
16
  const FETCH_TIMEOUT_MS = 20_000;
17
17
  const MAX_PDF_BYTES_DEFAULT = 30 * 1024 * 1024;
@@ -26,16 +26,9 @@ export class PdfFetchError extends Error {
26
26
  this.status = status;
27
27
  }
28
28
  }
29
- /** 设置里的根目录;空串回落到 ~/Downloads/dsh-litsearch。 */
30
- export function resolvePdfRoot() {
31
- const configured = getSettings().pdfRoot.trim();
32
- if (configured === '')
33
- return join(homedir(), 'Downloads', 'dsh-litsearch');
34
- if (configured === '~')
35
- return homedir();
36
- if (configured.startsWith('~/'))
37
- return join(homedir(), configured.slice(2));
38
- return configured;
29
+ /** 设置里的根目录;未配置时落在会话工作目录下的 dsh-litsearch/。 */
30
+ export function resolvePdfRoot(workspace) {
31
+ return resolvePdfRootPath(getSettings().pdfRoot, workspace);
39
32
  }
40
33
  export function pdfCacheDir(storageRoot) {
41
34
  return join(storageRoot, 'pdfs');
@@ -138,13 +131,13 @@ export async function fetchPdf(url, options = {}) {
138
131
  throw new PdfFetchError('failed', `重定向超过 ${MAX_REDIRECTS} 次`);
139
132
  }
140
133
  /** 写入去重缓存与用户目录;返回两份路径。 */
141
- export async function persistPdf(buffer, storageRoot, paper) {
134
+ export async function persistPdf(buffer, storageRoot, paper, workspace) {
142
135
  const hash = createHash('sha256').update(buffer).digest('hex');
143
136
  const cacheDir = pdfCacheDir(storageRoot);
144
137
  await mkdir(cacheDir, { recursive: true });
145
138
  const cachePath = join(cacheDir, `${hash}.pdf`);
146
139
  await writeFile(cachePath, buffer);
147
- const root = resolvePdfRoot();
140
+ const root = resolvePdfRoot(workspace);
148
141
  await mkdir(root, { recursive: true });
149
142
  const filename = readableFilename(paper);
150
143
  const filePath = join(root, filename);
@@ -178,8 +171,8 @@ export function readableFilename(paper) {
178
171
  return `${base.slice(0, 120)}.pdf`;
179
172
  }
180
173
  /** 预览时读取已保存的可读文件;没有则返回 null(由调用方决定是否联网)。 */
181
- export async function readLocalPdf(paper) {
182
- return readSavedPdf(join(resolvePdfRoot(), readableFilename(paper)));
174
+ export async function readLocalPdf(paper, workspace) {
175
+ return readSavedPdf(join(resolvePdfRoot(workspace), readableFilename(paper)));
183
176
  }
184
177
  /** 清理 PDF 缓存目录。 */
185
178
  export async function cleanupPdfCache(storageRoot) {
@@ -206,9 +199,61 @@ export async function cleanupPdfCache(storageRoot) {
206
199
  }
207
200
  return { removed, freedBytes };
208
201
  }
202
+ /**
203
+ * 按总量上限修剪去重缓存:超出时从最旧(mtime)开始删,直到不超过上限。
204
+ * `maxBytes <= 0` 表示关闭自动清理。
205
+ */
206
+ export async function prunePdfCache(storageRoot, maxBytes) {
207
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0)
208
+ return { removed: 0, freedBytes: 0 };
209
+ const dir = pdfCacheDir(storageRoot);
210
+ let names;
211
+ try {
212
+ names = await readdir(dir);
213
+ }
214
+ catch {
215
+ return { removed: 0, freedBytes: 0 };
216
+ }
217
+ const files = [];
218
+ let total = 0;
219
+ for (const name of names) {
220
+ if (!name.endsWith('.pdf'))
221
+ continue;
222
+ const path = join(dir, name);
223
+ try {
224
+ const info = await stat(path);
225
+ if (!info.isFile())
226
+ continue;
227
+ files.push({ path, size: info.size, mtimeMs: info.mtimeMs });
228
+ total += info.size;
229
+ }
230
+ catch {
231
+ // 单个缓存文件读不到元信息时跳过,不影响修剪其余文件。
232
+ }
233
+ }
234
+ if (total <= maxBytes)
235
+ return { removed: 0, freedBytes: 0 };
236
+ files.sort((a, b) => a.mtimeMs - b.mtimeMs);
237
+ let removed = 0;
238
+ let freedBytes = 0;
239
+ for (const file of files) {
240
+ if (total <= maxBytes)
241
+ break;
242
+ try {
243
+ await rm(file.path, { force: true });
244
+ total -= file.size;
245
+ freedBytes += file.size;
246
+ removed += 1;
247
+ }
248
+ catch {
249
+ // 删除失败继续尝试更近的文件。
250
+ }
251
+ }
252
+ return { removed, freedBytes };
253
+ }
209
254
  /** 测试下载目录是否可写(设置页)。 */
210
- export async function probePdfRoot() {
211
- const root = resolvePdfRoot();
255
+ export async function probePdfRoot(workspace) {
256
+ const root = resolvePdfRoot(workspace);
212
257
  const probe = join(root, `.write-test-${process.pid}-${Date.now()}`);
213
258
  try {
214
259
  await mkdir(root, { recursive: true });
@@ -6,13 +6,15 @@
6
6
  * `sec-fetch-site: cross-site`。写端点额外要求自定义头 + JSON content-type,
7
7
  * 两者一起让浏览器必须预检,堵掉跨站表单与简单请求。
8
8
  */
9
- import { MAX_LIMIT, ROUTE_PREFIX, WRITE_HEADER } from '../search/config.js';
9
+ import { MAX_LIMIT, ROUTE_PREFIX, SESSION_HEADER, WRITE_HEADER } from '../search/config.js';
10
10
  import { SearchError } from '../search/errors.js';
11
11
  import { asRecord } from '../search/normalize.js';
12
12
  import { isSourceId } from '../search/providers/index.js';
13
+ import { TASK_LANGUAGES } from '../search/types.js';
14
+ import { resolveSessionWorkspace } from './workspace.js';
13
15
  const MAX_BODY_BYTES = 1_000_000;
14
16
  const SORT_MODES = ['relevance', 'citations', 'year'];
15
- const LANGUAGES = ['any', 'eng', 'chi'];
17
+ const LANGUAGES = TASK_LANGUAGES;
16
18
  const ORIGINS = ['model', 'panel', 'history', 'api'];
17
19
  const SCREENINGS = ['unread', 'read', 'included', 'excluded'];
18
20
  const PDF_STATUSES = [
@@ -39,6 +41,13 @@ function isTrusted(req) {
39
41
  return false;
40
42
  return isLoopbackHost(req.headers.host);
41
43
  }
44
+ /** 面板把当前会话 id 放在请求头里;iframe 预览等无法带头部的 GET 用查询参数。 */
45
+ function readSessionId(req, params) {
46
+ const header = req.headers[SESSION_HEADER];
47
+ const raw = typeof header === 'string' && header.trim() !== '' ? header : params.get('session') ?? '';
48
+ const id = raw.trim();
49
+ return id === '' ? undefined : id;
50
+ }
42
51
  /** 写端点要求自定义头 + JSON:跨站请求无法在不触发预检的情况下伪造。 */
43
52
  function hasWriteIntent(req) {
44
53
  if (req.headers[WRITE_HEADER] !== '1')
@@ -561,7 +570,7 @@ function parseSegments(pathname) {
561
570
  }
562
571
  });
563
572
  }
564
- async function handleTasks(service, req, res, seg, params) {
573
+ async function handleTasks(service, req, res, seg, params, sessionId) {
565
574
  const id = seg[1];
566
575
  if (id === undefined) {
567
576
  if (req.method === 'GET') {
@@ -570,7 +579,8 @@ async function handleTasks(service, req, res, seg, params) {
570
579
  }
571
580
  if (req.method === 'POST') {
572
581
  const input = readCreateInput(await readJsonBody(req));
573
- const task = await service.create({ ...input, origin: 'panel' });
582
+ const workspace = await resolveSessionWorkspace(sessionId);
583
+ const task = await service.create({ ...input, origin: 'panel', workspace });
574
584
  ok(res, await service.getDetail(task.id), 201);
575
585
  return;
576
586
  }
@@ -629,7 +639,7 @@ async function handleTasks(service, req, res, seg, params) {
629
639
  fail(res, 405, 'bad_request', 'activate 仅支持 POST');
630
640
  return;
631
641
  }
632
- const task = await service.activate(id);
642
+ const task = await service.activate(id, await resolveSessionWorkspace(sessionId));
633
643
  ok(res, await service.getDetail(task.id));
634
644
  return;
635
645
  }
@@ -670,7 +680,7 @@ async function handleTasks(service, req, res, seg, params) {
670
680
  fail(res, 405, 'bad_request', 'download 仅支持 POST');
671
681
  return;
672
682
  }
673
- ok(res, await service.downloadPdf(id, paperId));
683
+ ok(res, await service.downloadPdf(id, paperId, await resolveSessionWorkspace(sessionId)));
674
684
  return;
675
685
  }
676
686
  if (op === 'pdf') {
@@ -679,7 +689,7 @@ async function handleTasks(service, req, res, seg, params) {
679
689
  return;
680
690
  }
681
691
  try {
682
- const file = await service.previewPdf(id, paperId);
692
+ const file = await service.previewPdf(id, paperId, await resolveSessionWorkspace(sessionId));
683
693
  sendPdf(req, res, file.buffer, file.filename);
684
694
  }
685
695
  catch (error) {
@@ -711,7 +721,7 @@ async function handleTasks(service, req, res, seg, params) {
711
721
  }
712
722
  const body = await readJsonBody(req);
713
723
  const ids = readStringArray(body['paperIds'], 'paperIds', 50, 80);
714
- ok(res, await service.zoteroSync(id, ids === undefined || ids === null ? undefined : ids));
724
+ ok(res, await service.zoteroSync(id, ids === undefined || ids === null ? undefined : ids, await resolveSessionWorkspace(sessionId)));
715
725
  return;
716
726
  }
717
727
  if (action === 'compare') {
@@ -737,7 +747,8 @@ async function handleTasks(service, req, res, seg, params) {
737
747
  throw new SearchError('conflict', '任务正在运行');
738
748
  // 面板里主动重跑 = 用户正在这个任务里工作,把它设为「当前任务」。
739
749
  service.setCurrentTask(id);
740
- void service.run(id, mode, 'panel').catch((error) => {
750
+ const workspace = await resolveSessionWorkspace(sessionId);
751
+ void service.run(id, mode, 'panel', workspace).catch((error) => {
741
752
  console.warn('[dsh-plugin-litsearch] 任务执行失败:', error);
742
753
  });
743
754
  ok(res, await service.getDetail(id), 202);
@@ -810,14 +821,15 @@ export function createHandler(service) {
810
821
  return;
811
822
  }
812
823
  if (seg[1] === 'storage' && req.method === 'POST') {
813
- ok(res, await service.storageProbe());
824
+ const workspace = await resolveSessionWorkspace(readSessionId(req, url.searchParams));
825
+ ok(res, await service.storageProbe(workspace));
814
826
  return;
815
827
  }
816
828
  fail(res, 404, 'not_found', `未知设置端点 ${seg[1] ?? ''}`);
817
829
  return;
818
830
  }
819
831
  if (head === 'tasks') {
820
- await handleTasks(service, req, res, seg, url.searchParams);
832
+ await handleTasks(service, req, res, seg, url.searchParams, readSessionId(req, url.searchParams));
821
833
  return;
822
834
  }
823
835
  if (head === 'export') {
@@ -37,6 +37,8 @@ export declare function filterPapers(task: SearchTask, filter?: PaperFilter): Pa
37
37
  export declare function screeningOf(task: SearchTask, paper: Paper): Screening;
38
38
  /** 有效全文状态:没有人工/下载记录时,上游给的 PDF 直链视为可用。 */
39
39
  export declare function pdfStatusOf(task: SearchTask, paper: Paper): PdfStatus;
40
- export declare function matchesLanguage(paper: Paper, language: TaskLanguage): boolean;
40
+ export declare function matchesLanguage(paper: {
41
+ language?: string;
42
+ }, language: TaskLanguage): boolean;
41
43
  /** 稳定的筛选指纹(统计缓存 key 的一部分)。 */
42
44
  export declare function filterKey(filter: PaperFilter): string;
@@ -72,11 +72,52 @@ export function pdfStatusOf(task, paper) {
72
72
  export function matchesLanguage(paper, language) {
73
73
  if (language === 'any')
74
74
  return true;
75
- const value = (paper.language ?? '').toLowerCase();
76
- if (language === 'chi')
77
- return value.startsWith('chi') || value.startsWith('zh');
78
- return value.startsWith('eng') || value.startsWith('en');
75
+ const raw = (paper.language ?? '').trim().toLowerCase();
76
+ // 兼容 `en-US` / `zh-CN` 这类带地区的写法,只取主语言码。
77
+ const code = LANGUAGE_ALIASES[raw] ?? LANGUAGE_ALIASES[raw.split(/[-_]/u)[0] ?? ''];
78
+ return code !== undefined && code === language;
79
79
  }
80
+ /**
81
+ * 上游语言标记 → 统一的 PubMed 三字母码。
82
+ *
83
+ * PubMed / Europe PMC 给三字母码,OpenAlex 等给两字母码,这里都收敛到同一套,
84
+ * 避免语言筛选把同一种语言判成两种。
85
+ */
86
+ const LANGUAGE_ALIASES = {
87
+ eng: 'eng',
88
+ en: 'eng',
89
+ english: 'eng',
90
+ chi: 'chi',
91
+ zho: 'chi',
92
+ zh: 'chi',
93
+ chinese: 'chi',
94
+ jpn: 'jpn',
95
+ ja: 'jpn',
96
+ japanese: 'jpn',
97
+ kor: 'kor',
98
+ ko: 'kor',
99
+ korean: 'kor',
100
+ fre: 'fre',
101
+ fra: 'fre',
102
+ fr: 'fre',
103
+ french: 'fre',
104
+ ger: 'ger',
105
+ deu: 'ger',
106
+ de: 'ger',
107
+ german: 'ger',
108
+ spa: 'spa',
109
+ es: 'spa',
110
+ spanish: 'spa',
111
+ rus: 'rus',
112
+ ru: 'rus',
113
+ russian: 'rus',
114
+ ita: 'ita',
115
+ it: 'ita',
116
+ italian: 'ita',
117
+ por: 'por',
118
+ pt: 'por',
119
+ portuguese: 'por',
120
+ };
80
121
  /** 稳定的筛选指纹(统计缓存 key 的一部分)。 */
81
122
  export function filterKey(filter) {
82
123
  return [
@@ -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
- }
@@ -26,6 +26,8 @@ export interface CreateTaskInput {
26
26
  sort?: SortMode;
27
27
  origin: TaskOrigin;
28
28
  fromTaskId?: string;
29
+ /** 发起方所在会话的工作目录;下载默认落在这里。 */
30
+ workspace?: string;
29
31
  runNow?: boolean;
30
32
  }
31
33
  export interface TaskPatch {
@@ -76,6 +78,8 @@ export declare class TaskService {
76
78
  * 又新建一个同名任务。
77
79
  */
78
80
  private restore;
81
+ /** 按设置的总量上限修剪 PDF 去重缓存;失败只告警,不阻断启动。 */
82
+ private trimPdfCache;
79
83
  /** 没有可用快照时,把最近更新的任务设为当前任务并落盘。 */
80
84
  private bootstrapCurrentTask;
81
85
  get globalRev(): number;
@@ -101,7 +105,9 @@ export declare class TaskService {
101
105
  focusTask(id: string, title: string, sessionId?: string): void;
102
106
  private persistCurrent;
103
107
  /** 用户从任务列表点开某任务 = 进入该任务,之后的模型检索默认落在这里。 */
104
- activate(id: string): Promise<SearchTask>;
108
+ activate(id: string, workspace?: string): Promise<SearchTask>;
109
+ /** 记下任务最近一次所在的工作目录;只改变下载落点,不改变 rev。 */
110
+ private applyWorkspace;
105
111
  /** 等待该任务当前这一轮结束;返回是否确实已空闲。 */
106
112
  waitUntilIdle(id: string, timeoutMs: number, signal?: AbortSignal): Promise<boolean>;
107
113
  create(input: CreateTaskInput): Promise<SearchTask>;
@@ -112,7 +118,7 @@ export declare class TaskService {
112
118
  *
113
119
  * 任务已缓存时同步登记运行态,调用方紧接着调 `waitUntilIdle` 也不会误判为空闲。
114
120
  */
115
- run(id: string, mode?: 'new' | 'more', origin?: TaskOrigin): Promise<SearchTask>;
121
+ run(id: string, mode?: 'new' | 'more', origin?: TaskOrigin, workspace?: string): Promise<SearchTask>;
116
122
  /**
117
123
  * 在同一任务内开新一轮:全量替换检索条件后执行,**不新建任务、不新开 tab**。
118
124
  *
@@ -137,9 +143,9 @@ export declare class TaskService {
137
143
  /** 设置页清理:任务文件与 PDF 缓存。 */
138
144
  cleanup(scope: CleanupScope): Promise<CleanupResult>;
139
145
  /** 下载单篇 PDF;失败时写入失败状态,若有 EPMC 全文则降级为 html-only。 */
140
- downloadPdf(id: string, paperId: string): Promise<PdfDownloadResult>;
146
+ downloadPdf(id: string, paperId: string, workspace?: string): Promise<PdfDownloadResult>;
141
147
  /** PDF 预览:优先用已落地文件;没有则临时抓取一次但不落盘。 */
142
- previewPdf(id: string, paperId: string): Promise<{
148
+ previewPdf(id: string, paperId: string, workspace?: string): Promise<{
143
149
  buffer: Buffer;
144
150
  filename: string;
145
151
  }>;
@@ -152,9 +158,9 @@ export declare class TaskService {
152
158
  /** 测试 Zotero connector 是否可达,并返回当前选中的分类。 */
153
159
  zoteroProbe(): Promise<ZoteroProbeResult>;
154
160
  /** 把选中论文同步到 Zotero(含本地 PDF 附件)。 */
155
- zoteroSync(id: string, paperIds?: readonly string[]): Promise<ZoteroSyncResult>;
161
+ zoteroSync(id: string, paperIds?: readonly string[], workspace?: string): Promise<ZoteroSyncResult>;
156
162
  /** 下载目录可写性测试(设置页)。 */
157
- storageProbe(): Promise<StorageProbeResult>;
163
+ storageProbe(workspace?: string): Promise<StorageProbeResult>;
158
164
  private ensureLocalPdf;
159
165
  private setPdfState;
160
166
  /** 单源诊断:用固定短查询测一次连通性与延迟(不受冷却影响)。 */