123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- #!/usr/bin/env node
- import fs from 'fs';
- import { pathToFileURL } from 'url';
- import { KeyvFile } from 'keyv-file';
- import boxen from 'boxen';
- import ora from 'ora';
- import clipboard from 'clipboardy';
- import inquirer from 'inquirer';
- import inquirerAutocompletePrompt from 'inquirer-autocomplete-prompt';
- import ChatGPTClient from '../src/ChatGPTClient.js';
- import BingAIClient from '../src/BingAIClient.js';
- const arg = process.argv.find(_arg => _arg.startsWith('--settings'));
- const path = arg?.split('=')[1] ?? './settings.js';
- let settings;
- if (fs.existsSync(path)) {
- // get the full path
- const fullPath = fs.realpathSync(path);
- settings = (await import(pathToFileURL(fullPath).toString())).default;
- } else {
- if (arg) {
- console.error('Error: the file specified by the --settings parameter does not exist.');
- } else {
- console.error('Error: the settings.js file does not exist.');
- }
- process.exit(1);
- }
- if (settings.storageFilePath && !settings.cacheOptions.store) {
- // make the directory and file if they don't exist
- const dir = settings.storageFilePath.split('/').slice(0, -1).join('/');
- if (!fs.existsSync(dir)) {
- fs.mkdirSync(dir, { recursive: true });
- }
- if (!fs.existsSync(settings.storageFilePath)) {
- fs.writeFileSync(settings.storageFilePath, '');
- }
- settings.cacheOptions.store = new KeyvFile({ filename: settings.storageFilePath });
- }
- // Disable the image generation in cli mode always.
- settings.bingAiClient.features = settings.bingAiClient.features || {};
- settings.bingAiClient.features.genImage = false;
- let conversationData = {};
- const availableCommands = [
- {
- name: '!editor - Open the editor (for multi-line messages)',
- value: '!editor',
- },
- {
- name: '!resume - Resume last conversation',
- value: '!resume',
- },
- {
- name: '!new - Start new conversation',
- value: '!new',
- },
- {
- name: '!copy - Copy conversation to clipboard',
- value: '!copy',
- },
- {
- name: '!delete-all - Delete all conversations',
- value: '!delete-all',
- },
- {
- name: '!exit - Exit ChatGPT CLI',
- value: '!exit',
- },
- ];
- inquirer.registerPrompt('autocomplete', inquirerAutocompletePrompt);
- const clientToUse = settings.cliOptions?.clientToUse || settings.clientToUse || 'chatgpt';
- let client;
- switch (clientToUse) {
- case 'bing':
- client = new BingAIClient({
- ...settings.bingAiClient,
- cache: settings.cacheOptions,
- });
- break;
- default:
- client = new ChatGPTClient(
- settings.openaiApiKey || settings.chatGptClient.openaiApiKey,
- settings.chatGptClient,
- settings.cacheOptions,
- );
- break;
- }
- console.log(tryBoxen('ChatGPT CLI', {
- padding: 0.7, margin: 1, borderStyle: 'double', dimBorder: true,
- }));
- await conversation();
- async function conversation() {
- console.log('Type "!" to access the command menu.');
- const prompt = inquirer.prompt([
- {
- type: 'autocomplete',
- name: 'message',
- message: 'Write a message:',
- searchText: '',
- emptyText: '',
- suggestOnly: true,
- source: () => Promise.resolve([]),
- },
- ]);
- // hiding the ugly autocomplete hint
- prompt.ui.activePrompt.firstRender = false;
- // The below is a hack to allow selecting items from the autocomplete menu while also being able to submit messages.
- // This basically simulates a hybrid between having `suggestOnly: false` and `suggestOnly: true`.
- await new Promise(resolve => setTimeout(resolve, 0));
- prompt.ui.activePrompt.opt.source = (answers, input) => {
- if (!input) {
- return [];
- }
- prompt.ui.activePrompt.opt.suggestOnly = !input.startsWith('!');
- return availableCommands.filter(command => command.value.startsWith(input));
- };
- let { message } = await prompt;
- message = message.trim();
- if (!message) {
- return conversation();
- }
- if (message.startsWith('!')) {
- switch (message) {
- case '!editor':
- return useEditor();
- case '!resume':
- return resumeConversation();
- case '!new':
- return newConversation();
- case '!copy':
- return copyConversation();
- case '!delete-all':
- return deleteAllConversations();
- case '!exit':
- return true;
- default:
- return conversation();
- }
- }
- return onMessage(message);
- }
- async function onMessage(message) {
- let aiLabel;
- switch (clientToUse) {
- case 'bing':
- aiLabel = 'Bing';
- break;
- default:
- aiLabel = settings.chatGptClient?.chatGptLabel || 'ChatGPT';
- break;
- }
- let reply = '';
- const spinnerPrefix = `${aiLabel} is typing...`;
- const spinner = ora(spinnerPrefix);
- spinner.prefixText = '\n ';
- spinner.start();
- try {
- if (clientToUse === 'bing' && !conversationData.jailbreakConversationId) {
- // activate jailbreak mode for Bing
- conversationData.jailbreakConversationId = true;
- }
- const response = await client.sendMessage(message, {
- ...conversationData,
- onProgress: (token) => {
- reply += token;
- const output = tryBoxen(`${reply.trim()}█`, {
- title: aiLabel, padding: 0.7, margin: 1, dimBorder: true,
- });
- spinner.text = `${spinnerPrefix}\n${output}`;
- },
- });
- let responseText;
- switch (clientToUse) {
- case 'bing':
- responseText = response.details.adaptiveCards?.[0]?.body?.[0]?.text?.trim() || response.response;
- break;
- default:
- responseText = response.response;
- break;
- }
- clipboard.write(responseText).then(() => {}).catch(() => {});
- spinner.stop();
- switch (clientToUse) {
- case 'bing':
- conversationData = {
- parentMessageId: response.messageId,
- jailbreakConversationId: response.jailbreakConversationId,
- // conversationId: response.conversationId,
- // conversationSignature: response.conversationSignature,
- // clientId: response.clientId,
- // invocationId: response.invocationId,
- };
- break;
- default:
- conversationData = {
- conversationId: response.conversationId,
- parentMessageId: response.messageId,
- };
- break;
- }
- await client.conversationsCache.set('lastConversation', conversationData);
- const output = tryBoxen(responseText, {
- title: aiLabel, padding: 0.7, margin: 1, dimBorder: true,
- });
- console.log(output);
- } catch (error) {
- spinner.stop();
- logError(error?.json?.error?.message || error.body || error || 'Unknown error');
- }
- return conversation();
- }
- async function useEditor() {
- let { message } = await inquirer.prompt([
- {
- type: 'editor',
- name: 'message',
- message: 'Write a message:',
- waitUserInput: false,
- },
- ]);
- message = message.trim();
- if (!message) {
- return conversation();
- }
- console.log(message);
- return onMessage(message);
- }
- async function resumeConversation() {
- conversationData = (await client.conversationsCache.get('lastConversation')) || {};
- if (conversationData.conversationId) {
- logSuccess(`Resumed conversation ${conversationData.conversationId}.`);
- } else {
- logWarning('No conversation to resume.');
- }
- return conversation();
- }
- async function newConversation() {
- conversationData = {};
- logSuccess('Started new conversation.');
- return conversation();
- }
- async function deleteAllConversations() {
- if (clientToUse !== 'chatgpt') {
- logWarning('Deleting all conversations is only supported for ChatGPT client.');
- return conversation();
- }
- await client.conversationsCache.clear();
- logSuccess('Deleted all conversations.');
- return conversation();
- }
- async function copyConversation() {
- if (clientToUse !== 'chatgpt') {
- logWarning('Copying conversations is only supported for ChatGPT client.');
- return conversation();
- }
- if (!conversationData.conversationId) {
- logWarning('No conversation to copy.');
- return conversation();
- }
- const { messages } = await client.conversationsCache.get(conversationData.conversationId);
- // get the last message ID
- const lastMessageId = messages[messages.length - 1].id;
- const orderedMessages = ChatGPTClient.getMessagesForConversation(messages, lastMessageId);
- const conversationString = orderedMessages.map(message => `#### ${message.role}:\n${message.message}`).join('\n\n');
- try {
- await clipboard.write(`${conversationString}\n\n----\nMade with ChatGPT CLI: <https://github.com/waylaidwanderer/node-chatgpt-api>`);
- logSuccess('Copied conversation to clipboard.');
- } catch (error) {
- logError(error?.message || error);
- }
- return conversation();
- }
- function logError(message) {
- console.log(tryBoxen(message, {
- title: 'Error', padding: 0.7, margin: 1, borderColor: 'red',
- }));
- }
- function logSuccess(message) {
- console.log(tryBoxen(message, {
- title: 'Success', padding: 0.7, margin: 1, borderColor: 'green',
- }));
- }
- function logWarning(message) {
- console.log(tryBoxen(message, {
- title: 'Warning', padding: 0.7, margin: 1, borderColor: 'yellow',
- }));
- }
- /**
- * Boxen can throw an error if the input is malformed, so this function wraps it in a try/catch.
- * @param {string} input
- * @param {*} options
- */
- function tryBoxen(input, options) {
- try {
- return boxen(input, options);
- } catch {
- return input;
- }
- }
|