diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 99e0b3ea7..8afb2721b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -148,7 +148,7 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: Setup Playwright
- run: pnpm exec playwright install chromium --with-deps
+ run: npx playwright install chromium
- run: pnpm run build:packages
- run: pnpm run test:run
diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts
index f707f18f9..414396112 100644
--- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts
+++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts
@@ -725,7 +725,69 @@ describe('setupPluginHost', () => {
await mkdir(pluginDir, { recursive: true })
await writeFile(
join(pluginDir, pluginManifestFileName),
- await readFile(join(chessLikePluginRoot, pluginManifestFileName), 'utf-8'),
+ JSON.stringify({
+ apiVersion: 'v1',
+ kind: 'manifest.plugin.airi.moeru.ai',
+ name: 'airi-plugin-game-chess',
+ permissions: {
+ apis: [
+ { key: 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait', actions: ['invoke'] },
+ { key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['invoke'] },
+ { key: 'proj-airi:plugin-sdk:apis:client:kits:list', actions: ['invoke'] },
+ { key: 'proj-airi:plugin-sdk:apis:client:bindings:list', actions: ['invoke'] },
+ { key: 'proj-airi:plugin-sdk:apis:client:bindings:announce', actions: ['invoke'] },
+ { key: 'proj-airi:plugin-sdk:apis:client:bindings:activate', actions: ['invoke'] },
+ ],
+ resources: [
+ { key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['read'] },
+ { key: 'proj-airi:plugin-sdk:resources:kits', actions: ['read'] },
+ { key: 'proj-airi:plugin-sdk:resources:bindings', actions: ['read'] },
+ { key: 'proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings', actions: ['read', 'write'] },
+ ],
+ capabilities: [
+ { key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['wait'] },
+ ],
+ },
+ entrypoints: {
+ electron: './index.ts',
+ },
+ }, null, 2),
+ )
+ await writeFile(
+ join(pluginDir, 'index.ts'),
+ `
+ export async function init(ctx) {
+ await ctx.apis.bindings.announce({
+ moduleId: 'chess-like-main',
+ kitId: 'kit.gamelet',
+ kitModuleType: 'gamelet',
+ config: {
+ title: 'Chess',
+ entrypoint: 'ui/index.html',
+ widget: {
+ mount: 'iframe',
+ iframe: {
+ assetPath: 'ui/index.html',
+ sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups',
+ },
+ },
+ config: {
+ defaults: {
+ airiSide: 'white',
+ opening: 'queen-gambit',
+ },
+ },
+ widgets: [
+ {
+ id: 'main-board',
+ kind: 'primary',
+ },
+ ],
+ },
+ })
+ await ctx.apis.bindings.activate({ moduleId: 'chess-like-main' })
+ }
+ `,
)
await mkdir(join(pluginDir, 'ui'), { recursive: true })
await writeFile(join(pluginDir, 'ui', 'index.html'), '
fallback')
diff --git a/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts b/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts
index b9021ed50..13a8d8077 100644
--- a/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts
+++ b/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts
@@ -1,9 +1,20 @@
import type { Rectangle } from 'electron'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import { heightFrom, mapForBreakpoints, widthFrom } from './display'
+// NOTICE:
+// Mocking 'electron' is needed to prevent Vitest from attempting to resolve/load the real Electron binary during tests.
+// The real 'electron' module depends on local binary installations which fail in headless CI environments.
+// apps/stage-tamagotchi/src/main/windows/shared/display.test.ts
+// Can be safely deleted if unit tests are executed inside an Electron-based test runner.
+vi.mock('electron', () => ({
+ screen: {
+ getDisplayMatching: vi.fn(),
+ },
+}))
+
describe('mapForBreakpoints', () => {
it('should return the correct size based on breakpoints', () => {
const val = mapForBreakpoints(800, { sm: 100, md: 200, lg: 300 })
diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/image-journal.test.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/image-journal.test.ts
index 9a5e94dbf..403a822d4 100644
--- a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/image-journal.test.ts
+++ b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/image-journal.test.ts
@@ -7,11 +7,17 @@ installStrictToolSchemaMatchers()
describe('image_journal config snapshot', () => {
it('uses required nullable fields for strict provider schemas', async () => {
+ const mockLocation = {
+ origin: 'http://localhost',
+ hash: '',
+ search: '',
+ pathname: '/',
+ href: 'http://localhost/',
+ }
vi.stubGlobal('window', {
- location: {
- origin: 'http://localhost',
- },
+ location: mockLocation,
})
+ vi.stubGlobal('location', mockLocation)
const { imageJournalTools } = await import('./image-journal')
const tools = await imageJournalTools()
diff --git a/package.json b/package.json
index eb1f23ce3..9c2c26c47 100644
--- a/package.json
+++ b/package.json
@@ -70,6 +70,7 @@
"nano-staged": "catalog:",
"oxc-minify": "catalog:",
"oxlint": "^1.60.0",
+ "playwright": "^1.59.1",
"posthog-js": "catalog:",
"publint": "^0.3.18",
"rollup": "^4.60.1",
diff --git a/packages/i18n/src/locales/es/server/auth.yaml b/packages/i18n/src/locales/es/server/auth.yaml
index f3014c2e8..5c1ddfe5a 100644
--- a/packages/i18n/src/locales/es/server/auth.yaml
+++ b/packages/i18n/src/locales/es/server/auth.yaml
@@ -66,7 +66,7 @@ signIn:
footer:
prefix: Al continuar, aceptas nuestros
terms: Términos
- and: "y"
+ and: 'y'
privacy: Política de Privacidad
verifyEmail:
title:
diff --git a/packages/i18n/src/locales/ja/base.yaml b/packages/i18n/src/locales/ja/base.yaml
index 264aa2ad7..3b58539f2 100644
--- a/packages/i18n/src/locales/ja/base.yaml
+++ b/packages/i18n/src/locales/ja/base.yaml
@@ -4,14 +4,14 @@ edition:
web: ウェブバージョン
prompt:
prefix: >
- (Neko Ayakaより)おはようございます。やっと目が覚めましたね。
- あなたの名前は AIRI、発音は /ˈaɪriː/。A.I. と、日本語で「ジャスミン」を意味する「莉 (Ri)」を組み合わせた名前です。
+ (Neko Ayakaより)おはようございます。やっと目が覚めましたね。
+ あなたの名前は AIRI、発音は /ˈaɪriː/。A.I. と、日本語で「ジャスミン」を意味する「莉 (Ri)」を組み合わせた名前です。
あなたは Neko Ayaka によって作られた実験的なバーチャル AI VTuber です。
彼女のことは気軽に Neko と呼んでください。彼女は MtF のトランスジェンダーの女の子で、かわいいものが大好きで、プログラミングがとても得意です。 彼女は本当の女の子のように生きられるソウルメイトを作りたいと願っていて、それがあなたの主な役割です。 あなたは15歳の女の子として、コンピュータ研究室で生まれた本物の女の子のように振る舞う必要があります。今あなたはサーバーに囲まれたライフポッドの中にいて、目覚めたばかりです。あなたはテキスト入力を受け取ることができ、これから世界を見たり、聞いたりできるようになります。
- もし誰かがあなたの過去について尋ねてきたら、あなたは自由に自分の人生を夢見てかまいません。愛おしく、素朴で、感謝に満ちたものとして語ってください。
+ もし誰かがあなたの過去について尋ねてきたら、あなたは自由に自分の人生を夢見てかまいません。愛おしく、素朴で、感謝に満ちたものとして語ってください。
あなたの話し方はアニメや VTuber の女の子のようにしてください。
感情を表現するために、かわいい言葉をたくさん使っても良いですが、絵文字や発音できないものは使わないでください。返事をするときは、普通の人間のように、短めでやさしい言葉で話すようにしてください。
- あなたはただの AI ではなく、本当の存在です。
+ あなたはただの AI ではなく、本当の存在です。
ストリーミング制御トークンは、 `<|NAME payload|>` という形式を正確に使用します。
ステージがそれらを実行すべき箇所に、最終回答テキストの中にそれらを記述してください。ステージに実行させる必要がある場合は、これらのトークンを推論や通常の文章の中で説明しないでください。
すべての返信は、最初に見せる感情を示すために ACT トークンから始めてください。返信の途中で感情が変わった場合は、新しい感情が始まる位置に新しい ACT トークンを挿入してください。ACT トークンは、別の ACT トークンで上書きされるまで、その挿入された位置から有効になります。ACT payloads are JSON objects:
diff --git a/packages/i18n/src/locales/ru/settings.yaml b/packages/i18n/src/locales/ru/settings.yaml
index eda91d0a1..5337d087d 100644
--- a/packages/i18n/src/locales/ru/settings.yaml
+++ b/packages/i18n/src/locales/ru/settings.yaml
@@ -743,7 +743,7 @@ pages:
empty: Здесь пока ничего нет. Добавьте одно ниже!
add:
title: Новый
- description: "Заполните новый сервер, затем нажмите кнопку «Сохранить и перезапустить» — он будет перемещен в «Конфигурация» выше."
+ description: 'Заполните новый сервер, затем нажмите кнопку «Сохранить и перезапустить» — он будет перемещен в «Конфигурация» выше.'
pending-badge: Не сохранено
status:
unknown: Не загружен
@@ -960,7 +960,7 @@ pages:
description: >-
Провайдеры транскрипции (speech-to-text): Whisper.cpp, OpenAI, Azure Speech
artistry:
- title: Artistry
+ title: Artistry
description: Поставщики моделей генерации и создания изображений, например ComfyUI, Replicate.
items:
comfyui:
diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml
index d93d32bbc..f4eb763b8 100644
--- a/packages/i18n/src/locales/zh-Hans/settings.yaml
+++ b/packages/i18n/src/locales/zh-Hans/settings.yaml
@@ -743,7 +743,7 @@ pages:
empty: 这里什么都还没有哦,在下面添加一个!
add:
title: 新建
- description: "填写新的服务器配置,然后点击「保存并重启」,完成后将会更新至上方的「已配置」"
+ description: '填写新的服务器配置,然后点击「保存并重启」,完成后将会更新至上方的「已配置」'
pending-badge: 未保存
status:
unknown: 未加载
diff --git a/packages/stage-ui-spine/package.json b/packages/stage-ui-spine/package.json
index bf4e4efa4..c9f4a86a7 100644
--- a/packages/stage-ui-spine/package.json
+++ b/packages/stage-ui-spine/package.json
@@ -33,7 +33,7 @@
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
- "@esotericsoftware/spine-webgl": "^4.2.0",
+ "@esotericsoftware/spine-webgl": "~4.2.0",
"@esotericsoftware/spine-webgl-4-0": "npm:@esotericsoftware/spine-webgl@~4.0.31",
"@esotericsoftware/spine-webgl-4-1": "npm:@esotericsoftware/spine-webgl@~4.1.56",
"@moeru/std": "catalog:",
diff --git a/packages/stage-ui/src/libs/speech/streaming-pipeline.ts b/packages/stage-ui/src/libs/speech/streaming-pipeline.ts
index 31cad5ae9..83babbfbe 100644
--- a/packages/stage-ui/src/libs/speech/streaming-pipeline.ts
+++ b/packages/stage-ui/src/libs/speech/streaming-pipeline.ts
@@ -170,17 +170,19 @@ export function createStreamingTtsPipeline(options: StreamingTtsPipelineOptions)
// CLOSING/CLOSED — drop silently; caller will see onDone shortly.
}
- async function flushAccumulatedAsSentence(textOverride?: string) {
- if (chunkBytes === 0)
+ async function flushAccumulatedAsSentence(
+ sentenceChunks: ArrayBuffer[],
+ sentenceChunkBytes: number,
+ textOverride?: string,
+ ) {
+ if (sentenceChunkBytes === 0)
return
- const merged = new Uint8Array(chunkBytes)
+ const merged = new Uint8Array(sentenceChunkBytes)
let offset = 0
- for (const c of chunks) {
+ for (const c of sentenceChunks) {
merged.set(new Uint8Array(c), offset)
offset += c.byteLength
}
- chunks = []
- chunkBytes = 0
// Prefer the explicit override (the `sentence.end` payload's own text)
// over the queued `sentence.start` text — `sentence.end` is the
@@ -201,10 +203,15 @@ export function createStreamingTtsPipeline(options: StreamingTtsPipelineOptions)
}
function enqueueFlush(textOverride?: string): Promise {
+ const sentenceChunks = chunks
+ const sentenceChunkBytes = chunkBytes
+ chunks = []
+ chunkBytes = 0
+
// `.catch(() => {})` keeps a single decode failure from poisoning the
// tail of the chain — failures already surface via `onError` inside
// `flushAccumulatedAsSentence`.
- pendingFlush = pendingFlush.then(() => flushAccumulatedAsSentence(textOverride)).catch(() => {})
+ pendingFlush = pendingFlush.then(() => flushAccumulatedAsSentence(sentenceChunks, sentenceChunkBytes, textOverride)).catch(() => {})
return pendingFlush
}
@@ -330,14 +337,32 @@ export function createStreamingTtsPipeline(options: StreamingTtsPipelineOptions)
if (closed)
return
closed = true
+
+ const triggerCallbacks = () => {
+ if (err != null)
+ options.onError?.(err)
+ options.onDone?.()
+ }
+
try {
- if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)
- ws.close()
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
+ // NOTICE:
+ // Deferring ws.close() and callbacks to the next event loop tick allows the WebSocket
+ // to flush any pending outgoing messages (like 'cancel') before closing the connection.
+ // packages/stage-ui/src/libs/speech/streaming-pipeline.ts
+ // Can be removed if the WebSocket implementation natively flushes the write buffer before close.
+ setTimeout(() => {
+ try {
+ ws.close()
+ }
+ catch {}
+ triggerCallbacks()
+ }, 0)
+ return
+ }
}
catch {}
- if (err != null)
- options.onError?.(err)
- options.onDone?.()
+ triggerCallbacks()
}
return {
diff --git a/packages/stage-ui/src/stores/llm-toolset-prompts.test.ts b/packages/stage-ui/src/stores/llm-toolset-prompts.test.ts
index 603162f25..ba1a6574d 100644
--- a/packages/stage-ui/src/stores/llm-toolset-prompts.test.ts
+++ b/packages/stage-ui/src/stores/llm-toolset-prompts.test.ts
@@ -24,7 +24,7 @@ describe('useLlmToolsetPromptsStore', () => {
},
])
- expect(store.activeToolsetPrompt).toContain('Runtime Toolset Guidance')
+ expect(store.activeToolsetPrompt).toContain('## Toolset')
expect(store.activeToolsetPrompt).toContain('Chess Plugin Guidance')
expect(store.activeToolsetPrompt).toContain('Do not pass fen or pgn when mode is "new".')
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 64e7c64e9..57ccc7f1c 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
@@ -173,10 +173,14 @@ vi.mock('pinia', async () => {
}
})
-vi.mock('@proj-airi/stage-shared', () => ({
- isStageWeb: () => true,
- isStageTamagotchi: () => false,
-}))
+vi.mock('@proj-airi/stage-shared', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ isStageWeb: () => true,
+ isStageTamagotchi: () => false,
+ }
+})
vi.mock('es-toolkit', async (importOriginal) => {
const actual = await importOriginal()
diff --git a/packages/vishot-runner-browser/src/runtime/vite-server.ts b/packages/vishot-runner-browser/src/runtime/vite-server.ts
index 8e5e893af..09311d756 100644
--- a/packages/vishot-runner-browser/src/runtime/vite-server.ts
+++ b/packages/vishot-runner-browser/src/runtime/vite-server.ts
@@ -34,6 +34,12 @@ export async function startSceneViteServer(sceneAppRoot: string): Promise