Spaces:
Sleeping
Sleeping
| /** | |
| * 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 || ''; | |
| } | |