refactor(*): generalize events

This commit is contained in:
Neko Ayaka
2026-08-09 01:34:48 +08:00
parent b90e6726f3
commit 83ea2afbd7
52 changed files with 986 additions and 664 deletions
+2 -3
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
import { isAnalyticsAvailableInBuild, useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { initializeAnalytics, isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/analytics'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
@@ -35,7 +35,6 @@ const settingsAudioDeviceStore = useSettingsAudioDevice()
const { showingSetup } = storeToRefs(onboardingStore)
const { isDark } = useTheme()
const cardStore = useAiriCardStore()
const analyticsStore = useSharedAnalyticsStore()
const primaryColor = computed(() => {
return isDark.value
@@ -73,7 +72,7 @@ watch(settings.themeColorsHueDynamic, () => {
// Initialize first-time setup check when app mounts
onMounted(async () => {
analyticsStore.initialize()
initializeAnalytics()
await displayModelsStore.initialize()
cardStore.initialize()
+2 -2
View File
@@ -7,7 +7,7 @@ import NProgress from 'nprogress'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { isEnvTruthy } from '@proj-airi/stage-shared'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/stores/analytics/client'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/libs/analytics'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
@@ -29,7 +29,7 @@ import './styles/main.css'
import 'uno.css'
configureAnalyticsAdapter(async (options) => {
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/stores/analytics/posthog')
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/libs/analytics/posthog')
return createPosthogAdapter(options)
})
+2 -3
View File
@@ -6,8 +6,8 @@ import { artistrySyncConfig } from '@proj-airi/stage-shared'
import { ToasterRoot } from '@proj-airi/stage-ui/components'
import { useInferencePreload } from '@proj-airi/stage-ui/composables'
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
import { initializeAnalytics } from '@proj-airi/stage-ui/libs/analytics'
import { usePiniaSynced } from '@proj-airi/stage-ui/libs/pinia'
import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
@@ -119,7 +119,6 @@ function createFullStageRuntime() {
const cardStore = useAiriCardStore()
const serverChannelStore = useModsServerChannelStore()
const characterOrchestratorStore = useCharacterOrchestratorStore()
const analyticsStore = useSharedAnalyticsStore()
const inferencePreload = useInferencePreload()
const pluginHostInspectorStore = usePluginHostInspectorStore()
const stageWindowLifecycleStore = useStageWindowLifecycleStore()
@@ -214,7 +213,7 @@ function createFullStageRuntime() {
return {
async initialize() {
analyticsStore.initialize()
initializeAnalytics()
await displayModelsStore.initialize()
cardStore.initialize()
+2 -2
View File
@@ -6,8 +6,8 @@ import Tres from '@tresjs/core'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { PiniaColada } from '@pinia/colada'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/libs/analytics'
import { setupSynced } from '@proj-airi/stage-ui/libs/pinia'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/stores/analytics/client'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
@@ -40,7 +40,7 @@ import '@fontsource/m-plus-rounded-1c/index.css'
import '@fontsource-variable/nunito/index.css'
configureAnalyticsAdapter(async (options) => {
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/stores/analytics/posthog')
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/libs/analytics/posthog')
return createPosthogAdapter(options)
})
@@ -7,11 +7,9 @@ import semver from 'semver'
import { useElectronAutoUpdater, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { AboutContent, BugReportDialog, createBugReportPageContext, MarkdownRenderer } from '@proj-airi/stage-ui/components'
import { useAnalytics, useBreakpoints } from '@proj-airi/stage-ui/composables'
import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { useAnalytics, useBreakpoints, useBuildInfo } from '@proj-airi/stage-ui/composables'
import { Button, ContainerError, DoubleCheckButton, FieldSelect, Progress } from '@proj-airi/ui'
import { useClipboard } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { DrawerContent, DrawerDescription, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTitle } from 'vaul-vue'
import { computed, onMounted, ref, watch } from 'vue'
@@ -19,8 +17,7 @@ import { useI18n } from 'vue-i18n'
import { electronGetUpdaterPreferences, electronSetUpdaterPreferences } from '../../shared/eventa'
const analyticsStore = useSharedAnalyticsStore()
const { buildInfo } = storeToRefs(analyticsStore)
const buildInfo = useBuildInfo()
const { t } = useI18n()
const { copy: copyToClipboard, isSupported: isClipboardSupported } = useClipboard()
@@ -115,7 +112,7 @@ function normalizeSemver(version: string | undefined) {
}
const isDowngradeUpdate = computed(() => {
const currentVersion = normalizeSemver(buildInfo.value.version)
const currentVersion = normalizeSemver(buildInfo.version)
const targetVersion = normalizeSemver(updateState.value.info?.version)
if (!currentVersion || !targetVersion)
return false
@@ -152,7 +149,7 @@ function confirmDownload() {
function openBugReportDialog() {
const details = [
`Current version: ${buildInfo.value.version}`,
`Current version: ${buildInfo.version}`,
`Update status: ${updateState.value.status}`,
updaterErrorMessage.value ? `Error: ${updaterErrorMessage.value}` : '',
]
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { OnboardingScreen, OnboardingStepAnalyticsNotice } from '@proj-airi/stage-ui/components'
import { isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/stores/analytics'
import { isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/analytics'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useTheme } from '@proj-airi/ui'
+2 -3
View File
@@ -2,7 +2,7 @@
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useInferencePreload } from '@proj-airi/stage-ui/composables'
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
import { isAnalyticsAvailableInBuild, useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { initializeAnalytics, isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/analytics'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
@@ -39,7 +39,6 @@ const settingsAudioDeviceStore = useSettingsAudioDevice()
const { showingSetup } = storeToRefs(onboardingStore)
const { isDark } = useTheme()
const cardStore = useAiriCardStore()
const analyticsStore = useSharedAnalyticsStore()
const inferencePreload = useInferencePreload()
const primaryColor = computed(() => {
@@ -84,7 +83,7 @@ watch(settings.themeColorsHueDynamic, () => {
// Initialize first-time setup check when app mounts
onMounted(async () => {
analyticsStore.initialize()
initializeAnalytics()
await displayModelsStore.initialize()
cardStore.initialize()
+2 -2
View File
@@ -8,8 +8,8 @@ import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { PiniaColada } from '@pinia/colada'
import { isEnvTruthy } from '@proj-airi/stage-shared'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/libs/analytics'
import { setupSynced } from '@proj-airi/stage-ui/libs/pinia'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/stores/analytics/client'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
@@ -30,7 +30,7 @@ import './styles/main.css'
import 'uno.css'
configureAnalyticsAdapter(async (options) => {
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/stores/analytics/posthog')
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/libs/analytics/posthog')
return createPosthogAdapter(options)
})
+10
View File
@@ -22,6 +22,16 @@ export function isStageTamagotchi(): boolean {
return import.meta.env.RUNTIME_ENVIRONMENT === 'electron'
}
export function getStage() {
if (isStageTamagotchi())
return StageEnvironment.Tamagotchi
if (isStageCapacitor())
return StageEnvironment.Capacitor
return StageEnvironment.Web
}
export function isUrlMode(mode: 'file' | 'server'): boolean {
if (!import.meta.env.URL_MODE) {
return mode === 'server'
+2 -5
View File
@@ -29,18 +29,15 @@
"./directives/*": "./src/directives/*.ts",
"./libs/inference/adapters/*": "./src/libs/inference/adapters/*.ts",
"./libs/inference": "./src/libs/inference/index.ts",
"./libs/analytics": "./src/libs/analytics/index.ts",
"./libs/analytics/*": "./src/libs/analytics/*.ts",
"./libs/pinia": "./src/libs/pinia/index.ts",
"./libs/*": "./src/libs/*.ts",
"./libs": "./src/libs/index.ts",
"./services/*": "./src/services/*.ts",
"./tools/mcp": "./src/tools/mcp.ts",
"./stores/providers/aliyun": "./src/stores/providers/aliyun/index.ts",
"./stores/analytics": "./src/stores/analytics/index.ts",
"./stores/analytics/client": "./src/stores/analytics/client.ts",
"./stores/analytics/posthog": "./src/stores/analytics/posthog.ts",
"./stores/analytics/privacy-policy": "./src/stores/analytics/privacy-policy.ts",
"./stores/character": "./src/stores/character/index.ts",
"./stores/character/orchestrator/spark-notify-agent": "./src/stores/character/orchestrator/spark-notify-agent.ts",
"./stores/settings/analytics": "./src/stores/settings/analytics.ts",
"./stores/settings": "./src/stores/settings/index.ts",
"./stores/modules/vision": "./src/stores/modules/vision/index.ts",
@@ -8,11 +8,16 @@ const analyticsMocks = vi.hoisted(() => ({
isStageCapacitorMock: vi.fn(() => false),
isStageTamagotchiMock: vi.fn(() => false),
isAnalyticsAvailableInBuildMock: vi.fn(() => true),
markFirstMessageTrackedMock: vi.fn(),
recordFirstMessageMock: vi.fn(() => true),
posthogCaptureMock: vi.fn(),
}))
vi.mock('@proj-airi/stage-shared', () => ({
getStage: () => analyticsMocks.isStageTamagotchiMock()
? 'tamagotchi'
: analyticsMocks.isStageCapacitorMock()
? 'capacitor'
: 'web',
isStageCapacitor: analyticsMocks.isStageCapacitorMock,
isStageTamagotchi: analyticsMocks.isStageTamagotchiMock,
}))
@@ -23,22 +28,20 @@ vi.mock('vue-i18n', () => ({
}),
}))
vi.mock('../stores/analytics', () => ({
useSharedAnalyticsStore: () => ({
appStartTime: null,
firstMessageTracked: false,
markFirstMessageTracked: analyticsMocks.markFirstMessageTrackedMock,
}),
}))
vi.mock('../stores/analytics/client', () => ({
vi.mock('../libs/analytics', () => ({
captureAnalyticsEvent: analyticsMocks.posthogCaptureMock,
ensureAnalyticsInitialized: analyticsMocks.ensureAnalyticsInitializedMock,
isAnalyticsAvailableInBuild: analyticsMocks.isAnalyticsAvailableInBuildMock,
}))
enableAnalytics: analyticsMocks.ensureAnalyticsInitializedMock,
getAnalytics: () => ({
emit: (event: { name: string }, payload: object, options?: object) => {
if (options)
return analyticsMocks.posthogCaptureMock(event.name, payload, options)
vi.mock('../stores/analytics/privacy-policy', () => ({
return analyticsMocks.posthogCaptureMock(event.name, payload)
},
recordFirstMessage: analyticsMocks.recordFirstMessageMock,
}),
getAnalyticsPrivacyPolicyUrl: () => 'https://example.com/privacy',
isAnalyticsAvailableInBuild: analyticsMocks.isAnalyticsAvailableInBuildMock,
}))
vi.mock('../stores/settings/analytics', () => ({
@@ -56,7 +59,7 @@ vi.mock('../stores/settings/general', () => ({
describe('useAnalytics conversation product events', () => {
beforeEach(() => {
analyticsMocks.posthogCaptureMock.mockClear()
analyticsMocks.markFirstMessageTrackedMock.mockClear()
analyticsMocks.recordFirstMessageMock.mockClear()
analyticsMocks.ensureAnalyticsInitializedMock.mockClear()
analyticsMocks.isStageCapacitorMock.mockReset()
analyticsMocks.isStageTamagotchiMock.mockReset()
@@ -995,14 +998,14 @@ describe('useAnalytics conversation product events', () => {
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'controls_island_action', {
action: 'toggle_chat',
app_surface: 'electron',
environment: 'tamagotchi',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(
2,
'controls_island_action',
{
action: 'refresh_window',
app_surface: 'electron',
environment: 'tamagotchi',
},
{ beforeNavigation: true },
)
@@ -1032,8 +1035,8 @@ describe('useAnalytics conversation product events', () => {
{ channel: 'stable', version: '0.11.0' },
{ beforeNavigation: true },
)
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'mcp_server_added', {})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'mcp_server_removed', {})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'mcp_server_updated', { action: 'add' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'mcp_server_updated', { action: 'remove' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'mcp_connection_test_run', { success: false })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(9, 'device_pairing_qr_shown', {})
})
@@ -1,14 +1,12 @@
import type { ControlsIslandAction } from '../stores/analytics/button-events'
import type { ControlsIslandAction } from '../libs/analytics/events/controls-island'
import type { SpeechOutputStopReason } from '../stores/speech-output-control'
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSharedAnalyticsStore } from '../stores/analytics'
import { captureTrackButtonEvent } from '../stores/analytics/button-events'
import { captureAnalyticsEvent, ensureAnalyticsInitialized, isAnalyticsAvailableInBuild } from '../stores/analytics/client'
import { getAnalyticsPrivacyPolicyUrl } from '../stores/analytics/privacy-policy'
import { captureAnalyticsEvent, enableAnalytics, getAnalytics, getAnalyticsPrivacyPolicyUrl, isAnalyticsAvailableInBuild } from '../libs/analytics'
import { captureTrackButtonEvent } from '../libs/analytics/events/interaction'
import { useSettingsAnalytics } from '../stores/settings/analytics'
import { useSettingsGeneral } from '../stores/settings/general'
@@ -40,7 +38,7 @@ export type MessageInputMode = 'text' | 'voice'
export type ConversationEventSource = 'new_session' | 'fork' | 'history' | 'share_button' | 'unknown'
export type AiUsageSource = 'reported' | 'estimated' | 'unavailable'
/** Stable, low-cardinality actions emitted by the Electron controls island. */
export type { ControlsIslandAction } from '../stores/analytics/button-events'
export type { ControlsIslandAction } from '../libs/analytics/events/controls-island'
/**
* Full stage vocabulary of the cross-surface `oauth_callback_failed` event.
@@ -124,7 +122,7 @@ export function getConversationAnalyticsSurface(): ConversationAnalyticsSurface
}
export function useAnalytics() {
const analyticsStore = useSharedAnalyticsStore()
const analytics = getAnalytics()
const settingsAnalytics = useSettingsAnalytics()
const settingsGeneral = useSettingsGeneral()
const { locale } = useI18n()
@@ -137,7 +135,7 @@ export function useAnalytics() {
if (!isAnalyticsEnabled.value)
return false
return ensureAnalyticsInitialized(true)
return enableAnalytics()
}
function trackProviderClick(providerId: string, module: string) {
@@ -154,20 +152,7 @@ export function useAnalytics() {
if (!canCapture())
return
// Only track the first message once
if (analyticsStore.firstMessageTracked)
return
analyticsStore.markFirstMessageTracked()
// Calculate time from app start to message sent
const timeToFirstMessageMs = analyticsStore.appStartTime
? Date.now() - analyticsStore.appStartTime
: null
captureAnalyticsEvent('first_message_sent', {
time_to_first_message_ms: timeToFirstMessageMs,
})
analytics.recordFirstMessage()
}
/**
@@ -1233,11 +1218,11 @@ export function useAnalytics() {
}
function trackMcpServerAdded() {
captureTrackButtonEvent({ name: 'mcp_server_added' })
captureTrackButtonEvent({ name: 'mcp_server_updated', action: 'add' })
}
function trackMcpServerRemoved() {
captureTrackButtonEvent({ name: 'mcp_server_removed' })
captureTrackButtonEvent({ name: 'mcp_server_updated', action: 'remove' })
}
function trackMcpConnectionTestRun(properties: { success: boolean }) {
@@ -63,7 +63,7 @@ describe('createTrackButtonDirective', () => {
const app = createApp({
render: () => withDirectives(
h('button'),
[[directive, { name: 'mcp_server_added' } satisfies TrackButtonEvent]],
[[directive, { name: 'mcp_server_updated', action: 'add' } satisfies TrackButtonEvent]],
),
})
@@ -1,10 +1,11 @@
import type { ObjectDirective, Plugin } from 'vue'
import type { TrackButtonEvent } from '../stores/analytics/button-events'
import type { TrackButtonEvent } from '../libs/analytics/events/interaction'
import { captureTrackButtonEvent } from '../stores/analytics/button-events'
import { captureTrackButtonEvent } from '../libs/analytics/events/interaction'
export type { ControlsIslandAction, TrackButtonEvent } from '../stores/analytics/button-events'
export type { ControlsIslandAction } from '../libs/analytics/events/controls-island'
export type { TrackButtonEvent } from '../libs/analytics/events/interaction'
/**
* Creates the DOM directive used by the app-level tracking plugin.
@@ -35,13 +35,7 @@ describe('analytics client', () => {
expect(adapter.identify).toHaveBeenCalledWith('user-1')
})
// ROOT CAUSE:
//
// A content blocker rejects the provider adapter's dynamic module request.
// If that rejection escapes into application startup, Vue never mounts.
//
// The client converts provider loading failure into a permanent no-op state.
it('fails open when a content blocker rejects the provider adapter', async () => {
it('degrades to a no-op when a content blocker rejects the provider adapter', async () => {
const client = new AnalyticsClient(async () => {
throw new TypeError('Failed to fetch dynamically imported module')
})
@@ -3,23 +3,19 @@ import type { AboutBuildInfo } from '../../components/scenarios/about/types'
import { isEnvTruthy } from '@proj-airi/stage-shared'
export interface AnalyticsIdentitySnapshot {
/** Current provider distinct id for the browser/device/user person. */
/** Current provider distinct id for the browser, device, or user. */
distinctId: string
/** Current provider session id, when one has been established. */
/** Current provider session id, when one exists. */
sessionId?: string
}
/** Provider-neutral delivery hints for one product event. */
/** Provider-neutral delivery options for one product event. */
export interface AnalyticsCaptureOptions {
/**
* Indicates that document navigation immediately follows capture, allowing
* the adapter to select an unload-safe delivery mechanism.
* @default false
*/
/** Selects an unload-safe delivery method before document navigation. */
beforeNavigation?: boolean
}
/** Provider contract installed behind the shared analytics façade. */
/** Provider contract behind the product analytics facade. */
export interface AnalyticsAdapter {
capture: (name: string, properties: object, options?: AnalyticsCaptureOptions) => boolean
getIdentitySnapshot: () => AnalyticsIdentitySnapshot | null
@@ -29,13 +25,12 @@ export interface AnalyticsAdapter {
setCaptureEnabled: (enabled: boolean) => boolean
}
/** Initialization state supplied to an analytics adapter loader. */
/** Initial state given to an asynchronously loaded analytics provider. */
export interface AnalyticsAdapterOptions {
/** Whether capture is enabled when the adapter initializes. */
enabled: boolean
}
/** Loads one provider adapter. Rejections permanently degrade the client to no-op. */
/** Loads one analytics provider adapter. */
export type AnalyticsAdapterLoader = (options: AnalyticsAdapterOptions) => Promise<AnalyticsAdapter>
type PendingOperation
@@ -48,7 +43,7 @@ type PendingOperation
type LoadState = 'idle' | 'loading' | 'ready' | 'unavailable'
/**
* Owns an optional provider's lifecycle and keeps provider loading out of the
* Owns an optional provider lifecycle and keeps provider loading outside the
* static application graph. Calls made while loading use a bounded queue.
*/
export class AnalyticsClient {
@@ -138,8 +133,6 @@ export class AnalyticsClient {
return true
})
.catch(() => {
// A denied dynamic module request is expected under content blockers.
// Product events are optional, so core application work stays no-op.
this.pendingOperations.length = 0
this.loadState = 'unavailable'
return false
@@ -149,8 +142,6 @@ export class AnalyticsClient {
}
private enqueue(operation: PendingOperation): void {
// Keep the latest events without letting a slow provider grow memory for
// the lifetime of a long-running desktop session.
if (this.pendingOperations.length === 100)
this.pendingOperations.shift()
this.pendingOperations.push(operation)
@@ -183,13 +174,13 @@ export class AnalyticsClient {
}
let adapterLoader: AnalyticsAdapterLoader | undefined
const analytics = new AnalyticsClient(async (options) => {
const analyticsClient = new AnalyticsClient(async (options) => {
if (!adapterLoader)
throw new Error('No analytics adapter has been configured')
return adapterLoader(options)
})
/** Configures the provider loader before the shared store initializes. */
/** Configures the provider adapter before analytics starts. */
export function configureAnalyticsAdapter(loader: AnalyticsAdapterLoader): void {
adapterLoader = loader
}
@@ -198,38 +189,34 @@ export function isAnalyticsAvailableInBuild(): boolean {
return isEnvTruthy(import.meta.env.VITE_ENABLE_POSTHOG)
}
export function ensureAnalyticsInitialized(enabled: boolean): boolean {
export function enableAnalyticsCapture(): boolean {
if (!isAnalyticsAvailableInBuild() || !adapterLoader)
return false
return analytics.ensureInitialized(enabled)
return analyticsClient.syncCapture(true)
}
export function syncAnalyticsCapture(enabled: boolean): boolean {
export function disableAnalyticsCapture(): void {
if (!isAnalyticsAvailableInBuild() || !adapterLoader)
return false
return analytics.syncCapture(enabled)
}
export function registerAnalyticsBuildInfo(buildInfo: AboutBuildInfo): void {
analytics.registerBuildInfo(buildInfo)
}
export function identifyAnalyticsUser(userId: string): void {
analytics.identify(userId)
}
export function resetAnalyticsIdentity(): void {
analytics.resetIdentity()
return
analyticsClient.syncCapture(false)
}
export function getAnalyticsIdentitySnapshot(): AnalyticsIdentitySnapshot | null {
return analytics.getIdentitySnapshot()
return analyticsClient.getIdentitySnapshot()
}
/**
* Emits an event through the configured provider. Returns `false` when capture
* is disabled or the provider is unavailable, allowing callers to gate dedup.
*/
export function identifyAnalyticsUser(userId: string): void {
analyticsClient.identify(userId)
}
export function registerAnalyticsBuildInfo(buildInfo: AboutBuildInfo): void {
analyticsClient.registerBuildInfo(buildInfo)
}
export function resetAnalyticsIdentity(): void {
analyticsClient.resetIdentity()
}
export function captureAnalyticsEvent(name: string, properties: object, options?: AnalyticsCaptureOptions): boolean {
return analytics.capture(name, properties, options)
return analyticsClient.capture(name, properties, options)
}
@@ -0,0 +1,51 @@
import { defineEvent } from '../../../utils/dsl'
export const appLoadedEvent = defineEvent<{
platform: 'web' | 'desktop' | 'mobile'
version: string
}>('app_loaded')
export const analyticsSettingChangedEvent = defineEvent<{
setting_name: 'analytics_enabled'
previous_value: boolean
new_value: boolean
source: 'settings'
app_surface: 'web' | 'desktop' | 'mobile'
}>('settings_changed')
export const firstMessageSentEvent = defineEvent<{
time_to_first_message_ms: number | null
}>('first_message_sent')
export const firstModelSelectedEvent = defineEvent<{
model_id: string
provider: string
}>('first_model_selected')
export const providerSwitchedEvent = defineEvent<{
from_provider: string
to_provider: string
from_provider_type: 'official' | 'custom' | 'unknown'
to_provider_type: 'official' | 'custom' | 'unknown'
reason: 'manual'
app_surface: 'web' | 'desktop' | 'mobile'
}>('provider_switched')
export const modelSwitchedEvent = defineEvent<{
from_model: string
to_model: string
reason: 'manual'
}>('model_switched')
export const modelChangedEvent = defineEvent<{
from_model: string
to_model: string
provider: string
reason: 'manual'
app_surface: 'web' | 'desktop' | 'mobile'
}>('model_changed')
export const characterSwitchedEvent = defineEvent<{
from_character_id: string
to_character_id: string
}>('character_switched')
@@ -0,0 +1 @@
export * from './events/lifecycle'
@@ -0,0 +1,22 @@
import { defineEvent } from '../../../utils/dsl'
interface ChatActivationProperties {
conversation_id: string
round_id: string
turn_index: number
provider_mode: 'official' | 'custom' | 'unknown'
provider_id: string
model_id: string
source: 'text' | 'voice'
}
export const chatActivationStartedEvent = defineEvent<ChatActivationProperties>('chat_activation_started')
export const chatActivationSucceededEvent = defineEvent<ChatActivationProperties & {
time_to_first_message_ms?: number
}>('chat_activation_succeeded')
export const chatActivationFailedEvent = defineEvent<ChatActivationProperties & {
error_code?: string
failure_stage?: string
}>('chat_activation_failed')
@@ -0,0 +1,13 @@
import { defineEvent } from '../../../utils/dsl'
export const aiGenerationEvent = defineEvent<{
conversation_id: string
round_id: string
provider_type: 'official' | 'custom' | 'unknown'
provider_id: string
model_id: string
usage_source: 'reported' | 'estimated' | 'unavailable'
input_tokens?: number
output_tokens?: number
total_tokens?: number
}>('$ai_generation')
@@ -0,0 +1,4 @@
export * from './activation'
export * from './generation'
export * from './message'
export * from './round'
@@ -0,0 +1,25 @@
import { defineEvent } from '../../../utils/dsl'
export const messageSentEvent = defineEvent<{
conversation_id: string
provider_type: 'official' | 'custom' | 'unknown'
provider_name: string
model: string
message_id: string
round_id: string
turn_index: number
message_index: number
message_length: number
has_attachment: boolean
mode: 'text' | 'voice'
}>('message_sent')
export const secondTurnStartedEvent = defineEvent<{
conversation_id: string
provider_mode: 'official' | 'custom' | 'unknown'
provider_id: string
model_id: string
round_id: string
source: 'text' | 'voice'
turn_index: number
}>('second_turn_started')
@@ -0,0 +1,46 @@
import { defineEvent } from '../../../utils/dsl'
interface ChatRoundProperties {
conversation_id: string
round_id: string
turn_index: number
}
export const messageSendStartedEvent = defineEvent<ChatRoundProperties & {
source: 'text' | 'voice'
model?: string
}>('message_send_started')
export const llmRequestStartedEvent = defineEvent<ChatRoundProperties & {
model: string
provider: string
has_voice: boolean
}>('llm_request_started')
export const llmFirstTokenEvent = defineEvent<ChatRoundProperties & {
model: string
ttfb_ms: number
}>('llm_first_token')
export const assistantResponseRenderedEvent = defineEvent<ChatRoundProperties & {
model: string
latency_ms: number
}>('assistant_response_rendered')
export const messageRoundEvent = defineEvent<ChatRoundProperties & {
duration_ms: number
has_voice: boolean
model: string
input_tokens?: number
output_tokens?: number
total_tokens?: number
usage_source?: 'reported' | 'estimated' | 'unavailable'
}>('message_round')
export const messageRoundFailedEvent = defineEvent<ChatRoundProperties & {
provider_id: string
model_id: string
source: 'text' | 'voice'
error_code?: string
failure_stage?: string
}>('message_round_failed')
@@ -0,0 +1,3 @@
export * from './events'
export * from './runtime'
export * from './types'
@@ -0,0 +1,98 @@
import type { AnalyticsRecorder } from '../../index'
import { describe, expect, it, vi } from 'vitest'
import { aiGenerationEvent, messageSentEvent, secondTurnStartedEvent } from './events'
import { createChatAnalyticsHooks } from './runtime'
function createRecorder(): AnalyticsRecorder {
return {
emit: vi.fn(() => true),
recordFirstMessage: vi.fn(() => true),
}
}
describe('createChatAnalyticsHooks', () => {
it('keeps the runtime provider, model, and source when it records a user turn', () => {
const analytics = createRecorder()
const hooks = createChatAnalyticsHooks({
analytics,
getSessionMessages: () => [{ role: 'user', content: 'Hello' }],
})
hooks.onUserMessageAppended?.({
sessionId: 'session-1',
message: { role: 'user', content: 'Hello', id: 'message-1' },
messageText: 'Hello',
source: 'voice',
model: 'selected-model',
provider: 'official-provider-chat',
roundId: 'round-1',
turnIndex: 2,
})
expect(analytics.emit).toHaveBeenCalledWith(messageSentEvent, {
conversation_id: 'session-1',
provider_type: 'official',
provider_name: 'official-provider-chat',
model: 'selected-model',
message_id: 'message-1',
round_id: 'round-1',
turn_index: 2,
message_index: 1,
message_length: 5,
has_attachment: false,
mode: 'voice',
})
expect(analytics.emit).toHaveBeenCalledWith(secondTurnStartedEvent, {
conversation_id: 'session-1',
provider_mode: 'official',
provider_id: 'official-provider-chat',
model_id: 'selected-model',
round_id: 'round-1',
source: 'voice',
turn_index: 2,
})
})
it('records generation usage only for custom providers', () => {
const analytics = createRecorder()
const hooks = createChatAnalyticsHooks({
analytics,
getSessionMessages: () => [],
})
hooks.onLlmGeneration?.({
conversationId: 'session-1',
roundId: 'round-1',
turnIndex: 1,
model: 'custom-model',
provider: 'custom-provider',
inputTokens: 12,
outputTokens: 8,
totalTokens: 20,
usageSource: 'reported',
})
hooks.onLlmGeneration?.({
conversationId: 'session-1',
roundId: 'round-2',
turnIndex: 2,
model: 'official-model',
provider: 'official-provider-chat',
usageSource: 'reported',
})
expect(analytics.emit).toHaveBeenCalledTimes(1)
expect(analytics.emit).toHaveBeenCalledWith(aiGenerationEvent, {
conversation_id: 'session-1',
round_id: 'round-1',
provider_type: 'custom',
provider_id: 'custom-provider',
model_id: 'custom-model',
usage_source: 'reported',
input_tokens: 12,
output_tokens: 8,
total_tokens: 20,
})
})
})
@@ -0,0 +1,203 @@
import type { ChatOrchestratorRuntimeDeps } from '@proj-airi/core-agent'
import type { ChatHistoryItem } from '../../../../types/chat'
import type { AnalyticsRecorder } from '../../index'
import { getAnalytics } from '../../index'
import {
aiGenerationEvent,
assistantResponseRenderedEvent,
chatActivationFailedEvent,
chatActivationStartedEvent,
chatActivationSucceededEvent,
llmFirstTokenEvent,
llmRequestStartedEvent,
messageRoundEvent,
messageRoundFailedEvent,
messageSendStartedEvent,
messageSentEvent,
secondTurnStartedEvent,
} from './events'
import { getProviderMode } from './types'
type ChatAnalyticsCallbacks = Pick<
ChatOrchestratorRuntimeDeps,
| 'onAssistantResponseRendered'
| 'onChatActivationFailed'
| 'onChatActivationStarted'
| 'onChatActivationSucceeded'
| 'onLlmFirstToken'
| 'onLlmGeneration'
| 'onLlmRequestStarted'
| 'onMessageRound'
| 'onMessageRoundFailed'
| 'onMessageSendStarted'
| 'onTrackFirstMessage'
| 'onUserMessageAppended'
>
/** Options used to bind analytics to one chat runtime. */
export interface CreateChatAnalyticsHooksOptions {
/** Reads the message count after the runtime persists a user message. */
getSessionMessages: (sessionId: string) => ChatHistoryItem[]
/** Typed analytics recorder. Defaults to the module-global recorder. */
analytics?: AnalyticsRecorder
}
/**
* Maps core chat runtime callbacks to typed chat product events.
*
* The chat store owns session state and streaming. This module owns event
* names, payload projection, and the provider-cardinality policy.
*/
export function createChatAnalyticsHooks(options: CreateChatAnalyticsHooksOptions): ChatAnalyticsCallbacks {
const analytics = options.analytics ?? getAnalytics()
return {
onTrackFirstMessage: () => analytics.recordFirstMessage(),
onMessageSendStarted: ({ conversationId, roundId, turnIndex, source, model }) => {
analytics.emit(messageSendStartedEvent, {
conversation_id: conversationId,
round_id: roundId,
turn_index: turnIndex,
source,
model,
})
},
onLlmRequestStarted: ({ conversationId, roundId, turnIndex, model, provider, hasVoice }) => {
analytics.emit(llmRequestStartedEvent, {
conversation_id: conversationId,
round_id: roundId,
turn_index: turnIndex,
model,
provider,
has_voice: hasVoice,
})
},
onLlmFirstToken: ({ conversationId, roundId, turnIndex, model, ttfbMs }) => {
analytics.emit(llmFirstTokenEvent, {
conversation_id: conversationId,
round_id: roundId,
turn_index: turnIndex,
model,
ttfb_ms: ttfbMs,
})
},
onAssistantResponseRendered: ({ conversationId, roundId, turnIndex, model, latencyMs }) => {
analytics.emit(assistantResponseRenderedEvent, {
conversation_id: conversationId,
round_id: roundId,
turn_index: turnIndex,
model,
latency_ms: latencyMs,
})
},
onLlmGeneration: ({ conversationId, roundId, model, provider, inputTokens, outputTokens, totalTokens, usageSource }) => {
const providerType = getProviderMode(provider)
if (providerType !== 'custom')
return
analytics.emit(aiGenerationEvent, {
conversation_id: conversationId,
round_id: roundId,
provider_type: providerType,
provider_id: provider,
model_id: model,
usage_source: usageSource,
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: totalTokens,
})
},
onMessageRound: ({ conversationId, roundId, turnIndex, durationMs, hasVoice, model, inputTokens, outputTokens, totalTokens, usageSource }) => {
analytics.emit(messageRoundEvent, {
conversation_id: conversationId,
round_id: roundId,
turn_index: turnIndex,
duration_ms: durationMs,
has_voice: hasVoice,
model,
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: totalTokens,
usage_source: usageSource,
})
},
onMessageRoundFailed: ({ conversationId, roundId, turnIndex, model, provider, errorCode, failureStage, source }) => {
analytics.emit(messageRoundFailedEvent, {
conversation_id: conversationId,
round_id: roundId,
turn_index: turnIndex,
provider_id: provider || 'unknown',
model_id: model || 'unknown',
source,
error_code: errorCode,
failure_stage: failureStage,
})
},
onChatActivationStarted: ({ conversationId, roundId, turnIndex, model, provider, source }) => {
analytics.emit(chatActivationStartedEvent, {
conversation_id: conversationId,
provider_mode: getProviderMode(provider),
provider_id: provider || 'unknown',
model_id: model || 'unknown',
round_id: roundId,
source,
turn_index: turnIndex,
})
},
onChatActivationSucceeded: ({ conversationId, roundId, turnIndex, model, provider, durationMs, source }) => {
analytics.emit(chatActivationSucceededEvent, {
conversation_id: conversationId,
provider_mode: getProviderMode(provider),
provider_id: provider || 'unknown',
model_id: model || 'unknown',
round_id: roundId,
time_to_first_message_ms: durationMs,
source,
turn_index: turnIndex,
})
},
onChatActivationFailed: ({ conversationId, roundId, turnIndex, model, provider, errorCode, failureStage, source }) => {
analytics.emit(chatActivationFailedEvent, {
conversation_id: conversationId,
provider_mode: getProviderMode(provider),
provider_id: provider || 'unknown',
model_id: model || 'unknown',
round_id: roundId,
error_code: errorCode,
failure_stage: failureStage,
source,
turn_index: turnIndex,
})
},
onUserMessageAppended: ({ sessionId, message, messageText, source, model, provider, roundId, turnIndex }) => {
const providerType = getProviderMode(provider)
analytics.emit(messageSentEvent, {
conversation_id: sessionId,
provider_type: providerType,
provider_name: provider || 'unknown',
model: model || 'unknown',
message_id: message.id,
round_id: roundId,
turn_index: turnIndex,
message_index: options.getSessionMessages(sessionId).length,
message_length: messageText.length,
has_attachment: false,
mode: source,
})
if (turnIndex !== 2)
return
analytics.emit(secondTurnStartedEvent, {
conversation_id: sessionId,
provider_mode: providerType,
provider_id: provider || 'unknown',
model_id: model || 'unknown',
round_id: roundId,
source,
turn_index: turnIndex,
})
},
}
}
@@ -0,0 +1,16 @@
/** Provider classes allowed in low-cardinality chat analytics properties. */
export type ProviderMode = 'official' | 'custom' | 'unknown'
/** Failure stages emitted by a chat activation or message round. */
export type ChatActivationFailureStage = 'provider_config' | 'model_list' | 'message_send' | 'llm_response' | 'tts'
/** Source of token accounting data for one chat generation. */
export type AiUsageSource = 'reported' | 'estimated' | 'unavailable'
/** Converts a provider identifier to the product analytics provider class. */
export function getProviderMode(providerId: string | undefined): ProviderMode {
if (!providerId)
return 'unknown'
return providerId.startsWith('official-provider') ? 'official' : 'custom'
}
@@ -0,0 +1,25 @@
import type { StageEnvironment } from '@proj-airi/stage-shared'
import { defineEvent } from '../../../utils/dsl'
/** Stable, low-cardinality actions emitted by the Electron controls island. */
export type ControlsIslandAction
= | 'expand_controls'
| 'collapse_controls'
| 'toggle_settings'
| 'toggle_profile_picker'
| 'toggle_chat'
| 'refresh_window'
| 'center_main_window'
| 'switch_to_light_mode'
| 'switch_to_dark_mode'
| 'pin_on_top'
| 'unpin_from_top'
| 'enable_fade_on_hover'
| 'disable_fade_on_hover'
| 'close_app'
export const controlsIslandActionEvent = defineEvent<{
action: ControlsIslandAction
environment: StageEnvironment
}>('controls_island_action')
@@ -0,0 +1 @@
export * from './events/action'
@@ -0,0 +1,6 @@
export * from './app'
export * from './chat'
export * from './controls-island'
export * from './interaction'
export * from './mcp'
export * from './update'
@@ -0,0 +1,53 @@
import type { ControlsIslandAction } from './controls-island'
import { getStage } from '@proj-airi/stage-shared'
import { useSettingsAnalytics } from '../../../stores/settings/analytics'
import { enableAnalytics, getAnalytics, isAnalyticsAvailableInBuild } from '../index'
import { controlsIslandActionEvent } from './controls-island'
import { mcpServerUpdatedEvent } from './mcp'
import { updateCheckClickedEvent, updateInstallClickedEvent } from './update'
/** User interaction events emitted by the shared tracking directive. */
export type TrackButtonEvent
= | { name: 'controls_island_action', action: ControlsIslandAction }
| { name: 'update_check_clicked', channel: string }
| { name: 'update_install_clicked', channel: string, version?: string }
| { name: 'mcp_server_updated', action: 'add' | 'remove' }
function canCapture(): boolean {
return isAnalyticsAvailableInBuild()
&& useSettingsAnalytics().analyticsEnabled
&& enableAnalytics()
}
/** Sends a typed interaction event through the shared consent boundary. */
export function captureTrackButtonEvent(event: TrackButtonEvent): void {
if (!canCapture())
return
const analytics = getAnalytics()
switch (event.name) {
case 'controls_island_action':
analytics.emit(
controlsIslandActionEvent,
{ action: event.action, environment: getStage() },
event.action === 'refresh_window' || event.action === 'close_app'
? { beforeNavigation: true }
: undefined,
)
return
case 'update_check_clicked':
analytics.emit(updateCheckClickedEvent, { channel: event.channel })
return
case 'update_install_clicked':
analytics.emit(updateInstallClickedEvent, {
channel: event.channel,
...(event.version && { version: event.version }),
}, { beforeNavigation: true })
return
case 'mcp_server_updated':
analytics.emit(mcpServerUpdatedEvent, { action: event.action })
}
}
@@ -0,0 +1,6 @@
import { defineEvent } from '../../../utils/dsl'
/** Changes to the set of MCP servers configured by the user. */
export const mcpServerUpdatedEvent = defineEvent<{
action: 'add' | 'remove'
}>('mcp_server_updated')
@@ -0,0 +1 @@
export * from './events/server'
@@ -0,0 +1,8 @@
import { defineEvent } from '../../../utils/dsl'
export const updateCheckClickedEvent = defineEvent<{ channel: string }>('update_check_clicked')
export const updateInstallClickedEvent = defineEvent<{
channel: string
version?: string
}>('update_install_clicked')
@@ -0,0 +1 @@
export * from './events/update'
@@ -0,0 +1,247 @@
import type { AnalyticsCaptureOptions } from './client'
import type { AnalyticsEvent, InferAnalyticsEventPayload } from './utils/dsl'
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
import { watch } from 'vue'
import { useBuildInfo } from '../../composables/use-build-info'
import { useAuthStore } from '../../stores/auth'
import { useAiriCardStore } from '../../stores/modules/airi-card'
import { useConsciousnessStore } from '../../stores/modules/consciousness'
import { useSettingsAnalytics } from '../../stores/settings/analytics'
import {
captureAnalyticsEvent,
configureAnalyticsAdapter,
disableAnalyticsCapture,
enableAnalyticsCapture,
getAnalyticsIdentitySnapshot,
identifyAnalyticsUser,
isAnalyticsAvailableInBuild,
registerAnalyticsBuildInfo,
resetAnalyticsIdentity,
} from './client'
import {
analyticsSettingChangedEvent,
appLoadedEvent,
characterSwitchedEvent,
firstMessageSentEvent,
firstModelSelectedEvent,
modelChangedEvent,
modelSwitchedEvent,
providerSwitchedEvent,
} from './events/app'
import { getProviderMode } from './events/chat/types'
export * from './client'
export * from './events'
export * from './privacy-policy'
export type { AnalyticsEvent, InferAnalyticsEventPayload } from './utils/dsl'
export { defineEvent } from './utils/dsl'
type AnyAnalyticsEvent = AnalyticsEvent<object>
/** Minimal analytics boundary that product event modules can depend on. */
export interface AnalyticsRecorder {
/** Emits one typed product event when capture is enabled. */
emit: <Event extends AnyAnalyticsEvent>(event: Event, payload: InferAnalyticsEventPayload<Event>, options?: AnalyticsCaptureOptions) => boolean
/** Emits the first-message event once for the current application lifetime. */
recordFirstMessage: () => boolean
}
function analyticsSurface(): 'web' | 'desktop' | 'mobile' {
return isStageTamagotchi()
? 'desktop'
: isStageCapacitor()
? 'mobile'
: 'web'
}
/**
* Owns analytics lifecycle state for one JavaScript runtime.
*
* This is not a Pinia store. The module singleton keeps lifecycle state out of
* product stores while the configured adapter keeps provider SDK calls at the
* transport boundary.
*/
class Analytics implements AnalyticsRecorder {
private appStartTime: number | null = null
private firstMessageRecorded = false
private firstModelSelectedRecorded = false
private initialized = false
initialize(): void {
if (this.initialized)
return
const buildInfo = useBuildInfo()
const settingsAnalytics = useSettingsAnalytics()
const authStore = useAuthStore()
this.appStartTime = Date.now()
if (settingsAnalytics.analyticsEnabled && enableAnalytics()) {
registerAnalyticsBuildInfo(buildInfo)
this.emit(appLoadedEvent, {
platform: analyticsSurface(),
version: buildInfo.version,
})
}
if (authStore.isAuthenticated && authStore.user?.id)
identifyAnalyticsUser(authStore.user.id)
authStore.onAuthenticated(() => {
if (authStore.user?.id)
identifyAnalyticsUser(authStore.user.id)
})
authStore.onLogout(() => {
resetAnalyticsIdentity()
})
watch(() => settingsAnalytics.analyticsEnabled, (enabled, previousEnabled) => {
if (previousEnabled && !enabled) {
this.emit(analyticsSettingChangedEvent, {
setting_name: 'analytics_enabled',
previous_value: previousEnabled,
new_value: enabled,
source: 'settings',
app_surface: analyticsSurface(),
})
}
if (!enabled) {
disableAnalytics()
return
}
if (!enableAnalytics())
return
if (!previousEnabled) {
this.emit(analyticsSettingChangedEvent, {
setting_name: 'analytics_enabled',
previous_value: previousEnabled,
new_value: enabled,
source: 'settings',
app_surface: analyticsSurface(),
})
}
if (!previousEnabled && !this.firstMessageRecorded) {
this.appStartTime = null
this.firstMessageRecorded = true
}
registerAnalyticsBuildInfo(buildInfo)
if (authStore.isAuthenticated && authStore.user?.id)
identifyAnalyticsUser(authStore.user.id)
})
const consciousness = useConsciousnessStore()
watch(
() => ({ provider: consciousness.activeProvider, model: consciousness.activeModel }),
(next, previous) => {
if (!next.provider || !next.model)
return
if (!previous) {
if (!this.firstModelSelectedRecorded && this.emit(firstModelSelectedEvent, { model_id: next.model, provider: next.provider }))
this.firstModelSelectedRecorded = true
return
}
if (previous.provider === next.provider && previous.model === next.model)
return
if (!this.firstModelSelectedRecorded) {
if (this.emit(firstModelSelectedEvent, { model_id: next.model, provider: next.provider }))
this.firstModelSelectedRecorded = true
return
}
if (previous.provider && previous.provider !== next.provider) {
this.emit(providerSwitchedEvent, {
from_provider: previous.provider,
to_provider: next.provider,
from_provider_type: getProviderMode(previous.provider),
to_provider_type: getProviderMode(next.provider),
reason: 'manual',
app_surface: analyticsSurface(),
})
}
if (previous.model) {
this.emit(modelSwitchedEvent, {
from_model: previous.model,
to_model: next.model,
reason: 'manual',
})
this.emit(modelChangedEvent, {
from_model: previous.model,
to_model: next.model,
provider: next.provider,
reason: 'manual',
app_surface: analyticsSurface(),
})
}
},
{ immediate: true },
)
const cardStore = useAiriCardStore()
watch(() => cardStore.activeCardId, (next, previous) => {
if (!next || !previous || previous === next)
return
this.emit(characterSwitchedEvent, {
from_character_id: previous,
to_character_id: next,
})
})
this.initialized = true
}
emit<Event extends AnyAnalyticsEvent>(event: Event, payload: InferAnalyticsEventPayload<Event>, options?: AnalyticsCaptureOptions): boolean {
return captureAnalyticsEvent(event.name, payload, options)
}
recordFirstMessage(): boolean {
if (this.firstMessageRecorded)
return false
const timeToFirstMessageMs = this.appStartTime === null
? null
: Date.now() - this.appStartTime
const captured = this.emit(firstMessageSentEvent, {
time_to_first_message_ms: timeToFirstMessageMs,
})
if (captured)
this.firstMessageRecorded = true
return captured
}
}
const analytics = new Analytics()
/** Returns the analytics singleton for this JavaScript runtime. */
export function getAnalytics(): AnalyticsRecorder & Pick<Analytics, 'initialize'> {
return analytics
}
/** Enables capture through the configured analytics provider. */
export function enableAnalytics(): boolean {
return enableAnalyticsCapture()
}
/** Disables capture through the configured analytics provider. */
export function disableAnalytics(): void {
disableAnalyticsCapture()
}
/** Starts analytics lifecycle observers after Pinia is available. */
export function initializeAnalytics(): void {
analytics.initialize()
}
export { configureAnalyticsAdapter, getAnalyticsIdentitySnapshot, isAnalyticsAvailableInBuild }
@@ -24,11 +24,6 @@ vi.mock('../../../../../posthog.config', () => ({
}))
describe('posthog analytics adapter', () => {
// ROOT CAUSE:
//
// `surface` was registered as the runtime platform but individual events
// also used `surface` for entry points such as `settings_flux`. Event
// properties overwrite super properties, so platform breakdowns drifted.
it('registers the runtime under the dedicated app_surface property', () => {
createPosthogAdapter({ enabled: true })
expect(posthogMocks.register).toHaveBeenCalledWith({ app_surface: 'web' })
@@ -18,7 +18,7 @@ function currentSurface(): 'web' | 'mobile' | 'electron' {
return 'web'
}
/** Creates and initializes the default PostHog analytics adapter. */
/** Creates and initializes the default PostHog adapter. */
export function createPosthogAdapter(options: AnalyticsAdapterOptions): AnalyticsAdapter {
posthog.init(POSTHOG_PROJECT_KEY, {
...DEFAULT_POSTHOG_CONFIG,
@@ -1,13 +1,12 @@
import { localeRemap } from '@proj-airi/i18n'
// TODO: Replace this with docs-owned metadata so app links cannot drift from
// the actual locales published under docs/content/*/about/privacy.md.
const supportedPrivacyPolicyLocales = new Set([
'en',
'ja',
'zh-Hans',
])
/** Returns the privacy policy URL for the requested UI locale. */
export function getAnalyticsPrivacyPolicyUrl(locale?: string): string {
const normalizedLocale = localeRemap[locale ?? 'en'] ?? locale ?? 'en'
const docsLocale = supportedPrivacyPolicyLocales.has(normalizedLocale)
@@ -0,0 +1,17 @@
import type { InferAnalyticsEventPayload } from './dsl'
import { describe, expect, it } from 'vitest'
import { defineEvent } from './dsl'
describe('defineEvent', () => {
it('keeps the event name and infers its payload type', () => {
const event = defineEvent<{ conversation_id: string }>('message_sent')
const payload: InferAnalyticsEventPayload<typeof event> = {
conversation_id: 'conversation-1',
}
expect(event.name).toBe('message_sent')
expect(payload).toEqual({ conversation_id: 'conversation-1' })
})
})
@@ -0,0 +1,20 @@
/**
* A stable product event definition.
*
* The phantom `payload` field exists only for TypeScript inference. Analytics
* providers receive the event name and payload, not this definition object.
*/
export interface AnalyticsEvent<Payload extends object> {
readonly name: string
readonly payload?: Payload
}
/** Extracts the payload accepted by one analytics event definition. */
export type InferAnalyticsEventPayload<Event> = Event extends AnalyticsEvent<infer Payload>
? Payload
: never
/** Defines one product event and its payload contract. */
export function defineEvent<Payload extends object>(name: string): AnalyticsEvent<Payload> {
return { name }
}
@@ -17,7 +17,7 @@ vi.mock('./auth', () => ({
getAuthToken: authMocks.getAuthToken,
}))
vi.mock('../stores/analytics/client', () => ({
vi.mock('./analytics', () => ({
getAnalyticsIdentitySnapshot: posthogMocks.getAnalyticsIdentitySnapshot,
}))
+1 -1
View File
@@ -1,5 +1,5 @@
import { getAnalyticsIdentitySnapshot } from '../stores/analytics/client'
import { useAuthStore } from '../stores/auth'
import { getAnalyticsIdentitySnapshot } from './analytics'
import { getAuthToken } from './auth'
import { SERVER_URL } from './server'
@@ -1,108 +0,0 @@
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
import { useSettingsAnalytics } from '../settings/analytics'
import { captureAnalyticsEvent, ensureAnalyticsInitialized, isAnalyticsAvailableInBuild } from './client'
/** Stable, low-cardinality actions emitted by the Electron controls island. */
export type ControlsIslandAction
= | 'expand_controls'
| 'collapse_controls'
| 'toggle_settings'
| 'toggle_profile_picker'
| 'toggle_chat'
| 'refresh_window'
| 'center_main_window'
| 'switch_to_light_mode'
| 'switch_to_dark_mode'
| 'pin_on_top'
| 'unpin_from_top'
| 'enable_fade_on_hover'
| 'disable_fade_on_hover'
| 'close_app'
/**
* Explicit product events that may be emitted directly from a button click.
*
* Add an event here only when clicking the button is itself the fact being
* measured. Async outcomes and confirmed state changes should remain in their
* owning business flow.
*/
export type TrackButtonEvent
= | {
name: 'controls_island_action'
action: ControlsIslandAction
}
| {
name: 'update_check_clicked'
channel: string
}
| {
name: 'update_install_clicked'
channel: string
version?: string
}
| {
name: 'mcp_server_added'
}
| {
name: 'mcp_server_removed'
}
function canCapture(): boolean {
const settingsAnalytics = useSettingsAnalytics()
return isAnalyticsAvailableInBuild()
&& settingsAnalytics.analyticsEnabled
&& ensureAnalyticsInitialized(true)
}
function appSurface(): 'web' | 'mobile' | 'electron' {
if (isStageTamagotchi())
return 'electron'
if (isStageCapacitor())
return 'mobile'
return 'web'
}
/**
* Emits a typed button event through the existing analytics opt-in boundary.
*
* This is shared with the global directive and imperative analytics facade so
* both retain one event schema and transport policy.
*/
export function captureTrackButtonEvent(event: TrackButtonEvent) {
if (!canCapture())
return
switch (event.name) {
case 'controls_island_action': {
const properties = {
action: event.action,
app_surface: appSurface(),
}
if (event.action === 'refresh_window' || event.action === 'close_app') {
captureAnalyticsEvent(event.name, properties, { beforeNavigation: true })
return
}
captureAnalyticsEvent(event.name, properties)
return
}
case 'update_check_clicked':
captureAnalyticsEvent(event.name, { channel: event.channel })
return
case 'update_install_clicked':
captureAnalyticsEvent(
event.name,
{ channel: event.channel, ...(event.version && { version: event.version }) },
{ beforeNavigation: true },
)
return
case 'mcp_server_added':
captureAnalyticsEvent(event.name, {})
return
case 'mcp_server_removed':
captureAnalyticsEvent(event.name, {})
}
}
@@ -1,244 +0,0 @@
import type { AboutBuildInfo } from '../../components/scenarios/about/types'
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
import { defineStore, storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import { useBuildInfo } from '../../composables/use-build-info'
import { useAuthStore } from '../auth'
import { useAiriCardStore } from '../modules/airi-card'
import { useConsciousnessStore } from '../modules/consciousness'
import { useSettingsAnalytics } from '../settings/analytics'
import {
captureAnalyticsEvent,
identifyAnalyticsUser,
isAnalyticsAvailableInBuild,
registerAnalyticsBuildInfo,
resetAnalyticsIdentity,
syncAnalyticsCapture,
} from './client'
export * from './client'
export * from './privacy-policy'
function analyticsSurface(): 'web' | 'desktop' | 'mobile' {
return isStageTamagotchi()
? 'desktop'
: isStageCapacitor()
? 'mobile'
: 'web'
}
function providerMode(providerId: string | undefined): 'official' | 'custom' | 'unknown' {
if (!providerId)
return 'unknown'
return providerId.startsWith('official-provider') ? 'official' : 'custom'
}
export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
const buildInfo = ref<AboutBuildInfo>(useBuildInfo())
const settingsAnalytics = useSettingsAnalytics()
const { analyticsEnabled } = storeToRefs(settingsAnalytics)
const isInitialized = ref(false)
const appStartTime = ref<number | null>(null)
const firstMessageTracked = ref(false)
// In-memory only, intentionally — matches `firstMessageTracked` semantics
// (resets on reload). PostHog can compute true "first time across all
// sessions" with provider-side person-level dedup at query time.
const firstModelSelectedTracked = ref(false)
watch(analyticsEnabled, (enabled, previousEnabled) => {
if (!isInitialized.value)
return
if (previousEnabled && !enabled) {
captureAnalyticsEvent('settings_changed', {
setting_name: 'analytics_enabled',
previous_value: previousEnabled,
new_value: enabled,
source: 'settings',
app_surface: analyticsSurface(),
})
}
const shouldCapture = syncAnalyticsCapture(enabled)
if (shouldCapture) {
if (!previousEnabled && enabled) {
captureAnalyticsEvent('settings_changed', {
setting_name: 'analytics_enabled',
previous_value: previousEnabled,
new_value: enabled,
source: 'settings',
app_surface: analyticsSurface(),
})
}
// When analytics is enabled mid-session, invalidate appStartTime and
// mark first message as already tracked to avoid backfilling a stale
// event with a misleading duration or timing.
if (!previousEnabled && !firstMessageTracked.value) {
appStartTime.value = null
markFirstMessageTracked()
}
registerAnalyticsBuildInfo(buildInfo.value)
// If a user enabled analytics mid-session while already authenticated,
// identify them now — `initialize()`'s identify only fires once at
// app startup and at auth-state changes, neither of which trigger
// on a delayed opt-in. Without this, server-side `payment_completed`
// (keyed by Better Auth user id) won't merge with the browser's
// anonymous funnel events.
const authStore = useAuthStore()
if (authStore.isAuthenticated && authStore.user?.id)
identifyAnalyticsUser(authStore.user.id)
}
})
function initialize() {
if (isInitialized.value)
return
appStartTime.value = Date.now()
if (isAnalyticsAvailableInBuild()) {
const shouldCapture = syncAnalyticsCapture(analyticsEnabled.value)
if (shouldCapture) {
registerAnalyticsBuildInfo(buildInfo.value)
const platform = analyticsSurface()
captureAnalyticsEvent('app_loaded', {
platform,
version: buildInfo.value.version,
})
}
}
// Wire PostHog identity to auth state. Without this server-side events
// (`payment_completed` keyed on Better Auth `user.id`) and browser-side
// funnel events (anonymous `distinct_id` until identify) live on
// different person profiles and the funnel never joins.
const authStore = useAuthStore()
if (authStore.isAuthenticated && authStore.user?.id)
identifyAnalyticsUser(authStore.user.id)
authStore.onAuthenticated(() => {
if (authStore.user?.id)
identifyAnalyticsUser(authStore.user.id)
})
authStore.onLogout(() => {
resetAnalyticsIdentity()
})
// Wire model-selection events. The consciousness store holds the
// user-chosen chat model; both `activeProvider` and `activeModel` are
// persisted via `useLocalStorageManualReset`, so on app load this
// watcher fires once with the restored value as the "new" half (oldVal
// is undefined). We guard on `oldProvider == null` to treat the boot
// case as a baseline, not as a switch — otherwise every page load
// would emit `model_switched`.
//
// Single `model_switched` callsite by design: consciousness reads/writes
// happen across many UI surfaces (onboarding step, settings page,
// model picker dropdown). Centralising the event here means new model-
// change UI doesn't need to remember to fire analytics.
const consciousness = useConsciousnessStore()
watch(
() => ({ provider: consciousness.activeProvider, model: consciousness.activeModel }),
(next, prev) => {
if (!next.provider || !next.model)
return
// Baseline on first watcher tick (oldVal undefined when the watcher
// mounts with already-restored localStorage state).
if (!prev) {
if (!firstModelSelectedTracked.value) {
// User has a model picked from a prior session — count it as
// their first observed selection, but don't emit `model_switched`
// since we have nothing to switch from. Only flip the dedup flag
// when the capture actually went out (PostHog initialised + user
// not opted out); otherwise an early opt-in or delayed init
// would never get the chance to emit `first_model_selected`.
const captured = captureAnalyticsEvent('first_model_selected', { model_id: next.model, provider: next.provider })
if (captured)
firstModelSelectedTracked.value = true
}
return
}
if (prev.provider === next.provider && prev.model === next.model)
return
if (!firstModelSelectedTracked.value) {
// Same gating as the baseline branch: only mark first-selection
// as tracked when capture actually shipped.
const captured = captureAnalyticsEvent('first_model_selected', { model_id: next.model, provider: next.provider })
if (captured)
firstModelSelectedTracked.value = true
return
}
// Genuine switch — emit only when we have a meaningful "from" model.
// Provider transitions without a prior model (e.g. user clears then
// re-selects) skip the switch event; the next clean A → B will fire.
if (prev.provider && prev.provider !== next.provider) {
captureAnalyticsEvent('provider_switched', {
from_provider: prev.provider,
to_provider: next.provider,
from_provider_type: providerMode(prev.provider),
to_provider_type: providerMode(next.provider),
reason: 'manual',
app_surface: analyticsSurface(),
})
}
if (prev.model) {
captureAnalyticsEvent('model_switched', {
from_model: prev.model,
to_model: next.model,
reason: 'manual',
})
captureAnalyticsEvent('model_changed', {
from_model: prev.model,
to_model: next.model,
provider: next.provider,
reason: 'manual',
app_surface: analyticsSurface(),
})
}
},
{ immediate: true },
)
// Same single-callsite pattern as model_switched: track active
// character changes here so any UI surface that swaps cards
// (settings page, character picker, hotkey) is auto-instrumented.
const cardStore = useAiriCardStore()
watch(
() => cardStore.activeCardId,
(next, prev) => {
// Boot-time watcher tick (no previous value) is the baseline,
// not a switch — skip emit; the first real A→B will fire.
if (!next || !prev || prev === next)
return
captureAnalyticsEvent('character_switched', {
from_character_id: prev,
to_character_id: next,
})
},
)
isInitialized.value = true
}
function markFirstMessageTracked() {
firstMessageTracked.value = true
}
return {
buildInfo,
appStartTime,
firstMessageTracked,
initialize,
markFirstMessageTracked,
}
})
+16 -167
View File
@@ -2,7 +2,6 @@ import type { ChatOrchestratorRuntimeState, ChatOrchestratorSendOptions, StreamE
import type { WebSocketEventInputs } from '@proj-airi/server-sdk'
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { Message } from '@xsai/shared-chat'
import type {} from 'pinia-plugin-synced'
import type { ChatHistoryItem, ChatToolReference } from '../types/chat'
import type { ToolCallRerunPayload } from './tool-call-rerun'
@@ -14,13 +13,14 @@ import { nanoid } from 'nanoid'
import { defineStore, storeToRefs } from 'pinia'
import { ref, toRaw, watch } from 'vue'
import { getConversationAnalyticsSurface, useAnalytics } from '../composables'
import { getConversationAnalyticsSurface } from '../composables'
import { activeTurnSpan, startSpan } from '../composables/use-io-tracer'
import {
AIRI_CHAT_APP_SURFACE_HEADER,
AIRI_CHAT_ROUND_ID_HEADER,
AIRI_CHAT_SESSION_ID_HEADER,
} from '../libs/analytics-headers'
import { createChatAnalyticsHooks, getProviderMode } from '../libs/analytics/events/chat'
import { extractMessageText, isCloudSyncableMessage } from '../libs/chat-sync'
import { createMinecraftContext } from './chat/context-providers'
import { useChatContextStore } from './chat/context-store'
@@ -148,22 +148,6 @@ export const useChatStore = defineStore('chat', () => {
const providerStore = useProviderStore()
const artistryAutonomousStore = useAutonomousArtistryStore()
const { activeModel, activeProvider } = storeToRefs(consciousnessStore)
const {
trackFirstMessage,
trackMessageSendStarted,
trackMessageSent,
trackLlmRequestStarted,
trackLlmFirstToken,
trackAssistantResponseRendered,
trackAiGeneration,
trackMessageRound,
trackMessageRoundFailed,
trackChatActivationStarted,
trackChatActivationSucceeded,
trackChatActivationFailed,
trackSecondTurnStarted,
} = useAnalytics()
const chatSession = useChatSessionStore()
const chatStream = useChatStreamStore()
const chatContext = useChatContextStore()
@@ -175,6 +159,9 @@ export const useChatStore = defineStore('chat', () => {
const sending = ref(false)
const pendingQueuedSendCount = ref(0)
let ownedActiveTurnSpan: typeof activeTurnSpan.value
const analyticsHooks = createChatAnalyticsHooks({
getSessionMessages: sessionId => chatSession.getSessionMessages(sessionId),
})
async function streamWithStageAdapters(
model: string,
@@ -184,7 +171,7 @@ export const useChatStore = defineStore('chat', () => {
) {
let llmTextLength = 0
const headers = { ...options?.headers }
if (providerMode(activeProvider.value) === 'official' && options?.requestCorrelation) {
if (getProviderMode(activeProvider.value) === 'official' && options?.requestCorrelation) {
headers[AIRI_CHAT_SESSION_ID_HEADER] = options.requestCorrelation.conversationId
headers[AIRI_CHAT_ROUND_ID_HEADER] = options.requestCorrelation.roundId
headers[AIRI_CHAT_APP_SURFACE_HEADER] = getConversationAnalyticsSurface()
@@ -245,17 +232,6 @@ export const useChatStore = defineStore('chat', () => {
ownedActiveTurnSpan = undefined
}
/**
* Classifies configured chat providers into low-cardinality product analytics buckets.
*/
function providerMode(providerId: string | undefined): 'official' | 'custom' | 'unknown' {
if (!providerId)
return 'unknown'
return providerId.startsWith('official-provider') ? 'official' : 'custom'
}
let lastSendSource: 'text' | 'voice' = 'text'
const runtime = createChatOrchestratorRuntime({
session: {
ensureSession: sessionId => chatSession.ensureSession(sessionId),
@@ -288,147 +264,20 @@ export const useChatStore = defineStore('chat', () => {
unwrapMessage: message => toRaw(message),
onStateChange: syncRuntimeState,
onSendSettled: settleOwnedActiveTurnSpan,
onTrackFirstMessage: trackFirstMessage,
onMessageSendStarted: ({ conversationId, roundId, turnIndex, source, model }) => {
lastSendSource = source
trackMessageSendStarted({
conversation_id: conversationId,
round_id: roundId,
turn_index: turnIndex,
source,
model,
})
},
onLlmRequestStarted: ({ conversationId, roundId, turnIndex, model, provider, hasVoice }) => trackLlmRequestStarted({
conversation_id: conversationId,
round_id: roundId,
turn_index: turnIndex,
model,
provider,
has_voice: hasVoice,
}),
onLlmFirstToken: ({ conversationId, roundId, turnIndex, model, ttfbMs }) => trackLlmFirstToken({
conversation_id: conversationId,
round_id: roundId,
turn_index: turnIndex,
model,
ttfb_ms: ttfbMs,
}),
onAssistantResponseRendered: ({ conversationId, roundId, turnIndex, model, latencyMs }) => {
trackAssistantResponseRendered({
conversation_id: conversationId,
round_id: roundId,
turn_index: turnIndex,
model,
latency_ms: latencyMs,
})
},
onLlmGeneration: ({ conversationId, roundId, model, provider, inputTokens, outputTokens, totalTokens, usageSource }) => {
const mode = providerMode(provider)
// The official path is captured server-side from authoritative upstream usage.
if (mode !== 'custom')
return
trackAiGeneration({
conversation_id: conversationId,
round_id: roundId,
provider_type: mode,
provider_id: provider,
model_id: model,
usage_source: usageSource,
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: totalTokens,
})
},
onMessageRound: ({ conversationId, roundId, turnIndex, durationMs, hasVoice, model, inputTokens, outputTokens, totalTokens, usageSource }) => trackMessageRound({
conversation_id: conversationId,
round_id: roundId,
turn_index: turnIndex,
duration_ms: durationMs,
has_voice: hasVoice,
model,
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: totalTokens,
usage_source: usageSource,
}),
onMessageRoundFailed: ({ conversationId, roundId, turnIndex, model, provider, errorCode, failureStage, source }) => trackMessageRoundFailed({
conversation_id: conversationId,
round_id: roundId,
turn_index: turnIndex,
provider_id: provider || 'unknown',
model_id: model || 'unknown',
source,
error_code: errorCode,
failure_stage: failureStage,
}),
onChatActivationStarted: ({ conversationId, roundId, turnIndex, model, provider, source }) => {
const mode = providerMode(provider)
const providerId = provider || 'unknown'
const modelId = model || 'unknown'
trackChatActivationStarted({
conversation_id: conversationId,
provider_mode: mode,
provider_id: providerId,
model_id: modelId,
round_id: roundId,
source,
turn_index: turnIndex,
})
},
onChatActivationSucceeded: ({ conversationId, roundId, turnIndex, model, provider, durationMs, source }) => trackChatActivationSucceeded({
conversation_id: conversationId,
provider_mode: providerMode(provider),
provider_id: provider || 'unknown',
model_id: model || 'unknown',
round_id: roundId,
time_to_first_message_ms: durationMs,
source,
turn_index: turnIndex,
}),
onChatActivationFailed: ({ conversationId, roundId, turnIndex, model, provider, errorCode, failureStage, source }) => {
trackChatActivationFailed({
conversation_id: conversationId,
provider_mode: providerMode(provider),
provider_id: provider || 'unknown',
model_id: model || 'unknown',
round_id: roundId,
error_code: errorCode,
failure_stage: failureStage,
source,
turn_index: turnIndex,
})
},
...analyticsHooks,
onLifecycle: record => contextObservability.recordLifecycle(record),
onPromptProjection: payload => contextObservability.capturePromptProjection(payload),
onUserMessageAppended: ({ sessionId, message, messageText, source, model, provider, roundId, turnIndex }) => {
trackMessageSent({
conversation_id: sessionId,
provider_type: providerMode(activeProvider.value),
provider_name: activeProvider.value || 'unknown',
model: activeModel.value || 'unknown',
message_id: message.id,
round_id: roundId,
turn_index: turnIndex,
message_index: chatSession.getSessionMessages(sessionId).length,
message_length: messageText.length,
has_attachment: false,
mode: lastSendSource,
analyticsHooks.onUserMessageAppended?.({
sessionId,
message,
messageText,
source,
model,
provider,
roundId,
turnIndex,
})
if (turnIndex === 2) {
trackSecondTurnStarted({
conversation_id: sessionId,
provider_mode: providerMode(provider),
provider_id: provider || 'unknown',
model_id: model || 'unknown',
round_id: roundId,
source,
turn_index: turnIndex,
})
}
if (isCloudSyncableMessage(message)) {
void chatSession.pushMessageToCloud(sessionId, {
id: message.id,
@@ -1,5 +1,4 @@
import type { MessageRole, NewMessagesPayload } from '@proj-airi/server-sdk-shared'
import type {} from 'pinia-plugin-synced'
import type { ChatSendOutboxEntry } from '../../database/repos/chat-sessions.repo'
import type { ChatWsClient, CloudChatMapper } from '../../libs/chat-sync'
@@ -13,6 +12,7 @@ import { defineStore, storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { chatSessionsRepo } from '../../database/repos/chat-sessions.repo'
import { captureAnalyticsEvent } from '../../libs/analytics'
import { authedFetch } from '../../libs/auth-fetch'
import {
applyCreateActions,
@@ -24,7 +24,6 @@ import {
reconcileLocalAndRemote,
} from '../../libs/chat-sync'
import { SERVER_URL } from '../../libs/server'
import { captureAnalyticsEvent } from '../analytics/client'
import { useAuthStore } from '../auth'
import { useAiriCardStore } from '../modules/airi-card'
import { mergeLoadedSessionMessages } from './session-message-merge'
@@ -1,5 +1,3 @@
import type {} from 'pinia-plugin-synced'
import type { StreamingAssistantMessage } from '../../types/chat'
import { defineStore } from 'pinia'
@@ -1,5 +1,4 @@
import type { Tool } from '@xsai/shared-chat'
import type {} from 'pinia-plugin-synced'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
@@ -11,7 +11,7 @@ import { useI18n } from 'vue-i18n'
import SystemPromptV2 from '../../constants/prompts/system-v2'
import { DEFAULT_ARTISTRY_WIDGET_SPAWNING_PROMPT } from '../../constants/prompts/character-defaults'
import { captureAnalyticsEvent } from '../analytics/client'
import { captureAnalyticsEvent } from '../../libs/analytics'
import { useSettingsStageModel } from '../settings/stage-model'
import { useArtistryStore } from './artistry'
import { useConsciousnessStore } from './consciousness'
@@ -1,5 +1,3 @@
import type {} from 'pinia-plugin-synced'
import type { InferenceServiceProvider, ProviderValidationStatus } from '../../libs/providers/types'
import { useMutation, useQuery } from '@pinia/colada'
@@ -8,7 +8,6 @@ import type {
TranscriptionProvider,
TranscriptionProviderWithExtraOptions,
} from '@xsai-ext/providers/utils'
import type {} from 'pinia-plugin-synced'
import type { ProviderMetadata, ProviderValidationPlan } from '../../libs/providers'
import type { ModelInfo, ProviderDefinition, ProviderInstance } from '../../libs/providers/types'
@@ -22,6 +21,7 @@ import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { captureAnalyticsEvent, enableAnalytics, isAnalyticsAvailableInBuild } from '../../libs/analytics'
import {
CHAT_COMPLETIONS_VALIDATOR_ID,
getProviderValidationIntervalMs,
@@ -31,7 +31,6 @@ import {
validateProvider as runProviderValidation,
} from '../../libs/providers'
import { selectProviderMetadata, selectProvidersMetadata } from '../../libs/providers/metadata'
import { captureAnalyticsEvent, ensureAnalyticsInitialized, isAnalyticsAvailableInBuild } from '../analytics/client'
import { useAuthStore } from '../auth'
import { useSettingsAnalytics } from '../settings/analytics'
import { useProviderConfigStore } from './config'
@@ -69,7 +68,7 @@ function canCaptureProviderAnalytics(): boolean {
if (!settingsAnalytics.analyticsEnabled)
return false
return ensureAnalyticsInitialized(true)
return enableAnalytics()
}
/**