diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/GeneralLLMServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/GeneralLLMServiceTests.cs index 3bca80f8..039d04ae 100644 --- a/TelegramSearchBot.LLM.Test/Service/AI/LLM/GeneralLLMServiceTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/GeneralLLMServiceTests.cs @@ -74,10 +74,6 @@ public GeneralLLMServiceTests() { _redisMock.Object, _dbContext, _loggerMock.Object, - _ollamaServiceMock.Object, - _openAIServiceMock.Object, - _geminiServiceMock.Object, - anthropicServiceMock.Object, _factoryMock.Object); } diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/LLMFactoryTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/LLMFactoryTests.cs index 51e609a8..652b381f 100644 --- a/TelegramSearchBot.LLM.Test/Service/AI/LLM/LLMFactoryTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/LLMFactoryTests.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Moq; using StackExchange.Redis; @@ -55,15 +56,17 @@ public LLMFactoryTests() { var responsesService = new OpenAIResponsesService( _dbContext, responsesLogger.Object, messageExtensionServiceMock.Object, httpClientFactoryMock.Object); + var services = new ServiceCollection() + .AddSingleton(_openAIServiceMock.Object) + .AddSingleton(_ollamaServiceMock.Object) + .AddSingleton(_geminiServiceMock.Object) + .AddSingleton(anthropicServiceMock.Object) + .AddSingleton(responsesService) + .BuildServiceProvider(); + _factory = new LLMFactory( - _redisMock.Object, - _dbContext, - _loggerMock.Object, - _ollamaServiceMock.Object, - _openAIServiceMock.Object, - _geminiServiceMock.Object, - anthropicServiceMock.Object, - responsesService); + services, + _loggerMock.Object); } [Fact] diff --git a/TelegramSearchBot.LLM/Interface/AI/LLM/IBotIdentityProvider.cs b/TelegramSearchBot.LLM/Interface/AI/LLM/IBotIdentityProvider.cs new file mode 100644 index 00000000..69da26e9 --- /dev/null +++ b/TelegramSearchBot.LLM/Interface/AI/LLM/IBotIdentityProvider.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace TelegramSearchBot.Interface.AI.LLM { + public interface IBotIdentityProvider { + Task GetIdentityAsync(CancellationToken cancellationToken = default); + void SetIdentity(long botUserId, string botName); + } + + public sealed record BotIdentity(long UserId, string UserName); +} diff --git a/TelegramSearchBot.LLM/Interface/AI/LLM/IGroupLlmSettingsService.cs b/TelegramSearchBot.LLM/Interface/AI/LLM/IGroupLlmSettingsService.cs new file mode 100644 index 00000000..51d91810 --- /dev/null +++ b/TelegramSearchBot.LLM/Interface/AI/LLM/IGroupLlmSettingsService.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; + +#nullable enable + +namespace TelegramSearchBot.Interface.AI.LLM { + public interface IGroupLlmSettingsService { + Task GetModelAsync(long chatId, CancellationToken cancellationToken = default); + Task<(string Previous, string Current)> SetModelAsync(long chatId, string modelName, CancellationToken cancellationToken = default); + } +} diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs index 889b4a39..5fb20019 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs @@ -32,11 +32,18 @@ public class AnthropicService : IService, ILLMService { private readonly DataDbContext _dbContext; private readonly IMessageExtensionService _messageExtensionService; private readonly IHttpClientFactory _httpClientFactory; + private readonly IBotIdentityProvider _botIdentityProvider; + private string _fallbackBotName = string.Empty; - public static string _botName; public string BotName { - get => _botName; - set => _botName = value; + get => GetBotNameAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + set { + if (_botIdentityProvider != null) { + _botIdentityProvider.SetIdentity(Env.BotId, value); + } else { + _fallbackBotName = value ?? string.Empty; + } + } } private static readonly string[] _anthropicModels = { @@ -53,14 +60,33 @@ public AnthropicService( DataDbContext context, ILogger logger, IMessageExtensionService messageExtensionService, - IHttpClientFactory httpClientFactory) { + IHttpClientFactory httpClientFactory) + : this(context, logger, messageExtensionService, httpClientFactory, null) { + } + + public AnthropicService( + DataDbContext context, + ILogger logger, + IMessageExtensionService messageExtensionService, + IHttpClientFactory httpClientFactory, + IBotIdentityProvider botIdentityProvider) { _logger = logger; _dbContext = context; _messageExtensionService = messageExtensionService; _httpClientFactory = httpClientFactory; + _botIdentityProvider = botIdentityProvider; _logger.LogInformation("AnthropicService instance created. McpToolHelper should be initialized at application startup."); } + private async Task GetBotNameAsync() { + if (_botIdentityProvider == null) { + return _fallbackBotName; + } + + var identity = await _botIdentityProvider.GetIdentityAsync(); + return identity.UserName ?? string.Empty; + } + private AnthropicClient CreateClient(LLMChannel channel) { var options = new Anthropic.Core.ClientOptions { ApiKey = channel.ApiKey, @@ -496,7 +522,8 @@ private async IAsyncEnumerable ExecWithNativeToolCallingAsync( List nativeTools, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { - string systemPrompt = McpToolHelper.FormatSystemPromptForNativeToolCalling(BotName, ChatId); + var botName = await GetBotNameAsync(); + string systemPrompt = McpToolHelper.FormatSystemPromptForNativeToolCalling(botName, ChatId); bool supportsVision = await CheckVisionSupport(modelName, channel.Id); var (_, providerHistory) = await GetChatHistory(ChatId, systemPrompt, message, supportsVision); @@ -663,7 +690,8 @@ private async IAsyncEnumerable ExecWithXmlToolCallingAsync( LlmExecutionContext executionContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { - string systemPrompt = McpToolHelper.FormatSystemPrompt(BotName, ChatId); + var botName = await GetBotNameAsync(); + string systemPrompt = McpToolHelper.FormatSystemPrompt(botName, ChatId); bool supportsVision = await CheckVisionSupport(modelName, channel.Id); var (_, providerHistory) = await GetChatHistory(ChatId, systemPrompt, message, supportsVision); diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/BotIdentityProvider.cs b/TelegramSearchBot.LLM/Service/AI/LLM/BotIdentityProvider.cs new file mode 100644 index 00000000..603963ec --- /dev/null +++ b/TelegramSearchBot.LLM/Service/AI/LLM/BotIdentityProvider.cs @@ -0,0 +1,29 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using TelegramSearchBot.Attributes; +using TelegramSearchBot.Common; +using TelegramSearchBot.Interface; +using TelegramSearchBot.Interface.AI.LLM; + +namespace TelegramSearchBot.Service.AI.LLM { + [Injectable(ServiceLifetime.Singleton)] + public class BotIdentityProvider : IService, IBotIdentityProvider { + private readonly object _lock = new(); + private BotIdentity _identity = new(0, string.Empty); + + public string ServiceName => nameof(BotIdentityProvider); + + public Task GetIdentityAsync(CancellationToken cancellationToken = default) { + lock (_lock) { + return Task.FromResult(_identity); + } + } + + public void SetIdentity(long botUserId, string botName) { + lock (_lock) { + _identity = new BotIdentity(botUserId, botName ?? string.Empty); + Env.BotId = botUserId; + } + } + } +} diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/GeminiService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/GeminiService.cs index 5cf52ba7..a6fbcc5c 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/GeminiService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/GeminiService.cs @@ -29,18 +29,47 @@ public class GeminiService : ILLMService, IService { private readonly DataDbContext _dbContext; private readonly Dictionary _chatSessions = new(); private readonly IHttpClientFactory _httpClientFactory; - public string BotName { get; set; } + private readonly IBotIdentityProvider _botIdentityProvider; + private string _fallbackBotName = string.Empty; + public string BotName { + get => GetBotNameAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + set { + if (_botIdentityProvider != null) { + _botIdentityProvider.SetIdentity(Env.BotId, value); + } else { + _fallbackBotName = value ?? string.Empty; + } + } + } public GeminiService( DataDbContext context, ILogger logger, - IHttpClientFactory httpClientFactory) { + IHttpClientFactory httpClientFactory) + : this(context, logger, httpClientFactory, null) { + } + + public GeminiService( + DataDbContext context, + ILogger logger, + IHttpClientFactory httpClientFactory, + IBotIdentityProvider botIdentityProvider) { _logger = logger; _dbContext = context; _httpClientFactory = httpClientFactory; + _botIdentityProvider = botIdentityProvider; _logger.LogInformation("GeminiService instance created"); } + private async Task GetBotNameAsync() { + if (_botIdentityProvider == null) { + return _fallbackBotName; + } + + var identity = await _botIdentityProvider.GetIdentityAsync(); + return identity.UserName ?? string.Empty; + } + private void AddMessageToHistory(List chatHistory, long fromUserId, string content) { AddMessageToHistory(chatHistory, fromUserId, content, null); } @@ -369,7 +398,8 @@ public async IAsyncEnumerable ExecAsync( var currentMessageBuilder = new StringBuilder(); // Track history for snapshot var trackedHistory = new List(); - trackedHistory.Add(new SerializedChatMessage { Role = "system", Content = McpToolHelper.FormatSystemPrompt(null, ChatId) }); + var botName = await GetBotNameAsync(); + trackedHistory.Add(new SerializedChatMessage { Role = "system", Content = McpToolHelper.FormatSystemPrompt(botName, ChatId) }); for (int cycle = 0; cycle < maxToolCycles; cycle++) { if (cancellationToken.IsCancellationRequested) throw new TaskCanceledException(); diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs index 62549804..fceed287 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs @@ -18,10 +18,6 @@ namespace TelegramSearchBot.Service.AI.LLM { public class GeneralLLMService : IService, IGeneralLLMService { protected IConnectionMultiplexer connectionMultiplexer { get; set; } private readonly DataDbContext _dbContext; - private readonly OpenAIService _openAIService; - private readonly OllamaService _ollamaService; - private readonly GeminiService _geminiService; - private readonly AnthropicService _anthropicService; private readonly ILogger _logger; private readonly ILLMFactory _LLMFactory; @@ -40,21 +36,11 @@ public GeneralLLMService( IConnectionMultiplexer connectionMultiplexer, DataDbContext dbContext, ILogger logger, - OllamaService ollamaService, - OpenAIService openAIService, - GeminiService geminiService, - AnthropicService anthropicService, ILLMFactory _LLMFactory ) { this.connectionMultiplexer = connectionMultiplexer; _dbContext = dbContext; _logger = logger; - - // Initialize services with default values - _openAIService = openAIService; - _ollamaService = ollamaService; - _geminiService = geminiService; - _anthropicService = anthropicService; this._LLMFactory = _LLMFactory; } public async Task> GetChannelsAsync(string modelName) { diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/GroupLlmSettingsService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/GroupLlmSettingsService.cs new file mode 100644 index 00000000..be63a54a --- /dev/null +++ b/TelegramSearchBot.LLM/Service/AI/LLM/GroupLlmSettingsService.cs @@ -0,0 +1,71 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TelegramSearchBot.Attributes; +using TelegramSearchBot.Interface; +using TelegramSearchBot.Interface.AI.LLM; +using TelegramSearchBot.Model; +using TelegramSearchBot.Model.Data; + +#nullable enable + +namespace TelegramSearchBot.Service.AI.LLM { + [Injectable(ServiceLifetime.Scoped)] + public class GroupLlmSettingsService : IService, IGroupLlmSettingsService { + private readonly DataDbContext _dbContext; + + public GroupLlmSettingsService(DataDbContext dbContext) { + _dbContext = dbContext; + } + + public string ServiceName => nameof(GroupLlmSettingsService); + + public async Task GetModelAsync(long chatId, CancellationToken cancellationToken = default) { + var settings = await _dbContext.GroupSettings.AsNoTracking() + .FirstOrDefaultAsync(s => s.GroupId == chatId, cancellationToken); + return settings == null ? null : settings.LLMModelName; + } + + public async Task<(string Previous, string Current)> SetModelAsync(long chatId, string modelName, CancellationToken cancellationToken = default) { + var normalizedModelName = modelName?.Trim(); + if (string.IsNullOrWhiteSpace(normalizedModelName)) { + throw new ArgumentException("Model name cannot be empty.", nameof(modelName)); + } + + modelName = normalizedModelName; + var settings = await _dbContext.GroupSettings + .FirstOrDefaultAsync(s => s.GroupId == chatId, cancellationToken); + var previous = settings == null ? null : settings.LLMModelName; + GroupSettings? newSettings = null; + + if (settings is null) { + newSettings = new GroupSettings { + GroupId = chatId, + LLMModelName = modelName + }; + await _dbContext.GroupSettings.AddAsync(newSettings, cancellationToken); + } else { + settings.LLMModelName = modelName; + } + + try { + await _dbContext.SaveChangesAsync(cancellationToken); + } catch (DbUpdateException) when (newSettings != null) { + _dbContext.Entry(newSettings).State = EntityState.Detached; + settings = await _dbContext.GroupSettings + .FirstOrDefaultAsync(s => s.GroupId == chatId, cancellationToken); + if (settings is null) { + throw; + } + + previous = settings.LLMModelName; + settings.LLMModelName = modelName; + await _dbContext.SaveChangesAsync(cancellationToken); + } + + return (previous ?? "Default", modelName); + } + } +} diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/LLMFactory.cs b/TelegramSearchBot.LLM/Service/AI/LLM/LLMFactory.cs index fd99ed0a..bebfdcf0 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/LLMFactory.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/LLMFactory.cs @@ -1,64 +1,40 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using StackExchange.Redis; using TelegramSearchBot.Attributes; using TelegramSearchBot.Interface; using TelegramSearchBot.Interface.AI.LLM; -using TelegramSearchBot.Model; using TelegramSearchBot.Model.AI; -using static GenerativeAI.VertexAIModels; namespace TelegramSearchBot.Service.AI.LLM { - [Injectable(Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton)] + [Injectable(ServiceLifetime.Transient)] public class LLMFactory : IService, ILLMFactory { public string ServiceName => "LLMFactory"; - protected IConnectionMultiplexer connectionMultiplexer { get; set; } - private readonly DataDbContext _dbContext; - private readonly OpenAIService _openAIService; - private readonly OllamaService _ollamaService; - private readonly GeminiService _geminiService; - private readonly AnthropicService _anthropicService; - private readonly OpenAIResponsesService _openAIResponsesService; + private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; - private readonly Dictionary _services; + public LLMFactory( - IConnectionMultiplexer connectionMultiplexer, - DataDbContext dbContext, - ILogger logger, - OllamaService ollamaService, - OpenAIService openAIService, - GeminiService geminiService, - AnthropicService anthropicService, - OpenAIResponsesService openAIResponsesService + IServiceProvider serviceProvider, + ILogger logger ) { - this.connectionMultiplexer = connectionMultiplexer; - _dbContext = dbContext; _logger = logger; + _serviceProvider = serviceProvider; + } - // Initialize services with default values - _openAIService = openAIService; - _ollamaService = ollamaService; - _geminiService = geminiService; - _anthropicService = anthropicService; - _openAIResponsesService = openAIResponsesService; - _services = new() { - [LLMProvider.OpenAI] = _openAIService, - [LLMProvider.Ollama] = _ollamaService, - [LLMProvider.Gemini] = _geminiService, - [LLMProvider.MiniMax] = _openAIService, - [LLMProvider.LMStudio] = _openAIService, - [LLMProvider.Anthropic] = _anthropicService, - [LLMProvider.ResponsesAPI] = _openAIResponsesService + public ILLMService GetLLMService(LLMProvider provider) { + return provider switch { + LLMProvider.OpenAI => _serviceProvider.GetRequiredService(), + LLMProvider.Ollama => _serviceProvider.GetRequiredService(), + LLMProvider.Gemini => _serviceProvider.GetRequiredService(), + LLMProvider.MiniMax => _serviceProvider.GetRequiredService(), + LLMProvider.LMStudio => _serviceProvider.GetRequiredService(), + LLMProvider.Anthropic => _serviceProvider.GetRequiredService(), + LLMProvider.ResponsesAPI => _serviceProvider.GetRequiredService(), + _ => throw new KeyNotFoundException($"No LLM service registered for provider {provider}.") }; } - public ILLMService GetLLMService(LLMProvider provider) => _services[provider]; - } } diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs index 6a7eb5a4..6cd1560b 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs @@ -34,21 +34,51 @@ public class OllamaService : IService, ILLMService { private readonly DataDbContext _dbContext; private readonly IServiceProvider _serviceProvider; private readonly IHttpClientFactory _httpClientFactory; - public string BotName { get; set; } + private readonly IBotIdentityProvider _botIdentityProvider; + private string _fallbackBotName = string.Empty; + public string BotName { + get => GetBotNameAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + set { + if (_botIdentityProvider != null) { + _botIdentityProvider.SetIdentity(Env.BotId, value); + } else { + _fallbackBotName = value ?? string.Empty; + } + } + } + + public OllamaService( + DataDbContext context, + ILogger logger, + IServiceProvider serviceProvider, + IHttpClientFactory httpClientFactory) + : this(context, logger, serviceProvider, httpClientFactory, null) { + } // Constructor requires dependencies needed directly by this class public OllamaService( DataDbContext context, ILogger logger, IServiceProvider serviceProvider, - IHttpClientFactory httpClientFactory) { + IHttpClientFactory httpClientFactory, + IBotIdentityProvider botIdentityProvider) { _logger = logger; _dbContext = context; _serviceProvider = serviceProvider; _httpClientFactory = httpClientFactory; + _botIdentityProvider = botIdentityProvider; _logger.LogInformation("OllamaService instance created. McpToolHelper should be initialized at application startup."); } + private async Task GetBotNameAsync() { + if (_botIdentityProvider == null) { + return _fallbackBotName; + } + + var identity = await _botIdentityProvider.GetIdentityAsync(); + return identity.UserName ?? string.Empty; + } + // --- Helper methods specific to this service --- public async Task CheckAndPullModelAsync(OllamaApiClient ollama, string modelName) { @@ -122,7 +152,8 @@ public async IAsyncEnumerable ExecAsync(Model.Data.Message message, long // --- History and Prompt Setup --- // NOTE: History context is limited as OllamaSharp.Chat manages it. - var systemPrompt = McpToolHelper.FormatSystemPrompt(BotName, ChatId); + var botName = await GetBotNameAsync(); + var systemPrompt = McpToolHelper.FormatSystemPrompt(botName, ChatId); var chat = new OllamaSharp.Chat(ollama, systemPrompt); diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs index 587a010f..cc80ef00 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs @@ -49,10 +49,17 @@ private class ResponsesToolCallAccumulator { } private readonly ILogger _logger; - private static string _botName; + private readonly IBotIdentityProvider _botIdentityProvider; + private string _fallbackBotName = string.Empty; public string BotName { - get => _botName; - set => _botName = value; + get => GetBotNameAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + set { + if (_botIdentityProvider != null) { + _botIdentityProvider.SetIdentity(Env.BotId, value); + } else { + _fallbackBotName = value ?? string.Empty; + } + } } private readonly DataDbContext _dbContext; private readonly IHttpClientFactory _httpClientFactory; @@ -62,14 +69,33 @@ public OpenAIResponsesService( DataDbContext context, ILogger logger, IMessageExtensionService messageExtensionService, - IHttpClientFactory httpClientFactory) { + IHttpClientFactory httpClientFactory) + : this(context, logger, messageExtensionService, httpClientFactory, null) { + } + + public OpenAIResponsesService( + DataDbContext context, + ILogger logger, + IMessageExtensionService messageExtensionService, + IHttpClientFactory httpClientFactory, + IBotIdentityProvider botIdentityProvider) { _logger = logger; _dbContext = context; _messageExtensionService = messageExtensionService; _httpClientFactory = httpClientFactory; + _botIdentityProvider = botIdentityProvider; _logger.LogInformation("OpenAIResponsesService instance created."); } + private async Task GetBotNameAsync() { + if (_botIdentityProvider == null) { + return _fallbackBotName; + } + + var identity = await _botIdentityProvider.GetIdentityAsync(); + return identity.UserName ?? string.Empty; + } + // ======================================================================== // ILLMService Implementation // ======================================================================== @@ -114,7 +140,8 @@ private async IAsyncEnumerable ExecWithResponsesApiAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { // --- Build system instructions --- - string instructions = McpToolHelper.FormatSystemPromptForNativeToolCalling(BotName, ChatId); + var botName = await GetBotNameAsync(); + string instructions = McpToolHelper.FormatSystemPromptForNativeToolCalling(botName, ChatId); // --- Get native tool definitions and convert to ResponseTool format --- var nativeToolDefs = McpToolHelper.GetNativeToolDefinitions(); @@ -320,7 +347,8 @@ public async IAsyncEnumerable ResumeFromSnapshotAsync( var inputItems = DeserializeResponseItemsFromSnapshot(snapshot.ProviderHistory); // Restore instructions - string instructions = McpToolHelper.FormatSystemPromptForNativeToolCalling(BotName, snapshot.ChatId); + var botName = await GetBotNameAsync(); + string instructions = McpToolHelper.FormatSystemPromptForNativeToolCalling(botName, snapshot.ChatId); // Get tools var nativeToolDefs = McpToolHelper.GetNativeToolDefinitions(); diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs index c2cbcb1d..3c614a0c 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs @@ -74,31 +74,57 @@ internal static Dictionary DeserializeToolArgumentsForDisplay(st } private readonly ILogger _logger; - public static string _botName; - public string BotName { - get { - return _botName; - } - set { - _botName = value; - } - } private readonly DataDbContext _dbContext; private readonly IHttpClientFactory _httpClientFactory; private readonly IMessageExtensionService _messageExtensionService; + private readonly IBotIdentityProvider _botIdentityProvider; + private readonly IGroupLlmSettingsService _groupLlmSettingsService; + private string _fallbackBotName = string.Empty; public OpenAIService( DataDbContext context, ILogger logger, IMessageExtensionService messageExtensionService, - IHttpClientFactory httpClientFactory) { + IHttpClientFactory httpClientFactory) + : this(context, logger, messageExtensionService, httpClientFactory, null, null) { + } + + public OpenAIService( + DataDbContext context, + ILogger logger, + IMessageExtensionService messageExtensionService, + IHttpClientFactory httpClientFactory, + IBotIdentityProvider botIdentityProvider, + IGroupLlmSettingsService groupLlmSettingsService) { _logger = logger; _dbContext = context; _messageExtensionService = messageExtensionService; _httpClientFactory = httpClientFactory; + _botIdentityProvider = botIdentityProvider; + _groupLlmSettingsService = groupLlmSettingsService; _logger.LogInformation("OpenAIService instance created. McpToolHelper should be initialized at application startup."); } + public string BotName { + get => GetBotNameAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + set { + if (_botIdentityProvider != null) { + _botIdentityProvider.SetIdentity(Env.BotId, value); + } else { + _fallbackBotName = value ?? string.Empty; + } + } + } + + private async Task GetBotNameAsync() { + if (_botIdentityProvider == null) { + return _fallbackBotName; + } + + var identity = await _botIdentityProvider.GetIdentityAsync(); + return identity.UserName ?? string.Empty; + } + public virtual async Task> GetAllModels(LLMChannel channel) { if (channel.Provider.Equals(LLMProvider.Ollama)) { return new List(); @@ -971,7 +997,8 @@ private async IAsyncEnumerable ExecWithNativeToolCallingAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { // --- History and Prompt Setup (simplified - no XML tool instructions) --- - string systemPrompt = McpToolHelper.FormatSystemPromptForNativeToolCalling(BotName, ChatId); + var botName = await GetBotNameAsync(); + string systemPrompt = McpToolHelper.FormatSystemPromptForNativeToolCalling(botName, ChatId); List providerHistory = new List() { new SystemChatMessage(systemPrompt) }; bool supportsVision = await CheckVisionSupport(modelName, channel.Id); providerHistory = await GetChatHistory(ChatId, providerHistory, message, supportsVision); @@ -1153,7 +1180,8 @@ private async IAsyncEnumerable ExecWithXmlToolCallingAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { // --- History and Prompt Setup --- - string systemPrompt = McpToolHelper.FormatSystemPrompt(BotName, ChatId); + var botName = await GetBotNameAsync(); + string systemPrompt = McpToolHelper.FormatSystemPrompt(botName, ChatId); List providerHistory = new List() { new SystemChatMessage(systemPrompt) }; bool supportsVision = await CheckVisionSupport(modelName, channel.Id); providerHistory = await GetChatHistory(ChatId, providerHistory, message, supportsVision); @@ -1584,6 +1612,11 @@ public async Task GenerateEmbeddingsAsync(string text, string modelName } public async Task<(string, string)> SetModel(string ModelName, long ChatId) { + if (_groupLlmSettingsService != null) { + var (previous, current) = await _groupLlmSettingsService.SetModelAsync(ChatId, ModelName); + return (previous, current); + } + var GroupSetting = await _dbContext.GroupSettings .Where(s => s.GroupId == ChatId) .FirstOrDefaultAsync(); @@ -1597,6 +1630,10 @@ public async Task GenerateEmbeddingsAsync(string text, string modelName return (CurrentModelName ?? "Default", ModelName); } public async Task GetModel(long ChatId) { + if (_groupLlmSettingsService != null) { + return await _groupLlmSettingsService.GetModelAsync(ChatId); + } + var GroupSetting = await _dbContext.GroupSettings.AsNoTracking() .Where(s => s.GroupId == ChatId) .FirstOrDefaultAsync(); diff --git a/TelegramSearchBot.LLMAgent/LLMAgentProgram.cs b/TelegramSearchBot.LLMAgent/LLMAgentProgram.cs index 19e00f53..dc013d4e 100644 --- a/TelegramSearchBot.LLMAgent/LLMAgentProgram.cs +++ b/TelegramSearchBot.LLMAgent/LLMAgentProgram.cs @@ -102,6 +102,8 @@ private static ServiceProvider BuildServices(int port) { }, contextLifetime: ServiceLifetime.Scoped, optionsLifetime: ServiceLifetime.Singleton); services.AddSingleton(_ => ConnectionMultiplexer.Connect($"localhost:{port},abortConnect=false,connectTimeout=5000,connectRetry=5")); services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/TelegramSearchBot.LLMAgent/Service/LlmServiceProxy.cs b/TelegramSearchBot.LLMAgent/Service/LlmServiceProxy.cs index b52f0746..c94b1098 100644 --- a/TelegramSearchBot.LLMAgent/Service/LlmServiceProxy.cs +++ b/TelegramSearchBot.LLMAgent/Service/LlmServiceProxy.cs @@ -25,7 +25,7 @@ public async IAsyncEnumerable CallAsync( await SeedTaskDataAsync(task, cancellationToken); var service = ResolveService(task.Channel.Provider); - ApplyBotIdentity(service, task.BotName, task.BotUserId); + ApplyBotIdentity(task.BotName, task.BotUserId); var channel = ToEntity(task.Channel); if (task.Kind == AgentTaskKind.Continuation && task.ContinuationSnapshot != null) { @@ -63,24 +63,16 @@ private ILLMService ResolveService(LLMProvider provider) { }; } - private static void ApplyBotIdentity(ILLMService service, string botName, long botUserId) { - Env.BotId = botUserId; - switch (service) { - case OpenAIService openAi: - openAi.BotName = botName; - break; - case OllamaService ollama: - ollama.BotName = botName; - break; - case GeminiService gemini: - gemini.BotName = botName; - break; - case AnthropicService anthropic: - anthropic.BotName = botName; - break; - case OpenAIResponsesService responses: - responses.BotName = botName; - break; + private void ApplyBotIdentity(string botName, long botUserId) { + var identityProvider = _serviceProvider.GetService(); + if (identityProvider != null) { + identityProvider.SetIdentity(botUserId, botName); + } else { + _logger.LogDebug( + "IBotIdentityProvider is not registered; falling back to Env.BotId for bot {BotName} ({BotUserId}).", + botName, + botUserId); + Env.BotId = botUserId; } } diff --git a/TelegramSearchBot/Controller/AI/LLM/GeneralLLMController.cs b/TelegramSearchBot/Controller/AI/LLM/GeneralLLMController.cs index b3f58e96..e1a01ac6 100644 --- a/TelegramSearchBot/Controller/AI/LLM/GeneralLLMController.cs +++ b/TelegramSearchBot/Controller/AI/LLM/GeneralLLMController.cs @@ -23,8 +23,9 @@ namespace TelegramSearchBot.Controller.AI.LLM { public class GeneralLLMController : IOnUpdate { private readonly ILogger logger; - private readonly OpenAIService service; private readonly SendMessage Send; + private readonly IBotIdentityProvider _botIdentityProvider; + private readonly IGroupLlmSettingsService _groupLlmSettingsService; public List Dependencies => new List(); public ITelegramBotClient botClient { get; set; } public MessageService messageService { get; set; } @@ -36,18 +37,18 @@ public class GeneralLLMController : IOnUpdate { public GeneralLLMController( MessageService messageService, ITelegramBotClient botClient, - OpenAIService openaiService, SendMessage Send, ILogger logger, AdminService adminService, ISendMessageService SendMessageService, IGeneralLLMService generalLLMService, ILlmContinuationService continuationService, - LLMTaskQueueService llmTaskQueueService + LLMTaskQueueService llmTaskQueueService, + IBotIdentityProvider botIdentityProvider, + IGroupLlmSettingsService groupLlmSettingsService ) { this.logger = logger; this.botClient = botClient; - service = openaiService; this.Send = Send; this.messageService = messageService; this.adminService = adminService; @@ -55,89 +56,109 @@ LLMTaskQueueService llmTaskQueueService GeneralLLMService = generalLLMService; ContinuationService = continuationService; LlmTaskQueueService = llmTaskQueueService; + _botIdentityProvider = botIdentityProvider; + _groupLlmSettingsService = groupLlmSettingsService; } public async Task ExecuteAsync(PipelineContext p) { var e = p.Update; + var telegramMessage = e.Message; + if (telegramMessage == null) { + return; + } + if (!Env.EnableOpenAI) { return; } - if (string.IsNullOrEmpty(service.BotName)) { + var botIdentity = await _botIdentityProvider.GetIdentityAsync(); + if (string.IsNullOrEmpty(botIdentity.UserName)) { var me = await botClient.GetMe(); - Env.BotId = me.Id; - service.BotName = me.Username; // service.BotName is the username, e.g., "MyBot" + _botIdentityProvider.SetIdentity(me.Id, me.Username); + botIdentity = new BotIdentity(me.Id, me.Username ?? string.Empty); } - var Message = string.IsNullOrEmpty(e?.Message?.Text) ? e?.Message?.Caption : e.Message.Text; + var Message = string.IsNullOrEmpty(telegramMessage.Text) ? telegramMessage.Caption : telegramMessage.Text; if (string.IsNullOrEmpty(Message)) { return; } + var fromUserId = telegramMessage.From?.Id ?? 0; // Check if the message is a bot command specifically targeting this bot - // service.BotName should be initialized by the block above. - if (e.Message.Entities != null && !string.IsNullOrEmpty(service.BotName) && - e.Message.Entities.Any(entity => entity.Type == MessageEntityType.BotCommand)) { - var botCommandEntity = e.Message.Entities.First(entity => entity.Type == MessageEntityType.BotCommand); + // Bot identity should be initialized by the block above. + if (telegramMessage.Entities != null && !string.IsNullOrEmpty(botIdentity.UserName) && + telegramMessage.Entities.Any(entity => entity.Type == MessageEntityType.BotCommand)) { + var botCommandEntity = telegramMessage.Entities.First(entity => entity.Type == MessageEntityType.BotCommand); // Ensure the command is at the beginning of the message if (botCommandEntity.Offset == 0) { string commandText = Message.Substring(botCommandEntity.Offset, botCommandEntity.Length); // Check if the command text itself contains @BotName (e.g., /cmd@MyBot) - if (commandText.Contains($"@{service.BotName}")) { - logger.LogInformation($"Ignoring command '{commandText}' in GeneralLLMController as it's a direct command to the bot and should be handled by a dedicated command handler. MessageId: {e.Message.MessageId}"); + if (commandText.Contains($"@{botIdentity.UserName}")) { + logger.LogInformation($"Ignoring command '{commandText}' in GeneralLLMController as it's a direct command to the bot and should be handled by a dedicated command handler. MessageId: {telegramMessage.MessageId}"); return; // Let other command handlers process it } } } - if (Message.StartsWith("设置模型 ") && await adminService.IsNormalAdmin(e.Message.From.Id)) { - var (previous, current) = await service.SetModel(Message.Substring(5), e.Message.Chat.Id); - logger.LogInformation($"群{e.Message.Chat.Id}模型设置成功,原模型:{previous},现模型:{current}。消息来源:{e.Message.MessageId}"); - await SendMessageService.SendMessage($"模型设置成功,原模型:{previous},现模型:{current}", e.Message.Chat.Id, e.Message.MessageId); + if (Message.StartsWith("设置模型 ") && fromUserId != 0 && await adminService.IsNormalAdmin(fromUserId)) { + var requestedModelName = Message.Substring(5).Trim(); + if (string.IsNullOrWhiteSpace(requestedModelName)) { + await SendMessageService.SendMessage("模型名称不能为空", telegramMessage.Chat.Id, telegramMessage.MessageId); + return; + } + + var (previous, current) = await _groupLlmSettingsService.SetModelAsync(telegramMessage.Chat.Id, requestedModelName); + logger.LogInformation($"群{telegramMessage.Chat.Id}模型设置成功,原模型:{previous},现模型:{current}。消息来源:{telegramMessage.MessageId}"); + await SendMessageService.SendMessage($"模型设置成功,原模型:{previous},现模型:{current}", telegramMessage.Chat.Id, telegramMessage.MessageId); return; } // Trigger LLM if: - // 1. Message contains an explicit mention @BotName (and service.BotName is set) + // 1. Message contains an explicit mention @BotName (and bot identity is set) // 2. Message is a reply to the bot (Env.BotId must be set) - bool isMentionToBot = !string.IsNullOrEmpty(service.BotName) && Message.Contains($"@{service.BotName}"); - bool isReplyToBot = e.Message.ReplyToMessage != null && e.Message.ReplyToMessage.From != null && e.Message.ReplyToMessage.From.Id == Env.BotId; + bool isMentionToBot = !string.IsNullOrEmpty(botIdentity.UserName) && Message.Contains($"@{botIdentity.UserName}"); + bool isReplyToBot = telegramMessage.ReplyToMessage != null && telegramMessage.ReplyToMessage.From != null && telegramMessage.ReplyToMessage.From.Id == botIdentity.UserId; if (isMentionToBot || isReplyToBot) { - var modelName = await service.GetModel(e.Message.Chat.Id); + var modelName = await _groupLlmSettingsService.GetModelAsync(telegramMessage.Chat.Id); + if (string.IsNullOrWhiteSpace(modelName)) { + logger.LogWarning("请指定模型名称"); + return; + } + var initialContentPlaceholder = $"{modelName}初始化中。。。"; // Prepare the input message for GeneralLLMService var inputLlMessage = new Model.Data.Message() { Content = Message, - DateTime = e.Message.Date, - FromUserId = e.Message.From.Id, - GroupId = e.Message.Chat.Id, - MessageId = e.Message.MessageId, - ReplyToMessageId = e.Message.ReplyToMessage?.MessageId ?? 0, + DateTime = telegramMessage.Date, + FromUserId = fromUserId, + GroupId = telegramMessage.Chat.Id, + MessageId = telegramMessage.MessageId, + ReplyToMessageId = telegramMessage.ReplyToMessage?.MessageId ?? 0, Id = -1, }; // Use execution context to detect iteration limit (no stream pollution) var executionContext = new LlmExecutionContext(); IAsyncEnumerable fullMessageStream; - AgentTaskStreamHandle? agentTaskHandle = null; + AgentTaskStreamHandle agentTaskHandle = null; if (Env.EnableLLMAgentProcess) { agentTaskHandle = await LlmTaskQueueService.EnqueueMessageTaskAsync( - e.Message, - service.BotName, - Env.BotId, + telegramMessage, + botIdentity.UserName, + botIdentity.UserId, CancellationToken.None); fullMessageStream = agentTaskHandle.ReadSnapshotsAsync(CancellationToken.None); } else { fullMessageStream = GeneralLLMService.ExecAsync( - inputLlMessage, e.Message.Chat.Id, executionContext, CancellationToken.None); + inputLlMessage, telegramMessage.Chat.Id, executionContext, CancellationToken.None); } // Use sendMessageDraft API for LLM streaming (better performance, no send+edit) List sentMessagesForDb = await SendMessageService.SendDraftStream( fullMessageStream, - e.Message.Chat.Id, - e.Message.MessageId, + telegramMessage.Chat.Id, + telegramMessage.MessageId, initialContentPlaceholder, CancellationToken.None ); @@ -149,7 +170,7 @@ public async Task ExecuteAsync(PipelineContext p) { botUser = await botClient.GetMe(); } await messageService.ExecuteAsync(new MessageOption() { - Chat = e.Message.Chat, + Chat = telegramMessage.Chat, ChatId = dbMessage.GroupId, Content = dbMessage.Content, DateTime = dbMessage.DateTime, @@ -163,7 +184,7 @@ await messageService.ExecuteAsync(new MessageOption() { if (agentTaskHandle != null) { var terminalChunk = await agentTaskHandle.Completion; if (terminalChunk.Type == AgentChunkType.Error) { - await SendMessageService.SendMessage($"AI Agent 执行失败:{terminalChunk.ErrorMessage}", e.Message.Chat.Id, e.Message.MessageId); + await SendMessageService.SendMessage($"AI Agent 执行失败:{terminalChunk.ErrorMessage}", telegramMessage.Chat.Id, telegramMessage.MessageId); } else if (terminalChunk.Type == AgentChunkType.IterationLimitReached && terminalChunk.ContinuationSnapshot != null) { var snapshotId = await ContinuationService.SaveSnapshotAsync(terminalChunk.ContinuationSnapshot); @@ -175,10 +196,10 @@ await messageService.ExecuteAsync(new MessageOption() { }); await botClient.SendMessage( - e.Message.Chat.Id, + telegramMessage.Chat.Id, $"⚠️ AI 已达到最大迭代次数限制({Env.MaxToolCycles} 次),是否继续迭代?", replyMarkup: keyboard, - replyParameters: new ReplyParameters { MessageId = e.Message.MessageId } + replyParameters: new ReplyParameters { MessageId = telegramMessage.MessageId } ); } @@ -188,7 +209,7 @@ await botClient.SendMessage( // Check if the iteration limit was reached via execution context if (executionContext.IterationLimitReached && executionContext.SnapshotData != null) { logger.LogInformation("Iteration limit reached for ChatId {ChatId}, MessageId {MessageId}. Saving snapshot and prompting user.", - e.Message.Chat.Id, e.Message.MessageId); + telegramMessage.Chat.Id, telegramMessage.MessageId); // Save the snapshot to Redis var snapshotId = await ContinuationService.SaveSnapshotAsync(executionContext.SnapshotData); @@ -201,10 +222,10 @@ await botClient.SendMessage( }); await botClient.SendMessage( - e.Message.Chat.Id, + telegramMessage.Chat.Id, $"⚠️ AI 已达到最大迭代次数限制({Env.MaxToolCycles} 次),是否继续迭代?", replyMarkup: keyboard, - replyParameters: new ReplyParameters { MessageId = e.Message.MessageId } + replyParameters: new ReplyParameters { MessageId = telegramMessage.MessageId } ); }