|
2 | 2 | using System.Net.Http.Headers; |
3 | 3 | using System.Net.Http.Json; |
4 | 4 | using System.Text.Json; |
| 5 | +using System.Text.Json.Nodes; |
5 | 6 |
|
6 | 7 | namespace Iyu.VaultAi; |
7 | 8 |
|
@@ -75,4 +76,86 @@ public async Task<string> GetMessageAsync(Guid agentId, string prompt, Cancellat |
75 | 76 |
|
76 | 77 | return text ?? string.Empty; |
77 | 78 | } |
| 79 | + |
| 80 | + public async Task<JsonNode> GetStructuredMessageAsync( |
| 81 | + Guid agentId, |
| 82 | + string prompt, |
| 83 | + JsonNode outputSchema, |
| 84 | + IReadOnlyList<VaultAiImage>? images = null, |
| 85 | + CancellationToken ct = default) |
| 86 | + { |
| 87 | + // content 파트 조립 — 텍스트 + (선택) 이미지. 이미지는 vault-ai wire 계약상 |
| 88 | + // "file" 콘텐츠의 data 블록([{type:"image", mimeType, data(base64)}])으로 실린다 |
| 89 | + // (VaultAI.WebServer VaultFileContent/ImageBlock — 채널 파일업로드 경유 없이 인라인 전달). |
| 90 | + var content = new List<object> { new { type = "text", text = prompt } }; |
| 91 | + foreach (var img in images ?? []) |
| 92 | + { |
| 93 | + content.Add(new |
| 94 | + { |
| 95 | + type = "file", |
| 96 | + id = Guid.NewGuid().ToString(), |
| 97 | + fileName = img.FileName ?? "image", |
| 98 | + contentType = img.MimeType, |
| 99 | + contentSize = img.Data.LongLength, |
| 100 | + data = new object[] |
| 101 | + { |
| 102 | + new { type = "image", mimeType = img.MimeType, data = Convert.ToBase64String(img.Data) }, |
| 103 | + }, |
| 104 | + }); |
| 105 | + } |
| 106 | + |
| 107 | + var response = await _http.PostAsJsonAsync( |
| 108 | + $"api/agents/{agentId}/messages", |
| 109 | + new |
| 110 | + { |
| 111 | + messages = new[] |
| 112 | + { |
| 113 | + new |
| 114 | + { |
| 115 | + role = "user", |
| 116 | + id = Guid.NewGuid().ToString(), |
| 117 | + content = (object)content, |
| 118 | + createdAt = DateTimeOffset.UtcNow.ToString("o") |
| 119 | + } |
| 120 | + }, |
| 121 | + stream = false, |
| 122 | + output = outputSchema, // JSON schema → 응답 output으로 구조화 수신 |
| 123 | + }, ct); |
| 124 | + |
| 125 | + if (!response.IsSuccessStatusCode) |
| 126 | + { |
| 127 | + var errorBody = await response.Content.ReadAsStringAsync(ct); |
| 128 | + var snippet = errorBody.Length > 1000 ? errorBody[..1000] + "…(중략)" : errorBody; |
| 129 | + throw new HttpRequestException( |
| 130 | + $"vault-ai 응답 {(int)response.StatusCode} ({response.StatusCode}): {snippet}"); |
| 131 | + } |
| 132 | + |
| 133 | + var json = await response.Content.ReadAsStringAsync(ct); |
| 134 | + var root = JsonNode.Parse(json); |
| 135 | + |
| 136 | + // 1순위: 응답 최상위 output(서버가 파싱해 준 구조화 결과). |
| 137 | + var output = root?["output"]; |
| 138 | + if (output is not null) return output; |
| 139 | + |
| 140 | + // 폴백: 마지막 text 파트를 JSON으로 파싱(구버전 서버·output 미주입 경로 호환). |
| 141 | + var contentArr = root?["message"]?["content"] as JsonArray; |
| 142 | + var lastText = contentArr? |
| 143 | + .OfType<JsonObject>() |
| 144 | + .Where(o => (string?)o["type"] == "text") |
| 145 | + .Select(o => (string?)o["text"]) |
| 146 | + .LastOrDefault(t => !string.IsNullOrWhiteSpace(t)); |
| 147 | + if (lastText is not null) |
| 148 | + { |
| 149 | + try { return JsonNode.Parse(lastText) ?? throw new JsonException("null JSON"); } |
| 150 | + catch (JsonException ex) |
| 151 | + { |
| 152 | + var snippet = lastText.Length > 500 ? lastText[..500] + "…(중략)" : lastText; |
| 153 | + throw new HttpRequestException($"vault-ai 응답 text가 유효한 JSON이 아닙니다: {ex.Message} — {snippet}"); |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | + // output도 text도 없음 — 스키마 불일치를 조용히 흘리지 않는다(silent-failure 방지). |
| 158 | + var bodySnippet = json.Length > 1000 ? json[..1000] + "…(중략)" : json; |
| 159 | + throw new HttpRequestException($"vault-ai 응답에 output/text가 없습니다: {bodySnippet}"); |
| 160 | + } |
78 | 161 | } |
0 commit comments