From ee560255f75b966157541dfa79da2ed9db7ab8b6 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 30 Apr 2026 13:40:33 -0700 Subject: [PATCH 01/11] AB#32543 integrate AI reporting page --- .../Pages/AIReporting/Index.cshtml | 173 +++- .../Pages/AIReporting/Index.cshtml.cs | 6 +- .../Pages/AIReporting/Index.css | 1 + .../Pages/AIReporting/Index.js | 765 +++++++++++++++++- 4 files changed, 899 insertions(+), 46 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.css diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml index 89dbce3511..541a903c95 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml @@ -1,34 +1,171 @@ -@page +@page @model Unity.GrantManager.Web.Pages.AIReporting.IndexModel -@using Unity.GrantManager.Web.Pages.AIReporting @using Unity.Modules.Shared.Permissions @using Volo.Abp.Features +@inject IFeatureChecker FeatureChecker @section styles { - + @if (await FeatureChecker.IsEnabledAsync("Unity.AIReporting") || User.IsInRole(IdentityConsts.ITAdminRoleName)) + { + + } } -@section scripts -{ - - + +@section scripts { @if (await FeatureChecker.IsEnabledAsync("Unity.AIReporting") || User.IsInRole(IdentityConsts.ITAdminRoleName)) { } } -@inject IFeatureChecker FeatureChecker - + + +
+ +
+
+

What would you like to know?

+
+
+ + +
+
+
+
-
\ No newline at end of file + + +
+ + + +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml.cs index d83d79c390..e86bbb2f06 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml.cs @@ -1,16 +1,16 @@ -using Microsoft.AspNetCore.Mvc.RazorPages; using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.RazorPages; using Unity.GrantManager.Integrations; namespace Unity.GrantManager.Web.Pages.AIReporting { public class IndexModel(IEndpointManagementAppService endpointManagementAppService) : PageModel { - public string ReportingAiUrl { get; set; } = string.Empty; + public string ReportingAiApiBaseUrl { get; private set; } = string.Empty; public async Task OnGetAsync() { - ReportingAiUrl = await endpointManagementAppService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.REPORTING_AI); + ReportingAiApiBaseUrl = await endpointManagementAppService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.REPORTING_AI); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.css new file mode 100644 index 0000000000..b99ce14729 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.css @@ -0,0 +1 @@ +/* =========================================================================== AI Reporting page — port of .working/unity-ai/applications/Unity.AI.Reporting.Frontend Sources: app.css, sidebar.css, sql-explanation.ts inline styles. =========================================================================== *//* Local CSS variables (reference uses Metabase global vars; we inline) */:root { --mb-radius: 8px; --mb-blue-600: #2563eb; --mb-blue-700: #1d4ed8; --mb-teal-600: #0d9488; --mb-gray-200: #e2e8f0; --mb-gray-700: #334155; --mb-red-700: #b91c1c; --mb-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08); --mb-font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;}/* ---------- layout shell ---------- */.outer-container { display: grid; grid-template-columns: auto 1fr; /* Use dvh so the layout follows the actual visible viewport (handles browser chrome, address bar, and zoom changes more reliably than 100vh). */ height: calc(100dvh - 6.75rem); min-height: 480px; background: #fff;}.container { display: flex; flex-direction: column; overflow-y: auto; scrollbar-width: none; -ms-overflow-style: none;}.container::-webkit-scrollbar { display: none; }/* ---------- sidebar ---------- */.sidebar { border-right: 1px solid #e9ecef; display: flex; flex-direction: column; height: 100%; width: 220px; background: #fff; box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);}.sidebar-header { padding: 16px 12px 6px 12px; display: flex; flex-direction: column; gap: 8px; flex-shrink: 0;}.sidebar-header h2 { margin: 14px 0 0 14px; font-size: 0.8rem; font-weight: 500; color: #6c757d; text-align: left;}.new-chat-btn { background: transparent; color: #333; border: none; padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 0.9rem; font-weight: 500; display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;}.new-chat-btn:hover,.chat-item:hover,.chat-item.active { background-color: #e9ecef;}.new-chat-icon { font-size: 16px;}.sidebar-content { flex: 1; overflow-y: auto; padding: 0;}.loading,.empty-state { padding: 20px; text-align: center; color: #6c757d; font-style: italic; font-size: 0.9rem;}.chat-list { padding: 0;}.chat-item { margin: 6px 12px; padding: 8px 14px; cursor: pointer; position: relative; display: flex; flex-direction: column; border-radius: 8px;}.chat-title { font-weight: 400; color: #333; font-size: 0.9rem; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: 30px;}.delete-chat-btn { position: absolute; top: 50%; right: 12px; transform: translateY(-50%); background: none; border: none; cursor: pointer; border-radius: 4px; opacity: 0; font-size: 16px; color: #6c757d; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; line-height: 1; padding: 0;}.chat-item:hover .delete-chat-btn { opacity: 1; }.delete-chat-btn:hover { background-color: #d9d9d9; color: #333;}.sidebar-footer { border-top: 1px solid #e9ecef; padding: 12px; display: flex; justify-content: flex-end; flex-shrink: 0; background: #fff;}.footer-btn { background: transparent; border: 1px solid transparent; border-radius: 8px; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; cursor: pointer; color: #6c757d; transition: all 0.2s ease;}.footer-btn:hover { background-color: #f8f9fa; border-color: #dee2e6; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);}.info-btn:hover { background-color: #d1ecf1; border-color: #17a2b8; color: #0c5460;}/* ---------- empty welcome ---------- */.empty-chat-container { display: flex; align-items: center; justify-content: center; height: 100%; flex: 1;}.welcome-content { text-align: center; width: 100%; max-width: 800px;}.welcome-title { font-size: 2rem; color: #333; margin: 0 0 40px 0; line-height: 1.2; font-weight: 500;}/* ---------- chat layout ---------- */.chat-container { display: grid; grid-template-rows: 1fr auto; height: 100%; min-height: 0; flex: 1;}.turns { height: 100%; overflow: hidden; /* Scale padding gracefully across viewport widths — 15vw on big screens, but capped so the chat doesn't get squeezed on smaller laptops/zoom levels. */ padding: 0 clamp(16px, 15vw, 240px); position: relative; min-height: 0;}.turn { display: flex; flex-direction: column; height: 100%; box-sizing: border-box; padding: 24px 0;}/* ---------- bubbles ---------- */.bubble { border-radius: 18px; padding: 12px 16px; font-size: 16px; box-shadow: var(--mb-shadow-sm); border: 1px solid var(--mb-gray-200); background: #fff;}.bubble.bot { width: 100%; position: relative; height: 100%; overflow-y: auto; box-sizing: border-box; scrollbar-width: none; -ms-overflow-style: none; padding: 8px; background: radial-gradient(ellipse at center, rgba(66, 153, 225, 0.08) 0%, rgba(255, 255, 255, 0.95) 70%);}.bubble.bot::-webkit-scrollbar { display: none; }.bubble.bot > div:not(.spinner-center) { height: 100%; align-self: flex-start; display: flex; flex-direction: column;}.bot-inner { opacity: 0; transition: opacity 0.6s; padding: 0; height: 100%; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; align-items: center;}.bot-inner.loaded { opacity: 1; }/* ---------- ask row ---------- */.ask-row-container { margin: 0 clamp(12px, 10vw, 160px) 16px; position: relative; padding: 20px 20px 12px 20px; background: #fff; box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); border-radius: 40px;}.welcome-ask-row { margin: 0; padding: 10px 10px 10px 24px;}.ask-row { display: flex; gap: 12px;}.ask-row-bottom { display: flex; justify-content: space-between; align-items: center; margin-top: 16px;}.bottom-left-controls { display: flex; align-items: center; gap: 12px;}.ask-row input[type="text"] { flex: 1 1 0; border: none; border-radius: var(--mb-radius); font-size: 16px; padding: 8px 4px; background: transparent;}.ask-row input[type="text"]:focus { outline: none; }.ask-question-btn { background: var(--mb-blue-600); color: #fff; border: none; border-radius: 50%; cursor: pointer; transition: background-color 0.12s, box-shadow 0.12s; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;}.ask-question-btn:hover { background: var(--mb-blue-700); box-shadow: var(--mb-shadow-sm);}.ask-question-btn svg { color: #fff; }/* ---------- action buttons ---------- */.action-buttons { display: flex; align-items: center; gap: 8px;}.action-button { display: flex; align-items: center; justify-content: center; padding: 8px; background: transparent; border: 1px solid transparent; border-radius: 20px; font-size: 14px; font-weight: 500; color: #495057; cursor: pointer; transition: all 0.2s ease; white-space: nowrap; width: 32px; height: 32px; box-sizing: border-box;}.action-button:hover:not(:disabled) { background: #f8f9fa; border-color: #dee2e6;}.action-button:disabled { cursor: not-allowed; opacity: 0.4;}.action-button.delete-action:hover:not(:disabled) { background: #f8d7da; border-color: #f5c6cb; color: #721c24;}.action-button svg { width: 16px; height: 16px; }.nav-controls { display: flex; align-items: center; gap: 8px;}.action-button.nav-button:disabled { background: transparent; border-color: transparent; color: #adb5bd; opacity: 0.6;}.turn-counter { font-size: 0.875rem; color: #6c757d; font-weight: 500; min-width: 60px; text-align: center; white-space: nowrap;}/* ---------- loading state ---------- */.sql-loader-container { flex: 1 1 auto; display: flex; flex-direction: column; box-sizing: border-box; overflow: hidden; position: relative; width: 100%; height: 100%; min-height: 400px;}.loading-animation-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; padding: 20px; background: rgba(255, 255, 255, 0.9); border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 10;}.loading-dots { display: flex; gap: 8px; align-items: center;}.loading-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--mb-blue-600); animation: pulse 1.5s ease-in-out infinite;}.loading-dot:nth-child(1) { animation-delay: 0s; }.loading-dot:nth-child(2) { animation-delay: 0.2s; }.loading-dot:nth-child(3) { animation-delay: 0.4s; }@keyframes pulse { 0%, 80%, 100% { transform: scale(0.8); opacity: 0.6; } 40% { transform: scale(1.2); opacity: 1; }}.loading-text { font-size: 16px; font-weight: 500; color: #666; text-align: center;}/* ---------- failure state ---------- */.failure-container { padding: 24px; height: 100%; box-sizing: border-box; position: relative;}.failure-content { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; padding: 20px; background: rgba(255, 255, 255, 0.9); border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 10; max-width: 480px;}.failure-icon { font-size: 32px; flex-shrink: 0; }.failure-title { font-size: 18px; font-weight: 600; color: #c53030; margin: 0;}.failure-message { font-size: 14px; color: #718096; line-height: 1.5; margin: 0; text-align: center;}.failure-actions { display: flex; flex-direction: column; align-items: center; gap: 8px; margin-top: 4px;}.retry-btn { padding: 8px 16px; font-size: 14px; font-weight: 500; border: none; border-radius: 6px; cursor: pointer; transition: all 0.2s ease; background: #3182ce; color: #fff;}.retry-btn:hover { background: #2c5aa0; transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);}.retry-btn--disabled,.retry-btn--disabled:hover { background: #64748b; color: #fff; cursor: not-allowed; transform: none; box-shadow: none;}.retry-hint { font-size: 0.9rem; color: #666; margin: 0;}/* ---------- semantic cache badge ---------- */.cache-badge { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; padding: 4px 10px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 4px; font-size: 0.8rem; color: #334155; margin-bottom: 8px; cursor: default; user-select: none; flex-shrink: 0;}.cache-badge-hint { color: #64748b; }.cache-fresh-btn { background: none; border: none; padding: 0; margin-left: 4px; font-size: inherit; color: #2563eb; text-decoration: underline; cursor: pointer;}.cache-fresh-btn:hover:not(:disabled) { color: #1d4ed8; }.cache-fresh-btn:disabled { color: #94a3b8; cursor: not-allowed; text-decoration: none;}/* ---------- Metabase view button ---------- */.metabase-view-container { display: flex; justify-content: center; align-items: center; flex: 1;}.metabase-view-btn { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 16px 32px; background: linear-gradient(135deg, #4299e1 0%, #3182ce 100%); color: #fff; border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 4px 12px rgba(66, 153, 225, 0.3);}.metabase-view-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(66, 153, 225, 0.4); background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%); color: #fff;}.metabase-view-btn:active { transform: translateY(0); box-shadow: 0 4px 12px rgba(66, 153, 225, 0.3);}.metabase-view-btn svg { flex-shrink: 0; transition: all 0.3s ease; }.metabase-view-btn:hover svg { transform: translateX(2px) translateY(-2px); }.metabase-view-btn span { position: relative; z-index: 1; letter-spacing: 0.3px; }/* ---------- SQL panel (overlay) ---------- */.sql-panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: #fff; z-index: 5; overflow-y: auto; scrollbar-width: none; -ms-overflow-style: none; display: flex; flex-direction: column; justify-content: flex-start; box-sizing: border-box; padding: 8px 16px;}.sql-panel::-webkit-scrollbar { display: none; }.sql-panel-header { padding: 8px 0 16px 0; font-weight: 600; font-size: 18px; color: var(--mb-gray-700); flex-shrink: 0;}.sql-code { padding: 12px 16px; margin: 0; font-family: 'Fira Mono', Consolas, 'Courier New', monospace; font-size: 14px; line-height: 1.35; color: #1e1e1e; background: #fff; white-space: pre-wrap; overflow-y: auto; border-radius: 8px; border: 1px solid var(--mb-gray-200); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); flex: 1;}/* ---------- SQL explanation bubble (typewriter) ---------- */.sql-explanation-bubble { position: relative; background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 12px; padding: 0; margin: 12px 8px 8px 8px; font-size: 0.85em; color: #075985; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); animation: slideIn 0.3s ease-out; flex-shrink: 0;}@keyframes slideIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); }}.bubble-content { padding: 10px 14px; line-height: 1.4;}.bubble-tail { position: absolute; top: -6px; left: 30px; width: 12px; height: 12px; background: #f0f9ff; border-left: 1px solid #bae6fd; border-top: 1px solid #bae6fd; transform: rotate(45deg);}.cursor { animation: blink 1s infinite; font-weight: normal; opacity: 0.8; color: #075985;}@keyframes blink { 0%, 50% { opacity: 0.8; } 51%, 100% { opacity: 0; }}/* ---------- responsive ---------- */@media (max-width: 768px) { .outer-container { grid-template-columns: 1fr; } .sidebar { width: 100%; } .turns { padding: 0 16px; } .ask-row-container { margin-left: 12px; margin-right: 12px; }} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.js index 1d55598c3f..33942eedcc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.js @@ -1,38 +1,753 @@ -(async () => { - const token = await unity.grantManager.identity.jwtToken.generateJWTToken(); - const iframe = document.createElement('iframe'); +/* ========================================================================== + Unity AI Reporting — vanilla JS port of .working/unity-ai (Angular). + ========================================================================== */ +(function () { + 'use strict'; - iframe.style.width = '100%'; - iframe.style.height = '100%'; - iframe.style.border = 'none'; + // ─── DOM refs ─────────────────────────────────────────────────────────── + const root = document.getElementById('ai-reporting-root'); + if (!root) return; - const targetOrigin = new URL(window.reportingAiUrl).origin; + const chatList = document.getElementById('chat-list'); + const emptyState = document.getElementById('empty-state'); + const chatContainer = document.getElementById('chat-container'); + const turnsContainer = document.getElementById('turns-container'); + const questionEmpty = document.getElementById('question-input-empty'); + const questionActive = document.getElementById('question-input-active'); + const navControls = document.getElementById('nav-controls'); + const turnCounter = document.getElementById('turn-counter'); + const btnPrev = document.getElementById('btn-prev-turn'); + const btnNext = document.getElementById('btn-next-turn'); + const btnMetabase = document.getElementById('btn-metabase'); + const btnSql = document.getElementById('btn-sql'); + const btnExplain = document.getElementById('btn-explain'); + const btnDeleteQ = document.getElementById('btn-delete-question'); - // Listen for "READY" message from iframe before sending auth token - const messageHandler = (event) => { - if (event.origin !== targetOrigin) return; - if (event.data?.type === 'READY') { + const apiBase = (window.reportingAiApiBaseUrl || '').replace(/\/+$/, '') + '/api'; + const MAX_RETRIES = 2; + + // ─── State ────────────────────────────────────────────────────────────── + let turnIdSeq = 0; + const newTurnId = () => `turn-${++turnIdSeq}`; + + const state = { + conversation: [], // Turn[] + currentChatId: null, + currentTurnIndex: 0, + chats: [], + }; + + let resizeTimer = null; + + // ─── Utilities ────────────────────────────────────────────────────────── + const escapeHtml = (v) => String(v ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + + const isLoading = () => state.conversation.some(t => t.safeUrl === 'loading'); + const findTurn = (id) => state.conversation.find(t => t.id === id); + + const notify = abp.notify; + + // Fresh turn (matches Turn interface from reference) + const createTurn = (question, retryCount = 0) => ({ + id: newTurnId(), + question, + embed: null, + safeUrl: 'loading', // 'loading' | 'failure' | null (success) + iframeLoaded: false, + sqlPanelOpen: false, + sql_explanation_visible: false, + sql_explanation_text: '', // local rendered text (typewriter) + sqlExplanationStreaming: false, + errorType: null, + errorMessage: null, + errorDetail: null, + retryCount, + canRetry: true, + }); + + // ─── API ──────────────────────────────────────────────────────────────── + const authHeader = async () => { + const token = await unity.grantManager.identity.jwtToken.generateJWTToken(); + return { Authorization: `Bearer ${token}` }; + }; + + const apiFetch = async (path, options = {}) => { + const headers = { + 'Content-Type': 'application/json', + ...(await authHeader()), + ...(options.headers || {}), + }; + const response = await fetch(`${apiBase}${path}`, { ...options, headers }); + let body = null; + try { body = await response.json(); } catch { /* no body */ } + if (!response.ok) { + const err = new Error(body?.message || body?.error || `Request failed: ${response.status}`); + err.status = response.status; + err.errorType = body?.error_type ?? null; + err.errorDetail = body?.detail ?? null; + throw err; + } + return body; + }; + + // Specific endpoints (mirror api.service.ts) + const api = { + ask: (question, conversation, isRetry, retryErrorType, retryErrorDetail) => { + const payload = { question, conversation, is_retry: !!isRetry }; + if (isRetry && retryErrorType) payload.retry_error_type = retryErrorType; + if (isRetry && retryErrorDetail) payload.retry_error_detail = retryErrorDetail; + return apiFetch('/ask', { method: 'POST', body: JSON.stringify(payload) }); + }, + deleteCard: (cardId) => apiFetch('/delete', { method: 'POST', body: JSON.stringify({ card_id: cardId }) }), + explainSql: (sql) => apiFetch('/explain_sql', { method: 'POST', body: JSON.stringify({ sql }) }), + getChats: () => apiFetch('/chats', { method: 'POST', body: '{}' }), + getChat: (id) => apiFetch(`/chats/${encodeURIComponent(id)}`, { method: 'POST', body: '{}' }), + saveChat: (chatId, conversation, title) => + apiFetch('/chats/save', { method: 'POST', body: JSON.stringify({ chat_id: chatId, conversation, title }) }), + deleteChat: (id) => apiFetch(`/chats/${encodeURIComponent(id)}`, { method: 'DELETE', body: '{}' }), + getMetabaseUrl:() => apiFetch('/metabase-url', { method: 'GET' }), + }; + + // ─── Rendering ────────────────────────────────────────────────────────── + + // Build the inner HTML of a turn's bot bubble (just the bubble contents). + const renderTurnInner = (turn) => { + // Loading + if (turn.safeUrl === 'loading') { + return ` +
+
+
+
+
+
+
+
Generating Report...
+
+
`; + } + + // Failure + if (turn.safeUrl === 'failure') { + const icon = + turn.errorType === 'rate_limit' ? '⏳' : + turn.errorType === 'connection_error' ? '🔌' : + '⚠️'; + const title = + turn.errorType === 'rate_limit' ? 'Service Busy' : + turn.errorType === 'connection_error' ? 'Connection Error' : + turn.errorType === 'ai_failure' ? 'Unable to Generate Report' : + turn.errorType === 'server_error' ? 'Something Went Wrong' : + 'Something Went Wrong'; + const canRetry = (turn.retryCount ?? 0) < MAX_RETRIES && !!turn.canRetry; + const hint = turn.errorType === 'ai_failure' + ? 'Try rephrasing your question or adding more detail.' + : 'Please start a new question.'; + const actions = canRetry + ? `` + : ` +

${escapeHtml(hint)}

`; + return ` +
+
+
${icon}
+
${escapeHtml(title)}
+
${escapeHtml(turn.errorMessage || '')}
+
${actions}
+
+
`; + } + + // Success (safeUrl === null) + const embed = turn.embed || {}; + + // SQL explanation typewriter bubble + const showCursor = turn.sqlExplanationStreaming || (!turn.sql_explanation_text && turn.sql_explanation_visible); + const explanationHtml = turn.sql_explanation_visible + ? `
+
+ ${escapeHtml(turn.sql_explanation_text || '')} + ${showCursor ? '' : ''} +
+
+
` + : ''; + + // Cache badge + const cacheBadgeHtml = embed.cache_hit_type === 'llm_judge_hit' + ? `
+ Based on a similar previous question. + ${embed.cache_original_query + ? `Previous question: “${escapeHtml(embed.cache_original_query)}”` + : 'Check if this matches what you meant.'} + +
` + : ''; + + // Metabase view button + const metabaseHtml = ` +
+ +
`; + + // SQL panel overlay + const sqlPanelHtml = turn.sqlPanelOpen + ? `
+
Generated SQL
+
${escapeHtml(embed.SQL || '')}
+
` + : ''; + + return ` +
+ ${explanationHtml} + ${cacheBadgeHtml} + ${metabaseHtml} + ${sqlPanelHtml} +
`; + }; + + const renderTurnElement = (turn) => ` +
+
+ ${renderTurnInner(turn)} +
+
`; + + const renderConversation = () => { + const has = state.conversation.length > 0; + emptyState.hidden = has; + chatContainer.hidden = !has; + turnsContainer.innerHTML = state.conversation.map(renderTurnElement).join(''); + syncControls(); + }; + + // Re-render only one bubble (avoids destroying scroll/focus elsewhere). + const updateBubble = (turnId) => { + const turn = findTurn(turnId); + if (!turn) return; + const bubble = turnsContainer.querySelector(`[data-bubble-id="${CSS.escape(turnId)}"]`); + if (bubble) bubble.innerHTML = renderTurnInner(turn); + }; + + const syncControls = () => { + const len = state.conversation.length; + const idx = state.currentTurnIndex; + const current = state.conversation[idx] || null; + const isSuccess = current?.safeUrl === null; + const hasSql = isSuccess && !!current?.embed?.SQL; + + turnCounter.textContent = len ? `${idx + 1} / ${len}` : '0 / 0'; + navControls.hidden = len <= 1; + btnPrev.disabled = idx <= 0; + btnNext.disabled = idx >= len - 1; + + btnMetabase.disabled = !isSuccess; + btnSql.disabled = !hasSql; + btnExplain.disabled = !hasSql; + btnDeleteQ.disabled = !isSuccess; + }; + + // Smooth scroll to a turn (matches reference scrollToTurn). + const scrollToTurn = (index) => { + const turns = turnsContainer.querySelectorAll('.turn'); + const el = turns[index]; + if (!el) return; + const containerRect = turnsContainer.getBoundingClientRect(); + const turnRect = el.getBoundingClientRect(); + const top = turnsContainer.scrollTop + (turnRect.top - containerRect.top); + turnsContainer.scrollTo({ top, behavior: 'smooth' }); + }; + + // ─── Sidebar ──────────────────────────────────────────────────────────── + const renderChats = () => { + if (!state.chats.length) { + chatList.innerHTML = '
No reports yet. Start a new one!
'; + return; + } + chatList.innerHTML = state.chats.map(c => ` +
+
${escapeHtml(c.title || 'Untitled report')}
+ +
`).join(''); + }; + + const loadChats = async () => { + try { + const chats = await api.getChats(); + state.chats = Array.isArray(chats) ? chats : []; + renderChats(); + } catch (err) { + console.error('Failed to load chats:', err); + state.chats = []; + chatList.innerHTML = '
Unable to load reports.
'; + } + }; + + // ─── Save / load chat ─────────────────────────────────────────────────── + const saveChat = async () => { + if (state.conversation.length === 0) return; + const turnsToSave = state.conversation.filter(t => t.safeUrl !== 'loading'); + if (!turnsToSave.length) return; + + const mostRecent = turnsToSave[turnsToSave.length - 1]; + const title = mostRecent?.embed?.title || state.conversation[0]?.question || 'New Report'; + const conversation = turnsToSave.map(t => ({ + question: t.question, + embed: t.embed, + safeUrl: t.safeUrl, + iframeLoaded: t.iframeLoaded, + sqlPanelOpen: t.sqlPanelOpen, + sql_explanation_visible: t.sql_explanation_visible, + errorType: t.errorType, + errorMessage: t.errorMessage, + errorDetail: t.errorDetail, + retryCount: t.retryCount, + canRetry: t.canRetry, + })); + + try { + const res = await api.saveChat(state.currentChatId, conversation, title); + if (res?.chat_id) state.currentChatId = res.chat_id; + await loadChats(); + } catch (err) { + console.error('Failed to save chat:', err); + notify.error('Failed to save report. Please try again.'); + } + }; + + const loadChat = async (chatId) => { + try { + const data = await api.getChat(chatId); + const raw = Array.isArray(data?.conversation) ? data.conversation : []; + state.conversation = raw.map(r => ({ + id: newTurnId(), + question: r.question || '', + embed: r.embed || null, + safeUrl: r.safeUrl ?? null, + iframeLoaded: true, + sqlPanelOpen: r.sqlPanelOpen ?? false, + sql_explanation_visible: r.sql_explanation_visible ?? false, + sql_explanation_text: r.embed?.sql_explanation || '', + sqlExplanationStreaming: false, + errorType: r.errorType ?? null, + errorMessage: r.errorMessage ?? null, + errorDetail: r.errorDetail ?? null, + retryCount: r.retryCount ?? 0, + canRetry: r.canRetry ?? true, + })); + state.currentChatId = chatId; + state.currentTurnIndex = Math.max(0, state.conversation.length - 1); + renderConversation(); + renderChats(); + requestAnimationFrame(() => scrollToTurn(state.currentTurnIndex)); + } catch (err) { + console.error('Failed to load chat:', err); + notify.error('Failed to load chat. Please try again.'); + } + }; + + const newChat = () => { + state.conversation = []; + state.currentChatId = null; + state.currentTurnIndex = 0; + renderConversation(); + renderChats(); + questionEmpty?.focus(); + }; + + // ─── Ask question (and retry / fresh answer) ──────────────────────────── + const askQuestion = async (text, opts = {}) => { + const trimmed = (text || '').trim(); + if (!trimmed) { + notify.info('Please enter a question.'); + return; + } + if (isLoading()) return; + + const { retryCount = 0, isRetry = false, retryErrorType = null, retryErrorDetail = null } = opts; + + const turn = createTurn(trimmed, retryCount); + state.conversation.push(turn); + state.currentTurnIndex = state.conversation.length - 1; + renderConversation(); + requestAnimationFrame(() => scrollToTurn(state.currentTurnIndex)); + + if (questionEmpty) questionEmpty.value = ''; + if (questionActive) questionActive.value = ''; + + // Build conversation context (success turns only, excluding the new one). + const context = state.conversation + .slice(0, -1) + .filter(t => t.safeUrl === null) + .map(t => ({ question: t.question, embed: t.embed })); + + try { + const result = await api.ask(trimmed, context, isRetry, retryErrorType, retryErrorDetail); + const t = findTurn(turn.id); + if (!t) return; + t.embed = { + card_id: result?.card_id, + x_field: result?.x_field || '', + y_field: result?.y_field || '', + title: result?.title || trimmed, + visualization_options: result?.visualization_options || [], + SQL: result?.SQL || '', + sql_explanation: result?.sql_explanation || '', + tokens: result?.tokens || null, + from_cache: result?.from_cache, + cache_similarity: result?.cache_similarity, + cache_hit_type: result?.cache_hit_type || null, + cache_original_query: result?.cache_original_query || null, + }; + t.safeUrl = null; + t.iframeLoaded = true; + updateBubble(t.id); + syncControls(); + await saveChat(); + } catch (err) { + console.error('Failed to process question:', err); + const t = findTurn(turn.id); + if (!t) return; + t.iframeLoaded = true; + t.safeUrl = 'failure'; + t.errorDetail = err?.errorDetail ?? null; + + const status = err?.status; + const errorType = err?.errorType; + const message = err?.message; + + if (errorType === 'rate_limit' || status === 429) { + t.errorType = 'rate_limit'; + t.errorMessage = message || 'Rate limit exceeded. Please wait a moment and try again.'; + t.canRetry = true; + } else if (errorType === 'connection_error' || status === 503) { + t.errorType = 'connection_error'; + t.errorMessage = message || 'Connection error. The service may be temporarily unavailable.'; + t.canRetry = true; + } else if (errorType === 'ai_failure' || status === 422) { + t.errorType = 'ai_failure'; + t.errorMessage = message || "I couldn't generate a report from that question."; + t.canRetry = false; + } else if (errorType === 'server_error' || (status && status >= 500)) { + t.errorType = 'server_error'; + t.errorMessage = message || 'Something went wrong on our end. Please try again.'; + t.canRetry = true; + } else { + t.errorType = 'unknown'; + t.errorMessage = message || 'Something went wrong. Please try again.'; + t.canRetry = true; + } + + updateBubble(t.id); + syncControls(); + } + }; + + const retryQuestion = (turnId) => { + const turn = findTurn(turnId); + if (!turn) return; + const nextRetry = (turn.retryCount ?? 0) + 1; + const errType = turn.errorType; + const errDetail = turn.errorDetail; + const question = turn.question; + const idx = state.conversation.findIndex(t => t.id === turnId); + state.conversation.splice(idx, 1); + if (state.currentTurnIndex >= state.conversation.length) { + state.currentTurnIndex = Math.max(0, state.conversation.length - 1); + } + askQuestion(question, { retryCount: nextRetry, isRetry: true, retryErrorType: errType, retryErrorDetail: errDetail }); + }; + + const getFreshAnswer = (turnId) => { + if (isLoading()) return; + const turn = findTurn(turnId); + if (!turn) return; + const question = turn.question; + const idx = state.conversation.findIndex(t => t.id === turnId); + state.conversation.splice(idx, 1); + if (state.currentTurnIndex >= state.conversation.length) { + state.currentTurnIndex = Math.max(0, state.conversation.length - 1); + } + // retryCount=1 + isRetry=true causes backend to skip the semantic cache. + askQuestion(question, { retryCount: 1, isRetry: true }); + }; + + // ─── SQL panel ────────────────────────────────────────────────────────── + const toggleSqlPanel = (turnId) => { + const turn = findTurn(turnId); + if (!turn) return; + turn.sqlPanelOpen = !turn.sqlPanelOpen; + updateBubble(turnId); + }; + + // ─── SQL explanation (typewriter) ─────────────────────────────────────── + const streamExplanation = (turnId, text) => { + const turn = findTurn(turnId); + if (!turn) return; + turn.sqlExplanationStreaming = true; + turn.sql_explanation_text = ''; + let i = 0; + const interval = setInterval(() => { + const t = findTurn(turnId); + if (!t || !t.sql_explanation_visible) { + clearInterval(interval); + if (t) t.sqlExplanationStreaming = false; + return; + } + if (i < text.length) { + t.sql_explanation_text += text[i++]; + updateBubble(turnId); + } else { + t.sqlExplanationStreaming = false; + clearInterval(interval); + updateBubble(turnId); + } + }, 10); + }; + + const generateSqlExplanation = async (turnId) => { + const turn = findTurn(turnId); + if (!turn?.embed?.SQL) return; + + // Toggle visibility (matches reference) + turn.sql_explanation_visible = !turn.sql_explanation_visible; + updateBubble(turnId); + + if (turn.sql_explanation_visible && !turn.embed.sql_explanation) { try { - iframe.contentWindow.postMessage( - { type: 'AUTH_TOKEN', token: token }, - targetOrigin - ); - } catch (error) { - console.error('Failed to send authentication token to AI Reporting iframe:', error); + const res = await api.explainSql(turn.embed.SQL); + const t = findTurn(turnId); + if (!t || !t.sql_explanation_visible) return; + t.embed.sql_explanation = res?.explanation || ''; + if (t.embed.tokens && res?.tokens) { + t.embed.tokens.prompt_tokens += res.tokens.prompt_tokens || 0; + t.embed.tokens.completion_tokens += res.tokens.completion_tokens || 0; + t.embed.tokens.total_tokens += res.tokens.total_tokens || 0; + } + streamExplanation(turnId, t.embed.sql_explanation); + } catch (err) { + console.error('Failed to generate SQL explanation:', err); + const status = err?.status; + let msg = 'Failed to generate SQL explanation. '; + if (status === 429) msg += 'Rate limit exceeded. Please try again later.'; + else if (status >= 500) msg += 'Server error. Please try again.'; + else msg += 'Please try again or contact support if the issue persists.'; + notify.error(msg); + const t = findTurn(turnId); + if (t) { + t.embed.sql_explanation = 'Unable to generate explanation at this time.'; + t.sql_explanation_text = t.embed.sql_explanation; + updateBubble(turnId); + } } - window.removeEventListener('message', messageHandler); + } else if (turn.sql_explanation_visible && turn.embed.sql_explanation && !turn.sql_explanation_text) { + // Already have the text from a saved chat — render it directly without streaming. + turn.sql_explanation_text = turn.embed.sql_explanation; + updateBubble(turnId); } + + await saveChat(); }; - window.addEventListener('message', messageHandler); + // ─── Metabase redirect ────────────────────────────────────────────────── + const isValidCardId = (id) => Number.isInteger(Number(id)) && Number(id) > 0 && Number(id) <= 999999999; - iframe.onerror = () => { - console.error('Failed to load AI Reporting iframe'); - window.removeEventListener('message', messageHandler); + const isValidRedirectUrl = (full, base) => { + try { + const f = new URL(full); + const b = new URL(base); + if (f.origin !== b.origin) return false; + return /^\/question\/\d+$/.test(f.pathname); + } catch { return false; } }; - iframe.src = window.reportingAiUrl; - document.getElementById('container').appendChild(iframe); -})(); + const redirectToMetabase = async (cardId) => { + if (!isValidCardId(cardId)) { + notify.error('Unable to open Metabase — invalid card ID'); + return; + } + try { + const res = await api.getMetabaseUrl(); + const baseUrl = res?.metabase_url; + if (!baseUrl) { + notify.error('Unable to open Metabase — invalid configuration'); + return; + } + const full = `${baseUrl.replace(/\/+$/, '')}/question/${cardId}`; + if (!isValidRedirectUrl(full, baseUrl)) { + notify.error('Unable to open Metabase — security validation failed'); + return; + } + window.open(full, '_blank', 'noopener,noreferrer'); + } catch (err) { + console.error('Error redirecting to Metabase:', err); + notify.error('Unable to open Metabase'); + } + }; + + // ─── Delete question / chat ───────────────────────────────────────────── + const deleteQuestion = async (turnId) => { + const turn = findTurn(turnId); + if (!turn) return; + const ok = await abp.message.confirm('Are you sure you want to delete this question? This action cannot be undone.', 'Delete Question'); + if (!ok) return; + + try { + if (turn.embed?.card_id) { + await api.deleteCard(turn.embed.card_id); + } + const idx = state.conversation.findIndex(t => t.id === turnId); + if (idx >= 0) state.conversation.splice(idx, 1); + + // Last turn → delete entire chat. + if (state.conversation.length === 0 && state.currentChatId) { + try { + await api.deleteChat(state.currentChatId); + state.currentChatId = null; + await loadChats(); + notify.success('Report deleted successfully'); + } catch (e) { + console.error('Error deleting empty chat:', e); + notify.error('Failed to delete report. Please try again.'); + } + renderConversation(); + return; + } + + // Adjust currentTurnIndex + if (idx <= state.currentTurnIndex && state.currentTurnIndex > 0) { + state.currentTurnIndex = Math.max(0, state.currentTurnIndex - 1); + } else if (state.currentTurnIndex >= state.conversation.length) { + state.currentTurnIndex = Math.max(0, state.conversation.length - 1); + } + + await saveChat(); + renderConversation(); + requestAnimationFrame(() => scrollToTurn(state.currentTurnIndex)); + notify.success('Question deleted successfully'); + } catch (err) { + console.error('Error deleting question:', err); + notify.error('Failed to delete question. Please try again.'); + } + }; + + const deleteChatPrompt = async (chatId) => { + const chat = state.chats.find(c => c.id === chatId); + const ok = await abp.message.confirm('Are you sure you want to delete this chat? This action cannot be undone.', 'Delete Chat'); + if (!ok) return; + try { + await api.deleteChat(chatId); + state.chats = state.chats.filter(c => c.id !== chatId); + if (state.currentChatId === chatId) newChat(); + else renderChats(); + notify.success(`Report${chat?.title ? ` "${chat.title}"` : ''} deleted successfully`); + } catch (err) { + console.error('Failed to delete chat:', err); + notify.error('Failed to delete report. Please try again.'); + } + }; + + // ─── Helpers for current turn ─────────────────────────────────────────── + const currentTurn = () => state.conversation[state.currentTurnIndex] || null; + // ─── Event delegation ─────────────────────────────────────────────────── + document.body.addEventListener('click', async (event) => { + const actionEl = event.target.closest('[data-action]'); + if (!actionEl) return; + const action = actionEl.getAttribute('data-action'); + switch (action) { + // sidebar + case 'new-chat': newChat(); break; + case 'select-chat': loadChat(actionEl.getAttribute('data-chat-id')); break; + case 'delete-chat': + event.stopPropagation(); + deleteChatPrompt(actionEl.getAttribute('data-chat-id')); + break; + + // ask row + case 'ask': { + const input = actionEl.closest('.ask-row-container')?.querySelector('input[type="text"]'); + askQuestion(input?.value || ''); + break; + } + + // toolbar (operates on current turn) + case 'metabase': { + const t = currentTurn(); + if (t?.embed?.card_id) redirectToMetabase(t.embed.card_id); + break; + } + case 'toggle-sql': { + const t = currentTurn(); + if (t) toggleSqlPanel(t.id); + break; + } + case 'explain-sql': { + const t = currentTurn(); + if (t) generateSqlExplanation(t.id); + break; + } + case 'delete-question': { + const t = currentTurn(); + if (t) deleteQuestion(t.id); + break; + } + case 'prev-turn': + if (state.currentTurnIndex > 0) { + state.currentTurnIndex--; + syncControls(); + scrollToTurn(state.currentTurnIndex); + } + break; + case 'next-turn': + if (state.currentTurnIndex < state.conversation.length - 1) { + state.currentTurnIndex++; + syncControls(); + scrollToTurn(state.currentTurnIndex); + } + break; + + // in-bubble actions + case 'metabase-turn': { + const t = findTurn(actionEl.getAttribute('data-turn-id')); + if (t?.embed?.card_id) redirectToMetabase(t.embed.card_id); + break; + } + case 'fresh-answer': getFreshAnswer(actionEl.getAttribute('data-turn-id')); break; + case 'retry-question': retryQuestion(actionEl.getAttribute('data-turn-id')); break; + } + }); + + // Enter key submits + [questionEmpty, questionActive].forEach((input) => { + input?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); askQuestion(input.value); } + }); + }); + + // Resize listener — keep current turn in view + window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + if (state.conversation.length > 0) scrollToTurn(state.currentTurnIndex); + }, 150); + }); + + // ─── Init ─────────────────────────────────────────────────────────────── + renderConversation(); + loadChats(); +})(); From 0b7a7c64f2551844bbe9a8780d2994e6c2ed0417 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 1 May 2026 14:37:17 -0700 Subject: [PATCH 02/11] AB#32452 refine prompt tools layout and access --- .../AIPromptToolViewOptionsProvider.cs | 29 +++-- .../IAIPromptToolViewOptionsProvider.cs | 6 +- .../Pages/GrantApplications/Details.cshtml | 117 ++++++++---------- .../Pages/GrantApplications/Details.cshtml.cs | 10 +- .../Pages/GrantApplications/Details.css | 84 +++++++++++++ .../Pages/GrantApplications/Details.js | 100 +++++---------- 6 files changed, 204 insertions(+), 142 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/AIPromptToolViewOptionsProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/AIPromptToolViewOptionsProvider.cs index eb99661503..51d7221147 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/AIPromptToolViewOptionsProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/AIPromptToolViewOptionsProvider.cs @@ -1,16 +1,31 @@ -using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Configuration; -using System; +using System.Threading.Tasks; +using Unity.Modules.Shared.Permissions; using Volo.Abp.DependencyInjection; +using Volo.Abp.Security.Claims; namespace Unity.AI.Web.PromptTools; -public class AIPromptToolViewOptionsProvider( - IWebHostEnvironment webHostEnvironment, - IConfiguration configuration) : IAIPromptToolViewOptionsProvider, ITransientDependency +public class AIPromptToolAccessProvider( + IAuthorizationService authorizationService, + ICurrentPrincipalAccessor currentPrincipalAccessor, + IConfiguration configuration) : IAIPromptToolAccessProvider, ITransientDependency { - public bool IsDevPromptControlsEnabled => - string.Equals(webHostEnvironment.EnvironmentName, "Development", StringComparison.OrdinalIgnoreCase); + public async Task CanViewPromptToolsAsync() + { + var principal = currentPrincipalAccessor.Principal; + if (principal?.Identity?.IsAuthenticated != true) + { + return false; + } + + var authorizationResult = await authorizationService.AuthorizeAsync( + principal, + IdentityConsts.ITOperationsPolicyName); + + return authorizationResult.Succeeded; + } public string DefaultPromptVersion { diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/IAIPromptToolViewOptionsProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/IAIPromptToolViewOptionsProvider.cs index c9d75a4883..80d0560ba7 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/IAIPromptToolViewOptionsProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/IAIPromptToolViewOptionsProvider.cs @@ -1,7 +1,9 @@ +using System.Threading.Tasks; + namespace Unity.AI.Web.PromptTools; -public interface IAIPromptToolViewOptionsProvider +public interface IAIPromptToolAccessProvider { - bool IsDevPromptControlsEnabled { get; } + Task CanViewPromptToolsAsync(); string DefaultPromptVersion { get; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 294997e161..0d5bf7268b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -298,12 +298,12 @@ } - @if (Model.IsDevPromptControlsEnabled) - { - - } + @if (Model.CanViewPromptTools) + { + + }
@@ -524,42 +524,33 @@
} @*-------- AI Analysis Tab Section END ---------*@ - @if (Model.IsDevPromptControlsEnabled) - { -
-
-
AI Dev Tools
-
-
- + @if (Model.DefaultPromptVersion == "v0") + { } - else - { - - - } - - @if (aiAttachmentSummariesEnabled && aiApplicationAnalysisEnabled && aiScoringEnabled) - { - - } -
-
-
+ else + { + + + } + +
+ +
-
-
-
Attachment Summary
+
+
+
Attachment Summary
-
-
-
- -
-
- -
-
-
Application Analysis
+ +
+
+ +
+
+
Application Analysis
-
-
-
- -
-
- -
-
-
Application Scoring
+ +
+
+ +
+
+
Application Scoring
-
-
-
- -
-
+ +
+
} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs index 499f338e15..829d28a058 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs @@ -33,6 +33,7 @@ public class DetailsModel : AbpPageModel private readonly IApplicationFormVersionAppService _applicationFormVersionAppService; private readonly IScoresheetRepository _scoresheetRepository; private readonly IFeatureChecker _featureChecker; + private readonly IAIPromptToolAccessProvider _aiPromptToolAccessProvider; protected readonly IZoneManagementAppService _zoneManagementAppService; [BindProperty(SupportsGet = true)] @@ -95,7 +96,7 @@ public class DetailsModel : AbpPageModel public HashSet ZoneStateSet { get; set; } = []; [BindProperty(SupportsGet = true)] - public bool IsDevPromptControlsEnabled { get; set; } + public bool CanViewPromptTools { get; set; } [BindProperty(SupportsGet = true)] public string DefaultPromptVersion { get; set; } @@ -111,7 +112,7 @@ public DetailsModel( IFeatureChecker featureChecker, ICurrentUser currentUser, IConfiguration configuration, - IAIPromptToolViewOptionsProvider aiPromptToolViewOptionsProvider, + IAIPromptToolAccessProvider aiPromptToolAccessProvider, IZoneManagementAppService zoneManagementAppService) { _grantApplicationAppService = grantApplicationAppService; @@ -120,6 +121,7 @@ public DetailsModel( _applicationFormVersionAppService = applicationFormVersionAppService; _scoresheetRepository = scoresheetRepository; _zoneManagementAppService = zoneManagementAppService; + _aiPromptToolAccessProvider = aiPromptToolAccessProvider; CurrentUserId = currentUser.Id; CurrentUserName = currentUser.SurName + ", " + currentUser.Name; @@ -127,12 +129,12 @@ public DetailsModel( MaxFileSize = configuration["S3:MaxFileSize"] ?? ""; EmailAttachmentMaxFileSize = configuration["S3:EmailAttachmentMaxFileSize"] ?? "20"; TotalEmailAttachmentMaxFileSize = configuration["S3:EmailAttachmentsTotalMaxFileSize"] ?? "25"; - IsDevPromptControlsEnabled = aiPromptToolViewOptionsProvider.IsDevPromptControlsEnabled; - DefaultPromptVersion = aiPromptToolViewOptionsProvider.DefaultPromptVersion; + DefaultPromptVersion = aiPromptToolAccessProvider.DefaultPromptVersion; } public async Task OnGetAsync() { + CanViewPromptTools = await _aiPromptToolAccessProvider.CanViewPromptToolsAsync(); ApplicationFormSubmission applicationFormSubmission = await _grantApplicationAppService.GetFormSubmissionByApplicationId(ApplicationId); ZoneStateSet = await _zoneManagementAppService.GetZoneStateSetAsync(applicationFormSubmission.ApplicationFormId); 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 bac97ee3cb..ff2e4603af 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 @@ -813,3 +813,87 @@ form label.error { background-color: #f9fafb; border-color: #9ca3af; } +.prompt-tools-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1rem; + flex-wrap: nowrap; +} + +.prompt-tools-header .prompt-tools-toolbar-row { + display: flex; + align-items: center; + gap: 0.5rem 0.75rem; +} + +.prompt-tools-header select, +.prompt-tools-header .form-select { + width: auto; + white-space: nowrap; +} + +.prompt-tools-header .prompt-tools-toolbar-row { + margin-left: auto; +} + +.prompt-tools-section { + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid #e7ebef; +} + +.prompt-tools-section:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: 0; +} + +.prompt-tools-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1rem; + margin-bottom: 0.5rem; +} + +.prompt-tools-section-header h6 { + min-width: 0; + flex: 1 1 auto; +} + +.prompt-tools-output-container { + position: relative; +} + +.prompt-tools-output-container .prompt-tools-output-actions { + position: absolute; + top: 0.375rem; + right: 0.5rem; + display: inline-flex; + align-items: center; + gap: 0.25rem; + z-index: 1; + padding: 0 0.25rem; + background: #ffffff; + border-radius: 999px; +} + +.prompt-tools-output-container .prompt-tools-output { + min-height: 10rem; + max-height: 24rem; + overflow: auto; + resize: vertical; + white-space: pre; + font-family: Consolas, "Courier New", monospace; + line-height: 1.35; + padding-top: 0.75rem; + padding-right: 2.5rem; +} + +.prompt-tools-output-container .prompt-tools-output-copy-btn { + width: 2rem; + height: 2rem; + padding: 0; + color: #5c6b7a; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index 1e7cabeb86..fa611d5c07 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -164,14 +164,14 @@ $(function () { ]); globalThis.getSelectedPromptVersion = function() { - return $('#devPromptVersion').val() || null; + return $('#promptVersion').val() || null; }; - function setDevAiOutput(selector, value) { + function setPromptToolsOutput(selector, value) { $(selector).val(value || ''); } - function setDevAiOutputTimestamp(selector, value) { + function setPromptToolsTimestamp(selector, value) { $(selector).text(value ? `(${value})` : ''); } @@ -267,7 +267,7 @@ $(function () { function formatAttachmentAiOutput(attachments) { const attachmentBody = formatAttachmentSummaryBody(attachments); if (!attachmentBody) { - setDevAiOutputTimestamp('#attachmentAiOutputTimestamp', ''); + setPromptToolsTimestamp('#attachmentOutputTimestamp', ''); return ''; } @@ -284,20 +284,20 @@ $(function () { .sort() .at(-1); - setDevAiOutputTimestamp('#attachmentAiOutputTimestamp', formatTimestamp(latestTimestamp)); + setPromptToolsTimestamp('#attachmentOutputTimestamp', formatTimestamp(latestTimestamp)); return attachmentBody; } - function loadDevAiOutputs() { + function loadPromptToolsOutputs() { const applicationId = $('#DetailsViewApplicationId').val(); if (!applicationId) { - setDevAiOutput('#analysisAiOutput', ''); - setDevAiOutput('#scoringAiOutput', ''); - setDevAiOutput('#attachmentAiOutput', ''); - setDevAiOutputTimestamp('#analysisAiOutputTimestamp', ''); - setDevAiOutputTimestamp('#scoringAiOutputTimestamp', ''); - setDevAiOutputTimestamp('#attachmentAiOutputTimestamp', ''); + setPromptToolsOutput('#analysisOutput', ''); + setPromptToolsOutput('#scoringOutput', ''); + setPromptToolsOutput('#attachmentOutput', ''); + setPromptToolsTimestamp('#analysisOutputTimestamp', ''); + setPromptToolsTimestamp('#scoringOutputTimestamp', ''); + setPromptToolsTimestamp('#attachmentOutputTimestamp', ''); return; } @@ -311,10 +311,10 @@ $(function () { const updatedAt = application?.lastModificationTime || application?.creationTime || null; const formattedUpdatedAt = formatTimestamp(updatedAt); const attachmentSection = formatSectionBody('ATTACHMENTS', formatAttachmentSummaryJson(attachments)); - setDevAiOutputTimestamp('#analysisAiOutputTimestamp', formattedUpdatedAt); - setDevAiOutputTimestamp('#scoringAiOutputTimestamp', formattedUpdatedAt); - setDevAiOutput( - '#analysisAiOutput', + setPromptToolsTimestamp('#analysisOutputTimestamp', formattedUpdatedAt); + setPromptToolsTimestamp('#scoringOutputTimestamp', formattedUpdatedAt); + setPromptToolsOutput( + '#analysisOutput', formatOutputBody('APPLICATION ANALYSIS', [ formatSectionBody('DATA', getPromptDataPayload()), attachmentSection, @@ -324,8 +324,8 @@ $(function () { ) ]) ); - setDevAiOutput( - '#scoringAiOutput', + setPromptToolsOutput( + '#scoringOutput', formatOutputBody('APPLICATION SCORING', [ formatSectionBody('SCORESHEET', formatJsonOrRaw(getScoresheetSchemaJson())), formatSectionBody('DATA', getPromptDataPayload()), @@ -336,18 +336,18 @@ $(function () { ) ]) ); - setDevAiOutput( - '#attachmentAiOutput', + setPromptToolsOutput( + '#attachmentOutput', formatOutputBody('ATTACHMENT SUMMARY', [formatAttachmentAiOutput(attachments)]) ); }) .fail(function() { - setDevAiOutput('#analysisAiOutput', ''); - setDevAiOutput('#scoringAiOutput', ''); - setDevAiOutput('#attachmentAiOutput', ''); - setDevAiOutputTimestamp('#analysisAiOutputTimestamp', ''); - setDevAiOutputTimestamp('#scoringAiOutputTimestamp', ''); - setDevAiOutputTimestamp('#attachmentAiOutputTimestamp', ''); + setPromptToolsOutput('#analysisOutput', ''); + setPromptToolsOutput('#scoringOutput', ''); + setPromptToolsOutput('#attachmentOutput', ''); + setPromptToolsTimestamp('#analysisOutputTimestamp', ''); + setPromptToolsTimestamp('#scoringOutputTimestamp', ''); + setPromptToolsTimestamp('#attachmentOutputTimestamp', ''); }); } @@ -371,16 +371,16 @@ $(function () { stopAIGenerationPolling(); globalThis.AIGenerationButtonState?.restore(restoreButton); restoreButton.html(originalHtml).prop('disabled', false); - loadDevAiOutputs(); + loadPromptToolsOutputs(); abp.message.error(request?.failureReason || 'AI generate all failed.'); return; } if (!request || request.isActive === false || statusText === 'Completed') { stopAIGenerationPolling(); - setDevAiOutputTimestamp('#analysisAiOutputTimestamp', request?.completedAt || request?.startedAt || null); - setDevAiOutputTimestamp('#scoringAiOutputTimestamp', request?.completedAt || request?.startedAt || null); - loadDevAiOutputs(); + setPromptToolsTimestamp('#analysisOutputTimestamp', request?.completedAt || request?.startedAt || null); + setPromptToolsTimestamp('#scoringOutputTimestamp', request?.completedAt || request?.startedAt || null); + loadPromptToolsOutputs(); globalThis.AIGenerationButtonState?.setCompleted(restoreButton); restoreButton.html('Completed').prop('disabled', true); return; @@ -463,41 +463,9 @@ $(function () { ); }; - globalThis.refreshDevAiOutputs = loadDevAiOutputs; + globalThis.refreshPromptToolsOutputs = loadPromptToolsOutputs; - globalThis.generateAllAIDevOutputs = function(triggerButton = null) { - const $button = triggerButton ? $(triggerButton) : $('#generateAllAiDevToolsBtn'); - const existingHtml = $button.html(); - const applicationId = $('#DetailsViewApplicationId').val(); - const promptVersion = globalThis.getSelectedPromptVersion?.() || null; - - if (!applicationId || $button.prop('disabled')) { - return; - } - - $button - .html('Generating...') - .prop('disabled', true); - globalThis.AIGenerationButtonState?.setGenerating($button); - - unity.grantManager.grantApplications.grantApplication - .queueAllAIStages(applicationId, promptVersion) - .done(function(request) { - pollAIGenerationStatus(applicationId, 'pipeline', promptVersion, $button, existingHtml); - }) - .fail(function() { - abp.message.error('Failed to queue AI generate all. Please try again.'); - globalThis.AIGenerationButtonState?.restore($button); - $button.html(existingHtml).prop('disabled', false); - }) - ; - }; - - $('#generateAllAiDevToolsBtn').on('click', function() { - globalThis.generateAllAIDevOutputs(this); - }); - - $(document).on('click', '.ai-dev-output-copy-btn', async function () { + $(document).on('click', '.prompt-tools-output-copy-btn', async function () { const targetSelector = $(this).data('target'); const text = $(targetSelector).val(); @@ -543,7 +511,7 @@ $(function () { updateLinksCounters(); renderSubmission(); loadAIAnalysis(); - loadDevAiOutputs(); + loadPromptToolsOutputs(); applyTabHeightOffset(); } @@ -831,7 +799,7 @@ $(function () { PubSub.subscribe('refresh_assessment_scores', (msg, data) => { assessmentScoresWidgetManager.refresh(); updateSubtotal(); - loadDevAiOutputs(); + loadPromptToolsOutputs(); }); PubSub.subscribe('refresh_chefs_attachment_list', () => { From 774b56e7662c094bf5c2b956aca25fa9f529cdf2 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 1 May 2026 14:52:12 -0700 Subject: [PATCH 03/11] AB#32543 clean up AI reporting state naming --- .../Pages/AIReporting/Index.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.js index 33942eedcc..2559b0aa44 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.js @@ -52,13 +52,13 @@ const notify = abp.notify; - // Fresh turn (matches Turn interface from reference) + // Fresh turn const createTurn = (question, retryCount = 0) => ({ id: newTurnId(), question, embed: null, safeUrl: 'loading', // 'loading' | 'failure' | null (success) - iframeLoaded: false, + loaded: false, sqlPanelOpen: false, sql_explanation_visible: false, sql_explanation_text: '', // local rendered text (typewriter) @@ -308,7 +308,7 @@ question: t.question, embed: t.embed, safeUrl: t.safeUrl, - iframeLoaded: t.iframeLoaded, + loaded: t.loaded, sqlPanelOpen: t.sqlPanelOpen, sql_explanation_visible: t.sql_explanation_visible, errorType: t.errorType, @@ -337,7 +337,7 @@ question: r.question || '', embed: r.embed || null, safeUrl: r.safeUrl ?? null, - iframeLoaded: true, + loaded: true, sqlPanelOpen: r.sqlPanelOpen ?? false, sql_explanation_visible: r.sql_explanation_visible ?? false, sql_explanation_text: r.embed?.sql_explanation || '', @@ -413,7 +413,7 @@ cache_original_query: result?.cache_original_query || null, }; t.safeUrl = null; - t.iframeLoaded = true; + t.loaded = true; updateBubble(t.id); syncControls(); await saveChat(); @@ -421,7 +421,7 @@ console.error('Failed to process question:', err); const t = findTurn(turn.id); if (!t) return; - t.iframeLoaded = true; + t.loaded = true; t.safeUrl = 'failure'; t.errorDetail = err?.errorDetail ?? null; From 65d53b0102fbb329cc23f1a59fb68900a99c2554 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 1 May 2026 16:24:16 -0700 Subject: [PATCH 04/11] AB#32543 simplify AI reporting page setup --- .../Pages/AIReporting/Index.cshtml | 13 +- .../Pages/AIReporting/Index.css | 719 +++++++++++++++++- .../Pages/AIReporting/Index.js | 11 +- 3 files changed, 735 insertions(+), 8 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml index 541a903c95..105b27e25a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml @@ -4,24 +4,29 @@ @using Volo.Abp.Features @inject IFeatureChecker FeatureChecker +@{ + var canViewAiReporting = await FeatureChecker.IsEnabledAsync("Unity.AIReporting") + || User.IsInRole(IdentityConsts.ITAdminRoleName); +} + @section styles { - @if (await FeatureChecker.IsEnabledAsync("Unity.AIReporting") || User.IsInRole(IdentityConsts.ITAdminRoleName)) + @if (canViewAiReporting) { } } @section scripts { - @if (await FeatureChecker.IsEnabledAsync("Unity.AIReporting") || User.IsInRole(IdentityConsts.ITAdminRoleName)) + @if (canViewAiReporting) { } } -@if (await FeatureChecker.IsEnabledAsync("Unity.AIReporting") || User.IsInRole(IdentityConsts.ITAdminRoleName)) +@if (canViewAiReporting) {
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.css index b99ce14729..c74a9eab14 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.css @@ -1 +1,718 @@ -/* =========================================================================== AI Reporting page — port of .working/unity-ai/applications/Unity.AI.Reporting.Frontend Sources: app.css, sidebar.css, sql-explanation.ts inline styles. =========================================================================== *//* Local CSS variables (reference uses Metabase global vars; we inline) */:root { --mb-radius: 8px; --mb-blue-600: #2563eb; --mb-blue-700: #1d4ed8; --mb-teal-600: #0d9488; --mb-gray-200: #e2e8f0; --mb-gray-700: #334155; --mb-red-700: #b91c1c; --mb-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08); --mb-font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;}/* ---------- layout shell ---------- */.outer-container { display: grid; grid-template-columns: auto 1fr; /* Use dvh so the layout follows the actual visible viewport (handles browser chrome, address bar, and zoom changes more reliably than 100vh). */ height: calc(100dvh - 6.75rem); min-height: 480px; background: #fff;}.container { display: flex; flex-direction: column; overflow-y: auto; scrollbar-width: none; -ms-overflow-style: none;}.container::-webkit-scrollbar { display: none; }/* ---------- sidebar ---------- */.sidebar { border-right: 1px solid #e9ecef; display: flex; flex-direction: column; height: 100%; width: 220px; background: #fff; box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);}.sidebar-header { padding: 16px 12px 6px 12px; display: flex; flex-direction: column; gap: 8px; flex-shrink: 0;}.sidebar-header h2 { margin: 14px 0 0 14px; font-size: 0.8rem; font-weight: 500; color: #6c757d; text-align: left;}.new-chat-btn { background: transparent; color: #333; border: none; padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 0.9rem; font-weight: 500; display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;}.new-chat-btn:hover,.chat-item:hover,.chat-item.active { background-color: #e9ecef;}.new-chat-icon { font-size: 16px;}.sidebar-content { flex: 1; overflow-y: auto; padding: 0;}.loading,.empty-state { padding: 20px; text-align: center; color: #6c757d; font-style: italic; font-size: 0.9rem;}.chat-list { padding: 0;}.chat-item { margin: 6px 12px; padding: 8px 14px; cursor: pointer; position: relative; display: flex; flex-direction: column; border-radius: 8px;}.chat-title { font-weight: 400; color: #333; font-size: 0.9rem; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: 30px;}.delete-chat-btn { position: absolute; top: 50%; right: 12px; transform: translateY(-50%); background: none; border: none; cursor: pointer; border-radius: 4px; opacity: 0; font-size: 16px; color: #6c757d; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; line-height: 1; padding: 0;}.chat-item:hover .delete-chat-btn { opacity: 1; }.delete-chat-btn:hover { background-color: #d9d9d9; color: #333;}.sidebar-footer { border-top: 1px solid #e9ecef; padding: 12px; display: flex; justify-content: flex-end; flex-shrink: 0; background: #fff;}.footer-btn { background: transparent; border: 1px solid transparent; border-radius: 8px; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; cursor: pointer; color: #6c757d; transition: all 0.2s ease;}.footer-btn:hover { background-color: #f8f9fa; border-color: #dee2e6; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);}.info-btn:hover { background-color: #d1ecf1; border-color: #17a2b8; color: #0c5460;}/* ---------- empty welcome ---------- */.empty-chat-container { display: flex; align-items: center; justify-content: center; height: 100%; flex: 1;}.welcome-content { text-align: center; width: 100%; max-width: 800px;}.welcome-title { font-size: 2rem; color: #333; margin: 0 0 40px 0; line-height: 1.2; font-weight: 500;}/* ---------- chat layout ---------- */.chat-container { display: grid; grid-template-rows: 1fr auto; height: 100%; min-height: 0; flex: 1;}.turns { height: 100%; overflow: hidden; /* Scale padding gracefully across viewport widths — 15vw on big screens, but capped so the chat doesn't get squeezed on smaller laptops/zoom levels. */ padding: 0 clamp(16px, 15vw, 240px); position: relative; min-height: 0;}.turn { display: flex; flex-direction: column; height: 100%; box-sizing: border-box; padding: 24px 0;}/* ---------- bubbles ---------- */.bubble { border-radius: 18px; padding: 12px 16px; font-size: 16px; box-shadow: var(--mb-shadow-sm); border: 1px solid var(--mb-gray-200); background: #fff;}.bubble.bot { width: 100%; position: relative; height: 100%; overflow-y: auto; box-sizing: border-box; scrollbar-width: none; -ms-overflow-style: none; padding: 8px; background: radial-gradient(ellipse at center, rgba(66, 153, 225, 0.08) 0%, rgba(255, 255, 255, 0.95) 70%);}.bubble.bot::-webkit-scrollbar { display: none; }.bubble.bot > div:not(.spinner-center) { height: 100%; align-self: flex-start; display: flex; flex-direction: column;}.bot-inner { opacity: 0; transition: opacity 0.6s; padding: 0; height: 100%; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; align-items: center;}.bot-inner.loaded { opacity: 1; }/* ---------- ask row ---------- */.ask-row-container { margin: 0 clamp(12px, 10vw, 160px) 16px; position: relative; padding: 20px 20px 12px 20px; background: #fff; box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); border-radius: 40px;}.welcome-ask-row { margin: 0; padding: 10px 10px 10px 24px;}.ask-row { display: flex; gap: 12px;}.ask-row-bottom { display: flex; justify-content: space-between; align-items: center; margin-top: 16px;}.bottom-left-controls { display: flex; align-items: center; gap: 12px;}.ask-row input[type="text"] { flex: 1 1 0; border: none; border-radius: var(--mb-radius); font-size: 16px; padding: 8px 4px; background: transparent;}.ask-row input[type="text"]:focus { outline: none; }.ask-question-btn { background: var(--mb-blue-600); color: #fff; border: none; border-radius: 50%; cursor: pointer; transition: background-color 0.12s, box-shadow 0.12s; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;}.ask-question-btn:hover { background: var(--mb-blue-700); box-shadow: var(--mb-shadow-sm);}.ask-question-btn svg { color: #fff; }/* ---------- action buttons ---------- */.action-buttons { display: flex; align-items: center; gap: 8px;}.action-button { display: flex; align-items: center; justify-content: center; padding: 8px; background: transparent; border: 1px solid transparent; border-radius: 20px; font-size: 14px; font-weight: 500; color: #495057; cursor: pointer; transition: all 0.2s ease; white-space: nowrap; width: 32px; height: 32px; box-sizing: border-box;}.action-button:hover:not(:disabled) { background: #f8f9fa; border-color: #dee2e6;}.action-button:disabled { cursor: not-allowed; opacity: 0.4;}.action-button.delete-action:hover:not(:disabled) { background: #f8d7da; border-color: #f5c6cb; color: #721c24;}.action-button svg { width: 16px; height: 16px; }.nav-controls { display: flex; align-items: center; gap: 8px;}.action-button.nav-button:disabled { background: transparent; border-color: transparent; color: #adb5bd; opacity: 0.6;}.turn-counter { font-size: 0.875rem; color: #6c757d; font-weight: 500; min-width: 60px; text-align: center; white-space: nowrap;}/* ---------- loading state ---------- */.sql-loader-container { flex: 1 1 auto; display: flex; flex-direction: column; box-sizing: border-box; overflow: hidden; position: relative; width: 100%; height: 100%; min-height: 400px;}.loading-animation-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; padding: 20px; background: rgba(255, 255, 255, 0.9); border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 10;}.loading-dots { display: flex; gap: 8px; align-items: center;}.loading-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--mb-blue-600); animation: pulse 1.5s ease-in-out infinite;}.loading-dot:nth-child(1) { animation-delay: 0s; }.loading-dot:nth-child(2) { animation-delay: 0.2s; }.loading-dot:nth-child(3) { animation-delay: 0.4s; }@keyframes pulse { 0%, 80%, 100% { transform: scale(0.8); opacity: 0.6; } 40% { transform: scale(1.2); opacity: 1; }}.loading-text { font-size: 16px; font-weight: 500; color: #666; text-align: center;}/* ---------- failure state ---------- */.failure-container { padding: 24px; height: 100%; box-sizing: border-box; position: relative;}.failure-content { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; padding: 20px; background: rgba(255, 255, 255, 0.9); border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 10; max-width: 480px;}.failure-icon { font-size: 32px; flex-shrink: 0; }.failure-title { font-size: 18px; font-weight: 600; color: #c53030; margin: 0;}.failure-message { font-size: 14px; color: #718096; line-height: 1.5; margin: 0; text-align: center;}.failure-actions { display: flex; flex-direction: column; align-items: center; gap: 8px; margin-top: 4px;}.retry-btn { padding: 8px 16px; font-size: 14px; font-weight: 500; border: none; border-radius: 6px; cursor: pointer; transition: all 0.2s ease; background: #3182ce; color: #fff;}.retry-btn:hover { background: #2c5aa0; transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);}.retry-btn--disabled,.retry-btn--disabled:hover { background: #64748b; color: #fff; cursor: not-allowed; transform: none; box-shadow: none;}.retry-hint { font-size: 0.9rem; color: #666; margin: 0;}/* ---------- semantic cache badge ---------- */.cache-badge { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; padding: 4px 10px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 4px; font-size: 0.8rem; color: #334155; margin-bottom: 8px; cursor: default; user-select: none; flex-shrink: 0;}.cache-badge-hint { color: #64748b; }.cache-fresh-btn { background: none; border: none; padding: 0; margin-left: 4px; font-size: inherit; color: #2563eb; text-decoration: underline; cursor: pointer;}.cache-fresh-btn:hover:not(:disabled) { color: #1d4ed8; }.cache-fresh-btn:disabled { color: #94a3b8; cursor: not-allowed; text-decoration: none;}/* ---------- Metabase view button ---------- */.metabase-view-container { display: flex; justify-content: center; align-items: center; flex: 1;}.metabase-view-btn { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 16px 32px; background: linear-gradient(135deg, #4299e1 0%, #3182ce 100%); color: #fff; border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 4px 12px rgba(66, 153, 225, 0.3);}.metabase-view-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(66, 153, 225, 0.4); background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%); color: #fff;}.metabase-view-btn:active { transform: translateY(0); box-shadow: 0 4px 12px rgba(66, 153, 225, 0.3);}.metabase-view-btn svg { flex-shrink: 0; transition: all 0.3s ease; }.metabase-view-btn:hover svg { transform: translateX(2px) translateY(-2px); }.metabase-view-btn span { position: relative; z-index: 1; letter-spacing: 0.3px; }/* ---------- SQL panel (overlay) ---------- */.sql-panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: #fff; z-index: 5; overflow-y: auto; scrollbar-width: none; -ms-overflow-style: none; display: flex; flex-direction: column; justify-content: flex-start; box-sizing: border-box; padding: 8px 16px;}.sql-panel::-webkit-scrollbar { display: none; }.sql-panel-header { padding: 8px 0 16px 0; font-weight: 600; font-size: 18px; color: var(--mb-gray-700); flex-shrink: 0;}.sql-code { padding: 12px 16px; margin: 0; font-family: 'Fira Mono', Consolas, 'Courier New', monospace; font-size: 14px; line-height: 1.35; color: #1e1e1e; background: #fff; white-space: pre-wrap; overflow-y: auto; border-radius: 8px; border: 1px solid var(--mb-gray-200); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); flex: 1;}/* ---------- SQL explanation bubble (typewriter) ---------- */.sql-explanation-bubble { position: relative; background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 12px; padding: 0; margin: 12px 8px 8px 8px; font-size: 0.85em; color: #075985; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); animation: slideIn 0.3s ease-out; flex-shrink: 0;}@keyframes slideIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); }}.bubble-content { padding: 10px 14px; line-height: 1.4;}.bubble-tail { position: absolute; top: -6px; left: 30px; width: 12px; height: 12px; background: #f0f9ff; border-left: 1px solid #bae6fd; border-top: 1px solid #bae6fd; transform: rotate(45deg);}.cursor { animation: blink 1s infinite; font-weight: normal; opacity: 0.8; color: #075985;}@keyframes blink { 0%, 50% { opacity: 0.8; } 51%, 100% { opacity: 0; }}/* ---------- responsive ---------- */@media (max-width: 768px) { .outer-container { grid-template-columns: 1fr; } .sidebar { width: 100%; } .turns { padding: 0 16px; } .ask-row-container { margin-left: 12px; margin-right: 12px; }} \ No newline at end of file +/* ========================================================================== + AI Reporting page — port of .working/unity-ai/applications/Unity.AI.Reporting.Frontend + Sources: app.css, sidebar.css, sql-explanation.ts inline styles. + ========================================================================== */ +/* Local CSS variables (reference uses Metabase global vars; we inline) */ +:root { + --mb-radius: 8px; + --mb-blue-600: #2563eb; + --mb-blue-700: #1d4ed8; + --mb-teal-600: #0d9488; + --mb-gray-200: #e2e8f0; + --mb-gray-700: #334155; + --mb-red-700: #b91c1c; + --mb-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08); + --mb-font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} +/* ---------- layout shell ---------- */ +.outer-container { + display: grid; + grid-template-columns: auto 1fr; + /* Use dvh so the layout follows the actual visible viewport (handles browser chrome, address bar, and zoom changes more reliably than 100vh). */ + height: calc(100dvh - 6.75rem); + min-height: 480px; + background: #fff; +} +.container { + display: flex; + flex-direction: column; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; +} +.container::-webkit-scrollbar { + display: none; +} +/* ---------- sidebar ---------- */ +.sidebar { + border-right: 1px solid #e9ecef; + display: flex; + flex-direction: column; + height: 100%; + width: 220px; + background: #fff; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); +} +.sidebar-header { + padding: 16px 12px 6px 12px; + display: flex; + flex-direction: column; + gap: 8px; + flex-shrink: 0; +} +.sidebar-header h2 { + margin: 14px 0 0 14px; + font-size: 0.8rem; + font-weight: 500; + color: #6c757d; + text-align: left; +} +.new-chat-btn { + background: transparent; + color: #333; + border: none; + padding: 8px 14px; + border-radius: 8px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; +} +.new-chat-btn:hover, +.chat-item:hover, +.chat-item.active { + background-color: #e9ecef; +} +.new-chat-icon { + font-size: 16px; +} +.sidebar-content { + flex: 1; + overflow-y: auto; + padding: 0; +} +.loading, +.empty-state { + padding: 20px; + text-align: center; + color: #6c757d; + font-style: italic; + font-size: 0.9rem; +} +.chat-list { + padding: 0; +} +.chat-item { + margin: 6px 12px; + padding: 8px 14px; + cursor: pointer; + position: relative; + display: flex; + flex-direction: column; + border-radius: 8px; +} +.chat-title { + font-weight: 400; + color: #333; + font-size: 0.9rem; + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-right: 30px; +} +.delete-chat-btn { + position: absolute; + top: 50%; + right: 12px; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + border-radius: 4px; + opacity: 0; + font-size: 16px; + color: #6c757d; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + padding: 0; +} +.chat-item:hover .delete-chat-btn { + opacity: 1; +} +.delete-chat-btn:hover { + background-color: #d9d9d9; + color: #333; +} +.sidebar-footer { + border-top: 1px solid #e9ecef; + padding: 12px; + display: flex; + justify-content: flex-end; + flex-shrink: 0; + background: #fff; +} +.footer-btn { + background: transparent; + border: 1px solid transparent; + border-radius: 8px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: #6c757d; + transition: all 0.2s ease; +} +.footer-btn:hover { + background-color: #f8f9fa; + border-color: #dee2e6; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} +.info-btn:hover { + background-color: #d1ecf1; + border-color: #17a2b8; + color: #0c5460; +} +/* ---------- empty welcome ---------- */ +.empty-chat-container { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + flex: 1; +} +.welcome-content { + text-align: center; + width: 100%; + max-width: 800px; +} +.welcome-title { + font-size: 2rem; + color: #333; + margin: 0 0 40px 0; + line-height: 1.2; + font-weight: 500; +} +/* ---------- chat layout ---------- */ +.chat-container { + display: grid; + grid-template-rows: 1fr auto; + height: 100%; + min-height: 0; + flex: 1; +} +.turns { + height: 100%; + overflow: hidden; + /* Scale padding gracefully across viewport widths — 15vw on big screens, but capped so the chat doesn't get squeezed on smaller laptops/zoom levels. */ + padding: 0 clamp(16px, 15vw, 240px); + position: relative; + min-height: 0; +} +.turn { + display: flex; + flex-direction: column; + height: 100%; + box-sizing: border-box; + padding: 24px 0; +} +/* ---------- bubbles ---------- */ +.bubble { + border-radius: 18px; + padding: 12px 16px; + font-size: 16px; + box-shadow: var(--mb-shadow-sm); + border: 1px solid var(--mb-gray-200); + background: #fff; +} +.bubble.bot { + width: 100%; + position: relative; + height: 100%; + overflow-y: auto; + box-sizing: border-box; + scrollbar-width: none; + -ms-overflow-style: none; + padding: 8px; + background: radial-gradient(ellipse at center, rgba(66, 153, 225, 0.08) 0%, rgba(255, 255, 255, 0.95) 70%); +} +.bubble.bot::-webkit-scrollbar { + display: none; +} +.bubble.bot > div:not(.spinner-center) { + height: 100%; + align-self: flex-start; + display: flex; + flex-direction: column; +} +.bot-inner { + opacity: 0; + transition: opacity 0.6s; + padding: 0; + height: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} +.bot-inner.loaded { + opacity: 1; +} +/* ---------- ask row ---------- */ +.ask-row-container { + margin: 0 clamp(12px, 10vw, 160px) 16px; + position: relative; + padding: 20px 20px 12px 20px; + background: #fff; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + border-radius: 40px; +} +.welcome-ask-row { + margin: 0; + padding: 10px 10px 10px 24px; +} +.ask-row { + display: flex; + gap: 12px; +} +.ask-row-bottom { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 16px; +} +.bottom-left-controls { + display: flex; + align-items: center; + gap: 12px; +} +.ask-row input[type="text"] { + flex: 1 1 0; + border: none; + border-radius: var(--mb-radius); + font-size: 16px; + padding: 8px 4px; + background: transparent; +} +.ask-row input[type="text"]:focus { + outline: none; +} +.ask-question-btn { + background: var(--mb-blue-600); + color: #fff; + border: none; + border-radius: 50%; + cursor: pointer; + transition: background-color 0.12s, box-shadow 0.12s; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.ask-question-btn:hover { + background: var(--mb-blue-700); + box-shadow: var(--mb-shadow-sm); +} +.ask-question-btn svg { + color: #fff; +} +/* ---------- action buttons ---------- */ +.action-buttons { + display: flex; + align-items: center; + gap: 8px; +} +.action-button { + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + background: transparent; + border: 1px solid transparent; + border-radius: 20px; + font-size: 14px; + font-weight: 500; + color: #495057; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + width: 32px; + height: 32px; + box-sizing: border-box; +} +.action-button:hover:not(:disabled) { + background: #f8f9fa; + border-color: #dee2e6; +} +.action-button:disabled { + cursor: not-allowed; + opacity: 0.4; +} +.action-button.delete-action:hover:not(:disabled) { + background: #f8d7da; + border-color: #f5c6cb; + color: #721c24; +} +.action-button svg { + width: 16px; + height: 16px; +} +.nav-controls { + display: flex; + align-items: center; + gap: 8px; +} +.action-button.nav-button:disabled { + background: transparent; + border-color: transparent; + color: #adb5bd; + opacity: 0.6; +} +.turn-counter { + font-size: 0.875rem; + color: #6c757d; + font-weight: 500; + min-width: 60px; + text-align: center; + white-space: nowrap; +} +/* ---------- loading state ---------- */ +.sql-loader-container { + flex: 1 1 auto; + display: flex; + flex-direction: column; + box-sizing: border-box; + overflow: hidden; + position: relative; + width: 100%; + height: 100%; + min-height: 400px; +} +.loading-animation-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + padding: 20px; + background: rgba(255, 255, 255, 0.9); + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 10; +} +.loading-dots { + display: flex; + gap: 8px; + align-items: center; +} +.loading-dot { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--mb-blue-600); + animation: pulse 1.5s ease-in-out infinite; +} +.loading-dot:nth-child(1) { + animation-delay: 0s; +} +.loading-dot:nth-child(2) { + animation-delay: 0.2s; +} +.loading-dot:nth-child(3) { + animation-delay: 0.4s; +} +@keyframes pulse { + 0%, 80%, 100% { + transform: scale(0.8); + opacity: 0.6; + } + 40% { + transform: scale(1.2); + opacity: 1; + } +} +.loading-text { + font-size: 16px; + font-weight: 500; + color: #666; + text-align: center; +} +/* ---------- failure state ---------- */ +.failure-container { + padding: 24px; + height: 100%; + box-sizing: border-box; + position: relative; +} +.failure-content { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + padding: 20px; + background: rgba(255, 255, 255, 0.9); + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 10; + max-width: 480px; +} +.failure-icon { + font-size: 32px; + flex-shrink: 0; +} +.failure-title { + font-size: 18px; + font-weight: 600; + color: #c53030; + margin: 0; +} +.failure-message { + font-size: 14px; + color: #718096; + line-height: 1.5; + margin: 0; + text-align: center; +} +.failure-actions { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + margin-top: 4px; +} +.retry-btn { + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + background: #3182ce; + color: #fff; +} +.retry-btn:hover { + background: #2c5aa0; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} +.retry-btn--disabled, +.retry-btn--disabled:hover { + background: #64748b; + color: #fff; + cursor: not-allowed; + transform: none; + box-shadow: none; +} +.retry-hint { + font-size: 0.9rem; + color: #666; + margin: 0; +} +/* ---------- semantic cache badge ---------- */ +.cache-badge { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + padding: 4px 10px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 4px; + font-size: 0.8rem; + color: #334155; + margin-bottom: 8px; + cursor: default; + user-select: none; + flex-shrink: 0; +} +.cache-badge-hint { + color: #64748b; +} +.cache-fresh-btn { + background: none; + border: none; + padding: 0; + margin-left: 4px; + font-size: inherit; + color: #2563eb; + text-decoration: underline; + cursor: pointer; +} +.cache-fresh-btn:hover:not(:disabled) { + color: #1d4ed8; +} +.cache-fresh-btn:disabled { + color: #94a3b8; + cursor: not-allowed; + text-decoration: none; +} +/* ---------- Metabase view button ---------- */ +.metabase-view-container { + display: flex; + justify-content: center; + align-items: center; + flex: 1; +} +.metabase-view-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 16px 32px; + background: linear-gradient(135deg, #4299e1 0%, #3182ce 100%); + color: #fff; + border: none; + border-radius: 12px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 12px rgba(66, 153, 225, 0.3); +} +.metabase-view-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(66, 153, 225, 0.4); + background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%); + color: #fff; +} +.metabase-view-btn:active { + transform: translateY(0); + box-shadow: 0 4px 12px rgba(66, 153, 225, 0.3); +} +.metabase-view-btn svg { + flex-shrink: 0; + transition: all 0.3s ease; +} +.metabase-view-btn:hover svg { + transform: translateX(2px) translateY(-2px); +} +.metabase-view-btn span { + position: relative; + z-index: 1; + letter-spacing: 0.3px; +} +/* ---------- SQL panel (overlay) ---------- */ +.sql-panel { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #fff; + z-index: 5; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; + display: flex; + flex-direction: column; + justify-content: flex-start; + box-sizing: border-box; + padding: 8px 16px; +} +.sql-panel::-webkit-scrollbar { + display: none; +} +.sql-panel-header { + padding: 8px 0 16px 0; + font-weight: 600; + font-size: 18px; + color: var(--mb-gray-700); + flex-shrink: 0; +} +.sql-code { + padding: 12px 16px; + margin: 0; + font-family: 'Fira Mono', Consolas, 'Courier New', monospace; + font-size: 14px; + line-height: 1.35; + color: #1e1e1e; + background: #fff; + white-space: pre-wrap; + overflow-y: auto; + border-radius: 8px; + border: 1px solid var(--mb-gray-200); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + flex: 1; +} +/* ---------- SQL explanation bubble (typewriter) ---------- */ +.sql-explanation-bubble { + position: relative; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 12px; + padding: 0; + margin: 12px 8px 8px 8px; + font-size: 0.85em; + color: #075985; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + animation: slideIn 0.3s ease-out; + flex-shrink: 0; +} +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.bubble-content { + padding: 10px 14px; + line-height: 1.4; +} +.bubble-tail { + position: absolute; + top: -6px; + left: 30px; + width: 12px; + height: 12px; + background: #f0f9ff; + border-left: 1px solid #bae6fd; + border-top: 1px solid #bae6fd; + transform: rotate(45deg); +} +.cursor { + animation: blink 1s infinite; + font-weight: normal; + opacity: 0.8; + color: #075985; +} +@keyframes blink { + 0%, 50% { + opacity: 0.8; + } + 51%, 100% { + opacity: 0; + } +} +/* ---------- responsive ---------- */ +@media (max-width: 768px) { + .outer-container { + grid-template-columns: 1fr; + } + .sidebar { + width: 100%; + } + .turns { + padding: 0 16px; + } + .ask-row-container { + margin-left: 12px; + margin-right: 12px; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.js index 2559b0aa44..4a67ba5042 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.js @@ -23,7 +23,14 @@ const btnExplain = document.getElementById('btn-explain'); const btnDeleteQ = document.getElementById('btn-delete-question'); - const apiBase = (window.reportingAiApiBaseUrl || '').replace(/\/+$/, '') + '/api'; + const notify = abp.notify; + const reportingAiApiBaseUrl = (window.reportingAiApiBaseUrl || '').trim(); + if (!reportingAiApiBaseUrl) { + notify.error('AI Reporting is not configured.'); + return; + } + + const apiBase = reportingAiApiBaseUrl.replace(/\/+$/, '') + '/api'; const MAX_RETRIES = 2; // ─── State ────────────────────────────────────────────────────────────── @@ -50,8 +57,6 @@ const isLoading = () => state.conversation.some(t => t.safeUrl === 'loading'); const findTurn = (id) => state.conversation.find(t => t.id === id); - const notify = abp.notify; - // Fresh turn const createTurn = (question, retryCount = 0) => ({ id: newTurnId(), From efdc14df6e44c590693424151c4c3117b13574c5 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Tue, 5 May 2026 19:27:51 -0700 Subject: [PATCH 05/11] AB#32842: Setup User-Friendly Error Page on UAT and PROD Only --- .claude/settings.local.json | 8 ++++++++ .../Unity.GrantManager.Web/GrantManagerWebModule.cs | 11 +++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000000..2f398ec3f7 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:github.com)", + "WebFetch(domain:raw.githubusercontent.com)" + ] + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 4c73bc85e3..957112a641 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -559,8 +559,15 @@ public override void OnApplicationInitialization(ApplicationInitializationContex app.UseAbpRequestLocalization(); - app.UseStatusCodePagesWithReExecute("/Error", "?httpStatusCode={0}"); - app.UseErrorPage(); + if (env.IsProduction() || env.IsStaging()) + { + app.UseStatusCodePagesWithReExecute("/Error", "?httpStatusCode={0}"); + app.UseErrorPage(); + } + else + { + app.UseDeveloperExceptionPage(); + } if (Convert.ToBoolean(configuration["AuthServer:IsBehindTlsTerminationProxy"])) { From 5d182f94f3f0d1750ed160b4cf52b5bb4ac987b3 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Tue, 5 May 2026 19:37:24 -0700 Subject: [PATCH 06/11] Removed unneeded file --- .claude/settings.local.json | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 2f398ec3f7..0000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "permissions": { - "allow": [ - "WebFetch(domain:github.com)", - "WebFetch(domain:raw.githubusercontent.com)" - ] - } -} From 981acf14000aec72729c564f9aa7101d611407bd Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 6 May 2026 09:00:26 -0700 Subject: [PATCH 07/11] AB#32452 address prompt tools review feedback --- ...vider.cs => AIPromptToolAccessProvider.cs} | 0 ...ider.cs => IAIPromptToolAccessProvider.cs} | 0 .../Pages/GrantApplications/Details.css | 5 +- .../Pages/GrantApplications/Details.js | 56 +++++++++++++------ 4 files changed, 39 insertions(+), 22 deletions(-) rename applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/{AIPromptToolViewOptionsProvider.cs => AIPromptToolAccessProvider.cs} (100%) rename applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/{IAIPromptToolViewOptionsProvider.cs => IAIPromptToolAccessProvider.cs} (100%) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/AIPromptToolViewOptionsProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/AIPromptToolAccessProvider.cs similarity index 100% rename from applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/AIPromptToolViewOptionsProvider.cs rename to applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/AIPromptToolAccessProvider.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/IAIPromptToolViewOptionsProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/IAIPromptToolAccessProvider.cs similarity index 100% rename from applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/IAIPromptToolViewOptionsProvider.cs rename to applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/PromptTools/IAIPromptToolAccessProvider.cs 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 ff2e4603af..ef0fce9a3d 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 @@ -825,6 +825,7 @@ form label.error { display: flex; align-items: center; gap: 0.5rem 0.75rem; + margin-left: auto; } .prompt-tools-header select, @@ -833,10 +834,6 @@ form label.error { white-space: nowrap; } -.prompt-tools-header .prompt-tools-toolbar-row { - margin-left: auto; -} - .prompt-tools-section { margin-bottom: 1rem; padding-bottom: 1rem; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index fa611d5c07..7a031fdf9d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -181,6 +181,21 @@ $(function () { ''; } + function hasPromptTools() { + return $('#prompt-tools').length > 0 && $('#promptVersion').length > 0; + } + + function getAIGenerationFailureMessage(operationType) { + switch (operationType) { + case 'attachment-summary': + return 'AI attachment summary failed.'; + case 'application-scoring': + return 'AI application scoring failed.'; + default: + return 'AI operation failed.'; + } + } + function getPromptDataPayload() { const submissionJson = $('#ApplicationFormSubmissionData').val(); if (!submissionJson) { @@ -289,6 +304,10 @@ $(function () { } function loadPromptToolsOutputs() { + if (!hasPromptTools()) { + return; + } + const applicationId = $('#DetailsViewApplicationId').val(); if (!applicationId) { @@ -367,24 +386,25 @@ $(function () { .getAIGenerationStatus(applicationId, operationType, promptVersion) .done(function(request) { const statusText = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; - if (statusText === 'Failed') { - stopAIGenerationPolling(); - globalThis.AIGenerationButtonState?.restore(restoreButton); - restoreButton.html(originalHtml).prop('disabled', false); - loadPromptToolsOutputs(); - abp.message.error(request?.failureReason || 'AI generate all failed.'); - return; - } - if (!request || request.isActive === false || statusText === 'Completed') { - stopAIGenerationPolling(); - setPromptToolsTimestamp('#analysisOutputTimestamp', request?.completedAt || request?.startedAt || null); - setPromptToolsTimestamp('#scoringOutputTimestamp', request?.completedAt || request?.startedAt || null); - loadPromptToolsOutputs(); - globalThis.AIGenerationButtonState?.setCompleted(restoreButton); - restoreButton.html('Completed').prop('disabled', true); - return; - } + if (statusText === 'Failed') { + stopAIGenerationPolling(); + globalThis.AIGenerationButtonState?.restore(restoreButton); + restoreButton.html(originalHtml).prop('disabled', false); + loadPromptToolsOutputs(); + abp.message.error(request?.failureReason || getAIGenerationFailureMessage(operationType)); + return; + } + + if (!request || request.isActive === false || statusText === 'Completed') { + stopAIGenerationPolling(); + setPromptToolsTimestamp('#analysisOutputTimestamp', request?.completedAt || request?.startedAt || null); + setPromptToolsTimestamp('#scoringOutputTimestamp', request?.completedAt || request?.startedAt || null); + loadPromptToolsOutputs(); + globalThis.AIGenerationButtonState?.setCompleted(restoreButton); + restoreButton.html('Completed').prop('disabled', true); + return; + } aiGenerationPollTimeoutId = setTimeout(poll, aiGenerationPollIntervalMs); }) @@ -511,7 +531,7 @@ $(function () { updateLinksCounters(); renderSubmission(); loadAIAnalysis(); - loadPromptToolsOutputs(); + loadPromptToolsOutputs(); applyTabHeightOffset(); } From 6051a44225d546e97c11c55dae9dcfbf16903dc1 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 6 May 2026 09:08:57 -0700 Subject: [PATCH 08/11] AB#32543 address AI reporting review feedback --- .../Pages/AIReporting/Index.cshtml | 6 ++-- .../Pages/AIReporting/Index.css | 12 +++---- .../Pages/AIReporting/Index.js | 36 +++++++++---------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml index 105b27e25a..748d7754ce 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/AIReporting/Index.cshtml @@ -51,14 +51,14 @@
-
+

What would you like to know?

- +