ChatMotif Ai
ChatMotif ai no account unlimited chat realtime support file input, session/conversation chat available 3 mode, about check markdown file
3 Oktober 20261082 file
ChatMotif.jsjavascript · 648 baris
/** * Motif Chat Wrapper (Node.js ESM) – durable version * * Fitur: * - Auto-discover next-action (tidak perlu update manual) * - Response level: small | medium | high (atau low) * - Upload & baca file * - Session / cookie persistence (opsional) * - Streaming real-time * * CLI: * node motif-chat.mjs "hi siapa kamu?" * node motif-chat.mjs "apa isi file?" --file ./doc.pdf --level high * node motif-chat.mjs "lanjut" --conv <id> --stream * * Session: * Simpan cookie di file JSON (default: .motif-session.json) * --session path/to/session.json */import { readFile, writeFile, access } from 'node:fs/promises';import { basename } from 'node:path';// Native fetch ada di Node 18+. Kalau lebih tua, install undici.const fetch = globalThis.fetch;const BASE = 'https://chat.motiftech.io';const DEFAULT_SESSION = '.motif-session.json';const LEVEL_MAP = { low: 'small', small: 'small', medium: 'medium', high: 'high',};// ─── Session / Cookie helpers ───────────────────────────────────────────────async function loadSession(path = DEFAULT_SESSION) { try { await access(path); const raw = await readFile(path, 'utf8'); return JSON.parse(raw); } catch { return { cookies: {}, nextAction: null, updatedAt: null }; }}async function saveSession(session, path = DEFAULT_SESSION) { session.updatedAt = new Date().toISOString(); await writeFile(path, JSON.stringify(session, null, 2));}function cookieHeader(cookies = {}) { return Object.entries(cookies) .map(([k, v]) => `${k}=${v}`) .join('; ');}function mergeSetCookie(res, cookies = {}) { // undici / node fetch: getSetCookie() tersedia di Node 20+ const setCookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : (res.headers.get('set-cookie') || '').split(/,(?=\s*[^;]+=)/); for (const sc of setCookies) { if (!sc) continue; const [pair] = sc.split(';'); const eq = pair.indexOf('='); if (eq > 0) { const name = pair.slice(0, eq).trim(); const value = pair.slice(eq + 1).trim(); cookies[name] = value; } } return cookies;}// ─── Auto-discover next-action ──────────────────────────────────────────────/** * Scrape semua createServerReference hash dari chunk JS. * Hash Next.js server action di build ini panjangnya 42 karakter (bukan 40). * Kita kumpulkan kandidat, prioritaskan yang pernah berhasil (di session), * lalu sisanya diurutkan (yang diawali "40" sering muncul untuk chat). */async function scrapeActionCandidates(session) { const headers = { 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Mobile Safari/537.36', Accept: 'text/html', }; if (Object.keys(session.cookies || {}).length) { headers.Cookie = cookieHeader(session.cookies); } const pageRes = await fetch(`${BASE}/chat`, { headers }); session.cookies = mergeSetCookie(pageRes, session.cookies || {}); const html = await pageRes.text(); const chunkUrls = [ ...new Set( (html.match(/\/_next\/static\/chunks\/[^"'\s]+\.js/g) || []).map((u) => u.startsWith('http') ? u : BASE + u ) ), ]; // Prioritas chunk besar yang biasanya berisi server actions const priority = chunkUrls.filter( (u) => u.includes('5890') || u.includes('5813') || u.includes('c16f') || u.includes('main-app') || u.includes('/chat/') ); const toScan = [ ...priority, ...chunkUrls.filter((u) => !priority.includes(u)), ].slice(0, 10); // Hash bisa 40–44 hex (saat ini 42) const re = /createServerReference\s*\)?\s*\(\s*["']([a-f0-9]{40,44})["']/g; const found = new Set(); for (const url of toScan) { try { const jsRes = await fetch(url, { headers }); if (!jsRes.ok) continue; const js = await jsRes.text(); let m; while ((m = re.exec(js)) !== null) { found.add(m[1]); } } catch { // skip } } return [...found];}async function discoverNextAction(session, sessionPath) { // Cache masih valid (< 12 jam) → pakai langsung if (session.nextAction && session.updatedAt) { const age = Date.now() - new Date(session.updatedAt).getTime(); if (age < 12 * 60 * 60 * 1000) return session.nextAction; } const candidates = await scrapeActionCandidates(session); if (!candidates.length) { if (session.nextAction) return session.nextAction; // pakai yang lama throw new Error( 'Gagal menemukan next-action (tidak ada hash di JS). Coba lagi nanti atau cek koneksi.' ); } // Prioritas: // 1. Yang sebelumnya berhasil (masih ada di list) // 2. Yang mirip pola chat historis (diawali 401e...) // 3. Sisanya const ordered = []; if (session.nextAction && candidates.includes(session.nextAction)) { ordered.push(session.nextAction); } for (const h of candidates) { if (h.startsWith('401e') && !ordered.includes(h)) ordered.push(h); } for (const h of candidates) { if (!ordered.includes(h)) ordered.push(h); } // Ambil kandidat pertama sebagai default (akan divalidasi saat init; // kalau gagal, chat() akan clear cache dan retry) const chosen = ordered[0]; session.nextAction = chosen; session._candidates = ordered; // simpan untuk retry await saveSession(session, sessionPath); return chosen;}// ─── Upload file ────────────────────────────────────────────────────────────/** * @param {string|Buffer} filePathOrBuffer * @param {object} opts * @param {string} [opts.filename] * @param {string|null} [opts.conversationId] * @param {object} [opts.session] * @param {string} [opts.sessionPath] *//** MIME map – server menolak application/octet-stream */const MIME_BY_EXT = { '.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/plain', '.csv': 'text/plain', '.json': 'text/plain', '.xml': 'text/plain', '.html': 'text/plain', '.htm': 'text/plain', '.js': 'text/plain', '.mjs': 'text/plain', '.cjs': 'text/plain', '.ts': 'text/plain', '.tsx': 'text/plain', '.jsx': 'text/plain', '.py': 'text/plain', '.java': 'text/plain', '.c': 'text/plain', '.cpp': 'text/plain', '.h': 'text/plain', '.cs': 'text/plain', '.go': 'text/plain', '.rs': 'text/plain', '.rb': 'text/plain', '.php': 'text/plain', '.sh': 'text/plain', '.sql': 'text/plain', '.yaml': 'text/plain', '.yml': 'text/plain', '.toml': 'text/plain', '.ini': 'text/plain', '.log': 'text/plain', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.doc': 'application/msword',};function guessMime(filename) { const lower = String(filename).toLowerCase(); const dot = lower.lastIndexOf('.'); if (dot >= 0) { const ext = lower.slice(dot); if (MIME_BY_EXT[ext]) return MIME_BY_EXT[ext]; } return 'text/plain';}/** * Upload file. Wajib ada conversationId (API Motif mensyaratkan). * Untuk alur praktis pakai chatWithFile() yang bootstrap conversation otomatis. */export async function uploadFile(filePathOrBuffer, opts = {}) { const { filename, conversationId = null, sessionPath = DEFAULT_SESSION, } = opts; if (!conversationId) { throw new Error( 'uploadFile butuh conversationId. Pakai chatWithFile() atau kirim --conv <id>.' ); } let session = opts.session || (await loadSession(sessionPath)); let buffer, name; if (typeof filePathOrBuffer === 'string') { buffer = await readFile(filePathOrBuffer); name = filename || basename(filePathOrBuffer); } else { buffer = filePathOrBuffer; name = filename || 'file.txt'; } const mime = guessMime(name); const form = new FormData(); form.append('file', new Blob([buffer], { type: mime }), name); form.append('conversation_id', conversationId); const headers = { Referer: `${BASE}/chat`, Origin: BASE, 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Mobile Safari/537.36', }; if (Object.keys(session.cookies || {}).length) { headers.Cookie = cookieHeader(session.cookies); } const res = await fetch(`${BASE}/api/file/upload/direct`, { method: 'POST', headers, body: form, }); session.cookies = mergeSetCookie(res, session.cookies || {}); await saveSession(session, sessionPath); if (!res.ok) { const txt = await res.text(); throw new Error(`Upload gagal (${res.status}): ${txt.slice(0, 300)}`); } const data = await res.json(); if (!data.file_id) throw new Error('Response upload tidak berisi file_id'); return data;}// ─── Chat ───────────────────────────────────────────────────────────────────/** * @param {string} query * @param {object} options * @param {string|null} [options.conversationId] * @param {string[]} [options.fileIds] * @param {string} [options.level] - small|medium|high|low * @param {string} [options.timezone] * @param {string} [options.language] * @param {boolean} [options.stream] * @param {string} [options.sessionPath] * @param {object} [options.session] - inject session object */export async function chat(query, options = {}) { const { conversationId = null, fileIds = [], level = 'small', timezone = 'Asia/Jakarta', language = 'en', stream = false, sessionPath = DEFAULT_SESSION, } = options; let session = options.session || (await loadSession(sessionPath)); const reasoningEffort = LEVEL_MAP[String(level).toLowerCase()] || 'small'; // Pastikan daftar kandidat next-action tersedia await discoverNextAction(session, sessionPath); let candidates = session._candidates?.length ? session._candidates : [session.nextAction].filter(Boolean); if (!candidates.length) { // force scrape lagi session.nextAction = null; session.updatedAt = null; await discoverNextAction(session, sessionPath); candidates = session._candidates?.length ? session._candidates : [session.nextAction].filter(Boolean); } const initBody = [ { brainstormingMode: 'auto', conversationId, documentReference: '$undefined', fileIds: fileIds.length ? fileIds : '$undefined', language, projectId: null, query, reasoningEffort, templateId: null, timezone, uiLocale: language, }, ]; // ── 1. Init – coba kandidat sampai dapat payload yang valid ── let payload = null; let lastError = null; for (const nextAction of candidates) { const initHeaders = { Accept: 'text/x-component', 'Content-Type': 'text/plain;charset=UTF-8', 'next-action': nextAction, 'next-router-state-tree': '%5B%22%22%2C%7B%22children%22%3A%5B%22(model)%22%2C%7B%22children%22%3A%5B%22chat%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C0%5D%7D%2Cnull%2Cnull%2C0%5D%7D%2Cnull%2Cnull%2C0%5D%7D%2Cnull%2Cnull%2C16%5D', Referer: `${BASE}/chat`, 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Mobile Safari/537.36', }; if (Object.keys(session.cookies || {}).length) { initHeaders.Cookie = cookieHeader(session.cookies); } try { const initRes = await fetch(`${BASE}/chat`, { method: 'POST', headers: initHeaders, body: JSON.stringify(initBody), }); session.cookies = mergeSetCookie(initRes, session.cookies || {}); if (!initRes.ok) { lastError = `HTTP ${initRes.status}`; continue; } const initText = await initRes.text(); // Parse RSC payload – cari object yang punya url + body let found = null; for (const line of initText.split('\n')) { const m = line.match(/^\d+:(\{.*\})$/); if (!m) continue; try { const obj = JSON.parse(m[1]); if (obj?.url && obj?.body) { found = obj; break; } } catch { // skip } } // fallback if (!found) { const m2 = initText.match(/1:(\{[\s\S]*?\})(?:\n|$)/); if (m2) { try { const obj = JSON.parse(m2[1]); if (obj?.url && obj?.body) found = obj; } catch {} } } // Validasi: url harus mengarah ke enterprise chat if ( found?.url && found?.body && String(found.url).includes('/api/v1/enterprise/chat') ) { payload = found; session.nextAction = nextAction; // cache yang berhasil await saveSession(session, sessionPath); break; } lastError = 'payload tidak valid / bukan chat endpoint'; } catch (err) { lastError = err.message; } } if (!payload) { // clear cache biar next run scrape ulang session.nextAction = null; session._candidates = null; await saveSession(session, sessionPath); throw new Error( `Gagal init chat (semua kandidat next-action gagal). Last error: ${lastError}` ); } const streamUrl = payload.url; // Bersihkan body: hapus nilai "$undefined" (server menolaknya) const streamBody = {}; for (const [k, v] of Object.entries(payload.body || {})) { if (v === '$undefined') continue; streamBody[k] = v; } if (fileIds.length) { streamBody.file_ids = fileIds; } else { delete streamBody.file_ids; } // ── 2. Stream SSE ── const streamHeaders = { Accept: 'text/event-stream', 'Content-Type': 'application/json', Referer: `${BASE}/chat`, Origin: BASE, 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Mobile Safari/537.36', }; if (payload.headers?.['x-request-id']) { streamHeaders['x-request-id'] = payload.headers['x-request-id']; } if (Object.keys(session.cookies || {}).length) { streamHeaders.Cookie = cookieHeader(session.cookies); } const streamRes = await fetch(streamUrl, { method: 'POST', headers: streamHeaders, body: JSON.stringify(streamBody), }); session.cookies = mergeSetCookie(streamRes, session.cookies || {}); await saveSession(session, sessionPath); if (!streamRes.ok) { const errText = await streamRes.text().catch(() => ''); throw new Error( `Stream gagal: ${streamRes.status} ${errText.slice(0, 200)}` ); } const reader = streamRes.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let fullText = ''; let finalConversationId = conversationId; const rawChunks = []; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split('\n'); buffer = parts.pop() || ''; for (const line of parts) { if (!line.startsWith('data: ')) continue; const dataStr = line.slice(6).trim(); if (!dataStr || dataStr === '[[HUB_STOP_SIGN]]') continue; try { const data = JSON.parse(dataStr); rawChunks.push(data); if (data.type === 'shared.data.conversation_id') { finalConversationId = data.data; } if (data.type === 'chat.text') { fullText += data.data; if (stream) process.stdout.write(data.data); } } catch { // non-json → skip } } } if (stream) process.stdout.write('\n'); return { conversationId: finalConversationId, text: fullText.trim(), rawChunks, session, // biar caller bisa reuse };}/** * Upload + chat sekaligus. * Jika belum ada conversationId, buat conversation dulu (bootstrap), * lalu upload file, lalu kirim query dengan file terlampir. */export async function chatWithFile(query, filePath, options = {}) { let convId = options.conversationId || null; const shared = { session: options.session, sessionPath: options.sessionPath, }; // Bootstrap conversation bila belum ada (API upload wajib conversation_id) if (!convId) { const boot = await chat('\u200b', { // zero-width space – pesan minim, hanya untuk dapat conversation_id level: 'small', ...shared, }); convId = boot.conversationId; } const uploaded = await uploadFile(filePath, { conversationId: convId, ...shared, }); return chat(query, { ...options, conversationId: convId, fileIds: [uploaded.file_id], });}// ─── CLI ────────────────────────────────────────────────────────────────────function isMain() { const arg = process.argv[1] || ''; return ( import.meta.url === `file://${arg}` || arg.endsWith('motif-chat.mjs') || arg.endsWith('motif-chat.js') );}if (isMain()) { const args = process.argv.slice(2); let query = ''; let file = null; let level = 'small'; let convId = null; let stream = false; let sessionPath = DEFAULT_SESSION; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === '--file' || a === '-f') file = args[++i]; else if (a === '--level' || a === '-l') level = args[++i]; else if (a === '--conv' || a === '-c') convId = args[++i]; else if (a === '--stream' || a === '-s') stream = true; else if (a === '--session') sessionPath = args[++i]; else query += (query ? ' ' : '') + a; } if (!query) query = 'hi! siapa kamu?'; (async () => { console.error('Query :', query); console.error('Level :', level); if (file) console.error('File :', file); console.error('Session:', sessionPath); console.error('---'); let res; if (file) { res = await chatWithFile(query, file, { level, conversationId: convId, stream, sessionPath, }); } else { res = await chat(query, { level, conversationId: convId, stream, sessionPath, }); } if (!stream) { console.log(res.text); } console.error('---'); console.error('Conversation ID:', res.conversationId); })().catch((err) => { console.error('Error:', err.message); process.exit(1); });}README.mdmarkdown · 166 baris
Komentar
Masuk untuk ikut berkomentar.
Belum ada komentar.