添加统计

This commit is contained in:
liquid committed 2025-11-23 17:32:39 +08:00
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
+268 -120
View File
@@ -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();