diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/GeneralLLMServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/GeneralLLMServiceTests.cs index 040a824d..3bca80f8 100644 --- a/TelegramSearchBot.LLM.Test/Service/AI/LLM/GeneralLLMServiceTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/GeneralLLMServiceTests.cs @@ -214,5 +214,37 @@ public async Task AnalyzeImageAsync_NoChannels_ReturnsErrorMessage() { var result = await _service.AnalyzeImageAsync("/tmp/test.jpg", 123, CancellationToken.None); Assert.StartsWith("Error:", result); } + + [Fact] + public async Task AnalyzeImageAsync_WithCustomPrompt_ForwardsPromptToProvider() { + var providerMock = new Mock(); + var channel = new LLMChannel { + Name = "vision-channel", + Gateway = "https://example.com", + ApiKey = "key", + Provider = LLMProvider.OpenAI, + Parallel = 1, + Priority = 1 + }; + + providerMock + .Setup(s => s.AnalyzeImageAsync("image.jpg", "vision-model", channel, GeneralLLMService.DefaultVisionOcrPrompt)) + .ReturnsAsync("recognized text"); + + var results = new List(); + await foreach (var result in _service.AnalyzeImageAsync( + "image.jpg", + 123, + "vision-model", + providerMock.Object, + channel, + GeneralLLMService.DefaultVisionOcrPrompt, + CancellationToken.None)) { + results.Add(result); + } + + Assert.Single(results); + Assert.Equal("recognized text", results[0]); + } } } diff --git a/TelegramSearchBot.LLM/Interface/AI/LLM/IGeneralLLMService.cs b/TelegramSearchBot.LLM/Interface/AI/LLM/IGeneralLLMService.cs index bd0aa73c..80c41914 100644 --- a/TelegramSearchBot.LLM/Interface/AI/LLM/IGeneralLLMService.cs +++ b/TelegramSearchBot.LLM/Interface/AI/LLM/IGeneralLLMService.cs @@ -21,7 +21,9 @@ public interface IGeneralLLMService { IAsyncEnumerable ResumeFromSnapshotAsync(LlmContinuationSnapshot snapshot, LlmExecutionContext executionContext, CancellationToken cancellationToken = default); Task AnalyzeImageAsync(string PhotoPath, long ChatId, CancellationToken cancellationToken = default); + Task AnalyzeImageAsync(string PhotoPath, long ChatId, string prompt, CancellationToken cancellationToken = default); IAsyncEnumerable AnalyzeImageAsync(string PhotoPath, long ChatId, string modelName, ILLMService service, LLMChannel channel, CancellationToken cancellationToken = default); + IAsyncEnumerable AnalyzeImageAsync(string PhotoPath, long ChatId, string modelName, ILLMService service, LLMChannel channel, string prompt, CancellationToken cancellationToken = default); Task GenerateEmbeddingsAsync(Message message, long ChatId); Task GenerateEmbeddingsAsync(string message, CancellationToken cancellationToken = default); IAsyncEnumerable GenerateEmbeddingsAsync(string message, string modelName, ILLMService service, LLMChannel channel, CancellationToken cancellationToken = default); diff --git a/TelegramSearchBot.LLM/Interface/AI/LLM/ILLMService.cs b/TelegramSearchBot.LLM/Interface/AI/LLM/ILLMService.cs index 295e2906..c812158d 100644 --- a/TelegramSearchBot.LLM/Interface/AI/LLM/ILLMService.cs +++ b/TelegramSearchBot.LLM/Interface/AI/LLM/ILLMService.cs @@ -42,7 +42,7 @@ public IAsyncEnumerable ResumeFromSnapshotAsync(LlmContinuationSnapshot /// public Task> GetAllModelsWithCapabilities(LLMChannel channel); - public Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel); + public Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, string prompt = null); public virtual async Task IsHealthyAsync(LLMChannel channel) => ( await GetAllModels(channel) ).Any(); } } diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs index 2d0a65ad..889b4a39 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs @@ -971,11 +971,13 @@ public Task GenerateEmbeddingsAsync(string text, string modelName, LLMC #region Image Analysis - public async Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel) { + public async Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, string prompt = null) { if (string.IsNullOrWhiteSpace(modelName)) { modelName = "claude-sonnet-4-20250514"; } + prompt = string.IsNullOrWhiteSpace(prompt) ? GeneralLLMService.DefaultAltPhotoPrompt : prompt; + if (channel == null || string.IsNullOrWhiteSpace(channel.ApiKey)) { _logger.LogError("{ServiceName}: Channel or ApiKey is not configured.", ServiceName); return $"Error: {ServiceName} channel/apikey is not configured."; @@ -990,8 +992,6 @@ public async Task AnalyzeImageAsync(string photoPath, string modelName, var imgArray = imgData.ToArray(); var base64Image = Convert.ToBase64String(imgArray); - var prompt = "请根据这张图片生成一句准确、详尽的中文alt文本,说明画面中重要的元素、场景和含义,避免使用'图中显示'或'这是一张图片'这类通用表达。"; - var imageSource = new Base64ImageSource { Data = base64Image, MediaType = MediaType.ImagePng, diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/GeminiService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/GeminiService.cs index 81f46fba..5cf52ba7 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/GeminiService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/GeminiService.cs @@ -458,11 +458,13 @@ public async Task GenerateEmbeddingsAsync(string text, string modelName } } - public async Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel) { + public async Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, string prompt = null) { if (string.IsNullOrWhiteSpace(modelName)) { modelName = "gpt-4-vision-preview"; } + prompt = string.IsNullOrWhiteSpace(prompt) ? GeneralLLMService.DefaultAltPhotoPrompt : prompt; + if (channel == null || string.IsNullOrWhiteSpace(channel.Gateway) || string.IsNullOrWhiteSpace(channel.ApiKey)) { _logger.LogError("{ServiceName}: Channel, Gateway or ApiKey is not configured.", ServiceName); return $"Error: {ServiceName} channel/gateway/apikey is not configured."; @@ -471,8 +473,6 @@ public async Task AnalyzeImageAsync(string photoPath, string modelName, var googleAI = new GoogleAi(channel.ApiKey, client: _httpClientFactory.CreateClient()); var model = googleAI.CreateGenerativeModel("models/" + modelName); try { - var prompt = $"请根据这张图片生成一句准确、详尽的中文alt文本,说明画面中重要的元素、场景和含义,避免使用'图中显示'或'这是一张图片'这类通用表达。"; - var chat = model.StartChat(); diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs index 5aa138aa..62549804 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs @@ -31,6 +31,8 @@ public class GeneralLLMService : IService, IGeneralLLMService { public const string MaxImageRetryCountKey = "LLM:MaxImageRetryCount"; public const string AltPhotoModelName = "LLM:AltPhotoModelName"; public const string EmbeddingModelName = "LLM:EmbeddingModelName"; + public const string DefaultAltPhotoPrompt = "请根据这张图片生成一句准确、详尽的中文alt文本,说明画面中重要的元素、场景和含义,避免使用'图中显示'或'这是一张图片'这类通用表达。"; + public const string DefaultVisionOcrPrompt = "请使用视觉能力识别并提取这张图片里可见的文字内容,优先输出原文,并按从上到下、从左到右的自然阅读顺序整理。尽量保留换行和段落,不要改写、总结或解释;只有在完全没有可识别文字时,才用一句中文简要描述图片主要内容。不要添加“图中显示”或“这是一张图片”这类套话。"; public const int DefaultMaxRetryCount = 100; public const int DefaultMaxImageRetryCount = 1000; @@ -225,7 +227,13 @@ orderby s.Priority descending _logger.LogWarning($"所有{modelName}关联的渠道当前都已满载,重试{maxRetries}次后放弃"); } - public async Task AnalyzeImageAsync(string PhotoPath, long ChatId, CancellationToken cancellationToken = default) { + public Task AnalyzeImageAsync(string PhotoPath, long ChatId, CancellationToken cancellationToken = default) { + return AnalyzeImageAsync(PhotoPath, ChatId, DefaultAltPhotoPrompt, cancellationToken); + } + + public async Task AnalyzeImageAsync(string PhotoPath, long ChatId, string prompt, CancellationToken cancellationToken = default) { + prompt = string.IsNullOrWhiteSpace(prompt) ? DefaultAltPhotoPrompt : prompt; + // 1. 获取模型名称 var modelName = "gemma3:27b"; var config = await _dbContext.AppConfigurationItems @@ -235,7 +243,7 @@ public async Task AnalyzeImageAsync(string PhotoPath, long ChatId, Cance } await using var enumerator = ExecOperationAsync((service, channel, cancel) => { - return AnalyzeImageAsync(PhotoPath, ChatId, modelName, service, channel, cancel); + return AnalyzeImageAsync(PhotoPath, ChatId, modelName, service, channel, prompt, cancel); }, modelName, cancellationToken).GetAsyncEnumerator(); if (await enumerator.MoveNextAsync()) { @@ -246,7 +254,14 @@ public async Task AnalyzeImageAsync(string PhotoPath, long ChatId, Cance return $"Error:未能获取 {modelName} 模型的图片分析结果"; } public async IAsyncEnumerable AnalyzeImageAsync(string PhotoPath, long ChatId, string modelName, ILLMService service, LLMChannel channel, CancellationToken cancellationToken = default) { - yield return await service.AnalyzeImageAsync(PhotoPath, modelName, channel); + await foreach (var result in AnalyzeImageAsync(PhotoPath, ChatId, modelName, service, channel, DefaultAltPhotoPrompt, cancellationToken)) { + yield return result; + } + } + + public async IAsyncEnumerable AnalyzeImageAsync(string PhotoPath, long ChatId, string modelName, ILLMService service, LLMChannel channel, string prompt, CancellationToken cancellationToken = default) { + prompt = string.IsNullOrWhiteSpace(prompt) ? DefaultAltPhotoPrompt : prompt; + yield return await service.AnalyzeImageAsync(PhotoPath, modelName, channel, prompt); yield break; } diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs index 7bccc262..6a7eb5a4 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs @@ -389,16 +389,17 @@ public async Task GenerateEmbeddingsAsync(string text, string modelName } } - public async Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel) { + public async Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, string prompt = null) { if (string.IsNullOrWhiteSpace(modelName)) { modelName = "gemma3:27b"; } + prompt = string.IsNullOrWhiteSpace(prompt) ? GeneralLLMService.DefaultAltPhotoPrompt : prompt; + var httpClient = _httpClientFactory?.CreateClient() ?? new HttpClient(); httpClient.BaseAddress = new Uri(channel.Gateway); var ollama = new OllamaApiClient(httpClient, modelName); ollama.SelectedModel = modelName; - var prompt = "请根据这张图片生成一句准确、详尽的中文alt文本,说明画面中重要的元素、场景和含义,避免使用'图中显示'或'这是一张图片'这类通用表达。"; var chat = new Chat(ollama); chat.Options = new RequestOptions(); chat.Options.Temperature = 0.1f; diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs index 74595976..8044cda4 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs @@ -1461,11 +1461,13 @@ public async Task GetModel(long ChatId) { return ModelName; } - public async Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel) { + public async Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, string prompt = null) { if (string.IsNullOrWhiteSpace(modelName)) { modelName = "gpt-4-vision-preview"; } + prompt = string.IsNullOrWhiteSpace(prompt) ? GeneralLLMService.DefaultAltPhotoPrompt : prompt; + if (channel == null || string.IsNullOrWhiteSpace(channel.Gateway) || string.IsNullOrWhiteSpace(channel.ApiKey)) { _logger.LogError("{ServiceName}: Channel, Gateway or ApiKey is not configured.", ServiceName); return $"Error: {ServiceName} channel/gateway/apikey is not configured."; @@ -1488,8 +1490,6 @@ public async Task AnalyzeImageAsync(string photoPath, string modelName, var tg_img_arr = tg_img_data.ToArray(); var base64Image = Convert.ToBase64String(tg_img_arr); - var prompt = $"请根据这张图片生成一句准确、详尽的中文alt文本,说明画面中重要的元素、场景和含义,避免使用'图中显示'或'这是一张图片'这类通用表达。"; - var messages = new List { new UserChatMessage(new List() { ChatMessageContentPart.CreateTextPart(prompt), diff --git a/TelegramSearchBot.Test/Manage/EditOCRConfServiceTests.cs b/TelegramSearchBot.Test/Manage/EditOCRConfServiceTests.cs new file mode 100644 index 00000000..59a4dd3e --- /dev/null +++ b/TelegramSearchBot.Test/Manage/EditOCRConfServiceTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using StackExchange.Redis; +using Telegram.Bot.Types.ReplyMarkups; +using TelegramSearchBot.Model.AI; +using TelegramSearchBot.Model; +using TelegramSearchBot.Model.Data; +using TelegramSearchBot.Service.Manage; +using Xunit; + +namespace TelegramSearchBot.Test.Manage { + public class EditOCRConfServiceTests { + private readonly DataDbContext _dbContext; + private readonly Mock _redisMock; + private readonly Mock _dbMock; + private readonly Dictionary _redisStore = new(); + private readonly EditOCRConfService _service; + + public EditOCRConfServiceTests() { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + _dbContext = new DataDbContext(options); + + _redisMock = new Mock(); + _dbMock = new Mock(); + _redisMock.Setup(r => r.GetDatabase(It.IsAny(), It.IsAny())) + .Returns(_dbMock.Object); + + _dbMock.Setup(d => d.StringGetAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((RedisKey key, CommandFlags flags) => + _redisStore.TryGetValue(key.ToString(), out var value) ? value : RedisValue.Null); + + _dbMock.Setup(d => d.StringSetAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((RedisKey key, RedisValue value, TimeSpan? expiry, When when) => { + _redisStore[key.ToString()] = value; + return true; + }); + + _dbMock.Setup(d => d.StringSetAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) => { + _redisStore[key.ToString()] = value; + return true; + }); + + _dbMock.Setup(d => d.StringSetAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((RedisKey key, RedisValue value, TimeSpan? expiry, bool keepTtl, When when, CommandFlags flags) => { + _redisStore[key.ToString()] = value; + return true; + }); + + _dbMock.Setup(d => d.KeyDeleteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((RedisKey key, CommandFlags flags) => _redisStore.Remove(key.ToString())); + + var loggerMock = new Mock>(); + _service = new EditOCRConfService(_dbContext, _redisMock.Object, loggerMock.Object); + } + + [Fact] + public async Task ExecuteAsync_UnrelatedCommandWithoutActiveSession_ReturnsFalse() { + var (status, message) = await _service.ExecuteAsync("你好", 12345); + + Assert.False(status); + Assert.Equal(string.Empty, message); + } + + [Fact] + public async Task ExecuteAsync_SwitchEngineFlow_UsesReplyKeyboardAndTransitionsState() { + _dbContext.LLMChannels.Add(new LLMChannel { + Name = "vision-channel", + Gateway = "https://example.com", + ApiKey = "key", + Provider = LLMProvider.OpenAI, + Parallel = 1, + Priority = 1 + }); + await _dbContext.SaveChangesAsync(); + + _redisStore["ocrconf:100:state"] = OCRConfState.MainMenu.GetDescription(); + var mainMenuMarkup = await _service.GetReplyMarkupAsync(100); + var mainMenuKeyboard = Assert.IsType(mainMenuMarkup); + Assert.Contains(mainMenuKeyboard.Keyboard, row => row.Any(button => button.Text == "切换OCR引擎")); + + var (switched, engineMessage) = await _service.ExecuteAsync("切换OCR引擎", 100); + Assert.True(switched); + Assert.Contains("请选择 OCR 引擎", engineMessage); + + _redisStore["ocrconf:100:state"] = OCRConfState.SelectingEngine.GetDescription(); + var engineMarkup = await _service.GetReplyMarkupAsync(100); + var engineKeyboard = Assert.IsType(engineMarkup); + Assert.Contains(engineKeyboard.Keyboard, row => row.Any(button => button.Text == "PaddleOCR")); + Assert.Contains(engineKeyboard.Keyboard, row => row.Any(button => button.Text == "LLM")); + } + } +} diff --git a/TelegramSearchBot.Test/Service/AI/OCR/LLMOCRServiceTests.cs b/TelegramSearchBot.Test/Service/AI/OCR/LLMOCRServiceTests.cs new file mode 100644 index 00000000..04564a3f --- /dev/null +++ b/TelegramSearchBot.Test/Service/AI/OCR/LLMOCRServiceTests.cs @@ -0,0 +1,43 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using SkiaSharp; +using TelegramSearchBot.Interface.AI.LLM; +using TelegramSearchBot.Service.AI.LLM; +using TelegramSearchBot.Service.AI.OCR; +using Xunit; + +namespace TelegramSearchBot.Test.Service.AI.OCR { + public class LLMOCRServiceTests { + [Fact] + public async Task ExecuteAsync_UsesVisionOcrPrompt() { + var llmServiceMock = new Mock(); + llmServiceMock + .Setup(s => s.AnalyzeImageAsync( + It.Is(path => path.EndsWith(".jpg")), + 0, + GeneralLLMService.DefaultVisionOcrPrompt, + It.IsAny())) + .ReturnsAsync("recognized text"); + + var loggerMock = new Mock>(); + var service = new LLMOCRService(llmServiceMock.Object, loggerMock.Object); + + using var bitmap = new SKBitmap(8, 8); + using var image = SKImage.FromBitmap(bitmap); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + using var stream = new MemoryStream(data.ToArray()); + + var result = await service.ExecuteAsync(stream); + + Assert.Equal("recognized text", result); + llmServiceMock.Verify(s => s.AnalyzeImageAsync( + It.Is(path => path.EndsWith(".jpg")), + 0, + GeneralLLMService.DefaultVisionOcrPrompt, + It.IsAny()), Times.Once); + } + } +} diff --git a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs index f2d04f4b..6e165285 100644 --- a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs +++ b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs @@ -112,6 +112,23 @@ private static async Task WaitForExternalLocalBotApiIfNeededAsync() { await WaitForTcpServiceReady(localBotApiUri.Host, port, "external telegram-bot-api", maxRetries: 40); } + /// + /// 等待本地 telegram-bot-api 服务端口就绪,最多等待 20 秒 + /// + private static async Task WaitForLocalBotApiReady(int port, int maxRetries = 40, int delayMs = 500) { + for (int i = 0; i < maxRetries; i++) { + try { + using var tcp = new System.Net.Sockets.TcpClient(); + await tcp.ConnectAsync("127.0.0.1", port); + Log.Information("telegram-bot-api 服务已就绪 (端口 {Port}),耗时约 {ElapsedMs}ms", port, i * delayMs); + return; + } catch { + await Task.Delay(delayMs); + } + } + Log.Warning("等待 telegram-bot-api 服务就绪超时 (端口 {Port}),将继续启动", port); + } + public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .UseSerilog() @@ -139,6 +156,42 @@ public static async Task Startup(string[] args) { await WaitForExternalLocalBotApiIfNeededAsync(); } + // 如果启用了本地 telegram-bot-api,则在此启动它 + if (Env.EnableLocalBotAPI) { + string botApiExePath = Path.Combine(AppContext.BaseDirectory, "telegram-bot-api.exe"); + if (File.Exists(botApiExePath)) { + if (string.IsNullOrEmpty(Env.TelegramBotApiId) || string.IsNullOrEmpty(Env.TelegramBotApiHash)) { + Log.Warning("EnableLocalBotAPI 为 true,但 TelegramBotApiId 或 TelegramBotApiHash 未配置,跳过本地 Bot API 启动"); + } else { + var botApiDataDir = Path.Combine(Env.WorkDir, "telegram-bot-api"); + Directory.CreateDirectory(botApiDataDir); + // 使用 ArgumentList 以正确处理路径中的空格 + // --local 模式允许大文件上传下载并将文件存储在本地 dir 下 + var startInfo = new ProcessStartInfo { + FileName = botApiExePath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + startInfo.ArgumentList.Add("--local"); + startInfo.ArgumentList.Add($"--api-id={Env.TelegramBotApiId}"); + startInfo.ArgumentList.Add($"--api-hash={Env.TelegramBotApiHash}"); + startInfo.ArgumentList.Add($"--dir={botApiDataDir}"); + startInfo.ArgumentList.Add($"--http-port={Env.LocalBotApiPort}"); + var botApiProcess = Process.Start(startInfo); + if (botApiProcess == null) { + Log.Warning("telegram-bot-api 进程启动失败"); + } else { + childProcessManager.AddProcess(botApiProcess); + Log.Information("telegram-bot-api 已启动,等待端口 {Port} 就绪...", Env.LocalBotApiPort); + await WaitForLocalBotApiReady(Env.LocalBotApiPort); + } + } + } else { + Log.Warning("未找到 telegram-bot-api 可执行文件 {Path},跳过本地 Bot API 启动", botApiExePath); + } + } + IHost host = CreateHostBuilder(args) //.ConfigureLogging(logging => { // logging.ClearProviders(); diff --git a/TelegramSearchBot/Controller/AI/OCR/AutoOCRController.cs b/TelegramSearchBot/Controller/AI/OCR/AutoOCRController.cs index 0ca6ade8..054c11f1 100644 --- a/TelegramSearchBot/Controller/AI/OCR/AutoOCRController.cs +++ b/TelegramSearchBot/Controller/AI/OCR/AutoOCRController.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Telegram.Bot; @@ -18,13 +19,17 @@ using TelegramSearchBot.Interface.Controller; using TelegramSearchBot.Manager; using TelegramSearchBot.Model; +using TelegramSearchBot.Model.AI; using TelegramSearchBot.Service.AI.OCR; using TelegramSearchBot.Service.BotAPI; +using TelegramSearchBot.Service.Common; +using TelegramSearchBot.Service.Manage; using TelegramSearchBot.Service.Storage; namespace TelegramSearchBot.Controller.AI.OCR { public class AutoOCRController : IOnUpdate { - private readonly IPaddleOCRService paddleOCRService; + private readonly IEnumerable _ocrServices; + private readonly IAppConfigurationService _configService; private readonly MessageService messageService; private readonly ITelegramBotClient botClient; private readonly SendMessage Send; @@ -33,14 +38,16 @@ public class AutoOCRController : IOnUpdate { private readonly MessageExtensionService MessageExtensionService; public AutoOCRController( ITelegramBotClient botClient, - IPaddleOCRService paddleOCRService, + IEnumerable ocrServices, + IAppConfigurationService configService, SendMessage Send, MessageService messageService, ILogger logger, ISendMessageService sendMessageService, MessageExtensionService messageExtensionService ) { - this.paddleOCRService = paddleOCRService; + this._ocrServices = ocrServices; + this._configService = configService; this.messageService = messageService; this.botClient = botClient; this.Send = Send; @@ -63,7 +70,13 @@ public async Task ExecuteAsync(PipelineContext p) { try { var PhotoStream = await IProcessPhoto.GetPhoto(e); logger.LogInformation($"Get Photo File: {e.Message.Chat.Id}/{e.Message.MessageId}"); - OcrStr = await paddleOCRService.ExecuteAsync(new MemoryStream(PhotoStream)); + + var engine = await GetOCREngineAsync(); + var ocrService = _ocrServices.FirstOrDefault(s => s.Engine == engine) + ?? _ocrServices.First(s => s.Engine == OCREngine.PaddleOCR); + + logger.LogInformation($"使用OCR引擎: {engine}"); + OcrStr = await ocrService.ExecuteAsync(new MemoryStream(PhotoStream)); if (!string.IsNullOrWhiteSpace(OcrStr)) { logger.LogInformation(OcrStr); await MessageExtensionService.AddOrUpdateAsync(p.MessageDataId, "OCR_Result", OcrStr); @@ -80,7 +93,6 @@ ex is DirectoryNotFoundException ( e.Message.ReplyToMessage != null && e.Message.Text != null && e.Message.Text.Equals("打印") )) { string ocrResult = OcrStr; - // 如果是回复消息触发打印 if (e.Message.ReplyToMessage != null) { var originalMessageId = await MessageExtensionService.GetMessageIdByMessageIdAndGroupId( e.Message.ReplyToMessage.MessageId, @@ -100,5 +112,13 @@ ex is DirectoryNotFoundException } } } + + private async Task GetOCREngineAsync() { + var engineStr = await _configService.GetConfigurationValueAsync(EditOCRConfService.OCREngineKey); + if (!string.IsNullOrEmpty(engineStr) && Enum.TryParse(engineStr, out var engine)) { + return engine; + } + return OCREngine.PaddleOCR; + } } } diff --git a/TelegramSearchBot/Controller/Manage/EditOCRConfController.cs b/TelegramSearchBot/Controller/Manage/EditOCRConfController.cs new file mode 100644 index 00000000..ce104ae9 --- /dev/null +++ b/TelegramSearchBot/Controller/Manage/EditOCRConfController.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.ReplyMarkups; +using TelegramSearchBot.Common; +using TelegramSearchBot.Interface.Controller; +using TelegramSearchBot.Manager; +using TelegramSearchBot.Model; +using TelegramSearchBot.Service.BotAPI; +using TelegramSearchBot.Service.Manage; +using TelegramSearchBot.View; + +namespace TelegramSearchBot.Controller.Manage { + public class EditOCRConfController : IOnUpdate { + protected readonly AdminService AdminService; + protected readonly EditOCRConfService EditOCRConfService; + protected readonly EditOCRConfView EditOCRConfView; + public ITelegramBotClient botClient { get; set; } + public EditOCRConfController( + ITelegramBotClient botClient, + AdminService AdminService, + EditOCRConfService EditOCRConfService, + EditOCRConfView EditOCRConfView) { + this.AdminService = AdminService; + this.EditOCRConfService = EditOCRConfService; + this.EditOCRConfView = EditOCRConfView; + this.botClient = botClient; + } + public List Dependencies => new List(); + + public async Task ExecuteAsync(PipelineContext p) { + var e = p.Update; + if (e?.Message?.Chat?.Id < 0) { + return; + } + if (e?.Message?.From?.Id != Env.AdminId) { + return; + } + string Command; + if (!string.IsNullOrEmpty(e.Message.Text)) { + Command = e.Message.Text; + } else if (!string.IsNullOrEmpty(e.Message.Caption)) { + Command = e.Message.Caption; + } else return; + + var (status, message) = await EditOCRConfService.ExecuteAsync(Command, e.Message.Chat.Id); + if (status) { + var replyMarkup = await EditOCRConfService.GetReplyMarkupAsync(e.Message.Chat.Id); + await EditOCRConfView + .WithChatId(e.Message.Chat.Id) + .WithReplyTo(e.Message.MessageId) + .WithMessage(message) + .WithReplyMarkup(replyMarkup) + .Render(); + } + } + } +} diff --git a/TelegramSearchBot/Helper/EditOCRConfRedisHelper.cs b/TelegramSearchBot/Helper/EditOCRConfRedisHelper.cs new file mode 100644 index 00000000..59714f86 --- /dev/null +++ b/TelegramSearchBot/Helper/EditOCRConfRedisHelper.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using StackExchange.Redis; + +namespace TelegramSearchBot.Helper { + public class EditOCRConfRedisHelper { + private readonly IConnectionMultiplexer _connection; + private readonly long _chatId; + private IDatabase _database; + + public EditOCRConfRedisHelper(IConnectionMultiplexer connection, long chatId) { + _connection = connection; + _chatId = chatId; + _database = _connection.GetDatabase(); + } + + private IDatabase GetDatabase() => _database; + + private string StateKey => $"ocrconf:{_chatId}:state"; + private string DataKey => $"ocrconf:{_chatId}:data"; + private string ChannelKey => $"ocrconf:{_chatId}:channel"; + + public async Task GetStateAsync() { + return await GetDatabase().StringGetAsync(StateKey); + } + + public async Task SetStateAsync(string state) { + await GetDatabase().StringSetAsync(StateKey, state); + } + + public async Task GetDataAsync() { + return await GetDatabase().StringGetAsync(DataKey); + } + + public async Task SetDataAsync(string data) { + await GetDatabase().StringSetAsync(DataKey, data); + } + + public async Task GetChannelIdAsync() { + var value = await GetDatabase().StringGetAsync(ChannelKey); + if (value.HasValue && int.TryParse(value.ToString(), out var channelId)) { + return channelId; + } + return null; + } + + public async Task SetChannelIdAsync(int channelId) { + await GetDatabase().StringSetAsync(ChannelKey, channelId.ToString()); + } + + public async Task DeleteKeysAsync() { + var db = GetDatabase(); + await db.KeyDeleteAsync(StateKey); + await db.KeyDeleteAsync(DataKey); + await db.KeyDeleteAsync(ChannelKey); + } + } +} diff --git a/TelegramSearchBot/Interface/AI/OCR/IOCRService.cs b/TelegramSearchBot/Interface/AI/OCR/IOCRService.cs new file mode 100644 index 00000000..b82314cd --- /dev/null +++ b/TelegramSearchBot/Interface/AI/OCR/IOCRService.cs @@ -0,0 +1,14 @@ +using System.IO; +using System.Threading.Tasks; + +namespace TelegramSearchBot.Interface.AI.OCR { + public enum OCREngine { + PaddleOCR, + LLM + } + + public interface IOCRService { + OCREngine Engine { get; } + Task ExecuteAsync(Stream file); + } +} diff --git a/TelegramSearchBot/Model/AI/OCRConfState.cs b/TelegramSearchBot/Model/AI/OCRConfState.cs new file mode 100644 index 00000000..1605adcb --- /dev/null +++ b/TelegramSearchBot/Model/AI/OCRConfState.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; + +namespace TelegramSearchBot.Model.AI { + public enum OCRConfState { + [Description("main_menu")] + MainMenu, + + [Description("selecting_engine")] + SelectingEngine, + + [Description("selecting_llm_channel")] + SelectingLLMChannel, + + [Description("selecting_llm_model")] + SelectingLLMModel, + + [Description("viewing_config")] + ViewingConfig + } + + public static class OCRConfStateExtensions { + public static string GetDescription(this OCRConfState state) { + var fieldInfo = state.GetType().GetField(state.ToString()); + var attributes = ( DescriptionAttribute[] ) fieldInfo.GetCustomAttributes(typeof(DescriptionAttribute), false); + return attributes.Length > 0 ? attributes[0].Description : state.ToString(); + } + } +} diff --git a/TelegramSearchBot/Service/AI/OCR/LLMOCRService.cs b/TelegramSearchBot/Service/AI/OCR/LLMOCRService.cs new file mode 100644 index 00000000..d62ad2ea --- /dev/null +++ b/TelegramSearchBot/Service/AI/OCR/LLMOCRService.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using SkiaSharp; +using TelegramSearchBot.Attributes; +using TelegramSearchBot.Interface.AI.LLM; +using TelegramSearchBot.Interface.AI.OCR; +using TelegramSearchBot.Service.AI.LLM; + +namespace TelegramSearchBot.Service.AI.OCR { + [Injectable(Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton)] + public class LLMOCRService : IOCRService { + private readonly IGeneralLLMService _generalLLMService; + private readonly ILogger _logger; + + public OCREngine Engine => OCREngine.LLM; + + public LLMOCRService( + IGeneralLLMService generalLLMService, + ILogger logger + ) { + _generalLLMService = generalLLMService; + _logger = logger; + } + + public async Task ExecuteAsync(Stream file) { + try { + var tg_img = SKBitmap.Decode(file); + var tg_img_data = tg_img.Encode(SKEncodedImageFormat.Jpeg, 99); + var tempPath = Path.GetTempFileName() + ".jpg"; + + try { + using (var fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write)) { + tg_img_data.SaveTo(fs); + } + + _logger.LogInformation("正在使用LLM进行OCR识别..."); + var result = await _generalLLMService.AnalyzeImageAsync(tempPath, 0, GeneralLLMService.DefaultVisionOcrPrompt); + + if (string.IsNullOrWhiteSpace(result)) { + _logger.LogWarning("LLM OCR返回空结果"); + return string.Empty; + } + + _logger.LogInformation("LLM OCR识别完成"); + return result; + } finally { + if (File.Exists(tempPath)) { + File.Delete(tempPath); + } + } + } catch (Exception ex) { + _logger.LogError(ex, "LLM OCR处理失败"); + throw; + } + } + } +} diff --git a/TelegramSearchBot/Service/AI/OCR/PaddleOCRService.cs b/TelegramSearchBot/Service/AI/OCR/PaddleOCRService.cs index 4a7064c9..93e9b1b4 100644 --- a/TelegramSearchBot/Service/AI/OCR/PaddleOCRService.cs +++ b/TelegramSearchBot/Service/AI/OCR/PaddleOCRService.cs @@ -9,9 +9,10 @@ namespace TelegramSearchBot.Service.AI.OCR { [Injectable(Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton)] - public class PaddleOCRService : SubProcessService, IPaddleOCRService { + public class PaddleOCRService : SubProcessService, IPaddleOCRService, IOCRService { public new string ServiceName => "PaddleOCRService"; + public OCREngine Engine => OCREngine.PaddleOCR; public PaddleOCRService(IConnectionMultiplexer connectionMultiplexer) : base(connectionMultiplexer) { ForkName = "OCR"; diff --git a/TelegramSearchBot/Service/Manage/EditOCRConfService.cs b/TelegramSearchBot/Service/Manage/EditOCRConfService.cs new file mode 100644 index 00000000..57eb2e6d --- /dev/null +++ b/TelegramSearchBot/Service/Manage/EditOCRConfService.cs @@ -0,0 +1,377 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; +using Telegram.Bot.Types.ReplyMarkups; +using TelegramSearchBot.Attributes; +using TelegramSearchBot.Helper; +using TelegramSearchBot.Interface; +using TelegramSearchBot.Interface.AI.OCR; +using TelegramSearchBot.Interface.Manage; +using TelegramSearchBot.Model; +using TelegramSearchBot.Model.AI; +using TelegramSearchBot.Model.Data; + +namespace TelegramSearchBot.Service.Manage { + [Injectable(Microsoft.Extensions.DependencyInjection.ServiceLifetime.Transient)] + public class EditOCRConfService : IService { + public string ServiceName => "EditOCRConfService"; + protected readonly DataDbContext DataContext; + protected readonly ILogger _logger; + protected IConnectionMultiplexer connectionMultiplexer { get; set; } + + public const string OCREngineKey = "OCR:Engine"; + public const string OCRLLMModelNameKey = "OCR:LLMModelName"; + public const string OCRLLMChannelIdKey = "OCR:LLMChannelId"; + + private readonly Dictionary>> _stateHandlers; + + public EditOCRConfService( + DataDbContext context, + IConnectionMultiplexer connectionMultiplexer, + ILogger logger + ) { + this.connectionMultiplexer = connectionMultiplexer; + DataContext = context; + _logger = logger; + + _stateHandlers = new Dictionary>> + { + { OCRConfState.MainMenu.GetDescription(), HandleMainMenuAsync }, + { OCRConfState.SelectingEngine.GetDescription(), HandleSelectingEngineAsync }, + { OCRConfState.SelectingLLMChannel.GetDescription(), HandleSelectingLLMChannelAsync }, + { OCRConfState.SelectingLLMModel.GetDescription(), HandleSelectingLLMModelAsync }, + { OCRConfState.ViewingConfig.GetDescription(), HandleViewingConfigAsync } + }; + } + + public async Task<(bool, string)> ExecuteAsync(string command, long chatId) { + var redis = new EditOCRConfRedisHelper(connectionMultiplexer, chatId); + var normalizedCommand = command?.Trim() ?? string.Empty; + var isEntryCommand = normalizedCommand == "OCR设置" || normalizedCommand == "OCR配置"; + + var currentState = await redis.GetStateAsync(); + if (isEntryCommand) { + await redis.SetStateAsync(OCRConfState.MainMenu.GetDescription()); + return (true, await BuildMainMenuMessageAsync()); + } + + if (string.IsNullOrEmpty(currentState)) { + return (false, string.Empty); + } + + if (normalizedCommand == "退出") { + await redis.DeleteKeysAsync(); + return (true, "已退出OCR配置"); + } + + if (normalizedCommand == "返回") { + return await HandleBackAsync(redis, currentState); + } + + if (_stateHandlers.TryGetValue(currentState, out var handler)) { + return await handler(redis, normalizedCommand); + } + + return (false, string.Empty); + } + + private async Task<(bool, string)> HandleMainMenuAsync(EditOCRConfRedisHelper redis, string command) { + if (command == "切换OCR引擎" || command == "1") { + await redis.SetStateAsync(OCRConfState.SelectingEngine.GetDescription()); + return (true, await BuildEngineSelectionMessageAsync()); + } + + if (command == "查看配置详情" || command == "2") { + return await HandleViewingConfigAsync(redis, command); + } + + return (true, await BuildMainMenuMessageAsync()); + } + + private async Task<(bool, string)> HandleSelectingEngineAsync(EditOCRConfRedisHelper redis, string command) { + if (command == "1" || command == "PaddleOCR") { + await SetEngineAsync(OCREngine.PaddleOCR); + await redis.SetStateAsync(OCRConfState.MainMenu.GetDescription()); + return (true, $"已切换到PaddleOCR引擎{System.Environment.NewLine}{System.Environment.NewLine}{await BuildMainMenuMessageAsync()}"); + } else if (command == "2" || command == "LLM") { + await SetEngineAsync(OCREngine.LLM); + await redis.SetStateAsync(OCRConfState.SelectingLLMChannel.GetDescription()); + return (true, await BuildLLMChannelSelectionMessageAsync()); + } + + return (true, $"无效选择,请使用下方键盘选择 OCR 引擎。{System.Environment.NewLine}{System.Environment.NewLine}{await BuildEngineSelectionMessageAsync()}"); + } + + private async Task<(bool, string)> HandleSelectingLLMChannelAsync(EditOCRConfRedisHelper redis, string command) { + if (TryParseChannelId(command, out var channelId)) { + var channel = await DataContext.LLMChannels + .FirstOrDefaultAsync(c => c.Id == channelId); + + if (channel != null) { + await redis.SetChannelIdAsync(channelId); + await redis.SetStateAsync(OCRConfState.SelectingLLMModel.GetDescription()); + return (true, await BuildLLMModelSelectionMessageAsync(channelId, channel.Name)); + } + } + + return (true, $"无效的渠道ID,请使用下方键盘重新选择。{System.Environment.NewLine}{System.Environment.NewLine}{await BuildLLMChannelSelectionMessageAsync()}"); + } + + private async Task<(bool, string)> HandleSelectingLLMModelAsync(EditOCRConfRedisHelper redis, string command) { + var channelId = await redis.GetChannelIdAsync(); + if (!channelId.HasValue) { + await redis.SetStateAsync(OCRConfState.SelectingLLMChannel.GetDescription()); + return (true, await BuildLLMChannelSelectionMessageAsync()); + } + + await SetLLMConfigAsync(channelId.Value, command); + await redis.SetStateAsync(OCRConfState.MainMenu.GetDescription()); + + return (true, $"OCR配置完成!{System.Environment.NewLine}引擎: LLM{System.Environment.NewLine}渠道ID: {channelId.Value}{System.Environment.NewLine}模型: {command}{System.Environment.NewLine}{System.Environment.NewLine}{await BuildMainMenuMessageAsync()}"); + } + + private async Task<(bool, string)> HandleViewingConfigAsync(EditOCRConfRedisHelper redis, string command) { + await redis.SetStateAsync(OCRConfState.MainMenu.GetDescription()); + return (true, await BuildCurrentConfigMessageAsync()); + } + + public async Task GetReplyMarkupAsync(long chatId) { + var redis = new EditOCRConfRedisHelper(connectionMultiplexer, chatId); + var currentState = await redis.GetStateAsync(); + if (string.IsNullOrEmpty(currentState)) { + return new ReplyKeyboardRemove(); + } + + if (currentState == OCRConfState.MainMenu.GetDescription()) { + return CreateReplyKeyboard( + new[] { "切换OCR引擎", "查看配置详情" }, + new[] { "退出" }); + } + + if (currentState == OCRConfState.SelectingEngine.GetDescription()) { + return CreateReplyKeyboard( + new[] { "PaddleOCR", "LLM" }, + new[] { "返回", "退出" }); + } + + if (currentState == OCRConfState.SelectingLLMChannel.GetDescription()) { + var channels = await GetAvailableLLMChannelsAsync(); + var rows = channels + .Select(channel => new[] { $"{channel.Id}. {channel.Name} ({channel.Provider})" }) + .ToList(); + rows.Add(new[] { "返回", "退出" }); + return CreateReplyKeyboard(rows.ToArray()); + } + + if (currentState == OCRConfState.SelectingLLMModel.GetDescription()) { + var channelId = await redis.GetChannelIdAsync(); + if (!channelId.HasValue) { + return CreateReplyKeyboard(new[] { "返回", "退出" }); + } + + var models = await GetAvailableModelsAsync(channelId.Value); + var rows = models + .Select(model => new[] { model }) + .ToList(); + rows.Add(new[] { "返回", "退出" }); + return CreateReplyKeyboard(rows.ToArray()); + } + + return new ReplyKeyboardRemove(); + } + + private async Task GetCurrentEngineAsync() { + var config = await DataContext.AppConfigurationItems + .FirstOrDefaultAsync(x => x.Key == OCREngineKey); + return config?.Value ?? OCREngine.PaddleOCR.ToString(); + } + + private async Task SetEngineAsync(OCREngine engine) { + var config = await DataContext.AppConfigurationItems + .FirstOrDefaultAsync(x => x.Key == OCREngineKey); + + if (config == null) { + await DataContext.AppConfigurationItems.AddAsync(new AppConfigurationItem { + Key = OCREngineKey, + Value = engine.ToString() + }); + } else { + config.Value = engine.ToString(); + } + + await DataContext.SaveChangesAsync(); + } + + private async Task SetLLMConfigAsync(int channelId, string modelName) { + await SetConfigAsync(OCRLLMChannelIdKey, channelId.ToString()); + await SetConfigAsync(OCRLLMModelNameKey, modelName); + } + + private async Task SetConfigAsync(string key, string value) { + var config = await DataContext.AppConfigurationItems + .FirstOrDefaultAsync(x => x.Key == key); + + if (config == null) { + await DataContext.AppConfigurationItems.AddAsync(new AppConfigurationItem { + Key = key, + Value = value + }); + } else { + config.Value = value; + } + + await DataContext.SaveChangesAsync(); + } + + private async Task> GetAvailableLLMChannelsAsync() { + return await DataContext.LLMChannels.ToListAsync(); + } + + private async Task<(bool, string)> HandleBackAsync(EditOCRConfRedisHelper redis, string currentState) { + if (currentState == OCRConfState.MainMenu.GetDescription()) { + await redis.DeleteKeysAsync(); + return (true, "已退出OCR配置"); + } + + if (currentState == OCRConfState.SelectingEngine.GetDescription() || + currentState == OCRConfState.ViewingConfig.GetDescription()) { + await redis.SetStateAsync(OCRConfState.MainMenu.GetDescription()); + return (true, await BuildMainMenuMessageAsync()); + } + + if (currentState == OCRConfState.SelectingLLMChannel.GetDescription()) { + await redis.SetStateAsync(OCRConfState.SelectingEngine.GetDescription()); + return (true, await BuildEngineSelectionMessageAsync()); + } + + if (currentState == OCRConfState.SelectingLLMModel.GetDescription()) { + await redis.SetStateAsync(OCRConfState.SelectingLLMChannel.GetDescription()); + return (true, await BuildLLMChannelSelectionMessageAsync()); + } + + await redis.SetStateAsync(OCRConfState.MainMenu.GetDescription()); + return (true, await BuildMainMenuMessageAsync()); + } + + private async Task BuildMainMenuMessageAsync() { + var sb = new StringBuilder(); + sb.AppendLine("🔧 OCR配置"); + sb.AppendLine(); + sb.AppendLine($"当前OCR引擎: {await GetCurrentEngineAsync()}"); + sb.AppendLine(); + sb.AppendLine("请使用下方键盘选择操作。"); + return sb.ToString(); + } + + private Task BuildEngineSelectionMessageAsync() { + var sb = new StringBuilder(); + sb.AppendLine("请选择 OCR 引擎:"); + sb.AppendLine("- PaddleOCR:本地 OCR"); + sb.AppendLine("- LLM:使用视觉模型读取图片文字"); + sb.AppendLine(); + sb.AppendLine("可使用下方键盘直接选择。"); + return Task.FromResult(sb.ToString()); + } + + private async Task BuildLLMChannelSelectionMessageAsync() { + var channels = await GetAvailableLLMChannelsAsync(); + if (!channels.Any()) { + await SetEngineAsync(OCREngine.PaddleOCR); + return $"当前没有可用的 LLM 渠道,已切回 PaddleOCR。{System.Environment.NewLine}{System.Environment.NewLine}{await BuildMainMenuMessageAsync()}"; + } + + var sb = new StringBuilder(); + sb.AppendLine("请选择 LLM 渠道:"); + foreach (var channel in channels) { + sb.AppendLine($"{channel.Id}. {channel.Name} ({channel.Provider})"); + } + sb.AppendLine(); + sb.AppendLine("可使用下方键盘直接选择。"); + return sb.ToString(); + } + + private async Task BuildLLMModelSelectionMessageAsync(int channelId, string channelName) { + var models = await GetAvailableModelsAsync(channelId); + var sb = new StringBuilder(); + sb.AppendLine($"已选择渠道: {channelName}"); + sb.AppendLine(); + sb.AppendLine("请选择 OCR 使用的模型:"); + if (models.Any()) { + foreach (var model in models) { + sb.AppendLine($"- {model}"); + } + sb.AppendLine(); + sb.AppendLine("可使用下方键盘直接选择。"); + } else { + sb.AppendLine("该渠道下没有已配置模型,请直接发送模型名称。"); + } + return sb.ToString(); + } + + private async Task BuildCurrentConfigMessageAsync() { + var sb = new StringBuilder(); + sb.AppendLine("📋 OCR配置详情"); + sb.AppendLine(); + sb.AppendLine($"引擎: {await GetCurrentEngineAsync()}"); + + var engineConfig = await DataContext.AppConfigurationItems + .FirstOrDefaultAsync(x => x.Key == OCREngineKey); + + if (engineConfig?.Value == OCREngine.LLM.ToString()) { + var channelConfig = await DataContext.AppConfigurationItems + .FirstOrDefaultAsync(x => x.Key == OCRLLMChannelIdKey); + var modelConfig = await DataContext.AppConfigurationItems + .FirstOrDefaultAsync(x => x.Key == OCRLLMModelNameKey); + + if (channelConfig != null && int.TryParse(channelConfig.Value, out var channelId)) { + var channel = await DataContext.LLMChannels + .FirstOrDefaultAsync(c => c.Id == channelId); + sb.AppendLine($"LLM渠道: {channel?.Name ?? "未找到"} ({channelId})"); + } + + if (modelConfig != null) { + sb.AppendLine($"LLM模型: {modelConfig.Value}"); + } + } + + sb.AppendLine(); + sb.AppendLine("请使用下方键盘继续操作。"); + return sb.ToString(); + } + + private async Task> GetAvailableModelsAsync(int channelId) { + return await DataContext.ChannelsWithModel + .Where(m => m.LLMChannelId == channelId && !m.IsDeleted) + .Select(m => m.ModelName) + .ToListAsync(); + } + + private static bool TryParseChannelId(string command, out int channelId) { + if (int.TryParse(command, out channelId)) { + return true; + } + + var match = Regex.Match(command, @"^\s*(\d+)"); + if (match.Success && int.TryParse(match.Groups[1].Value, out channelId)) { + return true; + } + + channelId = default; + return false; + } + + private static ReplyKeyboardMarkup CreateReplyKeyboard(params string[][] rows) { + return new ReplyKeyboardMarkup(rows + .Select(row => row.Select(text => new KeyboardButton(text)).ToArray()) + .ToArray()) { + ResizeKeyboard = true + }; + } + } +} diff --git a/TelegramSearchBot/TelegramSearchBot.csproj b/TelegramSearchBot/TelegramSearchBot.csproj index c357fcce..8d2371e7 100644 --- a/TelegramSearchBot/TelegramSearchBot.csproj +++ b/TelegramSearchBot/TelegramSearchBot.csproj @@ -103,6 +103,11 @@ Always + + + Always + + diff --git a/TelegramSearchBot/View/EditOCRConfView.cs b/TelegramSearchBot/View/EditOCRConfView.cs new file mode 100644 index 00000000..2f094609 --- /dev/null +++ b/TelegramSearchBot/View/EditOCRConfView.cs @@ -0,0 +1,52 @@ +using System.Threading.Tasks; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.ReplyMarkups; +using TelegramSearchBot.Interface; +using TelegramSearchBot.Manager; +using TelegramSearchBot.Service.BotAPI; + +namespace TelegramSearchBot.View { + public class EditOCRConfView : IView { + private readonly ITelegramBotClient _botClient; + private readonly SendMessage _sendMessage; + + private long _chatId; + private int _replyToMessageId; + private string _messageText = string.Empty; + private ReplyMarkup? _replyMarkup; + + public EditOCRConfView(ITelegramBotClient botClient, SendMessage sendMessage) { + _botClient = botClient; + _sendMessage = sendMessage; + } + + public EditOCRConfView WithChatId(long chatId) { + _chatId = chatId; + return this; + } + + public EditOCRConfView WithReplyTo(int messageId) { + _replyToMessageId = messageId; + return this; + } + + public EditOCRConfView WithMessage(string message) { + _messageText = message; + return this; + } + + public EditOCRConfView WithReplyMarkup(ReplyMarkup? replyMarkup) { + _replyMarkup = replyMarkup; + return this; + } + + public async Task Render() { + await _sendMessage.AddTask(async () => await _botClient.SendMessage( + chatId: _chatId, + text: _messageText, + replyParameters: new ReplyParameters { MessageId = _replyToMessageId }, + replyMarkup: _replyMarkup), _chatId < 0); + } + } +}