Sfoglia il codice sorgente

Add Chatwoot agent translation tools

Maciek 3 settimane fa
parent
commit
8783c1267f

+ 10 - 0
.env.example

@@ -30,6 +30,10 @@ CHATWOOT_API_IDENTITY_VALIDATION_TOKEN=
 
 # --- Flowise ---------------------------------------------------------------
 FLOWISE_PREDICT_URL=https://botek.easyklima.com/api/v1/prediction/09fc9332-8cdd-4142-8f0e-4259881fc7bf
+# Optional separate Flowise chatflow for agent UI translations. Falls back to
+# FLOWISE_PREDICT_URL when empty, but production should point this at the small
+# translation-only flow.
+FLOWISE_TRANSLATION_PREDICT_URL=
 FLOWISE_API_KEY=
 # Optional; derived from FLOWISE_PREDICT_URL when empty. Used by /ready only.
 FLOWISE_BASE_URL=
@@ -50,6 +54,12 @@ STORE_CURRENCIES=PLN,EUR,AED,CZK,HUF,DKK,SEK,NOK,RON,BGN,GBP
 RELAY_SHARED_SECRET=
 # Guards /admin/*. Empty means the admin endpoints are closed, not open.
 ADMIN_TOKEN=
+# Guards /agent-tools/* browser helper endpoints used by the Chatwoot AI overlay.
+# Use a separate operator/UI token, not ADMIN_TOKEN.
+AGENT_UI_TOKEN=
+# Comma-separated extra browser origins allowed to call /agent-tools/*.
+# CHATWOOT_BASE_URL is allowed automatically.
+AGENT_UI_ALLOWED_ORIGINS=
 
 # --- Behaviour -------------------------------------------------------------
 TENANT_ID=easyklima

+ 111 - 0
docs/CHATWOOT_AI_TRANSLATION.md

@@ -0,0 +1,111 @@
+# Chatwoot AI translation implementation
+
+Date: 2026-08-21
+
+## EKSRelay endpoints
+
+Browser/operator-facing endpoints are separate from Flowise tool endpoints and admin endpoints:
+
+```text
+GET  /chatwoot-ai-tools.js
+POST /agent-tools/translate-message
+POST /agent-tools/translate-draft
+```
+
+Auth:
+
+```http
+Authorization: Bearer <AGENT_UI_TOKEN>
+```
+
+`AGENT_UI_TOKEN` is intentionally separate from `ADMIN_TOKEN`; do not use the full admin/ops token in daily operator UI.
+
+CORS allows `CHATWOOT_BASE_URL` automatically and optional extra origins from:
+
+```text
+AGENT_UI_ALLOWED_ORIGINS
+```
+
+## Translation flow
+
+A separate Flowise chatflow was created:
+
+```text
+name: EK Agent UI Translation
+id: 47ea6bda-1be4-4c11-96f6-ea0187ea8a75
+predict URL: https://botek.easyklima.com/api/v1/prediction/47ea6bda-1be4-4c11-96f6-ea0187ea8a75
+```
+
+Smoke test returned:
+
+```text
+Hello, when will my order #123 arrive?
+→ Witam, kiedy dotrze moje zamówienie nr 123?
+```
+
+Production EKSRelay should set:
+
+```text
+FLOWISE_TRANSLATION_PREDICT_URL=https://botek.easyklima.com/api/v1/prediction/47ea6bda-1be4-4c11-96f6-ea0187ea8a75
+AGENT_UI_TOKEN=<separate operator token>
+```
+
+## JS overlay/injection asset
+
+EKSRelay serves:
+
+```html
+<script src="https://eks-relay.easyklima.com/chatwoot-ai-tools.js" defer></script>
+```
+
+The script:
+
+- adds a floating `AI tłumacz` panel in Chatwoot,
+- stores only the operator UI token in browser `localStorage`,
+- lets the operator translate selected/pasted message text,
+- lets the operator translate a draft to the customer language,
+- attempts to add small inline `AI→PL` buttons to detected message bubbles,
+- never sends automatically to the customer; result is shown/copied only.
+
+## Payload examples
+
+Message translation:
+
+```json
+{
+  "conversationId": 1604,
+  "messageId": "12345",
+  "targetLanguage": "pl",
+  "sourceLanguage": "auto",
+  "sourceText": "Hello, when will my order arrive?"
+}
+```
+
+Draft translation:
+
+```json
+{
+  "conversationId": 1604,
+  "targetLanguage": "de",
+  "draftText": "Dzień dobry, proszę podać numer zamówienia."
+}
+```
+
+## Persistence/cache
+
+Translations are cached in EKSRelay SQLite table `TranslationCache`, keyed by:
+
+```text
+sha256(sourceText.trim()) + targetLanguage
+```
+
+The raw source text is not stored as a lookup key. The translated text is stored so repeated operator requests do not call Flowise/OpenAI again.
+
+## Injection options still pending
+
+The JS is ready and served by EKSRelay. To make it appear automatically in Chatwoot, use one of:
+
+1. reverse-proxy HTML injection sidecar, or
+2. minimal Chatwoot custom image / host-mounted layout override that adds the script tag.
+
+For production, prefer reverse-proxy injection or a documented minimal override instead of editing files inside the running Chatwoot container.

+ 17 - 0
prisma/migrations/20260821130000_agent_translation_cache/migration.sql

@@ -0,0 +1,17 @@
+-- Cache agent UI translations without storing raw source text in lookup keys.
+CREATE TABLE "TranslationCache" (
+    "id" TEXT NOT NULL PRIMARY KEY,
+    "conversationId" INTEGER,
+    "messageId" TEXT,
+    "sourceHash" TEXT NOT NULL,
+    "sourceLanguage" TEXT,
+    "targetLanguage" TEXT NOT NULL,
+    "translatedText" TEXT NOT NULL,
+    "provider" TEXT NOT NULL,
+    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" DATETIME NOT NULL
+);
+
+CREATE UNIQUE INDEX "TranslationCache_sourceHash_targetLanguage_key" ON "TranslationCache"("sourceHash", "targetLanguage");
+CREATE INDEX "TranslationCache_conversationId_idx" ON "TranslationCache"("conversationId");
+CREATE INDEX "TranslationCache_messageId_idx" ON "TranslationCache"("messageId");

+ 19 - 0
prisma/schema.prisma

@@ -82,3 +82,22 @@ model AuditEvent {
   @@index([createdAt])
   @@index([jobId])
 }
+
+/// Cached AI translations for agent UI. Keyed by text hash rather than raw text
+/// to avoid duplicating customer content unnecessarily in lookup keys.
+model TranslationCache {
+  id              String   @id @default(cuid())
+  conversationId  Int?
+  messageId       String?
+  sourceHash      String
+  sourceLanguage  String?
+  targetLanguage  String
+  translatedText  String
+  provider        String
+  createdAt       DateTime @default(now())
+  updatedAt       DateTime @updatedAt
+
+  @@unique([sourceHash, targetLanguage])
+  @@index([conversationId])
+  @@index([messageId])
+}

+ 247 - 0
public/chatwoot-ai-tools.js

@@ -0,0 +1,247 @@
+(() => {
+  'use strict';
+
+  if (window.__EKS_CHATWOOT_AI_TOOLS__) return;
+  window.__EKS_CHATWOOT_AI_TOOLS__ = true;
+
+  const RELAY_ORIGIN = new URL(document.currentScript?.src || 'https://eks-relay.easyklima.com/chatwoot-ai-tools.js').origin;
+  const STORE_KEY = 'eksAgentUiToken';
+  const LANG_KEY = 'eksAgentUiTargetLanguage';
+  const PANEL_ID = 'eks-ai-translate-panel';
+  const BUTTON_ID = 'eks-ai-translate-toggle';
+
+  const state = {
+    busy: false,
+    result: '',
+    error: '',
+  };
+
+  function t(s) { return s; }
+
+  function ensureStyles() {
+    if (document.getElementById('eks-ai-translate-styles')) return;
+    const style = document.createElement('style');
+    style.id = 'eks-ai-translate-styles';
+    style.textContent = `
+      #${BUTTON_ID} {
+        position: fixed; right: 18px; bottom: 18px; z-index: 2147483000;
+        border: 0; border-radius: 999px; padding: 10px 14px;
+        background: #1f6feb; color: white; font: 600 13px system-ui, sans-serif;
+        box-shadow: 0 8px 22px rgba(15,23,42,.25); cursor: pointer;
+      }
+      #${PANEL_ID} {
+        position: fixed; right: 18px; bottom: 66px; z-index: 2147483000;
+        width: min(420px, calc(100vw - 36px)); max-height: min(720px, calc(100vh - 92px));
+        overflow: auto; background: #fff; color: #0f172a; border: 1px solid #d7dee8;
+        border-radius: 14px; box-shadow: 0 18px 48px rgba(15,23,42,.28);
+        padding: 14px; font: 13px system-ui, sans-serif;
+      }
+      #${PANEL_ID}[hidden] { display: none; }
+      #${PANEL_ID} h3 { margin: 0 0 10px; font-size: 15px; }
+      #${PANEL_ID} label { display: block; margin: 10px 0 4px; font-weight: 600; }
+      #${PANEL_ID} input, #${PANEL_ID} textarea, #${PANEL_ID} select {
+        width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px;
+        padding: 8px; font: 13px system-ui, sans-serif; background: #fff; color: #0f172a;
+      }
+      #${PANEL_ID} textarea { min-height: 88px; resize: vertical; }
+      #${PANEL_ID} .eks-row { display: flex; gap: 8px; align-items: center; }
+      #${PANEL_ID} .eks-row > * { flex: 1; }
+      #${PANEL_ID} button {
+        border: 0; border-radius: 8px; padding: 8px 10px; background: #1f6feb; color: white;
+        font: 600 13px system-ui, sans-serif; cursor: pointer;
+      }
+      #${PANEL_ID} button.secondary { background: #e2e8f0; color: #0f172a; }
+      #${PANEL_ID} button.danger { background: #b91c1c; }
+      #${PANEL_ID} button:disabled { opacity: .55; cursor: wait; }
+      #${PANEL_ID} .eks-muted { color: #64748b; font-size: 12px; }
+      #${PANEL_ID} .eks-error { color: #b91c1c; white-space: pre-wrap; }
+      #${PANEL_ID} .eks-result { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; white-space: pre-wrap; min-height: 44px; }
+      .eks-inline-translate {
+        margin-left: 6px !important; border: 0 !important; border-radius: 999px !important;
+        padding: 3px 7px !important; background: #e0f2fe !important; color: #075985 !important;
+        font: 600 11px system-ui, sans-serif !important; cursor: pointer !important;
+      }
+    `;
+    document.head.appendChild(style);
+  }
+
+  function makeUi() {
+    ensureStyles();
+    if (!document.getElementById(BUTTON_ID)) {
+      const toggle = document.createElement('button');
+      toggle.id = BUTTON_ID;
+      toggle.type = 'button';
+      toggle.textContent = 'AI tłumacz';
+      toggle.addEventListener('click', () => {
+        const panel = document.getElementById(PANEL_ID);
+        panel.hidden = !panel.hidden;
+        if (!panel.hidden) fillSelectedText();
+      });
+      document.body.appendChild(toggle);
+    }
+
+    if (document.getElementById(PANEL_ID)) return;
+    const panel = document.createElement('section');
+    panel.id = PANEL_ID;
+    panel.hidden = true;
+    panel.innerHTML = `
+      <h3>${t('EKS AI tłumaczenia')}</h3>
+      <div class="eks-muted">Token operatora jest zapisany tylko w tej przeglądarce. OpenAI/Flowise sekrety zostają po stronie EKSRelay.</div>
+      <label>Agent UI token</label>
+      <input data-eks="token" type="password" autocomplete="off" placeholder="Wklej token operatora" />
+      <div class="eks-row">
+        <div>
+          <label>Język docelowy</label>
+          <input data-eks="target" value="pl" placeholder="pl, en, de, cs..." />
+        </div>
+        <div>
+          <label>Źródłowy</label>
+          <input data-eks="source" value="auto" placeholder="auto" />
+        </div>
+      </div>
+      <label>Tekst do tłumaczenia</label>
+      <textarea data-eks="text" placeholder="Zaznacz tekst wiadomości albo wklej go tutaj"></textarea>
+      <div class="eks-row" style="margin-top:8px">
+        <button data-eks="selected" type="button">Tłumacz wiadomość</button>
+        <button data-eks="draft" type="button">Tłumacz draft</button>
+      </div>
+      <div class="eks-row" style="margin-top:8px">
+        <button data-eks="fill-selected" class="secondary" type="button">Wstaw zaznaczenie</button>
+        <button data-eks="copy" class="secondary" type="button">Kopiuj wynik</button>
+        <button data-eks="close" class="secondary" type="button">Zamknij</button>
+      </div>
+      <label>Wynik</label>
+      <div data-eks="result" class="eks-result"></div>
+      <div data-eks="error" class="eks-error"></div>
+    `;
+    document.body.appendChild(panel);
+
+    const token = panel.querySelector('[data-eks="token"]');
+    const target = panel.querySelector('[data-eks="target"]');
+    token.value = localStorage.getItem(STORE_KEY) || '';
+    target.value = localStorage.getItem(LANG_KEY) || 'pl';
+    token.addEventListener('change', () => localStorage.setItem(STORE_KEY, token.value.trim()));
+    target.addEventListener('change', () => localStorage.setItem(LANG_KEY, target.value.trim() || 'pl'));
+    panel.querySelector('[data-eks="fill-selected"]').addEventListener('click', fillSelectedText);
+    panel.querySelector('[data-eks="selected"]').addEventListener('click', () => translate('message'));
+    panel.querySelector('[data-eks="draft"]').addEventListener('click', () => translate('draft'));
+    panel.querySelector('[data-eks="copy"]').addEventListener('click', copyResult);
+    panel.querySelector('[data-eks="close"]').addEventListener('click', () => { panel.hidden = true; });
+  }
+
+  function panelField(name) {
+    return document.querySelector(`#${PANEL_ID} [data-eks="${name}"]`);
+  }
+
+  function fillSelectedText() {
+    const selected = String(window.getSelection?.() || '').trim();
+    if (selected) panelField('text').value = selected;
+    else {
+      const draft = getDraftText();
+      if (draft) panelField('text').value = draft;
+    }
+  }
+
+  function getDraftText() {
+    const active = document.activeElement;
+    if (active && (active.tagName === 'TEXTAREA' || active.tagName === 'INPUT')) return active.value || '';
+    if (active?.isContentEditable) return active.innerText || active.textContent || '';
+    const candidate = document.querySelector('[contenteditable="true"][role="textbox"], textarea');
+    return candidate ? (candidate.value || candidate.innerText || candidate.textContent || '') : '';
+  }
+
+  async function translate(mode) {
+    const token = panelField('token').value.trim();
+    const targetLanguage = panelField('target').value.trim() || 'pl';
+    const sourceLanguageRaw = panelField('source').value.trim();
+    let text = panelField('text').value.trim();
+    if (mode === 'draft' && !text) text = getDraftText().trim();
+    if (!token) return showError('Brak Agent UI token.');
+    if (!text) return showError('Brak tekstu do tłumaczenia.');
+    localStorage.setItem(STORE_KEY, token);
+    localStorage.setItem(LANG_KEY, targetLanguage);
+
+    setBusy(true);
+    showError('');
+    panelField('result').textContent = '';
+    try {
+      const endpoint = mode === 'draft' ? '/agent-tools/translate-draft' : '/agent-tools/translate-message';
+      const payload = {
+        targetLanguage,
+        sourceLanguage: sourceLanguageRaw && sourceLanguageRaw !== 'auto' ? sourceLanguageRaw : undefined,
+        conversationId: detectConversationId(),
+        ...(mode === 'draft' ? { draftText: text } : { sourceText: text, messageId: detectMessageId() }),
+      };
+      const res = await fetch(`${RELAY_ORIGIN}${endpoint}`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
+        body: JSON.stringify(payload),
+      });
+      const json = await res.json().catch(() => null);
+      if (!res.ok || !json?.ok) throw new Error(json?.message || `HTTP ${res.status}`);
+      state.result = json.translatedText || '';
+      panelField('result').textContent = state.result + (json.cached ? '\n\n[cached]' : '');
+    } catch (err) {
+      showError(err instanceof Error ? err.message : String(err));
+    } finally {
+      setBusy(false);
+    }
+  }
+
+  function detectConversationId() {
+    const m = location.pathname.match(/conversations\/(\d+)/) || location.hash.match(/conversations\/(\d+)/);
+    return m ? Number(m[1]) : undefined;
+  }
+
+  function detectMessageId() {
+    const el = window.getSelection?.()?.anchorNode?.parentElement?.closest?.('[data-message-id], [data-id]');
+    return el?.getAttribute('data-message-id') || el?.getAttribute('data-id') || undefined;
+  }
+
+  async function copyResult() {
+    if (!state.result) return;
+    await navigator.clipboard.writeText(state.result);
+  }
+
+  function showError(msg) {
+    state.error = msg;
+    const el = panelField('error');
+    if (el) el.textContent = msg;
+  }
+
+  function setBusy(busy) {
+    state.busy = busy;
+    document.querySelectorAll(`#${PANEL_ID} button`).forEach((btn) => { btn.disabled = busy; });
+  }
+
+  function addInlineButtons() {
+    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])');
+    candidates.forEach((el) => {
+      const text = (el.innerText || '').trim();
+      if (text.length < 12 || text.length > 5000) return;
+      el.setAttribute('data-eks-ai-bound', '1');
+      const btn = document.createElement('button');
+      btn.type = 'button';
+      btn.className = 'eks-inline-translate';
+      btn.textContent = 'AI→PL';
+      btn.addEventListener('click', (e) => {
+        e.preventDefault(); e.stopPropagation();
+        const panel = document.getElementById(PANEL_ID);
+        panel.hidden = false;
+        panelField('target').value = localStorage.getItem(LANG_KEY) || 'pl';
+        panelField('text').value = text;
+      });
+      el.appendChild(btn);
+    });
+  }
+
+  function boot() {
+    if (!document.body) return setTimeout(boot, 200);
+    makeUi();
+    addInlineButtons();
+    const obs = new MutationObserver(() => addInlineButtons());
+    obs.observe(document.body, { childList: true, subtree: true });
+  }
+
+  boot();
+})();

+ 128 - 0
src/clients/translationClient.ts

@@ -0,0 +1,128 @@
+import { createHash } from 'node:crypto';
+import { config } from '../config.js';
+import { upstream } from '../errors.js';
+import { request, safeLabel } from './httpClient.js';
+import { logger } from '../logger.js';
+
+export interface TranslationInput {
+  text: string;
+  targetLanguage: string;
+  sourceLanguage?: string | null;
+  context?: {
+    conversationId?: number | null;
+    messageId?: string | null;
+    mode?: 'message' | 'draft';
+  };
+}
+
+export interface TranslationOutput {
+  translatedText: string;
+  sourceLanguage: string | null;
+  targetLanguage: string;
+  provider: string;
+}
+
+export function translationHash(text: string): string {
+  return createHash('sha256').update(text.trim()).digest('hex');
+}
+
+export class TranslationClient {
+  private readonly predictUrl: string;
+  private readonly apiKey: string;
+  private readonly timeoutMs: number;
+
+  constructor() {
+    const cfg = config();
+    this.predictUrl = cfg.FLOWISE_TRANSLATION_PREDICT_URL || cfg.FLOWISE_PREDICT_URL;
+    this.apiKey = cfg.FLOWISE_API_KEY;
+    this.timeoutMs = cfg.FLOWISE_TIMEOUT_MS;
+  }
+
+  async translate(input: TranslationInput): Promise<TranslationOutput> {
+    const target = normaliseLanguage(input.targetLanguage);
+    const source = input.sourceLanguage ? normaliseLanguage(input.sourceLanguage) : 'auto';
+    const question = buildTranslationQuestion(input.text, target, source, input.context?.mode ?? 'message');
+    const headers: Record<string, string> = {};
+    if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
+
+    logger.info('Calling Flowise translation flow', { target: safeLabel(this.predictUrl) });
+    const res = await request<Record<string, unknown>>(this.predictUrl, {
+      method: 'POST',
+      headers,
+      timeoutMs: this.timeoutMs,
+      label: 'flowise translation predict',
+      json: {
+        question,
+        overrideConfig: {
+          sessionId: `agent-translation:${translationHash(input.text).slice(0, 16)}:${target}`,
+          targetLanguage: target,
+          sourceLanguage: source,
+          mode: input.context?.mode ?? 'message',
+          conversationId: input.context?.conversationId ?? null,
+          messageId: input.context?.messageId ?? null,
+        },
+        metadata: {
+          purpose: 'agent_ui_translation',
+          sourceLanguage: source,
+          targetLanguage: target,
+          mode: input.context?.mode ?? 'message',
+          conversationId: input.context?.conversationId ?? null,
+          messageId: input.context?.messageId ?? null,
+        },
+      },
+    });
+
+    if (res.status >= 400) {
+      throw upstream('FLOWISE_TRANSLATION_ERROR', `Flowise translation returned HTTP ${res.status}.`);
+    }
+
+    const translatedText = extractTranslatedText(res.json, res.body);
+    if (!translatedText) {
+      throw upstream('EMPTY_TRANSLATION', 'Flowise translation returned no usable text.');
+    }
+
+    return {
+      translatedText,
+      sourceLanguage: source === 'auto' ? null : source,
+      targetLanguage: target,
+      provider: 'flowise',
+    };
+  }
+}
+
+function buildTranslationQuestion(text: string, targetLanguage: string, sourceLanguage: string, mode: string): string {
+  const modeInstruction =
+    mode === 'draft'
+      ? 'Translate this support agent draft so it can be sent to the customer.'
+      : 'Translate this customer/support message for an agent.';
+  return [
+    '[TRANSLATION_TASK]',
+    modeInstruction,
+    `Source language: ${sourceLanguage}`,
+    `Target language: ${targetLanguage}`,
+    'Rules:',
+    '- Return only the translated text, without preface or markdown fences.',
+    '- Preserve meaning, order numbers, product names, URLs, email addresses and quoted identifiers exactly.',
+    '- Keep line breaks where useful.',
+    '- Do not answer the customer; only translate.',
+    '[/TRANSLATION_TASK]',
+    '',
+    text,
+  ].join('\n');
+}
+
+function extractTranslatedText(json: unknown, rawBody: string): string {
+  if (json && typeof json === 'object' && !Array.isArray(json)) {
+    const obj = json as Record<string, unknown>;
+    const candidate = obj.text ?? obj.response ?? obj.answer ?? obj.translatedText ?? obj.translation;
+    if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
+  }
+  if (!json && rawBody.trim()) return rawBody.trim();
+  return '';
+}
+
+export function normaliseLanguage(value: string): string {
+  const v = value.trim().replace('_', '-').toLowerCase();
+  if (!/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/.test(v)) return 'pl';
+  return v;
+}

+ 3 - 0
src/config.ts

@@ -44,6 +44,7 @@ const schema = z.object({
 
   // --- Flowise ---
   FLOWISE_PREDICT_URL: z.string().url(),
+  FLOWISE_TRANSLATION_PREDICT_URL: z.string().url().optional(),
   FLOWISE_API_KEY: z.string().default(''),
   FLOWISE_BASE_URL: z.string().default(''),
   FLOWISE_TIMEOUT_MS: z.coerce.number().int().positive().default(90_000),
@@ -58,6 +59,8 @@ const schema = z.object({
   // --- Relay auth ---
   RELAY_SHARED_SECRET: z.string().min(8),
   ADMIN_TOKEN: z.string().default(''),
+  AGENT_UI_TOKEN: z.string().default(''),
+  AGENT_UI_ALLOWED_ORIGINS: z.string().default(''),
 
   // --- Behaviour ---
   TENANT_ID: z.string().default('easyklima'),

+ 2 - 0
src/http/app.ts

@@ -4,6 +4,7 @@ import { webhookRouter } from './routes/webhooks.js';
 import { toolsRouter } from './routes/tools.js';
 import { adminRouter } from './routes/admin.js';
 import { opsRouter } from './routes/ops.js';
+import { agentToolsRouter } from './routes/agentTools.js';
 import { requestLog } from './middleware/requestLog.js';
 import { errorHandler, notFoundHandler } from './middleware/errorHandler.js';
 
@@ -19,6 +20,7 @@ export function createApp(): express.Express {
   app.use(healthRouter);
   app.use(webhookRouter);
   app.use(toolsRouter);
+  app.use(agentToolsRouter);
   app.use(opsRouter);
   app.use(adminRouter);
 

+ 17 - 0
src/http/middleware/auth.ts

@@ -43,3 +43,20 @@ export function requireAdminAuth(req: Request, _res: Response, next: NextFunctio
   }
   next();
 }
+
+/** Guards browser-facing agent helper endpoints. It is intentionally separate
+ * from ADMIN_TOKEN: daily operator UI must not require the full ops/admin token.
+ */
+export function requireAgentUiAuth(req: Request, _res: Response, next: NextFunction): void {
+  const token = bearerToken(req);
+  const secret = config().AGENT_UI_TOKEN;
+  if (!secret) {
+    next(unauthorized('Agent UI endpoints are disabled (AGENT_UI_TOKEN is not set).'));
+    return;
+  }
+  if (!token || !safeEquals(secret, token)) {
+    next(unauthorized('Invalid or missing agent UI token.'));
+    return;
+  }
+  next();
+}

+ 153 - 0
src/http/routes/agentTools.ts

@@ -0,0 +1,153 @@
+import path from 'node:path';
+import { Router, type Request, type Response, type NextFunction } from 'express';
+import { z } from 'zod';
+import { config } from '../../config.js';
+import { badRequest } from '../../errors.js';
+import { requireAgentUiAuth } from '../middleware/auth.js';
+import { TranslationClient, normaliseLanguage, translationHash } from '../../clients/translationClient.js';
+import { db } from '../../store/db.js';
+import { audit } from '../../store/auditLog.js';
+
+export const agentToolsRouter = Router();
+
+const ASSET_PATH = path.resolve(import.meta.dirname, '../../../public/chatwoot-ai-tools.js');
+
+const translateSchema = z.object({
+  conversationId: z.coerce.number().int().positive().optional(),
+  messageId: z.union([z.string(), z.number()]).optional(),
+  targetLanguage: z.string().min(2).max(16).default('pl'),
+  sourceLanguage: z.string().min(2).max(16).optional(),
+  sourceText: z.string().min(1).max(20_000).optional(),
+  draftText: z.string().min(1).max(20_000).optional(),
+});
+
+agentToolsRouter.get('/chatwoot-ai-tools.js', (_req, res, next) => {
+  res.setHeader('Cache-Control', 'public, max-age=60');
+  res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
+  res.sendFile(ASSET_PATH, (err) => {
+    if (err) next(err);
+  });
+});
+
+agentToolsRouter.options('/agent-tools/{*path}', corsForAgentTools, (_req, res) => res.status(204).end());
+agentToolsRouter.use('/agent-tools', corsForAgentTools, requireAgentUiAuth);
+
+agentToolsRouter.post('/agent-tools/translate-message', async (req, res, next) => {
+  try {
+    const body = translateSchema.parse(req.body ?? {});
+    const sourceText = body.sourceText?.trim();
+    if (!sourceText) throw badRequest('MISSING_TEXT', 'sourceText is required.');
+    const result = await translateWithCache({
+      text: sourceText,
+      targetLanguage: body.targetLanguage,
+      sourceLanguage: body.sourceLanguage,
+      conversationId: body.conversationId,
+      messageId: body.messageId === undefined ? undefined : String(body.messageId),
+      mode: 'message',
+    });
+    res.json({ ok: true, ...result });
+  } catch (err) {
+    next(err);
+  }
+});
+
+agentToolsRouter.post('/agent-tools/translate-draft', async (req, res, next) => {
+  try {
+    const body = translateSchema.parse(req.body ?? {});
+    const draftText = body.draftText?.trim();
+    if (!draftText) throw badRequest('MISSING_TEXT', 'draftText is required.');
+    const result = await translateWithCache({
+      text: draftText,
+      targetLanguage: body.targetLanguage,
+      sourceLanguage: body.sourceLanguage,
+      conversationId: body.conversationId,
+      messageId: body.messageId === undefined ? undefined : String(body.messageId),
+      mode: 'draft',
+    });
+    res.json({ ok: true, ...result });
+  } catch (err) {
+    next(err);
+  }
+});
+
+async function translateWithCache(input: {
+  text: string;
+  targetLanguage: string;
+  sourceLanguage?: string;
+  conversationId?: number;
+  messageId?: string;
+  mode: 'message' | 'draft';
+}): Promise<{
+  translatedText: string;
+  sourceLanguage: string | null;
+  targetLanguage: string;
+  provider: string;
+  cached: boolean;
+}> {
+  const targetLanguage = normaliseLanguage(input.targetLanguage);
+  const sourceHash = translationHash(input.text);
+  const cached = await db().translationCache.findUnique({
+    where: { sourceHash_targetLanguage: { sourceHash, targetLanguage } },
+  });
+  if (cached) {
+    return {
+      translatedText: cached.translatedText,
+      sourceLanguage: cached.sourceLanguage,
+      targetLanguage: cached.targetLanguage,
+      provider: cached.provider,
+      cached: true,
+    };
+  }
+
+  const translated = await new TranslationClient().translate({
+    text: input.text,
+    targetLanguage,
+    sourceLanguage: input.sourceLanguage,
+    context: {
+      conversationId: input.conversationId ?? null,
+      messageId: input.messageId ?? null,
+      mode: input.mode,
+    },
+  });
+
+  await db().translationCache.create({
+    data: {
+      conversationId: input.conversationId ?? null,
+      messageId: input.messageId ?? null,
+      sourceHash,
+      sourceLanguage: translated.sourceLanguage,
+      targetLanguage: translated.targetLanguage,
+      translatedText: translated.translatedText,
+      provider: translated.provider,
+    },
+  });
+  await audit({
+    conversationId: input.conversationId,
+    messageId: input.messageId,
+    eventType: 'agent_translation',
+    summary: `Agent UI translated ${input.mode} to ${translated.targetLanguage}`,
+    meta: { mode: input.mode, provider: translated.provider },
+  });
+
+  return { ...translated, cached: false };
+}
+
+function corsForAgentTools(req: Request, res: Response, next: NextFunction): void {
+  const origin = req.header('origin') ?? '';
+  const allowed = allowedOrigins();
+  if (origin && allowed.has(origin)) {
+    res.setHeader('Access-Control-Allow-Origin', origin);
+    res.setHeader('Vary', 'Origin');
+    res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
+    res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
+  }
+  next();
+}
+
+function allowedOrigins(): Set<string> {
+  const cfg = config();
+  const entries = [cfg.CHATWOOT_BASE_URL, ...cfg.AGENT_UI_ALLOWED_ORIGINS.split(',')]
+    .map((s) => s.trim().replace(/\/+$/, ''))
+    .filter(Boolean);
+  return new Set(entries);
+}

+ 97 - 0
tests/integration/agentTools.test.ts

@@ -0,0 +1,97 @@
+import { after, before, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { prepareTestDatabase, startServer, type RunningServer } from '../helpers/testServer.js';
+import { applyTestConfig } from '../helpers/testConfig.js';
+
+const dbUrl = prepareTestDatabase('test-agent-tools');
+const { createApp } = await import('../../src/http/app.js');
+const { disconnectDb } = await import('../../src/store/db.js');
+
+let running: RunningServer;
+let originalFetch: typeof globalThis.fetch;
+let flowiseCalls = 0;
+
+before(async () => {
+  applyTestConfig({
+    DATABASE_URL: dbUrl,
+    AGENT_UI_TOKEN: 'test-agent-token',
+    CHATWOOT_BASE_URL: 'https://eksupport.easyklima.com',
+    FLOWISE_TRANSLATION_PREDICT_URL: 'https://flowise.test/api/v1/prediction/translation',
+  });
+  originalFetch = globalThis.fetch;
+  globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
+    const url = String(input);
+    if (url.includes('flowise.test')) {
+      flowiseCalls += 1;
+      const body = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>;
+      assert.match(String(body.question), /Translate this/);
+      return new Response(JSON.stringify({ text: 'Dzień dobry' }), {
+        status: 200,
+        headers: { 'Content-Type': 'application/json' },
+      });
+    }
+    return originalFetch(input, init);
+  }) as typeof globalThis.fetch;
+  running = await startServer(createApp());
+});
+
+after(async () => {
+  globalThis.fetch = originalFetch;
+  await running.close();
+  await disconnectDb();
+});
+
+test('serves the Chatwoot injection asset without secrets', async () => {
+  const res = await fetch(`${running.baseUrl}/chatwoot-ai-tools.js`);
+  const text = await res.text();
+  assert.equal(res.status, 200);
+  assert.match(res.headers.get('content-type') ?? '', /application\/javascript/);
+  assert.match(text, /AI tłumacz/);
+  assert.ok(!text.includes('test-agent-token'));
+});
+
+test('agent translation endpoints require the agent UI token', async () => {
+  const res = await fetch(`${running.baseUrl}/agent-tools/translate-message`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ sourceText: 'Hello', targetLanguage: 'pl' }),
+  });
+  assert.equal(res.status, 401);
+});
+
+test('translates message text through Flowise and caches repeated requests', async () => {
+  const payload = { sourceText: 'Good morning', targetLanguage: 'pl', conversationId: 42, messageId: 'm-1' };
+  const first = await postTranslate('/agent-tools/translate-message', payload);
+  const second = await postTranslate('/agent-tools/translate-message', payload);
+
+  assert.equal(first.status, 200);
+  assert.equal(second.status, 200);
+  assert.equal(first.body.translatedText, 'Dzień dobry');
+  assert.equal(first.body.cached, false);
+  assert.equal(second.body.cached, true);
+  assert.equal(flowiseCalls, 1);
+});
+
+test('translates draft text through the draft endpoint', async () => {
+  const res = await postTranslate('/agent-tools/translate-draft', {
+    draftText: 'Proszę podać numer zamówienia.',
+    targetLanguage: 'de',
+    conversationId: 42,
+  });
+  assert.equal(res.status, 200);
+  assert.equal(res.body.ok, true);
+  assert.equal(res.body.provider, 'flowise');
+});
+
+async function postTranslate(path: string, body: Record<string, unknown>) {
+  const res = await fetch(`${running.baseUrl}${path}`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Authorization: 'Bearer test-agent-token',
+      Origin: 'https://eksupport.easyklima.com',
+    },
+    body: JSON.stringify(body),
+  });
+  return { status: res.status, body: (await res.json()) as Record<string, unknown> };
+}