diff --git a/backend/DramaLing.Api/Controllers/FlashcardsController.cs b/backend/DramaLing.Api/Controllers/FlashcardsController.cs index b43375a..490ab81 100644 --- a/backend/DramaLing.Api/Controllers/FlashcardsController.cs +++ b/backend/DramaLing.Api/Controllers/FlashcardsController.cs @@ -116,23 +116,18 @@ public class FlashcardsController : ControllerBase var flashcardDtos = new List(); foreach (var flashcard in flashcards) { - var exampleImages = new List(); - - // 處理關聯的圖片 - foreach (var flashcardImage in flashcard.FlashcardExampleImages) - { - var imageUrl = await _imageStorageService.GetImageUrlAsync(flashcardImage.ExampleImage.RelativePath); - - exampleImages.Add(new ExampleImageDto + // 獲取例句圖片資料 (與 GetFlashcard 方法保持一致) + var exampleImages = flashcard.FlashcardExampleImages? + .Select(fei => new { - Id = flashcardImage.ExampleImage.Id.ToString(), - ImageUrl = imageUrl, - IsPrimary = flashcardImage.IsPrimary, - QualityScore = flashcardImage.ExampleImage.QualityScore, - FileSize = flashcardImage.ExampleImage.FileSize, - CreatedAt = flashcardImage.ExampleImage.CreatedAt - }); - } + Id = fei.ExampleImage.Id, + ImageUrl = $"http://localhost:5008/images/examples/{fei.ExampleImage.RelativePath}", + IsPrimary = fei.IsPrimary, + QualityScore = fei.ExampleImage.QualityScore, + FileSize = fei.ExampleImage.FileSize, + CreatedAt = fei.ExampleImage.CreatedAt + }) + .ToList(); flashcardDtos.Add(new { @@ -152,9 +147,9 @@ public class FlashcardsController : ControllerBase flashcard.CreatedAt, flashcard.UpdatedAt, // 新增圖片相關欄位 - ExampleImages = exampleImages, - HasExampleImage = exampleImages.Any(), - PrimaryImageUrl = exampleImages.FirstOrDefault(img => img.IsPrimary)?.ImageUrl + ExampleImages = exampleImages ?? (object)new List(), + HasExampleImage = exampleImages?.Any() ?? false, + PrimaryImageUrl = exampleImages?.FirstOrDefault(img => img.IsPrimary)?.ImageUrl }); } @@ -283,6 +278,8 @@ public class FlashcardsController : ControllerBase var userId = GetUserId(); var flashcard = await _context.Flashcards + .Include(f => f.FlashcardExampleImages) + .ThenInclude(fei => fei.ExampleImage) .FirstOrDefaultAsync(f => f.Id == id && f.UserId == userId); if (flashcard == null) @@ -290,6 +287,19 @@ public class FlashcardsController : ControllerBase return NotFound(new { Success = false, Error = "Flashcard not found" }); } + // 獲取例句圖片資料 + var exampleImages = flashcard.FlashcardExampleImages + ?.Select(fei => new + { + Id = fei.ExampleImage.Id, + ImageUrl = $"http://localhost:5008/images/examples/{fei.ExampleImage.RelativePath}", + IsPrimary = fei.IsPrimary, + QualityScore = fei.ExampleImage.QualityScore, + FileSize = fei.ExampleImage.FileSize, + CreatedAt = fei.ExampleImage.CreatedAt + }) + .ToList(); + return Ok(new { Success = true, @@ -309,7 +319,14 @@ public class FlashcardsController : ControllerBase flashcard.NextReviewDate, flashcard.DifficultyLevel, flashcard.CreatedAt, - flashcard.UpdatedAt + flashcard.UpdatedAt, + // 新增圖片相關欄位 + ExampleImages = exampleImages ?? (object)new List(), + HasExampleImage = exampleImages?.Any() ?? false, + PrimaryImageUrl = flashcard.FlashcardExampleImages? + .Where(fei => fei.IsPrimary) + .Select(fei => $"http://localhost:5008/images/examples/{fei.ExampleImage.RelativePath}") + .FirstOrDefault() } }); } diff --git a/frontend/app/flashcards/page.tsx b/frontend/app/flashcards/page.tsx index 200adf6..2d46445 100644 --- a/frontend/app/flashcards/page.tsx +++ b/frontend/app/flashcards/page.tsx @@ -8,6 +8,7 @@ import { Navigation } from '@/components/Navigation' import { FlashcardForm } from '@/components/FlashcardForm' import { useToast } from '@/components/Toast' import { flashcardsService, type Flashcard } from '@/lib/services/flashcards' +import { imageGenerationService } from '@/lib/services/imageGeneration' import { useFlashcardSearch, type SearchActions } from '@/hooks/useFlashcardSearch' // 詞性簡寫轉換 (全域函數) @@ -43,6 +44,10 @@ function FlashcardsContent() { // 使用新的搜尋Hook const [searchState, searchActions] = useFlashcardSearch(activeTab) + // 圖片生成狀態管理 + const [generatingCards, setGeneratingCards] = useState>(new Set()) + const [generationProgress, setGenerationProgress] = useState<{[cardId: string]: string}>({}) + // 例句圖片邏輯 - 使用 API 資料 const getExampleImage = (card: Flashcard): string | null => { return card.primaryImageUrl || null @@ -54,11 +59,73 @@ function FlashcardsContent() { } - // 處理AI生成例句圖片 (預留接口) - const handleGenerateExampleImage = (card: Flashcard) => { - console.log('準備為詞彙生成例句圖片:', card.word) - // TODO: 下階段實現AI生成流程 - // router.push(`/generate-image?word=${encodeURIComponent(card.word)}`) + // 處理AI生成例句圖片 - 完整實現 + const handleGenerateExampleImage = async (card: Flashcard) => { + try { + // 檢查是否已在生成中 + if (generatingCards.has(card.id)) { + toast.error('該詞卡正在生成圖片中,請稍候...') + return + } + + // 標記為生成中 + setGeneratingCards(prev => new Set([...prev, card.id])) + setGenerationProgress(prev => ({ ...prev, [card.id]: '啟動生成中...' })) + + toast.info(`開始為「${card.word}」生成例句圖片...`) + + // 1. 啟動圖片生成 + const generateResult = await imageGenerationService.generateImage(card.id) + + if (!generateResult.success || !generateResult.data) { + throw new Error(generateResult.error || '啟動生成失敗') + } + + const requestId = generateResult.data.requestId + setGenerationProgress(prev => ({ ...prev, [card.id]: 'Gemini 生成描述中...' })) + + // 2. 輪詢生成進度 + const finalStatus = await imageGenerationService.pollUntilComplete( + requestId, + (status) => { + // 更新進度顯示 + const stage = status.stages.gemini.status === 'completed' + ? 'Replicate 生成圖片中...' + : 'Gemini 生成描述中...' + + setGenerationProgress(prev => ({ ...prev, [card.id]: stage })) + }, + 5 // 5分鐘超時 + ) + + // 3. 生成成功,刷新資料 + if (finalStatus.overallStatus === 'completed') { + setGenerationProgress(prev => ({ ...prev, [card.id]: '生成完成,載入中...' })) + + // 清除快取並重新載入詞卡列表以顯示新圖片 + await searchActions.refetch() + + toast.success(`「${card.word}」的例句圖片生成完成!`) + } else { + throw new Error('圖片生成未完成') + } + + } catch (error: any) { + console.error('圖片生成失敗:', error) + toast.error(`圖片生成失敗: ${error.message || '未知錯誤'}`) + } finally { + // 清理狀態 + setGeneratingCards(prev => { + const newSet = new Set(prev) + newSet.delete(card.id) + return newSet + }) + setGenerationProgress(prev => { + const newProgress = { ...prev } + delete newProgress[card.id] + return newProgress + }) + } } // 初始化數據載入 @@ -266,6 +333,8 @@ function FlashcardsContent() { getExampleImage={getExampleImage} hasExampleImage={hasExampleImage} onGenerateExampleImage={handleGenerateExampleImage} + generatingCards={generatingCards} + generationProgress={generationProgress} router={router} /> diff --git a/frontend/lib/services/imageGeneration.ts b/frontend/lib/services/imageGeneration.ts new file mode 100644 index 0000000..e78182b --- /dev/null +++ b/frontend/lib/services/imageGeneration.ts @@ -0,0 +1,170 @@ +// Image Generation API service + +export interface ImageGenerationRequest { + style: 'cartoon' | 'realistic' | 'minimal' + priority: 'normal' | 'high' | 'low' + width: number + height: number + replicateModel: string + options: { + useGeminiCache: boolean + useImageCache: boolean + maxRetries: number + learnerLevel: string + scenario: string + visualPreferences: string[] + } +} + +export interface GenerationStatus { + requestId: string + overallStatus: string + currentStage?: string + stages: { + gemini: { + status: string + startedAt?: string + completedAt?: string + processingTimeMs?: number + cost?: number + generatedDescription?: string + } + replicate: { + status: string + startedAt?: string + completedAt?: string + processingTimeMs?: number + cost?: number + model?: string + modelVersion?: string + progress?: string + } + } + totalCost?: number + completedAt?: string + result?: { + imageUrl: string + imageId: string + qualityScore?: number + dimensions?: { + width: number + height: number + } + fileSize?: number + } +} + +export interface ApiResponse { + success: boolean + data?: T + error?: string + details?: string +} + +class ImageGenerationService { + private baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5008' + + private async makeRequest(url: string, options: RequestInit = {}): Promise> { + const token = localStorage.getItem('token') + + const response = await fetch(`${this.baseUrl}${url}`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': token ? `Bearer ${token}` : '', + ...options.headers, + }, + ...options, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Network error' })) + throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`) + } + + return response.json() + } + + // 啟動圖片生成 + async generateImage(flashcardId: string, request?: Partial): Promise> { + const defaultRequest: ImageGenerationRequest = { + style: 'cartoon', + priority: 'normal', + width: 512, + height: 512, + replicateModel: 'ideogram-v2a-turbo', + options: { + useGeminiCache: true, + useImageCache: true, + maxRetries: 3, + learnerLevel: 'B1', + scenario: 'daily', + visualPreferences: ['colorful', 'simple'] + }, + ...request + } + + return this.makeRequest(`/api/imagegeneration/flashcards/${flashcardId}/generate`, { + method: 'POST', + body: JSON.stringify(defaultRequest) + }) + } + + // 查詢生成狀態 + async getGenerationStatus(requestId: string): Promise> { + return this.makeRequest(`/api/imagegeneration/requests/${requestId}/status`) + } + + // 取消生成 + async cancelGeneration(requestId: string): Promise> { + return this.makeRequest(`/api/imagegeneration/requests/${requestId}/cancel`, { + method: 'POST' + }) + } + + // 輪詢直到完成 + async pollUntilComplete( + requestId: string, + onProgress?: (status: GenerationStatus) => void, + timeoutMinutes = 5 + ): Promise { + const startTime = Date.now() + const timeout = timeoutMinutes * 60 * 1000 + + while (Date.now() - startTime < timeout) { + try { + const result = await this.getGenerationStatus(requestId) + + if (!result.success || !result.data) { + throw new Error(result.error || 'Failed to get status') + } + + const status = result.data + + // 呼叫進度回調 + if (onProgress) { + onProgress(status) + } + + // 檢查是否完成 + if (status.overallStatus === 'completed') { + return status + } + + // 檢查是否失敗 + if (status.overallStatus === 'failed') { + throw new Error('圖片生成失敗') + } + + // 等待 2 秒後再次檢查 + await new Promise(resolve => setTimeout(resolve, 2000)) + } catch (error) { + console.error('輪詢狀態時發生錯誤:', error) + throw error + } + } + + throw new Error('圖片生成超時') + } +} + +export const imageGenerationService = new ImageGenerationService() \ No newline at end of file