refactor(stage-*): analytics and telemetry, including consent (#1414)

---------

Co-authored-by-agent: Codex <267193182+codex@users.noreply.github.com>
Co-authored-by-agent: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: RainbowBird <rbxin2003@outlook.com>
Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
Rin
2026-03-24 13:01:39 +08:00
committed by GitHub
co-authored by autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> RainbowBird Neko
parent 8baaff7778
commit f85a809d7c
37 changed files with 425 additions and 191 deletions
@@ -78,6 +78,7 @@ jobs:
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
S3_REGION: ${{ secrets.S3_REGION }}
VITE_ENABLE_POSTHOG: 'true'
WARP_DRIVE_PUBLIC_BASE: ${{ secrets.WARP_DRIVE_PUBLIC_BASE }}
- run: |
CI=true pnpm install -g @posthog/cli
@@ -33,6 +33,7 @@ jobs:
env:
TARGET_HUGGINGFACE_SPACE: 'true'
VITE_APP_TARGET_HUGGINGFACE_SPACE: 'true'
VITE_ENABLE_POSTHOG: 'true'
- id: airi_diff
working-directory: ./apps/stage-web/dist
+2
View File
@@ -45,6 +45,8 @@ jobs:
with:
context: ./
file: ./apps/stage-web/Dockerfile
build-args: |
VITE_ENABLE_POSTHOG=true
platforms: linux/amd64,linux/arm64,linux/arm64/v8
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -23,6 +23,9 @@ jobs:
# TODO: build release APK
name: Build Debuggable Android APK
runs-on: ubuntu-latest
env:
# Every current trigger path produces an installable APK artifact, so ship the analytics-enabled build consistently.
VITE_ENABLE_POSTHOG: 'true'
steps:
- uses: actions/checkout@v6
+2
View File
@@ -47,6 +47,8 @@ jobs:
build:
name: Build
continue-on-error: ${{ matrix.skip }} # WORKAROUND: Skipping subsequent steps without failing the whole workflow
env:
VITE_ENABLE_POSTHOG: ${{ ((github.event_name == 'release') || (github.event_name == 'workflow_dispatch' && !inputs.build_only)) && 'true' || 'false' }}
strategy:
matrix:
include:
+7 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { OnboardingDialog, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
import { isPosthogAvailableInBuild, useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/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'
@@ -96,6 +96,11 @@ function handleSetupSkipped() {
}
const extraSteps = computed(() => [
...(
isPosthogAvailableInBuild()
? [{ id: 'analytics-notice', component: OnboardingStepAnalyticsNotice }]
: []
),
{
id: 'step-permissions',
component: OnboardingPermissionsStep,
-1
View File
@@ -17,7 +17,6 @@ import App from './App.vue'
import { i18n } from './modules/i18n'
import './modules/posthog'
import '@proj-airi/font-cjkfonts-allseto/index.css'
import '@proj-airi/font-xiaolai/index.css'
import '@unocss/reset/tailwind.css'
+1
View File
@@ -2,6 +2,7 @@ import messages from '@proj-airi/i18n/locales'
import { createI18n } from 'vue-i18n'
// TODO: Align this bootstrap locale normalization with `@proj-airi/i18n`.
const languageRemap: Record<string, string> = {
'zh-CN': 'zh-Hans',
'zh-TW': 'zh-Hant',
-9
View File
@@ -1,9 +0,0 @@
import posthog from 'posthog-js'
import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY_POCKET } from '../../../../posthog.config'
if (!import.meta.env.DEV) {
posthog.init(POSTHOG_PROJECT_KEY_POCKET, {
...DEFAULT_POSTHOG_CONFIG,
})
}
@@ -15,9 +15,6 @@ import App from './App.vue'
import { i18n } from './modules/i18n'
import './modules/posthog'
// eslint-disable-next-line perfectionist/sort-imports
import '@unocss/reset/tailwind.css'
import 'splitpanes/dist/splitpanes.css'
import 'vue-sonner/style.css'
@@ -2,6 +2,7 @@ import messages from '@proj-airi/i18n/locales'
import { createI18n } from 'vue-i18n'
// TODO: Align this bootstrap locale normalization with `@proj-airi/i18n`.
const languageRemap: Record<string, string> = {
'zh-CN': 'zh-Hans',
'zh-TW': 'zh-Hans', // TODO: remove this when zh-Hant is supported
@@ -1,9 +0,0 @@
import posthog from 'posthog-js'
import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY_DESKTOP } from '../../../../../posthog.config'
if (!import.meta.env.DEV) {
posthog.init(POSTHOG_PROJECT_KEY_DESKTOP, {
...DEFAULT_POSTHOG_CONFIG,
})
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useElectronAutoUpdater } from '@proj-airi/electron-vueuse'
import { AboutContent, MarkdownRenderer } from '@proj-airi/stage-ui/components'
import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics/index'
import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { Button, DoubleCheckButton, Progress } from '@proj-airi/ui'
import { useMediaQuery } from '@vueuse/core'
import { storeToRefs } from 'pinia'
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { OnboardingScreen } from '@proj-airi/stage-ui/components'
import { OnboardingScreen, OnboardingStepAnalyticsNotice } from '@proj-airi/stage-ui/components'
import { isPosthogAvailableInBuild } from '@proj-airi/stage-ui/stores/analytics'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useTheme } from '@proj-airi/ui'
import { computed } from 'vue'
@@ -11,6 +12,11 @@ const onboardingStore = useOnboardingStore()
const { isDark } = useTheme()
const bgClass = computed(() => isDark.value ? 'bg-[#0f0f0f]' : 'bg-white')
const extraSteps = computed(() => {
return isPosthogAvailableInBuild()
? [{ id: 'analytics-notice', component: OnboardingStepAnalyticsNotice }]
: []
})
const closeWindow = useElectronEventaInvoke(electronOnboardingClose)
@@ -30,7 +36,7 @@ async function handleConfigured() {
<div :class="bgClass" w="100dvw" min-h="12" w-full flex-shrink-0 select-none data-tauri-drag-region />
<div class="onboarding-scroll" w-full flex-1 px-3>
<div class="onboarding-content" h-full>
<OnboardingScreen @skipped="handleSkipped" @configured="handleConfigured" />
<OnboardingScreen :extra-steps="extraSteps" @skipped="handleSkipped" @configured="handleConfigured" />
</div>
</div>
</div>
+3
View File
@@ -2,6 +2,9 @@ FROM node:24-trixie AS build-stage
WORKDIR /app
ARG VITE_ENABLE_POSTHOG=false
ENV VITE_ENABLE_POSTHOG=${VITE_ENABLE_POSTHOG}
RUN apt update && apt install -y ca-certificates curl
RUN update-ca-certificates
RUN apt update && apt install -y build-essential python3 python3-setuptools curl
+9 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { OnboardingDialog, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
import { isPosthogAvailableInBuild, useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/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'
@@ -59,6 +59,12 @@ const colors = computed(() => {
return [primaryColor.value, secondaryColor.value, tertiaryColor.value, isDark.value ? '#121212' : '#FFFFFF']
})
const onboardingExtraSteps = computed(() => {
return isPosthogAvailableInBuild()
? [{ id: 'analytics-notice', component: OnboardingStepAnalyticsNotice }]
: []
})
watch(settings.language, () => {
i18n.locale.value = settings.language.value
})
@@ -125,6 +131,7 @@ function handleSetupSkipped() {
<!-- First Time Setup Dialog -->
<OnboardingDialog
v-model="showingSetup"
:extra-steps="onboardingExtraSteps"
@configured="handleSetupConfigured"
@skipped="handleSetupSkipped"
/>
-1
View File
@@ -17,7 +17,6 @@ import App from './App.vue'
import { i18n } from './modules/i18n'
import './modules/posthog'
import '@proj-airi/font-cjkfonts-allseto/index.css'
import '@proj-airi/font-xiaolai/index.css'
import '@unocss/reset/tailwind.css'
+2 -29
View File
@@ -1,28 +1,8 @@
import messages from '@proj-airi/i18n/locales'
import { resolveSupportedLocale } from '@proj-airi/i18n'
import { createI18n } from 'vue-i18n'
const languageRemap: Record<string, string> = {
'zh-CN': 'zh-Hans',
'zh-TW': 'zh-Hant',
'zh-HK': 'zh-Hans', // TODO: remove this when zh-Hant is supported
'zh-Hant': 'zh-Hans', // TODO: remove this when zh-Hant is supported
'en-US': 'en',
'en-GB': 'en',
'en-AU': 'en',
'en': 'en',
'es-ES': 'es',
'es-MX': 'es',
'es-AR': 'es',
'es': 'es',
'vi': 'vi',
'vi-VN': 'vi',
'ru': 'ru',
'ru-RU': 'ru',
'fr': 'fr',
'fr-FR': 'fr',
}
function getLocale() {
let language = localStorage.getItem('settings/language')
@@ -31,14 +11,7 @@ function getLocale() {
language = navigator.language || 'en'
}
const languages = Object.keys(messages!)
if (languageRemap[language || 'en'] != null) {
language = languageRemap[language || 'en']
}
if (language && languages.includes(language))
return language
return 'en'
return resolveSupportedLocale(language, Object.keys(messages!))
}
export const i18n = createI18n({
-9
View File
@@ -1,9 +0,0 @@
import posthog from 'posthog-js'
import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY_WEB } from '../../../../posthog.config'
if (!import.meta.env.DEV) {
posthog.init(POSTHOG_PROJECT_KEY_WEB, {
...DEFAULT_POSTHOG_CONFIG,
})
}
+38
View File
@@ -9,3 +9,41 @@ export const all = {
'zh-Hans': '简体中文',
'zh-Hant': '繁體中文',
}
export const localeRemap: Record<string, string> = {
'zh-CN': 'zh-Hans',
'zh-TW': 'zh-Hant',
'zh-HK': 'zh-Hans',
'zh-Hant': 'zh-Hant',
'zh-Hans': 'zh-Hans',
'en': 'en',
'en-US': 'en',
'en-GB': 'en',
'en-AU': 'en',
'es': 'es',
'es-ES': 'es',
'es-MX': 'es',
'es-AR': 'es',
'fr': 'fr',
'fr-FR': 'fr',
'ja': 'ja',
'ja-JP': 'ja',
'ko': 'ko',
'ko-KR': 'ko',
'ru': 'ru',
'ru-RU': 'ru',
'vi': 'vi',
'vi-VN': 'vi',
}
export function resolveSupportedLocale(
locale: string | null | undefined,
supportedLocales: readonly string[],
fallbackLocale = 'en',
): string {
const normalizedLocale = localeRemap[locale ?? fallbackLocale] ?? locale ?? fallbackLocale
return supportedLocales.includes(normalizedLocale)
? normalizedLocale
: fallbackLocale
}
@@ -59,6 +59,19 @@ controls-island:
auto: Auto (responsive)
large: Large (default)
small: Small
analytics:
notice:
title: Usage analytics
description: AIRI collects anonymous usage analytics to help us understand how the app is used and improve stability. No personal data is collected.
privacyPrefix: Read the
privacyLink: privacy policy
onboardingHint: You can turn analytics off later in Settings > System > General.
settingsHint: You can turn analytics off at any time.
toggle:
title: Enable usage analytics
description: Turn this off to opt out of analytics collection.
disabled:
title: Analytics disabled for this development build
live2d:
change-model:
from-file: Load from File
@@ -1,14 +1,30 @@
<script setup lang="ts">
import { all } from '@proj-airi/i18n'
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
import { isPosthogAvailableInBuild } from '@proj-airi/stage-ui/stores/analytics'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { FieldCheckbox, FieldSelect, useTheme } from '@proj-airi/ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = withDefaults(defineProps<{
needsControlsIslandIconSizeSetting?: boolean
}>(), {
needsControlsIslandIconSizeSetting: import.meta.env.RUNTIME_ENVIRONMENT === 'electron',
})
const settings = useSettings()
const showControlsIsland = computed(() => props.needsControlsIslandIconSizeSetting)
const showAnalyticsSettings = computed(() => isPosthogAvailableInBuild())
const analyticsToggleValue = computed({
get: () => showAnalyticsSettings.value ? settings.analyticsEnabled : false,
set: (value: boolean) => settings.analyticsEnabled = value,
})
const { t } = useI18n()
const { isDark: dark } = useTheme()
const { privacyPolicyUrl } = useAnalytics()
const languages = computed(() => {
return Object.entries(all).map(([value, label]) => ({ value, label }))
@@ -42,6 +58,55 @@ const languages = computed(() => {
:options="languages"
/>
<FieldSelect
v-if="showControlsIsland"
v-model="settings.controlsIslandIconSize"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (4 * 10)"
:delay="4 * 50"
:class="['transition-all', 'ease-in-out', 'duration-250']"
:label="t('settings.controls-island.icon-size.title')"
:description="t('settings.controls-island.icon-size.description')"
:options="[
{ value: 'auto', label: t('settings.controls-island.icon-size.auto') },
{ value: 'large', label: t('settings.controls-island.icon-size.large') },
{ value: 'small', label: t('settings.controls-island.icon-size.small') },
]"
/>
<FieldCheckbox
v-model="analyticsToggleValue"
v-motion
:disabled="!showAnalyticsSettings"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (5 * 10)"
:delay="5 * 50"
:label="t('settings.analytics.toggle.title')"
>
<template #description>
<div :class="['flex', 'flex-col', 'gap-2', 'text-xs', 'leading-relaxed']">
<p>{{ t('settings.analytics.notice.description') }}</p>
<p>
{{ t('settings.analytics.notice.privacyPrefix') }}
<a
:href="privacyPolicyUrl"
target="_blank"
rel="noopener noreferrer"
:class="['underline', 'decoration-dotted']"
>
{{ t('settings.analytics.notice.privacyLink') }}
</a>.
</p>
<p>
{{ showAnalyticsSettings ? t('settings.analytics.notice.settingsHint') : t('settings.analytics.disabled.title') }}
</p>
</div>
</template>
</FieldCheckbox>
<slot name="additional-fields" />
<div
@@ -1,87 +1,9 @@
<script setup lang="ts">
import { all } from '@proj-airi/i18n'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { FieldCheckbox, FieldSelect, useTheme } from '@proj-airi/ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = withDefaults(defineProps<{
needsControlsIslandIconSizeSetting?: boolean
}>(), {
needsControlsIslandIconSizeSetting: import.meta.env.RUNTIME_ENVIRONMENT === 'electron',
})
const showControlsIsland = computed(() => props.needsControlsIslandIconSizeSetting)
const settings = useSettings()
const { t } = useI18n()
const { isDark: dark } = useTheme()
const languages = computed(() => {
return Object.entries(all).map(([value, label]) => ({ value, label }))
})
import SettingsGeneralFields from '../../../components/settings-general-fields.vue'
</script>
<template>
<div rounded-lg bg-neutral-50 p-4 dark:bg-neutral-800 flex="~ col gap-4">
<FieldCheckbox
v-model="dark"
v-motion
mb-2
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (2 * 10)"
:delay="2 * 50"
:label="t('settings.theme.title')"
:description="t('settings.theme.description')"
/>
<!-- Language Setting -->
<FieldSelect
v-model="settings.language"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (3 * 10)"
:delay="3 * 50"
transition="all ease-in-out duration-250"
:label="t('settings.language.title')"
:description="t('settings.language.description')"
:options="languages as Array<{ value: string; label: string }>"
/>
<!-- Controls Island Icon Size -->
<FieldSelect
v-if="showControlsIsland"
v-model="settings.controlsIslandIconSize"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (4 * 10)"
:delay="4 * 50"
transition="all ease-in-out duration-250"
:label="t('settings.controls-island.icon-size.title')"
:description="t('settings.controls-island.icon-size.description')"
:options="[
{ value: 'auto', label: t('settings.controls-island.icon-size.auto') },
{ value: 'large', label: t('settings.controls-island.icon-size.large') },
{ value: 'small', label: t('settings.controls-island.icon-size.small') },
]"
/>
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[65dvh]" right--15 z--1
:initial="{ scale: 0.9, opacity: 0, rotate: 30 }"
:enter="{ scale: 1, opacity: 1, rotate: 0 }"
:duration="250"
flex items-center justify-center
>
<div text="60" i-solar:emoji-funny-square-bold-duotone />
</div>
</div>
<SettingsGeneralFields />
</template>
<route lang="yaml">
+3
View File
@@ -28,7 +28,10 @@
"./libs": "./src/libs/index.ts",
"./stores/providers/aliyun": "./src/stores/providers/aliyun/index.ts",
"./stores/analytics": "./src/stores/analytics/index.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/settings/analytics": "./src/stores/settings/analytics.ts",
"./stores/settings": "./src/stores/settings/index.ts",
"./stores/modules/vision": "./src/stores/modules/vision/index.ts",
"./stores/*": "./src/stores/*.ts",
@@ -1,3 +1,4 @@
export { default as OnboardingDialog } from './onboarding-dialog.vue'
export { default as OnboardingScreen } from './onboarding.vue'
export { default as OnboardingStepAnalyticsNotice } from './step-analytics-notice.vue'
export type { OnboardingStep } from './types'
@@ -0,0 +1,63 @@
<script setup lang="ts">
import type { OnboardingStepNextHandler, OnboardingStepPrevHandler } from './types'
import { Button, Callout } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
import { useAnalytics } from '../../../../composables/use-analytics'
const props = defineProps<{
onNext: OnboardingStepNextHandler
onPrevious?: OnboardingStepPrevHandler
}>()
const { t } = useI18n()
const { privacyPolicyUrl } = useAnalytics()
</script>
<template>
<div h-full flex flex-col gap-4>
<div sticky top-0 z-100 flex flex-shrink-0 items-center gap-2>
<button
type="button"
:class="[
'outline-none',
props.onPrevious ? '' : 'invisible',
]"
@click="props.onPrevious?.()"
>
<div :class="['i-solar:alt-arrow-left-line-duotone', 'h-5', 'w-5']" />
</button>
<h2 :class="['flex-1', 'text-center', 'text-xl', 'text-neutral-800', 'font-semibold', 'md:text-left', 'md:text-2xl', 'dark:text-neutral-100']">
{{ t('settings.analytics.notice.title') }}
</h2>
<div h-5 w-5 />
</div>
<div flex flex-1 flex-col justify-center gap-4>
<Callout theme="primary" :label="t('settings.analytics.notice.title')">
<div :class="['flex', 'flex-col', 'gap-3', 'text-sm', 'leading-relaxed', 'md:text-base']">
<p>{{ t('settings.analytics.notice.description') }}</p>
<p>
{{ t('settings.analytics.notice.privacyPrefix') }}
<a
:href="privacyPolicyUrl"
target="_blank"
rel="noopener noreferrer"
:class="['underline', 'decoration-dotted']"
>
{{ t('settings.analytics.notice.privacyLink') }}
</a>.
</p>
<p>{{ t('settings.analytics.notice.onboardingHint') }}</p>
</div>
</Callout>
</div>
<Button
variant="primary"
:label="t('settings.dialogs.onboarding.next')"
@click="props.onNext"
/>
</div>
</template>
@@ -1,11 +1,36 @@
import posthog from 'posthog-js'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSharedAnalyticsStore } from '../stores/analytics'
import { ensurePosthogInitialized, isPosthogAvailableInBuild } from '../stores/analytics/posthog'
import { getAnalyticsPrivacyPolicyUrl } from '../stores/analytics/privacy-policy'
import { useSettingsAnalytics } from '../stores/settings/analytics'
import { useSettingsGeneral } from '../stores/settings/general'
export function useAnalytics() {
const analyticsStore = useSharedAnalyticsStore()
const settingsAnalytics = useSettingsAnalytics()
const settingsGeneral = useSettingsGeneral()
const { locale } = useI18n()
const privacyPolicyUrl = computed(() => getAnalyticsPrivacyPolicyUrl(locale.value || settingsGeneral.language))
const isAnalyticsEnabled = computed(() => isPosthogAvailableInBuild() && settingsAnalytics.analyticsEnabled)
function canCapture(): boolean {
if (!isAnalyticsEnabled.value)
return false
// Ensure PostHog is initialized before any capture call.
return ensurePosthogInitialized(true)
}
function trackProviderClick(providerId: string, module: string) {
if (!canCapture())
return
posthog.capture('provider_card_clicked', {
provider_id: providerId,
module,
@@ -13,6 +38,9 @@ export function useAnalytics() {
}
function trackFirstMessage() {
if (!canCapture())
return
// Only track the first message once
if (analyticsStore.firstMessageTracked)
return
@@ -30,6 +58,7 @@ export function useAnalytics() {
}
return {
privacyPolicyUrl,
trackProviderClick,
trackFirstMessage,
}
+36 -11
View File
@@ -1,32 +1,57 @@
import type { AboutBuildInfo } from '../../components/scenarios/about/types'
import posthog from 'posthog-js'
import { defineStore, storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useBuildInfo } from '../../composables/use-build-info'
import { useSettingsAnalytics } from '../settings/analytics'
import {
isPosthogAvailableInBuild,
registerPosthogBuildInfo,
syncPosthogCapture,
} from './posthog'
import { useBuildInfo } from '../../composables'
export * from './posthog'
export * from './privacy-policy'
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)
watch(analyticsEnabled, (enabled, previousEnabled) => {
if (!isInitialized.value)
return
const shouldCapture = syncPosthogCapture(enabled)
if (shouldCapture) {
// 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()
}
registerPosthogBuildInfo(buildInfo.value)
}
})
function initialize() {
if (isInitialized.value)
return
appStartTime.value = Date.now()
// Register metadata with PostHog after buildInfo is set
posthog.register({
app_version: (buildInfo.value.version && buildInfo.value.version !== '0.0.0') ? buildInfo.value.version : 'dev',
app_commit: buildInfo.value.commit,
app_branch: buildInfo.value.branch,
app_build_time: buildInfo.value.builtOn,
})
if (isPosthogAvailableInBuild()) {
const shouldCapture = syncPosthogCapture(analyticsEnabled.value)
if (shouldCapture)
registerPosthogBuildInfo(buildInfo.value)
}
isInitialized.value = true
}
@@ -0,0 +1,75 @@
import type { AboutBuildInfo } from '../../components/scenarios/about/types'
import posthog from 'posthog-js'
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
import {
DEFAULT_POSTHOG_CONFIG,
POSTHOG_ENABLED,
POSTHOG_PROJECT_KEY_DESKTOP,
POSTHOG_PROJECT_KEY_POCKET,
POSTHOG_PROJECT_KEY_WEB,
} from '../../../../../posthog.config'
let posthogInitialized = false
function getPosthogProjectKey(): string {
if (isStageTamagotchi())
return POSTHOG_PROJECT_KEY_DESKTOP
if (isStageCapacitor())
return POSTHOG_PROJECT_KEY_POCKET
return POSTHOG_PROJECT_KEY_WEB
}
export function isPosthogAvailableInBuild(): boolean {
return POSTHOG_ENABLED
}
export function ensurePosthogInitialized(enabled: boolean): boolean {
if (!POSTHOG_ENABLED)
return false
if (posthogInitialized)
return true
posthog.init(getPosthogProjectKey(), {
...DEFAULT_POSTHOG_CONFIG,
opt_out_capturing_by_default: !enabled,
})
posthogInitialized = true
return true
}
export function syncPosthogCapture(enabled: boolean): boolean {
if (!POSTHOG_ENABLED)
return false
if (enabled) {
ensurePosthogInitialized(true)
if (posthog.has_opted_out_capturing())
posthog.opt_in_capturing()
return true
}
if (posthogInitialized && !posthog.has_opted_out_capturing())
posthog.opt_out_capturing()
return false
}
export function registerPosthogBuildInfo(buildInfo: AboutBuildInfo): void {
if (!posthogInitialized)
return
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,
})
}
@@ -0,0 +1,18 @@
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',
])
export function getAnalyticsPrivacyPolicyUrl(locale?: string): string {
const normalizedLocale = localeRemap[locale ?? 'en'] ?? locale ?? 'en'
const docsLocale = supportedPrivacyPolicyLocales.has(normalizedLocale)
? normalizedLocale
: 'en'
return `https://airi.moeru.ai/docs/${docsLocale}/about/privacy`
}
@@ -0,0 +1,15 @@
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { defineStore } from 'pinia'
export const useSettingsAnalytics = defineStore('settings-analytics', () => {
const analyticsEnabled = useLocalStorageManualReset<boolean>('settings/analytics/enabled', true)
function resetState() {
analyticsEnabled.reset()
}
return {
analyticsEnabled,
resetState,
}
})
@@ -1,30 +1,10 @@
import messages from '@proj-airi/i18n/locales'
import { resolveSupportedLocale } from '@proj-airi/i18n'
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { defineStore } from 'pinia'
import { onMounted } from 'vue'
const languageRemap: Record<string, string> = {
'zh-CN': 'zh-Hans',
'zh-TW': 'zh-Hans', // TODO: remove this when zh-Hant is supported
'zh-HK': 'zh-Hans', // TODO: remove this when zh-Hant is supported
'zh-Hant': 'zh-Hans', // TODO: remove this when zh-Hant is supported
'en-US': 'en',
'en-GB': 'en',
'en-AU': 'en',
'en': 'en',
'es-ES': 'es',
'es-MX': 'es',
'es-AR': 'es',
'es': 'es',
'ru': 'ru',
'ru-RU': 'ru',
'fr': 'fr',
'fr-FR': 'fr',
'ja': 'ja',
'ja-JP': 'ja',
}
export const useSettingsGeneral = defineStore('settings-general', () => {
const language = useLocalStorageManualReset<string>('settings/language', '')
@@ -41,14 +21,7 @@ export const useSettingsGeneral = defineStore('settings-general', () => {
language = navigator.language || 'en'
}
const languages = Object.keys(messages!)
if (languageRemap[language || 'en'] != null) {
language = languageRemap[language || 'en']
}
if (language && languages.includes(language))
return language
return 'en'
return resolveSupportedLocale(language, Object.keys(messages!))
}
function resetState() {
@@ -1,11 +1,13 @@
import { defineStore, storeToRefs } from 'pinia'
import { useSettingsAnalytics } from './analytics'
import { useSettingsControlsIsland } from './controls-island'
import { useSettingsGeneral } from './general'
import { useSettingsLive2d } from './live2d'
import { useSettingsStageModel } from './stage-model'
import { useSettingsTheme } from './theme'
export * from './analytics'
// Export sub-stores
export * from './audio-device'
export * from './controls-island'
@@ -25,6 +27,7 @@ export { DEFAULT_THEME_COLORS_HUE } from './theme'
*/
export const useSettings = defineStore('settings', () => {
const general = useSettingsGeneral()
const analytics = useSettingsAnalytics()
const stageModel = useSettingsStageModel()
const live2d = useSettingsLive2d()
const theme = useSettingsTheme()
@@ -32,6 +35,7 @@ export const useSettings = defineStore('settings', () => {
async function resetState() {
await stageModel.resetState()
analytics.resetState()
general.resetState()
live2d.resetState()
theme.resetState()
@@ -40,6 +44,7 @@ export const useSettings = defineStore('settings', () => {
// Extract refs from sub-stores to maintain proper reactivity
const generalRefs = storeToRefs(general)
const analyticsRefs = storeToRefs(analytics)
const stageModelRefs = storeToRefs(stageModel)
const live2dRefs = storeToRefs(live2d)
const themeRefs = storeToRefs(theme)
@@ -50,6 +55,7 @@ export const useSettings = defineStore('settings', () => {
disableTransitions: generalRefs.disableTransitions,
usePageSpecificTransitions: generalRefs.usePageSpecificTransitions,
language: generalRefs.language,
analyticsEnabled: analyticsRefs.analyticsEnabled,
websocketSecureEnabled: generalRefs.websocketSecureEnabled,
// Stage model settings
@@ -1,12 +1,17 @@
<script setup lang="ts">
import { SwitchRoot, SwitchThumb } from 'reka-ui'
const props = defineProps<{
disabled?: boolean
}>()
const modelValue = defineModel<boolean>({ required: true })
</script>
<template>
<SwitchRoot
v-model="modelValue"
:disabled="props.disabled"
:class="[
'duration-250 ease-in-out',
'focus-within:outline-none',
@@ -16,6 +21,7 @@ const modelValue = defineModel<boolean>({ required: true })
'data-[state=checked]:bg-primary-400 data-[state=unchecked]:bg-neutral-300 data-[state=checked]:dark:bg-primary-400/80 dark:data-[state=unchecked]:bg-neutral-800',
'relative h-7 w-12.5 rounded-full',
'shadow-sm focus-within:shadow-none',
props.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
]"
>
<SwitchThumb
@@ -4,6 +4,7 @@ import { Checkbox } from '../checkbox'
const props = defineProps<{
label?: string
description?: string
disabled?: boolean
}>()
const modelValue = defineModel<boolean>({ required: true })
@@ -24,7 +25,7 @@ const modelValue = defineModel<boolean>({ required: true })
</slot>
</div>
</div>
<Checkbox v-model="modelValue" />
<Checkbox v-model="modelValue" :disabled="props.disabled" />
</div>
</label>
</template>
+9 -2
View File
@@ -2,8 +2,15 @@
import type { PostHogConfig } from 'posthog-js'
// These PostHog project API keys are considered public and are safe
// to be stored here as clear text.
function isEnvFlagEnabled(value: string | undefined): boolean {
if (value == null)
return false
return /^(?:1|true|t|yes|y|on)$/i.test(value.trim())
}
// For Release workflows set `VITE_ENABLE_POSTHOG=true`.
export const POSTHOG_ENABLED = isEnvFlagEnabled(import.meta.env.VITE_ENABLE_POSTHOG)
export const POSTHOG_PROJECT_KEY_WEB
= import.meta.env.VITE_POSTHOG_PROJECT_KEY_WEB
+1
View File
@@ -1,6 +1,7 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_ENABLE_POSTHOG?: string
readonly VITE_POSTHOG_PROJECT_KEY_WEB?: string
readonly VITE_POSTHOG_PROJECT_KEY_DESKTOP?: string
readonly VITE_POSTHOG_PROJECT_KEY_POCKET?: string