初始提交
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MAF1.Agents;
|
||||
|
||||
public sealed class AgentStepResult
|
||||
{
|
||||
public string Message { get; init; } = "";
|
||||
public Dictionary<string, object?> Inputs { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, object?> Outputs { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
internal static class AgentInputs
|
||||
{
|
||||
public static string? ReadString(IReadOnlyDictionary<string, object?> inputs, string key)
|
||||
{
|
||||
if (!inputs.TryGetValue(key, out object? value) || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value is JsonElement el)
|
||||
{
|
||||
return el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString();
|
||||
}
|
||||
|
||||
return value.ToString();
|
||||
}
|
||||
|
||||
public static List<string> ReadStringList(IReadOnlyDictionary<string, object?> inputs, string key)
|
||||
{
|
||||
if (!inputs.TryGetValue(key, out object? value) || value is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (value is JsonElement el)
|
||||
{
|
||||
if (el.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return el.EnumerateArray()
|
||||
.Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() : item.ToString())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s!.Trim())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (el.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return Split(el.GetString());
|
||||
}
|
||||
}
|
||||
|
||||
if (value is IEnumerable<string> typed)
|
||||
{
|
||||
return typed.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v.Trim()).ToList();
|
||||
}
|
||||
|
||||
if (value is IEnumerable<object> objects)
|
||||
{
|
||||
return objects.Select(o => o?.ToString())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s!.Trim())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return Split(value.ToString());
|
||||
}
|
||||
|
||||
private static List<string> Split(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return text.Split(['、', ',', ';', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MAF1.Agents.FileCity;
|
||||
|
||||
public sealed class CityExtraction
|
||||
{
|
||||
[JsonPropertyName("hasValidCities")]
|
||||
public bool HasValidCities { get; set; }
|
||||
|
||||
[JsonPropertyName("cities")]
|
||||
public List<string> Cities { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Text.Json;
|
||||
using MAF1.Tools;
|
||||
using MAF1.Utils;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace MAF1.Agents.FileCity;
|
||||
|
||||
public static class FileCityAgent
|
||||
{
|
||||
public static AIAgent Create(AgentFactory factory)
|
||||
{
|
||||
return factory.CreateAgent(
|
||||
name: "FileCityAgent",
|
||||
instructions:
|
||||
"""
|
||||
你负责读取用户指定的本地文件,并从中提取有效城市名。
|
||||
必须先调用 ReadTextFile 读取文件,不要猜测文件内容。
|
||||
有效城市名:现实世界中真实存在的城市(如 成都、北京、Amsterdam)。
|
||||
忽略空行、注释、人名、水果、随意单词等不是城市的内容。
|
||||
只输出一个 JSON 对象,不要 Markdown,不要其它说明:
|
||||
{"hasValidCities": true, "cities": ["成都"], "reason": "说明"}
|
||||
若没有有效城市:{"hasValidCities": false, "cities": [], "reason": "原因"}
|
||||
cities 去重,保持文件中的出现顺序。
|
||||
""",
|
||||
tools: [AIFunctionFactory.Create(FileTools.ReadTextFile)]);
|
||||
}
|
||||
|
||||
public static async Task<AgentStepResult> RunAsync(
|
||||
AIAgent agent,
|
||||
IReadOnlyDictionary<string, object?> inputs,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string filePath = AgentInputs.ReadString(inputs, "filePath") ?? "Data/cities.txt";
|
||||
AgentResponse response = await agent.RunAsync(
|
||||
$"请读取这个文件并提取有效城市名:{filePath}",
|
||||
cancellationToken: cancellationToken);
|
||||
CityExtraction extraction = Parse(response.Text);
|
||||
return new AgentStepResult
|
||||
{
|
||||
Message = response.Text,
|
||||
Inputs = new Dictionary<string, object?> { ["filePath"] = filePath },
|
||||
Outputs = new Dictionary<string, object?>
|
||||
{
|
||||
["hasValidCities"] = extraction.HasValidCities,
|
||||
["cities"] = extraction.Cities,
|
||||
["reason"] = extraction.Reason,
|
||||
["raw"] = response.Text,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static CityExtraction Parse(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
CityExtraction? result = JsonSerializer.Deserialize<CityExtraction>(
|
||||
JsonText.UnwrapObject(text),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (result is null)
|
||||
{
|
||||
return Invalid("无法解析文件 Agent 的输出。");
|
||||
}
|
||||
|
||||
result.Cities = result.Cities
|
||||
.Where(city => !string.IsNullOrWhiteSpace(city))
|
||||
.Select(city => city.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
result.HasValidCities = result.HasValidCities && result.Cities.Count > 0;
|
||||
return result;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return Invalid($"文件 Agent 没有返回合法 JSON:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static CityExtraction Invalid(string reason) =>
|
||||
new() { HasValidCities = false, Reason = reason };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using MAF1.Tools;
|
||||
using MAF1.Utils;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace MAF1.Agents.Weather;
|
||||
|
||||
public static class WeatherAgent
|
||||
{
|
||||
public static AIAgent Create(AgentFactory factory, WeatherTools weatherTools)
|
||||
{
|
||||
return factory.CreateAgent(
|
||||
name: "WeatherAgent",
|
||||
instructions:
|
||||
"""
|
||||
你负责查询天气。用户给出的每个城市都必须调用一次 GetWeather,不要编造天气。
|
||||
用中文汇总所有城市的天气。
|
||||
""",
|
||||
tools: [AIFunctionFactory.Create(weatherTools.GetWeather)]);
|
||||
}
|
||||
|
||||
public static async Task<AgentStepResult> RunAsync(
|
||||
AIAgent agent,
|
||||
IReadOnlyDictionary<string, object?> inputs,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<string> cities = AgentInputs.ReadStringList(inputs, "cities");
|
||||
if (cities.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("输入 cities 为空。请把上一节点的 cities 连到本节点的 cities。");
|
||||
}
|
||||
|
||||
AgentResponse response = await agent.RunAsync(
|
||||
$"请查询这些城市的天气:{string.Join("、", cities)}",
|
||||
cancellationToken: cancellationToken);
|
||||
return new AgentStepResult
|
||||
{
|
||||
Message = response.Text,
|
||||
Inputs = new Dictionary<string, object?> { ["cities"] = cities },
|
||||
Outputs = new Dictionary<string, object?> { ["summary"] = response.Text },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>MAF1</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.1.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.19.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MAF1.PluginContract;
|
||||
|
||||
public sealed class PluginManifest
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string Version { get; set; } = "1.0.0";
|
||||
public PluginLaunch Launch { get; set; } = new();
|
||||
public int TimeoutSeconds { get; set; }
|
||||
public Dictionary<string, string> Env { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<PluginCredentialNeed> Credentials { get; set; } = [];
|
||||
public List<PluginPort> Inputs { get; set; } = [];
|
||||
public List<PluginPort> Outputs { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class PluginLaunch
|
||||
{
|
||||
public string Command { get; set; } = "";
|
||||
public List<string> Args { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class PluginPort
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Type { get; set; } = "string";
|
||||
public string Description { get; set; } = "";
|
||||
public bool Required { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PluginCredentialNeed
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Type { get; set; } = "";
|
||||
public bool Required { get; set; } = true;
|
||||
public string Description { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class PluginRequest
|
||||
{
|
||||
public Dictionary<string, object?> Inputs { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[JsonPropertyName("credentials")]
|
||||
public Dictionary<string, PluginCredentialPayload> Credentials { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class PluginCredentialPayload
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Type { get; set; } = "";
|
||||
public string? Endpoint { get; set; }
|
||||
public string? ApiKey { get; set; }
|
||||
public string? Model { get; set; }
|
||||
public Dictionary<string, string> Extra { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static class PluginJson
|
||||
{
|
||||
public static readonly System.Text.Json.JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace MAF1.PluginContract;
|
||||
|
||||
public static class PluginStdio
|
||||
{
|
||||
public static async Task<PluginRequest> ReadRequestAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using Stream stdin = Console.OpenStandardInput();
|
||||
using StreamReader reader = new(stdin, Encoding.UTF8);
|
||||
string json = await reader.ReadToEndAsync(cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
throw new InvalidOperationException("插件没有从标准输入读到 JSON 请求。");
|
||||
}
|
||||
|
||||
PluginRequest? request = JsonSerializer.Deserialize<PluginRequest>(json, PluginJson.Options);
|
||||
return request ?? new PluginRequest();
|
||||
}
|
||||
|
||||
public static async Task WriteOutputsAsync(Dictionary<string, object?> outputs, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(outputs, PluginJson.Options);
|
||||
await Console.Out.WriteAsync(json.AsMemory(), cancellationToken);
|
||||
await Console.Out.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public static void ApplyCredentialsToEnvironment(IReadOnlyDictionary<string, PluginCredentialPayload> credentials)
|
||||
{
|
||||
foreach (KeyValuePair<string, PluginCredentialPayload> pair in credentials)
|
||||
{
|
||||
PluginCredentialPayload cred = pair.Value;
|
||||
if (cred.Type is "openai-compatible" or "llm" || pair.Key.Equals("llm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SetIfNotEmpty("OPENAI_ENDPOINT", cred.Endpoint);
|
||||
SetIfNotEmpty("OPENAI_API_KEY", cred.ApiKey);
|
||||
SetIfNotEmpty("OPENAI_CHAT_MODEL", cred.Model);
|
||||
}
|
||||
|
||||
foreach (KeyValuePair<string, string> extra in cred.Extra)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(extra.Value))
|
||||
{
|
||||
Environment.SetEnvironmentVariable(extra.Key, extra.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static IConfiguration LoadHostConfiguration()
|
||||
{
|
||||
string contentRoot = Environment.GetEnvironmentVariable("MAF1_CONTENT_ROOT");
|
||||
if (string.IsNullOrWhiteSpace(contentRoot) || !Directory.Exists(contentRoot))
|
||||
{
|
||||
contentRoot = AppContext.BaseDirectory;
|
||||
DirectoryInfo? parent = Directory.GetParent(contentRoot.TrimEnd(Path.DirectorySeparatorChar));
|
||||
if (parent?.Name.Equals("plugins", StringComparison.OrdinalIgnoreCase) == false
|
||||
&& parent?.Parent is not null
|
||||
&& File.Exists(Path.Combine(parent.Parent.FullName, "appsettings.json")))
|
||||
{
|
||||
contentRoot = parent.Parent.FullName;
|
||||
}
|
||||
else if (parent is not null && File.Exists(Path.Combine(parent.FullName, "appsettings.json")))
|
||||
{
|
||||
contentRoot = parent.FullName;
|
||||
}
|
||||
}
|
||||
|
||||
return new ConfigurationBuilder()
|
||||
.SetBasePath(contentRoot)
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
}
|
||||
|
||||
public static string? ReadString(Dictionary<string, object?> inputs, string key)
|
||||
{
|
||||
if (!inputs.TryGetValue(key, out object? value) || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value is JsonElement el)
|
||||
{
|
||||
return el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString();
|
||||
}
|
||||
|
||||
return value.ToString();
|
||||
}
|
||||
|
||||
public static List<string> ReadStringList(Dictionary<string, object?> inputs, string key)
|
||||
{
|
||||
if (!inputs.TryGetValue(key, out object? value) || value is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (value is JsonElement el)
|
||||
{
|
||||
if (el.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return el.EnumerateArray()
|
||||
.Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() : item.ToString())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s!.Trim())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (el.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return Split(el.GetString());
|
||||
}
|
||||
}
|
||||
|
||||
if (value is IEnumerable<string> typed)
|
||||
{
|
||||
return typed.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v.Trim()).ToList();
|
||||
}
|
||||
|
||||
return Split(value.ToString());
|
||||
}
|
||||
|
||||
private static List<string> Split(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return text.Split(['、', ',', ';', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static void SetIfNotEmpty(string name, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
|
||||
namespace MAF1.Tools;
|
||||
|
||||
public static class FileTools
|
||||
{
|
||||
[Description("Read the full UTF-8 text of a local file. Always call this before judging city names.")]
|
||||
public static string ReadTextFile([Description("Absolute or relative path of the file to read.")] string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return "未提供文件路径。";
|
||||
}
|
||||
|
||||
string? resolved = Resolve(path.Trim().Trim('"'));
|
||||
if (resolved is null)
|
||||
{
|
||||
return $"找不到文件:{path}";
|
||||
}
|
||||
|
||||
FileInfo info = new(resolved);
|
||||
if (info.Length > 64 * 1024)
|
||||
{
|
||||
return $"文件过大({info.Length} 字节),请换一个不超过 64KB 的文本文件。";
|
||||
}
|
||||
|
||||
return File.ReadAllText(resolved, Encoding.UTF8);
|
||||
}
|
||||
|
||||
private static string? Resolve(string path)
|
||||
{
|
||||
if (Path.IsPathRooted(path) && File.Exists(path))
|
||||
{
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
|
||||
string fromCwd = Path.GetFullPath(path);
|
||||
if (File.Exists(fromCwd))
|
||||
{
|
||||
return fromCwd;
|
||||
}
|
||||
|
||||
foreach (string root in SearchRoots())
|
||||
{
|
||||
string candidate = Path.GetFullPath(Path.Combine(root, path));
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SearchRoots()
|
||||
{
|
||||
yield return AppContext.BaseDirectory;
|
||||
string? contentRoot = Environment.GetEnvironmentVariable("MAF1_CONTENT_ROOT");
|
||||
if (!string.IsNullOrWhiteSpace(contentRoot))
|
||||
{
|
||||
yield return contentRoot;
|
||||
}
|
||||
|
||||
DirectoryInfo? parent = Directory.GetParent(AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar));
|
||||
if (parent is not null)
|
||||
{
|
||||
yield return parent.FullName;
|
||||
if (parent.Parent is not null)
|
||||
{
|
||||
yield return parent.Parent.FullName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace MAF1.Tools;
|
||||
|
||||
public sealed class WeatherOptions
|
||||
{
|
||||
public string Provider { get; set; } = "Wttr";
|
||||
public string Language { get; set; } = "zh";
|
||||
public WttrOptions Wttr { get; set; } = new();
|
||||
public OpenWeatherOptions OpenWeather { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class WttrOptions
|
||||
{
|
||||
public string UrlTemplate { get; set; } = "https://wttr.in/{location}?lang={lang}&format=3";
|
||||
}
|
||||
|
||||
public sealed class OpenWeatherOptions
|
||||
{
|
||||
public string UrlTemplate { get; set; } =
|
||||
"https://api.openweathermap.org/data/2.5/weather?q={location}&appid={apiKey}&units=metric&lang={lang}";
|
||||
|
||||
public string ApiKey { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.ComponentModel;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MAF1.Tools;
|
||||
|
||||
public sealed class WeatherTools(WeatherOptions options, HttpClient http)
|
||||
{
|
||||
[Description("Look up live weather for a city or location. Always call this instead of guessing.")]
|
||||
public async Task<string> GetWeather(
|
||||
[Description("City or location name, for example Amsterdam or 北京.")] string location)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(location))
|
||||
{
|
||||
return "未提供地点,无法查询天气。";
|
||||
}
|
||||
|
||||
string provider = options.Provider.Trim();
|
||||
try
|
||||
{
|
||||
if (provider.Equals("OpenWeather", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.OpenWeather.ApiKey))
|
||||
{
|
||||
return "OpenWeather 已启用,但 appsettings.json 里 Weather:OpenWeather:ApiKey 为空。请填入密钥,或把 Provider 改回 Wttr。";
|
||||
}
|
||||
|
||||
return await QueryOpenWeatherAsync(location);
|
||||
}
|
||||
|
||||
return await QueryWttrAsync(location);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"查询天气失败({provider} / {location}):{ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> QueryWttrAsync(string location)
|
||||
{
|
||||
string url = Expand(options.Wttr.UrlTemplate, location, apiKey: null);
|
||||
using HttpResponseMessage response = await http.GetAsync(url);
|
||||
string body = (await response.Content.ReadAsStringAsync()).Trim();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return $"wttr.in 返回 {(int)response.StatusCode}:{body}";
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(body)
|
||||
? $"wttr.in 没有返回 {location} 的天气。"
|
||||
: body;
|
||||
}
|
||||
|
||||
private async Task<string> QueryOpenWeatherAsync(string location)
|
||||
{
|
||||
string url = Expand(options.OpenWeather.UrlTemplate, location, options.OpenWeather.ApiKey);
|
||||
using HttpResponseMessage response = await http.GetAsync(url);
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return $"OpenWeather 返回 {(int)response.StatusCode}:{body}";
|
||||
}
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(body);
|
||||
JsonElement root = doc.RootElement;
|
||||
string name = root.TryGetProperty("name", out JsonElement nameEl) ? nameEl.GetString() ?? location : location;
|
||||
string description = root.TryGetProperty("weather", out JsonElement weather)
|
||||
&& weather.ValueKind == JsonValueKind.Array
|
||||
&& weather.GetArrayLength() > 0
|
||||
&& weather[0].TryGetProperty("description", out JsonElement descEl)
|
||||
? descEl.GetString() ?? ""
|
||||
: "";
|
||||
double temp = root.TryGetProperty("main", out JsonElement main) && main.TryGetProperty("temp", out JsonElement tempEl)
|
||||
? tempEl.GetDouble()
|
||||
: double.NaN;
|
||||
int humidity = main.ValueKind == JsonValueKind.Object && main.TryGetProperty("humidity", out JsonElement humidityEl)
|
||||
? humidityEl.GetInt32()
|
||||
: 0;
|
||||
|
||||
return double.IsNaN(temp)
|
||||
? body
|
||||
: $"{name}:{description},气温 {temp:0.#}°C,湿度 {humidity}%";
|
||||
}
|
||||
|
||||
private string Expand(string template, string location, string? apiKey)
|
||||
{
|
||||
return template
|
||||
.Replace("{location}", Uri.EscapeDataString(location), StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{lang}", Uri.EscapeDataString(options.Language), StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{apiKey}", apiKey ?? "", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static HttpClient CreateHttpClient()
|
||||
{
|
||||
HttpClient http = new() { Timeout = TimeSpan.FromSeconds(20) };
|
||||
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("MAF1", "1.0"));
|
||||
http.DefaultRequestHeaders.AcceptLanguage.ParseAdd("zh-CN,zh;q=0.9,en;q=0.8");
|
||||
return http;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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;
|
||||
|
||||
public sealed class AgentFactory
|
||||
{
|
||||
private readonly ChatClient _chatClient;
|
||||
|
||||
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;
|
||||
|
||||
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}");
|
||||
_chatClient = new OpenAIClient(new ApiKeyCredential(options.ApiKey), clientOptions)
|
||||
.GetChatClient(model);
|
||||
}
|
||||
|
||||
public AIAgent CreateAgent(string name, string instructions, IList<AITool>? tools = null)
|
||||
{
|
||||
return _chatClient.AsAIAgent(instructions: instructions, name: name, tools: tools);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace MAF1.Utils;
|
||||
|
||||
public static class JsonText
|
||||
{
|
||||
public static string UnwrapObject(string text)
|
||||
{
|
||||
string trimmed = text.Trim();
|
||||
if (trimmed.StartsWith("```", StringComparison.Ordinal))
|
||||
{
|
||||
int firstNewLine = trimmed.IndexOf('\n');
|
||||
int lastFence = trimmed.LastIndexOf("```", StringComparison.Ordinal);
|
||||
if (firstNewLine >= 0 && lastFence > firstNewLine)
|
||||
{
|
||||
trimmed = trimmed[(firstNewLine + 1)..lastFence].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
int objectStart = trimmed.IndexOf('{');
|
||||
int objectEnd = trimmed.LastIndexOf('}');
|
||||
if (objectStart >= 0 && objectEnd > objectStart)
|
||||
{
|
||||
return trimmed[objectStart..(objectEnd + 1)];
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace MAF1.Utils;
|
||||
|
||||
public sealed class LlmOptions
|
||||
{
|
||||
public string ApiKey { get; set; } = "";
|
||||
public string Endpoint { get; set; } = "";
|
||||
public string Model { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace MAF1.Utils;
|
||||
|
||||
public static class WindowsConsole
|
||||
{
|
||||
private const uint Utf8CodePage = 65001;
|
||||
private const int StdOutputHandle = -11;
|
||||
private const uint EnableVirtualTerminalProcessing = 0x0004;
|
||||
|
||||
public static void EnableUtf8()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
||||
Console.InputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
||||
return;
|
||||
}
|
||||
|
||||
SetConsoleOutputCP(Utf8CodePage);
|
||||
SetConsoleCP(Utf8CodePage);
|
||||
|
||||
UTF8Encoding utf8 = new(encoderShouldEmitUTF8Identifier: false);
|
||||
Console.OutputEncoding = utf8;
|
||||
Console.InputEncoding = utf8;
|
||||
|
||||
nint stdout = GetStdHandle(StdOutputHandle);
|
||||
if (stdout != nint.Zero && GetConsoleMode(stdout, out uint mode))
|
||||
{
|
||||
SetConsoleMode(stdout, mode | EnableVirtualTerminalProcessing);
|
||||
}
|
||||
|
||||
StreamWriter writer = new(Console.OpenStandardOutput(), utf8)
|
||||
{
|
||||
AutoFlush = true,
|
||||
};
|
||||
Console.SetOut(writer);
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern bool SetConsoleOutputCP(uint wCodePageID);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern bool SetConsoleCP(uint wCodePageID);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern nint GetStdHandle(int nStdHandle);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern bool GetConsoleMode(nint hConsoleHandle, out uint lpMode);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern bool SetConsoleMode(nint hConsoleHandle, uint dwMode);
|
||||
}
|
||||
Reference in New Issue
Block a user