agentTools.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import path from 'node:path';
  2. import { Router, type Request, type Response, type NextFunction } from 'express';
  3. import { z } from 'zod';
  4. import { config } from '../../config.js';
  5. import { badRequest } from '../../errors.js';
  6. import { requireAgentUiAuth } from '../middleware/auth.js';
  7. import { TranslationClient, normaliseLanguage, translationHash } from '../../clients/translationClient.js';
  8. import { ChatwootClient, type ChatwootMessage } from '../../clients/chatwootClient.js';
  9. import { db } from '../../store/db.js';
  10. import { audit } from '../../store/auditLog.js';
  11. export const agentToolsRouter = Router();
  12. const ASSET_PATH = path.resolve(import.meta.dirname, '../../../public/chatwoot-ai-tools.js');
  13. const translateSchema = z.object({
  14. conversationId: z.coerce.number().int().positive().optional(),
  15. messageId: z.union([z.string(), z.number()]).optional(),
  16. targetLanguage: z.string().min(2).max(16).default('pl'),
  17. sourceLanguage: z.string().min(2).max(16).optional(),
  18. sourceText: z.string().min(1).max(20_000).optional(),
  19. draftText: z.string().min(1).max(20_000).optional(),
  20. });
  21. agentToolsRouter.get('/chatwoot-ai-tools.js', (_req, res, next) => {
  22. res.setHeader('Cache-Control', 'public, max-age=60');
  23. res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
  24. res.sendFile(ASSET_PATH, (err) => {
  25. if (err) next(err);
  26. });
  27. });
  28. agentToolsRouter.options('/agent-tools/{*path}', corsForAgentTools, (_req, res) => res.status(204).end());
  29. agentToolsRouter.use('/agent-tools', corsForAgentTools, requireAgentUiAuth);
  30. agentToolsRouter.post('/agent-tools/translate-message', async (req, res, next) => {
  31. try {
  32. const body = translateSchema.parse(req.body ?? {});
  33. const sourceText = body.sourceText?.trim();
  34. if (!sourceText) throw badRequest('MISSING_TEXT', 'sourceText is required.');
  35. const result = await translateWithCache({
  36. text: sourceText,
  37. targetLanguage: body.targetLanguage,
  38. sourceLanguage: body.sourceLanguage,
  39. conversationId: body.conversationId,
  40. messageId: body.messageId === undefined ? undefined : String(body.messageId),
  41. mode: 'message',
  42. });
  43. res.json({ ok: true, ...result });
  44. } catch (err) {
  45. next(err);
  46. }
  47. });
  48. agentToolsRouter.post('/agent-tools/translate-draft', async (req, res, next) => {
  49. try {
  50. const body = translateSchema.parse(req.body ?? {});
  51. const draftText = body.draftText?.trim();
  52. if (!draftText) throw badRequest('MISSING_TEXT', 'draftText is required.');
  53. const targetLanguage = normaliseLanguage(body.targetLanguage);
  54. const firstCustomerMessage = body.conversationId
  55. ? await firstIncomingCustomerMessage(body.conversationId)
  56. : null;
  57. if (targetLanguage === 'auto' && !firstCustomerMessage) {
  58. throw badRequest(
  59. 'MISSING_CUSTOMER_LANGUAGE_SAMPLE',
  60. 'conversationId with at least one incoming customer message is required for auto target language.',
  61. );
  62. }
  63. const result = await translateWithCache({
  64. text: draftText,
  65. targetLanguage,
  66. sourceLanguage: body.sourceLanguage ?? 'pl',
  67. conversationId: body.conversationId,
  68. messageId: body.messageId === undefined ? undefined : String(body.messageId),
  69. mode: 'draft',
  70. firstCustomerMessage,
  71. });
  72. res.json({ ok: true, ...result });
  73. } catch (err) {
  74. next(err);
  75. }
  76. });
  77. async function translateWithCache(input: {
  78. text: string;
  79. targetLanguage: string;
  80. sourceLanguage?: string;
  81. conversationId?: number;
  82. messageId?: string;
  83. mode: 'message' | 'draft';
  84. firstCustomerMessage?: string | null;
  85. }): Promise<{
  86. translatedText: string;
  87. sourceLanguage: string | null;
  88. targetLanguage: string;
  89. provider: string;
  90. cached: boolean;
  91. }> {
  92. const targetLanguage = normaliseLanguage(input.targetLanguage);
  93. const sourceHash = translationHash(
  94. input.mode === 'draft' && input.firstCustomerMessage
  95. ? `${input.text}\n\n[FIRST_CUSTOMER_MESSAGE]\n${input.firstCustomerMessage}`
  96. : input.text,
  97. );
  98. const cached = await db().translationCache.findUnique({
  99. where: { sourceHash_targetLanguage: { sourceHash, targetLanguage } },
  100. });
  101. if (cached) {
  102. return {
  103. translatedText: cached.translatedText,
  104. sourceLanguage: cached.sourceLanguage,
  105. targetLanguage: cached.targetLanguage,
  106. provider: cached.provider,
  107. cached: true,
  108. };
  109. }
  110. const translated = await new TranslationClient().translate({
  111. text: input.text,
  112. targetLanguage,
  113. sourceLanguage: input.sourceLanguage,
  114. context: {
  115. conversationId: input.conversationId ?? null,
  116. messageId: input.messageId ?? null,
  117. mode: input.mode,
  118. firstCustomerMessage: input.firstCustomerMessage ?? null,
  119. },
  120. });
  121. await db().translationCache.create({
  122. data: {
  123. conversationId: input.conversationId ?? null,
  124. messageId: input.messageId ?? null,
  125. sourceHash,
  126. sourceLanguage: translated.sourceLanguage,
  127. targetLanguage: translated.targetLanguage,
  128. translatedText: translated.translatedText,
  129. provider: translated.provider,
  130. },
  131. });
  132. await audit({
  133. conversationId: input.conversationId,
  134. messageId: input.messageId,
  135. eventType: 'agent_translation',
  136. summary: `Agent UI translated ${input.mode} to ${translated.targetLanguage}`,
  137. meta: { mode: input.mode, provider: translated.provider },
  138. });
  139. return { ...translated, cached: false };
  140. }
  141. async function firstIncomingCustomerMessage(conversationId: number): Promise<string | null> {
  142. const messages = await new ChatwootClient().getConversationMessages(conversationId);
  143. const incoming = messages
  144. .filter(isPublicIncoming)
  145. .sort((a, b) => timestamp(a) - timestamp(b))
  146. .find((m) => typeof m.content === 'string' && m.content.trim().length > 0);
  147. return incoming?.content?.trim().slice(0, 5_000) ?? null;
  148. }
  149. function isPublicIncoming(message: ChatwootMessage): boolean {
  150. return message.private !== true && message.message_type === 'incoming';
  151. }
  152. function timestamp(message: ChatwootMessage): number {
  153. if (typeof message.created_at === 'number') return message.created_at;
  154. if (typeof message.created_at === 'string') {
  155. const parsed = Date.parse(message.created_at);
  156. return Number.isFinite(parsed) ? parsed : 0;
  157. }
  158. return 0;
  159. }
  160. function corsForAgentTools(req: Request, res: Response, next: NextFunction): void {
  161. const origin = req.header('origin') ?? '';
  162. const allowed = allowedOrigins();
  163. if (origin && allowed.has(origin)) {
  164. res.setHeader('Access-Control-Allow-Origin', origin);
  165. res.setHeader('Vary', 'Origin');
  166. res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
  167. res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
  168. }
  169. next();
  170. }
  171. function allowedOrigins(): Set<string> {
  172. const cfg = config();
  173. const entries = [cfg.CHATWOOT_BASE_URL, ...cfg.AGENT_UI_ALLOWED_ORIGINS.split(',')]
  174. .map((s) => s.trim().replace(/\/+$/, ''))
  175. .filter(Boolean);
  176. return new Set(entries);
  177. }