Files
chat_web_app/llm-fe/src/llm-fe/utils/analytics.ts
T
westfarn 3858b97104
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Failing after 2m10s
Message actions + citation Sources (#97, #98) (#101)
## Summary
- Closes [#97](#97) — message action row: copy (raw markdown + code-block copy), thumbs up/down with optimistic updates + down-reason popover, and export menu (PDF / DOCX / CSV / XLSX / TXT) for a single message plus conversation-level export in the chat toolbar.
- Closes [#98](#98) — versioned WS `citations` frame parsing, Sources list under assistant bubbles, clickable `[n]` markers, and history hydrate from `Prompt.citations`.
- Backend companion for ratings: [chat_backend#67](ai_ml_operations/chat_backend#67). Thumbs UI posts to `prompt_feedback` once that lands; votes rehydrate from `conversation_details.feedback`.
- Export libs (`pdfmake`, `docx`, `papaparse`, `xlsx`) are dynamically imported so they stay out of the main path until used.

## Test plan
- [ ] Stream a grounded answer → Sources appear after stream; inline `[n]` highlights the matching source; reload keeps Sources.
- [ ] Non-grounded turn → no Sources header.
- [ ] Copy message + code block; confirm checkmark ~2s; failure path shows toast.
- [ ] Thumbs up/down optimistic UI; clear by re-click; down opens reason popover without blocking the vote (needs chat_backend#67).
- [ ] Export one message and whole conversation in all five formats; check searchable PDF text and DOCX structure.
- [ ] Action row: hover reveal on desktop, always visible on last/touch; hidden while streaming.
- [ ] `npm test -- --testPathPattern='wsFrames|clipboard|exportChat|promptFeedback|ConversationDetailCard'`Reviewed-on: #101
2026-08-04 03:32:17 -07:00

122 lines
3.9 KiB
TypeScript

import { hasAnalyticsConsent, isAnalyticsEnvironment } from './analyticsConsent';
import type { Account } from '../data';
type QueuedCall =
| { type: 'track'; eventName: string; data?: Record<string, unknown> }
| { type: 'identify'; userInfo: Record<string, unknown> };
const queue: QueuedCall[] = [];
/**
* Named product events for Tianji (consent-gated). Page views stay on tracker.js.
*
* | Event | When |
* |-------|------|
* | Login Success / Failed | Password or SSO sign-in |
* | Sign Up Started / Success / Failed | Self-serve registration |
* | Logout | Sign out |
* | Checkout Started | Stripe Checkout session created |
* | Payment Success / Canceled | Billing return URLs |
* | Conversation Created | New chat id assigned over WS |
* | Message Sent | User submits prompt (no content) |
* | ToS Acknowledged | POST acknowledge_tos succeeds |
* | Billing Portal Opened | Account manage / portal CTAs |
* | Subscription Upgrade Started | Upgrade intent (#75) |
* | Plan Change Started | Change-plan intent (#75) |
* | Subscription Cancel Started | Cancel intent (#75) |
* | Account Delete Started / Success / Failed | Self-delete (#34 companion) |
* | Message Copied / Rated / Rating Reason / Exported | Message actions (#97) |
*/
export const AnalyticsEvents = {
LOGIN_SUCCESS: 'Login Success',
LOGIN_FAILED: 'Login Failed',
SIGNUP_STARTED: 'Sign Up Started',
SIGNUP_SUCCESS: 'Sign Up Success',
SIGNUP_FAILED: 'Sign Up Failed',
LOGOUT: 'Logout',
CHECKOUT_STARTED: 'Checkout Started',
PAYMENT_SUCCESS: 'Payment Success',
PAYMENT_CANCELED: 'Payment Canceled',
CONVERSATION_CREATED: 'Conversation Created',
MESSAGE_SENT: 'Message Sent',
TOS_ACKNOWLEDGED: 'ToS Acknowledged',
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
SUBSCRIPTION_UPGRADE_STARTED: 'Subscription Upgrade Started',
PLAN_CHANGE_STARTED: 'Plan Change Started',
SUBSCRIPTION_CANCEL_STARTED: 'Subscription Cancel Started',
ACCOUNT_DELETE_STARTED: 'Account Delete Started',
ACCOUNT_DELETE_SUCCESS: 'Account Delete Success',
ACCOUNT_DELETE_FAILED: 'Account Delete Failed',
MESSAGE_COPIED: 'Message Copied',
MESSAGE_RATED: 'Message Rated',
MESSAGE_RATING_REASON: 'Message Rating Reason',
MESSAGE_EXPORTED: 'Message Exported',
} as const;
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
/** Custom events + identify require consent. Automatic page views do not. */
const isEventTrackingEnabled = (): boolean =>
isAnalyticsEnvironment() && hasAnalyticsConsent();
const runQueuedCall = (call: QueuedCall): void => {
if (!window.tianji) return;
if (call.type === 'track') {
window.tianji.track(call.eventName, call.data);
return;
}
window.tianji.identify(call.userInfo);
};
export const flushAnalyticsQueue = (): void => {
if (!isEventTrackingEnabled() || !window.tianji) return;
while (queue.length > 0) {
const call = queue.shift();
if (call) runQueuedCall(call);
}
};
export const trackEvent = (
eventName: AnalyticsEventName | string,
data?: Record<string, unknown>,
): void => {
if (!isEventTrackingEnabled()) return;
const payload = data ? { ...data } : undefined;
if (window.tianji?.track) {
window.tianji.track(eventName, payload);
return;
}
queue.push({ type: 'track', eventName, data: payload });
};
export const identifyUser = (userInfo: Record<string, unknown>): void => {
if (!isEventTrackingEnabled()) return;
if (window.tianji?.identify) {
window.tianji.identify(userInfo);
return;
}
queue.push({ type: 'identify', userInfo: { ...userInfo } });
};
/** Identify with stable non-sensitive traits only (no names / prompts). */
export const identifyAccount = (account: Account): void => {
const traits: Record<string, unknown> = {
userId: account.email,
};
if (account.company?.id != null) {
traits.companyId = account.company.id;
}
if (account.is_company_manager) {
traits.isCompanyManager = true;
}
identifyUser(traits);
};