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.
- package/README.md +89 -14
- package/client/client.js +1192 -108
- package/lib/host/events.d.ts +9 -1
- package/lib/host/events.js +50 -3
- package/lib/host/router.js +66 -1
- package/lib/host/tasks/download.d.ts +104 -0
- package/lib/host/tasks/download.js +274 -0
- package/lib/host/tasks/filter.d.ts +8 -1
- package/lib/host/tasks/filter.js +14 -0
- package/lib/host/tasks/service.d.ts +34 -3
- package/lib/host/tasks/service.js +130 -17
- package/lib/host/tools.d.ts +4 -0
- package/lib/host/tools.js +808 -9
- package/lib/host/zotero/connector.d.ts +19 -10
- package/lib/host/zotero/connector.js +142 -39
- package/lib/host/zotero/match.d.ts +27 -0
- package/lib/host/zotero/match.js +59 -0
- package/lib/search/config.d.ts +0 -2
- package/lib/search/config.js +0 -2
- package/lib/search/types.d.ts +136 -1
- package/lib/search/types.js +7 -0
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
163
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
693
|
-
async
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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)
|
package/lib/host/tools.d.ts
CHANGED
|
@@ -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
|
*/
|