2026-08-26 18:06:02 +08:00
|
|
|
|
using System.ClientModel;
|
|
|
|
|
|
using Azure.AI.OpenAI;
|
|
|
|
|
|
using Microsoft.Agents.AI;
|
|
|
|
|
|
using Microsoft.Extensions.AI;
|
|
|
|
|
|
using Microsoft.Extensions.Configuration;
|
|
|
|
|
|
using OpenAI;
|
|
|
|
|
|
using OpenAI.Chat;
|
|
|
|
|
|
|
|
|
|
|
|
namespace MAF1.Utils;
|
|
|
|
|
|
|
2026-08-28 11:51:48 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 创建聊天 Agent 的工厂。根据 Endpoint 判断走 Azure OpenAI 还是 OpenAI 兼容接口(DeepSeek 等)。
|
|
|
|
|
|
/// 密钥优先级:appsettings Llm → 环境变量。浏览器永远拿不到 Key。
|
|
|
|
|
|
/// </summary>
|
2026-08-26 18:06:02 +08:00
|
|
|
|
public sealed class AgentFactory
|
|
|
|
|
|
{
|
|
|
|
|
|
private readonly ChatClient _chatClient;
|
2026-09-01 17:56:41 +08:00
|
|
|
|
private readonly bool _disableDeepSeekThinking;
|
2026-08-26 18:06:02 +08:00
|
|
|
|
|
|
|
|
|
|
public AgentFactory(LlmOptions options)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(options.ApiKey))
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
|
"""
|
|
|
|
|
|
还没有配置模型。请在 appsettings.json 的 Llm 节点,或环境变量中设置:
|
|
|
|
|
|
OPENAI_API_KEY / Llm:ApiKey
|
|
|
|
|
|
OPENAI_ENDPOINT / Llm:Endpoint
|
|
|
|
|
|
OPENAI_CHAT_MODEL / Llm:Model
|
|
|
|
|
|
""");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
string model = string.IsNullOrWhiteSpace(options.Model)
|
|
|
|
|
|
? DefaultModelFor(options.Endpoint)
|
|
|
|
|
|
: options.Model;
|
|
|
|
|
|
|
2026-09-01 17:56:41 +08:00
|
|
|
|
_disableDeepSeekThinking = LooksLikeDeepSeek(options.Endpoint);
|
|
|
|
|
|
|
2026-08-26 18:06:02 +08:00
|
|
|
|
if (LooksLikeAzureOpenAI(options.Endpoint))
|
|
|
|
|
|
{
|
|
|
|
|
|
Console.Error.WriteLine($"使用 Azure OpenAI: {options.Endpoint} 部署: {model}");
|
|
|
|
|
|
_chatClient = new AzureOpenAIClient(new Uri(options.Endpoint), new ApiKeyCredential(options.ApiKey))
|
|
|
|
|
|
.GetChatClient(model);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
OpenAIClientOptions clientOptions = new();
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(options.Endpoint))
|
|
|
|
|
|
{
|
|
|
|
|
|
clientOptions.Endpoint = ToOpenAICompatibleEndpoint(options.Endpoint);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Console.Error.WriteLine($"使用 OpenAI 兼容接口: {clientOptions.Endpoint?.ToString() ?? "https://api.openai.com/v1"} 模型: {model}");
|
2026-09-01 17:56:41 +08:00
|
|
|
|
if (_disableDeepSeekThinking)
|
|
|
|
|
|
{
|
|
|
|
|
|
Console.Error.WriteLine("已关闭 DeepSeek thinking,避免 Handoff 多轮丢掉 reasoning_content 导致 HTTP 400。");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-26 18:06:02 +08:00
|
|
|
|
_chatClient = new OpenAIClient(new ApiKeyCredential(options.ApiKey), clientOptions)
|
|
|
|
|
|
.GetChatClient(model);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-01 17:56:41 +08:00
|
|
|
|
/// <summary>把 ChatClient 包成 Microsoft.Agents.AI 的 AIAgent,并挂上 tools。Id 固定为 name,Handoff 工具才是 handoff_to_n1。</summary>
|
|
|
|
|
|
public AIAgent CreateAgent(string name, string instructions, IList<AITool>? tools = null, string? description = null)
|
2026-08-26 18:06:02 +08:00
|
|
|
|
{
|
2026-09-01 17:56:41 +08:00
|
|
|
|
ChatOptions chatOptions = new()
|
|
|
|
|
|
{
|
|
|
|
|
|
Instructions = instructions,
|
|
|
|
|
|
Tools = tools,
|
|
|
|
|
|
};
|
|
|
|
|
|
ChatClientAgentOptions options = new()
|
|
|
|
|
|
{
|
|
|
|
|
|
Id = name,
|
|
|
|
|
|
Name = name,
|
|
|
|
|
|
Description = description,
|
|
|
|
|
|
ChatOptions = chatOptions,
|
|
|
|
|
|
};
|
|
|
|
|
|
return _chatClient.AsAIAgent(options, clientFactory: inner =>
|
|
|
|
|
|
_disableDeepSeekThinking
|
|
|
|
|
|
? new ConfigureOptionsChatClient(inner, DeepSeekThinking.Disable)
|
|
|
|
|
|
: inner);
|
2026-08-26 18:06:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 11:51:48 +08:00
|
|
|
|
/// <summary>合并配置文件和环境变量。插件进程里环境变量通常由宿主写入。</summary>
|
2026-08-26 18:06:02 +08:00
|
|
|
|
public static LlmOptions Load(IConfiguration config)
|
|
|
|
|
|
{
|
|
|
|
|
|
LlmOptions options = config.GetSection("Llm").Get<LlmOptions>() ?? new LlmOptions();
|
|
|
|
|
|
options.ApiKey = FirstNonEmpty(options.ApiKey, Env("OPENAI_API_KEY"), Env("AZURE_OPENAI_API_KEY"));
|
|
|
|
|
|
options.Endpoint = FirstNonEmpty(options.Endpoint, Env("OPENAI_ENDPOINT"), Env("OPENAI_BASE_URL"), Env("AZURE_OPENAI_ENDPOINT"));
|
|
|
|
|
|
options.Model = FirstNonEmpty(options.Model, Env("OPENAI_CHAT_MODEL"), Env("AZURE_OPENAI_DEPLOYMENT_NAME"));
|
|
|
|
|
|
return options;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static string DefaultModelFor(string? endpoint)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(endpoint) && endpoint.Contains("deepseek", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
|
{
|
|
|
|
|
|
return "deepseek-chat";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return "gpt-4o-mini";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-01 17:56:41 +08:00
|
|
|
|
private static bool LooksLikeDeepSeek(string? endpoint)
|
|
|
|
|
|
=> !string.IsNullOrWhiteSpace(endpoint)
|
|
|
|
|
|
&& endpoint.Contains("deepseek", StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
|
|
2026-08-26 18:06:02 +08:00
|
|
|
|
private static bool LooksLikeAzureOpenAI(string? endpoint)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri))
|
|
|
|
|
|
{
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
string host = uri.Host;
|
|
|
|
|
|
return host.Contains("openai.azure.com", StringComparison.OrdinalIgnoreCase)
|
|
|
|
|
|
|| host.Contains("cognitiveservices.azure.com", StringComparison.OrdinalIgnoreCase)
|
|
|
|
|
|
|| host.Contains("services.ai.azure.com", StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 11:51:48 +08:00
|
|
|
|
/// <summary>OpenAI 兼容 API 要求 base URL 以 /v1 结尾,用户常只填到域名。</summary>
|
2026-08-26 18:06:02 +08:00
|
|
|
|
private static Uri ToOpenAICompatibleEndpoint(string endpoint)
|
|
|
|
|
|
{
|
|
|
|
|
|
string trimmed = endpoint.TrimEnd('/');
|
|
|
|
|
|
if (!trimmed.EndsWith("/v1", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
|
{
|
|
|
|
|
|
trimmed += "/v1";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return new Uri(trimmed);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static string? Env(string name) => Environment.GetEnvironmentVariable(name);
|
|
|
|
|
|
|
|
|
|
|
|
private static string FirstNonEmpty(params string?[] values)
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (string? value in values)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
|
|
|
|
{
|
|
|
|
|
|
return value;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|