diff --git a/README.md b/README.md index 7dc89d4e0b..9807e26680 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,13 @@ > Heavily inspired by [Neuro-sama](https://www.youtube.com/@Neurosama) > [!TIP] -> On Windows, you can also install AIRI with [Scoop](https://scoop.sh/): +> On Windows, you can also install AIRI with [winget](https://learn.microsoft.com/windows/package-manager/winget/): +> +> ```powershell +> winget install MoeruAI.AIRI +> ``` +> +> Or install AIRI with [Scoop](https://scoop.sh/): > > ```powershell > scoop bucket add airi https://github.com/moeru-ai/airi diff --git a/apps/stage-pocket/vite.config.ts b/apps/stage-pocket/vite.config.ts index 5b2baa86ae..9305131022 100644 --- a/apps/stage-pocket/vite.config.ts +++ b/apps/stage-pocket/vite.config.ts @@ -20,10 +20,11 @@ import VueMacros from 'vue-macros/vite' import VueRouter from 'vue-router/vite' import { tryCatch } from '@moeru/std' -import { Download } from '@proj-airi/unplugin-fetch/vite' import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk/vite' import { defineConfig } from 'vite' +import { downloadWithRetry } from '../stage-tamagotchi/build/downloadWithRetry' + // import { isEnvTruthy } from '@proj-airi/stage-shared' function isEnvTruthy(value: string | undefined | null): boolean { if (value == null) @@ -169,10 +170,10 @@ export default defineConfig({ VueDevTools(), DownloadLive2DSDK(), - Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), + downloadWithRetry('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), + downloadWithRetry('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), + downloadWithRetry('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), + downloadWithRetry('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), ...isEnvTruthy(process.env.VITE_CAP_SYNC_IOS_AFTER_BUILD ?? '') ? [{ diff --git a/apps/stage-tamagotchi/build/downloadWithRetry.ts b/apps/stage-tamagotchi/build/downloadWithRetry.ts new file mode 100644 index 0000000000..ebe33b3bce --- /dev/null +++ b/apps/stage-tamagotchi/build/downloadWithRetry.ts @@ -0,0 +1,119 @@ +import type { Plugin, ResolvedConfig } from 'vite' + +import { Buffer } from 'node:buffer' +import { copyFile, mkdir, stat, writeFile } from 'node:fs/promises' +import { isAbsolute, join, resolve } from 'node:path' + +import { createLogger } from 'vite' + +interface DownloadWithRetryOptions { + cacheDir?: string + parentDir?: false | string + retries?: number + retryDelayMs?: number +} + +async function exists(path: string): Promise { + try { + await stat(path) + return true + } + catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return false + } + throw error + } +} + +function resolveOutputDirs(config: ResolvedConfig, options?: DownloadWithRetryOptions) { + const cacheDirOption = options?.cacheDir ?? '.cache' + const parentDirOption = options?.parentDir ?? config.publicDir ?? config.root + + const cacheDir = isAbsolute(cacheDirOption) ? cacheDirOption : resolve(config.root, cacheDirOption) + const parentDir = parentDirOption === false + ? config.root + : isAbsolute(parentDirOption) + ? parentDirOption + : resolve(config.root, parentDirOption) + + return { cacheDir, parentDir } +} + +async function fetchArrayBufferWithRetry(url: string, logger: ReturnType, options?: DownloadWithRetryOptions) { + const retries = Math.max(0, options?.retries ?? 3) + const retryDelayMs = Math.max(0, options?.retryDelayMs ?? 1000) + + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`download_failed:${response.status}:${response.statusText}:${url}`) + } + return await response.arrayBuffer() + } + catch (error) { + if (attempt >= retries) { + throw error + } + + const retryInMs = retryDelayMs * (attempt + 1) + logger.warn(`Download failed for ${url} (attempt ${attempt + 1}/${retries + 1}). Retrying in ${retryInMs}ms.`) + await new Promise(resolveDelay => setTimeout(resolveDelay, retryInMs)) + } + } + + throw new Error(`unreachable_retry_state:${url}`) +} + +/** + * Downloads a remote static asset during Vite config resolution with bounded retries. + * + * Use when: + * - a build depends on a small set of remote demo assets + * - transient CDN/TLS socket resets should not fail CI immediately + * + * Expects: + * - stable destination paths inside the Vite app or shared asset tree + * - remote content can be cached safely between builds + * + * Returns: + * - a Vite plugin that populates cache/output paths before the build proceeds + */ +export function downloadWithRetry(url: string, filename: string, destination: string, options?: DownloadWithRetryOptions): Plugin { + return { + name: `download-with-retry-${filename}`, + async configResolved(config) { + const logger = createLogger() + const { cacheDir, parentDir } = resolveOutputDirs(config, options) + const cachedFile = join(cacheDir, destination, filename) + const outputFile = join(parentDir, destination, filename) + + try { + if (!(await exists(cachedFile))) { + logger.info(`Downloading ${filename}...`) + const stream = await fetchArrayBufferWithRetry(url, logger, options) + await mkdir(join(cacheDir, destination), { recursive: true }) + await writeFile(cachedFile, Buffer.from(stream)) + logger.info(`${filename} downloaded.`) + } + else { + logger.info(`${filename} already exists in cache.`) + } + + if (await exists(outputFile)) { + logger.info(`${filename} already exists in ${parentDir}.`) + return + } + + await mkdir(join(parentDir, destination), { recursive: true }).catch(() => {}) + await copyFile(cachedFile, outputFile) + logger.info(`${filename} copied to ${parentDir}.`) + } + catch (error) { + console.error(error) + throw error + } + }, + } +} diff --git a/apps/stage-tamagotchi/electron.vite.config.ts b/apps/stage-tamagotchi/electron.vite.config.ts index b37474c62d..8f2b649ce6 100644 --- a/apps/stage-tamagotchi/electron.vite.config.ts +++ b/apps/stage-tamagotchi/electron.vite.config.ts @@ -12,10 +12,11 @@ import Layouts from 'vite-plugin-vue-layouts' import VueMacros from 'vue-macros/vite' import VueRouter from 'vue-router/vite' -import { Download } from '@proj-airi/unplugin-fetch' import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk' import { defineConfig } from 'electron-vite' +import { downloadWithRetry } from './build/downloadWithRetry' + const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'assets')) const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache')) @@ -252,10 +253,10 @@ export default defineConfig({ }), DownloadLive2DSDK(), - Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), + downloadWithRetry('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), + downloadWithRetry('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), + downloadWithRetry('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), + downloadWithRetry('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), ], }, }) diff --git a/apps/stage-web/vite.config.ts b/apps/stage-web/vite.config.ts index ba5c4b35d4..e2de995c91 100644 --- a/apps/stage-web/vite.config.ts +++ b/apps/stage-web/vite.config.ts @@ -17,12 +17,13 @@ import VueMacros from 'vue-macros/vite' import VueRouter from 'vue-router/vite' import { tryCatch } from '@moeru/std' -import { Download } from '@proj-airi/unplugin-fetch/vite' import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk/vite' import { LFS, SpaceCard } from 'hfup/vite' import { defineConfig } from 'vite' import { VitePWA } from 'vite-plugin-pwa' +import { downloadWithRetry } from '../stage-tamagotchi/build/downloadWithRetry' + const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'assets')) const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache')) @@ -221,10 +222,10 @@ export default defineConfig({ VueDevTools(), DownloadLive2DSDK(), - Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), - Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), + downloadWithRetry('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), + downloadWithRetry('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), + downloadWithRetry('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), + downloadWithRetry('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), // HuggingFace Spaces LFS({ root: cwd(), extraGlobs: [ diff --git a/docs/README.fr.md b/docs/README.fr.md index f8f9ceb9a4..9ea15ed111 100644 --- a/docs/README.fr.md +++ b/docs/README.fr.md @@ -135,7 +135,13 @@ > Fortement inspiré par [Neuro-sama](https://www.youtube.com/@Neurosama) > [!TIP] -> Sous Windows, vous pouvez également installer AIRI avec [Scoop](https://scoop.sh/) : +> Sous Windows, vous pouvez installer AIRI avec [winget](https://learn.microsoft.com/windows/package-manager/winget/) : +> +> ```powershell +> winget install MoeruAI.AIRI +> ``` +> +> Ou installer AIRI avec [Scoop](https://scoop.sh/) : > > ```powershell > scoop bucket add airi https://github.com/moeru-ai/airi diff --git a/docs/README.ja-JP.md b/docs/README.ja-JP.md index dab0756718..84353ce5e9 100644 --- a/docs/README.ja-JP.md +++ b/docs/README.ja-JP.md @@ -135,7 +135,13 @@ > [Neuro-sama](https://www.youtube.com/@Neurosama) に大きな影響を受けました > [!TIP] -> Windows では、[Scoop](https://scoop.sh/) でも AIRI をインストールできます: +> Windows では、[winget](https://learn.microsoft.com/windows/package-manager/winget/) で AIRI をインストールできます: +> +> ```powershell +> winget install MoeruAI.AIRI +> ``` +> +> または、[Scoop](https://scoop.sh/) で AIRI をインストールできます: > > ```powershell > scoop bucket add airi https://github.com/moeru-ai/airi diff --git a/docs/README.ko-KR.md b/docs/README.ko-KR.md index f4d7d23402..dc76ac0842 100644 --- a/docs/README.ko-KR.md +++ b/docs/README.ko-KR.md @@ -135,7 +135,13 @@ > [Neuro-sama](https://www.youtube.com/@Neurosama)에서 큰 영감을 받았습니다 > [!TIP] -> Windows에서는 [Scoop](https://scoop.sh/)으로도 AIRI를 설치할 수 있습니다: +> Windows에서는 [winget](https://learn.microsoft.com/windows/package-manager/winget/)으로 AIRI를 설치할 수 있습니다: +> +> ```powershell +> winget install MoeruAI.AIRI +> ``` +> +> 또는 [Scoop](https://scoop.sh/)으로 AIRI를 설치할 수 있습니다: > > ```powershell > scoop bucket add airi https://github.com/moeru-ai/airi diff --git a/docs/README.ru-RU.md b/docs/README.ru-RU.md index c1a19eef0b..0f891a85d7 100644 --- a/docs/README.ru-RU.md +++ b/docs/README.ru-RU.md @@ -135,7 +135,13 @@ > Сильно вдохновлено [Neuro-sama](https://www.youtube.com/@Neurosama) > [!TIP] -> В Windows AIRI также можно установить с помощью [Scoop](https://scoop.sh/): +> В Windows AIRI можно установить с помощью [winget](https://learn.microsoft.com/windows/package-manager/winget/): +> +> ```powershell +> winget install MoeruAI.AIRI +> ``` +> +> Или установить AIRI через [Scoop](https://scoop.sh/): > > ```powershell > scoop bucket add airi https://github.com/moeru-ai/airi diff --git a/docs/README.vi.md b/docs/README.vi.md index f8d1b38cc9..36366ccdb5 100644 --- a/docs/README.vi.md +++ b/docs/README.vi.md @@ -135,7 +135,13 @@ > Lấy cảm hứng mạnh mẽ từ [Neuro-sama](https://www.youtube.com/@Neurosama) > [!TIP] -> Trên Windows, bạn cũng có thể cài AIRI bằng [Scoop](https://scoop.sh/): +> Trên Windows, bạn có thể cài AIRI bằng [winget](https://learn.microsoft.com/windows/package-manager/winget/): +> +> ```powershell +> winget install MoeruAI.AIRI +> ``` +> +> Hoặc cài AIRI bằng [Scoop](https://scoop.sh/): > > ```powershell > scoop bucket add airi https://github.com/moeru-ai/airi diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index f69fc9d4fc..8bb18b8ad0 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -135,7 +135,13 @@ > 深受 [Neuro-sama](https://www.youtube.com/@Neurosama) 启发 > [!TIP] -> 在 Windows 上,你也可以使用 [Scoop](https://scoop.sh/) 安装 AIRI: +> 在 Windows 上,你也可以使用 [winget](https://learn.microsoft.com/windows/package-manager/winget/) 安装 AIRI: +> +> ```powershell +> winget install MoeruAI.AIRI +> ``` +> +> 或者使用 [Scoop](https://scoop.sh/) 安装 AIRI: > > ```powershell > scoop bucket add airi https://github.com/moeru-ai/airi diff --git a/packages/stage-ui/src/stores/mods/api/context-bridge-sanitize.test.ts b/packages/stage-ui/src/stores/mods/api/context-bridge-sanitize.test.ts new file mode 100644 index 0000000000..5dc8745c3e --- /dev/null +++ b/packages/stage-ui/src/stores/mods/api/context-bridge-sanitize.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' + +import { sanitizeCloneable } from './context-bridge-sanitize' + +describe('sanitizeCloneable', () => { + it('preserves cloneable bigint values', () => { + const input = { + metadata: { + count: 3n, + nested: [1n, { latest: 5n }], + }, + } + + const sanitized = sanitizeCloneable(input) + const cloned = structuredClone(sanitized) + + expect(cloned).toEqual({ + metadata: { + count: 3n, + nested: [1n, { latest: 5n }], + }, + }) + }) + + it('preserves structured-cloneable built-ins', async () => { + const payload = new Uint8Array([1, 2, 3]) + const buffer = payload.buffer.slice(0) + const blob = new Blob(['hello']) + const input = { + metadata: { + mapping: new Map([['key', 7n]]), + seen: new Set(['a', 'b']), + payload, + buffer, + blob, + }, + } + + const sanitized = sanitizeCloneable(input) + const cloned = structuredClone(sanitized) + + expect(cloned).toEqual({ + metadata: { + mapping: new Map([['key', 7n]]), + seen: new Set(['a', 'b']), + payload, + buffer, + blob, + }, + }) + }) + + it('removes nested non-cloneable values and stays structuredClone-safe', () => { + const input = { + text: 'vision update', + metadata: { + safe: 'ok', + nested: { + window: globalThis, + fn: () => 'bad', + arr: [1, { keep: true, drop: globalThis }], + }, + }, + } + + const sanitized = sanitizeCloneable(input) + const cloned = structuredClone(sanitized) + + expect(cloned).toEqual({ + text: 'vision update', + metadata: { + safe: 'ok', + nested: { + arr: [1, { keep: true }], + }, + }, + }) + }) +}) diff --git a/packages/stage-ui/src/stores/mods/api/context-bridge-sanitize.ts b/packages/stage-ui/src/stores/mods/api/context-bridge-sanitize.ts new file mode 100644 index 0000000000..e69eca9154 --- /dev/null +++ b/packages/stage-ui/src/stores/mods/api/context-bridge-sanitize.ts @@ -0,0 +1,61 @@ +export function sanitizeCloneable(value: unknown, seen = new WeakSet()): unknown { + if (value == null) + return value + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') + return value + if (typeof value === 'symbol' || typeof value === 'function') + return undefined + + const rawValue = value + + if (Array.isArray(rawValue)) { + return rawValue + .map(item => sanitizeCloneable(item, seen)) + .filter(item => item !== undefined) + } + + if (rawValue instanceof Date) + return rawValue.toISOString() + + if (rawValue instanceof RegExp) + return rawValue.toString() + + if (typeof rawValue !== 'object') + return rawValue + + if (seen.has(rawValue)) + return undefined + seen.add(rawValue) + + if (rawValue instanceof Map) { + return new Map( + [...rawValue.entries()] + .map(([key, nestedValue]) => [ + sanitizeCloneable(key, seen), + sanitizeCloneable(nestedValue, seen), + ] as const) + .filter(([key, nestedValue]) => key !== undefined && nestedValue !== undefined), + ) + } + + if (rawValue instanceof Set) { + return new Set( + [...rawValue.values()] + .map(item => sanitizeCloneable(item, seen)) + .filter(item => item !== undefined), + ) + } + + if (ArrayBuffer.isView(rawValue) || rawValue instanceof ArrayBuffer || rawValue instanceof Blob) + return rawValue + + const proto = Object.getPrototypeOf(rawValue) + if (proto !== Object.prototype && proto !== null) + return undefined + + return Object.fromEntries( + Object.entries(rawValue) + .map(([key, nestedValue]) => [key, sanitizeCloneable(nestedValue, seen)] as const) + .filter(([, nestedValue]) => nestedValue !== undefined), + ) +} diff --git a/packages/stage-ui/src/stores/mods/api/context-bridge.contract.browser.test.ts b/packages/stage-ui/src/stores/mods/api/context-bridge.contract.browser.test.ts index 57ccc7f1c1..b4940d0e9f 100644 --- a/packages/stage-ui/src/stores/mods/api/context-bridge.contract.browser.test.ts +++ b/packages/stage-ui/src/stores/mods/api/context-bridge.contract.browser.test.ts @@ -390,6 +390,44 @@ describe('context bridge contract', () => { * @example * Input context updates record store-ingested and stay in chat input payload. */ + it('sanitizes nested non-cloneable context values before broadcasting stream events', async () => { + activeProviderRef.value = 'mock-provider' + activeModelRef.value = 'mock-model' + getProviderInstanceMock.mockResolvedValueOnce({}) + const store = useContextBridgeStore() + await store.initialize() + + const streamMessages = collectChannelMessages(CHAT_STREAM_CHANNEL_NAME) + const unsafeContext = { + contexts: { + vision: [ + { + text: 'vision update', + metadata: { + safe: 'ok', + nested: { window: globalThis, fn: () => 'bad' }, + }, + }, + ], + }, + } + + await emitHooks(beforeComposeHooks, { role: 'user', content: 'hello' }, unsafeContext) + await waitForBroadcastDelivery() + + expect(streamMessages).toHaveLength(1) + expect(streamMessages[0].type).toBe('before-compose') + expect(streamMessages[0].context.contexts.vision[0]).toEqual({ + text: 'vision update', + metadata: { + safe: 'ok', + nested: {}, + }, + }) + + await store.dispose() + }) + it('records core ingest result for input context updates and forwards accepted updates', async () => { chatContextIngestMock.mockReturnValueOnce({ sourceKey: 'weather:station-1', @@ -638,4 +676,43 @@ describe('context bridge contract', () => { await store.dispose() }) + + it('preserves bigint values in broadcast context snapshots', async () => { + const messages = collectChannelMessages<{ + id: string + metadata?: { + source?: { + id?: string + plugin?: { id?: string } + } + } + count?: bigint + nested?: Array<{ total?: bigint }> + }>(CONTEXT_CHANNEL_NAME) + + const store = useContextBridgeStore() + await store.initialize() + + const contextSender = createTestChannel(CONTEXT_CHANNEL_NAME) + contextSender.postMessage(createContextMessage({ + id: 'context-1', + metadata: createMetadata('weather', 'station-1'), + count: 7n, + nested: [{ total: 9n }], + })) + await waitForBroadcastDelivery() + + expect(messages.at(-1)).toMatchObject({ + id: 'context-1', + metadata: { + source: { + id: 'station-1', + }, + }, + count: 7n, + nested: [{ total: 9n }], + }) + + await store.dispose() + }) }) diff --git a/packages/stage-ui/src/stores/mods/api/context-bridge.ts b/packages/stage-ui/src/stores/mods/api/context-bridge.ts index c4b7e86b4c..cd17149ca0 100644 --- a/packages/stage-ui/src/stores/mods/api/context-bridge.ts +++ b/packages/stage-ui/src/stores/mods/api/context-bridge.ts @@ -26,6 +26,7 @@ import { useLlmStreamingControlStore } from '../../llm-streaming-control' import { useConsciousnessStore } from '../../modules/consciousness' import { useProvidersStore } from '../../providers' import { useModsServerChannelStore } from './channel-server' +import { sanitizeCloneable } from './context-bridge-sanitize' export function normalizeContextSnapshot>(contexts: C): C { return { @@ -35,7 +36,9 @@ export function normalizeContextSnapshot [ key, - ctx.map(c => toRaw(c)), + ctx + .map(c => sanitizeCloneable(c)) + .filter((value): value is NonNullable => value !== undefined), ]), ), } diff --git a/vitest.sanitize.config.ts b/vitest.sanitize.config.ts new file mode 100644 index 0000000000..9094d9fb81 --- /dev/null +++ b/vitest.sanitize.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['packages/stage-ui/src/stores/mods/api/context-bridge-sanitize.test.ts'], + }, +})