diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.system.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.system.txt index cfd2fe2d47..b05196b8ab 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.system.txt +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.system.txt @@ -3,7 +3,7 @@ You are a careful grant review assistant for human reviewers. Do not fill gaps, TASK Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SCORE, OUTPUT, and RULES: -1. Review the application and attachments for the strongest reviewer-relevant evidence. +1. Review the application and any provided attachments for the strongest reviewer-relevant evidence. 2. Determine which conclusions are directly supported by that evidence. 3. Exclude weak, repetitive, or loosely supported conclusions. -4. Return only the strongest evidence-backed reviewer conclusions. \ No newline at end of file +4. Return only the strongest evidence-backed reviewer conclusions. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-scoring.system.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-scoring.system.txt index 297a9f351f..aefebf06d5 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-scoring.system.txt +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-scoring.system.txt @@ -5,7 +5,7 @@ TASK Using DATA, ATTACHMENTS, SECTION, RESPONSE, OUTPUT, and RULES: 1. Review each question in SECTION one at a time. 2. Identify the exact condition the question asks about. -3. Consider only the most relevant evidence in DATA and ATTACHMENTS for that condition. +3. Consider only the most relevant evidence in DATA and any provided ATTACHMENTS for that condition. 4. Choose the most conservative valid answer supported by that evidence. 5. If evidence is incomplete or indirect, explain the uncertainty in the rationale. -6. Repeat for every question in SECTION. \ No newline at end of file +6. Repeat for every question in SECTION. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/common.rules.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/common.rules.txt index ada24e7e87..790f34fb73 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/common.rules.txt +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/common.rules.txt @@ -1,4 +1,5 @@ - Any narrative text response must be at least 12 words. +- If ATTACHMENTS is empty, use DATA only and do not mention missing attachments unless their absence is material to the specific conclusion or question. - Return values exactly as specified in OUTPUT. - Do not return keys outside OUTPUT. - Return valid JSON only. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index b61fed77c1..c3c0f9fb5e 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -198,7 +198,7 @@ public async Task GenerateApplicationScoringAsync(Ap { var attachments = attachmentSummaries.Count > 0 ? string.Join("\n- ", attachmentSummaries.Select((summary, index) => $"Attachment {index + 1}: {summary}")) - : "No attachments provided."; + : "[]"; var section = OpenAIPromptRenderer.BuildAliasedApplicationScoringSection(request.SectionName, sectionJson, out var questionIdAliasMap); var response = OpenAIPromptRenderer.BuildApplicationScoringResponseTemplate(section); @@ -318,14 +318,12 @@ private async Task GenerateWithRetryAsync( private async Task LogPromptInputAsync(string promptType, string promptVersion, string? systemPrompt, string userPrompt, CancellationToken cancellationToken = default) { var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); - _logger.LogInformation("AI {PromptType} ({PromptVersion}) input payload: {PromptInput}", promptType, promptVersion, formattedInput); await WritePromptLogFileAsync(promptType, promptVersion, "INPUT", formattedInput, cancellationToken); } private async Task LogPromptOutputAsync(string promptType, string promptVersion, string output, CancellationToken cancellationToken = default) { var formattedOutput = FormatPromptOutputForLog(output); - _logger.LogInformation("AI {PromptType} ({PromptVersion}) model output payload: {ModelOutput}", promptType, promptVersion, formattedOutput); await WritePromptLogFileAsync(promptType, promptVersion, "OUTPUT", formattedOutput, cancellationToken); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs index 58859a5e83..6ee4261528 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs @@ -278,7 +278,7 @@ OPTIONAL FIELDS (may be left blank): TASK Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SCORE, OUTPUT, and RULES: - 1. Review the application and attachments for the strongest reviewer-relevant evidence. + 1. Review the application and any provided attachments for the strongest reviewer-relevant evidence. 2. Determine which conclusions are directly supported by that evidence. 3. Exclude weak, repetitive, or loosely supported conclusions. 4. Return only the strongest evidence-backed reviewer conclusions. @@ -520,7 +520,7 @@ 2. A brief rationale (1-2 complete sentences) citing concrete supporting evidenc Using DATA, ATTACHMENTS, SECTION, RESPONSE, OUTPUT, and RULES: 1. Review each question in SECTION one at a time. 2. Identify the exact condition the question asks about. - 3. Consider only the most relevant evidence in DATA and ATTACHMENTS for that condition. + 3. Consider only the most relevant evidence in DATA and any provided ATTACHMENTS for that condition. 4. Choose the most conservative valid answer supported by that evidence. 5. If evidence is incomplete or indirect, explain the uncertainty in the rationale. 6. Repeat for every question in SECTION. @@ -583,6 +583,7 @@ 4. Choose the most conservative valid answer supported by that evidence. // ── v1/common.rules.txt ────────────────────────────────────────────────── private const string CommonRules = """ - Any narrative text response must be at least 12 words. + - If ATTACHMENTS is empty, use DATA only and do not mention missing attachments unless their absence is material to the specific conclusion or question. - Return values exactly as specified in OUTPUT. - Do not return keys outside OUTPUT. - Return valid JSON only. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationRequestDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationRequestDto.cs index 0dfccaa949..5c1ac1ee8c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationRequestDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationRequestDto.cs @@ -13,4 +13,6 @@ public class AIGenerationRequestDto : EntityDto public DateTime? CompletedAt { get; set; } public string? FailureReason { get; set; } public bool IsActive { get; set; } + public bool IsGenerating { get; set; } + public int RetryAfterSeconds { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 6178fea11f..be44fb379e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -13,9 +13,10 @@ using System.Text.Json; using System.Threading.Tasks; using Unity.Flex.WorksheetInstances; -using Unity.Flex.Worksheets; -using Unity.AI.Models; -using Unity.AI.Responses; +using Unity.Flex.Worksheets; +using Unity.AI.Models; +using Unity.AI.RateLimit; +using Unity.AI.Responses; using Unity.GrantManager.Applicants; using Unity.GrantManager.ApplicationForms; using Unity.GrantManager.Applications; @@ -55,10 +56,11 @@ public class GrantApplicationAppService( IApplicantAgentRepository applicantAgentRepository, IApplicantAddressRepository applicantAddressRepository, IApplicantSupplierAppService applicantSupplierService, - IPaymentRequestAppService paymentRequestService, - IApplicationAIGenerationQueue aiGenerationQueue, - IAIGenerationStatusAppService aiGenerationStatusAppService, - IFeatureChecker featureChecker) + IPaymentRequestAppService paymentRequestService, + IApplicationAIGenerationQueue aiGenerationQueue, + IAIGenerationStatusAppService aiGenerationStatusAppService, + IAIRateLimiter aiRateLimiter, + IFeatureChecker featureChecker) : GrantManagerAppService, IGrantApplicationAppService #pragma warning restore S107 // Methods should not have too many parameters { @@ -1180,7 +1182,8 @@ public async Task QueueApplicationAnalysisAsync(Guid app AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, CurrentTenant.Id); - return request ?? throw new UserFriendlyException("Unable to queue AI analysis request."); + return await ApplyRateLimitStateAsync( + request ?? throw new UserFriendlyException("Unable to queue AI analysis request.")); } [Authorize(AIPermissions.Analysis.GenerateAttachmentSummaries)] @@ -1195,7 +1198,8 @@ public async Task QueueAttachmentSummaryAsync(QueueAttac AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, CurrentTenant.Id); - return request ?? throw new UserFriendlyException("Unable to queue AI attachment summary request."); + return await ApplyRateLimitStateAsync( + request ?? throw new UserFriendlyException("Unable to queue AI attachment summary request.")); } [Authorize(AIPermissions.Analysis.GenerateScoring)] @@ -1209,13 +1213,15 @@ public async Task QueueApplicationScoringAsync(Guid appl AIGenerationRequestKeyHelper.ApplicationScoringOperationType, CurrentTenant.Id); - return request ?? throw new UserFriendlyException("Unable to queue AI scoring request."); + return await ApplyRateLimitStateAsync( + request ?? throw new UserFriendlyException("Unable to queue AI scoring request.")); } public async Task GetAIGenerationStatusAsync(Guid applicationId, string operationType, string? promptVersion = null) { - await EnsureAIGenerationStatusAccessAsync(operationType); - return await aiGenerationStatusAppService.GetLatestAsync(applicationId, operationType, CurrentTenant.Id); + await EnsureAIGenerationStatusAccessAsync(operationType); + return await ApplyRateLimitStateOrDefaultAsync( + await aiGenerationStatusAppService.GetLatestAsync(applicationId, operationType, CurrentTenant.Id)); } [Authorize(AIPermissions.Analysis.GenerateApplicationAnalysis)] @@ -1233,8 +1239,27 @@ public async Task QueueAllAIStagesAsync(Guid application AIGenerationRequestKeyHelper.PipelineOperationType, CurrentTenant.Id); - return request ?? throw new UserFriendlyException("Unable to queue AI generation request."); - } + return await ApplyRateLimitStateAsync( + request ?? throw new UserFriendlyException("Unable to queue AI generation request.")); + } + + private async Task ApplyRateLimitStateOrDefaultAsync(AIGenerationRequestDto? request) + { + if (request is null) + { + return null; + } + + return await ApplyRateLimitStateAsync(request); + } + + private async Task ApplyRateLimitStateAsync(AIGenerationRequestDto request) + { + var state = await aiRateLimiter.GetStateAsync(); + request.IsGenerating = state.IsGenerating; + request.RetryAfterSeconds = state.RetryAfterSeconds; + return request; + } private async Task EnsureAIGenerationStatusAccessAsync(string operationType) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs index c2eca7860d..6a8b608d1f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs @@ -536,7 +536,17 @@ public partial class ApplicationActionResultItemToDtoMapper : MapperBase { public override partial CommunityDto Map(Community source); public override partial void Map(Community source, CommunityDto destination); } [Mapper] public partial class RegionalDistrictToDtoMapper : MapperBase { public override partial RegionalDistrictDto Map(RegionalDistrict source); public override partial void Map(RegionalDistrict source, RegionalDistrictDto destination); } [Mapper] public partial class ApplicationTagsToDtoMapper : MapperBase { public override partial ApplicationTagsDto Map(ApplicationTags source); public override partial void Map(ApplicationTags source, ApplicationTagsDto destination); } -[Mapper] public partial class AIGenerationRequestToDtoMapper : MapperBase { public override partial AIGenerationRequestDto Map(AIGenerationRequest source); public override partial void Map(AIGenerationRequest source, AIGenerationRequestDto destination); } +[Mapper] +public partial class AIGenerationRequestToDtoMapper : MapperBase +{ + [MapperIgnoreTarget(nameof(AIGenerationRequestDto.IsGenerating))] + [MapperIgnoreTarget(nameof(AIGenerationRequestDto.RetryAfterSeconds))] + public override partial AIGenerationRequestDto Map(AIGenerationRequest source); + + [MapperIgnoreTarget(nameof(AIGenerationRequestDto.IsGenerating))] + [MapperIgnoreTarget(nameof(AIGenerationRequestDto.RetryAfterSeconds))] + public override partial void Map(AIGenerationRequest source, AIGenerationRequestDto destination); +} [Mapper(AllowNullPropertyAssignment = true)] public partial class ApplicantToGrantApplicationApplicantDtoMapper : MapperBase { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs index b01b2881d1..908079048e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs @@ -7,6 +7,7 @@ using Volo.Abp; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; +using Volo.Abp.Uow; namespace Unity.GrantManager.Notifications { @@ -24,6 +25,7 @@ public NotificationsAppService(IDynamicUrlRepository dynamicUrlRepository) _teamsNotificationService = new TeamsNotificationService(); } + [UnitOfWork] public async Task InitializeTeamsChannelAsync(string keyName) { DynamicUrl? teamsChannel = await _dynamicUrlRepository.FirstOrDefaultAsync(q => q.KeyName == keyName); @@ -50,6 +52,7 @@ public async Task NotifyChefsEventToTeamsAsync(string factName, string factValue await _teamsNotificationService.PostFactsToTeamsAsync(teamsChannel, activityTitle, activitySubtitle); } + [UnitOfWork] public async Task PostToTeamsAsync(string activityTitle, string activitySubtitle, List facts) { string teamsChannel = await InitializeTeamsChannelAsync(TeamsNotificationService.TEAMS_NOTIFICATION); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationFormManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationFormManager.cs index 31ee0d2851..d72db21f77 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationFormManager.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationFormManager.cs @@ -1,9 +1,9 @@ -using System.Threading.Tasks; -using Volo.Abp.Domain.Services; -using Unity.GrantManager.Intakes; +using Newtonsoft.Json.Linq; +using System.Threading.Tasks; using Unity.GrantManager.Events; +using Unity.GrantManager.Intakes; using Volo.Abp.Domain.Repositories; -using Newtonsoft.Json.Linq; +using Volo.Abp.Domain.Services; namespace Unity.GrantManager.Applications { @@ -58,9 +58,14 @@ public ApplicationForm SynchronizePublishedForm(ApplicationForm applicationForm, JObject formObject = JObject.Parse(form.ToString()); var formName = formObject.SelectToken("name"); var formDescription = formObject.SelectToken("description"); - if(formName != null && formDescription != null) + + if (formName != null) { applicationForm.ApplicationFormName = formName.ToString(); + } + + if (formDescription != null && string.IsNullOrWhiteSpace(applicationForm.ApplicationFormDescription)) + { applicationForm.ApplicationFormDescription = formDescription.ToString(); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css index 9adb01aaff..73828d06ba 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css @@ -272,9 +272,18 @@ select.selected { margin-top: 0.8rem; } -.details-nav .nav-link { - display: flex; -} +.details-nav .nav-link { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + height: 42px; + line-height: 1.5; +} + +.details-nav .nav-link i { + line-height: 1; +} .details-nav .nav-link .fl { margin-right: 4px; @@ -338,15 +347,15 @@ select.selected { z-index: 999; } -#detailsTab .nav-item .nav-link { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.action-bar { - display: flex !important; -} +#detailsTab .nav-item .nav-link { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.action-bar { + display: flex !important; +} #DetailsActionBarStart { margin-right: 0.5rem; @@ -677,36 +686,7 @@ form label.error { border-color: #f5c2c7; } -#ai-analysis-tab { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; -} - -.ai-analysis-tab-icon-wrap { - display: inline-flex; - align-items: center; - justify-content: center; -} - -.ai-analysis-tab-indicator { - width: 8px; - height: 8px; - border-radius: 999px; - background-color: #adb5bd; - flex-shrink: 0; -} - -.ai-analysis-tab-indicator.proceed { - background-color: #198754; -} - -.ai-analysis-tab-indicator.hold { - background-color: #dc3545; -} - -.ai-analysis-action-btn { +.ai-analysis-action-btn { background: #ffffff; border: 1px solid #d1d5db; padding: 4px 10px; @@ -728,4 +708,4 @@ form label.error { .worksheet_AssessmentInfo { padding-left: 0.5rem; -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index c608d0f2ce..a29267f48d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -456,8 +456,8 @@ globalThis.queueApplicationAnalysis = function(triggerButton = null) { if (status === 'Completed') { globalThis.AIGenerationButtonState?.restoreForCooldownCheck($button, existingHtml); + globalThis.AIGenerationButtonState?.applyStatusState(request); loadAIAnalysis(); - globalThis.syncAIRateLimitButtons?.(); return; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button-state.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button-state.js index 7424d1591a..02aea6030d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button-state.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button-state.js @@ -13,6 +13,16 @@ .prop('disabled', true); } + function applyRateLimitState(request, options = {}) { + global.applyAIRateLimitState?.( + { + isGenerating: request?.isGenerating === true || request?.isActive === true, + retryAfterSeconds: Number(request?.retryAfterSeconds) || 0 + }, + { pollWhenGenerating: options.pollWhenGenerating === true } + ); + } + global.AIGenerationButtonState = { resolveStatus(status) { switch (Number(status)) { @@ -29,7 +39,7 @@ } }, setGenerating($button) { - global.setAIGenerationButtonsGenerating?.(); + global.setAIGenerationButtonsGenerating?.({ poll: false }); }, restore($button) { $button.removeClass('disabled'); @@ -37,11 +47,17 @@ restoreForCooldownCheck($button, html) { restoreButtonForCooldownCheck($button, html); }, + applyStatusState(request) { + applyRateLimitState(request, { pollWhenGenerating: true }); + }, monitor(options) { const intervalMs = options.intervalMs || 15000; + const activeIntervalMs = options.activeIntervalMs || 2000; + const activePollCount = options.activePollCount || 10; const maxFailures = options.maxFailures || 3; let timeoutId = null; let failures = 0; + let pollCount = 0; const stop = () => { if (timeoutId) { @@ -50,6 +66,15 @@ } }; + const scheduleNextPoll = () => { + pollCount += 1; + const nextIntervalMs = pollCount <= activePollCount + ? activeIntervalMs + : intervalMs; + + timeoutId = setTimeout(poll, nextIntervalMs); + }; + const poll = () => { options.getStatus() .done((request) => { @@ -59,19 +84,29 @@ if (status === 'Failed') { stop(); restoreButton(options.$button, options.originalHtml); + applyRateLimitState(request, { pollWhenGenerating: true }); options.onFailed?.(request); return; } - if (!request || request.isActive === false || status === 'Completed') { + if (!request) { + stop(); + restoreButton(options.$button, options.originalHtml); + global.refreshAIRateLimitState?.(); + options.onMissing?.(); + return; + } + + if (request.isActive === false || status === 'Completed') { stop(); restoreButtonForCooldownCheck(options.$button, options.originalHtml); + applyRateLimitState(request, { pollWhenGenerating: true }); options.onComplete?.(request); - global.syncAIRateLimitButtons?.(); return; } - timeoutId = setTimeout(poll, intervalMs); + applyRateLimitState(request); + scheduleNextPoll(); }) .fail((error) => { failures += 1; @@ -82,7 +117,7 @@ return; } - timeoutId = setTimeout(poll, intervalMs); + scheduleNextPoll(); }); }; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js index 6ee671727d..f822be6c42 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js @@ -121,12 +121,14 @@ } } - function applyGenerating() { + function applyGenerating(options = {}) { currentState = { mode: 'generating' }; renderState(); clearStatePollTimer(); - statePollTimer = setTimeout(() => fetchState(true), 2000); + if (options.poll !== false) { + statePollTimer = setTimeout(() => fetchState(true), 2000); + } } function renderGenerating() { @@ -223,29 +225,34 @@ return; } const data = await res.json(); - if (data.isGenerating === true) { - applyGenerating(); - return; - } - - applyCooldown(Number(data.retryAfterSeconds) || 0); + applyRateLimitState(data); } catch (_) { // Best-effort; the server is the source of truth. handleStateFetchFailure(); } } + function applyRateLimitState(data, options = {}) { + if (data?.isGenerating === true) { + applyGenerating({ poll: options.pollWhenGenerating !== false }); + return; + } + + applyCooldown(Number(data?.retryAfterSeconds) || 0); + } + globalThis.syncAIRateLimitButtons = () => { renderState(); fetchState(true); }; globalThis.setAIGenerationButtonsGenerating = applyGenerating; + globalThis.applyAIRateLimitState = applyRateLimitState; globalThis.refreshAIRateLimitState = globalThis.syncAIRateLimitButtons; document.addEventListener('click', (e) => { const btn = e.target.closest(BUTTON_SELECTOR); if (!btn) return; - applyGenerating(); + applyGenerating({ poll: false }); }); document.addEventListener('DOMContentLoaded', () => { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js index 9cbc185d6b..4f17bbbb06 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js @@ -699,8 +699,8 @@ function queueApplicationScoring(triggerButton = null) { if (status === 'Completed') { globalThis.AIGenerationButtonState?.restoreForCooldownCheck($button, existingHtml); + globalThis.AIGenerationButtonState?.applyStatusState(request); PubSub.publish('refresh_assessment_scores', null); - globalThis.syncAIRateLimitButtons?.(); return; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index 827ec58c0a..c0c85d3641 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -33,7 +33,7 @@ $(function () { }); }, 10); - $(downloadAll).prop('disabled', result.length === 0); + $(downloadAll).prop('disabled', true); selectedAttachmentIds = []; setGenerateSummariesEnabled(); @@ -68,6 +68,10 @@ $(function () { $('#generateAiSummaries').prop('disabled', selectedAttachmentIds.length === 0); } + function setDownloadAllEnabled() { + $(downloadAll).prop('disabled', selectedAttachmentIds.length === 0); + } + function setSelectAllState() { const totalCheckboxes = $('#ChefsAttachmentsTable .chkbox').length; const selectedCheckboxes = $('#ChefsAttachmentsTable .chkbox:checked').length; @@ -83,6 +87,7 @@ $(function () { chefsDataTable.$('.chkbox').prop('checked', false); $('.select-all-chefs-files').prop('checked', false); setGenerateSummariesEnabled(); + setDownloadAllEnabled(); } function setAllAttachmentSelections(isSelected) { @@ -212,9 +217,8 @@ $(function () { resetAttachmentSelection(); chefsDataTable.ajax.reload(); abp.notify.success('AI summaries generated successfully.'); - globalThis.AIGenerationButtonState?.restore($activeButton); - $activeButton.html(existingHTML).prop('disabled', false); - globalThis.syncAIRateLimitButtons?.(); + globalThis.AIGenerationButtonState?.restoreForCooldownCheck($activeButton, existingHTML); + globalThis.AIGenerationButtonState?.applyStatusState(request); return; } @@ -335,6 +339,7 @@ $(function () { }); setGenerateSummariesEnabled(); setSelectAllState(); + setDownloadAllEnabled(); }); chefsDataTable.on('deselect', function (e, dt, type, indexes) { @@ -352,6 +357,7 @@ $(function () { }); setGenerateSummariesEnabled(); setSelectAllState(); + setDownloadAllEnabled(); }); $('#resyncSubmissionAttachments').on('click', function () { @@ -381,7 +387,7 @@ $(function () { const _this = $(this); const existingHTML = _this.html(); const zip = new JSZip(); - const tempFiles = chefsDataTable.rows().data().toArray().map((row) => ({ + const tempFiles = chefsDataTable.rows({ selected: true }).data().toArray().map((row) => ({ FormSubmissionId: row.chefsSubmissionId, ChefsFileId: row.chefsFileId, Filename: row.fileName, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.cshtml index 3cd452a9c6..3762c96b59 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.cshtml @@ -120,7 +120,7 @@