feat(stage): track conversation product events

Add PostHog product events for manual TTS stop clicks and conversation controls across Web, mobile, and Electron chat surfaces. Document the events in metrics ownership and cover the analytics API with Vitest.

Signed-off-by: RainbowBird <git@luoling.moe>

Commit-Message-Assisted-by: Codex
This commit is contained in:
RainbowBird
2026-06-06 01:37:59 +08:00
parent a3dc9c24b0
commit 4708e462d2
11 changed files with 280 additions and 11 deletions
@@ -125,6 +125,9 @@
| Activation / Retention | `first_model_selected` / `model_switched` | 前端(consciousness store watcher | `packages/stage-ui/src/stores/analytics/index.ts` | PostHog |
| Retention | `character_created` | 前端 | `apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue` | PostHog |
| Retention | `chat_session_started` | 前端 | `packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue` | PostHog |
| Conversation controls | `chat_session_selected` | 前端 | `packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue` | PostHog |
| Conversation controls | `chat_message_deleted` / `chat_messages_cleared` / `chat_message_retried` | 前端 | `packages/stage-layouts/src/components/Layouts/*InteractiveArea.vue` / `packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue` / `apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue` | PostHog |
| Conversation controls | `tts_stop_clicked` | 前端 | `packages/stage-layouts/src/composables/useStopSpeakingButton.ts` | PostHog |
| Churn | `subscription_cancelled`(带 cancellation_reason | 外部 Stripe 数据源 | PostHog Stripe source connector | Stripe/Postgres |
| 老事件 | `provider_card_clicked` / `first_message_sent` | 前端 | `packages/stage-ui/src/composables/use-analytics.ts` | PostHog |
@@ -5,6 +5,7 @@ import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
import { errorMessageFrom } from '@moeru/std'
import { useStopSpeakingButton } from '@proj-airi/stage-layouts/composables/useStopSpeakingButton'
import { ChatHistory, JournalPreviewModal } from '@proj-airi/stage-ui/components'
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
import { useBackgroundStore } from '@proj-airi/stage-ui/stores/background'
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
@@ -57,7 +58,12 @@ const sendModeLabels = computed<Record<SendMode, string>>(() => ({
'ctrl-enter': t('stage.send-mode.ctrl-enter'),
'double-enter': t('stage.send-mode.double-enter'),
}))
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton()
const {
trackChatMessageDeleted,
trackChatMessageRetried,
trackChatMessagesCleared,
} = useAnalytics()
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton('electron')
const latestImageEntries = computed(() => {
if (!activeCardId.value)
@@ -198,7 +204,13 @@ watch(sendMode, () => {
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
async function handleDeleteMessage(index: number) {
const message = messages.value[index]
await chatSyncStore.requestDeleteMessage({ index })
trackChatMessageDeleted({
surface: 'electron',
source: 'history',
message_role: message?.role ?? 'unknown',
})
}
onMounted(() => {
@@ -210,6 +222,20 @@ async function handleRetryMessage(index: number) {
sessionId: chatSession.activeSessionId,
index,
})
trackChatMessageRetried({
surface: 'electron',
source: 'history',
})
}
async function handleCleanupMessages() {
const messageCount = messages.value.filter(message => message.role !== 'system').length
await chatSyncStore.requestCleanup()
trackChatMessagesCleared({
surface: 'electron',
source: 'chat_controls',
message_count: messageCount,
})
}
</script>
@@ -351,7 +377,7 @@ async function handleRetryMessage(index: number) {
hover:text="red-500 dark:red-400"
flex items-center justify-center rounded-md p-2 outline-none
transition-colors transition-transform active:scale-95
@click="() => chatSyncStore.requestCleanup()"
@click="handleCleanupMessages"
>
<div class="i-solar:trash-bin-2-bold-duotone" />
</button>
@@ -2,6 +2,7 @@
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
import { ChatHistory } from '@proj-airi/stage-ui/components'
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
@@ -20,9 +21,16 @@ const { streamingMessage } = storeToRefs(useChatStreamStore())
const isLoading = ref(true)
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
const { trackChatMessageDeleted } = useAnalytics()
function handleDeleteMessage(index: number) {
const message = messages.value[index]
messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index)
trackChatMessageDeleted({
surface: 'web',
source: 'history',
message_role: message?.role ?? 'unknown',
})
}
</script>
@@ -6,7 +6,7 @@ import { isStageTamagotchi } from '@proj-airi/stage-shared'
import { useThreeViewControl } from '@proj-airi/stage-ui-three'
import { ChatHistory, HearingConfigDialog } from '@proj-airi/stage-ui/components'
import { ChatSessionsDrawer } from '@proj-airi/stage-ui/components/scenarios/chat'
import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
import { useAnalytics, useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
@@ -40,9 +40,26 @@ const { messages } = storeToRefs(chatSession)
const { streamingMessage } = storeToRefs(chatStream)
const { sending } = storeToRefs(chatOrchestrator)
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
const { trackChatMessageDeleted, trackChatMessagesCleared } = useAnalytics()
function handleDeleteMessage(index: number) {
const message = messages.value[index]
messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index)
trackChatMessageDeleted({
surface: 'mobile',
source: 'history',
message_role: message?.role ?? 'unknown',
})
}
function handleCleanupMessages() {
const messageCount = messages.value.filter(message => message.role !== 'system').length
cleanupMessages()
trackChatMessagesCleared({
surface: 'mobile',
source: 'chat_controls',
message_count: messageCount,
})
}
const messageInput = ref('')
@@ -77,7 +94,7 @@ const { isListening, startStreamingTranscription, stopStreamingTranscription } =
isStageTamagotchi,
},
)
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton()
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton('mobile')
const toggleTranscription = () => isListening.value ? stopStreamingTranscription() : startStreamingTranscription()
async function handleSubmit() {
@@ -232,7 +249,7 @@ onMounted(() => {
bg="neutral-50/70 dark:neutral-800/70"
w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md
title="Cleanup Messages"
@click="cleanupMessages()"
@click="handleCleanupMessages"
>
<div class="i-solar:trash-bin-2-bold-duotone" />
</button>
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useTheme } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import ViewControls from '../Layouts/InteractiveArea/Actions/ViewControls.vue'
@@ -8,9 +11,21 @@ import ViewControls from '../Layouts/InteractiveArea/Actions/ViewControls.vue'
import { BackgroundDialogPicker } from '../Backgrounds'
const { cleanupMessages } = useChatMaintenanceStore()
const { messages } = storeToRefs(useChatSessionStore())
const { trackChatMessagesCleared } = useAnalytics()
const { isDark, toggleDark } = useTheme()
const backgroundDialogOpen = ref(false)
function handleCleanupMessages() {
const messageCount = messages.value.filter(message => message.role !== 'system').length
cleanupMessages()
trackChatMessagesCleared({
surface: 'web',
source: 'chat_controls',
message_count: messageCount,
})
}
</script>
<template>
@@ -24,7 +39,7 @@ const backgroundDialogOpen = ref(false)
hover:text="red-500 dark:red-400"
flex items-center justify-center rounded-md p-2 outline-none
transition-colors transition-transform active:scale-95
@click="cleanupMessages()"
@click="handleCleanupMessages"
>
<div class="i-solar:trash-bin-2-bold-duotone" />
</button>
@@ -60,7 +60,7 @@ const { isListening, startStreamingTranscription, stopStreamingTranscription, au
isStageTamagotchi,
},
)
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton()
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton('web')
async function handleSend() {
if (!messageInput.value.trim() || isComposing.value) {
@@ -5,6 +5,7 @@ import { useStopSpeakingButton } from './useStopSpeakingButton'
const nowSpeaking = ref(false)
const requestStopSpeakingMock = vi.fn()
const trackTtsStopClickedMock = vi.fn()
vi.mock('@proj-airi/stage-ui/stores/audio', () => ({
useSpeakingStore: () => ({
@@ -18,6 +19,12 @@ vi.mock('@proj-airi/stage-ui/stores/speech-output-control', () => ({
}),
}))
vi.mock('@proj-airi/stage-ui/composables/use-analytics', () => ({
useAnalytics: () => ({
trackTtsStopClicked: trackTtsStopClickedMock,
}),
}))
vi.mock('pinia', () => ({
storeToRefs: (store: object) => store,
}))
@@ -26,7 +33,7 @@ describe('useStopSpeakingButton', () => {
it('shows the manual stop button only while the assistant is speaking', () => {
nowSpeaking.value = false
const { showStopSpeakingButton } = useStopSpeakingButton()
const { showStopSpeakingButton } = useStopSpeakingButton('web')
expect(showStopSpeakingButton.value).toBe(false)
@@ -37,11 +44,16 @@ describe('useStopSpeakingButton', () => {
it('requests a manual chat stop without touching chat input state', () => {
requestStopSpeakingMock.mockClear()
trackTtsStopClickedMock.mockClear()
const { stopSpeakingFromChat } = useStopSpeakingButton()
const { stopSpeakingFromChat } = useStopSpeakingButton('mobile')
stopSpeakingFromChat()
expect(requestStopSpeakingMock).toHaveBeenCalledWith('manual-chat')
expect(trackTtsStopClickedMock).toHaveBeenCalledWith({
surface: 'mobile',
reason: 'manual-chat',
})
})
})
@@ -1,3 +1,6 @@
import type { ConversationAnalyticsSurface } from '@proj-airi/stage-ui/composables/use-analytics'
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
import { useSpeakingStore } from '@proj-airi/stage-ui/stores/audio'
import { useSpeechOutputControlStore } from '@proj-airi/stage-ui/stores/speech-output-control'
import { storeToRefs } from 'pinia'
@@ -15,13 +18,15 @@ import { computed } from 'vue'
* Returns:
* - Visibility state for the button and a click handler for manual chat stops.
*/
export function useStopSpeakingButton() {
export function useStopSpeakingButton(surface: ConversationAnalyticsSurface) {
const { nowSpeaking } = storeToRefs(useSpeakingStore())
const speechOutputControlStore = useSpeechOutputControlStore()
const { trackTtsStopClicked } = useAnalytics()
const showStopSpeakingButton = computed(() => nowSpeaking.value)
function stopSpeakingFromChat() {
trackTtsStopClicked({ surface, reason: 'manual-chat' })
speechOutputControlStore.requestStopSpeaking('manual-chat')
}
@@ -47,7 +47,7 @@ const { sessionMetas, sessionMessages, activeSessionId } = storeToRefs(chatSessi
const { activeCardId } = storeToRefs(useAiriCardStore())
const { userId } = storeToRefs(useAuthStore())
const { activeModel } = storeToRefs(useConsciousnessStore())
const { trackChatSessionStarted } = useAnalytics()
const { trackChatSessionSelected, trackChatSessionStarted } = useAnalytics()
// Re-entry guard for the "new session" button. Without this, a rapid
// double-click would call `createSession` twice (creating two orphan
@@ -154,6 +154,15 @@ const rows = computed<SessionRow[]>(() => {
})
async function selectSession(sessionId: string) {
const selectedRow = rows.value.find(row => row.meta.sessionId === sessionId)
if (sessionId !== activeSessionId.value && selectedRow) {
trackChatSessionSelected({
surface: isDesktop.value ? 'web' : 'mobile',
source: 'sessions_drawer',
message_count: (sessionMessages.value[sessionId] ?? []).filter(message => message.role !== 'system').length,
cloud_synced: !!selectedRow.meta.cloudChatId,
})
}
chatSession.setActiveSession(sessionId)
showDialog.value = false
}
@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useAnalytics } from './use-analytics'
const analyticsMocks = vi.hoisted(() => ({
ensurePosthogInitializedMock: vi.fn(() => true),
isPosthogAvailableInBuildMock: vi.fn(() => true),
markFirstMessageTrackedMock: vi.fn(),
posthogCaptureMock: vi.fn(),
}))
vi.mock('posthog-js', () => ({
default: {
capture: analyticsMocks.posthogCaptureMock,
},
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
locale: ref('en'),
}),
}))
vi.mock('../stores/analytics', () => ({
useSharedAnalyticsStore: () => ({
appStartTime: null,
firstMessageTracked: false,
markFirstMessageTracked: analyticsMocks.markFirstMessageTrackedMock,
}),
}))
vi.mock('../stores/analytics/posthog', () => ({
ensurePosthogInitialized: analyticsMocks.ensurePosthogInitializedMock,
isPosthogAvailableInBuild: analyticsMocks.isPosthogAvailableInBuildMock,
}))
vi.mock('../stores/analytics/privacy-policy', () => ({
getAnalyticsPrivacyPolicyUrl: () => 'https://example.com/privacy',
}))
vi.mock('../stores/settings/analytics', () => ({
useSettingsAnalytics: () => ({
analyticsEnabled: true,
}),
}))
vi.mock('../stores/settings/general', () => ({
useSettingsGeneral: () => ({
language: 'en',
}),
}))
describe('useAnalytics conversation product events', () => {
beforeEach(() => {
analyticsMocks.posthogCaptureMock.mockClear()
analyticsMocks.markFirstMessageTrackedMock.mockClear()
analyticsMocks.ensurePosthogInitializedMock.mockClear()
analyticsMocks.isPosthogAvailableInBuildMock.mockClear()
})
it('tracks manual TTS stop clicks with the UI surface and reason', () => {
const analytics = useAnalytics()
analytics.trackTtsStopClicked({
surface: 'mobile',
reason: 'manual-chat',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('tts_stop_clicked', {
surface: 'mobile',
reason: 'manual-chat',
})
})
it('tracks chat session selection from the sessions drawer', () => {
const analytics = useAnalytics()
analytics.trackChatSessionSelected({
surface: 'web',
source: 'sessions_drawer',
message_count: 4,
cloud_synced: true,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('chat_session_selected', {
surface: 'web',
source: 'sessions_drawer',
message_count: 4,
cloud_synced: true,
})
})
it('tracks destructive and recovery chat message actions', () => {
const analytics = useAnalytics()
analytics.trackChatMessageDeleted({
surface: 'electron',
source: 'history',
message_role: 'assistant',
})
analytics.trackChatMessagesCleared({
surface: 'electron',
source: 'chat_controls',
message_count: 3,
})
analytics.trackChatMessageRetried({
surface: 'electron',
source: 'history',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'chat_message_deleted', {
surface: 'electron',
source: 'history',
message_role: 'assistant',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'chat_messages_cleared', {
surface: 'electron',
source: 'chat_controls',
message_count: 3,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'chat_message_retried', {
surface: 'electron',
source: 'history',
})
})
})
@@ -9,6 +9,16 @@ import { getAnalyticsPrivacyPolicyUrl } from '../stores/analytics/privacy-policy
import { useSettingsAnalytics } from '../stores/settings/analytics'
import { useSettingsGeneral } from '../stores/settings/general'
/**
* User-facing chat surfaces that can emit product analytics.
*/
export type ConversationAnalyticsSurface = 'web' | 'mobile' | 'electron'
/**
* Low-cardinality source names for conversation action events.
*/
export type ConversationAnalyticsSource = 'chat_controls' | 'history' | 'sessions_drawer'
export function useAnalytics() {
const analyticsStore = useSharedAnalyticsStore()
const settingsAnalytics = useSettingsAnalytics()
@@ -207,6 +217,38 @@ export function useAnalytics() {
posthog.capture('message_round', properties)
}
// ─── Conversation action events ─────────────────────────────────────
function trackTtsStopClicked(properties: { surface: ConversationAnalyticsSurface, reason: 'manual-chat' }) {
if (!canCapture())
return
posthog.capture('tts_stop_clicked', properties)
}
function trackChatSessionSelected(properties: { surface: ConversationAnalyticsSurface, source: 'sessions_drawer', message_count: number, cloud_synced: boolean }) {
if (!canCapture())
return
posthog.capture('chat_session_selected', properties)
}
function trackChatMessageDeleted(properties: { surface: ConversationAnalyticsSurface, source: 'history', message_role: string }) {
if (!canCapture())
return
posthog.capture('chat_message_deleted', properties)
}
function trackChatMessagesCleared(properties: { surface: ConversationAnalyticsSurface, source: 'chat_controls', message_count: number }) {
if (!canCapture())
return
posthog.capture('chat_messages_cleared', properties)
}
function trackChatMessageRetried(properties: { surface: ConversationAnalyticsSurface, source: 'history' }) {
if (!canCapture())
return
posthog.capture('chat_message_retried', properties)
}
// ─── STT events ──────────────────────────────────────────────────────
function trackSttStarted(provider: string) {
@@ -362,6 +404,11 @@ export function useAnalytics() {
trackLlmFirstToken,
trackAssistantResponseRendered,
trackMessageRound,
trackTtsStopClicked,
trackChatSessionSelected,
trackChatMessageDeleted,
trackChatMessagesCleared,
trackChatMessageRetried,
trackSttStarted,
trackSttSucceeded,