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 _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 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 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 TryGenerateReplyAsync(string s, string u, CancellationToken ct = default) { if (!IsEnabled) return null; return await L(s, u, ct); } public async Task?> 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(); 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); } } public async Task?> ReconcileRecognitionBatchAsync( RecognitionBatchModelInput input, CancellationToken ct = default) { if (!IsEnabled) return null; var candidateJson = JsonSerializer.Serialize( input.Candidates.Select(candidate => new { candidateId = candidate.CandidateId, flowSessionId = candidate.FlowSessionId, packageName = candidate.PackageName, type = candidate.Type, amount = candidate.Amount, merchant = candidate.Merchant, orderId = candidate.OrderId, occurredAt = candidate.OccurredAt, recognitionKind = candidate.RecognitionKind, categoryHint = candidate.CategoryHint, confidence = candidate.Confidence, evidenceIds = candidate.EvidenceIds, }), new JsonSerializerOptions(JsonSerializerDefaults.Web)); var systemPrompt = $$""" 你是支付结果批次对账器。只返回 JSON: {"actions":[{"action":"keep|update|create|drop","actionId":"a1","candidateId":null,"evidenceId":null,"type":null,"amount":null,"categoryName":null,"paymentMethod":null,"note":null,"occurredAt":null,"confidence":0.0,"reason":""}]} 当前批次候选:{{candidateJson}} 支出分类只能是:{{string.Join('/', input.ExpenseCategories)}}。 收入分类只能是:{{string.Join('/', input.IncomeCategories)}}。 每个候选必须且只能返回一个 keep、update 或 drop;缺失字段用候选原值。 不同 flowSessionId 代表不同支付流程。即使收款人、金额和时间相同,也绝不能据此合并或删除。 只有截图明确显示失败、取消、待支付,或明确是同一 flowSessionId 的重复结果页时才可 drop。 只有存在没有对应候选的 evidenceId 且截图明确显示交易成功时才可 create,并必须引用该 evidenceId。 update/create 的 type 只能是 expense 或 income,amount 必须大于 0。 截图未明确显示时间时沿用候选时间,禁止猜测时间。reason 不超过 40 个汉字。 """; var messages = new List(); var content = new List(); if (_protocol == "messages") { content.Add(new { type = "text", text = systemPrompt }); foreach (var evidence in input.Evidence) { content.Add(new { type = "text", text = $"证据 {evidence.EvidenceId}" }); content.Add(new { type = "image", source = new { type = "base64", media_type = evidence.MimeType, data = Convert.ToBase64String(evidence.ImageBytes), }, }); } messages.Add(new { role = "user", content }); } else if (_protocol == "responses") { messages.Add(new { role = "system", content = systemPrompt }); foreach (var evidence in input.Evidence) { content.Add(new { type = "input_text", text = $"证据 {evidence.EvidenceId}" }); content.Add(new { type = "input_image", image_url = "data:" + evidence.MimeType + ";base64," + Convert.ToBase64String(evidence.ImageBytes), }); } if (content.Count == 0) content.Add(new { type = "input_text", text = "仅根据候选结构化信息完成对账" }); messages.Add(new { role = "user", content }); } else { messages.Add(new { role = "system", content = systemPrompt }); foreach (var evidence in input.Evidence) { content.Add(new { type = "text", text = $"证据 {evidence.EvidenceId}" }); content.Add(new { type = "image_url", image_url = new { url = "data:" + evidence.MimeType + ";base64," + Convert.ToBase64String(evidence.ImageBytes), }, }); } if (content.Count == 0) content.Add(new { type = "text", text = "仅根据候选结构化信息完成对账" }); messages.Add(new { role = "user", content }); } var (json, error) = await CA(BuildBody(messages, 2048, 0.1), ct); if (json is null) { _logger.LogWarning( "Recognition batch model request failed. batchId={BatchId} candidates={Candidates} evidence={Evidence} error={Error}", input.BatchId, input.Candidates.Count, input.Evidence.Count, error); throw new InvalidOperationException(error ?? "批次对账模型未返回内容"); } try { return ParseRecognitionBatchActions(EX(json)); } catch (Exception ex) when (ex is JsonException or InvalidOperationException) { _logger.LogWarning( ex, "Recognition batch output was invalid. batchId={BatchId} candidates={Candidates} evidence={Evidence}", input.BatchId, input.Candidates.Count, input.Evidence.Count); throw new InvalidOperationException("批次对账模型返回格式无法解析", ex); } } private static IReadOnlyList ParseRecognitionBatchActions( string raw) { foreach (var candidate in EnumerateJsonCandidates(raw)) { try { using var document = JsonDocument.Parse(candidate); var root = document.RootElement; JsonElement actions; if (root.ValueKind == JsonValueKind.Array) { actions = root; } else if (!TryGetProperty(root, "actions", out actions) || actions.ValueKind != JsonValueKind.Array) { continue; } var results = new List(); foreach (var item in actions.EnumerateArray().Take(30)) { if (item.ValueKind != JsonValueKind.Object) continue; var action = ReadText(item, "action")?.Trim().ToLowerInvariant(); if (action is not ("keep" or "update" or "create" or "drop")) continue; decimal? amount = TryGetProperty(item, "amount", out var amountValue) && amountValue.ValueKind != JsonValueKind.Null ? ReadAmount(item) : null; var confidence = TryGetProperty(item, "confidence", out var confidenceValue) && confidenceValue.TryGetDouble(out var parsedConfidence) ? Math.Clamp(parsedConfidence, 0, 1) : 0; var reason = ReadText(item, "reason")?.Trim() ?? "AI 对账"; results.Add(new RecognitionBatchModelAction( action, ReadText(item, "actionId", "action_id")?.Trim() ?? $"a{results.Count + 1}", ReadText(item, "candidateId", "candidate_id")?.Trim(), ReadText(item, "evidenceId", "evidence_id")?.Trim(), ReadText(item, "type")?.Trim().ToLowerInvariant(), amount, ReadText(item, "categoryName", "category_name")?.Trim(), ReadText(item, "paymentMethod", "payment_method")?.Trim(), ReadText(item, "note", "merchant")?.Trim(), ReadOccurredAt(item), confidence, reason.Length > 80 ? reason[..80] : reason)); } return results; } catch (JsonException) { // Models may wrap the JSON in prose; continue scanning candidates. } } throw new InvalidOperationException("JSON 中没有 actions 数组"); } private static IReadOnlyList 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(); 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 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(); 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 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 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( """{"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 onToken, CancellationToken ct = default) { if (!IsEnabled) return; Load(); var msgs = new List(); 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(); 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 L(string sys, string user, CancellationToken ct) { Load(); var msgs = new List(); 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 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 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(); var fallbackParts = new List(); 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(); 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; } } }