dsh-plugin-litsearch 0.1.0 → 0.2.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 +31 -9
- package/client/client.js +1096 -438
- package/lib/host/events.d.ts +3 -0
- package/lib/host/events.js +25 -1
- package/lib/host/paths.d.ts +43 -0
- package/lib/host/paths.js +128 -0
- package/lib/host/router.js +12 -0
- package/lib/host/tasks/filter.d.ts +2 -0
- package/lib/host/tasks/filter.js +4 -0
- package/lib/host/tasks/service.d.ts +22 -9
- package/lib/host/tasks/service.js +101 -30
- package/lib/host/tasks/store.d.ts +23 -3
- package/lib/host/tasks/store.js +63 -5
- package/lib/host/tools.d.ts +13 -6
- package/lib/host/tools.js +56 -22
- package/lib/search/config.d.ts +0 -7
- package/lib/search/config.js +0 -7
- package/lib/search/types.d.ts +10 -1
- package/package.json +1 -1
package/lib/host/events.d.ts
CHANGED
|
@@ -9,8 +9,11 @@ import type { EventsPage, TaskSummary } from '../search/types.js';
|
|
|
9
9
|
export declare class TaskEvents {
|
|
10
10
|
private rev;
|
|
11
11
|
private readonly entries;
|
|
12
|
+
private focus;
|
|
12
13
|
get currentRev(): number;
|
|
13
14
|
publish(summary: TaskSummary): void;
|
|
15
|
+
/** 请求客户端把某个任务 tab 打开/切到前台(面板关着时重新打开)。 */
|
|
16
|
+
requestFocus(taskId: string, title: string, sessionId?: string): void;
|
|
14
17
|
remove(id: string): void;
|
|
15
18
|
since(sinceRev: number): EventsPage;
|
|
16
19
|
}
|
package/lib/host/events.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
export class TaskEvents {
|
|
9
9
|
rev = 0;
|
|
10
10
|
entries = new Map();
|
|
11
|
+
focus;
|
|
11
12
|
get currentRev() {
|
|
12
13
|
return this.rev;
|
|
13
14
|
}
|
|
@@ -15,8 +16,21 @@ export class TaskEvents {
|
|
|
15
16
|
this.rev += 1;
|
|
16
17
|
this.entries.set(summary.id, { globalRev: this.rev, summary });
|
|
17
18
|
}
|
|
19
|
+
/** 请求客户端把某个任务 tab 打开/切到前台(面板关着时重新打开)。 */
|
|
20
|
+
requestFocus(taskId, title, sessionId) {
|
|
21
|
+
this.rev += 1;
|
|
22
|
+
this.focus = {
|
|
23
|
+
globalRev: this.rev,
|
|
24
|
+
taskId,
|
|
25
|
+
title,
|
|
26
|
+
at: Date.now(),
|
|
27
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
18
30
|
remove(id) {
|
|
19
31
|
this.entries.delete(id);
|
|
32
|
+
if (this.focus?.taskId === id)
|
|
33
|
+
this.focus = undefined;
|
|
20
34
|
}
|
|
21
35
|
since(sinceRev) {
|
|
22
36
|
if (!Number.isFinite(sinceRev) || sinceRev < 0) {
|
|
@@ -29,6 +43,16 @@ export class TaskEvents {
|
|
|
29
43
|
.filter((entry) => entry.globalRev > sinceRev)
|
|
30
44
|
.sort((a, b) => a.globalRev - b.globalRev)
|
|
31
45
|
.map((entry) => entry.summary);
|
|
32
|
-
|
|
46
|
+
const focus = this.focus !== undefined && this.focus.globalRev > sinceRev
|
|
47
|
+
? {
|
|
48
|
+
taskId: this.focus.taskId,
|
|
49
|
+
title: this.focus.title,
|
|
50
|
+
at: this.focus.at,
|
|
51
|
+
...(this.focus.sessionId === undefined
|
|
52
|
+
? {}
|
|
53
|
+
: { sessionId: this.focus.sessionId }),
|
|
54
|
+
}
|
|
55
|
+
: undefined;
|
|
56
|
+
return { rev: this.rev, reset: false, tasks, ...(focus === undefined ? {} : { focus }) };
|
|
33
57
|
}
|
|
34
58
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件数据目录解析与旧版数据迁移。
|
|
3
|
+
*
|
|
4
|
+
* 平台约定:插件私有数据统一放在 `$DSH_HOME/storages/<plugin-id>/`,
|
|
5
|
+
* `DSH_HOME` 由宿主注入,未设置时回落 `~/.dsh`。本插件历史版本把数据直接
|
|
6
|
+
* 写在 `~/.dsh/dsh-litsearch/`,这里负责一次性搬迁到统一存储区:
|
|
7
|
+
*
|
|
8
|
+
* - 只复制、不删除,旧目录原样保留作备份;
|
|
9
|
+
* - 不覆盖新目录里已有的文件,重复启动不会反复迁移;
|
|
10
|
+
* - 复制失败或校验不通过时回退到旧目录读取,保证任务标注不丢。
|
|
11
|
+
*
|
|
12
|
+
* PDF 的可读文件由设置项 `pdfRoot` 决定(默认 `~/Downloads/dsh-litsearch`),
|
|
13
|
+
* 不受本模块影响;只有插件内部的去重缓存 `<root>/pdfs` 随数据目录一起搬迁。
|
|
14
|
+
*/
|
|
15
|
+
export declare const PLUGIN_ID = "dsh-plugin-litsearch";
|
|
16
|
+
/** 历史版本使用的目录名(`$DSH_HOME` 下的一级目录)。 */
|
|
17
|
+
export declare const LEGACY_DIR = "dsh-litsearch";
|
|
18
|
+
/** 解析宿主数据根目录;支持绝对路径、`~/...` 与空值回落。 */
|
|
19
|
+
export declare function dshHome(env?: NodeJS.ProcessEnv, home?: string): string;
|
|
20
|
+
/** 当前数据根目录:平台统一存储区下的插件域。 */
|
|
21
|
+
export declare function storageRoot(env?: NodeJS.ProcessEnv, home?: string): string;
|
|
22
|
+
/** 旧数据根目录候选,按优先级排列,已排除新根目录。 */
|
|
23
|
+
export declare function legacyRoots(env?: NodeJS.ProcessEnv, home?: string): string[];
|
|
24
|
+
/** 目录里是否存在至少一个任务正文文件(索引与快照不算)。 */
|
|
25
|
+
export declare function hasTaskFiles(root: string): Promise<boolean>;
|
|
26
|
+
export interface StorageResolution {
|
|
27
|
+
/** 最终使用的数据根目录。 */
|
|
28
|
+
root: string;
|
|
29
|
+
/** 本次复制的文件数。 */
|
|
30
|
+
copied: number;
|
|
31
|
+
/** 从该旧目录迁移成功。 */
|
|
32
|
+
migratedFrom?: string;
|
|
33
|
+
/** 迁移失败,暂时回退到该旧目录读取。 */
|
|
34
|
+
fallbackFrom?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 决定最终数据根目录,并在必要时做一次性迁移:
|
|
38
|
+
*
|
|
39
|
+
* 1. 新目录已有任务 → 直接使用,绝不碰旧数据;
|
|
40
|
+
* 2. 新目录为空且旧目录有任务 → 复制缺失文件到新目录,旧目录保留;
|
|
41
|
+
* 3. 复制失败或复制后仍无任务 → 回滚本次副本并回退到旧目录读取。
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveStorageRoot(env?: NodeJS.ProcessEnv, home?: string): Promise<StorageResolution>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件数据目录解析与旧版数据迁移。
|
|
3
|
+
*
|
|
4
|
+
* 平台约定:插件私有数据统一放在 `$DSH_HOME/storages/<plugin-id>/`,
|
|
5
|
+
* `DSH_HOME` 由宿主注入,未设置时回落 `~/.dsh`。本插件历史版本把数据直接
|
|
6
|
+
* 写在 `~/.dsh/dsh-litsearch/`,这里负责一次性搬迁到统一存储区:
|
|
7
|
+
*
|
|
8
|
+
* - 只复制、不删除,旧目录原样保留作备份;
|
|
9
|
+
* - 不覆盖新目录里已有的文件,重复启动不会反复迁移;
|
|
10
|
+
* - 复制失败或校验不通过时回退到旧目录读取,保证任务标注不丢。
|
|
11
|
+
*
|
|
12
|
+
* PDF 的可读文件由设置项 `pdfRoot` 决定(默认 `~/Downloads/dsh-litsearch`),
|
|
13
|
+
* 不受本模块影响;只有插件内部的去重缓存 `<root>/pdfs` 随数据目录一起搬迁。
|
|
14
|
+
*/
|
|
15
|
+
import { copyFile, mkdir, readdir, rm, stat } from 'node:fs/promises';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { dirname, join, resolve } from 'node:path';
|
|
18
|
+
export const PLUGIN_ID = 'dsh-plugin-litsearch';
|
|
19
|
+
/** 历史版本使用的目录名(`$DSH_HOME` 下的一级目录)。 */
|
|
20
|
+
export const LEGACY_DIR = 'dsh-litsearch';
|
|
21
|
+
/** 允许搬迁的条目:任务正文与索引、导出文件、PDF 去重缓存、当前任务快照。 */
|
|
22
|
+
const MIGRATABLE_ENTRIES = ['tasks', 'out', 'pdfs', 'current.json'];
|
|
23
|
+
/** 解析宿主数据根目录;支持绝对路径、`~/...` 与空值回落。 */
|
|
24
|
+
export function dshHome(env = process.env, home = homedir()) {
|
|
25
|
+
const raw = env.DSH_HOME?.trim() ?? '';
|
|
26
|
+
if (raw === '')
|
|
27
|
+
return join(home, '.dsh');
|
|
28
|
+
if (raw === '~')
|
|
29
|
+
return home;
|
|
30
|
+
if (raw.startsWith('~/'))
|
|
31
|
+
return join(home, raw.slice(2));
|
|
32
|
+
// 绝对路径原样规范化,相对路径按进程工作目录解析(与宿主 path.resolve 一致)。
|
|
33
|
+
return resolve(raw);
|
|
34
|
+
}
|
|
35
|
+
/** 当前数据根目录:平台统一存储区下的插件域。 */
|
|
36
|
+
export function storageRoot(env = process.env, home = homedir()) {
|
|
37
|
+
return join(dshHome(env, home), 'storages', PLUGIN_ID);
|
|
38
|
+
}
|
|
39
|
+
/** 旧数据根目录候选,按优先级排列,已排除新根目录。 */
|
|
40
|
+
export function legacyRoots(env = process.env, home = homedir()) {
|
|
41
|
+
const current = storageRoot(env, home);
|
|
42
|
+
const candidates = [
|
|
43
|
+
join(dshHome(env, home), LEGACY_DIR), // 过渡形态:$DSH_HOME/dsh-litsearch
|
|
44
|
+
join(home, '.dsh', LEGACY_DIR), // 历史硬编码:~/.dsh/dsh-litsearch
|
|
45
|
+
];
|
|
46
|
+
return [...new Set(candidates)].filter((path) => path !== current);
|
|
47
|
+
}
|
|
48
|
+
/** 目录里是否存在至少一个任务正文文件(索引与快照不算)。 */
|
|
49
|
+
export async function hasTaskFiles(root) {
|
|
50
|
+
const names = await readdir(join(root, 'tasks')).catch(() => []);
|
|
51
|
+
return names.some((name) => name.endsWith('.json') && name !== 'index.json');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 决定最终数据根目录,并在必要时做一次性迁移:
|
|
55
|
+
*
|
|
56
|
+
* 1. 新目录已有任务 → 直接使用,绝不碰旧数据;
|
|
57
|
+
* 2. 新目录为空且旧目录有任务 → 复制缺失文件到新目录,旧目录保留;
|
|
58
|
+
* 3. 复制失败或复制后仍无任务 → 回滚本次副本并回退到旧目录读取。
|
|
59
|
+
*/
|
|
60
|
+
export async function resolveStorageRoot(env = process.env, home = homedir()) {
|
|
61
|
+
const root = storageRoot(env, home);
|
|
62
|
+
if (await hasTaskFiles(root))
|
|
63
|
+
return { root, copied: 0 };
|
|
64
|
+
for (const legacy of legacyRoots(env, home)) {
|
|
65
|
+
if (!(await hasTaskFiles(legacy)))
|
|
66
|
+
continue;
|
|
67
|
+
const copied = [];
|
|
68
|
+
try {
|
|
69
|
+
await copyLegacyStorage(legacy, root, copied);
|
|
70
|
+
if (await hasTaskFiles(root)) {
|
|
71
|
+
console.info(`[dsh-plugin-litsearch] 任务数据已迁移:${legacy} → ${root}(${copied.length} 个文件,原目录保留)`);
|
|
72
|
+
return { root, migratedFrom: legacy, copied: copied.length };
|
|
73
|
+
}
|
|
74
|
+
await rollback(copied);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
await rollback(copied);
|
|
78
|
+
console.warn('[dsh-plugin-litsearch] 任务数据迁移失败:', error);
|
|
79
|
+
}
|
|
80
|
+
console.warn(`[dsh-plugin-litsearch] 暂时沿用旧任务目录:${legacy}`);
|
|
81
|
+
return { root: legacy, fallbackFrom: legacy, copied: 0 };
|
|
82
|
+
}
|
|
83
|
+
return { root, copied: 0 };
|
|
84
|
+
}
|
|
85
|
+
/** 复制旧目录里的已知条目;目标已存在则跳过,复制路径写入 `copied` 供回滚。 */
|
|
86
|
+
async function copyLegacyStorage(from, to, copied) {
|
|
87
|
+
for (const entry of MIGRATABLE_ENTRIES) {
|
|
88
|
+
const source = join(from, entry);
|
|
89
|
+
const target = join(to, entry);
|
|
90
|
+
const info = await stat(source).catch(() => null);
|
|
91
|
+
if (info === null)
|
|
92
|
+
continue;
|
|
93
|
+
if (info.isFile()) {
|
|
94
|
+
await copyFileIfAbsent(source, target, copied);
|
|
95
|
+
}
|
|
96
|
+
else if (info.isDirectory()) {
|
|
97
|
+
await copyDirIfAbsent(source, target, copied);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function copyDirIfAbsent(source, target, copied) {
|
|
102
|
+
const entries = await readdir(source, { withFileTypes: true });
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
// 跳过原子写残留的临时文件与系统杂项。
|
|
105
|
+
if (entry.name.startsWith('.') || entry.name.endsWith('.tmp'))
|
|
106
|
+
continue;
|
|
107
|
+
const from = join(source, entry.name);
|
|
108
|
+
const to = join(target, entry.name);
|
|
109
|
+
if (entry.isDirectory()) {
|
|
110
|
+
await copyDirIfAbsent(from, to, copied);
|
|
111
|
+
}
|
|
112
|
+
else if (entry.isFile()) {
|
|
113
|
+
await copyFileIfAbsent(from, to, copied);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function copyFileIfAbsent(source, target, copied) {
|
|
118
|
+
if ((await stat(target).catch(() => null)) !== null)
|
|
119
|
+
return;
|
|
120
|
+
await mkdir(dirname(target), { recursive: true });
|
|
121
|
+
await copyFile(source, target);
|
|
122
|
+
copied.push(target);
|
|
123
|
+
}
|
|
124
|
+
/** 迁移未完成时清掉本次副本,让下次启动可以重新迁移(源文件始终不动)。 */
|
|
125
|
+
async function rollback(files) {
|
|
126
|
+
for (const file of files)
|
|
127
|
+
await rm(file, { force: true }).catch(() => { });
|
|
128
|
+
}
|
package/lib/host/router.js
CHANGED
|
@@ -15,6 +15,15 @@ const SORT_MODES = ['relevance', 'citations', 'year'];
|
|
|
15
15
|
const LANGUAGES = ['any', 'eng', 'chi'];
|
|
16
16
|
const ORIGINS = ['model', 'panel', 'history', 'api'];
|
|
17
17
|
const SCREENINGS = ['unread', 'read', 'included', 'excluded'];
|
|
18
|
+
const PDF_STATUSES = [
|
|
19
|
+
'unknown',
|
|
20
|
+
'available',
|
|
21
|
+
'downloaded',
|
|
22
|
+
'html-only',
|
|
23
|
+
'paywalled',
|
|
24
|
+
'blocked',
|
|
25
|
+
'failed',
|
|
26
|
+
];
|
|
18
27
|
const EXPORT_FORMATS = ['csv', 'ris', 'bibtex', 'md'];
|
|
19
28
|
const CLEANUP_SCOPES = ['tasks', 'pdf', 'all'];
|
|
20
29
|
const MAX_ANNOTATIONS = 50;
|
|
@@ -521,6 +530,9 @@ function readResultsFilter(params) {
|
|
|
521
530
|
const missingFullText = params.get('missingFullText');
|
|
522
531
|
if (missingFullText === '1' || missingFullText === 'true')
|
|
523
532
|
filter.missingFullText = true;
|
|
533
|
+
const pdfStatus = readEnum(params.get('pdfStatus') ?? undefined, 'pdfStatus', PDF_STATUSES);
|
|
534
|
+
if (pdfStatus !== undefined)
|
|
535
|
+
filter.pdfStatus = pdfStatus;
|
|
524
536
|
const groupId = params.get('groupId')?.trim() ?? '';
|
|
525
537
|
if (groupId !== '')
|
|
526
538
|
filter.groupId = groupId;
|
|
@@ -20,6 +20,8 @@ export interface PaperFilter {
|
|
|
20
20
|
hasPdf?: boolean;
|
|
21
21
|
/** 只保留尚无可用全文(PDF 或 EPMC HTML)的论文。 */
|
|
22
22
|
missingFullText?: boolean;
|
|
23
|
+
/** 只保留有效全文状态恰为该值的论文(与覆盖率的 pdfStatusOf 同一口径)。 */
|
|
24
|
+
pdfStatus?: PdfStatus;
|
|
23
25
|
/** 只保留归入该分组的论文。 */
|
|
24
26
|
groupId?: string;
|
|
25
27
|
/** 期刊/出处(精确匹配,不分大小写)。 */
|
package/lib/host/tasks/filter.js
CHANGED
|
@@ -38,6 +38,9 @@ export function filterPapers(task, filter = {}) {
|
|
|
38
38
|
if (status === 'downloaded' || status === 'available' || status === 'html-only')
|
|
39
39
|
return false;
|
|
40
40
|
}
|
|
41
|
+
if (filter.pdfStatus !== undefined && pdfStatusOf(task, paper) !== filter.pdfStatus) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
41
44
|
if (filter.groupId !== undefined && task.states[paper.paperId]?.groupId !== filter.groupId) {
|
|
42
45
|
return false;
|
|
43
46
|
}
|
|
@@ -88,6 +91,7 @@ export function filterKey(filter) {
|
|
|
88
91
|
filter.language ?? '',
|
|
89
92
|
filter.hasPdf === true ? '1' : '',
|
|
90
93
|
filter.missingFullText === true ? '1' : '',
|
|
94
|
+
filter.pdfStatus ?? '',
|
|
91
95
|
filter.groupId ?? '',
|
|
92
96
|
filter.venue ?? '',
|
|
93
97
|
filter.author ?? '',
|
|
@@ -25,7 +25,6 @@ export interface CreateTaskInput {
|
|
|
25
25
|
types?: string[];
|
|
26
26
|
sort?: SortMode;
|
|
27
27
|
origin: TaskOrigin;
|
|
28
|
-
sessionId?: string;
|
|
29
28
|
fromTaskId?: string;
|
|
30
29
|
runNow?: boolean;
|
|
31
30
|
}
|
|
@@ -62,17 +61,23 @@ export declare class TaskService {
|
|
|
62
61
|
private readonly events;
|
|
63
62
|
private readonly runner;
|
|
64
63
|
/**
|
|
65
|
-
*
|
|
64
|
+
* 全局「当前任务」:最后一次使用或打开的任务。
|
|
66
65
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* 的多次检索默认落在同一个任务里,从而从根上避免重复开 tab。
|
|
70
|
-
* 跨会话误并的风险由 TTL、工具返回里自报任务名、以及模型可显式
|
|
71
|
-
* `newTask` / `taskId` 覆盖来缓解。
|
|
66
|
+
* 不按对话、也不按时间过期——同一主题的后续检索必须在原任务里加一轮,
|
|
67
|
+
* 跨对话、宿主重启都成立。只有用户显式新建任务或点开别的任务时才会改变。
|
|
72
68
|
*/
|
|
73
69
|
private currentTaskId;
|
|
74
70
|
private currentTaskAt;
|
|
75
71
|
constructor();
|
|
72
|
+
/**
|
|
73
|
+
* 先加载任务索引,再恢复「当前任务」快照。
|
|
74
|
+
*
|
|
75
|
+
* 快照缺失或指向已删除任务时,自举为最近更新的任务,避免下一轮检索
|
|
76
|
+
* 又新建一个同名任务。
|
|
77
|
+
*/
|
|
78
|
+
private restore;
|
|
79
|
+
/** 没有可用快照时,把最近更新的任务设为当前任务并落盘。 */
|
|
80
|
+
private bootstrapCurrentTask;
|
|
76
81
|
get globalRev(): number;
|
|
77
82
|
get root(): string;
|
|
78
83
|
isRunning(id: string): boolean;
|
|
@@ -83,10 +88,18 @@ export declare class TaskService {
|
|
|
83
88
|
getDetail(id: string): Promise<TaskDetail>;
|
|
84
89
|
/** 任务内轮次历史(时间升序、深拷贝)。 */
|
|
85
90
|
getRounds(id: string): Promise<SearchRound[]>;
|
|
86
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* 当前任务:最近一次使用或打开的任务。
|
|
93
|
+
*
|
|
94
|
+
* 没有时间窗口、也不区分对话;只要任务还在就一直有效。任务被全部删除后
|
|
95
|
+
* 返回 undefined,由调用方决定是否新建。
|
|
96
|
+
*/
|
|
87
97
|
currentTask(): Promise<SearchTask | undefined>;
|
|
88
|
-
/**
|
|
98
|
+
/** 记住最近使用的任务(下一次模型检索默认落在这里)。 */
|
|
89
99
|
setCurrentTask(id: string): void;
|
|
100
|
+
/** 请求客户端打开/聚焦某任务 tab(面板被关掉时重新打开)。 */
|
|
101
|
+
focusTask(id: string, title: string, sessionId?: string): void;
|
|
102
|
+
private persistCurrent;
|
|
90
103
|
/** 用户从任务列表点开某任务 = 进入该任务,之后的模型检索默认落在这里。 */
|
|
91
104
|
activate(id: string): Promise<SearchTask>;
|
|
92
105
|
/** 等待该任务当前这一轮结束;返回是否确实已空闲。 */
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* 模型工具与面板 HTTP 路由都只调用这里,保证两边操作同一份状态。
|
|
6
6
|
*/
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
-
import {
|
|
10
|
+
import { MAX_LIMIT, PLUGIN_VERSION, 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';
|
|
@@ -37,25 +37,61 @@ export class TaskService {
|
|
|
37
37
|
events = new TaskEvents();
|
|
38
38
|
runner = new TaskRunner();
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
40
|
+
* 全局「当前任务」:最后一次使用或打开的任务。
|
|
41
41
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* 的多次检索默认落在同一个任务里,从而从根上避免重复开 tab。
|
|
45
|
-
* 跨会话误并的风险由 TTL、工具返回里自报任务名、以及模型可显式
|
|
46
|
-
* `newTask` / `taskId` 覆盖来缓解。
|
|
42
|
+
* 不按对话、也不按时间过期——同一主题的后续检索必须在原任务里加一轮,
|
|
43
|
+
* 跨对话、宿主重启都成立。只有用户显式新建任务或点开别的任务时才会改变。
|
|
47
44
|
*/
|
|
48
45
|
currentTaskId;
|
|
49
46
|
currentTaskAt = 0;
|
|
50
47
|
constructor() {
|
|
51
|
-
this.ready = this.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
48
|
+
this.ready = this.restore();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 先加载任务索引,再恢复「当前任务」快照。
|
|
52
|
+
*
|
|
53
|
+
* 快照缺失或指向已删除任务时,自举为最近更新的任务,避免下一轮检索
|
|
54
|
+
* 又新建一个同名任务。
|
|
55
|
+
*/
|
|
56
|
+
async restore() {
|
|
57
|
+
try {
|
|
58
|
+
await this.store.load({
|
|
59
|
+
retentionDays: getSettings().taskRetentionDays,
|
|
60
|
+
retentionCount: getSettings().taskRetentionCount,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
57
64
|
console.warn('[dsh-plugin-litsearch] 任务目录加载失败:', error);
|
|
58
|
-
}
|
|
65
|
+
}
|
|
66
|
+
let restored = false;
|
|
67
|
+
try {
|
|
68
|
+
const snapshot = await this.store.loadCurrent();
|
|
69
|
+
if (snapshot?.current !== undefined &&
|
|
70
|
+
this.store.summary(snapshot.current.taskId) !== undefined) {
|
|
71
|
+
this.currentTaskId = snapshot.current.taskId;
|
|
72
|
+
this.currentTaskAt = snapshot.current.at;
|
|
73
|
+
restored = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
console.warn('[dsh-plugin-litsearch] 当前任务快照加载失败:', error);
|
|
78
|
+
}
|
|
79
|
+
if (!restored)
|
|
80
|
+
this.bootstrapCurrentTask();
|
|
81
|
+
}
|
|
82
|
+
/** 没有可用快照时,把最近更新的任务设为当前任务并落盘。 */
|
|
83
|
+
bootstrapCurrentTask() {
|
|
84
|
+
const latest = this.store.list()[0];
|
|
85
|
+
if (latest === undefined) {
|
|
86
|
+
// 任务已全部删除:清掉快照里的旧 id,避免 current.json 指向不存在的任务。
|
|
87
|
+
this.currentTaskId = undefined;
|
|
88
|
+
this.currentTaskAt = 0;
|
|
89
|
+
this.persistCurrent();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.currentTaskId = latest.id;
|
|
93
|
+
this.currentTaskAt = Date.parse(latest.updatedAt) || Date.now();
|
|
94
|
+
this.persistCurrent();
|
|
59
95
|
}
|
|
60
96
|
get globalRev() {
|
|
61
97
|
return this.events.currentRev;
|
|
@@ -132,25 +168,42 @@ export class TaskService {
|
|
|
132
168
|
async getRounds(id) {
|
|
133
169
|
return roundsOf(await this.get(id)).map(cloneRound);
|
|
134
170
|
}
|
|
135
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* 当前任务:最近一次使用或打开的任务。
|
|
173
|
+
*
|
|
174
|
+
* 没有时间窗口、也不区分对话;只要任务还在就一直有效。任务被全部删除后
|
|
175
|
+
* 返回 undefined,由调用方决定是否新建。
|
|
176
|
+
*/
|
|
136
177
|
async currentTask() {
|
|
178
|
+
await this.ready;
|
|
137
179
|
if (this.currentTaskId === undefined)
|
|
138
180
|
return undefined;
|
|
139
|
-
if (Date.now() - this.currentTaskAt > CURRENT_TASK_TTL_MS) {
|
|
140
|
-
this.currentTaskId = undefined;
|
|
141
|
-
return undefined;
|
|
142
|
-
}
|
|
143
181
|
const task = await this.tryGet(this.currentTaskId);
|
|
144
|
-
if (task
|
|
145
|
-
|
|
182
|
+
if (task !== undefined)
|
|
183
|
+
return task;
|
|
184
|
+
this.currentTaskId = undefined;
|
|
185
|
+
this.bootstrapCurrentTask();
|
|
186
|
+
if (this.currentTaskId === undefined)
|
|
146
187
|
return undefined;
|
|
147
|
-
|
|
148
|
-
return task;
|
|
188
|
+
return this.tryGet(this.currentTaskId);
|
|
149
189
|
}
|
|
150
|
-
/**
|
|
190
|
+
/** 记住最近使用的任务(下一次模型检索默认落在这里)。 */
|
|
151
191
|
setCurrentTask(id) {
|
|
152
192
|
this.currentTaskId = id;
|
|
153
193
|
this.currentTaskAt = Date.now();
|
|
194
|
+
this.persistCurrent();
|
|
195
|
+
}
|
|
196
|
+
/** 请求客户端打开/聚焦某任务 tab(面板被关掉时重新打开)。 */
|
|
197
|
+
focusTask(id, title, sessionId) {
|
|
198
|
+
this.events.requestFocus(id, title, sessionId);
|
|
199
|
+
}
|
|
200
|
+
persistCurrent() {
|
|
201
|
+
void this.store.saveCurrent({
|
|
202
|
+
version: 1,
|
|
203
|
+
...(this.currentTaskId === undefined
|
|
204
|
+
? {}
|
|
205
|
+
: { current: { taskId: this.currentTaskId, at: this.currentTaskAt } }),
|
|
206
|
+
});
|
|
154
207
|
}
|
|
155
208
|
/** 用户从任务列表点开某任务 = 进入该任务,之后的模型检索默认落在这里。 */
|
|
156
209
|
async activate(id) {
|
|
@@ -219,11 +272,12 @@ export class TaskService {
|
|
|
219
272
|
result: { papers: [], sources: [] },
|
|
220
273
|
rounds: [],
|
|
221
274
|
states: inherited === undefined ? {} : inheritStates(inherited),
|
|
222
|
-
...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }),
|
|
223
275
|
...(input.fromTaskId === undefined ? {} : { fromTaskId: input.fromTaskId }),
|
|
224
276
|
};
|
|
225
277
|
this.store.put(task);
|
|
226
278
|
await this.store.save(task);
|
|
279
|
+
// 新建的任务就是「当前任务」:后续检索默认在它里面加轮次。
|
|
280
|
+
this.setCurrentTask(task.id);
|
|
227
281
|
this.events.publish(toSummary(task));
|
|
228
282
|
if (input.runNow !== false) {
|
|
229
283
|
void this.begin(task, 'new').catch((error) => {
|
|
@@ -271,11 +325,17 @@ export class TaskService {
|
|
|
271
325
|
}
|
|
272
326
|
async remove(id) {
|
|
273
327
|
const task = await this.get(id);
|
|
274
|
-
|
|
328
|
+
// 先等这一轮收尾(finally 里还会落一次盘),再删文件,避免任务被写回。
|
|
329
|
+
if (this.runner.cancel(task.id))
|
|
330
|
+
await this.runner.wait(task.id);
|
|
275
331
|
await this.store.remove(task.id);
|
|
332
|
+
// 模型工具导出到 <storage>/out/<taskId>/ 的文件随任务一起清掉。
|
|
333
|
+
await rm(join(this.store.root, 'out', task.id), { recursive: true, force: true });
|
|
276
334
|
this.events.remove(task.id);
|
|
277
|
-
if (this.currentTaskId
|
|
278
|
-
|
|
335
|
+
if (this.currentTaskId !== task.id)
|
|
336
|
+
return;
|
|
337
|
+
this.currentTaskId = undefined;
|
|
338
|
+
this.bootstrapCurrentTask();
|
|
279
339
|
}
|
|
280
340
|
/**
|
|
281
341
|
* 启动一次执行(任务内新一轮);返回的 Promise 在运行结束后 resolve 为最新任务。
|
|
@@ -597,6 +657,9 @@ export class TaskService {
|
|
|
597
657
|
return saved.filePath;
|
|
598
658
|
}
|
|
599
659
|
async setPdfState(task, paperId, patch) {
|
|
660
|
+
// 下载进行中任务被删除:不再写回,也不向客户端广播已删除的任务。
|
|
661
|
+
if (this.store.summary(task.id) === undefined)
|
|
662
|
+
return;
|
|
600
663
|
const current = task.states[paperId] ?? emptyState();
|
|
601
664
|
const next = { ...current, pdfStatus: patch.pdfStatus };
|
|
602
665
|
if (patch.pdfError === undefined)
|
|
@@ -693,7 +756,12 @@ export class TaskService {
|
|
|
693
756
|
throw new SearchError('bad_request', '没有启用的检索式');
|
|
694
757
|
const done = this.runner.start(task, {
|
|
695
758
|
persist: (current) => this.store.save(current),
|
|
696
|
-
publish: (current) =>
|
|
759
|
+
publish: (current) => {
|
|
760
|
+
// 删除后这一轮收尾仍会 publish,跳过已删除任务,避免客户端又去打开它。
|
|
761
|
+
if (this.store.summary(current.id) !== undefined) {
|
|
762
|
+
this.events.publish(toSummary(current));
|
|
763
|
+
}
|
|
764
|
+
},
|
|
697
765
|
}, origin);
|
|
698
766
|
return done.then(() => {
|
|
699
767
|
if (getSettings().autoDownloadPdf) {
|
|
@@ -710,6 +778,9 @@ export class TaskService {
|
|
|
710
778
|
.filter((paper) => paper.pdfUrl !== '' || paper.ids.pmcid !== '' || paper.ids.arxivId !== '')
|
|
711
779
|
.slice(0, 10);
|
|
712
780
|
for (const paper of targets) {
|
|
781
|
+
// 任务中途被删除时停止后续下载,避免继续往用户下载目录写文件。
|
|
782
|
+
if (this.store.summary(task.id) === undefined)
|
|
783
|
+
return;
|
|
713
784
|
if (task.states[paper.paperId]?.pdfStatus === 'downloaded')
|
|
714
785
|
continue;
|
|
715
786
|
const resolution = await resolvePdf(paper).catch(() => null);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 任务持久化:`<root>/tasks/<id>.json` + `tasks/index.json`。
|
|
3
3
|
*
|
|
4
|
+
* - 数据根目录为 `$DSH_HOME/storages/dsh-plugin-litsearch`(见 `host/paths.ts`),
|
|
5
|
+
* 加载时若新目录为空而旧目录有任务,会一次性复制过来;显式传入 root 时不迁移;
|
|
4
6
|
* - 写盘全部串行执行,先写临时文件再 rename,避免半截 JSON;
|
|
5
7
|
* - 内存里只保留摘要,任务正文按需从磁盘读;
|
|
6
8
|
* - 落盘时摘要截到 {@link MAX_ABSTRACT_CHARS} 字符,任务数按保留策略裁剪。
|
|
@@ -10,14 +12,27 @@ export interface RetentionOptions {
|
|
|
10
12
|
retentionDays: number;
|
|
11
13
|
retentionCount: number;
|
|
12
14
|
}
|
|
15
|
+
/** 「当前任务」快照:最近一次使用或打开的任务。 */
|
|
16
|
+
export interface CurrentTaskSnapshot {
|
|
17
|
+
version: number;
|
|
18
|
+
current?: {
|
|
19
|
+
taskId: string;
|
|
20
|
+
at: number;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
13
23
|
export declare class TaskStore {
|
|
14
|
-
|
|
15
|
-
private
|
|
16
|
-
private
|
|
24
|
+
root: string;
|
|
25
|
+
private tasksDir;
|
|
26
|
+
private indexFile;
|
|
27
|
+
private currentFile;
|
|
17
28
|
private readonly summaries;
|
|
18
29
|
private readonly cache;
|
|
19
30
|
private chain;
|
|
20
31
|
private retention;
|
|
32
|
+
/** 只有使用默认(平台统一)目录时才做旧数据迁移;显式 root 多用于测试。 */
|
|
33
|
+
private readonly migrateOnLoad;
|
|
34
|
+
/** 迁移过来的任务文件必须重建索引,避免沿用旧目录里那份空 index.json。 */
|
|
35
|
+
private forceRebuildIndex;
|
|
21
36
|
constructor(root?: string);
|
|
22
37
|
load(retention: RetentionOptions): Promise<void>;
|
|
23
38
|
list(): TaskSummary[];
|
|
@@ -29,11 +44,16 @@ export declare class TaskStore {
|
|
|
29
44
|
save(task: SearchTask): Promise<void>;
|
|
30
45
|
remove(id: string): Promise<void>;
|
|
31
46
|
flush(): Promise<void>;
|
|
47
|
+
/** 读取「当前任务」快照;文件缺失或损坏时返回 null(由调用方自举)。 */
|
|
48
|
+
loadCurrent(): Promise<CurrentTaskSnapshot | null>;
|
|
49
|
+
saveCurrent(snapshot: CurrentTaskSnapshot): Promise<void>;
|
|
32
50
|
/** 手动清理:删除所有未置顶任务,返回条数与释放的字节数。 */
|
|
33
51
|
cleanupUnpinned(): Promise<{
|
|
34
52
|
removed: number;
|
|
35
53
|
freedBytes: number;
|
|
36
54
|
}>;
|
|
55
|
+
/** 迁移回退时切回旧目录读取,所有派生路径(含设置页展示)同步更新。 */
|
|
56
|
+
private rebind;
|
|
37
57
|
private taskFile;
|
|
38
58
|
private readIndex;
|
|
39
59
|
/** 索引缺失/损坏时按任务文件重建(只读头部,不长期占内存)。 */
|