Merge pull request #32 from pavlov346346-source/feature/support-bot

Add AI support assistant to the Help dialog
This commit is contained in:
pavlov346346-source 2026-09-19 18:42:50 +03:00 committed by GitHub
commit b99bb30aa1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 65 additions and 7 deletions

View File

@ -0,0 +1,33 @@
# AI support assistant in Help
The Help dialog's "Поддержка" tab can now open an AI assistant that answers
"how do I..." questions from the Caterium handbook (the same articles as the
"Руководство" tab).
Privacy and isolation:
- Nothing is requested from the assistant service until the user presses
"Задать вопрос помощнику". Opening Help, searching the handbook and switching
tabs stay fully local, exactly as before.
- The chat runs in an `<iframe>` served from the assistant's own origin
(`sandbox`, `referrerpolicy="no-referrer"`). It cannot read orders, clients,
local storage or the Supabase session of Caterium, and no third-party script
is added to the app page.
- The assistant page sets `frame-ancestors` to `https://app.caterium.ru`, so no
other site can embed it.
- The assistant has no access to company data and cannot change anything in
Caterium. The tab tells users not to type passwords, confirmation codes or
customer data because their question is processed by an external service.
Assistant configuration (agent, instructions, knowledge base and rate limits)
lives in the assistant service, not in this repository. The client only needs
`BOT.origin` and `BOT.agent` in `public/core/help-center.js`.
If the assistant service is unreachable, only the chat area is affected (it
shows the browser's or the assistant's own error page); the handbook and its
search keep working.
Regression coverage: `tests/help-center.spec.mjs` asserts that no external
request is made before the click, and that the iframe uses the expected
`src`, `referrerpolicy` and a restrictive `sandbox` (no top navigation, forms
or modals). No database, migration or permission change.

View File

@ -35,3 +35,8 @@
.ct-help-login{display:block!important;margin:16px auto 0!important;background:transparent!important;border:0!important;color:#5b655b!important;text-decoration:underline;min-height:44px;font-size:14px!important;cursor:pointer}
@media(max-width:600px){#ctHelpDialog{border-radius:14px}#ctHelpDialog h2{font-size:20px}#ctHelpDialog .ct-help-header,#ctHelpDialog .ct-help-content{padding:16px}#ctHelpDialog .ct-help-modes{padding:10px 16px}#ctHelpDialog .ct-help-filters{grid-template-columns:1fr}#ctHelpDialog .ct-help-prompts{flex-direction:column}}
@media print{#ctHelpDialog{display:none!important}}
#ctHelpDialog .ct-help-bot-start{margin:4px 0 16px;padding:11px 18px;border:0;border-radius:10px;background:#3b5340;color:#fff;font:inherit;font-weight:700;cursor:pointer}
#ctHelpDialog .ct-help-bot-start:hover{background:#2f4434}
#ctHelpDialog .ct-help-bot{height:min(440px,58vh);margin:4px 0 16px;border:1px solid #d9ddd2;border-radius:12px;overflow:hidden;background:#fff}
#ctHelpDialog .ct-help-bot iframe{display:block;width:100%;height:100%;border:0}

View File

@ -4,6 +4,7 @@
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;
@ -16,6 +17,15 @@
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));
@ -34,12 +44,12 @@
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>Бот и канал обращений пока не подключены. Сейчас можно найти готовую инструкцию. Поиск работает внутри руководства и не отправляет вопросы или данные вашей компании внешнему сервису.</p><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>`;
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('#ctHelpGuideTab').onclick=()=>switchMode(false);dialog.querySelector('#ctHelpSupportTab').onclick=()=>switchMode(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=>{

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu';
const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot';
const VERSION='20260918-ui-stability';
const CORE=[
'./core/catalog-pricing.js?v=20260919-client-menu','./core/client-menu.css?v=20260919-client-menu',

View File

@ -24,11 +24,21 @@ test('manual search, categories and related instructions work without sending co
expect(await page.evaluate(()=>localStorage.sunOrders)).toBe('private-order-canary');
});
test('support clearly stays offline, question opens a guide and dialog fits mobile',async({page})=>{
await fixture(page);await page.getByRole('button',{name:'Помощь',exact:true}).click();
test('support assistant connects only after an explicit click, inside a sandboxed iframe',async({page})=>{
await fixture(page);
const external=[];page.on('request',r=>{if(!r.url().startsWith('http://127.0.0.1'))external.push(r.url())});
await page.route('https://ai-staff-alpha.vercel.app/**',r=>r.fulfill({contentType:'text/html',body:'<!doctype html><title>stub</title>'}));
await page.getByRole('button',{name:'Помощь',exact:true}).click();
await expect(page.locator('#ctHelpResults details')).toHaveCount(kb.articles.length);
await page.getByRole('button',{name:'Поддержка',exact:true}).click();await expect(page.locator('#ctHelpSupport')).toContainText('пока не подключены');
await page.getByRole('button',{name:'Поддержка',exact:true}).click();await expect(page.locator('#ctHelpSupport')).toContainText('внешний сервис');
await page.getByRole('button',{name:'Как отметить оплату?',exact:true}).click();await expect(page.getByRole('searchbox')).toHaveValue('оплата');
expect(external).toEqual([]);
await page.getByRole('button',{name:'Поддержка',exact:true}).click();
await page.getByRole('button',{name:'Задать вопрос помощнику',exact:true}).click();
const frame=page.locator('#ctHelpBot iframe');
await expect(frame).toHaveAttribute('src',/^https:\/\/ai-staff-alpha\.vercel\.app\/embed\/[0-9a-f-]{36}$/);
await expect(frame).toHaveAttribute('referrerpolicy','no-referrer');
const sandbox=await frame.getAttribute('sandbox');expect(sandbox).toContain('allow-scripts');expect(sandbox).not.toMatch(/allow-top-navigation|allow-forms|allow-modals/);
expect(await page.locator('#ctHelpDialog').evaluate(el=>el.getBoundingClientRect().width<=innerWidth&&el.scrollWidth<=el.clientWidth)).toBe(true);
await page.keyboard.press('Escape');await expect(page.locator('#ctHelpDialog')).not.toBeVisible();await expect(page.getByRole('button',{name:'Помощь',exact:true})).toBeFocused();
});