e10-ebuilder-prototype 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -7
- package/dist/archive.d.ts +2 -2
- package/dist/common.d.ts +1 -1
- package/dist/common.js +1 -1
- package/dist/form-context.d.ts +1 -1
- package/dist/form-context.js +68 -18
- package/dist/form-options.d.ts +12 -0
- package/dist/form-options.js +54 -0
- package/dist/html-handoff.d.ts +25 -0
- package/dist/html-handoff.js +89 -0
- package/dist/html-inspect.d.ts +4 -0
- package/dist/html-inspect.js +168 -0
- package/dist/html.d.ts +22 -3
- package/dist/html.js +112 -38
- package/dist/index.js +52 -8
- package/dist/model.d.ts +2 -2
- package/dist/model.js +1 -1
- package/dist/site.d.ts +1 -1
- package/dist/site.js +1 -1
- package/dist/templates/form-guide.md +8 -2
- package/dist/templates/index.html +100 -81
- package/dist/templates/workflow-guide.md +5 -1
- package/docs/PROTOCOL.md +99 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# e10-ebuilder-prototype
|
|
2
2
|
|
|
3
3
|
根据 E10 **应用 ID 或后台应用地址生成应用原型,包含已发布菜单中的页面、建模列表和流程页面**。
|
|
4
|
-
|
|
4
|
+
版本 0.5.4,默认并发 6。保持我们的 TypeScript CLI 为主:CLI 管登录、菜单发现、配置采集、截图、任务队列、校验、恢复与打包;宿主 AI 根据完整截图或建模配置生成独立 HTML。CLI 不调用 LLM,不要求额外模型 API Key,也不调用 ui-code-agent 生成。
|
|
5
5
|
|
|
6
6
|
## 范围与交付
|
|
7
7
|
|
|
@@ -31,7 +31,7 @@ ZIP 名为 `应用页面-<appId>.zip`,位于任务目录 artifacts/;本地
|
|
|
31
31
|
需要 Node >=22.5、npm 和已安装 Google Chrome,playwright-core 固定 1.62.1,不下载浏览器。
|
|
32
32
|
|
|
33
33
|
```sh
|
|
34
|
-
npm install -g e10-ebuilder-prototype@0.5.
|
|
34
|
+
npm install -g e10-ebuilder-prototype@0.5.4 --registry=https://registry.npmjs.org/
|
|
35
35
|
e10-ebuilder-prototype auth set --base-url https://your-e10.example.com --eteamsid-stdin
|
|
36
36
|
e10-ebuilder-prototype run --app-id 1234567890123456789 --dir ./work/app-demo --json
|
|
37
37
|
# 或使用后台应用地址:
|
|
@@ -45,14 +45,15 @@ e10-ebuilder-prototype run --app-url "https://your-e10.example.com/sp/ebdapp/det
|
|
|
45
45
|
`run` 完成确定性工作后停在 HTML,由宿主继续:
|
|
46
46
|
|
|
47
47
|
```sh
|
|
48
|
-
e10-ebuilder-prototype html next --dir ./work/app-demo --json
|
|
49
|
-
#
|
|
50
|
-
e10-ebuilder-prototype html
|
|
48
|
+
e10-ebuilder-prototype html next --dir ./work/app-demo --brief --json
|
|
49
|
+
# 宿主读取各 job 的 promptPath,写完整草稿;CLI 离线渲染后实际看图
|
|
50
|
+
e10-ebuilder-prototype html inspect --dir ./work/app-demo --kind page --page-id "<ID>" --token "<TOKEN>" --json
|
|
51
|
+
e10-ebuilder-prototype html accept --dir ./work/app-demo --kind page --page-id "<ID>" --token "<TOKEN>" --refill --brief --json
|
|
51
52
|
e10-ebuilder-prototype html accept --dir ./work/app-demo --kind form --page-id "<菜单键>" --token "<TOKEN>" --json
|
|
52
53
|
e10-ebuilder-prototype pack --dir ./work/app-demo --json
|
|
53
54
|
```
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
同一个生成队列默认最多 6 个任务,可按用户需求 --concurrency 1..8。page 输入 screenshotPath;form 输入 sourcePath,附带 contextPath 分片索引、guidePath 完整规范、runtimePath 固定存储脚本及 navigationPath 已发布目标映射。一个协调者串行 accept/fail/next,任务只写自己的草稿;宿主支持且允许并行时补位执行,否则如实采用顺序生成。
|
|
56
57
|
|
|
57
58
|
生成文件为真实 DOM、内联 CSS/JS/SVG,无外部资源或后台调用。页面忠实还原视觉结构,空数据用一致 Mock 内容填充。表单按当前模式/字段/选项/按钮及布局依据生成,固定种子约 20 条主记录、明细每条 2–5 行;所有统计由数据推导。完整规范随 CLI 打包,不能用“能打开表格”代替验收。
|
|
58
59
|
|
|
@@ -91,7 +92,7 @@ isIndependentDeploy 请求失败时,默认按非独立部署继续采集,并
|
|
|
91
92
|
|
|
92
93
|
## 截图和资源限制
|
|
93
94
|
|
|
94
|
-
PC 默认 1440×900,MOBILE-only 页面 390×844;默认并发
|
|
95
|
+
PC 默认 1440×900,MOBILE-only 页面 390×844;默认并发 6。页面初始渲染上限 10 秒、稳定窗口 800ms;超时本轮失败,其他可恢复失败额外重试一次。就绪后立即继续,不固定睡眠。
|
|
95
96
|
只用页面组件请求、可见加载、字体/图片、主体内容与稳定性判断;全局后台请求不阻塞。随后滚动检查、编码和清理另有边界,详见协议。
|
|
96
97
|
|
|
97
98
|
截图覆盖当前已加载/可滚动内容,不自动点击标签或分页。承载 EB 主体的 iframe 完整滚动展开,普通内嵌网站保留设计视口。稳定业务错误可原样截图并带 warning,未知空白、超时、身份失效、内容丢失仍失败。源站错误截图不等于业务功能健康。
|
|
@@ -121,3 +122,16 @@ Windows 使用原生 Node,无需 Bash/WSL/Python;支持 WorkBuddy 内置 npm
|
|
|
121
122
|
流程列表的列、标签、筛选和按钮来自内置固定原型模板;目录接口提供真实流程名称和关联表单,不读取真实流程实例/审批权限。宿主按 CLI 的 workflow-guide 生成右侧页面,固定 E10WorkflowStore 负责本地已读、批量提交、草稿续填、收藏/关注、实例评论以及关联记录联合保存。所有流程动作仅修改本地模拟数据。流程仍使用 kind=form 队列和 form/<菜单键>/ 交付路径,job.workflowType 区分流程类别。
|
|
122
123
|
|
|
123
124
|
完整流程协议见 [PROTOCOL.md](docs/PROTOCOL.md)。
|
|
125
|
+
|
|
126
|
+
## WorkBuddy 生成效率(0.5.4)
|
|
127
|
+
|
|
128
|
+
首次 status 未就绪时仅提示“首次使用需要初始化工作环境,大概需要几分钟,完成后自动继续。”
|
|
129
|
+
ensure 保留后台进度与耗时,成功后自动继续,后续正常使用不再提示初始化或依赖名称。不要给 CLI 命令附加 tail 或吞掉退出码。
|
|
130
|
+
|
|
131
|
+
页面先看完整原图再写完整首版,不要求逐像素测量。html inspect 统一生成离线校对截图,
|
|
132
|
+
相同源码与完整截图可复用;宿主实际看图并集中修正,最多两轮视觉微调。业务缺项或运行错误仍需处理。
|
|
133
|
+
|
|
134
|
+
WorkBuddy 子任务显式使用主任务当前实际模型,保留可继承的思考配置,不写死某个模型。
|
|
135
|
+
后台生成完成一个接收一个,accept/fail --refill --brief 同时返回新任务;promptPath 将页面细节
|
|
136
|
+
留在对应子任务。连接失败确认旧写者已退出后恢复一次,已有草稿可继续;持续失败明确报告。
|
|
137
|
+
并发额度不是实际活跃数,也不保证线性提速,结论以同输入同模型的实际验收计时为准。
|
package/dist/archive.d.ts
CHANGED
|
@@ -54,9 +54,9 @@ export declare function pack(store: Store, s: TaskState): Promise<{
|
|
|
54
54
|
sha256: string | undefined;
|
|
55
55
|
bytes: number | undefined;
|
|
56
56
|
file: string | undefined;
|
|
57
|
-
status: "running" | "succeeded" | "failed";
|
|
57
|
+
status: "running" | "succeeded" | "failed" | "pending";
|
|
58
58
|
sourceSha256: string;
|
|
59
|
-
promptVersion: 1 | 2 |
|
|
59
|
+
promptVersion: 1 | 2 | 6 | 3 | 4 | 5 | 7 | 8 | 9;
|
|
60
60
|
startedAt: string;
|
|
61
61
|
finishedAt: string | undefined;
|
|
62
62
|
error: {
|
package/dist/common.d.ts
CHANGED
package/dist/common.js
CHANGED
|
@@ -3,7 +3,7 @@ import { renameWithRetry } from './runtime-support.mjs';
|
|
|
3
3
|
import { createReadStream } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
-
export const VERSION = '0.5.
|
|
6
|
+
export const VERSION = '0.5.4';
|
|
7
7
|
export class CaptureError extends Error {
|
|
8
8
|
code;
|
|
9
9
|
details;
|
package/dist/form-context.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { Store } from './store.js';
|
|
2
|
-
/**
|
|
2
|
+
/** Bounded, lossless views. Fixed metadata is grouped, never reinterpreted or discarded. */
|
|
3
3
|
export declare function formContext(store: Store, key: string): Promise<string>;
|
package/dist/form-context.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { fileDigest } from './common.js';
|
|
3
|
+
import { digest, fileDigest, CaptureError } from './common.js';
|
|
4
|
+
import { formOptionData } from './form-options.js';
|
|
4
5
|
import { formInputPath } from './forms.js';
|
|
5
6
|
import { navigationKey } from './menus.js';
|
|
7
|
+
const limit = 18000;
|
|
8
|
+
const lineLimit = 1800; // WorkBuddy Read truncates long individual lines, not only large files.
|
|
6
9
|
function* parts(value, location) {
|
|
7
|
-
if (JSON.stringify(
|
|
10
|
+
if (JSON.stringify(location).length > lineLimit - 96)
|
|
11
|
+
throw new CaptureError('FORM_CONTEXT_LIMIT', '配置属性路径超过宿主可完整读取的单行长度');
|
|
12
|
+
const encoded = JSON.stringify({ entries: [{ location, value }] }, null, 2);
|
|
13
|
+
if (encoded.length <= limit && encoded.split('\n').every((line) => line.length <= lineLimit)) {
|
|
8
14
|
yield { location, value };
|
|
9
15
|
return;
|
|
10
16
|
}
|
|
@@ -17,34 +23,76 @@ function* parts(value, location) {
|
|
|
17
23
|
yield* parts(item, `${location}.${key}`);
|
|
18
24
|
}
|
|
19
25
|
else if (typeof value === 'string') {
|
|
20
|
-
for (let offset = 0; offset < value.length;
|
|
26
|
+
for (let offset = 0; offset < value.length;) {
|
|
27
|
+
let low = offset + 1, high = Math.min(value.length, offset + lineLimit - 32);
|
|
28
|
+
while (low < high) {
|
|
29
|
+
const middle = Math.ceil((low + high) / 2);
|
|
30
|
+
if (JSON.stringify(value.slice(offset, middle)).length <= lineLimit - 32)
|
|
31
|
+
low = middle;
|
|
32
|
+
else
|
|
33
|
+
high = middle - 1;
|
|
34
|
+
}
|
|
35
|
+
let end = low;
|
|
36
|
+
if (end < value.length && /[\uD800-\uDBFF]/.test(value[end - 1]))
|
|
37
|
+
end--;
|
|
21
38
|
yield {
|
|
22
|
-
location: `${location} (characters ${offset}:${
|
|
23
|
-
value: value.slice(offset,
|
|
39
|
+
location: `${location} (characters ${offset}:${end})`,
|
|
40
|
+
value: value.slice(offset, end),
|
|
24
41
|
};
|
|
42
|
+
offset = end;
|
|
43
|
+
}
|
|
25
44
|
}
|
|
26
45
|
else
|
|
27
46
|
yield { location, value };
|
|
28
47
|
}
|
|
29
|
-
/**
|
|
48
|
+
/** Bounded, lossless views. Fixed metadata is grouped, never reinterpreted or discarded. */
|
|
30
49
|
export async function formContext(store, key) {
|
|
31
|
-
const source = formInputPath(store, key),
|
|
32
|
-
const folder = path.join(store.meta, 'form-context', navigationKey(key),
|
|
50
|
+
const source = formInputPath(store, key), sourceSha256 = await fileDigest(source);
|
|
51
|
+
const folder = path.join(store.meta, 'form-context', navigationKey(key), sourceSha256);
|
|
52
|
+
const indexPath = path.join(folder, 'index.json');
|
|
53
|
+
const previous = await fs
|
|
54
|
+
.readFile(indexPath, 'utf8')
|
|
55
|
+
.then(JSON.parse)
|
|
56
|
+
.catch(() => null);
|
|
57
|
+
if (previous?.schema === 4 &&
|
|
58
|
+
previous.sourceSha256 === sourceSha256 &&
|
|
59
|
+
Array.isArray(previous.fragments)) {
|
|
60
|
+
const valid = await Promise.all(previous.fragments.map(async (part) => typeof part.path === 'string' &&
|
|
61
|
+
path.basename(part.path) === part.path &&
|
|
62
|
+
(await fileDigest(path.join(folder, part.path)).catch(() => '')) === part.sha256));
|
|
63
|
+
if (valid.length && valid.every(Boolean))
|
|
64
|
+
return indexPath;
|
|
65
|
+
}
|
|
66
|
+
const input = JSON.parse(await fs.readFile(source, 'utf8'));
|
|
67
|
+
const options = formOptionData(input);
|
|
33
68
|
await fs.mkdir(folder, { recursive: true });
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
69
|
+
const fragments = [];
|
|
70
|
+
let batch = [];
|
|
71
|
+
const flush = async () => {
|
|
72
|
+
if (!batch.length)
|
|
73
|
+
return;
|
|
74
|
+
const filename = `context-${fragments.length + 1}.json`;
|
|
75
|
+
const bytes = JSON.stringify({ entries: batch }, null, 2);
|
|
76
|
+
await fs.writeFile(path.join(folder, filename), bytes, { mode: 0o600 });
|
|
77
|
+
fragments.push({
|
|
78
|
+
path: filename,
|
|
79
|
+
locations: batch.map((part) => part.location),
|
|
80
|
+
sha256: digest(bytes),
|
|
81
|
+
});
|
|
82
|
+
batch = [];
|
|
83
|
+
};
|
|
84
|
+
for (const [category, value] of Object.entries(options.context)) {
|
|
37
85
|
for (const part of parts(value, category)) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
});
|
|
42
|
-
index.push({ path: filename, location: part.location });
|
|
86
|
+
if (batch.length && JSON.stringify({ entries: [...batch, part] }, null, 2).length > limit)
|
|
87
|
+
await flush();
|
|
88
|
+
batch.push(part);
|
|
43
89
|
}
|
|
44
90
|
}
|
|
45
|
-
|
|
91
|
+
await flush();
|
|
46
92
|
await fs.writeFile(indexPath, JSON.stringify({
|
|
93
|
+
schema: 4,
|
|
47
94
|
source,
|
|
95
|
+
sourceSha256,
|
|
48
96
|
appId: input.appId,
|
|
49
97
|
objId: input.objId,
|
|
50
98
|
menu: input.page,
|
|
@@ -52,7 +100,9 @@ export async function formContext(store, key) {
|
|
|
52
100
|
customFieldCount: input.fields?.filter((f) => !f.system).length,
|
|
53
101
|
layoutSource: input.layout?.source,
|
|
54
102
|
warnings: input.warnings,
|
|
55
|
-
|
|
103
|
+
optionDatasets: options.descriptions,
|
|
104
|
+
format: 'Each fragment contains entries [{location,value}]; read each fragment once. Long strings use character ranges and concatenate in order. A {$e10Options:id,count,sample} replaces a large options array: call E10FormOptions.get(id) in the generated HTML to retrieve the complete original array. sample describes shape only; never use it as the full choices. CLI injects all dataset values during inspect/accept. Original input retains every property; host-readable lines are bounded.',
|
|
105
|
+
fragments,
|
|
56
106
|
}, null, 2), { mode: 0o600 });
|
|
57
107
|
return indexPath;
|
|
58
108
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Large static choice catalogs travel as data, never as model-authored business code. */
|
|
2
|
+
export declare function formOptionData(input: any): {
|
|
3
|
+
context: any;
|
|
4
|
+
datasets: Record<string, any[]>;
|
|
5
|
+
descriptions: {
|
|
6
|
+
id: string;
|
|
7
|
+
count: number;
|
|
8
|
+
locations: string[];
|
|
9
|
+
}[];
|
|
10
|
+
};
|
|
11
|
+
export declare function formOptionsRuntime(datasets: Record<string, any[]>): string;
|
|
12
|
+
export declare function attachFormOptions(source: string, datasets: Record<string, any[]>): string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { digest } from './common.js';
|
|
2
|
+
/** Large static choice catalogs travel as data, never as model-authored business code. */
|
|
3
|
+
export function formOptionData(input) {
|
|
4
|
+
const datasets = Object.create(null);
|
|
5
|
+
const descriptions = new Map();
|
|
6
|
+
const sample = (value, depth = 0) => {
|
|
7
|
+
if (Array.isArray(value))
|
|
8
|
+
return depth >= 2
|
|
9
|
+
? `[${value.length} items]`
|
|
10
|
+
: value.slice(0, 2).map((v) => sample(v, depth + 1));
|
|
11
|
+
if (value && typeof value === 'object')
|
|
12
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, sample(v, depth + 1)]));
|
|
13
|
+
return typeof value === 'string' && value.length > 160 ? value.slice(0, 160) + '…' : value;
|
|
14
|
+
};
|
|
15
|
+
const visit = (value, location, key = '') => {
|
|
16
|
+
if (key === 'options' && Array.isArray(value) && JSON.stringify(value).length > 8000) {
|
|
17
|
+
const id = digest(JSON.stringify(value));
|
|
18
|
+
datasets[id] = value;
|
|
19
|
+
if (!descriptions.has(id))
|
|
20
|
+
descriptions.set(id, { id, count: value.length, locations: [] });
|
|
21
|
+
descriptions.get(id).locations.push(location);
|
|
22
|
+
return { $e10Options: id, count: value.length, sample: sample(value) };
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(value))
|
|
25
|
+
return value.map((v, i) => visit(v, `${location}[${i}]`));
|
|
26
|
+
if (value && typeof value === 'object')
|
|
27
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, visit(v, location ? `${location}.${k}` : k, k)]));
|
|
28
|
+
return value;
|
|
29
|
+
};
|
|
30
|
+
return { context: visit(input, ''), datasets, descriptions: [...descriptions.values()] };
|
|
31
|
+
}
|
|
32
|
+
function installOptions(datasets) {
|
|
33
|
+
Object.defineProperty(window, 'E10FormOptions', {
|
|
34
|
+
value: Object.freeze({
|
|
35
|
+
get: (id) => {
|
|
36
|
+
if (!Object.prototype.hasOwnProperty.call(datasets, id))
|
|
37
|
+
throw new Error('本菜单没有对应的选项数据集');
|
|
38
|
+
return JSON.parse(JSON.stringify(datasets[id]));
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
configurable: true,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export function formOptionsRuntime(datasets) {
|
|
45
|
+
const json = JSON.stringify(JSON.stringify(datasets))
|
|
46
|
+
.replaceAll('<', '\\u003c')
|
|
47
|
+
.replaceAll('\u2028', '\\u2028')
|
|
48
|
+
.replaceAll('\u2029', '\\u2029');
|
|
49
|
+
return `<script id="e10-form-options">(${installOptions.toString()})(JSON.parse(${json}));</script>`;
|
|
50
|
+
}
|
|
51
|
+
export function attachFormOptions(source, datasets) {
|
|
52
|
+
const clean = source.replace(/<script\b[^>]*\bid\s*=\s*["']e10-form-options["'][^>]*>[\s\S]*?<\/script\s*>/gi, '');
|
|
53
|
+
return clean.replace(/<head(?:\s[^>]*)?>/i, (match) => match + formOptionsRuntime(datasets));
|
|
54
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { TaskState } from './model.js';
|
|
2
|
+
import type { Store } from './store.js';
|
|
3
|
+
/** The worker attests its review; this is not an independent visual quality score. */
|
|
4
|
+
export declare function readyHtml(store: Store, state: TaskState, pageId: string, token: string, kind: 'page' | 'form', summary: string): Promise<{
|
|
5
|
+
ok: boolean;
|
|
6
|
+
kind: "page" | "form";
|
|
7
|
+
pageId: string;
|
|
8
|
+
receiptPath: string;
|
|
9
|
+
next: string;
|
|
10
|
+
frozen: boolean;
|
|
11
|
+
}>;
|
|
12
|
+
/** Called only by the coordinator under the queue lock. Each acceptance is durable. */
|
|
13
|
+
export declare function acceptReadyHtml(store: Store, state: TaskState): Promise<{
|
|
14
|
+
accepted: {
|
|
15
|
+
kind: "page" | "form";
|
|
16
|
+
pageId: string;
|
|
17
|
+
summary: any;
|
|
18
|
+
}[];
|
|
19
|
+
rejected: {
|
|
20
|
+
kind: "page" | "form";
|
|
21
|
+
pageId: string;
|
|
22
|
+
code: string;
|
|
23
|
+
message: string;
|
|
24
|
+
}[];
|
|
25
|
+
}>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { atomicJson, CaptureError, digest, fileDigest } from './common.js';
|
|
4
|
+
import { prepareHtml, acceptHtml, draftPath } from './html.js';
|
|
5
|
+
async function evidence(store, state, pageId, token, kind) {
|
|
6
|
+
const prepared = await prepareHtml(store, state, pageId, token, kind);
|
|
7
|
+
if (prepared.r.status !== 'running')
|
|
8
|
+
throw new CaptureError('HTML_JOB_NOT_RUNNING', '只可提交当前运行中的草稿');
|
|
9
|
+
const directory = path.join(path.dirname(prepared.draft), 'review');
|
|
10
|
+
const sourceSha256 = digest(prepared.bytes);
|
|
11
|
+
const widths = kind === 'form' ? [1440, 390] : [prepared.png.width];
|
|
12
|
+
const inspections = [];
|
|
13
|
+
for (const width of widths) {
|
|
14
|
+
const filename = path.join(directory, `inspection-${width}.json`);
|
|
15
|
+
const receipt = await fs
|
|
16
|
+
.readFile(filename, 'utf8')
|
|
17
|
+
.then(JSON.parse)
|
|
18
|
+
.catch(() => null);
|
|
19
|
+
if (!receipt?.ok ||
|
|
20
|
+
receipt.sourceSha256 !== sourceSha256 ||
|
|
21
|
+
receipt.viewport?.width !== width ||
|
|
22
|
+
!receipt.screenshots?.length)
|
|
23
|
+
throw new CaptureError('HTML_REVIEW_REQUIRED', `需要当前草稿 ${width}px 校对成功并实际看图`);
|
|
24
|
+
for (const image of receipt.screenshots) {
|
|
25
|
+
if (typeof image.path !== 'string' ||
|
|
26
|
+
path.dirname(image.path) !== directory ||
|
|
27
|
+
(await fileDigest(image.path).catch(() => '')) !== image.sha256)
|
|
28
|
+
throw new CaptureError('HTML_REVIEW_REQUIRED', '校对截图已变化,需要重新校对');
|
|
29
|
+
}
|
|
30
|
+
inspections.push({ width, sha256: await fileDigest(filename) });
|
|
31
|
+
}
|
|
32
|
+
return { directory, sourceSha256, inspections };
|
|
33
|
+
}
|
|
34
|
+
/** The worker attests its review; this is not an independent visual quality score. */
|
|
35
|
+
export async function readyHtml(store, state, pageId, token, kind, summary) {
|
|
36
|
+
if (!summary?.trim() || summary.length > 4000)
|
|
37
|
+
throw new CaptureError('ARGUMENT_INVALID', 'summary 需为已实际检查和剩余差异的简短说明(1..4000字符)');
|
|
38
|
+
const proof = await evidence(store, state, pageId, token, kind);
|
|
39
|
+
const receiptPath = path.join(proof.directory, 'host-review.json');
|
|
40
|
+
await atomicJson(receiptPath, {
|
|
41
|
+
schema: 1,
|
|
42
|
+
kind,
|
|
43
|
+
pageId,
|
|
44
|
+
token,
|
|
45
|
+
sourceSha256: proof.sourceSha256,
|
|
46
|
+
inspections: proof.inspections,
|
|
47
|
+
summary: summary.trim(),
|
|
48
|
+
reviewedAt: new Date().toISOString(),
|
|
49
|
+
});
|
|
50
|
+
return { ok: true, kind, pageId, receiptPath, next: 'notify-coordinator', frozen: true };
|
|
51
|
+
}
|
|
52
|
+
/** Called only by the coordinator under the queue lock. Each acceptance is durable. */
|
|
53
|
+
export async function acceptReadyHtml(store, state) {
|
|
54
|
+
const status = await store.status(state);
|
|
55
|
+
const accepted = [], rejected = [];
|
|
56
|
+
for (const result of [...status.htmlResults, ...status.formHtmlResults]) {
|
|
57
|
+
if (result?.status !== 'running')
|
|
58
|
+
continue;
|
|
59
|
+
const kind = result.kind || 'page';
|
|
60
|
+
const receiptPath = path.join(path.dirname(draftPath(store, result)), 'review', 'host-review.json');
|
|
61
|
+
const receipt = await fs
|
|
62
|
+
.readFile(receiptPath, 'utf8')
|
|
63
|
+
.then(JSON.parse)
|
|
64
|
+
.catch(() => null);
|
|
65
|
+
if (!receipt)
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
const proof = await evidence(store, state, result.id, result.token, kind);
|
|
69
|
+
if (receipt.schema !== 1 ||
|
|
70
|
+
receipt.token !== result.token ||
|
|
71
|
+
receipt.pageId !== result.id ||
|
|
72
|
+
receipt.kind !== kind ||
|
|
73
|
+
receipt.sourceSha256 !== proof.sourceSha256 ||
|
|
74
|
+
JSON.stringify(receipt.inspections) !== JSON.stringify(proof.inspections) ||
|
|
75
|
+
typeof receipt.summary !== 'string' ||
|
|
76
|
+
!receipt.summary.trim() ||
|
|
77
|
+
receipt.summary.length > 4000)
|
|
78
|
+
throw new CaptureError('HTML_REVIEW_STALE', '完成回执与当前草稿/校对不一致');
|
|
79
|
+
await acceptHtml(store, state, result.id, result.token, kind, proof.sourceSha256);
|
|
80
|
+
accepted.push({ kind, pageId: result.id, summary: receipt.summary });
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (!(error instanceof CaptureError))
|
|
84
|
+
throw error;
|
|
85
|
+
rejected.push({ kind, pageId: result.id, code: error.code, message: error.message });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { accepted, rejected };
|
|
89
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Store } from './store.js';
|
|
2
|
+
import type { TaskState } from './model.js';
|
|
3
|
+
/** Deterministic local rendering, never a visual similarity assertion or model call. */
|
|
4
|
+
export declare function inspectHtml(store: Store, state: TaskState, pageId: string, token: string, kind?: 'page' | 'form', width?: number): Promise<any>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { bounded, CaptureError, digest, fileDigest, atomicJson } from './common.js';
|
|
5
|
+
import { launchChrome } from './capture.js';
|
|
6
|
+
import { prepareHtml } from './html.js';
|
|
7
|
+
/** Deterministic local rendering, never a visual similarity assertion or model call. */
|
|
8
|
+
export async function inspectHtml(store, state, pageId, token, kind = 'page', width) {
|
|
9
|
+
const prepared = await prepareHtml(store, state, pageId, token, kind);
|
|
10
|
+
const viewport = {
|
|
11
|
+
width: width ?? (kind === 'page' ? prepared.png.width : 1440),
|
|
12
|
+
height: 900,
|
|
13
|
+
};
|
|
14
|
+
if (!Number.isInteger(viewport.width) || viewport.width < 320 || viewport.width > 3840)
|
|
15
|
+
throw new CaptureError('ARGUMENT_INVALID', '校对宽度范围 320..3840');
|
|
16
|
+
const directory = path.join(path.dirname(prepared.draft), 'review');
|
|
17
|
+
await fs.mkdir(directory, { recursive: true });
|
|
18
|
+
const receiptPath = path.join(directory, `inspection-${viewport.width}.json`);
|
|
19
|
+
const sourceSha256 = digest(prepared.bytes);
|
|
20
|
+
const key = digest(JSON.stringify({ version: 3, sourceSha256, input: prepared.png.sha256, viewport }));
|
|
21
|
+
const old = await fs
|
|
22
|
+
.readFile(receiptPath, 'utf8')
|
|
23
|
+
.then(JSON.parse)
|
|
24
|
+
.catch(() => null);
|
|
25
|
+
if (old?.ok && old.key === key && Array.isArray(old.screenshots)) {
|
|
26
|
+
const valid = await Promise.all(old.screenshots.map(async (image) => path.dirname(image.path) === directory &&
|
|
27
|
+
(await fileDigest(image.path).catch(() => '')) === image.sha256));
|
|
28
|
+
if (valid.length && valid.every(Boolean))
|
|
29
|
+
return { ...old, reused: true, receiptPath };
|
|
30
|
+
}
|
|
31
|
+
const started = Date.now();
|
|
32
|
+
const errors = new Set();
|
|
33
|
+
const runtimeErrors = [];
|
|
34
|
+
const runtimeErrorDetails = [];
|
|
35
|
+
const screenshots = [];
|
|
36
|
+
const previewPath = path.join(directory, 'preview.html');
|
|
37
|
+
await fs.writeFile(previewPath, prepared.bytes, { mode: 0o600 });
|
|
38
|
+
const previewUrl = pathToFileURL(previewPath).href;
|
|
39
|
+
const sourceLines = prepared.bytes.toString('utf8').split(/\r?\n/);
|
|
40
|
+
const chrome = await launchChrome();
|
|
41
|
+
try {
|
|
42
|
+
const context = await chrome.browser.newContext({
|
|
43
|
+
viewport,
|
|
44
|
+
offline: true,
|
|
45
|
+
serviceWorkers: 'block',
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
await context.route('**/*', async (route) => {
|
|
49
|
+
if (route.request().url() === previewUrl || /^(data:|about:)/.test(route.request().url()))
|
|
50
|
+
await route.continue();
|
|
51
|
+
else {
|
|
52
|
+
errors.add('页面尝试访问外部或旁路资源');
|
|
53
|
+
await route.abort();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
const page = await context.newPage();
|
|
57
|
+
try {
|
|
58
|
+
// Capture browser source locations, including parse errors without a JS stack.
|
|
59
|
+
await page.addInitScript(() => {
|
|
60
|
+
;
|
|
61
|
+
window.__E10_INSPECTION_ERRORS__ = [];
|
|
62
|
+
addEventListener('error', (event) => {
|
|
63
|
+
const errors = window.__E10_INSPECTION_ERRORS__;
|
|
64
|
+
if (errors.length < 10 && event.message)
|
|
65
|
+
errors.push({
|
|
66
|
+
message: event.message.slice(0, 500),
|
|
67
|
+
url: event.filename,
|
|
68
|
+
line: event.lineno,
|
|
69
|
+
column: event.colno,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
page.on('pageerror', (error) => {
|
|
74
|
+
errors.add('页面存在 JavaScript 运行错误');
|
|
75
|
+
if (runtimeErrors.length < 10)
|
|
76
|
+
runtimeErrors.push(`${error.name}: ${error.message}`.slice(0, 500));
|
|
77
|
+
});
|
|
78
|
+
page.on('websocket', () => errors.add('页面尝试建立网络连接'));
|
|
79
|
+
await bounded((async () => {
|
|
80
|
+
await page.goto(previewUrl, { waitUntil: 'load', timeout: 10000 });
|
|
81
|
+
if (kind === 'form') {
|
|
82
|
+
try {
|
|
83
|
+
await page.waitForFunction(() => window.__E10_FORM_READY__ === true ||
|
|
84
|
+
window.__E10_INSPECTION_ERRORS__?.length > 0, undefined, { timeout: 10000 });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (!(error instanceof Error) || error.name !== 'TimeoutError')
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
if (!(await page.evaluate(() => window.__E10_FORM_READY__ === true)))
|
|
91
|
+
errors.add('表单未完成初始化,检查脚本错误及 __E10_FORM_READY__ 设置');
|
|
92
|
+
}
|
|
93
|
+
await page.evaluate(() => document.fonts.ready);
|
|
94
|
+
const layout = await page.evaluate(() => ({
|
|
95
|
+
height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight),
|
|
96
|
+
width: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth),
|
|
97
|
+
visible: [...document.body.querySelectorAll('*')].some((element) => element.getBoundingClientRect().width > 0 &&
|
|
98
|
+
element.getBoundingClientRect().height > 0),
|
|
99
|
+
broken: [...document.images].some((image) => !image.complete || !image.naturalWidth),
|
|
100
|
+
}));
|
|
101
|
+
if (!layout.visible)
|
|
102
|
+
errors.add('页面没有可见业务内容');
|
|
103
|
+
if (layout.broken)
|
|
104
|
+
errors.add('页面包含未加载的图片');
|
|
105
|
+
if (layout.height > 50000 || layout.width * layout.height > 60000000)
|
|
106
|
+
throw new CaptureError('HTML_RENDER_SIZE', '页面尺寸超过校对限制');
|
|
107
|
+
const save = async (name, clip) => {
|
|
108
|
+
const filename = path.join(directory, `${viewport.width}-${name}.png`);
|
|
109
|
+
const bytes = await page.screenshot({
|
|
110
|
+
fullPage: true,
|
|
111
|
+
...(clip ? { clip } : {}),
|
|
112
|
+
timeout: 10000,
|
|
113
|
+
animations: 'disabled',
|
|
114
|
+
});
|
|
115
|
+
await fs.writeFile(filename, bytes, { mode: 0o600 });
|
|
116
|
+
screenshots.push({ path: filename, sha256: digest(bytes) });
|
|
117
|
+
};
|
|
118
|
+
await save('full');
|
|
119
|
+
if (layout.height > 1800) {
|
|
120
|
+
for (const [i, y] of [
|
|
121
|
+
...new Set([0, Math.floor((layout.height - 900) / 2), layout.height - 900]),
|
|
122
|
+
].entries())
|
|
123
|
+
await save(`section-${i + 1}`, { x: 0, y, width: viewport.width, height: 900 });
|
|
124
|
+
}
|
|
125
|
+
const browserErrors = await page.evaluate(() => window.__E10_INSPECTION_ERRORS__ || []);
|
|
126
|
+
for (const error of browserErrors.slice(0, 10)) {
|
|
127
|
+
if (error.url !== previewUrl || !Number.isInteger(error.line) || error.line < 1)
|
|
128
|
+
continue;
|
|
129
|
+
runtimeErrorDetails.push({
|
|
130
|
+
message: String(error.message).slice(0, 500),
|
|
131
|
+
file: previewPath,
|
|
132
|
+
line: error.line,
|
|
133
|
+
column: error.column,
|
|
134
|
+
sourceLine: (sourceLines[error.line - 1] || '').slice(Math.max(0, error.column - 120), Math.max(0, error.column - 120) + 300),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
})(), 30000, 'HTML_RENDER_TIMEOUT');
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await bounded(page.close(), 8000, 'PAGE_CLOSE_TIMEOUT');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
await bounded(context.close(), 8000, 'CONTEXT_CLOSE_TIMEOUT');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
await chrome.close();
|
|
149
|
+
}
|
|
150
|
+
const current = await prepareHtml(store, state, pageId, token, kind);
|
|
151
|
+
if (digest(current.bytes) !== sourceSha256)
|
|
152
|
+
throw new CaptureError('HTML_REVIEW_STALE', '渲染期间草稿发生变化,请重新校对');
|
|
153
|
+
const receipt = {
|
|
154
|
+
ok: errors.size === 0,
|
|
155
|
+
key,
|
|
156
|
+
sourceSha256,
|
|
157
|
+
viewport,
|
|
158
|
+
screenshots,
|
|
159
|
+
errors: [...errors],
|
|
160
|
+
runtimeErrors,
|
|
161
|
+
runtimeErrorDetails,
|
|
162
|
+
elapsedMs: Date.now() - started,
|
|
163
|
+
reviewedAt: new Date().toISOString(),
|
|
164
|
+
visual: 'host-review-required',
|
|
165
|
+
};
|
|
166
|
+
await atomicJson(receiptPath, receipt);
|
|
167
|
+
return { ...receipt, reused: false, receiptPath };
|
|
168
|
+
}
|
package/dist/html.d.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import type { HtmlResult, TaskState } from './model.js';
|
|
1
|
+
import type { HtmlResult, PageResult, TaskState, FormCollection } from './model.js';
|
|
2
2
|
import { Store } from './store.js';
|
|
3
|
-
export declare function
|
|
3
|
+
export declare function draftPath(store: Store, r: HtmlResult): string;
|
|
4
|
+
export declare function nextHtml(store: Store, s: TaskState, options?: {
|
|
5
|
+
brief?: boolean;
|
|
6
|
+
}): Promise<{
|
|
4
7
|
state: string;
|
|
5
8
|
concurrency: number;
|
|
9
|
+
host: {
|
|
10
|
+
modelPolicy: string;
|
|
11
|
+
scheduling: string;
|
|
12
|
+
maxVisualCorrections: number;
|
|
13
|
+
};
|
|
6
14
|
jobs: any[];
|
|
7
15
|
html: {
|
|
8
16
|
required: boolean;
|
|
@@ -14,7 +22,18 @@ export declare function nextHtml(store: Store, s: TaskState): Promise<{
|
|
|
14
22
|
next: string;
|
|
15
23
|
}>;
|
|
16
24
|
export declare function validateHtml(source: string, screenshotSha256: string): void;
|
|
17
|
-
export declare function
|
|
25
|
+
export declare function activeHtml(store: Store, s: TaskState, pageId: string, token: string, kind: 'page' | 'form'): Promise<{
|
|
26
|
+
png: FormCollection | PageResult;
|
|
27
|
+
r: HtmlResult;
|
|
28
|
+
}>;
|
|
29
|
+
export declare function prepareHtml(store: Store, s: TaskState, pageId: string, token: string, kind: 'page' | 'form'): Promise<{
|
|
30
|
+
source: string;
|
|
31
|
+
bytes: Buffer<ArrayBufferLike>;
|
|
32
|
+
png: FormCollection | PageResult;
|
|
33
|
+
r: HtmlResult;
|
|
34
|
+
draft: string;
|
|
35
|
+
}>;
|
|
36
|
+
export declare function acceptHtml(store: Store, s: TaskState, pageId: string, token: string, kind?: 'page' | 'form', expectedSha256?: string): Promise<HtmlResult>;
|
|
18
37
|
export declare function failHtml(store: Store, s: TaskState, pageId: string, token: string, reason: string, kind?: 'page' | 'form'): Promise<HtmlResult>;
|
|
19
38
|
export declare function retryHtml(store: Store, s: TaskState, target?: {
|
|
20
39
|
id: string;
|