| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193 |
- 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 { ChatwootClient, type ChatwootMessage } from '../../clients/chatwootClient.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 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,
- 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) {
- next(err);
- }
- });
- async function translateWithCache(input: {
- text: string;
- targetLanguage: string;
- sourceLanguage?: string;
- conversationId?: number;
- messageId?: string;
- mode: 'message' | 'draft';
- firstCustomerMessage?: string | null;
- }): Promise<{
- translatedText: string;
- sourceLanguage: string | null;
- targetLanguage: string;
- provider: string;
- cached: boolean;
- }> {
- const targetLanguage = normaliseLanguage(input.targetLanguage);
- 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 } },
- });
- 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,
- firstCustomerMessage: input.firstCustomerMessage ?? null,
- },
- });
- 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 };
- }
- 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();
- 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);
- }
|