Compare commits
41 Commits
ag-ui
...
63490484e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63490484e9 | ||
|
|
6f1efafd86 | ||
|
|
2714a507d9 | ||
|
|
9a9230786b | ||
|
|
4a8b58a65c | ||
|
|
7d81f88658 | ||
|
|
0ce3c0bbdd | ||
|
|
981235e6e9 | ||
|
|
d0ecb232a1 | ||
|
|
c7a52604e7 | ||
|
|
da81b2d8a3 | ||
|
|
7b14fdd8de | ||
|
|
1fc2734eb7 | ||
|
|
f3bef72ebb | ||
|
|
7e6d2e829b | ||
|
|
944626960b | ||
|
|
c073868989 | ||
|
|
d2981100fa | ||
|
|
ce4f7e5711 | ||
|
|
cc812ba2cb | ||
|
|
8a6e5abf48 | ||
|
|
8b191330b8 | ||
|
|
5ed79c6dd0 | ||
|
|
6e2ca8f1c3 | ||
|
|
a46a552097 | ||
|
|
53e56134d4 | ||
|
|
0d2f2cb826 | ||
|
|
f90105ebb4 | ||
|
|
67ed1ac1e3 | ||
|
|
69b84f6613 | ||
|
|
433d616b9b | ||
|
|
53aa575ad4 | ||
|
|
571df74c43 | ||
|
|
b7847c7e7d | ||
|
|
94eb41996e | ||
|
|
cefde6848d | ||
|
|
381b712b25 | ||
|
|
c319b0b4e4 | ||
|
|
1a32fa9e20 | ||
|
|
909406238c | ||
|
|
54a1d2a66f |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -278,3 +278,5 @@ database_backup
|
|||||||
/Yi.Abp.Net8/src/Yi.Abp.Web/yi-abp-dev.db
|
/Yi.Abp.Net8/src/Yi.Abp.Web/yi-abp-dev.db
|
||||||
|
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
|
.claude
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<SatelliteResourceLanguages>en;zh-CN</SatelliteResourceLanguages>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.0.0</Version>
|
||||||
<NoWarn>$(NoWarn);CS1591;CS8618;CS1998;CS8604;CS8620;CS8600;CS8602</NoWarn>
|
<NoWarn>$(NoWarn);CS1591;CS8618;CS1998;CS8604;CS8620;CS8600;CS8602</NoWarn>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// API类型选项
|
||||||
|
/// </summary>
|
||||||
|
public class ModelApiTypeOption
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 显示名称
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 枚举值
|
||||||
|
/// </summary>
|
||||||
|
public int Value { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using Yi.Framework.AiHub.Domain.Shared.Enums;
|
||||||
|
using Yi.Framework.AiHub.Domain.Shared.Extensions;
|
||||||
|
|
||||||
|
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模型库展示数据
|
||||||
|
/// </summary>
|
||||||
|
public class ModelLibraryDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 模型ID
|
||||||
|
/// </summary>
|
||||||
|
public string ModelId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模型名称
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模型描述
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模型类型
|
||||||
|
/// </summary>
|
||||||
|
public ModelTypeEnum ModelType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模型类型名称
|
||||||
|
/// </summary>
|
||||||
|
public string ModelTypeName => ModelType.GetDescription();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模型支持的API类型
|
||||||
|
/// </summary>
|
||||||
|
public List<ModelApiTypeOutput> ModelApiTypes { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模型显示倍率
|
||||||
|
/// </summary>
|
||||||
|
public decimal MultiplierShow { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 供应商分组名称
|
||||||
|
/// </summary>
|
||||||
|
public string? ProviderName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模型图标URL
|
||||||
|
/// </summary>
|
||||||
|
public string? IconUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否为尊享模型(PremiumChat类型)
|
||||||
|
/// </summary>
|
||||||
|
public bool IsPremium { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序
|
||||||
|
/// </summary>
|
||||||
|
public int OrderNum { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ModelApiTypeOutput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 模型类型
|
||||||
|
/// </summary>
|
||||||
|
public ModelApiTypeEnum ModelApiType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模型类型名称
|
||||||
|
/// </summary>
|
||||||
|
public string ModelApiTypeName => ModelApiType.GetDescription();
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Yi.Framework.AiHub.Domain.Shared.Enums;
|
||||||
|
using Yi.Framework.Ddd.Application.Contracts;
|
||||||
|
|
||||||
|
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取模型库列表查询参数
|
||||||
|
/// </summary>
|
||||||
|
public class ModelLibraryGetListInput : PagedAllResultRequestDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 搜索关键词(搜索模型名称、模型ID)
|
||||||
|
/// </summary>
|
||||||
|
public string? SearchKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 供应商名称筛选
|
||||||
|
/// </summary>
|
||||||
|
public List<string>? ProviderNames { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模型类型筛选
|
||||||
|
/// </summary>
|
||||||
|
public List<ModelTypeEnum>? ModelTypes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// API类型筛选
|
||||||
|
/// </summary>
|
||||||
|
public List<ModelApiTypeEnum>? ModelApiTypes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否只显示尊享模型
|
||||||
|
/// </summary>
|
||||||
|
public bool? IsPremiumOnly { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模型类型选项
|
||||||
|
/// </summary>
|
||||||
|
public class ModelTypeOption
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 显示名称
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 枚举值
|
||||||
|
/// </summary>
|
||||||
|
public int Value { get; set; }
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ public class AiChatService : ApplicationService
|
|||||||
{
|
{
|
||||||
var output = await _aiModelRepository._DbQueryable
|
var output = await _aiModelRepository._DbQueryable
|
||||||
.Where(x => x.ModelType == ModelTypeEnum.Chat)
|
.Where(x => x.ModelType == ModelTypeEnum.Chat)
|
||||||
|
.Where(x => x.ModelApiType == ModelApiTypeEnum.OpenAi)
|
||||||
.OrderByDescending(x => x.OrderNum)
|
.OrderByDescending(x => x.OrderNum)
|
||||||
.Select(x => new ModelGetListOutput
|
.Select(x => new ModelGetListOutput
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Embeddings;
|
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.Images;
|
||||||
|
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
|
||||||
using Yi.Framework.AiHub.Domain.Shared.Enums;
|
using Yi.Framework.AiHub.Domain.Shared.Enums;
|
||||||
using Yi.Framework.Rbac.Application.Contracts.IServices;
|
using Yi.Framework.Rbac.Application.Contracts.IServices;
|
||||||
using Yi.Framework.SqlSugarCore.Abstractions;
|
using Yi.Framework.SqlSugarCore.Abstractions;
|
||||||
@@ -85,6 +86,7 @@ public class OpenApiService : ApplicationService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 图片生成
|
/// 图片生成
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -102,6 +104,7 @@ public class OpenApiService : ApplicationService
|
|||||||
await _aiGateWayManager.CreateImageForStatisticsAsync(httpContext, userId, null, input, tokenId);
|
await _aiGateWayManager.CreateImageForStatisticsAsync(httpContext, userId, null, input, tokenId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 向量生成
|
/// 向量生成
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -145,7 +148,7 @@ public class OpenApiService : ApplicationService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Anthropic对话(尊享服务专用)
|
/// Anthropic对话(尊享服务专用)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -185,18 +188,72 @@ public class OpenApiService : ApplicationService
|
|||||||
//ai网关代理httpcontext
|
//ai网关代理httpcontext
|
||||||
if (input.Stream)
|
if (input.Stream)
|
||||||
{
|
{
|
||||||
await _aiGateWayManager.AnthropicCompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
|
await _aiGateWayManager.AnthropicCompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext,
|
||||||
|
input,
|
||||||
userId, null, tokenId, cancellationToken);
|
userId, null, tokenId, cancellationToken);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await _aiGateWayManager.AnthropicCompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input, userId,
|
await _aiGateWayManager.AnthropicCompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input,
|
||||||
|
userId,
|
||||||
null, tokenId,
|
null, tokenId,
|
||||||
cancellationToken);
|
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 私有
|
#region 私有
|
||||||
|
|
||||||
private string? GetTokenByHttpContext(HttpContext httpContext)
|
private string? GetTokenByHttpContext(HttpContext httpContext)
|
||||||
@@ -210,7 +267,8 @@ public class OpenApiService : ApplicationService
|
|||||||
|
|
||||||
// 再检查 Authorization 头
|
// 再检查 Authorization 头
|
||||||
string authHeader = httpContext.Request.Headers["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();
|
return authHeader.Substring("Bearer ".Length).Trim();
|
||||||
}
|
}
|
||||||
@@ -227,5 +285,4 @@ public class OpenApiService : ApplicationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ public class PremiumPackageConst
|
|||||||
"claude-haiku-4-5-20251001",
|
"claude-haiku-4-5-20251001",
|
||||||
"claude-opus-4-5-20251101",
|
"claude-opus-4-5-20251101",
|
||||||
"gemini-3-pro-preview",
|
"gemini-3-pro-preview",
|
||||||
"gpt-5.1-codex-max"
|
"gpt-5.1-codex-max",
|
||||||
|
"gpt-5.2"
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1,648 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
|
|
||||||
|
|
||||||
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
|
|
||||||
|
|
||||||
public static class AnthropicToOpenAi
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 将AnthropicInput转换为ThorChatCompletionsRequest
|
|
||||||
/// </summary>
|
|
||||||
public static ThorChatCompletionsRequest ConvertAnthropicToOpenAi(AnthropicInput anthropicInput)
|
|
||||||
{
|
|
||||||
var openAiRequest = new ThorChatCompletionsRequest
|
|
||||||
{
|
|
||||||
Model = anthropicInput.Model,
|
|
||||||
MaxTokens = anthropicInput.MaxTokens,
|
|
||||||
Stream = anthropicInput.Stream,
|
|
||||||
Messages = new List<ThorChatMessage>(anthropicInput.Messages.Count)
|
|
||||||
};
|
|
||||||
|
|
||||||
// high medium minimal low
|
|
||||||
if (openAiRequest.Model.EndsWith("-high") ||
|
|
||||||
openAiRequest.Model.EndsWith("-medium") ||
|
|
||||||
openAiRequest.Model.EndsWith("-minimal") ||
|
|
||||||
openAiRequest.Model.EndsWith("-low"))
|
|
||||||
{
|
|
||||||
openAiRequest.ReasoningEffort = openAiRequest.Model switch
|
|
||||||
{
|
|
||||||
var model when model.EndsWith("-high") => "high",
|
|
||||||
var model when model.EndsWith("-medium") => "medium",
|
|
||||||
var model when model.EndsWith("-minimal") => "minimal",
|
|
||||||
var model when model.EndsWith("-low") => "low",
|
|
||||||
_ => "medium"
|
|
||||||
};
|
|
||||||
|
|
||||||
openAiRequest.Model = openAiRequest.Model.Replace("-high", "")
|
|
||||||
.Replace("-medium", "")
|
|
||||||
.Replace("-minimal", "")
|
|
||||||
.Replace("-low", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (anthropicInput.Thinking != null &&
|
|
||||||
anthropicInput.Thinking.Type.Equals("enabled", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
openAiRequest.Thinking = new ThorChatClaudeThinking()
|
|
||||||
{
|
|
||||||
BudgetToken = anthropicInput.Thinking.BudgetTokens,
|
|
||||||
Type = "enabled",
|
|
||||||
};
|
|
||||||
openAiRequest.EnableThinking = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (openAiRequest.Model.EndsWith("-thinking"))
|
|
||||||
{
|
|
||||||
openAiRequest.EnableThinking = true;
|
|
||||||
openAiRequest.Model = openAiRequest.Model.Replace("-thinking", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (openAiRequest.Stream == true)
|
|
||||||
{
|
|
||||||
openAiRequest.StreamOptions = new ThorStreamOptions()
|
|
||||||
{
|
|
||||||
IncludeUsage = true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(anthropicInput.System))
|
|
||||||
{
|
|
||||||
openAiRequest.Messages.Add(ThorChatMessage.CreateSystemMessage(anthropicInput.System));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (anthropicInput.Systems?.Count > 0)
|
|
||||||
{
|
|
||||||
foreach (var systemContent in anthropicInput.Systems)
|
|
||||||
{
|
|
||||||
openAiRequest.Messages.Add(ThorChatMessage.CreateSystemMessage(systemContent.Text ?? string.Empty));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理messages
|
|
||||||
if (anthropicInput.Messages != null)
|
|
||||||
{
|
|
||||||
foreach (var message in anthropicInput.Messages)
|
|
||||||
{
|
|
||||||
var thorMessages = ConvertAnthropicMessageToThor(message);
|
|
||||||
// 需要过滤 空消息
|
|
||||||
if (thorMessages.Count == 0)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
openAiRequest.Messages.AddRange(thorMessages);
|
|
||||||
}
|
|
||||||
|
|
||||||
openAiRequest.Messages = openAiRequest.Messages
|
|
||||||
.Where(m => !string.IsNullOrEmpty(m.Content) || m.Contents?.Count > 0 || m.ToolCalls?.Count > 0 ||
|
|
||||||
!string.IsNullOrEmpty(m.ToolCallId))
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理tools
|
|
||||||
if (anthropicInput.Tools is { Count: > 0 })
|
|
||||||
{
|
|
||||||
openAiRequest.Tools = anthropicInput.Tools.Where(x => x.name != "web_search")
|
|
||||||
.Select(ConvertAnthropicToolToThor).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 判断是否存在web_search
|
|
||||||
if (anthropicInput.Tools?.Any(x => x.name == "web_search") == true)
|
|
||||||
{
|
|
||||||
openAiRequest.WebSearchOptions = new ThorChatWebSearchOptions()
|
|
||||||
{
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理tool_choice
|
|
||||||
if (anthropicInput.ToolChoice != null)
|
|
||||||
{
|
|
||||||
openAiRequest.ToolChoice = ConvertAnthropicToolChoiceToThor(anthropicInput.ToolChoice);
|
|
||||||
}
|
|
||||||
|
|
||||||
return openAiRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 根据最后的内容块类型和OpenAI的完成原因确定Claude的停止原因
|
|
||||||
/// </summary>
|
|
||||||
public static string GetStopReasonByLastContentType(string? openAiFinishReason, string lastContentBlockType)
|
|
||||||
{
|
|
||||||
// 如果最后一个内容块是工具调用,优先返回tool_use
|
|
||||||
if (lastContentBlockType == "tool_use")
|
|
||||||
{
|
|
||||||
return "tool_use";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 否则使用标准的转换逻辑
|
|
||||||
return GetClaudeStopReason(openAiFinishReason);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建message_start事件
|
|
||||||
/// </summary>
|
|
||||||
public static AnthropicStreamDto CreateMessageStartEvent(string messageId, string model)
|
|
||||||
{
|
|
||||||
return new AnthropicStreamDto
|
|
||||||
{
|
|
||||||
Type = "message_start",
|
|
||||||
Message = new AnthropicChatCompletionDto
|
|
||||||
{
|
|
||||||
id = messageId,
|
|
||||||
type = "message",
|
|
||||||
role = "assistant",
|
|
||||||
model = model,
|
|
||||||
content = new AnthropicChatCompletionDtoContent[0],
|
|
||||||
Usage = new AnthropicCompletionDtoUsage
|
|
||||||
{
|
|
||||||
InputTokens = 0,
|
|
||||||
OutputTokens = 0,
|
|
||||||
CacheCreationInputTokens = 0,
|
|
||||||
CacheReadInputTokens = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建content_block_start事件
|
|
||||||
/// </summary>
|
|
||||||
public static AnthropicStreamDto CreateContentBlockStartEvent()
|
|
||||||
{
|
|
||||||
return new AnthropicStreamDto
|
|
||||||
{
|
|
||||||
Type = "content_block_start",
|
|
||||||
Index = 0,
|
|
||||||
ContentBlock = new AnthropicChatCompletionDtoContentBlock
|
|
||||||
{
|
|
||||||
Type = "text",
|
|
||||||
Id = null,
|
|
||||||
Name = null
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建thinking block start事件
|
|
||||||
/// </summary>
|
|
||||||
public static AnthropicStreamDto CreateThinkingBlockStartEvent()
|
|
||||||
{
|
|
||||||
return new AnthropicStreamDto
|
|
||||||
{
|
|
||||||
Type = "content_block_start",
|
|
||||||
Index = 0,
|
|
||||||
ContentBlock = new AnthropicChatCompletionDtoContentBlock
|
|
||||||
{
|
|
||||||
Type = "thinking",
|
|
||||||
Id = null,
|
|
||||||
Name = null
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建content_block_delta事件
|
|
||||||
/// </summary>
|
|
||||||
public static AnthropicStreamDto CreateContentBlockDeltaEvent(string text)
|
|
||||||
{
|
|
||||||
return new AnthropicStreamDto
|
|
||||||
{
|
|
||||||
Type = "content_block_delta",
|
|
||||||
Index = 0,
|
|
||||||
Delta = new AnthropicChatCompletionDtoDelta
|
|
||||||
{
|
|
||||||
Type = "text_delta",
|
|
||||||
Text = text
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建thinking delta事件
|
|
||||||
/// </summary>
|
|
||||||
public static AnthropicStreamDto CreateThinkingBlockDeltaEvent(string thinking)
|
|
||||||
{
|
|
||||||
return new AnthropicStreamDto
|
|
||||||
{
|
|
||||||
Type = "content_block_delta",
|
|
||||||
Index = 0,
|
|
||||||
Delta = new AnthropicChatCompletionDtoDelta
|
|
||||||
{
|
|
||||||
Type = "thinking",
|
|
||||||
Thinking = thinking
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建content_block_stop事件
|
|
||||||
/// </summary>
|
|
||||||
public static AnthropicStreamDto CreateContentBlockStopEvent()
|
|
||||||
{
|
|
||||||
return new AnthropicStreamDto
|
|
||||||
{
|
|
||||||
Type = "content_block_stop",
|
|
||||||
Index = 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建message_delta事件
|
|
||||||
/// </summary>
|
|
||||||
public static AnthropicStreamDto CreateMessageDeltaEvent(string finishReason, AnthropicCompletionDtoUsage usage)
|
|
||||||
{
|
|
||||||
return new AnthropicStreamDto
|
|
||||||
{
|
|
||||||
Type = "message_delta",
|
|
||||||
Usage = usage,
|
|
||||||
Delta = new AnthropicChatCompletionDtoDelta
|
|
||||||
{
|
|
||||||
StopReason = finishReason
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建message_stop事件
|
|
||||||
/// </summary>
|
|
||||||
public static AnthropicStreamDto CreateMessageStopEvent()
|
|
||||||
{
|
|
||||||
return new AnthropicStreamDto
|
|
||||||
{
|
|
||||||
Type = "message_stop"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建tool block start事件
|
|
||||||
/// </summary>
|
|
||||||
public static AnthropicStreamDto CreateToolBlockStartEvent(string? toolId, string? toolName)
|
|
||||||
{
|
|
||||||
return new AnthropicStreamDto
|
|
||||||
{
|
|
||||||
Type = "content_block_start",
|
|
||||||
Index = 0,
|
|
||||||
ContentBlock = new AnthropicChatCompletionDtoContentBlock
|
|
||||||
{
|
|
||||||
Type = "tool_use",
|
|
||||||
Id = toolId,
|
|
||||||
Name = toolName
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建tool delta事件
|
|
||||||
/// </summary>
|
|
||||||
public static AnthropicStreamDto CreateToolBlockDeltaEvent(string partialJson)
|
|
||||||
{
|
|
||||||
return new AnthropicStreamDto
|
|
||||||
{
|
|
||||||
Type = "content_block_delta",
|
|
||||||
Index = 0,
|
|
||||||
Delta = new AnthropicChatCompletionDtoDelta
|
|
||||||
{
|
|
||||||
Type = "input_json_delta",
|
|
||||||
PartialJson = partialJson
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 转换Anthropic消息为Thor消息列表
|
|
||||||
/// </summary>
|
|
||||||
public static List<ThorChatMessage> ConvertAnthropicMessageToThor(AnthropicMessageInput anthropicMessage)
|
|
||||||
{
|
|
||||||
var results = new List<ThorChatMessage>();
|
|
||||||
|
|
||||||
// 处理简单的字符串内容
|
|
||||||
if (anthropicMessage.Content != null)
|
|
||||||
{
|
|
||||||
var thorMessage = new ThorChatMessage
|
|
||||||
{
|
|
||||||
Role = anthropicMessage.Role,
|
|
||||||
Content = anthropicMessage.Content
|
|
||||||
};
|
|
||||||
results.Add(thorMessage);
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理多模态内容
|
|
||||||
if (anthropicMessage.Contents is { Count: > 0 })
|
|
||||||
{
|
|
||||||
var currentContents = new List<ThorChatMessageContent>();
|
|
||||||
var currentToolCalls = new List<ThorToolCall>();
|
|
||||||
|
|
||||||
foreach (var content in anthropicMessage.Contents)
|
|
||||||
{
|
|
||||||
switch (content.Type)
|
|
||||||
{
|
|
||||||
case "text":
|
|
||||||
currentContents.Add(ThorChatMessageContent.CreateTextContent(content.Text ?? string.Empty));
|
|
||||||
break;
|
|
||||||
case "thinking" when !string.IsNullOrEmpty(content.Thinking):
|
|
||||||
results.Add(new ThorChatMessage()
|
|
||||||
{
|
|
||||||
ReasoningContent = content.Thinking
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case "image":
|
|
||||||
{
|
|
||||||
if (content.Source != null)
|
|
||||||
{
|
|
||||||
var imageUrl = content.Source.Type == "base64"
|
|
||||||
? $"data:{content.Source.MediaType};base64,{content.Source.Data}"
|
|
||||||
: content.Source.Data;
|
|
||||||
currentContents.Add(ThorChatMessageContent.CreateImageUrlContent(imageUrl ?? string.Empty));
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "tool_use":
|
|
||||||
{
|
|
||||||
// 如果有普通内容,先创建内容消息
|
|
||||||
if (currentContents.Count > 0)
|
|
||||||
{
|
|
||||||
if (currentContents.Count == 1 && currentContents.Any(x => x.Type == "text"))
|
|
||||||
{
|
|
||||||
var contentMessage = new ThorChatMessage
|
|
||||||
{
|
|
||||||
Role = anthropicMessage.Role,
|
|
||||||
ContentCalculated = currentContents.FirstOrDefault()?.Text ?? string.Empty
|
|
||||||
};
|
|
||||||
results.Add(contentMessage);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var contentMessage = new ThorChatMessage
|
|
||||||
{
|
|
||||||
Role = anthropicMessage.Role,
|
|
||||||
Contents = currentContents
|
|
||||||
};
|
|
||||||
results.Add(contentMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentContents = new List<ThorChatMessageContent>();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 收集工具调用
|
|
||||||
var toolCall = new ThorToolCall
|
|
||||||
{
|
|
||||||
Id = content.Id,
|
|
||||||
Type = "function",
|
|
||||||
Function = new ThorChatMessageFunction
|
|
||||||
{
|
|
||||||
Name = content.Name,
|
|
||||||
Arguments = JsonSerializer.Serialize(content.Input)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
currentToolCalls.Add(toolCall);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "tool_result":
|
|
||||||
{
|
|
||||||
// 如果有普通内容,先创建内容消息
|
|
||||||
if (currentContents.Count > 0)
|
|
||||||
{
|
|
||||||
var contentMessage = new ThorChatMessage
|
|
||||||
{
|
|
||||||
Role = anthropicMessage.Role,
|
|
||||||
Contents = currentContents
|
|
||||||
};
|
|
||||||
results.Add(contentMessage);
|
|
||||||
currentContents = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果有工具调用,先创建工具调用消息
|
|
||||||
if (currentToolCalls.Count > 0)
|
|
||||||
{
|
|
||||||
var toolCallMessage = new ThorChatMessage
|
|
||||||
{
|
|
||||||
Role = anthropicMessage.Role,
|
|
||||||
ToolCalls = currentToolCalls
|
|
||||||
};
|
|
||||||
results.Add(toolCallMessage);
|
|
||||||
currentToolCalls = new List<ThorToolCall>();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建工具结果消息
|
|
||||||
var toolMessage = new ThorChatMessage
|
|
||||||
{
|
|
||||||
Role = "tool",
|
|
||||||
ToolCallId = content.ToolUseId,
|
|
||||||
Content = content.Content?.ToString() ?? string.Empty
|
|
||||||
};
|
|
||||||
results.Add(toolMessage);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理剩余的内容
|
|
||||||
if (currentContents.Count > 0)
|
|
||||||
{
|
|
||||||
var contentMessage = new ThorChatMessage
|
|
||||||
{
|
|
||||||
Role = anthropicMessage.Role,
|
|
||||||
Contents = currentContents
|
|
||||||
};
|
|
||||||
results.Add(contentMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理剩余的工具调用
|
|
||||||
if (currentToolCalls.Count > 0)
|
|
||||||
{
|
|
||||||
var toolCallMessage = new ThorChatMessage
|
|
||||||
{
|
|
||||||
Role = anthropicMessage.Role,
|
|
||||||
ToolCalls = currentToolCalls
|
|
||||||
};
|
|
||||||
results.Add(toolCallMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有任何内容,返回一个空的消息
|
|
||||||
if (results.Count == 0)
|
|
||||||
{
|
|
||||||
results.Add(new ThorChatMessage
|
|
||||||
{
|
|
||||||
Role = anthropicMessage.Role,
|
|
||||||
Content = string.Empty
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果只有一个text则使用content字段
|
|
||||||
if (results is [{ Contents.Count: 1 }] &&
|
|
||||||
results.FirstOrDefault()?.Contents?.FirstOrDefault()?.Type == "text" &&
|
|
||||||
!string.IsNullOrEmpty(results.FirstOrDefault()?.Contents?.FirstOrDefault()?.Text))
|
|
||||||
{
|
|
||||||
return
|
|
||||||
[
|
|
||||||
new ThorChatMessage
|
|
||||||
{
|
|
||||||
Role = results[0].Role,
|
|
||||||
Content = results.FirstOrDefault()?.Contents?.FirstOrDefault()?.Text ?? string.Empty
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 转换Anthropic工具为Thor工具
|
|
||||||
/// </summary>
|
|
||||||
public static ThorToolDefinition ConvertAnthropicToolToThor(AnthropicMessageTool anthropicTool)
|
|
||||||
{
|
|
||||||
IDictionary<string, ThorToolFunctionPropertyDefinition> values =
|
|
||||||
new Dictionary<string, ThorToolFunctionPropertyDefinition>();
|
|
||||||
|
|
||||||
if (anthropicTool.InputSchema?.Properties != null)
|
|
||||||
{
|
|
||||||
foreach (var property in anthropicTool.InputSchema.Properties)
|
|
||||||
{
|
|
||||||
if (property.Value?.description != null)
|
|
||||||
{
|
|
||||||
var definitionType = new ThorToolFunctionPropertyDefinition()
|
|
||||||
{
|
|
||||||
Description = property.Value.description,
|
|
||||||
Type = property.Value.type
|
|
||||||
};
|
|
||||||
if (property.Value?.items?.type != null)
|
|
||||||
{
|
|
||||||
definitionType.Items = new ThorToolFunctionPropertyDefinition()
|
|
||||||
{
|
|
||||||
Type = property.Value.items.type
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
values.Add(property.Key, definitionType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return new ThorToolDefinition
|
|
||||||
{
|
|
||||||
Type = "function",
|
|
||||||
Function = new ThorToolFunctionDefinition
|
|
||||||
{
|
|
||||||
Name = anthropicTool.name,
|
|
||||||
Description = anthropicTool.Description,
|
|
||||||
Parameters = new ThorToolFunctionPropertyDefinition
|
|
||||||
{
|
|
||||||
Type = anthropicTool.InputSchema?.Type ?? "object",
|
|
||||||
Properties = values,
|
|
||||||
Required = anthropicTool.InputSchema?.Required
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 将OpenAI的完成原因转换为Claude的停止原因
|
|
||||||
/// </summary>
|
|
||||||
public static string GetClaudeStopReason(string? openAIFinishReason)
|
|
||||||
{
|
|
||||||
return openAIFinishReason switch
|
|
||||||
{
|
|
||||||
"stop" => "end_turn",
|
|
||||||
"length" => "max_tokens",
|
|
||||||
"tool_calls" => "tool_use",
|
|
||||||
"content_filter" => "stop_sequence",
|
|
||||||
_ => "end_turn"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 将OpenAI响应转换为Claude响应格式
|
|
||||||
/// </summary>
|
|
||||||
public static AnthropicChatCompletionDto ConvertOpenAIToClaude(ThorChatCompletionsResponse openAIResponse,
|
|
||||||
AnthropicInput originalRequest)
|
|
||||||
{
|
|
||||||
var claudeResponse = new AnthropicChatCompletionDto
|
|
||||||
{
|
|
||||||
id = openAIResponse.Id,
|
|
||||||
type = "message",
|
|
||||||
role = "assistant",
|
|
||||||
model = openAIResponse.Model ?? originalRequest.Model,
|
|
||||||
stop_reason = GetClaudeStopReason(openAIResponse.Choices?.FirstOrDefault()?.FinishReason),
|
|
||||||
stop_sequence = "",
|
|
||||||
content = []
|
|
||||||
};
|
|
||||||
|
|
||||||
if (openAIResponse.Choices is { Count: > 0 })
|
|
||||||
{
|
|
||||||
var choice = openAIResponse.Choices.First();
|
|
||||||
var contents = new List<AnthropicChatCompletionDtoContent>();
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(choice.Message.Content) && !string.IsNullOrEmpty(choice.Message.ReasoningContent))
|
|
||||||
{
|
|
||||||
contents.Add(new AnthropicChatCompletionDtoContent
|
|
||||||
{
|
|
||||||
type = "thinking",
|
|
||||||
Thinking = choice.Message.ReasoningContent
|
|
||||||
});
|
|
||||||
|
|
||||||
contents.Add(new AnthropicChatCompletionDtoContent
|
|
||||||
{
|
|
||||||
type = "text",
|
|
||||||
text = choice.Message.Content
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// 处理思维内容
|
|
||||||
if (!string.IsNullOrEmpty(choice.Message.ReasoningContent))
|
|
||||||
contents.Add(new AnthropicChatCompletionDtoContent
|
|
||||||
{
|
|
||||||
type = "thinking",
|
|
||||||
Thinking = choice.Message.ReasoningContent
|
|
||||||
});
|
|
||||||
|
|
||||||
// 处理文本内容
|
|
||||||
if (!string.IsNullOrEmpty(choice.Message.Content))
|
|
||||||
contents.Add(new AnthropicChatCompletionDtoContent
|
|
||||||
{
|
|
||||||
type = "text",
|
|
||||||
text = choice.Message.Content
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理工具调用
|
|
||||||
if (choice.Message.ToolCalls is { Count: > 0 })
|
|
||||||
contents.AddRange(choice.Message.ToolCalls.Select(toolCall => new AnthropicChatCompletionDtoContent
|
|
||||||
{
|
|
||||||
type = "tool_use", id = toolCall.Id, name = toolCall.Function?.Name,
|
|
||||||
input = JsonSerializer.Deserialize<object>(toolCall.Function?.Arguments ?? "{}")
|
|
||||||
}));
|
|
||||||
|
|
||||||
claudeResponse.content = contents.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理使用情况统计 - 确保始终提供Usage信息
|
|
||||||
claudeResponse.Usage = new AnthropicCompletionDtoUsage
|
|
||||||
{
|
|
||||||
InputTokens = openAIResponse.Usage?.PromptTokens ?? 0,
|
|
||||||
OutputTokens = (int?)(openAIResponse.Usage?.CompletionTokens ?? 0),
|
|
||||||
CacheCreationInputTokens = openAIResponse.Usage?.PromptTokensDetails?.CachedTokens ?? 0,
|
|
||||||
CacheReadInputTokens = openAIResponse.Usage?.PromptTokensDetails?.CachedTokens ?? 0
|
|
||||||
};
|
|
||||||
|
|
||||||
return claudeResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 转换Anthropic工具选择为Thor工具选择
|
|
||||||
/// </summary>
|
|
||||||
public static ThorToolChoice ConvertAnthropicToolChoiceToThor(AnthropicTooChoiceInput anthropicToolChoice)
|
|
||||||
{
|
|
||||||
return new ThorToolChoice
|
|
||||||
{
|
|
||||||
Type = anthropicToolChoice.Type ?? "auto",
|
|
||||||
Function = anthropicToolChoice.Name != null
|
|
||||||
? new ThorToolChoiceFunctionTool { Name = anthropicToolChoice.Name }
|
|
||||||
: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
|
||||||
|
|
||||||
|
public class OpenAiResponsesInput
|
||||||
|
{
|
||||||
|
[JsonPropertyName("stream")] public bool? Stream { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("model")] public string Model { get; set; }
|
||||||
|
[JsonPropertyName("input")] public JsonElement Input { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("max_output_tokens")]
|
||||||
|
public int? MaxOutputTokens { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("max_tool_calls")] public JsonElement? MaxToolCalls { get; set; }
|
||||||
|
[JsonPropertyName("instructions")] public string? Instructions { get; set; }
|
||||||
|
[JsonPropertyName("metadata")] public JsonElement? Metadata { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("parallel_tool_calls")]
|
||||||
|
public bool? ParallelToolCalls { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("previous_response_id")]
|
||||||
|
public string? PreviousResponseId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("prompt")] public JsonElement? Prompt { get; set; }
|
||||||
|
[JsonPropertyName("prompt_cache_key")] public string? PromptCacheKey { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("prompt_cache_retention")]
|
||||||
|
public string? PromptCacheRetention { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("reasoning")] public JsonElement? Reasoning { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("safety_identifier")]
|
||||||
|
public string? SafetyIdentifier { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("service_tier")] public string? ServiceTier { get; set; }
|
||||||
|
[JsonPropertyName("store")] public bool? Store { get; set; }
|
||||||
|
[JsonPropertyName("stream_options")] public JsonElement? StreamOptions { get; set; }
|
||||||
|
[JsonPropertyName("temperature")] public decimal? Temperature { get; set; }
|
||||||
|
[JsonPropertyName("text")] public JsonElement? Text { get; set; }
|
||||||
|
[JsonPropertyName("tool_choice")] public JsonElement? ToolChoice { get; set; }
|
||||||
|
[JsonPropertyName("tools")] public JsonElement? Tools { get; set; }
|
||||||
|
[JsonPropertyName("top_logprobs")] public int? TopLogprobs { get; set; }
|
||||||
|
[JsonPropertyName("top_p")] public decimal? TopP { get; set; }
|
||||||
|
[JsonPropertyName("truncation")] public string? Truncation { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
|
||||||
|
|
||||||
|
public class OpenAiResponsesOutput
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string? Id { get; set; }
|
||||||
|
[JsonPropertyName("object")]
|
||||||
|
public string? Object { get; set; }
|
||||||
|
[JsonPropertyName("created_at")]
|
||||||
|
public long CreatedAt { get; set; }
|
||||||
|
[JsonPropertyName("status")]
|
||||||
|
public string? Status { get; set; }
|
||||||
|
[JsonPropertyName("error")]
|
||||||
|
public dynamic? Error { get; set; }
|
||||||
|
[JsonPropertyName("incomplete_details")]
|
||||||
|
public dynamic? IncompleteDetails { get; set; }
|
||||||
|
[JsonPropertyName("instructions")]
|
||||||
|
public dynamic? Instructions { get; set; }
|
||||||
|
[JsonPropertyName("max_output_tokens")]
|
||||||
|
public dynamic? MaxOutputTokens { get; set; }
|
||||||
|
[JsonPropertyName("model")]
|
||||||
|
public string? Model { get; set; }
|
||||||
|
// output 是复杂对象
|
||||||
|
[JsonPropertyName("output")]
|
||||||
|
public List<dynamic>? Output { get; set; }
|
||||||
|
[JsonPropertyName("parallel_tool_calls")]
|
||||||
|
public bool ParallelToolCalls { get; set; }
|
||||||
|
[JsonPropertyName("previous_response_id")]
|
||||||
|
public dynamic? PreviousResponseId { get; set; }
|
||||||
|
[JsonPropertyName("reasoning")]
|
||||||
|
public dynamic? Reasoning { get; set; }
|
||||||
|
[JsonPropertyName("store")]
|
||||||
|
public bool Store { get; set; }
|
||||||
|
[JsonPropertyName("temperature")]
|
||||||
|
public double Temperature { get; set; }
|
||||||
|
[JsonPropertyName("text")]
|
||||||
|
public dynamic? Text { get; set; }
|
||||||
|
[JsonPropertyName("tool_choice")]
|
||||||
|
public string? ToolChoice { get; set; }
|
||||||
|
[JsonPropertyName("tools")]
|
||||||
|
public List<dynamic>? Tools { get; set; }
|
||||||
|
[JsonPropertyName("top_p")]
|
||||||
|
public double TopP { get; set; }
|
||||||
|
[JsonPropertyName("truncation")]
|
||||||
|
public string? Truncation { get; set; }
|
||||||
|
// usage 为唯一强类型
|
||||||
|
[JsonPropertyName("usage")]
|
||||||
|
public OpenAiResponsesUsageOutput? Usage { get; set; }
|
||||||
|
[JsonPropertyName("user")]
|
||||||
|
public dynamic? User { get; set; }
|
||||||
|
[JsonPropertyName("metadata")]
|
||||||
|
public dynamic? Metadata { get; set; }
|
||||||
|
|
||||||
|
public void SupplementalMultiplier(decimal multiplier)
|
||||||
|
{
|
||||||
|
if (this.Usage is not null)
|
||||||
|
{
|
||||||
|
this.Usage.InputTokens =
|
||||||
|
(int)Math.Round((this.Usage?.InputTokens ?? 0) * multiplier);
|
||||||
|
|
||||||
|
this.Usage.OutputTokens =
|
||||||
|
(int)Math.Round((this.Usage?.OutputTokens ?? 0) * multiplier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OpenAiResponsesUsageOutput
|
||||||
|
{
|
||||||
|
[JsonPropertyName("input_tokens")]
|
||||||
|
public int InputTokens { get; set; }
|
||||||
|
[JsonPropertyName("input_tokens_details")]
|
||||||
|
public OpenAiResponsesInputTokensDetails? InputTokensDetails { get; set; }
|
||||||
|
[JsonPropertyName("output_tokens")]
|
||||||
|
public int OutputTokens { get; set; }
|
||||||
|
[JsonPropertyName("output_tokens_details")]
|
||||||
|
public OpenAiResponsesOutputTokensDetails? OutputTokensDetails { get; set; }
|
||||||
|
[JsonPropertyName("total_tokens")]
|
||||||
|
public int TotalTokens { get; set; }
|
||||||
|
}
|
||||||
|
public class OpenAiResponsesInputTokensDetails
|
||||||
|
{
|
||||||
|
[JsonPropertyName("cached_tokens")]
|
||||||
|
public int CachedTokens { get; set; }
|
||||||
|
}
|
||||||
|
public class OpenAiResponsesOutputTokensDetails
|
||||||
|
{
|
||||||
|
[JsonPropertyName("reasoning_tokens")]
|
||||||
|
public int ReasoningTokens { get; set; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
/// 【可选】参与者的可选名称。提供模型信息以区分相同角色的参与者。
|
/// 【可选】参与者的可选名称。提供模型信息以区分相同角色的参与者。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ public static class GoodsTypeEnumExtensions
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 计算折扣金额(仅用于尊享包)
|
/// 计算折扣金额(仅用于尊享包)
|
||||||
/// 规则:每累加充值10元,减少2.5元,最多减少50元
|
/// 规则:每累加充值10元,减少10元,最多减少50元
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="goodsType">商品类型</param>
|
/// <param name="goodsType">商品类型</param>
|
||||||
/// <param name="totalRechargeAmount">用户累加充值金额</param>
|
/// <param name="totalRechargeAmount">用户累加充值金额</param>
|
||||||
@@ -271,11 +271,10 @@ public static class GoodsTypeEnumExtensions
|
|||||||
{
|
{
|
||||||
return 0m;
|
return 0m;
|
||||||
}
|
}
|
||||||
|
// 每满 10 元减 10 元
|
||||||
// 每10元减2.5元
|
var discountTimes = Math.Floor(totalRechargeAmount / 10m);
|
||||||
var discountAmount = Math.Floor(totalRechargeAmount / 2.5m);
|
var discountAmount = discountTimes * 10m;
|
||||||
|
// 最多减少 50 元
|
||||||
// 最多减少50元
|
|
||||||
return Math.Min(discountAmount, 50m);
|
return Math.Min(discountAmount, 50m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
|
||||||
|
|
||||||
public enum ModelApiTypeEnum
|
public enum ModelApiTypeEnum
|
||||||
{
|
{
|
||||||
|
[Description("OpenAI")]
|
||||||
OpenAi,
|
OpenAi,
|
||||||
Claude
|
|
||||||
|
[Description("Claude")]
|
||||||
|
Claude,
|
||||||
|
|
||||||
|
[Description("Response")]
|
||||||
|
Response
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
|
||||||
|
|
||||||
public enum ModelTypeEnum
|
public enum ModelTypeEnum
|
||||||
{
|
{
|
||||||
|
[Description("聊天")]
|
||||||
Chat = 0,
|
Chat = 0,
|
||||||
|
|
||||||
|
[Description("图片")]
|
||||||
Image = 1,
|
Image = 1,
|
||||||
Embedding = 2,
|
|
||||||
PremiumChat = 3
|
[Description("嵌入")]
|
||||||
|
Embedding = 2
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Yi.Framework.AiHub.Domain.Shared.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 枚举扩展方法
|
||||||
|
/// </summary>
|
||||||
|
public static class EnumExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取枚举的Description特性值
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">枚举值</param>
|
||||||
|
/// <returns>Description特性值,如果没有则返回枚举名称</returns>
|
||||||
|
public static string GetDescription(this Enum value)
|
||||||
|
{
|
||||||
|
var field = value.GetType().GetField(value.ToString());
|
||||||
|
if (field == null)
|
||||||
|
{
|
||||||
|
return value.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
var attribute = field.GetCustomAttribute<DescriptionAttribute>();
|
||||||
|
return attribute?.Description ?? value.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Yi.Framework.AiHub.Domain.Shared.Extensions;
|
||||||
|
|
||||||
|
public static class JsonElementExtensions
|
||||||
|
{
|
||||||
|
#region 路径访问
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 链式获取深层属性,支持对象属性和数组索引
|
||||||
|
/// </summary>
|
||||||
|
/// <example>
|
||||||
|
/// root.GetPath("user", "addresses", 0, "city")
|
||||||
|
/// </example>
|
||||||
|
public static JsonElement? GetPath(this JsonElement element, params object[] path)
|
||||||
|
{
|
||||||
|
JsonElement current = element;
|
||||||
|
|
||||||
|
foreach (var key in path)
|
||||||
|
{
|
||||||
|
switch (key)
|
||||||
|
{
|
||||||
|
case string propertyName:
|
||||||
|
if (current.ValueKind != JsonValueKind.Object ||
|
||||||
|
!current.TryGetProperty(propertyName, out current))
|
||||||
|
return null;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case int index:
|
||||||
|
if (current.ValueKind != JsonValueKind.Array ||
|
||||||
|
index < 0 || index >= current.GetArrayLength())
|
||||||
|
return null;
|
||||||
|
current = current[index];
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 安全获取对象属性
|
||||||
|
/// </summary>
|
||||||
|
public static JsonElement? Get(this JsonElement element, string propertyName)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object &&
|
||||||
|
element.TryGetProperty(propertyName, out var value))
|
||||||
|
return value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 安全获取数组元素
|
||||||
|
/// </summary>
|
||||||
|
public static JsonElement? Get(this JsonElement element, int index)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Array &&
|
||||||
|
index >= 0 && index < element.GetArrayLength())
|
||||||
|
return element[index];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 链式安全获取对象属性
|
||||||
|
/// </summary>
|
||||||
|
public static JsonElement? Get(this JsonElement? element, string propertyName)
|
||||||
|
=> element?.Get(propertyName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 链式安全获取数组元素
|
||||||
|
/// </summary>
|
||||||
|
public static JsonElement? Get(this JsonElement? element, int index)
|
||||||
|
=> element?.Get(index);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 取值方法(带默认值)
|
||||||
|
|
||||||
|
public static string? GetString(this JsonElement? element, string? defaultValue = null)
|
||||||
|
=> element?.ValueKind == JsonValueKind.String ? element.Value.GetString() : defaultValue;
|
||||||
|
|
||||||
|
public static int GetInt(this JsonElement? element, int defaultValue = 0)
|
||||||
|
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetInt32() : defaultValue;
|
||||||
|
|
||||||
|
public static long GetLong(this JsonElement? element, long defaultValue = 0)
|
||||||
|
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetInt64() : defaultValue;
|
||||||
|
|
||||||
|
public static double GetDouble(this JsonElement? element, double defaultValue = 0)
|
||||||
|
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetDouble() : defaultValue;
|
||||||
|
|
||||||
|
public static decimal GetDecimal(this JsonElement? element, decimal defaultValue = 0)
|
||||||
|
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetDecimal() : defaultValue;
|
||||||
|
|
||||||
|
public static bool GetBool(this JsonElement? element, bool defaultValue = false)
|
||||||
|
=> element?.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||||
|
? element.Value.GetBoolean()
|
||||||
|
: defaultValue;
|
||||||
|
|
||||||
|
public static DateTime GetDateTime(this JsonElement? element, DateTime defaultValue = default)
|
||||||
|
=> element?.ValueKind == JsonValueKind.String && element.Value.TryGetDateTime(out var dt)
|
||||||
|
? dt
|
||||||
|
: defaultValue;
|
||||||
|
|
||||||
|
public static Guid GetGuid(this JsonElement? element, Guid defaultValue = default)
|
||||||
|
=> element?.ValueKind == JsonValueKind.String && element.Value.TryGetGuid(out var guid)
|
||||||
|
? guid
|
||||||
|
: defaultValue;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 可空取值方法
|
||||||
|
|
||||||
|
public static int? GetIntOrNull(this JsonElement? element)
|
||||||
|
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetInt32() : null;
|
||||||
|
|
||||||
|
public static long? GetLongOrNull(this JsonElement? element)
|
||||||
|
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetInt64() : null;
|
||||||
|
|
||||||
|
public static double? GetDoubleOrNull(this JsonElement? element)
|
||||||
|
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetDouble() : null;
|
||||||
|
|
||||||
|
public static decimal? GetDecimalOrNull(this JsonElement? element)
|
||||||
|
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetDecimal() : null;
|
||||||
|
|
||||||
|
public static bool? GetBoolOrNull(this JsonElement? element)
|
||||||
|
=> element?.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||||
|
? element.Value.GetBoolean()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
public static DateTime? GetDateTimeOrNull(this JsonElement? element)
|
||||||
|
=> element?.ValueKind == JsonValueKind.String && element.Value.TryGetDateTime(out var dt)
|
||||||
|
? dt
|
||||||
|
: null;
|
||||||
|
|
||||||
|
public static Guid? GetGuidOrNull(this JsonElement? element)
|
||||||
|
=> element?.ValueKind == JsonValueKind.String && element.Value.TryGetGuid(out var guid)
|
||||||
|
? guid
|
||||||
|
: null;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 数组操作
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 安全获取数组,不存在返回空数组
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<JsonElement> GetArray(this JsonElement? element)
|
||||||
|
{
|
||||||
|
if (element?.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var item in element.Value.EnumerateArray())
|
||||||
|
yield return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取数组长度
|
||||||
|
/// </summary>
|
||||||
|
public static int GetArrayLength(this JsonElement? element)
|
||||||
|
=> element?.ValueKind == JsonValueKind.Array ? element.Value.GetArrayLength() : 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 数组转 List
|
||||||
|
/// </summary>
|
||||||
|
public static List<string?> ToStringList(this JsonElement? element)
|
||||||
|
=> element.GetArray().Select(e => e.GetString()).ToList();
|
||||||
|
|
||||||
|
public static List<int> ToIntList(this JsonElement? element)
|
||||||
|
=> element.GetArray()
|
||||||
|
.Where(e => e.ValueKind == JsonValueKind.Number)
|
||||||
|
.Select(e => e.GetInt32())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 对象操作
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 安全枚举对象属性
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<JsonProperty> GetProperties(this JsonElement? element)
|
||||||
|
{
|
||||||
|
if (element?.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
foreach (var prop in element.Value.EnumerateObject())
|
||||||
|
yield return prop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取所有属性名
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<string> GetPropertyNames(this JsonElement? element)
|
||||||
|
=> element.GetProperties().Select(p => p.Name);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断是否包含某属性
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasProperty(this JsonElement? element, string propertyName)
|
||||||
|
=> element?.ValueKind == JsonValueKind.Object &&
|
||||||
|
element.Value.TryGetProperty(propertyName, out _);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 类型判断
|
||||||
|
|
||||||
|
public static bool IsNull(this JsonElement? element)
|
||||||
|
=> element == null || element.Value.ValueKind == JsonValueKind.Null;
|
||||||
|
|
||||||
|
public static bool IsNullOrUndefined(this JsonElement? element)
|
||||||
|
=> element == null || element.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined;
|
||||||
|
|
||||||
|
public static bool IsObject(this JsonElement? element)
|
||||||
|
=> element?.ValueKind == JsonValueKind.Object;
|
||||||
|
|
||||||
|
public static bool IsArray(this JsonElement? element)
|
||||||
|
=> element?.ValueKind == JsonValueKind.Array;
|
||||||
|
|
||||||
|
public static bool IsString(this JsonElement? element)
|
||||||
|
=> element?.ValueKind == JsonValueKind.String;
|
||||||
|
|
||||||
|
public static bool IsNumber(this JsonElement? element)
|
||||||
|
=> element?.ValueKind == JsonValueKind.Number;
|
||||||
|
|
||||||
|
public static bool IsBool(this JsonElement? element)
|
||||||
|
=> element?.ValueKind is JsonValueKind.True or JsonValueKind.False;
|
||||||
|
|
||||||
|
public static bool Exists(this JsonElement? element)
|
||||||
|
=> element != null && element.Value.ValueKind != JsonValueKind.Undefined;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 反序列化
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 反序列化为指定类型
|
||||||
|
/// </summary>
|
||||||
|
public static T? Deserialize<T>(this JsonElement? element, JsonSerializerOptions? options = null)
|
||||||
|
=> element.HasValue ? element.Value.Deserialize<T>(options) : default;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 反序列化为指定类型,带默认值
|
||||||
|
/// </summary>
|
||||||
|
public static T Deserialize<T>(this JsonElement? element, T defaultValue, JsonSerializerOptions? options = null)
|
||||||
|
=> element.HasValue ? element.Value.Deserialize<T>(options) ?? defaultValue : defaultValue;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 转换为字典/动态类型
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 转换为 Dictionary
|
||||||
|
/// </summary>
|
||||||
|
public static Dictionary<string, JsonElement>? ToDictionary(this JsonElement? element)
|
||||||
|
{
|
||||||
|
if (element?.ValueKind != JsonValueKind.Object)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var dict = new Dictionary<string, JsonElement>();
|
||||||
|
foreach (var prop in element.Value.EnumerateObject())
|
||||||
|
dict[prop.Name] = prop.Value;
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 原始值
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取原始 JSON 字符串
|
||||||
|
/// </summary>
|
||||||
|
public static string? GetRawText(this JsonElement? element)
|
||||||
|
=> element?.GetRawText();
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Yi.Framework.AiHub.Domain.Shared.Dtos;
|
||||||
|
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
|
||||||
|
|
||||||
|
namespace Yi.Framework.AiHub.Domain.AiGateWay;
|
||||||
|
|
||||||
|
public interface IOpenAiResponseService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 响应-流式
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="aiModelDescribe"></param>
|
||||||
|
/// <param name="input"></param>
|
||||||
|
/// <param name="cancellationToken"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public IAsyncEnumerable<(string, JsonElement?)> ResponsesStreamAsync(AiModelDescribe aiModelDescribe,
|
||||||
|
OpenAiResponsesInput input,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 响应-非流式
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="aiModelDescribe"></param>
|
||||||
|
/// <param name="input"></param>
|
||||||
|
/// <param name="cancellationToken"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public Task<OpenAiResponsesOutput> ResponsesAsync(AiModelDescribe aiModelDescribe,
|
||||||
|
OpenAiResponsesInput input,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Volo.Abp.DependencyInjection;
|
|
||||||
using Yi.Framework.AiHub.Domain.Shared.Dtos;
|
|
||||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
|
|
||||||
|
|
||||||
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorCustomOpenAI.Chats;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// OpenAI到Claude适配器服务
|
|
||||||
/// 将Claude格式的请求转换为OpenAI格式,然后将OpenAI的响应转换为Claude格式
|
|
||||||
/// </summary>
|
|
||||||
public class CustomOpenAIAnthropicChatCompletionsService(
|
|
||||||
IAbpLazyServiceProvider serviceProvider,
|
|
||||||
ILogger<CustomOpenAIAnthropicChatCompletionsService> logger)
|
|
||||||
: IAnthropicChatCompletionService
|
|
||||||
{
|
|
||||||
private IChatCompletionService GetChatCompletionService()
|
|
||||||
{
|
|
||||||
return serviceProvider.GetRequiredKeyedService<IChatCompletionService>(nameof(OpenAiChatCompletionsService));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<AnthropicChatCompletionDto> ChatCompletionsAsync(AiModelDescribe aiModelDescribe,
|
|
||||||
AnthropicInput request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// 转换请求格式:Claude -> OpenAI
|
|
||||||
var openAIRequest = AnthropicToOpenAi.ConvertAnthropicToOpenAi(request);
|
|
||||||
|
|
||||||
if (openAIRequest.Model.StartsWith("gpt-5"))
|
|
||||||
{
|
|
||||||
openAIRequest.MaxCompletionTokens = request.MaxTokens;
|
|
||||||
openAIRequest.MaxTokens = null;
|
|
||||||
}
|
|
||||||
else if (openAIRequest.Model.StartsWith("o3-mini") || openAIRequest.Model.StartsWith("o4-mini"))
|
|
||||||
{
|
|
||||||
openAIRequest.MaxCompletionTokens = request.MaxTokens;
|
|
||||||
openAIRequest.MaxTokens = null;
|
|
||||||
openAIRequest.Temperature = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用OpenAI服务
|
|
||||||
var openAIResponse =
|
|
||||||
await GetChatCompletionService().CompleteChatAsync(aiModelDescribe,openAIRequest, cancellationToken);
|
|
||||||
|
|
||||||
// 转换响应格式:OpenAI -> Claude
|
|
||||||
var claudeResponse = AnthropicToOpenAi.ConvertOpenAIToClaude(openAIResponse, request);
|
|
||||||
|
|
||||||
return claudeResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async IAsyncEnumerable<(string, AnthropicStreamDto?)> StreamChatCompletionsAsync(AiModelDescribe aiModelDescribe,
|
|
||||||
AnthropicInput request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var openAIRequest = AnthropicToOpenAi.ConvertAnthropicToOpenAi(request);
|
|
||||||
openAIRequest.Stream = true;
|
|
||||||
|
|
||||||
if (openAIRequest.Model.StartsWith("gpt-5"))
|
|
||||||
{
|
|
||||||
openAIRequest.MaxCompletionTokens = request.MaxTokens;
|
|
||||||
openAIRequest.MaxTokens = null;
|
|
||||||
}
|
|
||||||
else if (openAIRequest.Model.StartsWith("o3-mini") || openAIRequest.Model.StartsWith("o4-mini"))
|
|
||||||
{
|
|
||||||
openAIRequest.MaxCompletionTokens = request.MaxTokens;
|
|
||||||
openAIRequest.MaxTokens = null;
|
|
||||||
openAIRequest.Temperature = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var messageId = Guid.NewGuid().ToString();
|
|
||||||
var hasStarted = false;
|
|
||||||
var hasTextContentBlockStarted = false;
|
|
||||||
var hasThinkingContentBlockStarted = false;
|
|
||||||
var toolBlocksStarted = new Dictionary<int, bool>(); // 使用索引而不是ID
|
|
||||||
var toolCallIds = new Dictionary<int, string>(); // 存储每个索引对应的ID
|
|
||||||
var toolCallIndexToBlockIndex = new Dictionary<int, int>(); // 工具调用索引到块索引的映射
|
|
||||||
var accumulatedUsage = new AnthropicCompletionDtoUsage();
|
|
||||||
var isFinished = false;
|
|
||||||
var currentContentBlockType = ""; // 跟踪当前内容块类型
|
|
||||||
var currentBlockIndex = 0; // 跟踪当前块索引
|
|
||||||
var lastContentBlockType = ""; // 跟踪最后一个内容块类型,用于确定停止原因
|
|
||||||
|
|
||||||
await foreach (var openAIResponse in GetChatCompletionService().CompleteChatStreamAsync(aiModelDescribe,openAIRequest,
|
|
||||||
cancellationToken))
|
|
||||||
{
|
|
||||||
// 发送message_start事件
|
|
||||||
if (!hasStarted && openAIResponse.Choices?.Count > 0 &&
|
|
||||||
openAIResponse.Choices.Any(x => x.Delta.ToolCalls?.Count > 0) == false)
|
|
||||||
{
|
|
||||||
hasStarted = true;
|
|
||||||
var messageStartEvent = AnthropicToOpenAi.CreateMessageStartEvent(messageId, request.Model);
|
|
||||||
yield return ("message_start", messageStartEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新使用情况统计
|
|
||||||
if (openAIResponse.Usage != null)
|
|
||||||
{
|
|
||||||
// 使用最新的token计数(OpenAI通常在最后的响应中提供完整的统计)
|
|
||||||
if (openAIResponse.Usage.PromptTokens.HasValue)
|
|
||||||
{
|
|
||||||
accumulatedUsage.InputTokens = openAIResponse.Usage.PromptTokens.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (openAIResponse.Usage.CompletionTokens.HasValue)
|
|
||||||
{
|
|
||||||
accumulatedUsage.OutputTokens = (int)openAIResponse.Usage.CompletionTokens.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (openAIResponse.Usage.PromptTokensDetails?.CachedTokens.HasValue == true)
|
|
||||||
{
|
|
||||||
accumulatedUsage.CacheReadInputTokens =
|
|
||||||
openAIResponse.Usage.PromptTokensDetails.CachedTokens.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 记录调试信息
|
|
||||||
logger.LogDebug("OpenAI Usage更新: Input={InputTokens}, Output={OutputTokens}, CacheRead={CacheRead}",
|
|
||||||
accumulatedUsage.InputTokens, accumulatedUsage.OutputTokens,
|
|
||||||
accumulatedUsage.CacheReadInputTokens);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (openAIResponse.Choices is { Count: > 0 })
|
|
||||||
{
|
|
||||||
var choice = openAIResponse.Choices.First();
|
|
||||||
|
|
||||||
// 处理内容
|
|
||||||
if (!string.IsNullOrEmpty(choice.Delta?.Content))
|
|
||||||
{
|
|
||||||
// 如果当前有其他类型的内容块在运行,先结束它们
|
|
||||||
if (currentContentBlockType != "text" && !string.IsNullOrEmpty(currentContentBlockType))
|
|
||||||
{
|
|
||||||
var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
|
|
||||||
stopEvent.Index = currentBlockIndex;
|
|
||||||
yield return ("content_block_stop", stopEvent);
|
|
||||||
currentBlockIndex++; // 切换内容块时增加索引
|
|
||||||
currentContentBlockType = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送content_block_start事件(仅第一次)
|
|
||||||
if (!hasTextContentBlockStarted || currentContentBlockType != "text")
|
|
||||||
{
|
|
||||||
hasTextContentBlockStarted = true;
|
|
||||||
currentContentBlockType = "text";
|
|
||||||
lastContentBlockType = "text";
|
|
||||||
var contentBlockStartEvent = AnthropicToOpenAi.CreateContentBlockStartEvent();
|
|
||||||
contentBlockStartEvent.Index = currentBlockIndex;
|
|
||||||
yield return ("content_block_start",
|
|
||||||
contentBlockStartEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送content_block_delta事件
|
|
||||||
var contentDeltaEvent = AnthropicToOpenAi.CreateContentBlockDeltaEvent(choice.Delta.Content);
|
|
||||||
contentDeltaEvent.Index = currentBlockIndex;
|
|
||||||
yield return ("content_block_delta",
|
|
||||||
contentDeltaEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理工具调用
|
|
||||||
if (choice.Delta?.ToolCalls is { Count: > 0 })
|
|
||||||
{
|
|
||||||
foreach (var toolCall in choice.Delta.ToolCalls)
|
|
||||||
{
|
|
||||||
var toolCallIndex = toolCall.Index; // 使用索引来标识工具调用
|
|
||||||
|
|
||||||
// 发送tool_use content_block_start事件
|
|
||||||
if (toolBlocksStarted.TryAdd(toolCallIndex, true))
|
|
||||||
{
|
|
||||||
// 如果当前有文本或thinking内容块在运行,先结束它们
|
|
||||||
if (currentContentBlockType == "text" || currentContentBlockType == "thinking")
|
|
||||||
{
|
|
||||||
var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
|
|
||||||
stopEvent.Index = currentBlockIndex;
|
|
||||||
yield return ("content_block_stop", stopEvent);
|
|
||||||
currentBlockIndex++; // 增加块索引
|
|
||||||
}
|
|
||||||
// 如果当前有其他工具调用在运行,也需要结束它们
|
|
||||||
else if (currentContentBlockType == "tool_use")
|
|
||||||
{
|
|
||||||
var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
|
|
||||||
stopEvent.Index = currentBlockIndex;
|
|
||||||
yield return ("content_block_stop", stopEvent);
|
|
||||||
currentBlockIndex++; // 增加块索引
|
|
||||||
}
|
|
||||||
|
|
||||||
currentContentBlockType = "tool_use";
|
|
||||||
lastContentBlockType = "tool_use";
|
|
||||||
|
|
||||||
// 为此工具调用分配一个新的块索引
|
|
||||||
toolCallIndexToBlockIndex[toolCallIndex] = currentBlockIndex;
|
|
||||||
|
|
||||||
// 保存工具调用的ID(如果有的话)
|
|
||||||
if (!string.IsNullOrEmpty(toolCall.Id))
|
|
||||||
{
|
|
||||||
toolCallIds[toolCallIndex] = toolCall.Id;
|
|
||||||
}
|
|
||||||
else if (!toolCallIds.ContainsKey(toolCallIndex))
|
|
||||||
{
|
|
||||||
// 如果没有ID且之前也没有保存过,生成一个新的ID
|
|
||||||
toolCallIds[toolCallIndex] = Guid.NewGuid().ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
var toolBlockStartEvent = AnthropicToOpenAi.CreateToolBlockStartEvent(
|
|
||||||
toolCallIds[toolCallIndex],
|
|
||||||
toolCall.Function?.Name);
|
|
||||||
toolBlockStartEvent.Index = currentBlockIndex;
|
|
||||||
yield return ("content_block_start",
|
|
||||||
toolBlockStartEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果有增量的参数,发送content_block_delta事件
|
|
||||||
if (!string.IsNullOrEmpty(toolCall.Function?.Arguments))
|
|
||||||
{
|
|
||||||
var toolDeltaEvent =
|
|
||||||
AnthropicToOpenAi.CreateToolBlockDeltaEvent(toolCall.Function.Arguments);
|
|
||||||
// 使用该工具调用对应的块索引
|
|
||||||
toolDeltaEvent.Index = toolCallIndexToBlockIndex[toolCallIndex];
|
|
||||||
yield return ("content_block_delta",
|
|
||||||
toolDeltaEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理推理内容
|
|
||||||
if (!string.IsNullOrEmpty(choice.Delta?.ReasoningContent))
|
|
||||||
{
|
|
||||||
// 如果当前有其他类型的内容块在运行,先结束它们
|
|
||||||
if (currentContentBlockType != "thinking" && !string.IsNullOrEmpty(currentContentBlockType))
|
|
||||||
{
|
|
||||||
var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
|
|
||||||
stopEvent.Index = currentBlockIndex;
|
|
||||||
yield return ("content_block_stop", stopEvent);
|
|
||||||
currentBlockIndex++; // 增加块索引
|
|
||||||
currentContentBlockType = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 对于推理内容,也需要发送对应的事件
|
|
||||||
if (!hasThinkingContentBlockStarted || currentContentBlockType != "thinking")
|
|
||||||
{
|
|
||||||
hasThinkingContentBlockStarted = true;
|
|
||||||
currentContentBlockType = "thinking";
|
|
||||||
lastContentBlockType = "thinking";
|
|
||||||
var thinkingBlockStartEvent = AnthropicToOpenAi.CreateThinkingBlockStartEvent();
|
|
||||||
thinkingBlockStartEvent.Index = currentBlockIndex;
|
|
||||||
yield return ("content_block_start",
|
|
||||||
thinkingBlockStartEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
var thinkingDeltaEvent =
|
|
||||||
AnthropicToOpenAi.CreateThinkingBlockDeltaEvent(choice.Delta.ReasoningContent);
|
|
||||||
thinkingDeltaEvent.Index = currentBlockIndex;
|
|
||||||
yield return ("content_block_delta",
|
|
||||||
thinkingDeltaEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理结束
|
|
||||||
if (!string.IsNullOrEmpty(choice.FinishReason) && !isFinished)
|
|
||||||
{
|
|
||||||
isFinished = true;
|
|
||||||
|
|
||||||
// 发送content_block_stop事件(如果有活跃的内容块)
|
|
||||||
if (!string.IsNullOrEmpty(currentContentBlockType))
|
|
||||||
{
|
|
||||||
var contentBlockStopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
|
|
||||||
contentBlockStopEvent.Index = currentBlockIndex;
|
|
||||||
yield return ("content_block_stop",
|
|
||||||
contentBlockStopEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送message_delta事件
|
|
||||||
var messageDeltaEvent = AnthropicToOpenAi.CreateMessageDeltaEvent(
|
|
||||||
AnthropicToOpenAi.GetStopReasonByLastContentType(choice.FinishReason, lastContentBlockType),
|
|
||||||
accumulatedUsage);
|
|
||||||
|
|
||||||
// 记录最终Usage统计
|
|
||||||
logger.LogDebug(
|
|
||||||
"流式响应结束,最终Usage: Input={InputTokens}, Output={OutputTokens}, CacheRead={CacheRead}",
|
|
||||||
accumulatedUsage.InputTokens, accumulatedUsage.OutputTokens,
|
|
||||||
accumulatedUsage.CacheReadInputTokens);
|
|
||||||
|
|
||||||
yield return ("message_delta",
|
|
||||||
messageDeltaEvent);
|
|
||||||
|
|
||||||
// 发送message_stop事件
|
|
||||||
var messageStopEvent = AnthropicToOpenAi.CreateMessageStopEvent();
|
|
||||||
yield return ("message_stop",
|
|
||||||
messageStopEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保流正确结束
|
|
||||||
if (!isFinished)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(currentContentBlockType))
|
|
||||||
{
|
|
||||||
var contentBlockStopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
|
|
||||||
contentBlockStopEvent.Index = currentBlockIndex;
|
|
||||||
yield return ("content_block_stop",
|
|
||||||
contentBlockStopEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
var messageDeltaEvent =
|
|
||||||
AnthropicToOpenAi.CreateMessageDeltaEvent(
|
|
||||||
AnthropicToOpenAi.GetStopReasonByLastContentType("end_turn", lastContentBlockType),
|
|
||||||
accumulatedUsage);
|
|
||||||
yield return ("message_delta", messageDeltaEvent);
|
|
||||||
|
|
||||||
var messageStopEvent = AnthropicToOpenAi.CreateMessageStopEvent();
|
|
||||||
yield return ("message_stop",
|
|
||||||
messageStopEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -130,7 +130,7 @@ public sealed class OpenAiChatCompletionsService(ILogger<OpenAiChatCompletionsSe
|
|||||||
using var openai =
|
using var openai =
|
||||||
Activity.Current?.Source.StartActivity("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",
|
options?.Endpoint.TrimEnd('/') + "/chat/completions",
|
||||||
chatCompletionCreate, options.ApiKey).ConfigureAwait(false);
|
chatCompletionCreate, options.ApiKey).ConfigureAwait(false);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Yi.Framework.AiHub.Domain.AiGateWay.Exceptions;
|
||||||
|
using Yi.Framework.AiHub.Domain.Shared.Dtos;
|
||||||
|
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
|
||||||
|
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
|
||||||
|
|
||||||
|
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorCustomOpenAI.Chats;
|
||||||
|
|
||||||
|
public class OpenAiResponseService(ILogger<OpenAiResponseService> logger,IHttpClientFactory httpClientFactory):IOpenAiResponseService
|
||||||
|
{
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<(string, JsonElement?)> ResponsesStreamAsync(AiModelDescribe options, OpenAiResponsesInput input,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var openai =
|
||||||
|
Activity.Current?.Source.StartActivity("OpenAi 响应");
|
||||||
|
|
||||||
|
|
||||||
|
var client = httpClientFactory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.HttpRequestRaw(options.Endpoint.TrimEnd('/') + "/responses", input, options.ApiKey);
|
||||||
|
|
||||||
|
openai?.SetTag("Model", input.Model);
|
||||||
|
openai?.SetTag("Response", response.StatusCode.ToString());
|
||||||
|
|
||||||
|
// 大于等于400的状态码都认为是异常
|
||||||
|
if (response.StatusCode >= HttpStatusCode.BadRequest)
|
||||||
|
{
|
||||||
|
var error = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
logger.LogError("OpenAI响应异常 请求地址:{Address}, StatusCode: {StatusCode} Response: {Response}",
|
||||||
|
options.Endpoint,
|
||||||
|
response.StatusCode, error);
|
||||||
|
|
||||||
|
throw new Exception("OpenAI响应异常" + response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken));
|
||||||
|
|
||||||
|
using StreamReader reader = new(await response.Content.ReadAsStreamAsync(cancellationToken));
|
||||||
|
string? line = string.Empty;
|
||||||
|
|
||||||
|
string? data = null;
|
||||||
|
string eventType = string.Empty;
|
||||||
|
|
||||||
|
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) != null)
|
||||||
|
{
|
||||||
|
line += Environment.NewLine;
|
||||||
|
|
||||||
|
if (line.StartsWith('{'))
|
||||||
|
{
|
||||||
|
logger.LogInformation("OpenAI响应异常 , StatusCode: {StatusCode} Response: {Response}", response.StatusCode,
|
||||||
|
line);
|
||||||
|
|
||||||
|
throw new Exception("OpenAI响应异常" + line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.StartsWith("event:"))
|
||||||
|
{
|
||||||
|
eventType = line;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!line.StartsWith(OpenAIConstant.Data)) continue;
|
||||||
|
|
||||||
|
data = line[OpenAIConstant.Data.Length..].Trim();
|
||||||
|
|
||||||
|
var result = JsonSerializer.Deserialize<JsonElement>(data,
|
||||||
|
ThorJsonSerializer.DefaultOptions);
|
||||||
|
|
||||||
|
yield return (eventType, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OpenAiResponsesOutput> ResponsesAsync(AiModelDescribe options, OpenAiResponsesInput chatCompletionCreate,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var openai =
|
||||||
|
Activity.Current?.Source.StartActivity("OpenAI 响应");
|
||||||
|
|
||||||
|
var response = await httpClientFactory.CreateClient().PostJsonAsync(
|
||||||
|
options?.Endpoint.TrimEnd('/') + "/responses",
|
||||||
|
chatCompletionCreate, options.ApiKey).ConfigureAwait(false);
|
||||||
|
|
||||||
|
openai?.SetTag("Model", chatCompletionCreate.Model);
|
||||||
|
openai?.SetTag("Response", response.StatusCode.ToString());
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
throw new BusinessException("渠道未登录,请联系管理人员", "401");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果限流则抛出限流异常
|
||||||
|
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||||
|
{
|
||||||
|
throw new ThorRateLimitException();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 大于等于400的状态码都认为是异常
|
||||||
|
if (response.StatusCode >= HttpStatusCode.BadRequest)
|
||||||
|
{
|
||||||
|
var error = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
logger.LogError("OpenAI 响应异常 请求地址:{Address}, StatusCode: {StatusCode} Response: {Response}", options.Endpoint,
|
||||||
|
response.StatusCode, error);
|
||||||
|
|
||||||
|
throw new BusinessException("OpenAI响应异常", response.StatusCode.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
var result =
|
||||||
|
await response.Content.ReadFromJsonAsync<OpenAiResponsesOutput>(
|
||||||
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,4 +65,19 @@ public class AiModelEntity : Entity<Guid>, IOrderNum, ISoftDelete
|
|||||||
/// 模型倍率
|
/// 模型倍率
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal Multiplier { get; set; } = 1;
|
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; }
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
using Newtonsoft.Json.Serialization;
|
using Newtonsoft.Json.Serialization;
|
||||||
using Volo.Abp.Domain.Services;
|
using Volo.Abp.Domain.Services;
|
||||||
using Yi.Framework.AiHub.Domain.AiGateWay;
|
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;
|
||||||
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Embeddings;
|
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.Images;
|
||||||
|
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
|
||||||
using Yi.Framework.AiHub.Domain.Shared.Enums;
|
using Yi.Framework.AiHub.Domain.Shared.Enums;
|
||||||
|
using Yi.Framework.AiHub.Domain.Shared.Extensions;
|
||||||
using Yi.Framework.Core.Extensions;
|
using Yi.Framework.Core.Extensions;
|
||||||
using Yi.Framework.SqlSugarCore.Abstractions;
|
using Yi.Framework.SqlSugarCore.Abstractions;
|
||||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||||
@@ -89,30 +92,7 @@ public class AiGateWayManager : DomainService
|
|||||||
return aiModelDescribe;
|
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))
|
|
||||||
{
|
|
||||||
result.SupplementalMultiplier(modelDescribe.Multiplier);
|
|
||||||
yield return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 聊天完成-非流式
|
/// 聊天完成-非流式
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -174,6 +154,7 @@ public class AiGateWayManager : DomainService
|
|||||||
await response.WriteAsJsonAsync(data, cancellationToken);
|
await response.WriteAsJsonAsync(data, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 聊天完成-缓存处理
|
/// 聊天完成-缓存处理
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -199,8 +180,12 @@ public class AiGateWayManager : DomainService
|
|||||||
response.Headers.TryAdd("Connection", "keep-alive");
|
response.Headers.TryAdd("Connection", "keep-alive");
|
||||||
|
|
||||||
|
|
||||||
var gateWay = LazyServiceProvider.GetRequiredService<AiGateWayManager>();
|
_specialCompatible.Compatible(request);
|
||||||
var completeChatResponse = gateWay.CompleteChatStreamAsync(request, cancellationToken);
|
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();
|
var tokenUsage = new ThorUsageResponse();
|
||||||
|
|
||||||
//缓存队列算法
|
//缓存队列算法
|
||||||
@@ -242,6 +227,7 @@ public class AiGateWayManager : DomainService
|
|||||||
{
|
{
|
||||||
await foreach (var data in completeChatResponse)
|
await foreach (var data in completeChatResponse)
|
||||||
{
|
{
|
||||||
|
data.SupplementalMultiplier(modelDescribe.Multiplier);
|
||||||
if (data.Usage is not null && (data.Usage.CompletionTokens > 0 || data.Usage.OutputTokens > 0))
|
if (data.Usage is not null && (data.Usage.CompletionTokens > 0 || data.Usage.OutputTokens > 0))
|
||||||
{
|
{
|
||||||
tokenUsage = data.Usage;
|
tokenUsage = data.Usage;
|
||||||
@@ -256,7 +242,7 @@ public class AiGateWayManager : DomainService
|
|||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
_logger.LogError(e, $"Ai对话异常");
|
_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()
|
var model = new ThorChatCompletionsResponse()
|
||||||
{
|
{
|
||||||
Choices = new List<ThorChatChoiceResponse>()
|
Choices = new List<ThorChatChoiceResponse>()
|
||||||
@@ -289,7 +275,7 @@ public class AiGateWayManager : DomainService
|
|||||||
await _aiMessageManager.CreateUserMessageAsync(userId, sessionId,
|
await _aiMessageManager.CreateUserMessageAsync(userId, sessionId,
|
||||||
new MessageInputDto
|
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,
|
ModelId = request.Model,
|
||||||
TokenUsage = tokenUsage,
|
TokenUsage = tokenUsage,
|
||||||
}, tokenId);
|
}, tokenId);
|
||||||
@@ -314,8 +300,8 @@ public class AiGateWayManager : DomainService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 图片生成
|
/// 图片生成
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -379,12 +365,12 @@ public class AiGateWayManager : DomainService
|
|||||||
}
|
}
|
||||||
catch (Exception e)
|
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);
|
throw new UserFriendlyException(errorContent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 向量生成
|
/// 向量生成
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -492,34 +478,11 @@ public class AiGateWayManager : DomainService
|
|||||||
}
|
}
|
||||||
catch (Exception e)
|
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);
|
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))
|
|
||||||
{
|
|
||||||
result.Item2.SupplementalMultiplier(modelDescribe.Multiplier);
|
|
||||||
yield return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Anthropic聊天完成-非流式
|
/// Anthropic聊天完成-非流式
|
||||||
@@ -546,15 +509,15 @@ public class AiGateWayManager : DomainService
|
|||||||
var chatService =
|
var chatService =
|
||||||
LazyServiceProvider.GetRequiredKeyedService<IAnthropicChatCompletionService>(modelDescribe.HandlerName);
|
LazyServiceProvider.GetRequiredKeyedService<IAnthropicChatCompletionService>(modelDescribe.HandlerName);
|
||||||
var data = await chatService.ChatCompletionsAsync(modelDescribe, request, cancellationToken);
|
var data = await chatService.ChatCompletionsAsync(modelDescribe, request, cancellationToken);
|
||||||
|
|
||||||
data.SupplementalMultiplier(modelDescribe.Multiplier);
|
data.SupplementalMultiplier(modelDescribe.Multiplier);
|
||||||
|
|
||||||
if (userId is not null)
|
if (userId is not null)
|
||||||
{
|
{
|
||||||
await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId,
|
await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId,
|
||||||
new MessageInputDto
|
new MessageInputDto
|
||||||
{
|
{
|
||||||
Content = sessionId is null ? "不予存储" : request.Messages?.FirstOrDefault()?.Content ?? string.Empty,
|
Content = "不予存储",
|
||||||
ModelId = request.Model,
|
ModelId = request.Model,
|
||||||
TokenUsage = data.TokenUsage,
|
TokenUsage = data.TokenUsage,
|
||||||
}, tokenId);
|
}, tokenId);
|
||||||
@@ -562,7 +525,7 @@ public class AiGateWayManager : DomainService
|
|||||||
await _aiMessageManager.CreateSystemMessageAsync(userId.Value, sessionId,
|
await _aiMessageManager.CreateSystemMessageAsync(userId.Value, sessionId,
|
||||||
new MessageInputDto
|
new MessageInputDto
|
||||||
{
|
{
|
||||||
Content = sessionId is null ? "不予存储" : data.content?.FirstOrDefault()?.text,
|
Content = "不予存储",
|
||||||
ModelId = request.Model,
|
ModelId = request.Model,
|
||||||
TokenUsage = data.TokenUsage
|
TokenUsage = data.TokenUsage
|
||||||
}, tokenId);
|
}, tokenId);
|
||||||
@@ -580,6 +543,7 @@ public class AiGateWayManager : DomainService
|
|||||||
await response.WriteAsJsonAsync(data, cancellationToken);
|
await response.WriteAsJsonAsync(data, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Anthropic聊天完成-缓存处理
|
/// Anthropic聊天完成-缓存处理
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -603,16 +567,20 @@ public class AiGateWayManager : DomainService
|
|||||||
response.ContentType = "text/event-stream;charset=utf-8;";
|
response.ContentType = "text/event-stream;charset=utf-8;";
|
||||||
response.Headers.TryAdd("Cache-Control", "no-cache");
|
response.Headers.TryAdd("Cache-Control", "no-cache");
|
||||||
response.Headers.TryAdd("Connection", "keep-alive");
|
response.Headers.TryAdd("Connection", "keep-alive");
|
||||||
|
|
||||||
|
_specialCompatible.AnthropicCompatible(request);
|
||||||
var gateWay = LazyServiceProvider.GetRequiredService<AiGateWayManager>();
|
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.Claude, request.Model);
|
||||||
var completeChatResponse = gateWay.AnthropicCompleteChatStreamAsync(request, cancellationToken);
|
var chatService =
|
||||||
|
LazyServiceProvider.GetRequiredKeyedService<IAnthropicChatCompletionService>(modelDescribe.HandlerName);
|
||||||
|
|
||||||
|
var completeChatResponse = chatService.StreamChatCompletionsAsync(modelDescribe,request, cancellationToken);
|
||||||
ThorUsageResponse? tokenUsage = null;
|
ThorUsageResponse? tokenUsage = null;
|
||||||
StringBuilder backupSystemContent = new StringBuilder();
|
StringBuilder backupSystemContent = new StringBuilder();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await foreach (var responseResult in completeChatResponse)
|
await foreach (var responseResult in completeChatResponse)
|
||||||
{
|
{
|
||||||
|
responseResult.Item2.SupplementalMultiplier(modelDescribe.Multiplier);
|
||||||
//message_start是为了保底机制
|
//message_start是为了保底机制
|
||||||
if (responseResult.Item1.Contains("message_delta") || responseResult.Item1.Contains("message_start"))
|
if (responseResult.Item1.Contains("message_delta") || responseResult.Item1.Contains("message_start"))
|
||||||
{
|
{
|
||||||
@@ -627,14 +595,14 @@ public class AiGateWayManager : DomainService
|
|||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
_logger.LogError(e, $"Ai对话异常");
|
_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);
|
throw new UserFriendlyException(errorContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _aiMessageManager.CreateUserMessageAsync(userId, sessionId,
|
await _aiMessageManager.CreateUserMessageAsync(userId, sessionId,
|
||||||
new MessageInputDto
|
new MessageInputDto
|
||||||
{
|
{
|
||||||
Content = sessionId is null ? "不予存储" : request.Messages?.LastOrDefault()?.Content ?? string.Empty,
|
Content = "不予存储",
|
||||||
ModelId = request.Model,
|
ModelId = request.Model,
|
||||||
TokenUsage = tokenUsage,
|
TokenUsage = tokenUsage,
|
||||||
}, tokenId);
|
}, tokenId);
|
||||||
@@ -642,7 +610,7 @@ public class AiGateWayManager : DomainService
|
|||||||
await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId,
|
await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId,
|
||||||
new MessageInputDto
|
new MessageInputDto
|
||||||
{
|
{
|
||||||
Content = sessionId is null ? "不予存储" : backupSystemContent.ToString(),
|
Content = "不予存储",
|
||||||
ModelId = request.Model,
|
ModelId = request.Model,
|
||||||
TokenUsage = tokenUsage
|
TokenUsage = tokenUsage
|
||||||
}, tokenId);
|
}, tokenId);
|
||||||
@@ -660,7 +628,167 @@ public class AiGateWayManager : DomainService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Anthropic格式Http响应
|
|
||||||
|
/// <summary>
|
||||||
|
/// OpenAi 响应-非流式-缓存处理
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="httpContext"></param>
|
||||||
|
/// <param name="request"></param>
|
||||||
|
/// <param name="userId"></param>
|
||||||
|
/// <param name="sessionId"></param>
|
||||||
|
/// <param name="tokenId"></param>
|
||||||
|
/// <param name="cancellationToken"></param>
|
||||||
|
public async Task OpenAiResponsesAsyncForStatisticsAsync(HttpContext httpContext,
|
||||||
|
OpenAiResponsesInput request,
|
||||||
|
Guid? userId = null,
|
||||||
|
Guid? sessionId = null,
|
||||||
|
Guid? tokenId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// _specialCompatible.AnthropicCompatible(request);
|
||||||
|
var response = httpContext.Response;
|
||||||
|
// 设置响应头,声明是 json
|
||||||
|
//response.ContentType = "application/json; charset=UTF-8";
|
||||||
|
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.Response, request.Model);
|
||||||
|
|
||||||
|
var chatService =
|
||||||
|
LazyServiceProvider.GetRequiredKeyedService<IOpenAiResponseService>(modelDescribe.HandlerName);
|
||||||
|
var data = await chatService.ResponsesAsync(modelDescribe, request, cancellationToken);
|
||||||
|
|
||||||
|
data.SupplementalMultiplier(modelDescribe.Multiplier);
|
||||||
|
|
||||||
|
var tokenUsage= new ThorUsageResponse
|
||||||
|
{
|
||||||
|
InputTokens = data.Usage.InputTokens,
|
||||||
|
OutputTokens = data.Usage.OutputTokens,
|
||||||
|
TotalTokens = data.Usage.InputTokens + data.Usage.OutputTokens,
|
||||||
|
};
|
||||||
|
if (userId is not null)
|
||||||
|
{
|
||||||
|
await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId,
|
||||||
|
new MessageInputDto
|
||||||
|
{
|
||||||
|
Content = "不予存储",
|
||||||
|
ModelId = request.Model,
|
||||||
|
TokenUsage = tokenUsage,
|
||||||
|
}, tokenId);
|
||||||
|
|
||||||
|
await _aiMessageManager.CreateSystemMessageAsync(userId.Value, sessionId,
|
||||||
|
new MessageInputDto
|
||||||
|
{
|
||||||
|
Content = "不予存储",
|
||||||
|
ModelId = request.Model,
|
||||||
|
TokenUsage = tokenUsage
|
||||||
|
}, tokenId);
|
||||||
|
|
||||||
|
await _usageStatisticsManager.SetUsageAsync(userId.Value, request.Model, tokenUsage, tokenId);
|
||||||
|
|
||||||
|
// 扣减尊享token包用量
|
||||||
|
var totalTokens = tokenUsage.TotalTokens ?? 0;
|
||||||
|
if (totalTokens > 0)
|
||||||
|
{
|
||||||
|
await PremiumPackageManager.TryConsumeTokensAsync(userId.Value, totalTokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await response.WriteAsJsonAsync(data, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OpenAi响应-流式-缓存处理
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="httpContext"></param>
|
||||||
|
/// <param name="request"></param>
|
||||||
|
/// <param name="userId"></param>
|
||||||
|
/// <param name="sessionId"></param>
|
||||||
|
/// <param name="tokenId">Token Id(Web端传null或Guid.Empty)</param>
|
||||||
|
/// <param name="cancellationToken"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public async Task OpenAiResponsesStreamForStatisticsAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
OpenAiResponsesInput request,
|
||||||
|
Guid? userId = null,
|
||||||
|
Guid? sessionId = null,
|
||||||
|
Guid? tokenId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = httpContext.Response;
|
||||||
|
// 设置响应头,声明是 SSE 流
|
||||||
|
response.ContentType = "text/event-stream;charset=utf-8;";
|
||||||
|
response.Headers.TryAdd("Cache-Control", "no-cache");
|
||||||
|
response.Headers.TryAdd("Connection", "keep-alive");
|
||||||
|
|
||||||
|
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.Response, request.Model);
|
||||||
|
var chatService =
|
||||||
|
LazyServiceProvider.GetRequiredKeyedService<IOpenAiResponseService>(modelDescribe.HandlerName);
|
||||||
|
|
||||||
|
var completeChatResponse = chatService.ResponsesStreamAsync(modelDescribe,request, cancellationToken);
|
||||||
|
ThorUsageResponse? tokenUsage = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (var responseResult in completeChatResponse)
|
||||||
|
{
|
||||||
|
//message_start是为了保底机制
|
||||||
|
if (responseResult.Item1.Contains("response.completed"))
|
||||||
|
{
|
||||||
|
var obj = responseResult.Item2!.Value;
|
||||||
|
int inputTokens = obj.GetPath("response","usage","input_tokens").GetInt();
|
||||||
|
int outputTokens = obj.GetPath("response","usage","output_tokens").GetInt();
|
||||||
|
inputTokens=Convert.ToInt32(inputTokens * modelDescribe.Multiplier);
|
||||||
|
outputTokens=Convert.ToInt32(outputTokens * modelDescribe.Multiplier);
|
||||||
|
tokenUsage = new ThorUsageResponse
|
||||||
|
{
|
||||||
|
PromptTokens =inputTokens,
|
||||||
|
InputTokens = inputTokens,
|
||||||
|
OutputTokens = outputTokens,
|
||||||
|
CompletionTokens = outputTokens,
|
||||||
|
TotalTokens = inputTokens+outputTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteAsEventStreamDataAsync(httpContext, responseResult.Item1, responseResult.Item2,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
_logger.LogError(e, $"Ai响应异常");
|
||||||
|
var errorContent = $"响应Ai异常,异常信息:\n当前Ai模型:{request.Model}\n异常信息:{e.Message}\n异常堆栈:{e}";
|
||||||
|
throw new UserFriendlyException(errorContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _aiMessageManager.CreateUserMessageAsync(userId, sessionId,
|
||||||
|
new MessageInputDto
|
||||||
|
{
|
||||||
|
Content = "不予存储" ,
|
||||||
|
ModelId = request.Model,
|
||||||
|
TokenUsage = tokenUsage,
|
||||||
|
}, tokenId);
|
||||||
|
|
||||||
|
await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId,
|
||||||
|
new MessageInputDto
|
||||||
|
{
|
||||||
|
Content = "不予存储" ,
|
||||||
|
ModelId = request.Model,
|
||||||
|
TokenUsage = tokenUsage
|
||||||
|
}, tokenId);
|
||||||
|
|
||||||
|
await _usageStatisticsManager.SetUsageAsync(userId, request.Model, tokenUsage, tokenId);
|
||||||
|
|
||||||
|
// 扣减尊享token包用量
|
||||||
|
if (userId.HasValue && tokenUsage is not null)
|
||||||
|
{
|
||||||
|
var totalTokens = tokenUsage.TotalTokens ?? 0;
|
||||||
|
if (tokenUsage.TotalTokens > 0)
|
||||||
|
{
|
||||||
|
await PremiumPackageManager.TryConsumeTokensAsync(userId.Value, totalTokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#region 流式传输格式Http响应
|
||||||
|
|
||||||
private static readonly byte[] EventPrefix = "event: "u8.ToArray();
|
private static readonly byte[] EventPrefix = "event: "u8.ToArray();
|
||||||
private static readonly byte[] DataPrefix = "data: "u8.ToArray();
|
private static readonly byte[] DataPrefix = "data: "u8.ToArray();
|
||||||
@@ -675,7 +803,6 @@ public class AiGateWayManager : DomainService
|
|||||||
string @event,
|
string @event,
|
||||||
T value,
|
T value,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
where T : class
|
|
||||||
{
|
{
|
||||||
var response = context.Response;
|
var response = context.Response;
|
||||||
var bodyStream = response.Body;
|
var bodyStream = response.Body;
|
||||||
|
|||||||
@@ -48,14 +48,18 @@ namespace Yi.Framework.AiHub.Domain
|
|||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Anthropic ChatCompletion
|
#region Anthropic ChatCompletion
|
||||||
|
|
||||||
services.AddKeyedTransient<IAnthropicChatCompletionService, CustomOpenAIAnthropicChatCompletionsService>(
|
|
||||||
nameof(CustomOpenAIAnthropicChatCompletionsService));
|
|
||||||
services.AddKeyedTransient<IAnthropicChatCompletionService, AnthropicChatCompletionsService>(
|
services.AddKeyedTransient<IAnthropicChatCompletionService, AnthropicChatCompletionsService>(
|
||||||
nameof(AnthropicChatCompletionsService));
|
nameof(AnthropicChatCompletionsService));
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region OpenAi Response
|
||||||
|
|
||||||
|
services.AddKeyedTransient<IOpenAiResponseService, OpenAiResponseService>(
|
||||||
|
nameof(OpenAiResponseService));
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region Image
|
#region Image
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using Mapster;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using SqlSugar;
|
using SqlSugar;
|
||||||
using TencentCloud.Pds.V20210701.Models;
|
|
||||||
using Volo.Abp;
|
using Volo.Abp;
|
||||||
using Volo.Abp.Application.Dtos;
|
using Volo.Abp.Application.Dtos;
|
||||||
using Volo.Abp.EventBus.Local;
|
using Volo.Abp.EventBus.Local;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using TencentCloud.Tbm.V20180129.Models;
|
using Volo.Abp.DependencyInjection;
|
||||||
using Volo.Abp.DependencyInjection;
|
|
||||||
using Volo.Abp.Domain.Entities.Events;
|
using Volo.Abp.Domain.Entities.Events;
|
||||||
using Volo.Abp.EventBus;
|
using Volo.Abp.EventBus;
|
||||||
using Volo.Abp.EventBus.Local;
|
using Volo.Abp.EventBus.Local;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.VisualBasic;
|
using Microsoft.VisualBasic;
|
||||||
using TencentCloud.Mna.V20210119.Models;
|
|
||||||
using Volo.Abp.Application.Services;
|
using Volo.Abp.Application.Services;
|
||||||
using Volo.Abp.Caching;
|
using Volo.Abp.Caching;
|
||||||
using Volo.Abp.DependencyInjection;
|
using Volo.Abp.DependencyInjection;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using SqlSugar;
|
using SqlSugar;
|
||||||
using TencentCloud.Tcr.V20190924.Models;
|
|
||||||
using Volo.Abp;
|
using Volo.Abp;
|
||||||
using Volo.Abp.Application.Dtos;
|
using Volo.Abp.Application.Dtos;
|
||||||
using Volo.Abp.Caching;
|
using Volo.Abp.Caching;
|
||||||
|
|||||||
@@ -1,59 +1,59 @@
|
|||||||
using System;
|
// using System;
|
||||||
using System.Collections.Generic;
|
// using System.Collections.Generic;
|
||||||
using System.Linq;
|
// using System.Linq;
|
||||||
using System.Text;
|
// using System.Text;
|
||||||
using System.Threading.Tasks;
|
// using System.Threading.Tasks;
|
||||||
using TencentCloud.Common.Profile;
|
// using TencentCloud.Common.Profile;
|
||||||
using TencentCloud.Common;
|
// using TencentCloud.Common;
|
||||||
using TencentCloud.Sms.V20210111.Models;
|
// using TencentCloud.Sms.V20210111.Models;
|
||||||
using TencentCloud.Sms.V20210111;
|
// using TencentCloud.Sms.V20210111;
|
||||||
using Volo.Abp.Domain.Services;
|
// using Volo.Abp.Domain.Services;
|
||||||
using Microsoft.Extensions.Logging;
|
// using Microsoft.Extensions.Logging;
|
||||||
|
//
|
||||||
namespace Yi.Framework.Rbac.Domain.Managers
|
// namespace Yi.Framework.Rbac.Domain.Managers
|
||||||
{
|
// {
|
||||||
public class TencentCloudManager : DomainService
|
// public class TencentCloudManager : DomainService
|
||||||
{
|
// {
|
||||||
private ILogger<TencentCloudManager> _logger;
|
// private ILogger<TencentCloudManager> _logger;
|
||||||
public TencentCloudManager(ILogger<TencentCloudManager> logger)
|
// public TencentCloudManager(ILogger<TencentCloudManager> logger)
|
||||||
{
|
// {
|
||||||
_logger= logger;
|
// _logger= logger;
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
public async Task SendSmsAsync()
|
// public async Task SendSmsAsync()
|
||||||
{
|
// {
|
||||||
|
//
|
||||||
try
|
// try
|
||||||
{
|
// {
|
||||||
// 实例化一个认证对象,入参需要传入腾讯云账户 SecretId 和 SecretKey,此处还需注意密钥对的保密
|
// // 实例化一个认证对象,入参需要传入腾讯云账户 SecretId 和 SecretKey,此处还需注意密钥对的保密
|
||||||
// 代码泄露可能会导致 SecretId 和 SecretKey 泄露,并威胁账号下所有资源的安全性。以下代码示例仅供参考,建议采用更安全的方式来使用密钥,请参见:https://cloud.tencent.com/document/product/1278/85305
|
// // 代码泄露可能会导致 SecretId 和 SecretKey 泄露,并威胁账号下所有资源的安全性。以下代码示例仅供参考,建议采用更安全的方式来使用密钥,请参见:https://cloud.tencent.com/document/product/1278/85305
|
||||||
// 密钥可前往官网控制台 https://console.cloud.tencent.com/cam/capi 进行获取
|
// // 密钥可前往官网控制台 https://console.cloud.tencent.com/cam/capi 进行获取
|
||||||
Credential cred = new Credential
|
// Credential cred = new Credential
|
||||||
{
|
// {
|
||||||
SecretId = "SecretId",
|
// SecretId = "SecretId",
|
||||||
SecretKey = "SecretKey"
|
// SecretKey = "SecretKey"
|
||||||
};
|
// };
|
||||||
// 实例化一个client选项,可选的,没有特殊需求可以跳过
|
// // 实例化一个client选项,可选的,没有特殊需求可以跳过
|
||||||
ClientProfile clientProfile = new ClientProfile();
|
// ClientProfile clientProfile = new ClientProfile();
|
||||||
// 实例化一个http选项,可选的,没有特殊需求可以跳过
|
// // 实例化一个http选项,可选的,没有特殊需求可以跳过
|
||||||
HttpProfile httpProfile = new HttpProfile();
|
// HttpProfile httpProfile = new HttpProfile();
|
||||||
httpProfile.Endpoint = ("sms.tencentcloudapi.com");
|
// httpProfile.Endpoint = ("sms.tencentcloudapi.com");
|
||||||
clientProfile.HttpProfile = httpProfile;
|
// clientProfile.HttpProfile = httpProfile;
|
||||||
|
//
|
||||||
// 实例化要请求产品的client对象,clientProfile是可选的
|
// // 实例化要请求产品的client对象,clientProfile是可选的
|
||||||
SmsClient client = new SmsClient(cred, "", clientProfile);
|
// SmsClient client = new SmsClient(cred, "", clientProfile);
|
||||||
// 实例化一个请求对象,每个接口都会对应一个request对象
|
// // 实例化一个请求对象,每个接口都会对应一个request对象
|
||||||
SendSmsRequest req = new SendSmsRequest();
|
// SendSmsRequest req = new SendSmsRequest();
|
||||||
|
//
|
||||||
// 返回的resp是一个SendSmsResponse的实例,与请求对象对应
|
// // 返回的resp是一个SendSmsResponse的实例,与请求对象对应
|
||||||
SendSmsResponse resp = await client.SendSms(req);
|
// SendSmsResponse resp = await client.SendSms(req);
|
||||||
// 输出json格式的字符串回包
|
// // 输出json格式的字符串回包
|
||||||
_logger.LogInformation("腾讯云Sms返回:"+AbstractModel.ToJsonString(resp));
|
// _logger.LogInformation("腾讯云Sms返回:"+AbstractModel.ToJsonString(resp));
|
||||||
}
|
// }
|
||||||
catch (Exception e)
|
// catch (Exception e)
|
||||||
{
|
// {
|
||||||
_logger.LogError(e,e.ToString());
|
// _logger.LogError(e,e.ToString());
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<PackageReference Include="IPTools.China" Version="1.6.0" />
|
<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" />
|
<PackageReference Include="UAParser" Version="3.1.47" />
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ using Yi.Framework.AiHub.Application;
|
|||||||
using Yi.Framework.AiHub.Application.Services;
|
using Yi.Framework.AiHub.Application.Services;
|
||||||
using Yi.Framework.AiHub.Domain.Entities;
|
using Yi.Framework.AiHub.Domain.Entities;
|
||||||
using Yi.Framework.AiHub.Domain.Entities.Chat;
|
using Yi.Framework.AiHub.Domain.Entities.Chat;
|
||||||
|
using Yi.Framework.AiHub.Domain.Entities.Model;
|
||||||
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
|
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
|
||||||
using Yi.Framework.AspNetCore;
|
using Yi.Framework.AspNetCore;
|
||||||
using Yi.Framework.AspNetCore.Authentication.OAuth;
|
using Yi.Framework.AspNetCore.Authentication.OAuth;
|
||||||
@@ -287,19 +288,19 @@ namespace Yi.Abp.Web
|
|||||||
};
|
};
|
||||||
options.Events = new JwtBearerEvents
|
options.Events = new JwtBearerEvents
|
||||||
{
|
{
|
||||||
OnMessageReceived = context =>
|
OnMessageReceived = messageContext =>
|
||||||
{
|
{
|
||||||
//优先Query中获取,再去cookies中获取
|
//优先Query中获取,再去cookies中获取
|
||||||
var accessToken = context.Request.Query["access_token"];
|
var accessToken = messageContext.Request.Query["access_token"];
|
||||||
if (!string.IsNullOrEmpty(accessToken))
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
{
|
{
|
||||||
context.Token = accessToken;
|
messageContext.Token = accessToken;
|
||||||
}
|
}
|
||||||
else
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,19 +321,19 @@ namespace Yi.Abp.Web
|
|||||||
};
|
};
|
||||||
options.Events = new JwtBearerEvents
|
options.Events = new JwtBearerEvents
|
||||||
{
|
{
|
||||||
OnMessageReceived = context =>
|
OnMessageReceived = messageContext =>
|
||||||
{
|
{
|
||||||
var refresh_token = context.Request.Headers["refresh_token"];
|
var headerRefreshToken = messageContext.Request.Headers["refresh_token"];
|
||||||
if (!string.IsNullOrEmpty(refresh_token))
|
if (!string.IsNullOrEmpty(headerRefreshToken))
|
||||||
{
|
{
|
||||||
context.Token = refresh_token;
|
messageContext.Token = headerRefreshToken;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
var refreshToken = context.Request.Query["refresh_token"];
|
var queryRefreshToken = messageContext.Request.Query["refresh_token"];
|
||||||
if (!string.IsNullOrEmpty(refreshToken))
|
if (!string.IsNullOrEmpty(queryRefreshToken))
|
||||||
{
|
{
|
||||||
context.Token = refreshToken;
|
messageContext.Token = queryRefreshToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@@ -357,7 +358,7 @@ namespace Yi.Abp.Web
|
|||||||
var app = context.GetApplicationBuilder();
|
var app = context.GetApplicationBuilder();
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
|
|
||||||
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<MessageAggregateRoot>();
|
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<AiModelEntity>();
|
||||||
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<TokenAggregateRoot>();
|
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<TokenAggregateRoot>();
|
||||||
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<UsageStatisticsAggregateRoot>();
|
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<UsageStatisticsAggregateRoot>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using TencentCloud.Ame.V20190916.Models;
|
|
||||||
using TencentCloud.Tiw.V20190919.Models;
|
|
||||||
using Volo.Abp.Domain.Repositories;
|
using Volo.Abp.Domain.Repositories;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using Yi.Framework.Rbac.Application.Contracts.Dtos.User;
|
using Yi.Framework.Rbac.Application.Contracts.Dtos.User;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npx vue-tsc --noEmit)"
|
"Bash(npx vue-tsc --noEmit)",
|
||||||
|
"Bash(timeout 60 npx vue-tsc:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ VITE_WEB_BASE_API = '/dev-api'
|
|||||||
VITE_API_URL = http://localhost:19001/api/app
|
VITE_API_URL = http://localhost:19001/api/app
|
||||||
#VITE_API_URL=http://data.ccnetcore.com:19001/api/app
|
#VITE_API_URL=http://data.ccnetcore.com:19001/api/app
|
||||||
|
|
||||||
|
# 文件上传接口域名
|
||||||
|
VITE_FILE_UPLOAD_API = https://ai.ccnetcore.com
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# SSO单点登录url
|
# SSO单点登录url
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ VITE_WEB_BASE_API = '/prod-api'
|
|||||||
# 本地接口
|
# 本地接口
|
||||||
VITE_API_URL = http://data.ccnetcore.com:19001/api/app
|
VITE_API_URL = http://data.ccnetcore.com:19001/api/app
|
||||||
|
|
||||||
|
# 文件上传接口域名
|
||||||
|
VITE_FILE_UPLOAD_API = https://ai.ccnetcore.com
|
||||||
|
|
||||||
# 是否在打包时开启压缩,支持 gzip 和 brotli
|
# 是否在打包时开启压缩,支持 gzip 和 brotli
|
||||||
VITE_BUILD_COMPRESS = gzip
|
VITE_BUILD_COMPRESS = gzip
|
||||||
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
{
|
|
||||||
"globals": {
|
|
||||||
"Component": true,
|
|
||||||
"ComponentPublicInstance": true,
|
|
||||||
"ComputedRef": true,
|
|
||||||
"DirectiveBinding": true,
|
|
||||||
"EffectScope": true,
|
|
||||||
"ExtractDefaultPropTypes": true,
|
|
||||||
"ExtractPropTypes": true,
|
|
||||||
"ExtractPublicPropTypes": true,
|
|
||||||
"InjectionKey": true,
|
|
||||||
"MaybeRef": true,
|
|
||||||
"MaybeRefOrGetter": true,
|
|
||||||
"PropType": true,
|
|
||||||
"Ref": true,
|
|
||||||
"Slot": true,
|
|
||||||
"Slots": true,
|
|
||||||
"VNode": true,
|
|
||||||
"WritableComputedRef": true,
|
|
||||||
"computed": true,
|
|
||||||
"createApp": true,
|
|
||||||
"customRef": true,
|
|
||||||
"defineAsyncComponent": true,
|
|
||||||
"defineComponent": true,
|
|
||||||
"effectScope": true,
|
|
||||||
"getCurrentInstance": true,
|
|
||||||
"getCurrentScope": true,
|
|
||||||
"h": true,
|
|
||||||
"inject": true,
|
|
||||||
"isProxy": true,
|
|
||||||
"isReactive": true,
|
|
||||||
"isReadonly": true,
|
|
||||||
"isRef": true,
|
|
||||||
"markRaw": true,
|
|
||||||
"nextTick": true,
|
|
||||||
"onActivated": true,
|
|
||||||
"onBeforeMount": true,
|
|
||||||
"onBeforeUnmount": true,
|
|
||||||
"onBeforeUpdate": true,
|
|
||||||
"onDeactivated": true,
|
|
||||||
"onErrorCaptured": true,
|
|
||||||
"onMounted": true,
|
|
||||||
"onRenderTracked": true,
|
|
||||||
"onRenderTriggered": true,
|
|
||||||
"onScopeDispose": true,
|
|
||||||
"onServerPrefetch": true,
|
|
||||||
"onUnmounted": true,
|
|
||||||
"onUpdated": true,
|
|
||||||
"onWatcherCleanup": true,
|
|
||||||
"provide": true,
|
|
||||||
"reactive": true,
|
|
||||||
"readonly": true,
|
|
||||||
"ref": true,
|
|
||||||
"resolveComponent": true,
|
|
||||||
"shallowReactive": true,
|
|
||||||
"shallowReadonly": true,
|
|
||||||
"shallowRef": true,
|
|
||||||
"toRaw": true,
|
|
||||||
"toRef": true,
|
|
||||||
"toRefs": true,
|
|
||||||
"toValue": true,
|
|
||||||
"triggerRef": true,
|
|
||||||
"unref": true,
|
|
||||||
"useAttrs": true,
|
|
||||||
"useCssModule": true,
|
|
||||||
"useCssVars": true,
|
|
||||||
"useId": true,
|
|
||||||
"useModel": true,
|
|
||||||
"useSlots": true,
|
|
||||||
"useTemplateRef": true,
|
|
||||||
"watch": true,
|
|
||||||
"watchEffect": true,
|
|
||||||
"watchPostEffect": true,
|
|
||||||
"watchSyncEffect": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
7
Yi.Ai.Vue3/.gitignore
vendored
7
Yi.Ai.Vue3/.gitignore
vendored
@@ -23,3 +23,10 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
/.eslintrc-auto-import.json
|
||||||
|
/types/auto-imports.d.ts
|
||||||
|
/types/components.d.ts
|
||||||
|
/types/import_meta.d.ts
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<!-- 加载动画容器 -->
|
<!-- 加载动画容器 -->
|
||||||
<div id="yixinai-loader" class="loader-container">
|
<div id="yixinai-loader" class="loader-container">
|
||||||
<div class="loader-title">意心Ai 2.5</div>
|
<div class="loader-title">意心Ai 2.8</div>
|
||||||
<div class="loader-subtitle">海外地址,仅首次访问预计加载约10秒</div>
|
<div class="loader-subtitle">海外地址,仅首次访问预计加载约10秒</div>
|
||||||
<div class="loader-logo">
|
<div class="loader-logo">
|
||||||
<div class="pulse-box"></div>
|
<div class="pulse-box"></div>
|
||||||
|
|||||||
@@ -44,7 +44,9 @@
|
|||||||
"fingerprintjs": "^0.5.3",
|
"fingerprintjs": "^0.5.3",
|
||||||
"hook-fetch": "^2.0.4-beta.1",
|
"hook-fetch": "^2.0.4-beta.1",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
|
"mammoth": "^1.11.0",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
|
"pdfjs-dist": "^5.4.449",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"pinia-plugin-persistedstate": "^4.4.1",
|
"pinia-plugin-persistedstate": "^4.4.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -52,7 +54,30 @@
|
|||||||
"reset-css": "^5.0.2",
|
"reset-css": "^5.0.2",
|
||||||
"vue": "^3.5.17",
|
"vue": "^3.5.17",
|
||||||
"vue-element-plus-x": "1.3.7",
|
"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": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "^4.16.2",
|
"@antfu/eslint-config": "^4.16.2",
|
||||||
@@ -87,7 +112,37 @@
|
|||||||
"vite-plugin-compression": "^0.5.1",
|
"vite-plugin-compression": "^0.5.1",
|
||||||
"vite-plugin-env-typed": "^0.0.2",
|
"vite-plugin-env-typed": "^0.0.2",
|
||||||
"vite-plugin-svg-icons": "^2.0.1",
|
"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": {
|
"config": {
|
||||||
"commitizen": {
|
"commitizen": {
|
||||||
|
|||||||
4701
Yi.Ai.Vue3/pnpm-lock.yaml
generated
4701
Yi.Ai.Vue3/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
Yi.Ai.Vue3/publish_aihub_02.zip
Normal file
BIN
Yi.Ai.Vue3/publish_aihub_02.zip
Normal file
Binary file not shown.
@@ -220,4 +220,16 @@ export interface ChatMessageVo {
|
|||||||
* 用户id
|
* 用户id
|
||||||
*/
|
*/
|
||||||
userId?: number;
|
userId?: number;
|
||||||
|
/**
|
||||||
|
* 用户消息中的图片列表(前端扩展字段)
|
||||||
|
*/
|
||||||
|
images?: Array<{ url: string; name?: string }>;
|
||||||
|
/**
|
||||||
|
* 用户消息中的文件列表(前端扩展字段)
|
||||||
|
*/
|
||||||
|
files?: Array<{ name: string; size: number }>;
|
||||||
|
/**
|
||||||
|
* 创建时间(前端显示用)
|
||||||
|
*/
|
||||||
|
creationTime?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
34
Yi.Ai.Vue3/src/api/file/index.ts
Normal file
34
Yi.Ai.Vue3/src/api/file/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { UploadFileResponse } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件
|
||||||
|
* @param file 文件对象
|
||||||
|
* @returns 返回文件ID数组
|
||||||
|
*/
|
||||||
|
export async function uploadFile(file: File): Promise<UploadFileResponse[]> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const uploadApiUrl = import.meta.env.VITE_FILE_UPLOAD_API;
|
||||||
|
|
||||||
|
const response = await fetch(`${uploadApiUrl}/prod-api/file`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('文件上传失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成文件URL
|
||||||
|
* @param fileId 文件ID
|
||||||
|
* @returns 文件访问URL
|
||||||
|
*/
|
||||||
|
export function getFileUrl(fileId: string): string {
|
||||||
|
return `https://ccnetcore.com/prod-api/file/${fileId}/true`;
|
||||||
|
}
|
||||||
3
Yi.Ai.Vue3/src/api/file/types.ts
Normal file
3
Yi.Ai.Vue3/src/api/file/types.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export interface UploadFileResponse {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from './announcement'
|
export * from './announcement'
|
||||||
export * from './auth';
|
export * from './auth';
|
||||||
export * from './chat';
|
export * from './chat';
|
||||||
|
export * from './file';
|
||||||
export * from './model';
|
export * from './model';
|
||||||
export * from './pay';
|
export * from './pay';
|
||||||
export * from './session';
|
export * from './session';
|
||||||
|
|||||||
@@ -1,10 +1,56 @@
|
|||||||
import type { GetSessionListVO } from './types';
|
import type { GetSessionListVO, ModelApiTypeOption, ModelLibraryDto, ModelLibraryGetListInput, ModelTypeOption, PagedResultDto } from './types';
|
||||||
import { del, get, post, put } from '@/utils/request';
|
import { del, get, post, put } from '@/utils/request';
|
||||||
|
|
||||||
// 获取当前用户的模型列表
|
// 获取当前用户的模型列表
|
||||||
export function getModelList() {
|
export function getModelList() {
|
||||||
return get<GetSessionListVO[]>('/ai-chat/model').json();
|
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
|
// 申请ApiKey
|
||||||
export function applyApiKey() {
|
export function applyApiKey() {
|
||||||
return post<any>('/token').json();
|
return post<any>('/token').json();
|
||||||
|
|||||||
@@ -13,3 +13,60 @@ export interface GetSessionListVO {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 模型类型枚举
|
||||||
|
export enum ModelTypeEnum {
|
||||||
|
Chat = 0,
|
||||||
|
Image = 1,
|
||||||
|
Embedding = 2,
|
||||||
|
PremiumChat = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模型API类型枚举
|
||||||
|
export enum ModelApiTypeEnum {
|
||||||
|
OpenAi = 0,
|
||||||
|
Claude = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模型库展示数据
|
||||||
|
export interface ModelLibraryDto {
|
||||||
|
modelId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
modelType: ModelTypeEnum;
|
||||||
|
modelApiTypes: Array;
|
||||||
|
modelApiTypeName: string;
|
||||||
|
multiplierShow: number;
|
||||||
|
providerName?: string;
|
||||||
|
iconUrl?: string;
|
||||||
|
isPremium: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模型库列表查询参数
|
||||||
|
export interface ModelLibraryGetListInput {
|
||||||
|
searchKey?: string;
|
||||||
|
providerNames?: string[];
|
||||||
|
modelTypes?: ModelTypeEnum[];
|
||||||
|
modelApiTypes?: ModelApiTypeEnum[];
|
||||||
|
isPremiumOnly?: boolean;
|
||||||
|
skipCount?: number;
|
||||||
|
maxResultCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页结果
|
||||||
|
export interface PagedResultDto<T> {
|
||||||
|
items: T[];
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模型类型选项
|
||||||
|
export interface ModelTypeOption {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API类型选项
|
||||||
|
export interface ModelApiTypeOption {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|||||||
7
Yi.Ai.Vue3/src/assets/icons/System/notification-fill.svg
Normal file
7
Yi.Ai.Vue3/src/assets/icons/System/notification-fill.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<g>
|
||||||
|
|
||||||
|
<path fill="none" d="M0 0h24v24H0z"/>
|
||||||
|
<path d="M12 2C16.9706 2 21 6.04348 21 11.0314V20H3V11.0314C3 6.04348 7.02944 2 12 2ZM9.5 21H14.5C14.5 22.3807 13.3807 23.5 12 23.5C10.6193 23.5 9.5 22.3807 9.5 21Z"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 322 B |
340
Yi.Ai.Vue3/src/components/ChatMessageList/index.vue
Normal file
340
Yi.Ai.Vue3/src/components/ChatMessageList/index.vue
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
<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, watch } 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否滚动到底部
|
||||||
|
* 允许一定的误差范围(5px)
|
||||||
|
*/
|
||||||
|
function isScrolledToBottom(): boolean {
|
||||||
|
if (!scrollContainer.value) return false;
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainer.value;
|
||||||
|
return scrollHeight - scrollTop - clientHeight < 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平滑滚动到底部(不使用 smooth 行为,避免跳动)
|
||||||
|
*/
|
||||||
|
function scrollToBottomSmooth() {
|
||||||
|
if (!scrollContainer.value || !autoScroll.value) return;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (scrollContainer.value) {
|
||||||
|
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动到底部(立即)
|
||||||
|
*/
|
||||||
|
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(() => {
|
||||||
|
// 如果启用了自动滚动且用户没有在滚动,则滚动到底部
|
||||||
|
if (autoScroll.value && !isUserScrolling.value) {
|
||||||
|
scrollToBottomSmooth();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听子元素的变化和文本内容的变化
|
||||||
|
mutationObserver.observe(contentElement, {
|
||||||
|
childList: true, // 监听子元素的添加/删除
|
||||||
|
subtree: true, // 监听所有后代元素
|
||||||
|
characterData: true, // 监听文本内容变化
|
||||||
|
attributes: false // 不监听属性变化
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听列表变化
|
||||||
|
watch(
|
||||||
|
() => props.list,
|
||||||
|
() => {
|
||||||
|
// 列表变化时,如果启用了自动滚动,滚动到底部
|
||||||
|
if (autoScroll.value) {
|
||||||
|
nextTick(() => {
|
||||||
|
scrollToBottomSmooth();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 组件挂载时初始化
|
||||||
|
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 (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>
|
||||||
@@ -1,148 +1,751 @@
|
|||||||
<!-- 文件上传 -->
|
<!-- 文件上传 -->
|
||||||
<script setup lang="ts">
|
<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 { useFileDialog } from '@vueuse/core';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import Popover from '@/components/Popover/index.vue';
|
import mammoth from 'mammoth';
|
||||||
import SvgIcon from '@/components/SvgIcon/index.vue';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
import { useFilesStore } from '@/stores/modules/files';
|
import { useFilesStore } from '@/stores/modules/files';
|
||||||
|
|
||||||
type FilesList = FilesCardProps & {
|
// 配置 PDF.js worker - 使用稳定的 CDN
|
||||||
file: File;
|
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`;
|
||||||
};
|
|
||||||
|
|
||||||
const filesStore = useFilesStore();
|
const filesStore = useFilesStore();
|
||||||
|
|
||||||
/* 弹出面板 开始 */
|
// 文件大小限制 3MB
|
||||||
const popoverStyle = ref({
|
const MAX_FILE_SIZE = 3 * 1024 * 1024;
|
||||||
padding: '4px',
|
|
||||||
height: 'fit-content',
|
// 单个文件内容长度限制
|
||||||
background: 'var(--el-bg-color, #fff)',
|
const MAX_TEXT_FILE_LENGTH = 50000; // 文本文件最大字符数
|
||||||
border: '1px solid var(--el-border-color-light)',
|
const MAX_WORD_LENGTH = 30000; // Word 文档最大字符数
|
||||||
borderRadius: '8px',
|
const MAX_EXCEL_ROWS = 100; // Excel 最大行数
|
||||||
boxShadow: '0 2px 12px 0 rgba(0, 0, 0, 0.1)',
|
const MAX_PDF_PAGES = 10; // PDF 最大页数
|
||||||
});
|
|
||||||
const popoverRef = ref();
|
// 整个消息总长度限制(所有文件内容加起来,预估 token 安全限制)
|
||||||
/* 弹出面板 结束 */
|
// 272000 tokens * 0.55 安全系数 ≈ 150000 字符
|
||||||
|
const MAX_TOTAL_CONTENT_LENGTH = 150000;
|
||||||
|
|
||||||
const { reset, open, onChange } = useFileDialog({
|
const { reset, open, onChange } = useFileDialog({
|
||||||
// 允许所有图片文件,文档文件,音视频文件
|
// 支持图片、文档、文本文件等
|
||||||
accept: 'image/*,video/*,audio/*,application/*',
|
accept: 'image/*,.txt,.log,.csv,.tsv,.md,.markdown,.json,.xml,.yaml,.yml,.toml,.ini,.conf,.config,.properties,.prop,.env,'
|
||||||
directory: false, // 是否允许选择文件夹
|
+ '.js,.jsx,.ts,.tsx,.vue,.html,.htm,.css,.scss,.sass,.less,.styl,'
|
||||||
multiple: true, // 是否允许多选
|
+ '.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)
|
if (!files)
|
||||||
return;
|
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++) {
|
for (let i = 0; i < files!.length; i++) {
|
||||||
const file = files![i];
|
const file = files![i];
|
||||||
arr.push({
|
|
||||||
uid: crypto.randomUUID(), // 不写 uid,文件列表展示不出来,elx 1.2.0 bug 待修复
|
// 验证文件大小
|
||||||
name: file.name,
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
fileSize: file.size,
|
ElMessage.error(`文件 ${file.name} 超过 3MB 限制,已跳过`);
|
||||||
file,
|
continue;
|
||||||
maxWidth: '200px',
|
}
|
||||||
showDelIcon: true, // 显示删除图标
|
|
||||||
imgPreview: true, // 显示图片预览
|
const ext = getFileExtension(file.name);
|
||||||
imgVariant: 'square', // 图片预览的形状
|
const isImage = file.type.startsWith('image/');
|
||||||
url: URL.createObjectURL(file), // 图片预览地址
|
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());
|
nextTick(() => reset());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开文件选择对话框
|
||||||
|
*/
|
||||||
function handleUploadFiles() {
|
function handleUploadFiles() {
|
||||||
open();
|
open();
|
||||||
popoverRef.value.hide();
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="files-select">
|
<div class="files-select">
|
||||||
<Popover
|
<!-- 直接点击上传,添加 tooltip 提示 -->
|
||||||
ref="popoverRef"
|
<el-tooltip
|
||||||
placement="top-start"
|
content="上传文件或图片(支持 Excel、Word、PDF、代码文件等,最大3MB)"
|
||||||
:offset="[4, 0]"
|
placement="top"
|
||||||
popover-class="popover-content"
|
|
||||||
:popover-style="popoverStyle"
|
|
||||||
trigger="clickTarget"
|
|
||||||
>
|
>
|
||||||
<template #trigger>
|
<div
|
||||||
<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)]"
|
||||||
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>
|
<el-icon>
|
||||||
<Paperclip />
|
<Paperclip />
|
||||||
</el-icon>
|
</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>
|
</div>
|
||||||
</Popover>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -510,7 +510,12 @@ function onClose() {
|
|||||||
|
|
||||||
<div style="display: flex;justify-content: space-between;margin-top: 15px;">
|
<div style="display: flex;justify-content: space-between;margin-top: 15px;">
|
||||||
<div>
|
<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;">
|
<p style="margin-top: 10px;">
|
||||||
客服微信号:chengzilaoge520 或扫描右侧二维码
|
客服微信号:chengzilaoge520 或扫描右侧二维码
|
||||||
</p>
|
</p>
|
||||||
@@ -692,7 +697,13 @@ function onClose() {
|
|||||||
|
|
||||||
<div style="display: flex;justify-content: space-between;margin-top: 15px;">
|
<div style="display: flex;justify-content: space-between;margin-top: 15px;">
|
||||||
<div>
|
<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;">
|
<p style="margin-top: 10px;">
|
||||||
客服微信号:chengzilaoge520 或扫描右侧二维码
|
客服微信号:chengzilaoge520 或扫描右侧二维码
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -797,7 +797,7 @@ function generateShareContent(): string {
|
|||||||
👉 点击链接立即参与我的专属邀请码链接:
|
👉 点击链接立即参与我的专属邀请码链接:
|
||||||
${shareLink}
|
${shareLink}
|
||||||
|
|
||||||
🍀 未注册用户,微信扫码登录,进入用户中心👉每周邀请 即可立即参与!`;
|
🍀 未注册用户,微信扫码登录,进入控制台👉每周邀请 即可立即参与!`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,4 +12,4 @@ export const COLLAPSE_THRESHOLD: number = 600;
|
|||||||
export const SIDE_BAR_WIDTH: number = 280;
|
export const SIDE_BAR_WIDTH: number = 280;
|
||||||
|
|
||||||
// 路由白名单地址[本地存在的路由 staticRouter.ts 中]
|
// 路由白名单地址[本地存在的路由 staticRouter.ts 中]
|
||||||
export const ROUTER_WHITE_LIST: string[] = ['/chat', '/chat/not_login', '/products', '/403', '/404'];
|
export const ROUTER_WHITE_LIST: string[] = ['/chat', '/chat/not_login', '/products', '/model-library', '/403', '/404'];
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ export function useGuideTour() {
|
|||||||
{
|
{
|
||||||
element: '[data-tour="user-avatar"]',
|
element: '[data-tour="user-avatar"]',
|
||||||
popover: {
|
popover: {
|
||||||
title: '用户中心',
|
title: '控制台',
|
||||||
description: '点击头像可以进入用户中心,管理您的账户信息、查看使用统计、API密钥等。接下来将为您详细介绍用户中心的各项功能。',
|
description: '点击头像可以进入控制台,管理您的账户信息、查看使用统计、API密钥等。接下来将为您详细介绍用户中心的各项功能。',
|
||||||
side: 'bottom',
|
side: 'bottom',
|
||||||
align: 'end',
|
align: 'end',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
.layout-blank{
|
.layout-blank{
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
margin: 20px ;
|
|
||||||
}
|
}
|
||||||
/* 无样式 */
|
/* 无样式 */
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ useWindowWidthObserver();
|
|||||||
|
|
||||||
// 应用加载时检查是否需要显示公告弹窗
|
// 应用加载时检查是否需要显示公告弹窗
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
console.log('announcementStore.shouldShowDialog--', announcementStore.shouldShowDialog);
|
|
||||||
// 检查是否应该显示弹窗(只有"关闭一周"且未超过7天才不显示)
|
// 检查是否应该显示弹窗(只有"关闭一周"且未超过7天才不显示)
|
||||||
// 数据获取已移至 SystemAnnouncementDialog 组件内部,每次打开弹窗时都会获取最新数据
|
// 数据获取已移至 SystemAnnouncementDialog 组件内部,每次打开弹窗时都会获取最新数据
|
||||||
if (announcementStore.shouldShowDialog) {
|
if (announcementStore.shouldShowDialog) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function openTutorial() {
|
|||||||
@click="openTutorial"
|
@click="openTutorial"
|
||||||
>
|
>
|
||||||
<!-- PC端显示文字 -->
|
<!-- PC端显示文字 -->
|
||||||
<span class="pc-text">AI使用教程</span>
|
<span class="pc-text">文档</span>
|
||||||
<!-- 移动端显示图标 -->
|
<!-- 移动端显示图标 -->
|
||||||
<svg
|
<svg
|
||||||
class="mobile-icon w-6 h-6"
|
class="mobile-icon w-6 h-6"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Bell } from '@element-plus/icons-vue';
|
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useAnnouncementStore } from '@/stores';
|
import { useAnnouncementStore } from '@/stores';
|
||||||
|
|
||||||
@@ -30,14 +29,27 @@ function openAnnouncement() {
|
|||||||
<!-- :max="99" -->
|
<!-- :max="99" -->
|
||||||
<div
|
<div
|
||||||
class="announcement-btn"
|
class="announcement-btn"
|
||||||
|
title="查看公告"
|
||||||
@click="openAnnouncement"
|
@click="openAnnouncement"
|
||||||
>
|
>
|
||||||
<!-- PC端显示文字 -->
|
<!-- PC端显示文字 -->
|
||||||
<span class="pc-text">公告</span>
|
<span class="pc-text">公告</span>
|
||||||
<!-- 移动端显示图标 -->
|
<!-- 移动端显示图标 -->
|
||||||
<el-icon class="mobile-icon" :size="20">
|
<svg
|
||||||
<Bell />
|
class="mobile-icon"
|
||||||
</el-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>
|
</div>
|
||||||
</el-badge>
|
</el-badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ChatLineRound } from '@element-plus/icons-vue';
|
import { ChatLineRound } from '@element-plus/icons-vue';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
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 { useRouter } from 'vue-router';
|
||||||
import Popover from '@/components/Popover/index.vue';
|
import Popover from '@/components/Popover/index.vue';
|
||||||
import SvgIcon from '@/components/SvgIcon/index.vue';
|
import SvgIcon from '@/components/SvgIcon/index.vue';
|
||||||
import { useGuideTour } from '@/hooks/useGuideTour';
|
import { useGuideTour } from '@/hooks/useGuideTour';
|
||||||
import { useGuideTourStore, useUserStore } from '@/stores';
|
import { useAnnouncementStore, useGuideTourStore, useUserStore } from '@/stores';
|
||||||
import { useSessionStore } from '@/stores/modules/session';
|
import { useSessionStore } from '@/stores/modules/session';
|
||||||
import { showProductPackage } from '@/utils/product-package';
|
|
||||||
import { getUserProfilePicture, isUserVip } from '@/utils/user';
|
import { getUserProfilePicture, isUserVip } from '@/utils/user';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -17,15 +16,9 @@ const router = useRouter();
|
|||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
const guideTourStore = useGuideTourStore();
|
const guideTourStore = useGuideTourStore();
|
||||||
|
const announcementStore = useAnnouncementStore();
|
||||||
const { startUserCenterTour } = useGuideTour();
|
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({
|
const popoverStyle = ref({
|
||||||
width: '200px',
|
width: '200px',
|
||||||
@@ -36,21 +29,32 @@ const popoverRef = ref();
|
|||||||
|
|
||||||
// 弹出面板内容
|
// 弹出面板内容
|
||||||
const popoverList = ref([
|
const popoverList = ref([
|
||||||
// {
|
|
||||||
// key: '1',
|
|
||||||
// title: '收藏夹',
|
|
||||||
// icon: 'book-mark-fill',
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// key: '2',
|
|
||||||
// title: '设置',
|
|
||||||
// icon: 'settings-4-fill',
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
key: '5',
|
key: '5',
|
||||||
title: '用户中心',
|
title: '控制台',
|
||||||
icon: 'settings-4-fill',
|
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',
|
key: '6',
|
||||||
title: '新手引导',
|
title: '新手引导',
|
||||||
@@ -126,6 +130,21 @@ function handleClick(item: any) {
|
|||||||
case '6':
|
case '6':
|
||||||
handleStartTutorial();
|
handleStartTutorial();
|
||||||
break;
|
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':
|
case '4':
|
||||||
popoverRef.value?.hide?.();
|
popoverRef.value?.hide?.();
|
||||||
ElMessageBox.confirm('退出登录不会丢失任何数据,你仍可以登录此账号。', '确认退出登录?', {
|
ElMessageBox.confirm('退出登录不会丢失任何数据,你仍可以登录此账号。', '确认退出登录?', {
|
||||||
@@ -200,11 +219,6 @@ function openVipGuide() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 弹出面板 结束 */
|
|
||||||
function onProductPackage() {
|
|
||||||
showProductPackage();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 监听对话框打开事件,切换到邀请码标签页 ============
|
// ============ 监听对话框打开事件,切换到邀请码标签页 ============
|
||||||
watch(dialogVisible, (newVal) => {
|
watch(dialogVisible, (newVal) => {
|
||||||
if (newVal && externalInviteCode.value) {
|
if (newVal && externalInviteCode.value) {
|
||||||
@@ -287,19 +301,17 @@ watch(() => guideTourStore.shouldStartUserCenterTour, (shouldStart) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ 暴露方法供外部调用 ============
|
||||||
|
defineExpose({
|
||||||
|
openDialog,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 ">
|
||||||
<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=" 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">
|
<div class="text-sm font-semibold text-gray-800">
|
||||||
{{ userStore.userInfo?.user.nick ?? '未登录用户' }}
|
{{ userStore.userInfo?.user.nick ?? '未登录用户' }}
|
||||||
</div>
|
</div>
|
||||||
@@ -382,7 +394,7 @@ watch(() => guideTourStore.shouldStartUserCenterTour, (shouldStart) => {
|
|||||||
</div>
|
</div>
|
||||||
<nav-dialog
|
<nav-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
title="用户中心"
|
title="控制台"
|
||||||
:nav-items="navItems"
|
:nav-items="navItems"
|
||||||
:default-active="activeNav"
|
:default-active="activeNav"
|
||||||
@confirm="handleConfirm"
|
@confirm="handleConfirm"
|
||||||
@@ -453,44 +465,4 @@ watch(() => guideTourStore.shouldStartUserCenterTour, (shouldStart) => {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
|
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); }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { showProductPackage } from '@/utils/product-package';
|
||||||
|
|
||||||
|
// 点击购买按钮
|
||||||
|
function onProductPackage() {
|
||||||
|
showProductPackage();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="buy-btn-container">
|
||||||
|
<el-button
|
||||||
|
class="buy-btn flex items-center gap-2 px-5 py-2 font-semibold shadow-lg"
|
||||||
|
data-tour="buy-btn"
|
||||||
|
@click="onProductPackage"
|
||||||
|
>
|
||||||
|
<span>立即购买</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.buy-btn-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 22px 0 0;
|
||||||
|
|
||||||
|
.buy-btn {
|
||||||
|
background: linear-gradient(90deg, #FFD700, #FFC107);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 20px rgba(255, 215, 0, 0.5);
|
||||||
|
background: linear-gradient(90deg, #FFC107, #FFD700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-rocket {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-bounce {
|
||||||
|
animation: bounce 1.2s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端,屏幕小于756px
|
||||||
|
@media screen and (max-width: 756px) {
|
||||||
|
.buy-btn-container {
|
||||||
|
margin: 0 ;
|
||||||
|
|
||||||
|
.buy-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
max-width: 60px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-4px); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useUserStore } from '@/stores';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
// 打开用户中心对话框(通过调用 Avatar 组件的方法)
|
||||||
|
function openConsole() {
|
||||||
|
// 触发事件,由父组件处理
|
||||||
|
emit('open-console');
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits(['open-console']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="console-btn-container" data-tour="console-btn">
|
||||||
|
<div
|
||||||
|
class="console-btn"
|
||||||
|
title="打开控制台"
|
||||||
|
@click="openConsole"
|
||||||
|
>
|
||||||
|
<!-- PC端显示文字 -->
|
||||||
|
<span class="pc-text">控制台</span>
|
||||||
|
<!-- 移动端显示图标 -->
|
||||||
|
<svg
|
||||||
|
class="mobile-icon"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||||
|
<line x1="8" y1="21" x2="16" y2="21" />
|
||||||
|
<line x1="12" y1="17" x2="12" y2="21" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.console-btn-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.console-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #606266;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #909399;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PC端显示文字,隐藏图标
|
||||||
|
.pc-text {
|
||||||
|
display: inline;
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端显示图标,隐藏文字
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.console-btn-container {
|
||||||
|
.console-btn {
|
||||||
|
.pc-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-icon {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function goToModelLibrary() {
|
||||||
|
router.push('/model-library');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="model-library-btn-container" data-tour="model-library-btn">
|
||||||
|
<div
|
||||||
|
class="model-library-btn"
|
||||||
|
title="查看模型库"
|
||||||
|
@click="goToModelLibrary"
|
||||||
|
>
|
||||||
|
<!-- PC端显示文字 -->
|
||||||
|
<span class="pc-text">模型库</span>
|
||||||
|
<!-- 移动端显示图标 -->
|
||||||
|
<svg
|
||||||
|
class="mobile-icon"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="7" height="7" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.model-library-btn-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.model-library-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #606266;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #606266;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PC端显示文字,隐藏图标
|
||||||
|
.pc-text {
|
||||||
|
display: inline;
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端显示图标,隐藏文字
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.model-library-btn-container {
|
||||||
|
.model-library-btn {
|
||||||
|
.pc-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-icon {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,15 +7,20 @@ import { useSessionStore } from '@/stores/modules/session';
|
|||||||
import AiTutorialBtn from './components/AiTutorialBtn.vue';
|
import AiTutorialBtn from './components/AiTutorialBtn.vue';
|
||||||
import AnnouncementBtn from './components/AnnouncementBtn.vue';
|
import AnnouncementBtn from './components/AnnouncementBtn.vue';
|
||||||
import Avatar from './components/Avatar.vue';
|
import Avatar from './components/Avatar.vue';
|
||||||
|
import BuyBtn from './components/BuyBtn.vue';
|
||||||
import Collapse from './components/Collapse.vue';
|
import Collapse from './components/Collapse.vue';
|
||||||
|
import ConsoleBtn from './components/ConsoleBtn.vue';
|
||||||
import CreateChat from './components/CreateChat.vue';
|
import CreateChat from './components/CreateChat.vue';
|
||||||
import LoginBtn from './components/LoginBtn.vue';
|
import LoginBtn from './components/LoginBtn.vue';
|
||||||
|
import ModelLibraryBtn from './components/ModelLibraryBtn.vue';
|
||||||
import TitleEditing from './components/TitleEditing.vue';
|
import TitleEditing from './components/TitleEditing.vue';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const designStore = useDesignStore();
|
const designStore = useDesignStore();
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
|
|
||||||
|
const avatarRef = ref();
|
||||||
|
|
||||||
const currentSession = computed(() => sessionStore.currentSession);
|
const currentSession = computed(() => sessionStore.currentSession);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -42,6 +47,11 @@ function handleCtrlK(event: KeyboardEvent) {
|
|||||||
onKeyStroke(event => event.ctrlKey && event.key.toLowerCase() === 'k', handleCtrlK, {
|
onKeyStroke(event => event.ctrlKey && event.key.toLowerCase() === 'k', handleCtrlK, {
|
||||||
passive: false,
|
passive: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 打开控制台
|
||||||
|
function handleOpenConsole() {
|
||||||
|
avatarRef.value?.openDialog?.();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div class="right-box flex h-full items-center pr-20px flex-shrink-0 mr-auto flex-row">
|
||||||
<AnnouncementBtn />
|
<AnnouncementBtn />
|
||||||
|
<ModelLibraryBtn />
|
||||||
<AiTutorialBtn />
|
<AiTutorialBtn />
|
||||||
<Avatar v-show="userStore.userInfo" />
|
<ConsoleBtn @open-console="handleOpenConsole" />
|
||||||
|
<BuyBtn v-show="userStore.userInfo" />
|
||||||
|
<Avatar v-show="userStore.userInfo" ref="avatarRef" />
|
||||||
<LoginBtn v-show="!userStore.userInfo" />
|
<LoginBtn v-show="!userStore.userInfo" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { ElMessage } from 'element-plus';
|
|||||||
import { nextTick, ref, watch } from 'vue';
|
import { nextTick, ref, watch } from 'vue';
|
||||||
import ModelSelect from '@/components/ModelSelect/index.vue';
|
import ModelSelect from '@/components/ModelSelect/index.vue';
|
||||||
import WelecomeText from '@/components/WelecomeText/index.vue';
|
import WelecomeText from '@/components/WelecomeText/index.vue';
|
||||||
import { useGuideTour } from '@/hooks/useGuideTour';
|
|
||||||
import { useGuideTourStore, useUserStore } from '@/stores';
|
import { useGuideTourStore, useUserStore } from '@/stores';
|
||||||
import { useFilesStore } from '@/stores/modules/files';
|
import { useFilesStore } from '@/stores/modules/files';
|
||||||
|
|
||||||
@@ -135,6 +134,8 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
|
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
|
||||||
|
<FilesSelect />
|
||||||
|
|
||||||
<ModelSelect />
|
<ModelSelect />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AnyObject } from 'typescript-api-pro';
|
import type { AnyObject } from 'typescript-api-pro';
|
||||||
import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
|
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 { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
|
||||||
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
|
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 { ElIcon, ElMessage } from 'element-plus';
|
||||||
import { useHookFetch } from 'hook-fetch/vue';
|
import { useHookFetch } from 'hook-fetch/vue';
|
||||||
import { computed, nextTick, ref, watch } from '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 { useRoute } from 'vue-router';
|
||||||
import { send } from '@/api';
|
import { send } from '@/api';
|
||||||
import ModelSelect from '@/components/ModelSelect/index.vue';
|
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 { useGuideTourStore } from '@/stores';
|
||||||
import { useChatStore } from '@/stores/modules/chat';
|
import { useChatStore } from '@/stores/modules/chat';
|
||||||
import { useFilesStore } from '@/stores/modules/files';
|
import { useFilesStore } from '@/stores/modules/files';
|
||||||
@@ -30,6 +30,8 @@ type MessageItem = BubbleProps & {
|
|||||||
thinkingStatus?: ThinkingStatus;
|
thinkingStatus?: ThinkingStatus;
|
||||||
thinlCollapse?: boolean;
|
thinlCollapse?: boolean;
|
||||||
reasoning_content?: string;
|
reasoning_content?: string;
|
||||||
|
images?: Array<{ url: string; name?: string }>; // 用户消息中的图片列表
|
||||||
|
files?: Array<{ name: string; size: number }>; // 用户消息中的文件列表
|
||||||
};
|
};
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -48,7 +50,7 @@ const avatar = computed(() => {
|
|||||||
const inputValue = ref('');
|
const inputValue = ref('');
|
||||||
const senderRef = ref<InstanceType<typeof Sender> | null>(null);
|
const senderRef = ref<InstanceType<typeof Sender> | null>(null);
|
||||||
const bubbleItems = ref<MessageItem[]>([]);
|
const bubbleItems = ref<MessageItem[]>([]);
|
||||||
const bubbleListRef = ref<BubbleListInstance | null>(null);
|
const bubbleListRef = ref<InstanceType<typeof ChatMessageList> | null>(null);
|
||||||
const isSending = ref(false);
|
const isSending = ref(false);
|
||||||
|
|
||||||
const { stream, loading: isLoading, cancel } = useHookFetch({
|
const { stream, loading: isLoading, cancel } = useHookFetch({
|
||||||
@@ -114,7 +116,11 @@ watch(
|
|||||||
{ immediate: true, deep: true },
|
{ immediate: true, deep: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
// 封装数据处理逻辑
|
/**
|
||||||
|
* 处理流式响应的数据块
|
||||||
|
* 解析 AI 返回的数据,更新消息内容和思考状态
|
||||||
|
* @param {AnyObject} chunk - 流式响应的数据块
|
||||||
|
*/
|
||||||
function handleDataChunk(chunk: AnyObject) {
|
function handleDataChunk(chunk: AnyObject) {
|
||||||
try {
|
try {
|
||||||
// 安全获取 delta 和 content
|
// 安全获取 delta 和 content
|
||||||
@@ -170,34 +176,130 @@ function handleDataChunk(chunk: AnyObject) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 封装错误处理逻辑
|
/**
|
||||||
|
* 处理错误信息
|
||||||
|
* @param {any} err - 错误对象
|
||||||
|
*/
|
||||||
function handleError(err: any) {
|
function handleError(err: any) {
|
||||||
console.error('Fetch error:', err);
|
console.error('Fetch error:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息并处理流式响应
|
||||||
|
* 支持发送文本、图片和文件
|
||||||
|
* @param {string} chatContent - 用户输入的文本内容
|
||||||
|
*/
|
||||||
async function startSSE(chatContent: string) {
|
async function startSSE(chatContent: string) {
|
||||||
if (isSending.value)
|
if (isSending.value)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// 检查是否有未上传完成的文件
|
||||||
|
const hasUnuploadedFiles = filesStore.filesList.some(f => !f.isUploaded);
|
||||||
|
if (hasUnuploadedFiles) {
|
||||||
|
ElMessage.warning('文件正在上传中,请稍候...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
isSending.value = true;
|
isSending.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 清空输入框
|
// 清空输入框
|
||||||
inputValue.value = '';
|
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);
|
addMessage('', false);
|
||||||
|
|
||||||
|
// 立即清空文件列表(不要等到响应完成)
|
||||||
|
filesStore.clearFilesList();
|
||||||
|
|
||||||
// 这里有必要调用一下 BubbleList 组件的滚动到底部 手动触发 自动滚动
|
// 这里有必要调用一下 BubbleList 组件的滚动到底部 手动触发 自动滚动
|
||||||
bubbleListRef.value?.scrollToBottom();
|
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 处理流式响应
|
||||||
for await (const chunk of stream({
|
for await (const chunk of stream({
|
||||||
messages: bubbleItems.value.slice(0, -1).slice(-6).map((item: MessageItem) => ({
|
messages: messagesContent,
|
||||||
role: item.role,
|
|
||||||
content: (item.role === 'ai' || item.role === 'assistant') && item.content.length > 10000
|
|
||||||
? `${item.content.substring(0, 10000)}...(内容过长,已省略)`
|
|
||||||
: item.content,
|
|
||||||
})),
|
|
||||||
sessionId: route.params?.id !== 'not_login' ? String(route.params?.id) : 'not_login',
|
sessionId: route.params?.id !== 'not_login' ? String(route.params?.id) : 'not_login',
|
||||||
stream: true,
|
stream: true,
|
||||||
userId: userStore.userInfo?.userId,
|
userId: userStore.userInfo?.userId,
|
||||||
@@ -227,10 +329,18 @@ async function startSSE(chatContent: string) {
|
|||||||
latest.thinkingStatus = 'end';
|
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() {
|
async function cancelSSE() {
|
||||||
try {
|
try {
|
||||||
cancel(); // 直接调用,无需参数
|
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 i = bubbleItems.value.length;
|
||||||
const obj: MessageItem = {
|
const obj: MessageItem = {
|
||||||
key: i,
|
key: i,
|
||||||
@@ -267,14 +383,26 @@ function addMessage(message: string, isUser: boolean) {
|
|||||||
thinkingStatus: 'start',
|
thinkingStatus: 'start',
|
||||||
thinlCollapse: false,
|
thinlCollapse: false,
|
||||||
noStyle: !isUser,
|
noStyle: !isUser,
|
||||||
|
images: images && images.length > 0 ? images : undefined,
|
||||||
|
files: files && files.length > 0 ? files : undefined,
|
||||||
};
|
};
|
||||||
bubbleItems.value.push(obj);
|
bubbleItems.value.push(obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 展开收起 事件展示
|
/**
|
||||||
|
* 处理思考链展开/收起状态变化
|
||||||
|
* @param {Object} payload - 状态变化的载荷
|
||||||
|
* @param {boolean} payload.value - 展开/收起状态
|
||||||
|
* @param {ThinkingStatus} payload.status - 思考状态
|
||||||
|
*/
|
||||||
function handleChange(payload: { value: boolean; status: ThinkingStatus }) {
|
function handleChange(payload: { value: boolean; status: ThinkingStatus }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件卡片
|
||||||
|
* @param {FilesCardProps} _item - 文件卡片项(未使用)
|
||||||
|
* @param {number} index - 要删除的文件索引
|
||||||
|
*/
|
||||||
function handleDeleteCard(_item: FilesCardProps, index: number) {
|
function handleDeleteCard(_item: FilesCardProps, index: number) {
|
||||||
filesStore.deleteFileByIndex(index);
|
filesStore.deleteFileByIndex(index);
|
||||||
}
|
}
|
||||||
@@ -295,18 +423,30 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 复制
|
/**
|
||||||
|
* 复制消息内容到剪贴板
|
||||||
|
* @param {any} item - 消息项
|
||||||
|
*/
|
||||||
function copy(item: any) {
|
function copy(item: any) {
|
||||||
navigator.clipboard.writeText(item.content || '')
|
navigator.clipboard.writeText(item.content || '')
|
||||||
.then(() => ElMessage.success('已复制到剪贴板'))
|
.then(() => ElMessage.success('已复制到剪贴板'))
|
||||||
.catch(() => ElMessage.error('复制失败'));
|
.catch(() => ElMessage.error('复制失败'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片预览
|
||||||
|
* 在新窗口中打开图片
|
||||||
|
* @param {string} url - 图片 URL
|
||||||
|
*/
|
||||||
|
function handleImagePreview(url: string) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chat-with-id-container">
|
<div class="chat-with-id-container">
|
||||||
<div class="chat-warp">
|
<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 }">
|
<template #header="{ item }">
|
||||||
<Thinking
|
<Thinking
|
||||||
v-if="item.reasoning_content" v-model="item.thinlCollapse" :content="item.reasoning_content"
|
v-if="item.reasoning_content" v-model="item.thinlCollapse" :content="item.reasoning_content"
|
||||||
@@ -316,10 +456,37 @@ function copy(item: any) {
|
|||||||
<!-- 自定义气泡内容 -->
|
<!-- 自定义气泡内容 -->
|
||||||
<template #content="{ item }">
|
<template #content="{ item }">
|
||||||
<!-- chat 内容走 markdown -->
|
<!-- 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" />
|
<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 内容 纯文本 -->
|
<!-- user 内容 纯文本 + 图片 + 文件 -->
|
||||||
<div v-if="item.content && item.role === 'user'" class="user-content">
|
<div v-if="item.role === 'user'" class="user-content-wrapper">
|
||||||
{{ item.content }}
|
<!-- 图片列表 -->
|
||||||
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -336,7 +503,7 @@ function copy(item: any) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BubbleList>
|
</ChatMessageList>
|
||||||
|
|
||||||
<Sender
|
<Sender
|
||||||
ref="senderRef" v-model="inputValue" class="chat-defaul-sender" data-tour="chat-sender" :auto-size="{
|
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>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
|
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
|
||||||
<!-- <FilesSelect /> -->
|
<FilesSelect />
|
||||||
<ModelSelect />
|
<ModelSelect />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -410,10 +577,10 @@ function copy(item: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
:deep() {
|
:deep() {
|
||||||
.el-bubble-list {
|
.chat-message-list {
|
||||||
padding-top: 24px;
|
padding-top: 24px;
|
||||||
}
|
}
|
||||||
.el-bubble {
|
.chat-message-item {
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
padding-bottom: 24px;
|
padding-bottom: 24px;
|
||||||
}
|
}
|
||||||
@@ -421,6 +588,57 @@ function copy(item: any) {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 12px;
|
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 {
|
.user-content {
|
||||||
// 换行
|
// 换行
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|||||||
175
Yi.Ai.Vue3/src/pages/chat/layouts/chatWithId/上传文件与图片需求.text
Normal file
175
Yi.Ai.Vue3/src/pages/chat/layouts/chatWithId/上传文件与图片需求.text
Normal file
File diff suppressed because one or more lines are too long
1105
Yi.Ai.Vue3/src/pages/modelLibrary/index.vue
Normal file
1105
Yi.Ai.Vue3/src/pages/modelLibrary/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -188,8 +188,8 @@ function contactCustomerService() {
|
|||||||
|
|
||||||
<!-- 更多信息提示 -->
|
<!-- 更多信息提示 -->
|
||||||
<div class="mb-6 text-gray-600 text-sm">
|
<div class="mb-6 text-gray-600 text-sm">
|
||||||
更多订单信息和会员详情<br>请前往 <strong>用户中心 → 充值记录</strong> 查看。<br>
|
更多订单信息和会员详情<br>请前往 <strong>控制台 → 充值记录</strong> 查看。<br>
|
||||||
用户中心在首页右上角个人头像点击下拉菜单。
|
控制台在首页右上角个人头像点击下拉菜单。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 重新登录提示 -->
|
<!-- 重新登录提示 -->
|
||||||
|
|||||||
@@ -37,19 +37,30 @@ export const layoutRouter: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/pages/products/index.vue'),
|
component: () => import('@/pages/products/index.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '产品页面',
|
title: '产品页面',
|
||||||
keepAlive: true, // 如果需要缓存
|
keepAlive: 0,
|
||||||
isDefaultChat: false, // 根据实际情况设置
|
isDefaultChat: false,
|
||||||
layout: 'blankPage', // 如果需要自定义布局
|
layout: 'blankPage',
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/model-library',
|
||||||
|
name: 'modelLibrary',
|
||||||
|
component: () => import('@/pages/modelLibrary/index.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '模型库',
|
||||||
|
keepAlive: 0,
|
||||||
|
isDefaultChat: false,
|
||||||
|
layout: 'blankPage',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/pay-result',
|
path: '/pay-result',
|
||||||
name: 'payResult',
|
name: 'payResult',
|
||||||
component: () => import('@/pages/payResult/index.vue'),
|
component: () => import('@/pages/payResult/index.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '支付结果',
|
title: '支付结果',
|
||||||
keepAlive: true, // 如果需要缓存
|
keepAlive: 0, // 如果需要缓存
|
||||||
isDefaultChat: false, // 根据实际情况设置
|
isDefaultChat: false, // 根据实际情况设置
|
||||||
layout: 'blankPage', // 如果需要自定义布局
|
layout: 'blankPage', // 如果需要自定义布局
|
||||||
},
|
},
|
||||||
@@ -77,6 +88,7 @@ export const layoutRouter: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// staticRouter[静态路由] 预留
|
// staticRouter[静态路由] 预留
|
||||||
|
|||||||
@@ -17,17 +17,101 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
// 会议ID对应-聊天记录 map对象
|
// 会议ID对应-聊天记录 map对象
|
||||||
const chatMap = ref<Record<string, ChatMessageVo[]>>({});
|
const chatMap = ref<Record<string, ChatMessageVo[]>>({});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析消息内容,提取文本、图片和文件信息
|
||||||
|
* @param content - 消息内容,可能是字符串或数组格式的JSON字符串
|
||||||
|
* @returns 解析后的文本内容、图片列表和文件列表
|
||||||
|
*/
|
||||||
|
function parseMessageContent(content: string | any): {
|
||||||
|
text: string;
|
||||||
|
images: Array<{ url: string; name?: string }>;
|
||||||
|
files: Array<{ name: string; size: number }>;
|
||||||
|
} {
|
||||||
|
let text = '';
|
||||||
|
const images: Array<{ url: string; name?: string }> = [];
|
||||||
|
const files: Array<{ name: string; size: number }> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 如果 content 是字符串,尝试解析为 JSON
|
||||||
|
let contentArray: any;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
// 尝试解析 JSON 数组格式
|
||||||
|
if (content.trim().startsWith('[')) {
|
||||||
|
contentArray = JSON.parse(content);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 普通文本
|
||||||
|
text = content;
|
||||||
|
return { text, images, files };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
contentArray = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不是数组,直接返回
|
||||||
|
if (!Array.isArray(contentArray)) {
|
||||||
|
text = String(content);
|
||||||
|
return { text, images, files };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历数组,提取文本和图片
|
||||||
|
for (const item of contentArray) {
|
||||||
|
if (item.type === 'text') {
|
||||||
|
text += item.text || '';
|
||||||
|
}
|
||||||
|
else if (item.type === 'image_url') {
|
||||||
|
if (item.image_url?.url) {
|
||||||
|
images.push({
|
||||||
|
url: item.image_url.url,
|
||||||
|
name: item.image_url.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从文本中提取文件信息(如果有 ATTACHMENT_FILE 标签)
|
||||||
|
const fileMatches = text.matchAll(/<ATTACHMENT_FILE>[\s\S]*?<FILE_NAME>(.*?)<\/FILE_NAME>[\s\S]*?<\/ATTACHMENT_FILE>/g);
|
||||||
|
for (const match of fileMatches) {
|
||||||
|
const fileName = match[1];
|
||||||
|
files.push({
|
||||||
|
name: fileName,
|
||||||
|
size: 0, // 从历史记录中无法获取文件大小
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从文本中移除 ATTACHMENT_FILE 标签及其内容,只保留文件卡片显示
|
||||||
|
text = text.replace(/<ATTACHMENT_FILE>[\s\S]*?<\/ATTACHMENT_FILE>/g, '').trim();
|
||||||
|
|
||||||
|
return { text, images, files };
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('解析消息内容失败:', error);
|
||||||
|
// 解析失败,返回原始内容
|
||||||
|
return {
|
||||||
|
text: String(content),
|
||||||
|
images: [],
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const setChatMap = (id: string, data: ChatMessageVo[]) => {
|
const setChatMap = (id: string, data: ChatMessageVo[]) => {
|
||||||
chatMap.value[id] = data?.map((item: ChatMessageVo) => {
|
chatMap.value[id] = data?.map((item: ChatMessageVo) => {
|
||||||
const isUser = item.role === 'user';
|
const isUser = item.role === 'user';
|
||||||
const thinkContent = extractThkContent(item.content as string);
|
|
||||||
|
// 解析消息内容
|
||||||
|
const { text, images, files } = parseMessageContent(item.content as string);
|
||||||
|
|
||||||
|
// 处理思考内容
|
||||||
|
const thinkContent = extractThkContent(text);
|
||||||
|
const finalContent = extractThkContentAfter(text);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
key: item.id,
|
key: item.id,
|
||||||
placement: isUser ? 'end' : 'start',
|
placement: isUser ? 'end' : 'start',
|
||||||
isMarkdown: !isUser,
|
isMarkdown: !isUser,
|
||||||
// variant: 'shadow',
|
|
||||||
// shape: 'corner',
|
|
||||||
avatar: isUser
|
avatar: isUser
|
||||||
? getUserProfilePicture()
|
? getUserProfilePicture()
|
||||||
: systemProfilePicture,
|
: systemProfilePicture,
|
||||||
@@ -35,8 +119,11 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
typing: false,
|
typing: false,
|
||||||
reasoning_content: thinkContent,
|
reasoning_content: thinkContent,
|
||||||
thinkingStatus: 'end',
|
thinkingStatus: 'end',
|
||||||
content: extractThkContentAfter(item.content as string),
|
content: finalContent,
|
||||||
thinlCollapse: false,
|
thinlCollapse: false,
|
||||||
|
// 保留图片和文件信息(优先使用解析出来的,如果没有则使用原有的)
|
||||||
|
images: images.length > 0 ? images : item.images,
|
||||||
|
files: files.length > 0 ? files : item.files,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,21 @@ import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
|
|||||||
// 对话聊天的文件上传列表
|
// 对话聊天的文件上传列表
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
|
export interface FileItem extends FilesCardProps {
|
||||||
|
file: File;
|
||||||
|
fileId?: string; // 上传后返回的文件ID
|
||||||
|
isUploaded?: boolean; // 是否已上传
|
||||||
|
uploadProgress?: number; // 上传进度
|
||||||
|
base64?: string; // 图片的base64编码
|
||||||
|
fileContent?: string; // 文本文件的内容
|
||||||
|
fileType?: 'image' | 'text'; // 文件类型
|
||||||
|
}
|
||||||
|
|
||||||
export const useFilesStore = defineStore('files', () => {
|
export const useFilesStore = defineStore('files', () => {
|
||||||
const filesList = ref<FilesCardProps & { file: File }[]>([]);
|
const filesList = ref<FileItem[]>([]);
|
||||||
|
|
||||||
// 设置文件列表
|
// 设置文件列表
|
||||||
const setFilesList = (list: FilesCardProps & { file: File }[]) => {
|
const setFilesList = (list: FileItem[]) => {
|
||||||
filesList.value = list;
|
filesList.value = list;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -15,9 +25,24 @@ export const useFilesStore = defineStore('files', () => {
|
|||||||
filesList.value.splice(index, 1);
|
filesList.value.splice(index, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 更新文件上传状态
|
||||||
|
const updateFileUploadStatus = (index: number, fileId: string) => {
|
||||||
|
if (filesList.value[index]) {
|
||||||
|
filesList.value[index].fileId = fileId;
|
||||||
|
filesList.value[index].isUploaded = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清空文件列表
|
||||||
|
const clearFilesList = () => {
|
||||||
|
filesList.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filesList,
|
filesList,
|
||||||
setFilesList,
|
setFilesList,
|
||||||
deleteFileByIndex,
|
deleteFileByIndex,
|
||||||
|
updateFileUploadStatus,
|
||||||
|
clearFilesList,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {useUserStore} from '@/stores/index.js';
|
import { useUserStore } from '@/stores/index.js';
|
||||||
|
|
||||||
// 判断是否是 VIP 用户
|
// 判断是否是 VIP 用户
|
||||||
export function isUserVip(): boolean {
|
export function isUserVip(): boolean {
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
return userStore.userInfo.isVip;
|
return userStore?.userInfo?.isVip;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户头像
|
// 用户头像
|
||||||
|
|||||||
705
Yi.Ai.Vue3/src/vue-element-plus-y/assets/mock.ts
Normal file
705
Yi.Ai.Vue3/src/vue-element-plus-y/assets/mock.ts
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
import type { BubbleProps } from '@components/Bubble/types';
|
||||||
|
import type { BubbleListProps } from '@components/BubbleList/types';
|
||||||
|
import type { FilesType } from '@components/FilesCard/types';
|
||||||
|
|
||||||
|
import type { ThinkingStatus } from '@components/Thinking/types';
|
||||||
|
|
||||||
|
// 头像1
|
||||||
|
export const avatar1: string =
|
||||||
|
'https://avatars.githubusercontent.com/u/76239030?v=4';
|
||||||
|
|
||||||
|
// 头像2
|
||||||
|
export const avatar2: string =
|
||||||
|
'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png';
|
||||||
|
|
||||||
|
// md 普通内容
|
||||||
|
export const mdContent = `
|
||||||
|
### 行内公式
|
||||||
|
1. 欧拉公式:$e^{i\\pi} + 1 = 0$
|
||||||
|
2. 二次方程求根公式:$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$
|
||||||
|
3. 向量点积:$\\vec{a} \\cdot \\vec{b} = a_x b_x + a_y b_y + a_z b_z$
|
||||||
|
### []包裹公式
|
||||||
|
\\[ e^{i\\pi} + 1 = 0 \\]
|
||||||
|
|
||||||
|
\\[\\boxed{boxed包裹}\\]
|
||||||
|
|
||||||
|
### 块级公式
|
||||||
|
1. 傅里叶变换:
|
||||||
|
$$
|
||||||
|
F(\\omega) = \\int_{-\\infty}^{\\infty} f(t) e^{-i\\omega t} dt
|
||||||
|
$$
|
||||||
|
|
||||||
|
2. 矩阵乘法:
|
||||||
|
$$
|
||||||
|
\\begin{bmatrix}
|
||||||
|
a & b \\\\
|
||||||
|
c & d
|
||||||
|
\\end{bmatrix}
|
||||||
|
\\begin{bmatrix}
|
||||||
|
x \\\\
|
||||||
|
y
|
||||||
|
\\end{bmatrix}
|
||||||
|
=
|
||||||
|
\\begin{bmatrix}
|
||||||
|
ax + by \\\\
|
||||||
|
cx + dy
|
||||||
|
\\end{bmatrix}
|
||||||
|
$$
|
||||||
|
|
||||||
|
3. 泰勒级数展开:
|
||||||
|
$$
|
||||||
|
f(x) = \\sum_{n=0}^{\\infty} \\frac{f^{(n)}(a)}{n!} (x - a)^n
|
||||||
|
$$
|
||||||
|
|
||||||
|
4. 拉普拉斯方程:
|
||||||
|
$$
|
||||||
|
\\nabla^2 u = \\frac{\\partial^2 u}{\\partial x^2} + \\frac{\\partial^2 u}{\\partial y^2} + \\frac{\\partial^2 u}{\\partial z^2} = 0
|
||||||
|
$$
|
||||||
|
|
||||||
|
5. 概率密度函数(正态分布):
|
||||||
|
$$
|
||||||
|
f(x) = \\frac{1}{\\sqrt{2\\pi\\sigma^2}} e^{-\\frac{(x-\\mu)^2}{2\\sigma^2}}
|
||||||
|
$$
|
||||||
|
|
||||||
|
# 标题
|
||||||
|
这是一个 Markdown 示例。
|
||||||
|
- 列表项 1
|
||||||
|
- 列表项 2
|
||||||
|
**粗体文本** 和 *斜体文本*
|
||||||
|
|
||||||
|
- [x] Add some task
|
||||||
|
- [ ] Do some task
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
// md 代码块高亮
|
||||||
|
export const highlightMdContent = `
|
||||||
|
#### 切换右侧的secureViewCode进行安全预览或者不启用安全预览模式下 会呈现不同的网页预览效果
|
||||||
|
##### 通过enableCodeLineNumber属性开启代码行号
|
||||||
|
\`\`\`html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>炫酷文字动效</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; overflow: hidden; }
|
||||||
|
canvas { display: block; }
|
||||||
|
.text-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: clamp(2rem, 8vw, 5rem);
|
||||||
|
margin: 0;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 0 10px rgba(0,0,0,0.3);
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeIn 3s forwards 0.5s;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
|
<div class="text-container">
|
||||||
|
<h1 id="main-text">AWESOME TEXT</h1>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const canvas = document.getElementById('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
const text = document.getElementById('main-text');
|
||||||
|
|
||||||
|
class Particle {
|
||||||
|
constructor() {
|
||||||
|
this.x = Math.random() * canvas.width;
|
||||||
|
this.y = Math.random() * canvas.height;
|
||||||
|
this.size = Math.random() * 3 + 1;
|
||||||
|
this.speedX = Math.random() * 3 - 1.5;
|
||||||
|
this.speedY = Math.random() * 3 - 1.5;
|
||||||
|
this.color = \`hsl(\${Math.random() * 360}, 70%, 60%)\`;
|
||||||
|
}
|
||||||
|
update() {
|
||||||
|
this.x += this.speedX;
|
||||||
|
this.y += this.speedY;
|
||||||
|
if (this.size > 0.2) this.size -= 0.01;
|
||||||
|
}
|
||||||
|
draw() {
|
||||||
|
ctx.fillStyle = this.color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let particles = [];
|
||||||
|
function init() {
|
||||||
|
particles = [];
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
particles.push(new Particle());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
for (let i = 0; i < particles.length; i++) {
|
||||||
|
particles[i].update();
|
||||||
|
particles[i].draw();
|
||||||
|
for (let j = i; j < particles.length; j++) {
|
||||||
|
const dx = particles[i].x - particles[j].x;
|
||||||
|
const dy = particles[i].y - particles[j].y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (distance < 100) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = \`rgba(255,255,255,\${0.1 - distance/1000})\`;
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.moveTo(particles[i].x, particles[i].y);
|
||||||
|
ctx.lineTo(particles[j].x, particles[j].y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
animate();
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自定义文字功能
|
||||||
|
text.addEventListener('click', () => {
|
||||||
|
const newText = prompt('输入新文字:', text.textContent);
|
||||||
|
if (newText) {
|
||||||
|
text.textContent = newText;
|
||||||
|
text.style.opacity = 0;
|
||||||
|
setTimeout(() => {
|
||||||
|
text.style.opacity = 1;
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
\`\`\`
|
||||||
|
\`\`\`html
|
||||||
|
<div class="product-card">
|
||||||
|
<div class="badge">新品</div>
|
||||||
|
<img src="https://picsum.photos/300/200?product" alt="产品图片">
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<h3>无线蓝牙耳机 Pro</h3>
|
||||||
|
<p class="description">主动降噪技术,30小时续航,IPX5防水等级</p>
|
||||||
|
|
||||||
|
<div class="rating">
|
||||||
|
<span>★★★★☆</span>
|
||||||
|
<span class="reviews">(124条评价)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="price-container">
|
||||||
|
<span class="price">¥499</span>
|
||||||
|
<span class="original-price">¥699</span>
|
||||||
|
<span class="discount">7折</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="cart-btn">加入购物车</button>
|
||||||
|
<button class="fav-btn">❤️</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<span>✓ 次日达</span>
|
||||||
|
<span>✓ 7天无理由</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.product-card {
|
||||||
|
width: 280px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
position: relative;
|
||||||
|
background: white;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
background: #ff6b6b;
|
||||||
|
color: white;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 10px 0;
|
||||||
|
color: #ffb300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviews {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ff4757;
|
||||||
|
}
|
||||||
|
|
||||||
|
.original-price {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount {
|
||||||
|
background: #fff200;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 16px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-btn {
|
||||||
|
flex: 1;
|
||||||
|
background: #5352ed;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-btn:hover {
|
||||||
|
background: #3742fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-btn {
|
||||||
|
width: 42px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-btn:hover {
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #2ed573;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
\`\`\`
|
||||||
|
###### 非\`commonMark\`语法,dom多个
|
||||||
|
<pre>
|
||||||
|
<code class="language-java">
|
||||||
|
public class HelloWorld {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("Hello, world!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
\`\`\`echarts
|
||||||
|
use codeXRender for echarts render
|
||||||
|
\`\`\`
|
||||||
|
### javascript
|
||||||
|
\`\`\`javascript
|
||||||
|
console.log('Hello, world!');
|
||||||
|
\`\`\`
|
||||||
|
### java
|
||||||
|
\`\`\`java
|
||||||
|
public class HelloWorld {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("Hello, world!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
\`\`\`typescript
|
||||||
|
import {
|
||||||
|
ArrowDownBold,
|
||||||
|
CopyDocument,
|
||||||
|
Moon,
|
||||||
|
Sunny
|
||||||
|
} from '@element-plus/icons-vue';
|
||||||
|
import { ElButton, ElSpace } from 'element-plus';
|
||||||
|
import { h } from 'vue';
|
||||||
|
|
||||||
|
/* ----------------------------------- 按钮组 ---------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 描述 language标签
|
||||||
|
* @date 2025-06-25 17:48:15
|
||||||
|
* @author tingfeng
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @param language
|
||||||
|
*/
|
||||||
|
export function languageEle(language: string) {
|
||||||
|
return h(
|
||||||
|
ElSpace,
|
||||||
|
{},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
// md 美人鱼图表
|
||||||
|
export const mermaidMdContent = `
|
||||||
|
|
||||||
|
### mermaid 饼状图
|
||||||
|
\`\`\`mermaid
|
||||||
|
pie
|
||||||
|
"传媒及文化相关" : 35
|
||||||
|
"广告与市场营销" : 8
|
||||||
|
"游戏开发" : 15
|
||||||
|
"影视动画与特效" : 12
|
||||||
|
"互联网产品设计" : 10
|
||||||
|
"VR/AR开发" : 5
|
||||||
|
"其他" : 15
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
// md 数学公式
|
||||||
|
export const mathMdContent = `
|
||||||
|
### mermaid 流程图
|
||||||
|
\`\`\`mermaid
|
||||||
|
graph LR
|
||||||
|
1 --> 2
|
||||||
|
2 --> 3
|
||||||
|
3 --> 4
|
||||||
|
2 --> 1
|
||||||
|
2-3 --> 1-3
|
||||||
|
\`\`\`
|
||||||
|
\`\`\`mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start[开始] --> Check[是否通过?]
|
||||||
|
Check -- 是 --> Pass[流程继续]
|
||||||
|
Check -- 否 --> Reject[流程结束]
|
||||||
|
\`\`\`
|
||||||
|
\`\`\`mermaid
|
||||||
|
flowchart TD
|
||||||
|
%% 前端专项四层结构
|
||||||
|
A["战略层
|
||||||
|
【提升用户体验】"]
|
||||||
|
--> B["架构层
|
||||||
|
【微前端方案选型】"]
|
||||||
|
--> C["框架层
|
||||||
|
【React+TS技术栈】"]
|
||||||
|
--> D["实现层
|
||||||
|
【组件库开发】"]
|
||||||
|
style A fill:#FFD700,stroke:#FFA500
|
||||||
|
style B fill:#87CEFA,stroke:#1E90FF
|
||||||
|
style C fill:#9370DB,stroke:#663399
|
||||||
|
style D fill:#FF6347,stroke:#CD5C5C
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
### mermaid 数学公式
|
||||||
|
\`\`\`mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant 1 as $$alpha$$
|
||||||
|
participant 2 as $$beta$$
|
||||||
|
1->>2: Solve: $$\sqrt{2+2}$$
|
||||||
|
2-->>1: Answer: $$2$$
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
`;
|
||||||
|
export const customAttrContent = `
|
||||||
|
<a href="https://element-plus-x.com/">element-plus-x</a>
|
||||||
|
<h1>标题1</h1>
|
||||||
|
<h2>标题2</h2>
|
||||||
|
`;
|
||||||
|
export type MessageItem = BubbleProps & {
|
||||||
|
key: number;
|
||||||
|
role: 'ai' | 'user' | 'system';
|
||||||
|
avatar: string;
|
||||||
|
thinkingStatus?: ThinkingStatus;
|
||||||
|
expanded?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// md 复杂图表
|
||||||
|
export const mermaidComplexMdContent = `
|
||||||
|
### Mermaid 渲染复杂图表案例
|
||||||
|
\`\`\`mermaid
|
||||||
|
graph LR
|
||||||
|
A[用户] -->|请求交互| B[前端应用]
|
||||||
|
B -->|API调用| C[API网关]
|
||||||
|
C -->|认证请求| D[认证服务]
|
||||||
|
C -->|业务请求| E[业务服务]
|
||||||
|
E -->|数据读写| F[数据库]
|
||||||
|
E -->|缓存操作| G[缓存服务]
|
||||||
|
E -->|消息发布| H[消息队列]
|
||||||
|
H -->|触发任务| I[后台任务]
|
||||||
|
|
||||||
|
subgraph "微服务集群"
|
||||||
|
D[认证服务]
|
||||||
|
E[业务服务]
|
||||||
|
I[后台任务]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "数据持久层"
|
||||||
|
F[数据库]
|
||||||
|
G[缓存服务]
|
||||||
|
end
|
||||||
|
|
||||||
|
`;
|
||||||
|
// animateTestMdContent 为动画测试的 markdown 内容,包含唐代王勃《滕王阁序》并做了格式优化
|
||||||
|
// animateTestMdContent 为动画测试的 markdown 内容,包含唐代王勃《滕王阁序》并做了格式优化(部分内容采用表格样式展示)
|
||||||
|
export const animateTestMdContent = `
|
||||||
|
### 唐代:王勃《滕王阁序》
|
||||||
|
|
||||||
|
| 章节 | 内容 |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 开篇 | 豫章故郡,洪都新府。<br>星分翼轸,地接衡庐。<br>襟三江而带五湖,控蛮荆而引瓯越。<br>物华天宝,龙光射牛斗之墟;人杰地灵,徐孺下陈蕃之榻。<br>雄州雾列,俊采星驰。台隍枕夷夏之交,宾主尽东南之美。<br>都督阎公之雅望,棨戟遥临;宇文新州之懿范,襜帷暂驻。<br>十旬休假,胜友如云;千里逢迎,高朋满座。<br>腾蛟起凤,孟学士之词宗;紫电青霜,王将军之武库。<br>家君作宰,路出名区;童子何知,躬逢胜饯。 |
|
||||||
|
| 九月三秋 | 时维九月,序属三秋。<br>潦水尽而寒潭清,烟光凝而暮山紫。<br>俨骖騑于上路,访风景于崇阿。<br>临帝子之长洲,得天人之旧馆。<br>层峦耸翠,上出重霄;飞阁流丹,下临无地。<br>鹤汀凫渚,穷岛屿之萦回;桂殿兰宫,即冈峦之体势。 |
|
||||||
|
| 山川景色 | 披绣闼,俯雕甍,山原旷其盈视,川泽纡其骇瞩。<br>闾阎扑地,钟鸣鼎食之家;舸舰迷津,青雀黄龙之舳。<br>云销雨霁,彩彻区明。落霞与孤鹜齐飞,秋水共长天一色。<br>渔舟唱晚,响穷彭蠡之滨,雁阵惊寒,声断衡阳之浦。 |
|
||||||
|
| 兴致抒怀 | 遥襟甫畅,逸兴遄飞。爽籁发而清风生,纤歌凝而白云遏。<br>睢园绿竹,气凌彭泽之樽;邺水朱华,光照临川之笔。<br>四美具,二难并。穷睇眄于中天,极娱游于暇日。<br>天高地迥,觉宇宙之无穷;兴尽悲来,识盈虚之有数。<br>望长安于日下,目吴会于云间。地势极而南溟深,天柱高而北辰远。<br>关山难越,谁悲失路之人;萍水相逢,尽是他乡之客。<br>怀帝阍而不见,奉宣室以何年? |
|
||||||
|
| 感慨身世 | 嗟乎!时运不齐,命途多舛。<br>冯唐易老,李广难封。<br>屈贾谊于长沙,非无圣主;窜梁鸿于海曲,岂乏明时?<br>所赖君子见机,达人知命。<br>老当益壮,宁移白首之心?<br>穷且益坚,不坠青云之志。<br>酌贪泉而觉爽,处涸辙以犹欢。<br>北海虽赊,扶摇可接;东隅已逝,桑榆非晚。<br>孟尝高洁,空余报国之情;阮籍猖狂,岂效穷途之哭! |
|
||||||
|
| 自述 | 勃,三尺微命,一介书生。<br>无路请缨,等终军之弱冠;有怀投笔,慕宗悫之长风。<br>舍簪笏于百龄,奉晨昏于万里。<br>非谢家之宝树,接孟氏之芳邻。<br>他日趋庭,叨陪鲤对;今兹捧袂,喜托龙门。<br>杨意不逢,抚凌云而自惜;钟期既遇,奏流水以何惭? |
|
||||||
|
| 结尾 | 呜呼!胜地不常,盛筵难再;兰亭已矣,梓泽丘墟。<br>临别赠言,幸承恩于伟饯;登高作赋,是所望于群公。<br>敢竭鄙怀,恭疏短引;一言均赋,四韵俱成。<br>请洒潘江,各倾陆海云尔。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 滕王阁诗
|
||||||
|
|
||||||
|
> 滕王高阁临江渚,佩玉鸣鸾罢歌舞。
|
||||||
|
> 画栋朝飞南浦云,珠帘暮卷西山雨。
|
||||||
|
> 闲云潭影日悠悠,物换星移几度秋。
|
||||||
|
> 阁中帝子今何在?槛外长江空自流。
|
||||||
|
`;
|
||||||
|
export const messageArr: BubbleListProps<MessageItem>['list'] = [
|
||||||
|
{
|
||||||
|
key: 1,
|
||||||
|
role: 'ai',
|
||||||
|
placement: 'start',
|
||||||
|
content: '欢迎使用 Element Plus X .'.repeat(5),
|
||||||
|
loading: true,
|
||||||
|
shape: 'corner',
|
||||||
|
variant: 'filled',
|
||||||
|
isMarkdown: false,
|
||||||
|
typing: { step: 2, suffix: '💗' },
|
||||||
|
avatar: avatar2,
|
||||||
|
avatarSize: '32px'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 2,
|
||||||
|
role: 'user',
|
||||||
|
placement: 'end',
|
||||||
|
content: '这是用户的消息',
|
||||||
|
loading: true,
|
||||||
|
shape: 'corner',
|
||||||
|
variant: 'outlined',
|
||||||
|
isMarkdown: false,
|
||||||
|
avatar: avatar1,
|
||||||
|
avatarSize: '32px'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 3,
|
||||||
|
role: 'ai',
|
||||||
|
placement: 'start',
|
||||||
|
content: '欢迎使用 Element Plus X .'.repeat(5),
|
||||||
|
loading: true,
|
||||||
|
shape: 'corner',
|
||||||
|
variant: 'filled',
|
||||||
|
isMarkdown: false,
|
||||||
|
typing: { step: 2, suffix: '💗' },
|
||||||
|
avatar: avatar2,
|
||||||
|
avatarSize: '32px'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 4,
|
||||||
|
role: 'user',
|
||||||
|
placement: 'end',
|
||||||
|
content: '这是用户的消息',
|
||||||
|
loading: true,
|
||||||
|
shape: 'corner',
|
||||||
|
variant: 'outlined',
|
||||||
|
isMarkdown: false,
|
||||||
|
avatar: avatar1,
|
||||||
|
avatarSize: '32px'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 5,
|
||||||
|
role: 'ai',
|
||||||
|
placement: 'start',
|
||||||
|
content: '欢迎使用 Element Plus X .'.repeat(5),
|
||||||
|
loading: true,
|
||||||
|
shape: 'corner',
|
||||||
|
variant: 'filled',
|
||||||
|
isMarkdown: false,
|
||||||
|
typing: { step: 2, suffix: '💗' },
|
||||||
|
avatar: avatar2,
|
||||||
|
avatarSize: '32px'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 6,
|
||||||
|
role: 'user',
|
||||||
|
placement: 'end',
|
||||||
|
content: '这是用户的消息',
|
||||||
|
loading: true,
|
||||||
|
shape: 'corner',
|
||||||
|
variant: 'outlined',
|
||||||
|
isMarkdown: false,
|
||||||
|
avatar: avatar1,
|
||||||
|
avatarSize: '32px'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 7,
|
||||||
|
role: 'ai',
|
||||||
|
placement: 'start',
|
||||||
|
content: '欢迎使用 Element Plus X .'.repeat(5),
|
||||||
|
loading: true,
|
||||||
|
shape: 'corner',
|
||||||
|
variant: 'filled',
|
||||||
|
isMarkdown: false,
|
||||||
|
typing: { step: 2, suffix: '💗', isRequestEnd: true },
|
||||||
|
avatar: avatar2,
|
||||||
|
avatarSize: '32px'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 8,
|
||||||
|
role: 'user',
|
||||||
|
placement: 'end',
|
||||||
|
content: '这是用户的消息',
|
||||||
|
loading: true,
|
||||||
|
shape: 'corner',
|
||||||
|
variant: 'outlined',
|
||||||
|
isMarkdown: false,
|
||||||
|
avatar: avatar1,
|
||||||
|
avatarSize: '32px'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 模拟自定义文件卡片数据
|
||||||
|
// 内置样式
|
||||||
|
export const colorMap: Record<FilesType, string> = {
|
||||||
|
word: '#0078D4',
|
||||||
|
excel: '#00C851',
|
||||||
|
ppt: '#FF5722',
|
||||||
|
pdf: '#E53935',
|
||||||
|
txt: '#424242',
|
||||||
|
mark: '#6C6C6C',
|
||||||
|
image: '#FF80AB',
|
||||||
|
audio: '#FF7878',
|
||||||
|
video: '#8B72F7',
|
||||||
|
three: '#29B6F6',
|
||||||
|
code: '#00008B',
|
||||||
|
database: '#FF9800',
|
||||||
|
link: '#2962FF',
|
||||||
|
zip: '#673AB7',
|
||||||
|
file: '#FFC757',
|
||||||
|
unknown: '#6E9DA4'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自己定义文件颜色
|
||||||
|
export const colorMap1: Record<FilesType, string> = {
|
||||||
|
word: '#5E74A8',
|
||||||
|
excel: '#4A6B4A',
|
||||||
|
ppt: '#C27C40',
|
||||||
|
pdf: '#5A6976',
|
||||||
|
txt: '#D4C58C',
|
||||||
|
mark: '#FFA500',
|
||||||
|
image: '#8E7CC3',
|
||||||
|
audio: '#A67B5B',
|
||||||
|
video: '#4A5568',
|
||||||
|
three: '#5F9E86',
|
||||||
|
code: '#4B636E',
|
||||||
|
database: '#4A5A6B',
|
||||||
|
link: '#5D7CBA',
|
||||||
|
zip: '#8B5E3C',
|
||||||
|
file: '#AAB2BF',
|
||||||
|
unknown: '#888888'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自己定义文件颜色1
|
||||||
|
export const colorMap2: Record<FilesType, string> = {
|
||||||
|
word: '#0078D4',
|
||||||
|
excel: '#4CB050',
|
||||||
|
ppt: '#FF9933',
|
||||||
|
pdf: '#E81123',
|
||||||
|
txt: '#666666',
|
||||||
|
mark: '#FFA500',
|
||||||
|
image: '#B490F3',
|
||||||
|
audio: '#00B2EE',
|
||||||
|
video: '#2EC4B6',
|
||||||
|
three: '#00C8FF',
|
||||||
|
code: '#00589F',
|
||||||
|
database: '#F5A623',
|
||||||
|
link: '#007BFF',
|
||||||
|
zip: '#888888',
|
||||||
|
file: '#F0D9B5',
|
||||||
|
unknown: '#D8D8D8'
|
||||||
|
};
|
||||||
17
Yi.Ai.Vue3/src/vue-element-plus-y/components.ts
Normal file
17
Yi.Ai.Vue3/src/vue-element-plus-y/components.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Auto-Element-Plus-X by auto-export-all-components script
|
||||||
|
export { default as Attachments } from './components/Attachments/index.vue';
|
||||||
|
export { default as Bubble } from './components/Bubble/index.vue';
|
||||||
|
export { default as BubbleList } from './components/BubbleList/index.vue';
|
||||||
|
export { default as ConfigProvider } from './components/ConfigProvider/index.vue';
|
||||||
|
export { default as Conversations } from './components/Conversations/index.vue';
|
||||||
|
export { default as EditorSender } from './components/EditorSender/index.vue';
|
||||||
|
export { default as FilesCard } from './components/FilesCard/index.vue';
|
||||||
|
export { default as MentionSender } from './components/MentionSender/index.vue';
|
||||||
|
export { default as Prompts } from './components/Prompts/index.vue';
|
||||||
|
export { default as Sender } from './components/Sender/index.vue';
|
||||||
|
export { default as Thinking } from './components/Thinking/index.vue';
|
||||||
|
export { default as ThoughtChain } from './components/ThoughtChain/index.vue';
|
||||||
|
export { default as Typewriter } from './components/Typewriter/index.vue';
|
||||||
|
export { default as Welcome } from './components/Welcome/index.vue';
|
||||||
|
export { default as XMarkdown } from './components/XMarkdown/index.vue';
|
||||||
|
export { default as XMarkdownAsync } from './components/XMarkdownAsync/index.vue';
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { MarkdownProps } from '../XMarkdownCore/shared/types';
|
||||||
|
import { useShiki } from '@components/XMarkdownCore/hooks/useShiki';
|
||||||
|
import { MarkdownRenderer } from '../XMarkdownCore';
|
||||||
|
import { useMarkdownContext } from '../XMarkdownCore/components/MarkdownProvider';
|
||||||
|
import { DEFAULT_PROPS } from '../XMarkdownCore/shared/constants';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<MarkdownProps>(), DEFAULT_PROPS);
|
||||||
|
|
||||||
|
const slots = useSlots();
|
||||||
|
const customComponents = useMarkdownContext();
|
||||||
|
const colorReplacementsComputed = computed(() => {
|
||||||
|
return props.colorReplacements;
|
||||||
|
});
|
||||||
|
const needViewCodeBtnComputed = computed(() => {
|
||||||
|
return props.needViewCodeBtn;
|
||||||
|
});
|
||||||
|
useShiki();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="elx-xmarkdown-container">
|
||||||
|
<MarkdownRenderer
|
||||||
|
v-bind="props"
|
||||||
|
:color-replacements="colorReplacementsComputed"
|
||||||
|
:need-view-code-btn="needViewCodeBtnComputed"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="(slot, name) in customComponents"
|
||||||
|
:key="name"
|
||||||
|
#[name]="slotProps"
|
||||||
|
>
|
||||||
|
<component :is="slot" v-bind="slotProps" />
|
||||||
|
</template>
|
||||||
|
<template v-for="(_, name) in slots" :key="name" #[name]="slotProps">
|
||||||
|
<slot :name="name" v-bind="slotProps" />
|
||||||
|
</template>
|
||||||
|
</MarkdownRenderer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { MarkdownProps } from '../XMarkdownCore/shared/types';
|
||||||
|
import { useShiki } from '@components/XMarkdownCore/hooks/useShiki';
|
||||||
|
import { MarkdownRendererAsync } from '../XMarkdownCore';
|
||||||
|
import { useMarkdownContext } from '../XMarkdownCore/components/MarkdownProvider';
|
||||||
|
import { DEFAULT_PROPS } from '../XMarkdownCore/shared/constants';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<MarkdownProps>(), DEFAULT_PROPS);
|
||||||
|
|
||||||
|
const slots = useSlots();
|
||||||
|
const customComponents = useMarkdownContext();
|
||||||
|
const colorReplacementsComputed = computed(() => {
|
||||||
|
return props.colorReplacements;
|
||||||
|
});
|
||||||
|
const needViewCodeBtnComputed = computed(() => {
|
||||||
|
return props.needViewCodeBtn;
|
||||||
|
});
|
||||||
|
useShiki();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="elx-xmarkdown-container">
|
||||||
|
<MarkdownRendererAsync
|
||||||
|
v-bind="props"
|
||||||
|
:color-replacements="colorReplacementsComputed"
|
||||||
|
:need-view-code-btn="needViewCodeBtnComputed"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="(slot, name) in customComponents"
|
||||||
|
:key="name"
|
||||||
|
#[name]="slotProps"
|
||||||
|
>
|
||||||
|
<component :is="slot" v-bind="slotProps" />
|
||||||
|
</template>
|
||||||
|
<template v-for="(_, name) in slots" :key="name" #[name]="slotProps">
|
||||||
|
<slot :name="name" v-bind="slotProps" />
|
||||||
|
</template>
|
||||||
|
</MarkdownRendererAsync>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { defineComponent, h } from 'vue';
|
||||||
|
import {
|
||||||
|
MarkdownProvider,
|
||||||
|
useMarkdownContext
|
||||||
|
} from '../components/MarkdownProvider';
|
||||||
|
import { VueMarkdown, VueMarkdownAsync } from '../core';
|
||||||
|
import { useComponents } from '../hooks';
|
||||||
|
import { MARKDOWN_CORE_PROPS } from '../shared/constants';
|
||||||
|
|
||||||
|
const InnerRenderer = defineComponent({
|
||||||
|
name: 'InnerMarkdownRenderer',
|
||||||
|
setup(_, { slots }) {
|
||||||
|
const context = useMarkdownContext();
|
||||||
|
const components = useComponents();
|
||||||
|
return () =>
|
||||||
|
h(VueMarkdown, context.value as any, {
|
||||||
|
...components,
|
||||||
|
...slots
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const InnerRendererAsync = defineComponent({
|
||||||
|
name: 'InnerMarkdownRendererAsync',
|
||||||
|
setup(_, { slots }) {
|
||||||
|
const context: any = useMarkdownContext();
|
||||||
|
const components = useComponents();
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
h(VueMarkdownAsync, context.value, {
|
||||||
|
...components,
|
||||||
|
...slots
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const MarkdownRenderer = defineComponent({
|
||||||
|
name: 'MarkdownRenderer',
|
||||||
|
props: MARKDOWN_CORE_PROPS,
|
||||||
|
setup(props, { slots }) {
|
||||||
|
return () =>
|
||||||
|
h(MarkdownProvider, props, {
|
||||||
|
default: () => h(InnerRenderer, {}, slots)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const MarkdownRendererAsync = defineComponent({
|
||||||
|
name: 'MarkdownRendererAsync',
|
||||||
|
props: MARKDOWN_CORE_PROPS,
|
||||||
|
setup(props, { slots }) {
|
||||||
|
return () =>
|
||||||
|
h(MarkdownProvider, props, {
|
||||||
|
default: () => h(InnerRendererAsync, {}, slots)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { MarkdownRenderer, MarkdownRendererAsync };
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 使用
|
||||||
|
|
||||||
|
```vue
|
||||||
|
import {MarkdownRenderer,MarkdownRendererAsync} from '@/components/Markdown';
|
||||||
|
|
||||||
|
<template>
|
||||||
|
// 同步渲染
|
||||||
|
<MarkdownRenderer class="markdown-render" :markdown="content" />
|
||||||
|
|
||||||
|
// 异步渲染
|
||||||
|
<Suspense>
|
||||||
|
<MarkdownRendererAsync class="markdown-render" :markdown="content" />
|
||||||
|
</Suspense>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 属性
|
||||||
|
|
||||||
|
### customAttrs 自定义属性支持
|
||||||
|
|
||||||
|
通过 `customAttrs` 可以对 Markdown 渲染的节点动态添加自定义属性:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const customAttrs = {
|
||||||
|
heading: (node, { level }) => ({
|
||||||
|
class: ['heading', `heading-${level}`]
|
||||||
|
}),
|
||||||
|
a: node => ({
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 插槽
|
||||||
|
|
||||||
|
> 组件提供了多个插槽,可以自定义渲染,标签即为插槽,你可以接管任何插槽,自定义渲染逻辑。
|
||||||
|
|
||||||
|
**请注意:组件内部拦截了code标签的渲染,支持高亮代码块,mermaid图表等。如果你需要自定义渲染,可以接管code插槽。**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<header></header>
|
||||||
|
|
||||||
|
<MarkdownRenderer>
|
||||||
|
<template #heading="{ node, level }">
|
||||||
|
可自定义标题渲染
|
||||||
|
</template>
|
||||||
|
</MarkdownRenderer>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代码块渲染
|
||||||
|
|
||||||
|
组件内置了代码块渲染器,支持高亮代码块,mermaid图表等。
|
||||||
|
codeXSlot自定义代码块顶部
|
||||||
|
可通过 codeXRender 属性自定义代码块语言渲染器,如下可以自定义 echarts 渲染器:
|
||||||
|
|
||||||
|
```text
|
||||||
|
codeXRender: {
|
||||||
|
echarts: (props) => {
|
||||||
|
return h()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CopyDocument, Select } from '@element-plus/icons-vue';
|
||||||
|
import { ElButton } from 'element-plus';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
onCopy: () => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const copied = ref(false);
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (!copied.value) {
|
||||||
|
props.onCopy();
|
||||||
|
copied.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
copied.value = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElButton
|
||||||
|
class="shiki-header-button markdown-elxLanguage-header-button"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="copied ? Select : CopyDocument"
|
||||||
|
class="markdown-elxLanguage-header-button-text"
|
||||||
|
:class="[copied && 'copied']"
|
||||||
|
/>
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { GlobalShiki } from '@components/XMarkdownCore/hooks/useShiki';
|
||||||
|
import type { BundledLanguage } from 'shiki';
|
||||||
|
import type { ElxRunCodeProps } from '../RunCode/type';
|
||||||
|
import type { CodeBlockExpose } from './shiki-header';
|
||||||
|
import type { RawProps } from './types';
|
||||||
|
import {
|
||||||
|
transformerNotationDiff,
|
||||||
|
transformerNotationErrorLevel,
|
||||||
|
transformerNotationFocus,
|
||||||
|
transformerNotationHighlight,
|
||||||
|
transformerNotationWordHighlight
|
||||||
|
} from '@shikijs/transformers';
|
||||||
|
import { computed, h, reactive, ref, toValue, watch } from 'vue';
|
||||||
|
import HighLightCode from '../../components/HighLightCode/index.vue';
|
||||||
|
import { SHIKI_SUPPORT_LANGS, shikiThemeDefault } from '../../shared';
|
||||||
|
import { useMarkdownContext } from '../MarkdownProvider';
|
||||||
|
import RunCode from '../RunCode/index.vue';
|
||||||
|
import {
|
||||||
|
controlEle,
|
||||||
|
controlHasRunCodeEle,
|
||||||
|
copyCode,
|
||||||
|
isDark,
|
||||||
|
languageEle,
|
||||||
|
toggleExpand,
|
||||||
|
toggleTheme
|
||||||
|
} from './shiki-header';
|
||||||
|
import '../../style/shiki.scss';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
raw?: RawProps;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
raw: () => ({})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const context = useMarkdownContext();
|
||||||
|
const { codeXSlot, customAttrs, globalShiki } = toValue(context) || {};
|
||||||
|
const renderLines = ref<string[]>([]);
|
||||||
|
const preStyle = ref<any | null>(null);
|
||||||
|
const preClass = ref<string | null>(null);
|
||||||
|
const themes = computed(() => context?.value?.themes ?? shikiThemeDefault);
|
||||||
|
const colorReplacements = computed(() => context?.value?.colorReplacements);
|
||||||
|
const nowViewBtnShow = computed(() => context?.value?.needViewCodeBtn ?? false);
|
||||||
|
const viewCodeModalOptions = computed(
|
||||||
|
() => context?.value?.viewCodeModalOptions
|
||||||
|
);
|
||||||
|
const isExpand = ref(true);
|
||||||
|
const nowCodeLanguage = ref<BundledLanguage>();
|
||||||
|
const codeAttrs =
|
||||||
|
typeof customAttrs?.code === 'function'
|
||||||
|
? customAttrs.code(props.raw)
|
||||||
|
: customAttrs?.code || {};
|
||||||
|
const shikiTransformers = [
|
||||||
|
transformerNotationDiff(),
|
||||||
|
transformerNotationErrorLevel(),
|
||||||
|
transformerNotationFocus(),
|
||||||
|
transformerNotationHighlight(),
|
||||||
|
transformerNotationWordHighlight()
|
||||||
|
];
|
||||||
|
|
||||||
|
const { codeToHtml } = globalShiki as GlobalShiki;
|
||||||
|
// 生成高亮HTML
|
||||||
|
async function generateHtml() {
|
||||||
|
let { language = 'text', content = '' } = props.raw || {};
|
||||||
|
if (!(SHIKI_SUPPORT_LANGS as readonly string[]).includes(language)) {
|
||||||
|
language = 'text';
|
||||||
|
}
|
||||||
|
nowCodeLanguage.value = language as BundledLanguage;
|
||||||
|
const html = await codeToHtml(content.trim(), {
|
||||||
|
lang: language as BundledLanguage,
|
||||||
|
themes: themes.value,
|
||||||
|
colorReplacements: colorReplacements.value,
|
||||||
|
transformers: shikiTransformers
|
||||||
|
});
|
||||||
|
const parse = new DOMParser();
|
||||||
|
const doc = parse.parseFromString(html, 'text/html');
|
||||||
|
const preElement = doc.querySelector('pre');
|
||||||
|
preStyle.value = preElement?.getAttribute('style');
|
||||||
|
const preClassNames = preElement?.className;
|
||||||
|
preClass.value = preClassNames ?? '';
|
||||||
|
const codeElement = doc.querySelector('pre code');
|
||||||
|
if (codeElement) {
|
||||||
|
const lines = codeElement.querySelectorAll('.line'); // 获取所有代码行
|
||||||
|
renderLines.value = Array.from(lines).map(line => line.outerHTML); // 存储每行HTML
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.raw?.content,
|
||||||
|
async content => {
|
||||||
|
if (content) {
|
||||||
|
await generateHtml();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const runCodeOptions = reactive<ElxRunCodeProps>({
|
||||||
|
code: [],
|
||||||
|
content: '',
|
||||||
|
visible: false,
|
||||||
|
lang: '',
|
||||||
|
preClass: '',
|
||||||
|
preStyle: {}
|
||||||
|
});
|
||||||
|
function viewCode(renderLines: string[]) {
|
||||||
|
if (!renderLines?.length) return;
|
||||||
|
|
||||||
|
Object.assign(runCodeOptions, {
|
||||||
|
code: renderLines,
|
||||||
|
content: props.raw?.content ?? '',
|
||||||
|
lang: nowCodeLanguage.value || 'html',
|
||||||
|
preClass: preClass.value || 'pre-md',
|
||||||
|
preStyle: preStyle.value || {},
|
||||||
|
visible: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => renderLines.value,
|
||||||
|
val => {
|
||||||
|
if (runCodeOptions.visible) {
|
||||||
|
runCodeOptions.code = val;
|
||||||
|
runCodeOptions.content = props.raw.content ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 渲染插槽函数
|
||||||
|
function renderSlot(slotName: string) {
|
||||||
|
if (!codeXSlot) {
|
||||||
|
return 'div';
|
||||||
|
}
|
||||||
|
const slotFn = codeXSlot[slotName];
|
||||||
|
if (typeof slotFn === 'function') {
|
||||||
|
return slotFn({
|
||||||
|
...props,
|
||||||
|
renderLines: renderLines.value,
|
||||||
|
isDark,
|
||||||
|
isExpand,
|
||||||
|
nowViewBtnShow: nowViewBtnShow.value,
|
||||||
|
toggleExpand,
|
||||||
|
toggleTheme,
|
||||||
|
copyCode,
|
||||||
|
viewCode
|
||||||
|
} satisfies CodeBlockExpose);
|
||||||
|
}
|
||||||
|
|
||||||
|
return h(slotFn as any, {
|
||||||
|
...props,
|
||||||
|
renderLines: renderLines.value,
|
||||||
|
isDark,
|
||||||
|
isExpand,
|
||||||
|
nowViewBtnShow: nowViewBtnShow.value,
|
||||||
|
toggleExpand,
|
||||||
|
toggleTheme,
|
||||||
|
copyCode,
|
||||||
|
viewCode
|
||||||
|
} satisfies CodeBlockExpose);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHeaderLanguageClick() {
|
||||||
|
isExpand.value = !isExpand.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const computedClass = computed(() => `pre-md ${preClass.value} is-expanded`);
|
||||||
|
const codeClass = computed(() => `language-${props.raw?.language || 'text'}`);
|
||||||
|
const RunCodeComputed = computed(() => {
|
||||||
|
return nowCodeLanguage.value === 'html' && nowViewBtnShow.value
|
||||||
|
? RunCode
|
||||||
|
: undefined;
|
||||||
|
});
|
||||||
|
const codeControllerEleComputed = computed(() => {
|
||||||
|
if (nowCodeLanguage.value === 'html' && nowViewBtnShow.value) {
|
||||||
|
return controlHasRunCodeEle(
|
||||||
|
() => {
|
||||||
|
copyCode(renderLines.value);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
viewCode(renderLines.value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return controlEle(() => {
|
||||||
|
copyCode(renderLines.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => nowViewBtnShow.value,
|
||||||
|
v => {
|
||||||
|
if (!v) {
|
||||||
|
runCodeOptions.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取是否显示行号
|
||||||
|
const enableCodeLineNumber = computed(() => {
|
||||||
|
return context?.value?.codeXProps?.enableCodeLineNumber ?? false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :key="props.raw?.key" :class="computedClass" :style="preStyle">
|
||||||
|
<div class="markdown-elxLanguage-header-div is-always-shadow">
|
||||||
|
<component
|
||||||
|
:is="renderSlot('codeHeader')"
|
||||||
|
v-if="codeXSlot?.codeHeader && renderSlot('codeHeader')"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<component
|
||||||
|
:is="
|
||||||
|
codeXSlot?.codeHeaderLanguage
|
||||||
|
? renderSlot('codeHeaderLanguage')
|
||||||
|
: languageEle(props.raw?.language ?? 'text')
|
||||||
|
"
|
||||||
|
@click="handleHeaderLanguageClick"
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
:is="
|
||||||
|
codeXSlot?.codeHeaderControl
|
||||||
|
? renderSlot('codeHeaderControl')
|
||||||
|
: codeControllerEleComputed
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<code
|
||||||
|
:class="codeClass"
|
||||||
|
:style="{
|
||||||
|
display: 'block',
|
||||||
|
overflowX: 'auto'
|
||||||
|
}"
|
||||||
|
v-bind="codeAttrs"
|
||||||
|
>
|
||||||
|
<HighLightCode
|
||||||
|
:enable-code-line-number="enableCodeLineNumber"
|
||||||
|
:lang="props.raw?.language ?? 'text'"
|
||||||
|
:code="renderLines"
|
||||||
|
/>
|
||||||
|
</code>
|
||||||
|
<!-- run-code -->
|
||||||
|
<component
|
||||||
|
:is="RunCodeComputed"
|
||||||
|
v-bind="{ ...viewCodeModalOptions, ...runCodeOptions }"
|
||||||
|
v-model:visible="runCodeOptions.visible"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { View } from '@element-plus/icons-vue';
|
||||||
|
import { ElButton } from 'element-plus';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
onView: () => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
props.onView();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElButton
|
||||||
|
class="shiki-header-button markdown-elxLanguage-header-button"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<View />
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
import type { Component, Ref, VNode } from 'vue';
|
||||||
|
import type { MermaidExposeProps } from '../Mermaid/types';
|
||||||
|
import type {
|
||||||
|
ElxRunCodeCloseBtnExposeProps,
|
||||||
|
ElxRunCodeContentExposeProps,
|
||||||
|
ElxRunCodeExposeProps
|
||||||
|
} from '../RunCode/type';
|
||||||
|
import type { RawProps } from './types';
|
||||||
|
import { useMarkdownContext } from '@components/XMarkdownCore/components/MarkdownProvider';
|
||||||
|
import { ArrowDownBold, Moon, Sunny } from '@element-plus/icons-vue';
|
||||||
|
import { ElButton, ElMessage, ElSpace } from 'element-plus';
|
||||||
|
import { h, ref } from 'vue';
|
||||||
|
import CopyCodeButton from './copy-code-button.vue';
|
||||||
|
import RunCodeButton from './run-code-button.vue';
|
||||||
|
|
||||||
|
export interface CodeBlockExpose {
|
||||||
|
/**
|
||||||
|
* 代码块传入的代码原始数据属性
|
||||||
|
*/
|
||||||
|
raw: RawProps;
|
||||||
|
/**
|
||||||
|
* 渲染的行
|
||||||
|
*/
|
||||||
|
renderLines: Array<string>;
|
||||||
|
/**
|
||||||
|
* 当前主题色是否是暗色
|
||||||
|
*/
|
||||||
|
isDark: Ref<boolean>;
|
||||||
|
/**
|
||||||
|
* 当前代码块是否展开
|
||||||
|
*/
|
||||||
|
isExpand: Ref<boolean>;
|
||||||
|
/**
|
||||||
|
* 是否显示预览代码按钮
|
||||||
|
*/
|
||||||
|
nowViewBtnShow: boolean;
|
||||||
|
/**
|
||||||
|
* 切换展开折叠
|
||||||
|
* @param ev MouseEvent
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
toggleExpand: (ev: MouseEvent) => { isExpand: boolean };
|
||||||
|
/**
|
||||||
|
* 切换主题
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
toggleTheme: () => boolean;
|
||||||
|
/**
|
||||||
|
* 复制代码
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
|
copyCode: (value: string | Array<string>) => void;
|
||||||
|
/**
|
||||||
|
* 查看代码
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
|
viewCode: (value: Array<string>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComponentRenderer<T> = Component<T>;
|
||||||
|
|
||||||
|
export type ComponentFunctionRenderer<T> = (props: T) => VNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 代码块头部渲染器
|
||||||
|
*/
|
||||||
|
export type CodeBlockHeaderRenderer = ComponentRenderer<CodeBlockExpose>;
|
||||||
|
export type CodeBlockHeaderFunctionRenderer =
|
||||||
|
ComponentFunctionRenderer<CodeBlockExpose>;
|
||||||
|
/**
|
||||||
|
* @description 查看代码头部渲染器
|
||||||
|
*/
|
||||||
|
export type ViewCodeHeadRender = ComponentRenderer<ElxRunCodeExposeProps>;
|
||||||
|
export type ViewCodeHeadFunctionRender =
|
||||||
|
ComponentFunctionRenderer<ElxRunCodeExposeProps>;
|
||||||
|
/**
|
||||||
|
* @description 查看代码头部关闭按钮渲染器
|
||||||
|
*/
|
||||||
|
export type ViewCodeCloseBtnRender =
|
||||||
|
ComponentRenderer<ElxRunCodeCloseBtnExposeProps>;
|
||||||
|
export type ViewCodeCloseBtnFunctionRender =
|
||||||
|
ComponentFunctionRenderer<ElxRunCodeCloseBtnExposeProps>;
|
||||||
|
/**
|
||||||
|
* @description 查看代码内容渲染器
|
||||||
|
*/
|
||||||
|
export type ViewCodeContentRender =
|
||||||
|
ComponentRenderer<ElxRunCodeContentExposeProps>;
|
||||||
|
export type ViewCodeContentFunctionRender =
|
||||||
|
ComponentFunctionRenderer<ElxRunCodeContentExposeProps>;
|
||||||
|
/**
|
||||||
|
* @description Mermaid头部插槽渲染器
|
||||||
|
*/
|
||||||
|
export type MermaidHeaderControlRender = ComponentRenderer<MermaidExposeProps>;
|
||||||
|
export type MermaidHeaderControlFunctionRender =
|
||||||
|
ComponentFunctionRenderer<MermaidExposeProps>;
|
||||||
|
|
||||||
|
export interface CodeBlockHeaderExpose {
|
||||||
|
/**
|
||||||
|
* 代码块自定义头部(包括语言和复制按钮等)
|
||||||
|
* 当有此属性时,将不会显示默认的代码头部 和 codeHeaderLanguage codeHeaderControl 插槽里面的内容
|
||||||
|
*/
|
||||||
|
codeHeader?: CodeBlockHeaderRenderer;
|
||||||
|
/**
|
||||||
|
* 代码块语言插槽
|
||||||
|
*/
|
||||||
|
codeHeaderLanguage?: CodeBlockHeaderRenderer;
|
||||||
|
/**
|
||||||
|
* 代码块右侧插槽
|
||||||
|
*/
|
||||||
|
codeHeaderControl?: CodeBlockHeaderRenderer;
|
||||||
|
/**
|
||||||
|
* 代码块查看代码弹窗的头部插槽
|
||||||
|
*/
|
||||||
|
viewCodeHeader?: ViewCodeHeadRender;
|
||||||
|
/**
|
||||||
|
* 代码块查看代码弹窗的关闭按钮插槽
|
||||||
|
*/
|
||||||
|
viewCodeCloseBtn?: ViewCodeCloseBtnRender;
|
||||||
|
/**
|
||||||
|
* 代码块查看代码弹窗的代码内容插槽
|
||||||
|
*/
|
||||||
|
viewCodeContent?: ViewCodeContentRender;
|
||||||
|
/**
|
||||||
|
* 代码块mermaid头部插槽
|
||||||
|
*/
|
||||||
|
codeMermaidHeaderControl?: MermaidHeaderControlRender;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeBlockHeaderFunctionExpose {
|
||||||
|
/**
|
||||||
|
* 代码块自定义头部(包括语言和复制按钮等)
|
||||||
|
* 当有此属性时,将不会显示默认的代码头部 和 codeHeaderLanguage codeHeaderControl 插槽里面的内容
|
||||||
|
*/
|
||||||
|
codeHeader?: CodeBlockHeaderFunctionRenderer;
|
||||||
|
/**
|
||||||
|
* 代码块语言插槽
|
||||||
|
*/
|
||||||
|
codeHeaderLanguage?: CodeBlockHeaderFunctionRenderer;
|
||||||
|
/**
|
||||||
|
* 代码块右侧插槽
|
||||||
|
*/
|
||||||
|
codeHeaderControl?: CodeBlockHeaderFunctionRenderer;
|
||||||
|
/**
|
||||||
|
* 代码块查看代码弹窗的头部插槽
|
||||||
|
*/
|
||||||
|
viewCodeHeader?: ViewCodeHeadFunctionRender;
|
||||||
|
/**
|
||||||
|
* 代码块查看代码弹窗的关闭按钮插槽
|
||||||
|
*/
|
||||||
|
viewCodeCloseBtn?: ViewCodeCloseBtnFunctionRender;
|
||||||
|
/**
|
||||||
|
* 代码块查看代码弹窗的代码内容插槽
|
||||||
|
*/
|
||||||
|
viewCodeContent?: ViewCodeContentFunctionRender;
|
||||||
|
/**
|
||||||
|
* 代码块mermaid头部插槽
|
||||||
|
*/
|
||||||
|
codeMermaidHeaderControl?: MermaidHeaderControlFunctionRender;
|
||||||
|
}
|
||||||
|
|
||||||
|
let copyCodeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// 记录当前是否暗色模式
|
||||||
|
export const isDark = ref(document.body.classList.contains('dark'));
|
||||||
|
|
||||||
|
/* ----------------------------------- 按钮组 ---------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 描述 language标签
|
||||||
|
* @date 2025-06-25 17:48:15
|
||||||
|
* @author tingfeng
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @param language
|
||||||
|
*/
|
||||||
|
export function languageEle(language: string) {
|
||||||
|
return h(
|
||||||
|
ElSpace,
|
||||||
|
{
|
||||||
|
class: `markdown-elxLanguage-header-space markdown-elxLanguage-header-space-start markdown-elxLanguage-header-span`,
|
||||||
|
direction: 'horizontal',
|
||||||
|
onClick: (ev: MouseEvent) => {
|
||||||
|
toggleExpand(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{
|
||||||
|
class: 'markdown-elxLanguage-header-span is-always-shadow'
|
||||||
|
},
|
||||||
|
language || ''
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
ElButton,
|
||||||
|
{
|
||||||
|
class: 'shiki-header-button shiki-header-button-expand'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
h(ArrowDownBold, {
|
||||||
|
class:
|
||||||
|
'markdown-elxLanguage-header-toggle markdown-elxLanguage-header-toggle-expand '
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 描述 语言头部操作按钮
|
||||||
|
* @date 2025-06-25 17:49:04
|
||||||
|
* @author tingfeng
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @param {() => void} copy
|
||||||
|
*/
|
||||||
|
export function controlEle(copy: () => void) {
|
||||||
|
return h(
|
||||||
|
ElSpace,
|
||||||
|
{
|
||||||
|
class: `markdown-elxLanguage-header-space`,
|
||||||
|
direction: 'horizontal'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
toggleThemeEle(),
|
||||||
|
h(CopyCodeButton, { onCopy: copy }) // ✅ 改为组件形式
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 描述 语言头部操作按钮(带预览代码按钮)
|
||||||
|
* @date 2025-07-09 11:15:27
|
||||||
|
* @author tingfeng
|
||||||
|
* @param copy
|
||||||
|
* @param view
|
||||||
|
*/
|
||||||
|
export function controlHasRunCodeEle(copy: () => void, view: () => void) {
|
||||||
|
const context = useMarkdownContext();
|
||||||
|
const { codeXProps } = toValue(context) || {};
|
||||||
|
return h(
|
||||||
|
ElSpace,
|
||||||
|
{
|
||||||
|
class: `markdown-elxLanguage-header-space`,
|
||||||
|
direction: 'horizontal'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
codeXProps?.enableCodePreview
|
||||||
|
? h(RunCodeButton, { onView: view })
|
||||||
|
: null,
|
||||||
|
codeXProps?.enableThemeToggle ? toggleThemeEle() : null,
|
||||||
|
codeXProps?.enableCodeCopy ? h(CopyCodeButton, { onCopy: copy }) : null // ✅ 改为组件形式
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 描述 主题按钮
|
||||||
|
* @date 2025-06-25 17:49:53
|
||||||
|
* @author tingfeng
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export function toggleThemeEle() {
|
||||||
|
return h(
|
||||||
|
ElButton,
|
||||||
|
{
|
||||||
|
class: 'shiki-header-button markdown-elxLanguage-header-toggle',
|
||||||
|
onClick: () => {
|
||||||
|
toggleTheme();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
h(!isDark.value ? Moon : Sunny, {
|
||||||
|
class: 'markdown-elxLanguage-header-toggle'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------- 方法 ----------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 描述 展开代码
|
||||||
|
* @date 2025-07-01 11:33:32
|
||||||
|
* @author tingfeng
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @param elem
|
||||||
|
*/
|
||||||
|
export function expand(elem: HTMLElement) {
|
||||||
|
elem.classList.add('is-expanded');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 描述 折叠代码
|
||||||
|
* @date 2025-07-01 11:33:49
|
||||||
|
* @author tingfeng
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @param elem
|
||||||
|
*/
|
||||||
|
export function collapse(elem: HTMLElement) {
|
||||||
|
elem.classList.remove('is-expanded');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 复制代码内容到剪贴板
|
||||||
|
* @date 2025-03-28 14:03:22
|
||||||
|
* @author tingfeng
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param v
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
async function copy(v: string) {
|
||||||
|
try {
|
||||||
|
// 现代浏览器 Clipboard API
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
await navigator.clipboard.writeText(v);
|
||||||
|
ElMessage({
|
||||||
|
message: '复制成功',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
return; // 复制成功直接返回
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧浏览器的 execCommand 方案
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = v.trim();
|
||||||
|
textarea.style.position = 'fixed'; // 避免滚动到文本框位置
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
// 执行复制命令
|
||||||
|
const success = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
if (success) {
|
||||||
|
ElMessage({
|
||||||
|
message: '复制成功',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
return; // 复制成功直接返回
|
||||||
|
}
|
||||||
|
if (!success) {
|
||||||
|
throw new Error('复制失败,请检查浏览器权限');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
`复制失败: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 描述 将源代码行数转换可复制的string
|
||||||
|
* @date 2025-06-25 17:50:42
|
||||||
|
* @author tingfeng
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @param lines
|
||||||
|
*/
|
||||||
|
export function extractCodeFromHtmlLines(lines: string[]): string {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const output: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
container.innerHTML = lines[i];
|
||||||
|
const text = container.textContent?.trimEnd();
|
||||||
|
output.push(text ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
container.remove();
|
||||||
|
container.innerHTML = ''; // 清空引用内容
|
||||||
|
container.textContent = null;
|
||||||
|
|
||||||
|
return output.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
let isToggling = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 描述 切换展开状态
|
||||||
|
* @date 2025-06-26 21:29:50
|
||||||
|
* @author tingfeng
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @param ev
|
||||||
|
*/
|
||||||
|
export function toggleExpand(ev: MouseEvent): { isExpand: boolean } {
|
||||||
|
if (isToggling) return { isExpand: false }; // 防抖保护
|
||||||
|
isToggling = true;
|
||||||
|
|
||||||
|
const ele = ev.currentTarget as HTMLElement;
|
||||||
|
const preMd = ele.closest('.pre-md') as HTMLElement | null;
|
||||||
|
|
||||||
|
if (preMd) {
|
||||||
|
setTimeout(() => {
|
||||||
|
isToggling = false;
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
if (preMd.classList.contains('is-expanded')) {
|
||||||
|
collapse(preMd);
|
||||||
|
return { isExpand: false };
|
||||||
|
} else {
|
||||||
|
expand(preMd);
|
||||||
|
return { isExpand: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isToggling = false;
|
||||||
|
return { isExpand: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 描述 切换主题
|
||||||
|
* @date 2025-06-26 21:58:56
|
||||||
|
* @author tingfeng
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export function toggleTheme() {
|
||||||
|
const theme = document.body.classList.contains('dark') ? 'light' : 'dark';
|
||||||
|
isDark.value = theme === 'dark';
|
||||||
|
if (isDark.value) {
|
||||||
|
document.body.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('dark');
|
||||||
|
}
|
||||||
|
return isDark.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 描述 初始化主题模式
|
||||||
|
* @date 2025-07-08 13:43:19
|
||||||
|
* @author tingfeng
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @param defaultThemeMode
|
||||||
|
*/
|
||||||
|
export function initThemeMode(defaultThemeMode: 'light' | 'dark') {
|
||||||
|
const theme = document.body.classList.contains('dark') ? 'dark' : 'light';
|
||||||
|
if (theme !== defaultThemeMode) {
|
||||||
|
isDark.value = defaultThemeMode === 'dark';
|
||||||
|
if (defaultThemeMode === 'dark') {
|
||||||
|
document.body.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 描述 复制代码
|
||||||
|
* @date 2025-06-26 22:02:57
|
||||||
|
* @author tingfeng
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @param codeText
|
||||||
|
*/
|
||||||
|
export function copyCode(codeText: string | string[]) {
|
||||||
|
try {
|
||||||
|
if (copyCodeTimer) return false; // 阻止重复点击
|
||||||
|
|
||||||
|
if (Array.isArray(codeText)) {
|
||||||
|
const code = extractCodeFromHtmlLines(codeText);
|
||||||
|
copy(code);
|
||||||
|
} else {
|
||||||
|
copy(codeText);
|
||||||
|
}
|
||||||
|
|
||||||
|
copyCodeTimer = setTimeout(() => {
|
||||||
|
copyCodeTimer = null;
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log('🚀 ~ copyCode ~ error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/components/CodeBlock/types.d.ts
vendored
Normal file
5
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/components/CodeBlock/types.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface RawProps {
|
||||||
|
language?: string;
|
||||||
|
content?: string;
|
||||||
|
key?: string | number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CodeLineProps } from './types';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<CodeLineProps>(), {
|
||||||
|
raw: () => ({}),
|
||||||
|
content: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取实际内容
|
||||||
|
const content = computed(() => {
|
||||||
|
const result = props.raw?.content || props.content || '';
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="inline-code-tag">
|
||||||
|
{{ content }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.inline-code-tag {
|
||||||
|
display: inline;
|
||||||
|
background: #d7e2f8;
|
||||||
|
color: #376fde;
|
||||||
|
padding: 0 4px;
|
||||||
|
margin: 0 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid #d7e2f8;
|
||||||
|
word-wrap: break-word;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
7
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/components/CodeLine/types.d.ts
vendored
Normal file
7
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/components/CodeLine/types.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface CodeLineProps {
|
||||||
|
raw?: {
|
||||||
|
content?: string;
|
||||||
|
inline?: boolean;
|
||||||
|
};
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, h, toValue } from 'vue';
|
||||||
|
import { CodeBlock, CodeLine, Mermaid } from '../index';
|
||||||
|
import { useMarkdownContext } from '../MarkdownProvider';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
raw: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const context = useMarkdownContext();
|
||||||
|
const { codeXRender } = toValue(context);
|
||||||
|
return (): ReturnType<typeof h> | null => {
|
||||||
|
if (props.raw.inline) {
|
||||||
|
if (codeXRender && codeXRender.inline) {
|
||||||
|
const renderer = codeXRender.inline;
|
||||||
|
if (typeof renderer === 'function') {
|
||||||
|
return renderer(props);
|
||||||
|
}
|
||||||
|
return h(renderer, props);
|
||||||
|
}
|
||||||
|
return h(CodeLine, { raw: props.raw });
|
||||||
|
}
|
||||||
|
const { language } = props.raw;
|
||||||
|
if (codeXRender && codeXRender[language]) {
|
||||||
|
const renderer = codeXRender[language];
|
||||||
|
if (typeof renderer === 'function') {
|
||||||
|
return renderer(props);
|
||||||
|
}
|
||||||
|
return h(renderer, props);
|
||||||
|
}
|
||||||
|
if (language === 'mermaid') {
|
||||||
|
return h(Mermaid, props);
|
||||||
|
}
|
||||||
|
|
||||||
|
return h(CodeBlock, props);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElScrollbar } from 'element-plus';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
export interface HighLightCodeProps {
|
||||||
|
code: string[];
|
||||||
|
lang: string;
|
||||||
|
enableCodeLineNumber: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<HighLightCodeProps>();
|
||||||
|
|
||||||
|
const codeClass = computed(() => `language-${props.lang || 'text'}`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="elx-highlight-code-wrapper">
|
||||||
|
<div v-if="props.enableCodeLineNumber" class="line-numbers">
|
||||||
|
<span
|
||||||
|
v-for="(_line, index) in props.code"
|
||||||
|
:key="index"
|
||||||
|
class="line-number"
|
||||||
|
>
|
||||||
|
{{ index + 1 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ElScrollbar class="elx-highlight-code-scrollbar">
|
||||||
|
<div class="code-lines" :class="codeClass">
|
||||||
|
<span
|
||||||
|
v-for="(line, index) in props.code"
|
||||||
|
:key="index"
|
||||||
|
class="line-content"
|
||||||
|
v-html="line"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" src="./style.scss"></style>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
.elx-highlight-code-wrapper {
|
||||||
|
display: flex;
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.line-numbers {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-right: 1rem;
|
||||||
|
.line-number {
|
||||||
|
display: inline-block;
|
||||||
|
text-align: right;
|
||||||
|
padding: 0 0 0 0.3em;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.elx-highlight-code-scrollbar {
|
||||||
|
.code-lines {
|
||||||
|
white-space: pre;
|
||||||
|
& > span {
|
||||||
|
width: max-content;
|
||||||
|
display: block;
|
||||||
|
.line {
|
||||||
|
width: max-content;
|
||||||
|
display: inline-block;
|
||||||
|
white-space: pre;
|
||||||
|
span {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { GlobalShiki } from '@components/XMarkdownCore/hooks/useShiki';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
|
||||||
|
import type { MarkdownContext } from './types';
|
||||||
|
import deepmerge from 'deepmerge';
|
||||||
|
|
||||||
|
import { computed, defineComponent, h, inject, provide } from 'vue';
|
||||||
|
import {
|
||||||
|
useDarkModeWatcher,
|
||||||
|
usePlugins,
|
||||||
|
useProcessMarkdown
|
||||||
|
} from '../../hooks';
|
||||||
|
import { GLOBAL_SHIKI_KEY, MARKDOWN_PROVIDER_KEY } from '../../shared';
|
||||||
|
import { MARKDOWN_CORE_PROPS } from '../../shared/constants';
|
||||||
|
import { initThemeMode } from '../CodeBlock/shiki-header';
|
||||||
|
import '../../style/index.scss';
|
||||||
|
|
||||||
|
const MarkdownProvider = defineComponent({
|
||||||
|
name: 'MarkdownProvider',
|
||||||
|
props: MARKDOWN_CORE_PROPS,
|
||||||
|
setup(props, { slots, attrs }) {
|
||||||
|
const { rehypePlugins, remarkPlugins } = usePlugins(props);
|
||||||
|
const { isDark } = useDarkModeWatcher();
|
||||||
|
const globalShiki = inject<GlobalShiki>(GLOBAL_SHIKI_KEY);
|
||||||
|
const markdown = computed(() => {
|
||||||
|
if (props.enableLatex) {
|
||||||
|
return useProcessMarkdown(props.markdown);
|
||||||
|
} else {
|
||||||
|
return props.markdown;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const processProps = computed(() => {
|
||||||
|
return {
|
||||||
|
...props,
|
||||||
|
codeXProps: Object.assign(
|
||||||
|
{},
|
||||||
|
MARKDOWN_CORE_PROPS.codeXProps.default(),
|
||||||
|
props.codeXProps
|
||||||
|
),
|
||||||
|
markdown: markdown.value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
watch(
|
||||||
|
() => props.defaultThemeMode,
|
||||||
|
v => {
|
||||||
|
if (v) {
|
||||||
|
initThemeMode(v);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextProps = computed(() => {
|
||||||
|
return deepmerge(
|
||||||
|
{
|
||||||
|
rehypePlugins: toValue(rehypePlugins),
|
||||||
|
remarkPlugins: toValue(remarkPlugins),
|
||||||
|
isDark: toValue(isDark),
|
||||||
|
globalShiki: toValue(globalShiki)
|
||||||
|
},
|
||||||
|
processProps.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
provide(MARKDOWN_PROVIDER_KEY, contextProps);
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'elx-xmarkdown-provider', ...attrs },
|
||||||
|
slots.default && slots.default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function useMarkdownContext(): Ref<MarkdownContext> {
|
||||||
|
const context = inject<Ref<MarkdownContext>>(
|
||||||
|
MARKDOWN_PROVIDER_KEY,
|
||||||
|
computed(() => ({}))
|
||||||
|
);
|
||||||
|
if (!context) {
|
||||||
|
return computed(() => ({})) as unknown as Ref<MarkdownContext>;
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
export { MarkdownProvider, useMarkdownContext };
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { GlobalShiki } from '@components/XMarkdownCore/hooks/useShiki';
|
||||||
|
import type { InitShikiOptions } from '../../shared';
|
||||||
|
import type { ElxRunCodeOptions } from '../RunCode/type';
|
||||||
|
|
||||||
|
export interface MarkdownContext {
|
||||||
|
// markdown 字符串内容
|
||||||
|
markdown?: string;
|
||||||
|
// 是否允许 HTML
|
||||||
|
allowHtml?: boolean;
|
||||||
|
// 是否启用代码行号
|
||||||
|
enableCodeLineNumber?: boolean;
|
||||||
|
// 是否启用 LaTeX 支持
|
||||||
|
enableLatex?: boolean;
|
||||||
|
// 是否开启动画
|
||||||
|
enableAnimate?: boolean;
|
||||||
|
// 是否启用换行符转 <br>
|
||||||
|
enableBreaks?: boolean;
|
||||||
|
// 自定义代码块渲染函数
|
||||||
|
codeXRender?: Record<string, any>;
|
||||||
|
// 自定义代码块插槽
|
||||||
|
codeXSlot?: Record<string, any>;
|
||||||
|
// 自定义代码块属性
|
||||||
|
codeXProps?: Record<string, any>;
|
||||||
|
// 自定义代码高亮主题
|
||||||
|
codeHighlightTheme?: builtinTheme;
|
||||||
|
// 自定义属性对象
|
||||||
|
customAttrs?: CustomAttrs;
|
||||||
|
// remark 插件列表
|
||||||
|
remarkPlugins?: PluggableList;
|
||||||
|
remarkPluginsAhead?: PluggableList;
|
||||||
|
// rehype 插件列表
|
||||||
|
rehypePlugins?: PluggableList;
|
||||||
|
rehypePluginsAhead?: PluggableList;
|
||||||
|
// rehype 配置项
|
||||||
|
rehypeOptions?: Record<string, any>;
|
||||||
|
// 是否启用内容清洗
|
||||||
|
sanitize?: boolean;
|
||||||
|
// 清洗选项
|
||||||
|
sanitizeOptions?: SanitizeOptions;
|
||||||
|
// Mermaid 配置对象
|
||||||
|
mermaidConfig?: Record<string, any>;
|
||||||
|
// 主题配置
|
||||||
|
themes?: InitShikiOptions['themes'];
|
||||||
|
// 默认主题模式
|
||||||
|
defaultThemeMode?: 'light' | 'dark';
|
||||||
|
// 是否是暗黑模式(代码高亮块)
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
// 自定义当前主题下的代码颜色配置
|
||||||
|
colorReplacements?: InitShikiOptions['colorReplacements'];
|
||||||
|
// 是否显示查看代码按钮
|
||||||
|
needViewCodeBtn?: boolean;
|
||||||
|
// 是否是安全模式预览html
|
||||||
|
secureViewCode?: boolean;
|
||||||
|
// 预览代码弹窗部分配置
|
||||||
|
viewCodeModalOptions?: ElxRunCodeOptions;
|
||||||
|
// 全局shiki
|
||||||
|
globalShiki?: GlobalShiki;
|
||||||
|
}
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MermaidToolbarConfig, MermaidToolbarEmits } from './types';
|
||||||
|
import {
|
||||||
|
Aim,
|
||||||
|
Check,
|
||||||
|
CopyDocument,
|
||||||
|
Download,
|
||||||
|
FullScreen,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut
|
||||||
|
} from '@element-plus/icons-vue';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
interface MermaidToolbarInternalProps {
|
||||||
|
toolbarConfig?: MermaidToolbarConfig;
|
||||||
|
isSourceCodeMode?: boolean;
|
||||||
|
sourceCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<MermaidToolbarInternalProps>(), {
|
||||||
|
toolbarConfig: () => ({}),
|
||||||
|
isSourceCodeMode: false,
|
||||||
|
sourceCode: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<MermaidToolbarEmits>();
|
||||||
|
|
||||||
|
// 复制成功状态
|
||||||
|
const isCopySuccess = ref(false);
|
||||||
|
|
||||||
|
// 当前激活的 tab
|
||||||
|
const activeTab = computed({
|
||||||
|
get: () => (props.isSourceCodeMode ? 'code' : 'diagram'),
|
||||||
|
set: (value: string) => {
|
||||||
|
if (value === 'code' && !props.isSourceCodeMode) {
|
||||||
|
handleToggleCode();
|
||||||
|
} else if (value === 'diagram' && props.isSourceCodeMode) {
|
||||||
|
handleToggleCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 合并默认配置
|
||||||
|
const config = computed(() => {
|
||||||
|
return {
|
||||||
|
showToolbar: true,
|
||||||
|
showFullscreen: true,
|
||||||
|
showZoomIn: true,
|
||||||
|
showZoomOut: true,
|
||||||
|
showReset: true,
|
||||||
|
showDownload: true,
|
||||||
|
toolbarStyle: {},
|
||||||
|
toolbarClass: '',
|
||||||
|
iconColor: undefined,
|
||||||
|
tabTextColor: undefined,
|
||||||
|
hoverBackgroundColor: undefined,
|
||||||
|
tabActiveBackgroundColor: undefined,
|
||||||
|
...props.toolbarConfig
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 动态图标颜色
|
||||||
|
const iconColorStyle = computed(() => {
|
||||||
|
const style: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (config.value.iconColor) {
|
||||||
|
style.color = config.value.iconColor;
|
||||||
|
style['--custom-icon-color'] = config.value.iconColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置hover背景色
|
||||||
|
if (config.value.hoverBackgroundColor) {
|
||||||
|
style['--custom-hover-bg'] = config.value.hoverBackgroundColor;
|
||||||
|
} else if (config.value.iconColor) {
|
||||||
|
// 如果设置了图标颜色但没有设置hover背景色,使用稍暗的背景
|
||||||
|
style['--custom-hover-bg'] = 'rgba(0, 0, 0, 0.1)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 动态 tab 文字颜色
|
||||||
|
const tabTextColorStyle = computed(() => {
|
||||||
|
const style: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (config.value.tabTextColor) {
|
||||||
|
style['--tab-text-color'] = config.value.tabTextColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置tab激活状态背景色
|
||||||
|
if (config.value.tabActiveBackgroundColor) {
|
||||||
|
style['--tab-active-bg'] = config.value.tabActiveBackgroundColor;
|
||||||
|
} else if (config.value.tabTextColor) {
|
||||||
|
// 如果设置了文字颜色但没有设置激活背景色,使用稍暗的背景
|
||||||
|
style['--tab-active-bg'] = 'rgba(0, 0, 0, 0.1)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleZoomIn(event: Event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
emit('onZoomIn');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZoomOut(event: Event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
emit('onZoomOut');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset(event: Event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
emit('onReset');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFullscreen(event: Event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
emit('onFullscreen');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleCode(event?: Event) {
|
||||||
|
if (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
emit('onToggleCode');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownload(event: Event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
emit('onDownload');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyCode(event: Event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// 如果正在显示成功状态,不执行复制操作
|
||||||
|
if (isCopySuccess.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!props.sourceCode) {
|
||||||
|
emit('onCopyCode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用现代剪贴板 API
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(props.sourceCode);
|
||||||
|
} else {
|
||||||
|
// 降级方案:使用传统方法
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = props.sourceCode;
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.left = '-999999px';
|
||||||
|
textArea.style.top = '-999999px';
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
textArea.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置复制成功状态
|
||||||
|
isCopySuccess.value = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
isCopySuccess.value = false;
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
emit('onCopyCode');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy code: ', err);
|
||||||
|
// 如果复制失败,也通知父组件,让父组件决定如何处理
|
||||||
|
emit('onCopyCode');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToolbarClick(event: Event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTabClick(tabName: string) {
|
||||||
|
activeTab.value = tabName;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabClickEvent {
|
||||||
|
paneName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTabClickEvent(pane: TabClickEvent) {
|
||||||
|
handleTabClick(pane.paneName);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- 正常状态:显示工具栏 -->
|
||||||
|
<div
|
||||||
|
v-if="config.showToolbar"
|
||||||
|
class="mermaid-toolbar"
|
||||||
|
:class="config.toolbarClass"
|
||||||
|
:style="config.toolbarStyle"
|
||||||
|
@click="handleToolbarClick"
|
||||||
|
>
|
||||||
|
<!-- 左侧 Tabs -->
|
||||||
|
<div class="toolbar-left" :style="tabTextColorStyle">
|
||||||
|
<el-tabs
|
||||||
|
:model-value="activeTab"
|
||||||
|
class="toolbar-tabs"
|
||||||
|
@tab-click="handleTabClickEvent"
|
||||||
|
>
|
||||||
|
<el-tab-pane label="图片" name="diagram" />
|
||||||
|
<el-tab-pane label="代码" name="code" />
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧按钮组 -->
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<!-- 代码视图:只显示复制按钮 -->
|
||||||
|
<template v-if="props.isSourceCodeMode">
|
||||||
|
<div
|
||||||
|
class="toolbar-action-btn"
|
||||||
|
:class="{ 'copy-success': isCopySuccess }"
|
||||||
|
:style="iconColorStyle"
|
||||||
|
@click="handleCopyCode($event)"
|
||||||
|
>
|
||||||
|
<el-icon :size="16">
|
||||||
|
<Check v-if="isCopySuccess" />
|
||||||
|
<CopyDocument v-else />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 图片视图:显示所有操作按钮 -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- 下载按钮 -->
|
||||||
|
<div
|
||||||
|
v-if="config.showDownload"
|
||||||
|
class="toolbar-action-btn"
|
||||||
|
:style="iconColorStyle"
|
||||||
|
@click="handleDownload($event)"
|
||||||
|
>
|
||||||
|
<el-icon :size="16">
|
||||||
|
<Download />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分割线 -->
|
||||||
|
<div v-if="config.showDownload" class="toolbar-divider" />
|
||||||
|
|
||||||
|
<!-- 缩小按钮 -->
|
||||||
|
<div
|
||||||
|
v-if="config.showZoomOut"
|
||||||
|
class="toolbar-action-btn"
|
||||||
|
:style="iconColorStyle"
|
||||||
|
@click="handleZoomOut($event)"
|
||||||
|
>
|
||||||
|
<el-icon :size="16">
|
||||||
|
<ZoomOut />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 放大按钮 -->
|
||||||
|
<div
|
||||||
|
v-if="config.showZoomIn"
|
||||||
|
class="toolbar-action-btn"
|
||||||
|
:style="iconColorStyle"
|
||||||
|
@click="handleZoomIn($event)"
|
||||||
|
>
|
||||||
|
<el-icon :size="16">
|
||||||
|
<ZoomIn />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 适应按钮 (重置) -->
|
||||||
|
<div
|
||||||
|
v-if="config.showReset"
|
||||||
|
class="toolbar-action-btn"
|
||||||
|
:style="iconColorStyle"
|
||||||
|
@click="handleReset($event)"
|
||||||
|
>
|
||||||
|
<el-icon :size="16">
|
||||||
|
<Aim />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 全屏按钮 -->
|
||||||
|
<div
|
||||||
|
v-if="config.showFullscreen"
|
||||||
|
class="toolbar-action-btn"
|
||||||
|
:style="iconColorStyle"
|
||||||
|
@click="handleFullscreen($event)"
|
||||||
|
>
|
||||||
|
<el-icon :size="16">
|
||||||
|
<FullScreen />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.mermaid-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 42px;
|
||||||
|
background: #ebecef;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
padding: 0 12px;
|
||||||
|
pointer-events: auto;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.toolbar-tabs {
|
||||||
|
:deep(.el-tabs__header) {
|
||||||
|
margin: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__nav) {
|
||||||
|
background: #dddee1;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__nav-wrap) {
|
||||||
|
&::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item) {
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: none;
|
||||||
|
color: var(--tab-text-color, var(--el-text-color-regular));
|
||||||
|
width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
color: var(--tab-text-color, var(--el-text-color-primary));
|
||||||
|
background: var(--tab-active-bg, rgba(255, 255, 255, 0.8));
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(.is-active) {
|
||||||
|
color: var(--tab-text-color, var(--el-text-color-primary));
|
||||||
|
background: #d1d2d5;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__active-bar) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
.toolbar-action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
border-radius: 4px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover:not(.disabled) {
|
||||||
|
color: var(--custom-icon-color, var(--el-text-color-primary));
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: #dddee1;
|
||||||
|
border-radius: 4px;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(.disabled) {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none !important;
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.toolbar-action-btn-last {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.copy-success {
|
||||||
|
cursor: default;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--el-border-color);
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 父容器悬停时显示工具栏 */
|
||||||
|
:global(.markdown-mermaid:hover .mermaid-toolbar) {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全屏状态下的样式调整 */
|
||||||
|
:global(.markdown-mermaid:fullscreen .mermaid-toolbar) {
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
.toolbar-left .toolbar-tabs {
|
||||||
|
:deep(.el-tabs__item) {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
color: white;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(.is-active) {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
.toolbar-action-btn {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
|
||||||
|
&:hover:not(.disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-divider {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// 复制到剪贴板
|
||||||
|
export async function copyToClipboard(content: string): Promise<boolean> {
|
||||||
|
if (!content)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(content);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = content;
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.left = '-999999px';
|
||||||
|
textArea.style.top = '-999999px';
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
textArea.remove();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('复制失败: ', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG下载功能
|
||||||
|
export function downloadSvgAsPng(svg: string): void {
|
||||||
|
if (!svg)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: false });
|
||||||
|
if (!ctx)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const scale = 2;
|
||||||
|
canvas.width = img.width * scale;
|
||||||
|
canvas.height = img.height * scale;
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
|
||||||
|
// 白色背景
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// 绘制SVG
|
||||||
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// 下载
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 19)
|
||||||
|
.replace(/:/g, '-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
canvas.toBlob(
|
||||||
|
blob => {
|
||||||
|
if (!blob)
|
||||||
|
return;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `mermaid-diagram-${timestamp}.png`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
'image/png',
|
||||||
|
0.95
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (toBlobError) {
|
||||||
|
console.error('toBlobError:', toBlobError);
|
||||||
|
// 降级方案
|
||||||
|
try {
|
||||||
|
const dataUrl = canvas.toDataURL('image/png', 0.95);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.download = `mermaid-diagram-${timestamp}.png`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
catch (dataUrlError) {
|
||||||
|
console.error('dataUrlError:', dataUrlError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (canvasError) {
|
||||||
|
console.error('Canvas操作失败:', canvasError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = error => {
|
||||||
|
console.error('Image load error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = svgDataUrl;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('下载失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MdComponent } from '../types';
|
||||||
|
import type { MermaidExposeProps, MermaidToolbarConfig } from './types';
|
||||||
|
import { debounce } from 'radash';
|
||||||
|
import { computed, nextTick, ref, toValue, watch } from 'vue';
|
||||||
|
import { useMermaid, useMermaidZoom } from '../../hooks';
|
||||||
|
import { useMarkdownContext } from '../MarkdownProvider';
|
||||||
|
import { copyToClipboard, downloadSvgAsPng } from './composables';
|
||||||
|
import MermaidToolbar from './MermaidToolbar.vue';
|
||||||
|
|
||||||
|
interface MermaidProps extends MdComponent {
|
||||||
|
toolbarConfig?: MermaidToolbarConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<MermaidProps>(), {
|
||||||
|
raw: () => ({}),
|
||||||
|
toolbarConfig: () => ({})
|
||||||
|
});
|
||||||
|
|
||||||
|
const mermaidContent = computed(() => props.raw?.content || '');
|
||||||
|
const mermaidResult = useMermaid(mermaidContent, {
|
||||||
|
id: `mermaid-${props.raw?.key || 'default'}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const svg = ref('');
|
||||||
|
const isLoading = computed(
|
||||||
|
() => !mermaidResult.data.value && !mermaidResult.error.value
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取插槽上下文
|
||||||
|
const context = useMarkdownContext();
|
||||||
|
const { codeXSlot } = toValue(context);
|
||||||
|
|
||||||
|
// 计算工具栏配置,合并默认值
|
||||||
|
const toolbarConfig = computed(() => {
|
||||||
|
const contextMermaidConfig = toValue(context)?.mermaidConfig || {};
|
||||||
|
return {
|
||||||
|
showToolbar: true,
|
||||||
|
showFullscreen: true,
|
||||||
|
showZoomIn: true,
|
||||||
|
showZoomOut: true,
|
||||||
|
showReset: true,
|
||||||
|
...contextMermaidConfig,
|
||||||
|
...props.toolbarConfig
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
|
const showSourceCode = ref(false);
|
||||||
|
|
||||||
|
// 初始化缩放功能
|
||||||
|
const zoomControls = useMermaidZoom({
|
||||||
|
container: containerRef,
|
||||||
|
scaleStep: 0.2,
|
||||||
|
minScale: 0.1,
|
||||||
|
maxScale: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
const debouncedInitialize = debounce({ delay: 500 }, onContentTransitionEnter);
|
||||||
|
watch(
|
||||||
|
() => mermaidResult.data.value,
|
||||||
|
newSvg => {
|
||||||
|
if (newSvg) {
|
||||||
|
svg.value = newSvg;
|
||||||
|
debouncedInitialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
watch(svg, newSvg => {
|
||||||
|
if (newSvg) {
|
||||||
|
debouncedInitialize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 工具栏事件处理
|
||||||
|
function handleZoomIn() {
|
||||||
|
if (!showSourceCode.value) {
|
||||||
|
zoomControls?.zoomIn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZoomOut() {
|
||||||
|
if (!showSourceCode.value) {
|
||||||
|
zoomControls?.zoomOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
if (!showSourceCode.value) {
|
||||||
|
zoomControls?.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFullscreen() {
|
||||||
|
if (!showSourceCode.value) {
|
||||||
|
zoomControls?.fullscreen();
|
||||||
|
zoomControls?.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleCode() {
|
||||||
|
showSourceCode.value = !showSourceCode.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyCode() {
|
||||||
|
if (!props.raw.content) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copyToClipboard(props.raw.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownload() {
|
||||||
|
downloadSvgAsPng(svg.value);
|
||||||
|
}
|
||||||
|
// 处理图表内容过渡完成事件
|
||||||
|
function onContentTransitionEnter() {
|
||||||
|
// 只在图表模式下初始化缩放功能
|
||||||
|
if (!showSourceCode.value) {
|
||||||
|
// 使用 nextTick 确保 DOM 完全更新
|
||||||
|
nextTick(() => {
|
||||||
|
if (containerRef.value) {
|
||||||
|
zoomControls.initialize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建暴露给插槽的方法对象
|
||||||
|
const exposedMethods = computed(() => {
|
||||||
|
return {
|
||||||
|
// 基础属性
|
||||||
|
showSourceCode: showSourceCode.value,
|
||||||
|
svg: svg.value,
|
||||||
|
rawContent: props.raw.content || '',
|
||||||
|
toolbarConfig: toolbarConfig.value,
|
||||||
|
isLoading: isLoading.value,
|
||||||
|
|
||||||
|
// 缩放控制方法
|
||||||
|
zoomIn: handleZoomIn,
|
||||||
|
zoomOut: handleZoomOut,
|
||||||
|
reset: handleReset,
|
||||||
|
fullscreen: handleFullscreen,
|
||||||
|
|
||||||
|
// 其他操作方法
|
||||||
|
toggleCode: handleToggleCode,
|
||||||
|
copyCode: handleCopyCode,
|
||||||
|
download: handleDownload,
|
||||||
|
|
||||||
|
// 原始 props(除了重复的 toolbarConfig)
|
||||||
|
raw: props.raw
|
||||||
|
} satisfies MermaidExposeProps;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="containerRef" :key="props.raw.key" class="markdown-mermaid">
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<Transition name="toolbar" appear>
|
||||||
|
<div class="toolbar-container">
|
||||||
|
<!-- 自定义完整头部插槽 -->
|
||||||
|
<component
|
||||||
|
:is="codeXSlot.codeMermaidHeader"
|
||||||
|
v-if="codeXSlot?.codeMermaidHeader"
|
||||||
|
v-bind="exposedMethods"
|
||||||
|
/>
|
||||||
|
<!-- 默认工具栏 + 自定义操作插槽 -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- 自定义操作按钮插槽 -->
|
||||||
|
<component
|
||||||
|
:is="codeXSlot.codeMermaidHeaderControl"
|
||||||
|
v-if="codeXSlot?.codeMermaidHeaderControl"
|
||||||
|
v-bind="exposedMethods"
|
||||||
|
/>
|
||||||
|
<!-- 默认工具栏 -->
|
||||||
|
<MermaidToolbar
|
||||||
|
v-else
|
||||||
|
:toolbar-config="toolbarConfig"
|
||||||
|
:is-source-code-mode="showSourceCode"
|
||||||
|
:source-code="props.raw.content"
|
||||||
|
@on-zoom-in="handleZoomIn"
|
||||||
|
@on-zoom-out="handleZoomOut"
|
||||||
|
@on-reset="handleReset"
|
||||||
|
@on-fullscreen="handleFullscreen"
|
||||||
|
@on-toggle-code="handleToggleCode"
|
||||||
|
@on-copy-code="handleCopyCode"
|
||||||
|
@on-download="handleDownload"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
<Transition
|
||||||
|
name="content"
|
||||||
|
mode="out-in"
|
||||||
|
@after-enter="onContentTransitionEnter"
|
||||||
|
>
|
||||||
|
<pre v-if="showSourceCode" key="source" class="mermaid-source-code">{{
|
||||||
|
props.raw.content
|
||||||
|
}}</pre>
|
||||||
|
<div v-else class="mermaid-content" v-html="svg" />
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style src="./style.scss"></style>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
.markdown-mermaid {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 100px;
|
||||||
|
min-height: 100px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
// 工具栏容器样式
|
||||||
|
.toolbar-container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: white;
|
||||||
|
|
||||||
|
// 确保自定义头部插槽正确显示
|
||||||
|
.custom-mermaid-header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认工具栏的基础样式
|
||||||
|
.mermaid-language-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f0f9ff;
|
||||||
|
border: 1px solid #e0f2fe;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0891b2;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单默认工具栏样式
|
||||||
|
.mermaid-default-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: white;
|
||||||
|
|
||||||
|
.toolbar-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 200px;
|
||||||
|
// max-height: 80vh;
|
||||||
|
cursor: grab;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow: hidden; // 防止未缩放的大图表溢出
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform-origin: center center; // SVG 的变换原点
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染状态时的加载效果
|
||||||
|
&.rendering {
|
||||||
|
svg {
|
||||||
|
opacity: 0.8;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全屏
|
||||||
|
&:fullscreen {
|
||||||
|
.mermaid-content {
|
||||||
|
max-height: 100vh;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
.mermaid-content {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.zoom-limit {
|
||||||
|
.mermaid-content {
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid-source-code {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #333;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容切换过渡 - 减少闪烁,优化为淡入淡出
|
||||||
|
.content-enter-active {
|
||||||
|
transition: opacity 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-leave-active {
|
||||||
|
transition: opacity 0.15s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具栏过渡
|
||||||
|
.toolbar-enter-active,
|
||||||
|
.toolbar-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
80
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/components/Mermaid/types.d.ts
vendored
Normal file
80
Yi.Ai.Vue3/src/vue-element-plus-y/components/XMarkdownCore/components/Mermaid/types.d.ts
vendored
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
export interface MermaidToolbarConfig {
|
||||||
|
showToolbar?: boolean;
|
||||||
|
showFullscreen?: boolean;
|
||||||
|
showZoomIn?: boolean;
|
||||||
|
showZoomOut?: boolean;
|
||||||
|
showReset?: boolean;
|
||||||
|
showDownload?: boolean;
|
||||||
|
toolbarStyle?: Record<string, any>;
|
||||||
|
toolbarClass?: string;
|
||||||
|
iconColor?: string;
|
||||||
|
tabTextColor?: string;
|
||||||
|
hoverBackgroundColor?: string;
|
||||||
|
tabActiveBackgroundColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MermaidToolbarProps extends MermaidToolbarConfig {}
|
||||||
|
|
||||||
|
export interface MermaidZoomControls {
|
||||||
|
zoomIn: () => void;
|
||||||
|
zoomOut: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
fullscreen: () => void;
|
||||||
|
destroy: () => void;
|
||||||
|
initialize: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseMermaidZoomOptions {
|
||||||
|
container: Ref<HTMLElement | null>;
|
||||||
|
scaleStep?: number;
|
||||||
|
minScale?: number;
|
||||||
|
maxScale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MermaidToolbarEmits {
|
||||||
|
onZoomIn: [];
|
||||||
|
onZoomOut: [];
|
||||||
|
onReset: [];
|
||||||
|
onFullscreen: [];
|
||||||
|
onEdit: [];
|
||||||
|
onToggleCode: [];
|
||||||
|
onCopyCode: [];
|
||||||
|
onDownload: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mermaid 组件暴露给插槽的方法接口
|
||||||
|
export interface MermaidExposedMethods {
|
||||||
|
zoomIn: () => void;
|
||||||
|
zoomOut: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
fullscreen: () => void;
|
||||||
|
toggleCode: () => void;
|
||||||
|
copyCode: () => void;
|
||||||
|
download: () => void;
|
||||||
|
svg: import('vue').Ref<string>;
|
||||||
|
showSourceCode: import('vue').Ref<boolean>;
|
||||||
|
toolbarConfig: import('vue').ComputedRef<MermaidToolbarConfig>;
|
||||||
|
rawContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MermaidExposeProps {
|
||||||
|
showSourceCode: boolean;
|
||||||
|
svg: string;
|
||||||
|
rawContent: any;
|
||||||
|
toolbarConfig: MermaidToolbarConfig;
|
||||||
|
isLoading: boolean;
|
||||||
|
|
||||||
|
// 缩放控制方法
|
||||||
|
zoomIn: () => void;
|
||||||
|
zoomOut: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
fullscreen: () => void;
|
||||||
|
|
||||||
|
// 其他操作方法
|
||||||
|
toggleCode: () => void;
|
||||||
|
copyCode: () => Promise<void>;
|
||||||
|
download: () => void;
|
||||||
|
|
||||||
|
// 原始 props(除了重复的 toolbarConfig)
|
||||||
|
raw: any;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user