diff --git a/Docs/Bot_Commands_User_Guide.md b/Docs/Bot_Commands_User_Guide.md index 64efde7d..0b9797c3 100644 --- a/Docs/Bot_Commands_User_Guide.md +++ b/Docs/Bot_Commands_User_Guide.md @@ -154,6 +154,8 @@ 4. 机器人回复: `请输入 <预设名> 的 API Key(本地/免 Key 服务发送 - 跳过):` 5. 管理员发送 API Key(或 `-`) 6. 机器人回复: `渠道创建成功(ID: x)`,并自动预置默认模型;自定义 endpoint / 模型仍可用 `新建渠道` / `添加模型` 修改。 + * **预设目录数据化**:预设来自内置 `providers.json`(每 6 小时的渠道刷新会让模型表跟随供应商最新状态);如需增改服务商,可仿照内置文件在 `%LOCALAPPDATA%/TelegramSearchBot/providers.json` 放置覆盖文件(含 `generatedAt` 与 `presets`),重启后优先生效,覆盖文件损坏会自动回退内置目录。 + * **多协议网关**:OpenCode Zen 按模型名自动分派协议(`claude-*`→Anthropic、`gpt-*`/`grok-*`→Responses、`gemini-*`→Gemini、其余→Chat Completions);OpenCode Go 为订阅网关,预置官方模型并通过 `/models` 只增不删地同步目录。 * **例如,使用 `新建渠道` 指令的交互流程可能如下:** 1. 管理员发送: `新建渠道` 2. 机器人回复: `请输入渠道的名称` @@ -180,7 +182,7 @@ 1. 管理员发送: `编辑渠道` 2. 机器人回复: (列出现有渠道) `请选择要编辑的渠道ID:` 3. 管理员发送: (选择一个渠道ID,例如 `1`) - 4. 机器人回复: (列出可编辑字段) `请选择要编辑的字段:\n1. 名称 (当前值)\n2. 地址 (当前值)\n3. 类型 (当前值)\n4. API Key\n5. 最大并行数量 (当前值)\n6. 优先级 (当前值)` + 4. 机器人回复: (列出可编辑字段) `请选择要编辑的字段:\n1. 名称 (当前值)\n2. 地址 (当前值)\n3. 类型 (当前值)\n4. API Key\n5. 最大并行数量 (当前值)\n6. 优先级 (当前值)\n7. 协议绑定` 5. 管理员发送: (选择字段编号) 6. **如果选择 `1`, `2`, `4`, `5`, `6`**: - 机器人回复: `请输入新的值:` @@ -189,9 +191,14 @@ 6. **如果选择 `3` (类型)**: - 机器人回复: `请选择渠道类型:\n1. OpenAI\n2. Ollama` - 管理员发送: (输入选项编号,例如 `1`) + 6. **如果选择 `7` (协议绑定)**(多协议网关用,如 OpenCode Zen/Go): + - 机器人回复: (列出绑定) `渠道 x 的协议绑定:\n3. https://opencode.ai/zen/v1 (OpenAIChat/Bearer) [默认]\n...` + - 管理员发送: 绑定ID(设为默认)或 `0`(新增绑定) + - 新增时依次输入:端点地址 → 线协议(OpenAIChat/OpenAIResponses/AnthropicMessages/Ollama/Gemini)→ 认证方式(Bearer/AnthropicApiKey/None) + - 机器人回复: `绑定创建成功(ID: x,协议/认证)` 7. 管理员发送: (输入新值或选择编号) 8. 机器人回复: `更新成功` (或失败信息) - * **`移除模型` 指令的交互流程示例:** + * **`移除模型` 指令的交互流程示例:**(按模型行删除:多 binding 下同名模型会带 `[渠道/binding/协议]` 标注,删错行不会发生) 1. 管理员发送: `移除模型` 2. 机器人回复: (列出现有渠道) `请选择要移除模型的渠道ID:` 3. 管理员发送: (选择一个渠道ID,例如 `1`) diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/LlmProviderCatalogLoaderTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/LlmProviderCatalogLoaderTests.cs new file mode 100644 index 00000000..522b0b8c --- /dev/null +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/LlmProviderCatalogLoaderTests.cs @@ -0,0 +1,96 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using TelegramSearchBot.Model.AI; +using TelegramSearchBot.Service.AI.LLM; +using Xunit; + +namespace TelegramSearchBot.LLM.Test.Service.AI.LLM { + public class LlmProviderCatalogLoaderTests { + private static string TempPath(string name) { + var dir = Path.Combine(Path.GetTempPath(), "tsb-providers-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + return Path.Combine(dir, name); + } + + [Fact] + public void BuiltInDocument_LoadsAllPresets_WhenOverrideMissing() { + var missing = TempPath("providers.json"); + + var doc = LlmProviderCatalogLoader.Load(overridePath: missing); + + Assert.Equal("builtin", doc.Source); + Assert.Contains("不存在", doc.Warning); + Assert.Equal(11, doc.Presets.Count); + Assert.NotNull(doc.GeneratedAt); + Assert.NotNull(doc.Presets.FirstOrDefault(p => p.Id == "opencode-go")); + } + + [Fact] + public void BuiltInDocument_KeepsOpenCodeBindingsAndRules() { + var go = LlmProviderCatalog.FindById("opencode-go")!; + + Assert.Single(go.Bindings!); + Assert.Equal(LlmProtocol.OpenAIResponses, go.Bindings![0].Protocol); + Assert.Equal("responses", go.ModelBindingRules.ResolveBindingId("grok-4.6")); + Assert.True(go.CatalogIsEntitlement); + Assert.Equal("https://opencode.ai/zen/go/v1", go.DefaultGateway); + } + + [Fact] + public void OverrideFile_WinsOverBuiltIn() { + var path = TempPath("providers.json"); + File.WriteAllText(path, """ + { + "generatedAt": "2030-01-01T00:00:00Z", + "presets": [ + { "id": "custom", "displayName": "Custom", "provider": "OpenAI", "defaultGateway": "https://example.com/v1", + "defaultModels": ["m1"], "requiresApiKey": true, + "bindings": [ { "id": "responses", "protocol": "OpenAIResponses", "authProfile": "Bearer" } ], + "modelBindingRules": [ { "bindingId": "responses", "prefixes": ["gpt-"] } ] } + ] + } + """); + + var doc = LlmProviderCatalogLoader.Load(overridePath: path); + + Assert.StartsWith("file:", doc.Source); + var preset = Assert.Single(doc.Presets); + Assert.Equal("custom", preset.Id); + Assert.Equal("responses", preset.ModelBindingRules.ResolveBindingId("gpt-x")); + Assert.Equal(LlmProtocol.OpenAIResponses, preset.Bindings![0].Protocol); + Assert.Equal(LlmAuthProfile.Bearer, preset.Bindings![0].AuthProfile); + } + + [Fact] + public void InvalidOverride_FallsBackToBuiltInStream_WithWarning() { + var path = TempPath("providers.json"); + File.WriteAllText(path, "{ this is not valid json"); + + using var builtIn = new MemoryStream(Encoding.UTF8.GetBytes(""" + { "generatedAt": "2026-01-01T00:00:00Z", "presets": [ { "id": "fallback", "displayName": "Fallback", "provider": "OpenAI", "defaultGateway": null, "defaultModels": [], "requiresApiKey": false } ] } + """)); + + var doc = LlmProviderCatalogLoader.Load(overridePath: path, builtIn: builtIn); + + Assert.Equal("builtin", doc.Source); + Assert.Contains("回退", doc.Warning); + Assert.Equal("fallback", Assert.Single(doc.Presets).Id); + } + + [Fact] + public void EmptyPresetList_InOverride_IsRejected() { + var path = TempPath("providers.json"); + File.WriteAllText(path, """{ "presets": [] }"""); + using var builtIn = new MemoryStream(Encoding.UTF8.GetBytes(""" + { "presets": [ { "id": "fallback", "displayName": "Fallback", "provider": "OpenAI", "defaultGateway": null, "defaultModels": [], "requiresApiKey": false } ] } + """)); + + var doc = LlmProviderCatalogLoader.Load(overridePath: path, builtIn: builtIn); + + Assert.Equal("fallback", Assert.Single(doc.Presets).Id); + Assert.NotNull(doc.Warning); + } + } +} diff --git a/TelegramSearchBot.LLM/Providers/providers.json b/TelegramSearchBot.LLM/Providers/providers.json new file mode 100644 index 00000000..b9114b4b --- /dev/null +++ b/TelegramSearchBot.LLM/Providers/providers.json @@ -0,0 +1,125 @@ +{ + "generatedAt": "2026-09-16T00:00:00Z", + "presets": [ + { + "id": "anthropic", + "displayName": "Anthropic 官方", + "provider": "Anthropic", + "defaultGateway": "https://api.anthropic.com", + "defaultModels": ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5-20251001"], + "requiresApiKey": true + }, + { + "id": "openai", + "displayName": "OpenAI 官方 (Chat Completions)", + "provider": "OpenAI", + "defaultGateway": "https://api.openai.com/v1", + "defaultModels": ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra"], + "requiresApiKey": true + }, + { + "id": "openai-responses", + "displayName": "OpenAI 官方 (Responses API)", + "provider": "ResponsesAPI", + "defaultGateway": "https://api.openai.com/v1", + "defaultModels": ["gpt-6-astra", "gpt-5.6-sol"], + "requiresApiKey": true + }, + { + "id": "gemini", + "displayName": "Google Gemini", + "provider": "Gemini", + "defaultGateway": "https://generativelanguage.googleapis.com", + "defaultModels": ["gemini-3.8-flash", "gemini-3.1-pro-preview", "gemini-3.5-flash"], + "requiresApiKey": true + }, + { + "id": "minimax", + "displayName": "MiniMax", + "provider": "MiniMax", + "defaultGateway": "https://api.minimaxi.com/v1", + "defaultModels": ["MiniMax-M3", "MiniMax-M2.7"], + "requiresApiKey": true + }, + { + "id": "ollama", + "displayName": "本地 Ollama", + "provider": "Ollama", + "defaultGateway": "http://localhost:11434", + "defaultModels": [], + "requiresApiKey": false, + "notes": "本地服务无需 API Key;模型通过 `添加模型` 手动添加或自动发现。" + }, + { + "id": "lmstudio", + "displayName": "本地 LM Studio", + "provider": "LMStudio", + "defaultGateway": "http://localhost:1234/v1", + "defaultModels": [], + "requiresApiKey": false, + "notes": "本地服务无需 API Key;模型通过 `添加模型` 手动添加。" + }, + { + "id": "deepseek", + "displayName": "DeepSeek (OpenAI 兼容)", + "provider": "OpenAI", + "defaultGateway": "https://api.deepseek.com/v1", + "defaultModels": ["deepseek-flash", "deepseek-v4-pro"], + "requiresApiKey": true + }, + { + "id": "moonshot", + "displayName": "Moonshot Kimi (OpenAI 兼容)", + "provider": "OpenAI", + "defaultGateway": "https://api.moonshot.cn/v1", + "defaultModels": ["kimi-k3", "kimi-k2.7-code"], + "requiresApiKey": true + }, + { + "id": "opencode-zen", + "displayName": "OpenCode Zen (官方订阅目录)", + "provider": "OpenAI", + "defaultGateway": "https://opencode.ai/zen/v1", + "defaultModels": [], + "requiresApiKey": true, + "notes": "多协议网关(claude-*→Anthropic、gpt-*/grok-*→Responses、gemini-*→Google、其余→Chat Completions);按量计费,目录不自动创建,授权模型请用 `添加模型`。", + "bindings": [ + { "id": "anthropic", "protocol": "AnthropicMessages", "authProfile": "AnthropicApiKey", "endpointSuffix": "/v1" }, + { "id": "responses", "protocol": "OpenAIResponses", "authProfile": "Bearer", "endpointSuffix": "/v1" }, + { "id": "google", "protocol": "Gemini", "authProfile": "Bearer", "endpointSuffix": "/v1" } + ], + "modelBindingRules": [ + { "bindingId": "anthropic", "prefixes": ["claude-"] }, + { "bindingId": "responses", "prefixes": ["gpt-", "grok-", "o3", "o4"] }, + { "bindingId": "google", "prefixes": ["gemini-"] } + ] + }, + { + "id": "opencode-go", + "displayName": "OpenCode Go (订阅网关)", + "provider": "OpenAI", + "defaultGateway": "https://opencode.ai/zen/go/v1", + "defaultModels": [ + "grok-4.6", "gpt-5.6-luna", + "glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1", + "kimi-k3", "kimi-k2.7-code", "kimi-k2.6", + "longcat-2.0", + "deepseek-v4.1-flash", "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", + "minimax-m3", "minimax-m2.7", + "mimo-v2.5", "mimo-v2.5-pro", + "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", + "muse-spark-1.3-contributor", "muse-spark-1.2-contributor", + "hy4-preview", "hy3" + ], + "requiresApiKey": true, + "notes": "OpenAI 兼容订阅网关(/responses + /chat/completions),自动携带 x-opencode-session;订阅覆盖目录模型,刷新会同步目录。自建网关请用 `新建渠道`。", + "bindings": [ + { "id": "responses", "protocol": "OpenAIResponses", "authProfile": "Bearer", "endpointSuffix": "/v1" } + ], + "modelBindingRules": [ + { "bindingId": "responses", "prefixes": ["grok-", "gpt-"] } + ], + "catalogIsEntitlement": true + } + ] +} diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs b/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs index 582895de..bbe0929a 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs @@ -4,7 +4,7 @@ using TelegramSearchBot.Model.AI; namespace TelegramSearchBot.Service.AI.LLM { - /// A built-in provider preset: one code-defined entry of the provider catalog. + /// A built-in provider preset: one entry of the provider catalog. public sealed record LlmProviderPreset( string Id, string DisplayName, @@ -53,102 +53,22 @@ public static class LlmModelBindingRules { } /// - /// Code-defined provider catalog (pi-style): common providers ship preconfigured so the bot - /// admin only picks one and enters an API key. Custom endpoints are still supported by the - /// existing manual channel flow (新建渠道) and LLMApiBinding overrides. + /// Provider catalog (pi-style): presets ship as data (`Providers/providers.json`, embedded) so providers and + /// model lists can be updated without code changes — a user override at + /// `%LOCALAPPDATA%/TelegramSearchBot/providers.json` wins over the built-in copy. + /// Custom endpoints are still supported by the manual channel flow (新建渠道) and LLMApiBinding overrides. /// public static class LlmProviderCatalog { - public static readonly IReadOnlyList Presets = new[] { - new LlmProviderPreset( - "anthropic", "Anthropic 官方", LLMProvider.Anthropic, - "https://api.anthropic.com", - new[] { "claude-sonnet-4-5", "claude-opus-4-1", "claude-haiku-4-5" }, - RequiresApiKey: true), - new LlmProviderPreset( - "openai", "OpenAI 官方 (Chat Completions)", LLMProvider.OpenAI, - "https://api.openai.com/v1", - new[] { "gpt-4o", "gpt-4o-mini", "gpt-4.1" }, - RequiresApiKey: true), - new LlmProviderPreset( - "openai-responses", "OpenAI 官方 (Responses API)", LLMProvider.ResponsesAPI, - "https://api.openai.com/v1", - new[] { "gpt-4o", "gpt-4.1" }, - RequiresApiKey: true), - new LlmProviderPreset( - "gemini", "Google Gemini", LLMProvider.Gemini, - "https://generativelanguage.googleapis.com", - new[] { "gemini-2.0-flash", "gemini-2.5-pro" }, - RequiresApiKey: true), - new LlmProviderPreset( - "minimax", "MiniMax", LLMProvider.MiniMax, - "https://api.minimax.chat/v1", - new[] { "MiniMax-Text-01", "abab6.5s-chat" }, - RequiresApiKey: true), - new LlmProviderPreset( - "ollama", "本地 Ollama", LLMProvider.Ollama, - "http://localhost:11434", - Array.Empty(), - RequiresApiKey: false, - Notes: "本地服务无需 API Key;模型通过 `添加模型` 手动添加或自动发现。"), - new LlmProviderPreset( - "lmstudio", "本地 LM Studio", LLMProvider.LMStudio, - "http://localhost:1234/v1", - Array.Empty(), - RequiresApiKey: false, - Notes: "本地服务无需 API Key;模型通过 `添加模型` 手动添加。"), - new LlmProviderPreset( - "deepseek", "DeepSeek (OpenAI 兼容)", LLMProvider.OpenAI, - "https://api.deepseek.com/v1", - new[] { "deepseek-chat", "deepseek-reasoner" }, - RequiresApiKey: true), - new LlmProviderPreset( - "moonshot", "Moonshot Kimi (OpenAI 兼容)", LLMProvider.OpenAI, - "https://api.moonshot.cn/v1", - new[] { "kimi-k2-0711-preview", "moonshot-v1-128k" }, - RequiresApiKey: true), - // OpenCode 网关不是单协议服务商:Zen 按模型分属 Anthropic/OpenAI/Google 协议, - // Go 只有 Responses + Chat Completions。预设建渠道时按 ModelBindingRules 拆 binding。 - new LlmProviderPreset( - "opencode-zen", "OpenCode Zen (官方订阅目录)", LLMProvider.OpenAI, - "https://opencode.ai/zen/v1", - Array.Empty(), - RequiresApiKey: true, - Notes: "多协议网关(claude-*→Anthropic、gpt-*/grok-*→Responses、gemini-*→Google、其余→Chat Completions);按量计费,目录不自动创建,授权模型请用 `添加模型`。", - Bindings: new[] { - new LlmPresetBinding("anthropic", LlmProtocol.AnthropicMessages, LlmAuthProfile.AnthropicApiKey), - new LlmPresetBinding("responses", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer), - new LlmPresetBinding("google", LlmProtocol.Gemini, LlmAuthProfile.Bearer) - }, - ModelBindingRules: new[] { - new LlmModelBindingRule("anthropic", "claude-"), - new LlmModelBindingRule("responses", "gpt-", "grok-", "o3", "o4"), - new LlmModelBindingRule("google", "gemini-") - }), - new LlmProviderPreset( - "opencode-go", "OpenCode Go (订阅网关)", LLMProvider.OpenAI, - "https://opencode.ai/zen/go/v1", - new[] { - "grok-4.6", "gpt-5.6-luna", - "glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1", - "kimi-k3", "kimi-k2.7-code", "kimi-k2.6", - "longcat-2.0", - "deepseek-v4.1-flash", "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", - "minimax-m3", "minimax-m2.7", - "mimo-v2.5", "mimo-v2.5-pro", - "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", - "muse-spark-1.3-contributor", "muse-spark-1.2-contributor", - "hy4-preview", "hy3" - }, - RequiresApiKey: true, - Notes: "OpenAI 兼容订阅网关(/responses + /chat/completions),自动携带 x-opencode-session;订阅覆盖目录模型,刷新会同步目录。自建网关请用 `新建渠道`。", - Bindings: new[] { - new LlmPresetBinding("responses", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer) - }, - ModelBindingRules: new[] { - new LlmModelBindingRule("responses", "grok-", "gpt-") - }, - CatalogIsEntitlement: true), - }; + private static readonly Lazy Document = + new(() => LlmProviderCatalogLoader.Load(), LazyThreadSafetyMode.ExecutionAndPublication); + + public static IReadOnlyList Presets => Document.Value.Presets; + + /// 内置/覆盖目录的数据版本(providers.json 的 generatedAt)。 + public static DateTimeOffset? GeneratedAt => Document.Value.GeneratedAt; + + /// 目录来源(`builtin` 或 `file:`),便于排障。 + public static string Source => Document.Value.Source; public static LlmProviderPreset? FindById(string id) => Presets.FirstOrDefault(p => p.Id.Equals(id, StringComparison.OrdinalIgnoreCase)); diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalogLoader.cs b/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalogLoader.cs new file mode 100644 index 00000000..1332d85e --- /dev/null +++ b/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalogLoader.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using TelegramSearchBot.Common; + +namespace TelegramSearchBot.Service.AI.LLM { + /// 加载结果:预设列表 + 数据版本 + 来源(便于日志与测试)。 + public sealed record LlmProviderCatalogDocument( + DateTimeOffset? GeneratedAt, + IReadOnlyList Presets, + string Source, + string? Warning = null); + + /// + /// providers.json 加载器:优先用户覆盖 `%LOCALAPPDATA%/TelegramSearchBot/providers.json` + /// (不改代码、不发版即可增改服务商),其次程序集内置资源; + /// 覆盖文件损坏/为空时回退内置并在 Warning 中说明,绝不因目录问题启动失败。 + /// + public static class LlmProviderCatalogLoader { + public const string OverrideFileName = "providers.json"; + public const string BuiltInResourceName = "TelegramSearchBot.Service.AI.LLM.providers.json"; + + private static readonly JsonSerializerOptions SerializerOptions = new() { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + Converters = { new JsonStringEnumConverter() } + }; + + public static LlmProviderCatalogDocument Load(string? overridePath = null, Stream? builtIn = null) { + var path = overridePath ?? ResolveDefaultOverridePath(); + string? warning = null; + + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) { + try { + return Parse(File.ReadAllText(path), $"file:{path}"); + } catch (Exception ex) { + warning = $"providers.json 覆盖文件解析失败,回退内置目录: {ex.Message}"; + } + } else if (overridePath != null) { + warning = $"providers.json 覆盖文件不存在,使用内置目录: {overridePath}"; + } + + if (builtIn == null) { + using var stream = typeof(LlmProviderCatalogLoader).Assembly.GetManifestResourceStream(BuiltInResourceName) + ?? throw new InvalidOperationException($"内置预设目录资源缺失: {BuiltInResourceName}"); + using var reader = new StreamReader(stream); + return Parse(reader.ReadToEnd(), "builtin", warning); + } + + builtIn.Position = 0; + using (var reader = new StreamReader(builtIn, leaveOpen: true)) { + return Parse(reader.ReadToEnd(), "builtin", warning); + } + } + + /// Env.WorkDir 需要 Config.json,测试/无配置环境下退回空路径(只用内置目录)。 + private static string ResolveDefaultOverridePath() { + try { + return Path.Combine(Env.WorkDir, OverrideFileName); + } catch { + return string.Empty; + } + } + + private static LlmProviderCatalogDocument Parse(string json, string source, string? warning = null) { + var file = JsonSerializer.Deserialize(json, SerializerOptions) + ?? throw new InvalidOperationException("预设目录内容为空"); + if (file.Presets == null || file.Presets.Count == 0) { + throw new InvalidOperationException("预设目录里没有任何 preset"); + } + + return new LlmProviderCatalogDocument(file.GeneratedAt, file.Presets, source, warning); + } + + private sealed record CatalogFile(DateTimeOffset? GeneratedAt, List Presets); + } +} diff --git a/TelegramSearchBot.LLM/TelegramSearchBot.LLM.csproj b/TelegramSearchBot.LLM/TelegramSearchBot.LLM.csproj index 87206f1d..986e4bad 100644 --- a/TelegramSearchBot.LLM/TelegramSearchBot.LLM.csproj +++ b/TelegramSearchBot.LLM/TelegramSearchBot.LLM.csproj @@ -1,35 +1,42 @@ - - - - net10.0 - enable - warnings - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + net10.0 + enable + warnings + + + + + + + + + + + TelegramSearchBot.Service.AI.LLM.providers.json + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs b/TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs index 705436b4..1a5f94c0 100644 --- a/TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs +++ b/TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs @@ -496,9 +496,8 @@ private async Task SeedChannelWithDefaultBinding(int id, string n [Fact] public async Task RefreshAllChannel_OpenCodeBinding_CreatesNoRowsAndSoftDeletesNothing() { - // Arrange: OpenCode 默认 binding(opencode.ai/zen/* 空间)——目录不是授权快照, - // 刷新不得创建行、不得软删/复活任何行(blueprint §四.1/.5) - const string openCodeEndpoint = "https://opencode.ai/zen/go/v1/chat/completions"; + // Arrange: OpenCode Zen 是按量计费目录(非授权快照),刷新不得创建行、不得软删/复活任何行(blueprint §四.1/.5) + const string openCodeEndpoint = "https://opencode.ai/zen/v1/chat/completions"; var binding = await SeedChannelWithDefaultBinding(20, "OpenCode", LLMProvider.OpenAI, gateway: openCodeEndpoint, endpoint: openCodeEndpoint); await _context.ChannelsWithModel.AddRangeAsync(new[] { @@ -738,6 +737,103 @@ public async Task SetModelPreferred_UnknownRowRejected() { Assert.False(ok); } + /// OpenCode Go 是订阅网关(目录即授权):只增不删;既有行/手工行不被覆盖或删除。 + [Fact] + public async Task RefreshAllChannel_OpenCodeGo_SyncsCatalogAddOnly() { + // Arrange + const string goEndpoint = "https://opencode.ai/zen/go/v1"; + var binding = await SeedChannelWithDefaultBinding(40, "OpenCode Go", LLMProvider.OpenAI, + gateway: goEndpoint, endpoint: goEndpoint); + await _context.ChannelsWithModel.AddRangeAsync(new[] { + new ChannelWithModel { LLMChannelId = 40, ModelName = "existing-manual", IsDeleted = false, AuthorizationSource = AuthorizationSource.Manual }, + new ChannelWithModel { LLMChannelId = 40, ModelName = "restored-discovered", IsDeleted = true, AuthorizationSource = AuthorizationSource.Discovered, ApiBindingId = binding.Id }, + new ChannelWithModel { LLMChannelId = 40, ModelName = "catalog-dropped", IsDeleted = false, AuthorizationSource = AuthorizationSource.Discovered, ApiBindingId = binding.Id } + }); + await _context.SaveChangesAsync(); + + _openAIServiceMock.Setup(o => o.GetAllModels(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { "new-model", "existing-manual", "restored-discovered" }); + + // Act + await _helper.RefreshAllChannel(); + + // Assert: 新模型以 Discovered 行加入;既有行不重复;软删行恢复;目录里没有的行不被软删 + var rows = await _context.ChannelsWithModel.Where(m => m.LLMChannelId == 40).ToListAsync(); + Assert.Single(rows.Where(r => r.ModelName == "new-model")); + Assert.Single(rows.Where(r => r.ModelName == "existing-manual")); + Assert.False(rows.Single(r => r.ModelName == "restored-discovered").IsDeleted); + Assert.False(rows.Single(r => r.ModelName == "catalog-dropped").IsDeleted); + } + + [Fact] + public async Task PromoteBinding_SwitchesDefaultAndMirrorsChannel() { + // Arrange + var defaultBinding = await SeedChannelWithDefaultBinding(41, "Multi Prot", LLMProvider.OpenAI, gateway: "http://a", endpoint: "http://a"); + var responsesBinding = new LLMApiBinding { + LLMChannelId = 41, + Endpoint = "http://b", + Protocol = LlmProtocol.OpenAIResponses, + AuthProfile = LlmAuthProfile.Bearer, + IsDefault = false + }; + await _context.LLMApiBindings.AddAsync(responsesBinding); + await _context.SaveChangesAsync(); + + // Act + var ok = await _helper.PromoteBinding(41, responsesBinding.Id); + + // Assert + Assert.True(ok); + Assert.True((await _context.LLMApiBindings.FindAsync(responsesBinding.Id))!.IsDefault); + Assert.False((await _context.LLMApiBindings.FindAsync(defaultBinding.Id))!.IsDefault); + var channel = await _context.LLMChannels.FindAsync(41); + Assert.Equal("http://b", channel.Gateway); + Assert.Equal(LLMProvider.ResponsesAPI, channel.Provider); + } + + [Fact] + public async Task GetModelRowsByChannelId_QualifiesDuplicateNamesAndRemoveModelRowKeepsTheOther() { + // Arrange + var bindingA = await SeedChannelWithDefaultBinding(42, "Multi Model", LLMProvider.OpenAI, gateway: "http://a", endpoint: "http://a"); + var bindingB = new LLMApiBinding { LLMChannelId = 42, Endpoint = "http://b", Protocol = LlmProtocol.OpenAIResponses, AuthProfile = LlmAuthProfile.Bearer, IsDefault = false }; + await _context.LLMApiBindings.AddAsync(bindingB); + await _context.SaveChangesAsync(); + + var rowA = new ChannelWithModel { LLMChannelId = 42, ModelName = "shared-model", ApiBindingId = bindingA.Id }; + var rowB = new ChannelWithModel { LLMChannelId = 42, ModelName = "shared-model", ApiBindingId = bindingB.Id }; + var rowC = new ChannelWithModel { LLMChannelId = 42, ModelName = "single-model", ApiBindingId = bindingA.Id }; + await _context.ChannelsWithModel.AddRangeAsync(rowA, rowB, rowC); + await _context.SaveChangesAsync(); + + // Act + var rows = await _helper.GetModelRowsByChannelId(42); + + // Assert: 重名行带协议标注,唯一行保持原名 + Assert.Equal(3, rows.Count); + Assert.Equal(2, rows.Count(r => r.Display.Contains("shared-model ["))); + Assert.Contains(rows, r => r.Display == "single-model"); + + // 按行 Id 删除只影响目标行 + var rowToRemove = rows.First(r => r.RowId == rowB.Id); + Assert.True(await _helper.RemoveModelRow(rowToRemove.RowId)); + var remaining = await _context.ChannelsWithModel.Where(m => m.LLMChannelId == 42).ToListAsync(); + Assert.Equal(2, remaining.Count); + Assert.DoesNotContain(remaining, r => r.Id == rowB.Id); + Assert.Contains(remaining, r => r.Id == rowA.Id); + } + + [Fact] + public async Task GetBindings_ReturnsChannelBindingsOrderedById() { + await SeedChannelWithDefaultBinding(43, "Bindings", LLMProvider.OpenAI, gateway: "http://a", endpoint: "http://a"); + await _context.LLMApiBindings.AddAsync(new LLMApiBinding { LLMChannelId = 43, Endpoint = "http://b", Protocol = LlmProtocol.OpenAIResponses, AuthProfile = LlmAuthProfile.Bearer }); + await _context.SaveChangesAsync(); + + var bindings = await _helper.GetBindings(43); + + Assert.Equal(2, bindings.Count); + Assert.True(bindings[0].Id < bindings[1].Id); + } + [Fact] public async Task UpdateChannel_GatewayAndProviderChange_SyncsDefaultBinding() { // Arrange diff --git a/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs b/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs index e02de206..f6c9efc2 100644 --- a/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs +++ b/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs @@ -255,61 +255,43 @@ public async Task ExecuteAsync_RemoveModel_CompleteFlow() { ApiKey = "test-key", Provider = LLMProvider.OpenAI }; - await _context.LLMChannels.AddAsync(channel); - await _context.ChannelsWithModel.AddAsync(new ChannelWithModel { - LLMChannelId = 1, - ModelName = "model1" - }); - // Add model2 to the in-memory database - await _context.ChannelsWithModel.AddAsync(new ChannelWithModel { - LLMChannelId = 1, - ModelName = "model2" - }); - await _context.SaveChangesAsync(); var stateKey = $"llmconf:{chatId}:state"; var dataKey = $"llmconf:{chatId}:data"; - // Setup state transitions + // 1. initial state, 2. state after "移除模型", 3. state after channel input, 4. row id payload _dbMock.SetupSequence(d => d.StringGetAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(RedisValue.Null) // 1. Initial state before "移除模型" - .ReturnsAsync("removing_model_select_channel") // 2. State after "移除模型" command - .ReturnsAsync("removing_model_select") // 3. State after channel ID input - .ReturnsAsync("1|model1,model2"); // 4. Data read by HandleRemovingModelSelectAsync - - // Setup helper mock to return channel - helperMock.Setup(h => h.GetChannelById(1)) - .ReturnsAsync(channel); - - // Setup helper mock to return a channel in GetAllChannels, which should trigger GetModelsByChannelId in service (but service doesn't call helper here) - // This setup is still needed for the initial "移除模型" command which lists channels. - helperMock.Setup(h => h.GetAllChannels()) - .ReturnsAsync(new List { channel }); + .ReturnsAsync(RedisValue.Null) + .ReturnsAsync("removing_model_select_channel") + .ReturnsAsync("removing_model_select") + .ReturnsAsync("1|101,102"); + + helperMock.Setup(h => h.GetChannelById(1)).ReturnsAsync(channel); + helperMock.Setup(h => h.GetAllChannels()).ReturnsAsync(new List { channel }); + helperMock.Setup(h => h.GetModelRowsByChannelId(1)).ReturnsAsync(new List<(long, string, string)> { + (101, "model1", "model1"), + (102, "model2", "model2") + }); + helperMock.Setup(h => h.RemoveModelRow(It.IsAny())).ReturnsAsync(true); // Act & Assert var result1 = await _service.ExecuteAsync("移除模型", chatId); Assert.True(result1.Item1); Assert.Contains("请选择要移除模型的渠道ID:", result1.Item2); - // Verify the in-memory database contains the expected models - var modelsInDb = await _context.ChannelsWithModel.Where(m => m.LLMChannelId == 1).ToListAsync(); - Assert.NotNull(modelsInDb); - Assert.Equal(2, modelsInDb.Count); - Assert.Contains(modelsInDb, m => m.ModelName == "model1"); - Assert.Contains(modelsInDb, m => m.ModelName == "model2"); - - var result2 = await _service.ExecuteAsync("1", chatId); // User inputs channel ID + var result2 = await _service.ExecuteAsync("1", chatId); Assert.True(result2.Item1); - // Verify the response lists models Assert.Contains("请选择要移除的模型:", result2.Item2); Assert.Contains("1. model1", result2.Item2); + Assert.Contains("2. model2", result2.Item2); - var result3 = await _service.ExecuteAsync("1", chatId); // User inputs model index (1 for model1) + var result3 = await _service.ExecuteAsync("1", chatId); // 选择第一行 Assert.True(result3.Item1); Assert.Equal("模型移除成功", result3.Item2); - // Verify model was removed by helper (as service calls helper) - helperMock.Verify(h => h.RemoveModelFromChannel(1, "model1"), Times.Once); + // 按行 Id 删除(不是按模型名,多 binding 下不会删错行) + helperMock.Verify(h => h.RemoveModelRow(101), Times.Once); + helperMock.Verify(h => h.RemoveModelRow(It.IsAny()), Times.Once); // Verify Redis keys are deleted _dbMock.Verify(d => d.KeyDeleteAsync(stateKey, It.IsAny()), Times.Once); @@ -553,5 +535,83 @@ public async Task ExecuteAsync_PresetOpenCodeGo_CreatesResponseBindingAndAssigns helperMock.Verify(h => h.AssignModelBinding(7, "glm-5.3", It.IsAny()), Times.Never); helperMock.Verify(h => h.AssignModelBinding(7, It.IsAny(), It.IsAny()), Times.Exactly(2)); } + + [Fact] + public async Task ExecuteAsync_EditChannelBindings_PromotesSelectedBinding() { + // Arrange + long chatId = 123; + var channel = new LLMChannel { Id = 1, Name = "Test Channel", Provider = LLMProvider.OpenAI, Gateway = "http://test.com" }; + var binding = new LLMApiBinding { Id = 5, LLMChannelId = 1, Endpoint = "http://test.com/v1", Protocol = LlmProtocol.OpenAIResponses, AuthProfile = LlmAuthProfile.Bearer }; + + _dbMock.SetupSequence(d => d.StringGetAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(RedisValue.Null) + .ReturnsAsync("editing_select_channel") + .ReturnsAsync("editing_select_field") + .ReturnsAsync("1") + .ReturnsAsync("editing_select_binding") + .ReturnsAsync("1|7"); + + helperMock.Setup(h => h.GetAllChannels()).ReturnsAsync(new List { channel }); + helperMock.Setup(h => h.GetChannelById(1)).ReturnsAsync(channel); + helperMock.Setup(h => h.GetBindings(1)).ReturnsAsync(new List { binding }); + helperMock.Setup(h => h.PromoteBinding(1, 5)).ReturnsAsync(true); + + // Act + var result1 = await _service.ExecuteAsync("编辑渠道", chatId); + Assert.True(result1.Item1); + + var result2 = await _service.ExecuteAsync("1", chatId); + Assert.Contains("7. 协议绑定", result2.Item2); + + var result3 = await _service.ExecuteAsync("7", chatId); + Assert.True(result3.Item1); + Assert.Contains("5. http://test.com/v1 (OpenAIResponses/Bearer)", result3.Item2); + Assert.Contains("0 新增绑定", result3.Item2); + + var result4 = await _service.ExecuteAsync("5", chatId); + + // Assert + Assert.True(result4.Item1); + Assert.Contains("设为默认绑定", result4.Item2); + helperMock.Verify(h => h.PromoteBinding(1, 5), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_EditChannelBindings_AddsNewBinding() { + // Arrange + long chatId = 123; + var existing = new LLMApiBinding { Id = 3, LLMChannelId = 1, Endpoint = "https://opencode.ai/zen/v1", Protocol = LlmProtocol.OpenAIChat, AuthProfile = LlmAuthProfile.Bearer, IsDefault = true }; + + _dbMock.SetupSequence(d => d.StringGetAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("editing_select_field").ReturnsAsync("1") + .ReturnsAsync("editing_select_binding").ReturnsAsync("1|7") + .ReturnsAsync("editing_binding_endpoint").ReturnsAsync("1|new") + .ReturnsAsync("editing_binding_protocol").ReturnsAsync("1|https://opencode.ai/zen/v1") + .ReturnsAsync("editing_binding_auth").ReturnsAsync("1|https://opencode.ai/zen/v1|OpenAIResponses"); + + helperMock.Setup(h => h.GetBindings(1)).ReturnsAsync(new List { existing }); + helperMock.Setup(h => h.EnsureBinding(1, "https://opencode.ai/zen/v1", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer)).ReturnsAsync(55); + + // Act + var openBindings = await _service.ExecuteAsync("7", chatId); + Assert.Contains("3. https://opencode.ai/zen/v1", openBindings.Item2); + + var askEndpoint = await _service.ExecuteAsync("0", chatId); + Assert.Contains("请输入新绑定的端点地址", askEndpoint.Item2); + + var askProtocol = await _service.ExecuteAsync("https://opencode.ai/zen/v1", chatId); + Assert.Contains("请选择线协议", askProtocol.Item2); + Assert.Contains("2. OpenAIResponses", askProtocol.Item2); + + var askAuth = await _service.ExecuteAsync("2", chatId); + Assert.Contains("请选择认证方式", askAuth.Item2); + + var created = await _service.ExecuteAsync("1", chatId); + + // Assert + Assert.True(created.Item1); + Assert.Contains("绑定创建成功(ID: 55", created.Item2); + helperMock.Verify(h => h.EnsureBinding(1, "https://opencode.ai/zen/v1", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer), Times.Once); + } } } diff --git a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs index 01f6644d..b9c18c25 100644 --- a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs +++ b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs @@ -262,6 +262,14 @@ public static async Task Startup(string[] args) { Log.Warning(ex, "Failed to export tool definitions to Redis. Agent processes may have limited tools."); } + // 预加载并记录预设目录来源/版本(providers.json 覆盖或内置) + try { + Log.Information("LLM 预设目录已加载: {Source} (generatedAt={GeneratedAt}, presets={Count})", + LlmProviderCatalog.Source, LlmProviderCatalog.GeneratedAt, LlmProviderCatalog.Presets.Count); + } catch (Exception ex) { + Log.Error(ex, "LLM 预设目录加载失败,预制渠道将不可用"); + } + // 启动Host,SchedulerService作为HostedService会自动启动 await host.StartAsync(); Log.Information("Host已启动,定时任务调度器已作为后台服务启动"); diff --git a/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs b/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs index 528e5545..d3740f14 100644 --- a/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs +++ b/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs @@ -37,5 +37,20 @@ public interface IEditLLMConfHelper { /// 把模型行改挂到指定 binding,并标记为模型级协议覆盖;成功返回 true。 Task AssignModelBinding(int channelId, string modelName, int bindingId); + + /// 渠道下的全部协议绑定(按 Id 升序)。 + Task> GetBindings(int channelId); + + /// 把指定 binding 设为渠道默认(其余降级),并镜像 channel.Gateway/Provider;成功返回 true。 + Task PromoteBinding(int channelId, int bindingId); + + /// + /// 渠道下的模型行(按行 Id),展示格式与 GetModelsByChannelId 一致(多 binding 时带 [channel/binding/protocol]); + /// 移除模型按行 Id 删除,避免多 binding 下删错行。 + /// + Task> GetModelRowsByChannelId(long channelId); + + /// 按行 Id 删除模型行;行不存在返回 false。 + Task RemoveModelRow(long rowId); } } diff --git a/TelegramSearchBot/Model/AI/LLMConfState.cs b/TelegramSearchBot/Model/AI/LLMConfState.cs index 1734008a..723d9954 100644 --- a/TelegramSearchBot/Model/AI/LLMConfState.cs +++ b/TelegramSearchBot/Model/AI/LLMConfState.cs @@ -32,6 +32,18 @@ public enum LLMConfState { [Description("editing_select_channel")] EditingSelectChannel, + [Description("editing_select_binding")] + EditingSelectBinding, + + [Description("editing_binding_endpoint")] + EditingBindingEndpoint, + + [Description("editing_binding_protocol")] + EditingBindingProtocol, + + [Description("editing_binding_auth")] + EditingBindingAuth, + [Description("adding_model_select_channel")] AddingModelSelectChannel, diff --git a/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs b/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs index 9604212e..d50a5b8e 100644 --- a/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs +++ b/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs @@ -761,6 +761,82 @@ public async Task AssignModelBinding(int channelId, string modelName, int } } + /// 渠道下的全部协议绑定(按 Id 升序)。 + public async Task> GetBindings(int channelId) { + try { + return await DataContext.LLMApiBindings + .Where(b => b.LLMChannelId == channelId) + .OrderBy(b => b.Id) + .ToListAsync(); + } catch { + return new List(); + } + } + + /// 把指定 binding 设为渠道默认(其余降级),并镜像 channel.Gateway/Provider(blueprint §七)。 + public async Task PromoteBinding(int channelId, int bindingId) { + try { + var channel = await DataContext.LLMChannels.Include(c => c.Bindings).FirstOrDefaultAsync(c => c.Id == channelId); + if (channel == null) { + return false; + } + + var target = channel.Bindings.FirstOrDefault(b => b.Id == bindingId); + if (target == null) { + _logger.LogWarning("设置默认 binding 失败:渠道 {ChannelId} 不存在 binding {BindingId}", channelId, bindingId); + return false; + } + + foreach (var binding in channel.Bindings) { + binding.IsDefault = binding.Id == target.Id; + } + channel.Gateway = target.Endpoint; + channel.Provider = MapProtocolToProvider(target.Protocol); + await DataContext.SaveChangesAsync(); + return true; + } catch (Exception ex) { + _logger.LogError(ex, "把 binding {BindingId} 设为渠道 {ChannelId} 默认失败", bindingId, channelId); + return false; + } + } + + /// 渠道下的模型行(按行 Id),展示格式与 GetModelsByChannelId 一致(多 binding 带协议标注)。 + public async Task> GetModelRowsByChannelId(long channelId) { + var rows = await DataContext.ChannelsWithModel + .Include(c => c.ApiBinding) + .Include(c => c.LLMChannel) + .Where(c => c.LLMChannelId == channelId && !c.IsDeleted) + .OrderBy(c => c.ModelName).ThenBy(c => c.Id) + .ToListAsync(); + + var multiBindingNames = rows + .GroupBy(r => r.ModelName, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return rows + .Select(r => ((long)r.Id, r.ModelName, multiBindingNames.Contains(r.ModelName) ? FormatModelDisplay(r) : r.ModelName)) + .ToList(); + } + + /// 按行 Id 删除模型行(多 binding 下不会误删同名的其它行)。 + public async Task RemoveModelRow(long rowId) { + try { + var row = await DataContext.ChannelsWithModel.FirstOrDefaultAsync(m => m.Id == rowId); + if (row == null) { + return false; + } + + DataContext.ChannelsWithModel.Remove(row); + await DataContext.SaveChangesAsync(); + return true; + } catch (Exception ex) { + _logger.LogError(ex, "删除模型行 {RowId} 失败", rowId); + return false; + } + } + /// /// channel Gateway/Provider 变更时同步默认 binding(镜像规则,blueprint §七)。 /// 无默认 binding 时不创建(补建由 EnsureDefaultBinding 在 AddChannel/RefreshAllChannel 负责)。 diff --git a/TelegramSearchBot/Service/Manage/EditLLMConfService.cs b/TelegramSearchBot/Service/Manage/EditLLMConfService.cs index 2a268d48..41d341bd 100644 --- a/TelegramSearchBot/Service/Manage/EditLLMConfService.cs +++ b/TelegramSearchBot/Service/Manage/EditLLMConfService.cs @@ -61,6 +61,10 @@ MusicGenerationToolSettingsService musicGenerationToolSettingsService { LLMConfState.SettingAltPhotoModel.GetDescription(), HandleSettingAltPhotoModelAsync }, { LLMConfState.EditingSelectChannel.GetDescription(), HandleEditingSelectChannelAsync }, { LLMConfState.EditingSelectField.GetDescription(), HandleEditingSelectFieldAsync }, + { LLMConfState.EditingSelectBinding.GetDescription(), HandleEditingSelectBindingAsync }, + { LLMConfState.EditingBindingEndpoint.GetDescription(), HandleEditingBindingEndpointAsync }, + { LLMConfState.EditingBindingProtocol.GetDescription(), HandleEditingBindingProtocolAsync }, + { LLMConfState.EditingBindingAuth.GetDescription(), HandleEditingBindingAuthAsync }, { LLMConfState.AddingModelSelectChannel.GetDescription(), HandleAddingModelSelectChannelAsync }, { LLMConfState.AddingModelInput.GetDescription(), HandleAddingModelInputAsync }, { LLMConfState.RemovingModelSelectChannel.GetDescription(), HandleRemovingModelSelectChannelAsync }, @@ -341,7 +345,7 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration await redis.SetDataAsync(channelId.ToString()); await redis.SetStateAsync(LLMConfState.EditingSelectField.GetDescription()); - return (true, $"请选择要编辑的字段:\n1. 名称 ({channel.Name})\n2. 地址 ({channel.Gateway})\n3. 类型 ({channel.Provider})\n4. API Key\n5. 最大并行数量 ({channel.Parallel})\n6. 优先级 ({channel.Priority})"); + return (true, $"请选择要编辑的字段:\n1. 名称 ({channel.Name})\n2. 地址 ({channel.Gateway})\n3. 类型 ({channel.Provider})\n4. API Key\n5. 最大并行数量 ({channel.Parallel})\n6. 优先级 ({channel.Priority})\n7. 协议绑定"); } private async Task<(bool, string)> HandleEditingSelectFieldAsync(EditLLMConfRedisHelper redis, string command) { @@ -349,6 +353,10 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration var editChannelId = int.Parse(value); await redis.SetDataAsync($"{editChannelId}|{command}"); + if (command == "7") { + return await ShowBindingSelectionAsync(redis, editChannelId); + } + if (command == "3") { await redis.SetStateAsync(LLMConfState.EditingInputValue.GetDescription()); @@ -369,6 +377,135 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration } } + /// 列出渠道的协议绑定并询问操作(输入编号设为默认,0 新增)。 + private async Task<(bool, string)> ShowBindingSelectionAsync(EditLLMConfRedisHelper redis, int channelId) { + var bindings = await Helper.GetBindings(channelId); + if (bindings.Count == 0) { + await redis.DeleteKeysAsync(); + return (true, "该渠道没有任何协议绑定"); + } + + var sb = new StringBuilder($"渠道 {channelId} 的协议绑定:\n"); + foreach (var binding in bindings) { + var marker = binding.IsDefault ? " [默认]" : string.Empty; + sb.AppendLine($"{binding.Id}. {binding.Endpoint} ({binding.Protocol}/{binding.AuthProfile}){marker}"); + } + sb.Append("请输入要设为默认的绑定ID,或发送 0 新增绑定:"); + + await redis.SetStateAsync(LLMConfState.EditingSelectBinding.GetDescription()); + return (true, sb.ToString()); + } + + private async Task<(bool, string)> HandleEditingSelectBindingAsync(EditLLMConfRedisHelper redis, string command) { + var data = await redis.GetDataAsync(); + var parts = data.Split('|'); + if (parts.Length < 2 || !int.TryParse(parts[0], out var channelId)) { + await redis.DeleteKeysAsync(); + return (true, "内部错误:渠道状态丢失"); + } + + var bindings = await Helper.GetBindings(channelId); + var cmd = command.Trim(); + + if (cmd == "0" || cmd.Equals("新增", StringComparison.OrdinalIgnoreCase)) { + await redis.SetDataAsync($"{channelId}|new"); + await redis.SetStateAsync(LLMConfState.EditingBindingEndpoint.GetDescription()); + return (true, "请输入新绑定的端点地址(例如 https://opencode.ai/zen/v1):"); + } + + if (!int.TryParse(cmd, out var bindingId) || bindings.All(b => b.Id != bindingId)) { + return (false, "请输入列表中的绑定ID,或发送 0 新增绑定"); + } + + if (!await Helper.PromoteBinding(channelId, bindingId)) { + await redis.DeleteKeysAsync(); + return (true, "设置为默认绑定失败"); + } + + await redis.DeleteKeysAsync(); + return (true, $"已将 binding {bindingId} 设为默认绑定(渠道地址与类型已同步)。"); + } + + private async Task<(bool, string)> HandleEditingBindingEndpointAsync(EditLLMConfRedisHelper redis, string command) { + var data = await redis.GetDataAsync(); + var parts = data.Split('|'); + if (parts.Length < 2 || !int.TryParse(parts[0], out var channelId)) { + await redis.DeleteKeysAsync(); + return (true, "内部错误:渠道状态丢失"); + } + + var endpoint = command.Trim(); + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) || + ( uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps )) { + return (false, "请输入有效的端点地址(以 http:// 或 https:// 开头)"); + } + + await redis.SetDataAsync($"{channelId}|{endpoint}"); + await redis.SetStateAsync(LLMConfState.EditingBindingProtocol.GetDescription()); + return (true, FormatProtocolOptions()); + } + + private async Task<(bool, string)> HandleEditingBindingProtocolAsync(EditLLMConfRedisHelper redis, string command) { + var data = await redis.GetDataAsync(); + var parts = data.Split('|'); + if (parts.Length < 2 || !int.TryParse(parts[0], out var channelId)) { + await redis.DeleteKeysAsync(); + return (true, "内部错误:渠道状态丢失"); + } + + var protocols = Enum.GetValues(); + if (!int.TryParse(command.Trim(), out var index) || index < 1 || index > protocols.Length) { + return (false, $"请输入 1 到 {protocols.Length} 之间的数字"); + } + + await redis.SetDataAsync($"{channelId}|{parts[1]}|{protocols[index - 1]}"); + await redis.SetStateAsync(LLMConfState.EditingBindingAuth.GetDescription()); + return (true, FormatAuthOptions()); + } + + private async Task<(bool, string)> HandleEditingBindingAuthAsync(EditLLMConfRedisHelper redis, string command) { + var data = await redis.GetDataAsync(); + var parts = data.Split('|', 3); + if (parts.Length < 3 || !int.TryParse(parts[0], out var channelId)) { + await redis.DeleteKeysAsync(); + return (true, "内部错误:绑定状态丢失"); + } + + var authProfiles = Enum.GetValues(); + if (!int.TryParse(command.Trim(), out var index) || index < 1 || index > authProfiles.Length) { + return (false, $"请输入 1 到 {authProfiles.Length} 之间的数字"); + } + + if (!Enum.TryParse(parts[2], out var protocol)) { + await redis.DeleteKeysAsync(); + return (true, "内部错误:协议解析失败"); + } + + var bindingId = await Helper.EnsureBinding(channelId, parts[1], protocol, authProfiles[index - 1]); + await redis.DeleteKeysAsync(); + return bindingId > 0 + ? (true, $"绑定创建成功(ID: {bindingId},{protocol}/{authProfiles[index - 1]})。可用 `编辑渠道` 第 7 项把它设为默认。") + : (true, "绑定创建失败"); + } + + private static string FormatProtocolOptions() { + var protocols = Enum.GetValues(); + var sb = new StringBuilder("请选择线协议:\n"); + for (var i = 0; i < protocols.Length; i++) { + sb.AppendLine($"{i + 1}. {protocols[i]}"); + } + return sb.ToString().TrimEnd(); + } + + private static string FormatAuthOptions() { + var authProfiles = Enum.GetValues(); + var sb = new StringBuilder("请选择认证方式:\n"); + for (var i = 0; i < authProfiles.Length; i++) { + sb.AppendLine($"{i + 1}. {authProfiles[i]}"); + } + return sb.ToString().TrimEnd(); + } + private async Task<(bool, string)> HandleAddingModelSelectChannelAsync(EditLLMConfRedisHelper redis, string command) { if (!int.TryParse(command, out var addModelChannelId)) { return (false, "请输入有效的渠道ID"); @@ -404,23 +541,20 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration return (true, "找不到指定的渠道"); } - // 获取该渠道下的所有模型 - var models = await DataContext.ChannelsWithModel - .Where(m => m.LLMChannelId == removeModelChannelId && !m.IsDeleted) - .Select(m => m.ModelName) - .ToListAsync(); + // 获取该渠道下的所有模型行(多 binding 时带协议标注;按行 Id 删除避免误删同名行) + var modelRows = await Helper.GetModelRowsByChannelId(removeModelChannelId); - if (models.Count == 0) { + if (modelRows.Count == 0) { return (true, "该渠道下没有可移除的模型"); } var sb = new StringBuilder(); sb.AppendLine("请选择要移除的模型:"); - for (int i = 0; i < models.Count; i++) { - sb.AppendLine($"{i + 1}. {models[i]}"); + for (int i = 0; i < modelRows.Count; i++) { + sb.AppendLine($"{i + 1}. {modelRows[i].Display}"); } - await redis.SetDataAsync($"{removeModelChannelId}|{string.Join(",", models)}"); + await redis.SetDataAsync($"{removeModelChannelId}|{string.Join(",", modelRows.Select(r => r.RowId))}"); await redis.SetStateAsync(LLMConfState.RemovingModelSelect.GetDescription()); return (true, sb.ToString()); } @@ -433,23 +567,21 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration return (false, "内部错误:模型数据格式不正确"); } - var removeChannelId = int.Parse(parts[0]); - var modelList = parts[1].Split(','); + var rowIds = parts[1].Split(',', StringSplitOptions.RemoveEmptyEntries); - if (modelList.Length == 0 || ( modelList.Length == 1 && string.IsNullOrEmpty(modelList[0]) )) { + if (rowIds.Length == 0) { return (true, "该渠道下没有可移除的模型"); } - if (!int.TryParse(command, out var modelIndex) || modelIndex < 1 || modelIndex > modelList.Length) { + if (!int.TryParse(command, out var modelIndex) || modelIndex < 1 || modelIndex > rowIds.Length) { return (true, "请输入有效的模型序号"); } - if (modelIndex - 1 < 0 || modelIndex - 1 >= modelList.Length) { - return (true, "内部错误:无效的模型序号"); + if (!long.TryParse(rowIds[modelIndex - 1], out var rowId)) { + return (true, "内部错误:模型行ID无效"); } - var modelName = modelList[modelIndex - 1]; - var removeResult = await Helper.RemoveModelFromChannel(removeChannelId, modelName); + var removeResult = await Helper.RemoveModelRow(rowId); // 清理状态 await redis.DeleteKeysAsync();