@firetable/project-xiaochun 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,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`)
108
+ `data-*` attributes map to the `<xiaochun-avatar>` attributes of the same name.
128
109
 
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).
110
+ ---
130
111
 
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)
136
-
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).
112
+ ## ⚛️ React
142
113
 
143
- ## React
144
-
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,244 @@ 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"` (the opt-in `crossOriginIsolated` option below); 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
+ ### Opt-in: cross-origin isolation (multi-threaded EMAGE)
244
+
245
+ By default `crossOriginIsolated` is `false` inside the iframe and onnxruntime-web runs **single-threaded** wasm. For multi-threading (`SharedArrayBuffer`) all three must hold:
246
+
247
+ 1. **Your page is cross-origin isolated** — it is served with `Cross-Origin-Opener-Policy: same-origin` **and** `Cross-Origin-Embedder-Policy: credentialless` (or `require-corp`). `window.crossOriginIsolated` is `true` on your page.
248
+ 2. **The embed document sets COEP too** — `/embed` already sends `COEP: credentialless` + `CORP: cross-origin`. Nothing to do. (COOP is ignored inside an iframe.)
249
+ 3. **You delegate it to the iframe** — cross-origin iframes do not inherit it. Turn the option on (default off; default `allow` stays `'microphone; autoplay'`):
250
+
251
+ ```js
252
+ createXiaochun({ container: '#stage', crossOriginIsolated: true }); // allow="microphone; autoplay; cross-origin-isolated"
253
+ ```
254
+ ```tsx
255
+ <Xiaochun crossOriginIsolated /> // React
256
+ ```
257
+ ```html
258
+ <xiaochun-avatar cross-origin-isolated></xiaochun-avatar>
259
+ <!-- hand-written iframe: allow="microphone; autoplay; cross-origin-isolated" -->
260
+ ```
261
+
262
+ ```nginx
263
+ add_header Cross-Origin-Opener-Policy "same-origin" always;
264
+ add_header Cross-Origin-Embedder-Policy "credentialless" always; # or require-corp
265
+ ```
266
+
267
+ **Benefit**: EMAGE inference uses up to `min(hardwareConcurrency, 8 desktop / 4 mobile)` threads. Measured in Node on the same inference: **1 thread 274 ms → 4 threads 79 ms (~3.5×)**.
268
+
269
+ **Side effects (the isolation is on *your* page)**:
270
+
271
+ * Every cross-origin subresource on your page must satisfy COEP. With `credentialless`, no-cors cross-origin requests are sent *without* cookies/credentials (credentialed third-party images/scripts may break); with `require-corp` they need `CORP: cross-origin` or CORS or they are blocked.
272
+ * Other third-party **iframes** on your page (ads, maps, video, payments, comments…) must send COEP themselves or they are blocked. `COOP: same-origin` also severs `window.opener` (OAuth / payment popups that report back may stop working).
273
+ * Safari has no `credentialless`; use `require-corp` there. On browsers without isolation support the option is a harmless no-op (single-threaded fallback).
274
+ * Enabling the option on a non-isolated host does nothing. Verify on staging first.
275
+
276
+ **Verify**: the `xc.ready` handshake carries `capabilities.crossOriginIsolated` (should be `true`); or run `crossOriginIsolated` in the iframe's console context; the EMAGE worker reports `numThreads > 1`. Locally: `node examples/serve-isolated.mjs`, then open `http://localhost:8081/examples/embed-host.html?isolated=1`.
277
+
278
+ ---
279
+
280
+ ## ⚡ Lighthouse-Friendly Usage
281
+
282
+ ```ts
283
+ createXiaochun({
284
+ container: '#avatar',
285
+ width: 320, height: 480, // fixed box → no layout shift
286
+ placeholder: '/xc.webp', // an <img> is all that ships at first paint
287
+ lazy: true, // iframe is created when visible AND idle ('click' is the cheapest)
288
+ lazyMargin: 200, // px; larger = loads earlier but uses more data
289
+ heavy: 'lazy', // no WebLLM / EMAGE until the first interaction
290
+ autoPause: true, // pause rendering when scrolled out of view
291
+ });
292
+ ```
293
+
294
+ ---
295
+
296
+ ## 📚 API
297
+
298
+ ### `createXiaochun(options): XiaochunInstance`
299
+
300
+ | Option | Default | Description |
301
+ | :--- | :--- | :--- |
302
+ | `container` | — | Element or selector (required) |
303
+ | `src` | `https://xiaochun.firetable.tech/embed` | Embed page URL (override for self-hosting or local dev) |
304
+ | `origin` | derived from `src` | Expected iframe origin; every message is checked against it |
305
+ | `allowedOrigins` | `[]` | Extra trusted iframe origins. `'*'` is rejected |
306
+ | `lazy` | `true` | `true` / `'idle'`: visible and idle · `'click'`: on click or first API call · `false`: immediately |
307
+ | `lazyMargin` | `200` | rootMargin in px for the visibility trigger. Larger loads earlier and uses more data |
308
+ | `placeholder` | built-in SVG | Image URL, element, or `false` |
309
+ | `transparent` | `false` | Transparent background over your page (with pointer pass-through) |
310
+ | `width`, `height` | `320`, `480` | px or any CSS length. Always set them |
311
+ | `position` | `'inline'` | `'inline' \| 'bottom-right' \| 'bottom-left'` |
312
+ | `draggable` | `false` | Drag handle in floating mode |
313
+ | `lang`, `model` | — | `'zh-CN' \| 'en' \| 'ja'`; outfit key (e.g. `xiaochun_maid`) or an https `.vrm` URL |
314
+ | `ui` | `false` | Show the embed's built-in chat bar |
315
+ | `heavy` | `'lazy'` | `'lazy'`: load WebLLM / EMAGE on first use · `'eager'`: preload |
316
+ | `controls` | `false` | Allow wheel-zoom inside the iframe (swallows page scrolling) |
317
+ | `autoPause` | `true` | Pause when out of the viewport |
318
+ | `passthrough` | = `transparent` | Toggle the iframe's pointer-events depending on whether the cursor is over the character |
319
+ | `sandbox` | scripts + same-origin + popups | iframe `sandbox`; `false` = none. Dropping `allow-same-origin` breaks IndexedDB and the mic |
320
+ | `handshakeTimeout` | `20000` | ms; on timeout an `error { code: 'timeout' }` is emitted |
321
+ | `crossOriginIsolated` | `false` | Append `cross-origin-isolated` to the iframe `allow` (default stays `microphone; autoplay`). Needs a host page that is itself isolated; see [Opt-in: cross-origin isolation](#opt-in-cross-origin-isolation-multi-threaded-emage) |
322
+ | `zIndex` | `2147483000` | Floating mode layer (the `--xc-z-index` variable takes precedence) |
323
+
324
+ **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`)*.
325
+
326
+ **Events**: `handshake` · `ready` · `progress` · `state` · `stt` · `utterance` (`phase: 'start' | 'end'`, `kind: 'text' | 'audio'`) · `hit-region` · `error` · `destroy`.
327
+
328
+ ### `<xiaochun-avatar>`
329
+
330
+ | Attribute | Default | Description |
331
+ | :--- | :--- | :--- |
332
+ | `src` | official `/embed` | Changing it rebuilds the iframe |
333
+ | `model` | — | Outfit key or https `.vrm` / `.vrmaddon` / `.vrmbase` URL; changing it at runtime = `setModel` |
334
+ | `lang` | — | `zh-CN` · `en` · `ja`; runtime change = `setConfig` |
335
+ | `mic` | `false` | Toggle dictation (takes effect once the model is loaded) |
336
+ | `transparent` | `true` | `"false"` turns it off |
337
+ | `draggable` | `false` | Floating mode only |
338
+ | `position` | `inline` | `inline` · `bottom-right` · `bottom-left` |
339
+ | `size` | `320x480` | `"280"` (height = width × 1.5), `"320x480"`, `"100%x480px"` |
340
+ | `lazy` | idle + viewport | `"click"` for click only; `"false"` for immediate |
341
+ | `paused` | `false` | `pause()` / `resume()` |
342
+ | `placeholder` / `heavy` / `ui` / `controls` / `allowed-origins` | — | Same as `createXiaochun` |
343
+ | `cross-origin-isolated` | `false` | Same as `createXiaochun({ crossOriginIsolated })`; changing it rebuilds the iframe |
344
+
345
+ **Events** (`CustomEvent`, `composed`, `detail` = protocol payload): `xc-ready` · `xc-progress` · `xc-state` · `xc-stt` · `xc-utterance` · `xc-error`.
346
+ **Methods**: `say` · `speakAudio` · `speakAudioStream` · `motion` · `expression` · `destroy`; `el.client` gives you the full SDK instance.
347
+
348
+ ---
349
+
350
+ ## 🧪 Try It Locally
351
+
352
+ 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'`.
353
+
354
+ ---
355
+
356
+ ## 🏷️ Versioning & Release
357
+
358
+ 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.
359
+
360
+ 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).
361
+
362
+ ---
191
363
 
192
- See [`examples/embed-host.html`](./examples/embed-host.html).
364
+ ## 📚 More Documentation
193
365
 
194
- ## Versioning & release / 版本与发布
366
+ * [`docs/EMBED.md`](https://github.com/FireTable/project-xiaochun/blob/main/docs/EMBED.md): full embed design, protocol tables, response headers, risks, publishing
367
+ * [`docs/PROTOCOL.md`](https://github.com/FireTable/project-xiaochun/blob/main/docs/PROTOCOL.md): the `xiaochun://` URL scheme and its mapping to `xc.*`
368
+ * [`docs/README.md`](https://github.com/FireTable/project-xiaochun/blob/main/docs/README.md): documentation index
369
+ * [Project README](https://github.com/FireTable/project-xiaochun#readme): the main project
195
370
 
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)。
371
+ ---
198
372
 
199
- ## License
373
+ ## 📄 License
200
374
 
201
- MIT
375
+ [MIT](./LICENSE)
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * avatar-element.ts — `<xiaochun-avatar>` Web Component (Shadow DOM 内包 iframe)。
3
3
  *
4
- * 属性: src model lang mic transparent draggable position size lazy paused placeholder heavy ui controls allowed-origins
4
+ * 属性: src model lang mic transparent draggable position size lazy paused placeholder heavy ui controls allowed-origins cross-origin-isolated
5
5
  * - 布尔属性: 缺省取默认值; "" / "true" = true; "false" = false。
6
6
  * - size="320x480" 或 size="320" (高 = 宽 × 1.5); 也可写 CSS 长度 "100%x480px"。
7
7
  * - model: 内置服装 key (xiaochun_maid) 或 https .vrm/.vrmaddon/.vrmbase URL。
@@ -1,4 +1,4 @@
1
- /*! @firetable/project-xiaochun v0.1.12 | MIT | https://github.com/FireTable/project-xiaochun */
1
+ /*! @firetable/project-xiaochun v0.1.14 | 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-DUU5PTKT.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/protocol.ts"],
4
- "sourcesContent": ["/**\n * protocol.ts \u2014 Project XiaoChun `<iframe>` \u5D4C\u5165\u534F\u8BAE (\u5E38\u91CF + \u7C7B\u578B, \u96F6\u8FD0\u884C\u65F6\u4F9D\u8D56)\u3002\n *\n * \u5355\u4E00\u4E8B\u5B9E\u6E90: SDK (\u672C\u5305) \u4E0E `/embed` \u9875\u9762 (\u4E3B\u4ED3\u5E93 src/embed/) \u5171\u7528\u8FD9\u4E00\u4EFD\u3002\n * \u6240\u6709\u6D88\u606F\u540D\u7EDF\u4E00 `xc.` \u524D\u7F00; \u4FE1\u5C01 `{ type, v, id?, payload? }`\u3002\n *\n * \u901A\u9053:\n * 1. \u63E1\u624B: iframe \u2192 \u5BBF\u4E3B `xc.ready` (window.postMessage, targetOrigin = \u5BBF\u4E3B origin)\n * \u5BBF\u4E3B \u2192 iframe `xc.init` (window.postMessage, targetOrigin = iframe origin, \u8F6C\u79FB MessagePort)\n * 2. \u63E1\u624B\u540E\u5168\u90E8\u8D70 MessageChannel \u7AEF\u53E3 (\u7AEF\u53E3\u672C\u8EAB\u5373\u51ED\u8BC1, \u4E0D\u518D\u4F9D\u8D56 `*`)\n */\n\n/** \u534F\u8BAE\u7248\u672C; \u4E0D\u517C\u5BB9\u53D8\u66F4\u65F6 +1\u3002 */\nexport const XC_PROTOCOL_VERSION = 1 as const;\n\n/** \u5B98\u65B9\u90E8\u7F72\u7684 origin / embed \u5165\u53E3\u3002 */\nexport const XC_DEFAULT_ORIGIN = 'https://xiaochun.firetable.tech';\nexport const XC_DEFAULT_SRC = `${XC_DEFAULT_ORIGIN}/embed`;\n\n/** \u5BBF\u4E3B \u2192 iframe \u547D\u4EE4\u3002 */\nexport const XC_HOST_TO_FRAME = [\n 'xc.init',\n 'xc.say',\n 'xc.audio',\n 'xc.audio.chunk',\n 'xc.audio.end',\n 'xc.motion',\n 'xc.expression',\n 'xc.lookAt',\n 'xc.pointer',\n 'xc.setModel',\n 'xc.setConfig',\n 'xc.mic',\n 'xc.pause',\n 'xc.resume',\n 'xc.destroy',\n] as const;\n\n/** iframe \u2192 \u5BBF\u4E3B \u4E8B\u4EF6\u3002 */\nexport const XC_FRAME_TO_HOST = [\n 'xc.ready',\n 'xc.load.progress',\n 'xc.loaded',\n 'xc.state',\n 'xc.stt',\n 'xc.utterance',\n 'xc.hit-region',\n 'xc.error',\n] as const;\n\nexport type XcHostMessageType = (typeof XC_HOST_TO_FRAME)[number];\nexport type XcFrameMessageType = (typeof XC_FRAME_TO_HOST)[number];\n\n/** \u5F53\u524D /embed \u5DF2\u5B9E\u73B0\u7684\u547D\u4EE4 (xc.init \u662F\u63E1\u624B, \u4E0D\u8BA1\u5165)\u3002 */\nexport const XC_IMPLEMENTED_COMMANDS = [\n 'xc.say',\n 'xc.audio',\n 'xc.audio.chunk',\n 'xc.audio.end',\n 'xc.motion',\n 'xc.expression',\n 'xc.pointer',\n 'xc.setModel',\n 'xc.setConfig',\n 'xc.mic',\n 'xc.pause',\n 'xc.resume',\n 'xc.destroy',\n] as const satisfies readonly XcHostMessageType[];\n\n/**\n * \u6682\u4E0D\u652F\u6301 (\u5E95\u5C42\u65E0\u5BF9\u5E94\u80FD\u529B), /embed \u56DE `xc.error{code:'unsupported'}`\u3002\n * TODO(xc.lookAt): \u89C6\u7EBF\u76EE\u524D\u7531\u76F8\u673A + \u968F\u673A\u626B\u89C6\u9A71\u52A8 (GazeController), \u6CA1\u6709\"\u5916\u90E8\u6307\u5B9A\u6CE8\u89C6\u70B9\"\u7684\u5165\u53E3;\n * \u9700\u8981\u5148\u5728 gaze \u5C42\u52A0 overrideTarget \u518D\u5F00\u653E\u3002\n */\nexport const XC_UNSUPPORTED_COMMANDS = ['xc.lookAt'] as const satisfies readonly XcHostMessageType[];\n\n/**\n * xc.* \u2194 xiaochun:// \u5BF9\u7167\u8868 (\u540C\u4E00\u8BED\u4E49\u7684\u4E24\u4E2A\u4F20\u8F93\u5C42, \u4E3B\u5E94\u7528\u5185\u8D70\u540C\u4E00\u4E2A handler: src/core/protocol/handler.ts)\u3002\n * - `action`: \u5185\u90E8 protocol action \u5BF9\u8C61 (src/core/protocol/types.ts); null = \u6CA1\u6709 deep link \u5BF9\u5E94\u7269\u3002\n * - `url`: \u5BF9\u5E94\u7684 deep link \u5F62\u5F0F (OS \u7EA7, \u53EA\u5728 Tauri \u58F3\u751F\u6548; iframe \u4E0D\u4F1A\u54CD\u5E94 xiaochun://)\u3002\n */\nexport const XC_PROTOCOL_MAPPING = [\n { xc: 'xc.say', note: 'mode:\"speak\" (\u9ED8\u8BA4)', action: 'speak', url: 'xiaochun://speak?text=\u2026' },\n { xc: 'xc.say', note: 'mode:\"chat\" (\u8D70 LLM)', action: null, url: null },\n { xc: 'xc.audio', note: 'source \u4E3A URL \u5B57\u7B26\u4E32', action: 'speak', url: 'xiaochun://speak?audioUrl=\u2026[&text=\u2026]' },\n { xc: 'xc.audio', note: 'source \u4E3A ArrayBuffer / Blob', action: 'audio', url: null },\n { xc: 'xc.audio.chunk / xc.audio.end', note: '\u6D41\u5F0F PCM', action: 'audio', url: null },\n] as const;\n\nexport type XcErrorCode =\n | 'unsupported' // \u547D\u4EE4/\u53C2\u6570\u5B58\u5728\u4F46\u5E95\u5C42\u6682\u65E0\u80FD\u529B\n | 'bad_request' // \u53C2\u6570\u7F3A\u5931/\u975E\u6CD5\n | 'not_ready' // \u6A21\u578B\u5C1A\u672A\u52A0\u8F7D\u5B8C\u6210\n | 'origin_denied' // \u63E1\u624B\u6765\u6E90\u4E0D\u5728\u767D\u540D\u5355\n | 'failed'; // \u6267\u884C\u671F\u5F02\u5E38\n\nexport type XcLang = 'zh-CN' | 'en' | 'ja';\nexport type XcPhase = 'idle' | 'loading' | 'thinking' | 'speaking' | 'listening' | 'paused';\n/** lazy = \u4E0D\u9884\u70ED WebLLM / EMAGE, \u9996\u6B21\u4E92\u52A8\u518D\u52A0\u8F7D (\u9ED8\u8BA4); eager = \u7ACB\u5373\u9884\u70ED\u3002 */\nexport type XcHeavyMode = 'lazy' | 'eager';\n\nexport interface XcEnvelope<T extends string = string, P = unknown> {\n type: T;\n v: typeof XC_PROTOCOL_VERSION;\n /** \u5BBF\u4E3B\u547D\u4EE4\u53EF\u5E26 id; \u5BF9\u5E94\u7684 xc.error / xc.utterance \u4F1A\u56DE\u5E26\u540C\u4E00\u4E2A id\u3002 */\n id?: string;\n payload: P;\n}\n\n/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u5BBF\u4E3B \u2192 iframe payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nexport interface XcConfig {\n lang?: XcLang;\n /** \u80CC\u666F\u900F\u660E (\u53E0\u5728\u5BBF\u4E3B\u9875\u9762\u4E0A)\u3002 */\n transparent?: boolean;\n /** \u662F\u5426\u663E\u793A iframe \u5185\u7F6E UI (ChatBar)\u3002\u9ED8\u8BA4 false\u3002 */\n ui?: boolean;\n /** \u91CD\u8D44\u6E90\u52A0\u8F7D\u7B56\u7565, \u89C1 XcHeavyMode\u3002 */\n heavy?: XcHeavyMode;\n}\n\nexport interface XcInitPayload {\n /** \u5BBF\u4E3B\u58F0\u660E\u7684\u81EA\u8EAB origin (iframe \u4F1A\u4E0E event.origin \u4EA4\u53C9\u6821\u9A8C)\u3002 */\n hostOrigin: string;\n config?: XcConfig;\n}\n\nexport interface XcSayPayload {\n text: string;\n /**\n * 'speak' (\u9ED8\u8BA4): \u76F4\u63A5 TTS + \u52A8\u4F5C, \u4E0D\u7ECF\u8FC7 LLM\u3002\n * 'chat': \u5F53\u4F5C\u7528\u6237\u8F93\u5165\u8D70 WebLLM / \u81EA\u5B9A\u4E49 provider (\u4F1A\u89E6\u53D1\u5927\u6A21\u578B\u52A0\u8F7D)\u3002\n */\n mode?: 'speak' | 'chat';\n}\n\n/** \u5BBF\u4E3B\u97F3\u9891\u7684\u7F16\u7801: encoded = \u5BB9\u5668\u683C\u5F0F (mp3/wav/ogg/aac/webm\u2026 \u7531\u6D4F\u89C8\u5668 decodeAudioData \u51B3\u5B9A); pcm16 / float32 = \u65E0\u5934\u539F\u59CB PCM (\u5C0F\u7AEF, \u4EA4\u9519)\u3002 */\nexport type XcAudioFormat = 'encoded' | 'pcm16' | 'float32';\n\nexport interface XcAudioOptions {\n /** \u6C14\u6CE1\u91CC\u663E\u793A\u7684\u6587\u5B57 (\u53EF\u9009; \u4E0D\u5F71\u54CD\u97F3\u9891\u4E0E\u52A8\u4F5C)\u3002 */\n text?: string;\n /** \u662F\u5426\u7531 EMAGE \u6839\u636E\u8FD9\u6BB5\u97F3\u9891\u751F\u6210\u5168\u8EAB\u52A8\u4F5C, \u9ED8\u8BA4 true\u3002false = \u53EA\u64AD\u653E\u97F3\u9891 (\u6B64\u65F6\u5B8C\u5168\u4E0D\u52A0\u8F7D EMAGE)\u3002 */\n motion?: boolean;\n /** \u662F\u5426\u9A71\u52A8\u53E3\u578B (\u97F3\u91CF RMS \u2192 'aa'), \u9ED8\u8BA4 true\u3002 */\n lipsync?: boolean;\n}\n\n/**\n * xc.audio \u2014 \u6574\u6BB5\u97F3\u9891: iframe \u5185\u89E3\u7801 \u2192 16 kHz \u2192 EMAGE \u7A97\u53E3\u63A8\u7406 + \u540C\u4E00\u6BB5\u97F3\u9891\u64AD\u653E + \u53E3\u578B, \u64AD\u5B8C\u56DE xc.utterance{phase:'end'}\u3002\n * \u4FE1\u5C01 `id` \u7528\u4E8E\u5173\u8054 xc.utterance / xc.error\u3002\u5927 ArrayBuffer \u8BF7\u653E\u8FDB postMessage \u7684 transfer \u5217\u8868 (SDK \u9ED8\u8BA4\u8FD9\u4E48\u505A)\u3002\n */\nexport interface XcAudioPayload extends XcAudioOptions {\n /** ArrayBuffer / Blob (structured clone) \u6216 URL \u5B57\u7B26\u4E32 (iframe \u5185 fetch, \u4EC5 https \u6216\u540C\u6E90, \u9700 CORS)\u3002 */\n source: ArrayBuffer | Blob | string;\n /** MIME \u63D0\u793A, \u5982 'audio/mpeg'\u3002\u4EC5\u4F5C\u4FE1\u606F, \u89E3\u7801\u4EE5\u6D4F\u89C8\u5668\u55C5\u63A2\u4E3A\u51C6\u3002 */\n mimeType?: string;\n /** \u9ED8\u8BA4 'encoded'\u3002 */\n format?: XcAudioFormat;\n /** \u4EC5\u539F\u59CB PCM \u9700\u8981 (\u9ED8\u8BA4 16000); \u5BB9\u5668\u683C\u5F0F\u5FFD\u7565 (\u4EE5\u6587\u4EF6\u5185\u7684\u91C7\u6837\u7387\u4E3A\u51C6)\u3002\u8303\u56F4 8000~96000\u3002 */\n sampleRate?: number;\n /** \u539F\u59CB PCM \u7684\u58F0\u9053\u6570 (\u4EA4\u9519), 1 \u6216 2, \u9ED8\u8BA4 1; \u5185\u90E8\u4E0B\u6DF7\u4E3A\u5355\u58F0\u9053\u3002 */\n channels?: 1 | 2;\n}\n\n/**\n * xc.audio.chunk \u2014 \u6D41\u5F0F\u97F3\u9891\u5206\u5757 (\u539F\u59CB PCM)\u3002\u4FE1\u5C01 `id` = \u6D41 id: \u540C\u4E00\u4E2A id \u7684\u7B2C\u4E00\u4E2A chunk \u5F00\u542F\u4E00\u6B21\u8BF4\u8BDD (\u56DE xc.utterance start),\n * \u4E4B\u540E\u7684 chunk \u8FFD\u52A0; \u4EE5 xc.audio.end \u6536\u5C3E\u3002\u6BCF\u4E2A chunk \u7684 `data` \u5E94\u653E\u8FDB transfer \u5217\u8868\u3002\n * \u9009\u9879 (text/motion/lipsync) \u53EA\u5728\u7B2C\u4E00\u4E2A chunk \u91CC\u751F\u6548; sampleRate/format \u6574\u6761\u6D41\u5FC5\u987B\u4E00\u81F4\u3002\n */\nexport interface XcAudioChunkPayload extends XcAudioOptions {\n data: ArrayBuffer;\n format: Exclude<XcAudioFormat, 'encoded'>;\n /** 8000~96000\u3002 */\n sampleRate: number;\n channels?: 1 | 2;\n}\n\nexport interface XcAudioEndPayload {\n /** true = \u7ACB\u5373\u6253\u65AD (\u4E22\u5F03\u672A\u64AD\u653E\u7684\u97F3\u9891); \u9ED8\u8BA4 false = \u64AD\u5B8C\u5DF2\u6536\u5230\u7684\u97F3\u9891\u540E\u7ED3\u675F\u3002 */\n abort?: boolean;\n}\n\nexport interface XcMotionPayload {\n /** .vrma URL (https:// \u6216\u540C\u6E90\u8DEF\u5F84)\u3002\u4E0E stop \u4E8C\u9009\u4E00\u3002 */\n url?: string;\n /** \u5185\u7F6E\u52A8\u4F5C\u540D, \u76EE\u524D\u4EC5 'thinking'\u3002 */\n name?: 'thinking';\n stop?: boolean;\n loop?: boolean;\n /** \u6DE1\u5165\u6DE1\u51FA\u79D2\u6570, \u8303\u56F4 0.26~3, \u8C03\u5927\u66F4\u67D4\u548C\u4F46\u54CD\u5E94\u66F4\u6162\u3002 */\n fadeDuration?: number;\n /** \u64AD\u653E\u500D\u901F, \u8303\u56F4 0.25~3\u3002 */\n timeScale?: number;\n mask?: 'all' | 'upperBody';\n}\n\nexport interface XcExpressionPayload {\n name: 'neutral' | 'happy' | 'angry' | 'sad' | 'relaxed' | 'surprised';\n}\n\n/** xc.lookAt \u2014 TODO: \u6682\u4E0D\u652F\u6301, \u6536\u5230\u4F1A\u8FD4\u56DE unsupported\u3002 */\nexport interface XcLookAtPayload {\n /** \u5F52\u4E00\u5316 (-1..1), \u76F8\u5BF9 iframe \u4E2D\u5FC3\u3002 */\n x: number;\n y: number;\n}\n\nexport interface XcPointerPayload {\n /** iframe \u5185 client \u5750\u6807 (px)\u3002SDK \u4F1A\u628A\u5BBF\u4E3B pointer \u4E8B\u4EF6\u6362\u7B97\u597D\u518D\u53D1\u3002 */\n x: number;\n y: number;\n}\n\nexport interface XcSetModelPayload {\n /** \u5B8C\u6574 .vrm / .vrmaddon / .vrmbase URL (\u9700 CORS)\u3002 */\n url?: string;\n /** \u5185\u7F6E\u670D\u88C5 key, \u89C1\u4E3B\u4ED3\u5E93 APP_CONFIG.model.addons, \u5982 'xiaochun_maid'\u3002 */\n outfit?: string;\n name?: string;\n}\n\nexport interface XcMicPayload {\n enabled: boolean;\n}\n\n/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 iframe \u2192 \u5BBF\u4E3B payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nexport interface XcReadyPayload {\n version: string;\n protocol: typeof XC_PROTOCOL_VERSION;\n capabilities: {\n commands: string[];\n unsupported: string[];\n stt: boolean;\n transparent: boolean;\n /** \u5BBF\u4E3B\u97F3\u9891\u80FD\u529B (xc.audio / xc.audio.chunk)\u3002\u65E7\u7248 /embed \u6CA1\u6709\u8FD9\u4E2A\u5B57\u6BB5\u3002 */\n audio?: { formats: XcAudioFormat[]; streaming: boolean; maxSeconds: number };\n };\n}\n\nexport interface XcLoadProgressPayload {\n phase: 'model';\n /** 0~100 */\n progress: number;\n}\n\nexport interface XcLoadedPayload {\n model: string;\n}\n\nexport interface XcStatePayload {\n phase: XcPhase;\n paused: boolean;\n heavy: XcHeavyMode;\n}\n\nexport type XcSttPayload =\n | { kind: 'state'; state: 'idle' | 'loading' | 'listening' | 'recognizing' | 'error' }\n | { kind: 'progress'; percent: number }\n | { kind: 'text'; text: string };\n\nexport interface XcUtterancePayload {\n phase: 'start' | 'end';\n text: string;\n /** 'text' = xc.say (TTS); 'audio' = xc.audio / xc.audio.chunk (\u5BBF\u4E3B\u97F3\u9891, \u6CA1\u6709\u8D70 TTS)\u3002\u65E7\u7248 /embed \u6CA1\u6709\u8FD9\u4E2A\u5B57\u6BB5\u3002 */\n kind?: 'text' | 'audio';\n}\n\nexport interface XcHitRegionPayload {\n hit: boolean;\n x: number;\n y: number;\n}\n\nexport interface XcErrorPayload {\n code: XcErrorCode;\n message: string;\n /** \u89E6\u53D1\u9519\u8BEF\u7684\u547D\u4EE4\u540D (\u5982 'xc.lookAt')\u3002 */\n command?: string;\n}\n\n/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u7C7B\u578B\u6620\u5C04 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nexport interface XcHostPayloadMap {\n 'xc.init': XcInitPayload;\n 'xc.say': XcSayPayload;\n 'xc.audio': XcAudioPayload;\n 'xc.audio.chunk': XcAudioChunkPayload;\n 'xc.audio.end': XcAudioEndPayload;\n 'xc.motion': XcMotionPayload;\n 'xc.expression': XcExpressionPayload;\n 'xc.lookAt': XcLookAtPayload;\n 'xc.pointer': XcPointerPayload;\n 'xc.setModel': XcSetModelPayload;\n 'xc.setConfig': XcConfig;\n 'xc.mic': XcMicPayload;\n 'xc.pause': undefined;\n 'xc.resume': undefined;\n 'xc.destroy': undefined;\n}\n\nexport interface XcFramePayloadMap {\n 'xc.ready': XcReadyPayload;\n 'xc.load.progress': XcLoadProgressPayload;\n 'xc.loaded': XcLoadedPayload;\n 'xc.state': XcStatePayload;\n 'xc.stt': XcSttPayload;\n 'xc.utterance': XcUtterancePayload;\n 'xc.hit-region': XcHitRegionPayload;\n 'xc.error': XcErrorPayload;\n}\n\nexport type XcHostMessage = {\n [K in XcHostMessageType]: XcEnvelope<K, XcHostPayloadMap[K]>;\n}[XcHostMessageType];\n\nexport type XcFrameMessage = {\n [K in XcFrameMessageType]: XcEnvelope<K, XcFramePayloadMap[K]>;\n}[XcFrameMessageType];\n\n/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u5C0F\u5DE5\u5177 (SDK \u4E0E /embed \u5171\u7528) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nexport function xcMessage<K extends XcHostMessageType | XcFrameMessageType>(\n type: K,\n payload?: unknown,\n id?: string,\n): XcEnvelope<K, any> {\n const m: XcEnvelope<K, any> = { type, v: XC_PROTOCOL_VERSION, payload };\n if (id !== undefined) m.id = id;\n return m;\n}\n\n/** \u662F\u5426\u4E3A\u5408\u6CD5 xc \u4FE1\u5C01 (\u53EA\u68C0\u67E5\u7ED3\u6784, \u4E0D\u68C0\u67E5 payload)\u3002 */\nexport function isXcEnvelope(data: unknown): data is XcEnvelope {\n if (!data || typeof data !== 'object') return false;\n const d = data as Record<string, unknown>;\n return typeof d.type === 'string' && d.type.startsWith('xc.') && d.v === XC_PROTOCOL_VERSION;\n}\n\n/**\n * \u628A\u7528\u6237\u7ED9\u7684 origin \u89C4\u6574\u6210\u4E25\u683C\u7684 `scheme://host[:port]`\u3002\n * \u62D2\u7EDD '*' / \u901A\u914D / \u975E http(s) / \u5E26\u8DEF\u5F84\u7684\u5B57\u7B26\u4E32 \u2192 \u8FD4\u56DE null\u3002\n */\nexport function normalizeOrigin(input: string | null | undefined): string | null {\n if (!input || input === '*' || input === 'null') return null;\n try {\n const u = new URL(input);\n if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;\n return u.origin;\n } catch {\n return null;\n }\n}\n"],
5
- "mappings": ";;;AAaO,IAAM,sBAAsB;AAG5B,IAAM,oBAAoB;AAC1B,IAAM,iBAAiB,GAAG,iBAAiB;AAG3C,IAAM,mBAAmB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,IAAM,mBAAmB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAMO,IAAM,0BAA0B;AAAA,EACrC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAOO,IAAM,0BAA0B,CAAC,WAAW;AAO5C,IAAM,sBAAsB;AAAA,EACjC,EAAE,IAAI,UAAU,MAAM,+BAAqB,QAAQ,SAAS,KAAK,+BAA0B;AAAA,EAC3F,EAAE,IAAI,UAAU,MAAM,4BAAuB,QAAQ,MAAM,KAAK,KAAK;AAAA,EACrE,EAAE,IAAI,YAAY,MAAM,wCAAoB,QAAQ,SAAS,KAAK,iDAAuC;AAAA,EACzG,EAAE,IAAI,YAAY,MAAM,oCAA+B,QAAQ,SAAS,KAAK,KAAK;AAAA,EAClF,EAAE,IAAI,iCAAiC,MAAM,oBAAU,QAAQ,SAAS,KAAK,KAAK;AACpF;AA4OO,SAAS,UACd,MACA,SACA,IACoB;AACpB,QAAM,IAAwB,EAAE,MAAM,GAAG,qBAAqB,QAAQ;AACtE,MAAI,OAAO,OAAW,GAAE,KAAK;AAC7B,SAAO;AACT;AAGO,SAAS,aAAa,MAAmC;AAC9D,MAAI,CAAC,QAAQ,OAAO,SAAS,SAAU,QAAO;AAC9C,QAAM,IAAI;AACV,SAAO,OAAO,EAAE,SAAS,YAAY,EAAE,KAAK,WAAW,KAAK,KAAK,EAAE,MAAM;AAC3E;AAMO,SAAS,gBAAgB,OAAiD;AAC/E,MAAI,CAAC,SAAS,UAAU,OAAO,UAAU,OAAQ,QAAO;AACxD,MAAI;AACF,UAAM,IAAI,IAAI,IAAI,KAAK;AACvB,QAAI,EAAE,aAAa,YAAY,EAAE,aAAa,QAAS,QAAO;AAC9D,WAAO,EAAE;AAAA,EACX,QAAQ;AACN,WAAO;AAAA,EACT;AACF;",
4
+ "sourcesContent": ["/**\n * protocol.ts \u2014 Project XiaoChun `<iframe>` \u5D4C\u5165\u534F\u8BAE (\u5E38\u91CF + \u7C7B\u578B, \u96F6\u8FD0\u884C\u65F6\u4F9D\u8D56)\u3002\n *\n * \u5355\u4E00\u4E8B\u5B9E\u6E90: SDK (\u672C\u5305) \u4E0E `/embed` \u9875\u9762 (\u4E3B\u4ED3\u5E93 src/embed/) \u5171\u7528\u8FD9\u4E00\u4EFD\u3002\n * \u6240\u6709\u6D88\u606F\u540D\u7EDF\u4E00 `xc.` \u524D\u7F00; \u4FE1\u5C01 `{ type, v, id?, payload? }`\u3002\n *\n * \u901A\u9053:\n * 1. \u63E1\u624B: iframe \u2192 \u5BBF\u4E3B `xc.ready` (window.postMessage, targetOrigin = \u5BBF\u4E3B origin)\n * \u5BBF\u4E3B \u2192 iframe `xc.init` (window.postMessage, targetOrigin = iframe origin, \u8F6C\u79FB MessagePort)\n * 2. \u63E1\u624B\u540E\u5168\u90E8\u8D70 MessageChannel \u7AEF\u53E3 (\u7AEF\u53E3\u672C\u8EAB\u5373\u51ED\u8BC1, \u4E0D\u518D\u4F9D\u8D56 `*`)\n */\n\n/** \u534F\u8BAE\u7248\u672C; \u4E0D\u517C\u5BB9\u53D8\u66F4\u65F6 +1\u3002 */\nexport const XC_PROTOCOL_VERSION = 1 as const;\n\n/** \u5B98\u65B9\u90E8\u7F72\u7684 origin / embed \u5165\u53E3\u3002 */\nexport const XC_DEFAULT_ORIGIN = 'https://xiaochun.firetable.tech';\nexport const XC_DEFAULT_SRC = `${XC_DEFAULT_ORIGIN}/embed`;\n\n/** \u5BBF\u4E3B \u2192 iframe \u547D\u4EE4\u3002 */\nexport const XC_HOST_TO_FRAME = [\n 'xc.init',\n 'xc.say',\n 'xc.audio',\n 'xc.audio.chunk',\n 'xc.audio.end',\n 'xc.motion',\n 'xc.expression',\n 'xc.lookAt',\n 'xc.pointer',\n 'xc.setModel',\n 'xc.setConfig',\n 'xc.mic',\n 'xc.pause',\n 'xc.resume',\n 'xc.destroy',\n] as const;\n\n/** iframe \u2192 \u5BBF\u4E3B \u4E8B\u4EF6\u3002 */\nexport const XC_FRAME_TO_HOST = [\n 'xc.ready',\n 'xc.load.progress',\n 'xc.loaded',\n 'xc.state',\n 'xc.stt',\n 'xc.utterance',\n 'xc.hit-region',\n 'xc.error',\n] as const;\n\nexport type XcHostMessageType = (typeof XC_HOST_TO_FRAME)[number];\nexport type XcFrameMessageType = (typeof XC_FRAME_TO_HOST)[number];\n\n/** \u5F53\u524D /embed \u5DF2\u5B9E\u73B0\u7684\u547D\u4EE4 (xc.init \u662F\u63E1\u624B, \u4E0D\u8BA1\u5165)\u3002 */\nexport const XC_IMPLEMENTED_COMMANDS = [\n 'xc.say',\n 'xc.audio',\n 'xc.audio.chunk',\n 'xc.audio.end',\n 'xc.motion',\n 'xc.expression',\n 'xc.pointer',\n 'xc.setModel',\n 'xc.setConfig',\n 'xc.mic',\n 'xc.pause',\n 'xc.resume',\n 'xc.destroy',\n] as const satisfies readonly XcHostMessageType[];\n\n/**\n * \u6682\u4E0D\u652F\u6301 (\u5E95\u5C42\u65E0\u5BF9\u5E94\u80FD\u529B), /embed \u56DE `xc.error{code:'unsupported'}`\u3002\n * TODO(xc.lookAt): \u89C6\u7EBF\u76EE\u524D\u7531\u76F8\u673A + \u968F\u673A\u626B\u89C6\u9A71\u52A8 (GazeController), \u6CA1\u6709\"\u5916\u90E8\u6307\u5B9A\u6CE8\u89C6\u70B9\"\u7684\u5165\u53E3;\n * \u9700\u8981\u5148\u5728 gaze \u5C42\u52A0 overrideTarget \u518D\u5F00\u653E\u3002\n */\nexport const XC_UNSUPPORTED_COMMANDS = ['xc.lookAt'] as const satisfies readonly XcHostMessageType[];\n\n/**\n * xc.* \u2194 xiaochun:// \u5BF9\u7167\u8868 (\u540C\u4E00\u8BED\u4E49\u7684\u4E24\u4E2A\u4F20\u8F93\u5C42, \u4E3B\u5E94\u7528\u5185\u8D70\u540C\u4E00\u4E2A handler: src/core/protocol/handler.ts)\u3002\n * - `action`: \u5185\u90E8 protocol action \u5BF9\u8C61 (src/core/protocol/types.ts); null = \u6CA1\u6709 deep link \u5BF9\u5E94\u7269\u3002\n * - `url`: \u5BF9\u5E94\u7684 deep link \u5F62\u5F0F (OS \u7EA7, \u53EA\u5728 Tauri \u58F3\u751F\u6548; iframe \u4E0D\u4F1A\u54CD\u5E94 xiaochun://)\u3002\n */\nexport const XC_PROTOCOL_MAPPING = [\n { xc: 'xc.say', note: 'mode:\"speak\" (\u9ED8\u8BA4)', action: 'speak', url: 'xiaochun://speak?text=\u2026' },\n { xc: 'xc.say', note: 'mode:\"chat\" (\u8D70 LLM)', action: null, url: null },\n { xc: 'xc.audio', note: 'source \u4E3A URL \u5B57\u7B26\u4E32', action: 'speak', url: 'xiaochun://speak?audioUrl=\u2026[&text=\u2026]' },\n { xc: 'xc.audio', note: 'source \u4E3A ArrayBuffer / Blob', action: 'audio', url: null },\n { xc: 'xc.audio.chunk / xc.audio.end', note: '\u6D41\u5F0F PCM', action: 'audio', url: null },\n] as const;\n\nexport type XcErrorCode =\n | 'unsupported' // \u547D\u4EE4/\u53C2\u6570\u5B58\u5728\u4F46\u5E95\u5C42\u6682\u65E0\u80FD\u529B\n | 'bad_request' // \u53C2\u6570\u7F3A\u5931/\u975E\u6CD5\n | 'not_ready' // \u6A21\u578B\u5C1A\u672A\u52A0\u8F7D\u5B8C\u6210\n | 'origin_denied' // \u63E1\u624B\u6765\u6E90\u4E0D\u5728\u767D\u540D\u5355\n | 'failed'; // \u6267\u884C\u671F\u5F02\u5E38\n\nexport type XcLang = 'zh-CN' | 'en' | 'ja';\nexport type XcPhase = 'idle' | 'loading' | 'thinking' | 'speaking' | 'listening' | 'paused';\n/** lazy = \u4E0D\u9884\u70ED WebLLM / EMAGE, \u9996\u6B21\u4E92\u52A8\u518D\u52A0\u8F7D (\u9ED8\u8BA4); eager = \u7ACB\u5373\u9884\u70ED\u3002 */\nexport type XcHeavyMode = 'lazy' | 'eager';\n\nexport interface XcEnvelope<T extends string = string, P = unknown> {\n type: T;\n v: typeof XC_PROTOCOL_VERSION;\n /** \u5BBF\u4E3B\u547D\u4EE4\u53EF\u5E26 id; \u5BF9\u5E94\u7684 xc.error / xc.utterance \u4F1A\u56DE\u5E26\u540C\u4E00\u4E2A id\u3002 */\n id?: string;\n payload: P;\n}\n\n/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u5BBF\u4E3B \u2192 iframe payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nexport interface XcConfig {\n lang?: XcLang;\n /** \u80CC\u666F\u900F\u660E (\u53E0\u5728\u5BBF\u4E3B\u9875\u9762\u4E0A)\u3002 */\n transparent?: boolean;\n /** \u662F\u5426\u663E\u793A iframe \u5185\u7F6E UI (ChatBar)\u3002\u9ED8\u8BA4 false\u3002 */\n ui?: boolean;\n /** \u91CD\u8D44\u6E90\u52A0\u8F7D\u7B56\u7565, \u89C1 XcHeavyMode\u3002 */\n heavy?: XcHeavyMode;\n}\n\nexport interface XcInitPayload {\n /** \u5BBF\u4E3B\u58F0\u660E\u7684\u81EA\u8EAB origin (iframe \u4F1A\u4E0E event.origin \u4EA4\u53C9\u6821\u9A8C)\u3002 */\n hostOrigin: string;\n config?: XcConfig;\n}\n\nexport interface XcSayPayload {\n text: string;\n /**\n * 'speak' (\u9ED8\u8BA4): \u76F4\u63A5 TTS + \u52A8\u4F5C, \u4E0D\u7ECF\u8FC7 LLM\u3002\n * 'chat': \u5F53\u4F5C\u7528\u6237\u8F93\u5165\u8D70 WebLLM / \u81EA\u5B9A\u4E49 provider (\u4F1A\u89E6\u53D1\u5927\u6A21\u578B\u52A0\u8F7D)\u3002\n */\n mode?: 'speak' | 'chat';\n}\n\n/** \u5BBF\u4E3B\u97F3\u9891\u7684\u7F16\u7801: encoded = \u5BB9\u5668\u683C\u5F0F (mp3/wav/ogg/aac/webm\u2026 \u7531\u6D4F\u89C8\u5668 decodeAudioData \u51B3\u5B9A); pcm16 / float32 = \u65E0\u5934\u539F\u59CB PCM (\u5C0F\u7AEF, \u4EA4\u9519)\u3002 */\nexport type XcAudioFormat = 'encoded' | 'pcm16' | 'float32';\n\nexport interface XcAudioOptions {\n /** \u6C14\u6CE1\u91CC\u663E\u793A\u7684\u6587\u5B57 (\u53EF\u9009; \u4E0D\u5F71\u54CD\u97F3\u9891\u4E0E\u52A8\u4F5C)\u3002 */\n text?: string;\n /** \u662F\u5426\u7531 EMAGE \u6839\u636E\u8FD9\u6BB5\u97F3\u9891\u751F\u6210\u5168\u8EAB\u52A8\u4F5C, \u9ED8\u8BA4 true\u3002false = \u53EA\u64AD\u653E\u97F3\u9891 (\u6B64\u65F6\u5B8C\u5168\u4E0D\u52A0\u8F7D EMAGE)\u3002 */\n motion?: boolean;\n /** \u662F\u5426\u9A71\u52A8\u53E3\u578B (\u97F3\u91CF RMS \u2192 'aa'), \u9ED8\u8BA4 true\u3002 */\n lipsync?: boolean;\n}\n\n/**\n * xc.audio \u2014 \u6574\u6BB5\u97F3\u9891: iframe \u5185\u89E3\u7801 \u2192 16 kHz \u2192 EMAGE \u7A97\u53E3\u63A8\u7406 + \u540C\u4E00\u6BB5\u97F3\u9891\u64AD\u653E + \u53E3\u578B, \u64AD\u5B8C\u56DE xc.utterance{phase:'end'}\u3002\n * \u4FE1\u5C01 `id` \u7528\u4E8E\u5173\u8054 xc.utterance / xc.error\u3002\u5927 ArrayBuffer \u8BF7\u653E\u8FDB postMessage \u7684 transfer \u5217\u8868 (SDK \u9ED8\u8BA4\u8FD9\u4E48\u505A)\u3002\n */\nexport interface XcAudioPayload extends XcAudioOptions {\n /** ArrayBuffer / Blob (structured clone) \u6216 URL \u5B57\u7B26\u4E32 (iframe \u5185 fetch, \u4EC5 https \u6216\u540C\u6E90, \u9700 CORS)\u3002 */\n source: ArrayBuffer | Blob | string;\n /** MIME \u63D0\u793A, \u5982 'audio/mpeg'\u3002\u4EC5\u4F5C\u4FE1\u606F, \u89E3\u7801\u4EE5\u6D4F\u89C8\u5668\u55C5\u63A2\u4E3A\u51C6\u3002 */\n mimeType?: string;\n /** \u9ED8\u8BA4 'encoded'\u3002 */\n format?: XcAudioFormat;\n /** \u4EC5\u539F\u59CB PCM \u9700\u8981 (\u9ED8\u8BA4 16000); \u5BB9\u5668\u683C\u5F0F\u5FFD\u7565 (\u4EE5\u6587\u4EF6\u5185\u7684\u91C7\u6837\u7387\u4E3A\u51C6)\u3002\u8303\u56F4 8000~96000\u3002 */\n sampleRate?: number;\n /** \u539F\u59CB PCM \u7684\u58F0\u9053\u6570 (\u4EA4\u9519), 1 \u6216 2, \u9ED8\u8BA4 1; \u5185\u90E8\u4E0B\u6DF7\u4E3A\u5355\u58F0\u9053\u3002 */\n channels?: 1 | 2;\n}\n\n/**\n * xc.audio.chunk \u2014 \u6D41\u5F0F\u97F3\u9891\u5206\u5757 (\u539F\u59CB PCM)\u3002\u4FE1\u5C01 `id` = \u6D41 id: \u540C\u4E00\u4E2A id \u7684\u7B2C\u4E00\u4E2A chunk \u5F00\u542F\u4E00\u6B21\u8BF4\u8BDD (\u56DE xc.utterance start),\n * \u4E4B\u540E\u7684 chunk \u8FFD\u52A0; \u4EE5 xc.audio.end \u6536\u5C3E\u3002\u6BCF\u4E2A chunk \u7684 `data` \u5E94\u653E\u8FDB transfer \u5217\u8868\u3002\n * \u9009\u9879 (text/motion/lipsync) \u53EA\u5728\u7B2C\u4E00\u4E2A chunk \u91CC\u751F\u6548; sampleRate/format \u6574\u6761\u6D41\u5FC5\u987B\u4E00\u81F4\u3002\n */\nexport interface XcAudioChunkPayload extends XcAudioOptions {\n data: ArrayBuffer;\n format: Exclude<XcAudioFormat, 'encoded'>;\n /** 8000~96000\u3002 */\n sampleRate: number;\n channels?: 1 | 2;\n}\n\nexport interface XcAudioEndPayload {\n /** true = \u7ACB\u5373\u6253\u65AD (\u4E22\u5F03\u672A\u64AD\u653E\u7684\u97F3\u9891); \u9ED8\u8BA4 false = \u64AD\u5B8C\u5DF2\u6536\u5230\u7684\u97F3\u9891\u540E\u7ED3\u675F\u3002 */\n abort?: boolean;\n}\n\nexport interface XcMotionPayload {\n /** .vrma URL (https:// \u6216\u540C\u6E90\u8DEF\u5F84)\u3002\u4E0E stop \u4E8C\u9009\u4E00\u3002 */\n url?: string;\n /** \u5185\u7F6E\u52A8\u4F5C\u540D, \u76EE\u524D\u4EC5 'thinking'\u3002 */\n name?: 'thinking';\n stop?: boolean;\n loop?: boolean;\n /** \u6DE1\u5165\u6DE1\u51FA\u79D2\u6570, \u8303\u56F4 0.26~3, \u8C03\u5927\u66F4\u67D4\u548C\u4F46\u54CD\u5E94\u66F4\u6162\u3002 */\n fadeDuration?: number;\n /** \u64AD\u653E\u500D\u901F, \u8303\u56F4 0.25~3\u3002 */\n timeScale?: number;\n mask?: 'all' | 'upperBody';\n}\n\nexport interface XcExpressionPayload {\n name: 'neutral' | 'happy' | 'angry' | 'sad' | 'relaxed' | 'surprised';\n}\n\n/** xc.lookAt \u2014 TODO: \u6682\u4E0D\u652F\u6301, \u6536\u5230\u4F1A\u8FD4\u56DE unsupported\u3002 */\nexport interface XcLookAtPayload {\n /** \u5F52\u4E00\u5316 (-1..1), \u76F8\u5BF9 iframe \u4E2D\u5FC3\u3002 */\n x: number;\n y: number;\n}\n\nexport interface XcPointerPayload {\n /** iframe \u5185 client \u5750\u6807 (px)\u3002SDK \u4F1A\u628A\u5BBF\u4E3B pointer \u4E8B\u4EF6\u6362\u7B97\u597D\u518D\u53D1\u3002 */\n x: number;\n y: number;\n}\n\nexport interface XcSetModelPayload {\n /** \u5B8C\u6574 .vrm / .vrmaddon / .vrmbase URL (\u9700 CORS)\u3002 */\n url?: string;\n /** \u5185\u7F6E\u670D\u88C5 key, \u89C1\u4E3B\u4ED3\u5E93 APP_CONFIG.model.addons, \u5982 'xiaochun_maid'\u3002 */\n outfit?: string;\n name?: string;\n}\n\nexport interface XcMicPayload {\n enabled: boolean;\n}\n\n/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 iframe \u2192 \u5BBF\u4E3B payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nexport interface XcReadyPayload {\n version: string;\n protocol: typeof XC_PROTOCOL_VERSION;\n capabilities: {\n commands: string[];\n unsupported: string[];\n stt: boolean;\n transparent: boolean;\n /** \u5BBF\u4E3B\u97F3\u9891\u80FD\u529B (xc.audio / xc.audio.chunk)\u3002\u65E7\u7248 /embed \u6CA1\u6709\u8FD9\u4E2A\u5B57\u6BB5\u3002 */\n audio?: { formats: XcAudioFormat[]; streaming: boolean; maxSeconds: number };\n /** iframe \u5185 `self.crossOriginIsolated` (true = \u53EF\u7528 SharedArrayBuffer / \u591A\u7EBF\u7A0B wasm)\u3002\u65E7\u7248 /embed \u6CA1\u6709\u8FD9\u4E2A\u5B57\u6BB5\u3002\u4EC5\u8BCA\u65AD\u7528\u3002 */\n crossOriginIsolated?: boolean;\n };\n}\n\nexport interface XcLoadProgressPayload {\n phase: 'model';\n /** 0~100 */\n progress: number;\n}\n\nexport interface XcLoadedPayload {\n model: string;\n}\n\nexport interface XcStatePayload {\n phase: XcPhase;\n paused: boolean;\n heavy: XcHeavyMode;\n}\n\nexport type XcSttPayload =\n | { kind: 'state'; state: 'idle' | 'loading' | 'listening' | 'recognizing' | 'error' }\n | { kind: 'progress'; percent: number }\n | { kind: 'text'; text: string };\n\nexport interface XcUtterancePayload {\n phase: 'start' | 'end';\n text: string;\n /** 'text' = xc.say (TTS); 'audio' = xc.audio / xc.audio.chunk (\u5BBF\u4E3B\u97F3\u9891, \u6CA1\u6709\u8D70 TTS)\u3002\u65E7\u7248 /embed \u6CA1\u6709\u8FD9\u4E2A\u5B57\u6BB5\u3002 */\n kind?: 'text' | 'audio';\n}\n\nexport interface XcHitRegionPayload {\n hit: boolean;\n x: number;\n y: number;\n}\n\nexport interface XcErrorPayload {\n code: XcErrorCode;\n message: string;\n /** \u89E6\u53D1\u9519\u8BEF\u7684\u547D\u4EE4\u540D (\u5982 'xc.lookAt')\u3002 */\n command?: string;\n}\n\n/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u7C7B\u578B\u6620\u5C04 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nexport interface XcHostPayloadMap {\n 'xc.init': XcInitPayload;\n 'xc.say': XcSayPayload;\n 'xc.audio': XcAudioPayload;\n 'xc.audio.chunk': XcAudioChunkPayload;\n 'xc.audio.end': XcAudioEndPayload;\n 'xc.motion': XcMotionPayload;\n 'xc.expression': XcExpressionPayload;\n 'xc.lookAt': XcLookAtPayload;\n 'xc.pointer': XcPointerPayload;\n 'xc.setModel': XcSetModelPayload;\n 'xc.setConfig': XcConfig;\n 'xc.mic': XcMicPayload;\n 'xc.pause': undefined;\n 'xc.resume': undefined;\n 'xc.destroy': undefined;\n}\n\nexport interface XcFramePayloadMap {\n 'xc.ready': XcReadyPayload;\n 'xc.load.progress': XcLoadProgressPayload;\n 'xc.loaded': XcLoadedPayload;\n 'xc.state': XcStatePayload;\n 'xc.stt': XcSttPayload;\n 'xc.utterance': XcUtterancePayload;\n 'xc.hit-region': XcHitRegionPayload;\n 'xc.error': XcErrorPayload;\n}\n\nexport type XcHostMessage = {\n [K in XcHostMessageType]: XcEnvelope<K, XcHostPayloadMap[K]>;\n}[XcHostMessageType];\n\nexport type XcFrameMessage = {\n [K in XcFrameMessageType]: XcEnvelope<K, XcFramePayloadMap[K]>;\n}[XcFrameMessageType];\n\n/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u5C0F\u5DE5\u5177 (SDK \u4E0E /embed \u5171\u7528) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nexport function xcMessage<K extends XcHostMessageType | XcFrameMessageType>(\n type: K,\n payload?: unknown,\n id?: string,\n): XcEnvelope<K, any> {\n const m: XcEnvelope<K, any> = { type, v: XC_PROTOCOL_VERSION, payload };\n if (id !== undefined) m.id = id;\n return m;\n}\n\n/** \u662F\u5426\u4E3A\u5408\u6CD5 xc \u4FE1\u5C01 (\u53EA\u68C0\u67E5\u7ED3\u6784, \u4E0D\u68C0\u67E5 payload)\u3002 */\nexport function isXcEnvelope(data: unknown): data is XcEnvelope {\n if (!data || typeof data !== 'object') return false;\n const d = data as Record<string, unknown>;\n return typeof d.type === 'string' && d.type.startsWith('xc.') && d.v === XC_PROTOCOL_VERSION;\n}\n\n/**\n * \u628A\u7528\u6237\u7ED9\u7684 origin \u89C4\u6574\u6210\u4E25\u683C\u7684 `scheme://host[:port]`\u3002\n * \u62D2\u7EDD '*' / \u901A\u914D / \u975E http(s) / \u5E26\u8DEF\u5F84\u7684\u5B57\u7B26\u4E32 \u2192 \u8FD4\u56DE null\u3002\n */\nexport function normalizeOrigin(input: string | null | undefined): string | null {\n if (!input || input === '*' || input === 'null') return null;\n try {\n const u = new URL(input);\n if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;\n return u.origin;\n } catch {\n return null;\n }\n}\n"],
5
+ "mappings": ";;;AAaO,IAAM,sBAAsB;AAG5B,IAAM,oBAAoB;AAC1B,IAAM,iBAAiB,GAAG,iBAAiB;AAG3C,IAAM,mBAAmB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,IAAM,mBAAmB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAMO,IAAM,0BAA0B;AAAA,EACrC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAOO,IAAM,0BAA0B,CAAC,WAAW;AAO5C,IAAM,sBAAsB;AAAA,EACjC,EAAE,IAAI,UAAU,MAAM,+BAAqB,QAAQ,SAAS,KAAK,+BAA0B;AAAA,EAC3F,EAAE,IAAI,UAAU,MAAM,4BAAuB,QAAQ,MAAM,KAAK,KAAK;AAAA,EACrE,EAAE,IAAI,YAAY,MAAM,wCAAoB,QAAQ,SAAS,KAAK,iDAAuC;AAAA,EACzG,EAAE,IAAI,YAAY,MAAM,oCAA+B,QAAQ,SAAS,KAAK,KAAK;AAAA,EAClF,EAAE,IAAI,iCAAiC,MAAM,oBAAU,QAAQ,SAAS,KAAK,KAAK;AACpF;AA8OO,SAAS,UACd,MACA,SACA,IACoB;AACpB,QAAM,IAAwB,EAAE,MAAM,GAAG,qBAAqB,QAAQ;AACtE,MAAI,OAAO,OAAW,GAAE,KAAK;AAC7B,SAAO;AACT;AAGO,SAAS,aAAa,MAAmC;AAC9D,MAAI,CAAC,QAAQ,OAAO,SAAS,SAAU,QAAO;AAC9C,QAAM,IAAI;AACV,SAAO,OAAO,EAAE,SAAS,YAAY,EAAE,KAAK,WAAW,KAAK,KAAK,EAAE,MAAM;AAC3E;AAMO,SAAS,gBAAgB,OAAiD;AAC/E,MAAI,CAAC,SAAS,UAAU,OAAO,UAAU,OAAQ,QAAO;AACxD,MAAI;AACF,UAAM,IAAI,IAAI,IAAI,KAAK;AACvB,QAAI,EAAE,aAAa,YAAY,EAAE,aAAa,QAAS,QAAO;AAC9D,WAAO,EAAE;AAAA,EACX,QAAQ;AACN,WAAO;AAAA,EACT;AACF;",
6
6
  "names": []
7
7
  }