Files
2026-07-24 23:11:20 +08:00

339 lines
12 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}