mirror of
https://gitee.com/ccnetcore/Yi
synced 2026-03-03 00:00:58 +08:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f1efafd86 | ||
|
|
2714a507d9 | ||
|
|
9a9230786b | ||
|
|
4a8b58a65c | ||
|
|
7d81f88658 | ||
|
|
0ce3c0bbdd | ||
|
|
981235e6e9 | ||
|
|
d0ecb232a1 | ||
|
|
c7a52604e7 | ||
|
|
da81b2d8a3 | ||
|
|
7b14fdd8de | ||
|
|
1fc2734eb7 | ||
|
|
f3bef72ebb | ||
|
|
7e6d2e829b | ||
|
|
944626960b | ||
|
|
c073868989 | ||
|
|
d2981100fa | ||
|
|
ce4f7e5711 | ||
|
|
cc812ba2cb | ||
|
|
8a6e5abf48 | ||
|
|
8b191330b8 | ||
|
|
5ed79c6dd0 | ||
|
|
6e2ca8f1c3 | ||
|
|
a46a552097 | ||
|
|
53e56134d4 | ||
|
|
0d2f2cb826 | ||
|
|
f90105ebb4 | ||
|
|
67ed1ac1e3 | ||
|
|
69b84f6613 | ||
|
|
433d616b9b | ||
|
|
53aa575ad4 | ||
|
|
571df74c43 | ||
|
|
b7847c7e7d | ||
|
|
94eb41996e | ||
|
|
cefde6848d | ||
|
|
381b712b25 | ||
|
|
c319b0b4e4 | ||
|
|
1a32fa9e20 | ||
|
|
909406238c | ||
|
|
54a1d2a66f | ||
|
|
8dcbfcad33 | ||
|
|
f64fd43951 | ||
|
|
551597765c | ||
|
|
bfda33280a | ||
|
|
8d0411f1f4 | ||
|
|
3995d4acab | ||
|
|
6ff5727156 | ||
|
|
f654386dfe | ||
|
|
c03ef82643 | ||
|
|
525545329b | ||
|
|
755cb6f509 | ||
|
|
55469708f0 | ||
|
|
94c52c62fe | ||
|
|
37b4709d76 | ||
|
|
86555af6ce | ||
|
|
ddb00879f4 | ||
|
|
2d0ca08314 | ||
|
|
b78ecf27d5 | ||
|
|
02a5f69958 | ||
|
|
cf5bf746ef | ||
|
|
0a5e40ee25 | ||
|
|
51a266ef58 | ||
|
|
1f0901c90c | ||
|
|
a725c06396 | ||
|
|
54547f0d7c | ||
|
|
afe9c8bcae | ||
|
|
688d93e5c1 | ||
|
|
4c65b2398d | ||
|
|
41435f1aa3 | ||
|
|
20206bbc44 | ||
|
|
f2dc0d1825 | ||
|
|
51b4d1b072 | ||
|
|
9180799e4e | ||
|
|
9788b9182b | ||
|
|
260b9a4795 | ||
|
|
9380e3daa8 | ||
|
|
8e8338743d |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -278,3 +278,5 @@ database_backup
|
||||
/Yi.Abp.Net8/src/Yi.Abp.Web/yi-abp-dev.db
|
||||
|
||||
package-lock.json
|
||||
|
||||
.claude
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<SatelliteResourceLanguages>en;zh-CN</SatelliteResourceLanguages>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>1.0.0</Version>
|
||||
<NoWarn>$(NoWarn);CS1591;CS8618;CS1998;CS8604;CS8620;CS8600;CS8602</NoWarn>
|
||||
|
||||
@@ -14,8 +14,10 @@ namespace Yi.Framework.SqlSugarCore.Repositories
|
||||
{
|
||||
public class SqlSugarRepository<TEntity> : ISqlSugarRepository<TEntity>, IRepository<TEntity> where TEntity : class, IEntity, new()
|
||||
{
|
||||
[Obsolete("使用GetDbContextAsync()")]
|
||||
public ISqlSugarClient _Db => AsyncContext.Run(async () => await GetDbContextAsync());
|
||||
|
||||
[Obsolete("使用AsQueryable()")]
|
||||
public ISugarQueryable<TEntity> _DbQueryable => _Db.Queryable<TEntity>();
|
||||
|
||||
private readonly ISugarDbContextProvider<ISqlSugarDbContext> _dbContextProvider;
|
||||
@@ -320,12 +322,12 @@ namespace Yi.Framework.SqlSugarCore.Repositories
|
||||
|
||||
public virtual async Task<List<TEntity>> GetPageListAsync(Expression<Func<TEntity, bool>> whereExpression, int pageNum, int pageSize)
|
||||
{
|
||||
return await (await GetDbSimpleClientAsync()).GetPageListAsync(whereExpression, new PageModel() { PageIndex = pageNum, PageSize = pageSize });
|
||||
return await (await AsQueryable()).Where(whereExpression).ToPageListAsync(pageNum, pageSize);
|
||||
}
|
||||
|
||||
public virtual async Task<List<TEntity>> GetPageListAsync(Expression<Func<TEntity, bool>> whereExpression, int pageNum, int pageSize, Expression<Func<TEntity, object>>? orderByExpression = null, OrderByType orderByType = OrderByType.Asc)
|
||||
{
|
||||
return await (await GetDbSimpleClientAsync()).GetPageListAsync(whereExpression, new PageModel { PageIndex = pageNum, PageSize = pageSize }, orderByExpression, orderByType);
|
||||
return await (await AsQueryable()).Where(whereExpression) .OrderBy( orderByExpression,orderByType).ToPageListAsync(pageNum, pageSize);
|
||||
}
|
||||
|
||||
public virtual async Task<TEntity> GetSingleAsync(Expression<Func<TEntity, bool>> whereExpression)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
|
||||
|
||||
/// <summary>
|
||||
/// API类型选项
|
||||
/// </summary>
|
||||
public class ModelApiTypeOption
|
||||
{
|
||||
/// <summary>
|
||||
/// 显示名称
|
||||
/// </summary>
|
||||
public string Label { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 枚举值
|
||||
/// </summary>
|
||||
public int Value { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Yi.Framework.AiHub.Domain.Shared.Enums;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Extensions;
|
||||
|
||||
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
|
||||
|
||||
/// <summary>
|
||||
/// 模型库展示数据
|
||||
/// </summary>
|
||||
public class ModelLibraryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 模型ID
|
||||
/// </summary>
|
||||
public string ModelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模型名称
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模型描述
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模型类型
|
||||
/// </summary>
|
||||
public ModelTypeEnum ModelType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模型类型名称
|
||||
/// </summary>
|
||||
public string ModelTypeName => ModelType.GetDescription();
|
||||
|
||||
/// <summary>
|
||||
/// 模型支持的API类型
|
||||
/// </summary>
|
||||
public List<ModelApiTypeOutput> ModelApiTypes { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 模型显示倍率
|
||||
/// </summary>
|
||||
public decimal MultiplierShow { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 供应商分组名称
|
||||
/// </summary>
|
||||
public string? ProviderName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模型图标URL
|
||||
/// </summary>
|
||||
public string? IconUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否为尊享模型(PremiumChat类型)
|
||||
/// </summary>
|
||||
public bool IsPremium { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序
|
||||
/// </summary>
|
||||
public int OrderNum { get; set; }
|
||||
}
|
||||
|
||||
public class ModelApiTypeOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 模型类型
|
||||
/// </summary>
|
||||
public ModelApiTypeEnum ModelApiType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模型类型名称
|
||||
/// </summary>
|
||||
public string ModelApiTypeName => ModelApiType.GetDescription();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Yi.Framework.AiHub.Domain.Shared.Enums;
|
||||
using Yi.Framework.Ddd.Application.Contracts;
|
||||
|
||||
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
|
||||
|
||||
/// <summary>
|
||||
/// 获取模型库列表查询参数
|
||||
/// </summary>
|
||||
public class ModelLibraryGetListInput : PagedAllResultRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 搜索关键词(搜索模型名称、模型ID)
|
||||
/// </summary>
|
||||
public string? SearchKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 供应商名称筛选
|
||||
/// </summary>
|
||||
public List<string>? ProviderNames { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模型类型筛选
|
||||
/// </summary>
|
||||
public List<ModelTypeEnum>? ModelTypes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// API类型筛选
|
||||
/// </summary>
|
||||
public List<ModelApiTypeEnum>? ModelApiTypes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否只显示尊享模型
|
||||
/// </summary>
|
||||
public bool? IsPremiumOnly { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
|
||||
|
||||
/// <summary>
|
||||
/// 模型类型选项
|
||||
/// </summary>
|
||||
public class ModelTypeOption
|
||||
{
|
||||
/// <summary>
|
||||
/// 显示名称
|
||||
/// </summary>
|
||||
public string Label { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 枚举值
|
||||
/// </summary>
|
||||
public int Value { get; set; }
|
||||
}
|
||||
@@ -62,4 +62,9 @@ public class ModelGetListOutput
|
||||
/// 备注信息
|
||||
/// </summary>
|
||||
public string? Remark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否为尊享包
|
||||
/// </summary>
|
||||
public bool IsPremiumPackage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Token;
|
||||
|
||||
/// <summary>
|
||||
/// 创建Token输入
|
||||
/// </summary>
|
||||
public class TokenCreateInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 名称(同一用户不能重复)
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "名称不能为空")]
|
||||
[StringLength(100, ErrorMessage = "名称长度不能超过100个字符")]
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期时间(空为永不过期)
|
||||
/// </summary>
|
||||
public DateTime? ExpireTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 尊享包额度限制(空为不限制)
|
||||
/// </summary>
|
||||
public long? PremiumQuotaLimit { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Token;
|
||||
|
||||
/// <summary>
|
||||
/// Token列表输出
|
||||
/// </summary>
|
||||
public class TokenGetListOutputDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Token Id
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Token密钥
|
||||
/// </summary>
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期时间(空为永不过期)
|
||||
/// </summary>
|
||||
public DateTime? ExpireTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 尊享包额度限制(空为不限制)
|
||||
/// </summary>
|
||||
public long? PremiumQuotaLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 尊享包已使用额度
|
||||
/// </summary>
|
||||
public long PremiumUsedQuota { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否禁用
|
||||
/// </summary>
|
||||
public bool IsDisabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
public DateTime CreationTime { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Token;
|
||||
|
||||
public class TokenSelectListOutputDto
|
||||
{
|
||||
public Guid TokenId { get; set; }
|
||||
public string Name { get; set; }
|
||||
|
||||
public bool IsDisabled { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Token;
|
||||
|
||||
/// <summary>
|
||||
/// 编辑Token输入
|
||||
/// </summary>
|
||||
public class TokenUpdateInput
|
||||
{
|
||||
/// <summary>
|
||||
/// Token Id
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "Id不能为空")]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称(同一用户不能重复)
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "名称不能为空")]
|
||||
[StringLength(100, ErrorMessage = "名称长度不能超过100个字符")]
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期时间(空为永不过期)
|
||||
/// </summary>
|
||||
public DateTime? ExpireTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 尊享包额度限制(空为不限制)
|
||||
/// </summary>
|
||||
public long? PremiumQuotaLimit { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.UsageStatistics;
|
||||
|
||||
/// <summary>
|
||||
/// 尊享包不同Token用量占比DTO(饼图)
|
||||
/// </summary>
|
||||
public class TokenPremiumUsageDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Token Id
|
||||
/// </summary>
|
||||
public Guid TokenId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Token名称
|
||||
/// </summary>
|
||||
public string TokenName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Token消耗量
|
||||
/// </summary>
|
||||
public long Tokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(百分比)
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.UsageStatistics;
|
||||
|
||||
public class UsageStatisticsGetInput
|
||||
{
|
||||
/// <summary>
|
||||
/// tokenId
|
||||
/// </summary>
|
||||
public Guid? TokenId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Volo.Abp.Application.Dtos;
|
||||
using Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
|
||||
|
||||
namespace Yi.Framework.AiHub.Application.Contracts.IServices;
|
||||
|
||||
/// <summary>
|
||||
/// 模型服务接口
|
||||
/// </summary>
|
||||
public interface IModelService
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取模型库列表(公开接口,无需登录)
|
||||
/// </summary>
|
||||
/// <param name="input">查询参数</param>
|
||||
/// <returns>分页模型列表</returns>
|
||||
Task<PagedResultDto<ModelLibraryDto>> GetListAsync(ModelLibraryGetListInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 获取供应商列表(公开接口,无需登录)
|
||||
/// </summary>
|
||||
/// <returns>供应商列表</returns>
|
||||
Task<List<string>> GetProviderListAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 获取模型类型选项列表(公开接口,无需登录)
|
||||
/// </summary>
|
||||
/// <returns>模型类型选项</returns>
|
||||
Task<List<ModelTypeOption>> GetModelTypeOptionsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 获取API类型选项列表(公开接口,无需登录)
|
||||
/// </summary>
|
||||
/// <returns>API类型选项</returns>
|
||||
Task<List<ModelApiTypeOption>> GetApiTypeOptionsAsync();
|
||||
}
|
||||
@@ -11,13 +11,13 @@ public interface IUsageStatisticsService
|
||||
/// 获取当前用户近7天的Token消耗统计
|
||||
/// </summary>
|
||||
/// <returns>每日Token使用量列表</returns>
|
||||
Task<List<DailyTokenUsageDto>> GetLast7DaysTokenUsageAsync();
|
||||
Task<List<DailyTokenUsageDto>> GetLast7DaysTokenUsageAsync(UsageStatisticsGetInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前用户各个模型的Token消耗量及占比
|
||||
/// </summary>
|
||||
/// <returns>模型Token使用量列表</returns>
|
||||
Task<List<ModelTokenUsageDto>> GetModelTokenUsageAsync();
|
||||
Task<List<ModelTokenUsageDto>> GetModelTokenUsageAsync(UsageStatisticsGetInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前用户尊享服务Token用量统计
|
||||
|
||||
@@ -73,6 +73,7 @@ public class AiChatService : ApplicationService
|
||||
{
|
||||
var output = await _aiModelRepository._DbQueryable
|
||||
.Where(x => x.ModelType == ModelTypeEnum.Chat)
|
||||
.Where(x => x.ModelApiType == ModelApiTypeEnum.OpenAi)
|
||||
.OrderByDescending(x => x.OrderNum)
|
||||
.Select(x => new ModelGetListOutput
|
||||
{
|
||||
@@ -87,7 +88,8 @@ public class AiChatService : ApplicationService
|
||||
SystemPrompt = null,
|
||||
ApiHost = null,
|
||||
ApiKey = null,
|
||||
Remark = x.Description
|
||||
Remark = x.Description,
|
||||
IsPremiumPackage = PremiumPackageConst.ModeIds.Contains(x.ModelId)
|
||||
}).ToListAsync();
|
||||
return output;
|
||||
}
|
||||
@@ -134,7 +136,7 @@ public class AiChatService : ApplicationService
|
||||
|
||||
//ai网关代理httpcontext
|
||||
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
|
||||
CurrentUser.Id, sessionId, cancellationToken);
|
||||
CurrentUser.Id, sessionId, null, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +173,6 @@ public class AiChatService : ApplicationService
|
||||
|
||||
//ai网关代理httpcontext
|
||||
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
|
||||
CurrentUser.Id, null, cancellationToken);
|
||||
CurrentUser.Id, null, null, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Mapster;
|
||||
using SqlSugar;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
using Volo.Abp.Application.Services;
|
||||
using Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
|
||||
using Yi.Framework.AiHub.Application.Contracts.IServices;
|
||||
using Yi.Framework.AiHub.Domain.Entities.Model;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Consts;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Enums;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Extensions;
|
||||
using Yi.Framework.SqlSugarCore.Abstractions;
|
||||
|
||||
namespace Yi.Framework.AiHub.Application.Services.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// 模型服务
|
||||
/// </summary>
|
||||
public class ModelService : ApplicationService, IModelService
|
||||
{
|
||||
private readonly ISqlSugarRepository<AiModelEntity, Guid> _modelRepository;
|
||||
|
||||
public ModelService(ISqlSugarRepository<AiModelEntity, Guid> modelRepository)
|
||||
{
|
||||
_modelRepository = modelRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取模型库列表(公开接口,无需登录)
|
||||
/// </summary>
|
||||
public async Task<PagedResultDto<ModelLibraryDto>> GetListAsync(ModelLibraryGetListInput input)
|
||||
{
|
||||
RefAsync<int> total = 0;
|
||||
|
||||
// 查询所有未删除的模型,使用WhereIF动态添加筛选条件
|
||||
var modelIds = (await _modelRepository._DbQueryable
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.SearchKey), x =>
|
||||
x.Name.Contains(input.SearchKey) || x.ModelId.Contains(input.SearchKey))
|
||||
.WhereIF(input.ProviderNames is not null, x =>
|
||||
input.ProviderNames.Contains(x.ProviderName))
|
||||
.WhereIF(input.ModelTypes is not null, x =>
|
||||
input.ModelTypes.Contains(x.ModelType))
|
||||
.WhereIF(input.ModelApiTypes is not null, x =>
|
||||
input.ModelApiTypes.Contains(x.ModelApiType))
|
||||
.WhereIF(input.IsPremiumOnly == true, x =>
|
||||
PremiumPackageConst.ModeIds.Contains(x.ModelId))
|
||||
.GroupBy(x => x.ModelId)
|
||||
.Select(x => x.ModelId)
|
||||
.ToPageListAsync(input.SkipCount, input.MaxResultCount, total));
|
||||
|
||||
var entities = await _modelRepository._DbQueryable.Where(x => modelIds.Contains(x.ModelId))
|
||||
.OrderBy(x => x.OrderNum)
|
||||
.OrderBy(x => x.Name).ToListAsync();
|
||||
|
||||
var output= entities.GroupBy(x => x.ModelId).Select(x => new ModelLibraryDto
|
||||
{
|
||||
ModelId = x.First().ModelId,
|
||||
Name = x.First().Name,
|
||||
Description = x.First().Description,
|
||||
ModelType = x.First().ModelType,
|
||||
ModelApiTypes = x.Select(y => new ModelApiTypeOutput { ModelApiType = y.ModelApiType }).ToList(),
|
||||
MultiplierShow = x.First().MultiplierShow,
|
||||
ProviderName = x.First().ProviderName,
|
||||
IconUrl = x.First().IconUrl,
|
||||
IsPremium = PremiumPackageConst.ModeIds.Contains(x.First().ModelId),
|
||||
OrderNum = x.First().OrderNum
|
||||
}).ToList();
|
||||
|
||||
return new PagedResultDto<ModelLibraryDto>(total, output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取供应商列表(公开接口,无需登录)
|
||||
/// </summary>
|
||||
public async Task<List<string>> GetProviderListAsync()
|
||||
{
|
||||
var providers = await _modelRepository._DbQueryable
|
||||
.Where(x => !x.IsDeleted)
|
||||
.Where(x => !string.IsNullOrEmpty(x.ProviderName))
|
||||
.GroupBy(x => x.ProviderName)
|
||||
.OrderBy(x => x.ProviderName)
|
||||
.Select(x => x.ProviderName)
|
||||
.ToListAsync();
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取模型类型选项列表(公开接口,无需登录)
|
||||
/// </summary>
|
||||
public Task<List<ModelTypeOption>> GetModelTypeOptionsAsync()
|
||||
{
|
||||
var options = Enum.GetValues<ModelTypeEnum>()
|
||||
.Select(e => new ModelTypeOption
|
||||
{
|
||||
Label = e.GetDescription(),
|
||||
Value = (int)e
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取API类型选项列表(公开接口,无需登录)
|
||||
/// </summary>
|
||||
public Task<List<ModelApiTypeOption>> GetApiTypeOptionsAsync()
|
||||
{
|
||||
var options = Enum.GetValues<ModelApiTypeEnum>()
|
||||
.Select(e => new ModelApiTypeOption
|
||||
{
|
||||
Label = e.GetDescription(),
|
||||
Value = (int)e
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(options);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,245 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Dm.util;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SqlSugar;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
using Volo.Abp.Application.Services;
|
||||
using Volo.Abp.Users;
|
||||
using Yi.Framework.AiHub.Application.Contracts.Dtos.Token;
|
||||
using Yi.Framework.AiHub.Domain.Entities;
|
||||
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
|
||||
using Yi.Framework.AiHub.Domain.Extensions;
|
||||
using Yi.Framework.AiHub.Domain.Managers;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Consts;
|
||||
using Yi.Framework.Ddd.Application.Contracts;
|
||||
using Yi.Framework.SqlSugarCore.Abstractions;
|
||||
|
||||
namespace Yi.Framework.AiHub.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Token服务
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class TokenService : ApplicationService
|
||||
{
|
||||
private readonly ISqlSugarRepository<TokenAggregateRoot> _tokenRepository;
|
||||
private readonly TokenManager _tokenManager;
|
||||
private readonly ISqlSugarRepository<UsageStatisticsAggregateRoot> _usageStatisticsRepository;
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
/// <param name="tokenRepository"></param>
|
||||
/// <param name="tokenManager"></param>
|
||||
public TokenService(ISqlSugarRepository<TokenAggregateRoot> tokenRepository, TokenManager tokenManager)
|
||||
public TokenService(
|
||||
ISqlSugarRepository<TokenAggregateRoot> tokenRepository,
|
||||
ISqlSugarRepository<UsageStatisticsAggregateRoot> usageStatisticsRepository)
|
||||
{
|
||||
_tokenRepository = tokenRepository;
|
||||
_tokenManager = tokenManager;
|
||||
_usageStatisticsRepository = usageStatisticsRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取token
|
||||
/// 获取当前用户的Token列表
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[Authorize]
|
||||
public async Task<TokenOutput> GetAsync()
|
||||
[HttpGet("token/list")]
|
||||
public async Task<PagedResultDto<TokenGetListOutputDto>> GetListAsync([FromQuery] PagedAllResultRequestDto input)
|
||||
{
|
||||
return new TokenOutput
|
||||
RefAsync<int> total = 0;
|
||||
var userId = CurrentUser.GetId();
|
||||
|
||||
var tokens = await _tokenRepository._DbQueryable
|
||||
.Where(x => x.UserId == userId)
|
||||
.OrderByDescending(x => x.CreationTime)
|
||||
.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
|
||||
|
||||
if (!tokens.Any())
|
||||
{
|
||||
ApiKey = await _tokenManager.GetAsync(CurrentUser.GetId())
|
||||
};
|
||||
return new PagedResultDto<TokenGetListOutputDto>();
|
||||
}
|
||||
|
||||
// 获取尊享包模型ID列表
|
||||
var premiumModelIds = PremiumPackageConst.ModeIds;
|
||||
|
||||
// 批量查询所有Token的尊享包已使用额度
|
||||
var tokenIds = tokens.Select(t => t.Id).ToList();
|
||||
var usageStats = await _usageStatisticsRepository._DbQueryable
|
||||
.Where(x => x.UserId == userId && tokenIds.Contains(x.TokenId) && premiumModelIds.Contains(x.ModelId))
|
||||
.GroupBy(x => x.TokenId)
|
||||
.Select(g => new
|
||||
{
|
||||
TokenId = g.TokenId,
|
||||
UsedQuota = SqlFunc.AggregateSum(g.TotalTokenCount)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var result = tokens.Select(t =>
|
||||
{
|
||||
var usedQuota = usageStats.FirstOrDefault(u => u.TokenId == t.Id)?.UsedQuota ?? 0;
|
||||
return new TokenGetListOutputDto
|
||||
{
|
||||
Id = t.Id,
|
||||
Name = t.Name,
|
||||
ApiKey = t.Token,
|
||||
ExpireTime = t.ExpireTime,
|
||||
PremiumQuotaLimit = t.PremiumQuotaLimit,
|
||||
PremiumUsedQuota = usedQuota,
|
||||
IsDisabled = t.IsDisabled,
|
||||
CreationTime = t.CreationTime
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return new PagedResultDto<TokenGetListOutputDto>(total, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建token
|
||||
/// </summary>
|
||||
/// <exception cref="UserFriendlyException"></exception>
|
||||
[Authorize]
|
||||
public async Task CreateAsync()
|
||||
[HttpGet("token/select-list")]
|
||||
public async Task<List<TokenSelectListOutputDto>> GetSelectListAsync()
|
||||
{
|
||||
var userId = CurrentUser.GetId();
|
||||
var tokens = await _tokenRepository._DbQueryable
|
||||
.Where(x => x.UserId == userId)
|
||||
.OrderBy(x => x.IsDisabled)
|
||||
.OrderByDescending(x => x.CreationTime)
|
||||
.Select(x => new TokenSelectListOutputDto
|
||||
{
|
||||
TokenId = x.Id,
|
||||
Name = x.Name,
|
||||
IsDisabled = x.IsDisabled
|
||||
}).ToListAsync();
|
||||
|
||||
tokens.Insert(0,new TokenSelectListOutputDto
|
||||
{
|
||||
TokenId = Guid.Empty,
|
||||
Name = "默认",
|
||||
IsDisabled = false
|
||||
});
|
||||
return tokens;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 创建Token
|
||||
/// </summary>
|
||||
[HttpPost("token")]
|
||||
public async Task<TokenGetListOutputDto> CreateAsync([FromBody] TokenCreateInput input)
|
||||
{
|
||||
var userId = CurrentUser.GetId();
|
||||
|
||||
// 检查用户是否为VIP
|
||||
if (!CurrentUser.IsAiVip())
|
||||
{
|
||||
throw new UserFriendlyException("充值成为Vip,畅享第三方token服务");
|
||||
}
|
||||
|
||||
await _tokenManager.CreateAsync(CurrentUser.GetId());
|
||||
// 检查名称是否重复
|
||||
var exists = await _tokenRepository._DbQueryable
|
||||
.AnyAsync(x => x.UserId == userId && x.Name == input.Name);
|
||||
if (exists)
|
||||
{
|
||||
throw new UserFriendlyException($"名称【{input.Name}】已存在,请使用其他名称");
|
||||
}
|
||||
|
||||
var token = new TokenAggregateRoot(userId, input.Name)
|
||||
{
|
||||
ExpireTime = input.ExpireTime,
|
||||
PremiumQuotaLimit = input.PremiumQuotaLimit
|
||||
};
|
||||
|
||||
await _tokenRepository.InsertAsync(token);
|
||||
|
||||
return new TokenGetListOutputDto
|
||||
{
|
||||
Id = token.Id,
|
||||
Name = token.Name,
|
||||
ApiKey = token.Token,
|
||||
ExpireTime = token.ExpireTime,
|
||||
PremiumQuotaLimit = token.PremiumQuotaLimit,
|
||||
PremiumUsedQuota = 0,
|
||||
IsDisabled = token.IsDisabled,
|
||||
CreationTime = token.CreationTime
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 编辑Token
|
||||
/// </summary>
|
||||
[HttpPut("token")]
|
||||
public async Task UpdateAsync([FromBody] TokenUpdateInput input)
|
||||
{
|
||||
var userId = CurrentUser.GetId();
|
||||
|
||||
var token = await _tokenRepository._DbQueryable
|
||||
.FirstAsync(x => x.Id == input.Id && x.UserId == userId);
|
||||
|
||||
if (token is null)
|
||||
{
|
||||
throw new UserFriendlyException("Token不存在或无权限操作");
|
||||
}
|
||||
|
||||
// 检查名称是否重复(排除自己)
|
||||
var exists = await _tokenRepository._DbQueryable
|
||||
.AnyAsync(x => x.UserId == userId && x.Name == input.Name && x.Id != input.Id);
|
||||
if (exists)
|
||||
{
|
||||
throw new UserFriendlyException($"名称【{input.Name}】已存在,请使用其他名称");
|
||||
}
|
||||
|
||||
token.Name = input.Name;
|
||||
token.ExpireTime = input.ExpireTime;
|
||||
token.PremiumQuotaLimit = input.PremiumQuotaLimit;
|
||||
|
||||
await _tokenRepository.UpdateAsync(token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除Token
|
||||
/// </summary>
|
||||
[HttpDelete("token/{id}")]
|
||||
public async Task DeleteAsync(Guid id)
|
||||
{
|
||||
var userId = CurrentUser.GetId();
|
||||
|
||||
var token = await _tokenRepository._DbQueryable
|
||||
.FirstAsync(x => x.Id == id && x.UserId == userId);
|
||||
|
||||
if (token is null)
|
||||
{
|
||||
throw new UserFriendlyException("Token不存在或无权限操作");
|
||||
}
|
||||
|
||||
await _tokenRepository.DeleteAsync(token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启用Token
|
||||
/// </summary>
|
||||
[HttpPost("token/{id}/enable")]
|
||||
public async Task EnableAsync(Guid id)
|
||||
{
|
||||
var userId = CurrentUser.GetId();
|
||||
|
||||
var token = await _tokenRepository._DbQueryable
|
||||
.FirstAsync(x => x.Id == id && x.UserId == userId);
|
||||
|
||||
if (token is null)
|
||||
{
|
||||
throw new UserFriendlyException("Token不存在或无权限操作");
|
||||
}
|
||||
|
||||
token.Enable();
|
||||
await _tokenRepository.UpdateAsync(token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 禁用Token
|
||||
/// </summary>
|
||||
[HttpPost("token/{id}/disable")]
|
||||
public async Task DisableAsync(Guid id)
|
||||
{
|
||||
var userId = CurrentUser.GetId();
|
||||
|
||||
var token = await _tokenRepository._DbQueryable
|
||||
.FirstAsync(x => x.Id == id && x.UserId == userId);
|
||||
|
||||
if (token is null)
|
||||
{
|
||||
throw new UserFriendlyException("Token不存在或无权限操作");
|
||||
}
|
||||
|
||||
token.Disable();
|
||||
await _tokenRepository.UpdateAsync(token);
|
||||
}
|
||||
}
|
||||
@@ -76,12 +76,12 @@ public class FileMasterService : ApplicationService
|
||||
if (input.Stream == true)
|
||||
{
|
||||
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
|
||||
userId, null, cancellationToken);
|
||||
userId, null, null, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _aiGateWayManager.CompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input, userId,
|
||||
null,
|
||||
null, null,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Embeddings;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Images;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Enums;
|
||||
using Yi.Framework.Rbac.Application.Contracts.IServices;
|
||||
using Yi.Framework.SqlSugarCore.Abstractions;
|
||||
@@ -55,7 +56,9 @@ public class OpenApiService : ApplicationService
|
||||
{
|
||||
//前面都是校验,后面才是真正的调用
|
||||
var httpContext = this._httpContextAccessor.HttpContext;
|
||||
var userId = await _tokenManager.GetUserIdAsync(GetTokenByHttpContext(httpContext));
|
||||
var tokenValidation = await _tokenManager.ValidateTokenAsync(GetTokenByHttpContext(httpContext), input.Model);
|
||||
var userId = tokenValidation.UserId;
|
||||
var tokenId = tokenValidation.TokenId;
|
||||
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
|
||||
|
||||
//如果是尊享包服务,需要校验是是否尊享包足够
|
||||
@@ -68,21 +71,22 @@ public class OpenApiService : ApplicationService
|
||||
throw new UserFriendlyException("尊享token包用量不足,请先购买尊享token包");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//ai网关代理httpcontext
|
||||
if (input.Stream == true)
|
||||
{
|
||||
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
|
||||
userId, null, cancellationToken);
|
||||
userId, null, tokenId, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _aiGateWayManager.CompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input, userId,
|
||||
null,
|
||||
null, tokenId,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 图片生成
|
||||
/// </summary>
|
||||
@@ -93,11 +97,14 @@ public class OpenApiService : ApplicationService
|
||||
{
|
||||
var httpContext = this._httpContextAccessor.HttpContext;
|
||||
Intercept(httpContext);
|
||||
var userId = await _tokenManager.GetUserIdAsync(GetTokenByHttpContext(httpContext));
|
||||
var tokenValidation = await _tokenManager.ValidateTokenAsync(GetTokenByHttpContext(httpContext), input.Model);
|
||||
var userId = tokenValidation.UserId;
|
||||
var tokenId = tokenValidation.TokenId;
|
||||
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
|
||||
await _aiGateWayManager.CreateImageForStatisticsAsync(httpContext, userId, null, input);
|
||||
await _aiGateWayManager.CreateImageForStatisticsAsync(httpContext, userId, null, input, tokenId);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 向量生成
|
||||
/// </summary>
|
||||
@@ -108,9 +115,11 @@ public class OpenApiService : ApplicationService
|
||||
{
|
||||
var httpContext = this._httpContextAccessor.HttpContext;
|
||||
Intercept(httpContext);
|
||||
var userId = await _tokenManager.GetUserIdAsync(GetTokenByHttpContext(httpContext));
|
||||
var tokenValidation = await _tokenManager.ValidateTokenAsync(GetTokenByHttpContext(httpContext), input.Model);
|
||||
var userId = tokenValidation.UserId;
|
||||
var tokenId = tokenValidation.TokenId;
|
||||
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
|
||||
await _aiGateWayManager.EmbeddingForStatisticsAsync(httpContext, userId, null, input);
|
||||
await _aiGateWayManager.EmbeddingForStatisticsAsync(httpContext, userId, null, input, tokenId);
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +148,7 @@ public class OpenApiService : ApplicationService
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Anthropic对话(尊享服务专用)
|
||||
/// </summary>
|
||||
@@ -151,7 +160,9 @@ public class OpenApiService : ApplicationService
|
||||
{
|
||||
//前面都是校验,后面才是真正的调用
|
||||
var httpContext = this._httpContextAccessor.HttpContext;
|
||||
var userId = await _tokenManager.GetUserIdAsync(GetTokenByHttpContext(httpContext));
|
||||
var tokenValidation = await _tokenManager.ValidateTokenAsync(GetTokenByHttpContext(httpContext), input.Model);
|
||||
var userId = tokenValidation.UserId;
|
||||
var tokenId = tokenValidation.TokenId;
|
||||
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
|
||||
|
||||
// 验证用户是否为VIP
|
||||
@@ -177,18 +188,72 @@ public class OpenApiService : ApplicationService
|
||||
//ai网关代理httpcontext
|
||||
if (input.Stream)
|
||||
{
|
||||
await _aiGateWayManager.AnthropicCompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
|
||||
userId, null, cancellationToken);
|
||||
await _aiGateWayManager.AnthropicCompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext,
|
||||
input,
|
||||
userId, null, tokenId, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _aiGateWayManager.AnthropicCompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input, userId,
|
||||
null,
|
||||
await _aiGateWayManager.AnthropicCompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input,
|
||||
userId,
|
||||
null, tokenId,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 响应-Openai新规范 (尊享服务专用)
|
||||
/// </summary>
|
||||
/// <param name="input"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
[HttpPost("openApi/v1/responses")]
|
||||
public async Task ResponsesAsync([FromBody] OpenAiResponsesInput input, CancellationToken cancellationToken)
|
||||
{
|
||||
//前面都是校验,后面才是真正的调用
|
||||
var httpContext = this._httpContextAccessor.HttpContext;
|
||||
var tokenValidation = await _tokenManager.ValidateTokenAsync(GetTokenByHttpContext(httpContext), input.Model);
|
||||
var userId = tokenValidation.UserId;
|
||||
var tokenId = tokenValidation.TokenId;
|
||||
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
|
||||
|
||||
// 验证用户是否为VIP
|
||||
var userInfo = await _accountService.GetAsync(null, null, userId);
|
||||
if (userInfo == null)
|
||||
{
|
||||
throw new UserFriendlyException("用户信息不存在");
|
||||
}
|
||||
|
||||
// 检查是否为VIP(使用RoleCodes判断)
|
||||
if (!userInfo.RoleCodes.Contains(AiHubConst.VipRole) && userInfo.User.UserName != "cc")
|
||||
{
|
||||
throw new UserFriendlyException("该接口为尊享服务专用,需要VIP权限才能使用");
|
||||
}
|
||||
|
||||
// 检查尊享token包用量
|
||||
var availableTokens = await _premiumPackageManager.GetAvailableTokensAsync(userId);
|
||||
if (availableTokens <= 0)
|
||||
{
|
||||
throw new UserFriendlyException("尊享token包用量不足,请先购买尊享token包");
|
||||
}
|
||||
|
||||
//ai网关代理httpcontext
|
||||
if (input.Stream == true)
|
||||
{
|
||||
await _aiGateWayManager.OpenAiResponsesStreamForStatisticsAsync(_httpContextAccessor.HttpContext,
|
||||
input,
|
||||
userId, null, tokenId, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _aiGateWayManager.OpenAiResponsesAsyncForStatisticsAsync(_httpContextAccessor.HttpContext, input,
|
||||
userId,
|
||||
null, tokenId,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#region 私有
|
||||
|
||||
private string? GetTokenByHttpContext(HttpContext httpContext)
|
||||
@@ -202,7 +267,8 @@ public class OpenApiService : ApplicationService
|
||||
|
||||
// 再检查 Authorization 头
|
||||
string authHeader = httpContext.Request.Headers["Authorization"];
|
||||
if (!string.IsNullOrWhiteSpace(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
if (!string.IsNullOrWhiteSpace(authHeader) &&
|
||||
authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return authHeader.Substring("Bearer ".Length).Trim();
|
||||
}
|
||||
@@ -219,5 +285,4 @@ public class OpenApiService : ApplicationService
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
@@ -9,7 +9,9 @@ using Yi.Framework.AiHub.Application.Contracts.Dtos.UsageStatistics;
|
||||
using Yi.Framework.AiHub.Application.Contracts.IServices;
|
||||
using Yi.Framework.AiHub.Domain.Entities;
|
||||
using Yi.Framework.AiHub.Domain.Entities.Chat;
|
||||
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
|
||||
using Yi.Framework.AiHub.Domain.Extensions;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Consts;
|
||||
using Yi.Framework.Ddd.Application.Contracts;
|
||||
using Yi.Framework.SqlSugarCore.Abstractions;
|
||||
|
||||
@@ -24,22 +26,25 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic
|
||||
private readonly ISqlSugarRepository<MessageAggregateRoot> _messageRepository;
|
||||
private readonly ISqlSugarRepository<UsageStatisticsAggregateRoot> _usageStatisticsRepository;
|
||||
private readonly ISqlSugarRepository<PremiumPackageAggregateRoot> _premiumPackageRepository;
|
||||
private readonly ISqlSugarRepository<TokenAggregateRoot> _tokenRepository;
|
||||
|
||||
public UsageStatisticsService(
|
||||
ISqlSugarRepository<MessageAggregateRoot> messageRepository,
|
||||
ISqlSugarRepository<UsageStatisticsAggregateRoot> usageStatisticsRepository,
|
||||
ISqlSugarRepository<PremiumPackageAggregateRoot> premiumPackageRepository)
|
||||
ISqlSugarRepository<PremiumPackageAggregateRoot> premiumPackageRepository,
|
||||
ISqlSugarRepository<TokenAggregateRoot> tokenRepository)
|
||||
{
|
||||
_messageRepository = messageRepository;
|
||||
_usageStatisticsRepository = usageStatisticsRepository;
|
||||
_premiumPackageRepository = premiumPackageRepository;
|
||||
_tokenRepository = tokenRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前用户近7天的Token消耗统计
|
||||
/// </summary>
|
||||
/// <returns>每日Token使用量列表</returns>
|
||||
public async Task<List<DailyTokenUsageDto>> GetLast7DaysTokenUsageAsync()
|
||||
public async Task<List<DailyTokenUsageDto>> GetLast7DaysTokenUsageAsync([FromQuery]UsageStatisticsGetInput input)
|
||||
{
|
||||
var userId = CurrentUser.GetId();
|
||||
var endDate = DateTime.Today;
|
||||
@@ -50,6 +55,7 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic
|
||||
.Where(x => x.UserId == userId)
|
||||
.Where(x => x.Role == "assistant" || x.Role == "system")
|
||||
.Where(x => x.CreationTime >= startDate && x.CreationTime < endDate.AddDays(1))
|
||||
.WhereIF(input.TokenId.HasValue,x => x.TokenId == input.TokenId)
|
||||
.GroupBy(x => x.CreationTime.Date)
|
||||
.Select(g => new
|
||||
{
|
||||
@@ -79,17 +85,19 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic
|
||||
/// 获取当前用户各个模型的Token消耗量及占比
|
||||
/// </summary>
|
||||
/// <returns>模型Token使用量列表</returns>
|
||||
public async Task<List<ModelTokenUsageDto>> GetModelTokenUsageAsync()
|
||||
public async Task<List<ModelTokenUsageDto>> GetModelTokenUsageAsync([FromQuery]UsageStatisticsGetInput input)
|
||||
{
|
||||
var userId = CurrentUser.GetId();
|
||||
|
||||
// 从UsageStatistics表获取各模型的token消耗统计
|
||||
// 从UsageStatistics表获取各模型的token消耗统计(按ModelId聚合,因为同一模型可能有多个TokenId的记录)
|
||||
var modelUsages = await _usageStatisticsRepository._DbQueryable
|
||||
.Where(x => x.UserId == userId)
|
||||
.WhereIF(input.TokenId.HasValue,x => x.TokenId == input.TokenId)
|
||||
.GroupBy(x => x.ModelId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.ModelId,
|
||||
x.TotalTokenCount
|
||||
ModelId = x.ModelId,
|
||||
TotalTokenCount = SqlFunc.AggregateSum(x.TotalTokenCount)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
@@ -164,4 +172,54 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic
|
||||
return new PagedResultDto<PremiumTokenUsageGetListOutput>(total,
|
||||
entities.Adapt<List<PremiumTokenUsageGetListOutput>>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前用户尊享包不同Token用量占比(饼图)
|
||||
/// </summary>
|
||||
/// <returns>各Token的尊享模型用量及占比</returns>
|
||||
[HttpGet("usage-statistics/premium-token-usage/by-token")]
|
||||
public async Task<List<TokenPremiumUsageDto>> GetPremiumTokenUsageByTokenAsync()
|
||||
{
|
||||
var userId = CurrentUser.GetId();
|
||||
var premiumModelIds = PremiumPackageConst.ModeIds;
|
||||
|
||||
// 从UsageStatistics表获取尊享模型的token消耗统计(按TokenId聚合)
|
||||
var tokenUsages = await _usageStatisticsRepository._DbQueryable
|
||||
.Where(x => x.UserId == userId && premiumModelIds.Contains(x.ModelId))
|
||||
.GroupBy(x => x.TokenId)
|
||||
.Select(x => new
|
||||
{
|
||||
TokenId = x.TokenId,
|
||||
TotalTokenCount = SqlFunc.AggregateSum(x.TotalTokenCount)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
if (!tokenUsages.Any())
|
||||
{
|
||||
return new List<TokenPremiumUsageDto>();
|
||||
}
|
||||
|
||||
// 获取用户的所有Token信息用于名称映射
|
||||
var tokenIds = tokenUsages.Select(x => x.TokenId).ToList();
|
||||
var tokens = await _tokenRepository._DbQueryable
|
||||
.Where(x => x.UserId == userId && tokenIds.Contains(x.Id))
|
||||
.Select(x => new { x.Id, x.Name })
|
||||
.ToListAsync();
|
||||
|
||||
var tokenNameDict = tokens.ToDictionary(x => x.Id, x => x.Name);
|
||||
|
||||
// 计算总token数
|
||||
var totalTokens = tokenUsages.Sum(x => x.TotalTokenCount);
|
||||
|
||||
// 计算各Token占比
|
||||
var result = tokenUsages.Select(x => new TokenPremiumUsageDto
|
||||
{
|
||||
TokenId = x.TokenId,
|
||||
TokenName = x.TokenId == Guid.Empty ? "默认" : (tokenNameDict.TryGetValue(x.TokenId, out var name) ? name : "其他"),
|
||||
Tokens = x.TotalTokenCount,
|
||||
Percentage = totalTokens > 0 ? Math.Round((decimal)x.TotalTokenCount / totalTokens * 100, 2) : 0
|
||||
}).OrderByDescending(x => x.Tokens).ToList();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,13 @@ namespace Yi.Framework.AiHub.Domain.Shared.Consts;
|
||||
|
||||
public class PremiumPackageConst
|
||||
{
|
||||
public static List<string> ModeIds = ["claude-sonnet-4-5-20250929"];
|
||||
public static List<string> ModeIds =
|
||||
[
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-haiku-4-5-20251001",
|
||||
"claude-opus-4-5-20251101",
|
||||
"gemini-3-pro-preview",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.2"
|
||||
];
|
||||
}
|
||||
@@ -56,4 +56,9 @@ public class AiModelDescribe
|
||||
/// 模型额外信息
|
||||
/// </summary>
|
||||
public string? ModelExtraInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模型倍率
|
||||
/// </summary>
|
||||
public decimal Multiplier { get; set; }
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public class AnthropicStreamDto
|
||||
};
|
||||
|
||||
|
||||
public void SupplementalMultiplier(double multiplier)
|
||||
public void SupplementalMultiplier(decimal multiplier)
|
||||
{
|
||||
if (this.Usage is not null)
|
||||
{
|
||||
@@ -130,7 +130,7 @@ public class AnthropicChatCompletionDto
|
||||
CompletionTokensDetails = null
|
||||
};
|
||||
|
||||
public void SupplementalMultiplier(double multiplier)
|
||||
public void SupplementalMultiplier(decimal multiplier)
|
||||
{
|
||||
if (this.Usage is not null)
|
||||
{
|
||||
|
||||
@@ -1,648 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
|
||||
|
||||
public static class AnthropicToOpenAi
|
||||
{
|
||||
/// <summary>
|
||||
/// 将AnthropicInput转换为ThorChatCompletionsRequest
|
||||
/// </summary>
|
||||
public static ThorChatCompletionsRequest ConvertAnthropicToOpenAi(AnthropicInput anthropicInput)
|
||||
{
|
||||
var openAiRequest = new ThorChatCompletionsRequest
|
||||
{
|
||||
Model = anthropicInput.Model,
|
||||
MaxTokens = anthropicInput.MaxTokens,
|
||||
Stream = anthropicInput.Stream,
|
||||
Messages = new List<ThorChatMessage>(anthropicInput.Messages.Count)
|
||||
};
|
||||
|
||||
// high medium minimal low
|
||||
if (openAiRequest.Model.EndsWith("-high") ||
|
||||
openAiRequest.Model.EndsWith("-medium") ||
|
||||
openAiRequest.Model.EndsWith("-minimal") ||
|
||||
openAiRequest.Model.EndsWith("-low"))
|
||||
{
|
||||
openAiRequest.ReasoningEffort = openAiRequest.Model switch
|
||||
{
|
||||
var model when model.EndsWith("-high") => "high",
|
||||
var model when model.EndsWith("-medium") => "medium",
|
||||
var model when model.EndsWith("-minimal") => "minimal",
|
||||
var model when model.EndsWith("-low") => "low",
|
||||
_ => "medium"
|
||||
};
|
||||
|
||||
openAiRequest.Model = openAiRequest.Model.Replace("-high", "")
|
||||
.Replace("-medium", "")
|
||||
.Replace("-minimal", "")
|
||||
.Replace("-low", "");
|
||||
}
|
||||
|
||||
if (anthropicInput.Thinking != null &&
|
||||
anthropicInput.Thinking.Type.Equals("enabled", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
openAiRequest.Thinking = new ThorChatClaudeThinking()
|
||||
{
|
||||
BudgetToken = anthropicInput.Thinking.BudgetTokens,
|
||||
Type = "enabled",
|
||||
};
|
||||
openAiRequest.EnableThinking = true;
|
||||
}
|
||||
|
||||
if (openAiRequest.Model.EndsWith("-thinking"))
|
||||
{
|
||||
openAiRequest.EnableThinking = true;
|
||||
openAiRequest.Model = openAiRequest.Model.Replace("-thinking", "");
|
||||
}
|
||||
|
||||
if (openAiRequest.Stream == true)
|
||||
{
|
||||
openAiRequest.StreamOptions = new ThorStreamOptions()
|
||||
{
|
||||
IncludeUsage = true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(anthropicInput.System))
|
||||
{
|
||||
openAiRequest.Messages.Add(ThorChatMessage.CreateSystemMessage(anthropicInput.System));
|
||||
}
|
||||
|
||||
if (anthropicInput.Systems?.Count > 0)
|
||||
{
|
||||
foreach (var systemContent in anthropicInput.Systems)
|
||||
{
|
||||
openAiRequest.Messages.Add(ThorChatMessage.CreateSystemMessage(systemContent.Text ?? string.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
// 处理messages
|
||||
if (anthropicInput.Messages != null)
|
||||
{
|
||||
foreach (var message in anthropicInput.Messages)
|
||||
{
|
||||
var thorMessages = ConvertAnthropicMessageToThor(message);
|
||||
// 需要过滤 空消息
|
||||
if (thorMessages.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
openAiRequest.Messages.AddRange(thorMessages);
|
||||
}
|
||||
|
||||
openAiRequest.Messages = openAiRequest.Messages
|
||||
.Where(m => !string.IsNullOrEmpty(m.Content) || m.Contents?.Count > 0 || m.ToolCalls?.Count > 0 ||
|
||||
!string.IsNullOrEmpty(m.ToolCallId))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// 处理tools
|
||||
if (anthropicInput.Tools is { Count: > 0 })
|
||||
{
|
||||
openAiRequest.Tools = anthropicInput.Tools.Where(x => x.name != "web_search")
|
||||
.Select(ConvertAnthropicToolToThor).ToList();
|
||||
}
|
||||
|
||||
// 判断是否存在web_search
|
||||
if (anthropicInput.Tools?.Any(x => x.name == "web_search") == true)
|
||||
{
|
||||
openAiRequest.WebSearchOptions = new ThorChatWebSearchOptions()
|
||||
{
|
||||
};
|
||||
}
|
||||
|
||||
// 处理tool_choice
|
||||
if (anthropicInput.ToolChoice != null)
|
||||
{
|
||||
openAiRequest.ToolChoice = ConvertAnthropicToolChoiceToThor(anthropicInput.ToolChoice);
|
||||
}
|
||||
|
||||
return openAiRequest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据最后的内容块类型和OpenAI的完成原因确定Claude的停止原因
|
||||
/// </summary>
|
||||
public static string GetStopReasonByLastContentType(string? openAiFinishReason, string lastContentBlockType)
|
||||
{
|
||||
// 如果最后一个内容块是工具调用,优先返回tool_use
|
||||
if (lastContentBlockType == "tool_use")
|
||||
{
|
||||
return "tool_use";
|
||||
}
|
||||
|
||||
// 否则使用标准的转换逻辑
|
||||
return GetClaudeStopReason(openAiFinishReason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建message_start事件
|
||||
/// </summary>
|
||||
public static AnthropicStreamDto CreateMessageStartEvent(string messageId, string model)
|
||||
{
|
||||
return new AnthropicStreamDto
|
||||
{
|
||||
Type = "message_start",
|
||||
Message = new AnthropicChatCompletionDto
|
||||
{
|
||||
id = messageId,
|
||||
type = "message",
|
||||
role = "assistant",
|
||||
model = model,
|
||||
content = new AnthropicChatCompletionDtoContent[0],
|
||||
Usage = new AnthropicCompletionDtoUsage
|
||||
{
|
||||
InputTokens = 0,
|
||||
OutputTokens = 0,
|
||||
CacheCreationInputTokens = 0,
|
||||
CacheReadInputTokens = 0
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建content_block_start事件
|
||||
/// </summary>
|
||||
public static AnthropicStreamDto CreateContentBlockStartEvent()
|
||||
{
|
||||
return new AnthropicStreamDto
|
||||
{
|
||||
Type = "content_block_start",
|
||||
Index = 0,
|
||||
ContentBlock = new AnthropicChatCompletionDtoContentBlock
|
||||
{
|
||||
Type = "text",
|
||||
Id = null,
|
||||
Name = null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建thinking block start事件
|
||||
/// </summary>
|
||||
public static AnthropicStreamDto CreateThinkingBlockStartEvent()
|
||||
{
|
||||
return new AnthropicStreamDto
|
||||
{
|
||||
Type = "content_block_start",
|
||||
Index = 0,
|
||||
ContentBlock = new AnthropicChatCompletionDtoContentBlock
|
||||
{
|
||||
Type = "thinking",
|
||||
Id = null,
|
||||
Name = null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建content_block_delta事件
|
||||
/// </summary>
|
||||
public static AnthropicStreamDto CreateContentBlockDeltaEvent(string text)
|
||||
{
|
||||
return new AnthropicStreamDto
|
||||
{
|
||||
Type = "content_block_delta",
|
||||
Index = 0,
|
||||
Delta = new AnthropicChatCompletionDtoDelta
|
||||
{
|
||||
Type = "text_delta",
|
||||
Text = text
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建thinking delta事件
|
||||
/// </summary>
|
||||
public static AnthropicStreamDto CreateThinkingBlockDeltaEvent(string thinking)
|
||||
{
|
||||
return new AnthropicStreamDto
|
||||
{
|
||||
Type = "content_block_delta",
|
||||
Index = 0,
|
||||
Delta = new AnthropicChatCompletionDtoDelta
|
||||
{
|
||||
Type = "thinking",
|
||||
Thinking = thinking
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建content_block_stop事件
|
||||
/// </summary>
|
||||
public static AnthropicStreamDto CreateContentBlockStopEvent()
|
||||
{
|
||||
return new AnthropicStreamDto
|
||||
{
|
||||
Type = "content_block_stop",
|
||||
Index = 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建message_delta事件
|
||||
/// </summary>
|
||||
public static AnthropicStreamDto CreateMessageDeltaEvent(string finishReason, AnthropicCompletionDtoUsage usage)
|
||||
{
|
||||
return new AnthropicStreamDto
|
||||
{
|
||||
Type = "message_delta",
|
||||
Usage = usage,
|
||||
Delta = new AnthropicChatCompletionDtoDelta
|
||||
{
|
||||
StopReason = finishReason
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建message_stop事件
|
||||
/// </summary>
|
||||
public static AnthropicStreamDto CreateMessageStopEvent()
|
||||
{
|
||||
return new AnthropicStreamDto
|
||||
{
|
||||
Type = "message_stop"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建tool block start事件
|
||||
/// </summary>
|
||||
public static AnthropicStreamDto CreateToolBlockStartEvent(string? toolId, string? toolName)
|
||||
{
|
||||
return new AnthropicStreamDto
|
||||
{
|
||||
Type = "content_block_start",
|
||||
Index = 0,
|
||||
ContentBlock = new AnthropicChatCompletionDtoContentBlock
|
||||
{
|
||||
Type = "tool_use",
|
||||
Id = toolId,
|
||||
Name = toolName
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建tool delta事件
|
||||
/// </summary>
|
||||
public static AnthropicStreamDto CreateToolBlockDeltaEvent(string partialJson)
|
||||
{
|
||||
return new AnthropicStreamDto
|
||||
{
|
||||
Type = "content_block_delta",
|
||||
Index = 0,
|
||||
Delta = new AnthropicChatCompletionDtoDelta
|
||||
{
|
||||
Type = "input_json_delta",
|
||||
PartialJson = partialJson
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 转换Anthropic消息为Thor消息列表
|
||||
/// </summary>
|
||||
public static List<ThorChatMessage> ConvertAnthropicMessageToThor(AnthropicMessageInput anthropicMessage)
|
||||
{
|
||||
var results = new List<ThorChatMessage>();
|
||||
|
||||
// 处理简单的字符串内容
|
||||
if (anthropicMessage.Content != null)
|
||||
{
|
||||
var thorMessage = new ThorChatMessage
|
||||
{
|
||||
Role = anthropicMessage.Role,
|
||||
Content = anthropicMessage.Content
|
||||
};
|
||||
results.Add(thorMessage);
|
||||
return results;
|
||||
}
|
||||
|
||||
// 处理多模态内容
|
||||
if (anthropicMessage.Contents is { Count: > 0 })
|
||||
{
|
||||
var currentContents = new List<ThorChatMessageContent>();
|
||||
var currentToolCalls = new List<ThorToolCall>();
|
||||
|
||||
foreach (var content in anthropicMessage.Contents)
|
||||
{
|
||||
switch (content.Type)
|
||||
{
|
||||
case "text":
|
||||
currentContents.Add(ThorChatMessageContent.CreateTextContent(content.Text ?? string.Empty));
|
||||
break;
|
||||
case "thinking" when !string.IsNullOrEmpty(content.Thinking):
|
||||
results.Add(new ThorChatMessage()
|
||||
{
|
||||
ReasoningContent = content.Thinking
|
||||
});
|
||||
break;
|
||||
case "image":
|
||||
{
|
||||
if (content.Source != null)
|
||||
{
|
||||
var imageUrl = content.Source.Type == "base64"
|
||||
? $"data:{content.Source.MediaType};base64,{content.Source.Data}"
|
||||
: content.Source.Data;
|
||||
currentContents.Add(ThorChatMessageContent.CreateImageUrlContent(imageUrl ?? string.Empty));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "tool_use":
|
||||
{
|
||||
// 如果有普通内容,先创建内容消息
|
||||
if (currentContents.Count > 0)
|
||||
{
|
||||
if (currentContents.Count == 1 && currentContents.Any(x => x.Type == "text"))
|
||||
{
|
||||
var contentMessage = new ThorChatMessage
|
||||
{
|
||||
Role = anthropicMessage.Role,
|
||||
ContentCalculated = currentContents.FirstOrDefault()?.Text ?? string.Empty
|
||||
};
|
||||
results.Add(contentMessage);
|
||||
}
|
||||
else
|
||||
{
|
||||
var contentMessage = new ThorChatMessage
|
||||
{
|
||||
Role = anthropicMessage.Role,
|
||||
Contents = currentContents
|
||||
};
|
||||
results.Add(contentMessage);
|
||||
}
|
||||
|
||||
currentContents = new List<ThorChatMessageContent>();
|
||||
}
|
||||
|
||||
// 收集工具调用
|
||||
var toolCall = new ThorToolCall
|
||||
{
|
||||
Id = content.Id,
|
||||
Type = "function",
|
||||
Function = new ThorChatMessageFunction
|
||||
{
|
||||
Name = content.Name,
|
||||
Arguments = JsonSerializer.Serialize(content.Input)
|
||||
}
|
||||
};
|
||||
currentToolCalls.Add(toolCall);
|
||||
break;
|
||||
}
|
||||
case "tool_result":
|
||||
{
|
||||
// 如果有普通内容,先创建内容消息
|
||||
if (currentContents.Count > 0)
|
||||
{
|
||||
var contentMessage = new ThorChatMessage
|
||||
{
|
||||
Role = anthropicMessage.Role,
|
||||
Contents = currentContents
|
||||
};
|
||||
results.Add(contentMessage);
|
||||
currentContents = [];
|
||||
}
|
||||
|
||||
// 如果有工具调用,先创建工具调用消息
|
||||
if (currentToolCalls.Count > 0)
|
||||
{
|
||||
var toolCallMessage = new ThorChatMessage
|
||||
{
|
||||
Role = anthropicMessage.Role,
|
||||
ToolCalls = currentToolCalls
|
||||
};
|
||||
results.Add(toolCallMessage);
|
||||
currentToolCalls = new List<ThorToolCall>();
|
||||
}
|
||||
|
||||
// 创建工具结果消息
|
||||
var toolMessage = new ThorChatMessage
|
||||
{
|
||||
Role = "tool",
|
||||
ToolCallId = content.ToolUseId,
|
||||
Content = content.Content?.ToString() ?? string.Empty
|
||||
};
|
||||
results.Add(toolMessage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理剩余的内容
|
||||
if (currentContents.Count > 0)
|
||||
{
|
||||
var contentMessage = new ThorChatMessage
|
||||
{
|
||||
Role = anthropicMessage.Role,
|
||||
Contents = currentContents
|
||||
};
|
||||
results.Add(contentMessage);
|
||||
}
|
||||
|
||||
// 处理剩余的工具调用
|
||||
if (currentToolCalls.Count > 0)
|
||||
{
|
||||
var toolCallMessage = new ThorChatMessage
|
||||
{
|
||||
Role = anthropicMessage.Role,
|
||||
ToolCalls = currentToolCalls
|
||||
};
|
||||
results.Add(toolCallMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有任何内容,返回一个空的消息
|
||||
if (results.Count == 0)
|
||||
{
|
||||
results.Add(new ThorChatMessage
|
||||
{
|
||||
Role = anthropicMessage.Role,
|
||||
Content = string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
// 如果只有一个text则使用content字段
|
||||
if (results is [{ Contents.Count: 1 }] &&
|
||||
results.FirstOrDefault()?.Contents?.FirstOrDefault()?.Type == "text" &&
|
||||
!string.IsNullOrEmpty(results.FirstOrDefault()?.Contents?.FirstOrDefault()?.Text))
|
||||
{
|
||||
return
|
||||
[
|
||||
new ThorChatMessage
|
||||
{
|
||||
Role = results[0].Role,
|
||||
Content = results.FirstOrDefault()?.Contents?.FirstOrDefault()?.Text ?? string.Empty
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 转换Anthropic工具为Thor工具
|
||||
/// </summary>
|
||||
public static ThorToolDefinition ConvertAnthropicToolToThor(AnthropicMessageTool anthropicTool)
|
||||
{
|
||||
IDictionary<string, ThorToolFunctionPropertyDefinition> values =
|
||||
new Dictionary<string, ThorToolFunctionPropertyDefinition>();
|
||||
|
||||
if (anthropicTool.InputSchema?.Properties != null)
|
||||
{
|
||||
foreach (var property in anthropicTool.InputSchema.Properties)
|
||||
{
|
||||
if (property.Value?.description != null)
|
||||
{
|
||||
var definitionType = new ThorToolFunctionPropertyDefinition()
|
||||
{
|
||||
Description = property.Value.description,
|
||||
Type = property.Value.type
|
||||
};
|
||||
if (property.Value?.items?.type != null)
|
||||
{
|
||||
definitionType.Items = new ThorToolFunctionPropertyDefinition()
|
||||
{
|
||||
Type = property.Value.items.type
|
||||
};
|
||||
}
|
||||
|
||||
values.Add(property.Key, definitionType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return new ThorToolDefinition
|
||||
{
|
||||
Type = "function",
|
||||
Function = new ThorToolFunctionDefinition
|
||||
{
|
||||
Name = anthropicTool.name,
|
||||
Description = anthropicTool.Description,
|
||||
Parameters = new ThorToolFunctionPropertyDefinition
|
||||
{
|
||||
Type = anthropicTool.InputSchema?.Type ?? "object",
|
||||
Properties = values,
|
||||
Required = anthropicTool.InputSchema?.Required
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将OpenAI的完成原因转换为Claude的停止原因
|
||||
/// </summary>
|
||||
public static string GetClaudeStopReason(string? openAIFinishReason)
|
||||
{
|
||||
return openAIFinishReason switch
|
||||
{
|
||||
"stop" => "end_turn",
|
||||
"length" => "max_tokens",
|
||||
"tool_calls" => "tool_use",
|
||||
"content_filter" => "stop_sequence",
|
||||
_ => "end_turn"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将OpenAI响应转换为Claude响应格式
|
||||
/// </summary>
|
||||
public static AnthropicChatCompletionDto ConvertOpenAIToClaude(ThorChatCompletionsResponse openAIResponse,
|
||||
AnthropicInput originalRequest)
|
||||
{
|
||||
var claudeResponse = new AnthropicChatCompletionDto
|
||||
{
|
||||
id = openAIResponse.Id,
|
||||
type = "message",
|
||||
role = "assistant",
|
||||
model = openAIResponse.Model ?? originalRequest.Model,
|
||||
stop_reason = GetClaudeStopReason(openAIResponse.Choices?.FirstOrDefault()?.FinishReason),
|
||||
stop_sequence = "",
|
||||
content = []
|
||||
};
|
||||
|
||||
if (openAIResponse.Choices is { Count: > 0 })
|
||||
{
|
||||
var choice = openAIResponse.Choices.First();
|
||||
var contents = new List<AnthropicChatCompletionDtoContent>();
|
||||
|
||||
if (!string.IsNullOrEmpty(choice.Message.Content) && !string.IsNullOrEmpty(choice.Message.ReasoningContent))
|
||||
{
|
||||
contents.Add(new AnthropicChatCompletionDtoContent
|
||||
{
|
||||
type = "thinking",
|
||||
Thinking = choice.Message.ReasoningContent
|
||||
});
|
||||
|
||||
contents.Add(new AnthropicChatCompletionDtoContent
|
||||
{
|
||||
type = "text",
|
||||
text = choice.Message.Content
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// 处理思维内容
|
||||
if (!string.IsNullOrEmpty(choice.Message.ReasoningContent))
|
||||
contents.Add(new AnthropicChatCompletionDtoContent
|
||||
{
|
||||
type = "thinking",
|
||||
Thinking = choice.Message.ReasoningContent
|
||||
});
|
||||
|
||||
// 处理文本内容
|
||||
if (!string.IsNullOrEmpty(choice.Message.Content))
|
||||
contents.Add(new AnthropicChatCompletionDtoContent
|
||||
{
|
||||
type = "text",
|
||||
text = choice.Message.Content
|
||||
});
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if (choice.Message.ToolCalls is { Count: > 0 })
|
||||
contents.AddRange(choice.Message.ToolCalls.Select(toolCall => new AnthropicChatCompletionDtoContent
|
||||
{
|
||||
type = "tool_use", id = toolCall.Id, name = toolCall.Function?.Name,
|
||||
input = JsonSerializer.Deserialize<object>(toolCall.Function?.Arguments ?? "{}")
|
||||
}));
|
||||
|
||||
claudeResponse.content = contents.ToArray();
|
||||
}
|
||||
|
||||
// 处理使用情况统计 - 确保始终提供Usage信息
|
||||
claudeResponse.Usage = new AnthropicCompletionDtoUsage
|
||||
{
|
||||
InputTokens = openAIResponse.Usage?.PromptTokens ?? 0,
|
||||
OutputTokens = (int?)(openAIResponse.Usage?.CompletionTokens ?? 0),
|
||||
CacheCreationInputTokens = openAIResponse.Usage?.PromptTokensDetails?.CachedTokens ?? 0,
|
||||
CacheReadInputTokens = openAIResponse.Usage?.PromptTokensDetails?.CachedTokens ?? 0
|
||||
};
|
||||
|
||||
return claudeResponse;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 转换Anthropic工具选择为Thor工具选择
|
||||
/// </summary>
|
||||
public static ThorToolChoice ConvertAnthropicToolChoiceToThor(AnthropicTooChoiceInput anthropicToolChoice)
|
||||
{
|
||||
return new ThorToolChoice
|
||||
{
|
||||
Type = anthropicToolChoice.Type ?? "auto",
|
||||
Function = anthropicToolChoice.Name != null
|
||||
? new ThorToolChoiceFunctionTool { Name = anthropicToolChoice.Name }
|
||||
: null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
|
||||
|
||||
public class OpenAiResponsesInput
|
||||
{
|
||||
[JsonPropertyName("stream")] public bool? Stream { get; set; }
|
||||
|
||||
[JsonPropertyName("model")] public string Model { get; set; }
|
||||
[JsonPropertyName("input")] public JsonElement Input { get; set; }
|
||||
|
||||
[JsonPropertyName("max_output_tokens")]
|
||||
public int? MaxOutputTokens { get; set; }
|
||||
|
||||
[JsonPropertyName("max_tool_calls")] public JsonElement? MaxToolCalls { get; set; }
|
||||
[JsonPropertyName("instructions")] public string? Instructions { get; set; }
|
||||
[JsonPropertyName("metadata")] public JsonElement? Metadata { get; set; }
|
||||
|
||||
[JsonPropertyName("parallel_tool_calls")]
|
||||
public bool? ParallelToolCalls { get; set; }
|
||||
|
||||
[JsonPropertyName("previous_response_id")]
|
||||
public string? PreviousResponseId { get; set; }
|
||||
|
||||
[JsonPropertyName("prompt")] public JsonElement? Prompt { get; set; }
|
||||
[JsonPropertyName("prompt_cache_key")] public string? PromptCacheKey { get; set; }
|
||||
|
||||
[JsonPropertyName("prompt_cache_retention")]
|
||||
public string? PromptCacheRetention { get; set; }
|
||||
|
||||
[JsonPropertyName("reasoning")] public JsonElement? Reasoning { get; set; }
|
||||
|
||||
[JsonPropertyName("safety_identifier")]
|
||||
public string? SafetyIdentifier { get; set; }
|
||||
|
||||
[JsonPropertyName("service_tier")] public string? ServiceTier { get; set; }
|
||||
[JsonPropertyName("store")] public bool? Store { get; set; }
|
||||
[JsonPropertyName("stream_options")] public JsonElement? StreamOptions { get; set; }
|
||||
[JsonPropertyName("temperature")] public decimal? Temperature { get; set; }
|
||||
[JsonPropertyName("text")] public JsonElement? Text { get; set; }
|
||||
[JsonPropertyName("tool_choice")] public JsonElement? ToolChoice { get; set; }
|
||||
[JsonPropertyName("tools")] public JsonElement? Tools { get; set; }
|
||||
[JsonPropertyName("top_logprobs")] public int? TopLogprobs { get; set; }
|
||||
[JsonPropertyName("top_p")] public decimal? TopP { get; set; }
|
||||
[JsonPropertyName("truncation")] public string? Truncation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
|
||||
|
||||
public class OpenAiResponsesOutput
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
[JsonPropertyName("object")]
|
||||
public string? Object { get; set; }
|
||||
[JsonPropertyName("created_at")]
|
||||
public long CreatedAt { get; set; }
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; set; }
|
||||
[JsonPropertyName("error")]
|
||||
public dynamic? Error { get; set; }
|
||||
[JsonPropertyName("incomplete_details")]
|
||||
public dynamic? IncompleteDetails { get; set; }
|
||||
[JsonPropertyName("instructions")]
|
||||
public dynamic? Instructions { get; set; }
|
||||
[JsonPropertyName("max_output_tokens")]
|
||||
public dynamic? MaxOutputTokens { get; set; }
|
||||
[JsonPropertyName("model")]
|
||||
public string? Model { get; set; }
|
||||
// output 是复杂对象
|
||||
[JsonPropertyName("output")]
|
||||
public List<dynamic>? Output { get; set; }
|
||||
[JsonPropertyName("parallel_tool_calls")]
|
||||
public bool ParallelToolCalls { get; set; }
|
||||
[JsonPropertyName("previous_response_id")]
|
||||
public dynamic? PreviousResponseId { get; set; }
|
||||
[JsonPropertyName("reasoning")]
|
||||
public dynamic? Reasoning { get; set; }
|
||||
[JsonPropertyName("store")]
|
||||
public bool Store { get; set; }
|
||||
[JsonPropertyName("temperature")]
|
||||
public double Temperature { get; set; }
|
||||
[JsonPropertyName("text")]
|
||||
public dynamic? Text { get; set; }
|
||||
[JsonPropertyName("tool_choice")]
|
||||
public string? ToolChoice { get; set; }
|
||||
[JsonPropertyName("tools")]
|
||||
public List<dynamic>? Tools { get; set; }
|
||||
[JsonPropertyName("top_p")]
|
||||
public double TopP { get; set; }
|
||||
[JsonPropertyName("truncation")]
|
||||
public string? Truncation { get; set; }
|
||||
// usage 为唯一强类型
|
||||
[JsonPropertyName("usage")]
|
||||
public OpenAiResponsesUsageOutput? Usage { get; set; }
|
||||
[JsonPropertyName("user")]
|
||||
public dynamic? User { get; set; }
|
||||
[JsonPropertyName("metadata")]
|
||||
public dynamic? Metadata { get; set; }
|
||||
|
||||
public void SupplementalMultiplier(decimal multiplier)
|
||||
{
|
||||
if (this.Usage is not null)
|
||||
{
|
||||
this.Usage.InputTokens =
|
||||
(int)Math.Round((this.Usage?.InputTokens ?? 0) * multiplier);
|
||||
|
||||
this.Usage.OutputTokens =
|
||||
(int)Math.Round((this.Usage?.OutputTokens ?? 0) * multiplier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class OpenAiResponsesUsageOutput
|
||||
{
|
||||
[JsonPropertyName("input_tokens")]
|
||||
public int InputTokens { get; set; }
|
||||
[JsonPropertyName("input_tokens_details")]
|
||||
public OpenAiResponsesInputTokensDetails? InputTokensDetails { get; set; }
|
||||
[JsonPropertyName("output_tokens")]
|
||||
public int OutputTokens { get; set; }
|
||||
[JsonPropertyName("output_tokens_details")]
|
||||
public OpenAiResponsesOutputTokensDetails? OutputTokensDetails { get; set; }
|
||||
[JsonPropertyName("total_tokens")]
|
||||
public int TotalTokens { get; set; }
|
||||
}
|
||||
public class OpenAiResponsesInputTokensDetails
|
||||
{
|
||||
[JsonPropertyName("cached_tokens")]
|
||||
public int CachedTokens { get; set; }
|
||||
}
|
||||
public class OpenAiResponsesOutputTokensDetails
|
||||
{
|
||||
[JsonPropertyName("reasoning_tokens")]
|
||||
public int ReasoningTokens { get; set; }
|
||||
}
|
||||
@@ -61,7 +61,7 @@ public record ThorChatCompletionsResponse
|
||||
[JsonPropertyName("error")]
|
||||
public ThorError? Error { get; set; }
|
||||
|
||||
public void SupplementalMultiplier(double multiplier)
|
||||
public void SupplementalMultiplier(decimal multiplier)
|
||||
{
|
||||
if (this.Usage is not null)
|
||||
{
|
||||
@@ -73,6 +73,9 @@ public record ThorChatCompletionsResponse
|
||||
(int)Math.Round((this.Usage.CompletionTokens ?? 0) * multiplier);
|
||||
this.Usage.PromptTokens =
|
||||
(int)Math.Round((this.Usage.PromptTokens ?? 0) * multiplier);
|
||||
|
||||
this.Usage.TotalTokens =
|
||||
(int)Math.Round((this.Usage.TotalTokens ?? 0) * multiplier);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,28 @@ public class ThorChatMessage
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 用于数据存储
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string MessagesStore
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Content is not null)
|
||||
{
|
||||
return Content;
|
||||
}
|
||||
|
||||
if (Contents is not null && Contents.Any())
|
||||
{
|
||||
return JsonSerializer.Serialize(Contents);
|
||||
}
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【可选】参与者的可选名称。提供模型信息以区分相同角色的参与者。
|
||||
/// </summary>
|
||||
|
||||
@@ -102,11 +102,6 @@ public enum GoodsTypeEnum
|
||||
[Price(155.4, 6, 25.9)] [DisplayName("YiXinVip 6 month", "6个月", "年度热销")] [GoodsCategory(GoodsCategoryType.Vip)]
|
||||
YiXinVip6 = 6,
|
||||
|
||||
[Price(183.2, 8, 22.9)]
|
||||
[DisplayName("YiXinVip 8 month", "8个月(推荐)", "限时活动,超高性价比")]
|
||||
[GoodsCategory(GoodsCategoryType.Vip)]
|
||||
YiXinVip8 = 8,
|
||||
|
||||
// 尊享包服务 - 需要VIP资格才能购买
|
||||
[Price(188.9, 0, 1750)]
|
||||
[DisplayName("YiXinPremiumPackage 5000W Tokens", "5000万Tokens", "简单尝试")]
|
||||
@@ -264,7 +259,7 @@ public static class GoodsTypeEnumExtensions
|
||||
|
||||
/// <summary>
|
||||
/// 计算折扣金额(仅用于尊享包)
|
||||
/// 规则:每累加充值10元,减少2.5元,最多减少50元
|
||||
/// 规则:每累加充值10元,减少10元,最多减少50元
|
||||
/// </summary>
|
||||
/// <param name="goodsType">商品类型</param>
|
||||
/// <param name="totalRechargeAmount">用户累加充值金额</param>
|
||||
@@ -276,11 +271,10 @@ public static class GoodsTypeEnumExtensions
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
// 每10元减2.5元
|
||||
var discountAmount = Math.Floor(totalRechargeAmount / 2.5m);
|
||||
|
||||
// 最多减少50元
|
||||
// 每满 10 元减 10 元
|
||||
var discountTimes = Math.Floor(totalRechargeAmount / 10m);
|
||||
var discountAmount = discountTimes * 10m;
|
||||
// 最多减少 50 元
|
||||
return Math.Min(discountAmount, 50m);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
|
||||
|
||||
public enum ModelApiTypeEnum
|
||||
{
|
||||
[Description("OpenAI")]
|
||||
OpenAi,
|
||||
Claude
|
||||
|
||||
[Description("Claude")]
|
||||
Claude,
|
||||
|
||||
[Description("Response")]
|
||||
Response
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
|
||||
|
||||
public enum ModelTypeEnum
|
||||
{
|
||||
[Description("聊天")]
|
||||
Chat = 0,
|
||||
|
||||
[Description("图片")]
|
||||
Image = 1,
|
||||
Embedding = 2,
|
||||
PremiumChat = 3
|
||||
|
||||
[Description("嵌入")]
|
||||
Embedding = 2
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.Shared.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 枚举扩展方法
|
||||
/// </summary>
|
||||
public static class EnumExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取枚举的Description特性值
|
||||
/// </summary>
|
||||
/// <param name="value">枚举值</param>
|
||||
/// <returns>Description特性值,如果没有则返回枚举名称</returns>
|
||||
public static string GetDescription(this Enum value)
|
||||
{
|
||||
var field = value.GetType().GetField(value.ToString());
|
||||
if (field == null)
|
||||
{
|
||||
return value.ToString();
|
||||
}
|
||||
|
||||
var attribute = field.GetCustomAttribute<DescriptionAttribute>();
|
||||
return attribute?.Description ?? value.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.Shared.Extensions;
|
||||
|
||||
public static class JsonElementExtensions
|
||||
{
|
||||
#region 路径访问
|
||||
|
||||
/// <summary>
|
||||
/// 链式获取深层属性,支持对象属性和数组索引
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// root.GetPath("user", "addresses", 0, "city")
|
||||
/// </example>
|
||||
public static JsonElement? GetPath(this JsonElement element, params object[] path)
|
||||
{
|
||||
JsonElement current = element;
|
||||
|
||||
foreach (var key in path)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case string propertyName:
|
||||
if (current.ValueKind != JsonValueKind.Object ||
|
||||
!current.TryGetProperty(propertyName, out current))
|
||||
return null;
|
||||
break;
|
||||
|
||||
case int index:
|
||||
if (current.ValueKind != JsonValueKind.Array ||
|
||||
index < 0 || index >= current.GetArrayLength())
|
||||
return null;
|
||||
current = current[index];
|
||||
break;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 安全获取对象属性
|
||||
/// </summary>
|
||||
public static JsonElement? Get(this JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object &&
|
||||
element.TryGetProperty(propertyName, out var value))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 安全获取数组元素
|
||||
/// </summary>
|
||||
public static JsonElement? Get(this JsonElement element, int index)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Array &&
|
||||
index >= 0 && index < element.GetArrayLength())
|
||||
return element[index];
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 链式安全获取对象属性
|
||||
/// </summary>
|
||||
public static JsonElement? Get(this JsonElement? element, string propertyName)
|
||||
=> element?.Get(propertyName);
|
||||
|
||||
/// <summary>
|
||||
/// 链式安全获取数组元素
|
||||
/// </summary>
|
||||
public static JsonElement? Get(this JsonElement? element, int index)
|
||||
=> element?.Get(index);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 取值方法(带默认值)
|
||||
|
||||
public static string? GetString(this JsonElement? element, string? defaultValue = null)
|
||||
=> element?.ValueKind == JsonValueKind.String ? element.Value.GetString() : defaultValue;
|
||||
|
||||
public static int GetInt(this JsonElement? element, int defaultValue = 0)
|
||||
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetInt32() : defaultValue;
|
||||
|
||||
public static long GetLong(this JsonElement? element, long defaultValue = 0)
|
||||
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetInt64() : defaultValue;
|
||||
|
||||
public static double GetDouble(this JsonElement? element, double defaultValue = 0)
|
||||
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetDouble() : defaultValue;
|
||||
|
||||
public static decimal GetDecimal(this JsonElement? element, decimal defaultValue = 0)
|
||||
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetDecimal() : defaultValue;
|
||||
|
||||
public static bool GetBool(this JsonElement? element, bool defaultValue = false)
|
||||
=> element?.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? element.Value.GetBoolean()
|
||||
: defaultValue;
|
||||
|
||||
public static DateTime GetDateTime(this JsonElement? element, DateTime defaultValue = default)
|
||||
=> element?.ValueKind == JsonValueKind.String && element.Value.TryGetDateTime(out var dt)
|
||||
? dt
|
||||
: defaultValue;
|
||||
|
||||
public static Guid GetGuid(this JsonElement? element, Guid defaultValue = default)
|
||||
=> element?.ValueKind == JsonValueKind.String && element.Value.TryGetGuid(out var guid)
|
||||
? guid
|
||||
: defaultValue;
|
||||
|
||||
#endregion
|
||||
|
||||
#region 可空取值方法
|
||||
|
||||
public static int? GetIntOrNull(this JsonElement? element)
|
||||
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetInt32() : null;
|
||||
|
||||
public static long? GetLongOrNull(this JsonElement? element)
|
||||
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetInt64() : null;
|
||||
|
||||
public static double? GetDoubleOrNull(this JsonElement? element)
|
||||
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetDouble() : null;
|
||||
|
||||
public static decimal? GetDecimalOrNull(this JsonElement? element)
|
||||
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetDecimal() : null;
|
||||
|
||||
public static bool? GetBoolOrNull(this JsonElement? element)
|
||||
=> element?.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? element.Value.GetBoolean()
|
||||
: null;
|
||||
|
||||
public static DateTime? GetDateTimeOrNull(this JsonElement? element)
|
||||
=> element?.ValueKind == JsonValueKind.String && element.Value.TryGetDateTime(out var dt)
|
||||
? dt
|
||||
: null;
|
||||
|
||||
public static Guid? GetGuidOrNull(this JsonElement? element)
|
||||
=> element?.ValueKind == JsonValueKind.String && element.Value.TryGetGuid(out var guid)
|
||||
? guid
|
||||
: null;
|
||||
|
||||
#endregion
|
||||
|
||||
#region 数组操作
|
||||
|
||||
/// <summary>
|
||||
/// 安全获取数组,不存在返回空数组
|
||||
/// </summary>
|
||||
public static IEnumerable<JsonElement> GetArray(this JsonElement? element)
|
||||
{
|
||||
if (element?.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in element.Value.EnumerateArray())
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取数组长度
|
||||
/// </summary>
|
||||
public static int GetArrayLength(this JsonElement? element)
|
||||
=> element?.ValueKind == JsonValueKind.Array ? element.Value.GetArrayLength() : 0;
|
||||
|
||||
/// <summary>
|
||||
/// 数组转 List
|
||||
/// </summary>
|
||||
public static List<string?> ToStringList(this JsonElement? element)
|
||||
=> element.GetArray().Select(e => e.GetString()).ToList();
|
||||
|
||||
public static List<int> ToIntList(this JsonElement? element)
|
||||
=> element.GetArray()
|
||||
.Where(e => e.ValueKind == JsonValueKind.Number)
|
||||
.Select(e => e.GetInt32())
|
||||
.ToList();
|
||||
|
||||
#endregion
|
||||
|
||||
#region 对象操作
|
||||
|
||||
/// <summary>
|
||||
/// 安全枚举对象属性
|
||||
/// </summary>
|
||||
public static IEnumerable<JsonProperty> GetProperties(this JsonElement? element)
|
||||
{
|
||||
if (element?.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in element.Value.EnumerateObject())
|
||||
yield return prop;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有属性名
|
||||
/// </summary>
|
||||
public static IEnumerable<string> GetPropertyNames(this JsonElement? element)
|
||||
=> element.GetProperties().Select(p => p.Name);
|
||||
|
||||
/// <summary>
|
||||
/// 判断是否包含某属性
|
||||
/// </summary>
|
||||
public static bool HasProperty(this JsonElement? element, string propertyName)
|
||||
=> element?.ValueKind == JsonValueKind.Object &&
|
||||
element.Value.TryGetProperty(propertyName, out _);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 类型判断
|
||||
|
||||
public static bool IsNull(this JsonElement? element)
|
||||
=> element == null || element.Value.ValueKind == JsonValueKind.Null;
|
||||
|
||||
public static bool IsNullOrUndefined(this JsonElement? element)
|
||||
=> element == null || element.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined;
|
||||
|
||||
public static bool IsObject(this JsonElement? element)
|
||||
=> element?.ValueKind == JsonValueKind.Object;
|
||||
|
||||
public static bool IsArray(this JsonElement? element)
|
||||
=> element?.ValueKind == JsonValueKind.Array;
|
||||
|
||||
public static bool IsString(this JsonElement? element)
|
||||
=> element?.ValueKind == JsonValueKind.String;
|
||||
|
||||
public static bool IsNumber(this JsonElement? element)
|
||||
=> element?.ValueKind == JsonValueKind.Number;
|
||||
|
||||
public static bool IsBool(this JsonElement? element)
|
||||
=> element?.ValueKind is JsonValueKind.True or JsonValueKind.False;
|
||||
|
||||
public static bool Exists(this JsonElement? element)
|
||||
=> element != null && element.Value.ValueKind != JsonValueKind.Undefined;
|
||||
|
||||
#endregion
|
||||
|
||||
#region 反序列化
|
||||
|
||||
/// <summary>
|
||||
/// 反序列化为指定类型
|
||||
/// </summary>
|
||||
public static T? Deserialize<T>(this JsonElement? element, JsonSerializerOptions? options = null)
|
||||
=> element.HasValue ? element.Value.Deserialize<T>(options) : default;
|
||||
|
||||
/// <summary>
|
||||
/// 反序列化为指定类型,带默认值
|
||||
/// </summary>
|
||||
public static T Deserialize<T>(this JsonElement? element, T defaultValue, JsonSerializerOptions? options = null)
|
||||
=> element.HasValue ? element.Value.Deserialize<T>(options) ?? defaultValue : defaultValue;
|
||||
|
||||
#endregion
|
||||
|
||||
#region 转换为字典/动态类型
|
||||
|
||||
/// <summary>
|
||||
/// 转换为 Dictionary
|
||||
/// </summary>
|
||||
public static Dictionary<string, JsonElement>? ToDictionary(this JsonElement? element)
|
||||
{
|
||||
if (element?.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
|
||||
var dict = new Dictionary<string, JsonElement>();
|
||||
foreach (var prop in element.Value.EnumerateObject())
|
||||
dict[prop.Name] = prop.Value;
|
||||
return dict;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 原始值
|
||||
|
||||
/// <summary>
|
||||
/// 获取原始 JSON 字符串
|
||||
/// </summary>
|
||||
public static string? GetRawText(this JsonElement? element)
|
||||
=> element?.GetRawText();
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.AiGateWay;
|
||||
|
||||
public interface IOpenAiResponseService
|
||||
{
|
||||
/// <summary>
|
||||
/// 响应-流式
|
||||
/// </summary>
|
||||
/// <param name="aiModelDescribe"></param>
|
||||
/// <param name="input"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public IAsyncEnumerable<(string, JsonElement?)> ResponsesStreamAsync(AiModelDescribe aiModelDescribe,
|
||||
OpenAiResponsesInput input,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 响应-非流式
|
||||
/// </summary>
|
||||
/// <param name="aiModelDescribe"></param>
|
||||
/// <param name="input"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public Task<OpenAiResponsesOutput> ResponsesAsync(AiModelDescribe aiModelDescribe,
|
||||
OpenAiResponsesInput input,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -14,8 +14,6 @@ public class AnthropicChatCompletionsService(
|
||||
ILogger<AnthropicChatCompletionsService> logger)
|
||||
: IAnthropicChatCompletionService
|
||||
{
|
||||
public const double ClaudeMultiplier = 1.3d;
|
||||
|
||||
public async Task<AnthropicChatCompletionDto> ChatCompletionsAsync(AiModelDescribe options, AnthropicInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -86,8 +84,7 @@ public class AnthropicChatCompletionsService(
|
||||
var value =
|
||||
await response.Content.ReadFromJsonAsync<AnthropicChatCompletionDto>(ThorJsonSerializer.DefaultOptions,
|
||||
cancellationToken: cancellationToken);
|
||||
value.SupplementalMultiplier(AnthropicChatCompletionsService.ClaudeMultiplier);
|
||||
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -169,8 +166,7 @@ public class AnthropicChatCompletionsService(
|
||||
|
||||
var result = JsonSerializer.Deserialize<AnthropicStreamDto>(data,
|
||||
ThorJsonSerializer.DefaultOptions);
|
||||
|
||||
result.SupplementalMultiplier(AnthropicChatCompletionsService.ClaudeMultiplier);
|
||||
|
||||
yield return (eventType, result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +345,7 @@ public sealed class ClaudiaChatCompletionsService(
|
||||
var response = await client.HttpRequestRaw(options.Endpoint.TrimEnd('/') + "/v1/messages", new
|
||||
{
|
||||
model = input.Model,
|
||||
max_tokens = input.MaxTokens ?? 2048,
|
||||
max_tokens = input.MaxTokens ?? 64000,
|
||||
stream = true,
|
||||
tool_choice,
|
||||
system = CreateMessage(input.Messages.Where(x => x.Role == "system").ToList(), options),
|
||||
@@ -716,7 +716,7 @@ public sealed class ClaudiaChatCompletionsService(
|
||||
output.Usage.PromptTokens = output.Usage.InputTokens;
|
||||
output.Usage.CompletionTokens = output.Usage.OutputTokens;
|
||||
output.Usage.TotalTokens = output.Usage.InputTokens + output.Usage.OutputTokens;
|
||||
output.SupplementalMultiplier(AnthropicChatCompletionsService.ClaudeMultiplier);
|
||||
|
||||
yield return output;
|
||||
}
|
||||
}
|
||||
@@ -873,7 +873,6 @@ public sealed class ClaudiaChatCompletionsService(
|
||||
}
|
||||
|
||||
thor.Usage.TotalTokens = thor.Usage.InputTokens + thor.Usage.OutputTokens;
|
||||
thor.SupplementalMultiplier(AnthropicChatCompletionsService.ClaudeMultiplier);
|
||||
return thor;
|
||||
}
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorCustomOpenAI.Chats;
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI到Claude适配器服务
|
||||
/// 将Claude格式的请求转换为OpenAI格式,然后将OpenAI的响应转换为Claude格式
|
||||
/// </summary>
|
||||
public class CustomOpenAIAnthropicChatCompletionsService(
|
||||
IAbpLazyServiceProvider serviceProvider,
|
||||
ILogger<CustomOpenAIAnthropicChatCompletionsService> logger)
|
||||
: IAnthropicChatCompletionService
|
||||
{
|
||||
private IChatCompletionService GetChatCompletionService()
|
||||
{
|
||||
return serviceProvider.GetRequiredKeyedService<IChatCompletionService>(nameof(OpenAiChatCompletionsService));
|
||||
}
|
||||
|
||||
public async Task<AnthropicChatCompletionDto> ChatCompletionsAsync(AiModelDescribe aiModelDescribe,
|
||||
AnthropicInput request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 转换请求格式:Claude -> OpenAI
|
||||
var openAIRequest = AnthropicToOpenAi.ConvertAnthropicToOpenAi(request);
|
||||
|
||||
if (openAIRequest.Model.StartsWith("gpt-5"))
|
||||
{
|
||||
openAIRequest.MaxCompletionTokens = request.MaxTokens;
|
||||
openAIRequest.MaxTokens = null;
|
||||
}
|
||||
else if (openAIRequest.Model.StartsWith("o3-mini") || openAIRequest.Model.StartsWith("o4-mini"))
|
||||
{
|
||||
openAIRequest.MaxCompletionTokens = request.MaxTokens;
|
||||
openAIRequest.MaxTokens = null;
|
||||
openAIRequest.Temperature = null;
|
||||
}
|
||||
|
||||
// 调用OpenAI服务
|
||||
var openAIResponse =
|
||||
await GetChatCompletionService().CompleteChatAsync(aiModelDescribe,openAIRequest, cancellationToken);
|
||||
|
||||
// 转换响应格式:OpenAI -> Claude
|
||||
var claudeResponse = AnthropicToOpenAi.ConvertOpenAIToClaude(openAIResponse, request);
|
||||
|
||||
return claudeResponse;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<(string, AnthropicStreamDto?)> StreamChatCompletionsAsync(AiModelDescribe aiModelDescribe,
|
||||
AnthropicInput request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var openAIRequest = AnthropicToOpenAi.ConvertAnthropicToOpenAi(request);
|
||||
openAIRequest.Stream = true;
|
||||
|
||||
if (openAIRequest.Model.StartsWith("gpt-5"))
|
||||
{
|
||||
openAIRequest.MaxCompletionTokens = request.MaxTokens;
|
||||
openAIRequest.MaxTokens = null;
|
||||
}
|
||||
else if (openAIRequest.Model.StartsWith("o3-mini") || openAIRequest.Model.StartsWith("o4-mini"))
|
||||
{
|
||||
openAIRequest.MaxCompletionTokens = request.MaxTokens;
|
||||
openAIRequest.MaxTokens = null;
|
||||
openAIRequest.Temperature = null;
|
||||
}
|
||||
|
||||
var messageId = Guid.NewGuid().ToString();
|
||||
var hasStarted = false;
|
||||
var hasTextContentBlockStarted = false;
|
||||
var hasThinkingContentBlockStarted = false;
|
||||
var toolBlocksStarted = new Dictionary<int, bool>(); // 使用索引而不是ID
|
||||
var toolCallIds = new Dictionary<int, string>(); // 存储每个索引对应的ID
|
||||
var toolCallIndexToBlockIndex = new Dictionary<int, int>(); // 工具调用索引到块索引的映射
|
||||
var accumulatedUsage = new AnthropicCompletionDtoUsage();
|
||||
var isFinished = false;
|
||||
var currentContentBlockType = ""; // 跟踪当前内容块类型
|
||||
var currentBlockIndex = 0; // 跟踪当前块索引
|
||||
var lastContentBlockType = ""; // 跟踪最后一个内容块类型,用于确定停止原因
|
||||
|
||||
await foreach (var openAIResponse in GetChatCompletionService().CompleteChatStreamAsync(aiModelDescribe,openAIRequest,
|
||||
cancellationToken))
|
||||
{
|
||||
// 发送message_start事件
|
||||
if (!hasStarted && openAIResponse.Choices?.Count > 0 &&
|
||||
openAIResponse.Choices.Any(x => x.Delta.ToolCalls?.Count > 0) == false)
|
||||
{
|
||||
hasStarted = true;
|
||||
var messageStartEvent = AnthropicToOpenAi.CreateMessageStartEvent(messageId, request.Model);
|
||||
yield return ("message_start", messageStartEvent);
|
||||
}
|
||||
|
||||
// 更新使用情况统计
|
||||
if (openAIResponse.Usage != null)
|
||||
{
|
||||
// 使用最新的token计数(OpenAI通常在最后的响应中提供完整的统计)
|
||||
if (openAIResponse.Usage.PromptTokens.HasValue)
|
||||
{
|
||||
accumulatedUsage.InputTokens = openAIResponse.Usage.PromptTokens.Value;
|
||||
}
|
||||
|
||||
if (openAIResponse.Usage.CompletionTokens.HasValue)
|
||||
{
|
||||
accumulatedUsage.OutputTokens = (int)openAIResponse.Usage.CompletionTokens.Value;
|
||||
}
|
||||
|
||||
if (openAIResponse.Usage.PromptTokensDetails?.CachedTokens.HasValue == true)
|
||||
{
|
||||
accumulatedUsage.CacheReadInputTokens =
|
||||
openAIResponse.Usage.PromptTokensDetails.CachedTokens.Value;
|
||||
}
|
||||
|
||||
// 记录调试信息
|
||||
logger.LogDebug("OpenAI Usage更新: Input={InputTokens}, Output={OutputTokens}, CacheRead={CacheRead}",
|
||||
accumulatedUsage.InputTokens, accumulatedUsage.OutputTokens,
|
||||
accumulatedUsage.CacheReadInputTokens);
|
||||
}
|
||||
|
||||
if (openAIResponse.Choices is { Count: > 0 })
|
||||
{
|
||||
var choice = openAIResponse.Choices.First();
|
||||
|
||||
// 处理内容
|
||||
if (!string.IsNullOrEmpty(choice.Delta?.Content))
|
||||
{
|
||||
// 如果当前有其他类型的内容块在运行,先结束它们
|
||||
if (currentContentBlockType != "text" && !string.IsNullOrEmpty(currentContentBlockType))
|
||||
{
|
||||
var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
|
||||
stopEvent.Index = currentBlockIndex;
|
||||
yield return ("content_block_stop", stopEvent);
|
||||
currentBlockIndex++; // 切换内容块时增加索引
|
||||
currentContentBlockType = "";
|
||||
}
|
||||
|
||||
// 发送content_block_start事件(仅第一次)
|
||||
if (!hasTextContentBlockStarted || currentContentBlockType != "text")
|
||||
{
|
||||
hasTextContentBlockStarted = true;
|
||||
currentContentBlockType = "text";
|
||||
lastContentBlockType = "text";
|
||||
var contentBlockStartEvent = AnthropicToOpenAi.CreateContentBlockStartEvent();
|
||||
contentBlockStartEvent.Index = currentBlockIndex;
|
||||
yield return ("content_block_start",
|
||||
contentBlockStartEvent);
|
||||
}
|
||||
|
||||
// 发送content_block_delta事件
|
||||
var contentDeltaEvent = AnthropicToOpenAi.CreateContentBlockDeltaEvent(choice.Delta.Content);
|
||||
contentDeltaEvent.Index = currentBlockIndex;
|
||||
yield return ("content_block_delta",
|
||||
contentDeltaEvent);
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if (choice.Delta?.ToolCalls is { Count: > 0 })
|
||||
{
|
||||
foreach (var toolCall in choice.Delta.ToolCalls)
|
||||
{
|
||||
var toolCallIndex = toolCall.Index; // 使用索引来标识工具调用
|
||||
|
||||
// 发送tool_use content_block_start事件
|
||||
if (toolBlocksStarted.TryAdd(toolCallIndex, true))
|
||||
{
|
||||
// 如果当前有文本或thinking内容块在运行,先结束它们
|
||||
if (currentContentBlockType == "text" || currentContentBlockType == "thinking")
|
||||
{
|
||||
var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
|
||||
stopEvent.Index = currentBlockIndex;
|
||||
yield return ("content_block_stop", stopEvent);
|
||||
currentBlockIndex++; // 增加块索引
|
||||
}
|
||||
// 如果当前有其他工具调用在运行,也需要结束它们
|
||||
else if (currentContentBlockType == "tool_use")
|
||||
{
|
||||
var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
|
||||
stopEvent.Index = currentBlockIndex;
|
||||
yield return ("content_block_stop", stopEvent);
|
||||
currentBlockIndex++; // 增加块索引
|
||||
}
|
||||
|
||||
currentContentBlockType = "tool_use";
|
||||
lastContentBlockType = "tool_use";
|
||||
|
||||
// 为此工具调用分配一个新的块索引
|
||||
toolCallIndexToBlockIndex[toolCallIndex] = currentBlockIndex;
|
||||
|
||||
// 保存工具调用的ID(如果有的话)
|
||||
if (!string.IsNullOrEmpty(toolCall.Id))
|
||||
{
|
||||
toolCallIds[toolCallIndex] = toolCall.Id;
|
||||
}
|
||||
else if (!toolCallIds.ContainsKey(toolCallIndex))
|
||||
{
|
||||
// 如果没有ID且之前也没有保存过,生成一个新的ID
|
||||
toolCallIds[toolCallIndex] = Guid.NewGuid().ToString();
|
||||
}
|
||||
|
||||
var toolBlockStartEvent = AnthropicToOpenAi.CreateToolBlockStartEvent(
|
||||
toolCallIds[toolCallIndex],
|
||||
toolCall.Function?.Name);
|
||||
toolBlockStartEvent.Index = currentBlockIndex;
|
||||
yield return ("content_block_start",
|
||||
toolBlockStartEvent);
|
||||
}
|
||||
|
||||
// 如果有增量的参数,发送content_block_delta事件
|
||||
if (!string.IsNullOrEmpty(toolCall.Function?.Arguments))
|
||||
{
|
||||
var toolDeltaEvent =
|
||||
AnthropicToOpenAi.CreateToolBlockDeltaEvent(toolCall.Function.Arguments);
|
||||
// 使用该工具调用对应的块索引
|
||||
toolDeltaEvent.Index = toolCallIndexToBlockIndex[toolCallIndex];
|
||||
yield return ("content_block_delta",
|
||||
toolDeltaEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理推理内容
|
||||
if (!string.IsNullOrEmpty(choice.Delta?.ReasoningContent))
|
||||
{
|
||||
// 如果当前有其他类型的内容块在运行,先结束它们
|
||||
if (currentContentBlockType != "thinking" && !string.IsNullOrEmpty(currentContentBlockType))
|
||||
{
|
||||
var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
|
||||
stopEvent.Index = currentBlockIndex;
|
||||
yield return ("content_block_stop", stopEvent);
|
||||
currentBlockIndex++; // 增加块索引
|
||||
currentContentBlockType = "";
|
||||
}
|
||||
|
||||
// 对于推理内容,也需要发送对应的事件
|
||||
if (!hasThinkingContentBlockStarted || currentContentBlockType != "thinking")
|
||||
{
|
||||
hasThinkingContentBlockStarted = true;
|
||||
currentContentBlockType = "thinking";
|
||||
lastContentBlockType = "thinking";
|
||||
var thinkingBlockStartEvent = AnthropicToOpenAi.CreateThinkingBlockStartEvent();
|
||||
thinkingBlockStartEvent.Index = currentBlockIndex;
|
||||
yield return ("content_block_start",
|
||||
thinkingBlockStartEvent);
|
||||
}
|
||||
|
||||
var thinkingDeltaEvent =
|
||||
AnthropicToOpenAi.CreateThinkingBlockDeltaEvent(choice.Delta.ReasoningContent);
|
||||
thinkingDeltaEvent.Index = currentBlockIndex;
|
||||
yield return ("content_block_delta",
|
||||
thinkingDeltaEvent);
|
||||
}
|
||||
|
||||
// 处理结束
|
||||
if (!string.IsNullOrEmpty(choice.FinishReason) && !isFinished)
|
||||
{
|
||||
isFinished = true;
|
||||
|
||||
// 发送content_block_stop事件(如果有活跃的内容块)
|
||||
if (!string.IsNullOrEmpty(currentContentBlockType))
|
||||
{
|
||||
var contentBlockStopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
|
||||
contentBlockStopEvent.Index = currentBlockIndex;
|
||||
yield return ("content_block_stop",
|
||||
contentBlockStopEvent);
|
||||
}
|
||||
|
||||
// 发送message_delta事件
|
||||
var messageDeltaEvent = AnthropicToOpenAi.CreateMessageDeltaEvent(
|
||||
AnthropicToOpenAi.GetStopReasonByLastContentType(choice.FinishReason, lastContentBlockType),
|
||||
accumulatedUsage);
|
||||
|
||||
// 记录最终Usage统计
|
||||
logger.LogDebug(
|
||||
"流式响应结束,最终Usage: Input={InputTokens}, Output={OutputTokens}, CacheRead={CacheRead}",
|
||||
accumulatedUsage.InputTokens, accumulatedUsage.OutputTokens,
|
||||
accumulatedUsage.CacheReadInputTokens);
|
||||
|
||||
yield return ("message_delta",
|
||||
messageDeltaEvent);
|
||||
|
||||
// 发送message_stop事件
|
||||
var messageStopEvent = AnthropicToOpenAi.CreateMessageStopEvent();
|
||||
yield return ("message_stop",
|
||||
messageStopEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保流正确结束
|
||||
if (!isFinished)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(currentContentBlockType))
|
||||
{
|
||||
var contentBlockStopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
|
||||
contentBlockStopEvent.Index = currentBlockIndex;
|
||||
yield return ("content_block_stop",
|
||||
contentBlockStopEvent);
|
||||
}
|
||||
|
||||
var messageDeltaEvent =
|
||||
AnthropicToOpenAi.CreateMessageDeltaEvent(
|
||||
AnthropicToOpenAi.GetStopReasonByLastContentType("end_turn", lastContentBlockType),
|
||||
accumulatedUsage);
|
||||
yield return ("message_delta", messageDeltaEvent);
|
||||
|
||||
var messageStopEvent = AnthropicToOpenAi.CreateMessageStopEvent();
|
||||
yield return ("message_stop",
|
||||
messageStopEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ public sealed class OpenAiChatCompletionsService(ILogger<OpenAiChatCompletionsSe
|
||||
using var openai =
|
||||
Activity.Current?.Source.StartActivity("OpenAI 对话补全");
|
||||
|
||||
var response = await HttpClientFactory.GetHttpClient(options.Endpoint).PostJsonAsync(
|
||||
var response = await httpClientFactory.CreateClient().PostJsonAsync(
|
||||
options?.Endpoint.TrimEnd('/') + "/chat/completions",
|
||||
chatCompletionCreate, options.ApiKey).ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Yi.Framework.AiHub.Domain.AiGateWay.Exceptions;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorCustomOpenAI.Chats;
|
||||
|
||||
public class OpenAiResponseService(ILogger<OpenAiResponseService> logger,IHttpClientFactory httpClientFactory):IOpenAiResponseService
|
||||
{
|
||||
|
||||
public async IAsyncEnumerable<(string, JsonElement?)> ResponsesStreamAsync(AiModelDescribe options, OpenAiResponsesInput input,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var openai =
|
||||
Activity.Current?.Source.StartActivity("OpenAi 响应");
|
||||
|
||||
|
||||
var client = httpClientFactory.CreateClient();
|
||||
|
||||
var response = await client.HttpRequestRaw(options.Endpoint.TrimEnd('/') + "/responses", input, options.ApiKey);
|
||||
|
||||
openai?.SetTag("Model", input.Model);
|
||||
openai?.SetTag("Response", response.StatusCode.ToString());
|
||||
|
||||
// 大于等于400的状态码都认为是异常
|
||||
if (response.StatusCode >= HttpStatusCode.BadRequest)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError("OpenAI响应异常 请求地址:{Address}, StatusCode: {StatusCode} Response: {Response}",
|
||||
options.Endpoint,
|
||||
response.StatusCode, error);
|
||||
|
||||
throw new Exception("OpenAI响应异常" + response.StatusCode);
|
||||
}
|
||||
|
||||
using var stream = new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken));
|
||||
|
||||
using StreamReader reader = new(await response.Content.ReadAsStreamAsync(cancellationToken));
|
||||
string? line = string.Empty;
|
||||
|
||||
string? data = null;
|
||||
string eventType = string.Empty;
|
||||
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) != null)
|
||||
{
|
||||
line += Environment.NewLine;
|
||||
|
||||
if (line.StartsWith('{'))
|
||||
{
|
||||
logger.LogInformation("OpenAI响应异常 , StatusCode: {StatusCode} Response: {Response}", response.StatusCode,
|
||||
line);
|
||||
|
||||
throw new Exception("OpenAI响应异常" + line);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("event:"))
|
||||
{
|
||||
eventType = line;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.StartsWith(OpenAIConstant.Data)) continue;
|
||||
|
||||
data = line[OpenAIConstant.Data.Length..].Trim();
|
||||
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(data,
|
||||
ThorJsonSerializer.DefaultOptions);
|
||||
|
||||
yield return (eventType, result);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<OpenAiResponsesOutput> ResponsesAsync(AiModelDescribe options, OpenAiResponsesInput chatCompletionCreate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var openai =
|
||||
Activity.Current?.Source.StartActivity("OpenAI 响应");
|
||||
|
||||
var response = await httpClientFactory.CreateClient().PostJsonAsync(
|
||||
options?.Endpoint.TrimEnd('/') + "/responses",
|
||||
chatCompletionCreate, options.ApiKey).ConfigureAwait(false);
|
||||
|
||||
openai?.SetTag("Model", chatCompletionCreate.Model);
|
||||
openai?.SetTag("Response", response.StatusCode.ToString());
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
throw new BusinessException("渠道未登录,请联系管理人员", "401");
|
||||
}
|
||||
|
||||
// 如果限流则抛出限流异常
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
throw new ThorRateLimitException();
|
||||
}
|
||||
|
||||
// 大于等于400的状态码都认为是异常
|
||||
if (response.StatusCode >= HttpStatusCode.BadRequest)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError("OpenAI 响应异常 请求地址:{Address}, StatusCode: {StatusCode} Response: {Response}", options.Endpoint,
|
||||
response.StatusCode, error);
|
||||
|
||||
throw new BusinessException("OpenAI响应异常", response.StatusCode.ToString());
|
||||
}
|
||||
|
||||
var result =
|
||||
await response.Content.ReadFromJsonAsync<OpenAiResponsesOutput>(
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,11 @@ public class MessageAggregateRoot : FullAuditedAggregateRoot<Guid>
|
||||
}
|
||||
|
||||
public MessageAggregateRoot(Guid? userId, Guid? sessionId, string content, string role, string modelId,
|
||||
ThorUsageResponse? tokenUsage)
|
||||
ThorUsageResponse? tokenUsage, Guid? tokenId = null)
|
||||
{
|
||||
UserId = userId;
|
||||
SessionId = sessionId;
|
||||
TokenId = tokenId ?? Guid.Empty;
|
||||
//如果没有会话,不存储对话内容
|
||||
Content = sessionId is null ? null : content;
|
||||
Role = role;
|
||||
@@ -59,6 +60,11 @@ public class MessageAggregateRoot : FullAuditedAggregateRoot<Guid>
|
||||
public Guid? UserId { get; set; }
|
||||
public Guid? SessionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Token密钥Id(通过API调用时记录,Web调用为Guid.Empty)
|
||||
/// </summary>
|
||||
public Guid TokenId { get; set; }
|
||||
|
||||
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString)]
|
||||
public string? Content { get; set; }
|
||||
|
||||
|
||||
@@ -60,4 +60,24 @@ public class AiModelEntity : Entity<Guid>, IOrderNum, ISoftDelete
|
||||
/// 模型Api类型,现支持同一个模型id,多种接口格式
|
||||
/// </summary>
|
||||
public ModelApiTypeEnum ModelApiType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模型倍率
|
||||
/// </summary>
|
||||
public decimal Multiplier { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 模型显示倍率
|
||||
/// </summary>
|
||||
public decimal MultiplierShow { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 供应商分组名称(如:OpenAI、Anthropic、Google等)
|
||||
/// </summary>
|
||||
public string? ProviderName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模型图标URL
|
||||
/// </summary>
|
||||
public string? IconUrl { get; set; }
|
||||
}
|
||||
@@ -5,27 +5,84 @@ using Volo.Abp.Domain.Entities.Auditing;
|
||||
namespace Yi.Framework.AiHub.Domain.Entities.OpenApi;
|
||||
|
||||
[SugarTable("Ai_Token")]
|
||||
[SugarIndex($"index_{{table}}_{nameof(UserId)}", nameof(UserId), OrderByType.Asc)]
|
||||
public class TokenAggregateRoot : FullAuditedAggregateRoot<Guid>
|
||||
{
|
||||
public TokenAggregateRoot()
|
||||
{
|
||||
}
|
||||
|
||||
public TokenAggregateRoot(Guid userId)
|
||||
public TokenAggregateRoot(Guid userId, string name)
|
||||
{
|
||||
this.UserId = userId;
|
||||
this.Token = GenerateToken();
|
||||
UserId = userId;
|
||||
Name = name;
|
||||
Token = GenerateToken();
|
||||
IsDisabled = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Token密钥
|
||||
/// </summary>
|
||||
public string Token { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户Id
|
||||
/// </summary>
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 重置token
|
||||
/// 名称
|
||||
/// </summary>
|
||||
public void ResetToken()
|
||||
[SugarColumn(Length = 100)]
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期时间(空为永不过期)
|
||||
/// </summary>
|
||||
public DateTime? ExpireTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 尊享包额度限制(空为不限制)
|
||||
/// </summary>
|
||||
public long? PremiumQuotaLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否禁用
|
||||
/// </summary>
|
||||
public bool IsDisabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 检查Token是否可用
|
||||
/// </summary>
|
||||
public bool IsAvailable()
|
||||
{
|
||||
this.Token = GenerateToken();
|
||||
if (IsDisabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ExpireTime.HasValue && ExpireTime.Value < DateTime.Now)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 禁用Token
|
||||
/// </summary>
|
||||
public void Disable()
|
||||
{
|
||||
IsDisabled = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启用Token
|
||||
/// </summary>
|
||||
public void Enable()
|
||||
{
|
||||
IsDisabled = false;
|
||||
}
|
||||
|
||||
private string GenerateToken(int length = 36)
|
||||
|
||||
@@ -7,16 +7,22 @@ namespace Yi.Framework.AiHub.Domain.Entities;
|
||||
/// 用量统计
|
||||
/// </summary>
|
||||
[SugarTable("Ai_UsageStatistics")]
|
||||
[SugarIndex($"index_{{table}}_{nameof(UserId)}_{nameof(ModelId)}_{nameof(TokenId)}",
|
||||
nameof(UserId), OrderByType.Asc,
|
||||
nameof(ModelId), OrderByType.Asc,
|
||||
nameof(TokenId), OrderByType.Asc
|
||||
)]
|
||||
public class UsageStatisticsAggregateRoot : FullAuditedAggregateRoot<Guid>
|
||||
{
|
||||
public UsageStatisticsAggregateRoot()
|
||||
{
|
||||
}
|
||||
|
||||
public UsageStatisticsAggregateRoot(Guid? userId, string modelId)
|
||||
public UsageStatisticsAggregateRoot(Guid? userId, string modelId, Guid tokenId)
|
||||
{
|
||||
UserId = userId;
|
||||
ModelId = modelId;
|
||||
TokenId = tokenId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -29,6 +35,11 @@ public class UsageStatisticsAggregateRoot : FullAuditedAggregateRoot<Guid>
|
||||
/// </summary>
|
||||
public string ModelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Token密钥Id(通过API调用时记录,Web调用为Guid.Empty)
|
||||
/// </summary>
|
||||
public Guid TokenId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 对话次数
|
||||
/// </summary>
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using Volo.Abp.Domain.Services;
|
||||
using Yi.Framework.AiHub.Domain.AiGateWay;
|
||||
@@ -18,7 +19,9 @@ using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Embeddings;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Images;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Enums;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Extensions;
|
||||
using Yi.Framework.Core.Extensions;
|
||||
using Yi.Framework.SqlSugarCore.Abstractions;
|
||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
@@ -77,7 +80,8 @@ public class AiGateWayManager : DomainService
|
||||
ModelName = model.Name,
|
||||
Description = model.Description,
|
||||
AppExtraUrl = app.ExtraUrl,
|
||||
ModelExtraInfo = model.ExtraInfo
|
||||
ModelExtraInfo = model.ExtraInfo,
|
||||
Multiplier = model.Multiplier
|
||||
})
|
||||
.FirstAsync();
|
||||
if (aiModelDescribe is null)
|
||||
@@ -88,29 +92,7 @@ public class AiGateWayManager : DomainService
|
||||
return aiModelDescribe;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 聊天完成-流式
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public async IAsyncEnumerable<ThorChatCompletionsResponse> CompleteChatStreamAsync(
|
||||
ThorChatCompletionsRequest request,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
_specialCompatible.Compatible(request);
|
||||
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.OpenAi, request.Model);
|
||||
var chatService =
|
||||
LazyServiceProvider.GetRequiredKeyedService<IChatCompletionService>(modelDescribe.HandlerName);
|
||||
|
||||
await foreach (var result in chatService.CompleteChatStreamAsync(modelDescribe, request, cancellationToken))
|
||||
{
|
||||
yield return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 聊天完成-非流式
|
||||
/// </summary>
|
||||
@@ -118,12 +100,14 @@ public class AiGateWayManager : DomainService
|
||||
/// <param name="request"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="tokenId">Token Id(Web端传null或Guid.Empty)</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public async Task CompleteChatForStatisticsAsync(HttpContext httpContext,
|
||||
ThorChatCompletionsRequest request,
|
||||
Guid? userId = null,
|
||||
Guid? sessionId = null,
|
||||
Guid? tokenId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_specialCompatible.Compatible(request);
|
||||
@@ -134,6 +118,7 @@ public class AiGateWayManager : DomainService
|
||||
var chatService =
|
||||
LazyServiceProvider.GetRequiredKeyedService<IChatCompletionService>(modelDescribe.HandlerName);
|
||||
var data = await chatService.CompleteChatAsync(modelDescribe, request, cancellationToken);
|
||||
data.SupplementalMultiplier(modelDescribe.Multiplier);
|
||||
if (userId is not null)
|
||||
{
|
||||
await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId,
|
||||
@@ -142,7 +127,7 @@ public class AiGateWayManager : DomainService
|
||||
Content = sessionId is null ? "不予存储" : request.Messages?.LastOrDefault().Content ?? string.Empty,
|
||||
ModelId = request.Model,
|
||||
TokenUsage = data.Usage,
|
||||
});
|
||||
}, tokenId);
|
||||
|
||||
await _aiMessageManager.CreateSystemMessageAsync(userId.Value, sessionId,
|
||||
new MessageInputDto
|
||||
@@ -151,9 +136,9 @@ public class AiGateWayManager : DomainService
|
||||
sessionId is null ? "不予存储" : data.Choices?.FirstOrDefault()?.Delta.Content ?? string.Empty,
|
||||
ModelId = request.Model,
|
||||
TokenUsage = data.Usage
|
||||
});
|
||||
}, tokenId);
|
||||
|
||||
await _usageStatisticsManager.SetUsageAsync(userId.Value, request.Model, data.Usage);
|
||||
await _usageStatisticsManager.SetUsageAsync(userId.Value, request.Model, data.Usage, tokenId);
|
||||
|
||||
// 扣减尊享token包用量
|
||||
if (PremiumPackageConst.ModeIds.Contains(request.Model))
|
||||
@@ -169,6 +154,7 @@ public class AiGateWayManager : DomainService
|
||||
await response.WriteAsJsonAsync(data, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 聊天完成-缓存处理
|
||||
/// </summary>
|
||||
@@ -176,6 +162,7 @@ public class AiGateWayManager : DomainService
|
||||
/// <param name="request"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="tokenId">Token Id(Web端传null或Guid.Empty)</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public async Task CompleteChatStreamForStatisticsAsync(
|
||||
@@ -183,6 +170,7 @@ public class AiGateWayManager : DomainService
|
||||
ThorChatCompletionsRequest request,
|
||||
Guid? userId = null,
|
||||
Guid? sessionId = null,
|
||||
Guid? tokenId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = httpContext.Response;
|
||||
@@ -192,8 +180,12 @@ public class AiGateWayManager : DomainService
|
||||
response.Headers.TryAdd("Connection", "keep-alive");
|
||||
|
||||
|
||||
var gateWay = LazyServiceProvider.GetRequiredService<AiGateWayManager>();
|
||||
var completeChatResponse = gateWay.CompleteChatStreamAsync(request, cancellationToken);
|
||||
_specialCompatible.Compatible(request);
|
||||
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.OpenAi, request.Model);
|
||||
var chatService =
|
||||
LazyServiceProvider.GetRequiredKeyedService<IChatCompletionService>(modelDescribe.HandlerName);
|
||||
|
||||
var completeChatResponse = chatService.CompleteChatStreamAsync(modelDescribe,request, cancellationToken);
|
||||
var tokenUsage = new ThorUsageResponse();
|
||||
|
||||
//缓存队列算法
|
||||
@@ -235,6 +227,7 @@ public class AiGateWayManager : DomainService
|
||||
{
|
||||
await foreach (var data in completeChatResponse)
|
||||
{
|
||||
data.SupplementalMultiplier(modelDescribe.Multiplier);
|
||||
if (data.Usage is not null && (data.Usage.CompletionTokens > 0 || data.Usage.OutputTokens > 0))
|
||||
{
|
||||
tokenUsage = data.Usage;
|
||||
@@ -249,7 +242,7 @@ public class AiGateWayManager : DomainService
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, $"Ai对话异常");
|
||||
var errorContent = $"对话Ai异常,异常信息:\n当前Ai模型:{request.Model}\n异常信息:{e.Message}\n异常堆栈:{e}";
|
||||
var errorContent = $"对话Ai异常,异常信息:\n当前Ai模型:{request.Model}\n异常信息:{e.Message}\n异常堆栈:{e}";
|
||||
var model = new ThorChatCompletionsResponse()
|
||||
{
|
||||
Choices = new List<ThorChatChoiceResponse>()
|
||||
@@ -282,10 +275,10 @@ public class AiGateWayManager : DomainService
|
||||
await _aiMessageManager.CreateUserMessageAsync(userId, sessionId,
|
||||
new MessageInputDto
|
||||
{
|
||||
Content = sessionId is null ? "不予存储" : request.Messages?.LastOrDefault()?.Content ?? string.Empty,
|
||||
Content = sessionId is null ? "不予存储" : request.Messages?.LastOrDefault()?.MessagesStore ?? string.Empty,
|
||||
ModelId = request.Model,
|
||||
TokenUsage = tokenUsage,
|
||||
});
|
||||
}, tokenId);
|
||||
|
||||
await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId,
|
||||
new MessageInputDto
|
||||
@@ -293,9 +286,9 @@ public class AiGateWayManager : DomainService
|
||||
Content = sessionId is null ? "不予存储" : backupSystemContent.ToString(),
|
||||
ModelId = request.Model,
|
||||
TokenUsage = tokenUsage
|
||||
});
|
||||
}, tokenId);
|
||||
|
||||
await _usageStatisticsManager.SetUsageAsync(userId, request.Model, tokenUsage);
|
||||
await _usageStatisticsManager.SetUsageAsync(userId, request.Model, tokenUsage, tokenId);
|
||||
|
||||
// 扣减尊享token包用量
|
||||
if (userId is not null && PremiumPackageConst.ModeIds.Contains(request.Model))
|
||||
@@ -307,8 +300,8 @@ public class AiGateWayManager : DomainService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 图片生成
|
||||
/// </summary>
|
||||
@@ -316,10 +309,11 @@ public class AiGateWayManager : DomainService
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="tokenId">Token Id(Web端传null或Guid.Empty)</param>
|
||||
/// <exception cref="BusinessException"></exception>
|
||||
/// <exception cref="Exception"></exception>
|
||||
public async Task CreateImageForStatisticsAsync(HttpContext context, Guid? userId, Guid? sessionId,
|
||||
ImageCreateRequest request)
|
||||
ImageCreateRequest request, Guid? tokenId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -347,7 +341,7 @@ public class AiGateWayManager : DomainService
|
||||
Content = sessionId is null ? "不予存储" : request.Prompt,
|
||||
ModelId = model,
|
||||
TokenUsage = response.Usage,
|
||||
});
|
||||
}, tokenId);
|
||||
|
||||
await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId,
|
||||
new MessageInputDto
|
||||
@@ -355,9 +349,9 @@ public class AiGateWayManager : DomainService
|
||||
Content = sessionId is null ? "不予存储" : response.Results?.FirstOrDefault()?.Url,
|
||||
ModelId = model,
|
||||
TokenUsage = response.Usage
|
||||
});
|
||||
}, tokenId);
|
||||
|
||||
await _usageStatisticsManager.SetUsageAsync(userId, model, response.Usage);
|
||||
await _usageStatisticsManager.SetUsageAsync(userId, model, response.Usage, tokenId);
|
||||
|
||||
// 扣减尊享token包用量
|
||||
if (userId is not null && PremiumPackageConst.ModeIds.Contains(request.Model))
|
||||
@@ -371,23 +365,24 @@ public class AiGateWayManager : DomainService
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
var errorContent = $"图片生成Ai异常,异常信息:\n当前Ai模型:{request.Model}\n异常信息:{e.Message}\n异常堆栈:{e}";
|
||||
var errorContent = $"图片生成Ai异常,异常信息:\n当前Ai模型:{request.Model}\n异常信息:{e.Message}\n异常堆栈:{e}";
|
||||
throw new UserFriendlyException(errorContent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 向量生成
|
||||
/// </summary>
|
||||
/// <param name="context"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="input"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="tokenId">Token Id(Web端传null或Guid.Empty)</param>
|
||||
/// <exception cref="Exception"></exception>
|
||||
/// <exception cref="BusinessException"></exception>
|
||||
public async Task EmbeddingForStatisticsAsync(HttpContext context, Guid? userId, Guid? sessionId,
|
||||
ThorEmbeddingInput input)
|
||||
ThorEmbeddingInput input, Guid? tokenId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -471,7 +466,7 @@ public class AiGateWayManager : DomainService
|
||||
// TokenUsage = usage
|
||||
// });
|
||||
|
||||
await _usageStatisticsManager.SetUsageAsync(userId, input.Model, usage);
|
||||
await _usageStatisticsManager.SetUsageAsync(userId, input.Model, usage, tokenId);
|
||||
}
|
||||
catch (ThorRateLimitException)
|
||||
{
|
||||
@@ -483,33 +478,11 @@ public class AiGateWayManager : DomainService
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
var errorContent = $"嵌入Ai异常,异常信息:\n当前Ai模型:{input.Model}\n异常信息:{e.Message}\n异常堆栈:{e}";
|
||||
var errorContent = $"嵌入Ai异常,异常信息:\n当前Ai模型:{input.Model}\n异常信息:{e.Message}\n异常堆栈:{e}";
|
||||
throw new UserFriendlyException(errorContent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Anthropic聊天完成-流式
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public async IAsyncEnumerable<(string, AnthropicStreamDto?)> AnthropicCompleteChatStreamAsync(
|
||||
AnthropicInput request,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
_specialCompatible.AnthropicCompatible(request);
|
||||
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.Claude, request.Model);
|
||||
var chatService =
|
||||
LazyServiceProvider.GetRequiredKeyedService<IAnthropicChatCompletionService>(modelDescribe.HandlerName);
|
||||
|
||||
await foreach (var result in chatService.StreamChatCompletionsAsync(modelDescribe, request, cancellationToken))
|
||||
{
|
||||
yield return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Anthropic聊天完成-非流式
|
||||
@@ -518,12 +491,14 @@ public class AiGateWayManager : DomainService
|
||||
/// <param name="request"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="tokenId">Token Id(Web端传null或Guid.Empty)</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public async Task AnthropicCompleteChatForStatisticsAsync(HttpContext httpContext,
|
||||
AnthropicInput request,
|
||||
Guid? userId = null,
|
||||
Guid? sessionId = null,
|
||||
Guid? tokenId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_specialCompatible.AnthropicCompatible(request);
|
||||
@@ -534,25 +509,28 @@ public class AiGateWayManager : DomainService
|
||||
var chatService =
|
||||
LazyServiceProvider.GetRequiredKeyedService<IAnthropicChatCompletionService>(modelDescribe.HandlerName);
|
||||
var data = await chatService.ChatCompletionsAsync(modelDescribe, request, cancellationToken);
|
||||
|
||||
data.SupplementalMultiplier(modelDescribe.Multiplier);
|
||||
|
||||
if (userId is not null)
|
||||
{
|
||||
await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId,
|
||||
new MessageInputDto
|
||||
{
|
||||
Content = sessionId is null ? "不予存储" : request.Messages?.FirstOrDefault()?.Content ?? string.Empty,
|
||||
Content = "不予存储",
|
||||
ModelId = request.Model,
|
||||
TokenUsage = data.TokenUsage,
|
||||
});
|
||||
}, tokenId);
|
||||
|
||||
await _aiMessageManager.CreateSystemMessageAsync(userId.Value, sessionId,
|
||||
new MessageInputDto
|
||||
{
|
||||
Content = sessionId is null ? "不予存储" : data.content?.FirstOrDefault()?.text,
|
||||
Content = "不予存储",
|
||||
ModelId = request.Model,
|
||||
TokenUsage = data.TokenUsage
|
||||
});
|
||||
}, tokenId);
|
||||
|
||||
await _usageStatisticsManager.SetUsageAsync(userId.Value, request.Model, data.TokenUsage);
|
||||
await _usageStatisticsManager.SetUsageAsync(userId.Value, request.Model, data.TokenUsage, tokenId);
|
||||
|
||||
// 扣减尊享token包用量
|
||||
var totalTokens = data.TokenUsage.TotalTokens ?? 0;
|
||||
@@ -565,6 +543,7 @@ public class AiGateWayManager : DomainService
|
||||
await response.WriteAsJsonAsync(data, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Anthropic聊天完成-缓存处理
|
||||
/// </summary>
|
||||
@@ -572,6 +551,7 @@ public class AiGateWayManager : DomainService
|
||||
/// <param name="request"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="tokenId">Token Id(Web端传null或Guid.Empty)</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public async Task AnthropicCompleteChatStreamForStatisticsAsync(
|
||||
@@ -579,6 +559,7 @@ public class AiGateWayManager : DomainService
|
||||
AnthropicInput request,
|
||||
Guid? userId = null,
|
||||
Guid? sessionId = null,
|
||||
Guid? tokenId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = httpContext.Response;
|
||||
@@ -586,16 +567,20 @@ public class AiGateWayManager : DomainService
|
||||
response.ContentType = "text/event-stream;charset=utf-8;";
|
||||
response.Headers.TryAdd("Cache-Control", "no-cache");
|
||||
response.Headers.TryAdd("Connection", "keep-alive");
|
||||
|
||||
|
||||
var gateWay = LazyServiceProvider.GetRequiredService<AiGateWayManager>();
|
||||
var completeChatResponse = gateWay.AnthropicCompleteChatStreamAsync(request, cancellationToken);
|
||||
|
||||
_specialCompatible.AnthropicCompatible(request);
|
||||
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.Claude, request.Model);
|
||||
var chatService =
|
||||
LazyServiceProvider.GetRequiredKeyedService<IAnthropicChatCompletionService>(modelDescribe.HandlerName);
|
||||
|
||||
var completeChatResponse = chatService.StreamChatCompletionsAsync(modelDescribe,request, cancellationToken);
|
||||
ThorUsageResponse? tokenUsage = null;
|
||||
StringBuilder backupSystemContent = new StringBuilder();
|
||||
try
|
||||
{
|
||||
await foreach (var responseResult in completeChatResponse)
|
||||
{
|
||||
responseResult.Item2.SupplementalMultiplier(modelDescribe.Multiplier);
|
||||
//message_start是为了保底机制
|
||||
if (responseResult.Item1.Contains("message_delta") || responseResult.Item1.Contains("message_start"))
|
||||
{
|
||||
@@ -610,27 +595,27 @@ public class AiGateWayManager : DomainService
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, $"Ai对话异常");
|
||||
var errorContent = $"对话Ai异常,异常信息:\n当前Ai模型:{request.Model}\n异常信息:{e.Message}\n异常堆栈:{e}";
|
||||
var errorContent = $"对话Ai异常,异常信息:\n当前Ai模型:{request.Model}\n异常信息:{e.Message}\n异常堆栈:{e}";
|
||||
throw new UserFriendlyException(errorContent);
|
||||
}
|
||||
|
||||
await _aiMessageManager.CreateUserMessageAsync(userId, sessionId,
|
||||
new MessageInputDto
|
||||
{
|
||||
Content = sessionId is null ? "不予存储" : request.Messages?.LastOrDefault()?.Content ?? string.Empty,
|
||||
Content = "不予存储",
|
||||
ModelId = request.Model,
|
||||
TokenUsage = tokenUsage,
|
||||
});
|
||||
}, tokenId);
|
||||
|
||||
await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId,
|
||||
new MessageInputDto
|
||||
{
|
||||
Content = sessionId is null ? "不予存储" : backupSystemContent.ToString(),
|
||||
Content = "不予存储",
|
||||
ModelId = request.Model,
|
||||
TokenUsage = tokenUsage
|
||||
});
|
||||
}, tokenId);
|
||||
|
||||
await _usageStatisticsManager.SetUsageAsync(userId, request.Model, tokenUsage);
|
||||
await _usageStatisticsManager.SetUsageAsync(userId, request.Model, tokenUsage, tokenId);
|
||||
|
||||
// 扣减尊享token包用量
|
||||
if (userId.HasValue && tokenUsage is not null)
|
||||
@@ -643,7 +628,167 @@ public class AiGateWayManager : DomainService
|
||||
}
|
||||
}
|
||||
|
||||
#region Anthropic格式Http响应
|
||||
|
||||
/// <summary>
|
||||
/// OpenAi 响应-非流式-缓存处理
|
||||
/// </summary>
|
||||
/// <param name="httpContext"></param>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="tokenId"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
public async Task OpenAiResponsesAsyncForStatisticsAsync(HttpContext httpContext,
|
||||
OpenAiResponsesInput request,
|
||||
Guid? userId = null,
|
||||
Guid? sessionId = null,
|
||||
Guid? tokenId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// _specialCompatible.AnthropicCompatible(request);
|
||||
var response = httpContext.Response;
|
||||
// 设置响应头,声明是 json
|
||||
//response.ContentType = "application/json; charset=UTF-8";
|
||||
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.Response, request.Model);
|
||||
|
||||
var chatService =
|
||||
LazyServiceProvider.GetRequiredKeyedService<IOpenAiResponseService>(modelDescribe.HandlerName);
|
||||
var data = await chatService.ResponsesAsync(modelDescribe, request, cancellationToken);
|
||||
|
||||
data.SupplementalMultiplier(modelDescribe.Multiplier);
|
||||
|
||||
var tokenUsage= new ThorUsageResponse
|
||||
{
|
||||
InputTokens = data.Usage.InputTokens,
|
||||
OutputTokens = data.Usage.OutputTokens,
|
||||
TotalTokens = data.Usage.InputTokens + data.Usage.OutputTokens,
|
||||
};
|
||||
if (userId is not null)
|
||||
{
|
||||
await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId,
|
||||
new MessageInputDto
|
||||
{
|
||||
Content = "不予存储",
|
||||
ModelId = request.Model,
|
||||
TokenUsage = tokenUsage,
|
||||
}, tokenId);
|
||||
|
||||
await _aiMessageManager.CreateSystemMessageAsync(userId.Value, sessionId,
|
||||
new MessageInputDto
|
||||
{
|
||||
Content = "不予存储",
|
||||
ModelId = request.Model,
|
||||
TokenUsage = tokenUsage
|
||||
}, tokenId);
|
||||
|
||||
await _usageStatisticsManager.SetUsageAsync(userId.Value, request.Model, tokenUsage, tokenId);
|
||||
|
||||
// 扣减尊享token包用量
|
||||
var totalTokens = tokenUsage.TotalTokens ?? 0;
|
||||
if (totalTokens > 0)
|
||||
{
|
||||
await PremiumPackageManager.TryConsumeTokensAsync(userId.Value, totalTokens);
|
||||
}
|
||||
}
|
||||
|
||||
await response.WriteAsJsonAsync(data, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// OpenAi响应-流式-缓存处理
|
||||
/// </summary>
|
||||
/// <param name="httpContext"></param>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="tokenId">Token Id(Web端传null或Guid.Empty)</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public async Task OpenAiResponsesStreamForStatisticsAsync(
|
||||
HttpContext httpContext,
|
||||
OpenAiResponsesInput request,
|
||||
Guid? userId = null,
|
||||
Guid? sessionId = null,
|
||||
Guid? tokenId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = httpContext.Response;
|
||||
// 设置响应头,声明是 SSE 流
|
||||
response.ContentType = "text/event-stream;charset=utf-8;";
|
||||
response.Headers.TryAdd("Cache-Control", "no-cache");
|
||||
response.Headers.TryAdd("Connection", "keep-alive");
|
||||
|
||||
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.Response, request.Model);
|
||||
var chatService =
|
||||
LazyServiceProvider.GetRequiredKeyedService<IOpenAiResponseService>(modelDescribe.HandlerName);
|
||||
|
||||
var completeChatResponse = chatService.ResponsesStreamAsync(modelDescribe,request, cancellationToken);
|
||||
ThorUsageResponse? tokenUsage = null;
|
||||
try
|
||||
{
|
||||
await foreach (var responseResult in completeChatResponse)
|
||||
{
|
||||
//message_start是为了保底机制
|
||||
if (responseResult.Item1.Contains("response.completed"))
|
||||
{
|
||||
var obj = responseResult.Item2!.Value;
|
||||
int inputTokens = obj.GetPath("response","usage","input_tokens").GetInt();
|
||||
int outputTokens = obj.GetPath("response","usage","output_tokens").GetInt();
|
||||
inputTokens=Convert.ToInt32(inputTokens * modelDescribe.Multiplier);
|
||||
outputTokens=Convert.ToInt32(outputTokens * modelDescribe.Multiplier);
|
||||
tokenUsage = new ThorUsageResponse
|
||||
{
|
||||
PromptTokens =inputTokens,
|
||||
InputTokens = inputTokens,
|
||||
OutputTokens = outputTokens,
|
||||
CompletionTokens = outputTokens,
|
||||
TotalTokens = inputTokens+outputTokens,
|
||||
};
|
||||
}
|
||||
|
||||
await WriteAsEventStreamDataAsync(httpContext, responseResult.Item1, responseResult.Item2,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, $"Ai响应异常");
|
||||
var errorContent = $"响应Ai异常,异常信息:\n当前Ai模型:{request.Model}\n异常信息:{e.Message}\n异常堆栈:{e}";
|
||||
throw new UserFriendlyException(errorContent);
|
||||
}
|
||||
|
||||
await _aiMessageManager.CreateUserMessageAsync(userId, sessionId,
|
||||
new MessageInputDto
|
||||
{
|
||||
Content = "不予存储" ,
|
||||
ModelId = request.Model,
|
||||
TokenUsage = tokenUsage,
|
||||
}, tokenId);
|
||||
|
||||
await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId,
|
||||
new MessageInputDto
|
||||
{
|
||||
Content = "不予存储" ,
|
||||
ModelId = request.Model,
|
||||
TokenUsage = tokenUsage
|
||||
}, tokenId);
|
||||
|
||||
await _usageStatisticsManager.SetUsageAsync(userId, request.Model, tokenUsage, tokenId);
|
||||
|
||||
// 扣减尊享token包用量
|
||||
if (userId.HasValue && tokenUsage is not null)
|
||||
{
|
||||
var totalTokens = tokenUsage.TotalTokens ?? 0;
|
||||
if (tokenUsage.TotalTokens > 0)
|
||||
{
|
||||
await PremiumPackageManager.TryConsumeTokensAsync(userId.Value, totalTokens);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#region 流式传输格式Http响应
|
||||
|
||||
private static readonly byte[] EventPrefix = "event: "u8.ToArray();
|
||||
private static readonly byte[] DataPrefix = "data: "u8.ToArray();
|
||||
@@ -658,7 +803,6 @@ public class AiGateWayManager : DomainService
|
||||
string @event,
|
||||
T value,
|
||||
CancellationToken cancellationToken = default)
|
||||
where T : class
|
||||
{
|
||||
var response = context.Response;
|
||||
var bodyStream = response.Body;
|
||||
|
||||
@@ -19,28 +19,30 @@ public class AiMessageManager : DomainService
|
||||
/// <summary>
|
||||
/// 创建系统消息
|
||||
/// </summary>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="input"></param>
|
||||
/// <param name="userId">用户Id</param>
|
||||
/// <param name="sessionId">会话Id</param>
|
||||
/// <param name="input">消息输入</param>
|
||||
/// <param name="tokenId">Token Id(Web端传Guid.Empty)</param>
|
||||
/// <returns></returns>
|
||||
public async Task CreateSystemMessageAsync(Guid? userId, Guid? sessionId, MessageInputDto input)
|
||||
public async Task CreateSystemMessageAsync(Guid? userId, Guid? sessionId, MessageInputDto input, Guid? tokenId = null)
|
||||
{
|
||||
input.Role = "system";
|
||||
var message = new MessageAggregateRoot(userId, sessionId, input.Content, input.Role, input.ModelId,input.TokenUsage);
|
||||
var message = new MessageAggregateRoot(userId, sessionId, input.Content, input.Role, input.ModelId, input.TokenUsage, tokenId);
|
||||
await _repository.InsertAsync(message);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 创建系统消息
|
||||
/// 创建用户消息
|
||||
/// </summary>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="input"></param>
|
||||
/// <param name="userId">用户Id</param>
|
||||
/// <param name="sessionId">会话Id</param>
|
||||
/// <param name="input">消息输入</param>
|
||||
/// <param name="tokenId">Token Id(Web端传Guid.Empty)</param>
|
||||
/// <returns></returns>
|
||||
public async Task CreateUserMessageAsync(Guid? userId, Guid? sessionId, MessageInputDto input)
|
||||
public async Task CreateUserMessageAsync(Guid? userId, Guid? sessionId, MessageInputDto input, Guid? tokenId = null)
|
||||
{
|
||||
input.Role = "user";
|
||||
var message = new MessageAggregateRoot(userId, sessionId, input.Content, input.Role, input.ModelId,input.TokenUsage);
|
||||
var message = new MessageAggregateRoot(userId, sessionId, input.Content, input.Role, input.ModelId, input.TokenUsage, tokenId);
|
||||
await _repository.InsertAsync(message);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +1,134 @@
|
||||
using Volo.Abp.Domain.Services;
|
||||
using Volo.Abp.Users;
|
||||
using SqlSugar;
|
||||
using Volo.Abp.Domain.Services;
|
||||
using Yi.Framework.AiHub.Domain.Entities;
|
||||
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Consts;
|
||||
using Yi.Framework.SqlSugarCore.Abstractions;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.Managers;
|
||||
|
||||
/// <summary>
|
||||
/// Token验证结果
|
||||
/// </summary>
|
||||
public class TokenValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户Id
|
||||
/// </summary>
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Token Id
|
||||
/// </summary>
|
||||
public Guid TokenId { get; set; }
|
||||
}
|
||||
|
||||
public class TokenManager : DomainService
|
||||
{
|
||||
private readonly ISqlSugarRepository<TokenAggregateRoot> _tokenRepository;
|
||||
private readonly ISqlSugarRepository<UsageStatisticsAggregateRoot> _usageStatisticsRepository;
|
||||
|
||||
public TokenManager(ISqlSugarRepository<TokenAggregateRoot> tokenRepository)
|
||||
public TokenManager(
|
||||
ISqlSugarRepository<TokenAggregateRoot> tokenRepository,
|
||||
ISqlSugarRepository<UsageStatisticsAggregateRoot> usageStatisticsRepository)
|
||||
{
|
||||
_tokenRepository = tokenRepository;
|
||||
_usageStatisticsRepository = usageStatisticsRepository;
|
||||
}
|
||||
|
||||
public async Task<string?> GetAsync(Guid userId)
|
||||
{
|
||||
var entity = await _tokenRepository._DbQueryable.FirstAsync(x => x.UserId == userId);
|
||||
if (entity is not null)
|
||||
{
|
||||
return entity.Token;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CreateAsync(Guid userId)
|
||||
{
|
||||
var entity = await _tokenRepository._DbQueryable.FirstAsync(x => x.UserId == userId);
|
||||
if (entity is not null)
|
||||
{
|
||||
entity.ResetToken();
|
||||
await _tokenRepository.UpdateAsync(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
var token = new TokenAggregateRoot(userId);
|
||||
await _tokenRepository.InsertAsync(token);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Guid> GetUserIdAsync(string? token)
|
||||
/// <summary>
|
||||
/// 验证Token并返回用户Id和TokenId
|
||||
/// </summary>
|
||||
/// <param name="token">Token密钥</param>
|
||||
/// <param name="modelId">模型Id(用于判断是否是尊享模型需要检查额度)</param>
|
||||
/// <returns>Token验证结果</returns>
|
||||
public async Task<TokenValidationResult> ValidateTokenAsync(string? token, string? modelId = null)
|
||||
{
|
||||
if (token is null)
|
||||
{
|
||||
throw new UserFriendlyException("当前请求未包含token", "401");
|
||||
}
|
||||
|
||||
if (token.StartsWith("yi-"))
|
||||
if (!token.StartsWith("yi-"))
|
||||
{
|
||||
var entity = await _tokenRepository._DbQueryable.Where(x => x.Token == token).FirstAsync();
|
||||
if (entity is null)
|
||||
{
|
||||
throw new UserFriendlyException("当前请求token无效", "401");
|
||||
}
|
||||
|
||||
return entity.UserId;
|
||||
throw new UserFriendlyException("当前请求token非法", "401");
|
||||
}
|
||||
throw new UserFriendlyException("当前请求token非法", "401");
|
||||
|
||||
var entity = await _tokenRepository._DbQueryable
|
||||
.Where(x => x.Token == token)
|
||||
.FirstAsync();
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
throw new UserFriendlyException("当前请求token无效", "401");
|
||||
}
|
||||
|
||||
// 检查Token是否被禁用
|
||||
if (entity.IsDisabled)
|
||||
{
|
||||
throw new UserFriendlyException("当前Token已被禁用,请启用后再使用", "403");
|
||||
}
|
||||
|
||||
// 检查Token是否过期
|
||||
if (entity.ExpireTime.HasValue && entity.ExpireTime.Value < DateTime.Now)
|
||||
{
|
||||
throw new UserFriendlyException("当前Token已过期,请更新过期时间或创建新的Token", "403");
|
||||
}
|
||||
|
||||
// 如果是尊享模型且Token设置了额度限制,检查是否超限
|
||||
if (!string.IsNullOrEmpty(modelId) &&
|
||||
PremiumPackageConst.ModeIds.Contains(modelId) &&
|
||||
entity.PremiumQuotaLimit.HasValue)
|
||||
{
|
||||
var usedQuota = await GetTokenPremiumUsedQuotaAsync(entity.UserId, entity.Id);
|
||||
if (usedQuota >= entity.PremiumQuotaLimit.Value)
|
||||
{
|
||||
throw new UserFriendlyException($"当前Token的尊享包额度已用完(已使用:{usedQuota},限制:{entity.PremiumQuotaLimit.Value}),请调整额度限制或使用其他Token", "403");
|
||||
}
|
||||
}
|
||||
|
||||
return new TokenValidationResult
|
||||
{
|
||||
UserId = entity.UserId,
|
||||
TokenId = entity.Id
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取Token的尊享包已使用额度
|
||||
/// </summary>
|
||||
private async Task<long> GetTokenPremiumUsedQuotaAsync(Guid userId, Guid tokenId)
|
||||
{
|
||||
var premiumModelIds = PremiumPackageConst.ModeIds;
|
||||
|
||||
var usedQuota = await _usageStatisticsRepository._DbQueryable
|
||||
.Where(x => x.UserId == userId && x.TokenId == tokenId && premiumModelIds.Contains(x.ModelId))
|
||||
.SumAsync(x => x.TotalTokenCount);
|
||||
|
||||
return usedQuota;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户的Token(兼容旧接口,返回第一个可用的Token)
|
||||
/// </summary>
|
||||
[Obsolete("请使用 ValidateTokenAsync 方法")]
|
||||
public async Task<string?> GetAsync(Guid userId)
|
||||
{
|
||||
var entity = await _tokenRepository._DbQueryable
|
||||
.Where(x => x.UserId == userId && !x.IsDisabled)
|
||||
.OrderBy(x => x.CreationTime)
|
||||
.FirstAsync();
|
||||
|
||||
return entity?.Token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户Id(兼容旧接口)
|
||||
/// </summary>
|
||||
[Obsolete("请使用 ValidateTokenAsync 方法")]
|
||||
public async Task<Guid> GetUserIdAsync(string? token)
|
||||
{
|
||||
var result = await ValidateTokenAsync(token);
|
||||
return result.UserId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ public class UsageStatisticsManager : DomainService
|
||||
private IDistributedLockProvider DistributedLock =>
|
||||
LazyServiceProvider.LazyGetRequiredService<IDistributedLockProvider>();
|
||||
|
||||
public async Task SetUsageAsync(Guid? userId, string modelId, ThorUsageResponse? tokenUsage)
|
||||
public async Task SetUsageAsync(Guid? userId, string modelId, ThorUsageResponse? tokenUsage, Guid? tokenId = null)
|
||||
{
|
||||
var actualTokenId = tokenId ?? Guid.Empty;
|
||||
|
||||
long inputTokenCount = tokenUsage?.PromptTokens
|
||||
?? tokenUsage?.InputTokens
|
||||
?? 0;
|
||||
@@ -28,10 +30,10 @@ public class UsageStatisticsManager : DomainService
|
||||
?? tokenUsage?.OutputTokens
|
||||
?? 0;
|
||||
|
||||
await using (await DistributedLock.AcquireLockAsync($"UsageStatistics:{userId?.ToString()}"))
|
||||
await using (await DistributedLock.AcquireLockAsync($"UsageStatistics:{userId?.ToString()}:{actualTokenId}:{modelId}"))
|
||||
{
|
||||
var entity = await _repository._DbQueryable.FirstAsync(x => x.UserId == userId && x.ModelId == modelId);
|
||||
//存在数据,更细
|
||||
var entity = await _repository._DbQueryable.FirstAsync(x => x.UserId == userId && x.ModelId == modelId && x.TokenId == actualTokenId);
|
||||
//存在数据,更新
|
||||
if (entity is not null)
|
||||
{
|
||||
entity.AddOnceChat(inputTokenCount, outputTokenCount);
|
||||
@@ -40,7 +42,7 @@ public class UsageStatisticsManager : DomainService
|
||||
//不存在插入
|
||||
else
|
||||
{
|
||||
var usage = new UsageStatisticsAggregateRoot(userId, modelId);
|
||||
var usage = new UsageStatisticsAggregateRoot(userId, modelId, actualTokenId);
|
||||
usage.AddOnceChat(inputTokenCount, outputTokenCount);
|
||||
await _repository.InsertAsync(usage);
|
||||
}
|
||||
|
||||
@@ -44,17 +44,22 @@ namespace Yi.Framework.AiHub.Domain
|
||||
nameof(OpenAiChatCompletionsService));
|
||||
services.AddKeyedTransient<IChatCompletionService, ClaudiaChatCompletionsService>(
|
||||
nameof(ClaudiaChatCompletionsService));
|
||||
|
||||
#endregion
|
||||
|
||||
#region Anthropic ChatCompletion
|
||||
|
||||
services.AddKeyedTransient<IAnthropicChatCompletionService, CustomOpenAIAnthropicChatCompletionsService>(
|
||||
nameof(CustomOpenAIAnthropicChatCompletionsService));
|
||||
|
||||
services.AddKeyedTransient<IAnthropicChatCompletionService, AnthropicChatCompletionsService>(
|
||||
nameof(AnthropicChatCompletionsService));
|
||||
|
||||
#endregion
|
||||
|
||||
#region OpenAi Response
|
||||
|
||||
services.AddKeyedTransient<IOpenAiResponseService, OpenAiResponseService>(
|
||||
nameof(OpenAiResponseService));
|
||||
|
||||
#endregion
|
||||
|
||||
#region Image
|
||||
|
||||
@@ -73,6 +78,34 @@ namespace Yi.Framework.AiHub.Domain
|
||||
//ai模型特殊性兼容处理
|
||||
Configure<SpecialCompatibleOptions>(options =>
|
||||
{
|
||||
options.Handles.Add(request =>
|
||||
{
|
||||
if (request.Model == "gpt-5.1-chat")
|
||||
{
|
||||
request.Temperature = null;
|
||||
request.TopP = null;
|
||||
request.MaxCompletionTokens = request.MaxTokens;
|
||||
request.MaxTokens = null;
|
||||
request.PresencePenalty = null;
|
||||
}
|
||||
});
|
||||
|
||||
options.Handles.Add(request =>
|
||||
{
|
||||
if (request.Model =="gpt-5-mini")
|
||||
{
|
||||
request.Temperature = null;
|
||||
request.TopP = null;
|
||||
}
|
||||
});
|
||||
options.Handles.Add(request =>
|
||||
{
|
||||
if (request.Model == "databricks-claude-sonnet-4")
|
||||
{
|
||||
request.PresencePenalty = null;
|
||||
}
|
||||
});
|
||||
|
||||
options.Handles.Add(request =>
|
||||
{
|
||||
if (request.Model == "o1")
|
||||
@@ -101,9 +134,9 @@ namespace Yi.Framework.AiHub.Domain
|
||||
});
|
||||
options.Handles.Add(request =>
|
||||
{
|
||||
if (request.MaxTokens >= 16384)
|
||||
if (request.MaxTokens > 128000)
|
||||
{
|
||||
request.MaxTokens = 16384;
|
||||
request.MaxTokens = 128000;
|
||||
}
|
||||
});
|
||||
options.AnthropicHandles.add(request =>
|
||||
@@ -128,7 +161,7 @@ namespace Yi.Framework.AiHub.Domain
|
||||
{
|
||||
builder.ConfigureHttpClient(client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("User-Agent","Apifox/1.0.0 (https://apifox.com)");
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "Apifox/1.0.0 (https://apifox.com)");
|
||||
client.Timeout = TimeSpan.FromMinutes(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ using Mapster;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SqlSugar;
|
||||
using TencentCloud.Pds.V20210701.Models;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
using Volo.Abp.EventBus.Local;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using TencentCloud.Tbm.V20180129.Models;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.Domain.Entities.Events;
|
||||
using Volo.Abp.EventBus;
|
||||
using Volo.Abp.EventBus.Local;
|
||||
|
||||
@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.VisualBasic;
|
||||
using TencentCloud.Mna.V20210119.Models;
|
||||
using Volo.Abp.Application.Services;
|
||||
using Volo.Abp.Caching;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SqlSugar;
|
||||
using TencentCloud.Tcr.V20190924.Models;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
using Volo.Abp.Caching;
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using TencentCloud.Common.Profile;
|
||||
using TencentCloud.Common;
|
||||
using TencentCloud.Sms.V20210111.Models;
|
||||
using TencentCloud.Sms.V20210111;
|
||||
using Volo.Abp.Domain.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Yi.Framework.Rbac.Domain.Managers
|
||||
{
|
||||
public class TencentCloudManager : DomainService
|
||||
{
|
||||
private ILogger<TencentCloudManager> _logger;
|
||||
public TencentCloudManager(ILogger<TencentCloudManager> logger)
|
||||
{
|
||||
_logger= logger;
|
||||
}
|
||||
|
||||
public async Task SendSmsAsync()
|
||||
{
|
||||
|
||||
try
|
||||
{
|
||||
// 实例化一个认证对象,入参需要传入腾讯云账户 SecretId 和 SecretKey,此处还需注意密钥对的保密
|
||||
// 代码泄露可能会导致 SecretId 和 SecretKey 泄露,并威胁账号下所有资源的安全性。以下代码示例仅供参考,建议采用更安全的方式来使用密钥,请参见:https://cloud.tencent.com/document/product/1278/85305
|
||||
// 密钥可前往官网控制台 https://console.cloud.tencent.com/cam/capi 进行获取
|
||||
Credential cred = new Credential
|
||||
{
|
||||
SecretId = "SecretId",
|
||||
SecretKey = "SecretKey"
|
||||
};
|
||||
// 实例化一个client选项,可选的,没有特殊需求可以跳过
|
||||
ClientProfile clientProfile = new ClientProfile();
|
||||
// 实例化一个http选项,可选的,没有特殊需求可以跳过
|
||||
HttpProfile httpProfile = new HttpProfile();
|
||||
httpProfile.Endpoint = ("sms.tencentcloudapi.com");
|
||||
clientProfile.HttpProfile = httpProfile;
|
||||
|
||||
// 实例化要请求产品的client对象,clientProfile是可选的
|
||||
SmsClient client = new SmsClient(cred, "", clientProfile);
|
||||
// 实例化一个请求对象,每个接口都会对应一个request对象
|
||||
SendSmsRequest req = new SendSmsRequest();
|
||||
|
||||
// 返回的resp是一个SendSmsResponse的实例,与请求对象对应
|
||||
SendSmsResponse resp = await client.SendSms(req);
|
||||
// 输出json格式的字符串回包
|
||||
_logger.LogInformation("腾讯云Sms返回:"+AbstractModel.ToJsonString(resp));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e,e.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// using System;
|
||||
// using System.Collections.Generic;
|
||||
// using System.Linq;
|
||||
// using System.Text;
|
||||
// using System.Threading.Tasks;
|
||||
// using TencentCloud.Common.Profile;
|
||||
// using TencentCloud.Common;
|
||||
// using TencentCloud.Sms.V20210111.Models;
|
||||
// using TencentCloud.Sms.V20210111;
|
||||
// using Volo.Abp.Domain.Services;
|
||||
// using Microsoft.Extensions.Logging;
|
||||
//
|
||||
// namespace Yi.Framework.Rbac.Domain.Managers
|
||||
// {
|
||||
// public class TencentCloudManager : DomainService
|
||||
// {
|
||||
// private ILogger<TencentCloudManager> _logger;
|
||||
// public TencentCloudManager(ILogger<TencentCloudManager> logger)
|
||||
// {
|
||||
// _logger= logger;
|
||||
// }
|
||||
//
|
||||
// public async Task SendSmsAsync()
|
||||
// {
|
||||
//
|
||||
// try
|
||||
// {
|
||||
// // 实例化一个认证对象,入参需要传入腾讯云账户 SecretId 和 SecretKey,此处还需注意密钥对的保密
|
||||
// // 代码泄露可能会导致 SecretId 和 SecretKey 泄露,并威胁账号下所有资源的安全性。以下代码示例仅供参考,建议采用更安全的方式来使用密钥,请参见:https://cloud.tencent.com/document/product/1278/85305
|
||||
// // 密钥可前往官网控制台 https://console.cloud.tencent.com/cam/capi 进行获取
|
||||
// Credential cred = new Credential
|
||||
// {
|
||||
// SecretId = "SecretId",
|
||||
// SecretKey = "SecretKey"
|
||||
// };
|
||||
// // 实例化一个client选项,可选的,没有特殊需求可以跳过
|
||||
// ClientProfile clientProfile = new ClientProfile();
|
||||
// // 实例化一个http选项,可选的,没有特殊需求可以跳过
|
||||
// HttpProfile httpProfile = new HttpProfile();
|
||||
// httpProfile.Endpoint = ("sms.tencentcloudapi.com");
|
||||
// clientProfile.HttpProfile = httpProfile;
|
||||
//
|
||||
// // 实例化要请求产品的client对象,clientProfile是可选的
|
||||
// SmsClient client = new SmsClient(cred, "", clientProfile);
|
||||
// // 实例化一个请求对象,每个接口都会对应一个request对象
|
||||
// SendSmsRequest req = new SendSmsRequest();
|
||||
//
|
||||
// // 返回的resp是一个SendSmsResponse的实例,与请求对象对应
|
||||
// SendSmsResponse resp = await client.SendSms(req);
|
||||
// // 输出json格式的字符串回包
|
||||
// _logger.LogInformation("腾讯云Sms返回:"+AbstractModel.ToJsonString(resp));
|
||||
// }
|
||||
// catch (Exception e)
|
||||
// {
|
||||
// _logger.LogError(e,e.ToString());
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<PackageReference Include="IPTools.China" Version="1.6.0" />
|
||||
|
||||
<PackageReference Include="TencentCloudSDK" Version="3.0.966" />
|
||||
<!-- <PackageReference Include="TencentCloudSDK" Version="3.0.966" />-->
|
||||
|
||||
<PackageReference Include="UAParser" Version="3.1.47" />
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ using Yi.Abp.SqlsugarCore;
|
||||
using Yi.Framework.AiHub.Application;
|
||||
using Yi.Framework.AiHub.Application.Services;
|
||||
using Yi.Framework.AiHub.Domain.Entities;
|
||||
using Yi.Framework.AiHub.Domain.Entities.Chat;
|
||||
using Yi.Framework.AiHub.Domain.Entities.Model;
|
||||
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
|
||||
using Yi.Framework.AspNetCore;
|
||||
using Yi.Framework.AspNetCore.Authentication.OAuth;
|
||||
using Yi.Framework.AspNetCore.Authentication.OAuth.Gitee;
|
||||
@@ -285,19 +288,19 @@ namespace Yi.Abp.Web
|
||||
};
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
OnMessageReceived = messageContext =>
|
||||
{
|
||||
//优先Query中获取,再去cookies中获取
|
||||
var accessToken = context.Request.Query["access_token"];
|
||||
var accessToken = messageContext.Request.Query["access_token"];
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
context.Token = accessToken;
|
||||
messageContext.Token = accessToken;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (context.Request.Cookies.TryGetValue("Token", out var cookiesToken))
|
||||
if (messageContext.Request.Cookies.TryGetValue("Token", out var cookiesToken))
|
||||
{
|
||||
context.Token = cookiesToken;
|
||||
messageContext.Token = cookiesToken;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,19 +321,19 @@ namespace Yi.Abp.Web
|
||||
};
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
OnMessageReceived = messageContext =>
|
||||
{
|
||||
var refresh_token = context.Request.Headers["refresh_token"];
|
||||
if (!string.IsNullOrEmpty(refresh_token))
|
||||
var headerRefreshToken = messageContext.Request.Headers["refresh_token"];
|
||||
if (!string.IsNullOrEmpty(headerRefreshToken))
|
||||
{
|
||||
context.Token = refresh_token;
|
||||
messageContext.Token = headerRefreshToken;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var refreshToken = context.Request.Query["refresh_token"];
|
||||
if (!string.IsNullOrEmpty(refreshToken))
|
||||
var queryRefreshToken = messageContext.Request.Query["refresh_token"];
|
||||
if (!string.IsNullOrEmpty(queryRefreshToken))
|
||||
{
|
||||
context.Token = refreshToken;
|
||||
messageContext.Token = queryRefreshToken;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
@@ -355,10 +358,9 @@ namespace Yi.Abp.Web
|
||||
var app = context.GetApplicationBuilder();
|
||||
app.UseRouting();
|
||||
|
||||
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<AnnouncementAggregateRoot>();
|
||||
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<CardFlipTaskAggregateRoot>();
|
||||
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<InviteCodeAggregateRoot>();
|
||||
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<InvitationRecordAggregateRoot>();
|
||||
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<AiModelEntity>();
|
||||
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<TokenAggregateRoot>();
|
||||
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<UsageStatisticsAggregateRoot>();
|
||||
|
||||
//跨域
|
||||
app.UseCors(DefaultCorsPolicyName);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
using TencentCloud.Ame.V20190916.Models;
|
||||
using TencentCloud.Tiw.V20190919.Models;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
using Xunit;
|
||||
using Yi.Framework.Rbac.Application.Contracts.Dtos.User;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx vue-tsc --noEmit)"
|
||||
"Bash(npx vue-tsc --noEmit)",
|
||||
"Bash(timeout 60 npx vue-tsc:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -15,6 +15,9 @@ VITE_WEB_BASE_API = '/dev-api'
|
||||
VITE_API_URL = http://localhost:19001/api/app
|
||||
#VITE_API_URL=http://data.ccnetcore.com:19001/api/app
|
||||
|
||||
# 文件上传接口域名
|
||||
VITE_FILE_UPLOAD_API = https://ai.ccnetcore.com
|
||||
|
||||
|
||||
|
||||
# SSO单点登录url
|
||||
|
||||
@@ -13,6 +13,9 @@ VITE_WEB_BASE_API = '/prod-api'
|
||||
# 本地接口
|
||||
VITE_API_URL = http://data.ccnetcore.com:19001/api/app
|
||||
|
||||
# 文件上传接口域名
|
||||
VITE_FILE_UPLOAD_API = https://ai.ccnetcore.com
|
||||
|
||||
# 是否在打包时开启压缩,支持 gzip 和 brotli
|
||||
VITE_BUILD_COMPRESS = gzip
|
||||
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
{
|
||||
"globals": {
|
||||
"Component": true,
|
||||
"ComponentPublicInstance": true,
|
||||
"ComputedRef": true,
|
||||
"DirectiveBinding": true,
|
||||
"EffectScope": true,
|
||||
"ElMessage": true,
|
||||
"ElMessageBox": true,
|
||||
"ExtractDefaultPropTypes": true,
|
||||
"ExtractPropTypes": true,
|
||||
"ExtractPublicPropTypes": true,
|
||||
"InjectionKey": true,
|
||||
"MaybeRef": true,
|
||||
"MaybeRefOrGetter": true,
|
||||
"PropType": true,
|
||||
"Ref": true,
|
||||
"Slot": true,
|
||||
"Slots": true,
|
||||
"VNode": true,
|
||||
"WritableComputedRef": true,
|
||||
"computed": true,
|
||||
"createApp": true,
|
||||
"customRef": true,
|
||||
"defineAsyncComponent": true,
|
||||
"defineComponent": true,
|
||||
"effectScope": true,
|
||||
"getCurrentInstance": true,
|
||||
"getCurrentScope": true,
|
||||
"h": true,
|
||||
"inject": true,
|
||||
"isProxy": true,
|
||||
"isReactive": true,
|
||||
"isReadonly": true,
|
||||
"isRef": true,
|
||||
"markRaw": true,
|
||||
"nextTick": true,
|
||||
"onActivated": true,
|
||||
"onBeforeMount": true,
|
||||
"onBeforeUnmount": true,
|
||||
"onBeforeUpdate": true,
|
||||
"onDeactivated": true,
|
||||
"onErrorCaptured": true,
|
||||
"onMounted": true,
|
||||
"onRenderTracked": true,
|
||||
"onRenderTriggered": true,
|
||||
"onScopeDispose": true,
|
||||
"onServerPrefetch": true,
|
||||
"onUnmounted": true,
|
||||
"onUpdated": true,
|
||||
"onWatcherCleanup": true,
|
||||
"provide": true,
|
||||
"reactive": true,
|
||||
"readonly": true,
|
||||
"ref": true,
|
||||
"resolveComponent": true,
|
||||
"shallowReactive": true,
|
||||
"shallowReadonly": true,
|
||||
"shallowRef": true,
|
||||
"toRaw": true,
|
||||
"toRef": true,
|
||||
"toRefs": true,
|
||||
"toValue": true,
|
||||
"triggerRef": true,
|
||||
"unref": true,
|
||||
"useAttrs": true,
|
||||
"useCssModule": true,
|
||||
"useCssVars": true,
|
||||
"useId": true,
|
||||
"useModel": true,
|
||||
"useSlots": true,
|
||||
"useTemplateRef": true,
|
||||
"watch": true,
|
||||
"watchEffect": true,
|
||||
"watchPostEffect": true,
|
||||
"watchSyncEffect": true
|
||||
}
|
||||
}
|
||||
7
Yi.Ai.Vue3/.gitignore
vendored
7
Yi.Ai.Vue3/.gitignore
vendored
@@ -23,3 +23,10 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
/.eslintrc-auto-import.json
|
||||
/types/auto-imports.d.ts
|
||||
/types/components.d.ts
|
||||
/types/import_meta.d.ts
|
||||
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
<body>
|
||||
<!-- 加载动画容器 -->
|
||||
<div id="yixinai-loader" class="loader-container">
|
||||
<div class="loader-title">意心Ai 2.3</div>
|
||||
<div class="loader-title">意心Ai 2.8</div>
|
||||
<div class="loader-subtitle">海外地址,仅首次访问预计加载约10秒</div>
|
||||
<div class="loader-logo">
|
||||
<div class="pulse-box"></div>
|
||||
|
||||
@@ -44,7 +44,9 @@
|
||||
"fingerprintjs": "^0.5.3",
|
||||
"hook-fetch": "^2.0.4-beta.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mammoth": "^1.11.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"pdfjs-dist": "^5.4.449",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.4.1",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -52,7 +54,30 @@
|
||||
"reset-css": "^5.0.2",
|
||||
"vue": "^3.5.17",
|
||||
"vue-element-plus-x": "1.3.7",
|
||||
"vue-router": "4"
|
||||
"vue-router": "4",
|
||||
"xlsx": "^0.18.5",
|
||||
"@shikijs/transformers": "^3.7.0",
|
||||
"chatarea": "^6.0.3",
|
||||
"deepmerge": "^4.3.1",
|
||||
"dompurify": "^3.2.6",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lodash": "^4.17.21",
|
||||
"mermaid": "11.12.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"property-information": "^7.1.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"shiki": "^3.7.0",
|
||||
"ts-md5": "^2.0.1",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^4.16.2",
|
||||
@@ -87,7 +112,37 @@
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-env-typed": "^0.0.2",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"vue-tsc": "^3.0.1"
|
||||
"vue-tsc": "^3.0.1",
|
||||
"@chromatic-com/storybook": "^3.2.7",
|
||||
"@jsonlee_12138/markdown-it-mermaid": "0.0.6",
|
||||
"@storybook/addon-essentials": "^8.6.14",
|
||||
"@storybook/addon-onboarding": "^8.6.14",
|
||||
"@storybook/addons": "^7.6.17",
|
||||
"@storybook/api": "^7.6.17",
|
||||
"@storybook/blocks": "^8.6.14",
|
||||
"@storybook/experimental-addon-test": "^8.6.14",
|
||||
"@storybook/manager-api": "^8.6.14",
|
||||
"@storybook/test": "^8.6.14",
|
||||
"@storybook/theming": "^8.6.14",
|
||||
"@storybook/vue3": "^8.6.14",
|
||||
"@storybook/vue3-vite": "^8.6.14",
|
||||
"@types/dom-speech-recognition": "^0.0.4",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"esno": "^4.8.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"playwright": "^1.53.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"sass": "^1.89.2",
|
||||
"storybook": "^8.6.14",
|
||||
"storybook-dark-mode": "^4.0.2",
|
||||
"terser": "^5.43.1",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-plugin-lib-inject-css": "^2.2.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
|
||||
4701
Yi.Ai.Vue3/pnpm-lock.yaml
generated
4701
Yi.Ai.Vue3/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
Yi.Ai.Vue3/publish_aihub_02.zip
Normal file
BIN
Yi.Ai.Vue3/publish_aihub_02.zip
Normal file
Binary file not shown.
@@ -220,4 +220,16 @@ export interface ChatMessageVo {
|
||||
* 用户id
|
||||
*/
|
||||
userId?: number;
|
||||
/**
|
||||
* 用户消息中的图片列表(前端扩展字段)
|
||||
*/
|
||||
images?: Array<{ url: string; name?: string }>;
|
||||
/**
|
||||
* 用户消息中的文件列表(前端扩展字段)
|
||||
*/
|
||||
files?: Array<{ name: string; size: number }>;
|
||||
/**
|
||||
* 创建时间(前端显示用)
|
||||
*/
|
||||
creationTime?: string;
|
||||
}
|
||||
|
||||
34
Yi.Ai.Vue3/src/api/file/index.ts
Normal file
34
Yi.Ai.Vue3/src/api/file/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { UploadFileResponse } from './types';
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param file 文件对象
|
||||
* @returns 返回文件ID数组
|
||||
*/
|
||||
export async function uploadFile(file: File): Promise<UploadFileResponse[]> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const uploadApiUrl = import.meta.env.VITE_FILE_UPLOAD_API;
|
||||
|
||||
const response = await fetch(`${uploadApiUrl}/prod-api/file`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('文件上传失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件URL
|
||||
* @param fileId 文件ID
|
||||
* @returns 文件访问URL
|
||||
*/
|
||||
export function getFileUrl(fileId: string): string {
|
||||
return `https://ccnetcore.com/prod-api/file/${fileId}/true`;
|
||||
}
|
||||
3
Yi.Ai.Vue3/src/api/file/types.ts
Normal file
3
Yi.Ai.Vue3/src/api/file/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface UploadFileResponse {
|
||||
id: string;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './announcement'
|
||||
export * from './auth';
|
||||
export * from './chat';
|
||||
export * from './file';
|
||||
export * from './model';
|
||||
export * from './pay';
|
||||
export * from './session';
|
||||
|
||||
@@ -1,11 +1,56 @@
|
||||
import type { GetSessionListVO } from './types';
|
||||
import { get, post } from '@/utils/request';
|
||||
import type { GetSessionListVO, ModelApiTypeOption, ModelLibraryDto, ModelLibraryGetListInput, ModelTypeOption, PagedResultDto } from './types';
|
||||
import { del, get, post, put } from '@/utils/request';
|
||||
|
||||
// 获取当前用户的模型列表
|
||||
export function getModelList() {
|
||||
// return get<GetSessionListVO[]>('/system/model/modelList');
|
||||
return get<GetSessionListVO[]>('/ai-chat/model').json();
|
||||
}
|
||||
|
||||
// 获取模型库列表(公开接口,无需登录)
|
||||
export function getModelLibraryList(params?: ModelLibraryGetListInput) {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.searchKey) {
|
||||
queryParams.append('SearchKey', params.searchKey);
|
||||
}
|
||||
if (params?.providerNames && params.providerNames.length > 0) {
|
||||
params.providerNames.forEach(name => queryParams.append('ProviderNames', name));
|
||||
}
|
||||
if (params?.modelTypes && params.modelTypes.length > 0) {
|
||||
params.modelTypes.forEach(type => queryParams.append('ModelTypes', type.toString()));
|
||||
}
|
||||
if (params?.modelApiTypes && params.modelApiTypes.length > 0) {
|
||||
params.modelApiTypes.forEach(type => queryParams.append('ModelApiTypes', type.toString()));
|
||||
}
|
||||
if (params?.isPremiumOnly !== undefined) {
|
||||
queryParams.append('IsPremiumOnly', params.isPremiumOnly.toString());
|
||||
}
|
||||
if (params?.skipCount !== undefined) {
|
||||
queryParams.append('SkipCount', params.skipCount.toString());
|
||||
}
|
||||
if (params?.maxResultCount !== undefined) {
|
||||
queryParams.append('MaxResultCount', params.maxResultCount.toString());
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const url = queryString ? `/model?${queryString}` : '/model';
|
||||
|
||||
return get<PagedResultDto<ModelLibraryDto>>(url).json();
|
||||
}
|
||||
|
||||
// 获取供应商列表(公开接口,无需登录)
|
||||
export function getProviderList() {
|
||||
return get<string[]>('/model/provider-list').json();
|
||||
}
|
||||
|
||||
// 获取模型类型选项列表(公开接口,无需登录)
|
||||
export function getModelTypeOptions() {
|
||||
return get<ModelTypeOption[]>('/model/model-type-options').json();
|
||||
}
|
||||
|
||||
// 获取API类型选项列表(公开接口,无需登录)
|
||||
export function getApiTypeOptions() {
|
||||
return get<ModelApiTypeOption[]>('/model/api-type-options').json();
|
||||
}
|
||||
// 申请ApiKey
|
||||
export function applyApiKey() {
|
||||
return post<any>('/token').json();
|
||||
@@ -21,10 +66,99 @@ export function getRechargeLog() {
|
||||
}
|
||||
|
||||
// 查询用户近7天token消耗
|
||||
export function getLast7DaysTokenUsage() {
|
||||
return get<any>('/usage-statistics/last7Days-token-usage').json();
|
||||
// tokenId: 可选,传入则查询该token的用量,不传则查询全部
|
||||
export function getLast7DaysTokenUsage(tokenId?: string) {
|
||||
const url = tokenId
|
||||
? `/usage-statistics/last7Days-token-usage?tokenId=${tokenId}`
|
||||
: '/usage-statistics/last7Days-token-usage';
|
||||
return get<any>(url).json();
|
||||
}
|
||||
// 查询用户token消耗各模型占比
|
||||
export function getModelTokenUsage() {
|
||||
return get<any>('/usage-statistics/model-token-usage').json();
|
||||
// tokenId: 可选,传入则查询该token的用量,不传则查询全部
|
||||
export function getModelTokenUsage(tokenId?: string) {
|
||||
const url = tokenId
|
||||
? `/usage-statistics/model-token-usage?tokenId=${tokenId}`
|
||||
: '/usage-statistics/model-token-usage';
|
||||
return get<any>(url).json();
|
||||
}
|
||||
|
||||
// 获取当前用户得token列表
|
||||
export function getTokenList(params?: {
|
||||
skipCount?: number;
|
||||
maxResultCount?: number;
|
||||
orderByColumn?: string;
|
||||
isAsc?: string;
|
||||
}) {
|
||||
// 构建查询参数
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.skipCount !== undefined) {
|
||||
queryParams.append('SkipCount', params.skipCount.toString());
|
||||
}
|
||||
if (params?.maxResultCount !== undefined) {
|
||||
queryParams.append('MaxResultCount', params.maxResultCount.toString());
|
||||
}
|
||||
if (params?.orderByColumn) {
|
||||
queryParams.append('OrderByColumn', params.orderByColumn);
|
||||
}
|
||||
if (params?.isAsc) {
|
||||
queryParams.append('IsAsc', params.isAsc);
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const url = queryString ? `/token/list?${queryString}` : '/token/list';
|
||||
|
||||
return get<any>(url).json();
|
||||
}
|
||||
|
||||
// 创建token
|
||||
export function createToken(data: any) {
|
||||
return post<any>('/token', data).json();
|
||||
}
|
||||
|
||||
// 编辑token
|
||||
export function editToken(data: any) {
|
||||
return put('/token', data).json();
|
||||
}
|
||||
|
||||
// 删除token
|
||||
export function deleteToken(id: string) {
|
||||
return del(`/token/${id}`).json();
|
||||
}
|
||||
|
||||
// 启用token
|
||||
export function enableToken(id: string) {
|
||||
return post(`/token/${id}/enable`).json();
|
||||
}
|
||||
|
||||
// 禁用token
|
||||
export function disableToken(id: string) {
|
||||
return post(`/token/${id}/disable`).json();
|
||||
}
|
||||
|
||||
// 新增接口2
|
||||
// 获取可选择的token信息
|
||||
export function getSelectableTokenInfo() {
|
||||
return get<any>('/token/select-list').json();
|
||||
}
|
||||
/*
|
||||
返回数据
|
||||
[
|
||||
{
|
||||
"tokenId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"name": "string",
|
||||
"isDisabled": true
|
||||
}
|
||||
] */
|
||||
// 获取当前用户尊享包不同token用量占比(饼图)
|
||||
export function getPremiumPackageTokenUsage() {
|
||||
return get<any>('/usage-statistics/premium-token-usage/by-token').json();
|
||||
}
|
||||
/* 返回数据
|
||||
[
|
||||
{
|
||||
"tokenId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"tokenName": "string",
|
||||
"tokens": 0,
|
||||
"percentage": 0
|
||||
}
|
||||
] */
|
||||
|
||||
@@ -13,3 +13,60 @@ export interface GetSessionListVO {
|
||||
remark?: string;
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
// 模型类型枚举
|
||||
export enum ModelTypeEnum {
|
||||
Chat = 0,
|
||||
Image = 1,
|
||||
Embedding = 2,
|
||||
PremiumChat = 3,
|
||||
}
|
||||
|
||||
// 模型API类型枚举
|
||||
export enum ModelApiTypeEnum {
|
||||
OpenAi = 0,
|
||||
Claude = 1,
|
||||
}
|
||||
|
||||
// 模型库展示数据
|
||||
export interface ModelLibraryDto {
|
||||
modelId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
modelType: ModelTypeEnum;
|
||||
modelApiTypes: Array;
|
||||
modelApiTypeName: string;
|
||||
multiplierShow: number;
|
||||
providerName?: string;
|
||||
iconUrl?: string;
|
||||
isPremium: boolean;
|
||||
}
|
||||
|
||||
// 获取模型库列表查询参数
|
||||
export interface ModelLibraryGetListInput {
|
||||
searchKey?: string;
|
||||
providerNames?: string[];
|
||||
modelTypes?: ModelTypeEnum[];
|
||||
modelApiTypes?: ModelApiTypeEnum[];
|
||||
isPremiumOnly?: boolean;
|
||||
skipCount?: number;
|
||||
maxResultCount?: number;
|
||||
}
|
||||
|
||||
// 分页结果
|
||||
export interface PagedResultDto<T> {
|
||||
items: T[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
// 模型类型选项
|
||||
export interface ModelTypeOption {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
// API类型选项
|
||||
export interface ModelApiTypeOption {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
7
Yi.Ai.Vue3/src/assets/icons/System/notification-fill.svg
Normal file
7
Yi.Ai.Vue3/src/assets/icons/System/notification-fill.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<g>
|
||||
|
||||
<path fill="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M12 2C16.9706 2 21 6.04348 21 11.0314V20H3V11.0314C3 6.04348 7.02944 2 12 2ZM9.5 21H14.5C14.5 22.3807 13.3807 23.5 12 23.5C10.6193 23.5 9.5 22.3807 9.5 21Z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -1,148 +1,751 @@
|
||||
<!-- 文件上传 -->
|
||||
<script setup lang="ts">
|
||||
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
|
||||
import type { FileItem } from '@/stores/modules/files';
|
||||
import { useFileDialog } from '@vueuse/core';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import Popover from '@/components/Popover/index.vue';
|
||||
import SvgIcon from '@/components/SvgIcon/index.vue';
|
||||
import mammoth from 'mammoth';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useFilesStore } from '@/stores/modules/files';
|
||||
|
||||
type FilesList = FilesCardProps & {
|
||||
file: File;
|
||||
};
|
||||
// 配置 PDF.js worker - 使用稳定的 CDN
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`;
|
||||
|
||||
const filesStore = useFilesStore();
|
||||
|
||||
/* 弹出面板 开始 */
|
||||
const popoverStyle = ref({
|
||||
padding: '4px',
|
||||
height: 'fit-content',
|
||||
background: 'var(--el-bg-color, #fff)',
|
||||
border: '1px solid var(--el-border-color-light)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 12px 0 rgba(0, 0, 0, 0.1)',
|
||||
});
|
||||
const popoverRef = ref();
|
||||
/* 弹出面板 结束 */
|
||||
// 文件大小限制 3MB
|
||||
const MAX_FILE_SIZE = 3 * 1024 * 1024;
|
||||
|
||||
// 单个文件内容长度限制
|
||||
const MAX_TEXT_FILE_LENGTH = 50000; // 文本文件最大字符数
|
||||
const MAX_WORD_LENGTH = 30000; // Word 文档最大字符数
|
||||
const MAX_EXCEL_ROWS = 100; // Excel 最大行数
|
||||
const MAX_PDF_PAGES = 10; // PDF 最大页数
|
||||
|
||||
// 整个消息总长度限制(所有文件内容加起来,预估 token 安全限制)
|
||||
// 272000 tokens * 0.55 安全系数 ≈ 150000 字符
|
||||
const MAX_TOTAL_CONTENT_LENGTH = 150000;
|
||||
|
||||
const { reset, open, onChange } = useFileDialog({
|
||||
// 允许所有图片文件,文档文件,音视频文件
|
||||
accept: 'image/*,video/*,audio/*,application/*',
|
||||
directory: false, // 是否允许选择文件夹
|
||||
multiple: true, // 是否允许多选
|
||||
// 支持图片、文档、文本文件等
|
||||
accept: 'image/*,.txt,.log,.csv,.tsv,.md,.markdown,.json,.xml,.yaml,.yml,.toml,.ini,.conf,.config,.properties,.prop,.env,'
|
||||
+ '.js,.jsx,.ts,.tsx,.vue,.html,.htm,.css,.scss,.sass,.less,.styl,'
|
||||
+ '.java,.c,.cpp,.h,.hpp,.cs,.py,.rb,.go,.rs,.swift,.kt,.php,.sh,.bash,.zsh,.fish,.bat,.cmd,.ps1,'
|
||||
+ '.sql,.graphql,.proto,.thrift,'
|
||||
+ '.dockerfile,.gitignore,.gitattributes,.editorconfig,.npmrc,.nvmrc,'
|
||||
+ '.sln,.csproj,.vbproj,.fsproj,.props,.targets,'
|
||||
+ '.xlsx,.xls,.csv,.docx,.pdf',
|
||||
directory: false,
|
||||
multiple: true,
|
||||
});
|
||||
|
||||
onChange((files) => {
|
||||
/**
|
||||
* 压缩图片
|
||||
* @param {File} file - 原始图片文件
|
||||
* @param {number} maxWidth - 最大宽度,默认 1024px
|
||||
* @param {number} maxHeight - 最大高度,默认 1024px
|
||||
* @param {number} quality - 压缩质量,0-1之间,默认 0.8
|
||||
* @returns {Promise<Blob>} 压缩后的图片 Blob
|
||||
*/
|
||||
function compressImage(file: File, maxWidth = 1024, maxHeight = 1024, quality = 0.8): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
// 计算缩放比例
|
||||
if (width > maxWidth || height > maxHeight) {
|
||||
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
||||
width = width * ratio;
|
||||
height = height * ratio;
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// 转换为 Blob
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
}
|
||||
else {
|
||||
reject(new Error('压缩失败'));
|
||||
}
|
||||
},
|
||||
file.type,
|
||||
quality,
|
||||
);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = e.target?.result as string;
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Blob 转换为 base64 格式
|
||||
* @param {Blob} blob - 要转换的 Blob 对象
|
||||
* @returns {Promise<string>} base64 编码的字符串(包含 data:xxx;base64, 前缀)
|
||||
*/
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolve(reader.result as string);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文本文件内容
|
||||
* @param {File} file - 文本文件
|
||||
* @returns {Promise<string>} 文件内容字符串
|
||||
*/
|
||||
function readTextFile(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolve(reader.result as string);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为文本文件
|
||||
* 通过 MIME 类型或文件扩展名判断
|
||||
* @param {File} file - 要判断的文件
|
||||
* @returns {boolean} 是否为文本文件
|
||||
*/
|
||||
function isTextFile(file: File): boolean {
|
||||
// 通过 MIME type 判断
|
||||
if (file.type.startsWith('text/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 通过扩展名判断(更全面的列表)
|
||||
const textExtensions = [
|
||||
// 通用文本
|
||||
'txt',
|
||||
'log',
|
||||
'md',
|
||||
'markdown',
|
||||
'rtf',
|
||||
// 配置文件
|
||||
'json',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'toml',
|
||||
'ini',
|
||||
'conf',
|
||||
'config',
|
||||
'properties',
|
||||
'prop',
|
||||
'env',
|
||||
// 前端
|
||||
'js',
|
||||
'jsx',
|
||||
'ts',
|
||||
'tsx',
|
||||
'vue',
|
||||
'html',
|
||||
'htm',
|
||||
'css',
|
||||
'scss',
|
||||
'sass',
|
||||
'less',
|
||||
'styl',
|
||||
// 编程语言
|
||||
'java',
|
||||
'c',
|
||||
'cpp',
|
||||
'h',
|
||||
'hpp',
|
||||
'cs',
|
||||
'py',
|
||||
'rb',
|
||||
'go',
|
||||
'rs',
|
||||
'swift',
|
||||
'kt',
|
||||
'php',
|
||||
// 脚本
|
||||
'sh',
|
||||
'bash',
|
||||
'zsh',
|
||||
'fish',
|
||||
'bat',
|
||||
'cmd',
|
||||
'ps1',
|
||||
// 数据库/API
|
||||
'sql',
|
||||
'graphql',
|
||||
'proto',
|
||||
'thrift',
|
||||
// 版本控制/工具
|
||||
'dockerfile',
|
||||
'gitignore',
|
||||
'gitattributes',
|
||||
'editorconfig',
|
||||
'npmrc',
|
||||
'nvmrc',
|
||||
// .NET 项目文件
|
||||
'sln',
|
||||
'csproj',
|
||||
'vbproj',
|
||||
'fsproj',
|
||||
'props',
|
||||
'targets',
|
||||
// 数据文件
|
||||
'csv',
|
||||
'tsv',
|
||||
];
|
||||
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
return ext ? textExtensions.includes(ext) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Excel 文件,提取前 N 行数据转为 CSV 格式
|
||||
* @param {File} file - Excel 文件 (.xlsx, .xls)
|
||||
* @returns {Promise<{content: string, totalRows: number, extractedRows: number}>}
|
||||
* - content: CSV 格式的文本内容
|
||||
* - totalRows: 文件总行数
|
||||
* - extractedRows: 实际提取的行数(受 MAX_EXCEL_ROWS 限制)
|
||||
*/
|
||||
async function parseExcel(file: File): Promise<{ content: string; totalRows: number; extractedRows: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const workbook = XLSX.read(data, { type: 'array' });
|
||||
|
||||
let result = '';
|
||||
let totalRows = 0;
|
||||
let extractedRows = 0;
|
||||
|
||||
workbook.SheetNames.forEach((sheetName, index) => {
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
// 获取工作表的范围
|
||||
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1');
|
||||
const sheetTotalRows = range.e.r - range.s.r + 1;
|
||||
totalRows += sheetTotalRows;
|
||||
|
||||
// 限制行数
|
||||
const rowsToExtract = Math.min(sheetTotalRows, MAX_EXCEL_ROWS);
|
||||
extractedRows += rowsToExtract;
|
||||
|
||||
// 创建新的范围,只包含前 N 行
|
||||
const limitedRange = {
|
||||
s: { r: range.s.r, c: range.s.c },
|
||||
e: { r: range.s.r + rowsToExtract - 1, c: range.e.c },
|
||||
};
|
||||
|
||||
// 提取限制范围内的数据
|
||||
const limitedData: any[][] = [];
|
||||
for (let row = limitedRange.s.r; row <= limitedRange.e.r; row++) {
|
||||
const rowData: any[] = [];
|
||||
for (let col = limitedRange.s.c; col <= limitedRange.e.c; col++) {
|
||||
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
|
||||
const cell = worksheet[cellAddress];
|
||||
rowData.push(cell ? cell.v : '');
|
||||
}
|
||||
limitedData.push(rowData);
|
||||
}
|
||||
|
||||
// 转换为 CSV
|
||||
const csvData = limitedData.map(row => row.join(',')).join('\n');
|
||||
|
||||
if (workbook.SheetNames.length > 1) {
|
||||
result += `=== Sheet: ${sheetName} ===\n`;
|
||||
}
|
||||
result += csvData;
|
||||
if (index < workbook.SheetNames.length - 1) {
|
||||
result += '\n\n';
|
||||
}
|
||||
});
|
||||
|
||||
resolve({ content: result, totalRows, extractedRows });
|
||||
}
|
||||
catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Word 文档,提取纯文本内容
|
||||
* @param {File} file - Word 文档 (.docx)
|
||||
* @returns {Promise<{content: string, totalLength: number, extracted: boolean}>}
|
||||
* - content: 提取的文本内容
|
||||
* - totalLength: 原始文本总长度
|
||||
* - extracted: 是否被截断(超过 MAX_WORD_LENGTH)
|
||||
*/
|
||||
async function parseWord(file: File): Promise<{ content: string; totalLength: number; extracted: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const arrayBuffer = e.target?.result as ArrayBuffer;
|
||||
const result = await mammoth.extractRawText({ arrayBuffer });
|
||||
const fullText = result.value;
|
||||
const totalLength = fullText.length;
|
||||
|
||||
if (totalLength > MAX_WORD_LENGTH) {
|
||||
const truncated = fullText.substring(0, MAX_WORD_LENGTH);
|
||||
resolve({ content: truncated, totalLength, extracted: true });
|
||||
}
|
||||
else {
|
||||
resolve({ content: fullText, totalLength, extracted: false });
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 PDF 文件,提取前 N 页的文本内容
|
||||
* @param {File} file - PDF 文件
|
||||
* @returns {Promise<{content: string, totalPages: number, extractedPages: number}>}
|
||||
* - content: 提取的文本内容
|
||||
* - totalPages: 文件总页数
|
||||
* - extractedPages: 实际提取的页数(受 MAX_PDF_PAGES 限制)
|
||||
*/
|
||||
async function parsePDF(file: File): Promise<{ content: string; totalPages: number; extractedPages: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const typedArray = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const pdf = await pdfjsLib.getDocument(typedArray).promise;
|
||||
const totalPages = pdf.numPages;
|
||||
const pagesToExtract = Math.min(totalPages, MAX_PDF_PAGES);
|
||||
|
||||
let fullText = '';
|
||||
for (let i = 1; i <= pagesToExtract; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const textContent = await page.getTextContent();
|
||||
const pageText = textContent.items.map((item: any) => item.str).join(' ');
|
||||
fullText += `${pageText}\n`;
|
||||
}
|
||||
|
||||
resolve({ content: fullText, totalPages, extractedPages: pagesToExtract });
|
||||
}
|
||||
catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @param {string} filename - 文件名
|
||||
* @returns {string} 小写的扩展名,无点号
|
||||
*/
|
||||
function getFileExtension(filename: string): string {
|
||||
return filename.split('.').pop()?.toLowerCase() || '';
|
||||
}
|
||||
|
||||
onChange(async (files) => {
|
||||
if (!files)
|
||||
return;
|
||||
const arr = [] as FilesList[];
|
||||
|
||||
const arr = [] as FileItem[];
|
||||
let totalContentLength = 0; // 跟踪总内容长度
|
||||
|
||||
// 先计算已有文件的总内容长度
|
||||
filesStore.filesList.forEach((f) => {
|
||||
if (f.fileType === 'text' && f.fileContent) {
|
||||
totalContentLength += f.fileContent.length;
|
||||
}
|
||||
// 图片 base64 也计入(虽然转 token 时不同,但也要计算)
|
||||
if (f.fileType === 'image' && f.base64) {
|
||||
// base64 转 token 比例约 1:1.5,这里保守估计
|
||||
totalContentLength += Math.floor(f.base64.length * 0.5);
|
||||
}
|
||||
});
|
||||
|
||||
for (let i = 0; i < files!.length; i++) {
|
||||
const file = files![i];
|
||||
arr.push({
|
||||
uid: crypto.randomUUID(), // 不写 uid,文件列表展示不出来,elx 1.2.0 bug 待修复
|
||||
name: file.name,
|
||||
fileSize: file.size,
|
||||
file,
|
||||
maxWidth: '200px',
|
||||
showDelIcon: true, // 显示删除图标
|
||||
imgPreview: true, // 显示图片预览
|
||||
imgVariant: 'square', // 图片预览的形状
|
||||
url: URL.createObjectURL(file), // 图片预览地址
|
||||
});
|
||||
|
||||
// 验证文件大小
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
ElMessage.error(`文件 ${file.name} 超过 3MB 限制,已跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ext = getFileExtension(file.name);
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isExcel = ['xlsx', 'xls'].includes(ext);
|
||||
const isWord = ext === 'docx';
|
||||
const isPDF = ext === 'pdf';
|
||||
const isText = isTextFile(file);
|
||||
|
||||
// 处理图片文件
|
||||
if (isImage) {
|
||||
try {
|
||||
// 控制参数:是否开启图片压缩
|
||||
const enableImageCompression = true; // 这里可以设置为变量或从配置读取
|
||||
|
||||
let finalBlob: Blob = file;
|
||||
let base64 = '';
|
||||
let compressionLevel = 0;
|
||||
const originalSize = (file.size / 1024).toFixed(2);
|
||||
let finalSize = originalSize;
|
||||
|
||||
if (enableImageCompression) {
|
||||
// 多级压缩策略:逐步降低质量和分辨率
|
||||
const compressionLevels = [
|
||||
{ maxWidth: 800, maxHeight: 800, quality: 0.6 },
|
||||
{ maxWidth: 600, maxHeight: 600, quality: 0.5 },
|
||||
{ maxWidth: 400, maxHeight: 400, quality: 0.4 },
|
||||
];
|
||||
|
||||
let compressedBlob: Blob | null = null;
|
||||
|
||||
// 尝试不同级别的压缩
|
||||
for (const level of compressionLevels) {
|
||||
compressionLevel++;
|
||||
compressedBlob = await compressImage(file, level.maxWidth, level.maxHeight, level.quality);
|
||||
base64 = await blobToBase64(compressedBlob);
|
||||
|
||||
// 检查是否满足总长度限制
|
||||
const estimatedLength = Math.floor(base64.length * 0.5);
|
||||
if (totalContentLength + estimatedLength <= MAX_TOTAL_CONTENT_LENGTH) {
|
||||
// 满足限制,使用当前压缩级别
|
||||
totalContentLength += estimatedLength;
|
||||
finalBlob = compressedBlob;
|
||||
break;
|
||||
}
|
||||
|
||||
// 如果是最后一级压缩仍然超限,则跳过
|
||||
if (compressionLevel === compressionLevels.length) {
|
||||
const fileSizeMB = (file.size / 1024 / 1024).toFixed(2);
|
||||
ElMessage.error(`${file.name} 图片内容过大,请压缩后上传`);
|
||||
compressedBlob = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果压缩失败,跳过此文件
|
||||
if (!compressedBlob) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 计算压缩比例
|
||||
finalSize = (finalBlob.size / 1024).toFixed(2);
|
||||
console.log(`图片压缩: ${file.name} - 原始: ${originalSize}KB, 压缩后: ${finalSize}KB (级别${compressionLevel})`);
|
||||
}
|
||||
else {
|
||||
// 不开启压缩时,直接转换原始文件
|
||||
base64 = await blobToBase64(file);
|
||||
|
||||
// 检查总长度限制
|
||||
const estimatedLength = Math.floor(base64.length * 0.5);
|
||||
if (totalContentLength + estimatedLength > MAX_TOTAL_CONTENT_LENGTH) {
|
||||
const fileSizeMB = (file.size / 1024 / 1024).toFixed(2);
|
||||
ElMessage.error(`${file.name} (${fileSizeMB}MB) 超过总长度限制,已跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
totalContentLength += estimatedLength;
|
||||
console.log(`图片未压缩: ${file.name} - 大小: ${originalSize}KB`);
|
||||
}
|
||||
|
||||
arr.push({
|
||||
uid: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
fileSize: file.size,
|
||||
file,
|
||||
maxWidth: '200px',
|
||||
showDelIcon: true,
|
||||
imgPreview: true,
|
||||
imgVariant: 'square',
|
||||
url: base64, // 使用压缩后的 base64 作为预览地址
|
||||
isUploaded: true,
|
||||
base64,
|
||||
fileType: 'image',
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('处理图片失败:', error);
|
||||
ElMessage.error(`${file.name} 处理失败`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 处理 Excel 文件
|
||||
else if (isExcel) {
|
||||
try {
|
||||
const result = await parseExcel(file);
|
||||
|
||||
// 动态裁剪内容以适应剩余空间
|
||||
let finalContent = result.content;
|
||||
let wasTruncated = result.totalRows > MAX_EXCEL_ROWS;
|
||||
|
||||
// 如果超过总内容限制,裁剪内容
|
||||
const remainingSpace = MAX_TOTAL_CONTENT_LENGTH - totalContentLength;
|
||||
if (result.content.length > remainingSpace && remainingSpace > 1000) {
|
||||
// 至少保留1000字符才有意义
|
||||
finalContent = result.content.substring(0, remainingSpace);
|
||||
wasTruncated = true;
|
||||
}
|
||||
else if (remainingSpace <= 1000) {
|
||||
const fileSizeKB = (file.size / 1024).toFixed(2);
|
||||
ElMessage.error(`${file.name} (${fileSizeKB}KB) 会超过总长度限制,已跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
totalContentLength += finalContent.length;
|
||||
|
||||
arr.push({
|
||||
uid: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
fileSize: file.size,
|
||||
file,
|
||||
maxWidth: '200px',
|
||||
showDelIcon: true,
|
||||
imgPreview: false,
|
||||
isUploaded: true,
|
||||
fileContent: finalContent,
|
||||
fileType: 'text',
|
||||
});
|
||||
|
||||
// 提示信息
|
||||
if (wasTruncated) {
|
||||
const fileSizeKB = (file.size / 1024).toFixed(2);
|
||||
ElMessage.warning(`${file.name} (${fileSizeKB}KB) 内容过大,已自动截取部分内容`);
|
||||
}
|
||||
|
||||
console.log(`Excel 解析: ${file.name} - 大小: ${(file.size / 1024).toFixed(2)}KB, 总行数: ${result.totalRows}, 已提取: ${result.extractedRows} 行, 内容长度: ${finalContent.length} 字符`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('解析 Excel 失败:', error);
|
||||
ElMessage.error(`${file.name} 解析失败`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 处理 Word 文档
|
||||
else if (isWord) {
|
||||
try {
|
||||
const result = await parseWord(file);
|
||||
|
||||
// 动态裁剪内容以适应剩余空间
|
||||
let finalContent = result.content;
|
||||
let wasTruncated = result.extracted;
|
||||
|
||||
// 如果超过总内容限制,裁剪内容
|
||||
const remainingSpace = MAX_TOTAL_CONTENT_LENGTH - totalContentLength;
|
||||
if (result.content.length > remainingSpace && remainingSpace > 1000) {
|
||||
finalContent = result.content.substring(0, remainingSpace);
|
||||
wasTruncated = true;
|
||||
}
|
||||
else if (remainingSpace <= 1000) {
|
||||
const fileSizeKB = (file.size / 1024).toFixed(2);
|
||||
ElMessage.error(`${file.name} (${fileSizeKB}KB) 会超过总长度限制,已跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
totalContentLength += finalContent.length;
|
||||
|
||||
arr.push({
|
||||
uid: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
fileSize: file.size,
|
||||
file,
|
||||
maxWidth: '200px',
|
||||
showDelIcon: true,
|
||||
imgPreview: false,
|
||||
isUploaded: true,
|
||||
fileContent: finalContent,
|
||||
fileType: 'text',
|
||||
});
|
||||
|
||||
// 提示信息
|
||||
if (wasTruncated) {
|
||||
const fileSizeKB = (file.size / 1024).toFixed(2);
|
||||
ElMessage.warning(`${file.name} (${fileSizeKB}KB) 内容过大,已自动截取部分内容`);
|
||||
}
|
||||
|
||||
console.log(`Word 解析: ${file.name} - 大小: ${(file.size / 1024).toFixed(2)}KB, 总长度: ${result.totalLength}, 已提取: ${finalContent.length} 字符`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('解析 Word 失败:', error);
|
||||
ElMessage.error(`${file.name} 解析失败`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 处理 PDF 文件
|
||||
else if (isPDF) {
|
||||
try {
|
||||
const result = await parsePDF(file);
|
||||
|
||||
// 动态裁剪内容以适应剩余空间
|
||||
let finalContent = result.content;
|
||||
let wasTruncated = result.totalPages > MAX_PDF_PAGES;
|
||||
|
||||
// 如果超过总内容限制,裁剪内容
|
||||
const remainingSpace = MAX_TOTAL_CONTENT_LENGTH - totalContentLength;
|
||||
if (result.content.length > remainingSpace && remainingSpace > 1000) {
|
||||
finalContent = result.content.substring(0, remainingSpace);
|
||||
wasTruncated = true;
|
||||
}
|
||||
else if (remainingSpace <= 1000) {
|
||||
const fileSizeKB = (file.size / 1024).toFixed(2);
|
||||
ElMessage.error(`${file.name} (${fileSizeKB}KB) 会超过总长度限制,已跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
totalContentLength += finalContent.length;
|
||||
|
||||
arr.push({
|
||||
uid: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
fileSize: file.size,
|
||||
file,
|
||||
maxWidth: '200px',
|
||||
showDelIcon: true,
|
||||
imgPreview: false,
|
||||
isUploaded: true,
|
||||
fileContent: finalContent,
|
||||
fileType: 'text',
|
||||
});
|
||||
|
||||
// 提示信息
|
||||
if (wasTruncated) {
|
||||
const fileSizeKB = (file.size / 1024).toFixed(2);
|
||||
ElMessage.warning(`${file.name} (${fileSizeKB}KB) 内容过大,已自动截取部分内容`);
|
||||
}
|
||||
|
||||
console.log(`PDF 解析: ${file.name} - 大小: ${(file.size / 1024).toFixed(2)}KB, 总页数: ${result.totalPages}, 已提取: ${result.extractedPages} 页, 内容长度: ${finalContent.length} 字符`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('解析 PDF 失败:', error);
|
||||
ElMessage.error(`${file.name} 解析失败`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 处理文本文件
|
||||
else if (isText) {
|
||||
try {
|
||||
// 读取文本文件内容
|
||||
const content = await readTextFile(file);
|
||||
|
||||
// 限制单个文本文件长度
|
||||
let finalContent = content;
|
||||
let truncated = false;
|
||||
if (content.length > MAX_TEXT_FILE_LENGTH) {
|
||||
finalContent = content.substring(0, MAX_TEXT_FILE_LENGTH);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
// 动态裁剪内容以适应剩余空间
|
||||
const remainingSpace = MAX_TOTAL_CONTENT_LENGTH - totalContentLength;
|
||||
if (finalContent.length > remainingSpace && remainingSpace > 1000) {
|
||||
finalContent = finalContent.substring(0, remainingSpace);
|
||||
truncated = true;
|
||||
}
|
||||
else if (remainingSpace <= 1000) {
|
||||
const fileSizeKB = (file.size / 1024).toFixed(2);
|
||||
ElMessage.error(`${file.name} (${fileSizeKB}KB) 会超过总长度限制,已跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
totalContentLength += finalContent.length;
|
||||
|
||||
arr.push({
|
||||
uid: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
fileSize: file.size,
|
||||
file,
|
||||
maxWidth: '200px',
|
||||
showDelIcon: true,
|
||||
imgPreview: false,
|
||||
isUploaded: true,
|
||||
fileContent: finalContent,
|
||||
fileType: 'text',
|
||||
});
|
||||
|
||||
// 提示信息
|
||||
if (truncated) {
|
||||
const fileSizeKB = (file.size / 1024).toFixed(2);
|
||||
ElMessage.warning(`${file.name} (${fileSizeKB}KB) 内容过大,已自动截取部分内容`);
|
||||
}
|
||||
|
||||
console.log(`文本文件读取: ${file.name} - 大小: ${(file.size / 1024).toFixed(2)}KB, 内容长度: ${content.length} 字符`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('读取文件失败:', error);
|
||||
ElMessage.error(`${file.name} 读取失败`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 不支持的文件类型
|
||||
else {
|
||||
ElMessage.warning(`${file.name} 不是支持的文件类型`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
filesStore.setFilesList([...filesStore.filesList, ...arr]);
|
||||
|
||||
if (arr.length > 0) {
|
||||
filesStore.setFilesList([...filesStore.filesList, ...arr]);
|
||||
ElMessage.success(`已添加 ${arr.length} 个文件`);
|
||||
}
|
||||
|
||||
// 重置文件选择器
|
||||
nextTick(() => reset());
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开文件选择对话框
|
||||
*/
|
||||
function handleUploadFiles() {
|
||||
open();
|
||||
popoverRef.value.hide();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="files-select">
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
placement="top-start"
|
||||
:offset="[4, 0]"
|
||||
popover-class="popover-content"
|
||||
:popover-style="popoverStyle"
|
||||
trigger="clickTarget"
|
||||
<!-- 直接点击上传,添加 tooltip 提示 -->
|
||||
<el-tooltip
|
||||
content="上传文件或图片(支持 Excel、Word、PDF、代码文件等,最大3MB)"
|
||||
placement="top"
|
||||
>
|
||||
<template #trigger>
|
||||
<div
|
||||
class="flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px border-1px border-[rgba(0,0,0,0.08)] border-solid hover:bg-[rgba(0,0,0,.04)]"
|
||||
>
|
||||
<el-icon>
|
||||
<Paperclip />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="popover-content-box">
|
||||
<div
|
||||
class="popover-content-item flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px hover:bg-[rgba(0,0,0,.04)]"
|
||||
@click="handleUploadFiles"
|
||||
>
|
||||
<el-icon>
|
||||
<Upload />
|
||||
</el-icon>
|
||||
<div class="font-size-14px">
|
||||
上传文件或图片
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
placement="right-end"
|
||||
:offset="[8, 4]"
|
||||
popover-class="popover-content"
|
||||
:popover-style="popoverStyle"
|
||||
trigger="hover"
|
||||
:hover-delay="100"
|
||||
>
|
||||
<template #trigger>
|
||||
<div
|
||||
class="popover-content-item flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px hover:bg-[rgba(0,0,0,.04)]"
|
||||
>
|
||||
<SvgIcon name="code" size="16" />
|
||||
<div class="font-size-14px">
|
||||
上传代码
|
||||
</div>
|
||||
|
||||
<el-icon class="ml-auto">
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="popover-content-box">
|
||||
<div
|
||||
class="popover-content-item flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px hover:bg-[rgba(0,0,0,.04)]"
|
||||
@click="
|
||||
() => {
|
||||
ElMessage.warning('暂未开放');
|
||||
}
|
||||
"
|
||||
>
|
||||
代码文件
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="popover-content-item flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px hover:bg-[rgba(0,0,0,.04)]"
|
||||
@click="
|
||||
() => {
|
||||
ElMessage.warning('暂未开放');
|
||||
}
|
||||
"
|
||||
>
|
||||
代码文件夹
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
<div
|
||||
class="flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px border-1px border-[rgba(0,0,0,0.08)] border-solid hover:bg-[rgba(0,0,0,.04)]"
|
||||
@click="handleUploadFiles"
|
||||
>
|
||||
<el-icon>
|
||||
<Paperclip />
|
||||
</el-icon>
|
||||
</div>
|
||||
</Popover>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -99,14 +99,16 @@ function handleModelClick(item: GetSessionListVO) {
|
||||
规则2:金色光泽(VIP/付费)
|
||||
规则3:彩色流光(尊享/高级)
|
||||
-------------------------------- */
|
||||
function getModelStyleClass(modelName: any) {
|
||||
if (!modelName) {
|
||||
function getModelStyleClass(mode: any) {
|
||||
if (!mode) {
|
||||
return;
|
||||
}
|
||||
const name = modelName.toLowerCase();
|
||||
// isPremiumPackage
|
||||
const name = mode.modelName.toLowerCase();
|
||||
const isPremiumPackage = mode.isPremiumPackage;
|
||||
|
||||
// 规则3:彩色流光
|
||||
if (name.includes('claude-sonnet-4-5-20250929')) {
|
||||
if (isPremiumPackage) {
|
||||
return `
|
||||
text-transparent bg-clip-text
|
||||
bg-[linear-gradient(45deg,#ff0000,#ff8000,#ffff00,#00ff00,#00ffff,#0000ff,#8000ff,#ff0080)]
|
||||
@@ -167,7 +169,7 @@ function getWrapperClass(item: GetSessionListVO) {
|
||||
<div class="model-select-box-icon">
|
||||
<SvgIcon name="models" size="12" />
|
||||
</div>
|
||||
<div :class="getModelStyleClass(currentModelName)" class="model-select-box-text font-size-12px">
|
||||
<div :class="getModelStyleClass(modelStore.currentModelInfo)" class="model-select-box-text font-size-12px">
|
||||
{{ currentModelName }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +190,7 @@ function getWrapperClass(item: GetSessionListVO) {
|
||||
:offset="[12, 0]"
|
||||
>
|
||||
<template #trigger>
|
||||
<span :class="getModelStyleClass(item.modelName)">
|
||||
<span :class="getModelStyleClass(item)">
|
||||
{{ item.modelName }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -510,7 +510,12 @@ function onClose() {
|
||||
|
||||
<div style="display: flex;justify-content: space-between;margin-top: 15px;">
|
||||
<div>
|
||||
<p>充值后,加客服微信回复账号名,可专享vip售后服务</p>
|
||||
<p style="color: #f97316;font-weight: 800">
|
||||
全站任意充值,每累计充值10元永久优惠尊享包10元,最高可优惠50元
|
||||
</p>
|
||||
<p style="margin-top: 10px;">
|
||||
充值后,加客服微信回复账号名,可专享vip售后服务
|
||||
</p>
|
||||
<p style="margin-top: 10px;">
|
||||
客服微信号:chengzilaoge520 或扫描右侧二维码
|
||||
</p>
|
||||
@@ -692,7 +697,13 @@ function onClose() {
|
||||
|
||||
<div style="display: flex;justify-content: space-between;margin-top: 15px;">
|
||||
<div>
|
||||
<p>充值后,加客服微信回复账号名,可专享vip售后服务</p>
|
||||
<p style="color: #f97316;font-weight: 800">
|
||||
全站任意充值,每累计充值10元永久优惠尊享包10元,最高可优惠50元
|
||||
</p>
|
||||
|
||||
<p style="margin-top: 10px;">
|
||||
充值后,加客服微信回复账号名,可专享vip售后服务
|
||||
</p>
|
||||
<p style="margin-top: 10px;">
|
||||
客服微信号:chengzilaoge520 或扫描右侧二维码
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
// 图标文档 https://remixicon.com/
|
||||
const props = defineProps<{
|
||||
className?: string;
|
||||
name: string;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -797,7 +797,7 @@ function generateShareContent(): string {
|
||||
👉 点击链接立即参与我的专属邀请码链接:
|
||||
${shareLink}
|
||||
|
||||
🍀 未注册用户,微信扫码登录,进入用户中心👉每周邀请 即可立即参与!`;
|
||||
🍀 未注册用户,微信扫码登录,进入控制台👉每周邀请 即可立即参与!`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
import { Clock, Coin, TrophyBase, WarningFilled } from '@element-plus/icons-vue';
|
||||
import { PieChart as EPieChart } from 'echarts/charts';
|
||||
import {
|
||||
GraphicComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { getPremiumPackageTokenUsage } from '@/api';
|
||||
import { showProductPackage } from '@/utils/product-package.ts';
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
// 注册必要的组件
|
||||
echarts.use([
|
||||
EPieChart,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GraphicComponent,
|
||||
CanvasRenderer,
|
||||
]);
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
packageData: {
|
||||
@@ -15,14 +44,11 @@ interface Props {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
}>();
|
||||
// 饼图相关
|
||||
const tokenPieChart = ref(null);
|
||||
let tokenPieChartInstance: any = null;
|
||||
const tokenUsageData = ref<any[]>([]);
|
||||
const tokenUsageLoading = ref(false);
|
||||
|
||||
// 计算属性
|
||||
const usagePercent = computed(() => {
|
||||
@@ -64,6 +90,193 @@ function formatRawNumber(num: number): string {
|
||||
function onProductPackage() {
|
||||
showProductPackage();
|
||||
}
|
||||
|
||||
// 获取Token用量数据
|
||||
async function fetchTokenUsageData() {
|
||||
try {
|
||||
tokenUsageLoading.value = true;
|
||||
const res = await getPremiumPackageTokenUsage();
|
||||
if (res.data) {
|
||||
tokenUsageData.value = res.data;
|
||||
updateTokenPieChart();
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取Token用量数据失败:', error);
|
||||
}
|
||||
finally {
|
||||
tokenUsageLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化Token饼图
|
||||
function initTokenPieChart() {
|
||||
if (tokenPieChart.value) {
|
||||
tokenPieChartInstance = echarts.init(tokenPieChart.value);
|
||||
}
|
||||
window.addEventListener('resize', resizeTokenPieChart);
|
||||
}
|
||||
|
||||
// 更新Token饼图
|
||||
function updateTokenPieChart() {
|
||||
if (!tokenPieChartInstance)
|
||||
return;
|
||||
|
||||
// 空数据状态
|
||||
if (tokenUsageData.value.length === 0) {
|
||||
const emptyOption = {
|
||||
graphic: [
|
||||
{
|
||||
type: 'group',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
children: [
|
||||
{
|
||||
type: 'circle',
|
||||
shape: {
|
||||
r: 80,
|
||||
},
|
||||
style: {
|
||||
fill: '#f5f7fa',
|
||||
stroke: '#e9ecef',
|
||||
lineWidth: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
style: {
|
||||
text: '📊',
|
||||
fontSize: 48,
|
||||
x: -24,
|
||||
y: -40,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
style: {
|
||||
text: '暂无数据',
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
fill: '#909399',
|
||||
x: -36,
|
||||
y: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
style: {
|
||||
text: '还没有Token使用记录',
|
||||
fontSize: 14,
|
||||
fill: '#c0c4cc',
|
||||
x: -70,
|
||||
y: 50,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
tokenPieChartInstance.setOption(emptyOption, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = tokenUsageData.value.map(item => ({
|
||||
name: item.tokenName,
|
||||
value: item.tokens,
|
||||
}));
|
||||
|
||||
const option = {
|
||||
graphic: [],
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} tokens ({d}%)',
|
||||
},
|
||||
legend: {
|
||||
show: false, // 隐藏图例,使用标签线代替
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Token用量',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
center: ['50%', '50%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'outside',
|
||||
formatter: (params: any) => {
|
||||
const item = tokenUsageData.value.find(d => d.tokenName === params.name);
|
||||
const percentage = item?.percentage || 0;
|
||||
return `${params.name}: ${percentage.toFixed(1)}%`;
|
||||
},
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
},
|
||||
labelLine: {
|
||||
show: true,
|
||||
length: 15,
|
||||
length2: 10,
|
||||
lineStyle: {
|
||||
width: 1.5,
|
||||
},
|
||||
},
|
||||
data,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
tokenPieChartInstance.setOption(option, true);
|
||||
}
|
||||
|
||||
// 调整饼图大小
|
||||
function resizeTokenPieChart() {
|
||||
tokenPieChartInstance?.resize();
|
||||
}
|
||||
|
||||
// 根据索引获取Token颜色
|
||||
function getTokenColor(index: number) {
|
||||
const colors = [
|
||||
'#667eea',
|
||||
'#764ba2',
|
||||
'#f093fb',
|
||||
'#f5576c',
|
||||
'#4facfe',
|
||||
'#00f2fe',
|
||||
'#43e97b',
|
||||
'#38f9d7',
|
||||
'#fa709a',
|
||||
'#fee140',
|
||||
];
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initTokenPieChart();
|
||||
fetchTokenUsageData();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', resizeTokenPieChart);
|
||||
tokenPieChartInstance?.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -231,6 +444,98 @@ function onProductPackage() {
|
||||
</el-alert>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Token用量占比卡片 -->
|
||||
<el-card v-loading="tokenUsageLoading" class="package-card token-usage-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="card-header-left">
|
||||
<el-icon class="header-icon token-icon">
|
||||
<i-ep-pie-chart />
|
||||
</el-icon>
|
||||
<div class="header-text">
|
||||
<span class="header-title">各API密钥用量占比</span>
|
||||
<span class="header-subtitle">Premium APIKEY Usage Distribution</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="token-usage-content">
|
||||
<div class="chart-container-wrapper">
|
||||
<div ref="tokenPieChart" class="token-pie-chart" />
|
||||
</div>
|
||||
|
||||
<!-- Token统计列表 -->
|
||||
<div v-if="tokenUsageData.length > 0" class="token-stats-list">
|
||||
<div
|
||||
v-for="(item, index) in tokenUsageData"
|
||||
:key="item.tokenId"
|
||||
class="token-stat-item"
|
||||
>
|
||||
<div class="token-stat-header">
|
||||
<div class="token-rank">
|
||||
<span class="rank-badge" :class="`rank-${index + 1}`">#{{ index + 1 }}</span>
|
||||
</div>
|
||||
<div class="token-name">
|
||||
<el-icon><i-ep-key /></el-icon>
|
||||
<span>{{ item.tokenName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-stat-data">
|
||||
<div class="stat-tokens">
|
||||
<span class="label">用量:</span>
|
||||
<span class="value">{{ item.tokens.toLocaleString() }}</span>
|
||||
<span class="unit">tokens</span>
|
||||
</div>
|
||||
<div class="stat-percentage">
|
||||
<el-progress
|
||||
:percentage="item.percentage"
|
||||
:color="getTokenColor(index)"
|
||||
:stroke-width="8"
|
||||
:show-text="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<el-empty
|
||||
v-else
|
||||
description="暂无Token使用数据"
|
||||
class="token-empty-state"
|
||||
:image-size="120"
|
||||
>
|
||||
<template #image>
|
||||
<div class="custom-empty-image">
|
||||
<el-icon class="empty-main-icon">
|
||||
<i-ep-pie-chart />
|
||||
</el-icon>
|
||||
<div class="empty-decoration">
|
||||
<div class="decoration-circle" />
|
||||
<div class="decoration-circle" />
|
||||
<div class="decoration-circle" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="empty-description">
|
||||
<h3 class="empty-title">
|
||||
暂无Token使用数据
|
||||
</h3>
|
||||
<p class="empty-text">
|
||||
当您开始使用Token后,这里将展示各Token的用量占比统计
|
||||
</p>
|
||||
<div class="empty-tips">
|
||||
<el-icon><i-ep-info-filled /></el-icon>
|
||||
<span>创建并使用Token后即可查看详细的用量分析</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-empty>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -495,6 +800,270 @@ function onProductPackage() {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Token用量占比卡片 */
|
||||
.token-usage-card {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.token-icon {
|
||||
background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%);
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.token-usage-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.chart-container-wrapper {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #fafbfc 0%, #f5f6f8 100%);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.token-pie-chart {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
/* Token统计列表 */
|
||||
.token-stats-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.token-stat-item {
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #fafbfc 0%, #ffffff 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f2f5;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.token-stat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.token-rank {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
|
||||
&.rank-1 {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
}
|
||||
|
||||
&.rank-2 {
|
||||
background: linear-gradient(135deg, #94a3b8 0%, #64748b 100%);
|
||||
}
|
||||
|
||||
&.rank-3 {
|
||||
background: linear-gradient(135deg, #fb923c 0%, #ea580c 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.token-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
|
||||
.el-icon {
|
||||
color: #667eea;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.token-stat-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-tokens {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
|
||||
.label {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.unit {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-percentage {
|
||||
:deep(.el-progress__text) {
|
||||
font-size: 14px !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.token-empty-state {
|
||||
padding: 60px 20px;
|
||||
|
||||
:deep(.el-empty__image) {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-empty-image {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.empty-main-icon {
|
||||
font-size: 80px;
|
||||
color: #667eea;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.empty-decoration {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.decoration-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
||||
|
||||
&:nth-child(1) {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
top: 10%;
|
||||
left: 10%;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
bottom: 20%;
|
||||
right: 15%;
|
||||
animation: pulse 2s ease-in-out infinite 0.5s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
top: 60%;
|
||||
right: 5%;
|
||||
animation: pulse 2s ease-in-out infinite 1s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.empty-tips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #e8f0fe 100%);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #667eea;
|
||||
margin-top: 8px;
|
||||
|
||||
.el-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 警告卡片 */
|
||||
.warning-card {
|
||||
border-radius: 12px;
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
<script lang="ts" setup>
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
interface TokenFormData {
|
||||
id?: string;
|
||||
name: string;
|
||||
expireTime: string;
|
||||
premiumQuotaLimit: number | null;
|
||||
quotaUnit: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
mode: 'create' | 'edit';
|
||||
formData?: TokenFormData;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
mode: 'create',
|
||||
formData: () => ({
|
||||
name: '',
|
||||
expireTime: '',
|
||||
premiumQuotaLimit: 0,
|
||||
quotaUnit: '万',
|
||||
}),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean];
|
||||
'confirm': [data: TokenFormData];
|
||||
}>();
|
||||
|
||||
const localFormData = ref<TokenFormData>({
|
||||
name: '',
|
||||
expireTime: '',
|
||||
premiumQuotaLimit: 0,
|
||||
quotaUnit: '万',
|
||||
});
|
||||
|
||||
const submitting = ref(false);
|
||||
const neverExpire = ref(false); // 永不过期开关
|
||||
const unlimitedQuota = ref(false); // 无限制额度开关
|
||||
|
||||
const quotaUnitOptions = [
|
||||
{ label: '个', value: '个', multiplier: 1 },
|
||||
{ label: '十', value: '十', multiplier: 10 },
|
||||
{ label: '百', value: '百', multiplier: 100 },
|
||||
{ label: '千', value: '千', multiplier: 1000 },
|
||||
{ label: '万', value: '万', multiplier: 10000 },
|
||||
{ label: '亿', value: '亿', multiplier: 100000000 },
|
||||
];
|
||||
|
||||
// 监听visible变化,重置表单
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
if (props.mode === 'edit' && props.formData) {
|
||||
// 编辑模式:转换后端数据为展示数据
|
||||
const quota = props.formData.premiumQuotaLimit || 0;
|
||||
let displayValue = quota;
|
||||
let unit = '个';
|
||||
|
||||
// 判断是否无限制
|
||||
unlimitedQuota.value = quota === 0;
|
||||
|
||||
if (!unlimitedQuota.value) {
|
||||
// 自动选择合适的单位
|
||||
if (quota >= 100000000 && quota % 100000000 === 0) {
|
||||
displayValue = quota / 100000000;
|
||||
unit = '亿';
|
||||
}
|
||||
else if (quota >= 10000 && quota % 10000 === 0) {
|
||||
displayValue = quota / 10000;
|
||||
unit = '万';
|
||||
}
|
||||
else if (quota >= 1000 && quota % 1000 === 0) {
|
||||
displayValue = quota / 1000;
|
||||
unit = '千';
|
||||
}
|
||||
else if (quota >= 100 && quota % 100 === 0) {
|
||||
displayValue = quota / 100;
|
||||
unit = '百';
|
||||
}
|
||||
else if (quota >= 10 && quota % 10 === 0) {
|
||||
displayValue = quota / 10;
|
||||
unit = '十';
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否永不过期
|
||||
neverExpire.value = !props.formData.expireTime;
|
||||
|
||||
localFormData.value = {
|
||||
...props.formData,
|
||||
premiumQuotaLimit: displayValue,
|
||||
quotaUnit: unit,
|
||||
};
|
||||
}
|
||||
else {
|
||||
// 新增模式:重置表单
|
||||
localFormData.value = {
|
||||
name: '',
|
||||
expireTime: '',
|
||||
premiumQuotaLimit: 1,
|
||||
quotaUnit: '万',
|
||||
};
|
||||
neverExpire.value = false;
|
||||
unlimitedQuota.value = false;
|
||||
}
|
||||
submitting.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听永不过期开关
|
||||
watch(neverExpire, (newVal) => {
|
||||
if (newVal) {
|
||||
localFormData.value.expireTime = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 监听无限制开关
|
||||
watch(unlimitedQuota, (newVal) => {
|
||||
if (newVal) {
|
||||
localFormData.value.premiumQuotaLimit = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// 关闭对话框
|
||||
function handleClose() {
|
||||
if (submitting.value)
|
||||
return;
|
||||
emit('update:visible', false);
|
||||
}
|
||||
|
||||
// 确认提交
|
||||
async function handleConfirm() {
|
||||
if (!localFormData.value.name.trim()) {
|
||||
ElMessage.warning('请输入API密钥名称');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!neverExpire.value && !localFormData.value.expireTime) {
|
||||
ElMessage.warning('请选择过期时间');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!unlimitedQuota.value && localFormData.value.premiumQuotaLimit <= 0) {
|
||||
ElMessage.warning('请输入有效的配额限制');
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
try {
|
||||
// 将展示值转换为实际值
|
||||
let actualQuota = null;
|
||||
if (!unlimitedQuota.value) {
|
||||
const unit = quotaUnitOptions.find(u => u.value === localFormData.value.quotaUnit);
|
||||
actualQuota = localFormData.value.premiumQuotaLimit * (unit?.multiplier || 1);
|
||||
}
|
||||
const submitData: TokenFormData = {
|
||||
...localFormData.value,
|
||||
expireTime: neverExpire.value ? '' : localFormData.value.expireTime,
|
||||
premiumQuotaLimit: actualQuota,
|
||||
};
|
||||
|
||||
emit('confirm', submitData);
|
||||
}
|
||||
finally {
|
||||
// 注意:这里不设置 submitting.value = false
|
||||
// 因为父组件会关闭对话框,watch会重置状态
|
||||
}
|
||||
}
|
||||
|
||||
const dialogTitle = computed(() => props.mode === 'create' ? '新增 API密钥' : '编辑 API密钥');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="540px"
|
||||
:close-on-click-modal="false"
|
||||
:show-close="!submitting"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form :model="localFormData" label-width="110px" label-position="right">
|
||||
<el-form-item label="API密钥名称" required>
|
||||
<el-input
|
||||
v-model="localFormData.name"
|
||||
placeholder="例如:生产环境、测试环境、开发环境"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
clearable
|
||||
:disabled="submitting"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><i-ep-collection-tag /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="过期时间">
|
||||
<div class="form-item-with-switch">
|
||||
<el-switch
|
||||
v-model="neverExpire"
|
||||
active-text="永不过期"
|
||||
:disabled="submitting"
|
||||
class="expire-switch"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-if="!neverExpire"
|
||||
v-model="localFormData.expireTime"
|
||||
type="datetime"
|
||||
placeholder="选择过期时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
style="width: 100%"
|
||||
clearable
|
||||
:disabled="submitting"
|
||||
:disabled-date="(time: Date) => time.getTime() < Date.now()"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><i-ep-clock /></el-icon>
|
||||
</template>
|
||||
</el-date-picker>
|
||||
</div>
|
||||
<div v-if="!neverExpire" class="form-hint">
|
||||
<el-icon><i-ep-warning /></el-icon>
|
||||
API密钥将在过期时间后自动失效
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="配额限制">
|
||||
<div class="form-item-with-switch">
|
||||
<el-switch
|
||||
v-model="unlimitedQuota"
|
||||
active-text="无限制"
|
||||
:disabled="submitting"
|
||||
class="quota-switch"
|
||||
/>
|
||||
<div v-if="!unlimitedQuota" class="quota-input-group">
|
||||
<el-input-number
|
||||
v-model="localFormData.premiumQuotaLimit"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:controls="true"
|
||||
controls-position="right"
|
||||
placeholder="请输入配额"
|
||||
class="quota-number"
|
||||
:disabled="submitting"
|
||||
/>
|
||||
<el-select
|
||||
v-model="localFormData.quotaUnit"
|
||||
class="quota-unit"
|
||||
placeholder="单位"
|
||||
:disabled="submitting"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in quotaUnitOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!unlimitedQuota" class="form-hint">
|
||||
<el-icon><i-ep-info-filled /></el-icon>
|
||||
超出配额后API密钥将无法继续使用
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button :disabled="submitting" @click="handleClose">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="submitting"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ mode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-item-with-switch {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.expire-switch,
|
||||
.quota-switch {
|
||||
--el-switch-on-color: #67c23a;
|
||||
}
|
||||
|
||||
.quota-input-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quota-number {
|
||||
flex: 1;
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.quota-unit {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
background: #f4f4f5;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #409eff;
|
||||
|
||||
.el-icon {
|
||||
font-size: 14px;
|
||||
color: #409eff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
:deep(.el-input__prefix) {
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'echarts/components';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { getLast7DaysTokenUsage, getModelTokenUsage } from '@/api';
|
||||
import { getLast7DaysTokenUsage, getModelTokenUsage, getSelectableTokenInfo } from '@/api';
|
||||
|
||||
// 注册必要的组件
|
||||
echarts.use([
|
||||
@@ -48,16 +48,54 @@ const totalTokens = ref(0);
|
||||
const usageData = ref<any[]>([]);
|
||||
const modelUsageData = ref<any[]>([]);
|
||||
|
||||
// Token选择相关
|
||||
const selectedTokenId = ref<string>(''); // 空字符串表示查询全部
|
||||
const tokenOptions = ref<any[]>([]);
|
||||
const tokenOptionsLoading = ref(false);
|
||||
|
||||
// 计算属性:是否有模型数据
|
||||
const hasModelData = computed(() => modelUsageData.value.length > 0);
|
||||
|
||||
// 计算属性:当前选择的token名称
|
||||
const selectedTokenName = computed(() => {
|
||||
if (!selectedTokenId.value)
|
||||
return '全部API密钥';
|
||||
const token = tokenOptions.value.find(t => t.tokenId === selectedTokenId.value);
|
||||
return token?.name || '未知API密钥';
|
||||
});
|
||||
|
||||
// 获取可选择的Token列表
|
||||
async function fetchTokenOptions() {
|
||||
try {
|
||||
tokenOptionsLoading.value = true;
|
||||
const res = await getSelectableTokenInfo();
|
||||
if (res.data) {
|
||||
// 不再过滤禁用的token,全部显示
|
||||
tokenOptions.value = res.data;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取API密钥列表失败:', error);
|
||||
ElMessage.error('获取TAPI密钥列表失败');
|
||||
}
|
||||
finally {
|
||||
tokenOptionsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Token选择变化
|
||||
function handleTokenChange() {
|
||||
fetchUsageData();
|
||||
}
|
||||
|
||||
// 获取用量数据
|
||||
async function fetchUsageData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const tokenId = selectedTokenId.value || undefined;
|
||||
const [res, res2] = await Promise.all([
|
||||
getLast7DaysTokenUsage(),
|
||||
getModelTokenUsage(),
|
||||
getLast7DaysTokenUsage(tokenId),
|
||||
getModelTokenUsage(tokenId),
|
||||
]);
|
||||
|
||||
usageData.value = res.data || [];
|
||||
@@ -235,49 +273,47 @@ function updatePieChart() {
|
||||
formatter: '{a} <br/>{b}: {c} tokens ({d}%)',
|
||||
},
|
||||
legend: {
|
||||
orient: isManyItems ? 'vertical' : 'horizontal',
|
||||
right: isManyItems ? 10 : 'auto',
|
||||
bottom: isManyItems ? 0 : 10,
|
||||
type: isManyItems ? 'scroll' : 'plain',
|
||||
pageIconColor: '#3a4de9',
|
||||
pageIconInactiveColor: '#ccc',
|
||||
pageTextStyle: { color: '#333' },
|
||||
itemGap: isSmallContainer ? 5 : 10,
|
||||
itemWidth: isSmallContainer ? 15 : 25,
|
||||
itemHeight: isSmallContainer ? 10 : 14,
|
||||
textStyle: {
|
||||
fontSize: isSmallContainer ? 10 : 12,
|
||||
},
|
||||
formatter(name: string) {
|
||||
return name.length > 15 ? `${name.substring(0, 12)}...` : name;
|
||||
},
|
||||
data: data.map(item => item.name),
|
||||
show: false, // 隐藏图例,使用标签线代替
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '模型用量',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
center: isManyItems ? ['40%', '50%'] : ['50%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
center: ['50%', '50%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center',
|
||||
show: true,
|
||||
position: 'outside',
|
||||
formatter: '{b}: {d}%',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
},
|
||||
labelLine: {
|
||||
show: false,
|
||||
show: true,
|
||||
length: 15,
|
||||
length2: 10,
|
||||
lineStyle: {
|
||||
width: 1.5,
|
||||
},
|
||||
},
|
||||
data,
|
||||
},
|
||||
@@ -453,6 +489,7 @@ watch([pieContainerSize.width, barContainerSize.width], () => {
|
||||
|
||||
onMounted(() => {
|
||||
initCharts();
|
||||
fetchTokenOptions();
|
||||
fetchUsageData();
|
||||
});
|
||||
|
||||
@@ -475,19 +512,56 @@ onBeforeUnmount(() => {
|
||||
<el-icon><PieChart /></el-icon>
|
||||
Token用量统计
|
||||
</h2>
|
||||
<el-button
|
||||
:icon="FullScreen"
|
||||
circle
|
||||
plain
|
||||
size="small"
|
||||
@click="toggleFullscreen"
|
||||
/>
|
||||
<div class="header-actions">
|
||||
<el-select
|
||||
v-model="selectedTokenId"
|
||||
placeholder="选择API密钥"
|
||||
clearable
|
||||
filterable
|
||||
:loading="tokenOptionsLoading"
|
||||
class="token-selector"
|
||||
@change="handleTokenChange"
|
||||
>
|
||||
<el-option label="全部Token" value="">
|
||||
<div class="token-option">
|
||||
<el-icon class="option-icon all-icon">
|
||||
<i-ep-folder-opened />
|
||||
</el-icon>
|
||||
<span class="option-label">全部Token</span>
|
||||
</div>
|
||||
</el-option>
|
||||
<el-option
|
||||
v-for="token in tokenOptions"
|
||||
:key="token.tokenId"
|
||||
:label="token.name"
|
||||
:value="token.tokenId"
|
||||
:disabled="token.isDisabled"
|
||||
>
|
||||
<div class="token-option" :class="{ 'disabled-token': token.isDisabled }">
|
||||
<el-icon class="option-icon" :class="{ 'disabled-icon': token.isDisabled }">
|
||||
<i-ep-key />
|
||||
</el-icon>
|
||||
<span class="option-label">{{ token.name }}</span>
|
||||
<el-tag v-if="token.isDisabled" type="info" size="small" effect="plain" class="disabled-tag">
|
||||
已禁用
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<el-button
|
||||
:icon="FullScreen"
|
||||
circle
|
||||
plain
|
||||
size="small"
|
||||
@click="toggleFullscreen"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading" class="chart-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">📊 近七天每日Token消耗量</span>
|
||||
<span class="card-title">📊 近七天每日Token消耗量{{ selectedTokenId ? ` (${selectedTokenName})` : '' }}</span>
|
||||
<el-tag type="primary" size="large" effect="dark">
|
||||
近七日总计: {{ totalTokens }} tokens
|
||||
</el-tag>
|
||||
@@ -501,7 +575,7 @@ onBeforeUnmount(() => {
|
||||
<el-card v-loading="loading" class="chart-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">🥧 各模型Token消耗占比</span>
|
||||
<span class="card-title">🥧 各模型Token消耗占比{{ selectedTokenId ? ` (${selectedTokenName})` : '' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="chart-container">
|
||||
@@ -512,7 +586,7 @@ onBeforeUnmount(() => {
|
||||
<el-card v-loading="loading" class="chart-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">📈 各模型总Token消耗量</span>
|
||||
<span class="card-title">📈 各模型总Token消耗量{{ selectedTokenId ? ` (${selectedTokenName})` : '' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="chart-container">
|
||||
@@ -560,6 +634,62 @@ onBeforeUnmount(() => {
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.token-selector {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.token-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
&.disabled-token {
|
||||
opacity: 0.6;
|
||||
|
||||
.option-label {
|
||||
text-decoration: line-through;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
color: #667eea;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.all-icon {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
&.disabled-icon {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
}
|
||||
|
||||
.option-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.disabled-tag {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -12,4 +12,4 @@ export const COLLAPSE_THRESHOLD: number = 600;
|
||||
export const SIDE_BAR_WIDTH: number = 280;
|
||||
|
||||
// 路由白名单地址[本地存在的路由 staticRouter.ts 中]
|
||||
export const ROUTER_WHITE_LIST: string[] = ['/chat', '/chat/not_login', '/products', '/403', '/404'];
|
||||
export const ROUTER_WHITE_LIST: string[] = ['/chat', '/chat/not_login', '/products', '/model-library', '/403', '/404'];
|
||||
|
||||
@@ -100,8 +100,8 @@ export function useGuideTour() {
|
||||
{
|
||||
element: '[data-tour="user-avatar"]',
|
||||
popover: {
|
||||
title: '用户中心',
|
||||
description: '点击头像可以进入用户中心,管理您的账户信息、查看使用统计、API密钥等。接下来将为您详细介绍用户中心的各项功能。',
|
||||
title: '控制台',
|
||||
description: '点击头像可以进入控制台,管理您的账户信息、查看使用统计、API密钥等。接下来将为您详细介绍用户中心的各项功能。',
|
||||
side: 'bottom',
|
||||
align: 'end',
|
||||
},
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
.layout-blank{
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
margin: 20px ;
|
||||
}
|
||||
/* 无样式 */
|
||||
</style>
|
||||
|
||||
@@ -29,7 +29,6 @@ useWindowWidthObserver();
|
||||
|
||||
// 应用加载时检查是否需要显示公告弹窗
|
||||
onMounted(() => {
|
||||
console.log('announcementStore.shouldShowDialog--', announcementStore.shouldShowDialog);
|
||||
// 检查是否应该显示弹窗(只有"关闭一周"且未超过7天才不显示)
|
||||
// 数据获取已移至 SystemAnnouncementDialog 组件内部,每次打开弹窗时都会获取最新数据
|
||||
if (announcementStore.shouldShowDialog) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
// 打开AI使用教程(跳转到外部链接)
|
||||
function openTutorial() {
|
||||
window.open('https://ccnetcore.com/article/3a1bc4d1-6a7d-751d-91cc-2817eb2ddcde', '_blank');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ai-tutorial-btn-container" data-tour="ai-tutorial-link">
|
||||
<div
|
||||
class="ai-tutorial-btn"
|
||||
title="点击跳转YiXinAI玩法指南专栏"
|
||||
@click="openTutorial"
|
||||
>
|
||||
<!-- PC端显示文字 -->
|
||||
<span class="pc-text">文档</span>
|
||||
<!-- 移动端显示图标 -->
|
||||
<svg
|
||||
class="mobile-icon w-6 h-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 14l9-5-9-5-9 5 9 5z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 14l6.16-3.422A12.083 12.083 0 0118 13.5c0 2.579-3.582 4.5-6 4.5s-6-1.921-6-4.5c0-.432.075-.85.198-1.244L12 14z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ai-tutorial-btn-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.ai-tutorial-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: #E6A23C;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #F1B44C;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
// PC端显示文字,隐藏图标
|
||||
.pc-text {
|
||||
display: inline;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.mobile-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端显示图标,隐藏文字
|
||||
@media (max-width: 768px) {
|
||||
.ai-tutorial-btn-container {
|
||||
.ai-tutorial-btn {
|
||||
.pc-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-icon {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { Bell } from '@element-plus/icons-vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useAnnouncementStore } from '@/stores';
|
||||
|
||||
@@ -30,14 +29,27 @@ function openAnnouncement() {
|
||||
<!-- :max="99" -->
|
||||
<div
|
||||
class="announcement-btn"
|
||||
title="查看公告"
|
||||
@click="openAnnouncement"
|
||||
>
|
||||
<!-- PC端显示文字 -->
|
||||
<span class="pc-text">公告/活动</span>
|
||||
<span class="pc-text">公告</span>
|
||||
<!-- 移动端显示图标 -->
|
||||
<el-icon class="mobile-icon" :size="20">
|
||||
<Bell />
|
||||
</el-icon>
|
||||
<svg
|
||||
class="mobile-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
</div>
|
||||
</el-badge>
|
||||
</div>
|
||||
@@ -62,11 +74,11 @@ function openAnnouncement() {
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: #e6a23c;
|
||||
color: #409eff;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #ebb563;
|
||||
color: #66b1ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ChatLineRound } from '@element-plus/icons-vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Popover from '@/components/Popover/index.vue';
|
||||
import SvgIcon from '@/components/SvgIcon/index.vue';
|
||||
import { useGuideTour } from '@/hooks/useGuideTour';
|
||||
import { useGuideTourStore, useUserStore } from '@/stores';
|
||||
import { useAnnouncementStore, useGuideTourStore, useUserStore } from '@/stores';
|
||||
import { useSessionStore } from '@/stores/modules/session';
|
||||
import { showProductPackage } from '@/utils/product-package';
|
||||
import { getUserProfilePicture, isUserVip } from '@/utils/user';
|
||||
|
||||
const router = useRouter();
|
||||
@@ -17,15 +16,9 @@ const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const sessionStore = useSessionStore();
|
||||
const guideTourStore = useGuideTourStore();
|
||||
const announcementStore = useAnnouncementStore();
|
||||
const { startUserCenterTour } = useGuideTour();
|
||||
|
||||
// const src = computed(
|
||||
// () => userStore.userInfo?.avatar ?? 'https://avatars.githubusercontent.com/u/76239030',
|
||||
// );
|
||||
const src = computed(
|
||||
() => userStore.userInfo?.user?.icon ? `${import.meta.env.VITE_WEB_BASE_API}/file/${userStore.userInfo.user.icon}` : `@/assets/images/logo.png`,
|
||||
);
|
||||
|
||||
/* 弹出面板 开始 */
|
||||
const popoverStyle = ref({
|
||||
width: '200px',
|
||||
@@ -36,25 +29,41 @@ const popoverRef = ref();
|
||||
|
||||
// 弹出面板内容
|
||||
const popoverList = ref([
|
||||
// {
|
||||
// key: '1',
|
||||
// title: '收藏夹',
|
||||
// icon: 'book-mark-fill',
|
||||
// },
|
||||
// {
|
||||
// key: '2',
|
||||
// title: '设置',
|
||||
// icon: 'settings-4-fill',
|
||||
// },
|
||||
|
||||
{
|
||||
key: '5',
|
||||
title: '用户中心',
|
||||
title: '控制台',
|
||||
icon: 'settings-4-fill',
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
key: '7',
|
||||
title: '公告',
|
||||
icon: 'notification-fill',
|
||||
},
|
||||
{
|
||||
key: '8',
|
||||
title: '模型库',
|
||||
icon: 'apps-fill',
|
||||
},
|
||||
{
|
||||
key: '9',
|
||||
title: '文档',
|
||||
icon: 'book-fill',
|
||||
},
|
||||
|
||||
{
|
||||
key: '6',
|
||||
title: '新手引导',
|
||||
icon: 'dashboard-fill',
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
title: '退出登录',
|
||||
@@ -100,7 +109,12 @@ function handleNavChange(nav: string) {
|
||||
function handleContactSupport() {
|
||||
rechargeLogRef.value?.contactCustomerService();
|
||||
}
|
||||
const { startHeaderTour } = useGuideTour();
|
||||
|
||||
// 开始引导教程
|
||||
function handleStartTutorial() {
|
||||
startHeaderTour();
|
||||
}
|
||||
// 点击
|
||||
function handleClick(item: any) {
|
||||
switch (item.key) {
|
||||
@@ -113,6 +127,24 @@ function handleClick(item: any) {
|
||||
case '5':
|
||||
openDialog();
|
||||
break;
|
||||
case '6':
|
||||
handleStartTutorial();
|
||||
break;
|
||||
case '7':
|
||||
// 打开公告
|
||||
popoverRef.value?.hide?.();
|
||||
announcementStore.openDialog();
|
||||
break;
|
||||
case '8':
|
||||
// 打开模型库
|
||||
popoverRef.value?.hide?.();
|
||||
router.push('/model-library');
|
||||
break;
|
||||
case '9':
|
||||
// 打开文档
|
||||
popoverRef.value?.hide?.();
|
||||
window.open('https://ccnetcore.com/article/3a1bc4d1-6a7d-751d-91cc-2817eb2ddcde', '_blank');
|
||||
break;
|
||||
case '4':
|
||||
popoverRef.value?.hide?.();
|
||||
ElMessageBox.confirm('退出登录不会丢失任何数据,你仍可以登录此账号。', '确认退出登录?', {
|
||||
@@ -187,11 +219,6 @@ function openVipGuide() {
|
||||
});
|
||||
}
|
||||
|
||||
/* 弹出面板 结束 */
|
||||
function onProductPackage() {
|
||||
showProductPackage();
|
||||
}
|
||||
|
||||
// ============ 监听对话框打开事件,切换到邀请码标签页 ============
|
||||
watch(dialogVisible, (newVal) => {
|
||||
if (newVal && externalInviteCode.value) {
|
||||
@@ -274,62 +301,17 @@ watch(() => guideTourStore.shouldStartUserCenterTour, (shouldStart) => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ============ 暴露方法供外部调用 ============
|
||||
defineExpose({
|
||||
openDialog,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- <div class="text-1.2xl font-bold text-gray-800 hover:text-blue-600 transition-colors"> -->
|
||||
<!-- <a -->
|
||||
<!-- href="https://ccnetcore.com/article/3a1bc4d1-6a7d-751d-91cc-2817eb2ddcde" -->
|
||||
<!-- target="_blank" -->
|
||||
<!-- class="flex items-center gap-2 group" -->
|
||||
<!-- style="color: #E6A23C;" -->
|
||||
<!-- title="点击跳转YiXinAI玩法指南专栏" -->
|
||||
<!-- > -->
|
||||
<!-- AI使用教程 -->
|
||||
<!-- </a> -->
|
||||
<!-- </div> -->
|
||||
|
||||
<div class="text-1.2xl font-bold text-gray-800 hover:text-blue-600 transition-colors" data-tour="ai-tutorial-link">
|
||||
<a
|
||||
href="https://ccnetcore.com/article/3a1bc4d1-6a7d-751d-91cc-2817eb2ddcde"
|
||||
target="_blank"
|
||||
class="flex items-center gap-2 group"
|
||||
style="color: #E6A23C;"
|
||||
title="点击跳转YiXinAI玩法指南专栏"
|
||||
>
|
||||
<!-- PC端显示文字 -->
|
||||
<span class="pc-text">AI使用教程</span>
|
||||
<!-- 移动端显示图标,这里用一个示例SVG,实际可以换成你想要的 -->
|
||||
<svg
|
||||
class="inline md:hidden w-6 h-6 text-yellow-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 14l9-5-9-5-9 5 9 5z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 14l6.16-3.422A12.083 12.083 0 0118 13.5c0 2.579-3.582 4.5-6 4.5s-6-1.921-6-4.5c0-.432.075-.85.198-1.244L12 14z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
class="buy-btn flex items-center gap-2 px-5 py-2 font-semibold shadow-lg"
|
||||
data-tour="buy-btn"
|
||||
@click="onProductPackage"
|
||||
>
|
||||
<span>立即购买</span>
|
||||
</el-button>
|
||||
<div class="flex items-center gap-2 ">
|
||||
<!-- 用户信息区域 -->
|
||||
<div class=" cursor-pointer flex flex-col text-right mr-2 leading-tight" @click="onProductPackage">
|
||||
<div class="user-info-display cursor-pointer flex flex-col text-right mr-2 leading-tight" @click="openDialog">
|
||||
<div class="text-sm font-semibold text-gray-800">
|
||||
{{ userStore.userInfo?.user.nick ?? '未登录用户' }}
|
||||
</div>
|
||||
@@ -412,7 +394,7 @@ watch(() => guideTourStore.shouldStartUserCenterTour, (shouldStart) => {
|
||||
</div>
|
||||
<nav-dialog
|
||||
v-model="dialogVisible"
|
||||
title="用户中心"
|
||||
title="控制台"
|
||||
:nav-items="navItems"
|
||||
:default-active="activeNav"
|
||||
@confirm="handleConfirm"
|
||||
@@ -483,62 +465,4 @@ watch(() => guideTourStore.shouldStartUserCenterTour, (shouldStart) => {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
.buy-btn {
|
||||
background: linear-gradient(90deg, #FFD700, #FFC107);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(255, 215, 0, 0.5);
|
||||
background: linear-gradient(90deg, #FFC107, #FFD700);
|
||||
}
|
||||
|
||||
.icon-rocket {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.animate-bounce {
|
||||
animation: bounce 1.2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
//移动端,屏幕小于756px
|
||||
@media screen and (max-width: 756px) {
|
||||
.buy-btn {
|
||||
background: linear-gradient(90deg, #FFD700, #FFC107);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
font-size: 12px;
|
||||
max-width: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
/* 默认 PC 端文字显示,图标隐藏 */
|
||||
.pc-text {
|
||||
display: inline;
|
||||
}
|
||||
.mobile-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 移动端显示图标,隐藏文字 */
|
||||
@media (max-width: 768px) {
|
||||
.pc-text {
|
||||
display: none;
|
||||
}
|
||||
.mobile-icon {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { showProductPackage } from '@/utils/product-package';
|
||||
|
||||
// 点击购买按钮
|
||||
function onProductPackage() {
|
||||
showProductPackage();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="buy-btn-container">
|
||||
<el-button
|
||||
class="buy-btn flex items-center gap-2 px-5 py-2 font-semibold shadow-lg"
|
||||
data-tour="buy-btn"
|
||||
@click="onProductPackage"
|
||||
>
|
||||
<span>立即购买</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.buy-btn-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 22px 0 0;
|
||||
|
||||
.buy-btn {
|
||||
background: linear-gradient(90deg, #FFD700, #FFC107);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(255, 215, 0, 0.5);
|
||||
background: linear-gradient(90deg, #FFC107, #FFD700);
|
||||
}
|
||||
|
||||
.icon-rocket {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.animate-bounce {
|
||||
animation: bounce 1.2s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端,屏幕小于756px
|
||||
@media screen and (max-width: 756px) {
|
||||
.buy-btn-container {
|
||||
margin: 0 ;
|
||||
|
||||
.buy-btn {
|
||||
font-size: 12px;
|
||||
max-width: 60px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/stores';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 打开用户中心对话框(通过调用 Avatar 组件的方法)
|
||||
function openConsole() {
|
||||
// 触发事件,由父组件处理
|
||||
emit('open-console');
|
||||
}
|
||||
|
||||
const emit = defineEmits(['open-console']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="console-btn-container" data-tour="console-btn">
|
||||
<div
|
||||
class="console-btn"
|
||||
title="打开控制台"
|
||||
@click="openConsole"
|
||||
>
|
||||
<!-- PC端显示文字 -->
|
||||
<span class="pc-text">控制台</span>
|
||||
<!-- 移动端显示图标 -->
|
||||
<svg
|
||||
class="mobile-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.console-btn-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.console-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: #606266;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #909399;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
// PC端显示文字,隐藏图标
|
||||
.pc-text {
|
||||
display: inline;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.mobile-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端显示图标,隐藏文字
|
||||
@media (max-width: 768px) {
|
||||
.console-btn-container {
|
||||
.console-btn {
|
||||
.pc-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-icon {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goToModelLibrary() {
|
||||
router.push('/model-library');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="model-library-btn-container" data-tour="model-library-btn">
|
||||
<div
|
||||
class="model-library-btn"
|
||||
title="查看模型库"
|
||||
@click="goToModelLibrary"
|
||||
>
|
||||
<!-- PC端显示文字 -->
|
||||
<span class="pc-text">模型库</span>
|
||||
<!-- 移动端显示图标 -->
|
||||
<svg
|
||||
class="mobile-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.model-library-btn-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.model-library-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: #606266;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #606266;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
// PC端显示文字,隐藏图标
|
||||
.pc-text {
|
||||
display: inline;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.mobile-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端显示图标,隐藏文字
|
||||
@media (max-width: 768px) {
|
||||
.model-library-btn-container {
|
||||
.model-library-btn {
|
||||
.pc-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-icon {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,18 +4,23 @@ import { onKeyStroke } from '@vueuse/core';
|
||||
import { SIDE_BAR_WIDTH } from '@/config/index';
|
||||
import { useDesignStore, useUserStore } from '@/stores';
|
||||
import { useSessionStore } from '@/stores/modules/session';
|
||||
import AiTutorialBtn from './components/AiTutorialBtn.vue';
|
||||
import AnnouncementBtn from './components/AnnouncementBtn.vue';
|
||||
import Avatar from './components/Avatar.vue';
|
||||
import BuyBtn from './components/BuyBtn.vue';
|
||||
import Collapse from './components/Collapse.vue';
|
||||
import ConsoleBtn from './components/ConsoleBtn.vue';
|
||||
import CreateChat from './components/CreateChat.vue';
|
||||
import LoginBtn from './components/LoginBtn.vue';
|
||||
import ModelLibraryBtn from './components/ModelLibraryBtn.vue';
|
||||
import TitleEditing from './components/TitleEditing.vue';
|
||||
import TutorialBtn from './components/TutorialBtn.vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const designStore = useDesignStore();
|
||||
const sessionStore = useSessionStore();
|
||||
|
||||
const avatarRef = ref();
|
||||
|
||||
const currentSession = computed(() => sessionStore.currentSession);
|
||||
|
||||
onMounted(() => {
|
||||
@@ -42,6 +47,11 @@ function handleCtrlK(event: KeyboardEvent) {
|
||||
onKeyStroke(event => event.ctrlKey && event.key.toLowerCase() === 'k', handleCtrlK, {
|
||||
passive: false,
|
||||
});
|
||||
|
||||
// 打开控制台
|
||||
function handleOpenConsole() {
|
||||
avatarRef.value?.openDialog?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -72,8 +82,11 @@ onKeyStroke(event => event.ctrlKey && event.key.toLowerCase() === 'k', handleCtr
|
||||
<!-- 右边 -->
|
||||
<div class="right-box flex h-full items-center pr-20px flex-shrink-0 mr-auto flex-row">
|
||||
<AnnouncementBtn />
|
||||
<TutorialBtn />
|
||||
<Avatar v-show="userStore.userInfo" />
|
||||
<ModelLibraryBtn />
|
||||
<AiTutorialBtn />
|
||||
<ConsoleBtn @open-console="handleOpenConsole" />
|
||||
<BuyBtn v-show="userStore.userInfo" />
|
||||
<Avatar v-show="userStore.userInfo" ref="avatarRef" />
|
||||
<LoginBtn v-show="!userStore.userInfo" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { ElMessage } from 'element-plus';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import ModelSelect from '@/components/ModelSelect/index.vue';
|
||||
import WelecomeText from '@/components/WelecomeText/index.vue';
|
||||
import { useGuideTour } from '@/hooks/useGuideTour';
|
||||
import { useGuideTourStore, useUserStore } from '@/stores';
|
||||
import { useFilesStore } from '@/stores/modules/files';
|
||||
|
||||
@@ -135,6 +134,8 @@ watch(
|
||||
</template>
|
||||
<template #prefix>
|
||||
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
|
||||
<FilesSelect />
|
||||
|
||||
<ModelSelect />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
|
||||
import type { BubbleListInstance } from 'vue-element-plus-x/types/BubbleList';
|
||||
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
|
||||
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
|
||||
import { ArrowLeftBold, ArrowRightBold, Loading } from '@element-plus/icons-vue';
|
||||
import { ArrowLeftBold, ArrowRightBold, Document, Loading } from '@element-plus/icons-vue';
|
||||
import { ElIcon, ElMessage } from 'element-plus';
|
||||
import { useHookFetch } from 'hook-fetch/vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
@@ -13,7 +13,7 @@ import { Sender } from 'vue-element-plus-x';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { send } from '@/api';
|
||||
import ModelSelect from '@/components/ModelSelect/index.vue';
|
||||
import { useGuideTour } from '@/hooks/useGuideTour';
|
||||
import YMarkdown from '@/vue-element-plus-y/components/XMarkdown/index.vue';
|
||||
import { useGuideTourStore } from '@/stores';
|
||||
import { useChatStore } from '@/stores/modules/chat';
|
||||
import { useFilesStore } from '@/stores/modules/files';
|
||||
@@ -30,6 +30,8 @@ type MessageItem = BubbleProps & {
|
||||
thinkingStatus?: ThinkingStatus;
|
||||
thinlCollapse?: boolean;
|
||||
reasoning_content?: string;
|
||||
images?: Array<{ url: string; name?: string }>; // 用户消息中的图片列表
|
||||
files?: Array<{ name: string; size: number }>; // 用户消息中的文件列表
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
@@ -114,7 +116,11 @@ watch(
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
// 封装数据处理逻辑
|
||||
/**
|
||||
* 处理流式响应的数据块
|
||||
* 解析 AI 返回的数据,更新消息内容和思考状态
|
||||
* @param {AnyObject} chunk - 流式响应的数据块
|
||||
*/
|
||||
function handleDataChunk(chunk: AnyObject) {
|
||||
try {
|
||||
// 安全获取 delta 和 content
|
||||
@@ -170,34 +176,130 @@ function handleDataChunk(chunk: AnyObject) {
|
||||
}
|
||||
}
|
||||
|
||||
// 封装错误处理逻辑
|
||||
/**
|
||||
* 处理错误信息
|
||||
* @param {any} err - 错误对象
|
||||
*/
|
||||
function handleError(err: any) {
|
||||
console.error('Fetch error:', err);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息并处理流式响应
|
||||
* 支持发送文本、图片和文件
|
||||
* @param {string} chatContent - 用户输入的文本内容
|
||||
*/
|
||||
async function startSSE(chatContent: string) {
|
||||
if (isSending.value)
|
||||
return;
|
||||
|
||||
// 检查是否有未上传完成的文件
|
||||
const hasUnuploadedFiles = filesStore.filesList.some(f => !f.isUploaded);
|
||||
if (hasUnuploadedFiles) {
|
||||
ElMessage.warning('文件正在上传中,请稍候...');
|
||||
return;
|
||||
}
|
||||
|
||||
isSending.value = true;
|
||||
|
||||
try {
|
||||
// 清空输入框
|
||||
inputValue.value = '';
|
||||
addMessage(chatContent, true);
|
||||
|
||||
// 获取当前上传的图片和文件(在清空之前保存)
|
||||
const imageFiles = filesStore.filesList.filter(f => f.isUploaded && f.fileType === 'image');
|
||||
const textFiles = filesStore.filesList.filter(f => f.isUploaded && f.fileType === 'text');
|
||||
|
||||
const images = imageFiles.map(f => ({
|
||||
url: f.base64!, // 使用base64作为URL
|
||||
name: f.name,
|
||||
}));
|
||||
|
||||
const files = textFiles.map(f => ({
|
||||
name: f.name!,
|
||||
size: f.fileSize!,
|
||||
}));
|
||||
|
||||
addMessage(chatContent, true, images, files);
|
||||
addMessage('', false);
|
||||
|
||||
// 立即清空文件列表(不要等到响应完成)
|
||||
filesStore.clearFilesList();
|
||||
|
||||
// 这里有必要调用一下 BubbleList 组件的滚动到底部 手动触发 自动滚动
|
||||
bubbleListRef.value?.scrollToBottom();
|
||||
|
||||
// 组装消息内容,支持图片和文件
|
||||
const messagesContent = bubbleItems.value.slice(0, -1).slice(-6).map((item: MessageItem) => {
|
||||
const baseMessage: any = {
|
||||
role: item.role,
|
||||
};
|
||||
|
||||
// 如果是用户消息且有附件(图片或文件),组装成数组格式
|
||||
if (item.role === 'user' && item.key === bubbleItems.value.length - 2) {
|
||||
// 当前发送的消息
|
||||
const contentArray: any[] = [];
|
||||
|
||||
// 添加文本内容
|
||||
if (item.content) {
|
||||
contentArray.push({
|
||||
type: 'text',
|
||||
text: item.content,
|
||||
});
|
||||
}
|
||||
|
||||
// 添加文本文件内容(使用XML格式)
|
||||
if (textFiles.length > 0) {
|
||||
let fileContent = '\n\n';
|
||||
textFiles.forEach((fileItem, index) => {
|
||||
fileContent += `<ATTACHMENT_FILE>\n`;
|
||||
fileContent += `<FILE_INDEX>File ${index + 1}</FILE_INDEX>\n`;
|
||||
fileContent += `<FILE_NAME>${fileItem.name}</FILE_NAME>\n`;
|
||||
fileContent += `<FILE_CONTENT>\n${fileItem.fileContent}\n</FILE_CONTENT>\n`;
|
||||
fileContent += `</ATTACHMENT_FILE>\n`;
|
||||
if (index < textFiles.length - 1) {
|
||||
fileContent += '\n';
|
||||
}
|
||||
});
|
||||
|
||||
contentArray.push({
|
||||
type: 'text',
|
||||
text: fileContent,
|
||||
});
|
||||
}
|
||||
|
||||
// 添加图片内容(使用之前保存的 imageFiles)
|
||||
imageFiles.forEach((fileItem) => {
|
||||
if (fileItem.base64) {
|
||||
contentArray.push({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: fileItem.base64, // 使用base64
|
||||
name: fileItem.name, // 保存图片名称
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 如果有图片或文件,使用数组格式
|
||||
if (contentArray.length > 1 || imageFiles.length > 0 || textFiles.length > 0) {
|
||||
baseMessage.content = contentArray;
|
||||
} else {
|
||||
baseMessage.content = item.content;
|
||||
}
|
||||
} else {
|
||||
// 其他消息保持原样
|
||||
baseMessage.content = (item.role === 'ai' || item.role === 'assistant') && item.content.length > 10000
|
||||
? `${item.content.substring(0, 10000)}...(内容过长,已省略)`
|
||||
: item.content;
|
||||
}
|
||||
|
||||
return baseMessage;
|
||||
});
|
||||
|
||||
// 使用 for-await 处理流式响应
|
||||
for await (const chunk of stream({
|
||||
messages: bubbleItems.value.slice(0, -1).slice(-6).map((item: MessageItem) => ({
|
||||
role: item.role,
|
||||
content: (item.role === 'ai' || item.role === 'assistant') && item.content.length > 10000
|
||||
? `${item.content.substring(0, 10000)}...(内容过长,已省略)`
|
||||
: item.content,
|
||||
})),
|
||||
messages: messagesContent,
|
||||
sessionId: route.params?.id !== 'not_login' ? String(route.params?.id) : 'not_login',
|
||||
stream: true,
|
||||
userId: userStore.userInfo?.userId,
|
||||
@@ -227,10 +329,18 @@ async function startSSE(chatContent: string) {
|
||||
latest.thinkingStatus = 'end';
|
||||
}
|
||||
}
|
||||
|
||||
// 保存聊天记录到 chatMap(本地缓存,刷新后可恢复)
|
||||
if (route.params?.id && route.params.id !== 'not_login') {
|
||||
chatStore.chatMap[`${route.params.id}`] = bubbleItems.value as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 中断请求
|
||||
/**
|
||||
* 中断正在进行的请求
|
||||
* 停止流式响应并重置状态
|
||||
*/
|
||||
async function cancelSSE() {
|
||||
try {
|
||||
cancel(); // 直接调用,无需参数
|
||||
@@ -249,8 +359,14 @@ async function cancelSSE() {
|
||||
}
|
||||
}
|
||||
|
||||
// 添加消息 - 维护聊天记录
|
||||
function addMessage(message: string, isUser: boolean) {
|
||||
/**
|
||||
* 添加消息到聊天列表
|
||||
* @param {string} message - 消息内容
|
||||
* @param {boolean} isUser - 是否为用户消息
|
||||
* @param {Array<{url: string, name?: string}>} images - 图片列表(可选)
|
||||
* @param {Array<{name: string, size: number}>} files - 文件列表(可选)
|
||||
*/
|
||||
function addMessage(message: string, isUser: boolean, images?: Array<{ url: string; name?: string }>, files?: Array<{ name: string; size: number }>) {
|
||||
const i = bubbleItems.value.length;
|
||||
const obj: MessageItem = {
|
||||
key: i,
|
||||
@@ -267,14 +383,26 @@ function addMessage(message: string, isUser: boolean) {
|
||||
thinkingStatus: 'start',
|
||||
thinlCollapse: false,
|
||||
noStyle: !isUser,
|
||||
images: images && images.length > 0 ? images : undefined,
|
||||
files: files && files.length > 0 ? files : undefined,
|
||||
};
|
||||
bubbleItems.value.push(obj);
|
||||
}
|
||||
|
||||
// 展开收起 事件展示
|
||||
/**
|
||||
* 处理思考链展开/收起状态变化
|
||||
* @param {Object} payload - 状态变化的载荷
|
||||
* @param {boolean} payload.value - 展开/收起状态
|
||||
* @param {ThinkingStatus} payload.status - 思考状态
|
||||
*/
|
||||
function handleChange(payload: { value: boolean; status: ThinkingStatus }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件卡片
|
||||
* @param {FilesCardProps} _item - 文件卡片项(未使用)
|
||||
* @param {number} index - 要删除的文件索引
|
||||
*/
|
||||
function handleDeleteCard(_item: FilesCardProps, index: number) {
|
||||
filesStore.deleteFileByIndex(index);
|
||||
}
|
||||
@@ -295,12 +423,24 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
// 复制
|
||||
/**
|
||||
* 复制消息内容到剪贴板
|
||||
* @param {any} item - 消息项
|
||||
*/
|
||||
function copy(item: any) {
|
||||
navigator.clipboard.writeText(item.content || '')
|
||||
.then(() => ElMessage.success('已复制到剪贴板'))
|
||||
.catch(() => ElMessage.error('复制失败'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片预览
|
||||
* 在新窗口中打开图片
|
||||
* @param {string} url - 图片 URL
|
||||
*/
|
||||
function handleImagePreview(url: string) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -316,10 +456,37 @@ function copy(item: any) {
|
||||
<!-- 自定义气泡内容 -->
|
||||
<template #content="{ item }">
|
||||
<!-- chat 内容走 markdown -->
|
||||
<XMarkdown v-if="item.content && (item.role === 'assistant' || item.role === 'system')" class="markdown-body" :markdown="item.content" :themes="{ light: 'github-light', dark: 'github-dark' }" default-theme-mode="dark" />
|
||||
<!-- user 内容 纯文本 -->
|
||||
<div v-if="item.content && item.role === 'user'" class="user-content">
|
||||
{{ item.content }}
|
||||
<YMarkdown v-if="item.content && (item.role === 'assistant' || item.role === 'system')" class="markdown-body" :markdown="item.content" :themes="{ light: 'github-light', dark: 'github-dark' }" default-theme-mode="dark" />
|
||||
<!-- user 内容 纯文本 + 图片 + 文件 -->
|
||||
<div v-if="item.role === 'user'" class="user-content-wrapper">
|
||||
<!-- 图片列表 -->
|
||||
<div v-if="item.images && item.images.length > 0" class="user-images">
|
||||
<img
|
||||
v-for="(image, index) in item.images"
|
||||
:key="index"
|
||||
:src="image.url"
|
||||
:alt="image.name || '图片'"
|
||||
class="user-image"
|
||||
@click="() => handleImagePreview(image.url)"
|
||||
>
|
||||
</div>
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="item.files && item.files.length > 0" class="user-files">
|
||||
<div
|
||||
v-for="(file, index) in item.files"
|
||||
:key="index"
|
||||
class="user-file-item"
|
||||
>
|
||||
<el-icon class="file-icon">
|
||||
<Document />
|
||||
</el-icon>
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 文本内容 -->
|
||||
<div v-if="item.content" class="user-content">
|
||||
{{ item.content }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -375,7 +542,7 @@ function copy(item: any) {
|
||||
</template>
|
||||
<template #prefix>
|
||||
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
|
||||
<!-- <FilesSelect /> -->
|
||||
<FilesSelect />
|
||||
<ModelSelect />
|
||||
</div>
|
||||
</template>
|
||||
@@ -421,6 +588,57 @@ function copy(item: any) {
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.user-content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.user-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.user-image {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
.user-files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.user-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
.file-icon {
|
||||
font-size: 16px;
|
||||
color: #409eff;
|
||||
}
|
||||
.file-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-size {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.user-content {
|
||||
// 换行
|
||||
white-space: pre-wrap;
|
||||
|
||||
175
Yi.Ai.Vue3/src/pages/chat/layouts/chatWithId/上传文件与图片需求.text
Normal file
175
Yi.Ai.Vue3/src/pages/chat/layouts/chatWithId/上传文件与图片需求.text
Normal file
File diff suppressed because one or more lines are too long
1105
Yi.Ai.Vue3/src/pages/modelLibrary/index.vue
Normal file
1105
Yi.Ai.Vue3/src/pages/modelLibrary/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user