Files
chatGPTBox/src/services/apis/moonshot-web.mjs
T
2024-08-07 17:13:27 +08:00

624 lines
18 KiB
JavaScript

import { pushRecord, setAbortController } from './shared.mjs'
import { setUserConfig } from '../../config/index.mjs'
import { fetchSSE } from '../../utils/fetch-sse'
import { isEmpty } from 'lodash-es'
import { getModelValue } from '../../utils/model-name-convert.mjs'
export class MoonshotWeb {
/**
* If the moonshot 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
/**
* @property {UserConfig}
*/
config
refreshToken
accessToken
/**
* Create a new moonshot API client instance.
* @param {Object} options - Options
* @param {UserConfig} options.config
* @param {function} [options.fetch] - Fetch function
* @example
* const moonshot = new moonshot({
* sessionKey: 'sk-ant-sid01-*****',
* fetch: globalThis.fetch
* })
*
* await moonshot.init();
* moonshot.sendMessage('Hello world').then(console.log)
*/
constructor({ config, fetch }) {
if (fetch) {
this.fetch = fetch
}
this.config = config
this.refreshToken = config.kimiMoonShotRefreshToken
this.accessToken = config.kimiMoonShotAccessToken
}
/**
* Get available models.
* @returns {string[]} Array of model names
*/
models() {
return ['']
}
/**
* Get the default model.
* @returns {string} Default model name
*/
defaultModel() {
return this.models()[0]
}
/**
* todo: mod
* 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<MessageStreamChunk>} Result message
*/
async sendMessage(message, { conversation = null, temporary = true, ...params }) {
if (!this.ready) {
await this.init()
}
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<Response>} Fetch response
* @example
* await a.request('/api/chat/cnor0teaofogidj025b0/completion/stream').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 moonshot = new moonshot({fetch: fetch, sessionKey: "sk-ant-sid01-*****"});`,
)
}
if (!this.proxy) {
this.proxy = ({ endpoint, options }) => ({
endpoint: 'https://kimi.moonshot.cn' + 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>} Void
*/
async init() {
const response = this.request('/api/user', {
headers: {
accept: '*/*',
Authorization: `Bearer ${this.accessToken}`,
Origin: 'https://kimi.moonshot.cn',
},
method: 'GET',
})
if ((await response).status === 200) {
this.ready = true
} else {
const { access_token, refresh_token } = await this.request('/api/auth/token/refresh', {
headers: {
accept: '*/*',
Authorization: `Bearer ${this.refreshToken}`,
Origin: 'https://kimi.moonshot.cn',
},
method: 'GET',
})
.then((r) => r.json())
.catch(errorHandle('get kimi.moonshoot.cn access_token'))
this.accessToken = access_token
this.refreshToken = refresh_token
this.config.kimiMoonShotAccessToken = access_token
this.config.kimiMoonShotRefreshToken = refresh_token
await setUserConfig({
kimiMoonShotAccessToken: access_token,
kimiMoonShotRefreshToken: refresh_token,
})
this.ready = true
}
}
/**
* 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<Conversation>}
* @async
* @example
* const conversation = await moonshot.startConversation("Hello! How are you?")
* console.log(await conversation.getInfo());
*/
async startConversation(message, params = {}) {
if (!this.ready) {
await this.init()
}
const { id, name, created_at } = await this.request('/api/chat', {
headers: {
accept: '*/*',
'content-type': 'application/json',
Authorization: `Bearer ${this.accessToken}`,
Origin: 'https://kimi.moonshot.cn',
},
method: 'POST',
signal: params.signal,
body: JSON.stringify({ name: '未命名会话', is_example: false }),
})
.then((r) => r.json())
.catch(errorHandle('startConversation create'))
const convo = new Conversation(this, {
conversationId: id,
name,
created_at,
})
await convo.sendMessage(message, params)
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 moonshot.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 })
}
}
/**
* @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=moonshot.defaultModel()] The model to use
*/
/**
* A moonshot conversation instance.
* @class
* @typedef Conversation
* @classdesc Represents an active moonshot 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 request function (from parent moonshot instance)
* @property {(url: string, options: object) => Response}
*/
request
/**
* The current model
* @property {string}
*/
model
/**
* If the moonshot 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 {MoonshotWeb} moonshot - moonshot 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] - moonshot model
*/
constructor(
moonshot,
{ model = 'default', conversationId, name = '', summary = '', created_at, updated_at },
) {
this.moonshot = moonshot
this.conversationId = conversationId
this.request = moonshot.request
if (!this.moonshot) {
throw new Error('moonshot not initialized')
}
if (!this.moonshot.refreshToken) {
throw new Error(
'moonshot token required, please login at https://kimi.moonshot.cn first, and then click the retry button',
)
}
if (!this.conversationId) {
throw new Error('Conversation ID required, are you calling `await moonshot.init()`?')
}
if (model === 'default') {
model = this.moonshot.defaultModel()
}
this.model = model || this.moonshot.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,
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<MessageStreamChunk>}
*/
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<MessageStreamChunk>}
*/
async sendMessage(
message,
{
// eslint-disable-next-line no-unused-vars
retry = false,
model = 'default',
done = () => {},
progress = () => {},
// eslint-disable-next-line no-unused-vars
rawResponse = () => {},
signal = null,
} = {},
) {
if (model === 'default') {
model = this.moonshot.defaultModel()
}
// {"messages":[{"role":"user","content":"hello"}],"refs":[],"use_search":true}
const body = { messages: [{ role: 'user', content: message }], refs: [], use_search: true }
let resolve, reject
let returnPromise = new Promise((r, j) => {
resolve = r
reject = j
})
let fullResponse = ''
await fetchSSE(`https://kimi.moonshot.cn/api/chat/${this.conversationId}/completion/stream`, {
method: 'POST',
headers: {
accept: '*/*',
'content-type': 'application/json',
Authorization: `Bearer ${this.moonshot.accessToken}`,
},
signal: signal,
body: JSON.stringify(body),
onMessage(message) {
console.debug('sse message', message)
let parsed
try {
parsed = JSON.parse(message)
} catch (error) {
console.debug('json error', error)
return
}
if (parsed.event === 'cmpl' && parsed.text) fullResponse += parsed.text
const PROGRESS_OBJECT = {
...parsed,
completion: fullResponse,
delta: parsed.text || '',
}
progress(PROGRESS_OBJECT)
if (parsed.event === 'all_done') {
done(PROGRESS_OBJECT)
resolve(PROGRESS_OBJECT)
}
},
async onStart() {},
async onEnd() {
resolve({
completion: fullResponse,
})
},
async onError(resp) {
if (resp instanceof Error) {
reject(resp)
return
}
const error = await resp.json().catch(() => ({}))
reject(
new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`),
)
},
})
return returnPromise
}
/**
* Delete the conversation
* @async
* @returns Promise<Response>
*/
async delete() {
return await this.request(`/api/chat/chat_conversations/${this.conversationId}`, {
headers: {
accept: '*/*',
Authorization: `Bearer ${this.moonshot.accessToken}`,
Origin: 'https://kimi.moonshot.cn',
},
method: 'DELETE',
}).catch(errorHandle('Delete conversation ' + this.conversationId))
}
/**
* Get all messages in the conversation
* @async
* @returns {Promise<Message[]>}
*/
getMessages() {
return this.getInfo()
.then((a) => a.chat_messages)
.catch(errorHandle('getMessages'))
}
}
/**
* 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)
}
}
/**
* @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 moonshot instance)
* @property {JSONResponse} json The JSON representation
* @property {moonshot} moonshot The moonshot 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 {moonshot} params.moonshot - moonshot instance
* @param {Message} message - Message data
*/
constructor(
{ conversation, moonshot },
{ uuid, text, sender, index, updated_at, edited_at, chat_feedback, attachments },
) {
if (!moonshot) {
throw new Error('moonshot not initialized')
}
if (!conversation) {
throw new Error('Conversation not initialized')
}
Object.assign(this, { conversation, moonshot })
this.request = moonshot.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'
}
}
/**
* @param {Runtime.Port} port
* @param {string} question
* @param {Session} session
* @param {UserConfig} config
*/
export async function generateAnswersWithMoonshotWebApi(port, question, session, config) {
const bot = new MoonshotWeb({ config })
await bot.init()
const { controller, cleanController } = setAbortController(port)
const model = getModelValue(session)
let answer = ''
const progressFunc = ({ completion }) => {
answer = completion
port.postMessage({ answer: answer, done: false, session: null })
}
const doneFunc = () => {
pushRecord(session, question, answer)
console.debug('conversation history', { content: session.conversationRecords })
port.postMessage({ answer: answer, done: true, session: session })
}
const params = {
progress: progressFunc,
done: doneFunc,
model,
signal: controller.signal,
}
if (!session.moonshot_conversation)
await bot
.startConversation(question, params)
.then((conversation) => {
conversation.request = null
conversation.moonshot = null
session.moonshot_conversation = conversation
port.postMessage({ answer: answer, done: true, session: session })
cleanController()
})
.catch((err) => {
cleanController()
throw err
})
else
await bot
.sendMessage(question, {
conversation: session.moonshot_conversation,
...params,
})
.then(cleanController)
.catch((err) => {
cleanController()
throw err
})
}