chatwoot-ai-tools.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. (() => {
  2. 'use strict';
  3. if (window.__EKS_CHATWOOT_AI_TOOLS__) return;
  4. window.__EKS_CHATWOOT_AI_TOOLS__ = true;
  5. const RELAY_ORIGIN = new URL(document.currentScript?.src || 'https://eks-relay.easyklima.com/chatwoot-ai-tools.js').origin;
  6. const STORE_KEY = 'eksAgentUiToken';
  7. const PANEL_ID = 'eks-ai-translate-panel';
  8. const BUTTON_ID = 'eks-ai-translate-toggle';
  9. const POPOVER_ID = 'eks-ai-translate-popover';
  10. const state = { busy: false, result: '', error: '' };
  11. function ensureStyles() {
  12. if (document.getElementById('eks-ai-translate-styles')) return;
  13. const style = document.createElement('style');
  14. style.id = 'eks-ai-translate-styles';
  15. style.textContent = `
  16. #${BUTTON_ID} {
  17. position: fixed; right: 18px; bottom: 18px; z-index: 2147483000;
  18. border: 0; border-radius: 999px; padding: 10px 14px;
  19. background: #1f6feb; color: white; font: 600 13px system-ui, sans-serif;
  20. box-shadow: 0 8px 22px rgba(15,23,42,.25); cursor: pointer;
  21. }
  22. #${PANEL_ID}, #${POPOVER_ID} {
  23. position: fixed; z-index: 2147483000; background: #fff; color: #0f172a;
  24. border: 1px solid #d7dee8; border-radius: 14px;
  25. box-shadow: 0 18px 48px rgba(15,23,42,.28); font: 13px system-ui, sans-serif;
  26. }
  27. #${PANEL_ID} {
  28. right: 18px; bottom: 66px; width: min(420px, calc(100vw - 36px));
  29. max-height: min(720px, calc(100vh - 92px)); overflow: auto; padding: 14px;
  30. }
  31. #${POPOVER_ID} { right: 18px; top: 90px; width: min(460px, calc(100vw - 36px)); max-height: 55vh; overflow: auto; padding: 12px; }
  32. #${PANEL_ID}[hidden], #${POPOVER_ID}[hidden] { display: none; }
  33. #${PANEL_ID} h3, #${POPOVER_ID} h3 { margin: 0 0 10px; font-size: 15px; }
  34. #${PANEL_ID} label { display: block; margin: 10px 0 4px; font-weight: 600; }
  35. #${PANEL_ID} input, #${PANEL_ID} textarea {
  36. width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px;
  37. padding: 8px; font: 13px system-ui, sans-serif; background: #fff; color: #0f172a;
  38. }
  39. #${PANEL_ID} textarea { min-height: 88px; resize: vertical; }
  40. #${PANEL_ID} .eks-row, #${POPOVER_ID} .eks-row { display: flex; gap: 8px; align-items: center; }
  41. #${PANEL_ID} .eks-row > * { flex: 1; }
  42. #${PANEL_ID} button, #${POPOVER_ID} button {
  43. border: 0; border-radius: 8px; padding: 8px 10px; background: #1f6feb; color: white;
  44. font: 600 13px system-ui, sans-serif; cursor: pointer;
  45. }
  46. #${PANEL_ID} button.secondary, #${POPOVER_ID} button.secondary { background: #e2e8f0; color: #0f172a; }
  47. #${PANEL_ID} button:disabled, #${POPOVER_ID} button:disabled { opacity: .55; cursor: wait; }
  48. #${PANEL_ID} .eks-muted, #${POPOVER_ID} .eks-muted { color: #64748b; font-size: 12px; }
  49. #${PANEL_ID} .eks-error, #${POPOVER_ID} .eks-error { color: #b91c1c; white-space: pre-wrap; }
  50. #${PANEL_ID} .eks-result, #${POPOVER_ID} .eks-result { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; white-space: pre-wrap; min-height: 44px; }
  51. .eks-inline-translate {
  52. position: absolute !important; top: 4px !important; right: 4px !important; z-index: 6 !important;
  53. border: 0 !important; border-radius: 999px !important; padding: 1px 5px !important;
  54. background: rgba(224,242,254,.94) !important; color: #075985 !important;
  55. font: 700 9px system-ui, sans-serif !important; line-height: 14px !important; cursor: pointer !important;
  56. opacity: .72 !important; box-shadow: 0 1px 4px rgba(15,23,42,.18) !important;
  57. }
  58. .eks-inline-translate:hover { opacity: 1 !important; }
  59. .eks-composer-translate {
  60. border: 0 !important; border-radius: 999px !important; padding: 3px 7px !important;
  61. background: #1f6feb !important; color: white !important; font: 600 11px system-ui, sans-serif !important;
  62. cursor: pointer !important; margin: 0 4px !important; box-shadow: 0 2px 8px rgba(15,23,42,.16) !important;
  63. display: inline-flex !important; align-items: center !important; min-height: 22px !important;
  64. }
  65. .eks-composer-status { display: inline-block; margin-left: 6px; color: #64748b; font: 12px system-ui, sans-serif; }
  66. `;
  67. document.head.appendChild(style);
  68. }
  69. function makeUi() {
  70. ensureStyles();
  71. if (!document.getElementById(BUTTON_ID)) {
  72. const toggle = document.createElement('button');
  73. toggle.id = BUTTON_ID;
  74. toggle.type = 'button';
  75. toggle.textContent = 'AI tłumacz';
  76. toggle.addEventListener('click', () => {
  77. const panel = document.getElementById(PANEL_ID);
  78. panel.hidden = !panel.hidden;
  79. if (!panel.hidden) fillSelectedText();
  80. });
  81. document.body.appendChild(toggle);
  82. }
  83. if (!document.getElementById(POPOVER_ID)) {
  84. const pop = document.createElement('section');
  85. pop.id = POPOVER_ID;
  86. pop.hidden = true;
  87. pop.innerHTML = `
  88. <div class="eks-row" style="justify-content:space-between">
  89. <h3>Tłumaczenie wiadomości na polski</h3>
  90. <button data-eks-pop="close" class="secondary" type="button">Zamknij</button>
  91. </div>
  92. <div data-eks-pop="status" class="eks-muted"></div>
  93. <div data-eks-pop="result" class="eks-result"></div>
  94. <div data-eks-pop="error" class="eks-error"></div>
  95. `;
  96. pop.querySelector('[data-eks-pop="close"]').addEventListener('click', () => { pop.hidden = true; });
  97. document.body.appendChild(pop);
  98. }
  99. if (document.getElementById(PANEL_ID)) return;
  100. const panel = document.createElement('section');
  101. panel.id = PANEL_ID;
  102. panel.hidden = true;
  103. panel.innerHTML = `
  104. <h3>EKS AI tłumaczenia</h3>
  105. <div class="eks-muted">Token operatora jest zapisany tylko w tej przeglądarce. Sekrety Flowise/OpenAI zostają po stronie EKSRelay.</div>
  106. <label>Agent UI token</label>
  107. <input data-eks="token" type="password" autocomplete="off" placeholder="Wklej token operatora" />
  108. <div class="eks-row">
  109. <div><label>Język docelowy dla wiadomości</label><input data-eks="target" value="pl" placeholder="pl, en, de, cs..." /></div>
  110. <div><label>Źródłowy</label><input data-eks="source" value="auto" placeholder="auto" /></div>
  111. </div>
  112. <label>Tekst do tłumaczenia</label>
  113. <textarea data-eks="text" placeholder="Zaznacz tekst wiadomości albo wklej go tutaj"></textarea>
  114. <div class="eks-row" style="margin-top:8px">
  115. <button data-eks="selected" type="button">Tłumacz wiadomość na PL</button>
  116. <button data-eks="draft" type="button">Tekst z okna PL → język klienta</button>
  117. </div>
  118. <div class="eks-row" style="margin-top:8px">
  119. <button data-eks="fill-selected" class="secondary" type="button">Wstaw zaznaczenie</button>
  120. <button data-eks="copy" class="secondary" type="button">Kopiuj wynik</button>
  121. <button data-eks="close" class="secondary" type="button">Zamknij</button>
  122. </div>
  123. <label>Status</label>
  124. <div data-eks="status" class="eks-muted"></div>
  125. <label>Wynik</label>
  126. <div data-eks="result" class="eks-result"></div>
  127. <div data-eks="error" class="eks-error"></div>
  128. `;
  129. document.body.appendChild(panel);
  130. const token = panel.querySelector('[data-eks="token"]');
  131. token.value = localStorage.getItem(STORE_KEY) || '';
  132. token.addEventListener('change', () => localStorage.setItem(STORE_KEY, token.value.trim()));
  133. panel.querySelector('[data-eks="fill-selected"]').addEventListener('click', fillSelectedText);
  134. panel.querySelector('[data-eks="selected"]').addEventListener('click', () => translateMessageFromPanel());
  135. panel.querySelector('[data-eks="draft"]').addEventListener('click', () => translateAndReplaceDraft());
  136. panel.querySelector('[data-eks="copy"]').addEventListener('click', copyResult);
  137. panel.querySelector('[data-eks="close"]').addEventListener('click', () => { panel.hidden = true; });
  138. }
  139. function panelField(name) { return document.querySelector(`#${PANEL_ID} [data-eks="${name}"]`); }
  140. function popField(name) { return document.querySelector(`#${POPOVER_ID} [data-eks-pop="${name}"]`); }
  141. function fillSelectedText() {
  142. const selected = String(window.getSelection?.() || '').trim();
  143. if (selected) panelField('text').value = selected;
  144. else {
  145. const draft = getDraftText();
  146. if (draft) panelField('text').value = draft;
  147. }
  148. }
  149. function getDraftText() {
  150. const el = getReplyEditorElement();
  151. return el ? readEditableText(el) : '';
  152. }
  153. function readEditableText(el) {
  154. return el ? (el.value || el.innerText || el.textContent || '') : '';
  155. }
  156. function getReplyEditorElement() {
  157. const active = document.activeElement;
  158. if (active && isReplyEditor(active)) return active;
  159. const explicit = document.querySelector('.reply-box__top .ProseMirror[contenteditable="true"], .reply-box__top [contenteditable="true"].ProseMirror, .reply-box__top [contenteditable="true"][translate="no"]');
  160. if (explicit) return explicit;
  161. const replyBox = document.querySelector('.reply-box__top');
  162. if (replyBox) {
  163. const editor = replyBox.querySelector('.ProseMirror[contenteditable="true"], [contenteditable="true"][role="textbox"], [contenteditable="true"]');
  164. if (editor) return editor;
  165. }
  166. const editors = [...document.querySelectorAll('.ProseMirror[contenteditable="true"], [contenteditable="true"][role="textbox"]')];
  167. return editors.find((el) => !el.closest(`#${PANEL_ID}, #${POPOVER_ID}`) && isLikelyReplyEditor(el)) || null;
  168. }
  169. function isReplyEditor(el) {
  170. return Boolean(el && !el.closest(`#${PANEL_ID}, #${POPOVER_ID}`) && el.closest('.reply-box__top') && (el.isContentEditable || el.classList?.contains('ProseMirror')));
  171. }
  172. function isLikelyReplyEditor(el) {
  173. return Boolean(el && !el.closest('.message, .message-bubble, [data-message-id]') && (el.classList?.contains('ProseMirror') || el.isContentEditable));
  174. }
  175. function getDraftElement() {
  176. const replyEditor = getReplyEditorElement();
  177. if (replyEditor) return replyEditor;
  178. const active = document.activeElement;
  179. if (active && (active.tagName === 'TEXTAREA' || active.isContentEditable) && !active.closest(`#${PANEL_ID}, #${POPOVER_ID}`)) return active;
  180. const editors = [...document.querySelectorAll('.ProseMirror[contenteditable="true"], [contenteditable="true"][role="textbox"], textarea')];
  181. return editors.find((el) => !el.closest(`#${PANEL_ID}`)) || null;
  182. }
  183. function setDraftText(el, text) {
  184. if (!el) return false;
  185. if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
  186. el.value = text;
  187. el.dispatchEvent(new Event('input', { bubbles: true }));
  188. el.dispatchEvent(new Event('change', { bubbles: true }));
  189. el.focus();
  190. return true;
  191. }
  192. if (el.isContentEditable) {
  193. el.focus();
  194. replaceContentEditableText(el, text);
  195. return true;
  196. }
  197. return false;
  198. }
  199. function replaceContentEditableText(el, text) {
  200. el.focus();
  201. const selection = window.getSelection();
  202. const range = document.createRange();
  203. range.selectNodeContents(el);
  204. selection.removeAllRanges();
  205. selection.addRange(range);
  206. let inserted = false;
  207. try {
  208. inserted = document.execCommand && document.execCommand('insertText', false, text);
  209. } catch (_) {
  210. inserted = false;
  211. }
  212. if (!inserted) {
  213. const normalised = text.split(/\n{2,}/).map((block) => block.trim()).filter(Boolean);
  214. el.innerHTML = normalised.length
  215. ? normalised.map((block) => `<p>${escapeHtml(block).replace(/\n/g, '<br>')}</p>`).join('')
  216. : '<p></p>';
  217. el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, inputType: 'insertText', data: text }));
  218. el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
  219. }
  220. el.dispatchEvent(new Event('change', { bubbles: true }));
  221. }
  222. function escapeHtml(text) {
  223. return text.replace(/[&<>"]/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[ch]));
  224. }
  225. async function translateAndReplaceDraft(button) {
  226. const el = getReplyEditorElement();
  227. const text = readEditableText(el).trim();
  228. const token = getToken();
  229. if (!token) {
  230. showPanelError('Brak Agent UI token. Otwórz raz panel „AI tłumacz” i wklej token.');
  231. return showComposerStatus(button, 'Brak tokenu — otwórz panel AI tłumacz.', true);
  232. }
  233. if (!el) return showComposerStatus(button, 'Nie widzę okna odpowiedzi Chatwoot.', true);
  234. if (!text) return showComposerStatus(button, 'Najpierw wpisz tekst po polsku w oknie odpowiedzi.', true);
  235. const panel = document.getElementById(PANEL_ID);
  236. if (!panel.hidden) {
  237. panelField('source').value = 'pl';
  238. panelField('target').value = 'auto';
  239. panelField('text').value = text;
  240. panelField('status').textContent = 'Wykrywam język klienta z pierwszej wiadomości i tłumaczę tekst z okna odpowiedzi…';
  241. }
  242. showComposerStatus(button, 'Tłumaczę…');
  243. const originalButtonText = button?.textContent || '';
  244. if (button) button.textContent = 'AI…';
  245. try {
  246. setBusy(true);
  247. const translated = await callTranslate('/agent-tools/translate-draft', {
  248. draftText: text,
  249. sourceLanguage: 'pl',
  250. targetLanguage: 'auto',
  251. conversationId: detectConversationId(),
  252. });
  253. state.result = translated.translatedText || '';
  254. if (!panel.hidden) {
  255. panelField('result').textContent = state.result + (translated.cached ? '\n\n[cached]' : '');
  256. panelField('status').textContent = `Gotowe — wstawiłem tłumaczenie do pola odpowiedzi (${translated.targetLanguage}).`;
  257. }
  258. setDraftText(el, state.result);
  259. showComposerStatus(button, `Gotowe (${translated.targetLanguage})`);
  260. } catch (err) {
  261. const msg = err instanceof Error ? err.message : String(err);
  262. showPanelError(msg);
  263. showComposerStatus(button, msg, true);
  264. } finally {
  265. if (button) button.textContent = originalButtonText;
  266. setBusy(false);
  267. setTimeout(() => showComposerStatus(button, ''), 3500);
  268. }
  269. }
  270. async function translateMessageFromPanel() {
  271. const text = panelField('text').value.trim();
  272. if (!text) return showPanelError('Brak tekstu do tłumaczenia.');
  273. return translateMessageText(text, detectMessageId());
  274. }
  275. async function translateMessageText(text, messageId, button) {
  276. const token = getToken();
  277. if (!token) return showPanelError('Brak Agent UI token.');
  278. showPopover('Tłumaczę wiadomość na polski…', '', '');
  279. if (button) button.textContent = '…';
  280. try {
  281. setBusy(true);
  282. const translated = await callTranslate('/agent-tools/translate-message', {
  283. sourceText: text,
  284. targetLanguage: 'pl',
  285. conversationId: detectConversationId(),
  286. messageId,
  287. });
  288. state.result = translated.translatedText || '';
  289. showPopover(translated.cached ? 'Gotowe (cache).' : 'Gotowe.', state.result, '');
  290. return state.result;
  291. } catch (err) {
  292. const msg = err instanceof Error ? err.message : String(err);
  293. showPopover('', '', msg);
  294. } finally {
  295. if (button) button.textContent = 'AI';
  296. setBusy(false);
  297. }
  298. }
  299. async function callTranslate(endpoint, payload) {
  300. const token = getToken();
  301. localStorage.setItem(STORE_KEY, token);
  302. const res = await fetch(`${RELAY_ORIGIN}${endpoint}`, {
  303. method: 'POST',
  304. headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
  305. body: JSON.stringify(payload),
  306. });
  307. const json = await res.json().catch(() => null);
  308. if (!res.ok || !json?.ok) throw new Error(json?.message || `HTTP ${res.status}`);
  309. return json;
  310. }
  311. function getToken() {
  312. return panelField('token')?.value.trim() || localStorage.getItem(STORE_KEY) || '';
  313. }
  314. function detectConversationId() {
  315. const m = location.pathname.match(/conversations\/(\d+)/) || location.hash.match(/conversations\/(\d+)/);
  316. return m ? Number(m[1]) : undefined;
  317. }
  318. function detectMessageId() {
  319. const el = window.getSelection?.()?.anchorNode?.parentElement?.closest?.('[data-message-id], [data-id]');
  320. return el?.getAttribute('data-message-id') || el?.getAttribute('data-id') || undefined;
  321. }
  322. async function copyResult() {
  323. if (!state.result) return;
  324. await navigator.clipboard.writeText(state.result);
  325. }
  326. function showPanelError(msg) {
  327. state.error = msg;
  328. const el = panelField('error');
  329. if (el) el.textContent = msg;
  330. }
  331. function showPopover(status, result, error) {
  332. const pop = document.getElementById(POPOVER_ID);
  333. pop.hidden = false;
  334. popField('status').textContent = status || '';
  335. popField('result').textContent = result || '';
  336. popField('error').textContent = error || '';
  337. }
  338. function showComposerStatus(button, msg, isError = false) {
  339. const host = button?.parentElement;
  340. let el = host?.querySelector?.('.eks-composer-status');
  341. if (!el && host) {
  342. el = document.createElement('span');
  343. el.className = 'eks-composer-status';
  344. host.appendChild(el);
  345. }
  346. if (el) {
  347. el.textContent = msg || '';
  348. el.style.color = isError ? '#b91c1c' : '#64748b';
  349. }
  350. }
  351. function setBusy(busy) {
  352. state.busy = busy;
  353. document.querySelectorAll(`#${PANEL_ID} button, #${POPOVER_ID} button, .eks-composer-translate, .eks-inline-translate`).forEach((btn) => { btn.disabled = busy; });
  354. }
  355. function addInlineButtons() {
  356. const candidates = document.querySelectorAll('[data-message-id]:not([data-eks-ai-bound]), .message-bubble:not([data-eks-ai-bound]), [class*="message"]:not([data-eks-ai-bound])');
  357. candidates.forEach((el) => {
  358. const text = (el.innerText || '').trim();
  359. if (text.length < 12 || text.length > 5000) return;
  360. if (el.closest(`#${PANEL_ID}, #${POPOVER_ID}`)) return;
  361. el.setAttribute('data-eks-ai-bound', '1');
  362. if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
  363. const btn = document.createElement('button');
  364. btn.type = 'button';
  365. btn.className = 'eks-inline-translate';
  366. btn.textContent = 'AI';
  367. btn.title = 'Przetłumacz tę wiadomość na polski';
  368. btn.addEventListener('click', (e) => {
  369. e.preventDefault(); e.stopPropagation();
  370. void translateMessageText(text, el.getAttribute('data-message-id') || el.getAttribute('data-id') || undefined, btn);
  371. });
  372. el.appendChild(btn);
  373. });
  374. }
  375. function addComposerButtons() {
  376. const editors = document.querySelectorAll('.reply-box__top .ProseMirror[contenteditable="true"], .reply-box__top [contenteditable="true"].ProseMirror, .reply-box__top [contenteditable="true"][translate="no"]');
  377. editors.forEach((el) => {
  378. if (el.dataset?.eksComposerBound === '1') return;
  379. if (el.closest(`#${PANEL_ID}, #${POPOVER_ID}`)) return;
  380. if (el.dataset) el.dataset.eksComposerBound = '1';
  381. const btn = document.createElement('button');
  382. btn.type = 'button';
  383. btn.className = 'eks-composer-translate';
  384. btn.textContent = 'PL→klient';
  385. btn.title = 'Przetłumacz wpisaną odpowiedź po polsku na język wykryty z pierwszej wiadomości klienta';
  386. btn.addEventListener('click', (e) => {
  387. e.preventDefault(); e.stopPropagation();
  388. void translateAndReplaceDraft(btn);
  389. });
  390. const menubar = el.closest('.ProseMirror-menubar-wrapper')?.querySelector('.ProseMirror-menubar');
  391. const host = menubar || el.parentElement;
  392. if (host) host.appendChild(btn);
  393. });
  394. }
  395. function boot() {
  396. if (!document.body) return setTimeout(boot, 200);
  397. makeUi();
  398. addInlineButtons();
  399. addComposerButtons();
  400. const obs = new MutationObserver(() => { addInlineButtons(); addComposerButtons(); });
  401. obs.observe(document.body, { childList: true, subtree: true });
  402. }
  403. boot();
  404. })();