refactor(stage): infer conversation analytics surface
Centralize conversation event surface detection inside useAnalytics and keep chat UI call sites focused on business fields. Signed-off-by: RainbowBird <git@luoling.moe> Commit-Message-Assisted-by: Codex
This commit is contained in:
@@ -115,6 +115,7 @@
|
|||||||
- 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 初始化,三个 app(web / desktop / pocket)按 `isStageTamagotchi()` 等选 project key
|
- 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 初始化,三个 app(web / desktop / pocket)按 `isStageTamagotchi()` 等选 project key
|
||||||
- Server 不接入 `posthog-node`。后端产品事件写 `product_events`,Grafana 展示低基数聚合;需要 PostHog revenue/person analytics 时优先用 PostHog Stripe source connector 或离线导入,不允许在 API 请求路径同步发 PostHog。
|
- Server 不接入 `posthog-node`。后端产品事件写 `product_events`,Grafana 展示低基数聚合;需要 PostHog revenue/person analytics 时优先用 PostHog Stripe source connector 或离线导入,不允许在 API 请求路径同步发 PostHog。
|
||||||
- 前端 identity:`useSharedAnalyticsStore.initialize()` watch `authStore.isAuthenticated` 自动调 `posthog.identify(user.id)` / `reset()`
|
- 前端 identity:`useSharedAnalyticsStore.initialize()` watch `authStore.isAuthenticated` 自动调 `posthog.identify(user.id)` / `reset()`
|
||||||
|
- Conversation controls 事件的 `surface` 由 `packages/stage-ui/src/composables/use-analytics.ts` 统一按 runtime 推断,UI 调用点只传业务字段。
|
||||||
|
|
||||||
已埋点:
|
已埋点:
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const {
|
|||||||
trackChatMessageRetried,
|
trackChatMessageRetried,
|
||||||
trackChatMessagesCleared,
|
trackChatMessagesCleared,
|
||||||
} = useAnalytics()
|
} = useAnalytics()
|
||||||
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton('electron')
|
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton()
|
||||||
|
|
||||||
const latestImageEntries = computed(() => {
|
const latestImageEntries = computed(() => {
|
||||||
if (!activeCardId.value)
|
if (!activeCardId.value)
|
||||||
@@ -207,7 +207,6 @@ async function handleDeleteMessage(index: number) {
|
|||||||
const message = messages.value[index]
|
const message = messages.value[index]
|
||||||
await chatSyncStore.requestDeleteMessage({ index })
|
await chatSyncStore.requestDeleteMessage({ index })
|
||||||
trackChatMessageDeleted({
|
trackChatMessageDeleted({
|
||||||
surface: 'electron',
|
|
||||||
source: 'history',
|
source: 'history',
|
||||||
message_role: message?.role ?? 'unknown',
|
message_role: message?.role ?? 'unknown',
|
||||||
})
|
})
|
||||||
@@ -223,7 +222,6 @@ async function handleRetryMessage(index: number) {
|
|||||||
index,
|
index,
|
||||||
})
|
})
|
||||||
trackChatMessageRetried({
|
trackChatMessageRetried({
|
||||||
surface: 'electron',
|
|
||||||
source: 'history',
|
source: 'history',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -232,7 +230,6 @@ async function handleCleanupMessages() {
|
|||||||
const messageCount = messages.value.filter(message => message.role !== 'system').length
|
const messageCount = messages.value.filter(message => message.role !== 'system').length
|
||||||
await chatSyncStore.requestCleanup()
|
await chatSyncStore.requestCleanup()
|
||||||
trackChatMessagesCleared({
|
trackChatMessagesCleared({
|
||||||
surface: 'electron',
|
|
||||||
source: 'chat_controls',
|
source: 'chat_controls',
|
||||||
message_count: messageCount,
|
message_count: messageCount,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ function handleDeleteMessage(index: number) {
|
|||||||
const message = messages.value[index]
|
const message = messages.value[index]
|
||||||
messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index)
|
messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index)
|
||||||
trackChatMessageDeleted({
|
trackChatMessageDeleted({
|
||||||
surface: 'web',
|
|
||||||
source: 'history',
|
source: 'history',
|
||||||
message_role: message?.role ?? 'unknown',
|
message_role: message?.role ?? 'unknown',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ function handleDeleteMessage(index: number) {
|
|||||||
const message = messages.value[index]
|
const message = messages.value[index]
|
||||||
messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index)
|
messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index)
|
||||||
trackChatMessageDeleted({
|
trackChatMessageDeleted({
|
||||||
surface: 'mobile',
|
|
||||||
source: 'history',
|
source: 'history',
|
||||||
message_role: message?.role ?? 'unknown',
|
message_role: message?.role ?? 'unknown',
|
||||||
})
|
})
|
||||||
@@ -56,7 +55,6 @@ function handleCleanupMessages() {
|
|||||||
const messageCount = messages.value.filter(message => message.role !== 'system').length
|
const messageCount = messages.value.filter(message => message.role !== 'system').length
|
||||||
cleanupMessages()
|
cleanupMessages()
|
||||||
trackChatMessagesCleared({
|
trackChatMessagesCleared({
|
||||||
surface: 'mobile',
|
|
||||||
source: 'chat_controls',
|
source: 'chat_controls',
|
||||||
message_count: messageCount,
|
message_count: messageCount,
|
||||||
})
|
})
|
||||||
@@ -94,7 +92,7 @@ const { isListening, startStreamingTranscription, stopStreamingTranscription } =
|
|||||||
isStageTamagotchi,
|
isStageTamagotchi,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton('mobile')
|
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton()
|
||||||
const toggleTranscription = () => isListening.value ? stopStreamingTranscription() : startStreamingTranscription()
|
const toggleTranscription = () => isListening.value ? stopStreamingTranscription() : startStreamingTranscription()
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ function handleCleanupMessages() {
|
|||||||
const messageCount = messages.value.filter(message => message.role !== 'system').length
|
const messageCount = messages.value.filter(message => message.role !== 'system').length
|
||||||
cleanupMessages()
|
cleanupMessages()
|
||||||
trackChatMessagesCleared({
|
trackChatMessagesCleared({
|
||||||
surface: 'web',
|
|
||||||
source: 'chat_controls',
|
source: 'chat_controls',
|
||||||
message_count: messageCount,
|
message_count: messageCount,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const { isListening, startStreamingTranscription, stopStreamingTranscription, au
|
|||||||
isStageTamagotchi,
|
isStageTamagotchi,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton('web')
|
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton()
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
if (!messageInput.value.trim() || isComposing.value) {
|
if (!messageInput.value.trim() || isComposing.value) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ describe('useStopSpeakingButton', () => {
|
|||||||
it('shows the manual stop button only while the assistant is speaking', () => {
|
it('shows the manual stop button only while the assistant is speaking', () => {
|
||||||
nowSpeaking.value = false
|
nowSpeaking.value = false
|
||||||
|
|
||||||
const { showStopSpeakingButton } = useStopSpeakingButton('web')
|
const { showStopSpeakingButton } = useStopSpeakingButton()
|
||||||
|
|
||||||
expect(showStopSpeakingButton.value).toBe(false)
|
expect(showStopSpeakingButton.value).toBe(false)
|
||||||
|
|
||||||
@@ -46,13 +46,12 @@ describe('useStopSpeakingButton', () => {
|
|||||||
requestStopSpeakingMock.mockClear()
|
requestStopSpeakingMock.mockClear()
|
||||||
trackTtsStopClickedMock.mockClear()
|
trackTtsStopClickedMock.mockClear()
|
||||||
|
|
||||||
const { stopSpeakingFromChat } = useStopSpeakingButton('mobile')
|
const { stopSpeakingFromChat } = useStopSpeakingButton()
|
||||||
|
|
||||||
stopSpeakingFromChat()
|
stopSpeakingFromChat()
|
||||||
|
|
||||||
expect(requestStopSpeakingMock).toHaveBeenCalledWith('manual-chat')
|
expect(requestStopSpeakingMock).toHaveBeenCalledWith('manual-chat')
|
||||||
expect(trackTtsStopClickedMock).toHaveBeenCalledWith({
|
expect(trackTtsStopClickedMock).toHaveBeenCalledWith({
|
||||||
surface: 'mobile',
|
|
||||||
reason: 'manual-chat',
|
reason: 'manual-chat',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { ConversationAnalyticsSurface } from '@proj-airi/stage-ui/composables/use-analytics'
|
|
||||||
|
|
||||||
import { useAnalytics } 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 { useSpeakingStore } from '@proj-airi/stage-ui/stores/audio'
|
||||||
import { useSpeechOutputControlStore } from '@proj-airi/stage-ui/stores/speech-output-control'
|
import { useSpeechOutputControlStore } from '@proj-airi/stage-ui/stores/speech-output-control'
|
||||||
@@ -18,7 +16,7 @@ import { computed } from 'vue'
|
|||||||
* Returns:
|
* Returns:
|
||||||
* - Visibility state for the button and a click handler for manual chat stops.
|
* - Visibility state for the button and a click handler for manual chat stops.
|
||||||
*/
|
*/
|
||||||
export function useStopSpeakingButton(surface: ConversationAnalyticsSurface) {
|
export function useStopSpeakingButton() {
|
||||||
const { nowSpeaking } = storeToRefs(useSpeakingStore())
|
const { nowSpeaking } = storeToRefs(useSpeakingStore())
|
||||||
const speechOutputControlStore = useSpeechOutputControlStore()
|
const speechOutputControlStore = useSpeechOutputControlStore()
|
||||||
const { trackTtsStopClicked } = useAnalytics()
|
const { trackTtsStopClicked } = useAnalytics()
|
||||||
@@ -26,7 +24,7 @@ export function useStopSpeakingButton(surface: ConversationAnalyticsSurface) {
|
|||||||
const showStopSpeakingButton = computed(() => nowSpeaking.value)
|
const showStopSpeakingButton = computed(() => nowSpeaking.value)
|
||||||
|
|
||||||
function stopSpeakingFromChat() {
|
function stopSpeakingFromChat() {
|
||||||
trackTtsStopClicked({ surface, reason: 'manual-chat' })
|
trackTtsStopClicked({ reason: 'manual-chat' })
|
||||||
speechOutputControlStore.requestStopSpeaking('manual-chat')
|
speechOutputControlStore.requestStopSpeaking('manual-chat')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,6 @@ async function selectSession(sessionId: string) {
|
|||||||
const selectedRow = rows.value.find(row => row.meta.sessionId === sessionId)
|
const selectedRow = rows.value.find(row => row.meta.sessionId === sessionId)
|
||||||
if (sessionId !== activeSessionId.value && selectedRow) {
|
if (sessionId !== activeSessionId.value && selectedRow) {
|
||||||
trackChatSessionSelected({
|
trackChatSessionSelected({
|
||||||
surface: isDesktop.value ? 'web' : 'mobile',
|
|
||||||
source: 'sessions_drawer',
|
source: 'sessions_drawer',
|
||||||
message_count: (sessionMessages.value[sessionId] ?? []).filter(message => message.role !== 'system').length,
|
message_count: (sessionMessages.value[sessionId] ?? []).filter(message => message.role !== 'system').length,
|
||||||
cloud_synced: !!selectedRow.meta.cloudChatId,
|
cloud_synced: !!selectedRow.meta.cloudChatId,
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ import { useAnalytics } from './use-analytics'
|
|||||||
|
|
||||||
const analyticsMocks = vi.hoisted(() => ({
|
const analyticsMocks = vi.hoisted(() => ({
|
||||||
ensurePosthogInitializedMock: vi.fn(() => true),
|
ensurePosthogInitializedMock: vi.fn(() => true),
|
||||||
|
isStageCapacitorMock: vi.fn(() => false),
|
||||||
|
isStageTamagotchiMock: vi.fn(() => false),
|
||||||
isPosthogAvailableInBuildMock: vi.fn(() => true),
|
isPosthogAvailableInBuildMock: vi.fn(() => true),
|
||||||
markFirstMessageTrackedMock: vi.fn(),
|
markFirstMessageTrackedMock: vi.fn(),
|
||||||
posthogCaptureMock: vi.fn(),
|
posthogCaptureMock: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@proj-airi/stage-shared', () => ({
|
||||||
|
isStageCapacitor: analyticsMocks.isStageCapacitorMock,
|
||||||
|
isStageTamagotchi: analyticsMocks.isStageTamagotchiMock,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('posthog-js', () => ({
|
vi.mock('posthog-js', () => ({
|
||||||
default: {
|
default: {
|
||||||
capture: analyticsMocks.posthogCaptureMock,
|
capture: analyticsMocks.posthogCaptureMock,
|
||||||
@@ -56,56 +63,57 @@ describe('useAnalytics conversation product events', () => {
|
|||||||
analyticsMocks.posthogCaptureMock.mockClear()
|
analyticsMocks.posthogCaptureMock.mockClear()
|
||||||
analyticsMocks.markFirstMessageTrackedMock.mockClear()
|
analyticsMocks.markFirstMessageTrackedMock.mockClear()
|
||||||
analyticsMocks.ensurePosthogInitializedMock.mockClear()
|
analyticsMocks.ensurePosthogInitializedMock.mockClear()
|
||||||
|
analyticsMocks.isStageCapacitorMock.mockReset()
|
||||||
|
analyticsMocks.isStageTamagotchiMock.mockReset()
|
||||||
|
analyticsMocks.isStageCapacitorMock.mockReturnValue(false)
|
||||||
|
analyticsMocks.isStageTamagotchiMock.mockReturnValue(false)
|
||||||
analyticsMocks.isPosthogAvailableInBuildMock.mockClear()
|
analyticsMocks.isPosthogAvailableInBuildMock.mockClear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('tracks manual TTS stop clicks with the UI surface and reason', () => {
|
it('infers the web surface for browser conversation actions', () => {
|
||||||
const analytics = useAnalytics()
|
const analytics = useAnalytics()
|
||||||
|
|
||||||
analytics.trackTtsStopClicked({
|
analytics.trackTtsStopClicked({
|
||||||
surface: 'mobile',
|
|
||||||
reason: 'manual-chat',
|
reason: 'manual-chat',
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('tts_stop_clicked', {
|
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('tts_stop_clicked', {
|
||||||
surface: 'mobile',
|
surface: 'web',
|
||||||
reason: 'manual-chat',
|
reason: 'manual-chat',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('tracks chat session selection from the sessions drawer', () => {
|
it('infers the mobile surface for capacitor conversation actions', () => {
|
||||||
|
analyticsMocks.isStageCapacitorMock.mockReturnValue(true)
|
||||||
const analytics = useAnalytics()
|
const analytics = useAnalytics()
|
||||||
|
|
||||||
analytics.trackChatSessionSelected({
|
analytics.trackChatSessionSelected({
|
||||||
surface: 'web',
|
|
||||||
source: 'sessions_drawer',
|
source: 'sessions_drawer',
|
||||||
message_count: 4,
|
message_count: 4,
|
||||||
cloud_synced: true,
|
cloud_synced: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('chat_session_selected', {
|
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('chat_session_selected', {
|
||||||
surface: 'web',
|
surface: 'mobile',
|
||||||
source: 'sessions_drawer',
|
source: 'sessions_drawer',
|
||||||
message_count: 4,
|
message_count: 4,
|
||||||
cloud_synced: true,
|
cloud_synced: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('tracks destructive and recovery chat message actions', () => {
|
it('infers the electron surface for destructive and recovery chat message actions', () => {
|
||||||
|
analyticsMocks.isStageTamagotchiMock.mockReturnValue(true)
|
||||||
const analytics = useAnalytics()
|
const analytics = useAnalytics()
|
||||||
|
|
||||||
analytics.trackChatMessageDeleted({
|
analytics.trackChatMessageDeleted({
|
||||||
surface: 'electron',
|
|
||||||
source: 'history',
|
source: 'history',
|
||||||
message_role: 'assistant',
|
message_role: 'assistant',
|
||||||
})
|
})
|
||||||
analytics.trackChatMessagesCleared({
|
analytics.trackChatMessagesCleared({
|
||||||
surface: 'electron',
|
|
||||||
source: 'chat_controls',
|
source: 'chat_controls',
|
||||||
message_count: 3,
|
message_count: 3,
|
||||||
})
|
})
|
||||||
analytics.trackChatMessageRetried({
|
analytics.trackChatMessageRetried({
|
||||||
surface: 'electron',
|
|
||||||
source: 'history',
|
source: 'history',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import posthog from 'posthog-js'
|
import posthog from 'posthog-js'
|
||||||
|
|
||||||
|
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
@@ -19,6 +20,16 @@ export type ConversationAnalyticsSurface = 'web' | 'mobile' | 'electron'
|
|||||||
*/
|
*/
|
||||||
export type ConversationAnalyticsSource = 'chat_controls' | 'history' | 'sessions_drawer'
|
export type ConversationAnalyticsSource = 'chat_controls' | 'history' | 'sessions_drawer'
|
||||||
|
|
||||||
|
function getConversationAnalyticsSurface(): ConversationAnalyticsSurface {
|
||||||
|
if (isStageTamagotchi())
|
||||||
|
return 'electron'
|
||||||
|
|
||||||
|
if (isStageCapacitor())
|
||||||
|
return 'mobile'
|
||||||
|
|
||||||
|
return 'web'
|
||||||
|
}
|
||||||
|
|
||||||
export function useAnalytics() {
|
export function useAnalytics() {
|
||||||
const analyticsStore = useSharedAnalyticsStore()
|
const analyticsStore = useSharedAnalyticsStore()
|
||||||
const settingsAnalytics = useSettingsAnalytics()
|
const settingsAnalytics = useSettingsAnalytics()
|
||||||
@@ -219,34 +230,49 @@ export function useAnalytics() {
|
|||||||
|
|
||||||
// ─── Conversation action events ─────────────────────────────────────
|
// ─── Conversation action events ─────────────────────────────────────
|
||||||
|
|
||||||
function trackTtsStopClicked(properties: { surface: ConversationAnalyticsSurface, reason: 'manual-chat' }) {
|
function trackTtsStopClicked(properties: { reason: 'manual-chat' }) {
|
||||||
if (!canCapture())
|
if (!canCapture())
|
||||||
return
|
return
|
||||||
posthog.capture('tts_stop_clicked', properties)
|
posthog.capture('tts_stop_clicked', {
|
||||||
|
...properties,
|
||||||
|
surface: getConversationAnalyticsSurface(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackChatSessionSelected(properties: { surface: ConversationAnalyticsSurface, source: 'sessions_drawer', message_count: number, cloud_synced: boolean }) {
|
function trackChatSessionSelected(properties: { source: 'sessions_drawer', message_count: number, cloud_synced: boolean }) {
|
||||||
if (!canCapture())
|
if (!canCapture())
|
||||||
return
|
return
|
||||||
posthog.capture('chat_session_selected', properties)
|
posthog.capture('chat_session_selected', {
|
||||||
|
...properties,
|
||||||
|
surface: getConversationAnalyticsSurface(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackChatMessageDeleted(properties: { surface: ConversationAnalyticsSurface, source: 'history', message_role: string }) {
|
function trackChatMessageDeleted(properties: { source: 'history', message_role: string }) {
|
||||||
if (!canCapture())
|
if (!canCapture())
|
||||||
return
|
return
|
||||||
posthog.capture('chat_message_deleted', properties)
|
posthog.capture('chat_message_deleted', {
|
||||||
|
...properties,
|
||||||
|
surface: getConversationAnalyticsSurface(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackChatMessagesCleared(properties: { surface: ConversationAnalyticsSurface, source: 'chat_controls', message_count: number }) {
|
function trackChatMessagesCleared(properties: { source: 'chat_controls', message_count: number }) {
|
||||||
if (!canCapture())
|
if (!canCapture())
|
||||||
return
|
return
|
||||||
posthog.capture('chat_messages_cleared', properties)
|
posthog.capture('chat_messages_cleared', {
|
||||||
|
...properties,
|
||||||
|
surface: getConversationAnalyticsSurface(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackChatMessageRetried(properties: { surface: ConversationAnalyticsSurface, source: 'history' }) {
|
function trackChatMessageRetried(properties: { source: 'history' }) {
|
||||||
if (!canCapture())
|
if (!canCapture())
|
||||||
return
|
return
|
||||||
posthog.capture('chat_message_retried', properties)
|
posthog.capture('chat_message_retried', {
|
||||||
|
...properties,
|
||||||
|
surface: getConversationAnalyticsSurface(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── STT events ──────────────────────────────────────────────────────
|
// ─── STT events ──────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user