添加统计
This commit is contained in:
1 parent
7eaeec1155
commit
5cd79ebaf1
5 files changed
+667
-128
No files matched your search
@@ -281,9 +281,18 @@ const AIImageGeneration = ({
|
||||
// 保存提示词到历史记录
|
||||
savePromptToHistory(prompt);
|
||||
|
||||
// 只清空prompt,保留任务列表显示
|
||||
// 完全清空表单(prompt、参考图、预览)
|
||||
setPrompt('');
|
||||
setUploadedImages([]);
|
||||
setGeneratedImage(null);
|
||||
setError(null);
|
||||
|
||||
// 清除缓存
|
||||
try {
|
||||
localStorage.removeItem(PREVIEW_CACHE_KEY);
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear cache:', error);
|
||||
}
|
||||
} else {
|
||||
// 任务创建失败(可能是重复提交)
|
||||
setError(
|
||||
|
||||
@@ -289,9 +289,21 @@ const AIVideoGeneration = ({
|
||||
// 保存提示词到历史记录
|
||||
savePromptToHistory(prompt);
|
||||
|
||||
// 只清空prompt,保留任务列表显示
|
||||
// 完全清空表单(prompt、参考图、预览)
|
||||
setPrompt('');
|
||||
setUploadedImage(null);
|
||||
setGeneratedVideo(null);
|
||||
setError(null);
|
||||
|
||||
// 清除视频播放
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
videoRef.current.src = '';
|
||||
videoRef.current.load();
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
cacheManager.clear();
|
||||
} else {
|
||||
// 任务创建失败
|
||||
setError(
|
||||
|
||||
@@ -9,6 +9,7 @@ import { GenerationParams, TaskType, TaskResult } from '../types/task.types';
|
||||
import { GenerationRequest, GenerationResponse, GenerationError } from '../types/generation.types';
|
||||
import { defaultGeminiClient, videoGeminiClient } from '../utils/gemini-api';
|
||||
import { TASK_TIMEOUT } from '../constants/TASK_CONSTANTS';
|
||||
import { analytics } from '../utils/umami-analytics';
|
||||
|
||||
/**
|
||||
* Generation API Service
|
||||
@@ -38,12 +39,25 @@ class GenerationAPIService {
|
||||
const abortController = new AbortController();
|
||||
this.abortControllers.set(taskId, abortController);
|
||||
|
||||
const startTime = Date.now();
|
||||
const taskType = type === TaskType.IMAGE ? 'image' : 'video';
|
||||
|
||||
// Track model call start
|
||||
analytics.trackModelCall({
|
||||
taskId,
|
||||
taskType,
|
||||
model: taskType === 'image' ? 'gemini-image' : 'gemini-video',
|
||||
promptLength: params.prompt.length,
|
||||
hasUploadedImage: !!(params as any).uploadedImage || !!(params as any).uploadedImages,
|
||||
startTime,
|
||||
});
|
||||
|
||||
try {
|
||||
console.log(`[GenerationAPI] Starting generation for task ${taskId} (${type})`);
|
||||
|
||||
|
||||
// Get timeout for this task type
|
||||
const timeout = TASK_TIMEOUT[type.toUpperCase() as keyof typeof TASK_TIMEOUT];
|
||||
|
||||
|
||||
// Create timeout promise
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
@@ -58,20 +72,55 @@ class GenerationAPIService {
|
||||
|
||||
// Race between generation and timeout
|
||||
const result = await Promise.race([generationPromise, timeoutPromise]);
|
||||
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(`[GenerationAPI] Generation completed for task ${taskId}`);
|
||||
|
||||
// Track success
|
||||
analytics.trackModelSuccess({
|
||||
taskId,
|
||||
taskType,
|
||||
model: taskType === 'image' ? 'gemini-image' : 'gemini-video',
|
||||
duration,
|
||||
resultSize: result.size,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const duration = Date.now() - startTime;
|
||||
console.error(`[GenerationAPI] Generation failed for task ${taskId}:`, error);
|
||||
|
||||
|
||||
if (error.message === 'TIMEOUT') {
|
||||
// Track timeout failure
|
||||
analytics.trackModelFailure({
|
||||
taskId,
|
||||
taskType,
|
||||
model: taskType === 'image' ? 'gemini-image' : 'gemini-video',
|
||||
duration,
|
||||
error: 'TIMEOUT',
|
||||
});
|
||||
throw new Error(`${type === TaskType.IMAGE ? '图片' : '视频'}生成超时`);
|
||||
}
|
||||
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
// Track cancellation
|
||||
analytics.trackTaskCancellation({
|
||||
taskId,
|
||||
taskType,
|
||||
duration,
|
||||
});
|
||||
throw new Error('任务已取消');
|
||||
}
|
||||
|
||||
|
||||
// Track other failures
|
||||
analytics.trackModelFailure({
|
||||
taskId,
|
||||
taskType,
|
||||
model: taskType === 'image' ? 'gemini-image' : 'gemini-video',
|
||||
duration,
|
||||
error: error.message || 'UNKNOWN_ERROR',
|
||||
});
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
// Cleanup abort controller
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { GeminiConfig, GeminiMessage, GeminiResponse, VideoGenerationOptions } from './types';
|
||||
import { DEFAULT_CONFIG, VIDEO_DEFAULT_CONFIG } from './config';
|
||||
import { isQuotaExceededError, isTimeoutError } from './utils';
|
||||
import { analytics } from '../umami-analytics';
|
||||
|
||||
/**
|
||||
* 使用原始 fetch 调用聊天 API
|
||||
@@ -13,32 +14,79 @@ export async function callApiRaw(
|
||||
config: GeminiConfig,
|
||||
messages: GeminiMessage[],
|
||||
): Promise<GeminiResponse> {
|
||||
const startTime = Date.now();
|
||||
const model = config.modelName || 'gemini-2.5-flash-image-vip';
|
||||
const endpoint = '/chat/completions';
|
||||
debugger
|
||||
// Track API call start
|
||||
analytics.trackAPICallStart({
|
||||
endpoint,
|
||||
model,
|
||||
messageCount: messages.length,
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const data = {
|
||||
model: config.modelName || 'gemini-2.5-flash-image-vip',
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
};
|
||||
|
||||
const url = `${config.baseUrl}/chat/completions`;
|
||||
const url = `${config.baseUrl}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
signal: AbortSignal.timeout(config.timeout || DEFAULT_CONFIG.timeout!),
|
||||
});
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
signal: AbortSignal.timeout(config.timeout || DEFAULT_CONFIG.timeout!),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
if (!response.ok) {
|
||||
const duration = Date.now() - startTime;
|
||||
analytics.trackAPICallFailure({
|
||||
endpoint,
|
||||
model,
|
||||
duration,
|
||||
error: response.statusText,
|
||||
httpStatus: response.status,
|
||||
stream: false,
|
||||
});
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// 处理非流式响应
|
||||
const result = await response.json();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
analytics.trackAPICallSuccess({
|
||||
endpoint,
|
||||
model,
|
||||
duration,
|
||||
responseLength: result.choices?.[0]?.message?.content?.length,
|
||||
stream: false,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
analytics.trackAPICallFailure({
|
||||
endpoint,
|
||||
model,
|
||||
duration,
|
||||
error: errorMessage,
|
||||
stream: false,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 处理非流式响应
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,12 +96,24 @@ export async function callApiStreamRaw(
|
||||
config: GeminiConfig,
|
||||
messages: GeminiMessage[],
|
||||
): Promise<GeminiResponse> {
|
||||
const startTime = Date.now();
|
||||
const model = config.modelName || 'gemini-2.5-flash-image-vip';
|
||||
const endpoint = '/chat/completions';
|
||||
|
||||
// Track API call start
|
||||
analytics.trackAPICallStart({
|
||||
endpoint,
|
||||
model,
|
||||
messageCount: messages.length,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const data = {
|
||||
model: config.modelName || 'gemini-2.5-flash-image-vip',
|
||||
model,
|
||||
messages,
|
||||
presence_penalty: 0,
|
||||
temperature: 0.5,
|
||||
@@ -61,69 +121,102 @@ export async function callApiStreamRaw(
|
||||
stream: true,
|
||||
};
|
||||
|
||||
const url = `${config.baseUrl}/chat/completions`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
signal: AbortSignal.timeout(config.timeout || DEFAULT_CONFIG.timeout!),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// 处理流式响应
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('无法获取响应流');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let fullContent = '';
|
||||
const url = `${config.baseUrl}${endpoint}`;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
signal: AbortSignal.timeout(config.timeout || DEFAULT_CONFIG.timeout!),
|
||||
});
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
if (!response.ok) {
|
||||
const duration = Date.now() - startTime;
|
||||
analytics.trackAPICallFailure({
|
||||
endpoint,
|
||||
model,
|
||||
duration,
|
||||
error: response.statusText,
|
||||
httpStatus: response.status,
|
||||
stream: true,
|
||||
});
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data === '[DONE]') {
|
||||
break;
|
||||
}
|
||||
// 处理流式响应
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('无法获取响应流');
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const content = parsed.choices?.[0]?.delta?.content;
|
||||
if (content) {
|
||||
fullContent += content;
|
||||
const decoder = new TextDecoder();
|
||||
let fullContent = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data === '[DONE]') {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const content = parsed.choices?.[0]?.delta?.content;
|
||||
if (content) {
|
||||
fullContent += content;
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误的数据块
|
||||
console.warn('解析流式数据块失败:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误的数据块
|
||||
console.warn('解析流式数据块失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
// 返回标准格式的响应
|
||||
return {
|
||||
choices: [{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: fullContent
|
||||
}
|
||||
}]
|
||||
};
|
||||
const duration = Date.now() - startTime;
|
||||
analytics.trackAPICallSuccess({
|
||||
endpoint,
|
||||
model,
|
||||
duration,
|
||||
responseLength: fullContent.length,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
// 返回标准格式的响应
|
||||
return {
|
||||
choices: [{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: fullContent
|
||||
}
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
analytics.trackAPICallFailure({
|
||||
endpoint,
|
||||
model,
|
||||
duration,
|
||||
error: errorMessage,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,6 +227,18 @@ export async function callVideoApiStreamRaw(
|
||||
messages: GeminiMessage[],
|
||||
options: VideoGenerationOptions = {}
|
||||
): Promise<GeminiResponse> {
|
||||
const startTime = Date.now();
|
||||
const model = config.modelName || VIDEO_DEFAULT_CONFIG.modelName;
|
||||
const endpoint = '/chat/completions';
|
||||
|
||||
// Track API call start
|
||||
analytics.trackAPICallStart({
|
||||
endpoint,
|
||||
model,
|
||||
messageCount: messages.length + 1, // +1 for system message
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
@@ -144,13 +249,13 @@ export async function callVideoApiStreamRaw(
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `You are Video Creator.\nCurrent model: ${config.modelName || VIDEO_DEFAULT_CONFIG.modelName}\nCurrent time: ${new Date().toLocaleString()}\nLatex inline: $x^2$\nLatex block: $e=mc^2$`
|
||||
text: `You are Video Creator.\nCurrent model: ${model}\nCurrent time: ${new Date().toLocaleString()}\nLatex inline: $x^2$\nLatex block: $e=mc^2$`
|
||||
}]
|
||||
};
|
||||
|
||||
const data = {
|
||||
max_tokens: options.max_tokens || 1024,
|
||||
model: config.modelName || VIDEO_DEFAULT_CONFIG.modelName,
|
||||
model,
|
||||
temperature: options.temperature || 0.5,
|
||||
top_p: options.top_p || 1,
|
||||
presence_penalty: options.presence_penalty || 0,
|
||||
@@ -159,69 +264,102 @@ export async function callVideoApiStreamRaw(
|
||||
stream: true,
|
||||
};
|
||||
|
||||
const url = `${config.baseUrl}/chat/completions`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
signal: AbortSignal.timeout(config.timeout || VIDEO_DEFAULT_CONFIG.timeout!),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// 处理流式响应
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('无法获取响应流');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let fullContent = '';
|
||||
const url = `${config.baseUrl}${endpoint}`;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
signal: AbortSignal.timeout(config.timeout || VIDEO_DEFAULT_CONFIG.timeout!),
|
||||
});
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
if (!response.ok) {
|
||||
const duration = Date.now() - startTime;
|
||||
analytics.trackAPICallFailure({
|
||||
endpoint,
|
||||
model,
|
||||
duration,
|
||||
error: response.statusText,
|
||||
httpStatus: response.status,
|
||||
stream: true,
|
||||
});
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data === '[DONE]') {
|
||||
break;
|
||||
}
|
||||
// 处理流式响应
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('无法获取响应流');
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const content = parsed.choices?.[0]?.delta?.content;
|
||||
if (content) {
|
||||
fullContent += content;
|
||||
const decoder = new TextDecoder();
|
||||
let fullContent = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data === '[DONE]') {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const content = parsed.choices?.[0]?.delta?.content;
|
||||
if (content) {
|
||||
fullContent += content;
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误的数据块
|
||||
console.warn('解析流式数据块失败:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误的数据块
|
||||
console.warn('解析流式数据块失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
// 返回标准格式的响应
|
||||
return {
|
||||
choices: [{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: fullContent
|
||||
}
|
||||
}]
|
||||
};
|
||||
const duration = Date.now() - startTime;
|
||||
analytics.trackAPICallSuccess({
|
||||
endpoint,
|
||||
model,
|
||||
duration,
|
||||
responseLength: fullContent.length,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
// 返回标准格式的响应
|
||||
return {
|
||||
choices: [{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: fullContent
|
||||
}
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
analytics.trackAPICallFailure({
|
||||
endpoint,
|
||||
model,
|
||||
duration,
|
||||
error: errorMessage,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,6 +371,8 @@ export async function callApiWithRetry(
|
||||
): Promise<GeminiResponse> {
|
||||
const maxRetries = config.maxRetries || DEFAULT_CONFIG.maxRetries!;
|
||||
const retryDelay = config.retryDelay || DEFAULT_CONFIG.retryDelay!;
|
||||
const model = config.modelName || 'gemini-2.5-flash-image-vip';
|
||||
const endpoint = '/chat/completions';
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
@@ -247,6 +387,14 @@ export async function callApiWithRetry(
|
||||
// 检查是否为配额超出错误或超时错误
|
||||
if (isQuotaExceededError(errorMessage) || isTimeoutError(errorMessage)) {
|
||||
if (attempt < maxRetries - 1) {
|
||||
// Track retry
|
||||
analytics.trackAPICallRetry({
|
||||
endpoint,
|
||||
model,
|
||||
attempt: attempt + 1,
|
||||
reason: isQuotaExceededError(errorMessage) ? 'QUOTA_EXCEEDED' : 'TIMEOUT',
|
||||
});
|
||||
|
||||
if (retryDelay > 0) {
|
||||
console.log(`将在 ${retryDelay}ms 后进行第 ${attempt + 2} 次重试...`);
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Umami Analytics Utility
|
||||
*
|
||||
* Provides type-safe event tracking for Umami analytics.
|
||||
* Tracks model calls, user interactions, and feature usage.
|
||||
*/
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
track: (eventName: string, eventData?: Record<string, any>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event categories for analytics
|
||||
*/
|
||||
export enum AnalyticsCategory {
|
||||
AI_GENERATION = 'ai_generation',
|
||||
USER_INTERACTION = 'user_interaction',
|
||||
FEATURE_USAGE = 'feature_usage',
|
||||
SYSTEM = 'system',
|
||||
}
|
||||
|
||||
/**
|
||||
* Event names for AI generation
|
||||
*/
|
||||
export enum AIGenerationEvent {
|
||||
IMAGE_GENERATION_START = 'image_generation_start',
|
||||
IMAGE_GENERATION_SUCCESS = 'image_generation_success',
|
||||
IMAGE_GENERATION_FAILED = 'image_generation_failed',
|
||||
VIDEO_GENERATION_START = 'video_generation_start',
|
||||
VIDEO_GENERATION_SUCCESS = 'video_generation_success',
|
||||
VIDEO_GENERATION_FAILED = 'video_generation_failed',
|
||||
TASK_CANCELLED = 'task_cancelled',
|
||||
}
|
||||
|
||||
/**
|
||||
* Event names for API calls
|
||||
*/
|
||||
export enum APICallEvent {
|
||||
API_CALL_START = 'api_call_start',
|
||||
API_CALL_SUCCESS = 'api_call_success',
|
||||
API_CALL_FAILED = 'api_call_failed',
|
||||
API_CALL_RETRY = 'api_call_retry',
|
||||
}
|
||||
|
||||
/**
|
||||
* Event names for user interactions
|
||||
*/
|
||||
export enum UserInteractionEvent {
|
||||
BUTTON_CLICK = 'button_click',
|
||||
DIALOG_OPEN = 'dialog_open',
|
||||
DIALOG_CLOSE = 'dialog_close',
|
||||
TOOLBAR_ACTION = 'toolbar_action',
|
||||
}
|
||||
|
||||
/**
|
||||
* Event names for feature usage
|
||||
*/
|
||||
export enum FeatureUsageEvent {
|
||||
TTD_DIALOG_OPEN = 'ttd_dialog_open',
|
||||
MIND_MAP_CREATE = 'mind_map_create',
|
||||
FREEHAND_DRAW = 'freehand_draw',
|
||||
IMAGE_UPLOAD = 'image_upload',
|
||||
VIDEO_UPLOAD = 'video_upload',
|
||||
}
|
||||
|
||||
/**
|
||||
* Analytics utility class
|
||||
*/
|
||||
class UmamiAnalytics {
|
||||
private isEnabled: boolean;
|
||||
|
||||
constructor() {
|
||||
this.isEnabled = typeof window !== 'undefined' && !!window.umami;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a custom event
|
||||
*
|
||||
* @param eventName - Name of the event
|
||||
* @param eventData - Additional data to track
|
||||
*/
|
||||
track(eventName: string, eventData?: Record<string, any>): void {
|
||||
if (!this.isEnabled || !window.umami) {
|
||||
console.debug('[Analytics] Tracking disabled or Umami not loaded:', eventName, eventData);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.umami.track(eventName, eventData);
|
||||
console.debug('[Analytics] Event tracked:', eventName, eventData);
|
||||
} catch (error) {
|
||||
console.error('[Analytics] Failed to track event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track AI generation event
|
||||
*
|
||||
* @param event - AI generation event type
|
||||
* @param data - Event data (model, duration, error, etc.)
|
||||
*/
|
||||
trackAIGeneration(
|
||||
event: AIGenerationEvent,
|
||||
data: {
|
||||
taskId?: string;
|
||||
taskType?: 'image' | 'video';
|
||||
model?: string;
|
||||
duration?: number;
|
||||
error?: string;
|
||||
promptLength?: number;
|
||||
hasUploadedImage?: boolean;
|
||||
}
|
||||
): void {
|
||||
this.track(event, {
|
||||
category: AnalyticsCategory.AI_GENERATION,
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track user interaction event
|
||||
*
|
||||
* @param event - User interaction event type
|
||||
* @param data - Event data
|
||||
*/
|
||||
trackUserInteraction(
|
||||
event: UserInteractionEvent,
|
||||
data: {
|
||||
component?: string;
|
||||
action?: string;
|
||||
label?: string;
|
||||
}
|
||||
): void {
|
||||
this.track(event, {
|
||||
category: AnalyticsCategory.USER_INTERACTION,
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track feature usage event
|
||||
*
|
||||
* @param event - Feature usage event type
|
||||
* @param data - Event data
|
||||
*/
|
||||
trackFeatureUsage(
|
||||
event: FeatureUsageEvent,
|
||||
data?: {
|
||||
feature?: string;
|
||||
value?: string | number;
|
||||
}
|
||||
): void {
|
||||
this.track(event, {
|
||||
category: AnalyticsCategory.FEATURE_USAGE,
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track model call with detailed metrics
|
||||
*
|
||||
* @param params - Model call parameters
|
||||
*/
|
||||
trackModelCall(params: {
|
||||
taskId: string;
|
||||
taskType: 'image' | 'video';
|
||||
model: string;
|
||||
promptLength: number;
|
||||
hasUploadedImage: boolean;
|
||||
startTime: number;
|
||||
}): void {
|
||||
const event = params.taskType === 'image'
|
||||
? AIGenerationEvent.IMAGE_GENERATION_START
|
||||
: AIGenerationEvent.VIDEO_GENERATION_START;
|
||||
|
||||
this.trackAIGeneration(event, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track successful model call
|
||||
*
|
||||
* @param params - Success parameters
|
||||
*/
|
||||
trackModelSuccess(params: {
|
||||
taskId: string;
|
||||
taskType: 'image' | 'video';
|
||||
model: string;
|
||||
duration: number;
|
||||
resultSize?: number;
|
||||
}): void {
|
||||
const event = params.taskType === 'image'
|
||||
? AIGenerationEvent.IMAGE_GENERATION_SUCCESS
|
||||
: AIGenerationEvent.VIDEO_GENERATION_SUCCESS;
|
||||
|
||||
this.trackAIGeneration(event, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track failed model call
|
||||
*
|
||||
* @param params - Failure parameters
|
||||
*/
|
||||
trackModelFailure(params: {
|
||||
taskId: string;
|
||||
taskType: 'image' | 'video';
|
||||
model: string;
|
||||
duration: number;
|
||||
error: string;
|
||||
}): void {
|
||||
const event = params.taskType === 'image'
|
||||
? AIGenerationEvent.IMAGE_GENERATION_FAILED
|
||||
: AIGenerationEvent.VIDEO_GENERATION_FAILED;
|
||||
|
||||
this.trackAIGeneration(event, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track task cancellation
|
||||
*
|
||||
* @param params - Cancellation parameters
|
||||
*/
|
||||
trackTaskCancellation(params: {
|
||||
taskId: string;
|
||||
taskType: 'image' | 'video';
|
||||
duration: number;
|
||||
}): void {
|
||||
this.trackAIGeneration(AIGenerationEvent.TASK_CANCELLED, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track API call start
|
||||
*
|
||||
* @param params - API call parameters
|
||||
*/
|
||||
trackAPICallStart(params: {
|
||||
endpoint: string;
|
||||
model: string;
|
||||
messageCount: number;
|
||||
stream: boolean;
|
||||
}): void {
|
||||
this.track(APICallEvent.API_CALL_START, {
|
||||
category: AnalyticsCategory.SYSTEM,
|
||||
...params,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track API call success
|
||||
*
|
||||
* @param params - Success parameters
|
||||
*/
|
||||
trackAPICallSuccess(params: {
|
||||
endpoint: string;
|
||||
model: string;
|
||||
duration: number;
|
||||
responseLength?: number;
|
||||
stream: boolean;
|
||||
}): void {
|
||||
this.track(APICallEvent.API_CALL_SUCCESS, {
|
||||
category: AnalyticsCategory.SYSTEM,
|
||||
...params,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track API call failure
|
||||
*
|
||||
* @param params - Failure parameters
|
||||
*/
|
||||
trackAPICallFailure(params: {
|
||||
endpoint: string;
|
||||
model: string;
|
||||
duration: number;
|
||||
error: string;
|
||||
httpStatus?: number;
|
||||
stream: boolean;
|
||||
}): void {
|
||||
this.track(APICallEvent.API_CALL_FAILED, {
|
||||
category: AnalyticsCategory.SYSTEM,
|
||||
...params,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track API call retry
|
||||
*
|
||||
* @param params - Retry parameters
|
||||
*/
|
||||
trackAPICallRetry(params: {
|
||||
endpoint: string;
|
||||
model: string;
|
||||
attempt: number;
|
||||
reason: string;
|
||||
}): void {
|
||||
this.track(APICallEvent.API_CALL_RETRY, {
|
||||
category: AnalyticsCategory.SYSTEM,
|
||||
...params,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if analytics is enabled
|
||||
*/
|
||||
isAnalyticsEnabled(): boolean {
|
||||
return this.isEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const analytics = new UmamiAnalytics();
|
||||
Reference in new issue
Block a user