Files
tech-service-portal/app/static/assistant.js
T

112 lines
8.4 KiB
JavaScript

"use strict";
// AI-помощник: кнопка в правом нижнем углу на всех экранах (полная версия и мобильная /m).
// Показывается, только если пользователь вошёл и администратор включил помощника.
(function () {
const API_ = "/api/v1", MOBILE = location.pathname.startsWith("/m");
const el = (t, a, ...k) => { const e = document.createElement(t); for (const [n, v] of Object.entries(a || {})) { if (v == null || v === false) continue; if (n.startsWith("on")) e.addEventListener(n.slice(2), v); else if (n === "class") e.className = v; else e.setAttribute(n, v); } for (const c of k.flat()) if (c != null && c !== false) e.append(c.nodeType ? c : document.createTextNode(c)); return e; };
const req = (path, opts = {}) => {
const f = typeof authFetch === "function" ? authFetch : (u, o) => fetch(u, { ...o, headers: { ...(o.headers || {}), Authorization: "Bearer " + localStorage.getItem("token") } });
return f(API_ + path, opts);
};
let history = [];
try { history = JSON.parse(sessionStorage.getItem("assistant") || "[]"); } catch (e) { history = []; }
const save = () => { try { sessionStorage.setItem("assistant", JSON.stringify(history.slice(-20))); } catch (e) {} };
const style = el("style", {});
style.textContent = `
#ai-btn{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:70;width:56px;height:56px;border-radius:50%;border:0;padding:0;
background:linear-gradient(135deg,#2563eb,#7c3aed);color:#fff;font-size:26px;cursor:pointer;box-shadow:0 6px 20px rgba(37,99,235,.45);display:none;align-items:center;justify-content:center}
#ai-btn.on{display:flex}
#ai-panel{position:fixed;right:18px;bottom:calc(84px + env(safe-area-inset-bottom,0px));z-index:71;width:min(380px,calc(100vw - 24px));height:min(720px,calc(100vh - 120px));
background:var(--card,#fff);color:var(--text,#1d2330);border:1px solid var(--line,#dfe3ea);border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,.25);display:none;flex-direction:column;overflow:hidden;font:15px/1.45 system-ui,sans-serif}
#ai-panel.open{display:flex}
#ai-panel header{all:unset;display:flex;align-items:center;gap:8px;padding:12px 14px;background:linear-gradient(135deg,#2563eb,#7c3aed);color:#fff}
#ai-panel header b{flex:1}#ai-panel header button{all:unset;cursor:pointer;font-size:18px;padding:0 6px;color:#fff}
#ai-log{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px}
.ai-m{max-width:88%;padding:8px 12px;border-radius:12px;white-space:pre-wrap;word-wrap:break-word}
.ai-m.u{align-self:flex-end;background:#2563eb;color:#fff;border-bottom-right-radius:4px}
.ai-m.a{align-self:flex-start;background:var(--bg,#f4f5f8);border-bottom-left-radius:4px}
.ai-m.a a{color:#2563eb;font-weight:600}.ai-m.e{align-self:center;color:#c62828;font-size:13px}
.ai-hint{font-size:12px;color:var(--muted,#6b7385);text-align:center;padding:2px 12px}
#ai-form{display:flex;gap:6px;padding:10px;border-top:1px solid var(--line,#dfe3ea)}
#ai-form textarea{flex:1;resize:none;height:44px;min-height:44px;font:inherit;padding:10px;border:1px solid var(--line,#dfe3ea);border-radius:10px;background:var(--bg,#f4f5f8);color:inherit}
#ai-form button{border:0;border-radius:10px;background:#2563eb;color:#fff;font-weight:700;padding:0 14px;cursor:pointer;min-height:44px}
#ai-form button:disabled{opacity:.5}
.ai-sug{display:flex;flex-wrap:wrap;gap:6px;padding:0 12px 6px}.ai-sug button{border:1px solid var(--line,#dfe3ea);background:none;color:inherit;border-radius:99px;padding:4px 10px;font-size:13px;cursor:pointer}
@media(max-width:600px){#ai-panel{right:6px;left:6px;width:auto;bottom:calc(80px + env(safe-area-inset-bottom,0px));height:calc(100vh - 110px)}}`;
document.head.append(style);
const log = el("div", { id: "ai-log" });
const ta = el("textarea", { placeholder: "Спросите, где что найти или как сделать…", maxlength: "1000" });
const send = el("button", { type: "submit" }, "➤");
const sug = el("div", { class: "ai-sug" });
const panel = el("div", { id: "ai-panel", role: "dialog", "aria-label": "Помощник" },
el("header", {}, "🤖", el("b", {}, "Помощник по порталу"),
el("button", { title: "Очистить", onclick: () => { history = []; save(); draw(); } }, "⟲"),
el("button", { title: "Закрыть", onclick: () => panel.classList.remove("open") }, "✕")),
log, sug, el("div", { class: "ai-hint" }, "Отвечает только о работе в портале и видит только ваши данные"),
el("form", { id: "ai-form", onsubmit: e => { e.preventDefault(); ask(ta.value); } }, ta, send));
const btn = el("button", { id: "ai-btn", title: "Помощник", "aria-label": "Помощник", onclick: () => { panel.classList.toggle("open"); if (panel.classList.contains("open")) { draw(); ta.focus(); } } }, "🤖");
ta.addEventListener("keydown", e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(ta.value); } });
// ответ модели: только безопасный текст; ссылки — лишь на разделы портала (#/…, /report, /m)
function rich(text) {
const out = [], re = /\[([^\]]{1,80})\]\((#\/[^\s)]*|\/report|\/m)\)|\*\*([^*]{1,120})\*\*/g;
let last = 0, m;
while ((m = re.exec(text))) {
if (m.index > last) out.push(text.slice(last, m.index));
if (m[1]) {
const href = MOBILE && m[2].startsWith("#/") ? "/" + m[2] : m[2];
out.push(el("a", { href, onclick: () => { if (window.innerWidth < 600) panel.classList.remove("open"); } }, m[1]));
} else out.push(el("b", {}, m[3]));
last = re.lastIndex;
}
out.push(text.slice(last));
return out;
}
function draw() {
log.replaceChildren(...(history.length ? [] : [el("div", { class: "ai-m a" }, "Здравствуйте! Я подскажу, где что найти в портале и как выполнить действие, а также покажу ваши задания, наряды и выданный инструмент.")]),
...history.map(m => el("div", { class: "ai-m " + (m.role === "user" ? "u" : m.error ? "e" : "a") }, m.role === "user" ? m.content : rich(m.content))));
sug.replaceChildren(...(history.length ? [] : ["Какие у меня задания на сегодня?", "Как зафиксировать аварию?", "Где мой инструмент?", "Как подать заявку на инструмент?"]
.map(q => el("button", { type: "button", onclick: () => ask(q) }, q))));
log.scrollTop = log.scrollHeight;
}
async function ask(q) {
q = (q || "").trim();
if (!q || send.disabled) return;
ta.value = "";
history.push({ role: "user", content: q });
draw();
send.disabled = true;
const wait = el("div", { class: "ai-m a" }, "…");
log.append(wait); log.scrollTop = log.scrollHeight;
try {
const r = await req("/assistant/chat", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history.filter(m => !m.error).slice(-10).map(m => ({ role: m.role, content: m.content })) }) });
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(typeof d.detail === "string" ? d.detail : "Помощник недоступен");
history.push({ role: "assistant", content: d.reply });
} catch (e) {
history.push({ role: "assistant", content: e instanceof TypeError ? "Нет связи с сервером" : e.message, error: true });
} finally { send.disabled = false; save(); draw(); }
}
let checked = 0;
async function check() {
if (!localStorage.getItem("token")) { btn.classList.remove("on"); panel.classList.remove("open"); return; }
if (Date.now() - checked < 60000 && btn.dataset.state) return;
checked = Date.now();
try {
const r = await req("/assistant/status");
const on = r.ok && (await r.json()).enabled;
btn.classList.toggle("on", !!on); btn.dataset.state = on ? "on" : "off";
if (!on) panel.classList.remove("open");
} catch (e) { /* нет связи — кнопку не трогаем */ }
}
document.body.append(btn, panel);
window.addEventListener("hashchange", check);
setInterval(check, 15000);
setTimeout(check, 800);
window.assistantRefresh = () => { checked = 0; delete btn.dataset.state; check(); };
})();