diff --git a/Docs/Bot_Commands_User_Guide.md b/Docs/Bot_Commands_User_Guide.md index 34f60ef7..05b16083 100644 --- a/Docs/Bot_Commands_User_Guide.md +++ b/Docs/Bot_Commands_User_Guide.md @@ -63,6 +63,16 @@ * **权限**: 普通管理员或全局管理员。 * **相关指令**: `选择音乐模型` 显示可用音乐模型并通过编号选择,`音乐模型` / `查看音乐模型` 查看当前音乐模型,`清除音乐模型` 恢复使用全局默认。 +* **Agent 聊天模式** + * **指令格式**: `开启Agent聊天` / `开启Agent引导聊天` + * **功能**: 在当前群开启引导模式。普通文本、Caption、OCR/QR/ASR 提取结果会在短时间窗口内合并成一次 Agent 输入并自动回复。 + * **指令格式**: `开启Agent队列聊天` + * **功能**: 在当前群开启队列模式。每条触发消息都会按顺序进入 Agent 队列并分别回复。 + * **指令格式**: `关闭Agent聊天` / `Agent聊天状态` / `查看Agent聊天` + * **功能**: 关闭或查看当前群 Agent 聊天模式。 + * **权限**: 普通管理员或全局管理员。 + * **要求**: 需要已设置群 LLM 模型,并启用 `EnableLLMAgentProcess=true`。 + ### 2. 全局管理员指令 以下指令仅限机器人全局管理员(在 `Env.cs` 中配置的 `AdminId`)使用。 @@ -355,6 +365,7 @@ * **触发方式1 (提及机器人)**: 在消息中 `@机器人用户名 <你的消息>`。 * **示例**: `@MyAwesomeBot 帮我总结一下这篇文章的主要内容。` * **触发方式2 (回复机器人消息)**: 直接回复机器人的某条消息。 + * **触发方式3 (Agent 聊天模式)**: 管理员开启 `开启Agent聊天` / `开启Agent引导聊天` 或 `开启Agent队列聊天` 后,群内普通文本、Caption 和媒体提取结果会自动触发 LLM。 * **功能**: 将您的消息发送给配置的LLM进行处理,并回复LLM的响应。 * **工具调用迭代限制**: 为防止LLM无限调用工具,机器人默认限制为25次工具调用(可通过 `MaxToolCycles` 配置)。当达到限制时: * 机器人会保存当前对话快照到Redis diff --git a/README.md b/README.md index 7b10f829..01b7d9a5 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,9 @@ ### AI交互 - @机器人 + 问题: 使用配置的LLM回复 +- 管理员发送 `开启Agent聊天` / `开启Agent引导聊天`: 当前群进入引导模式,普通文本、Caption 以及 OCR/QR/ASR 提取内容会短窗口合并后自动触发 Agent 回复 +- 管理员发送 `开启Agent队列聊天`: 当前群进入逐条队列模式,每条触发消息按顺序进入 Agent 队列 +- 管理员发送 `关闭Agent聊天` / `Agent聊天状态` / `查看Agent聊天`: 关闭或查看当前群 Agent 聊天模式 完整命令列表: [Docs/Bot_Commands_User_Guide.md](Docs/Bot_Commands_User_Guide.md) diff --git a/TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs b/TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs index da67a6e4..71645794 100644 --- a/TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs +++ b/TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs @@ -224,6 +224,7 @@ public static class LlmAgentRedisKeys { public const string ActiveTaskSet = "AGENT_ACTIVE_TASKS"; public const string SubAgentTaskQueue = "SUBAGENT_TASKS"; public const string AgentToolDefs = "AGENT_TOOL_DEFS"; + public const string AgentChatBatchDueSet = "AGENT_CHAT_BATCH_DUE"; public static string AgentTaskState(string taskId) => $"AGENT_TASK:{taskId}"; public static string AgentSnapshot(string taskId) => $"AGENT_SNAPSHOT:{taskId}"; @@ -239,5 +240,13 @@ public static class LlmAgentRedisKeys { public static string SandboxToolQueue(long chatId) => $"SANDBOX_TOOL_TASKS:{chatId}"; public static string SandboxToolHeartbeat(long chatId) => $"SANDBOX_TOOL_HEARTBEAT:{chatId}"; public static string SandboxToolResult(string requestId) => $"SANDBOX_TOOL_RESULT:{requestId}"; + public static string ModelSelectState(long chatId) => $"modelselect:{chatId}:state"; + public static string ModelSelectModels(long chatId) => $"modelselect:{chatId}:models"; + public static string ImageGenerationModelSelection(long chatId, long userId) => $"image_generation:model_select:{chatId}:{userId}"; + public static string MusicGenerationModelSelection(long chatId, long userId) => $"music_generation:model_select:{chatId}:{userId}"; + public static string AgentChatBatchList(long chatId) => $"AGENT_CHAT_BATCH:{chatId}:MESSAGES"; + public static string AgentChatBatchMeta(long chatId) => $"AGENT_CHAT_BATCH:{chatId}:META"; + public static string AgentChatBatchLock(long chatId) => $"AGENT_CHAT_BATCH:{chatId}:LOCK"; + public static string AgentChatConfigWarning(long chatId, string warningType) => $"AGENT_CHAT_WARNING:{chatId}:{warningType}"; } } diff --git a/TelegramSearchBot.Database/Migrations/20260531153140_AddGroupAgentChatMode.Designer.cs b/TelegramSearchBot.Database/Migrations/20260531153140_AddGroupAgentChatMode.Designer.cs new file mode 100644 index 00000000..956a0837 --- /dev/null +++ b/TelegramSearchBot.Database/Migrations/20260531153140_AddGroupAgentChatMode.Designer.cs @@ -0,0 +1,893 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TelegramSearchBot.Model; + +#nullable disable + +namespace TelegramSearchBot.Migrations +{ + [DbContext(typeof(DataDbContext))] + [Migration("20260531153140_AddGroupAgentChatMode")] + partial class AddGroupAgentChatMode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.AccountBook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId", "Name") + .IsUnique(); + + b.ToTable("AccountBooks"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.AccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountBookId") + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUsername") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Tag") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Tag"); + + b.HasIndex("AccountBookId", "CreatedAt"); + + b.ToTable("AccountRecords"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.AppConfigurationItem", b => + { + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("AppConfigurationItems"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ChannelWithModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("LLMChannelId") + .HasColumnType("INTEGER"); + + b.Property("ModelName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LLMChannelId"); + + b.ToTable("ChannelsWithModel"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ConversationSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentSummary") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("FirstMessageId") + .HasColumnType("INTEGER"); + + b.Property("FullContent") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsVectorized") + .HasColumnType("INTEGER"); + + b.Property("LastMessageId") + .HasColumnType("INTEGER"); + + b.Property("MessageCount") + .HasColumnType("INTEGER"); + + b.Property("ParticipantCount") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("TopicKeywords") + .HasColumnType("TEXT"); + + b.Property("VectorId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId", "StartTime", "EndTime"); + + b.ToTable("ConversationSegments"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ConversationSegmentMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConversationSegmentId") + .HasColumnType("INTEGER"); + + b.Property("MessageDataId") + .HasColumnType("INTEGER"); + + b.Property("SequenceOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ConversationSegmentId"); + + b.HasIndex("MessageDataId"); + + b.ToTable("ConversationSegmentMessages"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.FaissIndexFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Dimension") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IndexType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VectorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GroupId", "IndexType") + .IsUnique(); + + b.ToTable("FaissIndexFiles"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.GroupAccountSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActiveAccountBookId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsAccountingEnabled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique(); + + b.ToTable("GroupAccountSettings"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.GroupData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsBlacklist") + .HasColumnType("INTEGER"); + + b.Property("IsForum") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GroupData"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.GroupSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentChatBatchWindowSeconds") + .HasColumnType("INTEGER"); + + b.Property("AgentChatMode") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("ImageGenerationModelName") + .HasColumnType("TEXT"); + + b.Property("IsAgentChatEnabled") + .HasColumnType("INTEGER"); + + b.Property("IsManagerGroup") + .HasColumnType("INTEGER"); + + b.Property("LLMModelName") + .HasColumnType("TEXT"); + + b.Property("MusicGenerationModelName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique(); + + b.ToTable("GroupSettings"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.LLMChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Parallel") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("LLMChannels"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.MemoryGraph", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChatId") + .HasColumnType("INTEGER"); + + b.Property("CreatedTime") + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromEntity") + .HasColumnType("TEXT"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Observations") + .HasColumnType("TEXT"); + + b.Property("RelationType") + .HasColumnType("TEXT"); + + b.Property("ToEntity") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MemoryGraphs"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("DateTime") + .HasColumnType("TEXT"); + + b.Property("FromUserId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("ReplyToMessageId") + .HasColumnType("INTEGER"); + + b.Property("ReplyToUserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.MessageExtension", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MessageDataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MessageDataId"); + + b.ToTable("MessageExtensions"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ModelCapability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapabilityName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CapabilityValue") + .HasColumnType("TEXT"); + + b.Property("ChannelWithModelId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChannelWithModelId"); + + b.ToTable("ModelCapabilities"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ScheduledTaskExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompletedTime") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastHeartbeat") + .HasColumnType("TEXT"); + + b.Property("ResultSummary") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TaskName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TaskName") + .IsUnique(); + + b.ToTable("ScheduledTaskExecutions"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.SearchPageCache", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedTime") + .HasColumnType("TEXT"); + + b.Property("SearchOptionJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UUID") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SearchPageCaches"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ShortUrlMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExpandedUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginalUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OriginalUrl"); + + b.ToTable("ShortUrlMappings"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.TelegramFileCacheEntry", b => + { + b.Property("CacheKey") + .HasColumnType("TEXT"); + + b.Property("ExpiryDate") + .HasColumnType("TEXT"); + + b.Property("FileId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("CacheKey"); + + b.HasIndex("CacheKey") + .IsUnique(); + + b.ToTable("TelegramFileCacheEntries"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.TodoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChatId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CompletedBy") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DueAtUtc") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("RemindAtUtc") + .HasColumnType("TEXT"); + + b.Property("ReminderMessageId") + .HasColumnType("INTEGER"); + + b.Property("ReminderSentAtUtc") + .HasColumnType("TEXT"); + + b.Property("SourceMessageId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TodoListId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TodoListId", "Status"); + + b.HasIndex("ChatId", "Status", "RemindAtUtc", "ReminderSentAtUtc"); + + b.ToTable("TodoItems"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.TodoList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChatId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChatId", "Name") + .IsUnique(); + + b.ToTable("TodoLists"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.UserData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("IsBot") + .HasColumnType("INTEGER"); + + b.Property("IsPremium") + .HasColumnType("INTEGER"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UserData"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.UserWithGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GroupId") + .IsUnique(); + + b.ToTable("UsersWithGroup"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.VectorIndex", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentSummary") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("FaissIndex") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VectorType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId", "FaissIndex"); + + b.HasIndex("GroupId", "VectorType", "EntityId") + .IsUnique(); + + b.ToTable("VectorIndexes"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.AccountRecord", b => + { + b.HasOne("TelegramSearchBot.Model.Data.AccountBook", "AccountBook") + .WithMany("Records") + .HasForeignKey("AccountBookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AccountBook"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ChannelWithModel", b => + { + b.HasOne("TelegramSearchBot.Model.Data.LLMChannel", "LLMChannel") + .WithMany("Models") + .HasForeignKey("LLMChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LLMChannel"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ConversationSegmentMessage", b => + { + b.HasOne("TelegramSearchBot.Model.Data.ConversationSegment", "ConversationSegment") + .WithMany("Messages") + .HasForeignKey("ConversationSegmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TelegramSearchBot.Model.Data.Message", "Message") + .WithMany() + .HasForeignKey("MessageDataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ConversationSegment"); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.MessageExtension", b => + { + b.HasOne("TelegramSearchBot.Model.Data.Message", "Message") + .WithMany("MessageExtensions") + .HasForeignKey("MessageDataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ModelCapability", b => + { + b.HasOne("TelegramSearchBot.Model.Data.ChannelWithModel", "ChannelWithModel") + .WithMany("Capabilities") + .HasForeignKey("ChannelWithModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChannelWithModel"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.TodoItem", b => + { + b.HasOne("TelegramSearchBot.Model.Data.TodoList", "TodoList") + .WithMany("Items") + .HasForeignKey("TodoListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TodoList"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.AccountBook", b => + { + b.Navigation("Records"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ChannelWithModel", b => + { + b.Navigation("Capabilities"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ConversationSegment", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.LLMChannel", b => + { + b.Navigation("Models"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.Message", b => + { + b.Navigation("MessageExtensions"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.TodoList", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TelegramSearchBot.Database/Migrations/20260531153140_AddGroupAgentChatMode.cs b/TelegramSearchBot.Database/Migrations/20260531153140_AddGroupAgentChatMode.cs new file mode 100644 index 00000000..4a9534a7 --- /dev/null +++ b/TelegramSearchBot.Database/Migrations/20260531153140_AddGroupAgentChatMode.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TelegramSearchBot.Migrations +{ + /// + public partial class AddGroupAgentChatMode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AgentChatBatchWindowSeconds", + table: "GroupSettings", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "AgentChatMode", + table: "GroupSettings", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "IsAgentChatEnabled", + table: "GroupSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AgentChatBatchWindowSeconds", + table: "GroupSettings"); + + migrationBuilder.DropColumn( + name: "AgentChatMode", + table: "GroupSettings"); + + migrationBuilder.DropColumn( + name: "IsAgentChatEnabled", + table: "GroupSettings"); + } + } +} diff --git a/TelegramSearchBot.Database/Migrations/DataDbContextModelSnapshot.cs b/TelegramSearchBot.Database/Migrations/DataDbContextModelSnapshot.cs index e1a3e339..79f5c0c1 100644 --- a/TelegramSearchBot.Database/Migrations/DataDbContextModelSnapshot.cs +++ b/TelegramSearchBot.Database/Migrations/DataDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class DataDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); modelBuilder.Entity("TelegramSearchBot.Model.Data.AccountBook", b => { @@ -300,12 +300,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AgentChatBatchWindowSeconds") + .HasColumnType("INTEGER"); + + b.Property("AgentChatMode") + .HasColumnType("INTEGER"); + b.Property("GroupId") .HasColumnType("INTEGER"); b.Property("ImageGenerationModelName") .HasColumnType("TEXT"); + b.Property("IsAgentChatEnabled") + .HasColumnType("INTEGER"); + b.Property("IsManagerGroup") .HasColumnType("INTEGER"); diff --git a/TelegramSearchBot.Database/Model/Data/GroupSettings.cs b/TelegramSearchBot.Database/Model/Data/GroupSettings.cs index e4233b63..e2114539 100644 --- a/TelegramSearchBot.Database/Model/Data/GroupSettings.cs +++ b/TelegramSearchBot.Database/Model/Data/GroupSettings.cs @@ -8,6 +8,11 @@ namespace TelegramSearchBot.Model.Data { + public enum GroupAgentChatMode { + GuidedBatch = 0, + Sequential = 1 + } + [Index(nameof(GroupId), IsUnique = true)] public class GroupSettings { [Key] @@ -17,6 +22,9 @@ public class GroupSettings { public string LLMModelName { get; set; } public string ImageGenerationModelName { get; set; } public string MusicGenerationModelName { get; set; } + public bool IsAgentChatEnabled { get; set; } + public GroupAgentChatMode AgentChatMode { get; set; } = GroupAgentChatMode.GuidedBatch; + public int? AgentChatBatchWindowSeconds { get; set; } /// /// 是否是有管理员权限的群,是的所有群友都可以作为管理员操作一部分功能 /// diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/GroupLlmSettingsServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/GroupLlmSettingsServiceTests.cs new file mode 100644 index 00000000..56101961 --- /dev/null +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/GroupLlmSettingsServiceTests.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using TelegramSearchBot.Interface.AI.LLM; +using TelegramSearchBot.Model; +using TelegramSearchBot.Model.Data; +using TelegramSearchBot.Service.AI.LLM; +using Xunit; + +namespace TelegramSearchBot.LLM.Test.Service.AI.LLM { + public class GroupLlmSettingsServiceTests { + [Fact] + public async Task SetAgentChatModeAsync_CreatesGroupSettingsWithDefaultWindow() { + await using var dbContext = CreateDbContext(); + var service = new GroupLlmSettingsService(dbContext); + + var settings = await service.SetAgentChatModeAsync(-1001, true, GroupAgentChatMode.GuidedBatch); + + Assert.True(settings.IsEnabled); + Assert.Equal(GroupAgentChatMode.GuidedBatch, settings.Mode); + Assert.Equal(GroupAgentChatSettings.DefaultBatchWindowSeconds, settings.BatchWindowSeconds); + + var stored = await dbContext.GroupSettings.SingleAsync(x => x.GroupId == -1001); + Assert.True(stored.IsAgentChatEnabled); + Assert.Equal(GroupAgentChatMode.GuidedBatch, stored.AgentChatMode); + Assert.Equal(GroupAgentChatSettings.DefaultBatchWindowSeconds, stored.AgentChatBatchWindowSeconds); + } + + [Fact] + public async Task SetAgentChatModeAsync_PreservesExistingModels() { + await using var dbContext = CreateDbContext(); + dbContext.GroupSettings.Add(new GroupSettings { + GroupId = -1002, + LLMModelName = "gpt-test", + ImageGenerationModelName = "gpt-image-test", + MusicGenerationModelName = "music-test" + }); + await dbContext.SaveChangesAsync(); + var service = new GroupLlmSettingsService(dbContext); + + var settings = await service.SetAgentChatModeAsync(-1002, true, GroupAgentChatMode.Sequential, 9); + + Assert.True(settings.IsEnabled); + Assert.Equal(GroupAgentChatMode.Sequential, settings.Mode); + Assert.Equal(9, settings.BatchWindowSeconds); + Assert.Equal("gpt-test", settings.ModelName); + + var stored = await dbContext.GroupSettings.SingleAsync(x => x.GroupId == -1002); + Assert.Equal("gpt-test", stored.LLMModelName); + Assert.Equal("gpt-image-test", stored.ImageGenerationModelName); + Assert.Equal("music-test", stored.MusicGenerationModelName); + } + + private static DataDbContext CreateDbContext() { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"GroupLlmSettingsServiceTests_{Guid.NewGuid():N}") + .Options; + return new DataDbContext(options); + } + } +} diff --git a/TelegramSearchBot.LLM/Interface/AI/LLM/IGroupLlmSettingsService.cs b/TelegramSearchBot.LLM/Interface/AI/LLM/IGroupLlmSettingsService.cs index 51d91810..74c653d1 100644 --- a/TelegramSearchBot.LLM/Interface/AI/LLM/IGroupLlmSettingsService.cs +++ b/TelegramSearchBot.LLM/Interface/AI/LLM/IGroupLlmSettingsService.cs @@ -1,11 +1,28 @@ using System.Threading; using System.Threading.Tasks; +using TelegramSearchBot.Model.Data; #nullable enable namespace TelegramSearchBot.Interface.AI.LLM { + public sealed class GroupAgentChatSettings { + public const int DefaultBatchWindowSeconds = 5; + + public bool IsEnabled { get; set; } + public GroupAgentChatMode Mode { get; set; } = GroupAgentChatMode.GuidedBatch; + public int BatchWindowSeconds { get; set; } = DefaultBatchWindowSeconds; + public string? ModelName { get; set; } + } + public interface IGroupLlmSettingsService { Task GetModelAsync(long chatId, CancellationToken cancellationToken = default); Task<(string Previous, string Current)> SetModelAsync(long chatId, string modelName, CancellationToken cancellationToken = default); + Task GetAgentChatSettingsAsync(long chatId, CancellationToken cancellationToken = default); + Task SetAgentChatModeAsync( + long chatId, + bool isEnabled, + GroupAgentChatMode mode, + int? batchWindowSeconds = null, + CancellationToken cancellationToken = default); } } diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/GroupLlmSettingsService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/GroupLlmSettingsService.cs index be63a54a..052135f2 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/GroupLlmSettingsService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/GroupLlmSettingsService.cs @@ -14,6 +14,8 @@ namespace TelegramSearchBot.Service.AI.LLM { [Injectable(ServiceLifetime.Scoped)] public class GroupLlmSettingsService : IService, IGroupLlmSettingsService { + private const int MinBatchWindowSeconds = 1; + private const int MaxBatchWindowSeconds = 60; private readonly DataDbContext _dbContext; public GroupLlmSettingsService(DataDbContext dbContext) { @@ -28,6 +30,22 @@ public GroupLlmSettingsService(DataDbContext dbContext) { return settings == null ? null : settings.LLMModelName; } + public async Task GetAgentChatSettingsAsync(long chatId, CancellationToken cancellationToken = default) { + var settings = await _dbContext.GroupSettings.AsNoTracking() + .FirstOrDefaultAsync(s => s.GroupId == chatId, cancellationToken); + + if (settings is null) { + return new GroupAgentChatSettings(); + } + + return new GroupAgentChatSettings { + IsEnabled = settings.IsAgentChatEnabled, + Mode = settings.AgentChatMode, + BatchWindowSeconds = NormalizeBatchWindow(settings.AgentChatBatchWindowSeconds), + ModelName = 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)) { @@ -67,5 +85,60 @@ public GroupLlmSettingsService(DataDbContext dbContext) { return (previous ?? "Default", modelName); } + + public async Task SetAgentChatModeAsync( + long chatId, + bool isEnabled, + GroupAgentChatMode mode, + int? batchWindowSeconds = null, + CancellationToken cancellationToken = default) { + var normalizedWindow = NormalizeBatchWindow(batchWindowSeconds); + var settings = await _dbContext.GroupSettings + .FirstOrDefaultAsync(s => s.GroupId == chatId, cancellationToken); + GroupSettings? newSettings = null; + + if (settings is null) { + newSettings = new GroupSettings { + GroupId = chatId + }; + settings = newSettings; + await _dbContext.GroupSettings.AddAsync(settings, cancellationToken); + } + + settings.IsAgentChatEnabled = isEnabled; + settings.AgentChatMode = mode; + settings.AgentChatBatchWindowSeconds = normalizedWindow; + + 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; + } + + settings.IsAgentChatEnabled = isEnabled; + settings.AgentChatMode = mode; + settings.AgentChatBatchWindowSeconds = normalizedWindow; + await _dbContext.SaveChangesAsync(cancellationToken); + } + + return new GroupAgentChatSettings { + IsEnabled = settings.IsAgentChatEnabled, + Mode = settings.AgentChatMode, + BatchWindowSeconds = NormalizeBatchWindow(settings.AgentChatBatchWindowSeconds), + ModelName = settings.LLMModelName + }; + } + + private static int NormalizeBatchWindow(int? batchWindowSeconds) { + if (!batchWindowSeconds.HasValue || batchWindowSeconds.Value <= 0) { + return GroupAgentChatSettings.DefaultBatchWindowSeconds; + } + + return Math.Clamp(batchWindowSeconds.Value, MinBatchWindowSeconds, MaxBatchWindowSeconds); + } } } diff --git a/TelegramSearchBot.Test/Service/AI/LLM/LLMTaskQueueServiceTests.cs b/TelegramSearchBot.Test/Service/AI/LLM/LLMTaskQueueServiceTests.cs index cbd01b3d..a756ae1f 100644 --- a/TelegramSearchBot.Test/Service/AI/LLM/LLMTaskQueueServiceTests.cs +++ b/TelegramSearchBot.Test/Service/AI/LLM/LLMTaskQueueServiceTests.cs @@ -16,6 +16,72 @@ namespace TelegramSearchBot.Test.Service.AI.LLM { [Collection("AgentEnvSerial")] public class LLMTaskQueueServiceTests { + [Fact] + public async Task EnqueueMessageTaskAsync_WithCustomInput_PersistsInputMessage() { + var originalFlag = Env.EnableLLMAgentProcess; + Env.EnableLLMAgentProcess = true; + + try { + await using var dbContext = CreateDbContext(); + SeedChannel(dbContext, 322, "gpt-agent-chat"); + dbContext.GroupSettings.Add(new GroupSettings { + GroupId = -1001, + LLMModelName = "gpt-agent-chat" + }); + await dbContext.SaveChangesAsync(); + + var redisMock = new Mock(); + var dbMock = new Mock(); + redisMock.Setup(r => r.GetDatabase(It.IsAny(), It.IsAny())).Returns(dbMock.Object); + + dbMock.Setup(d => d.HashGetAllAsync( + It.Is(key => key == LlmAgentRedisKeys.AgentSession(-1001)), + It.IsAny())) + .ReturnsAsync([ + new HashEntry("chatId", -1001), + new HashEntry("processId", 999), + new HashEntry("port", 0), + new HashEntry("status", "idle"), + new HashEntry("lastHeartbeatUtc", DateTime.UtcNow.ToString("O")), + new HashEntry("lastActiveAtUtc", DateTime.UtcNow.ToString("O")) + ]); + + string pushedPayload = string.Empty; + dbMock.Setup(d => d.ListLeftPushAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((_, value, _, _) => pushedPayload = value.ToString()) + .ReturnsAsync(1); + dbMock.Setup(d => d.HashSetAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var registry = new AgentRegistryService( + redisMock.Object, + Mock.Of(), + Mock.Of>()); + var polling = new ChunkPollingService(redisMock.Object); + var service = new LLMTaskQueueService(dbContext, redisMock.Object, polling, registry); + + var handle = await service.EnqueueMessageTaskAsync( + -1001, + 456, + 789, + DateTime.UtcNow, + "custom agent chat input", + "bot", + 1001); + + Assert.NotNull(handle); + Assert.Contains("\"InputMessage\":\"custom agent chat input\"", pushedPayload); + Assert.Contains("\"ChatId\":-1001", pushedPayload); + Assert.Contains("\"UserId\":456", pushedPayload); + } finally { + Env.EnableLLMAgentProcess = originalFlag; + } + } + [Fact] public async Task EnqueueContinuationTaskAsync_PersistsPayloadAndRecoveryMetadata() { var originalFlag = Env.EnableLLMAgentProcess; diff --git a/TelegramSearchBot/Controller/AI/ASR/AutoASRController.cs b/TelegramSearchBot/Controller/AI/ASR/AutoASRController.cs index e921fc10..bf50550a 100644 --- a/TelegramSearchBot/Controller/AI/ASR/AutoASRController.cs +++ b/TelegramSearchBot/Controller/AI/ASR/AutoASRController.cs @@ -86,6 +86,9 @@ public async Task ExecuteAsync(PipelineContext p) { logger.LogInformation(AsrStr); } await MessageExtensionService.AddOrUpdateAsync(p.MessageDataId, "ASR_Result", AsrStr); + if (!string.IsNullOrWhiteSpace(AsrStr)) { + p.ProcessingResults.Add($"[ASR识别结果] {AsrStr}"); + } if (AsrStr.Length > 4095) { await SendMessageService.SendDocument(AsrStr, $"{e.Message.MessageId}.srt", e.Message.Chat.Id, e.Message.MessageId); } else { diff --git a/TelegramSearchBot/Controller/AI/LLM/AgentChatModeController.cs b/TelegramSearchBot/Controller/AI/LLM/AgentChatModeController.cs new file mode 100644 index 00000000..e12e9713 --- /dev/null +++ b/TelegramSearchBot/Controller/AI/LLM/AgentChatModeController.cs @@ -0,0 +1,348 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; +using Telegram.Bot; +using Telegram.Bot.Types.Enums; +using TelegramSearchBot.Common; +using TelegramSearchBot.Controller.AI.ASR; +using TelegramSearchBot.Controller.AI.OCR; +using TelegramSearchBot.Controller.AI.QR; +using TelegramSearchBot.Controller.Storage; +using TelegramSearchBot.Interface; +using TelegramSearchBot.Interface.AI.LLM; +using TelegramSearchBot.Interface.Controller; +using TelegramSearchBot.Model; +using TelegramSearchBot.Model.AI; +using TelegramSearchBot.Model.Data; +using TelegramSearchBot.Service.AI.LLM; +using TelegramSearchBot.Service.BotAPI; +using TelegramSearchBot.Service.Manage; +using TelegramMessage = Telegram.Bot.Types.Message; + +namespace TelegramSearchBot.Controller.AI.LLM { + public sealed class AgentChatModeController : IOnUpdate { + private readonly IGroupLlmSettingsService _groupLlmSettingsService; + private readonly IBotIdentityProvider _botIdentityProvider; + private readonly ITelegramBotClient _botClient; + private readonly AdminService _adminService; + private readonly ISendMessageService _sendMessageService; + private readonly AgentChatExecutionService _executionService; + private readonly AgentChatBatchQueueService _batchQueueService; + private readonly IConnectionMultiplexer _redis; + private readonly ILogger _logger; + + public AgentChatModeController( + IGroupLlmSettingsService groupLlmSettingsService, + IBotIdentityProvider botIdentityProvider, + ITelegramBotClient botClient, + AdminService adminService, + ISendMessageService sendMessageService, + AgentChatExecutionService executionService, + AgentChatBatchQueueService batchQueueService, + IConnectionMultiplexer redis, + ILogger logger) { + _groupLlmSettingsService = groupLlmSettingsService; + _botIdentityProvider = botIdentityProvider; + _botClient = botClient; + _adminService = adminService; + _sendMessageService = sendMessageService; + _executionService = executionService; + _batchQueueService = batchQueueService; + _redis = redis; + _logger = logger; + } + + public List Dependencies => new() { + typeof(MessageController), + typeof(AutoOCRController), + typeof(AutoQRController), + typeof(AutoASRController) + }; + + public async Task ExecuteAsync(PipelineContext p) { + var telegramMessage = p.Update.Message; + if (p.BotMessageType != BotMessageType.Message || telegramMessage == null || telegramMessage.Chat.Id > 0) { + return; + } + + var messageText = telegramMessage.Text ?? telegramMessage.Caption ?? string.Empty; + if (TryParseAgentChatCommand(messageText, out var command)) { + await HandleAgentChatCommandAsync(telegramMessage, command); + return; + } + + if (!Env.EnableOpenAI) { + return; + } + + var settings = await _groupLlmSettingsService.GetAgentChatSettingsAsync(telegramMessage.Chat.Id); + if (!settings.IsEnabled) { + return; + } + + var botIdentity = await EnsureBotIdentityAsync(); + if (ShouldIgnoreMessage(telegramMessage, messageText, botIdentity)) { + return; + } + + if (await HasPendingConfigurationStateAsync(telegramMessage.Chat.Id, telegramMessage.From?.Id ?? 0)) { + return; + } + + if (!Env.EnableLLMAgentProcess) { + await SendRateLimitedWarningAsync( + telegramMessage.Chat.Id, + telegramMessage.MessageId, + "agent-disabled", + "Agent 聊天模式需要先启用 EnableLLMAgentProcess=true。"); + return; + } + + if (string.IsNullOrWhiteSpace(settings.ModelName)) { + await SendRateLimitedWarningAsync( + telegramMessage.Chat.Id, + telegramMessage.MessageId, + "model-missing", + "Agent 聊天模式已开启,但当前群还没有设置 LLM 模型。请先发送 `选择模型` 或 `设置模型 <模型名>`。"); + return; + } + + var inputMessage = BuildInputMessage(telegramMessage, p.ProcessingResults); + if (string.IsNullOrWhiteSpace(inputMessage)) { + return; + } + + var messageInput = AgentChatMessageInput.FromTelegramMessage(telegramMessage, inputMessage); + if (settings.Mode == GroupAgentChatMode.Sequential) { + await _executionService.ExecuteAsync(new AgentChatExecutionRequest { + ReplyTarget = messageInput, + InputMessage = BuildSingleInput(messageInput), + BotName = botIdentity.UserName, + BotUserId = botIdentity.UserId, + ModelName = settings.ModelName, + Mode = GroupAgentChatMode.Sequential + }, CancellationToken.None); + return; + } + + await _batchQueueService.BufferAsync( + messageInput, + botIdentity.UserName, + botIdentity.UserId, + settings.BatchWindowSeconds, + CancellationToken.None); + } + + private async Task HandleAgentChatCommandAsync(TelegramMessage telegramMessage, AgentChatCommand command) { + var userId = telegramMessage.From?.Id ?? 0; + var canManage = _adminService.IsGlobalAdmin(userId) || await _adminService.IsNormalAdmin(userId); + if (!canManage) { + await _sendMessageService.SendMessage("只有管理员可以调整 Agent 聊天模式。", telegramMessage.Chat.Id, telegramMessage.MessageId); + return; + } + + GroupAgentChatSettings settings; + switch (command) { + case AgentChatCommand.EnableGuided: + settings = await _groupLlmSettingsService.SetAgentChatModeAsync( + telegramMessage.Chat.Id, + true, + GroupAgentChatMode.GuidedBatch); + await _sendMessageService.SendMessage(BuildStatusMessage(settings, "Agent 聊天已开启。"), telegramMessage.Chat.Id, telegramMessage.MessageId); + return; + case AgentChatCommand.EnableSequential: + settings = await _groupLlmSettingsService.SetAgentChatModeAsync( + telegramMessage.Chat.Id, + true, + GroupAgentChatMode.Sequential); + await _sendMessageService.SendMessage(BuildStatusMessage(settings, "Agent 队列聊天已开启。"), telegramMessage.Chat.Id, telegramMessage.MessageId); + return; + case AgentChatCommand.Disable: + var current = await _groupLlmSettingsService.GetAgentChatSettingsAsync(telegramMessage.Chat.Id); + settings = await _groupLlmSettingsService.SetAgentChatModeAsync( + telegramMessage.Chat.Id, + false, + current.Mode); + await _sendMessageService.SendMessage(BuildStatusMessage(settings, "Agent 聊天已关闭。"), telegramMessage.Chat.Id, telegramMessage.MessageId); + return; + case AgentChatCommand.Status: + settings = await _groupLlmSettingsService.GetAgentChatSettingsAsync(telegramMessage.Chat.Id); + await _sendMessageService.SendMessage(BuildStatusMessage(settings, "Agent 聊天状态:"), telegramMessage.Chat.Id, telegramMessage.MessageId); + return; + } + } + + private async Task EnsureBotIdentityAsync() { + var botIdentity = await _botIdentityProvider.GetIdentityAsync(); + if (!string.IsNullOrEmpty(botIdentity.UserName)) { + return botIdentity; + } + + var me = await _botClient.GetMe(); + _botIdentityProvider.SetIdentity(me.Id, me.Username); + return new BotIdentity(me.Id, me.Username ?? string.Empty); + } + + private bool ShouldIgnoreMessage(TelegramMessage telegramMessage, string messageText, BotIdentity botIdentity) { + if (telegramMessage.From?.IsBot == true || telegramMessage.From?.Id == botIdentity.UserId) { + return true; + } + + if (IsBotCommand(telegramMessage) || IsKnownManagementCommand(messageText)) { + return true; + } + + var isMentionToBot = !string.IsNullOrEmpty(botIdentity.UserName) && messageText.Contains($"@{botIdentity.UserName}", StringComparison.OrdinalIgnoreCase); + var isReplyToBot = telegramMessage.ReplyToMessage?.From?.Id == botIdentity.UserId; + return isMentionToBot || isReplyToBot; + } + + private static bool IsBotCommand(TelegramMessage telegramMessage) { + return telegramMessage.Entities?.Any(entity => + entity.Type == MessageEntityType.BotCommand && + entity.Offset == 0) == true; + } + + private static bool IsKnownManagementCommand(string messageText) { + var text = messageText.Trim(); + if (string.IsNullOrWhiteSpace(text)) { + return false; + } + + return text.Equals("选择模型", StringComparison.OrdinalIgnoreCase) + || text.StartsWith("设置模型 ", StringComparison.OrdinalIgnoreCase) + || text.Equals("选择生图模型", StringComparison.OrdinalIgnoreCase) + || text.Equals("生图模型列表", StringComparison.OrdinalIgnoreCase) + || text.Equals("可用生图模型", StringComparison.OrdinalIgnoreCase) + || text.StartsWith("设置生图模型 ", StringComparison.OrdinalIgnoreCase) + || text.Equals("清除生图模型", StringComparison.OrdinalIgnoreCase) + || text.Equals("重置生图模型", StringComparison.OrdinalIgnoreCase) + || text.Equals("生图模型", StringComparison.OrdinalIgnoreCase) + || text.Equals("查看生图模型", StringComparison.OrdinalIgnoreCase) + || text.Equals("选择音乐模型", StringComparison.OrdinalIgnoreCase) + || text.Equals("音乐模型列表", StringComparison.OrdinalIgnoreCase) + || text.Equals("可用音乐模型", StringComparison.OrdinalIgnoreCase) + || text.StartsWith("设置音乐模型 ", StringComparison.OrdinalIgnoreCase) + || text.Equals("清除音乐模型", StringComparison.OrdinalIgnoreCase) + || text.Equals("重置音乐模型", StringComparison.OrdinalIgnoreCase) + || text.Equals("音乐模型", StringComparison.OrdinalIgnoreCase) + || text.Equals("查看音乐模型", StringComparison.OrdinalIgnoreCase); + } + + private static string BuildInputMessage(TelegramMessage telegramMessage, IReadOnlyCollection processingResults) { + var raw = telegramMessage.Text ?? telegramMessage.Caption ?? string.Empty; + var parts = new List(); + if (!string.IsNullOrWhiteSpace(raw)) { + parts.Add(raw.Trim()); + } + + foreach (var result in processingResults.Where(x => !string.IsNullOrWhiteSpace(x))) { + var trimmed = result.Trim(); + if (string.Equals(trimmed, raw.Trim(), StringComparison.Ordinal)) { + continue; + } + + if (parts.Any(x => string.Equals(x, trimmed, StringComparison.Ordinal))) { + continue; + } + + parts.Add(trimmed); + } + + return string.Join(Environment.NewLine + Environment.NewLine, parts); + } + + private static string BuildSingleInput(AgentChatMessageInput message) { + return $""" + 以下是群内的一条消息,请作为 Agent 输入处理。 + + --- 消息 | MessageId={message.MessageId} | User={FormatUser(message)} | Time={message.DateTime:O} --- + {message.Content.Trim()} + """; + } + + private static string BuildStatusMessage(GroupAgentChatSettings settings, string title) { + var modeName = settings.Mode == GroupAgentChatMode.Sequential ? "队列模式(逐条串行)" : "引导模式(短窗口合并)"; + var enabled = settings.IsEnabled ? "开启" : "关闭"; + var modelName = string.IsNullOrWhiteSpace(settings.ModelName) ? "未设置" : settings.ModelName; + return $"{title}\n状态:{enabled}\n模式:{modeName}\n模型:{modelName}\n合并窗口:{settings.BatchWindowSeconds} 秒"; + } + + private async Task SendRateLimitedWarningAsync(long chatId, int replyToMessageId, string warningType, string message) { + var db = _redis.GetDatabase(); + if (await db.StringSetAsync(LlmAgentRedisKeys.AgentChatConfigWarning(chatId, warningType), "1", TimeSpan.FromMinutes(1), When.NotExists)) { + await _sendMessageService.SendMessage(message, chatId, replyToMessageId); + } + } + + private async Task HasPendingConfigurationStateAsync(long chatId, long userId) { + var db = _redis.GetDatabase(); + var pendingKeys = new[] { + LlmAgentRedisKeys.ModelSelectState(chatId), + LlmAgentRedisKeys.ImageGenerationModelSelection(chatId, userId), + LlmAgentRedisKeys.MusicGenerationModelSelection(chatId, userId) + }; + + foreach (var key in pendingKeys) { + if (( await db.StringGetAsync(key) ).HasValue) { + return true; + } + } + + return false; + } + + private static bool TryParseAgentChatCommand(string messageText, out AgentChatCommand command) { + var normalized = messageText.Trim().Replace(" ", string.Empty, StringComparison.OrdinalIgnoreCase); + command = AgentChatCommand.None; + if (string.IsNullOrWhiteSpace(normalized)) { + return false; + } + + if (normalized.Equals("开启Agent聊天", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("开启Agent引导聊天", StringComparison.OrdinalIgnoreCase)) { + command = AgentChatCommand.EnableGuided; + return true; + } + + if (normalized.Equals("开启Agent队列聊天", StringComparison.OrdinalIgnoreCase)) { + command = AgentChatCommand.EnableSequential; + return true; + } + + if (normalized.Equals("关闭Agent聊天", StringComparison.OrdinalIgnoreCase)) { + command = AgentChatCommand.Disable; + return true; + } + + if (normalized.Equals("Agent聊天状态", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("查看Agent聊天", StringComparison.OrdinalIgnoreCase)) { + command = AgentChatCommand.Status; + return true; + } + + return false; + } + + private static string FormatUser(AgentChatMessageInput message) { + var displayName = $"{message.FirstName} {message.LastName}".Trim(); + if (string.IsNullOrWhiteSpace(displayName)) { + displayName = string.IsNullOrWhiteSpace(message.Username) ? message.UserId.ToString() : $"@{message.Username}"; + } + + return $"{displayName} ({message.UserId})"; + } + + private enum AgentChatCommand { + None = 0, + EnableGuided = 1, + EnableSequential = 2, + Disable = 3, + Status = 4 + } + } +} diff --git a/TelegramSearchBot/Controller/AI/LLM/GeneralLLMController.cs b/TelegramSearchBot/Controller/AI/LLM/GeneralLLMController.cs index 7fe37968..49a4f33e 100644 --- a/TelegramSearchBot/Controller/AI/LLM/GeneralLLMController.cs +++ b/TelegramSearchBot/Controller/AI/LLM/GeneralLLMController.cs @@ -522,11 +522,11 @@ private static string BuildMusicGenerationModelSelectionMessage(List() .AddHostedService(sp => sp.GetRequiredService()) .AddHostedService() + .AddHostedService() .AddSingleton>(sp => sp.GetRequiredService().Log) .AddSingleton() .AddSingleton() diff --git a/TelegramSearchBot/Model/AI/AgentChatModels.cs b/TelegramSearchBot/Model/AI/AgentChatModels.cs new file mode 100644 index 00000000..17a6e0c9 --- /dev/null +++ b/TelegramSearchBot/Model/AI/AgentChatModels.cs @@ -0,0 +1,64 @@ +using System; +using Telegram.Bot.Types.Enums; +using TelegramSearchBot.Model.Data; +using TelegramChat = Telegram.Bot.Types.Chat; +using TelegramMessage = Telegram.Bot.Types.Message; + +namespace TelegramSearchBot.Model.AI { + public sealed class AgentChatMessageInput { + public long ChatId { get; set; } + public ChatType ChatType { get; set; } + public string ChatTitle { get; set; } = string.Empty; + public string ChatUsername { get; set; } = string.Empty; + public long UserId { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public bool IsBot { get; set; } + public int MessageId { get; set; } + public DateTime DateTime { get; set; } + public string Content { get; set; } = string.Empty; + + public static AgentChatMessageInput FromTelegramMessage(TelegramMessage message, string content) { + return new AgentChatMessageInput { + ChatId = message.Chat.Id, + ChatType = message.Chat.Type, + ChatTitle = message.Chat.Title ?? string.Empty, + ChatUsername = message.Chat.Username ?? string.Empty, + UserId = message.From?.Id ?? 0, + FirstName = message.From?.FirstName ?? string.Empty, + LastName = message.From?.LastName ?? string.Empty, + Username = message.From?.Username ?? string.Empty, + IsBot = message.From?.IsBot ?? false, + MessageId = message.MessageId, + DateTime = message.Date, + Content = content + }; + } + + public TelegramChat ToTelegramChat() { + return new TelegramChat { + Id = ChatId, + Type = ChatType, + Title = string.IsNullOrWhiteSpace(ChatTitle) ? null : ChatTitle, + Username = string.IsNullOrWhiteSpace(ChatUsername) ? null : ChatUsername + }; + } + } + + public sealed class AgentChatExecutionRequest { + public AgentChatMessageInput ReplyTarget { get; set; } = new(); + public string InputMessage { get; set; } = string.Empty; + public string BotName { get; set; } = string.Empty; + public long BotUserId { get; set; } + public string ModelName { get; set; } = string.Empty; + public GroupAgentChatMode Mode { get; set; } = GroupAgentChatMode.GuidedBatch; + } + + public sealed class AgentChatBufferedMessage { + public AgentChatMessageInput Message { get; set; } = new(); + public string BotName { get; set; } = string.Empty; + public long BotUserId { get; set; } + public DateTime BufferedAtUtc { get; set; } = DateTime.UtcNow; + } +} diff --git a/TelegramSearchBot/Service/AI/LLM/AgentChatBatchDispatchService.cs b/TelegramSearchBot/Service/AI/LLM/AgentChatBatchDispatchService.cs new file mode 100644 index 00000000..5ad3cc8a --- /dev/null +++ b/TelegramSearchBot/Service/AI/LLM/AgentChatBatchDispatchService.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using StackExchange.Redis; +using TelegramSearchBot.Interface.AI.LLM; +using TelegramSearchBot.Model.AI; +using TelegramSearchBot.Model.Data; + +namespace TelegramSearchBot.Service.AI.LLM { + public sealed class AgentChatBatchDispatchService : BackgroundService { + private const int MaxDueChatFetchCount = 20; + private const int MaxDispatchConcurrency = 4; + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1); + private static readonly TimeSpan LockTtl = TimeSpan.FromSeconds(30); + private static readonly TimeSpan LockRenewInterval = TimeSpan.FromSeconds(10); + private const string ReleaseLockScript = @" +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) +end +return 0"; + private const string RenewLockScript = @" +if redis.call('GET', KEYS[1]) == ARGV[1] then + redis.call('PEXPIRE', KEYS[1], ARGV[2]) + return 1 +end +return 0"; + private readonly IConnectionMultiplexer _redis; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public AgentChatBatchDispatchService( + IConnectionMultiplexer redis, + IServiceProvider serviceProvider, + ILogger logger) { + _redis = redis; + _serviceProvider = serviceProvider; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + using var timer = new PeriodicTimer(PollInterval); + try { + while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) { + await DispatchDueBatchesAsync(stoppingToken); + } + } catch (OperationCanceledException) { + // Normal shutdown. + } + } + + internal async Task DispatchDueBatchesAsync(CancellationToken cancellationToken = default) { + var db = _redis.GetDatabase(); + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var dueChatIds = await db.SortedSetRangeByScoreAsync( + LlmAgentRedisKeys.AgentChatBatchDueSet, + stop: now, + order: Order.Ascending, + take: MaxDueChatFetchCount); + + await Parallel.ForEachAsync( + dueChatIds, + new ParallelOptions { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = MaxDispatchConcurrency + }, + async (chatIdValue, token) => { + token.ThrowIfCancellationRequested(); + if (!long.TryParse(chatIdValue.ToString(), out var chatId)) { + await db.SortedSetRemoveAsync(LlmAgentRedisKeys.AgentChatBatchDueSet, chatIdValue); + return; + } + + await TryDispatchChatBatchAsync(db, chatId, token); + }); + } + + private async Task TryDispatchChatBatchAsync(IDatabase db, long chatId, CancellationToken cancellationToken) { + var lockKey = LlmAgentRedisKeys.AgentChatBatchLock(chatId); + var lockValue = $"{Environment.MachineName}:{Environment.ProcessId}:{Guid.NewGuid():N}"; + if (!await db.StringSetAsync(lockKey, lockValue, LockTtl, When.NotExists)) { + return; + } + + using var lockRenewalCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var lockRenewalTask = RenewLockUntilCanceledAsync(db, lockKey, lockValue, lockRenewalCts.Token); + + try { + await db.SortedSetRemoveAsync(LlmAgentRedisKeys.AgentChatBatchDueSet, chatId.ToString()); + var dueAtValue = await db.HashGetAsync(LlmAgentRedisKeys.AgentChatBatchMeta(chatId), "dueAt"); + if (dueAtValue.HasValue + && long.TryParse(dueAtValue.ToString(), out var dueAt) + && dueAt > DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) { + await db.SortedSetAddAsync(LlmAgentRedisKeys.AgentChatBatchDueSet, chatId.ToString(), dueAt); + return; + } + + var bufferedMessages = await DrainBufferedMessagesAsync(db, chatId); + if (bufferedMessages.Count == 0) { + await db.KeyDeleteAsync(LlmAgentRedisKeys.AgentChatBatchMeta(chatId)); + return; + } + + using var scope = _serviceProvider.CreateScope(); + var groupSettings = scope.ServiceProvider.GetRequiredService(); + var settings = await groupSettings.GetAgentChatSettingsAsync(chatId, cancellationToken); + if (!settings.IsEnabled || settings.Mode != GroupAgentChatMode.GuidedBatch) { + _logger.LogInformation("Dropping stale agent chat batch. ChatId={ChatId}, Enabled={Enabled}, Mode={Mode}", + chatId, + settings.IsEnabled, + settings.Mode); + return; + } + + var last = bufferedMessages[^1]; + var executionService = scope.ServiceProvider.GetRequiredService(); + await executionService.ExecuteAsync(new AgentChatExecutionRequest { + ReplyTarget = last.Message, + InputMessage = BuildBatchInput(bufferedMessages), + BotName = last.BotName, + BotUserId = last.BotUserId, + ModelName = settings.ModelName ?? string.Empty, + Mode = GroupAgentChatMode.GuidedBatch + }, cancellationToken); + } catch (Exception ex) when (ex is not OperationCanceledException) { + _logger.LogError(ex, "Failed to dispatch agent chat batch. ChatId={ChatId}", chatId); + } finally { + lockRenewalCts.Cancel(); + try { + await lockRenewalTask; + } catch (OperationCanceledException) { + // Normal cancellation after dispatch exits. + } + + await ReleaseLockIfOwnedAsync(db, lockKey, lockValue); + } + } + + private async Task RenewLockUntilCanceledAsync(IDatabase db, string lockKey, string lockValue, CancellationToken cancellationToken) { + using var timer = new PeriodicTimer(LockRenewInterval); + try { + while (await timer.WaitForNextTickAsync(cancellationToken)) { + var renewed = await db.ScriptEvaluateAsync( + RenewLockScript, + new RedisKey[] { lockKey }, + new RedisValue[] { lockValue, (long)LockTtl.TotalMilliseconds }); + if (( int ) renewed != 1) { + _logger.LogWarning("Lost agent chat batch lock before dispatch completed. LockKey={LockKey}", lockKey); + return; + } + } + } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { + // Normal cancellation after dispatch exits. + } catch (Exception ex) { + _logger.LogWarning(ex, "Failed to renew agent chat batch lock. LockKey={LockKey}", lockKey); + } + } + + private static async Task ReleaseLockIfOwnedAsync(IDatabase db, string lockKey, string lockValue) { + await db.ScriptEvaluateAsync( + ReleaseLockScript, + new RedisKey[] { lockKey }, + new RedisValue[] { lockValue }); + } + + private static async Task> DrainBufferedMessagesAsync(IDatabase db, long chatId) { + var result = new List(); + var listKey = LlmAgentRedisKeys.AgentChatBatchList(chatId); + + while (true) { + var value = await db.ListLeftPopAsync(listKey); + if (!value.HasValue) { + break; + } + + var buffered = JsonConvert.DeserializeObject(value.ToString()); + if (buffered?.Message != null && !string.IsNullOrWhiteSpace(buffered.Message.Content)) { + result.Add(buffered); + } + } + + return result + .OrderBy(x => x.Message.DateTime) + .ThenBy(x => x.Message.MessageId) + .ToList(); + } + + private static string BuildBatchInput(IReadOnlyList bufferedMessages) { + var sb = new StringBuilder(); + sb.AppendLine("以下是群内短时间连续发送的多条消息,请作为同一次 Agent 输入处理。"); + sb.AppendLine("请结合所有消息理解用户意图,不要逐条机械回复。"); + + for (var i = 0; i < bufferedMessages.Count; i++) { + var message = bufferedMessages[i].Message; + sb.AppendLine(); + sb.AppendLine($"--- 消息 {i + 1}/{bufferedMessages.Count} | MessageId={message.MessageId} | User={FormatUser(message)} | Time={message.DateTime:O} ---"); + sb.AppendLine(message.Content.Trim()); + } + + return sb.ToString().Trim(); + } + + private static string FormatUser(AgentChatMessageInput message) { + var displayName = $"{message.FirstName} {message.LastName}".Trim(); + if (string.IsNullOrWhiteSpace(displayName)) { + displayName = string.IsNullOrWhiteSpace(message.Username) ? message.UserId.ToString() : $"@{message.Username}"; + } + + return $"{displayName} ({message.UserId})"; + } + } +} diff --git a/TelegramSearchBot/Service/AI/LLM/AgentChatBatchQueueService.cs b/TelegramSearchBot/Service/AI/LLM/AgentChatBatchQueueService.cs new file mode 100644 index 00000000..f4c33de3 --- /dev/null +++ b/TelegramSearchBot/Service/AI/LLM/AgentChatBatchQueueService.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using StackExchange.Redis; +using TelegramSearchBot.Attributes; +using TelegramSearchBot.Interface; +using TelegramSearchBot.Model.AI; + +namespace TelegramSearchBot.Service.AI.LLM { + [Injectable(ServiceLifetime.Singleton)] + public sealed class AgentChatBatchQueueService : IService { + private static readonly TimeSpan BatchTtl = TimeSpan.FromMinutes(30); + private readonly IConnectionMultiplexer _redis; + + public AgentChatBatchQueueService(IConnectionMultiplexer redis) { + _redis = redis; + } + + public string ServiceName => nameof(AgentChatBatchQueueService); + + public async Task BufferAsync( + AgentChatMessageInput message, + string botName, + long botUserId, + int batchWindowSeconds, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + + var db = _redis.GetDatabase(); + var buffered = new AgentChatBufferedMessage { + Message = message, + BotName = botName, + BotUserId = botUserId + }; + var dueAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(1, batchWindowSeconds)).ToUnixTimeMilliseconds(); + var chatIdMember = message.ChatId.ToString(); + + await db.ListRightPushAsync(LlmAgentRedisKeys.AgentChatBatchList(message.ChatId), JsonConvert.SerializeObject(buffered)); + await db.HashSetAsync(LlmAgentRedisKeys.AgentChatBatchMeta(message.ChatId), [ + new HashEntry("dueAt", dueAt), + new HashEntry("botName", botName), + new HashEntry("botUserId", botUserId), + new HashEntry("lastMessageId", message.MessageId), + new HashEntry("updatedAtUtc", DateTime.UtcNow.ToString("O")) + ]); + await db.SortedSetAddAsync(LlmAgentRedisKeys.AgentChatBatchDueSet, chatIdMember, dueAt); + await db.KeyExpireAsync(LlmAgentRedisKeys.AgentChatBatchList(message.ChatId), BatchTtl); + await db.KeyExpireAsync(LlmAgentRedisKeys.AgentChatBatchMeta(message.ChatId), BatchTtl); + } + } +} diff --git a/TelegramSearchBot/Service/AI/LLM/AgentChatExecutionService.cs b/TelegramSearchBot/Service/AI/LLM/AgentChatExecutionService.cs new file mode 100644 index 00000000..2c907082 --- /dev/null +++ b/TelegramSearchBot/Service/AI/LLM/AgentChatExecutionService.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.ReplyMarkups; +using TelegramSearchBot.Attributes; +using TelegramSearchBot.Common; +using TelegramSearchBot.Interface; +using TelegramSearchBot.Interface.AI.LLM; +using TelegramSearchBot.Model; +using TelegramSearchBot.Model.AI; +using TelegramSearchBot.Service.BotAPI; +using TelegramSearchBot.Service.Storage; + +namespace TelegramSearchBot.Service.AI.LLM { + [Injectable(ServiceLifetime.Transient)] + public sealed class AgentChatExecutionService : IService { + private readonly LLMTaskQueueService _taskQueueService; + private readonly ISendMessageService _sendMessageService; + private readonly MessageService _messageService; + private readonly ITelegramBotClient _botClient; + private readonly ILlmContinuationService _continuationService; + private readonly ILogger _logger; + + public AgentChatExecutionService( + LLMTaskQueueService taskQueueService, + ISendMessageService sendMessageService, + MessageService messageService, + ITelegramBotClient botClient, + ILlmContinuationService continuationService, + ILogger logger) { + _taskQueueService = taskQueueService; + _sendMessageService = sendMessageService; + _messageService = messageService; + _botClient = botClient; + _continuationService = continuationService; + _logger = logger; + } + + public string ServiceName => nameof(AgentChatExecutionService); + + public async Task ExecuteAsync(AgentChatExecutionRequest request, CancellationToken cancellationToken = default) { + if (string.IsNullOrWhiteSpace(request.InputMessage)) { + return; + } + + AgentTaskStreamHandle agentTaskHandle; + try { + agentTaskHandle = await _taskQueueService.EnqueueMessageTaskAsync( + request.ReplyTarget.ChatId, + request.ReplyTarget.UserId, + request.ReplyTarget.MessageId, + request.ReplyTarget.DateTime, + request.InputMessage, + request.BotName, + request.BotUserId, + cancellationToken); + } catch (Exception ex) when (ex is InvalidOperationException or ArgumentException) { + _logger.LogWarning(ex, "Agent chat execution could not be queued. ChatId={ChatId}, MessageId={MessageId}", + request.ReplyTarget.ChatId, + request.ReplyTarget.MessageId); + await _sendMessageService.SendMessage($"Agent 聊天无法执行:{ex.Message}", request.ReplyTarget.ChatId, request.ReplyTarget.MessageId); + return; + } + + var initialContentPlaceholder = $"{( string.IsNullOrWhiteSpace(request.ModelName) ? "Agent" : request.ModelName )}初始化中。。。"; + List sentMessagesForDb = await _sendMessageService.SendDraftStream( + agentTaskHandle.ReadSnapshotsAsync(cancellationToken), + request.ReplyTarget.ChatId, + request.ReplyTarget.MessageId, + initialContentPlaceholder, + cancellationToken); + + await SaveBotMessagesAsync(request, sentMessagesForDb); + await HandleTerminalChunkAsync(request, await agentTaskHandle.Completion); + } + + private async Task SaveBotMessagesAsync(AgentChatExecutionRequest request, List sentMessagesForDb) { + if (sentMessagesForDb.Count == 0) { + return; + } + + User botUser = await _botClient.GetMe(); + var chat = request.ReplyTarget.ToTelegramChat(); + foreach (var dbMessage in sentMessagesForDb) { + await _messageService.ExecuteAsync(new MessageOption { + Chat = chat, + ChatId = dbMessage.GroupId, + Content = dbMessage.Content, + DateTime = dbMessage.DateTime, + MessageId = dbMessage.MessageId, + User = botUser, + ReplyTo = dbMessage.ReplyToMessageId, + UserId = dbMessage.FromUserId, + }); + } + } + + private async Task HandleTerminalChunkAsync(AgentChatExecutionRequest request, AgentStreamChunk terminalChunk) { + if (terminalChunk.Type == AgentChunkType.Error) { + _logger.LogError( + "Agent chat execution failed. ChatId={ChatId}, MessageId={MessageId}, ErrorMessage={ErrorMessage}", + request.ReplyTarget.ChatId, + request.ReplyTarget.MessageId, + terminalChunk.ErrorMessage); + await _sendMessageService.SendMessage($"AI Agent 执行失败:{terminalChunk.ErrorMessage}", request.ReplyTarget.ChatId, request.ReplyTarget.MessageId); + return; + } + + if (terminalChunk.Type != AgentChunkType.IterationLimitReached || terminalChunk.ContinuationSnapshot == null) { + return; + } + + var snapshotId = await _continuationService.SaveSnapshotAsync(terminalChunk.ContinuationSnapshot); + var keyboard = new InlineKeyboardMarkup(new[] { + new[] { + InlineKeyboardButton.WithCallbackData("✅ 继续迭代", $"llm_continue:{snapshotId}"), + InlineKeyboardButton.WithCallbackData("❌ 停止", $"llm_stop:{snapshotId}"), + } + }); + + await _botClient.SendMessage( + request.ReplyTarget.ChatId, + $"⚠️ AI 已达到最大迭代次数限制({Env.MaxToolCycles} 次),是否继续迭代?", + replyMarkup: keyboard, + replyParameters: new ReplyParameters { MessageId = request.ReplyTarget.MessageId } + ); + } + } +} diff --git a/TelegramSearchBot/Service/AI/LLM/LLMTaskQueueService.cs b/TelegramSearchBot/Service/AI/LLM/LLMTaskQueueService.cs index ef3529a3..2ce4f630 100644 --- a/TelegramSearchBot/Service/AI/LLM/LLMTaskQueueService.cs +++ b/TelegramSearchBot/Service/AI/LLM/LLMTaskQueueService.cs @@ -43,7 +43,44 @@ public async Task EnqueueMessageTaskAsync( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(telegramMessage); - var task = await BuildMessageTaskAsync(telegramMessage, botName, botUserId, cancellationToken); + var inputMessage = string.IsNullOrWhiteSpace(telegramMessage.Text) + ? telegramMessage.Caption ?? string.Empty + : telegramMessage.Text; + var task = await BuildMessageTaskAsync( + telegramMessage.Chat.Id, + telegramMessage.From?.Id ?? 0, + telegramMessage.MessageId, + telegramMessage.Date, + inputMessage, + botName, + botUserId, + cancellationToken); + await _agentRegistryService.EnsureAgentAsync(task.ChatId, cancellationToken); + return await EnqueueTaskAsync(task); + } + + public async Task EnqueueMessageTaskAsync( + long chatId, + long userId, + long messageId, + DateTime messageDate, + string inputMessage, + string botName, + long botUserId, + CancellationToken cancellationToken = default) { + if (string.IsNullOrWhiteSpace(inputMessage)) { + throw new ArgumentException("Input message cannot be empty.", nameof(inputMessage)); + } + + var task = await BuildMessageTaskAsync( + chatId, + userId, + messageId, + messageDate, + inputMessage, + botName, + botUserId, + cancellationToken); await _agentRegistryService.EnsureAgentAsync(task.ChatId, cancellationToken); return await EnqueueTaskAsync(task); } @@ -95,12 +132,16 @@ await db.HashSetAsync(LlmAgentRedisKeys.AgentTaskState(task.TaskId), [ } private async Task BuildMessageTaskAsync( - TelegramMessage telegramMessage, + long chatId, + long userId, + long messageId, + DateTime messageDate, + string inputMessage, string botName, long botUserId, CancellationToken cancellationToken) { var modelName = await _dbContext.GroupSettings.AsNoTracking() - .Where(x => x.GroupId == telegramMessage.Chat.Id) + .Where(x => x.GroupId == chatId) .Select(x => x.LLMModelName) .FirstOrDefaultAsync(cancellationToken); @@ -109,21 +150,21 @@ private async Task BuildMessageTaskAsync( } var channelInfo = await LoadChannelAsync(modelName, null, cancellationToken); - var history = await LoadHistoryAsync(telegramMessage.Chat.Id, cancellationToken); + var history = await LoadHistoryAsync(chatId, cancellationToken); return new AgentExecutionTask { TaskId = Guid.NewGuid().ToString("N"), Kind = AgentTaskKind.Message, - ChatId = telegramMessage.Chat.Id, - UserId = telegramMessage.From?.Id ?? 0, - MessageId = telegramMessage.MessageId, + ChatId = chatId, + UserId = userId, + MessageId = messageId, BotName = botName, BotUserId = botUserId, ModelName = modelName, - InputMessage = string.IsNullOrWhiteSpace(telegramMessage.Text) ? telegramMessage.Caption ?? string.Empty : telegramMessage.Text, + InputMessage = inputMessage, MaxToolCycles = Env.MaxToolCycles, Channel = channelInfo, History = history, - CreatedAtUtc = telegramMessage.Date.ToUniversalTime() + CreatedAtUtc = messageDate.ToUniversalTime() }; } diff --git a/TelegramSearchBot/Service/Manage/AdminService.cs b/TelegramSearchBot/Service/Manage/AdminService.cs index 1347bfa1..9055d59a 100644 --- a/TelegramSearchBot/Service/Manage/AdminService.cs +++ b/TelegramSearchBot/Service/Manage/AdminService.cs @@ -12,6 +12,7 @@ using TelegramSearchBot.Common; using TelegramSearchBot.Interface; using TelegramSearchBot.Model; +using TelegramSearchBot.Model.AI; using TelegramSearchBot.Service.Common; // Added for IAppConfigurationService using TelegramSearchBot.Service.Scheduler; // Added for ISchedulerService @@ -78,13 +79,10 @@ private async Task SaveSelectedModelAsync(long groupId, string modelName) public async Task<(bool, string)> ExecuteAsync(long UserId, long ChatId, string Command) { - string modelSelectStateKey = $"modelselect:{ChatId}:state"; - string modelSelectDataKey = $"modelselect:{ChatId}:models"; + string modelSelectStateKey = LlmAgentRedisKeys.ModelSelectState(ChatId); + string modelSelectDataKey = LlmAgentRedisKeys.ModelSelectModels(ChatId); if (Command.Equals("选择模型", StringComparison.OrdinalIgnoreCase)) { - string stateKey = $"modelselect:{ChatId}:state"; - string dataKey = $"modelselect:{ChatId}:models"; - var models = await GetDistinctModelsAsync(); if (models.Count == 0) { return (true, "当前没有可用的模型");