Przeglądaj źródła

Refine Chatwoot translation workflow

Maciek 3 tygodni temu
rodzic
commit
891eba0b14

+ 155 - 96
public/chatwoot-ai-tools.js

@@ -6,17 +6,11 @@
 
   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 POPOVER_ID = 'eks-ai-translate-popover';
 
-  const state = {
-    busy: false,
-    result: '',
-    error: '',
-  };
-
-  function t(s) { return s; }
+  const state = { busy: false, result: '', error: '' };
 
   function ensureStyles() {
     if (document.getElementById('eks-ai-translate-styles')) return;
@@ -29,44 +23,49 @@
         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}, #${POPOVER_ID} {
+        position: fixed; z-index: 2147483000; background: #fff; color: #0f172a;
+        border: 1px solid #d7dee8; border-radius: 14px;
+        box-shadow: 0 18px 48px rgba(15,23,42,.28); font: 13px system-ui, sans-serif;
+      }
       #${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;
+        right: 18px; bottom: 66px; width: min(420px, calc(100vw - 36px));
+        max-height: min(720px, calc(100vh - 92px)); overflow: auto; padding: 14px;
       }
-      #${PANEL_ID}[hidden] { display: none; }
-      #${PANEL_ID} h3 { margin: 0 0 10px; font-size: 15px; }
+      #${POPOVER_ID} { right: 18px; top: 90px; width: min(460px, calc(100vw - 36px)); max-height: 55vh; overflow: auto; padding: 12px; }
+      #${PANEL_ID}[hidden], #${POPOVER_ID}[hidden] { display: none; }
+      #${PANEL_ID} h3, #${POPOVER_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 {
+      #${PANEL_ID} input, #${PANEL_ID} textarea {
         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, #${POPOVER_ID} .eks-row { display: flex; gap: 8px; align-items: center; }
       #${PANEL_ID} .eks-row > * { flex: 1; }
-      #${PANEL_ID} button {
+      #${PANEL_ID} button, #${POPOVER_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; }
+      #${PANEL_ID} button.secondary, #${POPOVER_ID} button.secondary { background: #e2e8f0; color: #0f172a; }
+      #${PANEL_ID} button:disabled, #${POPOVER_ID} button:disabled { opacity: .55; cursor: wait; }
+      #${PANEL_ID} .eks-muted, #${POPOVER_ID} .eks-muted { color: #64748b; font-size: 12px; }
+      #${PANEL_ID} .eks-error, #${POPOVER_ID} .eks-error { color: #b91c1c; white-space: pre-wrap; }
+      #${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; }
       .eks-inline-translate {
-        margin-left: 4px !important; border: 0 !important; border-radius: 999px !important;
-        padding: 1px 5px !important; background: #e0f2fe !important; color: #075985 !important;
-        font: 600 9px system-ui, sans-serif !important; line-height: 14px !important; cursor: pointer !important;
-        opacity: .82 !important; vertical-align: middle !important;
+        position: absolute !important; top: 4px !important; right: 4px !important; z-index: 6 !important;
+        border: 0 !important; border-radius: 999px !important; padding: 1px 5px !important;
+        background: rgba(224,242,254,.94) !important; color: #075985 !important;
+        font: 700 9px system-ui, sans-serif !important; line-height: 14px !important; cursor: pointer !important;
+        opacity: .72 !important; box-shadow: 0 1px 4px rgba(15,23,42,.18) !important;
       }
+      .eks-inline-translate:hover { opacity: 1 !important; }
       .eks-composer-translate {
         border: 0 !important; border-radius: 999px !important; padding: 5px 9px !important;
         background: #1f6feb !important; color: white !important; font: 600 12px system-ui, sans-serif !important;
         cursor: pointer !important; margin: 4px !important; box-shadow: 0 2px 8px rgba(15,23,42,.16) !important;
       }
+      .eks-composer-status { display: inline-block; margin-left: 6px; color: #64748b; font: 12px system-ui, sans-serif; }
     `;
     document.head.appendChild(style);
   }
@@ -86,36 +85,49 @@
       document.body.appendChild(toggle);
     }
 
+    if (!document.getElementById(POPOVER_ID)) {
+      const pop = document.createElement('section');
+      pop.id = POPOVER_ID;
+      pop.hidden = true;
+      pop.innerHTML = `
+        <div class="eks-row" style="justify-content:space-between">
+          <h3>Tłumaczenie wiadomości na polski</h3>
+          <button data-eks-pop="close" class="secondary" type="button">Zamknij</button>
+        </div>
+        <div data-eks-pop="status" class="eks-muted"></div>
+        <div data-eks-pop="result" class="eks-result"></div>
+        <div data-eks-pop="error" class="eks-error"></div>
+      `;
+      pop.querySelector('[data-eks-pop="close"]').addEventListener('click', () => { pop.hidden = true; });
+      document.body.appendChild(pop);
+    }
+
     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>
+      <h3>EKS AI tłumaczenia</h3>
+      <div class="eks-muted">Token operatora jest zapisany tylko w tej przeglądarce. Sekrety Flowise/OpenAI 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>Język docelowy dla wiadomości</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>
+        <button data-eks="selected" type="button">Tłumacz wiadomość na PL</button>
+        <button data-eks="draft" type="button">Draft PL → język klienta</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>Status</label>
+      <div data-eks="status" class="eks-muted"></div>
       <label>Wynik</label>
       <div data-eks="result" class="eks-result"></div>
       <div data-eks="error" class="eks-error"></div>
@@ -123,21 +135,17 @@
     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="selected"]').addEventListener('click', () => translateMessageFromPanel());
+    panel.querySelector('[data-eks="draft"]').addEventListener('click', () => translateAndReplaceDraft());
     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 panelField(name) { return document.querySelector(`#${PANEL_ID} [data-eks="${name}"]`); }
+  function popField(name) { return document.querySelector(`#${POPOVER_ID} [data-eks-pop="${name}"]`); }
 
   function fillSelectedText() {
     const selected = String(window.getSelection?.() || '').trim();
@@ -149,17 +157,15 @@
   }
 
   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 || '') : '';
+    const el = getDraftElement();
+    return el ? (el.value || el.innerText || el.textContent || '') : '';
   }
 
   function getDraftElement() {
     const active = document.activeElement;
     if (active && (active.tagName === 'TEXTAREA' || active.tagName === 'INPUT' || active.isContentEditable)) return active;
-    return document.querySelector('[contenteditable="true"][role="textbox"], textarea');
+    const editors = [...document.querySelectorAll('[contenteditable="true"][role="textbox"], textarea')];
+    return editors.find((el) => !el.closest(`#${PANEL_ID}`)) || null;
   }
 
   function setDraftText(el, text) {
@@ -180,59 +186,91 @@
     return false;
   }
 
-  async function translateAndReplaceDraft() {
+  async function translateAndReplaceDraft(button) {
     const el = getDraftElement();
     const text = (el ? (el.value || el.innerText || el.textContent || '') : '').trim();
+    const token = getToken();
+    if (!token) return showPanelError('Brak Agent UI token.');
+    if (!text) return showComposerStatus(button, 'Najpierw wpisz odpowiedź po polsku.', true);
+
     const panel = document.getElementById(PANEL_ID);
     panel.hidden = false;
     panelField('source').value = 'pl';
     panelField('text').value = text;
-    if (!panelField('target').value.trim() || panelField('target').value.trim() === 'pl') {
-      panelField('target').value = localStorage.getItem(LANG_KEY) || 'auto';
+    panelField('status').textContent = 'Wykrywam język klienta z pierwszej wiadomości i tłumaczę draft…';
+    showComposerStatus(button, 'Tłumaczę…');
+
+    try {
+      setBusy(true);
+      const translated = await callTranslate('/agent-tools/translate-draft', {
+        draftText: text,
+        sourceLanguage: 'pl',
+        targetLanguage: 'auto',
+        conversationId: detectConversationId(),
+      });
+      state.result = translated.translatedText || '';
+      panelField('result').textContent = state.result + (translated.cached ? '\n\n[cached]' : '');
+      panelField('status').textContent = 'Gotowe — wstawiłem tłumaczenie do pola odpowiedzi.';
+      setDraftText(el, state.result);
+      showComposerStatus(button, 'Gotowe');
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err);
+      showPanelError(msg);
+      showComposerStatus(button, msg, true);
+    } finally {
+      setBusy(false);
+      setTimeout(() => showComposerStatus(button, ''), 3500);
     }
-    const translated = await translate('draft');
-    if (translated) setDraftText(el, translated);
   }
 
-  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);
+  async function translateMessageFromPanel() {
+    const text = panelField('text').value.trim();
+    if (!text) return showPanelError('Brak tekstu do tłumaczenia.');
+    return translateMessageText(text, detectMessageId());
+  }
 
-    setBusy(true);
-    showError('');
-    panelField('result').textContent = '';
+  async function translateMessageText(text, messageId, button) {
+    const token = getToken();
+    if (!token) return showPanelError('Brak Agent UI token.');
+    showPopover('Tłumaczę wiadomość na polski…', '', '');
+    if (button) button.textContent = '…';
     try {
-      const endpoint = mode === 'draft' ? '/agent-tools/translate-draft' : '/agent-tools/translate-message';
-      const payload = {
-        targetLanguage,
-        sourceLanguage: sourceLanguageRaw && sourceLanguageRaw !== 'auto' ? sourceLanguageRaw : undefined,
+      setBusy(true);
+      const translated = await callTranslate('/agent-tools/translate-message', {
+        sourceText: text,
+        targetLanguage: 'pl',
         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),
+        messageId,
       });
-      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]' : '');
+      state.result = translated.translatedText || '';
+      showPopover(translated.cached ? 'Gotowe (cache).' : 'Gotowe.', state.result, '');
       return state.result;
     } catch (err) {
-      showError(err instanceof Error ? err.message : String(err));
+      const msg = err instanceof Error ? err.message : String(err);
+      showPopover('', '', msg);
     } finally {
+      if (button) button.textContent = 'AI';
       setBusy(false);
     }
   }
 
+  async function callTranslate(endpoint, payload) {
+    const token = getToken();
+    localStorage.setItem(STORE_KEY, token);
+    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}`);
+    return json;
+  }
+
+  function getToken() {
+    return panelField('token')?.value.trim() || localStorage.getItem(STORE_KEY) || '';
+  }
+
   function detectConversationId() {
     const m = location.pathname.match(/conversations\/(\d+)/) || location.hash.match(/conversations\/(\d+)/);
     return m ? Number(m[1]) : undefined;
@@ -248,15 +286,37 @@
     await navigator.clipboard.writeText(state.result);
   }
 
-  function showError(msg) {
+  function showPanelError(msg) {
     state.error = msg;
     const el = panelField('error');
     if (el) el.textContent = msg;
   }
 
+  function showPopover(status, result, error) {
+    const pop = document.getElementById(POPOVER_ID);
+    pop.hidden = false;
+    popField('status').textContent = status || '';
+    popField('result').textContent = result || '';
+    popField('error').textContent = error || '';
+  }
+
+  function showComposerStatus(button, msg, isError = false) {
+    const host = button?.parentElement;
+    let el = host?.querySelector?.('.eks-composer-status');
+    if (!el && host) {
+      el = document.createElement('span');
+      el.className = 'eks-composer-status';
+      host.appendChild(el);
+    }
+    if (el) {
+      el.textContent = msg || '';
+      el.style.color = isError ? '#b91c1c' : '#64748b';
+    }
+  }
+
   function setBusy(busy) {
     state.busy = busy;
-    document.querySelectorAll(`#${PANEL_ID} button`).forEach((btn) => { btn.disabled = busy; });
+    document.querySelectorAll(`#${PANEL_ID} button, #${POPOVER_ID} button, .eks-composer-translate, .eks-inline-translate`).forEach((btn) => { btn.disabled = busy; });
   }
 
   function addInlineButtons() {
@@ -264,7 +324,9 @@
     candidates.forEach((el) => {
       const text = (el.innerText || '').trim();
       if (text.length < 12 || text.length > 5000) return;
+      if (el.closest(`#${PANEL_ID}, #${POPOVER_ID}`)) return;
       el.setAttribute('data-eks-ai-bound', '1');
+      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
       const btn = document.createElement('button');
       btn.type = 'button';
       btn.className = 'eks-inline-translate';
@@ -272,10 +334,7 @@
       btn.title = 'Przetłumacz tę wiadomość na polski';
       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;
+        void translateMessageText(text, el.getAttribute('data-message-id') || el.getAttribute('data-id') || undefined, btn);
       });
       el.appendChild(btn);
     });
@@ -285,16 +344,16 @@
     const editors = document.querySelectorAll('[contenteditable="true"][role="textbox"], textarea');
     editors.forEach((el) => {
       if (el.dataset?.eksComposerBound === '1') return;
-      if (el.closest(`#${PANEL_ID}`)) return;
+      if (el.closest(`#${PANEL_ID}, #${POPOVER_ID}`)) return;
       if (el.dataset) el.dataset.eksComposerBound = '1';
       const btn = document.createElement('button');
       btn.type = 'button';
       btn.className = 'eks-composer-translate';
       btn.textContent = 'AI: PL → język klienta';
-      btn.title = 'Przetłumacz wpisaną odpowiedź po polsku na język klienta i wstaw do edytora';
+      btn.title = 'Przetłumacz wpisaną odpowiedź po polsku na język wykryty z pierwszej wiadomości klienta';
       btn.addEventListener('click', (e) => {
         e.preventDefault(); e.stopPropagation();
-        void translateAndReplaceDraft();
+        void translateAndReplaceDraft(btn);
       });
       const host = el.parentElement;
       if (host) host.appendChild(btn);

+ 18 - 0
src/clients/chatwootClient.ts

@@ -19,6 +19,15 @@ export interface ChatwootConversation {
   contact_inbox?: { source_id?: string };
 }
 
+export interface ChatwootMessage {
+  id?: number | string;
+  content?: string | null;
+  message_type?: 'incoming' | 'outgoing' | string;
+  private?: boolean;
+  created_at?: number | string;
+  sender?: { id?: number | string; name?: string; email?: string; type?: string };
+}
+
 export class ChatwootClient {
   private readonly baseUrl: string;
   private readonly token: string;
@@ -58,6 +67,15 @@ export class ChatwootClient {
     return this.api<ChatwootConversation>('GET', `/conversations/${conversationId}`);
   }
 
+  async getConversationMessages(conversationId: number): Promise<ChatwootMessage[]> {
+    const res = await this.api<ChatwootMessage[] | { payload?: ChatwootMessage[] }>(
+      'GET',
+      `/conversations/${conversationId}/messages`,
+    );
+    if (Array.isArray(res)) return res;
+    return Array.isArray(res.payload) ? res.payload : [];
+  }
+
   /**
    * Chatwoot's labels endpoint REPLACES the whole label set, so merge with what
    * is already there. No-op when the label is already present.

+ 35 - 3
src/clients/translationClient.ts

@@ -12,6 +12,7 @@ export interface TranslationInput {
     conversationId?: number | null;
     messageId?: string | null;
     mode?: 'message' | 'draft';
+    firstCustomerMessage?: string | null;
   };
 }
 
@@ -41,7 +42,7 @@ export class TranslationClient {
   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 question = buildTranslationQuestion(input.text, target, source, input.context);
     const headers: Record<string, string> = {};
     if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
 
@@ -64,10 +65,10 @@ export class TranslationClient {
         metadata: {
           purpose: 'agent_ui_translation',
           sourceLanguage: source,
-          targetLanguage: target,
           mode: input.context?.mode ?? 'message',
           conversationId: input.context?.conversationId ?? null,
           messageId: input.context?.messageId ?? null,
+          hasFirstCustomerMessage: Boolean(input.context?.firstCustomerMessage),
         },
       },
     });
@@ -90,7 +91,37 @@ export class TranslationClient {
   }
 }
 
-function buildTranslationQuestion(text: string, targetLanguage: string, sourceLanguage: string, mode: string): string {
+function buildTranslationQuestion(
+  text: string,
+  targetLanguage: string,
+  sourceLanguage: string,
+  context?: TranslationInput['context'],
+): string {
+  const mode = context?.mode ?? 'message';
+  const firstCustomerMessage = context?.firstCustomerMessage?.trim();
+  if (mode === 'draft' && firstCustomerMessage) {
+    return [
+      '[TRANSLATION_TASK]',
+      'You are translating a support agent draft for a customer.',
+      'First detect the language used by the customer in FIRST_CUSTOMER_MESSAGE.',
+      'Translate AGENT_DRAFT_POLISH from Polish into that detected customer language.',
+      'Rules:',
+      '- Return only the translated agent draft, without preface or markdown fences.',
+      '- Do not answer the customer; only translate the agent draft.',
+      '- Preserve order numbers, product names, URLs, email addresses and quoted identifiers exactly.',
+      '- Keep the support tone natural in the detected language.',
+      '[/TRANSLATION_TASK]',
+      '',
+      '[FIRST_CUSTOMER_MESSAGE]',
+      firstCustomerMessage,
+      '[/FIRST_CUSTOMER_MESSAGE]',
+      '',
+      '[AGENT_DRAFT_POLISH]',
+      text,
+      '[/AGENT_DRAFT_POLISH]',
+    ].join('\n');
+  }
+
   const modeInstruction =
     mode === 'draft'
       ? 'Translate this support agent draft so it can be sent to the customer.'
@@ -123,6 +154,7 @@ function extractTranslatedText(json: unknown, rawBody: string): string {
 
 export function normaliseLanguage(value: string): string {
   const v = value.trim().replace('_', '-').toLowerCase();
+  if (['auto', 'detect', 'detected', 'customer'].includes(v)) return 'auto';
   if (!/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/.test(v)) return 'pl';
   return v;
 }

+ 43 - 3
src/http/routes/agentTools.ts

@@ -5,6 +5,7 @@ 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 { ChatwootClient, type ChatwootMessage } from '../../clients/chatwootClient.js';
 import { db } from '../../store/db.js';
 import { audit } from '../../store/auditLog.js';
 
@@ -56,13 +57,24 @@ agentToolsRouter.post('/agent-tools/translate-draft', async (req, res, next) =>
     const body = translateSchema.parse(req.body ?? {});
     const draftText = body.draftText?.trim();
     if (!draftText) throw badRequest('MISSING_TEXT', 'draftText is required.');
+    const targetLanguage = normaliseLanguage(body.targetLanguage);
+    const firstCustomerMessage = body.conversationId
+      ? await firstIncomingCustomerMessage(body.conversationId)
+      : null;
+    if (targetLanguage === 'auto' && !firstCustomerMessage) {
+      throw badRequest(
+        'MISSING_CUSTOMER_LANGUAGE_SAMPLE',
+        'conversationId with at least one incoming customer message is required for auto target language.',
+      );
+    }
     const result = await translateWithCache({
       text: draftText,
-      targetLanguage: body.targetLanguage,
-      sourceLanguage: body.sourceLanguage,
+      targetLanguage,
+      sourceLanguage: body.sourceLanguage ?? 'pl',
       conversationId: body.conversationId,
       messageId: body.messageId === undefined ? undefined : String(body.messageId),
       mode: 'draft',
+      firstCustomerMessage,
     });
     res.json({ ok: true, ...result });
   } catch (err) {
@@ -77,6 +89,7 @@ async function translateWithCache(input: {
   conversationId?: number;
   messageId?: string;
   mode: 'message' | 'draft';
+  firstCustomerMessage?: string | null;
 }): Promise<{
   translatedText: string;
   sourceLanguage: string | null;
@@ -85,7 +98,11 @@ async function translateWithCache(input: {
   cached: boolean;
 }> {
   const targetLanguage = normaliseLanguage(input.targetLanguage);
-  const sourceHash = translationHash(input.text);
+  const sourceHash = translationHash(
+    input.mode === 'draft' && input.firstCustomerMessage
+      ? `${input.text}\n\n[FIRST_CUSTOMER_MESSAGE]\n${input.firstCustomerMessage}`
+      : input.text,
+  );
   const cached = await db().translationCache.findUnique({
     where: { sourceHash_targetLanguage: { sourceHash, targetLanguage } },
   });
@@ -107,6 +124,7 @@ async function translateWithCache(input: {
       conversationId: input.conversationId ?? null,
       messageId: input.messageId ?? null,
       mode: input.mode,
+      firstCustomerMessage: input.firstCustomerMessage ?? null,
     },
   });
 
@@ -132,6 +150,28 @@ async function translateWithCache(input: {
   return { ...translated, cached: false };
 }
 
+async function firstIncomingCustomerMessage(conversationId: number): Promise<string | null> {
+  const messages = await new ChatwootClient().getConversationMessages(conversationId);
+  const incoming = messages
+    .filter(isPublicIncoming)
+    .sort((a, b) => timestamp(a) - timestamp(b))
+    .find((m) => typeof m.content === 'string' && m.content.trim().length > 0);
+  return incoming?.content?.trim().slice(0, 5_000) ?? null;
+}
+
+function isPublicIncoming(message: ChatwootMessage): boolean {
+  return message.private !== true && message.message_type === 'incoming';
+}
+
+function timestamp(message: ChatwootMessage): number {
+  if (typeof message.created_at === 'number') return message.created_at;
+  if (typeof message.created_at === 'string') {
+    const parsed = Date.parse(message.created_at);
+    return Number.isFinite(parsed) ? parsed : 0;
+  }
+  return 0;
+}
+
 function corsForAgentTools(req: Request, res: Response, next: NextFunction): void {
   const origin = req.header('origin') ?? '';
   const allowed = allowedOrigins();

+ 14 - 2
tests/integration/agentTools.test.ts

@@ -24,12 +24,24 @@ before(async () => {
     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/);
+      assert.match(String(body.question), /Translate this|TRANSLATION_TASK/);
       return new Response(JSON.stringify({ text: 'Dzień dobry' }), {
         status: 200,
         headers: { 'Content-Type': 'application/json' },
       });
     }
+    if (url.includes('eksupport.easyklima.com') && url.includes('/conversations/42/messages')) {
+      assert.equal((init?.headers as Record<string, string>)?.api_access_token, 'test-chatwoot-token');
+      return new Response(
+        JSON.stringify({
+          payload: [
+            { id: 1, message_type: 'outgoing', content: 'Dzień dobry' },
+            { id: 2, message_type: 'incoming', private: false, created_at: 1, content: 'Guten Morgen, ich brauche Hilfe.' },
+          ],
+        }),
+        { status: 200, headers: { 'Content-Type': 'application/json' } },
+      );
+    }
     return originalFetch(input, init);
   }) as typeof globalThis.fetch;
   running = await startServer(createApp());
@@ -75,7 +87,7 @@ test('translates message text through Flowise and caches repeated requests', asy
 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',
+    targetLanguage: 'auto',
     conversationId: 42,
   });
   assert.equal(res.status, 200);