@ai-slot/runtime 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 iannil
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # @ai-slot/runtime
2
+
3
+ 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
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @ai-slot/runtime
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```html
14
+ <ai-slot name="hero" src="/ai-render/hero" editable stream live>
15
+ <h1>Original content — stays in the page as the fallback</h1>
16
+ </ai-slot>
17
+
18
+ <script type="module">
19
+ import { configureAiSlot, registerRenderer } from "@ai-slot/runtime";
20
+ import { createDomRenderer } from "@ai-slot/adapter-dom";
21
+
22
+ registerRenderer("dom", createDomRenderer(components));
23
+ configureAiSlot({ registry });
24
+ </script>
25
+ ```
26
+
27
+ Element attributes: `src`, `name`, `renderer` (default `dom`), `editable`, `stream` (SSE skeleton → tree), `live` + `live-src` (invalidation push), `refresh-interval`.
28
+
29
+ > Registration must happen synchronously in the same module graph that imports the runtime — custom element upgrade is synchronous.
30
+
31
+ ## Docs
32
+
33
+ - [Root README](https://github.com/iannil/ai-slot-component#readme) — full attribute table and framework examples
34
+
35
+ ## License
36
+
37
+ MIT
@@ -0,0 +1,78 @@
1
+ import { Registry, ComponentNode } from '@ai-slot/registry';
2
+
3
+ /** 全局配置:提供 registry 时,客户端在渲染前对 AI 输出再做一次校验(双保险)。 */
4
+ declare function configureAiSlot(opts: {
5
+ registry?: Registry;
6
+ }): void;
7
+ /**
8
+ * <ai-slot> 自定义元素。渐进增强:内部永远先保留原始兜底内容,
9
+ * AI 结果就绪且校验通过后才替换;任何失败静默回退,不白屏、不报错给用户。
10
+ */
11
+ declare class AiSlotElement extends HTMLElement {
12
+ protected fallbackHTML: string;
13
+ protected editor: HTMLElement | null;
14
+ private fallbackCaptured;
15
+ private timer;
16
+ private liveSub;
17
+ private loadSeq;
18
+ connectedCallback(): void;
19
+ disconnectedCallback(): void;
20
+ /** 拉取并渲染组件树;userPrompt 存在时走 POST 用户路径。失败时静默保留当前内容。 */
21
+ load(userPrompt?: string): Promise<void>;
22
+ /** live 属性:订阅失效推送,收到本槽位信号后静默重新加载。 */
23
+ protected mountLive(): void;
24
+ /** 恢复为挂载时的原始兜底内容。 */
25
+ restore(): void;
26
+ protected setContent(el: HTMLElement): void;
27
+ /** editable 的默认编辑条;样式通过 .ai-slot-editor 完全开放给用户自定义。 */
28
+ protected mountEditor(): void;
29
+ }
30
+
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
+ interface SubscribeInvalidationOptions {
48
+ /** 通道地址,如 /ai-invalidate(slot 以查询参数附加) */
49
+ src: string;
50
+ slot: string;
51
+ onInvalidate: () => void;
52
+ /** 测试注入用 */
53
+ fetchImpl?: typeof fetch;
54
+ }
55
+ interface InvalidationSubscription {
56
+ close: () => void;
57
+ }
58
+ /**
59
+ * 订阅失效推送(fetch 流式读取 SSE)。任何失败静默——订阅是渐进增强,
60
+ * 断开即停止(v1.1 不做自动重连)。
61
+ */
62
+ declare function subscribeInvalidation(opts: SubscribeInvalidationOptions): InvalidationSubscription;
63
+
64
+ interface RenderContext {
65
+ /** 已渲染完成的默认槽位子元素 */
66
+ children: HTMLElement[];
67
+ /** 已渲染完成的命名槽位子元素,按槽位名分组 */
68
+ slots: Record<string, HTMLElement[]>;
69
+ }
70
+ /** 把单个组件树节点映射为真实元素;返回 null 表示跳过该节点。 */
71
+ type Renderer = (node: ComponentNode, ctx: RenderContext) => HTMLElement | null | Promise<HTMLElement | null>;
72
+ /** 注册渲染适配器,如 registerRenderer("dom", createDomRenderer(...))。渲染器必须与导入 runtime 的代码在同一 module graph 中同步注册(custom elements upgrade 是同步的,首个 load 只推迟到一个 microtask)。 */
73
+ declare function registerRenderer(name: string, renderer: Renderer): void;
74
+ declare function getRenderer(name: string): Renderer | undefined;
75
+ /** 递归渲染组件树:先渲染子节点与命名槽位,再交给 Renderer 组装当前节点。 */
76
+ declare function renderTree(renderer: Renderer, node: ComponentNode): Promise<HTMLElement | null>;
77
+
78
+ export { AiSlotElement, type FetchTreeOptions, type InvalidationSubscription, type RenderContext, type Renderer, type SubscribeInvalidationOptions, configureAiSlot, fetchComponentTree, getRenderer, registerRenderer, renderTree, subscribeInvalidation };
package/dist/index.js ADDED
@@ -0,0 +1,265 @@
1
+ // src/fetch-tree.ts
2
+ import { validateComponentTree } from "@ai-slot/registry";
3
+ async function fetchComponentTree(opts) {
4
+ const doFetch = opts.fetchImpl ?? fetch;
5
+ try {
6
+ const headers = {};
7
+ if (opts.stream) headers.accept = "text/event-stream";
8
+ const init = opts.userPrompt === void 0 ? opts.stream ? { headers } : void 0 : {
9
+ method: "POST",
10
+ headers: { "content-type": "application/json", ...opts.stream ? { accept: headers.accept } : {} },
11
+ body: JSON.stringify({ prompt: opts.userPrompt })
12
+ };
13
+ const res = await doFetch(opts.src, init);
14
+ if (!res.ok) return null;
15
+ if (opts.stream && res.headers?.get("content-type")?.includes("text/event-stream")) {
16
+ return await readSSE(res, opts);
17
+ }
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 {
22
+ return null;
23
+ }
24
+ }
25
+ async function readSSE(res, opts) {
26
+ const text = (await readBody(res)).replace(/\r\n/g, "\n");
27
+ let finalTree = null;
28
+ for (const chunk of text.split("\n\n")) {
29
+ const event = chunk.match(/^event: (.+)$/m)?.[1];
30
+ const raw = chunk.match(/^data: (.+)$/m)?.[1];
31
+ if (!event || !raw) continue;
32
+ let data;
33
+ try {
34
+ data = JSON.parse(raw);
35
+ } catch {
36
+ continue;
37
+ }
38
+ if (event === "skeleton") {
39
+ if (data?.tree && !(opts.registry && !validateComponentTree(opts.registry, data.tree).ok)) {
40
+ try {
41
+ opts.onSkeleton?.(data.tree);
42
+ } catch {
43
+ }
44
+ }
45
+ } else if (event === "tree") {
46
+ finalTree = data?.tree ?? null;
47
+ }
48
+ }
49
+ if (finalTree === null) return null;
50
+ if (opts.registry && !validateComponentTree(opts.registry, finalTree).ok) return null;
51
+ return finalTree;
52
+ }
53
+ async function readBody(res) {
54
+ if (typeof res.text === "function") return res.text();
55
+ const reader = res.body?.getReader();
56
+ if (!reader) return "";
57
+ const decoder = new TextDecoder();
58
+ let text = "";
59
+ for (; ; ) {
60
+ const { done, value } = await reader.read();
61
+ if (done) break;
62
+ text += decoder.decode(value, { stream: true });
63
+ }
64
+ text += decoder.decode();
65
+ return text;
66
+ }
67
+
68
+ // src/live.ts
69
+ function subscribeInvalidation(opts) {
70
+ const doFetch = opts.fetchImpl ?? fetch;
71
+ const controller = new AbortController();
72
+ let closed = false;
73
+ 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
+ }
99
+ }
100
+ }
101
+ } catch {
102
+ }
103
+ })();
104
+ return {
105
+ close() {
106
+ closed = true;
107
+ controller.abort();
108
+ }
109
+ };
110
+ }
111
+
112
+ // src/renderer.ts
113
+ var renderers = /* @__PURE__ */ new Map();
114
+ function registerRenderer(name, renderer) {
115
+ renderers.set(name, renderer);
116
+ }
117
+ function getRenderer(name) {
118
+ return renderers.get(name);
119
+ }
120
+ async function renderTree(renderer, node) {
121
+ const children = [];
122
+ for (const child of node.children ?? []) {
123
+ const el = await renderTree(renderer, child);
124
+ if (el) children.push(el);
125
+ }
126
+ const slots = {};
127
+ for (const [name, nodes] of Object.entries(node.slots ?? {})) {
128
+ slots[name] = [];
129
+ for (const child of nodes) {
130
+ const el = await renderTree(renderer, child);
131
+ if (el) slots[name].push(el);
132
+ }
133
+ }
134
+ return renderer(node, { children, slots });
135
+ }
136
+
137
+ // src/ai-slot.ts
138
+ var globalRegistry;
139
+ function configureAiSlot(opts) {
140
+ globalRegistry = opts.registry;
141
+ }
142
+ var AiSlotElement = class extends HTMLElement {
143
+ fallbackHTML = "";
144
+ editor = null;
145
+ fallbackCaptured = false;
146
+ timer;
147
+ liveSub = null;
148
+ loadSeq = 0;
149
+ connectedCallback() {
150
+ if (!this.fallbackCaptured) {
151
+ this.fallbackHTML = this.innerHTML;
152
+ this.fallbackCaptured = true;
153
+ }
154
+ if (this.hasAttribute("editable")) this.mountEditor();
155
+ const intervalSec = Number(this.getAttribute("refresh-interval") ?? 0);
156
+ if (intervalSec > 0) {
157
+ this.timer = setInterval(() => void this.load(), intervalSec * 1e3);
158
+ }
159
+ queueMicrotask(() => {
160
+ if (this.isConnected) void this.load();
161
+ if (this.isConnected && this.hasAttribute("live")) this.mountLive();
162
+ });
163
+ }
164
+ disconnectedCallback() {
165
+ clearInterval(this.timer);
166
+ this.liveSub?.close();
167
+ this.liveSub = null;
168
+ }
169
+ /** 拉取并渲染组件树;userPrompt 存在时走 POST 用户路径。失败时静默保留当前内容。 */
170
+ async load(userPrompt) {
171
+ const seq = ++this.loadSeq;
172
+ let finalApplied = false;
173
+ let skeletonApplied = false;
174
+ const src = this.getAttribute("src");
175
+ const renderer = getRenderer(this.getAttribute("renderer") ?? "dom");
176
+ if (!src || !renderer) return;
177
+ const tree = await fetchComponentTree({
178
+ src,
179
+ userPrompt,
180
+ registry: globalRegistry,
181
+ stream: this.hasAttribute("stream"),
182
+ onSkeleton: (skeleton) => {
183
+ if (seq !== this.loadSeq || finalApplied) return;
184
+ skeletonApplied = true;
185
+ void renderTree(renderer, skeleton).then((el) => {
186
+ if (el && !finalApplied && seq === this.loadSeq) this.setContent(el);
187
+ }).catch(() => {
188
+ });
189
+ }
190
+ });
191
+ if (!tree) {
192
+ if (skeletonApplied && seq === this.loadSeq) this.restore();
193
+ return;
194
+ }
195
+ try {
196
+ const el = await renderTree(renderer, tree);
197
+ if (!el) return;
198
+ if (seq !== this.loadSeq) return;
199
+ finalApplied = true;
200
+ this.setContent(el);
201
+ } catch {
202
+ }
203
+ }
204
+ /** live 属性:订阅失效推送,收到本槽位信号后静默重新加载。 */
205
+ mountLive() {
206
+ const src = this.getAttribute("live-src") ?? this.getAttribute("src")?.replace(/\/ai-render\/.*$/, "/ai-invalidate");
207
+ const slot = this.getAttribute("name");
208
+ if (!src || !slot || this.liveSub) return;
209
+ this.liveSub = subscribeInvalidation({
210
+ src,
211
+ slot,
212
+ onInvalidate: () => void this.load()
213
+ });
214
+ }
215
+ /** 恢复为挂载时的原始兜底内容。 */
216
+ restore() {
217
+ this.loadSeq += 1;
218
+ this.innerHTML = this.fallbackHTML;
219
+ if (this.editor) this.appendChild(this.editor);
220
+ }
221
+ setContent(el) {
222
+ this.replaceChildren(el);
223
+ if (this.editor) this.appendChild(this.editor);
224
+ }
225
+ /** editable 的默认编辑条;样式通过 .ai-slot-editor 完全开放给用户自定义。 */
226
+ mountEditor() {
227
+ if (this.editor) {
228
+ if (this.editor.parentNode !== this) this.appendChild(this.editor);
229
+ return;
230
+ }
231
+ const form = document.createElement("form");
232
+ form.className = "ai-slot-editor";
233
+ const input = document.createElement("input");
234
+ input.name = "prompt";
235
+ input.maxLength = 500;
236
+ input.placeholder = "\u7528\u4E00\u53E5\u8BDD\u8C03\u6574\u8FD9\u4E2A\u533A\u57DF\u2026";
237
+ const submit = document.createElement("button");
238
+ submit.type = "submit";
239
+ submit.textContent = "\u5E94\u7528";
240
+ const reset = document.createElement("button");
241
+ reset.type = "button";
242
+ reset.textContent = "\u6062\u590D\u9ED8\u8BA4";
243
+ form.append(input, submit, reset);
244
+ form.addEventListener("submit", (event) => {
245
+ event.preventDefault();
246
+ const value = input.value.trim();
247
+ if (value) void this.load(value);
248
+ });
249
+ reset.addEventListener("click", () => this.restore());
250
+ this.editor = form;
251
+ this.appendChild(form);
252
+ }
253
+ };
254
+ if (typeof customElements !== "undefined" && !customElements.get("ai-slot")) {
255
+ customElements.define("ai-slot", AiSlotElement);
256
+ }
257
+ export {
258
+ AiSlotElement,
259
+ configureAiSlot,
260
+ fetchComponentTree,
261
+ getRenderer,
262
+ registerRenderer,
263
+ renderTree,
264
+ subscribeInvalidation
265
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@ai-slot/runtime",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "description": "Zero-dependency <ai-slot> Web Component runtime for AI-enhanced pages with guaranteed fallback.",
6
+ "keywords": [
7
+ "web-components",
8
+ "custom-elements",
9
+ "ai",
10
+ "llm",
11
+ "progressive-enhancement"
12
+ ],
13
+ "type": "module",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/iannil/ai-slot-component.git",
26
+ "directory": "packages/runtime"
27
+ },
28
+ "bugs": "https://github.com/iannil/ai-slot-component/issues",
29
+ "homepage": "https://github.com/iannil/ai-slot-component/tree/master/packages/runtime#readme",
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "registry": "https://registry.npmjs.org/"
33
+ },
34
+ "dependencies": {
35
+ "@ai-slot/registry": "0.1.0"
36
+ },
37
+ "devDependencies": {
38
+ "jsdom": "^25.0.0",
39
+ "tsup": "^8.3.0",
40
+ "typescript": "^5.6.0",
41
+ "vitest": "^2.1.0"
42
+ },
43
+ "scripts": {
44
+ "build": "tsup src/index.ts --format esm --dts --clean",
45
+ "test": "vitest run",
46
+ "typecheck": "tsc --noEmit"
47
+ }
48
+ }