diff --git a/packages/drawnix/src/components/ttd-dialog/ai-image-generation.tsx b/packages/drawnix/src/components/ttd-dialog/ai-image-generation.tsx index 268ef257..50c1bb3a 100644 --- a/packages/drawnix/src/components/ttd-dialog/ai-image-generation.tsx +++ b/packages/drawnix/src/components/ttd-dialog/ai-image-generation.tsx @@ -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( diff --git a/packages/drawnix/src/components/ttd-dialog/ai-video-generation.tsx b/packages/drawnix/src/components/ttd-dialog/ai-video-generation.tsx index 9ca50993..cc9f9690 100644 --- a/packages/drawnix/src/components/ttd-dialog/ai-video-generation.tsx +++ b/packages/drawnix/src/components/ttd-dialog/ai-video-generation.tsx @@ -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( diff --git a/packages/drawnix/src/services/generation-api-service.ts b/packages/drawnix/src/services/generation-api-service.ts index 43600386..903b052a 100644 --- a/packages/drawnix/src/services/generation-api-service.ts +++ b/packages/drawnix/src/services/generation-api-service.ts @@ -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((_, 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 diff --git a/packages/drawnix/src/utils/gemini-api/apiCalls.ts b/packages/drawnix/src/utils/gemini-api/apiCalls.ts index 33492896..1ad4024c 100644 --- a/packages/drawnix/src/utils/gemini-api/apiCalls.ts +++ b/packages/drawnix/src/utils/gemini-api/apiCalls.ts @@ -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 { + 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 { + 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 { + 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 { 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)); diff --git a/packages/drawnix/src/utils/umami-analytics.ts b/packages/drawnix/src/utils/umami-analytics.ts new file mode 100644 index 00000000..0112d86c --- /dev/null +++ b/packages/drawnix/src/utils/umami-analytics.ts @@ -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) => 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): 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();