chatwoot-ai-tools.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  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 LANG_KEY = 'eksAgentUiTargetLanguage';
  8. const PANEL_ID = 'eks-ai-translate-panel';
  9. const BUTTON_ID = 'eks-ai-translate-toggle';
  10. const state = {
  11. busy: false,
  12. result: '',
  13. error: '',
  14. };
  15. function t(s) { return s; }
  16. function ensureStyles() {
  17. if (document.getElementById('eks-ai-translate-styles')) return;
  18. const style = document.createElement('style');
  19. style.id = 'eks-ai-translate-styles';
  20. style.textContent = `
  21. #${BUTTON_ID} {
  22. position: fixed; right: 18px; bottom: 18px; z-index: 2147483000;
  23. border: 0; border-radius: 999px; padding: 10px 14px;
  24. background: #1f6feb; color: white; font: 600 13px system-ui, sans-serif;
  25. box-shadow: 0 8px 22px rgba(15,23,42,.25); cursor: pointer;
  26. }
  27. #${PANEL_ID} {
  28. position: fixed; right: 18px; bottom: 66px; z-index: 2147483000;
  29. width: min(420px, calc(100vw - 36px)); max-height: min(720px, calc(100vh - 92px));
  30. overflow: auto; background: #fff; color: #0f172a; border: 1px solid #d7dee8;
  31. border-radius: 14px; box-shadow: 0 18px 48px rgba(15,23,42,.28);
  32. padding: 14px; font: 13px system-ui, sans-serif;
  33. }
  34. #${PANEL_ID}[hidden] { display: none; }
  35. #${PANEL_ID} h3 { margin: 0 0 10px; font-size: 15px; }
  36. #${PANEL_ID} label { display: block; margin: 10px 0 4px; font-weight: 600; }
  37. #${PANEL_ID} input, #${PANEL_ID} textarea, #${PANEL_ID} select {
  38. width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px;
  39. padding: 8px; font: 13px system-ui, sans-serif; background: #fff; color: #0f172a;
  40. }
  41. #${PANEL_ID} textarea { min-height: 88px; resize: vertical; }
  42. #${PANEL_ID} .eks-row { display: flex; gap: 8px; align-items: center; }
  43. #${PANEL_ID} .eks-row > * { flex: 1; }
  44. #${PANEL_ID} button {
  45. border: 0; border-radius: 8px; padding: 8px 10px; background: #1f6feb; color: white;
  46. font: 600 13px system-ui, sans-serif; cursor: pointer;
  47. }
  48. #${PANEL_ID} button.secondary { background: #e2e8f0; color: #0f172a; }
  49. #${PANEL_ID} button.danger { background: #b91c1c; }
  50. #${PANEL_ID} button:disabled { opacity: .55; cursor: wait; }
  51. #${PANEL_ID} .eks-muted { color: #64748b; font-size: 12px; }
  52. #${PANEL_ID} .eks-error { color: #b91c1c; white-space: pre-wrap; }
  53. #${PANEL_ID} .eks-result { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; white-space: pre-wrap; min-height: 44px; }
  54. .eks-inline-translate {
  55. margin-left: 4px !important; border: 0 !important; border-radius: 999px !important;
  56. padding: 1px 5px !important; background: #e0f2fe !important; color: #075985 !important;
  57. font: 600 9px system-ui, sans-serif !important; line-height: 14px !important; cursor: pointer !important;
  58. opacity: .82 !important; vertical-align: middle !important;
  59. }
  60. .eks-composer-translate {
  61. border: 0 !important; border-radius: 999px !important; padding: 5px 9px !important;
  62. background: #1f6feb !important; color: white !important; font: 600 12px system-ui, sans-serif !important;
  63. cursor: pointer !important; margin: 4px !important; box-shadow: 0 2px 8px rgba(15,23,42,.16) !important;
  64. }
  65. `;
  66. document.head.appendChild(style);
  67. }
  68. function makeUi() {
  69. ensureStyles();
  70. if (!document.getElementById(BUTTON_ID)) {
  71. const toggle = document.createElement('button');
  72. toggle.id = BUTTON_ID;
  73. toggle.type = 'button';
  74. toggle.textContent = 'AI tłumacz';
  75. toggle.addEventListener('click', () => {
  76. const panel = document.getElementById(PANEL_ID);
  77. panel.hidden = !panel.hidden;
  78. if (!panel.hidden) fillSelectedText();
  79. });
  80. document.body.appendChild(toggle);
  81. }
  82. if (document.getElementById(PANEL_ID)) return;
  83. const panel = document.createElement('section');
  84. panel.id = PANEL_ID;
  85. panel.hidden = true;
  86. panel.innerHTML = `
  87. <h3>${t('EKS AI tłumaczenia')}</h3>
  88. <div class="eks-muted">Token operatora jest zapisany tylko w tej przeglądarce. OpenAI/Flowise sekrety zostają po stronie EKSRelay.</div>
  89. <label>Agent UI token</label>
  90. <input data-eks="token" type="password" autocomplete="off" placeholder="Wklej token operatora" />
  91. <div class="eks-row">
  92. <div>
  93. <label>Język docelowy</label>
  94. <input data-eks="target" value="pl" placeholder="pl, en, de, cs..." />
  95. </div>
  96. <div>
  97. <label>Źródłowy</label>
  98. <input data-eks="source" value="auto" placeholder="auto" />
  99. </div>
  100. </div>
  101. <label>Tekst do tłumaczenia</label>
  102. <textarea data-eks="text" placeholder="Zaznacz tekst wiadomości albo wklej go tutaj"></textarea>
  103. <div class="eks-row" style="margin-top:8px">
  104. <button data-eks="selected" type="button">Tłumacz wiadomość</button>
  105. <button data-eks="draft" type="button">Tłumacz draft</button>
  106. </div>
  107. <div class="eks-row" style="margin-top:8px">
  108. <button data-eks="fill-selected" class="secondary" type="button">Wstaw zaznaczenie</button>
  109. <button data-eks="copy" class="secondary" type="button">Kopiuj wynik</button>
  110. <button data-eks="close" class="secondary" type="button">Zamknij</button>
  111. </div>
  112. <label>Wynik</label>
  113. <div data-eks="result" class="eks-result"></div>
  114. <div data-eks="error" class="eks-error"></div>
  115. `;
  116. document.body.appendChild(panel);
  117. const token = panel.querySelector('[data-eks="token"]');
  118. const target = panel.querySelector('[data-eks="target"]');
  119. token.value = localStorage.getItem(STORE_KEY) || '';
  120. target.value = localStorage.getItem(LANG_KEY) || 'pl';
  121. token.addEventListener('change', () => localStorage.setItem(STORE_KEY, token.value.trim()));
  122. target.addEventListener('change', () => localStorage.setItem(LANG_KEY, target.value.trim() || 'pl'));
  123. panel.querySelector('[data-eks="fill-selected"]').addEventListener('click', fillSelectedText);
  124. panel.querySelector('[data-eks="selected"]').addEventListener('click', () => translate('message'));
  125. panel.querySelector('[data-eks="draft"]').addEventListener('click', () => translate('draft'));
  126. panel.querySelector('[data-eks="copy"]').addEventListener('click', copyResult);
  127. panel.querySelector('[data-eks="close"]').addEventListener('click', () => { panel.hidden = true; });
  128. }
  129. function panelField(name) {
  130. return document.querySelector(`#${PANEL_ID} [data-eks="${name}"]`);
  131. }
  132. function fillSelectedText() {
  133. const selected = String(window.getSelection?.() || '').trim();
  134. if (selected) panelField('text').value = selected;
  135. else {
  136. const draft = getDraftText();
  137. if (draft) panelField('text').value = draft;
  138. }
  139. }
  140. function getDraftText() {
  141. const active = document.activeElement;
  142. if (active && (active.tagName === 'TEXTAREA' || active.tagName === 'INPUT')) return active.value || '';
  143. if (active?.isContentEditable) return active.innerText || active.textContent || '';
  144. const candidate = document.querySelector('[contenteditable="true"][role="textbox"], textarea');
  145. return candidate ? (candidate.value || candidate.innerText || candidate.textContent || '') : '';
  146. }
  147. function getDraftElement() {
  148. const active = document.activeElement;
  149. if (active && (active.tagName === 'TEXTAREA' || active.tagName === 'INPUT' || active.isContentEditable)) return active;
  150. return document.querySelector('[contenteditable="true"][role="textbox"], textarea');
  151. }
  152. function setDraftText(el, text) {
  153. if (!el) return false;
  154. if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
  155. el.value = text;
  156. el.dispatchEvent(new Event('input', { bubbles: true }));
  157. el.dispatchEvent(new Event('change', { bubbles: true }));
  158. el.focus();
  159. return true;
  160. }
  161. if (el.isContentEditable) {
  162. el.focus();
  163. el.textContent = text;
  164. el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
  165. return true;
  166. }
  167. return false;
  168. }
  169. async function translateAndReplaceDraft() {
  170. const el = getDraftElement();
  171. const text = (el ? (el.value || el.innerText || el.textContent || '') : '').trim();
  172. const panel = document.getElementById(PANEL_ID);
  173. panel.hidden = false;
  174. panelField('source').value = 'pl';
  175. panelField('text').value = text;
  176. if (!panelField('target').value.trim() || panelField('target').value.trim() === 'pl') {
  177. panelField('target').value = localStorage.getItem(LANG_KEY) || 'auto';
  178. }
  179. const translated = await translate('draft');
  180. if (translated) setDraftText(el, translated);
  181. }
  182. async function translate(mode) {
  183. const token = panelField('token').value.trim();
  184. const targetLanguage = panelField('target').value.trim() || 'pl';
  185. const sourceLanguageRaw = panelField('source').value.trim();
  186. let text = panelField('text').value.trim();
  187. if (mode === 'draft' && !text) text = getDraftText().trim();
  188. if (!token) return showError('Brak Agent UI token.');
  189. if (!text) return showError('Brak tekstu do tłumaczenia.');
  190. localStorage.setItem(STORE_KEY, token);
  191. localStorage.setItem(LANG_KEY, targetLanguage);
  192. setBusy(true);
  193. showError('');
  194. panelField('result').textContent = '';
  195. try {
  196. const endpoint = mode === 'draft' ? '/agent-tools/translate-draft' : '/agent-tools/translate-message';
  197. const payload = {
  198. targetLanguage,
  199. sourceLanguage: sourceLanguageRaw && sourceLanguageRaw !== 'auto' ? sourceLanguageRaw : undefined,
  200. conversationId: detectConversationId(),
  201. ...(mode === 'draft' ? { draftText: text } : { sourceText: text, messageId: detectMessageId() }),
  202. };
  203. const res = await fetch(`${RELAY_ORIGIN}${endpoint}`, {
  204. method: 'POST',
  205. headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
  206. body: JSON.stringify(payload),
  207. });
  208. const json = await res.json().catch(() => null);
  209. if (!res.ok || !json?.ok) throw new Error(json?.message || `HTTP ${res.status}`);
  210. state.result = json.translatedText || '';
  211. panelField('result').textContent = state.result + (json.cached ? '\n\n[cached]' : '');
  212. return state.result;
  213. } catch (err) {
  214. showError(err instanceof Error ? err.message : String(err));
  215. } finally {
  216. setBusy(false);
  217. }
  218. }
  219. function detectConversationId() {
  220. const m = location.pathname.match(/conversations\/(\d+)/) || location.hash.match(/conversations\/(\d+)/);
  221. return m ? Number(m[1]) : undefined;
  222. }
  223. function detectMessageId() {
  224. const el = window.getSelection?.()?.anchorNode?.parentElement?.closest?.('[data-message-id], [data-id]');
  225. return el?.getAttribute('data-message-id') || el?.getAttribute('data-id') || undefined;
  226. }
  227. async function copyResult() {
  228. if (!state.result) return;
  229. await navigator.clipboard.writeText(state.result);
  230. }
  231. function showError(msg) {
  232. state.error = msg;
  233. const el = panelField('error');
  234. if (el) el.textContent = msg;
  235. }
  236. function setBusy(busy) {
  237. state.busy = busy;
  238. document.querySelectorAll(`#${PANEL_ID} button`).forEach((btn) => { btn.disabled = busy; });
  239. }
  240. function addInlineButtons() {
  241. 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])');
  242. candidates.forEach((el) => {
  243. const text = (el.innerText || '').trim();
  244. if (text.length < 12 || text.length > 5000) return;
  245. el.setAttribute('data-eks-ai-bound', '1');
  246. const btn = document.createElement('button');
  247. btn.type = 'button';
  248. btn.className = 'eks-inline-translate';
  249. btn.textContent = 'AI';
  250. btn.title = 'Przetłumacz tę wiadomość na polski';
  251. btn.addEventListener('click', (e) => {
  252. e.preventDefault(); e.stopPropagation();
  253. const panel = document.getElementById(PANEL_ID);
  254. panel.hidden = false;
  255. panelField('target').value = localStorage.getItem(LANG_KEY) || 'pl';
  256. panelField('text').value = text;
  257. });
  258. el.appendChild(btn);
  259. });
  260. }
  261. function addComposerButtons() {
  262. const editors = document.querySelectorAll('[contenteditable="true"][role="textbox"], textarea');
  263. editors.forEach((el) => {
  264. if (el.dataset?.eksComposerBound === '1') return;
  265. if (el.closest(`#${PANEL_ID}`)) return;
  266. if (el.dataset) el.dataset.eksComposerBound = '1';
  267. const btn = document.createElement('button');
  268. btn.type = 'button';
  269. btn.className = 'eks-composer-translate';
  270. btn.textContent = 'AI: PL → język klienta';
  271. btn.title = 'Przetłumacz wpisaną odpowiedź po polsku na język klienta i wstaw do edytora';
  272. btn.addEventListener('click', (e) => {
  273. e.preventDefault(); e.stopPropagation();
  274. void translateAndReplaceDraft();
  275. });
  276. const host = el.parentElement;
  277. if (host) host.appendChild(btn);
  278. });
  279. }
  280. function boot() {
  281. if (!document.body) return setTimeout(boot, 200);
  282. makeUi();
  283. addInlineButtons();
  284. addComposerButtons();
  285. const obs = new MutationObserver(() => { addInlineButtons(); addComposerButtons(); });
  286. obs.observe(document.body, { childList: true, subtree: true });
  287. }
  288. boot();
  289. })();