Initial project import
This commit is contained in:
@@ -0,0 +1,812 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using MiaoJiZhang.Domain.Enums;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public partial class OpenAiVisionClient : ILlmClient
|
||||
{
|
||||
private readonly IServiceScopeFactory _sf;
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<OpenAiVisionClient> _logger;
|
||||
private string? _baseUrl, _apiKey, _model, _protocol;
|
||||
private int _maxTokens = 1024;
|
||||
private DateTime _last = DateTime.MinValue;
|
||||
private static readonly object _lk = new();
|
||||
|
||||
public OpenAiVisionClient(
|
||||
IServiceScopeFactory sf,
|
||||
IHttpClientFactory hf,
|
||||
ILogger<OpenAiVisionClient> logger)
|
||||
{
|
||||
_sf = sf;
|
||||
_http = hf.CreateClient("LlmClient");
|
||||
_http.Timeout = TimeSpan.FromSeconds(120);
|
||||
_logger = logger;
|
||||
}
|
||||
public bool IsEnabled { get { Load(); return !string.IsNullOrEmpty(_apiKey); } }
|
||||
|
||||
public async Task<IntentResult?> TryParseIntentAsync(
|
||||
string userText,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!IsEnabled) return null;
|
||||
const string prompt =
|
||||
"你是记账意图识别器。只返回 JSON:" +
|
||||
"{\"kind\":\"bill\"|\"query\"|\"chat\",\"type\":\"expense\"|\"income\"," +
|
||||
"\"amount\":0,\"categoryName\":\"\",\"note\":\"\"}。" +
|
||||
"收入信号包括赚了、工资到账、奖金、兼职、稿费、红包、报销、退款、理财收益、收款;" +
|
||||
"支出分类:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他;" +
|
||||
"收入分类:工资/奖金/理财/兼职/红包/报销/其他。" +
|
||||
"查询句不得返回 bill,未明确表示收入的金额默认 expense。";
|
||||
var response = await L(prompt, userText, ct);
|
||||
return response is null ? null : PI(response);
|
||||
}
|
||||
public async Task<string?> TryGenerateReplyAsync(string s, string u, CancellationToken ct = default)
|
||||
{ if (!IsEnabled) return null; return await L(s, u, ct); }
|
||||
|
||||
public async Task<IReadOnlyList<ImageParseResult>?> AnalyzeImageAsync(
|
||||
byte[] img,
|
||||
string mime,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!IsEnabled) return null;
|
||||
|
||||
var b64 = Convert.ToBase64String(img);
|
||||
var shanghaiNow = ChinaClock.Now;
|
||||
var systemPrompt = $$"""
|
||||
你是账单截图识别器。逐条提取图片中所有独立、真实发生的交易,只返回 JSON:
|
||||
{"bills":[{"type":"expense","amount":0,"categoryName":"","paymentMethod":null,"note":"","occurredAt":"2026-07-18T14:30:00+08:00"}]}
|
||||
type 只能是 expense 或 income。
|
||||
支出分类只能是:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他。
|
||||
收入分类只能是:工资/奖金/理财/兼职/红包/报销/其他。
|
||||
note 只写简短商户、商品或交易对象,不要抄整行原文。
|
||||
当前上海时间是 {{shanghaiNow:yyyy-MM-dd HH:mm:ss}}。截图明确显示完整日期和时间时,occurredAt 返回带 +08:00 的 ISO 8601 时间;
|
||||
“今天、昨天、前天”等相对日期按当前上海时间换算。缺少日期或缺少具体时分时 occurredAt 必须返回 null,不得猜测。
|
||||
不要把总计、余额、优惠、原价、待支付、统计数字或重复展示的同一笔交易当成独立账单。
|
||||
无法确认真实交易时返回 {"bills":[]},最多返回 20 笔。
|
||||
""";
|
||||
var messages = new List<object>();
|
||||
|
||||
if (_protocol == "messages")
|
||||
{
|
||||
messages.Add(new
|
||||
{
|
||||
role = "user",
|
||||
content = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "image",
|
||||
source = new { type = "base64", media_type = mime, data = b64 },
|
||||
},
|
||||
new { type = "text", text = systemPrompt },
|
||||
},
|
||||
});
|
||||
}
|
||||
else if (_protocol == "responses")
|
||||
{
|
||||
messages.Add(new { role = "system", content = systemPrompt });
|
||||
messages.Add(new
|
||||
{
|
||||
role = "user",
|
||||
content = new object[]
|
||||
{
|
||||
new { type = "input_image", image_url = "data:" + mime + ";base64," + b64 },
|
||||
new { type = "input_text", text = "提取截图中的全部独立交易" },
|
||||
},
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
messages.Add(new { role = "system", content = systemPrompt });
|
||||
messages.Add(new
|
||||
{
|
||||
role = "user",
|
||||
content = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "image_url",
|
||||
image_url = new { url = "data:" + mime + ";base64," + b64 },
|
||||
},
|
||||
new { type = "text", text = "提取截图中的全部独立交易" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
var diagnosticId = Guid.NewGuid().ToString("N")[..12];
|
||||
var (json, error) = await CA(BuildBody(messages, 1536, 0.1), ct);
|
||||
if (json is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Image model request failed. model={Model} protocol={Protocol} requestId={RequestId} error={Error}",
|
||||
_model,
|
||||
_protocol,
|
||||
diagnosticId,
|
||||
error);
|
||||
throw new InvalidOperationException(error ?? "图片模型未返回内容");
|
||||
}
|
||||
|
||||
var providerRequestId = ReadProviderRequestId(json) ?? diagnosticId;
|
||||
var output = EX(json);
|
||||
try
|
||||
{
|
||||
var results = ParseImageResults(output);
|
||||
_logger.LogInformation(
|
||||
"Image model parse completed. model={Model} protocol={Protocol} requestId={RequestId} items={Count}",
|
||||
_model,
|
||||
_protocol,
|
||||
providerRequestId,
|
||||
results.Count);
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Image model parse failed. model={Model} protocol={Protocol} requestId={RequestId} output={Output}",
|
||||
_model,
|
||||
_protocol,
|
||||
providerRequestId,
|
||||
SanitizeOutputSnippet(output));
|
||||
throw new InvalidOperationException("图片模型返回格式无法解析,请重试", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ImageParseResult> ParseImageResults(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
throw new InvalidOperationException("模型没有返回可解析内容");
|
||||
|
||||
InvalidOperationException? shapeError = null;
|
||||
foreach (var candidate in EnumerateJsonCandidates(raw))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
candidate,
|
||||
new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
});
|
||||
var results = new List<ImageParseResult>();
|
||||
if (TryReadImageRoot(document.RootElement, results))
|
||||
return results;
|
||||
shapeError = new InvalidOperationException("JSON 中没有账单字段");
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Keep scanning: models often add prose or multiple fenced blocks.
|
||||
}
|
||||
}
|
||||
|
||||
if (LooksLikeNoBillReply(raw)) return [];
|
||||
throw shapeError ?? new InvalidOperationException("未找到完整 JSON");
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateJsonCandidates(string raw)
|
||||
{
|
||||
for (var start = 0; start < raw.Length; start++)
|
||||
{
|
||||
var opening = raw[start];
|
||||
if (opening is not ('{' or '[')) continue;
|
||||
|
||||
var stack = new Stack<char>();
|
||||
var inString = false;
|
||||
var escaped = false;
|
||||
for (var index = start; index < raw.Length; index++)
|
||||
{
|
||||
var current = raw[index];
|
||||
if (inString)
|
||||
{
|
||||
if (escaped)
|
||||
{
|
||||
escaped = false;
|
||||
}
|
||||
else if (current == '\\')
|
||||
{
|
||||
escaped = true;
|
||||
}
|
||||
else if (current == '"')
|
||||
{
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current == '"')
|
||||
{
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (current is '{' or '[')
|
||||
{
|
||||
stack.Push(current);
|
||||
continue;
|
||||
}
|
||||
if (current is not ('}' or ']')) continue;
|
||||
if (
|
||||
stack.Count == 0 ||
|
||||
(current == '}' && stack.Peek() != '{') ||
|
||||
(current == ']' && stack.Peek() != '[')
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
stack.Pop();
|
||||
if (stack.Count == 0)
|
||||
{
|
||||
yield return raw[start..(index + 1)];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadImageRoot(
|
||||
JsonElement root,
|
||||
List<ImageParseResult> results)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in root.EnumerateArray()) AddBill(item, results);
|
||||
return true;
|
||||
}
|
||||
if (root.ValueKind != JsonValueKind.Object) return false;
|
||||
|
||||
foreach (var name in new[] { "bills", "items", "transactions", "data" })
|
||||
{
|
||||
if (!TryGetProperty(root, name, out var items)) continue;
|
||||
if (items.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in items.EnumerateArray()) AddBill(item, results);
|
||||
return true;
|
||||
}
|
||||
if (items.ValueKind == JsonValueKind.Object)
|
||||
return TryReadImageRoot(items, results);
|
||||
if (items.ValueKind == JsonValueKind.Null) return true;
|
||||
}
|
||||
|
||||
if (TryGetProperty(root, "result", out var nested))
|
||||
return TryReadImageRoot(nested, results);
|
||||
if (
|
||||
TryGetProperty(root, "matched", out var matched) &&
|
||||
matched.ValueKind == JsonValueKind.False
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (TryGetProperty(root, "amount", out _))
|
||||
{
|
||||
AddBill(root, results);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void AddBill(
|
||||
JsonElement item,
|
||||
List<ImageParseResult> results)
|
||||
{
|
||||
if (results.Count >= 20 || item.ValueKind != JsonValueKind.Object) return;
|
||||
var amount = ReadAmount(item);
|
||||
if (amount <= 0) return;
|
||||
|
||||
var rawType = ReadText(item, "type", "transactionType", "direction");
|
||||
var type = rawType?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"income" or "收入" or "入账" => "income",
|
||||
"expense" or "支出" or "出账" => "expense",
|
||||
_ => null,
|
||||
};
|
||||
if (type is null) return;
|
||||
var category = ReadText(
|
||||
item,
|
||||
"categoryName",
|
||||
"category_name",
|
||||
"category");
|
||||
var payment = ReadText(
|
||||
item,
|
||||
"paymentMethod",
|
||||
"payment_method",
|
||||
"payment",
|
||||
"payMethod");
|
||||
var note = ReadText(
|
||||
item,
|
||||
"note",
|
||||
"merchant",
|
||||
"description",
|
||||
"title",
|
||||
"counterparty");
|
||||
var occurredAt = ReadOccurredAt(item);
|
||||
|
||||
results.Add(new ImageParseResult(
|
||||
type,
|
||||
amount,
|
||||
string.IsNullOrWhiteSpace(category) ? "其他" : category.Trim(),
|
||||
string.IsNullOrWhiteSpace(payment) ? null : payment.Trim(),
|
||||
string.IsNullOrWhiteSpace(note) ? "" : note.Trim(),
|
||||
occurredAt));
|
||||
}
|
||||
|
||||
private static DateTime? ReadOccurredAt(JsonElement item)
|
||||
{
|
||||
var text = ReadText(
|
||||
item,
|
||||
"occurredAt",
|
||||
"occurred_at",
|
||||
"transactionTime",
|
||||
"transaction_time",
|
||||
"dateTime");
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
|
||||
// Reject date-only values so the client can deliberately fall back to now.
|
||||
if (!Regex.IsMatch(text, @"\b\d{4}[-/.年]\d{1,2}[-/.月]\d{1,2}") ||
|
||||
!Regex.IsMatch(text, @"\b\d{1,2}:\d{2}\b"))
|
||||
return null;
|
||||
|
||||
if (DateTimeOffset.TryParse(
|
||||
text,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AllowWhiteSpaces,
|
||||
out var offsetValue))
|
||||
{
|
||||
var utc = offsetValue.UtcDateTime;
|
||||
return utc <= DateTime.UtcNow.AddMinutes(10) ? utc : null;
|
||||
}
|
||||
|
||||
if (!DateTime.TryParse(
|
||||
text,
|
||||
CultureInfo.GetCultureInfo("zh-CN"),
|
||||
DateTimeStyles.AllowWhiteSpaces,
|
||||
out var localValue))
|
||||
return null;
|
||||
var localUtc = ChinaClock.ToUtc(localValue);
|
||||
return localUtc <= DateTime.UtcNow.AddMinutes(10) ? localUtc : null;
|
||||
}
|
||||
|
||||
private static decimal ReadAmount(JsonElement item)
|
||||
{
|
||||
JsonElement amount = default;
|
||||
var found =
|
||||
TryGetProperty(item, "amount", out amount) ||
|
||||
TryGetProperty(item, "money", out amount) ||
|
||||
TryGetProperty(item, "price", out amount);
|
||||
if (!found) return 0;
|
||||
if (
|
||||
amount.ValueKind == JsonValueKind.Number &&
|
||||
amount.TryGetDecimal(out var number)
|
||||
) {
|
||||
return number;
|
||||
}
|
||||
if (amount.ValueKind != JsonValueKind.String) return 0;
|
||||
|
||||
var text = amount.GetString()?.Replace(",", "");
|
||||
if (string.IsNullOrWhiteSpace(text)) return 0;
|
||||
var match = Regex.Match(text, @"-?\d+(?:\.\d+)?");
|
||||
return match.Success &&
|
||||
decimal.TryParse(
|
||||
match.Value,
|
||||
NumberStyles.Number,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var parsed)
|
||||
? parsed
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static string? ReadText(JsonElement item, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!TryGetProperty(item, name, out var value)) continue;
|
||||
if (value.ValueKind == JsonValueKind.String) return value.GetString();
|
||||
if (value.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False)
|
||||
return value.ToString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryGetProperty(
|
||||
JsonElement element,
|
||||
string name,
|
||||
out JsonElement value)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (!property.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
value = property.Value;
|
||||
return true;
|
||||
}
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool LooksLikeNoBillReply(string raw)
|
||||
{
|
||||
return raw.Contains("没有账单", StringComparison.OrdinalIgnoreCase) ||
|
||||
raw.Contains("未发现交易", StringComparison.OrdinalIgnoreCase) ||
|
||||
raw.Contains("无法确认", StringComparison.OrdinalIgnoreCase) ||
|
||||
raw.Contains("no transaction", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string? ReadProviderRequestId(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
foreach (var name in new[] { "id", "request_id", "requestId" })
|
||||
{
|
||||
if (
|
||||
TryGetProperty(root, name, out var value) &&
|
||||
value.ValueKind == JsonValueKind.String
|
||||
) {
|
||||
return value.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// The normal response parser will report the malformed payload.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string SanitizeOutputSnippet(string raw)
|
||||
{
|
||||
var sanitized = Regex.Replace(
|
||||
raw,
|
||||
@"(?i)""(note|merchant|description|counterparty|paymentMethod|payment_method)""\s*:\s*""(?:\\.|[^""])*""",
|
||||
@"""$1"":""***""");
|
||||
sanitized = Regex.Replace(sanitized, @"\b\d{4,}\b", "***");
|
||||
sanitized = Regex.Replace(sanitized, @"\s+", " ").Trim();
|
||||
return sanitized.Length <= 500 ? sanitized : sanitized[..500] + "…";
|
||||
}
|
||||
|
||||
public async Task<(bool, string?)> TestConnectionAsync(CancellationToken ct = default)
|
||||
{
|
||||
Load();
|
||||
if (string.IsNullOrEmpty(_apiKey)) return (false, "API Key 为空");
|
||||
if (_protocol != "responses")
|
||||
return (false, "AI Agent 记账要求使用 Responses 协议");
|
||||
|
||||
try
|
||||
{
|
||||
var tool = new AgentToolDefinition(
|
||||
"diagnostic_echo",
|
||||
"连接测试时必须调用的无副作用工具",
|
||||
JsonSerializer.Deserialize<JsonElement>(
|
||||
"""{"type":"object","properties":{"value":{"type":"string"}},"required":["value"]}"""));
|
||||
var response = await RunAgentAsync(
|
||||
"你正在执行连接测试。必须调用 diagnostic_echo,参数 value 填 ok。",
|
||||
"执行工具调用测试",
|
||||
[tool],
|
||||
(_, _) => Task.FromResult("{\"ok\":true}"),
|
||||
ct: ct);
|
||||
return response.ToolCallCount > 0
|
||||
? (true, null)
|
||||
: (false, "模型返回了文本,但没有触发 Function Calling");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StreamReplyAsync(string s, string u, Func<string, CancellationToken, Task> onToken, CancellationToken ct = default)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
Load();
|
||||
|
||||
|
||||
var msgs = new List<object>();
|
||||
if (_protocol == "messages") msgs.Add(new { role = "user", content = s + "\n\n" + u });
|
||||
else { msgs.Add(new { role = "system", content = s }); msgs.Add(new { role = "user", content = u }); }
|
||||
await SA(BuildBody(msgs, _maxTokens, 0.7), onToken, ct);
|
||||
}
|
||||
|
||||
void Load()
|
||||
{
|
||||
if ((DateTime.UtcNow - _last).TotalMinutes < 5) return;
|
||||
lock (_lk)
|
||||
{
|
||||
if ((DateTime.UtcNow - _last).TotalMinutes < 5) return;
|
||||
try
|
||||
{
|
||||
using var scope = _sf.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var config = db.AppConfigs.ToDictionary(x => x.Key, x => x.Value);
|
||||
_apiKey = Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
|
||||
_baseUrl = (
|
||||
Environment.GetEnvironmentVariable("LLM_BASE_URL") ??
|
||||
config.GetValueOrDefault("llm.base_url", "https://api.openai.com/v1") ??
|
||||
"").TrimEnd('/');
|
||||
_model = Environment.GetEnvironmentVariable("LLM_MODEL") ??
|
||||
config.GetValueOrDefault("llm.model", "gpt-4o-mini");
|
||||
_protocol = Environment.GetEnvironmentVariable("LLM_PROTOCOL") ??
|
||||
config.GetValueOrDefault("llm.protocol", "chat_completions");
|
||||
_maxTokens = int.TryParse(
|
||||
config.GetValueOrDefault("llm.max_tokens"),
|
||||
out var maxTokens)
|
||||
? Math.Clamp(maxTokens, 64, 4096)
|
||||
: 1024;
|
||||
_last = DateTime.UtcNow;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Failed to load LLM configuration");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async Task<string?> L(string sys, string user, CancellationToken ct)
|
||||
{ Load(); var msgs = new List<object>(); if (_protocol == "messages") msgs.Add(new { role = "user", content = sys + "\n\n" + user }); else { msgs.Add(new { role = "system", content = sys }); msgs.Add(new { role = "user", content = user }); } var (j, _) = await CA(BuildBody(msgs, _maxTokens, 0.7), ct); if (j is null) return null; var content = EX(j).Trim(); return content.Length > 0 ? content : null; }
|
||||
|
||||
object BuildBody(List<object> msgs, int maxT, double temp) => _protocol switch
|
||||
{ "messages" => new { model = _model, messages = msgs, max_tokens = maxT, temperature = temp }, "responses" => new { model = _model, input = msgs, max_output_tokens = maxT, temperature = temp, thinking = new { type = "disabled" } }, _ => new { model = _model, messages = msgs, max_tokens = maxT, temperature = temp } };
|
||||
|
||||
async Task<(string?, string?)> CA(object body, CancellationToken ct)
|
||||
{
|
||||
Load();
|
||||
try
|
||||
{
|
||||
var ep = _protocol switch { "messages" => "/messages", "responses" => "/responses", _ => "/chat/completions" };
|
||||
var json = JsonSerializer.Serialize(body);
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, _baseUrl + ep) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
|
||||
if (_protocol == "messages") { req.Headers.Add("x-api-key", _apiKey); req.Headers.Add("anthropic-version", "2023-06-01"); }
|
||||
else req.Headers.Add("Authorization", "Bearer " + _apiKey);
|
||||
var r = await _http.SendAsync(req, ct); var t = await r.Content.ReadAsStringAsync(ct);
|
||||
if (r.IsSuccessStatusCode) return (t, null);
|
||||
var detail = "";
|
||||
try { var e = JsonDocument.Parse(t).RootElement; if (e.TryGetProperty("error", out var er)) { string? em = null; if (er.TryGetProperty("message", out var m)) em = m.GetString(); detail = ": " + (em ?? er.ToString()); } } catch { }
|
||||
var msg = ((int)r.StatusCode) switch { 401 => "401 认证失败", 403 => "403 禁止访问", 404 => "404 接口不存在", 429 => "429 频率过高", 500 => "500 服务异常", _ => ((int)r.StatusCode).ToString() };
|
||||
return (null, msg + detail);
|
||||
}
|
||||
catch (TaskCanceledException) { return (null, "请求超时"); }
|
||||
catch (HttpRequestException ex) { return (null, "网络错误: " + (ex.InnerException?.Message ?? ex.Message)); }
|
||||
catch (Exception ex) { return (null, ex.Message); }
|
||||
}
|
||||
|
||||
async Task SA(object body, Func<string, CancellationToken, Task> onToken, CancellationToken ct)
|
||||
{
|
||||
Load();
|
||||
var ep = _protocol switch { "messages" => "/messages", "responses" => "/responses", _ => "/chat/completions" };
|
||||
var serialized = JsonSerializer.Serialize(body);
|
||||
var json = serialized[..^1] + ",\"stream\":true}";
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, _baseUrl + ep) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
|
||||
if (_protocol == "messages") { req.Headers.Add("x-api-key", _apiKey); req.Headers.Add("anthropic-version", "2023-06-01"); }
|
||||
else req.Headers.Add("Authorization", "Bearer " + _apiKey);
|
||||
var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new HttpRequestException($"LLM 流式请求失败 ({(int)resp.StatusCode})");
|
||||
using var stream = await resp.Content.ReadAsStreamAsync(ct);
|
||||
using var reader = new StreamReader(stream);
|
||||
var emitted = false;
|
||||
while (!reader.EndOfStream && !ct.IsCancellationRequested)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(line) || line == "data: [DONE]") continue;
|
||||
var failure = StreamFailure(line);
|
||||
if (failure is not null) throw new InvalidOperationException(failure);
|
||||
var tok = ET(line);
|
||||
if (!string.IsNullOrWhiteSpace(tok)) { emitted = true; await onToken(tok, ct); }
|
||||
}
|
||||
if (!emitted) throw new InvalidOperationException("AI 未返回内容");
|
||||
}
|
||||
|
||||
static string? StreamFailure(string line)
|
||||
{
|
||||
var i = line.IndexOf('{');
|
||||
if (i < 0) return null;
|
||||
try
|
||||
{
|
||||
var d = JsonDocument.Parse(line[i..]).RootElement;
|
||||
if (!d.TryGetProperty("type", out var type)) return null;
|
||||
var value = type.GetString();
|
||||
if (value == "response.incomplete")
|
||||
{
|
||||
var reason = d.TryGetProperty("response", out var response)
|
||||
&& response.TryGetProperty("incomplete_details", out var details)
|
||||
&& details.TryGetProperty("reason", out var reasonElement)
|
||||
? reasonElement.GetString() : null;
|
||||
return reason == "length" ? "AI 输出长度不足,请稍后重试" : "AI 回复未完成";
|
||||
}
|
||||
if (value is "response.failed" or "error") return "AI 服务返回失败";
|
||||
return null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
static string? ET(string line)
|
||||
{
|
||||
if (!line.StartsWith("data: ") && !line.StartsWith("data:")) return null;
|
||||
var i = line.IndexOf('{'); if (i < 0) return null;
|
||||
try
|
||||
{
|
||||
var d = JsonDocument.Parse(line[i..]).RootElement;
|
||||
if (d.TryGetProperty("choices", out var ch) && ch.ValueKind == JsonValueKind.Array && ch.GetArrayLength() > 0 && ch[0].TryGetProperty("delta", out var dt) && dt.TryGetProperty("content", out var ct)) return ct.GetString();
|
||||
if (d.TryGetProperty("type", out var t))
|
||||
{ var ty = t.GetString(); if ((ty == "response.output_text.delta" || ty == "content_block_delta") && d.TryGetProperty("delta", out var dl)) { if (dl.ValueKind == JsonValueKind.String) return dl.GetString(); if (dl.TryGetProperty("text", out var tx)) return tx.GetString(); } }
|
||||
return null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
string EX(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return ExtractResponseText(document.RootElement);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractResponseText(JsonElement root)
|
||||
{
|
||||
if (
|
||||
TryGetProperty(root, "output_text", out var directOutput) &&
|
||||
directOutput.ValueKind == JsonValueKind.String
|
||||
) {
|
||||
return directOutput.GetString() ?? "";
|
||||
}
|
||||
|
||||
if (TryGetProperty(root, "choices", out var choices) &&
|
||||
choices.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var choice in choices.EnumerateArray())
|
||||
{
|
||||
if (!TryGetProperty(choice, "message", out var message)) continue;
|
||||
if (!TryGetProperty(message, "content", out var content)) continue;
|
||||
var value = ExtractTextParts(content);
|
||||
if (value.Length > 0) return value;
|
||||
}
|
||||
}
|
||||
|
||||
if (TryGetProperty(root, "content", out var anthropicContent))
|
||||
{
|
||||
var value = ExtractTextParts(anthropicContent);
|
||||
if (value.Length > 0) return value;
|
||||
}
|
||||
|
||||
if (TryGetProperty(root, "output", out var output) &&
|
||||
output.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var messageParts = new List<string>();
|
||||
var fallbackParts = new List<string>();
|
||||
foreach (var item in output.EnumerateArray())
|
||||
{
|
||||
var type = TryGetProperty(item, "type", out var typeElement)
|
||||
? typeElement.GetString()
|
||||
: null;
|
||||
if (type == "message" &&
|
||||
TryGetProperty(item, "content", out var messageContent))
|
||||
{
|
||||
var value = ExtractTextParts(messageContent);
|
||||
if (value.Length > 0) messageParts.Add(value);
|
||||
}
|
||||
if (TryGetProperty(item, "summary", out var summary))
|
||||
{
|
||||
var value = ExtractTextParts(summary);
|
||||
if (value.Length > 0) fallbackParts.Add(value);
|
||||
}
|
||||
}
|
||||
if (messageParts.Count > 0) return string.Join("", messageParts);
|
||||
if (fallbackParts.Count > 0) return string.Join("", fallbackParts);
|
||||
}
|
||||
|
||||
if (
|
||||
TryGetProperty(root, "response", out var response) &&
|
||||
response.ValueKind == JsonValueKind.Object
|
||||
) {
|
||||
return ExtractResponseText(response);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string ExtractTextParts(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
return element.GetString() ?? "";
|
||||
if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
var value = ExtractTextParts(item);
|
||||
if (value.Length > 0) parts.Add(value);
|
||||
}
|
||||
return string.Join("", parts);
|
||||
}
|
||||
if (element.ValueKind != JsonValueKind.Object) return "";
|
||||
|
||||
if (TryGetProperty(element, "text", out var text))
|
||||
{
|
||||
if (text.ValueKind == JsonValueKind.String)
|
||||
return text.GetString() ?? "";
|
||||
if (
|
||||
text.ValueKind == JsonValueKind.Object &&
|
||||
TryGetProperty(text, "value", out var textValue) &&
|
||||
textValue.ValueKind == JsonValueKind.String
|
||||
) {
|
||||
return textValue.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
if (
|
||||
TryGetProperty(element, "output_text", out var outputText) &&
|
||||
outputText.ValueKind == JsonValueKind.String
|
||||
) {
|
||||
return outputText.GetString() ?? "";
|
||||
}
|
||||
if (TryGetProperty(element, "content", out var content))
|
||||
return ExtractTextParts(content);
|
||||
return "";
|
||||
}
|
||||
|
||||
static IntentResult? PI(string response)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = response.Trim();
|
||||
var fence = new string((char)96, 3);
|
||||
if (json.StartsWith(fence))
|
||||
{
|
||||
json = json.Replace(fence, "").Trim();
|
||||
if (json.StartsWith("json", StringComparison.OrdinalIgnoreCase))
|
||||
json = json[4..].Trim();
|
||||
}
|
||||
|
||||
var document = JsonDocument.Parse(json).RootElement;
|
||||
var kind = document.TryGetProperty("kind", out var kindNode)
|
||||
? kindNode.GetString() ?? "chat"
|
||||
: "chat";
|
||||
if (kind != "bill") return new IntentResult(kind, null);
|
||||
|
||||
var typeText = document.TryGetProperty("type", out var typeNode)
|
||||
? typeNode.GetString()
|
||||
: null;
|
||||
typeText = typeText?.Trim().ToLowerInvariant();
|
||||
if (typeText is null ||
|
||||
typeText is not ("income" or "expense"))
|
||||
return new IntentResult("chat", null);
|
||||
var type = typeText == "income"
|
||||
? TransactionType.Income
|
||||
: TransactionType.Expense;
|
||||
var category = document.TryGetProperty(
|
||||
"categoryName",
|
||||
out var categoryNode)
|
||||
? categoryNode.GetString() ?? "其他"
|
||||
: "其他";
|
||||
var note = document.TryGetProperty("note", out var noteNode)
|
||||
? noteNode.GetString() ?? category
|
||||
: category;
|
||||
var amount = document.TryGetProperty("amount", out var amountNode) &&
|
||||
amountNode.TryGetDecimal(out var value)
|
||||
? value
|
||||
: 0;
|
||||
return new IntentResult(
|
||||
"bill",
|
||||
new ParsedBill(
|
||||
type,
|
||||
0,
|
||||
category,
|
||||
note,
|
||||
amount,
|
||||
null));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user