/* Observability chart rendering and page actions. * Chart.js is loaded lazily through analysis-chart-theme.js so observability * pages do not block initial HTML parsing on a chart library. */ (function () { 'use strict'; const providerLabelMap = { gcp_ollama: '主力 Ollama', ollama_secondary: '備援 Ollama', ollama_111: '111 Ollama', gemini: 'Gemini', claude: 'Claude', nim: 'NIM', openrouter: 'OpenRouter', nim_via_elephant: 'NIM Elephant' }; function readJson(id, fallback) { const node = document.getElementById(id); if (!node) return fallback; try { return JSON.parse(node.textContent || 'null') ?? fallback; } catch (error) { console.error(`[observability] JSON parse failed: ${id}`, error); return fallback; } } function loadChartJs() { if (window.EwoooCChartTheme && window.EwoooCChartTheme.loadChartJs) { return window.EwoooCChartTheme.loadChartJs(); } if (window.Chart) return Promise.resolve(window.Chart); return Promise.reject(new Error('Chart.js loader unavailable')); } function runWhenVisible(selector, render) { const targets = Array.from(document.querySelectorAll(selector)); if (!targets.length) return; if (!('IntersectionObserver' in window)) { render(); return; } const observer = new IntersectionObserver(entries => { if (!entries.some(entry => entry.isIntersecting)) return; observer.disconnect(); render(); }, { rootMargin: '240px 0px' }); targets.forEach(target => observer.observe(target)); } function mountChart(selector, render) { runWhenVisible(selector, () => { loadChartJs() .then(render) .catch(error => console.error('[observability] Chart.js 載入失敗:', error)); }); } function renderOverviewHostSparklines() { const data = readJson('obs-host-sparkline-data', {}); mountChart('canvas[data-host-sparkline]', () => { document.querySelectorAll('canvas[data-host-sparkline]').forEach(el => { if (el.dataset.chartReady === '1') return; const label = el.getAttribute('data-host-sparkline'); const sp = data[label]; if (!sp || !sp.hours || !sp.hours.length) return; el.dataset.chartReady = '1'; new Chart(el, { type: 'line', data: { labels: sp.hours, datasets: [{ data: sp.uptime_pct, borderColor: '#c96442', backgroundColor: 'rgba(201, 100, 66, 0.14)', borderWidth: 1.8, fill: true, tension: 0.42, pointRadius: 0 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { displayColors: false, callbacks: { label: c => `${c.label}: ${c.parsed.y.toFixed(0)}% 可用率` } } }, scales: { x: { display: false }, y: { display: false, min: 0, max: 100 } } } }); }); }); } function renderBusinessVerdict() { const verdictRows = readJson('obs-business-verdict-data', []); const canvas = document.getElementById('verdictPieChart'); if (!canvas || !verdictRows.length) return; const verdictLabelMap = { effective: '有效', success: '成功', positive: '正向', backfired: '反效果', negative: '負向', failed: '失敗', neutral: '中性', pending: '待回收', inconclusive: '尚未定論', no_data: '無資料' }; mountChart('#verdictPieChart', () => { new Chart(canvas, { type: 'doughnut', data: { labels: verdictRows.map(row => verdictLabelMap[row.verdict] || row.verdict || '未分類'), datasets: [{ data: verdictRows.map(row => row.count || 0), backgroundColor: ['#2f8f6b', '#c96442', '#f1b45a', '#6d4b3f', '#d9a06f'], borderColor: '#fff8ee', borderWidth: 4, hoverOffset: 10 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 8, color: '#6f564b', font: { weight: 700 } } } }, cutout: '66%' } }); }); } function renderAiCalls() { const data = readJson('obs-ai-calls-data', {}); const labels = data.labels || []; const calls = data.calls || []; const costs = data.costs || []; const errors = data.errors || []; if (!labels.length) return; mountChart('#hourlyTrendChart, canvas[data-spark]', () => { const sparkColors = { calls: '#c96442', cost: '#b8792f', errors: '#b94b45' }; const sparkData = { calls, cost: costs, errors }; document.querySelectorAll('canvas[data-spark]').forEach(el => { if (el.dataset.chartReady === '1') return; const key = el.getAttribute('data-spark'); const series = sparkData[key]; if (!series || !series.length) return; el.dataset.chartReady = '1'; new Chart(el, { type: 'line', data: { labels, datasets: [{ data: series, borderColor: sparkColors[key], backgroundColor: `${sparkColors[key]}24`, borderWidth: 1.4, fill: true, tension: 0.42, pointRadius: 0 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { enabled: false } }, scales: { x: { display: false }, y: { display: false, beginAtZero: true } } } }); }); const el = document.getElementById('hourlyTrendChart'); if (!el || el.dataset.chartReady === '1') return; el.dataset.chartReady = '1'; new Chart(el, { data: { labels, datasets: [ { type: 'line', label: '呼叫數', data: calls, borderColor: '#c96442', backgroundColor: 'rgba(201,100,66,.12)', tension: 0.35, fill: true, yAxisID: 'y' }, { type: 'line', label: '錯誤', data: errors, borderColor: '#b94b45', backgroundColor: 'rgba(185,75,69,.1)', tension: 0.35, yAxisID: 'y' }, { type: 'bar', label: '成本 USD', data: costs, backgroundColor: 'rgba(184,121,47,.38)', borderColor: '#b8792f', yAxisID: 'y1' } ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, scales: { y: { beginAtZero: true, title: { display: true, text: '次數' } }, y1: { position: 'right', beginAtZero: true, grid: { drawOnChartArea: false }, title: { display: true, text: 'USD' } } } } }); }); } function renderBudgetCharts() { const data = readJson('obs-budget-data', {}); const providerCostMonth = data.providerCostMonth || []; const costTrend30d = data.costTrend30d || []; if (providerCostMonth.length) { mountChart('#providerCostPieChart', () => { const el = document.getElementById('providerCostPieChart'); if (!el || el.dataset.chartReady === '1') return; el.dataset.chartReady = '1'; const colors = { gcp_ollama: '#4f8a5b', ollama_secondary: '#7aaa82', ollama_111: '#a3cfa8', gemini: '#b8792f', claude: '#4f6f8f', nim: '#6aa6a6', openrouter: '#8b8077', nim_via_elephant: '#c96442' }; new Chart(el, { type: 'doughnut', data: { labels: providerCostMonth.map(d => providerLabelMap[d.provider] || d.provider), datasets: [{ data: providerCostMonth.map(d => d.cost), backgroundColor: providerCostMonth.map((d, i) => colors[d.provider] || `hsl(${(i * 47) % 360},55%,55%)`), borderWidth: 1, borderColor: '#fff' }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { font: { size: 11 } } } } } }); }); } if (costTrend30d.length) { mountChart('#costTrend30dChart', () => { const el = document.getElementById('costTrend30dChart'); if (!el || el.dataset.chartReady === '1') return; el.dataset.chartReady = '1'; const dateSet = [...new Set(costTrend30d.map(row => row.date))].sort(); const providerSet = [...new Set(costTrend30d.map(row => row.provider))]; const palette = ['#c96442', '#b8792f', '#4f8a5b', '#4f6f8f', '#6aa6a6', '#8b8077', '#a66a4a']; const datasets = providerSet.map((provider, index) => ({ label: providerLabelMap[provider] || provider, data: dateSet.map(date => { const row = costTrend30d.find(item => item.date === date && item.provider === provider); return row ? row.cost : 0; }), backgroundColor: palette[index % palette.length] })); new Chart(el, { type: 'bar', data: { labels: dateSet, datasets }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true, title: { display: true, text: 'USD' } } } } }); }); } } function renderHostHealth() { const data = readJson('obs-host-health-data', {}); const healSparkline = data.healSparkline || []; if (!healSparkline.length) return; mountChart('#healSparkline', () => { const el = document.getElementById('healSparkline'); if (!el || el.dataset.chartReady === '1') return; el.dataset.chartReady = '1'; new Chart(el, { type: 'line', data: { labels: healSparkline.map(d => d.date), datasets: [{ data: healSparkline.map(d => d.rate), borderColor: '#c96442', backgroundColor: 'rgba(201,100,66,.14)', borderWidth: 2, fill: true, tension: 0.35, pointRadius: 2 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false }, y: { min: 0, max: 100, ticks: { callback: v => `${v}%` } } } } }); }); } function renderPromotionReview() { const dist = readJson('obs-promotion-review-data', {}); const keys = Object.keys(dist || {}); if (!keys.length) return; mountChart('#episodeDistChart', () => { const el = document.getElementById('episodeDistChart'); if (!el || el.dataset.chartReady === '1') return; el.dataset.chartReady = '1'; const colorMap = { pending: '#8b8077', awaiting_review: '#b8792f', approved: '#4f8a5b', rejected_quality: '#b94b45', rejected_hallucination: '#9f3330', rejected_duplicate: '#c96442', rejected_human: '#8f2925', expired: '#b8aea5' }; const labelMap = { pending: '待處理', awaiting_review: '待審核', approved: '已晉升', rejected_quality: '品質拒', rejected_hallucination: '幻覺拒', rejected_duplicate: '重複拒', rejected_human: '人工拒', expired: '已過期' }; new Chart(el, { type: 'doughnut', data: { labels: keys.map(k => labelMap[k] || k), datasets: [{ data: keys.map(k => dist[k]), backgroundColor: keys.map(k => colorMap[k] || '#999'), borderWidth: 1, borderColor: '#fff' }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { font: { size: 11 } } } } } }); }); } function renderQualityTrend() { const rows = readJson('obs-quality-trend-data', []); if (!rows.length) return; mountChart('#ragFeedbackPieChart', () => { const el = document.getElementById('ragFeedbackPieChart'); if (!el || el.dataset.chartReady === '1') return; el.dataset.chartReady = '1'; const colorMap = { 1: '#b94b45', 2: '#c96442', 3: '#b8792f', 4: '#7aaa82', 5: '#4f8a5b' }; new Chart(el, { type: 'doughnut', data: { labels: rows.map(row => `${row.score} 星`), datasets: [{ data: rows.map(row => row.count), backgroundColor: rows.map(row => colorMap[row.score] || '#8b8077'), borderWidth: 1, borderColor: '#fff' }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { font: { size: 12 } } } } } }); }); } function renderPptAudit() { const stats = readJson('obs-ppt-audit-data', {}); if (!stats.total) return; mountChart('#pptAuditPieChart', () => { const el = document.getElementById('pptAuditPieChart'); if (!el || el.dataset.chartReady === '1') return; el.dataset.chartReady = '1'; const data = [ { label: '通過', value: stats.passed || 0, color: '#4f8a5b' }, { label: '失敗', value: stats.failed || 0, color: '#b8792f' }, { label: '錯誤', value: stats.error || 0, color: '#b94b45' }, { label: '跳過', value: stats.skipped || 0, color: '#8b8077' } ].filter(item => item.value > 0); if (!data.length) return; new Chart(el, { type: 'doughnut', data: { labels: data.map(item => item.label), datasets: [{ data: data.map(item => item.value), backgroundColor: data.map(item => item.color), borderWidth: 1, borderColor: '#fff' }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { font: { size: 12 } } } } } }); }); } async function postJson(url, options) { const fetcher = window.fetchWithCSRF || window.fetch.bind(window); return fetcher(url, options || {}); } window.triggerCodeReview = async function triggerCodeReview() { if (!confirm('觸發程式碼審查管線?\n\n會對最新 commit 跑 5 步驟審查,背景執行。')) return; try { const response = await postJson('/observability/ai_calls/trigger_code_review', { method: 'POST' }); const data = await response.json(); if (data.ok) { alert(`✅ ${data.message}\n\n管線 ID: ${data.pipeline_id}\nCommit: ${data.commit_sha}\n變更檔案: ${data.changed_files_count} 個`); } else { alert(`❌ ${data.error || '觸發失敗'}`); } } catch (error) { console.warn('code_review_trigger_failed', error); alert('操作暫時無法完成,請稍後再試或查看系統日誌。'); } }; window.forceThrottle = async function forceThrottle() { if (!confirm('立即重算所有供應商的節流狀態?')) return; try { const response = await postJson('/observability/budget/force_throttle', { method: 'POST' }); const data = await response.json(); if (data.ok) { const providers = data.throttled_providers && data.throttled_providers.length > 0 ? data.throttled_providers.join(', ') : '(無)'; alert(`✅ 已重算:被節流的供應商 = ${providers}`); window.location.reload(); } else { alert(`❌ ${data.error || '重算失敗'}`); } } catch (error) { console.warn('budget_force_throttle_failed', error); alert('操作暫時無法完成,請稍後再試或查看系統日誌。'); } }; window.saveBudget = async function saveBudget(id) { const budgetInput = document.querySelector(`.budget-input[data-budget-id="${id}"]`); const alertInput = document.querySelector(`.alert-input[data-budget-id="${id}"]`); const button = document.querySelector(`.save-budget-btn[data-budget-id="${id}"]`); if (!budgetInput || !alertInput || !button) return; button.disabled = true; button.innerHTML = ''; try { const response = await postJson(`/observability/budget/update/${id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ budget_usd: parseFloat(budgetInput.value), alert_pct: parseInt(alertInput.value, 10) }) }); const data = await response.json(); if (data.ok) { button.innerHTML = ' 已儲存'; setTimeout(() => { button.innerHTML = '儲存'; button.disabled = false; }, 1500); } else { alert(`更新失敗:${data.error || '請稍後再試'}`); button.disabled = false; button.innerHTML = '儲存'; } } catch (error) { console.warn('budget_save_failed', error); alert('操作暫時無法完成,請稍後再試或查看系統日誌。'); button.disabled = false; button.innerHTML = '儲存'; } }; window.togglePlaybook = async function togglePlaybook(id, name) { if (!confirm(`切換 Playbook 「${name}」狀態?`)) return; try { const response = await postJson(`/observability/playbooks/toggle/${id}`, { method: 'POST' }); const data = await response.json(); if (data.ok) { alert(`✅ ${data.message}`); window.location.reload(); } else { alert(`❌ ${data.error || '切換失敗'}`); } } catch (error) { console.warn('playbook_toggle_failed', error); alert('操作暫時無法完成,請稍後再試或查看系統日誌。'); } }; window.triggerAutoHeal = async function triggerAutoHeal(hostLabel) { if (!confirm(`觸發 AutoHeal?\n\n主機:${hostLabel}`)) return; try { const response = await postJson('/observability/host_health/trigger_autoheal', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ host_label: hostLabel }) }); const data = await response.json(); if (data.ok) { alert(`✅ AutoHeal 已派出\n動作:${data.action || '—'}\n訊息:${data.message || ''}`); window.location.reload(); } else { alert(`❌ ${data.error || data.message || '觸發失敗'}`); } } catch (error) { console.warn('host_autoheal_failed', error); alert('操作暫時無法完成,請稍後再試或查看系統日誌。'); } }; window.approveEpisode = async function approveEpisode(id, button) { button.disabled = true; button.innerHTML = ' 處理中...'; try { const response = await postJson(`/observability/promotion_review/approve/${id}`, { method: 'POST' }); const data = await response.json(); if (data.ok) { const card = document.querySelector(`.episode-card[data-episode-id="${id}"]`); const footer = card ? card.querySelector('.card-footer') : null; if (card && footer) { card.classList.add('border-success'); footer.innerHTML = `已晉升 → ai_insights #${data.insight_id}(審核者:${data.approver})`; } else { window.location.reload(); } } else { alert(`晉升失敗:${data.error || '請稍後再試'}`); button.disabled = false; button.innerHTML = '通過晉升'; } } catch (error) { console.warn('promotion_approve_failed', error); alert('操作暫時無法完成,請稍後再試或查看系統日誌。'); button.disabled = false; button.innerHTML = '通過晉升'; } }; window.rejectEpisode = async function rejectEpisode(id, button) { if (!confirm(`拒絕學習片段 #${id}?此筆將永不晉升。`)) return; button.disabled = true; button.innerHTML = ' 處理中...'; try { const response = await postJson(`/observability/promotion_review/reject/${id}`, { method: 'POST' }); const data = await response.json(); if (data.ok) { const card = document.querySelector(`.episode-card[data-episode-id="${id}"]`); const footer = card ? card.querySelector('.card-footer') : null; if (card && footer) { card.classList.add('border-danger'); footer.innerHTML = '已拒絕(人工拒絕)'; } else { window.location.reload(); } } else { alert(`拒絕失敗:${data.error || '請稍後再試'}`); button.disabled = false; button.innerHTML = '拒絕'; } } catch (error) { console.warn('promotion_reject_failed', error); alert('操作暫時無法完成,請稍後再試或查看系統日誌。'); button.disabled = false; button.innerHTML = '拒絕'; } }; window.triggerAiderHeal = async function triggerAiderHeal(pptxFilename, errorMsg, triggerButton) { if (!confirm(`觸發 AiderHeal 自動修復?\n\n檔案:${pptxFilename}\n錯誤:${(errorMsg || '').substring(0, 200)}`)) return; const statusNode = document.querySelector('[data-ppt-auto-status]'); const originalHtml = triggerButton ? triggerButton.innerHTML : ''; if (triggerButton) { triggerButton.disabled = true; triggerButton.innerHTML = '派工中'; } if (statusNode) { statusNode.classList.add('is-working'); statusNode.textContent = `${pptxFilename || 'PPT'} 正在排入 AiderHeal 背景修復。`; } try { const response = await postJson('/observability/ppt_audit/trigger_aider_heal', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pptx_filename: pptxFilename, issue_summary: errorMsg || '' }) }); const data = await response.json(); if (!response.ok || !data.ok) { throw new Error(data.error || data.message || '觸發失敗'); } if (triggerButton) { triggerButton.innerHTML = data.status === 'already_running' ? '執行中' : '已排入'; } if (statusNode) { statusNode.classList.remove('is-working'); statusNode.textContent = data.message || 'AiderHeal 已排入背景執行,完成後會由 Telegram/Gitea/CD 結果回報。'; } if (window.refreshPptAiderHealStatus) { window.refreshPptAiderHealStatus(); } } catch (error) { console.warn('ppt_audit_trigger_aider_heal_failed', error); if (triggerButton) { triggerButton.disabled = false; triggerButton.innerHTML = originalHtml; } if (statusNode) { statusNode.classList.remove('is-working'); statusNode.textContent = 'AiderHeal 派工失敗,請稍後再試或查看系統日誌。'; } } }; function renderPptAiderHealStatus(payload) { const panel = document.querySelector('[data-ppt-aider-status]'); if (!panel) return; const jobs = Array.isArray(payload && payload.jobs) ? payload.jobs : []; const count = Number((payload && payload.active_count) || jobs.length || 0); panel.classList.toggle('is-empty', count <= 0); const title = panel.querySelector('[data-ppt-aider-status-title]'); const meta = panel.querySelector('[data-ppt-aider-status-meta]'); const list = panel.querySelector('[data-ppt-aider-job-list]'); if (title) title.textContent = count > 0 ? `AiderHeal 執行中 · ${count}` : 'AiderHeal 待命'; if (meta) { meta.textContent = count > 0 ? '修復完成後會由 Telegram/Gitea/CD 回報。' : '有問題的審核紀錄可直接一鍵派工。'; } if (list) { list.innerHTML = jobs.slice(0, 3).map(job => `
${escapeHtml(job.pptx_filename || 'manual')} ${escapeHtml(job.queued_at || '')} ${escapeHtml(job.diagnosis || '')}
`).join(''); } } window.refreshPptAiderHealStatus = async function refreshPptAiderHealStatus() { try { const response = await fetch('/observability/ppt_audit/aider_heal_status', { headers: { 'Accept': 'application/json' } }); const data = await response.json(); if (!response.ok || !data.ok) return; renderPptAiderHealStatus(data); } catch (error) { console.warn('ppt_audit_aider_heal_status_failed', error); } }; function renderPptVisionStatus(payload) { const panel = document.querySelector('[data-ppt-vision-status]'); if (!panel) return; const status = (payload && payload.status) || 'unknown'; panel.className = panel.className.replace(/\bis-[a-z_]+\b/g, '').trim(); panel.classList.add(`is-${status}`); const title = panel.querySelector('[data-ppt-vision-status-title]'); const meta = panel.querySelector('[data-ppt-vision-status-meta]'); const list = panel.querySelector('[data-ppt-vision-status-list]'); if (title) title.textContent = (payload && payload.status_label) || '狀態未知'; if (meta) meta.textContent = (payload && payload.message) || '最近視覺 QA 狀態無法讀取。'; if (!list) return; const lastRun = payload && payload.last_run; if (!lastRun) { list.innerHTML = `
尚無紀錄 待命 按下「立即視覺 QA」後會在這裡顯示背景任務狀態。
`; return; } const summary = lastRun.summary || {}; const timestamp = lastRun.finished_at || lastRun.started_at || lastRun.queued_at || ''; const issueText = `${Number(summary.audited_count || 0)} 份 / ${Number(summary.total_issues || 0)} 問題`; const errorText = Number(summary.error_count || 0) > 0 ? `錯誤 ${Number(summary.error_count || 0)}` : '無 runtime error'; list.innerHTML = `
${escapeHtml(timestamp)} ${escapeHtml(issueText)} ${escapeHtml(errorText)}
`; } window.refreshPptVisionStatus = async function refreshPptVisionStatus() { try { const response = await fetch('/observability/ppt_audit/vision_status', { headers: { 'Accept': 'application/json' } }); const data = await response.json(); if (!response.ok || !data.ok) return; renderPptVisionStatus(data); } catch (error) { console.warn('ppt_vision_status_failed', error); } }; function initPptAutoGeneration() { const panel = document.querySelector('[data-ppt-auto-generation]'); const pageStatus = document.querySelector('[data-ppt-auto-status]'); const previewModal = document.querySelector('[data-ppt-preview-modal]'); const previewFrame = previewModal ? previewModal.querySelector('[data-ppt-preview-frame]') : null; const previewTitle = previewModal ? previewModal.querySelector('[data-ppt-preview-modal-title]') : null; const previewFilename = previewModal ? previewModal.querySelector('[data-ppt-preview-filename]') : null; const previewOpenPage = previewModal ? previewModal.querySelector('[data-ppt-preview-open-page]') : null; const previewDownload = previewModal ? previewModal.querySelector('[data-ppt-preview-download]') : null; const previewLoading = previewModal ? previewModal.querySelector('[data-ppt-preview-loading]') : null; const previewFrameWrap = previewModal ? previewModal.querySelector('.ppt-preview-frame-wrap') : null; const visionAuditFilenames = readJson('obs-ppt-audit-filenames', []); if (document.querySelector('[data-ppt-vision-status]') && window.refreshPptVisionStatus) { window.refreshPptVisionStatus(); } function closePreviewModal() { if (!previewModal) return; previewModal.hidden = true; previewModal.setAttribute('aria-hidden', 'true'); document.body.classList.remove('ppt-preview-open'); if (previewFrame) { previewFrame.removeAttribute('src'); } } function openPreviewModal(trigger) { if (!previewModal || !previewFrame) return false; const pdfUrl = trigger.dataset.pptPreviewPdf || ''; if (!pdfUrl) return false; const title = trigger.dataset.pptPreviewTitle || '簡報線上預覽'; const filename = trigger.dataset.pptFilename || ''; if (previewTitle) previewTitle.textContent = title; if (previewFilename) previewFilename.textContent = filename; if (previewOpenPage) previewOpenPage.href = trigger.dataset.pptPreviewPage || trigger.href || pdfUrl; if (previewDownload) previewDownload.href = trigger.dataset.pptDownloadUrl || '#'; if (previewLoading) previewLoading.textContent = filename ? `${filename} 正在載入 PDF 預覽` : '正在載入 PDF 預覽'; previewModal.hidden = false; previewModal.setAttribute('aria-hidden', 'false'); document.body.classList.add('ppt-preview-open'); if (previewFrameWrap) previewFrameWrap.classList.remove('is-loaded'); previewFrame.src = pdfUrl; return true; } if (previewModal) { if (previewFrame && previewFrame.dataset.boundLoad !== '1') { previewFrame.dataset.boundLoad = '1'; previewFrame.addEventListener('load', () => { if (previewFrameWrap) previewFrameWrap.classList.add('is-loaded'); }); } previewModal.querySelectorAll('[data-ppt-preview-close]').forEach(button => { if (button.dataset.bound === '1') return; button.dataset.bound = '1'; button.addEventListener('click', closePreviewModal); }); document.addEventListener('keydown', event => { if (event.key === 'Escape' && !previewModal.hidden) closePreviewModal(); }); } document.querySelectorAll('[data-ppt-open-preview]').forEach(link => { if (link.dataset.bound === '1') return; link.dataset.bound = '1'; link.addEventListener('click', event => { if (openPreviewModal(link)) { event.preventDefault(); } }); }); document.querySelectorAll('[data-ppt-aider-heal]').forEach(button => { if (button.dataset.bound === '1') return; button.dataset.bound = '1'; button.addEventListener('click', () => { window.triggerAiderHeal(button.dataset.pptFilename || '', button.dataset.pptError || '', button); }); }); async function triggerVisionAudit(button) { const filenames = Array.isArray(visionAuditFilenames) ? visionAuditFilenames.filter(Boolean) : []; if (!filenames.length) { if (pageStatus) pageStatus.textContent = '目前沒有可送進視覺 QA 的 PPTX 檔案。'; return; } const buttons = Array.from(document.querySelectorAll('[data-ppt-run-vision]')); const originalHtml = button ? button.innerHTML : ''; buttons.forEach(item => { item.disabled = true; item.innerHTML = 'QA 排入中'; }); if (pageStatus) { pageStatus.classList.add('is-working'); pageStatus.textContent = `已準備送出 ${filenames.length} 份簡報進行視覺 QA,完成後會寫入資料庫。`; } try { const response = await postJson('/observability/ppt_audit/run_vision', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filenames, max_files: filenames.length }) }); const data = await response.json(); if (!response.ok || !data.ok) { throw new Error(data.error || data.message || '視覺 QA 送出失敗'); } buttons.forEach(item => { item.innerHTML = data.status === 'already_running' ? 'QA 執行中' : 'QA 已排入'; }); if (pageStatus) { pageStatus.textContent = data.status === 'already_running' ? '視覺 QA 已在執行中,請稍後重新整理查看資料庫結果。' : `視覺 QA 已排入 ${filenames.length} 份簡報;審核結果會寫入 ppt_audit_results。`; } if (window.refreshPptVisionStatus) { window.refreshPptVisionStatus(); } } catch (error) { console.warn('ppt_vision_audit_queue_failed', error); buttons.forEach(item => { item.disabled = false; item.innerHTML = item === button && originalHtml ? originalHtml : '立即視覺 QA'; }); if (pageStatus) { pageStatus.classList.remove('is-working'); pageStatus.textContent = '視覺 QA 送出失敗,請稍後再試或查看系統日誌。'; } } } document.querySelectorAll('[data-ppt-run-vision]').forEach(button => { if (button.dataset.bound === '1') return; button.dataset.bound = '1'; button.addEventListener('click', () => triggerVisionAudit(button)); }); function markPreviewCacheReady(filename) { document.querySelectorAll('[data-ppt-preview-state]').forEach(node => { if (node.dataset.pptPreviewState !== filename) return; node.classList.remove('status-warn'); node.classList.add('status-blue'); node.textContent = 'PDF 預覽快取已建立'; }); document.querySelectorAll('[data-ppt-prewarm-preview]').forEach(node => { if (node.dataset.pptFilename !== filename) return; node.innerHTML = '已快取'; node.disabled = true; }); } async function prewarmPreview(button, options = {}) { const filename = button.dataset.pptFilename || ''; if (!filename) return false; const originalHtml = button.innerHTML; button.disabled = true; button.innerHTML = '預熱中'; if (!options.quiet && pageStatus) { pageStatus.classList.add('is-working'); pageStatus.textContent = `${filename} 正在建立 PDF 預覽快取。`; } try { const response = await postJson(`/observability/ppt_audit_file/${encodeURIComponent(filename)}/prewarm`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await response.json(); if (!response.ok || !data.ok) { throw new Error(data.error || '預熱失敗'); } markPreviewCacheReady(filename); if (!options.quiet && pageStatus) { pageStatus.classList.remove('is-working'); pageStatus.textContent = data.message || `${filename} 的 PDF 預覽快取已建立。`; } return true; } catch (error) { console.warn('ppt_preview_prewarm_failed', error); button.disabled = false; button.innerHTML = originalHtml; if (!options.quiet && pageStatus) { pageStatus.classList.remove('is-working'); pageStatus.textContent = 'PDF 預覽預熱失敗,請稍後再試或直接開啟線上預覽。'; } return false; } } document.querySelectorAll('[data-ppt-prewarm-preview]').forEach(button => { if (button.dataset.bound === '1') return; button.dataset.bound = '1'; button.addEventListener('click', () => prewarmPreview(button)); }); document.querySelectorAll('[data-ppt-prewarm-all]').forEach(button => { if (button.dataset.bound === '1') return; button.dataset.bound = '1'; button.addEventListener('click', async () => { const targets = []; const seen = new Set(); document.querySelectorAll('[data-ppt-prewarm-preview]').forEach(item => { const filename = item.dataset.pptFilename || ''; if (!filename || item.disabled || seen.has(filename)) return; seen.add(filename); targets.push(item); }); if (!targets.length) { button.disabled = true; button.innerHTML = '本頁已快取'; if (pageStatus) { pageStatus.textContent = '本頁可預覽簡報的 PDF 快取都已建立。'; } return; } const originalHtml = button.innerHTML; const countBadge = button.querySelector('[data-ppt-prewarm-count]'); button.disabled = true; let successCount = 0; let failedCount = 0; for (let index = 0; index < targets.length; index += 1) { button.innerHTML = `預熱 ${index + 1}/${targets.length}`; if (pageStatus) { pageStatus.classList.add('is-working'); pageStatus.textContent = `正在預熱本頁 PDF 快取:${index + 1}/${targets.length}`; } const ok = await prewarmPreview(targets[index], { quiet: true }); if (ok) successCount += 1; else failedCount += 1; if (countBadge) { countBadge.textContent = String(Math.max(targets.length - successCount, 0)); } } if (pageStatus) { pageStatus.classList.remove('is-working'); pageStatus.textContent = failedCount ? `PDF 預熱完成 ${successCount}/${targets.length},仍有 ${failedCount} 份失敗。` : `本頁 ${successCount} 份 PDF 預覽快取已建立。`; } if (failedCount) { button.disabled = false; button.innerHTML = originalHtml; } else { button.innerHTML = '本頁已快取'; } }); }); if (!panel) return; const button = panel.querySelector('[data-ppt-generate-missing]'); const status = panel.querySelector('[data-ppt-auto-status]'); const reportTypes = (panel.dataset.reportTypes || '') .split(',') .map(value => value.trim()) .filter(Boolean); async function triggerGeneration(isAuto, selectedReportTypes, triggerButton, labelText, force = false) { const targetButton = triggerButton || button; const targetReportTypes = selectedReportTypes || reportTypes; const originalHtml = targetButton ? targetButton.innerHTML : ''; if (targetButton) { targetButton.disabled = true; targetButton.innerHTML = '排入中'; } if (status) { status.classList.add('is-working'); status.textContent = isAuto ? '偵測到本月定義簡報缺漏,已排入背景補齊。' : `${labelText || '簡報補齊'} 已排入背景產線,完成後重新整理即可看到最新狀態。`; } try { const response = await postJson('/observability/ppt_audit/generate_missing', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ report_types: targetReportTypes, force: Boolean(force) }) }); const data = await response.json(); if (status) { status.textContent = data.message || (data.status === 'queued' ? '已排入背景補齊,請稍後重新整理。' : `狀態:${data.status || '已送出'}`); } if (targetButton) { targetButton.innerHTML = '已排入'; } } catch (error) { console.warn('ppt_auto_generation_failed', error); if (status) { status.classList.remove('is-working'); status.textContent = '補齊任務送出失敗,請稍後再試或查看系統日誌。'; } if (targetButton) { targetButton.disabled = false; targetButton.innerHTML = originalHtml; } } } if (button) { button.addEventListener('click', () => triggerGeneration(false)); } document.querySelectorAll('[data-ppt-generate-one]').forEach(singleButton => { if (singleButton.dataset.bound === '1') return; singleButton.dataset.bound = '1'; singleButton.addEventListener('click', () => { const reportType = singleButton.dataset.reportType || ''; if (!reportType) return; const label = singleButton.dataset.reportLabel || reportType; triggerGeneration(false, [reportType], singleButton, label, true); }); }); if (panel.dataset.autoStart === 'true') { const key = `ppt-auto-generation:${new Date().toISOString().slice(0, 10)}`; let last = 0; const now = Date.now(); try { last = Number(window.localStorage.getItem(key) || 0); } catch (_error) { last = 0; } if (reportTypes.length && (!last || now - last > 6 * 60 * 60 * 1000)) { try { window.localStorage.setItem(key, String(now)); } catch (_error) { // Ignore storage failures; the server-side generation lock still protects the job. } triggerGeneration(true); } } } function escapeHtml(value) { if (!value) return ''; return String(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]); } function insightLabel(value) { const insightLabelMap = { product_pick: '選品攻擊', price_recommendation: '價格建議', competitor_price: '競品價格', sales_anomaly: '業績異常', budget_strategy: '預算策略', rag_feedback: 'RAG 反饋', ppt_audit: 'PPT 審核', quality_issue: '品質問題', promotion: '活動促銷', market_signal: '市場訊號', strategy: '策略洞察' }; return insightLabelMap[value] || String(value || '未分類洞察').replaceAll('_', ' '); } window.showHits = async function showHits(queryId) { const modalEl = document.getElementById('hitsModal'); const body = document.getElementById('hitsModalBody'); if (!modalEl || !body) return; body.innerHTML = '
載入中...
'; const modal = new bootstrap.Modal(modalEl); modal.show(); try { const response = await fetch(`/observability/rag_queries/${queryId}/hits`); const data = await response.json(); if (!data.ok) { body.innerHTML = `
❌ ${escapeHtml(data.error || '載入失敗')}
`; return; } let html = `
查詢 #${data.query_id} · 門檻 ${data.threshold} · 命中 ${data.hit_count}
查詢內容:
${escapeHtml(data.query_text || '')}
`; if (!data.hits || data.hits.length === 0) { html += '
無命中詳細資料
'; } else { html += '
主要命中內容預覽:
'; data.hits.forEach(hit => { html += `
#${hit.id}${escapeHtml(insightLabel(hit.insight_type))}${hit.period ? `${escapeHtml(hit.period)}` : ''}${hit.product_sku ? `SKU: ${escapeHtml(hit.product_sku)}` : ''}${escapeHtml(hit.created_at || '')}
${escapeHtml(hit.content || '')}${hit.content && hit.content.length >= 300 ? '…' : ''}
`; }); } body.innerHTML = html; } catch (error) { console.warn('rag_query_hits_load_failed', error); body.innerHTML = '
❌ 召回詳情暫時無法載入,請稍後再試或查看系統日誌。
'; } }; function boot() { renderOverviewHostSparklines(); renderBusinessVerdict(); renderAiCalls(); renderBudgetCharts(); renderHostHealth(); renderPromotionReview(); renderQualityTrend(); renderPptAudit(); initPptAutoGeneration(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } })();