From 12c460c00b83bfa537710a79f7d75e3900109d3a Mon Sep 17 00:00:00 2001 From: Kristina Date: Wed, 30 Oct 2024 12:10:30 +0400 Subject: [PATCH] Use rest api instead of mongo adapter (#7063) Signed-off-by: Kristina Fefelova --- .vscode/launch.json | 3 +- dev/docker-compose.yaml | 2 + models/server-ai-bot/src/index.ts | 57 +---- plugins/ai-bot/src/index.ts | 32 +-- plugins/ai-bot/src/rest.ts | 58 +++++ plugins/ai-bot/src/types.ts | 12 - pods/server/package.json | 1 - pods/server/src/__start.ts | 1 + pods/server/src/server.ts | 14 +- server-plugins/ai-bot-resources/package.json | 13 +- .../ai-bot-resources/src/adapter.ts | 70 ------ server-plugins/ai-bot-resources/src/index.ts | 77 +++---- server-plugins/ai-bot-resources/src/utils.ts | 49 ++++ server-plugins/ai-bot/src/index.ts | 3 +- server-plugins/ai-bot/src/types.ts | 8 - server/client/package.json | 1 + server/client/src/index.ts | 1 + server/client/src/token.ts | 44 ++++ services/ai-bot/pod-ai-bot/src/controller.ts | 215 ++++++------------ services/ai-bot/pod-ai-bot/src/platform.ts | 6 - .../pod-ai-bot/src/{ => server}/error.ts | 0 .../pod-ai-bot/src/{ => server}/server.ts | 88 +++---- services/ai-bot/pod-ai-bot/src/start.ts | 10 +- services/ai-bot/pod-ai-bot/src/storage.ts | 14 +- .../pod-ai-bot/src/{ => utils}/account.ts | 74 +++++- .../ai-bot/pod-ai-bot/src/utils/common.ts | 22 ++ .../ai-bot/pod-ai-bot/src/utils/openai.ts | 95 ++++++++ .../src/{utils.ts => utils/platform.ts} | 78 +------ .../ai-bot/pod-ai-bot/src/workspaceClient.ts | 96 +++----- .../pod-analytics-collector/src/server.ts | 56 +---- .../pod-telegram-bot/src/server.ts | 58 +---- 31 files changed, 559 insertions(+), 699 deletions(-) create mode 100644 plugins/ai-bot/src/rest.ts delete mode 100644 server-plugins/ai-bot-resources/src/adapter.ts create mode 100644 server/client/src/token.ts delete mode 100644 services/ai-bot/pod-ai-bot/src/platform.ts rename services/ai-bot/pod-ai-bot/src/{ => server}/error.ts (100%) rename services/ai-bot/pod-ai-bot/src/{ => server}/server.ts (65%) rename services/ai-bot/pod-ai-bot/src/{ => utils}/account.ts (58%) create mode 100644 services/ai-bot/pod-ai-bot/src/utils/common.ts create mode 100644 services/ai-bot/pod-ai-bot/src/utils/openai.ts rename services/ai-bot/pod-ai-bot/src/{utils.ts => utils/platform.ts} (53%) diff --git a/.vscode/launch.json b/.vscode/launch.json index 00ad0b4271..79f808f1f5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -54,7 +54,8 @@ "MODEL_VERSION": "0.6.287", // "VERSION": "0.6.289", "ELASTIC_INDEX_NAME": "local_storage_index", - "UPLOAD_URL": "/files", + "UPLOAD_URL": "/files", + "AI_BOT_URL": "http://localhost:4010" }, "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], "runtimeVersion": "20", diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index 19160c3415..8ddf002b86 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -257,6 +257,7 @@ services: - ELASTIC_INDEX_NAME=local_storage_index - BRANDING_PATH=/var/cfg/branding.json - SUPPORT_WORKSPACE=support + - AI_BOT_URL=http://host.docker.internal:4010 restart: unless-stopped transactor_pg: image: hardcoreeng/transactor @@ -384,6 +385,7 @@ services: - AVATAR_PATH=./avatar.png - AVATAR_CONTENT_TYPE=.png - STATS_URL=http://host.docker.internal:4900 +# - OPENAI_API_KEY=token deploy: resources: limits: diff --git a/models/server-ai-bot/src/index.ts b/models/server-ai-bot/src/index.ts index 7a1425d418..7fd057182d 100644 --- a/models/server-ai-bot/src/index.ts +++ b/models/server-ai-bot/src/index.ts @@ -13,18 +13,11 @@ // limitations under the License. // -import { type Builder, Mixin, Model, Prop, TypeRef, TypeString } from '@hcengineering/model' -import core, { type Account, type Class, type Doc, type Domain, type Ref, type Space } from '@hcengineering/core' +import { type Builder, Mixin } from '@hcengineering/model' +import core, { type Domain, type Ref } from '@hcengineering/core' import serverCore from '@hcengineering/server-core' import serverAiBot from '@hcengineering/server-ai-bot' -import { TDoc } from '@hcengineering/model-core' -import { getEmbeddedLabel } from '@hcengineering/platform' -import aiBot, { - type AIBotEvent, - type AIBotTransferEvent, - type AIBotResponseEvent, - type TransferredMessage -} from '@hcengineering/ai-bot' +import aiBot, { type TransferredMessage } from '@hcengineering/ai-bot' import chunter, { type ChatMessage } from '@hcengineering/chunter' import notification from '@hcengineering/notification' import { TChatMessage } from '@hcengineering/model-chunter' @@ -33,48 +26,6 @@ export { serverAiBotId } from '@hcengineering/server-ai-bot' export const DOMAIN_AI_BOT = 'ai_bot' as Domain -@Model(aiBot.class.AIBotEvent, core.class.Doc, DOMAIN_AI_BOT) -export class TAIBotEvent extends TDoc implements AIBotEvent { - @Prop(TypeRef(chunter.class.ChatMessage), core.string.Class) - messageClass!: Ref> - - @Prop(TypeRef(chunter.class.ChatMessage), core.string.Ref) - messageId!: Ref - - @Prop(TypeString(), getEmbeddedLabel('Collection')) - collection!: string - - @Prop(TypeString(), getEmbeddedLabel('Message')) - message!: string -} - -@Model(aiBot.class.AIBotResponseEvent, aiBot.class.AIBotEvent) -export class TAIBotResponseEvent extends TAIBotEvent implements AIBotResponseEvent { - @Prop(TypeRef(core.class.Doc), core.string.Object) - objectId!: Ref - - @Prop(TypeRef(core.class.Class), core.string.Class) - objectClass!: Ref> - - @Prop(TypeRef(core.class.Space), core.string.Space) - objectSpace!: Ref - - @Prop(TypeRef(core.class.Account), core.string.Account) - user!: Ref - - email!: string -} - -@Model(aiBot.class.AIBotTransferEvent, aiBot.class.AIBotEvent) -export class TAIBotTransferEvent extends TAIBotEvent implements AIBotTransferEvent { - toEmail!: string - toWorkspace!: string - fromWorkspace!: string - fromWorkspaceName!: string - fromWorkspaceUrl!: string - parentMessageId?: Ref -} - @Mixin(aiBot.mixin.TransferredMessage, chunter.class.ChatMessage) export class TTransferredMessage extends TChatMessage implements TransferredMessage { messageId!: Ref @@ -82,7 +33,7 @@ export class TTransferredMessage extends TChatMessage implements TransferredMess } export function createModel (builder: Builder): void { - builder.createModel(TAIBotEvent, TAIBotTransferEvent, TAIBotResponseEvent, TTransferredMessage) + builder.createModel(TTransferredMessage) builder.createDoc(serverCore.class.Trigger, core.space.Model, { trigger: serverAiBot.trigger.OnMessageSend, diff --git a/plugins/ai-bot/src/index.ts b/plugins/ai-bot/src/index.ts index e7748b0129..483c317c49 100644 --- a/plugins/ai-bot/src/index.ts +++ b/plugins/ai-bot/src/index.ts @@ -13,41 +13,18 @@ // limitations under the License. // -import { Account, Class, Doc, type Mixin, Ref, Space } from '@hcengineering/core' +import { Account, type Mixin, Ref } from '@hcengineering/core' import type { Metadata, Plugin } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' import { ChatMessage } from '@hcengineering/chunter' export * from './types' +export * from './rest' export const aiBotId = 'ai-bot' as Plugin export const aiBotAccountEmail = 'huly.ai.bot@hc.engineering' -export interface AIBotEvent extends Doc { - collection: string - messageClass: Ref> - messageId: Ref - message: string -} - -export interface AIBotResponseEvent extends AIBotEvent { - objectId: Ref - objectClass: Ref> - objectSpace: Ref - user: Ref - email: string -} - -export interface AIBotTransferEvent extends AIBotEvent { - toEmail: string - toWorkspace: string - fromWorkspace: string - fromWorkspaceName: string - fromWorkspaceUrl: string - parentMessageId?: Ref -} - export interface TransferredMessage extends ChatMessage { messageId: Ref parentMessageId?: Ref @@ -57,11 +34,6 @@ const aiBot = plugin(aiBotId, { metadata: { EndpointURL: '' as Metadata }, - class: { - AIBotEvent: '' as Ref>, - AIBotTransferEvent: '' as Ref>, - AIBotResponseEvent: '' as Ref> - }, mixin: { TransferredMessage: '' as Ref> }, diff --git a/plugins/ai-bot/src/rest.ts b/plugins/ai-bot/src/rest.ts new file mode 100644 index 0000000000..4a75914b7b --- /dev/null +++ b/plugins/ai-bot/src/rest.ts @@ -0,0 +1,58 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Account, Class, Doc, Markup, Ref, Space, Timestamp } from '@hcengineering/core' +import { ChatMessage } from '@hcengineering/chunter' + +export enum AIEventType { + Message = 'message', + Transfer = 'transfer' +} + +export interface AIEventRequest { + type: AIEventType + collection: string + messageClass: Ref> + messageId: Ref + message: string + createdOn: Timestamp +} + +export interface AIMessageEventRequest extends AIEventRequest { + objectId: Ref + objectClass: Ref> + objectSpace: Ref + user: Ref + email: string +} + +export interface AITransferEventRequest extends AIEventRequest { + toEmail: string + toWorkspace: string + fromWorkspace: string + fromWorkspaceName: string + fromWorkspaceUrl: string + parentMessageId?: Ref +} + +export interface TranslateRequest { + text: Markup + lang: string +} + +export interface TranslateResponse { + text: Markup + lang: string +} diff --git a/plugins/ai-bot/src/types.ts b/plugins/ai-bot/src/types.ts index 8f999d2452..5d447977d0 100644 --- a/plugins/ai-bot/src/types.ts +++ b/plugins/ai-bot/src/types.ts @@ -13,18 +13,6 @@ // limitations under the License. // -import { Markup } from '@hcengineering/core' - -export interface TranslateRequest { - text: Markup - lang: string -} - -export interface TranslateResponse { - text: Markup - lang: string -} - export enum OnboardingEvent { OpenChatInSidebar = 'openChatInSidebar' } diff --git a/pods/server/package.json b/pods/server/package.json index fe0c10e910..d58215176e 100644 --- a/pods/server/package.json +++ b/pods/server/package.json @@ -73,7 +73,6 @@ "@hcengineering/server-telegram": "^0.6.0", "@hcengineering/pod-telegram-bot": "^0.6.0", "@hcengineering/server-ai-bot": "^0.6.0", - "@hcengineering/server-ai-bot-resources": "^0.6.0", "ws": "^8.18.0", "bufferutil": "^4.0.8", "msgpackr": "^1.11.0", diff --git a/pods/server/src/__start.ts b/pods/server/src/__start.ts index 39559b6c7c..43c51895ab 100644 --- a/pods/server/src/__start.ts +++ b/pods/server/src/__start.ts @@ -72,6 +72,7 @@ setMetadata(serverCore.metadata.ElasticIndexName, config.elasticIndexName) setMetadata(serverCore.metadata.ElasticIndexVersion, 'v1') setMetadata(serverTelegram.metadata.BotUrl, process.env.TELEGRAM_BOT_URL) setMetadata(serverAiBot.metadata.SupportWorkspaceId, process.env.SUPPORT_WORKSPACE) +setMetadata(serverAiBot.metadata.EndpointURL, process.env.AI_BOT_URL) const { shutdown, sessionManager } = start(metricsContext, config.dbUrl, { fullTextUrl: config.elasticUrl, diff --git a/pods/server/src/server.ts b/pods/server/src/server.ts index 12b5d7f666..789a2bb05f 100644 --- a/pods/server/src/server.ts +++ b/pods/server/src/server.ts @@ -33,8 +33,6 @@ import { } from '@hcengineering/server-core' import { type Token } from '@hcengineering/server-token' -import { serverAiBotId } from '@hcengineering/server-ai-bot' -import { createAIBotAdapter } from '@hcengineering/server-ai-bot-resources' import { createServerPipeline, registerServerPlugins, registerStringLoaders } from '@hcengineering/server-pipeline' import { readFileSync } from 'node:fs' @@ -80,17 +78,7 @@ export function start ( dbUrl, model, { ...opt, externalStorage, adapterSecurity: dbUrl.startsWith('postgresql') }, - opt.mongoUrl !== undefined - ? { - serviceAdapters: { - [serverAiBotId]: { - factory: createAIBotAdapter, - db: '%ai-bot', - url: opt.mongoUrl - } - } - } - : {} + {} ) const sessionFactory = ( token: Token, diff --git a/server-plugins/ai-bot-resources/package.json b/server-plugins/ai-bot-resources/package.json index 8b3938c874..490e9dd0fa 100644 --- a/server-plugins/ai-bot-resources/package.json +++ b/server-plugins/ai-bot-resources/package.json @@ -37,19 +37,18 @@ }, "dependencies": { "@hcengineering/activity": "^0.6.0", + "@hcengineering/ai-bot": "^0.6.0", + "@hcengineering/analytics-collector": "^0.6.0", "@hcengineering/chunter": "^0.6.20", "@hcengineering/contact": "^0.6.24", "@hcengineering/core": "^0.6.32", - "@hcengineering/mongo": "^0.6.1", "@hcengineering/notification": "^0.6.23", "@hcengineering/platform": "^0.6.11", "@hcengineering/server-activity-resources": "^0.6.0", - "@hcengineering/server-core": "^0.6.1", - "@hcengineering/server-templates": "^0.6.0", - "@hcengineering/templates": "^0.6.11", - "@hcengineering/ai-bot": "^0.6.0", "@hcengineering/server-ai-bot": "^0.6.0", - "@hcengineering/analytics-collector": "^0.6.0", - "mongodb": "6.9.0-dev.20241016.sha.3d5bd513" + "@hcengineering/server-core": "^0.6.1", + "@hcengineering/server-token": "^0.6.11", + "@hcengineering/server-templates": "^0.6.0", + "@hcengineering/templates": "^0.6.11" } } diff --git a/server-plugins/ai-bot-resources/src/adapter.ts b/server-plugins/ai-bot-resources/src/adapter.ts deleted file mode 100644 index 9668f3534d..0000000000 --- a/server-plugins/ai-bot-resources/src/adapter.ts +++ /dev/null @@ -1,70 +0,0 @@ -// -// Copyright © 2024 Hardcore Engineering Inc. -// -// Licensed under the Eclipse Public License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. You may -// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import { MeasureContext, WorkspaceId } from '@hcengineering/core' -import { getMongoClient, MongoClientReference } from '@hcengineering/mongo' -import { AIBotServiceAdapter, WorkspaceInfoRecord } from '@hcengineering/server-ai-bot' -import { Collection, Db, MongoClient } from 'mongodb' - -class AIBotAdapter implements AIBotServiceAdapter { - private readonly workspacesInfoCollection: Collection - private readonly db: Db - closed = false - - constructor ( - private readonly _client: MongoClientReference, - private readonly client: MongoClient, - private readonly _metrics: MeasureContext, - private readonly dbName: string - ) { - this.db = client.db(dbName) - this.workspacesInfoCollection = this.db.collection('workspacesInfo') - } - - async processWorkspace (workspace: WorkspaceId): Promise { - if (this.closed) { - return - } - const existsRecord = await this.workspacesInfoCollection.findOne({ - workspace: workspace.name - }) - - if (existsRecord != null && !existsRecord.active) { - await this.workspacesInfoCollection.updateOne({ workspace: workspace.name }, { $set: { active: true } }) - } else if (existsRecord == null) { - const record: WorkspaceInfoRecord = { - workspace: workspace.name, - active: true - } - - await this.workspacesInfoCollection.insertOne(record) - } - } - - async close (): Promise { - this.closed = true - this._client.close() - } - - metrics (): MeasureContext { - return this._metrics - } -} - -export async function createAIBotAdapter (url: string, db: string, metrics: MeasureContext): Promise { - const _client = getMongoClient(url) - - return new AIBotAdapter(_client, await _client.getClient(), metrics, db) -} diff --git a/server-plugins/ai-bot-resources/src/index.ts b/server-plugins/ai-bot-resources/src/index.ts index e0ea3e3fbc..c6910e4280 100644 --- a/server-plugins/ai-bot-resources/src/index.ts +++ b/server-plugins/ai-bot-resources/src/index.ts @@ -16,7 +16,6 @@ import core, { AccountRole, AttachedDoc, - Data, Doc, Ref, toWorkspaceString, @@ -30,22 +29,17 @@ import core, { } from '@hcengineering/core' import { TriggerControl } from '@hcengineering/server-core' import chunter, { ChatMessage, DirectMessage, ThreadMessage } from '@hcengineering/chunter' -import aiBot, { aiBotAccountEmail, AIBotResponseEvent } from '@hcengineering/ai-bot' -import { AIBotServiceAdapter, serverAiBotId } from '@hcengineering/server-ai-bot' +import aiBot, { + aiBotAccountEmail, + AIEventType, + AIMessageEventRequest, + AITransferEventRequest +} from '@hcengineering/ai-bot' import contact, { PersonAccount } from '@hcengineering/contact' import { ActivityInboxNotification, MentionInboxNotification } from '@hcengineering/notification' import analyticsCollector, { OnboardingChannel } from '@hcengineering/analytics-collector' -import { getSupportWorkspaceId } from './utils' -async function processWorkspace (control: TriggerControl): Promise { - const adapter = control.serviceAdaptersManager.getAdapter(serverAiBotId) as AIBotServiceAdapter | undefined - - if (adapter !== undefined) { - await adapter.processWorkspace(control.workspace) - } else { - console.error('Cannot find server adapter: ', serverAiBotId) - } -} +import { createAccountRequest, getSupportWorkspaceId, sendAIEvents } from './utils' async function isDirectAvailable (direct: DirectMessage, control: TriggerControl): Promise { const { members } = direct @@ -77,8 +71,10 @@ async function getMessageDoc (message: ChatMessage, control: TriggerControl): Pr } } -function getMessageData (doc: Doc, message: ChatMessage, email: string): Data { +function getMessageData (doc: Doc, message: ChatMessage, email: string): AIMessageEventRequest { return { + type: AIEventType.Message, + createdOn: message.createdOn ?? message.modifiedOn, objectId: message.attachedTo, objectClass: message.attachedToClass, objectSpace: doc.space, @@ -91,8 +87,10 @@ function getMessageData (doc: Doc, message: ChatMessage, email: string): Data { +function getThreadMessageData (message: ThreadMessage, email: string): AIMessageEventRequest { return { + type: AIEventType.Message, + createdOn: message.createdOn ?? message.modifiedOn, objectId: message.attachedTo, objectClass: message.attachedToClass, objectSpace: message.space, @@ -105,15 +103,6 @@ function getThreadMessageData (message: ThreadMessage, email: string): Data -): Promise { - const eventTx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotResponseEvent, message.space, data) - await control.apply(control.ctx, [eventTx]) -} - async function getThreadParent (control: TriggerControl, message: ChatMessage): Promise | undefined> { if (!control.hierarchy.isDerived(message.attachedToClass, chunter.class.ChatMessage)) { return undefined @@ -137,8 +126,8 @@ async function createTransferEvent ( control: TriggerControl, message: ChatMessage, account: PersonAccount, - data: Data -): Promise { + data: AIMessageEventRequest +): Promise { if (account.role !== AccountRole.Owner) { return } @@ -149,7 +138,9 @@ async function createTransferEvent ( return } - const eventTx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotTransferEvent, message.space, { + return { + type: AIEventType.Transfer, + createdOn: message.createdOn ?? message.modifiedOn, messageClass: data.messageClass, message: message.message, collection: data.collection, @@ -160,9 +151,7 @@ async function createTransferEvent ( fromWorkspaceUrl: control.workspace.workspaceUrl, messageId: message._id, parentMessageId: await getThreadParent(control, message) - }) - - await control.apply(control.ctx, [eventTx]) + } } async function onBotDirectMessageSend (control: TriggerControl, message: ChatMessage): Promise { @@ -186,18 +175,18 @@ async function onBotDirectMessageSend (control: TriggerControl, message: ChatMes return } - let data: Data | undefined + let messageEvent: AIMessageEventRequest if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { - data = getThreadMessageData(message as ThreadMessage, account.email) + messageEvent = getThreadMessageData(message as ThreadMessage, account.email) } else { - data = getMessageData(direct, message, account.email) + messageEvent = getMessageData(direct, message, account.email) } - await createResponseEvent(message, control, data) - await createTransferEvent(control, message, account, data) + const transferEvent = await createTransferEvent(control, message, account, messageEvent) + const events = transferEvent !== undefined ? [messageEvent, transferEvent] : [messageEvent] - await processWorkspace(control) + await sendAIEvents(events, control.workspace, control.ctx) } async function onSupportWorkspaceMessage (control: TriggerControl, message: ChatMessage): Promise { @@ -222,18 +211,20 @@ async function onSupportWorkspaceMessage (control: TriggerControl, message: Chat } const { workspaceId, email } = channel - let data: Data | undefined const account = control.modelDb.findAllSync(contact.class.PersonAccount, { _id: (message.createdBy ?? message.modifiedBy) as Ref })[0] + let data: AIMessageEventRequest if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { data = getThreadMessageData(message as ThreadMessage, account.email) } else { data = getMessageData(channel, message, account.email) } - const tx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotTransferEvent, message.space, { + const transferEvent: AITransferEventRequest = { + type: AIEventType.Transfer, + createdOn: data.createdOn, messageClass: data.messageClass, message: message.message, collection: data.collection, @@ -244,11 +235,9 @@ async function onSupportWorkspaceMessage (control: TriggerControl, message: Chat fromWorkspaceName: control.workspace.workspaceName, messageId: message._id, parentMessageId: await getThreadParent(control, message) - }) + } - await control.apply(control.ctx, [tx]) - - await processWorkspace(control) + await sendAIEvents([transferEvent], control.workspace, control.ctx) } export async function OnMessageSend ( @@ -407,7 +396,7 @@ export async function OnUserStatus (originTx: Tx, control: TriggerControl): Prom return [] } - await processWorkspace(control) + await createAccountRequest(control.workspace, control.ctx) return [] } @@ -421,5 +410,3 @@ export default async () => ({ OnUserStatus } }) - -export * from './adapter' diff --git a/server-plugins/ai-bot-resources/src/utils.ts b/server-plugins/ai-bot-resources/src/utils.ts index 321d3963ad..a3f6366b18 100644 --- a/server-plugins/ai-bot-resources/src/utils.ts +++ b/server-plugins/ai-bot-resources/src/utils.ts @@ -15,6 +15,9 @@ import { getMetadata } from '@hcengineering/platform' import serverAIBot from '@hcengineering/server-ai-bot' +import { AIEventRequest } from '@hcengineering/ai-bot' +import { concatLink, MeasureContext, systemAccountEmail, WorkspaceId } from '@hcengineering/core' +import { generateToken } from '@hcengineering/server-token' export function getSupportWorkspaceId (): string | undefined { const supportWorkspaceId = getMetadata(serverAIBot.metadata.SupportWorkspaceId) @@ -25,3 +28,49 @@ export function getSupportWorkspaceId (): string | undefined { return supportWorkspaceId } + +export async function sendAIEvents ( + events: AIEventRequest[], + workspace: WorkspaceId, + ctx: MeasureContext +): Promise { + const url = getMetadata(serverAIBot.metadata.EndpointURL) ?? '' + + if (url === '') { + return + } + + try { + await fetch(concatLink(url, '/events'), { + method: 'POST', + headers: { + Authorization: 'Bearer ' + generateToken(systemAccountEmail, workspace), + 'Content-Type': 'application/json' + }, + body: JSON.stringify(events) + }) + } catch (err) { + ctx.error('Could not send ai events', { err }) + } +} + +export async function createAccountRequest (workspace: WorkspaceId, ctx: MeasureContext): Promise { + const url = getMetadata(serverAIBot.metadata.EndpointURL) ?? '' + + if (url === '') { + return + } + + try { + await fetch(concatLink(url, '/connect'), { + method: 'POST', + headers: { + Authorization: 'Bearer ' + generateToken(systemAccountEmail, workspace), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({}) + }) + } catch (err) { + ctx.error('Could not send create ai account request', { err }) + } +} diff --git a/server-plugins/ai-bot/src/index.ts b/server-plugins/ai-bot/src/index.ts index 29380a1f13..8683a1f5f1 100644 --- a/server-plugins/ai-bot/src/index.ts +++ b/server-plugins/ai-bot/src/index.ts @@ -22,7 +22,8 @@ export const serverAiBotId = 'server-ai-bot' as Plugin export default plugin(serverAiBotId, { metadata: { - SupportWorkspaceId: '' as Metadata + SupportWorkspaceId: '' as Metadata, + EndpointURL: '' as Metadata }, trigger: { OnMessageSend: '' as Resource, diff --git a/server-plugins/ai-bot/src/types.ts b/server-plugins/ai-bot/src/types.ts index 96ae3e5dba..5971fe5810 100644 --- a/server-plugins/ai-bot/src/types.ts +++ b/server-plugins/ai-bot/src/types.ts @@ -13,16 +13,8 @@ // limitations under the License. // -import { ServiceAdapter } from '@hcengineering/server-core' -import { WorkspaceId } from '@hcengineering/core' - -export interface AIBotServiceAdapter extends ServiceAdapter { - processWorkspace: (workspace: WorkspaceId) => Promise -} - export interface WorkspaceInfoRecord { workspace: string - active: boolean avatarPath?: string avatarLastModified?: number } diff --git a/server/client/package.json b/server/client/package.json index 4c90504459..02b4b8dafa 100644 --- a/server/client/package.json +++ b/server/client/package.json @@ -45,6 +45,7 @@ "@hcengineering/client-resources": "^0.6.27", "@hcengineering/client": "^0.6.18", "@hcengineering/server-core": "^0.6.1", + "@hcengineering/server-token": "^0.6.11", "ws": "^8.18.0" } } diff --git a/server/client/src/index.ts b/server/client/src/index.ts index 1ffa2ffbb3..eacb0dbe92 100644 --- a/server/client/src/index.ts +++ b/server/client/src/index.ts @@ -21,3 +21,4 @@ export default plugin export * from './account' export * from './blob' export * from './client' +export * from './token' diff --git a/server/client/src/token.ts b/server/client/src/token.ts new file mode 100644 index 0000000000..cef8bcded1 --- /dev/null +++ b/server/client/src/token.ts @@ -0,0 +1,44 @@ +import { Token, decodeToken } from '@hcengineering/server-token' +import { IncomingHttpHeaders } from 'http' + +const extractCookieToken = (cookie?: string): Token | null => { + if (cookie === undefined || cookie === null) { + return null + } + + const cookies = cookie.split(';') + const tokenCookie = cookies.find((cookie) => cookie.toLocaleLowerCase().includes('token')) + if (tokenCookie === undefined) { + return null + } + + const encodedToken = tokenCookie.split('=')[1] + if (encodedToken === undefined) { + return null + } + + return decodeToken(encodedToken) +} + +const extractAuthorizationToken = (authorization?: string): Token | null => { + if (authorization === undefined || authorization === null) { + return null + } + const encodedToken = authorization.split(' ')[1] + + if (encodedToken === undefined) { + return null + } + + return decodeToken(encodedToken) +} + +export function extractToken (headers: IncomingHttpHeaders): Token | undefined { + try { + const token = extractCookieToken(headers.cookie) ?? extractAuthorizationToken(headers.authorization) + + return token ?? undefined + } catch { + return undefined + } +} diff --git a/services/ai-bot/pod-ai-bot/src/controller.ts b/services/ai-bot/pod-ai-bot/src/controller.ts index 99574f2711..4abfb09b87 100644 --- a/services/ai-bot/pod-ai-bot/src/controller.ts +++ b/services/ai-bot/pod-ai-bot/src/controller.ts @@ -13,10 +13,13 @@ // limitations under the License. // -import { isWorkspaceCreating, Markup, MeasureContext, systemAccountEmail } from '@hcengineering/core' +import { Markup, MeasureContext } from '@hcengineering/core' import { aiBotAccountEmail, - AIBotTransferEvent, + AIEventRequest, + AIEventType, + AIMessageEventRequest, + AITransferEventRequest, OnboardingEvent, OnboardingEventRequest, OpenChatInSidebarData, @@ -26,38 +29,30 @@ import { import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot' import { getTransactorEndpoint } from '@hcengineering/server-client' import { generateToken } from '@hcengineering/server-token' -import { WorkspaceLoginInfo } from '@hcengineering/account' import OpenAI from 'openai' import { encodingForModel } from 'js-tiktoken' import { htmlToMarkup, markupToHTML } from '@hcengineering/text' import { WorkspaceClient } from './workspaceClient' -import { assignBotToWorkspace, getWorkspaceInfo } from './account' import config from './config' import { DbStorage } from './storage' import { SupportWsClient } from './supportWsClient' import { AIReplyTransferData } from './types' +import { tryAssignToWorkspace } from './utils/account' +import { translateHtml } from './utils/openai' -const POLLING_INTERVAL_MS = 5 * 1000 // 5 seconds const CLOSE_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes -const ASSIGN_WORKSPACE_DELAY_MS = 5 * 1000 // 5 secs -const MAX_ASSIGN_ATTEMPTS = 5 -export class AIBotController { +export class AIControl { private readonly workspaces: Map = new Map() private readonly closeWorkspaceTimeouts: Map = new Map() private readonly connectingWorkspaces: Set = new Set() - private readonly intervalId: NodeJS.Timeout - readonly aiClient?: OpenAI readonly encoding = encodingForModel(config.OpenAIModel) supportClient: SupportWsClient | undefined = undefined - assignTimeout: NodeJS.Timeout | undefined - assignAttempts = 0 - constructor ( readonly storage: DbStorage, private readonly ctx: MeasureContext @@ -70,37 +65,20 @@ export class AIBotController { }) : undefined - this.intervalId = setInterval(() => { - void this.updateWorkspaceClients() - }, POLLING_INTERVAL_MS) + void this.connectSupportWorkspace() } - async updateWorkspaceClients (): Promise { - const activeRecords = await this.storage.getActiveWorkspaces() + async getWorkspaceRecord (workspace: string): Promise { + return (await this.storage.getWorkspace(workspace)) ?? { workspace: config.SupportWorkspace } + } + async connectSupportWorkspace (): Promise { if (this.supportClient === undefined && !this.connectingWorkspaces.has(config.SupportWorkspace)) { this.connectingWorkspaces.add(config.SupportWorkspace) - const record = await this.storage.getWorkspace(config.SupportWorkspace) - this.supportClient = (await this.createWorkspaceClient( - config.SupportWorkspace, - record ?? { workspace: config.SupportWorkspace, active: true } - )) as SupportWsClient + const record = await this.getWorkspaceRecord(config.SupportWorkspace) + this.supportClient = (await this.createWorkspaceClient(config.SupportWorkspace, record)) as SupportWsClient this.connectingWorkspaces.delete(config.SupportWorkspace) } - - for (const record of activeRecords) { - const ws = record.workspace - - if (this.workspaces.has(ws)) { - continue - } - - if (this.connectingWorkspaces.has(ws)) { - continue - } - - await this.initWorkspaceClient(ws, record) - } } async closeWorkspaceClient (workspace: string): Promise { @@ -111,8 +89,6 @@ export class AIBotController { this.closeWorkspaceTimeouts.delete(workspace) } - await this.storage.inactiveWorkspace(workspace) - const client = this.workspaces.get(workspace) if (client !== undefined) { @@ -122,65 +98,18 @@ export class AIBotController { this.connectingWorkspaces.delete(workspace) } - private async getWorkspaceInfo (ws: string): Promise { - const systemToken = generateToken(systemAccountEmail, { name: ws }) - for (let i = 0; i < 5; i++) { - try { - const info = await getWorkspaceInfo(systemToken) + async createWorkspaceClient (workspace: string, info: WorkspaceInfoRecord): Promise { + const isAssigned = await tryAssignToWorkspace(workspace, this.ctx) - if (info == null) { - this.ctx.warn('Cannot find workspace info', { workspace: ws }) - await wait(ASSIGN_WORKSPACE_DELAY_MS) - continue - } - - return info - } catch (e) { - this.ctx.error('Error during get workspace info:', { e }) - await wait(ASSIGN_WORKSPACE_DELAY_MS) - } + if (!isAssigned) { + return } - } - private async assignToWorkspace (workspace: string): Promise { - clearTimeout(this.assignTimeout) - try { - const info = await this.getWorkspaceInfo(workspace) - - if (info === undefined) { - void this.closeWorkspaceClient(workspace) - return - } - - if (isWorkspaceCreating(info?.mode)) { - this.ctx.info('Workspace is creating -> waiting...', { workspace }) - this.assignTimeout = setTimeout(() => { - void this.assignToWorkspace(workspace) - }, ASSIGN_WORKSPACE_DELAY_MS) - return - } - - const result = await assignBotToWorkspace(workspace) - this.ctx.info('Assign to workspace result: ', { result, workspace }) - } catch (e) { - this.ctx.error('Error during assign workspace:', { e }) - if (this.assignAttempts < MAX_ASSIGN_ATTEMPTS) { - this.assignAttempts++ - this.assignTimeout = setTimeout(() => { - void this.assignToWorkspace(workspace) - }, ASSIGN_WORKSPACE_DELAY_MS) - } else { - void this.closeWorkspaceClient(workspace) - } - } - } - - async createWorkspaceClient (workspace: string, info: WorkspaceInfoRecord): Promise { - this.ctx.info('Listen workspace: ', { workspace }) - await this.assignToWorkspace(workspace) const token = generateToken(aiBotAccountEmail, { name: workspace }) const endpoint = await getTransactorEndpoint(token) + this.ctx.info('Listen workspace: ', { workspace }) + if (workspace === config.SupportWorkspace) { return new SupportWsClient(endpoint, token, workspace, this, this.ctx.newChild(workspace, {}), info) } @@ -188,14 +117,19 @@ export class AIBotController { return new WorkspaceClient(endpoint, token, workspace, this, this.ctx.newChild(workspace, {}), info) } - async initWorkspaceClient (workspace: string, info: WorkspaceInfoRecord): Promise { + async initWorkspaceClient (workspace: string): Promise { if (workspace === config.SupportWorkspace) { return } this.connectingWorkspaces.add(workspace) if (!this.workspaces.has(workspace)) { - const client = await this.createWorkspaceClient(workspace, info) + const record = await this.getWorkspaceRecord(workspace) + const client = await this.createWorkspaceClient(workspace, record) + if (client === undefined) { + this.connectingWorkspaces.delete(workspace) + return + } this.workspaces.set(workspace, client) } @@ -226,9 +160,8 @@ export class AIBotController { await this.supportClient.transferAIReply(response, data) } - async transfer (event: AIBotTransferEvent): Promise { + async transfer (event: AITransferEventRequest): Promise { const workspace = event.toWorkspace - const info = await this.storage.getWorkspace(workspace) if (workspace === config.SupportWorkspace) { if (this.supportClient === undefined) return @@ -237,25 +170,13 @@ export class AIBotController { return } - if (info === undefined) { - this.ctx.error('Workspace info not found -> cannot transfer event', { workspace }) - return - } - - await this.initWorkspaceClient(workspace, info) - - const wsClient = this.workspaces.get(workspace) - - if (wsClient === undefined) { - return - } + const wsClient = await this.getWorkspaceClient(workspace) + if (wsClient === undefined) return await wsClient.transfer(event) } async close (): Promise { - clearInterval(this.intervalId) - for (const workspace of this.workspaces.values()) { await workspace.close() } @@ -266,16 +187,23 @@ export class AIBotController { } async updateAvatarInfo (workspace: string, path: string, lastModified: number): Promise { - await this.storage.updateWorkspace(workspace, { $set: { avatarPath: path, avatarLastModified: lastModified } }) + const record = await this.storage.getWorkspace(workspace) + + if (record === undefined) { + await this.storage.addWorkspace({ workspace, avatarPath: path, avatarLastModified: lastModified }) + } else { + await this.storage.updateWorkspace(workspace, { $set: { avatarPath: path, avatarLastModified: lastModified } }) + } + } + + async getWorkspaceClient (workspace: string): Promise { + await this.initWorkspaceClient(workspace) + + return this.workspaces.get(workspace) } async openChatInSidebar (data: OpenChatInSidebarData): Promise { - const record = await this.storage.getWorkspace(data.workspace) - - await this.initWorkspaceClient(data.workspace, record ?? { workspace: data.workspace, active: true }) - - const wsClient = this.workspaces.get(data.workspace) - + const wsClient = await this.getWorkspaceClient(data.workspace) if (wsClient === undefined) return await wsClient.openAIChatInSidebar(data.email) } @@ -293,35 +221,38 @@ export class AIBotController { return undefined } const html = markupToHTML(req.text) - const start = Date.now() - const response = await this.aiClient.chat.completions.create({ - model: config.OpenAITranslateModel, - messages: [ - { - role: 'system', - content: `Your task is to translate the text into ${req.lang} while preserving the html structure and metadata` - }, - { - role: 'user', - content: html - } - ] - }) - const end = Date.now() - this.ctx.info('Translation time: ', { time: end - start }) - const result = response.choices[0].message.content - const text = result !== null ? htmlToMarkup(result) : req.text + const result = await translateHtml(this.aiClient, html, req.lang) + const text = result !== undefined ? htmlToMarkup(result) : req.text return { text, lang: req.lang } } -} -async function wait (delay: number): Promise { - await new Promise((resolve) => { - setTimeout(() => { - resolve() - }, delay) - }) + async processMessageEvent (workspace: string, event: AIMessageEventRequest): Promise { + const wsClient = await this.getWorkspaceClient(workspace) + if (wsClient === undefined) return + + await wsClient.processMessageEvent(event) + } + + async processEvent (workspace: string, events: AIEventRequest[]): Promise { + for (const event of events) { + switch (event.type) { + case AIEventType.Transfer: + await this.transfer(event as AITransferEventRequest) + break + case AIEventType.Message: + await this.processMessageEvent(workspace, event as AIMessageEventRequest) + break + default: + this.ctx.warn('unknown event', event) + break + } + } + } + + async connect (workspace: string): Promise { + await this.initWorkspaceClient(workspace) + } } diff --git a/services/ai-bot/pod-ai-bot/src/platform.ts b/services/ai-bot/pod-ai-bot/src/platform.ts deleted file mode 100644 index 6c9813b8b3..0000000000 --- a/services/ai-bot/pod-ai-bot/src/platform.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Client } from '@hcengineering/core' -import { createClient } from '@hcengineering/server-client' - -export async function connectPlatform (token: string, endpoint: string): Promise { - return await createClient(endpoint, token) -} diff --git a/services/ai-bot/pod-ai-bot/src/error.ts b/services/ai-bot/pod-ai-bot/src/server/error.ts similarity index 100% rename from services/ai-bot/pod-ai-bot/src/error.ts rename to services/ai-bot/pod-ai-bot/src/server/error.ts diff --git a/services/ai-bot/pod-ai-bot/src/server.ts b/services/ai-bot/pod-ai-bot/src/server/server.ts similarity index 65% rename from services/ai-bot/pod-ai-bot/src/server.ts rename to services/ai-bot/pod-ai-bot/src/server/server.ts index 0bdc01c5e3..06b8a9b7fa 100644 --- a/services/ai-bot/pod-ai-bot/src/server.ts +++ b/services/ai-bot/pod-ai-bot/src/server/server.ts @@ -13,60 +13,15 @@ // limitations under the License. // -import { Token, decodeToken } from '@hcengineering/server-token' +import { Token } from '@hcengineering/server-token' import cors from 'cors' import express, { type Express, type NextFunction, type Request, type Response } from 'express' -import { IncomingHttpHeaders, type Server } from 'http' -import { TranslateRequest, OnboardingEventRequest } from '@hcengineering/ai-bot' +import { type Server } from 'http' +import { TranslateRequest, OnboardingEventRequest, AIEventRequest } from '@hcengineering/ai-bot' +import { extractToken } from '@hcengineering/server-client' import { ApiError } from './error' -import { AIBotController } from './controller' - -const extractCookieToken = (cookie?: string): Token | null => { - if (cookie === undefined || cookie === null) { - return null - } - - const cookies = cookie.split(';') - const tokenCookie = cookies.find((cookie) => cookie.toLocaleLowerCase().includes('token')) - if (tokenCookie === undefined) { - return null - } - - const encodedToken = tokenCookie.split('=')[1] - if (encodedToken === undefined) { - return null - } - - return decodeToken(encodedToken) -} - -const extractAuthorizationToken = (authorization?: string): Token | null => { - if (authorization === undefined || authorization === null) { - return null - } - const encodedToken = authorization.split(' ')[1] - - if (encodedToken === undefined) { - return null - } - - return decodeToken(encodedToken) -} - -const extractToken = (headers: IncomingHttpHeaders): Token => { - try { - const token = extractCookieToken(headers.cookie) ?? extractAuthorizationToken(headers.authorization) - - if (token === null) { - throw new ApiError(401) - } - - return token - } catch { - throw new ApiError(401) - } -} +import { AIControl } from '../controller' type AsyncRequestHandler = (req: Request, res: Response, token: Token, next: NextFunction) => Promise @@ -76,8 +31,11 @@ const handleRequest = async ( res: Response, next: NextFunction ): Promise => { + const token = extractToken(req.headers) + if (token === undefined) { + throw new ApiError(401) + } try { - const token = extractToken(req.headers) await fn(req, res, token, next) } catch (err: unknown) { next(err) @@ -88,7 +46,7 @@ const wrapRequest = (fn: AsyncRequestHandler) => (req: Request, res: Response, n void handleRequest(fn, req, res, next) } -export function createServer (controller: AIBotController): Express { +export function createServer (controller: AIControl): Express { const app = express() app.use(cors()) app.use(express.json()) @@ -109,6 +67,32 @@ export function createServer (controller: AIBotController): Express { }) ) + app.post( + '/connect', + wrapRequest(async (_, res, token) => { + await controller.connect(token.workspace.name) + + res.status(200) + res.json({}) + }) + ) + + app.post( + '/events', + wrapRequest(async (req, res, token) => { + if (req.body == null) { + throw new ApiError(400) + } + + const events = Array.isArray(req.body) ? req.body : [req.body] + + await controller.processEvent(token.workspace.name, events as AIEventRequest[]) + + res.status(200) + res.json({}) + }) + ) + app.post( '/onboarding', wrapRequest(async (req, res) => { diff --git a/services/ai-bot/pod-ai-bot/src/start.ts b/services/ai-bot/pod-ai-bot/src/start.ts index 481c125083..040e9f319f 100644 --- a/services/ai-bot/pod-ai-bot/src/start.ts +++ b/services/ai-bot/pod-ai-bot/src/start.ts @@ -17,14 +17,14 @@ import { setMetadata } from '@hcengineering/platform' import serverAiBot from '@hcengineering/server-ai-bot' import serverClient from '@hcengineering/server-client' import serverToken from '@hcengineering/server-token' - import { initStatisticsContext } from '@hcengineering/server-core' -import { createBotAccount } from './account' + import config from './config' -import { AIBotController } from './controller' +import { AIControl } from './controller' import { registerLoaders } from './loaders' -import { createServer, listen } from './server' import { closeDB, DbStorage, getDB } from './storage' +import { createBotAccount } from './utils/account' +import { createServer, listen } from './server/server' export const start = async (): Promise => { setMetadata(serverToken.metadata.Secret, config.ServerSecret) @@ -49,7 +49,7 @@ export const start = async (): Promise => { } await new Promise((resolve) => setTimeout(resolve, 3000)) } - const aiController = new AIBotController(storage, ctx) + const aiController = new AIControl(storage, ctx) const app = createServer(aiController) const server = listen(app, config.Port) diff --git a/services/ai-bot/pod-ai-bot/src/storage.ts b/services/ai-bot/pod-ai-bot/src/storage.ts index 583db8b8a9..cd8d462e0e 100644 --- a/services/ai-bot/pod-ai-bot/src/storage.ts +++ b/services/ai-bot/pod-ai-bot/src/storage.ts @@ -15,8 +15,8 @@ import { MongoClientReference, getMongoClient } from '@hcengineering/mongo' import { Collection, Db, MongoClient, ObjectId, UpdateFilter, WithId } from 'mongodb' -import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot' import { Doc, Ref, SortingOrder } from '@hcengineering/core' +import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot' import config from './config' import { HistoryRecord } from './types' @@ -61,18 +61,14 @@ export class DbStorage { await this.historyCollection.deleteMany({ _id: { $in: _ids } }) } - async getActiveWorkspaces (): Promise { - return await this.workspacesInfoCollection.find({ active: true }).toArray() - } - - async inactiveWorkspace (workspace: string): Promise { - await this.workspacesInfoCollection.updateOne({ workspace }, { $set: { active: false } }) - } - async getWorkspace (workspace: string): Promise { return (await this.workspacesInfoCollection.findOne({ workspace })) ?? undefined } + async addWorkspace (record: WorkspaceInfoRecord): Promise { + await this.workspacesInfoCollection.insertOne(record) + } + async updateWorkspace (workspace: string, update: UpdateFilter): Promise { await this.workspacesInfoCollection.updateOne({ workspace }, update) } diff --git a/services/ai-bot/pod-ai-bot/src/account.ts b/services/ai-bot/pod-ai-bot/src/utils/account.ts similarity index 58% rename from services/ai-bot/pod-ai-bot/src/account.ts rename to services/ai-bot/pod-ai-bot/src/utils/account.ts index 04290b5f66..cc5561b824 100644 --- a/services/ai-bot/pod-ai-bot/src/account.ts +++ b/services/ai-bot/pod-ai-bot/src/utils/account.ts @@ -15,10 +15,14 @@ import { LoginInfo, Workspace, WorkspaceLoginInfo } from '@hcengineering/account' import aiBot, { aiBotAccountEmail } from '@hcengineering/ai-bot' -import { AccountRole, systemAccountEmail } from '@hcengineering/core' +import { AccountRole, isWorkspaceCreating, MeasureContext, systemAccountEmail } from '@hcengineering/core' import { generateToken } from '@hcengineering/server-token' -import config from './config' +import config from '../config' +import { wait } from './common' + +const ASSIGN_WORKSPACE_DELAY_MS = 5 * 1000 // 5 secs +const MAX_ASSIGN_ATTEMPTS = 5 export async function assignBotToWorkspace (workspace: string): Promise { const token = generateToken(systemAccountEmail, { name: '-' }, { service: 'aibot' }) @@ -102,3 +106,69 @@ export async function getWorkspaceInfo (token: string): Promise { + const systemToken = generateToken(systemAccountEmail, { name: ws }) + for (let i = 0; i < 5; i++) { + try { + const info = await getWorkspaceInfo(systemToken) + + if (info == null) { + await wait(ASSIGN_WORKSPACE_DELAY_MS) + continue + } + + return info + } catch (e) { + ctx.error('Error during get workspace info:', { e }) + await wait(ASSIGN_WORKSPACE_DELAY_MS) + } + } +} + +const timeoutByWorkspace = new Map() +const attemptsByWorkspace = new Map() + +export async function tryAssignToWorkspace ( + workspace: string, + ctx: MeasureContext, + clearAttempts = true +): Promise { + if (clearAttempts) { + attemptsByWorkspace.delete(workspace) + } + clearTimeout(timeoutByWorkspace.get(workspace)) + try { + const info = await tryGetWorkspaceInfo(workspace, ctx) + + if (info === undefined) { + return false + } + + if (isWorkspaceCreating(info?.mode)) { + const t = setTimeout(() => { + void tryAssignToWorkspace(workspace, ctx, false) + }, ASSIGN_WORKSPACE_DELAY_MS) + + timeoutByWorkspace.set(workspace, t) + + return false + } + + await assignBotToWorkspace(workspace) + ctx.info('Assigned to workspace: ', { workspace }) + return true + } catch (e) { + ctx.error('Error during assign workspace:', { e }) + const attempts = attemptsByWorkspace.get(workspace) ?? 0 + if (attempts < MAX_ASSIGN_ATTEMPTS) { + attemptsByWorkspace.set(workspace, attempts + 1) + const t = setTimeout(() => { + void tryAssignToWorkspace(workspace, ctx, false) + }, ASSIGN_WORKSPACE_DELAY_MS) + timeoutByWorkspace.set(workspace, t) + } + } + + return false +} diff --git a/services/ai-bot/pod-ai-bot/src/utils/common.ts b/services/ai-bot/pod-ai-bot/src/utils/common.ts new file mode 100644 index 0000000000..c788dc9d66 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/utils/common.ts @@ -0,0 +1,22 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export async function wait (delay: number): Promise { + await new Promise((resolve) => { + setTimeout(() => { + resolve() + }, delay) + }) +} diff --git a/services/ai-bot/pod-ai-bot/src/utils/openai.ts b/services/ai-bot/pod-ai-bot/src/utils/openai.ts new file mode 100644 index 0000000000..1dec521b8c --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/utils/openai.ts @@ -0,0 +1,95 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import OpenAI from 'openai' +import { countTokens } from '@hcengineering/openai' +import { Tiktoken } from 'js-tiktoken' + +import config from '../config' +import { HistoryRecord } from '../types' + +export async function translateHtml (client: OpenAI, html: string, lang: string): Promise { + const response = await client.chat.completions.create({ + model: config.OpenAITranslateModel, + messages: [ + { + role: 'system', + content: `Your task is to translate the text into ${lang} while preserving the html structure and metadata` + }, + { + role: 'user', + content: html + } + ] + }) + + return response.choices[0].message.content ?? undefined +} + +export async function createChatCompletion ( + client: OpenAI, + message: OpenAI.ChatCompletionMessageParam, + user?: string, + history: OpenAI.ChatCompletionMessageParam[] = [], + skipCache = true +): Promise { + const opt: OpenAI.RequestOptions = {} + if (skipCache) { + opt.headers = { 'cf-skip-cache': 'true' } + } + try { + return await client.chat.completions.create( + { + messages: [...history, message], + model: config.OpenAIModel, + user, + stream: false + }, + opt + ) + } catch (e) { + console.error(e) + } + + return undefined +} + +export async function requestSummary ( + aiClient: OpenAI, + encoding: Tiktoken, + history: HistoryRecord[] +): Promise<{ + summary?: string + tokens: number + }> { + const summaryPrompt: OpenAI.ChatCompletionMessageParam = { + content: `Summarize the following messages, keeping the key points: ${history.map((msg) => `${msg.role}: ${msg.message}`).join('\n')}`, + role: 'user' + } + + const response = await createChatCompletion(aiClient, summaryPrompt, undefined, [ + { role: 'system', content: 'Make a summary of messages history' } + ]) + + const summary = response?.choices[0].message.content + + if (summary == null) { + return { tokens: 0 } + } + + const tokens = response?.usage?.completion_tokens ?? countTokens([{ content: summary, role: 'assistant' }], encoding) + + return { summary, tokens } +} diff --git a/services/ai-bot/pod-ai-bot/src/utils.ts b/services/ai-bot/pod-ai-bot/src/utils/platform.ts similarity index 53% rename from services/ai-bot/pod-ai-bot/src/utils.ts rename to services/ai-bot/pod-ai-bot/src/utils/platform.ts index e87d91895e..09c173af2d 100644 --- a/services/ai-bot/pod-ai-bot/src/utils.ts +++ b/services/ai-bot/pod-ai-bot/src/utils/platform.ts @@ -13,28 +13,16 @@ // limitations under the License. // -import core, { Account, Ref, TxOperations } from '@hcengineering/core' +import core, { Account, Client, Ref, TxOperations } from '@hcengineering/core' +import { createClient } from '@hcengineering/server-client' import contact, { PersonAccount } from '@hcengineering/contact' -import aiBot from '@hcengineering/ai-bot' -import { loginBot } from './account' import chunter, { DirectMessage } from '@hcengineering/chunter' +import aiBot from '@hcengineering/ai-bot' import { deepEqual } from 'fast-equals' import notification from '@hcengineering/notification' -import OpenAI from 'openai' -import { countTokens } from '@hcengineering/openai' -import { Tiktoken } from 'js-tiktoken' -import { HistoryRecord } from './types' -import config from './config' - -export async function login (): Promise { - const token = (await loginBot())?.token - - if (token !== undefined) { - return token - } else { - return (await loginBot())?.token - } +export async function connectPlatform (token: string, endpoint: string): Promise { + return await createClient(endpoint, token) } export async function getDirect ( @@ -80,59 +68,3 @@ export async function getDirect ( return dmId } - -export async function createChatCompletion ( - client: OpenAI, - message: OpenAI.ChatCompletionMessageParam, - user?: string, - history: OpenAI.ChatCompletionMessageParam[] = [], - skipCache = true -): Promise { - const opt: OpenAI.RequestOptions = {} - if (skipCache) { - opt.headers = { 'cf-skip-cache': 'true' } - } - try { - return await client.chat.completions.create( - { - messages: [...history, message], - model: config.OpenAIModel, - user, - stream: false - }, - opt - ) - } catch (e) { - console.error(e) - } - - return undefined -} - -export async function requestSummary ( - aiClient: OpenAI, - encoding: Tiktoken, - history: HistoryRecord[] -): Promise<{ - summary?: string - tokens: number - }> { - const summaryPrompt: OpenAI.ChatCompletionMessageParam = { - content: `Summarize the following messages, keeping the key points: ${history.map((msg) => `${msg.role}: ${msg.message}`).join('\n')}`, - role: 'user' - } - - const response = await createChatCompletion(aiClient, summaryPrompt, undefined, [ - { role: 'system', content: 'Make a summary of messages history' } - ]) - - const summary = response?.choices[0].message.content - - if (summary == null) { - return { tokens: 0 } - } - - const tokens = response?.usage?.completion_tokens ?? countTokens([{ content: summary, role: 'assistant' }], encoding) - - return { summary, tokens } -} diff --git a/services/ai-bot/pod-ai-bot/src/workspaceClient.ts b/services/ai-bot/pod-ai-bot/src/workspaceClient.ts index bd425f0e1a..62f9e42bbd 100644 --- a/services/ai-bot/pod-ai-bot/src/workspaceClient.ts +++ b/services/ai-bot/pod-ai-bot/src/workspaceClient.ts @@ -13,7 +13,7 @@ // limitations under the License. // -import aiBot, { aiBotAccountEmail, AIBotEvent, AIBotResponseEvent, AIBotTransferEvent } from '@hcengineering/ai-bot' +import aiBot, { aiBotAccountEmail, AIMessageEventRequest, AITransferEventRequest } from '@hcengineering/ai-bot' import chunter, { ChatMessage, type ChatWidgetTab, @@ -43,7 +43,6 @@ import core, { Ref, Space, Tx, - TxCreateDoc, TxOperations, TxProcessor, TxRemoveDoc @@ -60,10 +59,11 @@ import analyticsCollector, { OnboardingChannel } from '@hcengineering/analytics- import workbench, { SidebarEvent, TxSidebarEvent } from '@hcengineering/workbench' import config from './config' -import { AIBotController } from './controller' -import { connectPlatform } from './platform' +import { AIControl } from './controller' +import { connectPlatform, getDirect } from './utils/platform' import { HistoryRecord } from './types' -import { createChatCompletion, getDirect, login, requestSummary } from './utils' +import { loginBot } from './utils/account' +import { createChatCompletion, requestSummary } from './utils/openai' const MAX_LOGIN_DELAY_MS = 15 * 1000 // 15 ses const UPDATE_TYPING_TIMEOUT_MS = 1000 @@ -95,7 +95,7 @@ export class WorkspaceClient { readonly transactorUrl: string, readonly token: string, readonly workspace: string, - readonly controller: AIBotController, + readonly controller: AIControl, readonly ctx: MeasureContext, readonly info: WorkspaceInfoRecord | undefined ) { @@ -115,9 +115,6 @@ export class WorkspaceClient { await this.uploadAvatarFile(opClient) const typing = await opClient.findAll(chunter.class.TypingInfo, { user: aiBot.account.AIBot }) this.typingMap = new Map(typing.map((t) => [t.objectId, t])) - const events = await opClient.findAll(aiBot.class.AIBotEvent, {}) - void this.processEvents(events) - this.client.notify = (...txes: Tx[]) => { void this.txHandler(opClient, txes) } @@ -143,8 +140,6 @@ export class WorkspaceClient { await this.blobClient.upload(this.ctx, config.AvatarName, data.length, config.AvatarContentType, data) await this.controller.updateAvatarInfo(this.workspace, config.AvatarPath, lastModified) this.ctx.info('Avatar file uploaded successfully', { workspace: this.workspace, path: config.AvatarPath }) - } else { - this.ctx.info('Avatar file already uploaded', { workspace: this.workspace, path: config.AvatarPath }) } } catch (e) { this.ctx.error('Failed to upload avatar file', { e }) @@ -155,7 +150,7 @@ export class WorkspaceClient { private async tryLogin (): Promise { this.ctx.info('Logging in: ', { workspace: this.workspace }) - const token = await login() + const token = (await loginBot())?.token clearTimeout(this.loginTimeout) @@ -232,13 +227,13 @@ export class WorkspaceClient { async createTransferMessage ( client: TxOperations, - event: AIBotTransferEvent, + event: AITransferEventRequest, _id: Ref, _class: Ref>, space: Ref, message: string ): Promise { - const op = client.apply(undefined, 'AIBotTransferEvent') + const op = client.apply(undefined, 'AITransferEventRequest') if (event.messageClass === chunter.class.ChatMessage) { await this.startTyping(client, space, _id, _class) const ref = await op.addCollection( @@ -249,7 +244,7 @@ export class WorkspaceClient { event.collection, { message }, undefined, - event.modifiedOn + event.createdOn ) await op.createMixin(ref, chunter.class.ChatMessage, space, aiBot.mixin.TransferredMessage, { messageId: event.messageId, @@ -269,7 +264,7 @@ export class WorkspaceClient { event.collection, { message, objectId: parent.attachedTo, objectClass: parent.attachedToClass }, undefined, - event.modifiedOn + event.createdOn ) await op.createMixin( ref, @@ -436,25 +431,27 @@ export class WorkspaceClient { this.historyMap.set(objectId, currentHistory) } - async processResponseEvent (event: AIBotResponseEvent): Promise { + async processMessageEvent (event: AIMessageEventRequest): Promise { if (this.controller.aiClient === undefined) return - const client = await this.opClient + + const { user, objectId, objectClass, messageClass } = event + const promptText = markupToText(event.message) + const prompt: OpenAI.ChatCompletionMessageParam = { content: promptText, role: 'user' } + const promptTokens = countTokens([prompt], this.controller.encoding) + if (!this.controller.allowAiReplies(this.workspace, event.email)) { - await client.remove(event) + void this.pushHistory(promptText, 'user', promptTokens, user, objectId, objectClass) return } + + const client = await this.opClient + const op = client.apply(undefined, 'AIMessageRequestEvent') const hierarchy = client.getHierarchy() - const op = client.apply(undefined, 'AIBotResponseEvent') - const { user, objectId, objectClass, messageClass } = event const space = hierarchy.isDerived(objectClass, core.class.Space) ? (objectId as Ref) : event.objectSpace await this.startTyping(client, space, objectId, objectClass) - const promptText = markupToText(event.message) - const prompt: OpenAI.ChatCompletionMessageParam = { content: promptText, role: 'user' } - - const promptTokens = countTokens([prompt], this.controller.encoding) const rawHistory = await this.getHistory(objectId) const history = this.toOpenAiHistory(rawHistory, promptTokens) @@ -464,10 +461,7 @@ export class WorkspaceClient { void this.pushHistory(promptText, prompt.role, promptTokens, user, objectId, objectClass) - const start = Date.now() const chatCompletion = await createChatCompletion(this.controller.aiClient, prompt, user, history) - const end = Date.now() - this.ctx.info('Chat completion time: ', { time: end - start }) const response = chatCompletion?.choices[0].message.content if (response == null) { @@ -509,7 +503,6 @@ export class WorkspaceClient { } } - await op.remove(event) await this.finishTyping(op, event.objectId) await op.commit() await this.controller.transferAIReplyToSupport(parseResponse, { @@ -523,14 +516,7 @@ export class WorkspaceClient { }) } - async processTransferEvent (event: AIBotTransferEvent): Promise { - const client = await this.opClient - - await this.controller.transfer(event) - await client.remove(event) - } - - async transferToSupport (event: AIBotTransferEvent, channelRef?: Ref): Promise { + async transferToSupport (event: AITransferEventRequest, channelRef?: Ref): Promise { const client = await this.opClient const key = `${event.toEmail}-${event.fromWorkspace}` const channel = @@ -560,7 +546,7 @@ export class WorkspaceClient { ) } - async transferToUserDirect (event: AIBotTransferEvent): Promise { + async transferToUserDirect (event: AITransferEventRequest): Promise { const client = await this.opClient const direct = this.directByEmail.get(event.toEmail) ?? (await getDirect(client, event.toEmail, this.aiAccount)) @@ -579,7 +565,7 @@ export class WorkspaceClient { return this.channelByKey.get(key) } - async transfer (event: AIBotTransferEvent): Promise { + async transfer (event: AITransferEventRequest): Promise { if (event.toWorkspace === config.SupportWorkspace) { const channel = this.getChannelRef(event.toEmail, event.fromWorkspace) @@ -603,24 +589,6 @@ export class WorkspaceClient { } } - async processEvents (events: AIBotEvent[]): Promise { - if (events.length === 0 || this.opClient === undefined) { - return - } - - for (const event of events) { - try { - if (event._class === aiBot.class.AIBotResponseEvent) { - void this.processResponseEvent(event as AIBotResponseEvent) - } else if (event._class === aiBot.class.AIBotTransferEvent) { - void this.processTransferEvent(event as AIBotTransferEvent) - } - } catch (e) { - this.ctx.error('Error processing event: ', { e }) - } - } - } - async close (): Promise { clearTimeout(this.loginTimeout) @@ -639,16 +607,6 @@ export class WorkspaceClient { this.ctx.info('Closed workspace client: ', { workspace: this.workspace }) } - private async handleCreateTx (tx: TxCreateDoc): Promise { - if (tx.objectClass === aiBot.class.AIBotResponseEvent) { - const doc = TxProcessor.createDoc2Doc(tx as TxCreateDoc) - await this.processResponseEvent(doc) - } else if (tx.objectClass === aiBot.class.AIBotTransferEvent) { - const doc = TxProcessor.createDoc2Doc(tx as TxCreateDoc) - await this.processTransferEvent(doc) - } - } - private async handleRemoveTx (tx: TxRemoveDoc): Promise { if (tx.objectClass === chunter.class.TypingInfo && this.typingMap.has(tx.objectId)) { this.typingMap.delete(tx.objectId) @@ -659,9 +617,7 @@ export class WorkspaceClient { for (const ttx of txes) { const tx = TxProcessor.extractTx(ttx) - if (tx._class === core.class.TxCreateDoc) { - await this.handleCreateTx(tx as TxCreateDoc) - } else if (tx._class === core.class.TxRemoveDoc) { + if (tx._class === core.class.TxRemoveDoc) { await this.handleRemoveTx(tx as TxRemoveDoc) } } diff --git a/services/analytics-collector/pod-analytics-collector/src/server.ts b/services/analytics-collector/pod-analytics-collector/src/server.ts index 8bd259d3b0..4ab69f30e7 100644 --- a/services/analytics-collector/pod-analytics-collector/src/server.ts +++ b/services/analytics-collector/pod-analytics-collector/src/server.ts @@ -13,62 +13,17 @@ // limitations under the License. // -import { Token, decodeToken } from '@hcengineering/server-token' +import { Token } from '@hcengineering/server-token' import cors from 'cors' import express, { type Express, type NextFunction, type Request, type Response } from 'express' -import { IncomingHttpHeaders, type Server } from 'http' +import { type Server } from 'http' import { AnalyticEvent } from '@hcengineering/analytics-collector' +import { extractToken } from '@hcengineering/server-client' import { ApiError } from './error' import { Collector } from './collector' import { Action } from './types' -const extractCookieToken = (cookie?: string): Token | null => { - if (cookie === undefined || cookie === null) { - return null - } - - const cookies = cookie.split(';') - const tokenCookie = cookies.find((cookie) => cookie.toLocaleLowerCase().includes('token')) - if (tokenCookie === undefined) { - return null - } - - const encodedToken = tokenCookie.split('=')[1] - if (encodedToken === undefined) { - return null - } - - return decodeToken(encodedToken) -} - -const extractAuthorizationToken = (authorization?: string): Token | null => { - if (authorization === undefined || authorization === null) { - return null - } - const encodedToken = authorization.split(' ')[1] - - if (encodedToken === undefined) { - return null - } - - return decodeToken(encodedToken) -} - -const extractToken = (headers: IncomingHttpHeaders): Token => { - try { - const token = extractCookieToken(headers.cookie) ?? extractAuthorizationToken(headers.authorization) - - if (token === null) { - throw new ApiError(401) - } - - return token - } catch { - throw new ApiError(401) - } -} - type AsyncRequestHandler = (req: Request, res: Response, token: Token, next: NextFunction) => Promise const handleRequest = async ( @@ -77,8 +32,11 @@ const handleRequest = async ( res: Response, next: NextFunction ): Promise => { + const token = extractToken(req.headers) + if (token === undefined) { + throw new ApiError(401) + } try { - const token = extractToken(req.headers) await fn(req, res, token, next) } catch (err: unknown) { next(err) diff --git a/services/telegram-bot/pod-telegram-bot/src/server.ts b/services/telegram-bot/pod-telegram-bot/src/server.ts index d2fb2274cc..2f0e5b1a69 100644 --- a/services/telegram-bot/pod-telegram-bot/src/server.ts +++ b/services/telegram-bot/pod-telegram-bot/src/server.ts @@ -13,14 +13,15 @@ // limitations under the License. // -import { Token, decodeToken } from '@hcengineering/server-token' +import { Token } from '@hcengineering/server-token' import cors from 'cors' import express, { type Express, type NextFunction, type Request, type Response } from 'express' -import { IncomingHttpHeaders, type Server } from 'http' +import { type Server } from 'http' import { MeasureContext } from '@hcengineering/core' import { Telegraf } from 'telegraf' import telegram, { TelegramNotificationRequest } from '@hcengineering/telegram' import { translate } from '@hcengineering/platform' +import { extractToken } from '@hcengineering/server-client' import { ApiError } from './error' import { PlatformWorker } from './worker' @@ -29,52 +30,6 @@ import config from './config' import { toTelegramHtml, toMediaGroups } from './utils' import { TgContext } from './telegraf/types' -const extractCookieToken = (cookie?: string): Token | null => { - if (cookie === undefined || cookie === null) { - return null - } - - const cookies = cookie.split(';') - const tokenCookie = cookies.find((cookie) => cookie.toLocaleLowerCase().includes('token')) - if (tokenCookie === undefined) { - return null - } - - const encodedToken = tokenCookie.split('=')[1] - if (encodedToken === undefined) { - return null - } - - return decodeToken(encodedToken) -} - -const extractAuthorizationToken = (authorization?: string): Token | null => { - if (authorization === undefined || authorization === null) { - return null - } - const encodedToken = authorization.split(' ')[1] - - if (encodedToken === undefined) { - return null - } - - return decodeToken(encodedToken) -} - -const extractToken = (headers: IncomingHttpHeaders): Token => { - try { - const token = extractCookieToken(headers.cookie) ?? extractAuthorizationToken(headers.authorization) - - if (token === null) { - throw new ApiError(401) - } - - return token - } catch { - throw new ApiError(401) - } -} - type AsyncRequestHandler = (req: Request, res: Response, token: Token, next: NextFunction) => Promise const handleRequest = async ( @@ -83,11 +38,14 @@ const handleRequest = async ( res: Response, next: NextFunction ): Promise => { + const token = extractToken(req.headers) + if (token === undefined) { + throw new ApiError(401) + } try { - const token = extractToken(req.headers) await fn(req, res, token, next) } catch (err: unknown) { - console.error('Error during extract token', err) + console.error(err) next(err) } }