Merge pull request #3456 from gradido/admin_add_ai_chat

feat(admin): add ai chat
This commit is contained in:
einhornimmond 2025-03-26 18:50:23 +01:00 committed by GitHub
commit 96bacae6ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 3683 additions and 3059 deletions

View File

@ -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

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
admin/public/img/Crea.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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>

View File

@ -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') },
])

View File

@ -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"

View File

@ -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,

View File

@ -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()

View 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
}
}

View File

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

View File

@ -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}“.",

View File

@ -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}\".",

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

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

View File

@ -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',

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

@ -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'],

View File

@ -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",

View 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 ''
}
}
}

View 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
}
}

View File

@ -0,0 +1,3 @@
import { RIGHTS } from './RIGHTS'
export const MODERATOR_AI_RIGHTS = [RIGHTS.AI_SEND_MESSAGE]

View File

@ -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',

View File

@ -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,
])

View File

@ -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)

View File

@ -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(

View File

@ -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
}

View File

@ -4,6 +4,7 @@ export enum RoleNames {
UNAUTHORIZED = 'UNAUTHORIZED',
USER = 'USER',
MODERATOR = 'MODERATOR',
MODERATOR_AI = 'MODERATOR_AI',
ADMIN = 'ADMIN',
DLT_CONNECTOR = 'DLT_CONNECTOR_ROLE',
}

View 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
}

View 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)
}
}

View 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
}
}

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

@ -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'

File diff suppressed because it is too large Load Diff

View File

@ -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')

View 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
}

View File

@ -0,0 +1 @@
export { OpenaiThreads } from './0089-add_openai_threads/OpenaiThreads'

View File

@ -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,

View 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`)
}

View File

@ -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