dsh-plugin-litsearch 0.0.0-stage → 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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -2
  3. package/client/client.js +9036 -0
  4. package/cordis.patch.yml +10 -0
  5. package/lib/host/events.d.ts +19 -0
  6. package/lib/host/events.js +58 -0
  7. package/lib/host/export/bibtex.d.ts +3 -0
  8. package/lib/host/export/bibtex.js +42 -0
  9. package/lib/host/export/common.d.ts +18 -0
  10. package/lib/host/export/common.js +25 -0
  11. package/lib/host/export/csv.d.ts +8 -0
  12. package/lib/host/export/csv.js +68 -0
  13. package/lib/host/export/index.d.ts +5 -0
  14. package/lib/host/export/index.js +25 -0
  15. package/lib/host/export/markdown.d.ts +6 -0
  16. package/lib/host/export/markdown.js +120 -0
  17. package/lib/host/export/ris.d.ts +3 -0
  18. package/lib/host/export/ris.js +35 -0
  19. package/lib/host/paths.d.ts +43 -0
  20. package/lib/host/paths.js +128 -0
  21. package/lib/host/pdf/store.d.ts +57 -0
  22. package/lib/host/pdf/store.js +291 -0
  23. package/lib/host/router.d.ts +20 -0
  24. package/lib/host/router.js +852 -0
  25. package/lib/host/tasks/compare.d.ts +8 -0
  26. package/lib/host/tasks/compare.js +49 -0
  27. package/lib/host/tasks/filter.d.ts +42 -0
  28. package/lib/host/tasks/filter.js +100 -0
  29. package/lib/host/tasks/rounds.d.ts +28 -0
  30. package/lib/host/tasks/rounds.js +74 -0
  31. package/lib/host/tasks/runner.d.ts +29 -0
  32. package/lib/host/tasks/runner.js +208 -0
  33. package/lib/host/tasks/service.d.ts +171 -0
  34. package/lib/host/tasks/service.js +976 -0
  35. package/lib/host/tasks/states.d.ts +28 -0
  36. package/lib/host/tasks/states.js +223 -0
  37. package/lib/host/tasks/stats.d.ts +13 -0
  38. package/lib/host/tasks/stats.js +233 -0
  39. package/lib/host/tasks/store.d.ts +64 -0
  40. package/lib/host/tasks/store.js +280 -0
  41. package/lib/host/tasks/summary.d.ts +9 -0
  42. package/lib/host/tasks/summary.js +54 -0
  43. package/lib/host/tools.d.ts +47 -0
  44. package/lib/host/tools.js +1138 -0
  45. package/lib/host/zotero/connector.d.ts +46 -0
  46. package/lib/host/zotero/connector.js +263 -0
  47. package/lib/index.d.ts +107 -0
  48. package/lib/index.js +153 -0
  49. package/lib/search/aggregate.d.ts +17 -0
  50. package/lib/search/aggregate.js +60 -0
  51. package/lib/search/config.d.ts +96 -0
  52. package/lib/search/config.js +96 -0
  53. package/lib/search/errors.d.ts +22 -0
  54. package/lib/search/errors.js +24 -0
  55. package/lib/search/health.d.ts +13 -0
  56. package/lib/search/health.js +53 -0
  57. package/lib/search/http.d.ts +13 -0
  58. package/lib/search/http.js +81 -0
  59. package/lib/search/identity.d.ts +27 -0
  60. package/lib/search/identity.js +154 -0
  61. package/lib/search/normalize.d.ts +17 -0
  62. package/lib/search/normalize.js +47 -0
  63. package/lib/search/oa.d.ts +21 -0
  64. package/lib/search/oa.js +149 -0
  65. package/lib/search/providers/arxiv.d.ts +9 -0
  66. package/lib/search/providers/arxiv.js +99 -0
  67. package/lib/search/providers/crossref.d.ts +8 -0
  68. package/lib/search/providers/crossref.js +107 -0
  69. package/lib/search/providers/europepmc.d.ts +8 -0
  70. package/lib/search/providers/europepmc.js +105 -0
  71. package/lib/search/providers/index.d.ts +13 -0
  72. package/lib/search/providers/index.js +34 -0
  73. package/lib/search/providers/openalex.d.ts +8 -0
  74. package/lib/search/providers/openalex.js +109 -0
  75. package/lib/search/providers/pubmed.d.ts +10 -0
  76. package/lib/search/providers/pubmed.js +143 -0
  77. package/lib/search/providers/pubtator.d.ts +9 -0
  78. package/lib/search/providers/pubtator.js +79 -0
  79. package/lib/search/providers/semanticscholar.d.ts +9 -0
  80. package/lib/search/providers/semanticscholar.js +97 -0
  81. package/lib/search/pubtator-entities.d.ts +9 -0
  82. package/lib/search/pubtator-entities.js +19 -0
  83. package/lib/search/pubtator-parse.d.ts +3 -0
  84. package/lib/search/pubtator-parse.js +42 -0
  85. package/lib/search/query-adapter.d.ts +15 -0
  86. package/lib/search/query-adapter.js +69 -0
  87. package/lib/search/rate-limit.d.ts +31 -0
  88. package/lib/search/rate-limit.js +219 -0
  89. package/lib/search/types.d.ts +577 -0
  90. package/lib/search/types.js +15 -0
  91. package/lib/search/xml.d.ts +20 -0
  92. package/lib/search/xml.js +63 -0
  93. package/package.json +75 -4
@@ -0,0 +1,1138 @@
1
+ /**
2
+ * 模型侧工具:直接注册进 dsh 工具表(不经过外部 MCP)。
3
+ *
4
+ * - `literature_search`:一个研究主题 = 一个任务 = 一个 tab。默认复用「当前
5
+ * 任务」并在其中开新一轮(当前任务是持久化的最近使用任务,跨对话、宿主重启
6
+ * 都有效);只有用户明确要求新任务时才新建。等待最多 25 秒后把任务头部与前
7
+ * 若干条结果交给模型,并明确要求本轮到此为止、只给完善建议、不发起下一轮。
8
+ * - `literature_sources`:列出已接入的数据源与能力,便于模型决定后续策略。
9
+ * - `literature_task`:读取/修改已保存任务(含轮次历史),写入筛读、分组与备注,
10
+ * 做新老对比与统计。
11
+ * - `literature_export`:把任务(按筛选)导出为 CSV / RIS / BibTeX / Markdown 并落盘。
12
+ *
13
+ * 参数在边界做一次校验(模型输出不可信),服务层仍会再兜底。
14
+ */
15
+ import { MAX_LIMIT } from '../search/config.js';
16
+ import { SearchError } from '../search/errors.js';
17
+ import { asRecord } from '../search/normalize.js';
18
+ import { PROVIDER_INFOS } from '../search/providers/index.js';
19
+ import { SOURCE_IDS } from '../search/types.js';
20
+ /** 模型侧最多等多久;超时后任务继续跑,只返回当前进度与已有结果。 */
21
+ const MODEL_WAIT_MS = 25_000;
22
+ /** 交给模型的条数(UI 走分页端点,不受此限制)。 */
23
+ const TOOL_PAPER_LIMIT = 10;
24
+ /** 复用任务时,最多等多久让上一轮跑完;超时直接返回提示,绝不新建任务。 */
25
+ const WAIT_PREVIOUS_MS = 12_000;
26
+ const SORT_MODES = ['relevance', 'citations', 'year'];
27
+ const LANGUAGES = ['any', 'eng', 'chi'];
28
+ const SCREENINGS = ['unread', 'read', 'included', 'excluded'];
29
+ const EXPORT_FORMATS = ['csv', 'ris', 'bibtex', 'md'];
30
+ const TASK_ACTIONS = [
31
+ 'current',
32
+ 'list',
33
+ 'get',
34
+ 'update',
35
+ 'run',
36
+ 'rounds',
37
+ 'annotate',
38
+ 'compare',
39
+ 'stats',
40
+ ];
41
+ /**
42
+ * 每轮检索结束后的强约束尾句。
43
+ *
44
+ * 这是「模型可以建议完善检索、但不许自己立刻进入下一轮」的工具契约:
45
+ * 既写在工具 description 里,也附在每次返回末尾,覆盖 `literature_task run`
46
+ * 这条可能的旁路。
47
+ */
48
+ const NO_AUTO_NEXT_ROUND = '本轮到此结束:请基于上面的结果给用户一份「如何完善检索」的建议(例如收窄或扩展检索式、调整年份/类型/数据源),但【禁止】自行发起下一次检索,不要再次调用 literature_search,也不要用 literature_task run 绕过。只有当用户查看结果后明确要求调整(在对话框里说明,或在右侧栏改条件)时,才可在同一任务内发起下一轮,并保持 taskId 不变或省略以复用当前任务。';
49
+ const TEXT_OUTPUT = {
50
+ schema: {
51
+ type: 'object',
52
+ properties: { text: { type: 'string' } },
53
+ required: ['text'],
54
+ additionalProperties: false,
55
+ },
56
+ render: (_args, value) => [
57
+ { type: 'text', text: value.text },
58
+ ],
59
+ };
60
+ const SEARCH_PARAMETERS = {
61
+ type: 'object',
62
+ properties: {
63
+ title: {
64
+ type: 'string',
65
+ description: '任务名称。仅新建任务时有效,请用能概括研究主题的短句自己命名(如「口臭治疗方案文献」)。复用已有任务时忽略。',
66
+ },
67
+ taskId: {
68
+ type: 'string',
69
+ description: '要复用的已有任务 id。同一主题的后续检索必须复用同一个任务;通常省略即可复用「当前任务」(最近一次使用或打开的任务,跨对话持续有效),也可用 literature_task list 查到 id 后显式指定。指定后不会新建任务、不会新开 tab。',
70
+ },
71
+ newTask: {
72
+ type: 'boolean',
73
+ description: '仅当用户明确要求「新建一个查询任务」,或研究主题与当前任务明显不同时才设为 true(此时请同时给 title)。同一主题改检索式(如加宽年份、换同义词)时绝对不要设 true。',
74
+ },
75
+ query: {
76
+ type: 'string',
77
+ description: '单条检索关键词,支持英文或中文;英文主题词召回更好。与 queries 至少给一个。',
78
+ },
79
+ queries: {
80
+ type: 'array',
81
+ maxItems: 20,
82
+ description: '多条具名检索式:一次跑完、跨式去重,适合把主题拆成同义词/子问题。提供时忽略 query。',
83
+ items: {
84
+ type: 'object',
85
+ properties: {
86
+ text: { type: 'string', description: '检索式文本。' },
87
+ name: { type: 'string', description: '检索式名称,用于进度与贡献统计。' },
88
+ enabled: { type: 'boolean', description: '是否启用,默认 true。' },
89
+ perSource: {
90
+ type: 'object',
91
+ additionalProperties: { type: 'string' },
92
+ description: '按数据源覆写检索式语法(key 为数据源 id)。',
93
+ },
94
+ },
95
+ required: ['text'],
96
+ additionalProperties: false,
97
+ },
98
+ },
99
+ types: {
100
+ type: 'array',
101
+ maxItems: 20,
102
+ items: { type: 'string' },
103
+ description: '文献类型过滤(如 journal-article、review、preprint),精确匹配。',
104
+ },
105
+ sources: {
106
+ type: 'array',
107
+ items: { type: 'string', enum: [...SOURCE_IDS] },
108
+ description: `要查询的数据源:${SOURCE_IDS.join(', ')};省略表示全部。`,
109
+ },
110
+ limit: {
111
+ type: 'integer',
112
+ minimum: 1,
113
+ maximum: MAX_LIMIT,
114
+ description: `每个来源取多少条,默认由插件配置决定,最大 ${MAX_LIMIT}。`,
115
+ },
116
+ yearFrom: { type: 'integer', minimum: 1, description: '起始出版年份(含)。' },
117
+ yearTo: { type: 'integer', minimum: 1, description: '结束出版年份(含)。' },
118
+ openAccessOnly: {
119
+ type: 'boolean',
120
+ description: '只返回开放获取文献。',
121
+ },
122
+ language: {
123
+ type: 'string',
124
+ enum: [...LANGUAGES],
125
+ description: '语言筛选:any 不限、eng 英文、chi 中文。',
126
+ },
127
+ sort: {
128
+ type: 'string',
129
+ enum: [...SORT_MODES],
130
+ description: '排序:relevance 相关度、citations 被引次数、year 出版年份。',
131
+ },
132
+ },
133
+ additionalProperties: false,
134
+ };
135
+ const TASK_PARAMETERS = {
136
+ type: 'object',
137
+ properties: {
138
+ action: {
139
+ type: 'string',
140
+ enum: [...TASK_ACTIONS],
141
+ description: 'current 读「当前任务」(推荐在执行检索/任务操作前先调用,确认这个对话正在用哪个任务,无需 id);list 列任务;get 读条件与结果;update 改条件/标题/笔记/分组;run 重跑(仅在用户明确要求进入下一轮时使用);rounds 读该任务的检索轮次历史;annotate 写筛读/备注;compare 与旧任务对比;stats 读语料统计。',
142
+ },
143
+ id: { type: 'string', description: '任务 id;除 current / list 外必填。' },
144
+ withTaskId: { type: 'string', description: 'compare 时的基准任务 id。' },
145
+ limit: {
146
+ type: 'integer',
147
+ minimum: 1,
148
+ maximum: 200,
149
+ description: 'get / list 返回条数,默认 30。',
150
+ },
151
+ offset: { type: 'integer', minimum: 0, description: '结果分页偏移。' },
152
+ screening: { type: 'string', enum: [...SCREENINGS], description: '按筛读状态过滤。' },
153
+ groupId: { type: 'string', description: '按任务内分组过滤。' },
154
+ queryId: { type: 'string', description: '按检索式 id 过滤命中文献。' },
155
+ q: { type: 'string', description: '标题/摘要关键词。' },
156
+ scope: {
157
+ type: 'string',
158
+ enum: ['task', 'filtered'],
159
+ description: 'stats 的口径:task 全任务、filtered 当前筛选。',
160
+ },
161
+ patch: {
162
+ type: 'object',
163
+ description: 'update 时的修改内容。',
164
+ properties: {
165
+ title: { type: 'string' },
166
+ notes: { type: 'string', description: '任务级研究笔记,覆盖写入。' },
167
+ pinned: { type: 'boolean' },
168
+ groups: {
169
+ type: 'array',
170
+ maxItems: 50,
171
+ items: {
172
+ type: 'object',
173
+ properties: {
174
+ id: { type: 'string' },
175
+ name: { type: 'string' },
176
+ description: { type: 'string' },
177
+ },
178
+ required: ['id', 'name'],
179
+ additionalProperties: false,
180
+ },
181
+ },
182
+ conditions: {
183
+ type: 'object',
184
+ properties: {
185
+ queries: SEARCH_PARAMETERS.properties.queries,
186
+ sources: SEARCH_PARAMETERS.properties.sources,
187
+ pageSize: SEARCH_PARAMETERS.properties.limit,
188
+ yearFrom: SEARCH_PARAMETERS.properties.yearFrom,
189
+ yearTo: SEARCH_PARAMETERS.properties.yearTo,
190
+ openAccessOnly: SEARCH_PARAMETERS.properties.openAccessOnly,
191
+ language: SEARCH_PARAMETERS.properties.language,
192
+ types: SEARCH_PARAMETERS.properties.types,
193
+ sort: SEARCH_PARAMETERS.properties.sort,
194
+ },
195
+ additionalProperties: false,
196
+ },
197
+ },
198
+ additionalProperties: false,
199
+ },
200
+ papers: {
201
+ type: 'array',
202
+ maxItems: 50,
203
+ description: 'annotate 时的逐篇写入(筛读 / 分组 / 必读顺序 / 备注 / 标签)。',
204
+ items: {
205
+ type: 'object',
206
+ properties: {
207
+ paperId: { type: 'string' },
208
+ screening: { type: 'string', enum: [...SCREENINGS] },
209
+ groupId: { type: ['string', 'null'] },
210
+ shortlistOrder: { type: ['integer', 'null'] },
211
+ note: { type: 'string' },
212
+ tags: { type: 'array', items: { type: 'string' } },
213
+ },
214
+ required: ['paperId'],
215
+ additionalProperties: false,
216
+ },
217
+ },
218
+ },
219
+ required: ['action'],
220
+ additionalProperties: false,
221
+ };
222
+ const EXPORT_PARAMETERS = {
223
+ type: 'object',
224
+ properties: {
225
+ taskId: { type: 'string', description: '要导出的任务 id。' },
226
+ format: {
227
+ type: 'string',
228
+ enum: [...EXPORT_FORMATS],
229
+ description: 'csv 全量台账;ris / bibtex 供文献管理器;md 为分层检索报告。',
230
+ },
231
+ paperIds: {
232
+ type: 'array',
233
+ maxItems: 1500,
234
+ items: { type: 'string' },
235
+ description: '只导出指定论文;省略则按筛选导出。',
236
+ },
237
+ screening: { type: 'string', enum: [...SCREENINGS] },
238
+ groupId: { type: 'string' },
239
+ queryId: { type: 'string' },
240
+ },
241
+ required: ['taskId', 'format'],
242
+ additionalProperties: false,
243
+ };
244
+ /** 构造本插件注册的全部工具(每次注册都新造定义,便于整体替换)。 */
245
+ export function literatureTools(service) {
246
+ return [
247
+ {
248
+ name: 'literature_search',
249
+ 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
+ '【轮次约束】本轮检索结束后,只给用户一份「如何完善检索」的建议,禁止自行发起下一轮、禁止重复调用本工具;必须等用户查看结果后,在右侧栏调整条件、或在对话里明确要求调整,才可在同一任务里再搜一次。' +
252
+ '超过 25 秒仍在进行时会先返回当前进度,任务会继续在右侧栏完成。适合找论文、查某主题的代表作、补充参考文献。',
253
+ parameters: SEARCH_PARAMETERS,
254
+ output: TEXT_OUTPUT,
255
+ async execute(args, exec) {
256
+ const input = readSearchArgs(args);
257
+ const { title, taskId, newTask, ...conditions } = input;
258
+ const createInput = { ...conditions, origin: 'model', runNow: false };
259
+ let task;
260
+ let reused = false;
261
+ if (taskId !== undefined) {
262
+ task = await service.get(taskId);
263
+ reused = true;
264
+ }
265
+ else if (newTask !== true) {
266
+ task = await service.currentTask();
267
+ reused = task !== undefined;
268
+ }
269
+ if (task === undefined) {
270
+ task = await service.create({
271
+ ...createInput,
272
+ ...(title === undefined ? {} : { title }),
273
+ });
274
+ }
275
+ else {
276
+ // 上一轮还在跑时先等,绝不「换一个任务重搜」——那正是多开 tab 的根因。
277
+ const idle = await service.waitUntilIdle(task.id, WAIT_PREVIOUS_MS, exec.signal);
278
+ if (!idle) {
279
+ return {
280
+ text: [
281
+ `任务「${task.title}」(id: ${task.id})的上一轮仍在检索中,本次调整未执行。`,
282
+ '请勿新建任务、请勿重复检索;可用 literature_task get 查看进度,等用户查看结果并明确要求后,再在【同一个任务】里发起下一轮。',
283
+ NO_AUTO_NEXT_ROUND,
284
+ ].join('\n'),
285
+ };
286
+ }
287
+ }
288
+ // 让客户端把该任务 tab 打开/切到前台(右侧面板关着时重新打开)。
289
+ service.focusTask(task.id, task.title, exec.agent?.id);
290
+ // 复用时不带 title:命名只属于新建任务,避免误改已有任务名。
291
+ const running = reused
292
+ ? service.rerun(task.id, createInput, 'model')
293
+ : service.run(task.id, 'new', 'model');
294
+ let outcome;
295
+ try {
296
+ outcome = await runWithin(service, task.id, exec.signal, running);
297
+ }
298
+ catch (error) {
299
+ if (error instanceof SearchError &&
300
+ (error.code === 'conflict' || error.code === 'not_found')) {
301
+ return {
302
+ text: [
303
+ `任务「${task.title}」(id: ${task.id})无法开新一轮:${error.message}`,
304
+ '请先用 literature_task list / get 确认任务状态;不要新建任务来绕过,也不要重复检索。',
305
+ NO_AUTO_NEXT_ROUND,
306
+ ].join('\n'),
307
+ };
308
+ }
309
+ throw error;
310
+ }
311
+ const detail = await service.getDetail(task.id);
312
+ const page = await service.results(task.id, { limit: TOOL_PAPER_LIMIT });
313
+ return { text: formatTaskResult(detail, page, outcome, reused) };
314
+ },
315
+ },
316
+ {
317
+ name: 'literature_sources',
318
+ description: '列出本插件已接入的免费文献数据源:能力(摘要、被引次数、开放获取、PDF 链接)、限流额度、检索式语法与是否需要可选 API Key。',
319
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
320
+ output: TEXT_OUTPUT,
321
+ async execute() {
322
+ return { text: formatSources() };
323
+ },
324
+ },
325
+ {
326
+ name: 'literature_task',
327
+ description: '读取与推进已保存的检索任务。current 读「当前任务」(推荐在执行任何检索/任务操作前先调用,确认这个对话正在用哪个任务;无需 id);list 列出任务;get 读条件、分组、笔记与结果(可按筛读/分组/检索式/关键词过滤);update 改标题、条件、任务笔记与分组;rounds 读该任务的检索轮次历史(每轮时间、条件、发起方、命中数与状态);annotate 批量写入筛读状态、分组、必读顺序、备注与标签;compare 与旧任务按论文身份对比新增/缺失;stats 读语料统计(年份、来源、期刊、作者、类型、语言)。' +
328
+ '注意:一个对话只保留一个任务 tab,切换任务由用户在任务列表里完成;run 会在指定任务内开新一轮检索(并把它设为当前任务),与 literature_search 一样受「用户查看结果并明确要求后才能进入下一轮」约束,不能用它绕过该约束自行加搜;也不要用 update 改完条件就立刻 run。适合在检索之后做筛选、整理与汇报。',
329
+ parameters: TASK_PARAMETERS,
330
+ output: TEXT_OUTPUT,
331
+ async execute(args, exec) {
332
+ return {
333
+ text: await runTaskAction(service, readTaskArgs(args), exec.signal, exec.agent?.id),
334
+ };
335
+ },
336
+ },
337
+ {
338
+ name: 'literature_export',
339
+ description: '把检索任务导出为文件并返回落盘路径。csv 是全量筛读台账(含命中检索式、筛读状态、分组、备注);ris / bibtex 供 Zotero、EndNote 等文献管理器;md 是分层检索报告(漏斗、按分组的「为什么读」、必读清单、开放获取与 PDF 覆盖率、对比新增)。可按筛读状态、分组、检索式或指定 paperIds 限定范围。',
340
+ parameters: EXPORT_PARAMETERS,
341
+ output: TEXT_OUTPUT,
342
+ async execute(args) {
343
+ return { text: await runExport(service, readExportArgs(args)) };
344
+ },
345
+ },
346
+ ];
347
+ }
348
+ /** 把模型给的参数收窄成建任务输入;非法输入抛错给模型看。 */
349
+ function readSearchArgs(args) {
350
+ const record = asRecord(args);
351
+ const queryRaw = record['query'];
352
+ if (queryRaw !== undefined && typeof queryRaw !== 'string') {
353
+ throw new Error('query 必须是字符串');
354
+ }
355
+ const query = typeof queryRaw === 'string' ? queryRaw.trim() : '';
356
+ const queries = readQueries(record['queries']);
357
+ if (query === '' && queries === undefined) {
358
+ throw new Error('query 与 queries 至少提供一项');
359
+ }
360
+ const input = { query };
361
+ if (queries !== undefined)
362
+ input.queries = queries;
363
+ const title = readText(record['title'], 'title');
364
+ if (title !== undefined)
365
+ input.title = title.slice(0, 120);
366
+ const taskId = readText(record['taskId'], 'taskId');
367
+ if (taskId !== undefined)
368
+ input.taskId = taskId;
369
+ if (record['newTask'] !== undefined) {
370
+ if (typeof record['newTask'] !== 'boolean')
371
+ throw new Error('newTask 必须是布尔值');
372
+ input.newTask = record['newTask'];
373
+ }
374
+ if (input.newTask === true && input.taskId !== undefined) {
375
+ throw new Error('taskId 与 newTask 不能同时使用;要复用请只给 taskId,要新建请只给 newTask');
376
+ }
377
+ const types = readTypes(record['types']);
378
+ if (types !== undefined)
379
+ input.types = types;
380
+ const sourcesRaw = record['sources'];
381
+ if (sourcesRaw !== undefined) {
382
+ if (!Array.isArray(sourcesRaw) || !sourcesRaw.every((item) => typeof item === 'string')) {
383
+ throw new Error('sources 必须是字符串数组');
384
+ }
385
+ const invalid = sourcesRaw.filter((item) => !SOURCE_IDS.includes(item));
386
+ if (invalid.length > 0) {
387
+ throw new Error(`未知数据源:${invalid.join(', ')};可选 ${SOURCE_IDS.join(', ')}`);
388
+ }
389
+ input.sources = sourcesRaw;
390
+ }
391
+ const limit = readInteger(record['limit'], 'limit', MAX_LIMIT);
392
+ if (limit !== undefined)
393
+ input.pageSize = limit;
394
+ const yearFrom = readInteger(record['yearFrom'], 'yearFrom');
395
+ if (yearFrom !== undefined)
396
+ input.yearFrom = yearFrom;
397
+ const yearTo = readInteger(record['yearTo'], 'yearTo');
398
+ if (yearTo !== undefined)
399
+ input.yearTo = yearTo;
400
+ if (record['openAccessOnly'] === true)
401
+ input.openAccessOnly = true;
402
+ const language = record['language'];
403
+ if (language !== undefined) {
404
+ if (typeof language !== 'string' || !LANGUAGES.includes(language)) {
405
+ throw new Error(`language 只能是 ${LANGUAGES.join(' / ')}`);
406
+ }
407
+ input.language = language;
408
+ }
409
+ const sort = record['sort'];
410
+ if (sort !== undefined) {
411
+ if (typeof sort !== 'string' || !SORT_MODES.includes(sort)) {
412
+ throw new Error(`sort 只能是 ${SORT_MODES.join(' / ')}`);
413
+ }
414
+ input.sort = sort;
415
+ }
416
+ return input;
417
+ }
418
+ function readQueries(value) {
419
+ if (value === undefined || value === null)
420
+ return undefined;
421
+ if (!Array.isArray(value) || value.length === 0) {
422
+ throw new Error('queries 必须是非空数组');
423
+ }
424
+ if (value.length > 20)
425
+ throw new Error('queries 最多 20 条');
426
+ return value.map((entry, index) => {
427
+ const record = asRecord(entry);
428
+ const text = record['text'];
429
+ if (typeof text !== 'string' || text.trim() === '') {
430
+ throw new Error(`第 ${index + 1} 条检索式缺少 text`);
431
+ }
432
+ const input = { text: text.trim() };
433
+ const name = record['name'];
434
+ if (typeof name === 'string' && name.trim() !== '')
435
+ input.name = name.trim().slice(0, 80);
436
+ if (record['enabled'] !== undefined) {
437
+ if (typeof record['enabled'] !== 'boolean')
438
+ throw new Error('enabled 必须是布尔值');
439
+ input.enabled = record['enabled'];
440
+ }
441
+ const perSource = record['perSource'];
442
+ if (perSource !== undefined && perSource !== null) {
443
+ const overrides = {};
444
+ for (const [key, raw] of Object.entries(asRecord(perSource))) {
445
+ if (!SOURCE_IDS.includes(key)) {
446
+ throw new Error(`未知数据源:${key};可选 ${SOURCE_IDS.join(', ')}`);
447
+ }
448
+ if (typeof raw !== 'string')
449
+ throw new Error(`perSource.${key} 必须是字符串`);
450
+ overrides[key] = raw;
451
+ }
452
+ input.perSource = overrides;
453
+ }
454
+ return input;
455
+ });
456
+ }
457
+ function readTypes(value) {
458
+ if (value === undefined || value === null)
459
+ return undefined;
460
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
461
+ throw new Error('types 必须是字符串数组');
462
+ }
463
+ const types = value
464
+ .map((item) => item.trim().slice(0, 40))
465
+ .filter((item) => item !== '')
466
+ .slice(0, 20);
467
+ return types.length === 0 ? undefined : types;
468
+ }
469
+ function readInteger(value, name, max) {
470
+ if (value === undefined)
471
+ return undefined;
472
+ if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
473
+ throw new Error(`${name} 必须是正整数`);
474
+ }
475
+ if (max !== undefined && value > max)
476
+ throw new Error(`${name} 不能超过 ${max}`);
477
+ return value;
478
+ }
479
+ /**
480
+ * 等待一次已在跑的执行,最多 {@link MODEL_WAIT_MS}。
481
+ *
482
+ * `running` 由调用方启动(新建走 `service.run`,复用走 `service.rerun`)。
483
+ * 超时不取消任务:模型先拿到当前结果,右侧栏继续跑完。调用方中止则取消任务。
484
+ */
485
+ async function runWithin(service, id, signal, running) {
486
+ let timer;
487
+ const timeout = new Promise((resolve) => {
488
+ timer = setTimeout(() => resolve('timeout'), MODEL_WAIT_MS);
489
+ });
490
+ let onAbort;
491
+ const aborted = new Promise((_resolve, reject) => {
492
+ if (signal === undefined)
493
+ return;
494
+ onAbort = () => {
495
+ void service.cancel(id).catch(() => undefined);
496
+ reject(new Error('检索调用被中断'));
497
+ };
498
+ if (signal.aborted)
499
+ onAbort();
500
+ else
501
+ signal.addEventListener('abort', onAbort, { once: true });
502
+ });
503
+ try {
504
+ return await Promise.race([running.then(() => 'done'), timeout, aborted]);
505
+ }
506
+ finally {
507
+ if (timer !== undefined)
508
+ clearTimeout(timer);
509
+ if (signal !== undefined && onAbort !== undefined) {
510
+ signal.removeEventListener('abort', onAbort);
511
+ }
512
+ }
513
+ }
514
+ function readTaskArgs(args) {
515
+ const record = asRecord(args);
516
+ const action = record['action'];
517
+ if (typeof action !== 'string' || !TASK_ACTIONS.includes(action)) {
518
+ throw new Error(`action 只能是 ${TASK_ACTIONS.join(' / ')}`);
519
+ }
520
+ const input = { action: action };
521
+ const id = readText(record['id'], 'id');
522
+ if (id !== undefined)
523
+ input.id = id;
524
+ if (input.action !== 'list' && input.action !== 'current' && input.id === undefined) {
525
+ throw new Error(`action=${input.action} 需要 id`);
526
+ }
527
+ const withTaskId = readText(record['withTaskId'], 'withTaskId');
528
+ if (withTaskId !== undefined)
529
+ input.withTaskId = withTaskId;
530
+ if (input.action === 'compare' && input.withTaskId === undefined) {
531
+ throw new Error('compare 需要 withTaskId');
532
+ }
533
+ const limit = readInteger(record['limit'], 'limit', 200);
534
+ if (limit !== undefined)
535
+ input.limit = limit;
536
+ const offset = record['offset'];
537
+ if (offset !== undefined) {
538
+ if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0) {
539
+ throw new Error('offset 必须是非负整数');
540
+ }
541
+ input.offset = offset;
542
+ }
543
+ const screening = record['screening'];
544
+ if (screening !== undefined) {
545
+ if (typeof screening !== 'string' || !SCREENINGS.includes(screening)) {
546
+ throw new Error(`screening 只能是 ${SCREENINGS.join(' / ')}`);
547
+ }
548
+ input.screening = screening;
549
+ }
550
+ const groupId = readText(record['groupId'], 'groupId');
551
+ if (groupId !== undefined)
552
+ input.groupId = groupId;
553
+ const queryId = readText(record['queryId'], 'queryId');
554
+ if (queryId !== undefined)
555
+ input.queryId = queryId;
556
+ const q = readText(record['q'], 'q');
557
+ if (q !== undefined)
558
+ input.q = q;
559
+ const scope = record['scope'];
560
+ if (scope !== undefined) {
561
+ if (scope !== 'task' && scope !== 'filtered') {
562
+ throw new Error('scope 只能是 task / filtered');
563
+ }
564
+ input.scope = scope;
565
+ }
566
+ if (record['patch'] !== undefined)
567
+ input.patch = readPatchArgs(record['patch']);
568
+ if (input.action === 'update' && input.patch === undefined) {
569
+ throw new Error('update 需要 patch');
570
+ }
571
+ if (record['papers'] !== undefined)
572
+ input.papers = readAnnotations(record['papers']);
573
+ if (input.action === 'annotate' && (input.papers === undefined || input.papers.length === 0)) {
574
+ throw new Error('annotate 需要 papers');
575
+ }
576
+ return input;
577
+ }
578
+ function readPatchArgs(value) {
579
+ const record = asRecord(value);
580
+ const patch = {};
581
+ const title = readText(record['title'], 'patch.title');
582
+ if (title !== undefined)
583
+ patch.title = title;
584
+ const notes = readText(record['notes'], 'patch.notes', true);
585
+ if (notes !== undefined)
586
+ patch.notes = notes;
587
+ if (record['pinned'] !== undefined) {
588
+ if (typeof record['pinned'] !== 'boolean')
589
+ throw new Error('patch.pinned 必须是布尔值');
590
+ patch.pinned = record['pinned'];
591
+ }
592
+ if (record['groups'] !== undefined)
593
+ patch.groups = readGroups(record['groups']);
594
+ if (record['conditions'] !== undefined) {
595
+ const conditions = asRecord(record['conditions']);
596
+ const next = {};
597
+ const queries = readQueries(conditions['queries']);
598
+ if (queries !== undefined)
599
+ next.queries = queries;
600
+ if (conditions['sources'] !== undefined) {
601
+ const sources = conditions['sources'];
602
+ if (!Array.isArray(sources) || !sources.every((item) => typeof item === 'string')) {
603
+ throw new Error('conditions.sources 必须是字符串数组');
604
+ }
605
+ const invalid = sources.filter((item) => !SOURCE_IDS.includes(item));
606
+ if (invalid.length > 0) {
607
+ throw new Error(`未知数据源:${invalid.join(', ')};可选 ${SOURCE_IDS.join(', ')}`);
608
+ }
609
+ next.sources = sources;
610
+ }
611
+ const pageSize = readInteger(conditions['pageSize'], 'conditions.pageSize', MAX_LIMIT);
612
+ if (pageSize !== undefined)
613
+ next.pageSize = pageSize;
614
+ const yearFrom = readInteger(conditions['yearFrom'], 'conditions.yearFrom');
615
+ if (yearFrom !== undefined)
616
+ next.yearFrom = yearFrom;
617
+ const yearTo = readInteger(conditions['yearTo'], 'conditions.yearTo');
618
+ if (yearTo !== undefined)
619
+ next.yearTo = yearTo;
620
+ if (conditions['openAccessOnly'] !== undefined) {
621
+ if (typeof conditions['openAccessOnly'] !== 'boolean') {
622
+ throw new Error('conditions.openAccessOnly 必须是布尔值');
623
+ }
624
+ next.openAccessOnly = conditions['openAccessOnly'];
625
+ }
626
+ const language = conditions['language'];
627
+ if (language !== undefined) {
628
+ if (typeof language !== 'string' || !LANGUAGES.includes(language)) {
629
+ throw new Error(`conditions.language 只能是 ${LANGUAGES.join(' / ')}`);
630
+ }
631
+ next.language = language;
632
+ }
633
+ const types = readTypes(conditions['types']);
634
+ if (types !== undefined)
635
+ next.types = types;
636
+ const sort = conditions['sort'];
637
+ if (sort !== undefined) {
638
+ if (typeof sort !== 'string' || !SORT_MODES.includes(sort)) {
639
+ throw new Error(`conditions.sort 只能是 ${SORT_MODES.join(' / ')}`);
640
+ }
641
+ next.sort = sort;
642
+ }
643
+ patch.conditions = next;
644
+ }
645
+ return patch;
646
+ }
647
+ function readGroups(value) {
648
+ if (!Array.isArray(value))
649
+ throw new Error('patch.groups 必须是数组');
650
+ if (value.length > 50)
651
+ throw new Error('分组最多 50 个');
652
+ return value.map((entry, index) => {
653
+ const record = asRecord(entry);
654
+ const id = readText(record['id'], `分组 ${index + 1} 的 id`);
655
+ const name = readText(record['name'], `分组 ${index + 1} 的 name`);
656
+ if (id === undefined || name === undefined)
657
+ throw new Error('分组需要 id 与 name');
658
+ const group = { id, name, order: index };
659
+ const description = readText(record['description'], `分组 ${index + 1} 的 description`);
660
+ if (description !== undefined)
661
+ group.description = description;
662
+ return group;
663
+ });
664
+ }
665
+ function readAnnotations(value) {
666
+ if (!Array.isArray(value))
667
+ throw new Error('papers 必须是数组');
668
+ if (value.length > 50)
669
+ throw new Error('papers 最多 50 条');
670
+ return value.map((entry, index) => {
671
+ const record = asRecord(entry);
672
+ const paperId = readText(record['paperId'], `papers[${index}].paperId`);
673
+ if (paperId === undefined)
674
+ throw new Error(`papers[${index}] 缺少 paperId`);
675
+ const input = { paperId };
676
+ const screening = record['screening'];
677
+ if (screening !== undefined) {
678
+ if (typeof screening !== 'string' || !SCREENINGS.includes(screening)) {
679
+ throw new Error(`papers[${index}].screening 只能是 ${SCREENINGS.join(' / ')}`);
680
+ }
681
+ input.screening = screening;
682
+ }
683
+ if (record['groupId'] !== undefined) {
684
+ const groupId = record['groupId'];
685
+ if (groupId !== null && typeof groupId !== 'string') {
686
+ throw new Error(`papers[${index}].groupId 必须是字符串或 null`);
687
+ }
688
+ input.groupId = groupId;
689
+ }
690
+ if (record['shortlistOrder'] !== undefined) {
691
+ const order = record['shortlistOrder'];
692
+ if (order !== null && (typeof order !== 'number' || !Number.isFinite(order))) {
693
+ throw new Error(`papers[${index}].shortlistOrder 必须是数字或 null`);
694
+ }
695
+ input.shortlistOrder = order;
696
+ }
697
+ const note = readText(record['note'], `papers[${index}].note`, true);
698
+ if (note !== undefined)
699
+ input.note = note;
700
+ if (record['tags'] !== undefined) {
701
+ const tags = record['tags'];
702
+ if (!Array.isArray(tags) || !tags.every((tag) => typeof tag === 'string')) {
703
+ throw new Error(`papers[${index}].tags 必须是字符串数组`);
704
+ }
705
+ input.tags = tags;
706
+ }
707
+ return input;
708
+ });
709
+ }
710
+ function readExportArgs(args) {
711
+ const record = asRecord(args);
712
+ const taskId = readText(record['taskId'], 'taskId');
713
+ if (taskId === undefined)
714
+ throw new Error('缺少 taskId');
715
+ const format = record['format'];
716
+ if (typeof format !== 'string' || !EXPORT_FORMATS.includes(format)) {
717
+ throw new Error(`format 只能是 ${EXPORT_FORMATS.join(' / ')}`);
718
+ }
719
+ const input = { taskId, format: format };
720
+ if (record['paperIds'] !== undefined) {
721
+ const papers = record['paperIds'];
722
+ if (!Array.isArray(papers) || !papers.every((item) => typeof item === 'string')) {
723
+ throw new Error('paperIds 必须是字符串数组');
724
+ }
725
+ if (papers.length > 1500)
726
+ throw new Error('paperIds 最多 1500 条');
727
+ input.paperIds = papers;
728
+ }
729
+ const screening = record['screening'];
730
+ if (screening !== undefined) {
731
+ if (typeof screening !== 'string' || !SCREENINGS.includes(screening)) {
732
+ throw new Error(`screening 只能是 ${SCREENINGS.join(' / ')}`);
733
+ }
734
+ input.screening = screening;
735
+ }
736
+ const groupId = readText(record['groupId'], 'groupId');
737
+ if (groupId !== undefined)
738
+ input.groupId = groupId;
739
+ const queryId = readText(record['queryId'], 'queryId');
740
+ if (queryId !== undefined)
741
+ input.queryId = queryId;
742
+ return input;
743
+ }
744
+ async function runTaskAction(service, args, signal, sessionId) {
745
+ switch (args.action) {
746
+ case 'current': {
747
+ const task = await service.currentTask();
748
+ return formatCurrentTask(task);
749
+ }
750
+ case 'list': {
751
+ const tasks = await service.list();
752
+ return formatTaskList(tasks, args.limit ?? 30);
753
+ }
754
+ case 'get': {
755
+ const id = requireId(args);
756
+ const task = await service.get(id);
757
+ const detail = await service.getDetail(id);
758
+ const page = await service.results(id, {
759
+ ...readFilterArgs(args),
760
+ limit: args.limit ?? 30,
761
+ offset: args.offset ?? 0,
762
+ });
763
+ return formatTaskDetail(detail, page, task.states, task.groups);
764
+ }
765
+ case 'update': {
766
+ const task = await service.update(requireId(args), args.patch ?? {});
767
+ return `已更新任务「${task.title}」(id: ${task.id},rev=${task.rev})。分组 ${task.groups.length} 个,任务笔记 ${task.notes === '' ? '为空' : `${task.notes.length} 字`}。`;
768
+ }
769
+ case 'run': {
770
+ const id = requireId(args);
771
+ const task = await service.get(id);
772
+ // 用户明确要求推进这个任务:它成为当前任务,并让客户端打开/聚焦对应 tab。
773
+ service.setCurrentTask(id);
774
+ service.focusTask(task.id, task.title, sessionId);
775
+ const outcome = await runWithin(service, id, signal, service.run(id, 'new', 'model'));
776
+ const latest = await service.get(id);
777
+ const detail = await service.getDetail(id);
778
+ const page = await service.results(id, {
779
+ ...readFilterArgs(args),
780
+ limit: args.limit ?? TOOL_PAPER_LIMIT,
781
+ offset: args.offset ?? 0,
782
+ });
783
+ // 这条旁路同样附上「不自动下一轮」约束,避免模型绕过 literature_search。
784
+ return `${formatTaskDetail(detail, page, latest.states, latest.groups, outcome)}\n\n${NO_AUTO_NEXT_ROUND}`;
785
+ }
786
+ case 'rounds': {
787
+ const id = requireId(args);
788
+ const task = await service.get(id);
789
+ return formatRounds(task.title, id, await service.getRounds(id), args.limit ?? 30);
790
+ }
791
+ case 'annotate': {
792
+ const result = await service.annotate(requireId(args), args.papers ?? []);
793
+ return `已写入 ${result.updated} 篇的筛读/备注(任务 id: ${result.taskId},rev=${result.rev})。`;
794
+ }
795
+ case 'compare': {
796
+ const report = await service.compare(requireId(args), args.withTaskId ?? '');
797
+ return formatCompare(report);
798
+ }
799
+ case 'stats': {
800
+ const report = await service.stats(requireId(args), readFilterArgs(args), args.scope ?? 'task');
801
+ return formatStatsReport(report);
802
+ }
803
+ default:
804
+ throw new Error(`未知 action:${String(args.action)}`);
805
+ }
806
+ }
807
+ async function runExport(service, args) {
808
+ const filter = {};
809
+ if (args.screening !== undefined)
810
+ filter.screening = args.screening;
811
+ if (args.groupId !== undefined)
812
+ filter.groupId = args.groupId;
813
+ if (args.queryId !== undefined)
814
+ filter.queryId = args.queryId;
815
+ const outcome = await service.exportToDisk(args.taskId, args.format, filter, args.paperIds);
816
+ const preview = truncate(outcome.file.content, 600);
817
+ return [
818
+ `已导出 ${outcome.count} 篇:${outcome.path}`,
819
+ `文件名:${outcome.file.filename}(${outcome.file.mime})`,
820
+ '',
821
+ '内容开头:',
822
+ preview,
823
+ ].join('\n');
824
+ }
825
+ function readFilterArgs(args) {
826
+ const filter = {};
827
+ if (args.screening !== undefined)
828
+ filter.screening = args.screening;
829
+ if (args.groupId !== undefined)
830
+ filter.groupId = args.groupId;
831
+ if (args.queryId !== undefined)
832
+ filter.queryId = args.queryId;
833
+ if (args.q !== undefined && args.q.trim() !== '')
834
+ filter.q = args.q.trim();
835
+ return filter;
836
+ }
837
+ function requireId(args) {
838
+ if (args.id === undefined || args.id === '')
839
+ throw new Error('缺少任务 id');
840
+ return args.id;
841
+ }
842
+ function readText(value, name, allowEmpty = false) {
843
+ if (value === undefined || value === null)
844
+ return undefined;
845
+ if (typeof value !== 'string')
846
+ throw new Error(`${name} 必须是字符串`);
847
+ if (!allowEmpty && value.trim() === '')
848
+ return undefined;
849
+ return allowEmpty ? value : value.trim();
850
+ }
851
+ /** 「当前任务」的轻量快照:模型在执行检索/任务操作前先确认这个对话正在用哪个任务。 */
852
+ function formatCurrentTask(task) {
853
+ if (task === undefined) {
854
+ return [
855
+ '当前没有任务:这个对话还没有任何检索任务。',
856
+ '若用户要求检索,请直接调用 literature_search(不要传 newTask);若用户要继续某个已有任务,请让用户从右侧栏任务列表打开它,或点任务页的「带入对话」把任务 id 带进对话。',
857
+ ].join('\n');
858
+ }
859
+ const conditions = task.conditions;
860
+ const queries = conditions.queries.map((query) => `${query.name}=「${query.text}」`).join(';');
861
+ const last = task.rounds[task.rounds.length - 1];
862
+ const screening = task.stats.screening;
863
+ const lines = [
864
+ `当前任务:${task.title}(id: ${task.id})`,
865
+ `状态:${statusLabel(task.status)}${task.error === undefined ? '' : `(${task.error})`}`,
866
+ `检索轮次:共 ${task.rounds.length} 轮${last === undefined
867
+ ? ''
868
+ : `|最近一轮 ${last.createdAt.slice(0, 19).replace('T', ' ')}(${last.origin === 'model' ? '模型' : '面板'})`}`,
869
+ `检索式:${queries === '' ? '(无)' : queries}`,
870
+ `条件:数据源 ${conditions.sources.join('/')}|每源 ${conditions.pageSize} 条|年份 ${conditions.yearFrom ?? '不限'}-${conditions.yearTo ?? '不限'}|${conditions.openAccessOnly ? '仅开放获取' : '不限获取方式'}|语言 ${conditions.language}|排序 ${conditions.sort}`,
871
+ `漏斗:原始 ${task.stats.rawHits}|去重 ${task.stats.deduped}|已加载 ${task.result.papers.length}|纳入 ${screening.included}、排除 ${screening.excluded}、待读 ${screening.unread}、已读 ${screening.read}`,
872
+ '',
873
+ '用法:同一主题继续检索时省略 taskId 即复用该任务;需要调整条件时直接在 literature_search 里提交【完整】的新条件;只有用户明确要求新建查询任务时才传 newTask=true。',
874
+ ];
875
+ return lines.join('\n');
876
+ }
877
+ function formatTaskList(tasks, limit) {
878
+ if (tasks.length === 0)
879
+ return '还没有保存的检索任务。可先用 literature_search 检索。';
880
+ const lines = [`共 ${tasks.length} 个任务${tasks.length > limit ? `,列出前 ${limit} 个` : ''}:`];
881
+ for (const task of tasks.slice(0, limit)) {
882
+ const screening = task.stats.screening;
883
+ lines.push(`- ${task.id}|${task.title}|${statusLabel(task.status)}|${task.roundCount ?? 0} 轮|${task.updatedAt.slice(0, 10)}|已加载 ${task.stats.deduped} 篇|纳入 ${screening.included}、排除 ${screening.excluded}、待读 ${screening.unread}`);
884
+ }
885
+ return lines.join('\n');
886
+ }
887
+ function formatTaskDetail(detail, page, states, groups, outcome) {
888
+ const lines = [`任务:${detail.title}(id: ${detail.id},rev=${detail.rev})`];
889
+ lines.push(outcome === 'timeout'
890
+ ? '状态:仍在检索中,已先返回当前结果;任务会继续在右侧栏完成。'
891
+ : `状态:${statusLabel(detail.status)}${detail.error === undefined ? '' : `(${detail.error})`}`);
892
+ lines.push(`检索轮次:共 ${detail.roundCount ?? 0} 轮(用 rounds 查看每轮条件与命中)`);
893
+ const conditions = detail.conditions;
894
+ const queries = conditions.queries.map((query) => `${query.name}=「${query.text}」`).join(';');
895
+ lines.push(`检索式:${queries}`);
896
+ lines.push(`条件:数据源 ${conditions.sources.join('/')}|每源 ${conditions.pageSize} 条|年份 ${conditions.yearFrom ?? '不限'}-${conditions.yearTo ?? '不限'}|${conditions.openAccessOnly ? '仅开放获取' : '不限获取方式'}|语言 ${conditions.language}|排序 ${conditions.sort}`);
897
+ if (detail.fromTaskId !== undefined) {
898
+ lines.push(`基准任务:${detail.fromTaskId}(筛读状态已继承,新增篇数见 stats)`);
899
+ }
900
+ const screening = detail.stats.screening;
901
+ lines.push(`漏斗:原始 ${detail.stats.rawHits}|去重 ${detail.stats.deduped}|已加载 ${detail.result.totalLoaded}|纳入 ${screening.included}、排除 ${screening.excluded}、待读 ${screening.unread}、已读 ${screening.read}`);
902
+ if (groups.length > 0) {
903
+ const usage = new Map();
904
+ for (const state of Object.values(states)) {
905
+ if (state.groupId !== undefined)
906
+ usage.set(state.groupId, (usage.get(state.groupId) ?? 0) + 1);
907
+ }
908
+ lines.push(`分组:${groups
909
+ .map((group) => `${group.name}(${group.id}) ${usage.get(group.id) ?? 0} 篇${group.description === undefined ? '' : `:${group.description}`}`)
910
+ .join(';')}`);
911
+ }
912
+ if (detail.notes.trim() !== '')
913
+ lines.push(`任务笔记:${truncate(detail.notes, 500)}`);
914
+ const groupNames = new Map(groups.map((group) => [group.id, group.name]));
915
+ lines.push('', `结果:筛选后 ${page.total} 篇,以下第 ${page.offset + 1}–${page.offset + page.papers.length} 篇:`);
916
+ page.papers.forEach((paper, index) => {
917
+ const state = states[paper.paperId];
918
+ const marks = [
919
+ state?.screening ?? 'unread',
920
+ state?.groupId === undefined ? '' : groupNames.get(state.groupId) ?? state.groupId,
921
+ state?.shortlistOrder === undefined ? '' : `必读#${state.shortlistOrder}`,
922
+ ].filter((part) => part !== '');
923
+ const meta = [
924
+ paper.authors.slice(0, 4).join(', '),
925
+ paper.year === null ? '' : String(paper.year),
926
+ paper.venue,
927
+ paper.doi === '' ? '' : `DOI: ${paper.doi}`,
928
+ paper.citedBy === null ? '' : `被引 ${paper.citedBy}`,
929
+ ].filter((part) => part !== '');
930
+ lines.push(`${page.offset + index + 1}. [${marks.join('|')}] ${paper.title}`);
931
+ if (meta.length > 0)
932
+ lines.push(` ${meta.join(' | ')}`);
933
+ if (state?.note !== undefined && state.note !== '')
934
+ lines.push(` 备注:${state.note}`);
935
+ if (paper.abstract !== '')
936
+ lines.push(` 摘要:${truncate(paper.abstract, 300)}`);
937
+ });
938
+ if (page.total > page.offset + page.papers.length) {
939
+ lines.push('', `还有 ${page.total - page.offset - page.papers.length} 篇;可用 offset 继续读。`);
940
+ }
941
+ return lines.join('\n');
942
+ }
943
+ function formatCompare(report) {
944
+ const { counts } = report;
945
+ const lines = [
946
+ `与「${report.withTitle}」对比(当前任务 id: ${report.taskId},基准 id: ${report.withTaskId}):`,
947
+ `当前 ${counts.total} 篇,基准 ${counts.baseTotal} 篇;新增 ${counts.added}、共有 ${counts.common}、缺失 ${counts.missing}。`,
948
+ ];
949
+ if (counts.added > 0) {
950
+ lines.push('', '新增:');
951
+ report.added.slice(0, 20).forEach((paper, index) => {
952
+ lines.push(`${index + 1}. ${paper.title}${describePaper(paper)}`);
953
+ });
954
+ if (counts.added > 20)
955
+ lines.push(`… 另有 ${counts.added - 20} 篇新增,可在右侧栏对比页查看。`);
956
+ }
957
+ if (counts.missing > 0) {
958
+ lines.push('', '基准有、当前缺失:');
959
+ report.missing.slice(0, 10).forEach((paper, index) => {
960
+ lines.push(`${index + 1}. ${paper.title}${describePaper(paper)}`);
961
+ });
962
+ if (counts.missing > 10)
963
+ lines.push(`… 另有 ${counts.missing - 10} 篇缺失。`);
964
+ }
965
+ return lines.join('\n');
966
+ }
967
+ function describePaper(paper) {
968
+ const meta = [
969
+ paper.authors.slice(0, 3).join(', '),
970
+ paper.year === null ? '' : String(paper.year),
971
+ paper.venue,
972
+ paper.doi === '' ? '' : `DOI: ${paper.doi}`,
973
+ ].filter((part) => part !== '');
974
+ return meta.length === 0 ? '' : `\n ${meta.join(' | ')}`;
975
+ }
976
+ function formatStatsReport(report) {
977
+ const lines = [
978
+ `统计口径:${report.scope === 'task' ? '全任务' : '当前筛选'}|参与统计 ${report.loaded} 篇(任务共 ${report.totalHint} 篇)`,
979
+ `漏斗:原始 ${report.funnel.rawHits}|去重 ${report.funnel.deduped}|筛选后 ${report.funnel.filtered}|纳入 ${report.funnel.included}、排除 ${report.funnel.excluded}、待读 ${report.funnel.unread}、已读 ${report.funnel.read}`,
980
+ `指标:开放获取率 ${percent(report.metrics.oaRate)}|PDF 覆盖率 ${percent(report.metrics.pdfRate)}|被引中位数 ${report.metrics.medianCitations}${report.metrics.newVsBase === undefined ? '' : `|相对基准新增 ${report.metrics.newVsBase} 篇`}`,
981
+ ];
982
+ lines.push('', '年份分布:', ...report.byYear.slice(-12).map((year) => `- ${year.year}: ${year.count} 篇(纳入 ${year.included})`));
983
+ lines.push('', '来源:', ...report.bySource.map((source) => `- ${source.id}: 命中 ${source.hits}|独有 ${source.uniqueOnly}|OA ${source.oa}|PDF ${source.withPdf}`));
984
+ if (report.byQuery.length > 1) {
985
+ lines.push('', '检索式贡献:', ...report.byQuery.map((query) => `- ${query.name}: 命中 ${query.hits}|独有 ${query.uniqueOnly}`));
986
+ }
987
+ if (report.byType.length > 0) {
988
+ lines.push('', '类型:', ...report.byType.slice(0, 10).map((entry) => `- ${entry.key}: ${entry.count}`));
989
+ }
990
+ if (report.byLanguage.length > 0) {
991
+ lines.push('', '语言:', ...report.byLanguage.slice(0, 10).map((entry) => `- ${entry.key}: ${entry.count}`));
992
+ }
993
+ if (report.topVenues.length > 0) {
994
+ lines.push('', '期刊/出处:', ...report.topVenues.slice(0, 10).map((entry) => `- ${entry.key}: ${entry.count}`));
995
+ }
996
+ if (report.topAuthors.length > 0) {
997
+ lines.push('', '作者:', ...report.topAuthors.slice(0, 10).map((entry) => `- ${entry.key}: ${entry.count}`));
998
+ }
999
+ return lines.join('\n');
1000
+ }
1001
+ function percent(value) {
1002
+ return `${Math.round(value * 100)}%`;
1003
+ }
1004
+ function formatSources() {
1005
+ const lines = ['已接入的免费文献数据源(无需付费;部分支持可选 Key 提高额度):'];
1006
+ for (const info of PROVIDER_INFOS) {
1007
+ const caps = [
1008
+ info.capabilities.abstract ? '摘要' : '',
1009
+ info.capabilities.citationCount ? '被引' : '',
1010
+ info.capabilities.openAccess ? '开放获取' : '',
1011
+ info.capabilities.pdf ? 'PDF' : '',
1012
+ ].filter((cap) => cap !== '');
1013
+ lines.push(`- ${info.id}(${info.label}):${caps.join('、') || '仅题录'}`);
1014
+ lines.push(` 限流:${info.rateLimit}|语法:${info.syntax}|${info.optionalKey ? '可选 Key' : '无需 Key'}`);
1015
+ lines.push(` 首页:${info.homepage}`);
1016
+ }
1017
+ return lines.join('\n');
1018
+ }
1019
+ function formatTaskResult(detail, page, outcome, reused) {
1020
+ const lines = [
1021
+ `任务:${detail.title}(id: ${detail.id})`,
1022
+ `轮次:第 ${detail.roundCount ?? 0} 轮|${reused ? '复用已有任务,未新建任务、未新开 tab' : '新建任务'}|右侧栏历史图标可查看各轮条件与结果概览`,
1023
+ ];
1024
+ if (outcome === 'timeout') {
1025
+ const progress = detail.progress;
1026
+ const position = progress === undefined ? '' : `(检索式 ${progress.queryIndex}/${progress.queryTotal})`;
1027
+ lines.push(`状态:仍在检索中${position},已先返回当前结果;任务会继续在右侧栏完成。`);
1028
+ }
1029
+ else {
1030
+ lines.push(`状态:${statusLabel(detail.status)}`);
1031
+ if (detail.error !== undefined)
1032
+ lines.push(`失败原因:${detail.error}`);
1033
+ }
1034
+ const okSources = detail.result.sources
1035
+ .filter((source) => source.ok)
1036
+ .map((source) => `${source.id} ${source.count} 条`)
1037
+ .join(',');
1038
+ const failed = detail.result.sources.filter((source) => !source.ok);
1039
+ lines.push(`命中:原始 ${detail.stats.rawHits} 条,去重后 ${detail.stats.deduped} 条,已加载 ${detail.result.totalLoaded} 篇${okSources === '' ? '' : `(${okSources})`}`);
1040
+ if (detail.conditions.queries.length > 1) {
1041
+ const contributions = detail.conditions.queries
1042
+ .map((query) => `${query.name} ${detail.stats.perQuery[query.id] ?? 0} 条`)
1043
+ .join(';');
1044
+ lines.push(`检索式原始命中:${contributions}`);
1045
+ }
1046
+ if (failed.length > 0) {
1047
+ lines.push(`失败的数据源:${failed
1048
+ .map((source) => `${source.id}(${source.error ?? '未知错误'})`)
1049
+ .join(';')}`);
1050
+ }
1051
+ if (detail.result.totalLoaded === 0) {
1052
+ if (outcome === 'timeout') {
1053
+ lines.push('当前还没有拿到结果;可在右侧栏查看任务进度,或稍后重跑。');
1054
+ }
1055
+ else {
1056
+ lines.push('没有检索到匹配文献。可尝试更短的关键词、英文主题词,或放宽年份与开放获取限制。');
1057
+ }
1058
+ lines.push('', NO_AUTO_NEXT_ROUND);
1059
+ return lines.join('\n');
1060
+ }
1061
+ page.papers.forEach((paper, index) => {
1062
+ lines.push('');
1063
+ lines.push(`${index + 1}. ${paper.title}`);
1064
+ const meta = [
1065
+ paper.authors.slice(0, 6).join(', '),
1066
+ paper.year === null ? '' : String(paper.year),
1067
+ paper.venue,
1068
+ ]
1069
+ .filter((part) => part !== '')
1070
+ .join(' | ');
1071
+ if (meta !== '')
1072
+ lines.push(` ${meta}`);
1073
+ const facts = [
1074
+ paper.doi === '' ? '' : `DOI: ${paper.doi}`,
1075
+ paper.citedBy === null ? '' : `被引 ${paper.citedBy}`,
1076
+ paper.openAccess ? '开放获取' : '',
1077
+ `来源: ${paper.hitSources.map((hit) => hit.source).join('/')}`,
1078
+ ].filter((part) => part !== '');
1079
+ if (facts.length > 0)
1080
+ lines.push(` ${facts.join(' | ')}`);
1081
+ if (paper.pdfUrl !== '')
1082
+ lines.push(` PDF: ${paper.pdfUrl}`);
1083
+ else if (paper.url !== '')
1084
+ lines.push(` 链接: ${paper.url}`);
1085
+ if (paper.abstract !== '')
1086
+ lines.push(` 摘要: ${truncate(paper.abstract, 500)}`);
1087
+ });
1088
+ if (page.total > page.papers.length) {
1089
+ lines.push('', `仅列出前 ${page.papers.length} 篇;完整 ${page.total} 篇可在右侧栏任务里分页查看。`);
1090
+ }
1091
+ lines.push('', '提示:可用 DOI 或标题继续追问;需要全文时优先使用开放获取 PDF 链接。');
1092
+ lines.push('', NO_AUTO_NEXT_ROUND);
1093
+ return lines.join('\n');
1094
+ }
1095
+ /** 轮次历史:只给元数据,不回吐文献列表。 */
1096
+ function formatRounds(title, id, rounds, limit) {
1097
+ if (rounds.length === 0) {
1098
+ return `任务「${title}」(id: ${id})还没有检索轮次。\n${NO_AUTO_NEXT_ROUND}`;
1099
+ }
1100
+ const recent = rounds.slice(-Math.max(1, limit));
1101
+ const lines = [
1102
+ `任务「${title}」(id: ${id})共 ${rounds.length} 轮检索${rounds.length > recent.length ? `,列出最近 ${recent.length} 轮` : ''}:`,
1103
+ ];
1104
+ for (const round of recent) {
1105
+ const queries = round.conditions.queries
1106
+ .map((query) => `「${query.text}」`)
1107
+ .join('、');
1108
+ const stats = round.stats === undefined
1109
+ ? '统计未回填'
1110
+ : `原始 ${round.stats.rawHits}|去重 ${round.stats.deduped}|已加载 ${round.stats.loaded} 篇`;
1111
+ lines.push(`- 第 ${round.index} 轮|${round.createdAt.slice(0, 19).replace('T', ' ')}|${round.origin === 'model' ? '模型' : '面板'}|${round.status === undefined ? '进行中' : statusLabel(round.status)}|${stats}${round.error === undefined ? '' : `|${round.error}`}`);
1112
+ lines.push(` 条件:${truncate(queries, 300)}|每源 ${round.conditions.pageSize} 条|源 ${round.conditions.sources.join('/')}`);
1113
+ }
1114
+ lines.push('', '提示:右侧栏历史时间轴可查看同样内容,并可一键回填某轮条件重跑。');
1115
+ lines.push('', NO_AUTO_NEXT_ROUND);
1116
+ return lines.join('\n');
1117
+ }
1118
+ function statusLabel(status) {
1119
+ switch (status) {
1120
+ case 'draft':
1121
+ return '草稿';
1122
+ case 'queued':
1123
+ return '排队中';
1124
+ case 'running':
1125
+ return '进行中';
1126
+ case 'done':
1127
+ return '完成';
1128
+ case 'partial':
1129
+ return '部分完成(有数据源失败)';
1130
+ case 'canceled':
1131
+ return '已取消';
1132
+ default:
1133
+ return '失败';
1134
+ }
1135
+ }
1136
+ function truncate(value, max) {
1137
+ return value.length <= max ? value : `${value.slice(0, max)}…`;
1138
+ }