helixlife-v5-cli 1.2.1 → 1.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helixlife-v5-cli",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "Helix(vip.helixlife.cn)精细化浏览器自动化命令行工具。",
5
5
  "main": "helix-cli.js",
6
6
  "bin": {
@@ -155,7 +155,7 @@
155
155
  | 对话 · 新建会话 | S-09 |
156
156
  | 工作站 · 新建任务弹窗 | S-10 |
157
157
  | 工作台 · 进入子工作台 | S-11 |
158
- | 工作台 · 综述全流程(拟定主题→精修) | WB-1 |
158
+ | 工作台 · 综述全流程(拟定主题→精修);**扩写/润色/校对/翻译等必先定页+run-code 脚本** | WB-1 |
159
159
  | 工作台 · SCI 论著全流程 | WB-2 |
160
160
  | 工作台 · 国自然基金全流程 | WB-3 |
161
161
  | 工作台 · 临床申请书全流程 | WB-4 |
@@ -407,6 +407,76 @@ div.side-menu--item[to='/edu/courses']
407
407
 
408
408
  **取消全文生成弹窗**:同 dialog 内 `getByRole('button', { name: '取 消' })` 或 `Close`。
409
409
 
410
+ **WB-1 · 用户说法与脚本路由(Agent 硬门禁)**
411
+
412
+ > 用户说「扩写前言」却被做成「添加子级」的根因:**同一动词在不同步骤页含义不同**,且 outline 树按钮默认隐藏(须 hover)。**必须先** `eval location.pathname`,**必须优先** `run-code` 下表脚本,禁止从 snapshot 猜「添加子级」。
413
+
414
+ **0 · 定页(第一步,不可跳过)**
415
+
416
+ ```bash
417
+ helixlife-v5-cli --raw eval "location.pathname"
418
+ ```
419
+
420
+ **1 · 说法 → 脚本(jova 综述)**
421
+
422
+ | 当前 `pathname` | 用户说法(示例) | 脚本(工作区 `scripts/`) | Agent 改动的变量 |
423
+ | --- | --- | --- | --- |
424
+ | `…/jova/outline` | `扩写前言`、`扩写 前言`、`精简结论`、`润色 1. 乳腺癌…` | `workbench-jova-outline-regenerate-node.js` | `intent`(动词+章节;支持连写或空格) |
425
+ | `…/jova/proofread` | `校对 前言`、`确认校对`、`AI 改写`、`替换原文` | `workbench-jova-proofread-section.js` | `intent` + `action` |
426
+ | `…/jova/writing` | `扩写 前言`、`翻译 前言`、`润色 摘要`、`降重`、`文献列表` | `workbench-jova-writing-tool.js` | `intent`(**工具与章节间建议空格**:`扩写 前言`) |
427
+
428
+ 执行:`helixlife-v5-cli run-code --filename=scripts/<上表脚本名>`
429
+
430
+ **2 · outline 页:扩写 ≠ 添加子级(反模式)**
431
+
432
+ 确认提纲页左侧为**提纲树**(非正文)。节点行 `aria-label` 含「操作区」;**移入前**行内按钮不可见。移入后出现:
433
+
434
+ | 按钮 | 作用 | 用户何时才会要 |
435
+ | --- | --- | --- |
436
+ | **编辑** | 右侧「重新生成」→ 用户需求(扩写/精简/润色等)→ 一键生成 → 替换 → 保存 | **扩写前言、润色某节**等改**节点文字** |
437
+ | 添加同级 | 树结构新增同级节点 | 用户明确说「加同级提纲」 |
438
+ | **添加子级** | 树结构新增子节点 | 用户明确说「加子标题/子条目」 |
439
+ | 删除 | 删节点 | 用户明确说删除 |
440
+
441
+ **禁止**:将「扩写 / 精简 / 润色 / 改写 + 章节名」映射为「添加子级」或「添加同级」。前者是 **AI 重写该节点正文**(`编辑` 路径),后者只改提纲层级。
442
+
443
+ `outline-regenerate-node.js` 已实现:定位 `[aria-label*="操作区"]` 且首列文案匹配章节 → hover → **编辑**(不点添加子级)。
444
+
445
+ **3 · writing 页:右侧 AI 工具栏**
446
+
447
+ 精修页中央为富文本 `textbox`;**右侧竖栏**文案:校对、文风、润色、降重、翻译、改写、**扩写**、缩写、文献列表。用户「扩写前言」在 **writing** 页时:选章节 → 点栏内 **扩写** → 一键生成(走 `writing-tool.js`),**不是** outline 树操作。
448
+
449
+ | 工具 | 特殊项 |
450
+ | --- | --- |
451
+ | 文风 | `getByRole('textbox', { name: '参考文风' })` **必填** |
452
+ | 文献列表 | 仅打开列表,无一键生成 |
453
+ | 其余 7 项 | 原文内容 + `getByRole('button', { name: '一键生成' })` + 生成结果 + `替换原文` |
454
+
455
+ **4 · 生成后门禁**
456
+
457
+ - 脚本返回 `needsReplaceChoice` 或用户未说「替换原文」:**禁止**自动点「替换原文」。
458
+ - proofread 脚本:用户仅说「校对 xxx」时 **禁止**自动「确认校对」。
459
+ - writing · 文风:`参考文风` 为必填;`writing-tool.js` 会用 `referenceStyle` 或选中段落兜底。
460
+
461
+ **5 · 禁止的低效路径(导致 Agent 数分钟仍失败)**
462
+
463
+ 1. 在 **outline** 页对用户「扩写/润色/精简 + 章节」点「添加子级」或「添加同级」——**永远不对**。
464
+ 2. 不 `eval location.pathname` 就在 **proofread / writing** 页找提纲树「编辑」或「添加子级」。
465
+ 3. 不用 `run-code --filename=scripts/workbench-jova-*.js`,改用手动 `snapshot` + `click` 拼 hover 才出现的图标按钮。
466
+ 4. 同一轮任务内反复 `snapshot` 猜 `e*` ref 定位提纲节点(须脚本内 `aria-label*="操作区"` + 章节文案匹配)。
467
+
468
+ **6 · 执行命令模板**
469
+
470
+ ```bash
471
+ # ① 定页(不可跳过)
472
+ helixlife-v5-cli --raw eval "location.pathname"
473
+
474
+ # ② 按上表改脚本内 intent / action 后执行,示例:
475
+ helixlife-v5-cli run-code --filename=scripts/workbench-jova-outline-regenerate-node.js
476
+ ```
477
+
478
+ 脚本路径相对于**打开浏览器时的工作区根**(helix-cli 仓库即 `scripts/...`)。
479
+
410
480
  ---
411
481
 
412
482
  ##### WB-2:SCI 论著工作台(`sci`)
@@ -0,0 +1,154 @@
1
+ /**
2
+ * 综述工作台(jova)· 确认提纲页(/workbench/jova/outline)· 节点 AI 重新生成
3
+ *
4
+ * 【页面门禁】仅用于 pathname 含 /workbench/jova/outline;writing 页扩写走 writing-tool.js。
5
+ *
6
+ * 【禁止误判】用户「扩写前言」= 本脚本(hover → 编辑 → 重新生成),绝不是提纲树「添加子级」。
7
+ *
8
+ * 典型场景:用户说「扩写前言」「扩写 前言」「精简结论」「润色 1. xxx」等。
9
+ *
10
+ * 流程:
11
+ * 悬停提纲节点 → 编辑(不点添加子级/添加同级)→ 填写「用户需求」→「一键生成」
12
+ * → 等待「生成结果」→「替换原文」→ Popconfirm「确 定」→「保 存」
13
+ *
14
+ * 用法(仅需改 intent 一行,或显式指定 nodeTitle / userRequirement):
15
+ * helixlife-v5-cli run-code --filename=scripts/workbench-jova-outline-regenerate-node.js
16
+ *
17
+ * intent 解析规则(动词与章节可连写或空格):
18
+ * 「扩写前言」「扩写 前言」 → nodeTitle=前言, userRequirement=扩写
19
+ * 「精简结论」 → nodeTitle=结论, userRequirement=精简
20
+ * 「润色摘要:」 → nodeTitle=摘要:, userRequirement=润色
21
+ * 无法解析时整句作为 userRequirement,nodeTitle 回退为「前言」
22
+ */
23
+ async page => {
24
+ // ── Agent 通常只改这一行 ──
25
+ const intent = '扩写前言';
26
+
27
+ // 也可显式覆盖(非 null 时优先于 intent 解析)
28
+ const nodeTitleOverride = null;
29
+ const userRequirementOverride = null;
30
+
31
+ function parseIntent(raw) {
32
+ const verbs = ['扩写', '精简', '改写', '润色', '重写', '优化'];
33
+ const text = raw.trim();
34
+ for (const verb of verbs) {
35
+ if (text.startsWith(verb)) {
36
+ const title = text.slice(verb.length).trim();
37
+ if (title) return { nodeTitle: title, userRequirement: verb };
38
+ }
39
+ }
40
+ return { nodeTitle: '前言', userRequirement: text };
41
+ }
42
+
43
+ const parsed = parseIntent(intent);
44
+ const nodeTitle = nodeTitleOverride ?? parsed.nodeTitle;
45
+ const userRequirement = userRequirementOverride ?? parsed.userRequirement;
46
+
47
+ const main = page.locator('main');
48
+
49
+ if (!page.url().includes('/workbench/jova/outline')) {
50
+ return {
51
+ ok: false,
52
+ reason: '当前不在综述工作台确认提纲页 /workbench/jova/outline',
53
+ intent,
54
+ nodeTitle,
55
+ userRequirement,
56
+ };
57
+ }
58
+
59
+ async function resetEditingState() {
60
+ const closeIcons = main.getByRole('img', { name: 'close' });
61
+ for (let i = 0; i < (await closeIcons.count()); i++) {
62
+ const icon = closeIcons.nth(i);
63
+ if (await icon.isVisible()) {
64
+ await icon.click();
65
+ await page.waitForTimeout(400);
66
+ }
67
+ }
68
+ const cancelReplace = main.getByRole('button', { name: '取 消' });
69
+ if ((await cancelReplace.count()) > 0 && (await cancelReplace.first().isVisible())) {
70
+ await cancelReplace.first().click();
71
+ await page.waitForTimeout(400);
72
+ }
73
+ const saveBtn = main.getByRole('button', { name: '保 存' });
74
+ if ((await saveBtn.count()) > 0 && (await saveBtn.first().isVisible())) {
75
+ await saveBtn.first().click();
76
+ await page.waitForTimeout(400);
77
+ }
78
+ }
79
+
80
+ function outlineNode(title) {
81
+ const base = title.replace(/[::]\s*$/, '');
82
+ const titleRe = new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[::]?$`);
83
+ return main
84
+ .locator('[aria-label*="操作区"]')
85
+ .filter({
86
+ has: page.locator(':scope > *').first().filter({ hasText: titleRe }),
87
+ })
88
+ .first();
89
+ }
90
+
91
+ await resetEditingState();
92
+
93
+ const opArea = outlineNode(nodeTitle);
94
+ if ((await opArea.count()) === 0) {
95
+ return {
96
+ ok: false,
97
+ reason: `未找到提纲节点「${nodeTitle}」`,
98
+ intent,
99
+ nodeTitle,
100
+ userRequirement,
101
+ };
102
+ }
103
+
104
+ // 1. 悬停 → 编辑
105
+ await opArea.hover();
106
+ await page.waitForTimeout(400);
107
+ await opArea.getByRole('button', { name: '编辑' }).click();
108
+
109
+ // 2. 右侧「重新生成」面板
110
+ const regenTitle = main.getByText('重新生成', { exact: true });
111
+ await regenTitle.waitFor({ state: 'visible', timeout: 10000 });
112
+
113
+ const userReqBox = main.getByRole('textbox', { name: '用户需求' });
114
+ await userReqBox.waitFor({ state: 'visible', timeout: 5000 });
115
+ await userReqBox.fill(userRequirement);
116
+
117
+ // 3. 一键生成 → 等待「替换原文」可用
118
+ await main.getByRole('button', { name: '一键生成' }).click();
119
+ const replaceBtn = main.getByRole('button', { name: '替换原文' });
120
+ await replaceBtn.waitFor({ state: 'visible', timeout: 120000 });
121
+ await page.waitForTimeout(800);
122
+
123
+ // 4. 替换原文 → Popconfirm「是否将本次生成结果替换原文?」→ 确 定
124
+ await replaceBtn.click();
125
+ const confirmPrompt = main.getByText('是否将本次生成结果替换原文?', { exact: true });
126
+ await confirmPrompt.waitFor({ state: 'visible', timeout: 10000 });
127
+ await main.getByRole('button', { name: '确 定' }).click();
128
+ await page.waitForTimeout(800);
129
+
130
+ // 5. 左侧内联编辑器「保 存」
131
+ const saveBtn = main.getByRole('button', { name: '保 存' });
132
+ await saveBtn.waitFor({ state: 'visible', timeout: 10000 });
133
+ const inlineEditor = saveBtn.locator('xpath=ancestor::li[1]//textarea | ancestor::li[1]//input[@type="text"] | ancestor::li[1]//*[@role="textbox"]').first();
134
+ const editorText = (await inlineEditor.inputValue().catch(() => '')) || (await inlineEditor.textContent().catch(() => '')) || '';
135
+
136
+ await saveBtn.click();
137
+ await page.waitForTimeout(1000);
138
+
139
+ const panelClosed = !(await regenTitle.isVisible().catch(() => false));
140
+ const nodePresent = (await outlineNode(nodeTitle).count()) > 0;
141
+ const contentApplied = editorText.includes(nodeTitle) || editorText.length > 0;
142
+
143
+ return {
144
+ ok: nodePresent && panelClosed && contentApplied,
145
+ intent,
146
+ nodeTitle,
147
+ userRequirement,
148
+ panelClosed,
149
+ nodePresent,
150
+ contentApplied,
151
+ editorTextLength: editorText.length,
152
+ url: page.url(),
153
+ };
154
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * 综述工作台(jova)· 初稿校对页 · 章节校对 / AI 改写
3
+ *
4
+ * Agent 调用约定(严格遵守):
5
+ *
6
+ * 1) 用户只说「校对 <章节>」、未指明操作时:
7
+ * action = null → 仅滚动定位 → 悬停 → 点「点击本段进入编辑校对」或「重新编辑」
8
+ * → 返回 needsUserChoice,由 Agent 询问用户选「确认校对」或「AI 改写」
9
+ * → 禁止自动点「确认校对」
10
+ *
11
+ * 2) 用户明确说「确认校对」:action = 'confirm'
12
+ *
13
+ * 3) 用户明确说「AI 改写」:
14
+ * action = 'ai_rewrite' → 点「AI 改写」→(有额外需求则填写)→「一键重写」→ 等待生成
15
+ * → 返回 needsReplaceChoice,提醒用户是否替换原文,禁止自动点「替换原文」
16
+ *
17
+ * 4) 用户明确同意替换原文:action = 'replace'
18
+ *
19
+ * 用法:
20
+ * helixlife-v5-cli run-code --filename=scripts/workbench-jova-proofread-section.js
21
+ */
22
+ async page => {
23
+ const intent = '校对 1. 乳腺癌分子分型体系及其分子特征';
24
+ const action = null; // null | 'confirm' | 'ai_rewrite' | 'replace'
25
+ const extraRequirement = ''; // 仅 ai_rewrite 时可选
26
+
27
+ const main = page.locator('main');
28
+
29
+ function parseIntent(raw) {
30
+ const m = raw.match(/^校对\s*(.+)$/);
31
+ return { sectionTitle: (m ? m[1] : raw).trim() };
32
+ }
33
+
34
+ const { sectionTitle } = parseIntent(intent);
35
+
36
+ if (!page.url().includes('/workbench/jova/proofread')) {
37
+ return {
38
+ ok: false,
39
+ reason: '当前不在综述工作台初稿校对页 /workbench/jova/proofread',
40
+ intent,
41
+ sectionTitle,
42
+ };
43
+ }
44
+
45
+ async function dismissPopconfirmOnly() {
46
+ const cancel = main.getByRole('button', { name: '取 消' });
47
+ if ((await cancel.count()) > 0 && (await cancel.first().isVisible())) {
48
+ await cancel.first().click();
49
+ await page.waitForTimeout(300);
50
+ }
51
+ }
52
+
53
+ function sectionHeading(title) {
54
+ return main.getByRole('heading', { name: title, exact: true }).first();
55
+ }
56
+
57
+ function sectionBlock(title) {
58
+ return sectionHeading(title).locator('xpath=ancestor::div[contains(@class,"group")][1]');
59
+ }
60
+
61
+ async function navigateToSection(title) {
62
+ const toc = main.locator('[class*="cursor-pointer"]').getByText(title, { exact: true });
63
+ if ((await toc.count()) > 0) {
64
+ await toc.first().click({ timeout: 3000 }).catch(() => {});
65
+ await page.waitForTimeout(500);
66
+ }
67
+ const heading = sectionHeading(title);
68
+ if ((await heading.count()) === 0) {
69
+ return { ok: false, reason: `未找到章节「${title}」` };
70
+ }
71
+ await heading.scrollIntoViewIfNeeded();
72
+ await page.waitForTimeout(400);
73
+ return { ok: true };
74
+ }
75
+
76
+ async function detectProofreadStatus(block) {
77
+ const pending = block.getByText('请校对本段内容', { exact: true });
78
+ const done = block.getByText('本段完成校对', { exact: true });
79
+ if ((await pending.count()) > 0) return 'pending';
80
+ if ((await done.count()) > 0) return 'done';
81
+ return 'completed_or_editing';
82
+ }
83
+
84
+ async function clickEnterEdit(block) {
85
+ const enterLabels = [
86
+ '点击本段进入编辑校对',
87
+ '点击本段进行校对',
88
+ '点击本段进行编辑校对',
89
+ ];
90
+ for (const label of enterLabels) {
91
+ const btn = block.getByText(label, { exact: true });
92
+ if ((await btn.count()) > 0 && (await btn.first().isVisible())) {
93
+ await btn.first().click();
94
+ return true;
95
+ }
96
+ }
97
+ return false;
98
+ }
99
+
100
+ async function openSectionEdit(title) {
101
+ const nav = await navigateToSection(title);
102
+ if (!nav.ok) return nav;
103
+
104
+ const block = sectionBlock(title);
105
+
106
+ if ((await block.getByRole('button', { name: '确认校对' }).count()) > 0) {
107
+ return { ok: true, proofreadStatus: 'already_editing' };
108
+ }
109
+
110
+ const proofreadStatus = await detectProofreadStatus(block);
111
+
112
+ if (proofreadStatus === 'pending') {
113
+ await block.getByText('请校对本段内容', { exact: true }).first().hover();
114
+ await page.waitForTimeout(600);
115
+ if (!(await clickEnterEdit(block))) {
116
+ return { ok: false, reason: '未找到「点击本段进入编辑校对」入口', proofreadStatus };
117
+ }
118
+ } else if (proofreadStatus === 'done') {
119
+ await block.getByText('本段完成校对', { exact: true }).first().hover();
120
+ await page.waitForTimeout(600);
121
+ const reEdit = block.getByText('重新编辑', { exact: true });
122
+ if ((await reEdit.count()) === 0 || !(await reEdit.first().isVisible())) {
123
+ return { ok: false, reason: '未找到「重新编辑」入口', proofreadStatus };
124
+ }
125
+ await reEdit.first().click();
126
+ } else {
127
+ await block.hover();
128
+ await page.waitForTimeout(600);
129
+ const reEdit = block.getByText('重新编辑', { exact: true });
130
+ if ((await reEdit.count()) > 0 && (await reEdit.first().isVisible())) {
131
+ await reEdit.first().click();
132
+ } else if (await clickEnterEdit(block)) {
133
+ // ok
134
+ } else {
135
+ return { ok: false, reason: '无法进入编辑模式(未找到入口)', proofreadStatus };
136
+ }
137
+ }
138
+
139
+ await block.getByRole('button', { name: '确认校对' }).waitFor({ state: 'visible', timeout: 10000 });
140
+ await page.waitForTimeout(400);
141
+ return { ok: true, proofreadStatus };
142
+ }
143
+
144
+ async function confirmProofread(title) {
145
+ const block = sectionBlock(title);
146
+ await block.getByRole('button', { name: '确认校对' }).click();
147
+ await page.waitForTimeout(1000);
148
+ return { ok: true };
149
+ }
150
+
151
+ async function waitReplaceReady(timeoutMs = 120000) {
152
+ const replaceBtn = main.getByRole('button', { name: '替换原文' });
153
+ await replaceBtn.waitFor({ state: 'visible', timeout: timeoutMs });
154
+ const deadline = Date.now() + timeoutMs;
155
+ while (Date.now() < deadline) {
156
+ if (await replaceBtn.isEnabled()) return replaceBtn;
157
+ await page.waitForTimeout(1000);
158
+ }
159
+ throw new Error('等待「替换原文」可点击超时');
160
+ }
161
+
162
+ async function aiRewriteGenerate(title, extra) {
163
+ const block = sectionBlock(title);
164
+ await block.getByRole('button', { name: /AI\s*改写/ }).click();
165
+ await main.getByText('原文内容', { exact: true }).waitFor({ state: 'visible', timeout: 10000 });
166
+
167
+ if (extra) {
168
+ const extraBox = main.getByRole('textbox', { name: '额外需求' });
169
+ if ((await extraBox.count()) > 0) await extraBox.fill(extra);
170
+ }
171
+
172
+ await main.getByRole('button', { name: '一键重写' }).click();
173
+ await waitReplaceReady();
174
+
175
+ return { ok: true, generated: true, replaceReady: true };
176
+ }
177
+
178
+ async function aiRewriteReplace() {
179
+ const replaceBtn = main.getByRole('button', { name: '替换原文' });
180
+ await replaceBtn.click();
181
+ await main.getByText('是否将本次生成结果替换原文?', { exact: true }).waitFor({ state: 'visible', timeout: 10000 });
182
+ await main.getByRole('button', { name: '确 定' }).click();
183
+ await page.waitForTimeout(1000);
184
+ return { ok: true, replaced: true };
185
+ }
186
+
187
+ await dismissPopconfirmOnly();
188
+
189
+ const needOpenEdit = action === null || action === '' || action === undefined || action === 'confirm' || action === 'ai_rewrite';
190
+ let opened = { ok: true, proofreadStatus: 'already_editing' };
191
+
192
+ if (needOpenEdit && action !== 'replace') {
193
+ opened = await openSectionEdit(sectionTitle);
194
+ if (!opened.ok) return { ok: false, intent, sectionTitle, ...opened };
195
+ }
196
+
197
+ if (action === null || action === undefined || action === '') {
198
+ return {
199
+ ok: true,
200
+ needsUserChoice: true,
201
+ intent,
202
+ sectionTitle,
203
+ proofreadStatus: opened.proofreadStatus,
204
+ availableActions: ['confirm', 'ai_rewrite'],
205
+ message: '已进入该段编辑模式。请选择:① 确认校对(直接完成本段校对)② AI 改写(AI 重新生成内容)',
206
+ url: page.url(),
207
+ };
208
+ }
209
+
210
+ if (action === 'confirm') {
211
+ await confirmProofread(sectionTitle);
212
+ const block = sectionBlock(sectionTitle);
213
+ const afterStatus = await detectProofreadStatus(block);
214
+ return {
215
+ ok: true,
216
+ intent,
217
+ sectionTitle,
218
+ action,
219
+ proofreadStatusBefore: opened.proofreadStatus,
220
+ proofreadStatusAfter: afterStatus,
221
+ message: '本段已确认校对完成。',
222
+ url: page.url(),
223
+ };
224
+ }
225
+
226
+ if (action === 'ai_rewrite') {
227
+ const generated = await aiRewriteGenerate(sectionTitle, extraRequirement);
228
+ return {
229
+ ok: generated.ok,
230
+ intent,
231
+ sectionTitle,
232
+ action,
233
+ extraRequirement: extraRequirement || null,
234
+ generated: true,
235
+ needsReplaceChoice: true,
236
+ availableActions: ['replace'],
237
+ message:
238
+ 'AI 改写结果已生成。请在右侧查看「生成结果」,确认无误后告知我「替换原文」以应用;若不满意可直接在页面调整或重新生成。',
239
+ url: page.url(),
240
+ };
241
+ }
242
+
243
+ if (action === 'replace') {
244
+ const replaced = await aiRewriteReplace();
245
+ return {
246
+ ok: replaced.ok,
247
+ intent,
248
+ sectionTitle,
249
+ action,
250
+ replaced: true,
251
+ message: '已替换原文。如需完成本段校对,请明确告知「确认校对」。',
252
+ url: page.url(),
253
+ };
254
+ }
255
+
256
+ return {
257
+ ok: false,
258
+ reason: `未知 action「${action}」,应为 null | confirm | ai_rewrite | replace`,
259
+ intent,
260
+ sectionTitle,
261
+ };
262
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * 综述工作台(jova)· 初稿精修页(/workbench/jova/writing)· 右侧 AI 工具
3
+ *
4
+ * 【页面门禁】仅用于 pathname 含 /workbench/jova/writing;outline 页「扩写前言」走 outline-regenerate-node.js。
5
+ *
6
+ * 支持工具(右侧栏):校对、文风、润色、降重、翻译、改写、扩写、缩写、文献列表
7
+ *
8
+ * Agent 调用约定(严格遵守):
9
+ *
10
+ * 1) 用户说「<工具> <章节>」、未指明后续操作时:
11
+ * action = null → 选中章节 → 打开工具 →(文风填参考文风)→ 一键生成
12
+ * → 返回 needsReplaceChoice,禁止自动点「替换原文」
13
+ * 例外:文献列表仅打开列表面板,无生成步骤
14
+ *
15
+ * 2) 用户明确说「替换原文」:action = 'replace'
16
+ *
17
+ * 3) 自检全部工具:probeAll = true(开发/验收用,依次跑 9 项,不替换原文)
18
+ *
19
+ * intent 示例:
20
+ * 「翻译 前言」 → tool=翻译, sectionTitle=前言
21
+ * 「文风 摘要」 → tool=文风, sectionTitle=摘要(须 referenceStyle 或自动兜底)
22
+ * 「文献列表」 → tool=文献列表, sectionTitle 可空(选中全文参考文献)
23
+ *
24
+ * 用法:
25
+ * helixlife-v5-cli run-code --filename=scripts/workbench-jova-writing-tool.js
26
+ */
27
+ async page => {
28
+ const intent = '翻译 前言';
29
+ const action = null; // null | 'replace'
30
+ const extraRequirement = ''; // 额外需求(可选)
31
+ const referenceStyle = ''; // 文风专用;空则从选中段落截取兜底
32
+ const probeAll = false; // true = 验收 9 个工具(用「关键词」短段)
33
+
34
+ const TOOLS = ['校对', '文风', '润色', '降重', '翻译', '改写', '扩写', '缩写', '文献列表'];
35
+
36
+ const main = page.locator('main');
37
+
38
+ if (!page.url().includes('/workbench/jova/writing')) {
39
+ return { ok: false, reason: '当前不在初稿精修页 /workbench/jova/writing', intent };
40
+ }
41
+
42
+ function parseIntent(raw) {
43
+ const text = raw.trim();
44
+ for (const tool of TOOLS) {
45
+ if (text === tool || text.startsWith(`${tool} `) || text.startsWith(tool)) {
46
+ const sectionTitle = text.slice(tool.length).trim();
47
+ return { tool, sectionTitle: sectionTitle || null };
48
+ }
49
+ }
50
+ return { tool: null, sectionTitle: text || null };
51
+ }
52
+
53
+ async function closePanel() {
54
+ const closeIcons = main.getByRole('img', { name: 'close' });
55
+ for (let i = 0; i < (await closeIcons.count()); i++) {
56
+ const icon = closeIcons.nth(i);
57
+ if (await icon.isVisible().catch(() => false)) {
58
+ await icon.click();
59
+ await page.waitForTimeout(400);
60
+ }
61
+ }
62
+ const cancel = main.getByRole('button', { name: '取 消' });
63
+ if ((await cancel.count()) > 0 && (await cancel.first().isVisible().catch(() => false))) {
64
+ await cancel.first().click();
65
+ await page.waitForTimeout(300);
66
+ }
67
+ }
68
+
69
+ async function navigateAndSelectSection(title) {
70
+ if (title) {
71
+ const toc = main.locator('[class*="cursor-pointer"]').getByText(title, { exact: true });
72
+ if ((await toc.count()) > 0) {
73
+ await toc.first().click({ timeout: 5000 }).catch(() => {});
74
+ await page.waitForTimeout(500);
75
+ }
76
+ const heading = main.getByRole('heading', { name: title, exact: true }).first();
77
+ if ((await heading.count()) > 0) {
78
+ await heading.scrollIntoViewIfNeeded();
79
+ await page.waitForTimeout(300);
80
+ }
81
+ }
82
+
83
+ const selected = await page.evaluate(t => {
84
+ const editor = document.querySelector('main [role="textbox"]');
85
+ if (!editor) return { ok: false, reason: '未找到富文本编辑器' };
86
+
87
+ if (!t) {
88
+ const body = editor.textContent?.trim() || '';
89
+ const range = document.createRange();
90
+ range.selectNodeContents(editor);
91
+ const sel = window.getSelection();
92
+ sel?.removeAllRanges();
93
+ sel?.addRange(range);
94
+ return { ok: true, textLength: body.length, preview: body.slice(0, 80), wholeDoc: true };
95
+ }
96
+
97
+ const headings = [...editor.querySelectorAll('h1,h2,h3,h4,h5,h6')];
98
+ const idx = headings.findIndex(h => h.textContent?.trim() === t);
99
+ if (idx < 0) return { ok: false, reason: `编辑器内未找到章节「${t}」` };
100
+
101
+ const start = headings[idx];
102
+ const level = parseInt(start.tagName[1], 10);
103
+ let end = null;
104
+ for (let i = idx + 1; i < headings.length; i++) {
105
+ if (parseInt(headings[i].tagName[1], 10) <= level) {
106
+ end = headings[i];
107
+ break;
108
+ }
109
+ }
110
+
111
+ const range = document.createRange();
112
+ range.setStartBefore(start);
113
+ range.setEndBefore(end || editor.lastChild || editor);
114
+ const sel = window.getSelection();
115
+ sel?.removeAllRanges();
116
+ sel?.addRange(range);
117
+
118
+ const text = range.toString();
119
+ return {
120
+ ok: true,
121
+ textLength: text.length,
122
+ preview: text.slice(0, 80),
123
+ sectionText: text.slice(0, 500),
124
+ };
125
+ }, title);
126
+
127
+ await page.waitForTimeout(400);
128
+ return selected;
129
+ }
130
+
131
+ async function waitReplaceReady(timeoutMs = 120000) {
132
+ const replaceBtn = main.getByRole('button', { name: '替换原文' });
133
+ await replaceBtn.waitFor({ state: 'visible', timeout: timeoutMs });
134
+ const deadline = Date.now() + timeoutMs;
135
+ while (Date.now() < deadline) {
136
+ if (await replaceBtn.isEnabled()) return true;
137
+ await page.waitForTimeout(1000);
138
+ }
139
+ return false;
140
+ }
141
+
142
+ async function fillExtraIfNeeded() {
143
+ if (!extraRequirement) return;
144
+ const box = main.getByRole('textbox', { name: '额外需求' });
145
+ if ((await box.count()) > 0 && (await box.first().isVisible().catch(() => false))) {
146
+ await box.first().fill(extraRequirement);
147
+ }
148
+ }
149
+
150
+ async function fillReferenceStyleIfNeeded(selected) {
151
+ const refBox = main.getByRole('textbox', { name: '参考文风' });
152
+ if ((await refBox.count()) === 0 || !(await refBox.first().isVisible().catch(() => false))) {
153
+ return { filled: false };
154
+ }
155
+ const ref =
156
+ referenceStyle ||
157
+ (selected.sectionText
158
+ ? selected.sectionText.slice(0, 200)
159
+ : 'This review synthesizes molecular subtyping and precision therapeutic strategies in breast cancer.');
160
+ await refBox.first().fill(ref);
161
+ return { filled: true, referenceStyle: ref.slice(0, 80) };
162
+ }
163
+
164
+ async function pickTranslationLanguage() {
165
+ for (const label of ['English', '英文']) {
166
+ const opt = main.getByText(label, { exact: true });
167
+ if ((await opt.count()) > 0 && (await opt.first().isVisible().catch(() => false))) {
168
+ await opt.first().click();
169
+ await page.waitForTimeout(300);
170
+ return label;
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+
176
+ async function openToolAndGenerate(tool, sectionTitle) {
177
+ await closePanel();
178
+
179
+ const selected = await navigateAndSelectSection(sectionTitle);
180
+ if (!selected.ok) return { ok: false, tool, sectionTitle, ...selected };
181
+
182
+ const toolBtn = main.getByText(tool, { exact: true });
183
+ if ((await toolBtn.count()) === 0) {
184
+ return { ok: false, tool, sectionTitle, reason: `未找到工具「${tool}」` };
185
+ }
186
+ await toolBtn.first().click();
187
+ await page.waitForTimeout(800);
188
+
189
+ if (tool === '文献列表') {
190
+ const panel = main.getByText('文献列表', { exact: true });
191
+ const visible = (await panel.count()) > 0;
192
+ const refLinks = await main.locator('a[href*="pubmed"], a[href*="doi"], a[href*="clmd"]').count();
193
+ return {
194
+ ok: visible,
195
+ tool,
196
+ sectionTitle,
197
+ selected,
198
+ mode: 'list',
199
+ refLinkCount: refLinks,
200
+ message: '已打开文献列表面板。',
201
+ };
202
+ }
203
+
204
+ await main.getByText('原文内容', { exact: true }).waitFor({ state: 'visible', timeout: 10000 }).catch(() => {});
205
+
206
+ let refStyle = null;
207
+ if (tool === '文风') {
208
+ refStyle = await fillReferenceStyleIfNeeded(selected);
209
+ }
210
+ if (tool === '翻译') {
211
+ await pickTranslationLanguage();
212
+ }
213
+ await fillExtraIfNeeded();
214
+
215
+ const genBtn = main.getByRole('button', { name: '一键生成' });
216
+ if ((await genBtn.count()) === 0 || !(await genBtn.first().isVisible().catch(() => false))) {
217
+ return { ok: false, tool, sectionTitle, reason: '未找到「一键生成」按钮' };
218
+ }
219
+ await genBtn.first().click();
220
+
221
+ const replaceReady = await waitReplaceReady();
222
+ return {
223
+ ok: replaceReady,
224
+ tool,
225
+ sectionTitle,
226
+ selected,
227
+ refStyle,
228
+ replaceReady,
229
+ mode: 'generate',
230
+ };
231
+ }
232
+
233
+ async function replaceGenerated() {
234
+ const replaceBtn = main.getByRole('button', { name: '替换原文' });
235
+ if ((await replaceBtn.count()) === 0 || !(await replaceBtn.isEnabled().catch(() => false))) {
236
+ return { ok: false, reason: '「替换原文」不可用' };
237
+ }
238
+ await replaceBtn.click();
239
+ await main.getByText('是否将本次生成结果替换原文?', { exact: true }).waitFor({ state: 'visible', timeout: 10000 });
240
+ await main.getByRole('button', { name: '确 定' }).click();
241
+ await page.waitForTimeout(1000);
242
+ return { ok: true, replaced: true };
243
+ }
244
+
245
+ if (probeAll) {
246
+ const probeSection = '关键词';
247
+ const results = [];
248
+ for (const tool of TOOLS) {
249
+ const r = await openToolAndGenerate(tool, probeSection);
250
+ results.push({ tool, ok: r.ok, mode: r.mode, replaceReady: r.replaceReady, reason: r.reason });
251
+ }
252
+ return {
253
+ ok: results.every(r => r.ok),
254
+ probeAll: true,
255
+ probeSection,
256
+ results,
257
+ url: page.url(),
258
+ };
259
+ }
260
+
261
+ const { tool, sectionTitle } = parseIntent(intent);
262
+ if (!tool || !TOOLS.includes(tool)) {
263
+ return {
264
+ ok: false,
265
+ intent,
266
+ reason: `无法解析工具,支持:${TOOLS.join('、')}`,
267
+ };
268
+ }
269
+ if (tool !== '文献列表' && !sectionTitle) {
270
+ return { ok: false, intent, reason: '请指定章节标题,如「翻译 前言」' };
271
+ }
272
+
273
+ if (action === 'replace') {
274
+ const replaced = await replaceGenerated();
275
+ return {
276
+ ok: replaced.ok,
277
+ intent,
278
+ tool,
279
+ sectionTitle,
280
+ action,
281
+ ...replaced,
282
+ message: replaced.ok ? '已替换原文。' : replaced.reason,
283
+ url: page.url(),
284
+ };
285
+ }
286
+
287
+ const generated = await openToolAndGenerate(tool, sectionTitle);
288
+
289
+ if (generated.mode === 'list') {
290
+ return { ok: generated.ok, intent, tool, sectionTitle, action: null, ...generated, url: page.url() };
291
+ }
292
+
293
+ if (!generated.ok) {
294
+ return { ok: false, intent, tool, sectionTitle, action: null, ...generated, url: page.url() };
295
+ }
296
+
297
+ return {
298
+ ok: true,
299
+ intent,
300
+ tool,
301
+ sectionTitle,
302
+ action: null,
303
+ needsReplaceChoice: true,
304
+ availableActions: ['replace'],
305
+ replaceReady: generated.replaceReady,
306
+ selected: generated.selected,
307
+ message: `「${tool}」已生成结果,请在右侧查看;确认后告知我「替换原文」以应用。`,
308
+ url: page.url(),
309
+ };
310
+ }