diff --git a/alint.config.ts b/alint.config.ts index fd45f9de1..bce4e4294 100644 --- a/alint.config.ts +++ b/alint.config.ts @@ -6,9 +6,9 @@ export default defineConfig([ { extends: ['js/recommended'], files: ['**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}'], + ignores: ['**/node_modules/**'], plugins: { js: jsPlugin, }, - ignores: ['**/node_modules/**'], }, ]) diff --git a/apps/component-calling/src/main.ts b/apps/component-calling/src/main.ts index 934946054..46b6ff1ec 100644 --- a/apps/component-calling/src/main.ts +++ b/apps/component-calling/src/main.ts @@ -7,7 +7,7 @@ import App from './App.vue' import '@unocss/reset/tailwind.css' import 'uno.css' -const router = createRouter({ routes, history: createWebHashHistory() }) +const router = createRouter({ history: createWebHashHistory(), routes }) createApp(App) .use(router) diff --git a/apps/component-calling/src/plugins/plugin-component-calling-weather/components/index.ts b/apps/component-calling/src/plugins/plugin-component-calling-weather/components/index.ts index 7f6073256..d899ddb27 100644 --- a/apps/component-calling/src/plugins/plugin-component-calling-weather/components/index.ts +++ b/apps/component-calling/src/plugins/plugin-component-calling-weather/components/index.ts @@ -11,12 +11,12 @@ export const weatherComponent = defineCallingComponent( Weather, object({ city: string(), - temperature: string(), condition: string(), + temperature: string(), }), { city: 'Tokyo', - temperature: '25°', condition: 'Sunny', + temperature: '25°', }, ) diff --git a/apps/component-calling/src/plugins/plugin-component-calling/index.ts b/apps/component-calling/src/plugins/plugin-component-calling/index.ts index 1b5a46fc8..f5fff52d8 100644 --- a/apps/component-calling/src/plugins/plugin-component-calling/index.ts +++ b/apps/component-calling/src/plugins/plugin-component-calling/index.ts @@ -6,9 +6,9 @@ import { toJsonSchema } from 'xsschema' export function defineCallingComponent(name: string, component: Component, schema: T, exampleProps?: Record) { return { - name, - schema: toJsonSchema(schema), component: markRaw(component), exampleProps, + name, + schema: toJsonSchema(schema), } } diff --git a/apps/component-calling/uno.config.ts b/apps/component-calling/uno.config.ts index cba9c11c2..2cd5eb2d7 100644 --- a/apps/component-calling/uno.config.ts +++ b/apps/component-calling/uno.config.ts @@ -25,8 +25,8 @@ export default defineConfig({ ...presetWebFontsFonts('fontsource'), }, timeouts: { - warning: 5000, failure: 10000, + warning: 5000, }, }), presetIcons({ @@ -35,14 +35,14 @@ export default defineConfig({ presetChromatic({ baseHue: 240.25, colors: { - primary: 0, complementary: 180, + primary: 0, }, }) as Preset, ], + safelist: 'prose prose-sm m-auto text-left'.split(' '), transformers: [ transformerDirectives(), transformerVariantGroup(), ], - safelist: 'prose prose-sm m-auto text-left'.split(' '), }) diff --git a/apps/component-calling/vite.config.ts b/apps/component-calling/vite.config.ts index 1282fd0cd..1efdc13a8 100644 --- a/apps/component-calling/vite.config.ts +++ b/apps/component-calling/vite.config.ts @@ -9,8 +9,8 @@ import { defineConfig } from 'vite' export default defineConfig({ plugins: [ VueRouter({ - extensions: ['.vue', '.md'], dts: resolve(import.meta.dirname, 'src', 'typed-router.d.ts'), + extensions: ['.vue', '.md'], }), Vue(), // https://github.com/antfu/unocss diff --git a/apps/stage-pocket/capacitor.config.ts b/apps/stage-pocket/capacitor.config.ts index c471e9198..5caa0ceb0 100644 --- a/apps/stage-pocket/capacitor.config.ts +++ b/apps/stage-pocket/capacitor.config.ts @@ -7,25 +7,25 @@ const serverURL = env.CAPACITOR_DEV_SERVER_URL const appId = argv.includes('android') ? 'ai.moeru.airi_pocket' : 'ai.moeru.airi-pocket' const config: CapacitorConfig = { - appId, - appName: 'AIRI', - webDir: 'dist', - server: serverURL - ? { - url: serverURL, - cleartext: false, - } - : undefined, android: { buildOptions: { - keystorePath: env.CAPACITOR_ANDROID_KEYSTORE_PATH, keystoreAlias: env.CAPACITOR_ANDROID_KEYSTORE_ALIAS, - keystorePassword: env.CAPACITOR_ANDROID_KEYSTORE_PASSWORD, keystoreAliasPassword: env.CAPACITOR_ANDROID_KEYSTORE_ALIAS_PASSWORD, + keystorePassword: env.CAPACITOR_ANDROID_KEYSTORE_PASSWORD, + keystorePath: env.CAPACITOR_ANDROID_KEYSTORE_PATH, releaseType: 'APK', signingType: 'apksigner', }, }, + appId, + appName: 'AIRI', + server: serverURL + ? { + cleartext: false, + url: serverURL, + } + : undefined, + webDir: 'dist', } export default config diff --git a/apps/stage-pocket/src/composables/audio-input.ts b/apps/stage-pocket/src/composables/audio-input.ts index 64ac2a549..034fbf0b4 100644 --- a/apps/stage-pocket/src/composables/audio-input.ts +++ b/apps/stage-pocket/src/composables/audio-input.ts @@ -9,7 +9,7 @@ export function useAudioInput() { const audioInputs = computed(() => devices.audioInputs.value) const constraints = ref({ audio: true }) - const media = useUserMedia({ constraints, autoSwitch: true, enabled: false }) + const media = useUserMedia({ autoSwitch: true, constraints, enabled: false }) async function request() { if (devices.permissionGranted.value) { @@ -71,13 +71,13 @@ export function useAudioInput() { } return { - selectedAudioInputId, - selectedAudioInput, audioInputs, + media, + request, + selectedAudioInput, + selectedAudioInputId, start, stop, - request, - media, } } diff --git a/apps/stage-pocket/src/composables/icon-animation.ts b/apps/stage-pocket/src/composables/icon-animation.ts index 3149a8e5b..311d3e211 100644 --- a/apps/stage-pocket/src/composables/icon-animation.ts +++ b/apps/stage-pocket/src/composables/icon-animation.ts @@ -22,8 +22,8 @@ export function useIconAnimation(icon: string) { }) return { + animationIcon, iconAnimationStarted, showIconAnimation, - animationIcon, } } diff --git a/apps/stage-pocket/src/main.ts b/apps/stage-pocket/src/main.ts index de64178b2..e5f49f90d 100644 --- a/apps/stage-pocket/src/main.ts +++ b/apps/stage-pocket/src/main.ts @@ -62,9 +62,9 @@ const routeRecords = setupLayouts(routes as RouteRecordRaw[]) let router: Router if (isEnvTruthy(import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE)) - router = createRouter({ routes: routeRecords, history: createWebHashHistory() }) + router = createRouter({ history: createWebHashHistory(), routes: routeRecords }) else - router = createRouter({ routes: routeRecords, history: createWebHistory() }) + router = createRouter({ history: createWebHistory(), routes: routeRecords }) router.beforeEach((to, from) => { if (to.path !== from.path) diff --git a/apps/stage-pocket/src/modules/i18n.ts b/apps/stage-pocket/src/modules/i18n.ts index 438770ebe..4c451a6e6 100644 --- a/apps/stage-pocket/src/modules/i18n.ts +++ b/apps/stage-pocket/src/modules/i18n.ts @@ -15,8 +15,8 @@ function getLocale() { } export const i18n = createI18n({ + fallbackLocale: 'en', legacy: false, locale: getLocale(), - fallbackLocale: 'en', messages, }) diff --git a/apps/stage-pocket/src/modules/microphone-permission.ts b/apps/stage-pocket/src/modules/microphone-permission.ts index 553dc4822..4851a9e20 100644 --- a/apps/stage-pocket/src/modules/microphone-permission.ts +++ b/apps/stage-pocket/src/modules/microphone-permission.ts @@ -1,12 +1,12 @@ import { registerPlugin } from '@capacitor/core' -interface MicrophonePermissionState { - granted: boolean -} - interface MicrophonePermissionPlugin { checkPermission: () => Promise } +interface MicrophonePermissionState { + granted: boolean +} + /** Reads Android's native microphone permission state without triggering a permission request. */ export const MicrophonePermission = registerPlugin('MicrophonePermission') diff --git a/apps/stage-pocket/src/modules/server-channel-qr-probe.ts b/apps/stage-pocket/src/modules/server-channel-qr-probe.ts index e78da1804..b6be3dc58 100644 --- a/apps/stage-pocket/src/modules/server-channel-qr-probe.ts +++ b/apps/stage-pocket/src/modules/server-channel-qr-probe.ts @@ -21,11 +21,11 @@ export async function probeServerChannelQrPayload(payload: ServerChannelQrPayloa const client = new Client({ autoConnect: false, autoReconnect: false, + connector: createTextProtocolConnector(connector), connectTimeoutMs: 2_000, name: WebSocketEventSource.StageWeb, token: payload.authToken, url, - connector: createTextProtocolConnector(connector), }) try { diff --git a/apps/stage-pocket/src/modules/web-authentication.ts b/apps/stage-pocket/src/modules/web-authentication.ts index d1599d295..b441b08e5 100644 --- a/apps/stage-pocket/src/modules/web-authentication.ts +++ b/apps/stage-pocket/src/modules/web-authentication.ts @@ -5,13 +5,13 @@ interface WebAuthenticationOptions { url: string } -interface WebAuthenticationResult { - callbackUrl?: string -} - interface WebAuthenticationPlugin { authenticate: (options: WebAuthenticationOptions) => Promise } +interface WebAuthenticationResult { + callbackUrl?: string +} + /** Opens an authorization URL with the native system browser session. */ export const WebAuthentication = registerPlugin('WebAuthentication') diff --git a/apps/stage-pocket/src/modules/websocket-bridge.ts b/apps/stage-pocket/src/modules/websocket-bridge.ts index 1590148df..f78910317 100644 --- a/apps/stage-pocket/src/modules/websocket-bridge.ts +++ b/apps/stage-pocket/src/modules/websocket-bridge.ts @@ -1,18 +1,21 @@ import type { ClientConnector, ClientEvents } from '@proj-airi/server-sdk' type HostBridgeCommand - = | { kind: 'connect', id: string, url: string } - | { kind: 'send', id: string, data: string } - | { kind: 'close', id: string, code?: number, reason?: string } + = | { code?: number, id: string, kind: 'close', reason?: string } + | { data: string, id: string, kind: 'send' } + | { id: string, kind: 'connect', url: string } type HostBridgeEvent - = | { kind: 'open', id: string } - | { kind: 'message', id: string, data: string } - | { kind: 'error', id: string, message: string } - | { kind: 'close', id: string, code?: number, reason?: string } + = | { code?: number, id: string, kind: 'close', reason?: string } + | { data: string, id: string, kind: 'message' } + | { id: string, kind: 'error', message: string } + | { id: string, kind: 'open' } declare global { interface Window { + __airiHostBridge?: { + onNativeMessage?: (payload: string) => void + } AiriHostBridge?: { postMessage: (payload: string) => void } @@ -23,38 +26,11 @@ declare global { } } } - __airiHostBridge?: { - onNativeMessage?: (payload: string) => void - } } } const connections = new Map() -function postBridgeMessage(command: HostBridgeCommand) { - if (window.AiriHostBridge) { - window.AiriHostBridge.postMessage(JSON.stringify(command)) - return - } - - if (window.webkit?.messageHandlers?.airiHostBridge) { - window.webkit.messageHandlers.airiHostBridge.postMessage(JSON.stringify(command)) - return - } - - throw new Error('AIRI host websocket bridge is unavailable') -} - -function dispatchNativeEvent(payload: string) { - const event = JSON.parse(payload) as HostBridgeEvent - const connection = connections.get(event.id) - if (!connection) { - return - } - - connection.handleNativeEvent(event) -} - class HostBridgeConnection { readonly id = crypto.randomUUID() private opened = false @@ -69,49 +45,37 @@ class HostBridgeConnection { connections.set(this.id, this) postBridgeMessage({ - kind: 'connect', id: this.id, + kind: 'connect', url: this.url, }) } - send(data: string) { - if (!this.opened) { - return false - } - - postBridgeMessage({ - kind: 'send', - id: this.id, - data, - }) - - return true - } - close(code?: number, reason?: string) { if (this.settled && !this.opened) { return } postBridgeMessage({ - kind: 'close', - id: this.id, code, + id: this.id, + kind: 'close', reason, }) } handleNativeEvent(event: HostBridgeEvent) { switch (event.kind) { - case 'open': - this.opened = true - this.settled = true - this.resolve() - break + case 'close': + connections.delete(this.id) + if (!this.settled) { + this.settled = true + this.reject(createCloseBeforeOpenError(event)) + return + } - case 'message': - this.events.message(event.data) + this.opened = false + this.events.close({ code: event.code, reason: event.reason }) break case 'error': @@ -125,25 +89,31 @@ class HostBridgeConnection { this.events.error(new Error(event.message)) break - case 'close': - connections.delete(this.id) - if (!this.settled) { - this.settled = true - this.reject(createCloseBeforeOpenError(event)) - return - } + case 'message': + this.events.message(event.data) + break - this.opened = false - this.events.close({ code: event.code, reason: event.reason }) + case 'open': + this.opened = true + this.settled = true + this.resolve() break } } -} -function createCloseBeforeOpenError(event: Extract) { - const reason = event.reason ? ` ${event.reason}` : '' - const code = typeof event.code === 'number' ? ` with code ${event.code}` : '' - return new Error(`AIRI host websocket bridge closed before opening${code}.${reason}`) + send(data: string) { + if (!this.opened) { + return false + } + + postBridgeMessage({ + data, + id: this.id, + kind: 'send', + }) + + return true + } } export function getHostWebSocketConnector(url: string): ClientConnector | undefined { @@ -168,10 +138,40 @@ export function getHostWebSocketConnector(url: string): ClientConnector } return { - send: message => activeConnection.send(message), close: (code?: number, reason?: string) => activeConnection.close(code, reason), + send: message => activeConnection.send(message), } }) }, } } + +function createCloseBeforeOpenError(event: Extract) { + const reason = event.reason ? ` ${event.reason}` : '' + const code = typeof event.code === 'number' ? ` with code ${event.code}` : '' + return new Error(`AIRI host websocket bridge closed before opening${code}.${reason}`) +} + +function dispatchNativeEvent(payload: string) { + const event = JSON.parse(payload) as HostBridgeEvent + const connection = connections.get(event.id) + if (!connection) { + return + } + + connection.handleNativeEvent(event) +} + +function postBridgeMessage(command: HostBridgeCommand) { + if (window.AiriHostBridge) { + window.AiriHostBridge.postMessage(JSON.stringify(command)) + return + } + + if (window.webkit?.messageHandlers?.airiHostBridge) { + window.webkit.messageHandlers.airiHostBridge.postMessage(JSON.stringify(command)) + return + } + + throw new Error('AIRI host websocket bridge is unavailable') +} diff --git a/apps/stage-pocket/src/workers/vad/vad.ts b/apps/stage-pocket/src/workers/vad/vad.ts index 1cf21fd3c..1ed26577a 100644 --- a/apps/stage-pocket/src/workers/vad/vad.ts +++ b/apps/stage-pocket/src/workers/vad/vad.ts @@ -7,30 +7,30 @@ import { AutoModel, Tensor } from '@huggingface/transformers' * Voice Activity Detection processor */ export class VAD implements BaseVAD { - private config: BaseVADConfig - private model: PreTrainedModel | undefined - private state: Tensor - private sampleRateTensor: Tensor private buffer: Float32Array private bufferPointer: number = 0 + private config: BaseVADConfig + private eventListeners: Partial[]>> = {} + private inferenceChain: Promise = Promise.resolve() + private isReady: boolean = false private isRecording: boolean = false + private model: PreTrainedModel | undefined private postSpeechSamples: number = 0 private prevBuffers: Float32Array[] = [] - private inferenceChain: Promise = Promise.resolve() - private eventListeners: Partial[]>> = {} - private isReady: boolean = false + private sampleRateTensor: Tensor + private state: Tensor constructor(userConfig: Partial = {}) { // Default configuration const defaultConfig: BaseVADConfig = { - sampleRate: 16000, - speechThreshold: 0.3, exitThreshold: 0.1, - minSilenceDurationMs: 400, - speechPadMs: 80, - minSpeechDurationMs: 250, maxBufferDuration: 30, + minSilenceDurationMs: 400, + minSpeechDurationMs: 250, newBufferSize: 512, + sampleRate: 16000, + speechPadMs: 80, + speechThreshold: 0.3, } this.config = { ...defaultConfig, ...userConfig } @@ -45,7 +45,7 @@ export class VAD implements BaseVAD { */ public async initialize(): Promise { try { - this.emit('status', { type: 'info', message: 'Loading VAD model...' }) + this.emit('status', { message: 'Loading VAD model...', type: 'info' }) this.model = await AutoModel.from_pretrained('onnx-community/silero-vad', { config: { model_type: 'custom' } as any, @@ -53,24 +53,14 @@ export class VAD implements BaseVAD { }) this.isReady = true - this.emit('status', { type: 'info', message: 'VAD model loaded successfully' }) + this.emit('status', { message: 'VAD model loaded successfully', type: 'info' }) } catch (error) { - this.emit('status', { type: 'error', message: `Failed to load VAD model: ${error}` }) + this.emit('status', { message: `Failed to load VAD model: ${error}`, type: 'error' }) throw error } } - /** - * Add event listener - */ - public on(event: K, callback: VADEventCallback): void { - if (!this.eventListeners[event]) { - this.eventListeners[event] = [] - } - this.eventListeners[event]!.push(callback as any) - } - /** * Remove event listener */ @@ -81,14 +71,13 @@ export class VAD implements BaseVAD { } /** - * Emit event + * Add event listener */ - private emit(event: K, data: VADEvents[K]): void { - if (!this.eventListeners[event]) - return - for (const callback of this.eventListeners[event]!) { - callback(data) + public on(event: K, callback: VADEventCallback): void { + if (!this.eventListeners[event]) { + this.eventListeners[event] = [] } + this.eventListeners[event]!.push(callback as any) } /** @@ -144,7 +133,7 @@ export class VAD implements BaseVAD { if (!this.isRecording) { // Speech just started this.emit('speech-start', undefined) - this.emit('status', { type: 'info', message: 'Speech detected' }) + this.emit('status', { message: 'Speech detected', type: 'info' }) } // Update state @@ -170,13 +159,31 @@ export class VAD implements BaseVAD { } } + /** + * Update configuration + */ + public updateConfig(newConfig: Partial): void { + this.config = { ...this.config, ...newConfig } + + // If buffer size changed, create a new buffer + if (newConfig.maxBufferDuration || newConfig.sampleRate) { + this.buffer = new Float32Array(this.config.maxBufferDuration * this.config.sampleRate) + this.bufferPointer = 0 + } + + // Update sample rate tensor if needed + if (newConfig.sampleRate) { + this.sampleRateTensor = new Tensor('int64', [this.config.sampleRate], []) + } + } + /** * Detect speech in an audio buffer */ private async detectSpeech(buffer: Float32Array): Promise { const input = new Tensor('float32', buffer, [1, buffer.length]) - const { stateN, output } = await (this.inferenceChain = this.inferenceChain.then(() => + const { output, stateN } = await (this.inferenceChain = this.inferenceChain.then(() => this.model?.({ input, sr: this.sampleRateTensor, @@ -189,7 +196,7 @@ export class VAD implements BaseVAD { // Get the speech probability const speechProb = output.data[0] - this.emit('debug', { message: 'VAD score', data: { probability: speechProb } }) + this.emit('debug', { data: { probability: speechProb }, message: 'VAD score' }) // Apply thresholds return ( @@ -198,6 +205,17 @@ export class VAD implements BaseVAD { ) } + /** + * Emit event + */ + private emit(event: K, data: VADEvents[K]): void { + if (!this.eventListeners[event]) + return + for (const callback of this.eventListeners[event]!) { + callback(data) + } + } + /** * Process a complete speech segment */ @@ -247,24 +265,6 @@ export class VAD implements BaseVAD { this.postSpeechSamples = 0 this.prevBuffers = [] } - - /** - * Update configuration - */ - public updateConfig(newConfig: Partial): void { - this.config = { ...this.config, ...newConfig } - - // If buffer size changed, create a new buffer - if (newConfig.maxBufferDuration || newConfig.sampleRate) { - this.buffer = new Float32Array(this.config.maxBufferDuration * this.config.sampleRate) - this.bufferPointer = 0 - } - - // Update sample rate tensor if needed - if (newConfig.sampleRate) { - this.sampleRateTensor = new Tensor('int64', [this.config.sampleRate], []) - } - } } /** diff --git a/apps/stage-pocket/uno.config.ts b/apps/stage-pocket/uno.config.ts index 99045aec0..a33582a21 100644 --- a/apps/stage-pocket/uno.config.ts +++ b/apps/stage-pocket/uno.config.ts @@ -11,15 +11,15 @@ export default mergeConfigs([ ...presetWebFontsFonts('fontsource'), }, timeouts: { - warning: 5000, failure: 10000, + warning: 5000, }, }), ], rules: [ ['transition-colors-none', { - 'transition-property': 'color, background-color, border-color, text-color', 'transition-duration': '0s', + 'transition-property': 'color, background-color, border-color, text-color', }], ['pt-safe', { 'padding-top': 'env(safe-area-inset-top)' }], @@ -27,10 +27,10 @@ export default mergeConfigs([ ['pl-safe', { 'padding-left': 'env(safe-area-inset-left)' }], ['pr-safe', { 'padding-right': 'env(safe-area-inset-right)' }], ['p-safe', { - 'padding-top': 'env(safe-area-inset-top)', 'padding-bottom': 'env(safe-area-inset-bottom)', 'padding-left': 'env(safe-area-inset-left)', 'padding-right': 'env(safe-area-inset-right)', + 'padding-top': 'env(safe-area-inset-top)', }], ], shortcuts: [ diff --git a/apps/stage-pocket/vite-env.d.ts b/apps/stage-pocket/vite-env.d.ts index 062516673..29d5c15c3 100644 --- a/apps/stage-pocket/vite-env.d.ts +++ b/apps/stage-pocket/vite-env.d.ts @@ -3,6 +3,6 @@ interface ImportMetaEnv { readonly VITE_APP_TARGET_HUGGINGFACE_SPACE: string - readonly VITE_PLATFORM: 'ios' | 'android' | 'web' + readonly VITE_PLATFORM: 'android' | 'ios' | 'web' // more env variables... } diff --git a/apps/stage-pocket/vite.config-env.d.ts b/apps/stage-pocket/vite.config-env.d.ts index 8c0f41492..cf1722dec 100644 --- a/apps/stage-pocket/vite.config-env.d.ts +++ b/apps/stage-pocket/vite.config-env.d.ts @@ -1,6 +1,6 @@ declare namespace NodeJS { export interface ProcessEnv { - VITE_SKIP_MKCERT?: string VITE_CAP_SYNC_IOS_AFTER_BUILD?: string + VITE_SKIP_MKCERT?: string } } diff --git a/apps/stage-pocket/vite.config.ts b/apps/stage-pocket/vite.config.ts index ba161c690..c01f8c865 100644 --- a/apps/stage-pocket/vite.config.ts +++ b/apps/stage-pocket/vite.config.ts @@ -25,7 +25,7 @@ import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk/vite' import { defineConfig } from 'vite' // import { isEnvTruthy } from '@proj-airi/stage-shared' -function isEnvTruthy(value: string | undefined | null): boolean { +function isEnvTruthy(value: null | string | undefined): boolean { if (value == null) return false @@ -36,6 +36,9 @@ const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'package const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache')) export default defineConfig({ + build: { + sourcemap: true, + }, optimizeDeps: { exclude: [ // Internal Packages @@ -63,46 +66,6 @@ export default defineConfig({ '@framework/model/cubismmoc', ], }, - resolve: { - alias: { - '@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')), - '@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')), - '@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')), - '@proj-airi/stage-layouts': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src')), - '@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')), - '@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')), - }, - }, - server: { - host: '0.0.0.0', - port: 5273, - fs: { - // To mute errors like: - // The request id ".../node_modules/@fontsource/sniglet/files/sniglet-latin-400-normal.woff" is outside of Vite serving allow list. - // - // See: https://vite.dev/config/server-options#server-fs-strict - strict: false, - }, - warmup: { - clientFiles: [ - `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`, - `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src'))}/*.vue`, - `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src'))}/*.vue`, - ], - }, - }, - build: { - sourcemap: true, - }, - worker: { - format: 'es', - rollupOptions: { - output: { - inlineDynamicImports: false, - }, - }, - }, - plugins: [ ...isEnvTruthy(process.env.VITE_SKIP_MKCERT ?? '') ? [] @@ -119,6 +82,7 @@ export default defineConfig({ Yaml(), VueMacros({ + betterDefine: false, plugins: { vue: Vue({ include: [/\.vue$/, /\.md$/], @@ -126,25 +90,24 @@ export default defineConfig({ }), vueJsx: false, }, - betterDefine: false, }), VueRouter({ - extensions: ['.vue', '.md'], dts: resolve(import.meta.dirname, 'src/typed-router.d.ts'), + exclude: ['**/components/**'], + extensions: ['.vue', '.md'], importMode: 'async', routesFolder: [ resolve(import.meta.dirname, 'src', 'pages'), { - src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'), exclude: base => [ ...base, '**/settings/connection/index.vue', '**/settings/modules/beat-sync.vue', ], + src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'), }, ], - exclude: ['**/components/**'], }), // https://github.com/JohnCampionJr/vite-plugin-vue-layouts @@ -161,36 +124,35 @@ export default defineConfig({ // https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n VueI18n({ - runtimeOnly: true, compositionOnly: true, fullInstall: true, + runtimeOnly: true, }), // https://github.com/webfansplz/vite-plugin-vue-devtools 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 }), + Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }), + Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }), + Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }), + Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }), ...isEnvTruthy(process.env.VITE_CAP_SYNC_IOS_AFTER_BUILD ?? '') ? [{ - name: 'proj-airi:capacitor-sync', closeBundle: { - sequential: true, handler() { if (this.meta.watchMode) { execSync('cap sync ios', { stdio: 'inherit' }) } }, + sequential: true, }, + name: 'proj-airi:capacitor-sync', } as PluginOption] : [], { - name: 'proj-airi:defines', config(ctx) { const define: Record = { 'import.meta.env.RUNTIME_ENVIRONMENT': '\'capacitor\'', @@ -204,6 +166,44 @@ export default defineConfig({ return { define } }, + name: 'proj-airi:defines', }, ], + resolve: { + alias: { + '@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')), + '@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')), + '@proj-airi/stage-layouts': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src')), + '@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')), + '@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')), + '@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')), + }, + }, + server: { + fs: { + // To mute errors like: + // The request id ".../node_modules/@fontsource/sniglet/files/sniglet-latin-400-normal.woff" is outside of Vite serving allow list. + // + // See: https://vite.dev/config/server-options#server-fs-strict + strict: false, + }, + host: '0.0.0.0', + port: 5273, + warmup: { + clientFiles: [ + `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`, + `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src'))}/*.vue`, + `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src'))}/*.vue`, + ], + }, + }, + + worker: { + format: 'es', + rollupOptions: { + output: { + inlineDynamicImports: false, + }, + }, + }, }) diff --git a/apps/stage-tamagotchi/electron-builder.config.ts b/apps/stage-tamagotchi/electron-builder.config.ts index 012778b1b..c9df2825c 100644 --- a/apps/stage-tamagotchi/electron-builder.config.ts +++ b/apps/stage-tamagotchi/electron-builder.config.ts @@ -42,11 +42,10 @@ else { export default { appId: 'ai.moeru.airi', - productName: 'AIRI', - directories: { - output: 'dist', - buildResources: 'build', + appImage: { + artifactName: '${productName}-${version}-linux-${arch}.${ext}', }, + asar: true, // // For self-publishing, testing, and distribution after modified the code without access to // // an Apple Developer account, comment and uncomment the following lines. // // Later on when you obtained one, you can set up the necessary certificates and provisioning @@ -63,6 +62,30 @@ export default { // return // } + asarUnpack: [ + '**/*.node', + ], + directories: { + buildResources: 'build', + output: 'dist', + }, + dmg: { + artifactName: '${productName}-${version}-darwin-${arch}.${ext}', + }, + extraMetadata: { + homepage: 'https://airi.moeru.ai/docs/', + license: 'MIT', + main: 'out/main/index.js', + name: 'ai.moeru.airi', + repository: 'https://github.com/moeru-ai/airi', + }, + extraResources: [ + { + filter: ['**/*'], + from: '../../engines/stage-tamagotchi-godot/out/${os}', + to: 'godot-stage', + }, + ], // const appName = context.packager.appInfo.productFilename // await notarize({ // appPath: `${appOutDir}/${appName}.app`, @@ -93,55 +116,45 @@ export default { '!{.env,.env.*,.npmrc,pnpm-lock.yaml}', '!{tsconfig.json}', ], - asar: true, - asarUnpack: [ - '**/*.node', - ], - extraResources: [ - { - from: '../../engines/stage-tamagotchi-godot/out/${os}', - to: 'godot-stage', - filter: ['**/*'], - }, - ], - extraMetadata: { - name: 'ai.moeru.airi', - main: 'out/main/index.js', - homepage: 'https://airi.moeru.ai/docs/', - repository: 'https://github.com/moeru-ai/airi', - license: 'MIT', - }, - win: { + linux: { + artifactName: '${productName}-${version}-linux-${arch}.${ext}', + category: 'Utility', + description: 'AIRI is an AI VTuber/Waifu chatbot supporting Live2D/VRM avatars, featuring human-like interactions and modular stage-based rendering.', executableName: 'airi', - // NOTICE: Keep `channel: 'latest-${arch}'` for architecture-aware updater metadata. - // electron-builder expands `${arch}` at publish-time (for example: `latest-x64`, `latest-arm64`), - // and electron-updater later consumes that expanded channel to resolve platform-specific *.yml files. - // This prevents cross-arch lookups such as arm64 clients reading x64 metadata. + icon: 'build/icons/icon.png', + // NOTICE: Same channel rule as Windows/macOS. Keep `${arch}` to avoid x64/arm64 feed collisions on Linux. publish: { - provider: 'github', - owner: 'moeru-ai', - repo: 'airi', channel: 'latest-${arch}', + owner: 'moeru-ai', + provider: 'github', + repo: 'airi', }, - }, - nsis: { - artifactName: '${productName}-${version}-windows-${arch}-setup.${ext}', - shortcutName: '${productName}', - uninstallDisplayName: '${productName}', - createDesktopShortcut: 'always', - deleteAppDataOnUninstall: true, - oneClick: false, - allowToChangeInstallationDirectory: true, - runAfterFinish: true, + synopsis: 'AI VTuber/Waifu chatbot app inspired by Neuro-sama.', + target: [ + 'deb', + 'rpm', + ], }, mac: { entitlementsInherit: 'build/entitlements.mac.plist', + executableName: 'airi', + extendInfo: { + NSCameraUsageDescription: 'AIRI requires camera access for vision understanding', + NSMicrophoneUsageDescription: 'AIRI requires microphone access for voice interaction', + NSSpeechRecognitionUsageDescription: 'AIRI uses Apple Speech to transcribe voice interactions on this device', + }, + // For self-publishing, testing, and distribution after modified the code without access to + // an Apple Developer account, comment and uncomment the following 4 lines. + // Later on when you obtained one, you can set up the necessary certificates and provisioning + // profiles to enable these security features. + // hardenedRuntime: false, + hardenedRuntime: true, + icon: useIconFormattedMacAppIcon ? 'icon.icon' : 'icon.icns', + // notarize: false, + notarize: true, // NOTICE: Same channel rule as Windows. Keep `${arch}` here so generated metadata resolves // to architecture-specific update feeds on macOS (for example: `latest-x64-mac.yml`, `latest-arm64-mac.yml`). publish: { - provider: 'github', - owner: 'moeru-ai', - repo: 'airi', // NOTICE: `channel: 'latest-${arch}'` matters because electron-builder expands // `${arch}` before it writes any publish metadata, and electron-updater later // reuses that expanded channel string when deciding which `*.yml` file to fetch. @@ -205,48 +218,35 @@ export default { // - Linux x64 -> `latest-x64-linux.yml` // - Linux arm64 -> `latest-arm64-linux-arm64.yml` channel: 'latest-${arch}', - }, - extendInfo: { - NSMicrophoneUsageDescription: 'AIRI requires microphone access for voice interaction', - NSSpeechRecognitionUsageDescription: 'AIRI uses Apple Speech to transcribe voice interactions on this device', - NSCameraUsageDescription: 'AIRI requires camera access for vision understanding', - }, - // For self-publishing, testing, and distribution after modified the code without access to - // an Apple Developer account, comment and uncomment the following 4 lines. - // Later on when you obtained one, you can set up the necessary certificates and provisioning - // profiles to enable these security features. - // hardenedRuntime: false, - hardenedRuntime: true, - // notarize: false, - notarize: true, - executableName: 'airi', - icon: useIconFormattedMacAppIcon ? 'icon.icon' : 'icon.icns', - }, - dmg: { - artifactName: '${productName}-${version}-darwin-${arch}.${ext}', - }, - linux: { - target: [ - 'deb', - 'rpm', - ], - // NOTICE: Same channel rule as Windows/macOS. Keep `${arch}` to avoid x64/arm64 feed collisions on Linux. - publish: { - provider: 'github', owner: 'moeru-ai', + provider: 'github', repo: 'airi', - channel: 'latest-${arch}', }, - category: 'Utility', - synopsis: 'AI VTuber/Waifu chatbot app inspired by Neuro-sama.', - description: 'AIRI is an AI VTuber/Waifu chatbot supporting Live2D/VRM avatars, featuring human-like interactions and modular stage-based rendering.', - executableName: 'airi', - artifactName: '${productName}-${version}-linux-${arch}.${ext}', - icon: 'build/icons/icon.png', - }, - appImage: { - artifactName: '${productName}-${version}-linux-${arch}.${ext}', }, npmRebuild: false, + nsis: { + allowToChangeInstallationDirectory: true, + artifactName: '${productName}-${version}-windows-${arch}-setup.${ext}', + createDesktopShortcut: 'always', + deleteAppDataOnUninstall: true, + oneClick: false, + runAfterFinish: true, + shortcutName: '${productName}', + uninstallDisplayName: '${productName}', + }, + productName: 'AIRI', + win: { + executableName: 'airi', + // NOTICE: Keep `channel: 'latest-${arch}'` for architecture-aware updater metadata. + // electron-builder expands `${arch}` at publish-time (for example: `latest-x64`, `latest-arm64`), + // and electron-updater later consumes that expanded channel to resolve platform-specific *.yml files. + // This prevents cross-arch lookups such as arm64 clients reading x64 metadata. + publish: { + channel: 'latest-${arch}', + owner: 'moeru-ai', + provider: 'github', + repo: 'airi', + }, + }, } satisfies Configuration diff --git a/apps/stage-tamagotchi/electron.vite.config.ts b/apps/stage-tamagotchi/electron.vite.config.ts index 578a60979..1bd71a286 100644 --- a/apps/stage-tamagotchi/electron.vite.config.ts +++ b/apps/stage-tamagotchi/electron.vite.config.ts @@ -73,8 +73,8 @@ export default defineConfig({ resolve: { alias: { '@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')), - '@proj-airi/server-runtime/server': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-runtime', 'src', 'server', 'index.ts')), '@proj-airi/server-runtime': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-runtime', 'src', 'index.ts')), + '@proj-airi/server-runtime/server': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-runtime', 'src', 'server', 'index.ts')), }, }, }, @@ -83,8 +83,8 @@ export default defineConfig({ build: { lib: { entry: { - 'index': resolve(join(import.meta.dirname, 'src', 'preload', 'index.ts')), 'beat-sync': resolve(join(import.meta.dirname, 'src', 'preload', 'beat-sync.ts')), + 'index': resolve(join(import.meta.dirname, 'src', 'preload', 'index.ts')), }, }, }, @@ -100,8 +100,8 @@ export default defineConfig({ build: { rolldownOptions: { input: { - 'main': resolve(join(import.meta.dirname, 'src', 'renderer', 'index.html')), 'beat-sync': resolve(join(import.meta.dirname, 'src', 'renderer', 'beat-sync.html')), + 'main': resolve(join(import.meta.dirname, 'src', 'renderer', 'index.html')), }, }, }, @@ -135,18 +135,102 @@ export default defineConfig({ ], }, + plugins: [ + Info(), + + { + config(ctx) { + const define: Record = { + 'import.meta.env.RUNTIME_ENVIRONMENT': '\'electron\'', + } + if (ctx.mode === 'development') { + define['import.meta.env.URL_MODE'] = '\'server\'' + } + if (ctx.mode === 'production') { + define['import.meta.env.URL_MODE'] = '\'file\'' + } + + return { define } + }, + name: 'proj-airi:defines', + }, + + Inspect(), + + Yaml(), + + VueMacros({ + betterDefine: false, + plugins: { + vue: Vue({ + include: [/\.vue$/, /\.md$/], + ...templateCompilerOptions, + }), + vueJsx: false, + }, + }), + + VueRouter({ + dts: resolve(import.meta.dirname, 'src/renderer/typed-router.d.ts'), + exclude: ['**/components/**'], + routesFolder: [ + { + exclude: base => [ + ...base, + '**/settings/account/index.vue', + '**/settings/connection/index.vue', + '**/settings/data/index.vue', + '**/settings/models/index.vue', + '**/settings/system/general.vue', + '**/settings/modules/mcp.vue', + '**/devtools/index.vue', + '**/settings/index.vue', + ], + src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'), + }, + resolve(import.meta.dirname, 'src', 'renderer', 'pages'), + ], + }), + + VitePluginVueDevTools(), + + // https://github.com/JohnCampionJr/vite-plugin-vue-layouts + Layouts({ + layoutsDirs: [ + resolve(import.meta.dirname, 'src', 'renderer', 'layouts'), + resolve(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src', 'layouts'), + ], + pagesDirs: [resolve(import.meta.dirname, 'src', 'renderer', 'pages')], + }), + + UnoCss(), + + // https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n + VueI18n({ + compositionOnly: true, + fullInstall: true, + runtimeOnly: true, + }), + + DownloadLive2DSDK(), + Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }), + Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }), + Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }), + Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }), + ], + resolve: { alias: { - '@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')), '@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')), + '@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')), + '@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')), + '@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')), + '@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')), // NOTICE: the @proj-airi/stage-ui alias resolves to a directory; rolldown // concatenates sub-paths without a file extension, so bare .ts files at the // stores/ root (e.g. mcp-tool-bridge.ts) are not found. Add explicit aliases // for each such file that the renderer imports from @proj-airi/stage-ui. '@proj-airi/stage-ui/stores/mcp-tool-bridge': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'stores', 'mcp-tool-bridge.ts')), - '@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')), - '@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')), - '@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')), }, }, @@ -174,89 +258,5 @@ export default defineConfig({ }, }, }, - - plugins: [ - Info(), - - { - name: 'proj-airi:defines', - config(ctx) { - const define: Record = { - 'import.meta.env.RUNTIME_ENVIRONMENT': '\'electron\'', - } - if (ctx.mode === 'development') { - define['import.meta.env.URL_MODE'] = '\'server\'' - } - if (ctx.mode === 'production') { - define['import.meta.env.URL_MODE'] = '\'file\'' - } - - return { define } - }, - }, - - Inspect(), - - Yaml(), - - VueMacros({ - plugins: { - vue: Vue({ - include: [/\.vue$/, /\.md$/], - ...templateCompilerOptions, - }), - vueJsx: false, - }, - betterDefine: false, - }), - - VueRouter({ - dts: resolve(import.meta.dirname, 'src/renderer/typed-router.d.ts'), - routesFolder: [ - { - src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'), - exclude: base => [ - ...base, - '**/settings/account/index.vue', - '**/settings/connection/index.vue', - '**/settings/data/index.vue', - '**/settings/models/index.vue', - '**/settings/system/general.vue', - '**/settings/modules/mcp.vue', - '**/devtools/index.vue', - '**/settings/index.vue', - ], - }, - resolve(import.meta.dirname, 'src', 'renderer', 'pages'), - ], - exclude: ['**/components/**'], - }), - - VitePluginVueDevTools(), - - // https://github.com/JohnCampionJr/vite-plugin-vue-layouts - Layouts({ - layoutsDirs: [ - resolve(import.meta.dirname, 'src', 'renderer', 'layouts'), - resolve(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src', 'layouts'), - ], - pagesDirs: [resolve(import.meta.dirname, 'src', 'renderer', 'pages')], - }), - - UnoCss(), - - // https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n - VueI18n({ - runtimeOnly: true, - compositionOnly: true, - fullInstall: true, - }), - - 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 }), - ], }, }) diff --git a/apps/stage-tamagotchi/scripts/artifacts-metadata.ts b/apps/stage-tamagotchi/scripts/artifacts-metadata.ts index f0d0eb43a..976eecf19 100644 --- a/apps/stage-tamagotchi/scripts/artifacts-metadata.ts +++ b/apps/stage-tamagotchi/scripts/artifacts-metadata.ts @@ -50,14 +50,14 @@ async function main() { const args = cli.parse() const argOptions = args.options as { - release: boolean autoTag: boolean - tag: string[] getBundleName: boolean - getProductName: boolean - getVersion: boolean getFilename: string[] getOutputFilename: string[] + getProductName: boolean + getVersion: boolean + release: boolean + tag: string[] } const target = args.args[0] @@ -94,7 +94,7 @@ async function main() { return } if (argOptions.getVersion) { - const version = await getVersion({ release: argOptions.release, autoTag: argOptions.autoTag, tag: argOptions.tag }) + const version = await getVersion({ autoTag: argOptions.autoTag, release: argOptions.release, tag: argOptions.tag }) console.info(version) } } diff --git a/apps/stage-tamagotchi/scripts/desktop-overlay-live-window-smoke.test.ts b/apps/stage-tamagotchi/scripts/desktop-overlay-live-window-smoke.test.ts index aedaa4ae3..7914866a6 100644 --- a/apps/stage-tamagotchi/scripts/desktop-overlay-live-window-smoke.test.ts +++ b/apps/stage-tamagotchi/scripts/desktop-overlay-live-window-smoke.test.ts @@ -10,9 +10,9 @@ afterEach(() => { function createMockSocket() { const socket = new EventEmitter() as EventEmitter & { - send: ReturnType - close: ReturnType addEventListener: (event: string, listener: (...args: any[]) => void) => void + close: ReturnType + send: ReturnType } socket.send = vi.fn() socket.close = vi.fn(() => { diff --git a/apps/stage-tamagotchi/scripts/desktop-overlay-live-window-smoke.ts b/apps/stage-tamagotchi/scripts/desktop-overlay-live-window-smoke.ts index 6ea6623fe..53d00b5ef 100644 --- a/apps/stage-tamagotchi/scripts/desktop-overlay-live-window-smoke.ts +++ b/apps/stage-tamagotchi/scripts/desktop-overlay-live-window-smoke.ts @@ -22,16 +22,24 @@ interface DebugTarget { webSocketDebuggerUrl?: string } -interface McpResult { - content?: unknown[] - structuredContent?: Record - isError?: boolean +interface McpApplyResult { + failed: Array<{ error: string, name: string }> + skipped: Array<{ name: string, reason: string }> + started: Array<{ name: string }> } -interface McpApplyResult { - started: Array<{ name: string }> - failed: Array<{ name: string, error: string }> - skipped: Array<{ name: string, reason: string }> +interface McpResult { + content?: unknown[] + isError?: boolean + structuredContent?: Record +} + +interface McpRuntimeStatus { + servers: Array<{ + lastError?: string + name: string + state: 'error' | 'running' | 'stopped' + }> } interface McpToolDescriptor { @@ -40,14 +48,6 @@ interface McpToolDescriptor { toolName: string } -interface McpRuntimeStatus { - servers: Array<{ - name: string - state: 'running' | 'stopped' | 'error' - lastError?: string - }> -} - const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') const repoDir = resolve(packageDir, '../..') const runId = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-') @@ -79,70 +79,15 @@ const smokeHtml = ` const smokeUrl = `data:text/html;charset=utf-8,${encodeURIComponent(smokeHtml)}` -function assert(condition: boolean, message: string): asserts condition { - if (!condition) - throw new Error(message) -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) -} - -async function findAvailablePort(): Promise { - return await new Promise((resolvePort, reject) => { - const server = createServer() - server.listen(0, '127.0.0.1', () => { - const address = server.address() - server.close(() => { - if (typeof address === 'object' && address?.port) { - resolvePort(address.port) - } - else { - reject(new Error('failed to allocate debug port')) - } - }) - }) - server.on('error', reject) - }) -} - -async function waitFor( - label: string, - probe: () => Promise | T | undefined, - timeoutMs: number, - intervalMs: number, -): Promise { - const start = Date.now() - let lastError: unknown - - while ((Date.now() - start) < timeoutMs) { - try { - const value = await probe() - if (value !== undefined) - return value - } - catch (error) { - lastError = error - } - await sleep(intervalMs) - } - - const suffix = lastError instanceof Error ? `: ${lastError.message}` : '' - throw new Error(`${label} timed out after ${timeoutMs}ms${suffix}`) -} - export class CdpClient { - private socket?: WebSocket private nextId = 1 private pending = new Map) => void reject: (error: Error) => void + resolve: (value: Record) => void }>() + private socket?: WebSocket + constructor(socket: WebSocket) { this.socket = socket this.socket.addEventListener('message', (event) => { @@ -180,24 +125,16 @@ export class CdpClient { return new CdpClient(socket) } - async send(method: string, params?: Record): Promise> { - if (!this.socket) { - throw new Error('CDP socket is closed') - } - - const id = this.nextId++ - const promise = new Promise>((resolveMessage, reject) => { - this.pending.set(id, { resolve: resolveMessage, reject }) - }) - - this.socket.send(JSON.stringify({ id, method, params: params ?? {} })) - return await promise + close() { + this.failPending('CDP socket closed') + this.socket?.close() + this.socket = undefined } async evaluate(expression: string): Promise { const response = await this.send('Runtime.evaluate', { - expression, awaitPromise: true, + expression, returnByValue: true, }) const result = response.result @@ -216,10 +153,18 @@ export class CdpClient { return remoteObject.value as T } - close() { - this.failPending('CDP socket closed') - this.socket?.close() - this.socket = undefined + async send(method: string, params?: Record): Promise> { + if (!this.socket) { + throw new Error('CDP socket is closed') + } + + const id = this.nextId++ + const promise = new Promise>((resolveMessage, reject) => { + this.pending.set(id, { reject, resolve: resolveMessage }) + }) + + this.socket.send(JSON.stringify({ id, method, params: params ?? {} })) + return await promise } private failPending(reason: string) { @@ -234,92 +179,17 @@ export class CdpClient { } } -async function fetchJson(url: string): Promise { - const response = await fetch(url) - if (!response.ok) - throw new Error(`${url} returned ${response.status}`) - return await response.json() as T +function assert(condition: boolean, message: string): asserts condition { + if (!condition) + throw new Error(message) } -async function prepareMcpConfig() { - await mkdir(userDataDir, { recursive: true }) - await mkdir(mcpSessionRoot, { recursive: true }) - - const mcpEnv: Record = { - PATH: env.PATH || '', - HOME: env.HOME || '', - SHELL: env.SHELL || '', - LANG: env.LANG || 'en_US.UTF-8', - TMPDIR: env.TMPDIR || '', - COMPUTER_USE_EXECUTOR: env.COMPUTER_USE_SMOKE_EXECUTOR || env.COMPUTER_USE_EXECUTOR || 'macos-local', - COMPUTER_USE_APPROVAL_MODE: env.COMPUTER_USE_SMOKE_APPROVAL_MODE || env.COMPUTER_USE_APPROVAL_MODE || 'never', - COMPUTER_USE_OPENABLE_APPS: env.COMPUTER_USE_OPENABLE_APPS || 'Terminal,Cursor,Google Chrome', - COMPUTER_USE_SESSION_TAG: `desktop-overlay-live-window-smoke-${runId}`, - COMPUTER_USE_SESSION_ROOT: mcpSessionRoot, +async function callOverlayMcpTool(client: CdpClient, name: string, args: Record = {}): Promise { + const result = await client.evaluate(`window.__AIRI_DESKTOP_OVERLAY_SMOKE__.callMcpTool(${JSON.stringify({ arguments: args, name })})`) + if (result.isError) { + throw new Error(`${name} returned isError=true`) } - - for (const optionalEnvName of ['PNPM_HOME', 'COREPACK_HOME']) { - const value = env[optionalEnvName]?.trim() - if (value) { - mcpEnv[optionalEnvName] = value - } - } - - const config = { - mcpServers: { - computer_use: { - command: 'pnpm', - args: ['-F', '@proj-airi/computer-use-mcp', 'start'], - cwd: repoDir, - enabled: true, - env: mcpEnv, - }, - }, - } - - await writeFile(mcpConfigPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8') -} - -async function ensureSmokePrerequisites() { - if (typeof WebSocket !== 'function') { - throw new TypeError('APP_START_FAILED: WebSocket is unavailable in this Node runtime. Run through the package script or set NODE_OPTIONS=--experimental-websocket.') - } - - const missingOutputs: string[] = [] - for (const relativePath of requiredWorkspaceBuildOutputs) { - try { - await access(resolve(repoDir, relativePath)) - } - catch { - missingOutputs.push(relativePath) - } - } - - if (missingOutputs.length === 0) - return - - throw new Error([ - 'APP_START_FAILED: required workspace build outputs are missing.', - `Missing: ${missingOutputs.join(', ')}`, - 'Build stage-tamagotchi dependencies manually before this smoke. The smoke command does not auto-build them to avoid saturating the local machine.', - 'Suggested command: pnpm -F \'@proj-airi/stage-tamagotchi^...\' --if-present build', - ].join(' ')) -} - -async function waitForRemoteDebug(debugPort: number): Promise { - const version = await waitFor('Electron remote debug endpoint', async () => { - const data = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${debugPort}/json/version`) - return data.webSocketDebuggerUrl - }, 120_000, 500) - - return version -} - -async function findOverlayTarget(debugPort: number): Promise { - return await waitFor('desktop overlay debug target', async () => { - const targets = await fetchJson(`http://127.0.0.1:${debugPort}/json/list`) - return targets.find(target => target.type === 'page' && target.url.includes('#/desktop-overlay')) - }, 120_000, 500) + return result } async function connectOverlayClient(debugPort: number): Promise { @@ -338,14 +208,6 @@ async function connectOverlayClient(debugPort: number): Promise { return client } -async function callOverlayMcpTool(client: CdpClient, name: string, args: Record = {}): Promise { - const result = await client.evaluate(`window.__AIRI_DESKTOP_OVERLAY_SMOKE__.callMcpTool(${JSON.stringify({ name, arguments: args })})`) - if (result.isError) { - throw new Error(`${name} returned isError=true`) - } - return result -} - async function ensureOverlayMcpServerReady(client: CdpClient): Promise { const applyResult = await client.evaluate('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.applyAndRestartMcp()') const failedComputerUse = applyResult.failed.find(item => item.name === 'computer_use') @@ -373,91 +235,68 @@ async function ensureOverlayMcpServerReady(client: CdpClient): Promise { }, 30_000, 500) } -function requireStructuredContent(result: McpResult, label: string): Record { - if (!isRecord(result.structuredContent)) - throw new Error(`${label} missing structuredContent`) - - if (result.structuredContent.status && result.structuredContent.status !== 'ok') - throw new Error(`${label} expected status=ok, got ${String(result.structuredContent.status)}`) - - return result.structuredContent -} - -function requireRunState(result: McpResult, label: string): Record { - const structuredContent = requireStructuredContent(result, label) - if (!isRecord(structuredContent.runState)) - throw new Error(`${label} missing runState`) - return structuredContent.runState -} - -function startStage(debugPort: number, heartbeatLines: string[]): ChildProcessWithoutNullStreams { - const stageProcess = spawn('pnpm', ['-F', '@proj-airi/stage-tamagotchi', 'dev'], { - cwd: repoDir, - detached: true, - env: { - ...env, - APP_REMOTE_DEBUG: 'true', - APP_REMOTE_DEBUG_PORT: String(debugPort), - APP_REMOTE_DEBUG_NO_OPEN: 'true', - APP_USER_DATA_PATH: userDataDir, - AIRI_DESKTOP_OVERLAY: '1', - AIRI_DESKTOP_OVERLAY_POLL_HEARTBEAT: '1', - }, - stdio: 'pipe', - }) - - const stageLogStream = createWriteStream(stageLogPath, { flags: 'a' }) - const capture = (chunk: Buffer) => { - const text = chunk.toString('utf-8') - stageLogStream.write(text) - for (const line of text.split(/\r?\n/u)) { - if (line.includes(desktopOverlayPollHeartbeatMarker)) { - heartbeatLines.push(line) - } - } +async function ensureSmokePrerequisites() { + if (typeof WebSocket !== 'function') { + throw new TypeError('APP_START_FAILED: WebSocket is unavailable in this Node runtime. Run through the package script or set NODE_OPTIONS=--experimental-websocket.') } - stageProcess.stdout.on('data', capture) - stageProcess.stderr.on('data', capture) - stageProcess.on('close', () => stageLogStream.end()) - return stageProcess -} - -async function stopStage(stageProcess: ChildProcessWithoutNullStreams | undefined) { - if (!stageProcess || stageProcess.exitCode !== null) - return - - const signalStageProcessGroup = (signal: NodeJS.Signals) => { + const missingOutputs: string[] = [] + for (const relativePath of requiredWorkspaceBuildOutputs) { try { - if (stageProcess.pid) { - killProcess(-stageProcess.pid, signal) - return - } + await access(resolve(repoDir, relativePath)) } catch { - // Fall back to the pnpm wrapper process if process-group signalling is - // unavailable. The smoke starts a detached group to make this reliable on - // macOS, but the fallback keeps the helper safe on other local setups. + missingOutputs.push(relativePath) } - - stageProcess.kill(signal) } - signalStageProcessGroup('SIGTERM') - await Promise.race([ - new Promise(resolve => stageProcess.once('exit', resolve)), - sleep(5_000).then(() => signalStageProcessGroup('SIGKILL')), - ]) + if (missingOutputs.length === 0) + return + + throw new Error([ + 'APP_START_FAILED: required workspace build outputs are missing.', + `Missing: ${missingOutputs.join(', ')}`, + 'Build stage-tamagotchi dependencies manually before this smoke. The smoke command does not auto-build them to avoid saturating the local machine.', + 'Suggested command: pnpm -F \'@proj-airi/stage-tamagotchi^...\' --if-present build', + ].join(' ')) } -function rejectWhenStageExits(stageProcess: ChildProcessWithoutNullStreams): Promise { - return new Promise((_, reject) => { - stageProcess.once('exit', (code, signal) => { - reject(new Error(`stage-tamagotchi exited with code=${String(code)} signal=${String(signal)}`)) +async function fetchJson(url: string): Promise { + const response = await fetch(url) + if (!response.ok) + throw new Error(`${url} returned ${response.status}`) + return await response.json() as T +} + +async function findAvailablePort(): Promise { + return await new Promise((resolvePort, reject) => { + const server = createServer() + server.listen(0, '127.0.0.1', () => { + const address = server.address() + server.close(() => { + if (typeof address === 'object' && address?.port) { + resolvePort(address.port) + } + else { + reject(new Error('failed to allocate debug port')) + } + }) }) + server.on('error', reject) }) } +async function findOverlayTarget(debugPort: number): Promise { + return await waitFor('desktop overlay debug target', async () => { + const targets = await fetchJson(`http://127.0.0.1:${debugPort}/json/list`) + return targets.find(target => target.type === 'page' && target.url.includes('#/desktop-overlay')) + }, 120_000, 500) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + async function main() { let stageProcess: ChildProcessWithoutNullStreams | undefined let overlayClient: CdpClient | undefined @@ -505,7 +344,7 @@ async function main() { throw new Error(`APP_START_FAILED: ${errorMessageFromValue(error)}`) }) - const readiness = await overlayClient.evaluate<{ state: 'booting' | 'ready' | 'degraded', error?: string }>('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.getReadiness()') + const readiness = await overlayClient.evaluate<{ error?: string, state: 'booting' | 'degraded' | 'ready' }>('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.getReadiness()') if (readiness.state !== 'ready') { throw new Error(`OVERLAY_READINESS_DEGRADED: state=${readiness.state}${readiness.error ? ` error=${readiness.error}` : ''}`) } @@ -521,8 +360,8 @@ async function main() { ) const candidateId = selectDesktopOverlaySmokeCandidateId(preClickRunState) await callOverlayMcpTool(overlayClient, 'computer_use::desktop_click_target', { - candidateId, button: 'left', + candidateId, clickCount: 1, }) const postClickRunState = requireRunState( @@ -544,10 +383,10 @@ async function main() { }) console.info(JSON.stringify({ + heartbeat, ok: true, reportDir, stageLogPath, - heartbeat, }, null, 2)) } finally { @@ -557,6 +396,168 @@ async function main() { } } +async function prepareMcpConfig() { + await mkdir(userDataDir, { recursive: true }) + await mkdir(mcpSessionRoot, { recursive: true }) + + const mcpEnv: Record = { + COMPUTER_USE_APPROVAL_MODE: env.COMPUTER_USE_SMOKE_APPROVAL_MODE || env.COMPUTER_USE_APPROVAL_MODE || 'never', + COMPUTER_USE_EXECUTOR: env.COMPUTER_USE_SMOKE_EXECUTOR || env.COMPUTER_USE_EXECUTOR || 'macos-local', + COMPUTER_USE_OPENABLE_APPS: env.COMPUTER_USE_OPENABLE_APPS || 'Terminal,Cursor,Google Chrome', + COMPUTER_USE_SESSION_ROOT: mcpSessionRoot, + COMPUTER_USE_SESSION_TAG: `desktop-overlay-live-window-smoke-${runId}`, + HOME: env.HOME || '', + LANG: env.LANG || 'en_US.UTF-8', + PATH: env.PATH || '', + SHELL: env.SHELL || '', + TMPDIR: env.TMPDIR || '', + } + + for (const optionalEnvName of ['PNPM_HOME', 'COREPACK_HOME']) { + const value = env[optionalEnvName]?.trim() + if (value) { + mcpEnv[optionalEnvName] = value + } + } + + const config = { + mcpServers: { + computer_use: { + args: ['-F', '@proj-airi/computer-use-mcp', 'start'], + command: 'pnpm', + cwd: repoDir, + enabled: true, + env: mcpEnv, + }, + }, + } + + await writeFile(mcpConfigPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8') +} + +function rejectWhenStageExits(stageProcess: ChildProcessWithoutNullStreams): Promise { + return new Promise((_, reject) => { + stageProcess.once('exit', (code, signal) => { + reject(new Error(`stage-tamagotchi exited with code=${String(code)} signal=${String(signal)}`)) + }) + }) +} + +function requireRunState(result: McpResult, label: string): Record { + const structuredContent = requireStructuredContent(result, label) + if (!isRecord(structuredContent.runState)) + throw new Error(`${label} missing runState`) + return structuredContent.runState +} + +function requireStructuredContent(result: McpResult, label: string): Record { + if (!isRecord(result.structuredContent)) + throw new Error(`${label} missing structuredContent`) + + if (result.structuredContent.status && result.structuredContent.status !== 'ok') + throw new Error(`${label} expected status=ok, got ${String(result.structuredContent.status)}`) + + return result.structuredContent +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function startStage(debugPort: number, heartbeatLines: string[]): ChildProcessWithoutNullStreams { + const stageProcess = spawn('pnpm', ['-F', '@proj-airi/stage-tamagotchi', 'dev'], { + cwd: repoDir, + detached: true, + env: { + ...env, + AIRI_DESKTOP_OVERLAY: '1', + AIRI_DESKTOP_OVERLAY_POLL_HEARTBEAT: '1', + APP_REMOTE_DEBUG: 'true', + APP_REMOTE_DEBUG_NO_OPEN: 'true', + APP_REMOTE_DEBUG_PORT: String(debugPort), + APP_USER_DATA_PATH: userDataDir, + }, + stdio: 'pipe', + }) + + const stageLogStream = createWriteStream(stageLogPath, { flags: 'a' }) + const capture = (chunk: Buffer) => { + const text = chunk.toString('utf-8') + stageLogStream.write(text) + for (const line of text.split(/\r?\n/u)) { + if (line.includes(desktopOverlayPollHeartbeatMarker)) { + heartbeatLines.push(line) + } + } + } + stageProcess.stdout.on('data', capture) + stageProcess.stderr.on('data', capture) + stageProcess.on('close', () => stageLogStream.end()) + + return stageProcess +} + +async function stopStage(stageProcess: ChildProcessWithoutNullStreams | undefined) { + if (!stageProcess || stageProcess.exitCode !== null) + return + + const signalStageProcessGroup = (signal: NodeJS.Signals) => { + try { + if (stageProcess.pid) { + killProcess(-stageProcess.pid, signal) + return + } + } + catch { + // Fall back to the pnpm wrapper process if process-group signalling is + // unavailable. The smoke starts a detached group to make this reliable on + // macOS, but the fallback keeps the helper safe on other local setups. + } + + stageProcess.kill(signal) + } + + signalStageProcessGroup('SIGTERM') + await Promise.race([ + new Promise(resolve => stageProcess.once('exit', resolve)), + sleep(5_000).then(() => signalStageProcessGroup('SIGKILL')), + ]) +} + +async function waitFor( + label: string, + probe: () => Promise | T | undefined, + timeoutMs: number, + intervalMs: number, +): Promise { + const start = Date.now() + let lastError: unknown + + while ((Date.now() - start) < timeoutMs) { + try { + const value = await probe() + if (value !== undefined) + return value + } + catch (error) { + lastError = error + } + await sleep(intervalMs) + } + + const suffix = lastError instanceof Error ? `: ${lastError.message}` : '' + throw new Error(`${label} timed out after ${timeoutMs}ms${suffix}`) +} + +async function waitForRemoteDebug(debugPort: number): Promise { + const version = await waitFor('Electron remote debug endpoint', async () => { + const data = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${debugPort}/json/version`) + return data.webSocketDebuggerUrl + }, 120_000, 500) + + return version +} + if (import.meta.main) { main().catch((error) => { console.error(errorMessageFromValue(error)) diff --git a/apps/stage-tamagotchi/scripts/merge-latest-mac.ts b/apps/stage-tamagotchi/scripts/merge-latest-mac.ts index 0f4dcf8b7..7efe973f3 100644 --- a/apps/stage-tamagotchi/scripts/merge-latest-mac.ts +++ b/apps/stage-tamagotchi/scripts/merge-latest-mac.ts @@ -8,22 +8,22 @@ import { cac } from 'cac' import * as yaml from 'yaml' -interface UpdateInfoFile { - url: string - sha2?: string - sha512?: string - size?: number -} +type Platform = 'arm64' | 'both' | 'none' | 'x64' interface UpdateInfo { + [key: string]: unknown files?: UpdateInfoFile[] path?: string sha2?: string sha512?: string - [key: string]: unknown } -type Platform = 'x64' | 'arm64' | 'both' | 'none' +interface UpdateInfoFile { + sha2?: string + sha512?: string + size?: number + url: string +} const regexpIsLatestMacMetadata = /^latest(?:-[^-]+)?-mac\.yml$/i @@ -51,18 +51,6 @@ export const regexpHasX64 = /(^|[-_/])x64([-.]|$)/i export const regexpIsMacZip = /-mac\.zip$/i export const regexpIsArm64MacZip = /-arm64-mac\.zip$/i -function isArm64MacZip(url: string): boolean { - return regexpIsArm64MacZip.test(url) -} - -function isX64MacZip(url: string): boolean { - return regexpIsMacZip.test(url) && !regexpContainsArm64.test(url) -} - -function getMacZipUrls(updateInfo: UpdateInfo): string[] { - return getUrls(updateInfo).filter(url => regexpIsMacZip.test(url)) -} - function assertContainsMacZip(updateInfo: UpdateInfo, platform: Exclude, filePath: string) { const zipUrls = getMacZipUrls(updateInfo) @@ -85,6 +73,46 @@ function assertMergedContainsBothMacZips(updateInfo: UpdateInfo) { } } +function collectLatestMacFiles(rootDir: string): string[] { + const results: string[] = [] + // eslint-disable-next-line no-console + console.debug('merge-latest-mac: scan context', { + cwd: cwd(), + rootDir, + }) + if (!existsSync(rootDir)) { + console.warn('merge-latest-mac: scan directory missing', rootDir) + return results + } + if (!statSync(rootDir).isDirectory()) { + return results + } + + const entries = readdirSync(rootDir, { withFileTypes: true }) + // eslint-disable-next-line no-console + console.debug('merge-latest-mac: scan directory entries', { + entries: entries.map(entry => ({ + isDirectory: entry.isDirectory(), + isFile: entry.isFile(), + name: entry.name, + })), + rootDir, + }) + + for (const entry of entries) { + const fullPath = resolve(rootDir, entry.name) + if (entry.isDirectory()) { + results.push(...collectLatestMacFiles(fullPath)) + continue + } + if (entry.isFile() && regexpIsLatestMacMetadata.test(entry.name)) { + results.push(fullPath) + } + } + + return results +} + function detectPlatform(updateInfo: UpdateInfo): Platform { const urls = getUrls(updateInfo) @@ -105,69 +133,16 @@ function detectPlatform(updateInfo: UpdateInfo): Platform { return 'none' } -function mergeFiles(arm64: UpdateInfo, x64: UpdateInfo): UpdateInfo { - const arm64Files = Array.isArray(arm64.files) ? arm64.files : [] - const x64Files = Array.isArray(x64.files) ? x64.files : [] - - const byUrl = new Map() - for (const file of [...arm64Files, ...x64Files]) { - if (file?.url) { - byUrl.set(file.url, file) - } - } - - return { - ...arm64, - files: [...byUrl.values()], - path: undefined, - sha2: undefined, - sha512: undefined, - } +function getMacZipUrls(updateInfo: UpdateInfo): string[] { + return getUrls(updateInfo).filter(url => regexpIsMacZip.test(url)) } -async function readUpdateInfo(filePath: string): Promise { - const raw = await readFile(filePath, 'utf8') - return yaml.parse(raw) as UpdateInfo +function isArm64MacZip(url: string): boolean { + return regexpIsArm64MacZip.test(url) } -function collectLatestMacFiles(rootDir: string): string[] { - const results: string[] = [] - // eslint-disable-next-line no-console - console.debug('merge-latest-mac: scan context', { - cwd: cwd(), - rootDir, - }) - if (!existsSync(rootDir)) { - console.warn('merge-latest-mac: scan directory missing', rootDir) - return results - } - if (!statSync(rootDir).isDirectory()) { - return results - } - - const entries = readdirSync(rootDir, { withFileTypes: true }) - // eslint-disable-next-line no-console - console.debug('merge-latest-mac: scan directory entries', { - rootDir, - entries: entries.map(entry => ({ - name: entry.name, - isDirectory: entry.isDirectory(), - isFile: entry.isFile(), - })), - }) - - for (const entry of entries) { - const fullPath = resolve(rootDir, entry.name) - if (entry.isDirectory()) { - results.push(...collectLatestMacFiles(fullPath)) - continue - } - if (entry.isFile() && regexpIsLatestMacMetadata.test(entry.name)) { - results.push(fullPath) - } - } - - return results +function isX64MacZip(url: string): boolean { + return regexpIsMacZip.test(url) && !regexpContainsArm64.test(url) } async function main() { @@ -210,7 +185,7 @@ async function main() { throw new Error('No latest-mac*.yml files found') } - const entries: { filePath: string, updateInfo: UpdateInfo, platform: Platform }[] = [] + const entries: { filePath: string, platform: Platform, updateInfo: UpdateInfo }[] = [] for (const filePath of files) { if (!existsSync(filePath)) { console.warn('merge-latest-mac: missing file', filePath) @@ -224,7 +199,7 @@ async function main() { assertContainsMacZip(updateInfo, platform, filePath) } - entries.push({ filePath, updateInfo, platform }) + entries.push({ filePath, platform, updateInfo }) } if (entries.length === 0) { @@ -264,6 +239,31 @@ async function main() { await writeFile(outputPath, yaml.stringify(merged), 'utf8') } +function mergeFiles(arm64: UpdateInfo, x64: UpdateInfo): UpdateInfo { + const arm64Files = Array.isArray(arm64.files) ? arm64.files : [] + const x64Files = Array.isArray(x64.files) ? x64.files : [] + + const byUrl = new Map() + for (const file of [...arm64Files, ...x64Files]) { + if (file?.url) { + byUrl.set(file.url, file) + } + } + + return { + ...arm64, + files: [...byUrl.values()], + path: undefined, + sha2: undefined, + sha512: undefined, + } +} + +async function readUpdateInfo(filePath: string): Promise { + const raw = await readFile(filePath, 'utf8') + return yaml.parse(raw) as UpdateInfo +} + main().catch((error) => { console.error(error) exit(1) diff --git a/apps/stage-tamagotchi/scripts/regenerate-windows-latest.test.ts b/apps/stage-tamagotchi/scripts/regenerate-windows-latest.test.ts index e840837ba..203e71a70 100644 --- a/apps/stage-tamagotchi/scripts/regenerate-windows-latest.test.ts +++ b/apps/stage-tamagotchi/scripts/regenerate-windows-latest.test.ts @@ -32,12 +32,12 @@ describe('regenerateWindowsLatest', () => { await writeFile(join(workspaceRoot, 'pnpm-workspace.yaml'), 'packages:\n - apps/*\n', 'utf8') await writeFile(join(bundleDir, 'AIRI-1.2.3-windows-x64-setup.exe'), 'signed-binary-content', 'utf8') await writeFile(join(bundleDir, 'latest.yml'), yaml.stringify({ - version: 'stale-version', + files: [{ sha512: 'stale-sha512', size: 10, url: 'stale.exe' }], path: 'stale.exe', - sha512: 'stale-sha512', releaseDate: '2026-01-02T03:04:05.000Z', + sha512: 'stale-sha512', stagingPercentage: 25, - files: [{ url: 'stale.exe', sha512: 'stale-sha512', size: 10 }], + version: 'stale-version', }), 'utf8') process.chdir(packageDir) @@ -50,18 +50,18 @@ describe('regenerateWindowsLatest', () => { const expectedHashes = await hashFile(join(bundleDir, 'AIRI-1.2.3-windows-x64-setup.exe')) expect(nextUpdateInfo).toMatchObject({ - version: '1.2.3', - path: 'AIRI-1.2.3-windows-x64-setup.exe', - sha512: expectedHashes.sha512, - sha2: expectedHashes.sha256, - releaseDate: '2026-01-02T03:04:05.000Z', - stagingPercentage: 25, files: [ { - url: 'AIRI-1.2.3-windows-x64-setup.exe', sha512: expectedHashes.sha512, + url: 'AIRI-1.2.3-windows-x64-setup.exe', }, ], + path: 'AIRI-1.2.3-windows-x64-setup.exe', + releaseDate: '2026-01-02T03:04:05.000Z', + sha2: expectedHashes.sha256, + sha512: expectedHashes.sha512, + stagingPercentage: 25, + version: '1.2.3', }) expect(nextUpdateInfo.files[0]?.size).toBe(Buffer.byteLength('signed-binary-content')) @@ -81,24 +81,24 @@ describe('regenerateWindowsLatest', () => { await regenerateWindowsLatest({ input: 'bundle/AIRI-9.9.9-windows-x64-setup.exe', output: 'bundle/latest.yml', - version: '9.9.9', releaseDate: '2026-03-23T00:00:00.000Z', + version: '9.9.9', }) const persisted = yaml.parse(await readFile(resolve(bundleDir, 'latest.yml'), 'utf8')) const expectedHashes = await hashFile(join(bundleDir, 'AIRI-9.9.9-windows-x64-setup.exe')) expect(persisted).toMatchObject({ - version: '9.9.9', - path: 'AIRI-9.9.9-windows-x64-setup.exe', - sha512: expectedHashes.sha512, - sha2: expectedHashes.sha256, - releaseDate: '2026-03-23T00:00:00.000Z', files: [ { - url: 'AIRI-9.9.9-windows-x64-setup.exe', sha512: expectedHashes.sha512, + url: 'AIRI-9.9.9-windows-x64-setup.exe', }, ], + path: 'AIRI-9.9.9-windows-x64-setup.exe', + releaseDate: '2026-03-23T00:00:00.000Z', + sha2: expectedHashes.sha256, + sha512: expectedHashes.sha512, + version: '9.9.9', }) }) }) diff --git a/apps/stage-tamagotchi/scripts/regenerate-windows-latest.ts b/apps/stage-tamagotchi/scripts/regenerate-windows-latest.ts index 23b595e4a..01fc7e7cd 100644 --- a/apps/stage-tamagotchi/scripts/regenerate-windows-latest.ts +++ b/apps/stage-tamagotchi/scripts/regenerate-windows-latest.ts @@ -9,23 +9,30 @@ import { cac } from 'cac' import * as yaml from 'yaml' +export interface RegenerateWindowsLatestOptions { + input: string + output: string + releaseDate?: string + version: string +} + interface UpdateFileInfo { - url: string sha512: string size?: number + url: string } interface WindowsUpdateInfo { - version: string + [key: string]: unknown files: UpdateFileInfo[] path: string - sha512: string - sha2?: string releaseDate?: string - [key: string]: unknown + sha2?: string + sha512: string + version: string } -export async function hashFile(filePath: string): Promise<{ sha512: string, sha256: string }> { +export async function hashFile(filePath: string): Promise<{ sha256: string, sha512: string }> { return await new Promise((resolveHash, reject) => { const sha512 = createHash('sha512') const sha256 = createHash('sha256') @@ -38,8 +45,8 @@ export async function hashFile(filePath: string): Promise<{ sha512: string, sha2 stream.on('error', reject) stream.on('end', () => { resolveHash({ - sha512: sha512.digest('base64'), sha256: sha256.digest('hex'), + sha512: sha512.digest('base64'), }) }) }) @@ -55,30 +62,6 @@ export async function readExistingUpdateInfo(filePath: string): Promise { - const resolved = resolve(inputPath) - if (existsSync(resolved)) { - return resolved - } - - const workspaceRoot = await findWorkspaceDir(cwd()) - if (workspaceRoot) { - const workspaceResolved = resolve(workspaceRoot, inputPath) - if (existsSync(workspaceResolved)) { - return workspaceResolved - } - } - - return resolved -} - -export interface RegenerateWindowsLatestOptions { - input: string - output: string - version: string - releaseDate?: string -} - export async function regenerateWindowsLatest(options: RegenerateWindowsLatestOptions): Promise { const input = String(options.input || '').trim() const output = String(options.output || '').trim() @@ -98,24 +81,24 @@ export async function regenerateWindowsLatest(options: RegenerateWindowsLatestOp const inputPath = await resolveFromWorkspace(input) const outputPath = await resolveFromWorkspace(output) const fileStats = await stat(inputPath) - const { sha512, sha256 } = await hashFile(inputPath) + const { sha256, sha512 } = await hashFile(inputPath) const existing = await readExistingUpdateInfo(outputPath) const url = basename(inputPath) const nextUpdateInfo: WindowsUpdateInfo = { ...existing, - version, files: [ { - url, sha512, size: fileStats.size, + url, }, ], path: url, - sha512, - sha2: sha256, releaseDate: releaseDate || existing.releaseDate || new Date().toISOString(), + sha2: sha256, + sha512, + version, } await mkdir(dirname(outputPath), { recursive: true }) @@ -124,6 +107,23 @@ export async function regenerateWindowsLatest(options: RegenerateWindowsLatestOp return nextUpdateInfo } +export async function resolveFromWorkspace(inputPath: string): Promise { + const resolved = resolve(inputPath) + if (existsSync(resolved)) { + return resolved + } + + const workspaceRoot = await findWorkspaceDir(cwd()) + if (workspaceRoot) { + const workspaceResolved = resolve(workspaceRoot, inputPath) + if (existsSync(workspaceResolved)) { + return workspaceResolved + } + } + + return resolved +} + async function main() { const cli = cac('regenerate-windows-latest') .option('--input ', 'Signed Windows installer path', { type: [String] }) @@ -141,8 +141,8 @@ async function main() { await regenerateWindowsLatest({ input, output, - version, releaseDate, + version, }) } diff --git a/apps/stage-tamagotchi/scripts/rename-artifacts.ts b/apps/stage-tamagotchi/scripts/rename-artifacts.ts index 3bd71b708..a4863345e 100644 --- a/apps/stage-tamagotchi/scripts/rename-artifacts.ts +++ b/apps/stage-tamagotchi/scripts/rename-artifacts.ts @@ -39,8 +39,8 @@ async function main() { const beforeProductName = productName const argOptions = args.options as { - release: boolean autoTag: boolean + release: boolean tag: string[] } diff --git a/apps/stage-tamagotchi/scripts/update-readme-download-links.ts b/apps/stage-tamagotchi/scripts/update-readme-download-links.ts index 5ff2ffac6..960d1905a 100644 --- a/apps/stage-tamagotchi/scripts/update-readme-download-links.ts +++ b/apps/stage-tamagotchi/scripts/update-readme-download-links.ts @@ -25,7 +25,7 @@ async function main() { } const cleanVersion = version.replace(/^v/, '') - const releaseOptions = { release: true, autoTag: false, tag: [cleanVersion] } + const releaseOptions = { autoTag: false, release: true, tag: [cleanVersion] } const windowsFilenames = await getFilenames('x86_64-pc-windows-msvc', releaseOptions) const macosFilenames = await getFilenames('aarch64-apple-darwin', releaseOptions) diff --git a/apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts b/apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts index e1bf2855b..7751b6c73 100644 --- a/apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts +++ b/apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts @@ -13,7 +13,7 @@ describe('generateManifestFixtures', () => { afterEach(async () => { await Promise.all(roots.map(async (root) => { - await import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true })) + await import('node:fs/promises').then(({ rm }) => rm(root, { force: true, recursive: true })) })) roots.length = 0 }) @@ -31,12 +31,12 @@ describe('generateManifestFixtures', () => { roots.push(root) const result = await generateManifestFixtures({ - rootDir: root, + artifactContent: 'mock-installer-binary', channel: 'stable', + releaseNotes: 'Mock update for AIRI local updater verification.', + rootDir: root, target: 'x86_64-pc-windows-msvc', version: '9.9.9-test.1', - releaseNotes: 'Mock update for AIRI local updater verification.', - artifactContent: 'mock-installer-binary', }) expect(result.channelDir).toBe(join(root, 'stable')) @@ -45,14 +45,14 @@ describe('generateManifestFixtures', () => { const manifest = yaml.parse(await readFile(result.manifestPath, 'utf8')) expect(manifest).toMatchObject({ - version: '9.9.9-test.1', - path: 'AIRI-9.9.9-test.1-windows-x64-setup.exe', - releaseNotes: 'Mock update for AIRI local updater verification.', files: [ { url: 'AIRI-9.9.9-test.1-windows-x64-setup.exe', }, ], + path: 'AIRI-9.9.9-test.1-windows-x64-setup.exe', + releaseNotes: 'Mock update for AIRI local updater verification.', + version: '9.9.9-test.1', }) expect(typeof manifest.sha512).toBe('string') @@ -66,12 +66,12 @@ describe('generateManifestFixtures', () => { roots.push(root) const result = await generateManifestFixtures({ - rootDir: root, + artifactContent: `mock-installer-${channel}`, channel, + releaseNotes: 'Mock update lane fixture', + rootDir: root, target: 'aarch64-apple-darwin', version: '9.9.9-test.2', - releaseNotes: 'Mock update lane fixture', - artifactContent: `mock-installer-${channel}`, }) expect(result.channelDir).toBe(join(root, channel)) diff --git a/apps/stage-tamagotchi/scripts/update-test/generate-manifest.ts b/apps/stage-tamagotchi/scripts/update-test/generate-manifest.ts index 891425d82..2bcb89626 100644 --- a/apps/stage-tamagotchi/scripts/update-test/generate-manifest.ts +++ b/apps/stage-tamagotchi/scripts/update-test/generate-manifest.ts @@ -10,59 +10,24 @@ import * as yaml from 'yaml' import { getFilenames } from '../utils' -export type UpdateTestChannel = 'stable' | 'beta' | 'alpha' | 'nightly' | 'canary' - export interface GenerateManifestFixturesOptions { - rootDir: string + artifactContent?: string channel: UpdateTestChannel + releaseNotes: string + rootDir: string target: string version: string - releaseNotes: string - artifactContent?: string } export interface GenerateManifestFixturesResult { - channelDir: string - manifestPath: string - artifactPath: string - latestFilename: string artifactFilename: string + artifactPath: string + channelDir: string + latestFilename: string + manifestPath: string } -export function resolveLatestFilenameForTarget(target: string) { - switch (target) { - case 'x86_64-pc-windows-msvc': - return 'latest-x64.yml' - case 'x86_64-unknown-linux-gnu': - return 'latest-x64-linux.yml' - case 'aarch64-unknown-linux-gnu': - return 'latest-arm64-linux-arm64.yml' - case 'x86_64-apple-darwin': - return 'latest-x64-mac.yml' - case 'aarch64-apple-darwin': - return 'latest-arm64-mac.yml' - default: - throw new Error(`Unsupported update-test target: ${target}`) - } -} - -function encodeBase64Sha512(content: string) { - return createHash('sha512').update(content).digest('base64') -} - -async function resolveArtifactFilename(target: string, version: string) { - const filenames = await getFilenames(target, { - release: true, - autoTag: false, - tag: [version], - }) - - const artifact = filenames.find(entry => !entry.optional && entry.extension !== 'blockmap') - if (!artifact) - throw new Error(`Unable to determine artifact filename for target: ${target}`) - - return artifact.releaseArtifactFilename -} +export type UpdateTestChannel = 'alpha' | 'beta' | 'canary' | 'nightly' | 'stable' export async function generateManifestFixtures(options: GenerateManifestFixturesOptions): Promise { const channelDir = join(options.rootDir, options.channel) @@ -80,31 +45,52 @@ export async function generateManifestFixtures(options: GenerateManifestFixtures const size = Buffer.byteLength(artifactContent) const manifest = { - version: options.version, files: [ { - url: artifactFilename, sha512, size, + url: artifactFilename, }, ], path: artifactFilename, - sha512, releaseDate, releaseNotes: options.releaseNotes, + sha512, + version: options.version, } await writeFile(manifestPath, yaml.stringify(manifest), 'utf8') return { - channelDir, - manifestPath, - artifactPath, - latestFilename, artifactFilename, + artifactPath, + channelDir, + latestFilename, + manifestPath, } } +export function resolveLatestFilenameForTarget(target: string) { + switch (target) { + case 'aarch64-apple-darwin': + return 'latest-arm64-mac.yml' + case 'aarch64-unknown-linux-gnu': + return 'latest-arm64-linux-arm64.yml' + case 'x86_64-apple-darwin': + return 'latest-x64-mac.yml' + case 'x86_64-pc-windows-msvc': + return 'latest-x64.yml' + case 'x86_64-unknown-linux-gnu': + return 'latest-x64-linux.yml' + default: + throw new Error(`Unsupported update-test target: ${target}`) + } +} + +function encodeBase64Sha512(content: string) { + return createHash('sha512').update(content).digest('base64') +} + async function main() { const cli = cac('generate-update-test-manifest') .option('--root ', 'Root directory for generated server fixtures', { default: 'scripts/update-test/fixtures/server' }) @@ -115,11 +101,11 @@ async function main() { const parsed = cli.parse() const result = await generateManifestFixtures({ - rootDir: String(parsed.options.root), channel: String(parsed.options.channel) as UpdateTestChannel, + releaseNotes: String(parsed.options.releaseNotes), + rootDir: String(parsed.options.root), target: String(parsed.options.target), version: String(parsed.options.version), - releaseNotes: String(parsed.options.releaseNotes), }) // eslint-disable-next-line no-console @@ -128,6 +114,20 @@ async function main() { console.log(`Artifact: ${result.artifactFilename}`) } +async function resolveArtifactFilename(target: string, version: string) { + const filenames = await getFilenames(target, { + autoTag: false, + release: true, + tag: [version], + }) + + const artifact = filenames.find(entry => !entry.optional && entry.extension !== 'blockmap') + if (!artifact) + throw new Error(`Unable to determine artifact filename for target: ${target}`) + + return artifact.releaseArtifactFilename +} + if (import.meta.main) { main().catch((error) => { console.error(error) diff --git a/apps/stage-tamagotchi/scripts/update-test/start-server.ts b/apps/stage-tamagotchi/scripts/update-test/start-server.ts index 8b8d054d4..c909631da 100644 --- a/apps/stage-tamagotchi/scripts/update-test/start-server.ts +++ b/apps/stage-tamagotchi/scripts/update-test/start-server.ts @@ -8,18 +8,14 @@ import { extname, join, normalize } from 'node:path' import { cac } from 'cac' const CONTENT_TYPES: Record = { - '.exe': 'application/vnd.microsoft.portable-executable', - '.yml': 'text/yaml; charset=utf-8', - '.yaml': 'text/yaml; charset=utf-8', - '.zip': 'application/zip', - '.dmg': 'application/octet-stream', '.deb': 'application/vnd.debian.binary-package', + '.dmg': 'application/octet-stream', + '.exe': 'application/vnd.microsoft.portable-executable', '.rpm': 'application/x-rpm', '.txt': 'text/plain; charset=utf-8', -} - -function getContentType(pathname: string) { - return CONTENT_TYPES[extname(pathname)] ?? 'application/octet-stream' + '.yaml': 'text/yaml; charset=utf-8', + '.yml': 'text/yaml; charset=utf-8', + '.zip': 'application/zip', } export async function startUpdateTestServer(options: { port: number, rootDir: string }) { @@ -48,6 +44,10 @@ export async function startUpdateTestServer(options: { port: number, rootDir: st return server } +function getContentType(pathname: string) { + return CONTENT_TYPES[extname(pathname)] ?? 'application/octet-stream' +} + async function main() { const cli = cac('update-test-server') .option('--port ', 'Port to listen on', { default: '8787' }) diff --git a/apps/stage-tamagotchi/scripts/utils.ts b/apps/stage-tamagotchi/scripts/utils.ts index 5e9699b00..adca3da83 100644 --- a/apps/stage-tamagotchi/scripts/utils.ts +++ b/apps/stage-tamagotchi/scripts/utils.ts @@ -6,7 +6,385 @@ import { x } from 'tinyexec' import packageJSON from '../package.json' with { type: 'json' } -export async function getVersion(options: { release: boolean, autoTag: boolean, tag: string[] }) { +interface FilenameOutputEntry { + extension: string + optional?: boolean + outputFilename: string + productName: string + releaseArtifactFilename: string + target: string + version: string +} + +export function applyTemplateOfArtifactName( + template: string, + productName: string, + version: string, + arch: string, + ext: string, +): string { + return template + // eslint-disable-next-line no-template-curly-in-string + .replace('${productName}', productName) + // eslint-disable-next-line no-template-curly-in-string + .replace('${version}', version) + // eslint-disable-next-line no-template-curly-in-string + .replace('${arch}', arch) + // eslint-disable-next-line no-template-curly-in-string + .replace('${ext}', ext) +} + +export async function getElectronBuilderConfig(): Promise { + const config = await import ('../electron-builder.config') + return config.default +} + +export async function getFilenames(target: string, options: { autoTag: boolean, release: boolean, tag: string[] }): Promise { + const electronBuilder = await getElectronBuilderConfig() + const version = await getVersion(options) + + if (!target) { + throw new Error(' is required') + } + + const beforeVersion = packageJSON.version + const productName = electronBuilder.productName! + + switch (target) { + case 'aarch64-apple-darwin': + { + const artifacts: FilenameOutputEntry[] = [ + { + extension: 'dmg', + outputFilename: applyTemplateOfArtifactName( + electronBuilder.dmg!.artifactName!, + productName, + beforeVersion, + mapArchFor(target, 'dmg'), + 'dmg', + ), + productName, + releaseArtifactFilename: applyTemplateOfArtifactName( + electronBuilder.dmg!.artifactName!, + productName, + version, + mapArchFor(target, 'dmg'), + 'dmg', + ), + target: 'aarch64-apple-darwin', + version, + }, + ] + + artifacts.push( + { + extension: 'zip', + outputFilename: getMacZipFilename(productName, beforeVersion, target), + productName, + releaseArtifactFilename: getMacZipFilename(productName, version, target), + target: 'aarch64-apple-darwin', + version, + }, + { + extension: getLatestUpdateFilename(target)!, + optional: true, + outputFilename: getLatestUpdateFilename(target)!, + productName, + releaseArtifactFilename: getLatestUpdateFilename(target)!, + target: 'aarch64-apple-darwin', + version, + }, + ) + + return artifacts + } + case 'aarch64-unknown-linux-gnu': + { + const artifacts: FilenameOutputEntry[] = [] + if (electronBuilder.linux?.artifactName) { + if ( + (Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('deb')) + || electronBuilder.linux.target === 'deb' + ) { + artifacts.push( + { + extension: 'deb', + outputFilename: applyTemplateOfArtifactName( + electronBuilder.linux.artifactName!, + productName, + beforeVersion, + mapArchFor(target, 'deb'), + 'deb', + ), + productName, + releaseArtifactFilename: applyTemplateOfArtifactName( + electronBuilder.linux.artifactName!, + productName, + version, + mapArchFor(target, 'deb'), + 'deb', + ), + target: 'aarch64-unknown-linux-gnu', + version, + }, + ) + } + + if ( + (Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('rpm')) + || electronBuilder.linux.target === 'rpm' + ) { + artifacts.push( + { + extension: 'rpm', + outputFilename: applyTemplateOfArtifactName( + electronBuilder.linux.artifactName!, + productName, + beforeVersion, + mapArchFor(target, 'rpm'), + 'rpm', + ), + productName, + releaseArtifactFilename: applyTemplateOfArtifactName( + electronBuilder.linux.artifactName!, + productName, + version, + mapArchFor(target, 'rpm'), + 'rpm', + ), + target: 'aarch64-unknown-linux-gnu', + version, + }, + ) + } + + // Flatpak artifact (built outside electron-builder, but we follow linux template) + artifacts.push( + { + extension: 'flatpak', + outputFilename: applyTemplateOfArtifactName( + electronBuilder.linux.artifactName!, + productName, + beforeVersion, + mapArchFor(target, 'flatpak'), + 'flatpak', + ), + productName, + releaseArtifactFilename: applyTemplateOfArtifactName( + electronBuilder.linux.artifactName!, + productName, + version, + mapArchFor(target, 'flatpak'), + 'flatpak', + ), + target: 'aarch64-unknown-linux-gnu', + version, + }, + ) + } + + const latestUpdateFilename = getLatestUpdateFilename(target) + if (latestUpdateFilename) { + artifacts.push({ + extension: latestUpdateFilename, + optional: true, + outputFilename: latestUpdateFilename, + productName, + releaseArtifactFilename: latestUpdateFilename, + target: 'aarch64-unknown-linux-gnu', + version, + }) + } + + return artifacts + } + case 'x86_64-apple-darwin': + { + const artifacts: FilenameOutputEntry[] = [ + { + extension: 'dmg', + outputFilename: applyTemplateOfArtifactName( + electronBuilder.dmg!.artifactName!, + productName, + beforeVersion, + mapArchFor(target, 'dmg'), + 'dmg', + ), + productName, + releaseArtifactFilename: applyTemplateOfArtifactName( + electronBuilder.dmg!.artifactName!, + productName, + version, + mapArchFor(target, 'dmg'), + 'dmg', + ), + target: 'x86_64-apple-darwin', + version, + }, + ] + + artifacts.push( + { + extension: 'zip', + outputFilename: getMacZipFilename(productName, beforeVersion, target), + productName, + releaseArtifactFilename: getMacZipFilename(productName, version, target), + target: 'x86_64-apple-darwin', + version, + }, + { + extension: getLatestUpdateFilename(target)!, + optional: true, + outputFilename: getLatestUpdateFilename(target)!, + productName, + releaseArtifactFilename: getLatestUpdateFilename(target)!, + target: 'x86_64-apple-darwin', + version, + }, + ) + + return artifacts + } + case 'x86_64-pc-windows-msvc': + + return [ + { + extension: 'exe', + outputFilename: applyTemplateOfArtifactName( + electronBuilder.nsis!.artifactName!, + productName, + beforeVersion, + mapArchFor(target, 'exe'), + 'exe', + ), + productName, + releaseArtifactFilename: applyTemplateOfArtifactName( + electronBuilder.nsis!.artifactName!, + productName, + version, + mapArchFor(target, 'exe'), + 'exe', + ), + target: 'x86_64-pc-windows-msvc', + version, + }, + { + extension: getLatestUpdateFilename(target)!, + optional: true, + outputFilename: getLatestUpdateFilename(target)!, + productName, + releaseArtifactFilename: getLatestUpdateFilename(target)!, + target: 'x86_64-pc-windows-msvc', + version, + }, + ] + case 'x86_64-unknown-linux-gnu': + { + const artifacts: FilenameOutputEntry[] = [] + if (electronBuilder.linux?.artifactName) { + if ( + (Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('deb')) + || electronBuilder.linux.target === 'deb' + ) { + artifacts.push( + { + extension: 'deb', + outputFilename: applyTemplateOfArtifactName( + electronBuilder.linux.artifactName!, + productName, + beforeVersion, + mapArchFor(target, 'deb'), + 'deb', + ), + productName, + releaseArtifactFilename: applyTemplateOfArtifactName( + electronBuilder.linux.artifactName!, + productName, + version, + mapArchFor(target, 'deb'), + 'deb', + ), + target: 'x86_64-unknown-linux-gnu', + version, + }, + ) + } + + if ( + (Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('rpm')) + || electronBuilder.linux.target === 'rpm' + ) { + artifacts.push( + { + extension: 'rpm', + outputFilename: applyTemplateOfArtifactName( + electronBuilder.linux.artifactName!, + productName, + beforeVersion, + mapArchFor(target, 'rpm'), + 'rpm', + ), + productName, + releaseArtifactFilename: applyTemplateOfArtifactName( + electronBuilder.linux.artifactName!, + productName, + version, + mapArchFor(target, 'rpm'), + 'rpm', + ), + target: 'x86_64-unknown-linux-gnu', + version, + }, + ) + } + + // Flatpak artifact (built outside electron-builder, but we follow linux template) + artifacts.push( + { + extension: 'flatpak', + outputFilename: applyTemplateOfArtifactName( + electronBuilder.linux.artifactName!, + productName, + beforeVersion, + mapArchFor(target, 'flatpak'), + 'flatpak', + ), + productName, + releaseArtifactFilename: applyTemplateOfArtifactName( + electronBuilder.linux.artifactName!, + productName, + version, + mapArchFor(target, 'flatpak'), + 'flatpak', + ), + target: 'x86_64-unknown-linux-gnu', + version, + }, + ) + } + + const latestUpdateFilename = getLatestUpdateFilename(target) + if (latestUpdateFilename) { + artifacts.push({ + extension: latestUpdateFilename, + optional: true, + outputFilename: latestUpdateFilename, + productName, + releaseArtifactFilename: latestUpdateFilename, + target: 'x86_64-unknown-linux-gnu', + version, + }) + } + + return artifacts + } + default: + console.error('Target is not supported') + process.exit(1) + } +} + +export async function getVersion(options: { autoTag: boolean, release: boolean, tag: string[] }) { if (!options.release || !options.tag) { // Otherwise, fetch from the latest git ref const res = await x('git', ['log', '-1', '--pretty=format:"%H"']) @@ -51,39 +429,6 @@ export async function getVersion(options: { release: boolean, autoTag: boolean, } } -export async function getElectronBuilderConfig(): Promise { - const config = await import ('../electron-builder.config') - return config.default -} - -export function applyTemplateOfArtifactName( - template: string, - productName: string, - version: string, - arch: string, - ext: string, -): string { - return template - // eslint-disable-next-line no-template-curly-in-string - .replace('${productName}', productName) - // eslint-disable-next-line no-template-curly-in-string - .replace('${version}', version) - // eslint-disable-next-line no-template-curly-in-string - .replace('${arch}', arch) - // eslint-disable-next-line no-template-curly-in-string - .replace('${ext}', ext) -} - -interface FilenameOutputEntry { - target: string - extension: string - outputFilename: string - releaseArtifactFilename: string - productName: string - version: string - optional?: boolean -} - export function mapArchFor( target: string, ext: string, @@ -118,17 +463,17 @@ export function mapArchFor( } } -function getLatestUpdateFilename(target: string): string | null { +function getLatestUpdateFilename(target: string): null | string { switch (target) { + case 'aarch64-apple-darwin': + case 'x86_64-apple-darwin': + return `latest-${mapArchFor(target, 'yml')}-mac.yml` + case 'aarch64-unknown-linux-gnu': + return `latest-${mapArchFor(target, 'yml')}-linux-${mapArchFor(target, 'yml')}.yml` case 'x86_64-pc-windows-msvc': return `latest-${mapArchFor(target, 'yml')}.yml` case 'x86_64-unknown-linux-gnu': return `latest-${mapArchFor(target, 'yml')}-linux.yml` - case 'aarch64-unknown-linux-gnu': - return `latest-${mapArchFor(target, 'yml')}-linux-${mapArchFor(target, 'yml')}.yml` - case 'aarch64-apple-darwin': - case 'x86_64-apple-darwin': - return `latest-${mapArchFor(target, 'yml')}-mac.yml` default: return null } @@ -139,348 +484,3 @@ function getMacZipFilename(productName: string, version: string, target: string) const archPrefix = arch === 'x64' ? '' : `${arch}-` return `${productName}-${version}-${archPrefix}mac.zip` } - -export async function getFilenames(target: string, options: { release: boolean, autoTag: boolean, tag: string[] }): Promise { - const electronBuilder = await getElectronBuilderConfig() - const version = await getVersion(options) - - if (!target) { - throw new Error(' is required') - } - - const beforeVersion = packageJSON.version - const productName = electronBuilder.productName! - - switch (target) { - case 'x86_64-pc-windows-msvc': - - return [ - { - target: 'x86_64-pc-windows-msvc', - extension: 'exe', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.nsis!.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'exe'), - 'exe', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.nsis!.artifactName!, - productName, - version, - mapArchFor(target, 'exe'), - 'exe', - ), - productName, - version, - }, - { - target: 'x86_64-pc-windows-msvc', - extension: getLatestUpdateFilename(target)!, - outputFilename: getLatestUpdateFilename(target)!, - releaseArtifactFilename: getLatestUpdateFilename(target)!, - productName, - version, - optional: true, - }, - ] - case 'x86_64-unknown-linux-gnu': - { - const artifacts: FilenameOutputEntry[] = [] - if (electronBuilder.linux?.artifactName) { - if ( - (Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('deb')) - || electronBuilder.linux.target === 'deb' - ) { - artifacts.push( - { - target: 'x86_64-unknown-linux-gnu', - extension: 'deb', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'deb'), - 'deb', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - version, - mapArchFor(target, 'deb'), - 'deb', - ), - productName, - version, - }, - ) - } - - if ( - (Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('rpm')) - || electronBuilder.linux.target === 'rpm' - ) { - artifacts.push( - { - target: 'x86_64-unknown-linux-gnu', - extension: 'rpm', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'rpm'), - 'rpm', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - version, - mapArchFor(target, 'rpm'), - 'rpm', - ), - productName, - version, - }, - ) - } - - // Flatpak artifact (built outside electron-builder, but we follow linux template) - artifacts.push( - { - target: 'x86_64-unknown-linux-gnu', - extension: 'flatpak', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'flatpak'), - 'flatpak', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - version, - mapArchFor(target, 'flatpak'), - 'flatpak', - ), - productName, - version, - }, - ) - } - - const latestUpdateFilename = getLatestUpdateFilename(target) - if (latestUpdateFilename) { - artifacts.push({ - target: 'x86_64-unknown-linux-gnu', - extension: latestUpdateFilename, - outputFilename: latestUpdateFilename, - releaseArtifactFilename: latestUpdateFilename, - productName, - version, - optional: true, - }) - } - - return artifacts - } - case 'aarch64-unknown-linux-gnu': - { - const artifacts: FilenameOutputEntry[] = [] - if (electronBuilder.linux?.artifactName) { - if ( - (Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('deb')) - || electronBuilder.linux.target === 'deb' - ) { - artifacts.push( - { - target: 'aarch64-unknown-linux-gnu', - extension: 'deb', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'deb'), - 'deb', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - version, - mapArchFor(target, 'deb'), - 'deb', - ), - productName, - version, - }, - ) - } - - if ( - (Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('rpm')) - || electronBuilder.linux.target === 'rpm' - ) { - artifacts.push( - { - target: 'aarch64-unknown-linux-gnu', - extension: 'rpm', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'rpm'), - 'rpm', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - version, - mapArchFor(target, 'rpm'), - 'rpm', - ), - productName, - version, - }, - ) - } - - // Flatpak artifact (built outside electron-builder, but we follow linux template) - artifacts.push( - { - target: 'aarch64-unknown-linux-gnu', - extension: 'flatpak', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'flatpak'), - 'flatpak', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.linux.artifactName!, - productName, - version, - mapArchFor(target, 'flatpak'), - 'flatpak', - ), - productName, - version, - }, - ) - } - - const latestUpdateFilename = getLatestUpdateFilename(target) - if (latestUpdateFilename) { - artifacts.push({ - target: 'aarch64-unknown-linux-gnu', - extension: latestUpdateFilename, - outputFilename: latestUpdateFilename, - releaseArtifactFilename: latestUpdateFilename, - productName, - version, - optional: true, - }) - } - - return artifacts - } - case 'aarch64-apple-darwin': - { - const artifacts: FilenameOutputEntry[] = [ - { - target: 'aarch64-apple-darwin', - extension: 'dmg', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.dmg!.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'dmg'), - 'dmg', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.dmg!.artifactName!, - productName, - version, - mapArchFor(target, 'dmg'), - 'dmg', - ), - productName, - version, - }, - ] - - artifacts.push( - { - target: 'aarch64-apple-darwin', - extension: 'zip', - outputFilename: getMacZipFilename(productName, beforeVersion, target), - releaseArtifactFilename: getMacZipFilename(productName, version, target), - productName, - version, - }, - { - target: 'aarch64-apple-darwin', - extension: getLatestUpdateFilename(target)!, - outputFilename: getLatestUpdateFilename(target)!, - releaseArtifactFilename: getLatestUpdateFilename(target)!, - productName, - version, - optional: true, - }, - ) - - return artifacts - } - case 'x86_64-apple-darwin': - { - const artifacts: FilenameOutputEntry[] = [ - { - target: 'x86_64-apple-darwin', - extension: 'dmg', - outputFilename: applyTemplateOfArtifactName( - electronBuilder.dmg!.artifactName!, - productName, - beforeVersion, - mapArchFor(target, 'dmg'), - 'dmg', - ), - releaseArtifactFilename: applyTemplateOfArtifactName( - electronBuilder.dmg!.artifactName!, - productName, - version, - mapArchFor(target, 'dmg'), - 'dmg', - ), - productName, - version, - }, - ] - - artifacts.push( - { - target: 'x86_64-apple-darwin', - extension: 'zip', - outputFilename: getMacZipFilename(productName, beforeVersion, target), - releaseArtifactFilename: getMacZipFilename(productName, version, target), - productName, - version, - }, - { - target: 'x86_64-apple-darwin', - extension: getLatestUpdateFilename(target)!, - outputFilename: getLatestUpdateFilename(target)!, - releaseArtifactFilename: getLatestUpdateFilename(target)!, - productName, - version, - optional: true, - }, - ) - - return artifacts - } - default: - console.error('Target is not supported') - process.exit(1) - } -} diff --git a/apps/stage-tamagotchi/src/main/app/debugger.ts b/apps/stage-tamagotchi/src/main/app/debugger.ts index 1b1ec1334..2d048c47e 100644 --- a/apps/stage-tamagotchi/src/main/app/debugger.ts +++ b/apps/stage-tamagotchi/src/main/app/debugger.ts @@ -4,19 +4,6 @@ import { env } from 'node:process' import { app, shell } from 'electron' -/** Enables Electron's CDP endpoint before the app ready event. */ -export function setupDebugger() { - if (/^true$/i.test(env.APP_REMOTE_DEBUG || '')) { - const remoteDebugPort = Number(env.APP_REMOTE_DEBUG_PORT || '9222') - if (Number.isNaN(remoteDebugPort) || !Number.isInteger(remoteDebugPort) || remoteDebugPort < 0 || remoteDebugPort > 65535) { - throw new Error(`Invalid remote debug port: ${env.APP_REMOTE_DEBUG_PORT}`) - } - - app.commandLine.appendSwitch('remote-debugging-port', String(remoteDebugPort)) - app.commandLine.appendSwitch('remote-allow-origins', `http://localhost:${remoteDebugPort}`) - } -} - /** * Opens the inspector for the first available Electron renderer target. * @@ -60,3 +47,16 @@ export function openDebugger() { }) } } + +/** Enables Electron's CDP endpoint before the app ready event. */ +export function setupDebugger() { + if (/^true$/i.test(env.APP_REMOTE_DEBUG || '')) { + const remoteDebugPort = Number(env.APP_REMOTE_DEBUG_PORT || '9222') + if (Number.isNaN(remoteDebugPort) || !Number.isInteger(remoteDebugPort) || remoteDebugPort < 0 || remoteDebugPort > 65535) { + throw new Error(`Invalid remote debug port: ${env.APP_REMOTE_DEBUG_PORT}`) + } + + app.commandLine.appendSwitch('remote-debugging-port', String(remoteDebugPort)) + app.commandLine.appendSwitch('remote-allow-origins', `http://localhost:${remoteDebugPort}`) + } +} diff --git a/apps/stage-tamagotchi/src/main/app/file-logger.ts b/apps/stage-tamagotchi/src/main/app/file-logger.ts index 143228be0..25bb8d1c2 100644 --- a/apps/stage-tamagotchi/src/main/app/file-logger.ts +++ b/apps/stage-tamagotchi/src/main/app/file-logger.ts @@ -42,10 +42,6 @@ const LOG_FILE_PREFIX = 'airi-tamagotchi' * Handle for the file logger, providing access to the log file and append operations. */ export interface FileLoggerHandle { - /** Path to the current session's log file, or null if initialization failed */ - logFilePath: string | null - /** File descriptor for the current session's log file, or null if initialization failed */ - logFileFd: number | null /** * Appends a log entry to the file. * @param content - The formatted log content to append @@ -55,68 +51,23 @@ export interface FileLoggerHandle { * Closes the log file and releases resources. */ close: () => Promise + /** File descriptor for the current session's log file, or null if initialization failed */ + logFileFd: null | number + /** Path to the current session's log file, or null if initialization failed */ + logFilePath: null | string } export const nullFileLoggerHandle: FileLoggerHandle = { - logFilePath: null, - logFileFd: null, appendLog: async () => {}, close: async () => {}, + logFileFd: null, + logFilePath: null, } // ============================================================================ // Internal Functions // ============================================================================ -/** - * Extracts a human-readable error message from an unknown error object. - */ -function getErrorMessage(error: unknown): string { - return errorMessageFromValue(error) -} - -/** - * Generates the log file path for the current session. - * Format: {userData}/logs/airi-tamagotchi-{timestamp}.log - */ -function createLogFilePath(logsDir: string, timestamp: number): string { - return join(logsDir, `${LOG_FILE_PREFIX}-${timestamp}.log`) -} - -/** - * Ensures the logs directory exists. - * Returns the logs directory path if successful, null otherwise. - */ -async function ensureLogsDirectory(): Promise { - try { - const logsDir = join(app.getPath('userData'), 'logs') - await mkdir(logsDir, { recursive: true }) - return logsDir - } - catch (error) { - const message = getErrorMessage(error) - console.error(`[FileLogger] Failed to create logs directory: ${message}`) - return null - } -} - -/** - * Checks if the current log file exists and returns its size. - */ -async function getLogFileSize(filePath: string): Promise { - try { - const stats = await stat(filePath) - return stats.size - } - catch { - return null - } -} - -// ============================================================================ -// Public API -// ============================================================================ - /** * Sets up the file logger by creating a timestamped log file. * @@ -186,7 +137,7 @@ export async function setupFileLogger(): Promise { console.info(`[FileLogger] Session log file: ${logFilePath}${sizeInfo}`) } - return { logFilePath, logFileFd, appendLog, close } + return { appendLog, close, logFileFd, logFilePath } } catch (error) { const message = getErrorMessage(error) @@ -194,3 +145,52 @@ export async function setupFileLogger(): Promise { return nullFileLoggerHandle } } + +/** + * Generates the log file path for the current session. + * Format: {userData}/logs/airi-tamagotchi-{timestamp}.log + */ +function createLogFilePath(logsDir: string, timestamp: number): string { + return join(logsDir, `${LOG_FILE_PREFIX}-${timestamp}.log`) +} + +/** + * Ensures the logs directory exists. + * Returns the logs directory path if successful, null otherwise. + */ +async function ensureLogsDirectory(): Promise { + try { + const logsDir = join(app.getPath('userData'), 'logs') + await mkdir(logsDir, { recursive: true }) + return logsDir + } + catch (error) { + const message = getErrorMessage(error) + console.error(`[FileLogger] Failed to create logs directory: ${message}`) + return null + } +} + +/** + * Extracts a human-readable error message from an unknown error object. + */ +function getErrorMessage(error: unknown): string { + return errorMessageFromValue(error) +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Checks if the current log file exists and returns its size. + */ +async function getLogFileSize(filePath: string): Promise { + try { + const stats = await stat(filePath) + return stats.size + } + catch { + return null + } +} diff --git a/apps/stage-tamagotchi/src/main/app/single-instance.ts b/apps/stage-tamagotchi/src/main/app/single-instance.ts index b0bcd56cf..5a200b92f 100644 --- a/apps/stage-tamagotchi/src/main/app/single-instance.ts +++ b/apps/stage-tamagotchi/src/main/app/single-instance.ts @@ -7,28 +7,6 @@ interface SingleInstanceGuardOptions { getWindow: () => BrowserWindow | undefined } -/** - * Focuses the main AIRI window after a duplicate launch. - * - * Use when: - * - Electron forwards a second process launch to the primary instance - * - The app should show the already-running UI instead of starting another runtime - * - * Expects: - * - `getWindow` returns the main user-facing window when it has been created - * - * Returns: - * - N/A - */ -function focusMainWindow(getWindow: SingleInstanceGuardOptions['getWindow']) { - const window = getWindow() - if (!window) { - return - } - - toggleWindowShow(window) -} - /** * Installs Electron's single-instance guard for the desktop runtime. * @@ -55,3 +33,25 @@ export function installSingleInstanceGuard(options: SingleInstanceGuardOptions) return true } + +/** + * Focuses the main AIRI window after a duplicate launch. + * + * Use when: + * - Electron forwards a second process launch to the primary instance + * - The app should show the already-running UI instead of starting another runtime + * + * Expects: + * - `getWindow` returns the main user-facing window when it has been created + * + * Returns: + * - N/A + */ +function focusMainWindow(getWindow: SingleInstanceGuardOptions['getWindow']) { + const window = getWindow() + if (!window) { + return + } + + toggleWindowShow(window) +} diff --git a/apps/stage-tamagotchi/src/main/configs/artistry.ts b/apps/stage-tamagotchi/src/main/configs/artistry.ts index bd44ad67d..ce657b245 100644 --- a/apps/stage-tamagotchi/src/main/configs/artistry.ts +++ b/apps/stage-tamagotchi/src/main/configs/artistry.ts @@ -3,19 +3,19 @@ import { any, array, number, object, optional, string } from 'valibot' import { createConfig } from '../libs/electron/persistence' export const artistryConfigSchema = object({ - artistryProvider: optional(string(), 'none'), artistryGlobals: optional(object({ - comfyuiServerUrl: optional(string(), 'http://localhost:8188'), - comfyuiSavedWorkflows: optional(array(any()), []), comfyuiActiveWorkflow: optional(string(), ''), - replicateApiKey: optional(string(), ''), - replicateDefaultModel: optional(string(), 'black-forest-labs/flux-schnell'), - replicateAspectRatio: optional(string(), '16:9'), - replicateInferenceSteps: optional(number(), 4), + comfyuiSavedWorkflows: optional(array(any()), []), + comfyuiServerUrl: optional(string(), 'http://localhost:8188'), nanobananaApiKey: optional(string(), ''), nanobananaModel: optional(string(), 'gemini-3.1-flash-image-preview'), nanobananaResolution: optional(string(), '1K'), + replicateApiKey: optional(string(), ''), + replicateAspectRatio: optional(string(), '16:9'), + replicateDefaultModel: optional(string(), 'black-forest-labs/flux-schnell'), + replicateInferenceSteps: optional(number(), 4), }), {}), + artistryProvider: optional(string(), 'none'), }) export function createArtistryConfig() { diff --git a/apps/stage-tamagotchi/src/main/configs/global.ts b/apps/stage-tamagotchi/src/main/configs/global.ts index eb54ecdaf..9319e50d9 100644 --- a/apps/stage-tamagotchi/src/main/configs/global.ts +++ b/apps/stage-tamagotchi/src/main/configs/global.ts @@ -3,8 +3,8 @@ import { array, object, optional, picklist, string } from 'valibot' import { createConfig } from '../libs/electron/persistence' const shortcutAcceleratorSchema = object({ - modifiers: array(picklist(['cmd-or-ctrl', 'cmd', 'ctrl', 'alt', 'shift', 'super'])), key: string(), + modifiers: array(picklist(['cmd-or-ctrl', 'cmd', 'ctrl', 'alt', 'shift', 'super'])), }) export const globalAppConfigSchema = object({ diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index 47f9437e9..9377b0cb5 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -134,7 +134,6 @@ app.whenReady().then(async () => { const artistryConfig = injeca.provide('configs:artistry', () => createArtistryConfig()) const electronApp = injeca.provide('host:electron:app', () => app) const autoUpdater = injeca.provide('services:auto-updater', { - dependsOn: { appConfig }, build: ({ dependsOn }) => setupAutoUpdater({ enabled: import.meta.env.VITE_DISTRIBUTION !== 'steam', getStoredUpdateLane: () => dependsOn.appConfig.get()?.updateChannel, @@ -146,16 +145,17 @@ app.whenReady().then(async () => { }) }, }), + dependsOn: { appConfig }, }) const i18n = injeca.provide('libs:i18n', { + build: ({ dependsOn }) => createI18n({ locale: dependsOn.appConfig.get()?.language, messages }), dependsOn: { appConfig }, - build: ({ dependsOn }) => createI18n({ messages, locale: dependsOn.appConfig.get()?.language }), }) const serverChannel = injeca.provide('modules:channel-server', { - dependsOn: { app: electronApp, lifecycle }, build: async ({ dependsOn }) => setupServerChannel(dependsOn), + dependsOn: { app: electronApp, lifecycle }, }) const airiHttpServer = injeca.provide('modules:airi-http-server', { @@ -167,8 +167,8 @@ app.whenReady().then(async () => { }) const appleSpeechTranscription = injeca.provide('modules:apple-speech-transcription', { - dependsOn: { lifecycle }, build: ({ dependsOn }) => setupAppleSpeechTranscriptionService(dependsOn), + dependsOn: { lifecycle }, }) const mcpStdioManager = injeca.provide('modules:mcp-stdio-manager', { @@ -176,13 +176,13 @@ app.whenReady().then(async () => { }) const widgetsManager = injeca.provide('windows:widgets', { - dependsOn: { serverChannel, i18n }, build: ({ dependsOn }) => setupWidgetsWindowManager(dependsOn), + dependsOn: { i18n, serverChannel }, }) const pluginHost = injeca.provide('modules:plugin-host', { - dependsOn: { serverChannel, widgetsManager }, build: ({ dependsOn }) => setupExtensionHost(dependsOn), + dependsOn: { serverChannel, widgetsManager }, }) const globalShortcut = injeca.provide('services:global-shortcut', () => setupGlobalShortcutService()) @@ -193,90 +193,90 @@ app.whenReady().then(async () => { const devtoolsMarkdownStressWindow = injeca.provide('windows:devtools:markdown-stress', () => setupDevtoolsWindow()) const onboardingWindowManager = injeca.provide('windows:onboarding', { - dependsOn: { serverChannel, i18n }, build: ({ dependsOn }) => setupOnboardingWindowManager(dependsOn), + dependsOn: { i18n, serverChannel }, }) const noticeWindow = injeca.provide('windows:notice', { - dependsOn: { i18n, serverChannel }, build: ({ dependsOn }) => setupNoticeWindowManager(dependsOn), + dependsOn: { i18n, serverChannel }, }) const aboutWindow = injeca.provide('windows:about', { - dependsOn: { autoUpdater, i18n, serverChannel }, build: ({ dependsOn }) => setupAboutWindowReusable(dependsOn), + dependsOn: { autoUpdater, i18n, serverChannel }, }) const chatWindow = injeca.provide('windows:chat', { - dependsOn: { widgetsManager, serverChannel, mcpStdioManager, i18n }, build: ({ dependsOn }) => setupChatWindowReusableFunc(dependsOn), + dependsOn: { i18n, mcpStdioManager, serverChannel, widgetsManager }, }) const spotlightWindow = injeca.provide('windows:spotlight', { - dependsOn: { serverChannel, i18n, chatWindow, globalShortcut, appConfig }, build: ({ dependsOn }) => setupSpotlightWindowManager(dependsOn), + dependsOn: { appConfig, chatWindow, globalShortcut, i18n, serverChannel }, }) const editorWindow = injeca.provide('windows:editor', { - dependsOn: { serverChannel, i18n }, build: ({ dependsOn }) => setupEditorWindowManager(dependsOn), + dependsOn: { i18n, serverChannel }, }) const settingsWindow = injeca.provide('windows:settings', { - dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, globalShortcut, spotlightWindow }, build: async ({ dependsOn }) => setupSettingsWindowReusableFunc({ ...dependsOn, getMainWindow: () => userFacingMainWindow, }), + dependsOn: { autoUpdater, beatSync, devtoolsWindow: devtoolsMarkdownStressWindow, globalShortcut, godotStageManager, i18n, mcpStdioManager, serverChannel, spotlightWindow, widgetsManager }, }) const mainWindow = injeca.provide('windows:main', { - dependsOn: { editorWindow, settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, appleSpeechTranscription }, build: async ({ dependsOn }) => setupMainWindow({ ...dependsOn, onWindowCreated: (window) => { userFacingMainWindow = window }, }), + dependsOn: { appleSpeechTranscription, autoUpdater, beatSync, chatWindow, editorWindow, godotStageManager, i18n, mcpStdioManager, noticeWindow, onboardingWindowManager, serverChannel, settingsWindow, widgetsManager }, }) const captionWindow = injeca.provide('windows:caption', { - dependsOn: { mainWindow, serverChannel, i18n }, build: async ({ dependsOn }) => setupCaptionWindowManager(dependsOn), + dependsOn: { i18n, mainWindow, serverChannel }, }) const tray = injeca.provide('app:tray', { - dependsOn: { mainWindow, settingsWindow, captionWindow, widgetsWindow: widgetsManager, serverChannel, beatSyncBgWindow: beatSync, aboutWindow, i18n }, build: async ({ dependsOn }) => setupTray(dependsOn), + dependsOn: { aboutWindow, beatSyncBgWindow: beatSync, captionWindow, i18n, mainWindow, serverChannel, settingsWindow, widgetsWindow: widgetsManager }, }) // Desktop grounding overlay — gated by AIRI_DESKTOP_OVERLAY=1 if (isDesktopOverlayEnabled()) { const desktopOverlay = injeca.provide('windows:desktop-overlay', { - dependsOn: { mcpStdioManager, serverChannel, i18n }, build: async ({ dependsOn }) => setupDesktopOverlayWindow(dependsOn), + dependsOn: { i18n, mcpStdioManager, serverChannel }, }) // NOTICE: Separate invoke ensures the overlay is eagerly built. // Without this, injeca.start() would skip it because no other // provider depends on 'windows:desktop-overlay'. injeca.invoke({ - dependsOn: { desktopOverlay }, callback: noop, + dependsOn: { desktopOverlay }, }) } injeca.invoke({ - dependsOn: { mainWindow, tray, serverChannel, airiHttpServer, godotStageManager, pluginHost, mcpStdioManager, onboardingWindow: onboardingWindowManager, widgetsWindow: widgetsManager, spotlightWindow, artistryConfig }, callback: async (deps) => { const { context } = createContext(ipcMain) await setupArtistryBridge({ - widgetsManager: deps.widgetsWindow, - context, artistryConfig: deps.artistryConfig, + context, + widgetsManager: deps.widgetsWindow, }) }, + dependsOn: { airiHttpServer, artistryConfig, godotStageManager, mainWindow, mcpStdioManager, onboardingWindow: onboardingWindowManager, pluginHost, serverChannel, spotlightWindow, tray, widgetsWindow: widgetsManager }, }) injeca.start().catch(err => console.error(err)) diff --git a/apps/stage-tamagotchi/src/main/libs/bootkit/lifecycle.ts b/apps/stage-tamagotchi/src/main/libs/bootkit/lifecycle.ts index 357985dbe..afcc41803 100644 --- a/apps/stage-tamagotchi/src/main/libs/bootkit/lifecycle.ts +++ b/apps/stage-tamagotchi/src/main/libs/bootkit/lifecycle.ts @@ -2,8 +2,10 @@ const onAppReadyHooks = [] as (() => Promise | void)[] const onAppBeforeQuitHooks = [] as (() => Promise | void)[] const onAppWindowAllClosedHooks = [] as (() => Promise | void)[] -export function onAppReady(fn: () => Promise | void) { - onAppReadyHooks.push(fn) +export async function emitAppBeforeQuit() { + for (const fn of onAppBeforeQuitHooks) { + await fn() + } } export async function emitAppReady() { @@ -12,22 +14,20 @@ export async function emitAppReady() { } } -export function onAppBeforeQuit(fn: () => Promise | void) { - onAppBeforeQuitHooks.push(fn) -} - -export async function emitAppBeforeQuit() { - for (const fn of onAppBeforeQuitHooks) { - await fn() - } -} - -export function onAppWindowAllClosed(fn: () => Promise | void) { - onAppWindowAllClosedHooks.push(fn) -} - export async function emitAppWindowAllClosed() { for (const fn of onAppWindowAllClosedHooks) { await fn() } } + +export function onAppBeforeQuit(fn: () => Promise | void) { + onAppBeforeQuitHooks.push(fn) +} + +export function onAppReady(fn: () => Promise | void) { + onAppReadyHooks.push(fn) +} + +export function onAppWindowAllClosed(fn: () => Promise | void) { + onAppWindowAllClosedHooks.push(fn) +} diff --git a/apps/stage-tamagotchi/src/main/libs/electron/location.ts b/apps/stage-tamagotchi/src/main/libs/electron/location.ts index 1c1c721a1..7630adbf1 100644 --- a/apps/stage-tamagotchi/src/main/libs/electron/location.ts +++ b/apps/stage-tamagotchi/src/main/libs/electron/location.ts @@ -7,14 +7,6 @@ import { is } from '@electron-toolkit/utils' let electronMainDirname: string = '' -export function setElectronMainDirname(dirname: string) { - electronMainDirname = dirname -} - -export function getElectronMainDirname() { - return electronMainDirname -} - export function baseUrl(parentOfIndexHtml: string, filename?: string) { if (is.dev && env.ELECTRON_RENDERER_URL) { if (!filename) { @@ -33,7 +25,11 @@ export function baseUrl(parentOfIndexHtml: string, filename?: string) { } } -export async function load(window: BrowserWindow, url: string | { url: string, options?: LoadURLOptions } | { file: string, options?: LoadFileOptions }) { +export function getElectronMainDirname() { + return electronMainDirname +} + +export async function load(window: BrowserWindow, url: string | { file: string, options?: LoadFileOptions } | { options?: LoadURLOptions, url: string }) { try { if (typeof url === 'object' && 'url' in url) { return await window.loadURL(url.url, url.options) @@ -91,6 +87,10 @@ export async function load(window: BrowserWindow, url: string | { url: string, o } } +export function setElectronMainDirname(dirname: string) { + electronMainDirname = dirname +} + /** * Adds a hash route and optional query to an Electron renderer location. * @@ -101,7 +101,7 @@ export async function load(window: BrowserWindow, url: string | { url: string, o * // => { url: 'http://localhost:5173/?synced-leader=false#/about' } */ export function withHashRoute( - baseUrl: string | { url: string } | { file: string }, + baseUrl: string | { file: string } | { url: string }, hashRoute: string, options: Pick = {}, ) { @@ -118,7 +118,7 @@ export function withHashRoute( baseURLinURL.hash = hashRoute - return { url: baseURLinURL.toString() } satisfies { url: string, options?: LoadURLOptions } + return { url: baseURLinURL.toString() } satisfies { options?: LoadURLOptions, url: string } } if (typeof baseUrl === 'object' && 'file' in baseUrl) { return { file: `${baseUrl.file}`, options: { hash: hashRoute, ...options } } satisfies { file: string, options?: LoadFileOptions } @@ -136,5 +136,5 @@ export function withHashRoute( baseURLinURL.hash = hashRoute - return { url: baseURLinURL.toString() } satisfies { url: string, options?: LoadURLOptions } + return { url: baseURLinURL.toString() } satisfies { options?: LoadURLOptions, url: string } } diff --git a/apps/stage-tamagotchi/src/main/libs/electron/persistence.test.ts b/apps/stage-tamagotchi/src/main/libs/electron/persistence.test.ts index 2115de1ff..4997e1c96 100644 --- a/apps/stage-tamagotchi/src/main/libs/electron/persistence.test.ts +++ b/apps/stage-tamagotchi/src/main/libs/electron/persistence.test.ts @@ -57,8 +57,8 @@ describe('createConfig', () => { }) const writeCoordinator = { calls: 0, - waitFor: Promise.resolve(), release: () => {}, + waitFor: Promise.resolve(), } const writeFileMock = vi.fn(async (path: string) => { existingTempFiles.add(path) diff --git a/apps/stage-tamagotchi/src/main/libs/electron/persistence.ts b/apps/stage-tamagotchi/src/main/libs/electron/persistence.ts index 7837f18c9..d4c62567d 100644 --- a/apps/stage-tamagotchi/src/main/libs/electron/persistence.ts +++ b/apps/stage-tamagotchi/src/main/libs/electron/persistence.ts @@ -10,57 +10,37 @@ import { app } from 'electron' import { throttle } from 'es-toolkit' import { safeParse } from 'valibot' -type ConfigStatus = 'ok' | 'missing' | 'invalid' | 'read-error' - export interface ConfigDiagnostics { - status: ConfigStatus - path: string - issues?: BaseIssue[] error?: unknown - raw?: string healed?: boolean + issues?: BaseIssue[] + path: string + raw?: string + status: ConfigStatus value?: T } export interface CreateConfigOptions { - default?: T autoHeal?: boolean - onValidationFailure?: (diagnostics: ConfigDiagnostics) => void + default?: T onReadError?: (diagnostics: ConfigDiagnostics) => void + onValidationFailure?: (diagnostics: ConfigDiagnostics) => void } +type ConfigStatus = 'invalid' | 'missing' | 'ok' | 'read-error' + const persistenceMap = new Map() const diagnosticsMap = new Map>() -function createConfigPath(namespace: string, filename: string) { - return join(app.getPath('userData'), `${namespace}-${filename}`) -} - -async function ensureConfigDirectory(path: string) { - await mkdir(dirname(path), { recursive: true }) +export interface Config { + get: () => InferOutput | undefined + getDiagnostics: () => ConfigDiagnostics> | undefined + setup: () => ConfigDiagnostics> + update: (newData: InferOutput) => void } type PersistedSchema = BaseSchema> -function parseWithSchema( - raw: string, - schema: TSchema, -): { value?: InferOutput, issues?: InferIssue[] } { - const parsed = safeDestr(raw) - const result = safeParse(schema, parsed) - if (result.success) { - return { value: result.output } - } - return { issues: result.issues } -} - -export interface Config { - setup: () => ConfigDiagnostics> - get: () => InferOutput | undefined - update: (newData: InferOutput) => void - getDiagnostics: () => ConfigDiagnostics> | undefined -} - export function createConfig( namespace: string, filename: string, @@ -110,8 +90,8 @@ export function createConfig( const path = configPath() if (!existsSync(path)) { const diagnostics = recordDiagnostics({ - status: 'missing', path, + status: 'missing', value: options?.default, }) persistenceMap.set(key, options?.default) @@ -123,8 +103,8 @@ export function createConfig( const parsed = parseWithSchema(raw, schema) if (parsed.value !== undefined) { const diagnostics = recordDiagnostics({ - status: 'ok', path, + status: 'ok', value: parsed.value, }) persistenceMap.set(key, parsed.value) @@ -133,10 +113,10 @@ export function createConfig( const fallback = options?.default const diagnostics = recordDiagnostics({ - status: 'invalid', - path, issues: parsed.issues, + path, raw, + status: 'invalid', value: fallback, }) options?.onValidationFailure?.(diagnostics) @@ -154,9 +134,9 @@ export function createConfig( catch (error) { const fallback = options?.default const diagnostics = recordDiagnostics({ - status: 'read-error', - path, error, + path, + status: 'read-error', value: fallback, }) options?.onReadError?.(diagnostics) @@ -175,9 +155,29 @@ export function createConfig( const getDiagnostics = () => diagnosticsMap.get(key) as ConfigDiagnostics> | undefined return { - setup, get, - update, getDiagnostics, + setup, + update, } } + +function createConfigPath(namespace: string, filename: string) { + return join(app.getPath('userData'), `${namespace}-${filename}`) +} + +async function ensureConfigDirectory(path: string) { + await mkdir(dirname(path), { recursive: true }) +} + +function parseWithSchema( + raw: string, + schema: TSchema, +): { issues?: InferIssue[], value?: InferOutput } { + const parsed = safeDestr(raw) + const result = safeParse(schema, parsed) + if (result.success) { + return { value: result.output } + } + return { issues: result.issues } +} diff --git a/apps/stage-tamagotchi/src/main/libs/i18n/index.ts b/apps/stage-tamagotchi/src/main/libs/i18n/index.ts index a1c4378c3..2152f9ed8 100644 --- a/apps/stage-tamagotchi/src/main/libs/i18n/index.ts +++ b/apps/stage-tamagotchi/src/main/libs/i18n/index.ts @@ -13,6 +13,12 @@ import { createCoreContext, translate } from '@intlify/core' import { effect, signal } from 'alien-signals' import { isString } from 'es-toolkit' +export interface I18n = Record> { + locale: + (() => (LocaleDetector | string | undefined)) | ((value: LocaleDetector | string | undefined) => void) + t: TranslationFunction +} + type ResolveResourceKeys< // eslint-disable-next-line ts/no-empty-object-type Schema extends Record = {}, @@ -28,7 +34,7 @@ type ResolveResourceKeys< [K in keyof DefinedLocaleMessage]: DefinedLocaleMessage[K] }> : never, -> = SchemaPaths | DefineMessagesPaths +> = DefineMessagesPaths | SchemaPaths interface TranslationFunction< // eslint-disable-next-line ts/no-empty-object-type @@ -132,23 +138,17 @@ interface TranslationFunction< ): string } -export interface I18n = Record> { - t: TranslationFunction - locale: - (() => (string | LocaleDetector | undefined)) | ((value: string | LocaleDetector | undefined) => void) -} - export function createI18n = Record>(options: CoreOptions): I18n { const log = useLogg('i18n').useGlobalConfig() const locale = signal(options.locale) const context = createCoreContext({ + fallbackFormat: true, fallbackLocale: options.fallbackLocale, fallbackWarn: false, missingWarn: false, warnHtmlMessage: false, - fallbackFormat: true, ...options, }) @@ -178,7 +178,7 @@ export function createI18n = Record void) | null = null let signingInFlight = false +interface TokenExchangeResult { + accessToken: string + expiresIn: number + idToken?: string + refreshToken?: string +} + +// --- Internal helpers --- + /** * Create the auth service IPC handlers for a given window context. */ @@ -126,29 +135,20 @@ export function createAuthService(params: { }) } -// --- Internal helpers --- - -interface TokenExchangeResult { - accessToken: string - refreshToken?: string - idToken?: string - expiresIn: number -} - async function exchangeCode(code: string, codeVerifier: string, redirectUri: string): Promise { const body = new URLSearchParams({ - grant_type: 'authorization_code', - code, - redirect_uri: redirectUri, client_id: OIDC_CLIENT_ID, + code, code_verifier: codeVerifier, + grant_type: 'authorization_code', + redirect_uri: redirectUri, resource: SERVER_URL, }) const response = await fetch(new URL(OIDC_TOKEN_PATH, SERVER_URL), { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + method: 'POST', }) if (!response.ok) { @@ -159,8 +159,8 @@ async function exchangeCode(code: string, codeVerifier: string, redirectUri: str const data = await response.json() as Record return { accessToken: data.access_token as string, - refreshToken: data.refresh_token as string | undefined, - idToken: data.id_token as string | undefined, expiresIn: data.expires_in as number, + idToken: data.id_token as string | undefined, + refreshToken: data.refresh_token as string | undefined, } } diff --git a/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts b/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts index cf92c8a23..aea9887e1 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts @@ -31,8 +31,8 @@ import { createConfig } from '../../../libs/electron/persistence' import { ensureServerChannelConfigDefaults } from './config' const channelServerConfigSchema = object({ - hostname: optional(string()), authToken: optional(string()), + hostname: optional(string()), tlsConfig: optional(nullable(object({ cert: optional(string()), key: optional(string()), @@ -41,412 +41,44 @@ const channelServerConfigSchema = object({ }) const channelServerInvokeConfigSchema = z.object({ - hostname: z.string().optional(), authToken: z.string().optional(), + hostname: z.string().optional(), tlsConfig: z.object({ }).nullable().optional(), }).strict() const channelServerConfigStore = createConfig('server-channel', 'config.json', channelServerConfigSchema, { + autoHeal: true, default: { - hostname: '127.0.0.1', authToken: '', + hostname: '127.0.0.1', tlsConfig: null, }, - autoHeal: true, }) let serverChannelServiceRegistered = false let serverChannelCertificateTrustConfigured = false interface ServerChannelCertificateVerifyRequest { - hostname: string - verificationResult: string - errorCode: number certificate: { - subject: { - commonName: string - } issuer: { commonName: string country: string locality: string organizations: string[] } + subject: { + commonName: string + } } + errorCode: number + hostname: string + verificationResult: string } function getServerChannelPort() { return env.SERVER_CHANNEL_PORT ? Number.parseInt(env.SERVER_CHANNEL_PORT) : 6121 } -const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']) - -function isLoopbackHost(host: string) { - return LOOPBACK_HOSTS.has(host) -} - -function getServerChannelQrHosts(config: ElectronServerChannelConfig, serverChannel: Server) { - if (config.hostname === '0.0.0.0') { - return Array.from(new Set(serverChannel.getConnectionHost())) - .filter(host => !isLoopbackHost(host)) - .sort() - } - - if (isLoopbackHost(config.hostname)) { - return [] - } - - return [config.hostname] -} - -function createServerChannelUrl(protocol: 'ws' | 'wss', host: string) { - const urlHost = isIP(host) === 6 ? `[${host}]` : host - // TODO: Deduplicate the server channel websocket path with `packages/server-runtime/src/index.ts` - // and `packages/server-sdk/src/client.ts` so this does not rely on three separate `/ws` literals. - return `${protocol}://${urlHost}:${getServerChannelPort()}/ws` -} - -function getServerChannelQrPayload(config: ElectronServerChannelConfig, serverChannel: Server) { - const protocol = config.tlsConfig ? 'wss' : 'ws' - const urls = getServerChannelQrHosts(config, serverChannel) - .map(host => createServerChannelUrl(protocol, host)) - - if (!urls.length) { - throw new Error('No reachable private LAN address is available for the current server channel host.') - } - - return createServerChannelQrPayload({ - type: 'airi:server-channel', - version: 1, - urls, - authToken: config.authToken, - }) -} - -async function getChannelServerConfig(): Promise { - const config = channelServerConfigStore.get() || { hostname: '127.0.0.1', authToken: '', tlsConfig: null } - - return { - hostname: config.hostname || '127.0.0.1', - authToken: config.authToken || '', - tlsConfig: config.tlsConfig || null, - } -} - -function getServerRuntimeBaseOptions() { - return { - port: getServerChannelPort(), - hostname: '127.0.0.1', - } -} - -async function resolveServerRuntimeOptions(config: ServerOptions): Promise { - return { - ...getServerRuntimeBaseOptions(), - auth: { - token: 'authToken' in config && typeof config.authToken === 'string' ? config.authToken : '', - }, - hostname: 'hostname' in config && typeof config.hostname === 'string' - ? config.hostname || '127.0.0.1' - : '127.0.0.1', - tlsConfig: config.tlsConfig ? await getOrCreateCertificate() : null, - } -} - -async function normalizeChannelServerOptions(payload: unknown, fallback?: ElectronServerChannelConfig) { - if (!fallback) { - fallback = await getChannelServerConfig() - } - - const parsed = channelServerInvokeConfigSchema.safeParse(payload) - if (!parsed.success) { - return fallback - } - - const normalizedConfig = { - hostname: parsed.data.hostname ?? fallback.hostname, - authToken: parsed.data.authToken ?? fallback.authToken, - tlsConfig: typeof parsed.data.tlsConfig === 'undefined' ? null : parsed.data.tlsConfig, - } - - return ensureServerChannelConfigDefaults(normalizedConfig, randomUUID).config -} - -function getCertificateDomains(): string[] { - const localIPs = getLocalIPs() - const hostname = channelServerConfigStore.get()?.hostname || env.SERVER_RUNTIME_HOSTNAME - return Array.from(new Set([ - 'localhost', - '127.0.0.1', - '::1', - ...(hostname ? [hostname] : []), - ...localIPs, - ])) -} - -function getCertificatePaths() { - const userDataPath = app.getPath('userData') - - return { - certPath: join(userDataPath, 'websocket-cert.pem'), - keyPath: join(userDataPath, 'websocket-key.pem'), - caCertPath: join(userDataPath, 'websocket-ca-cert.pem'), - caKeyPath: join(userDataPath, 'websocket-ca-key.pem'), - } -} - -function withCertificateChain(cert: string, caCert?: string) { - return caCert ? `${cert.trim()}\n${caCert.trim()}\n` : cert -} - -function certHasAllDomains(certPem: string, domains: string[]): boolean { - try { - const cert = new X509Certificate(certPem) - const san = cert.subjectAltName || '' - const entries = san.split(',').map(part => part.trim()) - const values = entries - .map((entry) => { - if (entry.startsWith('DNS:')) - return entry.slice(4).trim() - if (entry.startsWith('IP Address:')) - return entry.slice(11).trim() - return '' - }) - .filter(Boolean) - - const sanSet = new Set(values) - return domains.every(domain => sanSet.has(domain)) - } - catch { - return false - } -} - -function isTrustedServerChannelCertificate(request: ServerChannelCertificateVerifyRequest): boolean { - if (!['CERT_AUTHORITY_INVALID', 'ERR_CERT_AUTHORITY_INVALID'].includes(request.verificationResult) - && request.errorCode !== -202) { - return false - } - - if (!getCertificateDomains().includes(request.hostname)) { - return false - } - - const issuer = request.certificate.issuer - return request.certificate.subject.commonName === 'localhost' - && issuer.commonName === 'AIRI' - && issuer.country === 'US' - && issuer.locality === 'Local' - && issuer.organizations.includes('AIRI') -} - -function configureServerChannelCertificateTrust() { - if (serverChannelCertificateTrustConfigured) { - return - } - - session.defaultSession.setCertificateVerifyProc((request, callback) => { - if (isTrustedServerChannelCertificate(request)) { - callback(0) - return - } - - callback(-3) - }) - - serverChannelCertificateTrustConfigured = true -} - -async function installCACertificate(caCert: string) { - const { caCertPath } = getCertificatePaths() - const log = useLogg('main/server-runtime').useGlobalConfig() - writeFileSync(caCertPath, caCert) - - try { - if (platform === 'darwin') { - await x('security', ['add-trusted-cert', '-d', '-r', 'trustRoot', '-k', join(app.getPath('home'), 'Library/Keychains/login.keychain-db'), caCertPath], { nodeOptions: { stdio: 'ignore' } }) - } - else if (platform === 'win32') { - await x('certutil', ['-addstore', '-f', 'Root', caCertPath], { nodeOptions: { stdio: 'ignore' } }) - } - else if (platform === 'linux') { - const caDir = '/usr/local/share/ca-certificates' - const caFileName = 'airi-websocket-ca.crt' - try { - writeFileSync(join(caDir, caFileName), caCert) - await x('update-ca-certificates', [], { nodeOptions: { stdio: 'ignore' } }) - } - catch { - const userCaDir = join(env.HOME || '', '.local/share/ca-certificates') - try { - if (!existsSync(userCaDir)) { - await x('mkdir', ['-p', userCaDir], { nodeOptions: { stdio: 'ignore' } }) - } - writeFileSync(join(userCaDir, caFileName), caCert) - } - catch { - // Ignore errors - } - } - } - } - catch (error) { - log.withError(error).warn(`Failed to install AIRI WebSocket CA certificate from ${caCertPath}`) - } -} - -async function generateCertificate() { - const { caCertPath, caKeyPath } = getCertificatePaths() - - let ca: { key: string, cert: string } - - if (existsSync(caCertPath) && existsSync(caKeyPath)) { - ca = { - cert: readFileSync(caCertPath, 'utf-8'), - key: readFileSync(caKeyPath, 'utf-8'), - } - } - else { - ca = await createCA({ - organization: 'AIRI', - countryCode: 'US', - state: 'Development', - locality: 'Local', - validity: 365, - }) - writeFileSync(caCertPath, ca.cert) - writeFileSync(caKeyPath, ca.key) - } - - await installCACertificate(ca.cert) - - const domains = getCertificateDomains() - - const cert = await createCert({ - ca: { key: ca.key, cert: ca.cert }, - domains, - validity: 365, - }) - - return { - cert: cert.cert, - key: cert.key, - } -} - -async function getOrCreateCertificate() { - const { certPath, keyPath, caCertPath } = getCertificatePaths() - const expectedDomains = getCertificateDomains() - - if (existsSync(certPath) && existsSync(keyPath)) { - const cert = readFileSync(certPath, 'utf-8') - const key = readFileSync(keyPath, 'utf-8') - if (certHasAllDomains(cert, expectedDomains)) { - const caCert = existsSync(caCertPath) ? readFileSync(caCertPath, 'utf-8') : undefined - return { cert: withCertificateChain(cert, caCert), key } - } - } - - const { cert, key } = await generateCertificate() - writeFileSync(certPath, cert) - writeFileSync(keyPath, key) - - const caCert = existsSync(caCertPath) ? readFileSync(caCertPath, 'utf-8') : undefined - return { cert: withCertificateChain(cert, caCert), key } -} - -export async function setupServerChannel(params: { lifecycle: Lifecycle }): Promise { - channelServerConfigStore.setup() - configureServerChannelCertificateTrust() - - const storedConfig = await getChannelServerConfig() - const { changed: storedConfigChanged, config: normalizedStoredConfig } = ensureServerChannelConfigDefaults(storedConfig, randomUUID) - if (storedConfigChanged) { - channelServerConfigStore.update(normalizedStoredConfig) - } - - const serverChannel = createServer(await resolveServerRuntimeOptions(normalizedStoredConfig)) - - const mutex = new Mutex() - - params.lifecycle.appHooks.onStart(async () => { - const release = await mutex.acquire() - - const log = useLogg('main/server-runtime').useGlobalConfig() - - try { - await serverChannel.start() - log.log('WebSocket server started') - } - catch (error) { - log.withError(error).error('Error starting WebSocket server') - } - finally { - release() - } - }) - params.lifecycle.appHooks.onStop(async () => { - const release = await mutex.acquire() - - const log = useLogg('main/server-runtime').useGlobalConfig() - if (!serverChannel) { - return - } - - try { - await serverChannel.stop() - log.log('WebSocket server closed') - } - catch (error) { - log.withError(error).error('Error closing WebSocket server') - } - finally { - release() - } - }) - - return { - getConnectionHost() { - return serverChannel.getConnectionHost() - }, - async start() { - const release = await mutex.acquire() - try { - await serverChannel.start() - } - finally { - release() - } - }, - async restart() { - const release = await mutex.acquire() - try { - await serverChannel.stop() - await serverChannel.start() - } - finally { - release() - } - }, - async stop() { - const release = await mutex.acquire() - try { - await serverChannel.stop() - } - finally { - release() - } - }, - async updateConfig(config) { - const release = await mutex.acquire() - try { - await serverChannel.updateConfig(config) - } - finally { - release() - } - }, - } -} +const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']) export async function createServerChannelService(params: { serverChannel: Server }) { if (serverChannelServiceRegistered) { @@ -506,4 +138,372 @@ export async function createServerChannelService(params: { serverChannel: Server }) } +export async function setupServerChannel(params: { lifecycle: Lifecycle }): Promise { + channelServerConfigStore.setup() + configureServerChannelCertificateTrust() + + const storedConfig = await getChannelServerConfig() + const { changed: storedConfigChanged, config: normalizedStoredConfig } = ensureServerChannelConfigDefaults(storedConfig, randomUUID) + if (storedConfigChanged) { + channelServerConfigStore.update(normalizedStoredConfig) + } + + const serverChannel = createServer(await resolveServerRuntimeOptions(normalizedStoredConfig)) + + const mutex = new Mutex() + + params.lifecycle.appHooks.onStart(async () => { + const release = await mutex.acquire() + + const log = useLogg('main/server-runtime').useGlobalConfig() + + try { + await serverChannel.start() + log.log('WebSocket server started') + } + catch (error) { + log.withError(error).error('Error starting WebSocket server') + } + finally { + release() + } + }) + params.lifecycle.appHooks.onStop(async () => { + const release = await mutex.acquire() + + const log = useLogg('main/server-runtime').useGlobalConfig() + if (!serverChannel) { + return + } + + try { + await serverChannel.stop() + log.log('WebSocket server closed') + } + catch (error) { + log.withError(error).error('Error closing WebSocket server') + } + finally { + release() + } + }) + + return { + getConnectionHost() { + return serverChannel.getConnectionHost() + }, + async restart() { + const release = await mutex.acquire() + try { + await serverChannel.stop() + await serverChannel.start() + } + finally { + release() + } + }, + async start() { + const release = await mutex.acquire() + try { + await serverChannel.start() + } + finally { + release() + } + }, + async stop() { + const release = await mutex.acquire() + try { + await serverChannel.stop() + } + finally { + release() + } + }, + async updateConfig(config) { + const release = await mutex.acquire() + try { + await serverChannel.updateConfig(config) + } + finally { + release() + } + }, + } +} + +function certHasAllDomains(certPem: string, domains: string[]): boolean { + try { + const cert = new X509Certificate(certPem) + const san = cert.subjectAltName || '' + const entries = san.split(',').map(part => part.trim()) + const values = entries + .map((entry) => { + if (entry.startsWith('DNS:')) + return entry.slice(4).trim() + if (entry.startsWith('IP Address:')) + return entry.slice(11).trim() + return '' + }) + .filter(Boolean) + + const sanSet = new Set(values) + return domains.every(domain => sanSet.has(domain)) + } + catch { + return false + } +} + +function configureServerChannelCertificateTrust() { + if (serverChannelCertificateTrustConfigured) { + return + } + + session.defaultSession.setCertificateVerifyProc((request, callback) => { + if (isTrustedServerChannelCertificate(request)) { + callback(0) + return + } + + callback(-3) + }) + + serverChannelCertificateTrustConfigured = true +} + +function createServerChannelUrl(protocol: 'ws' | 'wss', host: string) { + const urlHost = isIP(host) === 6 ? `[${host}]` : host + // TODO: Deduplicate the server channel websocket path with `packages/server-runtime/src/index.ts` + // and `packages/server-sdk/src/client.ts` so this does not rely on three separate `/ws` literals. + return `${protocol}://${urlHost}:${getServerChannelPort()}/ws` +} + +async function generateCertificate() { + const { caCertPath, caKeyPath } = getCertificatePaths() + + let ca: { cert: string, key: string } + + if (existsSync(caCertPath) && existsSync(caKeyPath)) { + ca = { + cert: readFileSync(caCertPath, 'utf-8'), + key: readFileSync(caKeyPath, 'utf-8'), + } + } + else { + ca = await createCA({ + countryCode: 'US', + locality: 'Local', + organization: 'AIRI', + state: 'Development', + validity: 365, + }) + writeFileSync(caCertPath, ca.cert) + writeFileSync(caKeyPath, ca.key) + } + + await installCACertificate(ca.cert) + + const domains = getCertificateDomains() + + const cert = await createCert({ + ca: { cert: ca.cert, key: ca.key }, + domains, + validity: 365, + }) + + return { + cert: cert.cert, + key: cert.key, + } +} + +function getCertificateDomains(): string[] { + const localIPs = getLocalIPs() + const hostname = channelServerConfigStore.get()?.hostname || env.SERVER_RUNTIME_HOSTNAME + return Array.from(new Set([ + '127.0.0.1', + '::1', + 'localhost', + ...(hostname ? [hostname] : []), + ...localIPs, + ])) +} + +function getCertificatePaths() { + const userDataPath = app.getPath('userData') + + return { + caCertPath: join(userDataPath, 'websocket-ca-cert.pem'), + caKeyPath: join(userDataPath, 'websocket-ca-key.pem'), + certPath: join(userDataPath, 'websocket-cert.pem'), + keyPath: join(userDataPath, 'websocket-key.pem'), + } +} + +async function getChannelServerConfig(): Promise { + const config = channelServerConfigStore.get() || { authToken: '', hostname: '127.0.0.1', tlsConfig: null } + + return { + authToken: config.authToken || '', + hostname: config.hostname || '127.0.0.1', + tlsConfig: config.tlsConfig || null, + } +} + +async function getOrCreateCertificate() { + const { caCertPath, certPath, keyPath } = getCertificatePaths() + const expectedDomains = getCertificateDomains() + + if (existsSync(certPath) && existsSync(keyPath)) { + const cert = readFileSync(certPath, 'utf-8') + const key = readFileSync(keyPath, 'utf-8') + if (certHasAllDomains(cert, expectedDomains)) { + const caCert = existsSync(caCertPath) ? readFileSync(caCertPath, 'utf-8') : undefined + return { cert: withCertificateChain(cert, caCert), key } + } + } + + const { cert, key } = await generateCertificate() + writeFileSync(certPath, cert) + writeFileSync(keyPath, key) + + const caCert = existsSync(caCertPath) ? readFileSync(caCertPath, 'utf-8') : undefined + return { cert: withCertificateChain(cert, caCert), key } +} + +function getServerChannelQrHosts(config: ElectronServerChannelConfig, serverChannel: Server) { + if (config.hostname === '0.0.0.0') { + return Array.from(new Set(serverChannel.getConnectionHost())) + .filter(host => !isLoopbackHost(host)) + .sort() + } + + if (isLoopbackHost(config.hostname)) { + return [] + } + + return [config.hostname] +} + +function getServerChannelQrPayload(config: ElectronServerChannelConfig, serverChannel: Server) { + const protocol = config.tlsConfig ? 'wss' : 'ws' + const urls = getServerChannelQrHosts(config, serverChannel) + .map(host => createServerChannelUrl(protocol, host)) + + if (!urls.length) { + throw new Error('No reachable private LAN address is available for the current server channel host.') + } + + return createServerChannelQrPayload({ + authToken: config.authToken, + type: 'airi:server-channel', + urls, + version: 1, + }) +} + +function getServerRuntimeBaseOptions() { + return { + hostname: '127.0.0.1', + port: getServerChannelPort(), + } +} + +async function installCACertificate(caCert: string) { + const { caCertPath } = getCertificatePaths() + const log = useLogg('main/server-runtime').useGlobalConfig() + writeFileSync(caCertPath, caCert) + + try { + if (platform === 'darwin') { + await x('security', ['add-trusted-cert', '-d', '-r', 'trustRoot', '-k', join(app.getPath('home'), 'Library/Keychains/login.keychain-db'), caCertPath], { nodeOptions: { stdio: 'ignore' } }) + } + else if (platform === 'win32') { + await x('certutil', ['-addstore', '-f', 'Root', caCertPath], { nodeOptions: { stdio: 'ignore' } }) + } + else if (platform === 'linux') { + const caDir = '/usr/local/share/ca-certificates' + const caFileName = 'airi-websocket-ca.crt' + try { + writeFileSync(join(caDir, caFileName), caCert) + await x('update-ca-certificates', [], { nodeOptions: { stdio: 'ignore' } }) + } + catch { + const userCaDir = join(env.HOME || '', '.local/share/ca-certificates') + try { + if (!existsSync(userCaDir)) { + await x('mkdir', ['-p', userCaDir], { nodeOptions: { stdio: 'ignore' } }) + } + writeFileSync(join(userCaDir, caFileName), caCert) + } + catch { + // Ignore errors + } + } + } + } + catch (error) { + log.withError(error).warn(`Failed to install AIRI WebSocket CA certificate from ${caCertPath}`) + } +} + +function isLoopbackHost(host: string) { + return LOOPBACK_HOSTS.has(host) +} + +function isTrustedServerChannelCertificate(request: ServerChannelCertificateVerifyRequest): boolean { + if (!['CERT_AUTHORITY_INVALID', 'ERR_CERT_AUTHORITY_INVALID'].includes(request.verificationResult) + && request.errorCode !== -202) { + return false + } + + if (!getCertificateDomains().includes(request.hostname)) { + return false + } + + const issuer = request.certificate.issuer + return request.certificate.subject.commonName === 'localhost' + && issuer.commonName === 'AIRI' + && issuer.country === 'US' + && issuer.locality === 'Local' + && issuer.organizations.includes('AIRI') +} + +async function normalizeChannelServerOptions(payload: unknown, fallback?: ElectronServerChannelConfig) { + if (!fallback) { + fallback = await getChannelServerConfig() + } + + const parsed = channelServerInvokeConfigSchema.safeParse(payload) + if (!parsed.success) { + return fallback + } + + const normalizedConfig = { + authToken: parsed.data.authToken ?? fallback.authToken, + hostname: parsed.data.hostname ?? fallback.hostname, + tlsConfig: typeof parsed.data.tlsConfig === 'undefined' ? null : parsed.data.tlsConfig, + } + + return ensureServerChannelConfigDefaults(normalizedConfig, randomUUID).config +} + +async function resolveServerRuntimeOptions(config: ServerOptions): Promise { + return { + ...getServerRuntimeBaseOptions(), + auth: { + token: 'authToken' in config && typeof config.authToken === 'string' ? config.authToken : '', + }, + hostname: 'hostname' in config && typeof config.hostname === 'string' + ? config.hostname || '127.0.0.1' + : '127.0.0.1', + tlsConfig: config.tlsConfig ? await getOrCreateCertificate() : null, + } +} + +function withCertificateChain(cert: string, caCert?: string) { + return caCert ? `${cert.trim()}\n${caCert.trim()}\n` : cert +} + export type { Server as ServerChannel } diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/errors/index.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/errors/index.ts index 2acfd24af..008e6ff7e 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/errors/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/errors/index.ts @@ -1,18 +1,18 @@ import { HTTPError } from 'h3' -export interface HttpErrorInput { - status: number - code: string - message: string - reason?: string - details?: unknown - expose?: boolean -} - export interface H3HttpErrorOptions { headers?: HeadersInit } +export interface HttpErrorInput { + code: string + details?: unknown + expose?: boolean + message: string + reason?: string + status: number +} + /** * Unified HTTP error shape for AIRI local HTTP server modules. * @@ -28,11 +28,11 @@ export interface H3HttpErrorOptions { * - Error instance with status and structured metadata */ export class HttpError extends Error { - readonly status: number readonly code: string - readonly reason?: string readonly details?: unknown readonly expose: boolean + readonly reason?: string + readonly status: number constructor(input: HttpErrorInput) { super(input.message) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.ts index d0076b49c..646c03247 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.ts @@ -25,9 +25,9 @@ export interface LoopbackCallbackResult { * - Random bound port, callback result promise, and manual cancellation method */ export async function startLoopbackServer(expectedState: string): Promise<{ + close: () => void port: number result: Promise - close: () => void }> { const host = '127.0.0.1' let settled = false @@ -44,8 +44,8 @@ export async function startLoopbackServer(expectedState: string): Promise<{ const app = new H3() const loopbackServer = createH3Server({ app, host }) const corsOptions = { - origin: '*', methods: '*', + origin: '*', preflight: { statusCode: 204, }, @@ -93,8 +93,8 @@ export async function startLoopbackServer(expectedState: string): Promise<{ const state = typeof query.state === 'string' ? query.state : '' if (!state || state !== expectedState) { return new Response('

Invalid state

', { - status: 400, headers: { 'Content-Type': 'text/html; charset=utf-8' }, + status: 400, }) } @@ -107,16 +107,16 @@ export async function startLoopbackServer(expectedState: string): Promise<{ rejectResult(new Error(description)) }) return new Response('

Authentication failed

You can close this window.

', { - status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' }, + status: 200, }) } const code = typeof query.code === 'string' ? query.code : '' if (!code) { return new Response('

Missing parameters

', { - status: 400, headers: { 'Content-Type': 'text/html; charset=utf-8' }, + status: 400, }) } @@ -125,8 +125,8 @@ export async function startLoopbackServer(expectedState: string): Promise<{ }) return new Response('

Authentication successful!

You can close this window and return to the app.

', { - status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' }, + status: 200, }) })) @@ -139,12 +139,12 @@ export async function startLoopbackServer(expectedState: string): Promise<{ }, 5 * 60 * 1000) return { - port: address.port, - result, close: () => { finish(() => { rejectResult(new Error('OIDC sign-in attempt cancelled')) }) }, + port: address.port, + result, } } diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/index.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/index.ts index e8b9e5178..06fa0b437 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/index.ts @@ -21,8 +21,8 @@ export interface BuiltInServer { */ export function setupBuiltInServer(params: { authServer?: ServerManager - staticAssetServer?: ServerManager servers?: ServerManager[] + staticAssetServer?: ServerManager }): BuiltInServer { const servers = [ ...(params.authServer ? [params.authServer] : []), diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/server.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/server.ts index e569bfd8b..213314c12 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/server.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/server.ts @@ -3,9 +3,9 @@ import { getRandomPort } from 'get-port-please' import { serve } from 'h3' export interface BuiltInServerAddress { + baseUrl: string host: string port: number - baseUrl: string } /** @@ -36,6 +36,9 @@ export function createH3Server(options: { let address: BuiltInServerAddress | undefined return { + getAddress() { + return address + }, async start(): Promise { return await lifecycleMutex.runExclusive(async () => { if (address) { @@ -46,9 +49,9 @@ export function createH3Server(options: { server = serve(options.app, { hostname: host, port, silent }) address = { + baseUrl: `http://${host}:${port}`, host, port, - baseUrl: `http://${host}:${port}`, } return address @@ -66,8 +69,5 @@ export function createH3Server(options: { await activeServer.close().catch(() => {}) }) }, - getAddress() { - return address - }, } } diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.test.ts index 4b53a221a..492e27fd7 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.test.ts @@ -21,7 +21,7 @@ describe('createStaticAssetService', () => { } for (const root of tempRoots) { - await rm(root, { recursive: true, force: true }) + await rm(root, { force: true, recursive: true }) } tempRoots.length = 0 }) @@ -53,10 +53,10 @@ describe('createStaticAssetService', () => { const session = server.createSession({ extensionId, - version, ownerSessionId: 'session-1', pathPrefix: '', ttlMs: 60_000, + version, }) const baseUrl = server.getBaseUrl() @@ -69,13 +69,13 @@ describe('createStaticAssetService', () => { }) const responseBody = await response.text() expect({ + responseBody, status: response.status, validateInputs, - responseBody, }).toEqual({ + responseBody: 'console.log("ok")\n', status: 200, validateInputs: ['assets/app.js'], - responseBody: 'console.log("ok")\n', }) }) @@ -89,10 +89,10 @@ describe('createStaticAssetService', () => { const session = server.createSession({ extensionId, - version: '1.0.0', ownerSessionId: 'session-1', pathPrefix: '', ttlMs: 60_000, + version: '1.0.0', }) const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`, { @@ -127,10 +127,10 @@ describe('createStaticAssetService', () => { const { extensionId, server } = await createStartedAssetServer() const session = server.createSession({ extensionId, - version: '1.0.0', ownerSessionId: 'session-1', pathPrefix: '', ttlMs: 60_000, + version: '1.0.0', }) const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`) @@ -145,10 +145,10 @@ describe('createStaticAssetService', () => { const { extensionId, server } = await createStartedAssetServer() const session = server.createSession({ extensionId, - version: '1.0.0', ownerSessionId: 'session-1', pathPrefix: '', ttlMs: 60_000, + version: '1.0.0', }) const url = `${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js` const headers = { @@ -171,10 +171,10 @@ describe('createStaticAssetService', () => { const { extensionId, server } = await createStartedAssetServer() const session = server.createSession({ extensionId, - version: '1.0.0', ownerSessionId: 'session-1', pathPrefix: '', ttlMs: 60_000, + version: '1.0.0', }) const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/missing.js`, { @@ -213,10 +213,10 @@ describe('createStaticAssetService', () => { const session = server.createSession({ extensionId, - version, ownerSessionId: 'session-1', pathPrefix: '', ttlMs: 60_000, + version, }) const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`, { diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.ts index ec731c8c8..b09706b4e 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.ts @@ -22,12 +22,12 @@ export interface StaticAssetManifestEntry { } export interface StaticAssetService extends ServerManager { - getBaseUrl: () => string | undefined createSession: StaticAssetSessionStore['createSession'] - revokeSession: StaticAssetSessionStore['revokeSession'] - revokeByOwnerSessionId: StaticAssetSessionStore['revokeByOwnerSessionId'] - revokeByExtensionId: StaticAssetSessionStore['revokeByExtensionId'] + getBaseUrl: () => string | undefined revokeAll: StaticAssetSessionStore['revokeAll'] + revokeByExtensionId: StaticAssetSessionStore['revokeByExtensionId'] + revokeByOwnerSessionId: StaticAssetSessionStore['revokeByOwnerSessionId'] + revokeSession: StaticAssetSessionStore['revokeSession'] } /** @@ -46,9 +46,9 @@ export interface StaticAssetService extends ServerManager { */ export function createStaticAssetService(options: { getManifestEntryByExtensionId: () => Map + getType?: (ext: string) => string | undefined host?: string sessionStore?: StaticAssetSessionStore - getType?: (ext: string) => string | undefined }): StaticAssetService { const host = options.host ?? '127.0.0.1' const sessionStore = options.sessionStore ?? createStaticAssetSessionStore() @@ -71,54 +71,54 @@ export function createStaticAssetService(options: { } const staticAssetRoute = createStaticAssetRoute({ - getType, - authorize: async ({ extensionId, assetSessionId, assetPath, cookieValue }) => { + authorize: async ({ assetPath, assetSessionId, cookieValue, extensionId }) => { const entry = getManifestEntryForRequest(extensionId) if (!entry) { return { - ok: false, error: new HttpError({ - status: 401, code: 'EXTENSION_ASSET_EXTENSION_NOT_REGISTERED', message: 'Unauthorized', reason: 'extension manifest entry does not exist for requested extensionId', + status: 401, }), + ok: false, } } return sessionStore.validateRequest({ + assetPath, + assetSessionId, + cookieValue, extensionId, version: entry.version, - assetSessionId, - assetPath, - cookieValue, }) }, + getType, refreshSession: sessionStore.refreshSession, - resolveAsset: async ({ extensionId, assetPath }) => { + resolveAsset: async ({ assetPath, extensionId }) => { const entry = getManifestEntryForRequest(extensionId) if (!entry) { return { - ok: false, error: new HttpError({ - status: 404, code: 'EXTENSION_ASSET_EXTENSION_NOT_FOUND', message: 'Not Found', reason: 'extension manifest entry does not exist for requested extensionId', + status: 404, }), + ok: false, } } const normalizedAssetPath = normalizeStaticAssetPath(assetPath) if (!normalizedAssetPath) { return { - ok: false, error: new HttpError({ - status: 400, code: 'EXTENSION_ASSET_PATH_INVALID', message: 'Bad Request', reason: 'asset path could not be normalized', + status: 400, }), + ok: false, } } @@ -132,24 +132,24 @@ export function createStaticAssetService(options: { } catch { return { - ok: false, error: new HttpError({ - status: 404, code: 'EXTENSION_ASSET_NOT_FOUND', message: 'Not Found', reason: 'resolved file does not exist', + status: 404, }), + ok: false, } } return { - ok: false, error: new HttpError({ - status: 400, code: 'EXTENSION_ASSET_PATH_RESOLVE_FAILED', message: 'Bad Request', reason: 'resolved asset path is outside extension root', + status: 400, }), + ok: false, } } @@ -157,32 +157,32 @@ export function createStaticAssetService(options: { const fileStats = await stat(filePath) if (!fileStats.isFile()) { return { - ok: false, error: new HttpError({ - status: 404, code: 'EXTENSION_ASSET_NOT_FILE', message: 'Not Found', reason: 'resolved path exists but is not a file', + status: 404, }), + ok: false, } } return { - ok: true, filePath, - size: fileStats.size, mtime: fileStats.mtimeMs, + ok: true, + size: fileStats.size, } } catch { return { - ok: false, error: new HttpError({ - status: 404, code: 'EXTENSION_ASSET_NOT_FOUND', message: 'Not Found', reason: 'resolved file does not exist', + status: 404, }), + ok: false, } } }, @@ -191,29 +191,29 @@ export function createStaticAssetService(options: { app.use('/_airi/extensions/**', event => manifestEntryRequestCache.run(new Map(), () => staticAssetRoute(event))) return { + createSession: sessionStore.createSession, + getBaseUrl() { + return serverLifecycle.getAddress()?.baseUrl + }, key: 'static-assets', + revokeAll: sessionStore.revokeAll, + revokeByExtensionId: sessionStore.revokeByExtensionId, + revokeByOwnerSessionId: sessionStore.revokeByOwnerSessionId, + revokeSession: sessionStore.revokeSession, async start() { await serverLifecycle.start() }, async stop() { await serverLifecycle.stop() }, - getBaseUrl() { - return serverLifecycle.getAddress()?.baseUrl - }, - createSession: sessionStore.createSession, - revokeSession: sessionStore.revokeSession, - revokeByOwnerSessionId: sessionStore.revokeByOwnerSessionId, - revokeByExtensionId: sessionStore.revokeByExtensionId, - revokeAll: sessionStore.revokeAll, } } const staticAssetMimeTypeOverrides: Record = { - '.wasm': 'application/wasm', '.avif': 'image/avif', '.heic': 'image/heic', '.heif': 'image/heif', + '.wasm': 'application/wasm', } function defaultStaticAssetMimeTypeResolver(ext: string) { diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.test.ts index cff0a8bee..8f0864262 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.test.ts @@ -16,7 +16,7 @@ describe('static asset paths', () => { afterEach(async () => { for (const root of tempRoots) { - await rm(root, { recursive: true, force: true }) + await rm(root, { force: true, recursive: true }) } tempRoots.length = 0 }) @@ -30,9 +30,9 @@ describe('static asset paths', () => { it('parses session-scoped mounted plugin request path and rejects malformed routes', () => { expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/index.html')).toEqual({ - extensionId: 'airi-plugin-game-chess', - assetSessionId: 'asset-session-1', assetPath: 'dist/ui/index.html', + assetSessionId: 'asset-session-1', + extensionId: 'airi-plugin-game-chess', }) expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/ui/dist/ui/index.html')).toBeUndefined() expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/../../etc/passwd')).toBeUndefined() @@ -46,24 +46,24 @@ describe('static asset paths', () => { it('builds session-scoped mounted asset path with encoded segments', () => { expect(buildMountedStaticAssetPath({ - extensionId: 'airi-plugin-game-chess', - assetSessionId: 'asset-session-1', assetPath: 'dist/ui/index.html', + assetSessionId: 'asset-session-1', + extensionId: 'airi-plugin-game-chess', })).toBe('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/index.html') expect(buildMountedStaticAssetPath({ - extensionId: 'airi-plugin-game-chess', - assetSessionId: 'asset-session-1', assetPath: 'dist/ui/file name.html', + assetSessionId: 'asset-session-1', + extensionId: 'airi-plugin-game-chess', })).toBe('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/file%20name.html') expect(buildMountedStaticAssetPath({ - extensionId: 'bad/id', - assetSessionId: 'asset-session-1', assetPath: 'dist/ui/index.html', + assetSessionId: 'asset-session-1', + extensionId: 'bad/id', })).toBeUndefined() expect(buildMountedStaticAssetPath({ - extensionId: 'airi-plugin-game-chess', - assetSessionId: 'bad session', assetPath: 'dist/ui/index.html', + assetSessionId: 'bad session', + extensionId: 'airi-plugin-game-chess', })).toBeUndefined() }) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.ts index 6e2c4f97f..78b5315aa 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.ts @@ -8,30 +8,53 @@ import { resolve, sep } from 'node:path' * @param assetSessionId Asset session identifier from the mounted route. */ export interface ParsedStaticAssetRequest { - /** Plugin extension identifier validated as one safe route segment. */ - extensionId: string - /** Asset session identifier validated as one safe route segment. */ - assetSessionId: string /** Normalized plugin asset path relative to the mounted UI asset root. */ assetPath: string + /** Asset session identifier validated as one safe route segment. */ + assetSessionId: string + /** Plugin extension identifier validated as one safe route segment. */ + extensionId: string } const pathPrefix = '/_airi/extensions/' const segmentPattern = /^[\w.+-]+$/ -function decodePathSegment(segment: string): string | undefined { - try { - return decodeURIComponent(segment) - } - catch { +/** + * Builds a session-scoped mounted plugin asset route path. + * + * Use when: + * - Converting a validated plugin asset path into a mounted HTTP route + * - Emitting URLs for `/_airi/extensions/:extensionId/sessions/:assetSessionId/ui/:assetPath` + * + * Expects: + * - `extensionId` and `assetSessionId` are safe single route segments + * - `assetPath` is a plugin-relative asset path accepted by {@link normalizeStaticAssetPath} + * + * Returns: + * - Encoded mounted route path, or `undefined` when any input is unsafe + */ +export function buildMountedStaticAssetPath(input: { + assetPath: string + assetSessionId: string + extensionId: string +}) { + if (!isSafeRouteSegment(input.extensionId) || !isSafeRouteSegment(input.assetSessionId)) { return undefined } -} -function isSafeRouteSegment(segment: string): boolean { - return !segment.includes('/') - && !segment.includes('\\') - && segmentPattern.test(segment) + const normalizedAssetPath = normalizeStaticAssetPath(input.assetPath) + if (!normalizedAssetPath) { + return undefined + } + + const encodedExtensionId = encodeURIComponent(input.extensionId) + const encodedAssetSessionId = encodeURIComponent(input.assetSessionId) + const encodedAssetPath = normalizedAssetPath + .split('/') + .map(segment => encodeURIComponent(segment)) + .join('/') + + return `${pathPrefix}${encodedExtensionId}/sessions/${encodedAssetSessionId}/ui/${encodedAssetPath}` } /** @@ -151,9 +174,9 @@ export function parseStaticAssetRequestPath(pathname: string): ParsedStaticAsset } return { - extensionId, - assetSessionId, assetPath, + assetSessionId, + extensionId, } } @@ -196,40 +219,17 @@ export async function resolveStaticAssetFilePath(rootDir: string, assetPath: str return realCandidate } -/** - * Builds a session-scoped mounted plugin asset route path. - * - * Use when: - * - Converting a validated plugin asset path into a mounted HTTP route - * - Emitting URLs for `/_airi/extensions/:extensionId/sessions/:assetSessionId/ui/:assetPath` - * - * Expects: - * - `extensionId` and `assetSessionId` are safe single route segments - * - `assetPath` is a plugin-relative asset path accepted by {@link normalizeStaticAssetPath} - * - * Returns: - * - Encoded mounted route path, or `undefined` when any input is unsafe - */ -export function buildMountedStaticAssetPath(input: { - extensionId: string - assetSessionId: string - assetPath: string -}) { - if (!isSafeRouteSegment(input.extensionId) || !isSafeRouteSegment(input.assetSessionId)) { +function decodePathSegment(segment: string): string | undefined { + try { + return decodeURIComponent(segment) + } + catch { return undefined } +} - const normalizedAssetPath = normalizeStaticAssetPath(input.assetPath) - if (!normalizedAssetPath) { - return undefined - } - - const encodedExtensionId = encodeURIComponent(input.extensionId) - const encodedAssetSessionId = encodeURIComponent(input.assetSessionId) - const encodedAssetPath = normalizedAssetPath - .split('/') - .map(segment => encodeURIComponent(segment)) - .join('/') - - return `${pathPrefix}${encodedExtensionId}/sessions/${encodedAssetSessionId}/ui/${encodedAssetPath}` +function isSafeRouteSegment(segment: string): boolean { + return !segment.includes('/') + && !segment.includes('\\') + && segmentPattern.test(segment) } diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.test.ts index ea19b4f4b..507e3b200 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.test.ts @@ -17,7 +17,7 @@ describe('createStaticAssetRoute', () => { afterEach(async () => { for (const root of tempRoots) { - await rm(root, { recursive: true, force: true }) + await rm(root, { force: true, recursive: true }) } tempRoots.length = 0 @@ -36,21 +36,21 @@ describe('createStaticAssetRoute', () => { const app = new H3() app.get('/_airi/extensions/**', createStaticAssetRoute({ authorize: async () => ({ - ok: false, error: new HttpError({ - status: 401, code: 'COOKIE_MISSING', message: 'Unauthorized', + status: 401, }), + ok: false, }), refreshSession: () => undefined, resolveAsset: async () => ({ - ok: false, error: new HttpError({ - status: 404, code: 'NOT_FOUND', message: 'Not Found', + status: 404, }), + ok: false, }), })) @@ -70,21 +70,21 @@ describe('createStaticAssetRoute', () => { const app = new H3() app.use('/_airi/extensions/**', createStaticAssetRoute({ authorize: async () => ({ - ok: false, error: new HttpError({ - status: 401, code: 'COOKIE_MISSING', message: 'Unauthorized', + status: 401, }), + ok: false, }), refreshSession: () => undefined, resolveAsset: async () => ({ - ok: false, error: new HttpError({ - status: 404, code: 'NOT_FOUND', message: 'Not Found', + status: 404, }), + ok: false, }), })) @@ -122,23 +122,23 @@ describe('createStaticAssetRoute', () => { session: { assetSessionId: 's1', cookieName: createStaticAssetSessionCookieName('s1'), - cookieValue: 'test-token', cookiePath: '/_airi/extensions/a/sessions/s1/ui', + cookieValue: 'test-token', expiresAt: Date.now() + 1000, }, } }, + getType: ext => ext === '.wasm' ? 'application/wasm' : undefined, refreshSession: (assetSessionId) => { refreshedSessionId = assetSessionId return undefined }, resolveAsset: async () => ({ - ok: true, filePath: wasmFilePath, - size: 4, mtime: Date.now(), + ok: true, + size: 4, }), - getType: ext => ext === '.wasm' ? 'application/wasm' : undefined, })) server = createServer(toNodeHandler(app)) @@ -178,23 +178,23 @@ describe('createStaticAssetRoute', () => { session: { assetSessionId: 's1', cookieName: createStaticAssetSessionCookieName('s1'), - cookieValue: 'test-token', cookiePath: '/_airi/extensions/a/sessions/s1/ui', + cookieValue: 'test-token', expiresAt: Date.now() + 1000, }, } }, + getType: ext => ext === '.wasm' ? 'application/wasm' : undefined, refreshSession: (assetSessionId) => { refreshedSessionId = assetSessionId return undefined }, resolveAsset: async () => ({ - ok: true, filePath: wasmFilePath, - size: 4, mtime: Date.now(), + ok: true, + size: 4, }), - getType: ext => ext === '.wasm' ? 'application/wasm' : undefined, })) server = createServer(toNodeHandler(app)) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.ts index c1cdbfa6c..d321eddc2 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.ts @@ -16,14 +16,14 @@ const staticAssetSecurityHeaders = { export interface StaticAssetRouteOptions { authorize: (params: { - extensionId: string - assetSessionId: string assetPath: string + assetSessionId: string cookieValue: string | undefined + extensionId: string }) => Promise - refreshSession: (assetSessionId: string) => StaticAssetSession | undefined - resolveAsset: (params: { extensionId: string, assetPath: string }) => Promise getType?: (ext: string) => string | undefined + refreshSession: (assetSessionId: string) => StaticAssetSession | undefined + resolveAsset: (params: { assetPath: string, extensionId: string }) => Promise } /** @@ -48,9 +48,9 @@ export function createStaticAssetRoute(options: StaticAssetRouteOptions) { if (event.req.method !== 'GET' && event.req.method !== 'HEAD') { throw new HttpError({ - status: 405, code: 'EXTENSION_ASSET_METHOD_NOT_ALLOWED', message: 'Method Not Allowed', + status: 405, }) } @@ -61,19 +61,19 @@ export function createStaticAssetRoute(options: StaticAssetRouteOptions) { if (!extensionId || !assetSessionId || !assetPath) { throw new HttpError({ - status: 401, code: 'EXTENSION_ASSET_REQUEST_INVALID', message: 'Unauthorized', reason: 'required extensionId, assetSessionId, or assetPath is missing', + status: 401, }) } const cookieValue = getCookie(event, createStaticAssetSessionCookieName(assetSessionId)) const auth = await options.authorize({ - extensionId, - assetSessionId, assetPath, + assetSessionId, cookieValue, + extensionId, }) if (!auth.ok) { throw auth.error @@ -84,13 +84,12 @@ export function createStaticAssetRoute(options: StaticAssetRouteOptions) { let resolved: Awaited> | undefined const resolveOnce = async () => { if (!resolved) { - resolved = await options.resolveAsset({ extensionId, assetPath }) + resolved = await options.resolveAsset({ assetPath, extensionId }) } return resolved } return await serveStatic(event, { - getType: options.getType, getContents: async () => { const item = await resolveOnce() if (!item.ok) { @@ -105,10 +104,11 @@ export function createStaticAssetRoute(options: StaticAssetRouteOptions) { } return { - size: item.size, mtime: item.mtime, + size: item.size, } }, + getType: options.getType, }) } catch (error) { diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.test.ts index 2231917b0..bc6f268a8 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.test.ts @@ -27,21 +27,21 @@ describe('createStaticAssetSessionStore', () => { const store = createStaticAssetSessionStore({ now }) const session = store.createSession({ extensionId: 'airi-plugin-game-chess', - version: '0.1.0', ownerSessionId: 'plugin-session-1', pathPrefix: '', ttlMs: 30_000, + version: '0.1.0', }) expect(session.assetSessionId).toBeTruthy() expect(session.cookieName).toContain(session.assetSessionId) expect(session.cookieValue).toBeTruthy() expect(store.validateRequest({ + assetPath: 'assets/index.js', + assetSessionId: session.assetSessionId, + cookieValue: session.cookieValue, extensionId: 'airi-plugin-game-chess', version: '0.1.0', - assetSessionId: session.assetSessionId, - assetPath: 'assets/index.js', - cookieValue: session.cookieValue, }).ok).toBe(true) }) @@ -54,39 +54,39 @@ describe('createStaticAssetSessionStore', () => { const store = createStaticAssetSessionStore({ now }) const session = store.createSession({ extensionId: 'airi-plugin-game-chess', - version: '0.1.0', ownerSessionId: 'plugin-session-1', pathPrefix: '', ttlMs: 30_000, + version: '0.1.0', }) expect(store.validateRequest({ + assetPath: 'index.html', + assetSessionId: session.assetSessionId, + cookieValue: session.cookieValue, extensionId: 'other-plugin', version: '0.1.0', - assetSessionId: session.assetSessionId, - assetPath: 'index.html', - cookieValue: session.cookieValue, })).toMatchObject({ - ok: false, error: { - status: 401, code: 'EXTENSION_ASSET_EXTENSION_MISMATCH', + status: 401, }, + ok: false, }) expect(store.revokeByOwnerSessionId('plugin-session-1')).toHaveLength(1) expect(store.validateRequest({ + assetPath: 'index.html', + assetSessionId: session.assetSessionId, + cookieValue: session.cookieValue, extensionId: 'airi-plugin-game-chess', version: '0.1.0', - assetSessionId: session.assetSessionId, - assetPath: 'index.html', - cookieValue: session.cookieValue, })).toMatchObject({ - ok: false, error: { - status: 401, code: 'EXTENSION_ASSET_SESSION_NOT_FOUND', + status: 401, }, + ok: false, }) }) @@ -99,96 +99,96 @@ describe('createStaticAssetSessionStore', () => { const store = createStaticAssetSessionStore({ now }) const session = store.createSession({ extensionId: 'airi-plugin-game-chess', - version: '0.1.0', ownerSessionId: 'plugin-session-1', pathPrefix: 'assets/', ttlMs: 30_000, + version: '0.1.0', }) expect(store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: session.assetSessionId, assetPath: 'assets/index.js', + assetSessionId: session.assetSessionId, cookieValue: undefined, - })).toMatchObject({ - ok: false, - error: { - status: 401, - code: 'EXTENSION_ASSET_COOKIE_MISSING', - }, - }) - - expect(store.validateRequest({ extensionId: 'airi-plugin-game-chess', version: '0.1.0', - assetSessionId: session.assetSessionId, - assetPath: 'assets/index.js', - cookieValue: 'wrong-cookie', })).toMatchObject({ - ok: false, error: { + code: 'EXTENSION_ASSET_COOKIE_MISSING', status: 401, - code: 'EXTENSION_ASSET_COOKIE_MISMATCH', }, + ok: false, }) expect(store.validateRequest({ + assetPath: 'assets/index.js', + assetSessionId: session.assetSessionId, + cookieValue: 'wrong-cookie', + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + })).toMatchObject({ + error: { + code: 'EXTENSION_ASSET_COOKIE_MISMATCH', + status: 401, + }, + ok: false, + }) + + expect(store.validateRequest({ + assetPath: 'assets/index.js', + assetSessionId: session.assetSessionId, + cookieValue: session.cookieValue, extensionId: 'airi-plugin-game-chess', version: '0.2.0', - assetSessionId: session.assetSessionId, - assetPath: 'assets/index.js', - cookieValue: session.cookieValue, })).toMatchObject({ - ok: false, error: { - status: 401, code: 'EXTENSION_ASSET_VERSION_MISMATCH', + status: 401, }, + ok: false, }) expect(store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: session.assetSessionId, assetPath: '', + assetSessionId: session.assetSessionId, cookieValue: session.cookieValue, + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', })).toMatchObject({ - ok: false, error: { - status: 401, code: 'EXTENSION_ASSET_PATH_EMPTY', + status: 401, }, + ok: false, }) expect(store.validateRequest({ + assetPath: 'other/index.js', + assetSessionId: session.assetSessionId, + cookieValue: session.cookieValue, extensionId: 'airi-plugin-game-chess', version: '0.1.0', - assetSessionId: session.assetSessionId, - assetPath: 'other/index.js', - cookieValue: session.cookieValue, })).toMatchObject({ - ok: false, error: { - status: 401, code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH', + status: 401, }, + ok: false, }) now.mockReturnValue(31_001) expect(store.validateRequest({ + assetPath: 'assets/index.js', + assetSessionId: session.assetSessionId, + cookieValue: session.cookieValue, extensionId: 'airi-plugin-game-chess', version: '0.1.0', - assetSessionId: session.assetSessionId, - assetPath: 'assets/index.js', - cookieValue: session.cookieValue, })).toMatchObject({ - ok: false, error: { - status: 401, code: 'EXTENSION_ASSET_SESSION_EXPIRED', + status: 401, }, + ok: false, }) }) @@ -201,24 +201,24 @@ describe('createStaticAssetSessionStore', () => { const store = createStaticAssetSessionStore({ now }) const firstSession = store.createSession({ extensionId: 'airi-plugin-game-chess', - version: '0.1.0', ownerSessionId: 'plugin-session-1', pathPrefix: '', ttlMs: 30_000, + version: '0.1.0', }) const secondSession = store.createSession({ extensionId: 'airi-plugin-game-chess', - version: '0.1.0', ownerSessionId: 'plugin-session-2', pathPrefix: '', ttlMs: 30_000, + version: '0.1.0', }) const thirdSession = store.createSession({ extensionId: 'airi-plugin-game-go', - version: '0.1.0', ownerSessionId: 'plugin-session-3', pathPrefix: '', ttlMs: 30_000, + version: '0.1.0', }) now.mockReturnValue(2000) @@ -243,21 +243,21 @@ describe('createStaticAssetSessionStore', () => { const store = createStaticAssetSessionStore({ now }) const createdSession = store.createSession({ extensionId: 'airi-plugin-game-chess', - version: '0.1.0', ownerSessionId: 'plugin-session-1', pathPrefix: '', ttlMs: 30_000, + version: '0.1.0', }) const originalCookieValue = createdSession.cookieValue tryMutateCookieValue(createdSession, 'mutated-create-cookie') const firstValidation = store.validateRequest({ + assetPath: 'index.html', + assetSessionId: createdSession.assetSessionId, + cookieValue: originalCookieValue, extensionId: 'airi-plugin-game-chess', version: '0.1.0', - assetSessionId: createdSession.assetSessionId, - assetPath: 'index.html', - cookieValue: originalCookieValue, }) expect(firstValidation.ok).toBe(true) expect(Object.isFrozen(createdSession)).toBe(true) @@ -268,11 +268,11 @@ describe('createStaticAssetSessionStore', () => { tryMutateCookieValue(firstValidation.session, 'mutated-validation-cookie') const secondValidation = store.validateRequest({ + assetPath: 'index.html', + assetSessionId: createdSession.assetSessionId, + cookieValue: originalCookieValue, extensionId: 'airi-plugin-game-chess', version: '0.1.0', - assetSessionId: createdSession.assetSessionId, - assetPath: 'index.html', - cookieValue: originalCookieValue, }) expect(secondValidation.ok).toBe(true) expect(Object.isFrozen(firstValidation.session)).toBe(true) @@ -290,11 +290,11 @@ describe('createStaticAssetSessionStore', () => { tryMutateCookieValue(refreshedSession, 'mutated-refresh-cookie') const thirdValidation = store.validateRequest({ + assetPath: 'index.html', + assetSessionId: createdSession.assetSessionId, + cookieValue: originalCookieValue, extensionId: 'airi-plugin-game-chess', version: '0.1.0', - assetSessionId: createdSession.assetSessionId, - assetPath: 'index.html', - cookieValue: originalCookieValue, }) expect(thirdValidation.ok).toBe(true) expect(Object.isFrozen(refreshedSession)).toBe(true) @@ -321,9 +321,9 @@ describe('createStaticAssetSessionStore', () => { const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) }) const input = { extensionId: 'airi-plugin-game-chess', - version: '0.1.0', ownerSessionId: 'plugin-session-1', pathPrefix: '', + version: '0.1.0', } for (const ttlMs of [Number.NaN, Number.POSITIVE_INFINITY, 0, -1]) { @@ -342,10 +342,10 @@ describe('createStaticAssetSessionStore', () => { const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) }) const session = store.createSession({ extensionId: 'airi-plugin-game-chess', - version: '0.1.0', ownerSessionId: 'plugin-session-1', pathPrefix: 'assets/', ttlMs: 30_000, + version: '0.1.0', }) for (const assetPath of [ @@ -354,26 +354,26 @@ describe('createStaticAssetSessionStore', () => { 'assets%2Fsecret.js', ]) { expect(store.validateRequest({ + assetPath, + assetSessionId: session.assetSessionId, + cookieValue: session.cookieValue, extensionId: 'airi-plugin-game-chess', version: '0.1.0', - assetSessionId: session.assetSessionId, - assetPath, - cookieValue: session.cookieValue, })).toMatchObject({ - ok: false, error: { - status: 401, code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH', + status: 401, }, + ok: false, }) } expect(() => store.createSession({ extensionId: 'airi-plugin-game-chess', - version: '0.1.0', ownerSessionId: 'plugin-session-1', pathPrefix: '../', ttlMs: 30_000, + version: '0.1.0', })).toThrow(RangeError) }) @@ -385,47 +385,47 @@ describe('createStaticAssetSessionStore', () => { const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) }) const directorySession = store.createSession({ extensionId: 'airi-plugin-game-chess', - version: '0.1.0', ownerSessionId: 'plugin-session-1', pathPrefix: 'assets/', ttlMs: 30_000, + version: '0.1.0', }) const exactSession = store.createSession({ extensionId: 'airi-plugin-game-chess', - version: '0.1.0', ownerSessionId: 'plugin-session-2', pathPrefix: 'assets', ttlMs: 30_000, + version: '0.1.0', }) expect(store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', + assetPath: 'assets/index.js', assetSessionId: directorySession.assetSessionId, - assetPath: 'assets/index.js', cookieValue: directorySession.cookieValue, + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', }).ok).toBe(true) expect(store.validateRequest({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetSessionId: exactSession.assetSessionId, assetPath: 'assets', + assetSessionId: exactSession.assetSessionId, cookieValue: exactSession.cookieValue, + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', }).ok).toBe(true) expect(store.validateRequest({ + assetPath: 'assets/index.js', + assetSessionId: exactSession.assetSessionId, + cookieValue: exactSession.cookieValue, extensionId: 'airi-plugin-game-chess', version: '0.1.0', - assetSessionId: exactSession.assetSessionId, - assetPath: 'assets/index.js', - cookieValue: exactSession.cookieValue, })).toMatchObject({ - ok: false, error: { - status: 401, code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH', + status: 401, }, + ok: false, }) }) }) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.ts index a31cc2b95..4bfa5b6ee 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.ts @@ -14,57 +14,15 @@ import { normalizeStaticAssetPath } from './paths' interface StaticAssetSessionRecord { assetSessionId: string + cookieName: string + cookiePath: string + cookieValue: string + expiresAt: number extensionId: string - version: string ownerSessionId: string pathPrefix: string ttlMs: number - cookieName: string - cookieValue: string - cookiePath: string - expiresAt: number -} - -/** - * Normalizes asset path prefixes used to constrain a session. - * - * Before: - * - " assets\\ " - * - * After: - * - "assets/" - */ -function normalizePathPrefix(pathPrefix: string) { - const normalizedInput = pathPrefix.trim().replaceAll('\\', '/') - if (!normalizedInput) { - return '' - } - - const isDirectoryPrefix = normalizedInput.endsWith('/') - const normalized = normalizeStaticAssetPath(normalizedInput) - if (!normalized) { - throw new RangeError('Extension asset session pathPrefix must be empty or a safe plugin asset path') - } - - return isDirectoryPrefix ? `${normalized}/` : normalized -} - -/** - * Normalizes requested asset paths before comparing them to session prefixes. - * - * Before: - * - " assets\\index.js " - * - * After: - * - "assets/index.js" - */ -function normalizeAssetPath(assetPath: string) { - return normalizeStaticAssetPath(assetPath.trim().replaceAll('\\', '/')) -} - -function createOpaqueToken() { - // Node's base64url alphabet is route/cookie friendly while staying opaque. - return randomBytes(18).toString('base64url') + version: string } /** @@ -84,42 +42,6 @@ export function createStaticAssetSessionCookieName(assetSessionId: string) { return `airi_extension_asset_session_${assetSessionId}` } -function createCookiePath(extensionId: string, assetSessionId: string) { - return `/_airi/extensions/${encodeURIComponent(extensionId)}/sessions/${encodeURIComponent(assetSessionId)}/ui` -} - -function createSessionSnapshot(record: StaticAssetSessionRecord): StaticAssetSession { - return Object.freeze({ - assetSessionId: record.assetSessionId, - cookieName: record.cookieName, - cookieValue: record.cookieValue, - cookiePath: record.cookiePath, - expiresAt: record.expiresAt, - }) -} - -function cookieValuesMatch(expected: string, actual: string) { - const expectedBuffer = Buffer.from(expected, 'utf8') - const actualBuffer = Buffer.from(actual, 'utf8') - if (expectedBuffer.length !== actualBuffer.length) { - return false - } - - return timingSafeEqual(expectedBuffer, actualBuffer) -} - -function unauthorized(code: string, reason: string) { - return { - ok: false as const, - error: new HttpError({ - status: 401, - code, - message: 'Unauthorized', - reason, - }), - } -} - /** * Creates an in-memory cookie-backed session store for extension static assets. * @@ -147,7 +69,7 @@ export function createStaticAssetSessionStore(options: { now?: () => number } = return true } - const readActiveRecord = (assetSessionId: string, expiredCode: string): { ok: false, result: { ok: false, error: HttpError } } | { ok: true, record: StaticAssetSessionRecord } => { + const readActiveRecord = (assetSessionId: string, expiredCode: string): { ok: false, result: { error: HttpError, ok: false } } | { ok: true, record: StaticAssetSessionRecord } => { const record = records.get(assetSessionId) if (!record) { return { @@ -177,15 +99,15 @@ export function createStaticAssetSessionStore(options: { now?: () => number } = const assetSessionId = createOpaqueToken() const record: StaticAssetSessionRecord = { assetSessionId, + cookieName: createStaticAssetSessionCookieName(assetSessionId), + cookiePath: createCookiePath(input.extensionId, assetSessionId), + cookieValue: createOpaqueToken(), + expiresAt: now() + input.ttlMs, extensionId: input.extensionId, - version: input.version, ownerSessionId: input.ownerSessionId, pathPrefix: normalizePathPrefix(input.pathPrefix), ttlMs: input.ttlMs, - cookieName: createStaticAssetSessionCookieName(assetSessionId), - cookieValue: createOpaqueToken(), - cookiePath: createCookiePath(input.extensionId, assetSessionId), - expiresAt: now() + input.ttlMs, + version: input.version, } records.set(assetSessionId, record) @@ -253,7 +175,6 @@ export function createStaticAssetSessionStore(options: { now?: () => number } = return { createSession, - validateRequest, refreshSession(assetSessionId) { const active = readActiveRecord(assetSessionId, 'EXTENSION_ASSET_SESSION_EXPIRED') if (!active.ok) { @@ -263,6 +184,15 @@ export function createStaticAssetSessionStore(options: { now?: () => number } = active.record.expiresAt = now() + active.record.ttlMs return createSessionSnapshot(active.record) }, + revokeAll() { + return revokeWhere(() => true) + }, + revokeByExtensionId(extensionId) { + return revokeWhere(record => record.extensionId === extensionId) + }, + revokeByOwnerSessionId(ownerSessionId) { + return revokeWhere(record => record.ownerSessionId === ownerSessionId) + }, revokeSession(assetSessionId) { const record = records.get(assetSessionId) if (!record) { @@ -272,14 +202,84 @@ export function createStaticAssetSessionStore(options: { now?: () => number } = records.delete(assetSessionId) return createSessionSnapshot(record) }, - revokeByOwnerSessionId(ownerSessionId) { - return revokeWhere(record => record.ownerSessionId === ownerSessionId) - }, - revokeByExtensionId(extensionId) { - return revokeWhere(record => record.extensionId === extensionId) - }, - revokeAll() { - return revokeWhere(() => true) - }, + validateRequest, + } +} + +function cookieValuesMatch(expected: string, actual: string) { + const expectedBuffer = Buffer.from(expected, 'utf8') + const actualBuffer = Buffer.from(actual, 'utf8') + if (expectedBuffer.length !== actualBuffer.length) { + return false + } + + return timingSafeEqual(expectedBuffer, actualBuffer) +} + +function createCookiePath(extensionId: string, assetSessionId: string) { + return `/_airi/extensions/${encodeURIComponent(extensionId)}/sessions/${encodeURIComponent(assetSessionId)}/ui` +} + +function createOpaqueToken() { + // Node's base64url alphabet is route/cookie friendly while staying opaque. + return randomBytes(18).toString('base64url') +} + +function createSessionSnapshot(record: StaticAssetSessionRecord): StaticAssetSession { + return Object.freeze({ + assetSessionId: record.assetSessionId, + cookieName: record.cookieName, + cookiePath: record.cookiePath, + cookieValue: record.cookieValue, + expiresAt: record.expiresAt, + }) +} + +/** + * Normalizes requested asset paths before comparing them to session prefixes. + * + * Before: + * - " assets\\index.js " + * + * After: + * - "assets/index.js" + */ +function normalizeAssetPath(assetPath: string) { + return normalizeStaticAssetPath(assetPath.trim().replaceAll('\\', '/')) +} + +/** + * Normalizes asset path prefixes used to constrain a session. + * + * Before: + * - " assets\\ " + * + * After: + * - "assets/" + */ +function normalizePathPrefix(pathPrefix: string) { + const normalizedInput = pathPrefix.trim().replaceAll('\\', '/') + if (!normalizedInput) { + return '' + } + + const isDirectoryPrefix = normalizedInput.endsWith('/') + const normalized = normalizeStaticAssetPath(normalizedInput) + if (!normalized) { + throw new RangeError('Extension asset session pathPrefix must be empty or a safe plugin asset path') + } + + return isDirectoryPrefix ? `${normalized}/` : normalized +} + +function unauthorized(code: string, reason: string) { + return { + error: new HttpError({ + code, + message: 'Unauthorized', + reason, + status: 401, + }), + ok: false as const, } } diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/types.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/types.ts index 43df18669..0d392902b 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/types.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/types.ts @@ -1,13 +1,31 @@ import type { HttpError } from '../errors' +export type StaticAssetResolveResult + = | { error: HttpError, ok: false } + | { filePath: string, mtime: number, ok: true, size: number } + +/** + * Cookie data returned after creating a static asset session. + */ +export interface StaticAssetSession { + /** Opaque server-side session id embedded in extension asset routes. */ + readonly assetSessionId: string + /** Cookie name callers set on the asset route path. */ + readonly cookieName: string + /** Cookie path scope for browser requests. */ + readonly cookiePath: string + /** Opaque cookie value required to validate asset requests. */ + readonly cookieValue: string + /** Unix timestamp in milliseconds when the session expires. */ + readonly expiresAt: number +} + /** * Input required to create a cookie-backed static asset session. */ export interface StaticAssetSessionCreateInput { /** Plugin extension id that owns the served static assets. */ extensionId: string - /** Extension version expected by requests using this asset session. */ - version: string /** Parent plugin session id used for owner-scoped revocation. */ ownerSessionId: string /** @@ -19,67 +37,49 @@ export interface StaticAssetSessionCreateInput { pathPrefix: string /** Session lifetime in milliseconds from creation or refresh time. */ ttlMs: number -} - -/** - * Cookie data returned after creating a static asset session. - */ -export interface StaticAssetSession { - /** Opaque server-side session id embedded in extension asset routes. */ - readonly assetSessionId: string - /** Cookie name callers set on the asset route path. */ - readonly cookieName: string - /** Opaque cookie value required to validate asset requests. */ - readonly cookieValue: string - /** Cookie path scope for browser requests. */ - readonly cookiePath: string - /** Unix timestamp in milliseconds when the session expires. */ - readonly expiresAt: number -} - -/** - * Request data required to validate a cookie-backed static asset session. - */ -export interface StaticAssetSessionValidateInput { - /** Plugin extension id from the requested route. */ - extensionId: string - /** Extension version from the requested route. */ + /** Extension version expected by requests using this asset session. */ version: string - /** Opaque session id from the requested route. */ - assetSessionId: string - /** Static asset path being requested. */ - assetPath: string - /** Cookie value provided by the request, if any. */ - cookieValue: string | undefined } -/** - * Result of validating a cookie-backed static asset request. - */ -export type StaticAssetSessionValidationResult - = | { ok: true, session: StaticAssetSession } - | { ok: false, error: HttpError } - /** * In-memory store for cookie-backed extension static asset sessions. */ export interface StaticAssetSessionStore { /** Creates a new cookie-backed static asset session. */ createSession: (input: StaticAssetSessionCreateInput) => StaticAssetSession - /** Validates route and cookie data for a static asset request. */ - validateRequest: (input: StaticAssetSessionValidateInput) => StaticAssetSessionValidationResult /** Extends an existing session using its original TTL. */ refreshSession: (assetSessionId: string) => StaticAssetSession | undefined - /** Revokes one static asset session by id. */ - revokeSession: (assetSessionId: string) => StaticAssetSession | undefined - /** Revokes all static asset sessions owned by a plugin session. */ - revokeByOwnerSessionId: (ownerSessionId: string) => StaticAssetSession[] - /** Revokes all static asset sessions for one extension. */ - revokeByExtensionId: (extensionId: string) => StaticAssetSession[] /** Revokes every static asset session. */ revokeAll: () => StaticAssetSession[] + /** Revokes all static asset sessions for one extension. */ + revokeByExtensionId: (extensionId: string) => StaticAssetSession[] + /** Revokes all static asset sessions owned by a plugin session. */ + revokeByOwnerSessionId: (ownerSessionId: string) => StaticAssetSession[] + /** Revokes one static asset session by id. */ + revokeSession: (assetSessionId: string) => StaticAssetSession | undefined + /** Validates route and cookie data for a static asset request. */ + validateRequest: (input: StaticAssetSessionValidateInput) => StaticAssetSessionValidationResult } -export type StaticAssetResolveResult - = | { ok: true, filePath: string, size: number, mtime: number } - | { ok: false, error: HttpError } +/** + * Request data required to validate a cookie-backed static asset session. + */ +export interface StaticAssetSessionValidateInput { + /** Static asset path being requested. */ + assetPath: string + /** Opaque session id from the requested route. */ + assetSessionId: string + /** Cookie value provided by the request, if any. */ + cookieValue: string | undefined + /** Plugin extension id from the requested route. */ + extensionId: string + /** Extension version from the requested route. */ + version: string +} + +/** + * Result of validating a cookie-backed static asset request. + */ +export type StaticAssetSessionValidationResult + = | { error: HttpError, ok: false } + | { ok: true, session: StaticAssetSession } diff --git a/apps/stage-tamagotchi/src/main/services/airi/i18n/index.ts b/apps/stage-tamagotchi/src/main/services/airi/i18n/index.ts index e98df79c8..81b4cef68 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/i18n/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/i18n/index.ts @@ -11,7 +11,7 @@ import { injeca } from 'injeca' import { i18nGetLocale, i18nSetLocale } from '../../../../shared/eventa' -export async function createI18nService(params: { context: ReturnType['context'], window: BrowserWindow, i18n: I18n }) { +export async function createI18nService(params: { context: ReturnType['context'], i18n: I18n, window: BrowserWindow }) { const { config } = await injeca.resolve({ config: 'configs:app' } as { config: ProvidedBy> }) params.i18n.locale(config.get()?.language || 'en') diff --git a/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.test.ts index 1508d6013..5f12adba1 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.test.ts @@ -48,11 +48,11 @@ vi.mock('@modelcontextprotocol/sdk/client/stdio.js', async () => { return { StdioClientTransport: class { + close = vi.fn(async () => undefined) + stderr = new PassThrough() constructor(readonly server: unknown) {} - - close = vi.fn(async () => undefined) }, } }) @@ -76,10 +76,10 @@ describe('createMcpStdioManager', () => { }) const result = await manager.testServer({ - name: 'broken-server', config: { command: 'broken-mcp-server', }, + name: 'broken-server', }) expect(result.ok).toBe(false) diff --git a/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts b/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts index e6ed20685..ecee3737f 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts @@ -36,23 +36,23 @@ import { import { parseElectronMcpConfigText } from '../../../../shared/mcp-config' import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle' -interface McpServerSession { - client: Client - transport: StdioClientTransport - config: ElectronMcpStdioServerConfig +export interface McpStdioManager { + applyAndRestart: () => Promise + callTool: (payload: ElectronMcpCallToolPayload) => Promise + ensureConfigFile: () => Promise<{ path: string }> + getRuntimeStatus: () => ElectronMcpStdioRuntimeStatus + listTools: () => Promise + openConfigFile: () => Promise<{ path: string }> + readConfigText: () => Promise + stopAll: () => Promise + testServer: (payload: ElectronMcpStdioTestPayload) => Promise + writeConfigText: (text: string) => Promise } -export interface McpStdioManager { - ensureConfigFile: () => Promise<{ path: string }> - openConfigFile: () => Promise<{ path: string }> - applyAndRestart: () => Promise - listTools: () => Promise - callTool: (payload: ElectronMcpCallToolPayload) => Promise - stopAll: () => Promise - getRuntimeStatus: () => ElectronMcpStdioRuntimeStatus - readConfigText: () => Promise - writeConfigText: (text: string) => Promise - testServer: (payload: ElectronMcpStdioTestPayload) => Promise +interface McpServerSession { + client: Client + config: ElectronMcpStdioServerConfig + transport: StdioClientTransport } const defaultMcpConfig: ElectronMcpStdioConfigFile = { @@ -63,53 +63,38 @@ const mcpRequestTimeoutMsec = 10_000 const mcpRequestMaxTotalTimeoutMsec = 15_000 const mcpTestStderrMaxChars = 16_000 -function stringifyError(error: unknown) { - if (error instanceof Error) { - return error.message - } +export function createMcpServersService(params: { context: ReturnType['context'], manager: McpStdioManager }) { + defineInvokeHandler(params.context, electronMcpOpenConfigFile, async () => { + return params.manager.openConfigFile() + }) - return String(error) -} + defineInvokeHandler(params.context, electronMcpApplyAndRestart, async () => { + return params.manager.applyAndRestart() + }) -function getConfigPath() { - return join(app.getPath('userData'), 'mcp.json') -} + defineInvokeHandler(params.context, electronMcpGetRuntimeStatus, async () => { + return params.manager.getRuntimeStatus() + }) -function parseQualifiedToolName(name: string) { - const separatorIndex = name.indexOf(toolNameSeparator) - if (separatorIndex <= 0 || separatorIndex === name.length - toolNameSeparator.length) { - throw new Error(`invalid qualified tool name: ${name}`) - } + defineInvokeHandler(params.context, electronMcpListTools, async () => { + return params.manager.listTools() + }) - return { - serverName: name.slice(0, separatorIndex), - toolName: name.slice(separatorIndex + toolNameSeparator.length), - } -} + defineInvokeHandler(params.context, electronMcpCallTool, async (payload) => { + return params.manager.callTool(payload) + }) -function resolveFallbackToolName(toolName: string): string | undefined { - const normalizedTransportPrefix = toolName - .replace(/^\.(?:stdio|stdo)::/, '') - .replace(/^(?:stdio|stdo)::/, '') - if (normalizedTransportPrefix !== toolName) { - return normalizedTransportPrefix - } + defineInvokeHandler(params.context, electronMcpReadConfigText, async () => { + return params.manager.readConfigText() + }) - const lastSeparatorIndex = toolName.lastIndexOf(toolNameSeparator) - if (lastSeparatorIndex <= 0 || lastSeparatorIndex === toolName.length - toolNameSeparator.length) { - return undefined - } + defineInvokeHandler(params.context, electronMcpWriteConfigText, async (payload) => { + return params.manager.writeConfigText(payload.text) + }) - return toolName.slice(lastSeparatorIndex + toolNameSeparator.length) -} - -async function closeSession(session: McpServerSession) { - try { - await session.client.close() - } - catch { - await session.transport.close() - } + defineInvokeHandler(params.context, electronMcpTestServer, async (payload) => { + return params.manager.testServer(payload) + }) } export function createMcpStdioManager(): McpStdioManager { @@ -153,11 +138,11 @@ export function createMcpStdioManager(): McpStdioManager { for (const [name, session] of entries) { await closeSession(session) setRuntimeStatus({ - name, - state: 'stopped', - command: session.config.command, args: session.config.args ?? [], + command: session.config.command, + name, pid: null, + state: 'stopped', }) sessions.delete(name) } @@ -165,10 +150,10 @@ export function createMcpStdioManager(): McpStdioManager { const startServer = async (name: string, config: ElectronMcpStdioServerConfig) => { const transport = new StdioClientTransport({ - command: config.command, args: config.args ?? [], - env: config.env, + command: config.command, cwd: config.cwd, + env: config.env, stderr: 'pipe', }) const client = new Client({ @@ -184,13 +169,13 @@ export function createMcpStdioManager(): McpStdioManager { log.withFields({ serverName: name }).warn(text) } }) - sessions.set(name, { client, transport, config }) + sessions.set(name, { client, config, transport }) setRuntimeStatus({ - name, - state: 'running', - command: config.command, args: config.args ?? [], + command: config.command, + name, pid: transport.pid, + state: 'running', }) } catch (error) { @@ -207,21 +192,21 @@ export function createMcpStdioManager(): McpStdioManager { runtimeStatuses.clear() const result: ElectronMcpStdioApplyResult = { - path, - started: [], failed: [], + path, skipped: [], + started: [], } for (const [name, server] of Object.entries(config.mcpServers)) { if (server.enabled === false) { result.skipped.push({ name, reason: 'disabled' }) setRuntimeStatus({ - name, - state: 'stopped', - command: server.command, args: server.args ?? [], + command: server.command, + name, pid: null, + state: 'stopped', }) continue } @@ -232,14 +217,14 @@ export function createMcpStdioManager(): McpStdioManager { } catch (error) { const message = stringifyError(error) - result.failed.push({ name, error: message }) + result.failed.push({ error: message, name }) setRuntimeStatus({ - name, - state: 'error', - command: server.command, args: server.args ?? [], - pid: null, + command: server.command, lastError: message, + name, + pid: null, + state: 'error', }) } } @@ -254,15 +239,15 @@ export function createMcpStdioManager(): McpStdioManager { const listResult = await Promise.all(entries.map(async ([serverName, session]) => { try { const response = await session.client.listTools(undefined, { - timeout: mcpRequestTimeoutMsec, maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec, + timeout: mcpRequestTimeoutMsec, }) return response.tools.map(item => ({ - serverName, - name: `${serverName}${toolNameSeparator}${item.name}`, - toolName: item.name, description: item.description, inputSchema: item.inputSchema, + name: `${serverName}${toolNameSeparator}${item.name}`, + serverName, + toolName: item.name, })) } catch (error) { @@ -284,11 +269,11 @@ export function createMcpStdioManager(): McpStdioManager { let result try { result = await session.client.callTool({ - name: toolName, arguments: payload.arguments ?? {}, + name: toolName, }, undefined, { - timeout: mcpRequestTimeoutMsec, maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec, + timeout: mcpRequestTimeoutMsec, }) } catch (error) { @@ -298,17 +283,17 @@ export function createMcpStdioManager(): McpStdioManager { } log.withFields({ - serverName, - requestedToolName: toolName, fallbackToolName, + requestedToolName: toolName, + serverName, }).warn('retrying mcp tool call with normalized tool name') result = await session.client.callTool({ - name: fallbackToolName, arguments: payload.arguments ?? {}, + name: fallbackToolName, }, undefined, { - timeout: mcpRequestTimeoutMsec, maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec, + timeout: mcpRequestTimeoutMsec, }) } @@ -353,7 +338,7 @@ export function createMcpStdioManager(): McpStdioManager { const testServer = async (payload: ElectronMcpStdioTestPayload): Promise => { const startedAt = Date.now() - let transport: StdioClientTransport | null = null + let transport: null | StdioClientTransport = null let client: Client | null = null const stderrChunks: string[] = [] @@ -370,10 +355,10 @@ export function createMcpStdioManager(): McpStdioManager { try { transport = new StdioClientTransport({ - command: payload.config.command, args: payload.config.args ?? [], - env: payload.config.env, + command: payload.config.command, cwd: payload.config.cwd, + env: payload.config.env, stderr: 'pipe', }) client = new Client({ @@ -390,8 +375,8 @@ export function createMcpStdioManager(): McpStdioManager { await withDeadline(client.connect(transport), mcpRequestMaxTotalTimeoutMsec, 'connect') const response = await client.listTools(undefined, { - timeout: mcpRequestTimeoutMsec, maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec, + timeout: mcpRequestTimeoutMsec, }) if (stderrChunks.length > 0) { @@ -399,9 +384,9 @@ export function createMcpStdioManager(): McpStdioManager { } return { + durationMs: Date.now() - startedAt, ok: true, tools: response.tools.map(tool => tool.name), - durationMs: Date.now() - startedAt, } } catch (error) { @@ -409,9 +394,9 @@ export function createMcpStdioManager(): McpStdioManager { // Keep only the tail so a noisy failed server cannot flood the settings UI. const stderr = stderrChunks.join('').trim().slice(-mcpTestStderrMaxChars) return { - ok: false, - error: stderr ? `${message}\n\n${stderr}` : message, durationMs: Date.now() - startedAt, + error: stderr ? `${message}\n\n${stderr}` : message, + ok: false, } } finally { @@ -425,16 +410,16 @@ export function createMcpStdioManager(): McpStdioManager { } return { - ensureConfigFile, - openConfigFile, applyAndRestart, - listTools, callTool, - stopAll, + ensureConfigFile, getRuntimeStatus, + listTools, + openConfigFile, readConfigText, - writeConfigText, + stopAll, testServer, + writeConfigText, } } @@ -458,36 +443,51 @@ export async function setupMcpStdioManager() { return manager } -export function createMcpServersService(params: { context: ReturnType['context'], manager: McpStdioManager }) { - defineInvokeHandler(params.context, electronMcpOpenConfigFile, async () => { - return params.manager.openConfigFile() - }) - - defineInvokeHandler(params.context, electronMcpApplyAndRestart, async () => { - return params.manager.applyAndRestart() - }) - - defineInvokeHandler(params.context, electronMcpGetRuntimeStatus, async () => { - return params.manager.getRuntimeStatus() - }) - - defineInvokeHandler(params.context, electronMcpListTools, async () => { - return params.manager.listTools() - }) - - defineInvokeHandler(params.context, electronMcpCallTool, async (payload) => { - return params.manager.callTool(payload) - }) - - defineInvokeHandler(params.context, electronMcpReadConfigText, async () => { - return params.manager.readConfigText() - }) - - defineInvokeHandler(params.context, electronMcpWriteConfigText, async (payload) => { - return params.manager.writeConfigText(payload.text) - }) - - defineInvokeHandler(params.context, electronMcpTestServer, async (payload) => { - return params.manager.testServer(payload) - }) +async function closeSession(session: McpServerSession) { + try { + await session.client.close() + } + catch { + await session.transport.close() + } +} + +function getConfigPath() { + return join(app.getPath('userData'), 'mcp.json') +} + +function parseQualifiedToolName(name: string) { + const separatorIndex = name.indexOf(toolNameSeparator) + if (separatorIndex <= 0 || separatorIndex === name.length - toolNameSeparator.length) { + throw new Error(`invalid qualified tool name: ${name}`) + } + + return { + serverName: name.slice(0, separatorIndex), + toolName: name.slice(separatorIndex + toolNameSeparator.length), + } +} + +function resolveFallbackToolName(toolName: string): string | undefined { + const normalizedTransportPrefix = toolName + .replace(/^\.(?:stdio|stdo)::/, '') + .replace(/^(?:stdio|stdo)::/, '') + if (normalizedTransportPrefix !== toolName) { + return normalizedTransportPrefix + } + + const lastSeparatorIndex = toolName.lastIndexOf(toolNameSeparator) + if (lastSeparatorIndex <= 0 || lastSeparatorIndex === toolName.length - toolNameSeparator.length) { + return undefined + } + + return toolName.slice(lastSeparatorIndex + toolNameSeparator.length) +} + +function stringifyError(error: unknown) { + if (error instanceof Error) { + return error.message + } + + return String(error) } diff --git a/apps/stage-tamagotchi/src/main/services/airi/onboarding/index.ts b/apps/stage-tamagotchi/src/main/services/airi/onboarding/index.ts index e52980580..34ba84155 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/onboarding/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/onboarding/index.ts @@ -14,8 +14,8 @@ const ANIMATION_DURATION = 350 export function createOnboardingService(params: { context: ReturnType['context'] - onboardingWindowManager: OnboardingWindowManager mainWindow: BrowserWindow + onboardingWindowManager: OnboardingWindowManager }) { const mainWindowAnimator = new Animator(params.mainWindow) let cleanupOnClosed: (() => void) | undefined @@ -29,15 +29,15 @@ export function createOnboardingService(params: { const adjacent = computeAdjacentPosition( onboardingBounds, - { width: savedBounds.width, height: savedBounds.height }, + { height: savedBounds.height, width: savedBounds.width }, display.workArea, ) mainWindowAnimator.windowBoundsAnimateTo({ + height: adjacent.height, + width: adjacent.width, x: adjacent.x, y: adjacent.y, - width: adjacent.width, - height: adjacent.height, }, { duration: ANIMATION_DURATION }) let userMovedManually = false diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/auto-reload/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/auto-reload/index.ts index 871039d44..6929e44e2 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/auto-reload/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/auto-reload/index.ts @@ -24,12 +24,12 @@ import { manifestIdOf } from '../../host/registry' * - N/A */ export interface ExtensionAutoReloadFeatureOptions { - log: ReturnType getConfig: () => ExtensionConfig - listEntries: () => ManifestEntry[] isLoaded: (extensionId: string) => boolean - resolveWatchPaths: (extensionId: string) => string[] + listEntries: () => ManifestEntry[] + log: ReturnType reload: (extensionId: string, changedPath: string) => Promise + resolveWatchPaths: (extensionId: string) => string[] } /** @@ -102,6 +102,21 @@ export function createExtensionAutoReloadFeature(options: ExtensionAutoReloadFea } return { + clearExtension(extensionId: string) { + clearTimer(extensionId) + closeWatchers(extensionId) + }, + dispose() { + const managedNames = new Set([ + ...autoReloadTimers.keys(), + ...autoReloadWatchers.keys(), + ]) + + for (const extensionId of managedNames) { + clearTimer(extensionId) + closeWatchers(extensionId) + } + }, sync() { const enabledExtensionIds = new Set(options.getConfig().autoReload) const desiredExtensionIds = new Set(options.listEntries() @@ -144,20 +159,5 @@ export function createExtensionAutoReloadFeature(options: ExtensionAutoReloadFea } } }, - clearExtension(extensionId: string) { - clearTimer(extensionId) - closeWatchers(extensionId) - }, - dispose() { - const managedNames = new Set([ - ...autoReloadTimers.keys(), - ...autoReloadWatchers.keys(), - ]) - - for (const extensionId of managedNames) { - clearTimer(extensionId) - closeWatchers(extensionId) - } - }, } } diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.test.ts index 40d845cd1..c3ffb0a5c 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.test.ts @@ -14,54 +14,54 @@ vi.mock('../../../http-server/static-assets', () => ({ createStaticAssetService: mockState.createStaticAssetService, })) -function createSession(assetSessionId: string, extensionId = 'airi-plugin-game-chess'): StaticAssetSession { - return { - assetSessionId, - cookieName: `airi_extension_asset_session_${assetSessionId}`, - cookieValue: `cookie-value-${assetSessionId}`, - cookiePath: `/_airi/extensions/${extensionId}/sessions/${assetSessionId}/ui`, - expiresAt: 123_456, - } -} - -function createFakeServer(options: { - baseUrl?: string - createSessionResult?: StaticAssetSession - revokeByOwnerSessionIdResult?: StaticAssetSession[] - revokeByExtensionIdResult?: StaticAssetSession[] - revokeAllResult?: StaticAssetSession[] -} = {}) { - return { - key: 'static-assets', - start: vi.fn(async () => {}), - stop: vi.fn(async () => {}), - getBaseUrl: vi.fn(() => options.baseUrl), - createSession: vi.fn(() => options.createSessionResult ?? createSession('asset-session-1')), - revokeSession: vi.fn((assetSessionId: string) => createSession(assetSessionId)), - revokeByOwnerSessionId: vi.fn(() => options.revokeByOwnerSessionIdResult ?? []), - revokeByExtensionId: vi.fn(() => options.revokeByExtensionIdResult ?? []), - revokeAll: vi.fn(() => options.revokeAllResult ?? []), - } satisfies StaticAssetService -} - function createFakeCookieAdapter() { const setCookies: ExtensionAssetCookie[] = [] const removedCookies: ExtensionAssetCookie[] = [] return { adapter: { - setCookie: vi.fn(async (cookie) => { - setCookies.push(cookie) - }), removeCookie: vi.fn(async (cookie) => { removedCookies.push(cookie) }), + setCookie: vi.fn(async (cookie) => { + setCookies.push(cookie) + }), } satisfies ExtensionAssetCookieAdapter, removedCookies, setCookies, } } +function createFakeServer(options: { + baseUrl?: string + createSessionResult?: StaticAssetSession + revokeAllResult?: StaticAssetSession[] + revokeByExtensionIdResult?: StaticAssetSession[] + revokeByOwnerSessionIdResult?: StaticAssetSession[] +} = {}) { + return { + createSession: vi.fn(() => options.createSessionResult ?? createSession('asset-session-1')), + getBaseUrl: vi.fn(() => options.baseUrl), + key: 'static-assets', + revokeAll: vi.fn(() => options.revokeAllResult ?? []), + revokeByExtensionId: vi.fn(() => options.revokeByExtensionIdResult ?? []), + revokeByOwnerSessionId: vi.fn(() => options.revokeByOwnerSessionIdResult ?? []), + revokeSession: vi.fn((assetSessionId: string) => createSession(assetSessionId)), + start: vi.fn(async () => {}), + stop: vi.fn(async () => {}), + } satisfies StaticAssetService +} + +function createSession(assetSessionId: string, extensionId = 'airi-plugin-game-chess'): StaticAssetSession { + return { + assetSessionId, + cookieName: `airi_extension_asset_session_${assetSessionId}`, + cookiePath: `/_airi/extensions/${extensionId}/sessions/${assetSessionId}/ui`, + cookieValue: `cookie-value-${assetSessionId}`, + expiresAt: 123_456, + } +} + describe('createExtensionAssetService', () => { beforeEach(() => { mockState.createStaticAssetService.mockReset() @@ -76,41 +76,41 @@ describe('createExtensionAssetService', () => { mockState.createStaticAssetService.mockReturnValue(server) const service = createExtensionAssetService({ - getManifestEntryByExtensionId: () => new Map(), cookieAdapter: adapter, + getManifestEntryByExtensionId: () => new Map(), }) const result = await service.createAssetSession({ extensionId: 'airi-plugin-game-chess', - version: '1.0.0', ownerSessionId: 'owner-session-1', - routeAssetPath: 'assets/app.js', pathPrefix: 'assets/', + routeAssetPath: 'assets/app.js', ttlMs: 60_000, + version: '1.0.0', }) expect(server.createSession).toHaveBeenCalledWith({ extensionId: 'airi-plugin-game-chess', - version: '1.0.0', ownerSessionId: 'owner-session-1', pathPrefix: 'assets/', ttlMs: 60_000, + version: '1.0.0', }) expect(adapter.setCookie).toHaveBeenCalledOnce() expect(setCookies).toEqual([ { - name: 'airi_extension_asset_session_asset-session-1', - value: 'cookie-value-asset-session-1', - url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui', - path: '/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui', expiresAt: 123_456, + name: 'airi_extension_asset_session_asset-session-1', + path: '/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui', + url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui', + value: 'cookie-value-asset-session-1', }, ]) expect(result).toEqual({ - url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/assets/app.js', assetSessionId: 'asset-session-1', cookie: setCookies[0], expiresAt: 123_456, + url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/assets/app.js', }) }) @@ -123,17 +123,17 @@ describe('createExtensionAssetService', () => { mockState.createStaticAssetService.mockReturnValue(server) const service = createExtensionAssetService({ - getManifestEntryByExtensionId: () => new Map(), cookieAdapter: adapter, + getManifestEntryByExtensionId: () => new Map(), }) await expect(service.createAssetSession({ extensionId: 'airi-plugin-game-chess', - version: '1.0.0', ownerSessionId: 'owner-session-1', - routeAssetPath: 'assets/app.js', pathPrefix: 'assets/', + routeAssetPath: 'assets/app.js', ttlMs: 60_000, + version: '1.0.0', })).rejects.toThrow('Extension asset server base URL is unavailable') expect(server.revokeSession).toHaveBeenCalledWith('asset-session-2') @@ -149,17 +149,17 @@ describe('createExtensionAssetService', () => { const { adapter } = createFakeCookieAdapter() mockState.createStaticAssetService.mockReturnValue(server) const service = createExtensionAssetService({ - getManifestEntryByExtensionId: () => new Map(), cookieAdapter: adapter, + getManifestEntryByExtensionId: () => new Map(), }) await expect(service.createAssetSession({ extensionId: 'airi-plugin-game-chess', - version: '1.0.0', ownerSessionId: 'owner-session-1', - routeAssetPath: '../secret.txt', pathPrefix: '', + routeAssetPath: '../secret.txt', ttlMs: 60_000, + version: '1.0.0', })).rejects.toThrow('Extension asset session routeAssetPath must be a safe extension asset path') expect(server.revokeSession).toHaveBeenCalledWith('asset-session-3') @@ -170,11 +170,11 @@ describe('createExtensionAssetService', () => { await expect(service.createAssetSession({ extensionId: 'airi-plugin-game-chess', - version: '1.0.0', ownerSessionId: 'owner-session-1', - routeAssetPath: 'assets/app.js', pathPrefix: 'assets/', + routeAssetPath: 'assets/app.js', ttlMs: 60_000, + version: '1.0.0', })).rejects.toThrow('cookie jar unavailable') expect(server.revokeSession).toHaveBeenCalledWith('asset-session-4') @@ -187,17 +187,17 @@ describe('createExtensionAssetService', () => { const allSession = createSession('all-asset-session') const server = createFakeServer({ baseUrl: 'http://127.0.0.1:48123', - revokeByOwnerSessionIdResult: [ownerSession], - revokeByExtensionIdResult: [pluginSession], revokeAllResult: [allSession], + revokeByExtensionIdResult: [pluginSession], + revokeByOwnerSessionIdResult: [ownerSession], }) server.revokeSession.mockReturnValue(directSession) const { adapter, removedCookies } = createFakeCookieAdapter() mockState.createStaticAssetService.mockReturnValue(server) const service = createExtensionAssetService({ - getManifestEntryByExtensionId: () => new Map(), cookieAdapter: adapter, + getManifestEntryByExtensionId: () => new Map(), }) await service.revokeSession('direct-asset-session') @@ -212,32 +212,32 @@ describe('createExtensionAssetService', () => { expect(adapter.removeCookie).toHaveBeenCalledTimes(4) expect(removedCookies).toEqual([ { + expiresAt: 123_456, name: 'airi_extension_asset_session_direct-asset-session', - value: 'cookie-value-direct-asset-session', - url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/direct-asset-session/ui', path: '/_airi/extensions/airi-plugin-game-chess/sessions/direct-asset-session/ui', - expiresAt: 123_456, + url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/direct-asset-session/ui', + value: 'cookie-value-direct-asset-session', }, { + expiresAt: 123_456, name: 'airi_extension_asset_session_owner-asset-session', - value: 'cookie-value-owner-asset-session', - url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/owner-asset-session/ui', path: '/_airi/extensions/airi-plugin-game-chess/sessions/owner-asset-session/ui', - expiresAt: 123_456, + url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/owner-asset-session/ui', + value: 'cookie-value-owner-asset-session', }, { + expiresAt: 123_456, name: 'airi_extension_asset_session_plugin-asset-session', - value: 'cookie-value-plugin-asset-session', - url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/plugin-asset-session/ui', path: '/_airi/extensions/airi-plugin-game-chess/sessions/plugin-asset-session/ui', - expiresAt: 123_456, + url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/plugin-asset-session/ui', + value: 'cookie-value-plugin-asset-session', }, { - name: 'airi_extension_asset_session_all-asset-session', - value: 'cookie-value-all-asset-session', - url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/all-asset-session/ui', - path: '/_airi/extensions/airi-plugin-game-chess/sessions/all-asset-session/ui', expiresAt: 123_456, + name: 'airi_extension_asset_session_all-asset-session', + path: '/_airi/extensions/airi-plugin-game-chess/sessions/all-asset-session/ui', + url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/all-asset-session/ui', + value: 'cookie-value-all-asset-session', }, ]) }) @@ -252,8 +252,8 @@ describe('createExtensionAssetService', () => { mockState.createStaticAssetService.mockReturnValue(server) const service = createExtensionAssetService({ - getManifestEntryByExtensionId: () => new Map(), cookieAdapter: adapter, + getManifestEntryByExtensionId: () => new Map(), }) await service.stop() @@ -263,11 +263,11 @@ describe('createExtensionAssetService', () => { expect(server.stop).toHaveBeenCalledOnce() expect(removedCookies).toEqual([ { - name: 'airi_extension_asset_session_stop-asset-session', - value: 'cookie-value-stop-asset-session', - url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/stop-asset-session/ui', - path: '/_airi/extensions/airi-plugin-game-chess/sessions/stop-asset-session/ui', expiresAt: 123_456, + name: 'airi_extension_asset_session_stop-asset-session', + path: '/_airi/extensions/airi-plugin-game-chess/sessions/stop-asset-session/ui', + url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/stop-asset-session/ui', + value: 'cookie-value-stop-asset-session', }, ]) }) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.ts index e56eb5aa5..bb7ecb16d 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.ts @@ -5,6 +5,98 @@ import type { StaticAssetSession } from '../../../http-server/static-assets/type import { createStaticAssetService } from '../../../http-server/static-assets' import { buildMountedStaticAssetPath } from '../../../http-server/static-assets/paths' +/** + * Describes the cookie material Electron must apply before loading an extension asset URL. + * + * Use when: + * - Main process bridges server-side asset sessions into Electron's cookie jar + * - Revocation needs enough cookie identity to remove previously issued asset cookies + * + * Expects: + * - `url` belongs to the local asset server origin + * - `path` matches the server-issued cookie path for the asset session route + * + * Returns: + * - N/A + */ +export interface ExtensionAssetCookie { + /** Unix timestamp in milliseconds when the cookie-backed asset session expires. */ + expiresAt: number + /** Cookie name generated for the asset session. */ + name: string + /** Route path scope generated by the asset session store. */ + path: string + /** Absolute URL on the local asset server used by Electron cookie APIs. */ + url: string + /** Opaque cookie value required by the static asset route. */ + value: string +} + +/** + * Applies and removes extension asset cookies from the Electron host. + * + * Use when: + * - Asset sessions must exist in Electron's cookie jar before an iframe navigates to its URL + * - Asset session revocation must remove browser-visible cookie state + * + * Expects: + * - `setCookie` resolves only after Electron can send the cookie for matching asset URLs + * - `removeCookie` is idempotent for already-removed cookies + * + * Returns: + * - N/A + */ +export interface ExtensionAssetCookieAdapter { + removeCookie: (cookie: ExtensionAssetCookie) => Promise + setCookie: (cookie: ExtensionAssetCookie) => Promise +} + +/** + * Defines the extension-owned asset hosting service used by the extension host. + * + * Use when: + * - Plugin snapshots need mounted asset URLs without depending on the H3 server shape + * - Host teardown must revoke extension asset access independently from widget/gamelet logic + * + * Expects: + * - Implementations own the underlying transport, cookie, and session lifecycle + * + * Returns: + * - A startable/stoppable asset-hosting service with generic extension-facing methods + */ +export interface ExtensionAssetService extends ServerManager { + createAssetSession: (input: ExtensionAssetSessionInput) => Promise + getBaseUrl: () => string | undefined + revokeAll: () => Promise + revokeByExtensionId: (extensionId: string) => Promise + revokeByOwnerSessionId: (ownerSessionId: string) => Promise + revokeSession: (assetSessionId: string) => Promise +} + +/** + * Describes an extension asset session prepared for renderer iframe navigation. + * + * Use when: + * - A plugin iframe needs a mounted static asset URL and pre-applied cookie state + * - Callers need the opaque session id for later targeted revocation + * + * Expects: + * - `cookie` was set through the host adapter before the value is returned + * + * Returns: + * - Renderer-facing URL plus server and cookie metadata + */ +export interface ExtensionAssetSession { + /** Opaque server-side asset session id embedded in mounted asset routes. */ + assetSessionId: string + /** Cookie data that was applied through the host adapter. */ + cookie: ExtensionAssetCookie + /** Unix timestamp in milliseconds when the cookie-backed asset session expires. */ + expiresAt: number + /** Absolute mounted asset URL safe to hand to a renderer iframe after cookie setup. */ + url: string +} + /** * Describes one extension asset session creation request. * @@ -23,62 +115,16 @@ import { buildMountedStaticAssetPath } from '../../../http-server/static-assets/ export interface ExtensionAssetSessionInput { /** Extension manifest id that owns the static asset root. */ extensionId: string - /** Extension/plugin version expected by the server-side session validator. */ - version: string /** Parent extension session id used for owner-scoped revocation. */ ownerSessionId: string - /** Asset path to mount in the returned renderer-facing URL. */ - routeAssetPath: string /** Allowed asset path prefix enforced by the server-side session store. */ pathPrefix: string + /** Asset path to mount in the returned renderer-facing URL. */ + routeAssetPath: string /** Session lifetime in milliseconds from creation or refresh time. */ ttlMs: number -} - -/** - * Describes the cookie material Electron must apply before loading an extension asset URL. - * - * Use when: - * - Main process bridges server-side asset sessions into Electron's cookie jar - * - Revocation needs enough cookie identity to remove previously issued asset cookies - * - * Expects: - * - `url` belongs to the local asset server origin - * - `path` matches the server-issued cookie path for the asset session route - * - * Returns: - * - N/A - */ -export interface ExtensionAssetCookie { - /** Cookie name generated for the asset session. */ - name: string - /** Opaque cookie value required by the static asset route. */ - value: string - /** Absolute URL on the local asset server used by Electron cookie APIs. */ - url: string - /** Route path scope generated by the asset session store. */ - path: string - /** Unix timestamp in milliseconds when the cookie-backed asset session expires. */ - expiresAt: number -} - -/** - * Applies and removes extension asset cookies from the Electron host. - * - * Use when: - * - Asset sessions must exist in Electron's cookie jar before an iframe navigates to its URL - * - Asset session revocation must remove browser-visible cookie state - * - * Expects: - * - `setCookie` resolves only after Electron can send the cookie for matching asset URLs - * - `removeCookie` is idempotent for already-removed cookies - * - * Returns: - * - N/A - */ -export interface ExtensionAssetCookieAdapter { - setCookie: (cookie: ExtensionAssetCookie) => Promise - removeCookie: (cookie: ExtensionAssetCookie) => Promise + /** Extension/plugin version expected by the server-side session validator. */ + version: string } /** @@ -96,64 +142,8 @@ export interface ExtensionAssetCookieAdapter { * - A mounted asset URL and cookie-backed session metadata */ export interface ExtensionAssetSnapshotService { - getBaseUrl: () => string | undefined createAssetSession: (input: Omit) => Promise -} - -/** - * Describes an extension asset session prepared for renderer iframe navigation. - * - * Use when: - * - A plugin iframe needs a mounted static asset URL and pre-applied cookie state - * - Callers need the opaque session id for later targeted revocation - * - * Expects: - * - `cookie` was set through the host adapter before the value is returned - * - * Returns: - * - Renderer-facing URL plus server and cookie metadata - */ -export interface ExtensionAssetSession { - /** Absolute mounted asset URL safe to hand to a renderer iframe after cookie setup. */ - url: string - /** Opaque server-side asset session id embedded in mounted asset routes. */ - assetSessionId: string - /** Cookie data that was applied through the host adapter. */ - cookie: ExtensionAssetCookie - /** Unix timestamp in milliseconds when the cookie-backed asset session expires. */ - expiresAt: number -} - -/** - * Defines the extension-owned asset hosting service used by the extension host. - * - * Use when: - * - Plugin snapshots need mounted asset URLs without depending on the H3 server shape - * - Host teardown must revoke extension asset access independently from widget/gamelet logic - * - * Expects: - * - Implementations own the underlying transport, cookie, and session lifecycle - * - * Returns: - * - A startable/stoppable asset-hosting service with generic extension-facing methods - */ -export interface ExtensionAssetService extends ServerManager { getBaseUrl: () => string | undefined - createAssetSession: (input: ExtensionAssetSessionInput) => Promise - revokeSession: (assetSessionId: string) => Promise - revokeByOwnerSessionId: (ownerSessionId: string) => Promise - revokeByExtensionId: (extensionId: string) => Promise - revokeAll: () => Promise -} - -function createExtensionAssetCookie(baseUrl: string, session: StaticAssetSession): ExtensionAssetCookie { - return { - name: session.cookieName, - value: session.cookieValue, - url: new URL(session.cookiePath, baseUrl).toString(), - path: session.cookiePath, - expiresAt: session.expiresAt, - } } /** @@ -171,8 +161,8 @@ function createExtensionAssetCookie(baseUrl: string, session: StaticAssetSession * - An extension-facing asset host service with generic extension asset methods */ export function createExtensionAssetService(options: { - getManifestEntryByExtensionId: () => Map cookieAdapter: ExtensionAssetCookieAdapter + getManifestEntryByExtensionId: () => Map }): ExtensionAssetService { const server = createStaticAssetService({ getManifestEntryByExtensionId: options.getManifestEntryByExtensionId }) let lastBaseUrl: string | undefined @@ -195,24 +185,13 @@ export function createExtensionAssetService(options: { } return { - key: 'extension-assets', - async start() { - await server.start() - }, - async stop() { - await revokeSessions(server.revokeAll()) - await server.stop() - }, - getBaseUrl() { - return readBaseUrl() - }, async createAssetSession(input) { const session = server.createSession({ extensionId: input.extensionId, - version: input.version, ownerSessionId: input.ownerSessionId, pathPrefix: input.pathPrefix, ttlMs: input.ttlMs, + version: input.version, }) try { @@ -222,9 +201,9 @@ export function createExtensionAssetService(options: { } const mountedPath = buildMountedStaticAssetPath({ - extensionId: input.extensionId, - assetSessionId: session.assetSessionId, assetPath: input.routeAssetPath, + assetSessionId: session.assetSessionId, + extensionId: input.extensionId, }) if (!mountedPath) { @@ -235,10 +214,10 @@ export function createExtensionAssetService(options: { await options.cookieAdapter.setCookie(cookie) return { - url: new URL(mountedPath, baseUrl).toString(), assetSessionId: session.assetSessionId, cookie, expiresAt: session.expiresAt, + url: new URL(mountedPath, baseUrl).toString(), } } catch (error) { @@ -246,6 +225,19 @@ export function createExtensionAssetService(options: { throw error } }, + getBaseUrl() { + return readBaseUrl() + }, + key: 'extension-assets', + async revokeAll() { + await revokeSessions(server.revokeAll()) + }, + async revokeByExtensionId(extensionId) { + await revokeSessions(server.revokeByExtensionId(extensionId)) + }, + async revokeByOwnerSessionId(ownerSessionId) { + await revokeSessions(server.revokeByOwnerSessionId(ownerSessionId)) + }, async revokeSession(assetSessionId) { const session = server.revokeSession(assetSessionId) if (!session) { @@ -254,14 +246,22 @@ export function createExtensionAssetService(options: { await revokeSessions([session]) }, - async revokeByOwnerSessionId(ownerSessionId) { - await revokeSessions(server.revokeByOwnerSessionId(ownerSessionId)) + async start() { + await server.start() }, - async revokeByExtensionId(extensionId) { - await revokeSessions(server.revokeByExtensionId(extensionId)) - }, - async revokeAll() { + async stop() { await revokeSessions(server.revokeAll()) + await server.stop() }, } } + +function createExtensionAssetCookie(baseUrl: string, session: StaticAssetSession): ExtensionAssetCookie { + return { + expiresAt: session.expiresAt, + name: session.cookieName, + path: session.cookiePath, + url: new URL(session.cookiePath, baseUrl).toString(), + value: session.cookieValue, + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/config.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/config.ts index 7ab7fab4b..f0de0c815 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/config.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/config.ts @@ -5,21 +5,13 @@ import { array, object, record, string } from 'valibot' import { createConfig } from '../../../../libs/electron/persistence' const extensionConfigSchema = object({ - enabled: array(string()), autoReload: array(string()), + enabled: array(string()), known: record(string(), object({ path: string(), })), }) -function createDefaultExtensionConfig(): ExtensionConfig { - return { - enabled: [], - autoReload: [], - known: {}, - } -} - /** * Persists extension host enablement and discovery metadata. * @@ -35,8 +27,8 @@ function createDefaultExtensionConfig(): ExtensionConfig { * - Accessors around the persisted extension config document */ export interface ExtensionHostConfigStore { - setup: () => void get: () => ExtensionConfig + setup: () => void update: (config: ExtensionConfig) => void } @@ -54,19 +46,27 @@ export interface ExtensionHostConfigStore { */ export function createExtensionHostConfigStore(): ExtensionHostConfigStore { const extensionConfig = createConfig('extensions', 'v1.json', extensionConfigSchema, { - default: createDefaultExtensionConfig(), autoHeal: true, + default: createDefaultExtensionConfig(), }) return { - setup() { - extensionConfig.setup() - }, get() { return extensionConfig.get() ?? createDefaultExtensionConfig() }, + setup() { + extensionConfig.setup() + }, update(config) { extensionConfig.update(config) }, } } + +function createDefaultExtensionConfig(): ExtensionConfig { + return { + autoReload: [], + enabled: [], + known: {}, + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts index bbb649920..ea050d354 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts @@ -25,13 +25,13 @@ import { buildPluginRegistrySnapshot } from './registry' * - A full debug snapshot with registry, sessions, kits, modules, and capabilities */ export function buildPluginHostDebugSnapshot(options: { - host: ExtensionHost - extensionsRoot: string - entries: ManifestEntry[] config: ExtensionConfig + entries: ManifestEntry[] + extensionAssetService?: ExtensionAssetSnapshotService + extensionsRoot: string + host: ExtensionHost loaded: Set manifestEntryByExtensionId: Map - extensionAssetService?: ExtensionAssetSnapshotService }): Promise { const extensionAssetService = options.extensionAssetService const modules = Promise.all(options.host @@ -44,18 +44,18 @@ export function buildPluginHostDebugSnapshot(options: { extensionAssetBaseUrl: extensionAssetService?.getBaseUrl(), ...(extensionAssetService ? { - createAssetSession: ({ extensionId, version, sessionId, routeAssetPath, sessionPathPrefix }: { + createAssetSession: ({ extensionId, routeAssetPath, sessionId, sessionPathPrefix, version }: { extensionId: string - version: string - sessionId: string routeAssetPath: string + sessionId: string sessionPathPrefix: string + version: string }) => extensionAssetService.createAssetSession({ extensionId, - version, ownerSessionId: sessionId, - routeAssetPath, pathPrefix: sessionPathPrefix, + routeAssetPath, + version, }), } : {}), @@ -64,22 +64,22 @@ export function buildPluginHostDebugSnapshot(options: { )) return modules.then(resolvedModules => ({ + capabilities: options.host.listCapabilities(), + kits: options.host.listKits(), + modules: resolvedModules, + refreshedAt: Date.now(), registry: buildPluginRegistrySnapshot({ - extensionsRoot: options.extensionsRoot, - entries: options.entries, config: options.config, + entries: options.entries, + extensionsRoot: options.extensionsRoot, loaded: options.loaded, }), sessions: options.host.listSessions().map(session => ({ - id: session.id, extensionId: session.manifest.id, + id: session.id, + moduleId: session.extension.id, phase: session.phase, runtime: session.runtime ?? 'electron', - moduleId: session.extension.id, })), - kits: options.host.listKits(), - modules: resolvedModules, - capabilities: options.host.listCapabilities(), - refreshedAt: Date.now(), })) } diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts index 32af2f8e6..9a8d2430c 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts @@ -32,26 +32,6 @@ import { const extensionAssetSessionTtlMs = 30 * 24 * 60 * 60 * 1000 -function createElectronExtensionAssetCookieAdapter() { - return { - async setCookie(cookie: ExtensionAssetCookie) { - await electronSession.defaultSession.cookies.set({ - url: cookie.url, - name: cookie.name, - value: cookie.value, - path: cookie.path, - httpOnly: true, - sameSite: 'no_restriction', - secure: true, - expirationDate: Math.floor(cookie.expiresAt / 1000), - }) - }, - async removeCookie(cookie: ExtensionAssetCookie) { - await electronSession.defaultSession.cookies.remove(cookie.url, cookie.name) - }, - } -} - /** * Internal extension host bootstrap service used by the public `setupExtensionHost(...)` facade. * @@ -67,114 +47,20 @@ function createElectronExtensionAssetCookieAdapter() { * - The plain `ExtensionHostService` fields plus internal helpers for list/load/unload/inspect/dispose */ export interface ExtensionHostServiceInternal extends ExtensionHostService { - /** Tamagotchi-owned extension tool registry used by IPC tool bridges. */ - tools: TamagotchiToolRegistry - /** - * Lists the current extension registry snapshot. + * Disposes optional host features and asset hosting resources. * * Use when: - * - IPC callers need the latest discovered plugin entries and enablement state - * - Host operations need a refreshed renderer-facing registry view + * - Electron shutdown needs to stop extension-owned background work + * - Tests need to release watchers and local asset servers deterministically * * Expects: - * - Manifest discovery can be refreshed before the snapshot is built + * - Disposal may be called after partial startup or after prior plugin failures * * Returns: - * - The latest extension registry snapshot for renderer consumption + * - A promise that resolves after feature and asset cleanup finish */ - list: () => Promise - - /** - * Persists whether one plugin is enabled. - * - * Use when: - * - Renderer controls toggle plugin enablement - * - Host state must remember a known manifest path for a plugin name - * - * Expects: - * - `payload.extensionId` matches a discovered or previously known extension - * - `payload.path` is only needed when the manifest is not currently discoverable - * - * Returns: - * - The updated extension registry snapshot after persistence - */ - setEnabled: (payload: { extensionId: string, enabled: boolean, path?: string }) => Promise - - /** - * Persists whether one loaded plugin should use auto-reload. - * - * Use when: - * - Renderer controls toggle plugin file watching during development - * - Host features need to resync optional watcher state after config changes - * - * Expects: - * - `payload.extensionId` matches one extension entry in config or discovery state - * - * Returns: - * - The updated extension registry snapshot after persistence - */ - setAutoReload: (payload: { extensionId: string, enabled: boolean }) => Promise - - /** - * Loads every plugin currently marked as enabled. - * - * Use when: - * - App startup wants to restore persisted enabled plugins - * - Renderer requests a bulk load after configuration changes - * - * Expects: - * - Discovery state is current before load begins - * - * Returns: - * - The extension registry snapshot after load attempts finish - */ - loadEnabled: () => Promise - - /** - * Loads one extension by manifest id. - * - * Use when: - * - Renderer explicitly requests one plugin to start - * - Host features need to restart a plugin after manifest or entrypoint changes - * - * Expects: - * - `extensionId` resolves to a manifest entry in the current registry - * - * Returns: - * - The extension registry snapshot after the load completes - */ - load: (extensionId: string) => Promise - - /** - * Stops one loaded extension by manifest id. - * - * Use when: - * - Renderer explicitly requests one plugin to stop - * - Host features need to stop a plugin before reload or disposal - * - * Expects: - * - `extensionId` identifies an extension that may or may not currently be loaded - * - * Returns: - * - The extension registry snapshot after unload bookkeeping completes - */ - unload: (extensionId: string) => Promise - - /** - * Builds the full extension host debug snapshot. - * - * Use when: - * - Devtools need sessions, kits, bindings, capabilities, and rewritten asset URLs - * - Host debugging needs a fresh runtime snapshot after registry refresh - * - * Expects: - * - The host and extension asset service are both initialized - * - * Returns: - * - The full debug snapshot exposed through plugin inspection IPC - */ - inspect: () => Promise + dispose: () => Promise /** * Returns the mounted base URL for plugin-served assets. @@ -192,19 +78,113 @@ export interface ExtensionHostServiceInternal extends ExtensionHostService { getAssetBaseUrl: () => string /** - * Disposes optional host features and asset hosting resources. + * Builds the full extension host debug snapshot. * * Use when: - * - Electron shutdown needs to stop extension-owned background work - * - Tests need to release watchers and local asset servers deterministically + * - Devtools need sessions, kits, bindings, capabilities, and rewritten asset URLs + * - Host debugging needs a fresh runtime snapshot after registry refresh * * Expects: - * - Disposal may be called after partial startup or after prior plugin failures + * - The host and extension asset service are both initialized * * Returns: - * - A promise that resolves after feature and asset cleanup finish + * - The full debug snapshot exposed through plugin inspection IPC */ - dispose: () => Promise + inspect: () => Promise + + /** + * Lists the current extension registry snapshot. + * + * Use when: + * - IPC callers need the latest discovered plugin entries and enablement state + * - Host operations need a refreshed renderer-facing registry view + * + * Expects: + * - Manifest discovery can be refreshed before the snapshot is built + * + * Returns: + * - The latest extension registry snapshot for renderer consumption + */ + list: () => Promise + + /** + * Loads one extension by manifest id. + * + * Use when: + * - Renderer explicitly requests one plugin to start + * - Host features need to restart a plugin after manifest or entrypoint changes + * + * Expects: + * - `extensionId` resolves to a manifest entry in the current registry + * + * Returns: + * - The extension registry snapshot after the load completes + */ + load: (extensionId: string) => Promise + + /** + * Loads every plugin currently marked as enabled. + * + * Use when: + * - App startup wants to restore persisted enabled plugins + * - Renderer requests a bulk load after configuration changes + * + * Expects: + * - Discovery state is current before load begins + * + * Returns: + * - The extension registry snapshot after load attempts finish + */ + loadEnabled: () => Promise + + /** + * Persists whether one loaded plugin should use auto-reload. + * + * Use when: + * - Renderer controls toggle plugin file watching during development + * - Host features need to resync optional watcher state after config changes + * + * Expects: + * - `payload.extensionId` matches one extension entry in config or discovery state + * + * Returns: + * - The updated extension registry snapshot after persistence + */ + setAutoReload: (payload: { enabled: boolean, extensionId: string }) => Promise + + /** + * Persists whether one plugin is enabled. + * + * Use when: + * - Renderer controls toggle plugin enablement + * - Host state must remember a known manifest path for a plugin name + * + * Expects: + * - `payload.extensionId` matches a discovered or previously known extension + * - `payload.path` is only needed when the manifest is not currently discoverable + * + * Returns: + * - The updated extension registry snapshot after persistence + */ + setEnabled: (payload: { enabled: boolean, extensionId: string, path?: string }) => Promise + + /** Tamagotchi-owned extension tool registry used by IPC tool bridges. */ + tools: TamagotchiToolRegistry + + /** + * Stops one loaded extension by manifest id. + * + * Use when: + * - Renderer explicitly requests one plugin to stop + * - Host features need to stop a plugin before reload or disposal + * + * Expects: + * - `extensionId` identifies an extension that may or may not currently be loaded + * + * Returns: + * - The extension registry snapshot after unload bookkeeping completes + */ + unload: (extensionId: string) => Promise } /** @@ -248,8 +228,8 @@ export async function setupExtensionHostServiceInternal( // Extension feature: Static Assets serving const extensionAssetService = createExtensionAssetService({ - getManifestEntryByExtensionId: () => extensionRegistry.getManifestEntryByExtensionId(), cookieAdapter: createElectronExtensionAssetCookieAdapter(), + getManifestEntryByExtensionId: () => extensionRegistry.getManifestEntryByExtensionId(), }) await extensionAssetService.start() @@ -282,21 +262,21 @@ export async function setupExtensionHostServiceInternal( const listSnapshot = (): PluginRegistrySnapshot => { return buildPluginRegistrySnapshot({ - extensionsRoot, - entries: extensionRegistry.listEntries(), config: getConfig(), + entries: extensionRegistry.listEntries(), + extensionsRoot, loaded, }) } const createModuleAssetSession = async (input: { extensionId: string - version: string ownerSessionId: string - routeAssetPath: string pathPrefix: string + routeAssetPath: string + version: string }) => { - const { extensionId, version, ownerSessionId, routeAssetPath, pathPrefix } = input + const { extensionId, ownerSessionId, pathPrefix, routeAssetPath, version } = input const cacheKey = `${extensionId}:${version}:${ownerSessionId}:${routeAssetPath}:${pathPrefix}` const cachedSession = moduleAssetSessionCache.get(cacheKey) if (cachedSession) { @@ -305,38 +285,38 @@ export async function setupExtensionHostServiceInternal( const session = await extensionAssetService.createAssetSession({ extensionId, - version, ownerSessionId, - routeAssetPath, pathPrefix, + routeAssetPath, ttlMs: extensionAssetSessionTtlMs, + version, }) moduleAssetSessionCache.set(cacheKey, session) return session } const extensionAssetSnapshotService: ExtensionAssetSnapshotService = { - getBaseUrl: extensionAssetService.getBaseUrl, - createAssetSession: ({ extensionId, version, ownerSessionId, routeAssetPath, pathPrefix }) => { + createAssetSession: ({ extensionId, ownerSessionId, pathPrefix, routeAssetPath, version }) => { return createModuleAssetSession({ extensionId, - version, ownerSessionId, - routeAssetPath, pathPrefix, + routeAssetPath, + version, }) }, + getBaseUrl: extensionAssetService.getBaseUrl, } const inspectSnapshot = async (): Promise => { return await buildPluginHostDebugSnapshot({ - host, - extensionsRoot, - entries: extensionRegistry.listEntries(), config: getConfig(), + entries: extensionRegistry.listEntries(), + extensionAssetService: extensionAssetSnapshotService, + extensionsRoot, + host, loaded, manifestEntryByExtensionId: extensionRegistry.getManifestEntryByExtensionId(), - extensionAssetService: extensionAssetSnapshotService, }) } @@ -389,16 +369,16 @@ export async function setupExtensionHostServiceInternal( // Extension feature: Auto-reload for plugins const autoReloadFeature = createExtensionAutoReloadFeature({ - log, getConfig, - listEntries: () => extensionRegistry.listEntries(), isLoaded: extensionId => loaded.has(extensionId), - resolveWatchPaths: resolveAutoReloadWatchPaths, + listEntries: () => extensionRegistry.listEntries(), + log, reload: async (extensionId) => { await stopLoadedExtensionById(extensionId) await refreshManifests() await loadExtensionById(extensionId, { cacheBustKey: `auto-reload-${Date.now()}` }) }, + resolveWatchPaths: resolveAutoReloadWatchPaths, }) const unloadExtensionById = async (extensionId: string) => { @@ -433,45 +413,41 @@ export async function setupExtensionHostServiceInternal( autoReloadFeature.sync() return { + async dispose() { + autoReloadFeature.dispose() + builtInKitRuntime.dispose() + + moduleAssetSessionCache.clear() + await extensionAssetService.revokeAll() + await extensionAssetService.stop() + }, + getAssetBaseUrl() { + return extensionAssetService.getBaseUrl() ?? '' + }, host, - // REVIEW: Tool registry ownership is currently hidden inside the built-in kit runtime even though - // the host service also exposes it for IPC listing/invocation. Consider moving registry ownership - // to this host service and passing it into kit registration as a dependency. - tools: builtInKitRuntime.tools, - manifests: extensionRegistry.listManifests(), + async inspect() { + await refreshManifests() + autoReloadFeature.sync() + return await inspectSnapshot() + }, async list() { await refreshManifests() autoReloadFeature.sync() return listSnapshot() }, - async setEnabled(payload) { + async load(extensionId) { await refreshManifests() - - const config = getConfig() - const enabled = new Set(config.enabled) - if (payload.enabled) { - enabled.add(payload.extensionId) - } - else { - enabled.delete(payload.extensionId) - clearModuleAssetSessionCacheByExtensionId(payload.extensionId) - await extensionAssetService.revokeByExtensionId(payload.extensionId) - } - - const entry = extensionRegistry.findManifestEntry(payload.extensionId) - const manifestPath = entry?.path ?? payload.path ?? '' - extensionConfig.update({ - enabled: [...enabled], - autoReload: config.autoReload, - known: { - ...config.known, - [payload.extensionId]: { path: manifestPath }, - }, - }) - + await loadExtensionById(extensionId) autoReloadFeature.sync() return listSnapshot() }, + async loadEnabled() { + await refreshManifests() + await loadEnabledExtensions() + autoReloadFeature.sync() + return listSnapshot() + }, + manifests: extensionRegistry.listManifests(), async setAutoReload(payload) { await refreshManifests() @@ -492,38 +468,62 @@ export async function setupExtensionHostServiceInternal( autoReloadFeature.sync() return listSnapshot() }, - async loadEnabled() { + async setEnabled(payload) { await refreshManifests() - await loadEnabledExtensions() - autoReloadFeature.sync() - return listSnapshot() - }, - async load(extensionId) { - await refreshManifests() - await loadExtensionById(extensionId) + + const config = getConfig() + const enabled = new Set(config.enabled) + if (payload.enabled) { + enabled.add(payload.extensionId) + } + else { + enabled.delete(payload.extensionId) + clearModuleAssetSessionCacheByExtensionId(payload.extensionId) + await extensionAssetService.revokeByExtensionId(payload.extensionId) + } + + const entry = extensionRegistry.findManifestEntry(payload.extensionId) + const manifestPath = entry?.path ?? payload.path ?? '' + extensionConfig.update({ + autoReload: config.autoReload, + enabled: [...enabled], + known: { + ...config.known, + [payload.extensionId]: { path: manifestPath }, + }, + }) + autoReloadFeature.sync() return listSnapshot() }, + // REVIEW: Tool registry ownership is currently hidden inside the built-in kit runtime even though + // the host service also exposes it for IPC listing/invocation. Consider moving registry ownership + // to this host service and passing it into kit registration as a dependency. + tools: builtInKitRuntime.tools, async unload(extensionId) { await unloadExtensionById(extensionId) autoReloadFeature.sync() return listSnapshot() }, - async inspect() { - await refreshManifests() - autoReloadFeature.sync() - return await inspectSnapshot() - }, - getAssetBaseUrl() { - return extensionAssetService.getBaseUrl() ?? '' - }, - async dispose() { - autoReloadFeature.dispose() - builtInKitRuntime.dispose() + } +} - moduleAssetSessionCache.clear() - await extensionAssetService.revokeAll() - await extensionAssetService.stop() +function createElectronExtensionAssetCookieAdapter() { + return { + async removeCookie(cookie: ExtensionAssetCookie) { + await electronSession.defaultSession.cookies.remove(cookie.url, cookie.name) + }, + async setCookie(cookie: ExtensionAssetCookie) { + await electronSession.defaultSession.cookies.set({ + expirationDate: Math.floor(cookie.expiresAt / 1000), + httpOnly: true, + name: cookie.name, + path: cookie.path, + sameSite: 'no_restriction', + secure: true, + url: cookie.url, + value: cookie.value, + }) }, } } diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/registry.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/registry.ts index 2f13803b2..824a74024 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/registry.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/registry.ts @@ -17,30 +17,166 @@ import { safeParse } from 'valibot' export const extensionManifestFileName = 'extension.airi.json' -function isExtensionManifestV1(value: unknown): value is ExtensionManifestV1 { - return safeParse(extensionManifestV1Schema, value).success +/** + * Tracks the manifest registry state used by the Electron extension host. + * + * Use when: + * - Refreshing extension manifests from disk + * - Looking up manifests by extension id during load or inspect operations + * + * Expects: + * - `refresh()` is called before consumers read entries or manifests + * - `extensionsRoot` points at the extension manifest root under user data + * + * Returns: + * - Read access to the current manifest entries, manifest list, and lookup map + */ +export interface ExtensionHostRegistry { + findManifestEntry: (extensionId: string) => ManifestEntry | undefined + getManifestEntryByExtensionId: () => Map + getRoot: () => string + listEntries: () => ManifestEntry[] + listManifests: () => ExtensionManifestV1[] + refresh: () => Promise } -export function manifestIdOf(manifest: ExtensionManifestV1) { - return manifest.id +/** + * Builds the renderer-facing extension registry snapshot. + * + * Use when: + * - IPC clients request the plugin list + * - Internal host operations need a fresh registry view after config or load changes + * + * Expects: + * - `entries`, `config`, and `loaded` come from the latest in-memory host state + * + * Returns: + * - A stable registry snapshot for renderer consumption + */ +export function buildPluginRegistrySnapshot(options: { + config: ExtensionConfig + entries: ManifestEntry[] + extensionsRoot: string + loaded: Set +}): PluginRegistrySnapshot { + return { + plugins: options.entries.map(entry => createPluginSummary(entry, options.config, options.loaded)), + root: options.extensionsRoot, + } } -async function realPathOf(entry: Dirent, options?: { cwd?: string }): Promise<{ resolved: false, path?: string, error?: unknown } | { resolved: true, path: string, error?: unknown }> { - if (!entry.isSymbolicLink()) { - return { resolved: false } +/** + * Creates the manifest registry store used by the extension host bootstrap. + * + * Use when: + * - Host bootstrap needs in-memory manifest lookup and refresh operations + * + * Expects: + * - `log` is the plugin-host logger used for manifest loading diagnostics + * + * Returns: + * - A registry wrapper around the current manifest entry array and lookup map + */ +export function createExtensionHostRegistry(options: { + extensionsRoot: string + log: ReturnType +}): ExtensionHostRegistry { + let entries: ManifestEntry[] = [] + let manifests: ExtensionManifestV1[] = [] + let manifestEntryByExtensionId = new Map() + + return { + findManifestEntry(extensionId) { + return manifestEntryByExtensionId.get(extensionId) + }, + getManifestEntryByExtensionId() { + return manifestEntryByExtensionId + }, + getRoot() { + return options.extensionsRoot + }, + listEntries() { + return entries + }, + listManifests() { + return manifests + }, + async refresh() { + entries = await loadManifestsFrom(options.extensionsRoot, options.log) + manifestEntryByExtensionId = new Map() + for (const entry of entries) { + const id = manifestIdOf(entry.manifest) + if (!manifestEntryByExtensionId.has(id)) { + manifestEntryByExtensionId.set(id, entry) + } + } + manifests = entries.map(entry => entry.manifest) + return entries + }, + } +} + +/** + * Produces the manifest used for runtime loading, optionally with a cache-busted entrypoint. + * + * Use when: + * - Loading a plugin normally + * - Reloading a plugin after file changes to avoid stale module cache + * + * Expects: + * - `cacheBustKey` is omitted for standard loads + * - `cacheBustKey` is deterministic enough for one reload cycle when provided + * + * Returns: + * - Original manifest or cloned manifest with cache-busted runtime entrypoint + */ +export function createManifestForLoad( + entry: ManifestEntry, + options: { cacheBustKey?: string }, +): ExtensionManifestV1 { + const loadManifest = entry.manifest + if (!options.cacheBustKey) { + return loadManifest } - try { - const resolvedPath = await realpath(join(options?.cwd ?? '', entry.name)) - const stats = await stat(resolvedPath) - if (stats.isFile() || stats.isDirectory()) { - return { resolved: true, path: resolvedPath } - } - - return { resolved: false } + const manifest = structuredClone(loadManifest) + if (manifest.entrypoints.electron) { + manifest.entrypoints.electron = appendCacheBustKey(manifest.entrypoints.electron, options.cacheBustKey) } - catch (error) { - return { resolved: false, error } + else if (manifest.entrypoints.default) { + manifest.entrypoints.default = appendCacheBustKey(manifest.entrypoints.default, options.cacheBustKey) + } + return manifest +} + +/** + * Builds a renderer-facing extension summary from manifest, config, and runtime state. + * + * Use when: + * - Registry snapshots need one UI-friendly entry per discovered plugin + * + * Expects: + * - `entry` corresponds to a currently discovered manifest + * - `config` is the latest persisted extension config + * - `loaded` tracks currently running plugin names + * + * Returns: + * - Stable manifest summary for UI consumption + */ +export function createPluginSummary( + entry: ManifestEntry, + config: ExtensionConfig, + loaded: Set, +): PluginManifestSummary { + const extensionId = manifestIdOf(entry.manifest) + return { + autoReload: config.autoReload.includes(extensionId), + enabled: config.enabled.includes(extensionId), + entrypoints: entry.manifest.entrypoints, + extensionId, + isNew: !config.known[extensionId], + loaded: loaded.has(extensionId), + path: entry.path, } } @@ -71,7 +207,7 @@ export async function loadManifestsFrom( for (const entry of entries) { if (!entry.isDirectory()) { if (entry.isSymbolicLink()) { - const { resolved, error } = await realPathOf(entry, { cwd: dir }) + const { error, resolved } = await realPathOf(entry, { cwd: dir }) if (error) { log.withError(error).withFields({ name: entry.name }).warn('failed to resolve extension manifest path, skipping') continue @@ -163,60 +299,8 @@ export async function loadManifestsFrom( return manifests } -/** - * Builds a renderer-facing extension summary from manifest, config, and runtime state. - * - * Use when: - * - Registry snapshots need one UI-friendly entry per discovered plugin - * - * Expects: - * - `entry` corresponds to a currently discovered manifest - * - `config` is the latest persisted extension config - * - `loaded` tracks currently running plugin names - * - * Returns: - * - Stable manifest summary for UI consumption - */ -export function createPluginSummary( - entry: ManifestEntry, - config: ExtensionConfig, - loaded: Set, -): PluginManifestSummary { - const extensionId = manifestIdOf(entry.manifest) - return { - extensionId, - entrypoints: entry.manifest.entrypoints, - path: entry.path, - enabled: config.enabled.includes(extensionId), - autoReload: config.autoReload.includes(extensionId), - loaded: loaded.has(extensionId), - isNew: !config.known[extensionId], - } -} - -/** - * Builds the renderer-facing extension registry snapshot. - * - * Use when: - * - IPC clients request the plugin list - * - Internal host operations need a fresh registry view after config or load changes - * - * Expects: - * - `entries`, `config`, and `loaded` come from the latest in-memory host state - * - * Returns: - * - A stable registry snapshot for renderer consumption - */ -export function buildPluginRegistrySnapshot(options: { - extensionsRoot: string - entries: ManifestEntry[] - config: ExtensionConfig - loaded: Set -}): PluginRegistrySnapshot { - return { - root: options.extensionsRoot, - plugins: options.entries.map(entry => createPluginSummary(entry, options.config, options.loaded)), - } +export function manifestIdOf(manifest: ExtensionManifestV1) { + return manifest.id } /** @@ -247,109 +331,25 @@ function appendCacheBustKey(entrypoint: string, cacheBustKey: string): string { return `${entrypoint}${delimiter}cacheBust=${encodeURIComponent(cacheBustKey)}` } -/** - * Produces the manifest used for runtime loading, optionally with a cache-busted entrypoint. - * - * Use when: - * - Loading a plugin normally - * - Reloading a plugin after file changes to avoid stale module cache - * - * Expects: - * - `cacheBustKey` is omitted for standard loads - * - `cacheBustKey` is deterministic enough for one reload cycle when provided - * - * Returns: - * - Original manifest or cloned manifest with cache-busted runtime entrypoint - */ -export function createManifestForLoad( - entry: ManifestEntry, - options: { cacheBustKey?: string }, -): ExtensionManifestV1 { - const loadManifest = entry.manifest - if (!options.cacheBustKey) { - return loadManifest - } - - const manifest = structuredClone(loadManifest) - if (manifest.entrypoints.electron) { - manifest.entrypoints.electron = appendCacheBustKey(manifest.entrypoints.electron, options.cacheBustKey) - } - else if (manifest.entrypoints.default) { - manifest.entrypoints.default = appendCacheBustKey(manifest.entrypoints.default, options.cacheBustKey) - } - return manifest +function isExtensionManifestV1(value: unknown): value is ExtensionManifestV1 { + return safeParse(extensionManifestV1Schema, value).success } -/** - * Tracks the manifest registry state used by the Electron extension host. - * - * Use when: - * - Refreshing extension manifests from disk - * - Looking up manifests by extension id during load or inspect operations - * - * Expects: - * - `refresh()` is called before consumers read entries or manifests - * - `extensionsRoot` points at the extension manifest root under user data - * - * Returns: - * - Read access to the current manifest entries, manifest list, and lookup map - */ -export interface ExtensionHostRegistry { - getRoot: () => string - refresh: () => Promise - listEntries: () => ManifestEntry[] - listManifests: () => ExtensionManifestV1[] - findManifestEntry: (extensionId: string) => ManifestEntry | undefined - getManifestEntryByExtensionId: () => Map -} +async function realPathOf(entry: Dirent, options?: { cwd?: string }): Promise<{ error?: unknown, path: string, resolved: true } | { error?: unknown, path?: string, resolved: false }> { + if (!entry.isSymbolicLink()) { + return { resolved: false } + } -/** - * Creates the manifest registry store used by the extension host bootstrap. - * - * Use when: - * - Host bootstrap needs in-memory manifest lookup and refresh operations - * - * Expects: - * - `log` is the plugin-host logger used for manifest loading diagnostics - * - * Returns: - * - A registry wrapper around the current manifest entry array and lookup map - */ -export function createExtensionHostRegistry(options: { - extensionsRoot: string - log: ReturnType -}): ExtensionHostRegistry { - let entries: ManifestEntry[] = [] - let manifests: ExtensionManifestV1[] = [] - let manifestEntryByExtensionId = new Map() + try { + const resolvedPath = await realpath(join(options?.cwd ?? '', entry.name)) + const stats = await stat(resolvedPath) + if (stats.isFile() || stats.isDirectory()) { + return { path: resolvedPath, resolved: true } + } - return { - getRoot() { - return options.extensionsRoot - }, - async refresh() { - entries = await loadManifestsFrom(options.extensionsRoot, options.log) - manifestEntryByExtensionId = new Map() - for (const entry of entries) { - const id = manifestIdOf(entry.manifest) - if (!manifestEntryByExtensionId.has(id)) { - manifestEntryByExtensionId.set(id, entry) - } - } - manifests = entries.map(entry => entry.manifest) - return entries - }, - listEntries() { - return entries - }, - listManifests() { - return manifests - }, - findManifestEntry(extensionId) { - return manifestEntryByExtensionId.get(extensionId) - }, - getManifestEntryByExtensionId() { - return manifestEntryByExtensionId - }, + return { resolved: false } + } + catch (error) { + return { error, resolved: false } } } 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 b23a2539a..1034ac6f3 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 @@ -106,35 +106,6 @@ const samplePluginRoot = resolve( ) const extensionManifestFileName = 'extension.airi.json' -async function writeManifest(params: { dir: string, name: string, entrypoint: string }) { - const manifest = { - apiVersion: 'v1', - kind: 'manifest.extension.airi.moeru.ai' as const, - id: params.name, - permissions: {}, - entrypoints: { - electron: params.entrypoint, - }, - } - - const path = join(params.dir, extensionManifestFileName) - await writeFile(path, JSON.stringify(manifest, null, 2)) - return path -} - -async function writeManifestInPluginDir(params: { rootDir: string, pluginDirName: string, pluginName: string, entrypointPath: string }) { - const pluginDir = join(params.rootDir, params.pluginDirName) - await mkdir(pluginDir, { recursive: true }) - const entrypointFile = await copyEntrypoint({ dir: pluginDir, path: params.entrypointPath }) - const manifestPath = await writeManifest({ - dir: pluginDir, - name: params.pluginName, - entrypoint: `./${entrypointFile}`, - }) - - return { pluginDir, manifestPath } -} - async function copyEntrypoint(params: { dir: string, path: string }) { const file = basename(params.path) const destination = join(params.dir, file) @@ -143,37 +114,37 @@ async function copyEntrypoint(params: { dir: string, path: string }) { return file } -async function writeEntrypoint(params: { dir: string, name: string, contents: string }) { - const destination = join(params.dir, params.name) - await writeFile(destination, params.contents) - return destination -} +function createDynamicModuleManifest(entrypoint: string, id = 'test-dynamic-module'): ExtensionManifestV1 { + const providersCapability = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' + const permissions: ModulePermissionDeclaration = { + apis: [ + { actions: ['invoke'], key: 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait' }, + { actions: ['invoke'], key: providersCapability }, + { actions: ['invoke'], key: 'proj-airi:plugin-sdk:apis:client:kits:list' }, + { actions: ['invoke'], key: 'proj-airi:plugin-sdk:apis:client:kits:get-capabilities' }, + { actions: ['invoke'], key: 'proj-airi:plugin-sdk:apis:client:bindings:list' }, + { actions: ['invoke'], key: 'proj-airi:plugin-sdk:apis:client:bindings:announce' }, + ], + capabilities: [ + { actions: ['wait'], key: providersCapability }, + ], + resources: [ + { actions: ['read'], key: providersCapability }, + { actions: ['read'], key: 'proj-airi:plugin-sdk:resources:kits' }, + { actions: ['read'], key: 'proj-airi:plugin-sdk:resources:bindings' }, + { actions: ['read', 'write'], key: 'proj-airi:plugin-sdk:resources:kits:kit.widget:bindings' }, + ], + } -async function linkWorkspacePackageForPlugin(pluginDir: string, packageName: '@proj-airi/plugin-sdk' | '@proj-airi/plugin-sdk-tamagotchi') { - const packageDirName = packageName.replace('@proj-airi/', '') - const packageDir = join(pluginDir, 'node_modules', '@proj-airi', packageDirName) - await mkdir(packageDir, { recursive: true }) - await symlink(resolve(repoRoot, 'packages', packageDirName, 'src'), join(packageDir, 'src'), 'dir') - - const exports = packageName === '@proj-airi/plugin-sdk' - ? { - '.': './src/index.ts', - './plugin-host': './src/plugin-host/index.ts', - } - : { - '.': './src/index.ts', - './widgets': './src/widgets/index.ts', - './gamelet': './src/gamelet/index.ts', - './kits/gamelet': './src/kits/gamelet/index.ts', - './kits/tool': './src/kits/tool/index.ts', - './tools': './src/tools/index.ts', - } - - await writeFile(join(packageDir, 'package.json'), JSON.stringify({ - name: packageName, - type: 'module', - exports, - })) + return { + apiVersion: 'v1', + entrypoints: { + electron: entrypoint, + }, + id, + kind: 'manifest.extension.airi.moeru.ai' as const, + permissions, + } } function createEmptyExtensionEntrypoint(id: string) { @@ -188,73 +159,22 @@ function createEmptyExtensionEntrypoint(id: string) { ].join('\n') } -async function removeDirWithRetry(path: string, options: { attempts?: number, waitMs?: number } = {}) { - const attempts = Math.max(1, options.attempts ?? 5) - const waitMs = Math.max(1, options.waitMs ?? 20) - - for (let index = 0; index < attempts; index += 1) { - try { - await rm(path, { recursive: true, force: true }) - return - } - catch (error) { - if (index >= attempts - 1) { - throw error - } - await new Promise(resolve => setTimeout(resolve, waitMs)) - } - } -} - -function createDynamicModuleManifest(entrypoint: string, id = 'test-dynamic-module'): ExtensionManifestV1 { - const providersCapability = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' - const permissions: ModulePermissionDeclaration = { - apis: [ - { key: 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait', actions: ['invoke'] }, - { key: providersCapability, actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:kits:list', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:kits:get-capabilities', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:list', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:announce', actions: ['invoke'] }, - ], - resources: [ - { key: providersCapability, 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.widget:bindings', actions: ['read', 'write'] }, - ], - capabilities: [ - { key: providersCapability, actions: ['wait'] }, - ], - } - - return { - apiVersion: 'v1', - kind: 'manifest.extension.airi.moeru.ai' as const, - id, - permissions, - entrypoints: { - electron: entrypoint, - }, - } -} - function createExtensionGameletKitManifest(entrypoint: string, id = 'test-extension-gamelet-kit'): ExtensionManifestV1 { return { apiVersion: 'v1', - kind: 'manifest.extension.airi.moeru.ai' as const, - id, - permissions: { - apis: [ - { key: 'kit.gamelet', actions: ['invoke'] }, - ], - resources: [ - { key: 'proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings', actions: ['write'] }, - ], - }, entrypoints: { electron: entrypoint, }, + id, + kind: 'manifest.extension.airi.moeru.ai' as const, + permissions: { + apis: [ + { actions: ['invoke'], key: 'kit.gamelet' }, + ], + resources: [ + { actions: ['write'], key: 'proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings' }, + ], + }, } } @@ -264,13 +184,13 @@ function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = { const openWindow = vi.fn(async (_params?: { id?: string }) => {}) const pushWidget = vi.fn(async (payload: WidgetsAddPayload) => { const snapshot: WidgetSnapshot = { - id: payload.id ?? Math.random().toString(36).slice(2, 10), + alwaysOnTop: payload.alwaysOnTop ?? false, componentName: payload.componentName, componentProps: payload.componentProps ?? {}, - alwaysOnTop: payload.alwaysOnTop ?? false, + id: payload.id ?? Math.random().toString(36).slice(2, 10), size: payload.size ?? 'm', - windowSize: payload.windowSize, ttlMs: payload.ttlMs ?? 0, + windowSize: payload.windowSize, } widgetSnapshots.set(snapshot.id, snapshot) @@ -284,11 +204,11 @@ function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = { widgetSnapshots.set(payload.id, { ...existing, - componentProps: payload.componentProps ?? existing.componentProps, alwaysOnTop: payload.alwaysOnTop ?? existing.alwaysOnTop, + componentProps: payload.componentProps ?? existing.componentProps, size: payload.size ?? existing.size, - windowSize: payload.windowSize ?? existing.windowSize, ttlMs: payload.ttlMs ?? existing.ttlMs, + windowSize: payload.windowSize ?? existing.windowSize, }) }) const requestWidgetIframe = vi.fn() @@ -305,18 +225,67 @@ function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = { const getWidgetSnapshot = vi.fn((id: string) => widgetSnapshots.get(id)) return { - widgetSnapshots, widgetsManager: { + getWidgetSnapshot, openWindow, pushWidget, - updateWidget, removeWidget, - getWidgetSnapshot, requestWidgetIframe, + updateWidget, }, + widgetSnapshots, } } +async function linkWorkspacePackageForPlugin(pluginDir: string, packageName: '@proj-airi/plugin-sdk' | '@proj-airi/plugin-sdk-tamagotchi') { + const packageDirName = packageName.replace('@proj-airi/', '') + const packageDir = join(pluginDir, 'node_modules', '@proj-airi', packageDirName) + await mkdir(packageDir, { recursive: true }) + await symlink(resolve(repoRoot, 'packages', packageDirName, 'src'), join(packageDir, 'src'), 'dir') + + const exports = packageName === '@proj-airi/plugin-sdk' + ? { + '.': './src/index.ts', + './plugin-host': './src/plugin-host/index.ts', + } + : { + '.': './src/index.ts', + './gamelet': './src/gamelet/index.ts', + './kits/gamelet': './src/kits/gamelet/index.ts', + './kits/tool': './src/kits/tool/index.ts', + './tools': './src/tools/index.ts', + './widgets': './src/widgets/index.ts', + } + + await writeFile(join(packageDir, 'package.json'), JSON.stringify({ + exports, + name: packageName, + type: 'module', + })) +} + +async function removeDirWithRetry(path: string, options: { attempts?: number, waitMs?: number } = {}) { + const attempts = Math.max(1, options.attempts ?? 5) + const waitMs = Math.max(1, options.waitMs ?? 20) + + for (let index = 0; index < attempts; index += 1) { + try { + await rm(path, { force: true, recursive: true }) + return + } + catch (error) { + if (index >= attempts - 1) { + throw error + } + await new Promise(resolve => setTimeout(resolve, waitMs)) + } + } +} + +async function setupExtensionHost() { + return (await setupExtensionHostForTest()).service +} + async function setupExtensionHostForTest() { const widgets = createWidgetsManagerDouble() const service = await setupExtensionHostService({ widgetsManager: widgets.widgetsManager }) @@ -329,8 +298,39 @@ async function setupExtensionHostServiceInternalForTest() { return { service, ...widgets } } -async function setupExtensionHost() { - return (await setupExtensionHostForTest()).service +async function writeEntrypoint(params: { contents: string, dir: string, name: string }) { + const destination = join(params.dir, params.name) + await writeFile(destination, params.contents) + return destination +} + +async function writeManifest(params: { dir: string, entrypoint: string, name: string }) { + const manifest = { + apiVersion: 'v1', + entrypoints: { + electron: params.entrypoint, + }, + id: params.name, + kind: 'manifest.extension.airi.moeru.ai' as const, + permissions: {}, + } + + const path = join(params.dir, extensionManifestFileName) + await writeFile(path, JSON.stringify(manifest, null, 2)) + return path +} + +async function writeManifestInPluginDir(params: { entrypointPath: string, pluginDirName: string, pluginName: string, rootDir: string }) { + const pluginDir = join(params.rootDir, params.pluginDirName) + await mkdir(pluginDir, { recursive: true }) + const entrypointFile = await copyEntrypoint({ dir: pluginDir, path: params.entrypointPath }) + const manifestPath = await writeManifest({ + dir: pluginDir, + entrypoint: `./${entrypointFile}`, + name: params.pluginName, + }) + + return { manifestPath, pluginDir } } describe('setupExtensionHost', () => { @@ -348,10 +348,10 @@ describe('setupExtensionHost', () => { it('loads manifests through the internal host bootstrap helper', async () => { const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') await writeManifestInPluginDir({ - rootDir: pluginsDir, + entrypointPath: normalEntrypoint, pluginDirName: 'test-host-helper', pluginName: 'test-host-helper', - entrypointPath: normalEntrypoint, + rootDir: pluginsDir, }) const { service } = await setupExtensionHostServiceInternalForTest() @@ -381,16 +381,16 @@ describe('setupExtensionHost', () => { const errorEntrypoint = join(testDataRoot, 'test-error-plugin.ts') const { manifestPath: normalPath } = await writeManifestInPluginDir({ - rootDir: pluginsDir, + entrypointPath: normalEntrypoint, pluginDirName: 'test-normal', pluginName: 'test-normal', - entrypointPath: normalEntrypoint, + rootDir: pluginsDir, }) const { manifestPath: errorPath } = await writeManifestInPluginDir({ - rootDir: pluginsDir, + entrypointPath: errorEntrypoint, pluginDirName: 'test-error', pluginName: 'test-error', - entrypointPath: errorEntrypoint, + rootDir: pluginsDir, }) await setupExtensionHost() @@ -402,8 +402,8 @@ describe('setupExtensionHost', () => { expect(snapshot.root).toBe(pluginsDir) expect(snapshot.plugins).toHaveLength(2) expect(snapshot.plugins).toEqual(expect.arrayContaining([ - expect.objectContaining({ extensionId: 'test-normal', path: normalPath, enabled: false, loaded: false, isNew: true }), - expect.objectContaining({ extensionId: 'test-error', path: errorPath, enabled: false, loaded: false, isNew: true }), + expect.objectContaining({ enabled: false, extensionId: 'test-normal', isNew: true, loaded: false, path: normalPath }), + expect.objectContaining({ enabled: false, extensionId: 'test-error', isNew: true, loaded: false, path: errorPath }), ])) }) @@ -415,22 +415,22 @@ describe('setupExtensionHost', () => { await writeFile(join(extensionDir, extensionManifestFileName), JSON.stringify({ apiVersion: 'v1', - kind: 'manifest.extension.airi.moeru.ai' as const, - id: 'airi-extension-test', - permissions: {}, entrypoints: { electron: './extension.mjs', }, + id: 'airi-extension-test', + kind: 'manifest.extension.airi.moeru.ai' as const, + permissions: {}, }, null, 2)) await writeFile(join(legacyDir, extensionManifestFileName), JSON.stringify({ apiVersion: 'v1', - kind: 'manifest.plugin.airi.moeru.ai', - name: 'airi-plugin-legacy', - permissions: {}, entrypoints: { electron: './plugin.mjs', }, + kind: 'manifest.plugin.airi.moeru.ai', + name: 'airi-plugin-legacy', + permissions: {}, }, null, 2)) const entries = await loadManifestsFrom(pluginsDir, useLogg('test/plugin-registry')) @@ -447,16 +447,16 @@ describe('setupExtensionHost', () => { const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') const { manifestPath } = await writeManifestInPluginDir({ - rootDir: pluginsDir, + entrypointPath: normalEntrypoint, pluginDirName: 'devtools-sample-plugin', pluginName: 'devtools-sample-plugin', - entrypointPath: normalEntrypoint, + rootDir: pluginsDir, }) const rootEntrypointFile = await copyEntrypoint({ dir: pluginsDir, path: normalEntrypoint }) await writeManifest({ dir: pluginsDir, - name: 'root-level-plugin', entrypoint: rootEntrypointFile, + name: 'root-level-plugin', }) await setupExtensionHost() @@ -467,11 +467,11 @@ describe('setupExtensionHost', () => { expect(snapshot.plugins).toEqual([ expect.objectContaining({ - extensionId: 'devtools-sample-plugin', - path: manifestPath, enabled: false, - loaded: false, + extensionId: 'devtools-sample-plugin', isNew: true, + loaded: false, + path: manifestPath, }), ]) }) @@ -482,20 +482,20 @@ describe('setupExtensionHost', () => { const successPluginDir = join(pluginsDir, 'test-normal') await mkdir(successPluginDir, { recursive: true }) await writeEntrypoint({ + contents: createEmptyExtensionEntrypoint('test-normal'), dir: successPluginDir, name: 'test-normal-plugin.ts', - contents: createEmptyExtensionEntrypoint('test-normal'), }) await writeManifest({ dir: successPluginDir, - name: 'test-normal', entrypoint: './test-normal-plugin.ts', + name: 'test-normal', }) await writeManifestInPluginDir({ - rootDir: pluginsDir, + entrypointPath: errorEntrypoint, pluginDirName: 'test-error', pluginName: 'test-error', - entrypointPath: errorEntrypoint, + rootDir: pluginsDir, }) await setupExtensionHost() @@ -504,8 +504,8 @@ describe('setupExtensionHost', () => { const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) - await invokeSetEnabled({ extensionId: 'test-normal', enabled: true }) - await invokeSetEnabled({ extensionId: 'test-error', enabled: true }) + await invokeSetEnabled({ enabled: true, extensionId: 'test-normal' }) + await invokeSetEnabled({ enabled: true, extensionId: 'test-error' }) const snapshot = await invokeLoadEnabled() @@ -520,20 +520,20 @@ describe('setupExtensionHost', () => { const pluginDir = join(pluginsDir, 'test-tools-changed') await mkdir(pluginDir, { recursive: true }) await writeEntrypoint({ + contents: createEmptyExtensionEntrypoint('test-tools-changed'), dir: pluginDir, name: 'test-tools-changed.ts', - contents: createEmptyExtensionEntrypoint('test-tools-changed'), }) await writeManifest({ dir: pluginDir, - name: 'test-tools-changed', entrypoint: './test-tools-changed.ts', + name: 'test-tools-changed', }) await setupExtensionHost() expect(contextState.lastContext).toBeDefined() - const toolsChangedEvents: Array<{ reason: string, extensionId?: string }> = [] + const toolsChangedEvents: Array<{ extensionId?: string, reason: string }> = [] contextState.lastContext!.on(electronPluginToolsChanged, (event) => { if (!event.body) { throw new Error('Expected plugin tools changed event body.') @@ -547,8 +547,8 @@ describe('setupExtensionHost', () => { expect(toolsChangedEvents).toEqual([ { - reason: 'loaded', extensionId: 'test-tools-changed', + reason: 'loaded', }, ]) }) @@ -559,20 +559,20 @@ describe('setupExtensionHost', () => { const firstPluginDir = join(pluginsDir, 'duplicate-plugin-first') await mkdir(firstPluginDir, { recursive: true }) await writeEntrypoint({ + contents: createEmptyExtensionEntrypoint('duplicate-plugin'), dir: firstPluginDir, name: 'test-normal-plugin.ts', - contents: createEmptyExtensionEntrypoint('duplicate-plugin'), }) await writeManifest({ dir: firstPluginDir, - name: 'duplicate-plugin', entrypoint: './test-normal-plugin.ts', + name: 'duplicate-plugin', }) await writeManifestInPluginDir({ - rootDir: pluginsDir, + entrypointPath: errorEntrypoint, pluginDirName: 'duplicate-plugin-second', pluginName: 'duplicate-plugin', - entrypointPath: errorEntrypoint, + rootDir: pluginsDir, }) const { service } = await setupExtensionHostForTest() @@ -581,7 +581,7 @@ describe('setupExtensionHost', () => { const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) - await invokeSetEnabled({ extensionId: 'duplicate-plugin', enabled: true }) + await invokeSetEnabled({ enabled: true, extensionId: 'duplicate-plugin' }) await invokeLoadEnabled() const duplicateSession = service.host @@ -595,10 +595,10 @@ describe('setupExtensionHost', () => { it('persists plugin auto-reload state and surfaces it in registry snapshots', async () => { const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') await writeManifestInPluginDir({ - rootDir: pluginsDir, + entrypointPath: normalEntrypoint, pluginDirName: 'test-auto-reload', pluginName: 'test-auto-reload', - entrypointPath: normalEntrypoint, + rootDir: pluginsDir, }) await setupExtensionHost() @@ -607,16 +607,16 @@ describe('setupExtensionHost', () => { const invokeSetAutoReload = defineInvoke(contextState.lastContext!, electronPluginSetAutoReload) const invokeList = defineInvoke(contextState.lastContext!, electronPluginList) - await invokeSetAutoReload({ extensionId: 'test-auto-reload', enabled: true }) + await invokeSetAutoReload({ enabled: true, extensionId: 'test-auto-reload' }) let snapshot = await invokeList() expect(snapshot.plugins).toEqual(expect.arrayContaining([ - expect.objectContaining({ extensionId: 'test-auto-reload', autoReload: true }), + expect.objectContaining({ autoReload: true, extensionId: 'test-auto-reload' }), ])) - await invokeSetAutoReload({ extensionId: 'test-auto-reload', enabled: false }) + await invokeSetAutoReload({ enabled: false, extensionId: 'test-auto-reload' }) snapshot = await invokeList() expect(snapshot.plugins).toEqual(expect.arrayContaining([ - expect.objectContaining({ extensionId: 'test-auto-reload', autoReload: false }), + expect.objectContaining({ autoReload: false, extensionId: 'test-auto-reload' }), ])) }) @@ -624,14 +624,14 @@ describe('setupExtensionHost', () => { const pluginDir = join(pluginsDir, 'test-auto-reload-reload') await mkdir(pluginDir, { recursive: true }) const entrypointPath = await writeEntrypoint({ + contents: createEmptyExtensionEntrypoint('test-auto-reload-reload'), dir: pluginDir, name: 'test-auto-reload-reload.ts', - contents: createEmptyExtensionEntrypoint('test-auto-reload-reload'), }) await writeManifest({ dir: pluginDir, - name: 'test-auto-reload-reload', entrypoint: './test-auto-reload-reload.ts', + name: 'test-auto-reload-reload', }) await setupExtensionHost() @@ -643,9 +643,9 @@ describe('setupExtensionHost', () => { const invokeInspect = defineInvoke(contextState.lastContext!, electronPluginInspect) const invokeUnload = defineInvoke(contextState.lastContext!, electronPluginUnload) - await invokeSetEnabled({ extensionId: 'test-auto-reload-reload', enabled: true }) + await invokeSetEnabled({ enabled: true, extensionId: 'test-auto-reload-reload' }) await invokeLoadEnabled() - await invokeSetAutoReload({ extensionId: 'test-auto-reload-reload', enabled: true }) + await invokeSetAutoReload({ enabled: true, extensionId: 'test-auto-reload-reload' }) const before = await invokeInspect() const beforeSession = before.sessions.find(session => session.extensionId === 'test-auto-reload-reload') @@ -683,7 +683,7 @@ describe('setupExtensionHost', () => { expect(afterSessionId).toBeDefined() expect(afterSessionId).not.toEqual(beforeSession?.id) - await invokeSetAutoReload({ extensionId: 'test-auto-reload-reload', enabled: false }) + await invokeSetAutoReload({ enabled: false, extensionId: 'test-auto-reload-reload' }) await invokeUnload({ extensionId: 'test-auto-reload-reload' }) }) @@ -694,14 +694,14 @@ describe('setupExtensionHost', () => { const pluginDir = join(pluginsDir, 'test-absolute-entrypoint') await mkdir(pluginDir, { recursive: true }) const externalEntrypoint = await writeEntrypoint({ + contents: createEmptyExtensionEntrypoint('test-absolute-entrypoint'), dir: externalDir, name: 'test-absolute-plugin.ts', - contents: createEmptyExtensionEntrypoint('test-absolute-entrypoint'), }) await writeManifest({ dir: pluginDir, - name: 'test-absolute-entrypoint', entrypoint: externalEntrypoint, + name: 'test-absolute-entrypoint', }) await setupExtensionHost() @@ -710,7 +710,7 @@ describe('setupExtensionHost', () => { const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) - await invokeSetEnabled({ extensionId: 'test-absolute-entrypoint', enabled: true }) + await invokeSetEnabled({ enabled: true, extensionId: 'test-absolute-entrypoint' }) const snapshot = await invokeLoadEnabled() const plugin = snapshot.plugins.find(item => item.extensionId === 'test-absolute-entrypoint') @@ -718,7 +718,7 @@ describe('setupExtensionHost', () => { expect(plugin).toEqual(expect.objectContaining({ enabled: true, loaded: true })) } finally { - await rm(externalDir, { recursive: true, force: true }) + await rm(externalDir, { force: true, recursive: true }) } }) @@ -744,7 +744,7 @@ describe('setupExtensionHost', () => { const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) - await invokeSetEnabled({ extensionId: 'devtools-sample-plugin', enabled: true }) + await invokeSetEnabled({ enabled: true, extensionId: 'devtools-sample-plugin' }) const snapshot = await invokeLoadEnabled() const plugin = snapshot.plugins.find(item => item.extensionId === 'devtools-sample-plugin') @@ -759,19 +759,19 @@ describe('setupExtensionHost', () => { join(pluginDir, extensionManifestFileName), JSON.stringify({ apiVersion: 'v1', - kind: 'manifest.extension.airi.moeru.ai' as const, - id: 'airi-plugin-game-chess', - permissions: { - apis: [ - { key: 'kit.gamelet', actions: ['invoke'] }, - ], - resources: [ - { key: 'proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings', actions: ['write'] }, - ], - }, entrypoints: { electron: './airi-plugin-game-chess.mjs', }, + id: 'airi-plugin-game-chess', + kind: 'manifest.extension.airi.moeru.ai' as const, + permissions: { + apis: [ + { actions: ['invoke'], key: 'kit.gamelet' }, + ], + resources: [ + { actions: ['write'], key: 'proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings' }, + ], + }, }, null, 2), ) await writeFile(join(pluginDir, 'airi-plugin-game-chess.mjs'), [ @@ -810,7 +810,7 @@ describe('setupExtensionHost', () => { const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) const invokeInspect = defineInvoke(contextState.lastContext!, electronPluginInspect) - await invokeSetEnabled({ extensionId: 'airi-plugin-game-chess', enabled: true }) + await invokeSetEnabled({ enabled: true, extensionId: 'airi-plugin-game-chess' }) const registry = await invokeLoadEnabled() const plugin = registry.plugins.find(item => item.extensionId === 'airi-plugin-game-chess') @@ -821,31 +821,31 @@ describe('setupExtensionHost', () => { // Verify the host exposes the announced module snapshot after activation. expect(snapshot.modules).toEqual(expect.arrayContaining([ expect.objectContaining({ - moduleId: 'chess-like-main:gamelet', - ownerExtensionId: 'airi-plugin-game-chess', - kitId: 'kit.gamelet', - kitModuleType: 'gamelet', - runtime: 'electron', - state: 'announced', config: expect.objectContaining({ - title: 'Chess', - widget: expect.objectContaining({ - mount: 'iframe', - iframe: expect.objectContaining({ - assetPath: 'ui/index.html', - src: expect.stringMatching( - /^http:\/\/127\.0\.0\.1:\d+\/_airi\/extensions\/airi-plugin-game-chess\/sessions\/[\w-]{10,}\/ui\/index\.html$/, - ), - sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', - }), - }), config: { init: { airiSide: 'white', opening: 'queen-gambit', }, }, + title: 'Chess', + widget: expect.objectContaining({ + iframe: expect.objectContaining({ + assetPath: 'ui/index.html', + sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', + src: expect.stringMatching( + /^http:\/\/127\.0\.0\.1:\d+\/_airi\/extensions\/airi-plugin-game-chess\/sessions\/[\w-]{10,}\/ui\/index\.html$/, + ), + }), + mount: 'iframe', + }), }), + kitId: 'kit.gamelet', + kitModuleType: 'gamelet', + moduleId: 'chess-like-main:gamelet', + ownerExtensionId: 'airi-plugin-game-chess', + runtime: 'electron', + state: 'announced', }), ])) }) @@ -869,25 +869,25 @@ describe('setupExtensionHost', () => { await writeFile(join(pluginDir, 'ui', 'other.html'), 'other') await writeFile(join(pluginDir, 'ui', 'private', 'secret.txt'), 'secret') const entrypointFile = await writeEntrypoint({ + contents: createEmptyExtensionEntrypoint('test-plugin-widget-asset-url'), dir: pluginDir, name: 'test-plugin-widget-asset-url.ts', - contents: createEmptyExtensionEntrypoint('test-plugin-widget-asset-url'), }) await writeFile(join(pluginDir, extensionManifestFileName), JSON.stringify({ apiVersion: 'v1', - kind: 'manifest.extension.airi.moeru.ai' as const, - id: 'test-plugin-widget-asset-url', - permissions: { - apis: [ - { key: 'kit.widget', actions: ['invoke'] }, - ], - resources: [ - { key: 'proj-airi:plugin-sdk:resources:kits:kit.widget:bindings', actions: ['read', 'write'] }, - ], - }, entrypoints: { electron: `./${basename(entrypointFile)}`, }, + id: 'test-plugin-widget-asset-url', + kind: 'manifest.extension.airi.moeru.ai' as const, + permissions: { + apis: [ + { actions: ['invoke'], key: 'kit.widget' }, + ], + resources: [ + { actions: ['read', 'write'], key: 'proj-airi:plugin-sdk:resources:kits:kit.widget:bindings' }, + ], + }, }, null, 2)) const { service } = await setupExtensionHostForTest() @@ -897,7 +897,7 @@ describe('setupExtensionHost', () => { const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) const invokeInspect = defineInvoke(contextState.lastContext!, electronPluginInspect) - await invokeSetEnabled({ extensionId: 'test-plugin-widget-asset-url', enabled: true }) + await invokeSetEnabled({ enabled: true, extensionId: 'test-plugin-widget-asset-url' }) await invokeLoadEnabled() const session = service.host .listSessions() @@ -906,48 +906,48 @@ describe('setupExtensionHost', () => { throw new Error('Expected widget asset URL test extension to be loaded.') } service.host.bindExtensionKitModule(session.id, { - moduleId: 'widget-shell-under-test', - kitId: 'kit.widget', - kitModuleType: 'window', config: { - title: 'Widget Shell Under Test', entrypoint: './ui/index.html', + title: 'Widget Shell Under Test', widget: { - mount: 'iframe', iframe: { assetPath: './ui/index.html', sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', }, + mount: 'iframe', windowSize: { - width: 980, height: 840, - minWidth: 640, minHeight: 640, + minWidth: 640, + width: 980, }, }, }, + kitId: 'kit.widget', + kitModuleType: 'window', + moduleId: 'widget-shell-under-test', }) const snapshot = await invokeInspect() expect(snapshot.modules).toEqual(expect.arrayContaining([ expect.objectContaining({ - moduleId: 'widget-shell-under-test', - ownerExtensionId: 'test-plugin-widget-asset-url', - kitId: 'kit.widget', - kitModuleType: 'window', - runtime: 'electron', config: expect.objectContaining({ title: 'Widget Shell Under Test', widget: expect.objectContaining({ iframe: expect.objectContaining({ assetPath: './ui/index.html', + sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', src: expect.stringMatching( /^http:\/\/127\.0\.0\.1:\d+\/_airi\/extensions\/test-plugin-widget-asset-url\/sessions\/[\w-]{10,}\/ui\/index\.html$/, ), - sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', }), }), }), + kitId: 'kit.widget', + kitModuleType: 'window', + moduleId: 'widget-shell-under-test', + ownerExtensionId: 'test-plugin-widget-asset-url', + runtime: 'electron', }), ])) @@ -964,7 +964,7 @@ describe('setupExtensionHost', () => { expect(iframeUrlString).not.toContain('?t=') expect(sessionMock.defaultSession.cookies.set).toHaveBeenCalledOnce() - const setCookie = sessionMock.defaultSession.cookies.set.mock.calls.at(0)?.[0] as { name: string, value: string } | undefined + const setCookie = sessionMock.defaultSession.cookies.set.mock.calls.at(0)?.[0] as undefined | { name: string, value: string } if (!setCookie) { throw new Error('Expected plugin asset cookie to be set before iframe URL is returned') } @@ -999,31 +999,31 @@ describe('setupExtensionHost', () => { await invokeUpdateCapability({ key: 'cap:renderer-status', - state: 'degraded', metadata: { reason: 'renderer-restarting' }, + state: 'degraded', }) let snapshot = await invokeInspect() expect(snapshot.capabilities).toEqual(expect.arrayContaining([ expect.objectContaining({ key: 'cap:renderer-status', - state: 'degraded', metadata: { reason: 'renderer-restarting' }, + state: 'degraded', }), ])) await invokeUpdateCapability({ key: 'cap:renderer-status', - state: 'withdrawn', metadata: { reason: 'renderer-unmounted' }, + state: 'withdrawn', }) snapshot = await invokeInspect() expect(snapshot.capabilities).toEqual(expect.arrayContaining([ expect.objectContaining({ key: 'cap:renderer-status', - state: 'withdrawn', metadata: { reason: 'renderer-unmounted' }, + state: 'withdrawn', }), ])) }) @@ -1035,46 +1035,46 @@ describe('setupExtensionHost', () => { const invokeInspect = defineInvoke(contextState.lastContext!, electronPluginInspect) const dynamicEntrypoint = await writeEntrypoint({ + contents: createEmptyExtensionEntrypoint('test-dynamic-module'), dir: pluginsDir, name: 'test-dynamic-module.ts', - contents: createEmptyExtensionEntrypoint('test-dynamic-module'), }) const session = await host.start(createDynamicModuleManifest(dynamicEntrypoint), { cwd: pluginsDir }) host.bindExtensionKitModule(session.id, { - moduleId: 'widget-shell', + config: { route: '/widgets/runtime' }, kitId: 'kit.widget', kitModuleType: 'window', - config: { route: '/widgets/runtime' }, + moduleId: 'widget-shell', }) const snapshot = await invokeInspect() expect(snapshot.kits).toEqual(expect.arrayContaining([ expect.objectContaining({ + capabilities: [ + { actions: ['announce', 'activate', 'update', 'withdraw'], key: 'kit.widget.module' }, + ], kitId: 'kit.widget', runtimes: ['electron', 'web'], - capabilities: [ - { key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] }, - ], }), expect.objectContaining({ + capabilities: [ + { actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'], key: 'kit.gamelet.runtime' }, + ], kitId: 'kit.gamelet', runtimes: ['electron', 'web'], - capabilities: [ - { key: 'kit.gamelet.runtime', actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'] }, - ], }), ])) expect(snapshot.modules).toEqual(expect.arrayContaining([ expect.objectContaining({ - moduleId: 'widget-shell', - ownerSessionId: session.id, - ownerExtensionId: 'test-dynamic-module', + config: { route: '/widgets/runtime' }, kitId: 'kit.widget', kitModuleType: 'window', + moduleId: 'widget-shell', + ownerExtensionId: 'test-dynamic-module', + ownerSessionId: session.id, runtime: 'electron', state: 'announced', - config: { route: '/widgets/runtime' }, }), ])) @@ -1086,43 +1086,43 @@ describe('setupExtensionHost', () => { expect(nextSnapshot.kits).toEqual(expect.arrayContaining([ expect.objectContaining({ - kitId: 'kit.widget', capabilities: [ - { key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] }, + { actions: ['announce', 'activate', 'update', 'withdraw'], key: 'kit.widget.module' }, ], + kitId: 'kit.widget', }), expect.objectContaining({ - kitId: 'kit.gamelet', capabilities: [ - { key: 'kit.gamelet.runtime', actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'] }, + { actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'], key: 'kit.gamelet.runtime' }, ], + kitId: 'kit.gamelet', }), ])) expect(nextSnapshot.modules).toEqual(expect.arrayContaining([ expect.objectContaining({ - moduleId: 'widget-shell', config: { route: '/widgets/runtime' }, + moduleId: 'widget-shell', }), ])) }) it('sources built-in kit descriptors from installable kit modules', () => { expect(widgetPluginKitDescriptor).toEqual({ - kitId: 'kit.widget', - version: '1.0.0', - runtimes: ['electron', 'web'], capabilities: [ - { key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] }, + { actions: ['announce', 'activate', 'update', 'withdraw'], key: 'kit.widget.module' }, ], + kitId: 'kit.widget', + runtimes: ['electron', 'web'], + version: '1.0.0', }) expect(gameletPluginKitDescriptor).toEqual({ - kitId: 'kit.gamelet', - version: '1.0.0', - runtimes: ['electron', 'web'], capabilities: [ - { key: 'kit.gamelet.runtime', actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'] }, + { actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'], key: 'kit.gamelet.runtime' }, ], + kitId: 'kit.gamelet', + runtimes: ['electron', 'web'], + version: '1.0.0', }) }) @@ -1137,8 +1137,6 @@ describe('setupExtensionHost', () => { const pluginSdkUrl = pathToFileURL(resolve(repoRoot, 'packages/plugin-sdk/src/index.ts')).href const tamagotchiSdkUrl = pathToFileURL(resolve(repoRoot, 'packages/plugin-sdk-tamagotchi/src/index.ts')).href const entrypointPath = await writeEntrypoint({ - dir: pluginDir, - name: 'test-extension-gamelet-kit.ts', contents: [ `import { defineExtension } from '${pluginSdkUrl}'`, `import { gameletKit } from '${tamagotchiSdkUrl}'`, @@ -1161,29 +1159,31 @@ describe('setupExtensionHost', () => { ' },', '})', ].join('\n'), + dir: pluginDir, + name: 'test-extension-gamelet-kit.ts', }) const session = await service.host.start(createExtensionGameletKitManifest(entrypointPath), { cwd: pluginDir }) const binding = service.host.getBinding('kit-module:gamelet') expect(binding).toEqual(expect.objectContaining({ + kitId: 'kit.gamelet', + kitModuleType: 'gamelet', moduleId: 'kit-module:gamelet', ownerExtensionId: 'test-extension-gamelet-kit', ownerSessionId: session.id, - kitId: 'kit.gamelet', - kitModuleType: 'gamelet', })) expect(binding?.config).toEqual({ + config: { + init: {}, + }, title: 'Kit Runtime Gamelet', widget: { - mount: 'iframe', iframe: { assetPath: 'ui/index.html', sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', }, - }, - config: { - init: {}, + mount: 'iframe', }, }) }) @@ -1200,8 +1200,6 @@ describe('setupExtensionHost', () => { await linkWorkspacePackageForPlugin(pluginDir, '@proj-airi/plugin-sdk') await linkWorkspacePackageForPlugin(pluginDir, '@proj-airi/plugin-sdk-tamagotchi') const entrypointPath = await writeEntrypoint({ - dir: pluginDir, - name: 'test-extension-gamelet-orchestration.ts', contents: [ 'import { defineExtension } from \'@proj-airi/plugin-sdk\'', 'import { gameletKit } from \'@proj-airi/plugin-sdk-tamagotchi\'', @@ -1236,34 +1234,36 @@ describe('setupExtensionHost', () => { ' },', '})', ].join('\n'), + dir: pluginDir, + name: 'test-extension-gamelet-orchestration.ts', }) await service.host.start(createExtensionGameletKitManifest(entrypointPath, 'test-extension-gamelet-orchestration'), { cwd: pluginDir }) expect(widgetsManager.pushWidget).toHaveBeenCalledWith(expect.objectContaining({ - id: 'kit-module:board', componentName: 'extension-ui', componentProps: { moduleId: 'kit-module:board', payload: { mode: 'new' }, }, + id: 'kit-module:board', size: 'l', })) expect(widgetsManager.openWindow).toHaveBeenCalledWith({ id: 'kit-module:board' }) expect(widgetsManager.updateWidget).toHaveBeenCalledWith({ - id: 'kit-module:board', componentProps: { moduleId: 'kit-module:board', payload: { mode: 'resume' }, }, + id: 'kit-module:board', size: 'l', }) expect(widgetsManager.updateWidget).toHaveBeenCalledWith({ - id: 'kit-module:board', componentProps: { moduleId: 'kit-module:board', payload: { command: { requestId: 'ignored-by-test-double' } }, }, + id: 'kit-module:board', }) expect(widgetsManager.requestWidgetIframe).toHaveBeenCalledWith( 'kit-module:board', @@ -1285,8 +1285,6 @@ describe('setupExtensionHost', () => { await linkWorkspacePackageForPlugin(pluginDir, '@proj-airi/plugin-sdk') await linkWorkspacePackageForPlugin(pluginDir, '@proj-airi/plugin-sdk-tamagotchi') const entrypointPath = await writeEntrypoint({ - dir: pluginDir, - name: 'test-extension-gamelet-session-cleanup.ts', contents: [ 'import { createModule, defineExtension } from \'@proj-airi/plugin-sdk\'', 'import { createGamelet } from \'@proj-airi/plugin-sdk-tamagotchi/kits/gamelet\'', @@ -1304,6 +1302,8 @@ describe('setupExtensionHost', () => { ' },', '})', ].join('\n'), + dir: pluginDir, + name: 'test-extension-gamelet-session-cleanup.ts', }) const session = await service.host.start(createExtensionGameletKitManifest(entrypointPath, 'test-extension-gamelet-session-cleanup'), { cwd: pluginDir }) @@ -1384,23 +1384,23 @@ describe('setupExtensionHost', () => { const { host } = await setupExtensionHost() const dynamicEntrypoint = await writeEntrypoint({ + contents: createEmptyExtensionEntrypoint('test-dynamic-module'), dir: pluginsDir, name: 'test-dynamic-module.ts', - contents: createEmptyExtensionEntrypoint('test-dynamic-module'), }) const session = await host.start(createDynamicModuleManifest(dynamicEntrypoint), { cwd: pluginsDir }) host.registerKit({ + capabilities: [{ actions: ['announce'], key: 'kit.web-only.module' }], kitId: 'kit.web-only', - version: '1.0.0', runtimes: ['web'], - capabilities: [{ key: 'kit.web-only.module', actions: ['announce'] }], + version: '1.0.0', }) expect(() => host.bindExtensionKitModule(session.id, { - moduleId: 'web-only-shell', + config: { route: '/widgets/web-only' }, kitId: 'kit.web-only', kitModuleType: 'window', - config: { route: '/widgets/web-only' }, + moduleId: 'web-only-shell', })).toThrowError(/not available for runtime `electron`/i) }) }) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts index 82eba897c..f98fd7670 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts @@ -56,8 +56,8 @@ export async function setupExtensionHost(options: SetupExtensionHostOptions): Pr defineInvokeHandler(context, electronPluginSetEnabled, async (payload) => { const result = await hostService.setEnabled(payload) context.emit(electronPluginToolsChanged, { - reason: 'enabled-state-changed', extensionId: payload.extensionId, + reason: 'enabled-state-changed', }) return result }) @@ -77,8 +77,8 @@ export async function setupExtensionHost(options: SetupExtensionHostOptions): Pr defineInvokeHandler(context, electronPluginLoad, async (payload) => { const result = await hostService.load(payload.extensionId) context.emit(electronPluginToolsChanged, { - reason: 'loaded', extensionId: payload.extensionId, + reason: 'loaded', }) return result }) @@ -86,8 +86,8 @@ export async function setupExtensionHost(options: SetupExtensionHostOptions): Pr defineInvokeHandler(context, electronPluginUnload, async (payload) => { const result = await hostService.unload(payload.extensionId) context.emit(electronPluginToolsChanged, { - reason: 'unloaded', extensionId: payload.extensionId, + reason: 'unloaded', }) return result }) @@ -123,10 +123,10 @@ export async function setupExtensionHost(options: SetupExtensionHostOptions): Pr switch (payload.state) { case 'announced': return hostService.host.announceCapability(payload.key, payload.metadata) - case 'ready': - return hostService.host.markCapabilityReady(payload.key, payload.metadata) case 'degraded': return hostService.host.markCapabilityDegraded(payload.key, payload.metadata) + case 'ready': + return hostService.host.markCapabilityReady(payload.key, payload.metadata) case 'withdrawn': return hostService.host.withdrawCapability(payload.key, payload.metadata) default: { diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts index 7cd6f2a15..7fde5007c 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts @@ -14,12 +14,12 @@ import type { ExtensionHost, KitDescriptor } from '@proj-airi/plugin-sdk/plugin- * - The gamelet kit descriptor used for `kit.gamelet` */ export const gameletPluginKitDescriptor = { - kitId: 'kit.gamelet', - version: '1.0.0', - runtimes: ['electron', 'web'], capabilities: [ - { key: 'kit.gamelet.runtime', actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'] }, + { actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'], key: 'kit.gamelet.runtime' }, ], + kitId: 'kit.gamelet', + runtimes: ['electron', 'web'], + version: '1.0.0', } satisfies KitDescriptor /** diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/orchestration.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/orchestration.ts index 2d1cb4481..a03dd1605 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/orchestration.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/orchestration.ts @@ -27,39 +27,46 @@ export function createGameletOrchestrationRuntime( widgetsManager: ExtensionHostGameletWidgetsManager, ): GameletOrchestrationRuntime { return { + async close(bindingId) { + await widgetsManager.removeWidget(bindingId) + }, + async configure(bindingId, payload) { + await widgetsManager.updateWidget({ + componentProps: createComponentProps(bindingId, payload), + id: bindingId, + }) + }, + dispose() {}, + async isOpen(bindingId) { + return Boolean(widgetsManager.getWidgetSnapshot(bindingId)) + }, async open(bindingId, payload) { const componentProps = createComponentProps(bindingId, payload ?? {}) if (widgetsManager.getWidgetSnapshot(bindingId)) { await widgetsManager.updateWidget({ - id: bindingId, componentProps, + id: bindingId, size: 'l', }) } else { await widgetsManager.pushWidget({ - id: bindingId, componentName: 'extension-ui', componentProps, + id: bindingId, size: 'l', }) } await widgetsManager.openWindow({ id: bindingId }) }, - async configure(bindingId, payload) { - await widgetsManager.updateWidget({ - id: bindingId, - componentProps: createComponentProps(bindingId, payload), - }) - }, async request(bindingId: string, payload: HostDataRecord, options?: { timeoutMs?: number }): Promise { if (!widgetsManager.getWidgetSnapshot(bindingId)) { throw new Error(`Gamelet \`${bindingId}\` is not open.`) } - return await widgetsManager.requestWidgetIframe>( + return await widgetsManager.requestWidgetIframe & TResponse>( bindingId, payload, { @@ -67,13 +74,6 @@ export function createGameletOrchestrationRuntime( }, ) as TResponse }, - async close(bindingId) { - await widgetsManager.removeWidget(bindingId) - }, - async isOpen(bindingId) { - return Boolean(widgetsManager.getWidgetSnapshot(bindingId)) - }, - dispose() {}, } } diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/index.ts index 15732cb79..2d586535a 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/index.ts @@ -15,7 +15,43 @@ import { registerWidgetPluginKit } from './widget' type GameletKitClient = ReturnType type ToolKitClient = ReturnType -function createHostGameletKit(options: { host: ExtensionHost, gamelets: GameletOrchestrationRuntime }): KitRef { +/** + * Creates the built-in kit runtime installed by the Electron extension host. + * + * Use when: + * - Host bootstrap should depend on a kit-layer API instead of wiring widget/gamelet details inline + * - Built-in kit registration should remain outside the host layer + * + * Expects: + * - `widgetsManager` is initialized before host construction + * + * Returns: + * - Helpers to register built-in kits on the host + */ +export function createBuiltInExtensionKitRuntime(options: SetupExtensionHostOptions): { + dispose: () => void + registerHostKits: (host: ExtensionHost) => void + tools: TamagotchiToolRegistry +} { + const gamelets = createGameletOrchestrationRuntime(options.widgetsManager) + const tools = new TamagotchiToolRegistry() + + return { + dispose() { + gamelets.dispose() + tools.clear() + }, + registerHostKits(host) { + registerWidgetPluginKit(host) + registerGameletPluginKit(host) + host.registerKitApi(createHostGameletKit({ gamelets, host })) + host.registerKitApi(createHostToolKit({ tools })) + }, + tools, + } +} + +function createHostGameletKit(options: { gamelets: GameletOrchestrationRuntime, host: ExtensionHost }): KitRef { return { ...gameletKit, createClient(runtime) { @@ -56,18 +92,18 @@ function createHostToolKit(options: { tools: TamagotchiToolRegistry }): KitRef { ensureCleanup() options.tools.register({ - ownerSessionId: runtime.sessionId, ownerExtensionId: runtime.extensionId, ownerModuleId: runtime.moduleId, + ownerSessionId: runtime.sessionId, ...input, }) }, registerToolsetPrompt: (input) => { ensureCleanup() options.tools.registerToolsetPrompt({ - ownerSessionId: runtime.sessionId, ownerExtensionId: runtime.extensionId, ownerModuleId: runtime.moduleId, + ownerSessionId: runtime.sessionId, toolset: input, }) }, @@ -78,39 +114,3 @@ function createHostToolKit(options: { tools: TamagotchiToolRegistry }): KitRef void - tools: TamagotchiToolRegistry - dispose: () => void -} { - const gamelets = createGameletOrchestrationRuntime(options.widgetsManager) - const tools = new TamagotchiToolRegistry() - - return { - registerHostKits(host) { - registerWidgetPluginKit(host) - registerGameletPluginKit(host) - host.registerKitApi(createHostGameletKit({ host, gamelets })) - host.registerKitApi(createHostToolKit({ tools })) - }, - tools, - dispose() { - gamelets.dispose() - tools.clear() - }, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts index 191b48cae..9dd58b049 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts @@ -24,19 +24,6 @@ export interface WidgetAssetRoute { sessionPathPrefix: string } -function normalizeWidgetAssetPath(assetPath: string): string | undefined { - const trimmed = assetPath.trim().replaceAll('\\', '/') - if (!trimmed) { - return undefined - } - - const withoutRelativePrefix = trimmed.startsWith('./') - ? trimmed.slice(2) - : trimmed - - return normalizeStaticAssetPath(withoutRelativePrefix) -} - /** * Normalizes a widget iframe asset path into `/ui` route semantics. * @@ -51,7 +38,7 @@ function normalizeWidgetAssetPath(assetPath: string): string | undefined { * Returns: * - The route-relative asset path and the allowed session prefix for that route */ -export function resolveWidgetAssetRoute(assetPath: string): WidgetAssetRoute | undefined { +export function resolveWidgetAssetRoute(assetPath: string): undefined | WidgetAssetRoute { const normalized = normalizeWidgetAssetPath(assetPath) if (!normalized) { return undefined @@ -97,16 +84,16 @@ export function rewriteWidgetModuleAssetUrl( module: PluginHostModuleSummary, manifestEntryByExtensionId: Map, options?: { - extensionAssetBaseUrl?: string createAssetSession?: (input: { extensionId: string - version: string - sessionId: string routeAssetPath: string + sessionId: string sessionPathPrefix: string + version: string }) => Promise<{ assetSessionId: string, url?: string }> + extensionAssetBaseUrl?: string }, -): Promise | PluginHostModuleSummary { +): PluginHostModuleSummary | Promise { const entry = manifestEntryByExtensionId.get(module.ownerExtensionId) if (!entry) { return module @@ -144,15 +131,15 @@ export function rewriteWidgetModuleAssetUrl( return options.createAssetSession({ extensionId: module.ownerExtensionId, - version: entry.version, - sessionId: module.ownerSessionId, routeAssetPath: widgetAssetRoute.routeAssetPath, + sessionId: module.ownerSessionId, sessionPathPrefix: widgetAssetRoute.sessionPathPrefix, + version: entry.version, }).then((session) => { const mountedPath = buildMountedStaticAssetPath({ - extensionId: module.ownerExtensionId, - assetSessionId: session.assetSessionId, assetPath: widgetAssetRoute.routeAssetPath, + assetSessionId: session.assetSessionId, + extensionId: module.ownerExtensionId, }) const iframeUrl = session.url ?? (mountedPath ? new URL(mountedPath, options.extensionAssetBaseUrl).toString() : '') if (!iframeUrl) { @@ -174,3 +161,16 @@ export function rewriteWidgetModuleAssetUrl( } }) } + +function normalizeWidgetAssetPath(assetPath: string): string | undefined { + const trimmed = assetPath.trim().replaceAll('\\', '/') + if (!trimmed) { + return undefined + } + + const withoutRelativePrefix = trimmed.startsWith('./') + ? trimmed.slice(2) + : trimmed + + return normalizeStaticAssetPath(withoutRelativePrefix) +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/index.ts index 5f2218f57..df9e02fb4 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/index.ts @@ -16,12 +16,12 @@ export { resolveWidgetAssetRoute, rewriteWidgetModuleAssetUrl } from './asset-ur * - The widget kit descriptor used for `kit.widget` */ export const widgetPluginKitDescriptor = { - kitId: 'kit.widget', - version: '1.0.0', - runtimes: ['electron', 'web'], capabilities: [ - { key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] }, + { actions: ['announce', 'activate', 'update', 'withdraw'], key: 'kit.widget.module' }, ], + kitId: 'kit.widget', + runtimes: ['electron', 'web'], + version: '1.0.0', } satisfies KitDescriptor /** diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts index c8eb9b427..f07a62e6f 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts @@ -7,9 +7,89 @@ import type { } from '../../../../shared/eventa' /** - * Stable manifest id used as the runtime identity for one extension. + * Persisted extension configuration snapshot. + * + * Use when: + * - Reading/writing enabled and auto-reload extension state + * - Keeping known extension manifest path metadata + * + * Expects: + * - Arrays contain extension manifest ids + * - `known` maps extension manifest ids to canonical manifest paths + * + * Returns: + * - N/A */ -export type ExtensionId = string +export interface ExtensionConfig { + autoReload: ExtensionId[] + enabled: ExtensionId[] + known: Record +} + +/** + * Binding announcement payload used by extension-side runtime registration. + * + * Use when: + * - Announcing a new module for a registered kit + * - Reusing existing module ownership with the same module identifier + * + * Expects: + * - `moduleId` is unique per owner session/plugin pair + * - `kitId` and `kitModuleType` map to a registered kit descriptor + * - `config` is a JSON-compatible record + * + * Returns: + * - N/A + */ +export interface ExtensionHostBindingAnnounceInput { + config: Record + kitId: string + kitModuleType: string + moduleId: string +} + +/** + * Optional filters for listing announced bindings. + * + * Use when: + * - Querying only modules from one session + * - Querying modules belonging to one kit + * + * Expects: + * - Any provided key is treated as a strict equality filter + * + * Returns: + * - N/A + */ +export interface ExtensionHostBindingListOptions { + kitId?: string + ownerSessionId?: string +} + +/** + * Describes the widget manager surface required by extension-driven gamelet APIs. + * + * Use when: + * - `setupExtensionHost(...)` needs to open, update, or close extension-ui widgets + * + * Expects: + * - Widget ids remain stable and may be reused for the same module id + * + * Returns: + * - The minimal widget-manager contract consumed by the extension host service + */ +export interface ExtensionHostGameletWidgetsManager { + getWidgetSnapshot: (id: string) => undefined | WidgetSnapshot + openWindow: (params?: { id?: string }) => Promise + pushWidget: (payload: WidgetsAddPayload) => Promise + removeWidget: (id: string) => Promise + requestWidgetIframe: = Record>( + id: string, + payload: Record, + options?: { timeoutMs?: number }, + ) => Promise + updateWidget: (payload: WidgetsUpdatePayload) => Promise +} /** * Runtime-facing extension host service bundle returned by setup. @@ -31,106 +111,9 @@ export interface ExtensionHostService { } /** - * Describes the widget manager surface required by extension-driven gamelet APIs. - * - * Use when: - * - `setupExtensionHost(...)` needs to open, update, or close extension-ui widgets - * - * Expects: - * - Widget ids remain stable and may be reused for the same module id - * - * Returns: - * - The minimal widget-manager contract consumed by the extension host service + * Stable manifest id used as the runtime identity for one extension. */ -export interface ExtensionHostGameletWidgetsManager { - openWindow: (params?: { id?: string }) => Promise - pushWidget: (payload: WidgetsAddPayload) => Promise - updateWidget: (payload: WidgetsUpdatePayload) => Promise - removeWidget: (id: string) => Promise - getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined - requestWidgetIframe: = Record>( - id: string, - payload: Record, - options?: { timeoutMs?: number }, - ) => Promise -} - -/** - * Configures the runtime dependencies required by `setupExtensionHost(...)`. - * - * Use when: - * - Wiring the extension host during Electron startup - * - Providing test doubles for extension-driven gamelet orchestration - * - * Expects: - * - `widgetsManager` is already initialized and ready to manage overlay widgets - * - * Returns: - * - N/A - */ -export interface SetupExtensionHostOptions { - widgetsManager: ExtensionHostGameletWidgetsManager -} - -/** - * Binding announcement payload used by extension-side runtime registration. - * - * Use when: - * - Announcing a new module for a registered kit - * - Reusing existing module ownership with the same module identifier - * - * Expects: - * - `moduleId` is unique per owner session/plugin pair - * - `kitId` and `kitModuleType` map to a registered kit descriptor - * - `config` is a JSON-compatible record - * - * Returns: - * - N/A - */ -export interface ExtensionHostBindingAnnounceInput { - moduleId: string - kitId: string - kitModuleType: string - config: Record -} - -/** - * Optional filters for listing announced bindings. - * - * Use when: - * - Querying only modules from one session - * - Querying modules belonging to one kit - * - * Expects: - * - Any provided key is treated as a strict equality filter - * - * Returns: - * - N/A - */ -export interface ExtensionHostBindingListOptions { - ownerSessionId?: string - kitId?: string -} - -/** - * Persisted extension configuration snapshot. - * - * Use when: - * - Reading/writing enabled and auto-reload extension state - * - Keeping known extension manifest path metadata - * - * Expects: - * - Arrays contain extension manifest ids - * - `known` maps extension manifest ids to canonical manifest paths - * - * Returns: - * - N/A - */ -export interface ExtensionConfig { - enabled: ExtensionId[] - autoReload: ExtensionId[] - known: Record -} +export type ExtensionId = string /** * Internal manifest record with resolved location and package version. @@ -154,3 +137,20 @@ export interface ManifestEntry { rootDir: string version: string } + +/** + * Configures the runtime dependencies required by `setupExtensionHost(...)`. + * + * Use when: + * - Wiring the extension host during Electron startup + * - Providing test doubles for extension-driven gamelet orchestration + * + * Expects: + * - `widgetsManager` is already initialized and ready to manage overlay widgets + * + * Returns: + * - N/A + */ +export interface SetupExtensionHostOptions { + widgetsManager: ExtensionHostGameletWidgetsManager +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/artistry-bridge.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/artistry-bridge.ts index e19a2e292..81075115b 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/artistry-bridge.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/artistry-bridge.ts @@ -24,19 +24,19 @@ const DEFAULT_REMIX_ID = '48250602' const DEFAULT_ARTISTRY_PROVIDER = 'none' interface ArtistrySyncSnapshot { - provider?: string - model?: string - promptPrefix?: string - options?: Record globals?: Record + model?: string + options?: Record + promptPrefix?: string + provider?: string } interface TriggerConfig { - provider?: string - model?: string - promptPrefix?: string - options?: Record globals?: Record + model?: string + options?: Record + promptPrefix?: string + provider?: string } function robustParse(input: unknown, context?: string): Record { @@ -66,11 +66,11 @@ const activeRunMap = new Map() * Synced from the renderer App.vue whenever the character or settings change. */ const cardDefaults: ArtistrySyncSnapshot = { - provider: undefined as string | undefined, - model: undefined as string | undefined, - promptPrefix: undefined as string | undefined, - options: undefined as Record | undefined, globals: undefined as Record | undefined, + model: undefined as string | undefined, + options: undefined as Record | undefined, + promptPrefix: undefined as string | undefined, + provider: undefined as string | undefined, } function createRunId(widgetId: string) { @@ -105,15 +105,15 @@ artistryProviders.set('replicate', new ReplicateProvider()) artistryProviders.set('nanobanana', new NanoBananaProvider()) // Deduplication map for headless requests -const pendingHeadlessRequests = new Map>() +const pendingHeadlessRequests = new Map>() export async function generateHeadless(params: { - prompt: string - model?: string - provider?: string - options?: Record globals?: Record -}): Promise<{ imageUrl?: string, base64?: string, error?: string }> { + model?: string + options?: Record + prompt: string + provider?: string +}): Promise<{ base64?: string, error?: string, imageUrl?: string }> { // Resolve config and effective globals early to secure the deduplication fingerprint const { config: artistryConfig } = await injeca.resolve({ config: 'configs:artistry' } as { config: ProvidedBy> }) const activeGlobals = (params.globals || artistryConfig.get()?.artistryGlobals || {}) as Record @@ -130,12 +130,12 @@ export async function generateHeadless(params: { const globalsHash = createHash('sha256').update(JSON.stringify(globalsForFingerprint)).digest('hex') const fingerprint = JSON.stringify({ - p: params.prompt, - m: params.model, - pr: params.provider, - o: params.options, - ih: imageHash, gh: globalsHash, // Include globals hash (Issue #39) + ih: imageHash, + m: params.model, + o: params.options, + p: params.prompt, + pr: params.provider, }) if (pendingHeadlessRequests.has(fingerprint)) { @@ -167,16 +167,16 @@ export async function generateHeadless(params: { log.log(`[Headless] Source image length: ${activeGlobals.image.length}`) const request: ArtistryRequest = { - prompt: params.prompt, - negativePrompt: params.options?.negativePrompt, - width: typeof params.options?.width === 'number' ? params.options.width : undefined, - height: typeof params.options?.height === 'number' ? params.options.height : undefined, - model: params.model, extra: { ...params.options, image: activeGlobals?.image, internalJobId: createRunId('headless'), }, + height: typeof params.options?.height === 'number' ? params.options.height : undefined, + model: params.model, + negativePrompt: params.options?.negativePrompt, + prompt: params.prompt, + width: typeof params.options?.width === 'number' ? params.options.width : undefined, } log.log(`[Headless] Starting generation with provider: ${requestedProvider}, model: ${params.model || 'default'}`) @@ -215,12 +215,12 @@ export async function generateHeadless(params: { log.log(`[Headless] Job ${job.jobId} succeeded. Image URL: ${lastStatus.imageUrl}`) const base64 = lastStatus.imageUrl ? await downloadImageAsBase64(lastStatus.imageUrl) : undefined - return { imageUrl: lastStatus.imageUrl, base64 } + return { base64, imageUrl: lastStatus.imageUrl } } else { // For providers with callbacks (like ComfyUI), we wait for the result via the callback log.log(`[Headless] Using callback-based wait logic for provider: ${requestedProvider}`) - return new Promise<{ imageUrl?: string, base64?: string }>((resolve, reject) => { + return new Promise<{ base64?: string, imageUrl?: string }>((resolve, reject) => { const timeout = 1000 * 60 * 5 // 5 minutes timeout const timer = setTimeout(() => { reject(new Error('Image generation timed out after 5 minutes.')) @@ -231,7 +231,7 @@ export async function generateHeadless(params: { clearTimeout(timer) try { const base64 = status.imageUrl ? await downloadImageAsBase64(status.imageUrl) : undefined - resolve({ imageUrl: status.imageUrl, base64 }) + resolve({ base64, imageUrl: status.imageUrl }) } catch (e) { reject(e) @@ -260,10 +260,112 @@ export async function generateHeadless(params: { } } +export async function setupArtistryBridge(params: { + artistryConfig: Config + context?: ReturnType['context'] + widgetsManager: WidgetsWindowManager +}) { + log.log('🚀 Initializing Artistry bridge (Spawn + Update Interceptor + Headless Handler)...') + + if (params.context) { + defineInvokeHandler(params.context, artistryGenerateHeadless, async (payload) => { + log.log(`[Artistry Bridge] [Headless] Received invoke for prompt: ${payload.prompt.slice(0, 50)}...`) + return await generateHeadless(payload) + }) + + defineInvokeHandler(params.context, artistrySyncConfig, (payload) => { + log.log(`🔄 Syncing artistry config to main. Provider: ${payload.provider}`) + params.artistryConfig.update({ + artistryGlobals: payload.globals || params.artistryConfig.get()?.artistryGlobals || { + comfyuiActiveWorkflow: '', + comfyuiSavedWorkflows: [], + comfyuiServerUrl: 'http://localhost:8188', + nanobananaApiKey: '', + nanobananaModel: 'gemini-3.1-flash-image-preview', + nanobananaResolution: '1K', + replicateApiKey: '', + replicateAspectRatio: '16:9', + replicateDefaultModel: 'black-forest-labs/flux-schnell', + replicateInferenceSteps: 4, + }, + artistryProvider: payload.provider || params.artistryConfig.get()?.artistryProvider || DEFAULT_ARTISTRY_PROVIDER, + }) + + // Update character-level defaults (volatile only) + cardDefaults.provider = payload.provider + cardDefaults.model = payload.model + cardDefaults.promptPrefix = payload.promptPrefix + cardDefaults.options = payload.options + cardDefaults.globals = payload.globals + }) + + defineInvokeHandler(params.context, artistryTestComfyUIConnection, async (payload) => { + log.log(`🔌 Testing ComfyUI connection at: ${payload.url}`) + try { + const url = payload.url.replace(/\/+$/, '') + const controller = new AbortController() + const id = setTimeout(() => controller.abort(), 10000) + const resp = await fetch(`${url}/system_stats`, { signal: controller.signal }) + clearTimeout(id) + + if (!resp.ok) + throw new Error(`HTTP ${resp.status}`) + const data = await resp.json() as { devices?: Array<{ name?: string, vram_total?: number }> } + const gpus = data.devices?.map(d => d.name).join(', ') || 'Unknown GPU' + const vram = data.devices?.[0]?.vram_total + const vramStr = vram ? `${(vram / 1024 / 1024 / 1024).toFixed(1)} GB` : '' + return { + info: `Connected — ${gpus}${vramStr ? ` (${vramStr} VRAM)` : ''}`, + ok: true, + } + } + catch (e: unknown) { + const message = errorMessageFrom(e) ?? 'Unknown connection error' + log.error(`🔌 ComfyUI connection test failed: ${message}`) + return { + info: `Failed: ${message}`, + ok: false, + } + } + }) + } + + const originalUpdateWidget = params.widgetsManager.updateWidget + params.widgetsManager.updateWidget = async (payload) => { + const snapshot = params.widgetsManager.getWidgetSnapshot(payload.id) + await originalUpdateWidget.call(params.widgetsManager, payload) + await handleArtistryTrigger({ + componentName: snapshot?.componentName, + componentProps: payload.componentProps, + id: payload.id, + widgetsManager: params.widgetsManager, + }) + } + + const originalPushWidget = params.widgetsManager.pushWidget + params.widgetsManager.pushWidget = async (payload) => { + if (payload.componentName === 'comfy' || payload.componentName === 'artistry') { + log.log(`🖼️ Enabling 'Living Wall' mode for ${payload.id}. Forcing infinite TTL. (Component: ${payload.componentName})`) + payload.ttlMs = 0 + } + + const resultId = await originalPushWidget.call(params.widgetsManager, payload) + + await handleArtistryTrigger({ + componentName: payload.componentName, + componentProps: payload.componentProps, + id: resultId, + widgetsManager: params.widgetsManager, + }) + + return resultId + } +} + async function handleArtistryTrigger(params: { - id: string componentName?: string componentProps?: unknown + id: string widgetsManager: WidgetsWindowManager }) { if (params.componentName !== 'comfy' && params.componentName !== 'artistry') @@ -281,16 +383,16 @@ async function handleArtistryTrigger(params: { // 1. Explicitly provided in component props (_artistryConfig) // 2. Character-level defaults synced from renderer (cardDefaults) const config: TriggerConfig = { - provider: artistryConfigOverrides.provider as string | undefined, + // NOTICE: Keep legacy `Globals` fallback while standardizing on `globals`. + // Older widget payloads can still send `Globals`, and dropping it now would break them. + globals: robustParse(artistryConfigOverrides.globals || artistryConfigOverrides.Globals || cardDefaults.globals, 'artistryGlobals'), model: (artistryConfigOverrides.model as string | undefined) || cardDefaults.model, - promptPrefix: (artistryConfigOverrides.promptPrefix as string | undefined) || cardDefaults.promptPrefix, options: { ...cardDefaults.options, ...robustParse(artistryConfigOverrides.options, 'artistryOptions'), }, - // NOTICE: Keep legacy `Globals` fallback while standardizing on `globals`. - // Older widget payloads can still send `Globals`, and dropping it now would break them. - globals: robustParse(artistryConfigOverrides.globals || artistryConfigOverrides.Globals || cardDefaults.globals, 'artistryGlobals'), + promptPrefix: (artistryConfigOverrides.promptPrefix as string | undefined) || cardDefaults.promptPrefix, + provider: artistryConfigOverrides.provider as string | undefined, } const { config: artistryConfig } = await injeca.resolve({ config: 'configs:artistry' } as { config: ProvidedBy> }) const providerId = config.provider || cardDefaults.provider || artistryConfig.get()?.artistryProvider || DEFAULT_ARTISTRY_PROVIDER @@ -328,8 +430,8 @@ async function handleArtistryTrigger(params: { if (!provider) { log.error(`🔴 Provider '${providerId}' not found.`) params.widgetsManager.updateWidget({ + componentProps: { actionLabel: `Provider '${providerId}' not available`, status: 'error' }, id: params.id, - componentProps: { status: 'error', actionLabel: `Provider '${providerId}' not available` }, }) return } @@ -344,8 +446,6 @@ async function handleArtistryTrigger(params: { try { // Build the abstract request const request: ArtistryRequest = { - prompt: config.promptPrefix ? `${config.promptPrefix} ${prompt}` : (prompt || ''), - model: config.model, extra: { ...options, ...props, // Include root componentProps overrides (template, node overrides) @@ -353,6 +453,8 @@ async function handleArtistryTrigger(params: { internalJobId: runId, // Track each generation independently, even on the same widget. remixId, }, + model: config.model, + prompt: config.promptPrefix ? `${config.promptPrefix} ${prompt}` : (prompt || ''), } const updateIfActive = (statusUpdate: Record) => { @@ -365,11 +467,11 @@ async function handleArtistryTrigger(params: { // that would otherwise be lost when the final 'done' status is sent. const existing = params.widgetsManager.getWidgetSnapshot(params.id) params.widgetsManager.updateWidget({ - id: params.id, componentProps: { ...(existing?.componentProps as any), ...statusUpdate, }, + id: params.id, }) } @@ -379,7 +481,7 @@ async function handleArtistryTrigger(params: { updateIfActive(statusUpdate as Record) if (statusUpdate.status === 'succeeded') { log.log(`🎉 Job complete (via callback) for ${params.id}. Sending final status: done`) - updateIfActive({ status: 'done', progress: 100, actionLabel: undefined }) + updateIfActive({ actionLabel: undefined, progress: 100, status: 'done' }) } else if (statusUpdate.status === 'failed') { log.log(`🔴 Job failed (via callback) for ${params.id}. Preserving error status.`) @@ -400,7 +502,7 @@ async function handleArtistryTrigger(params: { // Check for timeout if (Date.now() - startTime > timeoutLength) { log.error(`[Artistry Bridge] Job ${job.jobId} timed out after 5 minutes.`) - updateIfActive({ status: 'error', actionLabel: 'Generation timed out' }) + updateIfActive({ actionLabel: 'Generation timed out', status: 'error' }) break } @@ -427,7 +529,7 @@ async function handleArtistryTrigger(params: { const finalStatus = await provider.getStatus(job.jobId) if (finalStatus.status === 'succeeded') { log.log(`🎉 Job complete (via polling) for ${params.id}. Sending final status: done`) - updateIfActive({ status: 'done', progress: 100, actionLabel: undefined }) + updateIfActive({ actionLabel: undefined, progress: 100, status: 'done' }) } else { log.log(`🔴 Job failed (via polling) for ${params.id}. Preserving error status.`) @@ -441,112 +543,10 @@ async function handleArtistryTrigger(params: { if (activeRunMap.get(params.id) === runId) { lastTriggerMap.delete(params.id) // [BY DESIGN]: Clear fingerprint on failure to allow retry (Issue #44) params.widgetsManager.updateWidget({ + componentProps: { actionLabel: message, status: 'error' }, id: params.id, - componentProps: { status: 'error', actionLabel: message }, }) } } } } - -export async function setupArtistryBridge(params: { - widgetsManager: WidgetsWindowManager - context?: ReturnType['context'] - artistryConfig: Config -}) { - log.log('🚀 Initializing Artistry bridge (Spawn + Update Interceptor + Headless Handler)...') - - if (params.context) { - defineInvokeHandler(params.context, artistryGenerateHeadless, async (payload) => { - log.log(`[Artistry Bridge] [Headless] Received invoke for prompt: ${payload.prompt.slice(0, 50)}...`) - return await generateHeadless(payload) - }) - - defineInvokeHandler(params.context, artistrySyncConfig, (payload) => { - log.log(`🔄 Syncing artistry config to main. Provider: ${payload.provider}`) - params.artistryConfig.update({ - artistryProvider: payload.provider || params.artistryConfig.get()?.artistryProvider || DEFAULT_ARTISTRY_PROVIDER, - artistryGlobals: payload.globals || params.artistryConfig.get()?.artistryGlobals || { - comfyuiServerUrl: 'http://localhost:8188', - comfyuiSavedWorkflows: [], - comfyuiActiveWorkflow: '', - replicateApiKey: '', - replicateDefaultModel: 'black-forest-labs/flux-schnell', - replicateAspectRatio: '16:9', - replicateInferenceSteps: 4, - nanobananaApiKey: '', - nanobananaModel: 'gemini-3.1-flash-image-preview', - nanobananaResolution: '1K', - }, - }) - - // Update character-level defaults (volatile only) - cardDefaults.provider = payload.provider - cardDefaults.model = payload.model - cardDefaults.promptPrefix = payload.promptPrefix - cardDefaults.options = payload.options - cardDefaults.globals = payload.globals - }) - - defineInvokeHandler(params.context, artistryTestComfyUIConnection, async (payload) => { - log.log(`🔌 Testing ComfyUI connection at: ${payload.url}`) - try { - const url = payload.url.replace(/\/+$/, '') - const controller = new AbortController() - const id = setTimeout(() => controller.abort(), 10000) - const resp = await fetch(`${url}/system_stats`, { signal: controller.signal }) - clearTimeout(id) - - if (!resp.ok) - throw new Error(`HTTP ${resp.status}`) - const data = await resp.json() as { devices?: Array<{ name?: string, vram_total?: number }> } - const gpus = data.devices?.map(d => d.name).join(', ') || 'Unknown GPU' - const vram = data.devices?.[0]?.vram_total - const vramStr = vram ? `${(vram / 1024 / 1024 / 1024).toFixed(1)} GB` : '' - return { - ok: true, - info: `Connected — ${gpus}${vramStr ? ` (${vramStr} VRAM)` : ''}`, - } - } - catch (e: unknown) { - const message = errorMessageFrom(e) ?? 'Unknown connection error' - log.error(`🔌 ComfyUI connection test failed: ${message}`) - return { - ok: false, - info: `Failed: ${message}`, - } - } - }) - } - - const originalUpdateWidget = params.widgetsManager.updateWidget - params.widgetsManager.updateWidget = async (payload) => { - const snapshot = params.widgetsManager.getWidgetSnapshot(payload.id) - await originalUpdateWidget.call(params.widgetsManager, payload) - await handleArtistryTrigger({ - id: payload.id, - componentName: snapshot?.componentName, - componentProps: payload.componentProps, - widgetsManager: params.widgetsManager, - }) - } - - const originalPushWidget = params.widgetsManager.pushWidget - params.widgetsManager.pushWidget = async (payload) => { - if (payload.componentName === 'comfy' || payload.componentName === 'artistry') { - log.log(`🖼️ Enabling 'Living Wall' mode for ${payload.id}. Forcing infinite TTL. (Component: ${payload.componentName})`) - payload.ttlMs = 0 - } - - const resultId = await originalPushWidget.call(params.widgetsManager, payload) - - await handleArtistryTrigger({ - id: resultId, - componentName: payload.componentName, - componentProps: payload.componentProps, - widgetsManager: params.widgetsManager, - }) - - return resultId - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/index.test.ts index 34e1d26e0..178e9b9c6 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/index.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/index.test.ts @@ -6,20 +6,12 @@ import { describe, expect, it, vi } from 'vitest' import { widgetsIframeRequestResultEvent } from '../../../../shared/eventa' import { createWidgetsService } from './index' -function createWindow(id: number): BrowserWindow { - return { - webContents: { - id, - }, - } as BrowserWindow -} - function createWidgetsManager() { return { clearWidgets: vi.fn(), fetchWidget: vi.fn(), - getWindow: vi.fn(), getWidgetSnapshot: vi.fn(), + getWindow: vi.fn(), hideWindow: vi.fn(), onWidgetEvent: vi.fn(), openWindow: vi.fn(), @@ -33,6 +25,14 @@ function createWidgetsManager() { } } +function createWindow(id: number): BrowserWindow { + return { + webContents: { + id, + }, + } as BrowserWindow +} + describe('createWidgetsService', () => { it('routes iframe request results from the widgets window to the manager', () => { const context = createContext() @@ -46,8 +46,8 @@ describe('createWidgetsService', () => { context.emit(widgetsIframeRequestResultEvent, { id: 'kit-module:board', - requestId: 'req-1', ok: true, + requestId: 'req-1', result: { fen: 'fen-after-request' }, }, { raw: { @@ -59,8 +59,8 @@ describe('createWidgetsService', () => { expect(widgetsManager.publishWidgetIframeRequestResult).toHaveBeenCalledWith({ id: 'kit-module:board', - requestId: 'req-1', ok: true, + requestId: 'req-1', result: { fen: 'fen-after-request' }, }) }) @@ -77,8 +77,8 @@ describe('createWidgetsService', () => { context.emit(widgetsIframeRequestResultEvent, { id: 'kit-module:board', - requestId: 'req-1', ok: true, + requestId: 'req-1', result: { fen: 'fen-after-request' }, }, { raw: { diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts index 4a3280cb8..5add588af 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts @@ -30,13 +30,6 @@ interface InvokeOptions { raw?: { ipcMainEvent?: IpcMainEvent } } -function isFromWindow(options: InvokeOptions | undefined, window: BrowserWindow) { - const sender = options?.raw?.ipcMainEvent?.sender - if (!sender) - return false - return sender.id === window.webContents.id -} - /** * Registers widget-related Electron invoke handlers for one window context. * @@ -71,51 +64,22 @@ export function createWidgetsService(params: { context: ReturnType { - if (!isFromWindow(options as InvokeOptions, params.window)) - return undefined - const id = normalizeOptionalWidgetId(payload?.id) - return params.widgetsManager.prepareWidgetWindow(id ? { id } : undefined) - }, - widgetsOpenWindow: async (payload, options) => { - if (!isFromWindow(options as InvokeOptions, params.window)) - return undefined - const id = normalizeOptionalWidgetId(payload?.id) - return params.widgetsManager.openWindow(id ? { id } : undefined) - }, - widgetsHideWindow: async (payload, options) => { - if (!isFromWindow(options as InvokeOptions, params.window)) - return undefined - return params.widgetsManager!.hideWindow(payload ?? undefined) - }, widgetsAdd: async (payload, options) => { if (!isFromWindow(options as InvokeOptions, params.window)) return undefined return params.widgetsManager.pushWidget(validateWidgetsAddPayload(payload)) }, - widgetsUpdate: async (payload, options) => { - if (!isFromWindow(options as InvokeOptions, params.window)) - return undefined - return params.widgetsManager.updateWidget(validateWidgetsUpdatePayload(payload)) - }, - widgetsRemove: async (payload, options) => { - if (!isFromWindow(options as InvokeOptions, params.window)) - return undefined - return params.widgetsManager.removeWidget( - normalizeRequiredWidgetId(payload?.id, 'id is required to remove a widget.'), - ) - }, widgetsClear: async (_payload, options) => { if (!isFromWindow(options as InvokeOptions, params.window)) return undefined @@ -128,12 +92,48 @@ export function createWidgetsService(params: { context: ReturnType { + if (!isFromWindow(options as InvokeOptions, params.window)) + return undefined + return params.widgetsManager!.hideWindow(payload ?? undefined) + }, widgetsIframePublish: async (payload, options) => { if (!isFromWindow(options as InvokeOptions, params.window)) return undefined const id = normalizeRequiredWidgetId(payload?.id, 'id is required to publish a widget iframe event.') params.widgetsManager.publishWidgetEvent(id, validateWidgetIframeEvent(payload?.event)) }, + widgetsOpenWindow: async (payload, options) => { + if (!isFromWindow(options as InvokeOptions, params.window)) + return undefined + const id = normalizeOptionalWidgetId(payload?.id) + return params.widgetsManager.openWindow(id ? { id } : undefined) + }, + widgetsPrepareWindow: async (payload, options) => { + if (!isFromWindow(options as InvokeOptions, params.window)) + return undefined + const id = normalizeOptionalWidgetId(payload?.id) + return params.widgetsManager.prepareWidgetWindow(id ? { id } : undefined) + }, + widgetsRemove: async (payload, options) => { + if (!isFromWindow(options as InvokeOptions, params.window)) + return undefined + return params.widgetsManager.removeWidget( + normalizeRequiredWidgetId(payload?.id, 'id is required to remove a widget.'), + ) + }, + widgetsUpdate: async (payload, options) => { + if (!isFromWindow(options as InvokeOptions, params.window)) + return undefined + return params.widgetsManager.updateWidget(validateWidgetsUpdatePayload(payload)) + }, }, ) } + +function isFromWindow(options: InvokeOptions | undefined, window: BrowserWindow) { + const sender = options?.raw?.ipcMainEvent?.sender + if (!sender) + return false + return sender.id === window.webContents.id +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/base.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/base.ts index 0905cb61c..ba382b727 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/base.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/base.ts @@ -6,21 +6,6 @@ * the current AIRI card's artistry settings. */ -export interface ArtistryRequest { - /** The text prompt describing the desired image */ - prompt: string - /** Negative prompt — things to avoid (provider support varies) */ - negativePrompt?: string - /** Image width in pixels */ - width?: number - /** Image height in pixels */ - height?: number - /** Provider-specific model identifier */ - model?: string - /** Provider-specific extras (e.g. remixId, checkpoint, seed, aspect_ratio) */ - extra?: Record -} - export interface ArtistryJob { /** Internal job ID for tracking */ jobId: string @@ -28,34 +13,47 @@ export interface ArtistryJob { providerJobId: string } -export type ArtistryJobStatusType = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' - export interface ArtistryJobStatus { - status: ArtistryJobStatusType - /** Generation progress 0-100 (not all providers support this) */ - progress?: number - /** Final output image URL */ - imageUrl?: string - /** Error message if failed */ - error?: string /** Human-readable label of current stage (e.g. "Sampling", "VAE Decode") */ actionLabel?: string + /** Error message if failed */ + error?: string + /** Final output image URL */ + imageUrl?: string + /** Generation progress 0-100 (not all providers support this) */ + progress?: number + status: ArtistryJobStatusType } -export interface ArtistryProviderConfig { - /** Unique provider ID (e.g. "comfyui", "replicate") */ - id: string - /** Human-readable display name */ - name: string - /** Provider-specific configuration (API keys, paths, etc.) */ - settings: Record +export type ArtistryJobStatusType = 'cancelled' | 'failed' | 'queued' | 'running' | 'succeeded' + +/** + * Per-card artistry settings stored in AiriExtension.modules.artistry + */ +export interface ArtistryModuleSettings { + /** String prepended to every LLM-generated prompt for style consistency */ + defaultPromptPrefix?: string + /** Provider-specific model identifier */ + model?: string + /** Active provider ID (e.g. "comfyui", "replicate") */ + provider?: string + /** + * Free-form provider-specific options as a JSON object. + * For Replicate: { go_fast: true, megapixels: "1", aspect_ratio: "16:9", ... } + * For ComfyUI: { remixId: 48250602, checkpoint: "bunnyMint.safetensors" } + */ + providerOptions?: Record } export interface ArtistryProvider { - /** Unique provider ID */ - readonly id: string - /** Human-readable display name */ - readonly name: string + /** + * Cancel a running job (optional — not all providers support this). + */ + cancel?: (jobId: string) => Promise + /** + * Clean up resources when the provider is being switched out. + */ + dispose?: () => void /** * Start an image generation job. @@ -69,41 +67,43 @@ export interface ArtistryProvider { */ getStatus: (jobId: string) => Promise - /** - * Cancel a running job (optional — not all providers support this). - */ - cancel?: (jobId: string) => Promise + /** Unique provider ID */ + readonly id: string /** * Called when the provider is first initialized with its config. */ initialize?: (config: Record) => Promise + /** Human-readable display name */ + readonly name: string + /** * Optional push callback for providers that stream or callback status updates. */ setJobCallback?: (jobId: string, callback: (status: ArtistryJobStatus) => void) => void - - /** - * Clean up resources when the provider is being switched out. - */ - dispose?: () => void } -/** - * Per-card artistry settings stored in AiriExtension.modules.artistry - */ -export interface ArtistryModuleSettings { - /** Active provider ID (e.g. "comfyui", "replicate") */ - provider?: string +export interface ArtistryProviderConfig { + /** Unique provider ID (e.g. "comfyui", "replicate") */ + id: string + /** Human-readable display name */ + name: string + /** Provider-specific configuration (API keys, paths, etc.) */ + settings: Record +} + +export interface ArtistryRequest { + /** Provider-specific extras (e.g. remixId, checkpoint, seed, aspect_ratio) */ + extra?: Record + /** Image height in pixels */ + height?: number /** Provider-specific model identifier */ model?: string - /** String prepended to every LLM-generated prompt for style consistency */ - defaultPromptPrefix?: string - /** - * Free-form provider-specific options as a JSON object. - * For Replicate: { go_fast: true, megapixels: "1", aspect_ratio: "16:9", ... } - * For ComfyUI: { remixId: 48250602, checkpoint: "bunnyMint.safetensors" } - */ - providerOptions?: Record + /** Negative prompt — things to avoid (provider support varies) */ + negativePrompt?: string + /** The text prompt describing the desired image */ + prompt: string + /** Image width in pixels */ + width?: number } diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/comfyui.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/comfyui.ts index d4aec9463..6f8e478fa 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/comfyui.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/comfyui.ts @@ -13,43 +13,37 @@ export class ComfyUIProvider implements ArtistryProvider { readonly id = 'comfyui' readonly name = 'ComfyUI (Local)' - private serverUrl = 'http://localhost:8188' - private savedWorkflows: any[] = [] private activeWorkflowId = '' - - private jobResults = new Map() private callbacks = new Map void>() + private jobResults = new Map() - private async fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = 30000) { - const controller = new AbortController() - const id = setTimeout(() => controller.abort(), timeoutMs) - try { - const response = await fetch(url, { - ...options, - signal: controller.signal, + private savedWorkflows: any[] = [] + private serverUrl = 'http://localhost:8188' + + async generate(request: ArtistryRequest): Promise { + const jobId = request.extra?.internalJobId || Math.random().toString(36).slice(2) + + // Resolve which workflow template to use --- per-request template override takes precedence over card model default + const templateId = request.extra?.template || request.model || this.activeWorkflowId + const template = this.savedWorkflows.find((w: any) => w.id === templateId) + + if (!template) { + this.updateStatus(jobId, { + actionLabel: 'Error: No workflow configured', + error: 'No workflow template configured. Upload a workflow in Settings > Providers > ComfyUI.', + status: 'failed', }) - clearTimeout(id) - return response - } - catch (error) { - clearTimeout(id) - throw error + return { jobId, providerJobId: jobId } } + + // Start async generation + this.pollForResult(jobId, template, request) + + return { jobId, providerJobId: jobId } } - setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) { - this.callbacks.set(jobId, callback) - // If we already have a result, fire it immediately - const result = this.jobResults.get(jobId) - if (result) - callback(result) - } - - private updateStatus(jobId: string, status: ArtistryJobStatus) { - this.jobResults.set(jobId, status) - const callback = this.callbacks.get(jobId) - if (callback) - callback(status) + async getStatus(jobId: string): Promise { + return this.jobResults.get(jobId) || { status: 'queued' } } async initialize(config: any): Promise { @@ -61,195 +55,12 @@ export class ComfyUIProvider implements ArtistryProvider { this.activeWorkflowId = config.comfyuiActiveWorkflow } - async generate(request: ArtistryRequest): Promise { - const jobId = request.extra?.internalJobId || Math.random().toString(36).slice(2) - - // Resolve which workflow template to use --- per-request template override takes precedence over card model default - const templateId = request.extra?.template || request.model || this.activeWorkflowId - const template = this.savedWorkflows.find((w: any) => w.id === templateId) - - if (!template) { - this.updateStatus(jobId, { - status: 'failed', - error: 'No workflow template configured. Upload a workflow in Settings > Providers > ComfyUI.', - actionLabel: 'Error: No workflow configured', - }) - return { jobId, providerJobId: jobId } - } - - // Start async generation - this.pollForResult(jobId, template, request) - - return { jobId, providerJobId: jobId } - } - - private async pollForResult( - jobId: string, - template: { workflow: Record, exposedFields: Record }, - request: ArtistryRequest, - ) { - this.updateStatus(jobId, { status: 'running', actionLabel: 'Preparing workflow...' }) - - try { - // 0. Handle potential image and prompt upload bidirectional flow - const extraStr = JSON.stringify(request.extra || {}) - const workflowStr = JSON.stringify(template.workflow || {}) - const hasImagePlaceholder = extraStr.includes('{{IMAGE}}') || workflowStr.includes('{{IMAGE}}') - const hasPromptPlaceholder = extraStr.includes('{{PROMPT}}') || workflowStr.includes('{{PROMPT}}') - - let uploadedImageName = '' - if (hasImagePlaceholder && request.extra?.image) { - log.log(`[ComfyUI] Bidirectional flow detected. Uploading texture for job ${jobId}...`) - this.updateStatus(jobId, { status: 'running', actionLabel: 'Uploading texture to ComfyUI...' }) - try { - uploadedImageName = await this.uploadImage(request.extra.image) - log.log(`[ComfyUI] Texture uploaded as: ${uploadedImageName}`) - } - catch (e: any) { - log.error(`[ComfyUI] Texture upload failed: ${e.message}`) - } - } - - // 1. Apply overrides to the workflow template (standard injection) - let resolvedPrompt = this.applyOverrides(template, request) - - // 2. Perform final placeholder resolution across the ENTIRE resolved prompt - if (hasImagePlaceholder || hasPromptPlaceholder) { - log.log(`[ComfyUI] Performing final placeholder resolution for ${jobId}...`) - const replacements: Record = { - '{{PROMPT}}': request.prompt || '', - } - if (uploadedImageName) { - replacements['{{IMAGE}}'] = uploadedImageName - } - - resolvedPrompt = this.replacePlaceholders(resolvedPrompt, replacements) - } - - log.log(`[ComfyUI] Resolved prompt for ${jobId}:`, JSON.stringify(resolvedPrompt, null, 2)) - - // 2. POST /prompt to queue the workflow - this.updateStatus(jobId, { status: 'running', actionLabel: 'Queuing in ComfyUI...' }) - - let queueResp: Response - try { - queueResp = await this.fetchWithTimeout(`${this.serverUrl}/prompt`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt: resolvedPrompt }), - }, 15000) - } - catch (e: any) { - throw new Error(`Cannot connect to ComfyUI at ${this.serverUrl}: ${e.message}`) - } - - if (!queueResp.ok) { - const errorBody = await queueResp.text() - throw new Error(`Workflow error: ${errorBody.slice(0, 200)}`) - } - - const queueData = await queueResp.json() - const promptId = queueData.prompt_id - if (!promptId) { - throw new Error('ComfyUI returned no prompt_id') - } - - log.log(`[ComfyUI] Queued prompt ${promptId} for job ${jobId}`) - this.updateStatus(jobId, { status: 'running', actionLabel: 'Generating...' }) - - // 3. Poll /history/{prompt_id} until completion - let historyDone = false - let attempt = 0 - const startTime = Date.now() - - while (!historyDone) { - await new Promise(r => setTimeout(r, POLL_INTERVAL_MS)) - attempt++ - - if (Date.now() - startTime > POLL_TIMEOUT_MS) { - throw new Error('Generation timed out after 5 minutes') - } - - if (attempt % 3 === 0) { - log.log(`[ComfyUI] Polling history for ${promptId}... attempt ${attempt}`) - } - - let histResp: Response - try { - histResp = await this.fetchWithTimeout(`${this.serverUrl}/history/${promptId}`, {}, 10000) - } - catch (e: any) { - throw new Error(`ComfyUI disconnected during polling: ${e.message}`) - } - - if (histResp.ok) { - const histData = await histResp.json() - if (histData[promptId]) { - let outputs = histData[promptId].outputs - const stats = histData[promptId].status - - // 3.1. Race condition protection: If outputs are missing, wait a beat and retry once - if ((!outputs || Object.keys(outputs).length === 0) && !historyDone) { - log.warn(`[ComfyUI] Job ${jobId} finished but outputs are empty. Retrying history in 1s...`) - await new Promise(r => setTimeout(r, 1000)) - const retryResp = await this.fetchWithTimeout(`${this.serverUrl}/history/${promptId}`, {}, 10000) - if (retryResp.ok) { - const retryData = await retryResp.json() - if (retryData[promptId] && retryData[promptId].outputs) { - log.log(`[ComfyUI] Retry successful for ${jobId}. Managed to find outputs!`) - outputs = retryData[promptId].outputs - } - } - } - - // Log raw history if no images found or if there are status messages - if (stats?.messages && stats.messages.length > 0) { - log.warn(`[ComfyUI] History messages for ${promptId}:`, stats.messages) - } - - // Find first image in any node's output - for (const nodeId in outputs) { - const nodeOutput = outputs[nodeId] - if (nodeOutput.images && nodeOutput.images.length > 0) { - const img = nodeOutput.images[0] - const imageUrl = `${this.serverUrl}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}` - log.log(`[ComfyUI] Generation complete for job ${jobId}. Image: ${imageUrl}`) - this.updateStatus(jobId, { status: 'succeeded', progress: 100, imageUrl }) - historyDone = true - break - } - } - - // Job finished but no images - if (!historyDone) { - log.error(`[ComfyUI] Job finished for ${jobId} (Prompt ${promptId}) but no output images found. Raw History:`, JSON.stringify(histData[promptId], null, 2)) - this.updateStatus(jobId, { - status: 'failed', - error: 'Job completed but no images were generated', - actionLabel: 'Error: No images generated', - }) - historyDone = true - } - } - } - } - } - catch (error: any) { - const errorMessage = error.message || String(error) - log.error(`[ComfyUI] Generation failed for job ${jobId}: ${errorMessage}`) - this.updateStatus(jobId, { - status: 'failed', - error: errorMessage, - actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`, - }) - } - finally { - // Clean up callback and job result after completion to prevent memory leaks - setTimeout(() => { - this.callbacks.delete(jobId) - this.jobResults.delete(jobId) - }, 10000) - } + setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) { + this.callbacks.set(jobId, callback) + // If we already have a result, fire it immediately + const result = this.jobResults.get(jobId) + if (result) + callback(result) } /** @@ -258,7 +69,7 @@ export class ComfyUIProvider implements ArtistryProvider { * Mirrors the logic from CUIPP's getComfyTemplate.js. */ private applyOverrides( - template: { workflow: Record, exposedFields: Record }, + template: { exposedFields: Record, workflow: Record }, request: ArtistryRequest, ): Record { // Deep clone the workflow so we don't mutate the stored template @@ -338,36 +149,190 @@ export class ComfyUIProvider implements ArtistryProvider { return prompt } - async getStatus(jobId: string): Promise { - return this.jobResults.get(jobId) || { status: 'queued' } + private async fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = 30000) { + const controller = new AbortController() + const id = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + }) + clearTimeout(id) + return response + } + catch (error) { + clearTimeout(id) + throw error + } } - private async uploadImage(base64Data: string): Promise { - // 1. Clean data URL prefix if present - const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '') - const buffer = Buffer.from(base64, 'base64') + private async pollForResult( + jobId: string, + template: { exposedFields: Record, workflow: Record }, + request: ArtistryRequest, + ) { + this.updateStatus(jobId, { actionLabel: 'Preparing workflow...', status: 'running' }) - // 2. Prepare multipart form data - const formData = new FormData() - const fileName = `vhack_${Date.now()}.png` + try { + // 0. Handle potential image and prompt upload bidirectional flow + const extraStr = JSON.stringify(request.extra || {}) + const workflowStr = JSON.stringify(template.workflow || {}) + const hasImagePlaceholder = extraStr.includes('{{IMAGE}}') || workflowStr.includes('{{IMAGE}}') + const hasPromptPlaceholder = extraStr.includes('{{PROMPT}}') || workflowStr.includes('{{PROMPT}}') - // Electron/Node 18+ fetch handles Blobs in FormData - const blob = new Blob([buffer], { type: 'image/png' }) - formData.append('image', blob, fileName) - formData.append('overwrite', 'true') + let uploadedImageName = '' + if (hasImagePlaceholder && request.extra?.image) { + log.log(`[ComfyUI] Bidirectional flow detected. Uploading texture for job ${jobId}...`) + this.updateStatus(jobId, { actionLabel: 'Uploading texture to ComfyUI...', status: 'running' }) + try { + uploadedImageName = await this.uploadImage(request.extra.image) + log.log(`[ComfyUI] Texture uploaded as: ${uploadedImageName}`) + } + catch (e: any) { + log.error(`[ComfyUI] Texture upload failed: ${e.message}`) + } + } - const response = await this.fetchWithTimeout(`${this.serverUrl}/upload/image`, { - method: 'POST', - body: formData, - }, 60000) // 1 minute timeout for uploads + // 1. Apply overrides to the workflow template (standard injection) + let resolvedPrompt = this.applyOverrides(template, request) - if (!response.ok) { - const error = await response.text() - throw new Error(`ComfyUI upload failed: ${error}`) + // 2. Perform final placeholder resolution across the ENTIRE resolved prompt + if (hasImagePlaceholder || hasPromptPlaceholder) { + log.log(`[ComfyUI] Performing final placeholder resolution for ${jobId}...`) + const replacements: Record = { + '{{PROMPT}}': request.prompt || '', + } + if (uploadedImageName) { + replacements['{{IMAGE}}'] = uploadedImageName + } + + resolvedPrompt = this.replacePlaceholders(resolvedPrompt, replacements) + } + + log.log(`[ComfyUI] Resolved prompt for ${jobId}:`, JSON.stringify(resolvedPrompt, null, 2)) + + // 2. POST /prompt to queue the workflow + this.updateStatus(jobId, { actionLabel: 'Queuing in ComfyUI...', status: 'running' }) + + let queueResp: Response + try { + queueResp = await this.fetchWithTimeout(`${this.serverUrl}/prompt`, { + body: JSON.stringify({ prompt: resolvedPrompt }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }, 15000) + } + catch (e: any) { + throw new Error(`Cannot connect to ComfyUI at ${this.serverUrl}: ${e.message}`) + } + + if (!queueResp.ok) { + const errorBody = await queueResp.text() + throw new Error(`Workflow error: ${errorBody.slice(0, 200)}`) + } + + const queueData = await queueResp.json() + const promptId = queueData.prompt_id + if (!promptId) { + throw new Error('ComfyUI returned no prompt_id') + } + + log.log(`[ComfyUI] Queued prompt ${promptId} for job ${jobId}`) + this.updateStatus(jobId, { actionLabel: 'Generating...', status: 'running' }) + + // 3. Poll /history/{prompt_id} until completion + let historyDone = false + let attempt = 0 + const startTime = Date.now() + + while (!historyDone) { + await new Promise(r => setTimeout(r, POLL_INTERVAL_MS)) + attempt++ + + if (Date.now() - startTime > POLL_TIMEOUT_MS) { + throw new Error('Generation timed out after 5 minutes') + } + + if (attempt % 3 === 0) { + log.log(`[ComfyUI] Polling history for ${promptId}... attempt ${attempt}`) + } + + let histResp: Response + try { + histResp = await this.fetchWithTimeout(`${this.serverUrl}/history/${promptId}`, {}, 10000) + } + catch (e: any) { + throw new Error(`ComfyUI disconnected during polling: ${e.message}`) + } + + if (histResp.ok) { + const histData = await histResp.json() + if (histData[promptId]) { + let outputs = histData[promptId].outputs + const stats = histData[promptId].status + + // 3.1. Race condition protection: If outputs are missing, wait a beat and retry once + if ((!outputs || Object.keys(outputs).length === 0) && !historyDone) { + log.warn(`[ComfyUI] Job ${jobId} finished but outputs are empty. Retrying history in 1s...`) + await new Promise(r => setTimeout(r, 1000)) + const retryResp = await this.fetchWithTimeout(`${this.serverUrl}/history/${promptId}`, {}, 10000) + if (retryResp.ok) { + const retryData = await retryResp.json() + if (retryData[promptId] && retryData[promptId].outputs) { + log.log(`[ComfyUI] Retry successful for ${jobId}. Managed to find outputs!`) + outputs = retryData[promptId].outputs + } + } + } + + // Log raw history if no images found or if there are status messages + if (stats?.messages && stats.messages.length > 0) { + log.warn(`[ComfyUI] History messages for ${promptId}:`, stats.messages) + } + + // Find first image in any node's output + for (const nodeId in outputs) { + const nodeOutput = outputs[nodeId] + if (nodeOutput.images && nodeOutput.images.length > 0) { + const img = nodeOutput.images[0] + const imageUrl = `${this.serverUrl}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}` + log.log(`[ComfyUI] Generation complete for job ${jobId}. Image: ${imageUrl}`) + this.updateStatus(jobId, { imageUrl, progress: 100, status: 'succeeded' }) + historyDone = true + break + } + } + + // Job finished but no images + if (!historyDone) { + log.error(`[ComfyUI] Job finished for ${jobId} (Prompt ${promptId}) but no output images found. Raw History:`, JSON.stringify(histData[promptId], null, 2)) + this.updateStatus(jobId, { + actionLabel: 'Error: No images generated', + error: 'Job completed but no images were generated', + status: 'failed', + }) + historyDone = true + } + } + } + } + } + catch (error: any) { + const errorMessage = error.message || String(error) + log.error(`[ComfyUI] Generation failed for job ${jobId}: ${errorMessage}`) + this.updateStatus(jobId, { + actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`, + error: errorMessage, + status: 'failed', + }) + } + finally { + // Clean up callback and job result after completion to prevent memory leaks + setTimeout(() => { + this.callbacks.delete(jobId) + this.jobResults.delete(jobId) + }, 10000) } - - const data = await response.json() - return data.name // Returns the filename in ComfyUI's input folder } private replacePlaceholders(obj: any, replacements: Record): any { @@ -391,4 +356,39 @@ export class ComfyUIProvider implements ArtistryProvider { } return obj } + + private updateStatus(jobId: string, status: ArtistryJobStatus) { + this.jobResults.set(jobId, status) + const callback = this.callbacks.get(jobId) + if (callback) + callback(status) + } + + private async uploadImage(base64Data: string): Promise { + // 1. Clean data URL prefix if present + const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '') + const buffer = Buffer.from(base64, 'base64') + + // 2. Prepare multipart form data + const formData = new FormData() + const fileName = `vhack_${Date.now()}.png` + + // Electron/Node 18+ fetch handles Blobs in FormData + const blob = new Blob([buffer], { type: 'image/png' }) + formData.append('image', blob, fileName) + formData.append('overwrite', 'true') + + const response = await this.fetchWithTimeout(`${this.serverUrl}/upload/image`, { + body: formData, + method: 'POST', + }, 60000) // 1 minute timeout for uploads + + if (!response.ok) { + const error = await response.text() + throw new Error(`ComfyUI upload failed: ${error}`) + } + + const data = await response.json() + return data.name // Returns the filename in ComfyUI's input folder + } } diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/nanobanana.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/nanobanana.ts index a93ea8243..120770baa 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/nanobanana.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/nanobanana.ts @@ -8,34 +8,11 @@ export class NanoBananaProvider implements ArtistryProvider { readonly id = 'nanobanana' readonly name = 'Nano Banana (Google AI Studio)' private apiKey = '' - private defaultModel = 'gemini-1.5-flash' - private defaultResolution = '1K' - - private jobResults = new Map() private callbacks = new Map void>() + private defaultModel = 'gemini-1.5-flash' - setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) { - this.callbacks.set(jobId, callback) - const result = this.jobResults.get(jobId) - if (result) - callback(result) - } - - private updateStatus(jobId: string, status: ArtistryJobStatus) { - this.jobResults.set(jobId, status) - const callback = this.callbacks.get(jobId) - if (callback) - callback(status) - } - - async initialize(config: any) { - this.apiKey = config.nanobananaApiKey || config.apiKey || '' - if (config.nanobananaModel) - this.defaultModel = config.nanobananaModel - if (config.nanobananaResolution) - this.defaultResolution = config.nanobananaResolution - log.log(`[Nano Banana] Initialized. API Key present: ${!!this.apiKey}`) - } + private defaultResolution = '1K' + private jobResults = new Map() async generate(request: ArtistryRequest): Promise { if (!this.apiKey) { @@ -59,23 +36,43 @@ export class NanoBananaProvider implements ArtistryProvider { } } + async getStatus(jobId: string): Promise { + return this.jobResults.get(jobId) || { status: 'queued' } + } + + async initialize(config: any) { + this.apiKey = config.nanobananaApiKey || config.apiKey || '' + if (config.nanobananaModel) + this.defaultModel = config.nanobananaModel + if (config.nanobananaResolution) + this.defaultResolution = config.nanobananaResolution + log.log(`[Nano Banana] Initialized. API Key present: ${!!this.apiKey}`) + } + + setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) { + this.callbacks.set(jobId, callback) + const result = this.jobResults.get(jobId) + if (result) + callback(result) + } + private async runGeneration(jobId: string, model: string, resolution: string, prompt: string, base64Image: string) { - this.updateStatus(jobId, { status: 'running', actionLabel: 'Inscribing with Nano Banana...' }) + this.updateStatus(jobId, { actionLabel: 'Inscribing with Nano Banana...', status: 'running' }) try { const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${this.apiKey}` const generationParts: any[] = [{ text: prompt }] if (base64Image) { - generationParts.push({ inline_data: { mime_type: 'image/jpeg', data: base64Image } }) + generationParts.push({ inline_data: { data: base64Image, mime_type: 'image/jpeg' } }) } const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: generationParts }], generationConfig: { imageConfig: { aspectRatio: '1:1', imageSize: resolution } }, }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', }) const json = await response.json() @@ -90,7 +87,7 @@ export class NanoBananaProvider implements ArtistryProvider { if (inlineData?.data) { const dataUrl = `data:${inlineData.mimeType};base64,${inlineData.data}` - this.updateStatus(jobId, { status: 'succeeded', progress: 100, imageUrl: dataUrl }) + this.updateStatus(jobId, { imageUrl: dataUrl, progress: 100, status: 'succeeded' }) } else { throw new Error('No image data returned from Nano Banana') @@ -98,7 +95,7 @@ export class NanoBananaProvider implements ArtistryProvider { } catch (e: any) { log.error(`[Nano Banana] Generation failed: ${e.message}`) - this.updateStatus(jobId, { status: 'failed', error: e.message }) + this.updateStatus(jobId, { error: e.message, status: 'failed' }) } finally { // Clean up callback and job result after completion to prevent memory leaks @@ -109,7 +106,10 @@ export class NanoBananaProvider implements ArtistryProvider { } } - async getStatus(jobId: string): Promise { - return this.jobResults.get(jobId) || { status: 'queued' } + private updateStatus(jobId: string, status: ArtistryJobStatus) { + this.jobResults.set(jobId, status) + const callback = this.callbacks.get(jobId) + if (callback) + callback(status) } } diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/replicate.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/replicate.ts index f80c01428..c9b13a9d9 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/replicate.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/providers/replicate.ts @@ -11,44 +11,13 @@ export class ReplicateProvider implements ArtistryProvider { readonly name = 'Replicate.ai (Cloud)' private apiKey = '' - private defaultModel = 'black-forest-labs/flux-schnell' private aspectRatio = '16:9' + private callbacks = new Map void>() + private defaultModel = 'black-forest-labs/flux-schnell' private inferenceSteps = 4 - private replicate: Replicate | null = null private jobResults = new Map() - private callbacks = new Map void>() - - setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) { - this.callbacks.set(jobId, callback) - const result = this.jobResults.get(jobId) - if (result) - callback(result) - } - - private updateStatus(jobId: string, status: ArtistryJobStatus) { - this.jobResults.set(jobId, status) - const callback = this.callbacks.get(jobId) - if (callback) - callback(status) - } - - async initialize(config: any): Promise { - if (config?.replicateApiKey) { - this.apiKey = config.replicateApiKey - this.replicate = new Replicate({ auth: this.apiKey }) - } - else { - this.apiKey = '' - this.replicate = null - } - if (config?.replicateDefaultModel) - this.defaultModel = config.replicateDefaultModel - if (config?.replicateAspectRatio) - this.aspectRatio = config.replicateAspectRatio - if (config?.replicateInferenceSteps) - this.inferenceSteps = config.replicateInferenceSteps - } + private replicate: null | Replicate = null async generate(request: ArtistryRequest): Promise { if (!this.replicate) { @@ -61,11 +30,11 @@ export class ReplicateProvider implements ArtistryProvider { // 1. Start with defaults const hasPromptPlaceholder = JSON.stringify(request.extra).includes('{{PROMPT}}') let inputOptions: Record = { - go_fast: request.extra?.go_fast ?? true, aspect_ratio: request.extra?.aspect_ratio ?? this.aspectRatio, + go_fast: request.extra?.go_fast ?? true, + num_inference_steps: request.extra?.num_inference_steps ?? this.inferenceSteps, output_format: request.extra?.output_format ?? 'png', output_quality: request.extra?.output_quality ?? 80, - num_inference_steps: request.extra?.num_inference_steps ?? this.inferenceSteps, } // Default prompt injection if NO placeholder is used in overrides @@ -127,8 +96,36 @@ export class ReplicateProvider implements ArtistryProvider { return { jobId, providerJobId: jobId } } + async getStatus(jobId: string): Promise { + return this.jobResults.get(jobId) || { status: 'queued' } + } + + async initialize(config: any): Promise { + if (config?.replicateApiKey) { + this.apiKey = config.replicateApiKey + this.replicate = new Replicate({ auth: this.apiKey }) + } + else { + this.apiKey = '' + this.replicate = null + } + if (config?.replicateDefaultModel) + this.defaultModel = config.replicateDefaultModel + if (config?.replicateAspectRatio) + this.aspectRatio = config.replicateAspectRatio + if (config?.replicateInferenceSteps) + this.inferenceSteps = config.replicateInferenceSteps + } + + setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) { + this.callbacks.set(jobId, callback) + const result = this.jobResults.get(jobId) + if (result) + callback(result) + } + private async runGeneration(jobId: string, model: `${string}/${string}`, input: object) { - this.updateStatus(jobId, { status: 'running', actionLabel: 'Requesting cloud generation...' }) + this.updateStatus(jobId, { actionLabel: 'Requesting cloud generation...', status: 'running' }) try { const output = await this.replicate!.run(model, { input }) @@ -160,7 +157,7 @@ export class ReplicateProvider implements ArtistryProvider { if (imageUrl && (imageUrl.startsWith('http') || imageUrl.startsWith('data:'))) { log.log(`[Replicate] EXTRACTED IMAGE: ${imageUrl.startsWith('data:') ? 'DATA_URL' : imageUrl}`) - this.updateStatus(jobId, { status: 'succeeded', progress: 100, imageUrl }) + this.updateStatus(jobId, { imageUrl, progress: 100, status: 'succeeded' }) } else { log.error(`[Replicate] Failed to extract URL from output: ${JSON.stringify(first)}`) @@ -175,9 +172,9 @@ export class ReplicateProvider implements ArtistryProvider { const errorMessage = error.message || (typeof error === 'object' ? JSON.stringify(error) : String(error)) log.error(`[Replicate] Generation Failed for ${jobId}: ${errorMessage}`) this.updateStatus(jobId, { - status: 'failed', - error: errorMessage, actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`, + error: errorMessage, + status: 'failed', }) } finally { @@ -189,14 +186,17 @@ export class ReplicateProvider implements ArtistryProvider { } } - async getStatus(jobId: string): Promise { - return this.jobResults.get(jobId) || { status: 'queued' } - } - private truncatePrompt(prompt: string, maxChars: number = 380): string { if (prompt.length <= maxChars) return prompt log.log(`[Replicate] Truncating prompt from ${prompt.length} to ${maxChars} chars.`) return `${prompt.slice(0, maxChars)}...` } + + private updateStatus(jobId: string, status: ArtistryJobStatus) { + this.jobResults.set(jobId, status) + const callback = this.callbacks.get(jobId) + if (callback) + callback(status) + } } diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts index d9bbb2a8b..c42de1a12 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts @@ -12,26 +12,26 @@ describe('widget invoke validation', () => { describe('validateWidgetsAddPayload', () => { it('normalizes add payloads for the widgets manager', () => { expect(validateWidgetsAddPayload({ - id: ' widget-1 ', + alwaysOnTop: true, componentName: ' weather ', componentProps: { city: 'Tokyo' }, - alwaysOnTop: true, + id: ' widget-1 ', ttlMs: 2500.9, windowSize: { - width: 620.8, height: 480.2, minWidth: 320.9, + width: 620.8, }, })).toEqual({ - id: 'widget-1', + alwaysOnTop: true, componentName: 'weather', componentProps: { city: 'Tokyo' }, - alwaysOnTop: true, + id: 'widget-1', ttlMs: 2500, windowSize: { - width: 620, height: 480, minWidth: 320, + width: 620, }, }) }) @@ -53,12 +53,12 @@ describe('widget invoke validation', () => { expect(() => validateWidgetsAddPayload({ componentName: 'weather', - windowSize: { width: 0, height: 320 }, + windowSize: { height: 320, width: 0 }, } as any)).toThrow('windowSize must contain a positive finite width and height.') expect(() => validateWidgetsAddPayload({ - componentName: 'weather', alwaysOnTop: 'yes' as any, + componentName: 'weather', })).toThrow('alwaysOnTop must be a boolean when provided.') }) }) @@ -66,14 +66,14 @@ describe('widget invoke validation', () => { describe('validateWidgetsUpdatePayload', () => { it('normalizes widget updates and keeps optional fields optional', () => { expect(validateWidgetsUpdatePayload({ - id: ' widget-1 ', - componentProps: { city: 'Taipei' }, alwaysOnTop: false, + componentProps: { city: 'Taipei' }, + id: ' widget-1 ', ttlMs: 1500.4, })).toEqual({ - id: 'widget-1', - componentProps: { city: 'Taipei' }, alwaysOnTop: false, + componentProps: { city: 'Taipei' }, + id: 'widget-1', ttlMs: 1500, windowSize: undefined, }) @@ -85,18 +85,18 @@ describe('widget invoke validation', () => { } as any)).toThrow('id is required to update a widget.') expect(() => validateWidgetsUpdatePayload({ - id: 'widget-1', componentProps: [] as any, + id: 'widget-1', })).toThrow('componentProps must be a plain object.') expect(() => validateWidgetsUpdatePayload({ id: 'widget-1', - windowSize: { width: Number.NaN, height: 400 }, + windowSize: { height: 400, width: Number.NaN }, } as any)).toThrow('windowSize must contain a positive finite width and height.') expect(() => validateWidgetsUpdatePayload({ - id: 'widget-1', alwaysOnTop: 'yes' as any, + id: 'widget-1', })).toThrow('alwaysOnTop must be a boolean when provided.') }) }) @@ -117,28 +117,28 @@ describe('widget invoke validation', () => { it('normalizes successful iframe request results', () => { expect(validateWidgetIframeRequestResult({ id: ' kit-module:board ', - requestId: ' req-1 ', ok: true, + requestId: ' req-1 ', result: { fen: 'fen-after-request' }, })).toEqual({ id: 'kit-module:board', - requestId: 'req-1', ok: true, + requestId: 'req-1', result: { fen: 'fen-after-request' }, }) }) it('normalizes failed iframe request results', () => { expect(validateWidgetIframeRequestResult({ - id: 'kit-module:board', - requestId: 'req-1', - ok: false, error: 'Board rejected request.', + id: 'kit-module:board', + ok: false, + requestId: 'req-1', })).toEqual({ - id: 'kit-module:board', - requestId: 'req-1', - ok: false, error: 'Board rejected request.', + id: 'kit-module:board', + ok: false, + requestId: 'req-1', }) }) @@ -146,13 +146,13 @@ describe('widget invoke validation', () => { expect(() => validateWidgetIframeRequestResult(null)).toThrow('iframe request result must be a plain object.') expect(() => validateWidgetIframeRequestResult({ id: 'kit-module:board', - requestId: 'req-1', ok: true, + requestId: 'req-1', })).toThrow('iframe request result payload must be a plain object.') expect(() => validateWidgetIframeRequestResult({ id: 'kit-module:board', - requestId: 'req-1', ok: false, + requestId: 'req-1', })).toThrow('iframe request result error is required.') }) }) diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts index 7d8343d46..0d16d3ff6 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts @@ -8,126 +8,19 @@ import { isPlainObject } from 'es-toolkit' import { normalizeWidgetWindowSize } from '../../../../shared/utils/electron/windows/window-size' -function normalizeWidgetId(value?: string): string | undefined { - if (!value) - return undefined - - const normalized = value.trim() - return normalized || undefined -} - -function normalizeTtlMs(ttlMs?: number): number { - if (ttlMs === undefined) - return 0 - - if (!Number.isFinite(ttlMs) || ttlMs < 0) - throw new Error('ttlMs must be a non-negative finite number.') - - return Math.floor(ttlMs) -} - -function normalizeComponentProps(componentProps?: Record): Record { - if (componentProps === undefined) - return {} - - if (!isPlainObject(componentProps)) - throw new Error('componentProps must be a plain object.') - - return componentProps -} - -function normalizeOptionalBoolean(value: boolean | undefined, fieldName: string): boolean | undefined { - if (value === undefined) - return undefined - - if (typeof value !== 'boolean') - throw new Error(`${fieldName} must be a boolean when provided.`) - - return value -} - /** - * Validates and normalizes widget spawn payloads at the Electron invoke boundary. + * Normalizes optional widget ids for open/prepare operations. * - * Use when: - * - `defineInvokeHandler(...)` receives a widgets add request from a renderer + * Before: + * - `" widget-1 "` + * - `""` * - * Expects: - * - `componentName` is a non-empty string - * - `componentProps`, when provided, is a plain object - * - `alwaysOnTop`, when provided, is a boolean - * - `ttlMs`, when provided, is a non-negative finite number - * - * Returns: - * - A normalized payload safe to pass into the widgets manager + * After: + * - `"widget-1"` + * - `undefined` */ -export function validateWidgetsAddPayload(payload?: WidgetsAddPayload): WidgetsAddPayload { - if (!payload) - throw new Error('widgets.add requires a payload.') - - const componentName = payload.componentName?.trim() - if (!componentName) - throw new Error('componentName is required to spawn a widget.') - - const normalizedWindowSize = payload.windowSize === undefined - ? undefined - : normalizeWidgetWindowSize(payload.windowSize) - - if (payload.windowSize !== undefined && !normalizedWindowSize) - throw new Error('windowSize must contain a positive finite width and height.') - - return { - ...payload, - id: normalizeWidgetId(payload.id), - componentName, - componentProps: normalizeComponentProps(payload.componentProps), - alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'), - ttlMs: normalizeTtlMs(payload.ttlMs), - windowSize: normalizedWindowSize, - } -} - -/** - * Validates and normalizes widget update payloads at the Electron invoke boundary. - * - * Use when: - * - `defineInvokeHandler(...)` receives a widgets update request from a renderer - * - * Expects: - * - `id` is a non-empty string after trimming - * - `componentProps`, when provided, is a plain object - * - `alwaysOnTop`, when provided, is a boolean - * - * Returns: - * - A normalized payload safe to pass into the widgets manager - */ -export function validateWidgetsUpdatePayload(payload?: WidgetsUpdatePayload): WidgetsUpdatePayload { - if (!payload) - throw new Error('widgets.update requires a payload.') - - const id = normalizeWidgetId(payload.id) - if (!id) - throw new Error('id is required to update a widget.') - - const normalizedWindowSize = payload.windowSize === undefined - ? undefined - : normalizeWidgetWindowSize(payload.windowSize) - - if (payload.windowSize !== undefined && !normalizedWindowSize) - throw new Error('windowSize must contain a positive finite width and height.') - - return { - ...payload, - id, - componentProps: payload.componentProps === undefined - ? undefined - : normalizeComponentProps(payload.componentProps), - alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'), - ttlMs: payload.ttlMs === undefined - ? undefined - : normalizeTtlMs(payload.ttlMs), - windowSize: normalizedWindowSize, - } +export function normalizeOptionalWidgetId(id?: string): string | undefined { + return normalizeWidgetId(id) } /** @@ -150,21 +43,6 @@ export function normalizeRequiredWidgetId(id?: string, reason = 'id is required. return normalized } -/** - * Normalizes optional widget ids for open/prepare operations. - * - * Before: - * - `" widget-1 "` - * - `""` - * - * After: - * - `"widget-1"` - * - `undefined` - */ -export function normalizeOptionalWidgetId(id?: string): string | undefined { - return normalizeWidgetId(id) -} - /** * Validates iframe-published widget events at the Electron invoke boundary. * @@ -221,8 +99,8 @@ export function validateWidgetIframeRequestResult(result: unknown): WidgetsIfram return { id, - requestId, ok: true, + requestId, result: result.result, } } @@ -233,12 +111,134 @@ export function validateWidgetIframeRequestResult(result: unknown): WidgetsIfram } return { - id, - requestId, - ok: false, error: result.error, + id, + ok: false, + requestId, } } throw new Error('iframe request result ok must be a boolean.') } + +/** + * Validates and normalizes widget spawn payloads at the Electron invoke boundary. + * + * Use when: + * - `defineInvokeHandler(...)` receives a widgets add request from a renderer + * + * Expects: + * - `componentName` is a non-empty string + * - `componentProps`, when provided, is a plain object + * - `alwaysOnTop`, when provided, is a boolean + * - `ttlMs`, when provided, is a non-negative finite number + * + * Returns: + * - A normalized payload safe to pass into the widgets manager + */ +export function validateWidgetsAddPayload(payload?: WidgetsAddPayload): WidgetsAddPayload { + if (!payload) + throw new Error('widgets.add requires a payload.') + + const componentName = payload.componentName?.trim() + if (!componentName) + throw new Error('componentName is required to spawn a widget.') + + const normalizedWindowSize = payload.windowSize === undefined + ? undefined + : normalizeWidgetWindowSize(payload.windowSize) + + if (payload.windowSize !== undefined && !normalizedWindowSize) + throw new Error('windowSize must contain a positive finite width and height.') + + return { + ...payload, + alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'), + componentName, + componentProps: normalizeComponentProps(payload.componentProps), + id: normalizeWidgetId(payload.id), + ttlMs: normalizeTtlMs(payload.ttlMs), + windowSize: normalizedWindowSize, + } +} + +/** + * Validates and normalizes widget update payloads at the Electron invoke boundary. + * + * Use when: + * - `defineInvokeHandler(...)` receives a widgets update request from a renderer + * + * Expects: + * - `id` is a non-empty string after trimming + * - `componentProps`, when provided, is a plain object + * - `alwaysOnTop`, when provided, is a boolean + * + * Returns: + * - A normalized payload safe to pass into the widgets manager + */ +export function validateWidgetsUpdatePayload(payload?: WidgetsUpdatePayload): WidgetsUpdatePayload { + if (!payload) + throw new Error('widgets.update requires a payload.') + + const id = normalizeWidgetId(payload.id) + if (!id) + throw new Error('id is required to update a widget.') + + const normalizedWindowSize = payload.windowSize === undefined + ? undefined + : normalizeWidgetWindowSize(payload.windowSize) + + if (payload.windowSize !== undefined && !normalizedWindowSize) + throw new Error('windowSize must contain a positive finite width and height.') + + return { + ...payload, + alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'), + componentProps: payload.componentProps === undefined + ? undefined + : normalizeComponentProps(payload.componentProps), + id, + ttlMs: payload.ttlMs === undefined + ? undefined + : normalizeTtlMs(payload.ttlMs), + windowSize: normalizedWindowSize, + } +} + +function normalizeComponentProps(componentProps?: Record): Record { + if (componentProps === undefined) + return {} + + if (!isPlainObject(componentProps)) + throw new Error('componentProps must be a plain object.') + + return componentProps +} + +function normalizeOptionalBoolean(value: boolean | undefined, fieldName: string): boolean | undefined { + if (value === undefined) + return undefined + + if (typeof value !== 'boolean') + throw new Error(`${fieldName} must be a boolean when provided.`) + + return value +} + +function normalizeTtlMs(ttlMs?: number): number { + if (ttlMs === undefined) + return 0 + + if (!Number.isFinite(ttlMs) || ttlMs < 0) + throw new Error('ttlMs must be a non-negative finite number.') + + return Math.floor(ttlMs) +} + +function normalizeWidgetId(value?: string): string | undefined { + if (!value) + return undefined + + const normalized = value.trim() + return normalized || undefined +} diff --git a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts index 03e14815f..44376f82d 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const appMock = vi.hoisted(() => ({ - getVersion: vi.fn(() => '0.9.0-beta.4'), getPath: vi.fn((name: string) => name === 'logs' ? '/tmp/airi/logs' : `/tmp/${name}`), - quit: vi.fn(), + getVersion: vi.fn(() => '0.9.0-beta.4'), isPackaged: false, + quit: vi.fn(), })) const isDevState = vi.hoisted(() => ({ @@ -21,16 +21,16 @@ const updaterState = vi.hoisted(() => ({ function createUpdaterMock() { return { - on: vi.fn(), - autoDownload: true, allowPrerelease: false, + autoDownload: true, channel: undefined as string | undefined, - logger: undefined as any, - forceDevUpdateConfig: false, - setFeedURL: vi.fn(), checkForUpdates: vi.fn().mockResolvedValue(undefined), downloadUpdate: vi.fn().mockResolvedValue(undefined), + forceDevUpdateConfig: false, + logger: undefined as any, + on: vi.fn(), quitAndInstall: vi.fn(), + setFeedURL: vi.fn(), } } @@ -55,10 +55,10 @@ vi.mock('std-env', () => ({ vi.mock('@guiiai/logg', () => ({ useLogg: () => ({ useGlobalConfig: () => ({ + debug: vi.fn(), + error: vi.fn(), info: vi.fn(), warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), withError: () => ({ error: vi.fn(), }), @@ -82,35 +82,35 @@ describe('setupAutoUpdater', () => { const expectedChannelByArch = process.arch === 'arm64' ? 'latest-arm64' : 'latest-x64' const laneReleaseTagMap = { - latest: 'v0.9.12-nightly.7', - stable: 'v0.9.9', - beta: 'v0.9.10-beta.3', alpha: 'v0.9.11-alpha.4', + beta: 'v0.9.10-beta.3', + latest: 'v0.9.12-nightly.7', nightly: 'v0.9.12-nightly.7', + stable: 'v0.9.9', } as const const bundleVersions = ['0.9.0', '0.9.0-beta.4', '0.9.0-alpha.2'] as const const laneMatrix = ['latest', 'stable', 'beta', 'alpha', 'nightly'] as const const defaultReleases = [ - { tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true }, + { draft: false, prerelease: true, tag_name: 'v0.9.0-beta.6' }, ] const matrixReleases = [ - { tag_name: 'v0.9.7', draft: false, prerelease: false }, - { tag_name: 'v0.9.9', draft: false, prerelease: false }, - { tag_name: 'v0.9.9-beta.1', draft: false, prerelease: true }, - { tag_name: 'v0.9.10-beta.3', draft: false, prerelease: true }, - { tag_name: 'v0.9.10-alpha.5', draft: false, prerelease: true }, - { tag_name: 'v0.9.11-alpha.4', draft: false, prerelease: true }, - { tag_name: 'v0.9.11-nightly.1', draft: false, prerelease: true }, - { tag_name: 'v0.9.12-nightly.7', draft: false, prerelease: true }, + { draft: false, prerelease: false, tag_name: 'v0.9.7' }, + { draft: false, prerelease: false, tag_name: 'v0.9.9' }, + { draft: false, prerelease: true, tag_name: 'v0.9.9-beta.1' }, + { draft: false, prerelease: true, tag_name: 'v0.9.10-beta.3' }, + { draft: false, prerelease: true, tag_name: 'v0.9.10-alpha.5' }, + { draft: false, prerelease: true, tag_name: 'v0.9.11-alpha.4' }, + { draft: false, prerelease: true, tag_name: 'v0.9.11-nightly.1' }, + { draft: false, prerelease: true, tag_name: 'v0.9.12-nightly.7' }, ] function mockGitHubReleasesFetch(releases = defaultReleases) { const fetchSpy = vi.fn().mockResolvedValue({ + json: async () => releases, ok: true, status: 200, statusText: 'OK', - json: async () => releases, }) vi.stubGlobal('fetch', fetchSpy) return fetchSpy @@ -131,8 +131,8 @@ describe('setupAutoUpdater', () => { it('resolves release tag from GitHub API and configures generic provider for checks', async () => { const fetchSpy = mockGitHubReleasesFetch([ - { tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true }, - { tag_name: 'v0.9.0-beta.5', draft: false, prerelease: true }, + { draft: false, prerelease: true, tag_name: 'v0.9.0-beta.6' }, + { draft: false, prerelease: true, tag_name: 'v0.9.0-beta.5' }, ]) const { setupAutoUpdater } = await import('./auto-updater') const service = setupAutoUpdater() @@ -198,9 +198,9 @@ describe('setupAutoUpdater', () => { it('supports explicit stable lane selection for future dynamic channel switching', async () => { process.env.AIRI_UPDATE_CHANNEL = 'stable' mockGitHubReleasesFetch([ - { tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true }, - { tag_name: 'v0.8.9', draft: false, prerelease: false }, - { tag_name: 'v0.8.8', draft: false, prerelease: false }, + { draft: false, prerelease: true, tag_name: 'v0.9.0-beta.6' }, + { draft: false, prerelease: false, tag_name: 'v0.8.9' }, + { draft: false, prerelease: false, tag_name: 'v0.8.8' }, ]) const { setupAutoUpdater } = await import('./auto-updater') @@ -274,12 +274,12 @@ describe('setupAutoUpdater', () => { const service = setupAutoUpdater() expect(service.state.diagnostics).toEqual(expect.objectContaining({ - platform: process.platform, arch: process.arch, channel: expectedChannelByArch, executablePath: expect.any(String), - logFilePath: expect.stringMatching(/stage-tamagotchi-updater[\\/]updater-log\.txt$/), isOverrideActive: false, + logFilePath: expect.stringMatching(/stage-tamagotchi-updater[\\/]updater-log\.txt$/), + platform: process.platform, })) expect(service.state.diagnostics).not.toHaveProperty('updaterCacheDir') expect(service.state.diagnostics).not.toHaveProperty('pendingDir') diff --git a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts index 513a6f298..91ab0cd23 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts @@ -49,10 +49,10 @@ function getCacheRoot() { function getLegacyCacheRoot() { switch (process.platform) { - case 'win32': - return process.env.LOCALAPPDATA || join(process.env.USERPROFILE || '', 'AppData', 'Local') case 'darwin': return join(process.env.HOME || '', 'Library', 'Caches') + case 'win32': + return process.env.LOCALAPPDATA || join(process.env.USERPROFILE || '', 'AppData', 'Local') default: return process.env.XDG_CACHE_HOME || join(process.env.HOME || '', '.cache') } @@ -63,199 +63,30 @@ const UPDATER_LOG_FILE = join(UPDATER_DEBUG_CACHE_DIR, 'updater-log.txt') const OFFICIAL_UPDATER_CACHE_DIR = join(getCacheRoot(), 'ai.moeru.airi-updater') const LEGACY_OFFICIAL_UPDATER_CACHE_DIR = join(getLegacyCacheRoot(), 'ai.moeru.airi-updater') const OFFICIAL_UPDATER_CACHE_DIRS = Array.from(new Set([ - OFFICIAL_UPDATER_CACHE_DIR, LEGACY_OFFICIAL_UPDATER_CACHE_DIR, + OFFICIAL_UPDATER_CACHE_DIR, ])) -async function logToFile(level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG', message: string) { - await mkdir(UPDATER_DEBUG_CACHE_DIR, { recursive: true }).catch(() => {}) - await appendFile(UPDATER_LOG_FILE, `${new Date().toISOString()} [${level}] ${message}\n`).catch(() => {}) -} - -async function cleanupStaleUpdateFiles() { - // Remove both current and legacy updater cache roots so stale installers do not linger. - await Promise.allSettled(OFFICIAL_UPDATER_CACHE_DIRS.map(cacheDir => rm(cacheDir, { recursive: true, force: true }))) - await logToFile('INFO', `Updater cache cleanup attempted: ${OFFICIAL_UPDATER_CACHE_DIRS.join(', ')}`) -} - -export type UpdateLane = ElectronUpdaterChannel -interface GitHubReleaseRecord { - tag_name?: string - draft?: boolean - prerelease?: boolean -} - -function getUpdateServerOverride() { - // NOTICE: UPDATE_SERVER_URL is intentionally development-only for local update-test harness. - // Production update routing must not depend on this variable. - if (!is.dev) - return undefined - - const value = process.env.UPDATE_SERVER_URL?.trim() - return value || undefined -} - -function normalizeLane(value: string | undefined): UpdateLane | undefined { - if (!value) - return undefined - - switch (value.toLowerCase()) { - case 'stable': - case 'latest': - case 'alpha': - case 'beta': - case 'nightly': - case 'canary': - return value.toLowerCase() as UpdateLane - default: - return undefined - } -} - -function laneFromVersion(version: string): UpdateLane { - const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase() - return normalizeLane(prerelease) ?? 'stable' -} - -function getPreferredUpdateLane(params: { version: string, storedLane?: UpdateLane }): UpdateLane { - return normalizeLane(process.env[UPDATE_CHANNEL_ENV_KEY]?.trim()) ?? params.storedLane ?? laneFromVersion(params.version) -} - -function getSemverFromTag(tag: string) { - return semver.valid(tag) ?? semver.valid(tag.startsWith('v') ? tag.slice(1) : tag) -} - -function isTagInLane(tag: string, lane: UpdateLane) { - const version = getSemverFromTag(tag) - if (!version) - return false - - if (lane === 'latest') - return true - - const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase() - if (lane === 'stable') - return !prerelease - - return prerelease === lane -} - -function isPathInside(parentPath: string, targetPath: string) { - const normalizedParent = normalize(parentPath) - const normalizedTarget = normalize(targetPath) - const parentWithSeparator = normalizedParent.endsWith('\\') ? normalizedParent : `${normalizedParent}\\` - return normalizedTarget === normalizedParent || normalizedTarget.startsWith(parentWithSeparator) -} - -function getWindowsProtectedInstallRoots() { - return [ - process.env.ProgramFiles, - process.env['ProgramFiles(x86)'], - process.env.ProgramW6432, - process.env.SystemRoot, - process.env.windir, - ] - .filter((value): value is string => Boolean(value)) - .map(value => normalize(value)) -} - -function requiresAdminForInstallPath(executablePath: string) { - if (!isWindows) - return false - - const installDirectory = dirname(executablePath) - return getWindowsProtectedInstallRoots().some(root => isPathInside(root, installDirectory)) -} - -function selectLatestTagForLane(releases: GitHubReleaseRecord[], lane: UpdateLane) { - const candidates = releases - .filter(release => !release.draft && typeof release.tag_name === 'string' && isTagInLane(release.tag_name, lane)) - .map((release) => { - const tag = release.tag_name as string - const version = getSemverFromTag(tag) - return version ? { tag, version } : null - }) - .filter(Boolean) as Array<{ tag: string, version: string }> - - candidates.sort((a, b) => semver.rcompare(a.version, b.version)) - return candidates[0]?.tag -} - -/** - * Extract release tags from GitHub releases Atom feed without adding XML-parser dependencies. - * - * The current feed contains entries like: - * `` - * and - * `tag:github.com,2008:Repository/963495975/v0.9.0-alpha.36` - * - * We intentionally scan for `/moeru-ai/airi/releases/tag/` so we only consume actual release tag links. - */ -function extractReleaseTagsFromAtom(atom: string) { - const tags: string[] = [] - const marker = '/moeru-ai/airi/releases/tag/' - let offset = 0 - - while (offset < atom.length) { - const markerIndex = atom.indexOf(marker, offset) - if (markerIndex === -1) - break - - const start = markerIndex + marker.length - let end = start - while (end < atom.length) { - const char = atom[end] - if (char === '"' || char === '<' || char === '?' || char === '&') - break - end += 1 - } - - // Slice the raw path segment after the marker, e.g. `v0.9.0-beta.6`. - const rawTag = atom.slice(start, end).trim() - // Atom encodes URLs, so decode in case future tags contain escaped characters. - const decodedTag = decodeURIComponent(rawTag) - // Feed entries can repeat across updates; keep a unique ordered tag list. - if (decodedTag && !tags.includes(decodedTag)) - tags.push(decodedTag) - - offset = end + 1 - } - - return tags -} - export interface AppUpdaterLike { - on: (event: string, listener: (...args: any[]) => void) => any - checkForUpdates: () => Promise - downloadUpdate: () => Promise - quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => Promise | void - setFeedURL?: (options: { provider: 'generic', url: string }) => void - logger?: any allowPrerelease?: boolean autoDownload?: boolean channel?: string + checkForUpdates: () => Promise + downloadUpdate: () => Promise forceDevUpdateConfig?: boolean + logger?: any + on: (event: string, listener: (...args: any[]) => void) => any + quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => Promise | void + setFeedURL?: (options: { provider: 'generic', url: string }) => void } -// NOTICE: this part of code is copied from https://www.electron.build/auto-update -// Or https://github.com/electron-userland/electron-builder/blob/b866e99ccd3ea9f85bc1e840f0f6a6a162fca388/pages/auto-update.md?plain=1#L57-L66 -export function fromImported(): AppUpdaterLike { - if (is.dev && !getUpdateServerOverride()) - return new MockAutoUpdater() - - const { autoUpdater } = electronUpdater - return autoUpdater as unknown as AppUpdaterLike -} - -type MainContext = ReturnType['context'] - export interface AutoUpdater { - state: AutoUpdaterState checkForUpdates: () => Promise downloadUpdate: () => Promise + getPreferredUpdateLane: () => undefined | UpdateLane quitAndInstall: () => Promise - getPreferredUpdateLane: () => UpdateLane | undefined - setPreferredUpdateLane: (lane: UpdateLane | undefined) => Promise + setPreferredUpdateLane: (lane: undefined | UpdateLane) => Promise + state: AutoUpdaterState subscribe: (callback: (state: AutoUpdaterState) => void) => () => void } @@ -267,42 +98,74 @@ export interface AutoUpdaterOptions { */ enabled?: boolean /** Reads the release channel persisted by the application configuration. */ - getStoredUpdateLane?: () => UpdateLane | undefined + getStoredUpdateLane?: () => undefined | UpdateLane /** Persists a release-channel change requested through updater IPC. */ - setStoredUpdateLane?: (lane: UpdateLane | undefined) => void + setStoredUpdateLane?: (lane: undefined | UpdateLane) => void +} +export type UpdateLane = ElectronUpdaterChannel + +interface GitHubReleaseRecord { + draft?: boolean + prerelease?: boolean + tag_name?: string } -function isPrereleaseVersion(version: string) { - return (semver.prerelease(version)?.length ?? 0) > 0 -} +type MainContext = ReturnType['context'] -/** - * Preserves the updater IPC contract when the storefront owns application updates. - * - * No method reaches Electron Updater or a release feed, while preference reads and - * subscriptions remain available to existing renderer consumers. - */ -function createDisabledAutoUpdater(options: AutoUpdaterOptions): AutoUpdater { - const state: AutoUpdaterState = { status: 'disabled' } - let storedPreferredLane = options.getStoredUpdateLane?.() +export function createAutoUpdaterService(params: { context: MainContext, service: AutoUpdater, window: BrowserWindow }) { + const { context, service, window } = params - return { - state, - async checkForUpdates() {}, - async downloadUpdate() {}, - async quitAndInstall() {}, - getPreferredUpdateLane() { - return storedPreferredLane - }, - async setPreferredUpdateLane(lane) { - storedPreferredLane = lane - options.setStoredUpdateLane?.(lane) - }, - subscribe(callback) { - callback(state) - return () => {} - }, + const log = useLogg('auto-updater-service').useGlobalConfig() + + const unsubscribe = service.subscribe((state) => { + if (window.isDestroyed()) + return + + tryCatch(() => context.emit(electronAutoUpdaterStateChanged, state)) + }) + + const cleanups: Array<() => void> = [ + unsubscribe, + defineInvokeHandler(context, autoUpdaterEventa.getState, () => service.state), + defineInvokeHandler(context, autoUpdaterEventa.checkForUpdates, async () => { + await service.checkForUpdates().catch(error => log.withError(error).error('checkForUpdates() failed')) + return service.state + }), + defineInvokeHandler(context, autoUpdaterEventa.downloadUpdate, async () => { + await service.downloadUpdate() + return service.state + }), + defineInvokeHandler(context, electronGetUpdaterPreferences, async () => ({ + channel: service.getPreferredUpdateLane(), + })), + defineInvokeHandler(context, electronSetUpdaterPreferences, async (payload) => { + await service.setPreferredUpdateLane(payload?.channel) + return { + channel: service.getPreferredUpdateLane(), + } + }), + defineInvokeHandler(context, autoUpdaterEventa.quitAndInstall, async () => { + await service.quitAndInstall() + }), + ] + + const cleanup = () => { + for (const fn of cleanups) + fn() } + + window.on('closed', cleanup) + return cleanup +} + +// NOTICE: this part of code is copied from https://www.electron.build/auto-update +// Or https://github.com/electron-userland/electron-builder/blob/b866e99ccd3ea9f85bc1e840f0f6a6a162fca388/pages/auto-update.md?plain=1#L57-L66 +export function fromImported(): AppUpdaterLike { + if (is.dev && !getUpdateServerOverride()) + return new MockAutoUpdater() + + const { autoUpdater } = electronUpdater + return autoUpdater as unknown as AppUpdaterLike } export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater { @@ -328,6 +191,14 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater autoUpdater.channel = releaseChannelName autoUpdater.forceDevUpdateConfig = !!feedUrlOverride && !app.isPackaged autoUpdater.logger = { + debug: (message: string) => { + log.debug(message) + void logToFile('DEBUG', message) + }, + error: (message: string) => { + log.error(message) + void logToFile('ERROR', message) + }, info: (message: string) => { log.log(message) void logToFile('INFO', message) @@ -336,14 +207,6 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater log.warn(message) void logToFile('WARN', message) }, - error: (message: string) => { - log.error(message) - void logToFile('ERROR', message) - }, - debug: (message: string) => { - log.debug(message) - void logToFile('DEBUG', message) - }, } if (activeFeedUrlOverride) @@ -352,14 +215,14 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater const withDiagnostics = (next: AutoUpdaterState): AutoUpdaterState => ({ ...next, diagnostics: { - platform: process.platform, arch: process.arch, channel: autoUpdater.channel || releaseChannelName, - logFilePath: UPDATER_LOG_FILE, executablePath: process.execPath, installDirectory: dirname(process.execPath), - requiresAdminForInstallPath: requiresAdminForInstallPath(process.execPath), isOverrideActive: !!activeFeedUrlOverride, + logFilePath: UPDATER_LOG_FILE, + platform: process.platform, + requiresAdminForInstallPath: requiresAdminForInstallPath(process.execPath), ...(activeFeedUrlOverride ? { feedUrl: activeFeedUrlOverride } : {}), }, }) @@ -382,8 +245,8 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater function broadcastUpdaterError(error: unknown, reason: string) { broadcast({ - status: 'error', error: { message: errorMessageFromValue(error) }, + status: 'error', }) log.withError(error).error(reason) } @@ -452,7 +315,7 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater } prepareFeedPromise = (async () => { - const preferredLane = getPreferredUpdateLane({ version: appVersion, storedLane: storedPreferredLane }) + const preferredLane = getPreferredUpdateLane({ storedLane: storedPreferredLane, version: appVersion }) const tag = await resolveGitHubReleaseTagForLane(preferredLane) resolvedReleaseTag = tag applyGenericFeedOverride(`${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${tag}`, `github-release-lane:${preferredLane}`) @@ -473,34 +336,31 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater autoUpdater.on('error', error => broadcastUpdaterError(error, 'autoUpdater error')) autoUpdater.on('checking-for-update', () => broadcast({ status: 'checking' })) - autoUpdater.on('update-available', (info: UpdateInfo) => broadcast({ status: 'available', info })) - autoUpdater.on('update-downloaded', (info: UpdateInfo) => broadcast({ status: 'downloaded', info })) + autoUpdater.on('update-available', (info: UpdateInfo) => broadcast({ info, status: 'available' })) + autoUpdater.on('update-downloaded', (info: UpdateInfo) => broadcast({ info, status: 'downloaded' })) autoUpdater.on('update-not-available', () => broadcast({ - status: 'not-available', info: { - version: app.getVersion(), files: [], releaseDate: committerDate, + version: app.getVersion(), }, + status: 'not-available', })) autoUpdater.on('download-progress', progress => broadcast({ ...state, - status: 'downloading', progress: { - percent: progress.percent, bytesPerSecond: progress.bytesPerSecond, - transferred: progress.transferred, + percent: progress.percent, total: progress.total, + transferred: progress.transferred, }, + status: 'downloading', })) void checkForUpdatesWithPreparedFeed() .catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed')) return { - get state() { - return state - }, async checkForUpdates() { broadcast({ status: 'checking' }) await checkForUpdatesWithPreparedFeed().catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed')) @@ -518,6 +378,9 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater semaphore.release() } }, + getPreferredUpdateLane() { + return storedPreferredLane + }, async quitAndInstall() { await semaphore.acquire() @@ -531,9 +394,6 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater semaphore.release() } }, - getPreferredUpdateLane() { - return storedPreferredLane - }, async setPreferredUpdateLane(lane) { if (storedPreferredLane === lane) return @@ -545,6 +405,9 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater // A fresh check runs right after channel update from renderer. broadcast({ status: 'idle' }) }, + get state() { + return state + }, subscribe(callback) { hooks.add(callback) @@ -560,48 +423,185 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater } } -export function createAutoUpdaterService(params: { context: MainContext, window: BrowserWindow, service: AutoUpdater }) { - const { context, window, service } = params +async function cleanupStaleUpdateFiles() { + // Remove both current and legacy updater cache roots so stale installers do not linger. + await Promise.allSettled(OFFICIAL_UPDATER_CACHE_DIRS.map(cacheDir => rm(cacheDir, { force: true, recursive: true }))) + await logToFile('INFO', `Updater cache cleanup attempted: ${OFFICIAL_UPDATER_CACHE_DIRS.join(', ')}`) +} - const log = useLogg('auto-updater-service').useGlobalConfig() +/** + * Preserves the updater IPC contract when the storefront owns application updates. + * + * No method reaches Electron Updater or a release feed, while preference reads and + * subscriptions remain available to existing renderer consumers. + */ +function createDisabledAutoUpdater(options: AutoUpdaterOptions): AutoUpdater { + const state: AutoUpdaterState = { status: 'disabled' } + let storedPreferredLane = options.getStoredUpdateLane?.() - const unsubscribe = service.subscribe((state) => { - if (window.isDestroyed()) - return + return { + async checkForUpdates() {}, + async downloadUpdate() {}, + getPreferredUpdateLane() { + return storedPreferredLane + }, + async quitAndInstall() {}, + async setPreferredUpdateLane(lane) { + storedPreferredLane = lane + options.setStoredUpdateLane?.(lane) + }, + state, + subscribe(callback) { + callback(state) + return () => {} + }, + } +} - tryCatch(() => context.emit(electronAutoUpdaterStateChanged, state)) - }) +/** + * Extract release tags from GitHub releases Atom feed without adding XML-parser dependencies. + * + * The current feed contains entries like: + * `` + * and + * `tag:github.com,2008:Repository/963495975/v0.9.0-alpha.36` + * + * We intentionally scan for `/moeru-ai/airi/releases/tag/` so we only consume actual release tag links. + */ +function extractReleaseTagsFromAtom(atom: string) { + const tags: string[] = [] + const marker = '/moeru-ai/airi/releases/tag/' + let offset = 0 - const cleanups: Array<() => void> = [ - unsubscribe, - defineInvokeHandler(context, autoUpdaterEventa.getState, () => service.state), - defineInvokeHandler(context, autoUpdaterEventa.checkForUpdates, async () => { - await service.checkForUpdates().catch(error => log.withError(error).error('checkForUpdates() failed')) - return service.state - }), - defineInvokeHandler(context, autoUpdaterEventa.downloadUpdate, async () => { - await service.downloadUpdate() - return service.state - }), - defineInvokeHandler(context, electronGetUpdaterPreferences, async () => ({ - channel: service.getPreferredUpdateLane(), - })), - defineInvokeHandler(context, electronSetUpdaterPreferences, async (payload) => { - await service.setPreferredUpdateLane(payload?.channel) - return { - channel: service.getPreferredUpdateLane(), - } - }), - defineInvokeHandler(context, autoUpdaterEventa.quitAndInstall, async () => { - await service.quitAndInstall() - }), - ] + while (offset < atom.length) { + const markerIndex = atom.indexOf(marker, offset) + if (markerIndex === -1) + break - const cleanup = () => { - for (const fn of cleanups) - fn() + const start = markerIndex + marker.length + let end = start + while (end < atom.length) { + const char = atom[end] + if (char === '"' || char === '<' || char === '?' || char === '&') + break + end += 1 + } + + // Slice the raw path segment after the marker, e.g. `v0.9.0-beta.6`. + const rawTag = atom.slice(start, end).trim() + // Atom encodes URLs, so decode in case future tags contain escaped characters. + const decodedTag = decodeURIComponent(rawTag) + // Feed entries can repeat across updates; keep a unique ordered tag list. + if (decodedTag && !tags.includes(decodedTag)) + tags.push(decodedTag) + + offset = end + 1 } - window.on('closed', cleanup) - return cleanup + return tags +} + +function getPreferredUpdateLane(params: { storedLane?: UpdateLane, version: string }): UpdateLane { + return normalizeLane(process.env[UPDATE_CHANNEL_ENV_KEY]?.trim()) ?? params.storedLane ?? laneFromVersion(params.version) +} + +function getSemverFromTag(tag: string) { + return semver.valid(tag) ?? semver.valid(tag.startsWith('v') ? tag.slice(1) : tag) +} + +function getUpdateServerOverride() { + // NOTICE: UPDATE_SERVER_URL is intentionally development-only for local update-test harness. + // Production update routing must not depend on this variable. + if (!is.dev) + return undefined + + const value = process.env.UPDATE_SERVER_URL?.trim() + return value || undefined +} + +function getWindowsProtectedInstallRoots() { + return [ + process.env.ProgramFiles, + process.env['ProgramFiles(x86)'], + process.env.ProgramW6432, + process.env.SystemRoot, + process.env.windir, + ] + .filter((value): value is string => Boolean(value)) + .map(value => normalize(value)) +} + +function isPathInside(parentPath: string, targetPath: string) { + const normalizedParent = normalize(parentPath) + const normalizedTarget = normalize(targetPath) + const parentWithSeparator = normalizedParent.endsWith('\\') ? normalizedParent : `${normalizedParent}\\` + return normalizedTarget === normalizedParent || normalizedTarget.startsWith(parentWithSeparator) +} + +function isPrereleaseVersion(version: string) { + return (semver.prerelease(version)?.length ?? 0) > 0 +} + +function isTagInLane(tag: string, lane: UpdateLane) { + const version = getSemverFromTag(tag) + if (!version) + return false + + if (lane === 'latest') + return true + + const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase() + if (lane === 'stable') + return !prerelease + + return prerelease === lane +} + +function laneFromVersion(version: string): UpdateLane { + const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase() + return normalizeLane(prerelease) ?? 'stable' +} + +async function logToFile(level: 'DEBUG' | 'ERROR' | 'INFO' | 'WARN', message: string) { + await mkdir(UPDATER_DEBUG_CACHE_DIR, { recursive: true }).catch(() => {}) + await appendFile(UPDATER_LOG_FILE, `${new Date().toISOString()} [${level}] ${message}\n`).catch(() => {}) +} + +function normalizeLane(value: string | undefined): undefined | UpdateLane { + if (!value) + return undefined + + switch (value.toLowerCase()) { + case 'alpha': + case 'beta': + case 'canary': + case 'latest': + case 'nightly': + case 'stable': + return value.toLowerCase() as UpdateLane + default: + return undefined + } +} + +function requiresAdminForInstallPath(executablePath: string) { + if (!isWindows) + return false + + const installDirectory = dirname(executablePath) + return getWindowsProtectedInstallRoots().some(root => isPathInside(root, installDirectory)) +} + +function selectLatestTagForLane(releases: GitHubReleaseRecord[], lane: UpdateLane) { + const candidates = releases + .filter(release => !release.draft && typeof release.tag_name === 'string' && isTagInLane(release.tag_name, lane)) + .map((release) => { + const tag = release.tag_name as string + const version = getSemverFromTag(tag) + return version ? { tag, version } : null + }) + .filter(Boolean) as Array<{ tag: string, version: string }> + + candidates.sort((a, b) => semver.rcompare(a.version, b.version)) + return candidates[0]?.tag } diff --git a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut-uiohook.test.ts b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut-uiohook.test.ts index 2b55906b8..5a90f8116 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut-uiohook.test.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut-uiohook.test.ts @@ -3,6 +3,24 @@ import type { ShortcutBinding } from '@proj-airi/stage-shared/global-shortcut' import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut' import { beforeEach, describe, expect, it, vi } from 'vitest' +interface KeyboardEvent { + altKey: boolean + ctrlKey: boolean + keycode: number + metaKey: boolean + shiftKey: boolean +} + +function event(partial: Partial & Pick): KeyboardEvent { + return { + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...partial, + } +} + /** * Builds a binding for the uiohook driver. * @@ -17,28 +35,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' */ function exampleBinding(id: string, modifiers: ShortcutBinding['accelerator']['modifiers'] = ['shift'], key = 'KeyK'): ShortcutBinding { return { + accelerator: { key, modifiers }, id, - accelerator: { modifiers, key }, - scope: 'global', receiveKeyUps: true, - } -} - -interface KeyboardEvent { - keycode: number - altKey: boolean - ctrlKey: boolean - metaKey: boolean - shiftKey: boolean -} - -function event(partial: Partial & Pick): KeyboardEvent { - return { - altKey: false, - ctrlKey: false, - metaKey: false, - shiftKey: false, - ...partial, + scope: 'global', } } @@ -78,8 +78,8 @@ async function setupMocks() { // mapper. KeyK = 37, KeyA = 30 (matches real upstream constants so // tests assert real keycodes, not arbitrary numbers). const UiohookKey = { - K: 37, A: 30, + K: 37, Q: 16, } as const @@ -118,17 +118,17 @@ async function setupMocks() { platform: overrides.platform ?? 'darwin', sessionType: overrides.sessionType, }) - return { driver, broadcastTriggered, logger } + return { broadcastTriggered, driver, logger } } return { + createDriver, + fire, + isTrustedAccessibilityClientMock, onMock, removeListenerMock, startMock, stopMock, - isTrustedAccessibilityClientMock, - fire, - createDriver, } } @@ -171,7 +171,7 @@ describe('createUiohookDriver', () => { it('broadcasts a "down" event when a matching keydown arrives', async () => { const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver() + const { broadcastTriggered, driver } = m.createDriver() driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK')) m.fire('keydown', event({ keycode: 37, shiftKey: true })) @@ -189,7 +189,7 @@ describe('createUiohookDriver', () => { // would emit hundreds of `down` broadcasts per second and the mic // would start/stop frantically. const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver() + const { broadcastTriggered, driver } = m.createDriver() driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK')) m.fire('keydown', event({ keycode: 37, shiftKey: true })) @@ -202,7 +202,7 @@ describe('createUiohookDriver', () => { it('broadcasts "up" on matching keyup and re-arms the binding for the next press', async () => { const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver() + const { broadcastTriggered, driver } = m.createDriver() driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK')) m.fire('keydown', event({ keycode: 37, shiftKey: true })) @@ -222,7 +222,7 @@ describe('createUiohookDriver', () => { // The driver keys the "up" broadcast off the prior `pressed` // state rather than the modifier predicate. const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver() + const { broadcastTriggered, driver } = m.createDriver() driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK')) m.fire('keydown', event({ keycode: 37, metaKey: true })) @@ -234,7 +234,7 @@ describe('createUiohookDriver', () => { it('ignores keyup when no matching keydown was tracked', async () => { const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver() + const { broadcastTriggered, driver } = m.createDriver() driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK')) m.fire('keyup', event({ keycode: 37, shiftKey: true })) @@ -246,21 +246,21 @@ describe('createUiohookDriver', () => { // Strict matching mirrors Electron's accelerator semantics: a // `Shift+K` binding must not fire on `Cmd+Shift+K`. const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver() + const { broadcastTriggered, driver } = m.createDriver() driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK')) - m.fire('keydown', event({ keycode: 37, shiftKey: true, metaKey: true })) + m.fire('keydown', event({ keycode: 37, metaKey: true, shiftKey: true })) expect(broadcastTriggered).not.toHaveBeenCalled() }) it('maps cmd-or-ctrl to metaKey on darwin', async () => { const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver({ platform: 'darwin' }) + const { broadcastTriggered, driver } = m.createDriver({ platform: 'darwin' }) driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK')) m.fire('keydown', event({ keycode: 37, metaKey: true })) - m.fire('keydown', event({ keycode: 37, ctrlKey: true })) + m.fire('keydown', event({ ctrlKey: true, keycode: 37 })) expect(broadcastTriggered).toHaveBeenCalledTimes(1) expect(broadcastTriggered).toHaveBeenCalledWith('ptt', 'down') @@ -268,10 +268,10 @@ describe('createUiohookDriver', () => { it('maps cmd-or-ctrl to ctrlKey on non-darwin platforms', async () => { const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver({ platform: 'win32' }) + const { broadcastTriggered, driver } = m.createDriver({ platform: 'win32' }) driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK')) - m.fire('keydown', event({ keycode: 37, ctrlKey: true })) + m.fire('keydown', event({ ctrlKey: true, keycode: 37 })) m.fire('keydown', event({ keycode: 37, metaKey: true })) // First (ctrl) matches; second (meta) does not — the pressed @@ -327,7 +327,7 @@ describe('createUiohookDriver', () => { it('unregisterAll clears every binding and stops the hook in one shot', async () => { const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver() + const { broadcastTriggered, driver } = m.createDriver() driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA')) driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ')) @@ -353,7 +353,7 @@ describe('createUiohookDriver', () => { it('keeps per-binding pressed state independent across multiple bindings', async () => { const m = await setupMocks() - const { driver, broadcastTriggered } = m.createDriver() + const { broadcastTriggered, driver } = m.createDriver() driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA')) driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ')) diff --git a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut-uiohook.ts b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut-uiohook.ts index eb0486e96..987e693eb 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut-uiohook.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut-uiohook.ts @@ -17,128 +17,26 @@ import { uIOhook, UiohookKey } from 'uiohook-napi' type Logger = ReturnType['useGlobalConfig']> interface ModifierMask { - ctrl: boolean - shift: boolean alt: boolean + ctrl: boolean meta: boolean + shift: boolean } interface UiohookEntry { binding: ShortcutBinding - predicate: (event: UiohookKeyboardEvent) => boolean expectedKeycode: number + predicate: (event: UiohookKeyboardEvent) => boolean pressed: boolean } const W3C_TO_UIOHOOK: Readonly> = buildKeycodeMap() -function buildKeycodeMap(): Record { - const map: Record = {} - - for (let i = 0; i < 26; i++) { - const letter = String.fromCharCode(65 + i) - map[`Key${letter}`] = (UiohookKey as unknown as Record)[letter] - } - - for (let i = 0; i <= 9; i++) { - map[`Digit${i}`] = (UiohookKey as unknown as Record)[String(i)] - } - - for (let i = 1; i <= 24; i++) { - map[`F${i}`] = (UiohookKey as unknown as Record)[`F${i}`] - } - - const named: Record = { - Space: 'Space', - Tab: 'Tab', - Enter: 'Enter', - Escape: 'Escape', - Backspace: 'Backspace', - Delete: 'Delete', - Insert: 'Insert', - ArrowUp: 'ArrowUp', - ArrowDown: 'ArrowDown', - ArrowLeft: 'ArrowLeft', - ArrowRight: 'ArrowRight', - Home: 'Home', - End: 'End', - PageUp: 'PageUp', - PageDown: 'PageDown', - Backquote: 'Backquote', - Minus: 'Minus', - Equal: 'Equal', - BracketLeft: 'BracketLeft', - BracketRight: 'BracketRight', - Backslash: 'Backslash', - Semicolon: 'Semicolon', - Quote: 'Quote', - Comma: 'Comma', - Period: 'Period', - Slash: 'Slash', - } - for (const [w3c, uioName] of Object.entries(named)) - map[w3c] = (UiohookKey as unknown as Record)[uioName as string] - - return map -} - -function resolveModifierMask(modifiers: readonly ShortcutModifier[], platform: NodeJS.Platform): ModifierMask { - const mask: ModifierMask = { ctrl: false, shift: false, alt: false, meta: false } - for (const m of modifiers) { - switch (m) { - case 'cmd-or-ctrl': - if (platform === 'darwin') - mask.meta = true - else - mask.ctrl = true - break - case 'cmd': - case 'super': - // libuiohook surfaces macOS Cmd, Windows key, and X11 Super - // through the same `metaKey` flag. - mask.meta = true - break - case 'ctrl': - mask.ctrl = true - break - case 'alt': - mask.alt = true - break - case 'shift': - mask.shift = true - break - } - } - return mask -} - -function buildPredicate(acc: ShortcutAccelerator, platform: NodeJS.Platform): { predicate: UiohookEntry['predicate'], expectedKeycode: number } | undefined { - const expectedKeycode = W3C_TO_UIOHOOK[acc.key] - if (expectedKeycode === undefined) - return undefined - const required = resolveModifierMask(acc.modifiers, platform) - const predicate: UiohookEntry['predicate'] = e => - e.keycode === expectedKeycode - && e.ctrlKey === required.ctrl - && e.shiftKey === required.shift - && e.altKey === required.alt - && e.metaKey === required.meta - return { predicate, expectedKeycode } -} - -function isNativeWayland(platform: NodeJS.Platform, sessionType: string | undefined): boolean { - return platform === 'linux' && sessionType === 'wayland' -} - -function isMacAccessibilityTrusted(platform: NodeJS.Platform, prompt: boolean): boolean { - if (platform !== 'darwin') - return true - try { - return systemPreferences.isTrustedAccessibilityClient(prompt) - } - catch { - return true - } +export interface UiohookDriver { + dispose: () => void + tryRegister: (binding: ShortcutBinding) => ShortcutRegistrationResult + unregisterAll: () => void + unregisterById: (id: string) => void } export interface UiohookDriverOptions { @@ -160,13 +58,6 @@ export interface UiohookDriverOptions { sessionType?: string } -export interface UiohookDriver { - tryRegister: (binding: ShortcutBinding) => ShortcutRegistrationResult - unregisterById: (id: string) => void - unregisterAll: () => void - dispose: () => void -} - /** * Driver that captures global key-down and key-up events via * libuiohook (through `uiohook-napi`). @@ -282,8 +173,8 @@ export function createUiohookDriver(options: UiohookDriverOptions): UiohookDrive entries.set(binding.id, { binding, - predicate: built.predicate, expectedKeycode: built.expectedKeycode, + predicate: built.predicate, pressed: false, }) ensureListeners() @@ -313,5 +204,114 @@ export function createUiohookDriver(options: UiohookDriverOptions): UiohookDrive } } - return { tryRegister, unregisterById, unregisterAll, dispose } + return { dispose, tryRegister, unregisterAll, unregisterById } +} + +function buildKeycodeMap(): Record { + const map: Record = {} + + for (let i = 0; i < 26; i++) { + const letter = String.fromCharCode(65 + i) + map[`Key${letter}`] = (UiohookKey as unknown as Record)[letter] + } + + for (let i = 0; i <= 9; i++) { + map[`Digit${i}`] = (UiohookKey as unknown as Record)[String(i)] + } + + for (let i = 1; i <= 24; i++) { + map[`F${i}`] = (UiohookKey as unknown as Record)[`F${i}`] + } + + const named: Record = { + ArrowDown: 'ArrowDown', + ArrowLeft: 'ArrowLeft', + ArrowRight: 'ArrowRight', + ArrowUp: 'ArrowUp', + Backquote: 'Backquote', + Backslash: 'Backslash', + Backspace: 'Backspace', + BracketLeft: 'BracketLeft', + BracketRight: 'BracketRight', + Comma: 'Comma', + Delete: 'Delete', + End: 'End', + Enter: 'Enter', + Equal: 'Equal', + Escape: 'Escape', + Home: 'Home', + Insert: 'Insert', + Minus: 'Minus', + PageDown: 'PageDown', + PageUp: 'PageUp', + Period: 'Period', + Quote: 'Quote', + Semicolon: 'Semicolon', + Slash: 'Slash', + Space: 'Space', + Tab: 'Tab', + } + for (const [w3c, uioName] of Object.entries(named)) + map[w3c] = (UiohookKey as unknown as Record)[uioName as string] + + return map +} + +function buildPredicate(acc: ShortcutAccelerator, platform: NodeJS.Platform): undefined | { expectedKeycode: number, predicate: UiohookEntry['predicate'] } { + const expectedKeycode = W3C_TO_UIOHOOK[acc.key] + if (expectedKeycode === undefined) + return undefined + const required = resolveModifierMask(acc.modifiers, platform) + const predicate: UiohookEntry['predicate'] = e => + e.keycode === expectedKeycode + && e.ctrlKey === required.ctrl + && e.shiftKey === required.shift + && e.altKey === required.alt + && e.metaKey === required.meta + return { expectedKeycode, predicate } +} + +function isMacAccessibilityTrusted(platform: NodeJS.Platform, prompt: boolean): boolean { + if (platform !== 'darwin') + return true + try { + return systemPreferences.isTrustedAccessibilityClient(prompt) + } + catch { + return true + } +} + +function isNativeWayland(platform: NodeJS.Platform, sessionType: string | undefined): boolean { + return platform === 'linux' && sessionType === 'wayland' +} + +function resolveModifierMask(modifiers: readonly ShortcutModifier[], platform: NodeJS.Platform): ModifierMask { + const mask: ModifierMask = { alt: false, ctrl: false, meta: false, shift: false } + for (const m of modifiers) { + switch (m) { + case 'alt': + mask.alt = true + break + case 'cmd': + case 'super': + // libuiohook surfaces macOS Cmd, Windows key, and X11 Super + // through the same `metaKey` flag. + mask.meta = true + break + case 'cmd-or-ctrl': + if (platform === 'darwin') + mask.meta = true + else + mask.ctrl = true + break + case 'ctrl': + mask.ctrl = true + break + case 'shift': + mask.shift = true + break + } + } + return mask } diff --git a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.test.ts b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.test.ts index ba2f1d773..1c4781460 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.test.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.test.ts @@ -6,23 +6,28 @@ import type { EventaContext } from './global-shortcut' import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut' import { beforeEach, describe, expect, it, vi } from 'vitest' -function exampleBinding(id: string, key = 'KeyK'): ShortcutBinding { - return { - id, - accelerator: { modifiers: ['cmd-or-ctrl', 'shift'], key }, - scope: 'global', - } -} - interface MockContext { emit: ReturnType invokeHandlers: Map unknown> } interface MockWindow { - on: ReturnType /** Manually trigger the registered `closed` handler. */ close: () => void + on: ReturnType +} + +function asBrowserWindow(window: MockWindow): BrowserWindow { + return window as unknown as BrowserWindow +} + +// NOTICE: +// MockContext / MockWindow are intentionally minimal — only what the +// driver touches. Casting through `unknown` lets us pass them to +// `service.registerWindow` whose typed signature wants the full +// `EventaContext` and `BrowserWindow` types. +function asEventaContext(ctx: MockContext): EventaContext { + return ctx as unknown as EventaContext } function createMockContext(): MockContext { @@ -39,27 +44,22 @@ function createMockContext(): MockContext { function createMockWindow(): MockWindow { let closedHandler: (() => void) | undefined return { + close() { + closedHandler?.() + }, on: vi.fn((event: string, handler: () => void) => { if (event === 'closed') closedHandler = handler }), - close() { - closedHandler?.() - }, } } -// NOTICE: -// MockContext / MockWindow are intentionally minimal — only what the -// driver touches. Casting through `unknown` lets us pass them to -// `service.registerWindow` whose typed signature wants the full -// `EventaContext` and `BrowserWindow` types. -function asEventaContext(ctx: MockContext): EventaContext { - return ctx as unknown as EventaContext -} - -function asBrowserWindow(window: MockWindow): BrowserWindow { - return window as unknown as BrowserWindow +function exampleBinding(id: string, key = 'KeyK'): ShortcutBinding { + return { + accelerator: { key, modifiers: ['cmd-or-ctrl', 'shift'] }, + id, + scope: 'global', + } } function registerMockWindow(service: { registerWindow: (params: { context: EventaContext, window: BrowserWindow }) => void }, ctx: MockContext): MockWindow { @@ -93,7 +93,7 @@ async function setupMocks() { triggerCallbacks.clear() }) - const onAppBeforeQuitMock = vi.fn<(fn: () => void | Promise) => void>() + const onAppBeforeQuitMock = vi.fn<(fn: () => Promise | void) => void>() vi.doMock('electron', () => ({ globalShortcut: { @@ -108,10 +108,10 @@ async function setupMocks() { vi.doMock('./global-shortcut-uiohook', () => ({ createUiohookDriver: () => ({ - tryRegister: vi.fn(async (binding: ShortcutBinding) => ({ id: binding.id, ok: true })), - unregisterById: vi.fn(), - unregisterAll: vi.fn(), dispose: vi.fn(), + tryRegister: vi.fn(async (binding: ShortcutBinding) => ({ id: binding.id, ok: true })), + unregisterAll: vi.fn(), + unregisterById: vi.fn(), }), })) @@ -144,12 +144,12 @@ async function setupMocks() { const { setupGlobalShortcutService } = await import('./global-shortcut') return { - setupGlobalShortcutService, - registerMock, - unregisterMock, - unregisterAllMock, - triggerCallbacks, onAppBeforeQuitMock, + registerMock, + setupGlobalShortcutService, + triggerCallbacks, + unregisterAllMock, + unregisterMock, } } @@ -417,7 +417,7 @@ describe('setupGlobalShortcutService', () => { const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')! expect(() => reg({})).toThrow(TypeError) expect(() => reg({ id: 'no-accel' })).toThrow(TypeError) - expect(() => reg({ accelerator: { modifiers: [], key: 'KeyK' } })).toThrow(TypeError) + expect(() => reg({ accelerator: { key: 'KeyK', modifiers: [] } })).toThrow(TypeError) expect(m.registerMock).not.toHaveBeenCalled() }) diff --git a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.ts b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.ts index 226ed1ebd..5cc0e8e06 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.ts @@ -20,9 +20,10 @@ import { onAppBeforeQuit } from '../../libs/bootkit/lifecycle' export type EventaContext = ReturnType['context'] -export interface RegisterWindowParams { - context: EventaContext - window: BrowserWindow +export interface GlobalShortcutService { + dispose: () => void + registerMainShortcut: (params: RegisterMainShortcutParams) => ShortcutRegistrationResult + registerWindow: (params: RegisterWindowParams) => void } export interface RegisterMainShortcutParams { @@ -30,16 +31,15 @@ export interface RegisterMainShortcutParams { onTriggered: () => void } -export interface GlobalShortcutService { - registerWindow: (params: RegisterWindowParams) => void - registerMainShortcut: (params: RegisterMainShortcutParams) => ShortcutRegistrationResult - dispose: () => void +export interface RegisterWindowParams { + context: EventaContext + window: BrowserWindow } type ActiveBinding - = | { binding: ShortcutBinding, owner: 'renderer', driver: 'electron', electronAccelerator: string } - | { binding: ShortcutBinding, owner: 'main', driver: 'electron', electronAccelerator: string, onTriggered: () => void } - | { binding: ShortcutBinding, owner: 'renderer', driver: 'uiohook' } + = | { binding: ShortcutBinding, driver: 'electron', electronAccelerator: string, onTriggered: () => void, owner: 'main' } + | { binding: ShortcutBinding, driver: 'electron', electronAccelerator: string, owner: 'renderer' } + | { binding: ShortcutBinding, driver: 'uiohook', owner: 'renderer' } export function setupGlobalShortcutService(): GlobalShortcutService { const log = useLogg('global-shortcut').useGlobalConfig() @@ -73,7 +73,7 @@ export function setupGlobalShortcutService(): GlobalShortcutService { return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Conflict } } - active.set(binding.id, { binding, owner: 'renderer', driver: 'electron', electronAccelerator }) + active.set(binding.id, { binding, driver: 'electron', electronAccelerator, owner: 'renderer' }) return { id: binding.id, ok: true } } @@ -96,7 +96,7 @@ export function setupGlobalShortcutService(): GlobalShortcutService { const result = uiohookDriver.tryRegister(binding) if (result.ok) - active.set(binding.id, { binding, owner: 'renderer', driver: 'uiohook' }) + active.set(binding.id, { binding, driver: 'uiohook', owner: 'renderer' }) return result } @@ -107,7 +107,7 @@ export function setupGlobalShortcutService(): GlobalShortcutService { return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId } const electronAccelerator = formatElectronAccelerator(binding.accelerator) - const nextEntry: ActiveBinding = { binding, owner: 'main', driver: 'electron', electronAccelerator, onTriggered } + const nextEntry: ActiveBinding = { binding, driver: 'electron', electronAccelerator, onTriggered, owner: 'main' } if (existing?.electronAccelerator === electronAccelerator) { releaseEntry(binding.id, existing) if (globalShortcut.register(electronAccelerator, onTriggered)) { @@ -146,7 +146,7 @@ export function setupGlobalShortcutService(): GlobalShortcutService { active.delete(id) } - function tryRegister(binding: ShortcutBinding): ShortcutRegistrationResult | Promise { + function tryRegister(binding: ShortcutBinding): Promise | ShortcutRegistrationResult { if (active.has(binding.id)) { return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId } } @@ -209,5 +209,5 @@ export function setupGlobalShortcutService(): GlobalShortcutService { onAppBeforeQuit(() => dispose()) - return { registerWindow, registerMainShortcut, dispose } + return { dispose, registerMainShortcut, registerWindow } } diff --git a/apps/stage-tamagotchi/src/main/services/electron/media-permissions.ts b/apps/stage-tamagotchi/src/main/services/electron/media-permissions.ts index fce457fd4..536e41da2 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/media-permissions.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/media-permissions.ts @@ -2,73 +2,42 @@ import type { Session, WebContents } from 'electron' import { isLocalAppURL } from '../../libs/electron/url' -type PermissionCheckHandler = Exclude[0], null> -type PermissionRequestHandler = Exclude[0], null> type ElectronPermission = Parameters[1] | Parameters[1] type ElectronPermissionDetails = Parameters[3] | Parameters[3] type LocalAppWebContents = Pick +type PermissionCheckHandler = Exclude[0], null> +type PermissionRequestHandler = Exclude[0], null> const LOCAL_APP_PERMISSION_NAMES = new Set([ - 'display-capture', 'clipboard-sanitized-write', + 'display-capture', ]) /** - * Filters out Chromium's opaque origin marker before evaluating explicit frame URLs. - */ -function isUsableRequesterURL(rawURL: string | undefined): rawURL is string { - return !!rawURL && rawURL !== 'null' -} - -/** - * Checks whether Electron described an audio-only media permission operation. - */ -function isAudioMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean { - if (permission !== 'media' || !details) - return false - - if ('mediaTypes' in details && details.mediaTypes?.length) { - return details.mediaTypes.includes('audio') && !details.mediaTypes.includes('video') - } - - return 'mediaType' in details && details.mediaType === 'audio' -} - -/** - * Checks whether Electron described a desktop capture operation of any kind. + * Registers the paired Electron session handlers required for complete permission policy. * - * Electron routes desktop capture through the `media` permission and only appends `audio` or `video` to - * `mediaTypes` for device capture, so desktop capture is the media operation that declares no media type - * at all. Both `getDisplayMedia()` and the legacy `chromeMediaSource: 'desktop'` constraint look like - * this, so the permission details alone cannot tell them apart. - * See {@link https://github.com/electron/electron/blob/v41.2.1/shell/browser/web_contents_permission_helper.cc#L249-L274}. + * Use when: + * - Initializing Electron's default session after app readiness + * + * Expects: + * - The session is the one used by AIRI renderer windows + * - macOS systemPreferences remains responsible for OS-level consent prompts and status + * - `isDesktopCaptureAuthorized` reports whether a renderer already selected a capture source + * + * Returns: + * - Nothing; both handlers are installed on the supplied session */ -function isDesktopCaptureMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean { - if (permission !== 'media' || !details) - return false +export function setupMediaPermissionHandlers( + targetSession: Pick, + isDesktopCaptureAuthorized: () => boolean, +): void { + targetSession.setPermissionRequestHandler((webContents, permission, callback, details) => { + callback(shouldGrantElectronPermission(webContents, permission, undefined, details, isDesktopCaptureAuthorized)) + }) - return 'mediaTypes' in details && details.mediaTypes?.length === 0 -} - -/** - * Checks whether every requester identity supplied by Electron is local to AIRI. - */ -function shouldGrantLocalAppPermission( - webContents: LocalAppWebContents | null, - requestingOrigin?: string, - details?: ElectronPermissionDetails, -): boolean { - const requesterURLs = [ - requestingOrigin, - details?.requestingUrl, - details && 'securityOrigin' in details ? details.securityOrigin : undefined, - details && 'embeddingOrigin' in details ? details.embeddingOrigin : undefined, - ].filter(isUsableRequesterURL) - - if (requesterURLs.length) - return requesterURLs.every(isLocalAppURL) - - return isLocalAppURL(webContents?.getURL()) + targetSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => { + return shouldGrantElectronPermission(webContents, permission, requestingOrigin, details, isDesktopCaptureAuthorized) + }) } /** @@ -140,28 +109,59 @@ export function shouldGrantElectronPermission( } /** - * Registers the paired Electron session handlers required for complete permission policy. - * - * Use when: - * - Initializing Electron's default session after app readiness - * - * Expects: - * - The session is the one used by AIRI renderer windows - * - macOS systemPreferences remains responsible for OS-level consent prompts and status - * - `isDesktopCaptureAuthorized` reports whether a renderer already selected a capture source - * - * Returns: - * - Nothing; both handlers are installed on the supplied session + * Checks whether Electron described an audio-only media permission operation. */ -export function setupMediaPermissionHandlers( - targetSession: Pick, - isDesktopCaptureAuthorized: () => boolean, -): void { - targetSession.setPermissionRequestHandler((webContents, permission, callback, details) => { - callback(shouldGrantElectronPermission(webContents, permission, undefined, details, isDesktopCaptureAuthorized)) - }) +function isAudioMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean { + if (permission !== 'media' || !details) + return false - targetSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => { - return shouldGrantElectronPermission(webContents, permission, requestingOrigin, details, isDesktopCaptureAuthorized) - }) + if ('mediaTypes' in details && details.mediaTypes?.length) { + return details.mediaTypes.includes('audio') && !details.mediaTypes.includes('video') + } + + return 'mediaType' in details && details.mediaType === 'audio' +} + +/** + * Checks whether Electron described a desktop capture operation of any kind. + * + * Electron routes desktop capture through the `media` permission and only appends `audio` or `video` to + * `mediaTypes` for device capture, so desktop capture is the media operation that declares no media type + * at all. Both `getDisplayMedia()` and the legacy `chromeMediaSource: 'desktop'` constraint look like + * this, so the permission details alone cannot tell them apart. + * See {@link https://github.com/electron/electron/blob/v41.2.1/shell/browser/web_contents_permission_helper.cc#L249-L274}. + */ +function isDesktopCaptureMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean { + if (permission !== 'media' || !details) + return false + + return 'mediaTypes' in details && details.mediaTypes?.length === 0 +} + +/** + * Filters out Chromium's opaque origin marker before evaluating explicit frame URLs. + */ +function isUsableRequesterURL(rawURL: string | undefined): rawURL is string { + return !!rawURL && rawURL !== 'null' +} + +/** + * Checks whether every requester identity supplied by Electron is local to AIRI. + */ +function shouldGrantLocalAppPermission( + webContents: LocalAppWebContents | null, + requestingOrigin?: string, + details?: ElectronPermissionDetails, +): boolean { + const requesterURLs = [ + requestingOrigin, + details?.requestingUrl, + details && 'securityOrigin' in details ? details.securityOrigin : undefined, + details && 'embeddingOrigin' in details ? details.embeddingOrigin : undefined, + ].filter(isUsableRequesterURL) + + if (requesterURLs.length) + return requesterURLs.every(isLocalAppURL) + + return isLocalAppURL(webContents?.getURL()) } diff --git a/apps/stage-tamagotchi/src/main/services/electron/mock-auto-updater.ts b/apps/stage-tamagotchi/src/main/services/electron/mock-auto-updater.ts index b47ea92da..50ae25f1a 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/mock-auto-updater.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/mock-auto-updater.ts @@ -14,12 +14,12 @@ export class MockAutoUpdater extends EventEmitter { // Simulate update available // We can toggle this based on some logic if needed, but for now let's assume update is always available in mock const updateInfo = { - version: '9.9.9-mock', files: [], path: 'mock-path', - sha512: 'mock-sha', releaseDate: new Date().toISOString(), releaseNotes: '## Mock Update\n\nThis is a simulated update for testing purposes.\n\n- Feature A\n- Bugfix B', + sha512: 'mock-sha', + version: '9.9.9-mock', } this.emit('update-available', updateInfo) @@ -41,10 +41,10 @@ export class MockAutoUpdater extends EventEmitter { transferred = total const progress = { + bytesPerSecond: speed, + percent: (transferred / total) * 100, total, transferred, - percent: (transferred / total) * 100, - bytesPerSecond: speed, } this.emit('download-progress', progress) @@ -52,12 +52,12 @@ export class MockAutoUpdater extends EventEmitter { if (transferred >= total) { clearInterval(interval) this.emit('update-downloaded', { - version: '9.9.9-mock', files: [], path: 'mock-path', - sha512: 'mock-sha', releaseDate: new Date().toISOString(), releaseNotes: '## Mock Update\n\nThis is a simulated update for testing purposes.\n\n- Feature A\n- Bugfix B', + sha512: 'mock-sha', + version: '9.9.9-mock', }) } }, 100) diff --git a/apps/stage-tamagotchi/src/main/services/electron/screen.ts b/apps/stage-tamagotchi/src/main/services/electron/screen.ts index ccf8e8b4c..36b0018b0 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/screen.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/screen.ts @@ -11,11 +11,11 @@ import { onAppBeforeQuit, onAppWindowAllClosed } from '../../libs/bootkit/lifecy export function createScreenService(params: { context: ReturnType['context'], window: BrowserWindow }) { const { start, stop } = createRendererLoop({ - window: params.window, run: () => { const dipPos = screen.getCursorScreenPoint() params.context.emit(cursorScreenPoint, dipPos) }, + window: params.window, }) onAppWindowAllClosed(() => stop()) diff --git a/apps/stage-tamagotchi/src/main/services/electron/window.ts b/apps/stage-tamagotchi/src/main/services/electron/window.ts index ebe61ec04..c3dd76839 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/window.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/window.ts @@ -34,10 +34,10 @@ export function createWindowService(params: { context: ReturnType { params.context.emit(bounds, params.window.getBounds()) }, + window: params.window, }) onAppWindowAllClosed(() => stop()) @@ -61,10 +61,10 @@ export function createWindowService(params: { context: ReturnType - widgetsWindow: WidgetsWindowManager - beatSyncBgWindow: Awaited> aboutWindow: () => Promise - serverChannel: ServerChannel + beatSyncBgWindow: Awaited> + captionWindow: ReturnType i18n: I18n + mainWindow: BrowserWindow + serverChannel: ServerChannel + settingsWindow: SettingsWindowManager + widgetsWindow: WidgetsWindowManager }): void { once(() => { const mainWindowAnimator = new Animator(params.mainWindow) @@ -134,8 +67,8 @@ export function setupTray(params: { const mainWindowBounds = params.mainWindow.getBounds() const currentDisplay = findDominantDisplayArea(mainWindowBounds, screen.getAllDisplays()) ?? screen.getDisplayMatching(mainWindowBounds) - const { x: areaX, y: areaY, width: areaWidth, height: areaHeight } = currentDisplay.workArea - const { width: windowWidth, height: windowHeight } = mainWindowBounds + const { height: areaHeight, width: areaWidth, x: areaX, y: areaY } = currentDisplay.workArea + const { height: windowHeight, width: windowWidth } = mainWindowBounds const fullHeightTarget = areaHeight const fullWidthTarget = Math.floor(areaHeight * ASPECT_RATIO) @@ -143,34 +76,34 @@ export function setupTray(params: { const halfWidthTarget = Math.floor(halfHeightTarget * ASPECT_RATIO) const contextMenu = Menu.buildFromTemplate([ - { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.show'), click: () => toggleWindowShow(params.mainWindow) }, + { click: () => toggleWindowShow(params.mainWindow), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.show') }, { type: 'separator' }, { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.adjust_sizes'), submenu: [ { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.recommended_size'), - type: 'checkbox', checked: isSizeMatch(params.mainWindow, RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT), click: () => applyMainWindowSize(RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT), + label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.recommended_size'), + type: 'checkbox', }, { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_height'), - type: 'checkbox', checked: isSizeMatch(params.mainWindow, fullWidthTarget, fullHeightTarget), click: () => applyMainWindowSize(fullWidthTarget, fullHeightTarget), + label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_height'), + type: 'checkbox', }, { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.half_height'), - type: 'checkbox', checked: isSizeMatch(params.mainWindow, halfWidthTarget, halfHeightTarget), click: () => applyMainWindowSize(halfWidthTarget, halfHeightTarget), + label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.half_height'), + type: 'checkbox', }, { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_screen'), - type: 'checkbox', checked: isSizeMatch(params.mainWindow, areaWidth, areaHeight), click: () => applyMainWindowSize(areaWidth, areaHeight, areaX, areaY), + label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_screen'), + type: 'checkbox', }, ], }, @@ -178,69 +111,69 @@ export function setupTray(params: { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.align_to'), submenu: [ { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.center'), - type: 'checkbox', checked: isPositionMatch(params.mainWindow, areaX + Math.floor((areaWidth - windowWidth) / 2), areaY + Math.floor((areaHeight - windowHeight) / 2)), click: () => animateMainWindowTo(currentDisplay.workArea, 'center'), + label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.center'), + type: 'checkbox', }, { type: 'separator' }, { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_left'), - type: 'checkbox', checked: isPositionMatch(params.mainWindow, areaX, areaY), click: () => animateMainWindowTo(currentDisplay.workArea, 'top-left'), + label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_left'), + type: 'checkbox', }, { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_right'), - type: 'checkbox', checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY), click: () => animateMainWindowTo(currentDisplay.workArea, 'top-right'), + label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_right'), + type: 'checkbox', }, { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_left'), - type: 'checkbox', checked: isPositionMatch(params.mainWindow, areaX, areaY + areaHeight - windowHeight), click: () => animateMainWindowTo(currentDisplay.workArea, 'bottom-left'), + label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_left'), + type: 'checkbox', }, { - label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_right'), - type: 'checkbox', checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY + areaHeight - windowHeight), click: () => animateMainWindowTo(currentDisplay.workArea, 'bottom-right'), + label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_right'), + type: 'checkbox', }, ], }, { type: 'separator' }, - { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.settings'), click: () => void params.settingsWindow.openWindow('/settings') }, - { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.about'), click: () => params.aboutWindow().then(window => toggleWindowShow(window)) }, + { click: () => void params.settingsWindow.openWindow('/settings'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.settings') }, + { click: () => params.aboutWindow().then(window => toggleWindowShow(window)), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.about') }, { type: 'separator' }, - { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.open_inlay'), click: () => setupInlayWindow({ i18n: params.i18n, serverChannel: params.serverChannel }) }, - { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.open_widgets'), click: () => params.widgetsWindow.getWindow().then(window => toggleWindowShow(window)) }, + { click: () => setupInlayWindow({ i18n: params.i18n, serverChannel: params.serverChannel }), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.open_inlay') }, + { click: () => params.widgetsWindow.getWindow().then(window => toggleWindowShow(window)), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.open_widgets') }, { - label: params.i18n.t(params.captionWindow.isVisible() - ? 'tamagotchi.electron.tray.menu.labels.label.close_caption' - : 'tamagotchi.electron.tray.menu.labels.label.open_caption'), click: () => { void params.captionWindow.toggleVisibility().then(() => rebuildContextMenu()) }, + label: params.i18n.t(params.captionWindow.isVisible() + ? 'tamagotchi.electron.tray.menu.labels.label.close_caption' + : 'tamagotchi.electron.tray.menu.labels.label.open_caption'), }, { - type: 'submenu', label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.caption_overlay'), submenu: Menu.buildFromTemplate([ - { type: 'checkbox', label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.follow_window'), checked: params.captionWindow.getIsFollowingWindow(), click: async menuItem => await params.captionWindow.setFollowWindow(Boolean(menuItem.checked)) }, - { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.reset_position'), click: async () => await params.captionWindow.resetToSide() }, + { checked: params.captionWindow.getIsFollowingWindow(), click: async menuItem => await params.captionWindow.setFollowWindow(Boolean(menuItem.checked)), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.follow_window'), type: 'checkbox' }, + { click: async () => await params.captionWindow.resetToSide(), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.reset_position') }, ]), + type: 'submenu', }, { type: 'separator' }, ...is.dev || env.MAIN_APP_DEBUG || env.APP_DEBUG ? [ - { type: 'header', label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.devtools') }, - { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.troubleshoot_beatsync'), click: () => params.beatSyncBgWindow.webContents.openDevTools({ mode: 'detach' }) }, + { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.devtools'), type: 'header' }, + { click: () => params.beatSyncBgWindow.webContents.openDevTools({ mode: 'detach' }), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.troubleshoot_beatsync') }, { type: 'separator' }, ] as const : [], - { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.quit'), click: () => app.quit() }, + { click: () => app.quit(), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.quit') }, ]) appTray.setContextMenu(contextMenu) @@ -253,7 +186,7 @@ export function setupTray(params: { rebuildContextMenu() const stopLocaleEffect = effect(() => { - const locale = params.i18n.locale as (() => string | LocaleDetector | undefined) + const locale = params.i18n.locale as (() => LocaleDetector | string | undefined) locale() rebuildContextMenu() }) @@ -282,3 +215,70 @@ export function setupTray(params: { } })() } + +function applyWindowSize(window: BrowserWindow, width: number, height: number, x?: number, y?: number): void { + if (isRendererUnavailable(window)) { + return + } + + window.setResizable(true) + + const bounds = x !== undefined && y !== undefined + ? { + height: Math.round(height), + width: Math.round(width), + x: Math.round(x), + y: Math.round(y), + } + : computeResizedBoundsAnchoredToDominantDisplay({ + currentBounds: window.getBounds(), + displays: screen.getAllDisplays(), + targetSize: { height, width }, + }) + + window.setBounds(bounds) + window.show() +} + +function isPositionMatch(window: BrowserWindow, targetX: number, targetY: number): boolean { + const { x, y } = window.getBounds() + return Math.abs(x - targetX) <= 5 && Math.abs(y - targetY) <= 5 +} + +function isSizeMatch(window: BrowserWindow, targetWidth: number, targetHeight: number): boolean { + const { height, width } = window.getBounds() + return Math.abs(width - Math.round(targetWidth)) <= 2 && Math.abs(height - Math.round(targetHeight)) <= 2 +} + +function resolveAlignedWindowBounds( + window: BrowserWindow, + workArea: Rectangle, + position: 'bottom-left' | 'bottom-right' | 'center' | 'top-left' | 'top-right', +): Rectangle { + const { height: windowHeight, width: windowWidth } = window.getBounds() + const { height: areaHeight, width: areaWidth, x: areaX, y: areaY } = workArea + + let x = areaX + let y = areaY + + switch (position) { + case 'bottom-left': + y = areaY + areaHeight - windowHeight + break + case 'bottom-right': + x = areaX + areaWidth - windowWidth + y = areaY + areaHeight - windowHeight + break + case 'center': + x = areaX + Math.floor((areaWidth - windowWidth) / 2) + y = areaY + Math.floor((areaHeight - windowHeight) / 2) + break + case 'top-left': + break + case 'top-right': + x = areaX + areaWidth - windowWidth + break + } + + return { height: windowHeight, width: windowWidth, x, y } +} diff --git a/apps/stage-tamagotchi/src/main/windows/about/index.ts b/apps/stage-tamagotchi/src/main/windows/about/index.ts index 52fe4abe5..e44e98d1a 100644 --- a/apps/stage-tamagotchi/src/main/windows/about/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/about/index.ts @@ -20,28 +20,28 @@ export function setupAboutWindowReusable(params: { }) { return createReusableWindow(async () => { const window = new BrowserWindow({ - title: 'About AIRI', - width: 670, height: 880, - show: false, - resizable: true, + icon, maximizable: false, minimizable: false, - icon, + resizable: true, + show: false, + title: 'About AIRI', webPreferences: { preload: join(getElectronMainDirname(), '../preload/index.mjs'), sandbox: false, }, + width: 670, }) window.on('ready-to-show', () => window.show()) protectPrivilegedWindowNavigation(window) await setupAboutWindowElectronInvokes({ - window, autoUpdater: params.autoUpdater, i18n: params.i18n, serverChannel: params.serverChannel, + window, }) await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/about', { diff --git a/apps/stage-tamagotchi/src/main/windows/about/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/about/rpc/index.electron.ts index 42e47e2f8..0bfcafeda 100644 --- a/apps/stage-tamagotchi/src/main/windows/about/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/about/rpc/index.electron.ts @@ -11,10 +11,10 @@ import { createAutoUpdaterService } from '../../../services/electron' import { setupBaseWindowElectronInvokes } from '../../shared/window' export async function setupAboutWindowElectronInvokes(params: { - window: BrowserWindow autoUpdater: AutoUpdater i18n: I18n serverChannel: ServerChannel + window: BrowserWindow }) { // TODO: once we refactored eventa to support window-namespaced contexts, // we can remove the setMaxListeners call below since eventa will be able to dispatch and @@ -23,7 +23,7 @@ export async function setupAboutWindowElectronInvokes(params: { const { context } = createContext(ipcMain, params.window) - await setupBaseWindowElectronInvokes({ context, window: params.window, i18n: params.i18n, serverChannel: params.serverChannel }) + await setupBaseWindowElectronInvokes({ context, i18n: params.i18n, serverChannel: params.serverChannel, window: params.window }) - createAutoUpdaterService({ context, window: params.window, service: params.autoUpdater }) + createAutoUpdaterService({ context, service: params.autoUpdater, window: params.window }) } diff --git a/apps/stage-tamagotchi/src/main/windows/caption/index.ts b/apps/stage-tamagotchi/src/main/windows/caption/index.ts index 5e254ee74..9292e9608 100644 --- a/apps/stage-tamagotchi/src/main/windows/caption/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/caption/index.ts @@ -28,10 +28,10 @@ const captionConfigSchema = object({ isFollowing: boolean(), matrices: record(string(), object({ bounds: object({ + height: number(), + width: number(), x: number(), y: number(), - width: number(), - height: number(), }), relativeToMain: optional(object({ dx: number(), @@ -41,121 +41,20 @@ const captionConfigSchema = object({ }) type CaptionConfig = InferOutput -function computeDisplayMatrixHash(): string { - const displays = screen.getAllDisplays() - const signature = displays - .slice() - .sort((a, b) => (a.bounds.x - b.bounds.x) || (a.bounds.y - b.bounds.y)) - .map(d => [d.bounds.x, d.bounds.y, d.bounds.width, d.bounds.height, d.scaleFactor ?? 1].join(',')) - .join('|') - - return createHash('sha256').update(signature).digest('hex').slice(0, 16) -} - -function clampBoundsWithinRect(bounds: Rectangle, rect: Rectangle): Rectangle { - const x = Math.min(Math.max(bounds.x, rect.x), rect.x + rect.width - bounds.width) - const y = Math.min(Math.max(bounds.y, rect.y), rect.y + rect.height - bounds.height) - return { x, y, width: bounds.width, height: bounds.height } -} - -function computeInitialCaptionBounds(params: { mainWindow: BrowserWindow, captionOptions?: Partial }): Rectangle { - const mainBounds = params.mainWindow.getBounds() - const displayWorkArea = screen.getDisplayMatching(mainBounds).workArea - - // Base sizing from display width with sensible caps - const width = mapForBreakpoints( - displayWorkArea.width, - { - '720p': widthFrom(displayWorkArea, { percentage: 0.9, max: { actual: 560 }, min: { actual: 280 } }), - '1080p': widthFrom(displayWorkArea, { percentage: 0.5, max: { actual: 640 }, min: { actual: 320 } }), - '2k': widthFrom(displayWorkArea, { percentage: 0.4, max: { actual: 720 }, min: { actual: 360 } }), - '4k': widthFrom(displayWorkArea, { percentage: 0.33, max: { actual: 768 }, min: { actual: 420 } }), - }, - { breakpoints: resolutionBreakpoints }, - ) - const height = Math.max(Math.floor(width / 3.2), 120) - - const margin = 16 - // Prefer to the right of main window, else to the left, else bottom centered - let x = mainBounds.x + mainBounds.width + margin - let y = mainBounds.y + mainBounds.height - height - - const rightEdge = x + width - const displayRight = displayWorkArea.x + displayWorkArea.width - - if (rightEdge > displayRight) { - // Place to the left - x = mainBounds.x - width - margin - } - - // If still out of bounds horizontally, fallback to bottom center - if (x < displayWorkArea.x || (x + width) > displayRight) { - x = displayWorkArea.x + Math.floor((displayWorkArea.width - width) / 2) - } - - // Clamp vertically - if (y < displayWorkArea.y) { - y = displayWorkArea.y + margin - } - - const initial = clampBoundsWithinRect({ x, y, width, height }, displayWorkArea) - - return { ...initial, ...params.captionOptions } -} - -function createCaptionWindow(options?: BrowserWindowConstructorOptions) { - const window = new ElectronBrowserWindow({ - title: 'Caption', - width: 480, - height: 180, - show: false, - icon, - webPreferences: { - preload: join(getElectronMainDirname(), '../preload/index.mjs'), - sandbox: false, - }, - // Thanks to [@HeartArmy](https://github.com/HeartArmy) for the tip implementation. - // - // https://github.com/electron/electron/issues/10078#issuecomment-3410164802 - // https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac - type: 'panel', - ...transparentWindowConfig(), - ...options, - }) - - // Click-through is controlled by caller via setIgnoreMouseEvents - // Avoid window buttons on macOS frameless windows - // Thanks to [@HeartArmy](https://github.com/HeartArmy) for the tip implementation. - // - // https://github.com/electron/electron/issues/10078#issuecomment-3410164802 - // https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac - window.setAlwaysOnTop(true, 'screen-saver', 2) - window.setFullScreenable(false) - window.setVisibleOnAllWorkspaces(true) - if (isMacOS) { - window.setWindowButtonVisibility(false) - } - - window.on('ready-to-show', () => window.show()) - protectPrivilegedWindowNavigation(window) - - return window -} - export function setupCaptionWindowManager(params: { + i18n: I18n mainWindow: BrowserWindow serverChannel: ServerChannel - i18n: I18n }) { const matrixHash = computeDisplayMatrixHash() const { - setup: setupConfig, get: getConfigRaw, + setup: setupConfig, update: updateConfig, } = createConfig('windows-caption', 'config.json', captionConfigSchema, { - default: { isFollowing: true, matrices: {} }, autoHeal: true, + default: { isFollowing: true, matrices: {} }, }) const getConfig = (): CaptionConfig => getConfigRaw() ?? { isFollowing: true, matrices: {} } @@ -185,7 +84,7 @@ export function setupCaptionWindowManager(params: { cfgToSave.matrices[matrixHash] = { ...cfgToSave.matrices[matrixHash], relativeToMain: initialOffset } updateConfig(cfgToSave) - let animation: ReturnType | null = null + let animation: null | ReturnType = null const state = { x: 0, y: 0 } const settleTo = (toX: number, toY: number) => { @@ -199,8 +98,6 @@ export function setupCaptionWindowManager(params: { state.y = Number.isFinite(b.y) ? b.y : 0 animation?.pause() animation = animate(state, { - x: toX, - y: toY, duration: 160, ease: 'outCubic', modifier: utils.round(0), @@ -215,6 +112,8 @@ export function setupCaptionWindowManager(params: { lastProgrammaticMoveAt = Date.now() win.setPosition(toX, toY) }, + x: toX, + y: toY, }) } @@ -232,7 +131,7 @@ export function setupCaptionWindowManager(params: { const b = win.getBounds() let tx = main.x + stored.dx let ty = main.y + stored.dy - const target = { x: tx, y: ty, width: b.width, height: b.height } + const target = { height: b.height, width: b.width, x: tx, y: ty } const workArea = screen.getDisplayMatching(target).workArea const clamped = clampBoundsWithinRect(target, workArea) tx = clamped.x @@ -308,7 +207,7 @@ export function setupCaptionWindowManager(params: { const { context } = createContext(ipcMain, window) eventaContext = context - await setupBaseWindowElectronInvokes({ context, window, serverChannel: params.serverChannel, i18n: params.i18n }) + await setupBaseWindowElectronInvokes({ context, i18n: params.i18n, serverChannel: params.serverChannel, window }) applyIgnoreMouseEvents(window, isFollowing) @@ -472,13 +371,114 @@ export function setupCaptionWindowManager(params: { } return { + getIsFollowingWindow, getWindow, + isVisible, + onVisibilityChanged, + resetToSide, setFollowWindow, toggleFollowWindow, - getIsFollowingWindow, - resetToSide, - isVisible, toggleVisibility, - onVisibilityChanged, } } + +function clampBoundsWithinRect(bounds: Rectangle, rect: Rectangle): Rectangle { + const x = Math.min(Math.max(bounds.x, rect.x), rect.x + rect.width - bounds.width) + const y = Math.min(Math.max(bounds.y, rect.y), rect.y + rect.height - bounds.height) + return { height: bounds.height, width: bounds.width, x, y } +} + +function computeDisplayMatrixHash(): string { + const displays = screen.getAllDisplays() + const signature = displays + .slice() + .sort((a, b) => (a.bounds.x - b.bounds.x) || (a.bounds.y - b.bounds.y)) + .map(d => [d.bounds.x, d.bounds.y, d.bounds.width, d.bounds.height, d.scaleFactor ?? 1].join(',')) + .join('|') + + return createHash('sha256').update(signature).digest('hex').slice(0, 16) +} + +function computeInitialCaptionBounds(params: { captionOptions?: Partial, mainWindow: BrowserWindow }): Rectangle { + const mainBounds = params.mainWindow.getBounds() + const displayWorkArea = screen.getDisplayMatching(mainBounds).workArea + + // Base sizing from display width with sensible caps + const width = mapForBreakpoints( + displayWorkArea.width, + { + '2k': widthFrom(displayWorkArea, { max: { actual: 720 }, min: { actual: 360 }, percentage: 0.4 }), + '4k': widthFrom(displayWorkArea, { max: { actual: 768 }, min: { actual: 420 }, percentage: 0.33 }), + '720p': widthFrom(displayWorkArea, { max: { actual: 560 }, min: { actual: 280 }, percentage: 0.9 }), + '1080p': widthFrom(displayWorkArea, { max: { actual: 640 }, min: { actual: 320 }, percentage: 0.5 }), + }, + { breakpoints: resolutionBreakpoints }, + ) + const height = Math.max(Math.floor(width / 3.2), 120) + + const margin = 16 + // Prefer to the right of main window, else to the left, else bottom centered + let x = mainBounds.x + mainBounds.width + margin + let y = mainBounds.y + mainBounds.height - height + + const rightEdge = x + width + const displayRight = displayWorkArea.x + displayWorkArea.width + + if (rightEdge > displayRight) { + // Place to the left + x = mainBounds.x - width - margin + } + + // If still out of bounds horizontally, fallback to bottom center + if (x < displayWorkArea.x || (x + width) > displayRight) { + x = displayWorkArea.x + Math.floor((displayWorkArea.width - width) / 2) + } + + // Clamp vertically + if (y < displayWorkArea.y) { + y = displayWorkArea.y + margin + } + + const initial = clampBoundsWithinRect({ height, width, x, y }, displayWorkArea) + + return { ...initial, ...params.captionOptions } +} + +function createCaptionWindow(options?: BrowserWindowConstructorOptions) { + const window = new ElectronBrowserWindow({ + height: 180, + icon, + show: false, + title: 'Caption', + // Thanks to [@HeartArmy](https://github.com/HeartArmy) for the tip implementation. + // + // https://github.com/electron/electron/issues/10078#issuecomment-3410164802 + // https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac + type: 'panel', + webPreferences: { + preload: join(getElectronMainDirname(), '../preload/index.mjs'), + sandbox: false, + }, + width: 480, + ...transparentWindowConfig(), + ...options, + }) + + // Click-through is controlled by caller via setIgnoreMouseEvents + // Avoid window buttons on macOS frameless windows + // Thanks to [@HeartArmy](https://github.com/HeartArmy) for the tip implementation. + // + // https://github.com/electron/electron/issues/10078#issuecomment-3410164802 + // https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac + window.setAlwaysOnTop(true, 'screen-saver', 2) + window.setFullScreenable(false) + window.setVisibleOnAllWorkspaces(true) + if (isMacOS) { + window.setWindowButtonVisibility(false) + } + + window.on('ready-to-show', () => window.show()) + protectPrivilegedWindowNavigation(window) + + return window +} diff --git a/apps/stage-tamagotchi/src/main/windows/chat/index.ts b/apps/stage-tamagotchi/src/main/windows/chat/index.ts index 52aede3ca..442eb87f0 100644 --- a/apps/stage-tamagotchi/src/main/windows/chat/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/chat/index.ts @@ -15,33 +15,33 @@ import { protectPrivilegedWindowNavigation } from '../shared' import { setupChatWindowElectronInvokes } from './rpc/index.electron' export function setupChatWindowReusableFunc(params: { - widgetsManager: WidgetsWindowManager - serverChannel: ServerChannel - mcpStdioManager: McpStdioManager i18n: I18n + mcpStdioManager: McpStdioManager + serverChannel: ServerChannel + widgetsManager: WidgetsWindowManager }) { return createReusableWindow(async () => { const window = new BrowserWindow({ - title: 'Chat', - width: 600.0, height: 800.0, - show: false, icon, + show: false, + title: 'Chat', webPreferences: { preload: join(getElectronMainDirname(), '../preload/index.mjs'), sandbox: false, }, + width: 600.0, }) window.on('ready-to-show', () => window.show()) protectPrivilegedWindowNavigation(window) await setupChatWindowElectronInvokes({ - window, - widgetsManager: params.widgetsManager, - serverChannel: params.serverChannel, - mcpStdioManager: params.mcpStdioManager, i18n: params.i18n, + mcpStdioManager: params.mcpStdioManager, + serverChannel: params.serverChannel, + widgetsManager: params.widgetsManager, + window, }) await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/chat', { diff --git a/apps/stage-tamagotchi/src/main/windows/chat/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/chat/rpc/index.electron.ts index 25f228e9f..24aa2f428 100644 --- a/apps/stage-tamagotchi/src/main/windows/chat/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/chat/rpc/index.electron.ts @@ -15,11 +15,11 @@ import { createWidgetsService } from '../../../services/airi/widgets' import { setupBaseWindowElectronInvokes } from '../../shared/window' export async function setupChatWindowElectronInvokes(params: { - window: BrowserWindow - widgetsManager: WidgetsWindowManager - serverChannel: ServerChannel - mcpStdioManager: McpStdioManager i18n: I18n + mcpStdioManager: McpStdioManager + serverChannel: ServerChannel + widgetsManager: WidgetsWindowManager + window: BrowserWindow }) { // TODO: once we refactored eventa to support window-namespaced contexts, // we can remove the setMaxListeners call below since eventa will be able to dispatch and @@ -28,7 +28,7 @@ export async function setupChatWindowElectronInvokes(params: { const { context } = createContext(ipcMain, params.window) - await setupBaseWindowElectronInvokes({ context, window: params.window, i18n: params.i18n, serverChannel: params.serverChannel }) + await setupBaseWindowElectronInvokes({ context, i18n: params.i18n, serverChannel: params.serverChannel, window: params.window }) createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window }) createMcpServersService({ context, manager: params.mcpStdioManager }) diff --git a/apps/stage-tamagotchi/src/main/windows/dashboard/index.ts b/apps/stage-tamagotchi/src/main/windows/dashboard/index.ts index c52a9498e..87ab7bbfe 100644 --- a/apps/stage-tamagotchi/src/main/windows/dashboard/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/dashboard/index.ts @@ -29,32 +29,32 @@ import { setupDashboardWindowElectronInvokes } from './rpc/index.electron' const appConfigSchema = object({ windows: optional(array(object({ - title: optional(string()), + height: optional(number()), tag: string(), + title: optional(string()), + width: optional(number()), x: optional(number()), y: optional(number()), - width: optional(number()), - height: optional(number()), }))), }) type AppConfig = InferOutput export async function setupDashboardWindow(params: { - settingsWindow: SettingsWindowManager chatWindow: () => Promise + i18n: I18n noticeWindow: NoticeWindowManager onWindowCreated?: (window: BrowserWindow) => void serverChannel: ServerChannel - i18n: I18n + settingsWindow: SettingsWindowManager }) { const { - setup: setupConfig, get: getConfigRaw, + setup: setupConfig, update: updateConfig, } = createConfig('app', 'config.json', appConfigSchema, { - default: { windows: [] }, autoHeal: true, + default: { windows: [] }, }) const getConfig = (): AppConfig => getConfigRaw() ?? { windows: [] } @@ -63,17 +63,17 @@ export async function setupDashboardWindow(params: { const windowConfig = getConfig().windows?.find(w => w.title === 'AIRI Dashboard' && w.tag === 'dashboard') const window = new BrowserWindow({ - title: 'AIRI Dashboard', - width: windowConfig?.width ?? 1200.0, height: windowConfig?.height ?? 600.0, - x: windowConfig?.x, - y: windowConfig?.y, - show: false, icon, + show: false, + title: 'AIRI Dashboard', webPreferences: { preload: join(dirname(fileURLToPath(import.meta.url)), '../preload/index.mjs'), sandbox: false, }, + width: windowConfig?.width ?? 1200.0, + x: windowConfig?.x, + y: windowConfig?.y, }) if (params.onWindowCreated) { @@ -100,16 +100,16 @@ export async function setupDashboardWindow(params: { if (existingConfigIndex === -1) { config.windows.push({ - title: 'AIRI Dashboard', + height: newBounds.height, tag: 'dashboard', + title: 'AIRI Dashboard', + width: newBounds.width, x: newBounds.x, y: newBounds.y, - width: newBounds.width, - height: newBounds.height, }) } else { - const windowConfig = defu(config.windows[existingConfigIndex], { title: 'AIRI Dashboard', tag: 'dashboard' }) + const windowConfig = defu(config.windows[existingConfigIndex], { tag: 'dashboard', title: 'AIRI Dashboard' }) windowConfig.x = newBounds.x windowConfig.y = newBounds.y @@ -129,12 +129,12 @@ export async function setupDashboardWindow(params: { protectPrivilegedWindowNavigation(window) await setupDashboardWindowElectronInvokes({ - window, - settingsWindow: params.settingsWindow, chatWindow: params.chatWindow, - noticeWindow: params.noticeWindow, i18n: params.i18n, + noticeWindow: params.noticeWindow, serverChannel: params.serverChannel, + settingsWindow: params.settingsWindow, + window, }) await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/dashboard', { diff --git a/apps/stage-tamagotchi/src/main/windows/dashboard/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/dashboard/rpc/index.electron.ts index 44628227e..81f1434b2 100644 --- a/apps/stage-tamagotchi/src/main/windows/dashboard/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/dashboard/rpc/index.electron.ts @@ -14,12 +14,12 @@ import { toggleWindowShow } from '../../shared' import { setupBaseWindowElectronInvokes } from '../../shared/window' export async function setupDashboardWindowElectronInvokes(params: { - window: BrowserWindow - settingsWindow: SettingsWindowManager chatWindow: () => Promise - noticeWindow: NoticeWindowManager i18n: I18n + noticeWindow: NoticeWindowManager serverChannel: ServerChannel + settingsWindow: SettingsWindowManager + window: BrowserWindow }) { // TODO: once we refactored eventa to support window-namespaced contexts, // we can remove the setMaxListeners call below since eventa will be able to dispatch and @@ -28,7 +28,7 @@ export async function setupDashboardWindowElectronInvokes(params: { const { context } = createContext(ipcMain, params.window) - await setupBaseWindowElectronInvokes({ context, window: params.window, serverChannel: params.serverChannel, i18n: params.i18n }) + await setupBaseWindowElectronInvokes({ context, i18n: params.i18n, serverChannel: params.serverChannel, window: params.window }) defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' })) defineInvokeHandler(context, electronOpenSettings, payload => params.settingsWindow.openWindow(payload?.route)) diff --git a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts index eef5f658f..29667a556 100644 --- a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts @@ -54,6 +54,23 @@ export function isDesktopOverlayPollHeartbeatEnabled(): boolean { let overlayWindow: BrowserWindow | null = null +/** + * Tear down the overlay window. + */ +export function destroyDesktopOverlay(): void { + if (overlayWindow && !overlayWindow.isDestroyed()) { + overlayWindow.close() + overlayWindow = null + } +} + +/** + * Get the current overlay window instance (if active). + */ +export function getDesktopOverlayWindow(): BrowserWindow | null { + return overlayWindow +} + /** * Create the transparent overlay window covering the full primary display. * The window is: @@ -65,9 +82,9 @@ let overlayWindow: BrowserWindow | null = null * Returns null if AIRI_DESKTOP_OVERLAY is not set. */ export async function setupDesktopOverlayWindow(params: { + i18n: I18n mcpStdioManager: McpStdioManager serverChannel: ServerChannel - i18n: I18n }): Promise { if (!isDesktopOverlayEnabled()) { return null @@ -109,10 +126,10 @@ export async function setupDesktopOverlayWindow(params: { // this window), and all subsequent poll cycles never fire because // the poll loop awaits each call sequentially. await setupDesktopOverlayElectronInvokes({ - window: overlayWindow, + i18n: params.i18n, mcpStdioManager: params.mcpStdioManager, serverChannel: params.serverChannel, - i18n: params.i18n, + window: overlayWindow, }) // Load the overlay renderer page @@ -129,20 +146,3 @@ export async function setupDesktopOverlayWindow(params: { return overlayWindow } - -/** - * Get the current overlay window instance (if active). - */ -export function getDesktopOverlayWindow(): BrowserWindow | null { - return overlayWindow -} - -/** - * Tear down the overlay window. - */ -export function destroyDesktopOverlay(): void { - if (overlayWindow && !overlayWindow.isDestroyed()) { - overlayWindow.close() - overlayWindow = null - } -} diff --git a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/index.electron.test.ts b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/index.electron.test.ts index 349fb199d..d7c152854 100644 --- a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/index.electron.test.ts +++ b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/index.electron.test.ts @@ -53,7 +53,7 @@ describe('setupDesktopOverlayElectronInvokes', () => { }) it('publishes ready after the base window invokes and MCP services are wired', async () => { - let readinessHandler: (() => Promise<{ state: 'booting' | 'ready' | 'degraded', error?: string }>) | undefined + let readinessHandler: (() => Promise<{ error?: string, state: 'booting' | 'degraded' | 'ready' }>) | undefined defineInvokeHandlerMock.mockImplementation((_context, _contract, handler) => { readinessHandler = handler @@ -62,10 +62,10 @@ describe('setupDesktopOverlayElectronInvokes', () => { createMcpServersServiceMock.mockReturnValue(undefined) await setupDesktopOverlayElectronInvokes({ - window, + i18n, mcpStdioManager, serverChannel, - i18n, + window, }) expect(ipcMainMock.setMaxListeners).toHaveBeenCalledWith(0) @@ -77,7 +77,7 @@ describe('setupDesktopOverlayElectronInvokes', () => { }) it('publishes degraded when the base window invokes fail', async () => { - let readinessHandler: (() => Promise<{ state: 'booting' | 'ready' | 'degraded', error?: string }>) | undefined + let readinessHandler: (() => Promise<{ error?: string, state: 'booting' | 'degraded' | 'ready' }>) | undefined defineInvokeHandlerMock.mockImplementation((_context, _contract, handler) => { readinessHandler = handler @@ -85,14 +85,14 @@ describe('setupDesktopOverlayElectronInvokes', () => { setupBaseWindowElectronInvokesMock.mockRejectedValueOnce(new Error('boom')) await setupDesktopOverlayElectronInvokes({ - window, + i18n, mcpStdioManager, serverChannel, - i18n, + window, }) expect(createMcpServersServiceMock).not.toHaveBeenCalled() expect(readinessHandler).toBeDefined() - await expect(readinessHandler!()).resolves.toEqual({ state: 'degraded', error: 'boom' }) + await expect(readinessHandler!()).resolves.toEqual({ error: 'boom', state: 'degraded' }) }) }) diff --git a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/index.electron.ts index 357afc1e3..9f199a6e4 100644 --- a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/index.electron.ts @@ -26,10 +26,10 @@ import { createMcpServersService } from '../../../services/airi/mcp-servers' import { setupBaseWindowElectronInvokes } from '../../shared/window' export async function setupDesktopOverlayElectronInvokes(params: { - window: BrowserWindow + i18n: I18n mcpStdioManager: McpStdioManager serverChannel: ServerChannel - i18n: I18n + window: BrowserWindow }) { // TODO: once we refactored eventa to support window-namespaced contexts, // we can remove the setMaxListeners call below since eventa will be able to dispatch and @@ -45,14 +45,14 @@ export async function setupDesktopOverlayElectronInvokes(params: { }) try { - await setupBaseWindowElectronInvokes({ context, window: params.window, i18n: params.i18n, serverChannel: params.serverChannel }) + await setupBaseWindowElectronInvokes({ context, i18n: params.i18n, serverChannel: params.serverChannel, window: params.window }) createMcpServersService({ context, manager: params.mcpStdioManager }) readiness = { state: 'ready' } } catch (error) { readiness = { - state: 'degraded', error: errorMessageFromValue(error), + state: 'degraded', } // We intentionally don't throw here so the window still opens and // the renderer gracefully detects the degraded state via polling. diff --git a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/window-contract.test.ts b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/window-contract.test.ts index fa6d19f37..dd7f42e82 100644 --- a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/window-contract.test.ts +++ b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/window-contract.test.ts @@ -9,7 +9,7 @@ import { describe('createDesktopOverlayWindowOptions', () => { it('creates non-focusable transparent overlay window options for display bounds', () => { const options = createDesktopOverlayWindowOptions({ - bounds: { x: -222, y: -1080, width: 1920, height: 1080 }, + bounds: { height: 1080, width: 1920, x: -222, y: -1080 }, preloadPath: '/tmp/airi-overlay-preload.js', }) diff --git a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/window-contract.ts b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/window-contract.ts index 17532fcca..24dc51632 100644 --- a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/window-contract.ts +++ b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/window-contract.ts @@ -1,45 +1,5 @@ import type { BrowserWindow, BrowserWindowConstructorOptions, Rectangle } from 'electron' -/** - * Build BrowserWindow options for the desktop grounding overlay. - * - * Use when: - * - Creating the transparent desktop overlay BrowserWindow - * - Testing overlay input-isolation without starting Electron - * - * Expects: - * - `bounds` are Electron screen logical coordinates for the display being covered - * - `preloadPath` is an absolute path to the renderer preload script - * - * Returns: - * - BrowserWindow options that keep the overlay visual-only and non-focusable - */ -export function createDesktopOverlayWindowOptions(params: { - bounds: Rectangle - preloadPath: string -}): BrowserWindowConstructorOptions { - return { - title: 'AIRI Desktop Overlay', - width: params.bounds.width, - height: params.bounds.height, - x: params.bounds.x, - y: params.bounds.y, - show: false, - frame: false, - transparent: true, - alwaysOnTop: true, - skipTaskbar: true, - hasShadow: false, - roundedCorners: false, - focusable: false, - webPreferences: { - preload: params.preloadPath, - sandbox: false, - backgroundThrottling: false, - }, - } -} - /** * Apply input-isolation flags to the desktop grounding overlay. * @@ -62,6 +22,46 @@ export function applyDesktopOverlayInputIsolation( window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) } +/** + * Build BrowserWindow options for the desktop grounding overlay. + * + * Use when: + * - Creating the transparent desktop overlay BrowserWindow + * - Testing overlay input-isolation without starting Electron + * + * Expects: + * - `bounds` are Electron screen logical coordinates for the display being covered + * - `preloadPath` is an absolute path to the renderer preload script + * + * Returns: + * - BrowserWindow options that keep the overlay visual-only and non-focusable + */ +export function createDesktopOverlayWindowOptions(params: { + bounds: Rectangle + preloadPath: string +}): BrowserWindowConstructorOptions { + return { + alwaysOnTop: true, + focusable: false, + frame: false, + hasShadow: false, + height: params.bounds.height, + roundedCorners: false, + show: false, + skipTaskbar: true, + title: 'AIRI Desktop Overlay', + transparent: true, + webPreferences: { + backgroundThrottling: false, + preload: params.preloadPath, + sandbox: false, + }, + width: params.bounds.width, + x: params.bounds.x, + y: params.bounds.y, + } +} + /** * Show the overlay without activating or focusing it. * diff --git a/apps/stage-tamagotchi/src/main/windows/devtools/index.ts b/apps/stage-tamagotchi/src/main/windows/devtools/index.ts index 17e9bf965..0a0a0639d 100644 --- a/apps/stage-tamagotchi/src/main/windows/devtools/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/devtools/index.ts @@ -8,15 +8,15 @@ import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs import { createReusableWindow } from '../../libs/electron/window-manager' import { protectPrivilegedWindowNavigation } from '../shared' +export interface DevtoolsWindowManager { + openWindow: (params: OpenDevtoolsWindowParams) => Promise +} + export interface OpenDevtoolsWindowParams extends Partial { key: string route?: string } -export interface DevtoolsWindowManager { - openWindow: (params: OpenDevtoolsWindowParams) => Promise -} - export function setupDevtoolsWindow(): DevtoolsWindowManager { const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')) const defaultRoute = '/devtools' @@ -29,18 +29,18 @@ export function setupDevtoolsWindow(): DevtoolsWindowManager { const reusable = createReusableWindow(async () => { const window = new BrowserWindow({ - title: 'Devtools', - width: 1020, height: 720, - minWidth: 640, - minHeight: 480, - show: false, icon, + minHeight: 480, + minWidth: 640, + show: false, + title: 'Devtools', webPreferences: { preload: join(getElectronMainDirname(), '../preload/index.mjs'), // Preload exposes Electron APIs and needs Node access. sandbox: false, }, + width: 1020, }) window.on('ready-to-show', () => window.show()) diff --git a/apps/stage-tamagotchi/src/main/windows/editor/index.ts b/apps/stage-tamagotchi/src/main/windows/editor/index.ts index b724e6779..7f7bab226 100644 --- a/apps/stage-tamagotchi/src/main/windows/editor/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/editor/index.ts @@ -34,26 +34,26 @@ export function setupEditorWindowManager(params: { const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')) const reusable = createReusableWindow(async () => { const window = new ElectronBrowserWindow({ - title: 'AIRI Editor', - width: 1200, height: 800, - minWidth: 800, - minHeight: 600, - show: false, icon, + minHeight: 600, + minWidth: 800, + show: false, + title: 'AIRI Editor', webPreferences: { preload: join(getElectronMainDirname(), '../preload/index.mjs'), sandbox: false, }, + width: 1200, }) window.on('ready-to-show', () => window.show()) protectPrivilegedWindowNavigation(window) await setupEditorWindowInvokes({ - window, i18n: params.i18n, serverChannel: params.serverChannel, + window, }) await load(window, withHashRoute(rendererBase, '/editor', { query: { diff --git a/apps/stage-tamagotchi/src/main/windows/editor/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/editor/rpc/index.electron.ts index 7dc1ac88c..161ee5533 100644 --- a/apps/stage-tamagotchi/src/main/windows/editor/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/editor/rpc/index.electron.ts @@ -13,9 +13,9 @@ import { setupBaseWindowElectronInvokes } from '../../shared/window' * Feature-specific RPC handlers should be added here as the editor gains capabilities. */ export async function setupEditorWindowInvokes(params: { - window: BrowserWindow i18n: I18n serverChannel: ServerChannel + window: BrowserWindow }) { // TODO: Remove this once Eventa supports window-namespaced Electron contexts. ipcMain.setMaxListeners(0) @@ -24,9 +24,9 @@ export async function setupEditorWindowInvokes(params: { await setupBaseWindowElectronInvokes({ context, - window: params.window, i18n: params.i18n, serverChannel: params.serverChannel, + window: params.window, }) return context diff --git a/apps/stage-tamagotchi/src/main/windows/inlay/index.ts b/apps/stage-tamagotchi/src/main/windows/inlay/index.ts index 154d809ae..b515f576d 100644 --- a/apps/stage-tamagotchi/src/main/windows/inlay/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/inlay/index.ts @@ -14,19 +14,19 @@ import { protectPrivilegedWindowNavigation, spotlightLikeWindowConfig } from '.. import { setupInlayWindowInvokes } from './rpc/index.electron' export async function setupInlayWindow(params: { - serverChannel: ServerChannel i18n: I18n + serverChannel: ServerChannel }) { const window = new BrowserWindow({ - title: 'Inlay', - width: 450, height: 150, - show: false, icon, + show: false, + title: 'Inlay', webPreferences: { preload: join(getElectronMainDirname(), '../preload/index.mjs'), sandbox: false, }, + width: 450, ...spotlightLikeWindowConfig(), }) @@ -38,25 +38,25 @@ export async function setupInlayWindow(params: { const width = mapForBreakpoints( displayBounds.width, { - '720p': widthFrom(displayBounds, { percentage: 1, max: { percentage: 0.5 } }), - '1080p': widthFrom(displayBounds, { percentage: 1, max: { percentage: 0.33 } }), - '2k': widthFrom(displayBounds, { percentage: 0.25, max: { actual: 710 } }), - '4k': widthFrom(displayBounds, { percentage: 0.2, max: { actual: 768 } }), + '2k': widthFrom(displayBounds, { max: { actual: 710 }, percentage: 0.25 }), + '4k': widthFrom(displayBounds, { max: { actual: 768 }, percentage: 0.2 }), + '720p': widthFrom(displayBounds, { max: { percentage: 0.5 }, percentage: 1 }), + '1080p': widthFrom(displayBounds, { max: { percentage: 0.33 }, percentage: 1 }), }, { breakpoints: resolutionBreakpoints }, ) const height = width / 4 window.setBounds({ - width, height: width / 4, + width, x: displayBounds.x + (displayBounds.width - width) / 2, // Center horizontally y: mapForBreakpoints( displayBounds.height, { - sm: displayBounds.height / 4 * 3 - height, // Bottom quarter, minus window height - md: displayBounds.height / 5 * 4 - height, // Center vertically lg: displayBounds.height / 6 * 5 - height, // Top quarter, minus half window height + md: displayBounds.height / 5 * 4 - height, // Center vertically + sm: displayBounds.height / 4 * 3 - height, // Bottom quarter, minus window height }, ), }) @@ -64,7 +64,7 @@ export async function setupInlayWindow(params: { window.on('ready-to-show', () => window.show()) protectPrivilegedWindowNavigation(window) - await setupInlayWindowInvokes({ inlayWindow: window, serverChannel: params.serverChannel, i18n: params.i18n }) + await setupInlayWindowInvokes({ i18n: params.i18n, inlayWindow: window, serverChannel: params.serverChannel }) await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/inlay', { query: { 'synced-leader': 'false' }, diff --git a/apps/stage-tamagotchi/src/main/windows/inlay/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/inlay/rpc/index.electron.ts index e672e142e..5a3cae7d7 100644 --- a/apps/stage-tamagotchi/src/main/windows/inlay/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/inlay/rpc/index.electron.ts @@ -9,9 +9,9 @@ import { ipcMain } from 'electron' import { setupBaseWindowElectronInvokes } from '../../shared/window' export async function setupInlayWindowInvokes(params: { + i18n: I18n inlayWindow: BrowserWindow serverChannel: ServerChannel - i18n: I18n }) { // TODO: once we refactored eventa to support window-namespaced contexts, // we can remove the setMaxListeners call below since eventa will be able to dispatch and @@ -22,8 +22,8 @@ export async function setupInlayWindowInvokes(params: { await setupBaseWindowElectronInvokes({ context, - window: params.inlayWindow, - serverChannel: params.serverChannel, i18n: params.i18n, + serverChannel: params.serverChannel, + window: params.inlayWindow, }) } diff --git a/apps/stage-tamagotchi/src/main/windows/main/index.ts b/apps/stage-tamagotchi/src/main/windows/main/index.ts index 6e9b32590..6c59824ab 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/index.ts @@ -36,38 +36,38 @@ import { setupMainWindowElectronInvokes } from './rpc/index.electron' const appConfigSchema = object({ windows: optional(array(object({ - title: optional(string()), + height: optional(number()), tag: string(), + title: optional(string()), + width: optional(number()), x: optional(number()), y: optional(number()), - width: optional(number()), - height: optional(number()), }))), }) type AppConfig = InferOutput export async function setupMainWindow(params: { - editorWindow: EditorWindowManager - settingsWindow: SettingsWindowManager - chatWindow: () => Promise - widgetsManager: WidgetsWindowManager - noticeWindow: NoticeWindowManager autoUpdater: AutoUpdater + chatWindow: () => Promise + editorWindow: EditorWindowManager + godotStageManager: GodotStageManager + i18n: I18n + mcpStdioManager: McpStdioManager + noticeWindow: NoticeWindowManager + onboardingWindowManager: OnboardingWindowManager onWindowCreated?: (window: BrowserWindow) => void serverChannel: ServerChannel - godotStageManager: GodotStageManager - mcpStdioManager: McpStdioManager - i18n: I18n - onboardingWindowManager: OnboardingWindowManager + settingsWindow: SettingsWindowManager + widgetsManager: WidgetsWindowManager }) { const { - setup: setupConfig, get: getConfigRaw, + setup: setupConfig, update: updateConfig, } = createConfig('app', 'config.json', appConfigSchema, { - default: { windows: [] }, autoHeal: true, + default: { windows: [] }, }) const getConfig = (): AppConfig => getConfigRaw() ?? { windows: [] } @@ -76,22 +76,22 @@ export async function setupMainWindow(params: { const mainWindowConfig = getConfig().windows?.find(w => w.title === 'AIRI' && w.tag === 'main') const window = new BrowserWindow({ - title: 'AIRI', - width: mainWindowConfig?.width ?? 450.0, height: mainWindowConfig?.height ?? 600.0, - x: mainWindowConfig?.x, - y: mainWindowConfig?.y, - show: false, icon, - webPreferences: { - preload: join(dirname(fileURLToPath(import.meta.url)), '../preload/index.mjs'), - sandbox: false, - }, + show: false, + title: 'AIRI', // Thanks to [@HeartArmy](https://github.com/HeartArmy) for the tip implementation. // // https://github.com/electron/electron/issues/10078#issuecomment-3410164802 // https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac type: 'panel', + webPreferences: { + preload: join(dirname(fileURLToPath(import.meta.url)), '../preload/index.mjs'), + sandbox: false, + }, + width: mainWindowConfig?.width ?? 450.0, + x: mainWindowConfig?.x, + y: mainWindowConfig?.y, ...transparentWindowConfig(), }) @@ -124,16 +124,16 @@ export async function setupMainWindow(params: { if (existingConfigIndex === -1) { config.windows.push({ - title: 'AIRI', + height: newBounds.height, tag: 'main', + title: 'AIRI', + width: newBounds.width, x: newBounds.x, y: newBounds.y, - width: newBounds.width, - height: newBounds.height, }) } else { - const mainWindowConfig = defu(config.windows[existingConfigIndex], { title: 'AIRI', tag: 'main' }) + const mainWindowConfig = defu(config.windows[existingConfigIndex], { tag: 'main', title: 'AIRI' }) mainWindowConfig.x = newBounds.x mainWindowConfig.y = newBounds.y @@ -172,18 +172,18 @@ export async function setupMainWindow(params: { protectPrivilegedWindowNavigation(window) await setupMainWindowElectronInvokes({ - window, - editorWindow: params.editorWindow, - settingsWindow: params.settingsWindow, - chatWindow: params.chatWindow, - widgetsManager: params.widgetsManager, - noticeWindow: params.noticeWindow, autoUpdater: params.autoUpdater, - serverChannel: params.serverChannel, + chatWindow: params.chatWindow, + editorWindow: params.editorWindow, godotStageManager: params.godotStageManager, - mcpStdioManager: params.mcpStdioManager, i18n: params.i18n, + mcpStdioManager: params.mcpStdioManager, + noticeWindow: params.noticeWindow, onboardingWindowManager: params.onboardingWindowManager, + serverChannel: params.serverChannel, + settingsWindow: params.settingsWindow, + widgetsManager: params.widgetsManager, + window, }) await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/', { diff --git a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts index f38190978..1e3e478fd 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts @@ -27,18 +27,18 @@ import { centerWindowOnDisplay } from '../../shared/display' import { setupBaseWindowElectronInvokes } from '../../shared/window' export async function setupMainWindowElectronInvokes(params: { - window: BrowserWindow - editorWindow: EditorWindowManager - settingsWindow: SettingsWindowManager - chatWindow: () => Promise - widgetsManager: WidgetsWindowManager - noticeWindow: NoticeWindowManager autoUpdater: AutoUpdater - serverChannel: ServerChannel + chatWindow: () => Promise + editorWindow: EditorWindowManager godotStageManager: GodotStageManager - mcpStdioManager: McpStdioManager i18n: I18n + mcpStdioManager: McpStdioManager + noticeWindow: NoticeWindowManager onboardingWindowManager: OnboardingWindowManager + serverChannel: ServerChannel + settingsWindow: SettingsWindowManager + widgetsManager: WidgetsWindowManager + window: BrowserWindow }) { // TODO: once we refactored eventa to support window-namespaced contexts, // we can remove the setMaxListeners call below since eventa will be able to dispatch and @@ -47,12 +47,12 @@ export async function setupMainWindowElectronInvokes(params: { const { context } = createContext(ipcMain, params.window) - await setupBaseWindowElectronInvokes({ context, window: params.window, serverChannel: params.serverChannel, i18n: params.i18n }) + await setupBaseWindowElectronInvokes({ context, i18n: params.i18n, serverChannel: params.serverChannel, window: params.window }) createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window }) - createAutoUpdaterService({ context, window: params.window, service: params.autoUpdater }) + createAutoUpdaterService({ context, service: params.autoUpdater, window: params.window }) createMcpServersService({ context, manager: params.mcpStdioManager }) createGodotStageService({ context, manager: params.godotStageManager, window: params.window }) - createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager, mainWindow: params.window }) + createOnboardingService({ context, mainWindow: params.window, onboardingWindowManager: params.onboardingWindowManager }) createAuthService({ context, window: params.window }) defineInvokeHandler(context, electronCenterMainWindow, () => centerWindowOnDisplay(params.window)) diff --git a/apps/stage-tamagotchi/src/main/windows/notice/index.ts b/apps/stage-tamagotchi/src/main/windows/notice/index.ts index f1acb1e26..c8c61bacb 100644 --- a/apps/stage-tamagotchi/src/main/windows/notice/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/notice/index.ts @@ -29,15 +29,15 @@ export function setupNoticeWindowManager(params: { function createWindow(_id: string): BrowserWindow { const window = new ElectronBrowserWindow({ - title: 'Notice', - width: 1020, height: 600, - show: false, icon, + show: false, + title: 'Notice', webPreferences: { preload: join(getElectronMainDirname(), '../preload/index.mjs'), sandbox: false, }, + width: 1020, }) protectPrivilegedWindowNavigation(window) @@ -53,11 +53,11 @@ export function setupNoticeWindowManager(params: { } const manager = createReferencedWindowManager({ + createWindow, eventa: noticeWindowEventa, i18n: params.i18n, - serverChannel: params.serverChannel, - createWindow, loadRoute: loadNoticeRoute, + serverChannel: params.serverChannel, }) return { diff --git a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts index 722f8981f..2469b03aa 100644 --- a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts @@ -19,14 +19,14 @@ import { protectPrivilegedWindowNavigation, toggleWindowShow } from '../shared' import { setupBaseWindowElectronInvokes } from '../shared/window' export interface OnboardingWindowManager { - getWindow: () => Promise getAndToggleWindow: () => Promise + getWindow: () => Promise onClosed: (callback: () => void) => () => void } export function setupOnboardingWindowManager(params: { - serverChannel: ServerChannel i18n: I18n + serverChannel: ServerChannel }): OnboardingWindowManager { const closeCallbacks = new Set<() => void>() @@ -39,22 +39,22 @@ export function setupOnboardingWindowManager(params: { const reusableWindow = createReusableWindow(async () => { const newWindow = new BrowserWindow({ - title: 'Welcome to AIRI', - width: 1000, - height: 650, - minWidth: 400, - minHeight: 500, - show: false, - icon, - resizable: true, + backgroundColor: '#0f0f0f', frame: !isMacOS, + height: 650, + icon, + minHeight: 500, + minWidth: 400, + resizable: true, + show: false, + title: 'Welcome to AIRI', titleBarStyle: isMacOS ? 'hidden' : undefined, transparent: false, - backgroundColor: '#0f0f0f', webPreferences: { preload: join(getElectronMainDirname(), '../preload/index.mjs'), sandbox: false, }, + width: 1000, }) newWindow.on('ready-to-show', () => newWindow.show()) @@ -71,7 +71,7 @@ export function setupOnboardingWindowManager(params: { safeClose(newWindow) }) - await setupBaseWindowElectronInvokes({ context, window: newWindow, i18n: params.i18n, serverChannel: params.serverChannel }) + await setupBaseWindowElectronInvokes({ context, i18n: params.i18n, serverChannel: params.serverChannel, window: newWindow }) createAuthService({ context, window: newWindow }) await load(newWindow, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/onboarding', { @@ -91,8 +91,8 @@ export function setupOnboardingWindowManager(params: { }) return { - getWindow: async () => reusableWindow.getWindow(), getAndToggleWindow: async () => await getOnboardingWindow(reusableWindow.getWindow), + getWindow: async () => reusableWindow.getWindow(), onClosed: (callback: () => void) => { closeCallbacks.add(callback) return () => { diff --git a/apps/stage-tamagotchi/src/main/windows/settings/index.ts b/apps/stage-tamagotchi/src/main/windows/settings/index.ts index fe449364e..096bc8c3a 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/index.ts @@ -27,17 +27,17 @@ export interface SettingsWindowManager { } export function setupSettingsWindowReusableFunc(params: { - widgetsManager: WidgetsWindowManager autoUpdater: AutoUpdater devtoolsWindow: DevtoolsWindowManager getMainWindow?: () => BrowserWindow | undefined + globalShortcut: GlobalShortcutService + godotStageManager: GodotStageManager + i18n: I18n + mcpStdioManager: McpStdioManager onWindowCreated?: (window: BrowserWindow) => void serverChannel: ServerChannel - godotStageManager: GodotStageManager - mcpStdioManager: McpStdioManager - i18n: I18n - globalShortcut: GlobalShortcutService spotlightWindow: SpotlightWindowManager + widgetsManager: WidgetsWindowManager }): SettingsWindowManager { const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')) const defaultRoute = '/settings' @@ -46,15 +46,15 @@ export function setupSettingsWindowReusableFunc(params: { const reusable = createReusableWindow(async () => { const window = new BrowserWindow({ - title: 'Settings', - width: 600.0, height: 800.0, - show: false, icon, + show: false, + title: 'Settings', webPreferences: { preload: join(getElectronMainDirname(), '../preload/index.mjs'), sandbox: false, }, + width: 600.0, }) if (params.onWindowCreated) { @@ -65,17 +65,17 @@ export function setupSettingsWindowReusableFunc(params: { protectPrivilegedWindowNavigation(window) settingsContext = await setupSettingsWindowInvokes({ - settingsWindow: window, - widgetsManager: params.widgetsManager, autoUpdater: params.autoUpdater, devtoolsWindow: params.devtoolsWindow, getMainWindow: params.getMainWindow, - serverChannel: params.serverChannel, - godotStageManager: params.godotStageManager, - mcpStdioManager: params.mcpStdioManager, - i18n: params.i18n, globalShortcut: params.globalShortcut, + godotStageManager: params.godotStageManager, + i18n: params.i18n, + mcpStdioManager: params.mcpStdioManager, + serverChannel: params.serverChannel, + settingsWindow: window, spotlightWindow: params.spotlightWindow, + widgetsManager: params.widgetsManager, }) await load(window, withHashRoute(rendererBase, currentRoute, { diff --git a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts index 3459d1184..9f946bb19 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts @@ -30,17 +30,17 @@ import { centerWindowOnDisplay } from '../../shared/display' import { setupBaseWindowElectronInvokes } from '../../shared/window' export async function setupSettingsWindowInvokes(params: { - settingsWindow: BrowserWindow - widgetsManager: WidgetsWindowManager autoUpdater: AutoUpdater devtoolsWindow: DevtoolsWindowManager getMainWindow?: () => BrowserWindow | undefined - serverChannel: ServerChannel - godotStageManager: GodotStageManager - mcpStdioManager: McpStdioManager - i18n: I18n globalShortcut: GlobalShortcutService + godotStageManager: GodotStageManager + i18n: I18n + mcpStdioManager: McpStdioManager + serverChannel: ServerChannel + settingsWindow: BrowserWindow spotlightWindow: SpotlightWindowManager + widgetsManager: WidgetsWindowManager }) { // TODO: once we refactored eventa to support window-namespaced contexts, // we can remove the setMaxListeners call below since eventa will be able to dispatch and @@ -49,10 +49,10 @@ export async function setupSettingsWindowInvokes(params: { const { context } = createContext(ipcMain, params.settingsWindow) - await setupBaseWindowElectronInvokes({ context, window: params.settingsWindow, i18n: params.i18n, serverChannel: params.serverChannel }) + await setupBaseWindowElectronInvokes({ context, i18n: params.i18n, serverChannel: params.serverChannel, window: params.settingsWindow }) createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.settingsWindow }) - createAutoUpdaterService({ context, window: params.settingsWindow, service: params.autoUpdater }) + createAutoUpdaterService({ context, service: params.autoUpdater, window: params.settingsWindow }) createMcpServersService({ context, manager: params.mcpStdioManager }) createGodotStageService({ context, manager: params.godotStageManager, window: params.settingsWindow }) createAuthService({ context, window: params.settingsWindow }) diff --git a/apps/stage-tamagotchi/src/main/windows/shared/animator.test.ts b/apps/stage-tamagotchi/src/main/windows/shared/animator.test.ts index df8347096..880fc3225 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/animator.test.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/animator.test.ts @@ -27,11 +27,11 @@ function waitForAnimation(): Promise { describe('window bounds animator', () => { it('animates position after it applies the target size', async () => { - const window = createWindow({ x: 10, y: 20, width: 300, height: 400 }) + const window = createWindow({ height: 400, width: 300, x: 10, y: 20 }) const animator = new Animator(window) animator.windowBoundsAnimateTo( - { x: 100, y: 200, width: 450, height: 600 }, + { height: 600, width: 450, x: 100, y: 200 }, { duration: 1 }, ) await waitForAnimation() @@ -41,15 +41,15 @@ describe('window bounds animator', () => { }) it('stops the previous animation before it starts a new animation', async () => { - const window = createWindow({ x: 10, y: 20, width: 300, height: 400 }) + const window = createWindow({ height: 400, width: 300, x: 10, y: 20 }) const animator = new Animator(window) animator.windowBoundsAnimateTo( - { x: 100, y: 100, width: 300, height: 400 }, + { height: 400, width: 300, x: 100, y: 100 }, { duration: 100 }, ) animator.windowBoundsAnimateTo( - { x: 200, y: 200, width: 300, height: 400 }, + { height: 400, width: 300, x: 200, y: 200 }, { duration: 1 }, ) await waitForAnimation() @@ -58,11 +58,11 @@ describe('window bounds animator', () => { }) it('does not start an animation for a destroyed window', () => { - const window = createWindow({ x: 10, y: 20, width: 300, height: 400 }) + const window = createWindow({ height: 400, width: 300, x: 10, y: 20 }) window.isDestroyed.mockReturnValue(true) const animator = new Animator(window) - animator.windowBoundsAnimateTo({ x: 100, y: 200, width: 300, height: 400 }) + animator.windowBoundsAnimateTo({ height: 400, width: 300, x: 100, y: 200 }) expect(window.setPosition).not.toHaveBeenCalled() expect(window.setSize).not.toHaveBeenCalled() diff --git a/apps/stage-tamagotchi/src/main/windows/shared/animator.ts b/apps/stage-tamagotchi/src/main/windows/shared/animator.ts index 082a35bf5..0d91444ba 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/animator.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/animator.ts @@ -2,14 +2,14 @@ import type { BrowserWindow, Rectangle } from 'electron' import { animate, utils } from 'animejs' -type AnimatableWindow = Pick - /** Options for one window bounds animation. */ export interface WindowBoundsAnimationOptions { /** Animation duration in milliseconds. @default 350 */ duration?: number } +type AnimatableWindow = Pick + /** * Owns the active position animation for one Electron window. * @@ -21,6 +21,12 @@ export class Animator { constructor(private readonly window: AnimatableWindow) {} + /** Stops the active animation. */ + stop(): void { + this.animation?.pause() + this.animation = undefined + } + /** Animates the window position to the target bounds. */ windowBoundsAnimateTo(target: Rectangle, options: WindowBoundsAnimationOptions = {}): void { this.stop() @@ -35,8 +41,6 @@ export class Animator { const state = { x: current.x, y: current.y } this.animation = animate(state, { - x: target.x, - y: target.y, duration: options.duration ?? 350, ease: 'outCubic', modifier: utils.round(0), @@ -44,12 +48,8 @@ export class Animator { if (!this.window.isDestroyed()) this.window.setPosition(Math.round(state.x), Math.round(state.y)) }, + x: target.x, + y: target.y, }) } - - /** Stops the active animation. */ - stop(): void { - this.animation?.pause() - this.animation = undefined - } } 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 81b7e833d..036626029 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts @@ -25,20 +25,20 @@ vi.mock('electron', () => ({ describe('mapForBreakpoints', () => { it('should return the correct size based on breakpoints', () => { - const val = mapForBreakpoints(800, { sm: 100, md: 200, lg: 300 }) + const val = mapForBreakpoints(800, { lg: 300, md: 200, sm: 100 }) expect(val).toBe(200) }) it('it should fallback to nearest smaller breakpoint', () => { - const val = mapForBreakpoints(1024, { sm: 100, md: 200 }) // expected to be lg + const val = mapForBreakpoints(1024, { md: 200, sm: 100 }) // expected to be lg expect(val).toBe(200) }) it('it should return the largest supplied size if bounds exceed all breakpoints', () => { - const val1 = mapForBreakpoints(2000, { sm: 100, md: 200 }) // expected to be lg + const val1 = mapForBreakpoints(2000, { md: 200, sm: 100 }) // expected to be lg expect(val1).toBe(200) - const val2 = mapForBreakpoints(2000, { 'sm': 100, 'md': 200, '2xl': 500 }) // expected to be lg + const val2 = mapForBreakpoints(2000, { '2xl': 500, 'md': 200, 'sm': 100 }) // expected to be lg expect(val2).toBe(500) }) }) @@ -53,13 +53,13 @@ describe('widthFrom', () => { }) it('should respect min constraint', () => { - expect(widthFrom({ width: 1000 } as Rectangle, { percentage: 0.1, min: 200 })).toBe(200) + expect(widthFrom({ width: 1000 } as Rectangle, { min: 200, percentage: 0.1 })).toBe(200) expect(widthFrom({ width: 1000 } as Rectangle, { actual: 150, min: 200 })).toBe(200) expect(widthFrom({ width: 1000 } as Rectangle, { actual: 250, min: 200 })).toBe(250) }) it('should respect max constraint', () => { - expect(widthFrom({ width: 1000 } as Rectangle, { percentage: 0.5, max: 400 })).toBe(400) + expect(widthFrom({ width: 1000 } as Rectangle, { max: 400, percentage: 0.5 })).toBe(400) expect(widthFrom({ width: 1000 } as Rectangle, { actual: 450, max: 400 })).toBe(400) expect(widthFrom({ width: 1000 } as Rectangle, { actual: 350, max: 400 })).toBe(350) }) @@ -75,13 +75,13 @@ describe('heightFrom', () => { }) it('should respect min constraint', () => { - expect(heightFrom({ height: 1000 } as Rectangle, { percentage: 0.1, min: 200 })).toBe(200) + expect(heightFrom({ height: 1000 } as Rectangle, { min: 200, percentage: 0.1 })).toBe(200) expect(heightFrom({ height: 1000 } as Rectangle, { actual: 150, min: 200 })).toBe(200) expect(heightFrom({ height: 1000 } as Rectangle, { actual: 250, min: 200 })).toBe(250) }) it('should respect max constraint', () => { - expect(heightFrom({ height: 1000 } as Rectangle, { percentage: 0.5, max: 400 })).toBe(400) + expect(heightFrom({ height: 1000 } as Rectangle, { max: 400, percentage: 0.5 })).toBe(400) expect(heightFrom({ height: 1000 } as Rectangle, { actual: 450, max: 400 })).toBe(400) expect(heightFrom({ height: 1000 } as Rectangle, { actual: 350, max: 400 })).toBe(350) }) @@ -89,23 +89,23 @@ describe('heightFrom', () => { describe('computeResizedBoundsAnchoredToDominantDisplay', () => { const primaryDisplay = { - bounds: { x: 0, y: 0, width: 1920, height: 1080 }, - workArea: { x: 0, y: 25, width: 1920, height: 1055 }, + bounds: { height: 1080, width: 1920, x: 0, y: 0 }, + workArea: { height: 1055, width: 1920, x: 0, y: 25 }, } const secondaryDisplay = { - bounds: { x: 1920, y: 0, width: 1920, height: 1080 }, - workArea: { x: 1920, y: 0, width: 1920, height: 1040 }, + bounds: { height: 1080, width: 1920, x: 1920, y: 0 }, + workArea: { height: 1040, width: 1920, x: 1920, y: 0 }, } const topDisplay = { - bounds: { x: 0, y: -900, width: 1600, height: 900 }, - workArea: { x: 0, y: -900, width: 1600, height: 860 }, + bounds: { height: 900, width: 1600, x: 0, y: -900 }, + workArea: { height: 860, width: 1600, x: 0, y: -900 }, } it('uses the display with the largest overlap when resizing a window across two displays', () => { const bounds = computeResizedBoundsAnchoredToDominantDisplay({ - currentBounds: { x: 1700, y: 220, width: 500, height: 600 }, - targetSize: { width: 450, height: 600 }, + currentBounds: { height: 600, width: 500, x: 1700, y: 220 }, displays: [primaryDisplay, secondaryDisplay], + targetSize: { height: 600, width: 450 }, }) expect(bounds.x).toBe(1920) @@ -116,9 +116,9 @@ describe('computeResizedBoundsAnchoredToDominantDisplay', () => { it('uses the display with the largest overlap across three displays', () => { const bounds = computeResizedBoundsAnchoredToDominantDisplay({ - currentBounds: { x: 1100, y: -700, width: 380, height: 620 }, - targetSize: { width: 450, height: 600 }, + currentBounds: { height: 620, width: 380, x: 1100, y: -700 }, displays: [primaryDisplay, secondaryDisplay, topDisplay], + targetSize: { height: 600, width: 450 }, }) expect(bounds.x).toBe(1030) @@ -129,9 +129,9 @@ describe('computeResizedBoundsAnchoredToDominantDisplay', () => { it('keeps the matching display bottom-right corner anchored when resizing in the bottom-right quadrant', () => { const bounds = computeResizedBoundsAnchoredToDominantDisplay({ - currentBounds: { x: 3420, y: 740, width: 300, height: 250 }, - targetSize: { width: 450, height: 600 }, + currentBounds: { height: 250, width: 300, x: 3420, y: 740 }, displays: [primaryDisplay, secondaryDisplay], + targetSize: { height: 600, width: 450 }, }) expect(bounds.x).toBe(3270) @@ -152,11 +152,11 @@ describe('computeCenteredWindowBounds', () => { */ it('preserves the window size and centers it inside the display work area', () => { const result = computeCenteredWindowBounds({ - displayWorkArea: { x: 0, y: 25, width: 1440, height: 875 }, - windowBounds: { x: 1200, y: 700, width: 450, height: 600 }, + displayWorkArea: { height: 875, width: 1440, x: 0, y: 25 }, + windowBounds: { height: 600, width: 450, x: 1200, y: 700 }, }) - expect(result).toEqual({ x: 495, y: 162, width: 450, height: 600 }) + expect(result).toEqual({ height: 600, width: 450, x: 495, y: 162 }) }) /** @@ -165,11 +165,11 @@ describe('computeCenteredWindowBounds', () => { */ it('supports display work areas with negative origins', () => { const result = computeCenteredWindowBounds({ - displayWorkArea: { x: -1920, y: -1080, width: 1920, height: 1055 }, - windowBounds: { x: -2300, y: -1300, width: 500, height: 620 }, + displayWorkArea: { height: 1055, width: 1920, x: -1920, y: -1080 }, + windowBounds: { height: 620, width: 500, x: -2300, y: -1300 }, }) - expect(result).toEqual({ x: -1210, y: -863, width: 500, height: 620 }) + expect(result).toEqual({ height: 620, width: 500, x: -1210, y: -863 }) }) /** @@ -178,11 +178,11 @@ describe('computeCenteredWindowBounds', () => { */ it('keeps oversized windows anchored inside the display work area origin', () => { const result = computeCenteredWindowBounds({ - displayWorkArea: { x: 120, y: 45, width: 800, height: 500 }, - windowBounds: { x: -2000, y: -900, width: 1000, height: 640 }, + displayWorkArea: { height: 500, width: 800, x: 120, y: 45 }, + windowBounds: { height: 640, width: 1000, x: -2000, y: -900 }, }) - expect(result).toEqual({ x: 120, y: 45, width: 1000, height: 640 }) + expect(result).toEqual({ height: 640, width: 1000, x: 120, y: 45 }) }) }) @@ -196,8 +196,8 @@ describe('centerWindowOnDisplay', () => { * The recovered window receives centered bounds and becomes visible. */ it('sets centered bounds and shows the window', () => { - const windowBounds = { x: 1200, y: 700, width: 450, height: 600 } - const displayWorkArea = { x: 0, y: 25, width: 1440, height: 875 } + const windowBounds = { height: 600, width: 450, x: 1200, y: 700 } + const displayWorkArea = { height: 875, width: 1440, x: 0, y: 25 } const setBounds = vi.fn() const show = vi.fn() vi.mocked(screen.getDisplayMatching).mockReturnValue({ workArea: displayWorkArea } as Electron.Display) @@ -209,9 +209,9 @@ describe('centerWindowOnDisplay', () => { show, }) - expect(result).toEqual({ x: 495, y: 162, width: 450, height: 600 }) + expect(result).toEqual({ height: 600, width: 450, x: 495, y: 162 }) expect(screen.getDisplayMatching).toHaveBeenCalledWith(windowBounds) - expect(setBounds).toHaveBeenCalledWith({ x: 495, y: 162, width: 450, height: 600 }) + expect(setBounds).toHaveBeenCalledWith({ height: 600, width: 450, x: 495, y: 162 }) expect(show).toHaveBeenCalledTimes(1) }) diff --git a/apps/stage-tamagotchi/src/main/windows/shared/display.ts b/apps/stage-tamagotchi/src/main/windows/shared/display.ts index 85d1ef83c..52c44b7aa 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/display.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/display.ts @@ -6,41 +6,20 @@ import { screen } from 'electron' import { findDominantDisplayArea } from '../../../shared/utils/electron/display' -export function currentDisplayBounds(window: BrowserWindow) { - const bounds = window.getBounds() - const nearbyDisplay = screen.getDisplayMatching(bounds) - - return nearbyDisplay.bounds +export interface DominantDisplayResizeOptions { + /** Current window bounds in Electron display coordinates. */ + currentBounds: Rectangle + /** Displays from Electron screen APIs. */ + displays: readonly DisplayArea[] + /** Desired size before display work-area clamping. */ + targetSize: Pick } -/** - * Computes bounds that center a window inside an Electron display work area. - * - * Use when: - * - Recovering a desktop window that was moved outside the visible work area - * - Preserving the current window size while changing only its position - * - * Expects: - * - Both rectangles use Electron logical display coordinates - * - The display work area excludes menu bars, docks, and taskbars - * - * Returns: - * - Centered bounds that preserve the window width and height - */ -export function computeCenteredWindowBounds(options: { - displayWorkArea: Rectangle - windowBounds: Rectangle -}): Rectangle { - const centeredOffsetX = Math.floor((options.displayWorkArea.width - options.windowBounds.width) / 2) - const centeredOffsetY = Math.floor((options.displayWorkArea.height - options.windowBounds.height) / 2) +type Size = number | SizeActual | SizePercentage - return { - x: options.displayWorkArea.x + Math.max(0, centeredOffsetX), - y: options.displayWorkArea.y + Math.max(0, centeredOffsetY), - width: options.windowBounds.width, - height: options.windowBounds.height, - } -} +interface SizeActual { actual: number } + +interface SizePercentage { percentage: number } /** * Centers and reveals an Electron window on the display matching its current bounds. @@ -69,13 +48,33 @@ export function centerWindowOnDisplay(window: Pick - /** Displays from Electron screen APIs. */ - displays: readonly DisplayArea[] +/** + * Computes bounds that center a window inside an Electron display work area. + * + * Use when: + * - Recovering a desktop window that was moved outside the visible work area + * - Preserving the current window size while changing only its position + * + * Expects: + * - Both rectangles use Electron logical display coordinates + * - The display work area excludes menu bars, docks, and taskbars + * + * Returns: + * - Centered bounds that preserve the window width and height + */ +export function computeCenteredWindowBounds(options: { + displayWorkArea: Rectangle + windowBounds: Rectangle +}): Rectangle { + const centeredOffsetX = Math.floor((options.displayWorkArea.width - options.windowBounds.width) / 2) + const centeredOffsetY = Math.floor((options.displayWorkArea.height - options.windowBounds.height) / 2) + + return { + height: options.windowBounds.height, + width: options.windowBounds.width, + x: options.displayWorkArea.x + Math.max(0, centeredOffsetX), + y: options.displayWorkArea.y + Math.max(0, centeredOffsetY), + } } /** @@ -89,8 +88,8 @@ export function computeResizedBoundsAnchoredToDominantDisplay(options: DominantD if (!display) { return { ...options.currentBounds, - width: targetWidth, height: targetHeight, + width: targetWidth, } } @@ -128,21 +127,22 @@ export function computeResizedBoundsAnchoredToDominantDisplay(options: DominantD // window crossed a screen boundary. Clamp after anchoring so resize intent // wins first, then display safety. return { + height, + width, x: Math.round(clamp(x, workArea.x, workAreaRight - width)), y: Math.round(clamp(y, workArea.y, workAreaBottom - height)), - width, - height, } } +export function currentDisplayBounds(window: BrowserWindow) { + const bounds = window.getBounds() + const nearbyDisplay = screen.getDisplayMatching(bounds) + return nearbyDisplay.bounds +} function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max) } -interface SizeActual { actual: number } -interface SizePercentage { percentage: number } -type Size = SizeActual | SizePercentage | number - function evaluateSize(basedOn: number, size: Size) { if (typeof size === 'number') { return size @@ -173,19 +173,19 @@ function evaluateSize(basedOn: number, size: Size) { * 10xl 224rem (3584px) @media (width >= 224rem) { ... } */ export const tailwindBreakpoints = { - 'sm': { min: 640, max: 767 }, - 'md': { min: 768, max: 1023 }, - 'lg': { min: 1024, max: 1279 }, - 'xl': { min: 1280, max: 1535 }, - '2xl': { min: 1536, max: 1791 }, - '3xl': { min: 1792, max: 2047 }, - '4xl': { min: 2048, max: 2303 }, - '5xl': { min: 2304, max: 2559 }, - '6xl': { min: 2560, max: 2815 }, - '7xl': { min: 2816, max: 3071 }, - '8xl': { min: 3072, max: 3327 }, - '9xl': { min: 3328, max: 3583 }, - '10xl': { min: 3584, max: Infinity }, + '2xl': { max: 1791, min: 1536 }, + '3xl': { max: 2047, min: 1792 }, + '4xl': { max: 2303, min: 2048 }, + '5xl': { max: 2559, min: 2304 }, + '6xl': { max: 2815, min: 2560 }, + '7xl': { max: 3071, min: 2816 }, + '8xl': { max: 3327, min: 3072 }, + '9xl': { max: 3583, min: 3328 }, + '10xl': { max: Infinity, min: 3584 }, + 'lg': { max: 1279, min: 1024 }, + 'md': { max: 1023, min: 768 }, + 'sm': { max: 767, min: 640 }, + 'xl': { max: 1535, min: 1280 }, } /** @@ -202,12 +202,109 @@ export const tailwindBreakpoints = { * @see {@link https://en.wikipedia.org/wiki/Display_resolution#Common_display_resolutions} */ export const resolutionBreakpoints = { - '720p': { min: 0, max: 1280 }, - '1080p': { min: 1281, max: 1920 }, - '2k': { min: 1921, max: 2560 }, - '4k': { min: 2561, max: 3840 }, - '5k': { min: 3841, max: 7680 }, - '8k': { min: 7681, max: Infinity }, + '2k': { max: 2560, min: 1921 }, + '4k': { max: 3840, min: 2561 }, + '5k': { max: 7680, min: 3841 }, + '8k': { max: Infinity, min: 7681 }, + '720p': { max: 1280, min: 0 }, + '1080p': { max: 1920, min: 1281 }, +} + +export interface AdjacentPositionResult { + height: number + scale: number + width: number + x: number + y: number +} + +/** + * Compute a position for `target` adjacent to `anchor`, staying within `workArea`. + * + * Compares available space on right, left, and bottom of the anchor and picks the + * side with the most room. Tie-breaking preference: right > left > bottom. + * + * If the target doesn't fit at full size on the best side, it is scaled down + * (preserving aspect ratio) to fit, respecting `minScale`. + */ +export function computeAdjacentPosition( + anchorBounds: Rectangle, + targetSize: { height: number, width: number }, + workArea: Rectangle, + options?: { margin?: number, minScale?: number }, +): AdjacentPositionResult { + const margin = options?.margin ?? 16 + const minScale = options?.minScale ?? 0.5 + + const waRight = workArea.x + workArea.width + const waBottom = workArea.y + workArea.height + + const rightSpace = { h: workArea.height, w: waRight - (anchorBounds.x + anchorBounds.width + margin) } + const leftSpace = { h: workArea.height, w: anchorBounds.x - workArea.x - margin } + const bottomSpace = { h: waBottom - (anchorBounds.y + anchorBounds.height + margin), w: workArea.width } + + function maxScale(space: { h: number, w: number }): number { + if (space.w <= 0 || space.h <= 0) + return 0 + return Math.min(space.w / targetSize.width, space.h / targetSize.height, 1) + } + + const candidates: { scale: number, side: 'bottom' | 'left' | 'right' }[] = [ + { scale: maxScale(rightSpace), side: 'right' }, + { scale: maxScale(leftSpace), side: 'left' }, + { scale: maxScale(bottomSpace), side: 'bottom' }, + ] + + candidates.sort((a, b) => b.scale - a.scale) + const best = candidates[0]! + + const scale = Math.max(best.scale, minScale) + const w = Math.round(targetSize.width * scale) + const h = Math.round(targetSize.height * scale) + + const clampX = (x: number) => Math.min(Math.max(x, workArea.x), waRight - w) + const clampY = (y: number) => Math.min(Math.max(y, workArea.y), waBottom - h) + + const centerY = anchorBounds.y + Math.floor((anchorBounds.height - h) / 2) + + switch (best.side) { + case 'bottom': { + const y = anchorBounds.y + anchorBounds.height + margin + const x = anchorBounds.x + Math.floor((anchorBounds.width - w) / 2) + return { height: h, scale, width: w, x: clampX(x), y: clampY(y) } + } + case 'left': { + const x = anchorBounds.x - w - margin + return { height: h, scale, width: w, x: clampX(x), y: clampY(centerY) } + } + case 'right': { + const x = anchorBounds.x + anchorBounds.width + margin + return { height: h, scale, width: w, x: clampX(x), y: clampY(centerY) } + } + } +} + +/** + * Calculate height based on options similar to how Web CSS does it. + * + * @param bounds + * @param sizeOptions + * @returns height in pixels + */ +export function heightFrom(bounds: Rectangle, sizeOptions: Size & { max?: Size, min?: Size }) { + const val = evaluateSize(bounds.height, sizeOptions) + const min = sizeOptions.min ? evaluateSize(bounds.height, sizeOptions.min) : undefined + const max = sizeOptions.max ? evaluateSize(bounds.height, sizeOptions.max) : undefined + + if (min && val < min) { + return min + } + + if (max && val > max) { + return max + } + + return val } /** @@ -215,10 +312,10 @@ export const resolutionBreakpoints = { * @see {@link https://tailwindcss.com/docs/responsive-design#overview} */ export function mapForBreakpoints< - B extends Record = typeof tailwindBreakpoints, + B extends Record = typeof tailwindBreakpoints, >( basedOn: number, - sizes: { [key in keyof B]?: number } | number, + sizes: number | { [key in keyof B]?: number }, options?: { breakpoints: B }, ) { if (typeof sizes === 'number') { @@ -240,7 +337,7 @@ export function mapForBreakpoints< // Fallback: find nearest-least smallest breakpoint const sortedSizes = Object.entries(sizes) - .map(([key, value]) => ({ key, value, min: breakpoints[key as keyof typeof breakpoints]?.min ?? 0 })) + .map(([key, value]) => ({ key, min: breakpoints[key as keyof typeof breakpoints]?.min ?? 0, value })) .sort((a, b) => b.min - a.min) // Sort descending by min width const fallback = sortedSizes.find(s => s.min <= basedOn) @@ -255,7 +352,7 @@ export function mapForBreakpoints< * @param sizeOptions * @returns width in pixels */ -export function widthFrom(bounds: Rectangle, sizeOptions: Size & { min?: Size, max?: Size }) { +export function widthFrom(bounds: Rectangle, sizeOptions: Size & { max?: Size, min?: Size }) { const val = evaluateSize(bounds.width, sizeOptions) const min = sizeOptions.min ? evaluateSize(bounds.width, sizeOptions.min) : undefined const max = sizeOptions.max ? evaluateSize(bounds.width, sizeOptions.max) : undefined @@ -270,100 +367,3 @@ export function widthFrom(bounds: Rectangle, sizeOptions: Size & { min?: Size, m return val } - -export interface AdjacentPositionResult { - x: number - y: number - width: number - height: number - scale: number -} - -/** - * Compute a position for `target` adjacent to `anchor`, staying within `workArea`. - * - * Compares available space on right, left, and bottom of the anchor and picks the - * side with the most room. Tie-breaking preference: right > left > bottom. - * - * If the target doesn't fit at full size on the best side, it is scaled down - * (preserving aspect ratio) to fit, respecting `minScale`. - */ -export function computeAdjacentPosition( - anchorBounds: Rectangle, - targetSize: { width: number, height: number }, - workArea: Rectangle, - options?: { margin?: number, minScale?: number }, -): AdjacentPositionResult { - const margin = options?.margin ?? 16 - const minScale = options?.minScale ?? 0.5 - - const waRight = workArea.x + workArea.width - const waBottom = workArea.y + workArea.height - - const rightSpace = { w: waRight - (anchorBounds.x + anchorBounds.width + margin), h: workArea.height } - const leftSpace = { w: anchorBounds.x - workArea.x - margin, h: workArea.height } - const bottomSpace = { w: workArea.width, h: waBottom - (anchorBounds.y + anchorBounds.height + margin) } - - function maxScale(space: { w: number, h: number }): number { - if (space.w <= 0 || space.h <= 0) - return 0 - return Math.min(space.w / targetSize.width, space.h / targetSize.height, 1) - } - - const candidates: { side: 'right' | 'left' | 'bottom', scale: number }[] = [ - { side: 'right', scale: maxScale(rightSpace) }, - { side: 'left', scale: maxScale(leftSpace) }, - { side: 'bottom', scale: maxScale(bottomSpace) }, - ] - - candidates.sort((a, b) => b.scale - a.scale) - const best = candidates[0]! - - const scale = Math.max(best.scale, minScale) - const w = Math.round(targetSize.width * scale) - const h = Math.round(targetSize.height * scale) - - const clampX = (x: number) => Math.min(Math.max(x, workArea.x), waRight - w) - const clampY = (y: number) => Math.min(Math.max(y, workArea.y), waBottom - h) - - const centerY = anchorBounds.y + Math.floor((anchorBounds.height - h) / 2) - - switch (best.side) { - case 'right': { - const x = anchorBounds.x + anchorBounds.width + margin - return { x: clampX(x), y: clampY(centerY), width: w, height: h, scale } - } - case 'left': { - const x = anchorBounds.x - w - margin - return { x: clampX(x), y: clampY(centerY), width: w, height: h, scale } - } - case 'bottom': { - const y = anchorBounds.y + anchorBounds.height + margin - const x = anchorBounds.x + Math.floor((anchorBounds.width - w) / 2) - return { x: clampX(x), y: clampY(y), width: w, height: h, scale } - } - } -} - -/** - * Calculate height based on options similar to how Web CSS does it. - * - * @param bounds - * @param sizeOptions - * @returns height in pixels - */ -export function heightFrom(bounds: Rectangle, sizeOptions: Size & { min?: Size, max?: Size }) { - const val = evaluateSize(bounds.height, sizeOptions) - const min = sizeOptions.min ? evaluateSize(bounds.height, sizeOptions.min) : undefined - const max = sizeOptions.max ? evaluateSize(bounds.height, sizeOptions.max) : undefined - - if (min && val < min) { - return min - } - - if (max && val > max) { - return max - } - - return val -} diff --git a/apps/stage-tamagotchi/src/main/windows/shared/referenced-window.ts b/apps/stage-tamagotchi/src/main/windows/shared/referenced-window.ts index 4eca9739f..1d92c7da9 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/referenced-window.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/referenced-window.ts @@ -12,15 +12,15 @@ import { ipcMain } from 'electron' import { setupBaseWindowElectronInvokes } from './window' export interface ReferencedWindowHandle { - id: string - window: BrowserWindow context: ReturnType['context'] eventa: ReturnType + id: string + window: BrowserWindow } export interface ReferencedWindowManager { - open: (payload: Payload & { id?: string }) => Promise close: (id: string) => void + open: (payload: Payload & { id?: string }) => Promise } /** @@ -29,13 +29,13 @@ export interface ReferencedWindowManager(params: { + createWindow: (id: string) => BrowserWindow eventa: ReturnType i18n: I18n - serverChannel: ServerChannel - createWindow: (id: string) => BrowserWindow loadRoute: (window: BrowserWindow, payload: Payload & { id: string }) => Promise + serverChannel: ServerChannel }): ReferencedWindowManager { - const windows = new Map['context'] }>() + const windows = new Map['context'], window: BrowserWindow }>() async function bindContext(id: string, payload: Payload, win: BrowserWindow) { // TODO: once we refactored eventa to support window-namespaced contexts, @@ -47,7 +47,7 @@ export function createReferencedWindowManager { if (req?.id && req.id !== id) return undefined - return { id, type: payload.type, payload: payload.payload } + return { id, payload: payload.payload, type: payload.type } }) defineInvokeHandler(context, params.eventa.pageUnmounted, (req) => { @@ -56,11 +56,11 @@ export function createReferencedWindowManager windows.delete(id)) - return { window: win, context } + return { context, window: win } } async function open(payload: Payload & { id?: string }): Promise { @@ -84,7 +84,7 @@ export function createReferencedWindowManager['context'] - window: BrowserWindow - serverChannel: ServerChannel i18n: I18n + serverChannel: ServerChannel + window: BrowserWindow }) { createScreenService({ context: params.context, window: params.window }) createWindowService({ context: params.context, window: params.window }) @@ -143,7 +111,39 @@ export async function setupBaseWindowElectronInvokes(params: { createPowerMonitorService({ context: params.context, window: params.window }) createSystemPreferencesService({ context: params.context, window: params.window }) - await createI18nService({ context: params.context, window: params.window, i18n: params.i18n }) + await createI18nService({ context: params.context, i18n: params.i18n, window: params.window }) createServerChannelService({ serverChannel: params.serverChannel }) } + +export function spotlightLikeWindowConfig(): BrowserWindowConstructorOptions { + return { + ...blurryWindowConfig(), + titleBarStyle: isMacOS ? 'hidden' : undefined, + } +} + +export function toggleWindowShow(window?: BrowserWindow | null): void { + if (!window) { + return + } + if (isRendererUnavailable(window)) { + return + } + + if (window?.isMinimized()) { + window?.restore() + } + + window?.show() + window?.focus() +} + +export function transparentWindowConfig(): BrowserWindowConstructorOptions { + return { + frame: false, + hasShadow: false, + titleBarStyle: isMacOS ? 'hidden' : undefined, + transparent: true, + } +} diff --git a/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts b/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts index 4979f45b1..52583bfc5 100644 --- a/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts @@ -29,33 +29,20 @@ import { protectPrivilegedWindowNavigation, setupBaseWindowElectronInvokes, tran const SPOTLIGHT_WINDOW_WIDTH = 720 const SPOTLIGHT_WINDOW_HEIGHT = 100 const SPOTLIGHT_SHORTCUT_ID = 'spotlight' -const defaultSpotlightAccelerator: ShortcutAccelerator = { modifiers: ['ctrl', 'shift'], key: 'KeyA' } +const defaultSpotlightAccelerator: ShortcutAccelerator = { key: 'KeyA', modifiers: ['ctrl', 'shift'] } export interface SpotlightWindowManager { - show: () => Promise getShortcutAccelerator: () => ShortcutAccelerator - updateShortcutAccelerator: (accelerator: ShortcutAccelerator | null) => ReturnType -} - -function resolveSpotlightBounds() { - const cursorPoint = screen.getCursorScreenPoint() - const display = screen.getDisplayNearestPoint(cursorPoint) - const { x, y, width } = display.workArea - - return { - x: Math.round(x + (width - SPOTLIGHT_WINDOW_WIDTH) / 2), - y: Math.round(y + display.workArea.height * 0.22), - width: SPOTLIGHT_WINDOW_WIDTH, - height: SPOTLIGHT_WINDOW_HEIGHT, - } + show: () => Promise + updateShortcutAccelerator: (accelerator: null | ShortcutAccelerator) => ReturnType } export function setupSpotlightWindowManager(params: { - serverChannel: ServerChannel - i18n: I18n + appConfig: Config chatWindow: () => Promise globalShortcut: GlobalShortcutService - appConfig: Config + i18n: I18n + serverChannel: ServerChannel }): SpotlightWindowManager { const log = useLogg('spotlight-window').useGlobalConfig() const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')) @@ -82,8 +69,8 @@ export function setupSpotlightWindowManager(params: { function showNotification(body: string, onClick?: () => void) { const notification = new Notification({ - title: 'AIRI', body, + title: 'AIRI', ...(onClick && !isMacOS ? { timeoutType: 'never' as const } : {}), }) resultNotifications.add(notification) @@ -101,21 +88,21 @@ export function setupSpotlightWindowManager(params: { const reusable = createReusableWindow(async () => { const window = new BrowserWindow({ ...transparentWindowConfig(), - titleBarStyle: undefined, - title: 'Spotlight', - width: SPOTLIGHT_WINDOW_WIDTH, + alwaysOnTop: true, height: SPOTLIGHT_WINDOW_HEIGHT, - show: false, - resizable: false, + icon, maximizable: false, minimizable: false, + resizable: false, + show: false, skipTaskbar: true, - alwaysOnTop: true, - icon, + title: 'Spotlight', + titleBarStyle: undefined, webPreferences: { preload: join(getElectronMainDirname(), '../preload/index.mjs'), sandbox: false, }, + width: SPOTLIGHT_WINDOW_WIDTH, }) protectPrivilegedWindowNavigation(window) @@ -123,7 +110,7 @@ export function setupSpotlightWindowManager(params: { window.on('blur', () => window.hide()) const { context } = createContext(ipcMain, window) - await setupBaseWindowElectronInvokes({ context, window, i18n: params.i18n, serverChannel: params.serverChannel }) + await setupBaseWindowElectronInvokes({ context, i18n: params.i18n, serverChannel: params.serverChannel, window }) // Only the Spotlight window may call these private invokes. const isFromSpotlightWindow = (senderId?: number) => window.webContents.id === senderId @@ -164,10 +151,10 @@ export function setupSpotlightWindowManager(params: { function createShortcutBinding(accelerator = getShortcutAccelerator()): ShortcutBinding { return { - id: SPOTLIGHT_SHORTCUT_ID, accelerator, - scope: 'global', description: 'Spotlight', + id: SPOTLIGHT_SHORTCUT_ID, + scope: 'global', } } @@ -177,7 +164,7 @@ export function setupSpotlightWindowManager(params: { }) } - function updateShortcutAccelerator(accelerator: ShortcutAccelerator | null) { + function updateShortcutAccelerator(accelerator: null | ShortcutAccelerator) { const nextAccelerator = accelerator ?? defaultSpotlightAccelerator if (!isSafeSpotlightAccelerator(nextAccelerator)) return { id: SPOTLIGHT_SHORTCUT_ID, ok: false as const, reason: ShortcutFailureReasons.Invalid } @@ -217,3 +204,16 @@ export function setupSpotlightWindowManager(params: { updateShortcutAccelerator, } } + +function resolveSpotlightBounds() { + const cursorPoint = screen.getCursorScreenPoint() + const display = screen.getDisplayNearestPoint(cursorPoint) + const { width, x, y } = display.workArea + + return { + height: SPOTLIGHT_WINDOW_HEIGHT, + width: SPOTLIGHT_WINDOW_WIDTH, + x: Math.round(x + (width - SPOTLIGHT_WINDOW_WIDTH) / 2), + y: Math.round(y + display.workArea.height * 0.22), + } +} diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/iframe-request-coordinator.ts b/apps/stage-tamagotchi/src/main/windows/widgets/iframe-request-coordinator.ts index d160fb194..4ece2a62b 100644 --- a/apps/stage-tamagotchi/src/main/windows/widgets/iframe-request-coordinator.ts +++ b/apps/stage-tamagotchi/src/main/windows/widgets/iframe-request-coordinator.ts @@ -8,23 +8,23 @@ import { randomUUID } from 'node:crypto' const DEFAULT_WIDGET_IFRAME_REQUEST_TIMEOUT_MS = 30000 const WIDGET_IFRAME_REQUEST_CLOSED_MESSAGE = 'Gamelet was closed before the request completed.' -interface PendingWidgetIframeRequest { - id: string - resolve: (result: Record) => void - reject: (error: Error) => void - timeout: ReturnType -} - /** * Runtime hooks used by the widget iframe request coordinator. */ export interface WidgetIframeRequestCoordinatorOptions { /** Emits the main-to-renderer iframe request event after pending state is registered. */ emitRequest: (payload: WidgetsIframeRequestPayload) => void - /** Returns whether the widget id currently has a mounted main-process record. */ - hasWidget: (id: string) => boolean /** Returns whether a renderer relay is available to receive iframe request events. */ hasRelay: () => boolean + /** Returns whether the widget id currently has a mounted main-process record. */ + hasWidget: (id: string) => boolean +} + +interface PendingWidgetIframeRequest { + id: string + reject: (error: Error) => void + resolve: (result: Record) => void + timeout: ReturnType } /** @@ -69,16 +69,16 @@ export function createWidgetIframeRequestCoordinator(options: WidgetIframeReques pendingRequests.set(requestId, { id, - resolve: result => resolve(result as TResponse), reject, + resolve: result => resolve(result as TResponse), timeout, }) }) options.emitRequest({ id, - requestId, payload: payload as WidgetsIframeRequestPayload['payload'], + requestId, timeoutMs, }) @@ -116,9 +116,9 @@ export function createWidgetIframeRequestCoordinator(options: WidgetIframeReques } return { - requestWidgetIframe, publishWidgetIframeRequestResult, - rejectPendingWidgetIframeRequests, rejectAllPendingWidgetIframeRequests, + rejectPendingWidgetIframeRequests, + requestWidgetIframe, } } diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/index.test.ts b/apps/stage-tamagotchi/src/main/windows/widgets/index.test.ts index 3867ad11d..43166716e 100644 --- a/apps/stage-tamagotchi/src/main/windows/widgets/index.test.ts +++ b/apps/stage-tamagotchi/src/main/windows/widgets/index.test.ts @@ -8,47 +8,47 @@ import { createWidgetIframeRequestCoordinator } from './iframe-request-coordinat describe('normalizeWidgetWindowSize', () => { it('returns undefined for missing or unusable base sizes', () => { expect(normalizeWidgetWindowSize()).toBeUndefined() - expect(normalizeWidgetWindowSize({ width: 0, height: 320 })).toBeUndefined() - expect(normalizeWidgetWindowSize({ width: 320, height: -1 })).toBeUndefined() - expect(normalizeWidgetWindowSize({ width: Number.NaN, height: 320 })).toBeUndefined() - expect(normalizeWidgetWindowSize({ width: 320, height: Number.POSITIVE_INFINITY })).toBeUndefined() + expect(normalizeWidgetWindowSize({ height: 320, width: 0 })).toBeUndefined() + expect(normalizeWidgetWindowSize({ height: -1, width: 320 })).toBeUndefined() + expect(normalizeWidgetWindowSize({ height: 320, width: Number.NaN })).toBeUndefined() + expect(normalizeWidgetWindowSize({ height: Number.POSITIVE_INFINITY, width: 320 })).toBeUndefined() }) it('floors valid dimensions and strips invalid optional constraints', () => { const input: WidgetWindowSize = { - width: 620.9, height: 480.4, - minWidth: -10, - minHeight: Number.NaN, - maxWidth: 1280.6, maxHeight: 720.1, + maxWidth: 1280.6, + minHeight: Number.NaN, + minWidth: -10, + width: 620.9, } expect(normalizeWidgetWindowSize(input)).toEqual({ - width: 620, height: 480, - maxWidth: 1280, maxHeight: 720, + maxWidth: 1280, + width: 620, }) }) it('keeps contradictory but numerically valid constraints for later display clamping', () => { const input: WidgetWindowSize = { - width: 900, height: 700, - minWidth: 1200, + maxHeight: 600, maxWidth: 800, minHeight: 900, - maxHeight: 600, + minWidth: 1200, + width: 900, } expect(normalizeWidgetWindowSize(input)).toEqual({ - width: 900, height: 700, - minWidth: 1200, + maxHeight: 600, maxWidth: 800, minHeight: 900, - maxHeight: 600, + minWidth: 1200, + width: 900, }) }) }) @@ -62,8 +62,8 @@ describe('createWidgetIframeRequestCoordinator', () => { const emitRequest = vi.fn() const coordinator = createWidgetIframeRequestCoordinator({ emitRequest, - hasWidget: () => false, hasRelay: () => true, + hasWidget: () => false, }) await expect(coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Gamelet `kit-module:board` is not open.') @@ -74,8 +74,8 @@ describe('createWidgetIframeRequestCoordinator', () => { const emitRequest = vi.fn() const coordinator = createWidgetIframeRequestCoordinator({ emitRequest, - hasWidget: id => id === 'kit-module:board', hasRelay: () => true, + hasWidget: id => id === 'kit-module:board', }) const request = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }) @@ -83,27 +83,27 @@ describe('createWidgetIframeRequestCoordinator', () => { expect(emitted).toEqual({ id: 'kit-module:board', - requestId: expect.any(String), payload: { action: 'snapshot' }, + requestId: expect.any(String), timeoutMs: 30000, }) coordinator.publishWidgetIframeRequestResult({ id: 'kit-module:other-board', - requestId: emitted.requestId, ok: true, + requestId: emitted.requestId, result: { fen: 'wrong-board' }, }) coordinator.publishWidgetIframeRequestResult({ id: 'kit-module:board', - requestId: 'unknown-request', ok: true, + requestId: 'unknown-request', result: { fen: 'unknown-request' }, }) coordinator.publishWidgetIframeRequestResult({ id: 'kit-module:board', - requestId: emitted.requestId, ok: true, + requestId: emitted.requestId, result: { fen: 'fen-after-request' }, }) @@ -114,17 +114,17 @@ describe('createWidgetIframeRequestCoordinator', () => { const emitRequest = vi.fn() const coordinator = createWidgetIframeRequestCoordinator({ emitRequest, - hasWidget: () => true, hasRelay: () => true, + hasWidget: () => true, }) const request = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }) const emitted = emitRequest.mock.calls[0]?.[0] coordinator.publishWidgetIframeRequestResult({ - id: 'kit-module:board', - requestId: emitted.requestId, - ok: false, error: 'Board rejected the snapshot request.', + id: 'kit-module:board', + ok: false, + requestId: emitted.requestId, }) await expect(request).rejects.toThrow('Board rejected the snapshot request.') @@ -135,8 +135,8 @@ describe('createWidgetIframeRequestCoordinator', () => { const emitRequest = vi.fn() const coordinator = createWidgetIframeRequestCoordinator({ emitRequest, - hasWidget: () => true, hasRelay: () => true, + hasWidget: () => true, }) const request = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }, { timeoutMs: 50 }) @@ -147,8 +147,8 @@ describe('createWidgetIframeRequestCoordinator', () => { coordinator.publishWidgetIframeRequestResult({ id: 'kit-module:board', - requestId: emitted.requestId, ok: true, + requestId: emitted.requestId, result: { fen: 'late-result' }, }) @@ -159,8 +159,8 @@ describe('createWidgetIframeRequestCoordinator', () => { const emitRequest = vi.fn() const coordinator = createWidgetIframeRequestCoordinator({ emitRequest, - hasWidget: () => true, hasRelay: () => true, + hasWidget: () => true, }) const request = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 }) @@ -174,8 +174,8 @@ describe('createWidgetIframeRequestCoordinator', () => { const emitRequest = vi.fn() const coordinator = createWidgetIframeRequestCoordinator({ emitRequest, - hasWidget: () => true, hasRelay: () => false, + hasWidget: () => true, }) await expect(coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Gamelet iframe relay is not available.') @@ -186,8 +186,8 @@ describe('createWidgetIframeRequestCoordinator', () => { const emitRequest = vi.fn() const coordinator = createWidgetIframeRequestCoordinator({ emitRequest, - hasWidget: () => true, hasRelay: () => true, + hasWidget: () => true, }) const firstRequest = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 }) diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts index 0a3cceb8a..9c7e6bc49 100644 --- a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts @@ -46,6 +46,32 @@ import { setupWidgetsWindowInvokes } from './rpc/index.electron' * - An imperative manager for opening the widget window and mutating widget state */ export interface WidgetsWindowManager { + /** + * Removes all widgets and closes any live widget windows. + * + * Use when: + * - The overlay surface should reset to an empty state + * + * Expects: + * - No additional input + * + * Returns: + * - Resolves after the registry, renderer, and child windows have been cleared + */ + clearWidgets: () => Promise + /** + * Reads the current snapshot for a single widget id. + * + * Use when: + * - Another service needs to inspect a widget before opening or mutating it + * + * Expects: + * - `id` is the widget identifier to inspect + * + * Returns: + * - The current snapshot, or `undefined` when the widget is unknown + */ + getWidgetSnapshot: (id: string) => undefined | WidgetSnapshot /** * Resolves the shared widgets window instance. * @@ -59,6 +85,8 @@ export interface WidgetsWindowManager { * - The live widgets {@link BrowserWindow}, creating it if necessary */ getWindow: () => Promise + hideWindow: (params?: { id?: string }) => Promise + onWidgetEvent: (listener: (event: { event: Record, id: string }) => void) => () => void /** * Opens the widgets window, optionally focusing a prepared widget route. * @@ -73,6 +101,33 @@ export interface WidgetsWindowManager { * - Resolves after the target window route has been shown */ openWindow: (params?: { id?: string }) => Promise + /** + * Reserves a widget id before content is pushed into the widgets window. + * + * Use when: + * - The caller wants a stable route or window context before rendering + * + * Expects: + * - `options.id`, when provided, should be stable for later reuse + * + * Returns: + * - The prepared widget id bound to a future window context + */ + prepareWidgetWindow: (options?: { id?: string }) => string + publishWidgetEvent: (id: string, event: Record) => void + /** + * Publishes a renderer-to-main iframe request result into the pending request coordinator. + * + * Use when: + * - The widgets renderer reports a completed iframe request + * + * Expects: + * - `result.requestId` matches a request previously emitted by {@link WidgetsWindowManager.requestWidgetIframe} + * + * Returns: + * - Nothing; unknown or mismatched results are ignored + */ + publishWidgetIframeRequestResult: (result: WidgetsIframeRequestResultPayload) => void /** * Inserts or replaces a widget snapshot and renders it in the widgets window. * @@ -87,19 +142,6 @@ export interface WidgetsWindowManager { * - The resolved widget id used for subsequent updates or removal */ pushWidget: (payload: WidgetsAddPayload) => Promise - /** - * Applies partial widget changes to an existing widget snapshot. - * - * Use when: - * - A widget's props, size, or time-to-live must change without respawning it - * - * Expects: - * - `payload.id` references an existing widget managed by this instance - * - * Returns: - * - Resolves after in-memory state and renderer events have been updated - */ - updateWidget: (payload: WidgetsUpdatePayload) => Promise /** * Removes a single widget from the registry and renderer surface. * @@ -113,35 +155,6 @@ export interface WidgetsWindowManager { * - Resolves after the widget has been removed and the renderer notified */ removeWidget: (id: string) => Promise - /** - * Removes all widgets and closes any live widget windows. - * - * Use when: - * - The overlay surface should reset to an empty state - * - * Expects: - * - No additional input - * - * Returns: - * - Resolves after the registry, renderer, and child windows have been cleared - */ - clearWidgets: () => Promise - hideWindow: (params?: { id?: string }) => Promise - /** - * Reads the current snapshot for a single widget id. - * - * Use when: - * - Another service needs to inspect a widget before opening or mutating it - * - * Expects: - * - `id` is the widget identifier to inspect - * - * Returns: - * - The current snapshot, or `undefined` when the widget is unknown - */ - getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined - publishWidgetEvent: (id: string, event: Record) => void - onWidgetEvent: (listener: (event: { id: string, event: Record }) => void) => () => void /** * Sends a correlated request to a mounted widget iframe through the widgets renderer. * @@ -160,110 +173,39 @@ export interface WidgetsWindowManager { options?: { timeoutMs?: number }, ) => Promise /** - * Publishes a renderer-to-main iframe request result into the pending request coordinator. + * Applies partial widget changes to an existing widget snapshot. * * Use when: - * - The widgets renderer reports a completed iframe request + * - A widget's props, size, or time-to-live must change without respawning it * * Expects: - * - `result.requestId` matches a request previously emitted by {@link WidgetsWindowManager.requestWidgetIframe} + * - `payload.id` references an existing widget managed by this instance * * Returns: - * - Nothing; unknown or mismatched results are ignored + * - Resolves after in-memory state and renderer events have been updated */ - publishWidgetIframeRequestResult: (result: WidgetsIframeRequestResultPayload) => void - /** - * Reserves a widget id before content is pushed into the widgets window. - * - * Use when: - * - The caller wants a stable route or window context before rendering - * - * Expects: - * - `options.id`, when provided, should be stable for later reuse - * - * Returns: - * - The prepared widget id bound to a future window context - */ - prepareWidgetWindow: (options?: { id?: string }) => string + updateWidget: (payload: WidgetsUpdatePayload) => Promise } const widgetsWindowConfigSchema = object({ bounds: optional(object({ + height: number(), + width: number(), x: number(), y: number(), - width: number(), - height: number(), })), }) -type WidgetsWindowConfig = InferOutput - -function computeDefaultBounds(): Rectangle { - const primary = screen.getPrimaryDisplay().workArea - const width = Math.min(500, Math.floor(primary.width * 0.35)) - const height = Math.min(500, Math.floor(primary.height * 0.6)) - const x = primary.x + primary.width - width - 16 - const y = primary.y + 16 - return { x, y, width, height } -} - -function resolveWindowSizeFromPayload(payload: Pick) { - const explicitWindowSize = normalizeWidgetWindowSize(payload.windowSize) - if (explicitWindowSize) - return explicitWindowSize - - if (payload.componentName?.trim().toLowerCase() !== 'plugin-module') - return undefined - - const pluginModulePayload = payload.componentProps as PluginModuleWidgetPayload | undefined - return normalizeWidgetWindowSize(pluginModulePayload?.windowSize) -} - -function createWidgetsWindow() { - const window = new ElectronBrowserWindow({ - title: 'Widgets', - width: 620, - height: 760, - show: false, - icon, - webPreferences: { - preload: join(getElectronMainDirname(), '../preload/index.mjs'), - sandbox: false, - }, - // Top-level overlay style like other overlay windows - type: 'panel', - ...transparentWindowConfig(), - ...spotlightLikeWindowConfig(), - }) - - window.setFullScreenable(false) - window.setVisibleOnAllWorkspaces(true) - if (isMacOS) - window.setWindowButtonVisibility(false) - - window.on('ready-to-show', () => window.show()) - protectPrivilegedWindowNavigation(window) - - return window -} - -function applyAlwaysOnTop(window: BrowserWindow, enabled: boolean) { - if (enabled) { - window.setAlwaysOnTop(true, 'screen-saver', 1) - return - } - - window.setAlwaysOnTop(false) -} - interface WidgetRecord extends WidgetSnapshot { timer?: ReturnType } +type WidgetsWindowConfig = InferOutput + interface WidgetWindowContext { widgetId: string - windowBuilder: () => Promise window?: BrowserWindow + windowBuilder: () => Promise } /** @@ -288,24 +230,24 @@ interface WidgetWindowContext { * -> {@link createContext} */ export function setupWidgetsWindowManager(params: { - serverChannel: ServerChannel i18n: I18n + serverChannel: ServerChannel }): WidgetsWindowManager { - const { setup, get: getConfigRaw, update } = createConfig('windows-widgets', 'config.json', widgetsWindowConfigSchema, { - default: {}, + const { get: getConfigRaw, setup, update } = createConfig('windows-widgets', 'config.json', widgetsWindowConfigSchema, { autoHeal: true, + default: {}, }) const getConfig = (): WidgetsWindowConfig => getConfigRaw() ?? {} setup() let eventaContext: ReturnType['context'] | undefined const widgetRecords = new Map() - const widgetEventListeners = new Set<(event: { id: string, event: Record }) => void>() + const widgetEventListeners = new Set<(event: { event: Record, id: string }) => void>() const windowContexts = new Map() const iframeRequests = createWidgetIframeRequestCoordinator({ - hasWidget: id => widgetRecords.has(id), - hasRelay: () => Boolean(eventaContext), emitRequest: payload => eventaContext?.emit(widgetsIframeRequestEvent, payload), + hasRelay: () => Boolean(eventaContext), + hasWidget: id => widgetRecords.has(id), }) const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')) @@ -316,7 +258,7 @@ export function setupWidgetsWindowManager(params: { let activeWidgetsWindow: BrowserWindow | undefined let persistWindowBounds = true - let widgetsManager: WidgetsWindowManager | undefined + let widgetsManager: undefined | WidgetsWindowManager const reusable = createReusableWindow(async () => { // TODO: once we refactored eventa to support window-namespaced contexts, @@ -333,10 +275,10 @@ export function setupWidgetsWindowManager(params: { if (saved) { const work = screen.getDisplayMatching(saved).workArea const clamped: Rectangle = { + height: Math.min(saved.height, work.height), + width: Math.min(saved.width, work.width), x: Math.min(Math.max(saved.x, work.x), work.x + work.width - saved.width), y: Math.min(Math.max(saved.y, work.y), work.y + work.height - saved.height), - width: Math.min(saved.width, work.width), - height: Math.min(saved.height, work.height), } window.setBounds(clamped) } @@ -354,10 +296,10 @@ export function setupWidgetsWindowManager(params: { const initialRoute = pendingRoute ?? defaultRoute await setupWidgetsWindowInvokes({ - widgetWindow: window, - widgetsManager: widgetsManager!, i18n: params.i18n, serverChannel: params.serverChannel, + widgetsManager: widgetsManager!, + widgetWindow: window, }) await loadWithRoute(window, initialRoute) @@ -396,8 +338,8 @@ export function setupWidgetsWindowManager(params: { if (!windowContexts.has(id)) { windowContexts.set(id, { widgetId: id, - windowBuilder: () => getWindow(), window: undefined, + windowBuilder: () => getWindow(), }) } return id @@ -453,10 +395,10 @@ export function setupWidgetsWindowManager(params: { const width = Math.min(saved.width, work.width) const height = Math.min(saved.height, work.height) const clamped: Rectangle = { + height, + width, x: clamp(saved.x, work.x, work.x + work.width - width), y: clamp(saved.y, work.y, work.y + work.height - height), - width, - height, } window.setBounds(clamped) return @@ -490,10 +432,10 @@ export function setupWidgetsWindowManager(params: { window.setMinimumSize(minWidth, minHeight) window.setMaximumSize(maxWidth, maxHeight) window.setBounds({ + height, + width, x: clamp(currentBounds.x, work.x, work.x + work.width - width), y: clamp(currentBounds.y, work.y, work.y + work.height - height), - width, - height, }) } @@ -572,13 +514,13 @@ export function setupWidgetsWindowManager(params: { async function pushWidget(payload: WidgetsAddPayload): Promise { const id = prepareWidgetWindow({ id: payload.id }) const snapshot: WidgetSnapshot = { - id, + alwaysOnTop: payload.alwaysOnTop ?? false, componentName: payload.componentName, componentProps: payload.componentProps ?? {}, - alwaysOnTop: payload.alwaysOnTop ?? false, + id, size: payload.size ?? 'm', - windowSize: resolveWindowSizeFromPayload(payload), ttlMs: payload.ttlMs ?? 0, + windowSize: resolveWindowSizeFromPayload(payload), } upsertRecord(snapshot) const context = windowContexts.get(id) @@ -610,11 +552,11 @@ export function setupWidgetsWindowManager(params: { const nextSnapshot: WidgetSnapshot = { ...toSnapshot(existing), - componentProps: payload.componentProps ?? existing.componentProps, alwaysOnTop: payload.alwaysOnTop ?? existing.alwaysOnTop, + componentProps: payload.componentProps ?? existing.componentProps, size: payload.size ?? existing.size, - windowSize: normalizeWidgetWindowSize(payload.windowSize) ?? existing.windowSize, ttlMs: payload.ttlMs ?? existing.ttlMs, + windowSize: normalizeWidgetWindowSize(payload.windowSize) ?? existing.windowSize, } upsertRecord(nextSnapshot) @@ -627,12 +569,12 @@ export function setupWidgetsWindowManager(params: { } eventaContext?.emit(widgetsUpdateEvent, { - id: nextSnapshot.id, - componentProps: nextSnapshot.componentProps, alwaysOnTop: nextSnapshot.alwaysOnTop, + componentProps: nextSnapshot.componentProps, + id: nextSnapshot.id, size: nextSnapshot.size, - windowSize: nextSnapshot.windowSize, ttlMs: nextSnapshot.ttlMs, + windowSize: nextSnapshot.windowSize, }) } @@ -711,11 +653,11 @@ export function setupWidgetsWindowManager(params: { function publishWidgetEvent(id: string, event: Record) { for (const listener of widgetEventListeners) { - listener({ id, event }) + listener({ event, id }) } } - function onWidgetEvent(listener: (event: { id: string, event: Record }) => void) { + function onWidgetEvent(listener: (event: { event: Record, id: string }) => void) { widgetEventListeners.add(listener) return () => { widgetEventListeners.delete(listener) @@ -743,20 +685,78 @@ export function setupWidgetsWindowManager(params: { } widgetsManager = { - getWindow, - openWindow, - pushWidget, - updateWidget, - removeWidget, clearWidgets, - hideWindow, getWidgetSnapshot, - publishWidgetEvent, + getWindow, + hideWindow, onWidgetEvent, - requestWidgetIframe, - publishWidgetIframeRequestResult, + openWindow, prepareWidgetWindow, + publishWidgetEvent, + publishWidgetIframeRequestResult, + pushWidget, + removeWidget, + requestWidgetIframe, + updateWidget, } return widgetsManager! } + +function applyAlwaysOnTop(window: BrowserWindow, enabled: boolean) { + if (enabled) { + window.setAlwaysOnTop(true, 'screen-saver', 1) + return + } + + window.setAlwaysOnTop(false) +} + +function computeDefaultBounds(): Rectangle { + const primary = screen.getPrimaryDisplay().workArea + const width = Math.min(500, Math.floor(primary.width * 0.35)) + const height = Math.min(500, Math.floor(primary.height * 0.6)) + const x = primary.x + primary.width - width - 16 + const y = primary.y + 16 + return { height, width, x, y } +} + +function createWidgetsWindow() { + const window = new ElectronBrowserWindow({ + height: 760, + icon, + show: false, + title: 'Widgets', + // Top-level overlay style like other overlay windows + type: 'panel', + webPreferences: { + preload: join(getElectronMainDirname(), '../preload/index.mjs'), + sandbox: false, + }, + width: 620, + ...transparentWindowConfig(), + ...spotlightLikeWindowConfig(), + }) + + window.setFullScreenable(false) + window.setVisibleOnAllWorkspaces(true) + if (isMacOS) + window.setWindowButtonVisibility(false) + + window.on('ready-to-show', () => window.show()) + protectPrivilegedWindowNavigation(window) + + return window +} + +function resolveWindowSizeFromPayload(payload: Pick) { + const explicitWindowSize = normalizeWidgetWindowSize(payload.windowSize) + if (explicitWindowSize) + return explicitWindowSize + + if (payload.componentName?.trim().toLowerCase() !== 'plugin-module') + return undefined + + const pluginModulePayload = payload.componentProps as PluginModuleWidgetPayload | undefined + return normalizeWidgetWindowSize(pluginModulePayload?.windowSize) +} diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/widgets/rpc/index.electron.ts index bcb4334b3..8cadf1513 100644 --- a/apps/stage-tamagotchi/src/main/windows/widgets/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/widgets/rpc/index.electron.ts @@ -11,10 +11,10 @@ import { createWidgetsService } from '../../../services/airi/widgets' import { setupBaseWindowElectronInvokes } from '../../shared/window' export async function setupWidgetsWindowInvokes(params: { - widgetWindow: BrowserWindow - widgetsManager: WidgetsWindowManager i18n: I18n serverChannel: ServerChannel + widgetsManager: WidgetsWindowManager + widgetWindow: BrowserWindow }) { // TODO: once we refactored eventa to support window-namespaced contexts, // we can remove the setMaxListeners call below since eventa will be able to dispatch and @@ -23,7 +23,7 @@ export async function setupWidgetsWindowInvokes(params: { const { context } = createContext(ipcMain, params.widgetWindow) - setupBaseWindowElectronInvokes({ context, window: params.widgetWindow, i18n: params.i18n, serverChannel: params.serverChannel }) + setupBaseWindowElectronInvokes({ context, i18n: params.i18n, serverChannel: params.serverChannel, window: params.widgetWindow }) createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.widgetWindow }) } diff --git a/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.test.ts b/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.test.ts index b3e06656b..bdecf64a3 100644 --- a/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.test.ts +++ b/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.test.ts @@ -53,18 +53,18 @@ describe('electron auth callback bridge', () => { await handler?.({ body: { accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', - idToken: 'new-id-token', expiresIn: 3600, + idToken: 'new-id-token', + refreshToken: 'new-refresh-token', }, }) expect(authMocks.completeSignIn).toHaveBeenCalledWith({ accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', - idToken: 'new-id-token', - expiresIn: 3600, clientId: 'airi-stage-electron', + expiresIn: 3600, + idToken: 'new-id-token', + refreshToken: 'new-refresh-token', }) expect(eventHandlers.has(electronAuthCallbackError)).toBe(true) }) diff --git a/apps/stage-tamagotchi/src/renderer/bridges/stage-three-runtime-trace.ts b/apps/stage-tamagotchi/src/renderer/bridges/stage-three-runtime-trace.ts index 01bbc3801..114e524c1 100644 --- a/apps/stage-tamagotchi/src/renderer/bridges/stage-three-runtime-trace.ts +++ b/apps/stage-tamagotchi/src/renderer/bridges/stage-three-runtime-trace.ts @@ -44,11 +44,6 @@ let releaseRelayTrace: (() => void) | undefined const remoteSubscribers = new Set() const latestEnvelopes = new Map() -function getChannel() { - channel ??= new BroadcastChannel(STAGE_THREE_RUNTIME_TRACE_CHANNEL) - return channel -} - export function getStageThreeRuntimeTraceBroadcastContext() { broadcastContext ??= createBroadcastChannelContext(getChannel()).context return broadcastContext @@ -58,53 +53,6 @@ export function getStageThreeRuntimeTraceBroadcastOriginId() { return instanceId } -function applyCollectionState(active: boolean) { - if (active) { - releaseRelayTrace ??= acquireStageThreeRuntimeTrace(relayTraceLeaseToken) - return - } - - releaseRelayTrace?.() - releaseRelayTrace = undefined -} - -function emitTraceEnvelope(envelope: StageThreeRuntimeTraceEnvelope) { - latestEnvelopes.set(envelope.type, envelope) - getStageThreeRuntimeTraceBroadcastContext().emit(stageThreeRuntimeTraceForwardedEvent, { - envelope, - origin: instanceId, - }) -} - -function replayLatestTraceEnvelopes() { - const context = getStageThreeRuntimeTraceBroadcastContext() - - for (const type of replayOrder) { - const envelope = latestEnvelopes.get(type) - if (!envelope) - continue - - context.emit(stageThreeRuntimeTraceForwardedEvent, { - envelope, - origin: instanceId, - }) - } -} - -function subscribeTraceEvent(eventa: Eventa, createEnvelope: (payload: T) => StageThreeRuntimeTraceEnvelope) { - localTraceContext.on(eventa, (event) => { - if (!event?.body) - return - - emitTraceEnvelope(createEnvelope(event.body)) - }) -} - -export async function setStageThreeRuntimeTraceRemoteSubscription(active: boolean) { - const eventa = active ? stageThreeRuntimeTraceRemoteEnableEvent : stageThreeRuntimeTraceRemoteDisableEvent - getStageThreeRuntimeTraceBroadcastContext().emit(eventa, { origin: instanceId }) -} - export function initializeStageThreeRuntimeTraceBridge() { if (initialized) return @@ -132,12 +80,64 @@ export function initializeStageThreeRuntimeTraceBridge() { applyCollectionState(remoteSubscribers.size > 0) }) - subscribeTraceEvent(stageThreeTraceRenderInfoEvent, payload => ({ type: 'three-render-info', payload })) - subscribeTraceEvent(stageThreeTraceHitTestReadEvent, payload => ({ type: 'three-hit-test-read', payload })) - subscribeTraceEvent(stageThreeTraceVrmUpdateFrameEvent, payload => ({ type: 'vrm-update-frame', payload })) - subscribeTraceEvent(stageThreeTraceVrmLoadStartEvent, payload => ({ type: 'vrm-load-start', payload })) - subscribeTraceEvent(stageThreeTraceVrmLoadEndEvent, payload => ({ type: 'vrm-load-end', payload })) - subscribeTraceEvent(stageThreeTraceVrmLoadErrorEvent, payload => ({ type: 'vrm-load-error', payload })) - subscribeTraceEvent(stageThreeTraceVrmDisposeStartEvent, payload => ({ type: 'vrm-dispose-start', payload })) - subscribeTraceEvent(stageThreeTraceVrmDisposeEndEvent, payload => ({ type: 'vrm-dispose-end', payload })) + subscribeTraceEvent(stageThreeTraceRenderInfoEvent, payload => ({ payload, type: 'three-render-info' })) + subscribeTraceEvent(stageThreeTraceHitTestReadEvent, payload => ({ payload, type: 'three-hit-test-read' })) + subscribeTraceEvent(stageThreeTraceVrmUpdateFrameEvent, payload => ({ payload, type: 'vrm-update-frame' })) + subscribeTraceEvent(stageThreeTraceVrmLoadStartEvent, payload => ({ payload, type: 'vrm-load-start' })) + subscribeTraceEvent(stageThreeTraceVrmLoadEndEvent, payload => ({ payload, type: 'vrm-load-end' })) + subscribeTraceEvent(stageThreeTraceVrmLoadErrorEvent, payload => ({ payload, type: 'vrm-load-error' })) + subscribeTraceEvent(stageThreeTraceVrmDisposeStartEvent, payload => ({ payload, type: 'vrm-dispose-start' })) + subscribeTraceEvent(stageThreeTraceVrmDisposeEndEvent, payload => ({ payload, type: 'vrm-dispose-end' })) +} + +export async function setStageThreeRuntimeTraceRemoteSubscription(active: boolean) { + const eventa = active ? stageThreeRuntimeTraceRemoteEnableEvent : stageThreeRuntimeTraceRemoteDisableEvent + getStageThreeRuntimeTraceBroadcastContext().emit(eventa, { origin: instanceId }) +} + +function applyCollectionState(active: boolean) { + if (active) { + releaseRelayTrace ??= acquireStageThreeRuntimeTrace(relayTraceLeaseToken) + return + } + + releaseRelayTrace?.() + releaseRelayTrace = undefined +} + +function emitTraceEnvelope(envelope: StageThreeRuntimeTraceEnvelope) { + latestEnvelopes.set(envelope.type, envelope) + getStageThreeRuntimeTraceBroadcastContext().emit(stageThreeRuntimeTraceForwardedEvent, { + envelope, + origin: instanceId, + }) +} + +function getChannel() { + channel ??= new BroadcastChannel(STAGE_THREE_RUNTIME_TRACE_CHANNEL) + return channel +} + +function replayLatestTraceEnvelopes() { + const context = getStageThreeRuntimeTraceBroadcastContext() + + for (const type of replayOrder) { + const envelope = latestEnvelopes.get(type) + if (!envelope) + continue + + context.emit(stageThreeRuntimeTraceForwardedEvent, { + envelope, + origin: instanceId, + }) + } +} + +function subscribeTraceEvent(eventa: Eventa, createEnvelope: (payload: T) => StageThreeRuntimeTraceEnvelope) { + localTraceContext.on(eventa, (event) => { + if (!event?.body) + return + + emitTraceEnvelope(createEnvelope(event.body)) + }) } diff --git a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.browser.test.ts b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.browser.test.ts index f6c24e433..d9b139a96 100644 --- a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.browser.test.ts +++ b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.browser.test.ts @@ -21,42 +21,42 @@ import InteractiveArea from './InteractiveArea.vue' function createTestI18n() { return createI18n({ + fallbackWarn: false, legacy: false, locale: 'en', - missingWarn: false, - fallbackWarn: false, messages: { en: {} }, + missingWarn: false, }) } async function renderArea(component: Component = InteractiveArea) { const sessionB: ChatSessionMeta = { - sessionId: 'session-b', - userId: 'local', characterId: 'default', createdAt: 1, + sessionId: 'session-b', updatedAt: 1, + userId: 'local', } const sessionA: ChatSessionMeta = { ...sessionB, - sessionId: 'session-a', createdAt: 2, + sessionId: 'session-a', updatedAt: 2, } const pinia = createPinia() pinia.state.value = { - 'chat-session-selection': { activeSessionId: 'session-b' }, 'chat-session': { - sessionMetas: { 'session-a': sessionA, 'session-b': sessionB }, sessionMessages: { - 'session-a': [{ id: 'system-a', role: 'system', content: 'session A prompt' }], - 'session-b': [{ id: 'system', role: 'system', content: 'system prompt' }], + 'session-a': [{ content: 'session A prompt', id: 'system-a', role: 'system' }], + 'session-b': [{ content: 'system prompt', id: 'system', role: 'system' }], }, + sessionMetas: { 'session-a': sessionA, 'session-b': sessionB }, }, + 'chat-session-selection': { activeSessionId: 'session-b' }, } const router = createRouter({ history: createMemoryHistory(), - routes: [{ path: '/', component: { template: '
' } }], + routes: [{ component: { template: '
' }, path: '/' }], }) await router.push('/') await router.isReady() @@ -92,23 +92,23 @@ describe('interactive area synchronized state', () => { chat.$patch({ activeSendSessionId: 'session-b', activeStreamingMessage: { + content: 'Follower B live response', + createdAt: 2, id: 'follower-b-stream', role: 'assistant', - content: 'Follower B live response', - slices: [{ type: 'text', text: 'Follower B live response' }], + slices: [{ text: 'Follower B live response', type: 'text' }], tool_results: [], - createdAt: 2, }, sending: true, }) chatStream.$patch({ streamingMessage: { + content: 'Leader A foreground response', + createdAt: 3, id: 'leader-a-stream', role: 'assistant', - content: 'Leader A foreground response', - slices: [{ type: 'text', text: 'Leader A foreground response' }], + slices: [{ text: 'Leader A foreground response', type: 'text' }], tool_results: [], - createdAt: 3, }, }) await nextTick() @@ -128,23 +128,23 @@ describe('interactive area synchronized state', () => { chat.$patch({ activeSendSessionId: 'session-a', activeStreamingMessage: { + content: 'Session A live response', + createdAt: 2, id: 'session-a-stream', role: 'assistant', - content: 'Session A live response', - slices: [{ type: 'text', text: 'Session A live response' }], + slices: [{ text: 'Session A live response', type: 'text' }], tool_results: [], - createdAt: 2, }, sending: true, }) chatStream.$patch({ streamingMessage: { + content: 'Session A live response', + createdAt: 2, id: 'session-a-foreground', role: 'assistant', - content: 'Session A live response', - slices: [{ type: 'text', text: 'Session A live response' }], + slices: [{ text: 'Session A live response', type: 'text' }], tool_results: [], - createdAt: 2, }, }) await nextTick() @@ -153,12 +153,12 @@ describe('interactive area synchronized state', () => { chat.$patch({ activeSendSessionId: 'session-b', activeStreamingMessage: { + content: 'Session B live response', + createdAt: 3, id: 'session-b-stream', role: 'assistant', - content: 'Session B live response', - slices: [{ type: 'text', text: 'Session B live response' }], + slices: [{ text: 'Session B live response', type: 'text' }], tool_results: [], - createdAt: 3, }, }) await nextTick() @@ -176,23 +176,23 @@ describe('interactive area synchronized state', () => { chat.$patch({ activeSendSessionId: 'session-b', activeStreamingMessage: { + content: 'Session B web response', + createdAt: 2, id: 'session-b-web-stream', role: 'assistant', - content: 'Session B web response', - slices: [{ type: 'text', text: 'Session B web response' }], + slices: [{ text: 'Session B web response', type: 'text' }], tool_results: [], - createdAt: 2, }, sending: true, }) chatStream.$patch({ streamingMessage: { + content: 'Session A foreground response', + createdAt: 3, id: 'session-a-web-foreground', role: 'assistant', - content: 'Session A foreground response', - slices: [{ type: 'text', text: 'Session A foreground response' }], + slices: [{ text: 'Session A foreground response', type: 'text' }], tool_results: [], - createdAt: 3, }, }) await nextTick() diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.test.ts b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.test.ts index 0d3edd917..a4aff5e89 100644 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.test.ts +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.test.ts @@ -6,13 +6,13 @@ import { createApp, h, nextTick, ref } from 'vue' import ControlsIslandAuthButton from './controls-island-auth-button.vue' const authState = { - isAuthenticated: ref(true), - user: ref<{ name: string, image?: string }>({ - name: 'Rainbow Bird', - image: 'https://example.com/broken-avatar.png', - }), - needsLogin: ref(false), credits: ref(9620), + isAuthenticated: ref(true), + needsLogin: ref(false), + user: ref<{ image?: string, name: string }>({ + image: 'https://example.com/broken-avatar.png', + name: 'Rainbow Bird', + }), } vi.mock('@proj-airi/stage-ui/stores/auth', () => ({ diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-root.test.ts b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-root.test.ts index fc518522f..d45419923 100644 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-root.test.ts +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-root.test.ts @@ -10,16 +10,16 @@ import ControlsIslandRoot from './controls-island-root.vue' import { resolveControlsIslandDock, useControlsIslandPlacement } from './use-controls-island-placement' const primaryDisplay = { - bounds: { x: 0, y: 0, width: 1920, height: 1080 }, - workArea: { x: 0, y: 25, width: 1920, height: 1055 }, + bounds: { height: 1080, width: 1920, x: 0, y: 0 }, + workArea: { height: 1055, width: 1920, x: 0, y: 25 }, } as Display const displays = shallowRef([primaryDisplay]) const windowBounds = { + height: shallowRef(600), + width: shallowRef(450), x: shallowRef(1370), y: shallowRef(430), - width: shallowRef(450), - height: shallowRef(600), } vi.mock('@proj-airi/electron-vueuse', () => ({ useElectronAllDisplays: () => displays, @@ -28,14 +28,6 @@ vi.mock('@proj-airi/electron-vueuse', () => ({ const mountedApps: Array<{ host: HTMLElement, unmount: () => void }> = [] -function resolve(windowBounds: Rectangle) { - return resolveControlsIslandDock({ - displays: [primaryDisplay], - previousDock: 'bottom-right', - windowBounds, - }) -} - function mountRoot() { const frozen = shallowRef(false) const ContextConsumer = defineComponent({ @@ -76,6 +68,14 @@ function readPlacement(host: HTMLElement) { } } +function resolve(windowBounds: Rectangle) { + return resolveControlsIslandDock({ + displays: [primaryDisplay], + previousDock: 'bottom-right', + windowBounds, + }) +} + beforeEach(() => { vi.stubGlobal('matchMedia', vi.fn((query: string): MediaQueryList => ({ addEventListener: vi.fn(), @@ -106,31 +106,31 @@ afterEach(() => { describe('resolveControlsIslandDock', () => { it('places the island in the top-left screen quadrant', () => { - expect(resolve({ x: 100, y: 100, width: 450, height: 600 })).toBe('top-left') + expect(resolve({ height: 600, width: 450, x: 100, y: 100 })).toBe('top-left') }) it('places the island in the top-right screen quadrant', () => { - expect(resolve({ x: 1370, y: 100, width: 450, height: 600 })).toBe('top-right') + expect(resolve({ height: 600, width: 450, x: 1370, y: 100 })).toBe('top-right') }) it('places the island in the bottom-left screen quadrant', () => { - expect(resolve({ x: 100, y: 430, width: 450, height: 600 })).toBe('bottom-left') + expect(resolve({ height: 600, width: 450, x: 100, y: 430 })).toBe('bottom-left') }) it('places the island in the bottom-right screen quadrant', () => { - expect(resolve({ x: 1370, y: 430, width: 450, height: 600 })).toBe('bottom-right') + expect(resolve({ height: 600, width: 450, x: 1370, y: 430 })).toBe('bottom-right') }) it('uses the display that contains the largest window area', () => { const secondaryDisplay = { - bounds: { x: -1600, y: -900, width: 1600, height: 900 }, - workArea: { x: -1600, y: -900, width: 1600, height: 860 }, + bounds: { height: 900, width: 1600, x: -1600, y: -900 }, + workArea: { height: 860, width: 1600, x: -1600, y: -900 }, } as Display const dock = resolveControlsIslandDock({ displays: [primaryDisplay, secondaryDisplay], previousDock: 'bottom-right', - windowBounds: { x: -500, y: -300, width: 450, height: 600 }, + windowBounds: { height: 600, width: 450, x: -500, y: -300 }, }) expect(dock).toBe('bottom-right') @@ -140,7 +140,7 @@ describe('resolveControlsIslandDock', () => { const dock = resolveControlsIslandDock({ displays: [primaryDisplay], previousDock: 'top-left', - windowBounds: { x: 735, y: 253, width: 450, height: 600 }, + windowBounds: { height: 600, width: 450, x: 735, y: 253 }, }) expect(dock).toBe('top-left') @@ -150,7 +150,7 @@ describe('resolveControlsIslandDock', () => { const dock = resolveControlsIslandDock({ displays: [], previousDock: 'top-right', - windowBounds: { x: 100, y: 100, width: 450, height: 600 }, + windowBounds: { height: 600, width: 450, x: 100, y: 100 }, }) expect(dock).toBe('top-right') @@ -160,7 +160,7 @@ describe('resolveControlsIslandDock', () => { const dock = resolveControlsIslandDock({ displays: [primaryDisplay], previousDock: 'bottom-right', - windowBounds: { x: 0, y: 0, width: 0, height: 0 }, + windowBounds: { height: 0, width: 0, x: 0, y: 0 }, }) expect(dock).toBe('bottom-right') diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.test.ts b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.test.ts index d7a13945a..796844de6 100644 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.test.ts +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.test.ts @@ -25,8 +25,8 @@ vi.mock('@proj-airi/stage-ui/stores/audio', () => ({ vi.mock('@proj-airi/stage-layouts/composables/useStopSpeakingButton', () => ({ useStopSpeakingButton: () => ({ - stopAllSpeaking: stopAllSpeakingMock, showStopSpeakingButton: nowSpeakingRef, + stopAllSpeaking: stopAllSpeakingMock, stopSpeakingFromChat: vi.fn(), }), })) @@ -42,7 +42,7 @@ vi.mock('pinia', () => ({ })) vi.mock('reka-ui', () => ({ - TooltipContent: { template: '
', inheritAttrs: false }, + TooltipContent: { inheritAttrs: false, template: '
' }, TooltipProvider: { template: '
' }, TooltipRoot: { template: '
' }, TooltipTrigger: { template: '
' }, @@ -60,12 +60,12 @@ describe('controlsIslandStopSpeaking', () => { }) app.provide(controlsIslandPlacementKey, placement) app.mount(host) - return { host, app } + return { app, host } } it('renders idle state when not speaking', async () => { nowSpeakingRef.value = false - const { host, app } = mountComponent() + const { app, host } = mountComponent() await nextTick() expect(host.querySelectorAll('button').length).toBeGreaterThan(0) app.unmount() @@ -74,7 +74,7 @@ describe('controlsIslandStopSpeaking', () => { it('renders active state when speaking', async () => { nowSpeakingRef.value = true - const { host, app } = mountComponent() + const { app, host } = mountComponent() await nextTick() expect(host.querySelectorAll('button').length).toBeGreaterThan(0) app.unmount() @@ -84,7 +84,7 @@ describe('controlsIslandStopSpeaking', () => { it('calls stopAllSpeaking on click', async () => { stopAllSpeakingMock.mockClear() nowSpeakingRef.value = false - const { host, app } = mountComponent() + const { app, host } = mountComponent() await nextTick() const button = host.querySelector('button') expect(button).toBeTruthy() diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/use-controls-island-placement.ts b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/use-controls-island-placement.ts index b683d88ce..91ee238a0 100644 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/use-controls-island-placement.ts +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/use-controls-island-placement.ts @@ -8,7 +8,7 @@ import { inject } from 'vue' import { findDominantDisplayArea } from '../../../../shared/utils/electron/display' /** A corner of the AIRI window where the Controls Island can dock. */ -export type ControlsIslandDock = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' +export type ControlsIslandDock = 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right' /** Inputs for the Controls Island quadrant policy. */ export interface ResolveControlsIslandDockOptions { @@ -23,6 +23,21 @@ export interface ResolveControlsIslandDockOptions { /** The half-width of the center band that prevents repeated flips near an axis. */ const displayCenterDeadZoneRatio = 0.05 +/** Visual phase for a Controls Island corner change. */ +export type ControlsIslandMotionPhase = 'arriving' | 'entering' | 'idle' | 'leaving' + +/** Placement state shared by the Controls Island and its anchored surfaces. */ +export interface ControlsIslandPlacement { + /** Current corner inside the AIRI window. */ + dock: Readonly> + /** True when the Island uses the left edge of the AIRI window. */ + isLeft: Readonly> + /** True when the Island uses the top edge of the AIRI window. */ + isTop: Readonly> + /** Current phase of the fade and move animation. */ + motionPhase: Readonly> +} + /** * Resolves the window corner that matches the current display quadrant. * @@ -47,7 +62,7 @@ export function resolveControlsIslandDock(options: ResolveControlsIslandDockOpti const verticalDeadZone = display.workArea.height * displayCenterDeadZoneRatio let horizontalDock: 'left' | 'right' = options.previousDock.endsWith('left') ? 'left' : 'right' - let verticalDock: 'top' | 'bottom' = options.previousDock.startsWith('top') ? 'top' : 'bottom' + let verticalDock: 'bottom' | 'top' = options.previousDock.startsWith('top') ? 'top' : 'bottom' if (windowCenterX < displayCenterX - horizontalDeadZone) { horizontalDock = 'left' @@ -70,21 +85,6 @@ export function resolveControlsIslandDock(options: ResolveControlsIslandDockOpti return horizontalDock === 'left' ? 'bottom-left' : 'bottom-right' } -/** Visual phase for a Controls Island corner change. */ -export type ControlsIslandMotionPhase = 'idle' | 'leaving' | 'entering' | 'arriving' - -/** Placement state shared by the Controls Island and its anchored surfaces. */ -export interface ControlsIslandPlacement { - /** Current corner inside the AIRI window. */ - dock: Readonly> - /** True when the Island uses the left edge of the AIRI window. */ - isLeft: Readonly> - /** True when the Island uses the top edge of the AIRI window. */ - isTop: Readonly> - /** Current phase of the fade and move animation. */ - motionPhase: Readonly> -} - /** Placement contract provided by the Controls Island root. */ export const controlsIslandPlacementKey: InjectionKey = Symbol('controls-island-placement') diff --git a/apps/stage-tamagotchi/src/renderer/composables/icon-animation.ts b/apps/stage-tamagotchi/src/renderer/composables/icon-animation.ts index 3149a8e5b..311d3e211 100644 --- a/apps/stage-tamagotchi/src/renderer/composables/icon-animation.ts +++ b/apps/stage-tamagotchi/src/renderer/composables/icon-animation.ts @@ -22,8 +22,8 @@ export function useIconAnimation(icon: string) { }) return { + animationIcon, iconAnimationStarted, showIconAnimation, - animationIcon, } } diff --git a/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-snapshot.ts b/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-snapshot.ts index ee7e8d615..8517442b3 100644 --- a/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-snapshot.ts +++ b/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-snapshot.ts @@ -61,7 +61,7 @@ export function useModelSettingsRuntimeSnapshot() { }) return { - runtimeSnapshot, requestCurrent, + runtimeSnapshot, } } diff --git a/apps/stage-tamagotchi/src/renderer/composables/runtime.ts b/apps/stage-tamagotchi/src/renderer/composables/runtime.ts index bf3d1fa52..a46026a5a 100644 --- a/apps/stage-tamagotchi/src/renderer/composables/runtime.ts +++ b/apps/stage-tamagotchi/src/renderer/composables/runtime.ts @@ -18,8 +18,8 @@ export function useAppRuntime() { }) return { - platform, isInitialized, isTauri, + platform, } } diff --git a/apps/stage-tamagotchi/src/renderer/composables/use-language.test.ts b/apps/stage-tamagotchi/src/renderer/composables/use-language.test.ts index 2d30094cd..0f48f2828 100644 --- a/apps/stage-tamagotchi/src/renderer/composables/use-language.test.ts +++ b/apps/stage-tamagotchi/src/renderer/composables/use-language.test.ts @@ -12,10 +12,10 @@ vi.mock('vue-i18n', () => ({ const localStorageMock = (() => { let store: Record = {} return { - getItem: (key: string) => store[key] ?? null, - setItem: (key: string, value: string) => { store[key] = value }, - removeItem: (key: string) => { delete store[key] }, clear: () => { store = {} }, + getItem: (key: string) => store[key] ?? null, + removeItem: (key: string) => { delete store[key] }, + setItem: (key: string, value: string) => { store[key] = value }, } })() diff --git a/apps/stage-tamagotchi/src/renderer/composables/use-vision-screen-capture.ts b/apps/stage-tamagotchi/src/renderer/composables/use-vision-screen-capture.ts index 571fa272c..1cd634ce3 100644 --- a/apps/stage-tamagotchi/src/renderer/composables/use-vision-screen-capture.ts +++ b/apps/stage-tamagotchi/src/renderer/composables/use-vision-screen-capture.ts @@ -129,7 +129,7 @@ export function useVisionScreenCapture(sourcesOptions: MaybeRefOrGetter sourceId, - async () => await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }), + async () => await navigator.mediaDevices.getDisplayMedia({ audio: false, video: true }), ) if (!isActiveStream(stream)) { stream.getTracks().forEach(track => track.stop()) @@ -175,16 +175,16 @@ export function useVisionScreenCapture(sourcesOptions: MaybeRefOrGetter { try { const captions = useCaptionItems({ ttlMs: 1000 }) - captions.add({ type: 'caption-speaker', text: 'first' }) + captions.add({ text: 'first', type: 'caption-speaker' }) vi.advanceTimersByTime(500) - captions.add({ type: 'caption-speaker', text: 'second' }) + captions.add({ text: 'second', type: 'caption-speaker' }) expect(captions.items.value.map(item => item.text)).toEqual(['first', 'second']) @@ -34,9 +34,9 @@ describe('useCaptionItems', () => { try { const captions = useCaptionItems({ ttlMs: 1000 }) - captions.add({ type: 'caption-speaker', text: 'speaker' }) - captions.add({ type: 'caption-assistant', text: 'assistant' }) - captions.add({ type: 'caption-speaker', text: '' }) + captions.add({ text: 'speaker', type: 'caption-speaker' }) + captions.add({ text: 'assistant', type: 'caption-assistant' }) + captions.add({ text: '', type: 'caption-speaker' }) expect(captions.items.value.map(item => item.text)).toEqual(['assistant']) @@ -59,9 +59,9 @@ describe('useCaptionItems', () => { try { const captions = useCaptionItems({ ttlMs: 1000 }) - captions.add({ operation: 'replace', type: 'caption-speaker', text: '今天天气很号' }) + captions.add({ operation: 'replace', text: '今天天气很号', type: 'caption-speaker' }) vi.advanceTimersByTime(500) - captions.add({ operation: 'replace', type: 'caption-speaker', text: '今天天气很好' }) + captions.add({ operation: 'replace', text: '今天天气很好', type: 'caption-speaker' }) expect(captions.items.value).toHaveLength(1) expect(captions.items.value[0]?.text).toBe('今天天气很好') diff --git a/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.ts b/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.ts index 8533b0216..51bb0987b 100644 --- a/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.ts +++ b/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.ts @@ -5,10 +5,10 @@ import { readonly, shallowRef } from 'vue' export interface CaptionItem { /** Stable render key and timer owner for one broadcast caption event. */ id: number - /** Caption source, used for styling and explicit type-level clears. */ - type: CaptionChannelEvent['type'] /** Text payload rendered by the overlay. */ text: string + /** Caption source, used for styling and explicit type-level clears. */ + type: CaptionChannelEvent['type'] } export interface UseCaptionItemsOptions { @@ -78,8 +78,8 @@ export function useCaptionItems(options: UseCaptionItemsOptions = {}) { if (!currentItem) { const item: CaptionItem = { id: nextId++, - type: event.type, text: event.text, + type: event.type, } items.value = [...items.value, item] scheduleExpiry(item) @@ -109,8 +109,8 @@ export function useCaptionItems(options: UseCaptionItemsOptions = {}) { const item: CaptionItem = { id: nextId++, - type: event.type, text: event.text, + type: event.type, } items.value = [...items.value, item] scheduleExpiry(item) @@ -125,9 +125,9 @@ export function useCaptionItems(options: UseCaptionItemsOptions = {}) { } return { - items: readonly(items), add, clearType, dispose, + items: readonly(items), } } diff --git a/apps/stage-tamagotchi/src/renderer/modules/i18n.ts b/apps/stage-tamagotchi/src/renderer/modules/i18n.ts index 438770ebe..4c451a6e6 100644 --- a/apps/stage-tamagotchi/src/renderer/modules/i18n.ts +++ b/apps/stage-tamagotchi/src/renderer/modules/i18n.ts @@ -15,8 +15,8 @@ function getLocale() { } export const i18n = createI18n({ + fallbackLocale: 'en', legacy: false, locale: getLocale(), - fallbackLocale: 'en', messages, }) diff --git a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-coordinates.test.ts b/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-coordinates.test.ts index f2ff64f3d..7d4be9526 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-coordinates.test.ts +++ b/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-coordinates.test.ts @@ -35,14 +35,14 @@ describe('screenToLocal', () => { describe('screenRectToLocal', () => { it('shifts rect origin, preserves size', () => { const result = screenRectToLocal( - { x: 100, y: -1000, width: 80, height: 30 }, + { height: 30, width: 80, x: 100, y: -1000 }, { x: 0, y: -1080 }, ) - expect(result).toEqual({ x: 100, y: 80, width: 80, height: 30 }) + expect(result).toEqual({ height: 30, width: 80, x: 100, y: 80 }) }) it('is identity when overlay origin is (0,0)', () => { - const rect = { x: 50, y: 100, width: 200, height: 150 } + const rect = { height: 150, width: 200, x: 50, y: 100 } const result = screenRectToLocal(rect, { x: 0, y: 0 }) expect(result).toEqual(rect) }) @@ -53,39 +53,39 @@ describe('screenRectToLocal', () => { // --------------------------------------------------------------------------- describe('rectIntersectsOverlay', () => { - const overlay = { x: 0, y: -1080, width: 1440, height: 900 } + const overlay = { height: 900, width: 1440, x: 0, y: -1080 } it('returns true for rect fully inside overlay', () => { expect(rectIntersectsOverlay( - { x: 100, y: -1000, width: 80, height: 30 }, + { height: 30, width: 80, x: 100, y: -1000 }, overlay, )).toBe(true) }) it('returns true for rect partially overlapping', () => { expect(rectIntersectsOverlay( - { x: 1400, y: -1080, width: 100, height: 50 }, + { height: 50, width: 100, x: 1400, y: -1080 }, overlay, )).toBe(true) }) it('returns false for rect entirely above overlay', () => { expect(rectIntersectsOverlay( - { x: 100, y: -2000, width: 80, height: 30 }, + { height: 30, width: 80, x: 100, y: -2000 }, overlay, )).toBe(false) }) it('returns false for rect entirely below overlay', () => { expect(rectIntersectsOverlay( - { x: 100, y: 0, width: 80, height: 30 }, + { height: 30, width: 80, x: 100, y: 0 }, overlay, )).toBe(false) }) it('returns false for rect entirely to the right', () => { expect(rectIntersectsOverlay( - { x: 1500, y: -500, width: 80, height: 30 }, + { height: 30, width: 80, x: 1500, y: -500 }, overlay, )).toBe(false) }) @@ -96,7 +96,7 @@ describe('rectIntersectsOverlay', () => { // --------------------------------------------------------------------------- describe('pointInOverlay', () => { - const overlay = { x: 0, y: -1080, width: 1440, height: 900 } + const overlay = { height: 900, width: 1440, x: 0, y: -1080 } it('returns true for point inside', () => { expect(pointInOverlay({ x: 720, y: -540 }, overlay)).toBe(true) diff --git a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-coordinates.ts b/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-coordinates.ts index 3c57870be..70b0b3c41 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-coordinates.ts +++ b/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-coordinates.ts @@ -15,14 +15,14 @@ // Types // --------------------------------------------------------------------------- -export interface Rect { +export interface Point { x: number y: number - width: number - height: number } -export interface Point { +export interface Rect { + height: number + width: number x: number y: number } @@ -32,26 +32,15 @@ export interface Point { // --------------------------------------------------------------------------- /** - * Convert a screen-absolute point to overlay-local coordinates. + * Check whether a screen-absolute point is within the overlay bounds. */ -export function screenToLocal(point: Point, overlayOrigin: Point): Point { - return { - x: point.x - overlayOrigin.x, - y: point.y - overlayOrigin.y, - } -} - -/** - * Convert a screen-absolute rect to overlay-local coordinates. - * Size is preserved; only the origin is shifted. - */ -export function screenRectToLocal(rect: Rect, overlayOrigin: Point): Rect { - return { - x: rect.x - overlayOrigin.x, - y: rect.y - overlayOrigin.y, - width: rect.width, - height: rect.height, - } +export function pointInOverlay(point: Point, overlayBounds: Rect): boolean { + return ( + point.x >= overlayBounds.x + && point.x < overlayBounds.x + overlayBounds.width + && point.y >= overlayBounds.y + && point.y < overlayBounds.y + overlayBounds.height + ) } /** @@ -68,13 +57,24 @@ export function rectIntersectsOverlay(rect: Rect, overlayBounds: Rect): boolean } /** - * Check whether a screen-absolute point is within the overlay bounds. + * Convert a screen-absolute rect to overlay-local coordinates. + * Size is preserved; only the origin is shifted. */ -export function pointInOverlay(point: Point, overlayBounds: Rect): boolean { - return ( - point.x >= overlayBounds.x - && point.x < overlayBounds.x + overlayBounds.width - && point.y >= overlayBounds.y - && point.y < overlayBounds.y + overlayBounds.height - ) +export function screenRectToLocal(rect: Rect, overlayOrigin: Point): Rect { + return { + height: rect.height, + width: rect.width, + x: rect.x - overlayOrigin.x, + y: rect.y - overlayOrigin.y, + } +} + +/** + * Convert a screen-absolute point to overlay-local coordinates. + */ +export function screenToLocal(point: Point, overlayOrigin: Point): Point { + return { + x: point.x - overlayOrigin.x, + y: point.y - overlayOrigin.y, + } } diff --git a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.test.ts b/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.test.ts index aed14d71b..8c46f0dd5 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.test.ts +++ b/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.test.ts @@ -23,7 +23,7 @@ describe('extractOverlayState', () => { expect(result.snapshotId).toBe('') expect(result.candidates).toEqual([]) expect(result.pointerIntent).toBeNull() - expect(result.staleFlags).toEqual({ screenshot: false, ax: false, chromeSemantic: false }) + expect(result.staleFlags).toEqual({ ax: false, chromeSemantic: false, screenshot: false }) expect(result.bootstrapState).toBe('booting') }) @@ -31,11 +31,11 @@ describe('extractOverlayState', () => { const result = extractOverlayState({ lastGroundingSnapshot: { snapshotId: 'dg_42', + staleFlags: { ax: false, chromeSemantic: false, screenshot: false }, targetCandidates: [ - { id: 't_0', source: 'chrome_dom', role: 'button', label: 'Submit', bounds: { x: 100, y: 200, width: 80, height: 30 }, confidence: 0.95 }, - { id: 't_1', source: 'ax', role: 'link', label: 'Help', bounds: { x: 300, y: 100, width: 40, height: 20 }, confidence: 0.7 }, + { bounds: { height: 30, width: 80, x: 100, y: 200 }, confidence: 0.95, id: 't_0', label: 'Submit', role: 'button', source: 'chrome_dom' }, + { bounds: { height: 20, width: 40, x: 300, y: 100 }, confidence: 0.7, id: 't_1', label: 'Help', role: 'link', source: 'ax' }, ], - staleFlags: { screenshot: false, ax: false, chromeSemantic: false }, }, }) @@ -49,11 +49,11 @@ describe('extractOverlayState', () => { it('extracts pointer intent from lastPointerIntent', () => { const result = extractOverlayState({ lastPointerIntent: { - snappedPoint: { x: 140, y: 215 }, candidateId: 't_0', - source: 'chrome_dom', confidence: 0.95, mode: 'execute', + snappedPoint: { x: 140, y: 215 }, + source: 'chrome_dom', }, }) @@ -67,8 +67,8 @@ describe('extractOverlayState', () => { const result = extractOverlayState({ lastGroundingSnapshot: { snapshotId: 'dg_1', + staleFlags: { ax: false, chromeSemantic: true, screenshot: true }, targetCandidates: [], - staleFlags: { screenshot: true, ax: false, chromeSemantic: true }, }, }) @@ -97,8 +97,8 @@ describe('extractOverlayState', () => { describe('extractRunStateFromResult', () => { it('returns undefined for error results', () => { const result = extractRunStateFromResult({ + content: [{ text: 'fail', type: 'text' }], isError: true, - content: [{ type: 'text', text: 'fail' }], }) expect(result).toBeUndefined() }) @@ -147,7 +147,7 @@ describe('createEmptyOverlayState', () => { expect(a.bootstrapState).toBe('booting') // Should not be the same reference (no shared mutation) - a.candidates.push({ id: 'x', source: 'raw', role: 'button', label: 'X', bounds: { x: 0, y: 0, width: 10, height: 10 }, confidence: 1 }) + a.candidates.push({ bounds: { height: 10, width: 10, x: 0, y: 0 }, confidence: 1, id: 'x', label: 'X', role: 'button', source: 'raw' }) expect(b.candidates).toHaveLength(0) }) }) @@ -169,10 +169,10 @@ describe('createOverlayPollController', () => { runState: { lastGroundingSnapshot: { snapshotId: 'dg_poll', + staleFlags: { ax: false, chromeSemantic: false, screenshot: false }, targetCandidates: [ - { id: 't_0', source: 'chrome_dom', role: 'button', label: 'OK', bounds: { x: 10, y: 20, width: 50, height: 25 }, confidence: 0.9 }, + { bounds: { height: 25, width: 50, x: 10, y: 20 }, confidence: 0.9, id: 't_0', label: 'OK', role: 'button', source: 'chrome_dom' }, ], - staleFlags: { screenshot: false, ax: false, chromeSemantic: false }, }, }, }, @@ -187,10 +187,10 @@ describe('createOverlayPollController', () => { const controller = createOverlayPollController({ callTool, - getReadiness, - onState: (s) => { received.push(s) }, - intervalMs: 100, fallbackIntervalMs: 200, + getReadiness, + intervalMs: 100, + onState: (s) => { received.push(s) }, }) controller.start() @@ -219,8 +219,8 @@ describe('createOverlayPollController', () => { const controller = createOverlayPollController({ callTool, getReadiness, - onState: () => {}, intervalMs: 100, + onState: () => {}, }) controller.start() @@ -245,8 +245,8 @@ describe('createOverlayPollController', () => { runState: { lastGroundingSnapshot: { snapshotId: 'dg_recover', + staleFlags: { ax: false, chromeSemantic: false, screenshot: false }, targetCandidates: [], - staleFlags: { screenshot: false, ax: false, chromeSemantic: false }, }, }, }, @@ -258,10 +258,10 @@ describe('createOverlayPollController', () => { const controller = createOverlayPollController({ callTool, - getReadiness, - onState: (s) => { received.push(s) }, - intervalMs: 100, fallbackIntervalMs: 200, + getReadiness, + intervalMs: 100, + onState: (s) => { received.push(s) }, }) controller.start() @@ -292,8 +292,8 @@ describe('createOverlayPollController', () => { const controller = createOverlayPollController({ callTool, getReadiness, - onState: () => {}, intervalMs: 100, + onState: () => {}, }) controller.start() @@ -316,8 +316,8 @@ describe('createOverlayPollController', () => { runState: { lastGroundingSnapshot: { snapshotId: 'dg_after_timeout', + staleFlags: { ax: false, chromeSemantic: false, screenshot: false }, targetCandidates: [], - staleFlags: { screenshot: false, ax: false, chromeSemantic: false }, }, }, }, @@ -328,12 +328,12 @@ describe('createOverlayPollController', () => { const getReadiness = vi.fn().mockResolvedValue({ state: 'ready' }) const controller = createOverlayPollController({ - callTool, - getReadiness, - onState: (s) => { received.push(s) }, - intervalMs: 100, - fallbackIntervalMs: 200, callTimeoutMs: 500, + callTool, + fallbackIntervalMs: 200, + getReadiness, + intervalMs: 100, + onState: (s) => { received.push(s) }, }) controller.start() @@ -363,12 +363,12 @@ describe('createOverlayPollController', () => { .mockImplementation(() => new Promise(() => {})) const controller = createOverlayPollController({ - callTool, - getReadiness: vi.fn().mockResolvedValue({ state: 'ready' }), - onState: () => {}, - intervalMs: 100, - fallbackIntervalMs: 200, callTimeoutMs: 500, + callTool, + fallbackIntervalMs: 200, + getReadiness: vi.fn().mockResolvedValue({ state: 'ready' }), + intervalMs: 100, + onState: () => {}, }) controller.start() @@ -394,12 +394,12 @@ describe('createOverlayPollController', () => { .mockImplementation(() => new Promise(() => {})) const controller = createOverlayPollController({ - callTool, - getReadiness: vi.fn().mockResolvedValue({ state: 'ready' }), - onState: () => {}, - intervalMs: 100, - fallbackIntervalMs: 200, callTimeoutMs: 500, + callTool, + fallbackIntervalMs: 200, + getReadiness: vi.fn().mockResolvedValue({ state: 'ready' }), + intervalMs: 100, + onState: () => {}, }) controller.start() @@ -435,8 +435,8 @@ describe('createOverlayPollController', () => { runState: { lastGroundingSnapshot: { snapshotId: 'dg_after_lease', + staleFlags: { ax: false, chromeSemantic: false, screenshot: false }, targetCandidates: [], - staleFlags: { screenshot: false, ax: false, chromeSemantic: false }, }, }, }, @@ -445,14 +445,14 @@ describe('createOverlayPollController', () => { const received: OverlayState[] = [] const controller = createOverlayPollController({ + callTimeoutMs: 500, callTool, + fallbackIntervalMs: 200, getReadiness: vi.fn().mockResolvedValue({ state: 'ready' }), + intervalMs: 100, onState: (state) => { received.push(state) }, - intervalMs: 100, - fallbackIntervalMs: 200, - callTimeoutMs: 500, }) controller.start() @@ -491,10 +491,10 @@ describe('createOverlayPollController', () => { const controller = createOverlayPollController({ callTool, - getReadiness, - onState: s => received.push(s), - intervalMs: 100, fallbackIntervalMs: 200, + getReadiness, + intervalMs: 100, + onState: s => received.push(s), }) controller.start() @@ -522,10 +522,10 @@ describe('createOverlayPollController', () => { const controller = createOverlayPollController({ callTool, - getReadiness, - onState: s => received.push(s), - intervalMs: 100, fallbackIntervalMs: 200, + getReadiness, + intervalMs: 100, + onState: s => received.push(s), }) controller.start() diff --git a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.ts b/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.ts index 6adf2e39d..c5ac9cb94 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.ts +++ b/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.ts @@ -15,64 +15,101 @@ import { desktopOverlayPollHeartbeatMarker, desktopOverlayPollHeartbeatQueryPara // Types — minimal shapes matching RunState fields the overlay consumes // --------------------------------------------------------------------------- -export interface OverlayTargetCandidate { - id: string - source: string - role: string - label: string - bounds: { x: number, y: number, width: number, height: number } - confidence: number -} - export interface OverlayPointerIntent { - snappedPoint: { x: number, y: number } candidateId?: string - source: string confidence: number + executionResult?: 'error' | 'fallback' | 'success' mode: string - phase?: 'preview' | 'executing' | 'completed' - executionResult?: 'success' | 'fallback' | 'error' -} - -export interface OverlayStaleFlags { - screenshot: boolean - ax: boolean - chromeSemantic: boolean -} - -export interface OverlayState { - hasSnapshot: boolean - snapshotId: string - candidates: OverlayTargetCandidate[] - staleFlags: OverlayStaleFlags - pointerIntent: OverlayPointerIntent | null - bootstrapState: 'booting' | 'ready' | 'degraded' - lastBootstrapError?: string + phase?: 'completed' | 'executing' | 'preview' + snappedPoint: { x: number, y: number } + source: string } export interface OverlayPollHeartbeat { - snapshotId: string candidateCount: number hasPointerIntent: boolean + snapshotId: string +} + +export interface OverlayStaleFlags { + ax: boolean + chromeSemantic: boolean + screenshot: boolean +} + +export interface OverlayState { + bootstrapState: 'booting' | 'degraded' | 'ready' + candidates: OverlayTargetCandidate[] + hasSnapshot: boolean + lastBootstrapError?: string + pointerIntent: null | OverlayPointerIntent + snapshotId: string + staleFlags: OverlayStaleFlags +} + +export interface OverlayTargetCandidate { + bounds: { height: number, width: number, x: number, y: number } + confidence: number + id: string + label: string + role: string + source: string } // --------------------------------------------------------------------------- // State extraction // --------------------------------------------------------------------------- -const EMPTY_STALE: OverlayStaleFlags = { screenshot: false, ax: false, chromeSemantic: false } +const EMPTY_STALE: OverlayStaleFlags = { ax: false, chromeSemantic: false, screenshot: false } + +export interface OverlayPollConfig { + /** Per-call timeout in ms. Default: 5000. Prevents poll loop hang on startup race. */ + callTimeoutMs?: number + /** Function to call MCP tool. */ + callTool: (name: string) => Promise + /** Fallback interval on error in ms. Default: 500. */ + fallbackIntervalMs?: number + /** Function to ping main process readiness contract via Eventa. */ + getReadiness: () => Promise<{ error?: string, state: 'booting' | 'degraded' | 'ready' }> + /** Normal poll interval in ms. Default: 250. */ + intervalMs?: number + /** Optional debug-only callback with a small heartbeat marker. */ + onHeartbeat?: (heartbeat: OverlayPollHeartbeat) => void + /** Callback with extracted state on each successful poll. */ + onState: (state: OverlayState) => void +} + +export interface OverlayPollController { + /** Whether the controller is actively polling. */ + isRunning: () => boolean + /** Start polling. No-op if already running. */ + start: () => void + /** Stop polling. */ + stop: () => void +} /** * Create a default empty overlay state. */ export function createEmptyOverlayState(): OverlayState { return { - hasSnapshot: false, - snapshotId: '', - candidates: [], - staleFlags: { ...EMPTY_STALE }, - pointerIntent: null, bootstrapState: 'booting', + candidates: [], + hasSnapshot: false, + pointerIntent: null, + snapshotId: '', + staleFlags: { ...EMPTY_STALE }, + } +} + +export function createOverlayPollHeartbeat(state: OverlayState): OverlayPollHeartbeat | undefined { + if (!state.hasSnapshot || !state.snapshotId) + return undefined + + return { + candidateCount: state.candidates.length, + hasPointerIntent: state.pointerIntent !== null, + snapshotId: state.snapshotId, } } @@ -121,16 +158,9 @@ export function extractRunStateFromResult(result: McpCallToolResult): Record } -export function createOverlayPollHeartbeat(state: OverlayState): OverlayPollHeartbeat | undefined { - if (!state.hasSnapshot || !state.snapshotId) - return undefined - - return { - snapshotId: state.snapshotId, - candidateCount: state.candidates.length, - hasPointerIntent: state.pointerIntent !== null, - } -} +// --------------------------------------------------------------------------- +// Polling controller (framework-agnostic) +// --------------------------------------------------------------------------- export function formatOverlayPollHeartbeat(heartbeat: OverlayPollHeartbeat): string { return [ @@ -152,36 +182,6 @@ export function isOverlayPollHeartbeatEnabled(locationLike: Pick void - /** Stop polling. */ - stop: () => void - /** Whether the controller is actively polling. */ - isRunning: () => boolean -} - -export interface OverlayPollConfig { - /** Function to call MCP tool. */ - callTool: (name: string) => Promise - /** Callback with extracted state on each successful poll. */ - onState: (state: OverlayState) => void - /** Optional debug-only callback with a small heartbeat marker. */ - onHeartbeat?: (heartbeat: OverlayPollHeartbeat) => void - /** Function to ping main process readiness contract via Eventa. */ - getReadiness: () => Promise<{ state: 'booting' | 'ready' | 'degraded', error?: string }> - /** Normal poll interval in ms. Default: 250. */ - intervalMs?: number - /** Fallback interval on error in ms. Default: 500. */ - fallbackIntervalMs?: number - /** Per-call timeout in ms. Default: 5000. Prevents poll loop hang on startup race. */ - callTimeoutMs?: number -} - const DEFAULT_INTERVAL = 250 const DEFAULT_FALLBACK_INTERVAL = 500 const DEFAULT_CALL_TIMEOUT = 5000 @@ -201,17 +201,17 @@ export function createOverlayPollController(config: OverlayPollConfig): OverlayP const normalInterval = config.intervalMs ?? DEFAULT_INTERVAL const fallbackInterval = config.fallbackIntervalMs ?? DEFAULT_FALLBACK_INTERVAL - let timer: ReturnType | null = null - let bootstrapTimer: ReturnType | null = null + let timer: null | ReturnType = null + let bootstrapTimer: null | ReturnType = null let running = false - let inFlightCall: Promise | null = null + let inFlightCall: null | Promise = null let backgroundHungCalls: Array<{ call: Promise timedOutAt: number }> = [] - let lastHungRecoveryProbeAt: number | null = null + let lastHungRecoveryProbeAt: null | number = null - let currentBootstrapState: 'booting' | 'ready' | 'degraded' = 'booting' + let currentBootstrapState: 'booting' | 'degraded' | 'ready' = 'booting' let currentBootstrapError: string | undefined function scheduleNext(nextInterval: number) { @@ -365,6 +365,10 @@ export function createOverlayPollController(config: OverlayPollConfig): OverlayP } return { + isRunning() { + return running + }, + start() { if (running) return @@ -384,9 +388,5 @@ export function createOverlayPollController(config: OverlayPollConfig): OverlayP bootstrapTimer = null } }, - - isRunning() { - return running - }, } } diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/models/godot-scene-input.ts b/apps/stage-tamagotchi/src/renderer/pages/settings/models/godot-scene-input.ts index b26e69970..441139ec6 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/settings/models/godot-scene-input.ts +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/models/godot-scene-input.ts @@ -2,23 +2,6 @@ import type { DisplayModel } from '@proj-airi/stage-ui/stores/display-models' import { DisplayModelFormat } from '@proj-airi/stage-ui/stores/display-models' -/** - * Checks whether a display model can be sent to the Godot stage scene input path. - * - * Use when: - * - Settings needs to decide whether to materialize a selected display model for Godot - * - Godot stage mode needs to reject formats outside the G1.1 VRM baseline - * - * Expects: - * - The display model comes from the shared display model store - * - * Returns: - * - `true` only for VRM display models - */ -export function isGodotSceneInputSupportedDisplayModel(model: DisplayModel): boolean { - return model.format === DisplayModelFormat.VRM -} - /** * Rejects display models that the Godot stage G1.1 scene input path cannot load. * @@ -35,3 +18,20 @@ export function assertGodotSceneInputSupportedDisplayModel(model: DisplayModel): if (!isGodotSceneInputSupportedDisplayModel(model)) throw new Error('Godot Stage currently supports VRM models only.') } + +/** + * Checks whether a display model can be sent to the Godot stage scene input path. + * + * Use when: + * - Settings needs to decide whether to materialize a selected display model for Godot + * - Godot stage mode needs to reject formats outside the G1.1 VRM baseline + * + * Expects: + * - The display model comes from the shared display model store + * + * Returns: + * - `true` only for VRM display models + */ +export function isGodotSceneInputSupportedDisplayModel(model: DisplayModel): boolean { + return model.format === DisplayModelFormat.VRM +} diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/models/godot-view-patch-queue.ts b/apps/stage-tamagotchi/src/renderer/pages/settings/models/godot-view-patch-queue.ts index 7e4f4d8cb..5066d1161 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/settings/models/godot-view-patch-queue.ts +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/models/godot-view-patch-queue.ts @@ -24,47 +24,6 @@ export interface GodotViewPatchQueueOptions { onError?: (error: unknown) => void } -function mergeVec3Patch( - current: Partial | undefined, - next: Partial | undefined, -) { - if (!next) - return current - - return { - ...current, - ...next, - } -} - -/** - * Merges two Godot stage view-state patches. - * - * Use when: - * - Several UI updates arrive before the next throttled bridge send - * - The latest field value should replace older queued values - * - * Expects: - * - Patch payloads have already passed component-level construction - * - * Returns: - * - A patch containing the newest value for each touched model and camera field - */ -export function mergeGodotViewPatch( - current: StageViewPatch | undefined, - next: StageViewPatch, -): StageViewPatch { - return { - camera: current?.camera || next.camera - ? { - ...current?.camera, - ...next.camera, - position: mergeVec3Patch(current?.camera?.position, next.camera?.position), - } - : undefined, - } -} - /** * Creates a leading-and-trailing queue for Godot view-state patches. * @@ -172,3 +131,44 @@ export function createGodotViewPatchQueue( reset, } } + +/** + * Merges two Godot stage view-state patches. + * + * Use when: + * - Several UI updates arrive before the next throttled bridge send + * - The latest field value should replace older queued values + * + * Expects: + * - Patch payloads have already passed component-level construction + * + * Returns: + * - A patch containing the newest value for each touched model and camera field + */ +export function mergeGodotViewPatch( + current: StageViewPatch | undefined, + next: StageViewPatch, +): StageViewPatch { + return { + camera: current?.camera || next.camera + ? { + ...current?.camera, + ...next.camera, + position: mergeVec3Patch(current?.camera?.position, next.camera?.position), + } + : undefined, + } +} + +function mergeVec3Patch( + current: Partial | undefined, + next: Partial | undefined, +) { + if (!next) + return current + + return { + ...current, + ...next, + } +} diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/modules/mcp-config.test.ts b/apps/stage-tamagotchi/src/renderer/pages/settings/modules/mcp-config.test.ts index 7ef1a78cc..659fac68a 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/settings/modules/mcp-config.test.ts +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/modules/mcp-config.test.ts @@ -24,8 +24,8 @@ describe('mcp-config helpers', () => { it('preserves the selected server identity when rows are reloaded', () => { const config = { mcpServers: { - filesystem: { command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem'] }, - github: { command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] }, + filesystem: { args: ['-y', '@modelcontextprotocol/server-filesystem'], command: 'npx' }, + github: { args: ['-y', '@modelcontextprotocol/server-github'], command: 'npx' }, }, } @@ -41,29 +41,29 @@ describe('mcp-config helpers', () => { it('keeps cwd when converting form rows into MCP config', () => { const server = { - rowId: 'mcp-static', - identifier: 'filesystem', - command: ' npx ', argsText: '-y\n@modelcontextprotocol/server-filesystem', - envEntries: [{ key: ' ROOT ', value: '/tmp' }], + command: ' npx ', cwd: ' /Users/doji/dojiwork/airi ', enabled: true, + envEntries: [{ key: ' ROOT ', value: '/tmp' }], + identifier: 'filesystem', + rowId: 'mcp-static', } expect(buildServerConfig(server)).toEqual({ - command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem'], - env: { ROOT: '/tmp' }, + command: 'npx', cwd: '/Users/doji/dojiwork/airi', + env: { ROOT: '/tmp' }, }) expect(buildConfigFile([server], translateMessage)).toEqual({ mcpServers: { filesystem: { - command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem'], - env: { ROOT: '/tmp' }, + command: 'npx', cwd: '/Users/doji/dojiwork/airi', + env: { ROOT: '/tmp' }, }, }, }) @@ -74,13 +74,13 @@ describe('mcp-config helpers', () => { const result = syncJsonDraftFromServers( [{ - rowId: 'pending', - identifier: '', - command: '', argsText: '', - envEntries: [], + command: '', cwd: '', enabled: true, + envEntries: [], + identifier: '', + rowId: 'pending', }], previousDraft, translateMessage, diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/modules/mcp-config.ts b/apps/stage-tamagotchi/src/renderer/pages/settings/modules/mcp-config.ts index b50164658..9d494e15d 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/settings/modules/mcp-config.ts +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/modules/mcp-config.ts @@ -3,84 +3,25 @@ import type { ElectronMcpStdioServerConfig, } from '../../../../shared/eventa' -type TranslateMcpMessage = (key: string, params?: Record) => string +/** Editable MCP server rows derived from persisted config. */ +export interface LoadedServerForms { + savedIds: Set + selectedRowId: string + servers: ServerForm[] +} /** Editable MCP server form state used by the settings page. */ export interface ServerForm { - rowId: string - identifier: string - command: string argsText: string - envEntries: { key: string, value: string }[] + command: string cwd: string enabled: boolean + envEntries: { key: string, value: string }[] + identifier: string + rowId: string } -/** Editable MCP server rows derived from persisted config. */ -export interface LoadedServerForms { - servers: ServerForm[] - savedIds: Set - selectedRowId: string -} - -function makeRowId() { - return `mcp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` -} - -function splitArgsText(argsText: string) { - return argsText.split(/\r?\n/).map(line => line.trim()).filter(Boolean) -} - -function envToObject(entries: { key: string, value: string }[]) { - const out: Record = {} - for (const { key, value } of entries) { - const normalizedKey = key.trim() - if (normalizedKey) - out[normalizedKey] = value - } - return out -} - -/** Creates a blank MCP server row for new entries. */ -export function createServerForm(): ServerForm { - return { - rowId: makeRowId(), - identifier: '', - command: '', - argsText: '', - envEntries: [], - cwd: '', - enabled: true, - } -} - -/** Resolves the persisted server identifier for a selected row. */ -export function findServerIdentifierByRowId(servers: ServerForm[], rowId: string) { - return servers.find(server => server.rowId === rowId)?.identifier.trim() || undefined -} - -/** Converts one editable server row into persisted MCP server config. */ -export function buildServerConfig(server: ServerForm): ElectronMcpStdioServerConfig { - const config: ElectronMcpStdioServerConfig = { - command: server.command.trim(), - } - - const args = splitArgsText(server.argsText) - if (args.length) - config.args = args - - const env = envToObject(server.envEntries) - if (Object.keys(env).length) - config.env = env - - if (server.cwd.trim()) - config.cwd = server.cwd.trim() - - if (!server.enabled) - config.enabled = false - - return config -} +type TranslateMcpMessage = (key: string, params?: Record) => string /** Builds the persisted MCP config file from editable rows. */ export function buildConfigFile( @@ -108,6 +49,78 @@ export function buildConfigFile( return config } +/** Converts one editable server row into persisted MCP server config. */ +export function buildServerConfig(server: ServerForm): ElectronMcpStdioServerConfig { + const config: ElectronMcpStdioServerConfig = { + command: server.command.trim(), + } + + const args = splitArgsText(server.argsText) + if (args.length) + config.args = args + + const env = envToObject(server.envEntries) + if (Object.keys(env).length) + config.env = env + + if (server.cwd.trim()) + config.cwd = server.cwd.trim() + + if (!server.enabled) + config.enabled = false + + return config +} + +/** Creates a blank MCP server row for new entries. */ +export function createServerForm(): ServerForm { + return { + argsText: '', + command: '', + cwd: '', + enabled: true, + envEntries: [], + identifier: '', + rowId: makeRowId(), + } +} + +/** Resolves the persisted server identifier for a selected row. */ +export function findServerIdentifierByRowId(servers: ServerForm[], rowId: string) { + return servers.find(server => server.rowId === rowId)?.identifier.trim() || undefined +} + +/** Loads editable rows from persisted MCP config. */ +export function loadServerForms( + config: ElectronMcpStdioConfigFile, + options: { selectedIdentifier?: string } = {}, +): LoadedServerForms { + const servers = Object.entries(config.mcpServers ?? {}).map(([identifier, server]) => ({ + argsText: (server.args ?? []).join('\n'), + command: server.command, + cwd: server.cwd ?? '', + enabled: server.enabled !== false, + envEntries: Object.entries(server.env ?? {}).map(([key, value]) => ({ key, value })), + identifier, + rowId: makeRowId(), + })) + + const selectedRowId = options.selectedIdentifier + ? (servers.find(server => server.identifier === options.selectedIdentifier)?.rowId ?? servers[0]?.rowId ?? '') + : (servers[0]?.rowId ?? '') + + return { + savedIds: new Set(servers.map(server => server.rowId)), + selectedRowId, + servers, + } +} + +/** Previews the command line assembled from one server row. */ +export function previewServerCommand(server: ServerForm) { + return [server.command, ...splitArgsText(server.argsText)].join(' ') +} + /** Builds the JSON editor draft while preserving the current draft when form validation fails. */ export function syncJsonDraftFromServers( servers: ServerForm[], @@ -129,33 +142,20 @@ export function syncJsonDraftFromServers( } } -/** Loads editable rows from persisted MCP config. */ -export function loadServerForms( - config: ElectronMcpStdioConfigFile, - options: { selectedIdentifier?: string } = {}, -): LoadedServerForms { - const servers = Object.entries(config.mcpServers ?? {}).map(([identifier, server]) => ({ - rowId: makeRowId(), - identifier, - command: server.command, - argsText: (server.args ?? []).join('\n'), - envEntries: Object.entries(server.env ?? {}).map(([key, value]) => ({ key, value })), - cwd: server.cwd ?? '', - enabled: server.enabled !== false, - })) - - const selectedRowId = options.selectedIdentifier - ? (servers.find(server => server.identifier === options.selectedIdentifier)?.rowId ?? servers[0]?.rowId ?? '') - : (servers[0]?.rowId ?? '') - - return { - servers, - savedIds: new Set(servers.map(server => server.rowId)), - selectedRowId, +function envToObject(entries: { key: string, value: string }[]) { + const out: Record = {} + for (const { key, value } of entries) { + const normalizedKey = key.trim() + if (normalizedKey) + out[normalizedKey] = value } + return out } -/** Previews the command line assembled from one server row. */ -export function previewServerCommand(server: ServerForm) { - return [server.command, ...splitArgsText(server.argsText)].join(' ') +function makeRowId() { + return `mcp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` +} + +function splitArgsText(argsText: string) { + return argsText.split(/\r?\n/).map(line => line.trim()).filter(Boolean) } diff --git a/apps/stage-tamagotchi/src/renderer/stores/controls-island.ts b/apps/stage-tamagotchi/src/renderer/stores/controls-island.ts index 20c9a6af5..b98566f4e 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/controls-island.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/controls-island.ts @@ -15,9 +15,9 @@ export const useControlsIslandStore = defineStore('controls-island', () => { } return { - fadeOnHoverEnabled, + disableFadeOnHover, dontShowItAgainNoticeFadeOnHover, enableFadeOnHover, - disableFadeOnHover, + fadeOnHoverEnabled, } }) diff --git a/apps/stage-tamagotchi/src/renderer/stores/resources.ts b/apps/stage-tamagotchi/src/renderer/stores/resources.ts index f124354c7..1cd278244 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/resources.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/resources.ts @@ -3,13 +3,6 @@ import type { Ref } from 'vue' import { defineStore } from 'pinia' import { computed, ref, watch } from 'vue' -export interface ProgressInfoItem { - filename: string - progress: number - currentSize?: number - totalSize?: number -} - export interface Component { files: Map loading: boolean @@ -24,6 +17,13 @@ export interface Module { reason?: string } +export interface ProgressInfoItem { + currentSize?: number + filename: string + progress: number + totalSize?: number +} + export type Resources = Map> function refDelayed(outRef: Ref, delay: number, options?: { immediate?: boolean }) { @@ -72,7 +72,7 @@ export const useResourcesStore = defineStore('resources', () => { const atLeastOneLoadingDelay5s = refDelayed(atLeastOneLoading, 5000, { immediate: true }) const atLeastOneLoadingDelay10s = refDelayed(atLeastOneLoading, 10000, { immediate: true }) - function updateResourceProgress(module: string, component: string, progress: { filename: string, progress: number, currentSize?: number, totalSize?: number }) { + function updateResourceProgress(module: string, component: string, progress: { currentSize?: number, filename: string, progress: number, totalSize?: number }) { registerModule(module) registerComponent(module, component) @@ -80,9 +80,9 @@ export const useResourcesStore = defineStore('resources', () => { const componentRef = componentOf(module, component)! componentRef.files.set(progress.filename, { + currentSize: progress.currentSize, filename: progress.filename, progress: progress.progress, - currentSize: progress.currentSize, totalSize: progress.totalSize, }) @@ -148,19 +148,19 @@ export const useResourcesStore = defineStore('resources', () => { } return { - resources, atLeastOneLoading, atLeastOneLoadingDelay5s, atLeastOneLoadingDelay10s, + componentOf, + moduleOf, + pendingResources, - updateResourceProgress, - - moduleOf, - registerModule, - setModuleLoading, - componentOf, registerComponent, + registerModule, + resources, setComponentLoading, + setModuleLoading, + updateResourceProgress, } }) diff --git a/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.test.ts b/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.test.ts index 6979b3447..ee7b7db4a 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.test.ts @@ -89,7 +89,7 @@ describe('useServerChannelSettingsStore', async () => { let resolveApply: ((config: { authToken: string hostname: string - tlsConfig: Record | null + tlsConfig: null | Record }) => void) | undefined invokeMocks.applyConfig.mockImplementationOnce(async () => await new Promise((resolve) => { resolveApply = resolve diff --git a/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.ts b/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.ts index 0705ca2cc..5dca51eb9 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.ts @@ -14,10 +14,10 @@ import { } from '../../../shared/eventa' export const useServerChannelSettingsStore = defineStore('tamagotchi-server-channel-settings', () => { - const tlsConfig = useLocalStorage<{ cert?: string, key?: string, passphrase?: string } | null | undefined>('settings/server-channel/websocket-tls-config', null) + const tlsConfig = useLocalStorage('settings/server-channel/websocket-tls-config', null) const hostname = useLocalStorage('settings/server-channel/hostname', '127.0.0.1') const authToken = useLocalStorage('settings/server-channel/auth-token', '') - const lastApplyError = shallowRef(null) + const lastApplyError = shallowRef(null) const syncingWithServer = shallowRef(false) const appliedConfig = shallowRef() @@ -54,9 +54,9 @@ export const useServerChannelSettingsStore = defineStore('tamagotchi-server-chan try { const config = await applyServerChannelConfig({ - tlsConfig: newTls ? {} : null, - hostname: newHost, authToken: newAuth, + hostname: newHost, + tlsConfig: newTls ? {} : null, }) syncConfigFromServer(config) } @@ -77,11 +77,11 @@ export const useServerChannelSettingsStore = defineStore('tamagotchi-server-chan void refreshServerChannelConfig() return { - lastApplyError, appliedConfig, + authToken, + hostname, + lastApplyError, refreshServerChannelConfig, tlsConfig, - hostname, - authToken, } }) diff --git a/apps/stage-tamagotchi/src/renderer/stores/stage-three-runtime-diagnostics.test.ts b/apps/stage-tamagotchi/src/renderer/stores/stage-three-runtime-diagnostics.test.ts index ab814dda7..479228f8a 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/stage-three-runtime-diagnostics.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/stage-three-runtime-diagnostics.test.ts @@ -43,8 +43,8 @@ describe('stage three runtime diagnostics helpers', () => { humanoidMs: 5, lipSyncMs: 6, lookAtMs: 7, - springBoneMs: 8, nodeConstraintMs: 16.7, + springBoneMs: 8, ts: 20, vrmFrameHookMs: 9, vrmRuntimeHookMs: 10, diff --git a/apps/stage-tamagotchi/src/renderer/stores/stage-three-runtime-diagnostics.ts b/apps/stage-tamagotchi/src/renderer/stores/stage-three-runtime-diagnostics.ts index 73064272a..76679b490 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/stage-three-runtime-diagnostics.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/stage-three-runtime-diagnostics.ts @@ -39,6 +39,31 @@ import { export const TRACE_HISTORY_LIMIT = 20 +export interface StageThreeRuntimeHitTestDiagnostics { + lastDurationMs: number + lastReadHeight: number + lastReadWidth: number + lastTimestampMs: number + readCount: number + totalDurationMs: number +} + +export interface StageThreeRuntimeResourceSnapshotDiagnostics { + history: StageThreeRuntimeResourceSnapshotRecord[] + lastAfterDispose?: StageThreeRuntimeResourceSnapshotRecord + lastAfterLoad?: StageThreeRuntimeResourceSnapshotRecord + lastBeforeDispose?: StageThreeRuntimeResourceSnapshotRecord +} + +export interface StageThreeRuntimeResourceSnapshotRecord { + modelSrc?: string + phase: 'after-dispose' | 'after-load' | 'before-dispose' + reason?: VrmLifecycleReason + rendererMemory?: ThreeRendererMemorySnapshot + sceneSummary?: VrmSceneSummarySnapshot + ts: number +} + export interface StageThreeRuntimeThreeRenderDiagnostics { drawCalls: number geometries: number @@ -50,6 +75,18 @@ export interface StageThreeRuntimeThreeRenderDiagnostics { triangles: number } +export interface StageThreeRuntimeVrmLifecycleDiagnostics { + lastDisposeDurationMs: number + lastDisposeEndAt: number + lastDisposeStartAt: number + lastErrorMessage: string + lastLoadDurationMs: number + lastLoadEndAt: number + lastLoadStartAt: number + lastModelSrc: string + lastReason?: VrmLifecycleReason +} + export interface StageThreeRuntimeVrmUpdateDiagnostics { animationMixerMs: number blinkAndSaccadeMs: number @@ -67,111 +104,37 @@ export interface StageThreeRuntimeVrmUpdateDiagnostics { vrmRuntimeHookMs: number } -export interface StageThreeRuntimeHitTestDiagnostics { - lastDurationMs: number - lastReadHeight: number - lastReadWidth: number - lastTimestampMs: number - readCount: number - totalDurationMs: number -} - -export interface StageThreeRuntimeVrmLifecycleDiagnostics { - lastDisposeDurationMs: number - lastDisposeEndAt: number - lastDisposeStartAt: number - lastErrorMessage: string - lastLoadDurationMs: number - lastLoadEndAt: number - lastLoadStartAt: number - lastModelSrc: string - lastReason?: VrmLifecycleReason -} - -export interface StageThreeRuntimeResourceSnapshotRecord { - modelSrc?: string - phase: 'after-dispose' | 'after-load' | 'before-dispose' - reason?: VrmLifecycleReason - rendererMemory?: ThreeRendererMemorySnapshot - sceneSummary?: VrmSceneSummarySnapshot - ts: number -} - -export interface StageThreeRuntimeResourceSnapshotDiagnostics { - history: StageThreeRuntimeResourceSnapshotRecord[] - lastAfterDispose?: StageThreeRuntimeResourceSnapshotRecord - lastAfterLoad?: StageThreeRuntimeResourceSnapshotRecord - lastBeforeDispose?: StageThreeRuntimeResourceSnapshotRecord -} - -export function createDefaultStageThreeRenderDiagnostics(): StageThreeRuntimeThreeRenderDiagnostics { +export function applyHitTestTracePayload( + current: StageThreeRuntimeHitTestDiagnostics, + payload: ThreeHitTestReadTracePayload, +): StageThreeRuntimeHitTestDiagnostics { return { - drawCalls: 0, - geometries: 0, - lastTimestampMs: 0, - lines: 0, - points: 0, - renderCount: 0, - textures: 0, - triangles: 0, + lastDurationMs: payload.durationMs, + lastReadHeight: payload.readHeight, + lastReadWidth: payload.readWidth, + lastTimestampMs: payload.ts, + readCount: current.readCount + 1, + totalDurationMs: current.totalDurationMs + payload.durationMs, } } -export function createDefaultStageVrmUpdateDiagnostics(): StageThreeRuntimeVrmUpdateDiagnostics { - return { - animationMixerMs: 0, - blinkAndSaccadeMs: 0, - deltaMs: 0, - emoteMs: 0, - expressionMs: 0, - frameCount: 0, - humanoidMs: 0, - lastTimestampMs: 0, - lipSyncMs: 0, - lookAtMs: 0, - springBoneMs: 0, - totalMs: 0, - vrmFrameHookMs: 0, - vrmRuntimeHookMs: 0, - } -} - -export function createDefaultStageHitTestDiagnostics(): StageThreeRuntimeHitTestDiagnostics { - return { - lastDurationMs: 0, - lastReadHeight: 0, - lastReadWidth: 0, - lastTimestampMs: 0, - readCount: 0, - totalDurationMs: 0, - } -} - -export function createDefaultStageVrmLifecycleDiagnostics(): StageThreeRuntimeVrmLifecycleDiagnostics { - return { - lastDisposeDurationMs: 0, - lastDisposeEndAt: 0, - lastDisposeStartAt: 0, - lastErrorMessage: '', - lastLoadDurationMs: 0, - lastLoadEndAt: 0, - lastLoadStartAt: 0, - lastModelSrc: '', - } -} - -export function createDefaultStageResourceSnapshotDiagnostics(): StageThreeRuntimeResourceSnapshotDiagnostics { - return { - history: [], - } -} - -export function pushTraceHistory( - history: StageThreeRuntimeResourceSnapshotRecord[], +export function applySnapshotRecord( + current: StageThreeRuntimeResourceSnapshotDiagnostics, record: StageThreeRuntimeResourceSnapshotRecord, -) { - const nextHistory = [...history, record] - return nextHistory.slice(-TRACE_HISTORY_LIMIT) +): StageThreeRuntimeResourceSnapshotDiagnostics { + const next: StageThreeRuntimeResourceSnapshotDiagnostics = { + ...current, + history: pushTraceHistory(current.history, record), + } + + if (record.phase === 'after-load') + next.lastAfterLoad = record + else if (record.phase === 'before-dispose') + next.lastBeforeDispose = record + else if (record.phase === 'after-dispose') + next.lastAfterDispose = record + + return next } export function applyThreeRenderTracePayload( @@ -190,20 +153,6 @@ export function applyThreeRenderTracePayload( } } -export function applyHitTestTracePayload( - current: StageThreeRuntimeHitTestDiagnostics, - payload: ThreeHitTestReadTracePayload, -): StageThreeRuntimeHitTestDiagnostics { - return { - lastDurationMs: payload.durationMs, - lastReadHeight: payload.readHeight, - lastReadWidth: payload.readWidth, - lastTimestampMs: payload.ts, - readCount: current.readCount + 1, - totalDurationMs: current.totalDurationMs + payload.durationMs, - } -} - export function applyVrmUpdateTracePayload( current: StageThreeRuntimeVrmUpdateDiagnostics, payload: VrmUpdateFrameTracePayload, @@ -226,15 +175,97 @@ export function applyVrmUpdateTracePayload( } } -function applyLoadStartPayload( +export function createDefaultStageHitTestDiagnostics(): StageThreeRuntimeHitTestDiagnostics { + return { + lastDurationMs: 0, + lastReadHeight: 0, + lastReadWidth: 0, + lastTimestampMs: 0, + readCount: 0, + totalDurationMs: 0, + } +} + +export function createDefaultStageResourceSnapshotDiagnostics(): StageThreeRuntimeResourceSnapshotDiagnostics { + return { + history: [], + } +} + +export function createDefaultStageThreeRenderDiagnostics(): StageThreeRuntimeThreeRenderDiagnostics { + return { + drawCalls: 0, + geometries: 0, + lastTimestampMs: 0, + lines: 0, + points: 0, + renderCount: 0, + textures: 0, + triangles: 0, + } +} + +export function createDefaultStageVrmLifecycleDiagnostics(): StageThreeRuntimeVrmLifecycleDiagnostics { + return { + lastDisposeDurationMs: 0, + lastDisposeEndAt: 0, + lastDisposeStartAt: 0, + lastErrorMessage: '', + lastLoadDurationMs: 0, + lastLoadEndAt: 0, + lastLoadStartAt: 0, + lastModelSrc: '', + } +} + +export function createDefaultStageVrmUpdateDiagnostics(): StageThreeRuntimeVrmUpdateDiagnostics { + return { + animationMixerMs: 0, + blinkAndSaccadeMs: 0, + deltaMs: 0, + emoteMs: 0, + expressionMs: 0, + frameCount: 0, + humanoidMs: 0, + lastTimestampMs: 0, + lipSyncMs: 0, + lookAtMs: 0, + springBoneMs: 0, + totalMs: 0, + vrmFrameHookMs: 0, + vrmRuntimeHookMs: 0, + } +} + +export function pushTraceHistory( + history: StageThreeRuntimeResourceSnapshotRecord[], + record: StageThreeRuntimeResourceSnapshotRecord, +) { + const nextHistory = [...history, record] + return nextHistory.slice(-TRACE_HISTORY_LIMIT) +} + +function applyDisposeEndPayload( current: StageThreeRuntimeVrmLifecycleDiagnostics, - payload: VrmLoadStartTracePayload, + payload: VrmDisposeEndTracePayload, ): StageThreeRuntimeVrmLifecycleDiagnostics { return { ...current, - lastErrorMessage: '', - lastLoadStartAt: payload.ts, - lastModelSrc: payload.modelSrc ?? '', + lastDisposeDurationMs: payload.durationMs ?? 0, + lastDisposeEndAt: payload.ts, + lastModelSrc: payload.modelSrc ?? current.lastModelSrc, + lastReason: payload.reason, + } +} + +function applyDisposeStartPayload( + current: StageThreeRuntimeVrmLifecycleDiagnostics, + payload: VrmDisposeStartTracePayload, +): StageThreeRuntimeVrmLifecycleDiagnostics { + return { + ...current, + lastDisposeStartAt: payload.ts, + lastModelSrc: payload.modelSrc ?? current.lastModelSrc, lastReason: payload.reason, } } @@ -265,34 +296,22 @@ function applyLoadErrorPayload( } } -function applyDisposeStartPayload( +function applyLoadStartPayload( current: StageThreeRuntimeVrmLifecycleDiagnostics, - payload: VrmDisposeStartTracePayload, + payload: VrmLoadStartTracePayload, ): StageThreeRuntimeVrmLifecycleDiagnostics { return { ...current, - lastDisposeStartAt: payload.ts, - lastModelSrc: payload.modelSrc ?? current.lastModelSrc, - lastReason: payload.reason, - } -} - -function applyDisposeEndPayload( - current: StageThreeRuntimeVrmLifecycleDiagnostics, - payload: VrmDisposeEndTracePayload, -): StageThreeRuntimeVrmLifecycleDiagnostics { - return { - ...current, - lastDisposeDurationMs: payload.durationMs ?? 0, - lastDisposeEndAt: payload.ts, - lastModelSrc: payload.modelSrc ?? current.lastModelSrc, + lastErrorMessage: '', + lastLoadStartAt: payload.ts, + lastModelSrc: payload.modelSrc ?? '', lastReason: payload.reason, } } function createSnapshotRecord( phase: StageThreeRuntimeResourceSnapshotRecord['phase'], - payload: VrmDisposeStartTracePayload | VrmDisposeEndTracePayload | VrmLoadEndTracePayload, + payload: VrmDisposeEndTracePayload | VrmDisposeStartTracePayload | VrmLoadEndTracePayload, ): StageThreeRuntimeResourceSnapshotRecord { return { modelSrc: payload.modelSrc, @@ -304,25 +323,6 @@ function createSnapshotRecord( } } -export function applySnapshotRecord( - current: StageThreeRuntimeResourceSnapshotDiagnostics, - record: StageThreeRuntimeResourceSnapshotRecord, -): StageThreeRuntimeResourceSnapshotDiagnostics { - const next: StageThreeRuntimeResourceSnapshotDiagnostics = { - ...current, - history: pushTraceHistory(current.history, record), - } - - if (record.phase === 'after-load') - next.lastAfterLoad = record - else if (record.phase === 'before-dispose') - next.lastBeforeDispose = record - else if (record.phase === 'after-dispose') - next.lastAfterDispose = record - - return next -} - export const useStageThreeRuntimeDiagnosticsStore = defineStore('stageThreeRuntimeDiagnostics', () => { const tracing = ref(false) const threeRender = ref(createDefaultStageThreeRenderDiagnostics()) @@ -390,17 +390,17 @@ export const useStageThreeRuntimeDiagnosticsStore = defineStore('stageThreeRunti const envelope: StageThreeRuntimeTraceEnvelope = payload.envelope switch (envelope.type) { - case 'three-render-info': - applyRenderPayload(envelope.payload) - break case 'three-hit-test-read': applyHitTestPayload(envelope.payload) break - case 'vrm-update-frame': - applyVrmUpdatePayload(envelope.payload) + case 'three-render-info': + applyRenderPayload(envelope.payload) break - case 'vrm-load-start': - applyVrmLoadStartPayload(envelope.payload) + case 'vrm-dispose-end': + applyVrmDisposeEndPayload(envelope.payload) + break + case 'vrm-dispose-start': + applyVrmDisposeStartPayload(envelope.payload) break case 'vrm-load-end': applyVrmLoadEndPayload(envelope.payload) @@ -408,11 +408,11 @@ export const useStageThreeRuntimeDiagnosticsStore = defineStore('stageThreeRunti case 'vrm-load-error': applyVrmLoadErrorPayload(envelope.payload) break - case 'vrm-dispose-start': - applyVrmDisposeStartPayload(envelope.payload) + case 'vrm-load-start': + applyVrmLoadStartPayload(envelope.payload) break - case 'vrm-dispose-end': - applyVrmDisposeEndPayload(envelope.payload) + case 'vrm-update-frame': + applyVrmUpdatePayload(envelope.payload) break default: break @@ -479,8 +479,8 @@ export const useStageThreeRuntimeDiagnosticsStore = defineStore('stageThreeRunti return { hitTest, - resourceSnapshots, resetSamples, + resourceSnapshots, startTracing, stopTracing, threeRender, diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/built-in.test.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/built-in.test.ts index 270db244d..b733945de 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/built-in.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/built-in.test.ts @@ -6,12 +6,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' function executableTool(name: string): Tool { return { - type: 'function', + execute: vi.fn(), function: { name, - parameters: { type: 'object', properties: {} }, + parameters: { properties: {}, type: 'object' }, }, - execute: vi.fn(), + type: 'function', } } @@ -39,12 +39,12 @@ describe('useTamagotchiBuiltinToolsStore', async () => { expect(toolsStore.activeTools).toEqual([]) expect(toolsStore.tools.map(tool => ({ - id: tool.id, defaultActive: tool.defaultActive, + id: tool.id, }))).toEqual([ - { id: 'tamagotchi:image_journal', defaultActive: false }, - { id: 'tamagotchi:stage_widgets', defaultActive: false }, - { id: 'tamagotchi:get_weather', defaultActive: false }, + { defaultActive: false, id: 'tamagotchi:image_journal' }, + { defaultActive: false, id: 'tamagotchi:stage_widgets' }, + { defaultActive: false, id: 'tamagotchi:get_weather' }, ]) expect(toolsStore.getToolsByNames('get_weather')[0]?.function.name).toBe('get_weather') }) 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 403a822d4..c5d6ea441 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 @@ -8,11 +8,11 @@ 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/', + origin: 'http://localhost', + pathname: '/', + search: '', } vi.stubGlobal('window', { location: mockLocation, @@ -27,39 +27,39 @@ describe('image_journal config snapshot', () => { it('extracts plain values instead of leaking Ref objects', () => { const config = resolveArtistryConfigFromStore({ - activeProvider: { value: 'comfyui' }, activeModel: { value: 'flux' }, - defaultPromptPrefix: { value: 'anime style' }, - providerOptions: { value: { seed: 42 } }, - comfyuiServerUrl: { value: 'http://localhost:8188' }, - comfyuiSavedWorkflows: { value: [{ id: 'wf-1' }] }, + activeProvider: { value: 'comfyui' }, comfyuiActiveWorkflow: { value: 'wf-1' }, - replicateApiKey: { value: 'r8_xxx' }, - replicateDefaultModel: { value: 'black-forest-labs/flux-schnell' }, - replicateAspectRatio: { value: '16:9' }, - replicateInferenceSteps: { value: 4 }, + comfyuiSavedWorkflows: { value: [{ id: 'wf-1' }] }, + comfyuiServerUrl: { value: 'http://localhost:8188' }, + defaultPromptPrefix: { value: 'anime style' }, nanobananaApiKey: { value: 'AIza-test' }, nanobananaModel: { value: 'gemini-3.1-flash-image-preview' }, nanobananaResolution: { value: '1K' }, + providerOptions: { value: { seed: 42 } }, + replicateApiKey: { value: 'r8_xxx' }, + replicateAspectRatio: { value: '16:9' }, + replicateDefaultModel: { value: 'black-forest-labs/flux-schnell' }, + replicateInferenceSteps: { value: 4 }, }) expect(config).toEqual({ - provider: 'comfyui', - model: 'flux', - promptPrefix: 'anime style', - options: { seed: 42 }, globals: { - comfyuiServerUrl: 'http://localhost:8188', - comfyuiSavedWorkflows: [{ id: 'wf-1' }], comfyuiActiveWorkflow: 'wf-1', - replicateApiKey: 'r8_xxx', - replicateDefaultModel: 'black-forest-labs/flux-schnell', - replicateAspectRatio: '16:9', - replicateInferenceSteps: 4, + comfyuiSavedWorkflows: [{ id: 'wf-1' }], + comfyuiServerUrl: 'http://localhost:8188', nanobananaApiKey: 'AIza-test', nanobananaModel: 'gemini-3.1-flash-image-preview', nanobananaResolution: '1K', + replicateApiKey: 'r8_xxx', + replicateAspectRatio: '16:9', + replicateDefaultModel: 'black-forest-labs/flux-schnell', + replicateInferenceSteps: 4, }, + model: 'flux', + options: { seed: 42 }, + promptPrefix: 'anime style', + provider: 'comfyui', }) }) }) diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/image-journal.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/image-journal.ts index 470141b46..aaff245b7 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/image-journal.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/image-journal.ts @@ -12,6 +12,8 @@ import { rawTool } from '@xsai/tool' import { widgetsAdd } from '../../../../shared/eventa' +type Invokers = ReturnType + export function getArtistryConfig(): ResolvedArtistryConfig { return resolveArtistryConfigFromStore(useArtistryStore()) } @@ -19,12 +21,10 @@ export function getArtistryConfig(): ResolvedArtistryConfig { function createInvokers() { const { context } = createContext(window.electron.ipcRenderer) return { - generateHeadless: defineInvoke(context, artistryGenerateHeadless), addWidget: defineInvoke(context, widgetsAdd), + generateHeadless: defineInvoke(context, artistryGenerateHeadless), } } - -type Invokers = ReturnType let invokeCache: Invokers | undefined function getInvokers(): Invokers { @@ -34,29 +34,29 @@ function getInvokers(): Invokers { } const imageJournalParams = { - type: 'object', + additionalProperties: false, properties: { action: { - type: 'string', - enum: ['create', 'apply'], description: 'Choose "create" to generate a new image, or "apply" to use an existing one.', - }, - prompt: { - type: ['string', 'null'], - description: 'Description for the image (required for "create").', - }, - title: { - type: ['string', 'null'], - description: 'Label for the entry (optional).', - }, - query: { - type: ['string', 'null'], - description: 'Search term for existing images (required for "apply").', + enum: ['create', 'apply'], + type: 'string', }, mode: { - type: ['string', 'null'], - enum: ['inline', 'widget', 'bg', 'bg_widget', null], description: 'Display mode: "inline" (in chat), "widget" (overlay), "bg" (environment), or "bg_widget" (both). Defaults to character preference.', + enum: ['inline', 'widget', 'bg', 'bg_widget', null], + type: ['string', 'null'], + }, + prompt: { + description: 'Description for the image (required for "create").', + type: ['string', 'null'], + }, + query: { + description: 'Search term for existing images (required for "apply").', + type: ['string', 'null'], + }, + title: { + description: 'Label for the entry (optional).', + type: ['string', 'null'], }, }, required: [ @@ -66,10 +66,10 @@ const imageJournalParams = { 'query', 'mode', ], - additionalProperties: false, + type: 'object', } satisfies JsonSchema -async function executeCreateImageJournalEntry(params: { prompt?: string, title?: string, mode?: 'inline' | 'widget' | 'bg' | 'bg_widget' }) { +async function executeCreateImageJournalEntry(params: { mode?: 'bg' | 'bg_widget' | 'inline' | 'widget', prompt?: string, title?: string }) { if (!params.prompt?.trim()) throw new Error('prompt is required for image_journal.create') @@ -81,11 +81,11 @@ async function executeCreateImageJournalEntry(params: { prompt?: string, title?: const airiExt = activeCard?.extensions?.airi const cardArtistry = airiExt?.modules?.artistry const artistryConfig = { - provider: cardArtistry?.provider || globalArtistryConfig.provider, - model: cardArtistry?.model || globalArtistryConfig.model, - promptPrefix: cardArtistry?.promptPrefix || globalArtistryConfig.promptPrefix, - options: cardArtistry?.options || globalArtistryConfig.options, globals: globalArtistryConfig.globals, + model: cardArtistry?.model || globalArtistryConfig.model, + options: cardArtistry?.options || globalArtistryConfig.options, + promptPrefix: cardArtistry?.promptPrefix || globalArtistryConfig.promptPrefix, + provider: cardArtistry?.provider || globalArtistryConfig.provider, } const title = params.title || `Generation ${new Date().toLocaleString()}` @@ -98,11 +98,11 @@ async function executeCreateImageJournalEntry(params: { prompt?: string, title?: try { const artistryResult = await generateHeadless({ - prompt: artistryConfig.promptPrefix ? `${artistryConfig.promptPrefix} ${params.prompt}` : params.prompt as string, - model: artistryConfig.model as string, - provider: artistryConfig.provider as string, - options: JSON.parse(JSON.stringify(artistryConfig.options || {})), globals: JSON.parse(JSON.stringify(artistryConfig.globals || {})), + model: artistryConfig.model as string, + options: JSON.parse(JSON.stringify(artistryConfig.options || {})), + prompt: artistryConfig.promptPrefix ? `${artistryConfig.promptPrefix} ${params.prompt}` : params.prompt as string, + provider: artistryConfig.provider as string, }) if (artistryResult.error || (!artistryResult.base64 && !artistryResult.imageUrl)) { @@ -143,12 +143,12 @@ async function executeCreateImageJournalEntry(params: { prompt?: string, title?: await addWidget({ componentName: 'artistry', componentProps: { - status: 'done', + _skipIngestion: true, entryId, imageUrl: artistryResult.imageUrl || artistryResult.base64, prompt: params.prompt as string, + status: 'done', title, - _skipIngestion: true, }, size: 'm', ttlMs: 0, @@ -161,12 +161,12 @@ async function executeCreateImageJournalEntry(params: { prompt?: string, title?: // Return structured result for UI rendering return JSON.stringify({ - message: `Image created in ${mode} mode${mode === 'bg' || mode === 'bg_widget' ? ' and set as background' : ''}.`, entryId, imageUrl: artistryResult.imageUrl || artistryResult.base64, - title, - prompt: params.prompt, + message: `Image created in ${mode} mode${mode === 'bg' || mode === 'bg_widget' ? ' and set as background' : ''}.`, mode, + prompt: params.prompt, + title, }) } catch (e) { @@ -175,6 +175,14 @@ async function executeCreateImageJournalEntry(params: { prompt?: string, title?: } } +async function executeImageJournalAction(params: any) { + if (params.action === 'create') + return await executeCreateImageJournalEntry(params) + if (params.action === 'apply' || params.action === 'set_as_background') + return await executeSetAsBackground(params) + return 'No action performed.' +} + async function executeSetAsBackground(params: { query?: string }) { if (!params.query?.trim()) return 'Error: query is required for image_journal.apply. Provide a title or ID to search for.' @@ -218,19 +226,11 @@ async function executeSetAsBackground(params: { query?: string }) { return `No match for "${params.query}".${available.length > 0 ? ` Try: ${available.join(', ')}` : ''}` } -async function executeImageJournalAction(params: any) { - if (params.action === 'create') - return await executeCreateImageJournalEntry(params) - if (params.action === 'apply' || params.action === 'set_as_background') - return await executeSetAsBackground(params) - return 'No action performed.' -} - const tools: Promise[] = [ Promise.resolve(rawTool({ - name: 'image_journal', description: 'Manage AI-generated images. Use "create" to generate and display images. An optional "mode" (inline, widget, bg, bg_widget) can override the default character routing preference. Use "apply" to switch to an existing image from the journal.', execute: params => executeImageJournalAction(params), + name: 'image_journal', parameters: imageJournalParams, })), ] diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/weather-api.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/weather-api.ts index 2d45c9205..d028e665d 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/weather-api.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/weather-api.ts @@ -2,23 +2,23 @@ interface GeocodingResult { results?: Array<{ - name: string + country: string latitude: number longitude: number - country: string + name: string timezone: string }> } interface OpenMeteoWeather { current: { - temperature_2m: number - relative_humidity_2m: number apparent_temperature: number + is_day: number + precipitation: number + relative_humidity_2m: number + temperature_2m: number weather_code: number wind_speed_10m: number - precipitation: number - is_day: number } daily?: { temperature_2m_max: number[] @@ -29,54 +29,107 @@ interface OpenMeteoWeather { // -- WMO Weather Code Mapping -- // https://open-meteo.com/en/docs#weathervariables -const wmoCodeToCondition: Record = { - 0: { conditionCode: 'clear-day', condition: 'Clear sky' }, - 1: { conditionCode: 'clear-day', condition: 'Mainly clear' }, - 2: { conditionCode: 'partly-cloudy-day', condition: 'Partly cloudy' }, - 3: { conditionCode: 'overcast', condition: 'Overcast' }, - 45: { conditionCode: 'fog', condition: 'Fog' }, - 48: { conditionCode: 'fog', condition: 'Depositing rime fog' }, - 51: { conditionCode: 'drizzle', condition: 'Light drizzle' }, - 53: { conditionCode: 'drizzle', condition: 'Moderate drizzle' }, - 55: { conditionCode: 'drizzle', condition: 'Dense drizzle' }, - 56: { conditionCode: 'sleet', condition: 'Freezing drizzle' }, - 57: { conditionCode: 'sleet', condition: 'Dense freezing drizzle' }, - 61: { conditionCode: 'rain', condition: 'Slight rain' }, - 63: { conditionCode: 'rain', condition: 'Moderate rain' }, - 65: { conditionCode: 'extreme-rain', condition: 'Heavy rain' }, - 66: { conditionCode: 'sleet', condition: 'Freezing rain' }, - 67: { conditionCode: 'sleet', condition: 'Heavy freezing rain' }, - 71: { conditionCode: 'snow', condition: 'Slight snow' }, - 73: { conditionCode: 'snow', condition: 'Moderate snow' }, - 75: { conditionCode: 'extreme-snow', condition: 'Heavy snow' }, - 77: { conditionCode: 'snow', condition: 'Snow grains' }, - 80: { conditionCode: 'rain', condition: 'Slight rain showers' }, - 81: { conditionCode: 'rain', condition: 'Moderate rain showers' }, - 82: { conditionCode: 'extreme-rain', condition: 'Violent rain showers' }, - 85: { conditionCode: 'snow', condition: 'Slight snow showers' }, - 86: { conditionCode: 'extreme-snow', condition: 'Heavy snow showers' }, - 95: { conditionCode: 'thunderstorm', condition: 'Thunderstorm' }, - 96: { conditionCode: 'thunderstorm', condition: 'Thunderstorm with slight hail' }, - 99: { conditionCode: 'thunderstorm', condition: 'Thunderstorm with heavy hail' }, +const wmoCodeToCondition: Record = { + 0: { condition: 'Clear sky', conditionCode: 'clear-day' }, + 1: { condition: 'Mainly clear', conditionCode: 'clear-day' }, + 2: { condition: 'Partly cloudy', conditionCode: 'partly-cloudy-day' }, + 3: { condition: 'Overcast', conditionCode: 'overcast' }, + 45: { condition: 'Fog', conditionCode: 'fog' }, + 48: { condition: 'Depositing rime fog', conditionCode: 'fog' }, + 51: { condition: 'Light drizzle', conditionCode: 'drizzle' }, + 53: { condition: 'Moderate drizzle', conditionCode: 'drizzle' }, + 55: { condition: 'Dense drizzle', conditionCode: 'drizzle' }, + 56: { condition: 'Freezing drizzle', conditionCode: 'sleet' }, + 57: { condition: 'Dense freezing drizzle', conditionCode: 'sleet' }, + 61: { condition: 'Slight rain', conditionCode: 'rain' }, + 63: { condition: 'Moderate rain', conditionCode: 'rain' }, + 65: { condition: 'Heavy rain', conditionCode: 'extreme-rain' }, + 66: { condition: 'Freezing rain', conditionCode: 'sleet' }, + 67: { condition: 'Heavy freezing rain', conditionCode: 'sleet' }, + 71: { condition: 'Slight snow', conditionCode: 'snow' }, + 73: { condition: 'Moderate snow', conditionCode: 'snow' }, + 75: { condition: 'Heavy snow', conditionCode: 'extreme-snow' }, + 77: { condition: 'Snow grains', conditionCode: 'snow' }, + 80: { condition: 'Slight rain showers', conditionCode: 'rain' }, + 81: { condition: 'Moderate rain showers', conditionCode: 'rain' }, + 82: { condition: 'Violent rain showers', conditionCode: 'extreme-rain' }, + 85: { condition: 'Slight snow showers', conditionCode: 'snow' }, + 86: { condition: 'Heavy snow showers', conditionCode: 'extreme-snow' }, + 95: { condition: 'Thunderstorm', conditionCode: 'thunderstorm' }, + 96: { condition: 'Thunderstorm with slight hail', conditionCode: 'thunderstorm' }, + 99: { condition: 'Thunderstorm with heavy hail', conditionCode: 'thunderstorm' }, } export interface WeatherData { city: string - country: string - temperature: string condition: string conditionCode: string - isNight: boolean + country: string feelsLike: string - humidity: string - wind: string - precipitation: string high?: string + humidity: string + isNight: boolean low?: string + precipitation: string + temperature: string + wind: string } -export function mapWmoCode(code: number, isNight: boolean): { conditionCode: string, condition: string } { - const mapped = wmoCodeToCondition[code] ?? { conditionCode: 'clear-day', condition: 'Unknown' } +export async function fetchWeather(city: string): Promise { + const geo = await geocodeCity(city) + + const params = new URLSearchParams({ + current: 'temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,precipitation,is_day', + daily: 'temperature_2m_max,temperature_2m_min', + forecast_days: '1', + latitude: String(geo.latitude), + longitude: String(geo.longitude), + }) + + const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`) + + if (!res.ok) + throw new Error(`Weather request failed: ${res.status}`) + + const data: OpenMeteoWeather = await res.json() + const current = data.current + const isNight = current.is_day === 0 + const { condition, conditionCode } = mapWmoCode(current.weather_code, isNight) + + return { + city: geo.name, + condition, + conditionCode, + country: geo.country, + feelsLike: `${Math.round(current.apparent_temperature)}°C`, + high: data.daily ? `${Math.round(data.daily.temperature_2m_max[0])}°C` : undefined, + humidity: `${current.relative_humidity_2m}%`, + isNight, + low: data.daily ? `${Math.round(data.daily.temperature_2m_min[0])}°C` : undefined, + precipitation: `${current.precipitation} mm`, + temperature: `${Math.round(current.temperature_2m)}°C`, + wind: `${Math.round(current.wind_speed_10m)} km/h`, + } +} + +export async function geocodeCity(city: string): Promise<{ country: string, latitude: number, longitude: number, name: string }> { + const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json` + const res = await fetch(url) + + if (!res.ok) + throw new Error(`Geocoding request failed: ${res.status}`) + + const data: GeocodingResult = await res.json() + + if (!data.results?.length) + throw new Error(`City not found: "${city}"`) + + const result = data.results[0] + return { country: result.country, latitude: result.latitude, longitude: result.longitude, name: result.name } +} + +export function mapWmoCode(code: number, isNight: boolean): { condition: string, conditionCode: string } { + const mapped = wmoCodeToCondition[code] ?? { condition: 'Unknown', conditionCode: 'clear-day' } if (isNight) { const nightVariants: Record = { @@ -91,56 +144,3 @@ export function mapWmoCode(code: number, isNight: boolean): { conditionCode: str return mapped } - -export async function geocodeCity(city: string): Promise<{ name: string, latitude: number, longitude: number, country: string }> { - const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json` - const res = await fetch(url) - - if (!res.ok) - throw new Error(`Geocoding request failed: ${res.status}`) - - const data: GeocodingResult = await res.json() - - if (!data.results?.length) - throw new Error(`City not found: "${city}"`) - - const result = data.results[0] - return { name: result.name, latitude: result.latitude, longitude: result.longitude, country: result.country } -} - -export async function fetchWeather(city: string): Promise { - const geo = await geocodeCity(city) - - const params = new URLSearchParams({ - latitude: String(geo.latitude), - longitude: String(geo.longitude), - current: 'temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,precipitation,is_day', - daily: 'temperature_2m_max,temperature_2m_min', - forecast_days: '1', - }) - - const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`) - - if (!res.ok) - throw new Error(`Weather request failed: ${res.status}`) - - const data: OpenMeteoWeather = await res.json() - const current = data.current - const isNight = current.is_day === 0 - const { conditionCode, condition } = mapWmoCode(current.weather_code, isNight) - - return { - city: geo.name, - country: geo.country, - temperature: `${Math.round(current.temperature_2m)}°C`, - condition, - conditionCode, - isNight, - feelsLike: `${Math.round(current.apparent_temperature)}°C`, - humidity: `${current.relative_humidity_2m}%`, - wind: `${Math.round(current.wind_speed_10m)} km/h`, - precipitation: `${current.precipitation} mm`, - high: data.daily ? `${Math.round(data.daily.temperature_2m_max[0])}°C` : undefined, - low: data.daily ? `${Math.round(data.daily.temperature_2m_min[0])}°C` : undefined, - } -} diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/weather.test.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/weather.test.ts index 1bb0254d7..7927f1e4d 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/weather.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/weather.test.ts @@ -63,8 +63,8 @@ describe('weather tool helpers', () => { describe('geocodeCity', () => { it('throws on empty results', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, json: () => Promise.resolve({ results: [] }), + ok: true, })) await expect(geocodeCity('NonexistentCity')).rejects.toThrow('City not found') @@ -74,10 +74,10 @@ describe('weather tool helpers', () => { it('returns first result', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, json: () => Promise.resolve({ - results: [{ name: 'Tokyo', latitude: 35.68, longitude: 139.69, country: 'Japan', timezone: 'Asia/Tokyo' }], + results: [{ country: 'Japan', latitude: 35.68, longitude: 139.69, name: 'Tokyo', timezone: 'Asia/Tokyo' }], }), + ok: true, })) const result = await geocodeCity('Tokyo') diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/weather.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/weather.ts index e12d355e2..8001189c5 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/weather.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/weather.ts @@ -20,9 +20,9 @@ async function executeGetWeather(input: { city: string }): Promise { await executeWidgetAction({ action: 'spawn', - id: `weather-${weather.city.toLowerCase().replace(/\s+/g, '-')}`, componentName: 'weather', componentProps: weather, + id: `weather-${weather.city.toLowerCase().replace(/\s+/g, '-')}`, size: 'm', ttlSeconds: 60, }) @@ -32,9 +32,9 @@ async function executeGetWeather(input: { city: string }): Promise { const tools: Promise[] = [ tool({ - name: 'get_weather', description: 'Get current weather for a city and display it as an overlay widget. Returns weather summary text.', execute: executeGetWeather, + name: 'get_weather', parameters: weatherParams, }), ] diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.test.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.test.ts index 46170ce93..ec655799e 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.test.ts @@ -20,21 +20,21 @@ const hasAihubmixApiKey = Boolean(aihubmixApiKey) const aihubmixBaseUrl = normalizeBaseUrl(process.env.AIHUBMIX_BASE_URL) const configuredAihubmixModel = process.env.AIHUBMIX_MODEL?.trim() +interface AihubmixErrorResponse { + error?: { + code?: string + message?: string + param?: string + type?: string + } +} + interface AihubmixModelListResponse { data?: Array<{ id?: string }> } -interface AihubmixErrorResponse { - error?: { - message?: string - type?: string - param?: string - code?: string - } -} - interface CurlJsonResponse { body: T status: number @@ -51,6 +51,28 @@ function getObjectSchema(schema?: JsonSchema) { return candidates.find((candidate): candidate is JsonSchema => Boolean(candidate && typeof candidate === 'object' && !Array.isArray(candidate) && candidate.type === 'object')) } +/** + * Builds the exact `stage_widgets` tool schema AIRI sends to the provider. + * + * Use when: + * - The integration test needs to prove the live provider sees the same tool schema as AIRI + * + * Expects: + * - `widgetsTools()` resolves in the Vitest Node runtime + * + * Returns: + * - The `stage_widgets` tool definition + */ +async function getStageWidgetsTool(): Promise { + const tools = await widgetsTools() + const stageWidgets = tools.find(tool => tool.function.name === 'stage_widgets') + + if (!stageWidgets) + throw new Error('Unable to resolve the stage_widgets tool definition.') + + return stageWidgets +} + /** * Normalizes the configured AIHubMix base URL to a trailing-slash form. * @@ -67,6 +89,55 @@ function normalizeBaseUrl(value: string | undefined): string { return normalized } +/** + * Picks one likely chat-capable model for the local provider repro. + * + * Use when: + * - The env file does not pin `AIHUBMIX_MODEL` + * - A live schema repro still needs a concrete chat model id + * + * Expects: + * - `/models` returns provider model ids + * + * Returns: + * - A concrete chat model id to use with `/chat/completions` + */ +async function resolveAihubmixModel(): Promise { + if (configuredAihubmixModel) + return configuredAihubmixModel + + const response = await runCurlJson({ + headers: [ + `Authorization: Bearer ${aihubmixApiKey}`, + ], + url: new URL('models', aihubmixBaseUrl).toString(), + }) + expect(response.status).toBe(200) + + const modelIds = (response.body.data ?? []) + .map(entry => entry.id?.trim()) + .filter((value): value is string => Boolean(value)) + + const preferredModel = [ + 'gpt-4o-mini', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'gpt-4o', + ].find(candidate => modelIds.includes(candidate)) + + if (preferredModel) + return preferredModel + + const fallbackModel = modelIds.find(model => + ['embed', 'embedding', 'tts', 'whisper', 'rerank'].every(fragment => !model.toLowerCase().includes(fragment)), + ) + + if (!fallbackModel) + throw new Error('Unable to resolve an AIHubMix chat model. Set AIHUBMIX_MODEL in .env.local.') + + return fallbackModel +} + /** * Executes one `curl` JSON request and returns both the body and HTTP status. * @@ -136,77 +207,6 @@ async function runCurlJson(options: { } } -/** - * Picks one likely chat-capable model for the local provider repro. - * - * Use when: - * - The env file does not pin `AIHUBMIX_MODEL` - * - A live schema repro still needs a concrete chat model id - * - * Expects: - * - `/models` returns provider model ids - * - * Returns: - * - A concrete chat model id to use with `/chat/completions` - */ -async function resolveAihubmixModel(): Promise { - if (configuredAihubmixModel) - return configuredAihubmixModel - - const response = await runCurlJson({ - url: new URL('models', aihubmixBaseUrl).toString(), - headers: [ - `Authorization: Bearer ${aihubmixApiKey}`, - ], - }) - expect(response.status).toBe(200) - - const modelIds = (response.body.data ?? []) - .map(entry => entry.id?.trim()) - .filter((value): value is string => Boolean(value)) - - const preferredModel = [ - 'gpt-4o-mini', - 'gpt-4.1-mini', - 'gpt-4.1-nano', - 'gpt-4o', - ].find(candidate => modelIds.includes(candidate)) - - if (preferredModel) - return preferredModel - - const fallbackModel = modelIds.find(model => - ['embed', 'embedding', 'tts', 'whisper', 'rerank'].every(fragment => !model.toLowerCase().includes(fragment)), - ) - - if (!fallbackModel) - throw new Error('Unable to resolve an AIHubMix chat model. Set AIHUBMIX_MODEL in .env.local.') - - return fallbackModel -} - -/** - * Builds the exact `stage_widgets` tool schema AIRI sends to the provider. - * - * Use when: - * - The integration test needs to prove the live provider sees the same tool schema as AIRI - * - * Expects: - * - `widgetsTools()` resolves in the Vitest Node runtime - * - * Returns: - * - The `stage_widgets` tool definition - */ -async function getStageWidgetsTool(): Promise { - const tools = await widgetsTools() - const stageWidgets = tools.find(tool => tool.function.name === 'stage_widgets') - - if (!stageWidgets) - throw new Error('Unable to resolve the stage_widgets tool definition.') - - return stageWidgets -} - describe('widgets tool helpers', () => { describe('provider-facing schema reproduction', () => { it('uses a provider-safe windowSize schema for strict tool validation', async () => { @@ -280,24 +280,24 @@ describe('widgets tool helpers', () => { // This test proves whether AIHubMix currently rejects the tool with the same provider- // side validation error reported in the bug report. const response = await runCurlJson({ - method: 'POST', - url: new URL('chat/completions', aihubmixBaseUrl).toString(), + body: JSON.stringify({ + messages: [ + { + content: 'Open the widgets window.', + role: 'user', + }, + ], + model, + temperature: 0, + tool_choice: 'auto', + tools: [stageWidgetsTool], + }), headers: [ `Authorization: Bearer ${aihubmixApiKey}`, 'Content-Type: application/json', ], - body: JSON.stringify({ - model, - messages: [ - { - role: 'user', - content: 'Open the widgets window.', - }, - ], - tools: [stageWidgetsTool], - tool_choice: 'auto', - temperature: 0, - }), + method: 'POST', + url: new URL('chat/completions', aihubmixBaseUrl).toString(), }) const payload = response.body @@ -331,12 +331,12 @@ describe('widgets tool helpers', () => { }) describe('executeWidgetAction with mocked invokers', () => { const makeInvokers = (): WidgetInvokers => ({ - prepareWindow: vi.fn(), - openWindow: vi.fn(), addWidget: vi.fn(), - updateWidget: vi.fn(), - removeWidget: vi.fn(), clearWidgets: vi.fn(), + openWindow: vi.fn(), + prepareWindow: vi.fn(), + removeWidget: vi.fn(), + updateWidget: vi.fn(), }) it('spawns with ttl conversion and parsed props', async () => { @@ -345,9 +345,9 @@ describe('widgets tool helpers', () => { const result = await executeWidgetAction({ action: 'spawn', - id: ' abc123 ', componentName: 'weather', componentProps: '{"city":"Tokyo"}', + id: ' abc123 ', size: 'm', ttlSeconds: 2, }, { invokers }) @@ -355,9 +355,9 @@ describe('widgets tool helpers', () => { expect(result).toContain('abc123') expect(invokers.addWidget).toHaveBeenCalledTimes(1) expect(invokers.addWidget).toHaveBeenCalledWith({ - id: 'abc123', componentName: 'weather', componentProps: { city: 'Tokyo' }, + id: 'abc123', size: 'm', ttlMs: 2000, }) @@ -369,20 +369,20 @@ describe('widgets tool helpers', () => { await executeWidgetAction({ action: 'spawn', - id: ' pinned-widget ', + alwaysOnTop: true, componentName: 'weather', componentProps: '{"city":"Tokyo"}', + id: ' pinned-widget ', size: 'm', ttlSeconds: 0, - alwaysOnTop: true, }, { invokers }) expect(invokers.addWidget).toHaveBeenCalledWith({ - id: 'pinned-widget', + alwaysOnTop: true, componentName: 'weather', componentProps: { city: 'Tokyo' }, + id: 'pinned-widget', size: 'm', - alwaysOnTop: true, ttlMs: 0, }) }) @@ -393,30 +393,30 @@ describe('widgets tool helpers', () => { await executeWidgetAction({ action: 'spawn', - id: ' sized-widget ', componentName: 'weather', componentProps: '{"city":"Taipei"}', + id: ' sized-widget ', size: 'l', ttlSeconds: 0, windowSize: { - width: 620, height: 760, - minWidth: 480, minHeight: 320, + minWidth: 480, + width: 620, }, } as any, { invokers }) expect(invokers.addWidget).toHaveBeenCalledWith({ - id: 'sized-widget', componentName: 'weather', componentProps: { city: 'Taipei' }, + id: 'sized-widget', size: 'l', ttlMs: 0, windowSize: { - width: 620, height: 760, - minWidth: 480, minHeight: 320, + minWidth: 480, + width: 620, }, }) }) @@ -427,43 +427,43 @@ describe('widgets tool helpers', () => { await executeWidgetAction({ action: 'spawn', - id: ' chess-main ', componentName: 'extension-ui', componentProps: JSON.stringify({ moduleId: 'chess-main', - title: 'Extension UI', - windowSize: { - width: 720, - height: 540, - minWidth: 480, - }, payload: { side: 'white', }, + title: 'Extension UI', + windowSize: { + height: 540, + minWidth: 480, + width: 720, + }, }), + id: ' chess-main ', size: 'm', ttlSeconds: 0, }, { invokers }) expect(invokers.addWidget).toHaveBeenCalledWith(expect.objectContaining({ - id: 'chess-main', componentName: 'extension-ui', componentProps: expect.objectContaining({ moduleId: 'chess-main', - title: 'Extension UI', - windowSize: { - width: 720, - height: 540, - minWidth: 480, - }, payload: { side: 'white', }, + title: 'Extension UI', + windowSize: { + height: 540, + minWidth: 480, + width: 720, + }, }), + id: 'chess-main', windowSize: { - width: 720, height: 540, minWidth: 480, + width: 720, }, })) }) @@ -474,20 +474,20 @@ describe('widgets tool helpers', () => { await executeWidgetAction({ action: 'spawn', - id: ' guarded-main ', componentName: 'extension-ui', componentProps: JSON.stringify({ - 'moduleId': 'guarded-main', - 'title': 'Guarded Module', + 'model-value': { injected: true }, 'modelValue': { injected: true }, 'module': { injected: true }, - 'moduleConfig': { injected: true }, - 'model-value': { injected: true }, 'module-config': { injected: true }, + 'moduleConfig': { injected: true }, + 'moduleId': 'guarded-main', 'payload': { safe: true, }, + 'title': 'Guarded Module', }), + id: ' guarded-main ', size: 'm', ttlSeconds: 0, }, { invokers }) @@ -496,10 +496,10 @@ describe('widgets tool helpers', () => { expect(dispatched).toBeDefined() expect(dispatched?.componentProps).toMatchObject({ moduleId: 'guarded-main', - title: 'Guarded Module', payload: { safe: true, }, + title: 'Guarded Module', }) expect(dispatched?.componentProps).not.toHaveProperty('modelValue') expect(dispatched?.componentProps).not.toHaveProperty('module') @@ -512,24 +512,24 @@ describe('widgets tool helpers', () => { const invokers = makeInvokers() await executeWidgetAction({ action: 'update', - id: ' xyz ', + alwaysOnTop: false, componentName: '', componentProps: '{"foo":1}', + id: ' xyz ', size: 'm', - alwaysOnTop: false, ttlSeconds: 0, }, { invokers }) - expect(invokers.updateWidget).toHaveBeenCalledWith({ id: 'xyz', componentProps: { foo: 1 }, alwaysOnTop: false }) + expect(invokers.updateWidget).toHaveBeenCalledWith({ alwaysOnTop: false, componentProps: { foo: 1 }, id: 'xyz' }) }) it('removes when id provided', async () => { const invokers = makeInvokers() await executeWidgetAction({ action: 'remove', - id: 'rem-id', componentName: '', componentProps: '{}', + id: 'rem-id', size: 's', ttlSeconds: 0, }, { invokers }) @@ -542,9 +542,9 @@ describe('widgets tool helpers', () => { vi.mocked(invokers.prepareWindow).mockResolvedValue('prepared-id') await executeWidgetAction({ action: 'open', - id: ' prepared-id ', componentName: '', componentProps: '{}', + id: ' prepared-id ', size: 'l', ttlSeconds: 0, }, { invokers }) @@ -557,9 +557,9 @@ describe('widgets tool helpers', () => { const invokers = makeInvokers() await executeWidgetAction({ action: 'clear', - id: '', componentName: '', componentProps: '{}', + id: '', size: 'm', ttlSeconds: 0, }, { invokers }) @@ -571,13 +571,13 @@ describe('widgets tool helpers', () => { describe('extension-ui host helpers', () => { it('removes host-controlled render props from payload props', () => { expect(sanitizeExtensionUiRenderProps({ - 'title': 'Override', + 'model-value': { injected: true }, 'modelValue': { injected: true }, 'module': { injected: true }, - 'moduleConfig': { injected: true }, - 'model-value': { injected: true }, 'module-config': { injected: true }, + 'moduleConfig': { injected: true }, 'safe': true, + 'title': 'Override', })).toEqual({ safe: true, }) @@ -585,27 +585,27 @@ describe('widgets tool helpers', () => { it('requires a registered module before rendering a resolved widget', () => { expect(canRenderExtensionUi({ + iframeSrc: 'https://example.com', loading: false, moduleSnapshot: undefined, - iframeSrc: 'https://example.com', })).toBe(false) expect(canRenderExtensionUi({ - loading: false, error: 'module missing', + iframeSrc: 'https://example.com', + loading: false, moduleSnapshot: { - moduleId: 'module-1', - ownerSessionId: 'session-1', - ownerExtensionId: 'plugin-1', + config: {}, kitId: 'kit.widget', kitModuleType: 'window', - state: 'active', - runtime: 'electron', + moduleId: 'module-1', + ownerExtensionId: 'plugin-1', + ownerSessionId: 'session-1', revision: 1, + runtime: 'electron', + state: 'active', updatedAt: Date.now(), - config: {}, }, - iframeSrc: 'https://example.com', })).toBe(false) }) }) diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.ts index 6b66f7d82..f74a42b4f 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.ts @@ -13,75 +13,75 @@ import { widgetsAdd, widgetsClear, widgetsOpenWindow, widgetsPrepareWindow, widg import { normalizeWidgetWindowSize } from '../../../../shared/utils/electron/windows/window-size' import { sanitizeExtensionUiDispatchProps } from '../../../widgets/extension-ui/host' -type SizePreset = 's' | 'm' | 'l' +export type WidgetInvokers = ReturnType + +type SizePreset = 'l' | 'm' | 's' type WidgetActionInput = | { - action: 'spawn' - id: string - componentName: string - componentProps: string | Record - alwaysOnTop?: boolean - size: SizePreset - windowSize?: WidgetWindowSize - ttlSeconds: number - } - | { - action: 'update' - id: string - componentProps: string | Record - componentName?: string - alwaysOnTop?: boolean - size?: SizePreset - windowSize?: WidgetWindowSize - ttlSeconds?: number - } - | { - action: 'remove' - id: string - componentName?: string - componentProps?: string | Record - alwaysOnTop?: boolean - size?: SizePreset - windowSize?: WidgetWindowSize - ttlSeconds?: number - } - | { action: 'clear' - id: string - componentName?: string - componentProps?: string | Record alwaysOnTop?: boolean + componentName?: string + componentProps?: Record | string + id: string size?: SizePreset - windowSize?: WidgetWindowSize ttlSeconds?: number + windowSize?: WidgetWindowSize } | { action: 'open' - id: string - componentName?: string - componentProps?: string | Record alwaysOnTop?: boolean + componentName?: string + componentProps?: Record | string + id: string size?: SizePreset - windowSize?: WidgetWindowSize ttlSeconds?: number + windowSize?: WidgetWindowSize + } + | { + action: 'remove' + alwaysOnTop?: boolean + componentName?: string + componentProps?: Record | string + id: string + size?: SizePreset + ttlSeconds?: number + windowSize?: WidgetWindowSize + } + | { + action: 'spawn' + alwaysOnTop?: boolean + componentName: string + componentProps: Record | string + id: string + size: SizePreset + ttlSeconds: number + windowSize?: WidgetWindowSize + } + | { + action: 'update' + alwaysOnTop?: boolean + componentName?: string + componentProps: Record | string + id: string + size?: SizePreset + ttlSeconds?: number + windowSize?: WidgetWindowSize } -export type WidgetInvokers = ReturnType - -let cachedInvokers: WidgetInvokers | undefined -const JSON_SCHEMA_NULLABLE_SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null']) +let cachedInvokers: undefined | WidgetInvokers +const JSON_SCHEMA_NULLABLE_SCALAR_TYPES = new Set(['boolean', 'integer', 'null', 'number', 'string']) function createInvokers() { const { context } = createContext(window.electron.ipcRenderer) return { - prepareWindow: defineInvoke(context, widgetsPrepareWindow), - openWindow: defineInvoke(context, widgetsOpenWindow), addWidget: defineInvoke(context, widgetsAdd), - updateWidget: defineInvoke(context, widgetsUpdate), - removeWidget: defineInvoke(context, widgetsRemove), clearWidgets: defineInvoke(context, widgetsClear), + openWindow: defineInvoke(context, widgetsOpenWindow), + prepareWindow: defineInvoke(context, widgetsPrepareWindow), + removeWidget: defineInvoke(context, widgetsRemove), + updateWidget: defineInvoke(context, widgetsUpdate), } } @@ -94,32 +94,117 @@ function resolveInvokers(override?: WidgetInvokers): WidgetInvokers { } const widgetWindowSizeParams = z.object({ - width: z.number().positive(), height: z.number().positive(), + maxHeight: z.union([z.number().positive(), z.null()]), + maxWidth: z.union([z.number().positive(), z.null()]), + minHeight: z.union([z.number().positive(), z.null()]), // NOTICE: OpenAI-compatible tool validators reject strict object schemas when // some nested properties are omitted from `required`. Keep these fields // required-but-nullable for the provider, then collapse `null` back to omitted // runtime fields before dispatching widget window updates. minWidth: z.union([z.number().positive(), z.null()]), - minHeight: z.union([z.number().positive(), z.null()]), - maxWidth: z.union([z.number().positive(), z.null()]), - maxHeight: z.union([z.number().positive(), z.null()]), + width: z.number().positive(), }).strict() const widgetParams = z.object({ action: z.enum(['spawn', 'update', 'remove', 'clear', 'open']).describe('Choose one: spawn, update, remove, clear, open'), - id: z.string().describe('Widget id; required for update/remove, optional for spawn/open'), + alwaysOnTop: z.boolean().describe('Whether the widget window should stay above other windows. Defaults to false when omitted by internal callers.'), componentName: z.string().describe('Widget component to render, e.g. weather (required for spawn)'), componentProps: z.string().describe('Widget props as JSON string (e.g. {"city":"Tokyo"})'), - alwaysOnTop: z.boolean().describe('Whether the widget window should stay above other windows. Defaults to false when omitted by internal callers.'), + id: z.string().describe('Widget id; required for update/remove, optional for spawn/open'), size: z.enum(['s', 'm', 'l']), - windowSize: z.union([widgetWindowSizeParams, z.null()]).describe('Optional pixel window size and constraints, e.g. {"width":620,"height":760,"minWidth":480}'), ttlSeconds: z.number().int().nonnegative().describe('Auto-close timer in seconds (spawn only)'), + windowSize: z.union([widgetWindowSizeParams, z.null()]).describe('Optional pixel window size and constraints, e.g. {"width":620,"height":760,"minWidth":480}'), }).strict() type WidgetToolInput = z.infer -function isJsonSchema(value: JsonSchema | boolean | JsonSchema[] | undefined): value is JsonSchema { +export async function executeWidgetAction(input: WidgetActionInput, deps?: { invokers?: WidgetInvokers }) { + const invokers = resolveInvokers(deps?.invokers) + const normalizedId = input.id?.trim() || undefined + + switch (input.action) { + case 'clear': { + await invokers.clearWidgets() + return 'Cleared all widgets.' + } + case 'open': { + const id = await invokers.prepareWindow(normalizedId ? { id: normalizedId } : {}) + await invokers.openWindow(normalizedId ? { id: normalizedId } : {}) + return `Opened widget window${id ? ` (${id})` : ''}.` + } + case 'remove': { + if (!normalizedId) + throw new Error('id is required to remove a widget.') + + await invokers.removeWidget({ id: normalizedId }) + return `Removed widget (${normalizedId}).` + } + case 'spawn': { + if (!input.componentName?.trim()) + throw new Error('componentName is required to spawn a widget.') + + const componentProps = normalizeComponentProps(input.componentProps) + const sanitizedComponentProps = sanitizeComponentPropsForDispatch(input.componentName, componentProps) + const windowSize = resolveWindowSize(input.componentName, sanitizedComponentProps, input.windowSize) + const ttlMs = input.ttlSeconds ? Math.floor(input.ttlSeconds * 1000) : 0 + const id = await invokers.addWidget({ + componentName: input.componentName, + componentProps: sanitizedComponentProps, + id: normalizedId, + size: input.size ?? 'm', + ...(input.alwaysOnTop === undefined ? {} : { alwaysOnTop: input.alwaysOnTop }), + ...(windowSize === undefined ? {} : { windowSize }), + ttlMs, + }) + + return `Spawned widget${id ? ` (${id})` : ''}.` + } + case 'update': { + if (!normalizedId) + throw new Error('id is required to update a widget.') + + const componentProps = normalizeComponentProps(input.componentProps) + const sanitizedComponentProps = sanitizeComponentPropsForDispatch(input.componentName, componentProps) + const windowSize = resolveWindowSize(input.componentName, sanitizedComponentProps, input.windowSize) + await invokers.updateWidget({ + componentProps: sanitizedComponentProps, + id: normalizedId, + ...(input.alwaysOnTop === undefined ? {} : { alwaysOnTop: input.alwaysOnTop }), + ...(windowSize === undefined ? {} : { windowSize }), + }) + + return `Updated widget (${normalizedId}).` + } + default: + return 'No action performed.' + } +} + +export function normalizeComponentProps(raw?: Record | string) { + if (raw === undefined || raw === null) + return {} + + if (typeof raw === 'string') { + const payload = raw.trim() + if (!payload) + return {} + try { + const parsed = JSON.parse(payload) + return typeof parsed === 'object' && parsed !== null ? parsed : {} + } + catch (error) { + throw new Error(`Invalid JSON for componentProps: ${(error as Error).message}`) + } + } + + if (typeof raw === 'object') + return raw + + return {} +} + +function isJsonSchema(value: boolean | JsonSchema | JsonSchema[] | undefined): value is JsonSchema { return Boolean(value && !Array.isArray(value) && typeof value === 'object') } @@ -179,20 +264,6 @@ function normalizeNullableAnyOf(schema: JsonSchema): JsonSchema { return next } -function normalizeWidgetWindowSizeInput(windowSize: WidgetToolInput['windowSize']): WidgetWindowSize | undefined { - if (!windowSize) - return undefined - - return { - width: windowSize.width, - height: windowSize.height, - ...(windowSize.minWidth == null ? {} : { minWidth: windowSize.minWidth }), - ...(windowSize.minHeight == null ? {} : { minHeight: windowSize.minHeight }), - ...(windowSize.maxWidth == null ? {} : { maxWidth: windowSize.maxWidth }), - ...(windowSize.maxHeight == null ? {} : { maxHeight: windowSize.maxHeight }), - } -} - function normalizeWidgetToolInput(input: WidgetToolInput): WidgetActionInput { return { ...input, @@ -200,27 +271,18 @@ function normalizeWidgetToolInput(input: WidgetToolInput): WidgetActionInput { } } -export function normalizeComponentProps(raw?: string | Record) { - if (raw === undefined || raw === null) - return {} +function normalizeWidgetWindowSizeInput(windowSize: WidgetToolInput['windowSize']): undefined | WidgetWindowSize { + if (!windowSize) + return undefined - if (typeof raw === 'string') { - const payload = raw.trim() - if (!payload) - return {} - try { - const parsed = JSON.parse(payload) - return typeof parsed === 'object' && parsed !== null ? parsed : {} - } - catch (error) { - throw new Error(`Invalid JSON for componentProps: ${(error as Error).message}`) - } + return { + height: windowSize.height, + width: windowSize.width, + ...(windowSize.minWidth == null ? {} : { minWidth: windowSize.minWidth }), + ...(windowSize.minHeight == null ? {} : { minHeight: windowSize.minHeight }), + ...(windowSize.maxWidth == null ? {} : { maxWidth: windowSize.maxWidth }), + ...(windowSize.maxHeight == null ? {} : { maxHeight: windowSize.maxHeight }), } - - if (typeof raw === 'object') - return raw - - return {} } function resolveWindowSize( @@ -245,73 +307,11 @@ function sanitizeComponentPropsForDispatch(componentName: string | undefined, co return sanitizeExtensionUiDispatchProps(componentProps) } -export async function executeWidgetAction(input: WidgetActionInput, deps?: { invokers?: WidgetInvokers }) { - const invokers = resolveInvokers(deps?.invokers) - const normalizedId = input.id?.trim() || undefined - - switch (input.action) { - case 'spawn': { - if (!input.componentName?.trim()) - throw new Error('componentName is required to spawn a widget.') - - const componentProps = normalizeComponentProps(input.componentProps) - const sanitizedComponentProps = sanitizeComponentPropsForDispatch(input.componentName, componentProps) - const windowSize = resolveWindowSize(input.componentName, sanitizedComponentProps, input.windowSize) - const ttlMs = input.ttlSeconds ? Math.floor(input.ttlSeconds * 1000) : 0 - const id = await invokers.addWidget({ - id: normalizedId, - componentName: input.componentName, - componentProps: sanitizedComponentProps, - size: input.size ?? 'm', - ...(input.alwaysOnTop === undefined ? {} : { alwaysOnTop: input.alwaysOnTop }), - ...(windowSize === undefined ? {} : { windowSize }), - ttlMs, - }) - - return `Spawned widget${id ? ` (${id})` : ''}.` - } - case 'update': { - if (!normalizedId) - throw new Error('id is required to update a widget.') - - const componentProps = normalizeComponentProps(input.componentProps) - const sanitizedComponentProps = sanitizeComponentPropsForDispatch(input.componentName, componentProps) - const windowSize = resolveWindowSize(input.componentName, sanitizedComponentProps, input.windowSize) - await invokers.updateWidget({ - id: normalizedId, - componentProps: sanitizedComponentProps, - ...(input.alwaysOnTop === undefined ? {} : { alwaysOnTop: input.alwaysOnTop }), - ...(windowSize === undefined ? {} : { windowSize }), - }) - - return `Updated widget (${normalizedId}).` - } - case 'remove': { - if (!normalizedId) - throw new Error('id is required to remove a widget.') - - await invokers.removeWidget({ id: normalizedId }) - return `Removed widget (${normalizedId}).` - } - case 'clear': { - await invokers.clearWidgets() - return 'Cleared all widgets.' - } - case 'open': { - const id = await invokers.prepareWindow(normalizedId ? { id: normalizedId } : {}) - await invokers.openWindow(normalizedId ? { id: normalizedId } : {}) - return `Opened widget window${id ? ` (${id})` : ''}.` - } - default: - return 'No action performed.' - } -} - const tools: Promise[] = [ (async () => rawTool({ - name: 'stage_widgets', description: 'Manage overlay widgets in the Stage desktop app (spawn, update, remove, clear, or open the widgets window).', execute: params => executeWidgetAction(normalizeWidgetToolInput(params as WidgetToolInput)), + name: 'stage_widgets', parameters: normalizeNullableAnyOf(await toJsonSchema(widgetParams) as JsonSchema), }))(), ] diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/mcp.test.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/mcp.test.ts index 4e4585ff5..933e6ee17 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/mcp.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/mcp.test.ts @@ -6,18 +6,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const invokeMocks = vi.hoisted(() => ({ callMcpTool: vi.fn(async () => ({ - content: [{ type: 'text', text: 'ok' }], + content: [{ text: 'ok', type: 'text' }], isError: false, })), listMcpTools: vi.fn(async () => [{ - serverName: 'filesystem', - name: 'filesystem::search', - toolName: 'search', description: 'Search files.', inputSchema: { - type: 'object', properties: {}, + type: 'object', }, + name: 'filesystem::search', + serverName: 'filesystem', + toolName: 'search', }]), })) @@ -54,39 +54,39 @@ describe('useTamagotchiMcpToolsStore', async () => { expect(mcpDefinitions).toEqual([ expect.objectContaining({ - id: 'mcp:builtIn_mcpListTools', function: expect.objectContaining({ name: 'builtIn_mcpListTools' }), + id: 'mcp:builtIn_mcpListTools', }), expect.objectContaining({ - id: 'mcp:builtIn_mcpCallTool', function: expect.objectContaining({ name: 'builtIn_mcpCallTool' }), + id: 'mcp:builtIn_mcpCallTool', }), ]) expect(JSON.stringify(llmToolsStore.$state)).not.toContain('execute') const listResult = await listTools?.execute({}, toolOptions) const callResult = await callTool?.execute({ + arguments: JSON.stringify({ limit: 10, query: 'hello' }), name: 'filesystem::search', - arguments: JSON.stringify({ query: 'hello', limit: 10 }), }, toolOptions) expect(invokeMocks.listMcpTools).toHaveBeenCalledTimes(1) expect(invokeMocks.callMcpTool).toHaveBeenCalledWith({ + arguments: { limit: 10, query: 'hello' }, name: 'filesystem::search', - arguments: { query: 'hello', limit: 10 }, }) expect(listResult).toEqual([{ - serverName: 'filesystem', - name: 'filesystem::search', - toolName: 'search', description: 'Search files.', inputSchema: { - type: 'object', properties: {}, + type: 'object', }, + name: 'filesystem::search', + serverName: 'filesystem', + toolName: 'search', }]) expect(callResult).toEqual({ - content: [{ type: 'text', text: 'ok' }], + content: [{ text: 'ok', type: 'text' }], isError: false, }) diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/mcp.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/mcp.ts index a62bbeeaa..9b711db60 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/mcp.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/mcp.ts @@ -21,8 +21,8 @@ export const useTamagotchiMcpToolsStore = defineStore('tamagotchi-mcp-tools', () async function refresh() { const tools = await Promise.all(createMcpTools({ - listTools: () => listMcpTools(), callTool: payload => callMcpTool(payload), + listTools: () => listMcpTools(), })) llmToolsStore.removeToolsByIds(...registeredToolIds()) diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/plugins.test.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/plugins.test.ts index a5259e64f..80a0536ee 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/plugins.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/plugins.test.ts @@ -8,25 +8,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const invokeMocks = vi.hoisted(() => ({ invokePluginTool: vi.fn(async (payload: unknown) => payload), listPluginXsaiTools: vi.fn(async () => ({ - tools: [ + prompts: [ { + id: 'chess-tools', ownerExtensionId: 'plugin-chess', - name: 'play_chess', - description: 'Play a chess move.', - parameters: { - type: 'object', - properties: {}, + prompt: { + content: 'Do not pass fen or pgn when mode is "new".', + id: 'airi-plugin-game-chess.prompt', + title: 'Chess Plugin Guidance', }, }, ], - prompts: [ + tools: [ { + description: 'Play a chess move.', + name: 'play_chess', ownerExtensionId: 'plugin-chess', - id: 'chess-tools', - prompt: { - id: 'airi-plugin-game-chess.prompt', - title: 'Chess Plugin Guidance', - content: 'Do not pass fen or pgn when mode is "new".', + parameters: { + properties: {}, + type: 'object', }, }, ], @@ -71,8 +71,8 @@ describe('useTamagotchiPluginToolsStore', async () => { expect(pluginDefinitions).toEqual([ expect.objectContaining({ - id: 'plugin:plugin-chess:play_chess', function: expect.objectContaining({ name: 'play_chess' }), + id: 'plugin:plugin-chess:play_chess', }), ]) expect(JSON.stringify(llmToolsStore.$state)).not.toContain('execute') @@ -83,18 +83,18 @@ describe('useTamagotchiPluginToolsStore', async () => { }, toolOptions) expect(invokeMocks.invokePluginTool).toHaveBeenCalledWith({ - ownerExtensionId: 'plugin-chess', - name: 'play_chess', input: { move: 'e2e4', }, + name: 'play_chess', + ownerExtensionId: 'plugin-chess', }) expect(executionResult).toEqual({ - ownerExtensionId: 'plugin-chess', - name: 'play_chess', input: { move: 'e2e4', }, + name: 'play_chess', + ownerExtensionId: 'plugin-chess', }) store.dispose() diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/plugins.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/plugins.ts index 8a46bc0e9..681600e9a 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/plugins.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/plugins.ts @@ -38,22 +38,22 @@ export const useTamagotchiPluginToolsStore = defineStore('tamagotchi-plugin-tool llmToolsetPromptsStore.registerToolsetPrompts( 'plugin-tools', definitions.prompts.map(definition => ({ + content: definition.prompt.content, id: `${definition.ownerExtensionId}:${definition.id}`, title: definition.prompt.title, - content: definition.prompt.content, })), ) const tools = definitions.tools.map((definition): ExecutableTool => ({ ...rawTool({ - name: definition.name, description: definition.description, - parameters: definition.parameters, execute: async input => invokePluginTool({ - ownerExtensionId: definition.ownerExtensionId, - name: definition.name, input, + name: definition.name, + ownerExtensionId: definition.ownerExtensionId, }), + name: definition.name, + parameters: definition.parameters, }), id: `${toolIdPrefix}${definition.ownerExtensionId}:${definition.name}`, })) diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/testing/strict-tool-schema.test.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/testing/strict-tool-schema.test.ts index 6fb66f860..17317791b 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/testing/strict-tool-schema.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/testing/strict-tool-schema.test.ts @@ -8,12 +8,12 @@ installStrictToolSchemaMatchers() function createTool(parameters: unknown): Tool { return { - type: 'function', function: { - name: 'test_tool', description: 'Test tool.', + name: 'test_tool', parameters, }, + type: 'function', } as Tool } @@ -24,14 +24,14 @@ describe('strict tool schema matchers', () => { */ it('accepts a strict provider-safe tool schema', () => { const tool = createTool({ - type: 'object', + additionalProperties: false, properties: { mode: { type: ['string', 'null'], }, }, required: ['mode'], - additionalProperties: false, + type: 'object', }) expect(tool).toSatisfyStrictToolSchema() @@ -43,14 +43,14 @@ describe('strict tool schema matchers', () => { */ it('reports missing required keys with schema paths', () => { const tool = createTool({ - type: 'object', + additionalProperties: false, properties: { mode: { type: ['string', 'null'], }, }, required: [], - additionalProperties: false, + type: 'object', }) expect(() => expect(tool).toSatisfyStrictToolSchema()).toThrow(/test_tool\.parameters.*mode/) @@ -62,10 +62,10 @@ describe('strict tool schema matchers', () => { */ it('checks a list of tools', () => { const tool = createTool({ - type: 'object', + additionalProperties: false, properties: {}, required: [], - additionalProperties: false, + type: 'object', }) expect([tool]).toSatisfyStrictToolSchemas() diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/testing/strict-tool-schema.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/testing/strict-tool-schema.ts index feb8e354d..35c6d1a22 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/testing/strict-tool-schema.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/testing/strict-tool-schema.ts @@ -4,8 +4,8 @@ import type { JsonSchema } from 'xsschema' import { expect } from 'vitest' interface StrictToolSchemaIssue { - path: string message: string + path: string } declare module 'vitest' { @@ -19,12 +19,61 @@ declare module 'vitest' { } } -function isSchemaRecord(value: unknown): value is JsonSchema { - return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +/** + * Collects strict provider schema issues from one xsAI tool. + * + * Use when: + * - Vitest checks need diagnostics instead of throwing immediately + * - A provider rejects schemas that omit `required` keys or allow extra object properties + * + * Expects: + * - `tool.function.parameters` contains the provider-facing JSON Schema + * + * Returns: + * - A list of path-qualified issues; empty means the schema satisfies the local strict rules + */ +export function collectStrictToolSchemaIssues(tool: Tool): StrictToolSchemaIssue[] { + const issues: StrictToolSchemaIssue[] = [] + collectSchemaIssues(tool.function.parameters, `${tool.function.name}.parameters`, issues) + return issues } -function sorted(values: string[]): string[] { - return [...values].sort((left, right) => left.localeCompare(right)) +/** + * Installs Vitest matchers for strict provider-facing tool schema checks. + * + * Use when: + * - A test file wants `expect(tool).toSatisfyStrictToolSchema()` + * - A test file wants `expect(tools).toSatisfyStrictToolSchemas()` + * + * Expects: + * - Called before the matcher is used in the current Vitest worker + * + * Returns: + * - Registers matchers on Vitest's `expect` object + */ +export function installStrictToolSchemaMatchers(): void { + expect.extend({ + toSatisfyStrictToolSchema(received: Tool) { + const issues = collectStrictToolSchemaIssues(received) + + return { + message: () => issues.length + ? `Expected tool schema to satisfy strict provider rules:\n${formatIssues(issues)}` + : 'Expected tool schema not to satisfy strict provider rules.', + pass: issues.length === 0, + } + }, + toSatisfyStrictToolSchemas(received: Tool[]) { + const issues = received.flatMap(tool => collectStrictToolSchemaIssues(tool)) + + return { + message: () => issues.length + ? `Expected tool schemas to satisfy strict provider rules:\n${formatIssues(issues)}` + : 'Expected tool schemas not to satisfy strict provider rules.', + pass: issues.length === 0, + } + }, + }) } function collectSchemaIssues(schema: unknown, path: string, issues: StrictToolSchemaIssue[]): void { @@ -38,27 +87,27 @@ function collectSchemaIssues(schema: unknown, path: string, issues: StrictToolSc if (!Array.isArray(schema.required)) { issues.push({ - path, message: '`required` must be supplied when `properties` is present.', + path, }) } else if (sorted(required).join('\0') !== sorted(propertyKeys).join('\0')) { const missing = propertyKeys.filter(key => !required.includes(key)) const extra = required.filter(key => !propertyKeys.includes(key)) issues.push({ - path, message: [ '`required` must include every key in `properties`.', missing.length ? `Missing: ${missing.join(', ')}.` : '', extra.length ? `Extra: ${extra.join(', ')}.` : '', ].filter(Boolean).join(' '), + path, }) } if (schema.additionalProperties !== false) { issues.push({ - path, message: '`additionalProperties` must be false when `properties` is present.', + path, }) } @@ -82,63 +131,14 @@ function collectSchemaIssues(schema: unknown, path: string, issues: StrictToolSc } } -/** - * Collects strict provider schema issues from one xsAI tool. - * - * Use when: - * - Vitest checks need diagnostics instead of throwing immediately - * - A provider rejects schemas that omit `required` keys or allow extra object properties - * - * Expects: - * - `tool.function.parameters` contains the provider-facing JSON Schema - * - * Returns: - * - A list of path-qualified issues; empty means the schema satisfies the local strict rules - */ -export function collectStrictToolSchemaIssues(tool: Tool): StrictToolSchemaIssue[] { - const issues: StrictToolSchemaIssue[] = [] - collectSchemaIssues(tool.function.parameters, `${tool.function.name}.parameters`, issues) - return issues -} - function formatIssues(issues: StrictToolSchemaIssue[]): string { return issues.map(issue => `- ${issue.path}: ${issue.message}`).join('\n') } -/** - * Installs Vitest matchers for strict provider-facing tool schema checks. - * - * Use when: - * - A test file wants `expect(tool).toSatisfyStrictToolSchema()` - * - A test file wants `expect(tools).toSatisfyStrictToolSchemas()` - * - * Expects: - * - Called before the matcher is used in the current Vitest worker - * - * Returns: - * - Registers matchers on Vitest's `expect` object - */ -export function installStrictToolSchemaMatchers(): void { - expect.extend({ - toSatisfyStrictToolSchema(received: Tool) { - const issues = collectStrictToolSchemaIssues(received) +function isSchemaRecord(value: unknown): value is JsonSchema { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} - return { - pass: issues.length === 0, - message: () => issues.length - ? `Expected tool schema to satisfy strict provider rules:\n${formatIssues(issues)}` - : 'Expected tool schema not to satisfy strict provider rules.', - } - }, - toSatisfyStrictToolSchemas(received: Tool[]) { - const issues = received.flatMap(tool => collectStrictToolSchemaIssues(tool)) - - return { - pass: issues.length === 0, - message: () => issues.length - ? `Expected tool schemas to satisfy strict provider rules:\n${formatIssues(issues)}` - : 'Expected tool schemas not to satisfy strict provider rules.', - } - }, - }) +function sorted(values: string[]): string[] { + return [...values].sort((left, right) => left.localeCompare(right)) } diff --git a/apps/stage-tamagotchi/src/renderer/stores/window.ts b/apps/stage-tamagotchi/src/renderer/stores/window.ts index ba5d44a9b..cbea6f084 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/window.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/window.ts @@ -4,7 +4,7 @@ import { defineStore } from 'pinia' import { computed } from 'vue' export const useWindowStore = defineStore('tamagotchi-window', () => { - const { width, height } = useWindowSize() + const { height, width } = useWindowSize() const centerPos = computed(() => ({ x: width.value / 2, y: height.value / 2 })) // Use window-relative mouse coordinates for Live2D focus @@ -12,10 +12,10 @@ export const useWindowStore = defineStore('tamagotchi-window', () => { const { x: live2dLookAtX, y: live2dLookAtY } = useElectronRelativeMouse({ initialValue: centerPos.value }) return { - width, - height, centerPos, + height, live2dLookAtX, live2dLookAtY, + width, } }) diff --git a/apps/stage-tamagotchi/src/renderer/utils/stage-three-transparency.ts b/apps/stage-tamagotchi/src/renderer/utils/stage-three-transparency.ts index a9f6c3a01..2076c2dfe 100644 --- a/apps/stage-tamagotchi/src/renderer/utils/stage-three-transparency.ts +++ b/apps/stage-tamagotchi/src/renderer/utils/stage-three-transparency.ts @@ -1,6 +1,6 @@ import type { StageModelRenderer } from '@proj-airi/stage-ui/stores/settings' -export type StageComponentState = 'pending' | 'loading' | 'mounted' +export type StageComponentState = 'loading' | 'mounted' | 'pending' export function shouldSampleStageTransparency(params: { componentState: StageComponentState diff --git a/apps/stage-tamagotchi/src/renderer/utils/voice-input-lifecycle.ts b/apps/stage-tamagotchi/src/renderer/utils/voice-input-lifecycle.ts index ddd757cb0..1b9d45197 100644 --- a/apps/stage-tamagotchi/src/renderer/utils/voice-input-lifecycle.ts +++ b/apps/stage-tamagotchi/src/renderer/utils/voice-input-lifecycle.ts @@ -77,9 +77,9 @@ export function createVoiceInputInteractionLifecycle( } return { - start, - stop, isStarting: () => startPromise !== undefined, isStopping: () => stopPromise !== undefined, + start, + stop, } } diff --git a/apps/stage-tamagotchi/src/renderer/utils/voice-input-suppression.ts b/apps/stage-tamagotchi/src/renderer/utils/voice-input-suppression.ts index d2f1e5595..0f0de9eed 100644 --- a/apps/stage-tamagotchi/src/renderer/utils/voice-input-suppression.ts +++ b/apps/stage-tamagotchi/src/renderer/utils/voice-input-suppression.ts @@ -5,23 +5,6 @@ export interface VoiceInputSuppressionOptions { suppressedUntil: number } -/** - * Decides whether voice input should be ignored while assistant audio can leak into the microphone. - * - * Use when: - * - The assistant is actively playing TTS. - * - The assistant just stopped speaking and speaker echo may still be captured. - * - * Expects: - * - `suppressedUntil` is a timestamp in milliseconds. - * - * Returns: - * - `true` when capture, transcription, and ingestion should be skipped. - */ -export function shouldSuppressVoiceInput(options: VoiceInputSuppressionOptions, now = Date.now()) { - return options.assistantSpeaking || now < options.suppressedUntil -} - /** * Calculates the timestamp until which voice input should stay muted after assistant speech. * @@ -40,3 +23,20 @@ export function assistantSpeechCooldownDeadline( ) { return endedAt + cooldownMs } + +/** + * Decides whether voice input should be ignored while assistant audio can leak into the microphone. + * + * Use when: + * - The assistant is actively playing TTS. + * - The assistant just stopped speaking and speaker echo may still be captured. + * + * Expects: + * - `suppressedUntil` is a timestamp in milliseconds. + * + * Returns: + * - `true` when capture, transcription, and ingestion should be skipped. + */ +export function shouldSuppressVoiceInput(options: VoiceInputSuppressionOptions, now = Date.now()) { + return options.assistantSpeaking || now < options.suppressedUntil +} diff --git a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/iframe-request.test.ts b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/iframe-request.test.ts index 6c00029d5..aadb95e2e 100644 --- a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/iframe-request.test.ts +++ b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/iframe-request.test.ts @@ -24,8 +24,8 @@ describe('createExtensionUiIframeRequestHandler', () => { await expect(requestWidgetIframe({ id: 'kit-module:board', - requestId: 'req-1', payload: { action: 'snapshot' }, + requestId: 'req-1', timeoutMs: 1000, })).resolves.toEqual({ fen: 'fen:snapshot', @@ -39,8 +39,8 @@ describe('createExtensionUiIframeRequestHandler', () => { await expect(requestWidgetIframe({ id: 'kit-module:board', - requestId: 'req-1', payload: { action: 'snapshot' }, + requestId: 'req-1', timeoutMs: 1000, })).rejects.toThrow('Gamelet `kit-module:board` iframe context is not ready.') }) @@ -59,8 +59,8 @@ describe('createExtensionUiIframeRequestHandler', () => { const request = requestWidgetIframe({ id: 'kit-module:board', - requestId: 'req-1', payload: { action: 'snapshot' }, + requestId: 'req-1', timeoutMs: 5, }) @@ -78,17 +78,17 @@ describe('createExtensionUiIframeRequestQueueProcessor', () => { fen: `fen:${request.requestId}`, })) const input = { - shouldHandle: (request: { id: string }) => request.id === 'kit-module:board', + emitResult, isReady: () => iframeReady, requestWidgetIframe, - emitResult, + shouldHandle: (request: { id: string }) => request.id === 'kit-module:board', } const processIframeRequests = createExtensionUiIframeRequestQueueProcessor(input) const requests = [{ id: 'kit-module:board', - requestId: 'req-1', payload: { action: 'start' }, + requestId: 'req-1', timeoutMs: 1000, }] @@ -105,8 +105,8 @@ describe('createExtensionUiIframeRequestQueueProcessor', () => { expect(requestWidgetIframe).toHaveBeenCalledOnce() expect(emitResult).toHaveBeenCalledWith({ id: 'kit-module:board', - requestId: 'req-1', ok: true, + requestId: 'req-1', result: { fen: 'fen:req-1' }, }) }) @@ -114,24 +114,24 @@ describe('createExtensionUiIframeRequestQueueProcessor', () => { it('emits results for every queued request delivered in one Vue update', async () => { const emitResult = vi.fn() const processIframeRequests = createExtensionUiIframeRequestQueueProcessor({ - shouldHandle: request => request.id === 'kit-module:board', + emitResult, requestWidgetIframe: async request => ({ fen: `fen:${request.requestId}`, }), - emitResult, + shouldHandle: request => request.id === 'kit-module:board', }) processIframeRequests([ { id: 'kit-module:board', - requestId: 'req-1', payload: { action: 'snapshot' }, + requestId: 'req-1', timeoutMs: 1000, }, { id: 'kit-module:board', - requestId: 'req-2', payload: { action: 'snapshot' }, + requestId: 'req-2', timeoutMs: 1000, }, ]) @@ -139,14 +139,14 @@ describe('createExtensionUiIframeRequestQueueProcessor', () => { expect(emitResult).toHaveBeenCalledWith({ id: 'kit-module:board', - requestId: 'req-1', ok: true, + requestId: 'req-1', result: { fen: 'fen:req-1' }, }) expect(emitResult).toHaveBeenCalledWith({ id: 'kit-module:board', - requestId: 'req-2', ok: true, + requestId: 'req-2', result: { fen: 'fen:req-2' }, }) }) @@ -155,15 +155,15 @@ describe('createExtensionUiIframeRequestQueueProcessor', () => { const emitResult = vi.fn() const requestWidgetIframe = vi.fn(async () => ({ fen: 'fen-once' })) const processIframeRequests = createExtensionUiIframeRequestQueueProcessor({ - shouldHandle: () => true, - requestWidgetIframe, emitResult, + requestWidgetIframe, + shouldHandle: () => true, }) const requests = [{ id: 'kit-module:board', - requestId: 'req-1', payload: { action: 'snapshot' }, + requestId: 'req-1', timeoutMs: 1000, }] processIframeRequests(requests) diff --git a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/iframe-request.ts b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/iframe-request.ts index 1606940c4..ae392c477 100644 --- a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/iframe-request.ts +++ b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/iframe-request.ts @@ -15,37 +15,14 @@ export interface ExtensionUiIframeRequestHandlerInput { } export interface ExtensionUiIframeRequestQueueProcessorInput { - /** Returns whether this mounted iframe owns the request. */ - shouldHandle: (request: WidgetsIframeRequestPayload) => boolean + /** Emits one correlated request result back to the widget host. */ + emitResult: (result: WidgetsIframeRequestResultPayload) => void /** Returns whether the iframe has announced its invoke handlers are ready. */ isReady?: () => boolean /** Invokes the mounted iframe and returns its response record. */ requestWidgetIframe: (request: WidgetsIframeRequestPayload) => Promise - /** Emits one correlated request result back to the widget host. */ - emitResult: (result: WidgetsIframeRequestResultPayload) => void -} - -function createTimeoutSignal(timeoutMs: number): { signal: AbortSignal, cleanup: () => void } { - const timeout = (AbortSignal as typeof AbortSignal & { - timeout?: (milliseconds: number) => AbortSignal - }).timeout - - if (timeout) { - return { - signal: timeout(timeoutMs), - cleanup: () => {}, - } - } - - const controller = new AbortController() - const timer = setTimeout(() => { - controller.abort(new DOMException('The operation timed out.', 'TimeoutError')) - }, timeoutMs) - - return { - signal: controller.signal, - cleanup: () => clearTimeout(timer), - } + /** Returns whether this mounted iframe owns the request. */ + shouldHandle: (request: WidgetsIframeRequestPayload) => boolean } /** @@ -67,8 +44,8 @@ export function createExtensionUiIframeRequestHandler(input: ExtensionUiIframeRe try { return await invokeGameletIframeRequest({ - requestId: request.requestId, payload: request.payload, + requestId: request.requestId, }, { signal: timeoutSignal.signal, }) @@ -104,19 +81,42 @@ export function createExtensionUiIframeRequestQueueProcessor(input: ExtensionUiI .then((result) => { input.emitResult({ id: request.id, - requestId: request.requestId, ok: true, + requestId: request.requestId, result, }) }) .catch((error: unknown) => { input.emitResult({ - id: request.id, - requestId: request.requestId, - ok: false, error: errorMessageFrom(error) ?? 'Gamelet request failed.', + id: request.id, + ok: false, + requestId: request.requestId, }) }) } } } + +function createTimeoutSignal(timeoutMs: number): { cleanup: () => void, signal: AbortSignal } { + const timeout = (AbortSignal as typeof AbortSignal & { + timeout?: (milliseconds: number) => AbortSignal + }).timeout + + if (timeout) { + return { + cleanup: () => {}, + signal: timeout(timeoutMs), + } + } + + const controller = new AbortController() + const timer = setTimeout(() => { + controller.abort(new DOMException('The operation timed out.', 'TimeoutError')) + }, timeoutMs) + + return { + cleanup: () => clearTimeout(timer), + signal: controller.signal, + } +} diff --git a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-bridge-spark.test.ts b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-bridge-spark.test.ts index a636274d2..62e4332dc 100644 --- a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-bridge-spark.test.ts +++ b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-bridge-spark.test.ts @@ -19,26 +19,26 @@ describe('publishWidgetSparkNotifyReaction', () => { const emit = vi.fn() const result = await publishWidgetSparkNotifyReaction({ - route: { - namespace: 'airi.plugin.game.chess.commentary', - name: 'request', - }, payload: { - requestId: 'req-1', fallbackResponseText: 'Fallback text.', + requestId: 'req-1', sparkNotify: { - kind: 'ping', - urgency: 'immediate', + destinations: ['character'], forceTextResponse: true, headline: 'AIRI move', + kind: 'ping', note: 'Explain the chess move.', - destinations: ['character'], - source: 'plugin:airi-plugin-game-chess', payload: { moveSan: 'Nf3', }, + source: 'plugin:airi-plugin-game-chess', + urgency: 'immediate', }, }, + route: { + name: 'request', + namespace: 'airi.plugin.game.chess.commentary', + }, }, { dispatchSparkNotifyReaction, emit, @@ -46,27 +46,27 @@ describe('publishWidgetSparkNotifyReaction', () => { expect(result).toBe(true) expect(dispatchSparkNotifyReaction).toHaveBeenCalledWith({ - kind: 'ping', - urgency: 'immediate', - forceTextResponse: true, - fallbackResponseText: 'Fallback text.', - headline: 'AIRI move', - note: 'Explain the chess move.', destinations: ['character'], - source: 'plugin:airi-plugin-game-chess', + fallbackResponseText: 'Fallback text.', + forceTextResponse: true, + headline: 'AIRI move', + kind: 'ping', + note: 'Explain the chess move.', payload: { moveSan: 'Nf3', }, + source: 'plugin:airi-plugin-game-chess', + urgency: 'immediate', }) expect(emit).toHaveBeenCalledWith(widgetsIframeBroadcastEvent, { - route: { - namespace: 'airi.plugin.game.chess.commentary', - name: 'response', - }, payload: { requestId: 'req-1', text: 'Nice tactic.', }, + route: { + name: 'response', + namespace: 'airi.plugin.game.chess.commentary', + }, }) }) @@ -80,13 +80,13 @@ describe('publishWidgetSparkNotifyReaction', () => { const emit = vi.fn() const result = await publishWidgetSparkNotifyReaction({ - route: { - namespace: 'airi.plugin.game.chess.gamelet', - name: 'response', - }, payload: { requestId: 'req-1', }, + route: { + name: 'response', + namespace: 'airi.plugin.game.chess.gamelet', + }, }, { dispatchSparkNotifyReaction, emit, @@ -106,18 +106,18 @@ describe('publishWidgetSparkNotifyReaction', () => { const emit = vi.fn() const result = await publishWidgetSparkNotifyReaction({ - route: { - namespace: 'airi.plugin.game.chess.commentary', - name: 'request', - }, payload: { - requestId: 'req-quick', fallbackResponseText: '', + requestId: 'req-quick', sparkNotify: { - headline: 'Quick move', forceTextResponse: true, + headline: 'Quick move', }, }, + route: { + name: 'request', + namespace: 'airi.plugin.game.chess.commentary', + }, }, { dispatchSparkNotifyReaction, emit, @@ -138,70 +138,70 @@ describe('publishWidgetSparkNotifyReaction', () => { it('uses awaitable performance notify when the widget declares calls', async () => { const dispatchSparkNotifyReaction = vi.fn(async () => 'unused') const dispatchSparkNotifyPerformance = vi.fn(async () => ({ - type: 'called' as const, name: 'chess.play', reaction: 'Played.', + type: 'called' as const, })) const emit = vi.fn() const result = await publishWidgetSparkNotifyReaction({ - route: { - namespace: 'airi.plugin.game.chess.commentary', - name: 'request', - }, payload: { - requestId: 'req-call', - fallbackResponseText: 'fallback', calls: [ { - name: 'chess.play', - prompt: 'Play the prepared chess reply.', examples: [ '<|CALL ["chess.play", {"move":"Nf3"}]|>', ], + name: 'chess.play', + prompt: 'Play the prepared chess reply.', }, ], - timeoutMs: 15000, + fallbackResponseText: 'fallback', + requestId: 'req-call', sparkNotify: { + destinations: ['character'], + headline: 'A move is ready', kind: 'ping', urgency: 'immediate', - headline: 'A move is ready', - destinations: ['character'], }, + timeoutMs: 15000, + }, + route: { + name: 'request', + namespace: 'airi.plugin.game.chess.commentary', }, }, { - dispatchSparkNotifyReaction, dispatchSparkNotifyPerformance, + dispatchSparkNotifyReaction, emit, }) expect(result).toBe(true) expect(dispatchSparkNotifyReaction).not.toHaveBeenCalled() expect(dispatchSparkNotifyPerformance).toHaveBeenCalledWith(expect.objectContaining({ - headline: 'A move is ready', - fallbackResponseText: 'fallback', - timeoutMs: 15000, calls: [ { + handler: expect.any(Function), manifest: { - name: 'chess.play', - prompt: 'Play the prepared chess reply.', examples: [ '<|CALL ["chess.play", {"move":"Nf3"}]|>', ], + name: 'chess.play', + prompt: 'Play the prepared chess reply.', }, - handler: expect.any(Function), }, ], + fallbackResponseText: 'fallback', + headline: 'A move is ready', + timeoutMs: 15000, })) expect(emit).toHaveBeenCalledWith(widgetsIframeBroadcastEvent, expect.objectContaining({ payload: expect.objectContaining({ + performance: { + name: 'chess.play', + type: 'called', + }, requestId: 'req-call', text: 'Played.', - performance: { - type: 'called', - name: 'chess.play', - }, }), })) }) diff --git a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-bridge-spark.ts b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-bridge-spark.ts index 65cfa2ca1..2883f0175 100644 --- a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-bridge-spark.ts +++ b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-bridge-spark.ts @@ -5,74 +5,41 @@ import { sparkNotifyReactionOptionsSchema } from '@proj-airi/stage-ui/stores/mod import { array, finite, looseObject, nonEmpty, number, optional, pipe, record, safeParse, string, trim, unknown } from 'valibot' interface PublishWidgetSparkNotifyReactionOptions { - dispatchSparkNotifyReaction: (options: SparkNotifyReactionOptions) => Promise dispatchSparkNotifyPerformance?: (options: SparkNotifyReactionOptions) => Promise + dispatchSparkNotifyReaction: (options: SparkNotifyReactionOptions) => Promise emit: (event: typeof widgetsIframeBroadcastEvent, payload: Record) => void } const widgetSparkNotifyEventSchema = looseObject({ + // `payload` carries the domain request for this route. For spark messages, + // the host turns `sparkNotify` into a stage-ui reaction request and sends the + // resulting text back over `responseRoute`. + payload: looseObject({ + calls: optional(array(looseObject({ + examples: optional(array(string())), + name: pipe(string(), trim(), nonEmpty()), + prompt: pipe(string(), trim(), nonEmpty()), + }))), + // Deterministic response text used when the spark reaction path fails, + // times out, or returns an empty reaction. The iframe chooses this value + // because it owns the user-facing fallback for its current UI state. + fallbackResponseText: string(), + // Correlation key owned by the iframe caller. It is not a trace/event id: + // the host only echoes it so the iframe can resolve the matching pending + // request when multiple commentary requests are in flight. + requestId: optional(string()), + responseRoute: optional(record(string(), unknown())), + sparkNotify: looseObject({}), + timeoutMs: optional(pipe(number(), finite())), + }), // `route` is the iframe-local delivery key for this publish envelope. // It is not a chat topic. The namespace lets the host derive a default // response route without knowing chess/commentary-specific route names. route: optional(looseObject({ namespace: optional(pipe(string(), trim(), nonEmpty())), })), - // `payload` carries the domain request for this route. For spark messages, - // the host turns `sparkNotify` into a stage-ui reaction request and sends the - // resulting text back over `responseRoute`. - payload: looseObject({ - // Correlation key owned by the iframe caller. It is not a trace/event id: - // the host only echoes it so the iframe can resolve the matching pending - // request when multiple commentary requests are in flight. - requestId: optional(string()), - // Deterministic response text used when the spark reaction path fails, - // times out, or returns an empty reaction. The iframe chooses this value - // because it owns the user-facing fallback for its current UI state. - fallbackResponseText: string(), - responseRoute: optional(record(string(), unknown())), - calls: optional(array(looseObject({ - name: pipe(string(), trim(), nonEmpty()), - prompt: pipe(string(), trim(), nonEmpty()), - examples: optional(array(string())), - }))), - timeoutMs: optional(pipe(number(), finite())), - sparkNotify: looseObject({}), - }), }) -function createSparkNotifyReactionOptions(event: Record) { - const result = safeParse(widgetSparkNotifyEventSchema, event) - if (!result.success) { - return undefined - } - - const { payload, route } = result.output - const responseRoute = payload.responseRoute ?? (route?.namespace - ? { - namespace: route.namespace, - name: 'response', - } - : undefined) - if (!responseRoute) { - return undefined - } - const reactionOptionsResult = safeParse(sparkNotifyReactionOptionsSchema, { - ...payload.sparkNotify, - fallbackResponseText: payload.fallbackResponseText, - }) - if (!reactionOptionsResult.success) { - return undefined - } - - return { - requestId: payload.requestId, - responseRoute, - calls: payload.calls, - timeoutMs: payload.timeoutMs, - reactionOptions: reactionOptionsResult.output satisfies SparkNotifyReactionOptions, - } -} - /** * Handles iframe-published spark notify requests and broadcasts the generated reaction. * @@ -102,11 +69,11 @@ export async function publishWidgetSparkNotifyReaction( const performance = widgetCallManifests.length > 0 && options.dispatchSparkNotifyPerformance ? await options.dispatchSparkNotifyPerformance({ ...request.reactionOptions, - timeoutMs: request.timeoutMs, calls: widgetCallManifests.map(manifest => ({ - manifest, handler: async () => undefined, + manifest, })), + timeoutMs: request.timeoutMs, }) : undefined @@ -115,20 +82,53 @@ export async function publishWidgetSparkNotifyReaction( : await options.dispatchSparkNotifyReaction(request.reactionOptions) options.emit(widgetsIframeBroadcastEvent, { - route: request.responseRoute, payload: { ...(request.requestId ? { requestId: request.requestId } : {}), text, ...(performance ? { performance: { - type: performance.type, name: performance.name, + type: performance.type, }, } : {}), }, + route: request.responseRoute, }) return true } + +function createSparkNotifyReactionOptions(event: Record) { + const result = safeParse(widgetSparkNotifyEventSchema, event) + if (!result.success) { + return undefined + } + + const { payload, route } = result.output + const responseRoute = payload.responseRoute ?? (route?.namespace + ? { + name: 'response', + namespace: route.namespace, + } + : undefined) + if (!responseRoute) { + return undefined + } + const reactionOptionsResult = safeParse(sparkNotifyReactionOptionsSchema, { + ...payload.sparkNotify, + fallbackResponseText: payload.fallbackResponseText, + }) + if (!reactionOptionsResult.success) { + return undefined + } + + return { + calls: payload.calls, + reactionOptions: reactionOptionsResult.output satisfies SparkNotifyReactionOptions, + requestId: payload.requestId, + responseRoute, + timeoutMs: payload.timeoutMs, + } +} diff --git a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-extension-ui-for-module.ts b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-extension-ui-for-module.ts index 48ac38841..7f5a364b8 100644 --- a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-extension-ui-for-module.ts +++ b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-extension-ui-for-module.ts @@ -41,9 +41,9 @@ const mountedPluginAssetPathPrefix = '/_airi/extensions/' * - The resolved module snapshot and normalized iframe configuration values */ export function useExtensionUIForModule(options: { - moduleId: ComputedRef - inspectPluginHost: () => Promise<{ modules: PluginHostModuleSummary[] }> getPluginAssetBaseUrl: () => Promise + inspectPluginHost: () => Promise<{ modules: PluginHostModuleSummary[] }> + moduleId: ComputedRef }) { const loading = shallowRef(false) const error = shallowRef() @@ -149,18 +149,18 @@ export function useExtensionUIForModule(options: { }) return { - loading, error, - moduleSnapshot, - moduleConfig, - widgetConfig, iframeConfig, + iframeMountError, iframeSrc, iframeSrcdoc, - resolvedIframeSrc, - iframeMountError, + loading, + moduleConfig, + moduleSnapshot, pluginAssetBaseUrl, - refreshPluginAssetBaseUrl, + resolvedIframeSrc, + + widgetConfig, } } diff --git a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-iframe-message-port.test.ts b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-iframe-message-port.test.ts index 64edc9821..23a511714 100644 --- a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-iframe-message-port.test.ts +++ b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-iframe-message-port.test.ts @@ -15,11 +15,11 @@ describe('toWidgetsIframePostMessageRecord', () => { */ it('normalizes reactive nested payloads into structured-clone-safe records', () => { const payload = reactive({ - command: { - requestId: 'req-1', - action: 'start', - }, callback: () => 'not cloneable', + command: { + action: 'start', + requestId: 'req-1', + }, nested: { createdAt: new Date('2026-04-28T00:00:00.000Z'), }, @@ -30,8 +30,8 @@ describe('toWidgetsIframePostMessageRecord', () => { expect(() => structuredClone(normalized)).not.toThrow() expect(normalized).toMatchObject({ command: { - requestId: 'req-1', action: 'start', + requestId: 'req-1', }, nested: { createdAt: '2026-04-28T00:00:00.000Z', diff --git a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-iframe-message-port.ts b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-iframe-message-port.ts index 66a0987f9..2b4e8e2a7 100644 --- a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-iframe-message-port.ts +++ b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-iframe-message-port.ts @@ -15,49 +15,6 @@ import { import { unrefElement } from '@vueuse/core' import { onBeforeUnmount, shallowRef, toRaw, watch } from 'vue' -function toWidgetsIframePostMessageValue(value: unknown, seen = new WeakSet()): unknown { - if (value == null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return value - } - - if (typeof value === 'bigint') { - return value - } - - if (typeof value === 'function' || typeof value === 'symbol') { - return undefined - } - - const raw = toRaw(value) - if (!raw || typeof raw !== 'object') { - return raw - } - - if (seen.has(raw)) { - return undefined - } - seen.add(raw) - - if (Array.isArray(raw)) { - const arrayValue = raw.map(item => toWidgetsIframePostMessageValue(item, seen)) - seen.delete(raw) - return arrayValue - } - - if (raw instanceof Date) { - seen.delete(raw) - return raw.toISOString() - } - - const recordValue = Object.fromEntries( - Object.entries(raw as Record) - .map(([key, entry]) => [key, toWidgetsIframePostMessageValue(entry, seen)]) - .filter(([, entry]) => entry !== undefined), - ) - seen.delete(raw) - return recordValue -} - /** * Normalizes extension iframe payload records into structured-clone-safe data. * @@ -94,11 +51,11 @@ export function toWidgetsIframePostMessageRecord(value: unknown): Record> moduleId: ComputedRef moduleSnapshot: ComputedRef - moduleConfig: ComputedRef> + onPublish?: (event: Record) => Promise | void propsPayload: ComputedRef> - onPublish?: (event: Record) => void | Promise }, ) { const iframeLoadError = shallowRef() @@ -120,9 +77,9 @@ export function useIframeMessagePort( function createInitPayload(): WidgetsIframeInitPayload { const module = options.moduleSnapshot.value return { - moduleId: module?.moduleId, - module: module ? toWidgetsIframePostMessageRecord(module) : undefined, config: toWidgetsIframePostMessageRecord(options.moduleConfig.value), + module: module ? toWidgetsIframePostMessageRecord(module) : undefined, + moduleId: module?.moduleId, props: toWidgetsIframePostMessageRecord(options.propsPayload.value), } } @@ -191,9 +148,52 @@ export function useIframeMessagePort( return { context: iframeRuntime.context, - iframeReady, iframeLoadError, - onIframeLoad, + iframeReady, onIframeError, + onIframeLoad, } } + +function toWidgetsIframePostMessageValue(value: unknown, seen = new WeakSet()): unknown { + if (value == null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value + } + + if (typeof value === 'bigint') { + return value + } + + if (typeof value === 'function' || typeof value === 'symbol') { + return undefined + } + + const raw = toRaw(value) + if (!raw || typeof raw !== 'object') { + return raw + } + + if (seen.has(raw)) { + return undefined + } + seen.add(raw) + + if (Array.isArray(raw)) { + const arrayValue = raw.map(item => toWidgetsIframePostMessageValue(item, seen)) + seen.delete(raw) + return arrayValue + } + + if (raw instanceof Date) { + seen.delete(raw) + return raw.toISOString() + } + + const recordValue = Object.fromEntries( + Object.entries(raw as Record) + .map(([key, entry]) => [key, toWidgetsIframePostMessageValue(entry, seen)]) + .filter(([, entry]) => entry !== undefined), + ) + seen.delete(raw) + return recordValue +} diff --git a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/host.ts b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/host.ts index 236db8f49..8146d91ac 100644 --- a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/host.ts +++ b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/host.ts @@ -1,11 +1,11 @@ import type { PluginHostModuleSummary } from '../../../shared/eventa/plugin/host' const extensionUiDispatchReservedPropKeys = new Set([ + 'model-value', 'modelValue', 'module', - 'moduleConfig', - 'model-value', 'module-config', + 'moduleConfig', ]) const extensionUiRenderReservedPropKeys = new Set([ @@ -13,9 +13,22 @@ const extensionUiRenderReservedPropKeys = new Set([ ...extensionUiDispatchReservedPropKeys, ]) -function sanitizeExtensionUiProps(record: Record, reservedKeys: Set) { - return Object.fromEntries( - Object.entries(record).filter(([key]) => !reservedKeys.has(key)), +export function canRenderExtensionUi(options: { + error?: string + iframeLoadError?: string + iframeMountError?: string + iframeSrc?: string + iframeSrcdoc?: string + loading: boolean + moduleSnapshot?: PluginHostModuleSummary +}) { + return Boolean( + options.moduleSnapshot + && (options.iframeSrc || options.iframeSrcdoc) + && !options.loading + && !options.error + && !options.iframeLoadError + && !options.iframeMountError, ) } @@ -27,21 +40,8 @@ export function sanitizeExtensionUiRenderProps(record: Record) { return sanitizeExtensionUiProps(record, extensionUiRenderReservedPropKeys) } -export function canRenderExtensionUi(options: { - loading: boolean - error?: string - iframeLoadError?: string - iframeMountError?: string - moduleSnapshot?: PluginHostModuleSummary - iframeSrc?: string - iframeSrcdoc?: string -}) { - return Boolean( - options.moduleSnapshot - && (options.iframeSrc || options.iframeSrcdoc) - && !options.loading - && !options.error - && !options.iframeLoadError - && !options.iframeMountError, +function sanitizeExtensionUiProps(record: Record, reservedKeys: Set) { + return Object.fromEntries( + Object.entries(record).filter(([key]) => !reservedKeys.has(key)), ) } diff --git a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/shared/eventa-runtime.test.ts b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/shared/eventa-runtime.test.ts index aa9db591a..348878c8b 100644 --- a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/shared/eventa-runtime.test.ts +++ b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/shared/eventa-runtime.test.ts @@ -25,14 +25,6 @@ class MockWindow { this.listeners.get(type)?.set(listener, handler) } - removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null) { - if (!listener) { - return - } - - this.listeners.get(type)?.delete(listener) - } - postMessage(data: unknown) { const messageEvent = { data, @@ -43,6 +35,14 @@ class MockWindow { listener(messageEvent as unknown as Event) } } + + removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null) { + if (!listener) { + return + } + + this.listeners.get(type)?.delete(listener) + } } /** @@ -90,9 +90,9 @@ describe('createContext', () => { }) host.context.emit(widgetsIframeInitEvent, { - moduleId: 'module-chess', config: {}, module: undefined, + moduleId: 'module-chess', props: {}, }) @@ -111,13 +111,13 @@ describe('createContext', () => { }) iframe.context.emit(widgetsIframePublishEvent, { - route: { - namespace: 'plugin.chess', - name: 'request', - }, payload: { requestId: 'req-1', }, + route: { + name: 'request', + namespace: 'plugin.chess', + }, }) await expect(publishedPayload).resolves.toEqual(expect.objectContaining({ @@ -157,10 +157,10 @@ describe('createContext', () => { const invokeGameletIframeRequest = defineInvoke(host.context, gameletIframeRequest) await expect(invokeGameletIframeRequest({ - requestId: 'req-1', payload: { action: 'snapshot', }, + requestId: 'req-1', })).resolves.toEqual({ fen: 'fen-after-request' }) host.dispose() diff --git a/apps/stage-tamagotchi/src/renderer/window-context.ts b/apps/stage-tamagotchi/src/renderer/window-context.ts index 602e2b3b0..6a74074a9 100644 --- a/apps/stage-tamagotchi/src/renderer/window-context.ts +++ b/apps/stage-tamagotchi/src/renderer/window-context.ts @@ -8,11 +8,6 @@ export interface RendererWindowContext { stageRuntime: 'full' | 'minimal' } -function normalizeRoutePath(routePath: string) { - const [path = ''] = routePath.split(/[?#]/) - return path || '/' -} - /** * Resolves the initial renderer route before Vue Router hydrates the hash. * @@ -49,3 +44,8 @@ export function resolveRendererWindowContext(search = globalThis.location?.searc stageRuntime: stageRuntime === 'minimal' ? 'minimal' : 'full', } } + +function normalizeRoutePath(routePath: string) { + const [path = ''] = routePath.split(/[?#]/) + return path || '/' +} diff --git a/apps/stage-tamagotchi/src/shared/desktop-overlay-live-window-smoke.ts b/apps/stage-tamagotchi/src/shared/desktop-overlay-live-window-smoke.ts index ca87337c3..69dc3e7c9 100644 --- a/apps/stage-tamagotchi/src/shared/desktop-overlay-live-window-smoke.ts +++ b/apps/stage-tamagotchi/src/shared/desktop-overlay-live-window-smoke.ts @@ -1,7 +1,3 @@ -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - export function selectDesktopOverlaySmokeCandidateId(runState: Record): string { const snapshot = runState.lastGroundingSnapshot if (!isRecord(snapshot)) @@ -22,3 +18,7 @@ export function selectDesktopOverlaySmokeCandidateId(runState: Record { + return typeof value === 'object' && value !== null +} diff --git a/apps/stage-tamagotchi/src/shared/eventa/index.ts b/apps/stage-tamagotchi/src/shared/eventa/index.ts index 036229a3b..7e23e01d3 100644 --- a/apps/stage-tamagotchi/src/shared/eventa/index.ts +++ b/apps/stage-tamagotchi/src/shared/eventa/index.ts @@ -42,20 +42,20 @@ export const electronOpenChat = defineInvokeEventa('eventa:invoke:electron:windo export const electronSpotlightHide = defineInvokeEventa('eventa:invoke:electron:windows:spotlight:hide') export const electronSpotlightShowResultNotification = defineInvokeEventa('eventa:invoke:electron:windows:spotlight:show-result-notification') export const electronSpotlightShortcutGet = defineInvokeEventa('eventa:invoke:electron:windows:spotlight:shortcut:get') -export const electronSpotlightShortcutSet = defineInvokeEventa('eventa:invoke:electron:windows:spotlight:shortcut:set') +export const electronSpotlightShortcutSet = defineInvokeEventa('eventa:invoke:electron:windows:spotlight:shortcut:set') export const electronOpenSettingsDevtools = defineInvokeEventa('eventa:invoke:electron:windows:settings:devtools:open') -export const electronOpenDevtoolsWindow = defineInvokeEventa('eventa:invoke:electron:windows:devtools:open') +export const electronOpenDevtoolsWindow = defineInvokeEventa('eventa:invoke:electron:windows:devtools:open') export interface ElectronServerChannelConfig { - tlsConfig?: ServerOptions['tlsConfig'] | null authToken: string hostname: string + tlsConfig?: null | ServerOptions['tlsConfig'] } export const electronGetServerChannelConfig = defineInvokeEventa('eventa:invoke:electron:server-channel:get-config') export const electronApplyServerChannelConfig = defineInvokeEventa>('eventa:invoke:electron:server-channel:apply-config') export const electronGetServerChannelQrPayload = defineInvokeEventa('eventa:invoke:electron:server-channel:get-qr-payload') -export type ElectronUpdaterChannel = 'latest' | 'stable' | 'alpha' | 'beta' | 'nightly' | 'canary' +export type ElectronUpdaterChannel = 'alpha' | 'beta' | 'canary' | 'latest' | 'nightly' | 'stable' export interface ElectronUpdaterPreferences { channel?: ElectronUpdaterChannel @@ -70,8 +70,8 @@ export * from './plugin/host' export * from './plugin/tools' export interface DesktopOverlayReadiness { - state: 'booting' | 'ready' | 'degraded' error?: string + state: 'booting' | 'degraded' | 'ready' } export const getDesktopOverlayReadinessContract = defineInvokeEventa('eventa:invoke:electron:windows:desktop-overlay:get-readiness') @@ -79,76 +79,173 @@ export const getDesktopOverlayReadinessContract = defineInvokeEventa('eventa:event:electron:windows:caption-overlay:is-following-window-changed') export const captionGetIsFollowingWindow = defineInvokeEventa('eventa:invoke:electron:windows:caption-overlay:get-is-following-window') -export type RequestWindowActionDefault = 'confirm' | 'cancel' | 'close' +// Reference window helpers are generic; callers can alias for clarity +export type NoticeAction = 'cancel' | 'close' | 'confirm' +export type RequestWindowActionDefault = 'cancel' | 'close' | 'confirm' export interface RequestWindowPayload { id?: string + payload?: Record route: string type?: string - payload?: Record -} -export interface RequestWindowPending { - id: string - type?: string - payload?: Record } -// Reference window helpers are generic; callers can alias for clarity -export type NoticeAction = 'confirm' | 'cancel' | 'close' +export interface RequestWindowPending { + id: string + payload?: Record + type?: string +} export function createRequestWindowEventa(namespace: string) { const prefix = (name: string) => `eventa:${name}:electron:windows:${namespace}` return { openWindow: defineInvokeEventa(prefix('invoke:open')), - windowAction: defineInvokeEventa(prefix('invoke:action')), pageMounted: defineInvokeEventa(prefix('invoke:page-mounted')), pageUnmounted: defineInvokeEventa(prefix('invoke:page-unmounted')), + windowAction: defineInvokeEventa(prefix('invoke:action')), } } // Notice window events built from generic factory export const noticeWindowEventa = createRequestWindowEventa('notice') -// Widgets / Adhoc window events -export interface WidgetWindowSize { - width?: number - height?: number - minWidth?: number - minHeight?: number - maxWidth?: number - maxHeight?: number +export interface ElectronMcpCallToolPayload { + arguments?: Record + name: string } -export type WidgetGridSize = 's' | 'm' | 'l' | { cols?: number, rows?: number } +export interface ElectronMcpCallToolResult { + content?: Array> + isError?: boolean + structuredContent?: Record + toolResult?: unknown +} + +export interface ElectronMcpStdioApplyResult { + failed: Array<{ error: string, name: string }> + path: string + skipped: Array<{ name: string, reason: string }> + started: Array<{ name: string }> +} + +export interface ElectronMcpStdioConfigFile { + mcpServers: Record +} + +export interface ElectronMcpStdioConfigText { + path: string + text: string +} + +export interface ElectronMcpStdioRuntimeStatus { + path: string + servers: ElectronMcpStdioServerRuntimeStatus[] + updatedAt: number +} + +export interface ElectronMcpStdioServerConfig { + args?: string[] + command: string + cwd?: string + enabled?: boolean + env?: Record +} + +export interface ElectronMcpStdioServerRuntimeStatus { + args: string[] + command: string + lastError?: string + name: string + pid: null | number + state: 'error' | 'running' | 'stopped' +} + +export interface ElectronMcpStdioTestPayload { + config: ElectronMcpStdioServerConfig + name: string +} + +export interface ElectronMcpStdioTestResult { + durationMs: number + error?: string + ok: boolean + tools?: string[] +} + +export interface ElectronMcpToolDescriptor { + description?: string + inputSchema: Record + name: string + serverName: string + toolName: string +} + +// TODO: Replace these manually duplicated IPC types with re-exports from +// @proj-airi/plugin-sdk (CapabilityDescriptor) once stage-ui and the shared +// eventa layer can depend on the SDK without introducing unwanted coupling. +export interface PluginCapabilityPayload { + key: string + metadata?: Record + state: 'announced' | 'degraded' | 'ready' | 'withdrawn' +} + +export interface PluginCapabilityState { + key: string + metadata?: Record + state: 'announced' | 'degraded' | 'ready' | 'withdrawn' + updatedAt: number +} + +export interface PluginHostDebugSnapshot { + capabilities: PluginCapabilityState[] + refreshedAt: number + registry: PluginRegistrySnapshot + sessions: PluginHostSessionSummary[] +} + +export interface PluginHostSessionSummary { + extensionId: string + id: string + moduleId: string + phase: string + runtime: 'electron' | 'node' | 'web' +} + +export interface PluginManifestSummary { + enabled: boolean + entrypoints: Record + extensionId: string + isNew: boolean + loaded: boolean + path: string +} + +export interface PluginRegistrySnapshot { + plugins: PluginManifestSummary[] + root: string +} + +export type WidgetGridSize = 'l' | 'm' | 's' | { cols?: number, rows?: number } export interface WidgetsAddPayload { - id?: string + alwaysOnTop?: boolean componentName: string componentProps?: Record - alwaysOnTop?: boolean + id?: string // size presets or explicit spans; renderer decides mapping size?: WidgetGridSize - windowSize?: WidgetWindowSize | Record // auto-dismiss in ms; if omitted, persistent until closed by user ttlMs?: number + windowSize?: Record | WidgetWindowSize } -export interface WidgetsUpdatePayload { - id: string - componentProps?: Record - alwaysOnTop?: boolean - size?: WidgetGridSize - windowSize?: WidgetWindowSize | Record - ttlMs?: number -} - -export interface WidgetSnapshot { - id: string - componentName: string - componentProps: Record - alwaysOnTop: boolean - size: WidgetGridSize - windowSize?: WidgetWindowSize - ttlMs: number +/** + * Failed renderer-to-main iframe request result. + */ +export interface WidgetsIframeRequestFailurePayload extends WidgetsIframeRequestResultBasePayload { + /** Error message returned when the iframe request fails. */ + error: string + /** Marks this result as a failed iframe response. */ + ok: false } /** @@ -157,10 +254,10 @@ export interface WidgetSnapshot { export interface WidgetsIframeRequestPayload { /** Widget id that identifies the mounted iframe target. */ id: string - /** Relay correlation id echoed by the renderer-to-main result event. */ - requestId: string /** Structured-clone-safe request record forwarded into the iframe Eventa runtime. */ payload: GameletIframeInvokePayload['payload'] + /** Relay correlation id echoed by the renderer-to-main result event. */ + requestId: string /** Request timeout budget in milliseconds. */ timeoutMs: number } @@ -175,6 +272,13 @@ export interface WidgetsIframeRequestResultBasePayload { requestId: string } +/** + * Result relayed from the widgets renderer back to Electron main for one iframe request. + */ +export type WidgetsIframeRequestResultPayload + = | WidgetsIframeRequestFailurePayload + | WidgetsIframeRequestSuccessPayload + /** * Successful renderer-to-main iframe request result. */ @@ -185,137 +289,33 @@ export interface WidgetsIframeRequestSuccessPayload extends WidgetsIframeRequest result: GameletIframeResponsePayload } -/** - * Failed renderer-to-main iframe request result. - */ -export interface WidgetsIframeRequestFailurePayload extends WidgetsIframeRequestResultBasePayload { - /** Marks this result as a failed iframe response. */ - ok: false - /** Error message returned when the iframe request fails. */ - error: string -} - -/** - * Result relayed from the widgets renderer back to Electron main for one iframe request. - */ -export type WidgetsIframeRequestResultPayload - = | WidgetsIframeRequestSuccessPayload - | WidgetsIframeRequestFailurePayload - -export interface PluginManifestSummary { - extensionId: string - entrypoints: Record - path: string - enabled: boolean - loaded: boolean - isNew: boolean -} - -export interface PluginRegistrySnapshot { - root: string - plugins: PluginManifestSummary[] -} - -// TODO: Replace these manually duplicated IPC types with re-exports from -// @proj-airi/plugin-sdk (CapabilityDescriptor) once stage-ui and the shared -// eventa layer can depend on the SDK without introducing unwanted coupling. -export interface PluginCapabilityPayload { - key: string - state: 'announced' | 'ready' | 'degraded' | 'withdrawn' - metadata?: Record -} - -export interface PluginCapabilityState { - key: string - state: 'announced' | 'ready' | 'degraded' | 'withdrawn' - metadata?: Record - updatedAt: number -} - -export interface PluginHostSessionSummary { +export interface WidgetSnapshot { + alwaysOnTop: boolean + componentName: string + componentProps: Record id: string - extensionId: string - phase: string - runtime: 'electron' | 'node' | 'web' - moduleId: string + size: WidgetGridSize + ttlMs: number + windowSize?: WidgetWindowSize } -export interface PluginHostDebugSnapshot { - registry: PluginRegistrySnapshot - sessions: PluginHostSessionSummary[] - capabilities: PluginCapabilityState[] - refreshedAt: number +export interface WidgetsUpdatePayload { + alwaysOnTop?: boolean + componentProps?: Record + id: string + size?: WidgetGridSize + ttlMs?: number + windowSize?: Record | WidgetWindowSize } -export interface ElectronMcpStdioServerConfig { - command: string - args?: string[] - env?: Record - cwd?: string - enabled?: boolean -} - -export interface ElectronMcpStdioConfigFile { - mcpServers: Record -} - -export interface ElectronMcpStdioApplyResult { - path: string - started: Array<{ name: string }> - failed: Array<{ name: string, error: string }> - skipped: Array<{ name: string, reason: string }> -} - -export interface ElectronMcpStdioServerRuntimeStatus { - name: string - state: 'running' | 'stopped' | 'error' - command: string - args: string[] - pid: number | null - lastError?: string -} - -export interface ElectronMcpStdioRuntimeStatus { - path: string - servers: ElectronMcpStdioServerRuntimeStatus[] - updatedAt: number -} - -export interface ElectronMcpToolDescriptor { - serverName: string - name: string - toolName: string - description?: string - inputSchema: Record -} - -export interface ElectronMcpCallToolPayload { - name: string - arguments?: Record -} - -export interface ElectronMcpCallToolResult { - content?: Array> - structuredContent?: Record - toolResult?: unknown - isError?: boolean -} - -export interface ElectronMcpStdioConfigText { - path: string - text: string -} - -export interface ElectronMcpStdioTestResult { - ok: boolean - error?: string - tools?: string[] - durationMs: number -} - -export interface ElectronMcpStdioTestPayload { - name: string - config: ElectronMcpStdioServerConfig +// Widgets / Adhoc window events +export interface WidgetWindowSize { + height?: number + maxHeight?: number + maxWidth?: number + minHeight?: number + minWidth?: number + width?: number } export const electronMcpOpenConfigFile = defineInvokeEventa<{ path: string }>('eventa:invoke:electron:mcp:open-config-file') @@ -333,20 +333,20 @@ export const widgetsAdd = defineInvokeEventa('eventa:invoke:electron:windows:widgets:remove') export const widgetsClear = defineInvokeEventa('eventa:invoke:electron:windows:widgets:clear') export const widgetsUpdate = defineInvokeEventa('eventa:invoke:electron:windows:widgets:update') -export const widgetsFetch = defineInvokeEventa('eventa:invoke:electron:windows:widgets:fetch') +export const widgetsFetch = defineInvokeEventa('eventa:invoke:electron:windows:widgets:fetch') export const widgetsPrepareWindow = defineInvokeEventa('eventa:invoke:electron:windows:widgets:prepare') -export const widgetsIframePublish = defineInvokeEventa }>('eventa:invoke:electron:windows:widgets:iframe-publish') +export const widgetsIframePublish = defineInvokeEventa, id: string }>('eventa:invoke:electron:windows:widgets:iframe-publish') export const electronWindowClose = defineInvokeEventa('eventa:invoke:electron:window:close') export type ElectronWindowLifecycleReason - = | 'initial' - | 'snapshot' - | 'show' + = | 'blur' + | 'focus' | 'hide' + | 'initial' | 'minimize' | 'restore' - | 'focus' - | 'blur' + | 'show' + | 'snapshot' export interface ElectronWindowLifecycleState { focused: boolean @@ -362,7 +362,28 @@ export const electronWindowSetAlwaysOnTop = defineInvokeEventa('e export const electronAppOpenUserDataFolder = defineInvokeEventa<{ path: string }>('eventa:invoke:electron:app:open-user-data-folder') export const electronAppQuit = defineInvokeEventa('eventa:invoke:electron:app:quit') -export type ElectronGodotStageState = 'stopped' | 'starting' | 'running' | 'stopping' | 'error' +/** + * Serialized scene input payload forwarded from renderer to Electron main. + * + * Use when: + * - The selected model should be materialized to disk and applied to the Godot scene + * + * Expects: + * - `data` contains the full model file bytes + * - `fileName` matches the original model asset name when available + * + * Returns: + * - N/A + */ +export interface ElectronGodotStageSceneInputPayload { + data: Uint8Array + fileName: string + format: 'vrm' + modelId: string + name: string +} + +export type ElectronGodotStageState = 'error' | 'running' | 'starting' | 'stopped' | 'stopping' /** * Snapshot of the Godot sidecar lifecycle owned by Electron main. @@ -379,38 +400,17 @@ export type ElectronGodotStageState = 'stopped' | 'starting' | 'running' | 'stop * - N/A */ export interface ElectronGodotStageStatus { - state: ElectronGodotStageState - pid: number | null lastError?: string + pid: null | number + state: ElectronGodotStageState updatedAt: number } -/** - * Serialized scene input payload forwarded from renderer to Electron main. - * - * Use when: - * - The selected model should be materialized to disk and applied to the Godot scene - * - * Expects: - * - `data` contains the full model file bytes - * - `fileName` matches the original model asset name when available - * - * Returns: - * - N/A - */ -export interface ElectronGodotStageSceneInputPayload { - modelId: string - format: 'vrm' - name: string - fileName: string - data: Uint8Array -} - export const electronGodotStageStart = defineInvokeEventa('eventa:invoke:electron:godot-stage:start') export const electronGodotStageStop = defineInvokeEventa('eventa:invoke:electron:godot-stage:stop') export const electronGodotStageGetStatus = defineInvokeEventa('eventa:invoke:electron:godot-stage:get-status') export const electronGodotStageApplySceneInput = defineInvokeEventa('eventa:invoke:electron:godot-stage:apply-scene-input') -export const electronGodotStageGetViewSnapshot = defineInvokeEventa('eventa:invoke:electron:godot-stage:view-snapshot:get') +export const electronGodotStageGetViewSnapshot = defineInvokeEventa('eventa:invoke:electron:godot-stage:view-snapshot:get') export const electronGodotStageApplyViewPatch = defineInvokeEventa('eventa:invoke:electron:godot-stage:view-state:apply-patch') export const electronGodotStageRequestViewSnapshot = defineInvokeEventa('eventa:invoke:electron:godot-stage:view-state:request-snapshot') export const electronGodotStageStatusChanged = defineEventa('eventa:event:electron:godot-stage:status-changed') @@ -419,15 +419,6 @@ export const electronGodotStageViewStateError = defineEventa -/** - * Phase of a shortcut trigger event. - * - * - `down` — key combination pressed - * - `up` — key combination released; only emitted by drivers that - * accepted a binding with `receiveKeyUps: true` - */ -export type ElectronShortcutTriggerPhase = 'down' | 'up' - /** * Payload broadcast to all subscribed windows when a registered shortcut * fires. Renderer composables filter by `id` to dispatch local handlers. @@ -437,6 +428,15 @@ export interface ElectronShortcutTriggerPayload { phase: ElectronShortcutTriggerPhase } +/** + * Phase of a shortcut trigger event. + * + * - `down` — key combination pressed + * - `up` — key combination released; only emitted by drivers that + * accepted a binding with `receiveKeyUps: true` + */ +export type ElectronShortcutTriggerPhase = 'down' | 'up' + export const electronShortcutRegister = defineInvokeEventa('eventa:invoke:electron:shortcut:register') export const electronShortcutUnregister = defineInvokeEventa('eventa:invoke:electron:shortcut:unregister') export const electronShortcutUnregisterAll = defineInvokeEventa('eventa:invoke:electron:shortcut:unregister-all') @@ -446,14 +446,14 @@ export const electronShortcutTriggered = defineEventa('eventa:invoke:electron:auth:start-login') export const electronAuthCallback = defineEventa('eventa:event:electron:auth:callback') diff --git a/apps/stage-tamagotchi/src/shared/eventa/plugin/capabilities.ts b/apps/stage-tamagotchi/src/shared/eventa/plugin/capabilities.ts index dc9e0dcba..33c549aa6 100644 --- a/apps/stage-tamagotchi/src/shared/eventa/plugin/capabilities.ts +++ b/apps/stage-tamagotchi/src/shared/eventa/plugin/capabilities.ts @@ -14,8 +14,8 @@ import { defineInvokeEventa } from '@moeru/eventa' */ export interface PluginCapabilityPayload { key: string - state: 'announced' | 'ready' | 'degraded' | 'withdrawn' metadata?: Record + state: 'announced' | 'degraded' | 'ready' | 'withdrawn' } /** @@ -32,8 +32,8 @@ export interface PluginCapabilityPayload { */ export interface PluginCapabilityState { key: string - state: 'announced' | 'ready' | 'degraded' | 'withdrawn' metadata?: Record + state: 'announced' | 'degraded' | 'ready' | 'withdrawn' updatedAt: number } diff --git a/apps/stage-tamagotchi/src/shared/eventa/plugin/host.ts b/apps/stage-tamagotchi/src/shared/eventa/plugin/host.ts index 6efb2cd2a..3f0d7700e 100644 --- a/apps/stage-tamagotchi/src/shared/eventa/plugin/host.ts +++ b/apps/stage-tamagotchi/src/shared/eventa/plugin/host.ts @@ -3,24 +3,127 @@ import type { PluginCapabilityState } from './capabilities' import { defineInvokeEventa } from '@moeru/eventa' /** - * Window sizing metadata forwarded through plugin widget payloads. + * Full plugin host inspection snapshot. * * Use when: - * - A plugin module wants the host to size an extension UI widget window + * - Renderer devtools need registry, session, kit, and module state together * * Expects: - * - Dimensions are pixel values understood by the Electron window layer + * - All arrays are snapshots captured at `refreshedAt` * * Returns: * - N/A */ -interface PluginModuleWidgetWindowSize { - width: number - height: number - minWidth?: number - minHeight?: number - maxWidth?: number - maxHeight?: number +export interface PluginHostDebugSnapshot { + capabilities: PluginCapabilityState[] + kits: PluginHostKitSummary[] + modules: PluginHostModuleSummary[] + refreshedAt: number + registry: PluginRegistrySnapshot + sessions: PluginHostSessionSummary[] +} + +/** + * Capability summary exposed by one registered kit. + * + * Use when: + * - Renderer tooling needs to show what actions a kit supports + * + * Expects: + * - `actions` contains unique action identifiers + * + * Returns: + * - N/A + */ +export interface PluginHostKitCapabilitySummary { + actions: string[] + key: string +} + +/** + * Registered kit summary exposed by the plugin host. + * + * Use when: + * - Inspecting kit registration state from renderer tooling + * + * Expects: + * - `capabilities` matches the installed kit descriptor state + * + * Returns: + * - N/A + */ +export interface PluginHostKitSummary { + capabilities: PluginHostKitCapabilitySummary[] + kitId: string + runtimes: Array<'electron' | 'node' | 'web'> + version: string +} + +/** + * Registered plugin module binding summary. + * + * Use when: + * - Inspecting plugin modules and deriving renderer-side extension UI state + * + * Expects: + * - `config` is JSON-compatible and structured-clone-safe + * + * Returns: + * - N/A + */ +export interface PluginHostModuleSummary { + config: Record + kitId: string + kitModuleType: string + moduleId: string + ownerExtensionId: string + ownerSessionId: string + revision: number + runtime: 'electron' | 'node' | 'web' + state: 'active' | 'announced' | 'degraded' | 'withdrawn' + updatedAt: number +} + +/** + * Active plugin session summary. + * + * Use when: + * - Inspecting the live plugin host runtime state + * + * Expects: + * - `id` stays stable for the lifetime of one started plugin session + * + * Returns: + * - N/A + */ +export interface PluginHostSessionSummary { + extensionId: string + id: string + moduleId: string + phase: string + runtime: 'electron' | 'node' | 'web' +} + +/** + * Renderer-facing plugin manifest summary. + * + * Use when: + * - Listing discovered plugins in devtools or settings surfaces + * + * Expects: + * - `path` points to the manifest file on disk + * + * Returns: + * - N/A + */ +export interface PluginManifestSummary { + autoReload: boolean + enabled: boolean + entrypoints: Record + extensionId: string + isNew: boolean + loaded: boolean + path: string } /** @@ -37,36 +140,14 @@ interface PluginModuleWidgetWindowSize { * - N/A */ export interface PluginModuleWidgetPayload { + componentProps?: Record moduleId: string + payload?: Record title?: string widgetComponent?: string - componentProps?: Record - payload?: Record windowSize?: PluginModuleWidgetWindowSize } -/** - * Renderer-facing plugin manifest summary. - * - * Use when: - * - Listing discovered plugins in devtools or settings surfaces - * - * Expects: - * - `path` points to the manifest file on disk - * - * Returns: - * - N/A - */ -export interface PluginManifestSummary { - extensionId: string - entrypoints: Record - path: string - enabled: boolean - autoReload: boolean - loaded: boolean - isNew: boolean -} - /** * Snapshot of the current plugin manifest registry. * @@ -80,115 +161,34 @@ export interface PluginManifestSummary { * - N/A */ export interface PluginRegistrySnapshot { - root: string plugins: PluginManifestSummary[] + root: string } /** - * Active plugin session summary. + * Window sizing metadata forwarded through plugin widget payloads. * * Use when: - * - Inspecting the live plugin host runtime state + * - A plugin module wants the host to size an extension UI widget window * * Expects: - * - `id` stays stable for the lifetime of one started plugin session + * - Dimensions are pixel values understood by the Electron window layer * * Returns: * - N/A */ -export interface PluginHostSessionSummary { - id: string - extensionId: string - phase: string - runtime: 'electron' | 'node' | 'web' - moduleId: string -} - -/** - * Capability summary exposed by one registered kit. - * - * Use when: - * - Renderer tooling needs to show what actions a kit supports - * - * Expects: - * - `actions` contains unique action identifiers - * - * Returns: - * - N/A - */ -export interface PluginHostKitCapabilitySummary { - key: string - actions: string[] -} - -/** - * Registered kit summary exposed by the plugin host. - * - * Use when: - * - Inspecting kit registration state from renderer tooling - * - * Expects: - * - `capabilities` matches the installed kit descriptor state - * - * Returns: - * - N/A - */ -export interface PluginHostKitSummary { - kitId: string - version: string - capabilities: PluginHostKitCapabilitySummary[] - runtimes: Array<'electron' | 'node' | 'web'> -} - -/** - * Registered plugin module binding summary. - * - * Use when: - * - Inspecting plugin modules and deriving renderer-side extension UI state - * - * Expects: - * - `config` is JSON-compatible and structured-clone-safe - * - * Returns: - * - N/A - */ -export interface PluginHostModuleSummary { - moduleId: string - ownerSessionId: string - ownerExtensionId: string - kitId: string - kitModuleType: string - state: 'announced' | 'active' | 'degraded' | 'withdrawn' - runtime: 'electron' | 'node' | 'web' - revision: number - updatedAt: number - config: Record -} - -/** - * Full plugin host inspection snapshot. - * - * Use when: - * - Renderer devtools need registry, session, kit, and module state together - * - * Expects: - * - All arrays are snapshots captured at `refreshedAt` - * - * Returns: - * - N/A - */ -export interface PluginHostDebugSnapshot { - registry: PluginRegistrySnapshot - sessions: PluginHostSessionSummary[] - kits: PluginHostKitSummary[] - modules: PluginHostModuleSummary[] - capabilities: PluginCapabilityState[] - refreshedAt: number +interface PluginModuleWidgetWindowSize { + height: number + maxHeight?: number + maxWidth?: number + minHeight?: number + minWidth?: number + width: number } export const electronPluginList = defineInvokeEventa('eventa:invoke:electron:plugins:list') -export const electronPluginSetEnabled = defineInvokeEventa('eventa:invoke:electron:plugins:set-enabled') -export const electronPluginSetAutoReload = defineInvokeEventa('eventa:invoke:electron:plugins:set-auto-reload') +export const electronPluginSetEnabled = defineInvokeEventa('eventa:invoke:electron:plugins:set-enabled') +export const electronPluginSetAutoReload = defineInvokeEventa('eventa:invoke:electron:plugins:set-auto-reload') export const electronPluginLoadEnabled = defineInvokeEventa('eventa:invoke:electron:plugins:load-enabled') export const electronPluginLoad = defineInvokeEventa('eventa:invoke:electron:plugins:load') export const electronPluginUnload = defineInvokeEventa('eventa:invoke:electron:plugins:unload') diff --git a/apps/stage-tamagotchi/src/shared/eventa/plugin/tools.ts b/apps/stage-tamagotchi/src/shared/eventa/plugin/tools.ts index 56475c81f..84aa42c22 100644 --- a/apps/stage-tamagotchi/src/shared/eventa/plugin/tools.ts +++ b/apps/stage-tamagotchi/src/shared/eventa/plugin/tools.ts @@ -13,13 +13,52 @@ import { defineEventa, defineInvokeEventa } from '@moeru/eventa' * - N/A */ export interface ElectronPluginToolDescriptor { - id: string - title: string - description: string activation: { keywords: string[] patterns: string[] } + description: string + id: string + title: string +} + +/** + * Describes why plugin-backed runtime tools should be refreshed. + * + * Use when: + * - The main process notifies renderers after plugin lifecycle changes + * + * Expects: + * - `extensionId` is present when the change is scoped to one extension + * + * Returns: + * - N/A + */ +export interface ElectronPluginToolsChangedPayload { + extensionId?: string + reason: 'enabled-state-changed' | 'load-enabled' | 'loaded' | 'unloaded' +} + +/** + * Serialized toolset prompt exposed by the plugin host. + * + * Use when: + * - Registering plugin-backed prompt guidance in the renderer + * + * Expects: + * - `content` is already model-facing prompt text + * + * Returns: + * - N/A + */ +export interface ElectronPluginToolsetPromptDefinition { + id: string + ownerExtensionId: string + prompt: { + content: string + id: string + title?: string + } } /** @@ -35,32 +74,10 @@ export interface ElectronPluginToolDescriptor { * - N/A */ export interface ElectronPluginXsaiToolDefinition { - ownerExtensionId: string - name: string description: string - parameters: Record -} - -/** - * Serialized toolset prompt exposed by the plugin host. - * - * Use when: - * - Registering plugin-backed prompt guidance in the renderer - * - * Expects: - * - `content` is already model-facing prompt text - * - * Returns: - * - N/A - */ -export interface ElectronPluginToolsetPromptDefinition { + name: string ownerExtensionId: string - id: string - prompt: { - id: string - title?: string - content: string - } + parameters: Record } /** @@ -76,32 +93,15 @@ export interface ElectronPluginToolsetPromptDefinition { * - N/A */ export interface ElectronPluginXsaiToolsetDefinition { - tools: ElectronPluginXsaiToolDefinition[] prompts: ElectronPluginToolsetPromptDefinition[] -} - -/** - * Describes why plugin-backed runtime tools should be refreshed. - * - * Use when: - * - The main process notifies renderers after plugin lifecycle changes - * - * Expects: - * - `extensionId` is present when the change is scoped to one extension - * - * Returns: - * - N/A - */ -export interface ElectronPluginToolsChangedPayload { - reason: 'loaded' | 'load-enabled' | 'unloaded' | 'enabled-state-changed' - extensionId?: string + tools: ElectronPluginXsaiToolDefinition[] } export const electronPluginListAgentTools = defineInvokeEventa('eventa:invoke:electron:plugins:tools:list') export const electronPluginListXsaiTools = defineInvokeEventa('eventa:invoke:electron:plugins:tools:list-xsai') export const electronPluginInvokeTool = defineInvokeEventa('eventa:invoke:electron:plugins:tools:invoke') export const electronPluginToolsChanged = defineEventa('eventa:event:electron:plugins:tools:changed') diff --git a/apps/stage-tamagotchi/src/shared/mcp-config.ts b/apps/stage-tamagotchi/src/shared/mcp-config.ts index 399158849..9e5e69293 100644 --- a/apps/stage-tamagotchi/src/shared/mcp-config.ts +++ b/apps/stage-tamagotchi/src/shared/mcp-config.ts @@ -28,11 +28,11 @@ function stringifyError(error: unknown) { * - A strict Zod schema matching the persisted MCP server shape */ export const electronMcpStdioServerConfigSchema = z.object({ - command: z.string().min(1), args: z.array(z.string()).optional(), - env: z.record(z.string(), z.string()).optional(), + command: z.string().min(1), cwd: z.string().optional(), enabled: z.boolean().optional(), + env: z.record(z.string(), z.string()).optional(), }).strict() satisfies z.ZodType /** diff --git a/apps/stage-tamagotchi/src/shared/model-settings-runtime.ts b/apps/stage-tamagotchi/src/shared/model-settings-runtime.ts index 1d1209791..700ede435 100644 --- a/apps/stage-tamagotchi/src/shared/model-settings-runtime.ts +++ b/apps/stage-tamagotchi/src/shared/model-settings-runtime.ts @@ -3,6 +3,6 @@ import type { ModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/component export const modelSettingsRuntimeSnapshotChannelName = 'airi-model-settings-runtime-snapshot' export type ModelSettingsRuntimeChannelEvent - = | { type: 'request-current' } - | { type: 'snapshot', snapshot: ModelSettingsRuntimeSnapshot } - | { type: 'owner-gone', ownerInstanceId: string } + = | { ownerInstanceId: string, type: 'owner-gone' } + | { snapshot: ModelSettingsRuntimeSnapshot, type: 'snapshot' } + | { type: 'request-current' } diff --git a/apps/stage-tamagotchi/src/shared/spotlight-shortcut.ts b/apps/stage-tamagotchi/src/shared/spotlight-shortcut.ts index ec4a8f597..716935f3b 100644 --- a/apps/stage-tamagotchi/src/shared/spotlight-shortcut.ts +++ b/apps/stage-tamagotchi/src/shared/spotlight-shortcut.ts @@ -1,6 +1,6 @@ import type { ShortcutAccelerator, ShortcutModifier } from '@proj-airi/stage-shared/global-shortcut' -const safeModifiers = new Set(['cmd', 'ctrl', 'alt', 'super']) +const safeModifiers = new Set(['alt', 'cmd', 'ctrl', 'super']) export function isSafeSpotlightAccelerator(accelerator: ShortcutAccelerator): boolean { return accelerator.modifiers.some(modifier => safeModifiers.has(modifier)) diff --git a/apps/stage-tamagotchi/src/shared/utils/electron/windows/window-size.ts b/apps/stage-tamagotchi/src/shared/utils/electron/windows/window-size.ts index 452665f92..1c682cad1 100644 --- a/apps/stage-tamagotchi/src/shared/utils/electron/windows/window-size.ts +++ b/apps/stage-tamagotchi/src/shared/utils/electron/windows/window-size.ts @@ -22,8 +22,8 @@ import type { WidgetWindowSize } from '../../../eventa' * - `undefined` */ export function normalizeWidgetWindowSize( - windowSize?: WidgetWindowSize | Record, -): WidgetWindowSize | undefined { + windowSize?: Record | WidgetWindowSize, +): undefined | WidgetWindowSize { if (!windowSize || typeof windowSize !== 'object' || Array.isArray(windowSize)) return undefined @@ -34,8 +34,8 @@ export function normalizeWidgetWindowSize( return undefined const normalized: WidgetWindowSize = { - width: Math.floor(width), height: Math.floor(height), + width: Math.floor(width), } for (const key of ['minWidth', 'minHeight', 'maxWidth', 'maxHeight'] as const) { diff --git a/apps/stage-tamagotchi/uno.config.ts b/apps/stage-tamagotchi/uno.config.ts index 8625102dd..ac5c599d3 100644 --- a/apps/stage-tamagotchi/uno.config.ts +++ b/apps/stage-tamagotchi/uno.config.ts @@ -11,11 +11,11 @@ export default mergeConfigs([ fonts: { ...presetWebFontsFonts('none'), }, - timeouts: { - warning: 5000, - failure: 10000, - }, processors: createLocalFontProcessor(), + timeouts: { + failure: 10000, + warning: 5000, + }, }), ], }), diff --git a/apps/stage-tamagotchi/vitest.config.ts b/apps/stage-tamagotchi/vitest.config.ts index 6b23175c7..fa6970eae 100644 --- a/apps/stage-tamagotchi/vitest.config.ts +++ b/apps/stage-tamagotchi/vitest.config.ts @@ -8,38 +8,38 @@ import { loadEnv } from 'vite' import { defineConfig } from 'vitest/config' export default defineConfig({ - root: import.meta.dirname, plugins: [ Info(), vue(), ], + root: import.meta.dirname, test: { env: loadEnv('test', cwd(), ''), projects: [ { extends: true, test: { - name: 'node', - include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], exclude: ['src/**/*.browser.test.ts', '**/node_modules/**', '**/.git/**'], fileParallelism: false, + include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], maxWorkers: 1, + name: 'node', }, }, { extends: true, test: { - name: 'browser', - include: ['src/**/*.browser.test.ts'], - exclude: ['**/node_modules/**', '**/.git/**'], browser: { enabled: true, headless: true, - provider: playwright(), instances: [ { browser: 'chromium' }, ], + provider: playwright(), }, + exclude: ['**/node_modules/**', '**/.git/**'], + include: ['src/**/*.browser.test.ts'], + name: 'browser', }, }, ], diff --git a/apps/stage-tamagotchi/vitest.node.config.ts b/apps/stage-tamagotchi/vitest.node.config.ts index 174b15aef..13b64263d 100644 --- a/apps/stage-tamagotchi/vitest.node.config.ts +++ b/apps/stage-tamagotchi/vitest.node.config.ts @@ -7,14 +7,14 @@ import { loadEnv } from 'vite' import { defineProject } from 'vitest/config' export default defineProject({ - root: import.meta.dirname, plugins: [Info(), vue()], + root: import.meta.dirname, test: { - name: 'stage-tamagotchi:node', env: loadEnv('test', cwd(), ''), - include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], exclude: ['src/**/*.browser.test.ts', '**/node_modules/**', '**/.git/**'], fileParallelism: false, + include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], maxWorkers: 1, + name: 'stage-tamagotchi:node', }, }) diff --git a/apps/stage-web/src/components/Devtools/performance-overlay.browser.test.ts b/apps/stage-web/src/components/Devtools/performance-overlay.browser.test.ts index 9cf740028..b49a9a096 100644 --- a/apps/stage-web/src/components/Devtools/performance-overlay.browser.test.ts +++ b/apps/stage-web/src/components/Devtools/performance-overlay.browser.test.ts @@ -38,10 +38,10 @@ describe('performance overlay recording controls', () => { }, }) - await screen.getByRole('button', { name: 'Record', exact: true }).click() + await screen.getByRole('button', { exact: true, name: 'Record' }).click() expect(store.recording).toBe(true) - await screen.getByRole('button', { name: 'Stop', exact: true }).click() + await screen.getByRole('button', { exact: true, name: 'Stop' }).click() expect(store.recording).toBe(false) expect(exportCsv).not.toHaveBeenCalled() diff --git a/apps/stage-web/src/composables/audio-input.ts b/apps/stage-web/src/composables/audio-input.ts index 64ac2a549..034fbf0b4 100644 --- a/apps/stage-web/src/composables/audio-input.ts +++ b/apps/stage-web/src/composables/audio-input.ts @@ -9,7 +9,7 @@ export function useAudioInput() { const audioInputs = computed(() => devices.audioInputs.value) const constraints = ref({ audio: true }) - const media = useUserMedia({ constraints, autoSwitch: true, enabled: false }) + const media = useUserMedia({ autoSwitch: true, constraints, enabled: false }) async function request() { if (devices.permissionGranted.value) { @@ -71,13 +71,13 @@ export function useAudioInput() { } return { - selectedAudioInputId, - selectedAudioInput, audioInputs, + media, + request, + selectedAudioInput, + selectedAudioInputId, start, stop, - request, - media, } } diff --git a/apps/stage-web/src/composables/icon-animation.ts b/apps/stage-web/src/composables/icon-animation.ts index 3149a8e5b..311d3e211 100644 --- a/apps/stage-web/src/composables/icon-animation.ts +++ b/apps/stage-web/src/composables/icon-animation.ts @@ -22,8 +22,8 @@ export function useIconAnimation(icon: string) { }) return { + animationIcon, iconAnimationStarted, showIconAnimation, - animationIcon, } } diff --git a/apps/stage-web/src/composables/perf/register-lag-sampler.ts b/apps/stage-web/src/composables/perf/register-lag-sampler.ts index 298b26f51..f6f4a6f6d 100644 --- a/apps/stage-web/src/composables/perf/register-lag-sampler.ts +++ b/apps/stage-web/src/composables/perf/register-lag-sampler.ts @@ -46,17 +46,17 @@ export function createLagSampler(tracer: PerfTracer) { const fps = delta > 0 ? 1000 / delta : 0 tracer.emit({ - tracerId: 'lag', - name: 'fps', - ts, duration: fps, + name: 'fps', + tracerId: 'lag', + ts, }) tracer.emit({ - tracerId: 'lag', - name: 'frameDuration', - ts, duration: delta, + name: 'frameDuration', + tracerId: 'lag', + ts, }) } @@ -81,14 +81,14 @@ export function createLagSampler(tracer: PerfTracer) { longTaskObserver = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { tracer.emit({ - tracerId: 'lag', - name: 'longtask', - ts: entry.startTime, duration: entry.duration, + name: 'longtask', + tracerId: 'lag', + ts: entry.startTime, }) } }) - longTaskObserver.observe({ type: 'longtask', buffered: true }) + longTaskObserver.observe({ buffered: true, type: 'longtask' }) } catch (error) { console.warn('[LagSampler] Failed to start longtask observer', error) @@ -110,10 +110,10 @@ export function createLagSampler(tracer: PerfTracer) { memoryTimer = setInterval(() => { tracer.emit({ - tracerId: 'lag', - name: 'memory', - ts: performance.now(), duration: perfWithMemory.memory?.usedJSHeapSize ?? 0, + name: 'memory', + tracerId: 'lag', + ts: performance.now(), }) }, 1000) } @@ -138,8 +138,8 @@ export function createLagSampler(tracer: PerfTracer) { } return { - supported, start, stop, + supported, } } diff --git a/apps/stage-web/src/main.ts b/apps/stage-web/src/main.ts index 635e186ac..85ea0ea87 100644 --- a/apps/stage-web/src/main.ts +++ b/apps/stage-web/src/main.ts @@ -47,9 +47,9 @@ const routeRecords = setupLayouts(routes as RouteRecordRaw[]) let router: Router if (isEnvTruthy(import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE)) - router = createRouter({ routes: routeRecords, history: createWebHashHistory() }) + router = createRouter({ history: createWebHashHistory(), routes: routeRecords }) else - router = createRouter({ routes: routeRecords, history: createWebHistory() }) + router = createRouter({ history: createWebHistory(), routes: routeRecords }) router.beforeEach((to, from) => { if (to.path !== from.path) diff --git a/apps/stage-web/src/modules/i18n.ts b/apps/stage-web/src/modules/i18n.ts index 438770ebe..4c451a6e6 100644 --- a/apps/stage-web/src/modules/i18n.ts +++ b/apps/stage-web/src/modules/i18n.ts @@ -15,8 +15,8 @@ function getLocale() { } export const i18n = createI18n({ + fallbackLocale: 'en', legacy: false, locale: getLocale(), - fallbackLocale: 'en', messages, }) diff --git a/apps/stage-web/src/pages/devtools/polaroid.browser.test.ts b/apps/stage-web/src/pages/devtools/polaroid.browser.test.ts index 85f79edda..7789453f6 100644 --- a/apps/stage-web/src/pages/devtools/polaroid.browser.test.ts +++ b/apps/stage-web/src/pages/devtools/polaroid.browser.test.ts @@ -63,6 +63,18 @@ afterAll(() => { layoutStyle.remove() }) +async function expectImportedModelPhoto(component: Component, modelId: string) { + const container = await renderPolaroid(component, modelId) + + await expect.poll(() => container.querySelector('option'), { timeout: 20_000 }).toBeInstanceOf(HTMLOptionElement) + + const canvas = container.querySelector('canvas') + if (!(canvas instanceof HTMLCanvasElement)) + throw new TypeError('Polaroid did not render a canvas.') + + expect(canvas.toDataURL('image/png')).toMatch(/^data:image\/png;base64,./) +} + async function renderPolaroid(component: Component, modelId: string) { const pinia = createPinia() const displayModels = useDisplayModelsStore(pinia) @@ -82,12 +94,12 @@ async function renderPolaroid(component: Component, modelId: string) { app.mount(container) displayModels.displayModels.unshift({ - id: modelId, - format: DisplayModelFormat.Live2dZip, - type: 'file', file, - name: file.name, + format: DisplayModelFormat.Live2dZip, + id: modelId, importedAt: Date.now(), + name: file.name, + type: 'file', }) settings.stageModelSelected = modelId @@ -97,18 +109,6 @@ async function renderPolaroid(component: Component, modelId: string) { return container } -async function expectImportedModelPhoto(component: Component, modelId: string) { - const container = await renderPolaroid(component, modelId) - - await expect.poll(() => container.querySelector('option'), { timeout: 20_000 }).toBeInstanceOf(HTMLOptionElement) - - const canvas = container.querySelector('canvas') - if (!(canvas instanceof HTMLCanvasElement)) - throw new TypeError('Polaroid did not render a canvas.') - - expect(canvas.toDataURL('image/png')).toMatch(/^data:image\/png;base64,./) -} - describe('polaroid imported Live2D model', () => { it('loads and captures an imported model on the web page', async () => { // ROOT CAUSE: diff --git a/apps/stage-web/src/stores/devtools-lag.ts b/apps/stage-web/src/stores/devtools-lag.ts index 7c3efe1df..53843bc02 100644 --- a/apps/stage-web/src/stores/devtools-lag.ts +++ b/apps/stage-web/src/stores/devtools-lag.ts @@ -8,22 +8,68 @@ import { createLagSampler } from '../composables/perf/register-lag-sampler' export type LagMetric = 'fps' | 'frameDuration' | 'longtask' | 'memory' -interface Sample { - ts: number - value: number - meta?: Record +interface HistogramBin { + count: number + end: number + start: number } interface RecordingSnapshot { + samples: Record startedAt: number stoppedAt: number - samples: Record } -interface HistogramBin { - start: number - end: number - count: number +interface Sample { + meta?: Record + ts: number + value: number +} + +function buildHistogram(values: number[], bins = 20): HistogramBin[] { + if (!values.length) + return [] + + const min = Math.min(...values) + const max = Math.max(...values) + if (min === max) { + return [{ + count: values.length, + end: max || min + 1, + start: min, + }] + } + + const width = (max - min) / bins + const buckets = Array.from({ length: bins }, (_, idx) => ({ + count: 0, + end: min + ((idx + 1) * width), + start: min + (idx * width), + })) + + for (const value of values) { + let binIndex = Math.floor((value - min) / width) + if (binIndex >= bins) + binIndex = bins - 1 + + buckets[binIndex].count += 1 + } + + return buckets +} + +function calcStats(values: number[]) { + if (!values.length) + return { avg: 0, latest: 0, p95: 0 } + + const total = values.reduce((acc, n) => acc + n, 0) + const avg = total / values.length + const sorted = [...values].sort((a, b) => a - b) + const idx = Math.max(0, Math.floor(0.95 * (sorted.length - 1))) + const p95 = sorted[idx] + const latest = values.at(-1) ?? 0 + + return { avg, latest, p95 } } function createEmptySamples(): Record { @@ -40,52 +86,6 @@ function pruneSamples(buffer: Sample[], cutoffTs: number) { buffer.shift() } -function calcStats(values: number[]) { - if (!values.length) - return { avg: 0, p95: 0, latest: 0 } - - const total = values.reduce((acc, n) => acc + n, 0) - const avg = total / values.length - const sorted = [...values].sort((a, b) => a - b) - const idx = Math.max(0, Math.floor(0.95 * (sorted.length - 1))) - const p95 = sorted[idx] - const latest = values.at(-1) ?? 0 - - return { avg, p95, latest } -} - -function buildHistogram(values: number[], bins = 20): HistogramBin[] { - if (!values.length) - return [] - - const min = Math.min(...values) - const max = Math.max(...values) - if (min === max) { - return [{ - start: min, - end: max || min + 1, - count: values.length, - }] - } - - const width = (max - min) / bins - const buckets = Array.from({ length: bins }, (_, idx) => ({ - start: min + (idx * width), - end: min + ((idx + 1) * width), - count: 0, - })) - - for (const value of values) { - let binIndex = Math.floor((value - min) / width) - if (binIndex >= bins) - binIndex = bins - 1 - - buckets[binIndex].count += 1 - } - - return buckets -} - export const useDevtoolsLagStore = defineStore('devtoolsLag', () => { const enabled = reactive({ fps: false, @@ -98,7 +98,7 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => { const buffers = reactive(createEmptySamples()) const recording = ref(false) - const recordingStartedAt = ref(null) + const recordingStartedAt = ref(null) const recordingElapsedMs = ref(0) const recordingSamples = reactive(createEmptySamples()) const lastRecording = ref() @@ -120,12 +120,12 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => { const cutoff = ts - windowMs const buffer = buffers[metric] - buffer.push({ ts, value, meta }) + buffer.push({ meta, ts, value }) pruneSamples(buffer, cutoff) if (recording.value) { const sampleBuffer = recordingSamples[metric] - sampleBuffer.push({ ts, value, meta }) + sampleBuffer.push({ meta, ts, value }) } } @@ -165,14 +165,14 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => { ? 0 : stoppedAt - recordingStartedAt.value const snapshot: RecordingSnapshot = { - startedAt: recordingStartedAt.value ?? stoppedAt, - stoppedAt, samples: { fps: [...recordingSamples.fps], frameDuration: [...recordingSamples.frameDuration], longtask: [...recordingSamples.longtask], memory: [...recordingSamples.memory], }, + startedAt: recordingStartedAt.value ?? stoppedAt, + stoppedAt, } lastRecording.value = snapshot @@ -253,7 +253,7 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => { if (!target) return - const rows: Array> = [['metric', 'ts', 'value', 'meta']] + const rows: Array> = [['metric', 'ts', 'value', 'meta']] for (const metric of Object.keys(target.samples) as LagMetric[]) { for (const sample of target.samples[metric]) { rows.push([ @@ -291,18 +291,18 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => { } return { - enabled, buffers, + buildHistogram, + calcStats, + enabled, + exportCsv, + lastRecording, recording, recordingElapsedMs, - lastRecording, - supported, startRecording, stopRecording, - toggleRecording, - exportCsv, + supported, toggleAll, - calcStats, - buildHistogram, + toggleRecording, } }) diff --git a/apps/stage-web/src/stores/pwa.ts b/apps/stage-web/src/stores/pwa.ts index beb005dc7..9f2ae42d5 100644 --- a/apps/stage-web/src/stores/pwa.ts +++ b/apps/stage-web/src/stores/pwa.ts @@ -25,8 +25,8 @@ export const usePWAStore = defineStore('pwa', () => { onNeedRefresh: () => { const id = nanoid() toast.custom(markRaw(h(ToasterPWAUpdateReady, { id, onUpdate: () => updateSW() })), { - id, duration: 30000, + id, position: isMobile.value ? 'top-center' : 'bottom-right', }) }, diff --git a/apps/stage-web/src/workers/vad/vad.ts b/apps/stage-web/src/workers/vad/vad.ts index 1cf21fd3c..1ed26577a 100644 --- a/apps/stage-web/src/workers/vad/vad.ts +++ b/apps/stage-web/src/workers/vad/vad.ts @@ -7,30 +7,30 @@ import { AutoModel, Tensor } from '@huggingface/transformers' * Voice Activity Detection processor */ export class VAD implements BaseVAD { - private config: BaseVADConfig - private model: PreTrainedModel | undefined - private state: Tensor - private sampleRateTensor: Tensor private buffer: Float32Array private bufferPointer: number = 0 + private config: BaseVADConfig + private eventListeners: Partial[]>> = {} + private inferenceChain: Promise = Promise.resolve() + private isReady: boolean = false private isRecording: boolean = false + private model: PreTrainedModel | undefined private postSpeechSamples: number = 0 private prevBuffers: Float32Array[] = [] - private inferenceChain: Promise = Promise.resolve() - private eventListeners: Partial[]>> = {} - private isReady: boolean = false + private sampleRateTensor: Tensor + private state: Tensor constructor(userConfig: Partial = {}) { // Default configuration const defaultConfig: BaseVADConfig = { - sampleRate: 16000, - speechThreshold: 0.3, exitThreshold: 0.1, - minSilenceDurationMs: 400, - speechPadMs: 80, - minSpeechDurationMs: 250, maxBufferDuration: 30, + minSilenceDurationMs: 400, + minSpeechDurationMs: 250, newBufferSize: 512, + sampleRate: 16000, + speechPadMs: 80, + speechThreshold: 0.3, } this.config = { ...defaultConfig, ...userConfig } @@ -45,7 +45,7 @@ export class VAD implements BaseVAD { */ public async initialize(): Promise { try { - this.emit('status', { type: 'info', message: 'Loading VAD model...' }) + this.emit('status', { message: 'Loading VAD model...', type: 'info' }) this.model = await AutoModel.from_pretrained('onnx-community/silero-vad', { config: { model_type: 'custom' } as any, @@ -53,24 +53,14 @@ export class VAD implements BaseVAD { }) this.isReady = true - this.emit('status', { type: 'info', message: 'VAD model loaded successfully' }) + this.emit('status', { message: 'VAD model loaded successfully', type: 'info' }) } catch (error) { - this.emit('status', { type: 'error', message: `Failed to load VAD model: ${error}` }) + this.emit('status', { message: `Failed to load VAD model: ${error}`, type: 'error' }) throw error } } - /** - * Add event listener - */ - public on(event: K, callback: VADEventCallback): void { - if (!this.eventListeners[event]) { - this.eventListeners[event] = [] - } - this.eventListeners[event]!.push(callback as any) - } - /** * Remove event listener */ @@ -81,14 +71,13 @@ export class VAD implements BaseVAD { } /** - * Emit event + * Add event listener */ - private emit(event: K, data: VADEvents[K]): void { - if (!this.eventListeners[event]) - return - for (const callback of this.eventListeners[event]!) { - callback(data) + public on(event: K, callback: VADEventCallback): void { + if (!this.eventListeners[event]) { + this.eventListeners[event] = [] } + this.eventListeners[event]!.push(callback as any) } /** @@ -144,7 +133,7 @@ export class VAD implements BaseVAD { if (!this.isRecording) { // Speech just started this.emit('speech-start', undefined) - this.emit('status', { type: 'info', message: 'Speech detected' }) + this.emit('status', { message: 'Speech detected', type: 'info' }) } // Update state @@ -170,13 +159,31 @@ export class VAD implements BaseVAD { } } + /** + * Update configuration + */ + public updateConfig(newConfig: Partial): void { + this.config = { ...this.config, ...newConfig } + + // If buffer size changed, create a new buffer + if (newConfig.maxBufferDuration || newConfig.sampleRate) { + this.buffer = new Float32Array(this.config.maxBufferDuration * this.config.sampleRate) + this.bufferPointer = 0 + } + + // Update sample rate tensor if needed + if (newConfig.sampleRate) { + this.sampleRateTensor = new Tensor('int64', [this.config.sampleRate], []) + } + } + /** * Detect speech in an audio buffer */ private async detectSpeech(buffer: Float32Array): Promise { const input = new Tensor('float32', buffer, [1, buffer.length]) - const { stateN, output } = await (this.inferenceChain = this.inferenceChain.then(() => + const { output, stateN } = await (this.inferenceChain = this.inferenceChain.then(() => this.model?.({ input, sr: this.sampleRateTensor, @@ -189,7 +196,7 @@ export class VAD implements BaseVAD { // Get the speech probability const speechProb = output.data[0] - this.emit('debug', { message: 'VAD score', data: { probability: speechProb } }) + this.emit('debug', { data: { probability: speechProb }, message: 'VAD score' }) // Apply thresholds return ( @@ -198,6 +205,17 @@ export class VAD implements BaseVAD { ) } + /** + * Emit event + */ + private emit(event: K, data: VADEvents[K]): void { + if (!this.eventListeners[event]) + return + for (const callback of this.eventListeners[event]!) { + callback(data) + } + } + /** * Process a complete speech segment */ @@ -247,24 +265,6 @@ export class VAD implements BaseVAD { this.postSpeechSamples = 0 this.prevBuffers = [] } - - /** - * Update configuration - */ - public updateConfig(newConfig: Partial): void { - this.config = { ...this.config, ...newConfig } - - // If buffer size changed, create a new buffer - if (newConfig.maxBufferDuration || newConfig.sampleRate) { - this.buffer = new Float32Array(this.config.maxBufferDuration * this.config.sampleRate) - this.bufferPointer = 0 - } - - // Update sample rate tensor if needed - if (newConfig.sampleRate) { - this.sampleRateTensor = new Tensor('int64', [this.config.sampleRate], []) - } - } } /** diff --git a/apps/stage-web/uno.config.ts b/apps/stage-web/uno.config.ts index 693fbd0ef..945ec5255 100644 --- a/apps/stage-web/uno.config.ts +++ b/apps/stage-web/uno.config.ts @@ -11,15 +11,15 @@ export default mergeConfigs([ ...presetWebFontsFonts('fontsource'), }, timeouts: { - warning: 5000, failure: 10000, + warning: 5000, }, }), ], rules: [ ['transition-colors-none', { - 'transition-property': 'color, background-color, border-color, text-color', 'transition-duration': '0s', + 'transition-property': 'color, background-color, border-color, text-color', }], ], }, diff --git a/apps/stage-web/vite.config.ts b/apps/stage-web/vite.config.ts index c9070c4cd..52eba4ab0 100644 --- a/apps/stage-web/vite.config.ts +++ b/apps/stage-web/vite.config.ts @@ -38,6 +38,26 @@ function hasFlagEnableMkcert(): boolean { } export default defineConfig({ + build: { + manifest: true, + rolldownOptions: { + output: { + chunkFileNames: (chunkInfo) => { + const containsAnalyticsModule = chunkInfo.moduleIds.some((moduleId) => { + const normalizedModuleId = moduleId.replaceAll('\\', '/').toLowerCase() + return normalizedModuleId.includes('analytics') || normalizedModuleId.includes('posthog') + }) + + // Only analytics/provider chunks receive the manual neutral mapping; + // all unrelated chunks retain Vite's readable default naming. + return containsAnalyticsModule + ? 'assets/auxiliary-[hash].js' + : 'assets/[name]-[hash].js' + }, + }, + }, + sourcemap: true, + }, optimizeDeps: { exclude: [ // Internal Packages @@ -65,60 +85,6 @@ export default defineConfig({ '@framework/model/cubismmoc', ], }, - resolve: { - alias: { - '@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')), - '@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')), - '@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')), - '@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')), - '@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')), - '@proj-airi/stage-layouts': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src')), - }, - }, - server: { - fs: { - // To mute errors like: - // The request id ".../node_modules/@fontsource/sniglet/files/sniglet-latin-400-normal.woff" is outside of Vite serving allow list. - // - // See: https://vite.dev/config/server-options#server-fs-strict - strict: false, - }, - warmup: { - clientFiles: [ - `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`, - `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src'))}/*.vue`, - ], - }, - }, - build: { - manifest: true, - rolldownOptions: { - output: { - chunkFileNames: (chunkInfo) => { - const containsAnalyticsModule = chunkInfo.moduleIds.some((moduleId) => { - const normalizedModuleId = moduleId.replaceAll('\\', '/').toLowerCase() - return normalizedModuleId.includes('analytics') || normalizedModuleId.includes('posthog') - }) - - // Only analytics/provider chunks receive the manual neutral mapping; - // all unrelated chunks retain Vite's readable default naming. - return containsAnalyticsModule - ? 'assets/auxiliary-[hash].js' - : 'assets/[name]-[hash].js' - }, - }, - }, - sourcemap: true, - }, - worker: { - format: 'es', - rollupOptions: { - output: { - inlineDynamicImports: false, - }, - }, - }, - plugins: [ ...( hasFlagEnableMkcert() @@ -137,6 +103,7 @@ export default defineConfig({ Yaml(), VueMacros({ + betterDefine: false, plugins: { vue: Vue({ include: [/\.vue$/, /\.md$/], @@ -144,18 +111,17 @@ export default defineConfig({ }), vueJsx: false, }, - betterDefine: false, }), VueRouter({ - extensions: ['.vue', '.md'], dts: resolve(import.meta.dirname, 'src/typed-router.d.ts'), + exclude: ['**/components/**'], + extensions: ['.vue', '.md'], importMode: 'async', routesFolder: [ resolve(import.meta.dirname, 'src', 'pages'), resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'), ], - exclude: ['**/components/**'], }), // https://github.com/JohnCampionJr/vite-plugin-vue-layouts @@ -186,20 +152,17 @@ export default defineConfig({ ...(env.TARGET_HUGGINGFACE_SPACE ? [] : [VitePWA({ - registerType: 'prompt', includeAssets: ['favicon.svg', 'apple-touch-icon.png'], manifest: { - name: 'AIRI', - short_name: 'AIRI', icons: [ { - src: '/web-app-manifest-192x192.png', sizes: '192x192', + src: '/web-app-manifest-192x192.png', type: 'image/png', }, { - src: '/web-app-manifest-512x512.png', sizes: '512x512', + src: '/web-app-manifest-512x512.png', type: 'image/png', }, { @@ -215,7 +178,10 @@ export default defineConfig({ type: 'image/png', }, ], + name: 'AIRI', + short_name: 'AIRI', }, + registerType: 'prompt', workbox: { maximumFileSizeToCacheInBytes: 64 * 1024 * 1024, navigateFallbackDenylist: [ @@ -229,22 +195,22 @@ export default defineConfig({ // https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n VueI18n({ - runtimeOnly: true, compositionOnly: true, fullInstall: true, + runtimeOnly: true, }), // https://github.com/webfansplz/vite-plugin-vue-devtools 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 }), + Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }), + Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }), + Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }), + Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }), // HuggingFace Spaces - LFS({ root: cwd(), extraGlobs: [ + LFS({ extraGlobs: [ // Scene & Models '*.vrm', '*.vrma', @@ -261,21 +227,21 @@ export default defineConfig({ '*.avif', // Tensorflow / MediaPipe task '*.task', - ] }), + ], root: cwd() }), SpaceCard({ - root: cwd(), - title: 'AIRI: Virtual Companion', - emoji: '🧸', colorFrom: 'pink', colorTo: 'pink', - sdk: 'static', - pinned: false, + emoji: '🧸', license: 'mit', models: [ 'onnx-community/whisper-base', 'onnx-community/silero-vad', ], + pinned: false, + root: cwd(), + sdk: 'static', short_description: 'AI driven VTuber & Companion, supports Live2D and VRM.', + title: 'AIRI: Virtual Companion', }), // For the following example assets: @@ -293,9 +259,6 @@ export default defineConfig({ ? [] : [ Basemove({ - prefix: env.STAGE_WEB_WARP_DRIVE_PREFIX || 'proj-airi/stage-web/main/', - include: [/\.wasm$/i, /\.ttf$/i, /\.vrm$/i, /\.zip$/i], // in existing assets, wasm, ttf, vrm files are the largest ones - manifest: true, clean: false, contentTypeBy: (filename: string) => { if (filename.endsWith('.wasm')) { @@ -311,14 +274,51 @@ export default defineConfig({ return 'application/zip' } }, + include: [/\.wasm$/i, /\.ttf$/i, /\.vrm$/i, /\.zip$/i], // in existing assets, wasm, ttf, vrm files are the largest ones + manifest: true, + prefix: env.STAGE_WEB_WARP_DRIVE_PREFIX || 'proj-airi/stage-web/main/', provider: createS3Provider({ - endpoint: env.S3_ENDPOINT, accessKeyId: env.S3_ACCESS_KEY_ID, - secretAccessKey: env.S3_SECRET_ACCESS_KEY, - region: env.S3_REGION, + endpoint: env.S3_ENDPOINT, publicBaseUrl: env.WARP_DRIVE_PUBLIC_BASE ?? env.S3_ENDPOINT, + region: env.S3_REGION, + secretAccessKey: env.S3_SECRET_ACCESS_KEY, }), }), ]), ], + resolve: { + alias: { + '@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')), + '@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')), + '@proj-airi/stage-layouts': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src')), + '@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')), + '@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')), + '@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')), + }, + }, + server: { + fs: { + // To mute errors like: + // The request id ".../node_modules/@fontsource/sniglet/files/sniglet-latin-400-normal.woff" is outside of Vite serving allow list. + // + // See: https://vite.dev/config/server-options#server-fs-strict + strict: false, + }, + warmup: { + clientFiles: [ + `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`, + `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src'))}/*.vue`, + ], + }, + }, + + worker: { + format: 'es', + rollupOptions: { + output: { + inlineDynamicImports: false, + }, + }, + }, }) diff --git a/apps/stage-web/vitest.config.ts b/apps/stage-web/vitest.config.ts index d8767ea43..ec12b3812 100644 --- a/apps/stage-web/vitest.config.ts +++ b/apps/stage-web/vitest.config.ts @@ -12,25 +12,25 @@ export default defineConfig({ { extends: true, test: { - name: 'unit', environment: 'jsdom', - include: ['src/**/*.test.ts'], exclude: ['src/**/*.browser.test.ts'], + include: ['src/**/*.test.ts'], + name: 'unit', }, }, mergeConfig(stageWebConfig, defineConfig({ test: { - name: 'browser', - include: ['src/**/*.browser.test.ts'], - setupFiles: ['./src/test/setup-live2d.browser.ts'], browser: { enabled: true, headless: true, - provider: playwright(), instances: [ { browser: 'chromium' }, ], + provider: playwright(), }, + include: ['src/**/*.browser.test.ts'], + name: 'browser', + setupFiles: ['./src/test/setup-live2d.browser.ts'], }, })), ], diff --git a/apps/ui-server-auth/src/composables/electron-callback.shared.ts b/apps/ui-server-auth/src/composables/electron-callback.shared.ts index 30c9848fa..5959f4790 100644 --- a/apps/ui-server-auth/src/composables/electron-callback.shared.ts +++ b/apps/ui-server-auth/src/composables/electron-callback.shared.ts @@ -1,14 +1,14 @@ export type ElectronCallbackParseResult = | { - status: 'ready' code: string port: string - state: string relayUrl: string + state: string + status: 'ready' } | { - status: 'error' message: string + status: 'error' } export function buildElectronLoopbackUrl(params: { diff --git a/apps/ui-server-auth/src/main.ts b/apps/ui-server-auth/src/main.ts index 0faedfd8c..77acf1986 100644 --- a/apps/ui-server-auth/src/main.ts +++ b/apps/ui-server-auth/src/main.ts @@ -38,9 +38,9 @@ const routeRecords = setupLayouts(routes as RouteRecordRaw[]) let router: Router if (isEnvTruthy(import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE)) - router = createRouter({ routes: routeRecords, history: createWebHashHistory(AUTH_UI_ROUTER_BASE_PATH) }) + router = createRouter({ history: createWebHashHistory(AUTH_UI_ROUTER_BASE_PATH), routes: routeRecords }) else - router = createRouter({ routes: routeRecords, history: createWebHistory(AUTH_UI_ROUTER_BASE_PATH) }) + router = createRouter({ history: createWebHistory(AUTH_UI_ROUTER_BASE_PATH), routes: routeRecords }) router.beforeEach((to, from) => { if (to.path !== from.path) diff --git a/apps/ui-server-auth/src/modules/analytics.test.ts b/apps/ui-server-auth/src/modules/analytics.test.ts index 8b958894f..5766aee6e 100644 --- a/apps/ui-server-auth/src/modules/analytics.test.ts +++ b/apps/ui-server-auth/src/modules/analytics.test.ts @@ -30,11 +30,11 @@ describe('auth analytics', () => { it('keeps anonymous signup UI completion separate from the canonical server signup fact', async () => { await expect(loadAnalyticsAdapter(async () => adapterMocks)).resolves.toBe(true) - trackSignupFormCompleted({ source: 'email', requires_verification: true }) + trackSignupFormCompleted({ requires_verification: true, source: 'email' }) expect(adapterMocks.capture).toHaveBeenCalledWith( 'signup_form_completed', - { source: 'email', requires_verification: true }, + { requires_verification: true, source: 'email' }, { beforeNavigation: false }, ) }) diff --git a/apps/ui-server-auth/src/modules/analytics.ts b/apps/ui-server-auth/src/modules/analytics.ts index 6d26a7830..40e759731 100644 --- a/apps/ui-server-auth/src/modules/analytics.ts +++ b/apps/ui-server-auth/src/modules/analytics.ts @@ -16,6 +16,12 @@ import type { OauthCallbackFailureStage } from '@proj-airi/stage-ui/composables' +/** Adapter contract installed by an optional analytics provider chunk. */ +export interface AnalyticsAdapter { + capture: (event: string, properties: Record, options?: CaptureOptions) => void + identify: (userId: string) => void +} + /** Login/signup credential kinds shown on the sign-in page. */ export type AuthMethod = 'email' | 'github' | 'google' | 'steam' @@ -27,18 +33,12 @@ interface CaptureOptions { beforeNavigation?: boolean } -/** Adapter contract installed by an optional analytics provider chunk. */ -export interface AnalyticsAdapter { - capture: (event: string, properties: Record, options?: CaptureOptions) => void - identify: (userId: string) => void -} +type LoadState = 'idle' | 'loading' | 'ready' | 'unavailable' type PendingOperation - = | { kind: 'capture', event: string, properties: Record, options?: CaptureOptions } + = | { event: string, kind: 'capture', options?: CaptureOptions, properties: Record } | { kind: 'identify', userId: string } -type LoadState = 'idle' | 'loading' | 'ready' | 'unavailable' - /** * Owns optional-adapter loading and guarantees that product-event calls never * make core auth UI wait for, or depend on, a provider SDK. @@ -49,6 +49,26 @@ export class AnalyticsClient { private loadState: LoadState = 'idle' private readonly pendingOperations: PendingOperation[] = [] + capture(event: string, properties: Record, options?: CaptureOptions): void { + if (this.adapter) { + this.adapter.capture(event, properties, options) + return + } + + if (this.loadState === 'loading') + this.enqueue({ event, kind: 'capture', options, properties }) + } + + identify(userId: string): void { + if (this.adapter) { + this.adapter.identify(userId) + return + } + + if (this.loadState === 'loading') + this.enqueue({ kind: 'identify', userId }) + } + load(loader: () => Promise): Promise { if (this.loadPromise) return this.loadPromise @@ -73,26 +93,6 @@ export class AnalyticsClient { return this.loadPromise } - capture(event: string, properties: Record, options?: CaptureOptions): void { - if (this.adapter) { - this.adapter.capture(event, properties, options) - return - } - - if (this.loadState === 'loading') - this.enqueue({ kind: 'capture', event, properties, options }) - } - - identify(userId: string): void { - if (this.adapter) { - this.adapter.identify(userId) - return - } - - if (this.loadState === 'loading') - this.enqueue({ kind: 'identify', userId }) - } - private enqueue(operation: PendingOperation): void { // A provider may remain slow indefinitely. Bound memory while preserving // the newest auth funnel steps, which are the most useful after recovery. @@ -117,14 +117,6 @@ export class AnalyticsClient { const analytics = new AnalyticsClient() -/** - * Starts loading the optional provider adapter without exposing its SDK to - * pages or to the application's static module graph. - */ -export function loadAnalyticsAdapter(loader: () => Promise): Promise { - return analytics.load(loader) -} - /** * Merge this browser's anonymous events with the Better Auth user person. * `userId` must be the Better Auth `user.id` — the same value the server @@ -134,13 +126,39 @@ export function identifyAuthUser(userId: string): void { analytics.identify(userId) } -function capture(event: string, properties: Record, options?: CaptureOptions): void { - analytics.capture(event, properties, options) +/** + * Starts loading the optional provider adapter without exposing its SDK to + * pages or to the application's static module graph. + */ +export function loadAnalyticsAdapter(loader: () => Promise): Promise { + return analytics.load(loader) } -/** Anonymous email-signup UI milestone; the server owns the registration fact. */ -export function trackSignupFormCompleted(properties: { source: AuthMethod, requires_verification: boolean }): void { - capture('signup_form_completed', properties, { beforeNavigation: !properties.requires_verification }) +/** + * Deletion-confirmed landing page reached (`delete-account.vue`). The + * deletion request itself is raised from the stage apps' account settings. + */ +export function trackAccountDeletionCompleted(): void { + capture('account_deletion_completed', {}) +} + +/** Verification link landing with `?verified=true`. */ +export function trackEmailVerificationCompleted(): void { + capture('email_verification_completed', {}) +} + +/** Verification link landing with `?error=...`. */ +export function trackEmailVerificationFailed(): void { + capture('email_verification_failed', {}) +} + +/** + * Sign-in attempt failed. No error detail on purpose — auth error messages + * can embed the email address, and the count per method is what the funnel + * needs. + */ +export function trackLoginFailed(properties: { method: AuthMethod }): void { + capture('login_failed', properties) } /** @@ -158,34 +176,13 @@ export function trackLoginSucceeded(properties: { method: AuthMethod }): void { } /** - * Sign-in attempt failed. No error detail on purpose — auth error messages - * can embed the email address, and the count per method is what the funnel - * needs. + * Electron OIDC relay handoff failed. `stage` distinguishes a malformed + * callback (`parse`) from an unreachable local app (`relay_unreachable`); + * the full cross-surface vocabulary lives in stage-ui's + * `OauthCallbackFailureStage` so the two emitters share one schema. */ -export function trackLoginFailed(properties: { method: AuthMethod }): void { - capture('login_failed', properties) -} - -/** Verification link landing with `?verified=true`. */ -export function trackEmailVerificationCompleted(): void { - capture('email_verification_completed', {}) -} - -/** Verification link landing with `?error=...`. */ -export function trackEmailVerificationFailed(): void { - capture('email_verification_failed', {}) -} - -export function trackPasswordResetRequested(): void { - capture('password_reset_requested', {}) -} - -export function trackPasswordResetCompleted(): void { - capture('password_reset_completed', {}) -} - -export function trackPasswordChanged(): void { - capture('password_changed', {}) +export function trackOauthCallbackFailed(properties: { stage: Extract }): void { + capture('oauth_callback_failed', properties) } /** @@ -201,24 +198,27 @@ export function trackOauthProviderUnlinked(properties: { provider: string }): vo capture('oauth_provider_unlinked', properties) } -/** - * Deletion-confirmed landing page reached (`delete-account.vue`). The - * deletion request itself is raised from the stage apps' account settings. - */ -export function trackAccountDeletionCompleted(): void { - capture('account_deletion_completed', {}) +export function trackPasswordChanged(): void { + capture('password_changed', {}) +} + +export function trackPasswordResetCompleted(): void { + capture('password_reset_completed', {}) +} + +export function trackPasswordResetRequested(): void { + capture('password_reset_requested', {}) } export function trackSignedOut(): void { capture('signed_out', {}) } -/** - * Electron OIDC relay handoff failed. `stage` distinguishes a malformed - * callback (`parse`) from an unreachable local app (`relay_unreachable`); - * the full cross-surface vocabulary lives in stage-ui's - * `OauthCallbackFailureStage` so the two emitters share one schema. - */ -export function trackOauthCallbackFailed(properties: { stage: Extract }): void { - capture('oauth_callback_failed', properties) +/** Anonymous email-signup UI milestone; the server owns the registration fact. */ +export function trackSignupFormCompleted(properties: { requires_verification: boolean, source: AuthMethod }): void { + capture('signup_form_completed', properties, { beforeNavigation: !properties.requires_verification }) +} + +function capture(event: string, properties: Record, options?: CaptureOptions): void { + analytics.capture(event, properties, options) } diff --git a/apps/ui-server-auth/src/modules/auth-client.ts b/apps/ui-server-auth/src/modules/auth-client.ts index 943a471b2..10ba93b98 100644 --- a/apps/ui-server-auth/src/modules/auth-client.ts +++ b/apps/ui-server-auth/src/modules/auth-client.ts @@ -51,11 +51,11 @@ export function getAuthClient(args: AuthClientArgs): AuthClient { if (args.fetchImpl || args.requestSignal) { return createAuthClient({ baseURL: args.apiServerUrl, - plugins: [steamClient()], fetchOptions: { ...(args.fetchImpl ? { customFetchImpl: args.fetchImpl } : {}), ...(args.requestSignal ? { signal: args.requestSignal } : {}), }, + plugins: [steamClient()], }) } diff --git a/apps/ui-server-auth/src/modules/auth-fetch.ts b/apps/ui-server-auth/src/modules/auth-fetch.ts index 5567ed628..311a924ee 100644 --- a/apps/ui-server-auth/src/modules/auth-fetch.ts +++ b/apps/ui-server-auth/src/modules/auth-fetch.ts @@ -28,49 +28,41 @@ export interface AuthFetchBase { } /** - * POST a JSON body to `/api/auth` and parse the response with `parse`. + * Pull a human-readable error string out of a Better Auth JSON error response. * - * Use when: - * - You need a typed wrapper around a Better Auth POST endpoint that - * responds with JSON on both success and failure (the common case). + * Before: + * - `{ "message": "Invalid credentials", "code": "INVALID_CREDENTIALS" }` + * - `{ "error": { "message": "Token expired" } }` + * - `{ "error": "Rate limit" }` * - * Expects: - * - `path` includes the leading slash (e.g. `/sign-in/email`). - * - `parse` runs only on 2xx responses; on non-2xx the wrapper throws. + * After: + * - `"Invalid credentials"` / `"Token expired"` / `"Rate limit"` * - * Returns: - * - Whatever `parse` returns. Never returns on non-2xx — throws an `Error` - * carrying the server's `message` / `error.message` field. + * Returns `null` when the payload has no message-like field, leaving the + * caller to fall back to a status-code-only message. */ -export async function postAuthJSON( - base: AuthFetchBase, - path: string, - body: Record, - parse: (data: unknown, response: Response) => T, -): Promise { - const fetchImpl = base.fetchImpl ?? fetch - const endpoint = new URL(`/api/auth${path}`, base.apiServerUrl) +export function extractAuthError(data: unknown): null | string { + if (!data || typeof data !== 'object') + return null - const response = await fetchImpl(endpoint.toString(), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - credentials: 'include', - }) + const maybe = data as { error?: unknown, message?: unknown } + if (typeof maybe.message === 'string') + return maybe.message - let data: unknown - try { - data = await response.json() - } - catch { - data = null + const error = maybe.error + if (typeof error === 'string') + return error + + if ( + error + && typeof error === 'object' + && 'message' in error + && typeof (error as { message: unknown }).message === 'string' + ) { + return (error as { message: string }).message } - if (!response.ok) { - throw new Error(extractAuthError(data) ?? `Auth request failed (${response.status})`) - } - - return parse(data, response) + return null } /** @@ -98,8 +90,8 @@ export async function getAuthJSON( const endpoint = new URL(`/api/auth${path}`, base.apiServerUrl) const response = await fetchImpl(endpoint.toString(), { - method: 'GET', credentials: 'include', + method: 'GET', }) let data: unknown @@ -118,39 +110,47 @@ export async function getAuthJSON( } /** - * Pull a human-readable error string out of a Better Auth JSON error response. + * POST a JSON body to `/api/auth` and parse the response with `parse`. * - * Before: - * - `{ "message": "Invalid credentials", "code": "INVALID_CREDENTIALS" }` - * - `{ "error": { "message": "Token expired" } }` - * - `{ "error": "Rate limit" }` + * Use when: + * - You need a typed wrapper around a Better Auth POST endpoint that + * responds with JSON on both success and failure (the common case). * - * After: - * - `"Invalid credentials"` / `"Token expired"` / `"Rate limit"` + * Expects: + * - `path` includes the leading slash (e.g. `/sign-in/email`). + * - `parse` runs only on 2xx responses; on non-2xx the wrapper throws. * - * Returns `null` when the payload has no message-like field, leaving the - * caller to fall back to a status-code-only message. + * Returns: + * - Whatever `parse` returns. Never returns on non-2xx — throws an `Error` + * carrying the server's `message` / `error.message` field. */ -export function extractAuthError(data: unknown): string | null { - if (!data || typeof data !== 'object') - return null +export async function postAuthJSON( + base: AuthFetchBase, + path: string, + body: Record, + parse: (data: unknown, response: Response) => T, +): Promise { + const fetchImpl = base.fetchImpl ?? fetch + const endpoint = new URL(`/api/auth${path}`, base.apiServerUrl) - const maybe = data as { error?: unknown, message?: unknown } - if (typeof maybe.message === 'string') - return maybe.message + const response = await fetchImpl(endpoint.toString(), { + body: JSON.stringify(body), + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) - const error = maybe.error - if (typeof error === 'string') - return error - - if ( - error - && typeof error === 'object' - && 'message' in error - && typeof (error as { message: unknown }).message === 'string' - ) { - return (error as { message: string }).message + let data: unknown + try { + data = await response.json() + } + catch { + data = null } - return null + if (!response.ok) { + throw new Error(extractAuthError(data) ?? `Auth request failed (${response.status})`) + } + + return parse(data, response) } diff --git a/apps/ui-server-auth/src/modules/email-password.ts b/apps/ui-server-auth/src/modules/email-password.ts index 538d0e6a0..5c27498ac 100644 --- a/apps/ui-server-auth/src/modules/email-password.ts +++ b/apps/ui-server-auth/src/modules/email-password.ts @@ -15,10 +15,6 @@ import { errorMessageFrom } from '@moeru/std' import { postAuthJSON } from './auth-fetch' -interface CheckEmailArgs extends AuthFetchBase { - email: string -} - /** * Result of the email-first identifier probe. * @@ -33,19 +29,23 @@ export interface CheckEmailResult { hasPassword: boolean } +interface CheckEmailArgs extends AuthFetchBase { + email: string +} + interface EmailSignInArgs extends AuthFetchBase { + callbackURL?: string email: string password: string - callbackURL?: string /** @default true */ rememberMe?: boolean } interface EmailSignUpArgs extends AuthFetchBase { - email: string - password: string - name: string callbackURL?: string + email: string + name: string + password: string } interface RequestPasswordResetArgs extends AuthFetchBase { @@ -64,7 +64,7 @@ interface ResetPasswordArgs extends AuthFetchBase { interface SignInResult { /** Set when better-auth allows browser to follow the OIDC redirect itself. */ - redirectURL: string | null + redirectURL: null | string /** * True if email verification is still pending; UI should route to * the `verify-email` notice page. @@ -106,51 +106,8 @@ export async function checkEmail(args: CheckEmailArgs): Promise { - return postAuthJSON( - args, - '/sign-in/email', - { - email: args.email, - password: args.password, - callbackURL: args.callbackURL, - rememberMe: args.rememberMe ?? true, - }, - (data) => { - const url = typeof (data as { url?: unknown })?.url === 'string' - ? (data as { url: string }).url - : null - // NOTICE: - // better-auth surfaces `requiresEmailVerification` (rather than throwing) - // when emailAndPassword.requireEmailVerification is true and the user is - // not yet verified. Frontend uses this to route into the `verify-email` - // notice page instead of bouncing to the OIDC callback. - // Source: node_modules/better-auth/dist/api/routes/sign-in.mjs L235+ - const requiresVerification = Boolean( - (data as { requiresEmailVerification?: unknown })?.requiresEmailVerification, - ) - return { redirectURL: url, requiresVerification } - }, - ) -} - -export async function signUpWithEmail(args: EmailSignUpArgs): Promise { - return postAuthJSON( - args, - '/sign-up/email', - { - email: args.email, - password: args.password, - name: args.name, - callbackURL: args.callbackURL, - }, - (data) => { - // When verification is required, better-auth returns `{ token: null, user: ... }` - // and queues the verification email; otherwise it returns a session token. - const token = (data as { token?: unknown })?.token - return { requiresVerification: token === null || token === undefined } - }, - ) +export function describeAuthError(error: unknown): string { + return errorMessageFrom(error) ?? 'Unexpected error' } export async function requestPasswordReset(args: RequestPasswordResetArgs): Promise { @@ -177,6 +134,49 @@ export async function resetPasswordWithToken(args: ResetPasswordArgs): Promise { + return postAuthJSON( + args, + '/sign-in/email', + { + callbackURL: args.callbackURL, + email: args.email, + password: args.password, + rememberMe: args.rememberMe ?? true, + }, + (data) => { + const url = typeof (data as { url?: unknown })?.url === 'string' + ? (data as { url: string }).url + : null + // NOTICE: + // better-auth surfaces `requiresEmailVerification` (rather than throwing) + // when emailAndPassword.requireEmailVerification is true and the user is + // not yet verified. Frontend uses this to route into the `verify-email` + // notice page instead of bouncing to the OIDC callback. + // Source: node_modules/better-auth/dist/api/routes/sign-in.mjs L235+ + const requiresVerification = Boolean( + (data as { requiresEmailVerification?: unknown })?.requiresEmailVerification, + ) + return { redirectURL: url, requiresVerification } + }, + ) +} + +export async function signUpWithEmail(args: EmailSignUpArgs): Promise { + return postAuthJSON( + args, + '/sign-up/email', + { + callbackURL: args.callbackURL, + email: args.email, + name: args.name, + password: args.password, + }, + (data) => { + // When verification is required, better-auth returns `{ token: null, user: ... }` + // and queues the verification email; otherwise it returns a session token. + const token = (data as { token?: unknown })?.token + return { requiresVerification: token === null || token === undefined } + }, + ) } diff --git a/apps/ui-server-auth/src/modules/i18n.ts b/apps/ui-server-auth/src/modules/i18n.ts index 438770ebe..4c451a6e6 100644 --- a/apps/ui-server-auth/src/modules/i18n.ts +++ b/apps/ui-server-auth/src/modules/i18n.ts @@ -15,8 +15,8 @@ function getLocale() { } export const i18n = createI18n({ + fallbackLocale: 'en', legacy: false, locale: getLocale(), - fallbackLocale: 'en', messages, }) diff --git a/apps/ui-server-auth/src/modules/profile.test.ts b/apps/ui-server-auth/src/modules/profile.test.ts index 9c082a85b..649f4408b 100644 --- a/apps/ui-server-auth/src/modules/profile.test.ts +++ b/apps/ui-server-auth/src/modules/profile.test.ts @@ -4,8 +4,8 @@ import { changePassword, getCurrentSession, signOut, updateUserProfile } from '. function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { - status, headers: { 'Content-Type': 'application/json' }, + status, }) } @@ -14,12 +14,12 @@ describe('ui-server-auth profile flow helpers', () => { const fetchImpl = vi.fn(async () => jsonResponse({ session: { id: 'sess-1' }, user: { - id: 'user-1', - name: 'Alice', + createdAt: '2025-04-01T00:00:00.000Z', email: 'alice@example.test', emailVerified: true, + id: 'user-1', image: 'https://cdn.example.test/avatar.png', - createdAt: '2025-04-01T00:00:00.000Z', + name: 'Alice', // Field intentionally not in ProfileUser — must be ignored. twoFactorEnabled: true, }, @@ -30,12 +30,12 @@ describe('ui-server-auth profile flow helpers', () => { fetchImpl, })).resolves.toEqual({ user: { - id: 'user-1', - name: 'Alice', + createdAt: '2025-04-01T00:00:00.000Z', email: 'alice@example.test', emailVerified: true, + id: 'user-1', image: 'https://cdn.example.test/avatar.png', - createdAt: '2025-04-01T00:00:00.000Z', + name: 'Alice', }, }) @@ -92,8 +92,8 @@ describe('ui-server-auth profile flow helpers', () => { await changePassword({ apiServerUrl: 'https://api.airi.test', - fetchImpl, currentPassword: 'old-pw', + fetchImpl, newPassword: 'new-pw', }) @@ -112,8 +112,8 @@ describe('ui-server-auth profile flow helpers', () => { await expect(changePassword({ apiServerUrl: 'https://api.airi.test', - fetchImpl, currentPassword: 'wrong', + fetchImpl, newPassword: 'new-pw', })).rejects.toThrow('Invalid current password') }) diff --git a/apps/ui-server-auth/src/modules/profile.ts b/apps/ui-server-auth/src/modules/profile.ts index 652f3f72e..03390d44d 100644 --- a/apps/ui-server-auth/src/modules/profile.ts +++ b/apps/ui-server-auth/src/modules/profile.ts @@ -19,6 +19,16 @@ import { errorMessageFrom } from '@moeru/std' import { getAuthClient } from './auth-client' +/** + * Result of a `/get-session` probe. + * + * `user` is `null` when no session cookie is present (or it expired). Caller + * uses that to redirect to the sign-in page instead of rendering the form. + */ +export interface CurrentSessionResult { + user: null | ProfileUser +} + /** * Trimmed view of the better-auth `user` row exposed via `/get-session`. * @@ -29,12 +39,12 @@ import { getAuthClient } from './auth-client' * worry about Date-vs-string drift across the ui-server-auth boundary. */ export interface ProfileUser { - id: string - /** Display name set on sign-up or via {@link updateUserProfile}. */ - name: string + /** ISO timestamp from `created_at`. */ + createdAt: null | string email: string /** True once the user clicked the verification link sent on sign-up. */ emailVerified: boolean + id: string /** * Avatar URL. Server decorates this so it's always populated for * signed-in users: provider-set / user-uploaded URL when present, or a @@ -42,26 +52,9 @@ export interface ProfileUser { * detects the fallback by URL prefix * (`https://www.gravatar.com/avatar/`). */ - image: string | null - /** ISO timestamp from `created_at`. */ - createdAt: string | null -} - -/** - * Result of a `/get-session` probe. - * - * `user` is `null` when no session cookie is present (or it expired). Caller - * uses that to redirect to the sign-in page instead of rendering the form. - */ -export interface CurrentSessionResult { - user: ProfileUser | null -} - -interface UpdateUserProfileArgs extends AuthFetchBase { - /** Trim before passing — server stores the value as-is. */ - name?: string - /** Optional avatar URL. Pass `null` to clear it. */ - image?: string | null + image: null | string + /** Display name set on sign-up or via {@link updateUserProfile}. */ + name: string } interface ChangePasswordArgs extends AuthFetchBase { @@ -75,58 +68,11 @@ interface ChangePasswordArgs extends AuthFetchBase { revokeOtherSessions?: boolean } -/** - * Read the current session via the typed better-auth client. - * - * Use when: - * - Bootstrapping the profile page; decides whether to render the form or - * bounce the user to the sign-in page. - * - * Returns: - * - `user: null` for unauthenticated requests (better-auth client returns - * `null` data, not an error, in that case). - * - {@link CurrentSessionResult} with the trimmed user fields otherwise. - */ -export async function getCurrentSession(args: AuthFetchBase): Promise { - const client = getAuthClient(args) - const { data, error } = await client.getSession() - if (error) - throw new Error(error.message ?? `Auth request failed (${error.status ?? 'unknown'})`) - if (!data?.user) - return { user: null } - - return { - user: { - id: data.user.id, - name: data.user.name, - email: data.user.email, - emailVerified: data.user.emailVerified, - image: data.user.image ?? null, - createdAt: toIsoString(data.user.createdAt), - }, - } -} - -/** - * Update the signed-in user's display name and/or avatar. - * - * Use when: - * - Saving the "display name" form on the profile page. - * - * Expects: - * - Caller has already trimmed `name` and confirmed it's non-empty. - * - `image` is either an absolute URL or `null` (clear). - */ -export async function updateUserProfile(args: UpdateUserProfileArgs): Promise { - const client = getAuthClient(args) - const body: { name?: string, image?: string | null } = {} - if (args.name !== undefined) - body.name = args.name - if (args.image !== undefined) - body.image = args.image - const { error } = await client.updateUser(body) - if (error) - throw new Error(error.message ?? 'updateUser failed') +interface UpdateUserProfileArgs extends AuthFetchBase { + /** Optional avatar URL. Pass `null` to clear it. */ + image?: null | string + /** Trim before passing — server stores the value as-is. */ + name?: string } /** @@ -151,6 +97,42 @@ export async function changePassword(args: ChangePasswordArgs): Promise { throw new Error(error.message ?? 'changePassword failed') } +export function describeProfileError(error: unknown): string { + return errorMessageFrom(error) ?? 'Unexpected error' +} + +/** + * Read the current session via the typed better-auth client. + * + * Use when: + * - Bootstrapping the profile page; decides whether to render the form or + * bounce the user to the sign-in page. + * + * Returns: + * - `user: null` for unauthenticated requests (better-auth client returns + * `null` data, not an error, in that case). + * - {@link CurrentSessionResult} with the trimmed user fields otherwise. + */ +export async function getCurrentSession(args: AuthFetchBase): Promise { + const client = getAuthClient(args) + const { data, error } = await client.getSession() + if (error) + throw new Error(error.message ?? `Auth request failed (${error.status ?? 'unknown'})`) + if (!data?.user) + return { user: null } + + return { + user: { + createdAt: toIsoString(data.user.createdAt), + email: data.user.email, + emailVerified: data.user.emailVerified, + id: data.user.id, + image: data.user.image ?? null, + name: data.user.name, + }, + } +} + /** * Sign the current user out via better-auth's `/sign-out` endpoint. * @@ -169,8 +151,26 @@ export async function signOut(args: AuthFetchBase): Promise { throw new Error(error.message ?? 'signOut failed') } -export function describeProfileError(error: unknown): string { - return errorMessageFrom(error) ?? 'Unexpected error' +/** + * Update the signed-in user's display name and/or avatar. + * + * Use when: + * - Saving the "display name" form on the profile page. + * + * Expects: + * - Caller has already trimmed `name` and confirmed it's non-empty. + * - `image` is either an absolute URL or `null` (clear). + */ +export async function updateUserProfile(args: UpdateUserProfileArgs): Promise { + const client = getAuthClient(args) + const body: { image?: null | string, name?: string } = {} + if (args.name !== undefined) + body.name = args.name + if (args.image !== undefined) + body.image = args.image + const { error } = await client.updateUser(body) + if (error) + throw new Error(error.message ?? 'updateUser failed') } /** @@ -183,7 +183,7 @@ export function describeProfileError(error: unknown): string { * After: * - `'2025-04-01T00:00:00.000Z'` / `'2025-04-01T00:00:00.000Z'` / `null` */ -function toIsoString(value: unknown): string | null { +function toIsoString(value: unknown): null | string { if (value instanceof Date) return value.toISOString() if (typeof value === 'string') diff --git a/apps/ui-server-auth/src/modules/server-auth-context.ts b/apps/ui-server-auth/src/modules/server-auth-context.ts index d0b9e8ca0..979695651 100644 --- a/apps/ui-server-auth/src/modules/server-auth-context.ts +++ b/apps/ui-server-auth/src/modules/server-auth-context.ts @@ -34,9 +34,9 @@ const TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS = [ /^https:\/\/127\.0\.0\.1(:\d+)?$/, ] -let cachedContext: ServerAuthBootstrapContext | null | undefined +let cachedContext: null | ServerAuthBootstrapContext | undefined -export function getServerAuthBootstrapContext(): ServerAuthBootstrapContext | null { +export function getServerAuthBootstrapContext(): null | ServerAuthBootstrapContext { if (cachedContext !== undefined) return cachedContext @@ -77,7 +77,7 @@ export function getServerAuthBootstrapContext(): ServerAuthBootstrapContext | nu * - A bootstrap context using the trusted API origin, or null when no trusted * override is present. */ -export function resolveStandaloneServerAuthContext(currentUrl: string, fallbackApiServerUrl: string): ServerAuthBootstrapContext | null { +export function resolveStandaloneServerAuthContext(currentUrl: string, fallbackApiServerUrl: string): null | ServerAuthBootstrapContext { const url = new URL(currentUrl) const apiServerUrl = normalizeTrustedApiServerUrl( url.searchParams.get(API_SERVER_URL_QUERY_PARAM), @@ -92,7 +92,7 @@ export function resolveStandaloneServerAuthContext(currentUrl: string, fallbackA } } -function normalizeTrustedApiServerUrl(value: string | null): string | null { +function normalizeTrustedApiServerUrl(value: null | string): null | string { if (!value) return null diff --git a/apps/ui-server-auth/src/modules/sign-in.test.ts b/apps/ui-server-auth/src/modules/sign-in.test.ts index 4eed5272e..1b8a541ce 100644 --- a/apps/ui-server-auth/src/modules/sign-in.test.ts +++ b/apps/ui-server-auth/src/modules/sign-in.test.ts @@ -117,9 +117,9 @@ describe('ui-server-auth sign-in flow helpers', () => { await expect(requestSocialSignInRedirect({ apiServerUrl: 'https://api.airi.test', - provider: 'google', callbackURL: 'https://api.airi.test/api/auth/oauth2/authorize?client_id=airi-stage-web', fetchImpl, + provider: 'google', })).resolves.toBe('https://accounts.example.test/oauth/google') expect(fetchImpl).toHaveBeenCalledTimes(1) @@ -127,24 +127,24 @@ describe('ui-server-auth sign-in flow helpers', () => { expect(String(url)).toBe('https://api.airi.test/api/auth/sign-in/social') expect((init as RequestInit).method).toBe('POST') expect(JSON.parse(String((init as RequestInit).body))).toEqual({ - provider: 'google', callbackURL: 'https://api.airi.test/api/auth/oauth2/authorize?client_id=airi-stage-web', disableRedirect: true, + provider: 'google', }) }) it('posts only the callback URL (no provider field) to the Steam sign-in endpoint', async () => { const fetchImpl = vi.fn(async () => { - return new Response(JSON.stringify({ url: 'https://steamcommunity.com/openid/login?...', redirect: true }), { + return new Response(JSON.stringify({ redirect: true, url: 'https://steamcommunity.com/openid/login?...' }), { headers: { 'Content-Type': 'application/json' }, }) }) await expect(requestSocialSignInRedirect({ apiServerUrl: 'https://api.airi.test', - provider: 'steam', callbackURL: 'https://api.airi.test/api/auth/oauth2/authorize?client_id=airi-stage-web', fetchImpl, + provider: 'steam', })).resolves.toBe('https://steamcommunity.com/openid/login?...') const [url, init] = fetchImpl.mock.calls[0] ?? [] @@ -168,9 +168,9 @@ describe('ui-server-auth sign-in flow helpers', () => { await expect(requestSocialSignInRedirect({ apiServerUrl: 'https://api.airi.test', - provider: 'github', callbackURL: '/', fetchImpl, + provider: 'github', })).rejects.toThrow('Provider is temporarily unavailable') }) @@ -193,9 +193,9 @@ describe('ui-server-auth sign-in flow helpers', () => { try { const request = requestSocialSignInRedirect({ apiServerUrl: 'https://api.airi.test', - provider, callbackURL: '/', fetchImpl, + provider, timeoutMs: 50, }) diff --git a/apps/ui-server-auth/src/modules/sign-in.ts b/apps/ui-server-auth/src/modules/sign-in.ts index a45233ae0..4db8773a1 100644 --- a/apps/ui-server-auth/src/modules/sign-in.ts +++ b/apps/ui-server-auth/src/modules/sign-in.ts @@ -20,14 +20,14 @@ const TRUSTED_LOCAL_ADMIN_REDIRECT_ORIGIN_PATTERNS = [ export interface ServerSignInContext { callbackURL: string - requestedProvider: string | null + requestedProvider: null | string } export interface SocialSignInRedirectParams { apiServerUrl: string - provider: OAuthProvider callbackURL: string fetchImpl?: typeof fetch + provider: OAuthProvider /** * Maximum wait for provider discovery before the UI restores sign-in controls. * @default 15_000 @@ -83,7 +83,34 @@ export function createServerSignInContext(currentUrl: string, apiServerUrl: stri } } -function normalizeStandaloneRedirect(currentUrl: URL, redirect: string | null): string | null { +export async function requestSocialSignInRedirect(params: SocialSignInRedirectParams): Promise { + const requestController = new AbortController() + const client = getAuthClient({ + apiServerUrl: params.apiServerUrl, + fetchImpl: params.fetchImpl, + requestSignal: requestController.signal, + }) + + // Steam is OpenID 2.0, not OAuth2 — the server steam plugin exposes + // `/sign-in/steam`, surfaced here as the typed `signIn.steam` action. + // Other providers use the standard `/sign-in/social`. + const request = params.provider === 'steam' + ? client.signIn.steam({ callbackURL: params.callbackURL, disableRedirect: true }) + : client.signIn.social({ callbackURL: params.callbackURL, disableRedirect: true, provider: params.provider }) + const result = await settleSocialSignInRequest( + request, + params.timeoutMs ?? SOCIAL_SIGN_IN_REQUEST_TIMEOUT_MS, + requestController, + ) + + const url = result.data?.url + if (typeof url === 'string') + return url + + throw new Error(extractAuthError(result.data ?? result.error) ?? 'Unexpected response') +} + +function normalizeStandaloneRedirect(currentUrl: URL, redirect: null | string): null | string { if (!redirect) return null @@ -100,7 +127,7 @@ function normalizeStandaloneRedirect(currentUrl: URL, redirect: string | null): return `${currentUrl.origin}${buildAuthUiPath(redirect)}` } -function normalizeTrustedAdminRedirect(redirect: string): string | null { +function normalizeTrustedAdminRedirect(redirect: string): null | string { try { const url = new URL(redirect) if (TRUSTED_ADMIN_REDIRECT_ORIGINS.includes(url.origin)) @@ -116,33 +143,6 @@ function normalizeTrustedAdminRedirect(redirect: string): string | null { } } -export async function requestSocialSignInRedirect(params: SocialSignInRedirectParams): Promise { - const requestController = new AbortController() - const client = getAuthClient({ - apiServerUrl: params.apiServerUrl, - fetchImpl: params.fetchImpl, - requestSignal: requestController.signal, - }) - - // Steam is OpenID 2.0, not OAuth2 — the server steam plugin exposes - // `/sign-in/steam`, surfaced here as the typed `signIn.steam` action. - // Other providers use the standard `/sign-in/social`. - const request = params.provider === 'steam' - ? client.signIn.steam({ callbackURL: params.callbackURL, disableRedirect: true }) - : client.signIn.social({ provider: params.provider, callbackURL: params.callbackURL, disableRedirect: true }) - const result = await settleSocialSignInRequest( - request, - params.timeoutMs ?? SOCIAL_SIGN_IN_REQUEST_TIMEOUT_MS, - requestController, - ) - - const url = result.data?.url - if (typeof url === 'string') - return url - - throw new Error(extractAuthError(result.data ?? result.error) ?? 'Unexpected response') -} - /** * Bounds and cancels provider discovery so a timed-out request cannot apply a * stale OAuth state cookie after the user starts another sign-in attempt. diff --git a/apps/ui-server-auth/uno.config.ts b/apps/ui-server-auth/uno.config.ts index 693fbd0ef..945ec5255 100644 --- a/apps/ui-server-auth/uno.config.ts +++ b/apps/ui-server-auth/uno.config.ts @@ -11,15 +11,15 @@ export default mergeConfigs([ ...presetWebFontsFonts('fontsource'), }, timeouts: { - warning: 5000, failure: 10000, + warning: 5000, }, }), ], rules: [ ['transition-colors-none', { - 'transition-property': 'color, background-color, border-color, text-color', 'transition-duration': '0s', + 'transition-property': 'color, background-color, border-color, text-color', }], ], }, diff --git a/apps/ui-server-auth/vite.config.ts b/apps/ui-server-auth/vite.config.ts index d85d159db..3b630de05 100644 --- a/apps/ui-server-auth/vite.config.ts +++ b/apps/ui-server-auth/vite.config.ts @@ -24,35 +24,6 @@ const assetsDirectory = 'assets-v2' export default defineConfig({ base: '/', - optimizeDeps: { - exclude: [ - // Internal Packages - '@proj-airi/stage-ui/*', - ], - }, - - resolve: { - alias: { - '@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')), - '@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')), - '@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')), - '@proj-airi/stage-layouts': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src')), - }, - }, - server: { - fs: { - // To mute errors like: - // The request id ".../node_modules/@fontsource/sniglet/files/sniglet-latin-400-normal.woff" is outside of Vite serving allow list. - // - // See: https://vite.dev/config/server-options#server-fs-strict - strict: false, - }, - warmup: { - clientFiles: [ - `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`, - ], - }, - }, build: { assetsDir: assetsDirectory, emptyOutDir: true, @@ -76,38 +47,36 @@ export default defineConfig({ }, sourcemap: true, }, - worker: { - format: 'es', - rollupOptions: { - output: { - inlineDynamicImports: false, - }, - }, - }, + optimizeDeps: { + exclude: [ + // Internal Packages + '@proj-airi/stage-ui/*', + ], + }, plugins: [ Info(), Yaml(), VueMacros({ + betterDefine: false, plugins: { vue: Vue({ include: [/\.vue$/, /\.md$/], }), vueJsx: false, }, - betterDefine: false, }), VueRouter({ - extensions: ['.vue', '.md'], dts: resolve(import.meta.dirname, 'src/typed-router.d.ts'), + exclude: ['**/components/**'], + extensions: ['.vue', '.md'], importMode: 'async', routesFolder: [ resolve(import.meta.dirname, 'src', 'pages'), ], - exclude: ['**/components/**'], }), // https://github.com/JohnCampionJr/vite-plugin-vue-layouts @@ -124,12 +93,43 @@ export default defineConfig({ // https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n VueI18n({ - runtimeOnly: true, compositionOnly: true, fullInstall: true, + runtimeOnly: true, }), // https://github.com/webfansplz/vite-plugin-vue-devtools VueDevTools(), ], + resolve: { + alias: { + '@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')), + '@proj-airi/stage-layouts': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src')), + '@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')), + '@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')), + }, + }, + server: { + fs: { + // To mute errors like: + // The request id ".../node_modules/@fontsource/sniglet/files/sniglet-latin-400-normal.woff" is outside of Vite serving allow list. + // + // See: https://vite.dev/config/server-options#server-fs-strict + strict: false, + }, + warmup: { + clientFiles: [ + `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`, + ], + }, + }, + + worker: { + format: 'es', + rollupOptions: { + output: { + inlineDynamicImports: false, + }, + }, + }, }) diff --git a/apps/ui-server-auth/vitest.config.ts b/apps/ui-server-auth/vitest.config.ts index 655cf89b8..85a1aab64 100644 --- a/apps/ui-server-auth/vitest.config.ts +++ b/apps/ui-server-auth/vitest.config.ts @@ -7,8 +7,8 @@ import { defineConfig } from 'vitest/config' export default defineConfig(({ mode }) => { return { test: { - include: ['src/**/*.test.ts'], env: loadEnv(mode, join(cwd(), 'apps', 'ui-server-auth'), ''), + include: ['src/**/*.test.ts'], }, } }) diff --git a/bump.config.ts b/bump.config.ts index 90183d5a5..90440c219 100644 --- a/bump.config.ts +++ b/bump.config.ts @@ -60,15 +60,15 @@ async function syncIOSVersion(version: string) { } export default defineConfig({ - recursive: true, - commit: 'release: v%s', - sign: false, - push: false, all: true, + commit: 'release: v%s', execute: async (operation) => { await x('pnpm', ['publish', '-r', '--access', 'public', '--no-git-checks', '--dry-run']) const nextVersion = operation.state.newVersion await syncAndroidVersion(nextVersion) await syncIOSVersion(nextVersion) }, + push: false, + recursive: true, + sign: false, }) diff --git a/docs/.vitepress/composables/date.ts b/docs/.vitepress/composables/date.ts index ae871c50e..b8dfb3522 100644 --- a/docs/.vitepress/composables/date.ts +++ b/docs/.vitepress/composables/date.ts @@ -1,13 +1,5 @@ import { isAfter, isBefore } from 'date-fns' -export function isBetweenHalloweenAndHalfOfNovember(date: Date) { - const year = date.getFullYear() - const halloween = new Date(year, 9, 25) // October 25 - const halfOfNovember = new Date(year, 10, 15) // November 15 - - return isAfter(date, halloween) && isBefore(date, halfOfNovember) -} - export function isBetweenChristmasAndHalfOfJanuary(date: Date) { const year = date.getFullYear() const christmas = new Date(year, 11, 20) // December 20 @@ -15,3 +7,11 @@ export function isBetweenChristmasAndHalfOfJanuary(date: Date) { return isAfter(date, christmas) || isBefore(date, halfOfJanuary) } + +export function isBetweenHalloweenAndHalfOfNovember(date: Date) { + const year = date.getFullYear() + const halloween = new Date(year, 9, 25) // October 25 + const halfOfNovember = new Date(year, 10, 15) // November 15 + + return isAfter(date, halloween) && isBefore(date, halfOfNovember) +} diff --git a/docs/.vitepress/composables/edit-link.ts b/docs/.vitepress/composables/edit-link.ts index 2c1b59f01..8b662f9a2 100644 --- a/docs/.vitepress/composables/edit-link.ts +++ b/docs/.vitepress/composables/edit-link.ts @@ -3,11 +3,11 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' export function useEditLink() { - const { theme, page } = useData() + const { page, theme } = useData() const { t } = useI18n() return computed(() => { - const { text = t('docs.theme.doc.community.edit.title'), pattern = '' } = theme.value.editLink || {} + const { pattern = '', text = t('docs.theme.doc.community.edit.title') } = theme.value.editLink || {} let url: string if (typeof pattern === 'function') { url = pattern(page.value) @@ -16,6 +16,6 @@ export function useEditLink() { url = pattern.replace(/:path/g, page.value.filePath) } - return { url, text } + return { text, url } }) } diff --git a/docs/.vitepress/composables/outline.ts b/docs/.vitepress/composables/outline.ts index c729b132b..ef2f09e54 100644 --- a/docs/.vitepress/composables/outline.ts +++ b/docs/.vitepress/composables/outline.ts @@ -7,22 +7,16 @@ import { getScrollOffset } from 'vitepress' import { onMounted, onUpdated } from 'vue' export interface Header { + /** + * The children of the header + */ + children: Header[] /** * The level of the header * * `1` to `6` for `

` to `

` */ level: number - /** - * The title of the header - */ - title: string - /** - * The slug of the header - * - * Typically the `id` attr of the header anchor - */ - slug: string /** * Link of the header * @@ -30,27 +24,23 @@ export interface Header { */ link: string /** - * The children of the header + * The slug of the header + * + * Typically the `id` attr of the header anchor */ - children: Header[] + slug: string + /** + * The title of the header + */ + title: string } // cached list of anchor elements from resolveHeaders const resolvedHeaders: { element: HTMLHeadElement, link: string }[] = [] -export type MenuItem = Omit & { - element: HTMLHeadElement +export type MenuItem = Omit & { children?: MenuItem[] -} - -export function resolveTitle(theme: DefaultTheme.Config) { - return ( - (typeof theme.outline === 'object' - && !Array.isArray(theme.outline) - && theme.outline.label) - || theme.outlineTitle - || 'On this page' - ) + element: HTMLHeadElement } export function getHeaders(range: DefaultTheme.Config['outline']) { @@ -60,35 +50,15 @@ export function getHeaders(range: DefaultTheme.Config['outline']) { const level = Number(el.tagName[1]) return { element: el as HTMLHeadElement, - title: serializeHeader(el), - link: `#${el.id}`, level, + link: `#${el.id}`, + title: serializeHeader(el), } }) return resolveHeaders(headers, range) } -function serializeHeader(h: Element): string { - let ret = '' - for (const node of Array.from(h.childNodes)) { - if (node.nodeType === 1) { - if ( - (node as Element).classList.contains('VPBadge') - || (node as Element).classList.contains('header-anchor') - || (node as Element).classList.contains('ignore-header') - ) { - continue - } - ret += node.textContent - } - else if (node.nodeType === 3) { - ret += node.textContent - } - } - return ret.trim() -} - export function resolveHeaders( headers: MenuItem[], range?: DefaultTheme.Config['outline'], @@ -140,6 +110,16 @@ export function resolveHeaders( return ret } +export function resolveTitle(theme: DefaultTheme.Config) { + return ( + (typeof theme.outline === 'object' + && !Array.isArray(theme.outline) + && theme.outline.label) + || theme.outlineTitle + || 'On this page' + ) +} + export function useActiveAnchor( container: Ref, marker: Ref, @@ -193,7 +173,7 @@ export function useActiveAnchor( } // find the last header above the top of viewport - let activeLink: string | null = null + let activeLink: null | string = null for (const { link, top } of headers) { if (top > scrollY + getScrollOffset() + 4) { break @@ -204,7 +184,7 @@ export function useActiveAnchor( activateLink(activeLink) } - function activateLink(hash: string | null) { + function activateLink(hash: null | string) { if (prevActiveLink) { prevActiveLink.classList.remove('active') } @@ -248,6 +228,26 @@ function getAbsoluteTop(element: HTMLElement): number { return offsetTop } +function serializeHeader(h: Element): string { + let ret = '' + for (const node of Array.from(h.childNodes)) { + if (node.nodeType === 1) { + if ( + (node as Element).classList.contains('VPBadge') + || (node as Element).classList.contains('header-anchor') + || (node as Element).classList.contains('ignore-header') + ) { + continue + } + ret += node.textContent + } + else if (node.nodeType === 3) { + ret += node.textContent + } + } + return ret.trim() +} + function throttleAndDebounce(fn: () => void, delay: number): () => void { let timeoutId: NodeJS.Timeout let called = false diff --git a/docs/.vitepress/composables/prev-next.ts b/docs/.vitepress/composables/prev-next.ts index 9f2b240c4..12f4e90e0 100644 --- a/docs/.vitepress/composables/prev-next.ts +++ b/docs/.vitepress/composables/prev-next.ts @@ -11,7 +11,7 @@ import { getFlatSideBarLinks, getSidebar, isActive } from './sidebar' * - Respects frontmatter overrides and hides when disabled. */ export function usePrevNext() { - const { page, theme, frontmatter, lang } = useData() + const { frontmatter, lang, page, theme } = useData() return computed(() => { // Blog-specific navigation: ensure next/prev stay within same language blog directory @@ -53,6 +53,7 @@ export function usePrevNext() { ? undefined : prevPost ? { + link: withBase(prevPost.url), text: (typeof frontmatter.value.prev === 'string' ? frontmatter.value.prev @@ -60,7 +61,6 @@ export function usePrevNext() { ? frontmatter.value.prev.text : undefined) ?? prevPost.title, - link: withBase(prevPost.url), } : undefined @@ -68,6 +68,7 @@ export function usePrevNext() { ? undefined : nextPost ? { + link: withBase(nextPost.url), text: (typeof frontmatter.value.next === 'string' ? frontmatter.value.next @@ -75,16 +76,15 @@ export function usePrevNext() { ? frontmatter.value.next.text : undefined) ?? nextPost.title, - link: withBase(nextPost.url), } : undefined return { - prev: blogPrev, next: blogNext, + prev: blogPrev, } as { - prev?: { text?: string, link?: string } - next?: { text?: string, link?: string } + next?: { link?: string, text?: string } + prev?: { link?: string, text?: string } } } @@ -110,9 +110,9 @@ export function usePrevNext() { const isSectionRoot = currentFullUrl.replace(/[?#].*$/, '') === sectionBase if (isSectionRoot) { return { - prev: undefined, next: undefined, - } as { prev?: { text?: string, link?: string }, next?: { text?: string, link?: string } } + prev: undefined, + } as { next?: { link?: string, text?: string }, prev?: { link?: string, text?: string } } } // Keep navigation within the same docs section and exclude the section root itself // to avoid showing a "next" link that points back to the section index. @@ -136,25 +136,13 @@ export function usePrevNext() { }) return { - prev: hidePrev || index <= 0 - ? undefined - : { - text: - (typeof frontmatter.value.prev === 'string' - ? frontmatter.value.prev - : typeof frontmatter.value.prev === 'object' - ? frontmatter.value.prev.text - : undefined) - ?? candidates[index - 1]?.docFooterText - ?? candidates[index - 1]?.text, - link: - (typeof frontmatter.value.prev === 'object' - ? frontmatter.value.prev.link - : undefined) ?? candidates[index - 1]?.link, - }, next: hideNext || index < 0 || index >= candidates.length - 1 ? undefined : { + link: + (typeof frontmatter.value.next === 'object' + ? frontmatter.value.next.link + : undefined) ?? candidates[index + 1]?.link, text: (typeof frontmatter.value.next === 'string' ? frontmatter.value.next @@ -163,14 +151,26 @@ export function usePrevNext() { : undefined) ?? candidates[index + 1]?.docFooterText ?? candidates[index + 1]?.text, + }, + prev: hidePrev || index <= 0 + ? undefined + : { link: - (typeof frontmatter.value.next === 'object' - ? frontmatter.value.next.link - : undefined) ?? candidates[index + 1]?.link, + (typeof frontmatter.value.prev === 'object' + ? frontmatter.value.prev.link + : undefined) ?? candidates[index - 1]?.link, + text: + (typeof frontmatter.value.prev === 'string' + ? frontmatter.value.prev + : typeof frontmatter.value.prev === 'object' + ? frontmatter.value.prev.text + : undefined) + ?? candidates[index - 1]?.docFooterText + ?? candidates[index - 1]?.text, }, } as { - prev?: { text?: string, link?: string } - next?: { text?: string, link?: string } + next?: { link?: string, text?: string } + prev?: { link?: string, text?: string } } }) } diff --git a/docs/.vitepress/composables/sidebar.ts b/docs/.vitepress/composables/sidebar.ts index 230a04a0a..75ae4cb7e 100644 --- a/docs/.vitepress/composables/sidebar.ts +++ b/docs/.vitepress/composables/sidebar.ts @@ -17,10 +17,10 @@ import { export interface SidebarControl { collapsed: Ref collapsible: ComputedRef - isLink: ComputedRef - isActiveLink: Ref hasActiveLink: ComputedRef hasChildren: ComputedRef + isActiveLink: Ref + isLink: ComputedRef toggle: () => void } @@ -55,7 +55,7 @@ export function useCloseSidebarOnEscape( export function useSidebarControl( item: ComputedRef, ): SidebarControl { - const { page, hash } = useData() + const { hash, page } = useData() const collapsed = ref(false) @@ -106,10 +106,10 @@ export function useSidebarControl( return { collapsed, collapsible, - isLink, - isActiveLink, hasActiveLink, hasChildren, + isActiveLink, + isLink, toggle, } } @@ -121,70 +121,35 @@ const HASH_RE = /#.*$/ const HASH_OR_QUERY_RE = /[?#].*$/ const INDEX_OR_EXT_RE = /(?:(^|\/)index)?\.(?:md|html)$/ -export function isActive( - currentPath: string, - matchPath?: string, - asRegex: boolean = false, -): boolean { - if (matchPath === undefined) { - return false - } - - if (currentPath.startsWith('/')) { - currentPath = normalize(`${currentPath}`) - } - else { - currentPath = normalize(`/${currentPath}`) - } - - if (asRegex) { - return new RegExp(matchPath).test(currentPath) - } - - if (normalize(matchPath) !== currentPath) { - return false - } - - const hashMatch = matchPath.match(HASH_RE) - - if (hashMatch) { - return (inBrowser ? location.hash : '') === hashMatch[0] - } - - return true -} - -function normalize(path: string): string { - return decodeURI(path) - .replace(HASH_OR_QUERY_RE, '') - .replace(INDEX_OR_EXT_RE, '$1') -} - -// From https://github.com/vuejs/vitepress/blob/97f9469b6d4eb7ba9de9a1111986581d1f704ec3/src/client/theme-default/support/sidebar.ts -function containsActiveLink( - path: string, - items: any | any[], -): boolean { - if (Array.isArray(items)) { - return items.some(item => containsActiveLink(path, item)) - } - - return isActive(path, items.link) - ? true - : items.items - ? containsActiveLink(path, items.items) - : false -} - // From https://github.com/vuejs/vitepress/blob/fa81e89643523170047ca2c9a690f4d7adf4ffdc/src/client/theme-default/support/sidebar.ts export interface SidebarLink { - text: string - link: string docFooterText?: string + link: string + text: string } -function ensureStartingSlash(path: string): string { - return path.startsWith('/') ? path : `/${path}` +export function getFlatSideBarLinks(sidebar: SidebarItem[]): SidebarLink[] { + const links: SidebarLink[] = [] + + function recursivelyExtractLinks(items: SidebarItem[]) { + for (const item of items) { + if (item.text && item.link) { + links.push({ + docFooterText: item.docFooterText, + link: item.link, + text: item.text, + }) + } + + if (item.items) { + recursivelyExtractLinks(item.items) + } + } + } + + recursivelyExtractLinks(sidebar) + + return links } /** @@ -245,30 +210,6 @@ export function getSidebarGroups(sidebar: SidebarItem[]): SidebarItem[] { return groups } -export function getFlatSideBarLinks(sidebar: SidebarItem[]): SidebarLink[] { - const links: SidebarLink[] = [] - - function recursivelyExtractLinks(items: SidebarItem[]) { - for (const item of items) { - if (item.text && item.link) { - links.push({ - text: item.text, - link: item.link, - docFooterText: item.docFooterText, - }) - } - - if (item.items) { - recursivelyExtractLinks(item.items) - } - } - } - - recursivelyExtractLinks(sidebar) - - return links -} - /** * Check if the given sidebar item contains any active link. */ @@ -287,6 +228,39 @@ export function hasActiveLink( : false } +export function isActive( + currentPath: string, + matchPath?: string, + asRegex: boolean = false, +): boolean { + if (matchPath === undefined) { + return false + } + + if (currentPath.startsWith('/')) { + currentPath = normalize(`${currentPath}`) + } + else { + currentPath = normalize(`/${currentPath}`) + } + + if (asRegex) { + return new RegExp(matchPath).test(currentPath) + } + + if (normalize(matchPath) !== currentPath) { + return false + } + + const hashMatch = matchPath.match(HASH_RE) + + if (hashMatch) { + return (inBrowser ? location.hash : '') === hashMatch[0] + } + + return true +} + function addBase(items: SidebarItem[], _base?: string): SidebarItem[] { return Array.from(items, (_item) => { const item = { ..._item } @@ -298,3 +272,29 @@ function addBase(items: SidebarItem[], _base?: string): SidebarItem[] { return item }) } + +// From https://github.com/vuejs/vitepress/blob/97f9469b6d4eb7ba9de9a1111986581d1f704ec3/src/client/theme-default/support/sidebar.ts +function containsActiveLink( + path: string, + items: any | any[], +): boolean { + if (Array.isArray(items)) { + return items.some(item => containsActiveLink(path, item)) + } + + return isActive(path, items.link) + ? true + : items.items + ? containsActiveLink(path, items.items) + : false +} + +function ensureStartingSlash(path: string): string { + return path.startsWith('/') ? path : `/${path}` +} + +function normalize(path: string): string { + return decodeURI(path) + .replace(HASH_OR_QUERY_RE, '') + .replace(INDEX_OR_EXT_RE, '$1') +} diff --git a/docs/.vitepress/composables/theme-color.ts b/docs/.vitepress/composables/theme-color.ts index 0cbdb085a..eab333608 100644 --- a/docs/.vitepress/composables/theme-color.ts +++ b/docs/.vitepress/composables/theme-color.ts @@ -26,7 +26,7 @@ export function themeColorFromPropertyOf(colorFromClass: string, property: strin * Reading VitePress' `isDark` also avoids stray `useDark()` instances that * force the theme back to the system preference. */ -export function themeColorFromValue(value: string | { light: string, dark: string }): () => Promise { +export function themeColorFromValue(value: string | { dark: string, light: string }): () => Promise { const { isDark } = useData() return async () => { if (typeof value === 'string') { @@ -36,7 +36,7 @@ export function themeColorFromValue(value: string | { light: string, dark: strin } } -export function useThemeColor(colorFrom: () => string | Promise) { +export function useThemeColor(colorFrom: () => Promise | string) { async function updateThemeColor() { if (!('document' in globalThis) || globalThis.document == null) return diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 19f92cd5e..253be5c7a 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -40,29 +40,28 @@ function withBase(url: string) { // https://vitepress.dev/reference/site-config export default defineConfig({ + appearance: 'dark', + base: env.BASE_URL || '/', cleanUrls: true, - ignoreDeadLinks: true, - title: projectName, description: projectDescription, - titleTemplate: projectShortName, head: [ - ['meta', { name: 'theme-color', content: '#0b0d0f' }], - ['link', { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg', sizes: 'any' }], - ['link', { rel: 'apple-touch-icon', href: '/apple-touch-icon.png', sizes: '180x180' }], - ['meta', { name: 'apple-mobile-web-app-title', content: projectName }], - ['meta', { name: 'apple-mobile-web-app-capable', content: 'yes' }], - ['meta', { name: 'author', content: `${teamMembers.map(c => c.name).join(', ')} and ${projectName} contributors` }], - ['meta', { name: 'keywords', content: '' }], - ['meta', { property: 'og:title', content: projectName }], - ['meta', { property: 'og:site_name', content: projectName }], - ['meta', { property: 'og:image', content: ogImage }], - ['meta', { property: 'og:description', content: projectDescription }], - ['meta', { property: 'og:url', content: ogUrl }], - ['meta', { name: 'twitter:title', content: projectName }], - ['meta', { name: 'twitter:description', content: projectDescription }], - ['meta', { name: 'twitter:image', content: ogImage }], - ['meta', { name: 'twitter:card', content: 'summary_large_image' }], - ['link', { rel: 'mask-icon', href: '/logo.svg', color: '#ffffff' }], + ['meta', { content: '#0b0d0f', name: 'theme-color' }], + ['link', { href: '/favicon.svg', rel: 'icon', sizes: 'any', type: 'image/svg+xml' }], + ['link', { href: '/apple-touch-icon.png', rel: 'apple-touch-icon', sizes: '180x180' }], + ['meta', { content: projectName, name: 'apple-mobile-web-app-title' }], + ['meta', { content: 'yes', name: 'apple-mobile-web-app-capable' }], + ['meta', { content: `${teamMembers.map(c => c.name).join(', ')} and ${projectName} contributors`, name: 'author' }], + ['meta', { content: '', name: 'keywords' }], + ['meta', { content: projectName, property: 'og:title' }], + ['meta', { content: projectName, property: 'og:site_name' }], + ['meta', { content: ogImage, property: 'og:image' }], + ['meta', { content: projectDescription, property: 'og:description' }], + ['meta', { content: ogUrl, property: 'og:url' }], + ['meta', { content: projectName, name: 'twitter:title' }], + ['meta', { content: projectDescription, name: 'twitter:description' }], + ['meta', { content: ogImage, name: 'twitter:image' }], + ['meta', { content: 'summary_large_image', name: 'twitter:card' }], + ['link', { color: '#ffffff', href: '/logo.svg', rel: 'mask-icon' }], ['script', {}, ` ;(function () { const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches @@ -73,897 +72,868 @@ export default defineConfig({ })() `], ], - base: env.BASE_URL || '/', + ignoreDeadLinks: true, lastUpdated: true, - sitemap: { - hostname: ogUrl, - }, locales: { - 'root': { - label: 'English', - lang: 'en', - themeConfig: { - // https://vitepress.dev/reference/default-theme-config - nav: [ - { text: 'Docs', link: withBase('/en/docs/overview/') }, - { text: 'Blog', link: withBase('/en/blog/') }, - { - text: `v${version}`, - items: [ - { text: 'Release Notes ', link: releases }, - ], - }, - { - text: 'About', - items: [ - { text: 'Privacy Policy', link: withBase('/en/about/privacy') }, - { text: 'Terms of Use', link: withBase('/en/about/terms') }, - ], - }, - ], - outline: { - level: 'deep', - label: 'On this page', - }, - docFooter: { - prev: 'Previous page', - next: 'Next page', - }, - editLink: { - pattern: 'https://github.com/moeru-ai/airi/edit/main/docs/content/:path', - text: 'Edit this page on GitHub', - }, - lastUpdated: { - text: 'Last updated', - }, - darkModeSwitchLabel: 'Appearance', - sidebarMenuLabel: 'Menu', - returnToTopLabel: 'Return to top', - langMenuLabel: 'Change language', - logo: withBase('/favicon.svg'), - - sidebar: [ - { - text: 'Overview', - icon: 'lucide:rocket', - items: [ - { text: 'Introduction', link: withBase('/en/docs/overview/') }, - { text: 'Versions & Downloads', link: withBase('/en/docs/overview/versions') }, - { text: 'About AI VTuber', link: withBase('/en/docs/overview/about-ai-vtuber') }, - { text: 'About Neuro-sama', link: withBase('/en/docs/overview/about-neuro-sama') }, - { text: 'Other Similar Projects', link: withBase('/en/docs/overview/other-similar-projects') }, - ], - }, - { - text: 'Manual', - icon: 'lucide:book-open', - link: withBase('/en/docs/manual/'), - items: [ - { - text: 'Quick Start', - items: [ - { text: 'Desktop ver.', link: withBase('/en/docs/manual/tamagotchi/') }, - { text: 'Web Version', link: withBase('/en/docs/manual/web/') }, - ], - }, - { text: 'Setup and Use', link: withBase('/en/docs/manual/tamagotchi/setup-and-use/') }, - { - text: 'Configuration', - items: [ - { text: 'Configuration Guide', link: withBase('/en/docs/manual/config/') }, - { text: 'Common Setup', link: withBase('/en/docs/manual/config/common') }, - { text: 'Feature Configuration', collapsed: true, items: [ - { text: 'Chat Models', link: withBase('/en/docs/manual/config/llm') }, - { text: 'Audio Input and Output', link: withBase('/en/docs/manual/config/audio') }, - { text: 'Vision', link: withBase('/en/docs/manual/config/vision') }, - { text: 'Web Search', link: withBase('/en/docs/manual/config/web-search') }, - ] }, - { text: 'Service Providers', collapsed: true, items: [ - { text: 'Chat', collapsed: true, items: [ - { text: 'AIRI Official Provider', link: withBase('/en/docs/manual/config/providers/consciousness/official') }, - { text: 'AIHubMix', link: withBase('/en/docs/manual/config/providers/consciousness/aihubmix') }, - { text: 'Amazon Bedrock', link: withBase('/en/docs/manual/config/providers/consciousness/amazon-bedrock') }, - { text: 'Anthropic', link: withBase('/en/docs/manual/config/providers/consciousness/anthropic') }, - { text: 'Atlas Cloud', link: withBase('/en/docs/manual/config/providers/consciousness/atlascloud') }, - { text: 'Azure AI Foundry', link: withBase('/en/docs/manual/config/providers/consciousness/azure-ai-foundry') }, - { text: 'Azure OpenAI', link: withBase('/en/docs/manual/config/providers/consciousness/azure-openai') }, - { text: 'BytePlus', link: withBase('/en/docs/manual/config/providers/consciousness/byteplus') }, - { text: 'BytePlus Coding Plan', link: withBase('/en/docs/manual/config/providers/consciousness/byteplus-coding-plan') }, - { text: 'Cerebras', link: withBase('/en/docs/manual/config/providers/consciousness/cerebras') }, - { text: 'Comet API', link: withBase('/en/docs/manual/config/providers/consciousness/comet-api') }, - { text: 'Google Gemini', link: withBase('/en/docs/manual/config/providers/consciousness/google-gemini') }, - { text: 'xAI', link: withBase('/en/docs/manual/config/providers/consciousness/xai') }, - { text: 'Cloudflare Workers AI', link: withBase('/en/docs/manual/config/providers/consciousness/cloudflare-workers-ai') }, - { text: 'LM Studio (Local Model)', link: withBase('/en/docs/manual/config/providers/consciousness/lm-studio') }, - { text: 'OpenPaths', link: withBase('/en/docs/manual/config/providers/consciousness/openpaths') }, - { text: 'OpenRouter', link: withBase('/en/docs/manual/config/providers/consciousness/openrouter') }, - { text: 'Ollama', link: withBase('/en/docs/manual/config/providers/consciousness/ollama') }, - { text: 'DeepSeek', link: withBase('/en/docs/manual/config/providers/consciousness/deepseek') }, - { text: 'OpenAI & Compatible APIs', link: withBase('/en/docs/manual/config/providers/consciousness/openai') }, - { text: '302.AI', link: withBase('/en/docs/manual/config/providers/consciousness/302ai') }, - { text: 'Fireworks.ai', link: withBase('/en/docs/manual/config/providers/consciousness/fireworks') }, - { text: 'Featherless AI', link: withBase('/en/docs/manual/config/providers/consciousness/featherless') }, - { text: 'Groq', link: withBase('/en/docs/manual/config/providers/consciousness/groq') }, - { text: 'MiniMax', link: withBase('/en/docs/manual/config/providers/consciousness/minimax') }, - { text: 'MiniMax Global', link: withBase('/en/docs/manual/config/providers/consciousness/minimax-global') }, - { text: 'Mistral', link: withBase('/en/docs/manual/config/providers/consciousness/mistral') }, - { text: 'Xiaomi MiMo', link: withBase('/en/docs/manual/config/providers/consciousness/mimo') }, - { text: 'ModelScope', link: withBase('/en/docs/manual/config/providers/consciousness/modelscope') }, - { text: 'Moonshot AI', link: withBase('/en/docs/manual/config/providers/consciousness/moonshot') }, - { text: 'NVIDIA NIM', link: withBase('/en/docs/manual/config/providers/consciousness/nvidia') }, - { text: 'n1n', link: withBase('/en/docs/manual/config/providers/consciousness/n1n') }, - { text: 'Novita', link: withBase('/en/docs/manual/config/providers/consciousness/novita') }, - { text: 'Perplexity', link: withBase('/en/docs/manual/config/providers/consciousness/perplexity') }, - { text: 'Together.ai', link: withBase('/en/docs/manual/config/providers/consciousness/together') }, - { text: 'Z.ai', link: withBase('/en/docs/manual/config/providers/consciousness/zhipu') }, - { text: 'Volcengine Coding Plan', link: withBase('/en/docs/manual/config/providers/consciousness/volcengine-coding-plan') }, - ] }, - { text: 'Speech', collapsed: true, items: [ - { text: 'Official Speech Provider', link: withBase('/en/docs/manual/config/providers/speech/official') }, - { text: 'Alibaba Cloud Model Studio', link: withBase('/en/docs/manual/config/providers/speech/alibaba-cloud-model-studio') }, - { text: 'Browser (Local)', link: withBase('/en/docs/manual/config/providers/speech/browser-local') }, - { text: 'Comet API', link: withBase('/en/docs/manual/config/providers/speech/comet-api') }, - { text: 'Deepgram', link: withBase('/en/docs/manual/config/providers/speech/deepgram') }, - { text: 'Desktop (Local)', link: withBase('/en/docs/manual/config/providers/speech/desktop-local') }, - { text: 'ElevenLabs', link: withBase('/en/docs/manual/config/providers/speech/elevenlabs') }, - { text: 'Google Gemini', link: withBase('/en/docs/manual/config/providers/speech/google-gemini') }, - { text: 'Bilibili / IndexTTS', link: withBase('/en/docs/manual/config/providers/speech/index-tts') }, - { text: 'Kokoro TTS (Local)', link: withBase('/en/docs/manual/config/providers/speech/kokoro') }, - { text: 'Microsoft Azure Speech', link: withBase('/en/docs/manual/config/providers/speech/azure-speech') }, - { text: 'MiniMax Speech (Unavailable)', link: withBase('/en/docs/manual/config/providers/speech/minimax') }, - { text: 'Xiaomi MiMo', link: withBase('/en/docs/manual/config/providers/speech/mimo') }, - { text: 'OpenAI & Compatible APIs', link: withBase('/en/docs/manual/config/providers/speech/openai') }, - { text: 'OpenRouter', link: withBase('/en/docs/manual/config/providers/speech/openrouter') }, - { text: 'Player2', link: withBase('/en/docs/manual/config/providers/speech/player2') }, - { text: 'Volcano Engine', link: withBase('/en/docs/manual/config/providers/speech/volcengine') }, - ] }, - { text: 'Transcription', collapsed: true, items: [ - { text: 'Official Transcription Provider', link: withBase('/en/docs/manual/config/providers/transcription/official') }, - { text: 'Aliyun NLS', link: withBase('/en/docs/manual/config/providers/transcription/aliyun') }, - { text: 'Browser (Local)', link: withBase('/en/docs/manual/config/providers/transcription/browser-local') }, - { text: 'Browser Web Speech API', link: withBase('/en/docs/manual/config/providers/transcription/web-speech-api') }, - { text: 'Comet API', link: withBase('/en/docs/manual/config/providers/transcription/comet-api') }, - { text: 'Desktop (Local)', link: withBase('/en/docs/manual/config/providers/transcription/desktop-local') }, - { text: 'Xiaomi MiMo', link: withBase('/en/docs/manual/config/providers/transcription/mimo') }, - { text: 'OpenAI & Compatible APIs', link: withBase('/en/docs/manual/config/providers/transcription/openai') }, - ] }, - { text: 'Artistry', collapsed: true, items: [ - { text: 'ComfyUI (Local Workflow)', link: withBase('/en/docs/manual/config/providers/artistry/comfyui') }, - { text: 'Nano Banana', link: withBase('/en/docs/manual/config/providers/artistry/nanobanana') }, - { text: 'Replicate', link: withBase('/en/docs/manual/config/providers/artistry/replicate') }, - ] }, - ] }, - ], - }, - ], - }, - { - text: 'Integration Services', - icon: 'lucide:plug', - items: [ - { - text: 'Games', - items: [ - { text: 'Minecraft Agent', link: withBase('/en/docs/integrations/minecraft') }, - { text: 'Factorio', link: withBase('/en/docs/integrations/factorio') }, - ], - }, - { - text: 'Messaging Platforms', - items: [ - { text: 'Satori Bot', link: withBase('/en/docs/integrations/satori') }, - { text: 'Telegram Bot', link: withBase('/en/docs/integrations/telegram') }, - { text: 'Discord Bot', link: withBase('/en/docs/integrations/discord') }, - { text: 'X / Twitter (Unavailable)', link: withBase('/en/docs/integrations/x') }, - ], - }, - ], - }, - { - text: 'Developer Guide', - icon: 'lucide:code-2', - items: [ - { - text: 'Contributing', - items: [ - { text: 'Development Setup & First Contribution', link: withBase('/en/docs/contributing/') }, - { text: 'Desktop App', link: withBase('/en/docs/contributing/tamagotchi') }, - { text: 'Web App', link: withBase('/en/docs/contributing/webui') }, - { text: 'Documentation Site', link: withBase('/en/docs/contributing/docs') }, - ], - }, - { - text: 'Desktop Debugging', - items: [ - { text: 'Developer Tools', link: withBase('/en/docs/contributing/desktop-developer-tools') }, - ], - }, - { - text: 'Design Guidelines', - items: [ - { text: 'Introduction', link: withBase('/en/docs/contributing/design-guidelines/') }, - { text: 'Artists & Developers (Resources)', link: withBase('/en/docs/contributing/design-guidelines/resources') }, - { text: 'Tools', link: withBase('/en/docs/contributing/design-guidelines/tools') }, - ], - }, - ], - }, - { - text: 'Chronicles', - icon: 'lucide:calendar-days', - items: [ - { text: 'Initial Publish v0.1.0', link: withBase('/en/docs/chronicles/version-v0.1.0/') }, - { text: 'Before Story v0.0.1', link: withBase('/en/docs/chronicles/version-v0.0.1/') }, - ], - }, - ] as (DefaultTheme.SidebarItem & { icon?: string })[], - - homepage: { - buttons: [ - { - text: 'Try Live', - link: webLive, - primary: true, - target: '_self', - }, - { - text: 'Download', - link: withBase('/en/docs/overview/versions'), - }, - { - text: 'Get Started', - link: withBase('/en/docs/overview/'), - }, - ], - }, - }, - }, - 'zh-Hans': { - label: '简体中文', - lang: 'zh-Hans', - themeConfig: { - // https://vitepress.dev/reference/default-theme-config - nav: [ - { text: '文档', link: withBase('/zh-Hans/docs/overview/') }, - { text: '博客 / 开发日志', link: withBase('/zh-Hans/blog/') }, - { - text: `v${version}`, - items: [ - { text: '发布说明 ', link: releases }, - ], - }, - { - text: '关于', - items: [ - { text: '隐私政策', link: withBase('/zh-Hans/about/privacy') }, - { text: '使用条款', link: withBase('/zh-Hans/about/terms') }, - ], - }, - ], - outline: { - level: 'deep', - label: '本页内容', - }, - docFooter: { - prev: '上一页', - next: '下一页', - }, - editLink: { - pattern: 'https://github.com/moeru-ai/airi/edit/main/docs/content/:path', - text: '在 GitHub 编辑此页', - }, - lastUpdated: { - text: '最后更新', - }, - darkModeSwitchLabel: '外观模式', - sidebarMenuLabel: '菜单', - returnToTopLabel: '返回顶部', - langMenuLabel: '切换语言', - logo: withBase('/favicon.svg'), - - sidebar: [ - { - text: '概览', - icon: 'lucide:rocket', - items: [ - { text: '这是什么项目?', link: withBase('/zh-Hans/docs/overview/') }, - { text: '版本与下载', link: withBase('/zh-Hans/docs/overview/versions') }, - { text: '有关 AI VTuber', link: withBase('/zh-Hans/docs/overview/about-ai-vtuber') }, - { text: '有关 Neuro-sama', link: withBase('/zh-Hans/docs/overview/about-neuro-sama') }, - { text: '其他类似项目', link: withBase('/zh-Hans/docs/overview/other-similar-projects') }, - { - text: '编年史', - collapsed: true, - items: [ - { text: '首次公开 v0.1.0', link: withBase('/zh-Hans/docs/chronicles/version-v0.1.0/') }, - { text: '先前的故事 v0.0.1', link: withBase('/zh-Hans/docs/chronicles/version-v0.0.1/') }, - ], - }, - ], - }, - { - text: '用户手册', - icon: 'lucide:book-open', - link: withBase('/zh-Hans/docs/manual/'), - items: [ - { - text: '快速开始', - items: [ - { text: '桌面版', link: withBase('/zh-Hans/docs/manual/tamagotchi/') }, - { text: '网页版', link: withBase('/zh-Hans/docs/manual/web/') }, - ], - }, - { - text: '安装与使用', - link: withBase('/zh-Hans/docs/manual/tamagotchi/setup-and-use/'), - }, - { - text: '配置', - items: [ - { text: '配置指南', link: withBase('/zh-Hans/docs/manual/config/') }, - { text: '通用说明', link: withBase('/zh-Hans/docs/manual/config/common') }, - { text: '功能配置', collapsed: true, items: [ - { text: '聊天模型', link: withBase('/zh-Hans/docs/manual/config/llm') }, - { text: '语音输入与输出', link: withBase('/zh-Hans/docs/manual/config/audio') }, - { text: '视觉理解', link: withBase('/zh-Hans/docs/manual/config/vision') }, - { text: '网络搜索', link: withBase('/zh-Hans/docs/manual/config/web-search') }, - ] }, - { text: '服务商', collapsed: true, items: [ - { text: '聊天服务商', collapsed: true, items: [ - { text: 'AIRI 官方提供商', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/official') }, - { text: 'AIHubMix', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/aihubmix') }, - { text: 'Amazon Bedrock', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/amazon-bedrock') }, - { text: 'Anthropic', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/anthropic') }, - { text: 'Atlas Cloud', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/atlascloud') }, - { text: 'Azure AI Foundry', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/azure-ai-foundry') }, - { text: 'Azure OpenAI', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/azure-openai') }, - { text: 'BytePlus', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/byteplus') }, - { text: 'BytePlus Coding Plan', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/byteplus-coding-plan') }, - { text: 'Cerebras', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/cerebras') }, - { text: 'CometAPI', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/comet-api') }, - { text: 'Google Gemini', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/google-gemini') }, - { text: 'xAI', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/xai') }, - { text: 'Cloudflare Workers AI', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/cloudflare-workers-ai') }, - { text: 'LM Studio(本地模型)', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/lm-studio') }, - { text: 'OpenPaths', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/openpaths') }, - { text: 'OpenRouter', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/openrouter') }, - { text: 'Ollama', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/ollama') }, - { text: '深度求索 DeepSeek', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/deepseek') }, - { text: 'OpenAI 与兼容 API', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/openai') }, - { text: '302.ai', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/302ai') }, - { text: 'Fireworks AI', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/fireworks') }, - { text: 'Featherless.ai', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/featherless') }, - { text: 'Groq', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/groq') }, - { text: 'MiniMax', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/minimax') }, - { text: 'MiniMax Global', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/minimax-global') }, - { text: 'Mistral', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/mistral') }, - { text: '小米 MiMo', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/mimo') }, - { text: 'ModelScope', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/modelscope') }, - { text: '月之暗面', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/moonshot') }, - { text: 'NVIDIA NIM', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/nvidia') }, - { text: 'n1n', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/n1n') }, - { text: 'Novita', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/novita') }, - { text: 'Perplexity', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/perplexity') }, - { text: 'Together.ai', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/together') }, - { text: 'Z.ai', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/zhipu') }, - { text: '火山引擎 Coding Plan', link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/volcengine-coding-plan') }, - ] }, - { text: '语音合成(TTS)', collapsed: true, items: [ - { text: 'AIRI 官方语音合成', link: withBase('/zh-Hans/docs/manual/config/providers/speech/official') }, - { text: '阿里云百炼', link: withBase('/zh-Hans/docs/manual/config/providers/speech/alibaba-cloud-model-studio') }, - { text: '浏览器本地语音合成', link: withBase('/zh-Hans/docs/manual/config/providers/speech/browser-local') }, - { text: 'CometAPI', link: withBase('/zh-Hans/docs/manual/config/providers/speech/comet-api') }, - { text: 'Deepgram', link: withBase('/zh-Hans/docs/manual/config/providers/speech/deepgram') }, - { text: '桌面端本地语音合成', link: withBase('/zh-Hans/docs/manual/config/providers/speech/desktop-local') }, - { text: 'ElevenLabs', link: withBase('/zh-Hans/docs/manual/config/providers/speech/elevenlabs') }, - { text: 'Google Gemini', link: withBase('/zh-Hans/docs/manual/config/providers/speech/google-gemini') }, - { text: 'Index-TTS', link: withBase('/zh-Hans/docs/manual/config/providers/speech/index-tts') }, - { text: 'Kokoro', link: withBase('/zh-Hans/docs/manual/config/providers/speech/kokoro') }, - { text: 'Microsoft Azure Speech', link: withBase('/zh-Hans/docs/manual/config/providers/speech/azure-speech') }, - { text: 'MiniMax Speech', link: withBase('/zh-Hans/docs/manual/config/providers/speech/minimax') }, - { text: '小米 MiMo', link: withBase('/zh-Hans/docs/manual/config/providers/speech/mimo') }, - { text: 'OpenAI 与兼容 API', link: withBase('/zh-Hans/docs/manual/config/providers/speech/openai') }, - { text: 'OpenRouter', link: withBase('/zh-Hans/docs/manual/config/providers/speech/openrouter') }, - { text: 'Player2 Speech', link: withBase('/zh-Hans/docs/manual/config/providers/speech/player2') }, - { text: '火山引擎', link: withBase('/zh-Hans/docs/manual/config/providers/speech/volcengine') }, - ] }, - { text: '语音识别(ASR/STT)', collapsed: true, items: [ - { text: 'AIRI 官方语音识别', link: withBase('/zh-Hans/docs/manual/config/providers/transcription/official') }, - { text: '阿里云智能语音服务', link: withBase('/zh-Hans/docs/manual/config/providers/transcription/aliyun') }, - { text: '浏览器本地语音识别', link: withBase('/zh-Hans/docs/manual/config/providers/transcription/browser-local') }, - { text: '浏览器 Web Speech API', link: withBase('/zh-Hans/docs/manual/config/providers/transcription/web-speech-api') }, - { text: 'CometAPI', link: withBase('/zh-Hans/docs/manual/config/providers/transcription/comet-api') }, - { text: '桌面端本地语音识别', link: withBase('/zh-Hans/docs/manual/config/providers/transcription/desktop-local') }, - { text: '小米 MiMo', link: withBase('/zh-Hans/docs/manual/config/providers/transcription/mimo') }, - { text: 'OpenAI 与兼容 API', link: withBase('/zh-Hans/docs/manual/config/providers/transcription/openai') }, - ] }, - { text: '艺术创作服务商', collapsed: true, items: [ - { text: 'ComfyUI(本地工作流)', link: withBase('/zh-Hans/docs/manual/config/providers/artistry/comfyui') }, - { text: 'Nano Banana', link: withBase('/zh-Hans/docs/manual/config/providers/artistry/nanobanana') }, - { text: 'Replicate', link: withBase('/zh-Hans/docs/manual/config/providers/artistry/replicate') }, - ] }, - ] }, - ], - }, - ], - }, - { - text: '集成服务', - icon: 'lucide:plug', - items: [ - { - text: '游戏', - items: [ - { text: 'Minecraft 智能体', link: withBase('/zh-Hans/docs/integrations/minecraft') }, - { text: '异星工厂', link: withBase('/zh-Hans/docs/integrations/factorio') }, - ], - }, - { - text: '消息平台', - items: [ - { text: 'Satori 机器人', link: withBase('/zh-Hans/docs/integrations/satori') }, - { text: 'Telegram 机器人', link: withBase('/zh-Hans/docs/integrations/telegram') }, - { text: 'Discord 机器人', link: withBase('/zh-Hans/docs/integrations/discord') }, - { text: 'X / Twitter', link: withBase('/zh-Hans/docs/integrations/x') }, - ], - }, - ], - }, - { - text: '开发者指南', - icon: 'lucide:code-2', - items: [ - { - text: '参与贡献', - items: [ - { text: '开发环境与首次贡献', link: withBase('/zh-Hans/docs/contributing/') }, - { text: '桌面端', link: withBase('/zh-Hans/docs/contributing/tamagotchi') }, - { text: '网页端', link: withBase('/zh-Hans/docs/contributing/webui') }, - { text: '文档站', link: withBase('/zh-Hans/docs/contributing/docs') }, - ], - }, - { - text: '桌面端调试', - items: [ - { text: '开发者工具', link: withBase('/zh-Hans/docs/contributing/desktop-developer-tools') }, - ], - }, - { - text: '设计指南', - items: [ - { text: '介绍', link: withBase('/zh-Hans/docs/contributing/design-guidelines/') }, - { text: '艺术家与开发者 (参考资源)', link: withBase('/zh-Hans/docs/contributing/design-guidelines/resources') }, - { text: '工具', link: withBase('/zh-Hans/docs/contributing/design-guidelines/tools') }, - ], - }, - ], - }, - ] as (DefaultTheme.SidebarItem & { icon?: string })[], - - homepage: { - buttons: [ - { - text: '网页版', - link: webLive, - primary: true, - target: '_self', - }, - { - text: '下载', - link: withBase('/zh-Hans/docs/overview/versions'), - }, - { - text: '使用教程', - link: withBase('/zh-Hans/docs/overview/'), - }, - ], - }, - }, - }, 'ja': { label: '日本語', lang: 'ja', themeConfig: { - // https://vitepress.dev/reference/default-theme-config - nav: [ - { text: 'ドキュメント', link: withBase('/ja/docs/overview/') }, - { text: 'ブログ', link: withBase('/ja/blog/') }, - { - text: `v${version}`, - items: [ - { text: 'リリースノート', link: releases }, - ], - }, - { - text: '概要', - items: [ - { text: 'プライバシーポリシー', link: withBase('/ja/about/privacy') }, - { text: '利用規約', link: withBase('/ja/about/terms') }, - ], - }, - ], - outline: { - level: 'deep', - label: 'このページの内容', - }, + darkModeSwitchLabel: '外観モード', docFooter: { - prev: '前のページ', next: '次のページ', + prev: '前のページ', }, editLink: { pattern: 'https://github.com/moeru-ai/airi/edit/main/docs/content/:path', text: 'GitHub でこのページを編集', }, - lastUpdated: { - text: '最終更新', - }, - darkModeSwitchLabel: '外観モード', - sidebarMenuLabel: 'メニュー', - returnToTopLabel: 'トップに戻る', - langMenuLabel: '言語を変更', - logo: withBase('/favicon.svg'), - - sidebar: [ - { - text: '概要', - icon: 'lucide:rocket', - items: [ - { text: 'はじめに', link: withBase('/ja/docs/overview/') }, - { text: 'バージョンとダウンロード', link: withBase('/ja/docs/overview/versions') }, - { text: 'AI VTuberについて', link: withBase('/ja/docs/overview/about-ai-vtuber') }, - { text: 'Neuro-samaについて', link: withBase('/ja/docs/overview/about-neuro-sama') }, - { text: 'その他の類似プロジェクト', link: withBase('/ja/docs/overview/other-similar-projects') }, - ], - }, - { - text: 'マニュアル', - icon: 'lucide:book-open', - link: withBase('/ja/docs/manual/'), - items: [ - { - text: 'クイックスタート', - items: [ - { text: 'デスクトップ版', link: withBase('/ja/docs/manual/tamagotchi/') }, - { text: 'Web版', link: withBase('/ja/docs/manual/web/') }, - ], - }, - { - text: '設定', - items: [ - { text: '設定ガイド', link: withBase('/ja/docs/manual/config/') }, - ], - }, - ], - }, - { - text: 'コントリビューション', - icon: 'lucide:users', - items: [ - { - text: '基本設定と開発', - items: [ - { text: '環境構築と事前準備', link: withBase('/ja/docs/contributing/') }, - { text: 'デスクトップアプリ', link: withBase('/ja/docs/contributing/tamagotchi') }, - { text: 'Web UI', link: withBase('/ja/docs/contributing/webui') }, - { text: 'ドキュメントサイト', link: withBase('/ja/docs/contributing/docs') }, - ], - }, - { - text: 'ゲーム&ソーシャルプラットフォーム', - items: [ - { text: 'Minecraft', link: withBase('/ja/docs/contributing/services/minecraft') }, - { text: 'Satori Bot', link: withBase('/ja/docs/contributing/services/satori') }, - { text: 'Telegram Bot', link: withBase('/ja/docs/contributing/services/telegram') }, - { text: 'Discord Bot', link: withBase('/ja/docs/contributing/services/discord') }, - ], - }, - { - text: 'デザインガイドライン', - items: [ - { text: 'はじめに', link: withBase('/ja/docs/contributing/design-guidelines/') }, - { text: 'アーティストと開発者 (参考リソース)', link: withBase('/ja/docs/contributing/design-guidelines/resources') }, - { text: 'ツール', link: withBase('/ja/docs/contributing/design-guidelines/tools') }, - ], - }, - ], - }, - { - text: '年表', - icon: 'lucide:calendar-days', - items: [ - { text: '初公開 v0.1.0', link: withBase('/ja/docs/chronicles/version-v0.1.0/') }, - { text: '前日譚 v0.0.1', link: withBase('/ja/docs/chronicles/version-v0.0.1/') }, - ], - }, - ] as (DefaultTheme.SidebarItem & { icon?: string })[], - homepage: { buttons: [ { - text: 'ライブ版を試す', link: webLive, primary: true, target: '_self', + text: 'ライブ版を試す', }, { - text: 'ダウンロード', link: withBase('/ja/docs/overview/versions'), + text: 'ダウンロード', }, { - text: 'はじめに', link: withBase('/ja/docs/overview/'), + text: 'はじめに', }, ], }, + langMenuLabel: '言語を変更', + lastUpdated: { + text: '最終更新', + }, + logo: withBase('/favicon.svg'), + // https://vitepress.dev/reference/default-theme-config + nav: [ + { link: withBase('/ja/docs/overview/'), text: 'ドキュメント' }, + { link: withBase('/ja/blog/'), text: 'ブログ' }, + { + items: [ + { link: releases, text: 'リリースノート' }, + ], + text: `v${version}`, + }, + { + items: [ + { link: withBase('/ja/about/privacy'), text: 'プライバシーポリシー' }, + { link: withBase('/ja/about/terms'), text: '利用規約' }, + ], + text: '概要', + }, + ], + outline: { + label: 'このページの内容', + level: 'deep', + }, + returnToTopLabel: 'トップに戻る', + + sidebar: [ + { + icon: 'lucide:rocket', + items: [ + { link: withBase('/ja/docs/overview/'), text: 'はじめに' }, + { link: withBase('/ja/docs/overview/versions'), text: 'バージョンとダウンロード' }, + { link: withBase('/ja/docs/overview/about-ai-vtuber'), text: 'AI VTuberについて' }, + { link: withBase('/ja/docs/overview/about-neuro-sama'), text: 'Neuro-samaについて' }, + { link: withBase('/ja/docs/overview/other-similar-projects'), text: 'その他の類似プロジェクト' }, + ], + text: '概要', + }, + { + icon: 'lucide:book-open', + items: [ + { + items: [ + { link: withBase('/ja/docs/manual/tamagotchi/'), text: 'デスクトップ版' }, + { link: withBase('/ja/docs/manual/web/'), text: 'Web版' }, + ], + text: 'クイックスタート', + }, + { + items: [ + { link: withBase('/ja/docs/manual/config/'), text: '設定ガイド' }, + ], + text: '設定', + }, + ], + link: withBase('/ja/docs/manual/'), + text: 'マニュアル', + }, + { + icon: 'lucide:users', + items: [ + { + items: [ + { link: withBase('/ja/docs/contributing/'), text: '環境構築と事前準備' }, + { link: withBase('/ja/docs/contributing/tamagotchi'), text: 'デスクトップアプリ' }, + { link: withBase('/ja/docs/contributing/webui'), text: 'Web UI' }, + { link: withBase('/ja/docs/contributing/docs'), text: 'ドキュメントサイト' }, + ], + text: '基本設定と開発', + }, + { + items: [ + { link: withBase('/ja/docs/contributing/services/minecraft'), text: 'Minecraft' }, + { link: withBase('/ja/docs/contributing/services/satori'), text: 'Satori Bot' }, + { link: withBase('/ja/docs/contributing/services/telegram'), text: 'Telegram Bot' }, + { link: withBase('/ja/docs/contributing/services/discord'), text: 'Discord Bot' }, + ], + text: 'ゲーム&ソーシャルプラットフォーム', + }, + { + items: [ + { link: withBase('/ja/docs/contributing/design-guidelines/'), text: 'はじめに' }, + { link: withBase('/ja/docs/contributing/design-guidelines/resources'), text: 'アーティストと開発者 (参考リソース)' }, + { link: withBase('/ja/docs/contributing/design-guidelines/tools'), text: 'ツール' }, + ], + text: 'デザインガイドライン', + }, + ], + text: 'コントリビューション', + }, + { + icon: 'lucide:calendar-days', + items: [ + { link: withBase('/ja/docs/chronicles/version-v0.1.0/'), text: '初公開 v0.1.0' }, + { link: withBase('/ja/docs/chronicles/version-v0.0.1/'), text: '前日譚 v0.0.1' }, + ], + text: '年表', + }, + ] as (DefaultTheme.SidebarItem & { icon?: string })[], + + sidebarMenuLabel: 'メニュー', }, }, 'ko': { label: '한국어', lang: 'ko', themeConfig: { - // https://vitepress.dev/reference/default-theme-config - nav: [ - { text: '문서', link: withBase('/ko/docs/overview/') }, - { text: '블로그', link: withBase('/ko/blog/') }, - { - text: `v${version}`, - items: [ - { text: '릴리스 노트', link: releases }, - ], - }, - { - text: '소개', - items: [ - { text: '개인정보 처리방침', link: withBase('/ko/about/privacy') }, - { text: '이용약관', link: withBase('/ko/about/terms') }, - ], - }, - ], - outline: { - level: 'deep', - label: '이 페이지의 내용', - }, + darkModeSwitchLabel: '테마', docFooter: { - prev: '이전 페이지', next: '다음 페이지', + prev: '이전 페이지', }, editLink: { pattern: 'https://github.com/moeru-ai/airi/edit/main/docs/content/:path', text: 'GitHub에서 이 페이지 편집하기', }, - lastUpdated: { - text: '마지막 업데이트', - }, - darkModeSwitchLabel: '테마', - sidebarMenuLabel: '메뉴', - returnToTopLabel: '맨 위로', - langMenuLabel: '언어 변경', - logo: withBase('/favicon.svg'), - - sidebar: [ - { - text: '개요', - icon: 'lucide:rocket', - items: [ - { text: '소개', link: withBase('/ko/docs/overview/') }, - { text: '버전과 다운로드', link: withBase('/ko/docs/overview/versions') }, - { text: 'AI VTuber란', link: withBase('/ko/docs/overview/about-ai-vtuber') }, - { text: 'Neuro-sama란', link: withBase('/ko/docs/overview/about-neuro-sama') }, - { text: '비슷한 다른 프로젝트들', link: withBase('/ko/docs/overview/other-similar-projects') }, - ], - }, - { - text: '사용 설명서', - icon: 'lucide:book-open', - link: withBase('/ko/docs/manual/'), - items: [ - { - text: '빠른 시작', - items: [ - { text: '데스크톱 버전', link: withBase('/ko/docs/manual/tamagotchi/') }, - { text: '웹 버전', link: withBase('/ko/docs/manual/web/') }, - ], - }, - { text: '설치와 사용', link: withBase('/ko/docs/manual/tamagotchi/setup-and-use/') }, - { - text: '설정', - items: [ - { text: '설정 가이드', link: withBase('/ko/docs/manual/config/') }, - { text: '공통 설정', link: withBase('/ko/docs/manual/config/common') }, - { text: '기능 설정', collapsed: true, items: [ - { text: '채팅 모델', link: withBase('/ko/docs/manual/config/llm') }, - { text: '오디오 입출력', link: withBase('/ko/docs/manual/config/audio') }, - { text: '비전', link: withBase('/ko/docs/manual/config/vision') }, - { text: '웹 검색', link: withBase('/ko/docs/manual/config/web-search') }, - ] }, - { text: '서비스 제공자', collapsed: true, items: [ - { text: '채팅', collapsed: true, items: [ - { text: 'AIRI 공식 제공자', link: withBase('/ko/docs/manual/config/providers/consciousness/official') }, - { text: 'AIHubMix', link: withBase('/ko/docs/manual/config/providers/consciousness/aihubmix') }, - { text: 'Amazon Bedrock', link: withBase('/ko/docs/manual/config/providers/consciousness/amazon-bedrock') }, - { text: 'Anthropic', link: withBase('/ko/docs/manual/config/providers/consciousness/anthropic') }, - { text: 'Atlas Cloud', link: withBase('/ko/docs/manual/config/providers/consciousness/atlascloud') }, - { text: 'Azure AI Foundry', link: withBase('/ko/docs/manual/config/providers/consciousness/azure-ai-foundry') }, - { text: 'Azure OpenAI', link: withBase('/ko/docs/manual/config/providers/consciousness/azure-openai') }, - { text: 'BytePlus', link: withBase('/ko/docs/manual/config/providers/consciousness/byteplus') }, - { text: 'BytePlus Coding Plan', link: withBase('/ko/docs/manual/config/providers/consciousness/byteplus-coding-plan') }, - { text: 'Cerebras', link: withBase('/ko/docs/manual/config/providers/consciousness/cerebras') }, - { text: 'Comet API', link: withBase('/ko/docs/manual/config/providers/consciousness/comet-api') }, - { text: 'Google Gemini', link: withBase('/ko/docs/manual/config/providers/consciousness/google-gemini') }, - { text: 'xAI', link: withBase('/ko/docs/manual/config/providers/consciousness/xai') }, - { text: 'Cloudflare Workers AI', link: withBase('/ko/docs/manual/config/providers/consciousness/cloudflare-workers-ai') }, - { text: 'LM Studio (로컬 모델)', link: withBase('/ko/docs/manual/config/providers/consciousness/lm-studio') }, - { text: 'OpenPaths', link: withBase('/ko/docs/manual/config/providers/consciousness/openpaths') }, - { text: 'OpenRouter', link: withBase('/ko/docs/manual/config/providers/consciousness/openrouter') }, - { text: 'Ollama', link: withBase('/ko/docs/manual/config/providers/consciousness/ollama') }, - { text: 'DeepSeek', link: withBase('/ko/docs/manual/config/providers/consciousness/deepseek') }, - { text: 'OpenAI & 호환 API', link: withBase('/ko/docs/manual/config/providers/consciousness/openai') }, - { text: '302.AI', link: withBase('/ko/docs/manual/config/providers/consciousness/302ai') }, - { text: 'Fireworks.ai', link: withBase('/ko/docs/manual/config/providers/consciousness/fireworks') }, - { text: 'Featherless AI', link: withBase('/ko/docs/manual/config/providers/consciousness/featherless') }, - { text: 'Groq', link: withBase('/ko/docs/manual/config/providers/consciousness/groq') }, - { text: 'MiniMax', link: withBase('/ko/docs/manual/config/providers/consciousness/minimax') }, - { text: 'MiniMax Global', link: withBase('/ko/docs/manual/config/providers/consciousness/minimax-global') }, - { text: 'Mistral', link: withBase('/ko/docs/manual/config/providers/consciousness/mistral') }, - { text: 'Xiaomi MiMo', link: withBase('/ko/docs/manual/config/providers/consciousness/mimo') }, - { text: 'ModelScope', link: withBase('/ko/docs/manual/config/providers/consciousness/modelscope') }, - { text: 'Moonshot AI', link: withBase('/ko/docs/manual/config/providers/consciousness/moonshot') }, - { text: 'NVIDIA NIM', link: withBase('/ko/docs/manual/config/providers/consciousness/nvidia') }, - { text: 'n1n', link: withBase('/ko/docs/manual/config/providers/consciousness/n1n') }, - { text: 'Novita', link: withBase('/ko/docs/manual/config/providers/consciousness/novita') }, - { text: 'Perplexity', link: withBase('/ko/docs/manual/config/providers/consciousness/perplexity') }, - { text: 'Together.ai', link: withBase('/ko/docs/manual/config/providers/consciousness/together') }, - { text: 'Z.ai', link: withBase('/ko/docs/manual/config/providers/consciousness/zhipu') }, - { text: 'Volcengine Coding Plan', link: withBase('/ko/docs/manual/config/providers/consciousness/volcengine-coding-plan') }, - ] }, - { text: '음성 합성', collapsed: true, items: [ - { text: '공식 음성 합성 제공자', link: withBase('/ko/docs/manual/config/providers/speech/official') }, - { text: 'Alibaba Cloud Model Studio', link: withBase('/ko/docs/manual/config/providers/speech/alibaba-cloud-model-studio') }, - { text: '브라우저 (로컬)', link: withBase('/ko/docs/manual/config/providers/speech/browser-local') }, - { text: 'Comet API', link: withBase('/ko/docs/manual/config/providers/speech/comet-api') }, - { text: 'Deepgram', link: withBase('/ko/docs/manual/config/providers/speech/deepgram') }, - { text: '데스크톱 (로컬)', link: withBase('/ko/docs/manual/config/providers/speech/desktop-local') }, - { text: 'ElevenLabs', link: withBase('/ko/docs/manual/config/providers/speech/elevenlabs') }, - { text: 'Google Gemini', link: withBase('/ko/docs/manual/config/providers/speech/google-gemini') }, - { text: 'Bilibili / IndexTTS', link: withBase('/ko/docs/manual/config/providers/speech/index-tts') }, - { text: 'Kokoro TTS (로컬)', link: withBase('/ko/docs/manual/config/providers/speech/kokoro') }, - { text: 'Microsoft Azure Speech', link: withBase('/ko/docs/manual/config/providers/speech/azure-speech') }, - { text: 'MiniMax Speech (사용 불가)', link: withBase('/ko/docs/manual/config/providers/speech/minimax') }, - { text: 'Xiaomi MiMo', link: withBase('/ko/docs/manual/config/providers/speech/mimo') }, - { text: 'OpenAI & 호환 API', link: withBase('/ko/docs/manual/config/providers/speech/openai') }, - { text: 'OpenRouter', link: withBase('/ko/docs/manual/config/providers/speech/openrouter') }, - { text: 'Player2', link: withBase('/ko/docs/manual/config/providers/speech/player2') }, - { text: 'Volcano Engine', link: withBase('/ko/docs/manual/config/providers/speech/volcengine') }, - ] }, - { text: '전사', collapsed: true, items: [ - { text: '공식 전사 제공자', link: withBase('/ko/docs/manual/config/providers/transcription/official') }, - { text: 'Aliyun NLS', link: withBase('/ko/docs/manual/config/providers/transcription/aliyun') }, - { text: '브라우저 (로컬)', link: withBase('/ko/docs/manual/config/providers/transcription/browser-local') }, - { text: '브라우저 Web Speech API', link: withBase('/ko/docs/manual/config/providers/transcription/web-speech-api') }, - { text: 'Comet API', link: withBase('/ko/docs/manual/config/providers/transcription/comet-api') }, - { text: '데스크톱 (로컬)', link: withBase('/ko/docs/manual/config/providers/transcription/desktop-local') }, - { text: 'Xiaomi MiMo', link: withBase('/ko/docs/manual/config/providers/transcription/mimo') }, - { text: 'OpenAI & 호환 API', link: withBase('/ko/docs/manual/config/providers/transcription/openai') }, - ] }, - { text: 'Artistry', collapsed: true, items: [ - { text: 'ComfyUI (로컬 워크플로)', link: withBase('/ko/docs/manual/config/providers/artistry/comfyui') }, - { text: 'Nano Banana', link: withBase('/ko/docs/manual/config/providers/artistry/nanobanana') }, - { text: 'Replicate', link: withBase('/ko/docs/manual/config/providers/artistry/replicate') }, - ] }, - ] }, - ], - }, - ], - }, - { - text: '연동 서비스', - icon: 'lucide:plug', - items: [ - { - text: '게임', - items: [ - { text: 'Minecraft 에이전트', link: withBase('/ko/docs/integrations/minecraft') }, - { text: 'Factorio', link: withBase('/ko/docs/integrations/factorio') }, - ], - }, - { - text: '메시징 플랫폼', - items: [ - { text: 'Satori 봇', link: withBase('/ko/docs/integrations/satori') }, - { text: 'Telegram 봇', link: withBase('/ko/docs/integrations/telegram') }, - { text: 'Discord 봇', link: withBase('/ko/docs/integrations/discord') }, - { text: 'X / Twitter (사용 불가)', link: withBase('/ko/docs/integrations/x') }, - ], - }, - ], - }, - { - text: '개발자 가이드', - icon: 'lucide:code-2', - items: [ - { - text: '기여하기', - items: [ - { text: '개발 환경 설정과 사전 준비', link: withBase('/ko/docs/contributing/') }, - { text: '데스크톱 앱', link: withBase('/ko/docs/contributing/tamagotchi') }, - { text: '웹 앱', link: withBase('/ko/docs/contributing/webui') }, - { text: '문서 사이트', link: withBase('/ko/docs/contributing/docs') }, - ], - }, - { - text: '데스크톱 디버깅', - items: [ - { text: '개발자 도구', link: withBase('/ko/docs/contributing/desktop-developer-tools') }, - ], - }, - { - text: '디자인 가이드라인', - items: [ - { text: '소개', link: withBase('/ko/docs/contributing/design-guidelines/') }, - { text: '아티스트 & 개발자 (참고 자료)', link: withBase('/ko/docs/contributing/design-guidelines/resources') }, - { text: '도구', link: withBase('/ko/docs/contributing/design-guidelines/tools') }, - ], - }, - ], - }, - { - text: '연대기', - icon: 'lucide:calendar-days', - items: [ - { text: '첫 공개 v0.1.0', link: withBase('/ko/docs/chronicles/version-v0.1.0/') }, - { text: '그 이전 이야기 v0.0.1', link: withBase('/ko/docs/chronicles/version-v0.0.1/') }, - ], - }, - ] as (DefaultTheme.SidebarItem & { icon?: string })[], - homepage: { buttons: [ { - text: '라이브 데모 체험하기', link: webLive, primary: true, target: '_self', + text: '라이브 데모 체험하기', }, { - text: '다운로드', link: withBase('/ko/docs/overview/versions'), + text: '다운로드', }, { - text: '시작하기', link: withBase('/ko/docs/overview/'), + text: '시작하기', }, ], }, + langMenuLabel: '언어 변경', + lastUpdated: { + text: '마지막 업데이트', + }, + logo: withBase('/favicon.svg'), + // https://vitepress.dev/reference/default-theme-config + nav: [ + { link: withBase('/ko/docs/overview/'), text: '문서' }, + { link: withBase('/ko/blog/'), text: '블로그' }, + { + items: [ + { link: releases, text: '릴리스 노트' }, + ], + text: `v${version}`, + }, + { + items: [ + { link: withBase('/ko/about/privacy'), text: '개인정보 처리방침' }, + { link: withBase('/ko/about/terms'), text: '이용약관' }, + ], + text: '소개', + }, + ], + outline: { + label: '이 페이지의 내용', + level: 'deep', + }, + returnToTopLabel: '맨 위로', + + sidebar: [ + { + icon: 'lucide:rocket', + items: [ + { link: withBase('/ko/docs/overview/'), text: '소개' }, + { link: withBase('/ko/docs/overview/versions'), text: '버전과 다운로드' }, + { link: withBase('/ko/docs/overview/about-ai-vtuber'), text: 'AI VTuber란' }, + { link: withBase('/ko/docs/overview/about-neuro-sama'), text: 'Neuro-sama란' }, + { link: withBase('/ko/docs/overview/other-similar-projects'), text: '비슷한 다른 프로젝트들' }, + ], + text: '개요', + }, + { + icon: 'lucide:book-open', + items: [ + { + items: [ + { link: withBase('/ko/docs/manual/tamagotchi/'), text: '데스크톱 버전' }, + { link: withBase('/ko/docs/manual/web/'), text: '웹 버전' }, + ], + text: '빠른 시작', + }, + { link: withBase('/ko/docs/manual/tamagotchi/setup-and-use/'), text: '설치와 사용' }, + { + items: [ + { link: withBase('/ko/docs/manual/config/'), text: '설정 가이드' }, + { link: withBase('/ko/docs/manual/config/common'), text: '공통 설정' }, + { collapsed: true, items: [ + { link: withBase('/ko/docs/manual/config/llm'), text: '채팅 모델' }, + { link: withBase('/ko/docs/manual/config/audio'), text: '오디오 입출력' }, + { link: withBase('/ko/docs/manual/config/vision'), text: '비전' }, + { link: withBase('/ko/docs/manual/config/web-search'), text: '웹 검색' }, + ], text: '기능 설정' }, + { collapsed: true, items: [ + { collapsed: true, items: [ + { link: withBase('/ko/docs/manual/config/providers/consciousness/official'), text: 'AIRI 공식 제공자' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/aihubmix'), text: 'AIHubMix' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/amazon-bedrock'), text: 'Amazon Bedrock' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/anthropic'), text: 'Anthropic' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/atlascloud'), text: 'Atlas Cloud' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/azure-ai-foundry'), text: 'Azure AI Foundry' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/azure-openai'), text: 'Azure OpenAI' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/byteplus'), text: 'BytePlus' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/byteplus-coding-plan'), text: 'BytePlus Coding Plan' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/cerebras'), text: 'Cerebras' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/comet-api'), text: 'Comet API' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/google-gemini'), text: 'Google Gemini' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/xai'), text: 'xAI' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/cloudflare-workers-ai'), text: 'Cloudflare Workers AI' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/lm-studio'), text: 'LM Studio (로컬 모델)' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/openpaths'), text: 'OpenPaths' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/openrouter'), text: 'OpenRouter' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/ollama'), text: 'Ollama' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/deepseek'), text: 'DeepSeek' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/openai'), text: 'OpenAI & 호환 API' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/302ai'), text: '302.AI' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/fireworks'), text: 'Fireworks.ai' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/featherless'), text: 'Featherless AI' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/groq'), text: 'Groq' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/minimax'), text: 'MiniMax' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/minimax-global'), text: 'MiniMax Global' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/mistral'), text: 'Mistral' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/mimo'), text: 'Xiaomi MiMo' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/modelscope'), text: 'ModelScope' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/moonshot'), text: 'Moonshot AI' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/nvidia'), text: 'NVIDIA NIM' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/n1n'), text: 'n1n' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/novita'), text: 'Novita' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/perplexity'), text: 'Perplexity' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/together'), text: 'Together.ai' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/zhipu'), text: 'Z.ai' }, + { link: withBase('/ko/docs/manual/config/providers/consciousness/volcengine-coding-plan'), text: 'Volcengine Coding Plan' }, + ], text: '채팅' }, + { collapsed: true, items: [ + { link: withBase('/ko/docs/manual/config/providers/speech/official'), text: '공식 음성 합성 제공자' }, + { link: withBase('/ko/docs/manual/config/providers/speech/alibaba-cloud-model-studio'), text: 'Alibaba Cloud Model Studio' }, + { link: withBase('/ko/docs/manual/config/providers/speech/browser-local'), text: '브라우저 (로컬)' }, + { link: withBase('/ko/docs/manual/config/providers/speech/comet-api'), text: 'Comet API' }, + { link: withBase('/ko/docs/manual/config/providers/speech/deepgram'), text: 'Deepgram' }, + { link: withBase('/ko/docs/manual/config/providers/speech/desktop-local'), text: '데스크톱 (로컬)' }, + { link: withBase('/ko/docs/manual/config/providers/speech/elevenlabs'), text: 'ElevenLabs' }, + { link: withBase('/ko/docs/manual/config/providers/speech/google-gemini'), text: 'Google Gemini' }, + { link: withBase('/ko/docs/manual/config/providers/speech/index-tts'), text: 'Bilibili / IndexTTS' }, + { link: withBase('/ko/docs/manual/config/providers/speech/kokoro'), text: 'Kokoro TTS (로컬)' }, + { link: withBase('/ko/docs/manual/config/providers/speech/azure-speech'), text: 'Microsoft Azure Speech' }, + { link: withBase('/ko/docs/manual/config/providers/speech/minimax'), text: 'MiniMax Speech (사용 불가)' }, + { link: withBase('/ko/docs/manual/config/providers/speech/mimo'), text: 'Xiaomi MiMo' }, + { link: withBase('/ko/docs/manual/config/providers/speech/openai'), text: 'OpenAI & 호환 API' }, + { link: withBase('/ko/docs/manual/config/providers/speech/openrouter'), text: 'OpenRouter' }, + { link: withBase('/ko/docs/manual/config/providers/speech/player2'), text: 'Player2' }, + { link: withBase('/ko/docs/manual/config/providers/speech/volcengine'), text: 'Volcano Engine' }, + ], text: '음성 합성' }, + { collapsed: true, items: [ + { link: withBase('/ko/docs/manual/config/providers/transcription/official'), text: '공식 전사 제공자' }, + { link: withBase('/ko/docs/manual/config/providers/transcription/aliyun'), text: 'Aliyun NLS' }, + { link: withBase('/ko/docs/manual/config/providers/transcription/browser-local'), text: '브라우저 (로컬)' }, + { link: withBase('/ko/docs/manual/config/providers/transcription/web-speech-api'), text: '브라우저 Web Speech API' }, + { link: withBase('/ko/docs/manual/config/providers/transcription/comet-api'), text: 'Comet API' }, + { link: withBase('/ko/docs/manual/config/providers/transcription/desktop-local'), text: '데스크톱 (로컬)' }, + { link: withBase('/ko/docs/manual/config/providers/transcription/mimo'), text: 'Xiaomi MiMo' }, + { link: withBase('/ko/docs/manual/config/providers/transcription/openai'), text: 'OpenAI & 호환 API' }, + ], text: '전사' }, + { collapsed: true, items: [ + { link: withBase('/ko/docs/manual/config/providers/artistry/comfyui'), text: 'ComfyUI (로컬 워크플로)' }, + { link: withBase('/ko/docs/manual/config/providers/artistry/nanobanana'), text: 'Nano Banana' }, + { link: withBase('/ko/docs/manual/config/providers/artistry/replicate'), text: 'Replicate' }, + ], text: 'Artistry' }, + ], text: '서비스 제공자' }, + ], + text: '설정', + }, + ], + link: withBase('/ko/docs/manual/'), + text: '사용 설명서', + }, + { + icon: 'lucide:plug', + items: [ + { + items: [ + { link: withBase('/ko/docs/integrations/minecraft'), text: 'Minecraft 에이전트' }, + { link: withBase('/ko/docs/integrations/factorio'), text: 'Factorio' }, + ], + text: '게임', + }, + { + items: [ + { link: withBase('/ko/docs/integrations/satori'), text: 'Satori 봇' }, + { link: withBase('/ko/docs/integrations/telegram'), text: 'Telegram 봇' }, + { link: withBase('/ko/docs/integrations/discord'), text: 'Discord 봇' }, + { link: withBase('/ko/docs/integrations/x'), text: 'X / Twitter (사용 불가)' }, + ], + text: '메시징 플랫폼', + }, + ], + text: '연동 서비스', + }, + { + icon: 'lucide:code-2', + items: [ + { + items: [ + { link: withBase('/ko/docs/contributing/'), text: '개발 환경 설정과 사전 준비' }, + { link: withBase('/ko/docs/contributing/tamagotchi'), text: '데스크톱 앱' }, + { link: withBase('/ko/docs/contributing/webui'), text: '웹 앱' }, + { link: withBase('/ko/docs/contributing/docs'), text: '문서 사이트' }, + ], + text: '기여하기', + }, + { + items: [ + { link: withBase('/ko/docs/contributing/desktop-developer-tools'), text: '개발자 도구' }, + ], + text: '데스크톱 디버깅', + }, + { + items: [ + { link: withBase('/ko/docs/contributing/design-guidelines/'), text: '소개' }, + { link: withBase('/ko/docs/contributing/design-guidelines/resources'), text: '아티스트 & 개발자 (참고 자료)' }, + { link: withBase('/ko/docs/contributing/design-guidelines/tools'), text: '도구' }, + ], + text: '디자인 가이드라인', + }, + ], + text: '개발자 가이드', + }, + { + icon: 'lucide:calendar-days', + items: [ + { link: withBase('/ko/docs/chronicles/version-v0.1.0/'), text: '첫 공개 v0.1.0' }, + { link: withBase('/ko/docs/chronicles/version-v0.0.1/'), text: '그 이전 이야기 v0.0.1' }, + ], + text: '연대기', + }, + ] as (DefaultTheme.SidebarItem & { icon?: string })[], + + sidebarMenuLabel: '메뉴', + }, + }, + 'root': { + label: 'English', + lang: 'en', + themeConfig: { + darkModeSwitchLabel: 'Appearance', + docFooter: { + next: 'Next page', + prev: 'Previous page', + }, + editLink: { + pattern: 'https://github.com/moeru-ai/airi/edit/main/docs/content/:path', + text: 'Edit this page on GitHub', + }, + homepage: { + buttons: [ + { + link: webLive, + primary: true, + target: '_self', + text: 'Try Live', + }, + { + link: withBase('/en/docs/overview/versions'), + text: 'Download', + }, + { + link: withBase('/en/docs/overview/'), + text: 'Get Started', + }, + ], + }, + langMenuLabel: 'Change language', + lastUpdated: { + text: 'Last updated', + }, + logo: withBase('/favicon.svg'), + // https://vitepress.dev/reference/default-theme-config + nav: [ + { link: withBase('/en/docs/overview/'), text: 'Docs' }, + { link: withBase('/en/blog/'), text: 'Blog' }, + { + items: [ + { link: releases, text: 'Release Notes ' }, + ], + text: `v${version}`, + }, + { + items: [ + { link: withBase('/en/about/privacy'), text: 'Privacy Policy' }, + { link: withBase('/en/about/terms'), text: 'Terms of Use' }, + ], + text: 'About', + }, + ], + outline: { + label: 'On this page', + level: 'deep', + }, + returnToTopLabel: 'Return to top', + + sidebar: [ + { + icon: 'lucide:rocket', + items: [ + { link: withBase('/en/docs/overview/'), text: 'Introduction' }, + { link: withBase('/en/docs/overview/versions'), text: 'Versions & Downloads' }, + { link: withBase('/en/docs/overview/about-ai-vtuber'), text: 'About AI VTuber' }, + { link: withBase('/en/docs/overview/about-neuro-sama'), text: 'About Neuro-sama' }, + { link: withBase('/en/docs/overview/other-similar-projects'), text: 'Other Similar Projects' }, + ], + text: 'Overview', + }, + { + icon: 'lucide:book-open', + items: [ + { + items: [ + { link: withBase('/en/docs/manual/tamagotchi/'), text: 'Desktop ver.' }, + { link: withBase('/en/docs/manual/web/'), text: 'Web Version' }, + ], + text: 'Quick Start', + }, + { link: withBase('/en/docs/manual/tamagotchi/setup-and-use/'), text: 'Setup and Use' }, + { + items: [ + { link: withBase('/en/docs/manual/config/'), text: 'Configuration Guide' }, + { link: withBase('/en/docs/manual/config/common'), text: 'Common Setup' }, + { collapsed: true, items: [ + { link: withBase('/en/docs/manual/config/llm'), text: 'Chat Models' }, + { link: withBase('/en/docs/manual/config/audio'), text: 'Audio Input and Output' }, + { link: withBase('/en/docs/manual/config/vision'), text: 'Vision' }, + { link: withBase('/en/docs/manual/config/web-search'), text: 'Web Search' }, + ], text: 'Feature Configuration' }, + { collapsed: true, items: [ + { collapsed: true, items: [ + { link: withBase('/en/docs/manual/config/providers/consciousness/official'), text: 'AIRI Official Provider' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/aihubmix'), text: 'AIHubMix' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/amazon-bedrock'), text: 'Amazon Bedrock' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/anthropic'), text: 'Anthropic' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/atlascloud'), text: 'Atlas Cloud' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/azure-ai-foundry'), text: 'Azure AI Foundry' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/azure-openai'), text: 'Azure OpenAI' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/byteplus'), text: 'BytePlus' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/byteplus-coding-plan'), text: 'BytePlus Coding Plan' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/cerebras'), text: 'Cerebras' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/comet-api'), text: 'Comet API' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/google-gemini'), text: 'Google Gemini' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/xai'), text: 'xAI' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/cloudflare-workers-ai'), text: 'Cloudflare Workers AI' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/lm-studio'), text: 'LM Studio (Local Model)' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/openpaths'), text: 'OpenPaths' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/openrouter'), text: 'OpenRouter' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/ollama'), text: 'Ollama' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/deepseek'), text: 'DeepSeek' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/openai'), text: 'OpenAI & Compatible APIs' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/302ai'), text: '302.AI' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/fireworks'), text: 'Fireworks.ai' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/featherless'), text: 'Featherless AI' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/groq'), text: 'Groq' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/minimax'), text: 'MiniMax' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/minimax-global'), text: 'MiniMax Global' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/mistral'), text: 'Mistral' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/mimo'), text: 'Xiaomi MiMo' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/modelscope'), text: 'ModelScope' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/moonshot'), text: 'Moonshot AI' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/nvidia'), text: 'NVIDIA NIM' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/n1n'), text: 'n1n' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/novita'), text: 'Novita' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/perplexity'), text: 'Perplexity' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/together'), text: 'Together.ai' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/zhipu'), text: 'Z.ai' }, + { link: withBase('/en/docs/manual/config/providers/consciousness/volcengine-coding-plan'), text: 'Volcengine Coding Plan' }, + ], text: 'Chat' }, + { collapsed: true, items: [ + { link: withBase('/en/docs/manual/config/providers/speech/official'), text: 'Official Speech Provider' }, + { link: withBase('/en/docs/manual/config/providers/speech/alibaba-cloud-model-studio'), text: 'Alibaba Cloud Model Studio' }, + { link: withBase('/en/docs/manual/config/providers/speech/browser-local'), text: 'Browser (Local)' }, + { link: withBase('/en/docs/manual/config/providers/speech/comet-api'), text: 'Comet API' }, + { link: withBase('/en/docs/manual/config/providers/speech/deepgram'), text: 'Deepgram' }, + { link: withBase('/en/docs/manual/config/providers/speech/desktop-local'), text: 'Desktop (Local)' }, + { link: withBase('/en/docs/manual/config/providers/speech/elevenlabs'), text: 'ElevenLabs' }, + { link: withBase('/en/docs/manual/config/providers/speech/google-gemini'), text: 'Google Gemini' }, + { link: withBase('/en/docs/manual/config/providers/speech/index-tts'), text: 'Bilibili / IndexTTS' }, + { link: withBase('/en/docs/manual/config/providers/speech/kokoro'), text: 'Kokoro TTS (Local)' }, + { link: withBase('/en/docs/manual/config/providers/speech/azure-speech'), text: 'Microsoft Azure Speech' }, + { link: withBase('/en/docs/manual/config/providers/speech/minimax'), text: 'MiniMax Speech (Unavailable)' }, + { link: withBase('/en/docs/manual/config/providers/speech/mimo'), text: 'Xiaomi MiMo' }, + { link: withBase('/en/docs/manual/config/providers/speech/openai'), text: 'OpenAI & Compatible APIs' }, + { link: withBase('/en/docs/manual/config/providers/speech/openrouter'), text: 'OpenRouter' }, + { link: withBase('/en/docs/manual/config/providers/speech/player2'), text: 'Player2' }, + { link: withBase('/en/docs/manual/config/providers/speech/volcengine'), text: 'Volcano Engine' }, + ], text: 'Speech' }, + { collapsed: true, items: [ + { link: withBase('/en/docs/manual/config/providers/transcription/official'), text: 'Official Transcription Provider' }, + { link: withBase('/en/docs/manual/config/providers/transcription/aliyun'), text: 'Aliyun NLS' }, + { link: withBase('/en/docs/manual/config/providers/transcription/browser-local'), text: 'Browser (Local)' }, + { link: withBase('/en/docs/manual/config/providers/transcription/web-speech-api'), text: 'Browser Web Speech API' }, + { link: withBase('/en/docs/manual/config/providers/transcription/comet-api'), text: 'Comet API' }, + { link: withBase('/en/docs/manual/config/providers/transcription/desktop-local'), text: 'Desktop (Local)' }, + { link: withBase('/en/docs/manual/config/providers/transcription/mimo'), text: 'Xiaomi MiMo' }, + { link: withBase('/en/docs/manual/config/providers/transcription/openai'), text: 'OpenAI & Compatible APIs' }, + ], text: 'Transcription' }, + { collapsed: true, items: [ + { link: withBase('/en/docs/manual/config/providers/artistry/comfyui'), text: 'ComfyUI (Local Workflow)' }, + { link: withBase('/en/docs/manual/config/providers/artistry/nanobanana'), text: 'Nano Banana' }, + { link: withBase('/en/docs/manual/config/providers/artistry/replicate'), text: 'Replicate' }, + ], text: 'Artistry' }, + ], text: 'Service Providers' }, + ], + text: 'Configuration', + }, + ], + link: withBase('/en/docs/manual/'), + text: 'Manual', + }, + { + icon: 'lucide:plug', + items: [ + { + items: [ + { link: withBase('/en/docs/integrations/minecraft'), text: 'Minecraft Agent' }, + { link: withBase('/en/docs/integrations/factorio'), text: 'Factorio' }, + ], + text: 'Games', + }, + { + items: [ + { link: withBase('/en/docs/integrations/satori'), text: 'Satori Bot' }, + { link: withBase('/en/docs/integrations/telegram'), text: 'Telegram Bot' }, + { link: withBase('/en/docs/integrations/discord'), text: 'Discord Bot' }, + { link: withBase('/en/docs/integrations/x'), text: 'X / Twitter (Unavailable)' }, + ], + text: 'Messaging Platforms', + }, + ], + text: 'Integration Services', + }, + { + icon: 'lucide:code-2', + items: [ + { + items: [ + { link: withBase('/en/docs/contributing/'), text: 'Development Setup & First Contribution' }, + { link: withBase('/en/docs/contributing/tamagotchi'), text: 'Desktop App' }, + { link: withBase('/en/docs/contributing/webui'), text: 'Web App' }, + { link: withBase('/en/docs/contributing/docs'), text: 'Documentation Site' }, + ], + text: 'Contributing', + }, + { + items: [ + { link: withBase('/en/docs/contributing/desktop-developer-tools'), text: 'Developer Tools' }, + ], + text: 'Desktop Debugging', + }, + { + items: [ + { link: withBase('/en/docs/contributing/design-guidelines/'), text: 'Introduction' }, + { link: withBase('/en/docs/contributing/design-guidelines/resources'), text: 'Artists & Developers (Resources)' }, + { link: withBase('/en/docs/contributing/design-guidelines/tools'), text: 'Tools' }, + ], + text: 'Design Guidelines', + }, + ], + text: 'Developer Guide', + }, + { + icon: 'lucide:calendar-days', + items: [ + { link: withBase('/en/docs/chronicles/version-v0.1.0/'), text: 'Initial Publish v0.1.0' }, + { link: withBase('/en/docs/chronicles/version-v0.0.1/'), text: 'Before Story v0.0.1' }, + ], + text: 'Chronicles', + }, + ] as (DefaultTheme.SidebarItem & { icon?: string })[], + + sidebarMenuLabel: 'Menu', + }, + }, + 'zh-Hans': { + label: '简体中文', + lang: 'zh-Hans', + themeConfig: { + darkModeSwitchLabel: '外观模式', + docFooter: { + next: '下一页', + prev: '上一页', + }, + editLink: { + pattern: 'https://github.com/moeru-ai/airi/edit/main/docs/content/:path', + text: '在 GitHub 编辑此页', + }, + homepage: { + buttons: [ + { + link: webLive, + primary: true, + target: '_self', + text: '网页版', + }, + { + link: withBase('/zh-Hans/docs/overview/versions'), + text: '下载', + }, + { + link: withBase('/zh-Hans/docs/overview/'), + text: '使用教程', + }, + ], + }, + langMenuLabel: '切换语言', + lastUpdated: { + text: '最后更新', + }, + logo: withBase('/favicon.svg'), + // https://vitepress.dev/reference/default-theme-config + nav: [ + { link: withBase('/zh-Hans/docs/overview/'), text: '文档' }, + { link: withBase('/zh-Hans/blog/'), text: '博客 / 开发日志' }, + { + items: [ + { link: releases, text: '发布说明 ' }, + ], + text: `v${version}`, + }, + { + items: [ + { link: withBase('/zh-Hans/about/privacy'), text: '隐私政策' }, + { link: withBase('/zh-Hans/about/terms'), text: '使用条款' }, + ], + text: '关于', + }, + ], + outline: { + label: '本页内容', + level: 'deep', + }, + returnToTopLabel: '返回顶部', + + sidebar: [ + { + icon: 'lucide:rocket', + items: [ + { link: withBase('/zh-Hans/docs/overview/'), text: '这是什么项目?' }, + { link: withBase('/zh-Hans/docs/overview/versions'), text: '版本与下载' }, + { link: withBase('/zh-Hans/docs/overview/about-ai-vtuber'), text: '有关 AI VTuber' }, + { link: withBase('/zh-Hans/docs/overview/about-neuro-sama'), text: '有关 Neuro-sama' }, + { link: withBase('/zh-Hans/docs/overview/other-similar-projects'), text: '其他类似项目' }, + { + collapsed: true, + items: [ + { link: withBase('/zh-Hans/docs/chronicles/version-v0.1.0/'), text: '首次公开 v0.1.0' }, + { link: withBase('/zh-Hans/docs/chronicles/version-v0.0.1/'), text: '先前的故事 v0.0.1' }, + ], + text: '编年史', + }, + ], + text: '概览', + }, + { + icon: 'lucide:book-open', + items: [ + { + items: [ + { link: withBase('/zh-Hans/docs/manual/tamagotchi/'), text: '桌面版' }, + { link: withBase('/zh-Hans/docs/manual/web/'), text: '网页版' }, + ], + text: '快速开始', + }, + { + link: withBase('/zh-Hans/docs/manual/tamagotchi/setup-and-use/'), + text: '安装与使用', + }, + { + items: [ + { link: withBase('/zh-Hans/docs/manual/config/'), text: '配置指南' }, + { link: withBase('/zh-Hans/docs/manual/config/common'), text: '通用说明' }, + { collapsed: true, items: [ + { link: withBase('/zh-Hans/docs/manual/config/llm'), text: '聊天模型' }, + { link: withBase('/zh-Hans/docs/manual/config/audio'), text: '语音输入与输出' }, + { link: withBase('/zh-Hans/docs/manual/config/vision'), text: '视觉理解' }, + { link: withBase('/zh-Hans/docs/manual/config/web-search'), text: '网络搜索' }, + ], text: '功能配置' }, + { collapsed: true, items: [ + { collapsed: true, items: [ + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/official'), text: 'AIRI 官方提供商' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/aihubmix'), text: 'AIHubMix' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/amazon-bedrock'), text: 'Amazon Bedrock' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/anthropic'), text: 'Anthropic' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/atlascloud'), text: 'Atlas Cloud' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/azure-ai-foundry'), text: 'Azure AI Foundry' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/azure-openai'), text: 'Azure OpenAI' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/byteplus'), text: 'BytePlus' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/byteplus-coding-plan'), text: 'BytePlus Coding Plan' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/cerebras'), text: 'Cerebras' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/comet-api'), text: 'CometAPI' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/google-gemini'), text: 'Google Gemini' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/xai'), text: 'xAI' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/cloudflare-workers-ai'), text: 'Cloudflare Workers AI' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/lm-studio'), text: 'LM Studio(本地模型)' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/openpaths'), text: 'OpenPaths' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/openrouter'), text: 'OpenRouter' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/ollama'), text: 'Ollama' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/deepseek'), text: '深度求索 DeepSeek' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/openai'), text: 'OpenAI 与兼容 API' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/302ai'), text: '302.ai' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/fireworks'), text: 'Fireworks AI' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/featherless'), text: 'Featherless.ai' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/groq'), text: 'Groq' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/minimax'), text: 'MiniMax' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/minimax-global'), text: 'MiniMax Global' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/mistral'), text: 'Mistral' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/mimo'), text: '小米 MiMo' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/modelscope'), text: 'ModelScope' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/moonshot'), text: '月之暗面' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/nvidia'), text: 'NVIDIA NIM' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/n1n'), text: 'n1n' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/novita'), text: 'Novita' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/perplexity'), text: 'Perplexity' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/together'), text: 'Together.ai' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/zhipu'), text: 'Z.ai' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/consciousness/volcengine-coding-plan'), text: '火山引擎 Coding Plan' }, + ], text: '聊天服务商' }, + { collapsed: true, items: [ + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/official'), text: 'AIRI 官方语音合成' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/alibaba-cloud-model-studio'), text: '阿里云百炼' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/browser-local'), text: '浏览器本地语音合成' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/comet-api'), text: 'CometAPI' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/deepgram'), text: 'Deepgram' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/desktop-local'), text: '桌面端本地语音合成' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/elevenlabs'), text: 'ElevenLabs' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/google-gemini'), text: 'Google Gemini' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/index-tts'), text: 'Index-TTS' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/kokoro'), text: 'Kokoro' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/azure-speech'), text: 'Microsoft Azure Speech' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/minimax'), text: 'MiniMax Speech' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/mimo'), text: '小米 MiMo' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/openai'), text: 'OpenAI 与兼容 API' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/openrouter'), text: 'OpenRouter' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/player2'), text: 'Player2 Speech' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/speech/volcengine'), text: '火山引擎' }, + ], text: '语音合成(TTS)' }, + { collapsed: true, items: [ + { link: withBase('/zh-Hans/docs/manual/config/providers/transcription/official'), text: 'AIRI 官方语音识别' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/transcription/aliyun'), text: '阿里云智能语音服务' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/transcription/browser-local'), text: '浏览器本地语音识别' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/transcription/web-speech-api'), text: '浏览器 Web Speech API' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/transcription/comet-api'), text: 'CometAPI' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/transcription/desktop-local'), text: '桌面端本地语音识别' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/transcription/mimo'), text: '小米 MiMo' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/transcription/openai'), text: 'OpenAI 与兼容 API' }, + ], text: '语音识别(ASR/STT)' }, + { collapsed: true, items: [ + { link: withBase('/zh-Hans/docs/manual/config/providers/artistry/comfyui'), text: 'ComfyUI(本地工作流)' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/artistry/nanobanana'), text: 'Nano Banana' }, + { link: withBase('/zh-Hans/docs/manual/config/providers/artistry/replicate'), text: 'Replicate' }, + ], text: '艺术创作服务商' }, + ], text: '服务商' }, + ], + text: '配置', + }, + ], + link: withBase('/zh-Hans/docs/manual/'), + text: '用户手册', + }, + { + icon: 'lucide:plug', + items: [ + { + items: [ + { link: withBase('/zh-Hans/docs/integrations/minecraft'), text: 'Minecraft 智能体' }, + { link: withBase('/zh-Hans/docs/integrations/factorio'), text: '异星工厂' }, + ], + text: '游戏', + }, + { + items: [ + { link: withBase('/zh-Hans/docs/integrations/satori'), text: 'Satori 机器人' }, + { link: withBase('/zh-Hans/docs/integrations/telegram'), text: 'Telegram 机器人' }, + { link: withBase('/zh-Hans/docs/integrations/discord'), text: 'Discord 机器人' }, + { link: withBase('/zh-Hans/docs/integrations/x'), text: 'X / Twitter' }, + ], + text: '消息平台', + }, + ], + text: '集成服务', + }, + { + icon: 'lucide:code-2', + items: [ + { + items: [ + { link: withBase('/zh-Hans/docs/contributing/'), text: '开发环境与首次贡献' }, + { link: withBase('/zh-Hans/docs/contributing/tamagotchi'), text: '桌面端' }, + { link: withBase('/zh-Hans/docs/contributing/webui'), text: '网页端' }, + { link: withBase('/zh-Hans/docs/contributing/docs'), text: '文档站' }, + ], + text: '参与贡献', + }, + { + items: [ + { link: withBase('/zh-Hans/docs/contributing/desktop-developer-tools'), text: '开发者工具' }, + ], + text: '桌面端调试', + }, + { + items: [ + { link: withBase('/zh-Hans/docs/contributing/design-guidelines/'), text: '介绍' }, + { link: withBase('/zh-Hans/docs/contributing/design-guidelines/resources'), text: '艺术家与开发者 (参考资源)' }, + { link: withBase('/zh-Hans/docs/contributing/design-guidelines/tools'), text: '工具' }, + ], + text: '设计指南', + }, + ], + text: '开发者指南', + }, + ] as (DefaultTheme.SidebarItem & { icon?: string })[], + + sidebarMenuLabel: '菜单', }, }, }, - themeConfig: { - socialLinks: [ - { icon: 'x', link: x }, - { icon: 'discord', link: discord }, - { icon: 'github', link: github }, - ], - search: { - provider: 'local', - }, - editLink: { - pattern: 'https://github.com/moeru-ai/airi/edit/main/docs/content/:path', - }, - }, - srcDir: 'content', - appearance: 'dark', markdown: { - theme: { - light: 'catppuccin-latte', - dark: 'catppuccin-mocha', - }, - headers: { - level: [2, 3, 4, 5, 6], - }, - config(md) { - md.use(tasklist) - md.use(footnote) - }, anchor: { callback(token) { // set tw `group` modifier to heading element @@ -975,7 +945,6 @@ export default defineConfig({ permalink: anchor.permalink.linkInsideHeader({ class: 'header-anchor [&_span]:focus:opacity-100 [&_span_>_span]:focus:outline', - symbol: ``, renderAttrs: (slug, state) => { // From: https://github.com/vuejs/vitepress/blob/256d742b733bfb62d54c78168b0e867b8eb829c9/src/node/markdown/markdown.ts#L263 // Find `heading_open` with the id identical to slug @@ -990,9 +959,40 @@ export default defineConfig({ 'aria-label': `Permalink to "${title}"`, } }, + symbol: ``, }), }, + config(md) { + md.use(tasklist) + md.use(footnote) + }, + headers: { + level: [2, 3, 4, 5, 6], + }, + theme: { + dark: 'catppuccin-mocha', + light: 'catppuccin-latte', + }, }, + sitemap: { + hostname: ogUrl, + }, + srcDir: 'content', + themeConfig: { + editLink: { + pattern: 'https://github.com/moeru-ai/airi/edit/main/docs/content/:path', + }, + search: { + provider: 'local', + }, + socialLinks: [ + { icon: 'x', link: x }, + { icon: 'discord', link: discord }, + { icon: 'github', link: github }, + ], + }, + title: projectName, + titleTemplate: projectShortName, transformPageData(pageData) { if (pageData.frontmatter.sidebar != null) return @@ -1001,19 +1001,6 @@ export default defineConfig({ pageData.frontmatter.sidebar = pageData.frontmatter.layout !== 'showcase' }, vite: { - resolve: { - alias: { - '@proj-airi/stage-ui/components': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'components')), - '@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')), - }, - }, - plugins: [ - // Thanks https://github.com/intlify/vue-i18n/issues/1205#issuecomment-2707075660 - i18n({ runtimeOnly: true, compositionOnly: true, fullInstall: true, ssr: true }), - unocss(), - yaml(), - frontmatterAssets(), - ], css: { postcss: { plugins: [ @@ -1021,5 +1008,18 @@ export default defineConfig({ ], }, }, + plugins: [ + // Thanks https://github.com/intlify/vue-i18n/issues/1205#issuecomment-2707075660 + i18n({ compositionOnly: true, fullInstall: true, runtimeOnly: true, ssr: true }), + unocss(), + yaml(), + frontmatterAssets(), + ], + resolve: { + alias: { + '@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')), + '@proj-airi/stage-ui/components': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'components')), + }, + }, }, }) diff --git a/docs/.vitepress/contributors.ts b/docs/.vitepress/contributors.ts index d53e0a7c5..81311949a 100644 --- a/docs/.vitepress/contributors.ts +++ b/docs/.vitepress/contributors.ts @@ -3,16 +3,16 @@ import type { DefaultTheme } from 'vitepress' import contributorNames from './contributor-names.json' export interface Contributor { - name: string avatar: string + name: string } export interface CoreTeam extends DefaultTheme.TeamMember { + discord?: string // required to download avatars from GitHub github: string - twitter?: string mastodon?: string - discord?: string + twitter?: string youtube?: string } @@ -24,7 +24,7 @@ function getAvatarUrl(name: string) { export const contributors = (contributorNames as string[]).reduce((acc, name) => { contributorsAvatars[name] = getAvatarUrl(name) - acc.push({ name, avatar: contributorsAvatars[name] }) + acc.push({ avatar: contributorsAvatars[name], name }) return acc }, [] as Contributor[]) function createLinks(tm: CoreTeam): CoreTeam { diff --git a/docs/.vitepress/data/releases.data.ts b/docs/.vitepress/data/releases.data.ts index f32dd792a..67fa2b0b9 100644 --- a/docs/.vitepress/data/releases.data.ts +++ b/docs/.vitepress/data/releases.data.ts @@ -1,33 +1,33 @@ import { defineLoader } from 'vitepress' -export interface Release { - name: string - tag_name: string - html_url: string - published_at: string - prerelease: boolean - draft: boolean - body: string -} - export interface NightlyBuild { + conclusion: string + created_at: string + head_commit_message: string + head_sha: string + html_url: string id: number name: string - html_url: string - created_at: string - updated_at: string status: string - conclusion: string + updated_at: string workflow_name: string - head_sha: string - head_commit_message: string +} + +export interface Release { + body: string + draft: boolean + html_url: string + name: string + prerelease: boolean + published_at: string + tag_name: string } export interface ReleasesData { - stable: Release[] - prerelease: Release[] nightly: NightlyBuild[] nightlyUrl: string + prerelease: Release[] + stable: Release[] } declare const data: ReleasesData @@ -91,17 +91,17 @@ export default defineLoader({ if (actionsResponse.ok) { const actionsData = await actionsResponse.json() nightlyBuilds = actionsData.workflow_runs?.map((run: { - id: number - name: string - head_sha: string - html_url: string - created_at: string - updated_at: string - status: string conclusion: string + created_at: string head_commit?: { message: string } + head_sha: string + html_url: string + id: number + name: string + status: string + updated_at: string }) => { const shortSha = run.head_sha.substring(0, 7) // Get first line of commit message @@ -109,16 +109,16 @@ export default defineLoader({ const firstLine = commitMessage.split('\n')[0] return { + conclusion: run.conclusion, + created_at: run.created_at, + head_commit_message: commitMessage, + head_sha: shortSha, + html_url: run.html_url, id: run.id, name: firstLine, - html_url: run.html_url, - created_at: run.created_at, - updated_at: run.updated_at, status: run.status, - conclusion: run.conclusion, + updated_at: run.updated_at, workflow_name: run.name, - head_sha: shortSha, - head_commit_message: commitMessage, } }) || [] } @@ -128,20 +128,20 @@ export default defineLoader({ } return { - stable, - prerelease, nightly: nightlyBuilds, nightlyUrl, + prerelease, + stable, } } catch (error) { console.error('Failed to fetch releases:', error) // Return empty data if fetch fails return { - stable: [], - prerelease: [], nightly: [], nightlyUrl, + prerelease: [], + stable: [], } } }, diff --git a/docs/.vitepress/functions/all-documents.data.ts b/docs/.vitepress/functions/all-documents.data.ts index 5c708ca47..866435e93 100644 --- a/docs/.vitepress/functions/all-documents.data.ts +++ b/docs/.vitepress/functions/all-documents.data.ts @@ -7,12 +7,12 @@ import { formatDate } from '../utils/utils' const config: SiteConfig = (globalThis as any).VITEPRESS_CONFIG interface Document { + date: ReturnType + frontmatter?: Record + lang: string title: string url: string urlWithoutLang: string - lang: string - date: ReturnType - frontmatter?: Record } declare const data: Document[] @@ -21,7 +21,7 @@ export { data } export default createContentLoader('**/*.md', { transform(raw): Document[] { return raw - .map(({ url, frontmatter }) => { + .map(({ frontmatter, url }) => { const foundLanguage = Object.values(config.userConfig.locales!).find((locale) => { let normalizedLanguagePrefix = locale.lang || 'en' if (!normalizedLanguagePrefix.startsWith('/')) { @@ -32,12 +32,12 @@ export default createContentLoader('**/*.md', { }) return { + date: formatDate(frontmatter.date), + frontmatter, + lang: foundLanguage?.lang || 'en', title: frontmatter.title, url, urlWithoutLang: url.replace(`/${foundLanguage?.lang || 'en'}`, ''), - date: formatDate(frontmatter.date), - lang: foundLanguage?.lang || 'en', - frontmatter, } }) .sort((a, b) => b.date.time - a.date.time) diff --git a/docs/.vitepress/functions/authors.data.ts b/docs/.vitepress/functions/authors.data.ts index 197a6bad8..f38246a16 100644 --- a/docs/.vitepress/functions/authors.data.ts +++ b/docs/.vitepress/functions/authors.data.ts @@ -3,26 +3,26 @@ import { webcrypto } from 'node:crypto' import { createContentLoader } from 'vitepress' export interface Author { - role: string - kind: 'person' | 'team' + avatar?: string + avatarFallback: string displayName: string - githubUsername?: string githubEmail?: string + githubUsername?: string - avatar?: string - avatarFallback: string + kind: 'person' | 'team' + role: string } interface MarkdownAuthor { + avatar?: string + githubEmail?: string + githubUsername?: string + kind?: 'person' | 'team' + name?: string role?: string - kind?: 'person' | 'team' - avatar?: string - - githubUsername?: string - githubEmail?: string } /** @@ -42,7 +42,7 @@ async function digestStringAsSHA256(message: string) { return hashHex } -async function newAvatarForAuthor(mappedAuthor?: { overrideAvatar?: string, githubUsername?: string, displayName?: string } | null, email?: string | null): Promise { +async function newAvatarForAuthor(mappedAuthor?: null | { displayName?: string, githubUsername?: string, overrideAvatar?: string }, email?: null | string): Promise { if (mappedAuthor) { if (mappedAuthor.overrideAvatar) return mappedAuthor.overrideAvatar @@ -54,10 +54,10 @@ async function newAvatarForAuthor(mappedAuthor?: { overrideAvatar?: string, gith } export default createContentLoader('**/*.md', { - async transform(raw): Promise> { + async transform(raw): Promise> { return (await Promise.all( raw - .map(async ({ url, frontmatter }) => { + .map(async ({ frontmatter, url }) => { const authors: MarkdownAuthor[] = frontmatter.authors if (!authors || !Array.isArray(authors)) { return @@ -67,22 +67,22 @@ export default createContentLoader('**/*.md', { const displayName = author.name || author.githubUsername || author.githubEmail || 'Unknown Author' return { - role: author.role || 'Contributor', - kind: author.kind || 'person', + avatar: author.avatar || await newAvatarForAuthor({ displayName, githubUsername: author.githubUsername }, author.githubEmail), + avatarFallback: `https://gravatar.com/avatar/${await digestStringAsSHA256(displayName)}?d=retro`, displayName, - githubUsername: author.githubUsername, githubEmail: author.githubEmail, + githubUsername: author.githubUsername, - avatar: author.avatar || await newAvatarForAuthor({ githubUsername: author.githubUsername, displayName }, author.githubEmail), - avatarFallback: `https://gravatar.com/avatar/${await digestStringAsSHA256(displayName)}?d=retro`, + kind: author.kind || 'person', + role: author.role || 'Contributor', } })) return { - url, authors: authorsTransformed, + url, } }), )).filter(item => item != null) diff --git a/docs/.vitepress/functions/blog.data.ts b/docs/.vitepress/functions/blog.data.ts index e31d6bc01..bf218d298 100644 --- a/docs/.vitepress/functions/blog.data.ts +++ b/docs/.vitepress/functions/blog.data.ts @@ -14,13 +14,13 @@ const config: SiteConfig = (globalThis as any).VITEPRESS_CONFIG const base = config.userConfig.base || env.BASE_URL || '/' interface Post { - title: string - url: string - urlWithoutLang: string - lang: string date: ReturnType excerpt: string | undefined frontmatter?: Record + lang: string + title: string + url: string + urlWithoutLang: string } declare const data: Post[] @@ -77,12 +77,12 @@ function withDirname(url?: string, cwd?: string) { } export default createContentLoader('**/blog/**/*.md', { + excerpt: true, includeSrc: true, render: true, - excerpt: true, async transform(raw): Promise { return (await Promise.all(raw - .map(async ({ url, frontmatter, excerpt }) => { + .map(async ({ excerpt, frontmatter, url }) => { const foundLanguage = Object.values(config.userConfig.locales!).find((locale) => { let normalizedLanguagePrefix = locale.lang || 'en' if (!normalizedLanguagePrefix.startsWith('/')) { @@ -117,19 +117,19 @@ export default createContentLoader('**/blog/**/*.md', { const previewCoverDark = withBase(await fileToUrl(withDirname(fromAtAssets(frontmatter['preview-cover']?.dark), cwdFromUrl(url))), base) const res = { - title: frontmatter.title, - url, - urlWithoutLang: url.replace(`/${foundLanguage?.lang || 'en'}`, ''), - excerpt, date: formatDate(frontmatter.date), - lang: foundLanguage?.lang || 'en', + excerpt, frontmatter: { ...frontmatter, 'preview-cover': { - light: previewCoverLight, dark: previewCoverDark, + light: previewCoverLight, }, }, + lang: foundLanguage?.lang || 'en', + title: frontmatter.title, + url, + urlWithoutLang: url.replace(`/${foundLanguage?.lang || 'en'}`, ''), } return res diff --git a/docs/.vitepress/functions/chronicles.data.ts b/docs/.vitepress/functions/chronicles.data.ts index 064f6be93..dcfff97a9 100644 --- a/docs/.vitepress/functions/chronicles.data.ts +++ b/docs/.vitepress/functions/chronicles.data.ts @@ -7,25 +7,25 @@ import { formatDate } from '../utils/utils' const config: SiteConfig = (globalThis as any).VITEPRESS_CONFIG interface ChronicleEntry { - title: string - url: string - urlWithoutLang: string - lang: string date: ReturnType excerpt: string | undefined frontmatter?: Record + lang: string + title: string + url: string + urlWithoutLang: string } declare const data: ChronicleEntry[] export { data } export default createContentLoader('**/chronicles/**/*.md', { + excerpt: true, includeSrc: true, render: true, - excerpt: true, transform(raw): ChronicleEntry[] { return raw - .map(({ url, frontmatter, excerpt }) => { + .map(({ excerpt, frontmatter, url }) => { const foundLanguage = Object.values(config.userConfig.locales!).find((locale) => { let normalizedLanguagePrefix = locale.lang || 'en' if (!normalizedLanguagePrefix.startsWith('/')) { @@ -36,13 +36,13 @@ export default createContentLoader('**/chronicles/**/*.md', { }) return { + date: formatDate(frontmatter.date), + excerpt, + frontmatter, + lang: foundLanguage?.lang || 'en', title: frontmatter.title, url, urlWithoutLang: url.replace(`/${foundLanguage?.lang || 'en'}`, ''), - excerpt, - date: formatDate(frontmatter.date), - lang: foundLanguage?.lang || 'en', - frontmatter, } }) .sort((a, b) => b.date.time - a.date.time) diff --git a/docs/.vitepress/plugins/vite-frontmatter-assets.ts b/docs/.vitepress/plugins/vite-frontmatter-assets.ts index 87e516b0e..92615f97c 100644 --- a/docs/.vitepress/plugins/vite-frontmatter-assets.ts +++ b/docs/.vitepress/plugins/vite-frontmatter-assets.ts @@ -9,6 +9,102 @@ import matter from 'gray-matter' import { glob } from 'tinyglobby' +interface VitePressConfig extends ResolvedConfig { + vitepress: SiteConfig +} + +export function frontmatterAssets(): Plugin { + let resolvedConfig: undefined | VitePressConfig + const mAssetAbsoluteUrlMetadata = new Map() + const mapAssetBuiltUrlAssetAbsoluteUrl = new Map() + + async function fileToUrl(file: string) { + if (!file) { + return { + url: file, + } + } + + const parsed = parse(file) + const hash = createHash('sha256') + .update(await readFile(file)) + .digest('hex') + .slice(0, 8) + + return { + hash, + url: `/assets/${parsed.name}.${hash}${parsed.ext}`, + } + } + + return { + async configResolved(config) { + resolvedConfig = config as VitePressConfig + + const markdownFiles = await glob('**/*.md', { absolute: true, cwd: resolvedConfig?.vitepress.srcDir || '', ignore: ['**/node_modules/**'] }) + for (const file of markdownFiles) { + const res = (await readFile(file)) + const { data } = matter(res.toString('utf-8')) + if (Object.keys(data).length === 0) { + continue + } + + recursivelyFindAtAssets(data, (matched) => { + const assetPath = fromAtAssets(matched) + let absoluteAssetPath: string + if (assetPath.startsWith('/')) { + absoluteAssetPath = join(resolvedConfig?.vitepress.srcDir || '', assetPath) + } + else { + absoluteAssetPath = join(dirname(file), assetPath) + } + + mAssetAbsoluteUrlMetadata.set(absoluteAssetPath, { builtUrl: file, hash: '', url: file }) + return undefined + }) + } + + for (const [key, value] of mAssetAbsoluteUrlMetadata) { + const { hash, url } = await fileToUrl(key) + mapAssetBuiltUrlAssetAbsoluteUrl.set(url, key) + mAssetAbsoluteUrlMetadata.set(key, { builtUrl: url, hash, url: value.url }) + } + }, + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const requesting = withoutBase(req.url, resolvedConfig?.base) + if (!requesting || !mapAssetBuiltUrlAssetAbsoluteUrl.has(requesting)) { + return next() + } + + const filePath = mapAssetBuiltUrlAssetAbsoluteUrl.get(requesting) + if (!filePath) { + return next() + } + + const ext = parse(filePath).ext.slice(1) + const fileContent = await readFile(filePath) + + res.writeHead(200, { + 'Cache-Control': 'public, max-age=31536000, immutable', + 'Content-Length': fileContent.length, + 'Content-Type': ext === 'svg' ? 'image/svg+xml' : `image/${ext}`, + }) + res.end(fileContent) + res.end() + }) + }, + enforce: 'pre', + name: '@proj-airi/docs:vite-plugin-frontmatter-assets', + async writeBundle() { + for (const [builtUrl, absoluteUrl] of mapAssetBuiltUrlAssetAbsoluteUrl.entries()) { + const content = await this.fs.readFile(absoluteUrl) + await this.fs.writeFile(join(resolvedConfig!.vitepress.outDir!, builtUrl), content) + } + }, + } +} + function fromAtAssets(url: string): string { const reg = /^@assets\((?:'(\S+)'|"(\S+)"|(\S+))\)$/ if (reg.test(url)) { @@ -28,10 +124,6 @@ function fromAtAssets(url: string): string { return url } -interface VitePressConfig extends ResolvedConfig { - vitepress: SiteConfig -} - function recursivelyFindAtAssets(propertyMaybeObjectOrScalar: unknown, fn: (value: string) => string | undefined) { if (typeof propertyMaybeObjectOrScalar === 'string') { // eslint-disable-next-line regexp/no-unused-capturing-group @@ -89,95 +181,3 @@ function withoutBase(url?: string, base?: string): string | undefined { return url } - -export function frontmatterAssets(): Plugin { - let resolvedConfig: VitePressConfig | undefined - const mAssetAbsoluteUrlMetadata = new Map() - const mapAssetBuiltUrlAssetAbsoluteUrl = new Map() - - async function fileToUrl(file: string) { - if (!file) { - return { - url: file, - } - } - - const parsed = parse(file) - const hash = createHash('sha256') - .update(await readFile(file)) - .digest('hex') - .slice(0, 8) - - return { - hash, - url: `/assets/${parsed.name}.${hash}${parsed.ext}`, - } - } - - return { - name: '@proj-airi/docs:vite-plugin-frontmatter-assets', - enforce: 'pre', - async configResolved(config) { - resolvedConfig = config as VitePressConfig - - const markdownFiles = await glob('**/*.md', { ignore: ['**/node_modules/**'], cwd: resolvedConfig?.vitepress.srcDir || '', absolute: true }) - for (const file of markdownFiles) { - const res = (await readFile(file)) - const { data } = matter(res.toString('utf-8')) - if (Object.keys(data).length === 0) { - continue - } - - recursivelyFindAtAssets(data, (matched) => { - const assetPath = fromAtAssets(matched) - let absoluteAssetPath: string - if (assetPath.startsWith('/')) { - absoluteAssetPath = join(resolvedConfig?.vitepress.srcDir || '', assetPath) - } - else { - absoluteAssetPath = join(dirname(file), assetPath) - } - - mAssetAbsoluteUrlMetadata.set(absoluteAssetPath, { url: file, builtUrl: file, hash: '' }) - return undefined - }) - } - - for (const [key, value] of mAssetAbsoluteUrlMetadata) { - const { url, hash } = await fileToUrl(key) - mapAssetBuiltUrlAssetAbsoluteUrl.set(url, key) - mAssetAbsoluteUrlMetadata.set(key, { url: value.url, builtUrl: url, hash }) - } - }, - configureServer(server) { - server.middlewares.use(async (req, res, next) => { - const requesting = withoutBase(req.url, resolvedConfig?.base) - if (!requesting || !mapAssetBuiltUrlAssetAbsoluteUrl.has(requesting)) { - return next() - } - - const filePath = mapAssetBuiltUrlAssetAbsoluteUrl.get(requesting) - if (!filePath) { - return next() - } - - const ext = parse(filePath).ext.slice(1) - const fileContent = await readFile(filePath) - - res.writeHead(200, { - 'Content-Type': ext === 'svg' ? 'image/svg+xml' : `image/${ext}`, - 'Content-Length': fileContent.length, - 'Cache-Control': 'public, max-age=31536000, immutable', - }) - res.end(fileContent) - res.end() - }) - }, - async writeBundle() { - for (const [builtUrl, absoluteUrl] of mapAssetBuiltUrlAssetAbsoluteUrl.entries()) { - const content = await this.fs.readFile(absoluteUrl) - await this.fs.writeFile(join(resolvedConfig!.vitepress.outDir!, builtUrl), content) - } - }, - } -} diff --git a/docs/.vitepress/theme/config.ts b/docs/.vitepress/theme/config.ts index 18bcd6133..aec186ae7 100644 --- a/docs/.vitepress/theme/config.ts +++ b/docs/.vitepress/theme/config.ts @@ -1,21 +1,11 @@ import type { DefaultTheme } from 'vitepress' -interface ExtraThemeConfig { - homepage: HomePageConfig -} - -interface HomePageConfig { - buttons: ButtonItem[] -} - export interface ButtonItem extends Link { primary?: boolean } export interface Link { - text?: string link?: string - /** * VitePress intercepts `` tag clicks for SPA navigation, which can cause routing errors for external links.
* Adding a `target` attribute allows the browser to handle the navigation natively, avoiding this problem. @@ -25,6 +15,16 @@ export interface Link { * https://stackoverflow.com/questions/79348337/redirect-main-title-link-in-vitepress-to-my-personal-website/79386388#79386388 */ target?: string + + text?: string } export type ThemeConfig = DefaultTheme.Config & ExtraThemeConfig + +interface ExtraThemeConfig { + homepage: HomePageConfig +} + +interface HomePageConfig { + buttons: ButtonItem[] +} diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index c640a9386..40ecfa5d5 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -23,20 +23,20 @@ import '@fontsource/dm-serif-display/index.css' import '@fontsource-variable/comfortaa/index.css' export default { - Layout, enhanceApp({ app, siteData }) { if (!import.meta.env.SSR && import.meta.env.PROD) { import('../modules/posthog') } const i18n = createI18n({ + fallbackLocale: 'en', legacy: false, locale: siteData.value.lang || 'en', - fallbackLocale: 'en', messages, }) app.use(i18n) app.component('ThemedVideo', ThemedVideo) }, + Layout, } satisfies Theme diff --git a/docs/.vitepress/utils/cache.ts b/docs/.vitepress/utils/cache.ts index 266104c7e..6418fdbf0 100644 --- a/docs/.vitepress/utils/cache.ts +++ b/docs/.vitepress/utils/cache.ts @@ -2,13 +2,21 @@ // eslint-disable-next-line ts/ban-ts-comment // @ts-nocheck export class LRUCache { - max cache + max constructor(max = 10) { this.max = max this.cache = new Map() } + clear() { + this.cache.clear() + } + + first() { + return this.cache.keys().next().value + } + get(key) { const item = this.cache.get(key) if (item !== undefined) { @@ -28,12 +36,4 @@ export class LRUCache { this.cache.delete(this.first()) this.cache.set(key, val) } - - first() { - return this.cache.keys().next().value - } - - clear() { - this.cache.clear() - } } diff --git a/docs/.vitepress/utils/utils.ts b/docs/.vitepress/utils/utils.ts index 3a5de409a..35ab966f4 100644 --- a/docs/.vitepress/utils/utils.ts +++ b/docs/.vitepress/utils/utils.ts @@ -1,16 +1,16 @@ export function formatDate(raw: string): { - time: number string: string + time: number } { const date = new Date(raw) date.setUTCHours(12) return { - time: +date, string: date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', day: 'numeric', + month: 'long', + year: 'numeric', }), + time: +date, } } diff --git a/docs/content/en/blog/DevLog-2025.04.06/index.md b/docs/content/en/blog/DevLog-2025.04.06/index.md index 41023efdf..f9c466a90 100644 --- a/docs/content/en/blog/DevLog-2025.04.06/index.md +++ b/docs/content/en/blog/DevLog-2025.04.06/index.md @@ -148,9 +148,9 @@ And connect the `pgvector.rs` instance with Drizzle: ```typescript export const chatMessagesTable = pgTable('chat_messages', { - id: uuid().primaryKey().defaultRandom(), content: text().notNull().default(''), content_vector_1024: vector({ dimensions: 1024 }), + id: uuid().primaryKey().defaultRandom(), }, table => [ index('chat_messages_content_vector_1024_index').using('hnsw', table.content_vector_1024.op('vector_cosine_ops')), ]) @@ -260,11 +260,11 @@ import { index, pgTable, serial, text, vector } from 'drizzle-orm/pg-core' export const demoTable = pgTable( 'demo', { + description: text('description').notNull().default(''), + embedding: vector('embedding', { dimensions: 1536 }), id: uuid().primaryKey().defaultRandom(), title: text('title').notNull().default(''), - description: text('description').notNull().default(''), url: text('url').notNull().default(''), - embedding: vector('embedding', { dimensions: 1536 }), }, table => [ index('embeddingIndex').using('hnsw', table.embedding.op('vector_cosine_ops')), @@ -301,14 +301,14 @@ integrations here: let similarity: SQL switch (env.EMBEDDING_DIMENSION) { - case '1536': - similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_1536, embedding.embedding)}))` + case '768': + similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_768, embedding.embedding)}))` break case '1024': similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_1024, embedding.embedding)}))` break - case '768': - similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_768, embedding.embedding)}))` + case '1536': + similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_1536, embedding.embedding)}))` break default: throw new Error(`Unsupported embedding dimension: ${env.EMBEDDING_DIMENSION}`) @@ -317,8 +317,8 @@ switch (env.EMBEDDING_DIMENSION) { // Get top messages with similarity above threshold const relevantMessages = await db .select({ - id: chatMessagesTable.id, content: chatMessagesTable.content, + id: chatMessagesTable.id, similarity: sql`${similarity} AS "similarity"`, }) .from(chatMessagesTable) diff --git a/docs/content/en/blog/DevLog-2025.04.28/index.md b/docs/content/en/blog/DevLog-2025.04.28/index.md index a080c0344..8135a114b 100644 --- a/docs/content/en/blog/DevLog-2025.04.28/index.md +++ b/docs/content/en/blog/DevLog-2025.04.28/index.md @@ -52,11 +52,11 @@ import { invoke } from '@Tauri-apps/api/core' export const mcp = [ { - name: 'list_tools', description: 'List all tools', execute: async () => { return await invoke('list_tools') - } + }, + name: 'list_tools' } ] ``` @@ -157,7 +157,7 @@ Then on the JavaScript side, we can simply pass an object: ```javascript import { invoke } from '@Tauri-apps/api/core' -invoke('call_tool', { name: 'input_swipe', args: { x1: 100, y1: 100, x2: 200, y2: 200, duration: 500 } }) +invoke('call_tool', { args: { duration: 500, x1: 100, x2: 200, y1: 100, y2: 200 }, name: 'input_swipe' }) ``` Super convenient! diff --git a/docs/content/ja/blog/DevLog-2025.04.06/index.md b/docs/content/ja/blog/DevLog-2025.04.06/index.md index 2b8acc7de..9254ee752 100644 --- a/docs/content/ja/blog/DevLog-2025.04.06/index.md +++ b/docs/content/ja/blog/DevLog-2025.04.06/index.md @@ -114,9 +114,9 @@ services: ```typescript export const chatMessagesTable = pgTable('chat_messages', { - id: uuid().primaryKey().defaultRandom(), content: text().notNull().default(''), content_vector_1024: vector({ dimensions: 1024 }), + id: uuid().primaryKey().defaultRandom(), }, table => [ index('chat_messages_content_vector_1024_index').using('hnsw', table.content_vector_1024.op('vector_cosine_ops')), ]) @@ -213,11 +213,11 @@ import { index, pgTable, serial, text, vector } from 'drizzle-orm/pg-core' export const demoTable = pgTable( 'demo', { + description: text('description').notNull().default(''), + embedding: vector('embedding', { dimensions: 1536 }), id: uuid().primaryKey().defaultRandom(), title: text('title').notNull().default(''), - description: text('description').notNull().default(''), url: text('url').notNull().default(''), - embedding: vector('embedding', { dimensions: 1536 }), }, table => [ index('embeddingIndex').using('hnsw', table.embedding.op('vector_cosine_ops')), @@ -252,14 +252,14 @@ CREATE INDEX "embeddingIndex" ON "demo" USING hnsw ("embedding" vector_cosine_op let similarity: SQL switch (env.EMBEDDING_DIMENSION) { - case '1536': - similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_1536, embedding.embedding)}))` + case '768': + similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_768, embedding.embedding)}))` break case '1024': similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_1024, embedding.embedding)}))` break - case '768': - similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_768, embedding.embedding)}))` + case '1536': + similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_1536, embedding.embedding)}))` break default: throw new Error(`Unsupported embedding dimension: ${env.EMBEDDING_DIMENSION}`) @@ -268,8 +268,8 @@ switch (env.EMBEDDING_DIMENSION) { // 類似度が閾値を超える上位メッセージを取得 const relevantMessages = await db .select({ - id: chatMessagesTable.id, content: chatMessagesTable.content, + id: chatMessagesTable.id, similarity: sql`${similarity} AS "similarity"`, }) .from(chatMessagesTable) diff --git a/docs/content/ja/blog/DevLog-2025.04.28/index.md b/docs/content/ja/blog/DevLog-2025.04.28/index.md index 6769f55fd..9444f8eec 100644 --- a/docs/content/ja/blog/DevLog-2025.04.28/index.md +++ b/docs/content/ja/blog/DevLog-2025.04.28/index.md @@ -52,11 +52,11 @@ import { invoke } from '@Tauri-apps/api/core' export const mcp = [ { - name: 'list_tools', description: 'List all tools', execute: async () => { return await invoke('list_tools') - } + }, + name: 'list_tools' } ] ``` @@ -157,7 +157,7 @@ JavaScript 側では、単にオブジェクトを渡すだけです: ```javascript import { invoke } from '@Tauri-apps/api/core' -invoke('call_tool', { name: 'input_swipe', args: { x1: 100, y1: 100, x2: 200, y2: 200, duration: 500 } }) +invoke('call_tool', { args: { duration: 500, x1: 100, x2: 200, y1: 100, y2: 200 }, name: 'input_swipe' }) ``` 超便利! diff --git a/docs/content/ko/blog/DevLog-2025.04.06/index.md b/docs/content/ko/blog/DevLog-2025.04.06/index.md index a5c49460d..c7acbf958 100644 --- a/docs/content/ko/blog/DevLog-2025.04.06/index.md +++ b/docs/content/ko/blog/DevLog-2025.04.06/index.md @@ -143,9 +143,9 @@ Drizzle로 `pgvector.rs` 인스턴스에 연결하면: ```typescript export const chatMessagesTable = pgTable('chat_messages', { - id: uuid().primaryKey().defaultRandom(), content: text().notNull().default(''), content_vector_1024: vector({ dimensions: 1024 }), + id: uuid().primaryKey().defaultRandom(), }, table => [ index('chat_messages_content_vector_1024_index').using('hnsw', table.content_vector_1024.op('vector_cosine_ops')), ]) @@ -249,11 +249,11 @@ import { index, pgTable, serial, text, vector } from 'drizzle-orm/pg-core' export const demoTable = pgTable( 'demo', { + description: text('description').notNull().default(''), + embedding: vector('embedding', { dimensions: 1536 }), id: uuid().primaryKey().defaultRandom(), title: text('title').notNull().default(''), - description: text('description').notNull().default(''), url: text('url').notNull().default(''), - embedding: vector('embedding', { dimensions: 1536 }), }, table => [ index('embeddingIndex').using('hnsw', table.embedding.op('vector_cosine_ops')), @@ -288,14 +288,14 @@ CREATE INDEX "embeddingIndex" ON "demo" USING hnsw ("embedding" vector_cosine_op let similarity: SQL switch (env.EMBEDDING_DIMENSION) { - case '1536': - similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_1536, embedding.embedding)}))` + case '768': + similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_768, embedding.embedding)}))` break case '1024': similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_1024, embedding.embedding)}))` break - case '768': - similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_768, embedding.embedding)}))` + case '1536': + similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_1536, embedding.embedding)}))` break default: throw new Error(`Unsupported embedding dimension: ${env.EMBEDDING_DIMENSION}`) @@ -304,8 +304,8 @@ switch (env.EMBEDDING_DIMENSION) { // 임계값 이상의 유사도를 가진 상위 메시지를 가져온다 const relevantMessages = await db .select({ - id: chatMessagesTable.id, content: chatMessagesTable.content, + id: chatMessagesTable.id, similarity: sql`${similarity} AS "similarity"`, }) .from(chatMessagesTable) diff --git a/docs/content/ko/blog/DevLog-2025.04.28/index.md b/docs/content/ko/blog/DevLog-2025.04.28/index.md index ebf8c06b4..1836e2a27 100644 --- a/docs/content/ko/blog/DevLog-2025.04.28/index.md +++ b/docs/content/ko/blog/DevLog-2025.04.28/index.md @@ -54,11 +54,11 @@ import { invoke } from '@Tauri-apps/api/core' export const mcp = [ { - name: 'list_tools', description: 'List all tools', execute: async () => { return await invoke('list_tools') - } + }, + name: 'list_tools' } ] ``` @@ -159,7 +159,7 @@ async fn call_tool(state: State<'_, Mutex>>, name: String, arg ```javascript import { invoke } from '@Tauri-apps/api/core' -invoke('call_tool', { name: 'input_swipe', args: { x1: 100, y1: 100, x2: 200, y2: 200, duration: 500 } }) +invoke('call_tool', { args: { duration: 500, x1: 100, x2: 200, y1: 100, y2: 200 }, name: 'input_swipe' }) ``` 정말 편리하네요! diff --git a/docs/content/zh-Hans/blog/DevLog-2025.04.06/index.md b/docs/content/zh-Hans/blog/DevLog-2025.04.06/index.md index c7fcb4331..d9f9230bd 100644 --- a/docs/content/zh-Hans/blog/DevLog-2025.04.06/index.md +++ b/docs/content/zh-Hans/blog/DevLog-2025.04.06/index.md @@ -114,9 +114,9 @@ services: ```typescript export const chatMessagesTable = pgTable('chat_messages', { - id: uuid().primaryKey().defaultRandom(), content: text().notNull().default(''), content_vector_1024: vector({ dimensions: 1024 }), + id: uuid().primaryKey().defaultRandom(), }, table => [ index('chat_messages_content_vector_1024_index').using('hnsw', table.content_vector_1024.op('vector_cosine_ops')), ]) @@ -213,11 +213,11 @@ import { index, pgTable, serial, text, vector } from 'drizzle-orm/pg-core' export const demoTable = pgTable( 'demo', { + description: text('description').notNull().default(''), + embedding: vector('embedding', { dimensions: 1536 }), id: uuid().primaryKey().defaultRandom(), title: text('title').notNull().default(''), - description: text('description').notNull().default(''), url: text('url').notNull().default(''), - embedding: vector('embedding', { dimensions: 1536 }), }, table => [ index('embeddingIndex').using('hnsw', table.embedding.op('vector_cosine_ops')), @@ -252,14 +252,14 @@ CREATE INDEX "embeddingIndex" ON "demo" USING hnsw ("embedding" vector_cosine_op let similarity: SQL switch (env.EMBEDDING_DIMENSION) { - case '1536': - similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_1536, embedding.embedding)}))` + case '768': + similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_768, embedding.embedding)}))` break case '1024': similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_1024, embedding.embedding)}))` break - case '768': - similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_768, embedding.embedding)}))` + case '1536': + similarity = sql`(1 - (${cosineDistance(chatMessagesTable.content_vector_1536, embedding.embedding)}))` break default: throw new Error(`Unsupported embedding dimension: ${env.EMBEDDING_DIMENSION}`) @@ -268,8 +268,8 @@ switch (env.EMBEDDING_DIMENSION) { // Get top messages with similarity above threshold const relevantMessages = await db .select({ - id: chatMessagesTable.id, content: chatMessagesTable.content, + id: chatMessagesTable.id, similarity: sql`${similarity} AS "similarity"`, }) .from(chatMessagesTable) diff --git a/docs/content/zh-Hans/blog/DevLog-2025.04.28/index.md b/docs/content/zh-Hans/blog/DevLog-2025.04.28/index.md index c4c574507..9103225b6 100644 --- a/docs/content/zh-Hans/blog/DevLog-2025.04.28/index.md +++ b/docs/content/zh-Hans/blog/DevLog-2025.04.28/index.md @@ -52,11 +52,11 @@ import { invoke } from '@Tauri-apps/api/core' export const mcp = [ { - name: 'list_tools', description: 'List all tools', execute: async () => { return await invoke('list_tools') - } + }, + name: 'list_tools' } ] ``` @@ -157,7 +157,7 @@ async fn call_tool(state: State<'_, Mutex>>, name: String, arg ```javascript import { invoke } from '@Tauri-apps/api/core' -invoke('call_tool', { name: 'input_swipe', args: { x1: 100, y1: 100, x2: 200, y2: 200, duration: 500 } }) +invoke('call_tool', { args: { duration: 500, x1: 100, x2: 200, y1: 100, y2: 200 }, name: 'input_swipe' }) ``` 超方便! diff --git a/docs/scripts/avif.ts b/docs/scripts/avif.ts index 5028c67e5..7d31fc6ff 100644 --- a/docs/scripts/avif.ts +++ b/docs/scripts/avif.ts @@ -7,6 +7,17 @@ import { Transformer } from '@napi-rs/image' const SOURCE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp'] +async function main() { + const files = process.argv.slice(2).map(f => path.resolve(process.cwd(), f)) + if (files.length === 0) { + console.error('No files provided.') + process.exit(1) + } + + await Promise.all(files.map(f => stat(f))) + await Promise.allSettled(files.map(file => transform(file))) +} + async function transform(filePath: string): Promise { if ((await stat(filePath)).isDirectory()) { return await Promise.allSettled( @@ -27,15 +38,4 @@ async function transform(filePath: string): Promise { console.info(`√ ${filePath} -> ${dist}`) } -async function main() { - const files = process.argv.slice(2).map(f => path.resolve(process.cwd(), f)) - if (files.length === 0) { - console.error('No files provided.') - process.exit(1) - } - - await Promise.all(files.map(f => stat(f))) - await Promise.allSettled(files.map(file => transform(file))) -} - main() diff --git a/engines/stage-tamagotchi-godot/tools/dumpRenderStages.mjs b/engines/stage-tamagotchi-godot/tools/dumpRenderStages.mjs index 1a4f8b8cf..72fbd6027 100644 --- a/engines/stage-tamagotchi-godot/tools/dumpRenderStages.mjs +++ b/engines/stage-tamagotchi-godot/tools/dumpRenderStages.mjs @@ -37,148 +37,82 @@ const defaultRenderStageViews = [ const windowCaptureScriptPath = join(scriptDirectory, 'captureWindowClientPng.ps1') const webSocketGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' -function parseArgs(argv) { - const options = { - avatarEdgeLight: true, - godotPath: process.env.GODOT4, - headless: false, - height: 720, - logPath: defaultLogPath, - modelPath: defaultModelPath, - renderStageViews: null, - settleMs: 1000, - stageDumpDirectory: null, - viewPreset: 'default', - width: 1280, +async function applyViewPreset(host, preset) { + if (preset === 'default') { + return } - for (let index = 0; index < argv.length; index++) { - const argument = argv[index] - switch (argument) { - case '--avatar-edge-light': - options.avatarEdgeLight = parseAvatarEdgeLight( - resolveRequiredValue(argv, ++index, argument), - argument, - ) - break - case '--godot': - options.godotPath = resolveRequiredValue(argv, ++index, argument) - break - case '--dump-render-stages': - options.stageDumpDirectory = resolveRequiredValue(argv, ++index, argument) - break - case '--headless': - options.headless = true - break - case '--height': - options.height = parsePositiveInteger(resolveRequiredValue(argv, ++index, argument), argument) - break - case '--log-file': - options.logPath = resolveRequiredValue(argv, ++index, argument) - break - case '--model': - options.modelPath = resolveRequiredValue(argv, ++index, argument) - break - case '--render-stages': - options.renderStageViews = parseRenderStageViews( - resolveRequiredValue(argv, ++index, argument), - argument, - ) - break - case '--settle-ms': - options.settleMs = parseNonNegativeInteger( - resolveRequiredValue(argv, ++index, argument), - argument, - ) - break - case '--view-preset': - options.viewPreset = parseViewPreset(resolveRequiredValue(argv, ++index, argument), argument) - break - case '--width': - options.width = parsePositiveInteger(resolveRequiredValue(argv, ++index, argument), argument) - break - default: - throw new Error(`Unknown argument: ${argument}`) - } - } - - if (!options.godotPath) { - throw new Error('GODOT4 is not set. Pass --godot or set GODOT4 to the Godot .NET executable.') - } - - if (options.headless) { - throw new Error('Render-stage window capture requires a visible Godot window. Remove --headless.') - } - - if (!options.stageDumpDirectory) { - throw new Error('Pass --dump-render-stages . Baseline comparison is not supported.') - } - - options.godotPath = resolve(options.godotPath) - options.logPath = resolve(options.logPath) - options.modelPath = resolve(options.modelPath) - options.stageDumpDirectory = resolve(options.stageDumpDirectory) - options.renderStageViews ??= defaultRenderStageViews - return options + const snapshot = await requestViewSnapshot(host) + const patch = createViewPresetPatch(preset, snapshot) + const requestId = randomUUID() + host.send('host.view.patch', { + patch, + requestId, + }) + await host.waitForRequest('stage.view.snapshot', requestId, 10000) } -function resolveRequiredValue(argv, index, label) { - const value = argv[index] - if (!value || value.startsWith('--')) { - throw new Error(`${label} requires a value.`) +async function captureGodotWindowPng(processHandle, options) { + if (process.platform !== 'win32') { + throw new Error('Render-stage window capture currently requires Windows.') } - return value + if (!processHandle.pid) { + throw new Error('Godot process id was not available for window capture.') + } + + const result = await runProcess( + process.env.PWSH ?? 'powershell.exe', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + windowCaptureScriptPath, + '-TargetProcessId', + String(processHandle.pid), + '-OutputPath', + options.currentPath, + '-SettleMs', + String(options.settleMs), + ], + ) + + const stdout = result.stdout.trim() + const lines = stdout.split(/\r?\n/).filter(Boolean) + const lastLine = lines[lines.length - 1] + if (!lastLine) { + throw new Error('Window capture did not return JSON metadata.') + } + + try { + return JSON.parse(lastLine) + } + catch (error) { + throw new Error(`Failed to parse window capture metadata: ${error.message}\n${stdout}`) + } } -function parsePositiveInteger(value, label) { - const parsed = Number.parseInt(value, 10) - if (!Number.isInteger(parsed) || parsed <= 0) { - throw new Error(`${label} must be a positive integer.`) +async function captureRenderStageViews(host, processHandle, options) { + await mkdir(options.stageDumpDirectory, { recursive: true }) + + for (const view of options.renderStageViews) { + const isEdgeOffView = view === 'final-edge-off' + const edgeLightEnabled = isEdgeOffView ? false : options.avatarEdgeLight + await setAvatarEdgeLight(host, edgeLightEnabled) + await setRenderDebugView(host, isEdgeOffView ? 'final' : view) + const stageCapturePath = join(options.stageDumpDirectory, `${view}.png`) + const capture = await captureGodotWindowPng(processHandle, { + ...options, + currentPath: stageCapturePath, + }) + console.info( + `Captured render stage ${view}: ${capture.path} (${capture.width}x${capture.height})`, + ) } - return parsed -} - -function parseNonNegativeInteger(value, label) { - const parsed = Number.parseInt(value, 10) - if (!Number.isInteger(parsed) || parsed < 0) { - throw new Error(`${label} must be a non-negative integer.`) - } - - return parsed -} - -function parseRenderStageViews(value, label) { - const views = value.split(',').map(item => item.trim()).filter(Boolean) - if (views.length === 0) { - throw new Error(`${label} must include at least one render stage.`) - } - - return views -} - -function parseViewPreset(value, label) { - if (value === 'default' || value === 'upper-body') { - return value - } - - throw new Error(`${label} must be "default" or "upper-body".`) -} - -function parseAvatarEdgeLight(value, label) { - switch (value) { - case 'on': - case 'enabled': - case 'true': - return true - case 'off': - case 'disabled': - case 'false': - return false - default: - throw new Error(`${label} must be "on" or "off".`) - } + await setAvatarEdgeLight(host, options.avatarEdgeLight) + await setRenderDebugView(host, 'final') } async function createStageHost() { @@ -306,12 +240,9 @@ async function createStageHost() { throw new Error(`Cannot send ${type}; Godot WebSocket is not connected.`) } - writeFrame(peerSocket, 0x1, Buffer.from(JSON.stringify({ type, payload }), 'utf8')) + writeFrame(peerSocket, 0x1, Buffer.from(JSON.stringify({ payload, type }), 'utf8')) }, url: `ws://127.0.0.1:${address.port}/ws?token=${token}`, - waitForType(type, timeoutMs) { - return waitFor(message => message?.type === type, timeoutMs, type) - }, waitForRequest(type, requestId, timeoutMs) { return waitFor( message => message?.type === type && message?.payload?.requestId === requestId, @@ -319,9 +250,239 @@ async function createStageHost() { `${type} ${requestId}`, ) }, + waitForType(type, timeoutMs) { + return waitFor(message => message?.type === type, timeoutMs, type) + }, } } +function createViewPresetPatch(preset, snapshot) { + if (preset !== 'upper-body') { + throw new Error(`Unknown view preset: ${preset}`) + } + + const bounds = snapshot?.avatarBounds + if (!bounds) { + throw new Error('Upper-body view preset requires avatar bounds from Godot.') + } + + const center = bounds.center + const size = bounds.size + const fovDeg = 35 + const targetY = center.y + size.y * 0.22 + const distance = Math.max(size.y * 0.95, 1.2) + const positionY = targetY + size.y * 0.02 + const pitchDeg = Math.atan2(targetY - positionY, distance) * 180 / Math.PI + + return { + camera: { + fovDeg, + pitchDeg, + position: { + x: center.x, + y: positionY, + z: center.z + distance, + }, + yawDeg: 0, + }, + } +} + +function launchGodot(options, webSocketUrl) { + const args = [ + '--path', + projectDirectory, + '--resolution', + `${options.width}x${options.height}`, + '--log-file', + options.logPath, + ] + + if (options.headless) { + args.push('--headless') + } + + args.push('--', `--airi-ws-url=${webSocketUrl}`) + + return spawn(options.godotPath, args, { + cwd: projectDirectory, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: false, + }) +} + +async function main() { + const options = parseArgs(process.argv.slice(2)) + if (!existsSync(options.godotPath)) { + throw new Error(`Godot executable does not exist: ${options.godotPath}`) + } + + if (!existsSync(options.modelPath)) { + throw new Error(`VRM model does not exist: ${options.modelPath}`) + } + + await mkdir(dirname(options.logPath), { recursive: true }) + await mkdir(options.stageDumpDirectory, { recursive: true }) + + const host = await createStageHost() + const godot = launchGodot(options, host.url) + godot.stdout.on('data', chunk => process.stdout.write(chunk)) + godot.stderr.on('data', chunk => process.stderr.write(chunk)) + + try { + await host.waitForType('stage.ready', 20000) + host.send('host.scene.apply', { + format: 'vrm', + modelId: 'render-stage-observation-avatar-sample-a', + name: 'AvatarSample_A', + path: options.modelPath, + }) + await host.waitForType('scene.applied', 45000) + await applyViewPreset(host, options.viewPreset) + await setAvatarEdgeLight(host, options.avatarEdgeLight) + await captureRenderStageViews(host, godot, options) + } + finally { + await stopGodot(host, godot) + await host.close() + } +} + +function parseArgs(argv) { + const options = { + avatarEdgeLight: true, + godotPath: process.env.GODOT4, + headless: false, + height: 720, + logPath: defaultLogPath, + modelPath: defaultModelPath, + renderStageViews: null, + settleMs: 1000, + stageDumpDirectory: null, + viewPreset: 'default', + width: 1280, + } + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index] + switch (argument) { + case '--avatar-edge-light': + options.avatarEdgeLight = parseAvatarEdgeLight( + resolveRequiredValue(argv, ++index, argument), + argument, + ) + break + case '--dump-render-stages': + options.stageDumpDirectory = resolveRequiredValue(argv, ++index, argument) + break + case '--godot': + options.godotPath = resolveRequiredValue(argv, ++index, argument) + break + case '--headless': + options.headless = true + break + case '--height': + options.height = parsePositiveInteger(resolveRequiredValue(argv, ++index, argument), argument) + break + case '--log-file': + options.logPath = resolveRequiredValue(argv, ++index, argument) + break + case '--model': + options.modelPath = resolveRequiredValue(argv, ++index, argument) + break + case '--render-stages': + options.renderStageViews = parseRenderStageViews( + resolveRequiredValue(argv, ++index, argument), + argument, + ) + break + case '--settle-ms': + options.settleMs = parseNonNegativeInteger( + resolveRequiredValue(argv, ++index, argument), + argument, + ) + break + case '--view-preset': + options.viewPreset = parseViewPreset(resolveRequiredValue(argv, ++index, argument), argument) + break + case '--width': + options.width = parsePositiveInteger(resolveRequiredValue(argv, ++index, argument), argument) + break + default: + throw new Error(`Unknown argument: ${argument}`) + } + } + + if (!options.godotPath) { + throw new Error('GODOT4 is not set. Pass --godot or set GODOT4 to the Godot .NET executable.') + } + + if (options.headless) { + throw new Error('Render-stage window capture requires a visible Godot window. Remove --headless.') + } + + if (!options.stageDumpDirectory) { + throw new Error('Pass --dump-render-stages . Baseline comparison is not supported.') + } + + options.godotPath = resolve(options.godotPath) + options.logPath = resolve(options.logPath) + options.modelPath = resolve(options.modelPath) + options.stageDumpDirectory = resolve(options.stageDumpDirectory) + options.renderStageViews ??= defaultRenderStageViews + return options +} + +function parseAvatarEdgeLight(value, label) { + switch (value) { + case 'disabled': + case 'false': + case 'off': + return false + case 'enabled': + case 'on': + case 'true': + return true + default: + throw new Error(`${label} must be "on" or "off".`) + } +} + +function parseNonNegativeInteger(value, label) { + const parsed = Number.parseInt(value, 10) + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`${label} must be a non-negative integer.`) + } + + return parsed +} + +function parsePositiveInteger(value, label) { + const parsed = Number.parseInt(value, 10) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive integer.`) + } + + return parsed +} + +function parseRenderStageViews(value, label) { + const views = value.split(',').map(item => item.trim()).filter(Boolean) + if (views.length === 0) { + throw new Error(`${label} must include at least one render stage.`) + } + + return views +} + +function parseViewPreset(value, label) { + if (value === 'default' || value === 'upper-body') { + return value + } + + throw new Error(`${label} must be "default" or "upper-body".`) +} + function readFrames(buffer) { const frames = [] let offset = 0 @@ -381,154 +542,6 @@ function readFrames(buffer) { } } -function writeFrame(socket, opcode, payload) { - const length = payload.length - let header - if (length < 126) { - header = Buffer.from([0x80 | opcode, length]) - } - else if (length <= 0xFFFF) { - header = Buffer.alloc(4) - header[0] = 0x80 | opcode - header[1] = 126 - header.writeUInt16BE(length, 2) - } - else { - header = Buffer.alloc(10) - header[0] = 0x80 | opcode - header[1] = 127 - header.writeBigUInt64BE(BigInt(length), 2) - } - - socket.write(Buffer.concat([header, payload])) -} - -function launchGodot(options, webSocketUrl) { - const args = [ - '--path', - projectDirectory, - '--resolution', - `${options.width}x${options.height}`, - '--log-file', - options.logPath, - ] - - if (options.headless) { - args.push('--headless') - } - - args.push('--', `--airi-ws-url=${webSocketUrl}`) - - return spawn(options.godotPath, args, { - cwd: projectDirectory, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: false, - }) -} - -function waitForProcessExit(processHandle, timeoutMs) { - return new Promise((resolvePromise) => { - const timeout = setTimeout(resolvePromise, timeoutMs, false) - processHandle.once('close', () => { - clearTimeout(timeout) - resolvePromise(true) - }) - }) -} - -async function stopGodot(host, processHandle) { - try { - host.send('host.shutdown') - } - catch {} - - const exited = await waitForProcessExit(processHandle, 3000) - if (!exited) { - processHandle.kill() - await waitForProcessExit(processHandle, 3000) - } -} - -async function captureGodotWindowPng(processHandle, options) { - if (process.platform !== 'win32') { - throw new Error('Render-stage window capture currently requires Windows.') - } - - if (!processHandle.pid) { - throw new Error('Godot process id was not available for window capture.') - } - - const result = await runProcess( - process.env.PWSH ?? 'powershell.exe', - [ - '-NoProfile', - '-ExecutionPolicy', - 'Bypass', - '-File', - windowCaptureScriptPath, - '-TargetProcessId', - String(processHandle.pid), - '-OutputPath', - options.currentPath, - '-SettleMs', - String(options.settleMs), - ], - ) - - const stdout = result.stdout.trim() - const lines = stdout.split(/\r?\n/).filter(Boolean) - const lastLine = lines[lines.length - 1] - if (!lastLine) { - throw new Error('Window capture did not return JSON metadata.') - } - - try { - return JSON.parse(lastLine) - } - catch (error) { - throw new Error(`Failed to parse window capture metadata: ${error.message}\n${stdout}`) - } -} - -async function setRenderDebugView(host, view) { - const requestId = randomUUID() - host.send('host.render.set_debug_view', { - requestId, - view, - }) - const response = await host.waitForRequest('stage.render.debug_view', requestId, 10000) - if (response?.payload?.view !== view) { - throw new Error(`Godot applied unexpected render debug view: ${response?.payload?.view}`) - } -} - -async function setAvatarEdgeLight(host, enabled) { - const requestId = randomUUID() - host.send('host.render.set_avatar_edge_light', { - requestId, - enabled, - }) - const response = await host.waitForRequest('stage.render.avatar_edge_light', requestId, 10000) - if (response?.payload?.enabled !== enabled) { - throw new Error(`Godot applied unexpected avatar edge-light state: ${response?.payload?.enabled}`) - } -} - -async function applyViewPreset(host, preset) { - if (preset === 'default') { - return - } - - const snapshot = await requestViewSnapshot(host) - const patch = createViewPresetPatch(preset, snapshot) - const requestId = randomUUID() - host.send('host.view.patch', { - requestId, - patch, - }) - await host.waitForRequest('stage.view.snapshot', requestId, 10000) -} - async function requestViewSnapshot(host) { const requestId = randomUUID() host.send('host.view.request_snapshot', { @@ -538,58 +551,13 @@ async function requestViewSnapshot(host) { return response.payload } -function createViewPresetPatch(preset, snapshot) { - if (preset !== 'upper-body') { - throw new Error(`Unknown view preset: ${preset}`) +function resolveRequiredValue(argv, index, label) { + const value = argv[index] + if (!value || value.startsWith('--')) { + throw new Error(`${label} requires a value.`) } - const bounds = snapshot?.avatarBounds - if (!bounds) { - throw new Error('Upper-body view preset requires avatar bounds from Godot.') - } - - const center = bounds.center - const size = bounds.size - const fovDeg = 35 - const targetY = center.y + size.y * 0.22 - const distance = Math.max(size.y * 0.95, 1.2) - const positionY = targetY + size.y * 0.02 - const pitchDeg = Math.atan2(targetY - positionY, distance) * 180 / Math.PI - - return { - camera: { - position: { - x: center.x, - y: positionY, - z: center.z + distance, - }, - yawDeg: 0, - pitchDeg, - fovDeg, - }, - } -} - -async function captureRenderStageViews(host, processHandle, options) { - await mkdir(options.stageDumpDirectory, { recursive: true }) - - for (const view of options.renderStageViews) { - const isEdgeOffView = view === 'final-edge-off' - const edgeLightEnabled = isEdgeOffView ? false : options.avatarEdgeLight - await setAvatarEdgeLight(host, edgeLightEnabled) - await setRenderDebugView(host, isEdgeOffView ? 'final' : view) - const stageCapturePath = join(options.stageDumpDirectory, `${view}.png`) - const capture = await captureGodotWindowPng(processHandle, { - ...options, - currentPath: stageCapturePath, - }) - console.info( - `Captured render stage ${view}: ${capture.path} (${capture.width}x${capture.height})`, - ) - } - - await setAvatarEdgeLight(host, options.avatarEdgeLight) - await setRenderDebugView(host, 'final') + return value } function runProcess(command, args) { @@ -621,41 +589,73 @@ function runProcess(command, args) { }) } -async function main() { - const options = parseArgs(process.argv.slice(2)) - if (!existsSync(options.godotPath)) { - throw new Error(`Godot executable does not exist: ${options.godotPath}`) +async function setAvatarEdgeLight(host, enabled) { + const requestId = randomUUID() + host.send('host.render.set_avatar_edge_light', { + enabled, + requestId, + }) + const response = await host.waitForRequest('stage.render.avatar_edge_light', requestId, 10000) + if (response?.payload?.enabled !== enabled) { + throw new Error(`Godot applied unexpected avatar edge-light state: ${response?.payload?.enabled}`) } +} - if (!existsSync(options.modelPath)) { - throw new Error(`VRM model does not exist: ${options.modelPath}`) +async function setRenderDebugView(host, view) { + const requestId = randomUUID() + host.send('host.render.set_debug_view', { + requestId, + view, + }) + const response = await host.waitForRequest('stage.render.debug_view', requestId, 10000) + if (response?.payload?.view !== view) { + throw new Error(`Godot applied unexpected render debug view: ${response?.payload?.view}`) } +} - await mkdir(dirname(options.logPath), { recursive: true }) - await mkdir(options.stageDumpDirectory, { recursive: true }) - - const host = await createStageHost() - const godot = launchGodot(options, host.url) - godot.stdout.on('data', chunk => process.stdout.write(chunk)) - godot.stderr.on('data', chunk => process.stderr.write(chunk)) - +async function stopGodot(host, processHandle) { try { - await host.waitForType('stage.ready', 20000) - host.send('host.scene.apply', { - format: 'vrm', - modelId: 'render-stage-observation-avatar-sample-a', - name: 'AvatarSample_A', - path: options.modelPath, + host.send('host.shutdown') + } + catch {} + + const exited = await waitForProcessExit(processHandle, 3000) + if (!exited) { + processHandle.kill() + await waitForProcessExit(processHandle, 3000) + } +} + +function waitForProcessExit(processHandle, timeoutMs) { + return new Promise((resolvePromise) => { + const timeout = setTimeout(resolvePromise, timeoutMs, false) + processHandle.once('close', () => { + clearTimeout(timeout) + resolvePromise(true) }) - await host.waitForType('scene.applied', 45000) - await applyViewPreset(host, options.viewPreset) - await setAvatarEdgeLight(host, options.avatarEdgeLight) - await captureRenderStageViews(host, godot, options) + }) +} + +function writeFrame(socket, opcode, payload) { + const length = payload.length + let header + if (length < 126) { + header = Buffer.from([0x80 | opcode, length]) } - finally { - await stopGodot(host, godot) - await host.close() + else if (length <= 0xFFFF) { + header = Buffer.alloc(4) + header[0] = 0x80 | opcode + header[1] = 126 + header.writeUInt16BE(length, 2) } + else { + header = Buffer.alloc(10) + header[0] = 0x80 | opcode + header[1] = 127 + header.writeBigUInt64BE(BigInt(length), 2) + } + + socket.write(Buffer.concat([header, payload])) } main().catch((error) => { diff --git a/integrations/discord-bot/src/adapters/airi-adapter.ts b/integrations/discord-bot/src/adapters/airi-adapter.ts index 78b873791..a231c61d3 100644 --- a/integrations/discord-bot/src/adapters/airi-adapter.ts +++ b/integrations/discord-bot/src/adapters/airi-adapter.ts @@ -13,51 +13,23 @@ import { handlePing, registerCommands, VoiceManager } from '../bots/discord/comm const log = useLogg('DiscordAdapter').useGlobalConfig() export interface DiscordAdapterConfig { - discordToken?: string airiToken?: string airiUrl?: string + discordToken?: string } // Define Discord configuration type interface DiscordConfig { - token?: string enabled?: boolean -} - -// Type guard to safely validate the configuration object -function isDiscordConfig(config: unknown): config is DiscordConfig { - if (typeof config !== 'object' || config === null) - return false - const c = config as Record - return (typeof c.token === 'string' || typeof c.token === 'undefined') - && (typeof c.enabled === 'boolean' || typeof c.enabled === 'undefined') -} - -function normalizeDiscordMetadata(discord?: Discord): Discord | undefined { - if (!discord) - return undefined - - if (!discord.guildMember) - return discord - - const { guildMember } = discord - - return { - ...discord, - guildMember: { - id: guildMember.id ?? guildMember.displayName ?? guildMember.nickname ?? '', - nickname: guildMember.nickname ?? guildMember.displayName ?? '', - displayName: guildMember.displayName ?? guildMember.nickname ?? '', - }, - } + token?: string } export class DiscordAdapter { private airiClient: ServerChannel private discordClient: Client private discordToken: string - private voiceManager: VoiceManager private isReconnecting = false + private voiceManager: VoiceManager constructor(config: DiscordAdapterConfig) { this.discordToken = config.discordToken || env.DISCORD_TOKEN || '' @@ -93,6 +65,38 @@ export class DiscordAdapter { this.setupEventHandlers() } + async start(): Promise { + log.log('Starting Discord adapter...') + + try { + // Log in to Discord if token is available + if (this.discordToken) { + await this.discordClient.login(this.discordToken) + log.log('Discord adapter started successfully') + } + else { + log.warn('Discord token not provided. Waiting for configuration from UI.') + } + } + catch (error) { + log.withError(error).error('Failed to start Discord adapter') + throw error + } + } + + async stop(): Promise { + log.log('Stopping Discord adapter...') + try { + await this.discordClient.destroy() + this.airiClient.close() + log.log('Discord adapter stopped') + } + catch (error) { + log.withError(error).error('Error stopping Discord adapter') + throw error + } + } + private setupEventHandlers(): void { // Handle configuration from UI this.airiClient.onEvent('module:configure', async (event) => { @@ -106,7 +110,7 @@ export class DiscordAdapter { if (isDiscordConfig(event.data.config)) { const config = event.data.config as DiscordConfig - const { token, enabled } = config + const { enabled, token } = config if (enabled === false) { if (this.discordClient.isReady) { @@ -230,12 +234,12 @@ export class DiscordAdapter { const discordContext: Discord = { channelId: message.channelId, guildId: message.guildId ?? undefined, - guildName: message.guild?.name ?? undefined, guildMember: { - id: message.author.id, displayName: message.member?.displayName ?? message.author.username, + id: message.author.id, nickname: message.member?.nickname ?? message.author.username, }, + guildName: message.guild?.name ?? undefined, } const normalizedDiscord = normalizeDiscordMetadata(discordContext) const displayName = normalizedDiscord?.guildMember?.displayName @@ -260,28 +264,28 @@ export class DiscordAdapter { : undefined this.airiClient.send({ - type: 'input:text', data: { - text: content, - textRaw: rawContent, + contextUpdates: discordNotice + ? [{ + content: discordNotice, + metadata: { + discord: normalizedDiscord, + }, + strategy: ContextUpdateStrategy.AppendSelf, + text: discordNotice, + }] + : undefined, + discord: normalizedDiscord, overrides: { messagePrefix: displayName ? `(From Discord user ${displayName} ${contextPrefix}): ` : `(From Discord user ${contextPrefix}): `, sessionId: targetSessionId, }, - contextUpdates: discordNotice - ? [{ - strategy: ContextUpdateStrategy.AppendSelf, - text: discordNotice, - content: discordNotice, - metadata: { - discord: normalizedDiscord, - }, - }] - : undefined, - discord: normalizedDiscord, + text: content, + textRaw: rawContent, }, + type: 'input:text', }) } }) @@ -302,36 +306,32 @@ export class DiscordAdapter { } }) } +} - async start(): Promise { - log.log('Starting Discord adapter...') +// Type guard to safely validate the configuration object +function isDiscordConfig(config: unknown): config is DiscordConfig { + if (typeof config !== 'object' || config === null) + return false + const c = config as Record + return (typeof c.token === 'string' || typeof c.token === 'undefined') + && (typeof c.enabled === 'boolean' || typeof c.enabled === 'undefined') +} - try { - // Log in to Discord if token is available - if (this.discordToken) { - await this.discordClient.login(this.discordToken) - log.log('Discord adapter started successfully') - } - else { - log.warn('Discord token not provided. Waiting for configuration from UI.') - } - } - catch (error) { - log.withError(error).error('Failed to start Discord adapter') - throw error - } - } +function normalizeDiscordMetadata(discord?: Discord): Discord | undefined { + if (!discord) + return undefined - async stop(): Promise { - log.log('Stopping Discord adapter...') - try { - await this.discordClient.destroy() - this.airiClient.close() - log.log('Discord adapter stopped') - } - catch (error) { - log.withError(error).error('Error stopping Discord adapter') - throw error - } + if (!discord.guildMember) + return discord + + const { guildMember } = discord + + return { + ...discord, + guildMember: { + displayName: guildMember.displayName ?? guildMember.nickname ?? '', + id: guildMember.id ?? guildMember.displayName ?? guildMember.nickname ?? '', + nickname: guildMember.nickname ?? guildMember.displayName ?? '', + }, } } diff --git a/integrations/discord-bot/src/bots/discord/commands/summon.ts b/integrations/discord-bot/src/bots/discord/commands/summon.ts index 0e6d0ef6c..64e4efd63 100644 --- a/integrations/discord-bot/src/bots/discord/commands/summon.ts +++ b/integrations/discord-bot/src/bots/discord/commands/summon.ts @@ -34,68 +34,168 @@ import { convertOpusToWav } from '../../../utils/audio' import { AudioMonitor } from '../../../utils/audio-monitor' import { OpusDecoder } from '../../../utils/opus' -function isValidTranscription(text: string): boolean { - if (!text || text.includes('[BLANK_AUDIO]')) - return false - return true -} - -async function setSelfVoice(logger: Logg, me?: GuildMember | null) { - if (me?.voice && me.permissions.has('DeafenMembers')) { - try { - await me.voice.setDeaf(false) - await me.voice.setMute(false) - } - catch (error) { - logger.withError(error).log('Failed to modify voice state') // Continue anyway - } - } -} - -// eliza/packages/client-discord/src/voice.ts at develop · elizaOS/eliza -// https://github.com/elizaOS/eliza/blob/develop/packages/client-discord/src/voice.ts - export class VoiceManager extends EventEmitter { - private logger = useLogg('VoiceManager').useGlobalConfig() - private processingVoice: boolean = false - private transcriptionTimeout: NodeJS.Timeout | null = null - private userStates: Map< - string, - { - buffers: Buffer[] - totalLength: number - lastActive: number - transcriptionText: string - } - > = new Map() - private activeAudioPlayer: AudioPlayer | null = null - private client: DiscordClient - private airiClient: AiriClient - private streams: Map = new Map() - private connections: Map = new Map() private activeMonitors: Map< string, { channel: BaseGuildVoiceChannel, monitor: AudioMonitor } > = new Map() + private airiClient: AiriClient + private client: DiscordClient + // Track event listeners for cleanup private connectionListeners: Map Promise error: (error: Error) => void - speakingStart: (userId: string) => void speakingEnd: (userId: string) => void + speakingStart: (userId: string) => void + stateChange: (oldState: any, newState: any) => Promise }> = new Map() + private connections: Map = new Map() + private logger = useLogg('VoiceManager').useGlobalConfig() + private processingVoice: boolean = false + private streams: Map = new Map() + private transcriptionTimeout: NodeJS.Timeout | null = null + + private userStates: Map< + string, + { + buffers: Buffer[] + lastActive: number + totalLength: number + transcriptionText: string + } + > = new Map() + constructor(client: DiscordClient, airiClient: AiriClient) { super() this.client = client this.airiClient = airiClient } + cleanupAudioPlayer(audioPlayer: AudioPlayer) { + if (!audioPlayer) + return + + audioPlayer.stop() + audioPlayer.removeAllListeners() + if (audioPlayer === this.activeAudioPlayer) { + this.activeAudioPlayer = null + } + } + + async debouncedProcessTranscription( + userId: string, + member: GuildMember, + guildId: string, + channelId: string, + ) { + const DEBOUNCE_TRANSCRIPTION_THRESHOLD = 1500 // wait for 1.5 seconds of silence + + if (this.activeAudioPlayer?.state?.status === 'idle') { + this.logger.log('Cleaning up idle audio player.') + this.cleanupAudioPlayer(this.activeAudioPlayer) + } + if (this.activeAudioPlayer || this.processingVoice) { + const state = this.userStates.get(userId) + if (state) { + state.buffers.length = 0 + state.totalLength = 0 + } + return + } + if (this.transcriptionTimeout) { + clearTimeout(this.transcriptionTimeout) + } + + this.transcriptionTimeout = setTimeout(async () => { + this.processingVoice = true + try { + await this.processTranscription(userId, member, guildId, channelId) + // Clean all users' previous buffers + this.userStates.forEach((state, _) => { + state.buffers.length = 0 + state.totalLength = 0 + }) + } + finally { + this.processingVoice = false + } + }, DEBOUNCE_TRANSCRIPTION_THRESHOLD) + } + + handleAudioReceiveStreamEnd(channel: BaseGuildVoiceChannel): (userId: string) => void { + return async (userId: string) => { + const user = channel.members.get(userId) + if (!user?.user.bot) { + this.logger.log(`User stopped speaking: ${user.displayName}`) + this.streams.get(userId)?.emit('speakingStopped') + } + } + } + + handleAudioReceiveStreamStart(channel: BaseGuildVoiceChannel): (userId: string) => Promise { + return async (userId) => { + let user = channel.members.get(userId) + if (!user) { + try { + user = await channel.guild.members.fetch(userId) + } + catch (error) { + this.logger.withError(error).error('Failed to fetch user') + } + } + if (user && !user?.user.bot) { + this.logger.log(`User speaking: ${user.displayName}`) + this.monitorMember(user as GuildMember, channel.id) + this.streams.get(userId)?.emit('speakingStarted') + } + } + } + + async handleJoinChannelCommand(interaction: ChatInputCommandInteraction) { + try { + const currVoiceChannel = (interaction.member as GuildMember).voice.channel + if (!currVoiceChannel) { + return await interaction.reply('Please join a voice channel first.') + } + + await this.joinChannel(interaction, currVoiceChannel) + } + catch (error) { + this.logger.withError(error).log('Error joining voice channel') + } + } + + async handleLeaveChannelCommand(interaction: any) { + const connection = this.getVoiceConnection(interaction.guildId as any) + + if (!connection) { + await interaction.reply('Not currently in a voice channel.') + return + } + + try { + connection.destroy() + await interaction.reply('Left the voice channel.') + } + catch (error) { + this.logger.withError(error).log('Error leaving voice channel') + + await interaction.reply('Failed to leave the voice channel.') + } + } + + handleVoiceConnectionError(error: unknown) { + this.logger.withError(error).log('Voice connection error') + // Don't immediately destroy - let the state change handler deal with it + this.logger.log('Connection error - will attempt to recover...') + } + handleVoiceConnectionStateChange(channel: BaseGuildVoiceChannel, connection: VoiceConnection): (oldState: VoiceConnectionState, newState: VoiceConnectionState) => Promise { return async (oldState, newState) => { - this.logger.withFields({ old: oldState.status, new: newState.status }).log( + this.logger.withFields({ new: newState.status, old: oldState.status }).log( `Voice connection state changed from ${oldState.status} to ${newState.status}`, ) @@ -127,41 +227,6 @@ export class VoiceManager extends EventEmitter { } } - handleVoiceConnectionError(error: unknown) { - this.logger.withError(error).log('Voice connection error') - // Don't immediately destroy - let the state change handler deal with it - this.logger.log('Connection error - will attempt to recover...') - } - - handleAudioReceiveStreamStart(channel: BaseGuildVoiceChannel): (userId: string) => Promise { - return async (userId) => { - let user = channel.members.get(userId) - if (!user) { - try { - user = await channel.guild.members.fetch(userId) - } - catch (error) { - this.logger.withError(error).error('Failed to fetch user') - } - } - if (user && !user?.user.bot) { - this.logger.log(`User speaking: ${user.displayName}`) - this.monitorMember(user as GuildMember, channel.id) - this.streams.get(userId)?.emit('speakingStarted') - } - } - } - - handleAudioReceiveStreamEnd(channel: BaseGuildVoiceChannel): (userId: string) => void { - return async (userId: string) => { - const user = channel.members.get(userId) - if (!user?.user.bot) { - this.logger.log(`User stopped speaking: ${user.displayName}`) - this.streams.get(userId)?.emit('speakingStopped') - } - } - } - async joinChannel(interaction: ChatInputCommandInteraction, channel: BaseGuildVoiceChannel) { const oldConnection = this.getVoiceConnection( channel.guildId as string, @@ -179,12 +244,12 @@ export class VoiceManager extends EventEmitter { } const connection = joinVoiceChannel({ - channelId: channel.id, - guildId: channel.guild.id, adapterCreator: channel.guild.voiceAdapterCreator as any, + channelId: channel.id, + group: this.client.user.id, + guildId: channel.guild.id, selfDeaf: false, selfMute: false, - group: this.client.user.id, }) try { @@ -220,6 +285,79 @@ export class VoiceManager extends EventEmitter { } } + leaveChannel(channel: BaseGuildVoiceChannel) { + const connection = this.connections.get(channel.id) + + if (connection) { + // Remove event listeners to prevent memory leaks + const listeners = this.connectionListeners.get(channel.id) + if (listeners) { + connection.off('stateChange', listeners.stateChange) + connection.off('error', listeners.error) + connection.receiver.speaking.off('start', listeners.speakingStart) + connection.receiver.speaking.off('end', listeners.speakingEnd) + this.connectionListeners.delete(channel.id) + } + + connection.destroy() + this.connections.delete(channel.id) + } + + // Stop monitoring all members in this channel + for (const [memberId, monitorInfo] of this.activeMonitors) { + if (monitorInfo.channel.id === channel.id && memberId !== this.client.user?.id) { + this.stopMonitoringMember(memberId) + } + } + + this.logger.log(`Left voice channel: ${channel.name} (${channel.id})`) + } + + async playAudioStream(userId: string, audioStream: Readable) { + const connection = this.connections.get(userId) + if (connection == null) { + this.logger.log(`No connection for user ${userId}`) + return + } + + this.cleanupAudioPlayer(this.activeAudioPlayer) + const audioPlayer = createAudioPlayer({ + behaviors: { + noSubscriber: NoSubscriberBehavior.Pause, + }, + }) + + this.activeAudioPlayer = audioPlayer + connection.subscribe(audioPlayer) + + const audioStartTime = Date.now() + const resource = createAudioResource(audioStream, { + inputType: StreamType.Arbitrary, + }) + + audioPlayer.on('error', error => this.logger.withError(error).log('Audio player error')) + audioPlayer.on('stateChange', (_oldState: any, newState: { status: string }) => { + if (newState.status === 'idle') { + const idleTime = Date.now() + this.logger.withField('elapsed', idleTime - audioStartTime).log(`Audio playback done`) + } + }) + + audioPlayer.play(resource) + } + + stopMonitoringMember(memberId: string) { + const monitorInfo = this.activeMonitors.get(memberId) + if (!monitorInfo) { + return + } + + monitorInfo.monitor.stop() + this.activeMonitors.delete(memberId) + this.streams.delete(memberId) + this.logger.log(`Stopped monitoring user ${memberId}`) + } + private getVoiceConnection(guildId: string) { const connections = getVoiceConnections(this.client.user.id) if (!connections) { @@ -236,6 +374,57 @@ export class VoiceManager extends EventEmitter { return connection } + private async handleUserStream( + userId: string, + member: GuildMember, + guildId: string, + channelId: string, + audioStream: Readable, + ) { + this.logger.log(`Starting audio monitor for user: ${userId}`) + + if (!this.userStates.has(userId)) { + this.userStates.set(userId, { + buffers: [], + lastActive: Date.now(), + totalLength: 0, + transcriptionText: '', + }) + } + + const state = this.userStates.get(userId) + + const processBuffer = async (buffer: Buffer) => { + try { + state!.buffers.push(buffer) + state!.totalLength += buffer.length + state!.lastActive = Date.now() + + this.debouncedProcessTranscription(userId, member, guildId, channelId) + } + catch (error) { + this.logger.withError(error).withField('userId', userId).error('Error processing buffer') + } + } + + const _ = new AudioMonitor( + audioStream, + 10000000, + () => { + if (this.transcriptionTimeout) + clearTimeout(this.transcriptionTimeout) + }, + async (buffer) => { + if (!buffer) { + this.logger.error('Received empty buffer') + return + } + + await processBuffer(buffer) + }, + ) + } + private async monitorMember( member: GuildMember, channelId: string, @@ -316,137 +505,6 @@ export class VoiceManager extends EventEmitter { await this.handleUserStream(userId, member, member.guild.id, channelId, opusDecoder) } - leaveChannel(channel: BaseGuildVoiceChannel) { - const connection = this.connections.get(channel.id) - - if (connection) { - // Remove event listeners to prevent memory leaks - const listeners = this.connectionListeners.get(channel.id) - if (listeners) { - connection.off('stateChange', listeners.stateChange) - connection.off('error', listeners.error) - connection.receiver.speaking.off('start', listeners.speakingStart) - connection.receiver.speaking.off('end', listeners.speakingEnd) - this.connectionListeners.delete(channel.id) - } - - connection.destroy() - this.connections.delete(channel.id) - } - - // Stop monitoring all members in this channel - for (const [memberId, monitorInfo] of this.activeMonitors) { - if (monitorInfo.channel.id === channel.id && memberId !== this.client.user?.id) { - this.stopMonitoringMember(memberId) - } - } - - this.logger.log(`Left voice channel: ${channel.name} (${channel.id})`) - } - - stopMonitoringMember(memberId: string) { - const monitorInfo = this.activeMonitors.get(memberId) - if (!monitorInfo) { - return - } - - monitorInfo.monitor.stop() - this.activeMonitors.delete(memberId) - this.streams.delete(memberId) - this.logger.log(`Stopped monitoring user ${memberId}`) - } - - async debouncedProcessTranscription( - userId: string, - member: GuildMember, - guildId: string, - channelId: string, - ) { - const DEBOUNCE_TRANSCRIPTION_THRESHOLD = 1500 // wait for 1.5 seconds of silence - - if (this.activeAudioPlayer?.state?.status === 'idle') { - this.logger.log('Cleaning up idle audio player.') - this.cleanupAudioPlayer(this.activeAudioPlayer) - } - if (this.activeAudioPlayer || this.processingVoice) { - const state = this.userStates.get(userId) - if (state) { - state.buffers.length = 0 - state.totalLength = 0 - } - return - } - if (this.transcriptionTimeout) { - clearTimeout(this.transcriptionTimeout) - } - - this.transcriptionTimeout = setTimeout(async () => { - this.processingVoice = true - try { - await this.processTranscription(userId, member, guildId, channelId) - // Clean all users' previous buffers - this.userStates.forEach((state, _) => { - state.buffers.length = 0 - state.totalLength = 0 - }) - } - finally { - this.processingVoice = false - } - }, DEBOUNCE_TRANSCRIPTION_THRESHOLD) - } - - private async handleUserStream( - userId: string, - member: GuildMember, - guildId: string, - channelId: string, - audioStream: Readable, - ) { - this.logger.log(`Starting audio monitor for user: ${userId}`) - - if (!this.userStates.has(userId)) { - this.userStates.set(userId, { - buffers: [], - totalLength: 0, - lastActive: Date.now(), - transcriptionText: '', - }) - } - - const state = this.userStates.get(userId) - - const processBuffer = async (buffer: Buffer) => { - try { - state!.buffers.push(buffer) - state!.totalLength += buffer.length - state!.lastActive = Date.now() - - this.debouncedProcessTranscription(userId, member, guildId, channelId) - } - catch (error) { - this.logger.withError(error).withField('userId', userId).error('Error processing buffer') - } - } - - const _ = new AudioMonitor( - audioStream, - 10000000, - () => { - if (this.transcriptionTimeout) - clearTimeout(this.transcriptionTimeout) - }, - async (buffer) => { - if (!buffer) { - this.logger.error('Received empty buffer') - return - } - - await processBuffer(buffer) - }, - ) - } - private async processTranscription( userId: string, member: GuildMember, @@ -478,13 +536,13 @@ export class VoiceManager extends EventEmitter { } satisfies Discord this.airiClient.send({ + data: { discord: discordContext, transcription: transcriptionText }, type: 'input:text:voice', - data: { transcription: transcriptionText, discord: discordContext }, }) this.airiClient.send({ + data: { discord: discordContext, text: transcriptionText }, type: 'input:text', - data: { text: transcriptionText, discord: discordContext }, }) } if (state.transcriptionText.length) { @@ -499,81 +557,25 @@ export class VoiceManager extends EventEmitter { this.logger.withError(error).withField('userId', userId).error('Error processing transcription') } } +} - async playAudioStream(userId: string, audioStream: Readable) { - const connection = this.connections.get(userId) - if (connection == null) { - this.logger.log(`No connection for user ${userId}`) - return - } +function isValidTranscription(text: string): boolean { + if (!text || text.includes('[BLANK_AUDIO]')) + return false + return true +} - this.cleanupAudioPlayer(this.activeAudioPlayer) - const audioPlayer = createAudioPlayer({ - behaviors: { - noSubscriber: NoSubscriberBehavior.Pause, - }, - }) +// eliza/packages/client-discord/src/voice.ts at develop · elizaOS/eliza +// https://github.com/elizaOS/eliza/blob/develop/packages/client-discord/src/voice.ts - this.activeAudioPlayer = audioPlayer - connection.subscribe(audioPlayer) - - const audioStartTime = Date.now() - const resource = createAudioResource(audioStream, { - inputType: StreamType.Arbitrary, - }) - - audioPlayer.on('error', error => this.logger.withError(error).log('Audio player error')) - audioPlayer.on('stateChange', (_oldState: any, newState: { status: string }) => { - if (newState.status === 'idle') { - const idleTime = Date.now() - this.logger.withField('elapsed', idleTime - audioStartTime).log(`Audio playback done`) - } - }) - - audioPlayer.play(resource) - } - - cleanupAudioPlayer(audioPlayer: AudioPlayer) { - if (!audioPlayer) - return - - audioPlayer.stop() - audioPlayer.removeAllListeners() - if (audioPlayer === this.activeAudioPlayer) { - this.activeAudioPlayer = null - } - } - - async handleJoinChannelCommand(interaction: ChatInputCommandInteraction) { +async function setSelfVoice(logger: Logg, me?: GuildMember | null) { + if (me?.voice && me.permissions.has('DeafenMembers')) { try { - const currVoiceChannel = (interaction.member as GuildMember).voice.channel - if (!currVoiceChannel) { - return await interaction.reply('Please join a voice channel first.') - } - - await this.joinChannel(interaction, currVoiceChannel) + await me.voice.setDeaf(false) + await me.voice.setMute(false) } catch (error) { - this.logger.withError(error).log('Error joining voice channel') - } - } - - async handleLeaveChannelCommand(interaction: any) { - const connection = this.getVoiceConnection(interaction.guildId as any) - - if (!connection) { - await interaction.reply('Not currently in a voice channel.') - return - } - - try { - connection.destroy() - await interaction.reply('Left the voice channel.') - } - catch (error) { - this.logger.withError(error).log('Error leaving voice channel') - - await interaction.reply('Failed to leave the voice channel.') + logger.withError(error).log('Failed to modify voice state') // Continue anyway } } } diff --git a/integrations/discord-bot/src/index.ts b/integrations/discord-bot/src/index.ts index 671f5406e..619d04693 100644 --- a/integrations/discord-bot/src/index.ts +++ b/integrations/discord-bot/src/index.ts @@ -12,9 +12,9 @@ const log = useLogg('Bot').useGlobalConfig() async function main() { // Create Discord adapter with configuration const adapter = new DiscordAdapter({ - discordToken: env.DISCORD_TOKEN || '', // Fallback to env, but will be updated via WebSocket airiToken: env.AIRI_TOKEN || 'abcd', airiUrl: env.AIRI_URL || 'ws://localhost:6121/ws', + discordToken: env.DISCORD_TOKEN || '', // Fallback to env, but will be updated via WebSocket }) await adapter.start() diff --git a/integrations/discord-bot/src/pipelines/tts.ts b/integrations/discord-bot/src/pipelines/tts.ts index 537d1f66d..8b5ffa8f1 100644 --- a/integrations/discord-bot/src/pipelines/tts.ts +++ b/integrations/discord-bot/src/pipelines/tts.ts @@ -13,9 +13,9 @@ import { createOpenAI } from '@xsai-ext/providers/create' import { generateTranscription } from '@xsai/generate-transcription' export class WhisperLargeV3Pipeline { - static task: PipelineType = 'automatic-speech-recognition' - static model = 'Xenova/whisper-medium.en' static instance = null + static model = 'Xenova/whisper-medium.en' + static task: PipelineType = 'automatic-speech-recognition' static async getInstance(progress_callback = null) { if (this.instance === null) { @@ -26,6 +26,30 @@ export class WhisperLargeV3Pipeline { } } +export async function openaiTranscribe(wavBuffer: Buffer) { + const log = useLogg('Remote:Transcribe').useGlobalConfig() + + log.log('Transcribing audio...') + + const wavFile = new Blob([wavBuffer], { type: 'audio/wav' }) + const openai = createOpenAI(env.OPENAI_STT_API_KEY, env.OPENAI_STT_API_BASE_URL) + + try { + const result = await generateTranscription({ + ...openai.transcription(env.OPENAI_STT_MODEL), + file: wavFile, + }) + + log.withField('result', result.text).log('Transcription result') + return result.text + } + catch (err) { + log.withError(err).error('Failed to transcribe audio') + } + + return '' +} + export function textFromResult(result: Array<{ text: string }> | { text: string }) { if (Array.isArray(result)) { const arrayResult = result as { text: string }[] @@ -69,27 +93,3 @@ export async function transcribe(pcmBuffer: Buffer) { log.withField('result', text).log('Transcription result') return text } - -export async function openaiTranscribe(wavBuffer: Buffer) { - const log = useLogg('Remote:Transcribe').useGlobalConfig() - - log.log('Transcribing audio...') - - const wavFile = new Blob([wavBuffer], { type: 'audio/wav' }) - const openai = createOpenAI(env.OPENAI_STT_API_KEY, env.OPENAI_STT_API_BASE_URL) - - try { - const result = await generateTranscription({ - ...openai.transcription(env.OPENAI_STT_MODEL), - file: wavFile, - }) - - log.withField('result', result.text).log('Transcription result') - return result.text - } - catch (err) { - log.withError(err).error('Failed to transcribe audio') - } - - return '' -} diff --git a/integrations/discord-bot/src/utils/audio-monitor.ts b/integrations/discord-bot/src/utils/audio-monitor.ts index 8d52580e4..0aca5b6b7 100644 --- a/integrations/discord-bot/src/utils/audio-monitor.ts +++ b/integrations/discord-bot/src/utils/audio-monitor.ts @@ -7,12 +7,12 @@ import { useLogg } from '@guiiai/logg' // eliza/packages/client-discord/src/voice.ts at develop · elizaOS/eliza // https://github.com/elizaOS/eliza/blob/develop/packages/client-discord/src/voice.ts export class AudioMonitor { - private readable: Readable private buffers: Buffer[] = [] - private maxSize: number - private lastFlagged: number = -1 private ended: boolean = false + private lastFlagged: number = -1 private logger = useLogg('AudioMonitor').useGlobalConfig() + private maxSize: number + private readable: Readable constructor( readable: Readable, @@ -62,17 +62,6 @@ export class AudioMonitor { }) } - stop() { - this.readable.removeAllListeners('data') - this.readable.removeAllListeners('end') - this.readable.removeAllListeners('speakingStopped') - this.readable.removeAllListeners('speakingStarted') - } - - isFlagged() { - return this.lastFlagged >= 0 - } - getBufferFromFlag() { if (this.lastFlagged < 0) { return null @@ -86,12 +75,23 @@ export class AudioMonitor { return buffer } + isEnded() { + return this.ended + } + + isFlagged() { + return this.lastFlagged >= 0 + } + reset() { this.buffers = [] this.lastFlagged = -1 } - isEnded() { - return this.ended + stop() { + this.readable.removeAllListeners('data') + this.readable.removeAllListeners('end') + this.readable.removeAllListeners('speakingStopped') + this.readable.removeAllListeners('speakingStarted') } } diff --git a/integrations/discord-bot/src/utils/audio.ts b/integrations/discord-bot/src/utils/audio.ts index f51fcfcc1..29c635be5 100644 --- a/integrations/discord-bot/src/utils/audio.ts +++ b/integrations/discord-bot/src/utils/audio.ts @@ -2,6 +2,25 @@ import { Buffer } from 'node:buffer' import { DECODE_SAMPLE_RATE } from '../constants/audio' +export function convertOpusToWav(pcmBuffer: Buffer): Buffer { + try { + // Generate the WAV header + const wavHeader = getWavHeader( + pcmBuffer.length, + DECODE_SAMPLE_RATE, + ) + + // Concatenate the WAV header and PCM data + const wavBuffer = Buffer.concat([wavHeader, pcmBuffer]) + + return wavBuffer + } + catch (error) { + console.error('Error converting PCM to WAV:', error) + throw error + } +} + export function getWavHeader( audioLength: number, sampleRate: number, @@ -27,22 +46,3 @@ export function getWavHeader( wavHeader.writeUInt32LE(audioLength, 40) // Data chunk size return wavHeader } - -export function convertOpusToWav(pcmBuffer: Buffer): Buffer { - try { - // Generate the WAV header - const wavHeader = getWavHeader( - pcmBuffer.length, - DECODE_SAMPLE_RATE, - ) - - // Concatenate the WAV header and PCM data - const wavBuffer = Buffer.concat([wavHeader, pcmBuffer]) - - return wavBuffer - } - catch (error) { - console.error('Error converting PCM to WAV:', error) - throw error - } -} diff --git a/integrations/discord-bot/src/utils/opus.ts b/integrations/discord-bot/src/utils/opus.ts index f96d86221..93fe4a816 100644 --- a/integrations/discord-bot/src/utils/opus.ts +++ b/integrations/discord-bot/src/utils/opus.ts @@ -15,6 +15,10 @@ export class OpusDecoder extends Transform { this.decoder = new OpusScript(sampleRate, channels) } + _flush(callback: (...args: any[]) => void) { + callback() + } + _transform(chunk: Buffer, encoding: BufferEncoding, callback: (...args: any[]) => void) { try { // Decode Opus chunk to PCM @@ -29,8 +33,4 @@ export class OpusDecoder extends Transform { callback(error) } } - - _flush(callback: (...args: any[]) => void) { - callback() - } } diff --git a/integrations/minecraft/src/airi/airi-bridge.test.ts b/integrations/minecraft/src/airi/airi-bridge.test.ts index 2388b3b96..a429ec991 100644 --- a/integrations/minecraft/src/airi/airi-bridge.test.ts +++ b/integrations/minecraft/src/airi/airi-bridge.test.ts @@ -9,23 +9,23 @@ import { AiriBridge } from './airi-bridge' interface TestCommandEvent { data: { commandId: string - intent: 'plan' | 'proposal' | 'action' | 'pause' | 'resume' | 'reroute' | 'context' - interrupt: 'force' | 'soft' | false - priority: 'critical' | 'high' | 'normal' | 'low' guidance?: { options?: Array<{ label: string, steps: string[] }> } + intent: 'action' | 'context' | 'pause' | 'plan' | 'proposal' | 'reroute' | 'resume' + interrupt: 'force' | 'soft' | false + priority: 'critical' | 'high' | 'low' | 'normal' } } function createBridgeHarness(options: { commandAvailable?: boolean } = {}) { const handlers = new Map void>() const client = { - send: vi.fn(), + offEvent: vi.fn(), onEvent: vi.fn((type: string, handler: (event: TestCommandEvent) => void) => { handlers.set(type, handler) }), - offEvent: vi.fn(), + send: vi.fn(), } const eventBus = { emit: vi.fn(), @@ -60,9 +60,6 @@ describe('airiBridge spark command routing', () => { commandHandler?.({ data: { commandId: 'spark-1', - intent: 'action', - interrupt: false, - priority: 'normal', guidance: { options: [ { @@ -71,21 +68,24 @@ describe('airiBridge spark command routing', () => { }, ], }, + intent: 'action', + interrupt: false, + priority: 'normal', }, }) expect(eventBus.emit).toHaveBeenCalledWith(expect.objectContaining({ - type: 'signal:airi_command', payload: expect.objectContaining({ - type: 'airi_command', description: 'Directive from AIRI: "collect wood"', - sourceId: 'airi', metadata: expect.objectContaining({ message: 'collect wood', sparkCommandId: 'spark-1', sparkIntent: 'action', }), + sourceId: 'airi', + type: 'airi_command', }), + type: 'signal:airi_command', })) expect(eventBus.emit).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'signal:chat_message', @@ -105,9 +105,6 @@ describe('airiBridge spark command routing', () => { commandHandler?.({ data: { commandId: 'spark-offline', - intent: 'action', - interrupt: false, - priority: 'normal', guidance: { options: [ { @@ -116,16 +113,19 @@ describe('airiBridge spark command routing', () => { }, ], }, + intent: 'action', + interrupt: false, + priority: 'normal', }, }) expect(client.send).toHaveBeenCalledWith(expect.objectContaining({ - type: 'spark:emit', data: expect.objectContaining({ eventId: 'spark-offline', - state: 'dropped', note: 'Minecraft bot is offline', + state: 'dropped', }), + type: 'spark:emit', })) expect(eventBus.emit).not.toHaveBeenCalled() diff --git a/integrations/minecraft/src/airi/airi-bridge.ts b/integrations/minecraft/src/airi/airi-bridge.ts index 666b868e2..3a7e3eab8 100644 --- a/integrations/minecraft/src/airi/airi-bridge.ts +++ b/integrations/minecraft/src/airi/airi-bridge.ts @@ -8,12 +8,12 @@ import { nanoid } from 'nanoid' interface SparkCommandData { commandId: string - intent: 'plan' | 'proposal' | 'action' | 'pause' | 'resume' | 'reroute' | 'context' - interrupt: 'force' | 'soft' | false - priority: 'critical' | 'high' | 'normal' | 'low' guidance?: { options?: Array<{ label: string, steps: string[] }> } + intent: 'action' | 'context' | 'pause' | 'plan' | 'proposal' | 'reroute' | 'resume' + interrupt: 'force' | 'soft' | false + priority: 'critical' | 'high' | 'low' | 'normal' } /** @@ -31,10 +31,10 @@ interface SparkCommandData { * - Event handlers and send operations for the Minecraft side of the AIRI server seam. */ export class AiriBridge { - private readonly logger = useLogg('airi-bridge').useGlobalConfig() private commandAvailable = false private commandHandler: ((event: { data: SparkCommandData }) => void) | null = null private contextUpdateHandler: ((event: { data: ContextUpdate }) => void) | null = null + private readonly logger = useLogg('airi-bridge').useGlobalConfig() private moduleAnnouncedHandler: ((event: { data: ModuleAnnouncedEvent }) => void) | null = null private readonly moduleAnnouncedListeners = new Set<(event: ModuleAnnouncedEvent) => void>() @@ -43,10 +43,27 @@ export class AiriBridge { private readonly eventBus: EventBus, ) {} + destroy(): void { + if (this.commandHandler) { + this.client.offEvent('spark:command', this.commandHandler as Parameters>[1]) + this.commandHandler = null + } + if (this.contextUpdateHandler) { + this.client.offEvent('context:update', this.contextUpdateHandler as Parameters>[1]) + this.contextUpdateHandler = null + } + if (this.moduleAnnouncedHandler) { + this.client.offEvent('module:announced', this.moduleAnnouncedHandler as Parameters>[1]) + this.moduleAnnouncedHandler = null + } + this.moduleAnnouncedListeners.clear() + this.logger.log('AiriBridge destroyed') + } + init(): void { this.commandHandler = (event) => { const cmd = event.data - this.logger.log('Received spark:command', { intent: cmd.intent, commandId: cmd.commandId }) + this.logger.log('Received spark:command', { commandId: cmd.commandId, intent: cmd.intent }) if (!this.commandAvailable) { this.sendEmit(cmd.commandId, 'dropped', 'Minecraft bot is offline') @@ -72,21 +89,21 @@ export class AiriBridge { this.logger.log('Received context:update', { lane: ctx.lane, preview: ctx.text.slice(0, 80) }) this.eventBus.emit({ - type: 'signal:airi_context', payload: Object.freeze({ - type: 'airi_context' as const, - description: ctx.text, - sourceId: 'airi', confidence: 1.0, - timestamp: Date.now(), + description: ctx.text, metadata: { - source: 'airi', contextId: ctx.contextId, - lane: ctx.lane ?? 'general', hints: ctx.hints ?? [], + lane: ctx.lane ?? 'general', + source: 'airi', }, + sourceId: 'airi', + timestamp: Date.now(), + type: 'airi_context' as const, }), source: { component: 'airi', id: 'bridge' }, + type: 'signal:airi_context', }) } @@ -104,49 +121,24 @@ export class AiriBridge { this.logger.log('AiriBridge initialized, listening for spark:command, context:update, and module:announced') } - destroy(): void { - if (this.commandHandler) { - this.client.offEvent('spark:command', this.commandHandler as Parameters>[1]) - this.commandHandler = null - } - if (this.contextUpdateHandler) { - this.client.offEvent('context:update', this.contextUpdateHandler as Parameters>[1]) - this.contextUpdateHandler = null - } - if (this.moduleAnnouncedHandler) { - this.client.offEvent('module:announced', this.moduleAnnouncedHandler as Parameters>[1]) - this.moduleAnnouncedHandler = null - } - this.moduleAnnouncedListeners.clear() - this.logger.log('AiriBridge destroyed') - } + onModuleAnnounced(listener: (event: ModuleAnnouncedEvent) => void) { + this.moduleAnnouncedListeners.add(listener) - sendNotify(headline: string, note?: string, urgency: 'immediate' | 'soon' | 'later' = 'soon'): void { - this.client.send({ - type: 'spark:notify', - data: { - id: nanoid(), - eventId: nanoid(), - kind: 'ping', - urgency, - headline, - note, - destinations: ['proj-airi:stage-*'], - }, - } as Parameters[0]) - this.logger.log('Sent spark:notify', { headline, urgency }) + return () => { + this.moduleAnnouncedListeners.delete(listener) + } } sendContextUpdate(text: string, hints?: string[], lane?: string): void sendContextUpdate(update: ContextUpdate): void - sendContextUpdate(textOrUpdate: string | Omit & { contextId?: string }, hints?: string[], lane = 'game'): void { + sendContextUpdate(textOrUpdate: Omit & { contextId?: string } | string, hints?: string[], lane = 'game'): void { const update = typeof textOrUpdate === 'string' ? { - text: textOrUpdate, hints, lane, strategy: ContextUpdateStrategy.AppendSelf, - } satisfies Omit & { contextId?: string } + text: textOrUpdate, + } satisfies Omit & { contextId?: string } : { strategy: ContextUpdateStrategy.AppendSelf, ...textOrUpdate, @@ -154,46 +146,54 @@ export class AiriBridge { const contextId = update.contextId ?? nanoid() this.client.send({ - type: 'context:update', data: { - id: nanoid(), contextId, - lane: update.lane, - text: update.text, - hints: update.hints, - strategy: update.strategy, destinations: update.destinations, + hints: update.hints, + id: nanoid(), + lane: update.lane, + strategy: update.strategy, + text: update.text, }, + type: 'context:update', } as Parameters[0]) - this.logger.log('Sent context:update', { lane: update.lane, preview: update.text.slice(0, 80), contextId }) + this.logger.log('Sent context:update', { contextId, lane: update.lane, preview: update.text.slice(0, 80) }) } - sendEmit(eventId: string, state: 'queued' | 'working' | 'done' | 'dropped', note?: string): void { + sendEmit(eventId: string, state: 'done' | 'dropped' | 'queued' | 'working', note?: string): void { this.client.send({ - type: 'spark:emit', data: { - id: nanoid(), eventId, - state, + id: nanoid(), note, + state, }, + type: 'spark:emit', } as Parameters[0]) this.logger.log('Sent spark:emit', { eventId, state }) } + sendNotify(headline: string, note?: string, urgency: 'immediate' | 'later' | 'soon' = 'soon'): void { + this.client.send({ + data: { + destinations: ['proj-airi:stage-*'], + eventId: nanoid(), + headline, + id: nanoid(), + kind: 'ping', + note, + urgency, + }, + type: 'spark:notify', + } as Parameters[0]) + this.logger.log('Sent spark:notify', { headline, urgency }) + } + /** Enables command delivery only while a Minecraft bot runtime can consume it. */ setCommandAvailable(available: boolean): void { this.commandAvailable = available } - onModuleAnnounced(listener: (event: ModuleAnnouncedEvent) => void) { - this.moduleAnnouncedListeners.add(listener) - - return () => { - this.moduleAnnouncedListeners.delete(listener) - } - } - private handleActionIntent(cmd: SparkCommandData): void { // A spark:command is high-level guidance from the AIRI server. Route it through the explicit // `airi_command` signal so the brain runs a fresh decision cycle @@ -218,21 +218,21 @@ export class AiriBridge { }) this.eventBus.emit({ - type: 'signal:airi_command', payload: Object.freeze({ - type: 'airi_command' as const, - description: `Directive from AIRI: "${message}"`, - sourceId, confidence: 1.0, - timestamp: Date.now(), + description: `Directive from AIRI: "${message}"`, metadata: { message, // Keep the spark provenance for debugging; the brain sees a typed AIRI directive. sparkCommandId: cmd.commandId, sparkIntent: cmd.intent, }, + sourceId, + timestamp: Date.now(), + type: 'airi_command' as const, }), source: { component: 'airi', id: 'bridge' }, + type: 'signal:airi_command', }) } } diff --git a/integrations/minecraft/src/airi/minecraft-context-service.test.ts b/integrations/minecraft/src/airi/minecraft-context-service.test.ts index f4fe1dd63..452851f30 100644 --- a/integrations/minecraft/src/airi/minecraft-context-service.test.ts +++ b/integrations/minecraft/src/airi/minecraft-context-service.test.ts @@ -9,13 +9,13 @@ type ContextBot = Parameters[0] /** Minimal bot stub exposing only the status fields owned by the context module. */ function fakeBot(): ContextBot { return { - username: 'Airi', bot: { entity: { position: { x: 1, y: 2, z: 3 } }, - health: 20, game: { gameMode: 'survival' }, - players: { Airi: {}, dssadg: {}, Bob: {} }, + health: 20, + players: { Airi: {}, Bob: {}, dssadg: {} }, }, + username: 'Airi', } } @@ -36,9 +36,9 @@ function makeService(masterUsername?: string) { } const service = new MinecraftContextService({ airiBridge, + masterUsername, serverHost: '127.0.0.1', serverPort: 25565, - masterUsername, }) return { @@ -59,7 +59,7 @@ describe('minecraftContextService desktop relay context', () => { * expect(update.text).toContain('builtIn_emitSparkCommand') */ it('publishes the generic relay tool contract and configured master while the bot is online', () => { - const { airiBridge, service, captured } = makeService('dssadg') + const { airiBridge, captured, service } = makeService('dssadg') service.bindBot(fakeBot()) @@ -82,7 +82,7 @@ describe('minecraftContextService desktop relay context', () => { * expect(update.text).toContain('Desktop command relay: unavailable.') */ it('replaces the relay context with an offline capability when the bot unbinds', () => { - const { airiBridge, service, captured } = makeService() + const { airiBridge, captured, service } = makeService() service.bindBot(fakeBot()) service.unbindBot() @@ -102,16 +102,16 @@ describe('minecraftContextService desktop relay context', () => { * expect(update.destinations).toEqual(['instance:stage-1']) */ it('replays the current relay capability to a newly announced Stage instance', () => { - const { service, captured, getModuleAnnouncedListener } = makeService() + const { captured, getModuleAnnouncedListener, service } = makeService() service.init() getModuleAnnouncedListener()?.({ - name: 'proj-airi:stage-tamagotchi', identity: { id: 'stage-1', kind: 'plugin', plugin: { id: 'stage-tamagotchi' }, }, + name: 'proj-airi:stage-tamagotchi', }) const update = captured[0] diff --git a/integrations/minecraft/src/airi/minecraft-context-service.ts b/integrations/minecraft/src/airi/minecraft-context-service.ts index c1bac794d..2742b5438 100644 --- a/integrations/minecraft/src/airi/minecraft-context-service.ts +++ b/integrations/minecraft/src/airi/minecraft-context-service.ts @@ -5,30 +5,18 @@ import type { MineflayerWithAgents } from '../cognitive/types' import { ContextUpdateStrategy } from '@proj-airi/server-sdk' import { nanoid } from 'nanoid' -interface MinecraftStatusSnapshot { - botUsername: string - serverHost: string - serverPort: number - position: string - health: string - gameMode: string - otherPlayers: string[] - /** The owner's in-game username (from BOT_MASTER_USERNAME), if configured. */ - masterUsername?: string -} - interface MinecraftContextBot { - username: MineflayerWithAgents['username'] bot: { entity?: { position?: Pick } - health?: MineflayerWithAgents['bot']['health'] game?: { gameMode?: MineflayerWithAgents['bot']['game']['gameMode'] } + health?: MineflayerWithAgents['bot']['health'] players?: Partial, unknown>> } + username: MineflayerWithAgents['username'] } interface MinecraftContextBridge { @@ -37,54 +25,23 @@ interface MinecraftContextBridge { setCommandAvailable: (available: boolean) => void } +interface MinecraftStatusSnapshot { + botUsername: string + gameMode: string + health: string + /** The owner's in-game username (from BOT_MASTER_USERNAME), if configured. */ + masterUsername?: string + otherPlayers: string[] + position: string + serverHost: string + serverPort: number +} + const STATUS_CONTEXT_ID = 'minecraft:status' const STATUS_LANE = 'minecraft:status' const STATUS_REFRESH_INTERVAL_MS = 5_000 const DESKTOP_RELAY_TOOL_NAME = 'builtIn_emitSparkCommand' -function toPositionString(bot: MinecraftContextBot) { - const position = bot.bot.entity?.position - return position - ? `x: ${position.x.toFixed(1)}, y: ${position.y.toFixed(1)}, z: ${position.z.toFixed(1)}` - : 'unknown' -} - -function buildStatusText(snapshot: MinecraftStatusSnapshot) { - return [ - `Bot online: ${snapshot.botUsername}`, - 'Desktop command relay: available.', - `When the user asks to instruct or control this Minecraft bot, call the ${DESKTOP_RELAY_TOOL_NAME} tool.`, - 'Set destinations to ["minecraft-bot"], set intent to "action", and put the user\'s Minecraft instruction in guidance.options[0].label and guidance.options[0].steps.', - 'Do not claim that an instruction was relayed unless the tool call succeeds.', - `Server: ${snapshot.serverHost}:${snapshot.serverPort}`, - `Position: ${snapshot.position}`, - `Health: ${snapshot.health}/20, Mode: ${snapshot.gameMode}`, - `Other players online: ${snapshot.otherPlayers.length > 0 ? snapshot.otherPlayers.join(', ') : 'none'}`, - ...(snapshot.masterUsername ? [`Master (your owner) in-game username: ${snapshot.masterUsername}`] : []), - ].join('\n') -} - -function buildOfflineStatusText(serverHost: string, serverPort: number, masterUsername?: string) { - return [ - 'Bot offline: no active Minecraft bot.', - 'Desktop command relay: unavailable.', - `Do not call the ${DESKTOP_RELAY_TOOL_NAME} tool for Minecraft until a later status context says that the bot is online.`, - `Configured server: ${serverHost}:${serverPort}`, - ...(masterUsername ? [`Configured in-game master username: ${masterUsername}`] : []), - ].join('\n') -} - -function collectFrontendDestinations(event: ModuleAnnouncedEvent) { - const pluginId = event.identity?.plugin?.id - const instanceId = event.identity?.id - - if (!pluginId || !instanceId) { - return [] - } - - return [`instance:${instanceId}`] -} - /** * Publishes Minecraft capability and status context through the AIRI server event seam. * @@ -100,43 +57,28 @@ function collectFrontendDestinations(event: ModuleAnnouncedEvent) { * - Replace-self status context that describes relay availability and the existing generic relay tool. */ export class MinecraftContextService { - private runtimeBot: MinecraftContextBot | null = null private currentSnapshot: MinecraftStatusSnapshot | null = null private lastPublishedText = '' - private refreshTimer: ReturnType | null = null - private unsubscribeModuleAnnounced: (() => void) | null = null + private readonly masterUsername?: string + private refreshTimer: null | ReturnType = null + private runtimeBot: MinecraftContextBot | null = null private readonly serverHost: string private readonly serverPort: number - private readonly masterUsername?: string + private unsubscribeModuleAnnounced: (() => void) | null = null constructor(private readonly deps: { airiBridge: MinecraftContextBridge - serverHost: string - serverPort: number masterUsername?: string refreshIntervalMs?: number + serverHost: string + serverPort: number }) { this.serverHost = deps.serverHost this.serverPort = deps.serverPort this.masterUsername = deps.masterUsername } - init() { - if (this.unsubscribeModuleAnnounced) { - return - } - - this.unsubscribeModuleAnnounced = this.deps.airiBridge.onModuleAnnounced((event) => { - const destinations = collectFrontendDestinations(event) - if (destinations.length === 0) { - return - } - - this.publishStatus({ force: true, destinations }) - }) - } - bindBot(bot: MinecraftContextBot) { this.runtimeBot = bot this.deps.airiBridge.setCommandAvailable(true) @@ -152,6 +94,61 @@ export class MinecraftContextService { }, this.deps.refreshIntervalMs ?? STATUS_REFRESH_INTERVAL_MS) } + destroy() { + this.unbindBot() + this.unsubscribeModuleAnnounced?.() + this.unsubscribeModuleAnnounced = null + } + + getStatusSnapshot() { + return this.currentSnapshot ? { ...this.currentSnapshot, otherPlayers: [...this.currentSnapshot.otherPlayers] } : null + } + + init() { + if (this.unsubscribeModuleAnnounced) { + return + } + + this.unsubscribeModuleAnnounced = this.deps.airiBridge.onModuleAnnounced((event) => { + const destinations = collectFrontendDestinations(event) + if (destinations.length === 0) { + return + } + + this.publishStatus({ destinations, force: true }) + }) + } + + publishStatus(options: { destinations?: string[], force?: boolean } = {}) { + const snapshot = this.refreshStatusSnapshot() + const text = snapshot + ? buildStatusText(snapshot) + : buildOfflineStatusText(this.serverHost, this.serverPort, this.masterUsername) + if (!options.force && text === this.lastPublishedText) { + return + } + + const update: ContextUpdate = { + contextId: STATUS_CONTEXT_ID, + hints: [ + 'status', + snapshot ? 'online' : 'offline', + ...(snapshot ? [snapshot.botUsername] : []), + ], + id: nanoid(), + lane: STATUS_LANE, + strategy: ContextUpdateStrategy.ReplaceSelf, + text, + } + + if (options.destinations?.length) { + update.destinations = options.destinations + } + + this.deps.airiBridge.sendContextUpdate(update) + this.lastPublishedText = text + } + unbindBot() { const wasBound = this.runtimeBot !== null @@ -168,46 +165,6 @@ export class MinecraftContextService { this.publishStatus({ force: true }) } - publishStatus(options: { force?: boolean, destinations?: string[] } = {}) { - const snapshot = this.refreshStatusSnapshot() - const text = snapshot - ? buildStatusText(snapshot) - : buildOfflineStatusText(this.serverHost, this.serverPort, this.masterUsername) - if (!options.force && text === this.lastPublishedText) { - return - } - - const update: ContextUpdate = { - id: nanoid(), - contextId: STATUS_CONTEXT_ID, - lane: STATUS_LANE, - text, - hints: [ - 'status', - snapshot ? 'online' : 'offline', - ...(snapshot ? [snapshot.botUsername] : []), - ], - strategy: ContextUpdateStrategy.ReplaceSelf, - } - - if (options.destinations?.length) { - update.destinations = options.destinations - } - - this.deps.airiBridge.sendContextUpdate(update) - this.lastPublishedText = text - } - - getStatusSnapshot() { - return this.currentSnapshot ? { ...this.currentSnapshot, otherPlayers: [...this.currentSnapshot.otherPlayers] } : null - } - - destroy() { - this.unbindBot() - this.unsubscribeModuleAnnounced?.() - this.unsubscribeModuleAnnounced = null - } - private refreshStatusSnapshot() { if (!this.runtimeBot) { return this.currentSnapshot @@ -219,15 +176,58 @@ export class MinecraftContextService { this.currentSnapshot = { botUsername: this.runtimeBot.username, + gameMode: this.runtimeBot.bot.game?.gameMode ?? 'unknown', + health: String(this.runtimeBot.bot.health ?? 20), + masterUsername: this.masterUsername, + otherPlayers, + position: toPositionString(this.runtimeBot), serverHost: this.serverHost, serverPort: this.serverPort, - position: toPositionString(this.runtimeBot), - health: String(this.runtimeBot.bot.health ?? 20), - gameMode: this.runtimeBot.bot.game?.gameMode ?? 'unknown', - otherPlayers, - masterUsername: this.masterUsername, } return this.currentSnapshot } } + +function buildOfflineStatusText(serverHost: string, serverPort: number, masterUsername?: string) { + return [ + 'Bot offline: no active Minecraft bot.', + 'Desktop command relay: unavailable.', + `Do not call the ${DESKTOP_RELAY_TOOL_NAME} tool for Minecraft until a later status context says that the bot is online.`, + `Configured server: ${serverHost}:${serverPort}`, + ...(masterUsername ? [`Configured in-game master username: ${masterUsername}`] : []), + ].join('\n') +} + +function buildStatusText(snapshot: MinecraftStatusSnapshot) { + return [ + `Bot online: ${snapshot.botUsername}`, + 'Desktop command relay: available.', + `When the user asks to instruct or control this Minecraft bot, call the ${DESKTOP_RELAY_TOOL_NAME} tool.`, + 'Set destinations to ["minecraft-bot"], set intent to "action", and put the user\'s Minecraft instruction in guidance.options[0].label and guidance.options[0].steps.', + 'Do not claim that an instruction was relayed unless the tool call succeeds.', + `Server: ${snapshot.serverHost}:${snapshot.serverPort}`, + `Position: ${snapshot.position}`, + `Health: ${snapshot.health}/20, Mode: ${snapshot.gameMode}`, + `Other players online: ${snapshot.otherPlayers.length > 0 ? snapshot.otherPlayers.join(', ') : 'none'}`, + ...(snapshot.masterUsername ? [`Master (your owner) in-game username: ${snapshot.masterUsername}`] : []), + ].join('\n') +} + +function collectFrontendDestinations(event: ModuleAnnouncedEvent) { + const pluginId = event.identity?.plugin?.id + const instanceId = event.identity?.id + + if (!pluginId || !instanceId) { + return [] + } + + return [`instance:${instanceId}`] +} + +function toPositionString(bot: MinecraftContextBot) { + const position = bot.bot.entity?.position + return position + ? `x: ${position.x.toFixed(1)}, y: ${position.y.toFixed(1)}, z: ${position.z.toFixed(1)}` + : 'unknown' +} diff --git a/integrations/minecraft/src/airi/start-background-client.ts b/integrations/minecraft/src/airi/start-background-client.ts index 6dccd39c7..4230f4c4f 100644 --- a/integrations/minecraft/src/airi/start-background-client.ts +++ b/integrations/minecraft/src/airi/start-background-client.ts @@ -22,8 +22,8 @@ export function startAiriClientConnection(client: AiriClientLike, deps: { unavailableReported = true deps.logger.withFields({ - url: deps.url, error: errorMessageFrom(error) ?? 'Unknown error', + url: deps.url, }).warn('AIRI server is unavailable; continuing startup without AIRI and retrying in background') } @@ -46,13 +46,13 @@ export function startAiriClientConnection(client: AiriClientLike, deps: { }) .catch((error) => { deps.logger.withFields({ - url: deps.url, error: errorMessageFrom(error) ?? 'Unknown error', + url: deps.url, }).warn('AIRI client stopped retrying') }) return { - reportUnavailable, reportDisconnected, + reportUnavailable, } } diff --git a/integrations/minecraft/src/cognitive/action/action-registry.ts b/integrations/minecraft/src/cognitive/action/action-registry.ts index fd1b5168d..fcb17276f 100644 --- a/integrations/minecraft/src/cognitive/action/action-registry.ts +++ b/integrations/minecraft/src/cognitive/action/action-registry.ts @@ -16,10 +16,10 @@ export class ActionRegistry { } /** - * Set the mineflayer instance for action execution + * Get action by name */ - public setMineflayer(mineflayer: Mineflayer): void { - this.mineflayer = mineflayer + public getAction(name: string): Action | undefined { + return this.actions.find(a => a.name === name) } /** @@ -32,7 +32,7 @@ export class ActionRegistry { /** * Perform an action by name */ - public async performAction(step: { description?: string, tool: string, params: any }): Promise { + public async performAction(step: { description?: string, params: any, tool: string }): Promise { if (!this.mineflayer) { throw new Error('Mineflayer instance not set in ActionRegistry') } @@ -61,9 +61,9 @@ export class ActionRegistry { } /** - * Get action by name + * Set the mineflayer instance for action execution */ - public getAction(name: string): Action | undefined { - return this.actions.find(a => a.name === name) + public setMineflayer(mineflayer: Mineflayer): void { + this.mineflayer = mineflayer } } diff --git a/integrations/minecraft/src/cognitive/action/llm-actions.ts b/integrations/minecraft/src/cognitive/action/llm-actions.ts index a86c7a2e9..b8fb0e7ed 100644 --- a/integrations/minecraft/src/cognitive/action/llm-actions.ts +++ b/integrations/minecraft/src/cognitive/action/llm-actions.ts @@ -15,63 +15,59 @@ import * as skills from '../../skills' // Utils const pad = (str: string): string => `\n${str}\n` -function toCoord(pos: { x: number, y: number, z: number }) { - return { x: pos.x, y: pos.y, z: pos.z } -} - function cloneVec3(pos: { x: number, y: number, z: number }): Vec3 { return new Vec3(pos.x, pos.y, pos.z) } +function toCoord(pos: { x: number, y: number, z: number }) { + return { x: pos.x, y: pos.y, z: pos.z } +} + export const actionsList: Action[] = [ { - name: 'chat', description: 'Send a chat message to players in the game. Use this to communicate, respond to questions, or announce what you are doing.', execution: 'sync', - schema: z.object({ - message: z.string().describe('The message to send in chat.'), - feedback: z.boolean().default(false).describe('Whether to emit FEEDBACK for this chat action. Keep false for normal conversation to avoid feedback loops.'), - }), + name: 'chat', perform: mineflayer => (message: string): string => { mineflayer.bot.chat(message) return `Sent message: "${message}"` }, + schema: z.object({ + feedback: z.boolean().default(false).describe('Whether to emit FEEDBACK for this chat action. Keep false for normal conversation to avoid feedback loops.'), + message: z.string().describe('The message to send in chat.'), + }), }, { - name: 'giveUp', description: 'Admit you are currently stuck and halt all autonomous processing until a player speaks to you again.', execution: 'sync', + name: 'giveUp', + perform: () => (reason: string): string => `Gave up: ${reason}. Halted until player input.`, schema: z.object({ reason: z.string().min(1).describe('Short explanation of why you are stuck.'), }), - perform: () => (reason: string): string => `Gave up: ${reason}. Halted until player input.`, }, { - name: 'skip', description: 'Skip this turn without performing any world action.', execution: 'sync', - schema: z.object({}), + name: 'skip', perform: () => (): string => 'Skipped turn', + schema: z.object({}), }, { - name: 'stop', description: 'Force stop all actions', // TODO: include name of the current action in description? execution: 'async', - schema: z.object({}), + name: 'stop', perform: mineflayer => async () => { mineflayer.interrupt('stop tool called') return 'all actions stopped' }, + schema: z.object({}), }, { - name: 'goToPlayer', description: 'Go to the given player.', execution: 'async', - schema: z.object({ - player_name: z.string().describe('The name of the player to go to.'), - closeness: z.number().describe('How close to get to the player in blocks.').min(0), - }), + name: 'goToPlayer', perform: mineflayer => async (player_name: string, closeness: number) => { const getPlayerPos = () => { const entity = mineflayer.bot.players[player_name]?.entity @@ -89,29 +85,28 @@ export const actionsList: Action[] = [ const distanceToTargetAfter = targetEnd ? selfEnd.distanceTo(targetEnd) : null return { - ok: result.ok, - reason: result.reason, - target: { player_name, closeness }, - startPos: toCoord(selfStart), - endPos: toCoord(selfEnd), - movedDistance: selfStart.distanceTo(selfEnd), - distanceToTargetBefore, distanceToTargetAfter, + distanceToTargetBefore, elapsedMs: result.elapsedMs, + endPos: toCoord(selfEnd), estimatedTimeMs: result.estimatedTimeMs, message: result.message, + movedDistance: selfStart.distanceTo(selfEnd), + ok: result.ok, + reason: result.reason, + startPos: toCoord(selfStart), + target: { closeness, player_name }, } }, + schema: z.object({ + closeness: z.number().describe('How close to get to the player in blocks.').min(0), + player_name: z.string().describe('The name of the player to go to.'), + }), }, { - name: 'followPlayer', description: 'Set idle auto-follow target handled by reflex runtime. While idle, the bot will keep following this player until cleared.', execution: 'sync', - readonly: true, - schema: z.object({ - player_name: z.string().describe('name of the player to follow.'), - follow_dist: z.number().describe('The distance to follow from.').min(0), - }), + name: 'followPlayer', perform: mineflayer => (player_name: string, follow_dist: number) => { const reflexManager = (mineflayer as any).reflexManager if (!reflexManager || typeof reflexManager.setFollowTarget !== 'function') @@ -120,13 +115,16 @@ export const actionsList: Action[] = [ reflexManager.setFollowTarget(player_name, follow_dist) return `Auto-follow enabled for player [${player_name}] at distance ${follow_dist}` }, + readonly: true, + schema: z.object({ + follow_dist: z.number().describe('The distance to follow from.').min(0), + player_name: z.string().describe('name of the player to follow.'), + }), }, { - name: 'clearFollowTarget', description: 'Disable idle auto-follow. Use this before independent exploration or when you no longer want to shadow a player.', execution: 'sync', - readonly: true, - schema: z.object({}), + name: 'clearFollowTarget', perform: mineflayer => () => { const reflexManager = (mineflayer as any).reflexManager if (!reflexManager || typeof reflexManager.clearFollowTarget !== 'function') @@ -135,18 +133,14 @@ export const actionsList: Action[] = [ reflexManager.clearFollowTarget() return 'Auto-follow disabled' }, + readonly: true, + schema: z.object({}), }, { - name: 'goToCoordinate', description: 'Go to the given x, y, z location. Uses full A* pathfinding that automatically breaks/digs blocks in the way. Do NOT manually mine-then-move block by block; just call this with the destination.', execution: 'async', followControl: 'detach', - schema: z.object({ - x: z.number().describe('The x coordinate.'), - y: z.number().describe('The y coordinate.').min(-64).max(320), - z: z.number().describe('The z coordinate.'), - closeness: z.number().describe('0 If want to be exactly at the position, otherwise a positive number in blocks for leniency.').min(0), - }), + name: 'goToCoordinate', perform: mineflayer => async (x: number, y: number, z: number, closeness: number) => { const selfStart = cloneVec3(mineflayer.bot.entity.position) const targetVec = new Vec3(x, y, z) @@ -158,100 +152,105 @@ export const actionsList: Action[] = [ const distanceToTargetAfter = selfEnd.distanceTo(targetVec) return { - ok: result.ok, - reason: result.reason, - target: { x, y, z, closeness }, - startPos: toCoord(selfStart), - endPos: toCoord(selfEnd), - movedDistance: selfStart.distanceTo(selfEnd), - distanceToTargetBefore, distanceToTargetAfter, - withinCloseness: distanceToTargetAfter <= closeness, + distanceToTargetBefore, elapsedMs: result.elapsedMs, + endPos: toCoord(selfEnd), estimatedTimeMs: result.estimatedTimeMs, message: result.message, + movedDistance: selfStart.distanceTo(selfEnd), + ok: result.ok, + reason: result.reason, + startPos: toCoord(selfStart), + target: { closeness, x, y, z }, + withinCloseness: distanceToTargetAfter <= closeness, } }, + schema: z.object({ + closeness: z.number().describe('0 If want to be exactly at the position, otherwise a positive number in blocks for leniency.').min(0), + x: z.number().describe('The x coordinate.'), + y: z.number().describe('The y coordinate.').min(-64).max(320), + z: z.number().describe('The z coordinate.'), + }), }, { - name: 'givePlayer', description: 'Give the specified item to the given player.', execution: 'async', - schema: z.object({ - player_name: z.string().describe('The name of the player to give the item to.'), - item_name: z.string().describe('The name of the item to give.'), - num: z.number().int().describe('The number of items to give.').min(1), - }), + name: 'givePlayer', perform: mineflayer => async (player_name: string, item_name: string, num: number) => { await skills.giveToPlayer(mineflayer, item_name, player_name, num) return `Gave [${item_name}]x${num} to player [${player_name}]` }, + schema: z.object({ + item_name: z.string().describe('The name of the item to give.'), + num: z.number().int().describe('The number of items to give.').min(1), + player_name: z.string().describe('The name of the player to give the item to.'), + }), }, { - name: 'consume', description: 'Eat/drink the given item.', execution: 'async', - schema: z.object({ - item_name: z.string().describe('The name of the item to consume.'), - }), + name: 'consume', perform: mineflayer => async (item_name: string) => { await skills.consume(mineflayer, item_name) return `Consumed [${item_name}]` }, + schema: z.object({ + item_name: z.string().describe('The name of the item to consume.'), + }), }, { - name: 'equip', description: 'Equip the given item.', execution: 'async', - schema: z.object({ - item_name: z.string().describe('The name of the item to equip.'), - }), + name: 'equip', perform: mineflayer => async (item_name: string) => { await equip(mineflayer, item_name) return `Equipped [${item_name}]` }, + schema: z.object({ + item_name: z.string().describe('The name of the item to equip.'), + }), }, { - name: 'putInChest', description: 'Put the given item in the nearest chest.', execution: 'async', - schema: z.object({ - item_name: z.string().describe('The name of the item to put in the chest.'), - num: z.number().int().describe('The number of items to put in the chest.').min(1), - }), + name: 'putInChest', perform: mineflayer => async (item_name: string, num: number) => { await putInChest(mineflayer, item_name, num) return `Put [${item_name}]x${num} in chest` }, + schema: z.object({ + item_name: z.string().describe('The name of the item to put in the chest.'), + num: z.number().int().describe('The number of items to put in the chest.').min(1), + }), }, { - name: 'takeFromChest', description: 'Take the given items from the nearest chest.', execution: 'async', - schema: z.object({ - item_name: z.string().describe('The name of the item to take.'), - num: z.number().int().describe('The number of items to take.').min(1), - }), + name: 'takeFromChest', perform: mineflayer => async (item_name: string, num: number) => { await takeFromChest(mineflayer, item_name, num) return `Took [${item_name}]x${num} from chest` }, + schema: z.object({ + item_name: z.string().describe('The name of the item to take.'), + num: z.number().int().describe('The number of items to take.').min(1), + }), }, { - name: 'discard', description: 'Discard the given item from the inventory.', execution: 'async', - schema: z.object({ - item_name: z.string().describe('The name of the item to discard.'), - num: z.number().int().describe('The number of items to discard.').min(1), - }), + name: 'discard', perform: mineflayer => async (item_name: string, num: number) => { await discard(mineflayer, item_name, num) return `Discarded [${item_name}]x${num}` }, + schema: z.object({ + item_name: z.string().describe('The name of the item to discard.'), + num: z.number().int().describe('The number of items to discard.').min(1), + }), }, { - name: 'collectBlocks', description: 'Automatically collect the nearest blocks of a given type.', execution: 'async', // NOTICE: detach auto-follow before mining. The idle auto-follow reflex drives the same @@ -259,31 +258,26 @@ export const actionsList: Action[] = [ // followed player, which both cancels navigation to the ore ("Path was stopped") and aborts the // in-progress bot.dig ("Digging aborted"), producing the stutter of repeated half-digs. followControl: 'detach', - schema: z.object({ - type: z.string().describe('The block type to collect.'), - num: z.number().int().describe('The number of blocks to collect.').min(1), - }), + name: 'collectBlocks', perform: mineflayer => async (type: string, num: number) => { const collected = await collectBlock(mineflayer, type, num) if (collected <= 0) { - throw new ActionError('RESOURCE_MISSING', `Failed to collect any ${type}`, { type, requested: num, collected }) + throw new ActionError('RESOURCE_MISSING', `Failed to collect any ${type}`, { collected, requested: num, type }) } return `Collected [${type}] x${collected}` }, + schema: z.object({ + num: z.number().int().describe('The number of blocks to collect.').min(1), + type: z.string().describe('The block type to collect.'), + }), }, { - name: 'mineBlockAt', description: 'Mine (break) a block at a specific position. Do NOT use this for regular resource collection. Use collectBlocks instead.', execution: 'async', // NOTICE: detach auto-follow before mining (same reason as collectBlocks) so the follow reflex // cannot interrupt bot.dig mid-break. followControl: 'detach', - schema: z.object({ - x: z.number().describe('The x coordinate.'), - y: z.number().describe('The y coordinate.'), - z: z.number().describe('The z coordinate.'), - expected_block_type: z.string().optional().describe('Optional: expected block type at the position (e.g. oak_log). If provided and mismatched, the action fails.'), - }), + name: 'mineBlockAt', perform: mineflayer => async (x: number, y: number, z: number, expected_block_type?: string) => { const pos = new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)) if (expected_block_type) { @@ -294,9 +288,9 @@ export const actionsList: Action[] = [ if (!matchesBlockAlias(expected_block_type, block.name)) { throw new ActionError('UNKNOWN', `Block type mismatch at ${pos}: expected ${expected_block_type}, got ${block.name}`, { - position: pos, - expected: expected_block_type, actual: block.name, + expected: expected_block_type, + position: pos, }) } } @@ -304,75 +298,78 @@ export const actionsList: Action[] = [ await breakBlockAt(mineflayer, pos.x, pos.y, pos.z) return `Mined block at (${pos.x}, ${pos.y}, ${pos.z})` }, + schema: z.object({ + expected_block_type: z.string().optional().describe('Optional: expected block type at the position (e.g. oak_log). If provided and mismatched, the action fails.'), + x: z.number().describe('The x coordinate.'), + y: z.number().describe('The y coordinate.'), + z: z.number().describe('The z coordinate.'), + }), }, { - name: 'craftRecipe', description: 'Craft an item. Automatically finds or places a crafting table if needed, and handles intermediate materials for basic items (planks, sticks). Use recipePlan first to check required materials for complex items.', execution: 'async', - schema: z.object({ - recipe_name: z.string().describe('The name of the output item to craft.'), - num: z.number().int().describe('The number of times to execute the recipe (craft count, NOT output item count). E.g. crafting planks once yields 4 planks, so num=2 yields 8 planks.').min(1), - }), + name: 'craftRecipe', perform: mineflayer => async (recipe_name: string, num: number) => { await skills.craftRecipe(mineflayer, recipe_name, num) return `Crafted [${recipe_name}] ${num} time(s)` }, + schema: z.object({ + num: z.number().int().describe('The number of times to execute the recipe (craft count, NOT output item count). E.g. crafting planks once yields 4 planks, so num=2 yields 8 planks.').min(1), + recipe_name: z.string().describe('The name of the output item to craft.'), + }), }, { - name: 'smeltItem', description: 'Smelt the given item the given number of times.', execution: 'async', - schema: z.object({ - item_name: z.string().describe('The name of the input item to smelt.'), - num: z.number().int().describe('The number of times to smelt the item.').min(1), - }), + name: 'smeltItem', perform: mineflayer => async (item_name: string, num: number) => { await skills.smeltItem(mineflayer, item_name, num) return `Smelted [${item_name}] ${num} time(s)` }, + schema: z.object({ + item_name: z.string().describe('The name of the input item to smelt.'), + num: z.number().int().describe('The number of times to smelt the item.').min(1), + }), }, { - name: 'clearFurnace', description: 'Take all items out of the nearest furnace.', execution: 'async', - schema: z.object({}), + name: 'clearFurnace', perform: mineflayer => async () => { await skills.clearNearestFurnace(mineflayer) return 'Cleared furnace' }, + schema: z.object({}), }, { - name: 'placeHere', description: 'Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.', execution: 'async', - schema: z.object({ - type: z.string().describe('The block type to place.'), - }), + name: 'placeHere', perform: mineflayer => async (type: string) => { const pos = mineflayer.bot.entity.position await placeBlock(mineflayer, type, pos.x, pos.y, pos.z) return `Placed [${type}] here` }, + schema: z.object({ + type: z.string().describe('The block type to place.'), + }), }, { - name: 'attack', description: 'Attack and kill the nearest entity of a given type.', execution: 'async', - schema: z.object({ - type: z.string().describe('The type of entity to attack.'), - }), + name: 'attack', perform: mineflayer => async (type: string) => { await skills.attackNearest(mineflayer, type, true) return `Attacked nearest [${type}]` }, + schema: z.object({ + type: z.string().describe('The type of entity to attack.'), + }), }, { - name: 'attackPlayer', description: 'Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.', execution: 'async', - schema: z.object({ - player_name: z.string().describe('The name of the player to attack.'), - }), + name: 'attackPlayer', perform: mineflayer => async (player_name: string) => { const player = mineflayer.bot.players[player_name]?.entity if (!player) { @@ -381,39 +378,42 @@ export const actionsList: Action[] = [ await skills.attackEntity(mineflayer, player, true) return `Attacked player [${player_name}]` }, + schema: z.object({ + player_name: z.string().describe('The name of the player to attack.'), + }), }, { - name: 'goToBed', description: 'Go to the nearest bed and sleep.', execution: 'async', - schema: z.object({}), + name: 'goToBed', perform: mineflayer => async () => { await skills.goToBed(mineflayer) return 'Slept in a bed' }, + schema: z.object({}), }, { - name: 'activate', description: 'Activate the nearest object of a given type.', execution: 'async', - schema: z.object({ - type: z.string().describe('The type of object to activate.'), - }), + name: 'activate', perform: mineflayer => async (type: string) => { await activateNearestBlock(mineflayer, type) return `Activated nearest [${type}]` }, + schema: z.object({ + type: z.string().describe('The type of object to activate.'), + }), }, { - name: 'recipePlan', description: 'Plan how to craft an item. Shows the full recipe tree, what resources you have, what you\'re missing, and whether you can craft it now. Use this BEFORE attempting to craft complex items to understand what you need.', execution: 'sync', - schema: z.object({ - item_name: z.string().describe('The name of the item you want to craft (e.g., "diamond_pickaxe", "oak_planks").'), - amount: z.number().int().min(1).default(1).describe('How many of the item you want to craft.'), - }), + name: 'recipePlan', perform: mineflayer => (item_name: string, amount: number = 1): string => { return pad(describeRecipePlan(mineflayer.bot, item_name, amount)) }, + schema: z.object({ + amount: z.number().int().min(1).default(1).describe('How many of the item you want to craft.'), + item_name: z.string().describe('The name of the item you want to craft (e.g., "diamond_pickaxe", "oak_planks").'), + }), }, ] diff --git a/integrations/minecraft/src/cognitive/action/task-executor.ts b/integrations/minecraft/src/cognitive/action/task-executor.ts index fa85a700c..bc6bb7058 100644 --- a/integrations/minecraft/src/cognitive/action/task-executor.ts +++ b/integrations/minecraft/src/cognitive/action/task-executor.ts @@ -13,9 +13,9 @@ interface TaskExecutorConfig { } export class TaskExecutor extends EventEmitter { - private logger: Logger - private initialized = false private actionRegistry: ActionRegistry + private initialized = false + private logger: Logger constructor(config: TaskExecutorConfig) { super() @@ -23,21 +23,6 @@ export class TaskExecutor extends EventEmitter { this.actionRegistry = new ActionRegistry() } - public async initialize(): Promise { - if (this.initialized) - return - - this.logger.log('Initializing Task Executor') - this.initialized = true - } - - /** - * Set the mineflayer instance for action execution - */ - public setMineflayer(mineflayer: Mineflayer): void { - this.actionRegistry.setMineflayer(mineflayer) - } - public async destroy(): Promise { this.initialized = false } @@ -64,14 +49,33 @@ export class TaskExecutor extends EventEmitter { return this.runSingleAction(action) } + public getAvailableActions() { + return this.actionRegistry.getAvailableActions() + } + + public async initialize(): Promise { + if (this.initialized) + return + + this.logger.log('Initializing Task Executor') + this.initialized = true + } + + /** + * Set the mineflayer instance for action execution + */ + public setMineflayer(mineflayer: Mineflayer): void { + this.actionRegistry.setMineflayer(mineflayer) + } + private async runSingleAction(action: ActionInstruction): Promise { this.emit('action:started', { action }) try { const step = { description: action.tool, - tool: action.tool, params: action.params, + tool: action.tool, } const result = await this.actionRegistry.performAction(step) @@ -90,8 +94,4 @@ export class TaskExecutor extends EventEmitter { throw error } } - - public getAvailableActions() { - return this.actionRegistry.getAvailableActions() - } } diff --git a/integrations/minecraft/src/cognitive/action/types/index.ts b/integrations/minecraft/src/cognitive/action/types/index.ts index a92057c05..ae3a7eff7 100644 --- a/integrations/minecraft/src/cognitive/action/types/index.ts +++ b/integrations/minecraft/src/cognitive/action/types/index.ts @@ -3,8 +3,8 @@ * All actions are tool invocations with a tool name and parameters. */ export interface ActionInstruction { - tool: string params: Record + tool: string } /** @@ -12,6 +12,6 @@ export interface ActionInstruction { */ export interface PlanStep { description: string - tool: string params: Record + tool: string } diff --git a/integrations/minecraft/src/cognitive/conscious/brain.test.ts b/integrations/minecraft/src/cognitive/conscious/brain.test.ts index e969e4a1e..6001d00f2 100644 --- a/integrations/minecraft/src/cognitive/conscious/brain.test.ts +++ b/integrations/minecraft/src/cognitive/conscious/brain.test.ts @@ -5,29 +5,46 @@ import { config } from '../../composables/config' import { ActionError } from '../../utils/errors' import { Brain } from './brain' -function createReflexSnapshot() { +function createAiriCommandEvent() { return { - self: { - health: 20, - food: 20, - holding: null, - location: { x: 0, y: 64, z: 0 }, + payload: { + confidence: 1, + description: 'Directive from AIRI: "continue"', + metadata: { message: 'continue', sparkCommandId: 'spark-1', sparkIntent: 'action' }, + sourceId: 'airi', + timestamp: Date.now(), + type: 'airi_command', }, - environment: { - time: 'day', - weather: 'clear', - nearbyPlayers: [], - nearbyEntities: [], - lightLevel: 15, - }, - social: {}, - threat: {}, - attention: {}, - autonomy: { - followPlayer: null, - followActive: false, - }, - } + source: { id: 'airi', type: 'airi' }, + timestamp: Date.now(), + type: 'perception', + } as any +} + +function createAsyncControlAction(name: string = 'goToPlayer') { + return { + description: `${name} action`, + execution: 'async', + name, + perform: () => async () => 'ok', + schema: z.object({ + closeness: z.number(), + player_name: z.string(), + }), + } as any +} + +function createChatAction() { + return { + description: 'Chat action', + execution: 'sync', + name: 'chat', + perform: () => () => 'chat sent', + schema: z.object({ + feedback: z.boolean().optional(), + message: z.string(), + }), + } as any } function createDeps(llmText: string) { @@ -39,9 +56,9 @@ function createDeps(llmText: string) { } const logger = { + error: vi.fn(), log: vi.fn(), warn: vi.fn(), - error: vi.fn(), withError: vi.fn(), } as any logger.withError.mockReturnValue(logger) @@ -49,131 +66,114 @@ function createDeps(llmText: string) { return { eventBus: { subscribe: vi.fn() }, llmAgent: { - callLLM: vi.fn(async () => ({ text: llmText, reasoning: '', usage: {} })), + callLLM: vi.fn(async () => ({ reasoning: '', text: llmText, usage: {} })), }, logger, + reflexManager: { + clearFollowTarget: vi.fn(), + getContextSnapshot: vi.fn(() => createReflexSnapshot()), + }, taskExecutor: { - getAvailableActions: vi.fn(() => []), executeActionWithResult: vi.fn(async () => 'ok'), + getAvailableActions: vi.fn(() => []), on: vi.fn(), }, - reflexManager: { - getContextSnapshot: vi.fn(() => createReflexSnapshot()), - clearFollowTarget: vi.fn(), - }, - } as any -} - -function createPerceptionEvent() { - return { - type: 'perception', - payload: { - type: 'chat_message', - description: 'Chat from Alex: "hi"', - sourceId: 'Alex', - confidence: 1, - timestamp: Date.now(), - metadata: { username: 'Alex', message: 'hi' }, - }, - source: { type: 'minecraft', id: 'Alex' }, - timestamp: Date.now(), - } as any -} - -function createAiriCommandEvent() { - return { - type: 'perception', - payload: { - type: 'airi_command', - description: 'Directive from AIRI: "continue"', - sourceId: 'airi', - confidence: 1, - timestamp: Date.now(), - metadata: { message: 'continue', sparkCommandId: 'spark-1', sparkIntent: 'action' }, - }, - source: { type: 'airi', id: 'airi' }, - timestamp: Date.now(), - } as any -} - -function createNonResumingPerceptionEvent() { - return { - type: 'perception', - payload: { - type: 'saliency_high', - description: 'Distant noise', - sourceId: 'world', - confidence: 1, - timestamp: Date.now(), - metadata: { action: 'noise' }, - }, - source: { type: 'minecraft', id: 'world' }, - timestamp: Date.now(), - } as any -} - -function createAsyncControlAction(name: string = 'goToPlayer') { - return { - name, - description: `${name} action`, - execution: 'async', - schema: z.object({ - player_name: z.string(), - closeness: z.number(), - }), - perform: () => async () => 'ok', - } as any -} - -function createReadonlyAction(name: string = 'querySnapshot') { - return { - name, - description: `${name} action`, - execution: 'sync', - readonly: true, - schema: z.object({}), - perform: () => () => 'ok', } as any } function createGiveUpAction() { return { - name: 'giveUp', description: 'Give up action', execution: 'sync', + name: 'giveUp', + perform: () => () => 'gave up', schema: z.object({ reason: z.string(), }), - perform: () => () => 'gave up', } as any } -function createChatAction() { +function createNonResumingPerceptionEvent() { return { - name: 'chat', - description: 'Chat action', - execution: 'sync', - schema: z.object({ - message: z.string(), - feedback: z.boolean().optional(), - }), - perform: () => () => 'chat sent', + payload: { + confidence: 1, + description: 'Distant noise', + metadata: { action: 'noise' }, + sourceId: 'world', + timestamp: Date.now(), + type: 'saliency_high', + }, + source: { id: 'world', type: 'minecraft' }, + timestamp: Date.now(), + type: 'perception', } as any } +function createPerceptionEvent() { + return { + payload: { + confidence: 1, + description: 'Chat from Alex: "hi"', + metadata: { message: 'hi', username: 'Alex' }, + sourceId: 'Alex', + timestamp: Date.now(), + type: 'chat_message', + }, + source: { id: 'Alex', type: 'minecraft' }, + timestamp: Date.now(), + type: 'perception', + } as any +} + +function createReadonlyAction(name: string = 'querySnapshot') { + return { + description: `${name} action`, + execution: 'sync', + name, + perform: () => () => 'ok', + readonly: true, + schema: z.object({}), + } as any +} + +function createReflexSnapshot() { + return { + attention: {}, + autonomy: { + followActive: false, + followPlayer: null, + }, + environment: { + lightLevel: 15, + nearbyEntities: [], + nearbyPlayers: [], + time: 'day', + weather: 'clear', + }, + self: { + food: 20, + health: 20, + holding: null, + location: { x: 0, y: 64, z: 0 }, + }, + social: {}, + threat: {}, + } +} + describe('brain no-action follow-up', () => { it('forgets conversation only', () => { const brain: any = new Brain(createDeps('await skip()')) - brain.conversationHistory = [{ role: 'user', content: 'old' }] + brain.conversationHistory = [{ content: 'old', role: 'user' }] brain.lastLlmInputSnapshot = { - systemPrompt: 'sys', - userMessage: 'msg', - messages: [], - conversationHistory: [], - updatedAt: Date.now(), attempt: 1, + conversationHistory: [], + messages: [], + systemPrompt: 'sys', + updatedAt: Date.now(), + userMessage: 'msg', } - brain.llmLogEntries = [{ id: 1, turnId: 1, kind: 'turn_input', timestamp: Date.now(), eventType: 'x', sourceType: 'x', sourceId: 'x', tags: [], text: 'x' }] + brain.llmLogEntries = [{ eventType: 'x', id: 1, kind: 'turn_input', sourceId: 'x', sourceType: 'x', tags: [], text: 'x', timestamp: Date.now(), turnId: 1 }] const result = brain.forgetConversation() @@ -215,13 +215,13 @@ inv; expect(enqueueSpy).toHaveBeenCalledTimes(1) const queuedEvent = (enqueueSpy.mock.calls[0] as any[])?.[1] expect(queuedEvent).toMatchObject({ - type: 'system_alert', - source: { type: 'system', id: 'brain:no_action_followup' }, payload: { + noActionBudget: { default: 3, max: 8, remaining: 2 }, reason: 'no_actions', returnValue: '2', - noActionBudget: { remaining: 2, default: 3, max: 8 }, }, + source: { id: 'brain:no_action_followup', type: 'system' }, + type: 'system_alert', }) }) @@ -246,10 +246,10 @@ inv; brain.enqueueEvent = enqueueSpy await brain.processEvent({} as any, { - type: 'system_alert', payload: { reason: 'seed' }, - source: { type: 'system', id: 'brain:no_action_followup' }, + source: { id: 'brain:no_action_followup', type: 'system' }, timestamp: Date.now(), + type: 'system_alert', }) expect(enqueueSpy).toHaveBeenCalledTimes(1) @@ -265,18 +265,18 @@ inv; const bot = { bot: { chat: vi.fn() } } await brain.processEvent(bot as any, { - type: 'system_alert', payload: { source: 'budget-test' }, - source: { type: 'system', id: 'budget-test' }, + source: { id: 'budget-test', type: 'system' }, timestamp: Date.now(), + type: 'system_alert', }) expect(enqueueSpy).toHaveBeenCalledTimes(1) const queuedEvent = (enqueueSpy.mock.calls[0] as any[])?.[1] expect(queuedEvent).toMatchObject({ - type: 'system_alert', - source: { type: 'system', id: 'brain:no_action_budget' }, payload: { reason: 'no_action_budget_exhausted' }, + source: { id: 'brain:no_action_budget', type: 'system' }, + type: 'system_alert', }) expect(bot.bot.chat).toHaveBeenCalledTimes(1) }) @@ -288,9 +288,9 @@ inv; await brain.processEvent({} as any, createPerceptionEvent()) expect(brain.getNoActionBudgetState()).toEqual({ - remaining: 3, default: 3, max: 8, + remaining: 3, }) }) @@ -319,9 +319,9 @@ inv; expect(brain.givenUp).toBe(false) expect(brain.giveUpReason).toBeUndefined() expect(brain.getNoActionBudgetState()).toEqual({ - remaining: 3, default: 3, max: 8, + remaining: 3, }) expect(deps.llmAgent.callLLM).toHaveBeenCalledTimes(1) }) @@ -437,13 +437,13 @@ inv; .find((event: any) => event?.source?.id === 'brain:error_burst_guard') expect(guardEvent).toMatchObject({ - type: 'system_alert', - source: { type: 'system', id: 'brain:error_burst_guard' }, payload: { reason: 'error_burst_guard', threshold: 3, windowTurns: 5, }, + source: { id: 'brain:error_burst_guard', type: 'system' }, + type: 'system_alert', }) expect(brain.errorBurstGuardState?.errorTurnCount).toBeGreaterThanOrEqual(3) }) @@ -451,12 +451,12 @@ inv; it('includes mandatory give-up and chat instructions when error-burst guard is active', () => { const brain: any = new Brain(createDeps('await skip()')) brain.errorBurstGuardState = { - threshold: 3, - windowTurns: 5, errorTurnCount: 3, - recentTurnIds: [7, 6, 5, 4, 3], recentErrorSummary: ['turn=7 repl_error: parse failed'], + recentTurnIds: [7, 6, 5, 4, 3], + threshold: 3, triggeredAtTurnId: 8, + windowTurns: 5, } const message = brain.buildUserMessage( @@ -476,12 +476,12 @@ inv; const brain: any = new Brain(deps) brain.errorBurstGuardState = { - threshold: 3, - windowTurns: 5, errorTurnCount: 3, - recentTurnIds: [7, 6, 5, 4, 3], recentErrorSummary: ['turn=7 repl_error: parse failed'], + recentTurnIds: [7, 6, 5, 4, 3], + threshold: 3, triggeredAtTurnId: 8, + windowTurns: 5, } await brain.processEvent({} as any, createPerceptionEvent()) @@ -497,19 +497,19 @@ inv; function createFeedbackEvent() { return { - type: 'feedback', - payload: { status: 'success', action: { tool: 'goToCoordinate', params: {} }, result: 'ok' }, - source: { type: 'system', id: 'executor' }, + payload: { action: { params: {}, tool: 'goToCoordinate' }, result: 'ok', status: 'success' }, + source: { id: 'executor', type: 'system' }, timestamp: Date.now(), + type: 'feedback', } as any } function createNoActionFollowupEvent() { return { - type: 'system_alert', - payload: { reason: 'no_actions', returnValue: '0', logs: [] }, - source: { type: 'system', id: 'brain:no_action_followup' }, + payload: { logs: [], reason: 'no_actions', returnValue: '0' }, + source: { id: 'brain:no_action_followup', type: 'system' }, timestamp: Date.now(), + type: 'system_alert', } as any } @@ -520,9 +520,9 @@ describe('brain queue coalescing', () => { // Simulate a queue with feedback events followed by a player chat const resolved: string[] = [] brain.queue = [ - { event: createFeedbackEvent(), resolve: () => resolved.push('fb1'), reject: vi.fn() }, - { event: createFeedbackEvent(), resolve: () => resolved.push('fb2'), reject: vi.fn() }, - { event: createPerceptionEvent(), resolve: () => resolved.push('chat'), reject: vi.fn() }, + { event: createFeedbackEvent(), reject: vi.fn(), resolve: () => resolved.push('fb1') }, + { event: createFeedbackEvent(), reject: vi.fn(), resolve: () => resolved.push('fb2') }, + { event: createPerceptionEvent(), reject: vi.fn(), resolve: () => resolved.push('chat') }, ] brain.coalesceQueue() @@ -536,9 +536,9 @@ describe('brain queue coalescing', () => { const brain: any = new Brain(createDeps('await skip()')) brain.queue = [ - { event: createNonResumingPerceptionEvent(), resolve: vi.fn(), reject: vi.fn() }, - { event: createFeedbackEvent(), resolve: vi.fn(), reject: vi.fn() }, - { event: createAiriCommandEvent(), resolve: vi.fn(), reject: vi.fn() }, + { event: createNonResumingPerceptionEvent(), reject: vi.fn(), resolve: vi.fn() }, + { event: createFeedbackEvent(), reject: vi.fn(), resolve: vi.fn() }, + { event: createAiriCommandEvent(), reject: vi.fn(), resolve: vi.fn() }, ] brain.coalesceQueue() @@ -554,10 +554,10 @@ describe('brain queue coalescing', () => { const resolved: string[] = [] brain.queue = [ - { event: createNoActionFollowupEvent(), resolve: () => resolved.push('followup1'), reject: vi.fn() }, - { event: createNoActionFollowupEvent(), resolve: () => resolved.push('followup2'), reject: vi.fn() }, - { event: createFeedbackEvent(), resolve: () => resolved.push('fb'), reject: vi.fn() }, - { event: createPerceptionEvent(), resolve: () => resolved.push('chat'), reject: vi.fn() }, + { event: createNoActionFollowupEvent(), reject: vi.fn(), resolve: () => resolved.push('followup1') }, + { event: createNoActionFollowupEvent(), reject: vi.fn(), resolve: () => resolved.push('followup2') }, + { event: createFeedbackEvent(), reject: vi.fn(), resolve: () => resolved.push('fb') }, + { event: createPerceptionEvent(), reject: vi.fn(), resolve: () => resolved.push('chat') }, ] brain.coalesceQueue() @@ -574,7 +574,7 @@ describe('brain queue coalescing', () => { const brain: any = new Brain(createDeps('await skip()')) brain.queue = [ - { event: createNoActionFollowupEvent(), resolve: vi.fn(), reject: vi.fn() }, + { event: createNoActionFollowupEvent(), reject: vi.fn(), resolve: vi.fn() }, ] brain.coalesceQueue() @@ -586,8 +586,8 @@ describe('brain queue coalescing', () => { const brain: any = new Brain(createDeps('await skip()')) brain.queue = [ - { event: createFeedbackEvent(), resolve: vi.fn(), reject: vi.fn() }, - { event: createNoActionFollowupEvent(), resolve: vi.fn(), reject: vi.fn() }, + { event: createFeedbackEvent(), reject: vi.fn(), resolve: vi.fn() }, + { event: createNoActionFollowupEvent(), reject: vi.fn(), resolve: vi.fn() }, ] brain.coalesceQueue() @@ -604,9 +604,9 @@ describe('brain queue coalescing', () => { const chat2 = { ...createPerceptionEvent(), payload: { ...createPerceptionEvent().payload, description: 'Chat from Alex: "second"' } } brain.queue = [ - { event: createFeedbackEvent(), resolve: vi.fn(), reject: vi.fn() }, - { event: chat1, resolve: vi.fn(), reject: vi.fn() }, - { event: chat2, resolve: vi.fn(), reject: vi.fn() }, + { event: createFeedbackEvent(), reject: vi.fn(), resolve: vi.fn() }, + { event: chat1, reject: vi.fn(), resolve: vi.fn() }, + { event: chat2, reject: vi.fn(), resolve: vi.fn() }, ] brain.coalesceQueue() @@ -624,13 +624,13 @@ describe('brain queue coalescing', () => { brain.queue = [ ...Array.from({ length: 256 }).fill({ event: createPerceptionEvent(), - resolve: vi.fn(), reject: vi.fn(), + resolve: vi.fn(), }), { event: createNoActionFollowupEvent(), - resolve: droppedResolver, reject: vi.fn(), + resolve: droppedResolver, }, ] @@ -648,13 +648,13 @@ describe('brain queue coalescing', () => { brain.queue = [ ...Array.from({ length: 256 }).fill({ event: createPerceptionEvent(), - resolve: vi.fn(), reject: vi.fn(), + resolve: vi.fn(), }), { event: createFeedbackEvent(), - resolve: feedbackResolver, reject: vi.fn(), + resolve: feedbackResolver, }, ] @@ -675,8 +675,8 @@ describe('brain queue coalescing', () => { } brain.queue = [ - { event: createPerceptionEvent(), resolve: vi.fn(), reject: vi.fn() }, - { event: feedbackEvent, resolve: vi.fn(), reject: vi.fn() }, + { event: createPerceptionEvent(), reject: vi.fn(), resolve: vi.fn() }, + { event: feedbackEvent, reject: vi.fn(), resolve: vi.fn() }, ] brain.coalesceQueue() @@ -721,8 +721,8 @@ describe('brain control action queue', () => { const snapshot = brain.getDebugSnapshot() expect(snapshot.actionQueue.counts.total).toBe(0) expect(deps.taskExecutor.executeActionWithResult).toHaveBeenCalledWith({ - tool: 'querySnapshot', params: {}, + tool: 'querySnapshot', }) }) @@ -751,8 +751,8 @@ describe('brain control action queue', () => { } await brain.enqueueControlAction(bot, { + params: { closeness: 2, player_name: 'Alex' }, tool: 'goToPlayer', - params: { player_name: 'Alex', closeness: 2 }, }, 1) await new Promise(resolve => setTimeout(resolve, 20)) diff --git a/integrations/minecraft/src/cognitive/conscious/brain.ts b/integrations/minecraft/src/cognitive/conscious/brain.ts index 9e5ae55f6..b4cfee551 100644 --- a/integrations/minecraft/src/cognitive/conscious/brain.ts +++ b/integrations/minecraft/src/cognitive/conscious/brain.ts @@ -36,172 +36,164 @@ import { generateBrainSystemPrompt } from './prompts/brain-prompt' import { normalizeReplScript } from './repl-code-normalizer' import { createCancellationToken } from './task-state' +type ActionQueueEntryState = 'cancelled' | 'executing' | 'failed' | 'pending' | 'succeeded' + +interface ActionQueueEntryView { + enqueuedAt: number + error?: string + finishedAt?: number + id: number + params: Record + result?: unknown + sourceTurnId: number + startedAt?: number + state: ActionQueueEntryState + tool: string +} + +interface ActionQueueSnapshot { + capacity: { + executing: number + pending: number + total: number + } + counts: { + executing: number + pending: number + total: number + } + executing: ActionQueueEntryView | null + pending: ActionQueueEntryView[] + recent: ActionQueueEntryView[] + updatedAt: number +} + interface BrainDeps { + airiBridge: AiriBridge eventBus: EventBus llmAgent: LLMAgent logger: Logg - taskExecutor: TaskExecutor - reflexManager: ReflexManager - airiBridge: AiriBridge minecraftContextService: MinecraftContextService + reflexManager: ReflexManager + taskExecutor: TaskExecutor +} + +interface ControlActionQueueEntry { + action: ActionInstruction + enqueuedAt: number + error?: string + finishedAt?: number + id: number + result?: unknown + sourceTurnId: number + startedAt?: number + state: ActionQueueEntryState +} + +interface DebugReplResult { + actions: Array<{ + error?: string + ok: boolean + params: Record + result?: string + tool: string + }> + code: string + durationMs: number + error?: string + logs: string[] + returnValue?: string + source: 'llm' | 'manual' + timestamp: number +} + +interface ErrorBurstGuardState { + errorTurnCount: number + recentErrorSummary: string[] + recentTurnIds: number[] + threshold: number + triggeredAtTurnId: number + windowTurns: number +} + +interface LlmInputSnapshot { + attempt: number + conversationHistory: Message[] + messages: Message[] + systemPrompt: string + updatedAt: number + userMessage: string +} + +interface LlmTraceEntry { + attempt: number + content: string + durationMs: number + estimatedTokens: number + eventType: string + id: number + // NOTICE: Full messages array is no longer stored to prevent O(turns²) memory growth. + // Use messageCount + estimatedTokens for diagnostics, or llmLog for detailed history. + messageCount: number + model: string + reasoning?: string + sourceId: string + sourceType: string + timestamp: number + turnId: number + usage?: { + completion_tokens?: number + prompt_tokens?: number + total_tokens?: number + } +} + +interface NoActionBudgetState { + default: number + max: number + remaining: number } interface QueuedEvent { event: BotEvent - resolve: () => void reject: (err: Error) => void + resolve: () => void } interface ReplOutcomeSummary { actionCount: number - okCount: number errorCount: number - returnValue?: string logs: string[] - updatedAt: number -} - -interface DebugReplResult { - source: 'manual' | 'llm' - code: string - logs: string[] - actions: Array<{ - tool: string - params: Record - ok: boolean - result?: string - error?: string - }> + okCount: number returnValue?: string - error?: string - durationMs: number - timestamp: number -} - -interface LlmInputSnapshot { - systemPrompt: string - userMessage: string - messages: Message[] - conversationHistory: Message[] updatedAt: number - attempt: number -} - -interface LlmTraceEntry { - id: number - turnId: number - timestamp: number - eventType: string - sourceType: string - sourceId: string - attempt: number - model: string - // NOTICE: Full messages array is no longer stored to prevent O(turns²) memory growth. - // Use messageCount + estimatedTokens for diagnostics, or llmLog for detailed history. - messageCount: number - estimatedTokens: number - content: string - reasoning?: string - usage?: { - prompt_tokens?: number - completion_tokens?: number - total_tokens?: number - } - durationMs: number } interface RuntimeInputEnvelope { - id: number - turnId: number - timestamp: number - event: { - type: string - sourceType: string - sourceId: string - payload: unknown - } contextView: string - userMessage: string - systemPrompt: { - preview: string - length: number + event: { + payload: unknown + sourceId: string + sourceType: string + type: string } + id: number llm?: { attempt: number model: string usage?: { - prompt_tokens?: number completion_tokens?: number + prompt_tokens?: number total_tokens?: number } } -} - -type ActionQueueEntryState = 'pending' | 'executing' | 'succeeded' | 'failed' | 'cancelled' - -interface ActionQueueEntryView { - id: number - tool: string - params: Record - state: ActionQueueEntryState - enqueuedAt: number - sourceTurnId: number - startedAt?: number - finishedAt?: number - result?: unknown - error?: string -} - -interface ActionQueueSnapshot { - executing: ActionQueueEntryView | null - pending: ActionQueueEntryView[] - recent: ActionQueueEntryView[] - capacity: { - total: number - executing: number - pending: number + systemPrompt: { + length: number + preview: string } - counts: { - total: number - executing: number - pending: number - } - updatedAt: number -} - -interface ControlActionQueueEntry { - id: number - action: ActionInstruction - sourceTurnId: number - state: ActionQueueEntryState - enqueuedAt: number - startedAt?: number - finishedAt?: number - result?: unknown - error?: string -} - -interface NoActionBudgetState { - remaining: number - default: number - max: number -} - -interface ErrorBurstGuardState { - threshold: number - windowTurns: number - errorTurnCount: number - recentTurnIds: number[] - recentErrorSummary: string[] - triggeredAtTurnId: number -} - -function truncateForPrompt(value: string, maxLength = 220): string { - // NOTICE: callers can pass undefined despite the `string` type — a successful action with no return - // value hits `JSON.stringify(undefined) === undefined` upstream, which previously crashed the whole - // brain turn here with "Cannot read properties of undefined (reading 'length')". Coerce defensively. - const text = typeof value === 'string' ? value : String(value ?? '') - return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}...` + timestamp: number + turnId: number + userMessage: string } function stringifyForLog(value: unknown): string { @@ -215,6 +207,14 @@ function stringifyForLog(value: unknown): string { } } +function truncateForPrompt(value: string, maxLength = 220): string { + // NOTICE: callers can pass undefined despite the `string` type — a successful action with no return + // value hits `JSON.stringify(undefined) === undefined` upstream, which previously crashed the whole + // brain turn here with "Cannot read properties of undefined (reading 'length')". Coerce defensively. + const text = typeof value === 'string' ? value : String(value ?? '') + return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}...` +} + const NO_ACTION_FOLLOWUP_SOURCE_ID = 'brain:no_action_followup' const NO_ACTION_BUDGET_ALERT_SOURCE_ID = 'brain:no_action_budget' @@ -241,184 +241,67 @@ const MAX_EVENT_QUEUE_LENGTH = 256 const MAX_CONSECUTIVE_HIGH_PRIORITY_TURNS = 8 const PAUSE_ABORT_ERROR_NAME = 'AbortError' -/** - * Turn a cryptic sandbox runtime error into actionable guidance the LLM can act on next turn. - * - * The dominant recurring failure is reading a coordinate (`.x`/`.y`/`.z`/`.pos`) off a query result - * that was `null` — e.g. `query.entities().whereName("pig").first().pos.x` when no pig was found. - * The raw message ("Cannot read properties of undefined (reading 'x')") gave the model nothing to - * fix, so it would repeat the same crash for several turns and then give up. Appending the concrete - * fix lets it recover in one turn. - * - * Before: - * - "Cannot read properties of undefined (reading 'x')" - * After: - * - "...reading 'x') — You read coordinates from a missing query result. Check for null first..." - */ -function augmentDecisionError(message: string): string { - if (/Cannot read properties of (?:undefined|null) \(reading '(?:[xyz]|pos|position|location)'\)/.test(message)) { - return `${message} — You tried to read coordinates from a missing object. query.entities()/query.blocks().first() returns null when no target is found, and reading .pos/.x from that value crashes. Fix it by checking for null first, for example: const t = query.entities().whereName("pig").first(); if (!t) { await chat({ message: "I do not see the target nearby, so I will search another direction.", feedback: false }) } else { await goToCoordinate({ x: t.pos.x, y: t.pos.y, z: t.pos.z, closeness: 1 }) }. Tip: to kill an animal, use attack({ type: "pig" }) against the nearest one; you usually do not need to query coordinates manually.` - } - return message -} - -function getEventPriority(event: BotEvent): number { - if (event.type === 'perception') { - const signal = event.payload as PerceptionSignal - if (signal.type === 'chat_message' || signal.type === 'airi_command') - return EVENT_PRIORITY_URGENT_PERCEPTION - return EVENT_PRIORITY_PERCEPTION - } - if (event.source.type === 'system' && event.source.id === NO_ACTION_FOLLOWUP_SOURCE_ID) - return EVENT_PRIORITY_NO_ACTION_FOLLOWUP - if (event.type === 'feedback') - return EVENT_PRIORITY_FEEDBACK - return EVENT_PRIORITY_PERCEPTION -} - export class Brain { - private debugService: DebugService - private readonly repl = new JavaScriptPlanner() - private paused = false - - // State - private queue: QueuedEvent[] = [] - private consecutiveHighPriorityTurns = 0 - private isProcessing = false - private isReplEvaluating = false - private currentCancellationToken: CancellationToken | undefined - private currentLlmAbortController: AbortController | null = null - private givenUp = false - private giveUpReason: string | undefined - private lastContextView: string | undefined - private lastReplOutcome: ReplOutcomeSummary | undefined - private conversationHistory: Message[] = [] - private lastLlmInputSnapshot: LlmInputSnapshot | null = null - private runtimeMineflayer: MineflayerWithAgents | null = null - private readonly llmLogEntries: LlmLogEntry[] = [] - private llmLogIdCounter = 0 - private readonly llmTraceEntries: LlmTraceEntry[] = [] - private llmTraceIdCounter = 0 - private turnCounter = 0 - private currentInputEnvelope: RuntimeInputEnvelope | null = null - private readonly llmLogRuntime = createLlmLogRuntime(() => this.llmLogEntries) - private readonly patternRuntime = createPatternRuntime(PATTERN_CATALOG) - private readonly historyRuntime = createHistoryRuntime({ - getConversationHistory: () => this.conversationHistory, - getLlmLogEntries: () => this.llmLogEntries, - getCurrentTurnId: () => this.turnCounter, - }) - - private nextControlActionId = 0 - private pendingControlActions: ControlActionQueueEntry[] = [] - private activeControlAction: ControlActionQueueEntry | null = null - private recentControlActions: ControlActionQueueEntry[] = [] - private readonly stopCancelledControlActionIds = new Set() private actionQueueUpdatedAt = Date.now() - private isActionWorkerRunning = false + private activeControlAction: ControlActionQueueEntry | null = null private completedControlActionsSinceLastFeedback = 0 - private noActionFollowupBudgetRemaining = NO_ACTION_FOLLOWUP_BUDGET_DEFAULT - private noActionFollowupLastSignature: string | null = null - private noActionFollowupStagnationCount = 0 + + private consecutiveHighPriorityTurns = 0 + private conversationHistory: Message[] = [] + private currentCancellationToken: CancellationToken | undefined + private currentInputEnvelope: null | RuntimeInputEnvelope = null + private currentLlmAbortController: AbortController | null = null + private debugService: DebugService private errorBurstGuardState: ErrorBurstGuardState | null = null private errorBurstGuardSuppressUntilTurnId = 0 - private unsubscribeEventBus: (() => void) | null = null + private givenUp = false + private giveUpReason: string | undefined + private readonly llmLogEntries: LlmLogEntry[] = [] + private turnCounter = 0 + private readonly historyRuntime = createHistoryRuntime({ + getConversationHistory: () => this.conversationHistory, + getCurrentTurnId: () => this.turnCounter, + getLlmLogEntries: () => this.llmLogEntries, + }) + + private isActionWorkerRunning = false + private isProcessing = false + private isReplEvaluating = false + private lastContextView: string | undefined + private lastLlmInputSnapshot: LlmInputSnapshot | null = null + private lastReplOutcome: ReplOutcomeSummary | undefined + private llmLogIdCounter = 0 + private readonly llmLogRuntime = createLlmLogRuntime(() => this.llmLogEntries) + private readonly llmTraceEntries: LlmTraceEntry[] = [] + + private llmTraceIdCounter = 0 + private nextControlActionId = 0 + private noActionFollowupBudgetRemaining = NO_ACTION_FOLLOWUP_BUDGET_DEFAULT + private noActionFollowupLastSignature: null | string = null + private noActionFollowupStagnationCount = 0 private onActionCompleted: ((...args: any[]) => void) | null = null private onActionFailed: ((...args: any[]) => void) | null = null + private readonly patternRuntime = createPatternRuntime(PATTERN_CATALOG) + private paused = false + private pendingControlActions: ControlActionQueueEntry[] = [] + // State + private queue: QueuedEvent[] = [] + private recentControlActions: ControlActionQueueEntry[] = [] + private readonly repl = new JavaScriptPlanner() + private runtimeMineflayer: MineflayerWithAgents | null = null + private readonly stopCancelledControlActionIds = new Set() + private unsubscribeEventBus: (() => void) | null = null constructor(private readonly deps: BrainDeps) { this.debugService = DebugService.getInstance() } - public init(bot: MineflayerWithAgents): void { - this.deps.logger.log('INFO', 'Brain: Initializing stateful core...') - this.runtimeMineflayer = bot - - // Perception Handler - this.unsubscribeEventBus = this.deps.eventBus.subscribe('conscious:signal:*', (event: TracedEvent) => { - // AIRI context updates are injected into conversation history without triggering a full cognitive cycle - if (event.payload.type === 'airi_context') { - this.conversationHistory.push({ - role: 'user', - content: `[AIRI_CONTEXT] ${event.payload.description}`, - }) - this.deps.logger.log('INFO', `Brain: Injected AIRI context: ${event.payload.description.slice(0, 80)}`) - return - } - - this.enqueueEvent(bot, { - type: 'perception', - payload: event.payload, - source: { type: event.payload.sourceId === 'airi' ? 'airi' : 'minecraft', id: event.payload.sourceId ?? 'perception' }, - timestamp: Date.now(), - }).catch(err => this.deps.logger.withError(err).error('Brain: Failed to process perception event')) - }) - - // Action telemetry logger - this.onActionCompleted = async ({ action, result }: { action: ActionInstruction, result: unknown }) => { - this.deps.logger.log('INFO', `Brain: Action completed: ${action.tool}`) - this.appendLlmLog({ - turnId: this.turnCounter, - kind: 'feedback', - eventType: 'feedback', - sourceType: 'system', - sourceId: 'executor', - tags: ['feedback', 'success', action.tool], - text: `Action completed: ${action.tool}`, - metadata: { - params: action.params, - result: stringifyForLog(result), - }, - }) - - if (action.tool === 'chat' && action.params?.feedback !== true) { - return - } - - if (action.tool === 'giveUp') { - this.givenUp = true - this.giveUpReason = typeof action.params?.reason === 'string' ? action.params.reason : undefined - - try { - const reason = this.giveUpReason ? `: ${this.giveUpReason}` : '' - bot.bot.chat(`[debug] Gave up${reason}. Waiting for player input.`) - } - catch (err) { - this.deps.logger.withError(err as Error).warn('Brain: Failed to announce giveUp to chat') - } - } - - if (action.tool === 'chat' && action.params?.feedback === true) { - this.enqueueEvent(bot, { - type: 'feedback', - payload: { status: 'success', action, result }, - source: { type: 'system', id: 'executor' }, - timestamp: Date.now(), - }).catch(err => this.deps.logger.withError(err).error('Brain: Failed to process chat feedback')) - } - } - this.deps.taskExecutor.on('action:completed', this.onActionCompleted) - - this.onActionFailed = async ({ action, error }: { action: ActionInstruction, error: Error }) => { - this.deps.logger.withError(error).warn(`Brain: Action failed: ${action.tool}`) - this.appendLlmLog({ - turnId: this.turnCounter, - kind: 'feedback', - eventType: 'feedback', - sourceType: 'system', - sourceId: 'executor', - tags: ['feedback', 'error', action.tool], - text: `Action failed: ${action.tool}: ${error?.message || String(error)}`, - metadata: { - params: action.params, - }, - }) - } - this.deps.taskExecutor.on('action:failed', this.onActionFailed) - - this.deps.logger.log('INFO', 'Brain: Online.') - - this.deps.minecraftContextService.bindBot(bot) + /** + * Re-emit the current conversation state for the debug dashboard. + * Used by the debug dashboard's `request_conversation` handler on reconnect. + */ + public broadcastConversationState(): void { + this.emitConversationUpdate(this.isProcessing) } public destroy(): void { @@ -444,66 +327,106 @@ export class Brain { this.runtimeMineflayer = null } - public getReplState(options: { includeBuiltins?: boolean } = {}): { variables: PlannerGlobalDescriptor[], updatedAt: number, paused: boolean } { - const snapshot = this.deps.reflexManager.getContextSnapshot() - const replEvent: BotEvent = { - type: 'system_alert', - payload: { source: 'debug-repl-state' }, - source: { type: 'system', id: 'debug-repl' }, - timestamp: Date.now(), + public async executeDebugRepl(code: string): Promise { + const startedAt = Date.now() + if (this.isProcessing || this.isReplEvaluating) { + return { + actions: [], + code, + durationMs: Date.now() - startedAt, + error: 'Brain is currently processing an event. Try again in a moment.', + logs: [], + source: 'manual', + timestamp: Date.now(), + } } - const variables = this.repl.describeGlobals( - this.deps.taskExecutor.getAvailableActions(), - this.createRuntimeGlobals(replEvent, snapshot as unknown as Record), - { includeBuiltins: options.includeBuiltins }, - ) + const snapshot = this.deps.reflexManager.getContextSnapshot() + const actionDefs = new Map(this.deps.taskExecutor.getAvailableActions().map(action => [action.name, action])) + const normalizedReplCode = this.normalizeReplCode(code) + const codeToEvaluate = this.repl.canEvaluateAsExpression(normalizedReplCode) + ? `return (\n${normalizedReplCode}\n)` + : normalizedReplCode + + this.isReplEvaluating = true + try { + const runResult = await this.repl.evaluate( + codeToEvaluate, + this.deps.taskExecutor.getAvailableActions(), + this.createRuntimeGlobals({ + payload: { source: 'debug-repl' }, + source: { id: 'debug-repl', type: 'system' }, + timestamp: Date.now(), + type: 'system_alert', + }, snapshot as unknown as Record), + async (action: ActionInstruction) => { + const actionDef = actionDefs.get(action.tool) + if (actionDef?.followControl === 'detach') + this.deps.reflexManager.clearFollowTarget() + return this.deps.taskExecutor.executeActionWithResult(action) + }, + ) + + return { + actions: this.toDebugReplActions(runResult.actions), + code, + durationMs: Date.now() - startedAt, + logs: runResult.logs, + returnValue: runResult.returnValue, + source: 'manual', + timestamp: Date.now(), + } + } + catch (err) { + return { + actions: [], + code, + durationMs: Date.now() - startedAt, + error: toErrorMessage(err), + logs: [], + source: 'manual', + timestamp: Date.now(), + } + } + finally { + this.isReplEvaluating = false + } + } + + public forgetConversation(): { cleared: string[], ok: true } { + this.conversationHistory = [] + this.lastLlmInputSnapshot = null + this.emitConversationUpdate(false, true) return { - variables, - updatedAt: Date.now(), - paused: this.paused, + cleared: ['conversationHistory', 'lastLlmInputSnapshot'], + ok: true, } } public getDebugSnapshot(): { - isProcessing: boolean - queueLength: number actionQueue: ActionQueueSnapshot - turnCounter: number - givenUp: boolean - paused: boolean contextView: string | undefined conversationHistory: Message[] + givenUp: boolean + isProcessing: boolean llmLogEntries: LlmLogEntry[] + paused: boolean + queueLength: number + turnCounter: number } { return { - isProcessing: this.isProcessing, - queueLength: this.queue.length, actionQueue: this.getActionQueueSnapshot(), - turnCounter: this.turnCounter, - givenUp: this.givenUp, - paused: this.paused, contextView: this.lastContextView, conversationHistory: this.cloneMessages(this.conversationHistory), + givenUp: this.givenUp, + isProcessing: this.isProcessing, llmLogEntries: [...this.llmLogEntries], + paused: this.paused, + queueLength: this.queue.length, + turnCounter: this.turnCounter, } } - public setPaused(paused: boolean): boolean { - this.paused = paused - if (paused) - this.cancelInFlightLlm('Brain paused') - return this.paused - } - - public togglePaused(): boolean { - return this.setPaused(!this.paused) - } - - public isPaused(): boolean { - return this.paused - } - public getLastLlmInput(): LlmInputSnapshot | null { if (!this.lastLlmInputSnapshot) return null @@ -531,47 +454,116 @@ export class Brain { return JSON.parse(JSON.stringify(entries)) as LlmTraceEntry[] } - private cancelInFlightLlm(reason: string): void { - if (!this.currentLlmAbortController) - return - if (!this.currentLlmAbortController.signal.aborted) { - const abortError = Object.assign(new Error(reason), { name: 'AbortError' }) - this.currentLlmAbortController.abort(abortError) + public getReplState(options: { includeBuiltins?: boolean } = {}): { paused: boolean, updatedAt: number, variables: PlannerGlobalDescriptor[] } { + const snapshot = this.deps.reflexManager.getContextSnapshot() + const replEvent: BotEvent = { + payload: { source: 'debug-repl-state' }, + source: { id: 'debug-repl', type: 'system' }, + timestamp: Date.now(), + type: 'system_alert', + } + const variables = this.repl.describeGlobals( + this.deps.taskExecutor.getAvailableActions(), + this.createRuntimeGlobals(replEvent, snapshot as unknown as Record), + { includeBuiltins: options.includeBuiltins }, + ) + + return { + paused: this.paused, + updatedAt: Date.now(), + variables, } - this.currentLlmAbortController = null } - private async callLLM(messages: Message[]): Promise { - const abortController = new AbortController() + public init(bot: MineflayerWithAgents): void { + this.deps.logger.log('INFO', 'Brain: Initializing stateful core...') + this.runtimeMineflayer = bot - this.currentLlmAbortController = abortController - try { - return await this.deps.llmAgent.callLLM({ - messages, - abortSignal: abortController.signal, - timeoutMs: DEFAULT_LLM_ATTEMPT_TIMEOUT_MS, + // Perception Handler + this.unsubscribeEventBus = this.deps.eventBus.subscribe('conscious:signal:*', (event: TracedEvent) => { + // AIRI context updates are injected into conversation history without triggering a full cognitive cycle + if (event.payload.type === 'airi_context') { + this.conversationHistory.push({ + content: `[AIRI_CONTEXT] ${event.payload.description}`, + role: 'user', + }) + this.deps.logger.log('INFO', `Brain: Injected AIRI context: ${event.payload.description.slice(0, 80)}`) + return + } + + this.enqueueEvent(bot, { + payload: event.payload, + source: { id: event.payload.sourceId ?? 'perception', type: event.payload.sourceId === 'airi' ? 'airi' : 'minecraft' }, + timestamp: Date.now(), + type: 'perception', + }).catch(err => this.deps.logger.withError(err).error('Brain: Failed to process perception event')) + }) + + // Action telemetry logger + this.onActionCompleted = async ({ action, result }: { action: ActionInstruction, result: unknown }) => { + this.deps.logger.log('INFO', `Brain: Action completed: ${action.tool}`) + this.appendLlmLog({ + eventType: 'feedback', + kind: 'feedback', + metadata: { + params: action.params, + result: stringifyForLog(result), + }, + sourceId: 'executor', + sourceType: 'system', + tags: ['feedback', 'success', action.tool], + text: `Action completed: ${action.tool}`, + turnId: this.turnCounter, + }) + + if (action.tool === 'chat' && action.params?.feedback !== true) { + return + } + + if (action.tool === 'giveUp') { + this.givenUp = true + this.giveUpReason = typeof action.params?.reason === 'string' ? action.params.reason : undefined + + try { + const reason = this.giveUpReason ? `: ${this.giveUpReason}` : '' + bot.bot.chat(`[debug] Gave up${reason}. Waiting for player input.`) + } + catch (err) { + this.deps.logger.withError(err as Error).warn('Brain: Failed to announce giveUp to chat') + } + } + + if (action.tool === 'chat' && action.params?.feedback === true) { + this.enqueueEvent(bot, { + payload: { action, result, status: 'success' }, + source: { id: 'executor', type: 'system' }, + timestamp: Date.now(), + type: 'feedback', + }).catch(err => this.deps.logger.withError(err).error('Brain: Failed to process chat feedback')) + } + } + this.deps.taskExecutor.on('action:completed', this.onActionCompleted) + + this.onActionFailed = async ({ action, error }: { action: ActionInstruction, error: Error }) => { + this.deps.logger.withError(error).warn(`Brain: Action failed: ${action.tool}`) + this.appendLlmLog({ + eventType: 'feedback', + kind: 'feedback', + metadata: { + params: action.params, + }, + sourceId: 'executor', + sourceType: 'system', + tags: ['feedback', 'error', action.tool], + text: `Action failed: ${action.tool}: ${error?.message || String(error)}`, + turnId: this.turnCounter, }) } - finally { - if (this.currentLlmAbortController === abortController) - this.currentLlmAbortController = null - } - } + this.deps.taskExecutor.on('action:failed', this.onActionFailed) - private isAbortError(err: unknown): boolean { - if (!err || typeof err !== 'object') - return false - return (err as { name?: unknown }).name === PAUSE_ABORT_ERROR_NAME - } + this.deps.logger.log('INFO', 'Brain: Online.') - public forgetConversation(): { ok: true, cleared: string[] } { - this.conversationHistory = [] - this.lastLlmInputSnapshot = null - this.emitConversationUpdate(false, true) - return { - ok: true, - cleared: ['conversationHistory', 'lastLlmInputSnapshot'], - } + this.deps.minecraftContextService.bindBot(bot) } public async injectDebugEvent(event: BotEvent): Promise { @@ -594,477 +586,42 @@ export class Brain { await this.enqueueEvent(this.runtimeMineflayer, event) } - public async executeDebugRepl(code: string): Promise { - const startedAt = Date.now() - if (this.isProcessing || this.isReplEvaluating) { - return { - source: 'manual', - code, - logs: [], - actions: [], - error: 'Brain is currently processing an event. Try again in a moment.', - durationMs: Date.now() - startedAt, - timestamp: Date.now(), - } - } - - const snapshot = this.deps.reflexManager.getContextSnapshot() - const actionDefs = new Map(this.deps.taskExecutor.getAvailableActions().map(action => [action.name, action])) - const normalizedReplCode = this.normalizeReplCode(code) - const codeToEvaluate = this.repl.canEvaluateAsExpression(normalizedReplCode) - ? `return (\n${normalizedReplCode}\n)` - : normalizedReplCode - - this.isReplEvaluating = true - try { - const runResult = await this.repl.evaluate( - codeToEvaluate, - this.deps.taskExecutor.getAvailableActions(), - this.createRuntimeGlobals({ - type: 'system_alert', - payload: { source: 'debug-repl' }, - source: { type: 'system', id: 'debug-repl' }, - timestamp: Date.now(), - }, snapshot as unknown as Record), - async (action: ActionInstruction) => { - const actionDef = actionDefs.get(action.tool) - if (actionDef?.followControl === 'detach') - this.deps.reflexManager.clearFollowTarget() - return this.deps.taskExecutor.executeActionWithResult(action) - }, - ) - - return { - source: 'manual', - code, - logs: runResult.logs, - actions: this.toDebugReplActions(runResult.actions), - returnValue: runResult.returnValue, - durationMs: Date.now() - startedAt, - timestamp: Date.now(), - } - } - catch (err) { - return { - source: 'manual', - code, - logs: [], - actions: [], - error: toErrorMessage(err), - durationMs: Date.now() - startedAt, - timestamp: Date.now(), - } - } - finally { - this.isReplEvaluating = false - } + public isPaused(): boolean { + return this.paused } - private normalizeReplCode(code: string): string { - return normalizeReplScript(code) + public setPaused(paused: boolean): boolean { + this.paused = paused + if (paused) + this.cancelInFlightLlm('Brain paused') + return this.paused } - private toDebugReplActions(actions: Array<{ - action: ActionInstruction - ok: boolean - result?: unknown - error?: string - }>): DebugReplResult['actions'] { - return actions.map(item => ({ - tool: item.action.tool, - params: item.action.params, - ok: item.ok, - result: item.result === undefined ? undefined : (typeof item.result === 'string' ? item.result : JSON.stringify(item.result)), - error: item.error, - })) - } - - private cloneMessages(messages: Message[]): Message[] { - return JSON.parse(JSON.stringify(messages)) as Message[] - } - - // FIXME: Temporary fix to normalize xsai Message[] into the debug dashboard's string-only message schema. - private toDebugConversationMessages(messages: Message[]): ConversationUpdateEvent['messages'] { - return messages.map((message) => { - const normalizedMessage: ConversationUpdateEvent['messages'][number] = { - role: message.role, - content: this.toDebugMessageContent(message.content), - } - const reasoning = this.extractMessageReasoning(message) - if (reasoning) - normalizedMessage.reasoning = reasoning - return normalizedMessage - }) - } - - // FIXME: Temporary fix to flatten structured message parts into a string for debug transport compatibility. - private toDebugMessageContent(content: Message['content']): string { - if (typeof content === 'string') - return content - if (!content) - return '' - return content - .map((part) => { - if (part.type === 'text') - return part.text - if (part.type === 'refusal') - return part.refusal - return JSON.stringify(part) - }) - .join('\n') - } - - // FIXME: Temporary fix to preserve reasoning in debug payload while message typing is inconsistent. - private extractMessageReasoning(message: Message): string | undefined { - const maybeReasoning = (message as Message & { reasoning?: unknown }).reasoning - if (typeof maybeReasoning === 'string' && maybeReasoning.length > 0) - return maybeReasoning - if ('reasoning_content' in message && typeof message.reasoning_content === 'string' && message.reasoning_content.length > 0) - return message.reasoning_content - return undefined - } - - /** - * Re-emit the current conversation state for the debug dashboard. - * Used by the debug dashboard's `request_conversation` handler on reconnect. - */ - public broadcastConversationState(): void { - this.emitConversationUpdate(this.isProcessing) - } - - private emitConversationUpdate(isProcessing: boolean, sessionBoundary?: boolean): void { - this.debugService.emitConversationUpdate({ - messages: this.toDebugConversationMessages(this.cloneMessages(this.conversationHistory)), - isProcessing, - ...(sessionBoundary && { sessionBoundary }), - }) - } - - private createRuntimeGlobals( - event: BotEvent, - snapshot: Record, - mineflayerOverride?: MineflayerWithAgents | null, - ) { - const mineflayer = mineflayerOverride ?? this.runtimeMineflayer - return { - event, - snapshot, - patterns: this.patternRuntime, - mineflayer, - bot: mineflayer?.bot, - llmInput: this.lastLlmInputSnapshot, - currentInput: this.currentInputEnvelope, - llmLog: this.llmLogRuntime, - actionQueue: this.getActionQueueSnapshot(), - noActionBudget: this.getNoActionBudgetState(), - errorBurstGuard: this.errorBurstGuardState ? { ...this.errorBurstGuardState } : null, - setNoActionBudget: (value: number) => this.setNoActionFollowupBudget(value), - getNoActionBudget: () => this.getNoActionBudgetState(), - forgetConversation: () => this.forgetConversation(), - history: this.historyRuntime, - notifyAiri: (headline: string, note?: string, urgency?: 'immediate' | 'soon' | 'later') => - this.deps.airiBridge.sendNotify(headline, note, urgency), - updateAiriContext: (text: string, hints?: string[], lane?: string) => - this.deps.airiBridge.sendContextUpdate(text, hints, lane), - } - } - - private isPlayerChatEvent(event: BotEvent): boolean { - if (event.type !== 'perception') - return false - const signal = event.payload as PerceptionSignal - return signal.type === 'chat_message' - } - - private isAiriCommandEvent(event: BotEvent): boolean { - if (event.type !== 'perception') - return false - const signal = event.payload as PerceptionSignal - return signal.type === 'airi_command' - } - - private getNoActionBudgetState(): NoActionBudgetState { - return { - remaining: this.noActionFollowupBudgetRemaining, - default: NO_ACTION_FOLLOWUP_BUDGET_DEFAULT, - max: NO_ACTION_FOLLOWUP_BUDGET_MAX, - } - } - - private resetNoActionFollowupBudget(reason: 'player_chat' | 'manual' | 'airi_command'): NoActionBudgetState { - this.noActionFollowupBudgetRemaining = NO_ACTION_FOLLOWUP_BUDGET_DEFAULT - this.noActionFollowupLastSignature = null - this.noActionFollowupStagnationCount = 0 - this.appendLlmLog({ - turnId: this.turnCounter, - kind: 'scheduler', - eventType: 'system_alert', - sourceType: 'system', - sourceId: 'brain:no_action_budget', - tags: ['scheduler', 'no_action', 'budget_reset', reason], - text: `No-action follow-up budget reset (${reason})`, - metadata: { - budget: this.getNoActionBudgetState(), - }, - }) - return this.getNoActionBudgetState() - } - - private setNoActionFollowupBudget(value: number): { ok: true } & NoActionBudgetState { - const normalizedRaw = Number(value) - const normalized = Number.isFinite(normalizedRaw) - ? Math.floor(normalizedRaw) - : this.noActionFollowupBudgetRemaining - const clamped = Math.max(0, Math.min(NO_ACTION_FOLLOWUP_BUDGET_MAX, normalized)) - this.noActionFollowupBudgetRemaining = clamped - this.noActionFollowupLastSignature = null - this.noActionFollowupStagnationCount = 0 - - this.appendLlmLog({ - turnId: this.turnCounter, - kind: 'scheduler', - eventType: 'system_alert', - sourceType: 'system', - sourceId: 'brain:no_action_budget', - tags: ['scheduler', 'no_action', 'budget_set'], - text: `No-action follow-up budget set to ${clamped}`, - metadata: { - requested: value, - budget: this.getNoActionBudgetState(), - }, - }) - - return { - ok: true, - ...this.getNoActionBudgetState(), - } - } - - private buildNoActionSignature(returnValue: string | undefined, logs: string[]): string { - const returnPart = truncateForPrompt(returnValue ?? 'undefined', 320) - const logsPart = logs.slice(-3).map(line => truncateForPrompt(line, 140)).join('|') - return `${returnPart}||${logsPart}` - } - - private emitNoActionBudgetDebugChat( - bot: MineflayerWithAgents, - reason: 'no_action_budget_exhausted' | 'no_action_stagnated', - ): void { - const message = reason === 'no_action_budget_exhausted' - ? `[debug] no-action follow-up budget exhausted (remaining=0).` - : `[debug] no-action follow-up blocked due to stagnant eval loop.` - - try { - bot.bot.chat(message) - } - catch (err) { - this.deps.logger.withError(err as Error).warn('Brain: Failed to send no-action budget debug chat') - } - } - - private isErrorLlmLogEntry(entry: LlmLogEntry): boolean { - if (entry.kind === 'repl_error') - return true - - if (entry.kind === 'repl_result') { - const errorCount = Number((entry.metadata as Record | undefined)?.errorCount ?? 0) - return Number.isFinite(errorCount) && errorCount > 0 - } - - if (entry.kind === 'feedback') { - const tags = new Set(entry.tags.map(tag => tag.toLowerCase())) - return tags.has('error') || tags.has('failure') - } - - return false - } - - private describeErrorLlmLogEntry(entry: LlmLogEntry): string { - return `${entry.kind}: ${truncateForPrompt(entry.text, 140)}` - } - - private collectRecentErrorTurns(windowTurns = ERROR_BURST_WINDOW_TURNS): { - recentTurnIds: number[] - errorTurnIds: number[] - summaries: string[] - } { - const turnIds: number[] = [] - const seen = new Set() - - for (let index = this.llmLogEntries.length - 1; index >= 0; index--) { - const entry = this.llmLogEntries[index] - if (!entry || entry.kind !== 'turn_input') - continue - if (seen.has(entry.turnId)) - continue - seen.add(entry.turnId) - turnIds.push(entry.turnId) - if (turnIds.length >= windowTurns) - break - } - - const entriesByTurnId = new Map() - for (const turnId of turnIds) - entriesByTurnId.set(turnId, []) - - for (const entry of this.llmLogEntries) { - const bucket = entriesByTurnId.get(entry.turnId) - if (!bucket) - continue - bucket.push(entry) - } - - const errorTurnIds: number[] = [] - const summaries: string[] = [] - for (const turnId of turnIds) { - const turnEntries = entriesByTurnId.get(turnId) ?? [] - const errors = turnEntries.filter(entry => this.isErrorLlmLogEntry(entry)) - if (errors.length === 0) - continue - errorTurnIds.push(turnId) - const evidence = errors.slice(0, 2).map(entry => this.describeErrorLlmLogEntry(entry)).join(' | ') - summaries.push(`turn=${turnId} ${evidence}`) - } - - return { - recentTurnIds: turnIds, - errorTurnIds, - summaries, - } - } - - private maybeActivateErrorBurstGuard( - bot: MineflayerWithAgents, - event: BotEvent, - turnId: number, - ): void { - if (this.errorBurstGuardState) - return - - if (turnId <= this.errorBurstGuardSuppressUntilTurnId) - return - - const { recentTurnIds, errorTurnIds, summaries } = this.collectRecentErrorTurns(ERROR_BURST_WINDOW_TURNS) - if (errorTurnIds.length < ERROR_BURST_THRESHOLD) - return - - const recentErrorSummary = summaries.slice(0, ERROR_BURST_WINDOW_TURNS) - this.errorBurstGuardState = { - threshold: ERROR_BURST_THRESHOLD, - windowTurns: ERROR_BURST_WINDOW_TURNS, - errorTurnCount: errorTurnIds.length, - recentTurnIds, - recentErrorSummary, - triggeredAtTurnId: turnId, - } - - this.appendLlmLog({ - turnId, - kind: 'scheduler', - eventType: 'system_alert', - sourceType: 'system', - sourceId: ERROR_BURST_GUARD_SOURCE_ID, - tags: ['scheduler', 'error_burst', 'guard_triggered', 'error'], - text: `Error burst guard activated (${errorTurnIds.length}/${Math.max(recentTurnIds.length, ERROR_BURST_WINDOW_TURNS)} recent turns contain errors)`, - metadata: { - threshold: ERROR_BURST_THRESHOLD, - windowTurns: ERROR_BURST_WINDOW_TURNS, - errorTurnIds, - recentErrorSummary, - }, - }) - - if (event.source.type === 'system' && event.source.id === ERROR_BURST_GUARD_SOURCE_ID) - return - - void this.enqueueEvent(bot, { - type: 'system_alert', - payload: { - reason: 'error_burst_guard', - threshold: ERROR_BURST_THRESHOLD, - windowTurns: ERROR_BURST_WINDOW_TURNS, - errorTurnCount: errorTurnIds.length, - recentErrorSummary, - guidance: 'Too many recent errors. Call giveUp(...) and send one chat explanation.', - }, - source: { type: 'system', id: ERROR_BURST_GUARD_SOURCE_ID }, - timestamp: Date.now(), - }).catch(err => this.deps.logger.withError(err).error('Brain: Failed to enqueue error-burst guard alert')) - } - - private clearErrorBurstGuardState(turnId: number, reason: 'resolved' | 'manual'): void { - if (!this.errorBurstGuardState) - return - - this.appendLlmLog({ - turnId, - kind: 'scheduler', - eventType: 'system_alert', - sourceType: 'system', - sourceId: ERROR_BURST_GUARD_SOURCE_ID, - tags: ['scheduler', 'error_burst', 'guard_cleared', reason], - text: `Error burst guard cleared (${reason})`, - metadata: { - guard: { ...this.errorBurstGuardState }, - }, - }) - - this.errorBurstGuardSuppressUntilTurnId = turnId + ERROR_BURST_WINDOW_TURNS - this.errorBurstGuardState = null - } - - private updateErrorBurstGuardCompletion( - turnId: number, - actions: Array<{ - action: ActionInstruction - ok: boolean - }>, - ): void { - if (!this.errorBurstGuardState) - return - - const hasGiveUp = actions.some(item => item.action.tool === 'giveUp' && item.ok) - const hasChat = actions.some(item => item.action.tool === 'chat' && item.ok) - - if (hasGiveUp && hasChat) { - this.clearErrorBurstGuardState(turnId, 'resolved') - return - } - - if (hasGiveUp || hasChat) { - this.appendLlmLog({ - turnId, - kind: 'scheduler', - eventType: 'system_alert', - sourceType: 'system', - sourceId: ERROR_BURST_GUARD_SOURCE_ID, - tags: ['scheduler', 'error_burst', 'guard_pending'], - text: 'Error burst guard still pending: this turn must include both giveUp and chat actions', - }) - } + public togglePaused(): boolean { + return this.setPaused(!this.paused) } private appendLlmLog(entry: { - turnId: number - kind: LlmLogEntryKind eventType: string - sourceType: string + kind: LlmLogEntryKind + metadata?: Record sourceId: string + sourceType: string tags?: string[] text: string - metadata?: Record + turnId: number }): void { const normalized: LlmLogEntry = { - id: ++this.llmLogIdCounter, - turnId: entry.turnId, - kind: entry.kind, - timestamp: Date.now(), eventType: entry.eventType, - sourceType: entry.sourceType, + id: ++this.llmLogIdCounter, + kind: entry.kind, + metadata: entry.metadata, sourceId: entry.sourceId, + sourceType: entry.sourceType, tags: entry.tags ?? [], text: entry.text, - metadata: entry.metadata, + timestamp: Date.now(), + turnId: entry.turnId, } this.llmLogEntries.push(normalized) @@ -1073,1148 +630,10 @@ export class Brain { } } - private touchActionQueue(): void { - this.actionQueueUpdatedAt = Date.now() - } - - private cloneActionParams(params: Record): Record { - return JSON.parse(JSON.stringify(params)) as Record - } - - private toActionQueueEntryView(entry: ControlActionQueueEntry): ActionQueueEntryView { - return { - id: entry.id, - tool: entry.action.tool, - params: this.cloneActionParams(entry.action.params), - state: entry.state, - enqueuedAt: entry.enqueuedAt, - sourceTurnId: entry.sourceTurnId, - startedAt: entry.startedAt, - finishedAt: entry.finishedAt, - result: entry.result, - error: entry.error, - } - } - - private pushRecentControlAction(entry: ControlActionQueueEntry): void { - this.recentControlActions.push({ - ...entry, - action: { - tool: entry.action.tool, - params: this.cloneActionParams(entry.action.params), - }, - }) - if (this.recentControlActions.length > ACTION_QUEUE_RECENT_HISTORY_LIMIT) { - this.recentControlActions.shift() - } - } - - private getActionQueueSnapshot(): ActionQueueSnapshot { - const executing = this.activeControlAction ? this.toActionQueueEntryView(this.activeControlAction) : null - const pending = this.pendingControlActions.map(entry => this.toActionQueueEntryView(entry)) - const recent = this.recentControlActions.map(entry => this.toActionQueueEntryView(entry)) - const executingCount = executing ? 1 : 0 - const pendingCount = pending.length - - return { - executing, - pending, - recent, - capacity: { - total: MAX_QUEUED_CONTROL_ACTIONS, - executing: 1, - pending: MAX_PENDING_CONTROL_ACTIONS, - }, - counts: { - total: executingCount + pendingCount, - executing: executingCount, - pending: pendingCount, - }, - updatedAt: this.actionQueueUpdatedAt, - } - } - - private isQueueConsumingControlAction(action: ActionInstruction, actionDef: Action | undefined): boolean { - if (action.tool === 'chat' || action.tool === 'skip' || action.tool === 'stop') - return false - - if (!actionDef) - return false - - if (actionDef?.readonly) - return false - - return actionDef.execution === 'async' - } - - private clearPendingControlActions(state: Extract): number { - if (this.pendingControlActions.length === 0) - return 0 - - const clearedAt = Date.now() - const cleared = this.pendingControlActions.splice(0, this.pendingControlActions.length) - for (const entry of cleared) { - entry.state = state - entry.finishedAt = clearedAt - entry.error = state === 'failed' ? entry.error : entry.error ?? 'Cleared from action queue' - this.pushRecentControlAction(entry) - } - this.touchActionQueue() - return cleared.length - } - - private async enqueueControlAction( - bot: MineflayerWithAgents, - action: ActionInstruction, - sourceTurnId: number, - ): Promise { - const queueSize = this.pendingControlActions.length + (this.activeControlAction ? 1 : 0) - if (queueSize >= MAX_QUEUED_CONTROL_ACTIONS) { - throw new Error(`Action queue full (${queueSize}/${MAX_QUEUED_CONTROL_ACTIONS}). Use stop() or wait for completion.`) - } - - const entry: ControlActionQueueEntry = { - id: ++this.nextControlActionId, - action: { - tool: action.tool, - params: this.cloneActionParams(action.params), - }, - sourceTurnId, - state: 'pending', - enqueuedAt: Date.now(), - } - this.pendingControlActions.push(entry) - this.touchActionQueue() - - this.appendLlmLog({ - turnId: sourceTurnId, - kind: 'scheduler', - eventType: 'system_alert', - sourceType: 'system', - sourceId: 'brain:action_queue', - tags: ['scheduler', 'action_queue', 'enqueued'], - text: `Queued control action #${entry.id}: ${entry.action.tool}`, - metadata: { - actionId: entry.id, - pendingCount: this.pendingControlActions.length, - }, - }) - - this.startControlActionWorker(bot) - return { - queued: true, - actionId: entry.id, - state: entry.state, - pendingAhead: Math.max(0, this.pendingControlActions.length - 1), - queue: this.getActionQueueSnapshot().counts, - } - } - - private startControlActionWorker(bot: MineflayerWithAgents): void { - if (this.isActionWorkerRunning) - return - - this.isActionWorkerRunning = true - setImmediate(() => { - void this.runControlActionWorker(bot) - }) - } - - private async runControlActionWorker(bot: MineflayerWithAgents): Promise { - try { - while (this.pendingControlActions.length > 0) { - const entry = this.pendingControlActions.shift()! - entry.state = 'executing' - entry.startedAt = Date.now() - this.activeControlAction = entry - this.touchActionQueue() - - this.appendLlmLog({ - turnId: entry.sourceTurnId, - kind: 'scheduler', - eventType: 'system_alert', - sourceType: 'system', - sourceId: 'brain:action_queue', - tags: ['scheduler', 'action_queue', 'executing'], - text: `Executing control action #${entry.id}: ${entry.action.tool}`, - metadata: { - actionId: entry.id, - }, - }) - - const actionDef = this.deps.taskExecutor.getAvailableActions().find(item => item.name === entry.action.tool) - if (actionDef?.followControl === 'detach') - this.deps.reflexManager.clearFollowTarget() - - const cancellationToken = createCancellationToken() - this.currentCancellationToken = cancellationToken - - try { - const result = await this.deps.taskExecutor.executeActionWithResult(entry.action, cancellationToken) - const cancelledByStop = cancellationToken.isCancelled || this.stopCancelledControlActionIds.has(entry.id) - if (cancelledByStop) { - entry.state = 'cancelled' - entry.error = 'Cancelled by stop action' - entry.finishedAt = Date.now() - this.pushRecentControlAction(entry) - - this.appendLlmLog({ - turnId: entry.sourceTurnId, - kind: 'scheduler', - eventType: 'feedback', - sourceType: 'system', - sourceId: 'brain:action_queue', - tags: ['scheduler', 'action_queue', 'cancelled', entry.action.tool], - text: `Control action #${entry.id} cancelled: ${entry.action.tool}`, - metadata: { - actionId: entry.id, - reason: 'stop', - }, - }) - - this.stopCancelledControlActionIds.delete(entry.id) - this.activeControlAction = null - this.touchActionQueue() - continue - } - - entry.state = 'succeeded' - entry.result = result - entry.finishedAt = Date.now() - this.pushRecentControlAction(entry) - this.completedControlActionsSinceLastFeedback++ - - this.appendLlmLog({ - turnId: entry.sourceTurnId, - kind: 'scheduler', - eventType: 'feedback', - sourceType: 'system', - sourceId: 'brain:action_queue', - tags: ['scheduler', 'action_queue', 'success', entry.action.tool], - text: `Control action #${entry.id} succeeded: ${entry.action.tool}`, - }) - - this.activeControlAction = null - this.touchActionQueue() - - if (this.pendingControlActions.length === 0) { - const completedCount = this.completedControlActionsSinceLastFeedback - this.completedControlActionsSinceLastFeedback = 0 - await this.enqueueEvent(bot, { - type: 'feedback', - payload: { - status: 'success', - action: entry.action, - result: entry.result, - summary: { - queueDrained: true, - completedCount, - }, - }, - source: { type: 'system', id: 'executor' }, - timestamp: Date.now(), - }) - } - } - catch (err) { - const interrupted = err instanceof ActionError && err.code === 'INTERRUPTED' - const cancelledByStop = cancellationToken.isCancelled - || interrupted - || this.stopCancelledControlActionIds.has(entry.id) - - if (cancelledByStop) { - entry.state = 'cancelled' - entry.error = 'Cancelled by stop action' - entry.finishedAt = Date.now() - this.pushRecentControlAction(entry) - - this.appendLlmLog({ - turnId: entry.sourceTurnId, - kind: 'scheduler', - eventType: 'feedback', - sourceType: 'system', - sourceId: 'brain:action_queue', - tags: ['scheduler', 'action_queue', 'cancelled', entry.action.tool], - text: `Control action #${entry.id} cancelled: ${entry.action.tool}`, - metadata: { - actionId: entry.id, - reason: interrupted ? 'interrupted' : 'stop', - }, - }) - - this.stopCancelledControlActionIds.delete(entry.id) - this.activeControlAction = null - this.touchActionQueue() - continue - } - - const errorMessage = toErrorMessage(err) - entry.state = 'failed' - entry.error = errorMessage - entry.finishedAt = Date.now() - this.pushRecentControlAction(entry) - - const clearedCount = this.clearPendingControlActions('cancelled') - this.completedControlActionsSinceLastFeedback = 0 - this.activeControlAction = null - this.touchActionQueue() - - this.appendLlmLog({ - turnId: entry.sourceTurnId, - kind: 'scheduler', - eventType: 'feedback', - sourceType: 'system', - sourceId: 'brain:action_queue', - tags: ['scheduler', 'action_queue', 'failure', entry.action.tool], - text: `Control action #${entry.id} failed: ${entry.action.tool}`, - metadata: { - actionId: entry.id, - clearedPendingCount: clearedCount, - error: errorMessage, - }, - }) - - await this.enqueueEvent(bot, { - type: 'feedback', - payload: { - status: 'failure', - action: entry.action, - error: errorMessage, - summary: { - failedActionId: entry.id, - clearedPendingCount: clearedCount, - }, - }, - source: { type: 'system', id: 'executor' }, - timestamp: Date.now(), - }) - break - } - finally { - if (this.currentCancellationToken === cancellationToken) { - this.currentCancellationToken = undefined - } - } - } - } - finally { - this.isActionWorkerRunning = false - if (this.pendingControlActions.length > 0 && this.runtimeMineflayer) { - this.startControlActionWorker(this.runtimeMineflayer) - } - } - } - - private async executeStopAction(bot: MineflayerWithAgents, sourceTurnId: number): Promise { - const clearedCount = this.clearPendingControlActions('cancelled') - const cancelledActiveActionId = this.activeControlAction?.id - if (cancelledActiveActionId) - this.stopCancelledControlActionIds.add(cancelledActiveActionId) - - this.currentCancellationToken?.cancel() - this.deps.reflexManager.clearFollowTarget() - - try { - bot.interrupt('stop requested by brain') - } - catch (err) { - this.deps.logger.withError(err as Error).warn('Brain: Failed to interrupt mineflayer during stop') - } - - this.completedControlActionsSinceLastFeedback = 0 - - this.appendLlmLog({ - turnId: sourceTurnId, - kind: 'scheduler', - eventType: 'system_alert', - sourceType: 'system', - sourceId: 'brain:action_queue', - tags: ['scheduler', 'action_queue', 'stop'], - text: `Stop requested. Cleared pending control actions: ${clearedCount}`, - metadata: { - cancelledActiveActionId, - }, - }) - - const result = await this.deps.taskExecutor.executeActionWithResult({ tool: 'stop', params: {} }) - void this.enqueueEvent(bot, { - type: 'feedback', - payload: { - status: 'success', - action: { tool: 'stop', params: {} }, - result, - summary: { - clearedPendingCount: clearedCount, - cancelledActiveActionId, - }, - }, - source: { type: 'system', id: 'executor' }, - timestamp: Date.now(), - }).catch(err => this.deps.logger.withError(err).error('Brain: Failed to enqueue stop feedback')) - - return { - ok: true, - stopped: true, - clearedPendingCount: clearedCount, - cancelledActiveActionId, - } - } - - private queueNoActionFollowup( - bot: MineflayerWithAgents, - triggeringEvent: BotEvent, - turnId: number, - returnValue: string | undefined, - logs: string[], - ): void { - const signature = this.buildNoActionSignature(returnValue, logs) - const budgetBefore = this.noActionFollowupBudgetRemaining - if (signature === this.noActionFollowupLastSignature) - this.noActionFollowupStagnationCount++ - else - this.noActionFollowupStagnationCount = 0 - this.noActionFollowupLastSignature = signature - - const stagnated = this.noActionFollowupStagnationCount >= NO_ACTION_STAGNATION_REPEAT_LIMIT - const exhausted = this.noActionFollowupBudgetRemaining <= 0 - if (stagnated || exhausted) { - const reason: 'no_action_budget_exhausted' | 'no_action_stagnated' = exhausted - ? 'no_action_budget_exhausted' - : 'no_action_stagnated' - - this.appendLlmLog({ - turnId, - kind: 'scheduler', - eventType: triggeringEvent.type, - sourceType: triggeringEvent.source.type, - sourceId: triggeringEvent.source.id, - tags: ['scheduler', 'no_action', 'blocked', reason], - text: `Blocked no-action follow-up: ${reason}`, - metadata: { - budgetBefore, - budgetAfter: this.noActionFollowupBudgetRemaining, - stagnationCount: this.noActionFollowupStagnationCount, - signature, - returnValue: returnValue ?? 'undefined', - }, - }) - - if (triggeringEvent.source.type === 'system' && triggeringEvent.source.id === NO_ACTION_BUDGET_ALERT_SOURCE_ID) { - this.deps.logger.log('INFO', `Brain: Suppressed repeated no-action budget alert (${reason})`) - return - } - - this.debugService.log('DEBUG', `No-action follow-up blocked: ${reason}`) - this.emitNoActionBudgetDebugChat(bot, reason) - - const followupEvent: BotEvent = { - type: 'system_alert', - payload: { - reason, - returnValue: returnValue ?? 'undefined', - logs: logs.slice(-3), - noActionBudget: this.getNoActionBudgetState(), - guidance: 'No-action follow-up budget exhausted. Abandon this approach or call setNoActionBudget(n) for this scenario.', - }, - source: { type: 'system', id: NO_ACTION_BUDGET_ALERT_SOURCE_ID }, - timestamp: Date.now(), - } - - void this.enqueueEvent(bot, followupEvent).catch(err => - this.deps.logger.withError(err).error('Brain: Failed to enqueue no-action budget alert'), - ) - return - } - - this.noActionFollowupBudgetRemaining = Math.max(0, this.noActionFollowupBudgetRemaining - 1) - const budgetAfter = this.noActionFollowupBudgetRemaining - - const followupEvent: BotEvent = { - type: 'system_alert', - payload: { - reason: 'no_actions', - returnValue: returnValue ?? 'undefined', - logs: logs.slice(-3), - noActionBudget: this.getNoActionBudgetState(), - }, - source: { type: 'system', id: NO_ACTION_FOLLOWUP_SOURCE_ID }, - timestamp: Date.now(), - } - - this.appendLlmLog({ - turnId, - kind: 'scheduler', - eventType: triggeringEvent.type, - sourceType: triggeringEvent.source.type, - sourceId: triggeringEvent.source.id, - tags: ['scheduler', 'no_action'], - text: 'Scheduled budgeted no-action follow-up turn', - metadata: { - returnValue: returnValue ?? 'undefined', - budgetBefore, - budgetAfter, - stagnationCount: this.noActionFollowupStagnationCount, - signature, - }, - }) - this.debugService.log('DEBUG', 'Scheduling budgeted no-action follow-up turn') - void this.enqueueEvent(bot, followupEvent).catch(err => - this.deps.logger.withError(err).error('Brain: Failed to enqueue no-action follow-up'), - ) - } - - // --- Event Queue Logic --- - - private async enqueueEvent(bot: MineflayerWithAgents, event: BotEvent): Promise { - return new Promise((resolve, reject) => { - this.queue.push({ event, resolve, reject }) - this.trimEventQueueOverflow() - // Use setImmediate to avoid re-entrant processQueue calls that could - // bypass the isProcessing guard during the finally block. - if (!this.isProcessing) { - setImmediate(() => this.processQueue(bot)) - } - }) - } - - /** - * Coalesce the event queue: promote high-priority events (player chat, AIRI commands) - * ahead of stale low-priority events (feedback, no-action follow-ups), - * and drop redundant stale follow-ups when a higher-priority event exists. - */ - private coalesceQueue(): void { - if (this.queue.length <= 1) - return - - const hasHighPriority = this.queue.some( - item => getEventPriority(item.event) <= EVENT_PRIORITY_PERCEPTION, - ) - if (!hasHighPriority) - return - - // Drop redundant no-action follow-ups when an urgent perception is waiting - const hasUrgentPerception = this.queue.some( - item => getEventPriority(item.event) === EVENT_PRIORITY_URGENT_PERCEPTION, - ) - if (hasUrgentPerception) { - const before = this.queue.length - const dropped: QueuedEvent[] = [] - this.queue = this.queue.filter((item) => { - if (getEventPriority(item.event) === EVENT_PRIORITY_NO_ACTION_FOLLOWUP) { - dropped.push(item) - return false - } - return true - }) - // Resolve dropped promises so they don't hang - for (const item of dropped) - item.resolve() - - if (before !== this.queue.length) { - this.appendLlmLog({ - turnId: this.turnCounter, - kind: 'scheduler', - eventType: 'system_alert', - sourceType: 'system', - sourceId: 'brain:coalesce', - tags: ['scheduler', 'coalesce', 'drop_followups'], - text: `Coalesced queue: dropped ${before - this.queue.length} stale no-action follow-ups (urgent perception waiting)`, - }) - } - } - - // Stable-sort by priority so urgent perception events are processed first - this.queue.sort((a, b) => getEventPriority(a.event) - getEventPriority(b.event)) - } - - private trimEventQueueOverflow(): void { - while (this.queue.length > MAX_EVENT_QUEUE_LENGTH) { - const dropIndex = this.findOverflowDropIndex() - const [dropped] = this.queue.splice(dropIndex, 1) - if (!dropped) - break - - dropped.resolve() - this.appendLlmLog({ - turnId: this.turnCounter, - kind: 'scheduler', - eventType: dropped.event.type, - sourceType: dropped.event.source.type, - sourceId: dropped.event.source.id, - tags: ['scheduler', 'queue', 'overflow_drop'], - text: `Dropped queued event due to queue overflow (max=${MAX_EVENT_QUEUE_LENGTH})`, - metadata: { - droppedPriority: getEventPriority(dropped.event), - queueLength: this.queue.length, - }, - }) - } - } - - private findOverflowDropIndex(): number { - const nonFeedbackCandidateIndex = this.findOverflowDropIndexByFilter( - item => item.event.type !== 'feedback', - ) - if (nonFeedbackCandidateIndex >= 0) - return nonFeedbackCandidateIndex - - return this.findOverflowDropIndexByFilter(() => true) - } - - private findOverflowDropIndexByFilter(filter: (item: QueuedEvent) => boolean): number { - let candidateIndex = -1 - let candidatePriority = Number.NEGATIVE_INFINITY - - for (let index = 0; index < this.queue.length; index++) { - const item = this.queue[index]! - if (!filter(item)) - continue - - const priority = getEventPriority(item.event) - if (candidateIndex === -1 || priority > candidatePriority) { - candidatePriority = priority - candidateIndex = index - } - } - - return candidateIndex - } - - private dequeueNextQueuedEvent(): QueuedEvent { - const shouldForceLowPriorityDispatch = this.consecutiveHighPriorityTurns >= MAX_CONSECUTIVE_HIGH_PRIORITY_TURNS - let item: QueuedEvent | undefined - - if (shouldForceLowPriorityDispatch) { - const lowPriorityIndex = this.queue.findIndex( - candidate => getEventPriority(candidate.event) > EVENT_PRIORITY_PERCEPTION, - ) - if (lowPriorityIndex >= 0) { - // FIXME: Temporary starvation guard. Replace with weighted-fair scheduling once queue model is refactored. - item = this.queue.splice(lowPriorityIndex, 1)[0] - this.appendLlmLog({ - turnId: this.turnCounter, - kind: 'scheduler', - eventType: item.event.type, - sourceType: item.event.source.type, - sourceId: 'brain:starvation_guard', - tags: ['scheduler', 'queue', 'starvation_guard', 'temp_fix'], - text: 'Forced a low-priority event after high-priority streak', - metadata: { - streakBeforeDispatch: this.consecutiveHighPriorityTurns, - queueLength: this.queue.length, - }, - }) - } - } - - if (!item) - item = this.queue.shift()! - - if (getEventPriority(item.event) <= EVENT_PRIORITY_PERCEPTION) - this.consecutiveHighPriorityTurns += 1 - else - this.consecutiveHighPriorityTurns = 0 - - return item - } - - private async processQueue(bot: MineflayerWithAgents): Promise { - if (this.isProcessing || this.queue.length === 0) - return - - try { - this.isProcessing = true - this.debugService.emitBrainState({ - status: 'processing', - queueLength: this.queue.length, - lastContextView: this.lastContextView, - }) - - this.coalesceQueue() - const item = this.dequeueNextQueuedEvent() - - try { - await this.processEvent(bot, item.event) - item.resolve() - } - catch (err) { - this.deps.logger.withError(err).error('Brain: Error processing event') - item.reject(err as Error) - } - } - finally { - this.isProcessing = false - this.debugService.emitBrainState({ - status: 'idle', - queueLength: this.queue.length, - lastContextView: this.lastContextView, - }) - - if (this.queue.length === 0) - this.consecutiveHighPriorityTurns = 0 - - if (this.queue.length > 0) { - setImmediate(() => this.processQueue(bot)) - } - } - } - - // --- Cognitive Cycle --- - - private async processEvent(bot: MineflayerWithAgents, event: BotEvent): Promise { - if (this.paused) { - this.appendLlmLog({ - turnId: this.turnCounter, - kind: 'scheduler', - eventType: event.type, - sourceType: event.source.type, - sourceId: event.source.id, - tags: ['scheduler', 'paused', 'suppressed'], - text: `Suppressed event while paused: ${event.type} from ${event.source.type}:${event.source.id}`, - }) - this.deps.logger.log('INFO', `Brain: Ignoring event while paused (${event.type} from ${event.source.type}:${event.source.id})`) - return - } - - this.resumeFromGiveUpIfNeeded(event) - if (this.shouldSuppressDuringGiveUp(event)) - return - if (this.isPlayerChatEvent(event)) - this.resetNoActionFollowupBudget('player_chat') - if (this.isAiriCommandEvent(event)) - this.resetNoActionFollowupBudget('airi_command') - - const turnId = ++this.turnCounter - this.maybeActivateErrorBurstGuard(bot, event, turnId) - - // 0. Build Context View - const snapshot = this.deps.reflexManager.getContextSnapshot() - const view = buildConsciousContextView(snapshot) - const contextView = `[PERCEPTION] Self: ${view.selfSummary}\nEnvironment: ${view.environmentSummary}` - - // 1. Construct User Message (Diffing happens here) - const userMessage = this.buildUserMessage(event, contextView) - - // Update state after consuming difference - this.lastContextView = contextView - - // 2. Prepare System Prompt (static + bound master identity) - const systemPrompt = generateBrainSystemPrompt(this.deps.taskExecutor.getAvailableActions(), { masterUsername: config.bot.masterUsername }) - this.currentInputEnvelope = { - id: turnId, - turnId, - timestamp: Date.now(), - event: { - type: event.type, - sourceType: event.source.type, - sourceId: event.source.id, - payload: event.payload, - }, - contextView, - userMessage, - systemPrompt: { - preview: truncateForPrompt(systemPrompt, 240), - length: systemPrompt.length, - }, - } - this.appendLlmLog({ - turnId, - kind: 'turn_input', - eventType: event.type, - sourceType: event.source.type, - sourceId: event.source.id, - tags: ['input', event.type], - text: truncateForPrompt(userMessage, 600), - metadata: { - queueLength: this.queue.length, - }, - }) - - this.debugService.emitConversationUpdate({ - messages: this.toDebugConversationMessages(this.cloneMessages([ - ...this.conversationHistory, - { role: 'user', content: userMessage }, - ])), - isProcessing: true, - }) - - // 3. Call LLM with retry logic - const maxAttempts = 3 - let result: string | null = null - let capturedReasoning: string | undefined - let lastError: unknown - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - // Check pause at start of each retry attempt - if (this.paused) { - this.appendLlmLog({ - turnId, - kind: 'scheduler', - eventType: event.type, - sourceType: event.source.type, - sourceId: event.source.id, - tags: ['scheduler', 'paused', 'interrupted'], - text: `Interrupted during LLM retry loop (attempt ${attempt}/${maxAttempts}) while paused`, - }) - this.deps.logger.log('INFO', `Brain: Interrupted LLM retry loop while paused (attempt ${attempt}/${maxAttempts})`) - return - } - - try { - // Build messages: system + conversation history + new user message - const messages: Message[] = [ - { role: 'system', content: systemPrompt }, - ...this.conversationHistory, - { role: 'user', content: userMessage }, - ] - this.lastLlmInputSnapshot = { - systemPrompt, - userMessage, - messages: this.cloneMessages(messages), - conversationHistory: this.cloneMessages(this.conversationHistory), - updatedAt: Date.now(), - attempt, - } - this.currentInputEnvelope.llm = { - attempt, - model: config.openai.model, - } - this.appendLlmLog({ - turnId, - kind: 'llm_attempt', - eventType: event.type, - sourceType: event.source.type, - sourceId: event.source.id, - tags: ['llm', 'attempt'], - text: `LLM attempt ${attempt}/${maxAttempts}`, - metadata: { - attempt, - maxAttempts, - messageCount: messages.length, - }, - }) - - const traceStart = Date.now() - - const llmResult = await this.callLLM(messages) - - const content = llmResult.text - const reasoning = llmResult.reasoning - - if (!content) - throw new Error('No content from LLM') - - // Capture reasoning for later use - capturedReasoning = reasoning - result = content - - this.debugService.traceLLM({ - route: 'brain', - messages, - content, - reasoning, - usage: llmResult.usage, - model: config.openai.model, - duration: Date.now() - traceStart, - }) - // Store lightweight trace (no full messages clone to prevent O(turns²) memory) - const estimatedTokens = Math.ceil(messages.reduce((sum, m) => { - const c = typeof m.content === 'string' ? m.content.length : 0 - return sum + c - }, 0) / 4) - this.llmTraceEntries.push({ - id: ++this.llmTraceIdCounter, - turnId, - timestamp: Date.now(), - eventType: event.type, - sourceType: event.source.type, - sourceId: event.source.id, - attempt, - model: config.openai.model, - messageCount: messages.length, - estimatedTokens, - content, - reasoning, - usage: llmResult.usage, - durationMs: Date.now() - traceStart, - }) - if (this.llmTraceEntries.length > 500) { - this.llmTraceEntries.shift() - } - this.currentInputEnvelope.llm = { - attempt, - model: config.openai.model, - usage: llmResult.usage, - } - this.appendLlmLog({ - turnId, - kind: 'llm_attempt', - eventType: event.type, - sourceType: event.source.type, - sourceId: event.source.id, - tags: ['llm', 'response'], - text: truncateForPrompt(content, 400), - metadata: { - attempt, - usage: llmResult.usage, - reasoningSize: reasoning?.length ?? 0, - }, - }) - - this.debugService.emitBrainState({ - status: 'processing', - queueLength: this.queue.length, - lastContextView: this.lastContextView, - }) - - break // Success, exit retry loop - } - catch (err) { - if (this.paused && this.isAbortError(err)) { - this.appendLlmLog({ - turnId, - kind: 'scheduler', - eventType: event.type, - sourceType: event.source.type, - sourceId: event.source.id, - tags: ['scheduler', 'paused', 'interrupted'], - text: `Interrupted during LLM call (attempt ${attempt}/${maxAttempts}) while paused`, - metadata: { - attempt, - maxAttempts, - }, - }) - this.deps.logger.log('INFO', `Brain: Interrupted LLM call while paused (attempt ${attempt}/${maxAttempts})`) - return - } - - lastError = err - const remaining = maxAttempts - attempt - const isRateLimit = isRateLimitError(err) - const isAuthOrBadArg = isLikelyAuthOrBadArgError(err) - const { shouldRetry } = shouldRetryError(err, remaining) - this.deps.logger.withError(err).error(`Brain: Decision attempt failed (attempt ${attempt}/${maxAttempts}, retry: ${shouldRetry}, rateLimit: ${isRateLimit})`) - - if (!shouldRetry) { - if (isAuthOrBadArg) - throw err - - this.deps.logger.withError(err).warn('Brain: Decision attempts exhausted, skipping turn') - break - } - - const backoffMs = isRateLimit - ? Math.min(5000, 1000 * attempt) + Math.floor(Math.random() * 200) - : 150 - await sleep(backoffMs) - - // Check pause after backoff sleep (before next retry) - if (this.paused) { - this.appendLlmLog({ - turnId, - kind: 'scheduler', - eventType: event.type, - sourceType: event.source.type, - sourceId: event.source.id, - tags: ['scheduler', 'paused', 'interrupted'], - text: `Interrupted after retry backoff (attempt ${attempt}/${maxAttempts}) while paused`, - }) - this.deps.logger.log('INFO', `Brain: Interrupted after retry backoff while paused (attempt ${attempt}/${maxAttempts})`) - return - } - } - } - - // 4. Parse & Execute - if (!result) { - this.deps.logger.withError(lastError).warn('Brain: No response after all retries') - this.appendLlmLog({ - turnId, - kind: 'repl_error', - eventType: event.type, - sourceType: event.source.type, - sourceId: event.source.id, - tags: ['repl', 'error', 'empty_response'], - text: 'No LLM response after retries', - }) - this.maybeActivateErrorBurstGuard(bot, event, turnId) - return - } - - // Check pause again after LLM call (allows !pause to interrupt before REPL execution) - if (this.paused) { - this.appendLlmLog({ - turnId, - kind: 'scheduler', - eventType: event.type, - sourceType: event.source.type, - sourceId: event.source.id, - tags: ['scheduler', 'paused', 'interrupted'], - text: `Interrupted before REPL execution while paused: ${event.type} from ${event.source.type}:${event.source.id}`, - }) - this.deps.logger.log('INFO', `Brain: Interrupted processing before REPL while paused (${event.type} from ${event.source.type}:${event.source.id})`) - return - } - - try { - // Only append to conversation history after successful parsing (avoid dirty data on retry) - this.conversationHistory.push({ role: 'user', content: userMessage }) - // Store reasoning in the assistant message's reasoning field (if available) - // Reasoning is transient thinking and doesn't need the [REASONING] prefix hack anymore - this.conversationHistory.push({ - role: 'assistant', - content: result, - ...(capturedReasoning && { reasoning: capturedReasoning }), - } as Message) - - // Trim conversation history as an in-memory safety net for long sessions. - if (this.conversationHistory.length > MAX_CONVERSATION_HISTORY_MESSAGES) { - const trimCount = this.conversationHistory.length - MAX_CONVERSATION_HISTORY_MESSAGES - this.conversationHistory = this.conversationHistory.slice(trimCount) - } - - const actionDefs = new Map(this.deps.taskExecutor.getAvailableActions().map(action => [action.name, action])) - - const normalizedLlmCode = this.normalizeReplCode(result) - const codeToEvaluate = this.repl.canEvaluateAsExpression(normalizedLlmCode) - ? `return (\n${normalizedLlmCode}\n)` - : normalizedLlmCode - - const runResult = await this.repl.evaluate( - codeToEvaluate, - this.deps.taskExecutor.getAvailableActions(), - this.createRuntimeGlobals(event, snapshot as unknown as Record, bot), - async (action: ActionInstruction) => { - const actionDef = actionDefs.get(action.tool) - if (action.tool === 'stop') { - return this.executeStopAction(bot, turnId) - } - - const isControlAction = this.isQueueConsumingControlAction(action, actionDef) - if (isControlAction) - return this.enqueueControlAction(bot, action, turnId) - - if (actionDef?.followControl === 'detach') - this.deps.reflexManager.clearFollowTarget() - - return this.deps.taskExecutor.executeActionWithResult(action) - }, - ) - - this.lastReplOutcome = { - actionCount: runResult.actions.length, - okCount: runResult.actions.filter(item => item.ok).length, - errorCount: runResult.actions.filter(item => !item.ok).length, - returnValue: runResult.returnValue, - logs: runResult.logs.slice(-3), - updatedAt: Date.now(), - } - this.appendLlmLog({ - turnId, - kind: 'repl_result', - eventType: event.type, - sourceType: event.source.type, - sourceId: event.source.id, - tags: [ - 'repl', - runResult.actions.length === 0 ? 'no_actions' : 'actions', - runResult.actions.some(item => !item.ok) ? 'error' : 'ok', - ], - text: `actions=${runResult.actions.length} return=${runResult.returnValue ?? 'undefined'}`, - metadata: { - returnValue: runResult.returnValue, - actionCount: runResult.actions.length, - okCount: runResult.actions.filter(item => item.ok).length, - errorCount: runResult.actions.filter(item => !item.ok).length, - actions: runResult.actions.map(item => ({ - tool: item.action.tool, - ok: item.ok, - error: item.error, - })), - logs: runResult.logs.slice(-5), - }, - }) - this.updateErrorBurstGuardCompletion( - turnId, - runResult.actions.map(item => ({ - action: item.action, - ok: item.ok, - })), - ) - this.maybeActivateErrorBurstGuard(bot, event, turnId) - - if (runResult.actions.length === 0 || runResult.actions.every(item => item.action.tool === 'skip')) { - this.debugService.emit('debug:repl_result', { - source: 'llm', - code: result, - logs: runResult.logs, - actions: this.toDebugReplActions(runResult.actions), - returnValue: runResult.returnValue, - durationMs: 0, - timestamp: Date.now(), - }) - if (runResult.actions.length === 0) { - this.queueNoActionFollowup(bot, event, turnId, runResult.returnValue, runResult.logs) - } - this.deps.logger.log('INFO', 'Brain: Skipping turn (observing)') - this.emitConversationUpdate(false) - return - } - - this.debugService.emit('debug:repl_result', { - source: 'llm', - code: result, - logs: runResult.logs, - actions: this.toDebugReplActions(runResult.actions), - returnValue: runResult.returnValue, - durationMs: 0, - timestamp: Date.now(), - }) - - this.deps.logger.log('INFO', `Brain: Executed ${runResult.actions.length} action(s)`, { - actions: runResult.actions.map(item => ({ - tool: item.action.tool, - ok: item.ok, - result: item.result, - error: item.error, - })), - logs: runResult.logs, - returnValue: runResult.returnValue, - }) - this.emitConversationUpdate(false) - } - catch (err) { - this.deps.logger.withError(err).error('Brain: Failed to execute decision') - this.appendLlmLog({ - turnId, - kind: 'repl_error', - eventType: event.type, - sourceType: event.source.type, - sourceId: event.source.id, - tags: ['repl', 'error'], - text: truncateForPrompt(toErrorMessage(err), 360), - metadata: { - code: result, - }, - }) - this.maybeActivateErrorBurstGuard(bot, event, turnId) - const augmentedError = augmentDecisionError(toErrorMessage(err)) - this.debugService.emit('debug:repl_result', { - source: 'llm', - code: result, - logs: [], - actions: [], - error: augmentedError, - durationMs: 0, - timestamp: Date.now(), - }) - void this.enqueueEvent(bot, { - type: 'feedback', - payload: { status: 'failure', error: augmentedError }, - source: { type: 'system', id: 'brain' }, - timestamp: Date.now(), - }) - this.emitConversationUpdate(false) - } + private buildNoActionSignature(returnValue: string | undefined, logs: string[]): string { + const returnPart = truncateForPrompt(returnValue ?? 'undefined', 320) + const logsPart = logs.slice(-3).map(line => truncateForPrompt(line, 140)).join('|') + return `${returnPart}||${logsPart}` } private buildUserMessage(event: BotEvent, contextView: string): string { @@ -2294,20 +713,1210 @@ export class Brain { return parts.join('\n\n') } - private shouldSuppressDuringGiveUp(event: BotEvent): boolean { - if (!this.givenUp) - return false + private async callLLM(messages: Message[]): Promise { + const abortController = new AbortController() - if (event.source.type === 'system' && event.source.id === ERROR_BURST_GUARD_SOURCE_ID) - return false + this.currentLlmAbortController = abortController + try { + return await this.deps.llmAgent.callLLM({ + abortSignal: abortController.signal, + messages, + timeoutMs: DEFAULT_LLM_ATTEMPT_TIMEOUT_MS, + }) + } + finally { + if (this.currentLlmAbortController === abortController) + this.currentLlmAbortController = null + } + } + private cancelInFlightLlm(reason: string): void { + if (!this.currentLlmAbortController) + return + if (!this.currentLlmAbortController.signal.aborted) { + const abortError = Object.assign(new Error(reason), { name: 'AbortError' }) + this.currentLlmAbortController.abort(abortError) + } + this.currentLlmAbortController = null + } + + private canResumeFromGiveUp(signal: PerceptionSignal): boolean { + return signal.type === 'chat_message' || signal.type === 'airi_command' + } + + private clearErrorBurstGuardState(turnId: number, reason: 'manual' | 'resolved'): void { + if (!this.errorBurstGuardState) + return + + this.appendLlmLog({ + eventType: 'system_alert', + kind: 'scheduler', + metadata: { + guard: { ...this.errorBurstGuardState }, + }, + sourceId: ERROR_BURST_GUARD_SOURCE_ID, + sourceType: 'system', + tags: ['scheduler', 'error_burst', 'guard_cleared', reason], + text: `Error burst guard cleared (${reason})`, + turnId, + }) + + this.errorBurstGuardSuppressUntilTurnId = turnId + ERROR_BURST_WINDOW_TURNS + this.errorBurstGuardState = null + } + + private clearPendingControlActions(state: Extract): number { + if (this.pendingControlActions.length === 0) + return 0 + + const clearedAt = Date.now() + const cleared = this.pendingControlActions.splice(0, this.pendingControlActions.length) + for (const entry of cleared) { + entry.state = state + entry.finishedAt = clearedAt + entry.error = state === 'failed' ? entry.error : entry.error ?? 'Cleared from action queue' + this.pushRecentControlAction(entry) + } + this.touchActionQueue() + return cleared.length + } + + private cloneActionParams(params: Record): Record { + return JSON.parse(JSON.stringify(params)) as Record + } + + private cloneMessages(messages: Message[]): Message[] { + return JSON.parse(JSON.stringify(messages)) as Message[] + } + + /** + * Coalesce the event queue: promote high-priority events (player chat, AIRI commands) + * ahead of stale low-priority events (feedback, no-action follow-ups), + * and drop redundant stale follow-ups when a higher-priority event exists. + */ + private coalesceQueue(): void { + if (this.queue.length <= 1) + return + + const hasHighPriority = this.queue.some( + item => getEventPriority(item.event) <= EVENT_PRIORITY_PERCEPTION, + ) + if (!hasHighPriority) + return + + // Drop redundant no-action follow-ups when an urgent perception is waiting + const hasUrgentPerception = this.queue.some( + item => getEventPriority(item.event) === EVENT_PRIORITY_URGENT_PERCEPTION, + ) + if (hasUrgentPerception) { + const before = this.queue.length + const dropped: QueuedEvent[] = [] + this.queue = this.queue.filter((item) => { + if (getEventPriority(item.event) === EVENT_PRIORITY_NO_ACTION_FOLLOWUP) { + dropped.push(item) + return false + } + return true + }) + // Resolve dropped promises so they don't hang + for (const item of dropped) + item.resolve() + + if (before !== this.queue.length) { + this.appendLlmLog({ + eventType: 'system_alert', + kind: 'scheduler', + sourceId: 'brain:coalesce', + sourceType: 'system', + tags: ['scheduler', 'coalesce', 'drop_followups'], + text: `Coalesced queue: dropped ${before - this.queue.length} stale no-action follow-ups (urgent perception waiting)`, + turnId: this.turnCounter, + }) + } + } + + // Stable-sort by priority so urgent perception events are processed first + this.queue.sort((a, b) => getEventPriority(a.event) - getEventPriority(b.event)) + } + + private collectRecentErrorTurns(windowTurns = ERROR_BURST_WINDOW_TURNS): { + errorTurnIds: number[] + recentTurnIds: number[] + summaries: string[] + } { + const turnIds: number[] = [] + const seen = new Set() + + for (let index = this.llmLogEntries.length - 1; index >= 0; index--) { + const entry = this.llmLogEntries[index] + if (!entry || entry.kind !== 'turn_input') + continue + if (seen.has(entry.turnId)) + continue + seen.add(entry.turnId) + turnIds.push(entry.turnId) + if (turnIds.length >= windowTurns) + break + } + + const entriesByTurnId = new Map() + for (const turnId of turnIds) + entriesByTurnId.set(turnId, []) + + for (const entry of this.llmLogEntries) { + const bucket = entriesByTurnId.get(entry.turnId) + if (!bucket) + continue + bucket.push(entry) + } + + const errorTurnIds: number[] = [] + const summaries: string[] = [] + for (const turnId of turnIds) { + const turnEntries = entriesByTurnId.get(turnId) ?? [] + const errors = turnEntries.filter(entry => this.isErrorLlmLogEntry(entry)) + if (errors.length === 0) + continue + errorTurnIds.push(turnId) + const evidence = errors.slice(0, 2).map(entry => this.describeErrorLlmLogEntry(entry)).join(' | ') + summaries.push(`turn=${turnId} ${evidence}`) + } + + return { + errorTurnIds, + recentTurnIds: turnIds, + summaries, + } + } + + private createRuntimeGlobals( + event: BotEvent, + snapshot: Record, + mineflayerOverride?: MineflayerWithAgents | null, + ) { + const mineflayer = mineflayerOverride ?? this.runtimeMineflayer + return { + actionQueue: this.getActionQueueSnapshot(), + bot: mineflayer?.bot, + currentInput: this.currentInputEnvelope, + errorBurstGuard: this.errorBurstGuardState ? { ...this.errorBurstGuardState } : null, + event, + forgetConversation: () => this.forgetConversation(), + getNoActionBudget: () => this.getNoActionBudgetState(), + history: this.historyRuntime, + llmInput: this.lastLlmInputSnapshot, + llmLog: this.llmLogRuntime, + mineflayer, + noActionBudget: this.getNoActionBudgetState(), + notifyAiri: (headline: string, note?: string, urgency?: 'immediate' | 'later' | 'soon') => + this.deps.airiBridge.sendNotify(headline, note, urgency), + patterns: this.patternRuntime, + setNoActionBudget: (value: number) => this.setNoActionFollowupBudget(value), + snapshot, + updateAiriContext: (text: string, hints?: string[], lane?: string) => + this.deps.airiBridge.sendContextUpdate(text, hints, lane), + } + } + + private dequeueNextQueuedEvent(): QueuedEvent { + const shouldForceLowPriorityDispatch = this.consecutiveHighPriorityTurns >= MAX_CONSECUTIVE_HIGH_PRIORITY_TURNS + let item: QueuedEvent | undefined + + if (shouldForceLowPriorityDispatch) { + const lowPriorityIndex = this.queue.findIndex( + candidate => getEventPriority(candidate.event) > EVENT_PRIORITY_PERCEPTION, + ) + if (lowPriorityIndex >= 0) { + // FIXME: Temporary starvation guard. Replace with weighted-fair scheduling once queue model is refactored. + item = this.queue.splice(lowPriorityIndex, 1)[0] + this.appendLlmLog({ + eventType: item.event.type, + kind: 'scheduler', + metadata: { + queueLength: this.queue.length, + streakBeforeDispatch: this.consecutiveHighPriorityTurns, + }, + sourceId: 'brain:starvation_guard', + sourceType: item.event.source.type, + tags: ['scheduler', 'queue', 'starvation_guard', 'temp_fix'], + text: 'Forced a low-priority event after high-priority streak', + turnId: this.turnCounter, + }) + } + } + + if (!item) + item = this.queue.shift()! + + if (getEventPriority(item.event) <= EVENT_PRIORITY_PERCEPTION) + this.consecutiveHighPriorityTurns += 1 + else + this.consecutiveHighPriorityTurns = 0 + + return item + } + + private describeErrorLlmLogEntry(entry: LlmLogEntry): string { + return `${entry.kind}: ${truncateForPrompt(entry.text, 140)}` + } + + private emitConversationUpdate(isProcessing: boolean, sessionBoundary?: boolean): void { + this.debugService.emitConversationUpdate({ + isProcessing, + messages: this.toDebugConversationMessages(this.cloneMessages(this.conversationHistory)), + ...(sessionBoundary && { sessionBoundary }), + }) + } + + private emitNoActionBudgetDebugChat( + bot: MineflayerWithAgents, + reason: 'no_action_budget_exhausted' | 'no_action_stagnated', + ): void { + const message = reason === 'no_action_budget_exhausted' + ? `[debug] no-action follow-up budget exhausted (remaining=0).` + : `[debug] no-action follow-up blocked due to stagnant eval loop.` + + try { + bot.bot.chat(message) + } + catch (err) { + this.deps.logger.withError(err as Error).warn('Brain: Failed to send no-action budget debug chat') + } + } + + private async enqueueControlAction( + bot: MineflayerWithAgents, + action: ActionInstruction, + sourceTurnId: number, + ): Promise { + const queueSize = this.pendingControlActions.length + (this.activeControlAction ? 1 : 0) + if (queueSize >= MAX_QUEUED_CONTROL_ACTIONS) { + throw new Error(`Action queue full (${queueSize}/${MAX_QUEUED_CONTROL_ACTIONS}). Use stop() or wait for completion.`) + } + + const entry: ControlActionQueueEntry = { + action: { + params: this.cloneActionParams(action.params), + tool: action.tool, + }, + enqueuedAt: Date.now(), + id: ++this.nextControlActionId, + sourceTurnId, + state: 'pending', + } + this.pendingControlActions.push(entry) + this.touchActionQueue() + + this.appendLlmLog({ + eventType: 'system_alert', + kind: 'scheduler', + metadata: { + actionId: entry.id, + pendingCount: this.pendingControlActions.length, + }, + sourceId: 'brain:action_queue', + sourceType: 'system', + tags: ['scheduler', 'action_queue', 'enqueued'], + text: `Queued control action #${entry.id}: ${entry.action.tool}`, + turnId: sourceTurnId, + }) + + this.startControlActionWorker(bot) + return { + actionId: entry.id, + pendingAhead: Math.max(0, this.pendingControlActions.length - 1), + queue: this.getActionQueueSnapshot().counts, + queued: true, + state: entry.state, + } + } + + private async enqueueEvent(bot: MineflayerWithAgents, event: BotEvent): Promise { + return new Promise((resolve, reject) => { + this.queue.push({ event, reject, resolve }) + this.trimEventQueueOverflow() + // Use setImmediate to avoid re-entrant processQueue calls that could + // bypass the isProcessing guard during the finally block. + if (!this.isProcessing) { + setImmediate(() => this.processQueue(bot)) + } + }) + } + + private async executeStopAction(bot: MineflayerWithAgents, sourceTurnId: number): Promise { + const clearedCount = this.clearPendingControlActions('cancelled') + const cancelledActiveActionId = this.activeControlAction?.id + if (cancelledActiveActionId) + this.stopCancelledControlActionIds.add(cancelledActiveActionId) + + this.currentCancellationToken?.cancel() + this.deps.reflexManager.clearFollowTarget() + + try { + bot.interrupt('stop requested by brain') + } + catch (err) { + this.deps.logger.withError(err as Error).warn('Brain: Failed to interrupt mineflayer during stop') + } + + this.completedControlActionsSinceLastFeedback = 0 + + this.appendLlmLog({ + eventType: 'system_alert', + kind: 'scheduler', + metadata: { + cancelledActiveActionId, + }, + sourceId: 'brain:action_queue', + sourceType: 'system', + tags: ['scheduler', 'action_queue', 'stop'], + text: `Stop requested. Cleared pending control actions: ${clearedCount}`, + turnId: sourceTurnId, + }) + + const result = await this.deps.taskExecutor.executeActionWithResult({ params: {}, tool: 'stop' }) + void this.enqueueEvent(bot, { + payload: { + action: { params: {}, tool: 'stop' }, + result, + status: 'success', + summary: { + cancelledActiveActionId, + clearedPendingCount: clearedCount, + }, + }, + source: { id: 'executor', type: 'system' }, + timestamp: Date.now(), + type: 'feedback', + }).catch(err => this.deps.logger.withError(err).error('Brain: Failed to enqueue stop feedback')) + + return { + cancelledActiveActionId, + clearedPendingCount: clearedCount, + ok: true, + stopped: true, + } + } + + // FIXME: Temporary fix to preserve reasoning in debug payload while message typing is inconsistent. + private extractMessageReasoning(message: Message): string | undefined { + const maybeReasoning = (message as Message & { reasoning?: unknown }).reasoning + if (typeof maybeReasoning === 'string' && maybeReasoning.length > 0) + return maybeReasoning + if ('reasoning_content' in message && typeof message.reasoning_content === 'string' && message.reasoning_content.length > 0) + return message.reasoning_content + return undefined + } + + private findOverflowDropIndex(): number { + const nonFeedbackCandidateIndex = this.findOverflowDropIndexByFilter( + item => item.event.type !== 'feedback', + ) + if (nonFeedbackCandidateIndex >= 0) + return nonFeedbackCandidateIndex + + return this.findOverflowDropIndexByFilter(() => true) + } + + private findOverflowDropIndexByFilter(filter: (item: QueuedEvent) => boolean): number { + let candidateIndex = -1 + let candidatePriority = Number.NEGATIVE_INFINITY + + for (let index = 0; index < this.queue.length; index++) { + const item = this.queue[index]! + if (!filter(item)) + continue + + const priority = getEventPriority(item.event) + if (candidateIndex === -1 || priority > candidatePriority) { + candidatePriority = priority + candidateIndex = index + } + } + + return candidateIndex + } + + private getActionQueueSnapshot(): ActionQueueSnapshot { + const executing = this.activeControlAction ? this.toActionQueueEntryView(this.activeControlAction) : null + const pending = this.pendingControlActions.map(entry => this.toActionQueueEntryView(entry)) + const recent = this.recentControlActions.map(entry => this.toActionQueueEntryView(entry)) + const executingCount = executing ? 1 : 0 + const pendingCount = pending.length + + return { + capacity: { + executing: 1, + pending: MAX_PENDING_CONTROL_ACTIONS, + total: MAX_QUEUED_CONTROL_ACTIONS, + }, + counts: { + executing: executingCount, + pending: pendingCount, + total: executingCount + pendingCount, + }, + executing, + pending, + recent, + updatedAt: this.actionQueueUpdatedAt, + } + } + + private getNoActionBudgetState(): NoActionBudgetState { + return { + default: NO_ACTION_FOLLOWUP_BUDGET_DEFAULT, + max: NO_ACTION_FOLLOWUP_BUDGET_MAX, + remaining: this.noActionFollowupBudgetRemaining, + } + } + + private isAbortError(err: unknown): boolean { + if (!err || typeof err !== 'object') + return false + return (err as { name?: unknown }).name === PAUSE_ABORT_ERROR_NAME + } + + private isAiriCommandEvent(event: BotEvent): boolean { if (event.type !== 'perception') + return false + const signal = event.payload as PerceptionSignal + return signal.type === 'airi_command' + } + + private isErrorLlmLogEntry(entry: LlmLogEntry): boolean { + if (entry.kind === 'repl_error') return true - const signal = event.payload as PerceptionSignal - return !this.canResumeFromGiveUp(signal) + if (entry.kind === 'repl_result') { + const errorCount = Number((entry.metadata as Record | undefined)?.errorCount ?? 0) + return Number.isFinite(errorCount) && errorCount > 0 + } + + if (entry.kind === 'feedback') { + const tags = new Set(entry.tags.map(tag => tag.toLowerCase())) + return tags.has('error') || tags.has('failure') + } + + return false } + private isPlayerChatEvent(event: BotEvent): boolean { + if (event.type !== 'perception') + return false + const signal = event.payload as PerceptionSignal + return signal.type === 'chat_message' + } + + private isQueueConsumingControlAction(action: ActionInstruction, actionDef: Action | undefined): boolean { + if (action.tool === 'chat' || action.tool === 'skip' || action.tool === 'stop') + return false + + if (!actionDef) + return false + + if (actionDef?.readonly) + return false + + return actionDef.execution === 'async' + } + + private maybeActivateErrorBurstGuard( + bot: MineflayerWithAgents, + event: BotEvent, + turnId: number, + ): void { + if (this.errorBurstGuardState) + return + + if (turnId <= this.errorBurstGuardSuppressUntilTurnId) + return + + const { errorTurnIds, recentTurnIds, summaries } = this.collectRecentErrorTurns(ERROR_BURST_WINDOW_TURNS) + if (errorTurnIds.length < ERROR_BURST_THRESHOLD) + return + + const recentErrorSummary = summaries.slice(0, ERROR_BURST_WINDOW_TURNS) + this.errorBurstGuardState = { + errorTurnCount: errorTurnIds.length, + recentErrorSummary, + recentTurnIds, + threshold: ERROR_BURST_THRESHOLD, + triggeredAtTurnId: turnId, + windowTurns: ERROR_BURST_WINDOW_TURNS, + } + + this.appendLlmLog({ + eventType: 'system_alert', + kind: 'scheduler', + metadata: { + errorTurnIds, + recentErrorSummary, + threshold: ERROR_BURST_THRESHOLD, + windowTurns: ERROR_BURST_WINDOW_TURNS, + }, + sourceId: ERROR_BURST_GUARD_SOURCE_ID, + sourceType: 'system', + tags: ['scheduler', 'error_burst', 'guard_triggered', 'error'], + text: `Error burst guard activated (${errorTurnIds.length}/${Math.max(recentTurnIds.length, ERROR_BURST_WINDOW_TURNS)} recent turns contain errors)`, + turnId, + }) + + if (event.source.type === 'system' && event.source.id === ERROR_BURST_GUARD_SOURCE_ID) + return + + void this.enqueueEvent(bot, { + payload: { + errorTurnCount: errorTurnIds.length, + guidance: 'Too many recent errors. Call giveUp(...) and send one chat explanation.', + reason: 'error_burst_guard', + recentErrorSummary, + threshold: ERROR_BURST_THRESHOLD, + windowTurns: ERROR_BURST_WINDOW_TURNS, + }, + source: { id: ERROR_BURST_GUARD_SOURCE_ID, type: 'system' }, + timestamp: Date.now(), + type: 'system_alert', + }).catch(err => this.deps.logger.withError(err).error('Brain: Failed to enqueue error-burst guard alert')) + } + + private normalizeReplCode(code: string): string { + return normalizeReplScript(code) + } + + private async processEvent(bot: MineflayerWithAgents, event: BotEvent): Promise { + if (this.paused) { + this.appendLlmLog({ + eventType: event.type, + kind: 'scheduler', + sourceId: event.source.id, + sourceType: event.source.type, + tags: ['scheduler', 'paused', 'suppressed'], + text: `Suppressed event while paused: ${event.type} from ${event.source.type}:${event.source.id}`, + turnId: this.turnCounter, + }) + this.deps.logger.log('INFO', `Brain: Ignoring event while paused (${event.type} from ${event.source.type}:${event.source.id})`) + return + } + + this.resumeFromGiveUpIfNeeded(event) + if (this.shouldSuppressDuringGiveUp(event)) + return + if (this.isPlayerChatEvent(event)) + this.resetNoActionFollowupBudget('player_chat') + if (this.isAiriCommandEvent(event)) + this.resetNoActionFollowupBudget('airi_command') + + const turnId = ++this.turnCounter + this.maybeActivateErrorBurstGuard(bot, event, turnId) + + // 0. Build Context View + const snapshot = this.deps.reflexManager.getContextSnapshot() + const view = buildConsciousContextView(snapshot) + const contextView = `[PERCEPTION] Self: ${view.selfSummary}\nEnvironment: ${view.environmentSummary}` + + // 1. Construct User Message (Diffing happens here) + const userMessage = this.buildUserMessage(event, contextView) + + // Update state after consuming difference + this.lastContextView = contextView + + // 2. Prepare System Prompt (static + bound master identity) + const systemPrompt = generateBrainSystemPrompt(this.deps.taskExecutor.getAvailableActions(), { masterUsername: config.bot.masterUsername }) + this.currentInputEnvelope = { + contextView, + event: { + payload: event.payload, + sourceId: event.source.id, + sourceType: event.source.type, + type: event.type, + }, + id: turnId, + systemPrompt: { + length: systemPrompt.length, + preview: truncateForPrompt(systemPrompt, 240), + }, + timestamp: Date.now(), + turnId, + userMessage, + } + this.appendLlmLog({ + eventType: event.type, + kind: 'turn_input', + metadata: { + queueLength: this.queue.length, + }, + sourceId: event.source.id, + sourceType: event.source.type, + tags: ['input', event.type], + text: truncateForPrompt(userMessage, 600), + turnId, + }) + + this.debugService.emitConversationUpdate({ + isProcessing: true, + messages: this.toDebugConversationMessages(this.cloneMessages([ + ...this.conversationHistory, + { content: userMessage, role: 'user' }, + ])), + }) + + // 3. Call LLM with retry logic + const maxAttempts = 3 + let result: null | string = null + let capturedReasoning: string | undefined + let lastError: unknown + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + // Check pause at start of each retry attempt + if (this.paused) { + this.appendLlmLog({ + eventType: event.type, + kind: 'scheduler', + sourceId: event.source.id, + sourceType: event.source.type, + tags: ['scheduler', 'paused', 'interrupted'], + text: `Interrupted during LLM retry loop (attempt ${attempt}/${maxAttempts}) while paused`, + turnId, + }) + this.deps.logger.log('INFO', `Brain: Interrupted LLM retry loop while paused (attempt ${attempt}/${maxAttempts})`) + return + } + + try { + // Build messages: system + conversation history + new user message + const messages: Message[] = [ + { content: systemPrompt, role: 'system' }, + ...this.conversationHistory, + { content: userMessage, role: 'user' }, + ] + this.lastLlmInputSnapshot = { + attempt, + conversationHistory: this.cloneMessages(this.conversationHistory), + messages: this.cloneMessages(messages), + systemPrompt, + updatedAt: Date.now(), + userMessage, + } + this.currentInputEnvelope.llm = { + attempt, + model: config.openai.model, + } + this.appendLlmLog({ + eventType: event.type, + kind: 'llm_attempt', + metadata: { + attempt, + maxAttempts, + messageCount: messages.length, + }, + sourceId: event.source.id, + sourceType: event.source.type, + tags: ['llm', 'attempt'], + text: `LLM attempt ${attempt}/${maxAttempts}`, + turnId, + }) + + const traceStart = Date.now() + + const llmResult = await this.callLLM(messages) + + const content = llmResult.text + const reasoning = llmResult.reasoning + + if (!content) + throw new Error('No content from LLM') + + // Capture reasoning for later use + capturedReasoning = reasoning + result = content + + this.debugService.traceLLM({ + content, + duration: Date.now() - traceStart, + messages, + model: config.openai.model, + reasoning, + route: 'brain', + usage: llmResult.usage, + }) + // Store lightweight trace (no full messages clone to prevent O(turns²) memory) + const estimatedTokens = Math.ceil(messages.reduce((sum, m) => { + const c = typeof m.content === 'string' ? m.content.length : 0 + return sum + c + }, 0) / 4) + this.llmTraceEntries.push({ + attempt, + content, + durationMs: Date.now() - traceStart, + estimatedTokens, + eventType: event.type, + id: ++this.llmTraceIdCounter, + messageCount: messages.length, + model: config.openai.model, + reasoning, + sourceId: event.source.id, + sourceType: event.source.type, + timestamp: Date.now(), + turnId, + usage: llmResult.usage, + }) + if (this.llmTraceEntries.length > 500) { + this.llmTraceEntries.shift() + } + this.currentInputEnvelope.llm = { + attempt, + model: config.openai.model, + usage: llmResult.usage, + } + this.appendLlmLog({ + eventType: event.type, + kind: 'llm_attempt', + metadata: { + attempt, + reasoningSize: reasoning?.length ?? 0, + usage: llmResult.usage, + }, + sourceId: event.source.id, + sourceType: event.source.type, + tags: ['llm', 'response'], + text: truncateForPrompt(content, 400), + turnId, + }) + + this.debugService.emitBrainState({ + lastContextView: this.lastContextView, + queueLength: this.queue.length, + status: 'processing', + }) + + break // Success, exit retry loop + } + catch (err) { + if (this.paused && this.isAbortError(err)) { + this.appendLlmLog({ + eventType: event.type, + kind: 'scheduler', + metadata: { + attempt, + maxAttempts, + }, + sourceId: event.source.id, + sourceType: event.source.type, + tags: ['scheduler', 'paused', 'interrupted'], + text: `Interrupted during LLM call (attempt ${attempt}/${maxAttempts}) while paused`, + turnId, + }) + this.deps.logger.log('INFO', `Brain: Interrupted LLM call while paused (attempt ${attempt}/${maxAttempts})`) + return + } + + lastError = err + const remaining = maxAttempts - attempt + const isRateLimit = isRateLimitError(err) + const isAuthOrBadArg = isLikelyAuthOrBadArgError(err) + const { shouldRetry } = shouldRetryError(err, remaining) + this.deps.logger.withError(err).error(`Brain: Decision attempt failed (attempt ${attempt}/${maxAttempts}, retry: ${shouldRetry}, rateLimit: ${isRateLimit})`) + + if (!shouldRetry) { + if (isAuthOrBadArg) + throw err + + this.deps.logger.withError(err).warn('Brain: Decision attempts exhausted, skipping turn') + break + } + + const backoffMs = isRateLimit + ? Math.min(5000, 1000 * attempt) + Math.floor(Math.random() * 200) + : 150 + await sleep(backoffMs) + + // Check pause after backoff sleep (before next retry) + if (this.paused) { + this.appendLlmLog({ + eventType: event.type, + kind: 'scheduler', + sourceId: event.source.id, + sourceType: event.source.type, + tags: ['scheduler', 'paused', 'interrupted'], + text: `Interrupted after retry backoff (attempt ${attempt}/${maxAttempts}) while paused`, + turnId, + }) + this.deps.logger.log('INFO', `Brain: Interrupted after retry backoff while paused (attempt ${attempt}/${maxAttempts})`) + return + } + } + } + + // 4. Parse & Execute + if (!result) { + this.deps.logger.withError(lastError).warn('Brain: No response after all retries') + this.appendLlmLog({ + eventType: event.type, + kind: 'repl_error', + sourceId: event.source.id, + sourceType: event.source.type, + tags: ['repl', 'error', 'empty_response'], + text: 'No LLM response after retries', + turnId, + }) + this.maybeActivateErrorBurstGuard(bot, event, turnId) + return + } + + // Check pause again after LLM call (allows !pause to interrupt before REPL execution) + if (this.paused) { + this.appendLlmLog({ + eventType: event.type, + kind: 'scheduler', + sourceId: event.source.id, + sourceType: event.source.type, + tags: ['scheduler', 'paused', 'interrupted'], + text: `Interrupted before REPL execution while paused: ${event.type} from ${event.source.type}:${event.source.id}`, + turnId, + }) + this.deps.logger.log('INFO', `Brain: Interrupted processing before REPL while paused (${event.type} from ${event.source.type}:${event.source.id})`) + return + } + + try { + // Only append to conversation history after successful parsing (avoid dirty data on retry) + this.conversationHistory.push({ content: userMessage, role: 'user' }) + // Store reasoning in the assistant message's reasoning field (if available) + // Reasoning is transient thinking and doesn't need the [REASONING] prefix hack anymore + this.conversationHistory.push({ + content: result, + role: 'assistant', + ...(capturedReasoning && { reasoning: capturedReasoning }), + } as Message) + + // Trim conversation history as an in-memory safety net for long sessions. + if (this.conversationHistory.length > MAX_CONVERSATION_HISTORY_MESSAGES) { + const trimCount = this.conversationHistory.length - MAX_CONVERSATION_HISTORY_MESSAGES + this.conversationHistory = this.conversationHistory.slice(trimCount) + } + + const actionDefs = new Map(this.deps.taskExecutor.getAvailableActions().map(action => [action.name, action])) + + const normalizedLlmCode = this.normalizeReplCode(result) + const codeToEvaluate = this.repl.canEvaluateAsExpression(normalizedLlmCode) + ? `return (\n${normalizedLlmCode}\n)` + : normalizedLlmCode + + const runResult = await this.repl.evaluate( + codeToEvaluate, + this.deps.taskExecutor.getAvailableActions(), + this.createRuntimeGlobals(event, snapshot as unknown as Record, bot), + async (action: ActionInstruction) => { + const actionDef = actionDefs.get(action.tool) + if (action.tool === 'stop') { + return this.executeStopAction(bot, turnId) + } + + const isControlAction = this.isQueueConsumingControlAction(action, actionDef) + if (isControlAction) + return this.enqueueControlAction(bot, action, turnId) + + if (actionDef?.followControl === 'detach') + this.deps.reflexManager.clearFollowTarget() + + return this.deps.taskExecutor.executeActionWithResult(action) + }, + ) + + this.lastReplOutcome = { + actionCount: runResult.actions.length, + errorCount: runResult.actions.filter(item => !item.ok).length, + logs: runResult.logs.slice(-3), + okCount: runResult.actions.filter(item => item.ok).length, + returnValue: runResult.returnValue, + updatedAt: Date.now(), + } + this.appendLlmLog({ + eventType: event.type, + kind: 'repl_result', + metadata: { + actionCount: runResult.actions.length, + actions: runResult.actions.map(item => ({ + error: item.error, + ok: item.ok, + tool: item.action.tool, + })), + errorCount: runResult.actions.filter(item => !item.ok).length, + logs: runResult.logs.slice(-5), + okCount: runResult.actions.filter(item => item.ok).length, + returnValue: runResult.returnValue, + }, + sourceId: event.source.id, + sourceType: event.source.type, + tags: [ + 'repl', + runResult.actions.length === 0 ? 'no_actions' : 'actions', + runResult.actions.some(item => !item.ok) ? 'error' : 'ok', + ], + text: `actions=${runResult.actions.length} return=${runResult.returnValue ?? 'undefined'}`, + turnId, + }) + this.updateErrorBurstGuardCompletion( + turnId, + runResult.actions.map(item => ({ + action: item.action, + ok: item.ok, + })), + ) + this.maybeActivateErrorBurstGuard(bot, event, turnId) + + if (runResult.actions.length === 0 || runResult.actions.every(item => item.action.tool === 'skip')) { + this.debugService.emit('debug:repl_result', { + actions: this.toDebugReplActions(runResult.actions), + code: result, + durationMs: 0, + logs: runResult.logs, + returnValue: runResult.returnValue, + source: 'llm', + timestamp: Date.now(), + }) + if (runResult.actions.length === 0) { + this.queueNoActionFollowup(bot, event, turnId, runResult.returnValue, runResult.logs) + } + this.deps.logger.log('INFO', 'Brain: Skipping turn (observing)') + this.emitConversationUpdate(false) + return + } + + this.debugService.emit('debug:repl_result', { + actions: this.toDebugReplActions(runResult.actions), + code: result, + durationMs: 0, + logs: runResult.logs, + returnValue: runResult.returnValue, + source: 'llm', + timestamp: Date.now(), + }) + + this.deps.logger.log('INFO', `Brain: Executed ${runResult.actions.length} action(s)`, { + actions: runResult.actions.map(item => ({ + error: item.error, + ok: item.ok, + result: item.result, + tool: item.action.tool, + })), + logs: runResult.logs, + returnValue: runResult.returnValue, + }) + this.emitConversationUpdate(false) + } + catch (err) { + this.deps.logger.withError(err).error('Brain: Failed to execute decision') + this.appendLlmLog({ + eventType: event.type, + kind: 'repl_error', + metadata: { + code: result, + }, + sourceId: event.source.id, + sourceType: event.source.type, + tags: ['repl', 'error'], + text: truncateForPrompt(toErrorMessage(err), 360), + turnId, + }) + this.maybeActivateErrorBurstGuard(bot, event, turnId) + const augmentedError = augmentDecisionError(toErrorMessage(err)) + this.debugService.emit('debug:repl_result', { + actions: [], + code: result, + durationMs: 0, + error: augmentedError, + logs: [], + source: 'llm', + timestamp: Date.now(), + }) + void this.enqueueEvent(bot, { + payload: { error: augmentedError, status: 'failure' }, + source: { id: 'brain', type: 'system' }, + timestamp: Date.now(), + type: 'feedback', + }) + this.emitConversationUpdate(false) + } + } + + private async processQueue(bot: MineflayerWithAgents): Promise { + if (this.isProcessing || this.queue.length === 0) + return + + try { + this.isProcessing = true + this.debugService.emitBrainState({ + lastContextView: this.lastContextView, + queueLength: this.queue.length, + status: 'processing', + }) + + this.coalesceQueue() + const item = this.dequeueNextQueuedEvent() + + try { + await this.processEvent(bot, item.event) + item.resolve() + } + catch (err) { + this.deps.logger.withError(err).error('Brain: Error processing event') + item.reject(err as Error) + } + } + finally { + this.isProcessing = false + this.debugService.emitBrainState({ + lastContextView: this.lastContextView, + queueLength: this.queue.length, + status: 'idle', + }) + + if (this.queue.length === 0) + this.consecutiveHighPriorityTurns = 0 + + if (this.queue.length > 0) { + setImmediate(() => this.processQueue(bot)) + } + } + } + + private pushRecentControlAction(entry: ControlActionQueueEntry): void { + this.recentControlActions.push({ + ...entry, + action: { + params: this.cloneActionParams(entry.action.params), + tool: entry.action.tool, + }, + }) + if (this.recentControlActions.length > ACTION_QUEUE_RECENT_HISTORY_LIMIT) { + this.recentControlActions.shift() + } + } + + private queueNoActionFollowup( + bot: MineflayerWithAgents, + triggeringEvent: BotEvent, + turnId: number, + returnValue: string | undefined, + logs: string[], + ): void { + const signature = this.buildNoActionSignature(returnValue, logs) + const budgetBefore = this.noActionFollowupBudgetRemaining + if (signature === this.noActionFollowupLastSignature) + this.noActionFollowupStagnationCount++ + else + this.noActionFollowupStagnationCount = 0 + this.noActionFollowupLastSignature = signature + + const stagnated = this.noActionFollowupStagnationCount >= NO_ACTION_STAGNATION_REPEAT_LIMIT + const exhausted = this.noActionFollowupBudgetRemaining <= 0 + if (stagnated || exhausted) { + const reason: 'no_action_budget_exhausted' | 'no_action_stagnated' = exhausted + ? 'no_action_budget_exhausted' + : 'no_action_stagnated' + + this.appendLlmLog({ + eventType: triggeringEvent.type, + kind: 'scheduler', + metadata: { + budgetAfter: this.noActionFollowupBudgetRemaining, + budgetBefore, + returnValue: returnValue ?? 'undefined', + signature, + stagnationCount: this.noActionFollowupStagnationCount, + }, + sourceId: triggeringEvent.source.id, + sourceType: triggeringEvent.source.type, + tags: ['scheduler', 'no_action', 'blocked', reason], + text: `Blocked no-action follow-up: ${reason}`, + turnId, + }) + + if (triggeringEvent.source.type === 'system' && triggeringEvent.source.id === NO_ACTION_BUDGET_ALERT_SOURCE_ID) { + this.deps.logger.log('INFO', `Brain: Suppressed repeated no-action budget alert (${reason})`) + return + } + + this.debugService.log('DEBUG', `No-action follow-up blocked: ${reason}`) + this.emitNoActionBudgetDebugChat(bot, reason) + + const followupEvent: BotEvent = { + payload: { + guidance: 'No-action follow-up budget exhausted. Abandon this approach or call setNoActionBudget(n) for this scenario.', + logs: logs.slice(-3), + noActionBudget: this.getNoActionBudgetState(), + reason, + returnValue: returnValue ?? 'undefined', + }, + source: { id: NO_ACTION_BUDGET_ALERT_SOURCE_ID, type: 'system' }, + timestamp: Date.now(), + type: 'system_alert', + } + + void this.enqueueEvent(bot, followupEvent).catch(err => + this.deps.logger.withError(err).error('Brain: Failed to enqueue no-action budget alert'), + ) + return + } + + this.noActionFollowupBudgetRemaining = Math.max(0, this.noActionFollowupBudgetRemaining - 1) + const budgetAfter = this.noActionFollowupBudgetRemaining + + const followupEvent: BotEvent = { + payload: { + logs: logs.slice(-3), + noActionBudget: this.getNoActionBudgetState(), + reason: 'no_actions', + returnValue: returnValue ?? 'undefined', + }, + source: { id: NO_ACTION_FOLLOWUP_SOURCE_ID, type: 'system' }, + timestamp: Date.now(), + type: 'system_alert', + } + + this.appendLlmLog({ + eventType: triggeringEvent.type, + kind: 'scheduler', + metadata: { + budgetAfter, + budgetBefore, + returnValue: returnValue ?? 'undefined', + signature, + stagnationCount: this.noActionFollowupStagnationCount, + }, + sourceId: triggeringEvent.source.id, + sourceType: triggeringEvent.source.type, + tags: ['scheduler', 'no_action'], + text: 'Scheduled budgeted no-action follow-up turn', + turnId, + }) + this.debugService.log('DEBUG', 'Scheduling budgeted no-action follow-up turn') + void this.enqueueEvent(bot, followupEvent).catch(err => + this.deps.logger.withError(err).error('Brain: Failed to enqueue no-action follow-up'), + ) + } + + private resetNoActionFollowupBudget(reason: 'airi_command' | 'manual' | 'player_chat'): NoActionBudgetState { + this.noActionFollowupBudgetRemaining = NO_ACTION_FOLLOWUP_BUDGET_DEFAULT + this.noActionFollowupLastSignature = null + this.noActionFollowupStagnationCount = 0 + this.appendLlmLog({ + eventType: 'system_alert', + kind: 'scheduler', + metadata: { + budget: this.getNoActionBudgetState(), + }, + sourceId: 'brain:no_action_budget', + sourceType: 'system', + tags: ['scheduler', 'no_action', 'budget_reset', reason], + text: `No-action follow-up budget reset (${reason})`, + turnId: this.turnCounter, + }) + return this.getNoActionBudgetState() + } + + // --- Event Queue Logic --- + private resumeFromGiveUpIfNeeded(event: BotEvent): void { if (!this.givenUp) return @@ -2323,7 +1932,399 @@ export class Brain { this.giveUpReason = undefined } - private canResumeFromGiveUp(signal: PerceptionSignal): boolean { - return signal.type === 'chat_message' || signal.type === 'airi_command' + private async runControlActionWorker(bot: MineflayerWithAgents): Promise { + try { + while (this.pendingControlActions.length > 0) { + const entry = this.pendingControlActions.shift()! + entry.state = 'executing' + entry.startedAt = Date.now() + this.activeControlAction = entry + this.touchActionQueue() + + this.appendLlmLog({ + eventType: 'system_alert', + kind: 'scheduler', + metadata: { + actionId: entry.id, + }, + sourceId: 'brain:action_queue', + sourceType: 'system', + tags: ['scheduler', 'action_queue', 'executing'], + text: `Executing control action #${entry.id}: ${entry.action.tool}`, + turnId: entry.sourceTurnId, + }) + + const actionDef = this.deps.taskExecutor.getAvailableActions().find(item => item.name === entry.action.tool) + if (actionDef?.followControl === 'detach') + this.deps.reflexManager.clearFollowTarget() + + const cancellationToken = createCancellationToken() + this.currentCancellationToken = cancellationToken + + try { + const result = await this.deps.taskExecutor.executeActionWithResult(entry.action, cancellationToken) + const cancelledByStop = cancellationToken.isCancelled || this.stopCancelledControlActionIds.has(entry.id) + if (cancelledByStop) { + entry.state = 'cancelled' + entry.error = 'Cancelled by stop action' + entry.finishedAt = Date.now() + this.pushRecentControlAction(entry) + + this.appendLlmLog({ + eventType: 'feedback', + kind: 'scheduler', + metadata: { + actionId: entry.id, + reason: 'stop', + }, + sourceId: 'brain:action_queue', + sourceType: 'system', + tags: ['scheduler', 'action_queue', 'cancelled', entry.action.tool], + text: `Control action #${entry.id} cancelled: ${entry.action.tool}`, + turnId: entry.sourceTurnId, + }) + + this.stopCancelledControlActionIds.delete(entry.id) + this.activeControlAction = null + this.touchActionQueue() + continue + } + + entry.state = 'succeeded' + entry.result = result + entry.finishedAt = Date.now() + this.pushRecentControlAction(entry) + this.completedControlActionsSinceLastFeedback++ + + this.appendLlmLog({ + eventType: 'feedback', + kind: 'scheduler', + sourceId: 'brain:action_queue', + sourceType: 'system', + tags: ['scheduler', 'action_queue', 'success', entry.action.tool], + text: `Control action #${entry.id} succeeded: ${entry.action.tool}`, + turnId: entry.sourceTurnId, + }) + + this.activeControlAction = null + this.touchActionQueue() + + if (this.pendingControlActions.length === 0) { + const completedCount = this.completedControlActionsSinceLastFeedback + this.completedControlActionsSinceLastFeedback = 0 + await this.enqueueEvent(bot, { + payload: { + action: entry.action, + result: entry.result, + status: 'success', + summary: { + completedCount, + queueDrained: true, + }, + }, + source: { id: 'executor', type: 'system' }, + timestamp: Date.now(), + type: 'feedback', + }) + } + } + catch (err) { + const interrupted = err instanceof ActionError && err.code === 'INTERRUPTED' + const cancelledByStop = cancellationToken.isCancelled + || interrupted + || this.stopCancelledControlActionIds.has(entry.id) + + if (cancelledByStop) { + entry.state = 'cancelled' + entry.error = 'Cancelled by stop action' + entry.finishedAt = Date.now() + this.pushRecentControlAction(entry) + + this.appendLlmLog({ + eventType: 'feedback', + kind: 'scheduler', + metadata: { + actionId: entry.id, + reason: interrupted ? 'interrupted' : 'stop', + }, + sourceId: 'brain:action_queue', + sourceType: 'system', + tags: ['scheduler', 'action_queue', 'cancelled', entry.action.tool], + text: `Control action #${entry.id} cancelled: ${entry.action.tool}`, + turnId: entry.sourceTurnId, + }) + + this.stopCancelledControlActionIds.delete(entry.id) + this.activeControlAction = null + this.touchActionQueue() + continue + } + + const errorMessage = toErrorMessage(err) + entry.state = 'failed' + entry.error = errorMessage + entry.finishedAt = Date.now() + this.pushRecentControlAction(entry) + + const clearedCount = this.clearPendingControlActions('cancelled') + this.completedControlActionsSinceLastFeedback = 0 + this.activeControlAction = null + this.touchActionQueue() + + this.appendLlmLog({ + eventType: 'feedback', + kind: 'scheduler', + metadata: { + actionId: entry.id, + clearedPendingCount: clearedCount, + error: errorMessage, + }, + sourceId: 'brain:action_queue', + sourceType: 'system', + tags: ['scheduler', 'action_queue', 'failure', entry.action.tool], + text: `Control action #${entry.id} failed: ${entry.action.tool}`, + turnId: entry.sourceTurnId, + }) + + await this.enqueueEvent(bot, { + payload: { + action: entry.action, + error: errorMessage, + status: 'failure', + summary: { + clearedPendingCount: clearedCount, + failedActionId: entry.id, + }, + }, + source: { id: 'executor', type: 'system' }, + timestamp: Date.now(), + type: 'feedback', + }) + break + } + finally { + if (this.currentCancellationToken === cancellationToken) { + this.currentCancellationToken = undefined + } + } + } + } + finally { + this.isActionWorkerRunning = false + if (this.pendingControlActions.length > 0 && this.runtimeMineflayer) { + this.startControlActionWorker(this.runtimeMineflayer) + } + } + } + + private setNoActionFollowupBudget(value: number): NoActionBudgetState & { ok: true } { + const normalizedRaw = Number(value) + const normalized = Number.isFinite(normalizedRaw) + ? Math.floor(normalizedRaw) + : this.noActionFollowupBudgetRemaining + const clamped = Math.max(0, Math.min(NO_ACTION_FOLLOWUP_BUDGET_MAX, normalized)) + this.noActionFollowupBudgetRemaining = clamped + this.noActionFollowupLastSignature = null + this.noActionFollowupStagnationCount = 0 + + this.appendLlmLog({ + eventType: 'system_alert', + kind: 'scheduler', + metadata: { + budget: this.getNoActionBudgetState(), + requested: value, + }, + sourceId: 'brain:no_action_budget', + sourceType: 'system', + tags: ['scheduler', 'no_action', 'budget_set'], + text: `No-action follow-up budget set to ${clamped}`, + turnId: this.turnCounter, + }) + + return { + ok: true, + ...this.getNoActionBudgetState(), + } + } + + private shouldSuppressDuringGiveUp(event: BotEvent): boolean { + if (!this.givenUp) + return false + + if (event.source.type === 'system' && event.source.id === ERROR_BURST_GUARD_SOURCE_ID) + return false + + if (event.type !== 'perception') + return true + + const signal = event.payload as PerceptionSignal + return !this.canResumeFromGiveUp(signal) + } + + private startControlActionWorker(bot: MineflayerWithAgents): void { + if (this.isActionWorkerRunning) + return + + this.isActionWorkerRunning = true + setImmediate(() => { + void this.runControlActionWorker(bot) + }) + } + + private toActionQueueEntryView(entry: ControlActionQueueEntry): ActionQueueEntryView { + return { + enqueuedAt: entry.enqueuedAt, + error: entry.error, + finishedAt: entry.finishedAt, + id: entry.id, + params: this.cloneActionParams(entry.action.params), + result: entry.result, + sourceTurnId: entry.sourceTurnId, + startedAt: entry.startedAt, + state: entry.state, + tool: entry.action.tool, + } + } + + // FIXME: Temporary fix to normalize xsai Message[] into the debug dashboard's string-only message schema. + private toDebugConversationMessages(messages: Message[]): ConversationUpdateEvent['messages'] { + return messages.map((message) => { + const normalizedMessage: ConversationUpdateEvent['messages'][number] = { + content: this.toDebugMessageContent(message.content), + role: message.role, + } + const reasoning = this.extractMessageReasoning(message) + if (reasoning) + normalizedMessage.reasoning = reasoning + return normalizedMessage + }) + } + + // --- Cognitive Cycle --- + + // FIXME: Temporary fix to flatten structured message parts into a string for debug transport compatibility. + private toDebugMessageContent(content: Message['content']): string { + if (typeof content === 'string') + return content + if (!content) + return '' + return content + .map((part) => { + if (part.type === 'text') + return part.text + if (part.type === 'refusal') + return part.refusal + return JSON.stringify(part) + }) + .join('\n') + } + + private toDebugReplActions(actions: Array<{ + action: ActionInstruction + error?: string + ok: boolean + result?: unknown + }>): DebugReplResult['actions'] { + return actions.map(item => ({ + error: item.error, + ok: item.ok, + params: item.action.params, + result: item.result === undefined ? undefined : (typeof item.result === 'string' ? item.result : JSON.stringify(item.result)), + tool: item.action.tool, + })) + } + + private touchActionQueue(): void { + this.actionQueueUpdatedAt = Date.now() + } + + private trimEventQueueOverflow(): void { + while (this.queue.length > MAX_EVENT_QUEUE_LENGTH) { + const dropIndex = this.findOverflowDropIndex() + const [dropped] = this.queue.splice(dropIndex, 1) + if (!dropped) + break + + dropped.resolve() + this.appendLlmLog({ + eventType: dropped.event.type, + kind: 'scheduler', + metadata: { + droppedPriority: getEventPriority(dropped.event), + queueLength: this.queue.length, + }, + sourceId: dropped.event.source.id, + sourceType: dropped.event.source.type, + tags: ['scheduler', 'queue', 'overflow_drop'], + text: `Dropped queued event due to queue overflow (max=${MAX_EVENT_QUEUE_LENGTH})`, + turnId: this.turnCounter, + }) + } + } + + private updateErrorBurstGuardCompletion( + turnId: number, + actions: Array<{ + action: ActionInstruction + ok: boolean + }>, + ): void { + if (!this.errorBurstGuardState) + return + + const hasGiveUp = actions.some(item => item.action.tool === 'giveUp' && item.ok) + const hasChat = actions.some(item => item.action.tool === 'chat' && item.ok) + + if (hasGiveUp && hasChat) { + this.clearErrorBurstGuardState(turnId, 'resolved') + return + } + + if (hasGiveUp || hasChat) { + this.appendLlmLog({ + eventType: 'system_alert', + kind: 'scheduler', + sourceId: ERROR_BURST_GUARD_SOURCE_ID, + sourceType: 'system', + tags: ['scheduler', 'error_burst', 'guard_pending'], + text: 'Error burst guard still pending: this turn must include both giveUp and chat actions', + turnId, + }) + } } } + +/** + * Turn a cryptic sandbox runtime error into actionable guidance the LLM can act on next turn. + * + * The dominant recurring failure is reading a coordinate (`.x`/`.y`/`.z`/`.pos`) off a query result + * that was `null` — e.g. `query.entities().whereName("pig").first().pos.x` when no pig was found. + * The raw message ("Cannot read properties of undefined (reading 'x')") gave the model nothing to + * fix, so it would repeat the same crash for several turns and then give up. Appending the concrete + * fix lets it recover in one turn. + * + * Before: + * - "Cannot read properties of undefined (reading 'x')" + * After: + * - "...reading 'x') — You read coordinates from a missing query result. Check for null first..." + */ +function augmentDecisionError(message: string): string { + if (/Cannot read properties of (?:undefined|null) \(reading '(?:[xyz]|pos|position|location)'\)/.test(message)) { + return `${message} — You tried to read coordinates from a missing object. query.entities()/query.blocks().first() returns null when no target is found, and reading .pos/.x from that value crashes. Fix it by checking for null first, for example: const t = query.entities().whereName("pig").first(); if (!t) { await chat({ message: "I do not see the target nearby, so I will search another direction.", feedback: false }) } else { await goToCoordinate({ x: t.pos.x, y: t.pos.y, z: t.pos.z, closeness: 1 }) }. Tip: to kill an animal, use attack({ type: "pig" }) against the nearest one; you usually do not need to query coordinates manually.` + } + return message +} + +function getEventPriority(event: BotEvent): number { + if (event.type === 'perception') { + const signal = event.payload as PerceptionSignal + if (signal.type === 'chat_message' || signal.type === 'airi_command') + return EVENT_PRIORITY_URGENT_PERCEPTION + return EVENT_PRIORITY_PERCEPTION + } + if (event.source.type === 'system' && event.source.id === NO_ACTION_FOLLOWUP_SOURCE_ID) + return EVENT_PRIORITY_NO_ACTION_FOLLOWUP + if (event.type === 'feedback') + return EVENT_PRIORITY_FEEDBACK + return EVENT_PRIORITY_PERCEPTION +} diff --git a/integrations/minecraft/src/cognitive/conscious/context-view.ts b/integrations/minecraft/src/cognitive/conscious/context-view.ts index d6fe4c454..ca10f4417 100644 --- a/integrations/minecraft/src/cognitive/conscious/context-view.ts +++ b/integrations/minecraft/src/cognitive/conscious/context-view.ts @@ -1,8 +1,8 @@ import type { ReflexContextState } from '../reflex/context' export interface ConsciousContextView { - selfSummary: string environmentSummary: string + selfSummary: string } export function buildConsciousContextView(ctx: ReflexContextState): ConsciousContextView { @@ -21,7 +21,7 @@ export function buildConsciousContextView(ctx: ReflexContextState): ConsciousCon const environmentSummary = `${ctx.environment.time} ${ctx.environment.weather} Nearby players [${players}] Nearby entities [${entities}] Light ${ctx.environment.lightLevel}` return { - selfSummary, environmentSummary, + selfSummary, } } diff --git a/integrations/minecraft/src/cognitive/conscious/history-query.ts b/integrations/minecraft/src/cognitive/conscious/history-query.ts index d96b1d937..7c6626b1c 100644 --- a/integrations/minecraft/src/cognitive/conscious/history-query.ts +++ b/integrations/minecraft/src/cognitive/conscious/history-query.ts @@ -6,17 +6,17 @@ import type { LlmLogEntry } from './llm-log' * Compact turn summary returned by history.turns(). */ export interface TurnSummary { - turnId: number - eventType: string actionCount: number + eventType: string hasError: boolean text: string + turnId: number } interface HistoryQueryDeps { getConversationHistory: () => readonly Message[] - getLlmLogEntries: () => readonly LlmLogEntry[] getCurrentTurnId: () => number + getLlmLogEntries: () => readonly LlmLogEntry[] } /** @@ -25,20 +25,55 @@ interface HistoryQueryDeps { */ export function createHistoryRuntime(deps: HistoryQueryDeps) { return { + /** + * Total message count in the conversation history. + */ + count(): number { + return deps.getConversationHistory().length + }, + + /** + * Current turn ID. + */ + currentTurn(): number { + return deps.getCurrentTurnId() + }, + + /** + * Last N player chat messages extracted from conversation history. + */ + playerChats(n = 5): string[] { + const history = deps.getConversationHistory() + const chats: string[] = [] + + for (let i = history.length - 1; i >= 0 && chats.length < n; i--) { + const msg = history[i] + if (msg.role !== 'user' || typeof msg.content !== 'string') + continue + // eslint-disable-next-line regexp/no-super-linear-backtracking + const match = msg.content.match(/\[EVENT\]\s*([^:\n]+:[^\n]+)/) + if (match?.[1] && !match[1].startsWith('Perception Signal:')) { + chats.unshift(match[1]) + } + } + + return chats + }, + /** * Last N user/assistant message pairs from conversation history. */ - recent(n = 5): Array<{ role: string, content: string }> { + recent(n = 5): Array<{ content: string, role: string }> { const history = deps.getConversationHistory() - const pairs: Array<{ role: string, content: string }> = [] + const pairs: Array<{ content: string, role: string }> = [] // Walk backwards collecting user/assistant pairs for (let i = history.length - 1; i >= 0 && pairs.length < n * 2; i--) { const msg = history[i] if (msg.role === 'user' || msg.role === 'assistant') { pairs.unshift({ - role: msg.role, content: typeof msg.content === 'string' ? msg.content : String(msg.content), + role: msg.role, }) } } @@ -50,19 +85,19 @@ export function createHistoryRuntime(deps: HistoryQueryDeps) { * Text search across conversation history. * Returns matching messages with their role and a content snippet. */ - search(query: string, maxResults = 10): Array<{ role: string, content: string, source: 'conversation' }> { + search(query: string, maxResults = 10): Array<{ content: string, role: string, source: 'conversation' }> { if (!query || typeof query !== 'string') return [] const needle = query.toLowerCase() - const results: Array<{ role: string, content: string, source: 'conversation' }> = [] + const results: Array<{ content: string, role: string, source: 'conversation' }> = [] for (const msg of deps.getConversationHistory()) { const content = typeof msg.content === 'string' ? msg.content : String(msg.content) if (content.toLowerCase().includes(needle)) { results.push({ - role: msg.role, content: content.length > 300 ? `${content.slice(0, 297)}...` : content, + role: msg.role, source: 'conversation', }) if (results.length >= maxResults) @@ -85,11 +120,11 @@ export function createHistoryRuntime(deps: HistoryQueryDeps) { if (entry.kind !== 'turn_input') continue turnMap.set(entry.turnId, { - turnId: entry.turnId, - eventType: entry.eventType, actionCount: 0, + eventType: entry.eventType, hasError: false, text: entry.text, + turnId: entry.turnId, }) } @@ -120,40 +155,5 @@ export function createHistoryRuntime(deps: HistoryQueryDeps) { const sorted = [...turnMap.values()].sort((a, b) => b.turnId - a.turnId) return sorted.slice(0, Math.max(1, Math.floor(n))) }, - - /** - * Last N player chat messages extracted from conversation history. - */ - playerChats(n = 5): string[] { - const history = deps.getConversationHistory() - const chats: string[] = [] - - for (let i = history.length - 1; i >= 0 && chats.length < n; i--) { - const msg = history[i] - if (msg.role !== 'user' || typeof msg.content !== 'string') - continue - // eslint-disable-next-line regexp/no-super-linear-backtracking - const match = msg.content.match(/\[EVENT\]\s*([^:\n]+:[^\n]+)/) - if (match?.[1] && !match[1].startsWith('Perception Signal:')) { - chats.unshift(match[1]) - } - } - - return chats - }, - - /** - * Total message count in the conversation history. - */ - count(): number { - return deps.getConversationHistory().length - }, - - /** - * Current turn ID. - */ - currentTurn(): number { - return deps.getCurrentTurnId() - }, } } diff --git a/integrations/minecraft/src/cognitive/conscious/js-planner-sandbox-protocol.ts b/integrations/minecraft/src/cognitive/conscious/js-planner-sandbox-protocol.ts index 32f43adba..bcb5e710f 100644 --- a/integrations/minecraft/src/cognitive/conscious/js-planner-sandbox-protocol.ts +++ b/integrations/minecraft/src/cognitive/conscious/js-planner-sandbox-protocol.ts @@ -3,47 +3,9 @@ import type { BotEvent } from '../types' export interface ActionRuntimeResult { action: ActionInstruction + error?: string ok: boolean result?: unknown - error?: string -} - -export interface QuerySeed { - blocks: Array> - craftable: string[] - entities: Array> - gaze: unknown[] - inventory: Array> - self: Record | null -} - -export interface HistorySeed { - conversationHistory: Array<{ role: string, content: string }> - currentTurn: number - llmLogEntries: Array> -} - -export interface RuntimeSnapshot { - actionQueue: unknown - currentInput: unknown - errorBurstGuard: unknown - event: BotEvent - historySeed: HistorySeed - lastAction: ActionRuntimeResult | null - llmInput: { - attempt: number - conversationHistory: unknown[] - messages: unknown[] - systemPrompt: string - updatedAt: number - userMessage: string - } | null | undefined - llmLogEntries: Array> - mem: Record - noActionBudget: unknown - prevRun: { actions: ActionRuntimeResult[], logs: string[], returnRaw?: unknown } | null - querySeed: QuerySeed | null - snapshot: Record } export interface BridgeAvailability { @@ -61,6 +23,49 @@ export interface BridgeAvailability { updateAiriContext: boolean } +export interface HistorySeed { + conversationHistory: Array<{ content: string, role: string }> + currentTurn: number + llmLogEntries: Array> +} + +export type ParentToWorkerMessage + = | { error: SerializedWorkerError, ok: false, requestId: number, type: 'bridge-response' } + | { ok: true, requestId: number, result?: unknown, type: 'bridge-response' } + | { payload: SandboxWorkerRequest, type: 'evaluate' } + +export interface QuerySeed { + blocks: Array> + craftable: string[] + entities: Array> + gaze: unknown[] + inventory: Array> + self: null | Record +} + +export interface RuntimeSnapshot { + actionQueue: unknown + currentInput: unknown + errorBurstGuard: unknown + event: BotEvent + historySeed: HistorySeed + lastAction: ActionRuntimeResult | null + llmInput: null | undefined | { + attempt: number + conversationHistory: unknown[] + messages: unknown[] + systemPrompt: string + updatedAt: number + userMessage: string + } + llmLogEntries: Array> + mem: Record + noActionBudget: unknown + prevRun: null | { actions: ActionRuntimeResult[], logs: string[], returnRaw?: unknown } + querySeed: null | QuerySeed + snapshot: Record +} + export interface SandboxWorkerRequest { bootstrapScript: string bridgeAvailability: BridgeAvailability @@ -77,51 +82,36 @@ export interface SandboxWorkerResult { returnRaw?: unknown } +export interface SandboxWorkerState { + logs: string[] + mem: Record +} + export interface SerializedWorkerError { message: string name: string stack?: string } -export interface SandboxWorkerState { - logs: string[] - mem: Record -} - export type WorkerToParentMessage - = | { type: 'ready' } - | { type: 'bridge-request', requestId: number, method: string, args: unknown[] } - | { type: 'result', result: SandboxWorkerResult } - | { type: 'error', error: SerializedWorkerError, state?: SandboxWorkerState } - | { type: 'catastrophic-error', error: SerializedWorkerError } + = | { args: unknown[], method: string, requestId: number, type: 'bridge-request' } + | { error: SerializedWorkerError, state?: SandboxWorkerState, type: 'error' } + | { error: SerializedWorkerError, type: 'catastrophic-error' } + | { result: SandboxWorkerResult, type: 'result' } + | { type: 'ready' } -export type ParentToWorkerMessage - = | { type: 'evaluate', payload: SandboxWorkerRequest } - | { type: 'bridge-response', requestId: number, ok: true, result?: unknown } - | { type: 'bridge-response', requestId: number, ok: false, error: SerializedWorkerError } - -function workerErrorName(error: unknown): string { - if (typeof error === 'object' && error !== null && 'name' in error && typeof error.name === 'string') - return error.name - if (error instanceof Error) - return error.name - return 'Error' +export function createWorkerError(message: string, state?: SandboxWorkerState, cause?: unknown): Error & { state?: SandboxWorkerState } { + const error = cause instanceof Error ? cause : new Error(message) + error.message = message + return Object.assign(error, state ? { state } : {}) } -function workerErrorMessage(error: unknown): string { - if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') - return error.message - if (error instanceof Error) - return error.message - return String(error) -} - -function workerErrorStack(error: unknown): string | undefined { - if (typeof error === 'object' && error !== null && 'stack' in error && typeof error.stack === 'string') - return error.stack - if (error instanceof Error) - return error.stack - return undefined +export function hydrateWorkerError(error: SerializedWorkerError): Error { + const hydrated = new Error(error.message) + hydrated.name = error.name + if (error.stack) + hydrated.stack = error.stack + return hydrated } export function serializeWorkerError(error: unknown): SerializedWorkerError { @@ -138,16 +128,26 @@ export function serializeWorkerError(error: unknown): SerializedWorkerError { } } -export function hydrateWorkerError(error: SerializedWorkerError): Error { - const hydrated = new Error(error.message) - hydrated.name = error.name - if (error.stack) - hydrated.stack = error.stack - return hydrated +function workerErrorMessage(error: unknown): string { + if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') + return error.message + if (error instanceof Error) + return error.message + return String(error) } -export function createWorkerError(message: string, state?: SandboxWorkerState, cause?: unknown): Error & { state?: SandboxWorkerState } { - const error = cause instanceof Error ? cause : new Error(message) - error.message = message - return Object.assign(error, state ? { state } : {}) +function workerErrorName(error: unknown): string { + if (typeof error === 'object' && error !== null && 'name' in error && typeof error.name === 'string') + return error.name + if (error instanceof Error) + return error.name + return 'Error' +} + +function workerErrorStack(error: unknown): string | undefined { + if (typeof error === 'object' && error !== null && 'stack' in error && typeof error.stack === 'string') + return error.stack + if (error instanceof Error) + return error.stack + return undefined } diff --git a/integrations/minecraft/src/cognitive/conscious/js-planner-sandbox-runner.ts b/integrations/minecraft/src/cognitive/conscious/js-planner-sandbox-runner.ts index 38b7b588a..aa6b8a433 100644 --- a/integrations/minecraft/src/cognitive/conscious/js-planner-sandbox-runner.ts +++ b/integrations/minecraft/src/cognitive/conscious/js-planner-sandbox-runner.ts @@ -32,18 +32,6 @@ function ancestorPath(path: string, levels: number): string { const PNPM_MODULE_STORE_PATH = realpathSync(ancestorPath(ISOLATED_VM_ENTRY_PATH, 4)) -function cloneStructured(value: T): T { - if (typeof value === 'undefined') - return value - - try { - return structuredClone(value) - } - catch { - return JSON.parse(JSON.stringify(value)) as T - } -} - interface SandboxRunnerOptions { bridgeTimeoutMs: number maxBridgeCalls: number @@ -118,18 +106,18 @@ export async function executeSandboxWorker( ]) response = { - type: 'bridge-response', - requestId: message.requestId, ok: true, + requestId: message.requestId, result: cloneStructured(result), + type: 'bridge-response', } } catch (error) { response = { - type: 'bridge-response', - requestId: message.requestId, - ok: false, error: serializeWorkerError(error), + ok: false, + requestId: message.requestId, + type: 'bridge-response', } } @@ -167,7 +155,7 @@ export async function executeSandboxWorker( return if (message.type === 'ready') { - child.send({ type: 'evaluate', payload: request } satisfies ParentToWorkerMessage) + child.send({ payload: request, type: 'evaluate' } satisfies ParentToWorkerMessage) return } @@ -193,3 +181,15 @@ export async function executeSandboxWorker( }) }) } + +function cloneStructured(value: T): T { + if (typeof value === 'undefined') + return value + + try { + return structuredClone(value) + } + catch { + return JSON.parse(JSON.stringify(value)) as T + } +} diff --git a/integrations/minecraft/src/cognitive/conscious/js-planner-worker.ts b/integrations/minecraft/src/cognitive/conscious/js-planner-worker.ts index 2caa2868d..54e0e5491 100644 --- a/integrations/minecraft/src/cognitive/conscious/js-planner-worker.ts +++ b/integrations/minecraft/src/cognitive/conscious/js-planner-worker.ts @@ -12,104 +12,12 @@ import process from 'node:process' import { pathToFileURL } from 'node:url' import { inspect } from 'node:util' -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function cloneStructured(value: T): T { - if (typeof value === 'undefined') - return value - - try { - return structuredClone(value) - } - catch { - return JSON.parse(JSON.stringify(value)) as T - } -} - -function workerErrorName(error: unknown): string { - if (typeof error === 'object' && error !== null && 'name' in error && typeof error.name === 'string') - return error.name - if (error instanceof Error) - return error.name - return 'Error' -} - -function workerErrorMessage(error: unknown): string { - if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') - return error.message - if (error instanceof Error) - return error.message - return String(error) -} - -function workerErrorStack(error: unknown): string | undefined { - if (typeof error === 'object' && error !== null && 'stack' in error && typeof error.stack === 'string') - return error.stack - if (error instanceof Error) - return error.stack - return undefined -} - -function serializeWorkerError(error: unknown): SerializedWorkerError { - const stack = workerErrorStack(error) - return stack - ? { - message: workerErrorMessage(error), - name: workerErrorName(error), - stack, - } - : { - message: workerErrorMessage(error), - name: workerErrorName(error), - } -} - -function hydrateWorkerError(error: SerializedWorkerError): Error { - const hydrated = new Error(error.message) - hydrated.name = error.name - if (error.stack) - hydrated.stack = error.stack - return hydrated -} - -function send(message: WorkerToParentMessage): void { - process.send?.(message) -} - function appendLog(logs: string[], args: unknown[]): string { - const rendered = args.map(arg => inspect(arg, { depth: 4, breakLength: 120 })).join(' ') + const rendered = args.map(arg => inspect(arg, { breakLength: 120, depth: 4 })).join(' ') logs.push(rendered) return rendered } -function serializeBridgeValue(value: unknown): string { - return JSON.stringify({ - isUndefined: typeof value === 'undefined', - value, - }) -} - -function readMemSnapshot(context: any, timeoutMs: number): Record { - try { - const mem = context.evalSync('mem', { timeout: timeoutMs, copy: true }) - return isRecord(mem) ? cloneStructured(mem) : {} - } - catch { - return {} - } -} - -function setGlobalValue(globalRef: any, name: string, value: unknown, ivm: any): void { - if (typeof value === 'undefined') { - globalRef.setSync(name, undefined) - return - } - - globalRef.setSync(name, new ivm.ExternalCopy(value).copyInto()) -} - function bindDataGlobals(globalRef: any, runtime: RuntimeSnapshot, ivm: any): void { const currentRun = { actions: [] as ActionRuntimeResult[], @@ -150,20 +58,112 @@ function bindDataGlobals(globalRef: any, runtime: RuntimeSnapshot, ivm: any): vo setGlobalValue(globalRef, 'lastAction', runtime.lastAction, ivm) } +function cloneStructured(value: T): T { + if (typeof value === 'undefined') + return value + + try { + return structuredClone(value) + } + catch { + return JSON.parse(JSON.stringify(value)) as T + } +} + +function hydrateWorkerError(error: SerializedWorkerError): Error { + const hydrated = new Error(error.message) + hydrated.name = error.name + if (error.stack) + hydrated.stack = error.stack + return hydrated +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readMemSnapshot(context: any, timeoutMs: number): Record { + try { + const mem = context.evalSync('mem', { copy: true, timeout: timeoutMs }) + return isRecord(mem) ? cloneStructured(mem) : {} + } + catch { + return {} + } +} + +function send(message: WorkerToParentMessage): void { + process.send?.(message) +} + +function serializeBridgeValue(value: unknown): string { + return JSON.stringify({ + isUndefined: typeof value === 'undefined', + value, + }) +} + +function serializeWorkerError(error: unknown): SerializedWorkerError { + const stack = workerErrorStack(error) + return stack + ? { + message: workerErrorMessage(error), + name: workerErrorName(error), + stack, + } + : { + message: workerErrorMessage(error), + name: workerErrorName(error), + } +} + +function setGlobalValue(globalRef: any, name: string, value: unknown, ivm: any): void { + if (typeof value === 'undefined') { + globalRef.setSync(name, undefined) + return + } + + globalRef.setSync(name, new ivm.ExternalCopy(value).copyInto()) +} + +function workerErrorMessage(error: unknown): string { + if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') + return error.message + if (error instanceof Error) + return error.message + return String(error) +} + +function workerErrorName(error: unknown): string { + if (typeof error === 'object' && error !== null && 'name' in error && typeof error.name === 'string') + return error.name + if (error instanceof Error) + return error.name + return 'Error' +} + +function workerErrorStack(error: unknown): string | undefined { + if (typeof error === 'object' && error !== null && 'stack' in error && typeof error.stack === 'string') + return error.stack + if (error instanceof Error) + return error.stack + return undefined +} + let nextRequestId = 1 -const pendingRequests = new Map void, reject: (error: Error) => void }>() +const pendingRequests = new Map void, resolve: (value: unknown) => void }>() async function requestParent(method: string, args: unknown[]): Promise { const requestId = nextRequestId++ send({ - type: 'bridge-request', - requestId, - method, args: cloneStructured(args), + method, + requestId, + type: 'bridge-request', }) return await new Promise((resolve, reject) => { - pendingRequests.set(requestId, { resolve, reject }) + pendingRequests.set(requestId, { reject, resolve }) }) } @@ -185,7 +185,7 @@ async function runEvaluation(payload: SandboxWorkerRequest): Promise { isolate = new ivm.Isolate({ memoryLimit: payload.memoryLimitMb, onCatastrophicError: (error: unknown) => { - send({ type: 'catastrophic-error', error: serializeWorkerError(error) }) + send({ error: serializeWorkerError(error), type: 'catastrophic-error' }) }, }) @@ -211,28 +211,28 @@ async function runEvaluation(payload: SandboxWorkerRequest): Promise { `return (async () => {\n${payload.script}\n})()`, [], { - timeout: payload.timeoutMs, result: { copy: true, promise: true }, + timeout: payload.timeoutMs, }, ) send({ - type: 'result', result: { logs: cloneStructured(logs), mem: readMemSnapshot(context, payload.timeoutMs), returnRaw: typeof returnRaw === 'undefined' ? undefined : cloneStructured(returnRaw), }, + type: 'result', }) } catch (error) { send({ - type: 'error', error: serializeWorkerError(error), state: { logs: cloneStructured(logs), mem: context ? readMemSnapshot(context, payload.timeoutMs) : {}, }, + type: 'error', }) } finally { @@ -279,12 +279,12 @@ process.on('disconnect', () => { }) process.on('uncaughtException', (error) => { - send({ type: 'error', error: serializeWorkerError(error) }) + send({ error: serializeWorkerError(error), type: 'error' }) process.exitCode = 1 }) process.on('unhandledRejection', (reason) => { - send({ type: 'error', error: serializeWorkerError(reason) }) + send({ error: serializeWorkerError(reason), type: 'error' }) process.exitCode = 1 }) diff --git a/integrations/minecraft/src/cognitive/conscious/js-planner.test.ts b/integrations/minecraft/src/cognitive/conscious/js-planner.test.ts index 0da82cf27..e96ea0449 100644 --- a/integrations/minecraft/src/cognitive/conscious/js-planner.test.ts +++ b/integrations/minecraft/src/cognitive/conscious/js-planner.test.ts @@ -39,19 +39,19 @@ describe('extractJavaScriptCandidate', () => { function createAction(name: string, schema: Action['schema']): Action { return { - name, description: `${name} tool`, execution: 'sync', - schema, + name, perform: () => () => '', + schema, } } const actions: Action[] = [ createAction('chat', z.object({ message: z.string() })), createAction('goToPlayer', z.object({ - player_name: z.string(), closeness: z.number().min(0), + player_name: z.string(), })), ] @@ -62,91 +62,91 @@ const actionsWithSkip: Action[] = [ describe('javaScriptPlanner', () => { const globals = { - event: { - type: 'perception', - payload: { type: 'chat_message' }, - source: { type: 'minecraft', id: 'test' }, - timestamp: Date.now(), - }, - snapshot: { - self: { health: 20, food: 20, location: { x: 0, y: 64, z: 0 } }, - environment: { nearbyPlayers: [] }, - social: {}, - threat: {}, - attention: {}, - }, - llmInput: { - systemPrompt: 'system prompt', - userMessage: 'latest user message', - messages: [{ role: 'user', content: 'hello' }], - conversationHistory: [{ role: 'assistant', content: 'previous reply' }], + actionQueue: { + capacity: { executing: 1, pending: 4, total: 5 }, + counts: { executing: 0, pending: 0, total: 0 }, + executing: null, + pending: [], + recent: [], updatedAt: Date.now(), + }, + errorBurstGuard: null, + event: { + payload: { type: 'chat_message' }, + source: { id: 'test', type: 'minecraft' }, + timestamp: Date.now(), + type: 'perception', + }, + forgetConversation: () => ({ cleared: ['conversationHistory', 'lastLlmInputSnapshot'], ok: true }), + getNoActionBudget: () => ({ + default: 3, + max: 8, + remaining: 3, + }), + llmInput: { attempt: 1, + conversationHistory: [{ content: 'previous reply', role: 'assistant' }], + messages: [{ content: 'hello', role: 'user' }], + systemPrompt: 'system prompt', + updatedAt: Date.now(), + userMessage: 'latest user message', + }, + noActionBudget: { + default: 3, + max: 8, + remaining: 3, }, patterns: { - get: (id: string) => { - if (id !== 'collect.wall_torch') - return null - return { - id: 'collect.wall_torch', - title: 'Collect Wall Torches Reliably', - intent: 'Use variant-aware block lookup for torch tasks.', - whenToUse: ['torch tasks'], - steps: ['scan blocks', 'mine exact target'], - code: 'const target = query.blocks().within(32).list().find(b => b.name.includes("torch"));', - tags: ['torch', 'wall_torch'], - } - }, find: (query: string, limit = 10) => { if (!query.toLowerCase().includes('torch')) return [] return [{ - id: 'collect.wall_torch', - title: 'Collect Wall Torches Reliably', - intent: 'Use variant-aware block lookup for torch tasks.', - whenToUse: ['torch tasks'], - steps: ['scan blocks', 'mine exact target'], code: 'const target = query.blocks().within(32).list().find(b => b.name.includes("torch"));', + id: 'collect.wall_torch', + intent: 'Use variant-aware block lookup for torch tasks.', + steps: ['scan blocks', 'mine exact target'], tags: ['torch', 'wall_torch'], + title: 'Collect Wall Torches Reliably', + whenToUse: ['torch tasks'], }].slice(0, limit) }, + get: (id: string) => { + if (id !== 'collect.wall_torch') + return null + return { + code: 'const target = query.blocks().within(32).list().find(b => b.name.includes("torch"));', + id: 'collect.wall_torch', + intent: 'Use variant-aware block lookup for torch tasks.', + steps: ['scan blocks', 'mine exact target'], + tags: ['torch', 'wall_torch'], + title: 'Collect Wall Torches Reliably', + whenToUse: ['torch tasks'], + } + }, ids: () => ['collect.wall_torch'], list: (limit = 10) => [{ - id: 'collect.wall_torch', - title: 'Collect Wall Torches Reliably', - intent: 'Use variant-aware block lookup for torch tasks.', - whenToUse: ['torch tasks'], - steps: ['scan blocks', 'mine exact target'], code: 'const target = query.blocks().within(32).list().find(b => b.name.includes("torch"));', + id: 'collect.wall_torch', + intent: 'Use variant-aware block lookup for torch tasks.', + steps: ['scan blocks', 'mine exact target'], tags: ['torch', 'wall_torch'], + title: 'Collect Wall Torches Reliably', + whenToUse: ['torch tasks'], }].slice(0, limit), }, - actionQueue: { - executing: null, - pending: [], - recent: [], - capacity: { total: 5, executing: 1, pending: 4 }, - counts: { total: 0, executing: 0, pending: 0 }, - updatedAt: Date.now(), - }, - noActionBudget: { - remaining: 3, + setNoActionBudget: (value: number) => ({ default: 3, max: 8, - }, - errorBurstGuard: null, - setNoActionBudget: (value: number) => ({ ok: true, remaining: Math.max(0, Math.min(8, Math.floor(value))), - default: 3, - max: 8, }), - getNoActionBudget: () => ({ - remaining: 3, - default: 3, - max: 8, - }), - forgetConversation: () => ({ ok: true, cleared: ['conversationHistory', 'lastLlmInputSnapshot'] }), + snapshot: { + attention: {}, + environment: { nearbyPlayers: [] }, + self: { food: 20, health: 20, location: { x: 0, y: 64, z: 0 } }, + social: {}, + threat: {}, + }, } as any it('maps positional/object args and executes tools in order', async () => { @@ -158,11 +158,11 @@ describe('javaScriptPlanner', () => { `, actions, globals, executeAction) expect(executeAction).toHaveBeenCalledTimes(2) - expect(executeAction).toHaveBeenNthCalledWith(1, { tool: 'chat', params: { message: 'hello' } }) - expect(executeAction).toHaveBeenNthCalledWith(2, { tool: 'goToPlayer', params: { player_name: 'Alex', closeness: 2 } }) + expect(executeAction).toHaveBeenNthCalledWith(1, { params: { message: 'hello' }, tool: 'chat' }) + expect(executeAction).toHaveBeenNthCalledWith(2, { params: { closeness: 2, player_name: 'Alex' }, tool: 'goToPlayer' }) expect(planned.actions.map(a => a.action)).toEqual([ - { tool: 'chat', params: { message: 'hello' } }, - { tool: 'goToPlayer', params: { player_name: 'Alex', closeness: 2 } }, + { params: { message: 'hello' }, tool: 'chat' }, + { params: { closeness: 2, player_name: 'Alex' }, tool: 'goToPlayer' }, ]) }) @@ -171,7 +171,7 @@ describe('javaScriptPlanner', () => { const executeAction = vi.fn(async action => `ok:${action.tool}`) const planned = await planner.evaluate(`await use("chat", { message: "via-use" })`, actions, globals, executeAction) - expect(planned.actions.map(a => a.action)).toEqual([{ tool: 'chat', params: { message: 'via-use' } }]) + expect(planned.actions.map(a => a.action)).toEqual([{ params: { message: 'via-use' }, tool: 'chat' }]) }) it('persists script variables across turns with mem', async () => { @@ -181,7 +181,7 @@ describe('javaScriptPlanner', () => { await planner.evaluate('mem.count = 2', actions, globals, executeAction) const planned = await planner.evaluate('await chat("count=" + mem.count)', actions, globals, executeAction) - expect(planned.actions.map(a => a.action)).toEqual([{ tool: 'chat', params: { message: 'count=2' } }]) + expect(planned.actions.map(a => a.action)).toEqual([{ params: { message: 'count=2' }, tool: 'chat' }]) }) // https://github.com/moeru-ai/airi/pull/1915 (Codex P2) @@ -195,7 +195,7 @@ describe('javaScriptPlanner', () => { const executeAction = vi.fn(async action => `ok:${action.tool}`) const withSelf = { ...globals, - snapshot: { ...globals.snapshot, self: { health: 20, food: 20, location: { x: 12, y: 64, z: -7 } } }, + snapshot: { ...globals.snapshot, self: { food: 20, health: 20, location: { x: 12, y: 64, z: -7 } } }, } const planned = await planner.evaluate( @@ -222,7 +222,7 @@ describe('javaScriptPlanner', () => { await chat(inv.map(item => item.count + " " + item.name).join(", ")) `, actions, globals, executeAction) - expect(planned.actions.map(a => a.action)).toEqual([{ tool: 'chat', params: { message: '2 oak_log' } }]) + expect(planned.actions.map(a => a.action)).toEqual([{ params: { message: '2 oak_log' }, tool: 'chat' }]) }) it('does not expose stringified return mirror on prevRun', async () => { @@ -243,7 +243,7 @@ describe('javaScriptPlanner', () => { const executeAction = vi.fn(async action => `ok:${action.tool}`) const planned = await planner.evaluate('await chat("hp=" + self.health)', actions, globals, executeAction) - expect(planned.actions.map(a => a.action)).toEqual([{ tool: 'chat', params: { message: 'hp=20' } }]) + expect(planned.actions.map(a => a.action)).toEqual([{ params: { message: 'hp=20' }, tool: 'chat' }]) }) it('rejects mixed skip + tool calls', async () => { @@ -260,7 +260,7 @@ describe('javaScriptPlanner', () => { await expect(planner.evaluate('await skip()', actionsWithSkip, globals, executeAction)).resolves.toMatchObject({ actions: [ { - action: { tool: 'skip', params: {} }, + action: { params: {}, tool: 'skip' }, ok: true, result: 'Skipped turn', }, @@ -294,10 +294,10 @@ describe('javaScriptPlanner', () => { it('supports expectation guardrails on structured action telemetry', async () => { const planner = new JavaScriptPlanner() const executeAction = vi.fn(async () => ({ - ok: true, - movedDistance: 1.25, distanceToTargetAfter: 1.5, endPos: { x: 8, y: 64, z: 4 }, + movedDistance: 1.25, + ok: true, })) const planned = await planner.evaluate(` @@ -315,8 +315,8 @@ describe('javaScriptPlanner', () => { it('throws when expectation guardrail fails', async () => { const planner = new JavaScriptPlanner() const executeAction = vi.fn(async () => ({ - ok: true, movedDistance: 0.1, + ok: true, })) await expect(planner.evaluate(` @@ -377,9 +377,9 @@ describe('javaScriptPlanner', () => { const guardedGlobals = { ...globals, errorBurstGuard: { + errorTurnCount: 3, threshold: 3, windowTurns: 5, - errorTurnCount: 3, }, } as any const planned = await planner.evaluate('return errorBurstGuard.errorTurnCount', actions, guardedGlobals, executeAction) @@ -391,7 +391,7 @@ describe('javaScriptPlanner', () => { const planner = new JavaScriptPlanner() const executeAction = vi.fn(async action => `ok:${action.tool}`) const planned = await planner.evaluate('await chat("llm=" + llmUserMessage)', actions, globals, executeAction) - expect(planned.actions[0]?.action).toEqual({ tool: 'chat', params: { message: 'llm=latest user message' } }) + expect(planned.actions[0]?.action).toEqual({ params: { message: 'llm=latest user message' }, tool: 'chat' }) }) it('exposes patterns runtime global to scripts', async () => { @@ -526,23 +526,23 @@ describe('javaScriptPlanner', () => { const planner = new JavaScriptPlanner() const mineflayer = { bot: { - version: '1.21.1', - entity: { id: 0, position: { x: 0, y: 64, z: 0 } }, - health: 20, - food: 20, - heldItem: null, - game: { gameMode: 'survival' }, - isRaining: false, - time: { timeOfDay: 0 }, - entities: { - 1: { id: 1, name: 'dssadg', type: 'player', username: 'dssadg', position: { x: 3, y: 64, z: 0 } }, - }, - players: {}, - findBlocks: () => [], blockAt: () => null, - inventory: { items: () => [], emptySlotCount: () => 36 }, - registry: { items: {}, itemsByName: {}, blocksByName: { crafting_table: { id: 58 } } }, + entities: { + 1: { id: 1, name: 'dssadg', position: { x: 3, y: 64, z: 0 }, type: 'player', username: 'dssadg' }, + }, + entity: { id: 0, position: { x: 0, y: 64, z: 0 } }, + findBlocks: () => [], + food: 20, + game: { gameMode: 'survival' }, + health: 20, + heldItem: null, + inventory: { emptySlotCount: () => 36, items: () => [] }, + isRaining: false, + players: {}, recipesFor: () => [], + registry: { blocksByName: { crafting_table: { id: 58 } }, items: {}, itemsByName: {} }, + time: { timeOfDay: 0 }, + version: '1.21.1', }, } const executeAction = vi.fn(async action => `ok:${action.tool}`) @@ -567,23 +567,23 @@ describe('javaScriptPlanner', () => { const planner = new JavaScriptPlanner() const mineflayer = { bot: { - version: '1.21.1', - entity: { id: 0, position: { x: 0, y: 64, z: 0 } }, - health: 20, - food: 20, - heldItem: null, - game: { gameMode: 'survival' }, - isRaining: false, - time: { timeOfDay: 0 }, - entities: { - 1: { id: 1, name: 'dssadg', type: 'player', username: 'dssadg', position: { x: 3, y: 64, z: 0 } }, - }, - players: {}, - findBlocks: () => [], blockAt: () => null, - inventory: { items: () => [], emptySlotCount: () => 36 }, - registry: { items: {}, itemsByName: {}, blocksByName: { crafting_table: { id: 58 } } }, + entities: { + 1: { id: 1, name: 'dssadg', position: { x: 3, y: 64, z: 0 }, type: 'player', username: 'dssadg' }, + }, + entity: { id: 0, position: { x: 0, y: 64, z: 0 } }, + findBlocks: () => [], + food: 20, + game: { gameMode: 'survival' }, + health: 20, + heldItem: null, + inventory: { emptySlotCount: () => 36, items: () => [] }, + isRaining: false, + players: {}, recipesFor: () => [], + registry: { blocksByName: { crafting_table: { id: 58 } }, items: {}, itemsByName: {} }, + time: { timeOfDay: 0 }, + version: '1.21.1', }, } const executeAction = vi.fn(async action => `ok:${action.tool}`) diff --git a/integrations/minecraft/src/cognitive/conscious/js-planner.ts b/integrations/minecraft/src/cognitive/conscious/js-planner.ts index eeb4c7e75..3ab85a174 100644 --- a/integrations/minecraft/src/cognitive/conscious/js-planner.ts +++ b/integrations/minecraft/src/cognitive/conscious/js-planner.ts @@ -19,14 +19,6 @@ import { Vec3 } from 'vec3' import { executeSandboxWorker } from './js-planner-sandbox-runner' import { createQueryRuntime } from './query-dsl' -interface JavaScriptPlannerOptions { - bridgeTimeoutMs?: number - timeoutMs?: number - maxActionsPerTurn?: number - maxBridgeCalls?: number - memoryLimitMb?: number -} - interface ActivePlannerRun { actionCount: number actionsByName: Map @@ -36,6 +28,14 @@ interface ActivePlannerRun { sawSkip: boolean } +interface JavaScriptPlannerOptions { + bridgeTimeoutMs?: number + maxActionsPerTurn?: number + maxBridgeCalls?: number + memoryLimitMb?: number + timeoutMs?: number +} + interface ValidationResult { action?: ActionInstruction error?: string @@ -466,33 +466,6 @@ function __plannerExpectationDetail(message, fallback) { } ` -export interface RuntimeGlobals { - event: BotEvent - snapshot: Record - patterns?: PatternRuntime | null - mineflayer?: Mineflayer | null - bot?: unknown - actionQueue?: unknown - noActionBudget?: unknown - errorBurstGuard?: unknown - currentInput?: unknown - llmLog?: unknown - setNoActionBudget?: (value: number) => { ok: true, remaining: number, default: number, max: number } - getNoActionBudget?: () => { remaining: number, default: number, max: number } - forgetConversation?: () => { ok: true, cleared: string[] } - notifyAiri?: (headline: string, note?: string, urgency?: 'immediate' | 'soon' | 'later') => void - updateAiriContext?: (text: string, hints?: string[], lane?: string) => void - history?: unknown - llmInput?: { - systemPrompt: string - userMessage: string - messages: unknown[] - conversationHistory: unknown[] - updatedAt: number - attempt: number - } | null -} - export interface JavaScriptRunResult { actions: ActionRuntimeResult[] logs: string[] @@ -500,32 +473,43 @@ export interface JavaScriptRunResult { } export interface PlannerGlobalDescriptor { + kind: 'boolean' | 'function' | 'null' | 'number' | 'object' | 'string' | 'tool' | 'undefined' | 'unknown' name: string - kind: 'tool' | 'function' | 'object' | 'number' | 'string' | 'boolean' | 'undefined' | 'null' | 'unknown' - readonly: boolean preview: string + readonly: boolean +} + +export interface RuntimeGlobals { + actionQueue?: unknown + bot?: unknown + currentInput?: unknown + errorBurstGuard?: unknown + event: BotEvent + forgetConversation?: () => { cleared: string[], ok: true } + getNoActionBudget?: () => { default: number, max: number, remaining: number } + history?: unknown + llmInput?: null | { + attempt: number + conversationHistory: unknown[] + messages: unknown[] + systemPrompt: string + updatedAt: number + userMessage: string + } + llmLog?: unknown + mineflayer?: Mineflayer | null + noActionBudget?: unknown + notifyAiri?: (headline: string, note?: string, urgency?: 'immediate' | 'later' | 'soon') => void + patterns?: null | PatternRuntime + setNoActionBudget?: (value: number) => { default: number, max: number, ok: true, remaining: number } + snapshot: Record + updateAiriContext?: (text: string, hints?: string[], lane?: string) => void } interface DescribeGlobalsOptions { includeBuiltins?: boolean } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function deepFreeze(value: T): T { - if (!value || typeof value !== 'object') - return value - - for (const key of Object.keys(value as Record)) { - const child = (value as Record)[key] - deepFreeze(child) - } - - return Object.freeze(value) -} - function cloneStructured(value: T): T { if (typeof value === 'undefined') return value @@ -542,165 +526,49 @@ function copyForIsolate(value: T): T { return deepFreeze(cloneStructured(value)) } +function deepFreeze(value: T): T { + if (!value || typeof value !== 'object') + return value + + for (const key of Object.keys(value as Record)) { + const child = (value as Record)[key] + deepFreeze(child) + } + + return Object.freeze(value) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + // NOTICE: // `bot`/`mineflayer` are live socket-backed objects that cannot cross the sandbox // boundary, so they are exposed to scripts only through the `botCall` bridge below. // This denylist blocks methods that would tear down the connection or hijack the // event bus; everything else on the bot is allowed (open-by-default). const BOT_METHOD_DENYLIST = new Set([ + 'addListener', + 'emit', 'end', - 'quit', + 'off', 'on', 'once', - 'off', - 'addListener', - 'removeListener', + 'quit', 'removeAllListeners', - 'emit', + 'removeListener', ]) -/** - * Marshals one `botCall` argument from sandbox-serializable data into the value the - * mineflayer API expects. - * - * Before: - * - `{ x: 1, y: 2, z: 3 }` - * - * After: - * - `Vec3(1, 2, 3)` - * - * Non position-shaped values pass through unchanged. - */ -function marshalBotArg(value: unknown): unknown { - if (value && typeof value === 'object' && !Array.isArray(value)) { - const record = value as Record - if (typeof record.x === 'number' && typeof record.y === 'number' && typeof record.z === 'number') - return new Vec3(record.x, record.y, record.z) - } - return value -} - -/** - * Invokes a method on the live mineflayer bot on behalf of sandboxed script code. - * - * Use when: - * - A script needs a low-level bot action that has no dedicated tool (e.g. `lookAt`). - * - * Expects: - * - `method` is not in {@link BOT_METHOD_DENYLIST} and resolves to a bot function. - * - `rawArgs` are sandbox-serializable; position-shaped args are marshaled to `Vec3`. - * - * Returns: - * - The method's result, defensively cloned to a sandbox-safe value (or `null` when - * the result is a live object that cannot be serialized back). - */ -async function callBotMethod(mineflayer: Mineflayer, method: string, rawArgs: unknown): Promise { - if (BOT_METHOD_DENYLIST.has(method)) - throw new Error(`botCall: method "${method}" is not allowed`) - - const bot = mineflayer.bot as unknown as Record - const fn = bot[method] - if (typeof fn !== 'function') - throw new TypeError(`botCall: bot.${method} is not a function`) - - const callArgs = Array.isArray(rawArgs) ? rawArgs.map(marshalBotArg) : [] - const result = await (fn as (...a: unknown[]) => unknown).apply(bot, callArgs) - - try { - return cloneStructured(result) - } - catch { - // Live objects (Entity/Block/etc.) are not serializable back into the sandbox. - return null - } -} - -export function extractJavaScriptCandidate(input: string): string { - const trimmed = input.trim() - // Prefer a fenced code block found ANYWHERE in the reply: chat-style models often add a short line - // of reasoning before the code. The previous `^...$` anchoring only matched a reply that was nothing - // but a fence, so any leading prose caused the entire prose+code to be executed as a script. - // eslint-disable-next-line regexp/no-super-linear-backtracking - const fenced = trimmed.match(/```(?:js|javascript|ts|typescript)?[^\S\r\n]*\r?\n?([\s\S]*?)```/i) - if (fenced?.[1]) - return fenced[1].trim() - - // No fence: the model often wraps un-fenced code in a Chinese intro/outro line (e.g. - // "好的,我来做:\n\n这样就安全了"). Such a bare-word line is INSIDIOUS — CJK characters are - // valid JS identifiers, so a line like "然后我去做盔甲" PARSES as an identifier expression, sails - // past the syntax firewall, then throws "<那串中文> is not defined" at runtime (the real cause of the - // "X is not defined" failures). Strip those leading/trailing prose lines so the real code runs. - return stripEdgeProseLines(trimmed) -} - -/** A leading/trailing line that is natural-language prose (CJK text with no JS structure), to drop. */ -function isEdgeProseLine(line: string): boolean { - const t = line.trim() - if (!t) - return false - // Any JS structure means it's code, keep it (covers chat strings, which carry the CJK inside `(...)`). - if (/[()[\]{}=;]/.test(t)) - return false - // Otherwise treat a line containing CJK (Chinese/Japanese/Korean) as prose — conservative, so plain - // ASCII code lines are never stripped. - return /[\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]/.test(t) -} - -/** - * Drop natural-language prose lines wrapping un-fenced code. - * - * Before: "好的,我来做:\nawait craftRecipe({ item_name: \"diamond_helmet\" })\n这样就安全啦" - * After: "await craftRecipe({ item_name: \"diamond_helmet\" })" - * - * Only strips from the edges (never the middle, so multi-line constructs are safe) and only CJK prose. - * If nothing executable would remain (a pure-prose reply), returns the original so the firewall still - * corrects the model instead of silently running an empty script. - */ -function stripEdgeProseLines(code: string): string { - const lines = code.split(/\r?\n/) - let start = 0 - let end = lines.length - while (start < end && isEdgeProseLine(lines[start])) - start++ - while (end > start && isEdgeProseLine(lines[end - 1])) - end-- - const stripped = lines.slice(start, end).join('\n').trim() - return stripped || code -} - -// NOTICE: Firewall between the LLM's natural-language space and the Minecraft action (JS) space. -// The decision script is executed inside the sandbox as `return (async () => {\n