Use rest api instead of mongo adapter (#7063)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-10-30 12:10:30 +04:00 committed by GitHub
parent 67f9967551
commit 12c460c00b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 559 additions and 699 deletions

3
.vscode/launch.json vendored
View File

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

View File

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

View File

@ -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<Class<ChatMessage>>
@Prop(TypeRef(chunter.class.ChatMessage), core.string.Ref)
messageId!: Ref<ChatMessage>
@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<Doc>
@Prop(TypeRef(core.class.Class), core.string.Class)
objectClass!: Ref<Class<Doc>>
@Prop(TypeRef(core.class.Space), core.string.Space)
objectSpace!: Ref<Space>
@Prop(TypeRef(core.class.Account), core.string.Account)
user!: Ref<Account>
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<ChatMessage>
}
@Mixin(aiBot.mixin.TransferredMessage, chunter.class.ChatMessage)
export class TTransferredMessage extends TChatMessage implements TransferredMessage {
messageId!: Ref<ChatMessage>
@ -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,

View File

@ -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<Class<ChatMessage>>
messageId: Ref<ChatMessage>
message: string
}
export interface AIBotResponseEvent extends AIBotEvent {
objectId: Ref<Doc>
objectClass: Ref<Class<Doc>>
objectSpace: Ref<Space>
user: Ref<Account>
email: string
}
export interface AIBotTransferEvent extends AIBotEvent {
toEmail: string
toWorkspace: string
fromWorkspace: string
fromWorkspaceName: string
fromWorkspaceUrl: string
parentMessageId?: Ref<ChatMessage>
}
export interface TransferredMessage extends ChatMessage {
messageId: Ref<ChatMessage>
parentMessageId?: Ref<ChatMessage>
@ -57,11 +34,6 @@ const aiBot = plugin(aiBotId, {
metadata: {
EndpointURL: '' as Metadata<string>
},
class: {
AIBotEvent: '' as Ref<Class<AIBotEvent>>,
AIBotTransferEvent: '' as Ref<Class<AIBotTransferEvent>>,
AIBotResponseEvent: '' as Ref<Class<AIBotResponseEvent>>
},
mixin: {
TransferredMessage: '' as Ref<Mixin<TransferredMessage>>
},

View File

@ -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<Class<ChatMessage>>
messageId: Ref<ChatMessage>
message: string
createdOn: Timestamp
}
export interface AIMessageEventRequest extends AIEventRequest {
objectId: Ref<Doc>
objectClass: Ref<Class<Doc>>
objectSpace: Ref<Space>
user: Ref<Account>
email: string
}
export interface AITransferEventRequest extends AIEventRequest {
toEmail: string
toWorkspace: string
fromWorkspace: string
fromWorkspaceName: string
fromWorkspaceUrl: string
parentMessageId?: Ref<ChatMessage>
}
export interface TranslateRequest {
text: Markup
lang: string
}
export interface TranslateResponse {
text: Markup
lang: string
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<WorkspaceInfoRecord>
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<WorkspaceInfoRecord>('workspacesInfo')
}
async processWorkspace (workspace: WorkspaceId): Promise<void> {
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<void> {
this.closed = true
this._client.close()
}
metrics (): MeasureContext {
return this._metrics
}
}
export async function createAIBotAdapter (url: string, db: string, metrics: MeasureContext): Promise<any> {
const _client = getMongoClient(url)
return new AIBotAdapter(_client, await _client.getClient(), metrics, db)
}

View File

@ -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<void> {
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<boolean> {
const { members } = direct
@ -77,8 +71,10 @@ async function getMessageDoc (message: ChatMessage, control: TriggerControl): Pr
}
}
function getMessageData (doc: Doc, message: ChatMessage, email: string): Data<AIBotResponseEvent> {
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<AI
}
}
function getThreadMessageData (message: ThreadMessage, email: string): Data<AIBotResponseEvent> {
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<AIBo
}
}
async function createResponseEvent (
message: ChatMessage,
control: TriggerControl,
data: Data<AIBotResponseEvent>
): Promise<void> {
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<Ref<ChatMessage> | 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<AIBotResponseEvent>
): Promise<void> {
data: AIMessageEventRequest
): Promise<AITransferEventRequest | undefined> {
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<void> {
@ -186,18 +175,18 @@ async function onBotDirectMessageSend (control: TriggerControl, message: ChatMes
return
}
let data: Data<AIBotResponseEvent> | 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<void> {
@ -222,18 +211,20 @@ async function onSupportWorkspaceMessage (control: TriggerControl, message: Chat
}
const { workspaceId, email } = channel
let data: Data<AIBotResponseEvent> | undefined
const account = control.modelDb.findAllSync(contact.class.PersonAccount, {
_id: (message.createdBy ?? message.modifiedBy) as Ref<PersonAccount>
})[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'

View File

@ -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<void> {
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<void> {
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 })
}
}

View File

@ -22,7 +22,8 @@ export const serverAiBotId = 'server-ai-bot' as Plugin
export default plugin(serverAiBotId, {
metadata: {
SupportWorkspaceId: '' as Metadata<string>
SupportWorkspaceId: '' as Metadata<string>,
EndpointURL: '' as Metadata<string>
},
trigger: {
OnMessageSend: '' as Resource<TriggerFunc>,

View File

@ -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<void>
}
export interface WorkspaceInfoRecord {
workspace: string
active: boolean
avatarPath?: string
avatarLastModified?: number
}

View File

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

View File

@ -21,3 +21,4 @@ export default plugin
export * from './account'
export * from './blob'
export * from './client'
export * from './token'

View File

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

View File

@ -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<string, WorkspaceClient> = new Map<string, WorkspaceClient>()
private readonly closeWorkspaceTimeouts: Map<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>()
private readonly connectingWorkspaces: Set<string> = new Set<string>()
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<void> {
const activeRecords = await this.storage.getActiveWorkspaces()
async getWorkspaceRecord (workspace: string): Promise<WorkspaceInfoRecord> {
return (await this.storage.getWorkspace(workspace)) ?? { workspace: config.SupportWorkspace }
}
async connectSupportWorkspace (): Promise<void> {
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<void> {
@ -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<WorkspaceLoginInfo | undefined> {
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<WorkspaceClient | undefined> {
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<void> {
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<WorkspaceClient> {
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<void> {
async initWorkspaceClient (workspace: string): Promise<void> {
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<void> {
async transfer (event: AITransferEventRequest): Promise<void> {
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<void> {
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<void> {
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<WorkspaceClient | undefined> {
await this.initWorkspaceClient(workspace)
return this.workspaces.get(workspace)
}
async openChatInSidebar (data: OpenChatInSidebarData): Promise<void> {
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<void> {
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve()
}, delay)
})
async processMessageEvent (workspace: string, event: AIMessageEventRequest): Promise<void> {
const wsClient = await this.getWorkspaceClient(workspace)
if (wsClient === undefined) return
await wsClient.processMessageEvent(event)
}
async processEvent (workspace: string, events: AIEventRequest[]): Promise<void> {
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<void> {
await this.initWorkspaceClient(workspace)
}
}

View File

@ -1,6 +0,0 @@
import { Client } from '@hcengineering/core'
import { createClient } from '@hcengineering/server-client'
export async function connectPlatform (token: string, endpoint: string): Promise<Client> {
return await createClient(endpoint, token)
}

View File

@ -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<void>
@ -76,8 +31,11 @@ const handleRequest = async (
res: Response,
next: NextFunction
): Promise<void> => {
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) => {

View File

@ -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<void> => {
setMetadata(serverToken.metadata.Secret, config.ServerSecret)
@ -49,7 +49,7 @@ export const start = async (): Promise<void> => {
}
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)

View File

@ -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<WorkspaceInfoRecord[]> {
return await this.workspacesInfoCollection.find({ active: true }).toArray()
}
async inactiveWorkspace (workspace: string): Promise<void> {
await this.workspacesInfoCollection.updateOne({ workspace }, { $set: { active: false } })
}
async getWorkspace (workspace: string): Promise<WorkspaceInfoRecord | undefined> {
return (await this.workspacesInfoCollection.findOne({ workspace })) ?? undefined
}
async addWorkspace (record: WorkspaceInfoRecord): Promise<void> {
await this.workspacesInfoCollection.insertOne(record)
}
async updateWorkspace (workspace: string, update: UpdateFilter<WorkspaceInfoRecord>): Promise<void> {
await this.workspacesInfoCollection.updateOne({ workspace }, update)
}

View File

@ -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<Workspace> {
const token = generateToken(systemAccountEmail, { name: '-' }, { service: 'aibot' })
@ -102,3 +106,69 @@ export async function getWorkspaceInfo (token: string): Promise<WorkspaceLoginIn
return workspaceInfo.result as WorkspaceLoginInfo
}
async function tryGetWorkspaceInfo (ws: string, ctx: MeasureContext): Promise<WorkspaceLoginInfo | undefined> {
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<string, NodeJS.Timeout>()
const attemptsByWorkspace = new Map<string, number>()
export async function tryAssignToWorkspace (
workspace: string,
ctx: MeasureContext,
clearAttempts = true
): Promise<boolean> {
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
}

View File

@ -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<void> {
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve()
}, delay)
})
}

View File

@ -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<string | undefined> {
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<OpenAI.ChatCompletion | undefined> {
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 }
}

View File

@ -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<string | undefined> {
const token = (await loginBot())?.token
if (token !== undefined) {
return token
} else {
return (await loginBot())?.token
}
export async function connectPlatform (token: string, endpoint: string): Promise<Client> {
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<OpenAI.ChatCompletion | undefined> {
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 }
}

View File

@ -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<void> {
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<Doc>,
_class: Ref<Class<Doc>>,
space: Ref<Space>,
message: string
): Promise<void> {
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<Doc, ChatMessage>(
@ -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<void> {
async processMessageEvent (event: AIMessageEventRequest): Promise<void> {
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<Space>) : 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<void> {
const client = await this.opClient
await this.controller.transfer(event)
await client.remove(event)
}
async transferToSupport (event: AIBotTransferEvent, channelRef?: Ref<OnboardingChannel>): Promise<void> {
async transferToSupport (event: AITransferEventRequest, channelRef?: Ref<OnboardingChannel>): Promise<void> {
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<void> {
async transferToUserDirect (event: AITransferEventRequest): Promise<void> {
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<void> {
async transfer (event: AITransferEventRequest): Promise<void> {
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<void> {
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<void> {
clearTimeout(this.loginTimeout)
@ -639,16 +607,6 @@ export class WorkspaceClient {
this.ctx.info('Closed workspace client: ', { workspace: this.workspace })
}
private async handleCreateTx (tx: TxCreateDoc<Doc>): Promise<void> {
if (tx.objectClass === aiBot.class.AIBotResponseEvent) {
const doc = TxProcessor.createDoc2Doc(tx as TxCreateDoc<AIBotResponseEvent>)
await this.processResponseEvent(doc)
} else if (tx.objectClass === aiBot.class.AIBotTransferEvent) {
const doc = TxProcessor.createDoc2Doc(tx as TxCreateDoc<AIBotTransferEvent>)
await this.processTransferEvent(doc)
}
}
private async handleRemoveTx (tx: TxRemoveDoc<Doc>): Promise<void> {
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<Doc>)
} else if (tx._class === core.class.TxRemoveDoc) {
if (tx._class === core.class.TxRemoveDoc) {
await this.handleRemoveTx(tx as TxRemoveDoc<Doc>)
}
}

View File

@ -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<void>
const handleRequest = async (
@ -77,8 +32,11 @@ const handleRequest = async (
res: Response,
next: NextFunction
): Promise<void> => {
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)

View File

@ -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<void>
const handleRequest = async (
@ -83,11 +38,14 @@ const handleRequest = async (
res: Response,
next: NextFunction
): Promise<void> => {
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)
}
}