diff --git a/package.json b/package.json index 8546dfd8..f0559c66 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@ainft-team/ainft-js", "main": "dist/ainft.js", "types": "dist/ainft.d.ts", - "version": "2.1.2", + "version": "2.1.3", "engines": { "node": ">=16" }, diff --git a/src/ai/assistant.ts b/src/ai/assistant.ts index c0b7df33..79302ab4 100644 --- a/src/ai/assistant.ts +++ b/src/ai/assistant.ts @@ -1,11 +1,10 @@ -import _ from 'lodash'; +import _ from "lodash"; -import FactoryBase from '../factoryBase'; -import AinftObject from '../ainft721Object'; -import { OperationType, getServiceName, request } from '../utils/ainize'; +import FactoryBase from "../factoryBase"; +import AinftObject from "../ainft721Object"; +import { OperationType, getServiceName, request } from "../utils/ainize"; import { Assistant, - AssistantCreateOptions, AssistantCreateParams, AssistantDeleteTransactionResult, AssistantDeleted, @@ -14,16 +13,16 @@ import { NftToken, NftTokens, QueryParamsWithoutSort, -} from '../types'; +} from "../types"; import { MESSAGE_GC_MAX_SIBLINGS, MESSAGE_GC_NUM_SIBLINGS_DELETED, THREAD_GC_MAX_SIBLINGS, THREAD_GC_NUM_SIBLINGS_DELETED, WHITELISTED_OBJECT_IDS, -} from '../constants'; -import { getEnv } from '../utils/env'; -import { Path } from '../utils/path'; +} from "../constants"; +import { getEnv } from "../utils/env"; +import { Path } from "../utils/path"; import { buildSetValueOp, buildSetWriteRuleOp, @@ -31,8 +30,8 @@ import { buildSetOp, buildSetTxBody, sendTx, -} from '../utils/transaction'; -import { getChecksumAddress, getAssistant, getToken } from '../utils/util'; +} from "../utils/transaction"; +import { getChecksumAddress, getAssistant, getToken, arrayToObject } from "../utils/util"; import { isObjectOwner, validateAssistant, @@ -40,13 +39,13 @@ import { validateObject, validateServerConfigurationForObject, validateToken, -} from '../utils/validator'; -import { authenticated } from '../utils/decorator'; -import { AinftError } from '../error'; +} from "../utils/validator"; +import { authenticated } from "../utils/decorator"; +import { AinftError } from "../error"; enum Role { - OWNER = 'owner', - USER = 'user', + OWNER = "owner", + USER = "user", } /** @@ -56,75 +55,68 @@ enum Role { export class Assistants extends FactoryBase { /** * Create an assistant with a model and instructions. - * @param {string} objectId - The ID of AINFT object. - * @param {string} tokenId - The ID of AINFT token. + * @param {string} objectId - The ID of the AINFT object. + * @param {string} tokenId - The ID of the AINFT token. * @param {AssistantCreateParams} AssistantCreateParams - The parameters to create assistant. - * @param {AssistantCreateOptions} AssistantCreateOptions - The creation options. * @returns A promise that resolves with both the transaction result and the created assistant. */ @authenticated async create( objectId: string, tokenId: string, - { model, name, instructions, description, metadata }: AssistantCreateParams, - options: AssistantCreateOptions = {} + params: AssistantCreateParams ): Promise { const address = await this.ain.signer.getAddress(); - const appId = AinftObject.getAppId(objectId); - const token = await getToken(this.ain, appId, tokenId); - // TODO(jiyoung): limit character count for 'instruction' and 'description'. + // TODO(jiyoung): check character count for input params. await validateObject(this.ain, objectId); await validateToken(this.ain, objectId, tokenId); await validateDuplicateAssistant(this.ain, objectId, tokenId); + // TODO(jiyoung): fix it. // NOTE(jiyoung): creation is limited to owners, except for whitelisted objects. const role = (await isObjectOwner(this.ain, objectId, address)) ? Role.OWNER : Role.USER; - const whitelisted = WHITELISTED_OBJECT_IDS[getEnv()].includes(objectId); - if (!whitelisted && role !== Role.OWNER) { - throw new AinftError('permission-denied', `cannot create assistant for ${objectId}`); + const isWhitelisted = WHITELISTED_OBJECT_IDS[getEnv()].includes(objectId); + if (!isWhitelisted && role !== Role.OWNER) { + throw new AinftError("permission-denied", `cannot create assistant for ${objectId}`); } + const token = await getToken(this.ain, objectId, tokenId); const serviceName = getServiceName(); await validateServerConfigurationForObject(this.ain, objectId, serviceName); const opType = OperationType.CREATE_ASSISTANT; - const body = { - role, - objectId, - tokenId, - model, - name, - instructions, - ...(description && { description }), - ...(metadata && !_.isEmpty(metadata) && { metadata }), - ...(options && !_.isEmpty(options) && { options }), - }; + const body = this.buildReqBodyForCreateAssistant(objectId, tokenId, role, params); - const { data } = await request(this.ainize!, { + const { data } = await request(this.ainize!, { serviceName, opType, data: body, }); - const assistant = { + const assistant: Assistant = { id: data.id, - objectId: objectId, - tokenId: tokenId, - owner: token.owner, + createdAt: data.createdAt, + objectId: data.objectId, + tokenId: data.tokenId, + tokenOwner: token.owner, model: data.model, name: data.name, - instructions: data.instructions, description: data.description, - metadata: data.metadata, - created_at: data.created_at, - metric: { - numThreads: 0, + instructions: data.instructions, + metadata: { + author: data.metadata?.author || null, + bio: data.metadata?.bio || null, + chatStarter: data.metadata?.chatStarter ? Object.values(data.metadata?.chatStarter) : null, + greetingMessage: data.metadata?.greetingMessage || null, + image: data.metadata?.image || null, + tags: data.metadata?.tags ? Object.values(data.metadata?.tags) : null, }, + metrics: data.metrics || {}, }; if (role === Role.OWNER) { - const txBody = this.buildTxBodyForCreateAssistant(address, objectId, tokenId, data); + const txBody = this.buildTxBodyForCreateAssistant(data, address); const result = await sendTx(txBody, this.ain); return { ...result, assistant }; } else { @@ -134,9 +126,9 @@ export class Assistants extends FactoryBase { /** * Updates an assistant. - * @param {string} objectId - The ID of AINFT object. - * @param {string} tokenId - The ID of AINFT token. - * @param {string} assistantId - The ID of assistant. + * @param {string} objectId - The ID of the AINFT object. + * @param {string} tokenId - The ID of the AINFT token. + * @param {string} assistantId - The ID of the assistant. * @param {AssistantUpdateParams} AssistantUpdateParams - The parameters to update assistant. * @returns A promise that resolves with both the transaction result and the updated assistant. */ @@ -145,7 +137,7 @@ export class Assistants extends FactoryBase { objectId: string, tokenId: string, assistantId: string, - { model, name, instructions, description, metadata }: AssistantUpdateParams + params: AssistantUpdateParams ): Promise { const address = await this.ain.signer.getAddress(); @@ -158,37 +150,48 @@ export class Assistants extends FactoryBase { const role = (await isObjectOwner(this.ain, objectId, address)) ? Role.OWNER : Role.USER; const whitelisted = WHITELISTED_OBJECT_IDS[getEnv()].includes(objectId); if (!whitelisted && role !== Role.OWNER) { - throw new AinftError('permission-denied', `cannot update assistant for ${objectId}`); + throw new AinftError("permission-denied", `cannot update assistant for ${objectId}`); } + const token = await getToken(this.ain, objectId, tokenId); const serviceName = getServiceName(); await validateServerConfigurationForObject(this.ain, objectId, serviceName); const opType = OperationType.MODIFY_ASSISTANT; - const body = { - role, - objectId, - tokenId, - assistantId, - ...(model && { model }), - ...(name && { name }), - ...(instructions && { instructions }), - ...(description && { description }), - ...(metadata && !_.isEmpty(metadata) && { metadata }), - }; - - const { data } = await request(this.ainize!, { + const body = this.buildReqBodyForUpdateAssistant(objectId, tokenId, assistantId, role, params); + const { data } = await request(this.ainize!, { serviceName, opType, data: body, }); + const assistant: Assistant = { + id: data.id, + createdAt: data.createdAt, + objectId: data.objectId, + tokenId: data.tokenId, + tokenOwner: token.owner, + model: data.model, + name: data.name, + description: data.description, + instructions: data.instructions, + metadata: { + author: data.metadata?.author || null, + bio: data.metadata?.bio || null, + chatStarter: data.metadata?.chatStarter ? Object.values(data.metadata?.chatStarter) : null, + greetingMessage: data.metadata?.greetingMessage || null, + image: data.metadata?.image || null, + tags: data.metadata?.tags ? Object.values(data.metadata?.tags) : null, + }, + metrics: data.metrics || {}, + }; + if (role === Role.OWNER) { const txBody = this.buildTxBodyForUpdateAssistant(address, objectId, tokenId, data); const result = await sendTx(txBody, this.ain); - return { ...result, assistant: data }; + return { ...result, assistant }; } else { - return { assistant: data }; + return { assistant }; } } @@ -215,7 +218,7 @@ export class Assistants extends FactoryBase { const role = (await isObjectOwner(this.ain, objectId, address)) ? Role.OWNER : Role.USER; const whitelisted = WHITELISTED_OBJECT_IDS[getEnv()].includes(objectId); if (!whitelisted && role !== Role.OWNER) { - throw new AinftError('permission-denied', `cannot delete assistant for ${objectId}`); + throw new AinftError("permission-denied", `cannot delete assistant for ${objectId}`); } const serviceName = getServiceName(); @@ -246,28 +249,7 @@ export class Assistants extends FactoryBase { * @returns A promise that resolves with the assistant. */ async get(objectId: string, tokenId: string, assistantId: string): Promise { - const appId = AinftObject.getAppId(objectId); - - await validateObject(this.ain, objectId); - await validateToken(this.ain, objectId, tokenId); - - const assistant = await getAssistant(this.ain, appId, tokenId); - const token = await getToken(this.ain, appId, tokenId); - - const data = { - id: assistant.id, - objectId, - tokenId, - owner: token.owner, - model: assistant.config.model, - name: assistant.config.name, - instructions: assistant.config.instructions, - description: assistant.config.description || null, - metadata: assistant.config.metadata || {}, - created_at: assistant.createdAt, - }; - - return data; + return getAssistant(this.ain, objectId, tokenId, assistantId); } /** @@ -280,14 +262,13 @@ export class Assistants extends FactoryBase { async list( objectIds: string[], address?: string | null, - { limit = 20, offset = 0, order = 'desc' }: QueryParamsWithoutSort = {} + { limit = 20, offset = 0, order = "desc" }: QueryParamsWithoutSort = {} ) { await Promise.all(objectIds.map((objectId) => validateObject(this.ain, objectId))); const allAssistants = await Promise.all( objectIds.map(async (objectId) => { - const tokens = await this.getTokens(objectId, address); - return this.getAssistantsFromTokens(tokens); + return this.getAssistants(objectId, address); }) ); const assistants = allAssistants.flat(); @@ -306,7 +287,7 @@ export class Assistants extends FactoryBase { const whitelisted = WHITELISTED_OBJECT_IDS[getEnv()].includes(objectId); if (!whitelisted) { throw new AinftError( - 'forbidden', + "forbidden", `cannot mint token for ${objectId}. please use the Ainft721Object.mint() function instead.` ); } @@ -324,30 +305,74 @@ export class Assistants extends FactoryBase { return data; } - private buildTxBodyForCreateAssistant( - address: string, + // NOTE(jiyoung): transform metadata by recursively converting arrays to objects + // and replacing empty arrays or objects with null. + private transformMetadata(metadata: any) { + if (Array.isArray(metadata)) { + // 1-1. empty array + if (metadata.length === 0) { + return null; + } + // 1-2. array to object + const result: { [key: string]: any } = {}; + metadata.forEach((v, i) => { + result[`${i}`] = this.transformMetadata(v); + }); + return result; + } else if (typeof metadata === "object" && metadata !== null) { + // 2-1. empty object + if (Object.keys(metadata).length === 0) { + return null; + } + // 2-2. nested object + const result: { [key: string]: any } = {}; + for (const key in metadata) { + result[key] = this.transformMetadata(metadata[key]); + } + return result; + } + return metadata; + } + + private buildReqBodyForCreateAssistant( objectId: string, tokenId: string, - { id, model, name, instructions, description, metadata, created_at }: Assistant + role: Role, + params: AssistantCreateParams ) { - const appId = AinftObject.getAppId(objectId); - const assistantPath = Path.app(appId).token(tokenId).ai().value(); - const historyPath = `/apps/${appId}/tokens/${tokenId}/ai/history/$user_addr`; + return { + role, + objectId, + tokenId, + model: params.model, + name: params.name, + description: params.description || null, + instructions: params.instructions || null, + metadata: this.transformMetadata(params.metadata), + options: { + autoImage: params.autoImage || false, + }, + }; + } + + private buildTxBodyForCreateAssistant(assistant: Assistant, address: string) { + const appId = AinftObject.getAppId(assistant.objectId); + const assistantPath = Path.app(appId).token(assistant.tokenId).ai().value(); + const historyPath = `/apps/${appId}/tokens/${assistant.tokenId}/ai/history/$user_addr`; const threadPath = `${historyPath}/threads/$thread_id`; const messagePath = `${threadPath}/messages/$message_id`; - const config = { - model, - name, - instructions, - ...(description && { description }), - ...(metadata && !_.isEmpty(metadata) && { metadata }), - }; - const info = { - id, - type: 'chat', - config, - createdAt: created_at, + const value = { + id: assistant.id, + createdAt: assistant.createdAt, + config: { + model: assistant.model, + name: assistant.name, + ...(assistant.instructions && { instructions: assistant.instructions }), + ...(assistant.description && { description: assistant.description }), + ...(assistant.metadata && + !_.isEmpty(assistant.metadata) && { metadata: assistant.metadata }), + }, history: true, }; @@ -365,7 +390,7 @@ export class Assistants extends FactoryBase { }, }; - const setAssistantInfoOp = buildSetValueOp(assistantPath, info); + const setAssistantInfoOp = buildSetValueOp(assistantPath, value); const setHistoryWriteRuleOp = buildSetWriteRuleOp(historyPath, rules.write); const setThreadGCRuleOp = buildSetStateRuleOp(threadPath, rules.state.thread); const setMessageGCRuleOp = buildSetStateRuleOp(messagePath, rules.state.message); @@ -381,6 +406,26 @@ export class Assistants extends FactoryBase { ); } + private buildReqBodyForUpdateAssistant( + objectId: string, + tokenId: string, + assistantId: string, + role: Role, + params: AssistantUpdateParams + ) { + return { + role, + objectId, + tokenId, + assistantId, + ...(params.model && { model: params.model }), + ...(params.name && { name: params.name }), + ...(params.description && { description: params.description }), + ...(params.instructions && { instructions: params.instructions }), + metadata: this.transformMetadata(params.metadata), + }; + } + private buildTxBodyForUpdateAssistant( address: string, objectId: string, @@ -424,59 +469,32 @@ export class Assistants extends FactoryBase { ); } - private async getTokens(objectId: string, address?: string | null) { + private async getAssistants(objectId: string, address?: string | null) { const appId = AinftObject.getAppId(objectId); const tokensPath = Path.app(appId).tokens().value(); - const tokens: NftTokens = (await this.ain.db.ref(tokensPath).getValue()) || {}; - return Object.entries(tokens).reduce((acc, [id, token]) => { - if (!address || token.owner === address) { - acc.push({ objectId, tokenId: id, ...token }); - } - return acc; - }, []); - } - - private getAssistantsFromTokens(tokens: NftToken[]) { - return tokens.reduce((acc, token) => { - if (token.ai) { - acc.push(this.toAssistant(token, this.countThreads(token.ai.history))); - } - return acc; - }, []); - } + const tokens: NftTokens = (await this.ain.db.ref().getValue(tokensPath)) || {}; + + const assistants = await Promise.all( + Object.entries(tokens).map(async ([tokenId, token]) => { + if (token.ai) { + const assistantId = token.ai.id; + return await getAssistant(this.ain, objectId, tokenId, assistantId); + } + return null; + }) + ); - private toAssistant(data: any, numThreads: number): Assistant { - return { - id: data.ai.id, - objectId: data.objectId, - tokenId: data.tokenId, - owner: data.owner, - model: data.ai.config.model, - name: data.ai.config.name, - instructions: data.ai.config.instructions, - description: data.ai.config.description || null, - metadata: data.ai.config.metadata || {}, - created_at: data.ai.createdAt, - metric: { numThreads }, - }; + return assistants.filter( + (assistant): assistant is Assistant => + assistant !== null && (!address || assistant.tokenOwner === address) + ); } - private sortAssistants(assistants: Assistant[], order: 'asc' | 'desc') { + private sortAssistants(assistants: Assistant[], order: "asc" | "desc") { return assistants.sort((a, b) => { - if (a.created_at < b.created_at) return order === 'asc' ? -1 : 1; - if (a.created_at > b.created_at) return order === 'asc' ? 1 : -1; + if (a.createdAt < b.createdAt) return order === "asc" ? -1 : 1; + if (a.createdAt > b.createdAt) return order === "asc" ? 1 : -1; return 0; }); } - - private countThreads(items: any) { - if (typeof items !== 'object' || !items) { - return 0; - } - return Object.values(items).reduce((sum: number, item: any) => { - const count = - item.threads && typeof item.threads === 'object' ? Object.keys(item.threads).length : 0; - return sum + count; - }, 0); - } } diff --git a/src/ai/message.ts b/src/ai/message.ts index 0a1c858e..f17643f8 100644 --- a/src/ai/message.ts +++ b/src/ai/message.ts @@ -50,13 +50,13 @@ export class Messages extends FactoryBase { await validateObject(this.ain, objectId); await validateToken(this.ain, objectId, tokenId); - await validateAssistant(this.ain, objectId, tokenId); + const _assistant = await validateAssistant(this.ain, objectId, tokenId); await validateThread(this.ain, objectId, tokenId, address, threadId); const serviceName = getServiceName(); await validateServerConfigurationForObject(this.ain, objectId, serviceName); - const assistant = await getAssistant(this.ain, appId, tokenId); + const assistant = await getAssistant(this.ain, objectId, tokenId, _assistant.id); const newMessages = await this.sendMessage(serviceName, threadId, objectId, tokenId, assistant.id, address, body); const allMessages = await this.getAllMessages(appId, tokenId, address, threadId, newMessages); diff --git a/src/ai/thread.ts b/src/ai/thread.ts index 8844e174..5dc79fdd 100644 --- a/src/ai/thread.ts +++ b/src/ai/thread.ts @@ -15,7 +15,7 @@ import { } from '../types'; import { Path } from '../utils/path'; import { buildSetValueOp, buildSetTxBody, sendTx } from '../utils/transaction'; -import { getAssistant, getValue } from '../utils/util'; +import { getAssistant, getValue, normalizeTimestamp } from '../utils/util'; import { validateAssistant, validateObject, @@ -44,14 +44,13 @@ export class Threads extends FactoryBase { tokenId: string, { metadata }: ThreadCreateParams ): Promise { - const appId = AinftObject.getAppId(objectId); const address = await this.ain.signer.getAddress(); await validateObject(this.ain, objectId); - await validateToken(this.ain, objectId, tokenId); - await validateAssistant(this.ain, objectId, tokenId); - const assistant = await getAssistant(this.ain, appId, tokenId); + const token = await validateToken(this.ain, objectId, tokenId); + const _assistant = await validateAssistant(this.ain, objectId, tokenId); + const assistant = await getAssistant(this.ain, objectId, tokenId, _assistant.id); const serviceName = getServiceName(); await validateServerConfigurationForObject(this.ain, objectId, serviceName); @@ -59,15 +58,7 @@ export class Threads extends FactoryBase { const body = { objectId, tokenId, - assistant: { - id: assistant?.id, - model: assistant?.config?.model, - name: assistant?.config?.name, - instructions: assistant?.config?.instructions, - description: assistant?.config?.description || null, - metadata: assistant?.config?.metadata || null, - createdAt: assistant?.createdAt, - }, + assistantId: assistant.id, address, ...(metadata && !_.isEmpty(metadata) && { metadata }), }; @@ -78,10 +69,39 @@ export class Threads extends FactoryBase { data: body, }); + const thread = { + id: data.id, + metadata: data.metadata, + createdAt: data.createdAt, + updatedAt: data.createdAt, + assistant: { + id: assistant.id, + createdAt: assistant.createdAt, + objectId: objectId, + tokenId: tokenId, + tokenOwner: token.owner, + model: assistant.model, + name: assistant.name, + description: assistant.description || null, + instructions: assistant.instructions || null, + metadata: { + author: assistant.metadata?.author || null, + bio: assistant.metadata?.bio || null, + chatStarter: assistant.metadata?.chatStarter + ? Object.values(assistant.metadata?.chatStarter) + : null, + greetingMessage: assistant.metadata?.greetingMessage || null, + image: assistant.metadata?.image || null, + tags: assistant.metadata?.tags ? Object.values(assistant.metadata?.tags) : null, + }, + metrics: assistant.metrics || {}, + }, + }; + const txBody = this.buildTxBodyForCreateThread(address, objectId, tokenId, data); const result = await sendTx(txBody, this.ain); - return { ...result, thread: data, tokenId }; + return { ...result, thread, tokenId }; } /** @@ -186,14 +206,13 @@ export class Threads extends FactoryBase { .history(address) .thread(threadId) .value(); - const data = await this.ain.db.ref(threadPath).getValue(); - const thread = { - id: data.id, - metadata: data.metadata || {}, - created_at: data.createdAt, - }; + const thread = await this.ain.db.ref(threadPath).getValue(); - return thread; + return { + id: thread.id, + createdAt: normalizeTimestamp(thread.createdAt), + metadata: thread.metadata || {}, + }; } /** @@ -219,7 +238,7 @@ export class Threads extends FactoryBase { const allThreads = await Promise.all( objectIds.map(async (objectId) => { const tokens = await this.fetchTokens(objectId); - return this.flattenThreads(objectId, tokens); + return await this.flattenThreads(objectId, tokens); }) ); const threads = allThreads.flat(); @@ -237,13 +256,13 @@ export class Threads extends FactoryBase { address: string, objectId: string, tokenId: string, - { id, metadata, created_at }: Thread + { id, metadata, createdAt }: Thread ) { const appId = AinftObject.getAppId(objectId); const threadPath = Path.app(appId).token(tokenId).ai().history(address).thread(id).value(); const value = { id, - createdAt: created_at, + createdAt, messages: true, ...(metadata && !_.isEmpty(metadata) && { metadata }), }; @@ -288,47 +307,52 @@ export class Threads extends FactoryBase { return this.ain.db.ref(tokensPath).getValue(); } - private flattenThreads(objectId: string, tokens: any) { + private async flattenThreads(objectId: string, tokens: any) { const flatten: any = []; - _.forEach(tokens, (token, tokenId) => { - const assistant = token.ai; - if (!assistant) { - return; - } - const histories = assistant.history; - if (typeof histories !== 'object' || histories === true) { - return; - } - _.forEach(histories, (history, address) => { - const threads = _.get(history, 'threads'); - _.forEach(threads, (thread) => { - let updatedAt = thread.createdAt; - if (typeof thread.messages === 'object' && thread.messages !== null) { - const keys = Object.keys(thread.messages); - updatedAt = Number(keys[keys.length - 1]); - } - flatten.push({ - id: thread.id, - metadata: thread.metadata || {}, - created_at: thread.createdAt, - updated_at: updatedAt, - assistant: { - id: assistant.id, - objectId, - tokenId, - owner: token.owner, - model: assistant.config.model, - name: assistant.config.name, - instructions: assistant.config.instructions, - description: assistant.config.description || null, - metadata: assistant.config.metadata || {}, - created_at: assistant.createdAt, - }, - author: { address }, + await Promise.all( + _.map(tokens, async (token, tokenId) => { + if (!token.ai) { + return; + } + const assistantId = token.ai.id; + const assistant = await getAssistant(this.ain, objectId, tokenId, assistantId); + const histories = token.ai.history; + if (typeof histories !== 'object' || histories === true) { + return; + } + _.forEach(histories, (history, address) => { + const threads = _.get(history, 'threads'); + _.forEach(threads, (thread) => { + const createdAt = normalizeTimestamp(thread.createdAt); + let updatedAt = createdAt; + if (typeof thread.messages === 'object' && thread.messages !== null) { + const keys = Object.keys(thread.messages); + updatedAt = Number(keys[keys.length - 1]); + } + flatten.push({ + id: thread.id, + metadata: thread.metadata || {}, + createdAt: createdAt, + updatedAt, + assistant: { + id: assistant.id, + createdAt: assistant.createdAt, + objectId, + tokenId, + tokenOwner: token.owner, + model: assistant.model, + name: assistant.name, + description: assistant.description || null, + instructions: assistant.instructions || null, + metadata: assistant.metadata || {}, + metrics: assistant.metrics || {}, + }, + author: { address }, + }); }); }); - }); - }); + }) + ); return flatten; } @@ -344,9 +368,9 @@ export class Threads extends FactoryBase { private sortThreads(threads: any, sort: string, order: 'asc' | 'desc') { if (sort === 'created') { - return _.orderBy(threads, ['created_at'], [order]); + return _.orderBy(threads, ['createdAt'], [order]); } else if (sort === 'updated') { - return _.orderBy(threads, ['updated_at'], [order]); + return _.orderBy(threads, ['updatedAt'], [order]); } else { throw new AinftError('bad-request', `invalid sort criteria: ${sort}`); } diff --git a/src/constants.ts b/src/constants.ts index d5c736ad..64cd0ca1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,6 +10,11 @@ export const AIN_BLOCKCHAIN_ENDPOINT = { prod: 'https://mainnet-api.ainetwork.ai', }; +export const AGENT_API_ENDPOINT = { + dev: 'https://aina-backend-dev.ainetwork.xyz', + prod: 'https://aina-backend.ainetwork.xyz', +}; + export const AIN_BLOCKCHAIN_CHAIN_ID = { dev: 0, prod: 1 } as const; export const MIN_GAS_PRICE = 500; @@ -28,6 +33,6 @@ export const MESSAGE_GC_NUM_SIBLINGS_DELETED = 10; export const DEFAULT_AINIZE_SERVICE_NAME = 'aina_backend'; export const WHITELISTED_OBJECT_IDS: Record = { - dev: ['0xCE3c4D8dA38c77dEC4ca99cD26B1C4BF116FC401'], + dev: ['0xA1425e477cF3e9413681d1508cF154C50f337675'], prod: ['0x6C8bB2aCBab0D807D74eB04034aA9Fd8c8E9C365'], }; diff --git a/src/types.ts b/src/types.ts index 07a12f36..878b792a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1253,29 +1253,72 @@ export interface AiConfiguration { export interface Assistant { /** The identifier. */ id: string; - /** The ID of AINFT object. */ - objectId: string | null; - /** The ID of AINFT token. */ - tokenId: string | null; - /** The owner address of AINFT token. */ - owner: string | null; + /** The Unix timestamp in seconds for when the assistant was created. */ + createdAt: number; + /** The ID of the AINFT object. */ + objectId: string; + /** The ID of the AINFT token. */ + tokenId: string; + /** The owner address of the AINFT token. */ + tokenOwner: string; /** The name of the model to use. */ model: string; /** The name of the assistant. */ name: string; - /** The system instructions that the assistant uses. The maximum length is 32768 characters. */ - instructions: string; /** The description of the assistant. The maximum length is 512 characters. */ description: string | null; - /** - * The metadata can contain up to 16 pairs, - * with keys limited to 64 characters and values to 512 characters. - */ - metadata: object | null; - /** The metric of the assistant. */ - metric?: { [key: string]: number } | null; - /** The UNIX timestamp in seconds. */ - created_at: number; + /** The system instructions that the assistant uses. The maximum length is 10000 characters. */ + instructions: string | null; + /** Any metadata related to the assistant. */ + metadata: AssistantMetadata | null; + /** The metrics of the assistant. */ + metrics: AssistantMetrics | null; +} + +export interface AssistantAuthor { + /** The address of the author. */ + address?: string; + /** The username of the author. */ + username?: string; + /** The image of the author. */ + picture?: string; +} + +export interface AssistantMetadata extends Metadata { + /** The author of the assistant. */ + author?: AssistantAuthor | null; + /** The bio of the assistant. */ + bio?: string | null; + /** The chat starter of the assistant. */ + chatStarter?: string[] | null; + /** The greeting message of the assistant. */ + greetingMessage?: string | null; + /** The image of the assistant. */ + image?: string | null; + /** The tags of the assistant. */ + tags?: string[] | null; +} + +export interface Money { + amount: number; + unit: 'USD' | 'AIN'; +} + +export interface AssistantMetrics { + /** The number of API calls. */ + numCalls?: number; + /** The number of conversations. */ + numThreads?: number; + /** The number of users. */ + numUsers?: number; + /** The total credits used by users. */ + totalUsedCredits?: Money; + /** The total revenue of the assistant. */ + totalRevenue?: Money; + /** The ad revenue of the assistant. */ + adRevenue?: Money; + /** The platform revenue of the assistant. */ + platformRevenue?: Money; } export interface AssistantDeleted { @@ -1288,38 +1331,29 @@ export interface AssistantDeleted { export interface AssistantCreateParams { /** The name of the model to use. */ model: Model; - /** The name of the assistant. The maximum length is 256 characters. */ + /** The name of the assistant. */ name: string; - /** The system instructions that the assistant uses. The maximum length is 32768 characters. */ - instructions: string; /** The description of the assistant. The maximum length is 512 characters. */ description?: string | null; - /** - * The metadata can contain up to 16 pairs, - * with keys limited to 64 characters and values to 512 characters. - */ - metadata?: object | null; -} - -export interface AssistantCreateOptions { + /** The system instructions that the assistant uses. The maximum length is 10000 characters. */ + instructions?: string | null; + /** Any metadata related to the assistant. */ + metadata?: AssistantMetadata | null; /** If true, automatically set the profile image for the assistant. */ - image?: boolean; + autoImage?: boolean; } export interface AssistantUpdateParams { /** The name of the model to use. */ model?: Model; - /** The name of the assistant. The maximum length is 256 characters. */ + /** The name of the assistant. */ name?: string | null; - /** The system instructions that the assistant uses. The maximum length is 32768 characters. */ - instructions?: string | null; /** The description of the assistant. The maximum length is 512 characters. */ description?: string | null; - /** - * The metadata can contain up to 16 pairs, - * with keys limited to 64 characters and values to 512 characters. - */ - metadata?: object | null; + /** The system instructions that the assistant uses. The maximum length is 10000 characters. */ + instructions?: string | null; + /** Any metadata related to the assistant. */ + metadata?: AssistantMetadata | null; } export interface Thread { @@ -1331,7 +1365,7 @@ export interface Thread { */ metadata: object | {}; /** The UNIX timestamp in seconds. */ - created_at: number; + createdAt: number; } export interface ThreadDeleted { diff --git a/src/utils/util.ts b/src/utils/util.ts index 15055e8a..c51a5178 100644 --- a/src/utils/util.ts +++ b/src/utils/util.ts @@ -1,17 +1,19 @@ -import stringify = require('fast-json-stable-stringify'); -import Ain from '@ainblockchain/ain-js'; +import stringify = require("fast-json-stable-stringify"); +import Ain from "@ainblockchain/ain-js"; import { SetOperation, SetMultiOperation, TransactionInput, GetOptions, -} from '@ainblockchain/ain-js/lib/types'; -import * as ainUtil from '@ainblockchain/ain-util'; +} from "@ainblockchain/ain-js/lib/types"; +import * as ainUtil from "@ainblockchain/ain-util"; -import { MIN_GAS_PRICE } from '../constants'; -import { HttpMethod } from '../types'; -import { Path } from './path'; -import { AinftError } from '../error'; +import { AGENT_API_ENDPOINT, MIN_GAS_PRICE } from "../constants"; +import { HttpMethod } from "../types"; +import { Path } from "./path"; +import { AinftError } from "../error"; +import axios from "axios"; +import { getEnv } from "./env"; export const buildData = ( method: HttpMethod, @@ -30,9 +32,9 @@ export const buildData = ( } if (method === HttpMethod.POST || method === HttpMethod.PUT) { - _data['body'] = stringify(data); + _data["body"] = stringify(data); } else { - _data['querystring'] = stringify(data); + _data["querystring"] = stringify(data); } return _data; @@ -45,29 +47,39 @@ export function sleep(ms: number) { } export function serializeEndpoint(endpoint: string) { - return endpoint.endsWith('/') ? endpoint.slice(0, -1) : endpoint; + return endpoint.endsWith("/") ? endpoint.slice(0, -1) : endpoint; } export const valueExists = async (ain: Ain, path: string): Promise => { return !!(await ain.db.ref(path).getValue()); }; -export const getAssistant = async (ain: Ain, appId: string, tokenId: string) => { - // TODO(jiyoung): fix circular reference with Ainft721Object.getAppId. - // const appId = AinftObject.getAppId(objectId); - const assistantPath = Path.app(appId).token(tokenId).ai().value(); - const assistant = await getValue(ain, assistantPath); +export const getAssistant = async ( + ain: Ain, + objectId: string, + tokenId: string, + assistantId: string +) => { + const token = await getToken(ain, objectId, tokenId); + // TODO(jiyoung): hide api endpoint. + const response = await axios.get(`${AGENT_API_ENDPOINT[getEnv()]}/agents/${assistantId}`); + const assistant = response.data?.data; if (!assistant) { - throw new AinftError('not-found', `assistant not found: ${appId}(${tokenId})`); + return null; } - return assistant; + return { + ...assistant, + createdAt: normalizeTimestamp(assistant.createdAt), + tokenOwner: token.owner, + }; }; -export const getToken = async (ain: Ain, appId: string, tokenId: string) => { +export const getToken = async (ain: Ain, objectId: string, tokenId: string) => { + const appId = `ainft721_${objectId.toLowerCase()}`; const tokenPath = Path.app(appId).token(tokenId).value(); const token = await getValue(ain, tokenPath); if (!token) { - throw new AinftError('not-found', `token not found: ${appId}(${tokenId})`); + throw new AinftError("not-found", `token ${tokenId} not found for ${objectId}`); } return token; }; @@ -83,3 +95,23 @@ export const getChecksumAddress = (address: string): string => { export const isJoiError = (error: any) => { return error.response?.data?.isJoiError === true; }; + +export const arrayToObject = (array: T[]): { [key: string]: T } => { + const result: { [key: string]: T } = {}; + array.forEach((v, i) => { + result[i.toString()] = v; + }); + return result; +}; + +export const normalizeTimestamp = (timestamp: number) => { + return isMillisecond(timestamp) ? toSecond(timestamp) : timestamp; +}; + +export const isMillisecond = (timestamp: number) => { + return timestamp.toString().length === 13; +}; + +export const toSecond = (millisecond: number) => { + return Math.floor(millisecond / 1000); +}; diff --git a/src/utils/validator.ts b/src/utils/validator.ts index 79519ffc..e5f0a024 100644 --- a/src/utils/validator.ts +++ b/src/utils/validator.ts @@ -1,9 +1,9 @@ -import Ain from '@ainblockchain/ain-js'; -import AinftObject from '../ainft721Object'; -import { MessageMap } from '../types'; -import { Path } from './path'; -import { valueExists, getValue } from './util'; -import { AinftError } from '../error'; +import Ain from "@ainblockchain/ain-js"; +import AinftObject from "../ainft721Object"; +import { MessageMap } from "../types"; +import { Path } from "./path"; +import { valueExists, getValue } from "./util"; +import { AinftError } from "../error"; export const isObjectOwner = async (ain: Ain, objectId: string, address: string) => { const appId = AinftObject.getAppId(objectId); @@ -17,7 +17,7 @@ export const validateObject = async (ain: Ain, objectId: string) => { const objectPath = Path.app(appId).value(); const object = await getValue(ain, objectPath, { is_shallow: true }); if (!object) { - throw new AinftError('not-found', `object not found: ${objectId}`); + throw new AinftError("not-found", `object not found: ${objectId}`); } }; @@ -30,7 +30,7 @@ export const validateServerConfigurationForObject = async ( const configPath = Path.app(appId).ai(serviceName).value(); if (!(await valueExists(ain, configPath))) { throw new AinftError( - 'precondition-failed', + "precondition-failed", `service configuration is missing for ${objectId}.` ); } @@ -38,23 +38,25 @@ export const validateServerConfigurationForObject = async ( export const validateObjectOwner = async (ain: Ain, objectId: string, address: string) => { if (!isObjectOwner(ain, objectId, address)) { - throw new AinftError('permission-denied', `${address} do not have owner permission.`); + throw new AinftError("permission-denied", `${address} do not have owner permission.`); } }; export const validateToken = async (ain: Ain, objectId: string, tokenId: string) => { const appId = AinftObject.getAppId(objectId); const tokenPath = Path.app(appId).token(tokenId).value(); - if (!(await valueExists(ain, tokenPath))) { - throw new AinftError('not-found', `token not found: ${objectId}(${tokenId})`); + const token = await getValue(ain, tokenPath, { is_shallow: true }); + if (!token) { + throw new AinftError("not-found", `token not found: ${objectId}(${tokenId})`); } + return token; }; export const validateDuplicateAssistant = async (ain: Ain, objectId: string, tokenId: string) => { const appId = AinftObject.getAppId(objectId); const assistantPath = Path.app(appId).token(tokenId).ai().value(); if (await valueExists(ain, assistantPath)) { - throw new AinftError('already-exists', 'assistant already exists.'); + throw new AinftError("already-exists", "assistant already exists."); } }; @@ -66,13 +68,14 @@ export const validateAssistant = async ( ) => { const appId = AinftObject.getAppId(objectId); const assistantPath = Path.app(appId).token(tokenId).ai().value(); - const assistant = await getValue(ain, assistantPath); + const assistant = await getValue(ain, assistantPath, { is_shallow: true }); if (!assistant) { - throw new AinftError('not-found', `assistant not found: ${assistantId}`); + throw new AinftError("not-found", `assistant not found: ${assistantId}`); } if (assistantId && assistantId !== assistant.id) { - throw new AinftError('bad-request', `invalid assistant id: ${assistantId} != ${assistant.id}`); + throw new AinftError("bad-request", `invalid assistant id: ${assistantId} != ${assistant.id}`); } + return assistant; }; export const validateThread = async ( @@ -85,7 +88,7 @@ export const validateThread = async ( const appId = AinftObject.getAppId(objectId); const threadPath = Path.app(appId).token(tokenId).ai().history(address).thread(threadId).value(); if (!(await valueExists(ain, threadPath))) { - throw new AinftError('not-found', `thread not found: ${threadId}`); + throw new AinftError("not-found", `thread not found: ${threadId}`); } }; @@ -112,5 +115,5 @@ export const validateMessage = async ( return; } } - throw new AinftError('not-found', `message not found: ${threadId}(${messageId})`); + throw new AinftError("not-found", `message not found: ${threadId}(${messageId})`); }; diff --git a/test/ai/assistant.test.ts b/test/ai/assistant.test.ts index 88c5806f..f22a0fc1 100644 --- a/test/ai/assistant.test.ts +++ b/test/ai/assistant.test.ts @@ -1,15 +1,15 @@ -import AinftJs from '../../src/ainft'; -import { address, assistantId, objectId, privateKey, tokenId } from '../test_data'; -import { ASSISTANT_REGEX, TX_HASH_REGEX } from '../constants'; +import AinftJs from "../../src/ainft"; +import { address, assistantId, objectId, privateKey, tokenId } from "../test_data"; +import { ASSISTANT_REGEX, TX_HASH_REGEX } from "../constants"; -describe.skip('assistant', () => { +describe.skip("assistant", () => { let ainft: AinftJs; beforeAll(async () => { ainft = new AinftJs({ privateKey, - baseUrl: 'https://ainft-api-dev.ainetwork.ai', - blockchainUrl: 'https://testnet-api.ainetwork.ai', + baseUrl: "https://ainft-api-dev.ainetwork.ai", + blockchainUrl: "https://testnet-api.ainetwork.ai", chainId: 0, }); await ainft.connect(); @@ -19,34 +19,50 @@ describe.skip('assistant', () => { await ainft.disconnect(); }); - it('should create assistant', async () => { + it("should create assistant", async () => { const result = await ainft.assistant.create(objectId, tokenId, { - model: 'gpt-4o-mini', - name: 'name', - instructions: 'instructions', - description: 'description', - metadata: { key1: 'value1' }, + model: "gpt-4o", + name: "name", + description: "description", + instructions: null, + metadata: { + author: { + address: "0xabc123", + username: "username", + picture: "https://example.com/image.png", + }, + bio: "bio", + chatStarter: ["chat_starter_1", "chat_starter_2"], + greetingMessage: "hello", + image: "https://example.com/image.png", + tags: ["tag1", "tag2"], + }, + autoImage: false, }); expect(result.tx_hash).toMatch(TX_HASH_REGEX); expect(result.result).toBeDefined(); expect(result.assistant.id).toMatch(ASSISTANT_REGEX); - expect(result.assistant.model).toBe('gpt-4o-mini'); - expect(result.assistant.name).toBe('name'); - expect(result.assistant.instructions).toBe('instructions'); - expect(result.assistant.description).toBe('description'); - expect(result.assistant.metadata).toEqual({ key1: 'value1' }); + expect(result.assistant.model).toBe("gpt-4o"); + expect(result.assistant.name).toBe("name"); + expect(result.assistant.description).toBe("description"); + expect(result.assistant.instructions).toBe(null); + expect(result.assistant.metadata).toEqual({ + author: { + address: "0xabc123", + username: "username", + picture: "https://example.com/image.png", + }, + bio: "bio", + chatStarter: ["chat_starter_1", "chat_starter_2"], + greetingMessage: "hello", + image: "https://example.com/image.png", + tags: ["tag1", "tag2"], + }); }); it('should get assistant', async () => { const assistant = await ainft.assistant.get(objectId, tokenId, assistantId); - - expect(assistant.id).toBe(assistantId); - expect(assistant.model).toBe('gpt-4o-mini'); - expect(assistant.name).toBe('name'); - expect(assistant.instructions).toBe('instructions'); - expect(assistant.description).toBe('description'); - expect(assistant.metadata).toEqual({ key1: 'value1' }); }); it('should list assistants', async () => { @@ -56,23 +72,15 @@ describe.skip('assistant', () => { expect(result.items).toBeDefined(); }); - it('should update assistant', async () => { + it("should update assistant", async () => { const result = await ainft.assistant.update(objectId, tokenId, assistantId, { - model: 'gpt-4', - name: 'new_name', - instructions: 'new_instructions', - description: 'new_description', - metadata: { key1: 'value1', key2: 'value2' }, + name: "new_name", }); expect(result.tx_hash).toMatch(TX_HASH_REGEX); expect(result.result).toBeDefined(); expect(result.assistant.id).toBe(assistantId); - expect(result.assistant.model).toBe('gpt-4'); - expect(result.assistant.name).toBe('new_name'); - expect(result.assistant.instructions).toBe('new_instructions'); - expect(result.assistant.description).toBe('new_description'); - expect(result.assistant.metadata).toEqual({ key1: 'value1', key2: 'value2' }); + expect(result.assistant.name).toBe("new_name"); }); it('should delete assistant', async () => {