diff --git a/Docs/Bot_Commands_User_Guide.md b/Docs/Bot_Commands_User_Guide.md
index e9d7543c..1e7c2772 100644
--- a/Docs/Bot_Commands_User_Guide.md
+++ b/Docs/Bot_Commands_User_Guide.md
@@ -49,6 +49,13 @@
* **示例**: `设置模型 gpt-4-turbo`
* **权限**: 普通管理员或全局管理员。
+* **设置当前聊天使用的生图模型**
+ * **指令格式**: `设置生图模型 <模型名称>`
+ * **功能**: 为当前聊天(群组或私聊)设置 `generate_image` 默认使用的图片生成模型;未设置时使用全局默认生图模型。
+ * **示例**: `设置生图模型 gpt-image-2`,或 `设置生图模型 image-01`
+ * **权限**: 普通管理员或全局管理员。
+ * **相关指令**: `选择生图模型` 显示可用生图模型并通过编号选择,`生图模型` / `查看生图模型` 查看当前生图模型,`清除生图模型` 恢复使用全局默认。
+
### 2. 全局管理员指令
以下指令仅限机器人全局管理员(在 `Env.cs` 中配置的 `AdminId`)使用。
@@ -195,11 +202,11 @@
2. 机器人回复: `生图工具已开启...`
3. 管理员发送: `关闭生图工具`
4. 机器人回复: `生图工具已关闭,并会从 LLM 工具提示词中隐藏。`
- 5. 管理员发送: `设置生图模型`
- 6. 机器人回复: `请输入生图使用的模型名称...`
- 7. 管理员发送: `gpt-image-2`
- 8. 机器人回复: `生图模型已设置为: gpt-image-2...`
- * **说明**: 生图工具使用内置工具 `generate_image`,默认模型为 `gpt-image-2`。API 地址和 API Key 不在工具参数中填写,而是来自该模型关联的 LLM 渠道;因此可通过 `新建渠道` / `编辑渠道` 自定义 OpenAI-compatible API 地址,例如 `https://api.openai.com/v1` 或自建兼容网关。
+ 5. 管理员发送: `设置默认生图模型`
+ 6. 机器人回复: `请输入没有群级配置时使用的默认生图模型名称...`
+ 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` 或自建兼容网关。
* **指令**: `新建渠道`
* **功能**: 开始创建新的LLM(大语言模型)API渠道的流程,包括名称、网关地址、API类型 (OpenAI/Ollama)、API Key、最大并行数和优先级。
* **指令**: `编辑渠道`
@@ -220,8 +227,8 @@
* **功能**: 设置用于图片分析的LLM模型名称。此过程会询问模型名称。
* **指令**: `开启生图工具` / `关闭生图工具` / `生图工具状态`
* **功能**: 开启、关闭或查看 LLM 生图内置工具。关闭后工具不会注入到 LLM 工具提示词或 native tool definitions 中。
- * **指令**: `设置生图模型`
- * **功能**: 设置生图工具默认使用的模型名称,默认 `gpt-image-2`。需要将该模型通过 `添加模型` 关联到一个带自定义 API 地址的 LLM 渠道。
+ * **指令**: `设置默认生图模型`
+ * **功能**: 设置没有群级生图模型配置时使用的全局默认模型,内置默认 `gpt-image-2`;MiniMax 可用 `image-01` / `image-01-live`。需要将该模型通过 `添加模型` 关联到一个带自定义 API 地址的 LLM 渠道。
* **MCP服务器管理** (在群组中由全局管理员发送,会触发交互式配置流程)
* **说明**: 以下指令用于管理MCP (Model Context Protocol) 服务器。它们会启动一个多步骤的对话流程(状态机),机器人会依次询问配置所需的各项信息。请按照机器人的提示逐步回复。
diff --git a/Docs/README_MCP.md b/Docs/README_MCP.md
index 7c6da094..06a853f8 100644
--- a/Docs/README_MCP.md
+++ b/Docs/README_MCP.md
@@ -54,15 +54,20 @@ TelegramSearchBot 内置了以下工具,通过 `BuiltInToolAttribute` 标记
| 工具名称 | 描述 | 参数 |
|---------|------|------|
-| `generate_image` | 通过 OpenAI-compatible Image API 生成图片,默认模型 `gpt-image-2`,保存到本地并可直接发送到当前聊天 | `prompt`, `model?`, `size?`, `quality?`, `output_format?`, `background?`, `moderation?`, `count?`, `send_to_chat?`, `caption?`, `reply_to_message_id?`, `timeout_seconds?` |
+| `generate_image` | 通过配置的图片 API 生成图片,默认使用当前群的生图模型;未配置时使用全局默认 `gpt-image-2`;OpenAI-compatible 模型走 `/v1/images/generations`,MiniMax `image-01` / `image-01-live` 走 `/v1/image_generation`;图片保存到本地并可直接发送到当前聊天 | `prompt`, `model?`, `size?`, `quality?`, `output_format?`, `background?`, `moderation?`, `aspect_ratio?`, `minimax_response_format?`, `seed?`, `prompt_optimizer?`, `aigc_watermark?`, `style_type?`, `style_weight?`, `count?`, `send_to_chat?`, `caption?`, `reply_to_message_id?`, `timeout_seconds?` |
管理员命令:
- `开启生图工具`:允许 `generate_image` 注入到 LLM 工具提示词和 native tool definitions。
- `关闭生图工具`:隐藏 `generate_image`,关闭后 LLM 不会看到该工具。
- `生图工具状态`:查看开关状态和默认生图模型。
-- `设置生图模型`:设置默认生图模型,默认 `gpt-image-2`。
+- `设置默认生图模型`:设置无群级配置时使用的全局默认生图模型,内置默认 `gpt-image-2`;MiniMax 可设置为 `image-01` 或 `image-01-live`。
+- 群内普通管理员可发送 `选择生图模型` 从已识别的图片生成模型里按编号选择,也可用 `设置生图模型 <模型名>` 直接设置当前群的生图模型,或用 `清除生图模型` 恢复使用全局默认。
-API 地址和 API Key 来自该模型关联的 LLM 渠道,因此可通过 `新建渠道` / `编辑渠道` 自定义 OpenAI-compatible 网关地址,例如 `https://api.openai.com/v1`。
+生图模型识别优先使用模型能力 `image_generation` / `text_to_image`;没有能力数据时,会按常见模型名兜底识别 `gpt-image-*`、`dall-e*`、MiniMax `image-01` / `image-01-live`、`imagen`、`flux`、`stable-diffusion`、`sdxl` 等名称。
+
+API 地址和 API Key 来自该模型关联的 LLM 渠道,因此可通过 `新建渠道` / `编辑渠道` 自定义网关地址,例如 `https://api.openai.com/v1` 或 `https://api.minimaxi.com`。配置 MiniMax 时,渠道类型建议选择 `MiniMax`,再通过 `添加模型` 关联 `image-01` / `image-01-live`。
+
+MiniMax 不支持选择输出文件格式;`output_format` 仅用于 OpenAI-compatible 图片接口。MiniMax 的 URL 响应会按实际 Content-Type 或 URL 扩展名保存,base64 响应默认按 PNG 保存。
**文件大小限制**:
- 本地 Bot API(内置或外部): 最大 2GB
diff --git a/README.md b/README.md
index 3dc6c51c..d0e83dc9 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- Anthropic Claude API
- 可配置多模型通道管理
- **MCP (Model Context Protocol) 工具支持**
- - **GPT Image 生图工具**: 内置 `generate_image`,默认 `gpt-image-2`,API 地址可通过 LLM 渠道自定义
+ - **生图工具**: 内置 `generate_image`,按当前群配置选择生图模型,未配置时默认 `gpt-image-2`,支持 OpenAI-compatible Image API 与 MiniMax `image-01` / `image-01-live`,API 地址可通过 LLM 渠道自定义
5. 高级功能:
- 短链接映射服务
- 消息扩展存储
@@ -160,7 +160,7 @@
## MCP (Model Context Protocol) 支持
通过MCP协议扩展机器人能力,支持外部工具服务器:
- ✅ **内置工具**: 发送文件、搜索、URL处理等24+内置工具
-- ✅ **内置生图**: 管理员可用 `开启生图工具` / `关闭生图工具` 控制是否向 LLM 注入 `generate_image`,默认模型 `gpt-image-2`
+- ✅ **内置生图**: 管理员可用 `开启生图工具` / `关闭生图工具` 控制是否向 LLM 注入 `generate_image`,群内可用 `选择生图模型` 或 `设置生图模型 <模型名>` 配置当前群生图模型,未配置时使用全局默认 `gpt-image-2`
- ✅ **外部MCP服务器**: 可动态添加第三方MCP服务器
- ✅ **管理员管理**: 通过指令管理MCP服务器(`新建渠道`等)
diff --git a/TelegramSearchBot.Common/Model/AI/ModelWithCapabilities.cs b/TelegramSearchBot.Common/Model/AI/ModelWithCapabilities.cs
index d6afc669..03abc5ad 100644
--- a/TelegramSearchBot.Common/Model/AI/ModelWithCapabilities.cs
+++ b/TelegramSearchBot.Common/Model/AI/ModelWithCapabilities.cs
@@ -44,6 +44,11 @@ public class ModelWithCapabilities {
///
public bool SupportsEmbedding => GetCapabilityBool("embedding") || GetCapabilityBool("text_embedding");
+ ///
+ /// 是否是图片生成模型
+ ///
+ public bool SupportsImageGeneration => GetCapabilityBool("image_generation") || GetCapabilityBool("text_to_image") || IsKnownImageGenerationModelName(ModelName);
+
///
/// 获取布尔类型的能力值
///
@@ -74,5 +79,21 @@ public void SetCapability(string capabilityName, string value) {
public void SetCapability(string capabilityName, bool value) {
Capabilities[capabilityName] = value.ToString().ToLower();
}
+
+ public static bool IsKnownImageGenerationModelName(string modelName) {
+ if (string.IsNullOrWhiteSpace(modelName)) {
+ return false;
+ }
+
+ var lowerName = modelName.Trim().ToLowerInvariant();
+ return lowerName.Contains("gpt-image") ||
+ lowerName.Contains("dall-e") ||
+ lowerName.Equals("image-01") ||
+ lowerName.Equals("image-01-live") ||
+ lowerName.Contains("imagen") ||
+ lowerName.Contains("flux") ||
+ lowerName.Contains("stable-diffusion") ||
+ lowerName.Contains("sdxl");
+ }
}
}
diff --git a/TelegramSearchBot.Database/Migrations/20260531041305_AddGroupImageGenerationModel.Designer.cs b/TelegramSearchBot.Database/Migrations/20260531041305_AddGroupImageGenerationModel.Designer.cs
new file mode 100644
index 00000000..0f87a1e3
--- /dev/null
+++ b/TelegramSearchBot.Database/Migrations/20260531041305_AddGroupImageGenerationModel.Designer.cs
@@ -0,0 +1,881 @@
+//
+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("20260531041305_AddGroupImageGenerationModel")]
+ partial class AddGroupImageGenerationModel
+ {
+ ///
+ 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.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/20260531041305_AddGroupImageGenerationModel.cs b/TelegramSearchBot.Database/Migrations/20260531041305_AddGroupImageGenerationModel.cs
new file mode 100644
index 00000000..7b474af9
--- /dev/null
+++ b/TelegramSearchBot.Database/Migrations/20260531041305_AddGroupImageGenerationModel.cs
@@ -0,0 +1,28 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace TelegramSearchBot.Migrations
+{
+ ///
+ public partial class AddGroupImageGenerationModel : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "ImageGenerationModelName",
+ table: "GroupSettings",
+ type: "TEXT",
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "ImageGenerationModelName",
+ table: "GroupSettings");
+ }
+ }
+}
diff --git a/TelegramSearchBot.Database/Migrations/DataDbContextModelSnapshot.cs b/TelegramSearchBot.Database/Migrations/DataDbContextModelSnapshot.cs
index 8cc70c8d..e45b6588 100644
--- a/TelegramSearchBot.Database/Migrations/DataDbContextModelSnapshot.cs
+++ b/TelegramSearchBot.Database/Migrations/DataDbContextModelSnapshot.cs
@@ -303,6 +303,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property("GroupId")
.HasColumnType("INTEGER");
+ b.Property("ImageGenerationModelName")
+ .HasColumnType("TEXT");
+
b.Property("IsManagerGroup")
.HasColumnType("INTEGER");
diff --git a/TelegramSearchBot.Database/Model/Data/GroupSettings.cs b/TelegramSearchBot.Database/Model/Data/GroupSettings.cs
index 7093398a..dee7036a 100644
--- a/TelegramSearchBot.Database/Model/Data/GroupSettings.cs
+++ b/TelegramSearchBot.Database/Model/Data/GroupSettings.cs
@@ -15,6 +15,7 @@ public class GroupSettings {
[Required]
public long GroupId { get; set; }
public string LLMModelName { get; set; }
+ public string ImageGenerationModelName { get; set; }
///
/// 是否是有管理员权限的群,是的所有群友都可以作为管理员操作一部分功能
///
diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelCapabilityServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelCapabilityServiceTests.cs
index da4ef579..e4ce2a91 100644
--- a/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelCapabilityServiceTests.cs
+++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelCapabilityServiceTests.cs
@@ -225,6 +225,68 @@ public async Task GetEmbeddingModels_ReturnsCorrectModels() {
Assert.Equal("text-embedding-3-small", result[0].ModelName);
}
+ [Fact]
+ public async Task GetImageGenerationModels_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-image-model",
+ LLMChannelId = channel.Id,
+ Capabilities = new List {
+ new ModelCapability {
+ CapabilityName = "image_generation",
+ CapabilityValue = "true",
+ LastUpdated = DateTime.UtcNow
+ }
+ }
+ },
+ new ChannelWithModel {
+ ModelName = "gpt-image-2",
+ LLMChannelId = channel.Id
+ },
+ new ChannelWithModel {
+ ModelName = "image-01",
+ 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.GetImageGenerationModels() )
+ .Select(x => x.ModelName)
+ .OrderBy(x => x)
+ .ToList();
+
+ Assert.Equal(new[] { "custom-image-model", "gpt-image-2", "image-01" }, 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 5f61a386..d10f65f0 100644
--- a/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelWithCapabilitiesTests.cs
+++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelWithCapabilitiesTests.cs
@@ -77,6 +77,24 @@ public void SupportsEmbedding_TextEmbedding_ReturnsTrue() {
Assert.True(model.SupportsEmbedding);
}
+ [Fact]
+ public void SupportsImageGeneration_CapabilitySet_ReturnsTrue() {
+ var model = new ModelWithCapabilities { ModelName = "test" };
+ model.SetCapability("image_generation", true);
+ Assert.True(model.SupportsImageGeneration);
+ }
+
+ [Theory]
+ [InlineData("gpt-image-2")]
+ [InlineData("dall-e-3")]
+ [InlineData("image-01")]
+ [InlineData("flux-pro")]
+ [InlineData("stable-diffusion-xl")]
+ public void SupportsImageGeneration_KnownName_ReturnsTrue(string modelName) {
+ var model = new ModelWithCapabilities { ModelName = modelName };
+ Assert.True(model.SupportsImageGeneration);
+ }
+
[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 037353a3..85c550aa 100644
--- a/TelegramSearchBot.LLM/Interface/IModelCapabilityService.cs
+++ b/TelegramSearchBot.LLM/Interface/IModelCapabilityService.cs
@@ -44,6 +44,11 @@ public interface IModelCapabilityService {
///
Task> GetEmbeddingModels();
+ ///
+ /// 获取图片生成模型
+ ///
+ Task> GetImageGenerationModels();
+
///
/// 删除过期的模型能力信息
///
diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/ModelCapabilityService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/ModelCapabilityService.cs
index bceacf93..6f4c9d78 100644
--- a/TelegramSearchBot.LLM/Service/AI/LLM/ModelCapabilityService.cs
+++ b/TelegramSearchBot.LLM/Service/AI/LLM/ModelCapabilityService.cs
@@ -151,6 +151,28 @@ public async Task> GetEmbeddingModels() {
return await GetModelsByCapability("embedding", "true");
}
+ ///
+ /// 获取图片生成模型
+ ///
+ public async Task> GetImageGenerationModels() {
+ var models = await _dbContext.ChannelsWithModel
+ .Include(c => c.LLMChannel)
+ .Include(c => c.Capabilities)
+ .Where(c => !c.IsDeleted)
+ .ToListAsync();
+
+ return models.Where(IsImageGenerationModelRecord).ToList();
+ }
+
+ private static bool IsImageGenerationModelRecord(ChannelWithModel model) {
+ return model.Capabilities.Any(cap =>
+ ( cap.CapabilityName == "image_generation" || cap.CapabilityName == "text_to_image" ) &&
+ string.Equals(cap.CapabilityValue, "true", StringComparison.OrdinalIgnoreCase)) ||
+ ModelWithCapabilities.IsKnownImageGenerationModelName(model.ModelName) ||
+ ( model.LLMChannel?.Provider == LLMProvider.MiniMax &&
+ ( model.ModelName == "image-01" || model.ModelName == "image-01-live" ) );
+ }
+
///
/// 删除过期的模型能力信息
///
@@ -221,6 +243,8 @@ private string GetCapabilityDescription(string capabilityName) {
"embedding" => "文本嵌入模型",
"streaming" => "支持流式响应",
"multimodal" => "支持多模态输入",
+ "image_generation" => "图片生成模型",
+ "text_to_image" => "文生图模型",
"code_generation" => "支持代码生成",
"audio_content" => "支持音频处理",
"video_content" => "支持视频处理",
diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs
index 711471b4..31c8ef6d 100644
--- a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs
+++ b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs
@@ -1015,8 +1015,9 @@ private ModelWithCapabilities InferOpenAIModelCapabilities(string modelName) {
if (lowerName.Contains("1106") || lowerName.Contains("0125")) {
model.SetCapability("response_json_object", true);
}
- } else if (lowerName.Contains("dall-e")) {
+ } else if (ModelWithCapabilities.IsKnownImageGenerationModelName(modelName)) {
model.SetCapability("image_generation", true);
+ model.SetCapability("text_to_image", true);
model.SetCapability("function_calling", false);
} else if (lowerName.Contains("whisper")) {
model.SetCapability("audio_transcription", true);
diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs
index 6079c2f5..a6229d43 100644
--- a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs
+++ b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs
@@ -276,7 +276,9 @@ private bool IsOpenRouter(string gateway) {
"MiniMax-M2.5-highspeed",
"MiniMax-M2.1",
"MiniMax-M2.1-highspeed",
- "MiniMax-M2"
+ "MiniMax-M2",
+ "image-01",
+ "image-01-live"
};
///
@@ -322,6 +324,10 @@ public virtual async Task> GetAllModelsWithCa
return new List();
}
+ if (channel.Provider == LLMProvider.MiniMax) {
+ return _miniMaxModels.Select(InferOpenAIModelCapabilities);
+ }
+
// 检查是否为OpenRouter
if (IsOpenRouter(channel.Gateway)) {
return await GetOpenRouterModelsWithCapabilities(channel);
@@ -455,9 +461,10 @@ private ModelWithCapabilities InferOpenAIModelCapabilities(string modelName) {
model.SetCapability("response_json_object", true);
}
}
- // DALL-E模型
- else if (lowerName.Contains("dall-e")) {
+ // 图片生成模型
+ else if (ModelWithCapabilities.IsKnownImageGenerationModelName(modelName)) {
model.SetCapability("image_generation", true);
+ model.SetCapability("text_to_image", true);
model.SetCapability("function_calling", false);
}
// Whisper模型
diff --git a/TelegramSearchBot.Test/Service/Tools/ImageGenerationToolServiceTests.cs b/TelegramSearchBot.Test/Service/Tools/ImageGenerationToolServiceTests.cs
index 14ba34c9..1c2e6af8 100644
--- a/TelegramSearchBot.Test/Service/Tools/ImageGenerationToolServiceTests.cs
+++ b/TelegramSearchBot.Test/Service/Tools/ImageGenerationToolServiceTests.cs
@@ -1,4 +1,12 @@
using System;
+using System.Linq;
+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;
@@ -17,5 +25,187 @@ public void BuildImageGenerationEndpoint_NormalizesGateway(string gateway, strin
public void BuildImageGenerationEndpoint_WhenGatewayEmpty_Throws() {
Assert.Throws(() => ImageGenerationToolService.BuildImageGenerationEndpoint(" "));
}
+
+ [Theory]
+ [InlineData("https://api.minimaxi.com", "https://api.minimaxi.com/v1/image_generation")]
+ [InlineData("https://api.minimaxi.com/v1", "https://api.minimaxi.com/v1/image_generation")]
+ [InlineData("https://example.test/custom/", "https://example.test/custom/v1/image_generation")]
+ [InlineData("https://example.test/v1/image_generation", "https://example.test/v1/image_generation")]
+ [InlineData("https://example.test/custom/image_generation", "https://example.test/custom/image_generation")]
+ public void BuildMiniMaxImageGenerationEndpoint_NormalizesGateway(string gateway, string expected) {
+ Assert.Equal(expected, ImageGenerationToolService.BuildMiniMaxImageGenerationEndpoint(gateway));
+ }
+
+ [Fact]
+ public void BuildMiniMaxImageGenerationEndpoint_WhenGatewayEmpty_Throws() {
+ Assert.Throws(() => ImageGenerationToolService.BuildMiniMaxImageGenerationEndpoint(" "));
+ }
+
+ [Fact]
+ public void BuildMiniMaxImageGenerationRequestBody_UsesAspectRatioAndStyle() {
+ var body = ImageGenerationToolService.BuildMiniMaxImageGenerationRequestBody(
+ "画一张水彩风格的城市夜景",
+ "image-01-live",
+ "1024x1024",
+ 2,
+ "base64",
+ "16:9",
+ 123,
+ true,
+ true,
+ "水彩",
+ 0.7);
+
+ Assert.Equal("image-01-live", Value(body, "model"));
+ Assert.Equal("画一张水彩风格的城市夜景", Value(body, "prompt"));
+ Assert.Equal(2, Value(body, "n"));
+ Assert.Equal("base64", Value(body, "response_format"));
+ Assert.Equal("16:9", Value(body, "aspect_ratio"));
+ Assert.Null(body["width"]);
+ Assert.Null(body["height"]);
+ Assert.Equal(123, Value(body, "seed"));
+ Assert.True(Value(body, "prompt_optimizer"));
+ Assert.True(Value(body, "aigc_watermark"));
+ Assert.Equal("水彩", NestedValue(body, "style", "style_type"));
+ Assert.Equal(0.7, NestedValue(body, "style", "style_weight"));
+ }
+
+ [Fact]
+ public void BuildMiniMaxImageGenerationRequestBody_UsesWidthHeightForImage01WhenAspectRatioMissing() {
+ var body = ImageGenerationToolService.BuildMiniMaxImageGenerationRequestBody(
+ "A minimal product render",
+ "image-01",
+ "1152x864",
+ 1,
+ "url",
+ null,
+ null,
+ false,
+ false,
+ null,
+ null);
+
+ Assert.Equal(1152, Value(body, "width"));
+ Assert.Equal(864, Value(body, "height"));
+ Assert.Null(body["aspect_ratio"]);
+ }
+
+ [Fact]
+ public void BuildMiniMaxImageGenerationRequestBody_DefaultResponseFormatIsBase64() {
+ var body = ImageGenerationToolService.BuildMiniMaxImageGenerationRequestBody(
+ "A minimal product render",
+ "image-01",
+ "1024x1024",
+ 1,
+ null,
+ null,
+ null,
+ false,
+ false,
+ null,
+ null);
+
+ Assert.Equal("base64", Value(body, "response_format"));
+ }
+
+ [Fact]
+ public void BuildMiniMaxImageGenerationRequestBody_WhenImage01SizeInvalid_Throws() {
+ Assert.Throws(() => ImageGenerationToolService.BuildMiniMaxImageGenerationRequestBody(
+ "A large render",
+ "image-01",
+ "2560x1440",
+ 1,
+ "url",
+ null,
+ null,
+ false,
+ false,
+ null,
+ null));
+ }
+
+ [Fact]
+ public void EnsureMiniMaxResponseSucceeded_WhenStatusNonZero_Throws() {
+ var ex = Assert.Throws(() => ImageGenerationToolService.EnsureMiniMaxResponseSucceeded(
+ @"{""base_resp"":{""status_code"":1026,""status_msg"":""sensitive prompt""}}"));
+
+ Assert.Contains("1026", ex.Message);
+ Assert.Contains("sensitive prompt", ex.Message);
+ }
+
+ [Fact]
+ public void ExtractImages_ReadsMiniMaxUrlAndBase64Arrays() {
+ var images = ImageGenerationToolService.ExtractImages(
+ @"{""data"":{""image_urls"":[""https://example.test/a.png""],""image_base64"":[""data:image/webp;base64,YWJj""]},""base_resp"":{""status_code"":0,""status_msg"":""success""}}");
+
+ Assert.Equal(2, images.Count);
+ Assert.Equal("https://example.test/a.png", images.First().Url);
+ Assert.Equal("YWJj", images.Last().Base64Data);
+ Assert.Equal("image/webp", images.Last().ContentType);
+ }
+
+ [Fact]
+ public async Task ImageGenerationSettings_UsesGroupModelBeforeDefault() {
+ await using var dbContext = CreateDbContext();
+ dbContext.AppConfigurationItems.Add(new AppConfigurationItem {
+ Key = ImageGenerationToolSettingsService.ModelNameKey,
+ Value = "gpt-image-2"
+ });
+ dbContext.GroupSettings.Add(new GroupSettings {
+ GroupId = -100,
+ ImageGenerationModelName = "image-01"
+ });
+ await dbContext.SaveChangesAsync();
+
+ var settings = CreateSettingsService(dbContext);
+
+ Assert.Equal("image-01", await settings.GetModelNameAsync(-100));
+ Assert.Equal("gpt-image-2", await settings.GetModelNameAsync(-200));
+ }
+
+ [Fact]
+ public async Task SetGroupModelNameAsync_DoesNotOverwriteChatLlmModel() {
+ await using var dbContext = CreateDbContext();
+ dbContext.GroupSettings.Add(new GroupSettings {
+ GroupId = -100,
+ LLMModelName = "gpt-4o"
+ });
+ await dbContext.SaveChangesAsync();
+
+ var settings = CreateSettingsService(dbContext);
+ var (previous, current) = await settings.SetGroupModelNameAsync(-100, "image-01-live");
+
+ var groupSettings = await dbContext.GroupSettings.SingleAsync(x => x.GroupId == -100);
+ Assert.Equal(ImageGenerationToolSettingsService.DefaultModelName, previous);
+ Assert.Equal("image-01-live", current);
+ Assert.Equal("gpt-4o", groupSettings.LLMModelName);
+ Assert.Equal("image-01-live", groupSettings.ImageGenerationModelName);
+ }
+
+ 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($"ImageGenerationToolServiceTests_{Guid.NewGuid():N}")
+ .Options;
+ return new DataDbContext(options);
+ }
+
+ private static ImageGenerationToolSettingsService CreateSettingsService(DataDbContext dbContext) {
+ return new ImageGenerationToolSettingsService(
+ dbContext,
+ new Mock().Object,
+ NullLogger.Instance);
+ }
}
}
diff --git a/TelegramSearchBot/Controller/AI/LLM/GeneralLLMController.cs b/TelegramSearchBot/Controller/AI/LLM/GeneralLLMController.cs
index fad3f6d7..b8383b9e 100644
--- a/TelegramSearchBot/Controller/AI/LLM/GeneralLLMController.cs
+++ b/TelegramSearchBot/Controller/AI/LLM/GeneralLLMController.cs
@@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq; // Added for LINQ methods
+using System.Text;
using System.Threading; // For CancellationToken
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
+using StackExchange.Redis;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums; // Added for MessageEntityType
@@ -19,6 +21,7 @@
using TelegramSearchBot.Service.BotAPI;
using TelegramSearchBot.Service.Manage;
using TelegramSearchBot.Service.Storage;
+using TelegramSearchBot.Service.Tools;
namespace TelegramSearchBot.Controller.AI.LLM {
public class GeneralLLMController : IOnUpdate {
@@ -26,6 +29,9 @@ public class GeneralLLMController : IOnUpdate {
private readonly SendMessage Send;
private readonly IBotIdentityProvider _botIdentityProvider;
private readonly IGroupLlmSettingsService _groupLlmSettingsService;
+ private readonly ImageGenerationToolSettingsService _imageGenerationToolSettingsService;
+ private readonly IModelCapabilityService _modelCapabilityService;
+ private readonly IConnectionMultiplexer _connectionMultiplexer;
public List Dependencies => new List();
public ITelegramBotClient botClient { get; set; }
public MessageService messageService { get; set; }
@@ -45,7 +51,10 @@ public GeneralLLMController(
ILlmContinuationService continuationService,
LLMTaskQueueService llmTaskQueueService,
IBotIdentityProvider botIdentityProvider,
- IGroupLlmSettingsService groupLlmSettingsService
+ IGroupLlmSettingsService groupLlmSettingsService,
+ ImageGenerationToolSettingsService imageGenerationToolSettingsService,
+ IModelCapabilityService modelCapabilityService,
+ IConnectionMultiplexer connectionMultiplexer
) {
this.logger = logger;
this.botClient = botClient;
@@ -58,6 +67,9 @@ IGroupLlmSettingsService groupLlmSettingsService
LlmTaskQueueService = llmTaskQueueService;
_botIdentityProvider = botIdentityProvider;
_groupLlmSettingsService = groupLlmSettingsService;
+ _imageGenerationToolSettingsService = imageGenerationToolSettingsService;
+ _modelCapabilityService = modelCapabilityService;
+ _connectionMultiplexer = connectionMultiplexer;
}
public async Task ExecuteAsync(PipelineContext p) {
@@ -101,7 +113,61 @@ public async Task ExecuteAsync(PipelineContext p) {
}
}
- if (Message.StartsWith("设置模型 ") && fromUserId != 0 && await adminService.IsNormalAdmin(fromUserId)) {
+ var isNormalAdmin = fromUserId != 0 && await adminService.IsNormalAdmin(fromUserId);
+
+ if (isNormalAdmin && await TryHandlePendingImageGenerationModelSelectionAsync(telegramMessage, Message, fromUserId)) {
+ return;
+ }
+
+ if (Message.Equals("选择生图模型", StringComparison.OrdinalIgnoreCase) ||
+ Message.Equals("生图模型列表", StringComparison.OrdinalIgnoreCase) ||
+ Message.Equals("可用生图模型", StringComparison.OrdinalIgnoreCase)) {
+ if (!isNormalAdmin) {
+ return;
+ }
+
+ var options = await LoadImageGenerationModelOptionsAsync();
+ if (options.Count == 0) {
+ await SendMessageService.SendMessage("当前没有识别到可用生图模型。请先通过 `新建渠道` / `添加模型` 关联 `gpt-image-*`、`dall-e*`、MiniMax `image-01` / `image-01-live`,或刷新渠道能力。", telegramMessage.Chat.Id, telegramMessage.MessageId);
+ return;
+ }
+
+ await SaveImageGenerationModelSelectionAsync(telegramMessage.Chat.Id, fromUserId, options);
+ await SendMessageService.SendMessage(BuildImageGenerationModelSelectionMessage(options), telegramMessage.Chat.Id, telegramMessage.MessageId);
+ 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 _imageGenerationToolSettingsService.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) {
+ var defaultModel = await _imageGenerationToolSettingsService.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) {
+ var modelName = await _imageGenerationToolSettingsService.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)) {
await SendMessageService.SendMessage("模型名称不能为空", telegramMessage.Chat.Id, telegramMessage.MessageId);
@@ -239,5 +305,91 @@ await botClient.SendMessage(
return;
}
}
+
+ private async Task TryHandlePendingImageGenerationModelSelectionAsync(Telegram.Bot.Types.Message telegramMessage, string messageText, long userId) {
+ var db = _connectionMultiplexer.GetDatabase();
+ var key = GetImageGenerationModelSelectionKey(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 _imageGenerationToolSettingsService.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
+ .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 ImageGenerationModelSelectionOption(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);
+ 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();
+ 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 sealed record ImageGenerationModelSelectionOption(string ModelName, string ChannelSummary);
}
}
diff --git a/TelegramSearchBot/Controller/Help/HelpController.cs b/TelegramSearchBot/Controller/Help/HelpController.cs
index b50be869..9d4b3fb4 100644
--- a/TelegramSearchBot/Controller/Help/HelpController.cs
+++ b/TelegramSearchBot/Controller/Help/HelpController.cs
@@ -34,11 +34,13 @@ public async Task ExecuteAsync(PipelineContext p) {
## 二、管理员指令
### 1. LLM 模型设置
- **设置模型**: `设置模型 <模型名称>`
+- **设置生图模型**: `设置生图模型 <模型名称>`
+- **选择生图模型**: `选择生图模型`
### 2. 全局管理员指令
- **群组管理**: `设置管理群`/`取消管理群`
- **Bilibili配置**: `/setbilicookie`, `/getbilicookie`
-- **生图工具**: `开启生图工具`, `关闭生图工具`, `生图工具状态`, `设置生图模型`
+- **生图工具**: `开启生图工具`, `关闭生图工具`, `生图工具状态`, `设置默认生图模型`
- **应用更新**: `检查更新`, `更新`
## 三、自动功能
diff --git a/TelegramSearchBot/Service/Manage/EditLLMConfService.cs b/TelegramSearchBot/Service/Manage/EditLLMConfService.cs
index 94f0ac7c..eaa82cdb 100644
--- a/TelegramSearchBot/Service/Manage/EditLLMConfService.cs
+++ b/TelegramSearchBot/Service/Manage/EditLLMConfService.cs
@@ -193,12 +193,12 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration
private async Task<(bool, string)> HandleSettingImageGenerationModelAsync(EditLLMConfRedisHelper redis, string command) {
try {
- await _imageGenerationToolSettingsService.SetModelNameAsync(command);
+ await _imageGenerationToolSettingsService.SetDefaultModelNameAsync(command);
await redis.DeleteKeysAsync();
- return (true, $"生图模型已设置为: {command.Trim()}。请确保该模型已通过 `添加模型` 关联到一个 OpenAI-compatible 渠道。");
+ return (true, $"默认生图模型已设置为: {command.Trim()}。群内没有单独配置生图模型时会使用该默认值;请确保该模型已通过 `添加模型` 关联到一个 OpenAI-compatible 或 MiniMax 渠道。");
} catch {
await redis.DeleteKeysAsync();
- return (false, "设置生图模型失败");
+ return (false, "设置默认生图模型失败");
}
}
@@ -502,8 +502,8 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration
if (cmd.Equals("开启生图工具", StringComparison.OrdinalIgnoreCase) ||
cmd.Equals("启用生图工具", StringComparison.OrdinalIgnoreCase)) {
await _imageGenerationToolSettingsService.SetToolEnabledAsync(true);
- var modelName = await _imageGenerationToolSettingsService.GetModelNameAsync();
- return (true, $"生图工具已开启,会注入到 LLM 工具提示词中。当前生图模型: {modelName}");
+ var modelName = await _imageGenerationToolSettingsService.GetDefaultModelNameAsync();
+ return (true, $"生图工具已开启,会注入到 LLM 工具提示词中。默认生图模型: {modelName}");
}
if (cmd.Equals("关闭生图工具", StringComparison.OrdinalIgnoreCase) ||
@@ -515,13 +515,14 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration
if (cmd.Equals("生图工具状态", StringComparison.OrdinalIgnoreCase) ||
cmd.Equals("查看生图工具", StringComparison.OrdinalIgnoreCase)) {
var enabled = await _imageGenerationToolSettingsService.IsToolEnabledAsync();
- var modelName = await _imageGenerationToolSettingsService.GetModelNameAsync();
- return (true, $"生图工具: {( enabled ? "已开启" : "已关闭" )}\n当前生图模型: {modelName}\nAPI 地址与 API Key 来自该模型关联的 LLM 渠道,可通过 `新建渠道` / `编辑渠道` 自定义渠道地址。");
+ var modelName = await _imageGenerationToolSettingsService.GetDefaultModelNameAsync();
+ return (true, $"生图工具: {( enabled ? "已开启" : "已关闭" )}\n默认生图模型: {modelName}\n群内可用 `选择生图模型` 或 `设置生图模型 <模型名>` 设置当前群的生图模型;未配置时使用默认值。\nAPI 地址与 API Key 来自对应模型关联的 LLM 渠道,可通过 `新建渠道` / `编辑渠道` 自定义渠道地址,支持 OpenAI-compatible 和 MiniMax。");
}
- if (cmd.Equals("设置生图模型", StringComparison.OrdinalIgnoreCase)) {
+ if (cmd.Equals("设置默认生图模型", StringComparison.OrdinalIgnoreCase) ||
+ cmd.Equals("设置生图默认模型", StringComparison.OrdinalIgnoreCase)) {
await redis.SetStateAsync(LLMConfState.SettingImageGenerationModel.GetDescription());
- return (true, $"请输入生图使用的模型名称(默认 {ImageGenerationToolSettingsService.DefaultModelName}):");
+ return (true, $"请输入没有群级配置时使用的默认生图模型名称(内置默认 {ImageGenerationToolSettingsService.DefaultModelName},MiniMax 可用 image-01 或 image-01-live):");
}
return null;
diff --git a/TelegramSearchBot/Service/Tools/ImageGenerationToolService.cs b/TelegramSearchBot/Service/Tools/ImageGenerationToolService.cs
index 03fa59c6..d399f9b4 100644
--- a/TelegramSearchBot/Service/Tools/ImageGenerationToolService.cs
+++ b/TelegramSearchBot/Service/Tools/ImageGenerationToolService.cs
@@ -21,6 +21,7 @@
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;
@@ -63,12 +64,32 @@ public async Task SetToolEnabledAsync(bool enabled) {
await RefreshAgentToolDefinitionsAsync();
}
- public async Task GetModelNameAsync() {
+ public async Task GetDefaultModelNameAsync() {
var value = await GetConfigurationValueAsync(ModelNameKey);
return string.IsNullOrWhiteSpace(value) ? DefaultModelName : value.Trim();
}
- public async Task SetModelNameAsync(string modelName) {
+ 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.ImageGenerationModelName)
+ .FirstOrDefaultAsync();
+
+ if (!string.IsNullOrWhiteSpace(groupModelName)) {
+ return groupModelName.Trim();
+ }
+ }
+
+ return await GetDefaultModelNameAsync();
+ }
+
+ public async Task SetDefaultModelNameAsync(string modelName) {
if (string.IsNullOrWhiteSpace(modelName)) {
throw new ArgumentException("Image generation model name cannot be empty.", nameof(modelName));
}
@@ -76,6 +97,69 @@ public async Task SetModelNameAsync(string 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("Image 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.ImageGenerationModelName;
+ GroupSettings newSettings = null;
+
+ if (settings == null) {
+ newSettings = new GroupSettings {
+ GroupId = chatId,
+ ImageGenerationModelName = normalizedModelName
+ };
+ await _dbContext.GroupSettings.AddAsync(newSettings);
+ } else {
+ settings.ImageGenerationModelName = 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.ImageGenerationModelName;
+ settings.ImageGenerationModelName = 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.ImageGenerationModelName = null;
+ await _dbContext.SaveChangesAsync();
+ }
+
+ return await GetDefaultModelNameAsync();
+ }
+
private static void ApplyToolVisibility(bool enabled) {
McpToolHelper.SetBuiltInToolEnabled(ImageGenerationToolService.ToolName, enabled);
}
@@ -134,9 +218,13 @@ public class ImageGenerationToolService : IService {
private const string DefaultSize = "1024x1024";
private const string DefaultQuality = "auto";
private const string DefaultOutputFormat = "png";
- private const int MaxImageCount = 4;
+ private const int MaxImageCount = 9;
+ private const string DefaultMiniMaxResponseFormat = "base64";
private const long TelegramPhotoLimitBytes = 10 * 1024 * 1024;
private static readonly TimeSpan SendTimeout = TimeSpan.FromSeconds(120);
+ private static readonly string[] MiniMaxImageModels = { "image-01", "image-01-live" };
+ private static readonly string[] MiniMaxAspectRatios = { "1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9" };
+ private static readonly string[] MiniMaxStyleTypes = { "漫画", "元气", "中世纪", "水彩" };
public string ServiceName => "ImageGenerationToolService";
@@ -165,19 +253,26 @@ public ImageGenerationToolService(
_logger = logger;
}
- [BuiltInTool(@"Generate an image through the configured OpenAI-compatible Image API and optionally send it to the current Telegram chat.
-The default model is gpt-image-2. The API base URL and API key are read from the configured LLM channel for the selected image model, so administrators can use OpenAI or a compatible custom endpoint.
+ [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.")]
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,
- [BuiltInParameter("Image model name. Defaults to the administrator-configured image generation model, normally gpt-image-2.", IsRequired = false)] string model = null,
+ [BuiltInParameter("Image model name. Defaults to the current chat's configured image generation model, or the bot-wide default gpt-image-2 when unset. Use image-01 or image-01-live for MiniMax.", IsRequired = false)] string model = null,
[BuiltInParameter("Image size such as 1024x1024, 1536x1024, 1024x1536, or auto. Defaults to 1024x1024.", IsRequired = false)] string size = DefaultSize,
[BuiltInParameter("Quality: auto, low, medium, or high. Defaults to auto.", IsRequired = false)] string quality = DefaultQuality,
- [BuiltInParameter("Output format: png, jpeg, or webp. Defaults to png.", IsRequired = false)] string outputFormat = DefaultOutputFormat,
+ [BuiltInParameter("OpenAI-compatible output format: png, jpeg, or webp. Defaults to png. MiniMax does not support choosing output file format; MiniMax base64 results are saved as png unless a downloaded URL provides a more specific content type.", IsRequired = false)] string outputFormat = DefaultOutputFormat,
[BuiltInParameter("Background: auto, opaque, or transparent. Leave empty unless the user asked for a specific background.", IsRequired = false)] string background = null,
[BuiltInParameter("Moderation setting: auto or low. Defaults to auto.", IsRequired = false)] string moderation = "auto",
- [BuiltInParameter("Number of images to generate, from 1 to 4. Defaults to 1.", IsRequired = false)] int count = 1,
+ [BuiltInParameter("MiniMax aspect ratio: 1:1, 16:9, 4:3, 3:2, 2:3, 3:4, 9:16, or 21:9. If set, MiniMax ignores size. Leave empty for OpenAI-compatible models.", IsRequired = false)] string aspectRatio = null,
+ [BuiltInParameter("MiniMax response format: base64 or url. Defaults to base64. Leave empty for OpenAI-compatible models.", IsRequired = false)] string minimaxResponseFormat = DefaultMiniMaxResponseFormat,
+ [BuiltInParameter("MiniMax random seed for reproducible similar results. Leave empty unless the user asks for a seed.", IsRequired = false)] long? seed = null,
+ [BuiltInParameter("Whether to enable MiniMax prompt_optimizer. Defaults to false.", IsRequired = false)] bool promptOptimizer = false,
+ [BuiltInParameter("Whether to add the MiniMax AIGC watermark. Defaults to false.", IsRequired = false)] bool aigcWatermark = false,
+ [BuiltInParameter("MiniMax image-01-live style_type. Supported values: 漫画, 元气, 中世纪, 水彩. Leave empty unless the user asks for one of these styles.", IsRequired = false)] string styleType = null,
+ [BuiltInParameter("MiniMax image-01-live style_weight in (0, 1]. Defaults to MiniMax service default when empty.", IsRequired = false)] double? styleWeight = null,
+ [BuiltInParameter("Number of images to generate, from 1 to 9. Defaults to 1.", IsRequired = false)] int count = 1,
[BuiltInParameter("Whether to send generated images to the current Telegram chat. Defaults to true.", IsRequired = false)] bool sendToChat = true,
[BuiltInParameter("Optional Telegram photo caption. Keep it short.", IsRequired = false)] string caption = null,
[BuiltInParameter("Optional Telegram message ID to reply to. Defaults to the original user message.", IsRequired = false)] long? replyToMessageId = null,
@@ -194,13 +289,16 @@ public async Task GenerateImage(
}
var effectiveModel = string.IsNullOrWhiteSpace(model)
- ? await _settingsService.GetModelNameAsync()
+ ? await _settingsService.GetModelNameAsync(toolContext?.ChatId ?? 0)
: model.Trim();
var normalizedSize = NormalizeSize(size);
var normalizedQuality = NormalizeChoice(quality, "quality", DefaultQuality, "auto", "low", "medium", "high");
var normalizedOutputFormat = NormalizeChoice(outputFormat, "outputFormat", DefaultOutputFormat, "png", "jpeg", "webp");
var normalizedBackground = NormalizeOptionalChoice(background, "background", "auto", "opaque", "transparent");
var normalizedModeration = NormalizeOptionalChoice(moderation, "moderation", "auto", "low") ?? "auto";
+ var normalizedMiniMaxResponseFormat = string.IsNullOrWhiteSpace(minimaxResponseFormat)
+ ? DefaultMiniMaxResponseFormat
+ : minimaxResponseFormat.Trim();
var normalizedCount = Math.Clamp(count, 1, MaxImageCount);
var normalizedTimeoutSeconds = Math.Clamp(timeoutSeconds, 30, 600);
@@ -209,7 +307,7 @@ public async Task GenerateImage(
var channels = await LoadChannelsAsync(effectiveModel);
if (channels.Count == 0) {
return Failure(
- $"No channel is configured for image model '{effectiveModel}'. Add an OpenAI-compatible channel and associate model '{effectiveModel}' with it. The channel gateway can be a custom base URL, for example https://api.openai.com/v1.");
+ $"No channel is configured for image model '{effectiveModel}'. Add an OpenAI-compatible or MiniMax channel and associate model '{effectiveModel}' with it. The channel gateway can be a custom base URL, for example https://api.openai.com/v1 or https://api.minimaxi.com.");
}
Exception lastException = null;
@@ -219,19 +317,39 @@ public async Task GenerateImage(
}
try {
- var endpoint = BuildImageGenerationEndpoint(channel.Gateway);
- var generated = await RequestImagesAsync(
- endpoint,
- channel,
- prompt.Trim(),
- effectiveModel,
- normalizedSize,
- normalizedQuality,
- normalizedOutputFormat,
- normalizedBackground,
- normalizedModeration,
- normalizedCount,
- normalizedTimeoutSeconds);
+ var useMiniMax = IsMiniMaxImageGeneration(channel, effectiveModel);
+ var endpoint = useMiniMax
+ ? BuildMiniMaxImageGenerationEndpoint(channel.Gateway)
+ : BuildImageGenerationEndpoint(channel.Gateway);
+ var generated = useMiniMax
+ ? await RequestMiniMaxImagesAsync(
+ endpoint,
+ channel,
+ prompt.Trim(),
+ effectiveModel,
+ normalizedSize,
+ DefaultOutputFormat,
+ normalizedMiniMaxResponseFormat,
+ aspectRatio,
+ seed,
+ promptOptimizer,
+ aigcWatermark,
+ styleType,
+ styleWeight,
+ normalizedCount,
+ normalizedTimeoutSeconds)
+ : await RequestImagesAsync(
+ endpoint,
+ channel,
+ prompt.Trim(),
+ effectiveModel,
+ normalizedSize,
+ normalizedQuality,
+ normalizedOutputFormat,
+ normalizedBackground,
+ normalizedModeration,
+ normalizedCount,
+ normalizedTimeoutSeconds);
result.ChannelId = channel.Id;
result.ChannelName = channel.Name;
@@ -297,6 +415,24 @@ public static string BuildImageGenerationEndpoint(string gateway) {
return $"{normalized}/v1/images/generations";
}
+ public static string BuildMiniMaxImageGenerationEndpoint(string gateway) {
+ if (string.IsNullOrWhiteSpace(gateway)) {
+ throw new ArgumentException("MiniMax image generation channel gateway cannot be empty.", nameof(gateway));
+ }
+
+ var normalized = gateway.Trim().TrimEnd('/');
+ if (normalized.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) {
+ return $"{normalized}/image_generation";
+ }
+
+ if (normalized.EndsWith("/v1/image_generation", StringComparison.OrdinalIgnoreCase) ||
+ normalized.EndsWith("/image_generation", StringComparison.OrdinalIgnoreCase)) {
+ return normalized;
+ }
+
+ return $"{normalized}/v1/image_generation";
+ }
+
private async Task> LoadChannelsAsync(string modelName) {
var channelIds = await _dbContext.ChannelsWithModel
.AsNoTracking()
@@ -401,6 +537,68 @@ private async Task> RequestImagesAsync(
return saved;
}
+ private async Task> RequestMiniMaxImagesAsync(
+ string endpoint,
+ LLMChannel channel,
+ string prompt,
+ string model,
+ string size,
+ string outputFormat,
+ string responseFormat,
+ string aspectRatio,
+ long? seed,
+ bool promptOptimizer,
+ bool aigcWatermark,
+ string styleType,
+ double? styleWeight,
+ int count,
+ int timeoutSeconds) {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
+ var requestBody = BuildMiniMaxImageGenerationRequestBody(
+ prompt,
+ model,
+ size,
+ count,
+ responseFormat,
+ aspectRatio,
+ seed,
+ promptOptimizer,
+ aigcWatermark,
+ styleType,
+ styleWeight);
+
+ 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 image API returned HTTP {( int ) response.StatusCode}: {ExtractErrorMessage(responseText)}");
+ }
+
+ EnsureMiniMaxResponseSucceeded(responseText);
+ var images = ExtractImages(responseText);
+ if (images.Count == 0) {
+ throw new InvalidOperationException("The MiniMax image API response did not include image_urls or image_base64 data.");
+ }
+
+ var saved = new List();
+ for (var i = 0; i < images.Count; i++) {
+ var savedImage = await SaveImageAsync(httpClient, images[i], outputFormat, i + 1, cts.Token);
+ saved.Add(savedImage);
+ }
+
+ return saved;
+ }
+
private async Task SaveImageAsync(
HttpClient httpClient,
ImageResponseData image,
@@ -506,26 +704,115 @@ private static ReplyParameters GetReplyParameters(long? explicitReplyToMessageId
return messageId.HasValue ? new ReplyParameters { MessageId = ( int ) messageId.Value } : null;
}
- private static List ExtractImages(string responseText) {
+ internal static JObject BuildMiniMaxImageGenerationRequestBody(
+ string prompt,
+ string model,
+ string size,
+ int count,
+ string responseFormat,
+ string aspectRatio,
+ long? seed,
+ bool promptOptimizer,
+ bool aigcWatermark,
+ string styleType,
+ double? styleWeight) {
+ if (string.IsNullOrWhiteSpace(prompt)) {
+ throw new ArgumentException("MiniMax prompt cannot be empty.", nameof(prompt));
+ }
+
+ if (prompt.Length > 1500) {
+ throw new ArgumentException("MiniMax prompt cannot exceed 1500 characters.", nameof(prompt));
+ }
+
+ var normalizedModel = string.IsNullOrWhiteSpace(model)
+ ? "image-01"
+ : model.Trim();
+ if (!IsMiniMaxImageModel(normalizedModel)) {
+ throw new ArgumentException("MiniMax image generation model must be image-01 or image-01-live.", nameof(model));
+ }
+
+ var normalizedResponseFormat = NormalizeChoice(responseFormat, "minimaxResponseFormat", DefaultMiniMaxResponseFormat, "url", "base64");
+ var requestBody = new JObject {
+ ["model"] = normalizedModel,
+ ["prompt"] = prompt.Trim(),
+ ["n"] = Math.Clamp(count, 1, MaxImageCount),
+ ["response_format"] = normalizedResponseFormat,
+ ["prompt_optimizer"] = promptOptimizer,
+ ["aigc_watermark"] = aigcWatermark
+ };
+
+ if (seed.HasValue) {
+ requestBody["seed"] = seed.Value;
+ }
+
+ var normalizedAspectRatio = NormalizeMiniMaxAspectRatio(aspectRatio, normalizedModel);
+ if (!string.IsNullOrWhiteSpace(normalizedAspectRatio)) {
+ requestBody["aspect_ratio"] = normalizedAspectRatio;
+ } else if (normalizedModel.Equals("image-01", StringComparison.OrdinalIgnoreCase) &&
+ TryParseMiniMaxSize(size, out var width, out var height)) {
+ requestBody["width"] = width;
+ requestBody["height"] = height;
+ }
+
+ var normalizedStyleType = NormalizeMiniMaxStyleType(styleType, normalizedModel);
+ if (!string.IsNullOrWhiteSpace(normalizedStyleType)) {
+ var style = new JObject {
+ ["style_type"] = normalizedStyleType
+ };
+ if (styleWeight.HasValue) {
+ if (styleWeight.Value <= 0 || styleWeight.Value > 1) {
+ throw new ArgumentException("MiniMax styleWeight must be in (0, 1].", nameof(styleWeight));
+ }
+
+ style["style_weight"] = styleWeight.Value;
+ }
+
+ requestBody["style"] = style;
+ } else if (styleWeight.HasValue) {
+ throw new ArgumentException("MiniMax styleWeight requires styleType.", nameof(styleWeight));
+ }
+
+ return requestBody;
+ }
+
+ internal static void EnsureMiniMaxResponseSucceeded(string responseText) {
+ if (string.IsNullOrWhiteSpace(responseText)) {
+ throw new InvalidOperationException("MiniMax image 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 image API returned status {statusCode}: {statusMessage ?? "unknown error"}");
+ }
+
+ internal static List ExtractImages(string responseText) {
var token = JToken.Parse(responseText);
var images = new List();
var dataArray = token["data"] as JArray;
if (dataArray != null) {
foreach (var item in dataArray.OfType()) {
- var image = ExtractImageFromObject(item);
- if (image != null) {
- images.Add(image);
- }
+ images.AddRange(ExtractImagesFromObject(item));
}
}
+ if (images.Count == 0 && token["data"] is JObject dataObject) {
+ images.AddRange(ExtractImagesFromObject(dataObject));
+ }
+
if (images.Count == 0) {
foreach (var item in EnumerateObjects(token)) {
- var image = ExtractImageFromObject(item);
- if (image != null) {
- images.Add(image);
- }
+ images.AddRange(ExtractImagesFromObject(item));
}
}
@@ -544,6 +831,25 @@ private static IEnumerable EnumerateObjects(JToken token) {
}
}
+ private static IEnumerable ExtractImagesFromObject(JObject obj) {
+ foreach (var url in GetStringArrayValues(obj, "image_urls")) {
+ yield return new ImageResponseData(null, url, null);
+ }
+
+ foreach (var base64 in GetStringArrayValues(obj, "image_base64")) {
+ if (TryParseImageDataUrl(base64, out _, out var contentType, out var strippedBase64)) {
+ yield return new ImageResponseData(strippedBase64, null, contentType);
+ } else {
+ yield return new ImageResponseData(base64, null, null);
+ }
+ }
+
+ var image = ExtractImageFromObject(obj);
+ if (image != null) {
+ yield return image;
+ }
+ }
+
private static ImageResponseData ExtractImageFromObject(JObject obj) {
var base64 = GetFirstString(obj, "b64_json", "b64", "base64", "image_b64", "image_base64", "partial_image_b64");
if (!string.IsNullOrWhiteSpace(base64)) {
@@ -563,6 +869,22 @@ private static ImageResponseData ExtractImageFromObject(JObject obj) {
return null;
}
+ private static IEnumerable GetStringArrayValues(JObject obj, params string[] keys) {
+ foreach (var key in keys) {
+ if (!obj.TryGetValue(key, StringComparison.OrdinalIgnoreCase, out var token) ||
+ token is not JArray values) {
+ continue;
+ }
+
+ foreach (var item in values) {
+ var value = item.Type == JTokenType.String ? item.Value()?.Trim() : null;
+ if (!string.IsNullOrWhiteSpace(value)) {
+ yield return value;
+ }
+ }
+ }
+ }
+
private static string GetFirstString(JObject obj, params string[] keys) {
foreach (var key in keys) {
if (obj.TryGetValue(key, StringComparison.OrdinalIgnoreCase, out var token) &&
@@ -577,6 +899,74 @@ private static string GetFirstString(JObject obj, params string[] keys) {
return null;
}
+ private static bool IsMiniMaxImageGeneration(LLMChannel channel, string modelName) {
+ return channel?.Provider == LLMProvider.MiniMax || IsMiniMaxImageModel(modelName);
+ }
+
+ private static bool IsMiniMaxImageModel(string modelName) {
+ return MiniMaxImageModels.Contains(modelName?.Trim() ?? string.Empty, StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static string NormalizeMiniMaxAspectRatio(string aspectRatio, string modelName) {
+ if (string.IsNullOrWhiteSpace(aspectRatio)) {
+ return null;
+ }
+
+ var normalized = aspectRatio.Trim();
+ if (!MiniMaxAspectRatios.Contains(normalized, StringComparer.OrdinalIgnoreCase)) {
+ throw new ArgumentException($"MiniMax aspectRatio must be one of: {string.Join(", ", MiniMaxAspectRatios)}.", nameof(aspectRatio));
+ }
+
+ if (normalized.Equals("21:9", StringComparison.OrdinalIgnoreCase) &&
+ !modelName.Equals("image-01", StringComparison.OrdinalIgnoreCase)) {
+ throw new ArgumentException("MiniMax aspectRatio 21:9 is only supported by image-01.", nameof(aspectRatio));
+ }
+
+ return normalized;
+ }
+
+ private static string NormalizeMiniMaxStyleType(string styleType, string modelName) {
+ if (string.IsNullOrWhiteSpace(styleType)) {
+ return null;
+ }
+
+ if (!modelName.Equals("image-01-live", StringComparison.OrdinalIgnoreCase)) {
+ throw new ArgumentException("MiniMax styleType is only supported by image-01-live.", nameof(styleType));
+ }
+
+ var normalized = styleType.Trim();
+ if (!MiniMaxStyleTypes.Contains(normalized, StringComparer.Ordinal)) {
+ throw new ArgumentException($"MiniMax styleType must be one of: {string.Join(", ", MiniMaxStyleTypes)}.", nameof(styleType));
+ }
+
+ return normalized;
+ }
+
+ private static bool TryParseMiniMaxSize(string size, out int width, out int height) {
+ width = 0;
+ height = 0;
+ if (string.IsNullOrWhiteSpace(size) || size.Trim().Equals("auto", StringComparison.OrdinalIgnoreCase)) {
+ return false;
+ }
+
+ var match = Regex.Match(size.Trim(), @"^(?[1-9]\d{1,4})x(?[1-9]\d{1,4})$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
+ if (!match.Success ||
+ !int.TryParse(match.Groups["width"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out width) ||
+ !int.TryParse(match.Groups["height"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out height)) {
+ throw new ArgumentException("MiniMax size must be auto or a WIDTHxHEIGHT value, for example 1024x1024.", nameof(size));
+ }
+
+ if (!IsValidMiniMaxDimension(width) || !IsValidMiniMaxDimension(height)) {
+ throw new ArgumentException("MiniMax width and height must be in [512, 2048] and multiples of 8.", nameof(size));
+ }
+
+ return true;
+ }
+
+ private static bool IsValidMiniMaxDimension(int value) {
+ return value >= 512 && value <= 2048 && value % 8 == 0;
+ }
+
private static string ExtractErrorMessage(string responseText) {
if (string.IsNullOrWhiteSpace(responseText)) {
return "empty response body";
@@ -697,7 +1087,7 @@ private static string ExtensionFromUrl(string url) {
}
}
- private sealed record ImageResponseData(string Base64Data, string Url, string ContentType);
+ internal sealed record ImageResponseData(string Base64Data, string Url, string ContentType);
private sealed record GeneratedImagePayload(byte[] Bytes, GeneratedImageInfo Info);
}
}