mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
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:
co-authored by
autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
RainbowBird
Neko
parent
8baaff7778
commit
f85a809d7c
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
|
||||
+63
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user