Merge pull request #3549 from gradido/frontend_openai_consider_thread_timeouts

fix(backend): check for openai thread timeout
This commit is contained in:
einhornimmond 2025-10-09 07:04:04 +02:00 committed by GitHub
commit 9dc35f8080
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 87 additions and 25 deletions

View File

@ -15,7 +15,11 @@
</div>
<div ref="chatContainer" class="messages-scroll-container">
<TransitionGroup class="messages" tag="div" name="chat">
<div v-for="(message, index) in messages" :key="index" :class="['message', message.role]">
<div
v-for="(message, index) in messages"
:key="index"
:class="['message', message.role, { 'message-error': message.isError }]"
>
<div class="message-content position-relative inner-container">
<span v-html="formatMessage(message)"></span>
<b-button
@ -170,7 +174,18 @@ const sendMessage = () => {
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;

View File

@ -26,6 +26,7 @@ fragment AiChatMessageFields on ChatGptMessage {
content
role
threadId
isError
}
fragment UserCommonFields on User {

View File

@ -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<boolean> {
@ -124,6 +140,7 @@ export class OpenaiClient {
}
public async runAndGetLastNewMessage(threadId: string): Promise<MessageModel> {
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')
}

View File

@ -10,10 +10,14 @@ export class ChatGptMessage {
@Field()
role: string
@Field()
threadId: string
@Field({ nullable: true })
threadId?: string
public constructor(data: Partial<Message>) {
@Field()
isError: boolean
public constructor(data: Partial<Message>, isError: boolean = false) {
Object.assign(this, data)
this.isError = isError
}
}

View File

@ -15,10 +15,10 @@ export class AiChatResolver {
async resumeChat(@Ctx() context: Context): Promise<ChatGptMessage[]> {
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<ChatGptMessage> {
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) {

View File

@ -0,0 +1,10 @@
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
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<Array<any>>) {
await queryFn('ALTER TABLE `openai_threads` DROP COLUMN `updatedAt`;')
}

View File

@ -1,13 +1,16 @@
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 {
@PrimaryColumn({ type: 'char', length: 30 })
id: string
@CreateDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
@CreateDateColumn({ type: 'timestamp' })
createdAt: Date
@UpdateDateColumn({ type: 'timestamp' })
updatedAt: Date
@Column({ name: 'user_id', type: 'int', unsigned: true })
userId: number
}