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.
- package/README.md +8 -8
- package/client/client.js +3406 -1620
- package/lib/host/paths.d.ts +3 -2
- package/lib/host/paths.js +3 -2
- package/lib/host/pdf/root.d.ts +11 -0
- package/lib/host/pdf/root.js +27 -0
- package/lib/host/pdf/store.d.ts +13 -5
- package/lib/host/pdf/store.js +62 -17
- package/lib/host/router.js +23 -11
- package/lib/host/tasks/filter.d.ts +3 -1
- package/lib/host/tasks/filter.js +45 -4
- package/lib/host/tasks/runner.js +1 -8
- package/lib/host/tasks/service.d.ts +12 -6
- package/lib/host/tasks/service.js +79 -23
- package/lib/host/tasks/stats.js +6 -4
- package/lib/host/tasks/store.d.ts +1 -1
- package/lib/host/tasks/store.js +1 -1
- package/lib/host/tools.js +19 -9
- package/lib/host/workspace.d.ts +20 -0
- package/lib/host/workspace.js +32 -0
- package/lib/index.d.ts +17 -8
- package/lib/index.js +33 -11
- package/lib/search/aggregate.js +1 -0
- package/lib/search/config.d.ts +17 -1
- package/lib/search/config.js +16 -1
- package/lib/search/identity.js +1 -0
- package/lib/search/providers/googlescholar.d.ts +12 -0
- package/lib/search/providers/googlescholar.js +154 -0
- package/lib/search/providers/index.js +2 -0
- package/lib/search/query-adapter.js +3 -0
- package/lib/search/rate-limit.js +2 -1
- package/lib/search/types.d.ts +17 -4
- package/lib/search/types.js +21 -1
- package/package.json +1 -1
|
@@ -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: {
|
|
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
|
|
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
|
|
616
|
-
const
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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,
|
package/lib/host/tasks/stats.js
CHANGED
|
@@ -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,
|
|
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;
|
package/lib/host/tasks/store.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
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: '
|
|
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
|
|
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 = [
|
|
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<
|
|
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<
|
|
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 全文兜底。 */
|