cli.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. #!/usr/bin/env node
  2. import fs from 'fs';
  3. import { pathToFileURL } from 'url';
  4. import { KeyvFile } from 'keyv-file';
  5. import boxen from 'boxen';
  6. import ora from 'ora';
  7. import clipboard from 'clipboardy';
  8. import inquirer from 'inquirer';
  9. import inquirerAutocompletePrompt from 'inquirer-autocomplete-prompt';
  10. import ChatGPTClient from '../src/ChatGPTClient.js';
  11. import BingAIClient from '../src/BingAIClient.js';
  12. const arg = process.argv.find(_arg => _arg.startsWith('--settings'));
  13. const path = arg?.split('=')[1] ?? './settings.js';
  14. let settings;
  15. if (fs.existsSync(path)) {
  16. // get the full path
  17. const fullPath = fs.realpathSync(path);
  18. settings = (await import(pathToFileURL(fullPath).toString())).default;
  19. } else {
  20. if (arg) {
  21. console.error('Error: the file specified by the --settings parameter does not exist.');
  22. } else {
  23. console.error('Error: the settings.js file does not exist.');
  24. }
  25. process.exit(1);
  26. }
  27. if (settings.storageFilePath && !settings.cacheOptions.store) {
  28. // make the directory and file if they don't exist
  29. const dir = settings.storageFilePath.split('/').slice(0, -1).join('/');
  30. if (!fs.existsSync(dir)) {
  31. fs.mkdirSync(dir, { recursive: true });
  32. }
  33. if (!fs.existsSync(settings.storageFilePath)) {
  34. fs.writeFileSync(settings.storageFilePath, '');
  35. }
  36. settings.cacheOptions.store = new KeyvFile({ filename: settings.storageFilePath });
  37. }
  38. // Disable the image generation in cli mode always.
  39. settings.bingAiClient.features = settings.bingAiClient.features || {};
  40. settings.bingAiClient.features.genImage = false;
  41. let conversationData = {};
  42. const availableCommands = [
  43. {
  44. name: '!editor - Open the editor (for multi-line messages)',
  45. value: '!editor',
  46. },
  47. {
  48. name: '!resume - Resume last conversation',
  49. value: '!resume',
  50. },
  51. {
  52. name: '!new - Start new conversation',
  53. value: '!new',
  54. },
  55. {
  56. name: '!copy - Copy conversation to clipboard',
  57. value: '!copy',
  58. },
  59. {
  60. name: '!delete-all - Delete all conversations',
  61. value: '!delete-all',
  62. },
  63. {
  64. name: '!exit - Exit ChatGPT CLI',
  65. value: '!exit',
  66. },
  67. ];
  68. inquirer.registerPrompt('autocomplete', inquirerAutocompletePrompt);
  69. const clientToUse = settings.cliOptions?.clientToUse || settings.clientToUse || 'chatgpt';
  70. let client;
  71. switch (clientToUse) {
  72. case 'bing':
  73. client = new BingAIClient({
  74. ...settings.bingAiClient,
  75. cache: settings.cacheOptions,
  76. });
  77. break;
  78. default:
  79. client = new ChatGPTClient(
  80. settings.openaiApiKey || settings.chatGptClient.openaiApiKey,
  81. settings.chatGptClient,
  82. settings.cacheOptions,
  83. );
  84. break;
  85. }
  86. console.log(tryBoxen('ChatGPT CLI', {
  87. padding: 0.7, margin: 1, borderStyle: 'double', dimBorder: true,
  88. }));
  89. await conversation();
  90. async function conversation() {
  91. console.log('Type "!" to access the command menu.');
  92. const prompt = inquirer.prompt([
  93. {
  94. type: 'autocomplete',
  95. name: 'message',
  96. message: 'Write a message:',
  97. searchText: '​',
  98. emptyText: '​',
  99. suggestOnly: true,
  100. source: () => Promise.resolve([]),
  101. },
  102. ]);
  103. // hiding the ugly autocomplete hint
  104. prompt.ui.activePrompt.firstRender = false;
  105. // The below is a hack to allow selecting items from the autocomplete menu while also being able to submit messages.
  106. // This basically simulates a hybrid between having `suggestOnly: false` and `suggestOnly: true`.
  107. await new Promise(resolve => setTimeout(resolve, 0));
  108. prompt.ui.activePrompt.opt.source = (answers, input) => {
  109. if (!input) {
  110. return [];
  111. }
  112. prompt.ui.activePrompt.opt.suggestOnly = !input.startsWith('!');
  113. return availableCommands.filter(command => command.value.startsWith(input));
  114. };
  115. let { message } = await prompt;
  116. message = message.trim();
  117. if (!message) {
  118. return conversation();
  119. }
  120. if (message.startsWith('!')) {
  121. switch (message) {
  122. case '!editor':
  123. return useEditor();
  124. case '!resume':
  125. return resumeConversation();
  126. case '!new':
  127. return newConversation();
  128. case '!copy':
  129. return copyConversation();
  130. case '!delete-all':
  131. return deleteAllConversations();
  132. case '!exit':
  133. return true;
  134. default:
  135. return conversation();
  136. }
  137. }
  138. return onMessage(message);
  139. }
  140. async function onMessage(message) {
  141. let aiLabel;
  142. switch (clientToUse) {
  143. case 'bing':
  144. aiLabel = 'Bing';
  145. break;
  146. default:
  147. aiLabel = settings.chatGptClient?.chatGptLabel || 'ChatGPT';
  148. break;
  149. }
  150. let reply = '';
  151. const spinnerPrefix = `${aiLabel} is typing...`;
  152. const spinner = ora(spinnerPrefix);
  153. spinner.prefixText = '\n ';
  154. spinner.start();
  155. try {
  156. if (clientToUse === 'bing' && !conversationData.jailbreakConversationId) {
  157. // activate jailbreak mode for Bing
  158. conversationData.jailbreakConversationId = true;
  159. }
  160. const response = await client.sendMessage(message, {
  161. ...conversationData,
  162. onProgress: (token) => {
  163. reply += token;
  164. const output = tryBoxen(`${reply.trim()}█`, {
  165. title: aiLabel, padding: 0.7, margin: 1, dimBorder: true,
  166. });
  167. spinner.text = `${spinnerPrefix}\n${output}`;
  168. },
  169. });
  170. let responseText;
  171. switch (clientToUse) {
  172. case 'bing':
  173. responseText = response.details.adaptiveCards?.[0]?.body?.[0]?.text?.trim() || response.response;
  174. break;
  175. default:
  176. responseText = response.response;
  177. break;
  178. }
  179. clipboard.write(responseText).then(() => {}).catch(() => {});
  180. spinner.stop();
  181. switch (clientToUse) {
  182. case 'bing':
  183. conversationData = {
  184. parentMessageId: response.messageId,
  185. jailbreakConversationId: response.jailbreakConversationId,
  186. // conversationId: response.conversationId,
  187. // conversationSignature: response.conversationSignature,
  188. // clientId: response.clientId,
  189. // invocationId: response.invocationId,
  190. };
  191. break;
  192. default:
  193. conversationData = {
  194. conversationId: response.conversationId,
  195. parentMessageId: response.messageId,
  196. };
  197. break;
  198. }
  199. await client.conversationsCache.set('lastConversation', conversationData);
  200. const output = tryBoxen(responseText, {
  201. title: aiLabel, padding: 0.7, margin: 1, dimBorder: true,
  202. });
  203. console.log(output);
  204. } catch (error) {
  205. spinner.stop();
  206. logError(error?.json?.error?.message || error.body || error || 'Unknown error');
  207. }
  208. return conversation();
  209. }
  210. async function useEditor() {
  211. let { message } = await inquirer.prompt([
  212. {
  213. type: 'editor',
  214. name: 'message',
  215. message: 'Write a message:',
  216. waitUserInput: false,
  217. },
  218. ]);
  219. message = message.trim();
  220. if (!message) {
  221. return conversation();
  222. }
  223. console.log(message);
  224. return onMessage(message);
  225. }
  226. async function resumeConversation() {
  227. conversationData = (await client.conversationsCache.get('lastConversation')) || {};
  228. if (conversationData.conversationId) {
  229. logSuccess(`Resumed conversation ${conversationData.conversationId}.`);
  230. } else {
  231. logWarning('No conversation to resume.');
  232. }
  233. return conversation();
  234. }
  235. async function newConversation() {
  236. conversationData = {};
  237. logSuccess('Started new conversation.');
  238. return conversation();
  239. }
  240. async function deleteAllConversations() {
  241. if (clientToUse !== 'chatgpt') {
  242. logWarning('Deleting all conversations is only supported for ChatGPT client.');
  243. return conversation();
  244. }
  245. await client.conversationsCache.clear();
  246. logSuccess('Deleted all conversations.');
  247. return conversation();
  248. }
  249. async function copyConversation() {
  250. if (clientToUse !== 'chatgpt') {
  251. logWarning('Copying conversations is only supported for ChatGPT client.');
  252. return conversation();
  253. }
  254. if (!conversationData.conversationId) {
  255. logWarning('No conversation to copy.');
  256. return conversation();
  257. }
  258. const { messages } = await client.conversationsCache.get(conversationData.conversationId);
  259. // get the last message ID
  260. const lastMessageId = messages[messages.length - 1].id;
  261. const orderedMessages = ChatGPTClient.getMessagesForConversation(messages, lastMessageId);
  262. const conversationString = orderedMessages.map(message => `#### ${message.role}:\n${message.message}`).join('\n\n');
  263. try {
  264. await clipboard.write(`${conversationString}\n\n----\nMade with ChatGPT CLI: <https://github.com/waylaidwanderer/node-chatgpt-api>`);
  265. logSuccess('Copied conversation to clipboard.');
  266. } catch (error) {
  267. logError(error?.message || error);
  268. }
  269. return conversation();
  270. }
  271. function logError(message) {
  272. console.log(tryBoxen(message, {
  273. title: 'Error', padding: 0.7, margin: 1, borderColor: 'red',
  274. }));
  275. }
  276. function logSuccess(message) {
  277. console.log(tryBoxen(message, {
  278. title: 'Success', padding: 0.7, margin: 1, borderColor: 'green',
  279. }));
  280. }
  281. function logWarning(message) {
  282. console.log(tryBoxen(message, {
  283. title: 'Warning', padding: 0.7, margin: 1, borderColor: 'yellow',
  284. }));
  285. }
  286. /**
  287. * Boxen can throw an error if the input is malformed, so this function wraps it in a try/catch.
  288. * @param {string} input
  289. * @param {*} options
  290. */
  291. function tryBoxen(input, options) {
  292. try {
  293. return boxen(input, options);
  294. } catch {
  295. return input;
  296. }
  297. }