diff --git a/src/BuildFlow.Host/BuildFlowHostModule.cs b/src/BuildFlow.Host/BuildFlowHostModule.cs index 3cb1b3b..3a835c2 100644 --- a/src/BuildFlow.Host/BuildFlowHostModule.cs +++ b/src/BuildFlow.Host/BuildFlowHostModule.cs @@ -21,7 +21,6 @@ using Volo.Abp; using Volo.Abp.Modularity; using Workflow.Application; using Workflow.Application.Engine; -using Workflow.Application.Integration; using Workflow.Application.Notifications; using Workflow.Infrastructure; @@ -90,8 +89,6 @@ public class BuildFlowHostModule : AbpModule // 串联:工作流通知 → 平台消息中心铃铛(跨模块集成,Host 实现 IPlatformNotificationPublisher 供 workflow 调用, // 内部转发为 PublishNotificationCommand 复用 platform 的 fanout 落库 + SignalR 推送) services.AddScoped(); - // 串联:AI 对话 → 触发工作流(跨模块集成,Workflow 实现 IWorkflowTriggerService 供 RAG 调用) - services.AddScoped(); // workflow 后台服务:超时调度 + Webhook 投递 services.AddScoped(); services.Configure(config.GetSection("Workflow:Notifications")); @@ -122,6 +119,9 @@ public class BuildFlowHostModule : AbpModule opts.PassthroughPrefixes.Add("/hubs"); // Swagger/OpenAPI 文档必须保持原始结构,不能包裹成 { code, data, message },否则 Swagger UI 无法识别 openapi 字段。 opts.PassthroughPrefixes.Add("/swagger"); + // RAG/Chat SSE 必须直连响应流。若进入响应包裹中间件的 MemoryStream,FastEndpoints 会在响应未启动时收尾成 204。 + opts.StreamPathPrefixes.Add("/api/chat/conversations"); + opts.StreamPathPrefixes.Add("/api/rag"); }); } diff --git a/src/BuildFlow.Host/Endpoints/Rag/Document/PreviewDocumentChunksEndpoint.cs b/src/BuildFlow.Host/Endpoints/Rag/Document/PreviewDocumentChunksEndpoint.cs new file mode 100644 index 0000000..b27beac --- /dev/null +++ b/src/BuildFlow.Host/Endpoints/Rag/Document/PreviewDocumentChunksEndpoint.cs @@ -0,0 +1,24 @@ +using BuildFlow.Application.Document.DTOs; +using BuildFlow.Application.Document.Queries; +using MediatR; + +namespace BuildFlow.Host.Endpoints.Rag.Document; + +/// +/// 预览文档切片端点。只返回按当前策略切出的片段,不触发向量化和持久化。 +/// +public class PreviewDocumentChunksEndpoint(IMediator mediator) : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/documents/{DocumentId}/chunks/preview"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var docId = Route("DocumentId"); + var result = await mediator.Send(new PreviewDocumentChunksQuery(docId), ct); + await Send.OkAsync(result, ct); + } +} diff --git a/src/BuildFlow.Host/Endpoints/Rag/RAG/RAGStreamEndpoint.cs b/src/BuildFlow.Host/Endpoints/Rag/RAG/RAGStreamEndpoint.cs index e610162..26c2fe8 100644 --- a/src/BuildFlow.Host/Endpoints/Rag/RAG/RAGStreamEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/Rag/RAG/RAGStreamEndpoint.cs @@ -68,7 +68,7 @@ public class RAGStreamEndpoint( var ragMessages = new List { - new(AiChatRole.System, "基于提供的上下文回答问题。如果上下文中没有相关信息,请如实说明。"), + new(AiChatRole.System, "基于提供的上下文直接回答问题。不要输出身份开场、Markdown标题或推理过程;上下文没有依据时,只用一句话说明未检索到相关内容。"), new(AiChatRole.User, $"{contextBuilder}\n\n问题:{req.Question}"), }; diff --git a/src/BuildFlow.Host/Endpoints/Workflow/BuildFlow/Buttons/DisableBuildFlowButtonDefinitionEndpoint.cs b/src/BuildFlow.Host/Endpoints/Workflow/BuildFlow/Buttons/DisableBuildFlowButtonDefinitionEndpoint.cs new file mode 100644 index 0000000..b065d74 --- /dev/null +++ b/src/BuildFlow.Host/Endpoints/Workflow/BuildFlow/Buttons/DisableBuildFlowButtonDefinitionEndpoint.cs @@ -0,0 +1,22 @@ +using MediatR; +using Workflow.Application.BuildFlow.Buttons.Commands; +using Workflow.Application.BuildFlow.Buttons.DTOs; + +namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.Buttons; + +/// 禁用 BuildFlow 按钮定义端点。POST /buildflow/buttons/{id}/disable。 +public class DisableBuildFlowButtonDefinitionEndpoint(IMediator mediator) + : Endpoint +{ + public override void Configure() + { + Post("/buildflow/buttons/{id}/disable"); + Permissions("buildflow:button:disable"); + } + + public override async Task HandleAsync(DisableBuildFlowButtonDefinitionRequest req, CancellationToken ct) + { + var result = await mediator.Send(new DisableBuildFlowButtonDefinitionCommand(req.Id), ct); + await Send.OkAsync(result, ct); + } +} diff --git a/src/BuildFlow.Host/Endpoints/Workflow/BuildFlow/Buttons/DisableBuildFlowButtonDefinitionRequest.cs b/src/BuildFlow.Host/Endpoints/Workflow/BuildFlow/Buttons/DisableBuildFlowButtonDefinitionRequest.cs new file mode 100644 index 0000000..1d1be4a --- /dev/null +++ b/src/BuildFlow.Host/Endpoints/Workflow/BuildFlow/Buttons/DisableBuildFlowButtonDefinitionRequest.cs @@ -0,0 +1,4 @@ +namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.Buttons; + +/// 禁用 BuildFlow 按钮定义请求。 +public sealed record DisableBuildFlowButtonDefinitionRequest(Guid Id); diff --git a/src/BuildFlow.Host/Endpoints/Workflow/BuildFlow/Buttons/PublishBuildFlowButtonDefinitionEndpoint.cs b/src/BuildFlow.Host/Endpoints/Workflow/BuildFlow/Buttons/PublishBuildFlowButtonDefinitionEndpoint.cs new file mode 100644 index 0000000..aee08e9 --- /dev/null +++ b/src/BuildFlow.Host/Endpoints/Workflow/BuildFlow/Buttons/PublishBuildFlowButtonDefinitionEndpoint.cs @@ -0,0 +1,22 @@ +using MediatR; +using Workflow.Application.BuildFlow.Buttons.Commands; +using Workflow.Application.BuildFlow.Buttons.DTOs; + +namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.Buttons; + +/// 发布 BuildFlow 按钮定义端点。POST /buildflow/buttons/{id}/publish。 +public class PublishBuildFlowButtonDefinitionEndpoint(IMediator mediator) + : Endpoint +{ + public override void Configure() + { + Post("/buildflow/buttons/{id}/publish"); + Permissions("buildflow:button:publish"); + } + + public override async Task HandleAsync(PublishBuildFlowButtonDefinitionRequest req, CancellationToken ct) + { + var result = await mediator.Send(new PublishBuildFlowButtonDefinitionCommand(req.Id), ct); + await Send.OkAsync(result, ct); + } +} diff --git a/src/BuildFlow.Host/Endpoints/Workflow/BuildFlow/Buttons/PublishBuildFlowButtonDefinitionRequest.cs b/src/BuildFlow.Host/Endpoints/Workflow/BuildFlow/Buttons/PublishBuildFlowButtonDefinitionRequest.cs new file mode 100644 index 0000000..d41820a --- /dev/null +++ b/src/BuildFlow.Host/Endpoints/Workflow/BuildFlow/Buttons/PublishBuildFlowButtonDefinitionRequest.cs @@ -0,0 +1,4 @@ +namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.Buttons; + +/// 发布 BuildFlow 按钮定义请求。 +public sealed record PublishBuildFlowButtonDefinitionRequest(Guid Id); diff --git a/src/BuildFlow.Host/Endpoints/Workflow/WorkflowTask/ApproveTaskEndpoint.cs b/src/BuildFlow.Host/Endpoints/Workflow/WorkflowTask/ApproveTaskEndpoint.cs index 82822cb..68f0538 100644 --- a/src/BuildFlow.Host/Endpoints/Workflow/WorkflowTask/ApproveTaskEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/Workflow/WorkflowTask/ApproveTaskEndpoint.cs @@ -7,7 +7,7 @@ namespace BuildFlow.Host.Endpoints.Workflow.WorkflowTask; /// /// 审批通过任务端点。驱动 TaskStateMachine 转 Approved,按 Approved 边推进流程。 /// -public class ApproveTaskEndpoint : Endpoint +public class ApproveTaskEndpoint : Endpoint { private readonly IMediator _mediator; @@ -27,8 +27,6 @@ public class ApproveTaskEndpoint : Endpoint { var command = new ApproveTaskCommand(req.Id, req.UserId, req.Comment, req.FormDataJson); await _mediator.Send(command, ct); - HttpContext.Response.StatusCode = 200; - HttpContext.Response.ContentType = "application/json"; - await HttpContext.Response.WriteAsync("{}", ct); + await Send.OkAsync(true, ct); } } diff --git a/src/BuildFlow.Host/Endpoints/Workflow/WorkflowTask/RejectTaskEndpoint.cs b/src/BuildFlow.Host/Endpoints/Workflow/WorkflowTask/RejectTaskEndpoint.cs index e6d1cea..6f016df 100644 --- a/src/BuildFlow.Host/Endpoints/Workflow/WorkflowTask/RejectTaskEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/Workflow/WorkflowTask/RejectTaskEndpoint.cs @@ -7,7 +7,7 @@ namespace BuildFlow.Host.Endpoints.Workflow.WorkflowTask; /// /// 驳回任务端点。转 Rejected 终态,按 Rejected 边流转(若无则结束实例)。 /// -public class RejectTaskEndpoint : Endpoint +public class RejectTaskEndpoint : Endpoint { private readonly IMediator _mediator; @@ -27,8 +27,6 @@ public class RejectTaskEndpoint : Endpoint { var command = new RejectTaskCommand(req.Id, req.UserId, req.Comment); await _mediator.Send(command, ct); - HttpContext.Response.StatusCode = 200; - HttpContext.Response.ContentType = "application/json"; - await HttpContext.Response.WriteAsync("{}", ct); + await Send.OkAsync(true, ct); } } diff --git a/src/BuildFlow.Host/appsettings.json b/src/BuildFlow.Host/appsettings.json index 1b4142e..cadf1a4 100644 --- a/src/BuildFlow.Host/appsettings.json +++ b/src/BuildFlow.Host/appsettings.json @@ -65,7 +65,7 @@ "ApiKey": "lm-studio", "ChatModel": "qwen/qwen3.6-27b", "EmbeddingModel": "text-embedding-embeddinggemma-300m", - "DefaultInstructions": "你是一个RAG知识库助手,基于提供的上下文准确回答用户问题。如果上下文中没有相关信息,请如实说明。", + "DefaultInstructions": "你是 BuildFlow AI 助手。普通对话直接回答;当消息明确包含知识库上下文时,优先基于上下文回答。不要输出“作为RAG知识库助手”等身份开场、Markdown标题或推理过程;不要因为普通对话没有上下文就要求用户补充资料。", "MaxTokens": 2000, "Temperature": 0.7 } diff --git a/src/modules/platform/Infrastructure/Persistence/SeedData.cs b/src/modules/platform/Infrastructure/Persistence/SeedData.cs index 1541d7b..43b0610 100644 --- a/src/modules/platform/Infrastructure/Persistence/SeedData.cs +++ b/src/modules/platform/Infrastructure/Persistence/SeedData.cs @@ -144,6 +144,8 @@ public static class SeedData new SeedPermission(Guid.Parse("10000000-0000-0000-0000-000000000051"), "读取按钮", "buildflow:button:read", "BuildFlow"), new SeedPermission(Guid.Parse("10000000-0000-0000-0000-000000000052"), "创建按钮", "buildflow:button:create", "BuildFlow"), new SeedPermission(Guid.Parse("10000000-0000-0000-0000-000000000053"), "执行按钮", "buildflow:button:execute", "BuildFlow"), + new SeedPermission(Guid.Parse("10000000-0000-0000-0000-000000000059"), "发布按钮", "buildflow:button:publish", "BuildFlow"), + new SeedPermission(Guid.Parse("10000000-0000-0000-0000-000000000060"), "禁用按钮", "buildflow:button:disable", "BuildFlow"), new SeedPermission(Guid.Parse("10000000-0000-0000-0000-000000000054"), "读取函数", "buildflow:function:read", "BuildFlow"), new SeedPermission(Guid.Parse("10000000-0000-0000-0000-000000000055"), "创建函数", "buildflow:function:create", "BuildFlow"), new SeedPermission(Guid.Parse("10000000-0000-0000-0000-000000000056"), "发布函数", "buildflow:function:publish", "BuildFlow"), diff --git a/src/modules/rag/Application/BuildFlow.Application.csproj b/src/modules/rag/Application/BuildFlow.Application.csproj index bb941d5..691b70f 100644 --- a/src/modules/rag/Application/BuildFlow.Application.csproj +++ b/src/modules/rag/Application/BuildFlow.Application.csproj @@ -18,5 +18,6 @@ + diff --git a/src/modules/rag/Application/Document/Handlers/PreviewDocumentChunksQueryHandler.cs b/src/modules/rag/Application/Document/Handlers/PreviewDocumentChunksQueryHandler.cs new file mode 100644 index 0000000..4c00e97 --- /dev/null +++ b/src/modules/rag/Application/Document/Handlers/PreviewDocumentChunksQueryHandler.cs @@ -0,0 +1,89 @@ +using BuildFlow.Application.Document.DTOs; +using BuildFlow.Application.Document.Queries; +using BuildFlow.Domain.Enums; +using BuildFlow.Domain.Interfaces; +using BuildFlow.Infrastructure.Persistence; +using BuildFlow.Shared.Domain.Exceptions; +using MediatR; +using Microsoft.AspNetCore.Http; + +namespace BuildFlow.Application.Document.Handlers; + +/// +/// 预览文档切片查询处理器。复用正式处理的提取器和分块策略,但不调用 embedding,也不持久化 chunk。 +/// +public class PreviewDocumentChunksQueryHandler( + RagDbContext db, + IDocumentExtractorFactory extractorFactory, + ITextChunker chunker, + IFileStorageClient fileStorage, + IHttpContextAccessor httpContextAccessor) + : IRequestHandler> +{ + public async Task> Handle(PreviewDocumentChunksQuery request, CancellationToken ct) + { + var doc = await db.Documents.FindAsync([request.DocumentId], ct) + ?? throw new NotFoundException("文档不存在"); + var kb = await db.KnowledgeBases.FindAsync([doc.KnowledgeBaseId], ct) + ?? throw new NotFoundException("知识库不存在"); + + string? tempDownloadPath = null; + try + { + var extractPath = await ResolveFilePathAsync(doc, ct); + tempDownloadPath = extractPath == doc.FilePath ? null : extractPath; + var extractor = extractorFactory.GetExtractor(doc.FileName); + var extracted = await extractor.ExtractAsync(extractPath, ct); + if (string.IsNullOrWhiteSpace(extracted.Content)) + throw new BusinessException($"文件「{doc.FileName}」提取到的内容为空,无法预览切片"); + + var effectiveMode = doc.ChunkingModeOverride ?? kb.ChunkingMode; + var chunks = chunker.Chunk(extracted, effectiveMode, kb.ChunkSize, kb.ChunkOverlap, kb.Separator); + + return chunks.Select(chunk => new ChunkPreviewDto( + Guid.NewGuid(), + chunk.Index, + chunk.Content, + chunk.Content.Length / 4, + false, + null, + null, + chunk.HeadingPath, + null)).ToList(); + } + finally + { + if (tempDownloadPath is not null && File.Exists(tempDownloadPath)) + { + try { File.Delete(tempDownloadPath); } catch { /* 忽略清理失败 */ } + } + } + } + + /// + /// 解析文档文件路径。本地路径直接返回;文件服务对象下载到临时文件供提取器读取。 + /// + private async Task ResolveFilePathAsync(BuildFlow.Domain.Entities.Document doc, CancellationToken ct) + { + if (File.Exists(doc.FilePath)) + return doc.FilePath; + + var slashIdx = doc.FilePath.IndexOf('/'); + if (slashIdx <= 0 || slashIdx >= doc.FilePath.Length - 1) + throw new BusinessException($"文档路径格式无效:{doc.FilePath}"); + + var bucket = doc.FilePath[..slashIdx]; + var objectKey = doc.FilePath[(slashIdx + 1)..]; + var authToken = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString().Replace("Bearer ", ""); + var ext = Path.GetExtension(doc.FileName); + var tempPath = Path.Combine(Path.GetTempPath(), $"rag_preview_{Guid.NewGuid()}{ext}"); + + await using (var stream = await fileStorage.DownloadAsync(bucket, objectKey, authToken, ct)) + await using (var fs = File.Create(tempPath)) + { + await stream.CopyToAsync(fs, ct); + } + + return tempPath; + } +} diff --git a/src/modules/rag/Application/Document/Queries/PreviewDocumentChunksQuery.cs b/src/modules/rag/Application/Document/Queries/PreviewDocumentChunksQuery.cs new file mode 100644 index 0000000..6486848 --- /dev/null +++ b/src/modules/rag/Application/Document/Queries/PreviewDocumentChunksQuery.cs @@ -0,0 +1,9 @@ +using MediatR; +using BuildFlow.Application.Document.DTOs; + +namespace BuildFlow.Application.Document.Queries; + +/// +/// 预览文档切片查询。只执行提取和分块,不生成向量、不写入正式分块表。 +/// +public record PreviewDocumentChunksQuery(Guid DocumentId) : IRequest>; diff --git a/src/modules/rag/Application/Integration/AiBotHandler.cs b/src/modules/rag/Application/Integration/AiBotHandler.cs index 855ea03..9f142ee 100644 --- a/src/modules/rag/Application/Integration/AiBotHandler.cs +++ b/src/modules/rag/Application/Integration/AiBotHandler.cs @@ -2,6 +2,8 @@ using MediatR; using BuildFlow.Application.RagQA.Commands; using BuildFlow.Infrastructure.Persistence; using BuildFlow.Shared.Integration; +using Workflow.Application.Integration.Commands; +using Workflow.Application.Integration.Queries; namespace BuildFlow.Application.Integration; @@ -12,19 +14,15 @@ namespace BuildFlow.Application.Integration; /// public class AiBotHandler( IMediator mediator, - RagDbContext db, - IWorkflowTriggerService? workflowTrigger = null) : IAiBotHandler + RagDbContext db) : IAiBotHandler { public async Task AnswerAsync(string question, Guid conversationId, CancellationToken ct) { // 串联链路4:AI 对话 → 触发工作流 // 检测"发起/启动/申请 XXX 流程"等意图 - if (workflowTrigger is not null) - { - var triggerResult = await TryDetectWorkflowIntentAsync(question, ct); - if (triggerResult is not null) - return triggerResult; - } + var triggerResult = await TryDetectWorkflowIntentAsync(question, ct); + if (triggerResult is not null) + return triggerResult; // 默认:RAG 知识库问答 var kb = await db.KnowledgeBases @@ -71,7 +69,7 @@ public class AiBotHandler( if (string.IsNullOrWhiteSpace(flowName)) return null; - var result = await workflowTrigger!.TryStartAsync(flowName, Guid.Empty, null, ct); + var result = await mediator.Send(new StartWorkflowByNameCommand(flowName, Guid.Empty, null), ct); if (result.Success) { @@ -80,7 +78,7 @@ public class AiBotHandler( else { // 列出可用流程供用户选择 - var defs = await workflowTrigger.GetTriggerableDefinitionsAsync(ct); + var defs = await mediator.Send(new GetTriggerableWorkflowDefinitionsQuery(), ct); if (defs.Count > 0) { var list = string.Join("\n", defs.Select(d => $" • {d.Name}")); diff --git a/src/modules/rag/Application/RagQA/Handlers/RagQueryCommandHandler.cs b/src/modules/rag/Application/RagQA/Handlers/RagQueryCommandHandler.cs index b3d00be..5f43c9e 100644 --- a/src/modules/rag/Application/RagQA/Handlers/RagQueryCommandHandler.cs +++ b/src/modules/rag/Application/RagQA/Handlers/RagQueryCommandHandler.cs @@ -45,11 +45,11 @@ public class RagQueryCommandHandler( // 用完整命名空间避免与 BuildFlow.Domain.Entities.ChatMessage 歧义 var ragMessages = new List { - new(ChatRole.System, "基于提供的上下文回答问题。如果上下文中没有相关信息,请如实说明。"), + new(ChatRole.System, "基于提供的上下文直接回答问题。不要输出身份开场、Markdown标题或推理过程;上下文没有依据时,只用一句话说明未检索到相关内容。"), new(ChatRole.User, $"{contextBuilder}\n\n问题:{request.Question}"), }; var answer = await chatAgent.RunAsync(ragMessages, ct); return new RAGQueryResponse(answer, sources); } -} \ No newline at end of file +} diff --git a/src/modules/rag/Infrastructure/AI/AiChatOutputSanitizer.cs b/src/modules/rag/Infrastructure/AI/AiChatOutputSanitizer.cs new file mode 100644 index 0000000..4518d21 --- /dev/null +++ b/src/modules/rag/Infrastructure/AI/AiChatOutputSanitizer.cs @@ -0,0 +1,209 @@ +using System.Text; + +namespace BuildFlow.Infrastructure.AI; + +/// +/// AI 对话输出清洗器。过滤思考标签和模型常见身份声明,避免模板话术进入用户可见气泡。 +/// +public sealed class AiChatOutputSanitizer +{ + private const string FallbackQuestionPrompt = "请先输入要查询的问题,或选择知识库后再提问。"; + + private static readonly string[] RagAssistantOpenings = + [ + "您好!作为RAG知识库助手", + "您好,作为RAG知识库助手", + "作为RAG知识库助手", + "您好!我是RAG知识库助手", + "您好,我是RAG知识库助手", + "我是RAG知识库助手", + ]; + + private readonly StringBuilder _buffer = new(); + private readonly StringBuilder _openingBuffer = new(); + private bool _inThinkTag; + private bool _openingResolved; + private bool _suppressAsBoilerplate; + + /// + /// 追加一段模型输出,返回可立即推送给前端的清洗后片段。 + /// + public IEnumerable Append(string text) + { + if (string.IsNullOrEmpty(text)) yield break; + + _buffer.Append(text); + foreach (var visible in DrainVisibleText(false)) + { + foreach (var chunk in DrainOpening(visible)) + yield return chunk; + } + } + + /// + /// 结束流式读取,返回尚未输出的安全尾部内容。 + /// + public IEnumerable Complete() + { + foreach (var visible in DrainVisibleText(true)) + { + foreach (var chunk in DrainOpening(visible)) + yield return chunk; + } + + if (_suppressAsBoilerplate) + { + yield return FallbackQuestionPrompt; + yield break; + } + + if (!_openingResolved && _openingBuffer.Length > 0) + yield return _openingBuffer.ToString(); + + _openingBuffer.Clear(); + } + + /// + /// 清洗完整回复文本,供非流式路径或持久化前兜底使用。 + /// + public static string SanitizeFinalText(string text) + { + if (string.IsNullOrWhiteSpace(text)) return string.Empty; + + var withoutThink = RemoveThinkTags(text).TrimStart('\n', '\r', ' '); + return IsBoilerplateQuestionPrompt(withoutThink) + ? FallbackQuestionPrompt + : withoutThink; + } + + private IEnumerable DrainVisibleText(bool flushAll) + { + while (_buffer.Length > 0) + { + var content = _buffer.ToString(); + if (_inThinkTag) + { + var endIdx = content.IndexOf(" 10) _buffer.Remove(0, _buffer.Length - 10); + yield break; + } + + var closeIdx = content.IndexOf('>', endIdx); + if (closeIdx < 0) yield break; + + _inThinkTag = false; + _buffer.Remove(0, closeIdx + 1); + continue; + } + + var thinkIdx = content.IndexOf(" 0) + yield return _buffer.ToString(0, thinkIdx); + + var endIdx2 = content.IndexOf("= 0) + { + var closeIdx = content.IndexOf('>', endIdx2); + if (closeIdx < 0) yield break; + _buffer.Remove(0, closeIdx + 1); + } + else + { + _inThinkTag = true; + _buffer.Remove(0, thinkIdx); + yield break; + } + } + } + + private IEnumerable DrainOpening(string text) + { + if (_suppressAsBoilerplate) yield break; + if (_openingResolved) + { + yield return text; + yield break; + } + + _openingBuffer.Append(text); + var opening = _openingBuffer.ToString(); + if (IsBoilerplateQuestionPrompt(opening)) + { + _suppressAsBoilerplate = true; + _openingBuffer.Clear(); + yield break; + } + + if (MightBecomeBoilerplate(opening)) yield break; + + _openingResolved = true; + yield return opening; + _openingBuffer.Clear(); + } + + private static string RemoveThinkTags(string text) + { + var span = text.AsSpan(); + var builder = new StringBuilder(); + while (!span.IsEmpty) + { + var thinkStart = span.IndexOf("'); + if (gtIdx < 0) break; + + var body = afterThink[(gtIdx + 1)..]; + var endTag = body.IndexOf("'); + if (endGt < 0) break; + + span = afterEnd[(endGt + 1)..]; + } + + return builder.ToString(); + } + + private static bool IsBoilerplateQuestionPrompt(string text) + { + var normalized = text.Replace("**", "", StringComparison.Ordinal); + return normalized.Contains("RAG知识库助手", StringComparison.OrdinalIgnoreCase) + && (normalized.Contains("具体的问题", StringComparison.Ordinal) + || normalized.Contains("知识库内容", StringComparison.Ordinal) + || normalized.Contains("请问您想了解什么", StringComparison.Ordinal) + || normalized.Contains("请随时补充信息", StringComparison.Ordinal)); + } + + private static bool MightBecomeBoilerplate(string text) + { + if (text.Length < 20 && text.StartsWith("您好", StringComparison.Ordinal)) + return true; + + return text.Length < 120 && RagAssistantOpenings.Any(opening => + opening.StartsWith(text, StringComparison.Ordinal) + || text.StartsWith(opening, StringComparison.Ordinal)); + } +} diff --git a/src/modules/rag/Infrastructure/AI/AiModelRuntimeConfig.cs b/src/modules/rag/Infrastructure/AI/AiModelRuntimeConfig.cs new file mode 100644 index 0000000..efc7922 --- /dev/null +++ b/src/modules/rag/Infrastructure/AI/AiModelRuntimeConfig.cs @@ -0,0 +1,12 @@ +namespace BuildFlow.Infrastructure.AI; + +/// +/// AI 运行时模型配置。封装一次 OpenAI 兼容调用所需的模型 ID、端点和密钥。 +/// +public record AiModelRuntimeConfig( + string ModelId, + string BaseUrl, + string ApiKey, + string? DefaultInstructions, + int MaxTokens, + double Temperature); diff --git a/src/modules/rag/Infrastructure/AI/AiModelRuntimeConfigResolver.cs b/src/modules/rag/Infrastructure/AI/AiModelRuntimeConfigResolver.cs new file mode 100644 index 0000000..1f59ada --- /dev/null +++ b/src/modules/rag/Infrastructure/AI/AiModelRuntimeConfigResolver.cs @@ -0,0 +1,67 @@ +using BuildFlow.Domain.Enums; +using BuildFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace BuildFlow.Infrastructure.AI; + +/// +/// AI 运行时模型配置解析器。优先读取模型管理中的默认启用模型,没有配置时回退到 appsettings。 +/// +public class AiModelRuntimeConfigResolver( + IServiceScopeFactory scopeFactory, + IOptions options) +{ + private readonly AiOptions _fallbackOptions = options.Value; + + /// + /// 按模型类型解析本次调用配置。Chat 与 Embedding 都通过该入口,避免某一类调用绕过本地模型配置。 + /// + public async Task ResolveAsync(ModelType modelType, CancellationToken ct) + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var model = await db.AiModels + .AsNoTracking() + .FirstOrDefaultAsync(m => m.ModelType == modelType && m.IsDefault && m.IsActive, ct); + + if (model is not null) + { + return new AiModelRuntimeConfig( + model.ModelId, + model.BaseUrl, + model.ApiKey, + ResolveDefaultInstructions(model.DefaultInstructions), + model.MaxTokens, + model.Temperature); + } + + var fallbackModelId = modelType == ModelType.Embedding + ? _fallbackOptions.EmbeddingModel + : _fallbackOptions.ChatModel; + + return new AiModelRuntimeConfig( + fallbackModelId, + _fallbackOptions.BaseUrl, + _fallbackOptions.ApiKey, + _fallbackOptions.DefaultInstructions, + _fallbackOptions.MaxTokens, + _fallbackOptions.Temperature); + } + + private string? ResolveDefaultInstructions(string? modelInstructions) + { + if (string.IsNullOrWhiteSpace(modelInstructions)) + return _fallbackOptions.DefaultInstructions; + + return IsLegacyRagOnlyInstructions(modelInstructions) + ? _fallbackOptions.DefaultInstructions + : modelInstructions; + } + + private static bool IsLegacyRagOnlyInstructions(string instructions) => + instructions.Contains("RAG知识库助手", StringComparison.OrdinalIgnoreCase) + && instructions.Contains("上下文", StringComparison.Ordinal) + && instructions.Contains("没有相关信息", StringComparison.Ordinal); +} diff --git a/src/modules/rag/Infrastructure/AI/ChatAgentService.cs b/src/modules/rag/Infrastructure/AI/ChatAgentService.cs index e1a48d5..805925f 100644 --- a/src/modules/rag/Infrastructure/AI/ChatAgentService.cs +++ b/src/modules/rag/Infrastructure/AI/ChatAgentService.cs @@ -1,137 +1,96 @@ using System.Runtime.CompilerServices; using System.Text; -using System.ClientModel; -using Microsoft.Agents.AI; -using Microsoft.EntityFrameworkCore; +using System.Net.Http.Headers; using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using OpenAI; -using BuildFlow.Domain.Enums; using BuildFlow.Domain.Interfaces; -using BuildFlow.Infrastructure.Persistence; +using ModelType = BuildFlow.Domain.Enums.ModelType; namespace BuildFlow.Infrastructure.AI; -public class ChatAgentService : IAIChatAgent +/// +/// AI 对话代理服务。按模型管理中的默认 Chat 模型调用 OpenAI 兼容 Chat Completions 接口。 +/// +public class ChatAgentService( + AiModelRuntimeConfigResolver configResolver, + IHttpClientFactory httpClientFactory) + : IAIChatAgent { - private readonly IServiceScopeFactory _scopeFactory; - private readonly AiOptions _fallbackOptions; - - public ChatAgentService(IServiceScopeFactory scopeFactory, IOptions options) - { - _scopeFactory = scopeFactory; - _fallbackOptions = options.Value; - } - - private async Task<(string ModelId, string BaseUrl, string ApiKey, string Instructions)> ResolveConfigAsync(CancellationToken ct) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var model = await db.AiModels - .AsNoTracking() - .FirstOrDefaultAsync(m => m.ModelType == ModelType.Chat && m.IsDefault && m.IsActive && !m.IsDeleted, ct); - - if (model is not null) - return (model.ModelId, model.BaseUrl, model.ApiKey, model.DefaultInstructions ?? _fallbackOptions.DefaultInstructions); - - return (_fallbackOptions.ChatModel, _fallbackOptions.BaseUrl, _fallbackOptions.ApiKey, _fallbackOptions.DefaultInstructions); - } - - private async Task CreateAgentAsync(CancellationToken ct) - { - var (modelId, baseUrl, apiKey, instructions) = await ResolveConfigAsync(ct); - var client = new OpenAIClient( - new ApiKeyCredential(apiKey), - new OpenAIClientOptions { Endpoint = new Uri(baseUrl) }); - IChatClient chatClient = client.GetChatClient(modelId).AsIChatClient(); - return chatClient.AsAIAgent(name: "BuildFlowAgent", instructions: instructions); - } + private const string ResponseStyleInstructions = """ + 回答规则: + 1. 直接回答用户问题,不要输出“您好!作为RAG知识库助手”“下面是我的回答”等开场白。 + 2. 不要输出 Markdown 一级/二级标题,除非用户明确要求标题或提纲。 + 3. 不要展示推理过程、思考标签或 reasoning 内容。 + 4. 普通对话没有知识库上下文时,按通用 AI 助手直接回答,不要说上下文不足。 + 5. 如果用户没有提出明确问题,只用一句话提示其输入问题,不要自我介绍。 + """; public async Task RunAsync(IReadOnlyList messages, CancellationToken ct = default) { - var agent = await CreateAgentAsync(ct); - var response = await agent.RunAsync(messages, cancellationToken: ct); - return FilterThinkTags(response.ToString()); + var reply = new StringBuilder(); + await foreach (var chunk in RunStreamingAsync(messages, ct)) + reply.Append(chunk); + + return AiChatOutputSanitizer.SanitizeFinalText(reply.ToString()); } public async IAsyncEnumerable RunStreamingAsync( IReadOnlyList messages, [EnumeratorCancellation] CancellationToken ct = default) { - var agent = await CreateAgentAsync(ct); - var buffer = new StringBuilder(); - var inThink = false; + var config = await configResolver.ResolveAsync(ModelType.Chat, ct); + using var request = BuildRequest(config, messages); + var client = httpClientFactory.CreateClient(); - await foreach (var update in agent.RunStreamingAsync(messages, cancellationToken: ct)) + using var response = await client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + ct); + response.EnsureSuccessStatusCode(); + + await using var responseStream = await response.Content.ReadAsStreamAsync(ct); + using var reader = new StreamReader(responseStream); + var sanitizer = new AiChatOutputSanitizer(); + + while (await reader.ReadLineAsync(ct) is { } line) { - var text = update.ToString(); - if (string.IsNullOrEmpty(text)) continue; - buffer.Append(text); + if (!line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + continue; - while (buffer.Length > 0) - { - var content = buffer.ToString(); - if (inThink) - { - var endIdx = content.IndexOf(" 10) buffer.Remove(0, buffer.Length - 10); break; } - var closeIdx = content.IndexOf('>', endIdx); - if (closeIdx < 0) break; - inThink = false; - buffer.Remove(0, closeIdx + 1); - } - else - { - var thinkIdx = content.IndexOf(" 7) - { - var safeLen = buffer.Length - 7; - var output = buffer.ToString(0, safeLen); - buffer.Remove(0, safeLen); - if (output.Length > 0) yield return output; - } - break; - } - if (thinkIdx > 0) yield return buffer.ToString(0, thinkIdx); - var endIdx = content.IndexOf("= 0) - { - var closeIdx = content.IndexOf('>', endIdx); - if (closeIdx >= 0) { buffer.Remove(0, closeIdx + 1); } - else { inThink = true; buffer.Clear(); break; } - } - else { inThink = true; buffer.Remove(0, thinkIdx); break; } - } - } + var data = line[5..].Trim(); + var content = OpenAiCompatibleChatStreamParser.ReadContentDelta(data); + if (content is null) + continue; + + foreach (var chunk in sanitizer.Append(content)) + yield return chunk; } - if (!inThink && buffer.Length > 0) yield return buffer.ToString(); - buffer.Clear(); + + foreach (var chunk in sanitizer.Complete()) + yield return chunk; } - private static string FilterThinkTags(string text) + private static HttpRequestMessage BuildRequest( + AiModelRuntimeConfig config, + IReadOnlyList messages) { - if (string.IsNullOrEmpty(text)) return text; - var sb = new StringBuilder(); - var span = text.AsSpan(); - while (!span.IsEmpty) - { - var thinkStart = span.IndexOf("'); - if (gtIdx < 0) break; - var endTag = afterThink[(gtIdx + 1)..].IndexOf("'); - if (endGt < 0) break; - span = afterEnd[(endGt + 1)..]; - } - return sb.ToString().TrimStart('\n', '\r'); + var request = new HttpRequestMessage(HttpMethod.Post, BuildChatCompletionsUri(config.BaseUrl)); + if (!string.IsNullOrWhiteSpace(config.ApiKey)) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.ApiKey); + + request.Content = new StringContent( + OpenAiCompatibleChatRequestPayload.BuildJson(config, messages, ResponseStyleInstructions), + Encoding.UTF8, + "application/json"); + return request; + } + + private static Uri BuildChatCompletionsUri(string baseUrl) + { + var normalized = baseUrl.TrimEnd('/'); + if (normalized.EndsWith("/chat/completions", StringComparison.OrdinalIgnoreCase)) + return new Uri(normalized); + + return new Uri($"{normalized}/chat/completions"); } } diff --git a/src/modules/rag/Infrastructure/AI/EmbeddingService.cs b/src/modules/rag/Infrastructure/AI/EmbeddingService.cs index b968fb3..da7d523 100644 --- a/src/modules/rag/Infrastructure/AI/EmbeddingService.cs +++ b/src/modules/rag/Infrastructure/AI/EmbeddingService.cs @@ -1,7 +1,7 @@ using System.ClientModel; -using Microsoft.Extensions.Options; using OpenAI; using OpenAI.Embeddings; +using BuildFlow.Domain.Enums; using BuildFlow.Domain.Interfaces; namespace BuildFlow.Infrastructure.AI; @@ -19,30 +19,26 @@ public class EmbeddingService : IEmbeddingService /// private const int EmbeddingBatchSize = 64; - private readonly EmbeddingClient _client; + private readonly AiModelRuntimeConfigResolver _configResolver; - public EmbeddingService(IOptions options) - { - var opt = options.Value; - var openaiClient = new OpenAIClient( - new ApiKeyCredential(opt.ApiKey), - new OpenAIClientOptions { Endpoint = new Uri(opt.BaseUrl) }); - - _client = openaiClient.GetEmbeddingClient(opt.EmbeddingModel); - } + public EmbeddingService(AiModelRuntimeConfigResolver configResolver) + => _configResolver = configResolver; public async Task EmbedAsync(string text, CancellationToken ct) { - var response = await _client.GenerateEmbeddingAsync(text, cancellationToken: ct); + var client = await CreateClientAsync(ct); + var response = await client.GenerateEmbeddingAsync(text, cancellationToken: ct); return response.Value.ToFloats().ToArray(); } public async Task> EmbedBatchAsync(List texts, CancellationToken ct) { + var client = await CreateClientAsync(ct); + // 文本量小则一次调用即可,避免小请求也走分批的循环开销 if (texts.Count <= EmbeddingBatchSize) { - return await EmbedOneBatchAsync(texts, ct); + return await EmbedOneBatchAsync(client, texts, ct); } // 大批量分批调用并按原顺序拼接结果。上层(ProcessDocumentCommand)按索引对应 chunk, @@ -51,14 +47,24 @@ public class EmbeddingService : IEmbeddingService for (var i = 0; i < texts.Count; i += EmbeddingBatchSize) { var slice = texts.GetRange(i, Math.Min(EmbeddingBatchSize, texts.Count - i)); - results.AddRange(await EmbedOneBatchAsync(slice, ct)); + results.AddRange(await EmbedOneBatchAsync(client, slice, ct)); } return results; } - private async Task> EmbedOneBatchAsync(List texts, CancellationToken ct) + private async Task CreateClientAsync(CancellationToken ct) { - var response = await _client.GenerateEmbeddingsAsync(texts, cancellationToken: ct); + var config = await _configResolver.ResolveAsync(ModelType.Embedding, ct); + var openaiClient = new OpenAIClient( + new ApiKeyCredential(config.ApiKey), + new OpenAIClientOptions { Endpoint = new Uri(config.BaseUrl) }); + + return openaiClient.GetEmbeddingClient(config.ModelId); + } + + private static async Task> EmbedOneBatchAsync(EmbeddingClient client, List texts, CancellationToken ct) + { + var response = await client.GenerateEmbeddingsAsync(texts, cancellationToken: ct); return response.Value.Select(e => e.ToFloats().ToArray()).ToList(); } } diff --git a/src/modules/rag/Infrastructure/AI/OpenAiCompatibleChatRequestPayload.cs b/src/modules/rag/Infrastructure/AI/OpenAiCompatibleChatRequestPayload.cs new file mode 100644 index 0000000..0b4fb24 --- /dev/null +++ b/src/modules/rag/Infrastructure/AI/OpenAiCompatibleChatRequestPayload.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace BuildFlow.Infrastructure.AI; + +/// +/// OpenAI 兼容 Chat Completions 请求载荷构造器。集中控制 BuildFlow 对本地模型的生成参数。 +/// +public static class OpenAiCompatibleChatRequestPayload +{ + /// + /// 构造 JSON 请求体。reasoning_effort=none 用于避免 Qwen/DeepSeek 类模型长时间只输出推理流。 + /// + public static string BuildJson( + AiModelRuntimeConfig config, + IReadOnlyList messages, + string responseStyleInstructions) + { + var payload = new + { + model = config.ModelId, + messages = BuildOpenAiMessages(config, messages, responseStyleInstructions), + stream = true, + temperature = config.Temperature, + max_tokens = config.MaxTokens, + reasoning_effort = "none", + }; + + return JsonSerializer.Serialize(payload); + } + + private static List BuildOpenAiMessages( + AiModelRuntimeConfig config, + IReadOnlyList messages, + string responseStyleInstructions) + { + var result = new List + { + new { role = "system", content = BuildSystemInstructions(config.DefaultInstructions, responseStyleInstructions) }, + }; + + result.AddRange(messages.Select(m => new + { + role = ToOpenAiRole(m.Role), + content = m.Text, + })); + + return result; + } + + private static string BuildSystemInstructions(string? configuredInstructions, string responseStyleInstructions) + { + if (string.IsNullOrWhiteSpace(configuredInstructions)) + return responseStyleInstructions; + + return $"{configuredInstructions.Trim()}\n\n{responseStyleInstructions}"; + } + + private static string ToOpenAiRole(ChatRole role) + { + if (role == ChatRole.System) return "system"; + if (role == ChatRole.Assistant) return "assistant"; + if (role == ChatRole.Tool) return "tool"; + return "user"; + } +} diff --git a/src/modules/rag/Infrastructure/AI/OpenAiCompatibleChatStreamParser.cs b/src/modules/rag/Infrastructure/AI/OpenAiCompatibleChatStreamParser.cs new file mode 100644 index 0000000..1a54aef --- /dev/null +++ b/src/modules/rag/Infrastructure/AI/OpenAiCompatibleChatStreamParser.cs @@ -0,0 +1,42 @@ +using System.Text.Json; + +namespace BuildFlow.Infrastructure.AI; + +/// +/// OpenAI 兼容 Chat Completions SSE 解析器。LM Studio 的 /v1/chat/completions 流式响应同样使用该结构。 +/// +public static class OpenAiCompatibleChatStreamParser +{ + /// + /// 从单条 SSE data 内容中读取可展示正文增量。reasoning_content 属于推理过程,不进入最终气泡。 + /// + public static string? ReadContentDelta(string data) + { + if (string.IsNullOrWhiteSpace(data) || data.Trim() == "[DONE]") + return null; + + using var document = JsonDocument.Parse(data); + if (!document.RootElement.TryGetProperty("choices", out var choices) + || choices.ValueKind != JsonValueKind.Array + || choices.GetArrayLength() == 0) + { + return null; + } + + var choice = choices[0]; + if (!choice.TryGetProperty("delta", out var delta) + || delta.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (delta.TryGetProperty("content", out var content) + && content.ValueKind == JsonValueKind.String) + { + var text = content.GetString(); + return string.IsNullOrEmpty(text) ? null : text; + } + + return null; + } +} diff --git a/src/modules/rag/Infrastructure/RAGInfrastructureModule.cs b/src/modules/rag/Infrastructure/RAGInfrastructureModule.cs index 1206ec4..d4d9cff 100644 --- a/src/modules/rag/Infrastructure/RAGInfrastructureModule.cs +++ b/src/modules/rag/Infrastructure/RAGInfrastructureModule.cs @@ -44,6 +44,7 @@ public class RAGInfrastructureModule : AbpModule // AI 服务注册 services.Configure(config.GetSection(AiOptions.SectionName)); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/modules/workflow/Application/BuildFlow/Buttons/Commands/DisableBuildFlowButtonDefinitionCommand.cs b/src/modules/workflow/Application/BuildFlow/Buttons/Commands/DisableBuildFlowButtonDefinitionCommand.cs new file mode 100644 index 0000000..fee5b09 --- /dev/null +++ b/src/modules/workflow/Application/BuildFlow/Buttons/Commands/DisableBuildFlowButtonDefinitionCommand.cs @@ -0,0 +1,8 @@ +using MediatR; +using Workflow.Application.BuildFlow.Buttons.DTOs; + +namespace Workflow.Application.BuildFlow.Buttons.Commands; + +/// 禁用 BuildFlow 按钮定义命令。禁用后运行时不再加载该按钮。 +public sealed record DisableBuildFlowButtonDefinitionCommand(Guid Id) + : IRequest; diff --git a/src/modules/workflow/Application/BuildFlow/Buttons/Commands/PublishBuildFlowButtonDefinitionCommand.cs b/src/modules/workflow/Application/BuildFlow/Buttons/Commands/PublishBuildFlowButtonDefinitionCommand.cs new file mode 100644 index 0000000..eaf24e0 --- /dev/null +++ b/src/modules/workflow/Application/BuildFlow/Buttons/Commands/PublishBuildFlowButtonDefinitionCommand.cs @@ -0,0 +1,8 @@ +using MediatR; +using Workflow.Application.BuildFlow.Buttons.DTOs; + +namespace Workflow.Application.BuildFlow.Buttons.Commands; + +/// 发布 BuildFlow 按钮定义命令。发布后运行时才能加载并执行按钮。 +public sealed record PublishBuildFlowButtonDefinitionCommand(Guid Id) + : IRequest; diff --git a/src/modules/workflow/Application/BuildFlow/Buttons/Handlers/DisableBuildFlowButtonDefinitionCommandHandler.cs b/src/modules/workflow/Application/BuildFlow/Buttons/Handlers/DisableBuildFlowButtonDefinitionCommandHandler.cs new file mode 100644 index 0000000..efefaa8 --- /dev/null +++ b/src/modules/workflow/Application/BuildFlow/Buttons/Handlers/DisableBuildFlowButtonDefinitionCommandHandler.cs @@ -0,0 +1,44 @@ +using MediatR; +using Workflow.Application.BuildFlow.Buttons.Commands; +using Workflow.Application.BuildFlow.Buttons.DTOs; +using Workflow.Domain.Entities; +using Workflow.Domain.Enums; +using Workflow.Infrastructure.Persistence; + +namespace Workflow.Application.BuildFlow.Buttons.Handlers; + +/// 禁用 BuildFlow 按钮定义处理器。用于立即阻止运行时继续展示和执行该按钮。 +public class DisableBuildFlowButtonDefinitionCommandHandler(WorkflowDbContext db) + : IRequestHandler +{ + public async Task Handle( + DisableBuildFlowButtonDefinitionCommand request, + CancellationToken ct) + { + var entity = await db.BuildFlowButtons.FindAsync([request.Id], ct) + ?? throw new BusinessException($"BuildFlow 按钮 {request.Id} 不存在"); + + entity.Status = BuildFlowMetadataStatus.Disabled; + await db.SaveChangesAsync(ct); + return ToDto(entity); + } + + private static BuildFlowButtonDefinitionDto ToDto(BuildFlowButtonDefinition entity) + => new( + entity.Id, + entity.ApplicationId, + entity.PageDefinitionId, + entity.FormDefinitionId, + entity.WorkflowDefinitionId, + entity.Code, + entity.Label, + entity.Icon, + entity.Placement, + entity.PermissionCode, + entity.VisibleWhenJson, + entity.DisabledWhenJson, + entity.ConfirmJson, + entity.ActionsJson, + entity.Status, + entity.SortOrder); +} diff --git a/src/modules/workflow/Application/BuildFlow/Buttons/Handlers/PublishBuildFlowButtonDefinitionCommandHandler.cs b/src/modules/workflow/Application/BuildFlow/Buttons/Handlers/PublishBuildFlowButtonDefinitionCommandHandler.cs new file mode 100644 index 0000000..988c6b6 --- /dev/null +++ b/src/modules/workflow/Application/BuildFlow/Buttons/Handlers/PublishBuildFlowButtonDefinitionCommandHandler.cs @@ -0,0 +1,44 @@ +using MediatR; +using Workflow.Application.BuildFlow.Buttons.Commands; +using Workflow.Application.BuildFlow.Buttons.DTOs; +using Workflow.Domain.Entities; +using Workflow.Domain.Enums; +using Workflow.Infrastructure.Persistence; + +namespace Workflow.Application.BuildFlow.Buttons.Handlers; + +/// 发布 BuildFlow 按钮定义处理器。按钮发布后才会被运行时按 Published 状态查询到。 +public class PublishBuildFlowButtonDefinitionCommandHandler(WorkflowDbContext db) + : IRequestHandler +{ + public async Task Handle( + PublishBuildFlowButtonDefinitionCommand request, + CancellationToken ct) + { + var entity = await db.BuildFlowButtons.FindAsync([request.Id], ct) + ?? throw new BusinessException($"BuildFlow 按钮 {request.Id} 不存在"); + + entity.Status = BuildFlowMetadataStatus.Published; + await db.SaveChangesAsync(ct); + return ToDto(entity); + } + + private static BuildFlowButtonDefinitionDto ToDto(BuildFlowButtonDefinition entity) + => new( + entity.Id, + entity.ApplicationId, + entity.PageDefinitionId, + entity.FormDefinitionId, + entity.WorkflowDefinitionId, + entity.Code, + entity.Label, + entity.Icon, + entity.Placement, + entity.PermissionCode, + entity.VisibleWhenJson, + entity.DisabledWhenJson, + entity.ConfirmJson, + entity.ActionsJson, + entity.Status, + entity.SortOrder); +} diff --git a/src/modules/workflow/Application/Integration/Commands/StartWorkflowByNameCommand.cs b/src/modules/workflow/Application/Integration/Commands/StartWorkflowByNameCommand.cs new file mode 100644 index 0000000..b8f4afa --- /dev/null +++ b/src/modules/workflow/Application/Integration/Commands/StartWorkflowByNameCommand.cs @@ -0,0 +1,12 @@ +using MediatR; +using Workflow.Application.Integration.DTOs; + +namespace Workflow.Application.Integration.Commands; + +/// +/// 按流程名称触发工作流命令。由 RAG/AI 意图识别后通过 MediatR 调用。 +/// +public sealed record StartWorkflowByNameCommand( + string DefinitionKey, + Guid InitiatorId, + string? Variables) : IRequest; diff --git a/src/modules/workflow/Application/Integration/DTOs/WorkflowTriggerResultDto.cs b/src/modules/workflow/Application/Integration/DTOs/WorkflowTriggerResultDto.cs new file mode 100644 index 0000000..cde7f75 --- /dev/null +++ b/src/modules/workflow/Application/Integration/DTOs/WorkflowTriggerResultDto.cs @@ -0,0 +1,10 @@ +namespace Workflow.Application.Integration.DTOs; + +/// +/// 工作流触发结果。用于跨模块 CQRS 调用返回 AI/RAG 触发流程的执行结果。 +/// +public sealed record WorkflowTriggerResultDto( + bool Success, + Guid? InstanceId, + string? Title, + string? ErrorMessage); diff --git a/src/modules/workflow/Application/Integration/DTOs/WorkflowTriggerableDefinitionDto.cs b/src/modules/workflow/Application/Integration/DTOs/WorkflowTriggerableDefinitionDto.cs new file mode 100644 index 0000000..e92d6e4 --- /dev/null +++ b/src/modules/workflow/Application/Integration/DTOs/WorkflowTriggerableDefinitionDto.cs @@ -0,0 +1,10 @@ +namespace Workflow.Application.Integration.DTOs; + +/// +/// 可由 AI/RAG 触发的工作流定义摘要。 +/// +public sealed record WorkflowTriggerableDefinitionDto( + Guid Id, + string Key, + string Name, + string? Description); diff --git a/src/modules/workflow/Application/Integration/Handlers/GetTriggerableWorkflowDefinitionsQueryHandler.cs b/src/modules/workflow/Application/Integration/Handlers/GetTriggerableWorkflowDefinitionsQueryHandler.cs new file mode 100644 index 0000000..faf3e83 --- /dev/null +++ b/src/modules/workflow/Application/Integration/Handlers/GetTriggerableWorkflowDefinitionsQueryHandler.cs @@ -0,0 +1,24 @@ +using MediatR; +using Workflow.Application.Integration.DTOs; +using Workflow.Application.Integration.Queries; +using Workflow.Domain.Enums; +using Workflow.Infrastructure.Persistence; + +namespace Workflow.Application.Integration.Handlers; + +/// +/// 可触发工作流定义查询处理器。供 RAG/AI 在无法匹配流程时展示候选项。 +/// +public sealed class GetTriggerableWorkflowDefinitionsQueryHandler(WorkflowDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + GetTriggerableWorkflowDefinitionsQuery request, + CancellationToken ct) + { + return await db.WorkflowDefinitions + .Where(d => d.Status == DefinitionStatus.Published) + .Select(d => new WorkflowTriggerableDefinitionDto(d.Id, d.Name, d.Name, d.Description)) + .ToListAsync(ct); + } +} diff --git a/src/modules/workflow/Application/Integration/Handlers/StartWorkflowByNameCommandHandler.cs b/src/modules/workflow/Application/Integration/Handlers/StartWorkflowByNameCommandHandler.cs new file mode 100644 index 0000000..6ea9e55 --- /dev/null +++ b/src/modules/workflow/Application/Integration/Handlers/StartWorkflowByNameCommandHandler.cs @@ -0,0 +1,53 @@ +using MediatR; +using Workflow.Application.Features.WorkflowInstances.Commands; +using Workflow.Application.Integration.Commands; +using Workflow.Application.Integration.DTOs; +using Workflow.Domain.Enums; +using Workflow.Infrastructure.Persistence; + +namespace Workflow.Application.Integration.Handlers; + +/// +/// 按名称触发工作流命令处理器。负责解析已发布流程定义并复用启动流程实例命令。 +/// +public sealed class StartWorkflowByNameCommandHandler( + WorkflowDbContext db, + ISender sender) : IRequestHandler +{ + public async Task Handle( + StartWorkflowByNameCommand request, + CancellationToken ct) + { + var definition = await db.WorkflowDefinitions + .FirstOrDefaultAsync( + d => d.Name.Contains(request.DefinitionKey) && + d.Status == DefinitionStatus.Published, + ct); + + if (definition is null) + { + return new WorkflowTriggerResultDto( + false, + null, + null, + $"未找到名为「{request.DefinitionKey}」的已发布流程定义"); + } + + try + { + var instanceId = await sender.Send( + new StartWorkflowInstanceCommand( + definition.Code ?? definition.Id.ToString(), + $"{request.DefinitionKey}-{DateTime.UtcNow:MMddHHmm}", + request.Variables, + null), + ct); + + return new WorkflowTriggerResultDto(true, instanceId, request.DefinitionKey, null); + } + catch (Exception ex) + { + return new WorkflowTriggerResultDto(false, null, null, ex.Message); + } + } +} diff --git a/src/modules/workflow/Application/Integration/Queries/GetTriggerableWorkflowDefinitionsQuery.cs b/src/modules/workflow/Application/Integration/Queries/GetTriggerableWorkflowDefinitionsQuery.cs new file mode 100644 index 0000000..c101258 --- /dev/null +++ b/src/modules/workflow/Application/Integration/Queries/GetTriggerableWorkflowDefinitionsQuery.cs @@ -0,0 +1,10 @@ +using MediatR; +using Workflow.Application.Integration.DTOs; + +namespace Workflow.Application.Integration.Queries; + +/// +/// 查询可由 AI/RAG 触发的已发布工作流定义。 +/// +public sealed record GetTriggerableWorkflowDefinitionsQuery + : IRequest>; diff --git a/src/modules/workflow/Application/Integration/WorkflowTriggerService.cs b/src/modules/workflow/Application/Integration/WorkflowTriggerService.cs deleted file mode 100644 index 572f229..0000000 --- a/src/modules/workflow/Application/Integration/WorkflowTriggerService.cs +++ /dev/null @@ -1,50 +0,0 @@ -using BuildFlow.Shared.Integration; -using Workflow.Application.Features.WorkflowInstances.Commands; -using Workflow.Domain.Enums; -using Workflow.Infrastructure.Persistence; - -namespace Workflow.Application.Integration; - -/// -/// 工作流触发服务实现(AI 对话 → 触发工作流)。 -/// 串联 RAG ↔ Workflow:AI 识别流程意图后调用,按名称查找定义并启动实例。 -/// -public class WorkflowTriggerService( - WorkflowDbContext db, - MediatR.IMediator mediator) : IWorkflowTriggerService -{ - public async Task TryStartAsync(string definitionKey, Guid initiatorId, string? variables, CancellationToken ct) - { - var definition = await db.WorkflowDefinitions - .FirstOrDefaultAsync(d => d.Name.Contains(definitionKey) && d.Status == DefinitionStatus.Published, ct); - - if (definition is null) - { - return new WorkflowTriggerResult(false, null, null, $"未找到名为「{definitionKey}」的已发布流程定义"); - } - - try - { - var instanceId = await mediator.Send( - new StartWorkflowInstanceCommand( - DefinitionCode: definition.Code ?? definition.Id.ToString(), - Title: $"{definitionKey}-{DateTime.Now:MMddHHmm}", - Variables: variables, - FormDataJson: null), ct); - - return new WorkflowTriggerResult(true, instanceId, definitionKey, null); - } - catch (Exception ex) - { - return new WorkflowTriggerResult(false, null, null, ex.Message); - } - } - - public async Task> GetTriggerableDefinitionsAsync(CancellationToken ct) - { - return await db.WorkflowDefinitions - .Where(d => d.Status == DefinitionStatus.Published) - .Select(d => new WorkflowDefinitionSummary(d.Id, d.Name, d.Name, d.Description)) - .ToListAsync(ct); - } -} diff --git a/src/shared/BuildFlow.Shared/Integration/IWorkflowTriggerService.cs b/src/shared/BuildFlow.Shared/Integration/IWorkflowTriggerService.cs deleted file mode 100644 index 91a8c06..0000000 --- a/src/shared/BuildFlow.Shared/Integration/IWorkflowTriggerService.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace BuildFlow.Shared.Integration; - -/// -/// 工作流触发服务接口(跨模块集成:AI 对话 → 触发工作流)。 -/// RAG 模块的 AI bot 识别到流程意图后调此接口,Workflow 模块实现它创建流程实例。 -/// 放在 Shared 层实现解耦——RAG 不引用 Workflow.Application。 -/// -public interface IWorkflowTriggerService -{ - /// - /// 按定义 key 启动工作流实例。 - /// - /// 流程定义的 Key/名称(如"报销审批"、"请假流程") - /// 发起人 userId - /// 初始变量(JSON 字符串,可含表单数据) - /// 取消令牌 - /// 启动结果:实例 ID + 流程标题,失败返回 null + 错误消息 - Task TryStartAsync(string definitionKey, Guid initiatorId, string? variables, CancellationToken ct); - - /// - /// 查询当前用户可触发的流程定义列表(供 AI bot 展示可选流程)。 - /// - Task> GetTriggerableDefinitionsAsync(CancellationToken ct); -} diff --git a/src/shared/BuildFlow.Shared/Integration/WorkflowDefinitionSummary.cs b/src/shared/BuildFlow.Shared/Integration/WorkflowDefinitionSummary.cs deleted file mode 100644 index 764ba7c..0000000 --- a/src/shared/BuildFlow.Shared/Integration/WorkflowDefinitionSummary.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace BuildFlow.Shared.Integration; - -/// 流程定义摘要(供 AI 展示)。 -public record WorkflowDefinitionSummary(Guid Id, string Key, string Name, string? Description); diff --git a/src/shared/BuildFlow.Shared/Integration/WorkflowTriggerResult.cs b/src/shared/BuildFlow.Shared/Integration/WorkflowTriggerResult.cs deleted file mode 100644 index e91e5ed..0000000 --- a/src/shared/BuildFlow.Shared/Integration/WorkflowTriggerResult.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace BuildFlow.Shared.Integration; - -/// 工作流启动结果。 -public record WorkflowTriggerResult(bool Success, Guid? InstanceId, string? Title, string? ErrorMessage); diff --git a/tests/BuildFlow.IM.Integration.Tests/ChatHubMessagingTests.cs b/tests/BuildFlow.IM.Integration.Tests/ChatHubMessagingTests.cs index 0d031a0..8e2dbb4 100644 --- a/tests/BuildFlow.IM.Integration.Tests/ChatHubMessagingTests.cs +++ b/tests/BuildFlow.IM.Integration.Tests/ChatHubMessagingTests.cs @@ -90,6 +90,79 @@ public class ChatHubMessagingTests : IAsyncLifetime } } + [Fact] + public async Task SendMessage_file_message_preserves_metadata_reply_and_updates_conversation_summary() + { + // Arrange:前端文件发送链路会先完成分片上传,再通过 ChatHub 发送文件名、大小和预览 URL。 + _fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice"); + _fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob"); + var convId = await ImTestSeeder.SeedPrivateConversationAsync( + _fixture, + ImServerFixture.AliceId, + ImServerFixture.BobId); + + var quoted = new MessageDocument + { + ConversationId = convId, + SenderId = ImServerFixture.BobId, + SenderName = "Bob", + ContentType = MessageContentType.Text, + Content = "请把方案文件发我", + CreatedAt = DateTime.UtcNow.AddSeconds(-10), + }; + await DB.Default.SaveAsync(quoted); + + var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice"); + var bob = await ConnectAsync(ImServerFixture.BobId, "Bob"); + + MessageDto? bobReceived = null; + bob.On("OnMessageReceived", m => bobReceived = m); + + var metadata = new MessageMetadataDto( + FileName: "BuildFlow-方案.pdf", + FileSize: 2048, + FileUrl: "/api/files/preview?objectKey=multipart%2Fhash", + ImageUrl: null, + ImageWidth: null, + ImageHeight: null, + EmojiCode: null); + + // Act:Alice 发送文件消息,并引用 Bob 的原始消息。 + await alice.InvokeAsync("SendMessage", new SendMessageRequest( + convId, + MessageContentType.File, + "BuildFlow-方案.pdf", + metadata, + quoted.ID)); + + await WaitForAsync(() => bobReceived is not null); + + // Assert ①:广播 DTO 保留文件元数据和引用消息 ID,前端可以直接渲染文件卡片与引用预览。 + Assert.NotNull(bobReceived); + Assert.Equal(MessageContentType.File, bobReceived!.ContentType); + Assert.Equal("BuildFlow-方案.pdf", bobReceived.Content); + Assert.Equal(quoted.ID, bobReceived.ReplyToId); + Assert.Equal("BuildFlow-方案.pdf", bobReceived.Metadata?.FileName); + Assert.Equal(2048, bobReceived.Metadata?.FileSize); + Assert.Equal("/api/files/preview?objectKey=multipart%2Fhash", bobReceived.Metadata?.FileUrl); + + // Assert ②:MongoDB 持久化同样保留元数据和引用关系,支撑历史搜索/定位后的再次渲染。 + var persisted = await DB.Default.Find() + .Match(m => m.ID == bobReceived.Id) + .ExecuteFirstAsync(); + Assert.NotNull(persisted); + Assert.Equal(quoted.ID, persisted!.ReplyToId); + Assert.Equal("BuildFlow-方案.pdf", persisted.Metadata?.FileName); + Assert.Equal("/api/files/preview?objectKey=multipart%2Fhash", persisted.Metadata?.FileUrl); + + // Assert ③:会话列表摘要使用文件占位,避免长 URL 或元数据污染列表。 + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var conv = await db.Conversations.AsNoTracking().FirstAsync(c => c.Id == convId); + Assert.Equal($"[文件] {metadata.FileName}", conv.LastMessageContent); + Assert.Equal(bobReceived.Id, conv.LastMessageId); + } + [Fact] public async Task SendMessage_increments_redis_unread_for_other_participant_only() { diff --git a/tests/BuildFlow.Rag.Tests/AiChatOutputSanitizerTests.cs b/tests/BuildFlow.Rag.Tests/AiChatOutputSanitizerTests.cs new file mode 100644 index 0000000..b27492d --- /dev/null +++ b/tests/BuildFlow.Rag.Tests/AiChatOutputSanitizerTests.cs @@ -0,0 +1,30 @@ +using BuildFlow.Infrastructure.AI; +using Xunit; + +namespace BuildFlow.Rag.Tests; + +/// +/// AI 对话输出清洗测试。确保模型模板开场不进入最终消息气泡。 +/// +public class AiChatOutputSanitizerTests +{ + [Fact] + public void SanitizeFinalTextShouldRemoveRagAssistantBoilerplate() + { + const string raw = "您好!作为RAG知识库助手,我需要您提供具体的问题以及相关的上下文或知识库内容,才能为您准确解答。"; + + var cleaned = AiChatOutputSanitizer.SanitizeFinalText(raw); + + Assert.Equal("请先输入要查询的问题,或选择知识库后再提问。", cleaned); + } + + [Fact] + public void SanitizeFinalTextShouldRemoveThinkTags() + { + const string raw = "内部推理\n答案正文"; + + var cleaned = AiChatOutputSanitizer.SanitizeFinalText(raw); + + Assert.Equal("答案正文", cleaned); + } +} diff --git a/tests/BuildFlow.Rag.Tests/AiModelRuntimeConfigResolverTests.cs b/tests/BuildFlow.Rag.Tests/AiModelRuntimeConfigResolverTests.cs new file mode 100644 index 0000000..0f62362 --- /dev/null +++ b/tests/BuildFlow.Rag.Tests/AiModelRuntimeConfigResolverTests.cs @@ -0,0 +1,133 @@ +using BuildFlow.Domain.Entities; +using BuildFlow.Domain.Enums; +using BuildFlow.Infrastructure.AI; +using BuildFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace BuildFlow.Rag.Tests; + +/// +/// AI 运行时模型配置解析测试。确保 Chat/Embedding 调用优先使用模型管理里配置的默认本地模型。 +/// +public class AiModelRuntimeConfigResolverTests +{ + [Fact] + public async Task ResolveAsyncShouldReplaceLegacyRagOnlyChatInstructions() + { + var services = new ServiceCollection(); + var dbName = $"rag-chat-model-{Guid.NewGuid():N}"; + services.AddDbContext(options => + options.UseInMemoryDatabase(dbName)); + services.AddScoped(); + services.AddSingleton(Options.Create(new AiOptions + { + BaseUrl = "http://localhost:1234/v1", + ApiKey = "lm-studio", + ChatModel = "fallback-chat", + EmbeddingModel = "fallback-embedding", + DefaultInstructions = "你是 BuildFlow AI 助手。没有知识库上下文时按普通对话直接回答。", + })); + + await using var provider = services.BuildServiceProvider(); + await using (var scope = provider.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.AiModels.AddAsync(new AiModel + { + Id = Guid.NewGuid(), + Name = "旧对话模型", + Provider = ModelProvider.LMStudio, + ModelId = "qwen/qwen3.6-27b", + BaseUrl = "http://localhost:1234/v1", + ApiKey = "lm-studio", + ModelType = ModelType.Chat, + IsActive = true, + IsDefault = true, + DefaultInstructions = "你是一个RAG知识库助手,基于提供的上下文准确回答用户问题。如果上下文中没有相关信息,请如实说明。", + CreatedBy = "test", + UpdatedBy = "test", + OperatorIP = "127.0.0.1", + }); + await db.SaveChangesAsync(); + } + + await using (var scope = provider.CreateAsyncScope()) + { + var resolver = scope.ServiceProvider.GetRequiredService(); + + var config = await resolver.ResolveAsync(ModelType.Chat, CancellationToken.None); + + Assert.Equal("你是 BuildFlow AI 助手。没有知识库上下文时按普通对话直接回答。", config.DefaultInstructions); + } + } + + [Fact] + public async Task ResolveAsyncShouldPreferDefaultEmbeddingModelFromDatabase() + { + var services = new ServiceCollection(); + var dbName = $"rag-ai-model-{Guid.NewGuid():N}"; + services.AddDbContext(options => + options.UseInMemoryDatabase(dbName)); + services.AddScoped(); + services.AddSingleton(Options.Create(new AiOptions + { + BaseUrl = "https://fallback.example/v1", + ApiKey = "fallback-key", + ChatModel = "fallback-chat", + EmbeddingModel = "fallback-embedding", + DefaultInstructions = "fallback instructions", + })); + + await using var provider = services.BuildServiceProvider(); + await using (var scope = provider.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.AiModels.AddRangeAsync( + new AiModel + { + Id = Guid.NewGuid(), + Name = "旧嵌入模型", + Provider = ModelProvider.OpenAI, + ModelId = "remote-embedding", + BaseUrl = "https://remote.example/v1", + ApiKey = "remote-key", + ModelType = ModelType.Embedding, + IsActive = true, + IsDefault = false, + CreatedBy = "test", + UpdatedBy = "test", + OperatorIP = "127.0.0.1", + }, + new AiModel + { + Id = Guid.NewGuid(), + Name = "本地嵌入模型", + Provider = ModelProvider.LMStudio, + ModelId = "text-embedding-embeddinggemma-300m", + BaseUrl = "http://localhost:1234/v1", + ApiKey = "lm-studio", + ModelType = ModelType.Embedding, + IsActive = true, + IsDefault = true, + CreatedBy = "test", + UpdatedBy = "test", + OperatorIP = "127.0.0.1", + }); + await db.SaveChangesAsync(); + } + + await using (var scope = provider.CreateAsyncScope()) + { + var resolver = scope.ServiceProvider.GetRequiredService(); + + var config = await resolver.ResolveAsync(ModelType.Embedding, CancellationToken.None); + + Assert.Equal("text-embedding-embeddinggemma-300m", config.ModelId); + Assert.Equal("http://localhost:1234/v1", config.BaseUrl); + Assert.Equal("lm-studio", config.ApiKey); + } + } +} diff --git a/tests/BuildFlow.Rag.Tests/OpenAiCompatibleChatRequestPayloadTests.cs b/tests/BuildFlow.Rag.Tests/OpenAiCompatibleChatRequestPayloadTests.cs new file mode 100644 index 0000000..6b06092 --- /dev/null +++ b/tests/BuildFlow.Rag.Tests/OpenAiCompatibleChatRequestPayloadTests.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using BuildFlow.Infrastructure.AI; +using Microsoft.Extensions.AI; +using Xunit; + +namespace BuildFlow.Rag.Tests; + +/// +/// OpenAI 兼容请求载荷测试。确保本地推理模型默认关闭 reasoning 空转阶段。 +/// +public class OpenAiCompatibleChatRequestPayloadTests +{ + [Fact] + public void BuildJsonShouldDisableReasoningEffort() + { + var config = new AiModelRuntimeConfig( + "qwen/qwen3.6-27b", + "http://localhost:1234/v1", + "lm-studio", + null, + 128, + 0); + + var json = OpenAiCompatibleChatRequestPayload.BuildJson( + config, + [new ChatMessage(ChatRole.User, "你好")], + "直接回答。"); + + using var document = JsonDocument.Parse(json); + + Assert.Equal("none", document.RootElement.GetProperty("reasoning_effort").GetString()); + } +} diff --git a/tests/BuildFlow.Rag.Tests/OpenAiCompatibleChatStreamParserTests.cs b/tests/BuildFlow.Rag.Tests/OpenAiCompatibleChatStreamParserTests.cs new file mode 100644 index 0000000..11ec2b7 --- /dev/null +++ b/tests/BuildFlow.Rag.Tests/OpenAiCompatibleChatStreamParserTests.cs @@ -0,0 +1,42 @@ +using BuildFlow.Infrastructure.AI; +using Xunit; + +namespace BuildFlow.Rag.Tests; + +/// +/// OpenAI 兼容流式响应解析测试。覆盖 LM Studio 返回的 content 与 reasoning_content 分离场景。 +/// +public class OpenAiCompatibleChatStreamParserTests +{ + [Fact] + public void ReadContentDeltaShouldReturnOnlyVisibleContent() + { + const string data = """ + {"choices":[{"delta":{"content":"你好"}}]} + """; + + var chunk = OpenAiCompatibleChatStreamParser.ReadContentDelta(data); + + Assert.Equal("你好", chunk); + } + + [Fact] + public void ReadContentDeltaShouldIgnoreReasoningContent() + { + const string data = """ + {"choices":[{"delta":{"reasoning_content":"这里是推理过程"}}]} + """; + + var chunk = OpenAiCompatibleChatStreamParser.ReadContentDelta(data); + + Assert.Null(chunk); + } + + [Fact] + public void ReadContentDeltaShouldIgnoreDoneSentinel() + { + var chunk = OpenAiCompatibleChatStreamParser.ReadContentDelta("[DONE]"); + + Assert.Null(chunk); + } +} diff --git a/tests/BuildFlow.Rag.Tests/PreviewDocumentChunksQueryHandlerTests.cs b/tests/BuildFlow.Rag.Tests/PreviewDocumentChunksQueryHandlerTests.cs new file mode 100644 index 0000000..c3dbca5 --- /dev/null +++ b/tests/BuildFlow.Rag.Tests/PreviewDocumentChunksQueryHandlerTests.cs @@ -0,0 +1,115 @@ +using BuildFlow.Application.Document.Handlers; +using BuildFlow.Application.Document.Queries; +using BuildFlow.Domain.Entities; +using BuildFlow.Domain.Enums; +using BuildFlow.Domain.Interfaces; +using BuildFlow.Infrastructure.AI; +using BuildFlow.Infrastructure.Persistence; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace BuildFlow.Rag.Tests; + +/// +/// 文档切片预览测试。预览只提取并分块,不生成 embedding,也不写入正式分块表。 +/// +public class PreviewDocumentChunksQueryHandlerTests +{ + [Fact] + public async Task HandleShouldReturnDryRunChunksWithoutPersistingDocumentChunks() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"rag-preview-{Guid.NewGuid():N}") + .Options; + await using var db = new RagDbContext(options); + var kbId = Guid.NewGuid(); + var docId = Guid.NewGuid(); + var filePath = Path.Combine(Path.GetTempPath(), $"preview-{Guid.NewGuid():N}.md"); + await File.WriteAllTextAsync(filePath, "# 第一章\n\nBuildFlow 文档切片预览。\n\n# 第二章\n\n上传后先看切片,再手动处理向量。"); + + try + { + await db.KnowledgeBases.AddAsync(new KnowledgeBase + { + Id = kbId, + Name = "预览知识库", + ChunkSize = 40, + ChunkOverlap = 5, + ChunkingMode = ChunkingMode.Heading, + EmbeddingModel = "text-embedding-embeddinggemma-300m", + Status = KnowledgeBaseStatus.Active, + CreatedBy = "test", + UpdatedBy = "test", + OperatorIP = "127.0.0.1", + }); + await db.Documents.AddAsync(new Document + { + Id = docId, + KnowledgeBaseId = kbId, + Title = "preview.md", + FileName = "preview.md", + FilePath = filePath, + FileSize = 64, + ContentType = "text/markdown", + Status = DocumentStatus.Pending, + CreatedBy = "test", + UpdatedBy = "test", + OperatorIP = "127.0.0.1", + }); + await db.SaveChangesAsync(); + + var handler = new PreviewDocumentChunksQueryHandler( + db, + new StubDocumentExtractorFactory(), + new TextChunker(), + new StubFileStorageClient(), + new HttpContextAccessor()); + + var result = await handler.Handle(new PreviewDocumentChunksQuery(docId), CancellationToken.None); + + Assert.Equal(2, result.Count); + Assert.All(result, chunk => Assert.False(chunk.HasEmbedding)); + Assert.Contains("第一章", result[0].Content); + Assert.Contains("第二章", result[1].Content); + Assert.Equal(0, await db.DocumentChunks.CountAsync()); + } + finally + { + File.Delete(filePath); + } + } + + private sealed class StubDocumentExtractorFactory : IDocumentExtractorFactory + { + public IDocumentExtractor GetExtractor(string fileNameOrExtension) => new StubDocumentExtractor(); + } + + private sealed class StubDocumentExtractor : IDocumentExtractor + { + public IReadOnlyCollection SupportedExtensions { get; init; } = ["md"]; + + public async Task ExtractAsync(string filePath, CancellationToken ct = default) + { + return new ExtractedDocument + { + Content = await File.ReadAllTextAsync(filePath, ct), + Title = Path.GetFileName(filePath), + }; + } + } + + private sealed class StubFileStorageClient : IFileStorageClient + { + public Task UploadAsync( + string bucket, + string objectKey, + Stream content, + string contentType, + string? authToken, + CancellationToken ct = default) => Task.CompletedTask; + + public Task DownloadAsync(string bucket, string objectKey, string? authToken, CancellationToken ct = default) + => throw new InvalidOperationException("本用例不应下载文件服务对象"); + } +} diff --git a/tests/BuildFlow.Workflow.Tests/BuildFlow/BuildFlowButtonActionTests.cs b/tests/BuildFlow.Workflow.Tests/BuildFlow/BuildFlowButtonActionTests.cs index 5c1abd4..14aae24 100644 --- a/tests/BuildFlow.Workflow.Tests/BuildFlow/BuildFlowButtonActionTests.cs +++ b/tests/BuildFlow.Workflow.Tests/BuildFlow/BuildFlowButtonActionTests.cs @@ -49,6 +49,45 @@ public class BuildFlowButtonActionTests button.Status.Should().Be(BuildFlowMetadataStatus.Draft); } + [Fact] + public async Task PublishButtonDefinition_ShouldMakeButtonExecutableByRuntime() + { + await using var db = TestDbFactory.Create(); + var button = CreateButton(Guid.NewGuid()); + button.Status = BuildFlowMetadataStatus.Draft; + db.BuildFlowButtons.Add(button); + await db.SaveChangesAsync(); + + var handler = new PublishBuildFlowButtonDefinitionCommandHandler(db); + + var result = await handler.Handle( + new PublishBuildFlowButtonDefinitionCommand(button.Id), + CancellationToken.None); + + result.Status.Should().Be(BuildFlowMetadataStatus.Published); + (await db.BuildFlowButtons.FindAsync(button.Id))!.Status + .Should().Be(BuildFlowMetadataStatus.Published); + } + + [Fact] + public async Task DisableButtonDefinition_ShouldRemoveButtonFromRuntime() + { + await using var db = TestDbFactory.Create(); + var button = CreateButton(Guid.NewGuid()); + db.BuildFlowButtons.Add(button); + await db.SaveChangesAsync(); + + var handler = new DisableBuildFlowButtonDefinitionCommandHandler(db); + + var result = await handler.Handle( + new DisableBuildFlowButtonDefinitionCommand(button.Id), + CancellationToken.None); + + result.Status.Should().Be(BuildFlowMetadataStatus.Disabled); + (await db.BuildFlowButtons.FindAsync(button.Id))!.Status + .Should().Be(BuildFlowMetadataStatus.Disabled); + } + [Fact] public async Task ExecuteAsync_RunFunctionAction_ReturnsFunctionResultAndWritesLog() { diff --git a/tests/BuildFlow.Workflow.Tests/Integration/WorkflowTriggerCqrsTests.cs b/tests/BuildFlow.Workflow.Tests/Integration/WorkflowTriggerCqrsTests.cs new file mode 100644 index 0000000..e7834ae --- /dev/null +++ b/tests/BuildFlow.Workflow.Tests/Integration/WorkflowTriggerCqrsTests.cs @@ -0,0 +1,113 @@ +using FluentAssertions; +using MediatR; +using Workflow.Application.Integration.Commands; +using Workflow.Application.Integration.Handlers; +using Workflow.Application.Integration.Queries; +using Workflow.Application.Features.WorkflowInstances.Commands; +using Workflow.Domain.Entities; +using Workflow.Domain.Enums; +using Xunit; + +namespace BuildFlow.Workflow.Tests.Integration; + +/// +/// AI/RAG 触发工作流的 CQRS 契约测试,禁止回退到 shared service bridge。 +/// +public class WorkflowTriggerCqrsTests +{ + [Fact] + public async Task StartWorkflowByNameCommand_ShouldDispatchStartInstanceCommand() + { + await using var db = TestDbFactory.Create(); + var definition = new WorkflowDefinition + { + Id = Guid.NewGuid(), + Name = "报销审批", + Code = "expense-approval", + Status = DefinitionStatus.Published, + Version = 1, + IsEnabled = true, + }; + db.WorkflowDefinitions.Add(definition); + await db.SaveChangesAsync(); + var instanceId = Guid.NewGuid(); + var sender = new TestSender(instanceId); + var handler = new StartWorkflowByNameCommandHandler(db, sender); + + var result = await handler.Handle( + new StartWorkflowByNameCommand("报销", Guid.NewGuid(), """{"amount":1200}"""), + CancellationToken.None); + + result.Success.Should().BeTrue(); + result.InstanceId.Should().Be(instanceId); + result.Title.Should().Be("报销"); + sender.StartCommand.Should().NotBeNull(); + sender.StartCommand!.DefinitionCode.Should().Be("expense-approval"); + sender.StartCommand.Title.Should().StartWith("报销-"); + sender.StartCommand.Variables.Should().Be("""{"amount":1200}"""); + } + + [Fact] + public async Task GetTriggerableWorkflowDefinitionsQuery_ShouldReturnPublishedDefinitions() + { + await using var db = TestDbFactory.Create(); + db.WorkflowDefinitions.AddRange( + new WorkflowDefinition + { + Id = Guid.NewGuid(), + Name = "报销审批", + Code = "expense-approval", + Status = DefinitionStatus.Published, + Version = 1, + IsEnabled = true, + }, + new WorkflowDefinition + { + Id = Guid.NewGuid(), + Name = "草稿流程", + Code = "draft-flow", + Status = DefinitionStatus.Draft, + Version = 1, + IsEnabled = true, + }); + await db.SaveChangesAsync(); + var handler = new GetTriggerableWorkflowDefinitionsQueryHandler(db); + + var result = await handler.Handle(new GetTriggerableWorkflowDefinitionsQuery(), CancellationToken.None); + + result.Should().ContainSingle(); + result[0].Key.Should().Be("报销审批"); + result[0].Name.Should().Be("报销审批"); + } + + private sealed class TestSender(Guid instanceId) : ISender + { + public StartWorkflowInstanceCommand? StartCommand { get; private set; } + + public Task Send(IRequest request, CancellationToken cancellationToken = default) + { + if (request is StartWorkflowInstanceCommand command && typeof(TResponse) == typeof(Guid)) + { + StartCommand = command; + return Task.FromResult((TResponse)(object)instanceId); + } + + throw new NotSupportedException($"Unexpected request {request.GetType().Name}"); + } + + public Task Send(TRequest request, CancellationToken cancellationToken = default) + where TRequest : IRequest + => throw new NotSupportedException($"Unexpected request {typeof(TRequest).Name}"); + + public Task Send(object request, CancellationToken cancellationToken = default) + => throw new NotSupportedException($"Unexpected request {request.GetType().Name}"); + + public IAsyncEnumerable CreateStream( + IStreamRequest request, + CancellationToken cancellationToken = default) + => throw new NotSupportedException($"Unexpected stream request {request.GetType().Name}"); + + public IAsyncEnumerable CreateStream(object request, CancellationToken cancellationToken = default) + => throw new NotSupportedException($"Unexpected stream request {request.GetType().Name}"); + } +}