diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/Chat/MessageCreatedOutput.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/Chat/MessageCreatedOutput.cs new file mode 100644 index 00000000..e0ec14f7 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/Chat/MessageCreatedOutput.cs @@ -0,0 +1,47 @@ +using System.Reflection; +using System.Text.Json.Serialization; + +namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Chat; + +/// +/// 消息创建结果输出 +/// +public class MessageCreatedOutput +{ + /// + /// 消息类型 + /// + [JsonIgnore] + public ChatMessageTypeEnum TypeEnum { get; set; } + + /// + /// 消息类型 + /// + public string Type => TypeEnum.ToString(); + + /// + /// 消息ID + /// + public Guid MessageId { get; set; } + + /// + /// 消息创建时间 + /// + public DateTime CreationTime { get; set; } +} + +/// +/// 消息类型枚举 +/// +public enum ChatMessageTypeEnum +{ + /// + /// 用户消息 + /// + UserMessage, + + /// + /// 系统消息 + /// + SystemMessage +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/RankingGetListInput.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/RankingGetListInput.cs new file mode 100644 index 00000000..71f39c56 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/RankingGetListInput.cs @@ -0,0 +1,14 @@ +using Yi.Framework.AiHub.Domain.Shared.Enums; + +namespace Yi.Framework.AiHub.Application.Contracts.Dtos; + +/// +/// 排行榜查询输入 +/// +public class RankingGetListInput +{ + /// + /// 排行榜类型:0-模型,1-工具,不传返回全部 + /// + public RankingTypeEnum? Type { get; set; } +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/RankingItemDto.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/RankingItemDto.cs new file mode 100644 index 00000000..12b478d8 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/RankingItemDto.cs @@ -0,0 +1,41 @@ +using Yi.Framework.AiHub.Domain.Shared.Enums; + +namespace Yi.Framework.AiHub.Application.Contracts.Dtos; + +/// +/// 排行榜项DTO +/// +public class RankingItemDto +{ + public Guid Id { get; set; } + + /// + /// 名称 + /// + public string Name { get; set; } = null!; + + /// + /// 描述 + /// + public string Description { get; set; } = null!; + + /// + /// Logo地址 + /// + public string? LogoUrl { get; set; } + + /// + /// 得分 + /// + public decimal Score { get; set; } + + /// + /// 提供者 + /// + public string Provider { get; set; } = null!; + + /// + /// 排行榜类型 + /// + public RankingTypeEnum Type { get; set; } +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/IServices/IRankingService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/IServices/IRankingService.cs new file mode 100644 index 00000000..d689d9eb --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/IServices/IRankingService.cs @@ -0,0 +1,16 @@ +using Yi.Framework.AiHub.Application.Contracts.Dtos; + +namespace Yi.Framework.AiHub.Application.Contracts.IServices; + +/// +/// 排行榜服务接口 +/// +public interface IRankingService +{ + /// + /// 获取排行榜列表(全量返回) + /// + /// 查询条件 + /// 排行榜列表 + Task> GetListAsync(RankingGetListInput input); +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/AiChatService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/AiChatService.cs index 5f3dbe1c..3ce93e5d 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/AiChatService.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/AiChatService.cs @@ -127,54 +127,55 @@ public class AiChatService : ApplicationService } - /// - /// 发送消息 - /// - /// - /// - /// - [HttpPost("ai-chat/send")] - public async Task PostSendAsync([FromBody] ThorChatCompletionsRequest input, [FromQuery] Guid? sessionId, - CancellationToken cancellationToken) - { - //除了免费模型,其他的模型都要校验 - if (input.Model!=FreeModelId) - { - //有token,需要黑名单校验 - if (CurrentUser.IsAuthenticated) - { - await _aiBlacklistManager.VerifiyAiBlacklist(CurrentUser.GetId()); - if (!CurrentUser.IsAiVip()) - { - throw new UserFriendlyException("该模型需要VIP用户才能使用,请购买VIP后重新登录重试"); - } - } - else - { - throw new UserFriendlyException("未登录用户,只能使用未加速的DeepSeek-R1,请登录后重试"); - } - } - - //如果是尊享包服务,需要校验是是否尊享包足够 - if (CurrentUser.IsAuthenticated) - { - var isPremium = await _modelManager.IsPremiumModelAsync(input.Model); - - if (isPremium) - { - // 检查尊享token包用量 - var availableTokens = await _premiumPackageManager.GetAvailableTokensAsync(CurrentUser.GetId()); - if (availableTokens <= 0) - { - throw new UserFriendlyException("尊享token包用量不足,请先购买尊享token包"); - } - } - } - - //ai网关代理httpcontext - await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input, - CurrentUser.Id, sessionId, null, CancellationToken.None); - } + // /// + // /// 发送消息 + // /// + // /// + // /// + // /// + // [HttpPost("ai-chat/send")] + // [Obsolete] + // public async Task PostSendAsync([FromBody] ThorChatCompletionsRequest input, [FromQuery] Guid? sessionId, + // CancellationToken cancellationToken) + // { + // //除了免费模型,其他的模型都要校验 + // if (input.Model!=FreeModelId) + // { + // //有token,需要黑名单校验 + // if (CurrentUser.IsAuthenticated) + // { + // await _aiBlacklistManager.VerifiyAiBlacklist(CurrentUser.GetId()); + // if (!CurrentUser.IsAiVip()) + // { + // throw new UserFriendlyException("该模型需要VIP用户才能使用,请购买VIP后重新登录重试"); + // } + // } + // else + // { + // throw new UserFriendlyException("未登录用户,只能使用未加速的DeepSeek-R1,请登录后重试"); + // } + // } + // + // //如果是尊享包服务,需要校验是是否尊享包足够 + // if (CurrentUser.IsAuthenticated) + // { + // var isPremium = await _modelManager.IsPremiumModelAsync(input.Model); + // + // if (isPremium) + // { + // // 检查尊享token包用量 + // var availableTokens = await _premiumPackageManager.GetAvailableTokensAsync(CurrentUser.GetId()); + // if (availableTokens <= 0) + // { + // throw new UserFriendlyException("尊享token包用量不足,请先购买尊享token包"); + // } + // } + // } + // + // //ai网关代理httpcontext + // await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input, + // CurrentUser.Id, sessionId, null, CancellationToken.None); + // } /// /// 发送消息 diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/MessageService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/MessageService.cs index 56101112..378fbb7b 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/MessageService.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/MessageService.cs @@ -46,7 +46,7 @@ public class MessageService : ApplicationService /// /// 删除参数,包含消息Id列表和是否删除后续消息的开关 [Authorize] - public async Task DeleteAsync([FromBody] MessageDeleteInput input) + public async Task DeleteAsync([FromQuery] MessageDeleteInput input) { var userId = CurrentUser.GetId(); diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/RankingService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/RankingService.cs new file mode 100644 index 00000000..92b13c84 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/RankingService.cs @@ -0,0 +1,38 @@ +using Mapster; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Volo.Abp.Application.Services; +using Yi.Framework.AiHub.Application.Contracts.Dtos; +using Yi.Framework.AiHub.Application.Contracts.IServices; +using Yi.Framework.AiHub.Domain.Entities; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace Yi.Framework.AiHub.Application.Services; + +/// +/// 排行榜服务 +/// +public class RankingService : ApplicationService, IRankingService +{ + private readonly ISqlSugarRepository _repository; + + public RankingService(ISqlSugarRepository repository) + { + _repository = repository; + } + + /// + /// 获取排行榜列表(全量返回,按得分降序) + /// + [HttpGet("ranking/list")] + [AllowAnonymous] + public async Task> GetListAsync([FromQuery] RankingGetListInput input) + { + var query = _repository._DbQueryable + .WhereIF(input.Type.HasValue, x => x.Type == input.Type!.Value) + .OrderByDescending(x => x.Score); + + var entities = await query.ToListAsync(); + return entities.Adapt>(); + } +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Enums/RankingTypeEnum.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Enums/RankingTypeEnum.cs new file mode 100644 index 00000000..d233b944 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Enums/RankingTypeEnum.cs @@ -0,0 +1,17 @@ +namespace Yi.Framework.AiHub.Domain.Shared.Enums; + +/// +/// 排行榜类型枚举 +/// +public enum RankingTypeEnum +{ + /// + /// 模型 + /// + Model = 0, + + /// + /// 工具 + /// + Tool = 1 +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Entities/Chat/MessageAggregateRoot.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Entities/Chat/MessageAggregateRoot.cs index a704e924..e390efd4 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Entities/Chat/MessageAggregateRoot.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Entities/Chat/MessageAggregateRoot.cs @@ -53,6 +53,15 @@ public class MessageAggregateRoot : FullAuditedAggregateRoot TotalTokenCount = tokenUsage.TotalTokens ?? 0 }; } + else + { + this.TokenUsage = new TokenUsageValueObject + { + OutputTokenCount = 0, + InputTokenCount = 0, + TotalTokenCount = 0 + }; + } this.MessageType = sessionId is null ? MessageTypeEnum.Api : MessageTypeEnum.Web; } diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Entities/RankingItemAggregateRoot.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Entities/RankingItemAggregateRoot.cs new file mode 100644 index 00000000..ef36886b --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Entities/RankingItemAggregateRoot.cs @@ -0,0 +1,42 @@ +using SqlSugar; +using Volo.Abp.Domain.Entities.Auditing; +using Yi.Framework.AiHub.Domain.Shared.Enums; + +namespace Yi.Framework.AiHub.Domain.Entities; + +/// +/// 排行榜项聚合根 +/// +[SugarTable("Ai_RankingItem")] +public class RankingItemAggregateRoot : FullAuditedAggregateRoot +{ + /// + /// 名称 + /// + public string Name { get; set; } + + /// + /// 描述 + /// + public string? Description { get; set; } + + /// + /// Logo地址 + /// + public string? LogoUrl { get; set; } + + /// + /// 得分 + /// + public decimal Score { get; set; } + + /// + /// 提供者 + /// + public string? Provider { get; set; } + + /// + /// 排行榜类型:0-模型,1-工具 + /// + public RankingTypeEnum Type { get; set; } +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs index 90708bbe..f789241a 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs @@ -23,6 +23,7 @@ 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.Application.Contracts.Dtos.Chat; using Yi.Framework.AiHub.Domain.Shared.Extensions; using Yi.Framework.Core.Extensions; using Yi.Framework.SqlSugarCore.Abstractions; @@ -1143,6 +1144,7 @@ public class AiGateWayManager : DomainService Guid? tokenId = null, CancellationToken cancellationToken = default) { + var startTime = DateTime.Now; var response = httpContext.Response; // 设置响应头,声明是 SSE 流 response.ContentType = "text/event-stream;charset=utf-8;"; @@ -1206,20 +1208,17 @@ public class AiGateWayManager : DomainService throw new UserFriendlyException($"不支持的API类型: {apiType}"); } - // 标记完成并等待消费任务结束 - isComplete = true; - await outputTask; - + // 统一的统计处理 - await _aiMessageManager.CreateUserMessageAsync(userId, sessionId, + var userMessageId = await _aiMessageManager.CreateUserMessageAsync(userId, sessionId, new MessageInputDto { Content = sessionId is null ? "不予存储" : processResult?.UserContent ?? string.Empty, ModelId = sourceModelId, TokenUsage = processResult?.TokenUsage, - }, tokenId); + }, tokenId,createTime:startTime); - await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId, + var systemMessageId = await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId, new MessageInputDto { Content = sessionId is null ? "不予存储" : processResult?.SystemContent ?? string.Empty, @@ -1227,6 +1226,29 @@ public class AiGateWayManager : DomainService TokenUsage = processResult?.TokenUsage }, tokenId); + // 流式返回消息ID + var now = DateTime.Now; + var userMessageOutput = new MessageCreatedOutput + { + TypeEnum = ChatMessageTypeEnum.UserMessage, + MessageId = userMessageId, + CreationTime = startTime + }; + messageQueue.Enqueue($"data: {JsonSerializer.Serialize(userMessageOutput, ThorJsonSerializer.DefaultOptions)}\n\n"); + + var systemMessageOutput = new MessageCreatedOutput + { + TypeEnum = ChatMessageTypeEnum.SystemMessage, + MessageId = systemMessageId, + CreationTime = now + }; + messageQueue.Enqueue($"data: {JsonSerializer.Serialize(systemMessageOutput, ThorJsonSerializer.DefaultOptions)}\n\n"); + + // 标记完成并等待消费任务结束 + messageQueue.Enqueue("data: [DONE]\n\n"); + isComplete = true; + await outputTask; + await _usageStatisticsManager.SetUsageAsync(userId, sourceModelId, processResult?.TokenUsage, tokenId); // 扣减尊享token包用量 @@ -1324,8 +1346,7 @@ public class AiGateWayManager : DomainService }); messageQueue.Enqueue($"data: {errorMessage}\n\n"); } - - messageQueue.Enqueue("data: [DONE]\n\n"); + return new StreamProcessResult { UserContent = userContent, diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiMessageManager.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiMessageManager.cs index 95730424..90509246 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiMessageManager.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiMessageManager.cs @@ -24,11 +24,12 @@ public class AiMessageManager : DomainService /// 消息输入 /// Token Id(Web端传Guid.Empty) /// - public async Task CreateSystemMessageAsync(Guid? userId, Guid? sessionId, MessageInputDto input, Guid? tokenId = null) + public async Task CreateSystemMessageAsync(Guid? userId, Guid? sessionId, MessageInputDto input, Guid? tokenId = null) { input.Role = "system"; var message = new MessageAggregateRoot(userId, sessionId, input.Content, input.Role, input.ModelId, input.TokenUsage, tokenId); await _repository.InsertAsync(message); + return message.Id; } /// @@ -38,11 +39,16 @@ public class AiMessageManager : DomainService /// 会话Id /// 消息输入 /// Token Id(Web端传Guid.Empty) + /// /// - public async Task CreateUserMessageAsync(Guid? userId, Guid? sessionId, MessageInputDto input, Guid? tokenId = null) + public async Task CreateUserMessageAsync( Guid? userId, Guid? sessionId, MessageInputDto input, Guid? tokenId = null,DateTime? createTime=null) { input.Role = "user"; - var message = new MessageAggregateRoot(userId, sessionId, input.Content, input.Role, input.ModelId, input.TokenUsage, tokenId); + var message = new MessageAggregateRoot(userId, sessionId, input.Content, input.Role, input.ModelId, input.TokenUsage, tokenId) + { + CreationTime = createTime??DateTime.Now + }; await _repository.InsertAsync(message); + return message.Id; } } \ No newline at end of file diff --git a/Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs b/Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs index 5529f1e8..afaa6969 100644 --- a/Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs +++ b/Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs @@ -361,7 +361,7 @@ namespace Yi.Abp.Web var app = context.GetApplicationBuilder(); app.UseRouting(); - //app.ApplicationServices.GetRequiredService().SqlSugarClient.CodeFirst.InitTables(); + // app.ApplicationServices.GetRequiredService().SqlSugarClient.CodeFirst.InitTables(); // app.ApplicationServices.GetRequiredService().SqlSugarClient.CodeFirst.InitTables(); // app.ApplicationServices.GetRequiredService().SqlSugarClient.CodeFirst.InitTables(); diff --git a/Yi.Ai.Vue3/.build/plugins/fontawesome.ts b/Yi.Ai.Vue3/.build/plugins/fontawesome.ts new file mode 100644 index 00000000..22207eda --- /dev/null +++ b/Yi.Ai.Vue3/.build/plugins/fontawesome.ts @@ -0,0 +1,23 @@ +import type { Plugin } from 'vite'; +import { config, library } from '@fortawesome/fontawesome-svg-core'; +import { fas } from '@fortawesome/free-solid-svg-icons'; + +/** + * Vite 插件:配置 FontAwesome + * 预注册所有图标,避免运行时重复注册 + */ +export default function fontAwesomePlugin(): Plugin { + // 在模块加载时配置 FontAwesome + library.add(fas); + + return { + name: 'vite-plugin-fontawesome', + config() { + return { + define: { + // 确保 FontAwesome 在客户端正确初始化 + }, + }; + }, + }; +} diff --git a/Yi.Ai.Vue3/.build/plugins/index.ts b/Yi.Ai.Vue3/.build/plugins/index.ts index 122c0fe2..bed77970 100644 --- a/Yi.Ai.Vue3/.build/plugins/index.ts +++ b/Yi.Ai.Vue3/.build/plugins/index.ts @@ -10,15 +10,21 @@ import Components from 'unplugin-vue-components/vite'; import viteCompression from 'vite-plugin-compression'; import envTyped from 'vite-plugin-env-typed'; +import fontAwesomePlugin from './fontawesome'; import gitHashPlugin from './git-hash'; +import preloadPlugin from './preload'; import createSvgIcon from './svg-icon'; +import versionHtmlPlugin from './version-html'; const root = path.resolve(__dirname, '../../'); function plugins({ mode, command }: ConfigEnv): PluginOption[] { return [ + versionHtmlPlugin(), // 最先处理 HTML 版本号 gitHashPlugin(), + preloadPlugin(), UnoCSS(), + fontAwesomePlugin(), envTyped({ mode, envDir: root, @@ -35,7 +41,18 @@ function plugins({ mode, command }: ConfigEnv): PluginOption[] { dts: path.join(root, 'types', 'auto-imports.d.ts'), }), Components({ - resolvers: [ElementPlusResolver()], + resolvers: [ + ElementPlusResolver(), + // 自动导入 FontAwesomeIcon 组件 + (componentName) => { + if (componentName === 'FontAwesomeIcon') { + return { + name: 'FontAwesomeIcon', + from: '@/components/FontAwesomeIcon/index.vue', + }; + } + }, + ], dts: path.join(root, 'types', 'components.d.ts'), }), createSvgIcon(command === 'build'), diff --git a/Yi.Ai.Vue3/.build/plugins/preload.ts b/Yi.Ai.Vue3/.build/plugins/preload.ts new file mode 100644 index 00000000..4bccc738 --- /dev/null +++ b/Yi.Ai.Vue3/.build/plugins/preload.ts @@ -0,0 +1,47 @@ +import type { Plugin } from 'vite'; + +/** + * Vite 插件:资源预加载优化 + * 自动添加 Link 标签预加载关键资源 + */ +export default function preloadPlugin(): Plugin { + return { + name: 'vite-plugin-preload-optimization', + apply: 'build', + transformIndexHtml(html, context) { + // 只在生产环境添加预加载 + if (process.env.NODE_ENV === 'development') { + return html; + } + + const bundle = context.bundle || {}; + const preloadLinks: string[] = []; + + // 收集关键资源 + const criticalChunks = ['vue-vendor', 'element-plus', 'pinia']; + const criticalAssets: string[] = []; + + Object.entries(bundle).forEach(([fileName, chunk]) => { + if (chunk.type === 'chunk' && criticalChunks.some(name => fileName.includes(name))) { + criticalAssets.push(`/${fileName}`); + } + }); + + // 生成预加载标签 + criticalAssets.forEach(href => { + if (href.endsWith('.js')) { + preloadLinks.push(``); + } else if (href.endsWith('.css')) { + preloadLinks.push(``); + } + }); + + // 将预加载标签插入到 之前 + if (preloadLinks.length > 0) { + return html.replace('', `${preloadLinks.join('\n ')}\n`); + } + + return html; + }, + }; +} diff --git a/Yi.Ai.Vue3/.build/plugins/version-html.ts b/Yi.Ai.Vue3/.build/plugins/version-html.ts new file mode 100644 index 00000000..674dd304 --- /dev/null +++ b/Yi.Ai.Vue3/.build/plugins/version-html.ts @@ -0,0 +1,20 @@ +import type { Plugin } from 'vite'; +import { APP_VERSION, APP_NAME } from '../../src/config/version'; + +/** + * Vite 插件:在 HTML 中注入版本号 + * 替换 HTML 中的占位符为实际版本号 + */ +export default function versionHtmlPlugin(): Plugin { + return { + name: 'vite-plugin-version-html', + enforce: 'pre', + transformIndexHtml(html) { + // 替换 HTML 中的版本占位符 + return html + .replace(/%APP_NAME%/g, APP_NAME) + .replace(/%APP_VERSION%/g, APP_VERSION) + .replace(/%APP_FULL_NAME%/g, `${APP_NAME} ${APP_VERSION}`); + }, + }; +} diff --git a/Yi.Ai.Vue3/.claude/settings.local.json b/Yi.Ai.Vue3/.claude/settings.local.json index 2379a8d2..89b03152 100644 --- a/Yi.Ai.Vue3/.claude/settings.local.json +++ b/Yi.Ai.Vue3/.claude/settings.local.json @@ -8,7 +8,9 @@ "Bash(timeout /t 5 /nobreak)", "Bash(git checkout:*)", "Bash(npm install marked --save)", - "Bash(pnpm add marked)" + "Bash(pnpm add marked)", + "Bash(pnpm lint:*)", + "Bash(pnpm list:*)" ], "deny": [], "ask": [] diff --git a/Yi.Ai.Vue3/CLAUDE.md b/Yi.Ai.Vue3/CLAUDE.md new file mode 100644 index 00000000..25c6ed7a --- /dev/null +++ b/Yi.Ai.Vue3/CLAUDE.md @@ -0,0 +1,367 @@ +# CLAUDE.md + +本文件为 Claude Code (claude.ai/code) 提供本项目代码开发指导。 + +## 项目简介 + +**意心AI** - 基于 Vue 3.5 + TypeScript 开发的企业级 AI 聊天应用模板,仿豆包/通义 AI 平台。支持流式对话、AI 模型库、文件上传、Mermaid 图表渲染等功能。 + +## 技术栈 + +- **框架**: Vue 3.5+ (Composition API) + TypeScript 5.8+ +- **构建工具**: Vite 6.3+ +- **UI 组件**: Element Plus 2.10.4 + vue-element-plus-x 1.3.7 +- **状态管理**: Pinia 3.0 + pinia-plugin-persistedstate +- **HTTP 请求**: hook-fetch(支持流式/SSE,替代 Axios) +- **CSS**: UnoCSS + SCSS +- **路由**: Vue Router 4 + +## 常用命令 + +```bash +# 安装依赖(必须用 pnpm) +pnpm install + +# 启动开发服务器(端口 17001) +pnpm dev + +# 生产构建 +pnpm build + +# 预览生产构建 +pnpm preview + +# 代码检查与修复 +pnpm lint # ESLint 检查 +pnpm fix # ESLint 自动修复 +pnpm lint:stylelint # 样式检查 + +# 规范提交(使用 cz-git) +pnpm cz +``` + +## 如何新增页面 + +### 1. 创建页面文件 + +页面文件统一放在 `src/pages/` 目录下: + +``` +src/pages/ +├── chat/ # 功能模块文件夹 +│ ├── index.vue # 父级布局页 +│ ├── conversation/ # 子页面文件夹 +│ │ └── index.vue # 子页面 +│ └── image/ +│ └── index.vue +├── console/ +│ └── index.vue +└── your-page/ # 新增页面在这里创建 + └── index.vue +``` + +**单页面示例** (`src/pages/your-page/index.vue`): + +```vue + + + + + +``` + +### 2. 配置路由 + +路由配置在 `src/routers/modules/staticRouter.ts`。 + +**新增独立页面**(添加到 `layoutRouter` 的 children 中): + +```typescript +{ + path: 'your-page', // URL 路径,最终为 /your-page + name: 'yourPage', // 路由名称,必须唯一 + component: () => import('@/pages/your-page/index.vue'), + meta: { + title: '页面标题', // 页面标题,会显示在浏览器标签 + keepAlive: 0, // 是否缓存页面:0=缓存,1=不缓存 + isDefaultChat: false, // 是否为默认聊天页 + layout: 'default', // 布局类型:default/blankPage + }, +} +``` + +**新增带子路由的功能模块**: + +```typescript +{ + path: 'module-name', + name: 'moduleName', + component: () => import('@/pages/module-name/index.vue'), // 父级布局页 + redirect: '/module-name/sub-page', // 默认重定向 + meta: { + title: '模块标题', + icon: 'HomeFilled', // Element Plus 图标名称 + }, + children: [ + { + path: 'sub-page', + name: 'subPage', + component: () => import('@/pages/module-name/sub-page/index.vue'), + meta: { + title: '子页面标题', + }, + }, + // 带参数的路由 + { + path: 'detail/:id', + name: 'detailPage', + component: () => import('@/pages/module-name/detail/index.vue'), + meta: { + title: '详情页', + }, + }, + ], +} +``` + +**无需布局的独立页面**(添加到 `staticRouter`): + +```typescript +{ + path: '/test/page', + name: 'testPage', + component: () => import('@/pages/test/page.vue'), + meta: { + title: '测试页面', + }, +} +``` + +### 3. 页面 Meta 配置说明 + +| 属性 | 类型 | 说明 | +|------|------|------| +| title | string | 页面标题,显示在浏览器标签页 | +| keepAlive | number | 0=缓存页面,1=不缓存 | +| layout | string | 布局类型:'default' 使用默认布局,'blankPage' 使用空白布局 | +| isDefaultChat | boolean | 是否为默认聊天页面 | +| icon | string | Element Plus 图标名称,用于菜单显示 | +| isHide | string | '0'=在菜单中隐藏,'1'=显示 | +| isKeepAlive | string | '0'=缓存,'1'=不缓存(字符串版) | + +### 4. 布局说明 + +布局组件位于 `src/layouts/`: + +- **default**: 默认布局,包含侧边栏、顶部导航等 +- **blankPage**: 空白布局,仅包含路由出口 + +在路由 meta 中通过 `layout` 字段指定: + +```typescript +meta: { + layout: 'default', // 使用默认布局(有侧边栏) + // 或 + layout: 'blankPage', // 使用空白布局(全屏页面) +} +``` + +### 5. 页面跳转示例 + +```typescript +// 在 script setup 中使用 +const router = useRouter() + +// 跳转页面 +router.push('/chat/conversation') +router.push({ name: 'chatConversation' }) +router.push({ path: '/chat/conversation/:id', params: { id: '123' } }) + +// 获取路由参数 +const route = useRoute() +console.log(route.params.id) +``` + +### 6. 完整新增页面示例 + +假设要新增一个"数据统计"页面: + +**步骤 1**: 创建页面文件 `src/pages/statistics/index.vue` + +```vue + + + + + +``` + +**步骤 2**: 在 `src/routers/modules/staticRouter.ts` 中添加路由 + +```typescript +{ + path: 'statistics', + name: 'statistics', + component: () => import('@/pages/statistics/index.vue'), + meta: { + title: '意心Ai-数据统计', + keepAlive: 0, + isDefaultChat: false, + layout: 'default', + }, +} +``` + +**步骤 3**: 在菜单中添加入口(如需要) + +如需在侧边栏显示,需在相应的位置添加菜单配置。 + +## 核心架构说明 + +### HTTP 请求封装 + +位于 `src/utils/request.ts`,使用 hook-fetch: + +```typescript +import { get, post, put, del } from '@/utils/request' + +// GET 请求 +const { data } = await get('/api/endpoint').json() + +// POST 请求 +const result = await post('/api/endpoint', { key: 'value' }).json() +``` + +特点: +- 自动附加 JWT Token 到请求头 +- 自动处理 401/403 错误 +- 支持 Server-Sent Events (SSE) 流式响应 + +### 状态管理 + +Pinia stores 位于 `src/stores/modules/`: + +| Store | 用途 | +|-------|------| +| user.ts | 用户认证、登录状态 | +| chat.ts | 聊天消息、流式输出 | +| session.ts | 会话列表、当前会话 | +| model.ts | AI 模型配置 | + +使用方式: + +```typescript +const userStore = useUserStore() +userStore.setToken(token, refreshToken) +userStore.logout() +``` + +### 自动导入 + +项目已配置 `unplugin-auto-import`,以下 API 无需手动 import: + +- Vue API: `ref`, `reactive`, `computed`, `watch`, `onMounted` 等 +- Vue Router: `useRoute`, `useRouter` +- Pinia: `createPinia`, `storeToRefs` +- VueUse: `useFetch`, `useStorage` 等 + +### 路径别名 + +| 别名 | 对应路径 | +|------|----------| +| `@/` | `src/` | +| `@components/` | `src/vue-element-plus-y/components/` | + +### 环境变量 + +开发配置在 `.env.development`: + +``` +VITE_WEB_BASE_API=/dev-api # API 前缀 +VITE_API_URL=http://localhost:19001/api/app # 后端地址 +VITE_SSO_SEVER_URL=http://localhost:18001 # SSO 地址 +``` + +Vite 开发服务器会自动将 `/dev-api` 代理到后端 API。 + +## 代码规范 + +### 提交规范 + +使用 `pnpm cz` 进行规范提交,类型包括: +- `feat`: 新功能 +- `fix`: 修复 +- `docs`: 文档 +- `style`: 代码格式 +- `refactor`: 重构 +- `perf`: 性能优化 +- `test`: 测试 +- `build`: 构建 +- `ci`: CI/CD +- `revert`: 回滚 +- `chore`: 其他 + +### Git Hooks + +- **pre-commit**: 自动运行 ESLint 修复 +- **commit-msg**: 校验提交信息格式 + +## 构建优化 + +Vite 配置中的代码分割策略(`vite.config.ts`): + +| Chunk 名称 | 包含内容 | +|-----------|---------| +| vue-vendor | Vue 核心库、Vue Router | +| pinia | Pinia 状态管理 | +| element-plus | Element Plus UI 库 | +| markdown | Markdown 解析相关 | +| utils | Lodash、VueUse 工具库 | +| highlight | 代码高亮库 | +| echarts | 图表库 | +| pdf | PDF 处理库 | + +## 后端集成 + +后端为 .NET 8 项目,本地启动命令: + +```bash +cd E:\devDemo\Yi\Yi.Abp.Net8\src\Yi.Abp.Web +dotnet run +``` + +前端开发时,后端默认运行在 `http://localhost:19001`。 diff --git a/Yi.Ai.Vue3/FONTAWESOME_MIGRATION.md b/Yi.Ai.Vue3/FONTAWESOME_MIGRATION.md new file mode 100644 index 00000000..b5ef1146 --- /dev/null +++ b/Yi.Ai.Vue3/FONTAWESOME_MIGRATION.md @@ -0,0 +1,133 @@ +# FontAwesome 图标迁移指南 + +## 迁移步骤 + +### 1. 在组件中使用 FontAwesomeIcon + +```vue + + + + +``` + +```vue + + + + + +``` + +### 2. 自动映射工具 + +使用 `getFontAwesomeIcon` 函数自动映射图标名: + +```typescript +import { getFontAwesomeIcon } from '@/utils/icon-mapping'; + +// 将 Element Plus 图标名转换为 FontAwesome 图标名 +const faIcon = getFontAwesomeIcon('Check'); // 返回 'check' +const faIcon2 = getFontAwesomeIcon('ArrowLeft'); // 返回 'arrow-left' +``` + +### 3. Props 说明 + +| Prop | 类型 | 可选值 | 说明 | +|------|------|--------|------| +| icon | string | 任意 FontAwesome 图标名 | 图标名称(不含 fa- 前缀) | +| size | string | xs, sm, lg, xl, 2x, 3x, 4x, 5x | 图标大小 | +| spin | boolean | true/false | 是否旋转 | +| pulse | boolean | true/false | 是否脉冲动画 | +| rotation | number | 0, 90, 180, 270 | 旋转角度 | + +### 4. 常用图标对照表 + +| Element Plus | FontAwesome | +|--------------|-------------| +| Check | check | +| Close | xmark | +| Delete | trash | +| Edit | pen-to-square | +| Plus | plus | +| Search | magnifying-glass | +| Refresh | rotate-right | +| Loading | spinner | +| Download | download | +| ArrowLeft | arrow-left | +| ArrowRight | arrow-right | +| User | user | +| Setting | gear | +| View | eye | +| Hide | eye-slash | +| Lock | lock | +| Share | share-nodes | +| Heart | heart | +| Star | star | +| Clock | clock | +| Calendar | calendar | +| ChatLineRound | comment | +| Bell | bell | +| Document | file | +| Picture | image | + +### 5. 批量迁移示例 + +```vue + + + + + + + + + +``` + +## 注意事项 + +1. **无需手动导入**:FontAwesomeIcon 组件已在 `vite.config.ts` 中配置为自动导入 +2. **图标名格式**:使用小写、带连字符的图标名(如 `magnifying-glass`) +3. **完整图标列表**:访问 [FontAwesome 官网](https://fontawesome.com/search?o=r&m=free) 查看所有可用图标 +4. **渐进步骤**:可以逐步迁移,Element Plus 图标和 FontAwesome 可以共存 + +## 优化建议 + +1. **减少包体积**:迁移后可以移除 `@element-plus/icons-vue` 依赖 +2. **统一图标风格**:FontAwesome 图标风格更统一 +3. **更好的性能**:FontAwesome 按需加载,不会加载未使用的图标 + +## 查找图标 + +- [Solid Icons 搜索](https://fontawesome.com/search?o=r&m=free) +- 图标名格式:`fa-solid fa-icon-name` +- 在代码中使用时只需:`icon="icon-name"` diff --git a/Yi.Ai.Vue3/index.html b/Yi.Ai.Vue3/index.html index 70c5ca53..f62b1dec 100644 --- a/Yi.Ai.Vue3/index.html +++ b/Yi.Ai.Vue3/index.html @@ -17,6 +17,14 @@ + + + + + + + + diff --git a/Yi.Ai.Vue3/src/components/FontAwesomeIcon/index.ts b/Yi.Ai.Vue3/src/components/FontAwesomeIcon/index.ts new file mode 100644 index 00000000..7e1e0dc6 --- /dev/null +++ b/Yi.Ai.Vue3/src/components/FontAwesomeIcon/index.ts @@ -0,0 +1,3 @@ +export { default as FontAwesomeIcon } from './index.vue'; +export { default as FontAwesomeDemo } from './demo.vue'; +export { getFontAwesomeIcon, iconMapping } from '@/utils/icon-mapping'; diff --git a/Yi.Ai.Vue3/src/components/FontAwesomeIcon/index.vue b/Yi.Ai.Vue3/src/components/FontAwesomeIcon/index.vue new file mode 100644 index 00000000..2bb285bd --- /dev/null +++ b/Yi.Ai.Vue3/src/components/FontAwesomeIcon/index.vue @@ -0,0 +1,33 @@ + + + diff --git a/Yi.Ai.Vue3/src/components/MarkedMarkdown/index.vue b/Yi.Ai.Vue3/src/components/MarkedMarkdown/index.vue index b457c6ad..6d0a078b 100644 --- a/Yi.Ai.Vue3/src/components/MarkedMarkdown/index.vue +++ b/Yi.Ai.Vue3/src/components/MarkedMarkdown/index.vue @@ -1,7 +1,7 @@ diff --git a/Yi.Ai.Vue3/src/components/ProductPackage/PackageTab.vue b/Yi.Ai.Vue3/src/components/ProductPackage/PackageTab.vue index 2bf3b79a..100f7ffa 100644 --- a/Yi.Ai.Vue3/src/components/ProductPackage/PackageTab.vue +++ b/Yi.Ai.Vue3/src/components/ProductPackage/PackageTab.vue @@ -1,10 +1,13 @@ diff --git a/Yi.Ai.Vue3/src/components/SupportModelProducts/indexl.vue b/Yi.Ai.Vue3/src/components/SupportModelProducts/indexl.vue index df88ebd9..c73acda0 100644 --- a/Yi.Ai.Vue3/src/components/SupportModelProducts/indexl.vue +++ b/Yi.Ai.Vue3/src/components/SupportModelProducts/indexl.vue @@ -1,4 +1,8 @@ diff --git a/Yi.Ai.Vue3/src/components/userPersonalCenter/components/CardFlipActivity.vue b/Yi.Ai.Vue3/src/components/userPersonalCenter/components/CardFlipActivity.vue index 14e47109..b360aa19 100644 --- a/Yi.Ai.Vue3/src/components/userPersonalCenter/components/CardFlipActivity.vue +++ b/Yi.Ai.Vue3/src/components/userPersonalCenter/components/CardFlipActivity.vue @@ -1,4 +1,4 @@ -