File size: 6,063 Bytes
48013ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee10e38
48013ee
ee10e38
48013ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
/**
 * 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<string, any>;
}

/**
 * Kirim pesan ke AI dan dapatkan response.
 * Support function calling / tool use.
 */
export async function chatCompletion(
  messages: PesanChat[],
  tools?: ChatCompletionTool[]
): Promise<HasilAI> {
  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"> ... </ | | DSML | | invoke>
    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<string, any> = {};

      // Regex mencocokkan argument: <arg name="NAMA">NILAI</arg>
      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<string> {
  const hasil = await chatCompletion([
    { role: 'system', content: systemPrompt },
    { role: 'user', content: userMessage },
  ]);
  return hasil.balasan || '';
}