Compare commits

..

77 Commits

Author SHA1 Message Date
chenchun
6f1efafd86 feat: 发布2.8版本 2025-12-17 12:10:24 +08:00
Gsh
2714a507d9 fix: 文件上传提示优化、element-plus-x版本回退 2025-12-16 22:54:43 +08:00
Gsh
9a9230786b fix: [临时方案]修复因element-plus-x 1.3.98 中Conversations组件销毁问题出现的布局路由缺陷 2025-12-16 22:00:15 +08:00
ccnetcore
4a8b58a65c build: 构建 2025-12-16 21:12:05 +08:00
ccnetcore
7d81f88658 feat: 完成包兼容 2025-12-16 21:08:26 +08:00
ccnetcore
0ce3c0bbdd feat:完成2.8 2025-12-15 23:59:04 +08:00
Gsh
981235e6e9 fix: 购买提示词优化 2025-12-15 21:28:24 +08:00
Gsh
d0ecb232a1 fix: 升级markdown包 2025-12-15 13:46:18 +08:00
Gsh
c7a52604e7 fix: 右上角导航优化 2025-12-14 21:34:20 +08:00
Gsh
da81b2d8a3 fix: 文件上传优化 2025-12-14 18:55:46 +08:00
ccnetcore
7b14fdd8de feat: 完成多message存储 2025-12-14 13:07:44 +08:00
ccnetcore
1fc2734eb7 feat: 新增忽略文件 2025-12-14 13:01:02 +08:00
ccnetcore
f3bef72ebb fix: 修复优惠 2025-12-14 11:43:21 +08:00
ccnetcore
7e6d2e829b feat: 修改优惠订单 2025-12-14 11:38:08 +08:00
Gsh
944626960b fix: 网页版增加对话文件支持 2025-12-14 00:54:34 +08:00
Gsh
c073868989 fix: 网页版增加对话图片支持 2025-12-13 18:09:12 +08:00
ccnetcore
d2981100fa feat: 支持gpt-5.2 2025-12-12 21:14:38 +08:00
chenchun
ce4f7e5711 refactor: 将 AnthropicInput.Messages 类型由 JsonElement? 更改为 IList<AnthropicMessageInput>
使用强类型消息集合,便于序列化与校验。
2025-12-12 09:40:24 +08:00
ccnetcore
cc812ba2cb Merge branch 'abp' into ai-hub 2025-12-11 23:33:33 +08:00
ccnetcore
8a6e5abf48 fix: 修复token鉴权 2025-12-11 23:32:57 +08:00
ccnetcore
8b191330b8 Revert "fix: 仅从 Query 获取 access_token/refresh_token,简化 OnMessageReceived 逻辑"
This reverts commit 0d2f2cb826.
2025-12-11 23:31:29 +08:00
Gsh
5ed79c6dd0 fix: vip取值优化 2025-12-11 21:47:48 +08:00
Gsh
6e2ca8f1c3 fix: 2.7 模型库优化 2025-12-11 21:35:32 +08:00
ccnetcore
a46a552097 feat: 完成模型库优化 2025-12-11 21:12:29 +08:00
chenchun
53e56134d4 Merge branch 'abp' into codex 2025-12-11 17:45:04 +08:00
chenchun
0d2f2cb826 fix: 仅从 Query 获取 access_token/refresh_token,简化 OnMessageReceived 逻辑
- 修改文件:Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs
- 将 JwtBearerEvents.OnMessageReceived 的上下文参数名改为 messageContext,统一变量名。
- 简化 Token 获取逻辑:只从 request.Query 中读取 access_token 与 refresh_token,移除从 Cookies(Token)和请求头(refresh_token)读取的分支。
2025-12-11 17:41:38 +08:00
chenchun
f90105ebb4 feat: 全站优化 2025-12-11 17:33:12 +08:00
chenchun
67ed1ac1e3 fix: 聊天模型列表仅返回 OpenAi 类型
在 Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/AiChatService.cs 中,为查询添加了 .Where(x => x.ModelApiType == ModelApiTypeEnum.OpenAi) 过滤,确保只返回 ModelType 为 Chat 且 ModelApiType 为 OpenAi 的模型,避免将非 OpenAi 的模型纳入聊天模型列表。
2025-12-11 17:17:35 +08:00
chenchun
69b84f6613 feat: 完成openai响应接口 2025-12-11 17:16:21 +08:00
ccnetcore
433d616b9b feat: 支持codex 2025-12-11 01:17:31 +08:00
chenchun
53aa575ad4 Merge branch 'abp' into ai-hub 2025-12-10 15:54:50 +08:00
chenchun
571df74c43 chore: 在 common.props 添加 SatelliteResourceLanguages=en;zh-CN
在 Yi.Abp.Net8/common.props 中新增 SatelliteResourceLanguages 属性,指定生成卫星资源语言为 en 和 zh-CN,以便打包对应的本地化资源。
2025-12-10 15:53:18 +08:00
chenchun
b7847c7e7d feat: 发布2.6版本 2025-12-10 15:14:45 +08:00
chenchun
94eb41996e Merge branch 'abp' into ai-hub 2025-12-10 15:11:44 +08:00
chenchun
cefde6848d perf: 去除35MB又臭又大的腾讯云sdk 2025-12-10 15:10:54 +08:00
chenchun
381b712b25 feat: 完成模型库功能模块 2025-12-10 15:08:16 +08:00
Gsh
c319b0b4e4 fix: 模型库优化 2025-12-10 01:34:40 +08:00
ccnetcore
1a32fa9e20 feat: 支持多选模型库条件 2025-12-10 00:31:14 +08:00
Gsh
909406238c fix: 模型库前端布局优化 2025-12-09 23:38:11 +08:00
chenchun
54a1d2a66f feat: 完成模型库 2025-12-09 19:11:30 +08:00
chenchun
8dcbfcad33 feat: 同步商品价格 2025-12-08 14:08:01 +08:00
ccnetcore
f64fd43951 Merge branch 'abp' into ai-hub 2025-12-07 18:50:37 +08:00
ccnetcore
551597765c perf: 优化sqlsguar分页查询 2025-12-07 18:50:02 +08:00
Gsh
bfda33280a fix: 图标显示优化 2025-12-05 23:32:59 +08:00
chenchun
8d0411f1f4 feat: 完成codefirst 2025-12-04 16:38:37 +08:00
chenchun
3995d4acab Merge branch 'token' into ai-hub 2025-12-04 16:35:17 +08:00
chenchun
6ff5727156 feat: 发布新版 2025-12-04 16:34:58 +08:00
chenchun
f654386dfe feat: 发布新版 2025-12-04 16:33:17 +08:00
chenchun
c03ef82643 feat:完成多token分发 2025-12-04 16:32:30 +08:00
Gsh
525545329b fix: 多api密钥增加分页 2025-11-30 00:04:33 +08:00
Gsh
755cb6f509 feat: 优化token用量查看 2025-11-29 23:44:38 +08:00
Gsh
55469708f0 feat: 新增多token用量查看 2025-11-29 23:29:54 +08:00
ccnetcore
94c52c62fe style: 修改token描述 2025-11-29 18:33:39 +08:00
ccnetcore
37b4709d76 feat: 新增token默认分组 2025-11-29 18:28:42 +08:00
ccnetcore
86555af6ce feat: 完成token下拉框 2025-11-29 18:25:43 +08:00
Gsh
ddb00879f4 feat: 新增多token功能 2025-11-29 17:35:17 +08:00
chenchun
2d0ca08314 feat: 新增功能 启动时初始化 AiHub 的 Message、Token、UsageStatistics 聚合根表并添加相应命名空间 2025-11-27 19:23:44 +08:00
chenchun
b78ecf27d5 feat: 完成token功能 2025-11-27 19:01:16 +08:00
Gsh
02a5f69958 feat: 前端2.4版本 2025-11-26 21:20:14 +08:00
Gsh
cf5bf746ef feat: 模型尊享标识优化 2025-11-25 22:14:48 +08:00
chenchun
0a5e40ee25 feat: 新增 PremiumPackageConst 模型 gpt-5.1-codex-max
在 Yi.Framework.AiHub.Domain.Shared/Consts/PremiumPackageConst.cs 的 premiumModels 列表中添加 "gpt-5.1-codex-max"(并补上末尾换行)。
2025-11-25 14:18:06 +08:00
chenchun
51a266ef58 feat: 在 PremiumPackageConst 中新增 claude-opus-4-5-20251101
文件:Yi.Framework.AiHub.Domain.Shared/Consts/PremiumPackageConst.cs
说明:向 premium package 列表中添加新模型标识 claude-opus-4-5-20251101,以支持该付费包。
2025-11-25 12:42:44 +08:00
chenchun
1f0901c90c feat: 新增功能
- 更新 PremiumPackageConst.ModeIds,新增支持的模型 ID:
  - claude-haiku-4-5-20251001
  - gemini-3-pro-preview
- 文件:Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Consts/PremiumPackageConst.cs
- 目的:扩展可识别的 premium 模型列表,便于后续对新模型的支持与路由处理

注意:修改后需重新编译并在相关使用处确认新模型 ID 的兼容性。
2025-11-25 10:57:08 +08:00
chenchun
a725c06396 fix: 移除对 Usage.TotalTokens 的空检查,始终按 multiplier 四舍五入并赋值
移除 TotalTokens 的 null 判断,避免保留 null 值,统一将其按 multiplier 四舍五入后赋为整数,防止后续使用出现空值异常。
2025-11-25 10:19:11 +08:00
chenchun
54547f0d7c fix: 缩放 ThorChatCompletionsResponse.Usage.TotalTokens 按 multiplier
当 Usage.TotalTokens 不为 null 时,按 multiplier 进行四舍五入缩放;与 PromptTokens/CompletionTokens 的缩放逻辑保持一致,修复 TotalTokens 未被缩放的问题。
2025-11-25 10:18:45 +08:00
chenchun
afe9c8bcae feat: 新增模型列表 IsPremiumPackage 字段并在 AiChatService 中设置
- 在 Yi.Framework.AiHub.Application.Contracts.Dtos.ModelGetListOutput 中新增 bool 属性 IsPremiumPackage。
- 在 Yi.Framework.AiHub.Application.Services.Chat.AiChatService 的模型映射中设置该属性,判断逻辑为 PremiumPackageConst.ModeIds.Contains(x.ModelId)。
- 便于前端区分并展示“尊享包”模型。
2025-11-25 09:59:31 +08:00
chenchun
688d93e5c1 feat: 完成倍率的配置化 2025-11-25 09:54:13 +08:00
chenchun
4c65b2398d fix: 将默认 max_tokens 从 100000 调整为 64000
将 Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorClaude/Chats/ClaudiaChatCompletionsService.cs 中对外请求的默认 max_tokens 值由 100000 降为 64000。

原因:避免超出模型/服务允许的 token 限制或引发资源/性能异常;仍然允许通过 input.MaxTokens 显式覆盖该默认值。已在本地构建并用简单请求验证变更生效。
2025-11-24 17:42:18 +08:00
chenchun
41435f1aa3 feat: 兼容maxtoken问题 2025-11-24 09:42:40 +08:00
chenchun
20206bbc44 fix: 调整 ThorClaude 聊天默认 max_tokens 从 2048 到 100000
修改文件:
Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorClaude/Chats/ClaudiaChatCompletionsService.cs

说明:
- 将默认 max_tokens 由 2048 提高到 100000,避免长回复被截断,提升对大输出场景的支持。
- 修改可能影响请求的响应长度与资源消耗,请确认后端/模型能够支持该上限并监控性能与计费变化。
2025-11-20 10:20:19 +08:00
chenchun
f2dc0d1825 fix: 仅对 gpt-5.1-chat 设置 MaxCompletionTokens,gpt-5-mini 单独处理 Temperature/TopP
将原先同时匹配 gpt-5.1-chat 与 gpt-5-mini 的处理拆分为两段:
- gpt-5.1-chat:仍将 MaxTokens 映射到 MaxCompletionTokens,并清空 Temperature/TopP。
- gpt-5-mini:只清空 Temperature/TopP,不再修改 MaxTokens/MaxCompletionTokens。

修复了为 gpt-5-mini 不当设置 MaxCompletionTokens 的问题。
2025-11-18 14:35:58 +08:00
chenchun
51b4d1b072 fix: 请求处理中同时重置 MaxTokens 避免与模型不兼容
在 YiFrameworkAiHubDomainModule 的请求处理器中,当清除 Temperature 与 TopP 时一并将 request.MaxTokens 设为 null,防止在不支持该参数的模型上出现错误或参数冲突。文件:Yi.Framework.AiHub.Domain/YiFrameworkAiHubDomainModule.cs。
2025-11-18 14:33:58 +08:00
chenchun
9180799e4e feat: 为 gpt-5-mini 与 databricks-claude-sonnet-4 添加请求特殊处理 2025-11-18 11:36:18 +08:00
chenchun
9788b9182b fix: 区分 gpt-5.1-chat 与 o1 的请求参数清理逻辑
将原先在同一处理器中对 gpt-5.1-chat 与 o1 一并清除 Temperature/TopP 的逻辑拆分为两个处理器:
- gpt-5.1-chat:清除 Temperature 与 TopP
- o1:仅清除 Temperature

文件:Yi.Framework.AiHub.Domain/YiFrameworkAiHubDomainModule.cs

目的:恢复/调整对不同模型的期望处理,避免对 o1 不必要地清除 TopP。
2025-11-18 11:26:05 +08:00
chenchun
260b9a4795 feat: 支持 gpt-5.1-chat 模型的特殊处理
- 将模型判断从仅 "o1" 扩展为 "gpt-5.1-chat" 或 "o1",对这些模型将 Temperature 置为 null。
- 微调了 User-Agent 字符串的空格并做了小范围的格式清理(增加空行以提升可读性)。
2025-11-18 10:39:34 +08:00
chenchun
9380e3daa8 Merge branch 'card-flip' into ai-hub 2025-11-18 10:27:53 +08:00
chenchun
8e8338743d fix: 修正 YiXinVip 枚举值及属性(8个月改为7个月,更新价格与显示名) 2025-11-10 17:03:54 +08:00
180 changed files with 19689 additions and 2306 deletions

2
.gitignore vendored
View File

@@ -278,3 +278,5 @@ database_backup
/Yi.Abp.Net8/src/Yi.Abp.Web/yi-abp-dev.db
package-lock.json
.claude

View File

@@ -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>

View File

@@ -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)

View File

@@ -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; }
}

View File

@@ -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();
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -62,4 +62,9 @@ public class ModelGetListOutput
/// 备注信息
/// </summary>
public string? Remark { get; set; }
/// <summary>
/// 是否为尊享包
/// </summary>
public bool IsPremiumPackage { get; set; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,9 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.UsageStatistics;
public class UsageStatisticsGetInput
{
/// <summary>
/// tokenId
/// </summary>
public Guid? TokenId { get; set; }
}

View File

@@ -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();
}

View File

@@ -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用量统计

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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"
];
}

View File

@@ -56,4 +56,9 @@ public class AiModelDescribe
/// 模型额外信息
/// </summary>
public string? ModelExtraInfo { get; set; }
/// <summary>
/// 模型倍率
/// </summary>
public decimal Multiplier { get; set; }
}

View File

@@ -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)
{

View File

@@ -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
};
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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);
}
}
}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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();
}
}

View File

@@ -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
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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; }

View File

@@ -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; }
}

View File

@@ -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)

View File

@@ -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>

View File

@@ -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 IdWeb端传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 IdWeb端传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 IdWeb端传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 IdWeb端传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 IdWeb端传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 IdWeb端传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 IdWeb端传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;

View File

@@ -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 IdWeb端传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 IdWeb端传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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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());
// }
// }
// }
// }

View File

@@ -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" />

View File

@@ -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);

View File

@@ -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;

View File

@@ -1,7 +1,8 @@
{
"permissions": {
"allow": [
"Bash(npx vue-tsc --noEmit)"
"Bash(npx vue-tsc --noEmit)",
"Bash(timeout 60 npx vue-tsc:*)"
],
"deny": [],
"ask": []

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -220,4 +220,16 @@ export interface ChatMessageVo {
* 用户id
*/
userId?: number;
/**
* 用户消息中的图片列表(前端扩展字段)
*/
images?: Array<{ url: string; name?: string }>;
/**
* 用户消息中的文件列表(前端扩展字段)
*/
files?: Array<{ name: string; size: number }>;
/**
* 创建时间(前端显示用)
*/
creationTime?: string;
}

View 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`;
}

View File

@@ -0,0 +1,3 @@
export interface UploadFileResponse {
id: string;
}

View File

@@ -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';

View File

@@ -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
}
] */

View File

@@ -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;
}

View 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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
// 图标文档 https://remixicon.com/
const props = defineProps<{
className?: string;
name: string;

View File

@@ -797,7 +797,7 @@ function generateShareContent(): string {
👉 点击链接立即参与我的专属邀请码链接:
${shareLink}
🍀 未注册用户,微信扫码登录,进入用户中心👉每周邀请 即可立即参与!`;
🍀 未注册用户,微信扫码登录,进入控制台👉每周邀请 即可立即参与!`;
}
/**

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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'];

View File

@@ -100,8 +100,8 @@ export function useGuideTour() {
{
element: '[data-tour="user-avatar"]',
popover: {
title: '用户中心',
description: '点击头像可以进入用户中心管理您的账户信息、查看使用统计、API密钥等。接下来将为您详细介绍用户中心的各项功能。',
title: '控制台',
description: '点击头像可以进入控制台管理您的账户信息、查看使用统计、API密钥等。接下来将为您详细介绍用户中心的各项功能。',
side: 'bottom',
align: 'end',
},

View File

@@ -13,7 +13,6 @@
.layout-blank{
height: 100vh;
overflow: auto;
margin: 20px ;
}
/* 无样式 */
</style>

View File

@@ -29,7 +29,6 @@ useWindowWidthObserver();
// 应用加载时检查是否需要显示公告弹窗
onMounted(() => {
console.log('announcementStore.shouldShowDialog--', announcementStore.shouldShowDialog);
// 检查是否应该显示弹窗(只有"关闭一周"且未超过7天才不显示
// 数据获取已移至 SystemAnnouncementDialog 组件内部,每次打开弹窗时都会获取最新数据
if (announcementStore.shouldShowDialog) {

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

File diff suppressed because one or more lines are too long

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