dsh-plugin-litsearch 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  * 负责校验/规范化任务条件、维护内存与磁盘状态、串行化执行并广播 rev。
5
5
  * 模型工具与面板 HTTP 路由都只调用这里,保证两边操作同一份状态。
6
6
  */
7
- import type { AnnotationInput, AnnotationResult, CleanupResult, CleanupScope, CompareResult, EventsPage, ExportFile, ExportFormat, HealthInfo, PaperDetail, PaperEntities, PdfDownloadResult, ProbeResult, QueryBlock, ResultsPage, SearchRound, SearchTask, SortMode, SourceId, StorageProbeResult, TaskDetail, TaskGroup, TaskLanguage, TaskOrigin, TaskStatsReport, TaskSummary, ZoteroProbeResult, ZoteroSyncResult } from '../../search/types.js';
7
+ import type { AnnotationInput, AnnotationResult, CleanupResult, CleanupScope, CompareResult, DownloadCommand, EventsPage, ExportFile, ExportFormat, HealthInfo, PaperDetail, PaperEntities, PdfDownloadResult, ProbeResult, QueryBlock, ResultsPage, SearchRound, SearchTask, SortMode, SourceId, StorageProbeResult, TaskDetail, TaskGroup, TaskLanguage, TaskOrigin, TaskStatsReport, TaskSummary, ViewSnapshot, ZoteroCommand, ZoteroProbeResult, ZoteroSyncResult, ZoteroTargetsResult } from '../../search/types.js';
8
8
  import { type PaperFilter } from './filter.js';
9
9
  export interface QueryInput {
10
10
  name?: string;
@@ -62,6 +62,13 @@ export declare class TaskService {
62
62
  private readonly store;
63
63
  private readonly events;
64
64
  private readonly runner;
65
+ /**
66
+ * 每个会话最近一次的右侧栏视图快照(内存、不落盘)。
67
+ *
68
+ * 模型工具跑在 Host 侧看不到界面,靠它把「当前页」「当前筛选全部」算准;
69
+ * 宿主重启即失效,与事件日志 rev 的生命周期一致。
70
+ */
71
+ private readonly views;
65
72
  /**
66
73
  * 全局「当前任务」:最后一次使用或打开的任务。
67
74
  *
@@ -103,6 +110,17 @@ export declare class TaskService {
103
110
  setCurrentTask(id: string): void;
104
111
  /** 请求客户端打开/聚焦某任务 tab(面板被关掉时重新打开)。 */
105
112
  focusTask(id: string, title: string, sessionId?: string): void;
113
+ /** 下发一条模型批量下载指令;右侧栏轮询 /events 拿增量后自行执行。 */
114
+ publishDownloadCommand(command: DownloadCommand): void;
115
+ /** 下发一条模型批量存入 Zotero 指令;与下载指令走独立的事件日志。 */
116
+ publishZoteroCommand(command: ZoteroCommand): void;
117
+ /**
118
+ * 面板上报当前视图。会话 id 以请求头为准覆盖 body,防止伪造别的会话的视图;
119
+ * 时间也由 Host 重置,避免客户端时钟偏差影响过期判断。
120
+ */
121
+ saveViewSnapshot(sessionId: string, snapshot: ViewSnapshot): void;
122
+ /** 取该会话在该任务上的最近视图;过期、跨任务或 rev 超前都视为不可信。 */
123
+ getViewSnapshot(sessionId: string | undefined, taskId: string): Promise<ViewSnapshot | undefined>;
106
124
  private persistCurrent;
107
125
  /** 用户从任务列表点开某任务 = 进入该任务,之后的模型检索默认落在这里。 */
108
126
  activate(id: string, workspace?: string): Promise<SearchTask>;
@@ -159,8 +177,16 @@ export declare class TaskService {
159
177
  entities(id: string, paperId: string, signal?: AbortSignal): Promise<PaperEntities>;
160
178
  /** 测试 Zotero connector 是否可达,并返回当前选中的分类。 */
161
179
  zoteroProbe(): Promise<ZoteroProbeResult>;
162
- /** 把选中论文同步到 Zotero(含本地 PDF 附件)。 */
163
- zoteroSync(id: string, paperIds?: readonly string[], workspace?: string): Promise<ZoteroSyncResult>;
180
+ /** 可写入的 Zotero 库与分类,供同步弹窗选择目标目录。 */
181
+ zoteroTargets(): Promise<ZoteroTargetsResult>;
182
+ /** 把选中论文同步到 Zotero(含本地 PDF 附件),并记录每篇的同步状态。 */
183
+ zoteroSync(id: string, paperIds?: readonly string[], workspace?: string, target?: {
184
+ id: string;
185
+ name?: string;
186
+ filesEditable?: boolean;
187
+ }): Promise<ZoteroSyncResult>;
188
+ /** 记录单篇文献的 Zotero 同步状态;与 PDF 状态一样写回任务并广播。 */
189
+ private setZoteroState;
164
190
  /** 下载目录可写性测试(设置页)。 */
165
191
  storageProbe(workspace?: string): Promise<StorageProbeResult>;
166
192
  private ensureLocalPdf;
@@ -169,6 +195,11 @@ export declare class TaskService {
169
195
  probe(source: SourceId): Promise<ProbeResult>;
170
196
  private tryGet;
171
197
  private baseInfo;
198
+ /**
199
+ * 供模型下载工具复用:`newOnly` 需要基准任务的论文集合才能与界面口径一致。
200
+ * 直接暴露给工具层,避免下载范围解析和 `/results` 出现两套过滤逻辑。
201
+ */
202
+ resolveDownloadFilter(id: string, filter: PaperFilter): Promise<PaperFilter>;
172
203
  /** `newOnly` 需要基准任务的论文集合;基准已删除时忽略该条件。 */
173
204
  private withBaseFilter;
174
205
  dispose(): void;
@@ -7,7 +7,7 @@
7
7
  import { randomUUID } from 'node:crypto';
8
8
  import { mkdir, rm, stat, writeFile } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
- import { MAX_LIMIT, PLUGIN_VERSION, getSettings } from '../../search/config.js';
10
+ import { MAX_LIMIT, 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';
@@ -21,7 +21,7 @@ import { cleanupPdfCache, epmcFullTextAvailable, persistPdf, probePdfRoot, prune
21
21
  import { ZoteroConnector } from '../zotero/connector.js';
22
22
  import { normalizeWorkspace } from '../workspace.js';
23
23
  import { compareTasks } from './compare.js';
24
- import { filterPapers } from './filter.js';
24
+ import { filterPapers, sortPapers } from './filter.js';
25
25
  import { TaskRunner } from './runner.js';
26
26
  import { computeStats } from './stats.js';
27
27
  import { applyAnnotations, countScreening, emptyState, normalizeGroups } from './states.js';
@@ -35,6 +35,10 @@ const PROBE_QUERY = 'machine learning';
35
35
  /** 已下载文件存在性检查的短缓存:详情轮询时不必反复 stat 同一批文件。 */
36
36
  const PDF_EXISTS_TTL_MS = 10_000;
37
37
  const pdfExistsCache = new Map();
38
+ /** 右侧栏视图快照有效期:面板长时间不动时不拿旧页码替模型算「当前页」。 */
39
+ const VIEW_TTL_MS = 30 * 60 * 1000;
40
+ /** 最多保留多少个会话的视图快照,超出按上报时间淘汰最旧的。 */
41
+ const MAX_VIEWS = 64;
38
42
  async function pdfFileExists(filePath) {
39
43
  const cached = pdfExistsCache.get(filePath);
40
44
  if (cached !== undefined && Date.now() - cached.at < PDF_EXISTS_TTL_MS)
@@ -79,6 +83,13 @@ export class TaskService {
79
83
  store = new TaskStore();
80
84
  events = new TaskEvents();
81
85
  runner = new TaskRunner();
86
+ /**
87
+ * 每个会话最近一次的右侧栏视图快照(内存、不落盘)。
88
+ *
89
+ * 模型工具跑在 Host 侧看不到界面,靠它把「当前页」「当前筛选全部」算准;
90
+ * 宿主重启即失效,与事件日志 rev 的生命周期一致。
91
+ */
92
+ views = new Map();
82
93
  /**
83
94
  * 全局「当前任务」:最后一次使用或打开的任务。
84
95
  *
@@ -195,7 +206,6 @@ export class TaskService {
195
206
  tag: settings.zoteroTag,
196
207
  },
197
208
  storageDir: this.store.root,
198
- version: PLUGIN_VERSION,
199
209
  };
200
210
  }
201
211
  async eventsSince(sinceRev) {
@@ -265,6 +275,53 @@ export class TaskService {
265
275
  focusTask(id, title, sessionId) {
266
276
  this.events.requestFocus(id, title, sessionId);
267
277
  }
278
+ /** 下发一条模型批量下载指令;右侧栏轮询 /events 拿增量后自行执行。 */
279
+ publishDownloadCommand(command) {
280
+ this.events.publishDownload(command);
281
+ }
282
+ /** 下发一条模型批量存入 Zotero 指令;与下载指令走独立的事件日志。 */
283
+ publishZoteroCommand(command) {
284
+ this.events.publishZotero(command);
285
+ }
286
+ /**
287
+ * 面板上报当前视图。会话 id 以请求头为准覆盖 body,防止伪造别的会话的视图;
288
+ * 时间也由 Host 重置,避免客户端时钟偏差影响过期判断。
289
+ */
290
+ saveViewSnapshot(sessionId, snapshot) {
291
+ if (sessionId === '' || snapshot.taskId === '')
292
+ return;
293
+ this.views.set(sessionId, { ...snapshot, sessionId, at: Date.now() });
294
+ if (this.views.size <= MAX_VIEWS)
295
+ return;
296
+ let oldestKey;
297
+ let oldestAt = Number.MAX_SAFE_INTEGER;
298
+ for (const [key, value] of this.views) {
299
+ if (value.at < oldestAt) {
300
+ oldestAt = value.at;
301
+ oldestKey = key;
302
+ }
303
+ }
304
+ if (oldestKey !== undefined)
305
+ this.views.delete(oldestKey);
306
+ }
307
+ /** 取该会话在该任务上的最近视图;过期、跨任务或 rev 超前都视为不可信。 */
308
+ async getViewSnapshot(sessionId, taskId) {
309
+ await this.ready;
310
+ if (sessionId === undefined || sessionId === '')
311
+ return undefined;
312
+ const snapshot = this.views.get(sessionId);
313
+ if (snapshot === undefined || snapshot.taskId !== taskId)
314
+ return undefined;
315
+ if (Date.now() - snapshot.at > VIEW_TTL_MS) {
316
+ this.views.delete(sessionId);
317
+ return undefined;
318
+ }
319
+ // 每下载一篇都会让 rev +1,因此只做「快照不比任务更新」的宽松校验。
320
+ const task = await this.tryGet(taskId);
321
+ if (task === undefined || snapshot.rev > task.rev)
322
+ return undefined;
323
+ return snapshot;
324
+ }
268
325
  persistCurrent() {
269
326
  void this.store.saveCurrent({
270
327
  version: 1,
@@ -411,6 +468,10 @@ export class TaskService {
411
468
  // 模型工具导出到 <storage>/out/<taskId>/ 的文件随任务一起清掉。
412
469
  await rm(join(this.store.root, 'out', task.id), { recursive: true, force: true });
413
470
  this.events.remove(task.id);
471
+ for (const [key, value] of this.views) {
472
+ if (value.taskId === task.id)
473
+ this.views.delete(key);
474
+ }
414
475
  if (this.currentTaskId !== task.id)
415
476
  return;
416
477
  this.currentTaskId = undefined;
@@ -469,13 +530,7 @@ export class TaskService {
469
530
  const limit = clampInt(filter.limit ?? task.conditions.pageSize, 1, MAX_PAGE_LIMIT);
470
531
  const offset = clampInt(filter.offset ?? 0, 0, Number.MAX_SAFE_INTEGER);
471
532
  const papers = filterPapers(task, await this.withBaseFilter(task, filter));
472
- const sort = filter.sort ?? task.conditions.sort;
473
- if (sort === 'citations') {
474
- papers.sort((a, b) => (b.citedBy ?? -1) - (a.citedBy ?? -1));
475
- }
476
- else if (sort === 'year') {
477
- papers.sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
478
- }
533
+ sortPapers(papers, filter.sort ?? task.conditions.sort);
479
534
  return {
480
535
  taskId: task.id,
481
536
  rev: task.rev,
@@ -689,13 +744,23 @@ export class TaskService {
689
744
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
690
745
  }
691
746
  }
692
- /** 把选中论文同步到 Zotero(含本地 PDF 附件)。 */
693
- async zoteroSync(id, paperIds, workspace) {
747
+ /** 可写入的 Zotero 库与分类,供同步弹窗选择目标目录。 */
748
+ async zoteroTargets() {
749
+ const settings = getSettings();
750
+ try {
751
+ return await new ZoteroConnector(settings.zoteroBaseUrl).listTargets();
752
+ }
753
+ catch (error) {
754
+ return { ok: false, targets: [], error: error instanceof Error ? error.message : String(error) };
755
+ }
756
+ }
757
+ /** 把选中论文同步到 Zotero(含本地 PDF 附件),并记录每篇的同步状态。 */
758
+ async zoteroSync(id, paperIds, workspace, target) {
694
759
  const settings = getSettings();
695
760
  if (!settings.zoteroEnabled)
696
761
  throw new SearchError('bad_request', 'Zotero 同步已在设置中关闭');
697
762
  const task = await this.get(id);
698
- const target = normalizeWorkspace(workspace) ?? normalizeWorkspace(task.workspace);
763
+ const workspaceDir = normalizeWorkspace(workspace) ?? normalizeWorkspace(task.workspace);
699
764
  const wanted = paperIds === undefined || paperIds.length === 0
700
765
  ? task.result.papers.slice(0, 50)
701
766
  : paperIds.map((paperId) => findPaper(task, paperId));
@@ -706,7 +771,9 @@ export class TaskService {
706
771
  if (!probe.ok) {
707
772
  throw new SearchError('network_error', `Zotero 未运行或 connector 不可达:${probe.error ?? settings.zoteroBaseUrl}`);
708
773
  }
709
- const readOnlyAttachments = settings.zoteroAttachPdf && probe.collection?.filesEditable === false;
774
+ // 目标目录优先:用户选定的分类可能是另一个库,不能只看当前选中的分类。
775
+ const filesEditable = target?.filesEditable ?? probe.collection?.filesEditable;
776
+ const readOnlyAttachments = settings.zoteroAttachPdf && filesEditable === false;
710
777
  const attachPdf = settings.zoteroAttachPdf && !readOnlyAttachments;
711
778
  const inputs = [];
712
779
  for (const paper of wanted) {
@@ -715,7 +782,7 @@ export class TaskService {
715
782
  const group = task.groups.find((entry) => entry.id === state?.groupId);
716
783
  const tags = group === undefined ? [] : [group.name];
717
784
  const pdfPath = attachPdf
718
- ? await this.ensureLocalPdf(paper, target, state?.pdfFile)
785
+ ? await this.ensureLocalPdf(paper, workspaceDir, state?.pdfFile)
719
786
  : undefined;
720
787
  inputs.push({ paper, tags, ...(pdfPath === undefined ? {} : { pdfPath }) });
721
788
  }
@@ -724,12 +791,31 @@ export class TaskService {
724
791
  baseUrl: settings.zoteroBaseUrl,
725
792
  attachPdf,
726
793
  tag: settings.zoteroTag,
794
+ ...(target === undefined
795
+ ? {}
796
+ : {
797
+ targetId: target.id,
798
+ ...(target.name === undefined ? {} : { targetName: target.name }),
799
+ }),
727
800
  });
728
- if (probe.collection === undefined) {
801
+ if (target === undefined && probe.collection === undefined) {
729
802
  result.messages.unshift('Zotero 当前未选择分类,条目已保存到「我的文库」');
730
803
  }
731
804
  else if (readOnlyAttachments) {
732
- result.messages.unshift('当前分类不可写附件,已只保存条目;可在 Zotero 中换一个可写分类后重试');
805
+ const label = target === undefined ? '当前分类' : `「${target.name ?? target.id}」`;
806
+ result.messages.unshift(`${label}不可写附件,已只保存条目;可换一个可写目录后重试`);
807
+ }
808
+ const collectionName = target === undefined ? probe.collection?.name : target.name;
809
+ for (const item of result.items) {
810
+ await this.setZoteroState(task, item.paperId, item.ok
811
+ ? {
812
+ zoteroStatus: 'synced',
813
+ ...(collectionName === undefined ? {} : { zoteroCollection: collectionName }),
814
+ }
815
+ : {
816
+ zoteroStatus: 'failed',
817
+ zoteroError: item.error ?? result.messages[0] ?? '同步失败',
818
+ });
733
819
  }
734
820
  return result;
735
821
  }
@@ -737,6 +823,26 @@ export class TaskService {
737
823
  throw new SearchError('upstream_error', error instanceof Error ? error.message : String(error));
738
824
  }
739
825
  }
826
+ /** 记录单篇文献的 Zotero 同步状态;与 PDF 状态一样写回任务并广播。 */
827
+ async setZoteroState(task, paperId, patch) {
828
+ if (this.store.summary(task.id) === undefined)
829
+ return;
830
+ const current = task.states[paperId] ?? emptyState();
831
+ const next = { ...current, zoteroStatus: patch.zoteroStatus };
832
+ if (patch.zoteroCollection === undefined)
833
+ delete next.zoteroCollection;
834
+ else
835
+ next.zoteroCollection = patch.zoteroCollection.slice(0, 200);
836
+ if (patch.zoteroError === undefined)
837
+ delete next.zoteroError;
838
+ else
839
+ next.zoteroError = patch.zoteroError.slice(0, 300);
840
+ task.states[paperId] = next;
841
+ task.rev += 1;
842
+ task.updatedAt = new Date().toISOString();
843
+ await this.store.save(task);
844
+ this.events.publish(toSummary(task));
845
+ }
740
846
  /** 下载目录可写性测试(设置页)。 */
741
847
  async storageProbe(workspace) {
742
848
  const outcome = await probePdfRoot(workspace);
@@ -840,6 +946,13 @@ export class TaskService {
840
946
  return undefined;
841
947
  return { paperIds: new Set(base.result.papers.map((paper) => paper.paperId)), rev: base.rev };
842
948
  }
949
+ /**
950
+ * 供模型下载工具复用:`newOnly` 需要基准任务的论文集合才能与界面口径一致。
951
+ * 直接暴露给工具层,避免下载范围解析和 `/results` 出现两套过滤逻辑。
952
+ */
953
+ async resolveDownloadFilter(id, filter) {
954
+ return this.withBaseFilter(await this.get(id), filter);
955
+ }
843
956
  /** `newOnly` 需要基准任务的论文集合;基准已删除时忽略该条件。 */
844
957
  async withBaseFilter(task, filter) {
845
958
  if (filter.newOnly !== true || task.fromTaskId === undefined)
@@ -9,6 +9,10 @@
9
9
  * - `literature_task`:读取/修改已保存任务(含轮次历史),写入筛读与分组,
10
10
  * 做新老对比与统计。
11
11
  * - `literature_export`:把任务(按筛选)导出为 CSV / RIS / BibTeX / Markdown 并落盘。
12
+ * - `literature_download`:把自然语言下载请求解析成已校验的 paperId 清单,
13
+ * 通过事件下发给右侧栏,由右侧栏复用批量下载弹窗执行并逐篇展示。
14
+ * - `literature_zotero`:与下载共用范围解析,改为让右侧栏复用 Zotero 同步
15
+ * 弹窗;对话里点名目录且匹配成功才自动开跑,否则由用户选目录。
12
16
  *
13
17
  * 参数在边界做一次校验(模型输出不可信),服务层仍会再兜底。
14
18
  */