@ai-slot/runtime 0.1.2 → 0.2.1

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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # @ai-slot/runtime
2
2
 
3
+ **English** | [简体中文](https://github.com/iannil/ai-slot-component/blob/master/packages/runtime/README.zh-CN.md)
4
+
3
5
  Zero-dependency `<ai-slot>` Web Component runtime for [ai-slot-component](https://github.com/iannil/ai-slot-component#readme): wrap existing markup, fetch a validated component tree from the proxy, and render it through a registered adapter — with a guaranteed fallback to your original content.
4
6
 
5
7
  ## Install
@@ -24,7 +26,11 @@ npm install @ai-slot/runtime
24
26
  </script>
25
27
  ```
26
28
 
27
- Element attributes: `src`, `name`, `renderer` (default `dom`), `editable`, `stream` (SSE skeleton → tree), `live` + `live-src` (invalidation push), `refresh-interval`.
29
+ Element attributes: `src`, `name`, `renderer` (default `dom`), `editable`, `stream` (SSE skeleton → tree), `live` + `live-src` (invalidation push), `refresh-interval`, `fresh-only`.
30
+
31
+ When the element is disconnected, in-flight load results are ignored. Reconnecting the same instance triggers a content check. Every successful `live` connection also triggers a check, so the initial load and the connection check may read concurrently; the latest load determines what is displayed.
32
+
33
+ `fresh-only` is an optional boundary for managed content, configured before mounting. First configure the registry with `configureAiSlot({ registry })`; the server must provide a trusted Unix millisecond deadline in `meta.expiresAt` of the validated final-tree response. Missing, invalid, or expired deadlines, and failed subsequent updates, restore the original fallback. The currently displayed tree also falls back when it expires. The runtime uses the visitor’s local clock and rechecks when the page becomes visible again. Background-tab timer throttling or an incorrect system clock may delay removal; immediate removal at the deadline millisecond cannot be guaranteed for every visitor. Ordinary slots retain their existing behavior and do not require expiry metadata. Like `live`, configure this attribute before mounting.
28
34
 
29
35
  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.
30
36
 
@@ -0,0 +1,45 @@
1
+ # @ai-slot/runtime
2
+
3
+ [English](https://github.com/iannil/ai-slot-component/blob/master/packages/runtime/README.md) | **简体中文**
4
+
5
+ [ai-slot-component](https://github.com/iannil/ai-slot-component#readme) 的 `<ai-slot>` Web Component 运行时:包裹原有标记,从代理获取经过校验的组件树,再由已注册的适配器渲染;失败时回退到原始内容。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ npm install @ai-slot/runtime
11
+ ```
12
+
13
+ ## 用法
14
+
15
+ ```html
16
+ <ai-slot name="hero" src="/ai-render/hero" editable stream live>
17
+ <h1>原始内容,保留在页面中作为兜底</h1>
18
+ </ai-slot>
19
+
20
+ <script type="module">
21
+ import { configureAiSlot, registerRenderer } from "@ai-slot/runtime";
22
+ import { createDomRenderer } from "@ai-slot/adapter-dom";
23
+
24
+ registerRenderer("dom", createDomRenderer(components));
25
+ configureAiSlot({ registry, onFailure: (f) => console.debug(f) });
26
+ </script>
27
+ ```
28
+
29
+ 元素属性:`src`、`name`、`renderer`(默认 `dom`)、`editable`、`stream`(SSE 骨架 → 终树)、`live` + `live-src`(失效推送)、`refresh-interval`、`fresh-only`。
30
+
31
+ 卸载元素时,在途的加载结果会被忽略;同一实例重新挂载后会重新核对内容。`live` 通道每次成功连接也会核对一次,因此初次挂载的加载与连接后的核对可能并发读取,由最新一次加载结果决定显示内容。
32
+
33
+ `fresh-only` 是挂载前配置的可选管理内容边界:需先通过 `configureAiSlot({ registry })` 配置注册表,且服务端必须在通过校验的最终树响应 `meta.expiresAt` 中提供可信的 Unix 毫秒截止时间。缺失、非法、已过期或后续更新失败时,槽位恢复原始兜底;当前显示的树到期也会恢复。运行时使用访客本机时钟,并在页面重新可见时复核。后台标签页的定时器节流或错误的系统时钟可能延迟撤回,不能保证所有访客在截止毫秒立即消失。普通槽位保持原有行为,不要求期限元数据。属性和 `live` 一样应在挂载前配置。
34
+
35
+ 传输格式 API:`registerWireFormat(id, format)` / `unregisterWireFormat(id)` 可将其他传输格式接入请求流水线。通过 `onFailure` 观测失败,类型为 `FetchFailure { stage: "http-non-ok" | "parse" | "validate" | "fetch-error", message }`;可在 `configureAiSlot` 中全局配置,也可在每次 `fetchComponentTree` 调用中配置。该钩子仅用于观测,不改变静默回退行为。
36
+
37
+ > 注册必须在导入 runtime 的同一个模块图中同步完成,因为自定义元素升级是同步的。
38
+
39
+ ## 文档
40
+
41
+ - [项目介绍](https://github.com/iannil/ai-slot-component#readme) — 完整属性表和框架示例
42
+
43
+ ## 许可证
44
+
45
+ MIT
package/dist/index.d.ts CHANGED
@@ -1,8 +1,41 @@
1
1
  import { Registry, ComponentNode } from '@ai-slot/registry';
2
2
 
3
- /** 全局配置:提供 registry 时,客户端在渲染前对 AI 输出再做一次校验(双保险)。 */
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
- /** 拉取并渲染组件树;userPrompt 存在时走 POST 用户路径。失败时静默保留当前内容。 */
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
- export { AiSlotElement, type FetchTreeOptions, type InvalidationSubscription, type RenderContext, type Renderer, type SubscribeInvalidationOptions, configureAiSlot, fetchComponentTree, getRenderer, registerRenderer, renderTree, subscribeInvalidation };
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) return null;
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 data = await res.json();
19
- if (opts.registry && !validateComponentTree(opts.registry, data?.tree).ok) return null;
20
- return data?.tree ?? null;
21
- } catch {
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
- if (data?.tree && !(opts.registry && !validateComponentTree(opts.registry, data.tree).ok)) {
109
+ const skeleton = extractTree(data).tree;
110
+ if (skeleton && !(opts.registry && !validateComponentTree(opts.registry, skeleton).ok)) {
40
111
  try {
41
- opts.onSkeleton?.(data.tree);
112
+ opts.onSkeleton?.(skeleton);
42
113
  } catch {
43
114
  }
44
115
  }
45
116
  } else if (event === "tree") {
46
- finalTree = data?.tree ?? null;
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
- if (finalTree === null) return null;
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 controller = new AbortController();
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
- try {
75
- const res = await doFetch(`${opts.src}${opts.src.includes("?") ? "&" : "?"}slot=${encodeURIComponent(opts.slot)}`, {
76
- headers: { accept: "text/event-stream" },
77
- signal: controller.signal
78
- });
79
- if (!res.ok || !res.body) return;
80
- const reader = res.body.getReader();
81
- const decoder = new TextDecoder();
82
- let buffer = "";
83
- for (; ; ) {
84
- const { done, value } = await reader.read();
85
- if (done) break;
86
- buffer += decoder.decode(value, { stream: true });
87
- buffer = buffer.replace(/\r\n/g, "\n");
88
- const frames = buffer.split("\n\n");
89
- buffer = frames.pop() ?? "";
90
- for (const frame of frames) {
91
- const event = frame.match(/^event: (.+)$/m)?.[1];
92
- const raw = frame.match(/^data: (.+)$/m)?.[1];
93
- if (event !== "invalidate" || !raw) continue;
94
- try {
95
- const data = JSON.parse(raw);
96
- if (data.slot === opts.slot && !closed) opts.onInvalidate();
97
- } catch {
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
- } catch {
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
- controller.abort();
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) void this.load();
161
- if (this.isConnected && this.hasAttribute("live")) this.mountLive();
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
- /** 拉取并渲染组件树;userPrompt 存在时走 POST 用户路径。失败时静默保留当前内容。 */
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) return;
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 (!el) return;
198
- if (seq !== this.loadSeq) return;
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 = "\u7528\u4E00\u53E5\u8BDD\u8C03\u6574\u8FD9\u4E2A\u533A\u57DF\u2026";
468
+ input.placeholder = globalEditorLabels.placeholder;
237
469
  const submit = document.createElement("button");
238
470
  submit.type = "submit";
239
- submit.textContent = "\u5E94\u7528";
471
+ submit.textContent = globalEditorLabels.submit;
240
472
  const reset = document.createElement("button");
241
473
  reset.type = "button";
242
- reset.textContent = "\u6062\u590D\u9ED8\u8BA4";
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.1.2",
3
+ "version": "0.2.1",
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.0"
35
+ "@ai-slot/registry": "0.1.2"
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",