feat: shared AI cache via git (ai-cache.json)

- AI analysis loaded from git on startup (same for all users)
- Saving new response commits ai-cache.json via Gitea API
- Requires GITEA_TOKEN in config.js to update cache
- Update UI: auto-expanded result, collapsible refresh section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyrykbaev-I 2026-06-01 10:43:20 +05:00
parent 85f344eae6
commit 40882b62ea
2 changed files with 122 additions and 9 deletions

5
ai-cache.json Normal file
View File

@ -0,0 +1,5 @@
{
"generated_at": null,
"period": null,
"response": null
}

View File

@ -159,6 +159,15 @@ body { font-family: var(--font-base); background: var(--color-bg); color: var(--
.ai-divider { height: 1px; background: var(--color-border); margin: 12px 0; }
.ai-hint { font-size: 11px; color: var(--color-text-secondary); text-align: center; line-height: 1.5; }
.ai-key-note { font-size: 11px; color: var(--color-green); text-align: center; margin-bottom: 8px; font-weight: 500; }
.ai-cache-meta { display: flex; align-items: center; gap: 4px; margin-bottom: 6px; }
.ai-update-details { margin-top: 4px; }
.ai-update-details[open] .ai-update-summary { color: var(--color-brand); }
.ai-update-summary {
font-size: 13px; font-weight: 600; color: var(--color-text-secondary);
cursor: pointer; list-style: none; padding: 6px 0; user-select: none;
}
.ai-update-summary::-webkit-details-marker { display: none; }
.ai-update-summary:hover { color: var(--color-brand); }
/* ── RESPONSIVE ── */
@media (max-width: 1200px) { .summary-bar { grid-template-columns: repeat(3,1fr); } }
@ -271,16 +280,30 @@ body { font-family: var(--font-base); background: var(--color-bg); color: var(--
<!-- AI Panel -->
<div class="ai-panel">
<div class="ai-panel-title">🤖 AI-анализ KPI</div>
<div class="ai-api-row">
<div id="ai-key-note" class="ai-key-note hidden">✓ Токен загружен из config.js</div>
<input type="password" class="api-key-input" id="ai-api-key" placeholder="Введите DeepSeek API ключ">
<button class="btn-primary" id="btn-ai-analyze">Запустить анализ</button>
<!-- Cached response (shown to all users automatically) -->
<div class="ai-loading hidden" id="ai-loading"><div class="spinner"></div><span>Получаем анализ...</span></div>
<div class="ai-result hidden" id="ai-result">
<div class="ai-cache-meta">
<span id="ai-cache-date" style="font-size:11px;color:var(--color-text-secondary);">Загрузка...</span>
<span style="font-size:11px;color:var(--color-text-secondary);">· общий для всех</span>
</div>
<div id="ai-text"></div>
<button class="btn-copy" id="btn-ai-copy">📋 Скопировать</button>
</div>
<div class="ai-loading hidden" id="ai-loading"><div class="spinner"></div><span>Анализируем данные...</span></div>
<div class="ai-result hidden" id="ai-result"><div id="ai-text"></div><button class="btn-copy" id="btn-ai-copy">📋 Скопировать</button></div>
<div class="ai-error hidden" id="ai-error"></div>
<!-- Update section: only useful if you have DeepSeek key -->
<div class="ai-divider"></div>
<p class="ai-hint">DeepSeek API · Ключ не сохраняется за пределами сессии браузера</p>
<details class="ai-update-details">
<summary class="ai-update-summary">🔄 Обновить анализ</summary>
<div class="ai-api-row" style="margin-top:10px;">
<div id="ai-key-note" class="ai-key-note hidden">✓ DeepSeek токен загружен из config.js</div>
<input type="password" class="api-key-input" id="ai-api-key" placeholder="DeepSeek API ключ">
<button class="btn-primary" id="btn-ai-analyze">Запустить и сохранить для всех</button>
</div>
<p class="ai-hint" style="margin-top:8px;">Новый ответ сохранится в репо и сразу станет доступен всем пользователям. Обновляйте раз в месяц при новых данных.</p>
</details>
</div>
</div>
</div>
@ -444,7 +467,12 @@ const EMBEDDED_CSV = `"report_period_id";"entry_date";"abons";"registered_total"
// ─────────────────────────────────────────────────────────────────────────────
const MONTH_NAMES = ['','Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя','Дек'];
const CSV_FILENAME = 'drb_iliyas_kpi_2026.csv';
const CSV_FILENAME = 'drb_iliyas_kpi_2026.csv';
const GITEA_API = 'https://git.telecom.quest/api/v1';
const GITEA_OWNER = 'Kyrykbaev-I';
const GITEA_REPO = 'kpi-dashboard';
const AI_CACHE_FILE = 'ai-cache.json';
const AI_CACHE_RAW = `https://git.telecom.quest/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/main/${AI_CACHE_FILE}`;
const AppState = {
rawRows: [], snapshots: [], dailySeries: {},
@ -696,7 +724,7 @@ function showDashboard() {
document.getElementById('dashboard').style.display = 'block';
renderHeader(); renderSummaryBar(); renderToolbar();
destroyAllCharts(); renderAllCharts();
restoreAiCache(); initAiKey();
loadAiCache(); initAiKey();
}
function renderHeader() {
@ -1120,6 +1148,7 @@ async function runAiAnalysis() {
AppState.lastAiResponse=text;
sessionStorage.setItem('lastAiResponse',text);
showAiResult(text);
await saveAiCache(text); // save to git repo for all users (requires GITEA_TOKEN in config.js)
} catch(e) {
clearTimeout(t);
showAiError(e.name==='AbortError'?'Превышено время ожидания. Попробуйте снова.':'Ошибка: '+e.message);
@ -1131,6 +1160,85 @@ function showAiResult(text){document.getElementById('ai-text').textContent=text;
function showAiError(msg){const el=document.getElementById('ai-error');if(msg){el.textContent=msg;el.classList.remove('hidden');}else el.classList.add('hidden');}
function restoreAiCache(){const c=sessionStorage.getItem('lastAiResponse');if(c)showAiResult(c);}
// ═══════════════════ AI CACHE (git-based, shared for all users) ═══════════════════
/** Load AI response from ai-cache.json stored in the git repo */
async function loadAiCache() {
const dateEl = document.getElementById('ai-cache-date');
try {
const resp = await fetch(AI_CACHE_RAW, {cache:'no-cache'});
if (!resp.ok) { if(dateEl) dateEl.textContent = 'Кэш не найден'; return; }
const data = await resp.json();
if (!data.response) return;
AppState.lastAiResponse = data.response;
showAiResult(data.response);
if (dateEl && data.generated_at) {
const d = new Date(data.generated_at);
dateEl.textContent = 'Обновлено: ' + d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'});
}
} catch(e) {
if(dateEl) dateEl.textContent = 'Кэш недоступен';
console.info('AI cache not loaded:', e.message);
}
}
/** Save AI response to ai-cache.json in git via Gitea API.
* Requires window.GITEA_TOKEN (from config.js). */
async function saveAiCache(text) {
const token = window.GITEA_TOKEN;
if (!token) return; // no write access — cache stays local only
const lastSnap = AppState.snapshots[AppState.snapshots.length-1];
const payload = {
generated_at: new Date().toISOString(),
period: periodLabel(lastSnap.report_period_id),
response: text,
};
// base64-encode the JSON (Gitea API requires base64 content)
const content = btoa(unescape(encodeURIComponent(JSON.stringify(payload, null, 2))));
// Fetch current file SHA (needed for update; absent for first create)
let sha = null;
try {
const fileResp = await fetch(
`${GITEA_API}/repos/${GITEA_OWNER}/${GITEA_REPO}/contents/${AI_CACHE_FILE}`,
{headers:{'Authorization':`token ${token}`}}
);
if (fileResp.ok) { sha = (await fileResp.json()).sha; }
} catch {}
const body = JSON.stringify({
message: `Update AI analysis cache — ${payload.period}`,
content,
...(sha ? {sha} : {}),
});
try {
const saveResp = await fetch(
`${GITEA_API}/repos/${GITEA_OWNER}/${GITEA_REPO}/contents/${AI_CACHE_FILE}`,
{
method: 'PUT',
headers: {
'Authorization': `token ${token}`,
'Content-Type': 'application/json',
},
body,
}
);
if (saveResp.ok) {
const dateEl = document.getElementById('ai-cache-date');
if (dateEl) dateEl.textContent = 'Обновлено: ' +
new Date().toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'});
console.info('AI cache saved to git ✓');
} else {
console.warn('AI cache save failed:', saveResp.status);
}
} catch(e) {
console.warn('AI cache save error:', e.message);
}
}
// ═══════════════════ EVENTS ═══════════════════
document.getElementById('file-input').addEventListener('change',function(e){
const file=e.target.files[0]; if(!file)return;