helixlife-v5-cli 1.2.0 → 1.2.2

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/visual.js ADDED
@@ -0,0 +1,248 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const INIT_SCRIPT_PATH = path.join(__dirname, 'automation-overlay.init.js');
7
+ const GENERATED_CONFIG_PATH = path.join(__dirname, '.cli.runtime.generated.json');
8
+
9
+ /** 默认开启页面内视觉反馈;HELIX_VISUAL=0 可全局关闭 */
10
+ const DEFAULT_VISUAL_ENABLED = process.env.HELIX_VISUAL !== '0';
11
+
12
+ const VISUAL_COMMANDS = {
13
+ click: { pending: '正在点击', success: '已点击', pendingTone: 'pending', successTone: 'success' },
14
+ dblclick: { pending: '正在双击', success: '已双击', pendingTone: 'pending', successTone: 'success' },
15
+ fill: { pending: '正在填写', success: '已填写', pendingTone: 'pending', successTone: 'success' },
16
+ hover: { pending: '正在悬停', success: '已悬停', pendingTone: 'info', successTone: 'info' },
17
+ check: { pending: '正在勾选', success: '已勾选', pendingTone: 'pending', successTone: 'success' },
18
+ uncheck: { pending: '正在取消勾选', success: '已取消勾选', pendingTone: 'pending', successTone: 'info' },
19
+ select: { pending: '正在选择', success: '已选择', pendingTone: 'pending', successTone: 'success' },
20
+ drag: { pending: '正在拖拽', success: '已拖拽', pendingTone: 'pending', successTone: 'success' },
21
+ drop: { pending: '正在放置', success: '已放置', pendingTone: 'pending', successTone: 'success' },
22
+ upload: { hudOnly: true, pending: '正在上传', success: '已上传', pendingTone: 'pending', successTone: 'success' },
23
+ press: { hudOnly: true, pending: '按键', success: '按键完成', pendingTone: 'info', successTone: 'success' },
24
+ type: { hudOnly: true, pending: '正在输入', success: '输入完成', pendingTone: 'pending', successTone: 'success' },
25
+ };
26
+
27
+ const SESSION_START_COMMANDS = new Set(['open', 'attach']);
28
+
29
+ function readInitScriptSource() {
30
+ return fs.readFileSync(INIT_SCRIPT_PATH, 'utf8');
31
+ }
32
+
33
+ function resolveGeneratedConfigPath() {
34
+ const payload = {
35
+ browser: {
36
+ initScript: [INIT_SCRIPT_PATH.replace(/\\/g, '/')],
37
+ },
38
+ };
39
+ fs.writeFileSync(GENERATED_CONFIG_PATH, JSON.stringify(payload, null, 2), 'utf8');
40
+ return GENERATED_CONFIG_PATH;
41
+ }
42
+
43
+ /**
44
+ * 从 argv 中提取并剥离 --visual / --no-visual / --visual-ms。
45
+ * @returns {{ tokens: string[], visual: { enabled: boolean, durationMs: number | null } }}
46
+ */
47
+ function parseVisualFlags(tokens) {
48
+ const visual = {
49
+ enabled: DEFAULT_VISUAL_ENABLED,
50
+ durationMs: null,
51
+ };
52
+ const out = [];
53
+ for (let i = 0; i < tokens.length; i += 1) {
54
+ const t = tokens[i];
55
+ if (t === '--visual') {
56
+ visual.enabled = true;
57
+ } else if (t === '--no-visual') {
58
+ visual.enabled = false;
59
+ } else if (t === '--visual-ms') {
60
+ visual.durationMs = tokens[i + 1];
61
+ i += 1;
62
+ } else if (t.startsWith('--visual-ms=')) {
63
+ visual.durationMs = t.slice('--visual-ms='.length);
64
+ } else {
65
+ out.push(t);
66
+ }
67
+ }
68
+ if (visual.durationMs != null) {
69
+ const n = Number(visual.durationMs);
70
+ if (!Number.isFinite(n) || n <= 0) {
71
+ throw Object.assign(new Error(`--visual-ms 需要正数,收到 ${JSON.stringify(visual.durationMs)}`), { code: 'E_BAD_ARG' });
72
+ }
73
+ visual.durationMs = Math.round(n);
74
+ }
75
+ return { tokens: out, visual };
76
+ }
77
+
78
+ function hasConfigFlag(tokens) {
79
+ return tokens.some((t) => t === '--config' || t.startsWith('--config='));
80
+ }
81
+
82
+ /**
83
+ * open / attach 时自动注入 initScript 配置,使新页面预装视觉层。
84
+ */
85
+ function injectVisualConfig(tokens, visual) {
86
+ if (!visual.enabled) return tokens;
87
+ const cmd = resolveCommandName(tokens);
88
+ if (!SESSION_START_COMMANDS.has(cmd) || hasConfigFlag(tokens)) {
89
+ return tokens;
90
+ }
91
+ const configPath = resolveGeneratedConfigPath();
92
+ return [...tokens, `--config=${configPath}`];
93
+ }
94
+
95
+ function resolveCommandName(tokens) {
96
+ let i = 0;
97
+ if (tokens[i] === '--raw') i += 1;
98
+ if (tokens[i] === '--json') i += 1;
99
+ if (tokens[i] && tokens[i].startsWith('-s=')) i += 1;
100
+ return tokens[i] || '';
101
+ }
102
+
103
+ function isElementRef(token) {
104
+ if (!token || token.startsWith('-')) return false;
105
+ if (/^e\d+$/.test(token)) return true;
106
+ if (token.startsWith('"') || token.startsWith("'")) return true;
107
+ if (token.startsWith('#') || token.startsWith('.')) return true;
108
+ if (token.startsWith('getBy')) return true;
109
+ return false;
110
+ }
111
+
112
+ /**
113
+ * 从命令参数中提取主要元素 target(用于高亮)。
114
+ */
115
+ function extractElementTarget(tokens) {
116
+ const cmd = resolveCommandName(tokens);
117
+ if (!VISUAL_COMMANDS[cmd]) return null;
118
+ const def = VISUAL_COMMANDS[cmd];
119
+ if (def.hudOnly) return null;
120
+
121
+ let i = 0;
122
+ if (tokens[i] === '--raw') i += 1;
123
+ if (tokens[i] === '--json') i += 1;
124
+ if (tokens[i] && tokens[i].startsWith('-s=')) i += 1;
125
+ i += 1; // skip command name
126
+
127
+ const positional = [];
128
+ for (; i < tokens.length; i += 1) {
129
+ const t = tokens[i];
130
+ if (t.startsWith('--')) {
131
+ if (t.includes('=')) continue;
132
+ const next = tokens[i + 1];
133
+ if (next !== undefined && !next.startsWith('-')) i += 1;
134
+ continue;
135
+ }
136
+ positional.push(t);
137
+ }
138
+
139
+ if (cmd === 'drag' && positional.length >= 1) return positional[0];
140
+ if (cmd === 'fill' && positional.length >= 1) return positional[0];
141
+ if (cmd === 'select' && positional.length >= 1) return positional[0];
142
+ if (cmd === 'drop' && positional.length >= 1) return positional[0];
143
+ if (positional.length >= 1 && isElementRef(positional[0])) return positional[0];
144
+ if (positional.length >= 2 && isElementRef(positional[positional.length - 1])) {
145
+ return positional[positional.length - 1];
146
+ }
147
+ return null;
148
+ }
149
+
150
+ function buildFlashEval(target, options) {
151
+ const optsLit = JSON.stringify(options);
152
+ return [
153
+ 'eval',
154
+ `el => { const v = window.__helix_visual__; if (!v) return false; return v.flash(el, ${optsLit}); }`,
155
+ target,
156
+ ];
157
+ }
158
+
159
+ function buildHudEval(options) {
160
+ const optsLit = JSON.stringify(options);
161
+ return ['eval', `() => { const v = window.__helix_visual__; if (!v) return false; return v.showHud(${optsLit}); }`];
162
+ }
163
+
164
+ function buildSetOptionsEval(visual) {
165
+ const payload = JSON.stringify({
166
+ enabled: visual.enabled,
167
+ durationMs: visual.durationMs,
168
+ });
169
+ return ['eval', `() => { const v = window.__helix_visual__; if (!v) return null; return v.setVisualOptions(${payload}); }`];
170
+ }
171
+
172
+ function buildInstallEval() {
173
+ const src = readInitScriptSource();
174
+ return ['eval', src];
175
+ }
176
+
177
+ function buildBridgeCheckEval() {
178
+ return ['eval', '--raw', 'Boolean(window.__helix_visual__)'];
179
+ }
180
+
181
+ function shouldWrapWithVisual(tokens, visual) {
182
+ if (!visual.enabled) return false;
183
+ const cmd = resolveCommandName(tokens);
184
+ return Boolean(VISUAL_COMMANDS[cmd]);
185
+ }
186
+
187
+ /**
188
+ * 在元素交互命令前后注入视觉反馈。
189
+ * @param {string[]} tokens 已剥离 visual flags 的参数
190
+ * @param {{ enabled: boolean, durationMs: number | null }} visual
191
+ * @param {(args: string[], opts?: object) => number} runPassthrough
192
+ */
193
+ async function runWithVisualFeedback(tokens, visual, runPassthrough) {
194
+ const cmd = resolveCommandName(tokens);
195
+ const def = VISUAL_COMMANDS[cmd];
196
+ const target = extractElementTarget(tokens);
197
+
198
+ const runCapture = (args) => runPassthrough(args, { capture: true });
199
+
200
+ const check = runCapture(buildBridgeCheckEval());
201
+ if (String(check.stdout || '').trim() !== 'true') {
202
+ runCapture(buildInstallEval());
203
+ }
204
+ runCapture(buildSetOptionsEval(visual));
205
+
206
+ const hudBase = { action: cmd, target: target || '', status: 'pending' };
207
+ runCapture(buildHudEval({ ...hudBase, detail: def.pending }));
208
+
209
+ if (target) {
210
+ runCapture(buildFlashEval(target, {
211
+ tone: def.pendingTone || 'pending',
212
+ label: def.pending,
213
+ durationMs: visual.durationMs,
214
+ }));
215
+ }
216
+
217
+ const code = runPassthrough(tokens);
218
+
219
+ const successTone = code === 0 ? (def.successTone || 'success') : 'danger';
220
+ const successLabel = code === 0 ? def.success : '操作失败';
221
+ runCapture(buildSetOptionsEval(visual));
222
+ if (target) {
223
+ runCapture(buildFlashEval(target, {
224
+ tone: successTone,
225
+ label: successLabel,
226
+ durationMs: visual.durationMs,
227
+ }));
228
+ }
229
+ runCapture(buildHudEval({
230
+ action: cmd,
231
+ target: target || '',
232
+ status: code === 0 ? 'success' : 'danger',
233
+ detail: successLabel,
234
+ }));
235
+
236
+ return code;
237
+ }
238
+
239
+ module.exports = {
240
+ INIT_SCRIPT_PATH,
241
+ VISUAL_COMMANDS,
242
+ DEFAULT_VISUAL_ENABLED,
243
+ parseVisualFlags,
244
+ injectVisualConfig,
245
+ shouldWrapWithVisual,
246
+ runWithVisualFeedback,
247
+ resolveGeneratedConfigPath,
248
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helixlife-v5-cli",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Helix(vip.helixlife.cn)精细化浏览器自动化命令行工具。",
5
5
  "main": "helix-cli.js",
6
6
  "bin": {
@@ -0,0 +1,149 @@
1
+ /**
2
+ * 综述工作台(jova)· 确认提纲页 · 节点 AI 重新生成
3
+ *
4
+ * 典型场景:用户说「扩写前言」「精简结论」「润色 1. xxx」等。
5
+ *
6
+ * 流程:
7
+ * 悬停提纲节点 → 编辑 → 填写「用户需求」→「一键生成」
8
+ * → 等待「生成结果」→「替换原文」→ Popconfirm「确 定」→「保 存」
9
+ *
10
+ * 用法(仅需改 intent 一行,或显式指定 nodeTitle / userRequirement):
11
+ * helixlife-v5-cli run-code --filename=scripts/workbench-jova-outline-regenerate-node.js
12
+ *
13
+ * intent 解析规则:
14
+ * 「扩写前言」 → nodeTitle=前言, userRequirement=扩写
15
+ * 「精简结论」 → nodeTitle=结论, userRequirement=精简
16
+ * 「润色摘要:」 → nodeTitle=摘要:, userRequirement=润色
17
+ * 无法解析时整句作为 userRequirement,nodeTitle 回退为「前言」
18
+ */
19
+ async page => {
20
+ // ── Agent 通常只改这一行 ──
21
+ const intent = '扩写前言';
22
+
23
+ // 也可显式覆盖(非 null 时优先于 intent 解析)
24
+ const nodeTitleOverride = null;
25
+ const userRequirementOverride = null;
26
+
27
+ function parseIntent(raw) {
28
+ const verbs = ['扩写', '精简', '改写', '润色', '重写', '优化'];
29
+ for (const verb of verbs) {
30
+ if (raw.startsWith(verb)) {
31
+ const title = raw.slice(verb.length).trim();
32
+ if (title) return { nodeTitle: title, userRequirement: verb };
33
+ }
34
+ }
35
+ return { nodeTitle: '前言', userRequirement: raw };
36
+ }
37
+
38
+ const parsed = parseIntent(intent);
39
+ const nodeTitle = nodeTitleOverride ?? parsed.nodeTitle;
40
+ const userRequirement = userRequirementOverride ?? parsed.userRequirement;
41
+
42
+ const main = page.locator('main');
43
+
44
+ if (!page.url().includes('/workbench/jova/outline')) {
45
+ return {
46
+ ok: false,
47
+ reason: '当前不在综述工作台确认提纲页 /workbench/jova/outline',
48
+ intent,
49
+ nodeTitle,
50
+ userRequirement,
51
+ };
52
+ }
53
+
54
+ async function resetEditingState() {
55
+ const closeIcons = main.getByRole('img', { name: 'close' });
56
+ for (let i = 0; i < (await closeIcons.count()); i++) {
57
+ const icon = closeIcons.nth(i);
58
+ if (await icon.isVisible()) {
59
+ await icon.click();
60
+ await page.waitForTimeout(400);
61
+ }
62
+ }
63
+ const cancelReplace = main.getByRole('button', { name: '取 消' });
64
+ if ((await cancelReplace.count()) > 0 && (await cancelReplace.first().isVisible())) {
65
+ await cancelReplace.first().click();
66
+ await page.waitForTimeout(400);
67
+ }
68
+ const saveBtn = main.getByRole('button', { name: '保 存' });
69
+ if ((await saveBtn.count()) > 0 && (await saveBtn.first().isVisible())) {
70
+ await saveBtn.first().click();
71
+ await page.waitForTimeout(400);
72
+ }
73
+ }
74
+
75
+ function outlineNode(title) {
76
+ const base = title.replace(/[::]\s*$/, '');
77
+ const titleRe = new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[::]?$`);
78
+ return main
79
+ .locator('[aria-label*="操作区"]')
80
+ .filter({
81
+ has: page.locator(':scope > *').first().filter({ hasText: titleRe }),
82
+ })
83
+ .first();
84
+ }
85
+
86
+ await resetEditingState();
87
+
88
+ const opArea = outlineNode(nodeTitle);
89
+ if ((await opArea.count()) === 0) {
90
+ return {
91
+ ok: false,
92
+ reason: `未找到提纲节点「${nodeTitle}」`,
93
+ intent,
94
+ nodeTitle,
95
+ userRequirement,
96
+ };
97
+ }
98
+
99
+ // 1. 悬停 → 编辑
100
+ await opArea.hover();
101
+ await page.waitForTimeout(400);
102
+ await opArea.getByRole('button', { name: '编辑' }).click();
103
+
104
+ // 2. 右侧「重新生成」面板
105
+ const regenTitle = main.getByText('重新生成', { exact: true });
106
+ await regenTitle.waitFor({ state: 'visible', timeout: 10000 });
107
+
108
+ const userReqBox = main.getByRole('textbox', { name: '用户需求' });
109
+ await userReqBox.waitFor({ state: 'visible', timeout: 5000 });
110
+ await userReqBox.fill(userRequirement);
111
+
112
+ // 3. 一键生成 → 等待「替换原文」可用
113
+ await main.getByRole('button', { name: '一键生成' }).click();
114
+ const replaceBtn = main.getByRole('button', { name: '替换原文' });
115
+ await replaceBtn.waitFor({ state: 'visible', timeout: 120000 });
116
+ await page.waitForTimeout(800);
117
+
118
+ // 4. 替换原文 → Popconfirm「是否将本次生成结果替换原文?」→ 确 定
119
+ await replaceBtn.click();
120
+ const confirmPrompt = main.getByText('是否将本次生成结果替换原文?', { exact: true });
121
+ await confirmPrompt.waitFor({ state: 'visible', timeout: 10000 });
122
+ await main.getByRole('button', { name: '确 定' }).click();
123
+ await page.waitForTimeout(800);
124
+
125
+ // 5. 左侧内联编辑器「保 存」
126
+ const saveBtn = main.getByRole('button', { name: '保 存' });
127
+ await saveBtn.waitFor({ state: 'visible', timeout: 10000 });
128
+ const inlineEditor = saveBtn.locator('xpath=ancestor::li[1]//textarea | ancestor::li[1]//input[@type="text"] | ancestor::li[1]//*[@role="textbox"]').first();
129
+ const editorText = (await inlineEditor.inputValue().catch(() => '')) || (await inlineEditor.textContent().catch(() => '')) || '';
130
+
131
+ await saveBtn.click();
132
+ await page.waitForTimeout(1000);
133
+
134
+ const panelClosed = !(await regenTitle.isVisible().catch(() => false));
135
+ const nodePresent = (await outlineNode(nodeTitle).count()) > 0;
136
+ const contentApplied = editorText.includes(nodeTitle) || editorText.length > 0;
137
+
138
+ return {
139
+ ok: nodePresent && panelClosed && contentApplied,
140
+ intent,
141
+ nodeTitle,
142
+ userRequirement,
143
+ panelClosed,
144
+ nodePresent,
145
+ contentApplied,
146
+ editorTextLength: editorText.length,
147
+ url: page.url(),
148
+ };
149
+ }
@@ -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
+ }