@firetable/project-xiaochun 0.1.12 → 0.1.13

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-CN.md ADDED
@@ -0,0 +1,337 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/FireTable/project-xiaochun/main/public/logo.png" width="96" height="96" alt="Project XiaoChun Logo" style="border-radius: 16px;" />
3
+ </p>
4
+
5
+ <h1 align="center">@firetable/project-xiaochun</h1>
6
+
7
+ <p align="center">
8
+ <b>把「小蠢」——100% 浏览器原生的 3D 二次元陪伴角色——嵌入任意网页:懒加载、严格 origin 校验、零运行时依赖</b>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="README.md">English</a> •
13
+ 简体中文
14
+ </p>
15
+
16
+ <p align="center">
17
+ <a href="https://xiaochun.firetable.tech"><b>🌐 在线体验</b></a>
18
+ •
19
+ <a href="https://github.com/FireTable/project-xiaochun/blob/main/docs/EMBED.md"><b>📚 完整嵌入文档</b></a>
20
+ </p>
21
+
22
+ <p align="center">
23
+ <a href="https://www.npmjs.com/package/@firetable/project-xiaochun"><img src="https://img.shields.io/npm/v/@firetable/project-xiaochun?logo=npm&color=cb3837" alt="npm version" /></a>
24
+ <a href="https://xiaochun.firetable.tech"><img src="https://img.shields.io/badge/Live_Demo-xiaochun.firetable.tech-10b981?logo=cloudflare&logoColor=white" alt="Live Demo" /></a>
25
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-amber.svg" alt="License" /></a>
26
+ </p>
27
+
28
+ ---
29
+
30
+ ## 📖 项目简介 (Overview)
31
+
32
+ 这个包通过一个**懒加载、严格校验 origin 的 `<iframe>`**,把 **[Project XiaoChun(小蠢)](https://github.com/FireTable/project-xiaochun)** 放到你自己的网站上。角色、端上 AI(WebLLM / SenseVoice 语音识别 / EMAGE 动作)和所有重资源都在 iframe 里,你的页面只多出几 KB 的胶水代码(SDK 不会打包 three.js)。
33
+
34
+ * 🧩 **`createXiaochun()`**:无框架 JS SDK。
35
+ * 🏷️ **`<xiaochun-avatar>`**:Web Component(Shadow DOM,属性 + 事件)。
36
+ * ⚛️ **`@firetable/project-xiaochun/react`**:`<Xiaochun />` 组件与 `useXiaochun` hook(SSR 安全、StrictMode 安全)。
37
+ * 📜 **一行 `<script>` loader**:IIFE 构建,走 jsDelivr / unpkg,无需构建。
38
+ * 🔊 **`speakAudio()`**:把你自己的音频交给小蠢,她会带着肢体动作和口型念出来。
39
+ * 📦 ESM + CJS + `.d.ts`;协议常量与类型从 `@firetable/project-xiaochun/protocol` 导出。
40
+
41
+ ---
42
+
43
+ ## 📥 安装 (Installation)
44
+
45
+ ```bash
46
+ npm i @firetable/project-xiaochun
47
+ # 或
48
+ pnpm add @firetable/project-xiaochun
49
+ # 或
50
+ yarn add @firetable/project-xiaochun
51
+ ```
52
+
53
+ 不想构建?用 CDN loader(生产环境请锁定版本):
54
+
55
+ ```html
56
+ <script src="https://cdn.jsdelivr.net/npm/@firetable/project-xiaochun@0.1/dist/loader.global.js" defer></script>
57
+ <xiaochun-avatar position="bottom-right" size="280" lang="zh-CN"></xiaochun-avatar>
58
+ ```
59
+
60
+ unpkg 上是同一个文件:`https://unpkg.com/@firetable/project-xiaochun@0.1/dist/loader.global.js`。
61
+
62
+ | 入口 | 用途 |
63
+ | :--- | :--- |
64
+ | `@firetable/project-xiaochun` | `createXiaochun`、协议常量/类型、`toProtocolUrl` |
65
+ | `@firetable/project-xiaochun/element` | 注册 `<xiaochun-avatar>`(副作用导入) |
66
+ | `@firetable/project-xiaochun/react` | `<Xiaochun />`、`useXiaochun`(需要 `react >= 18`,可选 peer 依赖) |
67
+ | `@firetable/project-xiaochun/protocol` | 仅协议常量与类型 |
68
+ | `@firetable/project-xiaochun/loader` | IIFE 包(全局变量 `window.Xiaochun`) |
69
+
70
+ ---
71
+
72
+ ## 🚀 快速上手 (Quick Start)
73
+
74
+ ### JavaScript SDK
75
+
76
+ ```ts
77
+ import { createXiaochun } from '@firetable/project-xiaochun';
78
+
79
+ const xc = createXiaochun({
80
+ container: '#avatar',
81
+ width: 320, height: 480, // 预留固定尺寸 → 零布局抖动
82
+ placeholder: '/img/xiaochun.webp',
83
+ transparent: true,
84
+ });
85
+
86
+ await xc.ready; // 模型加载完成
87
+ await xc.say('你好呀'); // 念完才 resolve
88
+ xc.on('stt', (p) => p.kind === 'text' && console.log(p.text));
89
+ xc.destroy();
90
+ ```
91
+
92
+ ### Web Component
93
+
94
+ ```html
95
+ <script type="module">import '@firetable/project-xiaochun/element';</script>
96
+ <xiaochun-avatar id="xc" size="320x480" lazy="click"></xiaochun-avatar>
97
+ <script>
98
+ xc.addEventListener('xc-ready', () => xc.say('你好!'));
99
+ </script>
100
+ ```
101
+
102
+ ### 一行 script(自动挂一个悬浮头像)
103
+
104
+ ```html
105
+ <script src=".../dist/loader.global.js" data-auto data-position="bottom-right" data-size="280" defer></script>
106
+ ```
107
+
108
+ `data-*` 属性对应 `<xiaochun-avatar>` 的同名属性。
109
+
110
+ ---
111
+
112
+ ## ⚛️ React
113
+
114
+ `react` 是**可选的 peer 依赖**(`>=18`,同时支持 React 18 与 19)。子路径是独立的包,主入口体积不会增加。文件带 `'use client'`(兼容 Next.js App Router)。服务端只渲染一个固定尺寸的空 `<div>`,iframe 在客户端 effect 里才创建;`<StrictMode>` 下 effect 清理一定会调用 `destroy()`,不会泄漏。
115
+
116
+ ```tsx
117
+ import { useRef } from 'react';
118
+ import { Xiaochun, useXiaochun, type XiaochunHandle } from '@firetable/project-xiaochun/react';
119
+
120
+ export function Mascot({ audio }: { audio?: ArrayBuffer }) {
121
+ const ref = useRef<XiaochunHandle>(null);
122
+ return (
123
+ <>
124
+ <Xiaochun
125
+ ref={ref} width={320} height={480} transparent lazy placeholder="/xc.webp"
126
+ onReady={() => ref.current?.say('你好呀')}
127
+ onUtterance={(u) => console.log(u.phase, u.kind)} // 'start' | 'end', 'text' | 'audio'
128
+ onError={(e) => console.warn(e.code, e.message)}
129
+ />
130
+ <button onClick={() => audio && ref.current?.speakAudio(audio)}>播放音频</button>
131
+ </>
132
+ );
133
+ }
134
+
135
+ // 想自己控制布局?用 hook:
136
+ const { containerRef, client, ready, state } = useXiaochun({ width: 280, height: 420 });
137
+ // <div ref={containerRef} style={{ width: 280, height: 420 }} />
138
+ ```
139
+
140
+ * **Props**:`createXiaochun` 除 `container` 外的全部选项,加上 `onHandshake / onReady / onProgress / onState / onStt / onUtterance / onHitRegion / onError / onDestroy`、`className`、`style`、`paused`、`mic`。
141
+ * **重建与热更新**:创建期选项(`src`、`lazy`、`transparent`、`width`、`height`、`position` 等)变化会重建实例,所以不要在每次渲染里传新值。`lang`、`model`、`paused`、`mic` 与回调会原地更新,不重建 iframe。
142
+ * **ref 方法**:`say`、`speakAudio`、`speakAudioStream`、`motion`、`expression`、`lookAt`、`setModel`、`setConfig`、`startListening`、`stopListening`、`mic`、`pause`、`resume`、`activate`、`destroy`,以及 `ready` 与 `instance`。组件挂载前,返回 Promise 的方法会 reject。
143
+
144
+ ---
145
+
146
+ ## 🔊 宿主传音频 (`speakAudio`)
147
+
148
+ 跳过内置 TTS,让小蠢念**你自己的**音频。iframe 负责解码,EMAGE 生成匹配的肢体动作(16 kHz 单声道窗口),音画同步并带口型,播放结束时触发 `utterance end`。重模型仍然懒加载:第一次带动作的 `speakAudio` 才会加载 EMAGE,`motion: false` 则永远不加载。
149
+
150
+ ```ts
151
+ await xc.speakAudio(arrayBuffer, { text: '你好', motion: true, lipsync: true }); // ArrayBuffer 会被 transfer(detach);要保留就传 { transfer: false }
152
+ await xc.speakAudio(blob); // Blob(mp3 / wav / ogg 等)
153
+ await xc.speakAudio('https://cdn.example.com/voice.mp3'); // URL:默认由宿主页 fetch({ fetch: 'frame' } = 交给 iframe 去 fetch)
154
+ await xc.speakAudio(pcm, { format: 'pcm16', sampleRate: 24000 }); // 无头原始 PCM 必须给 sampleRate(8000–96000)
155
+
156
+ const s = xc.speakAudioStream({ sampleRate: 24000 }); // 流式:比如 TTS 服务边合成边返回的 PCM 块
157
+ s.write(int16Chunk); s.write(next); s.end(); await s.done; // s.abort() 立即停止
158
+ // 随时可取消:传 { signal: abortController.signal }
159
+ ```
160
+
161
+ * 失败时 reject:`bad_request`(无法解码 / 空音频 / URL 或 sampleRate 不合法)、`unsupported`、`failed`。
162
+ * 浏览器仍要求宿主页先有用户手势才能出声,iframe 也要有 `allow="autoplay"`(SDK 已自动设置)。
163
+ * 新的 `say()` / `speakAudio()` 会打断正在进行的那一次,被打断的 Promise 会 resolve。
164
+ * `<xiaochun-avatar>` 与 React 的 ref 提供同名的 `speakAudio` / `speakAudioStream` 方法。
165
+
166
+ ---
167
+
168
+ ## 🎨 样式 (CSS 变量与 `::part`)
169
+
170
+ 宿主只能调整**外壳**。角色在跨域 iframe 里,你的 CSS 无法影响 iframe 内部。
171
+
172
+ | 变量 | 默认值 | 作用 |
173
+ | :--- | :--- | :--- |
174
+ | `--xc-radius` | `0` | 圆角(任意 CSS 长度,如 `24px`;`50%` 为圆形头像框)。调大更圆,过大会裁掉头和脚 |
175
+ | `--xc-shadow` | `none` | `box-shadow` 简写。透明悬浮头像请保持 `none` |
176
+ | `--xc-z-index` | `2147483000` | 仅悬浮模式。调小可以让你的弹窗和导航盖在头像上面 |
177
+ | `--xc-offset-x` / `--xc-offset-y` | `16px` | 仅悬浮模式:距屏幕侧边 / 底边的距离 |
178
+ | `--xc-bg` | `transparent` | iframe 加载期间的底色(`transparent` 模式下忽略) |
179
+
180
+ ```css
181
+ xiaochun-avatar {
182
+ --xc-radius: 24px;
183
+ --xc-shadow: 0 8px 24px rgba(0, 0, 0, .18);
184
+ --xc-offset-y: 72px; /* 抬高,避开底部导航栏 */
185
+ }
186
+ xiaochun-avatar::part(iframe) { outline: 1px solid #0002; }
187
+ ```
188
+
189
+ 可用的 part:`mount` · `wrapper` · `iframe` · `placeholder`。
190
+
191
+ ---
192
+
193
+ ## 🔌 `xiaochun://` 与 `xc.*`
194
+
195
+ `xiaochun://` 是由小蠢桌面客户端(Tauri)处理的 **OS 级 deep link**;`xc.*` 是你的页面与 `/embed` iframe 之间的 **postMessage 协议**。两者是同一组动作的两个传输层,在应用内共用同一个 handler。`/embed` 页面不会响应 `xiaochun://`。
196
+
197
+ | SDK / `xc.*` | 桌面 deep link |
198
+ | :--- | :--- |
199
+ | `say(text)` | `xiaochun://speak?text=…` |
200
+ | `speakAudio(url)` | `xiaochun://speak?audioUrl=…[&text=…]` |
201
+ | `speakAudio(ArrayBuffer \| Blob)`、`speakAudioStream` | —(二进制数据放不进 URL) |
202
+ | `say(text, { mode: 'chat' })`、`motion`、`expression` 等 | — |
203
+
204
+ `toProtocolUrl()` 与 `parseProtocolUrl()` 是纯字符串函数,可用于桌面脚本或 `<a href>` 链接;`XC_PROTOCOL_MAPPING` 是上表的机器可读版本:
205
+
206
+ ```ts
207
+ import { toProtocolUrl } from '@firetable/project-xiaochun';
208
+
209
+ toProtocolUrl({ action: 'speak', text: '你好' });
210
+ // → 'xiaochun://speak?text=%E4%BD%A0%E5%A5%BD'
211
+ toProtocolUrl({ action: 'speak', audioUrl: 'https://cdn.example.com/hi.mp3', text: '你好' });
212
+ // → 'xiaochun://speak?text=%E4%BD%A0%E5%A5%BD&audioUrl=https%3A%2F%2Fcdn.example.com%2Fhi.mp3'
213
+ ```
214
+
215
+ 详见 [`docs/PROTOCOL.md` §6](https://github.com/FireTable/project-xiaochun/blob/main/docs/PROTOCOL.md) 与 [`docs/EMBED.md` §2.4–2.5](https://github.com/FireTable/project-xiaochun/blob/main/docs/EMBED.md)。
216
+
217
+ ---
218
+
219
+ ## 🔐 权限、CSP 与跨源隔离
220
+
221
+ ### iframe 必须被允许使用麦克风和音频
222
+
223
+ SDK 会自动设置 `allow="microphone; autoplay"`。如果你手写 iframe,**必须**自己加上:
224
+
225
+ ```html
226
+ <iframe src="https://xiaochun.firetable.tech/embed?host=https%3A%2F%2Fyour-site.com"
227
+ allow="microphone; autoplay" loading="lazy" width="320" height="480"></iframe>
228
+ ```
229
+
230
+ * `microphone`:端上语音识别(`xc.mic`),需要 HTTPS。
231
+ * `autoplay`:语音音频。宿主页仍需要先有用户手势(把第一次 `say()` 绑定到点击上)。
232
+ * `host=`:你页面的 origin。缺少它时握手会**默认拒绝**(fail closed)。
233
+
234
+ ### CSP / COOP / COEP
235
+
236
+ * **你的 CSP**:允许 `frame-src https://xiaochun.firetable.tech`(如果用 CDN loader,还要允许 `script-src https://cdn.jsdelivr.net`)。
237
+ * **`/embed` 的响应头**(由小蠢部署端设置):没有 `X-Frame-Options`;默认 `Content-Security-Policy: frame-ancestors *`(自建部署可以收紧);`Permissions-Policy: microphone=(self)`;`Cross-Origin-Resource-Policy: cross-origin`。
238
+ * **你的 COOP/COEP**:宿主页设置 `COEP: require-corp` 也能正常嵌入(embed 会发送 CORP)。要让 iframe 自身跨源隔离,需要宿主页也隔离**并且** `allow="cross-origin-isolated"`;否则端上 ONNX 以单线程运行(更慢但可用)。
239
+ * **第三方存储分区**:iframe 内缓存的模型按顶层站点分区,所以每个宿主站点都会各自下载一份。因此 embed 默认**不会**预加载端上 LLM / EMAGE 模型(`heavy: 'lazy'`)。
240
+ * **安全性**:握手校验 origin 之后,消息走 `MessageChannel`;从不使用 `'*'` 作为 targetOrigin;通配的 `origin` / `allowedOrigins` 会被拒绝。
241
+
242
+ ---
243
+
244
+ ## ⚡ 对 Lighthouse 友好的用法
245
+
246
+ ```ts
247
+ createXiaochun({
248
+ container: '#avatar',
249
+ width: 320, height: 480, // 固定尺寸 → 无布局抖动
250
+ placeholder: '/xc.webp', // 首屏只有一张图
251
+ lazy: true, // 进入视口且空闲时才创建 iframe(用 'click' 最省)
252
+ lazyMargin: 200, // px;越大加载越早,但更耗流量
253
+ heavy: 'lazy', // 首次互动前不加载 WebLLM / EMAGE
254
+ autoPause: true, // 滚出视口自动暂停渲染
255
+ });
256
+ ```
257
+
258
+ ---
259
+
260
+ ## 📚 API
261
+
262
+ ### `createXiaochun(options): XiaochunInstance`
263
+
264
+ | 选项 | 默认值 | 说明 |
265
+ | :--- | :--- | :--- |
266
+ | `container` | — | 元素或选择器(必填) |
267
+ | `src` | `https://xiaochun.firetable.tech/embed` | embed 页面地址(自建或本地调试时覆盖) |
268
+ | `origin` | 由 `src` 推导 | iframe 的预期 origin,所有消息都以它校验 |
269
+ | `allowedOrigins` | `[]` | 额外可信的 iframe origin,不接受 `'*'` |
270
+ | `lazy` | `true` | `true` / `'idle'`:进入视口且空闲 · `'click'`:点击或首次调用 API · `false`:立即创建 |
271
+ | `lazyMargin` | `200` | 可见性触发的 rootMargin(px)。调大更早加载、更耗流量 |
272
+ | `placeholder` | 内置 SVG | 图片 URL、元素或 `false` |
273
+ | `transparent` | `false` | 背景透明叠在页面上(同时开启指针穿透) |
274
+ | `width`、`height` | `320`、`480` | px 或任意 CSS 长度,务必设置 |
275
+ | `position` | `'inline'` | `'inline' \| 'bottom-right' \| 'bottom-left'` |
276
+ | `draggable` | `false` | 悬浮模式下显示拖动手柄 |
277
+ | `lang`、`model` | — | `'zh-CN' \| 'en' \| 'ja'`;服装 key(如 `xiaochun_maid`)或 https `.vrm` URL |
278
+ | `ui` | `false` | 显示 embed 内置的聊天栏 |
279
+ | `heavy` | `'lazy'` | `'lazy'`:首次使用才加载 WebLLM / EMAGE · `'eager'`:预加载 |
280
+ | `controls` | `false` | 放开 iframe 内滚轮缩放(会吞掉页面滚动) |
281
+ | `autoPause` | `true` | 滚出视口自动暂停 |
282
+ | `passthrough` | = `transparent` | 按"鼠标是否在角色上"切换 iframe 的 pointer-events |
283
+ | `sandbox` | scripts + same-origin + popups | iframe `sandbox`;`false` = 不加。去掉 `allow-same-origin` 会让 IndexedDB 和麦克风失效 |
284
+ | `handshakeTimeout` | `20000` | 毫秒;超时会触发 `error { code: 'timeout' }` |
285
+ | `zIndex` | `2147483000` | 悬浮模式层级(`--xc-z-index` 变量优先) |
286
+
287
+ **实例**:`ready` · `say(text, { mode: 'speak' \| 'chat' })` · `speakAudio(source, opts)` · `speakAudioStream(opts)` · `motion(nameOrUrlOrOptions)` · `expression(name)` · `setModel(outfitOrUrl)` · `setConfig(cfg)` · `startListening()` / `stopListening()` / `mic(on)` · `pause()` / `resume()` · `activate()` · `destroy()` · `on(event, cb)` · `lookAt()` *(协议已预留,目前返回 `unsupported`)*。
288
+
289
+ **事件**:`handshake` · `ready` · `progress` · `state` · `stt` · `utterance`(`phase: 'start' | 'end'`,`kind: 'text' | 'audio'`)· `hit-region` · `error` · `destroy`。
290
+
291
+ ### `<xiaochun-avatar>`
292
+
293
+ | 属性 | 默认值 | 说明 |
294
+ | :--- | :--- | :--- |
295
+ | `src` | 官方 `/embed` | 修改会重建 iframe |
296
+ | `model` | — | 服装 key,或 https `.vrm` / `.vrmaddon` / `.vrmbase` URL;运行时修改 = `setModel` |
297
+ | `lang` | — | `zh-CN` · `en` · `ja`;运行时修改 = `setConfig` |
298
+ | `mic` | `false` | 开关听写(模型加载完成后生效) |
299
+ | `transparent` | `true` | `"false"` 关闭 |
300
+ | `draggable` | `false` | 仅悬浮模式 |
301
+ | `position` | `inline` | `inline` · `bottom-right` · `bottom-left` |
302
+ | `size` | `320x480` | `"280"`(高 = 宽 × 1.5)、`"320x480"`、`"100%x480px"` |
303
+ | `lazy` | 空闲 + 视口 | `"click"` 仅点击;`"false"` 立即创建 |
304
+ | `paused` | `false` | `pause()` / `resume()` |
305
+ | `placeholder` / `heavy` / `ui` / `controls` / `allowed-origins` | — | 同 `createXiaochun` |
306
+
307
+ **事件**(`CustomEvent`,`composed`,`detail` = 协议 payload):`xc-ready` · `xc-progress` · `xc-state` · `xc-stt` · `xc-utterance` · `xc-error`。
308
+ **方法**:`say` · `speakAudio` · `speakAudioStream` · `motion` · `expression` · `destroy`;`el.client` 可拿到完整的 SDK 实例。
309
+
310
+ ---
311
+
312
+ ## 🧪 本地试玩 (Try It Locally)
313
+
314
+ 打开 [`examples/embed-host.html`](./examples/embed-host.html),文件头部注释写了启动步骤。要连本地的小蠢开发服务器,传 `src: 'https://localhost:5185/embed'`。
315
+
316
+ ---
317
+
318
+ ## 🏷️ 版本与发布 (Versioning & Release)
319
+
320
+ 包版本与**小蠢桌面客户端锁步**:一次 `pnpm bump:patch|minor|major` 加一个 `v*` git tag,就会并行且互相独立地触发桌面发布和 npm 发布。
321
+
322
+ npm 版本由 GitHub Actions 通过 **npm Trusted Publishing(OIDC)** 发布:没有长期有效的 `NPM_TOKEN`,并自动生成 provenance。维护者的配置步骤(首次手动发布、绑定 Trusted Publisher、staged publish)见 [`docs/EMBED.md` §6](https://github.com/FireTable/project-xiaochun/blob/main/docs/EMBED.md)。
323
+
324
+ ---
325
+
326
+ ## 📚 更多文档
327
+
328
+ * [`docs/EMBED.md`](https://github.com/FireTable/project-xiaochun/blob/main/docs/EMBED.md):完整的嵌入设计、协议表、响应头、风险与发布流程
329
+ * [`docs/PROTOCOL.md`](https://github.com/FireTable/project-xiaochun/blob/main/docs/PROTOCOL.md):`xiaochun://` URL Scheme 及其与 `xc.*` 的对照
330
+ * [`docs/README.md`](https://github.com/FireTable/project-xiaochun/blob/main/docs/README.md):文档索引
331
+ * [项目主页 README](https://github.com/FireTable/project-xiaochun/blob/main/README-CN.md):主项目介绍
332
+
333
+ ---
334
+
335
+ ## 📄 许可证 (License)
336
+
337
+ [MIT](./LICENSE)
package/README.md CHANGED
@@ -1,46 +1,95 @@
1
- # @firetable/project-xiaochun
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/FireTable/project-xiaochun/main/public/logo.png" width="96" height="96" alt="Project XiaoChun Icon" style="border-radius: 16px;" />
3
+ </p>
2
4
 
3
- Embed **[Project XiaoChun](https://xiaochun.firetable.tech)** (a 100 % browser-native 3D anime companion) into any web page through a **lazy, origin-checked `<iframe>`**.
4
- 把「小蠢」用 `<iframe>` 内嵌到任意网页:懒加载、严格 origin 校验、零运行时依赖(不含 three.js)。
5
+ <h1 align="center">@firetable/project-xiaochun</h1>
5
6
 
6
- - `createXiaochun()` — framework-free JS SDK / 无框架 SDK
7
- - `<xiaochun-avatar>` — Web Component (Shadow DOM)
8
- - One-line `<script>` loader (IIFE, jsDelivr / unpkg) / 一行 script 形态
9
- - ESM + CJS + `.d.ts`; protocol constants & types exported from `project-xiaochun/protocol`
7
+ <p align="center">
8
+ <b>Embed XiaoChun (小蠢), a 100% browser-native 3D anime companion, into any web page — lazy, origin-checked, zero runtime dependencies</b>
9
+ </p>
10
10
 
11
- > Full design, protocol tables, response headers, risks: [`docs/EMBED.md`](https://github.com/FireTable/project-xiaochun/blob/main/docs/EMBED.md)
11
+ <p align="center">
12
+ English •
13
+ <a href="README-CN.md">简体中文</a>
14
+ </p>
12
15
 
13
- ## Install / 安装
16
+ <p align="center">
17
+ <a href="https://xiaochun.firetable.tech"><b>🌐 Live Demo</b></a>
18
+ •
19
+ <a href="https://github.com/FireTable/project-xiaochun/blob/main/docs/EMBED.md"><b>📚 Full Embed Docs</b></a>
20
+ </p>
21
+
22
+ <p align="center">
23
+ <a href="https://www.npmjs.com/package/@firetable/project-xiaochun"><img src="https://img.shields.io/npm/v/@firetable/project-xiaochun?logo=npm&color=cb3837" alt="npm version" /></a>
24
+ <a href="https://xiaochun.firetable.tech"><img src="https://img.shields.io/badge/Live_Demo-xiaochun.firetable.tech-10b981?logo=cloudflare&logoColor=white" alt="Live Demo" /></a>
25
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-amber.svg" alt="License" /></a>
26
+ </p>
27
+
28
+ ---
29
+
30
+ ## 📖 Overview
31
+
32
+ This package puts **[Project XiaoChun](https://github.com/FireTable/project-xiaochun)** on your own site through a **lazy, origin-checked `<iframe>`**. The character, the on-device AI (WebLLM / SenseVoice STT / EMAGE motion) and all heavy assets live inside the iframe; your page only ships a few KB of glue code (the SDK never bundles three.js).
33
+
34
+ * 🧩 **`createXiaochun()`**: framework-free JS SDK.
35
+ * 🏷️ **`<xiaochun-avatar>`**: Web Component (Shadow DOM, attributes + events).
36
+ * ⚛️ **`@firetable/project-xiaochun/react`**: `<Xiaochun />` component and `useXiaochun` hook (SSR-safe, StrictMode-safe).
37
+ * 📜 **One-line `<script>` loader**: IIFE build for jsDelivr / unpkg, no build step.
38
+ * 🔊 **`speakAudio()`**: hand XiaoChun your own audio and she speaks it with body motion and lip-sync.
39
+ * 📦 ESM + CJS + `.d.ts`; protocol constants and types are exported from `@firetable/project-xiaochun/protocol`.
40
+
41
+ ---
42
+
43
+ ## 📥 Installation
14
44
 
15
45
  ```bash
16
- npm i @firetable/project-xiaochun # or pnpm add / yarn add
46
+ npm i @firetable/project-xiaochun
47
+ # or
48
+ pnpm add @firetable/project-xiaochun
49
+ # or
50
+ yarn add @firetable/project-xiaochun
17
51
  ```
18
52
 
53
+ No build step? Use the CDN loader (pin the version in production):
54
+
19
55
  ```html
20
- <!-- or: no build step / 或:不用构建 -->
21
56
  <script src="https://cdn.jsdelivr.net/npm/@firetable/project-xiaochun@0.1/dist/loader.global.js" defer></script>
22
- <xiaochun-avatar position="bottom-right" size="280" lang="zh-CN"></xiaochun-avatar>
57
+ <xiaochun-avatar position="bottom-right" size="280" lang="en"></xiaochun-avatar>
23
58
  ```
24
59
 
25
- ## Quick start / 快速上手
60
+ The same file is available from unpkg: `https://unpkg.com/@firetable/project-xiaochun@0.1/dist/loader.global.js`.
61
+
62
+ | Entry | Purpose |
63
+ | :--- | :--- |
64
+ | `@firetable/project-xiaochun` | `createXiaochun`, protocol constants/types, `toProtocolUrl` |
65
+ | `@firetable/project-xiaochun/element` | Registers `<xiaochun-avatar>` (side-effect import) |
66
+ | `@firetable/project-xiaochun/react` | `<Xiaochun />`, `useXiaochun` (needs `react >= 18`, optional peer dependency) |
67
+ | `@firetable/project-xiaochun/protocol` | Protocol constants and types only |
68
+ | `@firetable/project-xiaochun/loader` | The IIFE bundle (global `window.Xiaochun`) |
69
+
70
+ ---
71
+
72
+ ## 🚀 Quick Start
73
+
74
+ ### JavaScript SDK
26
75
 
27
76
  ```ts
28
77
  import { createXiaochun } from '@firetable/project-xiaochun';
29
78
 
30
79
  const xc = createXiaochun({
31
80
  container: '#avatar',
32
- width: 320, height: 480, // reserve space → zero CLS / 预留固定尺寸 → 零 CLS
81
+ width: 320, height: 480, // reserve space → zero layout shift
33
82
  placeholder: '/img/xiaochun.webp',
34
83
  transparent: true,
35
84
  });
36
85
 
37
- await xc.ready; // model loaded / 模型加载完成
38
- await xc.say('你好呀'); // resolves when speech ends / 念完才 resolve
86
+ await xc.ready; // model loaded
87
+ await xc.say('Hello!'); // resolves when she finishes speaking
39
88
  xc.on('stt', (p) => p.kind === 'text' && console.log(p.text));
40
89
  xc.destroy();
41
90
  ```
42
91
 
43
- Web Component:
92
+ ### Web Component
44
93
 
45
94
  ```html
46
95
  <script type="module">import '@firetable/project-xiaochun/element';</script>
@@ -50,99 +99,19 @@ Web Component:
50
99
  </script>
51
100
  ```
52
101
 
53
- ## The iframe must be allowed to use the mic & audio / iframe 的 allow 属性
54
-
55
- The SDK sets `allow="microphone; autoplay"` for you. If you hand-write the iframe you **must** add it yourself:
56
-
57
- ```html
58
- <iframe src="https://xiaochun.firetable.tech/embed?host=https%3A%2F%2Fyour-site.com"
59
- allow="microphone; autoplay" loading="lazy" width="320" height="480"></iframe>
60
- ```
61
-
62
- - `microphone` — on-device speech-to-text (`xc.mic`). Requires HTTPS.
63
- - `autoplay` — TTS audio. The host page still needs a prior user gesture (bind the first `say()` to a click).
64
- - `host=` — your page's origin. Without it the handshake **fails closed**.
65
-
66
- ## CSP / COOP / COEP notes / 安全头注意事项
67
-
68
- - **Your CSP**: allow `frame-src https://xiaochun.firetable.tech` (and `script-src https://cdn.jsdelivr.net` if you use the CDN loader).
69
- - **`/embed` response headers** (set by the XiaoChun deployment): no `X-Frame-Options`; `Content-Security-Policy: frame-ancestors *` by default (self-hosters can restrict it); `Permissions-Policy: microphone=(self)`; `Cross-Origin-Resource-Policy: cross-origin`.
70
- - **Your COOP/COEP**: an embedding page with `COEP: require-corp` works (the embed sends CORP). Cross-origin-isolating the iframe itself needs both the host page isolated *and* `allow="cross-origin-isolated"`; otherwise on-device ONNX runs single-threaded (slower but functional).
71
- - **Third-party storage partitioning**: models cached inside the iframe are keyed per top-level site, so each host site downloads its own copy. The embed therefore does **not** preload the on-device LLM / EMAGE models by default (`heavy: 'lazy'`).
72
- - **Security**: messages are exchanged over a `MessageChannel` after an origin-checked handshake; `'*'` is never used as a target origin; wildcard `origin`/`allowedOrigins` are rejected.
73
-
74
- ## Lighthouse-friendly usage / 对 Lighthouse 友好的用法
75
-
76
- ```ts
77
- createXiaochun({
78
- container: '#avatar',
79
- width: 320, height: 480, // fixed box → no layout shift / 固定尺寸,无 CLS
80
- placeholder: '/xc.webp', // an <img> is all that ships at first paint / 首屏只有一张图
81
- lazy: true, // iframe is created when visible AND idle / 进入视口且空闲才创建 (用 'click' 最省)
82
- lazyMargin: 200, // px, 越大越早加载 / larger = earlier load
83
- heavy: 'lazy', // no WebLLM/EMAGE until first interaction / 首次互动才加载重模型
84
- autoPause: true, // pause rendering when scrolled out of view / 滚出视口暂停渲染
85
- });
86
- ```
87
-
88
- ## API
89
-
90
- ### `createXiaochun(options): XiaochunInstance`
91
-
92
- | Option | Default | Description |
93
- | --- | --- | --- |
94
- | `container` | — | Element or selector. 必填 |
95
- | `src` | `https://xiaochun.firetable.tech/embed` | Embed page URL (override for self-hosting / local dev) |
96
- | `origin` | derived from `src` | Expected iframe origin; every message is checked against it |
97
- | `allowedOrigins` | `[]` | Extra trusted iframe origins. `'*'` rejected |
98
- | `lazy` | `true` | `true`/`'idle'`: visible + idle · `'click'`: on click or first API call · `false`: immediately |
99
- | `lazyMargin` | `200` | rootMargin in px for the visibility trigger. Larger = loads earlier, uses more data |
100
- | `placeholder` | built-in SVG | Image URL / element / `false` |
101
- | `transparent` | `false` | Transparent background over your page (+ pointer pass-through) |
102
- | `width`, `height` | `320`, `480` | px or CSS length. Always set them |
103
- | `position` | `'inline'` | `'inline' \| 'bottom-right' \| 'bottom-left'` |
104
- | `draggable` | `false` | Drag handle for floating mode |
105
- | `lang`, `model` | — | `'zh-CN' \| 'en' \| 'ja'`; outfit key (e.g. `xiaochun_maid`) |
106
- | `ui` | `false` | Show the embed's built-in chat bar |
107
- | `heavy` | `'lazy'` | `'lazy'`: load WebLLM/EMAGE on first use · `'eager'`: preload |
108
- | `controls` | `false` | Allow wheel-zoom inside the iframe |
109
- | `autoPause` | `true` | Pause when out of viewport |
110
- | `handshakeTimeout` | `20000` | ms; emits `error{code:'timeout'}` |
111
-
112
- Instance: `ready` · `say(text, {mode:'speak'|'chat'})` · `speakAudio(source, opts)` · `speakAudioStream(opts)` · `motion(nameOrUrlOrOptions)` · `expression(name)` · `setModel(outfitOrUrl)` · `setConfig(cfg)` · `startListening()` / `stopListening()` / `mic(on)` · `pause()` / `resume()` · `activate()` · `destroy()` · `on(event, cb)` · `lookAt()` *(protocol reserved — currently returns `unsupported`)*.
113
-
114
- Events: `handshake` · `ready` · `progress` · `state` · `stt` · `utterance` · `hit-region` · `error` · `destroy`.
115
-
116
- ### `<xiaochun-avatar>` attributes / 属性
117
-
118
- `src` `model` `lang` `mic` `transparent` `draggable` `position` `size` `lazy` `paused` `placeholder` `heavy` `ui` `controls` `allowed-origins`
119
- Events: `xc-ready` `xc-progress` `xc-state` `xc-stt` `xc-utterance` `xc-error` · Methods: `say` `speakAudio` `speakAudioStream` `motion` `expression` `destroy`.
120
-
121
- ### Loader `data-auto`
102
+ ### One-line script (auto-mount a floating avatar)
122
103
 
123
104
  ```html
124
105
  <script src=".../dist/loader.global.js" data-auto data-position="bottom-right" data-size="280" defer></script>
125
106
  ```
126
107
 
127
- ## Host-supplied audio / 宿主传音频 (`speakAudio`)
128
-
129
- Skip the built-in TTS and let XiaoChun speak **your** audio: it is decoded inside the iframe, drives EMAGE motion (16 kHz mono windows), lip-sync and A/V sync, then emits `utterance end`. EMAGE / WebLLM stay lazy — only the first `speakAudio` with motion loads EMAGE (`motion: false` never does).
130
-
131
- ```ts
132
- await xc.speakAudio(arrayBuffer, { text: 'Hi', motion: true, lipsync: true }); // ArrayBuffer is transferred (detached); pass { transfer: false } to keep it
133
- await xc.speakAudio(blob); // Blob (mp3/wav/ogg/...)
134
- await xc.speakAudio('https://cdn.example.com/voice.mp3'); // URL: fetched by the host page by default ({ fetch: 'frame' } = fetched inside the iframe)
135
- await xc.speakAudio(pcm, { format: 'pcm16', sampleRate: 24000 }); // headerless PCM needs sampleRate (8000-96000)
108
+ `data-*` attributes map to the `<xiaochun-avatar>` attributes of the same name.
136
109
 
137
- const s = xc.speakAudioStream({ sampleRate: 24000 }); // streaming (TTS server -> PCM chunks)
138
- s.write(int16Chunk); s.write(next); s.end(); await s.done; // s.abort() stops immediately
139
- // any time: pass { signal: abortController.signal }
140
- ```
141
- `speakAudio` rejects with `bad_request` (undecodable / empty / bad URL or sampleRate), `unsupported`, or `failed`. Audio autoplay still needs a user gesture in the host page and `allow="autoplay"` on the iframe (the SDK adds it). The same payloads map to `xiaochun://speak?audioUrl=…` on the desktop — see `toProtocolUrl()` / `XC_PROTOCOL_MAPPING` and [`docs/EMBED.md` §2.4–2.5](https://github.com/FireTable/project-xiaochun/blob/main/docs/EMBED.md).
110
+ ---
142
111
 
143
- ## React
112
+ ## ⚛️ React
144
113
 
145
- `react` is an **optional peer dependency** (`>=18`; React 18 and 19 are supported). The subpath is a separate bundle, so the main entry does not grow. The file is marked `'use client'`; on the server only a fixed-size empty `<div>` is rendered and the iframe is created in a client effect. Under `<StrictMode>` the effect cleanup always calls `destroy()` (no leaked iframe or listeners).
114
+ `react` is an **optional peer dependency** (`>=18`; React 18 and 19 are supported). The subpath is a separate bundle, so the main entry does not grow. The file carries `'use client'` (Next.js App Router friendly). On the server only a fixed-size empty `<div>` is rendered and the iframe is created in a client effect; under `<StrictMode>` the effect cleanup always calls `destroy()`, so nothing leaks.
146
115
 
147
116
  ```tsx
148
117
  import { useRef } from 'react';
@@ -163,39 +132,206 @@ export function Mascot({ audio }: { audio?: ArrayBuffer }) {
163
132
  );
164
133
  }
165
134
 
166
- // Own layout: the hook
135
+ // Want your own layout? Use the hook:
167
136
  const { containerRef, client, ready, state } = useXiaochun({ width: 280, height: 420 });
168
137
  // <div ref={containerRef} style={{ width: 280, height: 420 }} />
169
138
  ```
170
- Next.js App Router: import it from a client component (the module already carries `'use client'`). Props are all `createXiaochun` options plus `onReady/onState/onStt/onUtterance/onError/…`, `paused`, `mic`. Creation-time options (size, `src`, `lazy`, `transparent`, …) rebuild the instance when changed; `lang` / `model` / `paused` / `mic` and callbacks update in place. Ref methods: `say speakAudio speakAudioStream motion expression lookAt setModel setConfig startListening stopListening mic pause resume activate destroy`.
171
139
 
172
- ## Styling / 样式 (CSS variables & `::part`)
140
+ * **Props**: every `createXiaochun` option except `container`, plus `onHandshake / onReady / onProgress / onState / onStt / onUtterance / onHitRegion / onError / onDestroy`, `className`, `style`, `paused`, and `mic`.
141
+ * **Rebuild vs. live update**: creation-time options (`src`, `lazy`, `transparent`, `width`, `height`, `position`, …) rebuild the instance when they change, so avoid passing fresh values on every render. `lang`, `model`, `paused`, `mic` and the callbacks update in place without recreating the iframe.
142
+ * **Ref methods**: `say`, `speakAudio`, `speakAudioStream`, `motion`, `expression`, `lookAt`, `setModel`, `setConfig`, `startListening`, `stopListening`, `mic`, `pause`, `resume`, `activate`, `destroy`, plus `ready` and `instance`. Methods that return a Promise reject until the component is mounted.
143
+
144
+ ---
145
+
146
+ ## 🔊 Host-Supplied Audio (`speakAudio`)
147
+
148
+ Skip the built-in TTS and let XiaoChun speak **your** audio. The iframe decodes it, EMAGE generates matching body motion (16 kHz mono windows), audio/video stay in sync with lip-sync, and `utterance end` fires when playback finishes. Heavy models stay lazy: the first `speakAudio` with motion loads EMAGE, and `motion: false` never does.
149
+
150
+ ```ts
151
+ await xc.speakAudio(arrayBuffer, { text: 'Hi', motion: true, lipsync: true }); // ArrayBuffer is transferred (detached); pass { transfer: false } to keep it
152
+ await xc.speakAudio(blob); // Blob (mp3 / wav / ogg …)
153
+ await xc.speakAudio('https://cdn.example.com/voice.mp3'); // URL: fetched by the host page by default ({ fetch: 'frame' } = fetched inside the iframe)
154
+ await xc.speakAudio(pcm, { format: 'pcm16', sampleRate: 24000 }); // headerless PCM needs sampleRate (8000–96000)
155
+
156
+ const s = xc.speakAudioStream({ sampleRate: 24000 }); // streaming: e.g. PCM chunks from a TTS server
157
+ s.write(int16Chunk); s.write(next); s.end(); await s.done; // s.abort() stops immediately
158
+ // Cancel any time: pass { signal: abortController.signal }
159
+ ```
160
+
161
+ * Rejects with `bad_request` (undecodable / empty audio, bad URL or sampleRate), `unsupported`, or `failed`.
162
+ * Browsers still require a user gesture on the host page before audio can play, and the iframe needs `allow="autoplay"` (the SDK sets it).
163
+ * A newer `say()` / `speakAudio()` call preempts the one in progress, and the preempted promise resolves.
164
+ * `<xiaochun-avatar>` and the React ref expose the same `speakAudio` / `speakAudioStream` methods.
165
+
166
+ ---
167
+
168
+ ## 🎨 Styling (CSS Variables & `::part`)
173
169
 
174
- The host can restyle the **shell** only (the avatar itself lives in a cross-origin iframe and cannot be styled from your CSS).
170
+ The host can restyle the **shell** only. The character lives in a cross-origin iframe, so your CSS cannot reach inside it.
175
171
 
176
172
  | Variable | Default | Effect |
177
- | --- | --- | --- |
178
- | `--xc-radius` | `0` | Corner radius (any CSS length, e.g. `24px`, `50%` for a round frame). Larger = rounder; too large clips the head/feet |
173
+ | :--- | :--- | :--- |
174
+ | `--xc-radius` | `0` | Corner radius (any CSS length, e.g. `24px`; `50%` makes a round frame). Larger is rounder; too large clips the head and feet |
179
175
  | `--xc-shadow` | `none` | `box-shadow` shorthand. Keep `none` for transparent floating avatars |
180
- | `--xc-z-index` | `2147483000` | Floating mode only. Lower it so your modals/nav sit above the avatar |
176
+ | `--xc-z-index` | `2147483000` | Floating mode only. Lower it so your modals and nav sit above the avatar |
181
177
  | `--xc-offset-x` / `--xc-offset-y` | `16px` | Floating mode only: distance from the side / bottom edge |
182
- | `--xc-bg` | `transparent` | Background behind the iframe while loading (ignored when `transparent`) |
178
+ | `--xc-bg` | `transparent` | Background behind the iframe while it loads (ignored when `transparent`) |
183
179
 
184
180
  ```css
185
- xiaochun-avatar { --xc-radius: 24px; --xc-shadow: 0 8px 24px rgba(0,0,0,.18); --xc-offset-y: 72px; }
181
+ xiaochun-avatar {
182
+ --xc-radius: 24px;
183
+ --xc-shadow: 0 8px 24px rgba(0, 0, 0, .18);
184
+ --xc-offset-y: 72px; /* lift above a bottom nav bar */
185
+ }
186
186
  xiaochun-avatar::part(iframe) { outline: 1px solid #0002; }
187
187
  ```
188
+
188
189
  Parts: `mount` · `wrapper` · `iframe` · `placeholder`.
189
190
 
190
- ## Try it locally / 本地试玩
191
+ ---
192
+
193
+ ## 🔌 `xiaochun://` and `xc.*`
194
+
195
+ `xiaochun://` is an **OS-level deep link** handled by the XiaoChun desktop app (Tauri). `xc.*` is the **postMessage protocol** between your page and the `/embed` iframe. They are two transports for the same actions and share one handler inside the app. The `/embed` page never responds to `xiaochun://`.
196
+
197
+ | SDK / `xc.*` | Desktop deep link |
198
+ | :--- | :--- |
199
+ | `say(text)` | `xiaochun://speak?text=…` |
200
+ | `speakAudio(url)` | `xiaochun://speak?audioUrl=…[&text=…]` |
201
+ | `speakAudio(ArrayBuffer \| Blob)`, `speakAudioStream` | — (binary data cannot fit in a URL) |
202
+ | `say(text, { mode: 'chat' })`, `motion`, `expression`, … | — |
203
+
204
+ `toProtocolUrl()` and `parseProtocolUrl()` are pure string helpers for desktop scripts or `<a href>` links; `XC_PROTOCOL_MAPPING` is the machine-readable version of the table above:
205
+
206
+ ```ts
207
+ import { toProtocolUrl } from '@firetable/project-xiaochun';
208
+
209
+ toProtocolUrl({ action: 'speak', text: 'Hello' });
210
+ // → 'xiaochun://speak?text=Hello'
211
+ toProtocolUrl({ action: 'speak', audioUrl: 'https://cdn.example.com/hi.mp3', text: 'Hello' });
212
+ // → 'xiaochun://speak?text=Hello&audioUrl=https%3A%2F%2Fcdn.example.com%2Fhi.mp3'
213
+ ```
214
+
215
+ Details: [`docs/PROTOCOL.md` §6](https://github.com/FireTable/project-xiaochun/blob/main/docs/PROTOCOL.md) and [`docs/EMBED.md` §2.4–2.5](https://github.com/FireTable/project-xiaochun/blob/main/docs/EMBED.md).
216
+
217
+ ---
218
+
219
+ ## 🔐 Permissions, CSP & Cross-Origin Isolation
220
+
221
+ ### The iframe must be allowed to use the mic and audio
222
+
223
+ The SDK sets `allow="microphone; autoplay"` for you. If you write the iframe by hand you **must** add it yourself:
224
+
225
+ ```html
226
+ <iframe src="https://xiaochun.firetable.tech/embed?host=https%3A%2F%2Fyour-site.com"
227
+ allow="microphone; autoplay" loading="lazy" width="320" height="480"></iframe>
228
+ ```
229
+
230
+ * `microphone`: on-device speech-to-text (`xc.mic`). Requires HTTPS.
231
+ * `autoplay`: speech audio. The host page still needs a prior user gesture (bind the first `say()` to a click).
232
+ * `host=`: your page's origin. Without it the handshake **fails closed**.
233
+
234
+ ### CSP / COOP / COEP
235
+
236
+ * **Your CSP**: allow `frame-src https://xiaochun.firetable.tech` (and `script-src https://cdn.jsdelivr.net` if you use the CDN loader).
237
+ * **`/embed` response headers** (set by the XiaoChun deployment): no `X-Frame-Options`; `Content-Security-Policy: frame-ancestors *` by default (self-hosters can restrict it); `Permissions-Policy: microphone=(self)`; `Cross-Origin-Resource-Policy: cross-origin`.
238
+ * **Your COOP/COEP**: an embedding page with `COEP: require-corp` works (the embed sends CORP). Cross-origin-isolating the iframe itself needs both the host page isolated *and* `allow="cross-origin-isolated"`; otherwise on-device ONNX runs single-threaded (slower but functional).
239
+ * **Third-party storage partitioning**: models cached inside the iframe are keyed per top-level site, so each host site downloads its own copy. The embed therefore does **not** preload the on-device LLM / EMAGE models by default (`heavy: 'lazy'`).
240
+ * **Security**: messages travel over a `MessageChannel` after an origin-checked handshake; `'*'` is never used as a target origin; wildcard `origin` / `allowedOrigins` are rejected.
241
+
242
+ ---
243
+
244
+ ## ⚡ Lighthouse-Friendly Usage
245
+
246
+ ```ts
247
+ createXiaochun({
248
+ container: '#avatar',
249
+ width: 320, height: 480, // fixed box → no layout shift
250
+ placeholder: '/xc.webp', // an <img> is all that ships at first paint
251
+ lazy: true, // iframe is created when visible AND idle ('click' is the cheapest)
252
+ lazyMargin: 200, // px; larger = loads earlier but uses more data
253
+ heavy: 'lazy', // no WebLLM / EMAGE until the first interaction
254
+ autoPause: true, // pause rendering when scrolled out of view
255
+ });
256
+ ```
257
+
258
+ ---
259
+
260
+ ## 📚 API
261
+
262
+ ### `createXiaochun(options): XiaochunInstance`
263
+
264
+ | Option | Default | Description |
265
+ | :--- | :--- | :--- |
266
+ | `container` | — | Element or selector (required) |
267
+ | `src` | `https://xiaochun.firetable.tech/embed` | Embed page URL (override for self-hosting or local dev) |
268
+ | `origin` | derived from `src` | Expected iframe origin; every message is checked against it |
269
+ | `allowedOrigins` | `[]` | Extra trusted iframe origins. `'*'` is rejected |
270
+ | `lazy` | `true` | `true` / `'idle'`: visible and idle · `'click'`: on click or first API call · `false`: immediately |
271
+ | `lazyMargin` | `200` | rootMargin in px for the visibility trigger. Larger loads earlier and uses more data |
272
+ | `placeholder` | built-in SVG | Image URL, element, or `false` |
273
+ | `transparent` | `false` | Transparent background over your page (with pointer pass-through) |
274
+ | `width`, `height` | `320`, `480` | px or any CSS length. Always set them |
275
+ | `position` | `'inline'` | `'inline' \| 'bottom-right' \| 'bottom-left'` |
276
+ | `draggable` | `false` | Drag handle in floating mode |
277
+ | `lang`, `model` | — | `'zh-CN' \| 'en' \| 'ja'`; outfit key (e.g. `xiaochun_maid`) or an https `.vrm` URL |
278
+ | `ui` | `false` | Show the embed's built-in chat bar |
279
+ | `heavy` | `'lazy'` | `'lazy'`: load WebLLM / EMAGE on first use · `'eager'`: preload |
280
+ | `controls` | `false` | Allow wheel-zoom inside the iframe (swallows page scrolling) |
281
+ | `autoPause` | `true` | Pause when out of the viewport |
282
+ | `passthrough` | = `transparent` | Toggle the iframe's pointer-events depending on whether the cursor is over the character |
283
+ | `sandbox` | scripts + same-origin + popups | iframe `sandbox`; `false` = none. Dropping `allow-same-origin` breaks IndexedDB and the mic |
284
+ | `handshakeTimeout` | `20000` | ms; on timeout an `error { code: 'timeout' }` is emitted |
285
+ | `zIndex` | `2147483000` | Floating mode layer (the `--xc-z-index` variable takes precedence) |
286
+
287
+ **Instance**: `ready` · `say(text, { mode: 'speak' \| 'chat' })` · `speakAudio(source, opts)` · `speakAudioStream(opts)` · `motion(nameOrUrlOrOptions)` · `expression(name)` · `setModel(outfitOrUrl)` · `setConfig(cfg)` · `startListening()` / `stopListening()` / `mic(on)` · `pause()` / `resume()` · `activate()` · `destroy()` · `on(event, cb)` · `lookAt()` *(reserved in the protocol; currently returns `unsupported`)*.
288
+
289
+ **Events**: `handshake` · `ready` · `progress` · `state` · `stt` · `utterance` (`phase: 'start' | 'end'`, `kind: 'text' | 'audio'`) · `hit-region` · `error` · `destroy`.
290
+
291
+ ### `<xiaochun-avatar>`
292
+
293
+ | Attribute | Default | Description |
294
+ | :--- | :--- | :--- |
295
+ | `src` | official `/embed` | Changing it rebuilds the iframe |
296
+ | `model` | — | Outfit key or https `.vrm` / `.vrmaddon` / `.vrmbase` URL; changing it at runtime = `setModel` |
297
+ | `lang` | — | `zh-CN` · `en` · `ja`; runtime change = `setConfig` |
298
+ | `mic` | `false` | Toggle dictation (takes effect once the model is loaded) |
299
+ | `transparent` | `true` | `"false"` turns it off |
300
+ | `draggable` | `false` | Floating mode only |
301
+ | `position` | `inline` | `inline` · `bottom-right` · `bottom-left` |
302
+ | `size` | `320x480` | `"280"` (height = width × 1.5), `"320x480"`, `"100%x480px"` |
303
+ | `lazy` | idle + viewport | `"click"` for click only; `"false"` for immediate |
304
+ | `paused` | `false` | `pause()` / `resume()` |
305
+ | `placeholder` / `heavy` / `ui` / `controls` / `allowed-origins` | — | Same as `createXiaochun` |
306
+
307
+ **Events** (`CustomEvent`, `composed`, `detail` = protocol payload): `xc-ready` · `xc-progress` · `xc-state` · `xc-stt` · `xc-utterance` · `xc-error`.
308
+ **Methods**: `say` · `speakAudio` · `speakAudioStream` · `motion` · `expression` · `destroy`; `el.client` gives you the full SDK instance.
309
+
310
+ ---
311
+
312
+ ## 🧪 Try It Locally
313
+
314
+ Open [`examples/embed-host.html`](./examples/embed-host.html); the header comment lists the steps. To point it at a local XiaoChun dev server, pass `src: 'https://localhost:5185/embed'`.
315
+
316
+ ---
317
+
318
+ ## 🏷️ Versioning & Release
319
+
320
+ The package version is **locked to the XiaoChun desktop app**: one `pnpm bump:patch|minor|major` and one `v*` git tag trigger both the desktop release and the npm publish, in parallel and independently.
321
+
322
+ npm releases are published from GitHub Actions with **npm Trusted Publishing (OIDC)**: no long-lived `NPM_TOKEN`, and provenance is generated automatically. Maintainer setup (first manual publish, binding the Trusted Publisher, staged publishing) is covered in [`docs/EMBED.md` §6](https://github.com/FireTable/project-xiaochun/blob/main/docs/EMBED.md).
323
+
324
+ ---
191
325
 
192
- See [`examples/embed-host.html`](./examples/embed-host.html).
326
+ ## 📚 More Documentation
193
327
 
194
- ## Versioning & release / 版本与发布
328
+ * [`docs/EMBED.md`](https://github.com/FireTable/project-xiaochun/blob/main/docs/EMBED.md): full embed design, protocol tables, response headers, risks, publishing
329
+ * [`docs/PROTOCOL.md`](https://github.com/FireTable/project-xiaochun/blob/main/docs/PROTOCOL.md): the `xiaochun://` URL scheme and its mapping to `xc.*`
330
+ * [`docs/README.md`](https://github.com/FireTable/project-xiaochun/blob/main/docs/README.md): documentation index
331
+ * [Project README](https://github.com/FireTable/project-xiaochun#readme): the main project
195
332
 
196
- The package version is locked to the XiaoChun desktop app: one `pnpm bump:patch|minor|major` + one `v*` git tag publishes both (the `Publish npm` workflow runs in parallel with the desktop release). Publishing uses **npm Trusted Publishing (OIDC)** — there is no `NPM_TOKEN`; provenance is generated automatically for public repos/packages. Maintainer setup (first manual publish, then bind the Trusted Publisher `FireTable/project-xiaochun` + `publish-npm.yml` in the package's npm settings) is in [`docs/EMBED.md` §6](https://github.com/FireTable/project-xiaochun/blob/main/docs/EMBED.md).
197
- 版本号与桌面 app 锁步:一次 `pnpm bump:*` + 一个 `v*` tag,同时触发桌面发布与 npm 发布(互相独立、并行)。npm 发布使用 Trusted Publishing(OIDC),无需 `NPM_TOKEN`;维护者首次需手动发一次并在 npm 包设置里绑定 Trusted Publisher(见 EMBED.md §6)。
333
+ ---
198
334
 
199
- ## License
335
+ ## 📄 License
200
336
 
201
- MIT
337
+ [MIT](./LICENSE)
@@ -1,7 +1,7 @@
1
- /*! @firetable/project-xiaochun v0.1.12 | MIT | https://github.com/FireTable/project-xiaochun */
1
+ /*! @firetable/project-xiaochun v0.1.13 | MIT | https://github.com/FireTable/project-xiaochun */
2
2
  import {
3
3
  createXiaochun
4
- } from "./chunk-UAIDX54S.js";
4
+ } from "./chunk-XE2YKXRA.js";
5
5
 
6
6
  // src/avatar-element.ts
7
7
  var OBSERVED = [
@@ -162,4 +162,4 @@ export {
162
162
  XIAOCHUN_ELEMENT_TAG,
163
163
  defineXiaochunElement
164
164
  };
165
- //# sourceMappingURL=chunk-XKLOFX52.js.map
165
+ //# sourceMappingURL=chunk-NKR4JG3C.js.map
@@ -1,4 +1,4 @@
1
- /*! @firetable/project-xiaochun v0.1.12 | MIT | https://github.com/FireTable/project-xiaochun */
1
+ /*! @firetable/project-xiaochun v0.1.13 | MIT | https://github.com/FireTable/project-xiaochun */
2
2
 
3
3
  // src/protocol.ts
4
4
  var XC_PROTOCOL_VERSION = 1;
@@ -88,4 +88,4 @@ export {
88
88
  isXcEnvelope,
89
89
  normalizeOrigin
90
90
  };
91
- //# sourceMappingURL=chunk-4VETYSWF.js.map
91
+ //# sourceMappingURL=chunk-UB2HFUBS.js.map
@@ -1,10 +1,10 @@
1
- /*! @firetable/project-xiaochun v0.1.12 | MIT | https://github.com/FireTable/project-xiaochun */
1
+ /*! @firetable/project-xiaochun v0.1.13 | MIT | https://github.com/FireTable/project-xiaochun */
2
2
  import {
3
3
  XC_DEFAULT_SRC,
4
4
  isXcEnvelope,
5
5
  normalizeOrigin,
6
6
  xcMessage
7
- } from "./chunk-4VETYSWF.js";
7
+ } from "./chunk-UB2HFUBS.js";
8
8
 
9
9
  // src/client.ts
10
10
  var FALLBACK_PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
@@ -541,4 +541,4 @@ function createXiaochun(options) {
541
541
  export {
542
542
  createXiaochun
543
543
  };
544
- //# sourceMappingURL=chunk-UAIDX54S.js.map
544
+ //# sourceMappingURL=chunk-XE2YKXRA.js.map
package/dist/element.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! @firetable/project-xiaochun v0.1.12 | MIT | https://github.com/FireTable/project-xiaochun */
1
+ /*! @firetable/project-xiaochun v0.1.13 | MIT | https://github.com/FireTable/project-xiaochun */
2
2
  "use strict";
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
package/dist/element.js CHANGED
@@ -1,10 +1,10 @@
1
- /*! @firetable/project-xiaochun v0.1.12 | MIT | https://github.com/FireTable/project-xiaochun */
1
+ /*! @firetable/project-xiaochun v0.1.13 | MIT | https://github.com/FireTable/project-xiaochun */
2
2
  import {
3
3
  XIAOCHUN_ELEMENT_TAG,
4
4
  defineXiaochunElement
5
- } from "./chunks/chunk-XKLOFX52.js";
6
- import "./chunks/chunk-UAIDX54S.js";
7
- import "./chunks/chunk-4VETYSWF.js";
5
+ } from "./chunks/chunk-NKR4JG3C.js";
6
+ import "./chunks/chunk-XE2YKXRA.js";
7
+ import "./chunks/chunk-UB2HFUBS.js";
8
8
 
9
9
  // src/element.ts
10
10
  defineXiaochunElement();
package/dist/index.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! @firetable/project-xiaochun v0.1.12 | MIT | https://github.com/FireTable/project-xiaochun */
1
+ /*! @firetable/project-xiaochun v0.1.13 | MIT | https://github.com/FireTable/project-xiaochun */
2
2
  "use strict";
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
- /*! @firetable/project-xiaochun v0.1.12 | MIT | https://github.com/FireTable/project-xiaochun */
1
+ /*! @firetable/project-xiaochun v0.1.13 | MIT | https://github.com/FireTable/project-xiaochun */
2
2
  import {
3
3
  XIAOCHUN_ELEMENT_TAG,
4
4
  defineXiaochunElement
5
- } from "./chunks/chunk-XKLOFX52.js";
5
+ } from "./chunks/chunk-NKR4JG3C.js";
6
6
  import {
7
7
  createXiaochun
8
- } from "./chunks/chunk-UAIDX54S.js";
8
+ } from "./chunks/chunk-XE2YKXRA.js";
9
9
  import {
10
10
  XC_DEFAULT_ORIGIN,
11
11
  XC_DEFAULT_SRC,
@@ -18,7 +18,7 @@ import {
18
18
  isXcEnvelope,
19
19
  normalizeOrigin,
20
20
  xcMessage
21
- } from "./chunks/chunk-4VETYSWF.js";
21
+ } from "./chunks/chunk-UB2HFUBS.js";
22
22
 
23
23
  // src/protocol-url.ts
24
24
  var XIAOCHUN_PROTOCOL_SCHEME = "xiaochun";
@@ -1,3 +1,3 @@
1
- /*! @firetable/project-xiaochun v0.1.12 | MIT | https://github.com/FireTable/project-xiaochun */
1
+ /*! @firetable/project-xiaochun v0.1.13 | MIT | https://github.com/FireTable/project-xiaochun */
2
2
  "use strict";var Xiaochun=(()=>{var re=Object.defineProperty;var Oe=Object.getOwnPropertyDescriptor;var Le=Object.getOwnPropertyNames;var Se=Object.prototype.hasOwnProperty;var Te=(t,n)=>{for(var s in n)re(t,s,{get:n[s],enumerable:!0})},Re=(t,n,s,a)=>{if(n&&typeof n=="object"||typeof n=="function")for(let i of Le(n))!Se.call(t,i)&&i!==s&&re(t,i,{get:()=>n[i],enumerable:!(a=Oe(n,i))||a.enumerable});return t};var He=t=>Re(re({},"__esModule",{value:!0}),t);var De={};Te(De,{XC_DEFAULT_ORIGIN:()=>ge,XC_DEFAULT_SRC:()=>ae,XC_FRAME_TO_HOST:()=>Ue,XC_HOST_TO_FRAME:()=>_e,XC_IMPLEMENTED_COMMANDS:()=>ze,XC_PROTOCOL_MAPPING:()=>Be,XC_PROTOCOL_VERSION:()=>Ie,XC_UNSUPPORTED_COMMANDS:()=>Fe,XIAOCHUN_ELEMENT_TAG:()=>ie,XIAOCHUN_PROTOCOL_SCHEME:()=>Y,createXiaochun:()=>G,defineXiaochunElement:()=>V,isXcEnvelope:()=>D,normalizeOrigin:()=>W,parseProtocolUrl:()=>ve,toProtocolUrl:()=>be,xcMessage:()=>C});var Ie=1,ge="https://xiaochun.firetable.tech",ae=`${ge}/embed`,_e=["xc.init","xc.say","xc.audio","xc.audio.chunk","xc.audio.end","xc.motion","xc.expression","xc.lookAt","xc.pointer","xc.setModel","xc.setConfig","xc.mic","xc.pause","xc.resume","xc.destroy"],Ue=["xc.ready","xc.load.progress","xc.loaded","xc.state","xc.stt","xc.utterance","xc.hit-region","xc.error"],ze=["xc.say","xc.audio","xc.audio.chunk","xc.audio.end","xc.motion","xc.expression","xc.pointer","xc.setModel","xc.setConfig","xc.mic","xc.pause","xc.resume","xc.destroy"],Fe=["xc.lookAt"],Be=[{xc:"xc.say",note:'mode:"speak" (\u9ED8\u8BA4)',action:"speak",url:"xiaochun://speak?text=\u2026"},{xc:"xc.say",note:'mode:"chat" (\u8D70 LLM)',action:null,url:null},{xc:"xc.audio",note:"source \u4E3A URL \u5B57\u7B26\u4E32",action:"speak",url:"xiaochun://speak?audioUrl=\u2026[&text=\u2026]"},{xc:"xc.audio",note:"source \u4E3A ArrayBuffer / Blob",action:"audio",url:null},{xc:"xc.audio.chunk / xc.audio.end",note:"\u6D41\u5F0F PCM",action:"audio",url:null}];function C(t,n,s){let a={type:t,v:1,payload:n};return s!==void 0&&(a.id=s),a}function D(t){if(!t||typeof t!="object")return!1;let n=t;return typeof n.type=="string"&&n.type.startsWith("xc.")&&n.v===1}function W(t){if(!t||t==="*"||t==="null")return null;try{let n=new URL(t);return n.protocol!=="https:"&&n.protocol!=="http:"?null:n.origin}catch{return null}}var je="data:image/svg+xml;utf8,"+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 180"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f6b8ae"/><stop offset="1" stop-color="#ea8377"/></linearGradient></defs><ellipse cx="60" cy="170" rx="34" ry="6" fill="#000" opacity=".12"/><circle cx="60" cy="48" r="26" fill="url(#g)" opacity=".75"/><path d="M26 160c2-40 18-62 34-62s32 22 34 62z" fill="url(#g)" opacity=".6"/></svg>'),Xe=(t,n)=>t===void 0?n:typeof t=="number"?`${t}px`:t;function G(t){if(typeof window>"u"||typeof document>"u")throw new Error("[project-xiaochun] createXiaochun must run in a browser");let n=typeof t.container=="string"?document.querySelector(t.container):t.container;if(!n)throw new Error(`[project-xiaochun] container not found: ${String(t.container)}`);let s=t.src??ae,a;try{a=new URL(s,window.location.href)}catch{throw new Error(`[project-xiaochun] invalid src: ${s}`)}let i=W(t.origin??a.origin);if(!i)throw new Error("[project-xiaochun] invalid origin (wildcards are not allowed)");let y=new Set([i]);for(let e of t.allowedOrigins??[]){let o=W(e);if(!o)throw new Error(`[project-xiaochun] invalid allowedOrigins entry: ${e} (wildcards are not allowed)`);y.add(o)}let h=t.transparent??!1,M=t.passthrough??h,U=t.lazy??!0,g=t.position??"inline",k=t.handshakeTimeout??2e4,p=document.createElement("div");p.setAttribute("data-xiaochun","");let l=p.style;l.position=g==="inline"?"relative":"fixed",l.width=Xe(t.width,"320px"),l.height=Xe(t.height,"480px"),l.maxWidth="100%",l.overflow="visible",l.contain="layout style",l.borderRadius="var(--xc-radius, 0)",l.boxShadow="var(--xc-shadow, none)",h||(l.background="var(--xc-bg, transparent)"),g==="bottom-right"&&(l.right="var(--xc-offset-x, 16px)",l.bottom="var(--xc-offset-y, 16px)"),g==="bottom-left"&&(l.left="var(--xc-offset-x, 16px)",l.bottom="var(--xc-offset-y, 16px)"),g!=="inline"&&(l.zIndex=`var(--xc-z-index, ${t.zIndex??2147483e3})`),l.pointerEvents="none",p.setAttribute("part","wrapper"),n.appendChild(p);let x=null;if(t.placeholder!==!1){if(t.placeholder instanceof HTMLElement)x=t.placeholder;else{let o=document.createElement("img");o.src=t.placeholder||je,o.alt="Project XiaoChun",o.decoding="async",o.width=320,o.height=480,x=o}let e=x.style;x.setAttribute("part","placeholder"),e.borderRadius="var(--xc-radius, 0)",e.position="absolute",e.inset="0",e.width="100%",e.height="100%",e.objectFit="contain",e.transition="opacity .25s ease",e.pointerEvents="auto",e.cursor=U===!1?"default":"pointer",x.addEventListener("click",()=>T()),p.appendChild(x)}let j=new Map,X=(e,o)=>{j.get(e)?.forEach(r=>{try{r(o)}catch(c){console.error("[project-xiaochun] listener error",c)}})},ce,le,de=new Promise((e,o)=>{ce=e,le=o});de.catch(()=>{});let u=null,m=null,P=!1,J=!1,ue=!1,Q=!1,$=!1,Z=[],L=new Map,ee=0,R=null,S=null,H=null,z=(e,o,r,c)=>{if(!m){Z.push({type:e,payload:o,id:r,transfer:c});return}m.postMessage(C(e,o,r),c??[])},b=(e,o,r=!1,c,I)=>{if(P)return Promise.reject(new Error("[project-xiaochun] instance destroyed"));u||T();let f=I??`c${++ee}`,d=r?new Promise((v,E)=>L.set(f,{resolve:v,reject:E,sawEnd:!1})):Promise.resolve();return z(e,o,f,c),d},pe=e=>e instanceof ArrayBuffer?e:e.buffer.slice(e.byteOffset,e.byteOffset+e.byteLength),fe=(e,o)=>{if(!e)return;let r=()=>{P||z("xc.audio.end",{abort:!0},o)};e.aborted?r():e.addEventListener("abort",r,{once:!0})},Pe=async(e,o={})=>{if(P)throw new Error("[project-xiaochun] instance destroyed");u||T();let{signal:r,transfer:c,fetch:I,...f}=o,d={...f},v=[];if(typeof e=="string")if(I==="frame")d.source=e;else{let A=await fetch(e);if(!A.ok)throw new Error(`[project-xiaochun] audio fetch failed: HTTP ${A.status}`);let oe=await A.arrayBuffer();d.source=oe,v.push(oe),d.mimeType??(d.mimeType=A.headers.get("content-type")??void 0)}else if(typeof Blob<"u"&&e instanceof Blob)d.source=e,d.mimeType??(d.mimeType=e.type||void 0);else{let A=pe(e);d.source=A,(!(e instanceof ArrayBuffer)||c!==!1)&&v.push(A)}if(P)throw new Error("[project-xiaochun] instance destroyed");let E=`c${++ee}`,_=b("xc.audio",d,!0,v,E);return fe(r,E),_},we=e=>{if(P)throw new Error("[project-xiaochun] instance destroyed");let{signal:o,transfer:r,fetch:c,mimeType:I,...f}=e,d=`c${++ee}`,v=!1,E=!1,_=f.format==="pcm16"||f.format==="float32"?f.format:null,A=Promise.resolve();return{write(K){if(E)throw new Error("[project-xiaochun] audio stream already ended");if(P)throw new Error("[project-xiaochun] instance destroyed");if(K instanceof Int16Array?_??(_="pcm16"):K instanceof Float32Array&&(_??(_="float32")),!_)throw new Error("[project-xiaochun] speakAudioStream: set opts.format when writing raw ArrayBuffers");let ne=pe(K),B={data:ne,format:_,sampleRate:f.sampleRate,channels:f.channels};v?z("xc.audio.chunk",B,d,!(K instanceof ArrayBuffer)||r!==!1?[ne]:[]):(v=!0,B.text=f.text,B.motion=f.motion,B.lipsync=f.lipsync,A=b("xc.audio.chunk",B,!0,[ne],d),fe(o,d))},end(){E||(E=!0,v&&z("xc.audio.end",{},d))},abort(){E&&!v||(E=!0,v&&z("xc.audio.end",{abort:!0},d))},get done(){return A}}};function Ee(){let e=new URL(a.href);return e.searchParams.set("host",window.location.origin),h&&e.searchParams.set("transparent","1"),t.ui&&e.searchParams.set("ui","1"),t.lang&&e.searchParams.set("lang",t.lang),t.heavy&&e.searchParams.set("heavy",t.heavy),t.controls&&e.searchParams.set("controls","1"),t.model&&e.searchParams.set("outfit",t.model),e.href}function T(){if(P||u)return;xe(),S?.disconnect(),S=null;let e=document.createElement("iframe");e.title="Project XiaoChun",e.loading="lazy",e.allow="microphone; autoplay",e.referrerPolicy="strict-origin-when-cross-origin",t.sandbox!==!1&&e.setAttribute("sandbox",t.sandbox??"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"),e.setAttribute("allowtransparency","true"),e.setAttribute("scrolling","no");let o=e.style;o.position="absolute",o.inset="0",o.width="100%",o.height="100%",o.border="0",o.background="transparent",o.colorScheme="normal",o.borderRadius="var(--xc-radius, 0)",e.setAttribute("part","iframe"),o.opacity="0",o.transition="opacity .25s ease",o.pointerEvents=M?"none":"auto",e.src=Ee(),u=e,p.appendChild(e),x&&(x.style.cursor="default"),R=setTimeout(()=>{m||X("error",{code:"timeout",message:`no xc.ready from ${i} within ${k}ms`})},k),ke(),Me()}let he=e=>{if(P||!u||e.source!==u.contentWindow)return;if(!y.has(e.origin)){X("error",{code:"origin_denied",message:`ignored message from untrusted origin ${e.origin}`});return}let o=e.data;if(!D(o)||o.type!=="xc.ready"||m)return;let r=new MessageChannel;m=r.port1,m.onmessage=Ae,u.contentWindow.postMessage(C("xc.init",{hostOrigin:window.location.origin}),e.origin,[r.port2]),R&&(clearTimeout(R),R=null),X("handshake",o.payload);for(let c of Z.splice(0))m.postMessage(C(c.type,c.payload,c.id),c.transfer??[]);q()};window.addEventListener("message",he);function Ae(e){let o=e.data;if(D(o))switch(o.type){case"xc.load.progress":X("progress",o.payload);break;case"xc.loaded":if(!Q){if(Q=!0,u&&(u.style.opacity="1"),x){let r=x;r.style.opacity="0",r.style.pointerEvents="none",setTimeout(()=>r.remove(),300),x=null}ce()}X("ready",o.payload);break;case"xc.state":X("state",o.payload);break;case"xc.stt":X("stt",o.payload);break;case"xc.utterance":{X("utterance",o.payload);let r=o.id?L.get(o.id):void 0;r&&o.payload.phase==="end"&&(L.delete(o.id),r.resolve());break}case"xc.hit-region":{let{hit:r}=o.payload;X("hit-region",o.payload),M&&u&&r!==$&&($=r,u.style.pointerEvents=r?"auto":"none");break}case"xc.error":{let{code:r,message:c,command:I}=o.payload;X("error",{code:r,message:c,command:I});let f=o.id?L.get(o.id):void 0;f&&o.id&&(L.delete(o.id),f.reject(new Error(`[${r}] ${c}`)));break}default:break}}let F=0,N=null,me=e=>{!u||!m||u.style.pointerEvents==="auto"||(N={x:e.clientX,y:e.clientY},!F&&(F=requestAnimationFrame(()=>{if(F=0,!u||!m||!N)return;let o=u.getBoundingClientRect(),r=N.x-o.left,c=N.y-o.top;if(r<0||c<0||r>o.width||c>o.height){$&&($=!1,u.style.pointerEvents="none");return}m.postMessage(C("xc.pointer",{x:r,y:c}))})))};function Me(){M&&window.addEventListener("pointermove",me,{passive:!0})}let te=null;function ke(){t.autoPause===!1||typeof IntersectionObserver>"u"||(te=new IntersectionObserver(e=>{ue=!(e[e.length-1]?.isIntersecting??!0),q()}),te.observe(p))}let ye=!1;function q(){if(!m)return;let e=J||ue;e!==ye&&(ye=e,m.postMessage(C(e?"xc.pause":"xc.resume")))}function xe(){if(H===null)return;let e=window;e.cancelIdleCallback?e.cancelIdleCallback(H):clearTimeout(H),H=null}function Ce(){if(U===!1){T();return}if(U==="click")return;let e=()=>{let o=window;H=o.requestIdleCallback?o.requestIdleCallback(()=>{H=null,T()},{timeout:3e3}):setTimeout(T,1200)};if(typeof IntersectionObserver>"u"){e();return}S=new IntersectionObserver(o=>{o.some(r=>r.isIntersecting)&&(S?.disconnect(),S=null,e())},{rootMargin:`${t.lazyMargin??200}px`}),S.observe(p)}let w=null;if(t.draggable&&g!=="inline"){w=document.createElement("div"),w.setAttribute("aria-label","drag"),Object.assign(w.style,{position:"absolute",left:"0",top:"0",width:"28px",height:"28px",cursor:"grab",pointerEvents:"auto",borderRadius:"8px",background:"rgba(0,0,0,.25)",color:"#fff",font:"14px/28px system-ui",textAlign:"center",userSelect:"none",touchAction:"none",zIndex:"2",opacity:"0.5"}),w.textContent="\u283F";let e=null;w.addEventListener("pointerdown",r=>{let c=p.getBoundingClientRect();e={dx:r.clientX-c.left,dy:r.clientY-c.top},w.setPointerCapture(r.pointerId)}),w.addEventListener("pointermove",r=>{e&&(l.left=`${Math.min(Math.max(0,r.clientX-e.dx),window.innerWidth-40)}px`,l.top=`${Math.min(Math.max(0,r.clientY-e.dy),window.innerHeight-40)}px`,l.right="auto",l.bottom="auto")});let o=()=>{e=null};w.addEventListener("pointerup",o),w.addEventListener("pointercancel",o),p.appendChild(w)}return Ce(),{element:p,get iframe(){return u},ready:de,activate:T,say:(e,o)=>b("xc.say",{text:e,mode:o?.mode},!0),speakAudio:Pe,speakAudioStream:we,motion:e=>b("xc.motion",typeof e=="string"?e.startsWith("http")||e.startsWith("/")?{url:e}:{name:e}:e),expression:e=>b("xc.expression",{name:e}),lookAt:(e,o)=>b("xc.lookAt",{x:e,y:o}),setModel:e=>b("xc.setModel",typeof e=="string"?/^(https?:)?\/|\.vrm/i.test(e)?{url:e}:{outfit:e}:e),setConfig:e=>b("xc.setConfig",e),startListening:()=>b("xc.mic",{enabled:!0}),stopListening:()=>b("xc.mic",{enabled:!1}),mic:e=>b("xc.mic",{enabled:e}),pause(){J=!0,q()},resume(){J=!1,q()},destroy(){if(!P){try{m?.postMessage(C("xc.destroy"))}catch{}P=!0,xe(),S?.disconnect(),te?.disconnect(),R&&clearTimeout(R),F&&cancelAnimationFrame(F),window.removeEventListener("message",he),window.removeEventListener("pointermove",me);try{m?.close()}catch{}m=null;for(let e of L.values())e.reject(new Error("[project-xiaochun] instance destroyed"));L.clear(),Z.length=0,Q||le(new Error("[project-xiaochun] destroyed before ready")),p.remove(),u=null,X("destroy",void 0),j.clear()}},on(e,o){let r=j.get(e);return r||(r=new Set,j.set(e,r)),r.add(o),()=>{r.delete(o)}}}}var $e=["src","model","lang","mic","transparent","draggable","position","size","lazy","paused","placeholder","heavy","ui","controls","allowed-origins"],Ne=new Set(["src","draggable","position","size","lazy","placeholder","heavy","ui","controls","allowed-origins","transparent"]);function O(t,n,s){let a=t.getAttribute(n);return a===null?s:a!=="false"}function qe(t){if(!t)return{width:"320px",height:"480px"};let n=t.trim().split(/x/i),s=i=>/^\d+(\.\d+)?$/.test(i)?`${i}px`:i;if(n.length>=2)return{width:s(n[0]),height:s(n[1])};let a=parseFloat(n[0]);return Number.isFinite(a)&&/^\d+(\.\d+)?$/.test(n[0])?{width:`${a}px`,height:`${Math.round(a*1.5)}px`}:{width:n[0],height:"480px"}}function Ke(){class t extends HTMLElement{constructor(){super(...arguments);this.client=null;this.mountEl=null;this.rebuildScheduled=!1}static get observedAttributes(){return[...$e]}connectedCallback(){if(!this.shadowRoot){let a=this.attachShadow({mode:"open"});a.innerHTML='<style>:host{display:inline-block;max-width:100%}:host([hidden]){display:none}</style><div part="mount"></div>'}this.mountEl=this.shadowRoot.querySelector("div"),this.build()}disconnectedCallback(){this.destroy()}attributeChangedCallback(a,i,y){if(!this.client||i===y)return;if(Ne.has(a)){this.rebuildScheduled||(this.rebuildScheduled=!0,queueMicrotask(()=>{this.rebuildScheduled=!1,this.isConnected&&(this.destroy(),this.build())}));return}let h=this.client;a==="model"&&y?h.setModel(y).catch(()=>{}):a==="lang"&&y?h.setConfig({lang:y}).catch(()=>{}):a==="mic"?h.mic(O(this,"mic",!1)).catch(()=>{}):a==="paused"&&(O(this,"paused",!1)?h.pause():h.resume())}build(){if(!this.mountEl||this.client)return;let{width:a,height:i}=qe(this.getAttribute("size")),y=this.getAttribute("placeholder"),h=this.getAttribute("allowed-origins"),M=this.getAttribute("lazy"),U=M==="click"?"click":M!=="false",g=G({container:this.mountEl,src:this.getAttribute("src")||void 0,allowedOrigins:h?h.split(",").map(p=>p.trim()).filter(Boolean):void 0,lazy:U,placeholder:y==="none"?!1:y||void 0,transparent:O(this,"transparent",!0),width:a,height:i,position:this.getAttribute("position")??"inline",draggable:O(this,"draggable",!1),lang:this.getAttribute("lang")??void 0,model:this.getAttribute("model")??void 0,heavy:this.getAttribute("heavy")??void 0,ui:O(this,"ui",!1),controls:O(this,"controls",!1)});this.client=g;let k=(p,l)=>g.on(p,x=>this.dispatchEvent(new CustomEvent(l,{detail:x,bubbles:!0,composed:!0})));k("ready","xc-ready"),k("progress","xc-progress"),k("state","xc-state"),k("stt","xc-stt"),k("utterance","xc-utterance"),k("error","xc-error"),O(this,"paused",!1)&&g.pause(),O(this,"mic",!1)&&g.ready.then(()=>g.mic(!0)).catch(()=>{})}say(a,i){return this.requireClient().say(a,i)}speakAudio(a,i){return this.requireClient().speakAudio(a,i)}speakAudioStream(a){return this.requireClient().speakAudioStream(a)}motion(a){return this.requireClient().motion(a)}expression(a){return this.requireClient().expression(a)}destroy(){this.client?.destroy(),this.client=null}requireClient(){if(!this.client)throw new Error("<xiaochun-avatar> is not connected");return this.client}}return t}var ie="xiaochun-avatar";function V(t=ie){if(typeof customElements>"u"||typeof HTMLElement>"u")return null;let n=customElements.get(t);if(n)return n;let s=Ke();return customElements.define(t,s),s}var Y="xiaochun";function be(t){if(t.action!=="speak")throw new Error(`[project-xiaochun] unsupported action: ${String(t.action)}`);if(!t.text&&!t.audioUrl&&!t.file)throw new Error("[project-xiaochun] speak needs text, audioUrl or file");if(t.audioUrl&&!/^https?:\/\//i.test(t.audioUrl))throw new Error("[project-xiaochun] audioUrl must be http(s)");let n=new URLSearchParams;return t.text&&n.set("text",t.text),t.audioUrl&&n.set("audioUrl",t.audioUrl),t.file&&n.set("file",t.file),`${Y}://speak?${n.toString().replace(/\+/g,"%20")}`}function ve(t){let n;try{n=new URL(t)}catch{return null}if(n.protocol!==`${Y}:`)return null;let s=n.hostname||n.pathname.replace(/^\/+/,"");if((s==="action"?n.searchParams.get("action"):s)!=="speak")return null;let i={action:"speak"},y=n.searchParams.get("text"),h=n.searchParams.get("audioUrl"),M=n.searchParams.get("file");return y&&(i.text=y),h&&(i.audioUrl=h),M&&(i.file=M),i.text||i.audioUrl||i.file?i:null}V();var se=document.currentScript;if(se&&se.hasAttribute("data-auto")){let t=()=>{let n=document.createElement("xiaochun-avatar");for(let s of Array.from(se.attributes))s.name.startsWith("data-")&&s.name!=="data-auto"&&n.setAttribute(s.name.slice(5),s.value);n.hasAttribute("position")||n.setAttribute("position","bottom-right"),document.body.appendChild(n)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",t,{once:!0}):t()}return He(De);})();
3
3
  //# sourceMappingURL=loader.global.js.map
package/dist/protocol.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! @firetable/project-xiaochun v0.1.12 | MIT | https://github.com/FireTable/project-xiaochun */
1
+ /*! @firetable/project-xiaochun v0.1.13 | MIT | https://github.com/FireTable/project-xiaochun */
2
2
  "use strict";
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
package/dist/protocol.js CHANGED
@@ -1,4 +1,4 @@
1
- /*! @firetable/project-xiaochun v0.1.12 | MIT | https://github.com/FireTable/project-xiaochun */
1
+ /*! @firetable/project-xiaochun v0.1.13 | MIT | https://github.com/FireTable/project-xiaochun */
2
2
  import {
3
3
  XC_DEFAULT_ORIGIN,
4
4
  XC_DEFAULT_SRC,
@@ -11,7 +11,7 @@ import {
11
11
  isXcEnvelope,
12
12
  normalizeOrigin,
13
13
  xcMessage
14
- } from "./chunks/chunk-4VETYSWF.js";
14
+ } from "./chunks/chunk-UB2HFUBS.js";
15
15
  export {
16
16
  XC_DEFAULT_ORIGIN,
17
17
  XC_DEFAULT_SRC,
package/dist/react.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! @firetable/project-xiaochun v0.1.12 | MIT | https://github.com/FireTable/project-xiaochun */
1
+ /*! @firetable/project-xiaochun v0.1.13 | MIT | https://github.com/FireTable/project-xiaochun */
2
2
  "use strict";
3
3
  "use client";
4
4
  var __defProp = Object.defineProperty;
package/dist/react.js CHANGED
@@ -1,9 +1,9 @@
1
- /*! @firetable/project-xiaochun v0.1.12 | MIT | https://github.com/FireTable/project-xiaochun */
1
+ /*! @firetable/project-xiaochun v0.1.13 | MIT | https://github.com/FireTable/project-xiaochun */
2
2
  "use client";
3
3
  import {
4
4
  createXiaochun
5
- } from "./chunks/chunk-UAIDX54S.js";
6
- import "./chunks/chunk-4VETYSWF.js";
5
+ } from "./chunks/chunk-XE2YKXRA.js";
6
+ import "./chunks/chunk-UB2HFUBS.js";
7
7
 
8
8
  // src/react.ts
9
9
  import { createElement, forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firetable/project-xiaochun",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Embed Project XiaoChun (3D anime companion) into any web page via a lazy, origin-checked iframe. Zero-dependency SDK + <xiaochun-avatar> web component.",
5
5
  "license": "MIT",
6
6
  "author": "FireTable",
@@ -42,6 +42,7 @@
42
42
  "files": [
43
43
  "dist",
44
44
  "README.md",
45
+ "README-CN.md",
45
46
  "LICENSE"
46
47
  ],
47
48
  "keywords": [