Guest User

Untitled

a guest
Apr 8th, 2023
226
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 20.88 KB | None | 0 0
  1. const os = require('os');
  2. const pty = require('node-pty');
  3. //const pty = require('@cdktf/node-pty-prebuilt-multiarch');
  4. const git = require('isomorphic-git');
  5. const http = require('isomorphic-git/http/node');
  6. const Http = require("http")
  7. const path = require('path');
  8. const fs = require("fs");
  9. const tar = require('tar');
  10. const { createServer } = require("http");
  11. const { Server } = require("socket.io");
  12. const { io } = require("socket.io-client");
  13. const term = require( 'terminal-kit' ).terminal;
  14. const Downloader = require("nodejs-file-downloader");
  15. const semver = require('semver');
  16. //const _7z = require('7zip-min');
  17. const axios = require('axios')
  18. const platform = os.platform()
  19. const shell = platform === 'win32' ? 'powershell.exe' : 'bash';
  20. const L = require("./llama")
  21. const A = require("./alpaca")
  22. const TorrentDownloader = require("./torrent")
  23. const exists = s => new Promise(r=>fs.access(s, fs.constants.F_OK, e => r(!e)))
  24. const escapeNewLine = (platform, arg) => platform === 'win32' ? arg.replaceAll(/\n/g, "\\n").replaceAll(/\r/g, "\\r") : arg
  25. const escapeDoubleQuotes = (platform, arg) => platform === 'win32' ? arg.replaceAll(/"/g, '`"') : arg.replaceAll(/"/g, '\\"')
  26. const stripAnsi = (str) => {
  27. const pattern = [
  28. '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)',
  29. '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))'
  30. ].join('|');
  31.  
  32. const regex = new RegExp(pattern, 'g')
  33. return str.replace(regex, '');
  34. }
  35. const winEscape = (str) => {
  36. return str
  37. .replaceAll(/\\n/g, "\n")
  38. .replaceAll(/\\r/g, "\r")
  39. .replaceAll(/\\t/g, "\t")
  40. .replaceAll(/\\b/g, "\b")
  41. .replaceAll(/\\f/g, "\f")
  42. .replaceAll(/\\/g, "")
  43. .replaceAll("{", "\{")
  44. .replaceAll("}", "\}")
  45. .replaceAll("\"", "\\\"")
  46. .replaceAll("`", "\\`")
  47. .replaceAll("$", "\$")
  48. }
  49.  
  50. class Dalai {
  51. constructor(home) {
  52. ////////////////////////////////////////////////////////////////////////////////////////////////////////////
  53. //
  54. // 1. manually set llama.cpp home
  55. // 2. otherwise store llama.cpp at ~/llama.cpp
  56. //
  57. // # NOTE
  58. // Could have used process.cwd() (The current execution directory) to download llama.cpp
  59. // but this makes it cumbersome as you try to build multiple apps, because by default Dalai client will
  60. // look for the current execution directory for llama.cpp.
  61. // It's simpler to set the ~/llama.cpp as the default directory and use that path as the single source
  62. // of truth and let multiple apps all connect to that path
  63. // Otherwise if you want to customize the path you can just pass in the "home" attribute to manually set it.
  64. //
  65. ////////////////////////////////////////////////////////////////////////////////////////////////////////////
  66.  
  67. this.taskInProgress = true; // allow tasks to process during init
  68. this.home = home ? path.resolve(home) : path.resolve(os.homedir(), "dalai")
  69. try {
  70. console.log("mkdir", this.home)
  71. fs.mkdirSync(this.home, { recursive: true })
  72. } catch (e) {
  73. console.log("E", e)
  74. }
  75. this.torrent = new TorrentDownloader()
  76. this.config = {
  77. name: 'xterm-color',
  78. cols: 1000,
  79. rows: 30,
  80. }
  81. this.cores = {
  82. llama: new L(this),
  83. alpaca: new A(this),
  84. }
  85. }
  86. htmlencode (str) {
  87. let encodedStr = '';
  88. for (let i = 0; i < str.length; i++) {
  89. let charCode = str.charCodeAt(i);
  90. if (charCode < 128) {
  91. // ASCII characters
  92. switch (str[i]) {
  93. case '<': encodedStr += '&lt;'; break;
  94. case '>': encodedStr += '&gt;'; break;
  95. case '&': encodedStr += '&amp;'; break;
  96. case '"': encodedStr += '&quot;'; break;
  97. case '\'': encodedStr += '&#39;'; break;
  98. case '\n': encodedStr += '<br>'; break;
  99. case '\r': break; // ignore
  100. case '\t': encodedStr += '&nbsp;&nbsp;&nbsp;&nbsp;'; break;
  101. case '\b': encodedStr += '&nbsp;'; break;
  102. case '\f': encodedStr += '&nbsp;'; break;
  103. default: encodedStr += String.fromCharCode(charCode); break;
  104. }
  105. } else {
  106. // Non-ASCII characters
  107. encodedStr += "&#" + charCode + ";";
  108. }
  109. }
  110. return encodedStr;
  111. }
  112. down(url, dest, headers) {
  113. return new Promise((resolve, reject) => {
  114. const task = path.basename(dest)
  115. this.startProgress(task)
  116. axios({
  117. url,
  118. method: 'GET',
  119. responseType: 'stream',
  120. maxContentLength: Infinity,
  121. headers,
  122. onDownloadProgress: progressEvent => {
  123. const progress = (progressEvent.loaded / progressEvent.total) * 100;
  124. this.progress(task, progress)
  125. }
  126.  
  127. }).then(response => {
  128. const writer = fs.createWriteStream(dest);
  129. response.data.pipe(writer);
  130. writer.on('finish', () => {
  131. this.progressBar.update(1);
  132. term("\n")
  133. resolve()
  134. });
  135. }).catch(error => {
  136. reject(error)
  137. });
  138. })
  139. }
  140. async python () {
  141. // install self-contained python => only for windows for now
  142. // 1. download
  143. // 2. unzip
  144.  
  145. const filename = "cpython-3.10.9+20230116-x86_64-pc-windows-msvc-shared-install_only.tar.gz"
  146. const task = "downloading self contained python"
  147. const downloader = new Downloader({
  148. url: `https://github.com/indygreg/python-build-standalone/releases/download/20230116/${filename}`,
  149. directory: this.home,
  150. onProgress: (percentage, chunk, remainingSize) => {
  151. this.progress(task, percentage)
  152. },
  153. });
  154. try {
  155. await this.startProgress(task)
  156. await downloader.download();
  157. } catch (error) {
  158. console.log(error);
  159. }
  160. this.progressBar.update(1);
  161. console.log("extracting python")
  162. await tar.x({
  163. file: path.resolve(this.home, filename),
  164. C: this.home,
  165. strict: true
  166. })
  167. console.log("cleaning up temp files")
  168. await fs.promises.rm(path.resolve(this.home, filename))
  169. }
  170. // async mingw() {
  171. // const mingw = "https://github.com/niXman/mingw-builds-binaries/releases/download/12.2.0-rt_v10-rev2/x86_64-12.2.0-release-win32-seh-msvcrt-rt_v10-rev2.7z"
  172. // const downloader = new Downloader({
  173. // url: mingw,
  174. // directory: this.home,
  175. // onProgress: (percentage, chunk, remainingSize) => {
  176. // this.progress("download mingw", percentage)
  177. // },
  178. // });
  179. // try {
  180. // await this.startProgress("download mingw")
  181. // await downloader.download();
  182. // } catch (error) {
  183. // console.log(error);
  184. // }
  185. // this.progressBar.update(1);
  186. // await new Promise((resolve, reject) => {
  187. // _7z.unpack(path.resolve(this.home, "x86_64-12.2.0-release-win32-seh-msvcrt-rt_v10-rev2.7z"), this.home, (err) => {
  188. // if (err) {
  189. // reject(err)
  190. // } else {
  191. // resolve()
  192. // }
  193. // })
  194. // })
  195. // console.log("cleaning up temp files")
  196. // await fs.promises.rm(path.resolve(this.home, "x86_64-12.2.0-release-win32-seh-msvcrt-rt_v10-rev2.7z"))
  197. // }
  198. async query(req, cb) {
  199.  
  200. if (!this.taskInProgress) {
  201. return;
  202. }
  203. console.log(`> query:`, req)
  204. if (req.method === "installed") {
  205. let models = await this.installed()
  206. for(let model of models) {
  207. cb(model)
  208. }
  209. cb('\n\n<end>')
  210. return
  211. }
  212.  
  213. if (req.prompt && req.prompt.startsWith("/")) {
  214. try {
  215. const mod = require(`./cmds/${req.prompt.slice(1)}`)
  216. if (mod) {
  217. mod(this)
  218. return
  219. }
  220. } catch (e) {
  221. console.log("require log", e)
  222. }
  223. }
  224.  
  225. if (!req.prompt) {
  226. return
  227. }
  228.  
  229. let [Core, Model] = req.model.split(".")
  230. Model = Model.toUpperCase()
  231.  
  232. console.log( { Core, Model } )
  233.  
  234. let o = {
  235. seed: req.seed || -1,
  236. threads: req.threads || 8,
  237. n_predict: req.n_predict || 128,
  238. model: `models/${Model || "7B"}/ggml-model-q4_0.bin`,
  239. }
  240.  
  241. let e = await exists(path.resolve(this.home, Core, "models", Model))
  242. if (!e) {
  243. cb(`File does not exist: ${Model}. Try "dalai ${Core} get ${Model}" first.`)
  244. return
  245. }
  246.  
  247. if (req.top_k) o.top_k = req.top_k
  248. if (req.top_p) o.top_p = req.top_p
  249. if (req.temp) o.temp = req.temp
  250. if (req.batch_size) o.batch_size = req.batch_size
  251. if (req.repeat_last_n) o.repeat_last_n = req.repeat_last_n
  252. if (req.repeat_penalty) o.repeat_penalty = req.repeat_penalty
  253. if (typeof req.interactive !== "undefined") o.interactive = req.interactive
  254.  
  255. let chunks = []
  256. for(let key in o) {
  257. chunks.push(`--${key} ${escapeDoubleQuotes(platform, o[key].toString())}`)
  258. }
  259. const escaped = escapeNewLine(platform, req.prompt)
  260. const prompt = `"${escapeDoubleQuotes(platform, escaped)}"`
  261.  
  262. chunks.push(`-p ${prompt}`)
  263.  
  264. const main_bin_path = platform === "win32" ? path.resolve(this.home, Core, "build", "Release", "main") : path.resolve(this.home, Core, "main")
  265. this.sessionBuffer = "";
  266. this.bufferStarted = false;
  267. if (req.full) {
  268. if (this.taskInProgress) {
  269. await this.exec(`${main_bin_path} ${chunks.join(" ")}`, this.cores[Core].home, cb)
  270. }
  271. } else {
  272. const startpattern = /.*sampling parameters:.*/g
  273. const endpattern = /.*mem per token.*/g
  274. let started = req.debug
  275. let ended = false
  276. let writeEnd = !req.skip_end
  277.  
  278. if (this.taskInProgress) {
  279. await this.exec(`${main_bin_path} ${chunks.join(" ")}`, this.cores[Core].home, (proc, msg) => {
  280. if (!this.taskInProgress) {
  281. proc.kill();
  282. this.taskInProgress = true;
  283. return;
  284. }
  285. if (endpattern.test(msg)) ended = true
  286. if (started && !ended) {
  287. this.buffer(req, msg, cb)
  288. } else if (ended && writeEnd) {
  289. cb('\n\n<end>')
  290. writeEnd = false
  291. }
  292. if (startpattern.test(msg)) started = true
  293. })
  294. }
  295. }
  296. }
  297. buffer(req, msg, cb) {
  298. if (!this.queue) this.queue = []
  299. if (platform === "win32") {
  300. for(let i=0;i<msg.length; i++) {
  301. if (msg[i] === "\\") {
  302. this.queueActive = true
  303. // start pushing to buffer
  304. this.queue.push(msg[i]);
  305. } else {
  306. // otherwise flush
  307. this.queue.push(msg[i])
  308. let queueContent = this.queue.join("")
  309.  
  310. if (!this.bufferStarted && ["\n", "\b", "\f", "\r", "\t"].includes(queueContent)) {
  311. // if the buffer hasn't started and incoming tokens are whitespaces, ignore
  312. } else {
  313. if (req.html) {
  314. cb(this.htmlencode(winEscape(queueContent)))
  315. } else {
  316. cb(winEscape(queueContent))
  317. }
  318. this.bufferStarted = true
  319. }
  320. this.queue = []
  321. this.queueActive = false
  322. }
  323. }
  324. } else {
  325. if (req.html) {
  326. cb(this.htmlencode(msg))
  327. } else {
  328. cb(msg)
  329. }
  330. }
  331. }
  332. async uninstall(core, ...models) {
  333. if (models.length > 0) {
  334. // delete the model folder
  335. const modelsPath = path.resolve(this.home, core, "models")
  336. for(let model of models) {
  337. const modelPath = path.resolve(modelsPath, model)
  338. console.log("rm", modelPath)
  339. await fs.promises.rm(modelPath, { recursive: true, force: true }).catch((e) => {
  340. console.log("rm", modelPath, e)
  341. })
  342. }
  343. }
  344. }
  345. async install(core, ...models) {
  346.  
  347. let engine = this.cores[core]
  348.  
  349. const venv_path = path.join(this.home, "venv")
  350. let ve = await exists(venv_path)
  351. if (!ve) {
  352. await this.setup()
  353. }
  354.  
  355. // temporary
  356.  
  357. let models_path = path.resolve(engine.home, "models")
  358. let temp_path = path.resolve(this.home, "tmp")
  359. let temp_models_path = path.resolve(temp_path, "models")
  360. await fs.promises.mkdir(temp_path, { recursive: true }).catch((e) => { console.log("1", e) })
  361. // 1. move the models folder to ../tmp
  362. await fs.promises.rename(models_path, temp_models_path).catch((e) => { console.log("2", e) })
  363. // 2. wipe out the folder
  364. await fs.promises.rm(engine.home, { recursive: true }).catch((e) => { console.log("3", e) })
  365. // 3. install engine
  366. await this.add(core)
  367. // 4. move back the files inside /tmp
  368. await fs.promises.rename(temp_models_path, models_path).catch((e) => { console.log("4", e) })
  369.  
  370. // next add the models
  371. let res = await this.cores[core].add(...models)
  372. return res
  373. }
  374. async installed() {
  375. // get cores
  376. const modelNames = []
  377. for(let core of ["alpaca", "llama"]) {
  378. const modelsPath = path.resolve(this.home, core, "models")
  379. console.log("modelsPath", modelsPath)
  380. let modelFolders = []
  381. try {
  382. modelFolders = (await fs.promises.readdir(modelsPath, { withFileTypes: true }))
  383. .filter(dirent => dirent.isDirectory())
  384. .map(dirent => dirent.name)
  385. } catch (e) {
  386. }
  387.  
  388. console.log({ modelFolders })
  389. for(let modelFolder of modelFolders) {
  390. let e = await exists(path.resolve(modelsPath, modelFolder, 'ggml-model-q4_0.bin'))
  391. if (e) {
  392. modelNames.push(`${core}.${modelFolder}`)
  393. console.log("exists", modelFolder)
  394. }
  395. }
  396. }
  397. return modelNames
  398. }
  399. async add (core) {
  400. /**************************************************************************************************************
  401. *
  402. * 2. Download Core
  403. *
  404. **************************************************************************************************************/
  405. let engine = this.cores[core]
  406. let e = await exists(path.resolve(engine.home));
  407. console.log("mkdir", path.resolve(engine.home))
  408. await fs.promises.mkdir(path.resolve(engine.home), { recursive: true }).catch((e) => {
  409. console.log("ERROR" ,e)
  410. })
  411.  
  412. try {
  413. console.log("try fetching", engine.home, engine.url)
  414. await git.pull({ fs, http, dir: engine.home, url: engine.url })
  415. } catch (e) {
  416. console.log("[E] Pull", e)
  417. try {
  418. console.log("try cloning", engine.home, engine.url)
  419. await git.clone({ fs, http, dir: engine.home, url: engine.url })
  420. } catch (e2) {
  421. console.log("[E] Clone", e2)
  422. }
  423. }
  424. console.log("next", core, engine.make);
  425. /**************************************************************************************************************
  426. *
  427. * 4. Compile & Build
  428. * - make: linux + mac
  429. * - cmake: windows
  430. *
  431. **************************************************************************************************************/
  432. await engine.make()
  433. }
  434. async setup() {
  435.  
  436. let success;
  437.  
  438. /**************************************************************************************************************
  439. *
  440. * 1. Validate
  441. *
  442. **************************************************************************************************************/
  443. // Check if current version is greater than or equal to 18
  444. const node_version = process.version;
  445. if (!semver.gte(node_version, '18.0.0')) {
  446. throw new Error("outdated Node version, please install Node 18 or newer")
  447. }
  448.  
  449. /**************************************************************************************************************
  450. *
  451. * 3. Download Global Dependencies
  452. * - Python (windows only)
  453. * - build-essential (linux only)
  454. * - virtualenv
  455. * - torch, numpy, etc.
  456. *
  457. **************************************************************************************************************/
  458.  
  459. // 3.1. Python: Windows doesn't ship with python, so install a dedicated self-contained python
  460. if (platform === "win32") {
  461. await this.python()
  462. }
  463. const root_python_paths = (platform === "win32" ? ["python3", "python", path.resolve(this.home, "python", "python.exe")] : ["python3", "python"])
  464. const root_pip_paths = (platform === "win32" ? ["pip3", "pip", path.resolve(this.home, "python", "python -m pip")] : ["pip3", "pip"])
  465.  
  466. // 3.2. Build tools
  467. if (platform === "linux") {
  468. // ubuntu debian
  469. success = await this.exec("apt-get install build-essential python3-venv -y")
  470. if (!success) {
  471. // fefdora
  472. success = await this.exec("dnf install make automake gcc gcc-c++ kernel-devel python3-virtualenv -y")
  473. }
  474. } else {
  475. // for win32 / darwin
  476. for(let root_pip_path of root_pip_paths) {
  477. success = await this.exec(`${root_pip_path} install --user virtualenv`)
  478. if (success) {
  479. break;
  480. }
  481. success = await this.exec(`${root_pip_path} install virtualenv`)
  482. if (success) {
  483. break;
  484. }
  485. }
  486. if (!success) {
  487. throw new Error("cannot install virtualenv")
  488. }
  489.  
  490. }
  491.  
  492. // 3.3. virtualenv
  493. const venv_path = path.join(this.home, "venv")
  494. for(let root_python_path of root_python_paths) {
  495. console.log("trying with", root_python_path)
  496. let code = await this.exec(`${root_python_path} -m venv ${venv_path}`)
  497. console.log({ code })
  498. }
  499. /*
  500. if (!success) {
  501. throw new Error("cannot execute python3 or python")
  502. return
  503. }
  504. */
  505.  
  506. // 3.4. Python libraries
  507. const pip_path = platform === "win32" ? path.join(venv_path, "Scripts", "pip.exe") : path.join(venv_path, "bin", "pip")
  508. const python_path = platform == "win32" ? path.join(venv_path, "Scripts", "python.exe") : path.join(venv_path, 'bin', 'python')
  509. // cmake (only on windows. the rest platforms use make)
  510. if (platform === "win32") {
  511. success = await this.exec(`${pip_path} install cmake`)
  512. if (!success) {
  513. throw new Error("cmake installation failed")
  514. return
  515. }
  516. }
  517. success = await this.exec(`${pip_path} install --upgrade pip setuptools wheel`)
  518. if (!success) {
  519. success = await this.exec(`${pip_path} install --user --upgrade pip setuptools wheel`)
  520. if (!success) {
  521. throw new Error("pip setuptools wheel upgrade failed")
  522. return
  523. }
  524. }
  525. success = await this.exec(`${pip_path} install torch torchvision torchaudio sentencepiece numpy`)
  526. //success = await this.exec(`${pip_path} install torch torchvision torchaudio sentencepiece numpy wget`)
  527. if (!success) {
  528. success = await this.exec(`${pip_path} install --user torch torchvision torchaudio sentencepiece numpy`)
  529. if (!success) {
  530. throw new Error("dependency installation failed")
  531. return
  532. }
  533. }
  534. }
  535.  
  536. serve(port, options) {
  537. const httpServer = createServer();
  538. const io = new Server(httpServer)
  539. io.on("connection", (socket) => {
  540. console.log('SocketIO client connected.');
  541. this.taskInProgress = false;
  542. socket.on('request', async (req) => {
  543. taskInProgress = true;
  544. if (taskInProgress){
  545. await this.query(req, (str) => {
  546. io.emit("result", { response: str, request: req })
  547. })
  548. }
  549. });
  550.  
  551. // Add a listener for the 'STOP RAMBLING' event
  552. socket.on('stop', async (stopResult) => {
  553. taskInProgress = false;
  554. await this.query(stopResult, (str) => {
  555. io.emit("stop", { response: str, request: stop})
  556. })
  557. });
  558.  
  559. socket.on('disconnect', () => {
  560. taskInProgress = false;
  561. console.log('SocketIO client disconnected.');
  562. });
  563. });
  564. httpServer.listen(port)
  565. }
  566.  
  567. http(httpServer) {
  568. const io = new Server(httpServer)
  569. io.on("connection", (socket) => {
  570. console.log('HTTP client connected.');
  571. this.taskInProgress = false;
  572. socket.on('request', async (req) => {
  573. this.taskInProgress = true;
  574. req.models = Array.from(new Set(req.models))
  575. if (this.taskInProgress){
  576. await this.query(req, (str) => {
  577. io.emit("result", { response: str, request: req })
  578. })
  579. }
  580. });
  581.  
  582. //Add a listener for the 'STOP RAMBLING' event
  583. socket.on('stop', () => {
  584. console.log('HTTP Stop event received.');
  585. this.taskInProgress = false;
  586. console.log('Task stopped.');
  587. });
  588.  
  589. socket.on('disconnect', () => {
  590. this.taskInProgress = false;
  591. console.log('HTTP client disconnected.');
  592. });
  593. });
  594. }
  595.  
  596. async request(req, cb) {
  597. if (req.url) {
  598. await this.connect(req, cb)
  599. } else {
  600. await this.query(req, cb)
  601. }
  602. }
  603. connect(req, cb) {
  604. const socket = io(req.url)
  605. socket.emit('request', req)
  606. socket.on('response', cb)
  607. socket.on('error', function(e) {
  608. throw e
  609. });
  610. }
  611. exec(cmd, cwd, cb) {
  612. return new Promise((resolve, reject) => {
  613. try {
  614. const config = Object.assign({}, this.config)
  615. if (cwd) {
  616. config.cwd = path.resolve(cwd)
  617. }
  618. console.log(`exec: ${cmd} in ${config.cwd}`)
  619. this.ptyProcess = pty.spawn(shell, [], config)
  620. this.ptyProcess.onData((data) => {
  621. if (!this.taskInProgress) { // if the "stop" emit set taskInProgress to false
  622. this.ptyProcess.write('\x03'); // send a ctrl + c (??? gpt-4 recommended it)
  623. resolve(true); // successful process kill after request by client
  624. return;
  625. }
  626. if (cb) {
  627. cb(this.ptyProcess, stripAnsi(data))
  628. } else {
  629. process.stdout.write(data);
  630. }
  631. });
  632. this.ptyProcess.onExit((res) => {
  633. if (res.exitCode === 0) {
  634. // successful
  635. resolve(true)
  636. } else {
  637. // something went wrong
  638. resolve(false)
  639. }
  640. });
  641.  
  642. if (platform === "win32") {
  643. this.ptyProcess.write(`[System.Console]::OutputEncoding=[System.Console]::InputEncoding=[System.Text.Encoding]::UTF8; ${cmd}\r`)
  644. } else {
  645. this.ptyProcess.write(`${cmd}\r`)
  646. }
  647. this.ptyProcess.write("exit\r")
  648. } catch (e) {
  649. console.log("caught error", e)
  650. this.ptyProcess.kill()
  651. // ptyProcess.write("exit\r")
  652. }
  653. })
  654. }
  655. progress(task, percent) {
  656. this.progressBar.update(percent/100);
  657. //if (percent >= 100) {
  658. // setTimeout(() => {
  659. // term("\n")
  660. // }, 200)
  661. //}
  662. }
  663. startProgress(title) {
  664. this.progressBar = term.progressBar({
  665. width: 120,
  666. title,
  667. eta: true ,
  668. percent: true
  669. });
  670. }
  671. }
  672. module.exports = Dalai
  673.  
Add Comment
Please, Sign In to add comment