Merge pull request #32 from pavlov346346-source/feature/support-bot
Add AI support assistant to the Help dialog
This commit is contained in:
commit
b99bb30aa1
33
docs/releases/2026-09-19-SUPPORT-BOT.md
Normal file
33
docs/releases/2026-09-19-SUPPORT-BOT.md
Normal 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.
|
||||
@ -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}
|
||||
|
||||
@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
@ -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',
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user