From 6a245826960adef98526316c5ab79375eb10bb4c Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Wed, 26 Aug 2026 19:01:04 +0800 Subject: [PATCH] chore(deps): bump dependencies --- .../main/services/airi/godot-stage/index.ts | 776 +- .../src/modules/analytics-adapters/posthog.ts | 2 +- docs/.vitepress/modules/posthog.ts | 2 +- docs/package.json | 1 + docs/uno.config.ts | 541 +- eslint.config.ts | 22 +- package.json | 3 +- packages/audio/package.json | 3 +- .../src/client/crossws/index.test.ts | 41 +- packages/cap-vite/src/vite-plugin.ts | 186 +- packages/core-agent/package.json | 1 + packages/core-agent/tsconfig.json | 1 + packages/electron-screen-capture/package.json | 10 +- packages/electron-vueuse/package.json | 3 +- .../src/llm-streaming-control/controller.ts | 412 +- .../src/plugin-host/shared/types.ts | 293 +- .../test-stoppable-extension-entrypoint.ts | 7 +- packages/stage-shared/package.json | 2 + .../stage-shared/src/analytics/posthog.ts | 25 + .../src/components/ThreeScene.vue | 7 +- .../components/data-pane/property-point.vue | 6 +- .../widgets/poppin-text/animators/fade.ts | 2 +- .../widgets/poppin-text/animators/float.ts | 10 +- .../widgets/poppin-text/animators/popup.ts | 2 +- .../poppin-text/animators/scale-popup.ts | 2 +- .../widgets/poppin-text/animators/stack.ts | 4 +- .../stage-ui/src/composables/use-io-tracer.ts | 107 +- .../src/libs/analytics/posthog.test.ts | 2 +- .../stage-ui/src/libs/analytics/posthog.ts | 23 +- .../ui-loading-screens/playground/src/App.vue | 2 +- .../components/LoadingSciFiCircle/index.vue | 27 +- .../ui-transitions/playground/src/App.vue | 2 +- pnpm-lock.yaml | 20453 +++++++--------- pnpm-workspace.yaml | 487 +- posthog.config.ts | 33 - server/apps/api/instrumentation.ts | 22 +- server/apps/api/package.json | 1 + server/apps/api/src/libs/env.ts | 94 +- server/apps/api/src/libs/mock-db.test.ts | 22 + server/apps/api/src/libs/mock-db.ts | 2 +- server/apps/auth/instrumentation.ts | 36 +- server/apps/auth/package.json | 1 + 42 files changed, 10997 insertions(+), 12681 deletions(-) create mode 100644 packages/stage-shared/src/analytics/posthog.ts delete mode 100644 posthog.config.ts create mode 100644 server/apps/api/src/libs/mock-db.test.ts diff --git a/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts b/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts index 16ef41539..d61135810 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts @@ -9,6 +9,7 @@ import type { StageViewSnapshotPayload, } from '@proj-airi/stage-shared/godot-stage' import type { BrowserWindow } from 'electron' +import type { WebSocketMessage, WebSocketPeer } from 'h3' import type { InferOutput } from 'valibot' import type { @@ -54,29 +55,15 @@ import { import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle' import { getElectronMainDirname } from '../../../libs/electron/location' -type MainContext = ReturnType['context'] -type GodotStageWebSocketHooks = Exclude[0], (...args: never[]) => unknown> -type GodotStagePeer = Parameters>[0] -type GodotStageMessage = Parameters>[1] type GodotStageProcess = ChildProcessByStdio +type MainContext = ReturnType['context'] const DEFAULT_GODOT_REMOTE_DEBUG_URI = 'tcp://127.0.0.1:6007' interface Deferred { promise: Promise reject: (error?: unknown) => void - resolve: (value: T | PromiseLike) => void -} - -interface ListenerChannel { - publish: (payload: T) => void - subscribe: (callback: (payload: T) => void) => () => void -} - -interface GodotStageSocketRuntime { - port: number - server: ReturnType - token: string + resolve: (value: PromiseLike | T) => void } interface GodotStageSceneApplyPayload { @@ -86,12 +73,23 @@ interface GodotStageSceneApplyPayload { path: string } +interface GodotStageSocketRuntime { + port: number + server: ReturnType + token: string +} + +interface ListenerChannel { + publish: (payload: T) => void + subscribe: (callback: (payload: T) => void) => () => void +} + const godotStageSceneInputPayloadSchema = object({ - modelId: string(), - format: literal('vrm'), - name: string(), - fileName: string(), data: instance(Uint8Array), + fileName: string(), + format: literal('vrm'), + modelId: string(), + name: string(), }) const godotStageSocketEnvelopeSchema = object({ @@ -103,8 +101,6 @@ const godotStagePayloadMessageSchema = object({ message: string(), }) -type GodotStageSocketEnvelope = InferOutput - /** * Godot sidecar lifecycle controller owned by Electron main. * @@ -124,7 +120,7 @@ export interface GodotStageManager { applySceneInput: (payload: ElectronGodotStageSceneInputPayload) => Promise applyViewPatch: (payload: StageViewPatch) => Promise getStatus: () => ElectronGodotStageStatus - getViewSnapshot: () => StageViewSnapshotPayload | null + getViewSnapshot: () => null | StageViewSnapshotPayload requestViewSnapshot: () => Promise start: () => Promise stop: () => Promise @@ -133,242 +129,12 @@ export interface GodotStageManager { subscribeViewSnapshot: (callback: (snapshot: StageViewSnapshotPayload) => void) => () => void } -function createDeferred(): Deferred { - let resolve!: Deferred['resolve'] - let reject!: Deferred['reject'] - - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise - }) - - return { - promise, - reject, - resolve, - } -} - -function createListenerChannel(onListenerError: (error: unknown) => void): ListenerChannel { - const listeners = new Set<(payload: T) => void>() - - return { - publish(payload) { - for (const listener of listeners) { - try { - listener(payload) - } - catch (error) { - onListenerError(error) - } - } - }, - subscribe(callback) { - listeners.add(callback) - - return () => { - listeners.delete(callback) - } - }, - } -} - -function createInitialStatus(): ElectronGodotStageStatus { - return { - state: 'stopped', - pid: null, - updatedAt: Date.now(), - } -} - -function createSocketEnvelope(type: string, payload?: unknown) { - return JSON.stringify({ type, payload }) -} - -function waitForProcessExit(exitPromise: Promise, timeoutMs: number) { - return Promise.race([ - exitPromise.then(() => true, () => false), - new Promise(resolve => setTimeout(resolve, timeoutMs, false)), - ]) -} - -function pipeProcessLog(stream: Readable, write: (message: string) => void) { - stream.on('data', (data) => { - const message = data.toString('utf-8').trim() - if (message) { - write(message) - } - }) -} - -function normalizeFileName(fileName: string) { - const normalized = basename(fileName.trim()) - return normalized || 'model.bin' -} - -function parseSocketMessage(message: GodotStageMessage): GodotStageSocketEnvelope { - const parsed = safeDestr(message.text(), { strict: true }) - const result = safeParse(godotStageSocketEnvelopeSchema, parsed) - if (!result.success) - throw new Error('Invalid Godot stage WebSocket envelope.') - - return result.output -} - -function getPayloadMessage(payload: unknown) { - const result = safeParse(godotStagePayloadMessageSchema, payload) - if (!result.success) { - return undefined - } - - return result.output.message -} - -function parseSceneInputPayload(payload: unknown): ElectronGodotStageSceneInputPayload { - const result = safeParse(godotStageSceneInputPayloadSchema, payload) - if (!result.success) - throw new Error('Invalid Godot stage scene input payload.') - - return result.output -} - -function resolveGodotStageStorageRoot() { - return join(app.getPath('userData'), 'godot-stage') -} - -function resolveGodotStageDebugLaunchOptions() { - const remoteDebugEnabled = ['1', 'true', 'yes', 'on'].includes( - (process.env.GODOT_STAGE_REMOTE_DEBUG ?? '').trim().toLowerCase(), - ) - const remoteDebugUri = remoteDebugEnabled - ? process.env.GODOT_STAGE_REMOTE_DEBUG_URI?.trim() || DEFAULT_GODOT_REMOTE_DEBUG_URI - : undefined - - // Godot engine/debugger flags must stay before `--`; StageRoot arguments stay - // after it and are assembled next to the WebSocket URL. - return { - engineArgs: remoteDebugUri ? ['--remote-debug', remoteDebugUri] : [], - remoteDebugUri, - } -} - -function resolveGodotStageProcessEnv(): NodeJS.ProcessEnv { - return { - ...process.env, - AIRI_GODOT_STAGE_DEV_MODE: app.isPackaged - ? process.env.AIRI_GODOT_STAGE_DEV_MODE ?? '0' - : process.env.AIRI_GODOT_STAGE_DEV_MODE ?? '1', - } -} - interface GodotBinaryResolution { executable: string mode: 'engine' | 'exported' } -// Dev builds run the Godot engine against the workspace project.godot. -async function resolveGodotProjectPath() { - let currentDirectory = getElectronMainDirname() - - while (true) { - const projectPath = resolve(currentDirectory, 'engines', 'stage-tamagotchi-godot') - - try { - await access(join(projectPath, 'project.godot')) - return projectPath - } - catch {} - - const parentDirectory = dirname(currentDirectory) - if (parentDirectory === currentDirectory) { - break - } - - currentDirectory = parentDirectory - } - - throw new Error(`Unable to locate engines/stage-tamagotchi-godot/project.godot from ${getElectronMainDirname()}.`) -} - -// Packaged builds ship a pre-exported sidecar under Electron resources. -async function resolveExportedGodotBinary(): Promise { - const platform = process.platform - let binaryName: string - - if (platform === 'win32') { - binaryName = 'godot-stage.exe' - } - else if (platform === 'darwin') { - binaryName = join('godot-stage.app', 'Contents', 'MacOS', 'godot-stage') - } - else { - binaryName = 'godot-stage' - } - - const binaryPath = join(process.resourcesPath, 'godot-stage', binaryName) - - try { - await access(binaryPath) - return binaryPath - } - catch { - return undefined - } -} - -async function validateConfiguredGodotEnginePath(executable: string) { - let executableStats - try { - executableStats = await stat(executable) - } - catch (error) { - throw new Error( - 'GODOT4 points to a missing Godot executable.\n' - + `Configured path: ${executable}\n` - + 'Set GODOT4 to the absolute path of your Godot 4.x .NET/Mono executable before starting dev mode.\n' - + `Original error: ${errorMessageFrom(error) ?? 'unknown error'}`, - ) - } - - if (!executableStats.isFile()) { - throw new Error( - 'GODOT4 must point to the Godot executable file, not a directory or app bundle.\n' - + `Configured path: ${executable}\n` - + 'Examples:\n' - + ' Windows: C:\\Path\\To\\Godot_v4.x-stable_mono_win64.exe\n' - + ' macOS: /Applications/Godot_mono.app/Contents/MacOS/Godot\n' - + ' Linux: /path/to/Godot_v4.x-stable_mono_linux.x86_64', - ) - } -} - -async function resolveGodotBinary(): Promise { - if (app.isPackaged) { - const exported = await resolveExportedGodotBinary() - if (exported) { - return { executable: exported, mode: 'exported' } - } - - throw new Error( - 'Godot stage exported binary not found. ' - + `Expected at: ${join(process.resourcesPath, 'godot-stage')}`, - ) - } - - const envPath = process.env.GODOT4?.trim() - if (!envPath) { - throw new Error( - 'GODOT4 is required to start Godot Stage in development mode.\n' - + 'Set GODOT4 to the absolute path of your Godot 4.x .NET/Mono executable, then restart the Electron dev app.\n' - + 'Examples:\n' - + ' PowerShell: $env:GODOT4 = "C:\\Path\\To\\Godot_v4.x-stable_mono_win64.exe"\n' - + ' Bash: export GODOT4="/path/to/godot"', - ) - } - - await validateConfiguredGodotEnginePath(envPath) - return { executable: envPath, mode: 'engine' } -} +type GodotStageSocketEnvelope = InferOutput /** * Creates the shared Godot stage manager. @@ -397,8 +163,8 @@ export function createGodotStageManager(): GodotStageManager { let currentProcessExit = createDeferred() let currentReady: Deferred | undefined let currentSocketRuntime: GodotStageSocketRuntime | undefined - let currentSocketPeer: GodotStagePeer | undefined - let currentViewSnapshot: StageViewSnapshotPayload | null = null + let currentSocketPeer: undefined | WebSocketPeer + let currentViewSnapshot: null | StageViewSnapshotPayload = null let expectedProcessExit = false function setStatus(next: Partial & Pick) { @@ -492,35 +258,12 @@ export function createGodotStageManager(): GodotStageManager { function handleSocketMessage(message: GodotStageSocketEnvelope) { switch (message.type) { - case 'stage.ready': { - setStatus({ - state: 'running', - pid: currentProcess?.pid ?? null, - lastError: undefined, - }) - currentReady?.resolve() - currentReady = undefined - - return - } - case 'stage.fatal': { - const error = getPayloadMessage(message.payload) ?? 'Godot stage reported a fatal startup error.' - setStatus({ - state: 'error', - pid: currentProcess?.pid ?? null, - lastError: error, - }) - currentReady?.reject(new Error(error)) - currentReady = undefined - currentProcess?.kill() - return - } case 'scene.applied': { if (currentStatus.state === 'running' && currentStatus.lastError) { setStatus({ - state: 'running', - pid: currentProcess?.pid ?? null, lastError: undefined, + pid: currentProcess?.pid ?? null, + state: 'running', }) } return @@ -528,24 +271,47 @@ export function createGodotStageManager(): GodotStageManager { case 'scene.error': { const error = getPayloadMessage(message.payload) ?? 'Godot stage failed to apply scene input.' setStatus({ - state: currentStatus.state, - pid: currentProcess?.pid ?? null, lastError: error, + pid: currentProcess?.pid ?? null, + state: currentStatus.state, }) return } - case 'stage.view.snapshot': { + case 'stage.fatal': { + const error = getPayloadMessage(message.payload) ?? 'Godot stage reported a fatal startup error.' + setStatus({ + lastError: error, + pid: currentProcess?.pid ?? null, + state: 'error', + }) + currentReady?.reject(new Error(error)) + currentReady = undefined + currentProcess?.kill() + return + } + case 'stage.ready': { + setStatus({ + lastError: undefined, + pid: currentProcess?.pid ?? null, + state: 'running', + }) + currentReady?.resolve() + currentReady = undefined + + return + } + case 'stage.view.error': { try { - broadcastViewSnapshot(parseStageViewSnapshotPayload(message.payload)) + broadcastViewError(parseStageViewErrorPayload(message.payload)) } catch (error) { broadcastInvalidViewPayloadError(error) } return } - case 'stage.view.error': { + case 'stage.view.snapshot': { try { - broadcastViewError(parseStageViewErrorPayload(message.payload)) + broadcastViewSnapshot(parseStageViewSnapshotPayload(message.payload)) } catch (error) { broadcastInvalidViewPayloadError(error) @@ -569,6 +335,19 @@ export function createGodotStageManager(): GodotStageManager { const appServer = new H3() appServer.get('/ws', defineWebSocketHandler({ + close: (peer) => { + if (currentSocketPeer?.id === peer.id) { + currentSocketPeer = undefined + } + }, + message: (_peer, message) => { + try { + handleSocketMessage(parseSocketMessage(message)) + } + catch (error) { + log.withError(error).warn('failed to parse Godot websocket message') + } + }, open: (peer) => { const requestUrl = peer.request.url ?? '' const url = new URL(requestUrl, `ws://${host}:${port}`) @@ -580,33 +359,20 @@ export function createGodotStageManager(): GodotStageManager { currentSocketPeer = peer log.withFields({ peer: peer.id }).debug('Godot websocket connected') }, - message: (_peer, message) => { - try { - handleSocketMessage(parseSocketMessage(message)) - } - catch (error) { - log.withError(error).warn('failed to parse Godot websocket message') - } - }, - close: (peer) => { - if (currentSocketPeer?.id === peer.id) { - currentSocketPeer = undefined - } - }, })) const server = serve(appServer, { - // @ts-expect-error - h3 does not extend the crossws response type. - plugins: [ws({ resolve: async req => (await appServer.fetch(req)).crossws })], - port, - hostname: host, - manual: true, - reusePort: false, - silent: true, gracefulShutdown: { forceTimeout: 0.25, gracefulTimeout: 0.25, }, + hostname: host, + manual: true, + // @ts-expect-error - h3 does not extend the crossws response type. + plugins: [ws({ resolve: async req => (await appServer.fetch(req)).crossws })], + port, + reusePort: false, + silent: true, }) await server.serve() @@ -632,9 +398,9 @@ export function createGodotStageManager(): GodotStageManager { const message = errorMessageFrom(error) ?? 'Failed to spawn Godot stage process.' setStatus({ - state: 'error', - pid: processHandle.pid ?? null, lastError: message, + pid: processHandle.pid ?? null, + state: 'error', }) currentReady?.reject(error) currentReady = undefined @@ -659,16 +425,16 @@ export function createGodotStageManager(): GodotStageManager { if (expectedProcessExit) { setStatus({ - state: 'stopped', - pid: null, lastError: undefined, + pid: null, + state: 'stopped', }) } else { setStatus({ - state: 'error', - pid: null, lastError: exitMessage, + pid: null, + state: 'error', }) } @@ -679,10 +445,34 @@ export function createGodotStageManager(): GodotStageManager { } return { - subscribe(callback) { - const unsubscribe = statusListeners.subscribe(callback) - callback(currentStatus) - return unsubscribe + async applySceneInput(payload) { + await lifecycleMutex.runExclusive(async () => { + if (currentStatus.state !== 'running') { + throw new Error('Godot stage is not running.') + } + + const sceneInputPayload = parseSceneInputPayload(payload) + + const fileName = normalizeFileName(sceneInputPayload.fileName) + const modelDirectory = join(resolveGodotStageStorageRoot(), 'models', sceneInputPayload.modelId) + const materializedPath = join(modelDirectory, fileName) + + await mkdir(modelDirectory, { recursive: true }) + await writeFile(materializedPath, sceneInputPayload.data) + + sendSceneInputToGodot({ + format: sceneInputPayload.format, + modelId: sceneInputPayload.modelId, + name: sceneInputPayload.name, + path: materializedPath, + }) + }) + }, + async applyViewPatch(payload) { + return await lifecycleMutex.runExclusive(async () => { + const patch = parseStageViewPatchPayload(payload) + return sendViewRequest('host.view.patch', { patch }) + }) }, getStatus() { return currentStatus @@ -690,11 +480,10 @@ export function createGodotStageManager(): GodotStageManager { getViewSnapshot() { return currentViewSnapshot }, - subscribeViewSnapshot(callback) { - return viewSnapshotListeners.subscribe(callback) - }, - subscribeViewError(callback) { - return viewErrorListeners.subscribe(callback) + async requestViewSnapshot() { + return await lifecycleMutex.runExclusive(async () => { + return sendViewRequest('host.view.request_snapshot') + }) }, async start() { return await lifecycleMutex.runExclusive(async () => { @@ -732,9 +521,9 @@ export function createGodotStageManager(): GodotStageManager { currentReady = readyDeferred expectedProcessExit = false setStatus({ - state: 'starting', - pid: null, lastError: undefined, + pid: null, + state: 'starting', }) let spawnArgs: string[] @@ -778,9 +567,9 @@ export function createGodotStageManager(): GodotStageManager { attachProcessListeners(processHandle) setStatus({ - state: 'starting', - pid: processHandle.pid ?? null, lastError: undefined, + pid: processHandle.pid ?? null, + state: 'starting', }) try { @@ -800,9 +589,9 @@ export function createGodotStageManager(): GodotStageManager { } await stopSocketRuntime() setStatus({ - state: 'error', - pid: null, lastError: errorMessageFrom(error) ?? 'Failed to start Godot stage.', + pid: null, + state: 'error', }) throw error } @@ -813,9 +602,9 @@ export function createGodotStageManager(): GodotStageManager { if (!currentProcess) { await stopSocketRuntime() setStatus({ - state: 'stopped', - pid: null, lastError: undefined, + pid: null, + state: 'stopped', }) return currentStatus } @@ -825,9 +614,9 @@ export function createGodotStageManager(): GodotStageManager { expectedProcessExit = true setStatus({ - state: 'stopping', - pid: activeProcess.pid ?? null, lastError: undefined, + pid: activeProcess.pid ?? null, + state: 'stopping', }) try { @@ -842,9 +631,9 @@ export function createGodotStageManager(): GodotStageManager { } catch (error) { setStatus({ - state: 'error', - pid: activeProcess.pid ?? null, lastError: errorMessageFrom(error) ?? 'Failed to stop Godot stage.', + pid: activeProcess.pid ?? null, + state: 'error', }) throw error } @@ -853,73 +642,28 @@ export function createGodotStageManager(): GodotStageManager { } setStatus({ - state: 'stopped', - pid: null, lastError: undefined, + pid: null, + state: 'stopped', }) return currentStatus }) }, - async applySceneInput(payload) { - await lifecycleMutex.runExclusive(async () => { - if (currentStatus.state !== 'running') { - throw new Error('Godot stage is not running.') - } - - const sceneInputPayload = parseSceneInputPayload(payload) - - const fileName = normalizeFileName(sceneInputPayload.fileName) - const modelDirectory = join(resolveGodotStageStorageRoot(), 'models', sceneInputPayload.modelId) - const materializedPath = join(modelDirectory, fileName) - - await mkdir(modelDirectory, { recursive: true }) - await writeFile(materializedPath, sceneInputPayload.data) - - sendSceneInputToGodot({ - modelId: sceneInputPayload.modelId, - format: sceneInputPayload.format, - name: sceneInputPayload.name, - path: materializedPath, - }) - }) + subscribe(callback) { + const unsubscribe = statusListeners.subscribe(callback) + callback(currentStatus) + return unsubscribe }, - async applyViewPatch(payload) { - return await lifecycleMutex.runExclusive(async () => { - const patch = parseStageViewPatchPayload(payload) - return sendViewRequest('host.view.patch', { patch }) - }) + subscribeViewError(callback) { + return viewErrorListeners.subscribe(callback) }, - async requestViewSnapshot() { - return await lifecycleMutex.runExclusive(async () => { - return sendViewRequest('host.view.request_snapshot') - }) + subscribeViewSnapshot(callback) { + return viewSnapshotListeners.subscribe(callback) }, } } -/** - * Creates and wires the shared Godot stage manager into app lifecycle hooks. - * - * Use when: - * - Electron main needs one app-wide Godot sidecar lifecycle owner - * - * Expects: - * - App shutdown to call the registered `onAppBeforeQuit` hook - * - * Returns: - * - The ready-to-use Godot stage manager - */ -export function setupGodotStageManager() { - const manager = createGodotStageManager() - - onAppBeforeQuit(async () => { - await manager.stop() - }) - - return manager -} - /** * Registers Godot stage invoke handlers for one Electron window context. * @@ -972,3 +716,257 @@ export function createGodotStageService(params: { params.window.on('closed', cleanup) return cleanup } + +/** + * Creates and wires the shared Godot stage manager into app lifecycle hooks. + * + * Use when: + * - Electron main needs one app-wide Godot sidecar lifecycle owner + * + * Expects: + * - App shutdown to call the registered `onAppBeforeQuit` hook + * + * Returns: + * - The ready-to-use Godot stage manager + */ +export function setupGodotStageManager() { + const manager = createGodotStageManager() + + onAppBeforeQuit(async () => { + await manager.stop() + }) + + return manager +} + +function createDeferred(): Deferred { + let resolve!: Deferred['resolve'] + let reject!: Deferred['reject'] + + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + + return { + promise, + reject, + resolve, + } +} + +function createInitialStatus(): ElectronGodotStageStatus { + return { + pid: null, + state: 'stopped', + updatedAt: Date.now(), + } +} + +function createListenerChannel(onListenerError: (error: unknown) => void): ListenerChannel { + const listeners = new Set<(payload: T) => void>() + + return { + publish(payload) { + for (const listener of listeners) { + try { + listener(payload) + } + catch (error) { + onListenerError(error) + } + } + }, + subscribe(callback) { + listeners.add(callback) + + return () => { + listeners.delete(callback) + } + }, + } +} + +function createSocketEnvelope(type: string, payload?: unknown) { + return JSON.stringify({ payload, type }) +} + +function getPayloadMessage(payload: unknown) { + const result = safeParse(godotStagePayloadMessageSchema, payload) + if (!result.success) { + return undefined + } + + return result.output.message +} + +function normalizeFileName(fileName: string) { + const normalized = basename(fileName.trim()) + return normalized || 'model.bin' +} + +function parseSceneInputPayload(payload: unknown): ElectronGodotStageSceneInputPayload { + const result = safeParse(godotStageSceneInputPayloadSchema, payload) + if (!result.success) + throw new Error('Invalid Godot stage scene input payload.') + + return result.output +} + +function parseSocketMessage(message: WebSocketMessage): GodotStageSocketEnvelope { + const parsed = safeDestr(message.text(), { strict: true }) + const result = safeParse(godotStageSocketEnvelopeSchema, parsed) + if (!result.success) + throw new Error('Invalid Godot stage WebSocket envelope.') + + return result.output +} + +function pipeProcessLog(stream: Readable, write: (message: string) => void) { + stream.on('data', (data) => { + const message = data.toString('utf-8').trim() + if (message) { + write(message) + } + }) +} + +// Packaged builds ship a pre-exported sidecar under Electron resources. +async function resolveExportedGodotBinary(): Promise { + const platform = process.platform + let binaryName: string + + if (platform === 'win32') { + binaryName = 'godot-stage.exe' + } + else if (platform === 'darwin') { + binaryName = join('godot-stage.app', 'Contents', 'MacOS', 'godot-stage') + } + else { + binaryName = 'godot-stage' + } + + const binaryPath = join(process.resourcesPath, 'godot-stage', binaryName) + + try { + await access(binaryPath) + return binaryPath + } + catch { + return undefined + } +} + +async function resolveGodotBinary(): Promise { + if (app.isPackaged) { + const exported = await resolveExportedGodotBinary() + if (exported) { + return { executable: exported, mode: 'exported' } + } + + throw new Error( + 'Godot stage exported binary not found. ' + + `Expected at: ${join(process.resourcesPath, 'godot-stage')}`, + ) + } + + const envPath = process.env.GODOT4?.trim() + if (!envPath) { + throw new Error( + 'GODOT4 is required to start Godot Stage in development mode.\n' + + 'Set GODOT4 to the absolute path of your Godot 4.x .NET/Mono executable, then restart the Electron dev app.\n' + + 'Examples:\n' + + ' PowerShell: $env:GODOT4 = "C:\\Path\\To\\Godot_v4.x-stable_mono_win64.exe"\n' + + ' Bash: export GODOT4="/path/to/godot"', + ) + } + + await validateConfiguredGodotEnginePath(envPath) + return { executable: envPath, mode: 'engine' } +} + +// Dev builds run the Godot engine against the workspace project.godot. +async function resolveGodotProjectPath() { + let currentDirectory = getElectronMainDirname() + + while (true) { + const projectPath = resolve(currentDirectory, 'engines', 'stage-tamagotchi-godot') + + try { + await access(join(projectPath, 'project.godot')) + return projectPath + } + catch {} + + const parentDirectory = dirname(currentDirectory) + if (parentDirectory === currentDirectory) { + break + } + + currentDirectory = parentDirectory + } + + throw new Error(`Unable to locate engines/stage-tamagotchi-godot/project.godot from ${getElectronMainDirname()}.`) +} + +function resolveGodotStageDebugLaunchOptions() { + const remoteDebugEnabled = ['1', 'on', 'true', 'yes'].includes( + (process.env.GODOT_STAGE_REMOTE_DEBUG ?? '').trim().toLowerCase(), + ) + const remoteDebugUri = remoteDebugEnabled + ? process.env.GODOT_STAGE_REMOTE_DEBUG_URI?.trim() || DEFAULT_GODOT_REMOTE_DEBUG_URI + : undefined + + // Godot engine/debugger flags must stay before `--`; StageRoot arguments stay + // after it and are assembled next to the WebSocket URL. + return { + engineArgs: remoteDebugUri ? ['--remote-debug', remoteDebugUri] : [], + remoteDebugUri, + } +} + +function resolveGodotStageProcessEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + AIRI_GODOT_STAGE_DEV_MODE: app.isPackaged + ? process.env.AIRI_GODOT_STAGE_DEV_MODE ?? '0' + : process.env.AIRI_GODOT_STAGE_DEV_MODE ?? '1', + } +} + +function resolveGodotStageStorageRoot() { + return join(app.getPath('userData'), 'godot-stage') +} + +async function validateConfiguredGodotEnginePath(executable: string) { + let executableStats + try { + executableStats = await stat(executable) + } + catch (error) { + throw new Error( + 'GODOT4 points to a missing Godot executable.\n' + + `Configured path: ${executable}\n` + + 'Set GODOT4 to the absolute path of your Godot 4.x .NET/Mono executable before starting dev mode.\n' + + `Original error: ${errorMessageFrom(error) ?? 'unknown error'}`, + ) + } + + if (!executableStats.isFile()) { + throw new Error( + 'GODOT4 must point to the Godot executable file, not a directory or app bundle.\n' + + `Configured path: ${executable}\n` + + 'Examples:\n' + + ' Windows: C:\\Path\\To\\Godot_v4.x-stable_mono_win64.exe\n' + + ' macOS: /Applications/Godot_mono.app/Contents/MacOS/Godot\n' + + ' Linux: /path/to/Godot_v4.x-stable_mono_linux.x86_64', + ) + } +} + +function waitForProcessExit(exitPromise: Promise, timeoutMs: number) { + return Promise.race([ + exitPromise.then(() => true, () => false), + new Promise(resolve => setTimeout(resolve, timeoutMs, false)), + ]) +} diff --git a/apps/ui-server-auth/src/modules/analytics-adapters/posthog.ts b/apps/ui-server-auth/src/modules/analytics-adapters/posthog.ts index 2f7bf6dc9..008d185a4 100644 --- a/apps/ui-server-auth/src/modules/analytics-adapters/posthog.ts +++ b/apps/ui-server-auth/src/modules/analytics-adapters/posthog.ts @@ -5,7 +5,7 @@ import posthog from 'posthog-js' import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY, -} from '../../../../../posthog.config' +} from '@proj-airi/stage-shared/analytics/posthog' /** Creates the auth analytics adapter and initializes its provider SDK. */ export function createPosthogAdapter(): AnalyticsAdapter { diff --git a/docs/.vitepress/modules/posthog.ts b/docs/.vitepress/modules/posthog.ts index 7f06faf38..e9185c1f6 100644 --- a/docs/.vitepress/modules/posthog.ts +++ b/docs/.vitepress/modules/posthog.ts @@ -1,6 +1,6 @@ import posthog from 'posthog-js' -import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY } from '../../../posthog.config' +import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY } from '@proj-airi/stage-shared/analytics/posthog' if (!import.meta.env.DEV) { posthog.init(POSTHOG_PROJECT_KEY, { diff --git a/docs/package.json b/docs/package.json index ab7506934..894582a06 100644 --- a/docs/package.json +++ b/docs/package.json @@ -20,6 +20,7 @@ "@moeru/std": "catalog:", "@proj-airi/chromatic": "catalog:", "@proj-airi/i18n": "workspace:^", + "@proj-airi/stage-shared": "workspace:^", "@vueuse/core": "catalog:", "colorjs.io": "catalog:", "date-fns": "catalog:", diff --git a/docs/uno.config.ts b/docs/uno.config.ts index fde3fe2f7..868dd7ec6 100644 --- a/docs/uno.config.ts +++ b/docs/uno.config.ts @@ -1,280 +1,16 @@ +import type { Preset } from 'unocss' + import { presetChromatic } from '@proj-airi/unocss-preset-chromatic' import { blackA, cyan, grass, green, indigo, mauve, purple, red, slate, teal, violet } from '@radix-ui/colors' import { defineConfig, presetAttributify, presetIcons, presetTypography, presetWebFonts, presetWind3, transformerDirectives, transformerVariantGroup } from 'unocss' -export default defineConfig({ - presets: [ - presetAttributify(), - presetTypography({ - cssExtend: { - 'h1': { - 'margin-bottom': '1rem', - }, - 'a': { - 'color': '#223f5dff', - 'text-decoration': 'underline', - 'text-decoration-style': 'dotted', - 'text-decoration-color': '#9fa4b1ff', - 'transition': 'color 0.2s ease-in-out', - }, - 'a:hover': { - '--primary': '207 62% 59%', - 'color': 'hsl(var(--primary))', - }, - '.dark a': { - 'color': '#9ca0a4', - 'text-decoration-color': '#4b5056', - }, - 'code::before': { - content: 'normal', - }, - 'code::after': { - content: 'normal', - }, - 'pre': { - 'margin-top': '0.5rem', - 'margin-bottom': '0', - }, - 'p': { - 'margin-top': '0.5rem', - 'margin-bottom': '0.5rem', - }, - 'details': { - 'margin-top': '0.5rem', - 'margin-bottom': '0.5rem', - 'padding': '0.5rem 1rem', - 'background-color': '#a6ceef1a', - }, - '.dark details': { - 'background-color': '#191c1e', - }, - 'ol': { - 'margin-top': '0.25rem', - 'margin-bottom': '0.25rem', - 'padding-inline-start': '1.25rem', - }, - 'ol li': { - 'padding-inline-start': '0.25rem', - }, - 'ul': { - 'margin-top': '0.25rem', - 'margin-bottom': '0.25rem', - 'padding-inline-start': '1.25rem', - }, - 'ul li': { - 'padding-inline-start': '0.25rem', - }, - 'li': { - 'margin-top': '0', - 'margin-bottom': '0', - }, - 'li p': { - 'margin-top': '0.25rem', - 'margin-bottom': '0.25rem', - }, - 'li blockquote': { - 'margin-top': '1rem', - 'margin-bottom': '1rem', - }, - }, - }), - presetWind3(), - presetWebFonts({ - fonts: { - 'sans': { - name: 'DM Sans Variable', - provider: 'none', - }, - 'serif': { - name: 'DM Serif Display', - provider: 'none', - }, - 'mono': { - name: 'DM Mono', - provider: 'none', - }, - 'sans-rounded': { - name: 'Comfortaa Variable', - provider: 'none', - }, - 'mystery-quest': { - name: 'Mystery Quest', - }, - 'grandstander': { - name: 'Grandstander', - }, - }, - }), - presetIcons(), - presetChromatic({ - baseHue: 220.44, - colors: { - primary: 0, - complementary: 180, - }, - }), - ], +export default defineConfig({ content: { filesystem: [ '.vitepress/**/*.{js,ts,vue}', 'content/**/*.md', ], }, - safelist: [ - '-ml-8', - 'top-0', - 'hidden', - 'border-0', - 'opacity-0', - 'group-hover:opacity-100', - 'focus:opacity-100', - 'lg:flex', - 'transition-opacity', - 'duration-200', - 'ease-in-out', - '[&_span]:focus:opacity-100', - '[&_span_>_span]:focus:outline', - ], - theme: { - fontFamily: { - 'sans': `ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`, - 'sans-rounded': `"DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`, - 'sans-serif-halloween-secondary': `"Grandstander", "DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`, - 'sans-serif-halloween': `"Mystery Quest", "DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`, - }, - colors: { - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - code: 'hsl(var(--code))', - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))', - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))', - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))', - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))', - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))', - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))', - }, - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))', - }, - ...blackA, - ...mauve, - ...violet, - ...green, - ...red, - ...grass, - ...teal, - ...cyan, - ...indigo, - ...purple, - ...slate, - }, - /** - * https://github.com/unocss/unocss/blob/1031312057a3bea1082b7d938eb2ad640f57613a/packages-presets/preset-wind4/src/theme/animate.ts - * https://unocss.dev/presets/wind4#transformdirectives - */ - animation: { - keyframes: { - overlayShow: '{from{opacity:0}to{opacity:1}}', - contentShow: '{from{opacity:0;transform:translate(-50%, -48%) scale(0.96)}to{opacity:1;transform:translate(-50%, -50%) scale(1)}}', - slideDownAndFade: '{from{opacity:0;transform:translateY(-2px)}to{opacity:1;transform:translateY(0)}}', - slideLeftAndFade: '{from{opacity:0;transform:translateX(2px)}to{opacity:1;transform:translateX(0)}}', - slideUpAndFade: '{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:translateY(0)}}', - slideRightAndFade: '{from{opacity:0;transform:translateX(-2px)}to{opacity:1;transform:translateX(0)}}', - slideDown: '{from{height:0}to{height:var(--reka-collapsible-content-height)}}', - slideUp: '{from{height:var(--reka-collapsible-content-height)}to{height:0}}', - enterFromRight: '{from{opacity:0;transform:translateX(200px)}to{opacity:1;transform:translateX(0)}}', - enterFromLeft: '{from{opacity:0;transform:translateX(-200px)}to{opacity:1;transform:translateX(0)}}', - exitToRight: '{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(200px)}}', - exitToLeft: '{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(-200px)}}', - scaleIn: '{from{opacity:0;transform:rotateX(-10deg) scale(0.9)}to{opacity:1;transform:rotateX(0deg) scale(1)}}', - scaleOut: '{from{opacity:1;transform:rotateX(0deg) scale(1)}to{opacity:0;transform:rotateX(-10deg) scale(0.95)}}', - fadeIn: '{from{opacity:0}to{opacity:1}}', - fadeOut: '{from{opacity:1}to{opacity:0}}', - hide: '{from{opacity:1}to{opacity:0}}', - slideIn: '{from{transform:translateX(calc(100% + var(--viewport-padding)))}to{transform:translateX(0)}}', - swipeOut: '{from{transform:translateX(var(--reka-toast-swipe-end-x))}to{transform:translateX(calc(100% + var(--viewport-padding)))}}', - text: '{0%,100%{background-size:200% 200%;background-position:left center}50%{background-size:200% 200%;background-position:right center}}', - progress: '{0%{background-position:0 0}100%{background-position:30px 30px}}', - }, - durations: { - overlayShow: '150ms', - contentShow: '150ms', - slideDownAndFade: '400ms', - slideLeftAndFade: '400ms', - slideUpAndFade: '400ms', - slideRightAndFade: '400ms', - slideDown: '300ms', - slideUp: '300ms', - scaleIn: '200ms', - scaleOut: '200ms', - fadeIn: '200ms', - fadeOut: '200ms', - enterFromLeft: '250ms', - enterFromRight: '250ms', - exitToLeft: '250ms', - exitToRight: '250ms', - hide: '100ms', - slideIn: '150ms', - swipeOut: '100ms', - text: '5s', - progress: '1s', - }, - timingFns: { - overlayShow: 'cubic-bezier(0.16, 1, 0.3, 1)', - contentShow: 'cubic-bezier(0.16, 1, 0.3, 1)', - slideDownAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)', - slideLeftAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)', - slideUpAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)', - slideRightAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)', - slideDown: 'cubic-bezier(0.87, 0, 0.13, 1)', - slideUp: 'cubic-bezier(0.87, 0, 0.13, 1)', - scaleIn: 'ease', - scaleOut: 'ease', - fadeIn: 'ease', - fadeOut: 'ease', - enterFromLeft: 'ease', - enterFromRight: 'ease', - exitToLeft: 'ease', - exitToRight: 'ease', - hide: 'ease-in', - slideIn: 'cubic-bezier(0.16, 1, 0.3, 1)', - swipeOut: 'ease-out', - text: 'ease', - progress: 'linear', - }, - counts: { - text: 'infinite', - progress: 'infinite', - }, - }, - }, - shortcuts: { - 'bg-gradient-radial': 'bg-gradient-radial-[var(--tw-gradient-stops)]', - }, - rules: [ - [/^bg-gradient-radial-\[(.+)\]$/, ([, d]) => ({ 'background-image': `radial-gradient(${d})` })], - ], preflights: [ { getCSS: () => { @@ -301,6 +37,277 @@ code,kbd,samp,pre { }, }, ], + presets: [ + presetAttributify(), + presetTypography({ + cssExtend: { + '.dark a': { + 'color': '#9ca0a4', + 'text-decoration-color': '#4b5056', + }, + '.dark details': { + 'background-color': '#191c1e', + }, + 'a': { + 'color': '#223f5dff', + 'text-decoration': 'underline', + 'text-decoration-color': '#9fa4b1ff', + 'text-decoration-style': 'dotted', + 'transition': 'color 0.2s ease-in-out', + }, + 'a:hover': { + '--primary': '207 62% 59%', + 'color': 'hsl(var(--primary))', + }, + 'code::after': { + content: 'normal', + }, + 'code::before': { + content: 'normal', + }, + 'details': { + 'background-color': '#a6ceef1a', + 'margin-bottom': '0.5rem', + 'margin-top': '0.5rem', + 'padding': '0.5rem 1rem', + }, + 'h1': { + 'margin-bottom': '1rem', + }, + 'li': { + 'margin-bottom': '0', + 'margin-top': '0', + }, + 'li blockquote': { + 'margin-bottom': '1rem', + 'margin-top': '1rem', + }, + 'li p': { + 'margin-bottom': '0.25rem', + 'margin-top': '0.25rem', + }, + 'ol': { + 'margin-bottom': '0.25rem', + 'margin-top': '0.25rem', + 'padding-inline-start': '1.25rem', + }, + 'ol li': { + 'padding-inline-start': '0.25rem', + }, + 'p': { + 'margin-bottom': '0.5rem', + 'margin-top': '0.5rem', + }, + 'pre': { + 'margin-bottom': '0', + 'margin-top': '0.5rem', + }, + 'ul': { + 'margin-bottom': '0.25rem', + 'margin-top': '0.25rem', + 'padding-inline-start': '1.25rem', + }, + 'ul li': { + 'padding-inline-start': '0.25rem', + }, + }, + }), + presetWind3(), + presetWebFonts({ + fonts: { + 'grandstander': { + name: 'Grandstander', + }, + 'mono': { + name: 'DM Mono', + provider: 'none', + }, + 'mystery-quest': { + name: 'Mystery Quest', + }, + 'sans': { + name: 'DM Sans Variable', + provider: 'none', + }, + 'sans-rounded': { + name: 'Comfortaa Variable', + provider: 'none', + }, + 'serif': { + name: 'DM Serif Display', + provider: 'none', + }, + }, + }), + presetIcons(), + // NOTICE: + // This cast bridges the preset's bundled UnoCSS declarations to the workspace declarations. + // Version 1.1.4 embeds @unocss/core types in its generated shared declaration file. + // Source: node_modules/@proj-airi/unocss-preset-chromatic/dist/shared-3Rd5ey9i.d.mts. + // Remove this when the package emits imports for UnoCSS types instead of bundling them. + presetChromatic({ + baseHue: 220.44, + colors: { + complementary: 180, + primary: 0, + }, + }) as unknown as Preset, + ], + rules: [ + [/^bg-gradient-radial-\[(.+)\]$/, ([, d]) => ({ 'background-image': `radial-gradient(${d})` })], + ], + safelist: [ + '-ml-8', + 'top-0', + 'hidden', + 'border-0', + 'opacity-0', + 'group-hover:opacity-100', + 'focus:opacity-100', + 'lg:flex', + 'transition-opacity', + 'duration-200', + 'ease-in-out', + '[&_span]:focus:opacity-100', + '[&_span_>_span]:focus:outline', + ], + shortcuts: { + 'bg-gradient-radial': 'bg-gradient-radial-[var(--tw-gradient-stops)]', + }, + theme: { + /** + * https://github.com/unocss/unocss/blob/1031312057a3bea1082b7d938eb2ad640f57613a/packages-presets/preset-wind4/src/theme/animate.ts + * https://unocss.dev/presets/wind4#transformdirectives + */ + animation: { + counts: { + progress: 'infinite', + text: 'infinite', + }, + durations: { + contentShow: '150ms', + enterFromLeft: '250ms', + enterFromRight: '250ms', + exitToLeft: '250ms', + exitToRight: '250ms', + fadeIn: '200ms', + fadeOut: '200ms', + hide: '100ms', + overlayShow: '150ms', + progress: '1s', + scaleIn: '200ms', + scaleOut: '200ms', + slideDown: '300ms', + slideDownAndFade: '400ms', + slideIn: '150ms', + slideLeftAndFade: '400ms', + slideRightAndFade: '400ms', + slideUp: '300ms', + slideUpAndFade: '400ms', + swipeOut: '100ms', + text: '5s', + }, + keyframes: { + contentShow: '{from{opacity:0;transform:translate(-50%, -48%) scale(0.96)}to{opacity:1;transform:translate(-50%, -50%) scale(1)}}', + enterFromLeft: '{from{opacity:0;transform:translateX(-200px)}to{opacity:1;transform:translateX(0)}}', + enterFromRight: '{from{opacity:0;transform:translateX(200px)}to{opacity:1;transform:translateX(0)}}', + exitToLeft: '{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(-200px)}}', + exitToRight: '{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(200px)}}', + fadeIn: '{from{opacity:0}to{opacity:1}}', + fadeOut: '{from{opacity:1}to{opacity:0}}', + hide: '{from{opacity:1}to{opacity:0}}', + overlayShow: '{from{opacity:0}to{opacity:1}}', + progress: '{0%{background-position:0 0}100%{background-position:30px 30px}}', + scaleIn: '{from{opacity:0;transform:rotateX(-10deg) scale(0.9)}to{opacity:1;transform:rotateX(0deg) scale(1)}}', + scaleOut: '{from{opacity:1;transform:rotateX(0deg) scale(1)}to{opacity:0;transform:rotateX(-10deg) scale(0.95)}}', + slideDown: '{from{height:0}to{height:var(--reka-collapsible-content-height)}}', + slideDownAndFade: '{from{opacity:0;transform:translateY(-2px)}to{opacity:1;transform:translateY(0)}}', + slideIn: '{from{transform:translateX(calc(100% + var(--viewport-padding)))}to{transform:translateX(0)}}', + slideLeftAndFade: '{from{opacity:0;transform:translateX(2px)}to{opacity:1;transform:translateX(0)}}', + slideRightAndFade: '{from{opacity:0;transform:translateX(-2px)}to{opacity:1;transform:translateX(0)}}', + slideUp: '{from{height:var(--reka-collapsible-content-height)}to{height:0}}', + slideUpAndFade: '{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:translateY(0)}}', + swipeOut: '{from{transform:translateX(var(--reka-toast-swipe-end-x))}to{transform:translateX(calc(100% + var(--viewport-padding)))}}', + text: '{0%,100%{background-size:200% 200%;background-position:left center}50%{background-size:200% 200%;background-position:right center}}', + }, + timingFns: { + contentShow: 'cubic-bezier(0.16, 1, 0.3, 1)', + enterFromLeft: 'ease', + enterFromRight: 'ease', + exitToLeft: 'ease', + exitToRight: 'ease', + fadeIn: 'ease', + fadeOut: 'ease', + hide: 'ease-in', + overlayShow: 'cubic-bezier(0.16, 1, 0.3, 1)', + progress: 'linear', + scaleIn: 'ease', + scaleOut: 'ease', + slideDown: 'cubic-bezier(0.87, 0, 0.13, 1)', + slideDownAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)', + slideIn: 'cubic-bezier(0.16, 1, 0.3, 1)', + slideLeftAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)', + slideRightAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)', + slideUp: 'cubic-bezier(0.87, 0, 0.13, 1)', + slideUpAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)', + swipeOut: 'ease-out', + text: 'ease', + }, + }, + colors: { + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + background: 'hsl(var(--background))', + border: 'hsl(var(--border))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + code: 'hsl(var(--code))', + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + foreground: 'hsl(var(--foreground))', + input: 'hsl(var(--input))', + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + ring: 'hsl(var(--ring))', + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + ...blackA, + ...mauve, + ...violet, + ...green, + ...red, + ...grass, + ...teal, + ...cyan, + ...indigo, + ...purple, + ...slate, + }, + fontFamily: { + 'sans': `ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`, + 'sans-rounded': `"DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`, + 'sans-serif-halloween': `"Mystery Quest", "DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`, + 'sans-serif-halloween-secondary': `"Grandstander", "DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`, + }, + }, transformers: [ transformerDirectives(), transformerVariantGroup(), diff --git a/eslint.config.ts b/eslint.config.ts index 20c49731c..c2ab798c9 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -2,8 +2,8 @@ import { defineConfig } from '@moeru/eslint-config' export default defineConfig({ masknet: false, + perfectionist: true, preferArrow: false, - perfectionist: false, sonarjs: false, sortPackageJsonScripts: false, typescript: true, @@ -32,15 +32,12 @@ export default defineConfig({ ], }, { rules: { - 'pnpm/json-valid-catalog': 'off', - 'pnpm/json-enforce-catalog': 'off', - 'pnpm/yaml-enforce-settings': 'off', 'antfu/import-dedupe': 'error', // TODO: remove this 'depend/ban-dependencies': 'warn', 'import/order': 'off', + 'markdown/require-alt-text': 'off', 'no-console': ['error', { allow: ['warn', 'error', 'info'] }], - // Catches the manual `error instanceof Error ? error.message : ...` // pattern AGENTS.md forbids. The selector matches a ConditionalExpression // whose test is ` instanceof Error` and whose consequent is `.message`, @@ -50,29 +47,32 @@ export default defineConfig({ 'no-restricted-syntax': [ 'error', { - selector: 'ConditionalExpression[test.type=\'BinaryExpression\'][test.operator=\'instanceof\'][test.right.name=\'Error\'][consequent.type=\'MemberExpression\'][consequent.property.name=\'message\']', message: 'Avoid `error instanceof Error ? error.message : ...`. Use `errorMessageFrom(error)` from \'@moeru/std\' (or `errorMessageFromUnknown(error, fallback)` from \'@proj-airi/stage-shared\'). Pair with `?? \'fallback\'` when a default is needed.', + selector: 'ConditionalExpression[test.type=\'BinaryExpression\'][test.operator=\'instanceof\'][test.right.name=\'Error\'][consequent.type=\'MemberExpression\'][consequent.property.name=\'message\']', }, { + message: 'Omit TypeScript and JavaScript source extensions from relative imports, dynamic imports, and re-exports.', selector: [ 'ImportDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]', 'ExportNamedDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]', 'ExportAllDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]', 'ImportExpression[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]', ].join(', '), - message: 'Omit TypeScript and JavaScript source extensions from relative imports, dynamic imports, and re-exports.', }, 'TSEnumDeclaration[const=true]', 'TSExportAssignment', ], + 'pnpm/json-enforce-catalog': 'off', + 'pnpm/json-valid-catalog': 'off', + + 'pnpm/yaml-enforce-settings': 'off', // 'sonarjs/cognitive-complexity': 'off', // 'sonarjs/no-commented-code': 'off', // 'sonarjs/pseudo-random': 'off', 'style/padding-line-between-statements': 'error', 'vue/prefer-separate-static-class': 'off', 'yaml/plain-scalar': 'off', - 'markdown/require-alt-text': 'off', }, }, { files: ['server/apps/api/**/*.ts'], @@ -80,21 +80,21 @@ export default defineConfig({ 'no-restricted-syntax': [ 'error', { - selector: 'CallExpression[callee.type=\'MemberExpression\'][callee.object.name=\'vi\'][callee.property.name=/^(mock|doMock)$/][arguments.0.type=\'Literal\'][arguments.0.value=/^(\\.|@proj-airi\\/|~)/]', message: 'Do not mock internal project modules with vi.mock or vi.doMock. Inject the collaborator through the route, service, or factory boundary and pass a fake or spy in tests.', + selector: 'CallExpression[callee.type=\'MemberExpression\'][callee.object.name=\'vi\'][callee.property.name=/^(mock|doMock)$/][arguments.0.type=\'Literal\'][arguments.0.value=/^(\\.|@proj-airi\\/|~)/]', }, { - selector: 'CallExpression[callee.type=\'MemberExpression\'][callee.object.name=\'vi\'][callee.property.name=\'hoisted\']', message: 'Do not use vi.hoisted. If a test needs a collaborator spy, expose an explicit dependency injection point instead of hoisting module mocks.', + selector: 'CallExpression[callee.type=\'MemberExpression\'][callee.object.name=\'vi\'][callee.property.name=\'hoisted\']', }, { + message: 'Omit TypeScript and JavaScript source extensions from relative imports, dynamic imports, and re-exports.', selector: [ 'ImportDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]', 'ExportNamedDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]', 'ExportAllDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]', 'ImportExpression[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]', ].join(', '), - message: 'Omit TypeScript and JavaScript source extensions from relative imports, dynamic imports, and re-exports.', }, ], }, diff --git a/package.json b/package.json index ef13f9f81..501b5f9bc 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "type": "module", "version": "0.12.0-beta.2", "private": true, - "packageManager": "pnpm@10.33.0", + "packageManager": "pnpm@11.24.0", "description": "LLM powered virtual character", "author": { "name": "Moeru AI Project AIRI Team", @@ -72,6 +72,7 @@ "eslint": "catalog:", "eslint-plugin-format": "catalog:", "eslint-plugin-oxlint": "catalog:", + "eslint-plugin-perfectionist": "catalog:", "knip": "catalog:", "nano-staged": "catalog:", "oxc-minify": "catalog:", diff --git a/packages/audio/package.json b/packages/audio/package.json index e100f9c2e..0b8f775e3 100644 --- a/packages/audio/package.json +++ b/packages/audio/package.json @@ -58,6 +58,7 @@ "@moeru/std": "catalog:" }, "devDependencies": { - "@types/audioworklet": "catalog:" + "@types/audioworklet": "catalog:", + "vue": "catalog:" } } diff --git a/packages/better-ws/src/client/crossws/index.test.ts b/packages/better-ws/src/client/crossws/index.test.ts index 8e3258e84..2f0285837 100644 --- a/packages/better-ws/src/client/crossws/index.test.ts +++ b/packages/better-ws/src/client/crossws/index.test.ts @@ -1,38 +1,49 @@ +import type { CrossWsConstructor } from '.' + import { describe, expect, it, vi } from 'vitest' import { createCrossWsConnector } from '.' import { createClient } from '../..' +// ROOT CAUSE: +// +// The mock narrowed message data to two types, but CrossWS exposes unknown data. +// This made the mock constructor incompatible with the production connector contract. +// +// The mock now derives its message event from the public constructor contract. +type CrossWsMessageEvent = Parameters['onmessage']>>[0] + const { MockWebSocket } = vi.hoisted(() => { class MockWebSocket { - static readonly CONNECTING = 0 - static readonly OPEN = 1 - static readonly CLOSING = 2 static readonly CLOSED = 3 + static readonly CLOSING = 2 + static readonly CONNECTING = 0 static readonly instances: MockWebSocket[] = [] + static readonly OPEN = 1 + + onclose?: (event: { code?: number, reason?: string, wasClean?: boolean }) => void + onerror?: (event: unknown | { error?: Error }) => void + onmessage?: (event: CrossWsMessageEvent) => void + onopen?: () => void + ping = vi.fn() + pong = vi.fn() + + readyState = MockWebSocket.CONNECTING readonly sent: string[] = [] - readyState = MockWebSocket.CONNECTING - onclose?: (event: { code?: number, reason?: string, wasClean?: boolean }) => void - onerror?: (event: { error?: Error } | unknown) => void - onmessage?: (event: { data: string | ArrayBuffer }) => void - onopen?: () => void constructor(readonly url: string | URL, readonly protocols?: string | string[]) { MockWebSocket.instances.push(this) } - send(message: string) { - this.sent.push(message) - } - close() { this.readyState = MockWebSocket.CLOSED this.onclose?.({ code: 1000, reason: 'closed', wasClean: true }) } - ping = vi.fn() - pong = vi.fn() + send(message: string) { + this.sent.push(message) + } } return { MockWebSocket } @@ -84,8 +95,8 @@ describe('createCrossWsConnector', () => { wsConstructor: MockWebSocket, }), reconnect: { - retries: 0, onFailed, + retries: 0, }, }) diff --git a/packages/cap-vite/src/vite-plugin.ts b/packages/cap-vite/src/vite-plugin.ts index 5549d2678..41c59e18b 100644 --- a/packages/cap-vite/src/vite-plugin.ts +++ b/packages/cap-vite/src/vite-plugin.ts @@ -18,89 +18,6 @@ export interface CapVitePluginOptions { capArgs: string[] } -async function stopCapProcess(current: Result | undefined) { - if (!current) { - return - } - - current.kill('SIGINT') - - try { - await current - } - catch { - // tinyexec rejects when a process is stopped during a restart. - } -} - -function startCapProcess(cwd: string, capArgs: string[], url: URL) { - return x('cap', ['run', ...capArgs], { - throwOnError: false, - nodeOptions: { - cwd, - env: { - CAPACITOR_DEV_SERVER_URL: url.toString(), - }, - // NOTICE: cap-vite owns the terminal shortcuts, so cap run should not - // consume stdin while still mirroring its stdout/stderr to the console. - stdio: ['ignore', 'inherit', 'inherit'], - }, - }) -} - -function bindCapViteShortcuts( - onRestart: () => void, - onShutdown: () => Promise, -) { - if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') { - return () => {} - } - - process.stdin.resume() - process.stdin.setEncoding('utf8') - readline.emitKeypressEvents(process.stdin) - - const shouldRestoreRawMode = !process.stdin.isRaw - if (shouldRestoreRawMode) { - process.stdin.setRawMode(true) - } - - async function shutdownFromShortcut() { - try { - await onShutdown() - } - finally { - if (shouldRestoreRawMode) { - process.stdin.setRawMode(false) - } - - process.kill(process.pid, 'SIGINT') - } - } - - const onKeyPress = (input: string, key: readline.Key) => { - if (key.ctrl && key.name === 'c') { - void shutdownFromShortcut() - return - } - - const keyName = key.name?.toLowerCase() ?? input.toLowerCase() - if (!key.ctrl && !key.meta && keyName === 'r') { - onRestart() - } - } - - process.stdin.on('keypress', onKeyPress) - - return () => { - process.stdin.off('keypress', onKeyPress) - - if (shouldRestoreRawMode) { - process.stdin.setRawMode(false) - } - } -} - export function capVitePlugin(options: CapVitePluginOptions): Plugin { const platform = parseCapacitorPlatform(options.capArgs[0]) if (!platform) { @@ -110,7 +27,6 @@ export function capVitePlugin(options: CapVitePluginOptions): Plugin { return { apply: 'serve', - name: 'cap-vite:run-capacitor', async configureServer(server) { const resolvedCapArgs = await resolveCapRunArgs(options.capArgs) const cwd = resolve(server.config.root) @@ -175,7 +91,23 @@ export function capVitePlugin(options: CapVitePluginOptions): Plugin { } } - function onWatcherEvent(_event, file) { + /** + * Requests a Capacitor restart after a native project file changes. + * + * Triggering workflow: + * + * `server.watcher` + * -> `all` + * -> `onWatcherEvent` + * -> `requestRestart` + * + * Upstream: + * - `server.watcher` + * + * Downstream: + * - `requestRestart` + */ + function onWatcherEvent(_event: string, file: string) { if (!shouldRestartForNativeChange(file, resolvedPlatform, cwd)) { return } @@ -219,5 +151,89 @@ export function capVitePlugin(options: CapVitePluginOptions): Plugin { process.once('SIGINT', handleShutdownRequest) process.once('SIGTERM', handleShutdownRequest) }, + name: 'cap-vite:run-capacitor', + } +} + +function bindCapViteShortcuts( + onRestart: () => void, + onShutdown: () => Promise, +) { + if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') { + return () => {} + } + + process.stdin.resume() + process.stdin.setEncoding('utf8') + readline.emitKeypressEvents(process.stdin) + + const shouldRestoreRawMode = !process.stdin.isRaw + if (shouldRestoreRawMode) { + process.stdin.setRawMode(true) + } + + async function shutdownFromShortcut() { + try { + await onShutdown() + } + finally { + if (shouldRestoreRawMode) { + process.stdin.setRawMode(false) + } + + process.kill(process.pid, 'SIGINT') + } + } + + const onKeyPress = (input: string, key: readline.Key) => { + if (key.ctrl && key.name === 'c') { + void shutdownFromShortcut() + return + } + + const keyName = key.name?.toLowerCase() ?? input.toLowerCase() + if (!key.ctrl && !key.meta && keyName === 'r') { + onRestart() + } + } + + process.stdin.on('keypress', onKeyPress) + + return () => { + process.stdin.off('keypress', onKeyPress) + + if (shouldRestoreRawMode) { + process.stdin.setRawMode(false) + } + } +} + +function startCapProcess(cwd: string, capArgs: string[], url: URL) { + return x('cap', ['run', ...capArgs], { + nodeOptions: { + cwd, + env: { + CAPACITOR_DEV_SERVER_URL: url.toString(), + }, + // NOTICE: cap-vite owns the terminal shortcuts, so cap run should not + // consume stdin while still mirroring its stdout/stderr to the console. + stdio: ['ignore', 'inherit', 'inherit'], + }, + throwOnError: false, + }) +} + +async function stopCapProcess(current: Result | undefined) { + if (!current) { + return + } + + current.kill('SIGINT') + + try { + await current + } + catch { + // tinyexec rejects when a process is stopped during a restart. } } diff --git a/packages/core-agent/package.json b/packages/core-agent/package.json index 03875f5f9..b5e608c62 100644 --- a/packages/core-agent/package.json +++ b/packages/core-agent/package.json @@ -63,6 +63,7 @@ }, "devDependencies": { "@types/hast": "catalog:", + "@types/node": "catalog:", "@types/unist": "catalog:", "tsdown": "catalog:", "typescript": "catalog:" diff --git a/packages/core-agent/tsconfig.json b/packages/core-agent/tsconfig.json index dd42464f1..06def89dc 100644 --- a/packages/core-agent/tsconfig.json +++ b/packages/core-agent/tsconfig.json @@ -4,6 +4,7 @@ "lib": ["ESNext", "DOM"], "module": "ESNext", "moduleResolution": "bundler", + "types": ["node"], "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, diff --git a/packages/electron-screen-capture/package.json b/packages/electron-screen-capture/package.json index 3610d2efb..990eb306b 100644 --- a/packages/electron-screen-capture/package.json +++ b/packages/electron-screen-capture/package.json @@ -47,16 +47,14 @@ "@moeru/std": "catalog:", "async-mutex": "catalog:", "nanoid": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "vue": "catalog:" }, "inlinedDependencies": { "@electron-toolkit/preload": "3.0.2", "@moeru/eventa": "1.0.0", "async-mutex": "0.5.0", - "nanoid": [ - "5.1.11", - "6.0.1" - ], - "picomatch": "4.0.4" + "nanoid": "6.0.1", + "picomatch": "4.0.5" } } diff --git a/packages/electron-vueuse/package.json b/packages/electron-vueuse/package.json index b5ae6da0b..69144b589 100644 --- a/packages/electron-vueuse/package.json +++ b/packages/electron-vueuse/package.json @@ -43,6 +43,7 @@ "std-env": "catalog:" }, "devDependencies": { - "@proj-airi/electron-eventa": "workspace:^" + "@proj-airi/electron-eventa": "workspace:^", + "vue": "catalog:" } } diff --git a/packages/pipelines-audio/src/llm-streaming-control/controller.ts b/packages/pipelines-audio/src/llm-streaming-control/controller.ts index 833d772c0..328584aed 100644 --- a/packages/pipelines-audio/src/llm-streaming-control/controller.ts +++ b/packages/pipelines-audio/src/llm-streaming-control/controller.ts @@ -7,6 +7,7 @@ import type { LlmStreamingControlSignal, LlmStreamingControlSignalContext, LlmStreamingControlSignalHandler, + LlmStreamingControlTurn, LlmStreamingControlTurnDone, } from './types' @@ -14,148 +15,10 @@ import { tokenAct, tokenCall, tokenDelay } from './parsers' import { renderCallManifestPrompt } from './parsers/call' interface StreamingControlTurnState { - handlers: Map> callManifests: Map - settle: (result: LlmStreamingControlTurnDone) => void done: Promise -} - -/** - * Converts parsed signal payload into observer-friendly text. - * - * Use when: - * - Observer logs need a compact human-readable parameter - * - * Notice: - * - Intentionally serializes payloads once - * - Returns undefined for empty CALL payload - */ -function parsedParameter(signal: LlmStreamingControlSignal): string | undefined { - switch (signal.type) { - case 'act': - return JSON.stringify(signal.payload) - - case 'call': - return signal.payload != null - ? JSON.stringify(signal.payload) - : undefined - - case 'delay': - return `${signal.seconds}s` - } -} - -function createTurnId() { - return `turn:${ - globalThis.crypto?.randomUUID?.() - ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` - }` -} - -/** - * Normalizes manifest values before registration. - * - * Notice: - * - Empty names/prompts are rejected - * - Prevents duplicated trim logic - */ -function normalizeManifest( - manifest: LlmStreamingControlCallManifest, -): LlmStreamingControlCallManifest | undefined { - const name = manifest.name.trim() - const prompt = manifest.prompt.trim() - - if (!name || !prompt) - return - - return { - ...manifest, - name, - prompt, - } -} - -/** - * Emits observer events safely. - * - * Notice: - * - Observer failures must never break dispatch - */ -function emit( - context: Pick | undefined, - payload: Parameters>[0], -) { - try { - context?.observer?.(payload) - } - catch {} -} - -/** - * Creates isolated turn state. - * - * Notice: - * - Promise resolves once only - * - Prevents accidental double completion - */ -function createTurnState(): StreamingControlTurnState { - let settled = false - let settle!: (result: LlmStreamingControlTurnDone) => void - - const done = new Promise((resolve) => { - settle = (result) => { - if (settled) - return - - settled = true - resolve(result) - } - }) - - return { - handlers: new Map(), - callManifests: new Map(), - settle, - done, - } -} - -/** - * Registers handler and keeps cleanup centralized. - * - * Notice: - * - Avoid duplicated Map allocations - * - Removes manifest automatically once empty - */ -function registerHandler>( - container: Pick, - manifest: LlmStreamingControlCallManifest, - handler: LlmStreamingControlCallHandler, -) { - const normalized = normalizeManifest(manifest) - - if (!normalized) - return () => undefined - - let set = container.handlers.get(normalized.name) - - if (!set) { - set = new Set() - container.handlers.set(normalized.name, set) - } - - container.callManifests.set(normalized.name, normalized) - - set.add(handler as LlmStreamingControlCallHandler) - - return () => { - set!.delete(handler) - - if (set!.size === 0) { - container.handlers.delete(normalized.name) - container.callManifests.delete(normalized.name) - } - } + handlers: Map> + settle: (result: LlmStreamingControlTurnDone) => void } /** @@ -223,11 +86,22 @@ export function createStreamingControlParser( function createTurnApi( turnId: string, turn: StreamingControlTurnState, - ) { + ): LlmStreamingControlTurn { return { - turnId, + cancel() { + finalizeTurn(turnId, 'cancelled') + }, - on = Record>(manifest, handler) { + complete() { + finalizeTurn(turnId, 'completed') + }, + + done: turn.done, + + on = Record>( + manifest: LlmStreamingControlCallManifest, + handler: LlmStreamingControlCallHandler, + ) { return registerHandler(turn, manifest, handler) }, @@ -237,21 +111,35 @@ export function createStreamingControlParser( ) }, - complete() { - finalizeTurn(turnId, 'completed') - }, - - cancel() { - finalizeTurn(turnId, 'cancelled') - }, - - done: turn.done, + turnId, } } return { - match(input) { - return !!findParser(input) + beginTurn(options) { + // crypto UUID avoids collision under concurrency + const turnId + = options?.turnId?.trim() + || createTurnId() + + const existing = turns.get(turnId) + + if (existing) + return createTurnApi(turnId, existing) + + const turn = createTurnState() + + turns.set(turnId, turn) + + return createTurnApi(turnId, turn) + }, + + cancelTurn(turnId) { + finalizeTurn(turnId, 'cancelled') + }, + + completeTurn(turnId) { + finalizeTurn(turnId, 'completed') }, async dispatchWith(special, context) { @@ -259,8 +147,8 @@ export function createStreamingControlParser( if (!parser) { emit(context, { - type: 'rejected', reason: 'no-matching-parser', + type: 'rejected', }) return false @@ -270,23 +158,23 @@ export function createStreamingControlParser( if (!parsed) { emit(context, { - type: 'rejected', - reason: 'parse-failed', parserName: parser.name, + reason: 'parse-failed', + type: 'rejected', }) return false } emit(context, { - type: 'parsed', - parserName: parser.name, - tokenType: parsed.type, callName: parsed.type === 'call' ? parsed.name : undefined, parameter: parsedParameter(parsed), + parserName: parser.name, + tokenType: parsed.type, + type: 'parsed', }) const { @@ -306,9 +194,9 @@ export function createStreamingControlParser( } catch (error) { emit(context, { - type: 'signal-handler-error', - tokenType: parsed.type, error, + tokenType: parsed.type, + type: 'signal-handler-error', }) console.warn( @@ -337,15 +225,15 @@ export function createStreamingControlParser( ] emit(context, { - type: 'call-handler-count', count: registeredHandlers.length, + type: 'call-handler-count', }) if (!registeredHandlers.length) { emit(context, { - type: 'call-handler-missing', callName: parsed.name, payload: parsed.payload, + type: 'call-handler-missing', }) return true @@ -356,8 +244,8 @@ export function createStreamingControlParser( for (const handler of registeredHandlers) { try { emit(context, { - type: 'call-handler-start', callName: parsed.name, + type: 'call-handler-start', }) await handler( @@ -366,15 +254,15 @@ export function createStreamingControlParser( ) emit(context, { - type: 'call-handler-end', callName: parsed.name, + type: 'call-handler-end', }) } catch (error) { emit(context, { - type: 'call-handler-error', callName: parsed.name, error, + type: 'call-handler-error', }) console.warn( @@ -387,23 +275,21 @@ export function createStreamingControlParser( return true }, + match(input) { + return !!findParser(input) + }, + on(manifest, handler) { return registerHandler( { - handlers, callManifests, + handlers, }, manifest, handler, ) }, - renderManifestPrompt() { - return renderCallManifestPrompt( - [...callManifests.values()], - ) - }, - onSignal(handler) { signalHandlers.add(handler) @@ -412,30 +298,160 @@ export function createStreamingControlParser( } }, - beginTurn(options) { - // crypto UUID avoids collision under concurrency - const turnId - = options?.turnId?.trim() - || createTurnId() - - const existing = turns.get(turnId) - - if (existing) - return createTurnApi(turnId, existing) - - const turn = createTurnState() - - turns.set(turnId, turn) - - return createTurnApi(turnId, turn) - }, - - completeTurn(turnId) { - finalizeTurn(turnId, 'completed') - }, - - cancelTurn(turnId) { - finalizeTurn(turnId, 'cancelled') + renderManifestPrompt() { + return renderCallManifestPrompt( + [...callManifests.values()], + ) }, } } + +function createTurnId() { + return `turn:${ + globalThis.crypto?.randomUUID?.() + ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` + }` +} + +/** + * Creates isolated turn state. + * + * Notice: + * - Promise resolves once only + * - Prevents accidental double completion + */ +function createTurnState(): StreamingControlTurnState { + let settled = false + let settle!: (result: LlmStreamingControlTurnDone) => void + + const done = new Promise((resolve) => { + settle = (result) => { + if (settled) + return + + settled = true + resolve(result) + } + }) + + return { + callManifests: new Map(), + done, + handlers: new Map(), + settle, + } +} + +/** + * Emits observer events safely. + * + * Notice: + * - Observer failures must never break dispatch + */ +function emit( + context: Pick | undefined, + payload: Parameters>[0], +) { + try { + context?.observer?.(payload) + } + catch {} +} + +/** + * Normalizes manifest values before registration. + * + * Notice: + * - Empty names/prompts are rejected + * - Prevents duplicated trim logic + */ +function normalizeManifest( + manifest: LlmStreamingControlCallManifest, +): LlmStreamingControlCallManifest | undefined { + const name = manifest.name.trim() + const prompt = manifest.prompt.trim() + + if (!name || !prompt) + return + + return { + ...manifest, + name, + prompt, + } +} + +/** + * Converts parsed signal payload into observer-friendly text. + * + * Use when: + * - Observer logs need a compact human-readable parameter + * + * Notice: + * - Intentionally serializes payloads once + * - Returns undefined for empty CALL payload + */ +function parsedParameter(signal: LlmStreamingControlSignal): string | undefined { + switch (signal.type) { + case 'act': + return JSON.stringify(signal.payload) + + case 'call': + return signal.payload != null + ? JSON.stringify(signal.payload) + : undefined + + case 'delay': + return `${signal.seconds}s` + } +} + +/** + * Registers one CALL handler and returns its disposer. + * + * Triggering workflow: + * + * {@link createStreamingControlParser} + * -> `LlmStreamingControl.on` or `LlmStreamingControlTurn.on` + * -> {@link registerHandler} + * + * Upstream: + * - {@link LlmStreamingControl} + * - {@link LlmStreamingControlTurn} + * + * Downstream: + * - {@link StreamingControlTurnState} handler and manifest registries + */ +function registerHandler>( + container: Pick, + manifest: LlmStreamingControlCallManifest, + handler: LlmStreamingControlCallHandler, +) { + const normalized = normalizeManifest(manifest) + + if (!normalized) + return () => undefined + + let registeredHandlers = container.handlers.get(normalized.name) + + if (!registeredHandlers) { + registeredHandlers = new Set() + container.handlers.set(normalized.name, registeredHandlers) + } + + container.callManifests.set(normalized.name, normalized) + + // The registration API associates each payload type with one opaque CALL name. + // The runtime registry erases that relation before it looks up the name. + const registeredHandler = handler as LlmStreamingControlCallHandler + registeredHandlers.add(registeredHandler) + + return () => { + registeredHandlers.delete(registeredHandler) + + if (registeredHandlers.size === 0) { + container.handlers.delete(normalized.name) + container.callManifests.delete(normalized.name) + } + } +} diff --git a/packages/plugin-sdk/src/plugin-host/shared/types.ts b/packages/plugin-sdk/src/plugin-host/shared/types.ts index 9b4b3d519..84f57ab80 100644 --- a/packages/plugin-sdk/src/plugin-host/shared/types.ts +++ b/packages/plugin-sdk/src/plugin-host/shared/types.ts @@ -3,6 +3,7 @@ import type { ModulePermissionDeclaration as ProtocolModulePermissionDeclaration, ModulePermissionGrant as ProtocolModulePermissionGrant, } from '@proj-airi/plugin-protocol/types' +import type { GenericSchema } from 'valibot' import type { KitDescriptor } from './kits' @@ -15,6 +16,7 @@ import { lazy, literal, minValue, + null_, number, object, optional, @@ -110,12 +112,12 @@ export interface HostDataRecord { * - The recursive union used across shared host data structures */ export type HostDataValue - = | null - | string - | number - | boolean + = | boolean | HostDataArray | HostDataRecord + | null + | number + | string /** * Creates the recursive Valibot schema used for one {@link HostDataValue}. @@ -129,14 +131,14 @@ export type HostDataValue * Returns: * - A Valibot schema covering the full `HostDataValue` recursion */ -export function createHostDataValueSchema() { +export function createHostDataValueSchema(): GenericSchema { return union([ - literal(null), + null_(), string(), boolean(), pipe(number(), finite()), array(lazy(createHostDataValueSchema)), - pipe(record(string(), lazy(createHostDataValueSchema)), check(isPlainObject)), + pipe(record(string(), lazy(createHostDataValueSchema)), check(isPlainObject)), ]) } @@ -166,7 +168,10 @@ export const hostDataValueSchema = createHostDataValueSchema() * Returns: * - A Valibot schema for one host-safe record */ -export const hostDataRecordSchema = pipe(record(string(), lazy(createHostDataValueSchema)), check(isPlainObject)) +export const hostDataRecordSchema = pipe( + record(string(), lazy(createHostDataValueSchema)), + check(isPlainObject), +) /** * Validates one non-negative safe integer used for timestamps and revisions. @@ -196,6 +201,34 @@ export const nonNegativeIntegerSchema = pipe(number(), safeInteger(), minValue(0 */ export type ExtensionIdentity = ProtocolExtensionIdentity +/** + * Describes a version-1 extension manifest consumed by `ExtensionHost`. + * + * Extension manifests are the install/session-level package description. Module + * registration happens later during `defineExtension({ setup })`. + */ +export interface ExtensionManifestV1 { + /** Manifest schema version expected by the current host implementation. */ + apiVersion: 'v1' + /** Runtime-specific extension entrypoints that the host can resolve and import. */ + entrypoints: { + /** Fallback entrypoint used when no runtime-specific path is provided. */ + default?: string + /** Electron-specific entrypoint path. */ + electron?: string + /** Node-specific entrypoint path. */ + node?: string + /** Web-specific entrypoint path. */ + web?: string + } + /** Stable extension id used for identity generation and display. */ + id: string + /** Manifest kind discriminator used to identify AIRI extension manifests. */ + kind: 'manifest.extension.airi.moeru.ai' + /** Package/session permission ceiling that module permissions are capped by. */ + permissions: ModulePermissionDeclaration +} + /** * Re-exports the protocol permission declaration model used by manifests and runtime permission flow. * @@ -224,77 +257,49 @@ export type ModulePermissionDeclaration = ProtocolModulePermissionDeclaration */ export type ModulePermissionGrant = ProtocolModulePermissionGrant -/** - * Describes a version-1 extension manifest consumed by `ExtensionHost`. - * - * Extension manifests are the install/session-level package description. Module - * registration happens later during `defineExtension({ setup })`. - */ -export interface ExtensionManifestV1 { - /** Manifest schema version expected by the current host implementation. */ - apiVersion: 'v1' - /** Manifest kind discriminator used to identify AIRI extension manifests. */ - kind: 'manifest.extension.airi.moeru.ai' - /** Stable extension id used for identity generation and display. */ - id: string - /** Package/session permission ceiling that module permissions are capped by. */ - permissions: ModulePermissionDeclaration - /** Runtime-specific extension entrypoints that the host can resolve and import. */ - entrypoints: { - /** Fallback entrypoint used when no runtime-specific path is provided. */ - default?: string - /** Electron-specific entrypoint path. */ - electron?: string - /** Node-specific entrypoint path. */ - node?: string - /** Web-specific entrypoint path. */ - web?: string - } -} - const localizableSchema = union([ string(), object({ - key: string(), fallback: optional(string()), + key: string(), params: optional(record(string(), union([string(), number(), boolean()]))), }), ]) const permissionDeclarationSchema = object({ apis: optional(array(object({ - key: string(), actions: array(picklist(['invoke', 'emit'])), - reason: optional(localizableSchema), - label: optional(localizableSchema), - required: optional(boolean()), - }))), - resources: optional(array(object({ key: string(), - actions: array(picklist(['read', 'write', 'subscribe'])), - reason: optional(localizableSchema), label: optional(localizableSchema), + reason: optional(localizableSchema), required: optional(boolean()), }))), capabilities: optional(array(object({ - key: string(), actions: array(picklist(['wait', 'snapshot'])), - reason: optional(localizableSchema), - label: optional(localizableSchema), - required: optional(boolean()), - }))), - processors: optional(array(object({ key: string(), - actions: array(picklist(['register', 'execute', 'manage'])), - reason: optional(localizableSchema), label: optional(localizableSchema), + reason: optional(localizableSchema), required: optional(boolean()), }))), pipelines: optional(array(object({ - key: string(), actions: array(picklist(['hook', 'process', 'emit', 'manage'])), - reason: optional(localizableSchema), + key: string(), label: optional(localizableSchema), + reason: optional(localizableSchema), + required: optional(boolean()), + }))), + processors: optional(array(object({ + actions: array(picklist(['register', 'execute', 'manage'])), + key: string(), + label: optional(localizableSchema), + reason: optional(localizableSchema), + required: optional(boolean()), + }))), + resources: optional(array(object({ + actions: array(picklist(['read', 'write', 'subscribe'])), + key: string(), + label: optional(localizableSchema), + reason: optional(localizableSchema), required: optional(boolean()), }))), }) @@ -321,12 +326,98 @@ const manifestEntrypointsSchema = object({ */ export const extensionManifestV1Schema = object({ apiVersion: literal('v1'), - kind: literal('manifest.extension.airi.moeru.ai'), - id: string(), - permissions: permissionDeclarationSchema, entrypoints: manifestEntrypointsSchema, + id: string(), + kind: literal('manifest.extension.airi.moeru.ai'), + permissions: permissionDeclarationSchema, }) +/** + * Installs one generic host feature into `ExtensionHost`. + * + * Use when: + * - The host should register kits, resources, capabilities, or runtime-specific behavior + * + * Expects: + * - Installation is idempotent for one host instance + * - Contributions keep domain-specific behavior out of the low-level host core + * + * Returns: + * - No value; the contribution mutates the provided install context + */ +export interface ExtensionHostContribution { + install: (context: ExtensionHostInstallContext) => void +} + +/** + * Provides the host-owned registration surface that contributions can use during installation. + * + * Use when: + * - Installing a host feature into `ExtensionHost` + * - Registering kits, resources, or capabilities + * + * Expects: + * - Installation happens during `ExtensionHost` construction + * + * Returns: + * - Registration helpers that keep `ExtensionHost` generic while allowing host-specific features + */ +export interface ExtensionHostInstallContext { + announceCapability: (key: string, metadata?: Record) => void + markCapabilityDegraded: (key: string, metadata?: Record) => void + markCapabilityReady: (key: string, metadata?: Record) => void + registerKit: (kit: KitDescriptor) => KitDescriptor + setResourceResolver: (key: string, resolver: () => Promise | T) => void + setResourceValue: (key: string, value: T) => void + unregisterKit: (kitId: string) => KitDescriptor | undefined + withdrawCapability: (key: string, metadata?: Record) => void +} + +/** + * Configures one `ExtensionHost` instance. + * + * Use when: + * - Constructing a host with specific runtime, permission, or contribution behavior + * + * Expects: + * - Omitted fields fall back to the host defaults documented below + * + * Returns: + * - The host bootstrap options consumed by {@link import('../core').ExtensionHost} + */ +export interface ExtensionHostOptions { + /** Installable host features that can register kits, resources, and capabilities. @default [] */ + contributions?: ExtensionHostContribution[] + /** Callback that decides the granted permission set for one extension session. */ + permissionResolver?: (payload: { + identity: ExtensionIdentity + manifest: ExtensionManifestV1 + persisted?: ModulePermissionGrant + requested: ModulePermissionDeclaration + }) => ModulePermissionGrant | Promise + /** Runtime used when callers do not override it per load/start call. @default 'electron' */ + runtime?: PluginRuntime +} + +/** + * Describes one permission gate that a contribution-owned session API can enforce. + * + * Use when: + * - A contribution method must check host-granted API, resource, or capability access + * + * Expects: + * - The permission key/action pair matches the manifest permission contract + * + * Returns: + * - The permission request consumed by `ExtensionHost.assertPermission(...)` + */ +export interface ExtensionHostPermissionRequest { + action: string + area: 'apis' | 'capabilities' | 'pipelines' | 'processors' | 'resources' + key: string + reason?: string +} + /** * Configures how the host resolves and loads an extension entrypoint. * @@ -346,92 +437,6 @@ export interface ExtensionLoadOptions { runtime?: PluginRuntime } -/** - * Configures one `ExtensionHost` instance. - * - * Use when: - * - Constructing a host with specific runtime, permission, or contribution behavior - * - * Expects: - * - Omitted fields fall back to the host defaults documented below - * - * Returns: - * - The host bootstrap options consumed by {@link import('../core').ExtensionHost} - */ -export interface ExtensionHostOptions { - /** Runtime used when callers do not override it per load/start call. @default 'electron' */ - runtime?: PluginRuntime - /** Callback that decides the granted permission set for one extension session. */ - permissionResolver?: (payload: { - identity: ExtensionIdentity - manifest: ExtensionManifestV1 - requested: ModulePermissionDeclaration - persisted?: ModulePermissionGrant - }) => ModulePermissionGrant | Promise - /** Installable host features that can register kits, resources, and capabilities. @default [] */ - contributions?: ExtensionHostContribution[] -} - -/** - * Describes one permission gate that a contribution-owned session API can enforce. - * - * Use when: - * - A contribution method must check host-granted API, resource, or capability access - * - * Expects: - * - The permission key/action pair matches the manifest permission contract - * - * Returns: - * - The permission request consumed by `ExtensionHost.assertPermission(...)` - */ -export interface ExtensionHostPermissionRequest { - area: 'apis' | 'resources' | 'capabilities' | 'processors' | 'pipelines' - action: string - key: string - reason?: string -} - -/** - * Provides the host-owned registration surface that contributions can use during installation. - * - * Use when: - * - Installing a host feature into `ExtensionHost` - * - Registering kits, resources, or capabilities - * - * Expects: - * - Installation happens during `ExtensionHost` construction - * - * Returns: - * - Registration helpers that keep `ExtensionHost` generic while allowing host-specific features - */ -export interface ExtensionHostInstallContext { - registerKit: (kit: KitDescriptor) => KitDescriptor - unregisterKit: (kitId: string) => KitDescriptor | undefined - setResourceResolver: (key: string, resolver: () => Promise | T) => void - setResourceValue: (key: string, value: T) => void - announceCapability: (key: string, metadata?: Record) => void - markCapabilityReady: (key: string, metadata?: Record) => void - markCapabilityDegraded: (key: string, metadata?: Record) => void - withdrawCapability: (key: string, metadata?: Record) => void -} - -/** - * Installs one generic host feature into `ExtensionHost`. - * - * Use when: - * - The host should register kits, resources, capabilities, or runtime-specific behavior - * - * Expects: - * - Installation is idempotent for one host instance - * - Contributions keep domain-specific behavior out of the low-level host core - * - * Returns: - * - No value; the contribution mutates the provided install context - */ -export interface ExtensionHostContribution { - install: (context: ExtensionHostInstallContext) => void -} - /** * Configures one `ExtensionHost.start(...)` call. * diff --git a/packages/plugin-sdk/src/plugin-host/testdata/test-stoppable-extension-entrypoint.ts b/packages/plugin-sdk/src/plugin-host/testdata/test-stoppable-extension-entrypoint.ts index f910c220f..facf82601 100644 --- a/packages/plugin-sdk/src/plugin-host/testdata/test-stoppable-extension-entrypoint.ts +++ b/packages/plugin-sdk/src/plugin-host/testdata/test-stoppable-extension-entrypoint.ts @@ -5,9 +5,14 @@ export const disposedSessionIds: string[] = [] export default defineExtension({ id: 'test-stoppable-extension-entrypoint', async setup(ctx) { + const sessionId = ctx.extension.sessionId + if (!sessionId) { + throw new Error('The plugin host did not provide an extension session ID.') + } + ctx.subscriptions.add({ dispose() { - disposedSessionIds.push(ctx.extension.sessionId) + disposedSessionIds.push(sessionId) }, }) diff --git a/packages/stage-shared/package.json b/packages/stage-shared/package.json index ec56472ce..fd9bd5cc4 100644 --- a/packages/stage-shared/package.json +++ b/packages/stage-shared/package.json @@ -17,6 +17,7 @@ "exports": { ".": "./src/index.ts", "./auth": "./src/auth/index.ts", + "./analytics/posthog": "./src/analytics/posthog.ts", "./beat-sync": "./src/beat-sync/index.ts", "./global-shortcut": "./src/global-shortcut/index.ts", "./godot-stage": "./src/godot-stage/index.ts", @@ -36,6 +37,7 @@ "@vueuse/core": "catalog:", "gpuu": "catalog:", "pinia": "catalog:", + "posthog-js": "catalog:", "valibot": "catalog:", "vue": "catalog:" }, diff --git a/packages/stage-shared/src/analytics/posthog.ts b/packages/stage-shared/src/analytics/posthog.ts new file mode 100644 index 000000000..09089adcb --- /dev/null +++ b/packages/stage-shared/src/analytics/posthog.ts @@ -0,0 +1,25 @@ +import type { PostHogConfig } from 'posthog-js' + +function isEnvFlagEnabled(value: string | undefined): boolean { + if (value == null) + return false + + return /^(?:1|true|t|yes|y|on)$/i.test(value.trim()) +} + +/** Whether client analytics is enabled for the current Vite build. */ +export const POSTHOG_ENABLED = isEnvFlagEnabled(import.meta.env.VITE_ENABLE_POSTHOG) + +/** The shared PostHog project key used by every AIRI client surface. */ +export const POSTHOG_PROJECT_KEY + = import.meta.env.VITE_POSTHOG_PROJECT_KEY + ?? 'phc_pzjziJjrVZpa9SqnQqq0QEKvkmuCPH7GDTA6TbRTEf9' // cspell:disable-line + +/** Shared PostHog defaults for AIRI single-page applications. */ +export const DEFAULT_POSTHOG_CONFIG = { + api_host: 'https://t.airi.build', + // This preset captures page views on history changes. PostHog then also + // captures page leaves, which keeps route-level dwell time measurable. + defaults: '2025-05-24', + person_profiles: 'identified_only', +} as const satisfies Partial diff --git a/packages/stage-ui-three/src/components/ThreeScene.vue b/packages/stage-ui-three/src/components/ThreeScene.vue index e3b24151d..e1585552d 100644 --- a/packages/stage-ui-three/src/components/ThreeScene.vue +++ b/packages/stage-ui-three/src/components/ThreeScene.vue @@ -245,6 +245,9 @@ function toVector3(value: Vec3) { return new Vector3(value.x, value.y, value.z) } +const hemisphereLightPosition = new Vector3(0, 1, 0) +const directionalLightPositionVector = computed(() => toVector3(directionalLightPosition.value)) + function toVec3(value: Vector3): Vec3 { return { x: value.x, y: value.y, z: value.z } } @@ -817,7 +820,7 @@ defineExpose({ v-else :color="formatHex(hemisphereSkyColor)" :ground-color="formatHex(hemisphereGroundColor)" - :position="[0, 1, 0]" + :position="hemisphereLightPosition" :intensity="hemisphereLightIntensity" cast-shadow /> @@ -829,7 +832,7 @@ defineExpose({ diff --git a/packages/stage-ui/src/components/data-pane/property-point.vue b/packages/stage-ui/src/components/data-pane/property-point.vue index 89fc8b2a1..a82da303c 100644 --- a/packages/stage-ui/src/components/data-pane/property-point.vue +++ b/packages/stage-ui/src/components/data-pane/property-point.vue @@ -18,9 +18,9 @@ const props = defineProps<{ zConfig?: AxisConfig }>() -const x = defineModel('x', { required: false, default: 0 }) -const y = defineModel('y', { required: false, default: 0 }) -const z = defineModel('z', { required: false, default: 0 }) +const x = defineModel('x', { required: false, default: 0 }) +const y = defineModel('y', { required: false, default: 0 }) +const z = defineModel('z', { required: false, default: 0 }) // Dragging state const isDragging = ref<'x' | 'y' | 'z' | undefined>() diff --git a/packages/stage-ui/src/components/widgets/poppin-text/animators/fade.ts b/packages/stage-ui/src/components/widgets/poppin-text/animators/fade.ts index aabaee912..373f8c6e2 100644 --- a/packages/stage-ui/src/components/widgets/poppin-text/animators/fade.ts +++ b/packages/stage-ui/src/components/widgets/poppin-text/animators/fade.ts @@ -16,7 +16,7 @@ export function createFadeAnimator(options: CreateAnimatorOptions): Animator { .add(elements, { opacity: [0, 1], ...options, - delay: (_, i) => options.duration / elements.length * (i + 1), + delay: (_: unknown, i = 0) => options.duration / elements.length * (i + 1), }) return () => { diff --git a/packages/stage-ui/src/components/widgets/poppin-text/animators/float.ts b/packages/stage-ui/src/components/widgets/poppin-text/animators/float.ts index a2a1a9d73..1cab0a0f8 100644 --- a/packages/stage-ui/src/components/widgets/poppin-text/animators/float.ts +++ b/packages/stage-ui/src/components/widgets/poppin-text/animators/float.ts @@ -12,19 +12,19 @@ export function createFloatAnimator(options: CreateAnimatorOptions): Animator { const timeline = createTimeline({ loop: options.loop }) .set(elements, { opacity: 0, + rotateZ: 180, translateX: '0.55em', translateY: '1.1em', - rotateZ: 180, translateZ: 0, }) .add(elements, { opacity: [0, 1], - translateY: ['1.1em', 0], - translateX: ['0.55em', 0], - translateZ: 0, rotateZ: [180, 0], + translateX: ['0.55em', 0], + translateY: ['1.1em', 0], + translateZ: 0, ...options, - delay: (_, i) => options.duration / elements.length * i, + delay: (_: unknown, i = 0) => options.duration / elements.length * i, }) return () => { diff --git a/packages/stage-ui/src/components/widgets/poppin-text/animators/popup.ts b/packages/stage-ui/src/components/widgets/poppin-text/animators/popup.ts index b1c9670a9..be47da950 100644 --- a/packages/stage-ui/src/components/widgets/poppin-text/animators/popup.ts +++ b/packages/stage-ui/src/components/widgets/poppin-text/animators/popup.ts @@ -20,7 +20,7 @@ export function createPopupAnimator(options: CreateAnimatorOptions): Animator { translateY: ['1.1em', 0], translateZ: 0, ...options, - delay: (_, i) => options.duration / elements.length * i, + delay: (_: unknown, i = 0) => options.duration / elements.length * i, }) return () => { diff --git a/packages/stage-ui/src/components/widgets/poppin-text/animators/scale-popup.ts b/packages/stage-ui/src/components/widgets/poppin-text/animators/scale-popup.ts index 41d717b8c..d25932ee4 100644 --- a/packages/stage-ui/src/components/widgets/poppin-text/animators/scale-popup.ts +++ b/packages/stage-ui/src/components/widgets/poppin-text/animators/scale-popup.ts @@ -22,7 +22,7 @@ export function createCutePopupAnimator(options: CreateAnimatorOptions): Animato translateY: ['1.1em', 0], translateZ: 0, ...options, - delay: (_, i) => options.duration / elements.length * i, + delay: (_: unknown, i = 0) => options.duration / elements.length * i, ease: createSpring(), }) diff --git a/packages/stage-ui/src/components/widgets/poppin-text/animators/stack.ts b/packages/stage-ui/src/components/widgets/poppin-text/animators/stack.ts index 13d93f9f2..58622fa70 100644 --- a/packages/stage-ui/src/components/widgets/poppin-text/animators/stack.ts +++ b/packages/stage-ui/src/components/widgets/poppin-text/animators/stack.ts @@ -16,11 +16,11 @@ export function createStackAnimator(options: CreateAnimatorOptions): Animator { translateZ: 0, }) .add(elements, { + opacity: [0, 1], translateX: [40, 0], translateZ: 0, - opacity: [0, 1], ...options, - delay: (_, i) => options.duration / elements.length * (i + 1), + delay: (_: unknown, i = 0) => options.duration / elements.length * (i + 1), }) return () => { diff --git a/packages/stage-ui/src/composables/use-io-tracer.ts b/packages/stage-ui/src/composables/use-io-tracer.ts index 5ba65fe95..ed49473a5 100644 --- a/packages/stage-ui/src/composables/use-io-tracer.ts +++ b/packages/stage-ui/src/composables/use-io-tracer.ts @@ -1,6 +1,5 @@ import type { Span, SpanContext, SpanStatusCode } from '@opentelemetry/api' import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base' -import type { TimedEvent } from '@opentelemetry/sdk-trace-base/build/esm/TimedEvent' import type { SerializedIOSpan } from '@proj-airi/stage-shared/types/io-trace' import { context, trace } from '@opentelemetry/api' @@ -13,27 +12,7 @@ export type { ReadableSpan } from '@opentelemetry/sdk-trace-base' const TRACER_NAME = 'ai.moeru.airi.io-tracer' const BROADCAST_CHANNEL = 'io-tracer-channel' // TODO: Use simple BroadcastChannel for now -function serializeSpan(span: ReadableSpan): SerializedIOSpan { - const ctx = span.spanContext() - const parentCtx = span.parentSpanContext - return { - traceId: ctx.traceId, - spanId: ctx.spanId, - parentSpanId: parentCtx?.spanId ?? '', - name: span.name, - kind: span.kind, - startTimeNano: String(hrTimeToNanoseconds(span.startTime)), - endTimeNano: span.ended ? String(hrTimeToNanoseconds(span.endTime)) : '0', - attributes: { ...span.attributes }, - events: span.events.map((e: TimedEvent) => ({ - name: e.name, - timeNano: String(hrTimeToNanoseconds(e.time)), - attributes: { ...e.attributes }, - })), - status: { code: span.status.code, message: span.status.message ?? '' }, - ended: span.ended, - } -} +type SpanCallback = (span: ReadableSpan) => void export function deserializeSpan(s: SerializedIOSpan): ReadableSpan { const nanoToHr = (nano: string): [number, number] => { @@ -41,42 +20,62 @@ export function deserializeSpan(s: SerializedIOSpan): ReadableSpan { return [Math.floor(n / 1e9), n % 1e9] } const spanCtx: SpanContext = { - traceId: s.traceId, + isRemote: false, spanId: s.spanId, traceFlags: 1, - isRemote: false, + traceId: s.traceId, } const parentCtx: SpanContext | undefined = s.parentSpanId - ? { traceId: s.traceId, spanId: s.parentSpanId, traceFlags: 1, isRemote: false } + ? { isRemote: false, spanId: s.parentSpanId, traceFlags: 1, traceId: s.traceId } : undefined return { - name: s.name, - kind: s.kind, - spanContext: () => spanCtx, - parentSpanContext: parentCtx, - startTime: nanoToHr(s.startTimeNano), - endTime: nanoToHr(s.endTimeNano), - status: { code: s.status.code as SpanStatusCode, message: s.status.message }, - attributes: s.attributes as Record, - links: [], - events: s.events.map(e => ({ - name: e.name, - time: nanoToHr(e.timeNano), - attributes: e.attributes as Record, - droppedAttributesCount: 0, - })), - duration: nanoToHr(String(Number(s.endTimeNano) - Number(s.startTimeNano))), - ended: s.ended, - resource: { attributes: {}, merge: () => ({ attributes: {} }) } as any, - instrumentationScope: { name: TRACER_NAME }, + attributes: s.attributes as Record, droppedAttributesCount: 0, droppedEventsCount: 0, droppedLinksCount: 0, + duration: nanoToHr(String(Number(s.endTimeNano) - Number(s.startTimeNano))), + ended: s.ended, + endTime: nanoToHr(s.endTimeNano), + events: s.events.map(e => ({ + attributes: e.attributes as Record, + droppedAttributesCount: 0, + name: e.name, + time: nanoToHr(e.timeNano), + })), + instrumentationScope: { name: TRACER_NAME }, + kind: s.kind, + links: [], + name: s.name, + parentSpanContext: parentCtx, + resource: { attributes: {}, merge: () => ({ attributes: {} }) } as any, + spanContext: () => spanCtx, + startTime: nanoToHr(s.startTimeNano), + status: { code: s.status.code as SpanStatusCode, message: s.status.message }, } } -type SpanCallback = (span: ReadableSpan) => void +function serializeSpan(span: ReadableSpan): SerializedIOSpan { + const ctx = span.spanContext() + const parentCtx = span.parentSpanContext + return { + attributes: { ...span.attributes }, + ended: span.ended, + endTimeNano: span.ended ? String(hrTimeToNanoseconds(span.endTime)) : '0', + events: span.events.map(e => ({ + attributes: { ...e.attributes }, + name: e.name, + timeNano: String(hrTimeToNanoseconds(e.time)), + })), + kind: span.kind, + name: span.name, + parentSpanId: parentCtx?.spanId ?? '', + spanId: ctx.spanId, + startTimeNano: String(hrTimeToNanoseconds(span.startTime)), + status: { code: span.status.code, message: span.status.message ?? '' }, + traceId: ctx.traceId, + } +} let provider: BasicTracerProvider | undefined let spanCallback: SpanCallback | undefined @@ -89,17 +88,23 @@ export function createCallbackSpanExporter(): SpanExporter { spanCallback?.(span) broadcastChannel?.postMessage({ - type: 'span', span: serializeSpan(span), + type: 'span', }) } resultCallback({ code: 0 /* SUCCESS */ }) }, - shutdown: () => Promise.resolve(), forceFlush: () => Promise.resolve(), + shutdown: () => Promise.resolve(), } } +export function getIOTracer() { + if (provider) + return provider.getTracer(TRACER_NAME) + return trace.getTracer(TRACER_NAME) +} + export function initIOTracer() { if (!broadcastChannel) broadcastChannel = new BroadcastChannel(BROADCAST_CHANNEL) @@ -113,12 +118,6 @@ export function initIOTracer() { trace.setGlobalTracerProvider(provider) } -export function getIOTracer() { - if (provider) - return provider.getTracer(TRACER_NAME) - return trace.getTracer(TRACER_NAME) -} - export function onIOSpan(cb: SpanCallback | undefined) { spanCallback = cb } @@ -137,7 +136,7 @@ export function onRemoteIOSpan(cb: SpanCallback): () => void { } } -export function startSpan(name: string, parent?: Span, attrs?: Record): Span { +export function startSpan(name: string, parent?: Span, attrs?: Record): Span { initIOTracer() const tracer = getIOTracer() diff --git a/packages/stage-ui/src/libs/analytics/posthog.test.ts b/packages/stage-ui/src/libs/analytics/posthog.test.ts index a96b1105c..ad5efc0f2 100644 --- a/packages/stage-ui/src/libs/analytics/posthog.test.ts +++ b/packages/stage-ui/src/libs/analytics/posthog.test.ts @@ -18,7 +18,7 @@ vi.mock('@proj-airi/stage-shared', () => ({ isStageTamagotchi: () => false, })) -vi.mock('../../../../../posthog.config', () => ({ +vi.mock('@proj-airi/stage-shared/analytics/posthog', () => ({ DEFAULT_POSTHOG_CONFIG: {}, POSTHOG_PROJECT_KEY: 'test-project-key', })) diff --git a/packages/stage-ui/src/libs/analytics/posthog.ts b/packages/stage-ui/src/libs/analytics/posthog.ts index 49bf4aa00..7fefd65bb 100644 --- a/packages/stage-ui/src/libs/analytics/posthog.ts +++ b/packages/stage-ui/src/libs/analytics/posthog.ts @@ -4,19 +4,10 @@ import type { AnalyticsAdapter, AnalyticsAdapterOptions } from './client' import posthog from 'posthog-js' import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared' - import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY, -} from '../../../../../posthog.config' - -function currentSurface(): 'web' | 'mobile' | 'electron' { - if (isStageTamagotchi()) - return 'electron' - if (isStageCapacitor()) - return 'mobile' - return 'web' -} +} from '@proj-airi/stage-shared/analytics/posthog' /** Creates and initializes the default PostHog adapter. */ export function createPosthogAdapter(options: AnalyticsAdapterOptions): AnalyticsAdapter { @@ -56,10 +47,10 @@ export function createPosthogAdapter(options: AnalyticsAdapterOptions): Analytic }, registerBuildInfo(buildInfo: AboutBuildInfo) { posthog.register({ - app_version: (buildInfo.version && buildInfo.version !== '0.0.0') ? buildInfo.version : 'dev', - app_commit: buildInfo.commit, app_branch: buildInfo.branch, app_build_time: buildInfo.builtOn, + app_commit: buildInfo.commit, + app_version: (buildInfo.version && buildInfo.version !== '0.0.0') ? buildInfo.version : 'dev', }) }, resetIdentity() { @@ -78,3 +69,11 @@ export function createPosthogAdapter(options: AnalyticsAdapterOptions): Analytic }, } } + +function currentSurface(): 'electron' | 'mobile' | 'web' { + if (isStageTamagotchi()) + return 'electron' + if (isStageCapacitor()) + return 'mobile' + return 'web' +} diff --git a/packages/ui-loading-screens/playground/src/App.vue b/packages/ui-loading-screens/playground/src/App.vue index 1bfa40cfd..6f63785e9 100644 --- a/packages/ui-loading-screens/playground/src/App.vue +++ b/packages/ui-loading-screens/playground/src/App.vue @@ -1,4 +1,4 @@ - diff --git a/packages/ui-loading-screens/src/components/LoadingSciFiCircle/index.vue b/packages/ui-loading-screens/src/components/LoadingSciFiCircle/index.vue index 9a2c0973d..ee4a8094f 100644 --- a/packages/ui-loading-screens/src/components/LoadingSciFiCircle/index.vue +++ b/packages/ui-loading-screens/src/components/LoadingSciFiCircle/index.vue @@ -3,7 +3,7 @@ import type { Ref } from 'vue' import { Rive } from '@rive-app/canvas-lite' import { breakpointsTailwind, useBreakpoints, useDark } from '@vueuse/core' -import { computed, onMounted, ref, watch } from 'vue' +import { computed, onMounted, ref, useTemplateRef, watch } from 'vue' import CircleFadeInAnimation from './assets/circle_blink_in_-_loading_(@proj-airi).riv' import CRT from './CRT.vue' @@ -243,9 +243,9 @@ const bootMessages = computed(() => [ }, ]) -const riveCanvas = ref() +const riveCanvas = useTemplateRef('riveCanvas') const rive = ref() -const crtRef = ref>() +const crtRef = useTemplateRef>('crtRef') const isDark = useDark() @@ -271,8 +271,9 @@ function handleUpdateDone(value: boolean) { // Method to update typing speed for a specific message function setMessageTypingSpeed(index: number, speed: number) { - if (index >= 0 && index < bootMessages.value.length && speed > 0) { - bootMessages[index].typingSpeed = speed + const message = bootMessages.value[index] + if (message && speed > 0) { + message.typingSpeed = speed } } @@ -376,12 +377,16 @@ async function writeLine( } onMounted(async () => { - riveCanvas.value.width = Math.max(window.innerWidth, 500) * 2 - riveCanvas.value.height = Math.max(window.innerWidth, 500) * 2 + const canvas = riveCanvas.value + if (!canvas) + return + + canvas.width = Math.max(window.innerWidth, 500) * 2 + canvas.height = Math.max(window.innerWidth, 500) * 2 rive.value = new Rive({ src: CircleFadeInAnimation, - canvas: riveCanvas.value, + canvas, autoplay: true, artboard: isDark.value ? 'Bold' : 'Bold (Light)', }) @@ -398,10 +403,14 @@ onMounted(async () => { }) watch(isDark, () => { + const canvas = riveCanvas.value + if (!canvas) + return + rive.value?.cleanup() rive.value = new Rive({ src: CircleFadeInAnimation, - canvas: riveCanvas.value, + canvas, autoplay: true, artboard: isDark.value ? 'Bold' : 'Bold (Light)', }) diff --git a/packages/ui-transitions/playground/src/App.vue b/packages/ui-transitions/playground/src/App.vue index f820f4909..11c351f7e 100644 --- a/packages/ui-transitions/playground/src/App.vue +++ b/packages/ui-transitions/playground/src/App.vue @@ -1,4 +1,4 @@ -