diff --git a/Yi.Abp.Net8/.claude/settings.local.json b/Yi.Abp.Net8/.claude/settings.local.json index 54ea23cd..e399b33d 100644 --- a/Yi.Abp.Net8/.claude/settings.local.json +++ b/Yi.Abp.Net8/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(dotnet build \"E:\\code\\github\\Yi\\Yi.Abp.Net8\\module\\ai-hub\\Yi.Framework.AiHub.Application\\Yi.Framework.AiHub.Application.csproj\" --no-restore)", - "Read(//e/code/github/Yi/Yi.Ai.Vue3/**)" + "Read(//e/code/github/Yi/Yi.Ai.Vue3/**)", + "Bash(dotnet build:*)" ], "deny": [], "ask": [] diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/Chat/ImageGenerationInput.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/Chat/ImageGenerationInput.cs new file mode 100644 index 00000000..c9bade65 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/Chat/ImageGenerationInput.cs @@ -0,0 +1,22 @@ +namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Chat; + +/// +/// 图片生成输入 +/// +public class ImageGenerationInput +{ + /// + /// 提示词 + /// + public string Prompt { get; set; } = string.Empty; + + /// + /// 模型ID + /// + public string ModelId { get; set; } = string.Empty; + + /// + /// 参考图Base64列表(可选,包含前缀如 data:image/png;base64,...) + /// + public List? ReferenceImagesBase64 { get; set; } +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/Chat/ImageTaskOutput.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/Chat/ImageTaskOutput.cs new file mode 100644 index 00000000..1834b5a0 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/Chat/ImageTaskOutput.cs @@ -0,0 +1,49 @@ +using Yi.Framework.AiHub.Domain.Shared.Enums; + +namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Chat; + +/// +/// 图片任务输出 +/// +public class ImageTaskOutput +{ + /// + /// 任务ID + /// + public Guid Id { get; set; } + + /// + /// 提示词 + /// + public string Prompt { get; set; } = string.Empty; + + /// + /// 参考图Base64列表 + /// + public List? ReferenceImagesBase64 { get; set; } + + /// + /// 参考图URL列表 + /// + public List? ReferenceImagesUrl { get; set; } + + /// + /// 生成图片Base64(包含前缀) + /// + public string? StoreBase64 { get; set; } + + /// + /// 生成图片URL + /// + public string? StoreUrl { get; set; } + + /// + /// 任务状态 + /// + public TaskStatusEnum TaskStatus { get; set; } + + /// + /// 创建时间 + /// + public DateTime CreationTime { get; set; } +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/Chat/ImageTaskPageInput.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/Chat/ImageTaskPageInput.cs new file mode 100644 index 00000000..c42985d5 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/Chat/ImageTaskPageInput.cs @@ -0,0 +1,24 @@ +using Yi.Framework.AiHub.Domain.Shared.Enums; + +namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Chat; + +/// +/// 图片任务分页查询输入 +/// +public class ImageTaskPageInput +{ + /// + /// 页码(从1开始) + /// + public int PageIndex { get; set; } = 1; + + /// + /// 每页数量 + /// + public int PageSize { get; set; } = 10; + + /// + /// 任务状态筛选(可选) + /// + public TaskStatusEnum? TaskStatus { get; set; } +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Jobs/ImageGenerationJob.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Jobs/ImageGenerationJob.cs new file mode 100644 index 00000000..fca09694 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Jobs/ImageGenerationJob.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Yi.Framework.AiHub.Domain.Entities.Chat; +using Yi.Framework.AiHub.Domain.Managers; +using Yi.Framework.AiHub.Domain.Shared.Enums; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace Yi.Framework.AiHub.Application.Jobs; + +/// +/// 图片生成后台任务 +/// +public class ImageGenerationJob : AsyncBackgroundJob, ITransientDependency +{ + private readonly ILogger _logger; + private readonly AiGateWayManager _aiGateWayManager; + private readonly ISqlSugarRepository _imageStoreTaskRepository; + + public ImageGenerationJob( + ILogger logger, + AiGateWayManager aiGateWayManager, + ISqlSugarRepository imageStoreTaskRepository) + { + _logger = logger; + _aiGateWayManager = aiGateWayManager; + _imageStoreTaskRepository = imageStoreTaskRepository; + } + + public override async Task ExecuteAsync(ImageGenerationJobArgs args) + { + _logger.LogInformation("开始执行图片生成任务,TaskId: {TaskId}, ModelId: {ModelId}, UserId: {UserId}", + args.TaskId, args.ModelId, args.UserId); + + try + { + var request = JsonSerializer.Deserialize(args.RequestJson); + + await _aiGateWayManager.GeminiGenerateContentImageForStatisticsAsync( + args.TaskId, + args.ModelId, + request, + args.UserId); + + _logger.LogInformation("图片生成任务完成,TaskId: {TaskId}", args.TaskId); + } + catch (Exception ex) + { + _logger.LogError(ex, "图片生成任务失败,TaskId: {TaskId}, Error: {Error}", args.TaskId, ex.Message); + + // 更新任务状态为失败 + var task = await _imageStoreTaskRepository.GetFirstAsync(x => x.Id == args.TaskId); + if (task != null) + { + task.TaskStatus = TaskStatusEnum.Fail; + await _imageStoreTaskRepository.UpdateAsync(task); + } + } + } +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Jobs/ImageGenerationJobArgs.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Jobs/ImageGenerationJobArgs.cs new file mode 100644 index 00000000..666b6b59 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Jobs/ImageGenerationJobArgs.cs @@ -0,0 +1,27 @@ +namespace Yi.Framework.AiHub.Application.Jobs; + +/// +/// 图片生成后台任务参数 +/// +public class ImageGenerationJobArgs +{ + /// + /// 图片任务ID + /// + public Guid TaskId { get; set; } + + /// + /// 模型ID + /// + public string ModelId { get; set; } = string.Empty; + + /// + /// 请求JSON字符串 + /// + public string RequestJson { get; set; } = string.Empty; + + /// + /// 用户ID + /// + public Guid UserId { get; set; } +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/AiImageService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/AiImageService.cs new file mode 100644 index 00000000..5bffa03d --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/AiImageService.cs @@ -0,0 +1,271 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Volo.Abp; +using Volo.Abp.Application.Services; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.Guids; +using Volo.Abp.Users; +using Yi.Framework.AiHub.Application.Contracts.Dtos.Chat; +using Yi.Framework.AiHub.Application.Jobs; +using Yi.Framework.AiHub.Domain.Entities.Chat; +using Yi.Framework.AiHub.Domain.Extensions; +using Yi.Framework.AiHub.Domain.Managers; +using Yi.Framework.AiHub.Domain.Shared.Consts; +using Yi.Framework.AiHub.Domain.Shared.Enums; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace Yi.Framework.AiHub.Application.Services.Chat; + +/// +/// AI图片生成服务 +/// +[Authorize] +public class AiImageService : ApplicationService +{ + private readonly ISqlSugarRepository _imageTaskRepository; + private readonly IBackgroundJobManager _backgroundJobManager; + private readonly AiBlacklistManager _aiBlacklistManager; + private readonly PremiumPackageManager _premiumPackageManager; + private readonly IGuidGenerator _guidGenerator; + private readonly IWebHostEnvironment _webHostEnvironment; + + public AiImageService( + ISqlSugarRepository imageTaskRepository, + IBackgroundJobManager backgroundJobManager, + AiBlacklistManager aiBlacklistManager, + PremiumPackageManager premiumPackageManager, + IGuidGenerator guidGenerator, + IWebHostEnvironment webHostEnvironment) + { + _imageTaskRepository = imageTaskRepository; + _backgroundJobManager = backgroundJobManager; + _aiBlacklistManager = aiBlacklistManager; + _premiumPackageManager = premiumPackageManager; + _guidGenerator = guidGenerator; + _webHostEnvironment = webHostEnvironment; + } + + /// + /// 生成图片(异步任务) + /// + /// 图片生成输入参数 + /// 任务ID + [HttpPost("ai-image/generate")] + [Authorize] + public async Task GenerateAsync([FromBody] ImageGenerationInput input) + { + var userId = CurrentUser.GetId(); + + // 黑名单校验 + await _aiBlacklistManager.VerifiyAiBlacklist(userId); + + // VIP校验 + if (!CurrentUser.IsAiVip()) + { + throw new UserFriendlyException("图片生成功能需要VIP用户才能使用,请购买VIP后重新登录重试"); + } + + // 尊享包校验 + if (PremiumPackageConst.ModeIds.Contains(input.ModelId)) + { + var availableTokens = await _premiumPackageManager.GetAvailableTokensAsync(userId); + if (availableTokens <= 0) + { + throw new UserFriendlyException("尊享token包用量不足,请先购买尊享token包"); + } + } + + // 创建任务实体 + var task = new ImageStoreTaskAggregateRoot + { + Prompt = input.Prompt, + ReferenceImagesBase64 = input.ReferenceImagesBase64 ?? new List(), + ReferenceImagesUrl = new List(), + TaskStatus = TaskStatusEnum.Processing, + UserId = userId + }; + + await _imageTaskRepository.InsertAsync(task); + var taskId = task.Id; + + // 构建请求JSON + var requestJson = JsonSerializer.Serialize(new + { + prompt = input.Prompt, + referenceImages = input.ReferenceImagesBase64 + }); + + // 入队后台任务 + await _backgroundJobManager.EnqueueAsync(new ImageGenerationJobArgs + { + TaskId = taskId, + ModelId = input.ModelId, + RequestJson = requestJson, + UserId = userId + }); + + return taskId; + } + + /// + /// 查询任务状态 + /// + /// 任务ID + /// 任务详情 + [HttpGet("ai-image/task/{taskId}")] + public async Task GetTaskAsync([FromRoute] Guid taskId) + { + var userId = CurrentUser.GetId(); + + var task = await _imageTaskRepository.GetFirstAsync(x => x.Id == taskId && x.UserId == userId); + if (task == null) + { + throw new UserFriendlyException("任务不存在或无权访问"); + } + + return new ImageTaskOutput + { + Id = task.Id, + Prompt = task.Prompt, + ReferenceImagesBase64 = task.ReferenceImagesBase64, + ReferenceImagesUrl = task.ReferenceImagesUrl, + StoreBase64 = task.StoreBase64, + StoreUrl = task.StoreUrl, + TaskStatus = task.TaskStatus, + CreationTime = task.CreationTime + }; + } + + /// + /// 上传Base64图片转换为URL + /// + /// Base64图片数据(包含前缀如 data:image/png;base64,) + /// 图片访问URL + [HttpPost("ai-image/upload-base64")] + public async Task UploadBase64ToUrlAsync([FromBody] string base64Data) + { + if (string.IsNullOrWhiteSpace(base64Data)) + { + throw new UserFriendlyException("Base64数据不能为空"); + } + + // 解析Base64数据 + string mimeType = "image/png"; + string base64Content = base64Data; + + if (base64Data.Contains(",")) + { + var parts = base64Data.Split(','); + if (parts.Length == 2) + { + // 提取MIME类型 + var header = parts[0]; + if (header.Contains(":") && header.Contains(";")) + { + mimeType = header.Split(':')[1].Split(';')[0]; + } + base64Content = parts[1]; + } + } + + // 获取文件扩展名 + var extension = mimeType switch + { + "image/png" => ".png", + "image/jpeg" => ".jpg", + "image/jpg" => ".jpg", + "image/gif" => ".gif", + "image/webp" => ".webp", + _ => ".png" + }; + + // 解码Base64 + byte[] imageBytes; + try + { + imageBytes = Convert.FromBase64String(base64Content); + } + catch (FormatException) + { + throw new UserFriendlyException("Base64格式无效"); + } + + // 创建存储目录 + var uploadPath = Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot", "ai-images"); + if (!Directory.Exists(uploadPath)) + { + Directory.CreateDirectory(uploadPath); + } + + // 生成文件名并保存 + var fileId = _guidGenerator.Create(); + var fileName = $"{fileId}{extension}"; + var filePath = Path.Combine(uploadPath, fileName); + + await File.WriteAllBytesAsync(filePath, imageBytes); + + // 返回访问URL + return $"/ai-images/{fileName}"; + } + + /// + /// 分页查询任务列表 + /// + /// 分页查询参数 + /// 任务列表 + [HttpGet("ai-image/tasks")] + public async Task> GetTaskPageAsync([FromQuery] ImageTaskPageInput input) + { + var userId = CurrentUser.GetId(); + + var query = _imageTaskRepository._DbQueryable + .Where(x => x.UserId == userId) + .WhereIF(input.TaskStatus.HasValue, x => x.TaskStatus == input.TaskStatus!.Value) + .OrderByDescending(x => x.CreationTime); + + var total = await query.CountAsync(); + var items = await query + .Skip((input.PageIndex - 1) * input.PageSize) + .Take(input.PageSize) + .Select(x => new ImageTaskOutput + { + Id = x.Id, + Prompt = x.Prompt, + ReferenceImagesBase64 = x.ReferenceImagesBase64, + ReferenceImagesUrl = x.ReferenceImagesUrl, + StoreBase64 = x.StoreBase64, + StoreUrl = x.StoreUrl, + TaskStatus = x.TaskStatus, + CreationTime = x.CreationTime + }) + .ToListAsync(); + + return new PagedResult(total, items); + } +} + +/// +/// 分页结果 +/// +/// 数据类型 +public class PagedResult +{ + /// + /// 总数 + /// + public long Total { get; set; } + + /// + /// 数据列表 + /// + public List Items { get; set; } + + public PagedResult(long total, List items) + { + Total = total; + Items = items; + } +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Yi.Framework.AiHub.Application.csproj b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Yi.Framework.AiHub.Application.csproj index 30caa193..dd8b3b65 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Yi.Framework.AiHub.Application.csproj +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Yi.Framework.AiHub.Application.csproj @@ -2,6 +2,10 @@ + + + + diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs index 1c30bb17..4bbe1d30 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs @@ -966,9 +966,14 @@ public class AiGateWayManager : DomainService //解析json,获取base64字符串 var imageBase64 = GeminiGenerateContentAcquirer.GetImageBase64(data); - //base64字符串存储,返回绝对路径,用于最后存储 - var storeUrl = Base64ToPng(imageBase64, "存储的路径?这个放什么"); - + //远程调用上传接口,将base64转换为URL + var httpClient = LazyServiceProvider.LazyGetRequiredService().CreateClient(); + var uploadUrl = $"https://ccnetcore.com/prod-api/ai-hub/ai-image/upload-base64"; + var content = new StringContent(JsonSerializer.Serialize(imageBase64), Encoding.UTF8, "application/json"); + var uploadResponse = await httpClient.PostAsync(uploadUrl, content, cancellationToken); + uploadResponse.EnsureSuccessStatusCode(); + var storeUrl = await uploadResponse.Content.ReadAsStringAsync(cancellationToken); + storeUrl = storeUrl.Trim('"'); // 移除JSON字符串的引号 var tokenUsage = new ThorUsageResponse { @@ -994,42 +999,12 @@ public class AiGateWayManager : DomainService await PremiumPackageManager.TryConsumeTokensAsync(userId, totalTokens); } - //设置存储url + //设置存储base64和url + imageStoreTask.StoreBase64 = imageBase64; imageStoreTask.SetSuccess(storeUrl); await _imageStoreTaskRepository.UpdateAsync(imageStoreTask); } - /// - /// 将 Base64 字符串转换为 PNG 图片并保存 - /// - /// Base64 字符串(可以包含或不包含 data:image/png;base64, 前缀) - /// 输出文件路径 - private string Base64ToPng(string base64String, string outputPath) - { - try - { - // 移除可能存在的 data URI scheme 前缀 - if (base64String.Contains(",")) - { - base64String = base64String.Substring(base64String.IndexOf(",") + 1); - } - - // 将 base64 字符串转换为字节数组 - byte[] imageBytes = Convert.FromBase64String(base64String); - - // 将字节数组写入文件 - File.WriteAllBytes(outputPath, imageBytes); - } - catch (Exception ex) - { - throw new UserFriendlyException("gemini Base64转图像失败", innerException: ex); - } - - //todo - //路径拼接一下? - return outputPath; - } - #region 流式传输格式Http响应 private static readonly byte[] EventPrefix = "event: "u8.ToArray();