Initial project import
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user