24 Commits

Author SHA1 Message Date
ccnetcore
89b19cf541 fix: 修复渲染bug 2025-12-14 12:39:58 +08:00
chenchun
21ef1d51a6 feat: 测试markdown 2025-12-12 19:38:27 +08:00
ccnetcore
cc812ba2cb Merge branch 'abp' into ai-hub 2025-12-11 23:33:33 +08:00
ccnetcore
8a6e5abf48 fix: 修复token鉴权 2025-12-11 23:32:57 +08:00
ccnetcore
8b191330b8 Revert "fix: 仅从 Query 获取 access_token/refresh_token,简化 OnMessageReceived 逻辑"
This reverts commit 0d2f2cb826.
2025-12-11 23:31:29 +08:00
Gsh
5ed79c6dd0 fix: vip取值优化 2025-12-11 21:47:48 +08:00
Gsh
6e2ca8f1c3 fix: 2.7 模型库优化 2025-12-11 21:35:32 +08:00
ccnetcore
a46a552097 feat: 完成模型库优化 2025-12-11 21:12:29 +08:00
chenchun
53e56134d4 Merge branch 'abp' into codex 2025-12-11 17:45:04 +08:00
chenchun
0d2f2cb826 fix: 仅从 Query 获取 access_token/refresh_token,简化 OnMessageReceived 逻辑
- 修改文件:Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs
- 将 JwtBearerEvents.OnMessageReceived 的上下文参数名改为 messageContext,统一变量名。
- 简化 Token 获取逻辑:只从 request.Query 中读取 access_token 与 refresh_token,移除从 Cookies(Token)和请求头(refresh_token)读取的分支。
2025-12-11 17:41:38 +08:00
chenchun
f90105ebb4 feat: 全站优化 2025-12-11 17:33:12 +08:00
chenchun
67ed1ac1e3 fix: 聊天模型列表仅返回 OpenAi 类型
在 Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/AiChatService.cs 中,为查询添加了 .Where(x => x.ModelApiType == ModelApiTypeEnum.OpenAi) 过滤,确保只返回 ModelType 为 Chat 且 ModelApiType 为 OpenAi 的模型,避免将非 OpenAi 的模型纳入聊天模型列表。
2025-12-11 17:17:35 +08:00
chenchun
69b84f6613 feat: 完成openai响应接口 2025-12-11 17:16:21 +08:00
ccnetcore
433d616b9b feat: 支持codex 2025-12-11 01:17:31 +08:00
chenchun
53aa575ad4 Merge branch 'abp' into ai-hub 2025-12-10 15:54:50 +08:00
chenchun
571df74c43 chore: 在 common.props 添加 SatelliteResourceLanguages=en;zh-CN
在 Yi.Abp.Net8/common.props 中新增 SatelliteResourceLanguages 属性,指定生成卫星资源语言为 en 和 zh-CN,以便打包对应的本地化资源。
2025-12-10 15:53:18 +08:00
chenchun
b7847c7e7d feat: 发布2.6版本 2025-12-10 15:14:45 +08:00
chenchun
94eb41996e Merge branch 'abp' into ai-hub 2025-12-10 15:11:44 +08:00
chenchun
cefde6848d perf: 去除35MB又臭又大的腾讯云sdk 2025-12-10 15:10:54 +08:00
chenchun
381b712b25 feat: 完成模型库功能模块 2025-12-10 15:08:16 +08:00
Gsh
c319b0b4e4 fix: 模型库优化 2025-12-10 01:34:40 +08:00
ccnetcore
1a32fa9e20 feat: 支持多选模型库条件 2025-12-10 00:31:14 +08:00
Gsh
909406238c fix: 模型库前端布局优化 2025-12-09 23:38:11 +08:00
chenchun
54a1d2a66f feat: 完成模型库 2025-12-09 19:11:30 +08:00
125 changed files with 14701 additions and 1183 deletions

2
.gitignore vendored
View File

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

View File

@@ -12,6 +12,7 @@
</PropertyGroup>
<PropertyGroup>
<SatelliteResourceLanguages>en;zh-CN</SatelliteResourceLanguages>
<LangVersion>latest</LangVersion>
<Version>1.0.0</Version>
<NoWarn>$(NoWarn);CS1591;CS8618;CS1998;CS8604;CS8620;CS8600;CS8602</NoWarn>

View File

@@ -0,0 +1,17 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
/// <summary>
/// API类型选项
/// </summary>
public class ModelApiTypeOption
{
/// <summary>
/// 显示名称
/// </summary>
public string Label { get; set; }
/// <summary>
/// 枚举值
/// </summary>
public int Value { get; set; }
}

View File

@@ -0,0 +1,79 @@
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.AiHub.Domain.Shared.Extensions;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
/// <summary>
/// 模型库展示数据
/// </summary>
public class ModelLibraryDto
{
/// <summary>
/// 模型ID
/// </summary>
public string ModelId { get; set; }
/// <summary>
/// 模型名称
/// </summary>
public string Name { get; set; }
/// <summary>
/// 模型描述
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 模型类型
/// </summary>
public ModelTypeEnum ModelType { get; set; }
/// <summary>
/// 模型类型名称
/// </summary>
public string ModelTypeName => ModelType.GetDescription();
/// <summary>
/// 模型支持的API类型
/// </summary>
public List<ModelApiTypeOutput> ModelApiTypes { get; set; }
/// <summary>
/// 模型显示倍率
/// </summary>
public decimal MultiplierShow { get; set; }
/// <summary>
/// 供应商分组名称
/// </summary>
public string? ProviderName { get; set; }
/// <summary>
/// 模型图标URL
/// </summary>
public string? IconUrl { get; set; }
/// <summary>
/// 是否为尊享模型PremiumChat类型
/// </summary>
public bool IsPremium { get; set; }
/// <summary>
/// 排序
/// </summary>
public int OrderNum { get; set; }
}
public class ModelApiTypeOutput
{
/// <summary>
/// 模型类型
/// </summary>
public ModelApiTypeEnum ModelApiType { get; set; }
/// <summary>
/// 模型类型名称
/// </summary>
public string ModelApiTypeName => ModelApiType.GetDescription();
}

View File

@@ -0,0 +1,35 @@
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.Ddd.Application.Contracts;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
/// <summary>
/// 获取模型库列表查询参数
/// </summary>
public class ModelLibraryGetListInput : PagedAllResultRequestDto
{
/// <summary>
/// 搜索关键词搜索模型名称、模型ID
/// </summary>
public string? SearchKey { get; set; }
/// <summary>
/// 供应商名称筛选
/// </summary>
public List<string>? ProviderNames { get; set; }
/// <summary>
/// 模型类型筛选
/// </summary>
public List<ModelTypeEnum>? ModelTypes { get; set; }
/// <summary>
/// API类型筛选
/// </summary>
public List<ModelApiTypeEnum>? ModelApiTypes { get; set; }
/// <summary>
/// 是否只显示尊享模型
/// </summary>
public bool? IsPremiumOnly { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
/// <summary>
/// 模型类型选项
/// </summary>
public class ModelTypeOption
{
/// <summary>
/// 显示名称
/// </summary>
public string Label { get; set; }
/// <summary>
/// 枚举值
/// </summary>
public int Value { get; set; }
}

View File

@@ -0,0 +1,35 @@
using Volo.Abp.Application.Dtos;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
namespace Yi.Framework.AiHub.Application.Contracts.IServices;
/// <summary>
/// 模型服务接口
/// </summary>
public interface IModelService
{
/// <summary>
/// 获取模型库列表(公开接口,无需登录)
/// </summary>
/// <param name="input">查询参数</param>
/// <returns>分页模型列表</returns>
Task<PagedResultDto<ModelLibraryDto>> GetListAsync(ModelLibraryGetListInput input);
/// <summary>
/// 获取供应商列表(公开接口,无需登录)
/// </summary>
/// <returns>供应商列表</returns>
Task<List<string>> GetProviderListAsync();
/// <summary>
/// 获取模型类型选项列表(公开接口,无需登录)
/// </summary>
/// <returns>模型类型选项</returns>
Task<List<ModelTypeOption>> GetModelTypeOptionsAsync();
/// <summary>
/// 获取API类型选项列表公开接口无需登录
/// </summary>
/// <returns>API类型选项</returns>
Task<List<ModelApiTypeOption>> GetApiTypeOptionsAsync();
}

View File

@@ -73,6 +73,7 @@ public class AiChatService : ApplicationService
{
var output = await _aiModelRepository._DbQueryable
.Where(x => x.ModelType == ModelTypeEnum.Chat)
.Where(x => x.ModelApiType == ModelApiTypeEnum.OpenAi)
.OrderByDescending(x => x.OrderNum)
.Select(x => new ModelGetListOutput
{

View File

@@ -0,0 +1,118 @@
using Mapster;
using SqlSugar;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
using Yi.Framework.AiHub.Application.Contracts.IServices;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.AiHub.Domain.Shared.Extensions;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Application.Services.Chat;
/// <summary>
/// 模型服务
/// </summary>
public class ModelService : ApplicationService, IModelService
{
private readonly ISqlSugarRepository<AiModelEntity, Guid> _modelRepository;
public ModelService(ISqlSugarRepository<AiModelEntity, Guid> modelRepository)
{
_modelRepository = modelRepository;
}
/// <summary>
/// 获取模型库列表(公开接口,无需登录)
/// </summary>
public async Task<PagedResultDto<ModelLibraryDto>> GetListAsync(ModelLibraryGetListInput input)
{
RefAsync<int> total = 0;
// 查询所有未删除的模型使用WhereIF动态添加筛选条件
var modelIds = (await _modelRepository._DbQueryable
.WhereIF(!string.IsNullOrWhiteSpace(input.SearchKey), x =>
x.Name.Contains(input.SearchKey) || x.ModelId.Contains(input.SearchKey))
.WhereIF(input.ProviderNames is not null, x =>
input.ProviderNames.Contains(x.ProviderName))
.WhereIF(input.ModelTypes is not null, x =>
input.ModelTypes.Contains(x.ModelType))
.WhereIF(input.ModelApiTypes is not null, x =>
input.ModelApiTypes.Contains(x.ModelApiType))
.WhereIF(input.IsPremiumOnly == true, x =>
PremiumPackageConst.ModeIds.Contains(x.ModelId))
.GroupBy(x => x.ModelId)
.Select(x => x.ModelId)
.ToPageListAsync(input.SkipCount, input.MaxResultCount, total));
var entities = await _modelRepository._DbQueryable.Where(x => modelIds.Contains(x.ModelId))
.OrderBy(x => x.OrderNum)
.OrderBy(x => x.Name).ToListAsync();
var output= entities.GroupBy(x => x.ModelId).Select(x => new ModelLibraryDto
{
ModelId = x.First().ModelId,
Name = x.First().Name,
Description = x.First().Description,
ModelType = x.First().ModelType,
ModelApiTypes = x.Select(y => new ModelApiTypeOutput { ModelApiType = y.ModelApiType }).ToList(),
MultiplierShow = x.First().MultiplierShow,
ProviderName = x.First().ProviderName,
IconUrl = x.First().IconUrl,
IsPremium = PremiumPackageConst.ModeIds.Contains(x.First().ModelId),
OrderNum = x.First().OrderNum
}).ToList();
return new PagedResultDto<ModelLibraryDto>(total, output);
}
/// <summary>
/// 获取供应商列表(公开接口,无需登录)
/// </summary>
public async Task<List<string>> GetProviderListAsync()
{
var providers = await _modelRepository._DbQueryable
.Where(x => !x.IsDeleted)
.Where(x => !string.IsNullOrEmpty(x.ProviderName))
.GroupBy(x => x.ProviderName)
.OrderBy(x => x.ProviderName)
.Select(x => x.ProviderName)
.ToListAsync();
return providers;
}
/// <summary>
/// 获取模型类型选项列表(公开接口,无需登录)
/// </summary>
public Task<List<ModelTypeOption>> GetModelTypeOptionsAsync()
{
var options = Enum.GetValues<ModelTypeEnum>()
.Select(e => new ModelTypeOption
{
Label = e.GetDescription(),
Value = (int)e
})
.ToList();
return Task.FromResult(options);
}
/// <summary>
/// 获取API类型选项列表公开接口无需登录
/// </summary>
public Task<List<ModelApiTypeOption>> GetApiTypeOptionsAsync()
{
var options = Enum.GetValues<ModelApiTypeEnum>()
.Select(e => new ModelApiTypeOption
{
Label = e.GetDescription(),
Value = (int)e
})
.ToList();
return Task.FromResult(options);
}
}

View File

@@ -12,6 +12,7 @@ using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Embeddings;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Images;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.Rbac.Application.Contracts.IServices;
using Yi.Framework.SqlSugarCore.Abstractions;
@@ -85,6 +86,7 @@ public class OpenApiService : ApplicationService
}
}
/// <summary>
/// 图片生成
/// </summary>
@@ -102,6 +104,7 @@ public class OpenApiService : ApplicationService
await _aiGateWayManager.CreateImageForStatisticsAsync(httpContext, userId, null, input, tokenId);
}
/// <summary>
/// 向量生成
/// </summary>
@@ -145,7 +148,7 @@ public class OpenApiService : ApplicationService
};
}
/// <summary>
/// Anthropic对话尊享服务专用
/// </summary>
@@ -185,18 +188,72 @@ public class OpenApiService : ApplicationService
//ai网关代理httpcontext
if (input.Stream)
{
await _aiGateWayManager.AnthropicCompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
await _aiGateWayManager.AnthropicCompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext,
input,
userId, null, tokenId, cancellationToken);
}
else
{
await _aiGateWayManager.AnthropicCompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input, userId,
await _aiGateWayManager.AnthropicCompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input,
userId,
null, tokenId,
cancellationToken);
}
}
/// <summary>
/// 响应-Openai新规范 (尊享服务专用)
/// </summary>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
[HttpPost("openApi/v1/responses")]
public async Task ResponsesAsync([FromBody] OpenAiResponsesInput input, CancellationToken cancellationToken)
{
//前面都是校验,后面才是真正的调用
var httpContext = this._httpContextAccessor.HttpContext;
var tokenValidation = await _tokenManager.ValidateTokenAsync(GetTokenByHttpContext(httpContext), input.Model);
var userId = tokenValidation.UserId;
var tokenId = tokenValidation.TokenId;
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
// 验证用户是否为VIP
var userInfo = await _accountService.GetAsync(null, null, userId);
if (userInfo == null)
{
throw new UserFriendlyException("用户信息不存在");
}
// 检查是否为VIP使用RoleCodes判断
if (!userInfo.RoleCodes.Contains(AiHubConst.VipRole) && userInfo.User.UserName != "cc")
{
throw new UserFriendlyException("该接口为尊享服务专用需要VIP权限才能使用");
}
// 检查尊享token包用量
var availableTokens = await _premiumPackageManager.GetAvailableTokensAsync(userId);
if (availableTokens <= 0)
{
throw new UserFriendlyException("尊享token包用量不足请先购买尊享token包");
}
//ai网关代理httpcontext
if (input.Stream == true)
{
await _aiGateWayManager.OpenAiResponsesStreamForStatisticsAsync(_httpContextAccessor.HttpContext,
input,
userId, null, tokenId, cancellationToken);
}
else
{
await _aiGateWayManager.OpenAiResponsesAsyncForStatisticsAsync(_httpContextAccessor.HttpContext, input,
userId,
null, tokenId,
cancellationToken);
}
}
#region
private string? GetTokenByHttpContext(HttpContext httpContext)
@@ -210,7 +267,8 @@ public class OpenApiService : ApplicationService
// 再检查 Authorization 头
string authHeader = httpContext.Request.Headers["Authorization"];
if (!string.IsNullOrWhiteSpace(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrWhiteSpace(authHeader) &&
authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return authHeader.Substring("Bearer ".Length).Trim();
}
@@ -227,5 +285,4 @@ public class OpenApiService : ApplicationService
}
#endregion
}

View File

@@ -12,7 +12,7 @@ public sealed class AnthropicInput
[JsonPropertyName("max_tokens")] public int? MaxTokens { get; set; }
[JsonPropertyName("messages")] public IList<AnthropicMessageInput> Messages { get; set; }
[JsonPropertyName("messages")] public JsonElement? Messages { get; set; }
[JsonPropertyName("tools")] public IList<AnthropicMessageTool>? Tools { get; set; }

View File

@@ -1,648 +0,0 @@
using System.Text.Json;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
public static class AnthropicToOpenAi
{
/// <summary>
/// 将AnthropicInput转换为ThorChatCompletionsRequest
/// </summary>
public static ThorChatCompletionsRequest ConvertAnthropicToOpenAi(AnthropicInput anthropicInput)
{
var openAiRequest = new ThorChatCompletionsRequest
{
Model = anthropicInput.Model,
MaxTokens = anthropicInput.MaxTokens,
Stream = anthropicInput.Stream,
Messages = new List<ThorChatMessage>(anthropicInput.Messages.Count)
};
// high medium minimal low
if (openAiRequest.Model.EndsWith("-high") ||
openAiRequest.Model.EndsWith("-medium") ||
openAiRequest.Model.EndsWith("-minimal") ||
openAiRequest.Model.EndsWith("-low"))
{
openAiRequest.ReasoningEffort = openAiRequest.Model switch
{
var model when model.EndsWith("-high") => "high",
var model when model.EndsWith("-medium") => "medium",
var model when model.EndsWith("-minimal") => "minimal",
var model when model.EndsWith("-low") => "low",
_ => "medium"
};
openAiRequest.Model = openAiRequest.Model.Replace("-high", "")
.Replace("-medium", "")
.Replace("-minimal", "")
.Replace("-low", "");
}
if (anthropicInput.Thinking != null &&
anthropicInput.Thinking.Type.Equals("enabled", StringComparison.OrdinalIgnoreCase))
{
openAiRequest.Thinking = new ThorChatClaudeThinking()
{
BudgetToken = anthropicInput.Thinking.BudgetTokens,
Type = "enabled",
};
openAiRequest.EnableThinking = true;
}
if (openAiRequest.Model.EndsWith("-thinking"))
{
openAiRequest.EnableThinking = true;
openAiRequest.Model = openAiRequest.Model.Replace("-thinking", "");
}
if (openAiRequest.Stream == true)
{
openAiRequest.StreamOptions = new ThorStreamOptions()
{
IncludeUsage = true,
};
}
if (!string.IsNullOrEmpty(anthropicInput.System))
{
openAiRequest.Messages.Add(ThorChatMessage.CreateSystemMessage(anthropicInput.System));
}
if (anthropicInput.Systems?.Count > 0)
{
foreach (var systemContent in anthropicInput.Systems)
{
openAiRequest.Messages.Add(ThorChatMessage.CreateSystemMessage(systemContent.Text ?? string.Empty));
}
}
// 处理messages
if (anthropicInput.Messages != null)
{
foreach (var message in anthropicInput.Messages)
{
var thorMessages = ConvertAnthropicMessageToThor(message);
// 需要过滤 空消息
if (thorMessages.Count == 0)
{
continue;
}
openAiRequest.Messages.AddRange(thorMessages);
}
openAiRequest.Messages = openAiRequest.Messages
.Where(m => !string.IsNullOrEmpty(m.Content) || m.Contents?.Count > 0 || m.ToolCalls?.Count > 0 ||
!string.IsNullOrEmpty(m.ToolCallId))
.ToList();
}
// 处理tools
if (anthropicInput.Tools is { Count: > 0 })
{
openAiRequest.Tools = anthropicInput.Tools.Where(x => x.name != "web_search")
.Select(ConvertAnthropicToolToThor).ToList();
}
// 判断是否存在web_search
if (anthropicInput.Tools?.Any(x => x.name == "web_search") == true)
{
openAiRequest.WebSearchOptions = new ThorChatWebSearchOptions()
{
};
}
// 处理tool_choice
if (anthropicInput.ToolChoice != null)
{
openAiRequest.ToolChoice = ConvertAnthropicToolChoiceToThor(anthropicInput.ToolChoice);
}
return openAiRequest;
}
/// <summary>
/// 根据最后的内容块类型和OpenAI的完成原因确定Claude的停止原因
/// </summary>
public static string GetStopReasonByLastContentType(string? openAiFinishReason, string lastContentBlockType)
{
// 如果最后一个内容块是工具调用优先返回tool_use
if (lastContentBlockType == "tool_use")
{
return "tool_use";
}
// 否则使用标准的转换逻辑
return GetClaudeStopReason(openAiFinishReason);
}
/// <summary>
/// 创建message_start事件
/// </summary>
public static AnthropicStreamDto CreateMessageStartEvent(string messageId, string model)
{
return new AnthropicStreamDto
{
Type = "message_start",
Message = new AnthropicChatCompletionDto
{
id = messageId,
type = "message",
role = "assistant",
model = model,
content = new AnthropicChatCompletionDtoContent[0],
Usage = new AnthropicCompletionDtoUsage
{
InputTokens = 0,
OutputTokens = 0,
CacheCreationInputTokens = 0,
CacheReadInputTokens = 0
}
}
};
}
/// <summary>
/// 创建content_block_start事件
/// </summary>
public static AnthropicStreamDto CreateContentBlockStartEvent()
{
return new AnthropicStreamDto
{
Type = "content_block_start",
Index = 0,
ContentBlock = new AnthropicChatCompletionDtoContentBlock
{
Type = "text",
Id = null,
Name = null
}
};
}
/// <summary>
/// 创建thinking block start事件
/// </summary>
public static AnthropicStreamDto CreateThinkingBlockStartEvent()
{
return new AnthropicStreamDto
{
Type = "content_block_start",
Index = 0,
ContentBlock = new AnthropicChatCompletionDtoContentBlock
{
Type = "thinking",
Id = null,
Name = null
}
};
}
/// <summary>
/// 创建content_block_delta事件
/// </summary>
public static AnthropicStreamDto CreateContentBlockDeltaEvent(string text)
{
return new AnthropicStreamDto
{
Type = "content_block_delta",
Index = 0,
Delta = new AnthropicChatCompletionDtoDelta
{
Type = "text_delta",
Text = text
}
};
}
/// <summary>
/// 创建thinking delta事件
/// </summary>
public static AnthropicStreamDto CreateThinkingBlockDeltaEvent(string thinking)
{
return new AnthropicStreamDto
{
Type = "content_block_delta",
Index = 0,
Delta = new AnthropicChatCompletionDtoDelta
{
Type = "thinking",
Thinking = thinking
}
};
}
/// <summary>
/// 创建content_block_stop事件
/// </summary>
public static AnthropicStreamDto CreateContentBlockStopEvent()
{
return new AnthropicStreamDto
{
Type = "content_block_stop",
Index = 0
};
}
/// <summary>
/// 创建message_delta事件
/// </summary>
public static AnthropicStreamDto CreateMessageDeltaEvent(string finishReason, AnthropicCompletionDtoUsage usage)
{
return new AnthropicStreamDto
{
Type = "message_delta",
Usage = usage,
Delta = new AnthropicChatCompletionDtoDelta
{
StopReason = finishReason
}
};
}
/// <summary>
/// 创建message_stop事件
/// </summary>
public static AnthropicStreamDto CreateMessageStopEvent()
{
return new AnthropicStreamDto
{
Type = "message_stop"
};
}
/// <summary>
/// 创建tool block start事件
/// </summary>
public static AnthropicStreamDto CreateToolBlockStartEvent(string? toolId, string? toolName)
{
return new AnthropicStreamDto
{
Type = "content_block_start",
Index = 0,
ContentBlock = new AnthropicChatCompletionDtoContentBlock
{
Type = "tool_use",
Id = toolId,
Name = toolName
}
};
}
/// <summary>
/// 创建tool delta事件
/// </summary>
public static AnthropicStreamDto CreateToolBlockDeltaEvent(string partialJson)
{
return new AnthropicStreamDto
{
Type = "content_block_delta",
Index = 0,
Delta = new AnthropicChatCompletionDtoDelta
{
Type = "input_json_delta",
PartialJson = partialJson
}
};
}
/// <summary>
/// 转换Anthropic消息为Thor消息列表
/// </summary>
public static List<ThorChatMessage> ConvertAnthropicMessageToThor(AnthropicMessageInput anthropicMessage)
{
var results = new List<ThorChatMessage>();
// 处理简单的字符串内容
if (anthropicMessage.Content != null)
{
var thorMessage = new ThorChatMessage
{
Role = anthropicMessage.Role,
Content = anthropicMessage.Content
};
results.Add(thorMessage);
return results;
}
// 处理多模态内容
if (anthropicMessage.Contents is { Count: > 0 })
{
var currentContents = new List<ThorChatMessageContent>();
var currentToolCalls = new List<ThorToolCall>();
foreach (var content in anthropicMessage.Contents)
{
switch (content.Type)
{
case "text":
currentContents.Add(ThorChatMessageContent.CreateTextContent(content.Text ?? string.Empty));
break;
case "thinking" when !string.IsNullOrEmpty(content.Thinking):
results.Add(new ThorChatMessage()
{
ReasoningContent = content.Thinking
});
break;
case "image":
{
if (content.Source != null)
{
var imageUrl = content.Source.Type == "base64"
? $"data:{content.Source.MediaType};base64,{content.Source.Data}"
: content.Source.Data;
currentContents.Add(ThorChatMessageContent.CreateImageUrlContent(imageUrl ?? string.Empty));
}
break;
}
case "tool_use":
{
// 如果有普通内容,先创建内容消息
if (currentContents.Count > 0)
{
if (currentContents.Count == 1 && currentContents.Any(x => x.Type == "text"))
{
var contentMessage = new ThorChatMessage
{
Role = anthropicMessage.Role,
ContentCalculated = currentContents.FirstOrDefault()?.Text ?? string.Empty
};
results.Add(contentMessage);
}
else
{
var contentMessage = new ThorChatMessage
{
Role = anthropicMessage.Role,
Contents = currentContents
};
results.Add(contentMessage);
}
currentContents = new List<ThorChatMessageContent>();
}
// 收集工具调用
var toolCall = new ThorToolCall
{
Id = content.Id,
Type = "function",
Function = new ThorChatMessageFunction
{
Name = content.Name,
Arguments = JsonSerializer.Serialize(content.Input)
}
};
currentToolCalls.Add(toolCall);
break;
}
case "tool_result":
{
// 如果有普通内容,先创建内容消息
if (currentContents.Count > 0)
{
var contentMessage = new ThorChatMessage
{
Role = anthropicMessage.Role,
Contents = currentContents
};
results.Add(contentMessage);
currentContents = [];
}
// 如果有工具调用,先创建工具调用消息
if (currentToolCalls.Count > 0)
{
var toolCallMessage = new ThorChatMessage
{
Role = anthropicMessage.Role,
ToolCalls = currentToolCalls
};
results.Add(toolCallMessage);
currentToolCalls = new List<ThorToolCall>();
}
// 创建工具结果消息
var toolMessage = new ThorChatMessage
{
Role = "tool",
ToolCallId = content.ToolUseId,
Content = content.Content?.ToString() ?? string.Empty
};
results.Add(toolMessage);
break;
}
}
}
// 处理剩余的内容
if (currentContents.Count > 0)
{
var contentMessage = new ThorChatMessage
{
Role = anthropicMessage.Role,
Contents = currentContents
};
results.Add(contentMessage);
}
// 处理剩余的工具调用
if (currentToolCalls.Count > 0)
{
var toolCallMessage = new ThorChatMessage
{
Role = anthropicMessage.Role,
ToolCalls = currentToolCalls
};
results.Add(toolCallMessage);
}
}
// 如果没有任何内容,返回一个空的消息
if (results.Count == 0)
{
results.Add(new ThorChatMessage
{
Role = anthropicMessage.Role,
Content = string.Empty
});
}
// 如果只有一个text则使用content字段
if (results is [{ Contents.Count: 1 }] &&
results.FirstOrDefault()?.Contents?.FirstOrDefault()?.Type == "text" &&
!string.IsNullOrEmpty(results.FirstOrDefault()?.Contents?.FirstOrDefault()?.Text))
{
return
[
new ThorChatMessage
{
Role = results[0].Role,
Content = results.FirstOrDefault()?.Contents?.FirstOrDefault()?.Text ?? string.Empty
}
];
}
return results;
}
/// <summary>
/// 转换Anthropic工具为Thor工具
/// </summary>
public static ThorToolDefinition ConvertAnthropicToolToThor(AnthropicMessageTool anthropicTool)
{
IDictionary<string, ThorToolFunctionPropertyDefinition> values =
new Dictionary<string, ThorToolFunctionPropertyDefinition>();
if (anthropicTool.InputSchema?.Properties != null)
{
foreach (var property in anthropicTool.InputSchema.Properties)
{
if (property.Value?.description != null)
{
var definitionType = new ThorToolFunctionPropertyDefinition()
{
Description = property.Value.description,
Type = property.Value.type
};
if (property.Value?.items?.type != null)
{
definitionType.Items = new ThorToolFunctionPropertyDefinition()
{
Type = property.Value.items.type
};
}
values.Add(property.Key, definitionType);
}
}
}
return new ThorToolDefinition
{
Type = "function",
Function = new ThorToolFunctionDefinition
{
Name = anthropicTool.name,
Description = anthropicTool.Description,
Parameters = new ThorToolFunctionPropertyDefinition
{
Type = anthropicTool.InputSchema?.Type ?? "object",
Properties = values,
Required = anthropicTool.InputSchema?.Required
}
}
};
}
/// <summary>
/// 将OpenAI的完成原因转换为Claude的停止原因
/// </summary>
public static string GetClaudeStopReason(string? openAIFinishReason)
{
return openAIFinishReason switch
{
"stop" => "end_turn",
"length" => "max_tokens",
"tool_calls" => "tool_use",
"content_filter" => "stop_sequence",
_ => "end_turn"
};
}
/// <summary>
/// 将OpenAI响应转换为Claude响应格式
/// </summary>
public static AnthropicChatCompletionDto ConvertOpenAIToClaude(ThorChatCompletionsResponse openAIResponse,
AnthropicInput originalRequest)
{
var claudeResponse = new AnthropicChatCompletionDto
{
id = openAIResponse.Id,
type = "message",
role = "assistant",
model = openAIResponse.Model ?? originalRequest.Model,
stop_reason = GetClaudeStopReason(openAIResponse.Choices?.FirstOrDefault()?.FinishReason),
stop_sequence = "",
content = []
};
if (openAIResponse.Choices is { Count: > 0 })
{
var choice = openAIResponse.Choices.First();
var contents = new List<AnthropicChatCompletionDtoContent>();
if (!string.IsNullOrEmpty(choice.Message.Content) && !string.IsNullOrEmpty(choice.Message.ReasoningContent))
{
contents.Add(new AnthropicChatCompletionDtoContent
{
type = "thinking",
Thinking = choice.Message.ReasoningContent
});
contents.Add(new AnthropicChatCompletionDtoContent
{
type = "text",
text = choice.Message.Content
});
}
else
{
// 处理思维内容
if (!string.IsNullOrEmpty(choice.Message.ReasoningContent))
contents.Add(new AnthropicChatCompletionDtoContent
{
type = "thinking",
Thinking = choice.Message.ReasoningContent
});
// 处理文本内容
if (!string.IsNullOrEmpty(choice.Message.Content))
contents.Add(new AnthropicChatCompletionDtoContent
{
type = "text",
text = choice.Message.Content
});
}
// 处理工具调用
if (choice.Message.ToolCalls is { Count: > 0 })
contents.AddRange(choice.Message.ToolCalls.Select(toolCall => new AnthropicChatCompletionDtoContent
{
type = "tool_use", id = toolCall.Id, name = toolCall.Function?.Name,
input = JsonSerializer.Deserialize<object>(toolCall.Function?.Arguments ?? "{}")
}));
claudeResponse.content = contents.ToArray();
}
// 处理使用情况统计 - 确保始终提供Usage信息
claudeResponse.Usage = new AnthropicCompletionDtoUsage
{
InputTokens = openAIResponse.Usage?.PromptTokens ?? 0,
OutputTokens = (int?)(openAIResponse.Usage?.CompletionTokens ?? 0),
CacheCreationInputTokens = openAIResponse.Usage?.PromptTokensDetails?.CachedTokens ?? 0,
CacheReadInputTokens = openAIResponse.Usage?.PromptTokensDetails?.CachedTokens ?? 0
};
return claudeResponse;
}
/// <summary>
/// 转换Anthropic工具选择为Thor工具选择
/// </summary>
public static ThorToolChoice ConvertAnthropicToolChoiceToThor(AnthropicTooChoiceInput anthropicToolChoice)
{
return new ThorToolChoice
{
Type = anthropicToolChoice.Type ?? "auto",
Function = anthropicToolChoice.Name != null
? new ThorToolChoiceFunctionTool { Name = anthropicToolChoice.Name }
: null
};
}
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
public class OpenAiResponsesInput
{
[JsonPropertyName("stream")] public bool? Stream { get; set; }
[JsonPropertyName("model")] public string Model { get; set; }
[JsonPropertyName("input")] public JsonElement Input { get; set; }
[JsonPropertyName("max_output_tokens")]
public int? MaxOutputTokens { get; set; }
[JsonPropertyName("max_tool_calls")] public JsonElement? MaxToolCalls { get; set; }
[JsonPropertyName("instructions")] public string? Instructions { get; set; }
[JsonPropertyName("metadata")] public JsonElement? Metadata { get; set; }
[JsonPropertyName("parallel_tool_calls")]
public bool? ParallelToolCalls { get; set; }
[JsonPropertyName("previous_response_id")]
public string? PreviousResponseId { get; set; }
[JsonPropertyName("prompt")] public JsonElement? Prompt { get; set; }
[JsonPropertyName("prompt_cache_key")] public string? PromptCacheKey { get; set; }
[JsonPropertyName("prompt_cache_retention")]
public string? PromptCacheRetention { get; set; }
[JsonPropertyName("reasoning")] public JsonElement? Reasoning { get; set; }
[JsonPropertyName("safety_identifier")]
public string? SafetyIdentifier { get; set; }
[JsonPropertyName("service_tier")] public string? ServiceTier { get; set; }
[JsonPropertyName("store")] public bool? Store { get; set; }
[JsonPropertyName("stream_options")] public JsonElement? StreamOptions { get; set; }
[JsonPropertyName("temperature")] public decimal? Temperature { get; set; }
[JsonPropertyName("text")] public JsonElement? Text { get; set; }
[JsonPropertyName("tool_choice")] public JsonElement? ToolChoice { get; set; }
[JsonPropertyName("tools")] public JsonElement? Tools { get; set; }
[JsonPropertyName("top_logprobs")] public int? TopLogprobs { get; set; }
[JsonPropertyName("top_p")] public decimal? TopP { get; set; }
[JsonPropertyName("truncation")] public string? Truncation { get; set; }
}

View File

@@ -0,0 +1,91 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
public class OpenAiResponsesOutput
{
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("object")]
public string? Object { get; set; }
[JsonPropertyName("created_at")]
public long CreatedAt { get; set; }
[JsonPropertyName("status")]
public string? Status { get; set; }
[JsonPropertyName("error")]
public dynamic? Error { get; set; }
[JsonPropertyName("incomplete_details")]
public dynamic? IncompleteDetails { get; set; }
[JsonPropertyName("instructions")]
public dynamic? Instructions { get; set; }
[JsonPropertyName("max_output_tokens")]
public dynamic? MaxOutputTokens { get; set; }
[JsonPropertyName("model")]
public string? Model { get; set; }
// output 是复杂对象
[JsonPropertyName("output")]
public List<dynamic>? Output { get; set; }
[JsonPropertyName("parallel_tool_calls")]
public bool ParallelToolCalls { get; set; }
[JsonPropertyName("previous_response_id")]
public dynamic? PreviousResponseId { get; set; }
[JsonPropertyName("reasoning")]
public dynamic? Reasoning { get; set; }
[JsonPropertyName("store")]
public bool Store { get; set; }
[JsonPropertyName("temperature")]
public double Temperature { get; set; }
[JsonPropertyName("text")]
public dynamic? Text { get; set; }
[JsonPropertyName("tool_choice")]
public string? ToolChoice { get; set; }
[JsonPropertyName("tools")]
public List<dynamic>? Tools { get; set; }
[JsonPropertyName("top_p")]
public double TopP { get; set; }
[JsonPropertyName("truncation")]
public string? Truncation { get; set; }
// usage 为唯一强类型
[JsonPropertyName("usage")]
public OpenAiResponsesUsageOutput? Usage { get; set; }
[JsonPropertyName("user")]
public dynamic? User { get; set; }
[JsonPropertyName("metadata")]
public dynamic? Metadata { get; set; }
public void SupplementalMultiplier(decimal multiplier)
{
if (this.Usage is not null)
{
this.Usage.InputTokens =
(int)Math.Round((this.Usage?.InputTokens ?? 0) * multiplier);
this.Usage.OutputTokens =
(int)Math.Round((this.Usage?.OutputTokens ?? 0) * multiplier);
}
}
}
public class OpenAiResponsesUsageOutput
{
[JsonPropertyName("input_tokens")]
public int InputTokens { get; set; }
[JsonPropertyName("input_tokens_details")]
public OpenAiResponsesInputTokensDetails? InputTokensDetails { get; set; }
[JsonPropertyName("output_tokens")]
public int OutputTokens { get; set; }
[JsonPropertyName("output_tokens_details")]
public OpenAiResponsesOutputTokensDetails? OutputTokensDetails { get; set; }
[JsonPropertyName("total_tokens")]
public int TotalTokens { get; set; }
}
public class OpenAiResponsesInputTokensDetails
{
[JsonPropertyName("cached_tokens")]
public int CachedTokens { get; set; }
}
public class OpenAiResponsesOutputTokensDetails
{
[JsonPropertyName("reasoning_tokens")]
public int ReasoningTokens { get; set; }
}

View File

@@ -1,7 +1,15 @@
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
using System.ComponentModel;
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
public enum ModelApiTypeEnum
{
[Description("OpenAI")]
OpenAi,
Claude
[Description("Claude")]
Claude,
[Description("Response")]
Response
}

View File

@@ -1,9 +1,15 @@
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
using System.ComponentModel;
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
public enum ModelTypeEnum
{
[Description("聊天")]
Chat = 0,
[Description("图片")]
Image = 1,
Embedding = 2,
PremiumChat = 3
[Description("嵌入")]
Embedding = 2
}

View File

@@ -0,0 +1,27 @@
using System.ComponentModel;
using System.Reflection;
namespace Yi.Framework.AiHub.Domain.Shared.Extensions;
/// <summary>
/// 枚举扩展方法
/// </summary>
public static class EnumExtensions
{
/// <summary>
/// 获取枚举的Description特性值
/// </summary>
/// <param name="value">枚举值</param>
/// <returns>Description特性值如果没有则返回枚举名称</returns>
public static string GetDescription(this Enum value)
{
var field = value.GetType().GetField(value.ToString());
if (field == null)
{
return value.ToString();
}
var attribute = field.GetCustomAttribute<DescriptionAttribute>();
return attribute?.Description ?? value.ToString();
}
}

View File

@@ -0,0 +1,279 @@
using System.Text.Json;
namespace Yi.Framework.AiHub.Domain.Shared.Extensions;
public static class JsonElementExtensions
{
#region 访
/// <summary>
/// 链式获取深层属性,支持对象属性和数组索引
/// </summary>
/// <example>
/// root.GetPath("user", "addresses", 0, "city")
/// </example>
public static JsonElement? GetPath(this JsonElement element, params object[] path)
{
JsonElement current = element;
foreach (var key in path)
{
switch (key)
{
case string propertyName:
if (current.ValueKind != JsonValueKind.Object ||
!current.TryGetProperty(propertyName, out current))
return null;
break;
case int index:
if (current.ValueKind != JsonValueKind.Array ||
index < 0 || index >= current.GetArrayLength())
return null;
current = current[index];
break;
default:
return null;
}
}
return current;
}
/// <summary>
/// 安全获取对象属性
/// </summary>
public static JsonElement? Get(this JsonElement element, string propertyName)
{
if (element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(propertyName, out var value))
return value;
return null;
}
/// <summary>
/// 安全获取数组元素
/// </summary>
public static JsonElement? Get(this JsonElement element, int index)
{
if (element.ValueKind == JsonValueKind.Array &&
index >= 0 && index < element.GetArrayLength())
return element[index];
return null;
}
/// <summary>
/// 链式安全获取对象属性
/// </summary>
public static JsonElement? Get(this JsonElement? element, string propertyName)
=> element?.Get(propertyName);
/// <summary>
/// 链式安全获取数组元素
/// </summary>
public static JsonElement? Get(this JsonElement? element, int index)
=> element?.Get(index);
#endregion
#region
public static string? GetString(this JsonElement? element, string? defaultValue = null)
=> element?.ValueKind == JsonValueKind.String ? element.Value.GetString() : defaultValue;
public static int GetInt(this JsonElement? element, int defaultValue = 0)
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetInt32() : defaultValue;
public static long GetLong(this JsonElement? element, long defaultValue = 0)
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetInt64() : defaultValue;
public static double GetDouble(this JsonElement? element, double defaultValue = 0)
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetDouble() : defaultValue;
public static decimal GetDecimal(this JsonElement? element, decimal defaultValue = 0)
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetDecimal() : defaultValue;
public static bool GetBool(this JsonElement? element, bool defaultValue = false)
=> element?.ValueKind is JsonValueKind.True or JsonValueKind.False
? element.Value.GetBoolean()
: defaultValue;
public static DateTime GetDateTime(this JsonElement? element, DateTime defaultValue = default)
=> element?.ValueKind == JsonValueKind.String && element.Value.TryGetDateTime(out var dt)
? dt
: defaultValue;
public static Guid GetGuid(this JsonElement? element, Guid defaultValue = default)
=> element?.ValueKind == JsonValueKind.String && element.Value.TryGetGuid(out var guid)
? guid
: defaultValue;
#endregion
#region
public static int? GetIntOrNull(this JsonElement? element)
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetInt32() : null;
public static long? GetLongOrNull(this JsonElement? element)
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetInt64() : null;
public static double? GetDoubleOrNull(this JsonElement? element)
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetDouble() : null;
public static decimal? GetDecimalOrNull(this JsonElement? element)
=> element?.ValueKind == JsonValueKind.Number ? element.Value.GetDecimal() : null;
public static bool? GetBoolOrNull(this JsonElement? element)
=> element?.ValueKind is JsonValueKind.True or JsonValueKind.False
? element.Value.GetBoolean()
: null;
public static DateTime? GetDateTimeOrNull(this JsonElement? element)
=> element?.ValueKind == JsonValueKind.String && element.Value.TryGetDateTime(out var dt)
? dt
: null;
public static Guid? GetGuidOrNull(this JsonElement? element)
=> element?.ValueKind == JsonValueKind.String && element.Value.TryGetGuid(out var guid)
? guid
: null;
#endregion
#region
/// <summary>
/// 安全获取数组,不存在返回空数组
/// </summary>
public static IEnumerable<JsonElement> GetArray(this JsonElement? element)
{
if (element?.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.Value.EnumerateArray())
yield return item;
}
}
/// <summary>
/// 获取数组长度
/// </summary>
public static int GetArrayLength(this JsonElement? element)
=> element?.ValueKind == JsonValueKind.Array ? element.Value.GetArrayLength() : 0;
/// <summary>
/// 数组转 List
/// </summary>
public static List<string?> ToStringList(this JsonElement? element)
=> element.GetArray().Select(e => e.GetString()).ToList();
public static List<int> ToIntList(this JsonElement? element)
=> element.GetArray()
.Where(e => e.ValueKind == JsonValueKind.Number)
.Select(e => e.GetInt32())
.ToList();
#endregion
#region
/// <summary>
/// 安全枚举对象属性
/// </summary>
public static IEnumerable<JsonProperty> GetProperties(this JsonElement? element)
{
if (element?.ValueKind == JsonValueKind.Object)
{
foreach (var prop in element.Value.EnumerateObject())
yield return prop;
}
}
/// <summary>
/// 获取所有属性名
/// </summary>
public static IEnumerable<string> GetPropertyNames(this JsonElement? element)
=> element.GetProperties().Select(p => p.Name);
/// <summary>
/// 判断是否包含某属性
/// </summary>
public static bool HasProperty(this JsonElement? element, string propertyName)
=> element?.ValueKind == JsonValueKind.Object &&
element.Value.TryGetProperty(propertyName, out _);
#endregion
#region
public static bool IsNull(this JsonElement? element)
=> element == null || element.Value.ValueKind == JsonValueKind.Null;
public static bool IsNullOrUndefined(this JsonElement? element)
=> element == null || element.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined;
public static bool IsObject(this JsonElement? element)
=> element?.ValueKind == JsonValueKind.Object;
public static bool IsArray(this JsonElement? element)
=> element?.ValueKind == JsonValueKind.Array;
public static bool IsString(this JsonElement? element)
=> element?.ValueKind == JsonValueKind.String;
public static bool IsNumber(this JsonElement? element)
=> element?.ValueKind == JsonValueKind.Number;
public static bool IsBool(this JsonElement? element)
=> element?.ValueKind is JsonValueKind.True or JsonValueKind.False;
public static bool Exists(this JsonElement? element)
=> element != null && element.Value.ValueKind != JsonValueKind.Undefined;
#endregion
#region
/// <summary>
/// 反序列化为指定类型
/// </summary>
public static T? Deserialize<T>(this JsonElement? element, JsonSerializerOptions? options = null)
=> element.HasValue ? element.Value.Deserialize<T>(options) : default;
/// <summary>
/// 反序列化为指定类型,带默认值
/// </summary>
public static T Deserialize<T>(this JsonElement? element, T defaultValue, JsonSerializerOptions? options = null)
=> element.HasValue ? element.Value.Deserialize<T>(options) ?? defaultValue : defaultValue;
#endregion
#region /
/// <summary>
/// 转换为 Dictionary
/// </summary>
public static Dictionary<string, JsonElement>? ToDictionary(this JsonElement? element)
{
if (element?.ValueKind != JsonValueKind.Object)
return null;
var dict = new Dictionary<string, JsonElement>();
foreach (var prop in element.Value.EnumerateObject())
dict[prop.Name] = prop.Value;
return dict;
}
#endregion
#region
/// <summary>
/// 获取原始 JSON 字符串
/// </summary>
public static string? GetRawText(this JsonElement? element)
=> element?.GetRawText();
#endregion
}

View File

@@ -0,0 +1,30 @@
using System.Text.Json;
using Yi.Framework.AiHub.Domain.Shared.Dtos;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
namespace Yi.Framework.AiHub.Domain.AiGateWay;
public interface IOpenAiResponseService
{
/// <summary>
/// 响应-流式
/// </summary>
/// <param name="aiModelDescribe"></param>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public IAsyncEnumerable<(string, JsonElement?)> ResponsesStreamAsync(AiModelDescribe aiModelDescribe,
OpenAiResponsesInput input,
CancellationToken cancellationToken);
/// <summary>
/// 响应-非流式
/// </summary>
/// <param name="aiModelDescribe"></param>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public Task<OpenAiResponsesOutput> ResponsesAsync(AiModelDescribe aiModelDescribe,
OpenAiResponsesInput input,
CancellationToken cancellationToken);
}

View File

@@ -1,313 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Volo.Abp.DependencyInjection;
using Yi.Framework.AiHub.Domain.Shared.Dtos;
using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorCustomOpenAI.Chats;
/// <summary>
/// OpenAI到Claude适配器服务
/// 将Claude格式的请求转换为OpenAI格式然后将OpenAI的响应转换为Claude格式
/// </summary>
public class CustomOpenAIAnthropicChatCompletionsService(
IAbpLazyServiceProvider serviceProvider,
ILogger<CustomOpenAIAnthropicChatCompletionsService> logger)
: IAnthropicChatCompletionService
{
private IChatCompletionService GetChatCompletionService()
{
return serviceProvider.GetRequiredKeyedService<IChatCompletionService>(nameof(OpenAiChatCompletionsService));
}
public async Task<AnthropicChatCompletionDto> ChatCompletionsAsync(AiModelDescribe aiModelDescribe,
AnthropicInput request,
CancellationToken cancellationToken = default)
{
// 转换请求格式Claude -> OpenAI
var openAIRequest = AnthropicToOpenAi.ConvertAnthropicToOpenAi(request);
if (openAIRequest.Model.StartsWith("gpt-5"))
{
openAIRequest.MaxCompletionTokens = request.MaxTokens;
openAIRequest.MaxTokens = null;
}
else if (openAIRequest.Model.StartsWith("o3-mini") || openAIRequest.Model.StartsWith("o4-mini"))
{
openAIRequest.MaxCompletionTokens = request.MaxTokens;
openAIRequest.MaxTokens = null;
openAIRequest.Temperature = null;
}
// 调用OpenAI服务
var openAIResponse =
await GetChatCompletionService().CompleteChatAsync(aiModelDescribe,openAIRequest, cancellationToken);
// 转换响应格式OpenAI -> Claude
var claudeResponse = AnthropicToOpenAi.ConvertOpenAIToClaude(openAIResponse, request);
return claudeResponse;
}
public async IAsyncEnumerable<(string, AnthropicStreamDto?)> StreamChatCompletionsAsync(AiModelDescribe aiModelDescribe,
AnthropicInput request,
CancellationToken cancellationToken = default)
{
var openAIRequest = AnthropicToOpenAi.ConvertAnthropicToOpenAi(request);
openAIRequest.Stream = true;
if (openAIRequest.Model.StartsWith("gpt-5"))
{
openAIRequest.MaxCompletionTokens = request.MaxTokens;
openAIRequest.MaxTokens = null;
}
else if (openAIRequest.Model.StartsWith("o3-mini") || openAIRequest.Model.StartsWith("o4-mini"))
{
openAIRequest.MaxCompletionTokens = request.MaxTokens;
openAIRequest.MaxTokens = null;
openAIRequest.Temperature = null;
}
var messageId = Guid.NewGuid().ToString();
var hasStarted = false;
var hasTextContentBlockStarted = false;
var hasThinkingContentBlockStarted = false;
var toolBlocksStarted = new Dictionary<int, bool>(); // 使用索引而不是ID
var toolCallIds = new Dictionary<int, string>(); // 存储每个索引对应的ID
var toolCallIndexToBlockIndex = new Dictionary<int, int>(); // 工具调用索引到块索引的映射
var accumulatedUsage = new AnthropicCompletionDtoUsage();
var isFinished = false;
var currentContentBlockType = ""; // 跟踪当前内容块类型
var currentBlockIndex = 0; // 跟踪当前块索引
var lastContentBlockType = ""; // 跟踪最后一个内容块类型,用于确定停止原因
await foreach (var openAIResponse in GetChatCompletionService().CompleteChatStreamAsync(aiModelDescribe,openAIRequest,
cancellationToken))
{
// 发送message_start事件
if (!hasStarted && openAIResponse.Choices?.Count > 0 &&
openAIResponse.Choices.Any(x => x.Delta.ToolCalls?.Count > 0) == false)
{
hasStarted = true;
var messageStartEvent = AnthropicToOpenAi.CreateMessageStartEvent(messageId, request.Model);
yield return ("message_start", messageStartEvent);
}
// 更新使用情况统计
if (openAIResponse.Usage != null)
{
// 使用最新的token计数OpenAI通常在最后的响应中提供完整的统计
if (openAIResponse.Usage.PromptTokens.HasValue)
{
accumulatedUsage.InputTokens = openAIResponse.Usage.PromptTokens.Value;
}
if (openAIResponse.Usage.CompletionTokens.HasValue)
{
accumulatedUsage.OutputTokens = (int)openAIResponse.Usage.CompletionTokens.Value;
}
if (openAIResponse.Usage.PromptTokensDetails?.CachedTokens.HasValue == true)
{
accumulatedUsage.CacheReadInputTokens =
openAIResponse.Usage.PromptTokensDetails.CachedTokens.Value;
}
// 记录调试信息
logger.LogDebug("OpenAI Usage更新: Input={InputTokens}, Output={OutputTokens}, CacheRead={CacheRead}",
accumulatedUsage.InputTokens, accumulatedUsage.OutputTokens,
accumulatedUsage.CacheReadInputTokens);
}
if (openAIResponse.Choices is { Count: > 0 })
{
var choice = openAIResponse.Choices.First();
// 处理内容
if (!string.IsNullOrEmpty(choice.Delta?.Content))
{
// 如果当前有其他类型的内容块在运行,先结束它们
if (currentContentBlockType != "text" && !string.IsNullOrEmpty(currentContentBlockType))
{
var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
stopEvent.Index = currentBlockIndex;
yield return ("content_block_stop", stopEvent);
currentBlockIndex++; // 切换内容块时增加索引
currentContentBlockType = "";
}
// 发送content_block_start事件仅第一次
if (!hasTextContentBlockStarted || currentContentBlockType != "text")
{
hasTextContentBlockStarted = true;
currentContentBlockType = "text";
lastContentBlockType = "text";
var contentBlockStartEvent = AnthropicToOpenAi.CreateContentBlockStartEvent();
contentBlockStartEvent.Index = currentBlockIndex;
yield return ("content_block_start",
contentBlockStartEvent);
}
// 发送content_block_delta事件
var contentDeltaEvent = AnthropicToOpenAi.CreateContentBlockDeltaEvent(choice.Delta.Content);
contentDeltaEvent.Index = currentBlockIndex;
yield return ("content_block_delta",
contentDeltaEvent);
}
// 处理工具调用
if (choice.Delta?.ToolCalls is { Count: > 0 })
{
foreach (var toolCall in choice.Delta.ToolCalls)
{
var toolCallIndex = toolCall.Index; // 使用索引来标识工具调用
// 发送tool_use content_block_start事件
if (toolBlocksStarted.TryAdd(toolCallIndex, true))
{
// 如果当前有文本或thinking内容块在运行先结束它们
if (currentContentBlockType == "text" || currentContentBlockType == "thinking")
{
var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
stopEvent.Index = currentBlockIndex;
yield return ("content_block_stop", stopEvent);
currentBlockIndex++; // 增加块索引
}
// 如果当前有其他工具调用在运行,也需要结束它们
else if (currentContentBlockType == "tool_use")
{
var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
stopEvent.Index = currentBlockIndex;
yield return ("content_block_stop", stopEvent);
currentBlockIndex++; // 增加块索引
}
currentContentBlockType = "tool_use";
lastContentBlockType = "tool_use";
// 为此工具调用分配一个新的块索引
toolCallIndexToBlockIndex[toolCallIndex] = currentBlockIndex;
// 保存工具调用的ID如果有的话
if (!string.IsNullOrEmpty(toolCall.Id))
{
toolCallIds[toolCallIndex] = toolCall.Id;
}
else if (!toolCallIds.ContainsKey(toolCallIndex))
{
// 如果没有ID且之前也没有保存过生成一个新的ID
toolCallIds[toolCallIndex] = Guid.NewGuid().ToString();
}
var toolBlockStartEvent = AnthropicToOpenAi.CreateToolBlockStartEvent(
toolCallIds[toolCallIndex],
toolCall.Function?.Name);
toolBlockStartEvent.Index = currentBlockIndex;
yield return ("content_block_start",
toolBlockStartEvent);
}
// 如果有增量的参数发送content_block_delta事件
if (!string.IsNullOrEmpty(toolCall.Function?.Arguments))
{
var toolDeltaEvent =
AnthropicToOpenAi.CreateToolBlockDeltaEvent(toolCall.Function.Arguments);
// 使用该工具调用对应的块索引
toolDeltaEvent.Index = toolCallIndexToBlockIndex[toolCallIndex];
yield return ("content_block_delta",
toolDeltaEvent);
}
}
}
// 处理推理内容
if (!string.IsNullOrEmpty(choice.Delta?.ReasoningContent))
{
// 如果当前有其他类型的内容块在运行,先结束它们
if (currentContentBlockType != "thinking" && !string.IsNullOrEmpty(currentContentBlockType))
{
var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
stopEvent.Index = currentBlockIndex;
yield return ("content_block_stop", stopEvent);
currentBlockIndex++; // 增加块索引
currentContentBlockType = "";
}
// 对于推理内容,也需要发送对应的事件
if (!hasThinkingContentBlockStarted || currentContentBlockType != "thinking")
{
hasThinkingContentBlockStarted = true;
currentContentBlockType = "thinking";
lastContentBlockType = "thinking";
var thinkingBlockStartEvent = AnthropicToOpenAi.CreateThinkingBlockStartEvent();
thinkingBlockStartEvent.Index = currentBlockIndex;
yield return ("content_block_start",
thinkingBlockStartEvent);
}
var thinkingDeltaEvent =
AnthropicToOpenAi.CreateThinkingBlockDeltaEvent(choice.Delta.ReasoningContent);
thinkingDeltaEvent.Index = currentBlockIndex;
yield return ("content_block_delta",
thinkingDeltaEvent);
}
// 处理结束
if (!string.IsNullOrEmpty(choice.FinishReason) && !isFinished)
{
isFinished = true;
// 发送content_block_stop事件如果有活跃的内容块
if (!string.IsNullOrEmpty(currentContentBlockType))
{
var contentBlockStopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
contentBlockStopEvent.Index = currentBlockIndex;
yield return ("content_block_stop",
contentBlockStopEvent);
}
// 发送message_delta事件
var messageDeltaEvent = AnthropicToOpenAi.CreateMessageDeltaEvent(
AnthropicToOpenAi.GetStopReasonByLastContentType(choice.FinishReason, lastContentBlockType),
accumulatedUsage);
// 记录最终Usage统计
logger.LogDebug(
"流式响应结束最终Usage: Input={InputTokens}, Output={OutputTokens}, CacheRead={CacheRead}",
accumulatedUsage.InputTokens, accumulatedUsage.OutputTokens,
accumulatedUsage.CacheReadInputTokens);
yield return ("message_delta",
messageDeltaEvent);
// 发送message_stop事件
var messageStopEvent = AnthropicToOpenAi.CreateMessageStopEvent();
yield return ("message_stop",
messageStopEvent);
}
}
}
// 确保流正确结束
if (!isFinished)
{
if (!string.IsNullOrEmpty(currentContentBlockType))
{
var contentBlockStopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
contentBlockStopEvent.Index = currentBlockIndex;
yield return ("content_block_stop",
contentBlockStopEvent);
}
var messageDeltaEvent =
AnthropicToOpenAi.CreateMessageDeltaEvent(
AnthropicToOpenAi.GetStopReasonByLastContentType("end_turn", lastContentBlockType),
accumulatedUsage);
yield return ("message_delta", messageDeltaEvent);
var messageStopEvent = AnthropicToOpenAi.CreateMessageStopEvent();
yield return ("message_stop",
messageStopEvent);
}
}
}

View File

@@ -130,7 +130,7 @@ public sealed class OpenAiChatCompletionsService(ILogger<OpenAiChatCompletionsSe
using var openai =
Activity.Current?.Source.StartActivity("OpenAI 对话补全");
var response = await HttpClientFactory.GetHttpClient(options.Endpoint).PostJsonAsync(
var response = await httpClientFactory.CreateClient().PostJsonAsync(
options?.Endpoint.TrimEnd('/') + "/chat/completions",
chatCompletionCreate, options.ApiKey).ConfigureAwait(false);

View File

@@ -0,0 +1,123 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Yi.Framework.AiHub.Domain.AiGateWay.Exceptions;
using Yi.Framework.AiHub.Domain.Shared.Dtos;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorCustomOpenAI.Chats;
public class OpenAiResponseService(ILogger<OpenAiResponseService> logger,IHttpClientFactory httpClientFactory):IOpenAiResponseService
{
public async IAsyncEnumerable<(string, JsonElement?)> ResponsesStreamAsync(AiModelDescribe options, OpenAiResponsesInput input,
CancellationToken cancellationToken)
{
using var openai =
Activity.Current?.Source.StartActivity("OpenAi 响应");
var client = httpClientFactory.CreateClient();
var response = await client.HttpRequestRaw(options.Endpoint.TrimEnd('/') + "/responses", input, options.ApiKey);
openai?.SetTag("Model", input.Model);
openai?.SetTag("Response", response.StatusCode.ToString());
// 大于等于400的状态码都认为是异常
if (response.StatusCode >= HttpStatusCode.BadRequest)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError("OpenAI响应异常 请求地址:{Address}, StatusCode: {StatusCode} Response: {Response}",
options.Endpoint,
response.StatusCode, error);
throw new Exception("OpenAI响应异常" + response.StatusCode);
}
using var stream = new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken));
using StreamReader reader = new(await response.Content.ReadAsStreamAsync(cancellationToken));
string? line = string.Empty;
string? data = null;
string eventType = string.Empty;
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) != null)
{
line += Environment.NewLine;
if (line.StartsWith('{'))
{
logger.LogInformation("OpenAI响应异常 , StatusCode: {StatusCode} Response: {Response}", response.StatusCode,
line);
throw new Exception("OpenAI响应异常" + line);
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
if (line.StartsWith("event:"))
{
eventType = line;
continue;
}
if (!line.StartsWith(OpenAIConstant.Data)) continue;
data = line[OpenAIConstant.Data.Length..].Trim();
var result = JsonSerializer.Deserialize<JsonElement>(data,
ThorJsonSerializer.DefaultOptions);
yield return (eventType, result);
}
}
public async Task<OpenAiResponsesOutput> ResponsesAsync(AiModelDescribe options, OpenAiResponsesInput chatCompletionCreate,
CancellationToken cancellationToken)
{
using var openai =
Activity.Current?.Source.StartActivity("OpenAI 响应");
var response = await httpClientFactory.CreateClient().PostJsonAsync(
options?.Endpoint.TrimEnd('/') + "/responses",
chatCompletionCreate, options.ApiKey).ConfigureAwait(false);
openai?.SetTag("Model", chatCompletionCreate.Model);
openai?.SetTag("Response", response.StatusCode.ToString());
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new BusinessException("渠道未登录,请联系管理人员", "401");
}
// 如果限流则抛出限流异常
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
throw new ThorRateLimitException();
}
// 大于等于400的状态码都认为是异常
if (response.StatusCode >= HttpStatusCode.BadRequest)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError("OpenAI 响应异常 请求地址:{Address}, StatusCode: {StatusCode} Response: {Response}", options.Endpoint,
response.StatusCode, error);
throw new BusinessException("OpenAI响应异常", response.StatusCode.ToString());
}
var result =
await response.Content.ReadFromJsonAsync<OpenAiResponsesOutput>(
cancellationToken: cancellationToken).ConfigureAwait(false);
return result;
}
}

View File

@@ -65,4 +65,19 @@ public class AiModelEntity : Entity<Guid>, IOrderNum, ISoftDelete
/// 模型倍率
/// </summary>
public decimal Multiplier { get; set; } = 1;
/// <summary>
/// 模型显示倍率
/// </summary>
public decimal MultiplierShow { get; set; } = 1;
/// <summary>
/// 供应商分组名称(如OpenAI、Anthropic、Google等)
/// </summary>
public string? ProviderName { get; set; }
/// <summary>
/// 模型图标URL
/// </summary>
public string? IconUrl { get; set; }
}

View File

@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using Volo.Abp.Domain.Services;
using Yi.Framework.AiHub.Domain.AiGateWay;
@@ -18,7 +19,9 @@ using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Embeddings;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Images;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses;
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.AiHub.Domain.Shared.Extensions;
using Yi.Framework.Core.Extensions;
using Yi.Framework.SqlSugarCore.Abstractions;
using JsonSerializer = System.Text.Json.JsonSerializer;
@@ -89,30 +92,7 @@ public class AiGateWayManager : DomainService
return aiModelDescribe;
}
/// <summary>
/// 聊天完成-流式
/// </summary>
/// <param name="request"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async IAsyncEnumerable<ThorChatCompletionsResponse> CompleteChatStreamAsync(
ThorChatCompletionsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
_specialCompatible.Compatible(request);
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.OpenAi, request.Model);
var chatService =
LazyServiceProvider.GetRequiredKeyedService<IChatCompletionService>(modelDescribe.HandlerName);
await foreach (var result in chatService.CompleteChatStreamAsync(modelDescribe, request, cancellationToken))
{
result.SupplementalMultiplier(modelDescribe.Multiplier);
yield return result;
}
}
/// <summary>
/// 聊天完成-非流式
/// </summary>
@@ -174,6 +154,7 @@ public class AiGateWayManager : DomainService
await response.WriteAsJsonAsync(data, cancellationToken);
}
/// <summary>
/// 聊天完成-缓存处理
/// </summary>
@@ -199,8 +180,12 @@ public class AiGateWayManager : DomainService
response.Headers.TryAdd("Connection", "keep-alive");
var gateWay = LazyServiceProvider.GetRequiredService<AiGateWayManager>();
var completeChatResponse = gateWay.CompleteChatStreamAsync(request, cancellationToken);
_specialCompatible.Compatible(request);
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.OpenAi, request.Model);
var chatService =
LazyServiceProvider.GetRequiredKeyedService<IChatCompletionService>(modelDescribe.HandlerName);
var completeChatResponse = chatService.CompleteChatStreamAsync(modelDescribe,request, cancellationToken);
var tokenUsage = new ThorUsageResponse();
//缓存队列算法
@@ -242,6 +227,7 @@ public class AiGateWayManager : DomainService
{
await foreach (var data in completeChatResponse)
{
data.SupplementalMultiplier(modelDescribe.Multiplier);
if (data.Usage is not null && (data.Usage.CompletionTokens > 0 || data.Usage.OutputTokens > 0))
{
tokenUsage = data.Usage;
@@ -314,8 +300,8 @@ public class AiGateWayManager : DomainService
}
}
}
/// <summary>
/// 图片生成
/// </summary>
@@ -383,8 +369,8 @@ public class AiGateWayManager : DomainService
throw new UserFriendlyException(errorContent);
}
}
/// <summary>
/// 向量生成
/// </summary>
@@ -496,30 +482,7 @@ public class AiGateWayManager : DomainService
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>
/// Anthropic聊天完成-非流式
@@ -546,15 +509,15 @@ public class AiGateWayManager : DomainService
var chatService =
LazyServiceProvider.GetRequiredKeyedService<IAnthropicChatCompletionService>(modelDescribe.HandlerName);
var data = await chatService.ChatCompletionsAsync(modelDescribe, request, cancellationToken);
data.SupplementalMultiplier(modelDescribe.Multiplier);
if (userId is not null)
{
await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId,
new MessageInputDto
{
Content = sessionId is null ? "不予存储" : request.Messages?.FirstOrDefault()?.Content ?? string.Empty,
Content = "不予存储",
ModelId = request.Model,
TokenUsage = data.TokenUsage,
}, tokenId);
@@ -562,7 +525,7 @@ public class AiGateWayManager : DomainService
await _aiMessageManager.CreateSystemMessageAsync(userId.Value, sessionId,
new MessageInputDto
{
Content = sessionId is null ? "不予存储" : data.content?.FirstOrDefault()?.text,
Content = "不予存储",
ModelId = request.Model,
TokenUsage = data.TokenUsage
}, tokenId);
@@ -580,6 +543,7 @@ public class AiGateWayManager : DomainService
await response.WriteAsJsonAsync(data, cancellationToken);
}
/// <summary>
/// Anthropic聊天完成-缓存处理
/// </summary>
@@ -603,16 +567,20 @@ public class AiGateWayManager : DomainService
response.ContentType = "text/event-stream;charset=utf-8;";
response.Headers.TryAdd("Cache-Control", "no-cache");
response.Headers.TryAdd("Connection", "keep-alive");
var gateWay = LazyServiceProvider.GetRequiredService<AiGateWayManager>();
var completeChatResponse = gateWay.AnthropicCompleteChatStreamAsync(request, cancellationToken);
_specialCompatible.AnthropicCompatible(request);
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.Claude, request.Model);
var chatService =
LazyServiceProvider.GetRequiredKeyedService<IAnthropicChatCompletionService>(modelDescribe.HandlerName);
var completeChatResponse = chatService.StreamChatCompletionsAsync(modelDescribe,request, cancellationToken);
ThorUsageResponse? tokenUsage = null;
StringBuilder backupSystemContent = new StringBuilder();
try
{
await foreach (var responseResult in completeChatResponse)
{
responseResult.Item2.SupplementalMultiplier(modelDescribe.Multiplier);
//message_start是为了保底机制
if (responseResult.Item1.Contains("message_delta") || responseResult.Item1.Contains("message_start"))
{
@@ -634,7 +602,7 @@ public class AiGateWayManager : DomainService
await _aiMessageManager.CreateUserMessageAsync(userId, sessionId,
new MessageInputDto
{
Content = sessionId is null ? "不予存储" : request.Messages?.LastOrDefault()?.Content ?? string.Empty,
Content = "不予存储",
ModelId = request.Model,
TokenUsage = tokenUsage,
}, tokenId);
@@ -642,7 +610,7 @@ public class AiGateWayManager : DomainService
await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId,
new MessageInputDto
{
Content = sessionId is null ? "不予存储" : backupSystemContent.ToString(),
Content = "不予存储",
ModelId = request.Model,
TokenUsage = tokenUsage
}, 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 IdWeb端传null或Guid.Empty</param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task OpenAiResponsesStreamForStatisticsAsync(
HttpContext httpContext,
OpenAiResponsesInput request,
Guid? userId = null,
Guid? sessionId = null,
Guid? tokenId = null,
CancellationToken cancellationToken = default)
{
var response = httpContext.Response;
// 设置响应头,声明是 SSE 流
response.ContentType = "text/event-stream;charset=utf-8;";
response.Headers.TryAdd("Cache-Control", "no-cache");
response.Headers.TryAdd("Connection", "keep-alive");
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.Response, request.Model);
var chatService =
LazyServiceProvider.GetRequiredKeyedService<IOpenAiResponseService>(modelDescribe.HandlerName);
var completeChatResponse = chatService.ResponsesStreamAsync(modelDescribe,request, cancellationToken);
ThorUsageResponse? tokenUsage = null;
try
{
await foreach (var responseResult in completeChatResponse)
{
//message_start是为了保底机制
if (responseResult.Item1.Contains("response.completed"))
{
var obj = responseResult.Item2!.Value;
int inputTokens = obj.GetPath("response","usage","input_tokens").GetInt();
int outputTokens = obj.GetPath("response","usage","output_tokens").GetInt();
inputTokens=Convert.ToInt32(inputTokens * modelDescribe.Multiplier);
outputTokens=Convert.ToInt32(outputTokens * modelDescribe.Multiplier);
tokenUsage = new ThorUsageResponse
{
PromptTokens =inputTokens,
InputTokens = inputTokens,
OutputTokens = outputTokens,
CompletionTokens = outputTokens,
TotalTokens = inputTokens+outputTokens,
};
}
await WriteAsEventStreamDataAsync(httpContext, responseResult.Item1, responseResult.Item2,
cancellationToken);
}
}
catch (Exception e)
{
_logger.LogError(e, $"Ai响应异常");
var errorContent = $"响应Ai异常异常信息\n当前Ai模型{request.Model}\n异常信息{e.Message}\n异常堆栈:{e}";
throw new UserFriendlyException(errorContent);
}
await _aiMessageManager.CreateUserMessageAsync(userId, sessionId,
new MessageInputDto
{
Content = "不予存储" ,
ModelId = request.Model,
TokenUsage = tokenUsage,
}, tokenId);
await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId,
new MessageInputDto
{
Content = "不予存储" ,
ModelId = request.Model,
TokenUsage = tokenUsage
}, tokenId);
await _usageStatisticsManager.SetUsageAsync(userId, request.Model, tokenUsage, tokenId);
// 扣减尊享token包用量
if (userId.HasValue && tokenUsage is not null)
{
var totalTokens = tokenUsage.TotalTokens ?? 0;
if (tokenUsage.TotalTokens > 0)
{
await PremiumPackageManager.TryConsumeTokensAsync(userId.Value, totalTokens);
}
}
}
#region Http响应
private static readonly byte[] EventPrefix = "event: "u8.ToArray();
private static readonly byte[] DataPrefix = "data: "u8.ToArray();
@@ -675,7 +803,6 @@ public class AiGateWayManager : DomainService
string @event,
T value,
CancellationToken cancellationToken = default)
where T : class
{
var response = context.Response;
var bodyStream = response.Body;

View File

@@ -48,14 +48,18 @@ namespace Yi.Framework.AiHub.Domain
#endregion
#region Anthropic ChatCompletion
services.AddKeyedTransient<IAnthropicChatCompletionService, CustomOpenAIAnthropicChatCompletionsService>(
nameof(CustomOpenAIAnthropicChatCompletionsService));
services.AddKeyedTransient<IAnthropicChatCompletionService, AnthropicChatCompletionsService>(
nameof(AnthropicChatCompletionsService));
#endregion
#region OpenAi Response
services.AddKeyedTransient<IOpenAiResponseService, OpenAiResponseService>(
nameof(OpenAiResponseService));
#endregion
#region Image

View File

@@ -3,7 +3,6 @@ using Mapster;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SqlSugar;
using TencentCloud.Pds.V20210701.Models;
using Volo.Abp;
using Volo.Abp.Application.Dtos;
using Volo.Abp.EventBus.Local;

View File

@@ -1,5 +1,4 @@
using TencentCloud.Tbm.V20180129.Models;
using Volo.Abp.DependencyInjection;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Entities.Events;
using Volo.Abp.EventBus;
using Volo.Abp.EventBus.Local;

View File

@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using Microsoft.VisualBasic;
using TencentCloud.Mna.V20210119.Models;
using Volo.Abp.Application.Services;
using Volo.Abp.Caching;
using Volo.Abp.DependencyInjection;

View File

@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using SqlSugar;
using TencentCloud.Tcr.V20190924.Models;
using Volo.Abp;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Caching;

View File

@@ -1,59 +1,59 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using TencentCloud.Common.Profile;
using TencentCloud.Common;
using TencentCloud.Sms.V20210111.Models;
using TencentCloud.Sms.V20210111;
using Volo.Abp.Domain.Services;
using Microsoft.Extensions.Logging;
namespace Yi.Framework.Rbac.Domain.Managers
{
public class TencentCloudManager : DomainService
{
private ILogger<TencentCloudManager> _logger;
public TencentCloudManager(ILogger<TencentCloudManager> logger)
{
_logger= logger;
}
public async Task SendSmsAsync()
{
try
{
// 实例化一个认证对象,入参需要传入腾讯云账户 SecretId 和 SecretKey此处还需注意密钥对的保密
// 代码泄露可能会导致 SecretId 和 SecretKey 泄露并威胁账号下所有资源的安全性。以下代码示例仅供参考建议采用更安全的方式来使用密钥请参见https://cloud.tencent.com/document/product/1278/85305
// 密钥可前往官网控制台 https://console.cloud.tencent.com/cam/capi 进行获取
Credential cred = new Credential
{
SecretId = "SecretId",
SecretKey = "SecretKey"
};
// 实例化一个client选项可选的没有特殊需求可以跳过
ClientProfile clientProfile = new ClientProfile();
// 实例化一个http选项可选的没有特殊需求可以跳过
HttpProfile httpProfile = new HttpProfile();
httpProfile.Endpoint = ("sms.tencentcloudapi.com");
clientProfile.HttpProfile = httpProfile;
// 实例化要请求产品的client对象,clientProfile是可选的
SmsClient client = new SmsClient(cred, "", clientProfile);
// 实例化一个请求对象,每个接口都会对应一个request对象
SendSmsRequest req = new SendSmsRequest();
// 返回的resp是一个SendSmsResponse的实例与请求对象对应
SendSmsResponse resp = await client.SendSms(req);
// 输出json格式的字符串回包
_logger.LogInformation("腾讯云Sms返回"+AbstractModel.ToJsonString(resp));
}
catch (Exception e)
{
_logger.LogError(e,e.ToString());
}
}
}
}
// using System;
// using System.Collections.Generic;
// using System.Linq;
// using System.Text;
// using System.Threading.Tasks;
// using TencentCloud.Common.Profile;
// using TencentCloud.Common;
// using TencentCloud.Sms.V20210111.Models;
// using TencentCloud.Sms.V20210111;
// using Volo.Abp.Domain.Services;
// using Microsoft.Extensions.Logging;
//
// namespace Yi.Framework.Rbac.Domain.Managers
// {
// public class TencentCloudManager : DomainService
// {
// private ILogger<TencentCloudManager> _logger;
// public TencentCloudManager(ILogger<TencentCloudManager> logger)
// {
// _logger= logger;
// }
//
// public async Task SendSmsAsync()
// {
//
// try
// {
// // 实例化一个认证对象,入参需要传入腾讯云账户 SecretId 和 SecretKey此处还需注意密钥对的保密
// // 代码泄露可能会导致 SecretId 和 SecretKey 泄露并威胁账号下所有资源的安全性。以下代码示例仅供参考建议采用更安全的方式来使用密钥请参见https://cloud.tencent.com/document/product/1278/85305
// // 密钥可前往官网控制台 https://console.cloud.tencent.com/cam/capi 进行获取
// Credential cred = new Credential
// {
// SecretId = "SecretId",
// SecretKey = "SecretKey"
// };
// // 实例化一个client选项可选的没有特殊需求可以跳过
// ClientProfile clientProfile = new ClientProfile();
// // 实例化一个http选项可选的没有特殊需求可以跳过
// HttpProfile httpProfile = new HttpProfile();
// httpProfile.Endpoint = ("sms.tencentcloudapi.com");
// clientProfile.HttpProfile = httpProfile;
//
// // 实例化要请求产品的client对象,clientProfile是可选的
// SmsClient client = new SmsClient(cred, "", clientProfile);
// // 实例化一个请求对象,每个接口都会对应一个request对象
// SendSmsRequest req = new SendSmsRequest();
//
// // 返回的resp是一个SendSmsResponse的实例与请求对象对应
// SendSmsResponse resp = await client.SendSms(req);
// // 输出json格式的字符串回包
// _logger.LogInformation("腾讯云Sms返回"+AbstractModel.ToJsonString(resp));
// }
// catch (Exception e)
// {
// _logger.LogError(e,e.ToString());
// }
// }
// }
// }

View File

@@ -8,7 +8,7 @@
<PackageReference Include="IPTools.China" Version="1.6.0" />
<PackageReference Include="TencentCloudSDK" Version="3.0.966" />
<!-- <PackageReference Include="TencentCloudSDK" Version="3.0.966" />-->
<PackageReference Include="UAParser" Version="3.1.47" />

View File

@@ -32,6 +32,7 @@ using Yi.Framework.AiHub.Application;
using Yi.Framework.AiHub.Application.Services;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.Chat;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
using Yi.Framework.AspNetCore;
using Yi.Framework.AspNetCore.Authentication.OAuth;
@@ -287,19 +288,19 @@ namespace Yi.Abp.Web
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
OnMessageReceived = messageContext =>
{
//优先Query中获取再去cookies中获取
var accessToken = context.Request.Query["access_token"];
var accessToken = messageContext.Request.Query["access_token"];
if (!string.IsNullOrEmpty(accessToken))
{
context.Token = accessToken;
messageContext.Token = accessToken;
}
else
{
if (context.Request.Cookies.TryGetValue("Token", out var cookiesToken))
if (messageContext.Request.Cookies.TryGetValue("Token", out var cookiesToken))
{
context.Token = cookiesToken;
messageContext.Token = cookiesToken;
}
}
@@ -320,19 +321,19 @@ namespace Yi.Abp.Web
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
OnMessageReceived = messageContext =>
{
var refresh_token = context.Request.Headers["refresh_token"];
if (!string.IsNullOrEmpty(refresh_token))
var headerRefreshToken = messageContext.Request.Headers["refresh_token"];
if (!string.IsNullOrEmpty(headerRefreshToken))
{
context.Token = refresh_token;
messageContext.Token = headerRefreshToken;
return Task.CompletedTask;
}
var refreshToken = context.Request.Query["refresh_token"];
if (!string.IsNullOrEmpty(refreshToken))
var queryRefreshToken = messageContext.Request.Query["refresh_token"];
if (!string.IsNullOrEmpty(queryRefreshToken))
{
context.Token = refreshToken;
messageContext.Token = queryRefreshToken;
}
return Task.CompletedTask;
@@ -357,7 +358,7 @@ namespace Yi.Abp.Web
var app = context.GetApplicationBuilder();
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<UsageStatisticsAggregateRoot>();

View File

@@ -1,7 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using TencentCloud.Ame.V20190916.Models;
using TencentCloud.Tiw.V20190919.Models;
using Volo.Abp.Domain.Repositories;
using Xunit;
using Yi.Framework.Rbac.Application.Contracts.Dtos.User;

View File

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

View File

@@ -5,6 +5,8 @@
"ComputedRef": true,
"DirectiveBinding": true,
"EffectScope": true,
"ElMessage": true,
"ElMessageBox": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,

View File

@@ -112,7 +112,7 @@
<body>
<!-- 加载动画容器 -->
<div id="yixinai-loader" class="loader-container">
<div class="loader-title">意心Ai 2.5</div>
<div class="loader-title">意心Ai 2.7</div>
<div class="loader-subtitle">海外地址仅首次访问预计加载约10秒</div>
<div class="loader-logo">
<div class="pulse-box"></div>

View File

@@ -35,21 +35,43 @@
"@floating-ui/dom": "^1.7.2",
"@floating-ui/vue": "^1.1.7",
"@jsonlee_12138/enum": "^1.0.4",
"@shikijs/transformers": "^3.7.0",
"@vueuse/core": "^13.5.0",
"@vueuse/integrations": "^13.5.0",
"chatarea": "^6.0.3",
"date-fns": "^2.30.0",
"deepmerge": "^4.3.1",
"dompurify": "^3.2.6",
"driver.js": "^1.3.6",
"echarts": "^6.0.0",
"element-plus": "^2.10.4",
"fingerprintjs": "^0.5.3",
"github-markdown-css": "^5.8.1",
"highlight.js": "^11.11.1",
"hook-fetch": "^2.0.4-beta.1",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"mermaid": "11.12.0",
"nprogress": "^0.2.0",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.4.1",
"prismjs": "^1.30.0",
"property-information": "^7.1.0",
"qrcode": "^1.5.4",
"radash": "^12.1.1",
"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",
"reset-css": "^5.0.2",
"shiki": "^3.7.0",
"ts-md5": "^2.0.1",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"vue": "^3.5.17",
"vue-element-plus-x": "1.3.7",
"vue-router": "4"
@@ -57,20 +79,46 @@
"devDependencies": {
"@antfu/eslint-config": "^4.16.2",
"@changesets/cli": "^2.29.5",
"@chromatic-com/storybook": "^3.2.7",
"@commitlint/config-conventional": "^19.8.1",
"@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/fingerprintjs__fingerprintjs": "^3.0.2",
"@types/fs-extra": "^11.0.4",
"@types/markdown-it": "^14.1.2",
"@types/prismjs": "^1.26.5",
"@vitejs/plugin-vue": "^6.0.0",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"@vue/tsconfig": "^0.7.0",
"commitlint": "^19.8.1",
"cz-git": "^1.12.0",
"eslint": "^9.31.0",
"esno": "^4.8.0",
"fast-glob": "^3.3.3",
"husky": "^9.1.7",
"lint-staged": "^16.1.2",
"playwright": "^1.53.2",
"postcss": "8.4.31",
"postcss-html": "1.5.0",
"prettier": "^3.6.2",
"rimraf": "^6.0.1",
"rollup-plugin-visualizer": "^6.0.3",
"sass": "^1.89.2",
"sass-embedded": "^1.89.2",
"storybook": "^8.6.14",
"storybook-dark-mode": "^4.0.2",
"stylelint": "^16.21.1",
"stylelint-config-html": "^1.1.0",
"stylelint-config-recess-order": "^7.1.0",
@@ -78,6 +126,7 @@
"stylelint-config-recommended-vue": "^1.6.1",
"stylelint-config-standard": "^38.0.0",
"stylelint-config-standard-scss": "^15.0.1",
"terser": "^5.43.1",
"typescript": "~5.8.3",
"typescript-api-pro": "^0.0.7",
"unocss": "66.3.3",
@@ -85,8 +134,11 @@
"unplugin-vue-components": "^28.8.0",
"vite": "^6.3.5",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-env-typed": "^0.0.2",
"vite-plugin-lib-inject-css": "^2.2.2",
"vite-plugin-svg-icons": "^2.0.1",
"vitest": "^3.2.4",
"vue-tsc": "^3.0.1"
},
"config": {

4324
Yi.Ai.Vue3/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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';
// 获取当前用户的模型列表
export function getModelList() {
return get<GetSessionListVO[]>('/ai-chat/model').json();
}
// 获取模型库列表(公开接口,无需登录)
export function getModelLibraryList(params?: ModelLibraryGetListInput) {
const queryParams = new URLSearchParams();
if (params?.searchKey) {
queryParams.append('SearchKey', params.searchKey);
}
if (params?.providerNames && params.providerNames.length > 0) {
params.providerNames.forEach(name => queryParams.append('ProviderNames', name));
}
if (params?.modelTypes && params.modelTypes.length > 0) {
params.modelTypes.forEach(type => queryParams.append('ModelTypes', type.toString()));
}
if (params?.modelApiTypes && params.modelApiTypes.length > 0) {
params.modelApiTypes.forEach(type => queryParams.append('ModelApiTypes', type.toString()));
}
if (params?.isPremiumOnly !== undefined) {
queryParams.append('IsPremiumOnly', params.isPremiumOnly.toString());
}
if (params?.skipCount !== undefined) {
queryParams.append('SkipCount', params.skipCount.toString());
}
if (params?.maxResultCount !== undefined) {
queryParams.append('MaxResultCount', params.maxResultCount.toString());
}
const queryString = queryParams.toString();
const url = queryString ? `/model?${queryString}` : '/model';
return get<PagedResultDto<ModelLibraryDto>>(url).json();
}
// 获取供应商列表(公开接口,无需登录)
export function getProviderList() {
return get<string[]>('/model/provider-list').json();
}
// 获取模型类型选项列表(公开接口,无需登录)
export function getModelTypeOptions() {
return get<ModelTypeOption[]>('/model/model-type-options').json();
}
// 获取API类型选项列表公开接口无需登录
export function getApiTypeOptions() {
return get<ModelApiTypeOption[]>('/model/api-type-options').json();
}
// 申请ApiKey
export function applyApiKey() {
return post<any>('/token').json();

View File

@@ -13,3 +13,60 @@ export interface GetSessionListVO {
remark?: string;
modelId?: string;
}
// 模型类型枚举
export enum ModelTypeEnum {
Chat = 0,
Image = 1,
Embedding = 2,
PremiumChat = 3,
}
// 模型API类型枚举
export enum ModelApiTypeEnum {
OpenAi = 0,
Claude = 1,
}
// 模型库展示数据
export interface ModelLibraryDto {
modelId: string;
name: string;
description?: string;
modelType: ModelTypeEnum;
modelApiTypes: Array;
modelApiTypeName: string;
multiplierShow: number;
providerName?: string;
iconUrl?: string;
isPremium: boolean;
}
// 获取模型库列表查询参数
export interface ModelLibraryGetListInput {
searchKey?: string;
providerNames?: string[];
modelTypes?: ModelTypeEnum[];
modelApiTypes?: ModelApiTypeEnum[];
isPremiumOnly?: boolean;
skipCount?: number;
maxResultCount?: number;
}
// 分页结果
export interface PagedResultDto<T> {
items: T[];
totalCount: number;
}
// 模型类型选项
export interface ModelTypeOption {
label: string;
value: number;
}
// API类型选项
export interface ModelApiTypeOption {
label: string;
value: number;
}

View File

@@ -12,4 +12,4 @@ export const COLLAPSE_THRESHOLD: number = 600;
export const SIDE_BAR_WIDTH: number = 280;
// 路由白名单地址[本地存在的路由 staticRouter.ts 中]
export const ROUTER_WHITE_LIST: string[] = ['/chat', '/chat/not_login', '/products', '/403', '/404'];
export const ROUTER_WHITE_LIST: string[] = ['/chat', '/chat/not_login', '/products', '/model-library', '/403', '/404'];

View File

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

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
function goToModelLibrary() {
router.push('/model-library');
}
</script>
<template>
<div class="model-library-btn-container" data-tour="model-library-btn">
<div
class="model-library-btn"
title="查看模型库"
@click="goToModelLibrary"
>
<!-- PC端显示文字 -->
<span class="pc-text">模型库</span>
<!-- 移动端显示图标 -->
<svg
class="mobile-icon"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
</div>
</div>
</template>
<style scoped lang="scss">
.model-library-btn-container {
display: flex;
align-items: center;
.model-library-btn {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 1.2rem;
font-weight: bold;
color: #606266;
transition: all 0.2s;
&:hover {
color: #606266;
transform: translateY(-1px);
}
// PC端显示文字隐藏图标
.pc-text {
display: inline;
margin: 0 12px;
}
.mobile-icon {
display: none;
}
}
}
// 移动端显示图标,隐藏文字
@media (max-width: 768px) {
.model-library-btn-container {
.model-library-btn {
.pc-text {
display: none;
}
.mobile-icon {
display: inline;
}
}
}
}
</style>

View File

@@ -10,6 +10,7 @@ import Avatar from './components/Avatar.vue';
import Collapse from './components/Collapse.vue';
import CreateChat from './components/CreateChat.vue';
import LoginBtn from './components/LoginBtn.vue';
import ModelLibraryBtn from './components/ModelLibraryBtn.vue';
import TitleEditing from './components/TitleEditing.vue';
const userStore = useUserStore();
@@ -72,6 +73,7 @@ onKeyStroke(event => event.ctrlKey && event.key.toLowerCase() === 'k', handleCtr
<!-- 右边 -->
<div class="right-box flex h-full items-center pr-20px flex-shrink-0 mr-auto flex-row">
<AnnouncementBtn />
<ModelLibraryBtn />
<AiTutorialBtn />
<Avatar v-show="userStore.userInfo" />
<LoginBtn v-show="!userStore.userInfo" />

View File

@@ -22,6 +22,7 @@ import { useUserStore } from '@/stores/modules/user';
import { getUserProfilePicture, systemProfilePicture } from '@/utils/user.ts';
import '@/styles/github-markdown.css';
import '@/styles/yixin-markdown.scss';
import YMarkdown from '@/vue-element-plus-y/components/XMarkdown/index.vue';
type MessageItem = BubbleProps & {
key: number;
@@ -316,7 +317,7 @@ function copy(item: any) {
<!-- 自定义气泡内容 -->
<template #content="{ item }">
<!-- chat 内容走 markdown -->
<XMarkdown v-if="item.content && (item.role === 'assistant' || item.role === 'system')" class="markdown-body" :markdown="item.content" :themes="{ light: 'github-light', dark: 'github-dark' }" default-theme-mode="dark" />
<YMarkdown v-if="item.content && (item.role === 'assistant' || item.role === 'system')" class="markdown-body" :markdown="item.content" :themes="{ light: 'github-light', dark: 'github-dark' }" default-theme-mode="dark" />
<!-- user 内容 纯文本 -->
<div v-if="item.content && item.role === 'user'" class="user-content">
{{ item.content }}

File diff suppressed because it is too large Load Diff

View File

@@ -37,12 +37,23 @@ export const layoutRouter: RouteRecordRaw[] = [
component: () => import('@/pages/products/index.vue'),
meta: {
title: '产品页面',
keepAlive: true, // 如果需要缓存
isDefaultChat: false, // 根据实际情况设置
layout: 'blankPage', // 如果需要自定义布局
keepAlive: true,
isDefaultChat: false,
layout: 'blankPage',
},
},
{
path: '/model-library',
name: 'modelLibrary',
component: () => import('@/pages/modelLibrary/index.vue'),
meta: {
title: '模型库',
keepAlive: true,
isDefaultChat: false,
layout: 'blankPage',
},
},
{
path: '/pay-result',
name: 'payResult',

View File

@@ -1,9 +1,9 @@
import {useUserStore} from '@/stores/index.js';
import { useUserStore } from '@/stores/index.js';
// 判断是否是 VIP 用户
export function isUserVip(): boolean {
const userStore = useUserStore();
return userStore.userInfo.isVip;
return userStore?.userInfo?.isVip;
}
// 用户头像

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
export interface RawProps {
language?: string;
content?: string;
key?: string | number;
}

View File

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

View File

@@ -0,0 +1,7 @@
export interface CodeLineProps {
raw?: {
content?: string;
inline?: boolean;
};
content?: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,120 @@
<script lang="ts" setup></script>
<template>
<div class="dot-spinner">
<div class="dot-spinner__dot" />
<div class="dot-spinner__dot" />
<div class="dot-spinner__dot" />
<div class="dot-spinner__dot" />
<div class="dot-spinner__dot" />
<div class="dot-spinner__dot" />
<div class="dot-spinner__dot" />
<div class="dot-spinner__dot" />
</div>
</template>
<style lang="scss" scoped>
.dot-spinner {
--uib-size: 2.8rem;
--uib-speed: 0.9s;
--uib-color: var(--el-color-primary);
position: relative;
display: flex;
align-items: center;
justify-content: flex-start;
height: var(--uib-size);
width: var(--uib-size);
}
.dot-spinner__dot {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: flex-start;
height: 100%;
width: 100%;
}
.dot-spinner__dot::before {
content: '';
height: 20%;
width: 20%;
border-radius: 50%;
background-color: var(--uib-color);
transform: scale(0);
opacity: 0.5;
animation: pulse0112 calc(var(--uib-speed) * 1.311) ease-in-out infinite;
box-shadow: 0 0 20px rgba(18, 31, 53, 0.3);
}
.dot-spinner__dot:nth-child(2) {
transform: rotate(45deg);
}
.dot-spinner__dot:nth-child(2)::before {
animation-delay: calc(var(--uib-speed) * -0.875);
}
.dot-spinner__dot:nth-child(3) {
transform: rotate(90deg);
}
.dot-spinner__dot:nth-child(3)::before {
animation-delay: calc(var(--uib-speed) * -0.75);
}
.dot-spinner__dot:nth-child(4) {
transform: rotate(135deg);
}
.dot-spinner__dot:nth-child(4)::before {
animation-delay: calc(var(--uib-speed) * -0.625);
}
.dot-spinner__dot:nth-child(5) {
transform: rotate(180deg);
}
.dot-spinner__dot:nth-child(5)::before {
animation-delay: calc(var(--uib-speed) * -0.5);
}
.dot-spinner__dot:nth-child(6) {
transform: rotate(225deg);
}
.dot-spinner__dot:nth-child(6)::before {
animation-delay: calc(var(--uib-speed) * -0.375);
}
.dot-spinner__dot:nth-child(7) {
transform: rotate(270deg);
}
.dot-spinner__dot:nth-child(7)::before {
animation-delay: calc(var(--uib-speed) * -0.25);
}
.dot-spinner__dot:nth-child(8) {
transform: rotate(315deg);
}
.dot-spinner__dot:nth-child(8)::before {
animation-delay: calc(var(--uib-speed) * -0.125);
}
@keyframes pulse0112 {
0%,
100% {
transform: scale(0);
opacity: 0.5;
}
50% {
transform: scale(1);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,4 @@
export enum SELECT_OPTIONS_ENUM {
'CODE' = '代码',
'VIEW' = '预览'
}

View File

@@ -0,0 +1,185 @@
<script lang="ts" setup>
import type { ElxRunCodeContentProps } from '../type';
import DOMPurify from 'dompurify';
import _ from 'lodash';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import HighLightCode from '../../HighLightCode/index.vue';
import { useMarkdownContext } from '../../MarkdownProvider';
import CustomLoading from './custom-loading.vue';
import { SELECT_OPTIONS_ENUM } from './options';
const props = defineProps<ElxRunCodeContentProps>();
const computedClass = computed(() => `pre-md ${props.preClass}`);
const codeClass = computed(() => `language-${props.lang || 'text'}`);
const iframeRef = ref<HTMLIFrameElement>();
const allHtml = computed(() => props.content);
const codeContainerRef = ref<HTMLElement>();
const isLoading = ref(false);
const context = useMarkdownContext();
const isSafeViewCode = computed(() => {
return context.value.secureViewCode;
});
const enableCodeLineNumber = computed(() => {
return context.value?.enableCodeLineNumber || false;
});
function doRenderIframe() {
const iframe = iframeRef.value;
if (!iframe)
return;
isLoading.value = true;
const rawHtml = allHtml.value || '';
let sanitizedHtml = rawHtml;
// 安全模式过滤
if (isSafeViewCode.value) {
sanitizedHtml = DOMPurify.sanitize(rawHtml, {
WHOLE_DOCUMENT: true,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed'],
FORBID_ATTR: ['onerror', 'onclick', 'onload', 'style']
});
}
// 检查 <head> 中是否有 UTF-8 charset
let finalHtml = sanitizedHtml;
const hasHead = /<head[^>]*>/i.test(sanitizedHtml);
const hasUtf8Meta =
/<head[^>]*>[\s\S]*?<meta\s[^>]*charset=["']?utf-8["']?/i.test(
sanitizedHtml
);
if (hasHead) {
if (!hasUtf8Meta) {
finalHtml = sanitizedHtml.replace(
/<head[^>]*>/i,
match => `${match}<meta charset="UTF-8">`
);
}
}
else {
// 没有 <head>,插入 <head><meta charset="UTF-8"></head> 到 <html> 或最前
if (/<html[^>]*>/i.test(sanitizedHtml)) {
finalHtml = sanitizedHtml.replace(
/<html[^>]*>/i,
match => `${match}<head><meta charset="UTF-8"></head>`
);
}
else {
// 甚至没有 <html>,包一层完整结构
finalHtml = `
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body>${sanitizedHtml}</body>
</html>
`;
}
}
const blob = new Blob([finalHtml], { type: 'text/html' });
if (iframe.src && iframe.src.startsWith('blob:')) {
URL.revokeObjectURL(iframe.src);
}
iframe.src = URL.createObjectURL(blob);
const onLoad = () => {
setTimeout(() => {
isLoading.value = false;
}, 300);
iframe.removeEventListener('load', onLoad);
};
iframe.addEventListener('load', onLoad);
}
const renderIframe = _.debounce(() => {
doRenderIframe();
}, 300);
function startRender() {
if (props.nowView === SELECT_OPTIONS_ENUM.VIEW) {
isLoading.value = true;
renderIframe();
}
}
watch(
() => [props.nowView, isSafeViewCode.value],
() => {
startRender();
},
{ immediate: true }
);
watch(
() => props.code,
() => {
startRender();
},
{ immediate: true }
);
onMounted(() => {
nextTick(() => {
startRender();
});
});
</script>
<template>
<el-scrollbar
ref="codeContainerRef"
class="elx-run-code-content-scrollbar"
:style="preStyle"
>
<div
v-show="props.nowView === SELECT_OPTIONS_ENUM.CODE"
class="elx-xmarkdown-container elx-run-code-content"
>
<pre>
<div
:class="computedClass"
:style="preStyle"
>
<code
class="elx-run-code-content-code"
:class="codeClass"
>
<HighLightCode
:enable-code-line-number="enableCodeLineNumber"
:lang="props.lang"
:code="props.code"
/>
</code>
</div>
</pre>
</div>
<div
v-show="props.nowView === SELECT_OPTIONS_ENUM.VIEW"
style="position: relative; width: 100%; height: 100%"
class="elx-run-code-content-view"
>
<div v-if="isLoading" class="iframe-loading-mask">
<CustomLoading />
</div>
<div v-show="!isLoading" class="elx-run-code-content-view-iframe">
<iframe
ref="iframeRef"
sandbox="allow-scripts"
style="border: 0; width: 100%; height: 79.5vh"
/>
</div>
</div>
</el-scrollbar>
</template>
<style src="./style/index.scss"></style>

View File

@@ -0,0 +1,32 @@
<script lang="ts" setup>
import type { ElxRunCodeHeaderTypes } from '../type';
import { useVModel } from '@vueuse/core';
import { SELECT_OPTIONS_ENUM } from './options';
interface ElxRunCodeProps {
value: ElxRunCodeHeaderTypes['options'];
}
const props = withDefaults(defineProps<ElxRunCodeProps>(), {});
const emit = defineEmits<{
(e: 'changeSelect', val: string): void;
(e: 'update:value'): void;
}>();
const options = Object.values(SELECT_OPTIONS_ENUM);
const selectValue = useVModel(props, 'value', emit);
function change(val: string) {
emit('changeSelect', val);
}
</script>
<template>
<div class="custom-style">
<el-segmented v-model="selectValue" :options="options" @change="change" />
</div>
</template>
<style src="./style/index.scss"></style>

View File

@@ -0,0 +1,61 @@
.custom-style .el-segmented {
--el-segmented-item-selected-color: white;
--el-border-radius-base: var(--shiki-custom-brr);
}
body.dark {
.custom-style .el-segmented {
--el-segmented-item-selected-bg-color: #409eff;
--el-segmented-item-selected-color: white;
--el-segmented-item-hover-bg-color: #4e4e4e;
--el-segmented-item-active-bg-color: #4e4e4e;
--el-fill-color-light: #39393a;
.el-segmented__item-label {
color: #ffffff;
}
}
.elx-run-code-content-scrollbar {
background-color: var(--shiki-dark-bg) !important;
}
}
.elx-run-code-content-scrollbar {
background-color: var(--shiki-bg) !important;
}
.elx-run-code-content {
width: 100%;
height: 80vh !important;
padding: 0 !important;
.elx-run-code-content-code {
overflow: visible !important;
padding: 10px 0;
}
pre {
white-space: nowrap !important;
margin: 0 !important;
}
pre div.pre-md {
width: 100%;
height: 100% !important;
border: none !important;
}
.code-line {
display: flex;
align-items: flex-start;
white-space: pre;
}
.line-content {
flex: 1;
}
}
.iframe-loading-mask {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 79.8vh;
}

View File

@@ -0,0 +1,136 @@
<script lang="ts" setup>
import type { ElxRunCodeHeaderTypes, ElxRunCodeProps } from './type';
import { CloseBold } from '@element-plus/icons-vue';
import { useVModel } from '@vueuse/core';
import { computed, h, ref, toValue } from 'vue';
import { useMarkdownContext } from '../MarkdownProvider';
import { SELECT_OPTIONS_ENUM } from './components/options';
import RunCodeContent from './components/run-code-content.vue';
import RunCodeHeader from './components/run-code-header.vue';
const props = withDefaults(defineProps<ElxRunCodeProps>(), {
code: () => [],
lang: '',
mode: 'drawer'
});
const emit = defineEmits<{
(e: 'update:visible'): void;
}>();
const drawer = useVModel(props, 'visible', emit);
const selectValue = ref<ElxRunCodeHeaderTypes['options']>(
SELECT_OPTIONS_ENUM.VIEW
);
const isView = computed(() => selectValue.value === SELECT_OPTIONS_ENUM.VIEW);
const context = useMarkdownContext();
const { codeXSlot } = toValue(context) || {};
function changeSelectValue(val: ElxRunCodeHeaderTypes['options']) {
selectValue.value = val;
}
function close() {
drawer.value = false;
}
// 渲染插槽函数
function renderSlot(slotName: string) {
if (!codeXSlot) {
return 'div';
}
const slotFn = codeXSlot[slotName];
if (typeof slotFn === 'function') {
return slotFn({
...props,
value: selectValue.value,
close,
changeSelectValue
});
}
return h(slotFn as any, {
...props,
value: selectValue.value,
close,
changeSelectValue
});
}
const RunCodeCloseBtnComputed = computed(() => {
if (codeXSlot?.viewCodeCloseBtn) {
return renderSlot('viewCodeCloseBtn');
}
return CloseBold;
});
const RunCodeHeaderComputed = computed(() => {
if (codeXSlot?.viewCodeHeader) {
return renderSlot('viewCodeHeader');
}
return RunCodeHeader;
});
const RunCodeContentComputed = computed(() => {
if (codeXSlot?.viewCodeContent) {
return renderSlot('viewCodeContent');
}
return null;
});
</script>
<template>
<el-dialog
v-if="props.mode === 'dialog'"
v-model="drawer"
:class="`${props.customClass} ${isView ? 'elx-run-code-dialog-view' : ''}`"
:close-on-click-modal="props.dialogOptions?.closeOnClickModal ?? true"
:close-on-press-escape="props.dialogOptions?.closeOnPressEscape ?? true"
:show-close="false"
class="elx-run-code-dialog"
align-center
destroy-on-close
append-to-body
>
<template #header>
<component :is="RunCodeHeaderComputed" v-model:value="selectValue" />
<el-button class="view-code-close-btn" @click="close">
<component :is="RunCodeCloseBtnComputed" />
</el-button>
</template>
<template #default>
<component :is="RunCodeContentComputed" v-if="RunCodeContentComputed" />
<RunCodeContent v-else v-bind="props" :now-view="selectValue" />
</template>
</el-dialog>
<el-drawer
v-if="props.mode === 'drawer'"
v-model="drawer"
:class="`${props.customClass} ${isView ? 'elx-run-code-drawer-view' : ''}`"
:close-on-click-modal="props.drawerOptions?.closeOnClickModal ?? true"
:close-on-press-escape="props.drawerOptions?.closeOnPressEscape ?? true"
:show-close="false"
class="elx-run-code-drawer"
align-center
destroy-on-close
append-to-body
>
<template #header>
<component :is="RunCodeHeaderComputed" v-model:value="selectValue" />
<el-button
class="view-code-close-btn"
:class="{ customCloseBtn: !!codeXSlot?.viewCodeCloseBtn }"
@click="close"
>
<component :is="RunCodeCloseBtnComputed" />
</el-button>
</template>
<template #default>
<component :is="RunCodeContentComputed" v-if="RunCodeContentComputed" />
<RunCodeContent v-else v-bind="props" :now-view="selectValue" />
</template>
</el-drawer>
</template>
<style src="./style.scss"></style>

View File

@@ -0,0 +1,86 @@
.elx-run-code-dialog,
.elx-run-code-drawer {
width: 75% !important;
background-color: var(--shiki-code-header-bg) !important;
.el-dialog__body {
overflow: auto;
border-radius: var(--shiki-custom-brr);
}
.el-drawer__body {
overflow: auto;
border-radius: var(--shiki-custom-brr);
}
.el-drawer__header {
position: relative;
margin-bottom: 0 !important;
}
.el-dialog__headerbtn,
.el-drawer__close-btn {
color: var(--shiki-code-header-span-color);
width: 32px;
height: 32px;
padding: 5px;
box-sizing: border-box;
right: 10px;
top: 15px;
font-size: 20px;
border-radius: var(--shiki-custom-brr);
&:hover {
background-color: var(--shiki-code-header-btn-bg);
}
}
.view-code-close-btn {
background-color: transparent;
border: none;
span {
color: var(--shiki-code-header-span-color);
width: 32px;
height: 32px;
&:hover {
color: var(--el-color-primary);
}
}
position: absolute;
width: 32px;
height: 32px;
padding: 5px;
box-sizing: border-box;
right: 10px;
top: 15px;
font-size: 20px;
border-radius: var(--shiki-custom-brr);
&:hover {
background-color: var(--shiki-code-header-btn-bg);
}
}
.customCloseBtn {
&:hover {
background-color: transparent !important;
}
}
}
// 媒体查询
@media (max-width: 768px) {
.elx-run-code-dialog,
.elx-run-code-drawer {
width: 100% !important;
}
}
.elx-run-code-content-view-iframe {
height: 713px;
overflow: hidden;
}
.elx-run-code-dialog-view {
.el-dialog__body {
border: 1px solid transparent !important;
}
.el-drawer__body {
border: 1px solid transparent !important;
}
}

View File

@@ -0,0 +1,116 @@
import type { SELECT_OPTIONS_ENUM } from './components/options';
export interface ElxRunCodeHeaderTypes {
/**
* 视图 code 代码 view 预览
*/
options: SELECT_OPTIONS_ENUM.CODE | SELECT_OPTIONS_ENUM.VIEW;
}
export interface DialogOptions {
/**
* 点击遮罩层是否可以关闭
*/
closeOnClickModal?: boolean;
/**
* 是否可以通过按下 ESC 键关闭 Dialog
*/
closeOnPressEscape?: boolean;
}
export interface DrawerOptions extends DialogOptions {
/**
* 抽屉的方向
*/
direction?: 'ltr' | 'rtl' | 'ttb' | 'btt';
}
export interface ElxRunCodeProps {
/**
* 代码块内容(高亮后的代码块内容)
*/
code: string[];
/**
* 代码块内容(原文)
*/
content: string;
/**
* 高亮后pre标签的类名
*/
preClass: string;
/**
* 高亮后pre标签的样式
*/
preStyle: any;
/**
* 语言
*/
lang: string;
/**
* 是否可见
*/
visible: boolean;
/**
* 自定义类名
*/
customClass?: string;
/**
* 弹窗模式
*/
mode?: 'dialog' | 'drawer';
/**
* 弹窗主题(暂时不支持)
*/
theme?: string;
/**
* 弹窗选项
*/
dialogOptions?: DialogOptions;
/**
* 抽屉选项
*/
drawerOptions?: DrawerOptions;
}
export type ElxRunCodeOptions = Pick<
ElxRunCodeProps,
'mode' | 'customClass' | 'dialogOptions' | 'drawerOptions'
>;
export type OmitOfElxRunCodeContent = Omit<
ElxRunCodeProps,
'visible' | 'customClass' | 'dialogOptions' | 'drawerOptions'
>;
export interface ElxRunCodeContentProps extends OmitOfElxRunCodeContent {
/**
* 当前内容区域显示的视图
*/
nowView: ElxRunCodeHeaderTypes['options'];
}
export interface ElxRunCodeExposeProps extends ElxRunCodeProps {
/**
* 当前选中的视图
*/
value: ElxRunCodeHeaderTypes['options'];
/**
* 切换视图
*/
changeSelectValue: (value: ElxRunCodeHeaderTypes['options']) => void;
}
export interface ElxRunCodeContentExposeProps extends ElxRunCodeContentProps {
/**
* 当前选中的视图
*/
value: ElxRunCodeHeaderTypes['options'];
/**
* 当前内容区域显示的视图
*/
nowView: ElxRunCodeHeaderTypes['options'];
}
export interface ElxRunCodeCloseBtnExposeProps {
close: () => void;
}

View File

@@ -0,0 +1,6 @@
import CodeBlock from './CodeBlock/index.vue';
import CodeLine from './CodeLine/index.vue';
import CodeX from './CodeX/index.vue';
import Mermaid from './Mermaid/index.vue';
export { CodeBlock, CodeLine, CodeX, Mermaid };

View File

@@ -0,0 +1,23 @@
// import type { CustomAttrs } from './core'
// export type * from './core/types'
import type { BuiltinTheme } from 'shiki';
import type { Component } from 'vue';
export interface MdComponent {
raw: any;
}
export type codeXRenderer =
| ((params: { language?: string; content: string }) => VNodeChild)
| Component;
export type codeXSlot = ((params: any) => VNodeChild) | Component;
export interface HighlightProps {
theme?: BuiltinTheme | null;
isDark?: boolean;
language?: string;
content?: string;
}
// 定义颜色替换的类型
export interface ColorReplacements {
[theme: string]: Record<string, string>;
}

View File

@@ -0,0 +1,147 @@
import type { Root } from 'hast';
import type { Options as TRehypeOptions } from 'mdast-util-to-hast';
import type { PluggableList } from 'unified';
import type { PropType } from 'vue';
import type { CustomAttrs, SanitizeOptions, TVueMarkdown } from './types';
import { computed, defineComponent, ref, shallowRef, toRefs, watch } from 'vue';
import { watchDebounced } from '@vueuse/core';
// import { useMarkdownContext } from '../components/MarkdownProvider';
import { render } from './hast-to-vnode';
import { useMarkdownProcessor } from './useProcessor';
export type { CustomAttrs, SanitizeOptions, TVueMarkdown };
const sharedProps = {
markdown: {
type: String as PropType<string>,
default: ''
},
customAttrs: {
type: Object as PropType<CustomAttrs>,
default: () => ({})
},
remarkPlugins: {
type: Array as PropType<PluggableList>,
default: () => []
},
rehypePlugins: {
type: Array as PropType<PluggableList>,
default: () => []
},
rehypeOptions: {
type: Object as PropType<Omit<TRehypeOptions, 'file'>>,
default: () => ({})
},
sanitize: {
type: Boolean,
default: false
},
sanitizeOptions: {
type: Object as PropType<SanitizeOptions>,
default: () => ({})
}
};
const vueMarkdownImpl = defineComponent({
name: 'VueMarkdown',
props: sharedProps,
setup(props, { slots, attrs }) {
const {
markdown,
remarkPlugins,
rehypePlugins,
rehypeOptions,
sanitize,
sanitizeOptions,
customAttrs
} = toRefs(props);
const { processor } = useMarkdownProcessor({
remarkPlugins,
rehypePlugins,
rehypeOptions,
sanitize,
sanitizeOptions
});
// 防抖优化控制markdown更新频率避免流式渲染时频繁触发
const debouncedMarkdown = ref(markdown.value);
watchDebounced(
markdown,
(val) => {
debouncedMarkdown.value = val;
},
{ debounce: 50, maxWait: 200 } // 50ms防抖最多200ms必须更新一次
);
// 缓存优化使用computed缓存解析结果避免重复计算
const hast = computed(() => {
const mdast = processor.value.parse(debouncedMarkdown.value);
return processor.value.runSync(mdast) as Root;
});
return () => {
return render(hast.value, attrs, slots, customAttrs.value);
};
}
});
const vueMarkdownAsyncImpl = defineComponent({
name: 'VueMarkdownAsync',
props: sharedProps,
async setup(props, { slots, attrs }) {
const {
markdown,
remarkPlugins,
rehypePlugins,
rehypeOptions,
sanitize,
sanitizeOptions,
customAttrs
} = toRefs(props);
const { processor } = useMarkdownProcessor({
remarkPlugins,
rehypePlugins,
rehypeOptions,
sanitize,
sanitizeOptions
});
const hast = shallowRef<Root | null>(null);
// 防抖优化控制markdown更新频率
const debouncedMarkdown = ref(markdown.value);
const process = async (): Promise<void> => {
const mdast = processor.value.parse(debouncedMarkdown.value);
hast.value = (await processor.value.run(mdast)) as Root;
};
// 使用防抖watch避免频繁触发异步处理
watchDebounced(
markdown,
(val) => {
debouncedMarkdown.value = val;
},
{ debounce: 50, maxWait: 200 }
);
watch(() => [debouncedMarkdown.value, processor.value], process, { flush: 'post' });
await process();
return () => {
return hast.value
? render(hast.value, attrs, slots, customAttrs.value)
: null;
};
}
});
// export the public type for h/tsx inference
// also to avoid inline import() in generated d.ts files
export const VueMarkdown: TVueMarkdown = vueMarkdownImpl as any;
// export the public type for h/tsx inference
// also to avoid inline import() in generated d.ts files
export const VueMarkdownAsync: TVueMarkdown = vueMarkdownAsyncImpl as any;

View File

@@ -0,0 +1,237 @@
import type { Element, Root, RootContent, Text } from 'hast';
import type { MaybeRefOrGetter, Slots, VNode, VNodeArrayChildren } from 'vue';
import type {
AliasList,
Attributes,
Context,
CustomAttrs,
CustomAttrsObjectResult
} from './types';
import { find, html, svg } from 'property-information';
import { h, toValue } from 'vue';
export function render(
hast: Root,
attrs: Record<string, unknown>,
slots?: Slots,
customAttrs?: MaybeRefOrGetter<CustomAttrs>
): VNode {
return h(
'div',
attrs,
renderChildren(
hast.children,
{ listDepth: -1, listOrdered: false, listItemIndex: -1, svg: false },
hast,
slots ?? {},
toValue(customAttrs) ?? {}
)
);
}
export function renderChildren(
nodeList: (RootContent | Root)[],
ctx: Context,
parent: Element | Root,
slots: Slots,
customAttrs: CustomAttrs
): VNodeArrayChildren {
const keyCounter: {
[key: string]: number;
} = {};
return nodeList.map(node => {
switch (node.type) {
case 'text':
return node.value;
case 'raw':
return node.value;
case 'root':
return renderChildren(node.children, ctx, parent, slots, customAttrs);
case 'element': {
const { attrs, context, aliasList, vnodeProps } = getVNodeInfos(
node,
parent,
ctx,
keyCounter,
customAttrs
);
for (let i = aliasList.length - 1; i >= 0; i--) {
const targetSlot = slots[aliasList[i]];
if (typeof targetSlot === 'function') {
return targetSlot({
...vnodeProps,
...attrs,
children: () =>
renderChildren(node.children, context, node, slots, customAttrs)
});
}
}
return h(
node.tagName,
attrs,
renderChildren(node.children, context, node, slots, customAttrs)
);
}
default:
return null;
}
});
}
export function getVNodeInfos(
node: RootContent,
parent: Element | Root,
context: Context,
keyCounter: Record<string, number>,
customAttrs: CustomAttrs
): {
attrs: Record<string, unknown>;
context: Context;
aliasList: AliasList;
vnodeProps: Record<string, any>;
} {
const aliasList: AliasList = [];
let attrs: Record<string, unknown> = {};
const vnodeProps: Record<string, any> = {};
const ctx = { ...context };
if (node.type === 'element') {
aliasList.push(node.tagName);
keyCounter[node.tagName] =
node.tagName in keyCounter ? keyCounter[node.tagName] + 1 : 0;
vnodeProps.key = `${node.tagName}-${keyCounter[node.tagName]}`;
node.properties = node.properties || {};
if (node.tagName === 'svg') {
ctx.svg = true;
}
attrs = Object.entries(node.properties).reduce<Record<string, any>>(
(acc, [hastKey, value]) => {
const attrInfo = find(ctx.svg ? svg : html, hastKey);
acc[attrInfo.attribute] = value;
return acc;
},
{}
);
switch (node.tagName) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
vnodeProps.level = Number.parseFloat(node.tagName.slice(1));
aliasList.push('heading');
break;
// TODO: maybe use <pre> instead for customizing from <pre> not <code> ?
case 'code':
vnodeProps.languageOriginal = Array.isArray(attrs.class)
? attrs.class.find(cls => cls.startsWith('language-'))
: '';
vnodeProps.language = vnodeProps.languageOriginal
? vnodeProps.languageOriginal.replace('language-', '')
: '';
vnodeProps.inline = 'tagName' in parent && parent.tagName !== 'pre';
// when tagName is code, it definitely has children and the first child is text
// https://github.com/syntax-tree/mdast-util-to-hast/blob/main/lib/handlers/code.js
vnodeProps.content = (node.children[0] as unknown as Text)?.value ?? '';
aliasList.push(vnodeProps.inline ? 'inline-code' : 'block-code');
break;
case 'thead':
case 'tbody':
ctx.currentContext = node.tagName;
break;
case 'td':
case 'th':
case 'tr':
vnodeProps.isHead = context.currentContext === 'thead';
break;
case 'ul':
case 'ol':
ctx.listDepth = context.listDepth + 1;
ctx.listOrdered = node.tagName === 'ol';
ctx.listItemIndex = -1;
vnodeProps.ordered = ctx.listOrdered;
vnodeProps.depth = ctx.listDepth;
aliasList.push('list');
break;
case 'li':
ctx.listItemIndex++;
vnodeProps.ordered = ctx.listOrdered;
vnodeProps.depth = ctx.listDepth;
vnodeProps.index = ctx.listItemIndex;
aliasList.push('list-item');
break;
case 'slot':
if (typeof node.properties['slot-name'] === 'string') {
aliasList.push(`${node.properties['slot-name']}`);
delete node.properties['slot-name'];
}
break;
default:
break;
}
attrs = computeAttrs(
node,
aliasList,
vnodeProps,
{ ...attrs } as Attributes, // TODO: fix this
customAttrs
);
}
return {
attrs,
context: ctx,
aliasList,
vnodeProps
};
}
/**
* TODO:
* @param node - hast node
* @param aliasList - html tag list. The earlier alias has higher priority. ?
* @param attrs - attrs
* @param customAttrs - custom attrs object
* @returns attrs
*/
function computeAttrs(
node: Element,
aliasList: AliasList,
vnodeProps: Record<string, any>,
attrs: Attributes,
customAttrs: CustomAttrs
): CustomAttrsObjectResult {
const result: CustomAttrsObjectResult = {
...attrs
};
for (let i = aliasList.length - 1; i >= 0; i--) {
const name = aliasList[i];
// console.log(Object.keys(customAttrs))
if (name in customAttrs) {
const customAttr = customAttrs[name];
return {
...result,
...(typeof customAttr === 'function'
? customAttr(node, { ...attrs, ...vnodeProps })
: customAttr)
};
}
}
return result;
}

View File

@@ -0,0 +1,5 @@
// shunnNet has the rights under the MIT license
export { VueMarkdown, VueMarkdownAsync } from './components';
export { getVNodeInfos, render, renderChildren } from './hast-to-vnode';
export type * from './types';
export { createProcessor, useMarkdownProcessor } from './useProcessor';

View File

@@ -0,0 +1,389 @@
import type { Options as DeepMergeOptions } from 'deepmerge';
import type { Element } from 'hast';
import type { Options as TRehypeOptions } from 'mdast-util-to-hast';
import type { Options } from 'rehype-sanitize';
import type { PluggableList } from 'unified';
import type {
AllowedComponentProps,
ComponentCustomProps,
VNode,
VNodeArrayChildren,
VNodeProps
} from 'vue';
export interface Context {
listDepth: number;
listOrdered: boolean;
listItemIndex: number;
currentContext?: string;
svg: boolean;
}
export type Attributes = Record<string, string>;
interface TTableProps {
/** whether it is in head */
isHead: boolean;
}
interface THeadingProps {
/** heading level */
level: number;
}
interface TListProps {
/** depth of the list */
depth: number;
/** whether it is ordered list */
ordered: boolean;
}
interface TCodeProps {
/** language name original @example 'language-js' */
languageOriginal: string;
/** language name @example 'js' */
language: string;
/** code content */
content: string;
/** whether it is inline code */
inline: boolean;
}
// https://www.google.com/search?q=record%3Cstring,+any%3E+vs+record%3Cstring,+unknown%3E&sourceid=chrome&ie=UTF-8
export type CustomAttrsObjectResult = Record<string, unknown>;
type CustomAttrsFunctionValue<T> = (
/**
* hast node
*
* Please refer to the source code at the following URL to understand the possible attributes for each element.
*
* @see https://github.com/syntax-tree/mdast-util-to-hast/tree/main/lib/handlers
*/
node: Element,
/**
* Properties of the current element.
*
* Except for the basic properties provided from hast, it also includes custom properties such as `level`, `ordered`, `depth`, `index` etc.
*/
combinedAttrs: T | Attributes
) => Record<string, unknown>;
type CustomAttrsValue<
T extends Record<string, unknown> = Record<string, unknown>
> = CustomAttrsObjectResult | CustomAttrsFunctionValue<T>;
type TBasicHTMLTagNames = keyof Omit<
HTMLElementTagNameMap,
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'h5'
| 'h6'
| 'ul'
| 'ol'
| 'li'
| 'code'
| 'td'
| 'th'
| 'tr'
>;
export type CustomAttrs = {
[key in TBasicHTMLTagNames]?: CustomAttrsValue; // << dynamic properties
} & {
[key: string]:
| CustomAttrsValue
| CustomAttrsValue<THeadingProps>
| CustomAttrsValue<TListProps>
| CustomAttrsValue<TCodeProps>
| CustomAttrsValue<TTableProps>
| undefined;
['h1']?: CustomAttrsValue<THeadingProps>; // << static properties
['h2']?: CustomAttrsValue<THeadingProps>;
['h3']?: CustomAttrsValue<THeadingProps>;
['h4']?: CustomAttrsValue<THeadingProps>;
['h5']?: CustomAttrsValue<THeadingProps>;
['h6']?: CustomAttrsValue<THeadingProps>;
['heading']?: CustomAttrsValue<THeadingProps>;
['ul']?: CustomAttrsValue<TListProps>;
['ol']?: CustomAttrsValue<TListProps>;
['list']?: CustomAttrsValue<TListProps>;
['li']?: CustomAttrsValue<TListProps>;
['list-item']?: CustomAttrsValue<TListProps>;
['code']?: CustomAttrsValue<TCodeProps>;
['inline-code']?: CustomAttrsValue<TCodeProps>;
['block-code']?: CustomAttrsValue<TCodeProps>;
['td']?: CustomAttrsValue<TTableProps>;
['th']?: CustomAttrsValue<TTableProps>;
['tr']?: CustomAttrsValue<TTableProps>;
};
export type AliasList = string[];
export type TagList = AliasList;
export interface SanitizeOptions {
/**
* Options for `rehype-sanitize`
*
* @see https://github.com/rehypejs/rehype-sanitize
*/
sanitizeOptions?: Options;
/**
* Options for `deepmerge`
*/
mergeOptions?: DeepMergeOptions;
}
export interface TVueMarkdownProps {
/**
* Markdown content
*
* @default '''
*/
markdown: string;
/**
* You can set custom attributes for each element, such as `href`, `target`, `rel`, `lazyload`, etc.
*
* The key is the HTML tag name, and the value can either be an object or a function that returns an object.
*
* The value will be passed to Vue's `h` function. You can refer to Vue's official documentation to learn how to configure `h`.
*
* @see https://vuejs.org/guide/extras/render-function.html#render-functions-jsx
*
* @default {}
*
* @example
* ```ts
* {
* a: { target: '_blank', rel: 'noopener' },
* img: { lazyload: true },
* h1: (node, combinedAttrs) => {
* return { class: ['title', 'mb-2'] }
* }
* }
* ```
*/
customAttrs?: CustomAttrs;
/**
* Remark plugins
*
* These plugins will be used between `remark-parse` and `remark-rehype`.
*
* @see https://github.com/remarkjs/remark?tab=readme-ov-file#plugins
*
* @default []
*/
remarkPlugins?: PluggableList;
/**
* rehype plugins
*
* These plugins will be used after `remark-rehype` but before `rehype-sanitize`.
*
* @see https://github.com/remarkjs/remark-rehype?tab=readme-ov-file#related
*
* @default []
*/
rehypePlugins?: PluggableList;
/**
* Whether to sanitize the HTML content. (use `rehype-sanitize`)
*
* You need disable this option if you want to render `<slot>` in markdown content.
*
* @default false
*/
sanitize?: boolean;
/**
* Options for `rehype-sanitize`
*
* @see https://github.com/rehypejs/rehype-sanitize?tab=readme-ov-file#options
*
* @default { allowDangerousHtml: true }
*/
sanitizeOptions?: SanitizeOptions;
/**
* Options for `rehype-parse`
*
* @see https://github.com/remarkjs/remark-rehype?tab=readme-ov-file#options
*
* @default {}
*/
rehypeOptions?: Omit<TRehypeOptions, 'file'>;
}
/**
* Typed version of the `VueMarkdown` component.
*
* Copy from vue-router
*/
export interface TVueMarkdown {
new (): {
$props: AllowedComponentProps &
ComponentCustomProps &
VNodeProps &
TVueMarkdownProps;
$slots: TBaseSlots & {
/**
* Customize `<h1>` tag
* @scope `level` heading level
* @scope `children` Functional component, child elements.
*/
['h1']?: THeadingSlot;
/**
* Customize `<h2>` tag
* @scope `level` heading level
* @scope `children` Functional component, child elements.
*/
['h2']?: THeadingSlot;
/**
* Customize `<h3>` tag
* @scope `level` heading level
* @scope `children` Functional component, child elements.
*/
['h3']?: THeadingSlot;
/**
* Customize `<h4>` tag
* @scope `level` heading level
* @scope `children` Functional component, child elements.
*/
['h4']?: THeadingSlot;
/**
* Customize `<h5>` tag
* @scope `level` heading level
* @scope `children` Functional component, child elements.
*/
['h5']?: THeadingSlot;
/**
* Customize `<h6>` tag
* @scope `level` heading level
* @scope `children` Functional component, child elements.
*/
['h6']?: THeadingSlot;
/**
* Customize `<h1>` ~ `<h6>` tag
* @scope `level` heading level
* @scope `children` Functional component, child elements.
*/
['heading']?: THeadingSlot;
/**
* Customize inline and block code.
* @scope `languageOriginal` language name original
* @scope `language` language name
* @scope `content` code content
* @scope `inline` whether it is inline code
* @scope `children` Functional component, child elements.
*/
['code']?: TCodeSlot;
/**
* Customize inline code.
* @scope `languageOriginal` language name original
* @scope `language` language name
* @scope `content` code content
* @scope `inline` whether it is inline code
* @scope `children` Functional component, child elements.
*/
['inline-code']?: TCodeSlot;
/**
* Customize block code.
* @scope `languageOriginal` language name original
* @scope `language` language name
* @scope `content` code content
* @scope `inline` whether it is inline code
* @scope `children` Functional component, child elements.
*/
['block-code']?: TCodeSlot;
/**
* Customize unordered list
* @scope `depth` depth of the list
* @scope `ordered` whether it is ordered list
* @scope `children` Functional component, child elements.
*/
['ul']?: TListSlot;
/**
* Customize ordered list
* @scope `depth` depth of the list
* @scope `ordered` whether it is ordered list
* @scope `children` Functional component, child elements.
*/
['ol']?: TListSlot;
/**
* Customize ordered and unordered list
* @scope `depth` depth of the list
* @scope `ordered` whether it is ordered list
* @scope `children` Functional component, child elements.
*/
['list']?: TListSlot;
/**
* Customize list item
* @scope `depth` depth of the list
* @scope `ordered` whether it is ordered list
* @scope `index` index of the list item
* @scope `children` Functional component, child elements.
*/
['li']?: TListSlot;
/**
* Customize list item
* @scope `depth` depth of the list
* @scope `ordered` whether it is ordered list
* @scope `index` index of the list item
* @scope `children` Functional component, child elements.
*/
['list-item']?: TListSlot;
/**
* Customize table element: td
*
* @scope `isHead` whether it is in head
* @scope `children` Functional component, child elements.
*/
['td']?: TTableElementSlot;
/**
* Customize table element: th
*
* @scope `isHead` whether it is in head
* @scope `children` Functional component, child elements.
*/
['th']?: TTableElementSlot;
/**
* Customize table element: tr
*
* @scope `isHead` whether it is in head
* @scope `children` Functional component, child elements.
*/
['tr']?: TTableElementSlot;
};
};
}
type TTableElementSlot = TCustomSlot<TTableProps>;
type TListSlot = TCustomSlot<TListProps>;
type THeadingSlot = TCustomSlot<THeadingProps>;
type TCodeSlot = TCustomSlot<TCodeProps>;
type HtmlTagNames = keyof HTMLElementTagNameMap;
type TBaseSlots = {
// An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead.ts(1337)
// [key: HtmlTagNames]: (scope: Record<string, any>) => VNode[] | VNode
[key in HtmlTagNames]?: (scope: TBaseSlotScope) => VNode[] | VNode;
} & {
[key: string]: (scope: TBaseSlotScope) => VNode[] | VNode;
};
type TBaseSlotScope = TElementChild & Attributes;
interface TElementChild {
/** Functional component, child elements. */
children: () => VNodeArrayChildren;
}
type TCustomSlot<T> = (scope: TBaseSlotScope & T) => VNode[] | VNode;

View File

@@ -0,0 +1,67 @@
import type { Root } from 'hast';
import type { Root as MdastRoot } from 'mdast';
import type { Options as TRehypeOptions } from 'mdast-util-to-hast';
import type { PluggableList, Processor } from 'unified';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import type { SanitizeOptions } from './types';
import deepmerge from 'deepmerge';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { unified } from 'unified';
import { computed, toValue } from 'vue';
export interface TUseMarkdownProcessorOptions {
remarkPlugins?: MaybeRefOrGetter<PluggableList>;
rehypePlugins?: MaybeRefOrGetter<PluggableList>;
rehypeOptions?: MaybeRefOrGetter<Omit<TRehypeOptions, 'file'>>;
sanitize?: MaybeRefOrGetter<boolean>;
sanitizeOptions?: MaybeRefOrGetter<SanitizeOptions>;
}
export function useMarkdownProcessor(options?: TUseMarkdownProcessorOptions): {
processor: ComputedRef<
Processor<MdastRoot, MdastRoot, Root, undefined, undefined>
>;
} {
const processor = computed(() => {
return createProcessor({
prePlugins: [remarkParse, ...(toValue(options?.remarkPlugins) ?? [])],
rehypePlugins: toValue(options?.rehypePlugins),
rehypeOptions: toValue(options?.rehypeOptions),
sanitize: toValue(options?.sanitize),
sanitizeOptions: toValue(options?.sanitizeOptions)
});
});
return { processor };
}
export function createProcessor(options?: {
prePlugins?: PluggableList;
rehypePlugins?: PluggableList;
rehypeOptions?: Omit<TRehypeOptions, 'file'>;
sanitize?: boolean;
sanitizeOptions?: SanitizeOptions;
// TODO: fix types
}): Processor<any, any, any, any, any> {
return unified()
.use(options?.prePlugins ?? [])
.use(remarkRehype, {
allowDangerousHtml: true,
...(options?.rehypeOptions || {})
})
.use(options?.rehypePlugins ?? [])
.use(
options?.sanitize
? [
[
rehypeSanitize,
deepmerge(
defaultSchema,
options?.sanitizeOptions?.sanitizeOptions || {},
options?.sanitizeOptions?.mergeOptions || {}
)
]
]
: []
);
}

View File

@@ -0,0 +1,6 @@
export * from './useComponents';
export * from './useMarkdown';
export * from './useMermaid';
export * from './useMermaidZoom';
export * from './usePlugins';
export * from './useThemeMode';

View File

@@ -0,0 +1,11 @@
import { h } from 'vue';
import { CodeX } from '../components/index';
function useComponents() {
const components = {
code: (raw: any) => h(CodeX, { raw })
};
return components;
}
export { useComponents };

View File

@@ -0,0 +1,42 @@
import { flow } from 'lodash-es';
export function useProcessMarkdown(markdown: string) {
return preprocessLaTeX(markdown);
}
export function preprocessLaTeX(markdown: string) {
if (typeof markdown !== 'string') return markdown;
const codeBlockRegex = /```[\s\S]*?```/g;
const codeBlocks = markdown.match(codeBlockRegex) || [];
const escapeReplacement = (str: string) => str.replace(/\$/g, '_ELX_DOLLAR_');
let processedMarkdown = markdown.replace(
codeBlockRegex,
'ELX_CODE_BLOCK_PLACEHOLDER'
);
processedMarkdown = flow([
(str: string) =>
str.replace(/\\\[(.*?)\\\]/g, (_, equation) => `$$${equation}$$`),
(str: string) =>
str.replace(/\\\[([\s\S]*?)\\\]/g, (_, equation) => `$$${equation}$$`),
(str: string) =>
str.replace(/\\\((.*?)\\\)/g, (_, equation) => `$$${equation}$$`),
(str: string) =>
str.replace(
/(^|[^\\])\$(.+?)\$/g,
(_, prefix, equation) => `${prefix}$${equation}$`
)
])(processedMarkdown);
codeBlocks.forEach(block => {
processedMarkdown = processedMarkdown.replace(
'ELX_CODE_BLOCK_PLACEHOLDER',
escapeReplacement(block)
);
});
processedMarkdown = processedMarkdown.replace(/_ELX_DOLLAR_/g, '$');
return processedMarkdown;
}

View File

@@ -0,0 +1,110 @@
import type { Ref } from 'vue';
import { throttle } from 'lodash-es';
import { computed, ref, watch } from 'vue';
interface UseMermaidOptions {
id?: string;
theme?: 'default' | 'dark' | 'forest' | 'neutral' | string;
config?: any;
}
async function loadMermaid() {
if (typeof window === 'undefined') return null;
const mermaidModule = await import('mermaid');
return mermaidModule.default;
}
let mermaidContainer: HTMLElement | null = null;
function getMermaidContainer(): HTMLElement {
if (!mermaidContainer) {
mermaidContainer = document.querySelector(
'.elx-markdown-mermaid-container'
) as HTMLElement;
if (!mermaidContainer) {
mermaidContainer = document.createElement('div') as HTMLElement;
mermaidContainer.ariaHidden = 'true';
mermaidContainer.style.maxHeight = '0';
mermaidContainer.style.opacity = '0';
mermaidContainer.style.overflow = 'hidden';
mermaidContainer.classList.add('elx-markdown-mermaid-container');
document.body.append(mermaidContainer);
}
}
return mermaidContainer;
}
export function useMermaid(
content: string | Ref<string>,
options: UseMermaidOptions = {}
) {
const { id = 'mermaid', theme = 'default', config = {} } = options;
const mermaidConfig = computed(() => ({
suppressErrorRendering: true,
startOnLoad: false,
securityLevel: 'loose',
theme,
...config
}));
const data = ref('');
const error = ref<unknown>(null);
const throttledRender = throttle(
async () => {
const contentValue =
typeof content === 'string' ? content : content.value;
if (!contentValue?.trim()) {
data.value = '';
error.value = null;
return;
}
try {
// 动态加载 mermaid 库
const mermaidInstance = await loadMermaid();
if (!mermaidInstance) {
data.value = contentValue;
error.value = null;
return;
}
// 语法校验
const isValid = await mermaidInstance.parse(contentValue.trim());
if (!isValid) {
console.log('Mermaid parse error: Invalid syntax');
data.value = '';
error.value = new Error('Mermaid parse error: Invalid syntax');
return;
}
// 初始化 mermaid 配置
mermaidInstance.initialize(mermaidConfig.value);
const renderId = `${id}-${Math.random().toString(36).substr(2, 9)}`;
const container = getMermaidContainer();
const { svg } = await mermaidInstance.render(
renderId,
contentValue,
container
);
data.value = svg;
error.value = null;
} catch (err) {
console.log('Mermaid render error:', err);
data.value = '';
error.value = err;
}
},
300,
{ trailing: true, leading: true }
);
// 监听内容变化,自动触发渲染
watch(
() => (typeof content === 'string' ? content : content.value),
() => {
throttledRender();
},
{ immediate: true }
);
return {
data,
error
};
}

View File

@@ -0,0 +1,179 @@
import type {
MermaidZoomControls,
UseMermaidZoomOptions
} from '../components/Mermaid/types';
import { onUnmounted, ref, watch } from 'vue';
export function useMermaidZoom(
options: UseMermaidZoomOptions
): MermaidZoomControls {
const { container } = options;
const scale = ref(1);
const posX = ref(0);
const posY = ref(0);
const isDragging = ref(false);
let removeEvents: (() => void) | null = null;
// 获取SVG元素
const getSvg = () =>
container.value?.querySelector('.mermaid-content svg') as HTMLElement;
// 更新变换
const updateTransform = (svg: HTMLElement) => {
svg.style.transformOrigin = 'center center';
svg.style.transform = `translate(${posX.value}px, ${posY.value}px) scale(${scale.value})`;
};
// 重置状态
const resetState = () => {
scale.value = 1;
posX.value = 0;
posY.value = 0;
isDragging.value = false;
};
// 添加拖拽事件
const addDragEvents = (content: HTMLElement) => {
let startX = 0;
let startY = 0;
const onStart = (clientX: number, clientY: number) => {
isDragging.value = true;
startX = clientX - posX.value;
startY = clientY - posY.value;
document.body.style.userSelect = 'none';
};
const onMove = (clientX: number, clientY: number) => {
if (isDragging.value) {
posX.value = clientX - startX;
posY.value = clientY - startY;
updateTransform(content);
}
};
const onEnd = () => {
isDragging.value = false;
document.body.style.userSelect = '';
};
// 鼠标事件
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0)
return; // ⭐️ 只响应鼠标左键
e.preventDefault();
onStart(e.clientX, e.clientY);
};
const onMouseMove = (e: MouseEvent) => onMove(e.clientX, e.clientY);
// 触摸事件
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length === 1) {
onStart(e.touches[0].clientX, e.touches[0].clientY);
}
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length === 1) {
e.preventDefault();
onMove(e.touches[0].clientX, e.touches[0].clientY);
}
};
// 绑定事件
content.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onEnd);
content.addEventListener('touchstart', onTouchStart, { passive: false });
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onEnd);
return () => {
content.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onEnd);
content.removeEventListener('touchstart', onTouchStart);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onEnd);
document.body.style.userSelect = '';
};
};
// 缩放功能
const zoomIn = () => {
const svg = getSvg();
if (svg) {
scale.value = Math.min(scale.value + 0.2, 10);
updateTransform(svg);
}
};
const zoomOut = () => {
const svg = getSvg();
if (svg) {
scale.value = Math.max(scale.value - 0.2, 0.1);
updateTransform(svg);
}
};
const reset = () => {
const svg = getSvg();
if (svg) {
resetState();
updateTransform(svg);
}
};
const fullscreen = () => {
if (!container.value)
return;
if (document.fullscreenElement) {
document.exitFullscreen();
}
else {
container.value.requestFullscreen?.();
}
};
const initialize = () => {
if (!container.value)
return;
resetState();
const svg = getSvg();
if (svg) {
removeEvents = addDragEvents(svg);
updateTransform(svg);
}
};
const destroy = () => {
removeEvents?.();
removeEvents = null;
resetState();
};
// 监听容器变化
watch(
() => container.value,
() => {
destroy();
resetState();
}
);
// 组件卸载时清理
onUnmounted(destroy);
return {
zoomIn,
zoomOut,
reset,
fullscreen,
destroy,
initialize
};
}

View File

@@ -0,0 +1,51 @@
import type { Pluggable } from 'unified';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { computed, toRefs } from 'vue';
import { rehypeAnimatedPlugin } from '../plugins/rehypePlugin';
function usePlugins(props: any) {
const {
allowHtml,
enableAnimate,
enableLatex,
enableBreaks,
rehypePlugins,
remarkPlugins,
rehypePluginsAhead,
remarkPluginsAhead
} = toRefs(props);
const rehype = computed(() => {
return [
...(rehypePluginsAhead.value as Pluggable[]),
allowHtml.value && rehypeRaw,
enableLatex.value && rehypeKatex,
enableAnimate.value && rehypeAnimatedPlugin,
...(rehypePlugins.value as Pluggable[])
].filter(Boolean) as Pluggable[];
});
const remark = computed(() => {
const base: (Pluggable | { plugins: Pluggable[] })[] = [
enableLatex.value && remarkMath,
enableBreaks.value && remarkBreaks
].filter(Boolean) as (Pluggable | { plugins: Pluggable[] })[];
return [
[remarkGfm, { singleTilde: false }],
...(remarkPluginsAhead.value as (Pluggable | { plugins: Pluggable[] })[]),
...base,
...(remarkPlugins.value as (Pluggable | { plugins: Pluggable[] })[])
];
});
return {
rehypePlugins: rehype,
remarkPlugins: remark
};
}
export { usePlugins };

View File

@@ -0,0 +1,168 @@
import type { Root } from 'hast';
import type {
BundledHighlighterOptions,
CodeToHastOptions,
CodeToTokensBaseOptions,
CodeToTokensOptions,
CodeToTokensWithThemesOptions,
GrammarState,
HighlighterGeneric,
RequireKeys,
ThemedToken,
ThemedTokenWithVariants,
TokensResult
} from 'shiki';
import { GLOBAL_SHIKI_KEY } from '@components/XMarkdownCore/shared';
import {
createdBundledHighlighter,
createOnigurumaEngine,
createSingletonShorthands
} from 'shiki';
import { onUnmounted, provide, ref } from 'vue';
import { languageLoaders, themeLoaders } from '../../../hooks/shiki-loader';
export interface GlobalShiki {
codeToHtml: (
code: string,
options: CodeToHastOptions<string, string>
) => Promise<string>;
codeToHast: (
code: string,
options: CodeToHastOptions<string, string>
) => Promise<Root>;
codeToTokensBase: (
code: string,
options: RequireKeys<
CodeToTokensBaseOptions<string, string>,
'theme' | 'lang'
>
) => Promise<ThemedToken[][]>;
codeToTokens: (
code: string,
options: CodeToTokensOptions<string, string>
) => Promise<TokensResult>;
codeToTokensWithThemes: (
code: string,
options: RequireKeys<
CodeToTokensWithThemesOptions<string, string>,
'lang' | 'themes'
>
) => Promise<ThemedTokenWithVariants[][]>;
getSingletonHighlighter: (
options?: Partial<BundledHighlighterOptions<string, string>>
) => Promise<HighlighterGeneric<string, string>>;
getLastGrammarState:
| ((element: ThemedToken[][] | Root) => GrammarState)
| ((
code: string,
options: CodeToTokensBaseOptions<string, string>
) => Promise<GrammarState>);
}
/**
* @description Shiki 管理器(单例 + 懒初始化)
*/
class ShikiManager {
private static instance: ShikiManager | null = null;
private shikiInstance: GlobalShiki | null = null;
private constructor() {}
static getInstance(): ShikiManager {
if (!ShikiManager.instance) {
ShikiManager.instance = new ShikiManager();
}
return ShikiManager.instance;
}
public getShiki(): GlobalShiki {
if (this.shikiInstance) return this.shikiInstance;
const highlighterFactory = createdBundledHighlighter({
langs: languageLoaders,
themes: themeLoaders,
engine: () => createOnigurumaEngine(import('shiki/wasm'))
});
const {
codeToHtml,
codeToHast,
codeToTokensBase,
codeToTokens,
codeToTokensWithThemes,
getSingletonHighlighter,
getLastGrammarState
} = createSingletonShorthands(highlighterFactory);
this.shikiInstance = {
codeToHtml,
codeToHast,
codeToTokensBase,
codeToTokens,
codeToTokensWithThemes,
getSingletonHighlighter,
getLastGrammarState
};
return this.shikiInstance;
}
public dispose() {
this.shikiInstance = null;
ShikiManager.instance = null;
}
}
// 全局状态管理
let globalShikiInstance: GlobalShiki | undefined;
let globalShikiManager: ShikiManager | undefined;
let referenceCount = 0;
const shikiIsCreated = ref(false);
const shikiInstance = ref<GlobalShiki>();
const shikiManager = ref<ShikiManager>();
/**
* @description 在 Vue 中提供 Shiki 实例(支持多组件实例)
*/
export function useShiki(): GlobalShiki {
// 增加引用计数
referenceCount++;
// ✅ 注册 onUnmounted 钩子
onUnmounted(() => {
referenceCount--;
console.log(`shiki reference count: ${referenceCount}`);
// 只有当所有组件都卸载时才清理
if (referenceCount === 0) {
console.log('shiki destroyed - all references removed');
shikiIsCreated.value = false;
shikiInstance.value = undefined;
shikiManager.value?.dispose();
globalShikiManager?.dispose();
globalShikiInstance = undefined;
globalShikiManager = undefined;
}
});
// ✅ 仅在首次时初始化
if (!globalShikiInstance) {
console.log('shiki created');
globalShikiManager = ShikiManager.getInstance();
globalShikiInstance = globalShikiManager.getShiki();
shikiManager.value = globalShikiManager;
shikiInstance.value = globalShikiInstance;
provide(GLOBAL_SHIKI_KEY, shikiInstance);
shikiIsCreated.value = true;
} else {
// 为后续组件实例提供相同的实例
shikiManager.value = globalShikiManager;
shikiInstance.value = globalShikiInstance;
provide(GLOBAL_SHIKI_KEY, shikiInstance);
}
return globalShikiInstance;
}

View File

@@ -0,0 +1,81 @@
import type {
BundledLanguage,
BundledTheme,
HighlighterGeneric,
ThemeRegistrationResolved
} from 'shiki';
import type { InitShikiOptions } from '../shared';
import { createHighlighter } from 'shiki';
import { shikiThemeDefault } from '../shared';
import { useDarkModeWatcher } from './useThemeMode';
interface UseShikiOptions {
themes?: InitShikiOptions['themes'];
}
const highlighter =
shallowRef<HighlighterGeneric<BundledLanguage, BundledTheme>>();
const shikiThemeColor = ref<ThemeRegistrationResolved>();
const hasCreated = ref(false);
const oldThemes = ref<InitShikiOptions['themes']>();
export function useGlobalShikiHighlighter(options?: UseShikiOptions) {
const { isDark } = useDarkModeWatcher();
const themeArr = computed(() => {
if (options?.themes) {
return Object.keys(options.themes).map(key => options.themes![key]);
}
return [shikiThemeDefault.light, shikiThemeDefault.dark];
});
const updateThemeColor = () => {
if (!highlighter.value || !hasCreated.value)
return;
const themeName = isDark.value ? themeArr.value[1] : themeArr.value[0];
shikiThemeColor.value = highlighter.value.getTheme(themeName as any);
};
const init = async () => {
if (
hasCreated.value &&
JSON.stringify(oldThemes.value) === JSON.stringify(options?.themes)
) {
updateThemeColor();
return;
}
const themes = [...themeArr.value];
if (!themes.length)
return;
const newHighlighter = await createHighlighter({
themes: themes as any[],
langs: []
});
highlighter.value?.dispose?.();
highlighter.value = newHighlighter;
oldThemes.value = options?.themes;
hasCreated.value = true;
updateThemeColor();
};
watch(isDark, updateThemeColor, { immediate: true });
const destroy = () => {
hasCreated.value = false;
highlighter.value?.dispose?.();
};
return {
highlighter,
shikiThemeColor,
isDark,
init,
destroy
};
}

View File

@@ -0,0 +1,26 @@
import { onMounted, onUnmounted, ref } from 'vue';
export function useDarkModeWatcher() {
const isDark = ref(document.body.classList.contains('dark'));
let observer: MutationObserver;
onMounted(() => {
observer = new MutationObserver(() => {
isDark.value = document.body.classList.contains('dark');
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class'] // 只监听 class 变化
});
});
onUnmounted(() => {
observer && observer.disconnect();
});
return {
isDark
};
}

View File

@@ -0,0 +1,2 @@
export * from './core';
export * from './MarkdownRender';

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import type { Element, ElementContent, Root } from 'hast';
import type { BuildVisitor } from 'unist-util-visit';
import { visit } from 'unist-util-visit';
export function rehypeAnimatedPlugin() {
return (tree: Root) => {
visit(tree, 'element', ((node: Element) => {
if (
[
'p',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'li',
'strong',
'th',
'td'
].includes(node.tagName) &&
node.children
) {
const newChildren: Array<ElementContent> = [];
for (const child of node.children) {
if (child.type === 'text') {
// @ts-expect-error Segmenter is not available in all environments
const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
const segments = segmenter.segment(child.value);
const words = [...segments]
.map(segment => segment.segment)
.filter(Boolean);
words.forEach((word: string) => {
newChildren.push({
children: [{ type: 'text', value: word }],
properties: {
className: 'x-markdown-animated-word'
},
tagName: 'span',
type: 'element'
});
});
} else {
newChildren.push(child);
}
}
node.children = newChildren;
}
}) as BuildVisitor<Root, 'element'>);
};
}

View File

@@ -0,0 +1,160 @@
import type { GlobalShiki } from '@components/XMarkdownCore/hooks/useShiki';
import type { CodeXProps } from '@components/XMarkdownCore/shared/types';
import type { BuiltinTheme } from 'shiki';
import type { PluggableList } from 'unified';
import type { MermaidToolbarConfig } from '../components/Mermaid/types';
import type { ElxRunCodeOptions } from '../components/RunCode/type';
import type { CustomAttrs, SanitizeOptions } from '../core';
import type { InitShikiOptions } from './shikiHighlighter';
export const shikiThemeDefault: InitShikiOptions['themes'] = {
light: 'vitesse-light',
dark: 'vitesse-dark'
};
export const DEFAULT_PROPS = {
markdown: '',
allowHtml: false,
enableLatex: true,
enableAnimate: false,
enableBreaks: true,
codeXProps: () => ({}),
codeXRender: () => ({}),
codeXSlot: () => ({}),
codeHighlightTheme: null,
customAttrs: () => ({}),
remarkPlugins: () => [],
remarkPluginsAhead: () => [],
rehypePlugins: () => [],
rehypePluginsAhead: () => [],
rehypeOptions: () => ({}),
sanitize: false,
sanitizeOptions: () => ({}),
mermaidConfig: () => ({}),
langs: () => [],
defaultThemeMode: '' as 'light' | 'dark',
themes: () => ({ ...shikiThemeDefault }),
colorReplacements: () => ({}),
needViewCodeBtn: true,
secureViewCode: false,
viewCodeModalOptions: () => ({})
};
export const MARKDOWN_CORE_PROPS = {
markdown: {
type: String,
default: ''
},
allowHtml: {
type: Boolean,
default: false
},
enableLatex: {
type: Boolean,
default: true
},
enableAnimate: {
type: Boolean,
default: false
},
enableBreaks: {
type: Boolean,
default: true
},
codeXProps: {
type: Object as PropType<CodeXProps>,
default: () => ({
enableCodePreview: false, // 启动代码预览功能
enableCodeCopy: true, // 启动代码复制功能
enableThemeToggle: false, // 启动主题切换
enableCodeLineNumber: false
})
},
codeXRender: {
type: Object,
default: () => ({})
},
codeXSlot: {
type: Object,
default: () => ({})
},
codeHighlightTheme: {
type: Object as PropType<BuiltinTheme | null>,
default: () => null
},
customAttrs: {
type: Object as PropType<CustomAttrs>,
default: () => ({})
},
remarkPlugins: {
type: Array as PropType<PluggableList>,
default: () => []
},
remarkPluginsAhead: {
type: Array as PropType<PluggableList>,
default: () => []
},
rehypePlugins: {
type: Array as PropType<PluggableList>,
default: () => []
},
rehypePluginsAhead: {
type: Array as PropType<PluggableList>,
default: () => []
},
rehypeOptions: {
type: Object as PropType<Record<string, any>>,
default: () => ({})
},
sanitize: {
type: Boolean,
default: false
},
sanitizeOptions: {
type: Object as PropType<SanitizeOptions>,
default: () => ({})
},
mermaidConfig: {
type: Object as PropType<Partial<MermaidToolbarConfig>>,
default: () => ({})
},
langs: {
type: Array as PropType<InitShikiOptions['langs']>,
default: () => []
},
defaultThemeMode: {
type: String as PropType<'light' | 'dark'>,
default: 'light'
},
themes: {
type: Object as PropType<InitShikiOptions['themes']>,
default: () =>
({
...shikiThemeDefault
}) satisfies InitShikiOptions['themes']
},
colorReplacements: {
type: Object as PropType<InitShikiOptions['colorReplacements']>,
default: () => ({})
},
needViewCodeBtn: {
type: Boolean,
default: true
},
secureViewCode: {
type: Boolean,
default: false
},
viewCodeModalOptions: {
type: Object as PropType<ElxRunCodeOptions>,
default: () => ({})
},
isDark: {
type: Boolean,
default: false
},
globalShiki: {
type: Object as PropType<GlobalShiki>,
default: () => ({})
}
};

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