From 2776a0972466a350d69e3868dbcd39bba01f7a93 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Sun, 31 May 2026 19:00:33 +0800 Subject: [PATCH 1/2] Add MiniMax music generation tool --- Docs/Bot_Commands_User_Guide.md | 21 + Docs/Existing_Codebase_Overview.md | 2 + Docs/README_MCP.md | 19 + Docs/README_ModelCapabilities.md | 10 +- .../Model/AI/ModelWithCapabilities.cs | 17 + .../Model/Tools/MusicGenerationResult.cs | 32 + ...0_AddGroupMusicGenerationModel.Designer.cs | 883 ++++++++++++++++ ...0531090000_AddGroupMusicGenerationModel.cs | 28 + .../Migrations/DataDbContextModelSnapshot.cs | 3 + .../Model/Data/GroupSettings.cs | 1 + .../AI/LLM/ModelCapabilityServiceTests.cs | 62 ++ .../AI/LLM/ModelWithCapabilitiesTests.cs | 17 + .../Interface/IModelCapabilityService.cs | 5 + .../Service/AI/LLM/ModelCapabilityService.cs | 27 + .../Manage/EditLLMConfTest.cs | 6 +- .../Tools/MusicGenerationToolServiceTests.cs | 211 ++++ .../AppBootstrap/GeneralBootstrap.cs | 3 + .../Controller/AI/LLM/GeneralLLMController.cs | 140 +++ .../Controller/Help/HelpController.cs | 3 + TelegramSearchBot/Model/AI/LLMConfState.cs | 3 + .../Service/Manage/EditLLMConfService.cs | 45 +- .../Tools/ImageGenerationToolService.cs | 2 +- .../Tools/MusicGenerationToolService.cs | 956 ++++++++++++++++++ 23 files changed, 2490 insertions(+), 6 deletions(-) create mode 100644 TelegramSearchBot.Common/Model/Tools/MusicGenerationResult.cs create mode 100644 TelegramSearchBot.Database/Migrations/20260531090000_AddGroupMusicGenerationModel.Designer.cs create mode 100644 TelegramSearchBot.Database/Migrations/20260531090000_AddGroupMusicGenerationModel.cs create mode 100644 TelegramSearchBot.Test/Service/Tools/MusicGenerationToolServiceTests.cs create mode 100644 TelegramSearchBot/Service/Tools/MusicGenerationToolService.cs diff --git a/Docs/Bot_Commands_User_Guide.md b/Docs/Bot_Commands_User_Guide.md index 1e7c2772..6bb83bda 100644 --- a/Docs/Bot_Commands_User_Guide.md +++ b/Docs/Bot_Commands_User_Guide.md @@ -56,6 +56,13 @@ * **权限**: 普通管理员或全局管理员。 * **相关指令**: `选择生图模型` 显示可用生图模型并通过编号选择,`生图模型` / `查看生图模型` 查看当前生图模型,`清除生图模型` 恢复使用全局默认。 +* **设置当前聊天使用的音乐生成模型** + * **指令格式**: `设置音乐模型 <模型名称>` + * **功能**: 为当前聊天(群组或私聊)设置 `generate_music` 默认使用的 MiniMax 音乐生成模型;未设置时使用全局默认音乐模型。 + * **示例**: `设置音乐模型 music-2.6`,或 `设置音乐模型 music-2.6-free` + * **权限**: 普通管理员或全局管理员。 + * **相关指令**: `选择音乐模型` 显示可用音乐模型并通过编号选择,`音乐模型` / `查看音乐模型` 查看当前音乐模型,`清除音乐模型` 恢复使用全局默认。 + ### 2. 全局管理员指令 以下指令仅限机器人全局管理员(在 `Env.cs` 中配置的 `AdminId`)使用。 @@ -207,6 +214,16 @@ 7. 管理员发送: `gpt-image-2`,或 MiniMax 的 `image-01` / `image-01-live` 8. 机器人回复: `默认生图模型已设置为: gpt-image-2...` * **说明**: 生图工具使用内置工具 `generate_image`,默认优先使用当前群通过 `设置生图模型 <模型名>` 或 `选择生图模型` 配置的模型;群内未配置时使用全局默认 `gpt-image-2`。OpenAI-compatible 模型调用 `/v1/images/generations`;MiniMax `image-01` / `image-01-live` 调用 `/v1/image_generation`。API 地址和 API Key 不在工具参数中填写,而是来自该模型关联的 LLM 渠道;因此可通过 `新建渠道` / `编辑渠道` 自定义 API 地址,例如 `https://api.openai.com/v1`、`https://api.minimaxi.com` 或自建兼容网关。 + * **音乐工具开关与模型设置:** + 1. 管理员发送: `开启音乐工具` + 2. 机器人回复: `音乐工具已开启...` + 3. 管理员发送: `关闭音乐工具` + 4. 机器人回复: `音乐工具已关闭,并会从 LLM 工具提示词中隐藏。` + 5. 管理员发送: `设置默认音乐模型` + 6. 机器人回复: `请输入没有群级配置时使用的默认音乐模型名称...` + 7. 管理员发送: `music-2.6`,或 MiniMax 的 `music-2.6-free` / `music-cover` / `music-cover-free` + 8. 机器人回复: `默认音乐模型已设置为: music-2.6...` + * **说明**: 音乐生成工具使用内置工具 `generate_music`,默认优先使用当前群通过 `设置音乐模型 <模型名>` 或 `选择音乐模型` 配置的模型;群内未配置时使用全局默认 `music-2.6`。MiniMax 音乐接口调用 `/v1/music_generation`,生成完成后会保存音频文件并默认自动回复到当前聊天。API 地址和 API Key 来自该模型关联的 LLM 渠道;建议渠道类型选择 `MiniMax`,网关例如 `https://api.minimaxi.com`。 * **指令**: `新建渠道` * **功能**: 开始创建新的LLM(大语言模型)API渠道的流程,包括名称、网关地址、API类型 (OpenAI/Ollama)、API Key、最大并行数和优先级。 * **指令**: `编辑渠道` @@ -229,6 +246,10 @@ * **功能**: 开启、关闭或查看 LLM 生图内置工具。关闭后工具不会注入到 LLM 工具提示词或 native tool definitions 中。 * **指令**: `设置默认生图模型` * **功能**: 设置没有群级生图模型配置时使用的全局默认模型,内置默认 `gpt-image-2`;MiniMax 可用 `image-01` / `image-01-live`。需要将该模型通过 `添加模型` 关联到一个带自定义 API 地址的 LLM 渠道。 + * **指令**: `开启音乐工具` / `关闭音乐工具` / `音乐工具状态` + * **功能**: 开启、关闭或查看 LLM 音乐生成内置工具。关闭后工具不会注入到 LLM 工具提示词或 native tool definitions 中。 + * **指令**: `设置默认音乐模型` + * **功能**: 设置没有群级音乐模型配置时使用的全局默认模型,内置默认 `music-2.6`;MiniMax 可用 `music-2.6` / `music-2.6-free` / `music-cover` / `music-cover-free`。需要将该模型通过 `添加模型` 关联到一个 MiniMax 渠道。 * **MCP服务器管理** (在群组中由全局管理员发送,会触发交互式配置流程) * **说明**: 以下指令用于管理MCP (Model Context Protocol) 服务器。它们会启动一个多步骤的对话流程(状态机),机器人会依次询问配置所需的各项信息。请按照机器人的提示逐步回复。 diff --git a/Docs/Existing_Codebase_Overview.md b/Docs/Existing_Codebase_Overview.md index e9527ec1..5f57eee9 100644 --- a/Docs/Existing_Codebase_Overview.md +++ b/Docs/Existing_Codebase_Overview.md @@ -227,6 +227,8 @@ * **`SendPhotoToolService.cs`**: 发送图片工具(send_photo_base64, send_photo_file)。 * **`SendVideoToolService.cs`**: 发送视频工具(send_video_file)。 * **`SendDocumentToolService.cs`**: 发送文件工具(send_document_file)。 + * **`ImageGenerationToolService.cs`**: 图片生成工具(generate_image),支持 OpenAI-compatible 和 MiniMax 图片接口。 + * **`MusicGenerationToolService.cs`**: MiniMax 音乐生成工具(generate_music),生成完成后保存音频并可自动回复到当前聊天。 ### 17. `Sink/` diff --git a/Docs/README_MCP.md b/Docs/README_MCP.md index 06a853f8..3c8cac7d 100644 --- a/Docs/README_MCP.md +++ b/Docs/README_MCP.md @@ -69,6 +69,25 @@ API 地址和 API Key 来自该模型关联的 LLM 渠道,因此可通过 `新 MiniMax 不支持选择输出文件格式;`output_format` 仅用于 OpenAI-compatible 图片接口。MiniMax 的 URL 响应会按实际 Content-Type 或 URL 扩展名保存,base64 响应默认按 PNG 保存。 +### 2.2.2 音乐生成工具 + +| 工具名称 | 描述 | 参数 | +|---------|------|------| +| `generate_music` | 通过配置的 MiniMax 音乐 API 生成歌曲或翻唱,默认使用当前群的音乐模型;未配置时使用全局默认 `music-2.6`;接口走 `/v1/music_generation`;音频保存到本地并默认自动回复到当前聊天 | `prompt?`, `lyrics?`, `model?`, `isInstrumental?`, `lyricsOptimizer?`, `outputFormat?`, `sampleRate?`, `bitrate?`, `format?`, `aigcWatermark?`, `audioUrl?`, `audioBase64?`, `coverFeatureId?`, `sendToChat?`, `caption?`, `title?`, `performer?`, `replyToMessageId?`, `timeoutSeconds?` | + +管理员命令: +- `开启音乐工具`:允许 `generate_music` 注入到 LLM 工具提示词和 native tool definitions。 +- `关闭音乐工具`:隐藏 `generate_music`,关闭后 LLM 不会看到该工具。 +- `音乐工具状态`:查看开关状态和默认音乐模型。 +- `设置默认音乐模型`:设置无群级配置时使用的全局默认音乐模型,内置默认 `music-2.6`;MiniMax 可设置为 `music-2.6`、`music-2.6-free`、`music-cover` 或 `music-cover-free`。 +- 群内普通管理员可发送 `选择音乐模型` 从已识别的音乐生成模型里按编号选择,也可用 `设置音乐模型 <模型名>` 直接设置当前群的音乐模型,或用 `清除音乐模型` 恢复使用全局默认。 + +音乐模型识别优先使用模型能力 `music_generation` / `text_to_music`;没有能力数据时,会按 MiniMax `music-2.6` / `music-2.6-free` / `music-cover` / `music-cover-free` 兜底识别。 + +API 地址和 API Key 来自该模型关联的 LLM 渠道,因此可通过 `新建渠道` / `编辑渠道` 自定义网关地址,例如 `https://api.minimaxi.com`。配置 MiniMax 时,渠道类型建议选择 `MiniMax`,再通过 `添加模型` 关联音乐模型。 + +`music-2.6` / `music-2.6-free` 支持文生音乐和纯音乐;非纯音乐默认需要传 `lyrics`,除非 `lyrics_optimizer` 为 true。`music-cover` / `music-cover-free` 支持基于 `audio_url`、`audio_base64` 或预处理得到的 `cover_feature_id` 生成翻唱。 + **文件大小限制**: - 本地 Bot API(内置或外部): 最大 2GB - 云端API: 最大 50MB diff --git a/Docs/README_ModelCapabilities.md b/Docs/README_ModelCapabilities.md index 1f183bfd..57420b00 100644 --- a/Docs/README_ModelCapabilities.md +++ b/Docs/README_ModelCapabilities.md @@ -44,7 +44,7 @@ #### ModelWithCapabilities.cs - 数据传输对象,包含模型信息和能力信息 -- 提供便捷的属性访问(如 SupportsToolCalling、SupportsVision、SupportsEmbedding) +- 提供便捷的属性访问(如 SupportsToolCalling、SupportsVision、SupportsEmbedding、SupportsImageGeneration、SupportsMusicGeneration) - 包含能力设置和获取的辅助方法 ### 2. 数据库更改 @@ -112,6 +112,8 @@ OpenRouter提供的详细能力信息包括: - 自动检测`tools`、`function_calling`支持 - 检测`stream`、`response_format`等参数 - 记录完整的支持参数列表 + - 图片生成模型可通过 `image_generation` / `text_to_image` 能力或常见模型名识别 + - 音乐生成模型可通过 `music_generation` / `text_to_music` 能力或 MiniMax `music-2.6` / `music-cover` 系列模型名识别 4. **定价信息** - prompt价格 @@ -162,6 +164,10 @@ OpenRouter提供的详细能力信息包括: // 查询支持视觉的模型 var visionModels = await modelCapabilityService.GetModelsByCapability("vision", true); + + // 查询图片/音乐生成模型 + var imageModels = await modelCapabilityService.GetImageGenerationModels(); + var musicModels = await modelCapabilityService.GetMusicGenerationModels(); ``` ## 技术特点 @@ -191,4 +197,4 @@ OpenRouter提供的详细能力信息包括: ✅ **编译成功** - 所有功能已实现并通过编译 ⚠️ **文件锁定** - 由于程序正在运行,无法覆盖exe文件(这是正常现象) -OpenRouter适配现已完成,可以正常获取模型列表和详细的能力信息! \ No newline at end of file +OpenRouter适配现已完成,可以正常获取模型列表和详细的能力信息! diff --git a/TelegramSearchBot.Common/Model/AI/ModelWithCapabilities.cs b/TelegramSearchBot.Common/Model/AI/ModelWithCapabilities.cs index 03abc5ad..3ca2c062 100644 --- a/TelegramSearchBot.Common/Model/AI/ModelWithCapabilities.cs +++ b/TelegramSearchBot.Common/Model/AI/ModelWithCapabilities.cs @@ -49,6 +49,11 @@ public class ModelWithCapabilities { /// public bool SupportsImageGeneration => GetCapabilityBool("image_generation") || GetCapabilityBool("text_to_image") || IsKnownImageGenerationModelName(ModelName); + /// + /// 是否是音乐生成模型 + /// + public bool SupportsMusicGeneration => GetCapabilityBool("music_generation") || GetCapabilityBool("text_to_music") || IsKnownMusicGenerationModelName(ModelName); + /// /// 获取布尔类型的能力值 /// @@ -95,5 +100,17 @@ public static bool IsKnownImageGenerationModelName(string modelName) { lowerName.Contains("stable-diffusion") || lowerName.Contains("sdxl"); } + + public static bool IsKnownMusicGenerationModelName(string modelName) { + if (string.IsNullOrWhiteSpace(modelName)) { + return false; + } + + var lowerName = modelName.Trim().ToLowerInvariant(); + return lowerName.Equals("music-2.6") || + lowerName.Equals("music-2.6-free") || + lowerName.Equals("music-cover") || + lowerName.Equals("music-cover-free"); + } } } diff --git a/TelegramSearchBot.Common/Model/Tools/MusicGenerationResult.cs b/TelegramSearchBot.Common/Model/Tools/MusicGenerationResult.cs new file mode 100644 index 00000000..9f6bfc72 --- /dev/null +++ b/TelegramSearchBot.Common/Model/Tools/MusicGenerationResult.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; + +namespace TelegramSearchBot.Model.Tools { + public class MusicGenerationResult { + public bool Success { get; set; } + public string Error { get; set; } = null!; + public string Model { get; set; } = null!; + public int? ChannelId { get; set; } + public string ChannelName { get; set; } = null!; + public string Endpoint { get; set; } = null!; + public GeneratedMusicInfo Music { get; set; } = null!; + public SendAudioResult SentAudio { get; set; } = null!; + } + + public class GeneratedMusicInfo { + public string FilePath { get; set; } = null!; + public long FileSizeBytes { get; set; } + public string ContentType { get; set; } = null!; + public int? DurationMilliseconds { get; set; } + public int? SampleRate { get; set; } + public int? Channels { get; set; } + public int? Bitrate { get; set; } + } + + public class SendAudioResult { + public bool Success { get; set; } + public int? MessageId { get; set; } + public long ChatId { get; set; } + public string Error { get; set; } = null!; + public bool SentAsDocument { get; set; } + } +} diff --git a/TelegramSearchBot.Database/Migrations/20260531090000_AddGroupMusicGenerationModel.Designer.cs b/TelegramSearchBot.Database/Migrations/20260531090000_AddGroupMusicGenerationModel.Designer.cs new file mode 100644 index 00000000..89ce6692 --- /dev/null +++ b/TelegramSearchBot.Database/Migrations/20260531090000_AddGroupMusicGenerationModel.Designer.cs @@ -0,0 +1,883 @@ +// +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("20260531090000_AddGroupMusicGenerationModel")] + partial class AddGroupMusicGenerationModel + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + 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("GroupId") + .HasColumnType("INTEGER"); + + b.Property("ImageGenerationModelName") + .HasColumnType("TEXT"); + + 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/20260531090000_AddGroupMusicGenerationModel.cs b/TelegramSearchBot.Database/Migrations/20260531090000_AddGroupMusicGenerationModel.cs new file mode 100644 index 00000000..adbe0f68 --- /dev/null +++ b/TelegramSearchBot.Database/Migrations/20260531090000_AddGroupMusicGenerationModel.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TelegramSearchBot.Migrations +{ + /// + public partial class AddGroupMusicGenerationModel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MusicGenerationModelName", + table: "GroupSettings", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MusicGenerationModelName", + table: "GroupSettings"); + } + } +} diff --git a/TelegramSearchBot.Database/Migrations/DataDbContextModelSnapshot.cs b/TelegramSearchBot.Database/Migrations/DataDbContextModelSnapshot.cs index e45b6588..e1a3e339 100644 --- a/TelegramSearchBot.Database/Migrations/DataDbContextModelSnapshot.cs +++ b/TelegramSearchBot.Database/Migrations/DataDbContextModelSnapshot.cs @@ -312,6 +312,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LLMModelName") .HasColumnType("TEXT"); + b.Property("MusicGenerationModelName") + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("GroupId") diff --git a/TelegramSearchBot.Database/Model/Data/GroupSettings.cs b/TelegramSearchBot.Database/Model/Data/GroupSettings.cs index dee7036a..e4233b63 100644 --- a/TelegramSearchBot.Database/Model/Data/GroupSettings.cs +++ b/TelegramSearchBot.Database/Model/Data/GroupSettings.cs @@ -16,6 +16,7 @@ public class GroupSettings { public long GroupId { get; set; } public string LLMModelName { get; set; } public string ImageGenerationModelName { get; set; } + public string MusicGenerationModelName { get; set; } /// /// 是否是有管理员权限的群,是的所有群友都可以作为管理员操作一部分功能 /// diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelCapabilityServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelCapabilityServiceTests.cs index e4ce2a91..9ff9df53 100644 --- a/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelCapabilityServiceTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelCapabilityServiceTests.cs @@ -287,6 +287,68 @@ public async Task GetImageGenerationModels_UsesCapabilitiesAndKnownNames() { Assert.Equal(new[] { "custom-image-model", "gpt-image-2", "image-01" }, result); } + [Fact] + public async Task GetMusicGenerationModels_UsesCapabilitiesAndKnownNames() { + var channel = new LLMChannel { + Name = "openai", + Gateway = "gw", + ApiKey = "key", + Provider = LLMProvider.OpenAI, + Parallel = 1, + Priority = 1 + }; + var minimaxChannel = new LLMChannel { + Name = "minimax", + Gateway = "gw", + ApiKey = "key", + Provider = LLMProvider.MiniMax, + Parallel = 1, + Priority = 1 + }; + _dbContext.LLMChannels.AddRange(channel, minimaxChannel); + await _dbContext.SaveChangesAsync(); + + _dbContext.ChannelsWithModel.AddRange( + new ChannelWithModel { + ModelName = "custom-music-model", + LLMChannelId = channel.Id, + Capabilities = new List { + new ModelCapability { + CapabilityName = "music_generation", + CapabilityValue = "true", + LastUpdated = DateTime.UtcNow + } + } + }, + new ChannelWithModel { + ModelName = "music-2.6", + LLMChannelId = minimaxChannel.Id + }, + new ChannelWithModel { + ModelName = "music-cover-free", + LLMChannelId = minimaxChannel.Id + }, + new ChannelWithModel { + ModelName = "gpt-4o", + LLMChannelId = channel.Id, + Capabilities = new List { + new ModelCapability { + CapabilityName = "vision", + CapabilityValue = "true", + LastUpdated = DateTime.UtcNow + } + } + }); + await _dbContext.SaveChangesAsync(); + + var result = ( await _service.GetMusicGenerationModels() ) + .Select(x => x.ModelName) + .OrderBy(x => x) + .ToList(); + + Assert.Equal(new[] { "custom-music-model", "music-2.6", "music-cover-free" }, result); + } + [Fact] public async Task CleanupOldCapabilities_RemovesOldEntries() { // Arrange diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelWithCapabilitiesTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelWithCapabilitiesTests.cs index d10f65f0..a8eb0b6a 100644 --- a/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelWithCapabilitiesTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelWithCapabilitiesTests.cs @@ -95,6 +95,23 @@ public void SupportsImageGeneration_KnownName_ReturnsTrue(string modelName) { Assert.True(model.SupportsImageGeneration); } + [Fact] + public void SupportsMusicGeneration_CapabilitySet_ReturnsTrue() { + var model = new ModelWithCapabilities { ModelName = "test" }; + model.SetCapability("music_generation", true); + Assert.True(model.SupportsMusicGeneration); + } + + [Theory] + [InlineData("music-2.6")] + [InlineData("music-2.6-free")] + [InlineData("music-cover")] + [InlineData("music-cover-free")] + public void SupportsMusicGeneration_KnownName_ReturnsTrue(string modelName) { + var model = new ModelWithCapabilities { ModelName = modelName }; + Assert.True(model.SupportsMusicGeneration); + } + [Fact] public void GetCapabilityBool_InvalidValue_ReturnsFalse() { var model = new ModelWithCapabilities { ModelName = "test" }; diff --git a/TelegramSearchBot.LLM/Interface/IModelCapabilityService.cs b/TelegramSearchBot.LLM/Interface/IModelCapabilityService.cs index 85c550aa..3e0dd501 100644 --- a/TelegramSearchBot.LLM/Interface/IModelCapabilityService.cs +++ b/TelegramSearchBot.LLM/Interface/IModelCapabilityService.cs @@ -49,6 +49,11 @@ public interface IModelCapabilityService { /// Task> GetImageGenerationModels(); + /// + /// 获取音乐生成模型 + /// + Task> GetMusicGenerationModels(); + /// /// 删除过期的模型能力信息 /// diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/ModelCapabilityService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/ModelCapabilityService.cs index 6f4c9d78..8528ee23 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/ModelCapabilityService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/ModelCapabilityService.cs @@ -164,6 +164,19 @@ public async Task> GetImageGenerationModels() { return models.Where(IsImageGenerationModelRecord).ToList(); } + /// + /// 获取音乐生成模型 + /// + public async Task> GetMusicGenerationModels() { + var models = await _dbContext.ChannelsWithModel + .Include(c => c.LLMChannel) + .Include(c => c.Capabilities) + .Where(c => !c.IsDeleted) + .ToListAsync(); + + return models.Where(IsMusicGenerationModelRecord).ToList(); + } + private static bool IsImageGenerationModelRecord(ChannelWithModel model) { return model.Capabilities.Any(cap => ( cap.CapabilityName == "image_generation" || cap.CapabilityName == "text_to_image" ) && @@ -173,6 +186,18 @@ private static bool IsImageGenerationModelRecord(ChannelWithModel model) { ( model.ModelName == "image-01" || model.ModelName == "image-01-live" ) ); } + private static bool IsMusicGenerationModelRecord(ChannelWithModel model) { + return model.Capabilities.Any(cap => + ( cap.CapabilityName == "music_generation" || cap.CapabilityName == "text_to_music" ) && + string.Equals(cap.CapabilityValue, "true", StringComparison.OrdinalIgnoreCase)) || + ModelWithCapabilities.IsKnownMusicGenerationModelName(model.ModelName) || + ( model.LLMChannel?.Provider == LLMProvider.MiniMax && + ( model.ModelName == "music-2.6" || + model.ModelName == "music-2.6-free" || + model.ModelName == "music-cover" || + model.ModelName == "music-cover-free" ) ); + } + /// /// 删除过期的模型能力信息 /// @@ -245,6 +270,8 @@ private string GetCapabilityDescription(string capabilityName) { "multimodal" => "支持多模态输入", "image_generation" => "图片生成模型", "text_to_image" => "文生图模型", + "music_generation" => "音乐生成模型", + "text_to_music" => "文生音乐模型", "code_generation" => "支持代码生成", "audio_content" => "支持音频处理", "video_content" => "支持视频处理", diff --git a/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs b/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs index c3525933..2a5a82ce 100644 --- a/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs +++ b/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs @@ -119,7 +119,11 @@ public EditLLMConfTest() { new ImageGenerationToolSettingsService( _context, _redisMock.Object, - Mock.Of>())); + Mock.Of>()), + new MusicGenerationToolSettingsService( + _context, + _redisMock.Object, + Mock.Of>())); } [Fact] diff --git a/TelegramSearchBot.Test/Service/Tools/MusicGenerationToolServiceTests.cs b/TelegramSearchBot.Test/Service/Tools/MusicGenerationToolServiceTests.cs new file mode 100644 index 00000000..8beffeab --- /dev/null +++ b/TelegramSearchBot.Test/Service/Tools/MusicGenerationToolServiceTests.cs @@ -0,0 +1,211 @@ +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Newtonsoft.Json.Linq; +using StackExchange.Redis; +using TelegramSearchBot.Model; +using TelegramSearchBot.Model.Data; +using TelegramSearchBot.Service.Tools; +using Xunit; + +namespace TelegramSearchBot.Test.Service.Tools { + public class MusicGenerationToolServiceTests { + [Theory] + [InlineData("https://api.minimaxi.com", "https://api.minimaxi.com/v1/music_generation")] + [InlineData("https://api.minimaxi.com/v1", "https://api.minimaxi.com/v1/music_generation")] + [InlineData("https://example.test/custom/", "https://example.test/custom/v1/music_generation")] + [InlineData("https://example.test/v1/music_generation", "https://example.test/v1/music_generation")] + [InlineData("https://example.test/custom/music_generation", "https://example.test/custom/music_generation")] + public void BuildMiniMaxMusicGenerationEndpoint_NormalizesGateway(string gateway, string expected) { + Assert.Equal(expected, MusicGenerationToolService.BuildMiniMaxMusicGenerationEndpoint(gateway)); + } + + [Fact] + public void BuildMiniMaxMusicGenerationEndpoint_WhenGatewayEmpty_Throws() { + Assert.Throws(() => MusicGenerationToolService.BuildMiniMaxMusicGenerationEndpoint(" ")); + } + + [Fact] + public void BuildMiniMaxMusicGenerationRequestBody_TextToMusicWithLyrics() { + var body = MusicGenerationToolService.BuildMiniMaxMusicGenerationRequestBody( + "music-2.6", + "流行摇滚, 明亮, 适合夏夜", + "[Verse]\n晚风吹过街角\n[Chorus]\n我们一起奔跑", + false, + false, + "hex", + 44100, + 256000, + "mp3", + false, + null, + null, + null); + + Assert.Equal("music-2.6", Value(body, "model")); + Assert.Equal("流行摇滚, 明亮, 适合夏夜", Value(body, "prompt")); + Assert.Equal("[Verse]\n晚风吹过街角\n[Chorus]\n我们一起奔跑", Value(body, "lyrics")); + Assert.Equal("hex", Value(body, "output_format")); + Assert.False(Value(body, "lyrics_optimizer")); + Assert.False(Value(body, "is_instrumental")); + Assert.Equal(44100, NestedValue(body, "audio_setting", "sample_rate")); + Assert.Equal(256000, NestedValue(body, "audio_setting", "bitrate")); + Assert.Equal("mp3", NestedValue(body, "audio_setting", "format")); + } + + [Fact] + public void BuildMiniMaxMusicGenerationRequestBody_LyricsOptimizerAllowsEmptyLyrics() { + var body = MusicGenerationToolService.BuildMiniMaxMusicGenerationRequestBody( + "music-2.6-free", + "独立民谣, 温柔女声, 咖啡馆雨夜", + null, + false, + true, + null, + 32000, + 128000, + "wav", + true, + null, + null, + null); + + Assert.Equal("music-2.6-free", Value(body, "model")); + Assert.Null(body["lyrics"]); + Assert.True(Value(body, "lyrics_optimizer")); + Assert.True(Value(body, "aigc_watermark")); + Assert.Equal("hex", Value(body, "output_format")); + Assert.Equal("wav", NestedValue(body, "audio_setting", "format")); + } + + [Fact] + public void BuildMiniMaxMusicGenerationRequestBody_WhenLyricsMissingAndNoOptimizer_Throws() { + Assert.Throws(() => MusicGenerationToolService.BuildMiniMaxMusicGenerationRequestBody( + "music-2.6", + "流行", + null, + false, + false, + "hex", + 44100, + 256000, + "mp3", + false, + null, + null, + null)); + } + + [Fact] + public void BuildMiniMaxMusicGenerationRequestBody_CoverWithFeatureIdRequiresLyrics() { + var body = MusicGenerationToolService.BuildMiniMaxMusicGenerationRequestBody( + "music-cover", + "warm acoustic cover", + "[Verse]\nnew words", + false, + false, + "url", + 44100, + 256000, + "mp3", + false, + null, + null, + "feature_123"); + + Assert.Equal("music-cover", Value(body, "model")); + Assert.Equal("feature_123", Value(body, "cover_feature_id")); + Assert.Equal("[Verse]\nnew words", Value(body, "lyrics")); + Assert.Equal("url", Value(body, "output_format")); + } + + [Fact] + public void BuildMiniMaxMusicGenerationRequestBody_CoverRequiresExactlyOneSource() { + Assert.Throws(() => MusicGenerationToolService.BuildMiniMaxMusicGenerationRequestBody( + "music-cover", + "warm acoustic cover", + null, + false, + false, + "url", + 44100, + 256000, + "mp3", + false, + null, + null, + null)); + } + + [Fact] + public void EnsureMiniMaxResponseSucceeded_WhenStatusNonZero_Throws() { + var ex = Assert.Throws(() => MusicGenerationToolService.EnsureMiniMaxResponseSucceeded( + @"{""base_resp"":{""status_code"":2013,""status_msg"":""bad args""}}")); + + Assert.Contains("2013", ex.Message); + Assert.Contains("bad args", ex.Message); + } + + [Fact] + public void ExtractMusic_ReadsHexAndExtraInfo() { + var music = MusicGenerationToolService.ExtractMusic( + @"{""data"":{""audio"":""000102ff"",""status"":2},""extra_info"":{""music_duration"":25364,""music_sample_rate"":44100,""music_channel"":2,""bitrate"":256000},""base_resp"":{""status_code"":0,""status_msg"":""success""}}", + "hex", + "mp3"); + + Assert.Equal(new byte[] { 0, 1, 2, 255 }, music.Bytes); + Assert.Null(music.Url); + Assert.Equal(25364, music.DurationMilliseconds); + Assert.Equal(44100, music.SampleRate); + Assert.Equal(2, music.Channels); + Assert.Equal(256000, music.Bitrate); + } + + [Fact] + public async Task MusicGenerationSettings_UsesGroupModelBeforeDefault() { + await using var dbContext = CreateDbContext(); + dbContext.AppConfigurationItems.Add(new AppConfigurationItem { + Key = MusicGenerationToolSettingsService.ModelNameKey, + Value = "music-2.6" + }); + dbContext.GroupSettings.Add(new GroupSettings { + GroupId = -100, + MusicGenerationModelName = "music-2.6-free" + }); + await dbContext.SaveChangesAsync(); + + var settings = CreateSettingsService(dbContext); + + Assert.Equal("music-2.6-free", await settings.GetModelNameAsync(-100)); + Assert.Equal("music-2.6", await settings.GetModelNameAsync(-200)); + } + + private static T Value(JObject obj, string key) { + var token = obj[key]; + Assert.NotNull(token); + return token!.ToObject()!; + } + + private static T NestedValue(JObject obj, string parentKey, string key) { + var parent = obj[parentKey] as JObject; + Assert.NotNull(parent); + return Value(parent!, key); + } + + private static DataDbContext CreateDbContext() { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"MusicGenerationToolServiceTests_{Guid.NewGuid():N}") + .Options; + return new DataDbContext(options); + } + + private static MusicGenerationToolSettingsService CreateSettingsService(DataDbContext dbContext) { + return new MusicGenerationToolSettingsService( + dbContext, + new Mock().Object, + NullLogger.Instance); + } + } +} diff --git a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs index b6726e0d..d8461c0f 100644 --- a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs +++ b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs @@ -220,6 +220,9 @@ public static async Task Startup(string[] args) { var imageGenerationSettings = serviceScope.ServiceProvider.GetRequiredService(); await imageGenerationSettings.InitializeToolVisibilityAsync(); + + var musicGenerationSettings = serviceScope.ServiceProvider.GetRequiredService(); + await musicGenerationSettings.InitializeToolVisibilityAsync(); } var loggerFactory = service.GetRequiredService(); diff --git a/TelegramSearchBot/Controller/AI/LLM/GeneralLLMController.cs b/TelegramSearchBot/Controller/AI/LLM/GeneralLLMController.cs index b8383b9e..7fe37968 100644 --- a/TelegramSearchBot/Controller/AI/LLM/GeneralLLMController.cs +++ b/TelegramSearchBot/Controller/AI/LLM/GeneralLLMController.cs @@ -30,6 +30,7 @@ public class GeneralLLMController : IOnUpdate { private readonly IBotIdentityProvider _botIdentityProvider; private readonly IGroupLlmSettingsService _groupLlmSettingsService; private readonly ImageGenerationToolSettingsService _imageGenerationToolSettingsService; + private readonly MusicGenerationToolSettingsService _musicGenerationToolSettingsService; private readonly IModelCapabilityService _modelCapabilityService; private readonly IConnectionMultiplexer _connectionMultiplexer; public List Dependencies => new List(); @@ -53,6 +54,7 @@ public GeneralLLMController( IBotIdentityProvider botIdentityProvider, IGroupLlmSettingsService groupLlmSettingsService, ImageGenerationToolSettingsService imageGenerationToolSettingsService, + MusicGenerationToolSettingsService musicGenerationToolSettingsService, IModelCapabilityService modelCapabilityService, IConnectionMultiplexer connectionMultiplexer ) { @@ -68,6 +70,7 @@ IConnectionMultiplexer connectionMultiplexer _botIdentityProvider = botIdentityProvider; _groupLlmSettingsService = groupLlmSettingsService; _imageGenerationToolSettingsService = imageGenerationToolSettingsService; + _musicGenerationToolSettingsService = musicGenerationToolSettingsService; _modelCapabilityService = modelCapabilityService; _connectionMultiplexer = connectionMultiplexer; @@ -119,6 +122,10 @@ public async Task ExecuteAsync(PipelineContext p) { return; } + if (isNormalAdmin && await TryHandlePendingMusicGenerationModelSelectionAsync(telegramMessage, Message, fromUserId)) { + return; + } + if (Message.Equals("选择生图模型", StringComparison.OrdinalIgnoreCase) || Message.Equals("生图模型列表", StringComparison.OrdinalIgnoreCase) || Message.Equals("可用生图模型", StringComparison.OrdinalIgnoreCase)) { @@ -137,6 +144,24 @@ public async Task ExecuteAsync(PipelineContext p) { return; } + if (Message.Equals("选择音乐模型", StringComparison.OrdinalIgnoreCase) || + Message.Equals("音乐模型列表", StringComparison.OrdinalIgnoreCase) || + Message.Equals("可用音乐模型", StringComparison.OrdinalIgnoreCase)) { + if (!isNormalAdmin) { + return; + } + + var options = await LoadMusicGenerationModelOptionsAsync(); + if (options.Count == 0) { + await SendMessageService.SendMessage("当前没有识别到可用音乐模型。请先通过 `新建渠道` / `添加模型` 关联 MiniMax `music-2.6`、`music-2.6-free`、`music-cover` 或 `music-cover-free`,或刷新渠道能力。", telegramMessage.Chat.Id, telegramMessage.MessageId); + return; + } + + await SaveMusicGenerationModelSelectionAsync(telegramMessage.Chat.Id, fromUserId, options); + await SendMessageService.SendMessage(BuildMusicGenerationModelSelectionMessage(options), telegramMessage.Chat.Id, telegramMessage.MessageId); + return; + } + if (Message.StartsWith("设置生图模型 ") && isNormalAdmin) { var requestedModelName = Message.Substring(7).Trim(); if (string.IsNullOrWhiteSpace(requestedModelName)) { @@ -150,6 +175,19 @@ public async Task ExecuteAsync(PipelineContext p) { return; } + if (Message.StartsWith("设置音乐模型 ") && isNormalAdmin) { + var requestedModelName = Message.Substring(7).Trim(); + if (string.IsNullOrWhiteSpace(requestedModelName)) { + await SendMessageService.SendMessage("音乐模型名称不能为空", telegramMessage.Chat.Id, telegramMessage.MessageId); + return; + } + + var (previous, current) = await _musicGenerationToolSettingsService.SetGroupModelNameAsync(telegramMessage.Chat.Id, requestedModelName); + logger.LogInformation($"群{telegramMessage.Chat.Id}音乐模型设置成功,原模型:{previous},现模型:{current}。消息来源:{telegramMessage.MessageId}"); + await SendMessageService.SendMessage($"音乐模型设置成功,原模型:{previous},现模型:{current}", telegramMessage.Chat.Id, telegramMessage.MessageId); + return; + } + if (( Message.Equals("清除生图模型", StringComparison.OrdinalIgnoreCase) || Message.Equals("重置生图模型", StringComparison.OrdinalIgnoreCase) ) && isNormalAdmin) { @@ -159,6 +197,15 @@ public async Task ExecuteAsync(PipelineContext p) { return; } + if (( Message.Equals("清除音乐模型", StringComparison.OrdinalIgnoreCase) || + Message.Equals("重置音乐模型", StringComparison.OrdinalIgnoreCase) ) && + isNormalAdmin) { + var defaultModel = await _musicGenerationToolSettingsService.ClearGroupModelNameAsync(telegramMessage.Chat.Id); + logger.LogInformation($"群{telegramMessage.Chat.Id}音乐模型已清除,将使用默认模型:{defaultModel}。消息来源:{telegramMessage.MessageId}"); + await SendMessageService.SendMessage($"音乐模型已清除,当前会使用默认模型:{defaultModel}", telegramMessage.Chat.Id, telegramMessage.MessageId); + return; + } + if (( Message.Equals("生图模型", StringComparison.OrdinalIgnoreCase) || Message.Equals("查看生图模型", StringComparison.OrdinalIgnoreCase) ) && isNormalAdmin) { @@ -167,6 +214,14 @@ public async Task ExecuteAsync(PipelineContext p) { return; } + if (( Message.Equals("音乐模型", StringComparison.OrdinalIgnoreCase) || + Message.Equals("查看音乐模型", StringComparison.OrdinalIgnoreCase) ) && + isNormalAdmin) { + var modelName = await _musicGenerationToolSettingsService.GetModelNameAsync(telegramMessage.Chat.Id); + await SendMessageService.SendMessage($"当前音乐模型:{modelName}", telegramMessage.Chat.Id, telegramMessage.MessageId); + return; + } + if (Message.StartsWith("设置模型 ") && isNormalAdmin) { var requestedModelName = Message.Substring(5).Trim(); if (string.IsNullOrWhiteSpace(requestedModelName)) { @@ -343,6 +398,43 @@ private async Task TryHandlePendingImageGenerationModelSelectionAsync(Tele return true; } + private async Task TryHandlePendingMusicGenerationModelSelectionAsync(Telegram.Bot.Types.Message telegramMessage, string messageText, long userId) { + var db = _connectionMultiplexer.GetDatabase(); + var key = GetMusicGenerationModelSelectionKey(telegramMessage.Chat.Id, userId); + var stored = await db.StringGetAsync(key); + if (!stored.HasValue) { + return false; + } + + var trimmed = messageText.Trim(); + if (trimmed.Equals("取消", StringComparison.OrdinalIgnoreCase)) { + await db.KeyDeleteAsync(key); + await SendMessageService.SendMessage("已取消选择音乐模型。", telegramMessage.Chat.Id, telegramMessage.MessageId); + return true; + } + + if (!int.TryParse(trimmed, out var index)) { + await SendMessageService.SendMessage("请输入音乐模型编号,或发送 `取消`。", telegramMessage.Chat.Id, telegramMessage.MessageId); + return true; + } + + var modelNames = stored.ToString() + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + if (index < 1 || index > modelNames.Count) { + await SendMessageService.SendMessage($"无效编号,请输入 1 到 {modelNames.Count} 之间的数字,或发送 `取消`。", telegramMessage.Chat.Id, telegramMessage.MessageId); + return true; + } + + var modelName = modelNames[index - 1]; + var (previous, current) = await _musicGenerationToolSettingsService.SetGroupModelNameAsync(telegramMessage.Chat.Id, modelName); + await db.KeyDeleteAsync(key); + + logger.LogInformation($"群{telegramMessage.Chat.Id}音乐模型设置成功,原模型:{previous},现模型:{current}。消息来源:{telegramMessage.MessageId}"); + await SendMessageService.SendMessage($"音乐模型设置成功,原模型:{previous},现模型:{current}", telegramMessage.Chat.Id, telegramMessage.MessageId); + return true; + } + private async Task> LoadImageGenerationModelOptionsAsync() { var models = await _modelCapabilityService.GetImageGenerationModels(); return models @@ -363,6 +455,26 @@ private async Task> LoadImageGeneratio .ToList(); } + private async Task> LoadMusicGenerationModelOptionsAsync() { + var models = await _modelCapabilityService.GetMusicGenerationModels(); + return models + .Where(x => !string.IsNullOrWhiteSpace(x.ModelName) && x.LLMChannel != null) + .GroupBy(x => x.ModelName.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(group => { + var channels = group + .Select(x => $"{x.LLMChannel.Name}#{x.LLMChannel.Id}/{x.LLMChannel.Provider}") + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x) + .ToList(); + var channelSummary = channels.Count <= 3 + ? string.Join(", ", channels) + : $"{string.Join(", ", channels.Take(3))} 等 {channels.Count} 个渠道"; + return new MusicGenerationModelSelectionOption(group.Key, channelSummary); + }) + .OrderBy(x => x.ModelName, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + private async Task SaveImageGenerationModelSelectionAsync(long chatId, long userId, List options) { var db = _connectionMultiplexer.GetDatabase(); var key = GetImageGenerationModelSelectionKey(chatId, userId); @@ -370,6 +482,13 @@ private async Task SaveImageGenerationModelSelectionAsync(long chatId, long user await db.StringSetAsync(key, string.Join('\n', modelNames), TimeSpan.FromMinutes(10)); } + private async Task SaveMusicGenerationModelSelectionAsync(long chatId, long userId, List options) { + var db = _connectionMultiplexer.GetDatabase(); + var key = GetMusicGenerationModelSelectionKey(chatId, userId); + var modelNames = options.Take(50).Select(x => x.ModelName); + await db.StringSetAsync(key, string.Join('\n', modelNames), TimeSpan.FromMinutes(10)); + } + private static string BuildImageGenerationModelSelectionMessage(List options) { var limited = options.Take(50).ToList(); var sb = new StringBuilder(); @@ -386,10 +505,31 @@ private static string BuildImageGenerationModelSelectionMessage(List options) { + var limited = options.Take(50).ToList(); + var sb = new StringBuilder(); + sb.AppendLine("请选择当前群使用的音乐模型,回复编号即可:"); + for (var i = 0; i < limited.Count; i++) { + sb.AppendLine($"{i + 1}. {limited[i].ModelName} ({limited[i].ChannelSummary})"); + } + + if (options.Count > limited.Count) { + sb.AppendLine($"仅显示前 {limited.Count} 个,共 {options.Count} 个。"); + } + + sb.AppendLine("发送 `取消` 可退出选择。"); + return sb.ToString(); + } + private static string GetImageGenerationModelSelectionKey(long chatId, long userId) { return $"image_generation:model_select:{chatId}:{userId}"; } + private static string GetMusicGenerationModelSelectionKey(long chatId, long userId) { + return $"music_generation:model_select:{chatId}:{userId}"; + } + private sealed record ImageGenerationModelSelectionOption(string ModelName, string ChannelSummary); + private sealed record MusicGenerationModelSelectionOption(string ModelName, string ChannelSummary); } } diff --git a/TelegramSearchBot/Controller/Help/HelpController.cs b/TelegramSearchBot/Controller/Help/HelpController.cs index 9d4b3fb4..c29b06b1 100644 --- a/TelegramSearchBot/Controller/Help/HelpController.cs +++ b/TelegramSearchBot/Controller/Help/HelpController.cs @@ -36,11 +36,14 @@ public async Task ExecuteAsync(PipelineContext p) { - **设置模型**: `设置模型 <模型名称>` - **设置生图模型**: `设置生图模型 <模型名称>` - **选择生图模型**: `选择生图模型` +- **设置音乐模型**: `设置音乐模型 <模型名称>` +- **选择音乐模型**: `选择音乐模型` ### 2. 全局管理员指令 - **群组管理**: `设置管理群`/`取消管理群` - **Bilibili配置**: `/setbilicookie`, `/getbilicookie` - **生图工具**: `开启生图工具`, `关闭生图工具`, `生图工具状态`, `设置默认生图模型` +- **音乐工具**: `开启音乐工具`, `关闭音乐工具`, `音乐工具状态`, `设置默认音乐模型` - **应用更新**: `检查更新`, `更新` ## 三、自动功能 diff --git a/TelegramSearchBot/Model/AI/LLMConfState.cs b/TelegramSearchBot/Model/AI/LLMConfState.cs index 8e164e07..5ec14ee1 100644 --- a/TelegramSearchBot/Model/AI/LLMConfState.cs +++ b/TelegramSearchBot/Model/AI/LLMConfState.cs @@ -14,6 +14,9 @@ public enum LLMConfState { [Description("setting_image_generation_model")] SettingImageGenerationModel, + [Description("setting_music_generation_model")] + SettingMusicGenerationModel, + [Description("awaiting_name")] AwaitingName, diff --git a/TelegramSearchBot/Service/Manage/EditLLMConfService.cs b/TelegramSearchBot/Service/Manage/EditLLMConfService.cs index eaa82cdb..7d8dca53 100644 --- a/TelegramSearchBot/Service/Manage/EditLLMConfService.cs +++ b/TelegramSearchBot/Service/Manage/EditLLMConfService.cs @@ -24,6 +24,7 @@ public class EditLLMConfService : IService { protected readonly DataDbContext DataContext; protected readonly IEditLLMConfHelper Helper; private readonly ImageGenerationToolSettingsService _imageGenerationToolSettingsService; + private readonly MusicGenerationToolSettingsService _musicGenerationToolSettingsService; protected IConnectionMultiplexer connectionMultiplexer { get; set; } // 状态处理器映射字典 @@ -36,12 +37,14 @@ public EditLLMConfService( IEditLLMConfHelper helper, DataDbContext context, IConnectionMultiplexer connectionMultiplexer, - ImageGenerationToolSettingsService imageGenerationToolSettingsService + ImageGenerationToolSettingsService imageGenerationToolSettingsService, + MusicGenerationToolSettingsService musicGenerationToolSettingsService ) { this.connectionMultiplexer = connectionMultiplexer; DataContext = context; Helper = helper ?? throw new ArgumentNullException(nameof(helper)); _imageGenerationToolSettingsService = imageGenerationToolSettingsService ?? throw new ArgumentNullException(nameof(imageGenerationToolSettingsService)); + _musicGenerationToolSettingsService = musicGenerationToolSettingsService ?? throw new ArgumentNullException(nameof(musicGenerationToolSettingsService)); // 初始化状态处理器映射 _stateHandlers = new Dictionary>> @@ -63,7 +66,8 @@ ImageGenerationToolSettingsService imageGenerationToolSettingsService { LLMConfState.EditingInputValue.GetDescription(), HandleEditingInputValueAsync }, { LLMConfState.SettingMaxRetry.GetDescription(), HandleSettingMaxRetryAsync }, { LLMConfState.SettingMaxImageRetry.GetDescription(), HandleSettingMaxImageRetryAsync }, - { LLMConfState.SettingImageGenerationModel.GetDescription(), HandleSettingImageGenerationModelAsync } + { LLMConfState.SettingImageGenerationModel.GetDescription(), HandleSettingImageGenerationModelAsync }, + { LLMConfState.SettingMusicGenerationModel.GetDescription(), HandleSettingMusicGenerationModelAsync } }; // 初始化字段更新处理器映射 @@ -202,6 +206,17 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration } } + private async Task<(bool, string)> HandleSettingMusicGenerationModelAsync(EditLLMConfRedisHelper redis, string command) { + try { + await _musicGenerationToolSettingsService.SetDefaultModelNameAsync(command); + await redis.DeleteKeysAsync(); + return (true, $"默认音乐模型已设置为: {command.Trim()}。群内没有单独配置音乐模型时会使用该默认值;请确保该模型已通过 `添加模型` 关联到一个 MiniMax 渠道。"); + } catch { + await redis.DeleteKeysAsync(); + return (false, "设置默认音乐模型失败"); + } + } + private async Task<(bool, string)> HandleEditingSelectChannelAsync(EditLLMConfRedisHelper redis, string command) { if (!int.TryParse(command, out var channelId)) { return (false, "请输入有效的渠道ID"); @@ -525,6 +540,32 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration return (true, $"请输入没有群级配置时使用的默认生图模型名称(内置默认 {ImageGenerationToolSettingsService.DefaultModelName},MiniMax 可用 image-01 或 image-01-live):"); } + if (cmd.Equals("开启音乐工具", StringComparison.OrdinalIgnoreCase) || + cmd.Equals("启用音乐工具", StringComparison.OrdinalIgnoreCase)) { + await _musicGenerationToolSettingsService.SetToolEnabledAsync(true); + var modelName = await _musicGenerationToolSettingsService.GetDefaultModelNameAsync(); + return (true, $"音乐工具已开启,会注入到 LLM 工具提示词中。默认音乐模型: {modelName}"); + } + + if (cmd.Equals("关闭音乐工具", StringComparison.OrdinalIgnoreCase) || + cmd.Equals("禁用音乐工具", StringComparison.OrdinalIgnoreCase)) { + await _musicGenerationToolSettingsService.SetToolEnabledAsync(false); + return (true, "音乐工具已关闭,并会从 LLM 工具提示词中隐藏。"); + } + + if (cmd.Equals("音乐工具状态", StringComparison.OrdinalIgnoreCase) || + cmd.Equals("查看音乐工具", StringComparison.OrdinalIgnoreCase)) { + var enabled = await _musicGenerationToolSettingsService.IsToolEnabledAsync(); + var modelName = await _musicGenerationToolSettingsService.GetDefaultModelNameAsync(); + return (true, $"音乐工具: {( enabled ? "已开启" : "已关闭" )}\n默认音乐模型: {modelName}\n群内可用 `选择音乐模型` 或 `设置音乐模型 <模型名>` 设置当前群的音乐模型;未配置时使用默认值。\nAPI 地址与 API Key 来自对应模型关联的 MiniMax LLM 渠道,可通过 `新建渠道` / `编辑渠道` 自定义渠道地址。"); + } + + if (cmd.Equals("设置默认音乐模型", StringComparison.OrdinalIgnoreCase) || + cmd.Equals("设置音乐默认模型", StringComparison.OrdinalIgnoreCase)) { + await redis.SetStateAsync(LLMConfState.SettingMusicGenerationModel.GetDescription()); + return (true, $"请输入没有群级配置时使用的默认音乐模型名称(内置默认 {MusicGenerationToolSettingsService.DefaultModelName},MiniMax 可用 music-2.6、music-2.6-free、music-cover 或 music-cover-free):"); + } + return null; } diff --git a/TelegramSearchBot/Service/Tools/ImageGenerationToolService.cs b/TelegramSearchBot/Service/Tools/ImageGenerationToolService.cs index d399f9b4..3d7bacd6 100644 --- a/TelegramSearchBot/Service/Tools/ImageGenerationToolService.cs +++ b/TelegramSearchBot/Service/Tools/ImageGenerationToolService.cs @@ -255,7 +255,7 @@ public ImageGenerationToolService( [BuiltInTool(@"Generate an image through the configured image API and optionally send it to the current Telegram chat. The default model is the current chat's configured image generation model, falling back to the bot-wide default gpt-image-2 when the chat has no image model configured. OpenAI-compatible models use /v1/images/generations. MiniMax image-01 and image-01-live use /v1/image_generation. The API base URL and API key are read from the configured LLM channel for the selected image model, so administrators can use OpenAI, MiniMax, or a compatible custom endpoint. -Use this when the user asks you to draw, create, render, generate, or revise an image. The tool saves generated image files under the bot work directory and returns their file paths.")] +Use this when the user asks you to draw, create, render, generate, or revise an image. The tool saves generated image files under the bot work directory and returns their file paths.", Name = ToolName)] public async Task GenerateImage( [BuiltInParameter("Image prompt. Be specific about subject, style, composition, lighting, colors, and any text that should appear.")] string prompt, ToolContext toolContext, diff --git a/TelegramSearchBot/Service/Tools/MusicGenerationToolService.cs b/TelegramSearchBot/Service/Tools/MusicGenerationToolService.cs new file mode 100644 index 00000000..8e178e49 --- /dev/null +++ b/TelegramSearchBot/Service/Tools/MusicGenerationToolService.cs @@ -0,0 +1,956 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using StackExchange.Redis; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; +using TelegramSearchBot.Attributes; +using TelegramSearchBot.Common; +using TelegramSearchBot.Interface; +using TelegramSearchBot.Manager; +using TelegramSearchBot.Model; +using TelegramSearchBot.Model.AI; +using TelegramSearchBot.Model.Data; +using TelegramSearchBot.Model.Tools; +using TelegramSearchBot.Service.AI.LLM; + +namespace TelegramSearchBot.Service.Tools { + [Injectable(ServiceLifetime.Transient)] + public class MusicGenerationToolSettingsService : IService { + public const string EnableToolKey = "LLM:EnableMusicGenerationTool"; + public const string ModelNameKey = "LLM:MusicGenerationModelName"; + public const string DefaultModelName = "music-2.6"; + + public string ServiceName => "MusicGenerationToolSettingsService"; + + private readonly DataDbContext _dbContext; + private readonly IConnectionMultiplexer _connectionMultiplexer; + private readonly ILogger _logger; + + public MusicGenerationToolSettingsService( + DataDbContext dbContext, + IConnectionMultiplexer connectionMultiplexer, + ILogger logger) { + _dbContext = dbContext; + _connectionMultiplexer = connectionMultiplexer; + _logger = logger; + } + + public async Task InitializeToolVisibilityAsync() { + var enabled = await IsToolEnabledAsync(); + ApplyToolVisibility(enabled); + } + + public async Task IsToolEnabledAsync() { + var value = await GetConfigurationValueAsync(EnableToolKey); + return ParseBool(value, defaultValue: true); + } + + public async Task SetToolEnabledAsync(bool enabled) { + await UpsertConfigurationAsync(EnableToolKey, enabled ? "true" : "false"); + ApplyToolVisibility(enabled); + await RefreshAgentToolDefinitionsAsync(); + } + + public async Task GetDefaultModelNameAsync() { + var value = await GetConfigurationValueAsync(ModelNameKey); + return string.IsNullOrWhiteSpace(value) ? DefaultModelName : value.Trim(); + } + + public Task GetModelNameAsync() { + return GetDefaultModelNameAsync(); + } + + public async Task GetModelNameAsync(long chatId) { + if (chatId != 0) { + var groupModelName = await _dbContext.GroupSettings + .AsNoTracking() + .Where(x => x.GroupId == chatId) + .Select(x => x.MusicGenerationModelName) + .FirstOrDefaultAsync(); + + if (!string.IsNullOrWhiteSpace(groupModelName)) { + return groupModelName.Trim(); + } + } + + return await GetDefaultModelNameAsync(); + } + + public async Task SetDefaultModelNameAsync(string modelName) { + if (string.IsNullOrWhiteSpace(modelName)) { + throw new ArgumentException("Music generation model name cannot be empty.", nameof(modelName)); + } + + await UpsertConfigurationAsync(ModelNameKey, modelName.Trim()); + } + + public Task SetModelNameAsync(string modelName) { + return SetDefaultModelNameAsync(modelName); + } + + public async Task<(string Previous, string Current)> SetGroupModelNameAsync(long chatId, string modelName) { + if (chatId == 0) { + throw new ArgumentException("Chat id is required.", nameof(chatId)); + } + + if (string.IsNullOrWhiteSpace(modelName)) { + throw new ArgumentException("Music generation model name cannot be empty.", nameof(modelName)); + } + + var normalizedModelName = modelName.Trim(); + var defaultModelName = await GetDefaultModelNameAsync(); + var settings = await _dbContext.GroupSettings + .FirstOrDefaultAsync(x => x.GroupId == chatId); + var previous = settings == null ? null : settings.MusicGenerationModelName; + GroupSettings newSettings = null; + + if (settings == null) { + newSettings = new GroupSettings { + GroupId = chatId, + MusicGenerationModelName = normalizedModelName + }; + await _dbContext.GroupSettings.AddAsync(newSettings); + } else { + settings.MusicGenerationModelName = normalizedModelName; + } + + try { + await _dbContext.SaveChangesAsync(); + } catch (DbUpdateException) when (newSettings != null) { + _dbContext.Entry(newSettings).State = EntityState.Detached; + settings = await _dbContext.GroupSettings + .FirstOrDefaultAsync(x => x.GroupId == chatId); + if (settings == null) { + throw; + } + + previous = settings.MusicGenerationModelName; + settings.MusicGenerationModelName = normalizedModelName; + await _dbContext.SaveChangesAsync(); + } + + return (string.IsNullOrWhiteSpace(previous) ? defaultModelName : previous.Trim(), normalizedModelName); + } + + public async Task ClearGroupModelNameAsync(long chatId) { + if (chatId == 0) { + throw new ArgumentException("Chat id is required.", nameof(chatId)); + } + + var settings = await _dbContext.GroupSettings + .FirstOrDefaultAsync(x => x.GroupId == chatId); + if (settings != null) { + settings.MusicGenerationModelName = null; + await _dbContext.SaveChangesAsync(); + } + + return await GetDefaultModelNameAsync(); + } + + private static void ApplyToolVisibility(bool enabled) { + McpToolHelper.SetBuiltInToolEnabled(MusicGenerationToolService.ToolName, enabled); + } + + private async Task RefreshAgentToolDefinitionsAsync() { + try { + await McpToolHelper.RefreshAgentToolDefsInRedisAsync(_connectionMultiplexer); + } catch (Exception ex) { + _logger.LogWarning(ex, "Failed to refresh agent tool definitions after music generation tool visibility changed."); + } + } + + private async Task GetConfigurationValueAsync(string key) { + var item = await _dbContext.AppConfigurationItems + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Key == key); + return item?.Value; + } + + private async Task UpsertConfigurationAsync(string key, string value) { + var item = await _dbContext.AppConfigurationItems + .FirstOrDefaultAsync(x => x.Key == key); + + if (item == null) { + await _dbContext.AppConfigurationItems.AddAsync(new AppConfigurationItem { + Key = key, + Value = value + }); + } else { + item.Value = value; + } + + await _dbContext.SaveChangesAsync(); + } + + private static bool ParseBool(string value, bool defaultValue) { + if (string.IsNullOrWhiteSpace(value)) { + return defaultValue; + } + + if (bool.TryParse(value.Trim(), out var parsed)) { + return parsed; + } + + return value.Trim() switch { + "1" or "on" or "yes" or "enable" or "enabled" => true, + "0" or "off" or "no" or "disable" or "disabled" => false, + _ => defaultValue + }; + } + } + + [Injectable(ServiceLifetime.Transient)] + public class MusicGenerationToolService : IService { + public const string ToolName = "generate_music"; + private const string DefaultOutputFormat = "hex"; + private const int DefaultSampleRate = 44100; + private const int DefaultBitrate = 256000; + private const string DefaultAudioFormat = "mp3"; + private static readonly TimeSpan SendTimeout = TimeSpan.FromSeconds(120); + private static readonly string[] MiniMaxMusicModels = { "music-2.6", "music-cover", "music-2.6-free", "music-cover-free" }; + private static readonly int[] AllowedSampleRates = { 16000, 24000, 32000, 44100 }; + private static readonly int[] AllowedBitrates = { 32000, 64000, 128000, 256000 }; + private static readonly string[] AllowedAudioFormats = { "mp3", "wav", "pcm" }; + + public string ServiceName => "MusicGenerationToolService"; + + private readonly DataDbContext _dbContext; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConnectionMultiplexer _connectionMultiplexer; + private readonly MusicGenerationToolSettingsService _settingsService; + private readonly ITelegramBotClient _botClient; + private readonly SendMessage _sendMessage; + private readonly ILogger _logger; + + public MusicGenerationToolService( + DataDbContext dbContext, + IHttpClientFactory httpClientFactory, + IConnectionMultiplexer connectionMultiplexer, + MusicGenerationToolSettingsService settingsService, + ITelegramBotClient botClient, + SendMessage sendMessage, + ILogger logger) { + _dbContext = dbContext; + _httpClientFactory = httpClientFactory; + _connectionMultiplexer = connectionMultiplexer; + _settingsService = settingsService; + _botClient = botClient; + _sendMessage = sendMessage; + _logger = logger; + } + + [BuiltInTool(@"Generate music through the configured MiniMax music API and optionally send the resulting audio to the current Telegram chat. +The default model is the current chat's configured music generation model, falling back to the bot-wide default music-2.6 when the chat has no music model configured. Supported MiniMax models are music-2.6, music-2.6-free, music-cover, and music-cover-free. The API base URL and API key are read from the configured LLM channel for the selected music model, so administrators can use https://api.minimaxi.com or a compatible gateway. +Use this when the user asks you to compose, generate, create, or cover a song/music track. For text-to-music, provide lyrics unless lyricsOptimizer is true or isInstrumental is true. The tool saves generated audio under the bot work directory and sends it back to the current chat by default.", Name = ToolName)] + public async Task GenerateMusic( + ToolContext toolContext, + [BuiltInParameter("Music description for style, mood, scene, instrumentation, language, vocal style, or arrangement. Required for instrumental tracks and lyricsOptimizer without lyrics. MiniMax limit: 2000 characters for music-2.6, 10-300 for music-cover.", IsRequired = false)] string prompt = null, + [BuiltInParameter("Lyrics separated by \\n. Supports structure tags such as [Intro], [Verse], [Pre Chorus], [Chorus], [Bridge], [Outro], [Hook], [Inst], and [Solo]. Required for non-instrumental text-to-music unless lyricsOptimizer is true.", IsRequired = false)] string lyrics = null, + [BuiltInParameter("MiniMax music model. Defaults to the current chat's configured music generation model, or the bot-wide default music-2.6 when unset. Use music-2.6-free if the account only has free MiniMax music access.", IsRequired = false)] string model = null, + [BuiltInParameter("Whether to generate instrumental music with no vocals. Only supported by music-2.6 and music-2.6-free. Defaults to false.", IsRequired = false)] bool isInstrumental = false, + [BuiltInParameter("Whether MiniMax should generate/optimize lyrics from the prompt. Only supported by music-2.6 and music-2.6-free. If true and lyrics is empty, prompt must describe the song. Defaults to false.", IsRequired = false)] bool lyricsOptimizer = false, + [BuiltInParameter("Output format returned by MiniMax: hex or url. Defaults to hex. URL results are downloaded immediately because MiniMax URLs expire.", IsRequired = false)] string outputFormat = DefaultOutputFormat, + [BuiltInParameter("Audio sample rate: 16000, 24000, 32000, or 44100. Defaults to 44100.", IsRequired = false)] int sampleRate = DefaultSampleRate, + [BuiltInParameter("Audio bitrate: 32000, 64000, 128000, or 256000. Defaults to 256000.", IsRequired = false)] int bitrate = DefaultBitrate, + [BuiltInParameter("Audio encoding format: mp3, wav, or pcm. Defaults to mp3.", IsRequired = false)] string format = DefaultAudioFormat, + [BuiltInParameter("Whether to add the MiniMax AIGC watermark at the end of the audio. Defaults to false.", IsRequired = false)] bool aigcWatermark = false, + [BuiltInParameter("Reference audio URL for music-cover/music-cover-free. For cover models, provide exactly one of audioUrl, audioBase64, or coverFeatureId.", IsRequired = false)] string audioUrl = null, + [BuiltInParameter("Base64 encoded reference audio for music-cover/music-cover-free. For cover models, provide exactly one of audioUrl, audioBase64, or coverFeatureId.", IsRequired = false)] string audioBase64 = null, + [BuiltInParameter("Feature ID returned by MiniMax cover preprocess for two-step cover generation. For cover models, provide exactly one of audioUrl, audioBase64, or coverFeatureId. Lyrics is required when this is used.", IsRequired = false)] string coverFeatureId = null, + [BuiltInParameter("Whether to send generated music to the current Telegram chat. Defaults to true.", IsRequired = false)] bool sendToChat = true, + [BuiltInParameter("Optional Telegram caption for the generated audio/document. Keep it short.", IsRequired = false)] string caption = null, + [BuiltInParameter("Optional Telegram audio title. Defaults to a generated file name.", IsRequired = false)] string title = null, + [BuiltInParameter("Optional Telegram audio performer/artist.", IsRequired = false)] string performer = null, + [BuiltInParameter("Optional Telegram message ID to reply to. Defaults to the original user message.", IsRequired = false)] long? replyToMessageId = null, + [BuiltInParameter("Request timeout in seconds, from 60 to 900. Defaults to 600.", IsRequired = false)] int timeoutSeconds = 600) { + var result = new MusicGenerationResult(); + + try { + if (!await _settingsService.IsToolEnabledAsync()) { + return Failure("Music generation tool is disabled by the administrator."); + } + + var effectiveModel = string.IsNullOrWhiteSpace(model) + ? await _settingsService.GetModelNameAsync(toolContext?.ChatId ?? 0) + : model.Trim(); + var normalizedOutputFormat = NormalizeChoice(outputFormat, "outputFormat", DefaultOutputFormat, "url", "hex"); + var normalizedAudioFormat = NormalizeChoice(format, "format", DefaultAudioFormat, AllowedAudioFormats); + var normalizedTimeoutSeconds = Math.Clamp(timeoutSeconds, 60, 900); + ValidateAudioSetting(sampleRate, bitrate); + + result.Model = effectiveModel; + + var channels = await LoadChannelsAsync(effectiveModel); + if (channels.Count == 0) { + return Failure( + $"No channel is configured for music model '{effectiveModel}'. Add a MiniMax channel and associate model '{effectiveModel}' with it. The channel gateway can be https://api.minimaxi.com or a compatible MiniMax gateway."); + } + + Exception lastException = null; + foreach (var channel in channels) { + if (!await TryAcquireChannelAsync(channel)) { + continue; + } + + try { + var endpoint = BuildMiniMaxMusicGenerationEndpoint(channel.Gateway); + var generated = await RequestMiniMaxMusicAsync( + endpoint, + channel, + effectiveModel, + prompt, + lyrics, + isInstrumental, + lyricsOptimizer, + normalizedOutputFormat, + sampleRate, + bitrate, + normalizedAudioFormat, + aigcWatermark, + audioUrl, + audioBase64, + coverFeatureId, + normalizedTimeoutSeconds); + + result.ChannelId = channel.Id; + result.ChannelName = channel.Name; + result.Endpoint = endpoint; + result.Music = generated.Info; + + if (sendToChat) { + result.SentAudio = await SendGeneratedMusicAsync( + generated.Bytes, + Path.GetFileName(generated.Info.FilePath), + generated.Info, + toolContext, + caption, + title, + performer, + replyToMessageId); + } + + result.Success = true; + if (result.SentAudio != null && !result.SentAudio.Success) { + result.Error = "Generated music file was saved, but Telegram audio send failed."; + } + + return result; + } catch (Exception ex) { + lastException = ex; + _logger.LogWarning(ex, "Music generation failed for channel {ChannelId} ({ChannelName}) and model {Model}.", channel.Id, channel.Name, effectiveModel); + } finally { + await ReleaseChannelAsync(channel); + } + } + + return Failure(lastException == null + ? $"All channels for music model '{effectiveModel}' are currently at capacity." + : $"Music generation failed for all configured channels: {lastException.Message}"); + } catch (Exception ex) { + _logger.LogError(ex, "Music generation tool failed."); + return Failure(ex.Message); + } + + MusicGenerationResult Failure(string error) { + return new MusicGenerationResult { + Success = false, + Error = error, + Model = result.Model + }; + } + } + + public static string BuildMiniMaxMusicGenerationEndpoint(string gateway) { + if (string.IsNullOrWhiteSpace(gateway)) { + throw new ArgumentException("MiniMax music generation channel gateway cannot be empty.", nameof(gateway)); + } + + var normalized = gateway.Trim().TrimEnd('/'); + if (normalized.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) { + return $"{normalized}/music_generation"; + } + + if (normalized.EndsWith("/v1/music_generation", StringComparison.OrdinalIgnoreCase) || + normalized.EndsWith("/music_generation", StringComparison.OrdinalIgnoreCase)) { + return normalized; + } + + return $"{normalized}/v1/music_generation"; + } + + private async Task> LoadChannelsAsync(string modelName) { + var channelIds = await _dbContext.ChannelsWithModel + .AsNoTracking() + .Where(x => x.ModelName == modelName && !x.IsDeleted) + .Select(x => x.LLMChannelId) + .Distinct() + .ToListAsync(); + + if (channelIds.Count == 0) { + return new List(); + } + + return await _dbContext.LLMChannels + .AsNoTracking() + .Where(x => channelIds.Contains(x.Id)) + .OrderByDescending(x => x.Priority) + .ToListAsync(); + } + + private async Task TryAcquireChannelAsync(LLMChannel channel) { + var redisDb = _connectionMultiplexer.GetDatabase(); + var key = GetSemaphoreKey(channel.Id); + var currentCount = await redisDb.StringGetAsync(key); + var current = currentCount.HasValue ? ( int ) currentCount : 0; + var limit = Math.Max(1, channel.Parallel); + if (current >= limit) { + return false; + } + + await redisDb.StringIncrementAsync(key); + return true; + } + + private async Task ReleaseChannelAsync(LLMChannel channel) { + try { + await _connectionMultiplexer.GetDatabase().StringDecrementAsync(GetSemaphoreKey(channel.Id)); + } catch (Exception ex) { + _logger.LogWarning(ex, "Failed to release music generation semaphore for channel {ChannelId}.", channel.Id); + } + } + + private static string GetSemaphoreKey(int channelId) => $"llm:channel:{channelId}:music_generation_semaphore"; + + private async Task RequestMiniMaxMusicAsync( + string endpoint, + LLMChannel channel, + string model, + string prompt, + string lyrics, + bool isInstrumental, + bool lyricsOptimizer, + string outputFormat, + int sampleRate, + int bitrate, + string format, + bool aigcWatermark, + string audioUrl, + string audioBase64, + string coverFeatureId, + int timeoutSeconds) { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); + var requestBody = BuildMiniMaxMusicGenerationRequestBody( + model, + prompt, + lyrics, + isInstrumental, + lyricsOptimizer, + outputFormat, + sampleRate, + bitrate, + format, + aigcWatermark, + audioUrl, + audioBase64, + coverFeatureId); + + var httpClient = _httpClientFactory.CreateClient(); + httpClient.Timeout = Timeout.InfiniteTimeSpan; + + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) { + Content = new StringContent(requestBody.ToString(Formatting.None), Encoding.UTF8, "application/json") + }; + + if (!string.IsNullOrWhiteSpace(channel.ApiKey)) { + request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {channel.ApiKey}"); + } + + using var response = await httpClient.SendAsync(request, cts.Token); + var responseText = await response.Content.ReadAsStringAsync(cts.Token); + if (!response.IsSuccessStatusCode) { + throw new InvalidOperationException($"MiniMax music API returned HTTP {( int ) response.StatusCode}: {ExtractErrorMessage(responseText)}"); + } + + EnsureMiniMaxResponseSucceeded(responseText); + var music = ExtractMusic(responseText, outputFormat, format); + return await SaveMusicAsync(httpClient, music, format, cts.Token); + } + + private async Task SaveMusicAsync( + HttpClient httpClient, + MusicResponseData music, + string requestedFormat, + CancellationToken cancellationToken) { + byte[] bytes; + string contentType; + string extension; + + if (music.Bytes != null) { + bytes = music.Bytes; + contentType = ContentTypeFromAudioFormat(requestedFormat); + extension = ExtensionFromContentType(contentType) ?? ExtensionFromAudioFormat(requestedFormat); + } else if (!string.IsNullOrWhiteSpace(music.Url)) { + using var response = await httpClient.GetAsync(music.Url, cancellationToken); + if (!response.IsSuccessStatusCode) { + throw new InvalidOperationException($"Unable to download generated music: HTTP {( int ) response.StatusCode}."); + } + + bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + contentType = response.Content.Headers.ContentType?.MediaType ?? ContentTypeFromAudioFormat(requestedFormat); + extension = ExtensionFromContentType(contentType) + ?? ExtensionFromUrl(music.Url) + ?? ExtensionFromAudioFormat(requestedFormat); + } else { + throw new InvalidOperationException("The MiniMax music API response did not include audio data."); + } + + var directory = Path.Combine(Env.WorkDir, "GeneratedMusic", DateTimeOffset.Now.ToString("yyyyMMdd", CultureInfo.InvariantCulture)); + Directory.CreateDirectory(directory); + var filePath = Path.Combine(directory, $"{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}_{Guid.NewGuid():N}{extension}"); + await File.WriteAllBytesAsync(filePath, bytes, cancellationToken); + + return new GeneratedMusicPayload( + bytes, + new GeneratedMusicInfo { + FilePath = filePath, + FileSizeBytes = bytes.LongLength, + ContentType = contentType, + DurationMilliseconds = music.DurationMilliseconds, + SampleRate = music.SampleRate, + Channels = music.Channels, + Bitrate = music.Bitrate + }); + } + + private async Task SendGeneratedMusicAsync( + byte[] audioBytes, + string fileName, + GeneratedMusicInfo info, + ToolContext toolContext, + string caption, + string title, + string performer, + long? replyToMessageId) { + try { + if (toolContext == null || toolContext.ChatId == 0) { + return new SendAudioResult { + Success = false, + Error = "Cannot send generated music because chat context is missing." + }; + } + + long maxFileSizeBytes = Env.IsLocalAPI + ? 2L * 1024 * 1024 * 1024 + : 50 * 1024 * 1024; + if (audioBytes.LongLength > maxFileSizeBytes) { + return new SendAudioResult { + Success = false, + ChatId = toolContext.ChatId, + Error = $"Generated music is too large for Telegram upload ({audioBytes.LongLength / 1024 / 1024}MB). Maximum allowed size is {( Env.IsLocalAPI ? "2GB" : "50MB" )}." + }; + } + + var replyParameters = GetReplyParameters(replyToMessageId, toolContext); + var safeCaption = string.IsNullOrWhiteSpace(caption) + ? null + : caption.Length > 1024 ? caption.Substring(0, 1024) : caption; + var safeTitle = string.IsNullOrWhiteSpace(title) + ? Path.GetFileNameWithoutExtension(fileName) + : title.Trim(); + var safePerformer = string.IsNullOrWhiteSpace(performer) ? null : performer.Trim(); + + using var cts = new CancellationTokenSource(SendTimeout); + if (IsTelegramAudioContentType(info.ContentType)) { + var audio = InputFile.FromStream(new MemoryStream(audioBytes), fileName); + var message = await _sendMessage.AddTaskWithResult(async () => await _botClient.SendAudio( + chatId: toolContext.ChatId, + audio: audio, + caption: safeCaption, + parseMode: ParseMode.Html, + duration: info.DurationMilliseconds.HasValue ? info.DurationMilliseconds.Value / 1000 : null, + performer: safePerformer, + title: safeTitle, + replyParameters: replyParameters, + cancellationToken: cts.Token + ), toolContext.ChatId); + + return new SendAudioResult { + Success = true, + MessageId = message.MessageId, + ChatId = message.Chat.Id + }; + } + + var document = InputFile.FromStream(new MemoryStream(audioBytes), fileName); + var sentDocument = await _sendMessage.AddTaskWithResult(async () => await _botClient.SendDocument( + chatId: toolContext.ChatId, + document: document, + caption: safeCaption, + parseMode: ParseMode.Html, + replyParameters: replyParameters, + cancellationToken: cts.Token + ), toolContext.ChatId); + + return new SendAudioResult { + Success = true, + MessageId = sentDocument.MessageId, + ChatId = sentDocument.Chat.Id, + SentAsDocument = true + }; + } catch (Exception ex) { + return new SendAudioResult { + Success = false, + ChatId = toolContext?.ChatId ?? 0, + Error = $"Failed to send generated music: {ex.Message}" + }; + } + } + + private static ReplyParameters GetReplyParameters(long? explicitReplyToMessageId, ToolContext toolContext) { + long? messageId = explicitReplyToMessageId ?? ( toolContext.MessageId != 0 ? toolContext.MessageId : ( long? ) null ); + return messageId.HasValue ? new ReplyParameters { MessageId = ( int ) messageId.Value } : null; + } + + internal static JObject BuildMiniMaxMusicGenerationRequestBody( + string model, + string prompt, + string lyrics, + bool isInstrumental, + bool lyricsOptimizer, + string outputFormat, + int sampleRate, + int bitrate, + string format, + bool aigcWatermark, + string audioUrl, + string audioBase64, + string coverFeatureId) { + var normalizedModel = string.IsNullOrWhiteSpace(model) + ? MusicGenerationToolSettingsService.DefaultModelName + : model.Trim(); + if (!IsMiniMaxMusicModel(normalizedModel)) { + throw new ArgumentException($"MiniMax music generation model must be one of: {string.Join(", ", MiniMaxMusicModels)}.", nameof(model)); + } + + var normalizedOutputFormat = NormalizeChoice(outputFormat, "outputFormat", DefaultOutputFormat, "url", "hex"); + var normalizedFormat = NormalizeChoice(format, "format", DefaultAudioFormat, AllowedAudioFormats); + ValidateAudioSetting(sampleRate, bitrate); + + var isCoverModel = IsMiniMaxCoverModel(normalizedModel); + var requestBody = new JObject { + ["model"] = normalizedModel, + ["output_format"] = normalizedOutputFormat, + ["audio_setting"] = new JObject { + ["sample_rate"] = sampleRate, + ["bitrate"] = bitrate, + ["format"] = normalizedFormat + }, + ["aigc_watermark"] = aigcWatermark + }; + + if (isCoverModel) { + AppendCoverRequestFields(requestBody, prompt, lyrics, audioUrl, audioBase64, coverFeatureId, isInstrumental, lyricsOptimizer); + } else { + AppendTextToMusicRequestFields(requestBody, prompt, lyrics, isInstrumental, lyricsOptimizer); + } + + return requestBody; + } + + internal static void EnsureMiniMaxResponseSucceeded(string responseText) { + if (string.IsNullOrWhiteSpace(responseText)) { + throw new InvalidOperationException("MiniMax music API returned an empty response body."); + } + + var token = JToken.Parse(responseText); + var statusToken = token.SelectToken("$.base_resp.status_code"); + if (statusToken == null || statusToken.Type == JTokenType.Null) { + return; + } + + var statusCode = statusToken.Value(); + if (statusCode == 0) { + return; + } + + var statusMessage = token.SelectToken("$.base_resp.status_msg")?.Value(); + throw new InvalidOperationException($"MiniMax music API returned status {statusCode}: {statusMessage ?? "unknown error"}"); + } + + internal static MusicResponseData ExtractMusic(string responseText, string outputFormat, string requestedFormat) { + if (string.IsNullOrWhiteSpace(responseText)) { + throw new InvalidOperationException("MiniMax music API returned an empty response body."); + } + + var token = JToken.Parse(responseText); + var status = token.SelectToken("$.data.status")?.Value(); + if (status == 1) { + throw new InvalidOperationException("MiniMax music API reports that synthesis is still in progress and did not return completed audio."); + } + + var audio = token.SelectToken("$.data.audio")?.Value()?.Trim(); + if (string.IsNullOrWhiteSpace(audio)) { + throw new InvalidOperationException("MiniMax music API response did not include data.audio."); + } + + byte[] bytes = null; + string url = null; + if (IsHttpUrl(audio)) { + url = audio; + } else if (LooksLikeHex(audio) || string.Equals(outputFormat, "hex", StringComparison.OrdinalIgnoreCase)) { + bytes = HexToBytes(audio); + } else { + url = audio; + } + + return new MusicResponseData( + bytes, + url, + token.SelectToken("$.extra_info.music_duration")?.Value(), + token.SelectToken("$.extra_info.music_sample_rate")?.Value(), + token.SelectToken("$.extra_info.music_channel")?.Value(), + token.SelectToken("$.extra_info.bitrate")?.Value(), + requestedFormat); + } + + private static void AppendTextToMusicRequestFields(JObject requestBody, string prompt, string lyrics, bool isInstrumental, bool lyricsOptimizer) { + var trimmedPrompt = prompt?.Trim(); + var trimmedLyrics = lyrics?.Trim(); + + if (isInstrumental || ( lyricsOptimizer && string.IsNullOrWhiteSpace(trimmedLyrics) )) { + ValidateTextLength(trimmedPrompt, "prompt", 1, 2000); + } else if (!string.IsNullOrWhiteSpace(trimmedPrompt)) { + ValidateTextLength(trimmedPrompt, "prompt", 0, 2000); + } + + if (!isInstrumental && !lyricsOptimizer && string.IsNullOrWhiteSpace(trimmedLyrics)) { + throw new ArgumentException("Lyrics are required for non-instrumental music-2.6 generation unless lyricsOptimizer is true.", nameof(lyrics)); + } + + if (!string.IsNullOrWhiteSpace(trimmedLyrics)) { + ValidateTextLength(trimmedLyrics, "lyrics", 1, 3500); + requestBody["lyrics"] = trimmedLyrics; + } + + if (!string.IsNullOrWhiteSpace(trimmedPrompt)) { + requestBody["prompt"] = trimmedPrompt; + } + + requestBody["lyrics_optimizer"] = lyricsOptimizer; + requestBody["is_instrumental"] = isInstrumental; + } + + private static void AppendCoverRequestFields( + JObject requestBody, + string prompt, + string lyrics, + string audioUrl, + string audioBase64, + string coverFeatureId, + bool isInstrumental, + bool lyricsOptimizer) { + if (isInstrumental) { + throw new ArgumentException("isInstrumental is not supported by music-cover models.", nameof(isInstrumental)); + } + + if (lyricsOptimizer) { + throw new ArgumentException("lyricsOptimizer is not supported by music-cover models.", nameof(lyricsOptimizer)); + } + + var trimmedPrompt = prompt?.Trim(); + ValidateTextLength(trimmedPrompt, "prompt", 10, 300); + requestBody["prompt"] = trimmedPrompt; + + var sources = new[] { + !string.IsNullOrWhiteSpace(audioUrl), + !string.IsNullOrWhiteSpace(audioBase64), + !string.IsNullOrWhiteSpace(coverFeatureId) + }.Count(x => x); + if (sources != 1) { + throw new ArgumentException("For music-cover models, provide exactly one of audioUrl, audioBase64, or coverFeatureId."); + } + + var trimmedLyrics = lyrics?.Trim(); + if (!string.IsNullOrWhiteSpace(coverFeatureId)) { + ValidateTextLength(trimmedLyrics, "lyrics", 10, 1000); + requestBody["cover_feature_id"] = coverFeatureId.Trim(); + requestBody["lyrics"] = trimmedLyrics; + return; + } + + if (!string.IsNullOrWhiteSpace(audioUrl)) { + requestBody["audio_url"] = audioUrl.Trim(); + } else { + requestBody["audio_base64"] = audioBase64.Trim(); + } + + if (!string.IsNullOrWhiteSpace(trimmedLyrics)) { + ValidateTextLength(trimmedLyrics, "lyrics", 10, 1000); + requestBody["lyrics"] = trimmedLyrics; + } + } + + private static void ValidateTextLength(string value, string name, int minLength, int maxLength) { + var length = value?.Length ?? 0; + if (length < minLength || length > maxLength) { + throw new ArgumentException($"{name} length must be between {minLength} and {maxLength} characters.", name); + } + } + + private static void ValidateAudioSetting(int sampleRate, int bitrate) { + if (!AllowedSampleRates.Contains(sampleRate)) { + throw new ArgumentException($"sampleRate must be one of: {string.Join(", ", AllowedSampleRates)}.", nameof(sampleRate)); + } + + if (!AllowedBitrates.Contains(bitrate)) { + throw new ArgumentException($"bitrate must be one of: {string.Join(", ", AllowedBitrates)}.", nameof(bitrate)); + } + } + + private static bool IsMiniMaxMusicModel(string modelName) { + return MiniMaxMusicModels.Contains(modelName?.Trim() ?? string.Empty, StringComparer.OrdinalIgnoreCase); + } + + private static bool IsMiniMaxCoverModel(string modelName) { + return modelName?.Trim().Equals("music-cover", StringComparison.OrdinalIgnoreCase) == true || + modelName?.Trim().Equals("music-cover-free", StringComparison.OrdinalIgnoreCase) == true; + } + + private static string NormalizeChoice(string value, string parameterName, string defaultValue, params string[] allowed) { + var normalized = string.IsNullOrWhiteSpace(value) ? defaultValue : value.Trim().ToLowerInvariant(); + if (!allowed.Contains(normalized, StringComparer.OrdinalIgnoreCase)) { + throw new ArgumentException($"{parameterName} must be one of: {string.Join(", ", allowed)}."); + } + + return normalized; + } + + private static string ExtractErrorMessage(string responseText) { + if (string.IsNullOrWhiteSpace(responseText)) { + return "empty response body"; + } + + try { + var token = JToken.Parse(responseText); + var message = token.SelectToken("$.error.message")?.Value() + ?? token.SelectToken("$.base_resp.status_msg")?.Value() + ?? token.SelectToken("$.message")?.Value(); + if (!string.IsNullOrWhiteSpace(message)) { + return message; + } + } catch { + // fall through to truncated raw body + } + + return responseText.Length <= 2000 ? responseText : responseText.Substring(0, 2000); + } + + private static bool LooksLikeHex(string value) { + if (string.IsNullOrWhiteSpace(value)) { + return false; + } + + var normalized = NormalizeHex(value); + return normalized.Length > 0 && + normalized.Length % 2 == 0 && + Regex.IsMatch(normalized, "^[0-9a-fA-F]+$", RegexOptions.CultureInvariant); + } + + private static byte[] HexToBytes(string value) { + var normalized = NormalizeHex(value); + if (normalized.Length == 0 || normalized.Length % 2 != 0 || !Regex.IsMatch(normalized, "^[0-9a-fA-F]+$", RegexOptions.CultureInvariant)) { + throw new InvalidOperationException("MiniMax music API returned invalid hex audio data."); + } + + var bytes = new byte[normalized.Length / 2]; + for (var i = 0; i < bytes.Length; i++) { + bytes[i] = Convert.ToByte(normalized.Substring(i * 2, 2), 16); + } + + return bytes; + } + + private static string NormalizeHex(string value) { + var normalized = Regex.Replace(value.Trim(), @"\s+", string.Empty); + return normalized.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? normalized.Substring(2) : normalized; + } + + private static bool IsHttpUrl(string value) { + return Uri.TryCreate(value, UriKind.Absolute, out var uri) && + ( uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || + uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) ); + } + + private static bool IsTelegramAudioContentType(string contentType) { + var normalized = contentType?.Trim().ToLowerInvariant(); + return normalized == "audio/mpeg" || + normalized == "audio/mp3" || + normalized == "audio/mp4" || + normalized == "audio/x-m4a"; + } + + private static string ContentTypeFromAudioFormat(string format) { + return format?.Trim().ToLowerInvariant() switch { + "wav" => "audio/wav", + "pcm" => "audio/L16", + _ => "audio/mpeg" + }; + } + + private static string ExtensionFromAudioFormat(string format) { + return format?.Trim().ToLowerInvariant() switch { + "wav" => ".wav", + "pcm" => ".pcm", + _ => ".mp3" + }; + } + + private static string ExtensionFromContentType(string contentType) { + return contentType?.Trim().ToLowerInvariant() switch { + "audio/mpeg" or "audio/mp3" => ".mp3", + "audio/wav" or "audio/wave" or "audio/x-wav" => ".wav", + "audio/l16" or "audio/pcm" => ".pcm", + _ => null + }; + } + + private static string ExtensionFromUrl(string url) { + try { + var extension = Path.GetExtension(new Uri(url).AbsolutePath); + return extension?.ToLowerInvariant() switch { + ".mp3" => ".mp3", + ".wav" or ".wave" => ".wav", + ".pcm" => ".pcm", + _ => null + }; + } catch { + return null; + } + } + + internal sealed record MusicResponseData( + byte[] Bytes, + string Url, + int? DurationMilliseconds, + int? SampleRate, + int? Channels, + int? Bitrate, + string RequestedFormat); + + private sealed record GeneratedMusicPayload(byte[] Bytes, GeneratedMusicInfo Info); + } +} From 338d255106ff4644aef5bd60e5578f63f19df21c Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Sun, 31 May 2026 19:16:37 +0800 Subject: [PATCH 2/2] Address music generation review feedback --- Docs/README_MCP.md | 2 +- .../Service/AI/LLM/ModelCapabilityService.cs | 7 +---- .../Tools/MusicGenerationToolService.cs | 26 +++++++++++++------ 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/Docs/README_MCP.md b/Docs/README_MCP.md index 3c8cac7d..89f0b04a 100644 --- a/Docs/README_MCP.md +++ b/Docs/README_MCP.md @@ -86,7 +86,7 @@ MiniMax 不支持选择输出文件格式;`output_format` 仅用于 OpenAI-com API 地址和 API Key 来自该模型关联的 LLM 渠道,因此可通过 `新建渠道` / `编辑渠道` 自定义网关地址,例如 `https://api.minimaxi.com`。配置 MiniMax 时,渠道类型建议选择 `MiniMax`,再通过 `添加模型` 关联音乐模型。 -`music-2.6` / `music-2.6-free` 支持文生音乐和纯音乐;非纯音乐默认需要传 `lyrics`,除非 `lyrics_optimizer` 为 true。`music-cover` / `music-cover-free` 支持基于 `audio_url`、`audio_base64` 或预处理得到的 `cover_feature_id` 生成翻唱。 +`music-2.6` / `music-2.6-free` 支持文生音乐和纯音乐;非纯音乐默认需要传 `lyrics`,除非 `lyricsOptimizer` 为 true。`music-cover` / `music-cover-free` 支持基于 `audioUrl`、`audioBase64` 或预处理得到的 `coverFeatureId` 生成翻唱。 **文件大小限制**: - 本地 Bot API(内置或外部): 最大 2GB diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/ModelCapabilityService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/ModelCapabilityService.cs index 8528ee23..2e629f5b 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/ModelCapabilityService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/ModelCapabilityService.cs @@ -190,12 +190,7 @@ private static bool IsMusicGenerationModelRecord(ChannelWithModel model) { return model.Capabilities.Any(cap => ( cap.CapabilityName == "music_generation" || cap.CapabilityName == "text_to_music" ) && string.Equals(cap.CapabilityValue, "true", StringComparison.OrdinalIgnoreCase)) || - ModelWithCapabilities.IsKnownMusicGenerationModelName(model.ModelName) || - ( model.LLMChannel?.Provider == LLMProvider.MiniMax && - ( model.ModelName == "music-2.6" || - model.ModelName == "music-2.6-free" || - model.ModelName == "music-cover" || - model.ModelName == "music-cover-free" ) ); + ModelWithCapabilities.IsKnownMusicGenerationModelName(model.ModelName); } /// diff --git a/TelegramSearchBot/Service/Tools/MusicGenerationToolService.cs b/TelegramSearchBot/Service/Tools/MusicGenerationToolService.cs index 8e178e49..e70950a0 100644 --- a/TelegramSearchBot/Service/Tools/MusicGenerationToolService.cs +++ b/TelegramSearchBot/Service/Tools/MusicGenerationToolService.cs @@ -225,6 +225,19 @@ public class MusicGenerationToolService : IService { private static readonly int[] AllowedSampleRates = { 16000, 24000, 32000, 44100 }; private static readonly int[] AllowedBitrates = { 32000, 64000, 128000, 256000 }; private static readonly string[] AllowedAudioFormats = { "mp3", "wav", "pcm" }; + private const string AcquireChannelSemaphoreScript = @" +local key = KEYS[1] +local limit = tonumber(ARGV[1]) +local current = tonumber(redis.call('GET', key) or '0') +if current < 0 then + redis.call('SET', key, 0) + current = 0 +end +if current < limit then + redis.call('INCR', key) + return 1 +end +return 0"; public string ServiceName => "MusicGenerationToolService"; @@ -414,15 +427,12 @@ private async Task> LoadChannelsAsync(string modelName) { private async Task TryAcquireChannelAsync(LLMChannel channel) { var redisDb = _connectionMultiplexer.GetDatabase(); var key = GetSemaphoreKey(channel.Id); - var currentCount = await redisDb.StringGetAsync(key); - var current = currentCount.HasValue ? ( int ) currentCount : 0; var limit = Math.Max(1, channel.Parallel); - if (current >= limit) { - return false; - } - - await redisDb.StringIncrementAsync(key); - return true; + var result = await redisDb.ScriptEvaluateAsync( + AcquireChannelSemaphoreScript, + new RedisKey[] { key }, + new RedisValue[] { limit }); + return ( int ) result == 1; } private async Task ReleaseChannelAsync(LLMChannel channel) {