Compare commits

...

85 Commits

Author SHA1 Message Date
chenchun
1eca2cb273 perf: 优化版本 2025-12-17 16:50:00 +08:00
chenchun
28452b4c07 Merge branch 'ai-hub' into bubblelist 2025-12-17 16:25:37 +08:00
chenchun
340e2016d6 Merge remote-tracking branch 'origin/ai-hub' into ai-hub
# Conflicts:
#	Yi.Ai.Vue3/index.html
2025-12-17 16:10:05 +08:00
chenchun
5dfaead60e style: 更新版本描述 2025-12-17 16:09:36 +08:00
chenchun
c8acb12e4a style: 更新版本描述 2025-12-17 16:09:32 +08:00
chenchun
5fbcb8cbd9 style: 更新版本描述 2025-12-17 16:09:21 +08:00
chenchun
fd8d4399d3 perf: 优化markdown输出 2025-12-17 16:03:03 +08:00
chenchun
63490484e9 feat: 自定义列表 2025-12-17 15:42:13 +08:00
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 20057 additions and 2313 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,8 +112,8 @@
<body>
<!-- 加载动画容器 -->
<div id="yixinai-loader" class="loader-container">
<div class="loader-title">意心Ai 2.3</div>
<div class="loader-subtitle">海外地址仅首次访问预计加载约10秒</div>
<div class="loader-title">意心Ai 2.8</div>
<div class="loader-subtitle">海外地址仅首次访问预计加载约10秒,无需梯子</div>
<div class="loader-logo">
<div class="pulse-box"></div>
</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

@@ -0,0 +1,355 @@
<template>
<div class="chat-message-list" :style="{ maxHeight }">
<div ref="scrollContainer" class="chat-message-list__container">
<div class="chat-message-list__content">
<div
v-for="(item, index) in list"
:key="item.key || index"
class="chat-message-item"
:class="{
'chat-message-item--user': item.placement === 'end',
'chat-message-item--assistant': item.placement === 'start'
}"
>
<!-- 消息头部 -->
<div v-if="$slots.header" class="chat-message-item__header">
<slot name="header" :item="item" :index="index" />
</div>
<!-- 消息主体 -->
<div class="chat-message-item__body">
<!-- 头像 -->
<div v-if="item.avatar" class="chat-message-item__avatar">
<img
:src="item.avatar"
:style="{
width: item.avatarSize || '40px',
height: item.avatarSize || '40px'
}"
alt="avatar"
>
</div>
<!-- 消息内容 -->
<div
class="chat-message-item__content"
:class="{
'chat-message-item__content--no-style': item.noStyle
}"
>
<slot name="content" :item="item" :index="index">
{{ item.content }}
</slot>
</div>
</div>
<!-- 消息底部 -->
<div v-if="$slots.footer" class="chat-message-item__footer">
<slot name="footer" :item="item" :index="index" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue';
interface MessageItem {
key?: number;
avatar?: string;
avatarSize?: string;
placement?: 'start' | 'end';
content?: string;
noStyle?: boolean;
[key: string]: any;
}
interface Props {
list: MessageItem[];
maxHeight?: string;
}
const props = withDefaults(defineProps<Props>(), {
list: () => [],
maxHeight: '100%'
});
const scrollContainer = ref<HTMLDivElement | null>(null);
const autoScroll = ref(true); // 是否自动滚动到底部
const isUserScrolling = ref(false); // 用户是否正在手动滚动
let scrollTimeout: any = null;
let mutationObserver: MutationObserver | null = null;
let scrollRAF: number | null = null; // 用于存储 requestAnimationFrame ID
/**
* 检查是否滚动到底部
* 允许一定的误差范围5px
*/
function isScrolledToBottom(): boolean {
if (!scrollContainer.value) return false;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer.value;
return scrollHeight - scrollTop - clientHeight < 5;
}
/**
* 立即滚动到底部(用于内容快速变化时)
*/
function scrollToBottomImmediate() {
if (!scrollContainer.value || !autoScroll.value) return;
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight;
}
/**
* 平滑滚动到底部(使用 RAF 避免过度调用)
*/
function scrollToBottomSmooth() {
if (!scrollContainer.value || !autoScroll.value) return;
// 取消之前的 RAF避免重复调用
if (scrollRAF !== null) {
cancelAnimationFrame(scrollRAF);
}
scrollRAF = requestAnimationFrame(() => {
if (scrollContainer.value) {
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight;
}
scrollRAF = null;
});
}
/**
* 滚动到底部(立即)
*/
function scrollToBottom() {
nextTick(() => {
if (scrollContainer.value) {
autoScroll.value = true; // 重新启用自动滚动
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight;
}
});
}
/**
* 处理滚动事件
* 检测用户是否手动滚动离开底部
*/
function handleScroll() {
if (!scrollContainer.value) return;
// 清除之前的定时器
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
// 标记用户正在滚动
isUserScrolling.value = true;
// 检查是否在底部
const atBottom = isScrolledToBottom();
if (atBottom) {
// 如果滚动到底部,启用自动滚动
autoScroll.value = true;
} else {
// 如果不在底部,禁用自动滚动
autoScroll.value = false;
}
// 300ms 后标记用户停止滚动
scrollTimeout = setTimeout(() => {
isUserScrolling.value = false;
}, 300);
}
/**
* 监听内容变化,自动滚动到底部
*/
function observeContentChanges() {
if (!scrollContainer.value) return;
const contentElement = scrollContainer.value.querySelector('.chat-message-list__content');
if (!contentElement) return;
// 创建 MutationObserver 监听内容变化
mutationObserver = new MutationObserver((mutations) => {
// 如果启用了自动滚动且用户没有在滚动,则滚动到底部
if (autoScroll.value && !isUserScrolling.value) {
// 检查是否有文本内容变化characterData这通常是 SSE 流式输出
const hasCharacterDataChange = mutations.some(
mutation => mutation.type === 'characterData'
);
if (hasCharacterDataChange) {
// SSE 流式输出时使用立即滚动,避免跳动
scrollToBottomImmediate();
} else {
// 其他情况使用平滑滚动
scrollToBottomSmooth();
}
}
});
// 监听子元素的变化和文本内容的变化
mutationObserver.observe(contentElement, {
childList: true, // 监听子元素的添加/删除
subtree: true, // 监听所有后代元素
characterData: true, // 监听文本内容变化
attributes: false // 不监听属性变化
});
}
// 组件挂载时初始化
onMounted(() => {
if (scrollContainer.value) {
scrollContainer.value.addEventListener('scroll', handleScroll, { passive: true });
}
// 初始化时滚动到底部
nextTick(() => {
scrollToBottom();
// 开始监听内容变化
observeContentChanges();
});
});
// 组件卸载时清理
onUnmounted(() => {
if (scrollContainer.value) {
scrollContainer.value.removeEventListener('scroll', handleScroll);
}
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
if (scrollRAF !== null) {
cancelAnimationFrame(scrollRAF);
}
if (mutationObserver) {
mutationObserver.disconnect();
}
});
// 暴露方法给父组件调用
defineExpose({
scrollToBottom
});
</script>
<style scoped lang="scss">
.chat-message-list {
width: 100%;
height: 100%;
overflow: hidden;
&__container {
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
/* 美化滚动条 */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
&:hover {
background: rgba(0, 0, 0, 0.3);
}
}
}
&__content {
width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
}
}
.chat-message-item {
display: flex;
flex-direction: column;
width: 100%;
margin-bottom: 16px;
&__header {
width: 100%;
margin-bottom: 8px;
}
&__body {
display: flex;
gap: 12px;
width: 100%;
}
&__avatar {
flex-shrink: 0;
img {
border-radius: 50%;
object-fit: cover;
}
}
&__content {
flex: 1;
min-width: 0;
padding: 12px 16px;
background-color: #f5f5f5;
border-radius: 12px;
word-wrap: break-word;
overflow-wrap: break-word;
&--no-style {
background-color: transparent;
padding: 0;
}
}
&__footer {
width: 100%;
margin-top: 8px;
padding-left: 52px; /* 头像宽度 + gap */
}
/* 用户消息样式 */
&--user {
.chat-message-item__body {
flex-direction: row-reverse;
}
.chat-message-item__content {
background-color: #409eff;
color: white;
}
.chat-message-item__footer {
padding-left: 0;
padding-right: 52px;
text-align: right;
}
}
/* 助手消息样式 */
&--assistant {
.chat-message-item__body {
flex-direction: row;
}
.chat-message-item__content {
background-color: #f5f5f5;
}
}
}
</style>

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

@@ -2,10 +2,9 @@
<script setup lang="ts">
import type { AnyObject } from 'typescript-api-pro';
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 +12,8 @@ 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 ChatMessageList from '@/components/ChatMessageList/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();
@@ -48,7 +50,7 @@ const avatar = computed(() => {
const inputValue = ref('');
const senderRef = ref<InstanceType<typeof Sender> | null>(null);
const bubbleItems = ref<MessageItem[]>([]);
const bubbleListRef = ref<BubbleListInstance | null>(null);
const bubbleListRef = ref<InstanceType<typeof ChatMessageList> | null>(null);
const isSending = ref(false);
const { stream, loading: isLoading, cancel } = useHookFetch({
@@ -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,18 +423,30 @@ 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>
<div class="chat-with-id-container">
<div class="chat-warp">
<BubbleList ref="bubbleListRef" :list="bubbleItems" max-height="calc(100vh - 240px)">
<ChatMessageList ref="bubbleListRef" :list="bubbleItems" max-height="calc(100vh - 240px)">
<template #header="{ item }">
<Thinking
v-if="item.reasoning_content" v-model="item.thinlCollapse" :content="item.reasoning_content"
@@ -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>
@@ -336,7 +503,7 @@ function copy(item: any) {
</div>
</div>
</template>
</BubbleList>
</ChatMessageList>
<Sender
ref="senderRef" v-model="inputValue" class="chat-defaul-sender" data-tour="chat-sender" :auto-size="{
@@ -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>
@@ -410,10 +577,10 @@ function copy(item: any) {
}
}
:deep() {
.el-bubble-list {
.chat-message-list {
padding-top: 24px;
}
.el-bubble {
.chat-message-item {
padding: 0 12px;
padding-bottom: 24px;
}
@@ -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

Some files were not shown because too many files have changed in this diff Show More