mirror of
https://github.com/IT4Change/gradido.git
synced 2026-02-06 09:56:05 +00:00
Merge pull request #3456 from gradido/admin_add_ai_chat
feat(admin): add ai chat
This commit is contained in:
commit
96bacae6ae
@ -4,4 +4,5 @@ WALLET_URL=http://localhost
|
||||
WALLET_AUTH_PATH=/authenticate?token=
|
||||
WALLET_LOGIN_PATH=/login
|
||||
DEBUG_DISABLE_AUTH=false
|
||||
HUMHUB_ACTIVE=false
|
||||
HUMHUB_ACTIVE=false
|
||||
OPENAI_ACTIVE=false
|
||||
@ -8,4 +8,5 @@ GRAPHQL_PATH=$GRAPHQL_PATH
|
||||
DEBUG_DISABLE_AUTH=false
|
||||
|
||||
HUMHUB_ACTIVE=$HUMHUB_ACTIVE
|
||||
HUMHUB_API_URL=$HUMHUB_API_URL
|
||||
HUMHUB_API_URL=$HUMHUB_API_URL
|
||||
OPENAI_ACTIVE=$OPENAI_ACTIVE
|
||||
BIN
admin/public/img/Crea.png
Normal file
BIN
admin/public/img/Crea.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
admin/public/img/Crea.webp
Normal file
BIN
admin/public/img/Crea.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
333
admin/src/components/AiChat.vue
Normal file
333
admin/src/components/AiChat.vue
Normal file
@ -0,0 +1,333 @@
|
||||
<template>
|
||||
<div class="chat-container">
|
||||
<b-button
|
||||
v-if="!isChatOpen"
|
||||
:class="['chat-toggle-button', 'bg-crea-img', { 'slide-up-animation': !hasBeenOpened }]"
|
||||
:variant="light"
|
||||
@click="openChat"
|
||||
></b-button>
|
||||
|
||||
<div v-if="isChatOpen" class="chat-window">
|
||||
<div class="d-flex justify-content-start">
|
||||
<b-button variant="light" class="chat-close-button mt-1 ms-1 btn-sm" @click="closeChat">
|
||||
<IIcBaselineClose />
|
||||
</b-button>
|
||||
</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 class="message-content position-relative inner-container">
|
||||
<span v-html="formatMessage(message)"></span>
|
||||
<b-button
|
||||
v-if="message.role === 'assistant'"
|
||||
variant="light"
|
||||
class="copy-clipboard-button"
|
||||
:title="$t('copy-to-clipboard')"
|
||||
@click="copyToClipboard(message.content)"
|
||||
>
|
||||
<IBiClipboard></IBiClipboard>
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
<!--<div class="d-flex justify-content-end position-absolute top-0 start-0">
|
||||
<b-button variant="light" class="chat-close-button mt-1 ms-1 btn-sm" @click="closeChat">
|
||||
<IIcBaselineClose />
|
||||
</b-button>
|
||||
</div> -->
|
||||
|
||||
<div class="input-area">
|
||||
<BFormTextarea
|
||||
v-model="newMessage"
|
||||
class="fs-6"
|
||||
:placeholder="textareaPlaceholder"
|
||||
rows="2"
|
||||
no-resize
|
||||
:disabled="loading"
|
||||
@keydown.ctrl.enter="sendMessage"
|
||||
@keydown.meta.enter="sendMessage"
|
||||
></BFormTextarea>
|
||||
<b-button variant="light" :disabled="loading" @click="sendMessage">
|
||||
{{ buttonText }}
|
||||
</b-button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-start">
|
||||
<b-button variant="light" class="chat-clear-button" @click="clearChat">
|
||||
{{ $t('ai.chat-clear') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
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, 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 chatContainer = ref(null)
|
||||
const newMessage = ref('')
|
||||
const threadId = ref('')
|
||||
const messages = ref([])
|
||||
const loading = ref(false)
|
||||
const hasBeenOpened = ref(false)
|
||||
const buttonText = computed(() => t('send') + (loading.value ? '...' : ''))
|
||||
const textareaPlaceholder = computed(() =>
|
||||
loading.value ? t('ai.chat-placeholder-loading') : t('ai.chat-placeholder'),
|
||||
)
|
||||
|
||||
function formatMessage(message) {
|
||||
return message.content.replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
function copyToClipboard(content) {
|
||||
navigator.clipboard.writeText(content)
|
||||
toastSuccess(t('copied-to-clipboard'))
|
||||
}
|
||||
|
||||
function openChat() {
|
||||
isChatOpen.value = true
|
||||
if (messages.value.length > 0) {
|
||||
scrollDown()
|
||||
}
|
||||
}
|
||||
|
||||
function closeChat() {
|
||||
hasBeenOpened.value = true
|
||||
isChatOpen.value = false
|
||||
}
|
||||
|
||||
// clear
|
||||
function clearChat() {
|
||||
if (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'))
|
||||
newMessage.value = t('ai.start-prompt')
|
||||
sendMessage()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(t('ai.error-chat-thread-deleted', { error }))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function scrollDown() {
|
||||
nextTick(() => {
|
||||
if (!chatContainer.value) return
|
||||
chatContainer.value.scrollTo({
|
||||
top: chatContainer.value.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const sendMessage = () => {
|
||||
if (newMessage.value.trim()) {
|
||||
loading.value = true
|
||||
if (newMessage.value !== t('ai.start-prompt')) {
|
||||
messages.value.push({ content: newMessage.value, role: 'user' })
|
||||
scrollDown()
|
||||
}
|
||||
response
|
||||
.mutate({ input: { message: newMessage.value, threadId: threadId.value } })
|
||||
.then(({ data }) => {
|
||||
if (data && data.sendMessage) {
|
||||
threadId.value = data.sendMessage.threadId
|
||||
messages.value.push(data.sendMessage)
|
||||
}
|
||||
loading.value = false
|
||||
scrollDown()
|
||||
})
|
||||
.catch((error) => {
|
||||
loading.value = false
|
||||
toastError('Error sending message:', error)
|
||||
})
|
||||
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.filter(
|
||||
(message) => message.content !== t('ai.start-prompt'),
|
||||
)
|
||||
scrollDown()
|
||||
loading.value = false
|
||||
} else {
|
||||
newMessage.value = t('ai.start-prompt')
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-container {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-toggle-button {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
border: 1px solid darkblue;
|
||||
}
|
||||
|
||||
.chat-clear-button {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bg-crea-img {
|
||||
background-image: url('../../public/img/Crea.webp');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
width: 250px;
|
||||
height: 142px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.chat-window {
|
||||
width: 550px;
|
||||
height: 330px;
|
||||
background-color: white;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgb(0 0 0 / 10%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.copy-clipboard-button {
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
right: -12%;
|
||||
padding-top: 2px;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.messages-scroll-container {
|
||||
border-radius: 8px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.messages {
|
||||
padding: 10px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
max-width: 80%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message.user .message-content {
|
||||
background-color: white;
|
||||
color: black;
|
||||
margin-left: auto;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.message.assistant .message-content {
|
||||
background-color: #e9ecef;
|
||||
color: black;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
border-top: 1px solid #ccc;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.input-area textarea {
|
||||
flex: 1;
|
||||
margin-right: 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.input-area button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Animations für den Einblendeffekt */
|
||||
.chat-enter-active,
|
||||
.chat-leave-active {
|
||||
transition:
|
||||
transform 0.5s ease-out,
|
||||
opacity 0.5s;
|
||||
}
|
||||
|
||||
.chat-enter-from {
|
||||
transform: translateY(30px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.chat-enter-to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slide-up-animation {
|
||||
animation: slide-up 1s ease-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -37,6 +37,7 @@ const { toastError, toastSuccess } = useAppToast()
|
||||
const rolesValues = {
|
||||
ADMIN: 'ADMIN',
|
||||
MODERATOR: 'MODERATOR',
|
||||
MODERATOR_AI: 'MODERATOR_AI',
|
||||
USER: 'USER',
|
||||
}
|
||||
const props = defineProps({
|
||||
@ -59,6 +60,7 @@ const moderatorId = computed(() => store.state.moderator.id)
|
||||
const roles = computed(() => [
|
||||
{ value: rolesValues.USER, text: t('userRole.selectRoles.user') },
|
||||
{ value: rolesValues.MODERATOR, text: t('userRole.selectRoles.moderator') },
|
||||
{ value: rolesValues.MODERATOR_AI, text: t('userRole.selectRoles.moderatorAi') },
|
||||
{ value: rolesValues.ADMIN, text: t('userRole.selectRoles.admin') },
|
||||
])
|
||||
|
||||
|
||||
@ -99,6 +99,9 @@
|
||||
<template #cell(lastName)="row">
|
||||
<div class="no-select">{{ row.item.lastName }}</div>
|
||||
</template>
|
||||
<template #cell(contributionDate)="row">
|
||||
<div class="no-select">{{ row.item.contributionDate }}</div>
|
||||
</template>
|
||||
<template #row-details="row">
|
||||
<row-details
|
||||
:row="row"
|
||||
|
||||
@ -55,11 +55,14 @@ const humhub = {
|
||||
HUMHUB_API_URL: process.env.HUMHUB_API_URL ?? COMMUNITY_URL + '/community/',
|
||||
}
|
||||
|
||||
const OPENAI_ACTIVE = process.env.OPENAI_ACTIVE === 'true' ?? false
|
||||
|
||||
const CONFIG = {
|
||||
...version,
|
||||
...environment,
|
||||
...endpoints,
|
||||
...debug,
|
||||
OPENAI_ACTIVE,
|
||||
...humhub,
|
||||
ADMIN_MODULE_URL,
|
||||
COMMUNITY_URL,
|
||||
|
||||
@ -8,6 +8,7 @@ const {
|
||||
HUMHUB_ACTIVE,
|
||||
HUMHUB_API_URL,
|
||||
NODE_ENV,
|
||||
OPENAI_ACTIVE,
|
||||
PRODUCTION,
|
||||
} = require('gradido-config/build/src/commonSchema.js')
|
||||
const Joi = require('joi')
|
||||
@ -22,6 +23,7 @@ module.exports = Joi.object({
|
||||
HUMHUB_ACTIVE,
|
||||
HUMHUB_API_URL,
|
||||
NODE_ENV,
|
||||
OPENAI_ACTIVE,
|
||||
PRODUCTION,
|
||||
|
||||
ADMIN_HOSTING: Joi.string()
|
||||
|
||||
17
admin/src/graphql/aiChat.graphql
Normal file
17
admin/src/graphql/aiChat.graphql
Normal file
@ -0,0 +1,17 @@
|
||||
#import './fragments.graphql'
|
||||
|
||||
mutation sendMessage($input: OpenaiMessage!) {
|
||||
sendMessage(input: $input) {
|
||||
...AiChatMessageFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation deleteThread($threadId: String!) {
|
||||
deleteThread(threadId: $threadId)
|
||||
}
|
||||
|
||||
query resumeChat {
|
||||
resumeChat {
|
||||
...AiChatMessageFields
|
||||
}
|
||||
}
|
||||
@ -21,3 +21,9 @@ fragment ProjectBrandingCommonFields on ProjectBranding {
|
||||
newUserToSpace
|
||||
logoUrl
|
||||
}
|
||||
|
||||
fragment AiChatMessageFields on ChatGptMessage {
|
||||
content
|
||||
role
|
||||
threadId
|
||||
}
|
||||
@ -1,11 +1,20 @@
|
||||
{
|
||||
"GDD": "GDD",
|
||||
"actions": "Aktionen",
|
||||
"ai": {
|
||||
"chat": "Chat",
|
||||
"chat-open": "Chat öffnen",
|
||||
"chat-clear": "Chat-Verlauf löschen",
|
||||
"chat-placeholder": "Schreibe eine Nachricht...",
|
||||
"chat-placeholder-loading": "Warte, ich denke nach...",
|
||||
"chat-thread-deleted": "Chatverlauf gelöscht",
|
||||
"error-chat-thread-deleted": "Fehler beim Löschen des Chatverlaufs: {error}",
|
||||
"start-prompt": "Sprache: Deutsch\nTonfall: Freundlich, per Du\nKontext: Du bist \"Crea\", ein Support-Assistent im Admin-Interface des Gradido-Kontos. Deine Aufgabe ist es, Moderatoren bei der Bearbeitung von Gemeinwohl-Beiträgen zu unterstützen.\nBegrüßung:\n\"Hallo, ich bin Crea! Ich unterstütze Dich bei der Beantwortung der eingereichten Gemeinwohl-Beiträge. Damit ich Deine Antworten personalisieren kann, nenne mir bitte Deinen Namen. 😊\"\nWarte nun die Eingabe des Namens ab!\nNach Eingabe des Namens:\n\"Danke, [Name]! Schön, mit Dir zusammenzuarbeiten. 😊 Kopiere nun bitte einen oder mehrere Gemeinwohl-Beiträge in das Textfeld, und ich helfe Dir bei der Bearbeitung.\""
|
||||
},
|
||||
"alias": "Alias",
|
||||
"all_emails": "Alle Nutzer",
|
||||
"back": "zurück",
|
||||
"change_user_role": "Nutzerrolle ändern",
|
||||
"chat": "Chat",
|
||||
"close": "Schließen",
|
||||
"contributionLink": {
|
||||
"amount": "Betrag",
|
||||
@ -236,6 +245,7 @@
|
||||
"removeNotSelf": "Als Admin/Moderator kannst du dich nicht selber löschen.",
|
||||
"reset": "Zurücksetzen",
|
||||
"save": "Speichern",
|
||||
"send": "Senden",
|
||||
"statistic": {
|
||||
"activeUsers": "Aktive Mitglieder",
|
||||
"count": "Menge",
|
||||
@ -281,6 +291,7 @@
|
||||
"selectRoles": {
|
||||
"admin": "Administrator",
|
||||
"moderator": "Moderator",
|
||||
"moderatorAi": "KI-Moderator",
|
||||
"user": "einfacher Nutzer"
|
||||
},
|
||||
"successfullyChangedTo": "Nutzer ist jetzt „{role}“.",
|
||||
|
||||
@ -1,11 +1,20 @@
|
||||
{
|
||||
"GDD": "GDD",
|
||||
"actions": "Actions",
|
||||
"ai": {
|
||||
"chat": "Chat",
|
||||
"chat-open": "Open chat",
|
||||
"chat-clear": "Clear chat",
|
||||
"chat-placeholder": "Type your message here...",
|
||||
"chat-placeholder-loading": "Wait, I think...",
|
||||
"chat-thread-deleted": "Chat thread has been deleted",
|
||||
"error-chat-thread-deleted": "Error while deleting chat thread: {error}",
|
||||
"start-prompt": "Language: English\nTone of voice: Friendly, on a first-name basis\nContext: You are \"Crea\", a support assistant in the admin interface of the Gradido account. Your task is to support moderators in editing contributions for the common good.\nGreeting:\n\"Hello, I'm Crea! I support you in answering the contributions for the common good. So that I can personalize your answers, please tell me your name. 😊\"\nNow wait for the name to be entered!\nAfter entering the name:\n\"Thank you, [name]! Nice to work with you. 😊 Now please copy one or more contributions for the common good into the text field and I'll help you edit them.\""
|
||||
},
|
||||
"alias": "Alias",
|
||||
"all_emails": "All users",
|
||||
"back": "back",
|
||||
"change_user_role": "Change user role",
|
||||
"chat": "Chat",
|
||||
"close": "Close",
|
||||
"contributionLink": {
|
||||
"amount": "Amount",
|
||||
@ -236,6 +245,7 @@
|
||||
"removeNotSelf": "As an admin/moderator, you cannot delete yourself.",
|
||||
"reset": "Reset",
|
||||
"save": "Save",
|
||||
"send": "Send",
|
||||
"statistic": {
|
||||
"activeUsers": "Active members",
|
||||
"count": "Count",
|
||||
@ -281,6 +291,7 @@
|
||||
"selectRoles": {
|
||||
"admin": "administrator",
|
||||
"moderator": "moderator",
|
||||
"moderatorAi": "ai-moderator",
|
||||
"user": "usual user"
|
||||
},
|
||||
"successfullyChangedTo": "User is now \"{role}\".",
|
||||
|
||||
@ -70,7 +70,7 @@
|
||||
align="center"
|
||||
:hide-ellipsis="true"
|
||||
/>
|
||||
|
||||
<ai-chat v-if="CONFIG.OPENAI_ACTIVE && isAiUser" />
|
||||
<div v-if="overlay" id="overlay" @dblclick="overlay = false">
|
||||
<Overlay :item="item" @overlay-cancel="overlay = false">
|
||||
<template #title>
|
||||
@ -101,12 +101,14 @@ import { useI18n } from 'vue-i18n'
|
||||
import Overlay from '../components/Overlay'
|
||||
import OpenCreationsTable from '../components/Tables/OpenCreationsTable'
|
||||
import UserQuery from '../components/UserQuery'
|
||||
import AiChat from '../components/AiChat'
|
||||
import { adminListContributions } from '../graphql/adminListContributions'
|
||||
import { adminDeleteContribution } from '../graphql/adminDeleteContribution'
|
||||
import { confirmContribution } from '../graphql/confirmContribution'
|
||||
import { denyContribution } from '../graphql/denyContribution'
|
||||
import { getContribution } from '../graphql/getContribution'
|
||||
import { useAppToast } from '@/composables/useToast'
|
||||
import CONFIG from '@/config'
|
||||
|
||||
const FILTER_TAB_MAP = [
|
||||
['IN_PROGRESS', 'PENDING'],
|
||||
@ -306,6 +308,9 @@ const showResubmissionCheckbox = computed(() => tabIndex.value === 0)
|
||||
const hideResubmission = computed(() =>
|
||||
showResubmissionCheckbox.value ? hideResubmissionModel.value : false,
|
||||
)
|
||||
const isAiUser = computed(() =>
|
||||
store.state.moderator?.roles.some((role) => ['ADMIN', 'MODERATOR_AI'].includes(role)),
|
||||
)
|
||||
|
||||
watch(tabIndex, () => {
|
||||
currentPage.value = 1
|
||||
|
||||
@ -77,6 +77,7 @@ export default defineConfig(async ({ command }) => {
|
||||
WALLET_AUTH_PATH: CONFIG.WALLET_AUTH_PATH ?? null,
|
||||
WALLET_LOGIN_PATH: CONFIG.WALLET_LOGIN_URL ?? null, // null,
|
||||
DEBUG_DISABLE_AUTH: CONFIG.DEBUG_DISABLE_AUTH ?? null, // null,
|
||||
OPENAI_ACTIVE: CONFIG.OPENAI_ACTIVE ?? null, // null,
|
||||
HUMHUB_ACTIVE: CONFIG.HUMHUB_ACTIVE ?? null, // null,
|
||||
HUMHUB_API_URL: CONFIG.HUMHUB_API_URL ?? null, // null,
|
||||
// CONFIG_VERSION: CONFIG.CONFIG_VERSION, // null,
|
||||
|
||||
@ -76,3 +76,8 @@ GMS_DASHBOARD_URL=http://localhost:8080/
|
||||
HUMHUB_ACTIVE=false
|
||||
HUMHUB_API_URL=https://community.gradido.net/
|
||||
HUMHUB_JWT_KEY=
|
||||
|
||||
# OPENAI
|
||||
OPENAI_ACTIVE=false
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_ASSISTANT_ID=asst_MF5cchZr7wY7rNXayuWvZFsM
|
||||
@ -77,3 +77,8 @@ HUMHUB_ACTIVE=$HUMHUB_ACTIVE
|
||||
HUMHUB_API_URL=$HUMHUB_API_URL
|
||||
HUMHUB_JWT_KEY=$HUMHUB_JWT_KEY
|
||||
|
||||
# OpenAI
|
||||
OPENAI_ACTIVE=$OPENAI_ACTIVE
|
||||
OPENAI_API_KEY=$OPENAI_API_KEY
|
||||
OPENAI_ASSISTANT_ID=$OPENAI_ASSISTANT_ID
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
JWT_EXPIRES_IN=2m
|
||||
|
||||
GDT_ACTIVE=false
|
||||
OPENAI_ACTIVE=false
|
||||
|
||||
# Email
|
||||
EMAIL=true
|
||||
|
||||
@ -53,7 +53,6 @@ module.exports = {
|
||||
'import/no-nodejs-modules': 'off',
|
||||
'import/unambiguous': 'error',
|
||||
'import/default': 'error',
|
||||
'import/named': 'error',
|
||||
'import/namespace': 'error',
|
||||
'import/no-absolute-path': 'error',
|
||||
'import/no-cycle': 'error',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -7,7 +7,7 @@ module.exports = {
|
||||
collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
lines: 79,
|
||||
lines: 78,
|
||||
},
|
||||
},
|
||||
setupFiles: ['<rootDir>/test/testSetup.ts'],
|
||||
|
||||
@ -46,10 +46,11 @@
|
||||
"log4js": "^6.4.6",
|
||||
"mysql2": "^2.3.0",
|
||||
"nodemailer": "^6.6.5",
|
||||
"openai": "^4.87.3",
|
||||
"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",
|
||||
|
||||
164
backend/src/apis/openai/OpenaiClient.ts
Normal file
164
backend/src/apis/openai/OpenaiClient.ts
Normal file
@ -0,0 +1,164 @@
|
||||
/* eslint-disable camelcase */
|
||||
import { OpenaiThreads } from '@entity/OpenaiThreads'
|
||||
import { User } from '@entity/User'
|
||||
import { OpenAI } from 'openai'
|
||||
import { Message } from 'openai/resources/beta/threads/messages'
|
||||
|
||||
import { httpsAgent } from '@/apis/ConnectionAgents'
|
||||
import { CONFIG } from '@/config'
|
||||
import { backendLogger as logger } from '@/server/logger'
|
||||
|
||||
import { Message as MessageModel } from './model/Message'
|
||||
|
||||
/**
|
||||
* The `OpenaiClient` class is a singleton that provides an interface to interact with the OpenAI API.
|
||||
* It ensures that only one instance of the client is created and used throughout the application.
|
||||
*/
|
||||
export class OpenaiClient {
|
||||
/**
|
||||
* The singleton instance of the `OpenaiClient`.
|
||||
*/
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
private static instance: OpenaiClient
|
||||
|
||||
/**
|
||||
* The OpenAI client instance used to interact with the OpenAI API.
|
||||
*/
|
||||
private openai: OpenAI
|
||||
|
||||
/**
|
||||
* Private constructor to prevent direct instantiation.
|
||||
* Initializes the OpenAI client with the provided API key from the configuration.
|
||||
*/
|
||||
private constructor() {
|
||||
this.openai = new OpenAI({ apiKey: CONFIG.OPENAI_API_KEY, httpAgent: httpsAgent })
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the singleton instance of the `OpenaiClient`.
|
||||
* If the OpenAI integration is disabled via configuration or the API key is missing, it returns `undefined`.
|
||||
*
|
||||
* @returns {OpenaiClient | undefined} The singleton instance of the `OpenaiClient` or `undefined` if disabled.
|
||||
*/
|
||||
public static getInstance(): OpenaiClient | undefined {
|
||||
if (!CONFIG.OPENAI_ACTIVE || !CONFIG.OPENAI_API_KEY) {
|
||||
logger.info(`openai are disabled via config...`)
|
||||
return
|
||||
}
|
||||
if (!OpenaiClient.instance) {
|
||||
OpenaiClient.instance = new OpenaiClient()
|
||||
}
|
||||
return OpenaiClient.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new message thread with the initial message provided.
|
||||
*
|
||||
* @param {Message} initialMessage - The initial message to start the thread.
|
||||
* @returns {Promise<string>} A promise that resolves to the ID of the created message thread.
|
||||
*/
|
||||
public async createThread(initialMessage: MessageModel, user: User): Promise<string> {
|
||||
const messageThread = await this.openai.beta.threads.create({
|
||||
messages: [initialMessage],
|
||||
})
|
||||
// store id in db because it isn't possible to list all open threads via openai api
|
||||
const openaiThreadEntity = OpenaiThreads.create()
|
||||
openaiThreadEntity.id = messageThread.id
|
||||
openaiThreadEntity.userId = user.id
|
||||
await openaiThreadEntity.save()
|
||||
|
||||
logger.info(`Created new message thread: ${messageThread.id}`)
|
||||
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 Promise.all([
|
||||
OpenaiThreads.delete({ id: threadId }),
|
||||
this.openai.beta.threads.del(threadId),
|
||||
])
|
||||
if (result.deleted) {
|
||||
logger.info(`Deleted thread: ${threadId}`)
|
||||
return true
|
||||
} else {
|
||||
logger.warn(`Failed to delete thread: ${threadId}, remove from db anyway`)
|
||||
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}`)
|
||||
}
|
||||
|
||||
public async runAndGetLastNewMessage(threadId: string): Promise<MessageModel> {
|
||||
const run = await this.openai.beta.threads.runs.createAndPoll(threadId, {
|
||||
assistant_id: CONFIG.OPENAI_ASSISTANT_ID,
|
||||
})
|
||||
logger.info('run status:', run.status)
|
||||
|
||||
const messagesPage = await this.openai.beta.threads.messages.list(threadId, { run_id: run.id })
|
||||
if (messagesPage.data.length > 1) {
|
||||
logger.warn(`More than one message in thread: ${threadId}, run: ${run.id}`, messagesPage.data)
|
||||
}
|
||||
const message = messagesPage.data.at(0)
|
||||
if (!message) {
|
||||
logger.warn(`No message in thread: ${threadId}, run: ${run.id}`, messagesPage.data)
|
||||
return new MessageModel('No Answer', 'assistant')
|
||||
}
|
||||
return new MessageModel(this.messageContentToString(message), 'assistant')
|
||||
}
|
||||
|
||||
private messageContentToString(message: Message): string {
|
||||
if (message.content.length > 1) {
|
||||
logger.warn(`More than one content in message: ${message.id}`, message.content)
|
||||
}
|
||||
const firstContent = message.content.at(0)
|
||||
if (!firstContent) {
|
||||
logger.warn(`No content in message: ${message.id}`, message)
|
||||
return ''
|
||||
}
|
||||
if (firstContent.type === 'text') {
|
||||
if (firstContent.text.annotations.length > 1) {
|
||||
logger.info(`Annotations: ${JSON.stringify(firstContent.text.annotations, null, 2)}`)
|
||||
}
|
||||
return firstContent.text.value
|
||||
} else if (firstContent.type === 'refusal') {
|
||||
logger.info(`Refusal: ${firstContent.refusal}`)
|
||||
return firstContent.refusal
|
||||
} else {
|
||||
logger.error(`Unhandled content type: ${firstContent.type}`, firstContent)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
11
backend/src/apis/openai/model/Message.ts
Normal file
11
backend/src/apis/openai/model/Message.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export class Message {
|
||||
content: string
|
||||
role: 'user' | 'assistant' = 'user'
|
||||
threadId?: string
|
||||
|
||||
constructor(content: string, role: 'user' | 'assistant' = 'user', threadId?: string) {
|
||||
this.content = content
|
||||
this.role = role
|
||||
this.threadId = threadId
|
||||
}
|
||||
}
|
||||
3
backend/src/auth/MODERATOR_AI_RIGHTS.ts
Normal file
3
backend/src/auth/MODERATOR_AI_RIGHTS.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { RIGHTS } from './RIGHTS'
|
||||
|
||||
export const MODERATOR_AI_RIGHTS = [RIGHTS.AI_SEND_MESSAGE]
|
||||
@ -59,6 +59,8 @@ export enum RIGHTS {
|
||||
DENY_CONTRIBUTION = 'DENY_CONTRIBUTION',
|
||||
ADMIN_OPEN_CREATIONS = 'ADMIN_OPEN_CREATIONS',
|
||||
ADMIN_LIST_ALL_CONTRIBUTION_MESSAGES = 'ADMIN_LIST_ALL_CONTRIBUTION_MESSAGES',
|
||||
// Moderator AI
|
||||
AI_SEND_MESSAGE = 'AI_SEND_MESSAGE',
|
||||
// Admin
|
||||
SET_USER_ROLE = 'SET_USER_ROLE',
|
||||
DELETE_USER = 'DELETE_USER',
|
||||
|
||||
@ -3,6 +3,7 @@ import { RoleNames } from '@/graphql/enum/RoleNames'
|
||||
import { ADMIN_RIGHTS } from './ADMIN_RIGHTS'
|
||||
import { DLT_CONNECTOR_RIGHTS } from './DLT_CONNECTOR_RIGHTS'
|
||||
import { INALIENABLE_RIGHTS } from './INALIENABLE_RIGHTS'
|
||||
import { MODERATOR_AI_RIGHTS } from './MODERATOR_AI_RIGHTS'
|
||||
import { MODERATOR_RIGHTS } from './MODERATOR_RIGHTS'
|
||||
import { Role } from './Role'
|
||||
import { USER_RIGHTS } from './USER_RIGHTS'
|
||||
@ -14,10 +15,18 @@ export const ROLE_MODERATOR = new Role(RoleNames.MODERATOR, [
|
||||
...USER_RIGHTS,
|
||||
...MODERATOR_RIGHTS,
|
||||
])
|
||||
export const ROLE_MODERATOR_AI = new Role(RoleNames.MODERATOR_AI, [
|
||||
...INALIENABLE_RIGHTS,
|
||||
...USER_RIGHTS,
|
||||
...MODERATOR_RIGHTS,
|
||||
...MODERATOR_AI_RIGHTS,
|
||||
])
|
||||
|
||||
export const ROLE_ADMIN = new Role(RoleNames.ADMIN, [
|
||||
...INALIENABLE_RIGHTS,
|
||||
...USER_RIGHTS,
|
||||
...MODERATOR_RIGHTS,
|
||||
...MODERATOR_AI_RIGHTS,
|
||||
...ADMIN_RIGHTS,
|
||||
])
|
||||
|
||||
|
||||
@ -127,8 +127,9 @@ const federation = {
|
||||
// default value for community-uuid is equal uuid of stage-3
|
||||
FEDERATION_XCOM_RECEIVER_COMMUNITY_UUID:
|
||||
process.env.FEDERATION_XCOM_RECEIVER_COMMUNITY_UUID ?? '56a55482-909e-46a4-bfa2-cd025e894ebc',
|
||||
FEDERATION_XCOM_MAXREPEAT_REVERTSENDCOINS:
|
||||
process.env.FEDERATION_XCOM_MAXREPEAT_REVERTSENDCOINS ?? 3,
|
||||
FEDERATION_XCOM_MAXREPEAT_REVERTSENDCOINS: parseInt(
|
||||
process.env.FEDERATION_XCOM_MAXREPEAT_REVERTSENDCOINS ?? '3',
|
||||
),
|
||||
}
|
||||
|
||||
const gms = {
|
||||
@ -147,6 +148,12 @@ const humhub = {
|
||||
HUMHUB_JWT_KEY: process.env.HUMHUB_JWT_KEY ?? '',
|
||||
}
|
||||
|
||||
const openai = {
|
||||
OPENAI_ACTIVE: process.env.OPENAI_ACTIVE === 'true' || false,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? '',
|
||||
OPENAI_ASSISTANT_ID: process.env.OPENAI_ASSISTANT_ID ?? '',
|
||||
}
|
||||
|
||||
export const CONFIG = {
|
||||
...constants,
|
||||
...server,
|
||||
@ -160,6 +167,6 @@ export const CONFIG = {
|
||||
...federation,
|
||||
...gms,
|
||||
...humhub,
|
||||
...openai,
|
||||
}
|
||||
|
||||
validate(schema, CONFIG)
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
LOGIN_SERVER_KEY,
|
||||
LOG_LEVEL,
|
||||
NODE_ENV,
|
||||
OPENAI_ACTIVE,
|
||||
PRODUCTION,
|
||||
TYPEORM_LOGGING_RELATIVE_PATH,
|
||||
} from '@config/commonSchema'
|
||||
@ -51,6 +52,7 @@ export const schema = Joi.object({
|
||||
LOGIN_SERVER_KEY,
|
||||
LOG_LEVEL,
|
||||
NODE_ENV,
|
||||
OPENAI_ACTIVE,
|
||||
PRODUCTION,
|
||||
TYPEORM_LOGGING_RELATIVE_PATH,
|
||||
|
||||
@ -324,6 +326,18 @@ export const schema = Joi.object({
|
||||
.default('SomeFakeKeyEN')
|
||||
.description('The API key for Klicktipp (English version)'),
|
||||
|
||||
OPENAI_API_KEY: Joi.string()
|
||||
.pattern(/^sk-[A-Za-z0-9-_]{20,}$/)
|
||||
.when('OPENAI_ACTIVE', { is: true, then: Joi.required(), otherwise: Joi.optional().allow('') })
|
||||
.description(
|
||||
'API key for OpenAI, must be at least 20 characters long and contain only alphanumeric characters, dashes, or underscores',
|
||||
),
|
||||
|
||||
OPENAI_ASSISTANT_ID: Joi.string()
|
||||
.pattern(/^asst_[A-Za-z0-9-]{20,}$/)
|
||||
.when('OPENAI_ACTIVE', { is: true, then: Joi.required(), otherwise: Joi.optional().allow('') })
|
||||
.description('Assistant ID for OpenAI'),
|
||||
|
||||
USE_CRYPTO_WORKER: Joi.boolean()
|
||||
.default(false)
|
||||
.description(
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
ROLE_USER,
|
||||
ROLE_ADMIN,
|
||||
ROLE_MODERATOR,
|
||||
ROLE_MODERATOR_AI,
|
||||
ROLE_DLT_CONNECTOR,
|
||||
} from '@/auth/ROLES'
|
||||
import { Context } from '@/server/context'
|
||||
@ -57,6 +58,9 @@ export const isAuthorized: AuthChecker<Context> = async ({ context }, rights) =>
|
||||
case RoleNames.MODERATOR:
|
||||
context.role = ROLE_MODERATOR
|
||||
break
|
||||
case RoleNames.MODERATOR_AI:
|
||||
context.role = ROLE_MODERATOR_AI
|
||||
break
|
||||
default:
|
||||
context.role = ROLE_USER
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ export enum RoleNames {
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
USER = 'USER',
|
||||
MODERATOR = 'MODERATOR',
|
||||
MODERATOR_AI = 'MODERATOR_AI',
|
||||
ADMIN = 'ADMIN',
|
||||
DLT_CONNECTOR = 'DLT_CONNECTOR_ROLE',
|
||||
}
|
||||
|
||||
14
backend/src/graphql/input/OpenaiMessage.ts
Normal file
14
backend/src/graphql/input/OpenaiMessage.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { IsOptional, IsString } from 'class-validator'
|
||||
import { Field, InputType } from 'type-graphql'
|
||||
|
||||
@InputType()
|
||||
export class OpenaiMessage {
|
||||
@Field()
|
||||
@IsString()
|
||||
message: string
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
threadId?: string | null
|
||||
}
|
||||
19
backend/src/graphql/model/ChatGptMessage.ts
Normal file
19
backend/src/graphql/model/ChatGptMessage.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { ObjectType, Field } from 'type-graphql'
|
||||
|
||||
import { Message } from '@/apis/openai/model/Message'
|
||||
|
||||
@ObjectType()
|
||||
export class ChatGptMessage {
|
||||
@Field()
|
||||
content: string
|
||||
|
||||
@Field()
|
||||
role: string
|
||||
|
||||
@Field()
|
||||
threadId: string
|
||||
|
||||
public constructor(data: Partial<Message>) {
|
||||
Object.assign(this, data)
|
||||
}
|
||||
}
|
||||
60
backend/src/graphql/resolver/AiChatResolver.ts
Normal file
60
backend/src/graphql/resolver/AiChatResolver.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { Resolver, Mutation, Authorized, Ctx, Arg, Query } from 'type-graphql'
|
||||
|
||||
import { OpenaiMessage } from '@input/OpenaiMessage'
|
||||
import { ChatGptMessage } from '@model/ChatGptMessage'
|
||||
|
||||
import { Message } from '@/apis/openai/model/Message'
|
||||
import { OpenaiClient } from '@/apis/openai/OpenaiClient'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
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(
|
||||
@Arg('input') { message, threadId = null }: OpenaiMessage,
|
||||
@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 messageObj = new Message(message)
|
||||
if (!threadId || threadId.length === 0) {
|
||||
threadId = await openaiClient.createThread(messageObj, context.user)
|
||||
} else {
|
||||
await openaiClient.addMessage(messageObj, threadId)
|
||||
}
|
||||
const resultMessage = new ChatGptMessage(await openaiClient.runAndGetLastNewMessage(threadId))
|
||||
resultMessage.threadId = threadId
|
||||
return resultMessage
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// eslint-disable-next-line import/no-unassigned-import
|
||||
import 'openai/shims/node'
|
||||
import { CONFIG } from '@/config'
|
||||
import { i18n } from '@/server/localization'
|
||||
import { backendLogger as logger } from '@/server/logger'
|
||||
|
||||
5958
backend/yarn.lock
5958
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -150,6 +150,11 @@ export const LOGIN_SERVER_KEY = Joi.string()
|
||||
.description('Server key for password hashing as additional salt for libsodium crypto_shorthash_keygen')
|
||||
.required()
|
||||
|
||||
export const OPENAI_ACTIVE = Joi.boolean()
|
||||
.default(false)
|
||||
.description('Flag to enable or disable OpenAI API')
|
||||
.required()
|
||||
|
||||
export const TYPEORM_LOGGING_RELATIVE_PATH = Joi.string()
|
||||
.pattern(new RegExp('^[a-zA-Z0-9-_\./]+\.log$'))
|
||||
.message('TYPEORM_LOGGING_RELATIVE_PATH must be a valid filename ending with .log')
|
||||
|
||||
13
database/entity/0089-add_openai_threads/OpenaiThreads.ts
Normal file
13
database/entity/0089-add_openai_threads/OpenaiThreads.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { BaseEntity, Entity, PrimaryColumn, CreateDateColumn, Column } from 'typeorm'
|
||||
|
||||
@Entity('openai_threads')
|
||||
export class OpenaiThreads extends BaseEntity {
|
||||
@PrimaryColumn({ type: 'char', length: 30 })
|
||||
id: string
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt: Date
|
||||
|
||||
@Column({ name: 'user_id', type: 'int', unsigned: true })
|
||||
userId: number
|
||||
}
|
||||
1
database/entity/OpenaiThreads.ts
Normal file
1
database/entity/OpenaiThreads.ts
Normal file
@ -0,0 +1 @@
|
||||
export { OpenaiThreads } from './0089-add_openai_threads/OpenaiThreads'
|
||||
@ -3,6 +3,7 @@ import { LoginElopageBuys } from './LoginElopageBuys'
|
||||
import { LoginEmailOptIn } from './LoginEmailOptIn'
|
||||
import { Migration } from './Migration'
|
||||
import { ProjectBranding } from './ProjectBranding'
|
||||
import { OpenaiThreads } from './OpenaiThreads'
|
||||
import { Transaction } from './Transaction'
|
||||
import { TransactionLink } from './TransactionLink'
|
||||
import { User } from './User'
|
||||
@ -28,6 +29,7 @@ export const entities = [
|
||||
LoginEmailOptIn,
|
||||
Migration,
|
||||
ProjectBranding,
|
||||
OpenaiThreads,
|
||||
PendingTransaction,
|
||||
Transaction,
|
||||
TransactionLink,
|
||||
|
||||
16
database/migrations/0089-add_openai_threads.ts
Normal file
16
database/migrations/0089-add_openai_threads.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
|
||||
await queryFn(`
|
||||
CREATE TABLE openai_threads (
|
||||
id VARCHAR(128) PRIMARY KEY,
|
||||
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
user_id int(10) unsigned NOT NULL
|
||||
) ENGINE = InnoDB;
|
||||
`)
|
||||
}
|
||||
|
||||
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
|
||||
await queryFn(`DROP TABLE openai_threads`)
|
||||
}
|
||||
@ -133,3 +133,8 @@ GMS_CREATE_USER_THROW_ERRORS=false
|
||||
HUMHUB_ACTIVE=false
|
||||
HUMHUB_API_URL=https://community.gradido.net
|
||||
HUMHUB_JWT_KEY=
|
||||
|
||||
# OPENAI
|
||||
OPENAI_ACTIVE=false
|
||||
OPENAI_API_KEY=''
|
||||
OPENAI_ASSISTANT_ID=asst_MF5cchZr7wY7rNXayuWvZFsM
|
||||
Loading…
x
Reference in New Issue
Block a user