mirror of
https://github.com/hcengineering/platform.git
synced 2025-06-08 17:00:47 +00:00
Use rest api instead of mongo adapter (#7063)
Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
parent
67f9967551
commit
12c460c00b
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@ -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",
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
|
@ -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>>
|
||||
},
|
||||
|
58
plugins/ai-bot/src/rest.ts
Normal file
58
plugins/ai-bot/src/rest.ts
Normal 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
|
||||
}
|
@ -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'
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
@ -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'
|
||||
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
@ -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>,
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -21,3 +21,4 @@ export default plugin
|
||||
export * from './account'
|
||||
export * from './blob'
|
||||
export * from './client'
|
||||
export * from './token'
|
||||
|
44
server/client/src/token.ts
Normal file
44
server/client/src/token.ts
Normal 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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
@ -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) => {
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
22
services/ai-bot/pod-ai-bot/src/utils/common.ts
Normal file
22
services/ai-bot/pod-ai-bot/src/utils/common.ts
Normal 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)
|
||||
})
|
||||
}
|
95
services/ai-bot/pod-ai-bot/src/utils/openai.ts
Normal file
95
services/ai-bot/pod-ai-bot/src/utils/openai.ts
Normal 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 }
|
||||
}
|
@ -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 }
|
||||
}
|
@ -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>)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user