feat: 實現 AI 服務整合和前後端連接
- 整合 Google Gemini API 服務 - 添加測試端點支援無認證調用 - 實現前端 API 調用和資料格式轉換 - 完善錯誤處理和模擬資料回退 - 添加詞卡保存到資料庫功能 - 配置 User Secrets 安全管理 API 金鑰 - 優化 AI 服務條件判斷邏輯 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
303f0ac727
commit
448883ff97
|
|
@ -29,6 +29,88 @@ public class AIController : ControllerBase
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI 生成詞卡測試端點 (開發用,無需認證)
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("test/generate")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<ActionResult> TestGenerateCards([FromBody] GenerateCardsRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 基本驗證
|
||||||
|
if (string.IsNullOrWhiteSpace(request.InputText))
|
||||||
|
{
|
||||||
|
return BadRequest(new { Success = false, Error = "Input text is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.InputText.Length > 5000)
|
||||||
|
{
|
||||||
|
return BadRequest(new { Success = false, Error = "Input text must be less than 5000 characters" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!new[] { "vocabulary", "smart" }.Contains(request.ExtractionType))
|
||||||
|
{
|
||||||
|
return BadRequest(new { Success = false, Error = "Invalid extraction type" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.CardCount < 1 || request.CardCount > 20)
|
||||||
|
{
|
||||||
|
return BadRequest(new { Success = false, Error = "Card count must be between 1 and 20" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 測試模式:直接使用模擬資料
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var generatedCards = await _geminiService.GenerateCardsAsync(
|
||||||
|
request.InputText,
|
||||||
|
request.ExtractionType,
|
||||||
|
request.CardCount);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = new
|
||||||
|
{
|
||||||
|
TaskId = Guid.NewGuid(),
|
||||||
|
Status = "completed",
|
||||||
|
GeneratedCards = generatedCards
|
||||||
|
},
|
||||||
|
Message = $"Successfully generated {generatedCards.Count} cards"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) when (ex.Message.Contains("API key"))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Gemini API key not configured, using mock data");
|
||||||
|
|
||||||
|
// 返回模擬資料
|
||||||
|
var mockCards = GenerateMockCards(request.CardCount);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = new
|
||||||
|
{
|
||||||
|
TaskId = Guid.NewGuid(),
|
||||||
|
Status = "completed",
|
||||||
|
GeneratedCards = mockCards
|
||||||
|
},
|
||||||
|
Message = $"Generated {mockCards.Count} mock cards (Test mode)"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error in AI card generation test");
|
||||||
|
return StatusCode(500, new
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "Failed to generate cards",
|
||||||
|
Details = ex.Message,
|
||||||
|
Timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// AI 生成詞卡 (支援 /frontend/app/generate/page.tsx)
|
/// AI 生成詞卡 (支援 /frontend/app/generate/page.tsx)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -155,6 +237,100 @@ public class AIController : ControllerBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 測試版保存生成的詞卡 (無需認證)
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("test/save")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<ActionResult> TestSaveCards([FromBody] TestSaveCardsRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 基本驗證
|
||||||
|
if (request.SelectedCards == null || request.SelectedCards.Count == 0)
|
||||||
|
{
|
||||||
|
return BadRequest(new { Success = false, Error = "Selected cards are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 創建或使用預設卡組
|
||||||
|
var defaultCardSet = await _context.CardSets
|
||||||
|
.FirstOrDefaultAsync(cs => cs.IsDefault);
|
||||||
|
|
||||||
|
if (defaultCardSet == null)
|
||||||
|
{
|
||||||
|
// 創建預設卡組
|
||||||
|
defaultCardSet = new CardSet
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = Guid.NewGuid(), // 測試用戶 ID
|
||||||
|
Name = "AI 生成詞卡",
|
||||||
|
Description = "通過 AI 智能生成的詞卡集合",
|
||||||
|
Color = "#3B82F6",
|
||||||
|
IsDefault = true,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
_context.CardSets.Add(defaultCardSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 將生成的詞卡轉換為資料庫實體
|
||||||
|
var flashcardsToSave = request.SelectedCards.Select(card => new Flashcard
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = defaultCardSet.UserId,
|
||||||
|
CardSetId = defaultCardSet.Id,
|
||||||
|
Word = card.Word,
|
||||||
|
Translation = card.Translation,
|
||||||
|
Definition = card.Definition,
|
||||||
|
PartOfSpeech = card.PartOfSpeech,
|
||||||
|
Pronunciation = card.Pronunciation,
|
||||||
|
Example = card.Example,
|
||||||
|
ExampleTranslation = card.ExampleTranslation,
|
||||||
|
DifficultyLevel = card.DifficultyLevel,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
_context.Flashcards.AddRange(flashcardsToSave);
|
||||||
|
|
||||||
|
// 更新卡組計數
|
||||||
|
defaultCardSet.CardCount += flashcardsToSave.Count;
|
||||||
|
defaultCardSet.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = new
|
||||||
|
{
|
||||||
|
SavedCount = flashcardsToSave.Count,
|
||||||
|
CardSetId = defaultCardSet.Id,
|
||||||
|
CardSetName = defaultCardSet.Name,
|
||||||
|
Cards = flashcardsToSave.Select(f => new
|
||||||
|
{
|
||||||
|
f.Id,
|
||||||
|
f.Word,
|
||||||
|
f.Translation,
|
||||||
|
f.Definition
|
||||||
|
})
|
||||||
|
},
|
||||||
|
Message = $"Successfully saved {flashcardsToSave.Count} cards to deck '{defaultCardSet.Name}'"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error saving generated cards");
|
||||||
|
return StatusCode(500, new
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "Failed to save cards",
|
||||||
|
Details = ex.Message,
|
||||||
|
Timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 保存生成的詞卡
|
/// 保存生成的詞卡
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -374,4 +550,9 @@ public class ValidateCardRequest
|
||||||
{
|
{
|
||||||
public Guid FlashcardId { get; set; }
|
public Guid FlashcardId { get; set; }
|
||||||
public Guid? ErrorReportId { get; set; }
|
public Guid? ErrorReportId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TestSaveCardsRequest
|
||||||
|
{
|
||||||
|
public List<GeneratedCard> SelectedCards { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
@ -1,23 +1,24 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
<UserSecretsId>d32b5470-3ba2-442c-8352-dd968fd406d8</UserSecretsId>
|
||||||
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.20" />
|
<ItemGroup>
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.20" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
|
||||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
|
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Cors" Version="2.2.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
|
||||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
<PackageReference Include="Microsoft.AspNetCore.Cors" Version="2.2.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
</ItemGroup>
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
|
||||||
|
</Project>
|
||||||
|
|
@ -30,7 +30,7 @@ public class GeminiService : IGeminiService
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_apiKey))
|
if (string.IsNullOrEmpty(_apiKey) || _apiKey == "your-gemini-api-key-here")
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Gemini API key not configured");
|
throw new InvalidOperationException("Gemini API key not configured");
|
||||||
}
|
}
|
||||||
|
|
@ -51,7 +51,7 @@ public class GeminiService : IGeminiService
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_apiKey))
|
if (string.IsNullOrEmpty(_apiKey) || _apiKey == "your-gemini-api-key-here")
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Gemini API key not configured");
|
throw new InvalidOperationException("Gemini API key not configured");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,19 +86,116 @@ function GenerateContent() {
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const handleGenerate = () => {
|
const handleGenerate = async () => {
|
||||||
|
if (!textInput.trim()) return
|
||||||
|
|
||||||
setIsGenerating(true)
|
setIsGenerating(true)
|
||||||
// Simulate AI generation
|
|
||||||
setTimeout(() => {
|
try {
|
||||||
|
const response = await fetch('http://localhost:5000/api/ai/test/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
inputText: textInput,
|
||||||
|
extractionType: extractionType,
|
||||||
|
cardCount: cardCount
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// 將後端格式轉換為前端格式
|
||||||
|
const convertedCards = result.data.generatedCards.map((card: any, index: number) => ({
|
||||||
|
id: index + 1,
|
||||||
|
word: card.word,
|
||||||
|
partOfSpeech: card.partOfSpeech,
|
||||||
|
pronunciation: {
|
||||||
|
us: card.pronunciation || '/unknown/',
|
||||||
|
uk: card.pronunciation || '/unknown/'
|
||||||
|
},
|
||||||
|
translation: card.translation,
|
||||||
|
definition: card.definition,
|
||||||
|
synonyms: card.synonyms || [],
|
||||||
|
antonyms: [], // 後端暫不提供,使用空陣列
|
||||||
|
originalExample: card.example || '',
|
||||||
|
originalExampleTranslation: card.exampleTranslation || '',
|
||||||
|
generatedExample: {
|
||||||
|
sentence: card.example || '',
|
||||||
|
translation: card.exampleTranslation || '',
|
||||||
|
imageUrl: '/images/examples/placeholder.png', // 佔位圖
|
||||||
|
audioUrl: '#'
|
||||||
|
},
|
||||||
|
difficulty: card.difficultyLevel || 'B1'
|
||||||
|
}))
|
||||||
|
|
||||||
|
setGeneratedCards(convertedCards)
|
||||||
|
setShowPreview(true)
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Generation failed')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating cards:', error)
|
||||||
|
alert(`生成詞卡時發生錯誤: ${error instanceof Error ? error.message : '未知錯誤'}`)
|
||||||
|
// 錯誤時使用模擬資料作為備用
|
||||||
setGeneratedCards(mockGeneratedCards)
|
setGeneratedCards(mockGeneratedCards)
|
||||||
setShowPreview(true)
|
setShowPreview(true)
|
||||||
|
} finally {
|
||||||
setIsGenerating(false)
|
setIsGenerating(false)
|
||||||
}, 2000)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveCards = () => {
|
const handleSaveCards = async () => {
|
||||||
// Mock save action
|
if (generatedCards.length === 0) return
|
||||||
alert('詞卡已保存到您的卡組!')
|
|
||||||
|
try {
|
||||||
|
// 將前端格式轉換為後端格式
|
||||||
|
const cardsToSave = generatedCards.map(card => ({
|
||||||
|
word: card.word,
|
||||||
|
partOfSpeech: card.partOfSpeech,
|
||||||
|
pronunciation: card.pronunciation.us, // 使用美式發音
|
||||||
|
translation: card.translation,
|
||||||
|
definition: card.definition,
|
||||||
|
synonyms: card.synonyms,
|
||||||
|
example: card.originalExample || card.generatedExample.sentence,
|
||||||
|
exampleTranslation: card.originalExampleTranslation || card.generatedExample.translation,
|
||||||
|
difficultyLevel: card.difficulty
|
||||||
|
}))
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:5000/api/ai/test/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
selectedCards: cardsToSave
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert(`🎉 成功保存 ${result.data.savedCount} 張詞卡到「${result.data.cardSetName}」!`)
|
||||||
|
// 可選:清空生成的詞卡或跳轉到詞卡列表
|
||||||
|
// setGeneratedCards([])
|
||||||
|
// setShowPreview(false)
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Save failed')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving cards:', error)
|
||||||
|
alert(`❌ 保存詞卡時發生錯誤: ${error instanceof Error ? error.message : '未知錯誤'}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleImageForCard = (cardId: number) => {
|
const toggleImageForCard = (cardId: number) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue