From 643d5fef8d0375b7df53777da24c3600bf6a2bb3 Mon Sep 17 00:00:00 2001 From: josc146 Date: Sun, 3 Mar 2024 23:05:19 +0800 Subject: [PATCH] custom npm claude-ai --- package-lock.json | 61 -- package.json | 1 - src/services/apis/claude-web.mjs | 2 +- src/services/clients/claude/index.mjs | 999 ++++++++++++++++++++++++++ 4 files changed, 1000 insertions(+), 63 deletions(-) create mode 100644 src/services/clients/claude/index.mjs diff --git a/package-lock.json b/package-lock.json index a1f5a3a..3872d5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "@picocss/pico": "^1.5.9", "@primer/octicons-react": "^18.3.0", "buffer": "^6.0.3", - "claude-ai": "^1.2.2", "countries-list": "^2.6.1", "crypto-browserify": "^3.12.0", "diff": "^5.1.0", @@ -3431,14 +3430,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/claude-ai": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/claude-ai/-/claude-ai-1.2.2.tgz", - "integrity": "sha512-/4NWVJvlT5+jiFxuXq6HUxFW2bN3fyaXbL2cJNF1u5vpNE/C4WkFO2O50KqwFYeeuo4PsamUbFPx6bAdLbk9RA==", - "dependencies": { - "isomorphic-fetch": "^3.0.0" - } - }, "node_modules/clsx": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", @@ -6169,53 +6160,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "node_modules/isomorphic-fetch": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", - "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", - "dependencies": { - "node-fetch": "^2.6.1", - "whatwg-fetch": "^3.4.1" - } - }, - "node_modules/isomorphic-fetch/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/isomorphic-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "node_modules/isomorphic-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/isomorphic-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/iterator.prototype": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", @@ -11235,11 +11179,6 @@ "node": ">=12" } }, - "node_modules/whatwg-fetch": { - "version": "3.6.19", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.19.tgz", - "integrity": "sha512-d67JP4dHSbm2TrpFj8AbO8DnL1JXL5J9u0Kq2xW6d0TFDbCA3Muhdt8orXC22utleTVj7Prqt82baN6RBvnEgw==" - }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", diff --git a/package.json b/package.json index bacde0e..52212e1 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "@picocss/pico": "^1.5.9", "@primer/octicons-react": "^18.3.0", "buffer": "^6.0.3", - "claude-ai": "^1.2.2", "countries-list": "^2.6.1", "crypto-browserify": "^3.12.0", "diff": "^5.1.0", diff --git a/src/services/apis/claude-web.mjs b/src/services/apis/claude-web.mjs index d1525ff..f8ec51f 100644 --- a/src/services/apis/claude-web.mjs +++ b/src/services/apis/claude-web.mjs @@ -1,5 +1,5 @@ import { pushRecord } from './shared.mjs' -import Claude from 'claude-ai' +import Claude from '../clients/claude' import { Models } from '../../config/index.mjs' /** diff --git a/src/services/clients/claude/index.mjs b/src/services/clients/claude/index.mjs new file mode 100644 index 0000000..134dd2e --- /dev/null +++ b/src/services/clients/claude/index.mjs @@ -0,0 +1,999 @@ +// https://github.com/Explosion-Scratch/claude-unofficial-api +/* eslint-disable */ + +/** + * The main Claude API client class. + * @typedef Claude + * @class + * @classdesc Creates an instance of the Claude API client. + */ +export class Claude { + /** + * If the Claude client has initialized yet (call `init()` if you haven't and this is false) + * @property {boolean} + */ + ready + /** + * A proxy function/string to connect via + * @property {({endpoint: string, options: Object}) => {endpoint: string, options: Object} | string} + */ + proxy + /** + * A fetch function, defaults to globalThis.fetch + * @property {Function} + */ + fetch + /** + * The session key string (from the cookie) + * @property {string} + */ + sessionKey + /** + * A UUID string + * @typedef UUID + * @example "222aa20a-bc79-48d2-8f6d-c819a1b5eaed" + */ + /** + * Create a new Claude API client instance. + * @param {Object} options - Options + * @param {string} options.sessionKey - Claude session key + * @param {string|function} [options.proxy] - Proxy URL or proxy function + * @param {function} [options.fetch] - Fetch function + * @example + * const claude = new Claude({ + * sessionKey: 'sk-ant-sid01-*****', + * fetch: globalThis.fetch + * }) + * + * await claude.init(); + * claude.sendMessage('Hello world').then(console.log) + */ + constructor({ sessionKey, proxy, fetch }) { + this.ready = false + if (typeof proxy === 'string') { + const HOST = proxy + this.proxy = ({ endpoint, options }) => ({ endpoint: HOST + endpoint, options }) + } else if (typeof proxy === 'function') { + this.proxy = proxy + } else if (proxy) { + console.log( + 'Proxy supported formats:\n\t({ endpoint /* endpoint (path) */, options /* fetch options */ }) => { endpoint /* full url */, options /* fetch options */ }', + ) + console.log('Received proxy: ' + proxy) + throw new Error('Proxy must be a string (host) or a function') + } + if (!this.proxy) { + this.proxy = ({ endpoint, options }) => ({ + endpoint: 'https://claude.ai' + endpoint, + options, + }) + } + if (!sessionKey) { + throw new Error('Session key required') + } + if (!sessionKey.startsWith('sk-ant-sid01')) { + throw new Error('Session key invalid: Must be in the format sk-ant-sid01-*****') + } + if (fetch) { + this.fetch = fetch + } + this.sessionKey = sessionKey + } + /** + * Get available Claude models. + * @returns {string[]} Array of model names + */ + models() { + return ['claude-2', 'claude-1.3', 'claude-instant', 'claude-instant-100k'] + } + /** + * Get total token count for a Claude model. + * @param {string} [model] - Claude model name + * @returns {number} Total token count + */ + totalTokens(model) { + // TODO: Figure out if this is correct, the blog article said "We’ve expanded Claude’s context window from 9K to 100K tokens" + const TOKENS = { + 'claude-2': 100_000, + 'claude-1.3': 9000, + 'claude-instant': 9000, + 'claude-instant-100k': 100_000, + } + return TOKENS[model || this.defaultModel()] + } + /** + * Get the default Claude model. + * @returns {string} Default model name + */ + defaultModel() { + return this.models()[0] + } + /** + * A partial or total completion for a message. + * @typedef MessageStreamChunk + * @property {String} completion The markdown text completion for this response + * @property {String | null} stop_reason The reason for the response stop (if any) + * @property {String} model The model used + * @property {String} stop The string at which Claude stopped responding at, e.g. "\n\nHuman:" + * @property {String} log_id A logging ID + * @property {Object} messageLimit If you're within the message limit + * @param {String} messageLimit.type The type of message limit ("within_limit") + */ + /** + * Send a message to a new or existing conversation. + * @param {string} message - Initial message + * @param {SendMessageParams} [params] - Additional parameters + * @param {string} [params.conversation] - Existing conversation ID + * @param {boolean} [params.temporary=true] - Delete after getting response + * @returns {Promise} Result message + */ + async sendMessage(message, { conversation = null, temporary = true, ...params }) { + if (!conversation) { + let out + let convo = await this.startConversation(message, { + ...params, + done: (a) => { + if (params.done) { + params.done(a) + } + out = a + }, + }) + if (temporary) { + await convo.delete() + } + return out + } else { + return (await this.getConversation(conversation)).sendMessage(message, { + ...params, + }) + } + } + /** + * Make an API request. + * @param {string} endpoint - API endpoint + * @param {Object} options - Request options + * @returns {Promise} Fetch response + * @example + * await claude.request('/api/organizations').then(r => r.json()) + */ + request(endpoint, options) { + // Can't figure out a way to test this so I'm just assuming it works + if (!(this.fetch || globalThis.fetch)) { + throw new Error( + `No fetch available in your environment. Use node-18 or later, a modern browser, or add the following code to your project:\n\nimport "isomorphic-fetch";\nconst claude = new Claude({fetch: fetch, sessionKey: "sk-ant-sid01-*****"});`, + ) + } + if (!this.proxy) { + this.proxy = ({ endpoint, options }) => ({ + endpoint: 'https://claude.ai' + endpoint, + options, + }) + } + if (typeof this.proxy === 'string') { + const HOST = this.proxy + this.proxy = ({ endpoint, options }) => ({ endpoint: HOST + endpoint, options }) + } + const proxied = this.proxy({ endpoint, options }) + return (this.fetch || globalThis.fetch)(proxied.endpoint, proxied.options) + } + /** + * Initialize the client. + * @async + * @returns {Promise} Void + */ + async init() { + const organizations = await this.getOrganizations() + if (organizations.error) { + throw new Error(JSON.stringify(organizations, null, 2)) + } + this.organizationId = organizations[0].uuid + this.recent_conversations = await this.getConversations() + this.ready = true + } + /** + * An organization + * @typedef Organization + * @property {String} join_token A token + * @property {String} name The organization name + * @property {String} uuid The organization UUID + * @property {String} created_at The organization creation date + * @property {String} updated_at The organization update date + * @property {String[]} capabilities What the organization can do + * @property {Object} settings The organization's settings + * @property {Array} active_flags Organization's flags (none that I've found) + */ + /** + * Get the organizations list. + * @async + * @returns {Promise} A list of organizations + * @example + * await claude.getOrganizations().then(organizations => { + * console.log('Users organization name is:', organizations[0].name) + * }) + */ + async getOrganizations() { + const response = await this.request('/api/organizations', { + headers: { + 'content-type': 'application/json', + cookie: `sessionKey=${this.sessionKey}`, + }, + }) + return await response.json().catch(errorHandle('getOrganizations')) + } + /** + * Delete all conversations + * @async + * @returns {Promise} An array of responses for the DELETE requests + * @example + * await claude.clearConversations(); + * console.assert(await claude.getConversations().length === 0); + */ + async clearConversations() { + const convos = await this.getConversations() + return Promise.all(convos.map((i) => i.delete())) + } + /** + * @callback doneCallback + * @param {MessageStreamChunk} a The completed response + */ + /** + * @callback progressCallback + * @param {MessageStreamChunk} a The response in progress + */ + /** + * Start a new conversation + * @param {String} message The message to send to start the conversation + * @param {SendMessageParams} [params={}] Message params passed to Conversation.sendMessage + * @returns {Promise} + * @async + * @example + * const conversation = await claude.startConversation("Hello! How are you?") + * console.log(await conversation.getInfo()); + */ + async startConversation(message, params = {}) { + if (!this.ready) { + await this.init() + } + const { + uuid: convoID, + name, + summary, + created_at, + updated_at, + } = await this.request(`/api/organizations/${this.organizationId}/chat_conversations`, { + headers: { + 'content-type': 'application/json', + cookie: `sessionKey=${this.sessionKey}`, + }, + method: 'POST', + body: JSON.stringify({ + name: '', + uuid: uuid(), + }), + }) + .then((r) => r.json()) + .catch(errorHandle('startConversation create')) + const convo = new Conversation(this, { + conversationId: convoID, + name, + summary, + created_at, + updated_at, + }) + await convo.sendMessage(message, params) + await this.request(`/api/generate_chat_title`, { + headers: { + 'content-type': 'application/json', + cookie: `sessionKey=${this.sessionKey}`, + }, + body: JSON.stringify({ + organization_uuid: this.organizationId, + conversation_uuid: convoID, + message_content: message, + recent_titles: this.recent_conversations.map((i) => i.name), + }), + method: 'POST', + }) + .then((r) => r.json()) + .catch(errorHandle('startConversation generate_chat_title')) + return convo + } + /** + * Get a conversation by its ID + * @param {UUID} id The uuid of the conversation (Conversation.uuid or Conversation.conversationId) + * @async + * @returns {Conversation | null} The conversation + * @example + * const conversation = await claude.getConversation("222aa20a-bc79-48d2-8f6d-c819a1b5eaed"); + */ + async getConversation(id) { + if (id instanceof Conversation || id.conversationId) { + return new Conversation(this, { conversationId: id.conversationId }) + } + return new Conversation(this, { conversationId: id }) + } + /** + * Get all conversations + * @async + * @returns {Promise} A list of conversations + * @example + * console.log(`You have ${await claude.getConversations().length} conversations:`); + */ + async getConversations() { + const response = await this.request( + `/api/organizations/${this.organizationId}/chat_conversations`, + { + headers: { + 'content-type': 'application/json', + cookie: `sessionKey=${this.sessionKey}`, + }, + }, + ) + const json = await response.json() + return json.map((convo) => new Conversation(this, { conversationId: convo.uuid, ...convo })) + } + /** + * The response from uploading a file (an attachment) + * @typedef Attachment + * @property {String} file_name The file name + * @property {String} file_type The file's mime type + * @property {Number} file_size The file size in bytes + * @property {String} extracted_content The contents of the file that were extracted + * @property {Number | null} [totalPages] The total pages of the document + */ + /** + * Extract the contents of a file + * @param {File} file A JS File (like) object to upload. + * @async + * @returns {Promise} + * @example + * const file = await claude.uploadFile( + * new File(["test"], "test.txt", { type: "text/plain" } + * ); + * console.log(await claude.sendMessage("What's the contents of test.txt?", { + * attachments: [file] + * })) + */ + async uploadFile(file) { + const { content, isText } = await readAsText(file) + if (isText) { + console.log(`Extracted ${content.length} characters from ${file.name}`) + return { + file_name: file.name, + file_type: file.type, + file_size: file.size, + extracted_content: content, + } + } + const fd = new FormData() + fd.append('file', file, file.name) + fd.append('orgUuid', this.organizationId) + const response = await this.request('/api/convert_document', { + headers: { + cookie: `sessionKey=${this.sessionKey}`, + }, + method: 'POST', + body: fd, + }) + let json + try { + json = await response.json() + } catch (e) { + console.log("Couldn't parse JSON", response.status) + throw new Error('Invalid response when uploading ' + file.name) + } + if (response.status !== 200) { + console.log('Status not 200') + throw new Error('Invalid response when uploading ' + file.name) + } + if (!json.hasOwnProperty('extracted_content')) { + console.log(json) + throw new Error('Invalid response when uploading ' + file.name) + } + console.log(`Extracted ${json.extracted_content.length} characters from ${file.name}`) + return json + } +} + +/** + * @typedef SendMessageParams + * @property {Boolean} [retry=false] Whether to retry the most recent message in the conversation instead of sending a new one + * @property {String} [timezone="America/New_York"] The timezone + * @property {Attachment[]} [attachments=[]] Attachments + * @property {doneCallback} [done] Callback when done receiving the message response + * @property {progressCallback} [progress] Callback on message response progress + * @property {string} [model=claude.defaultModel()] The model to use + */ +/** + * A Claude conversation instance. + * @class + * @typedef Conversation + * @classdesc Represents an active Claude conversation. + */ +export class Conversation { + /** + * The conversation ID + * @property {string} + */ + conversationId + + /** + * The conversation name + * @property {string} + */ + name + + /** + * The conversation summary (usually empty) + * @property {string} + */ + summary + + /** + * The conversation created at + * @property {string} + */ + created_at + + /** + * The conversation updated at + * @property {string} + */ + updated_at + + /** + * The Claude client + * @property {Claude} + */ + claude + + /** + * The request function (from parent claude instance) + * @property {(url: string, options: object) => Response} + */ + request + + /** + * The current model + * @property {string} + */ + model + + /** + * If the Claude client has initialized yet (call `init()` if you haven't and this is false) + * @property {boolean} + */ + ready + + /** + * A proxy function/string to connect via + * @property {({endpoint: string, options: Object}) => {endpoint: string, options: Object} | string} + */ + proxy + + /** + * A fetch function, defaults to globalThis.fetch + * @property {Function} + */ + fetch + /** + * Create a Conversation instance. + * @param {Claude} claude - Claude client instance + * @param {Object} options - Options + * @param {String} options.conversationId - Conversation ID + * @param {String} [options.name] - Conversation name + * @param {String} [options.summary] - Conversation summary + * @param {String} [options.created_at] - Conversation created at + * @param {String} [options.updated_at] - Conversation updated at + * @param {String} [options.model] - Claude model + */ + constructor( + claude, + { model = 'default', conversationId, name = '', summary = '', created_at, updated_at }, + ) { + this.claude = claude + this.conversationId = conversationId + this.request = claude.request + if (!this.claude) { + throw new Error('Claude not initialized') + } + if (!this.claude.sessionKey) { + throw new Error('Session key required') + } + if (!this.conversationId) { + throw new Error('Conversation ID required, are you calling `await claude.init()`?') + } + if (model === 'default') { + model = this.claude.defaultModel() + } + this.model = model || this.claude.defaultModel() + Object.assign(this, { + name, + summary, + created_at: created_at || new Date().toISOString(), + updated_at: updated_at || new Date().toISOString(), + }) + } + /** + * Convert the conversation to a JSON object + * @returns {Conversation} The serializable object + */ + toJSON() { + return { + conversationId: this.conversationId, + uuid: this.conversationId, + name: this.name, + summary: this.summary, + created_at: this.created_at, + updated_at: this.updated_at, + model: this.model, + } + } + /** + * Retry the last message in the conversation + * @param {SendMessageParams} [params={}] + * @returns {Promise} + */ + async retry(params) { + return this.sendMessage('', { ...params, retry: true }) + } + /** + * Send a message to this conversation + * @param {String} message + * @async + * @param {SendMessageParams} params The parameters to send along with the message + * @returns {Promise} + */ + async sendMessage( + message, + { + retry = false, + timezone = 'America/New_York', + attachments = [], + model = 'default', + done = () => {}, + progress = () => {}, + rawResponse = () => {}, + } = {}, + ) { + if (model === 'default') { + model = this.claude.defaultModel() + } + const body = { + prompt: message, + attachments, + timezone, + } + const response = await this.request( + `/api/organizations/${this.claude.organizationId}/chat_conversations/${this.conversationId}/${ + retry ? 'retry_completion' : 'completion' + }`, + { + method: 'POST', + headers: { + accept: 'text/event-stream,text/event-stream', + 'content-type': 'application/json', + cookie: `sessionKey=${this.claude.sessionKey}`, + }, + body: JSON.stringify(body), + }, + ) + let resolve + let returnPromise = new Promise((r) => (resolve = r)) + let parsed + readStream(response, (a, fullResponse) => { + rawResponse(a, fullResponse) + if (!a.toString().startsWith('data:')) { + return + } + try { + parsed = JSON.parse( + a + .toString() + .replace(/^data\:/, '') + .split('\n\ndata:')[0] + ?.trim() || '{}', + ) + } catch (e) { + return + } + const PROGRESS_OBJECT = { + ...parsed, + completion: fullResponse + .split('\n\n') + .filter((i) => i.startsWith('data:')) + .map((i) => { + try { + return JSON.parse( + i + .toString() + .replace(/^data\: */, '') + .split('\n\ndata:')[0] + ?.trim() || '{}', + ) + } catch (e) { + return {} + } + }) + .map((i) => i.completion) + .join(''), + delta: parsed.completion, + } + progress(PROGRESS_OBJECT) + if (parsed.stop_reason === 'stop_sequence') { + done(PROGRESS_OBJECT) + resolve(PROGRESS_OBJECT) + } + }) + return returnPromise + } + /** + * Rename the current conversation + * @async + * @param {String} title The new title + * @returns {Promise} A Response object + */ + async rename(title) { + if (!title?.length) { + throw new Error('Title required') + } + return await this.request('/api/rename_chat', { + method: 'POST', + headers: { + cookie: `sessionKey=${this.claude.sessionKey}`, + }, + body: JSON.stringify({ + conversation_uuid: this.conversationId, + organization_uuid: this.claude.organizationId, + title, + }), + }).catch(errorHandle('Rename conversation ' + this.conversationId)) + } + /** + * Delete the conversation + * @async + * @returns Promise + */ + async delete() { + return await this.request( + `/api/organizations/${this.claude.organizationId}/chat_conversations/${this.conversationId}`, + { + headers: { + cookie: `sessionKey=${this.claude.sessionKey}`, + }, + method: 'DELETE', + }, + ).catch(errorHandle('Delete conversation ' + this.conversationId)) + } + /** + * @typedef Message + * @property {UUID} uuid The message UUID + * @property {String} text The message text + * @property {String} created_at The message created at + * @property {String} updated_at The message updated at + * @property {String | null} edited_at When the message was last edited (no editing support via api/web client) + * @property {Any | null} chat_feedback Feedback + * @property {Attachment[]} attachments The attachments + */ + /** + * @typedef ConversationInfo + * @extends Conversation + * @property {Message[]} chat_messages The messages in this conversation + */ + /** + * Get information about this conversation + * @returns {Promise} + */ + async getInfo() { + const response = await this.request( + `/api/organizations/${this.claude.organizationId}/chat_conversations/${this.conversationId}`, + { + headers: { + 'content-type': 'application/json', + cookie: `sessionKey=${this.claude.sessionKey}`, + }, + }, + ) + return await response + .json() + .then(this.#formatMessages('chat_messages')) + .catch(errorHandle('getInfo')) + } + /** + * Get all the files from this conversation + * @async + * @returns {Promise} + */ + getFiles() { + return this.getMessages() + .then((r) => r.map((i) => i.attachments)) + .then((r) => r.flat()) + .catch(errorHandle('getFiles')) + } + /** + * Get all messages in the conversation + * @async + * @returns {Promise} + */ + getMessages() { + return this.getInfo() + .then((a) => a.chat_messages) + .catch(errorHandle('getMessages')) + } + /** + * Internal method for converting a JSON response to contain Message objects + * @param {String} message_key The message key in the object + * @returns {Function} + */ + #formatMessages(message_key) { + return (response) => { + if (!response[message_key]) { + return response + } + return { + ...response, + [message_key]: response[message_key].map( + (i) => new Message({ claude: this.claude, conversation: this }, { ...i }), + ), + } + } + } +} + +/** + * Reads a stream and returns the decoded data as a string. + * + * @param {Response} response - The response object containing the stream. + * @param {function} progressCallback - A callback function to track the progress of reading the stream. + * @return {Promise} - A promise that resolves with the decoded data as a string. + */ +async function readStream(response, progressCallback) { + const reader = response.body.getReader() + let received = 0 + let chunks = [] + let loading = true + while (loading) { + const { done, value } = await reader.read() + if (done) { + loading = false + break + } + chunks.push(value) + received += value?.length || 0 + + let full = new Uint8Array(received) + let position = 0 + + for (let chunk of chunks) { + full.set(chunk, position) + position += chunk.length + } + + if (value) { + progressCallback( + new TextDecoder('utf-8').decode(value), + new TextDecoder('utf-8').decode(full), + ) + } + } + + let body = new Uint8Array(received) + let position = 0 + + for (let chunk of chunks) { + body.set(chunk, position) + position += chunk.length + } + + return new TextDecoder('utf-8').decode(body) +} + +/** + * Reads the contents of a file as text. + * + * @param {File} file - The file object to read. + * @return {Object} - An object containing the content of the file and a flag indicating if it is a text file. + */ +async function readAsText(file) { + const buf = await file.arrayBuffer() + // const allow = ['text', 'javascript', 'json', 'html', 'sh', 'xml', 'latex', 'ecmascript'] + const notText = ['doc', 'pdf', 'ppt', 'xls'] + return { + content: new TextDecoder('utf-8').decode(buf), + isText: !notText.find((i) => file.name.includes(i)), + } +} + +/** + * A function that handles errors. + * + * @param {string} msg - The error message. + * @return {function} - A function that logs the error message and exits the process. + */ +function errorHandle(msg) { + return (e) => { + console.error(`Error at: ${msg}`) + console.error(e) + process.exit(0) + } +} + +/** + * Generates a random UUID. + * + * @return {UUID} A randomly generated UUID. + */ +function uuid() { + var h = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'] + var k = [ + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + '-', + 'x', + 'x', + 'x', + 'x', + '-', + '4', + 'x', + 'x', + 'x', + '-', + 'y', + 'x', + 'x', + 'x', + '-', + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + ] + var u = '', + i = 0, + rb = (Math.random() * 0xffffffff) | 0 + while (i++ < 36) { + var c = k[i - 1], + r = rb & 0xf, + v = c == 'x' ? r : (r & 0x3) | 0x8 + u += c == '-' || c == '4' ? c : h[v] + rb = i % 8 == 0 ? (Math.random() * 0xffffffff) | 0 : rb >> 4 + } + return u +} + +/** + * @typedef JSONResponse + * @property {'human' | 'assistant'} sender The sender + * @property {string} text The text + * @property {UUID} uuid msg uuid + * @property {string} created_at The message created at + * @property {string} updated_at The message updated at + * @property {string} edited_at When the message was last edited (no editing support via api/web client) + * @property {Attachment[]} attachments The attachments + * @property {string} chat_feedback Feedback + */ +/** + * Message class + * @class + * @classdesc A class representing a message in a Conversation + * @property {Function} request The request function (inherited from claude instance) + * @property {JSONResponse} json The JSON representation + * @property {Claude} claude The claude instance + * @property {Conversation} conversation The conversation this message belongs to + * @property {UUID} uuid The message uuid + */ +export class Message { + /** + * Create a Message instance. + * @param {Object} params - Params + * @param {Conversation} params.conversation - Conversation instance + * @param {Claude} params.claude - Claude instance + * @param {Message} message - Message data + */ + constructor( + { conversation, claude }, + { uuid, text, sender, index, updated_at, edited_at, chat_feedback, attachments }, + ) { + if (!claude) { + throw new Error('Claude not initialized') + } + if (!conversation) { + throw new Error('Conversation not initialized') + } + Object.assign(this, { conversation, claude }) + this.request = claude.request + this.json = { uuid, text, sender, index, updated_at, edited_at, chat_feedback, attachments } + Object.assign(this, this.json) + } + /** + * Convert this message to a JSON representation + * Necessary to prevent circular JSON errors + * @returns {Message} + */ + toJSON() { + return this.json + } + /** + * Returns the value of the "created_at" property as a Date object. + * + * @return {Date} The value of the "created_at" property as a Date object. + */ + get createdAt() { + return new Date(this.json.created_at) + } + /** + * Returns the value of the "updated_at" property as a Date object. + * + * @return {Date} The value of the "updated_at" property as a Date object. + */ + get updatedAt() { + return new Date(this.json.updated_at) + } + /** + * Returns the value of the "edited_at" property as a Date object. + * + * @return {Date} The value of the "edited_at" property as a Date object. + */ + get editedAt() { + return new Date(this.json.edited_at) + } + /** + * Get if message is from the assistant. + * @type {boolean} + */ + get isBot() { + return this.sender === 'assistant' + } + /** + * @typedef MessageFeedback + * @property {UUID} uuid - Message UUID + * @property {"flag/bug" | "flag/harmful" | "flag/other"} type - Feedback type + * @property {String | null} reason - Feedback reason (details box) + * @property {String} created_at - Feedback creation date + * @property {String} updated_at - Feedback update date + */ + /** + * Send feedback on the message. + * @param {string} type - Feedback type + * @param {string} [reason] - Feedback reason + * @returns {Promise} Response + */ + async sendFeedback(type, reason = '') { + const FEEDBACK_TYPES = ['flag/bug', 'flag/harmful', 'flag/other'] + if (!FEEDBACK_TYPES.includes(type)) { + throw new Error('Invalid feedback type, must be one of: ' + FEEDBACK_TYPES.join(', ')) + } + return await this.request( + `/api/organizations/${this.claude.organizationId}/chat_conversations/${this.conversation.conversationId}/chat_messages/${this.uuid}/chat_feedback`, + { + headers: { + cookie: `sessionKey=${this.claude.sessionKey}`, + }, + body: JSON.stringify({ + type, + reason, + }), + method: 'POST', + }, + ).catch(errorHandle('Send feedback')) + } +} + +export default Claude +/* eslint-enable */