@world-engines/dag-flow 0.1.3-alpha.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/LICENSE +46 -0
- package/dist/document.d.ts +193 -0
- package/dist/document.js +704 -0
- package/dist/executor.d.ts +136 -0
- package/dist/executor.js +543 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/sandbox.d.ts +43 -0
- package/dist/sandbox.js +379 -0
- package/dist/tmw-scribe.d.ts +88 -0
- package/dist/tmw-scribe.js +128 -0
- package/package.json +50 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器脚本执行隔离层。
|
|
3
|
+
*
|
|
4
|
+
* 不可信源码只会进入 opaque-origin iframe 内创建的 Dedicated Worker。宿主窗口
|
|
5
|
+
* 宿主窗口永远不解释用户源码,也不会把任何 host API、cookie 或凭据传入。
|
|
6
|
+
*/
|
|
7
|
+
export type SandboxJson = null | boolean | number | string | SandboxJson[] | {
|
|
8
|
+
readonly [key: string]: SandboxJson;
|
|
9
|
+
};
|
|
10
|
+
export interface SandboxedScriptResult {
|
|
11
|
+
path: string;
|
|
12
|
+
body: SandboxJson;
|
|
13
|
+
}
|
|
14
|
+
export interface BrowserScriptRunnerOptions {
|
|
15
|
+
/** 超时会直接移除 iframe,从而终止其中的 Worker。默认 5 秒。 */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
/** 输入 JSON UTF-8 上限。默认 128 KiB。 */
|
|
18
|
+
maxInputBytes?: number;
|
|
19
|
+
/** 输出 JSON UTF-8 上限。默认 128 KiB。 */
|
|
20
|
+
maxOutputBytes?: number;
|
|
21
|
+
/** 将隐藏 iframe 挂到指定容器;默认 document.body。 */
|
|
22
|
+
container?: HTMLElement;
|
|
23
|
+
/** 仅用于测试或嵌入到非全局 document 的页面。 */
|
|
24
|
+
document?: Document;
|
|
25
|
+
}
|
|
26
|
+
export interface RunSandboxedScriptOptions extends BrowserScriptRunnerOptions {
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
}
|
|
29
|
+
export interface BrowserScriptRunner {
|
|
30
|
+
run(source: string, input: unknown, options?: Pick<RunSandboxedScriptOptions, "signal" | "timeoutMs" | "maxInputBytes" | "maxOutputBytes">): Promise<SandboxedScriptResult>;
|
|
31
|
+
dispose(): void;
|
|
32
|
+
}
|
|
33
|
+
export declare class SandboxScriptError extends Error {
|
|
34
|
+
readonly code: "aborted" | "timeout" | "protocol" | "validation" | "execution" | "unavailable";
|
|
35
|
+
constructor(code: SandboxScriptError["code"], message: string);
|
|
36
|
+
}
|
|
37
|
+
/** 创建可复用的执行器;每次 run 仍会创建全新的 iframe 与 Worker。 */
|
|
38
|
+
export declare function createBrowserScriptRunner(baseOptions?: BrowserScriptRunnerOptions): BrowserScriptRunner;
|
|
39
|
+
/**
|
|
40
|
+
* 在浏览器内执行 `main(input)`。source 必须是一个函数表达式,例如
|
|
41
|
+
* `async (input) => ({ path: "next", body: input })`。
|
|
42
|
+
*/
|
|
43
|
+
export declare function runSandboxedScript(source: string, input: unknown, options?: RunSandboxedScriptOptions): Promise<SandboxedScriptResult>;
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器脚本执行隔离层。
|
|
3
|
+
*
|
|
4
|
+
* 不可信源码只会进入 opaque-origin iframe 内创建的 Dedicated Worker。宿主窗口
|
|
5
|
+
* 宿主窗口永远不解释用户源码,也不会把任何 host API、cookie 或凭据传入。
|
|
6
|
+
*/
|
|
7
|
+
export class SandboxScriptError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "SandboxScriptError";
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
16
|
+
const DEFAULT_MAX_INPUT_BYTES = 128 * 1024;
|
|
17
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 128 * 1024;
|
|
18
|
+
const MAX_SOURCE_BYTES = 512 * 1024;
|
|
19
|
+
const READY_TYPE = "chat.dag-flow.sandbox.ready";
|
|
20
|
+
const BIND_TYPE = "chat.dag-flow.sandbox.bind";
|
|
21
|
+
const RUN_TYPE = "chat.dag-flow.sandbox.run";
|
|
22
|
+
const RESULT_TYPE = "chat.dag-flow.sandbox.result";
|
|
23
|
+
const ERROR_TYPE = "chat.dag-flow.sandbox.error";
|
|
24
|
+
const ABORT_TYPE = "chat.dag-flow.sandbox.abort";
|
|
25
|
+
/** 创建可复用的执行器;每次 run 仍会创建全新的 iframe 与 Worker。 */
|
|
26
|
+
export function createBrowserScriptRunner(baseOptions = {}) {
|
|
27
|
+
let disposed = false;
|
|
28
|
+
const active = new Set();
|
|
29
|
+
return {
|
|
30
|
+
async run(source, input, options = {}) {
|
|
31
|
+
if (disposed)
|
|
32
|
+
throw new SandboxScriptError("unavailable", "浏览器脚本执行器已销毁");
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
active.add(controller);
|
|
35
|
+
const forwardAbort = () => controller.abort();
|
|
36
|
+
if (options.signal?.aborted)
|
|
37
|
+
controller.abort();
|
|
38
|
+
else
|
|
39
|
+
options.signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
40
|
+
try {
|
|
41
|
+
return await runSandboxedScript(source, input, {
|
|
42
|
+
...baseOptions,
|
|
43
|
+
...options,
|
|
44
|
+
signal: controller.signal,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
options.signal?.removeEventListener("abort", forwardAbort);
|
|
49
|
+
active.delete(controller);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
dispose() {
|
|
53
|
+
if (disposed)
|
|
54
|
+
return;
|
|
55
|
+
disposed = true;
|
|
56
|
+
for (const controller of active)
|
|
57
|
+
controller.abort();
|
|
58
|
+
active.clear();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 在浏览器内执行 `main(input)`。source 必须是一个函数表达式,例如
|
|
64
|
+
* `async (input) => ({ path: "next", body: input })`。
|
|
65
|
+
*/
|
|
66
|
+
export function runSandboxedScript(source, input, options = {}) {
|
|
67
|
+
const documentRef = options.document ?? globalThis.document;
|
|
68
|
+
const windowRef = documentRef?.defaultView ?? globalThis.window;
|
|
69
|
+
if (!documentRef || !windowRef || typeof MessageChannel === "undefined") {
|
|
70
|
+
return Promise.reject(new SandboxScriptError("unavailable", "当前环境不支持浏览器 iframe sandbox"));
|
|
71
|
+
}
|
|
72
|
+
if (options.signal?.aborted)
|
|
73
|
+
return Promise.reject(new SandboxScriptError("aborted", "脚本执行已取消"));
|
|
74
|
+
if (typeof source !== "string")
|
|
75
|
+
return Promise.reject(new SandboxScriptError("validation", "脚本源码必须是字符串"));
|
|
76
|
+
if (utf8Length(source) > MAX_SOURCE_BYTES) {
|
|
77
|
+
return Promise.reject(new SandboxScriptError("validation", `脚本源码超过 ${MAX_SOURCE_BYTES} 字节上限`));
|
|
78
|
+
}
|
|
79
|
+
let maxInputBytes;
|
|
80
|
+
let maxOutputBytes;
|
|
81
|
+
let timeoutMs;
|
|
82
|
+
let jsonInput;
|
|
83
|
+
try {
|
|
84
|
+
maxInputBytes = checkedLimit(options.maxInputBytes, DEFAULT_MAX_INPUT_BYTES, "输入");
|
|
85
|
+
maxOutputBytes = checkedLimit(options.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES, "输出");
|
|
86
|
+
timeoutMs = checkedTimeout(options.timeoutMs);
|
|
87
|
+
jsonInput = normalizeJson(input, maxInputBytes, "输入");
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
return Promise.reject(error);
|
|
91
|
+
}
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const nonce = secureNonce();
|
|
94
|
+
const channel = new MessageChannel();
|
|
95
|
+
const iframe = documentRef.createElement("iframe");
|
|
96
|
+
let settled = false;
|
|
97
|
+
let bound = false;
|
|
98
|
+
let timeout;
|
|
99
|
+
iframe.setAttribute("sandbox", "allow-scripts");
|
|
100
|
+
iframe.setAttribute("referrerpolicy", "no-referrer");
|
|
101
|
+
iframe.setAttribute("aria-hidden", "true");
|
|
102
|
+
iframe.tabIndex = -1;
|
|
103
|
+
iframe.style.display = "none";
|
|
104
|
+
iframe.srcdoc = createIframeDocument(source, nonce, maxOutputBytes);
|
|
105
|
+
const cleanup = () => {
|
|
106
|
+
if (timeout !== undefined)
|
|
107
|
+
clearTimeout(timeout);
|
|
108
|
+
windowRef.removeEventListener("message", onWindowMessage);
|
|
109
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
110
|
+
channel.port1.onmessage = null;
|
|
111
|
+
channel.port1.onmessageerror = null;
|
|
112
|
+
channel.port1.close();
|
|
113
|
+
// 移除 browsing context 会终止 iframe 及其 Dedicated Worker,即使用户代码死循环。
|
|
114
|
+
iframe.remove();
|
|
115
|
+
};
|
|
116
|
+
const finish = (outcome) => {
|
|
117
|
+
if (settled)
|
|
118
|
+
return;
|
|
119
|
+
settled = true;
|
|
120
|
+
cleanup();
|
|
121
|
+
if ("result" in outcome)
|
|
122
|
+
resolve(outcome.result);
|
|
123
|
+
else
|
|
124
|
+
reject(outcome.error);
|
|
125
|
+
};
|
|
126
|
+
const fail = (code, message) => finish({ error: new SandboxScriptError(code, message) });
|
|
127
|
+
const onAbort = () => {
|
|
128
|
+
if (bound) {
|
|
129
|
+
try {
|
|
130
|
+
channel.port1.postMessage({ type: ABORT_TYPE, nonce });
|
|
131
|
+
}
|
|
132
|
+
catch { /* 已销毁的 port 无需处理 */ }
|
|
133
|
+
}
|
|
134
|
+
fail("aborted", "脚本执行已取消");
|
|
135
|
+
};
|
|
136
|
+
const onWindowMessage = (event) => {
|
|
137
|
+
// opaque iframe 的 origin 必然为 null;绝不因 origin=null 单独信任消息。
|
|
138
|
+
if (event.origin !== "null" || event.source !== iframe.contentWindow || !isReadyMessage(event.data, nonce))
|
|
139
|
+
return;
|
|
140
|
+
if (bound)
|
|
141
|
+
return;
|
|
142
|
+
bound = true;
|
|
143
|
+
try {
|
|
144
|
+
iframe.contentWindow?.postMessage({ type: BIND_TYPE, nonce }, "*", [channel.port2]);
|
|
145
|
+
channel.port1.start();
|
|
146
|
+
channel.port1.onmessage = onPortMessage;
|
|
147
|
+
channel.port1.onmessageerror = () => fail("protocol", "sandbox 消息通道损坏");
|
|
148
|
+
channel.port1.postMessage({ type: RUN_TYPE, nonce, input: jsonInput });
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
fail("protocol", "无法绑定 sandbox 消息通道");
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const onPortMessage = (event) => {
|
|
155
|
+
const message = event.data;
|
|
156
|
+
if (!isRecord(message) || message.nonce !== nonce)
|
|
157
|
+
return;
|
|
158
|
+
if (message.type === ERROR_TYPE && typeof message.message === "string") {
|
|
159
|
+
fail("execution", safeErrorMessage(message.message));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (message.type !== RESULT_TYPE)
|
|
163
|
+
return;
|
|
164
|
+
try {
|
|
165
|
+
finish({ result: normalizeResult(message.result, maxOutputBytes) });
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
finish({ error: asSandboxError(error) });
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
windowRef.addEventListener("message", onWindowMessage);
|
|
172
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
173
|
+
timeout = setTimeout(() => fail("timeout", `脚本执行超过 ${timeoutMs}ms`), timeoutMs);
|
|
174
|
+
const mount = options.container ?? documentRef.body;
|
|
175
|
+
if (!mount) {
|
|
176
|
+
fail("unavailable", "document.body 尚不可用,无法挂载 sandbox iframe");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
mount.append(iframe);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function checkedLimit(value, fallback, name) {
|
|
183
|
+
const limit = value ?? fallback;
|
|
184
|
+
if (!Number.isSafeInteger(limit) || limit <= 0)
|
|
185
|
+
throw new SandboxScriptError("validation", `${name}大小上限必须是正整数`);
|
|
186
|
+
return limit;
|
|
187
|
+
}
|
|
188
|
+
function checkedTimeout(value) {
|
|
189
|
+
const timeout = value ?? DEFAULT_TIMEOUT_MS;
|
|
190
|
+
if (!Number.isSafeInteger(timeout) || timeout <= 0)
|
|
191
|
+
throw new SandboxScriptError("validation", "超时必须是正整数毫秒数");
|
|
192
|
+
return timeout;
|
|
193
|
+
}
|
|
194
|
+
function secureNonce() {
|
|
195
|
+
const cryptoRef = globalThis.crypto;
|
|
196
|
+
if (!cryptoRef?.getRandomValues)
|
|
197
|
+
throw new SandboxScriptError("unavailable", "当前浏览器缺少安全随机数 API");
|
|
198
|
+
const bytes = new Uint8Array(24);
|
|
199
|
+
cryptoRef.getRandomValues(bytes);
|
|
200
|
+
return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
|
|
201
|
+
}
|
|
202
|
+
function isReadyMessage(value, nonce) {
|
|
203
|
+
return isRecord(value) && value.type === READY_TYPE && value.nonce === nonce && Object.keys(value).length === 2;
|
|
204
|
+
}
|
|
205
|
+
function normalizeResult(value, maxBytes) {
|
|
206
|
+
if (!isRecord(value) || Object.keys(value).length !== 2 || !("path" in value) || !("body" in value)) {
|
|
207
|
+
throw new SandboxScriptError("validation", "脚本必须返回且只返回 { path, body }");
|
|
208
|
+
}
|
|
209
|
+
if (typeof value.path !== "string" || value.path.length === 0) {
|
|
210
|
+
throw new SandboxScriptError("validation", "脚本返回的 path 必须是非空字符串");
|
|
211
|
+
}
|
|
212
|
+
return { path: value.path, body: normalizeJson(value.body, maxBytes, "输出") };
|
|
213
|
+
}
|
|
214
|
+
function normalizeJson(value, maxBytes, label) {
|
|
215
|
+
const normalized = normalizeJsonValue(value, new Set());
|
|
216
|
+
const encoded = JSON.stringify(normalized);
|
|
217
|
+
if (utf8Length(encoded) > maxBytes)
|
|
218
|
+
throw new SandboxScriptError("validation", `${label}超过 ${maxBytes} 字节上限`);
|
|
219
|
+
return normalized;
|
|
220
|
+
}
|
|
221
|
+
function normalizeJsonValue(value, seen) {
|
|
222
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
223
|
+
return value;
|
|
224
|
+
if (typeof value === "number") {
|
|
225
|
+
if (!Number.isFinite(value))
|
|
226
|
+
throw new SandboxScriptError("validation", "JSON 数字必须是有限值");
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
if (Array.isArray(value)) {
|
|
230
|
+
if (seen.has(value))
|
|
231
|
+
throw new SandboxScriptError("validation", "JSON 不能包含循环引用");
|
|
232
|
+
seen.add(value);
|
|
233
|
+
try {
|
|
234
|
+
return value.map((item) => normalizeJsonValue(item, seen));
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
seen.delete(value);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (isRecord(value)) {
|
|
241
|
+
if (seen.has(value))
|
|
242
|
+
throw new SandboxScriptError("validation", "JSON 不能包含循环引用");
|
|
243
|
+
seen.add(value);
|
|
244
|
+
try {
|
|
245
|
+
const output = Object.create(null);
|
|
246
|
+
for (const key of Object.keys(value))
|
|
247
|
+
output[key] = normalizeJsonValue(value[key], seen);
|
|
248
|
+
return output;
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
seen.delete(value);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
throw new SandboxScriptError("validation", "值必须是 JSON 可序列化数据");
|
|
255
|
+
}
|
|
256
|
+
function isRecord(value) {
|
|
257
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
258
|
+
return false;
|
|
259
|
+
const prototype = Object.getPrototypeOf(value);
|
|
260
|
+
return prototype === Object.prototype || prototype === null;
|
|
261
|
+
}
|
|
262
|
+
function utf8Length(value) {
|
|
263
|
+
return new TextEncoder().encode(value).byteLength;
|
|
264
|
+
}
|
|
265
|
+
function safeErrorMessage(message) {
|
|
266
|
+
return message.slice(0, 1_024) || "用户脚本执行失败";
|
|
267
|
+
}
|
|
268
|
+
function asSandboxError(error) {
|
|
269
|
+
return error instanceof SandboxScriptError ? error : new SandboxScriptError("protocol", "sandbox 返回了无效消息");
|
|
270
|
+
}
|
|
271
|
+
function createIframeDocument(source, nonce, maxOutputBytes) {
|
|
272
|
+
// `</script` 必须转义,否则用户源码能提前结束可信 bootstrap 的 script 标签。
|
|
273
|
+
const sourceLiteral = JSON.stringify(source).replaceAll("<", "\\u003c");
|
|
274
|
+
const nonceLiteral = JSON.stringify(nonce);
|
|
275
|
+
const maxBytesLiteral = JSON.stringify(maxOutputBytes);
|
|
276
|
+
return `<!doctype html><html><head>
|
|
277
|
+
<meta charset="utf-8">
|
|
278
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' blob:; worker-src blob:; child-src blob:; connect-src 'none'; img-src 'none'; media-src 'none'; object-src 'none'; style-src 'none'; font-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'">
|
|
279
|
+
</head><body><script>
|
|
280
|
+
(() => {
|
|
281
|
+
'use strict';
|
|
282
|
+
const READY = ${JSON.stringify(READY_TYPE)};
|
|
283
|
+
const BIND = ${JSON.stringify(BIND_TYPE)};
|
|
284
|
+
const RUN = ${JSON.stringify(RUN_TYPE)};
|
|
285
|
+
const RESULT = ${JSON.stringify(RESULT_TYPE)};
|
|
286
|
+
const ERROR = ${JSON.stringify(ERROR_TYPE)};
|
|
287
|
+
const ABORT = ${JSON.stringify(ABORT_TYPE)};
|
|
288
|
+
const nonce = ${nonceLiteral};
|
|
289
|
+
const source = ${sourceLiteral};
|
|
290
|
+
const maxOutputBytes = ${maxBytesLiteral};
|
|
291
|
+
let port = null;
|
|
292
|
+
let worker = null;
|
|
293
|
+
const parentPostMessage = window.parent.postMessage.bind(window.parent);
|
|
294
|
+
const addWindowListener = window.addEventListener.bind(window);
|
|
295
|
+
const makeBlobUrl = URL.createObjectURL.bind(URL);
|
|
296
|
+
const revokeBlobUrl = URL.revokeObjectURL.bind(URL);
|
|
297
|
+
|
|
298
|
+
const workerPrefix = String.raw\`
|
|
299
|
+
'use strict';
|
|
300
|
+
const __post = self.postMessage.bind(self);
|
|
301
|
+
const __addEventListener = self.addEventListener.bind(self);
|
|
302
|
+
const __arrayIsArray = Array.isArray.bind(Array);
|
|
303
|
+
const __getPrototypeOf = Object.getPrototypeOf.bind(Object);
|
|
304
|
+
const __objectPrototype = Object.prototype;
|
|
305
|
+
const __objectCreate = Object.create.bind(Object);
|
|
306
|
+
const __objectKeys = Object.keys.bind(Object);
|
|
307
|
+
const __hasOwn = Object.prototype.hasOwnProperty.call.bind(Object.prototype.hasOwnProperty);
|
|
308
|
+
const __isFinite = Number.isFinite.bind(Number);
|
|
309
|
+
const __Set = Set;
|
|
310
|
+
const __TextEncoder = TextEncoder;
|
|
311
|
+
const __arrayMap = Array.prototype.map.call.bind(Array.prototype.map);
|
|
312
|
+
const __jsonStringify = JSON.stringify.bind(JSON);
|
|
313
|
+
const __isRecord = (value) => value !== null && typeof value === 'object' && !__arrayIsArray(value) && (__getPrototypeOf(value) === __objectPrototype || __getPrototypeOf(value) === null);
|
|
314
|
+
const __json = (value, seen = new __Set()) => {
|
|
315
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
316
|
+
if (typeof value === 'number') { if (!__isFinite(value)) throw new Error('JSON number must be finite'); return value; }
|
|
317
|
+
if (__arrayIsArray(value)) { if (seen.has(value)) throw new Error('JSON cycle'); seen.add(value); try { return __arrayMap(value, (item) => __json(item, seen)); } finally { seen.delete(value); } }
|
|
318
|
+
if (__isRecord(value)) { if (seen.has(value)) throw new Error('JSON cycle'); seen.add(value); try { const out = __objectCreate(null); for (const key of __objectKeys(value)) out[key] = __json(value[key], seen); return out; } finally { seen.delete(value); } }
|
|
319
|
+
throw new Error('value is not JSON serializable');
|
|
320
|
+
};
|
|
321
|
+
const __result = (value) => {
|
|
322
|
+
if (!__isRecord(value) || __objectKeys(value).length !== 2 || !__hasOwn(value, 'path') || !__hasOwn(value, 'body') || typeof value.path !== 'string' || value.path.length === 0) throw new Error('main must return exactly { path, body }');
|
|
323
|
+
const result = { path: value.path, body: __json(value.body) };
|
|
324
|
+
if (new __TextEncoder().encode(__jsonStringify(result)).byteLength > ${maxBytesLiteral}) throw new Error('result exceeds output limit');
|
|
325
|
+
return result;
|
|
326
|
+
};
|
|
327
|
+
const __main = (() => {
|
|
328
|
+
\`;
|
|
329
|
+
// source 是 JavaScript 源码,但它只会被拼进 iframe 内的 Worker 文件;宿主 window
|
|
330
|
+
// 从不解释它;源码只作为 Worker 文件的私有作用域内容。
|
|
331
|
+
const workerSuffix = String.raw\`
|
|
332
|
+
return typeof main === 'function' ? main : null;
|
|
333
|
+
})();
|
|
334
|
+
__addEventListener('message', async (event) => {
|
|
335
|
+
const message = event.data;
|
|
336
|
+
if (!message || message.type !== 'run') return;
|
|
337
|
+
if (typeof __main !== 'function') { __post({ type: 'error', id: message.id, message: 'source must evaluate to a function' }); return; }
|
|
338
|
+
try { __post({ type: 'result', id: message.id, result: __result(await __main(message.input)) }); }
|
|
339
|
+
catch (error) { __post({ type: 'error', id: message.id, message: String(error && error.message || error).slice(0, 1024) }); }
|
|
340
|
+
});
|
|
341
|
+
\`;
|
|
342
|
+
const workerSource = workerPrefix + source + workerSuffix;
|
|
343
|
+
|
|
344
|
+
const stopWorker = () => {
|
|
345
|
+
if (worker) worker.terminate();
|
|
346
|
+
worker = null;
|
|
347
|
+
};
|
|
348
|
+
const send = (message) => port && port.postMessage(message);
|
|
349
|
+
const start = (input) => {
|
|
350
|
+
stopWorker();
|
|
351
|
+
const url = makeBlobUrl(new Blob([workerSource], { type: 'text/javascript' }));
|
|
352
|
+
worker = new Worker(url);
|
|
353
|
+
revokeBlobUrl(url);
|
|
354
|
+
const current = worker;
|
|
355
|
+
current.onmessage = (event) => {
|
|
356
|
+
const message = event.data;
|
|
357
|
+
if (!message || (message.type !== 'result' && message.type !== 'error')) return;
|
|
358
|
+
send({ type: message.type === 'result' ? RESULT : ERROR, nonce, result: message.result, message: message.message });
|
|
359
|
+
stopWorker();
|
|
360
|
+
};
|
|
361
|
+
current.onerror = () => { send({ type: ERROR, nonce, message: '用户脚本执行失败' }); stopWorker(); };
|
|
362
|
+
current.postMessage({ type: 'run', id: nonce, input });
|
|
363
|
+
};
|
|
364
|
+
const bind = (event) => {
|
|
365
|
+
if (event.source !== window.parent || !event.data || event.data.type !== BIND || event.data.nonce !== nonce || event.ports.length !== 1 || port) return;
|
|
366
|
+
port = event.ports[0];
|
|
367
|
+
port.onmessage = (portEvent) => {
|
|
368
|
+
const message = portEvent.data;
|
|
369
|
+
if (!message || message.nonce !== nonce) return;
|
|
370
|
+
if (message.type === ABORT) { stopWorker(); return; }
|
|
371
|
+
if (message.type === RUN) start(message.input);
|
|
372
|
+
};
|
|
373
|
+
port.start();
|
|
374
|
+
};
|
|
375
|
+
addWindowListener('message', bind);
|
|
376
|
+
parentPostMessage({ type: READY, nonce }, '*');
|
|
377
|
+
})();
|
|
378
|
+
</script></body></html>`;
|
|
379
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Director -> TMW -> Scribe 协调的无副作用边界。
|
|
3
|
+
*
|
|
4
|
+
* 该模块不实现数据库或 LLM;调用方通过 ports 注入真实实现。
|
|
5
|
+
* Scribe 在一次 judge 调用中接收所有自然语言条件,避免逐条件产生 LLM 调用。
|
|
6
|
+
*/
|
|
7
|
+
export interface TmwTriggerForScribe {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly naturalLanguage?: string;
|
|
10
|
+
readonly enabled?: boolean;
|
|
11
|
+
/** 时间触发器用于在场记提交前做确定性拦截。 */
|
|
12
|
+
readonly dueAtMs?: number;
|
|
13
|
+
readonly crossingPolicy?: "forward_only" | "backward_only" | "both_directions";
|
|
14
|
+
}
|
|
15
|
+
export interface TmwTriggerVerdict {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly truth: "true" | "false" | "unknown";
|
|
18
|
+
readonly reason?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ScribeTriggerInput {
|
|
21
|
+
readonly manuscript: string;
|
|
22
|
+
readonly scene?: unknown;
|
|
23
|
+
readonly worldTimeMs?: number;
|
|
24
|
+
/** Director 已按剧本历法换算的有符号时间变化。 */
|
|
25
|
+
readonly timeDeltaMs?: number;
|
|
26
|
+
readonly triggers: readonly TmwTriggerForScribe[];
|
|
27
|
+
/** TMW recall 结果;由 production caller 在同一 pass 内缓存并复用。 */
|
|
28
|
+
readonly recalled?: readonly unknown[];
|
|
29
|
+
}
|
|
30
|
+
/** 真实 TMW 数据库检索边界。实现方应连接 world_search/recall API。 */
|
|
31
|
+
export interface ScribeRecallPort {
|
|
32
|
+
recall(input: Readonly<{
|
|
33
|
+
manuscript: string;
|
|
34
|
+
scene?: unknown;
|
|
35
|
+
worldTimeMs?: number;
|
|
36
|
+
}>): Promise<readonly unknown[]>;
|
|
37
|
+
}
|
|
38
|
+
export interface ScribeTriggerJudgePort {
|
|
39
|
+
judge(input: Readonly<{
|
|
40
|
+
readonly manuscript: string;
|
|
41
|
+
readonly scene?: unknown;
|
|
42
|
+
readonly worldTimeMs?: number;
|
|
43
|
+
readonly timeDeltaMs?: number;
|
|
44
|
+
readonly predicates: readonly {
|
|
45
|
+
readonly id: string;
|
|
46
|
+
readonly text: string;
|
|
47
|
+
}[];
|
|
48
|
+
readonly recalled?: readonly unknown[];
|
|
49
|
+
}>): Promise<readonly TmwTriggerVerdict[]>;
|
|
50
|
+
}
|
|
51
|
+
export interface ScribeTriggerCommitPort {
|
|
52
|
+
commit(input: Readonly<{
|
|
53
|
+
readonly manuscript: string;
|
|
54
|
+
readonly scene?: unknown;
|
|
55
|
+
readonly worldTimeMs?: number;
|
|
56
|
+
readonly timeDeltaMs?: number;
|
|
57
|
+
readonly verdicts: readonly TmwTriggerVerdict[];
|
|
58
|
+
readonly recalled?: readonly unknown[];
|
|
59
|
+
}>): Promise<unknown>;
|
|
60
|
+
}
|
|
61
|
+
export interface ScribeTriggerPassOptions {
|
|
62
|
+
readonly judge: ScribeTriggerJudgePort;
|
|
63
|
+
readonly commit: ScribeTriggerCommitPort;
|
|
64
|
+
readonly maxAttempts?: number;
|
|
65
|
+
readonly recallCache?: Map<string, readonly TmwTriggerVerdict[]>;
|
|
66
|
+
readonly recallKey?: string;
|
|
67
|
+
readonly signal?: AbortSignal;
|
|
68
|
+
/** Optional real TMW recall. It is called once per recallKey and cached across retries. */
|
|
69
|
+
readonly recall?: ScribeRecallPort;
|
|
70
|
+
readonly onRetry?: (event: Readonly<{
|
|
71
|
+
attempt: number;
|
|
72
|
+
reason: string;
|
|
73
|
+
}>) => void | Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
export interface ScribeTriggerPassResult {
|
|
76
|
+
readonly status: "committed" | "rejected" | "aborted";
|
|
77
|
+
readonly verdicts: readonly TmwTriggerVerdict[];
|
|
78
|
+
readonly receipt?: unknown;
|
|
79
|
+
readonly attempts: number;
|
|
80
|
+
readonly reason?: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 场记触发器 pass:所有自然语言条件一次性判断;同一 recallKey 的重试复用 verdict。
|
|
84
|
+
* commit 返回 rejected 时只重试提交/判定流程,不重新召回外部数据库结果。
|
|
85
|
+
*/
|
|
86
|
+
export declare function runScribeTriggerPass(input: ScribeTriggerInput, options: ScribeTriggerPassOptions): Promise<ScribeTriggerPassResult>;
|
|
87
|
+
/** 确定性时间门:手稿造成的世界时间不能跨过最近延迟触发器。 */
|
|
88
|
+
export declare function manuscriptCrossesTimeTrigger(worldTimeMs: number, timeDeltaMs: number, nearestDueAtMs: number | undefined, policy?: "forward_only" | "backward_only" | "both_directions"): boolean;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Director -> TMW -> Scribe 协调的无副作用边界。
|
|
3
|
+
*
|
|
4
|
+
* 该模块不实现数据库或 LLM;调用方通过 ports 注入真实实现。
|
|
5
|
+
* Scribe 在一次 judge 调用中接收所有自然语言条件,避免逐条件产生 LLM 调用。
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* 场记触发器 pass:所有自然语言条件一次性判断;同一 recallKey 的重试复用 verdict。
|
|
9
|
+
* commit 返回 rejected 时只重试提交/判定流程,不重新召回外部数据库结果。
|
|
10
|
+
*/
|
|
11
|
+
export async function runScribeTriggerPass(input, options) {
|
|
12
|
+
const triggerIds = new Set();
|
|
13
|
+
for (const trigger of input.triggers) {
|
|
14
|
+
if (typeof trigger.id !== "string" || !trigger.id.trim() || triggerIds.has(trigger.id)
|
|
15
|
+
|| (trigger.enabled !== undefined && typeof trigger.enabled !== "boolean")
|
|
16
|
+
|| (trigger.naturalLanguage !== undefined && typeof trigger.naturalLanguage !== "string")) {
|
|
17
|
+
return { status: "rejected", verdicts: [], attempts: 0, reason: "scribe_trigger_definitions_invalid" };
|
|
18
|
+
}
|
|
19
|
+
triggerIds.add(trigger.id);
|
|
20
|
+
}
|
|
21
|
+
if (options.maxAttempts !== undefined && (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 1)) {
|
|
22
|
+
return { status: "rejected", verdicts: [], attempts: 0, reason: "scribe_retry_limit_invalid" };
|
|
23
|
+
}
|
|
24
|
+
if (input.timeDeltaMs !== undefined) {
|
|
25
|
+
if (!Number.isFinite(input.timeDeltaMs) || !Number.isFinite(input.worldTimeMs)
|
|
26
|
+
|| !Number.isFinite(input.worldTimeMs + input.timeDeltaMs)) {
|
|
27
|
+
return { status: "rejected", verdicts: [], attempts: 0, reason: "manuscript_time_invalid" };
|
|
28
|
+
}
|
|
29
|
+
for (const trigger of input.triggers) {
|
|
30
|
+
if (trigger.enabled === false || trigger.dueAtMs === undefined)
|
|
31
|
+
continue;
|
|
32
|
+
if (!Number.isFinite(trigger.dueAtMs))
|
|
33
|
+
return { status: "rejected", verdicts: [], attempts: 0, reason: "trigger_time_invalid" };
|
|
34
|
+
if (trigger.crossingPolicy !== undefined && !["forward_only", "backward_only", "both_directions"].includes(trigger.crossingPolicy)) {
|
|
35
|
+
return { status: "rejected", verdicts: [], attempts: 0, reason: "trigger_crossing_policy_invalid" };
|
|
36
|
+
}
|
|
37
|
+
if (manuscriptCrossesTimeTrigger(input.worldTimeMs, input.timeDeltaMs, trigger.dueAtMs, trigger.crossingPolicy)) {
|
|
38
|
+
return { status: "rejected", verdicts: [], attempts: 0, reason: `manuscript_time_trigger_crossed:${trigger.id}` };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const maxAttempts = Math.max(1, Math.floor(options.maxAttempts ?? 2));
|
|
43
|
+
const cache = options.recallCache;
|
|
44
|
+
// 同一个 turn 的手稿修订或场景变化必须重新判断,不能复用旧真假结果。
|
|
45
|
+
const key = JSON.stringify([options.recallKey ?? null, input.manuscript, input.worldTimeMs ?? null, input.timeDeltaMs ?? null, input.scene ?? null, input.triggers]);
|
|
46
|
+
const recallKey = options.recallKey ?? key;
|
|
47
|
+
let verdicts = cache?.get(key);
|
|
48
|
+
let recalled = input.recalled;
|
|
49
|
+
const recallCache = options.recallCache;
|
|
50
|
+
if (recalled === undefined && recallCache?.__tmwRecall)
|
|
51
|
+
recalled = recallCache.__tmwRecall.get(recallKey);
|
|
52
|
+
let attempts = 0;
|
|
53
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
54
|
+
attempts = attempt;
|
|
55
|
+
if (options.signal?.aborted)
|
|
56
|
+
return { status: "aborted", verdicts: verdicts ?? [], attempts };
|
|
57
|
+
try {
|
|
58
|
+
if (verdicts === undefined) {
|
|
59
|
+
if (recalled === undefined && options.recall) {
|
|
60
|
+
recalled = await options.recall.recall({ manuscript: input.manuscript, ...(input.scene === undefined ? {} : { scene: input.scene }), ...(input.worldTimeMs === undefined ? {} : { worldTimeMs: input.worldTimeMs }) });
|
|
61
|
+
if (recallCache) {
|
|
62
|
+
recallCache.__tmwRecall ??= new Map();
|
|
63
|
+
recallCache.__tmwRecall.set(recallKey, recalled);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const predicates = input.triggers
|
|
67
|
+
.filter((trigger) => trigger.enabled !== false && typeof trigger.naturalLanguage === "string" && trigger.naturalLanguage.length > 0)
|
|
68
|
+
.map((trigger) => ({ id: trigger.id, text: trigger.naturalLanguage }));
|
|
69
|
+
if (options.signal?.aborted)
|
|
70
|
+
return { status: "aborted", verdicts: [], attempts };
|
|
71
|
+
const candidate = await options.judge.judge({ manuscript: input.manuscript, ...(input.scene === undefined ? {} : { scene: input.scene }), ...(input.worldTimeMs === undefined ? {} : { worldTimeMs: input.worldTimeMs }), ...(input.timeDeltaMs === undefined ? {} : { timeDeltaMs: input.timeDeltaMs }), predicates, ...(recalled === undefined ? {} : { recalled }) });
|
|
72
|
+
if (options.signal?.aborted)
|
|
73
|
+
return { status: "aborted", verdicts: [], attempts };
|
|
74
|
+
const candidateError = validateVerdicts(candidate, input.triggers);
|
|
75
|
+
if (candidateError !== undefined)
|
|
76
|
+
throw new Error(candidateError);
|
|
77
|
+
verdicts = candidate;
|
|
78
|
+
if (cache)
|
|
79
|
+
cache.set(key, verdicts);
|
|
80
|
+
}
|
|
81
|
+
const invalid = validateVerdicts(verdicts, input.triggers);
|
|
82
|
+
if (invalid !== undefined)
|
|
83
|
+
throw new Error(invalid);
|
|
84
|
+
const blocked = verdicts.find((verdict) => verdict.truth === "unknown");
|
|
85
|
+
if (blocked)
|
|
86
|
+
return { status: "rejected", verdicts, attempts, reason: blocked.reason ?? `trigger_unknown:${blocked.id}` };
|
|
87
|
+
if (options.signal?.aborted)
|
|
88
|
+
return { status: "aborted", verdicts, attempts };
|
|
89
|
+
const receipt = await options.commit.commit({ manuscript: input.manuscript, ...(input.scene === undefined ? {} : { scene: input.scene }), ...(input.worldTimeMs === undefined ? {} : { worldTimeMs: input.worldTimeMs }), ...(input.timeDeltaMs === undefined ? {} : { timeDeltaMs: input.timeDeltaMs }), verdicts, ...(recalled === undefined ? {} : { recalled }) });
|
|
90
|
+
if (receipt === null || receipt === undefined || (typeof receipt === "object"
|
|
91
|
+
&& (receipt.ok === false || receipt.status === "rejected"))) {
|
|
92
|
+
return { status: "rejected", verdicts, attempts, reason: "scribe_commit_not_confirmed" };
|
|
93
|
+
}
|
|
94
|
+
return { status: "committed", verdicts, receipt, attempts };
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
98
|
+
if (attempt >= maxAttempts)
|
|
99
|
+
return { status: "rejected", verdicts: verdicts ?? [], attempts, reason };
|
|
100
|
+
await options.onRetry?.({ attempt, reason });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { status: "rejected", verdicts: verdicts ?? [], attempts, reason: "scribe_trigger_pass_failed" };
|
|
104
|
+
}
|
|
105
|
+
/** 确定性时间门:手稿造成的世界时间不能跨过最近延迟触发器。 */
|
|
106
|
+
export function manuscriptCrossesTimeTrigger(worldTimeMs, timeDeltaMs, nearestDueAtMs, policy = "forward_only") {
|
|
107
|
+
if (nearestDueAtMs === undefined)
|
|
108
|
+
return false;
|
|
109
|
+
return (policy !== "backward_only" && timeDeltaMs > 0 && worldTimeMs < nearestDueAtMs && worldTimeMs + timeDeltaMs >= nearestDueAtMs)
|
|
110
|
+
|| (policy !== "forward_only" && timeDeltaMs < 0 && worldTimeMs > nearestDueAtMs && worldTimeMs + timeDeltaMs <= nearestDueAtMs);
|
|
111
|
+
}
|
|
112
|
+
function validateVerdicts(verdicts, triggers) {
|
|
113
|
+
const expected = new Set(triggers.filter((trigger) => trigger.enabled !== false && trigger.naturalLanguage).map((trigger) => trigger.id));
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
for (const verdict of verdicts) {
|
|
116
|
+
if (!expected.has(verdict.id))
|
|
117
|
+
return `trigger_verdict_unexpected:${verdict.id}`;
|
|
118
|
+
if (seen.has(verdict.id))
|
|
119
|
+
return `trigger_verdict_duplicate:${verdict.id}`;
|
|
120
|
+
if (!["true", "false", "unknown"].includes(verdict.truth))
|
|
121
|
+
return `trigger_verdict_invalid:${verdict.id}`;
|
|
122
|
+
seen.add(verdict.id);
|
|
123
|
+
}
|
|
124
|
+
for (const id of expected)
|
|
125
|
+
if (!seen.has(id))
|
|
126
|
+
return `trigger_verdict_missing:${id}`;
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|