From 4708e462d26cb3bbf0596d5b9e7961ccfdb22645 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sat, 6 Jun 2026 01:37:59 +0800 Subject: [PATCH] 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 Commit-Message-Assisted-by: Codex --- .../docs/ai-context/metrics-ownership.md | 3 + .../renderer/components/InteractiveArea.vue | 30 ++++- .../components/Layouts/InteractiveArea.vue | 8 ++ .../Layouts/MobileInteractiveArea.vue | 23 +++- .../components/Widgets/ChatActionButtons.vue | 17 ++- .../src/components/Widgets/ChatArea.vue | 2 +- .../composables/useStopSpeakingButton.test.ts | 16 ++- .../src/composables/useStopSpeakingButton.ts | 7 +- .../chat/components/sessions-drawer.vue | 11 +- .../src/composables/use-analytics.test.ts | 127 ++++++++++++++++++ .../stage-ui/src/composables/use-analytics.ts | 47 +++++++ 11 files changed, 280 insertions(+), 11 deletions(-) create mode 100644 packages/stage-ui/src/composables/use-analytics.test.ts diff --git a/apps/server/docs/ai-context/metrics-ownership.md b/apps/server/docs/ai-context/metrics-ownership.md index 8b3f82c4b..c12a0c03e 100644 --- a/apps/server/docs/ai-context/metrics-ownership.md +++ b/apps/server/docs/ai-context/metrics-ownership.md @@ -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 | diff --git a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue index 620e4d9d6..1b3b350b5 100644 --- a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue +++ b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue @@ -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>(() => ({ '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, + }) } @@ -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" >
diff --git a/packages/stage-layouts/src/components/Layouts/InteractiveArea.vue b/packages/stage-layouts/src/components/Layouts/InteractiveArea.vue index f33465c2e..e515ed2cb 100644 --- a/packages/stage-layouts/src/components/Layouts/InteractiveArea.vue +++ b/packages/stage-layouts/src/components/Layouts/InteractiveArea.vue @@ -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', + }) } diff --git a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue index 09ced3276..81b6323f5 100644 --- a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue +++ b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue @@ -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" >
diff --git a/packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue b/packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue index d0133d488..1871be0c4 100644 --- a/packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue +++ b/packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue @@ -1,6 +1,9 @@