@ai-slot/runtime 0.1.1 → 0.2.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/README.md +8 -2
- package/dist/index.d.ts +68 -23
- package/dist/index.js +288 -53
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -20,11 +20,17 @@ npm install @ai-slot/runtime
|
|
|
20
20
|
import { createDomRenderer } from "@ai-slot/adapter-dom";
|
|
21
21
|
|
|
22
22
|
registerRenderer("dom", createDomRenderer(components));
|
|
23
|
-
configureAiSlot({ registry });
|
|
23
|
+
configureAiSlot({ registry, onFailure: (f) => console.debug(f) });
|
|
24
24
|
</script>
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Element attributes: `src`, `name`, `renderer` (default `dom`), `editable`, `stream` (SSE skeleton → tree), `live` + `live-src` (invalidation push), `refresh-interval`.
|
|
27
|
+
Element attributes: `src`, `name`, `renderer` (default `dom`), `editable`, `stream` (SSE skeleton → tree), `live` + `live-src` (invalidation push), `refresh-interval`, `fresh-only`.
|
|
28
|
+
|
|
29
|
+
卸载元素时,在途的加载结果会被忽略;同一实例重新挂载后会重新核对内容。`live` 通道每次成功连接也会核对一次,因此初次挂载的加载与连接后的核对可能并发读取,由最新一次加载结果决定显示内容。
|
|
30
|
+
|
|
31
|
+
`fresh-only` 是挂载前配置的可选管理内容边界:需先通过 `configureAiSlot({ registry })` 配置注册表,且服务端必须在通过校验的最终树响应 `meta.expiresAt` 中提供可信的 Unix 毫秒截止时间。缺失、非法、已过期或后续更新失败时,槽位恢复原始兜底;当前显示的树到期也会恢复。运行时使用访客本机时钟,并在页面重新可见时复核。后台标签页的定时器节流或错误的系统时钟可能延迟撤回,不能保证所有访客在截止毫秒立即消失。普通槽位保持原有行为,不要求期限元数据。属性和 `live` 一样应在挂载前配置。
|
|
32
|
+
|
|
33
|
+
Wire format APIs: `registerWireFormat(id, format)` / `unregisterWireFormat(id)` plug alternative wire formats into the fetch pipeline. Failures are observed via `onFailure` as `FetchFailure { stage: "http-non-ok" | "parse" | "validate" | "fetch-error", message }` — set it globally in `configureAiSlot` or per call on `fetchComponentTree`. The silent-fallback behavior never changes; the hook is observation only.
|
|
28
34
|
|
|
29
35
|
> Registration must happen synchronously in the same module graph that imports the runtime — custom element upgrade is synchronous.
|
|
30
36
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,41 @@
|
|
|
1
1
|
import { Registry, ComponentNode } from '@ai-slot/registry';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
interface FetchTreeOptions {
|
|
4
|
+
src: string;
|
|
5
|
+
/** 存在时走 POST 用户路径 */
|
|
6
|
+
userPrompt?: string;
|
|
7
|
+
/** 提供时渲染前对 AI 输出再校验一次(双保险) */
|
|
8
|
+
registry?: Registry;
|
|
9
|
+
/** true 时请求 SSE 流式(Accept: text/event-stream) */
|
|
10
|
+
stream?: boolean;
|
|
11
|
+
/** SSE skeleton 帧回调(先于终树到达) */
|
|
12
|
+
onSkeleton?: (tree: ComponentNode) => void;
|
|
13
|
+
/** 可选失败观测:每个失败路径触发一次。回调异常被吞掉,绝不影响兜底。 */
|
|
14
|
+
onFailure?: (failure: FetchFailure) => void;
|
|
15
|
+
/** 最终树校验通过后报告服务端截止时间(Unix 毫秒);缺失或非法时传 undefined。 */
|
|
16
|
+
onExpiration?: (expiresAt: number | undefined) => void;
|
|
17
|
+
/** 测试注入用 */
|
|
18
|
+
fetchImpl?: typeof fetch;
|
|
19
|
+
}
|
|
20
|
+
/** 失败观测事件:stage 标识失败阶段,message 为人读原因(英文短语,API 面向国际用户)。 */
|
|
21
|
+
interface FetchFailure {
|
|
22
|
+
stage: "http-non-ok" | "parse" | "validate" | "fetch-error";
|
|
23
|
+
message: string;
|
|
24
|
+
}
|
|
25
|
+
/** 拉取并(可选)校验组件树。任何失败返回 null——调用方据此静默保留兜底内容。 */
|
|
26
|
+
declare function fetchComponentTree(opts: FetchTreeOptions): Promise<ComponentNode | null>;
|
|
27
|
+
|
|
28
|
+
/** editable 编辑条文案,可被 configureAiSlot 的 editorLabels 覆盖(默认中文)。 */
|
|
29
|
+
interface EditorLabels {
|
|
30
|
+
placeholder: string;
|
|
31
|
+
submit: string;
|
|
32
|
+
reset: string;
|
|
33
|
+
}
|
|
34
|
+
/** 全局配置:registry 供渲染前二次校验;onFailure 为可选失败观测(load 时透传给 fetchComponentTree);editorLabels 覆盖编辑条文案。 */
|
|
4
35
|
declare function configureAiSlot(opts: {
|
|
5
36
|
registry?: Registry;
|
|
37
|
+
onFailure?: (failure: FetchFailure) => void;
|
|
38
|
+
editorLabels?: Partial<EditorLabels>;
|
|
6
39
|
}): void;
|
|
7
40
|
/**
|
|
8
41
|
* <ai-slot> 自定义元素。渐进增强:内部永远先保留原始兜底内容,
|
|
@@ -15,12 +48,20 @@ declare class AiSlotElement extends HTMLElement {
|
|
|
15
48
|
private timer;
|
|
16
49
|
private liveSub;
|
|
17
50
|
private loadSeq;
|
|
51
|
+
private mountSeq;
|
|
52
|
+
private displayedExpiresAt;
|
|
53
|
+
private expirationTimer;
|
|
54
|
+
private readonly checkVisibleExpiration;
|
|
18
55
|
connectedCallback(): void;
|
|
19
56
|
disconnectedCallback(): void;
|
|
20
|
-
/**
|
|
57
|
+
/** 拉取并渲染组件树;普通槽位失败时保留当前内容,fresh-only 失败时恢复兜底。 */
|
|
21
58
|
load(userPrompt?: string): Promise<void>;
|
|
22
59
|
/** live 属性:订阅失效推送,收到本槽位信号后静默重新加载。 */
|
|
23
60
|
protected mountLive(): void;
|
|
61
|
+
/** 计时器绑定当前显示产物;长于浏览器上限的间隔分段复核。 */
|
|
62
|
+
private checkExpiration;
|
|
63
|
+
private clearExpirationTimer;
|
|
64
|
+
private clearExpiration;
|
|
24
65
|
/** 恢复为挂载时的原始兜底内容。 */
|
|
25
66
|
restore(): void;
|
|
26
67
|
protected setContent(el: HTMLElement): void;
|
|
@@ -28,37 +69,21 @@ declare class AiSlotElement extends HTMLElement {
|
|
|
28
69
|
protected mountEditor(): void;
|
|
29
70
|
}
|
|
30
71
|
|
|
31
|
-
interface FetchTreeOptions {
|
|
32
|
-
src: string;
|
|
33
|
-
/** 存在时走 POST 用户路径 */
|
|
34
|
-
userPrompt?: string;
|
|
35
|
-
/** 提供时渲染前对 AI 输出再校验一次(双保险) */
|
|
36
|
-
registry?: Registry;
|
|
37
|
-
/** true 时请求 SSE 流式(Accept: text/event-stream) */
|
|
38
|
-
stream?: boolean;
|
|
39
|
-
/** SSE skeleton 帧回调(先于终树到达) */
|
|
40
|
-
onSkeleton?: (tree: ComponentNode) => void;
|
|
41
|
-
/** 测试注入用 */
|
|
42
|
-
fetchImpl?: typeof fetch;
|
|
43
|
-
}
|
|
44
|
-
/** 拉取并(可选)校验组件树。任何失败返回 null——调用方据此静默保留兜底内容。 */
|
|
45
|
-
declare function fetchComponentTree(opts: FetchTreeOptions): Promise<ComponentNode | null>;
|
|
46
|
-
|
|
47
72
|
interface SubscribeInvalidationOptions {
|
|
48
73
|
/** 通道地址,如 /ai-invalidate(slot 以查询参数附加) */
|
|
49
74
|
src: string;
|
|
50
75
|
slot: string;
|
|
51
76
|
onInvalidate: () => void;
|
|
77
|
+
/** 每次成功建立连接时调用,供上层主动核对内容。 */
|
|
78
|
+
onConnect?: () => void;
|
|
52
79
|
/** 测试注入用 */
|
|
53
80
|
fetchImpl?: typeof fetch;
|
|
81
|
+
random?: () => number;
|
|
54
82
|
}
|
|
55
83
|
interface InvalidationSubscription {
|
|
56
84
|
close: () => void;
|
|
57
85
|
}
|
|
58
|
-
/**
|
|
59
|
-
* 订阅失效推送(fetch 流式读取 SSE)。任何失败静默——订阅是渐进增强,
|
|
60
|
-
* 断开即停止(v1.1 不做自动重连)。
|
|
61
|
-
*/
|
|
86
|
+
/** 订阅失效推送;断线、超时和异常均静默重连。 */
|
|
62
87
|
declare function subscribeInvalidation(opts: SubscribeInvalidationOptions): InvalidationSubscription;
|
|
63
88
|
|
|
64
89
|
interface RenderContext {
|
|
@@ -75,4 +100,24 @@ declare function getRenderer(name: string): Renderer | undefined;
|
|
|
75
100
|
/** 递归渲染组件树:先渲染子节点与命名槽位,再交给 Renderer 组装当前节点。 */
|
|
76
101
|
declare function renderTree(renderer: Renderer, node: ComponentNode): Promise<HTMLElement | null>;
|
|
77
102
|
|
|
78
|
-
|
|
103
|
+
/**
|
|
104
|
+
* 可插拔线格式:把非原生响应(如 A2UI messages)解析为内部组件树。
|
|
105
|
+
* detect 必须是纯函数且不得抛异常;parse 任何失败返回 null,调用方据此静默兜底。
|
|
106
|
+
*/
|
|
107
|
+
interface WireFormat {
|
|
108
|
+
detect(data: unknown): boolean;
|
|
109
|
+
parse(data: unknown): ComponentNode | null;
|
|
110
|
+
}
|
|
111
|
+
/** 注册线格式解析器(与 registerRenderer 对称)。同 id 后注册覆盖先注册;检测按注册顺序。 */
|
|
112
|
+
declare function registerWireFormat(id: string, format: WireFormat): void;
|
|
113
|
+
/** 注销线格式解析器。返回是否确实删除了条目(Map.delete 语义)。用于测试隔离与动态换装。 */
|
|
114
|
+
declare function unregisterWireFormat(id: string): boolean;
|
|
115
|
+
interface WireParseResult {
|
|
116
|
+
/** 有 wire format 的 detect 命中(命中后无论 parse 成败都不再走原生路径) */
|
|
117
|
+
matched: boolean;
|
|
118
|
+
tree: ComponentNode | null;
|
|
119
|
+
}
|
|
120
|
+
/** 供 fetch-tree 调用:按注册顺序探测。防御性容错——detect/parse 抛异常按「未命中/失败」处理。 */
|
|
121
|
+
declare function parseWithWireFormats(data: unknown): WireParseResult;
|
|
122
|
+
|
|
123
|
+
export { AiSlotElement, type EditorLabels, type FetchFailure, type FetchTreeOptions, type InvalidationSubscription, type RenderContext, type Renderer, type SubscribeInvalidationOptions, type WireFormat, type WireParseResult, configureAiSlot, fetchComponentTree, getRenderer, parseWithWireFormats, registerRenderer, registerWireFormat, renderTree, subscribeInvalidation, unregisterWireFormat };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
// src/fetch-tree.ts
|
|
2
2
|
import { validateComponentTree } from "@ai-slot/registry";
|
|
3
|
+
|
|
4
|
+
// src/wire.ts
|
|
5
|
+
var wireFormats = /* @__PURE__ */ new Map();
|
|
6
|
+
function registerWireFormat(id, format) {
|
|
7
|
+
wireFormats.set(id, format);
|
|
8
|
+
}
|
|
9
|
+
function unregisterWireFormat(id) {
|
|
10
|
+
return wireFormats.delete(id);
|
|
11
|
+
}
|
|
12
|
+
function parseWithWireFormats(data) {
|
|
13
|
+
for (const format of wireFormats.values()) {
|
|
14
|
+
try {
|
|
15
|
+
if (!format.detect(data)) continue;
|
|
16
|
+
} catch {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
return { matched: true, tree: format.parse(data) };
|
|
21
|
+
} catch {
|
|
22
|
+
return { matched: true, tree: null };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { matched: false, tree: null };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/fetch-tree.ts
|
|
29
|
+
function safeReport(onFailure, failure) {
|
|
30
|
+
if (!onFailure) return;
|
|
31
|
+
try {
|
|
32
|
+
onFailure(failure);
|
|
33
|
+
} catch {
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function reportExpiration(value, callback) {
|
|
37
|
+
try {
|
|
38
|
+
callback?.(typeof value === "number" && Number.isFinite(value) ? value : void 0);
|
|
39
|
+
} catch {
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function validationFailure(result) {
|
|
43
|
+
return { stage: "validate", message: result.errors[0]?.message ?? "validation failed" };
|
|
44
|
+
}
|
|
3
45
|
async function fetchComponentTree(opts) {
|
|
4
46
|
const doFetch = opts.fetchImpl ?? fetch;
|
|
5
47
|
try {
|
|
@@ -11,20 +53,48 @@ async function fetchComponentTree(opts) {
|
|
|
11
53
|
body: JSON.stringify({ prompt: opts.userPrompt })
|
|
12
54
|
};
|
|
13
55
|
const res = await doFetch(opts.src, init);
|
|
14
|
-
if (!res.ok)
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
safeReport(opts.onFailure, { stage: "http-non-ok", message: `HTTP ${res.status}` });
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
15
60
|
if (opts.stream && res.headers?.get("content-type")?.includes("text/event-stream")) {
|
|
16
61
|
return await readSSE(res, opts);
|
|
17
62
|
}
|
|
18
|
-
const
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
|
|
63
|
+
const { tree, failure, expiresAt } = extractTree(await res.json());
|
|
64
|
+
if (!tree) {
|
|
65
|
+
safeReport(opts.onFailure, failure ?? { stage: "parse", message: "empty response" });
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (opts.registry) {
|
|
69
|
+
const result = validateComponentTree(opts.registry, tree);
|
|
70
|
+
if (!result.ok) {
|
|
71
|
+
safeReport(opts.onFailure, validationFailure(result));
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
reportExpiration(opts.registry ? expiresAt : void 0, opts.onExpiration);
|
|
76
|
+
return tree;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
safeReport(opts.onFailure, {
|
|
79
|
+
stage: "fetch-error",
|
|
80
|
+
message: error instanceof Error ? error.message : "unknown error"
|
|
81
|
+
});
|
|
22
82
|
return null;
|
|
23
83
|
}
|
|
24
84
|
}
|
|
85
|
+
function extractTree(data) {
|
|
86
|
+
const wire = parseWithWireFormats(data);
|
|
87
|
+
if (wire.matched) {
|
|
88
|
+
return wire.tree ? { tree: wire.tree } : { tree: null, failure: { stage: "parse", message: "wire format parse returned null" } };
|
|
89
|
+
}
|
|
90
|
+
const body = data;
|
|
91
|
+
return body?.tree ? { tree: body.tree, expiresAt: body.meta?.expiresAt } : { tree: null, failure: { stage: "parse", message: "response carried no component tree" } };
|
|
92
|
+
}
|
|
25
93
|
async function readSSE(res, opts) {
|
|
26
94
|
const text = (await readBody(res)).replace(/\r\n/g, "\n");
|
|
27
95
|
let finalTree = null;
|
|
96
|
+
let finalFailure;
|
|
97
|
+
let finalExpiresAt;
|
|
28
98
|
for (const chunk of text.split("\n\n")) {
|
|
29
99
|
const event = chunk.match(/^event: (.+)$/m)?.[1];
|
|
30
100
|
const raw = chunk.match(/^data: (.+)$/m)?.[1];
|
|
@@ -36,18 +106,32 @@ async function readSSE(res, opts) {
|
|
|
36
106
|
continue;
|
|
37
107
|
}
|
|
38
108
|
if (event === "skeleton") {
|
|
39
|
-
|
|
109
|
+
const skeleton = extractTree(data).tree;
|
|
110
|
+
if (skeleton && !(opts.registry && !validateComponentTree(opts.registry, skeleton).ok)) {
|
|
40
111
|
try {
|
|
41
|
-
opts.onSkeleton?.(
|
|
112
|
+
opts.onSkeleton?.(skeleton);
|
|
42
113
|
} catch {
|
|
43
114
|
}
|
|
44
115
|
}
|
|
45
116
|
} else if (event === "tree") {
|
|
46
|
-
|
|
117
|
+
const extracted = extractTree(data);
|
|
118
|
+
finalTree = extracted.tree;
|
|
119
|
+
finalFailure = extracted.failure;
|
|
120
|
+
finalExpiresAt = extracted.expiresAt;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (finalTree === null) {
|
|
124
|
+
if (finalFailure) safeReport(opts.onFailure, finalFailure);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (opts.registry) {
|
|
128
|
+
const result = validateComponentTree(opts.registry, finalTree);
|
|
129
|
+
if (!result.ok) {
|
|
130
|
+
safeReport(opts.onFailure, validationFailure(result));
|
|
131
|
+
return null;
|
|
47
132
|
}
|
|
48
133
|
}
|
|
49
|
-
|
|
50
|
-
if (opts.registry && !validateComponentTree(opts.registry, finalTree).ok) return null;
|
|
134
|
+
reportExpiration(opts.registry ? finalExpiresAt : void 0, opts.onExpiration);
|
|
51
135
|
return finalTree;
|
|
52
136
|
}
|
|
53
137
|
async function readBody(res) {
|
|
@@ -66,45 +150,120 @@ async function readBody(res) {
|
|
|
66
150
|
}
|
|
67
151
|
|
|
68
152
|
// src/live.ts
|
|
153
|
+
function abortable(work, signal) {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const stop = () => reject(new Error("aborted"));
|
|
156
|
+
if (signal.aborted) {
|
|
157
|
+
stop();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
161
|
+
work.then(
|
|
162
|
+
(value) => {
|
|
163
|
+
signal.removeEventListener("abort", stop);
|
|
164
|
+
resolve(value);
|
|
165
|
+
},
|
|
166
|
+
(error) => {
|
|
167
|
+
signal.removeEventListener("abort", stop);
|
|
168
|
+
reject(error);
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function retryDelay(failures, random) {
|
|
174
|
+
return Math.floor(Math.min(3e4, 1e3 * 2 ** Math.min(failures, 5)) * (0.5 + 0.5 * random()));
|
|
175
|
+
}
|
|
176
|
+
function consumeFrames(buffer, slot, notify) {
|
|
177
|
+
const frames = buffer.replace(/\r\n/g, "\n").split("\n\n");
|
|
178
|
+
const rest = frames.pop() ?? "";
|
|
179
|
+
for (const frame of frames) {
|
|
180
|
+
const event = frame.match(/^event: ?(.+)$/m)?.[1];
|
|
181
|
+
const raw = frame.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).replace(/^ /, "")).join("\n");
|
|
182
|
+
if (event !== "invalidate" || !raw) continue;
|
|
183
|
+
try {
|
|
184
|
+
if (JSON.parse(raw).slot === slot) notify();
|
|
185
|
+
} catch {
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return rest;
|
|
189
|
+
}
|
|
69
190
|
function subscribeInvalidation(opts) {
|
|
70
191
|
const doFetch = opts.fetchImpl ?? fetch;
|
|
71
|
-
const
|
|
192
|
+
const random = opts.random ?? Math.random;
|
|
193
|
+
const url = `${opts.src}${opts.src.includes("?") ? "&" : "?"}slot=${encodeURIComponent(opts.slot)}`;
|
|
72
194
|
let closed = false;
|
|
195
|
+
let failures = 0;
|
|
196
|
+
let currentController;
|
|
197
|
+
let currentReader;
|
|
198
|
+
let timer;
|
|
199
|
+
let wake;
|
|
200
|
+
const clearTimer = () => {
|
|
201
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
202
|
+
timer = void 0;
|
|
203
|
+
};
|
|
73
204
|
void (async () => {
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
205
|
+
while (!closed) {
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
currentController = controller;
|
|
208
|
+
let connectedAt;
|
|
209
|
+
try {
|
|
210
|
+
timer = setTimeout(() => controller.abort(), 8e3);
|
|
211
|
+
const res = await abortable(doFetch(url, {
|
|
212
|
+
headers: { accept: "text/event-stream" },
|
|
213
|
+
signal: controller.signal
|
|
214
|
+
}), controller.signal);
|
|
215
|
+
clearTimer();
|
|
216
|
+
if (!res.ok || !res.body) throw new Error("invalid SSE response");
|
|
217
|
+
if (closed) break;
|
|
218
|
+
connectedAt = Date.now();
|
|
219
|
+
try {
|
|
220
|
+
opts.onConnect?.();
|
|
221
|
+
} catch {
|
|
222
|
+
}
|
|
223
|
+
const reader = res.body.getReader();
|
|
224
|
+
currentReader = reader;
|
|
225
|
+
const decoder = new TextDecoder();
|
|
226
|
+
let buffer = "";
|
|
227
|
+
for (; ; ) {
|
|
228
|
+
timer = setTimeout(() => controller.abort(), 6e4);
|
|
229
|
+
const { done, value } = await abortable(reader.read(), controller.signal);
|
|
230
|
+
clearTimer();
|
|
231
|
+
if (done) break;
|
|
232
|
+
buffer += decoder.decode(value, { stream: true });
|
|
233
|
+
if (buffer.length > 65536) throw new Error("SSE buffer exceeded");
|
|
234
|
+
buffer = consumeFrames(buffer, opts.slot, () => {
|
|
235
|
+
failures = 0;
|
|
236
|
+
if (!closed) opts.onInvalidate();
|
|
237
|
+
});
|
|
99
238
|
}
|
|
239
|
+
} catch {
|
|
240
|
+
} finally {
|
|
241
|
+
clearTimer();
|
|
242
|
+
controller.abort();
|
|
243
|
+
if (currentReader) void currentReader.cancel().catch(() => {
|
|
244
|
+
});
|
|
245
|
+
currentReader = void 0;
|
|
246
|
+
currentController = void 0;
|
|
100
247
|
}
|
|
101
|
-
|
|
248
|
+
if (closed) break;
|
|
249
|
+
if (connectedAt !== void 0 && Date.now() - connectedAt >= 3e4) failures = 0;
|
|
250
|
+
await new Promise((resolve) => {
|
|
251
|
+
wake = resolve;
|
|
252
|
+
timer = setTimeout(resolve, retryDelay(failures++, random));
|
|
253
|
+
});
|
|
254
|
+
wake = void 0;
|
|
255
|
+
clearTimer();
|
|
102
256
|
}
|
|
103
257
|
})();
|
|
104
258
|
return {
|
|
105
259
|
close() {
|
|
260
|
+
if (closed) return;
|
|
106
261
|
closed = true;
|
|
107
|
-
|
|
262
|
+
clearTimer();
|
|
263
|
+
currentController?.abort();
|
|
264
|
+
if (currentReader) void currentReader.cancel().catch(() => {
|
|
265
|
+
});
|
|
266
|
+
wake?.();
|
|
108
267
|
}
|
|
109
268
|
};
|
|
110
269
|
}
|
|
@@ -136,8 +295,17 @@ async function renderTree(renderer, node) {
|
|
|
136
295
|
|
|
137
296
|
// src/ai-slot.ts
|
|
138
297
|
var globalRegistry;
|
|
298
|
+
var globalOnFailure;
|
|
299
|
+
var defaultEditorLabels = {
|
|
300
|
+
placeholder: "\u7528\u4E00\u53E5\u8BDD\u8C03\u6574\u8FD9\u4E2A\u533A\u57DF\u2026",
|
|
301
|
+
submit: "\u5E94\u7528",
|
|
302
|
+
reset: "\u6062\u590D\u9ED8\u8BA4"
|
|
303
|
+
};
|
|
304
|
+
var globalEditorLabels = { ...defaultEditorLabels };
|
|
139
305
|
function configureAiSlot(opts) {
|
|
140
306
|
globalRegistry = opts.registry;
|
|
307
|
+
globalOnFailure = opts.onFailure;
|
|
308
|
+
globalEditorLabels = { ...defaultEditorLabels, ...opts.editorLabels };
|
|
141
309
|
}
|
|
142
310
|
var AiSlotElement = class extends HTMLElement {
|
|
143
311
|
fallbackHTML = "";
|
|
@@ -146,59 +314,101 @@ var AiSlotElement = class extends HTMLElement {
|
|
|
146
314
|
timer;
|
|
147
315
|
liveSub = null;
|
|
148
316
|
loadSeq = 0;
|
|
317
|
+
mountSeq = 0;
|
|
318
|
+
displayedExpiresAt;
|
|
319
|
+
expirationTimer;
|
|
320
|
+
checkVisibleExpiration = () => {
|
|
321
|
+
if (document.visibilityState === "visible") this.checkExpiration();
|
|
322
|
+
};
|
|
149
323
|
connectedCallback() {
|
|
324
|
+
const mount = ++this.mountSeq;
|
|
325
|
+
if (this.hasAttribute("fresh-only")) {
|
|
326
|
+
document.addEventListener("visibilitychange", this.checkVisibleExpiration);
|
|
327
|
+
this.checkExpiration();
|
|
328
|
+
}
|
|
150
329
|
if (!this.fallbackCaptured) {
|
|
151
330
|
this.fallbackHTML = this.innerHTML;
|
|
152
331
|
this.fallbackCaptured = true;
|
|
153
332
|
}
|
|
154
|
-
if (this.hasAttribute("editable")) this.mountEditor();
|
|
155
333
|
const intervalSec = Number(this.getAttribute("refresh-interval") ?? 0);
|
|
156
334
|
if (intervalSec > 0) {
|
|
157
335
|
this.timer = setInterval(() => void this.load(), intervalSec * 1e3);
|
|
158
336
|
}
|
|
159
337
|
queueMicrotask(() => {
|
|
160
|
-
if (this.isConnected
|
|
161
|
-
if (this.
|
|
338
|
+
if (!this.isConnected || mount !== this.mountSeq) return;
|
|
339
|
+
if (this.hasAttribute("editable")) this.mountEditor();
|
|
340
|
+
void this.load();
|
|
341
|
+
if (this.hasAttribute("live")) this.mountLive();
|
|
162
342
|
});
|
|
163
343
|
}
|
|
164
344
|
disconnectedCallback() {
|
|
345
|
+
this.mountSeq++;
|
|
346
|
+
this.loadSeq++;
|
|
165
347
|
clearInterval(this.timer);
|
|
166
348
|
this.liveSub?.close();
|
|
167
349
|
this.liveSub = null;
|
|
350
|
+
document.removeEventListener("visibilitychange", this.checkVisibleExpiration);
|
|
351
|
+
if (this.hasAttribute("fresh-only")) this.restore();
|
|
168
352
|
}
|
|
169
|
-
/**
|
|
353
|
+
/** 拉取并渲染组件树;普通槽位失败时保留当前内容,fresh-only 失败时恢复兜底。 */
|
|
170
354
|
async load(userPrompt) {
|
|
171
355
|
const seq = ++this.loadSeq;
|
|
356
|
+
const freshOnly = this.hasAttribute("fresh-only");
|
|
357
|
+
let expiresAt;
|
|
172
358
|
let finalApplied = false;
|
|
173
359
|
let skeletonApplied = false;
|
|
174
360
|
const src = this.getAttribute("src");
|
|
175
361
|
const renderer = getRenderer(this.getAttribute("renderer") ?? "dom");
|
|
176
|
-
if (!src || !renderer)
|
|
362
|
+
if (!src || !renderer) {
|
|
363
|
+
if (freshOnly && this.isConnected && seq === this.loadSeq) this.restore();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
177
366
|
const tree = await fetchComponentTree({
|
|
178
367
|
src,
|
|
179
368
|
userPrompt,
|
|
180
369
|
registry: globalRegistry,
|
|
370
|
+
onFailure: globalOnFailure,
|
|
181
371
|
stream: this.hasAttribute("stream"),
|
|
372
|
+
onExpiration: (value) => {
|
|
373
|
+
expiresAt = value;
|
|
374
|
+
},
|
|
182
375
|
onSkeleton: (skeleton) => {
|
|
183
|
-
if (seq !== this.loadSeq || finalApplied) return;
|
|
376
|
+
if (freshOnly || !this.isConnected || seq !== this.loadSeq || finalApplied) return;
|
|
184
377
|
skeletonApplied = true;
|
|
185
378
|
void renderTree(renderer, skeleton).then((el) => {
|
|
186
|
-
if (el && !finalApplied && seq === this.loadSeq) this.setContent(el);
|
|
379
|
+
if (el && this.isConnected && !finalApplied && seq === this.loadSeq) this.setContent(el);
|
|
187
380
|
}).catch(() => {
|
|
188
381
|
});
|
|
189
382
|
}
|
|
190
383
|
});
|
|
191
384
|
if (!tree) {
|
|
192
|
-
if (skeletonApplied && seq === this.loadSeq) this.restore();
|
|
385
|
+
if ((freshOnly || skeletonApplied) && this.isConnected && seq === this.loadSeq) this.restore();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (freshOnly && (expiresAt === void 0 || expiresAt <= Date.now())) {
|
|
389
|
+
if (this.isConnected && seq === this.loadSeq) this.restore();
|
|
193
390
|
return;
|
|
194
391
|
}
|
|
195
392
|
try {
|
|
196
393
|
const el = await renderTree(renderer, tree);
|
|
197
|
-
if (!
|
|
198
|
-
if (
|
|
394
|
+
if (!this.isConnected || seq !== this.loadSeq) return;
|
|
395
|
+
if (freshOnly && (expiresAt === void 0 || expiresAt <= Date.now())) {
|
|
396
|
+
this.restore();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (!el) {
|
|
400
|
+
if (freshOnly) this.restore();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
199
403
|
finalApplied = true;
|
|
200
404
|
this.setContent(el);
|
|
405
|
+
if (freshOnly) {
|
|
406
|
+
this.clearExpiration();
|
|
407
|
+
this.displayedExpiresAt = expiresAt;
|
|
408
|
+
this.checkExpiration();
|
|
409
|
+
}
|
|
201
410
|
} catch {
|
|
411
|
+
if (freshOnly && this.isConnected && seq === this.loadSeq) this.restore();
|
|
202
412
|
}
|
|
203
413
|
}
|
|
204
414
|
/** live 属性:订阅失效推送,收到本槽位信号后静默重新加载。 */
|
|
@@ -209,11 +419,33 @@ var AiSlotElement = class extends HTMLElement {
|
|
|
209
419
|
this.liveSub = subscribeInvalidation({
|
|
210
420
|
src,
|
|
211
421
|
slot,
|
|
212
|
-
onInvalidate: () => void this.load()
|
|
422
|
+
onInvalidate: () => void this.load(),
|
|
423
|
+
onConnect: () => void this.load()
|
|
213
424
|
});
|
|
214
425
|
}
|
|
426
|
+
/** 计时器绑定当前显示产物;长于浏览器上限的间隔分段复核。 */
|
|
427
|
+
checkExpiration() {
|
|
428
|
+
const deadline = this.displayedExpiresAt;
|
|
429
|
+
if (deadline === void 0 || !this.isConnected) return;
|
|
430
|
+
this.clearExpirationTimer();
|
|
431
|
+
const remaining = deadline - Date.now();
|
|
432
|
+
if (remaining <= 0) {
|
|
433
|
+
this.restore();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
this.expirationTimer = setTimeout(() => this.checkExpiration(), Math.min(remaining, 2147483647));
|
|
437
|
+
}
|
|
438
|
+
clearExpirationTimer() {
|
|
439
|
+
if (this.expirationTimer !== void 0) clearTimeout(this.expirationTimer);
|
|
440
|
+
this.expirationTimer = void 0;
|
|
441
|
+
}
|
|
442
|
+
clearExpiration() {
|
|
443
|
+
this.clearExpirationTimer();
|
|
444
|
+
this.displayedExpiresAt = void 0;
|
|
445
|
+
}
|
|
215
446
|
/** 恢复为挂载时的原始兜底内容。 */
|
|
216
447
|
restore() {
|
|
448
|
+
this.clearExpiration();
|
|
217
449
|
this.loadSeq += 1;
|
|
218
450
|
this.innerHTML = this.fallbackHTML;
|
|
219
451
|
if (this.editor) this.appendChild(this.editor);
|
|
@@ -233,13 +465,13 @@ var AiSlotElement = class extends HTMLElement {
|
|
|
233
465
|
const input = document.createElement("input");
|
|
234
466
|
input.name = "prompt";
|
|
235
467
|
input.maxLength = 500;
|
|
236
|
-
input.placeholder =
|
|
468
|
+
input.placeholder = globalEditorLabels.placeholder;
|
|
237
469
|
const submit = document.createElement("button");
|
|
238
470
|
submit.type = "submit";
|
|
239
|
-
submit.textContent =
|
|
471
|
+
submit.textContent = globalEditorLabels.submit;
|
|
240
472
|
const reset = document.createElement("button");
|
|
241
473
|
reset.type = "button";
|
|
242
|
-
reset.textContent =
|
|
474
|
+
reset.textContent = globalEditorLabels.reset;
|
|
243
475
|
form.append(input, submit, reset);
|
|
244
476
|
form.addEventListener("submit", (event) => {
|
|
245
477
|
event.preventDefault();
|
|
@@ -259,7 +491,10 @@ export {
|
|
|
259
491
|
configureAiSlot,
|
|
260
492
|
fetchComponentTree,
|
|
261
493
|
getRenderer,
|
|
494
|
+
parseWithWireFormats,
|
|
262
495
|
registerRenderer,
|
|
496
|
+
registerWireFormat,
|
|
263
497
|
renderTree,
|
|
264
|
-
subscribeInvalidation
|
|
498
|
+
subscribeInvalidation,
|
|
499
|
+
unregisterWireFormat
|
|
265
500
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-slot/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Zero-dependency <ai-slot> Web Component runtime for AI-enhanced pages with guaranteed fallback.",
|
|
6
6
|
"keywords": [
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"registry": "https://registry.npmjs.org/"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ai-slot/registry": "0.1.
|
|
35
|
+
"@ai-slot/registry": "0.1.1"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"jsdom": "^25.0.0",
|
|
@@ -40,6 +40,9 @@
|
|
|
40
40
|
"typescript": "^5.6.0",
|
|
41
41
|
"vitest": "^2.1.0"
|
|
42
42
|
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18"
|
|
45
|
+
},
|
|
43
46
|
"scripts": {
|
|
44
47
|
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
45
48
|
"test": "vitest run",
|