/** * ai-client.ts * Client untuk berkomunikasi dengan AI melalui OpenRouter API. * Menggunakan SDK OpenAI karena OpenRouter API-nya OpenAI-compatible. */ import OpenAI from 'openai'; import type { ChatCompletionMessageParam, ChatCompletionTool } from 'openai/resources/chat/completions'; // Inisialisasi client OpenAI dengan baseURL OpenRouter // Lazy initialization client OpenAI menggunakan konfigurasi dari .env let openai: OpenAI | null = null; function getOpenAI(): OpenAI { if (openai) return openai; const aiBaseUrl = process.env.AI_BASE_URL; const aiApiKey = process.env.AI_API_KEY; const aiModel = process.env.AI_MODEL; if (!aiApiKey || !aiBaseUrl || !aiModel) { throw new Error('Konfigurasi AI tidak lengkap di file .env (Pastikan AI_BASE_URL, AI_API_KEY, dan AI_MODEL terisi).'); } openai = new OpenAI({ baseURL: aiBaseUrl, apiKey: aiApiKey, }); return openai; } export interface PesanChat { role: 'system' | 'user' | 'assistant' | 'tool'; content: string; tool_call_id?: string; tool_calls?: any[]; } export interface HasilAI { balasan: string | null; tool_calls: ToolCall[] | null; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number; }; } export interface ToolCall { id: string; nama: string; argumen: Record; } /** * Kirim pesan ke AI dan dapatkan response. * Support function calling / tool use. */ export async function chatCompletion( messages: PesanChat[], tools?: ChatCompletionTool[] ): Promise { const model = process.env.AI_MODEL!; const maxTokens = Number(process.env.AI_MAX_TOKENS) || 2000; const requestMessages: ChatCompletionMessageParam[] = messages.map((m) => { if (m.role === 'tool') { return { role: 'tool' as const, content: m.content, tool_call_id: m.tool_call_id || '', }; } if (m.role === 'assistant' && m.tool_calls) { return { role: 'assistant' as const, content: m.content || null, tool_calls: m.tool_calls, }; } return { role: m.role as 'system' | 'user' | 'assistant', content: m.content, }; }); const params: any = { model, messages: requestMessages, max_tokens: maxTokens, temperature: 0.7, }; // Hanya tambahkan tools jika ada if (tools && tools.length > 0) { params.tools = tools; params.tool_choice = 'auto'; } console.log('[AI Client] Params:', JSON.stringify(params, null, 2)); const response = await getOpenAI().chat.completions.create(params); console.log('[AI Client] Raw Response Choices:', JSON.stringify(response.choices, null, 2)); const choice = response.choices[0]; // Parse tool calls jika ada secara native let toolCalls: ToolCall[] | null = null; if (choice.message.tool_calls && choice.message.tool_calls.length > 0) { toolCalls = choice.message.tool_calls.map((tc) => ({ id: tc.id, nama: (tc as any).function.name, argumen: JSON.parse((tc as any).function.arguments || '{}'), })); } let balasan = choice.message.content || null; // Fallback parser jika AI menulis tag DSML secara literal di teks (karena limitasi API/model gratis) if (!toolCalls && balasan) { const dsmlCalls = parseDsmlToolCalls(balasan); if (dsmlCalls) { toolCalls = dsmlCalls; // Bersihkan teks tag XML/DSML dari balasan agar bubble chat rapi balasan = balasan .replace(/<\s*\|?\s*\|?\s*DSML[\s\S]*?<\s*\/\s*\|?\s*\|?\s*DSML\s*\|?\s*\|?\s*tool_calls\s*>/g, '') // Hapus juga tag invoke/tool_calls sisa jika terpisah .replace(/<\s*\|?\s*\|?\s*DSML[\s\S]*?>/g, '') .replace(/<\s*\/\s*\|?\s*\|?\s*DSML[\s\S]*?>/g, '') .trim(); } } return { balasan, tool_calls: toolCalls, usage: response.usage ? { prompt_tokens: response.usage.prompt_tokens, completion_tokens: response.usage.completion_tokens, total_tokens: response.usage.total_tokens, } : undefined, }; } /** * Parser penanganan tag DSML/XML tool calls yang dikirim literal oleh model AI */ function parseDsmlToolCalls(text: string): ToolCall[] | null { if (!text.includes('tool_calls') && !text.includes('DSML')) return null; try { const toolCalls: ToolCall[] = []; // Regex fleksibel mencocokkan invoke block: < | | DSML | | invoke name="NAMA"> ... const invokeRegex = /<\s*\|?\s*\|?\s*DSML\s*\|?\s*\|?\s*invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\s*\/\s*\|?\s*\|?\s*DSML\s*\|?\s*\|?\s*invoke\s*>/g; let match; let idx = 0; while ((match = invokeRegex.exec(text)) !== null) { const toolName = match[1]; const innerContent = match[2]; const args: Record = {}; // Regex mencocokkan argument: NILAI const argRegex = /<\s*(?:arg|parameter)\s+name="([^"]+)"\s*>([\s\S]*?)<\s*\/\s*(?:arg|parameter)\s*>/g; let argMatch; while ((argMatch = argRegex.exec(innerContent)) !== null) { const argName = argMatch[1]; const argValue = argMatch[2].trim(); if (/^-?\d+$/.test(argValue)) { args[argName] = parseInt(argValue, 10); } else if (/^-?\d+\.\d+$/.test(argValue)) { args[argName] = parseFloat(argValue); } else { args[argName] = argValue; } } toolCalls.push({ id: `dsml_${Date.now()}_${idx++}`, nama: toolName, argumen: args, }); } return toolCalls.length > 0 ? toolCalls : null; } catch (err) { console.error('Gagal parsing DSML tool calls:', err); return null; } } /** * Kirim pesan sederhana tanpa tools (untuk format ulang jawaban dll). */ export async function chatSederhana(systemPrompt: string, userMessage: string): Promise { const hasil = await chatCompletion([ { role: 'system', content: systemPrompt }, { role: 'user', content: userMessage }, ]); return hasil.balasan || ''; }