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.
@@ -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';
@@ -63,6 +64,8 @@ export class TaskService {
63
64
  catch (error) {
64
65
  console.warn('[dsh-plugin-litsearch] 任务目录加载失败:', error);
65
66
  }
67
+ // 与任务自动清理同批执行:按总量上限修剪 PDF 去重缓存(0 表示关闭)。
68
+ await this.trimPdfCache();
66
69
  let restored = false;
67
70
  try {
68
71
  const snapshot = await this.store.loadCurrent();
@@ -79,6 +82,21 @@ export class TaskService {
79
82
  if (!restored)
80
83
  this.bootstrapCurrentTask();
81
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
+ }
82
100
  /** 没有可用快照时,把最近更新的任务设为当前任务并落盘。 */
83
101
  bootstrapCurrentTask() {
84
102
  const latest = this.store.list()[0];
@@ -109,7 +127,11 @@ export class TaskService {
109
127
  globalRev: this.events.currentRev,
110
128
  sources: [...PROVIDER_INFOS],
111
129
  sourceHealth: sourceHealthList(),
112
- keys: { ncbi: settings.ncbiApiKey !== '', s2: settings.s2ApiKey !== '' },
130
+ keys: {
131
+ ncbi: settings.ncbiApiKey !== '',
132
+ s2: settings.s2ApiKey !== '',
133
+ serpapi: settings.serpApiKey !== '',
134
+ },
113
135
  settings: {
114
136
  defaultSources: [...settings.defaultSources],
115
137
  defaultPageSize: settings.defaultPageSize,
@@ -119,7 +141,7 @@ export class TaskService {
119
141
  maxTaskTabs: settings.maxTaskTabs,
120
142
  },
121
143
  fullText: {
122
- pdfRoot: resolvePdfRoot(),
144
+ pdfRoot: settings.pdfRoot.trim() === '' ? '' : resolvePdfRoot(),
123
145
  pdfMaxMb: settings.pdfMaxMb,
124
146
  autoDownload: settings.autoDownloadPdf,
125
147
  epmcFulltext: settings.enableEpmcFulltext,
@@ -206,11 +228,20 @@ export class TaskService {
206
228
  });
207
229
  }
208
230
  /** 用户从任务列表点开某任务 = 进入该任务,之后的模型检索默认落在这里。 */
209
- async activate(id) {
231
+ async activate(id, workspace) {
210
232
  const task = await this.get(id);
233
+ await this.applyWorkspace(task, workspace);
211
234
  this.setCurrentTask(task.id);
212
235
  return task;
213
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
+ }
214
245
  /** 等待该任务当前这一轮结束;返回是否确实已空闲。 */
215
246
  async waitUntilIdle(id, timeoutMs, signal) {
216
247
  if (!this.runner.isRunning(id))
@@ -250,6 +281,7 @@ export class TaskService {
250
281
  const conditions = this.readConditions(input, settings);
251
282
  const title = readTitle(input.title, conditions);
252
283
  const inherited = input.fromTaskId === undefined ? undefined : await this.get(input.fromTaskId);
284
+ const workspace = normalizeWorkspace(input.workspace);
253
285
  const now = new Date().toISOString();
254
286
  const task = {
255
287
  id: `t${Date.now().toString(36)}${randomUUID().slice(0, 6)}`,
@@ -272,6 +304,7 @@ export class TaskService {
272
304
  result: { papers: [], sources: [] },
273
305
  rounds: [],
274
306
  states: inherited === undefined ? {} : inheritStates(inherited),
307
+ ...(workspace === undefined ? {} : { workspace }),
275
308
  ...(input.fromTaskId === undefined ? {} : { fromTaskId: input.fromTaskId }),
276
309
  };
277
310
  this.store.put(task);
@@ -342,17 +375,17 @@ export class TaskService {
342
375
  *
343
376
  * 任务已缓存时同步登记运行态,调用方紧接着调 `waitUntilIdle` 也不会误判为空闲。
344
377
  */
345
- run(id, mode = 'new', origin = 'panel') {
378
+ run(id, mode = 'new', origin = 'panel', workspace) {
346
379
  const cached = this.store.cached(id);
347
380
  if (cached !== undefined) {
348
381
  try {
349
- return this.begin(cached, mode, origin);
382
+ return this.begin(cached, mode, origin, workspace);
350
383
  }
351
384
  catch (error) {
352
385
  return Promise.reject(error);
353
386
  }
354
387
  }
355
- return this.get(id).then((task) => this.begin(task, mode, origin));
388
+ return this.get(id).then((task) => this.begin(task, mode, origin, workspace));
356
389
  }
357
390
  /**
358
391
  * 在同一任务内开新一轮:全量替换检索条件后执行,**不新建任务、不新开 tab**。
@@ -369,6 +402,9 @@ export class TaskService {
369
402
  if (nextTitle !== '')
370
403
  task.title = nextTitle.slice(0, MAX_TITLE);
371
404
  task.conditions = this.readConditions(input, getSettings());
405
+ const workspace = normalizeWorkspace(input.workspace);
406
+ if (workspace !== undefined)
407
+ task.workspace = workspace;
372
408
  task.rev += 1;
373
409
  task.updatedAt = new Date().toISOString();
374
410
  await this.store.save(task);
@@ -484,9 +520,12 @@ export class TaskService {
484
520
  };
485
521
  }
486
522
  /** 下载单篇 PDF;失败时写入失败状态,若有 EPMC 全文则降级为 html-only。 */
487
- async downloadPdf(id, paperId) {
523
+ async downloadPdf(id, paperId, workspace) {
488
524
  const task = await this.get(id);
489
525
  const paper = findPaper(task, paperId);
526
+ const target = normalizeWorkspace(workspace) ?? normalizeWorkspace(task.workspace);
527
+ if (target !== undefined)
528
+ task.workspace = target;
490
529
  const settings = getSettings();
491
530
  const resolution = await resolvePdf(paper);
492
531
  if (!resolution.ok || resolution.buffer === undefined) {
@@ -503,7 +542,7 @@ export class TaskService {
503
542
  };
504
543
  return result;
505
544
  }
506
- const saved = await persistPdf(resolution.buffer, this.store.root, paper);
545
+ const saved = await persistPdf(resolution.buffer, this.store.root, paper, target);
507
546
  await this.setPdfState(task, paperId, {
508
547
  pdfStatus: 'downloaded',
509
548
  pdfFile: saved.filePath,
@@ -520,11 +559,14 @@ export class TaskService {
520
559
  return result;
521
560
  }
522
561
  /** PDF 预览:优先用已落地文件;没有则临时抓取一次但不落盘。 */
523
- async previewPdf(id, paperId) {
562
+ async previewPdf(id, paperId, workspace) {
524
563
  const task = await this.get(id);
525
564
  const paper = findPaper(task, paperId);
526
565
  const filename = readableFilename(paper);
527
- 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));
528
570
  if (local !== null)
529
571
  return { buffer: local, filename };
530
572
  const resolution = await resolvePdf(paper);
@@ -593,11 +635,12 @@ export class TaskService {
593
635
  }
594
636
  }
595
637
  /** 把选中论文同步到 Zotero(含本地 PDF 附件)。 */
596
- async zoteroSync(id, paperIds) {
638
+ async zoteroSync(id, paperIds, workspace) {
597
639
  const settings = getSettings();
598
640
  if (!settings.zoteroEnabled)
599
641
  throw new SearchError('bad_request', 'Zotero 同步已在设置中关闭');
600
642
  const task = await this.get(id);
643
+ const target = normalizeWorkspace(workspace) ?? normalizeWorkspace(task.workspace);
601
644
  const wanted = paperIds === undefined || paperIds.length === 0
602
645
  ? task.result.papers.slice(0, 50)
603
646
  : paperIds.map((paperId) => findPaper(task, paperId));
@@ -612,8 +655,11 @@ export class TaskService {
612
655
  const attachPdf = settings.zoteroAttachPdf && !readOnlyAttachments;
613
656
  const inputs = [];
614
657
  for (const paper of wanted) {
615
- const tags = task.states[paper.paperId]?.tags ?? [];
616
- 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;
617
663
  inputs.push({ paper, tags, ...(pdfPath === undefined ? {} : { pdfPath }) });
618
664
  }
619
665
  try {
@@ -635,8 +681,8 @@ export class TaskService {
635
681
  }
636
682
  }
637
683
  /** 下载目录可写性测试(设置页)。 */
638
- async storageProbe() {
639
- const outcome = await probePdfRoot();
684
+ async storageProbe(workspace) {
685
+ const outcome = await probePdfRoot(workspace);
640
686
  if (outcome.writable)
641
687
  return { ok: true, root: outcome.root, writable: true };
642
688
  return {
@@ -646,14 +692,19 @@ export class TaskService {
646
692
  ...(outcome.error === undefined ? {} : { error: outcome.error }),
647
693
  };
648
694
  }
649
- async ensureLocalPdf(paper) {
650
- const local = await readLocalPdf(paper);
651
- if (local !== null)
652
- 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;
653
704
  const resolution = await resolvePdf(paper).catch(() => null);
654
705
  if (resolution === null || !resolution.ok || resolution.buffer === undefined)
655
706
  return undefined;
656
- const saved = await persistPdf(resolution.buffer, this.store.root, paper);
707
+ const saved = await persistPdf(resolution.buffer, this.store.root, paper, workspace);
657
708
  return saved.filePath;
658
709
  }
659
710
  async setPdfState(task, paperId, patch) {
@@ -744,10 +795,15 @@ export class TaskService {
744
795
  this.runner.cancelAll();
745
796
  void this.store.flush();
746
797
  }
747
- begin(task, mode, origin = task.origin) {
798
+ begin(task, mode, origin = task.origin, workspace) {
748
799
  if (this.runner.isRunning(task.id)) {
749
800
  throw new SearchError('conflict', '任务正在运行');
750
801
  }
802
+ const nextWorkspace = normalizeWorkspace(workspace);
803
+ if (nextWorkspace !== undefined && nextWorkspace !== task.workspace) {
804
+ task.workspace = nextWorkspace;
805
+ void this.store.save(task);
806
+ }
751
807
  if (mode === 'more') {
752
808
  throw new SearchError('bad_request', '续页加载将在后续版本提供,请先重跑任务');
753
809
  }
@@ -796,7 +852,7 @@ export class TaskService {
796
852
  }
797
853
  continue;
798
854
  }
799
- const saved = await persistPdf(resolution.buffer, this.store.root, paper);
855
+ const saved = await persistPdf(resolution.buffer, this.store.root, paper, task.workspace);
800
856
  await this.setPdfState(task, paper.paperId, {
801
857
  pdfStatus: 'downloaded',
802
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;
@@ -47,7 +47,7 @@ export declare class TaskStore {
47
47
  /** 读取「当前任务」快照;文件缺失或损坏时返回 null(由调用方自举)。 */
48
48
  loadCurrent(): Promise<CurrentTaskSnapshot | null>;
49
49
  saveCurrent(snapshot: CurrentTaskSnapshot): Promise<void>;
50
- /** 手动清理:删除所有未置顶任务,返回条数与释放的字节数。 */
50
+ /** 手动清理:删除所有未保留任务,返回条数与释放的字节数。 */
51
51
  cleanupUnpinned(): Promise<{
52
52
  removed: number;
53
53
  freedBytes: number;
@@ -135,7 +135,7 @@ export class TaskStore {
135
135
  await atomicWrite(this.currentFile, body);
136
136
  });
137
137
  }
138
- /** 手动清理:删除所有未置顶任务,返回条数与释放的字节数。 */
138
+ /** 手动清理:删除所有未保留任务,返回条数与释放的字节数。 */
139
139
  async cleanupUnpinned() {
140
140
  const targets = this.list().filter((summary) => !summary.pinned);
141
141
  let freedBytes = 0;
package/lib/host/tools.js CHANGED
@@ -16,7 +16,8 @@ import { MAX_LIMIT } from '../search/config.js';
16
16
  import { SearchError } from '../search/errors.js';
17
17
  import { asRecord } from '../search/normalize.js';
18
18
  import { PROVIDER_INFOS } from '../search/providers/index.js';
19
- import { SOURCE_IDS } from '../search/types.js';
19
+ import { SOURCE_IDS, TASK_LANGUAGES } from '../search/types.js';
20
+ import { resolveSessionWorkspace } from './workspace.js';
20
21
  /** 模型侧最多等多久;超时后任务继续跑,只返回当前进度与已有结果。 */
21
22
  const MODEL_WAIT_MS = 25_000;
22
23
  /** 交给模型的条数(UI 走分页端点,不受此限制)。 */
@@ -24,7 +25,7 @@ const TOOL_PAPER_LIMIT = 10;
24
25
  /** 复用任务时,最多等多久让上一轮跑完;超时直接返回提示,绝不新建任务。 */
25
26
  const WAIT_PREVIOUS_MS = 12_000;
26
27
  const SORT_MODES = ['relevance', 'citations', 'year'];
27
- const LANGUAGES = ['any', 'eng', 'chi'];
28
+ const LANGUAGES = TASK_LANGUAGES;
28
29
  const SCREENINGS = ['unread', 'read', 'included', 'excluded'];
29
30
  const EXPORT_FORMATS = ['csv', 'ris', 'bibtex', 'md'];
30
31
  const TASK_ACTIONS = [
@@ -105,7 +106,7 @@ const SEARCH_PARAMETERS = {
105
106
  sources: {
106
107
  type: 'array',
107
108
  items: { type: 'string', enum: [...SOURCE_IDS] },
108
- description: `要查询的数据源:${SOURCE_IDS.join(', ')};省略表示全部。`,
109
+ description: `要查询的数据源:${SOURCE_IDS.join(', ')};省略表示默认数据源(googlescholar 需配置 SerpAPI Key,默认关闭)。`,
109
110
  },
110
111
  limit: {
111
112
  type: 'integer',
@@ -247,7 +248,7 @@ export function literatureTools(service) {
247
248
  {
248
249
  name: 'literature_search',
249
250
  description: '检索免费开放学术文献。并发查询 PubMed、Europe PMC、OpenAlex、Crossref、Semantic Scholar、arXiv、PubTator,跨源与跨检索式去重合并成一篇文献一条,返回标题、作者、年份、出处、DOI、被引次数、开放获取状态与摘要,支持一次提交多条检索式(queries)。' +
250
- '【任务模型】一个研究主题 = 一个任务 = 一个右侧栏 tab;一个对话只保留一个任务 tab,切换任务由用户在任务列表里完成。若不确定这个对话当前用哪个任务,先调用 literature_task(action=current)查清再执行。默认复用「当前任务」并在同一任务内开新一轮:任务 id 不变、tab 不变,结果替换为最新一轮,已有的已读/纳入/排除/备注按文献身份保留;绝不会新建任务或新开 tab。当前任务是持久化的「最近一次使用或打开的任务」,跨对话、宿主重启都有效;用户从右侧栏点开别的任务会切换当前任务。只有用户明确要求新建查询任务,或研究主题与当前任务明显不同时,才传 newTask=true 并用 title 给新任务起一个概括主题的名称;也可用 taskId 显式指定要复用的任务(先用 literature_task current/list 查 id,或让用户点右侧栏的「带入对话」把任务 id 带进对话)。每次调用请提交【完整】的新检索条件,缺省项回落到插件默认设置而非沿用旧条件。' +
251
+ '【任务模型】一个研究主题 = 一个任务 = 一个右侧栏 tab;一个对话只保留一个任务 tab,切换任务由用户在任务列表里完成。若不确定这个对话当前用哪个任务,先调用 literature_task(action=current)查清再执行。默认复用「当前任务」并在同一任务内开新一轮:任务 id 不变、tab 不变,结果替换为最新一轮,已有的已读/纳入/排除/备注按文献身份保留;绝不会新建任务或新开 tab。当前任务是持久化的「最近一次使用或打开的任务」,跨对话、宿主重启都有效;用户从右侧栏点开别的任务会切换当前任务。只有用户明确要求新建查询任务,或研究主题与当前任务明显不同时,才传 newTask=true 并用 title 给新任务起一个概括主题的名称;也可用 taskId 显式指定要复用的任务(先用 literature_task current/list 查 id,或让用户点右侧栏的「加入对话」把任务 id 带进对话)。每次调用请提交【完整】的新检索条件,缺省项回落到插件默认设置而非沿用旧条件。' +
251
252
  '【轮次约束】本轮检索结束后,只给用户一份「如何完善检索」的建议,禁止自行发起下一轮、禁止重复调用本工具;必须等用户查看结果后,在右侧栏调整条件、或在对话里明确要求调整,才可在同一任务里再搜一次。' +
252
253
  '超过 25 秒仍在进行时会先返回当前进度,任务会继续在右侧栏完成。适合找论文、查某主题的代表作、补充参考文献。',
253
254
  parameters: SEARCH_PARAMETERS,
@@ -255,7 +256,13 @@ export function literatureTools(service) {
255
256
  async execute(args, exec) {
256
257
  const input = readSearchArgs(args);
257
258
  const { title, taskId, newTask, ...conditions } = input;
258
- const createInput = { ...conditions, origin: 'model', runNow: false };
259
+ const workspace = await resolveSessionWorkspace(exec.agent?.id);
260
+ const createInput = {
261
+ ...conditions,
262
+ origin: 'model',
263
+ runNow: false,
264
+ ...(workspace === undefined ? {} : { workspace }),
265
+ };
259
266
  let task;
260
267
  let reused = false;
261
268
  if (taskId !== undefined) {
@@ -315,7 +322,7 @@ export function literatureTools(service) {
315
322
  },
316
323
  {
317
324
  name: 'literature_sources',
318
- description: '列出本插件已接入的免费文献数据源:能力(摘要、被引次数、开放获取、PDF 链接)、限流额度、检索式语法与是否需要可选 API Key。',
325
+ description: '列出本插件已接入的文献数据源:能力(摘要、被引次数、开放获取、PDF 链接)、限流额度、检索式语法与是否需要可选 API Key。默认数据源均免费;Google Scholar 需在设置页配置 SerpAPI Key(有免费额度,超出付费),未配置时不参与检索。',
319
326
  parameters: { type: 'object', properties: {}, additionalProperties: false },
320
327
  output: TEXT_OUTPUT,
321
328
  async execute() {
@@ -769,10 +776,11 @@ async function runTaskAction(service, args, signal, sessionId) {
769
776
  case 'run': {
770
777
  const id = requireId(args);
771
778
  const task = await service.get(id);
779
+ const workspace = await resolveSessionWorkspace(sessionId);
772
780
  // 用户明确要求推进这个任务:它成为当前任务,并让客户端打开/聚焦对应 tab。
773
781
  service.setCurrentTask(id);
774
782
  service.focusTask(task.id, task.title, sessionId);
775
- const outcome = await runWithin(service, id, signal, service.run(id, 'new', 'model'));
783
+ const outcome = await runWithin(service, id, signal, service.run(id, 'new', 'model', workspace));
776
784
  const latest = await service.get(id);
777
785
  const detail = await service.getDetail(id);
778
786
  const page = await service.results(id, {
@@ -853,7 +861,7 @@ function formatCurrentTask(task) {
853
861
  if (task === undefined) {
854
862
  return [
855
863
  '当前没有任务:这个对话还没有任何检索任务。',
856
- '若用户要求检索,请直接调用 literature_search(不要传 newTask);若用户要继续某个已有任务,请让用户从右侧栏任务列表打开它,或点任务页的「带入对话」把任务 id 带进对话。',
864
+ '若用户要求检索,请直接调用 literature_search(不要传 newTask);若用户要继续某个已有任务,请让用户从右侧栏任务列表打开它,或点任务页的「加入对话」把任务 id 带进对话。',
857
865
  ].join('\n');
858
866
  }
859
867
  const conditions = task.conditions;
@@ -1002,7 +1010,9 @@ function percent(value) {
1002
1010
  return `${Math.round(value * 100)}%`;
1003
1011
  }
1004
1012
  function formatSources() {
1005
- const lines = ['已接入的免费文献数据源(无需付费;部分支持可选 Key 提高额度):'];
1013
+ const lines = [
1014
+ '已接入的文献数据源(默认均为免费;Google Scholar 需在设置页配置 SerpAPI Key,未配置时不参与检索):',
1015
+ ];
1006
1016
  for (const info of PROVIDER_INFOS) {
1007
1017
  const caps = [
1008
1018
  info.capabilities.abstract ? '摘要' : '',
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 会话工作目录解析。
3
+ *
4
+ * DSH 为每个会话保存一个绝对工作目录(SessionHeader.cwd)。PDF 默认落在
5
+ * 该目录下的 `dsh-litsearch/`,让下载物跟随项目而不是散落到 `~/Downloads`。
6
+ * 会话服务由宿主注入;未装配或读不到时返回 undefined,由调用方回落到旧默认。
7
+ */
8
+ /** `ctx.sessionPersistence` 中本插件用到的最小结构。 */
9
+ export interface SessionStoreLike {
10
+ stat(id: string): Promise<{
11
+ header: {
12
+ cwd?: string;
13
+ };
14
+ } | undefined>;
15
+ }
16
+ export declare function setSessionStore(store: SessionStoreLike | undefined): void;
17
+ /** 读取某个会话的工作目录;只接受绝对路径。 */
18
+ export declare function resolveSessionWorkspace(sessionId: string | undefined): Promise<string | undefined>;
19
+ /** 规范化工作目录:去空白、去尾斜杠,只接受绝对路径。 */
20
+ export declare function normalizeWorkspace(value: string | undefined): string | undefined;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 会话工作目录解析。
3
+ *
4
+ * DSH 为每个会话保存一个绝对工作目录(SessionHeader.cwd)。PDF 默认落在
5
+ * 该目录下的 `dsh-litsearch/`,让下载物跟随项目而不是散落到 `~/Downloads`。
6
+ * 会话服务由宿主注入;未装配或读不到时返回 undefined,由调用方回落到旧默认。
7
+ */
8
+ import { isAbsolute, resolve } from 'node:path';
9
+ let sessionStore;
10
+ export function setSessionStore(store) {
11
+ sessionStore = store;
12
+ }
13
+ /** 读取某个会话的工作目录;只接受绝对路径。 */
14
+ export async function resolveSessionWorkspace(sessionId) {
15
+ const id = sessionId?.trim() ?? '';
16
+ if (id === '' || sessionStore === undefined)
17
+ return undefined;
18
+ try {
19
+ const snapshot = await sessionStore.stat(id);
20
+ return normalizeWorkspace(snapshot?.header.cwd);
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ /** 规范化工作目录:去空白、去尾斜杠,只接受绝对路径。 */
27
+ export function normalizeWorkspace(value) {
28
+ const path = value?.trim() ?? '';
29
+ if (path === '' || !isAbsolute(path))
30
+ return undefined;
31
+ return resolve(path);
32
+ }
package/lib/index.d.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import type { Context } from '@deepseek-ai/cordis';
16
16
  import Schema from '@deepseek-ai/schemastery';
17
+ import type { TaskLanguage } from './search/types.js';
17
18
  export declare const name = "dsh-plugin-litsearch";
18
19
  /**
19
20
  * 插件配置 schema(逐字段 volatile)。
@@ -28,6 +29,8 @@ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
28
29
  ncbiApiKey: Schema<string, string, "volatile-defined">;
29
30
  /** 可选 Semantic Scholar API Key。 */
30
31
  s2ApiKey: Schema<string, string, "volatile-defined">;
32
+ /** 可选 SerpAPI Key;配置后启用 Google Scholar 数据源(付费/有免费额度)。 */
33
+ serpApiKey: Schema<string, string, "volatile-defined">;
31
34
  /** 各源最小请求间隔(毫秒)。 */
32
35
  pubmedIntervalMs: Schema<number, number, "volatile-defined">;
33
36
  s2IntervalMs: Schema<number, number, "volatile-defined">;
@@ -37,22 +40,24 @@ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
37
40
  /** 单次上游请求超时(毫秒)。 */
38
41
  requestTimeoutMs: Schema<number, number, "volatile-defined">;
39
42
  /** 默认数据源。 */
40
- defaultSources: Schema<NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator")[]>, NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator")[]>, "volatile-defined">;
43
+ defaultSources: Schema<NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator" | "googlescholar")[]>, NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator" | "googlescholar")[]>, "volatile-defined">;
41
44
  defaultSort: Schema<"relevance" | "citations" | "year", "relevance" | "citations" | "year", "volatile-defined">;
42
- defaultLanguage: Schema<"any" | "eng" | "chi", "any" | "eng" | "chi", "volatile-defined">;
45
+ defaultLanguage: Schema<TaskLanguage, TaskLanguage, "volatile-defined">;
43
46
  /** 模型发起检索后是否自动打开任务 tab。 */
44
47
  autoOpenTaskTab: Schema<boolean, boolean, "volatile-defined">;
45
48
  /** 同时保留的任务 tab 上限。 */
46
49
  maxTaskTabs: Schema<number, number, "volatile-defined">;
47
- /** 任务保留天数与条数(置顶任务不受限)。 */
50
+ /** 任务保留天数与条数(已保留任务不受限)。 */
48
51
  taskRetentionDays: Schema<number, number, "volatile-defined">;
49
52
  taskRetentionCount: Schema<number, number, "volatile-defined">;
50
53
  /** 单任务结果上限。 */
51
54
  taskMaxPapers: Schema<number, number, "volatile-defined">;
52
- /** PDF 下载根目录。 */
55
+ /** PDF 下载根目录;留空时跟随会话工作目录(其下的 dsh-litsearch/)。 */
53
56
  pdfRoot: Schema<string, string, "volatile-defined">;
54
57
  /** 单个 PDF 最大体积(MB)。 */
55
58
  pdfMaxMb: Schema<number, number, "volatile-defined">;
59
+ /** PDF 去重缓存总量上限(MB),启动时随任务清理一起修剪;0 表示关闭。 */
60
+ pdfCacheMaxMb: Schema<number, number, "volatile-defined">;
56
61
  /** 检索完成后自动下载缺失 PDF。 */
57
62
  autoDownloadPdf: Schema<boolean, boolean, "volatile-defined">;
58
63
  /** 无 PDF 时用 Europe PMC HTML 全文兜底。 */
@@ -69,6 +74,8 @@ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
69
74
  ncbiApiKey: Schema<string, string, "volatile-defined">;
70
75
  /** 可选 Semantic Scholar API Key。 */
71
76
  s2ApiKey: Schema<string, string, "volatile-defined">;
77
+ /** 可选 SerpAPI Key;配置后启用 Google Scholar 数据源(付费/有免费额度)。 */
78
+ serpApiKey: Schema<string, string, "volatile-defined">;
72
79
  /** 各源最小请求间隔(毫秒)。 */
73
80
  pubmedIntervalMs: Schema<number, number, "volatile-defined">;
74
81
  s2IntervalMs: Schema<number, number, "volatile-defined">;
@@ -78,22 +85,24 @@ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
78
85
  /** 单次上游请求超时(毫秒)。 */
79
86
  requestTimeoutMs: Schema<number, number, "volatile-defined">;
80
87
  /** 默认数据源。 */
81
- defaultSources: Schema<NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator")[]>, NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator")[]>, "volatile-defined">;
88
+ defaultSources: Schema<NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator" | "googlescholar")[]>, NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator" | "googlescholar")[]>, "volatile-defined">;
82
89
  defaultSort: Schema<"relevance" | "citations" | "year", "relevance" | "citations" | "year", "volatile-defined">;
83
- defaultLanguage: Schema<"any" | "eng" | "chi", "any" | "eng" | "chi", "volatile-defined">;
90
+ defaultLanguage: Schema<TaskLanguage, TaskLanguage, "volatile-defined">;
84
91
  /** 模型发起检索后是否自动打开任务 tab。 */
85
92
  autoOpenTaskTab: Schema<boolean, boolean, "volatile-defined">;
86
93
  /** 同时保留的任务 tab 上限。 */
87
94
  maxTaskTabs: Schema<number, number, "volatile-defined">;
88
- /** 任务保留天数与条数(置顶任务不受限)。 */
95
+ /** 任务保留天数与条数(已保留任务不受限)。 */
89
96
  taskRetentionDays: Schema<number, number, "volatile-defined">;
90
97
  taskRetentionCount: Schema<number, number, "volatile-defined">;
91
98
  /** 单任务结果上限。 */
92
99
  taskMaxPapers: Schema<number, number, "volatile-defined">;
93
- /** PDF 下载根目录。 */
100
+ /** PDF 下载根目录;留空时跟随会话工作目录(其下的 dsh-litsearch/)。 */
94
101
  pdfRoot: Schema<string, string, "volatile-defined">;
95
102
  /** 单个 PDF 最大体积(MB)。 */
96
103
  pdfMaxMb: Schema<number, number, "volatile-defined">;
104
+ /** PDF 去重缓存总量上限(MB),启动时随任务清理一起修剪;0 表示关闭。 */
105
+ pdfCacheMaxMb: Schema<number, number, "volatile-defined">;
97
106
  /** 检索完成后自动下载缺失 PDF。 */
98
107
  autoDownloadPdf: Schema<boolean, boolean, "volatile-defined">;
99
108
  /** 无 PDF 时用 Europe PMC HTML 全文兜底。 */