diff --git a/admin/src/components/AiChat.vue b/admin/src/components/AiChat.vue index e167d685d..819c8ba93 100644 --- a/admin/src/components/AiChat.vue +++ b/admin/src/components/AiChat.vue @@ -15,7 +15,11 @@
-
+
{ onMounted(async () => { if (messages.value.length === 0) { loading.value = true - await resumeChatRefetch() + try { + await resumeChatRefetch() + } catch (error) { + if (error.graphQLErrors && error.graphQLErrors.length > 0) { + toastError(`Error loading chat: ${error.graphQLErrors[0].message}`) + return + } else { + // eslint-disable-next-line no-console + console.log(JSON.stringify(error, null, 2)) + toastError(`Error loading chat: ${error}`) + } + } const messagesFromServer = resumeChatResult.value.resumeChat if (messagesFromServer && messagesFromServer.length > 0) { threadId.value = messagesFromServer[0].threadId @@ -279,6 +294,17 @@ onMounted(async () => { margin-right: auto; } +.message.error { + text-align: center; +} + +.message.error .message-content { + background-color: #f1e5e5; + color: rgb(194, 12, 12); + margin-left: auto; + margin-right: auto; +} + .input-area { display: flex; padding: 10px; diff --git a/admin/src/graphql/fragments.graphql b/admin/src/graphql/fragments.graphql index 113e15606..c5cf21eba 100644 --- a/admin/src/graphql/fragments.graphql +++ b/admin/src/graphql/fragments.graphql @@ -26,6 +26,7 @@ fragment AiChatMessageFields on ChatGptMessage { content role threadId + isError } fragment UserCommonFields on User { diff --git a/backend/src/apis/openai/OpenaiClient.ts b/backend/src/apis/openai/OpenaiClient.ts index 35744a7dd..eb37a04f8 100644 --- a/backend/src/apis/openai/OpenaiClient.ts +++ b/backend/src/apis/openai/OpenaiClient.ts @@ -11,6 +11,8 @@ import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const' import { getLogger } from 'log4js' const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.apis.openai.OpenaiClient`) +// this is the time after when openai is deleting an inactive thread +const OPENAI_AI_THREAD_DEFAULT_TIMEOUT_DAYS = 60 /** * The `OpenaiClient` class is a singleton that provides an interface to interact with the OpenAI API. @@ -87,21 +89,35 @@ export class OpenaiClient { logger.warn(`No openai thread found for user: ${user.id}`) return [] } - const threadMessages = ( - await this.openai.beta.threads.messages.list(openaiThreadEntity.id, { order: 'desc' }) - ).getPaginatedItems() + if (openaiThreadEntity.updatedAt < new Date(Date.now() - OPENAI_AI_THREAD_DEFAULT_TIMEOUT_DAYS * 24 * 60 * 60 * 1000)) { + logger.info(`Openai thread for user: ${user.id} is older than ${OPENAI_AI_THREAD_DEFAULT_TIMEOUT_DAYS} days, deleting...`) + // let run async, because it could need some time, but we don't need to wait, because we create a new one nevertheless + void this.deleteThread(openaiThreadEntity.id) + return [] + } + try { + const threadMessages = ( + await this.openai.beta.threads.messages.list(openaiThreadEntity.id, { order: 'desc' }) + ).getPaginatedItems() - logger.info(`Resumed thread: ${openaiThreadEntity.id}`) - return threadMessages - .map( - (message) => - new MessageModel( - this.messageContentToString(message), - message.role, - openaiThreadEntity.id, - ), - ) - .reverse() + logger.info(`Resumed thread: ${openaiThreadEntity.id}`) + return threadMessages + .map( + (message) => + new MessageModel( + this.messageContentToString(message), + message.role, + openaiThreadEntity.id, + ), + ) + .reverse() + } catch (e) { + if(e instanceof Error && e.toString().includes('No thread found with id')) { + logger.info(`Thread not found: ${openaiThreadEntity.id}`) + return [] + } + throw e + } } public async deleteThread(threadId: string): Promise { @@ -124,6 +140,7 @@ export class OpenaiClient { } public async runAndGetLastNewMessage(threadId: string): Promise { + const updateOpenAiThreadResolver = OpenaiThreads.update({ id: threadId }, { updatedAt: new Date() }) const run = await this.openai.beta.threads.runs.createAndPoll(threadId, { assistant_id: CONFIG.OPENAI_ASSISTANT_ID, }) @@ -138,6 +155,7 @@ export class OpenaiClient { logger.warn(`No message in thread: ${threadId}, run: ${run.id}`, messagesPage.data) return new MessageModel('No Answer', 'assistant') } + await updateOpenAiThreadResolver return new MessageModel(this.messageContentToString(message), 'assistant') } diff --git a/backend/src/graphql/model/ChatGptMessage.ts b/backend/src/graphql/model/ChatGptMessage.ts index 3a5f08a59..3eaaeb7c7 100644 --- a/backend/src/graphql/model/ChatGptMessage.ts +++ b/backend/src/graphql/model/ChatGptMessage.ts @@ -10,10 +10,14 @@ export class ChatGptMessage { @Field() role: string - @Field() - threadId: string + @Field({ nullable: true }) + threadId?: string - public constructor(data: Partial) { + @Field() + isError: boolean + + public constructor(data: Partial, isError: boolean = false) { Object.assign(this, data) + this.isError = isError } } diff --git a/backend/src/graphql/resolver/AiChatResolver.ts b/backend/src/graphql/resolver/AiChatResolver.ts index 3fd01826f..c8f6bb4f9 100644 --- a/backend/src/graphql/resolver/AiChatResolver.ts +++ b/backend/src/graphql/resolver/AiChatResolver.ts @@ -15,10 +15,10 @@ export class AiChatResolver { async resumeChat(@Ctx() context: Context): Promise { const openaiClient = OpenaiClient.getInstance() if (!openaiClient) { - return Promise.resolve([new ChatGptMessage({ content: 'OpenAI API is not enabled' })]) + return Promise.resolve([new ChatGptMessage({ content: 'OpenAI API is not enabled', role: 'assistant' }, true)]) } if (!context.user) { - return Promise.resolve([new ChatGptMessage({ content: 'User not found' })]) + return Promise.resolve([new ChatGptMessage({ content: 'User not found', role: 'assistant' }, true)]) } const messages = await openaiClient.resumeThread(context.user) return messages.map((message) => new ChatGptMessage(message)) @@ -42,10 +42,10 @@ export class AiChatResolver { ): Promise { const openaiClient = OpenaiClient.getInstance() if (!openaiClient) { - return Promise.resolve(new ChatGptMessage({ content: 'OpenAI API is not enabled' })) + return Promise.resolve(new ChatGptMessage({ content: 'OpenAI API is not enabled', role: 'assistant' }, true)) } if (!context.user) { - return Promise.resolve(new ChatGptMessage({ content: 'User not found' })) + return Promise.resolve(new ChatGptMessage({ content: 'User not found', role: 'assistant' }, true)) } const messageObj = new Message(message) if (!threadId || threadId.length === 0) { diff --git a/database/migration/migrations/0094-openai_threads_add_updated_at.ts b/database/migration/migrations/0094-openai_threads_add_updated_at.ts new file mode 100644 index 000000000..8505bf21c --- /dev/null +++ b/database/migration/migrations/0094-openai_threads_add_updated_at.ts @@ -0,0 +1,10 @@ + +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn( + 'ALTER TABLE `openai_threads` ADD COLUMN `updatedAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP AFTER `createdAt`;' + ) +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn('ALTER TABLE `openai_threads` DROP COLUMN `updatedAt`;') +} diff --git a/database/src/entity/OpenaiThreads.ts b/database/src/entity/OpenaiThreads.ts index 38e4b6c33..7feda4762 100644 --- a/database/src/entity/OpenaiThreads.ts +++ b/database/src/entity/OpenaiThreads.ts @@ -1,4 +1,4 @@ -import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm' +import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm' @Entity('openai_threads') export class OpenaiThreads extends BaseEntity { @@ -8,6 +8,9 @@ export class OpenaiThreads extends BaseEntity { @CreateDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) createdAt: Date + @UpdateDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' }) + updatedAt: Date + @Column({ name: 'user_id', type: 'int', unsigned: true }) userId: number }