improve openai thread management

This commit is contained in:
einhornimmond 2025-03-16 16:38:15 +01:00
parent 978aa59e82
commit 33899e5d2b
15 changed files with 159 additions and 17 deletions

View File

@ -31,15 +31,21 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMutation } from '@vue/apollo-composable'
import { sendMessage as sendMessageMutation } from '../graphql/aiChat.graphql'
import { useMutation, useQuery } from '@vue/apollo-composable'
import {
sendMessage as sendMessageMutation,
resumeChat,
deleteThread,
} from '../graphql/aiChat.graphql'
import { useAppToast } from '@/composables/useToast'
const { t } = useI18n()
const { toastError } = useAppToast()
const { toastError, toastSuccess } = useAppToast()
const response = useMutation(sendMessageMutation, { input: ref('') })
const deleteResponse = useMutation(deleteThread, { threadId: ref('') })
const { result: resumeChatResult, refetch: resumeChatRefetch } = useQuery(resumeChat)
const isChatOpen = ref(false)
const newMessage = ref('')
@ -51,6 +57,21 @@ const toggleButtonVariant = computed(() => (isChatOpen.value ? 'secondary' : 'pr
const toggleChat = () => {
isChatOpen.value = !isChatOpen.value
if (!isChatOpen.value && threadId.value && threadId.value.length > 0) {
// delete thread on closing chat
deleteResponse
.mutate({ threadId: threadId.value })
.then((result) => {
threadId.value = ''
messages.value = []
if (result.data.deleteThread) {
toastSuccess(t('ai.chat-thread-deleted'))
}
})
.catch((error) => {
toastError(t('ai.error-chat-thread-deleted', { error }))
})
}
}
const sendMessage = () => {
@ -73,6 +94,19 @@ const sendMessage = () => {
newMessage.value = ''
}
}
onMounted(async () => {
if (messages.value.length === 0) {
loading.value = true
await resumeChatRefetch()
const messagesFromServer = resumeChatResult.value.resumeChat
if (messagesFromServer && messagesFromServer.length > 0) {
threadId.value = messagesFromServer[0].threadId
messages.value = messagesFromServer
}
loading.value = false
}
})
</script>
<style scoped>

View File

@ -1,7 +1,17 @@
#import './fragments.graphql'
mutation sendMessage($input: OpenaiMessage!) {
sendMessage(input: $input) {
content
role
threadId
...AiChatMessageFields
}
}
mutation deleteThread($threadId: String!) {
deleteThread(threadId: $threadId)
}
query resumeChat {
resumeChat {
...AiChatMessageFields
}
}

View File

@ -21,3 +21,9 @@ fragment ProjectBrandingCommonFields on ProjectBranding {
newUserToSpace
logoUrl
}
fragment AiChatMessageFields on ChatGptMessage {
content
role
threadId
}

View File

@ -4,7 +4,9 @@
"ai": {
"chat": "Chat",
"chat-open": "Chat öffnen",
"chat-placeholder": "Schreibe eine Nachricht..."
"chat-placeholder": "Schreibe eine Nachricht...",
"chat-thread-deleted": "Chatverlauf gelöscht",
"error-chat-thread-deleted": "Fehler beim Löschen des Chatverlaufs: {error}"
},
"alias": "Alias",
"all_emails": "Alle Nutzer",

View File

@ -4,7 +4,9 @@
"ai": {
"chat": "Chat",
"chat-open": "Open chat",
"chat-placeholder": "Type your message here..."
"chat-placeholder": "Type your message here...",
"chat-thread-deleted": "Chat thread has been deleted",
"error-chat-thread-deleted": "Error while deleting chat thread: {error}"
},
"alias": "Alias",
"all_emails": "All users",

View File

@ -2,6 +2,7 @@
JWT_EXPIRES_IN=2m
GDT_ACTIVE=false
OPENAI_ACTIVE=false
# Email
EMAIL=true

View File

@ -1,7 +1,7 @@
##################################################################################
# BASE ###########################################################################
##################################################################################
FROM node:18.20.7-alpine3.21 as base
FROM node:18.20.7-bookworm-slim as base
# ENVs (available in production aswell, can be overwritten by commandline or env file)
## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame

View File

@ -50,7 +50,7 @@
"pug": "^3.0.2",
"random-bigint": "^0.0.1",
"reflect-metadata": "^0.1.13",
"sodium-native": "^3.3.0",
"sodium-native": "^3.4.1",
"type-graphql": "^1.1.1",
"typed-rest-client": "^1.8.11",
"uuid": "^8.3.2",

View File

@ -71,6 +71,49 @@ export class OpenaiClient {
return messageThread.id
}
/**
* Resumes the last message thread for the given user.
* @param user
* @returns
*/
public async resumeThread(user: User): Promise<MessageModel[]> {
const openaiThreadEntity = await OpenaiThreads.findOne({
where: { userId: user.id },
order: { createdAt: 'DESC' },
})
if (!openaiThreadEntity) {
// 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()
logger.info(`Resumed thread: ${openaiThreadEntity.id}`)
return threadMessages
.map(
(message) =>
new MessageModel(
this.messageContentToString(message),
message.role,
openaiThreadEntity.id,
),
)
.reverse()
}
public async deleteThread(threadId: string): Promise<boolean> {
const result = await this.openai.beta.threads.del(threadId)
if (result.deleted) {
await OpenaiThreads.delete({ id: threadId })
logger.info(`Deleted thread: ${threadId}`)
return true
} else {
logger.warn(`Failed to delete thread: ${threadId}`)
return false
}
}
public async addMessage(message: MessageModel, threadId: string): Promise<void> {
const threadMessages = await this.openai.beta.threads.messages.create(threadId, message)
logger.info(`Added message to thread: ${threadMessages.id}`)
@ -94,7 +137,7 @@ export class OpenaiClient {
return new MessageModel(this.messageContentToString(message), 'assistant')
}
messageContentToString(message: Message): string {
private messageContentToString(message: Message): string {
if (message.content.length > 1) {
logger.warn(`More than one content in message: ${message.id}`, message.content)
}

View File

@ -1,10 +1,11 @@
export class Message {
content: string
role: 'user' | 'assistant' = 'user'
threadId: string
threadId?: string
constructor(content: string, role: 'user' | 'assistant' = 'user') {
constructor(content: string, role: 'user' | 'assistant' = 'user', threadId?: string) {
this.content = content
this.role = role
this.threadId = threadId
}
}

View File

@ -1,4 +1,4 @@
import { Resolver, Mutation, Authorized, Ctx, Arg } from 'type-graphql'
import { Resolver, Mutation, Authorized, Ctx, Arg, Query } from 'type-graphql'
import { OpenaiMessage } from '@input/OpenaiMessage'
import { ChatGptMessage } from '@model/ChatGptMessage'
@ -10,6 +10,30 @@ import { Context } from '@/server/context'
@Resolver()
export class AiChatResolver {
@Authorized([RIGHTS.AI_SEND_MESSAGE])
@Query(() => [ChatGptMessage])
async resumeChat(@Ctx() context: Context): Promise<ChatGptMessage[]> {
const openaiClient = OpenaiClient.getInstance()
if (!openaiClient) {
return Promise.resolve([new ChatGptMessage({ content: 'OpenAI API is not enabled' })])
}
if (!context.user) {
return Promise.resolve([new ChatGptMessage({ content: 'User not found' })])
}
const messages = await openaiClient.resumeThread(context.user)
return messages.map((message) => new ChatGptMessage(message))
}
@Authorized([RIGHTS.AI_SEND_MESSAGE])
@Mutation(() => Boolean)
async deleteThread(@Arg('threadId') threadId: string): Promise<boolean> {
const openaiClient = OpenaiClient.getInstance()
if (!openaiClient) {
return false
}
return openaiClient.deleteThread(threadId)
}
@Authorized([RIGHTS.AI_SEND_MESSAGE])
@Mutation(() => ChatGptMessage)
async sendMessage(

View File

@ -520,7 +520,6 @@ export class UserResolver {
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
)
}
// load code
const userContact = await DbUserContact.findOneOrFail({
where: { emailVerificationCode: code },

View File

@ -14,6 +14,7 @@ import {
crypto_pwhash,
crypto_shorthash,
} from 'sodium-native'
import { createHash } from 'node:crypto'
export const SecretKeyCryptographyCreateKey = (
salt: string,
@ -21,12 +22,29 @@ export const SecretKeyCryptographyCreateKey = (
configLoginAppSecret: Buffer,
configLoginServerKey: Buffer,
): bigint => {
/*
console.log('SecretKeyCryptographyCreateKey')
const state = Buffer.alloc(crypto_hash_sha512_STATEBYTES)
crypto_hash_sha512_init(state)
console.log('before salt')
console.log('salt as buffer', Buffer.from(salt).toString('hex'))
crypto_hash_sha512_update(state, Buffer.from(salt))
console.log('before configLoginAppSecret')
console.log('configLoginAppSecret', configLoginAppSecret.toString('hex'))
crypto_hash_sha512_update(state, configLoginAppSecret)
const hash = Buffer.alloc(crypto_hash_sha512_BYTES)
console.log('hash buffer size', hash.length)
console.log('before final')
console.log(typeof crypto_hash_sha512_final)
crypto_hash_sha512_final(state, hash)
console.log('after final')
console.log('hash', hash.toString('hex'))
*/
const state = createHash('sha512')
state.update(Buffer.from(salt))
state.update(configLoginAppSecret)
const hash = state.digest()
console.log('hash', hash.toString('hex'))
const encryptionKey = Buffer.alloc(crypto_box_SEEDBYTES)
const opsLimit = 10

View File

@ -58,6 +58,7 @@ export const SecretKeyCryptographyCreateKey = async (
}
let result: Promise<bigint>
if (encryptionWorkerPool) {
console.log('encrypt password with worker')
result = (await encryptionWorkerPool.exec('SecretKeyCryptographyCreateKey', [
salt,
password,
@ -65,6 +66,7 @@ export const SecretKeyCryptographyCreateKey = async (
configLoginServerKey,
])) as Promise<bigint>
} else {
console.log('encrypt password without worker')
result = Promise.resolve(
SecretKeyCryptographyCreateKeySync(
salt,

View File

@ -6594,7 +6594,7 @@ slick@^1.12.2:
resolved "https://registry.yarnpkg.com/slick/-/slick-1.12.2.tgz#bd048ddb74de7d1ca6915faa4a57570b3550c2d7"
integrity sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==
sodium-native@^3.3.0:
sodium-native@^3.4.1:
version "3.4.1"
resolved "https://registry.yarnpkg.com/sodium-native/-/sodium-native-3.4.1.tgz#44616c07ccecea15195f553af88b3e574b659741"
integrity sha512-PaNN/roiFWzVVTL6OqjzYct38NSXewdl2wz8SRB51Br/MLIJPrbM3XexhVWkq7D3UWMysfrhKVf1v1phZq6MeQ==