LOADING
ChatMotif ai no account unlimited chat realtime support file input, session/conversation chat available 3 mode, about check markdown file
/**
* 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);
});
}Wrapper Node.js (ESM) untuk Chat Motif — tanpa perlu update token/next-action manual.
small (Low) / medium / highnext-action dari bundle JS (tahan update deploy)fetch, FormData, Blob)Tidak ada dependency npm tambahan.
bash# kalau pakai .js, pastikan package.json punya: # { "type": "module" }
bashnode ChatMotifjs "pesan kamu"
| Flag | Alias | Keterangan |
|---|---|---|
--level <lv> | -l | small / medium / high (atau low = small) |
--file <path> | -f | path file yang di-upload |
--conv <id> | -c | lanjut conversation yang sudah ada |
--stream | -s | cetak jawaban per token (real-time) |
--session <path> | file session JSON (default: .motif-session.json) |
bash# Chat biasa node ChatMotif.js "halo siapa kamu?" # Level High + streaming node ChatMotif.js "jelaskan quantum computing" --level high --stream # Baca file (PDF / TXT / kode) node ChatMotif.js "apa isi dokumen ini?" --file ./dokumen.pdf --level medium # Lanjut conversation node ChatMotif.js "lanjutkan penjelasanmu" --conv 01M3YZ2GQS9W409MMEA3M3RY06 # All-in: file + high + stream + lanjut conv node ChatMotif.js "ringkas poin utamanya" \ --file "./Presentasi.pdf" \ --conv 01M3YZ2GQS9W409MMEA3M3RY06 \ --level high \ --stream
jsimport { chat, uploadFile, chatWithFile } from './ChatMotif.js'; // Chat sederhana const res = await chat('halo', { level: 'high', stream: false, }); console.log(res.text); console.log(res.conversationId); // Chat + file (otomatis buat conversation kalau belum ada) const res2 = await chatWithFile('apa isi file ini?', './script.py', { level: 'medium', stream: true, }); // Upload manual (wajib conversationId) const { file_id } = await uploadFile('./data.txt', { conversationId: res.conversationId, }); const res3 = await chat('analisis file ini', { conversationId: res.conversationId, fileIds: [file_id], level: 'high', });
chat() / chatWithFile()ts{ conversationId: string, // simpan untuk --conv berikutnya text: string, // jawaban lengkap rawChunks: object[], // event SSE mentah (opsional) session: object, // state session (cookie, nextAction, …) }
| Jenis | Ekstensi | Keterangan |
|---|---|---|
| Dokumen | .pdf .docx .doc | Resmi didukung Motif |
| Teks | .txt .md .csv .json .yaml .log … | OK |
| Kode | .js .ts .py .java .go .rs .php .sh … | Dikirim sebagai text/plain |
Tidak didukung: binary mentah (application/octet-stream), image murni, zip/exe, dll.
Upload wajib punya
conversation_id.
chatWithFile()otomatis bootstrap conversation jika--convtidak diberikan.
File default: .motif-session.json
Isinya antara lain:
cookies — cookie browser (kalau nanti login)nextAction — hash server action yang sedang valid_candidates — daftar hash cadanganupdatedAt — waktu cacheHapus file ini jika ingin force refresh discovery:
bashrm -f .motif-session.json
| Nilai CLI | Di UI Motif | Kegunaan |
|---|---|---|
small / low | Low | Jawaban cepat |
medium | Medium | Seimbang |
high | High | Reasoning lebih dalam |
| Error | Solusi |
|---|---|
Gagal menemukan next-action | Hapus .motif-session.json, jalankan lagi (butuh akses ke chat.motiftech.io) |
Upload gagal … conversation_id or project_id is required | Pakai chatWithFile / beri --conv |
file type is not supported | Ganti ekstensi / pastikan bukan binary; kode harus terdeteksi sebagai teks |
Stream gagal: 400 | Biasanya body kotor ($undefined) — sudah di-handle di versi terbaru |
Init gagal | Coba lagi; wrapper akan coba kandidat action lain |
next-action bisa berubah sewaktu-waktu; wrapper berusaha menyesuaikan otomatis.Bebas dipakai / dimodifikasi untuk keperluan pribadi.