Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
@@ -0,0 +1,338 @@
using System.Text;
using System.Text.Json;
namespace MiaoJiZhang.Api.Services;
public partial class OpenAiVisionClient
{
public async Task<AgentRunResponse> RunAgentAsync(
string systemPrompt,
string userText,
IReadOnlyList<AgentToolDefinition> tools,
Func<AgentToolCall, CancellationToken, Task<string>> executeTool,
Func<string, CancellationToken, Task>? onToken = null,
Func<string, CancellationToken, Task>? onToolRunning = null,
CancellationToken ct = default)
{
Load();
if (string.IsNullOrWhiteSpace(_apiKey))
throw new InvalidOperationException("AI 功能尚未配置");
if (_protocol != "responses")
throw new InvalidOperationException("AI Agent 记账要求使用 Responses 协议");
var providerTools = tools.Select(tool => new
{
type = "function",
name = tool.Name,
description = tool.Description,
parameters = tool.Parameters,
}).ToArray();
var input = new object[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userText },
};
string? previousResponseId = null;
IReadOnlyList<object>? toolOutputs = null;
var text = new StringBuilder();
var toolCallCount = 0;
for (var round = 0; round < 4; round++)
{
object body = previousResponseId is null
? new
{
model = _model,
input,
tools = providerTools,
tool_choice = "auto",
max_output_tokens = _maxTokens,
temperature = 0.3,
thinking = new { type = "disabled" },
store = true,
stream = true,
}
: new
{
model = _model,
previous_response_id = previousResponseId,
input = toolOutputs,
tools = providerTools,
tool_choice = "auto",
max_output_tokens = _maxTokens,
temperature = 0.3,
thinking = new { type = "disabled" },
store = true,
stream = true,
};
var turn = await StreamAgentTurnAsync(
body,
async (token, tokenCt) =>
{
text.Append(token);
if (onToken is not null) await onToken(token, tokenCt);
},
ct);
previousResponseId = turn.ResponseId;
if (turn.Calls.Count == 0)
return new AgentRunResponse(text.ToString().Trim(), toolCallCount);
if (string.IsNullOrWhiteSpace(previousResponseId))
throw new InvalidOperationException("AI 工具调用缺少响应 ID");
var outputs = new List<object>();
foreach (var call in turn.Calls)
{
toolCallCount++;
if (toolCallCount > 12)
throw new InvalidOperationException("AI 工具调用次数过多,请重新描述需求");
if (onToolRunning is not null)
await onToolRunning(call.Name, ct);
string output;
try
{
output = await executeTool(call, ct);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Agent tool failed. tool={Tool} callId={CallId}",
call.Name,
call.CallId);
output = JsonSerializer.Serialize(new
{
ok = false,
error = ex.Message,
});
}
outputs.Add(new
{
type = "function_call_output",
call_id = call.CallId,
output,
});
}
toolOutputs = outputs;
}
throw new InvalidOperationException("AI 工具调用轮次过多,请重新描述需求");
}
private async Task<AgentProviderTurn> StreamAgentTurnAsync(
object body,
Func<string, CancellationToken, Task> onToken,
CancellationToken ct)
{
var request = new HttpRequestMessage(
HttpMethod.Post,
_baseUrl + "/responses")
{
Content = new StringContent(
JsonSerializer.Serialize(body),
Encoding.UTF8,
"application/json"),
};
request.Headers.Add("Authorization", "Bearer " + _apiKey);
using var response = await _http.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
ct);
if (!response.IsSuccessStatusCode)
{
var detail = await response.Content.ReadAsStringAsync(ct);
throw new HttpRequestException(
$"AI Agent 请求失败 ({(int)response.StatusCode}){ReadAgentError(detail)}");
}
using var stream = await response.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
var calls = new Dictionary<string, AgentCallBuilder>();
string? responseId = null;
var emittedText = false;
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(ct);
if (string.IsNullOrWhiteSpace(line) || line == "data: [DONE]") continue;
if (!line.StartsWith("data:", StringComparison.Ordinal)) continue;
var payload = line[5..].TrimStart();
if (payload.Length == 0 || payload == "[DONE]") continue;
using var document = JsonDocument.Parse(payload);
var root = document.RootElement;
var eventType = TryGetProperty(root, "type", out var typeNode)
? typeNode.GetString()
: null;
if (eventType is "response.failed" or "error")
throw new InvalidOperationException(ReadAgentError(payload));
if (TryGetProperty(root, "response", out var responseNode) &&
responseNode.ValueKind == JsonValueKind.Object &&
TryGetProperty(responseNode, "id", out var responseIdNode))
{
responseId = responseIdNode.GetString() ?? responseId;
}
if (eventType == "response.output_text.delta" &&
TryGetProperty(root, "delta", out var deltaNode) &&
deltaNode.ValueKind == JsonValueKind.String)
{
var token = deltaNode.GetString();
if (!string.IsNullOrEmpty(token))
{
emittedText = true;
await onToken(token, ct);
}
continue;
}
if (eventType == "response.output_item.added" &&
TryGetProperty(root, "item", out var itemNode))
{
AddFunctionCall(itemNode, calls);
continue;
}
if (eventType == "response.function_call_arguments.delta")
{
var key = ReadCallKey(root);
if (key is not null && calls.TryGetValue(key, out var builder) &&
TryGetProperty(root, "delta", out var argumentDelta))
{
builder.Arguments.Append(argumentDelta.GetString());
}
continue;
}
if (eventType == "response.function_call_arguments.done")
{
var key = ReadCallKey(root);
if (key is not null && calls.TryGetValue(key, out var builder) &&
TryGetProperty(root, "arguments", out var argumentsNode))
{
builder.Arguments.Clear();
builder.Arguments.Append(argumentsNode.GetString());
}
continue;
}
if (eventType == "response.completed" &&
TryGetProperty(root, "response", out var completedResponse))
{
AddFunctionCallsFromResponse(completedResponse, calls);
if (!emittedText)
{
var completeText = ExtractResponseText(completedResponse);
if (!string.IsNullOrWhiteSpace(completeText))
{
emittedText = true;
await onToken(completeText, ct);
}
}
}
}
return new AgentProviderTurn(
responseId,
calls.Values
.Where(call => !string.IsNullOrWhiteSpace(call.CallId) &&
!string.IsNullOrWhiteSpace(call.Name))
.Select(call => new AgentToolCall(
call.CallId,
call.Name,
call.Arguments.ToString()))
.ToList());
}
private static void AddFunctionCallsFromResponse(
JsonElement response,
Dictionary<string, AgentCallBuilder> calls)
{
if (!TryGetProperty(response, "output", out var output) ||
output.ValueKind != JsonValueKind.Array) return;
foreach (var item in output.EnumerateArray()) AddFunctionCall(item, calls);
}
private static void AddFunctionCall(
JsonElement item,
Dictionary<string, AgentCallBuilder> calls)
{
if (!TryGetProperty(item, "type", out var type) ||
type.GetString() != "function_call") return;
var itemId = TryGetProperty(item, "id", out var idNode)
? idNode.GetString()
: null;
var callId = TryGetProperty(item, "call_id", out var callIdNode)
? callIdNode.GetString()
: null;
var name = TryGetProperty(item, "name", out var nameNode)
? nameNode.GetString()
: null;
var key = itemId ?? callId;
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(callId)) return;
if (!calls.TryGetValue(key, out var builder))
{
builder = new AgentCallBuilder(callId!, name ?? "");
calls[key] = builder;
}
if (!string.IsNullOrWhiteSpace(name)) builder.Name = name!;
if (TryGetProperty(item, "arguments", out var arguments) &&
arguments.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(arguments.GetString()))
{
builder.Arguments.Clear();
builder.Arguments.Append(arguments.GetString());
}
}
private static string? ReadCallKey(JsonElement root)
{
if (TryGetProperty(root, "item_id", out var itemId))
return itemId.GetString();
if (TryGetProperty(root, "call_id", out var callId))
return callId.GetString();
return null;
}
private static string ReadAgentError(string raw)
{
try
{
using var document = JsonDocument.Parse(raw);
var root = document.RootElement;
if (TryGetProperty(root, "error", out var error))
{
if (error.ValueKind == JsonValueKind.String)
return error.GetString() ?? "AI 服务返回错误";
if (TryGetProperty(error, "message", out var message))
return message.GetString() ?? error.ToString();
return error.ToString();
}
if (TryGetProperty(root, "message", out var directMessage))
return directMessage.GetString() ?? "AI 服务返回错误";
}
catch (JsonException)
{
}
return raw.Length > 300 ? raw[..300] : raw;
}
private sealed record AgentProviderTurn(
string? ResponseId,
IReadOnlyList<AgentToolCall> Calls);
private sealed class AgentCallBuilder(string callId, string name)
{
public string CallId { get; } = callId;
public string Name { get; set; } = name;
public StringBuilder Arguments { get; } = new();
}
}