Iniziale
This commit is contained in:
324
bin/cli.js
Executable file
324
bin/cli.js
Executable file
@@ -0,0 +1,324 @@
|
||||
#!/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';
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
250
bin/server.js
Executable file
250
bin/server.js
Executable file
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env node
|
||||
import fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { FastifySSEPlugin } from '@waylaidwanderer/fastify-sse-v2';
|
||||
import fs from 'fs';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { KeyvFile } from 'keyv-file';
|
||||
import ChatGPTClient from '../src/ChatGPTClient.js';
|
||||
import ChatGPTBrowserClient from '../src/ChatGPTBrowserClient.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 });
|
||||
}
|
||||
|
||||
const clientToUse = settings.apiOptions?.clientToUse || settings.clientToUse || 'chatgpt';
|
||||
const perMessageClientOptionsWhitelist = settings.apiOptions?.perMessageClientOptionsWhitelist || null;
|
||||
|
||||
const server = fastify();
|
||||
|
||||
console.log('Crea Server')
|
||||
|
||||
await server.register(FastifySSEPlugin);
|
||||
await server.register(cors, {
|
||||
origin: '*',
|
||||
});
|
||||
|
||||
server.get('/ping', () => Date.now().toString());
|
||||
|
||||
server.post('/conversation', async (request, reply) => {
|
||||
const body = request.body || {};
|
||||
const abortController = new AbortController();
|
||||
|
||||
reply.raw.on('close', () => {
|
||||
if (abortController.signal.aborted === false) {
|
||||
abortController.abort();
|
||||
}
|
||||
});
|
||||
|
||||
let onProgress;
|
||||
if (body.stream === true) {
|
||||
onProgress = (token) => {
|
||||
if (settings.apiOptions?.debug) {
|
||||
console.debug(token);
|
||||
}
|
||||
if (token !== '[DONE]') {
|
||||
reply.sse({ id: '', data: JSON.stringify(token) });
|
||||
}
|
||||
};
|
||||
} else {
|
||||
onProgress = null;
|
||||
}
|
||||
|
||||
let result;
|
||||
let error;
|
||||
try {
|
||||
if (!body.message) {
|
||||
const invalidError = new Error();
|
||||
invalidError.data = {
|
||||
code: 400,
|
||||
message: 'The message parameter is required.',
|
||||
};
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw invalidError;
|
||||
}
|
||||
|
||||
let clientToUseForMessage = clientToUse;
|
||||
const clientOptions = filterClientOptions(body.clientOptions, clientToUseForMessage);
|
||||
if (clientOptions && clientOptions.clientToUse) {
|
||||
clientToUseForMessage = clientOptions.clientToUse;
|
||||
delete clientOptions.clientToUse;
|
||||
}
|
||||
|
||||
let { shouldGenerateTitle } = body;
|
||||
if (typeof shouldGenerateTitle !== 'boolean') {
|
||||
shouldGenerateTitle = settings.apiOptions?.generateTitles || false;
|
||||
}
|
||||
|
||||
const messageClient = getClient(clientToUseForMessage);
|
||||
|
||||
result = await messageClient.sendMessage(body.message, {
|
||||
jailbreakConversationId: body.jailbreakConversationId,
|
||||
conversationId: body.conversationId ? body.conversationId.toString() : undefined,
|
||||
parentMessageId: body.parentMessageId ? body.parentMessageId.toString() : undefined,
|
||||
systemMessage: body.systemMessage,
|
||||
context: body.context,
|
||||
conversationSignature: body.conversationSignature,
|
||||
clientId: body.clientId,
|
||||
invocationId: body.invocationId,
|
||||
shouldGenerateTitle, // only used for ChatGPTClient
|
||||
toneStyle: body.toneStyle,
|
||||
clientOptions,
|
||||
onProgress,
|
||||
abortController,
|
||||
});
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (result !== undefined) {
|
||||
if (settings.apiOptions?.debug) {
|
||||
console.debug(result);
|
||||
}
|
||||
if (body.stream === true) {
|
||||
reply.sse({ event: 'result', id: '', data: JSON.stringify(result) });
|
||||
reply.sse({ id: '', data: '[DONE]' });
|
||||
await nextTick();
|
||||
return reply.raw.end();
|
||||
}
|
||||
return reply.send(result);
|
||||
}
|
||||
|
||||
const code = error?.data?.code || (error.name === 'UnauthorizedRequest' ? 401 : 503);
|
||||
if (code === 503) {
|
||||
console.error(error);
|
||||
} else if (settings.apiOptions?.debug) {
|
||||
console.debug(error);
|
||||
}
|
||||
const message = error?.data?.message || error?.message || `There was an error communicating with ${clientToUse === 'bing' ? 'Bing' : 'ChatGPT'}.`;
|
||||
if (body.stream === true) {
|
||||
reply.sse({
|
||||
id: '',
|
||||
event: 'error',
|
||||
data: JSON.stringify({
|
||||
code,
|
||||
error: message,
|
||||
}),
|
||||
});
|
||||
await nextTick();
|
||||
return reply.raw.end();
|
||||
}
|
||||
return reply.code(code).send({ error: message });
|
||||
});
|
||||
|
||||
server.listen({
|
||||
port: settings.apiOptions?.port || settings.port || 3100,
|
||||
host: settings.apiOptions?.host || 'localhost',
|
||||
}, (error) => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
function nextTick() {
|
||||
return new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function getClient(clientToUseForMessage) {
|
||||
switch (clientToUseForMessage) {
|
||||
case 'bing':
|
||||
return new BingAIClient({ ...settings.bingAiClient, cache: settings.cacheOptions });
|
||||
case 'chatgpt-browser':
|
||||
return new ChatGPTBrowserClient(
|
||||
settings.chatGptBrowserClient,
|
||||
settings.cacheOptions,
|
||||
);
|
||||
case 'chatgpt':
|
||||
return new ChatGPTClient(
|
||||
settings.openaiApiKey || settings.chatGptClient.openaiApiKey,
|
||||
settings.chatGptClient,
|
||||
settings.cacheOptions,
|
||||
);
|
||||
default:
|
||||
throw new Error(`Invalid clientToUse: ${clientToUseForMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter objects to only include whitelisted properties set in
|
||||
* `settings.js` > `apiOptions.perMessageClientOptionsWhitelist`.
|
||||
* Returns original object if no whitelist is set.
|
||||
* @param {*} inputOptions
|
||||
* @param clientToUseForMessage
|
||||
*/
|
||||
function filterClientOptions(inputOptions, clientToUseForMessage) {
|
||||
if (!inputOptions || !perMessageClientOptionsWhitelist) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If inputOptions.clientToUse is set and is in the whitelist, use it instead of the default
|
||||
if (
|
||||
perMessageClientOptionsWhitelist.validClientsToUse
|
||||
&& inputOptions.clientToUse
|
||||
&& perMessageClientOptionsWhitelist.validClientsToUse.includes(inputOptions.clientToUse)
|
||||
) {
|
||||
clientToUseForMessage = inputOptions.clientToUse;
|
||||
} else {
|
||||
inputOptions.clientToUse = clientToUseForMessage;
|
||||
}
|
||||
|
||||
const whitelist = perMessageClientOptionsWhitelist[clientToUseForMessage];
|
||||
if (!whitelist) {
|
||||
// No whitelist, return all options
|
||||
return inputOptions;
|
||||
}
|
||||
|
||||
const outputOptions = {
|
||||
clientToUse: clientToUseForMessage,
|
||||
};
|
||||
|
||||
for (const property of Object.keys(inputOptions)) {
|
||||
const allowed = whitelist.includes(property);
|
||||
|
||||
if (!allowed && typeof inputOptions[property] === 'object') {
|
||||
// Check for nested properties
|
||||
for (const nestedProp of Object.keys(inputOptions[property])) {
|
||||
const nestedAllowed = whitelist.includes(`${property}.${nestedProp}`);
|
||||
if (nestedAllowed) {
|
||||
outputOptions[property] = outputOptions[property] || {};
|
||||
outputOptions[property][nestedProp] = inputOptions[property][nestedProp];
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Copy allowed properties to outputOptions
|
||||
if (allowed) {
|
||||
outputOptions[property] = inputOptions[property];
|
||||
}
|
||||
}
|
||||
|
||||
return outputOptions;
|
||||
}
|
||||
Reference in New Issue
Block a user