caterium-app/public/core/help-center.js
pavlo d7d6f6ff38 Add AI support assistant to the Help dialog
The Поддержка tab opens an assistant that answers how-to questions from
the handbook. The chat is a sandboxed iframe on the assistant's own
origin and is only loaded after an explicit click, so Help and its search
stay local and no third-party script runs inside the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 18:27:47 +03:00

73 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(()=>{
'use strict';
const version=new URL(document.currentScript.src).searchParams.get('v')||'1';
const url=new URL('../help/knowledge-v1.json',document.currentScript.src);url.searchParams.set('v',version);
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const norm=s=>String(s).toLowerCase().replace(/ё/g,'е');
const BOT={origin:"https://ai-staff-alpha.vercel.app",agent:"e9cc0f28-aaa5-48d4-a020-ae9633988faf"};
let dialog,data,loading=false,returnFocus;
function render(){
if(!data||!dialog)return;
const query=norm(dialog.querySelector('#ctHelpSearch').value).trim(),category=dialog.querySelector('#ctHelpCategory').value;
const terms=query.split(/\s+/).filter(Boolean);
const rows=data.articles.filter(a=>!category||a.category===category).map(a=>({a,score:terms.reduce((n,t)=>n+(norm(a.title+' '+a.keywords).includes(t)?3:0)+(norm(a.steps.join(' ')+' '+a.note).includes(t)?1:0),0)})).filter(r=>!terms.length||r.score>0).sort((a,b)=>b.score-a.score);
dialog.querySelector('#ctHelpStatus').textContent=rows.length?`Инструкций: ${rows.length}`:'Ничего не найдено. Попробуйте «оплата», «PDF», «склад» или откройте все темы.';
dialog.querySelector('#ctHelpResults').innerHTML=rows.map(({a})=>`<details data-article="${esc(a.id)}"><summary>${esc(a.title)}<small>${esc(a.category)}</small></summary><ol>${a.steps.map(s=>`<li>${esc(s)}</li>`).join('')}</ol><p class="ct-help-note">${esc(a.note)}</p><div class="ct-help-related">${(a.related||[]).map(id=>{const r=data.articles.find(x=>x.id===id);return r?`<button type="button" data-help-article="${esc(id)}">${esc(r.title)}</button>`:''}).join('')}</div></details>`).join('');
}
function showGuide(query=''){
switchMode(false);dialog.querySelector('#ctHelpSearch').value=query;dialog.querySelector('#ctHelpCategory').value='';render();dialog.querySelector('#ctHelpSearch').focus();
}
function startBot(){
const box=dialog.querySelector("#ctHelpBot"),start=dialog.querySelector("#ctHelpBotStart");
if(box.querySelector("iframe"))return;
// The chat lives in a sandboxed iframe on the assistant's own origin: nothing from the app (orders, clients, session) is reachable from it, and no request leaves the device until the user asks for the assistant.
const frame=document.createElement("iframe");
frame.src=`${BOT.origin}/embed/${BOT.agent}`;frame.title="ИИ-помощник Caterium";frame.loading="eager";frame.referrerPolicy="no-referrer";
frame.setAttribute("sandbox","allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox");
box.textContent="";box.hidden=false;box.appendChild(frame);start.hidden=true;
}
function switchMode(support){
dialog.querySelector('#ctHelpGuide').hidden=support;dialog.querySelector('#ctHelpSupport').hidden=!support;
dialog.querySelector('#ctHelpGuideTab').setAttribute('aria-pressed',String(!support));dialog.querySelector('#ctHelpSupportTab').setAttribute('aria-pressed',String(support));
}
async function load(){
if(data){render();return;}if(loading)return;loading=true;
const status=dialog.querySelector('#ctHelpStatus'),retry=dialog.querySelector('#ctHelpRetry');status.textContent='Загружаю руководство…';retry.hidden=true;
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),8000);
try{
const r=await fetch(url,{signal:controller.signal,credentials:'omit'});if(!r.ok)throw new Error('http');const value=await r.json();
if(!Array.isArray(value.articles)||!value.articles.every(a=>typeof a.id==='string'&&typeof a.title==='string'&&typeof a.category==='string'&&Array.isArray(a.steps)&&a.steps.every(s=>typeof s==='string')))throw new Error('format');
data=value;dialog.querySelector('#ctHelpCategory').innerHTML='<option value="">Все темы</option>'+[...new Set(data.articles.map(a=>a.category))].map(c=>`<option>${esc(c)}</option>`).join('');
dialog.querySelector('#ctHelpEdition').textContent=`Руководство · редакция ${data.version}`;render();
}catch(_){status.textContent='Не удалось загрузить руководство. Проверьте соединение и повторите.';retry.hidden=false;}finally{loading=false;clearTimeout(timer);}
}
function ensureDialog(){
if(dialog)return;
dialog=document.createElement('dialog');dialog.id='ctHelpDialog';dialog.setAttribute('aria-labelledby','ctHelpTitle');
dialog.innerHTML=`<div class="ct-help-header"><div><h2 id="ctHelpTitle">Помощь в Caterium</h2><p id="ctHelpEdition">Руководство пользователя и поддержка</p></div><button type="button" id="ctHelpClose" aria-label="Закрыть помощь">×</button></div><div class="ct-help-modes" aria-label="Раздел помощи"><button type="button" id="ctHelpGuideTab" aria-pressed="true">Руководство</button><button type="button" id="ctHelpSupportTab" aria-pressed="false">Поддержка</button></div><div class="ct-help-content"><section id="ctHelpGuide"><div class="ct-help-filters"><label>Что хотите сделать?<input id="ctHelpSearch" type="search" placeholder="Например: оплата, PDF, склад" maxlength="240" autocomplete="off"></label><label>Категория<select id="ctHelpCategory"><option value="">Все темы</option></select></label><button type="button" id="ctHelpReset">Все инструкции</button></div><p id="ctHelpStatus" role="status"></p><button type="button" id="ctHelpRetry" hidden>Повторить загрузку</button><div id="ctHelpResults"></div></section><section id="ctHelpSupport" hidden><span class="ct-help-badge">ИИ-помощник</span><h3>Ответы по работе с приложением</h3><p>Помощник отвечает на вопросы «как это сделать» по руководству Caterium. Он не видит данные вашей компании, но ваш вопрос обрабатывает внешний сервис, поэтому не пишите пароли, коды подтверждения и данные клиентов. Поиск по руководству остаётся на этом устройстве и ничего не отправляет.</p><button type="button" id="ctHelpBotStart" class="ct-help-bot-start">Задать вопрос помощнику</button><div id="ctHelpBot" class="ct-help-bot" hidden></div><div class="ct-help-prompts"><button type="button" data-help-query="создать заказ">Как создать заказ?</button><button type="button" data-help-query="оплата">Как отметить оплату?</button><button type="button" data-help-query="закупки">Как рассчитать закупки?</button><button type="button" data-help-query="синхронизация">Не загружается база</button></div><h3>Если инструкция не помогла</h3><p>Подготовьте название раздела, последовательность действий, точный текст ошибки, устройство и браузер. На снимке экрана закройте лишние телефоны и адреса. Не передавайте пароли и коды подтверждения.</p><button type="button" data-help-query="обращение">Памятка для обращения</button></section></div>`;
document.body.appendChild(dialog);
dialog.querySelector('#ctHelpClose').onclick=()=>dialog.close();
dialog.addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();e.stopPropagation();dialog.close();}});
dialog.addEventListener('close',()=>{dialog.querySelector('#ctHelpSearch').value='';if(returnFocus?.isConnected)returnFocus.focus({preventScroll:true});});
dialog.querySelector('#ctHelpBotStart').onclick=startBot;dialog.querySelector('#ctHelpGuideTab').onclick=()=>switchMode(false);dialog.querySelector('#ctHelpSupportTab').onclick=()=>switchMode(true);
dialog.querySelector('#ctHelpSearch').oninput=render;dialog.querySelector('#ctHelpCategory').onchange=render;
dialog.querySelector('#ctHelpReset').onclick=()=>showGuide();dialog.querySelector('#ctHelpRetry').onclick=load;
dialog.addEventListener('click',e=>{
const q=e.target.closest('[data-help-query]');if(q){showGuide(q.dataset.helpQuery);return;}
const a=e.target.closest('[data-help-article]');if(a&&data){showGuide();const target=[...dialog.querySelectorAll('details')].find(x=>x.dataset.article===a.dataset.helpArticle);if(target){target.open=true;target.scrollIntoView({block:'nearest'});target.querySelector('summary').focus();}}
});
}
function open(trigger){ensureDialog();returnFocus=trigger?.currentTarget||trigger||document.activeElement;switchMode(false);if(!dialog.open)dialog.showModal();load();dialog.querySelector('#ctHelpSearch').focus();}
function mount(){
const nav=document.querySelector('header nav');
if(nav&&!document.getElementById('ctHelpNav')){const b=document.createElement('button');b.id='ctHelpNav';b.type='button';b.dataset.navLabel='Помощь';b.textContent='Помощь';b.onclick=open;nav.appendChild(b);window.sunRegisterNavButton?.({button:b,group:'management',navLabel:'Помощь'});}
const gate=document.getElementById('sunCloudAuthGateV3');
if(gate&&!gate.querySelector('.ct-help-login')){const b=document.createElement('button');b.type='button';b.className='ct-help-login';b.textContent='Помощь со входом';b.onclick=()=>{open(b);showGuide('вход');};(gate.querySelector('.sun-cloud-auth-card')||gate).appendChild(b);}
}
function boot(){mount();let frame=0;new MutationObserver(()=>{if(frame)return;frame=requestAnimationFrame(()=>{frame=0;mount();});}).observe(document.body,{childList:true,subtree:true});
const context=()=>`${window.SunCloudV2?.getSession?.()?.user?.id||''}:${window.SunCloudV2?.getWorkspace?.()?.id||''}`;
let current=context();window.addEventListener('sun:cloud-permissions-changed',()=>{const next=context();if(next!==current&&dialog?.open)dialog.close();current=next;});
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
})();