@ai-slot/proxy 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,227 @@
1
+ # @ai-slot/proxy
2
+
3
+ > AI-rendered page content — without leaked API keys, invalid LLM output, or white screens.
4
+
5
+ The server side of the **ai-slot-component** SDK (private monorepo, not yet on npm): one stateless handler that turns a slot ID into a **validated component tree**. It speaks Web standard `Request → Response`, so it deploys unchanged to Cloudflare Workers, Deno, Bun, Vercel Edge, or a plain Node server.
6
+
7
+ ```text
8
+ PromptCompiler → LLM Client (retry, 8s timeout) → OutputValidator → Cache (fresh → stale → 503)
9
+ ```
10
+
11
+ 中文文档:[仓库 CLAUDE.md](../../CLAUDE.md) · [总体设计](../../docs/superpowers/specs/2026-09-24-ai-native-rendering-sdk-design.md)
12
+
13
+ ## Quick start
14
+
15
+ Workspace install (Node ≥ 18): `pnpm install && pnpm build` at the repo root, then `import ... from "@ai-slot/proxy"` in any workspace package.
16
+
17
+ ```ts
18
+ import { createAiRenderHandler, createOpenAIClient } from "@ai-slot/proxy";
19
+ import { defineRegistry } from "@ai-slot/registry";
20
+
21
+ // 1. Declare the only components the LLM may ever output.
22
+ const registry = defineRegistry({
23
+ components: {
24
+ "hero-banner": {
25
+ description: "Page hero section",
26
+ props: { title: { type: "string", maxLength: 60 }, subtitle: { type: "string", maxLength: 120 } },
27
+ required: ["title"],
28
+ },
29
+ },
30
+ });
31
+
32
+ // 2. Map slot IDs to the original page content you want enhanced.
33
+ const slots = new Map([
34
+ ["hero", {
35
+ slotId: "hero",
36
+ originalContent: "<h1>Our product</h1><p>A plain product intro.</p>",
37
+ developerPrompt: "Audience: developers. Emphasize the 5-minute integration.",
38
+ contentVersion: "v1", // bump to bust the L1 cache
39
+ }],
40
+ ]);
41
+
42
+ // 3. Assemble the handler. Your key stays here, on the server.
43
+ const handler = createAiRenderHandler({
44
+ registry,
45
+ llm: createOpenAIClient({ apiKey: process.env.OPENAI_API_KEY! }),
46
+ resolveSlot: (slotId) => slots.get(slotId) ?? null,
47
+ });
48
+
49
+ export default handler;
50
+ // Serve it: Deno.serve(handler) · Bun.serve({ fetch: handler })
51
+ // · or wrap in node:http like examples/plain-html/server.mjs
52
+ ```
53
+
54
+ ```bash
55
+ curl "http://localhost:8000/ai-render/hero"
56
+ ```
57
+
58
+ ```json
59
+ {
60
+ "version": 1,
61
+ "slot": "hero",
62
+ "tree": { "component": "hero-banner", "props": { "title": "Ship AI-enhanced pages in 5 minutes" } },
63
+ "meta": { "reason": "developer-prompt" }
64
+ }
65
+ ```
66
+
67
+ That `tree` is what [`<ai-slot>` in packages/runtime](../runtime/) renders; the element's original content stays on the page as the fallback.
68
+
69
+ ## Two paths, one endpoint
70
+
71
+ | | `GET /ai-render/:slotId` | `POST /ai-render/:slotId` |
72
+ |---|---|---|
73
+ | Who calls it | You, at build time | End users, at runtime |
74
+ | Prompt | Developer prompt, from `resolveSlot()` | User prompt, request body `{ "prompt": "..." }` |
75
+ | Cache | L1 — fresh 1 h, key = slot + `contentVersion` + `promptVersion` | L2 — fresh 10 min, key = slot + normalized prompt (trim/lowercase/collapse whitespace, so " Big Title " and "big title" share an entry) |
76
+ | Guardrails | — | 10 requests/min/IP (`x-forwarded-for`), prompt sanitized to ≤500 chars with control characters stripped |
77
+ | Typical use | `prewarm()` in CI, then serve static | `<ai-slot editable>` live rewriting |
78
+
79
+ Model tiers follow the paths: `models.developer` for GET (default `gpt-4o-mini`), `models.user` for POST — point the cheap model at live user traffic.
80
+
81
+ ## Why it is safe to put an LLM on your page
82
+
83
+ - **The model never outputs HTML.** It may only compose components from your registry; `validateComponentTree` (from `@ai-slot/registry`, shared with the client runtime) enforces component names, prop schemas, slot nesting, depth, and node count. Anything else is discarded.
84
+ - **Keys never reach the browser.** Every LLM call happens inside this handler.
85
+ - **Failure is invisible.** Retry once → fall back to stale cache (1 day window) → `503 ai_unavailable`. The `<ai-slot>` element keeps its original content either way: no white screen, no user-facing error.
86
+ - **User prompts are untrusted input.** They are sanitized, then injected as constrained context — never as instructions — and the validator has the final say.
87
+ - **Budgets are enforced, not hoped for.** 8 s timeout, `maxTokens` cap (default 2000), per-IP rate limit, and an `onUsage` hook for billing/tuning logs.
88
+
89
+ ## Streaming: skeleton first, tree second
90
+
91
+ Send `Accept: text/event-stream` and the same endpoint responds with two SSE frames — the skeleton is derived from the final tree, so the placeholder already has the exact shape the real render will take:
92
+
93
+ ```text
94
+ event: skeleton
95
+ data: {"version":1,"slot":"hero","tree":{"component":"hero-banner","props":{"title":"","subtitle":""}},"meta":{"phase":"skeleton"}}
96
+
97
+ event: tree
98
+ data: {"version":1,"slot":"hero","tree":{...full tree...},"meta":{"reason":"developer-prompt"}}
99
+ ```
100
+
101
+ Client side: `<ai-slot stream>`. Cached hits are re-framed as SSE too, so streaming and caching never disagree.
102
+
103
+ ## Pregenerate in CI ("AI static pages")
104
+
105
+ ```ts
106
+ const store = new MemoryCacheStore();
107
+ const handler = createAiRenderHandler({
108
+ registry, llm, store, resolveSlot,
109
+ developerTtlMs: 24 * 3600_000, staleMs: 7 * 24 * 3600_000,
110
+ });
111
+
112
+ const { ok, failed } = await prewarm(handler, ["hero", "pricing"]); // one slot failing won't abort the rest
113
+ await writeFile("ai-cache.json", store.dump());
114
+ ```
115
+
116
+ Ship `ai-cache.json` with your build, then hydrate at boot:
117
+
118
+ ```ts
119
+ const store = MemoryCacheStore.load(await readFile("ai-cache.json", "utf8"), Date.now());
120
+ ```
121
+
122
+ Expired entries are dropped on load; your live path inherits the prewarmed cache with zero extra code.
123
+
124
+ ## Push invalidation when your data changes
125
+
126
+ `createInvalidationChannel()` gives you an SSE endpoint plus a broadcast function; you decide what triggers it (CMS webhook, cron diff, admin route).
127
+
128
+ ```ts
129
+ const invalidation = createInvalidationChannel(); // { handler, invalidate, subscriberCount }
130
+ // route GET /ai-invalidate to invalidation.handler
131
+ invalidation.invalidate("hero"); // → event: invalidate / data: {"slot":"hero"} to every subscriber
132
+ ```
133
+
134
+ `?slot=<id>` filter, `: ping` heartbeat every 25 s (configurable via `heartbeatMs`), subscribers cleaned up on disconnect. Client side: `<ai-slot live live-src="/ai-invalidate">`.
135
+
136
+ ## How it works
137
+
138
+ ```mermaid
139
+ flowchart TD
140
+ A["Request: /ai-render/:slotId"] --> B{"POST?"}
141
+ B -- "user prompt" --> C["rate limit + sanitize"]
142
+ B -- "build time" --> D["resolveSlot()"]
143
+ C --> D
144
+ D --> E{"fresh cache hit?"}
145
+ E -- "yes" --> F["return cached tree"]
146
+ E -- "no" --> G["compilePrompt → LLM → validateComponentTree"]
147
+ G -- "valid" --> F
148
+ G -- "invalid / LLM down" --> H["stale cache → 503"]
149
+ ```
150
+
151
+ ## API
152
+
153
+ ### `createAiRenderHandler(opts)`
154
+
155
+ | Option | Type | Default | Notes |
156
+ |---|---|---|---|
157
+ | `registry` | `Registry` | — | required — the component allowlist |
158
+ | `llm` | `LLMClient` | — | required — any provider; wrapped in `withRetry` (1 retry) for you |
159
+ | `resolveSlot` | `(slotId) => SlotSource \| null` | — | required — return `null` for unknown slots |
160
+ | `models` | `{ developer?, user? }` | `gpt-4o-mini` / `gpt-4o-mini` | per-path model tiers |
161
+ | `maxTokens` | `number` | `2000` | token budget per call |
162
+ | `store` | `MemoryCacheStore` | new instance | pass one to share with `prewarm()` or hydrate from disk |
163
+ | `developerTtlMs` | `number` | `3_600_000` | L1 fresh window |
164
+ | `userTtlMs` | `number` | `600_000` | L2 fresh window |
165
+ | `staleMs` | `number` | `86_400_000` | how long a stale entry may still serve as fallback |
166
+ | `rateLimit` | `{ limit, windowMs } \| false` | `{ 10, 60_000 }` | POST only; in-process, swap in a shared store for multi-instance |
167
+ | `onUsage` | `(entry: UsageLogEntry) => void` | — | called after every successful LLM call; callback errors are swallowed |
168
+ | `now` | `() => number` | `Date.now` | injectable clock for tests |
169
+
170
+ Errors are plain JSON, and every failure mode is distinct:
171
+
172
+ | Status | `error` | When |
173
+ |---|---|---|
174
+ | 400 | `invalid_prompt` | missing/empty/oversized user prompt |
175
+ | 404 | `not_found` | path is not `/ai-render/:slotId` |
176
+ | 404 | `unknown_slot` | `resolveSlot` returned `null` |
177
+ | 405 | `method_not_allowed` | anything but GET/POST |
178
+ | 429 | `rate_limited` | POST over the per-IP limit |
179
+ | 503 | `ai_unavailable` | LLM failed or output invalid, with no stale cache to serve |
180
+
181
+ ### Bring your own model
182
+
183
+ `LLMClient` is one method — point it at Anthropic, a local model, or a fixture:
184
+
185
+ ```ts
186
+ const llm = {
187
+ async complete({ model, system, user, maxTokens }) {
188
+ return { text: JSON.stringify({ version: 1, slot: "hero", tree: { component: "hero-banner", props: { title: "Hello" } } }) };
189
+ },
190
+ };
191
+ ```
192
+
193
+ `createOpenAIClient({ apiKey, baseUrl?, fetchImpl? })` covers any OpenAI-compatible endpoint (DeepSeek, vLLM, gateways) with zero SDK dependency and JSON-mode output.
194
+
195
+ ### Module map
196
+
197
+ | Module | What it is |
198
+ |---|---|
199
+ | `handler.ts` | `createAiRenderHandler` — routing, caching, degradation chain, SSE negotiation |
200
+ | `cache.ts` | `MemoryCacheStore` (+ `dump`/`load`), `prewarm` cache keys, fresh/stale `lookup` |
201
+ | `prewarm.ts` | CI pregeneration over the developer path |
202
+ | `prompt-compiler.ts` | layered prompt: system rules → registry constraints → developer prompt → user prompt (narrowest last) |
203
+ | `llm-client.ts` | `LLMClient` interface, `LLMError`, `withRetry` |
204
+ | `openai-client.ts` | fetch-based OpenAI-compatible adapter |
205
+ | `sanitize.ts` | user prompt filter (type, length, control characters) |
206
+ | `rate-limit.ts` | in-process sliding-window limiter |
207
+ | `invalidate.ts` | `createInvalidationChannel` — SSE invalidation push |
208
+
209
+ ## Development
210
+
211
+ ```bash
212
+ pnpm --filter @ai-slot/proxy test # all unit tests, no network access needed
213
+ cd packages/proxy && npx vitest run src/handler.test.ts -t "降级" # one file / one case
214
+ pnpm --filter @ai-slot/proxy typecheck # run pnpm build first on a clean checkout
215
+ ```
216
+
217
+ Every LLM in the test suite is a fixture or a mock — nothing here calls a real API, so the package is safe to explore and modify with a coding agent. The spec of record is [`docs/superpowers/specs/2026-09-24-ai-native-rendering-sdk-design.md`](../../docs/superpowers/specs/2026-09-24-ai-native-rendering-sdk-design.md).
218
+
219
+ ## The monorepo
220
+
221
+ | Package | Role |
222
+ |---|---|
223
+ | [`@ai-slot/registry`](../registry/) | protocol + `OutputValidator` — the security boundary, consumed by proxy, runtime, and build tooling |
224
+ | **`@ai-slot/proxy`** | this package — stateless render proxy |
225
+ | [`@ai-slot/runtime`](../runtime/) | zero-dependency `<ai-slot>` element (`stream`, `live`, `editable`) |
226
+ | [`@ai-slot/adapter-dom`](../adapter-dom/) / [`adapter-react`](../adapter-react/) / [`adapter-vue`](../adapter-vue/) | map validated trees onto your real components |
227
+ | [`examples/plain-html`](../../examples/plain-html/) | end-to-end demo: legacy page, editable + streaming + live slots, Playwright e2e |
@@ -0,0 +1,189 @@
1
+ import { Registry } from '@ai-slot/registry';
2
+
3
+ /** 双层缓存:fresh TTL 内直接返回;超过 TTL 但在 stale 窗口内可作为降级结果返回。 */
4
+ interface CacheEntry<T> {
5
+ value: T;
6
+ /** fresh 截止时刻(ms 时间戳) */
7
+ expiresAt: number;
8
+ /** stale 截止时刻,之后彻底删除 */
9
+ staleUntil: number;
10
+ }
11
+ declare class MemoryCacheStore {
12
+ private map;
13
+ get<T>(key: string, now: number): CacheEntry<T> | undefined;
14
+ set<T>(key: string, value: T, ttlMs: number, staleMs: number, now: number): void;
15
+ /** 序列化为 JSON(绝对毫秒时间戳;CI 与部署时钟需一致)。 */
16
+ dump(): string;
17
+ /** 反序列化;剔除调用时已彻底过期或形状非法(缺时间戳)的条目。非法 JSON 抛错(SyntaxError/TypeError)。 */
18
+ static load(json: string, now: number): MemoryCacheStore;
19
+ }
20
+ interface CacheLookup<T> {
21
+ value: T;
22
+ status: "fresh" | "stale";
23
+ }
24
+ declare function lookup<T>(store: MemoryCacheStore, key: string, now: number): CacheLookup<T> | undefined;
25
+ /** djb2 哈希:同步、零依赖,足够做缓存 key(非安全用途)。 */
26
+ declare function hashKey(input: string): string;
27
+ /** 用户提示词规范化:trim + 小写 + 合并空白,提高 L2 缓存命中率。 */
28
+ declare function normalizeUserPrompt(prompt: string): string;
29
+ /** L1 开发者路径 key:hash(slotId + 内容版本 + 提示词版本)。 */
30
+ declare function developerCacheKey(slotId: string, contentVersion: string, promptVersion: string): string;
31
+ /** L2 用户路径 key:hash(slotId + 规范化用户提示词)。 */
32
+ declare function userCacheKey(slotId: string, userPrompt: string): string;
33
+
34
+ /** LLM 调用的最小抽象:输入分层提示词,输出 JSON 文本。 */
35
+ interface LLMRequest {
36
+ model: string;
37
+ system: string;
38
+ user: string;
39
+ /** token 预算上限 */
40
+ maxTokens?: number;
41
+ /** 超时(ms),默认 8000 */
42
+ timeoutMs?: number;
43
+ }
44
+ interface LLMResponse {
45
+ text: string;
46
+ usage?: {
47
+ promptTokens?: number;
48
+ completionTokens?: number;
49
+ };
50
+ }
51
+ interface LLMClient {
52
+ complete(req: LLMRequest): Promise<LLMResponse>;
53
+ }
54
+ declare class LLMError extends Error {
55
+ constructor(message: string, options?: {
56
+ cause?: unknown;
57
+ });
58
+ }
59
+ /** 失败重试:默认重试 1 次(共 2 次尝试)。 */
60
+ declare function withRetry(client: LLMClient, retries?: number): LLMClient;
61
+
62
+ interface SlotSource {
63
+ slotId: string;
64
+ /** 原始兜底内容摘要 */
65
+ originalContent: string;
66
+ developerPrompt?: string;
67
+ /** 内容版本,进入 L1 缓存 key */
68
+ contentVersion: string;
69
+ /** 提示词版本,默认 "1" */
70
+ promptVersion?: string;
71
+ /** 数据源解析后的实时数据 */
72
+ data?: Record<string, unknown>;
73
+ }
74
+ interface ProxyOptions {
75
+ registry: Registry;
76
+ llm: LLMClient;
77
+ resolveSlot: (slotId: string) => SlotSource | null | Promise<SlotSource | null>;
78
+ /** 双模型档位:开发者预生成可用大模型,用户实时路径默认小模型 */
79
+ models?: {
80
+ developer?: string;
81
+ user?: string;
82
+ };
83
+ /** token 预算上限,默认 2000 */
84
+ maxTokens?: number;
85
+ store?: MemoryCacheStore;
86
+ /** L1 缓存 TTL,默认 1 小时 */
87
+ developerTtlMs?: number;
88
+ /** L2 缓存 TTL,默认 10 分钟 */
89
+ userTtlMs?: number;
90
+ /** stale 窗口,默认 1 天 */
91
+ staleMs?: number;
92
+ /** 用户路径限流,默认 10 次/分钟;传 false 关闭。进程内单实例语义,多实例部署需外部存储(见 rate-limit.ts) */
93
+ rateLimit?: {
94
+ limit: number;
95
+ windowMs: number;
96
+ } | false;
97
+ /** 用量日志:每次 LLM 调用成功后回调(供计费与调优) */
98
+ onUsage?: (entry: UsageLogEntry) => void;
99
+ /** 测试注入用 */
100
+ now?: () => number;
101
+ }
102
+ /** 用量日志条目(设计文档 §4.2:全部调用记录用量日志)。 */
103
+ interface UsageLogEntry {
104
+ slotId: string;
105
+ model: string;
106
+ reason: "developer-prompt" | "user-prompt";
107
+ usage?: LLMResponse["usage"];
108
+ at: number;
109
+ }
110
+ /** 无状态渲染代理:GET 开发者路径(长缓存)/ POST 用户路径(实时 + 限流 + 短 TTL)。 */
111
+ declare function createAiRenderHandler(opts: ProxyOptions): (req: Request) => Promise<Response>;
112
+
113
+ interface InvalidationChannel {
114
+ /** GET /ai-invalidate?slot=<slotId> 的 SSE 订阅端点 */
115
+ handler: (req: Request) => Response;
116
+ /** 向某槽位的所有订阅者广播失效信号 */
117
+ invalidate: (slotId: string) => void;
118
+ /** 当前订阅者数(测试与观测用) */
119
+ subscriberCount: (slotId: string) => number;
120
+ }
121
+ /**
122
+ * 失效推送通道(SSE)。协议:订阅后服务端按 slotId 过滤广播
123
+ * `event: invalidate\ndata: {"slot":"<slotId>"}\n\n`,心跳为 `: ping` 注释行。
124
+ * 接入方自行决定 invalidate() 的触发时机(数据源 webhook、定时 diff、管理端点等)。
125
+ */
126
+ declare function createInvalidationChannel(opts?: {
127
+ heartbeatMs?: number;
128
+ }): InvalidationChannel;
129
+
130
+ interface OpenAIClientOptions {
131
+ apiKey: string;
132
+ /** 默认 https://api.openai.com/v1,可指向任何 OpenAI 兼容端点 */
133
+ baseUrl?: string;
134
+ /** 测试注入用 */
135
+ fetchImpl?: typeof fetch;
136
+ }
137
+ /** 基于 fetch 的 OpenAI 兼容适配器:零 SDK 依赖,JSON mode 结构化输出。 */
138
+ declare function createOpenAIClient(opts: OpenAIClientOptions): LLMClient;
139
+
140
+ interface PrewarmResult {
141
+ ok: string[];
142
+ failed: string[];
143
+ }
144
+ /**
145
+ * CI 构建时预生成(「AI 静态化」):复用渲染代理 handler 逐槽位跑开发者路径,
146
+ * 结果写入 handler 自带的缓存 store;之后 dump() 序列化随产物部署。
147
+ * 单个槽位失败不中断整体。
148
+ */
149
+ declare function prewarm(handler: (req: Request) => Promise<Response>, slotIds: string[]): Promise<PrewarmResult>;
150
+
151
+ interface CompileInput {
152
+ registry: Registry;
153
+ slotId: string;
154
+ /** 槽位的原始兜底内容(摘要) */
155
+ originalContent: string;
156
+ developerPrompt?: string;
157
+ /** 已经过 sanitize 的用户提示词 */
158
+ userPrompt?: string;
159
+ /** 数据源解析后的实时数据 */
160
+ data?: Record<string, unknown>;
161
+ }
162
+ interface CompiledPrompt {
163
+ system: string;
164
+ user: string;
165
+ }
166
+ /**
167
+ * 分层提示词组装。层级:系统提示(框架内置)→ 注册表约束 → 开发者提示词 → 用户提示词,
168
+ * 越往下权限越窄;用户提示词只能以受限上下文的形式注入。
169
+ */
170
+ declare function compilePrompt(input: CompileInput): CompiledPrompt;
171
+
172
+ /** 进程内滑动窗口限流(Edge 单实例语义;多实例部署需换外部存储,接口保持不变)。 */
173
+ declare class RateLimiter {
174
+ private opts;
175
+ private hits;
176
+ constructor(opts: {
177
+ limit: number;
178
+ windowMs: number;
179
+ });
180
+ check(key: string, now: number): boolean;
181
+ }
182
+
183
+ /**
184
+ * 用户提示词提交前过滤:类型、长度、控制字符。
185
+ * 返回 null 表示非法输入;通过过滤的提示词仍会经 OutputValidator 最终兜底。
186
+ */
187
+ declare function sanitizeUserPrompt(raw: unknown, maxLength?: number): string | null;
188
+
189
+ export { type CacheEntry, type CacheLookup, type CompileInput, type CompiledPrompt, type InvalidationChannel, type LLMClient, LLMError, type LLMRequest, type LLMResponse, MemoryCacheStore, type OpenAIClientOptions, type PrewarmResult, type ProxyOptions, RateLimiter, type SlotSource, type UsageLogEntry, compilePrompt, createAiRenderHandler, createInvalidationChannel, createOpenAIClient, developerCacheKey, hashKey, lookup, normalizeUserPrompt, prewarm, sanitizeUserPrompt, userCacheKey, withRetry };
package/dist/index.js ADDED
@@ -0,0 +1,419 @@
1
+ // src/cache.ts
2
+ var MemoryCacheStore = class _MemoryCacheStore {
3
+ map = /* @__PURE__ */ new Map();
4
+ get(key, now) {
5
+ const entry = this.map.get(key);
6
+ if (!entry) return void 0;
7
+ if (now >= entry.staleUntil) {
8
+ this.map.delete(key);
9
+ return void 0;
10
+ }
11
+ return entry;
12
+ }
13
+ set(key, value, ttlMs, staleMs, now) {
14
+ this.map.set(key, { value, expiresAt: now + ttlMs, staleUntil: now + ttlMs + staleMs });
15
+ }
16
+ /** 序列化为 JSON(绝对毫秒时间戳;CI 与部署时钟需一致)。 */
17
+ dump() {
18
+ return JSON.stringify(Object.fromEntries(this.map));
19
+ }
20
+ /** 反序列化;剔除调用时已彻底过期或形状非法(缺时间戳)的条目。非法 JSON 抛错(SyntaxError/TypeError)。 */
21
+ static load(json2, now) {
22
+ const raw = JSON.parse(json2);
23
+ const store = new _MemoryCacheStore();
24
+ for (const [key, entry] of Object.entries(raw)) {
25
+ if (typeof entry?.staleUntil !== "number" || typeof entry?.expiresAt !== "number") continue;
26
+ if (now < entry.staleUntil) {
27
+ store.map.set(key, entry);
28
+ }
29
+ }
30
+ return store;
31
+ }
32
+ };
33
+ function lookup(store, key, now) {
34
+ const entry = store.get(key, now);
35
+ if (!entry) return void 0;
36
+ return { value: entry.value, status: now < entry.expiresAt ? "fresh" : "stale" };
37
+ }
38
+ function hashKey(input) {
39
+ let h = 5381;
40
+ for (let i = 0; i < input.length; i++) {
41
+ h = (h << 5) + h + input.charCodeAt(i) >>> 0;
42
+ }
43
+ return h.toString(36);
44
+ }
45
+ function normalizeUserPrompt(prompt) {
46
+ return prompt.trim().toLowerCase().replace(/\s+/g, " ");
47
+ }
48
+ function developerCacheKey(slotId, contentVersion, promptVersion) {
49
+ return `dev:${hashKey(`${slotId}|${contentVersion}|${promptVersion}`)}`;
50
+ }
51
+ function userCacheKey(slotId, userPrompt) {
52
+ return `usr:${hashKey(`${slotId}|${normalizeUserPrompt(userPrompt)}`)}`;
53
+ }
54
+
55
+ // src/handler.ts
56
+ import {
57
+ deriveSkeleton,
58
+ validateComponentTree
59
+ } from "@ai-slot/registry";
60
+
61
+ // src/llm-client.ts
62
+ var LLMError = class extends Error {
63
+ constructor(message, options) {
64
+ super(message, options);
65
+ this.name = "LLMError";
66
+ }
67
+ };
68
+ function withRetry(client, retries = 1) {
69
+ return {
70
+ async complete(req) {
71
+ let lastError;
72
+ for (let attempt = 0; attempt <= retries; attempt++) {
73
+ try {
74
+ return await client.complete(req);
75
+ } catch (error) {
76
+ lastError = error;
77
+ }
78
+ }
79
+ throw lastError;
80
+ }
81
+ };
82
+ }
83
+
84
+ // src/prompt-compiler.ts
85
+ function compilePrompt(input) {
86
+ const system = [
87
+ "\u4F60\u662F\u4E00\u4E2A\u9875\u9762\u5185\u5BB9\u589E\u5F3A\u5F15\u64CE\u3002\u4F60\u53EA\u80FD\u8F93\u51FA\u7B26\u5408\u7ED9\u5B9A\u7EC4\u4EF6\u6CE8\u518C\u8868\u7684\u7EC4\u4EF6\u6811 JSON\uFF0C\u7981\u6B62\u8F93\u51FA HTML\u3001CSS \u6216\u4EFB\u4F55\u4EE3\u7801\u3002",
88
+ "\u6240\u6709\u6587\u672C\u5185\u5BB9\u5FC5\u987B\u662F\u7EAF\u6587\u672C\uFF1B\u7EC4\u4EF6\u540D\u5FC5\u987B\u6765\u81EA\u6CE8\u518C\u8868\uFF1Bprops \u5FC5\u987B\u6EE1\u8DB3 Schema \u7EA6\u675F\u3002",
89
+ '\u8F93\u51FA\u534F\u8BAE\uFF1A{"version":1,"slot":"<\u69FD\u4F4D\u540D>","tree":{"component":"<\u7EC4\u4EF6\u540D>","props":{...},"children":[...],"slots":{"<\u69FD\u4F4D\u540D>":[...]}}}',
90
+ "children \u5BF9\u5E94\u7EC4\u4EF6\u58F0\u660E\u7684 default \u69FD\u4F4D\uFF1B\u672A\u58F0\u660E\u69FD\u4F4D\u7684\u7EC4\u4EF6\u4E0D\u5141\u8BB8\u5305\u542B\u5B50\u8282\u70B9\u3002",
91
+ "\u7EC4\u4EF6\u6CE8\u518C\u8868\uFF08JSON\uFF09\uFF1A",
92
+ JSON.stringify(serializeRegistry(input.registry), null, 2)
93
+ ].join("\n");
94
+ const userParts = [
95
+ `\u69FD\u4F4D\uFF1A${input.slotId}`,
96
+ `\u539F\u59CB\u5185\u5BB9\uFF1A
97
+ ${input.originalContent}`
98
+ ];
99
+ if (input.data && Object.keys(input.data).length > 0) {
100
+ userParts.push(`\u5B9E\u65F6\u6570\u636E\uFF1A
101
+ ${JSON.stringify(input.data)}`);
102
+ }
103
+ if (input.developerPrompt) {
104
+ userParts.push(`\u5F00\u53D1\u8005\u8981\u6C42\uFF1A${input.developerPrompt}`);
105
+ }
106
+ if (input.userPrompt) {
107
+ userParts.push(`\u5728\u4FDD\u6301\u4E0A\u8FF0\u7EC4\u4EF6\u7EA6\u675F\u7684\u524D\u63D0\u4E0B\uFF0C\u6309\u7528\u6237\u8981\u6C42\u8C03\u6574\uFF1A${input.userPrompt}`);
108
+ }
109
+ return { system, user: userParts.join("\n\n") };
110
+ }
111
+ function serializeRegistry(registry) {
112
+ const out = {};
113
+ for (const [name, def] of Object.entries(registry.components)) {
114
+ out[name] = { description: def.description, props: def.props, required: def.required, slots: def.slots };
115
+ }
116
+ return out;
117
+ }
118
+
119
+ // src/rate-limit.ts
120
+ var RateLimiter = class {
121
+ constructor(opts) {
122
+ this.opts = opts;
123
+ }
124
+ opts;
125
+ hits = /* @__PURE__ */ new Map();
126
+ check(key, now) {
127
+ const windowStart = now - this.opts.windowMs;
128
+ const list = (this.hits.get(key) ?? []).filter((t) => t > windowStart);
129
+ if (list.length >= this.opts.limit) {
130
+ this.hits.set(key, list);
131
+ return false;
132
+ }
133
+ list.push(now);
134
+ this.hits.set(key, list);
135
+ return true;
136
+ }
137
+ };
138
+
139
+ // src/sanitize.ts
140
+ function sanitizeUserPrompt(raw, maxLength = 500) {
141
+ if (typeof raw !== "string") return null;
142
+ const cleaned = raw.replace(/[\u0000-\u001F\u007F]/g, " ").replace(/[\u0080-\u009F]/g, "").trim();
143
+ if (cleaned.length === 0 || cleaned.length > maxLength) return null;
144
+ return cleaned;
145
+ }
146
+
147
+ // src/handler.ts
148
+ function json(body, status) {
149
+ return new Response(JSON.stringify(body), {
150
+ status,
151
+ headers: { "content-type": "application/json; charset=utf-8" }
152
+ });
153
+ }
154
+ function sse(response) {
155
+ const skeleton = {
156
+ ...response,
157
+ tree: deriveSkeleton(response.tree),
158
+ meta: { ...response.meta, phase: "skeleton" }
159
+ };
160
+ const body = `event: skeleton
161
+ data: ${JSON.stringify(skeleton)}
162
+
163
+ event: tree
164
+ data: ${JSON.stringify(response)}
165
+
166
+ `;
167
+ return new Response(body, {
168
+ status: 200,
169
+ headers: { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache" }
170
+ });
171
+ }
172
+ function createAiRenderHandler(opts) {
173
+ const store = opts.store ?? new MemoryCacheStore();
174
+ const now = opts.now ?? (() => Date.now());
175
+ const limiter = opts.rateLimit === false ? null : new RateLimiter(opts.rateLimit ?? { limit: 10, windowMs: 6e4 });
176
+ const llm = withRetry(opts.llm);
177
+ return async function handler(req) {
178
+ const match = new URL(req.url).pathname.match(/\/ai-render\/([\w-]+)\/?$/);
179
+ if (!match) return json({ error: "not_found" }, 404);
180
+ const slotId = match[1];
181
+ const wantsSSE = req.headers.get("accept")?.includes("text/event-stream") ?? false;
182
+ let userPrompt;
183
+ if (req.method === "POST") {
184
+ if (limiter) {
185
+ const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "anonymous";
186
+ if (!limiter.check(ip, now())) return json({ error: "rate_limited" }, 429);
187
+ }
188
+ const body = await req.json().catch(() => null);
189
+ const cleaned = sanitizeUserPrompt(body?.prompt);
190
+ if (!cleaned) return json({ error: "invalid_prompt" }, 400);
191
+ userPrompt = cleaned;
192
+ } else if (req.method !== "GET") {
193
+ return json({ error: "method_not_allowed" }, 405);
194
+ }
195
+ let hit;
196
+ if (userPrompt !== void 0) {
197
+ hit = lookup(store, userCacheKey(slotId, userPrompt), now());
198
+ if (hit?.status === "fresh") return wantsSSE ? sse(hit.value) : json(hit.value, 200);
199
+ }
200
+ let slot;
201
+ try {
202
+ slot = await opts.resolveSlot(slotId);
203
+ } catch (error) {
204
+ console.warn("[ai-render] \u69FD\u4F4D\u89E3\u6790\u5931\u8D25", error);
205
+ return hit ? wantsSSE ? sse(hit.value) : json(hit.value, 200) : json({ error: "ai_unavailable" }, 503);
206
+ }
207
+ if (!slot) return json({ error: "unknown_slot" }, 404);
208
+ if (userPrompt === void 0) {
209
+ hit = lookup(
210
+ store,
211
+ developerCacheKey(slotId, slot.contentVersion, slot.promptVersion ?? "1"),
212
+ now()
213
+ );
214
+ if (hit?.status === "fresh") return wantsSSE ? sse(hit.value) : json(hit.value, 200);
215
+ }
216
+ const isUserPath = userPrompt !== void 0;
217
+ const key = userPrompt !== void 0 ? userCacheKey(slotId, userPrompt) : developerCacheKey(slotId, slot.contentVersion, slot.promptVersion ?? "1");
218
+ const ttlMs = isUserPath ? opts.userTtlMs ?? 6e5 : opts.developerTtlMs ?? 36e5;
219
+ const staleMs = opts.staleMs ?? 864e5;
220
+ const staleOr503 = () => {
221
+ if (hit) return wantsSSE ? sse(hit.value) : json(hit.value, 200);
222
+ return json({ error: "ai_unavailable" }, 503);
223
+ };
224
+ try {
225
+ const compiled = compilePrompt({
226
+ registry: opts.registry,
227
+ slotId,
228
+ originalContent: slot.originalContent,
229
+ developerPrompt: slot.developerPrompt,
230
+ userPrompt,
231
+ data: slot.data
232
+ });
233
+ const model = isUserPath ? opts.models?.user ?? "gpt-4o-mini" : opts.models?.developer ?? "gpt-4o-mini";
234
+ const llmRes = await llm.complete({
235
+ model,
236
+ system: compiled.system,
237
+ user: compiled.user,
238
+ maxTokens: opts.maxTokens ?? 2e3,
239
+ timeoutMs: 8e3
240
+ });
241
+ try {
242
+ opts.onUsage?.({
243
+ slotId,
244
+ model,
245
+ reason: isUserPath ? "user-prompt" : "developer-prompt",
246
+ usage: llmRes.usage,
247
+ at: now()
248
+ });
249
+ } catch (error) {
250
+ console.warn("[ai-render] \u7528\u91CF\u65E5\u5FD7\u56DE\u8C03\u5931\u8D25\uFF0C\u5DF2\u5FFD\u7565", error);
251
+ }
252
+ const parsed = JSON.parse(llmRes.text);
253
+ const tree = parsed?.tree;
254
+ const result = validateComponentTree(opts.registry, tree);
255
+ if (!result.ok) {
256
+ console.warn("[ai-render] \u8F93\u51FA\u6821\u9A8C\u5931\u8D25\uFF0C\u5DF2\u4E22\u5F03", result.errors, llmRes.text.slice(0, 500));
257
+ return staleOr503();
258
+ }
259
+ const response = {
260
+ version: 1,
261
+ slot: slotId,
262
+ tree,
263
+ meta: { reason: isUserPath ? "user-prompt" : "developer-prompt" }
264
+ };
265
+ store.set(key, response, ttlMs, staleMs, now());
266
+ return wantsSSE ? sse(response) : json(response, 200);
267
+ } catch (error) {
268
+ console.warn("[ai-render] LLM \u8C03\u7528\u5931\u8D25", error);
269
+ return staleOr503();
270
+ }
271
+ };
272
+ }
273
+
274
+ // src/invalidate.ts
275
+ var encoder = new TextEncoder();
276
+ function createInvalidationChannel(opts = {}) {
277
+ const heartbeatMs = opts.heartbeatMs ?? 25e3;
278
+ const subscribers = /* @__PURE__ */ new Map();
279
+ function subscribe(slotId, controller) {
280
+ let set = subscribers.get(slotId);
281
+ if (!set) {
282
+ set = /* @__PURE__ */ new Set();
283
+ subscribers.set(slotId, set);
284
+ }
285
+ set.add(controller);
286
+ }
287
+ function unsubscribe(slotId, controller) {
288
+ subscribers.get(slotId)?.delete(controller);
289
+ }
290
+ return {
291
+ handler(req) {
292
+ const slotId = new URL(req.url).searchParams.get("slot");
293
+ if (!slotId) {
294
+ return new Response(JSON.stringify({ error: "missing_slot" }), {
295
+ status: 400,
296
+ headers: { "content-type": "application/json; charset=utf-8" }
297
+ });
298
+ }
299
+ let controller;
300
+ let heartbeat;
301
+ const body = new ReadableStream({
302
+ start(c) {
303
+ controller = c;
304
+ subscribe(slotId, c);
305
+ },
306
+ cancel() {
307
+ unsubscribe(slotId, controller);
308
+ if (heartbeat !== void 0) clearInterval(heartbeat);
309
+ }
310
+ });
311
+ heartbeat = setInterval(() => {
312
+ try {
313
+ controller.enqueue(encoder.encode(": ping\n\n"));
314
+ } catch {
315
+ if (heartbeat !== void 0) clearInterval(heartbeat);
316
+ }
317
+ }, heartbeatMs);
318
+ return new Response(body, {
319
+ status: 200,
320
+ headers: {
321
+ "content-type": "text/event-stream; charset=utf-8",
322
+ "cache-control": "no-cache",
323
+ connection: "keep-alive"
324
+ }
325
+ });
326
+ },
327
+ invalidate(slotId) {
328
+ const frame = encoder.encode(`event: invalidate
329
+ data: ${JSON.stringify({ slot: slotId })}
330
+
331
+ `);
332
+ for (const controller of subscribers.get(slotId) ?? []) {
333
+ try {
334
+ controller.enqueue(frame);
335
+ } catch {
336
+ unsubscribe(slotId, controller);
337
+ }
338
+ }
339
+ },
340
+ subscriberCount(slotId) {
341
+ return subscribers.get(slotId)?.size ?? 0;
342
+ }
343
+ };
344
+ }
345
+
346
+ // src/openai-client.ts
347
+ function createOpenAIClient(opts) {
348
+ const baseUrl = opts.baseUrl ?? "https://api.openai.com/v1";
349
+ const doFetch = opts.fetchImpl ?? fetch;
350
+ return {
351
+ async complete(req) {
352
+ const timeoutMs = req.timeoutMs ?? 8e3;
353
+ const controller = new AbortController();
354
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
355
+ try {
356
+ const res = await doFetch(`${baseUrl}/chat/completions`, {
357
+ method: "POST",
358
+ headers: {
359
+ authorization: `Bearer ${opts.apiKey}`,
360
+ "content-type": "application/json"
361
+ },
362
+ body: JSON.stringify({
363
+ model: req.model,
364
+ messages: [
365
+ { role: "system", content: req.system },
366
+ { role: "user", content: req.user }
367
+ ],
368
+ response_format: { type: "json_object" },
369
+ ...req.maxTokens !== void 0 ? { max_tokens: req.maxTokens } : {}
370
+ }),
371
+ signal: controller.signal
372
+ });
373
+ if (!res.ok) throw new LLMError(`LLM HTTP ${res.status}`);
374
+ const data = await res.json();
375
+ const text = data.choices?.[0]?.message?.content;
376
+ if (typeof text !== "string") throw new LLMError("LLM \u54CD\u5E94\u7F3A\u5C11\u5185\u5BB9");
377
+ return {
378
+ text,
379
+ usage: { promptTokens: data.usage?.prompt_tokens, completionTokens: data.usage?.completion_tokens }
380
+ };
381
+ } finally {
382
+ clearTimeout(timer);
383
+ }
384
+ }
385
+ };
386
+ }
387
+
388
+ // src/prewarm.ts
389
+ async function prewarm(handler, slotIds) {
390
+ const ok = [];
391
+ const failed = [];
392
+ for (const slotId of slotIds) {
393
+ try {
394
+ const res = await handler(new Request(`https://prewarm.local/ai-render/${slotId}`));
395
+ if (res.ok) ok.push(slotId);
396
+ else failed.push(slotId);
397
+ } catch {
398
+ failed.push(slotId);
399
+ }
400
+ }
401
+ return { ok, failed };
402
+ }
403
+ export {
404
+ LLMError,
405
+ MemoryCacheStore,
406
+ RateLimiter,
407
+ compilePrompt,
408
+ createAiRenderHandler,
409
+ createInvalidationChannel,
410
+ createOpenAIClient,
411
+ developerCacheKey,
412
+ hashKey,
413
+ lookup,
414
+ normalizeUserPrompt,
415
+ prewarm,
416
+ sanitizeUserPrompt,
417
+ userCacheKey,
418
+ withRetry
419
+ };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@ai-slot/proxy",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "description": "Stateless AI render proxy: prompt compiler, LLM client with retry, output validator, and two-tier cache.",
6
+ "keywords": [
7
+ "ai",
8
+ "llm",
9
+ "proxy",
10
+ "edge",
11
+ "serverless",
12
+ "cache",
13
+ "rate-limit"
14
+ ],
15
+ "type": "module",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/iannil/ai-slot-component.git",
28
+ "directory": "packages/proxy"
29
+ },
30
+ "bugs": "https://github.com/iannil/ai-slot-component/issues",
31
+ "homepage": "https://github.com/iannil/ai-slot-component/tree/master/packages/proxy#readme",
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "registry": "https://registry.npmjs.org/"
35
+ },
36
+ "sideEffects": false,
37
+ "dependencies": {
38
+ "@ai-slot/registry": "0.1.0"
39
+ },
40
+ "devDependencies": {
41
+ "tsup": "^8.3.0",
42
+ "typescript": "^5.6.0",
43
+ "vitest": "^2.1.0"
44
+ },
45
+ "scripts": {
46
+ "build": "tsup src/index.ts --format esm --dts --clean",
47
+ "test": "vitest run",
48
+ "typecheck": "tsc --noEmit"
49
+ }
50
+ }