dsh-plugin-litsearch 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/host/tools.js CHANGED
@@ -9,14 +9,22 @@
9
9
  * - `literature_task`:读取/修改已保存任务(含轮次历史),写入筛读与分组,
10
10
  * 做新老对比与统计。
11
11
  * - `literature_export`:把任务(按筛选)导出为 CSV / RIS / BibTeX / Markdown 并落盘。
12
+ * - `literature_download`:把自然语言下载请求解析成已校验的 paperId 清单,
13
+ * 通过事件下发给右侧栏,由右侧栏复用批量下载弹窗执行并逐篇展示。
14
+ * - `literature_zotero`:与下载共用范围解析,改为让右侧栏复用 Zotero 同步
15
+ * 弹窗;对话里点名目录且匹配成功才自动开跑,否则由用户选目录。
12
16
  *
13
17
  * 参数在边界做一次校验(模型输出不可信),服务层仍会再兜底。
14
18
  */
15
- import { MAX_LIMIT } from '../search/config.js';
19
+ import { randomUUID } from 'node:crypto';
20
+ import { MAX_LIMIT, getSettings } from '../search/config.js';
16
21
  import { SearchError } from '../search/errors.js';
17
22
  import { asRecord } from '../search/normalize.js';
18
23
  import { PROVIDER_INFOS } from '../search/providers/index.js';
19
- import { SOURCE_IDS, TASK_LANGUAGES } from '../search/types.js';
24
+ import { RESULT_PAGE_SIZE, SOURCE_IDS, TASK_LANGUAGES } from '../search/types.js';
25
+ import { describePages, mergeDownloadFilter, resolveDownloadScope, toPaperFilter, } from './tasks/download.js';
26
+ import { filterPapers, pdfStatusOf, sortPapers } from './tasks/filter.js';
27
+ import { describeZoteroTargets, matchZoteroTarget } from './zotero/match.js';
20
28
  import { resolveSessionWorkspace } from './workspace.js';
21
29
  /** 模型侧最多等多久;超时后任务继续跑,只返回当前进度与已有结果。 */
22
30
  const MODEL_WAIT_MS = 25_000;
@@ -28,6 +36,28 @@ const SORT_MODES = ['relevance', 'citations', 'year'];
28
36
  const LANGUAGES = TASK_LANGUAGES;
29
37
  const SCREENINGS = ['unread', 'read', 'included', 'excluded'];
30
38
  const EXPORT_FORMATS = ['csv', 'ris', 'bibtex', 'md'];
39
+ const DOWNLOAD_SCOPES = [
40
+ 'ids',
41
+ 'currentPage',
42
+ 'pages',
43
+ 'currentView',
44
+ 'all',
45
+ 'group',
46
+ 'filters',
47
+ ];
48
+ const PAGE_BASES = ['view', 'filtered'];
49
+ /** `fields=ids` 时一次最多回多少条 id(与 `literature_task` 的 limit 上限一致)。 */
50
+ const IDS_LIMIT = 200;
51
+ const PDF_STATUSES = [
52
+ 'unknown',
53
+ 'available',
54
+ 'downloaded',
55
+ 'missing',
56
+ 'html-only',
57
+ 'paywalled',
58
+ 'blocked',
59
+ 'failed',
60
+ ];
31
61
  const TASK_ACTIONS = [
32
62
  'current',
33
63
  'list',
@@ -154,6 +184,25 @@ const TASK_PARAMETERS = {
154
184
  groupId: { type: 'string', description: '按任务内分组过滤。' },
155
185
  queryId: { type: 'string', description: '按检索式 id 过滤命中文献。' },
156
186
  q: { type: 'string', description: '标题/摘要关键词。' },
187
+ hasPdf: {
188
+ type: 'boolean',
189
+ description: 'get 时只看有 PDF 直链或已下载的文献;引用「有 PDF 的那批」前先用它核对。',
190
+ },
191
+ missingFullText: { type: 'boolean', description: 'get 时只看尚无可用全文的文献。' },
192
+ pdfStatus: { type: 'string', enum: [...PDF_STATUSES], description: '按全文状态精确过滤。' },
193
+ yearFrom: { type: 'integer', minimum: 1, maximum: 3000, description: '起始年份。' },
194
+ yearTo: { type: 'integer', minimum: 1, maximum: 3000, description: '截止年份。' },
195
+ source: { type: 'string', enum: [...SOURCE_IDS], description: '只看该数据源命中的文献。' },
196
+ type: { type: 'string', description: '文献类型(精确匹配)。' },
197
+ language: { type: 'string', enum: [...TASK_LANGUAGES], description: '语言码。' },
198
+ venue: { type: 'string', description: '期刊/出处(精确匹配)。' },
199
+ author: { type: 'string', description: '作者全名(精确匹配)。' },
200
+ newOnly: { type: 'boolean', description: '只看相对基准任务新增的文献。' },
201
+ fields: {
202
+ type: 'string',
203
+ enum: ['full', 'ids'],
204
+ description: 'get 的返回格式。full(默认)= 条件 + 摘要 + paperId,适合阅读;ids = 每条只回「paperId|标题|是否有 PDF」,一次最多 200 条,适合列出清单或核对范围(配 offset 翻页)。',
205
+ },
157
206
  scope: {
158
207
  type: 'string',
159
208
  enum: ['task', 'filtered'],
@@ -239,6 +288,136 @@ const EXPORT_PARAMETERS = {
239
288
  required: ['taskId', 'format'],
240
289
  additionalProperties: false,
241
290
  };
291
+ /**
292
+ * 下载范围参数的字段名一律沿用 `ResultsQuery` / `PaperFilter`,让模型下载与
293
+ * 界面筛选共用同一套口径;这里不引入任何新的条件表达式。
294
+ */
295
+ const DOWNLOAD_PARAMETERS = {
296
+ type: 'object',
297
+ properties: {
298
+ taskId: { type: 'string', description: '要下载的任务 id;省略则用当前任务。' },
299
+ scope: {
300
+ type: 'string',
301
+ enum: [...DOWNLOAD_SCOPES],
302
+ description: '下载范围,按用户话术选择:ids = 用户点名了具体文献且你已从工具结果看到确切 paperId;currentPage = 「当前页/这一页」;pages = 用户给了明确页码(「第 2 页」「第 4–5 页」),必须同时给 pages;currentView = 「当前全部/当前筛选下全部」(含跨页,默认,不等于整个任务);all = 用户明确说「整个任务/所有文献/不管筛选」;group = 「xx 分组」(必须同时给 groupId);filters = 用户给了年份、开放获取、关键词、来源、筛读状态等条件。',
303
+ },
304
+ pages: {
305
+ type: 'array',
306
+ maxItems: 20,
307
+ items: { type: 'integer', minimum: 1, maximum: 500 },
308
+ description: 'scope=pages 时的页码,1-based,指用户在右侧栏看到的那一页(每页 20 条,按当前排序与筛选),不是任务条件的「每源条数」。例:「第二页」→ [2];「第 4–5 页」→ [4,5]。页码由宿主按面板上报的视图快照换算成 paperId,你不要自己算 offset 或搬 id。',
309
+ },
310
+ pageBase: {
311
+ type: 'string',
312
+ enum: [...PAGE_BASES],
313
+ description: '页码的基准,默认 view。view = 先按面板当前筛选/排序切页,再在页内套 hasPdf 等附加条件(「第二页里有 PDF 的文献」用这个);filtered = 先把附加条件合并进筛选再切页(「有 PDF 的文献的第 2 页」用这个)。',
314
+ },
315
+ reason: {
316
+ type: 'string',
317
+ description: '一句话复述用户要下载的范围(会显示在右侧批量下载框里),必填。',
318
+ },
319
+ paperIds: {
320
+ type: 'array',
321
+ maxItems: 1500,
322
+ items: { type: 'string' },
323
+ description: 'scope=ids 时的确切 paperId 列表。只能填你在工具返回的「paperId:」行里看到的 id,禁止编造或猜测;其余 id 会被忽略。',
324
+ },
325
+ q: { type: 'string', description: '标题/摘要关键词,用于「下载关于 xx 的文献」。' },
326
+ groupId: { type: 'string', description: '任务内分组 id;scope=group 时必填。' },
327
+ queryId: { type: 'string', description: '只下某条检索式命中的文献。' },
328
+ source: { type: 'string', enum: [...SOURCE_IDS], description: '只下该数据源命中的文献。' },
329
+ yearFrom: { type: 'integer', minimum: 1, maximum: 3000, description: '起始年份。' },
330
+ yearTo: { type: 'integer', minimum: 1, maximum: 3000, description: '截止年份。' },
331
+ openAccessOnly: {
332
+ type: 'boolean',
333
+ description: '仅上游标记为开放获取的文献;与 hasPdf 不等价,不要同时默认开启。',
334
+ },
335
+ hasPdf: {
336
+ type: 'boolean',
337
+ description: '仅有 PDF 直链或已下载的文献。「下载当前页的所有 pdf 文献」用这个;付费墙文献会被跳过。',
338
+ },
339
+ missingFullText: { type: 'boolean', description: '只下尚无可用全文的文献。' },
340
+ pdfStatus: {
341
+ type: 'string',
342
+ enum: [...PDF_STATUSES],
343
+ description: '按全文状态精确筛选(如只要 missing 或 failed 的重试)。',
344
+ },
345
+ screening: { type: 'string', enum: [...SCREENINGS], description: '按筛读状态过滤。' },
346
+ type: { type: 'string', description: '文献类型(精确匹配)。' },
347
+ language: { type: 'string', enum: [...TASK_LANGUAGES], description: '语言码。' },
348
+ venue: { type: 'string', description: '期刊/出处(精确匹配)。' },
349
+ author: { type: 'string', description: '作者全名(精确匹配)。' },
350
+ },
351
+ required: ['scope', 'reason'],
352
+ additionalProperties: false,
353
+ };
354
+ /**
355
+ * Zotero 同步的范围字段与下载**完全一致**(同一个 schema 展开),只把文案
356
+ * 换成同步语境;额外多一个 `collection`。这样两个工具不会各自演化出一套
357
+ * 筛选口径,模型学到的字段也能直接迁移。
358
+ */
359
+ const ZOTERO_PARAMETERS = {
360
+ ...DOWNLOAD_PARAMETERS,
361
+ properties: {
362
+ ...DOWNLOAD_PARAMETERS.properties,
363
+ taskId: { type: 'string', description: '要同步的任务 id;省略则用当前任务。' },
364
+ scope: {
365
+ type: 'string',
366
+ enum: [...DOWNLOAD_SCOPES],
367
+ description: '同步范围,按用户话术选择:ids = 用户点名了具体文献且你已从工具结果看到确切 paperId;currentPage = 「当前页/这一页」;pages = 用户给了明确页码(「第 2 页」「第 4–5 页」),必须同时给 pages;currentView = 「当前全部/当前筛选下全部」(含跨页,默认,不等于整个任务);all = 用户明确说「整个任务/所有文献/不管筛选」;group = 「xx 分组」(必须同时给 groupId);filters = 用户给了年份、开放获取、关键词、来源、筛读状态等条件。',
368
+ },
369
+ pages: {
370
+ type: 'array',
371
+ maxItems: 20,
372
+ items: { type: 'integer', minimum: 1, maximum: 500 },
373
+ description: 'scope=pages 时的页码,1-based,指用户在右侧栏看到的那一页(每页 20 条,按当前排序与筛选),不是任务条件的「每源条数」。例:「第二页」→ [2];「第 4–5 页」→ [4,5]。页码由宿主按面板上报的视图快照换算成 paperId,你不要自己算 offset 或搬 id。',
374
+ },
375
+ pageBase: {
376
+ type: 'string',
377
+ enum: [...PAGE_BASES],
378
+ description: '页码的基准,默认 view。view = 先按面板当前筛选/排序切页,再在页内套 hasPdf 等附加条件(「第二页里有 PDF 的文献」用这个);filtered = 先把附加条件合并进筛选再切页(「有 PDF 的文献的第 2 页」用这个)。',
379
+ },
380
+ reason: {
381
+ type: 'string',
382
+ description: '一句话复述用户要同步的范围(会显示在右侧同步弹窗里),必填。',
383
+ },
384
+ paperIds: {
385
+ type: 'array',
386
+ maxItems: 1500,
387
+ items: { type: 'string' },
388
+ description: 'scope=ids 时的确切 paperId 列表。只能填你在工具返回的「paperId:」行里看到的 id,禁止编造或猜测;其余 id 会被忽略。',
389
+ },
390
+ collection: {
391
+ type: 'string',
392
+ description: '用户点名的 Zotero 目标目录(名称或 id,如「综述」「C15」)。省略时右侧栏会弹出同步框、加载好文献,由用户自己选目录再点「同步」;填了则宿主按名称/ id 精确匹配,唯一命中时自动选中并立即开始,找不到或有歧义时本工具不下发指令并返回可用目录清单,让你在对话里向用户确认。不确定目录名就不要猜,直接省略本字段。',
393
+ },
394
+ q: { type: 'string', description: '标题/摘要关键词,用于「同步关于 xx 的文献」。' },
395
+ queryId: { type: 'string', description: '只同步某条检索式命中的文献。' },
396
+ source: { type: 'string', enum: [...SOURCE_IDS], description: '只同步该数据源命中的文献。' },
397
+ hasPdf: {
398
+ type: 'boolean',
399
+ description: '仅有 PDF 直链或已下载的文献(这类才会带 PDF 附件)。',
400
+ },
401
+ missingFullText: { type: 'boolean', description: '只同步尚无可用全文的文献。' },
402
+ },
403
+ required: ['scope', 'reason'],
404
+ additionalProperties: false,
405
+ };
406
+ /**
407
+ * `literature_scope` 的参数:与下载 / 同步的范围字段**完全同构**,只把 reason
408
+ * 设为可选(预览不产生指令,不需要复述)。同构是有意的:模型核对过一次范围后,
409
+ * 直接把这套参数搬给 literature_download / literature_zotero 即可,不会再出现
410
+ * 「预览的口径」和「执行的口径」两套。
411
+ */
412
+ const SCOPE_PARAMETERS = {
413
+ ...DOWNLOAD_PARAMETERS,
414
+ properties: {
415
+ ...DOWNLOAD_PARAMETERS.properties,
416
+ reason: { type: 'string', description: '可选:一句话复述当前范围,仅用于回显。' },
417
+ },
418
+ required: ['scope'],
419
+ additionalProperties: false,
420
+ };
242
421
  /** 构造本插件注册的全部工具(每次注册都新造定义,便于整体替换)。 */
243
422
  export function literatureTools(service) {
244
423
  return [
@@ -328,8 +507,9 @@ export function literatureTools(service) {
328
507
  },
329
508
  {
330
509
  name: 'literature_task',
331
- description: '读取与推进已保存的检索任务。current 读「当前任务」(推荐在执行任何检索/任务操作前先调用,确认这个对话正在用哪个任务;无需 id);list 列出任务;get 读条件、分组、笔记与结果(可按筛读/分组/检索式/关键词过滤);update 改标题、条件、任务笔记与分组;rounds 读该任务的检索轮次历史(每轮时间、条件、发起方、命中数与状态);annotate 批量写入筛读状态与分组;compare 与旧任务按论文身份对比新增/缺失;stats 读语料统计(年份、来源、期刊、作者、类型、语言)。' +
332
- '注意:一个对话只保留一个任务 tab,切换任务由用户在任务列表里完成;run 会在指定任务内开新一轮检索(并把它设为当前任务),与 literature_search 一样受「用户查看结果并明确要求后才能进入下一轮」约束,不能用它绕过该约束自行加搜;也不要用 update 改完条件就立刻 run。适合在检索之后做筛选、整理与汇报。',
510
+ description: '读取与推进已保存的检索任务。current 读「当前任务」(推荐在执行任何检索/任务操作前先调用,确认这个对话正在用哪个任务;无需 id);list 列出任务;get 读条件、分组、笔记与结果(可按筛读/分组/检索式/关键词/年份/来源/类型/语言/全文状态/是否有 PDF 过滤;fields=ids 时每条只回「paperId|标题|有无 PDF」,一次最多 200 条,适合列清单或核对范围);update 改标题、条件、任务笔记与分组;rounds 读该任务的检索轮次历史(每轮时间、条件、发起方、命中数与状态);annotate 批量写入筛读状态与分组;compare 与旧任务按论文身份对比新增/缺失;stats 读语料统计(年份、来源、期刊、作者、类型、语言)。' +
511
+ 'current / get 会回显「面板视图:第 x/y 页|每页 20 条|筛选…」,这是判断用户所说「第几页」的唯一依据;含页码的下载/同步请直接用 literature_download 的 scope=pages,不要靠 get 的 offset 自己拼页码。' +
512
+ '注意:一个对话只保留一个任务 tab,切换任务由用户在任务列表里完成;run 会在指定任务内开新一轮检索(并把它设为当前任务),与 literature_search 一样受「用户查看结果并明确要求后才能进入下一轮」约束,不能用它绕过该约束自行加搜;也不要用 update 改完条件就立刻 run。适合在检索之后做筛选、整理与汇报。用户要求批量下载 PDF 时改用 literature_download,要求批量存入 Zotero 时改用 literature_zotero。',
333
513
  parameters: TASK_PARAMETERS,
334
514
  output: TEXT_OUTPUT,
335
515
  async execute(args, exec) {
@@ -347,6 +527,44 @@ export function literatureTools(service) {
347
527
  return { text: await runExport(service, readExportArgs(args)) };
348
528
  },
349
529
  },
530
+ {
531
+ name: 'literature_scope',
532
+ description: '只读的「范围预演」:把用户的说法(当前页 / 第 2 页 / 第 4–5 页 / 当前筛选全部 / 全部 / 分组 / 条件)算成一份 paperId 清单并回显,**不下载、不同步、不修改任何数据**。' +
533
+ '返回:范围说明、命中篇数、按面板每页 20 条算出的总页数与本次页窗口、面板当前视图(第 x/y 页、筛选条件)、清单前 10 篇(paperId + 是否有 PDF)。' +
534
+ '适合两种情况:(1) 用户提到了页码或范围较模糊,先预演一次确认「第 2 页」到底是哪 20 篇;(2) 执行前核对命中篇数,避免下错范围。参数与 literature_download / literature_zotero 完全一致(scope + pages + pageBase + 筛选字段),核对无误后把它们原样传给下载 / 同步工具即可,不需要搬 paperId。' +
535
+ '注意:页码只能用 scope=pages + pages 表达(1-based,宿主按视图快照换算);不要自己用 offset 推算页码,也不要把 ids 当页码范围。',
536
+ parameters: SCOPE_PARAMETERS,
537
+ output: TEXT_OUTPUT,
538
+ async execute(args, exec) {
539
+ return {
540
+ text: await runScopePreview(service, readScopeArgs(args, false), exec.agent?.id),
541
+ };
542
+ },
543
+ },
544
+ {
545
+ name: 'literature_download',
546
+ description: '在用户要求「帮我下载 XX 文献 / 下载当前页的所有 pdf 文献 / 下载 xx 分组下的文献 / 下载 xx 条件的文献 / 帮我下载当前全部文献」时调用:把范围算成一份已校验的 paperId 清单,下发给右侧栏,右侧栏会自动弹出批量下载框并立即逐篇下载,用户能看到每篇标题、来源与实时状态,也能点「取消下载」中止。' +
547
+ '【范围怎么选】用户点名具体文献且你已在工具结果的「paperId:」行看到确切 id → scope=ids 并给 paperIds;「当前页/这一页」→ scope=currentPage;「第 2 页」「第 4–5 页」→ scope=pages 并给 pages(1-based,页码指面板上那一页,宿主换算,不要自己算 offset);「当前全部/当前筛选下全部」→ scope=currentView(含跨页,不等于整个任务);明确说「整个任务/所有文献/不管筛选」→ scope=all;「xx 分组」→ scope=group + groupId;给了年份、OA、关键词、来源、筛读状态等条件 → scope=filters 并填对应字段。pages 与 hasPdf 同时给时,默认先按面板切页、再在页内留下有 PDF 的(「第二页里有 PDF 的文献」);要「有 PDF 的文献的第 2 页」则传 pageBase=filtered。' +
548
+ '【重要】你看不到右侧栏界面,「当前页」「第 N 页」「当前筛选全部」由宿主用面板上报的视图快照 + 服务端筛选精确计算,不要自己猜页码或编造 paperId;页码不等于任务条件的「每源条数」(面板每页恒为 20 条)。范围不确定时先调 literature_scope 预演,再执行。scope=ids 时只能使用工具结果里出现过的 paperId,无效 id 会被忽略。hasPdf 表示「有 PDF 直链或已下载」,openAccessOnly 表示上游标记为开放获取,两者不等价,不要同时默认开启。' +
549
+ '本工具只下发指令、不等待下载完成,也不要重复调用同一条指令或臆测下载结果;若范围为空会返回原因而不下发。需要检索后再下载时,先按检索工具的轮次约束完成检索,再由用户明确提出下载。用户要求把文献「存入 Zotero / 同步到文献库」时改用 literature_zotero,不要用本工具。',
550
+ parameters: DOWNLOAD_PARAMETERS,
551
+ output: TEXT_OUTPUT,
552
+ async execute(args, exec) {
553
+ return { text: await runDownload(service, readScopeArgs(args), exec.agent?.id) };
554
+ },
555
+ },
556
+ {
557
+ name: 'literature_zotero',
558
+ description: '在用户要求「把这批文献存入 Zotero / 存到『综述』目录 / 把当前页的文献同步到 Zotero / 把 xx 分组的文献存进 Zotero」时调用:把范围算成一份已校验的 paperId 清单,下发给右侧栏,右侧栏复用「存入 Zotero」弹窗逐篇同步,用户能看到每篇标题、来源与实时状态,也能中途取消。' +
559
+ '【目录怎么选】用户点名了目标目录(如「综述」「C15」)→ 填 collection,宿主会在本机 Zotero 目录列表里精确匹配,唯一命中时弹窗自动选中该目录并立即开始同步;没点名 → 省略 collection,弹窗加载好文献和目录后由用户自己选目录再点「同步」(与手动操作完全一致);匹配失败或有歧义、Zotero 没开、同步在设置里关闭时,本工具不下发指令并返回原因与可用目录清单,此时请在对话里向用户确认目录或请其检查 Zotero,绝不猜一个目录就写入。也可以先主动问一句「要存到哪个目录」,但不要把它当成必经步骤。' +
560
+ '【范围怎么选】与 literature_download 完全相同的 7 种 scope 和筛选字段:用户点名具体文献且你已在工具结果的「paperId:」行看到确切 id → scope=ids + paperIds;「当前页」→ currentPage;「第 2 页 / 第 4–5 页」→ scope=pages + pages(1-based,面板上那一页);「当前全部」→ currentView(含跨页,不等于整个任务);「整个任务」→ all;「xx 分组」→ scope=group + groupId;给了年份、OA、关键词、筛读状态等条件 → scope=filters 并填对应字段。你看不到右侧栏界面,「当前页 / 第 N 页 / 当前筛选全部」由宿主用面板上报的视图快照 + 服务端筛选精确计算,不要自己猜页码或编造 paperId;面板每页恒为 20 条,与任务条件的「每源条数」不是一回事。范围不确定时先调 literature_scope 预演,再同步。' +
561
+ '同步会在 Zotero 里新建条目、不会覆盖已有条目,也不要跳过「已同步」的文献;目录只读时只存条目、不带 PDF 附件。本工具只下发指令、不等同步完成,也不要重复调用同一条指令或臆测同步结果;范围为空会返回原因而不下发。需要检索后再同步时,先按检索工具的轮次约束完成检索,再由用户明确提出同步。',
562
+ parameters: ZOTERO_PARAMETERS,
563
+ output: TEXT_OUTPUT,
564
+ async execute(args, exec) {
565
+ return { text: await runZotero(service, readZoteroArgs(args), exec.agent?.id) };
566
+ },
567
+ },
350
568
  ];
351
569
  }
352
570
  /** 把模型给的参数收窄成建任务输入;非法输入抛错给模型看。 */
@@ -560,6 +778,36 @@ function readTaskArgs(args) {
560
778
  const q = readText(record['q'], 'q');
561
779
  if (q !== undefined)
562
780
  input.q = q;
781
+ // get 的筛选字段与下载工具同口径,复用同一个校验函数,避免两套白名单。
782
+ const filter = readScopeFilter(record);
783
+ if (filter.hasPdf === true)
784
+ input.hasPdf = true;
785
+ if (filter.missingFullText === true)
786
+ input.missingFullText = true;
787
+ if (filter.pdfStatus !== undefined)
788
+ input.pdfStatus = filter.pdfStatus;
789
+ if (filter.yearFrom !== undefined)
790
+ input.yearFrom = filter.yearFrom;
791
+ if (filter.yearTo !== undefined)
792
+ input.yearTo = filter.yearTo;
793
+ if (filter.source !== undefined)
794
+ input.source = filter.source;
795
+ if (filter.type !== undefined)
796
+ input.type = filter.type;
797
+ if (filter.language !== undefined)
798
+ input.language = filter.language;
799
+ if (filter.venue !== undefined)
800
+ input.venue = filter.venue;
801
+ if (filter.author !== undefined)
802
+ input.author = filter.author;
803
+ if (filter.newOnly === true)
804
+ input.newOnly = true;
805
+ const fields = record['fields'];
806
+ if (fields !== undefined) {
807
+ if (fields !== 'full' && fields !== 'ids')
808
+ throw new Error('fields 只能是 full / ids');
809
+ input.fields = fields;
810
+ }
563
811
  const scope = record['scope'];
564
812
  if (scope !== undefined) {
565
813
  if (scope !== 'task' && scope !== 'filtered') {
@@ -728,11 +976,168 @@ function readExportArgs(args) {
728
976
  input.queryId = queryId;
729
977
  return input;
730
978
  }
979
+ /**
980
+ * 模型范围参数边界校验:枚举白名单、严格布尔、paperIds 去空去重且保序。
981
+ * 下载 / Zotero 同步 / 范围预演共用;`reason` 只有真正下发指令时才必填。
982
+ */
983
+ function readScopeArgs(args, requireReason = true) {
984
+ const record = asRecord(args);
985
+ const scope = record['scope'];
986
+ if (typeof scope !== 'string' || !DOWNLOAD_SCOPES.includes(scope)) {
987
+ throw new Error(`scope 只能是 ${DOWNLOAD_SCOPES.join(' / ')}`);
988
+ }
989
+ const reason = readText(record['reason'], 'reason');
990
+ if (requireReason && reason === undefined) {
991
+ throw new Error('缺少 reason:请用一句话复述用户要处理的范围');
992
+ }
993
+ const input = {
994
+ scope: scope,
995
+ reason: (reason ?? '').slice(0, 200),
996
+ filter: {},
997
+ };
998
+ const taskId = readText(record['taskId'], 'taskId');
999
+ if (taskId !== undefined)
1000
+ input.taskId = taskId;
1001
+ if (record['paperIds'] !== undefined) {
1002
+ const papers = record['paperIds'];
1003
+ if (!Array.isArray(papers) || !papers.every((item) => typeof item === 'string')) {
1004
+ throw new Error('paperIds 必须是字符串数组');
1005
+ }
1006
+ if (papers.length > 1500)
1007
+ throw new Error('paperIds 最多 1500 条');
1008
+ input.paperIds = [
1009
+ ...new Set(papers.map((id) => id.trim()).filter((id) => id !== '')),
1010
+ ];
1011
+ }
1012
+ if (input.scope === 'ids' && (input.paperIds ?? []).length === 0) {
1013
+ throw new Error('scope=ids 必须给出 paperIds,且只能使用工具结果里出现过的 paperId');
1014
+ }
1015
+ if (record['pages'] !== undefined) {
1016
+ const pages = record['pages'];
1017
+ if (!Array.isArray(pages) || pages.length === 0) {
1018
+ throw new Error('pages 必须是非空的页码数组,例如 [2] 或 [4,5]');
1019
+ }
1020
+ if (pages.length > 20)
1021
+ throw new Error('pages 最多 20 个页码');
1022
+ const parsed = [];
1023
+ for (const value of pages) {
1024
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 500) {
1025
+ throw new Error('pages 里只能是 1–500 的整数页码');
1026
+ }
1027
+ parsed.push(value);
1028
+ }
1029
+ input.pages = [...new Set(parsed)].sort((a, b) => a - b);
1030
+ }
1031
+ if (input.scope === 'pages' && (input.pages ?? []).length === 0) {
1032
+ throw new Error('scope=pages 必须给出 pages(1-based 页码,如 [2] 或 [4,5])');
1033
+ }
1034
+ if (input.scope !== 'pages' && input.pages !== undefined) {
1035
+ throw new Error('pages 只能与 scope=pages 一起使用;「当前页」请用 scope=currentPage');
1036
+ }
1037
+ const pageBase = record['pageBase'];
1038
+ if (pageBase !== undefined) {
1039
+ if (typeof pageBase !== 'string' || !PAGE_BASES.includes(pageBase)) {
1040
+ throw new Error(`pageBase 只能是 ${PAGE_BASES.join(' / ')}`);
1041
+ }
1042
+ input.pageBase = pageBase;
1043
+ }
1044
+ const filter = readScopeFilter(record);
1045
+ if (input.scope === 'group' && filter.groupId === undefined) {
1046
+ throw new Error('scope=group 必须给出 groupId');
1047
+ }
1048
+ input.filter = filter;
1049
+ return input;
1050
+ }
1051
+ /**
1052
+ * 把 `ResultsQuery` / `PaperFilter` 同名键收窄成 `PaperFilter`。
1053
+ *
1054
+ * `literature_task get` 与下载工具共用这一份白名单:任何键只在这里识别一次,
1055
+ * 模型在「读」和「下」两侧看到的筛选口径就不会分叉。
1056
+ */
1057
+ function readScopeFilter(record) {
1058
+ const filter = {};
1059
+ const q = readText(record['q'], 'q');
1060
+ if (q !== undefined)
1061
+ filter.q = q.slice(0, 200);
1062
+ const groupId = readText(record['groupId'], 'groupId');
1063
+ if (groupId !== undefined)
1064
+ filter.groupId = groupId;
1065
+ const queryId = readText(record['queryId'], 'queryId');
1066
+ if (queryId !== undefined)
1067
+ filter.queryId = queryId;
1068
+ const type = readText(record['type'], 'type');
1069
+ if (type !== undefined)
1070
+ filter.type = type.slice(0, 100);
1071
+ const venue = readText(record['venue'], 'venue');
1072
+ if (venue !== undefined)
1073
+ filter.venue = venue.slice(0, 200);
1074
+ const author = readText(record['author'], 'author');
1075
+ if (author !== undefined)
1076
+ filter.author = author.slice(0, 200);
1077
+ const yearFrom = readInteger(record['yearFrom'], 'yearFrom', 3000);
1078
+ if (yearFrom !== undefined)
1079
+ filter.yearFrom = yearFrom;
1080
+ const yearTo = readInteger(record['yearTo'], 'yearTo', 3000);
1081
+ if (yearTo !== undefined)
1082
+ filter.yearTo = yearTo;
1083
+ if (yearFrom !== undefined && yearTo !== undefined && yearFrom > yearTo) {
1084
+ throw new Error('yearFrom 不能大于 yearTo');
1085
+ }
1086
+ for (const key of ['openAccessOnly', 'hasPdf', 'missingFullText', 'newOnly']) {
1087
+ const value = record[key];
1088
+ if (value === undefined)
1089
+ continue;
1090
+ if (typeof value !== 'boolean')
1091
+ throw new Error(`${key} 必须是布尔值`);
1092
+ if (value)
1093
+ filter[key] = true;
1094
+ }
1095
+ const source = record['source'];
1096
+ if (source !== undefined) {
1097
+ if (typeof source !== 'string' || !SOURCE_IDS.includes(source)) {
1098
+ throw new Error(`source 只能是 ${SOURCE_IDS.join(' / ')}`);
1099
+ }
1100
+ filter.source = source;
1101
+ }
1102
+ const language = record['language'];
1103
+ if (language !== undefined) {
1104
+ if (typeof language !== 'string' || !LANGUAGES.includes(language)) {
1105
+ throw new Error('language 不是支持的语言码');
1106
+ }
1107
+ filter.language = language;
1108
+ }
1109
+ const screening = record['screening'];
1110
+ if (screening !== undefined) {
1111
+ if (typeof screening !== 'string' || !SCREENINGS.includes(screening)) {
1112
+ throw new Error(`screening 只能是 ${SCREENINGS.join(' / ')}`);
1113
+ }
1114
+ filter.screening = screening;
1115
+ }
1116
+ const pdfStatus = record['pdfStatus'];
1117
+ if (pdfStatus !== undefined) {
1118
+ if (typeof pdfStatus !== 'string' || !PDF_STATUSES.includes(pdfStatus)) {
1119
+ throw new Error(`pdfStatus 只能是 ${PDF_STATUSES.join(' / ')}`);
1120
+ }
1121
+ filter.pdfStatus = pdfStatus;
1122
+ }
1123
+ return filter;
1124
+ }
1125
+ /** Zotero 额外接受一个自由文本的目录名 / id;空串当作没指定。 */
1126
+ function readZoteroArgs(args) {
1127
+ const base = readScopeArgs(args);
1128
+ const collection = readText(asRecord(args)['collection'], 'collection');
1129
+ if (collection === undefined)
1130
+ return base;
1131
+ return { ...base, collection: collection.slice(0, 120) };
1132
+ }
731
1133
  async function runTaskAction(service, args, signal, sessionId) {
732
1134
  switch (args.action) {
733
1135
  case 'current': {
734
1136
  const task = await service.currentTask();
735
- return formatCurrentTask(task);
1137
+ if (task === undefined)
1138
+ return formatCurrentTask(undefined);
1139
+ const snapshot = await service.getViewSnapshot(sessionId, task.id);
1140
+ return formatCurrentTask(task, describeView(snapshot, snapshot?.total ?? task.result.papers.length));
736
1141
  }
737
1142
  case 'list': {
738
1143
  const tasks = await service.list();
@@ -744,10 +1149,14 @@ async function runTaskAction(service, args, signal, sessionId) {
744
1149
  const detail = await service.getDetail(id);
745
1150
  const page = await service.results(id, {
746
1151
  ...readFilterArgs(args),
747
- limit: args.limit ?? 30,
1152
+ limit: args.fields === 'ids' ? Math.min(args.limit ?? IDS_LIMIT, IDS_LIMIT) : (args.limit ?? 30),
748
1153
  offset: args.offset ?? 0,
749
1154
  });
750
- return formatTaskDetail(detail, page, task.states, task.groups);
1155
+ const snapshot = await service.getViewSnapshot(sessionId, id);
1156
+ const view = describeView(snapshot, page.total);
1157
+ if (args.fields === 'ids')
1158
+ return formatPaperIds(task, page, view);
1159
+ return formatTaskDetail(detail, page, task.states, task.groups, undefined, view);
751
1160
  }
752
1161
  case 'update': {
753
1162
  const task = await service.update(requireId(args), args.patch ?? {});
@@ -810,6 +1219,346 @@ async function runExport(service, args) {
810
1219
  preview,
811
1220
  ].join('\n');
812
1221
  }
1222
+ /** 按界面排序原地排好并返回同一数组,便于内联到 `filterPapers` 的结果上。 */
1223
+ function sortPapersInPlace(papers, sort) {
1224
+ sortPapers(papers, sort);
1225
+ return papers;
1226
+ }
1227
+ async function resolveModelScope(service, args, sessionId, verb) {
1228
+ const task = args.taskId !== undefined ? await service.get(args.taskId) : await service.currentTask();
1229
+ if (task === undefined) {
1230
+ return {
1231
+ ok: false,
1232
+ text: `当前没有检索任务:请先检索文献,或让用户从右侧栏打开一个任务,再让我${verb}。`,
1233
+ };
1234
+ }
1235
+ const snapshot = await service.getViewSnapshot(sessionId, task.id);
1236
+ const needsView = args.scope === 'currentPage' || args.scope === 'currentView' || args.scope === 'pages';
1237
+ if (needsView && snapshot === undefined) {
1238
+ return {
1239
+ ok: false,
1240
+ text: [
1241
+ `任务「${task.title}」(id: ${task.id})暂时读不到右侧栏的当前视图,无法准确判断「当前页 / 第 N 页 / 当前全部」。`,
1242
+ args.scope === 'pages'
1243
+ ? '页码是按面板的筛选、排序与每页 20 条切出来的,缺视图快照就无法换算成同一批文献。'
1244
+ : '',
1245
+ '请让用户先在右侧栏打开该任务的结果页、翻到要看的那一页,再重复这条指令;也可以改用 scope=all 处理整个任务、用 filters 给明确条件、用 ids 指定文献。',
1246
+ `本次没有下发任何${verb}指令。`,
1247
+ ]
1248
+ .filter((line) => line !== '')
1249
+ .join('\n'),
1250
+ };
1251
+ }
1252
+ // 「当前页 / 第 N 页 / 当前全部」以界面筛选打底;其余范围忽略界面筛选,避免
1253
+ // 用户说「把全部文献存进去」时却只处理了筛出来的那批。
1254
+ const viewFilter = needsView ? toPaperFilter(snapshot?.filters ?? {}) : {};
1255
+ const filter = await service.resolveDownloadFilter(task.id, mergeDownloadFilter(viewFilter, args.filter));
1256
+ // 页码切片的基准必须与界面完全一致:排序取快照(缺省回落任务排序),
1257
+ // 且由这一层先排好再交给纯函数切页 —— 下载解析层不引运行时依赖。
1258
+ const sort = snapshot?.sort ?? task.conditions.sort;
1259
+ const viewMatched = args.scope === 'pages' && (args.pageBase ?? 'view') === 'view'
1260
+ ? sortPapersInPlace(filterPapers(task, await service.resolveDownloadFilter(task.id, viewFilter)), sort)
1261
+ : undefined;
1262
+ // 当前页优先用面板直接上报的顺序;缺 paperIds 时按视图筛选与排序重算这一页。
1263
+ let pagePaperIds;
1264
+ if (args.scope === 'currentPage' && snapshot !== undefined) {
1265
+ if (snapshot.pagePaperIds.length > 0) {
1266
+ pagePaperIds = snapshot.pagePaperIds;
1267
+ }
1268
+ else {
1269
+ const page = await service.results(task.id, {
1270
+ ...viewFilter,
1271
+ ...(snapshot.sort === undefined ? {} : { sort: snapshot.sort }),
1272
+ offset: snapshot.pageIndex * snapshot.pageSize,
1273
+ limit: snapshot.pageSize,
1274
+ });
1275
+ pagePaperIds = page.papers.map((paper) => paper.paperId);
1276
+ }
1277
+ }
1278
+ const groupName = args.scope === 'group' && args.filter.groupId !== undefined
1279
+ ? task.groups.find((group) => group.id === args.filter.groupId)?.name
1280
+ : undefined;
1281
+ const matched = args.scope === 'pages'
1282
+ ? sortPapersInPlace(filterPapers(task, filter), sort)
1283
+ : filterPapers(task, filter);
1284
+ const resolved = resolveDownloadScope(task, {
1285
+ scope: args.scope,
1286
+ filter,
1287
+ matched,
1288
+ ...(args.paperIds === undefined ? {} : { explicitIds: args.paperIds }),
1289
+ ...(args.pages === undefined ? {} : { pages: args.pages }),
1290
+ ...(args.pageBase === undefined ? {} : { pageBase: args.pageBase }),
1291
+ ...(snapshot === undefined ? {} : { snapshot }),
1292
+ ...(snapshot === undefined ? {} : { pageSize: snapshot.pageSize }),
1293
+ ...(viewMatched === undefined ? {} : { viewMatched }),
1294
+ ...(pagePaperIds === undefined ? {} : { pagePaperIds }),
1295
+ ...(groupName === undefined ? {} : { groupName }),
1296
+ });
1297
+ if (resolved.outOfRange !== undefined && resolved.outOfRange.length > 0) {
1298
+ const size = snapshot?.pageSize ?? RESULT_PAGE_SIZE;
1299
+ return {
1300
+ ok: false,
1301
+ text: [
1302
+ `第 ${describePages(resolved.outOfRange)} 页超出范围:该任务在当前筛选与排序下共 ${resolved.pageCount} 页(每页 ${size} 条)。`,
1303
+ '请让用户确认页码,或先用 literature_scope 预演一次看清单前几篇;不要自行改动页码后重复下发。',
1304
+ `本次没有下发任何${verb}指令。`,
1305
+ ].join('\n'),
1306
+ };
1307
+ }
1308
+ if (resolved.paperIds.length === 0) {
1309
+ return {
1310
+ ok: false,
1311
+ text: [
1312
+ `没有匹配到可${verb}的文献(${resolved.scopeLabel})。`,
1313
+ `任务「${task.title}」共 ${task.result.papers.length} 篇;本次没有下发任何${verb}指令。`,
1314
+ '请放宽条件、确认页码/分组/年份是否正确,或改用 scope=all;不要重复下发同一条指令。',
1315
+ ].join('\n'),
1316
+ };
1317
+ }
1318
+ return {
1319
+ ok: true,
1320
+ task,
1321
+ resolved,
1322
+ matchedTotal: matched.length,
1323
+ ...(snapshot === undefined ? {} : { snapshot }),
1324
+ };
1325
+ }
1326
+ /** 面板筛选的键名 → 中文短标签;键名沿用 `ResultsQuery`。 */
1327
+ const SNAPSHOT_FILTER_LABELS = {
1328
+ q: '关键词',
1329
+ source: '来源',
1330
+ queryId: '检索式',
1331
+ type: '类型',
1332
+ venue: '出处',
1333
+ author: '作者',
1334
+ openAccessOnly: '仅开放获取',
1335
+ hasPdf: '仅含 PDF',
1336
+ missingFullText: '缺全文',
1337
+ newOnly: '仅新增',
1338
+ screening: '筛读',
1339
+ language: '语言',
1340
+ pdfStatus: '全文状态',
1341
+ groupId: '分组',
1342
+ sort: '排序',
1343
+ };
1344
+ function describeSnapshotFilters(filters) {
1345
+ const parts = [];
1346
+ if (filters['yearFrom'] !== undefined || filters['yearTo'] !== undefined) {
1347
+ parts.push(`${filters['yearFrom'] ?? '不限'}–${filters['yearTo'] ?? '不限'} 年`);
1348
+ }
1349
+ for (const [key, value] of Object.entries(filters)) {
1350
+ if (key === 'yearFrom' || key === 'yearTo')
1351
+ continue;
1352
+ const label = SNAPSHOT_FILTER_LABELS[key];
1353
+ if (label === undefined)
1354
+ continue;
1355
+ parts.push(`${label} ${String(value)}`);
1356
+ }
1357
+ return parts.join('、');
1358
+ }
1359
+ /**
1360
+ * 「你现在看到的是哪一页」——模型判断页码的唯一依据。
1361
+ *
1362
+ * 面板每页条数、排序、筛选都由视图快照决定;没有快照就不猜,直接不输出,
1363
+ * 让 `scope=pages` 走失败分支而不是悄悄按错误的基准切页。
1364
+ */
1365
+ function describeView(snapshot, total) {
1366
+ if (snapshot === undefined)
1367
+ return [];
1368
+ const size = Math.max(1, snapshot.pageSize);
1369
+ const count = total ?? snapshot.total;
1370
+ const pageCount = count === undefined ? undefined : Math.max(1, Math.ceil(count / size));
1371
+ const parts = [
1372
+ `第 ${snapshot.pageIndex + 1}${pageCount === undefined ? '' : `/${pageCount}`} 页`,
1373
+ `每页 ${size} 条`,
1374
+ ];
1375
+ if (count !== undefined)
1376
+ parts.push(`共 ${count} 篇`);
1377
+ const filters = describeSnapshotFilters(snapshot.filters);
1378
+ if (filters !== '')
1379
+ parts.push(`筛选:${filters}`);
1380
+ return [`面板视图:${parts.join('|')}`];
1381
+ }
1382
+ /**
1383
+ * 只读预演:把范围算一遍给模型看,**不下发任何指令**。
1384
+ *
1385
+ * 存在的意义是替掉「模型自己翻页 / 自己搬 paperId」:页码换算、页内再筛、
1386
+ * 越界判断都在这里一次算清,模型只需要核对命中数与标题,再把同一套参数
1387
+ * 原样交给下载 / 同步工具。
1388
+ */
1389
+ async function runScopePreview(service, args, sessionId) {
1390
+ const scope = await resolveModelScope(service, args, sessionId, '处理');
1391
+ if (!scope.ok)
1392
+ return scope.text;
1393
+ const { task, resolved, matchedTotal, snapshot } = scope;
1394
+ const size = Math.max(1, snapshot?.pageSize ?? RESULT_PAGE_SIZE);
1395
+ const pageCount = resolved.pageCount ?? Math.max(1, Math.ceil(matchedTotal / size));
1396
+ const byId = new Map(task.result.papers.map((paper) => [paper.paperId, paper]));
1397
+ const preview = resolved.paperIds.slice(0, 10);
1398
+ // 页窗口按视图切页时,总篇数用面板上报的视图总数;否则用本次筛选命中的篇数。
1399
+ const pageMode = args.scope === 'pages';
1400
+ const totalHint = pageMode && (args.pageBase ?? 'view') === 'view' ? undefined : matchedTotal;
1401
+ const lines = [
1402
+ '范围预演(只读,未下发任何指令):',
1403
+ `任务:${task.title}(id: ${task.id})`,
1404
+ `范围:${resolved.scopeLabel}`,
1405
+ ...describeView(snapshot, totalHint),
1406
+ pageMode
1407
+ ? `页窗口:第 ${describePages(args.pages ?? [])} 页(每页 ${size} 条,共 ${pageCount} 页)→ 与附加条件取交集后 ${resolved.paperIds.length} 篇;附加条件在全任务命中 ${matchedTotal} 篇。`
1408
+ : `命中:该条件下共 ${matchedTotal} 篇(每页 ${size} 条 ≈ ${pageCount} 页)→ 本次范围 ${resolved.paperIds.length} 篇。`,
1409
+ '',
1410
+ `清单前 ${preview.length} 篇:`,
1411
+ ];
1412
+ preview.forEach((paperId, index) => {
1413
+ const paper = byId.get(paperId);
1414
+ const pdf = paper !== undefined && (paper.pdfUrl !== '' || pdfStatusOf(task, paper) === 'downloaded');
1415
+ lines.push(`${index + 1}. ${paper?.title ?? paperId}|paperId: ${paperId}|${pdf ? '有 PDF' : '无 PDF 直链'}`);
1416
+ });
1417
+ if (resolved.paperIds.length > preview.length) {
1418
+ lines.push(`… 还有 ${resolved.paperIds.length - preview.length} 篇,执行时会按同一范围一起处理。`);
1419
+ }
1420
+ lines.push('', '确认无误后,把同一套参数(scope / pages / pageBase / 筛选字段)原样传给 literature_download 或 literature_zotero 即可;不要改用 ids 把 paperId 抄一遍,也不要自己按 offset 推算页码。');
1421
+ return lines.join('\n');
1422
+ }
1423
+ /** 清单前几篇的预览,供模型核对范围有没有选错。 */
1424
+ function previewItems(resolved) {
1425
+ return resolved.items
1426
+ .slice(0, 5)
1427
+ .map((item, index) => `${index + 1}. ${item.title}(${item.source})`)
1428
+ .join('\n');
1429
+ }
1430
+ /**
1431
+ * 「少掉的那些」怎么解释。
1432
+ *
1433
+ * `scope=pages` 的差额是页内不满足附加条件的文献(例如第 2 页 20 条里只有
1434
+ * 10 条有 PDF),属于预期行为,不能写成「id 被忽略」——否则模型会以为页码
1435
+ * 或 id 填错了。
1436
+ */
1437
+ function ignoredNote(resolved, scope) {
1438
+ if (resolved.ignored <= 0)
1439
+ return '';
1440
+ return scope === 'pages'
1441
+ ? `,页内另有 ${resolved.ignored} 篇不满足附加条件已跳过`
1442
+ : `,另有 ${resolved.ignored} 个 id 被忽略(不属于该任务或不满足条件)`;
1443
+ }
1444
+ /**
1445
+ * 把自然语言下载请求算成已校验清单并下发指令。
1446
+ *
1447
+ * 只等服务端把范围算完(毫秒级),不等右侧栏下载完成:下载由 Client 复用
1448
+ * 现有批量下载弹窗执行,模型拿到的只是「已受理」的说明。
1449
+ */
1450
+ async function runDownload(service, args, sessionId) {
1451
+ const scope = await resolveModelScope(service, args, sessionId, '下载');
1452
+ if (!scope.ok)
1453
+ return scope.text;
1454
+ const { task, resolved } = scope;
1455
+ const command = {
1456
+ commandId: randomUUID(),
1457
+ taskId: task.id,
1458
+ ...(sessionId === undefined ? {} : { sessionId }),
1459
+ at: Date.now(),
1460
+ origin: 'model',
1461
+ reason: args.reason,
1462
+ scopeLabel: resolved.scopeLabel,
1463
+ paperIds: resolved.paperIds,
1464
+ items: resolved.items,
1465
+ };
1466
+ // 先请求打开任务 tab,再下发指令;两者在同一次 /events 增量里送到客户端,
1467
+ // 面板即使原本没挂载也能被拉起后再消费指令。
1468
+ service.focusTask(task.id, task.title, sessionId);
1469
+ service.publishDownloadCommand(command);
1470
+ const lines = [
1471
+ `已向右侧栏下发批量下载指令:${resolved.scopeLabel}。`,
1472
+ `任务「${task.title}」(id: ${task.id});范围:${args.reason}`,
1473
+ `清单已逐个校验:${resolved.paperIds.length} 篇属于该任务且满足条件${ignoredNote(resolved, args.scope)}。`,
1474
+ '',
1475
+ '清单前几篇:',
1476
+ previewItems(resolved),
1477
+ '',
1478
+ '右侧栏会自动弹出批量下载框并立即开始,用户能看到每篇标题、来源与实时状态,也可点「取消下载」中止。本工具只下发指令、不等待下载完成;请勿重复调用,也不要替用户臆测下载结果。',
1479
+ ];
1480
+ if (resolved.paperIds.length > 200) {
1481
+ lines.push('', `注意:本次 ${resolved.paperIds.length} 篇数量较大,可能耗时较长并触发上游限流;已下载的会跳过,建议提示用户必要时收窄范围。`);
1482
+ }
1483
+ return lines.join('\n');
1484
+ }
1485
+ /**
1486
+ * 与下载共用范围解析,改由右侧栏复用 Zotero 同步弹窗执行。
1487
+ *
1488
+ * 唯一多出来的一步是目录:点名了目录就先在本机 Zotero 目录列表里精确匹配,
1489
+ * 只有唯一命中才带上 `autoStart` 自动开跑;匹配失败 / 歧义 / Zotero 不可用
1490
+ * 一律不下发,避免往错误的分类里写入数据。
1491
+ */
1492
+ async function runZotero(service, args, sessionId) {
1493
+ if (!getSettings().zoteroEnabled) {
1494
+ return [
1495
+ 'Zotero 同步已在设置中关闭:请让用户先在插件设置里开启「Zotero 同步」再试。',
1496
+ '本次没有下发任何同步指令。',
1497
+ ].join('\n');
1498
+ }
1499
+ const scope = await resolveModelScope(service, args, sessionId, '同步');
1500
+ if (!scope.ok)
1501
+ return scope.text;
1502
+ const { task, resolved } = scope;
1503
+ const targetsResult = await service.zoteroTargets();
1504
+ if (!targetsResult.ok || targetsResult.targets.length === 0) {
1505
+ return [
1506
+ `读取本机 Zotero 目录失败:${targetsResult.error ?? 'Zotero 里没有可用目录'}`,
1507
+ '请让用户启动 Zotero 桌面端、确认允许其他应用与本机通信,并在 Zotero 里准备好可写入的分类后重试。',
1508
+ '本次没有下发任何同步指令(不会在未经确认的情况下写入「我的文库」)。',
1509
+ ].join('\n');
1510
+ }
1511
+ let target;
1512
+ if (args.collection !== undefined) {
1513
+ const match = matchZoteroTarget(targetsResult.targets, args.collection);
1514
+ if (match.kind !== 'matched') {
1515
+ const head = match.kind === 'ambiguous'
1516
+ ? `「${args.collection}」在 Zotero 里匹配到多个目录,无法确定目标:`
1517
+ : `在 Zotero 里没有找到目录「${args.collection}」。可用目录:`;
1518
+ return [
1519
+ head,
1520
+ describeZoteroTargets(match.candidates),
1521
+ '',
1522
+ '请在对话里让用户确认要存到哪个目录,再重新调用本工具并填 collection;也可以不填 collection,让用户直接在右侧同步弹窗里选目录。',
1523
+ '本次没有下发任何同步指令。',
1524
+ ].join('\n');
1525
+ }
1526
+ target = match.target;
1527
+ }
1528
+ const command = {
1529
+ commandId: randomUUID(),
1530
+ taskId: task.id,
1531
+ ...(sessionId === undefined ? {} : { sessionId }),
1532
+ at: Date.now(),
1533
+ origin: 'model',
1534
+ reason: args.reason,
1535
+ scopeLabel: resolved.scopeLabel,
1536
+ paperIds: resolved.paperIds,
1537
+ items: resolved.items,
1538
+ ...(target === undefined ? {} : { target }),
1539
+ autoStart: target !== undefined,
1540
+ };
1541
+ service.focusTask(task.id, task.title, sessionId);
1542
+ service.publishZoteroCommand(command);
1543
+ const targetLine = target === undefined
1544
+ ? '未在指令里指定目录:右侧栏会弹出「存入 Zotero」框并加载好文献与目录,请用户自己选目录再点「同步」。'
1545
+ : `已按指令选中目录「${target.name}」(id: ${target.id})${target.filesEditable === false ? ',该目录不可写附件,只会保存条目' : ''}:右侧栏会自动预选并立即开始同步。`;
1546
+ const lines = [
1547
+ `已向右侧栏下发批量存入 Zotero 指令:${resolved.scopeLabel}。`,
1548
+ `任务「${task.title}」(id: ${task.id});范围:${args.reason}`,
1549
+ `清单已逐个校验:${resolved.paperIds.length} 篇属于该任务且满足条件${ignoredNote(resolved, args.scope)}。`,
1550
+ '',
1551
+ '清单前几篇:',
1552
+ previewItems(resolved),
1553
+ '',
1554
+ targetLine,
1555
+ '用户能在弹窗里看到每篇标题、来源与实时状态,也可中途取消;重新同步会在 Zotero 里新建条目,不会覆盖已有条目。本工具只下发指令、不等同步完成;请勿重复调用,也不要替用户臆测同步结果。',
1556
+ ];
1557
+ if (resolved.paperIds.length > 200) {
1558
+ lines.push('', `注意:本次 ${resolved.paperIds.length} 篇数量较大,可能耗时较长;建议提示用户必要时收窄范围。`);
1559
+ }
1560
+ return lines.join('\n');
1561
+ }
813
1562
  function readFilterArgs(args) {
814
1563
  const filter = {};
815
1564
  if (args.screening !== undefined)
@@ -820,6 +1569,28 @@ function readFilterArgs(args) {
820
1569
  filter.queryId = args.queryId;
821
1570
  if (args.q !== undefined && args.q.trim() !== '')
822
1571
  filter.q = args.q.trim();
1572
+ if (args.hasPdf === true)
1573
+ filter.hasPdf = true;
1574
+ if (args.missingFullText === true)
1575
+ filter.missingFullText = true;
1576
+ if (args.pdfStatus !== undefined)
1577
+ filter.pdfStatus = args.pdfStatus;
1578
+ if (args.yearFrom !== undefined)
1579
+ filter.yearFrom = args.yearFrom;
1580
+ if (args.yearTo !== undefined)
1581
+ filter.yearTo = args.yearTo;
1582
+ if (args.source !== undefined)
1583
+ filter.source = args.source;
1584
+ if (args.type !== undefined)
1585
+ filter.type = args.type;
1586
+ if (args.language !== undefined)
1587
+ filter.language = args.language;
1588
+ if (args.venue !== undefined)
1589
+ filter.venue = args.venue;
1590
+ if (args.author !== undefined)
1591
+ filter.author = args.author;
1592
+ if (args.newOnly === true)
1593
+ filter.newOnly = true;
823
1594
  return filter;
824
1595
  }
825
1596
  function requireId(args) {
@@ -837,7 +1608,7 @@ function readText(value, name, allowEmpty = false) {
837
1608
  return allowEmpty ? value : value.trim();
838
1609
  }
839
1610
  /** 「当前任务」的轻量快照:模型在执行检索/任务操作前先确认这个对话正在用哪个任务。 */
840
- function formatCurrentTask(task) {
1611
+ function formatCurrentTask(task, view = []) {
841
1612
  if (task === undefined) {
842
1613
  return [
843
1614
  '当前没有任务:这个对话还没有任何检索任务。',
@@ -851,6 +1622,7 @@ function formatCurrentTask(task) {
851
1622
  const lines = [
852
1623
  `当前任务:${task.title}(id: ${task.id})`,
853
1624
  `状态:${statusLabel(task.status)}${task.error === undefined ? '' : `(${task.error})`}`,
1625
+ ...view,
854
1626
  `检索轮次:共 ${task.rounds.length} 轮${last === undefined
855
1627
  ? ''
856
1628
  : `|最近一轮 ${last.createdAt.slice(0, 19).replace('T', ' ')}(${last.origin === 'model' ? '模型' : '面板'})`}`,
@@ -872,7 +1644,7 @@ function formatTaskList(tasks, limit) {
872
1644
  }
873
1645
  return lines.join('\n');
874
1646
  }
875
- function formatTaskDetail(detail, page, states, groups, outcome) {
1647
+ function formatTaskDetail(detail, page, states, groups, outcome, view = []) {
876
1648
  const lines = [`任务:${detail.title}(id: ${detail.id},rev=${detail.rev})`];
877
1649
  lines.push(outcome === 'timeout'
878
1650
  ? '状态:仍在检索中,已先返回当前结果;任务会继续在右侧栏完成。'
@@ -887,6 +1659,8 @@ function formatTaskDetail(detail, page, states, groups, outcome) {
887
1659
  }
888
1660
  const screening = detail.stats.screening;
889
1661
  lines.push(`漏斗:原始 ${detail.stats.rawHits}|去重 ${detail.stats.deduped}|已加载 ${detail.result.totalLoaded}|纳入 ${screening.included}、排除 ${screening.excluded}、待读 ${screening.unread}、已读 ${screening.read}`);
1662
+ if (view.length > 0)
1663
+ lines.push(...view);
890
1664
  if (groups.length > 0) {
891
1665
  const usage = new Map();
892
1666
  for (const state of Object.values(states)) {
@@ -915,6 +1689,8 @@ function formatTaskDetail(detail, page, states, groups, outcome) {
915
1689
  paper.citedBy === null ? '' : `被引 ${paper.citedBy}`,
916
1690
  ].filter((part) => part !== '');
917
1691
  lines.push(`${page.offset + index + 1}. [${marks.join('|')}] ${paper.title}`);
1692
+ // 供 literature_download 的 scope=ids 引用;模型不得编造这个 id。
1693
+ lines.push(` paperId: ${paper.paperId}`);
918
1694
  if (meta.length > 0)
919
1695
  lines.push(` ${meta.join(' | ')}`);
920
1696
  if (paper.abstract !== '')
@@ -925,6 +1701,27 @@ function formatTaskDetail(detail, page, states, groups, outcome) {
925
1701
  }
926
1702
  return lines.join('\n');
927
1703
  }
1704
+ /**
1705
+ * `fields=ids`:一行一篇的紧凑清单。
1706
+ *
1707
+ * 只给「序号|paperId|标题|有无 PDF」,方便模型核对范围或向用户复述篇数;
1708
+ * 需要摘要时用默认的 full 模式。一次最多 `IDS_LIMIT` 条,超出用 offset 续读。
1709
+ */
1710
+ function formatPaperIds(task, page, view) {
1711
+ const lines = [
1712
+ `结果清单(ids 模式):筛选后共 ${page.total} 篇,以下第 ${page.offset + 1}–${page.offset + page.papers.length} 篇`,
1713
+ ...view,
1714
+ '',
1715
+ ];
1716
+ page.papers.forEach((paper, index) => {
1717
+ const pdf = paper.pdfUrl !== '' || pdfStatusOf(task, paper) === 'downloaded';
1718
+ lines.push(`${page.offset + index + 1}. ${paper.paperId}|${truncate(paper.title, 120)}|${pdf ? '有PDF' : '无PDF'}`);
1719
+ });
1720
+ if (page.total > page.offset + page.papers.length) {
1721
+ lines.push('', `还有 ${page.total - page.offset - page.papers.length} 篇;用 offset 继续读,或直接用 scope=pages 让宿主按页取(不必搬 id)。`);
1722
+ }
1723
+ return lines.join('\n');
1724
+ }
928
1725
  function formatCompare(report) {
929
1726
  const { counts } = report;
930
1727
  const lines = [
@@ -1048,6 +1845,8 @@ function formatTaskResult(detail, page, outcome, reused) {
1048
1845
  page.papers.forEach((paper, index) => {
1049
1846
  lines.push('');
1050
1847
  lines.push(`${index + 1}. ${paper.title}`);
1848
+ // 供 literature_download 的 scope=ids 引用;模型不得编造这个 id。
1849
+ lines.push(` paperId: ${paper.paperId}`);
1051
1850
  const meta = [
1052
1851
  paper.authors.slice(0, 6).join(', '),
1053
1852
  paper.year === null ? '' : String(paper.year),