|
1 | 1 | using System.Text; |
2 | 2 | using System.Text.Json; |
| 3 | +using System.Text.RegularExpressions; |
3 | 4 | using Juggle.Domain.Engine.NodeExecutors; |
4 | 5 | using Juggle.Domain.Entities; |
5 | 6 | using Juggle.Infrastructure.Persistence; |
@@ -272,6 +273,128 @@ public async Task<AiChatResult> ChatRequestAsync(AiChatRequest req) |
272 | 273 | return result; |
273 | 274 | } |
274 | 275 |
|
| 276 | + /// <summary>模型类型识别(chat/image/video,按模型名关键词,与前端 utils/aiModel.ts 保持一致)。</summary> |
| 277 | + public static string DetectModelKind(string? model) |
| 278 | + { |
| 279 | + var m = (model ?? "").ToLower(); |
| 280 | + if (Regex.IsMatch(m, @"(t2v|wanx[^a-z0-9]*video|kling|hailuo|pixverse|pika|cogvideo|minimax[^a-z0-9]*video|sora|veo|mochi|ltx|video)")) |
| 281 | + return "video"; |
| 282 | + if (Regex.IsMatch(m, @"(wanx|t2i|dall|gpt-image|flux|stable|midjourney|kandinsky|playground|image)")) |
| 283 | + return "image"; |
| 284 | + return "chat"; |
| 285 | + } |
| 286 | + |
| 287 | + /// <summary>生成图片/视频(生图/生视频模型):通义万相走 DashScope 异步任务+轮询,OpenAI 兼容走 images/generations。返回 {type,url}。</summary> |
| 288 | + public async Task<object> GenerateMediaAsync(long providerId, string? model, string prompt) |
| 289 | + { |
| 290 | + var kind = DetectModelKind(model); |
| 291 | + if (kind == "chat") |
| 292 | + throw new Exception($"模型「{model}」不是生图/生视频模型"); |
| 293 | + |
| 294 | + var provider = providerId > 0 |
| 295 | + ? await _db.AiProviders.FirstOrDefaultAsync(p => p.Id == providerId && p.Deleted == 0) |
| 296 | + : null; |
| 297 | + provider ??= await _db.AiProviders.FirstOrDefaultAsync(p => p.Deleted == 0 && p.Enabled == 1) |
| 298 | + ?? throw new Exception("未配置 AI 大模型:请在系统设置 → 大模型设置中添加并启用供应商"); |
| 299 | + |
| 300 | + var apiKey = provider.ApiKey ?? ""; |
| 301 | + if (string.IsNullOrEmpty(apiKey)) |
| 302 | + throw new Exception("供应商未配置 API Key"); |
| 303 | + var modelName = string.IsNullOrWhiteSpace(model) ? provider.Model ?? "" : model; |
| 304 | + |
| 305 | + var client = _httpClientFactory.CreateClient(); |
| 306 | + client.Timeout = TimeSpan.FromSeconds(60); |
| 307 | + |
| 308 | + // 通义万相(DashScope):异步任务提交 + 轮询结果 |
| 309 | + if ((provider.BaseUrl ?? "").Contains("dashscope", StringComparison.OrdinalIgnoreCase)) |
| 310 | + { |
| 311 | + var isVideo = kind == "video"; |
| 312 | + var api = isVideo |
| 313 | + ? "https://dashscope.aliyuncs.com/api/v1/services/aigc/text2video/video-synthesis" |
| 314 | + : "https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis"; |
| 315 | + var body = JsonSerializer.Serialize(new |
| 316 | + { |
| 317 | + model = modelName, |
| 318 | + input = new { prompt }, |
| 319 | + parameters = isVideo ? (object)new { size = "1280*720" } : new { size = "1024*1024", n = 1 } |
| 320 | + }); |
| 321 | + using var reqMsg = new HttpRequestMessage(HttpMethod.Post, api); |
| 322 | + reqMsg.Headers.TryAddWithoutValidation("Authorization", $"Bearer {apiKey}"); |
| 323 | + reqMsg.Headers.TryAddWithoutValidation("X-DashScope-Async", "enable"); |
| 324 | + reqMsg.Content = new StringContent(body, Encoding.UTF8, "application/json"); |
| 325 | + using var resp = await client.SendAsync(reqMsg); |
| 326 | + var respBody = await resp.Content.ReadAsStringAsync(); |
| 327 | + if (!resp.IsSuccessStatusCode) |
| 328 | + throw new Exception($"{(isVideo ? "视频" : "图片")}生成提交失败({(int)resp.StatusCode}): {Truncate(respBody, 200)}"); |
| 329 | + using var doc = JsonDocument.Parse(respBody); |
| 330 | + var taskId = doc.RootElement.GetProperty("output").GetProperty("task_id").GetString() |
| 331 | + ?? throw new Exception("未获取到生成任务 ID"); |
| 332 | + |
| 333 | + // 轮询任务结果(最多约 3 分钟,视频生成较慢) |
| 334 | + for (var i = 0; i < 90; i++) |
| 335 | + { |
| 336 | + await Task.Delay(2000); |
| 337 | + var taskResp = await client.GetAsync($"https://dashscope.aliyuncs.com/api/v1/tasks/{taskId}"); |
| 338 | + var taskBody = await taskResp.Content.ReadAsStringAsync(); |
| 339 | + using var tdoc = JsonDocument.Parse(taskBody); |
| 340 | + var root = tdoc.RootElement; |
| 341 | + var output = root.TryGetProperty("output", out var outEl) ? outEl : default; |
| 342 | + var status = output.ValueKind == JsonValueKind.Object && output.TryGetProperty("task_status", out var st) |
| 343 | + ? st.GetString() : ""; |
| 344 | + if (status == "SUCCEEDED") |
| 345 | + { |
| 346 | + var results = output.GetProperty("results"); |
| 347 | + var url = results[0].GetProperty("url").GetString(); |
| 348 | + return new { type = kind, url }; |
| 349 | + } |
| 350 | + if (status == "FAILED") |
| 351 | + { |
| 352 | + var msg = output.TryGetProperty("message", out var me) ? me.GetString() : "未知错误"; |
| 353 | + throw new Exception($"生成任务失败: {msg}"); |
| 354 | + } |
| 355 | + } |
| 356 | + throw new Exception("生成超时(约 3 分钟),请稍后重试"); |
| 357 | + } |
| 358 | + |
| 359 | + // OpenAI 兼容 images/generations(仅生图) |
| 360 | + if (kind == "image") |
| 361 | + { |
| 362 | + var baseUrl = provider.BaseUrl!.TrimEnd('/'); |
| 363 | + var url = baseUrl.EndsWith("/images/generations", StringComparison.OrdinalIgnoreCase) |
| 364 | + ? baseUrl : baseUrl + "/images/generations"; |
| 365 | + var body = JsonSerializer.Serialize(new { model = modelName, prompt, n = 1, size = "1024x1024" }); |
| 366 | + using var reqMsg = new HttpRequestMessage(HttpMethod.Post, url); |
| 367 | + reqMsg.Headers.TryAddWithoutValidation("Authorization", $"Bearer {apiKey}"); |
| 368 | + reqMsg.Content = new StringContent(body, Encoding.UTF8, "application/json"); |
| 369 | + using var resp = await client.SendAsync(reqMsg); |
| 370 | + var respBody = await resp.Content.ReadAsStringAsync(); |
| 371 | + if (!resp.IsSuccessStatusCode) |
| 372 | + throw new Exception($"图片生成失败({(int)resp.StatusCode}): {Truncate(respBody, 200)}"); |
| 373 | + using var doc = JsonDocument.Parse(respBody); |
| 374 | + var data = doc.RootElement.GetProperty("data")[0]; |
| 375 | + if (data.TryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String) |
| 376 | + return new { type = "image", url = u.GetString() }; |
| 377 | + if (data.TryGetProperty("b64_json", out var b64) && b64.ValueKind == JsonValueKind.String) |
| 378 | + return new { type = "image", url = "data:image/png;base64," + b64.GetString() }; |
| 379 | + throw new Exception("图片生成响应缺少 url/b64_json"); |
| 380 | + } |
| 381 | + |
| 382 | + throw new Exception("视频生成仅支持通义万相(DashScope)供应商,请添加 dashscope 供应商或将模型切换为通义视频模型"); |
| 383 | + } |
| 384 | + |
| 385 | + /// <summary>追加媒体消息(生图/生视频的提问与结果)到会话历史。</summary> |
| 386 | + public async Task AppendMediaMessageAsync(AiConversationEntity conv, string userContent, string assistantContent) |
| 387 | + { |
| 388 | + var history = ParseMessages(conv.Messages); |
| 389 | + history.Add(("user", userContent)); |
| 390 | + history.Add(("assistant", assistantContent)); |
| 391 | + conv.Messages = JsonSerializer.Serialize(history.Select(m => new { role = m.Role, content = m.Content })); |
| 392 | + if (string.IsNullOrEmpty(conv.Title)) |
| 393 | + conv.Title = userContent.Length > 30 ? userContent[..30] : userContent; |
| 394 | + conv.UpdatedAt = DateTime.Now.ToString("o"); |
| 395 | + await _db.SaveChangesAsync(); |
| 396 | + } |
| 397 | + |
275 | 398 | private static string Truncate(string s, int len) => s.Length <= len ? s : s[..len] + "..."; |
276 | 399 |
|
277 | 400 | // ==================== 流程生成 ==================== |
|
0 commit comments