diff --git a/Epub/KoeBook.Epub/Services/AnalyzerService.cs b/Epub/KoeBook.Epub/Services/AnalyzerService.cs index d09d936..044102c 100644 --- a/Epub/KoeBook.Epub/Services/AnalyzerService.cs +++ b/Epub/KoeBook.Epub/Services/AnalyzerService.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.Text; using System.Text.RegularExpressions; using KoeBook.Core; using KoeBook.Core.Contracts.Services; @@ -67,23 +66,8 @@ public async ValueTask AnalyzeAsync(BookProperties bookProperties, }).Where(l => !string.IsNullOrEmpty(l.Text)) .ToArray(); - // 800文字以上になったら1チャンクに分ける - var chunks = new List(); - var chunk = new StringBuilder(); - foreach (var line in scriptLines) - { - if (chunk.Length + line.Text.Length > 800) - { - chunks.Add(chunk.ToString()); - chunk.Clear(); - } - chunk.AppendLine(line.Text); - } - if (chunk.Length > 0) chunks.Add(chunk.ToString()); - - // GPT4による話者、スタイル解析 - var bookScripts = await _llmAnalyzerService.LlmAnalyzeScriptLinesAsync(bookProperties, [.. scriptLines], chunks, cancellationToken); - + // LLMによる話者、スタイル解析 + var bookScripts = await _llmAnalyzerService.LlmAnalyzeScriptLinesAsync(bookProperties, scriptLines, cancellationToken)!; return bookScripts; } diff --git a/KoeBook.Core/Contracts/Services/IClaudeService.cs b/KoeBook.Core/Contracts/Services/IClaudeService.cs new file mode 100644 index 0000000..d5cc5ea --- /dev/null +++ b/KoeBook.Core/Contracts/Services/IClaudeService.cs @@ -0,0 +1,8 @@ +using Claudia; + +namespace KoeBook.Core.Contracts.Services; + +public interface IClaudeService +{ + IMessages? Messages { get; } +} diff --git a/KoeBook.Core/Contracts/Services/ILlmAnalyzerService.cs b/KoeBook.Core/Contracts/Services/ILlmAnalyzerService.cs index ddf948b..7d26c02 100644 --- a/KoeBook.Core/Contracts/Services/ILlmAnalyzerService.cs +++ b/KoeBook.Core/Contracts/Services/ILlmAnalyzerService.cs @@ -4,5 +4,5 @@ namespace KoeBook.Core.Contracts.Services; public interface ILlmAnalyzerService { - ValueTask LlmAnalyzeScriptLinesAsync(BookProperties bookProperties, List scriptLines, List chunks, CancellationToken cancellationToken); + ValueTask LlmAnalyzeScriptLinesAsync(BookProperties bookProperties, ScriptLine[] scriptLines, CancellationToken cancellationToken); } diff --git a/KoeBook.Core/EbookException.cs b/KoeBook.Core/EbookException.cs index bd88010..497abfd 100644 --- a/KoeBook.Core/EbookException.cs +++ b/KoeBook.Core/EbookException.cs @@ -45,6 +45,12 @@ public enum ExceptionType [EnumMember(Value = "GPT4による話者・スタイル設定に失敗しました")] Gpt4TalkerAndStyleSettingFailed, + [EnumMember(Value = "APIキーが設定されていません")] + ApiKeyNotSet, + + [EnumMember(Value = "Claudeによる話者・スタイル設定に失敗しました")] + ClaudeTalkerAndStyleSettingFailed, + [EnumMember(Value = "webページの解析に失敗しました")] WebScrapingFailed, diff --git a/KoeBook.Core/KoeBook.Core.csproj b/KoeBook.Core/KoeBook.Core.csproj index 3963f59..ea979ab 100644 --- a/KoeBook.Core/KoeBook.Core.csproj +++ b/KoeBook.Core/KoeBook.Core.csproj @@ -9,7 +9,8 @@ - + + diff --git a/KoeBook.Core/Models/BookScripts.cs b/KoeBook.Core/Models/BookScripts.cs index 4bc87af..5c34284 100644 --- a/KoeBook.Core/Models/BookScripts.cs +++ b/KoeBook.Core/Models/BookScripts.cs @@ -1,4 +1,6 @@ -namespace KoeBook.Core.Models; +using System.Collections.Immutable; + +namespace KoeBook.Core.Models; /// /// 本の読み上げ情報 @@ -15,5 +17,5 @@ public class BookScripts(BookProperties bookProperties, BookOptions options) /// /// 読み上げテキストの配列 /// - public required IReadOnlyList ScriptLines { get; set; } + public required ImmutableArray ScriptLines { get; set; } } diff --git a/KoeBook.Core/Services/ChatGptAnalyzerService.cs b/KoeBook.Core/Services/ChatGptAnalyzerService.cs index dd0d84e..efe656a 100644 --- a/KoeBook.Core/Services/ChatGptAnalyzerService.cs +++ b/KoeBook.Core/Services/ChatGptAnalyzerService.cs @@ -1,4 +1,5 @@ -using System.Text; +using System.Collections.Immutable; +using System.Text; using System.Text.RegularExpressions; using KoeBook.Core.Contracts.Services; using KoeBook.Core.Helpers; @@ -13,8 +14,20 @@ public partial class ChatGptAnalyzerService(IOpenAIService openAIService, IDispl private readonly IOpenAIService _openAiService = openAIService; private readonly IDisplayStateChangeService _displayStateChangeService = displayStateChangeService; - public async ValueTask LlmAnalyzeScriptLinesAsync(BookProperties bookProperties, List scriptLines, List chunks, CancellationToken cancellationToken) + public async ValueTask LlmAnalyzeScriptLinesAsync(BookProperties bookProperties, ScriptLine[] scriptLines, CancellationToken cancellationToken) { + var chunks = new List(); + var chunk = new StringBuilder(); + foreach (var line in scriptLines) + { + if (chunk.Length + line.Text.Length > 800) + { + chunks.Add(chunk.ToString()); + chunk.Clear(); + } + chunk.AppendLine(line.Text); + } + if (chunk.Length > 0) chunks.Add(chunk.ToString()); var progress = _displayStateChangeService.ResetProgress(bookProperties, GenerationState.Analyzing, chunks.Count); Queue summaryList = new(); Queue characterList = new(); @@ -38,7 +51,7 @@ public async ValueTask LlmAnalyzeScriptLinesAsync(BookProperties bo summary1 = summaryList.Dequeue(); characters1 = characterList.Dequeue(); } - var Task2 = SummaryCharacterListAnalysisAsync(scriptLines, chunks, summary1, characters1, i, cancellationToken); + var Task2 = SummaryCharacterListAnalysisAsync(chunks, summary1, characters1, i, cancellationToken); // WhenAllで非同期処理を待つ await Task.WhenAll(Task1, Task2); currentLineIndex += chunks[i].Split("\n").Length - 1; @@ -60,12 +73,12 @@ public async ValueTask LlmAnalyzeScriptLinesAsync(BookProperties bo } ) { - ScriptLines = scriptLines + ScriptLines = [.. scriptLines] }; return bookScripts; } - private async Task CharacterStyleAnalysisAsync(List scriptLines, + private async Task CharacterStyleAnalysisAsync(ScriptLine[] scriptLines, List chunks, string summary, string characterList, @@ -77,8 +90,8 @@ private async Task CharacterStyleAnalysisAsync(List scriptLines, RESTART: var completionResult = await _openAiService.ChatCompletion.CreateCompletion(new ChatCompletionCreateRequest { - Messages = new List - { + Messages = + [ ChatMessage.FromSystem($$""" All Information - Goal @@ -144,15 +157,15 @@ 3. Target Sentence ``` """ ) - }, + ], Model = OpenAI.ObjectModels.Models.Gpt_4_turbo_preview, MaxTokens = 4000 - }); + }, cancellationToken: cancellationToken); if (completionResult.Successful) { var result = completionResult.Choices.First().Message.Content; // "#### Talker and Style Setting"以下の文章を改行区切りでリスト化 - List output = new List(); + var output = new List(); var lines = result?.Split("\n"); var start = false; for (var i = 0; i < lines?.Length; i++) @@ -200,7 +213,7 @@ 3. Target Sentence } } - private async Task<(string summary, string characterList)> SummaryCharacterListAnalysisAsync(List scriptLines, + private async Task<(string summary, string characterList)> SummaryCharacterListAnalysisAsync( List chunks, string summary, string characterList, @@ -210,8 +223,8 @@ 3. Target Sentence var storyText = string.Join("\n", chunks.Skip(int.Max(0, idx - 4)).Take(4)); var completionResult = await _openAiService.ChatCompletion.CreateCompletion(new ChatCompletionCreateRequest { - Messages = new List - { + Messages = + [ ChatMessage.FromSystem($$""" All Information - Goal @@ -252,16 +265,16 @@ 3. Story ... - #### Summery of {{Math.Min(20,(idx+1)*5)}} points + #### Summery of {{Math.Min(20, (idx + 1) * 5)}} points - {summary1} - {summary2} ... ``` """), - }, + ], Model = OpenAI.ObjectModels.Models.Gpt_4_turbo_preview, MaxTokens = 4000 - }); + }, cancellationToken: cancellationToken); if (completionResult.Successful) { var result = completionResult.Choices.First().Message.Content; @@ -308,15 +321,15 @@ 3. Story } } - private async Task> GetCharacterVoiceMappingAsync(List scriptLines, string characterDescription, CancellationToken cancellationToken) + private async Task> GetCharacterVoiceMappingAsync(ScriptLine[] scriptLines, string characterDescription, CancellationToken cancellationToken) { // キャラクター名一覧の取得 var characterList = scriptLines.Select(x => "- " + x.Character).Distinct().ToList(); var characterListString = string.Join("\n", characterList); var completionResult = await _openAiService.ChatCompletion.CreateCompletion(new ChatCompletionCreateRequest { - Messages = new List - { + Messages = + [ ChatMessage.FromSystem($$""" All Information - Goal @@ -353,15 +366,15 @@ Make a table of character names and voices ... ``` """) - }, + ], Model = OpenAI.ObjectModels.Models.Gpt_4_turbo_preview, MaxTokens = 4000 - }); + }, cancellationToken: cancellationToken); if (completionResult.Successful) { var result = completionResult.Choices.First().Message.Content; var lines = result?.Split("\n"); - Dictionary characterVoiceMapping = new(); + Dictionary characterVoiceMapping = []; foreach (var match in from line in lines let match = CharacterMappingRegex().Match(line) select match) diff --git a/KoeBook.Core/Services/ClaudeAnalyzerService.cs b/KoeBook.Core/Services/ClaudeAnalyzerService.cs new file mode 100644 index 0000000..f47aeaf --- /dev/null +++ b/KoeBook.Core/Services/ClaudeAnalyzerService.cs @@ -0,0 +1,201 @@ +using System.Buffers; +using System.Collections.Immutable; +using System.Text; +using KoeBook.Core.Contracts.Services; +using KoeBook.Core.Helpers; +using KoeBook.Core.Models; + +namespace KoeBook.Core.Services; + +public partial class ClaudeAnalyzerService(IClaudeService claudeService, IDisplayStateChangeService displayStateChangeService, ISoundGenerationSelectorService soundGenerationSelectorService) : ILlmAnalyzerService +{ + private readonly IClaudeService _claudeService = claudeService; + private readonly IDisplayStateChangeService _displayStateChangeService = displayStateChangeService; + private readonly ISoundGenerationSelectorService _soundGenerationSelectorService = soundGenerationSelectorService; + private static readonly SearchValues _searchValues = SearchValues.Create(", "); + + public async ValueTask LlmAnalyzeScriptLinesAsync(BookProperties bookProperties, ScriptLine[] scriptLines, CancellationToken cancellationToken) + { + var progress = _displayStateChangeService.ResetProgress(bookProperties, GenerationState.Analyzing, 2); + var lineNumberingText = LineNumbering(scriptLines); + if (_claudeService.Messages is null) + { + throw new EbookException(ExceptionType.ApiKeyNotSet); + } + try + { + var message1 = await _claudeService.Messages.CreateAsync(new() + { + Model = Claudia.Models.Claude3Opus, + MaxTokens = 4000, + Messages = [new() + { + Role = "user", + Content = CreateCharacterGuessPrompt(lineNumberingText) + }] + }, + cancellationToken: cancellationToken + ); + var (characters, characterId2Name) = ExtractCharacterList(message1.ToString(), scriptLines); + progress.IncrementProgress(); + + var message2 = await _claudeService.Messages.CreateAsync(new() + { + Model = Claudia.Models.Claude3Opus, + MaxTokens = 4000, + Messages = [new() + { + Role = "user", + Content = CreateVoiceTypeAnalyzePrompt(characters) + }] + }, + cancellationToken: cancellationToken + ); + var characterVoiceMapping = ExtractCharacterVoiceMapping(message2.ToString(), characterId2Name); + progress.Finish(); + + return new(bookProperties, new(characterVoiceMapping)) { ScriptLines = [.. scriptLines] }; + } + catch (OperationCanceledException) { throw; } + catch (Exception e) + { + throw new EbookException(ExceptionType.ClaudeTalkerAndStyleSettingFailed, innerException: e); + } + } + + private Dictionary ExtractCharacterVoiceMapping(string response, Dictionary characterIdDic) + { + return response.Split("\n") + .SkipWhile(l => !l.StartsWith("[Assign Voices]")) + .Where(l => l.StartsWith('c')) + .Select(l => + { + var characterId = l[1..l.IndexOf('.')]; + var voiceTypeSpan = l.AsSpan()[(l.IndexOf(':') + 2)..]; + // ボイス割り当てが複数あたったときに先頭のものを使う(例:群衆 AdultMan, AdultWoman) + var separatorIndex = voiceTypeSpan.IndexOfAny(_searchValues); + if (separatorIndex > 0) + { + voiceTypeSpan = voiceTypeSpan[..separatorIndex]; + } + // voiceTypeが_soundGenerationSelectorService.Modelsに含まれているかチェック + var voiceType = voiceTypeSpan.ToString(); + return _soundGenerationSelectorService.Models.Any(x => x.Name == voiceType) + ? (characterIdDic[characterId], voiceType) + : (characterIdDic[characterId], string.Empty); + }).ToDictionary(); + } + + private static string CreateCharacterGuessPrompt(string lineNumberingText) + { + return $$""" + {{lineNumberingText}} + + Notes: + - For narration parts, which are not enclosed in quotation marks, select the narrator. + - For dialogues enclosed in quotation marks, assign a voice other than the narrator. + - In the character description, include the appropriate voice characteristics. + Tasks: Based on the notes above, perform the following two tasks: + - List of characters Objective: To understand the characters appearing in the text, list the character ID, name, and description for all characters who speak at least one line. + - Output the speaking character or narrator for each line Objective: To identify which character is speaking in each line of the text, output the speaking character or narrator for all lines. Carefully recognize the context and output with attention. Select only one character_id per line. + - Revise CHARACTER LIST and VOICE ID (in the event that the CHARACTER LIST is incomplete) + Output Format: + [CHARACTER LIST] + c0. ナレーター: {character_and_voice_description, example:"The person who speaks the narration parts. A calm-toned male voice."} + c1. {character_name}: {character_and_voice_description} + {character_id}. {character_name}: {character_and_voice_description} + ... + [VOICE ID] + 1. {character_id} {narration|dialogue} + 2. {character_id} {narration|dialogue} + 3. {character_id} {narration|dialogue} + ... + [REVISE CHARACTER LIST] + c0. ナレーター: {character_and_voice_description} + c1. {character_name}: {character_and_voice_description} + {character_id}. {character_name}: {character_and_voice_description} + ... + [REVISE VOICE ID] + 1. {character_id} {narration|dialogue} + 2. {character_id} {narration|dialogue} + 3. {character_id} {narration|dialogue} + ... + """; + } + + private string CreateVoiceTypeAnalyzePrompt(Character[] characterList) + { + return $$""" + Assign the most fitting voice type to each character from the provided list, ensuring the chosen voice aligns with their role and attributes in the story. Only select from the available voice types. + + Characters: + {{string.Join("\n", characterList.Select(character => $"c{character.Id}. {character.Name}: {character.Description}"))}} + + Voice Types: + {{string.Join(",", _soundGenerationSelectorService.Models.Select(m => m.Name))}} + + Output Format: + [Assign Voices] + c0. {character_name}: {voice_type} + c1. {character_name}: {voice_type} + """; + } + + private static string LineNumbering(ScriptLine[] scriptLines) + { + var sb = new StringBuilder(); + foreach (var (index, scriptLine) in scriptLines.Select((x, i) => (i, x))) + { + sb.AppendLine($"{index + 1}. {scriptLine.Text}"); + } + return sb.ToString(); + } + + private static (Character[], Dictionary) ExtractCharacterList(string response, ScriptLine[] scriptLines) + { + var lines = response.Split("\n"); + var characters = lines + .SkipWhile(l => !l.StartsWith("[REVISE CHARACTER LIST]")) + .TakeWhile(l => !l.StartsWith("[REVISE VOICE ID]")) + .Where(l => l.StartsWith('c')) + .Select(l => + { + var dotIndex = l.IndexOf('.'); + var colonIndex = l.IndexOf(':'); + return new Character(l[1..dotIndex], l[(dotIndex + 2)..colonIndex], l[(colonIndex + 2)..]); + }).ToArray(); + + var characterId2Name = characters.Select(x => (x.Id, x.Name)).ToDictionary(); + var voiceIdLinesCount = lines.SkipWhile(l => !l.StartsWith("[REVISE VOICE ID]")) + .Where((x, i) => x.StartsWith(i.ToString())) //[REVISE VOICE ID]の分ズレる + .Zip(scriptLines) + .Select(zippedLine => + { + var voiceIdLine = zippedLine.First.AsSpan(); + voiceIdLine = voiceIdLine[(voiceIdLine.IndexOf(' ') + 2)..];//cまで無視 + voiceIdLine = voiceIdLine[..voiceIdLine.IndexOf(' ')];// 二人以上話す時には先頭のものを使う + if (characterId2Name.TryGetValue(voiceIdLine.ToString(), out var characterName)) + { + zippedLine.Second.Character = characterName; + } + return 0; + }).Count(); + if (voiceIdLinesCount != scriptLines.Length) + throw new EbookException(ExceptionType.ClaudeTalkerAndStyleSettingFailed); + return (characters, characterId2Name); + } + + private class Character + { + public string Id { get; } + public string Name { get; } + public string Description { get; } + public string VoiceType { get; set; } = string.Empty; + public Character(string id, string name, string description) + { + Id = id; + Name = name; + Description = description; + } + } +} diff --git a/KoeBook.Core/Services/ClaudeService.cs b/KoeBook.Core/Services/ClaudeService.cs new file mode 100644 index 0000000..909276a --- /dev/null +++ b/KoeBook.Core/Services/ClaudeService.cs @@ -0,0 +1,33 @@ +using Claudia; +using KoeBook.Core.Contracts.Services; + +namespace KoeBook.Core.Services; + +public class ClaudeService(ISecretSettingsService secretSettingsService) : IClaudeService +{ + private readonly ISecretSettingsService _secretSettingsService = secretSettingsService; + + private string? _apiKey; + private Anthropic? _anthropic; + + public IMessages? Messages => GetAnthropic()?.Messages; + + + private Anthropic? GetAnthropic() + { + if (_apiKey != _secretSettingsService.ApiKey) + { + if (string.IsNullOrEmpty(_secretSettingsService.ApiKey)) + { + _apiKey = _secretSettingsService.ApiKey; + _anthropic?.Dispose(); + _anthropic = null; + return null; + } + + _anthropic = new Anthropic { ApiKey = _secretSettingsService.ApiKey }; + _apiKey = _secretSettingsService.ApiKey; + } + return _anthropic; + } +} diff --git a/KoeBook.Core/Services/Mocks/SoundGenerationSelectorServiceMock.cs b/KoeBook.Core/Services/Mocks/SoundGenerationSelectorServiceMock.cs index 1f8c81d..3fe9b9d 100644 --- a/KoeBook.Core/Services/Mocks/SoundGenerationSelectorServiceMock.cs +++ b/KoeBook.Core/Services/Mocks/SoundGenerationSelectorServiceMock.cs @@ -11,12 +11,16 @@ public async ValueTask InitializeAsync(CancellationToken cancellationToken) { await Task.Delay(1000, cancellationToken).ConfigureAwait(false); Models = [ - new SoundModel("0", "青年1", ["neutral", "laughing", "happy", "sad", "cry", "surprised", "angry"]), - new SoundModel("1", "青年2", ["neutral", "laughing", "happy", "sad", "cry", "surprised", "angry"]), - new SoundModel("2", "女性1", ["neutral", "laughing", "happy", "sad", "cry", "surprised", "angry"]), - new SoundModel("3", "女性2", ["neutral", "laughing", "happy", "sad", "cry", "surprised", "angry"]), - new SoundModel("4", "ナレーション (男性)", ["narration"]), - new SoundModel("5", "ナレーション (女性)", ["narration"]), + new SoundModel("0", "MaleNarrator", ["narration"]), + new SoundModel("1", "ElementarySchoolBoy", ["neutral", "laughing", "happy", "sad", "cry", "surprised", "angry"]), + new SoundModel("2", "MiddleHighSchoolBoy", ["neutral", "laughing", "happy", "sad", "cry", "surprised", "angry"]), + new SoundModel("3", "AdultMan", ["neutral", "laughing", "happy", "sad", "cry", "surprised", "angry"]), + new SoundModel("4", "ElderlyMan", ["neutral", "laughing", "happy", "sad", "cry", "surprised", "angry"]), + new SoundModel("5", "FemaleNarrator", ["narration"]), + new SoundModel("6", "ElementarySchoolGirl", ["neutral", "laughing", "happy", "sad", "cry", "surprised", "angry"]), + new SoundModel("7", "MiddleHighSchoolGirl", ["neutral", "laughing", "happy", "sad", "cry", "surprised", "angry"]), + new SoundModel("8", "AdultWoman", ["neutral", "laughing", "happy", "sad", "cry", "surprised", "angry"]), + new SoundModel("9", "ElderlyWoman", ["neutral", "laughing", "happy", "sad", "cry", "surprised", "angry"]) ]; } } diff --git a/KoeBook.Core/Services/MyOpenAiService.cs b/KoeBook.Core/Services/MyOpenAiService.cs index e8c4793..fa47079 100644 --- a/KoeBook.Core/Services/MyOpenAiService.cs +++ b/KoeBook.Core/Services/MyOpenAiService.cs @@ -36,6 +36,8 @@ public class MyOpenAiService(ISecretSettingsService secretSettingsService, IHttp public IAudioService Audio => GetOpenAiService()?.Audio!; + public IBatchService Batch => GetOpenAiService()?.Batch!; + public void SetDefaultModelId(string modelId) { GetOpenAiService()?.SetDefaultModelId(modelId); @@ -48,6 +50,8 @@ public void SetDefaultModelId(string modelId) if (string.IsNullOrEmpty(_secretSettingsService.ApiKey)) { _apiKey = _secretSettingsService.ApiKey; + _openAiService?.Dispose(); + _openAiService = null; return null; } var options = new OpenAiOptions diff --git a/KoeBook.Core/Services/StyleBertVitsClientService.cs b/KoeBook.Core/Services/StyleBertVitsClientService.cs index 2d45c9a..92d6a6c 100644 --- a/KoeBook.Core/Services/StyleBertVitsClientService.cs +++ b/KoeBook.Core/Services/StyleBertVitsClientService.cs @@ -1,4 +1,5 @@ -using System.Net.Http.Json; +using System; +using System.Net.Http.Json; using KoeBook.Core.Contracts.Services; namespace KoeBook.Core.Services; @@ -28,8 +29,10 @@ private async ValueTask GetAsync(string path, ExceptionType excepti var root = _apiRootSelectorService.StyleBertVitsRoot; if (string.IsNullOrEmpty(root)) throw new EbookException(ExceptionType.UnknownStyleBertVitsRoot); + var baseUri = new Uri(root); + var requestUri = new Uri(baseUri, path); var response = await _httpClientFactory.CreateClient() - .GetAsync($"{root}{path}", cancellationToken) + .GetAsync(requestUri, cancellationToken) .ConfigureAwait(false) ?? throw new EbookException(exceptionType); if (!response.IsSuccessStatusCode) diff --git a/KoeBook/App.xaml.cs b/KoeBook/App.xaml.cs index 0ed572e..2a60e96 100644 --- a/KoeBook/App.xaml.cs +++ b/KoeBook/App.xaml.cs @@ -6,7 +6,6 @@ using KoeBook.Core.Contracts.Services; using KoeBook.Core.Services; using KoeBook.Core.Services.Mocks; -using KoeBook.Epub; using KoeBook.Epub.Contracts.Services; using KoeBook.Epub.Services; using KoeBook.Models; diff --git a/KoeBook/Startup.cs b/KoeBook/Startup.cs index d19c6a9..304dc32 100644 --- a/KoeBook/Startup.cs +++ b/KoeBook/Startup.cs @@ -33,8 +33,8 @@ public static IHostBuilder UseCoreStartup(this IHostBuilder builder) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // Epub Services services