Merge branch 'develop' into staging-new

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev
2025-02-12 00:31:41 +07:00
61 changed files with 840 additions and 2086 deletions
-2
View File
@@ -566,7 +566,6 @@
"MINIO_SECRET_KEY": "minioadmin",
"SERVER_SECRET": "secret",
"SERVICE_ID": "analytics-collector",
"SUPPORT_WORKSPACE": "09b65664-b3c3-4ea8-b9a1-c9688bde17f0",
"ACCOUNTS_URL": "http://localhost:3000"
},
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
@@ -586,7 +585,6 @@
"MONGO_URL": "mongodb://localhost:27017",
"PORT": "4010",
"SERVER_SECRET": "secret",
"SUPPORT_WORKSPACE": "09b65664-b3c3-4ea8-b9a1-c9688bde17f0",
"FIRST_NAME": "Jolie",
"LAST_NAME": "AI",
"PASSWORD": "password",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "desktop",
"version": "0.6.271",
"version": "0.6.435",
"main": "dist/main/electron.js",
"author": "Hardcore Engineering <hey@huly.io>",
"template": "@hcengineering/default-package",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hcengineering/desktop",
"version": "0.6.271",
"version": "0.6.435",
"main": "dist/main/electron.js",
"template": "@hcengineering/webpack-package",
"scripts": {
+16 -18
View File
@@ -246,7 +246,6 @@ services:
- ACCOUNTS_URL=http://host.docker.internal:3000
- LAST_NAME_FIRST=true
- BRANDING_PATH=/var/cfg/branding.json
- SUPPORT_WORKSPACE=09b65664-b3c3-4ea8-b9a1-c9688bde17f0
- AI_BOT_URL=http://host.docker.internal:4010
restart: unless-stopped
transactor_cockroach:
@@ -282,6 +281,7 @@ services:
- ACCOUNTS_URL=http://host.docker.internal:3000
- LAST_NAME_FIRST=true
- BRANDING_PATH=/var/cfg/branding.json
- AI_BOT_URL=http://host.docker.internal:4010
restart: unless-stopped
green:
image: hardcoreeng/green
@@ -374,22 +374,21 @@ services:
- SERVICE_ID=sign-service
- BRANDING_PATH=/var/cfg/branding.json
- STATS_URL=http://host.docker.internal:4900
analytics:
image: hardcoreeng/analytics-collector
extra_hosts:
- 'host.docker.internal:host-gateway'
restart: unless-stopped
ports:
- 4017:4017
environment:
- SECRET=secret
- PORT=4017
- MONGO_URL=${MONGO_URL}
- 'MONGO_OPTIONS={"appName":"analytics","maxPoolSize":1}'
- SERVICE_ID=analytics-collector-service
- ACCOUNTS_URL=http://host.docker.internal:3000
- SUPPORT_WORKSPACE=09b65664-b3c3-4ea8-b9a1-c9688bde17f0
- STATS_URL=http://host.docker.internal:4900
# analytics:
# image: hardcoreeng/analytics-collector
# extra_hosts:
# - 'host.docker.internal:host-gateway'
# restart: unless-stopped
# ports:
# - 4017:4017
# environment:
# - SECRET=secret
# - PORT=4017
# - MONGO_URL=${MONGO_URL}
# - 'MONGO_OPTIONS={"appName":"analytics","maxPoolSize":1}'
# - SERVICE_ID=analytics-collector-service
# - ACCOUNTS_URL=http://host.docker.internal:3000
# - STATS_URL=http://host.docker.internal:4900
aiBot:
image: hardcoreeng/ai-bot
ports:
@@ -401,7 +400,6 @@ services:
- SERVER_SECRET=secret
- MONGO_URL=${MONGO_URL}
- ACCOUNTS_URL=http://host.docker.internal:3000
- SUPPORT_WORKSPACE=09b65664-b3c3-4ea8-b9a1-c9688bde17f0
- STORAGE_CONFIG=${STORAGE_CONFIG}
- FIRST_NAME=Jolie
- LAST_NAME=AI
+16 -18
View File
@@ -181,23 +181,22 @@ services:
resources:
limits:
memory: 300M
analytics:
image: hardcoreeng/analytics-collector
restart: unless-stopped
ports:
- 4007:4007
environment:
- SECRET=secret
- PORT=4007
- MONGO_URL=mongodb://host.docker.internal:27017
- 'MONGO_OPTIONS={"appName":"analytics","maxPoolSize":1}'
- SERVICE_ID=analytics-collector-service
- ACCOUNTS_URL=http://account:3000
- SUPPORT_WORKSPACE=09b65664-b3c3-4ea8-b9a1-c9688bde17f0
deploy:
resources:
limits:
memory: 300M
# analytics:
# image: hardcoreeng/analytics-collector
# restart: unless-stopped
# ports:
# - 4007:4007
# environment:
# - SECRET=secret
# - PORT=4007
# - MONGO_URL=mongodb://host.docker.internal:27017
# - 'MONGO_OPTIONS={"appName":"analytics","maxPoolSize":1}'
# - SERVICE_ID=analytics-collector-service
# - ACCOUNTS_URL=http://account:3000
# deploy:
# resources:
# limits:
# memory: 300M
aiBot:
image: hardcoreeng/ai-bot
restart: unless-stopped
@@ -205,7 +204,6 @@ services:
- SERVER_SECRET=secret
- MONGO_URL=mongodb://host.docker.internal:27017
- ACCOUNTS_URL=http://account:3000
- SUPPORT_WORKSPACE=09b65664-b3c3-4ea8-b9a1-c9688bde17f0
- FIRST_NAME=Jolie
- LAST_NAME=AI
- PASSWORD=password
+1 -3
View File
@@ -3,7 +3,6 @@
//
import { type AnalyticProvider, Analytics } from "@hcengineering/analytics"
import { AnalyticsCollectorProvider } from './analytics/analyticsCollector'
import { PosthogAnalyticProvider } from "./analytics/posthog"
import { SentryAnalyticProvider } from "./analytics/sentry"
import { type Config } from "./platform"
@@ -11,8 +10,7 @@ import { type Config } from "./platform"
export function configureAnalytics (config: Config) {
const providers: AnalyticProvider[] = [
new SentryAnalyticProvider,
new PosthogAnalyticProvider,
new AnalyticsCollectorProvider
new PosthogAnalyticProvider
]
for (const provider of providers) {
Analytics.init(provider, config)
+11 -14
View File
@@ -201,7 +201,7 @@ async function migrateAccountsToSocialIds (client: MigrationClient): Promise<voi
const socialIdByAccount = await getSocialIdByOldAccount(client)
ctx.info('processing activity reactions ', {})
const iterator = await client.traverse(DOMAIN_ACTIVITY, { _class: activity.class.Reaction })
const iterator = await client.traverse(DOMAIN_REACTION, { _class: activity.class.Reaction })
try {
let processed = 0
@@ -228,7 +228,7 @@ async function migrateAccountsToSocialIds (client: MigrationClient): Promise<voi
}
if (operations.length > 0) {
await client.bulk(DOMAIN_ACTIVITY, operations)
await client.bulk(DOMAIN_REACTION, operations)
}
processed += docs.length
@@ -249,9 +249,9 @@ async function migrateAccountsInDocUpdates (client: MigrationClient): Promise<vo
au: DocAttributeUpdates,
update: MigrateUpdate<DocUpdateMessage>['attributeUpdates'],
field: P
): boolean {
): void {
const oldValue = au?.[field]
if (oldValue == null) return false
if (oldValue == null) return
let changed = false
let newValue: any
@@ -275,8 +275,6 @@ async function migrateAccountsInDocUpdates (client: MigrationClient): Promise<vo
update[field] = newValue
}
return changed
}
const iterator = await client.traverse(DOMAIN_ACTIVITY, {
@@ -301,15 +299,14 @@ async function migrateAccountsInDocUpdates (client: MigrationClient): Promise<vo
for (const doc of docs) {
const dum = doc as DocUpdateMessage
if (dum.attributeUpdates == null) continue
let changed = false
const update: any = { attributeUpdates: { ...dum.attributeUpdates } }
changed = migrateField(dum.attributeUpdates, update.attributeUpdates, 'added') || changed
changed = migrateField(dum.attributeUpdates, update.attributeUpdates, 'prevValue') || changed
changed = migrateField(dum.attributeUpdates, update.attributeUpdates, 'removed') || changed
changed = migrateField(dum.attributeUpdates, update.attributeUpdates, 'set') || changed
migrateField(dum.attributeUpdates, update.attributeUpdates, 'added')
migrateField(dum.attributeUpdates, update.attributeUpdates, 'prevValue')
migrateField(dum.attributeUpdates, update.attributeUpdates, 'removed')
migrateField(dum.attributeUpdates, update.attributeUpdates, 'set')
if (!changed) continue
update.attributeUpdates.attrClass = core.class.TypePersonId
operations.push({
filter: { _id: dum._id },
@@ -375,11 +372,11 @@ export const activityOperation: MigrateOperation = {
}
},
{
state: 'accounts-to-social-ids',
state: 'accounts-to-social-ids-v2',
func: migrateAccountsToSocialIds
},
{
state: 'accounts-in-doc-updates',
state: 'accounts-in-doc-updates-v2',
func: migrateAccountsInDocUpdates
}
])
+1 -7
View File
@@ -29,16 +29,10 @@
},
"dependencies": {
"@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/model": "^0.6.11",
"@hcengineering/model-contact": "^0.6.1",
"@hcengineering/model-core": "^0.6.0",
"@hcengineering/model-view": "^0.6.0",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/ui": "^0.6.15",
"@hcengineering/view": "^0.6.13"
"@hcengineering/ui": "^0.6.15"
}
}
+1 -12
View File
@@ -14,9 +14,6 @@
//
import { type Builder } from '@hcengineering/model'
import core, { type Domain } from '@hcengineering/core'
import chunter from '@hcengineering/chunter'
import analyticsCollector from '@hcengineering/analytics-collector'
import aiBot from './plugin'
@@ -24,12 +21,4 @@ export { aiBotId } from '@hcengineering/ai-bot'
export { aiBotOperation } from './migration'
export default aiBot
export const DOMAIN_AI_BOT = 'ai_bot' as Domain
export function createModel (builder: Builder): void {
builder.createDoc(chunter.class.ChunterExtension, core.space.Model, {
point: 'aside',
ofClass: analyticsCollector.class.OnboardingChannel,
component: aiBot.component.OnboardingChannelPanelExtension
})
}
export function createModel (builder: Builder): void {}
+1 -6
View File
@@ -15,10 +15,5 @@
import { mergeIds } from '@hcengineering/platform'
import aiBot, { aiBotId } from '@hcengineering/ai-bot'
import type { AnyComponent } from '@hcengineering/ui/src/types'
export default mergeIds(aiBotId, aiBot, {
component: {
OnboardingChannelPanelExtension: '' as AnyComponent
}
})
export default mergeIds(aiBotId, aiBot, {})
-2
View File
@@ -14,8 +14,6 @@
//
import { mergeIds } from '@hcengineering/platform'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { Ref, Mixin } from '@hcengineering/core'
import analyticsCollector, { analyticsCollectorId } from '@hcengineering/analytics-collector'
export default mergeIds(analyticsCollectorId, analyticsCollector, {})
+2 -17
View File
@@ -32,13 +32,10 @@ import {
TChatMessage,
TChatMessageViewlet,
TChatSyncInfo,
TChunterExtension,
TChunterSpace,
TDirectMessage,
TInlineButton,
TObjectChatPanel,
TThreadMessage,
TTypingInfo
TThreadMessage
} from './types'
export { chunterId } from '@hcengineering/chunter'
@@ -54,10 +51,7 @@ export function createModel (builder: Builder): void {
TThreadMessage,
TChatMessageViewlet,
TObjectChatPanel,
TChatSyncInfo,
TInlineButton,
TTypingInfo,
TChunterExtension
TChatSyncInfo
)
builder.createDoc(
@@ -161,10 +155,6 @@ export function createModel (builder: Builder): void {
presenter: chunter.component.ThreadMessagePresenter
})
builder.mixin(chunter.class.TypingInfo, core.class.Class, core.mixin.TransientConfiguration, {
broadcastOnly: true
})
builder.createDoc(
view.class.Viewlet,
core.space.Model,
@@ -315,11 +305,6 @@ export function createModel (builder: Builder): void {
defineActions(builder)
defineNotifications(builder)
builder.mixin(chunter.class.InlineButton, core.class.Class, core.mixin.IndexConfiguration, {
indexes: [],
searchDisabled: true
})
builder.mixin(chunter.class.ChatSyncInfo, core.class.Class, core.mixin.IndexConfiguration, {
indexes: [],
searchDisabled: true
+3 -36
View File
@@ -24,28 +24,22 @@ import {
TypeString,
UX
} from '@hcengineering/model'
import core, { TAttachedDoc, TClass, TDoc, TSpace } from '@hcengineering/model-core'
import core, { TClass, TDoc, TSpace } from '@hcengineering/model-core'
import type {
Channel,
ChatMessage,
ChatMessageViewlet,
ChatSyncInfo,
ChunterExtension,
ChunterSpace,
DirectMessage,
InlineButton,
InlineButtonAction,
ObjectChatPanel,
ThreadMessage,
TypingInfo,
ChunterExtensionPoint
ThreadMessage
} from '@hcengineering/chunter'
import {
type Class,
type Doc,
type Domain,
DOMAIN_MODEL,
DOMAIN_TRANSIENT,
IndexKind,
type Ref,
type Timestamp
@@ -54,11 +48,10 @@ import contact, { type ChannelProvider as SocialChannelProvider, type Person } f
import activity, { type ActivityMessage } from '@hcengineering/activity'
import { TActivityMessage } from '@hcengineering/model-activity'
import attachment from '@hcengineering/model-attachment'
import type { IntlString, Resource } from '@hcengineering/platform'
import type { IntlString } from '@hcengineering/platform'
import type { DocNotifyContext } from '@hcengineering/notification'
import chunter from './plugin'
import type { AnyComponent } from '@hcengineering/ui'
export const DOMAIN_CHUNTER = 'chunter' as Domain
@@ -94,9 +87,6 @@ export class TChatMessage extends TActivityMessage implements ChatMessage {
@Prop(TypeRef(contact.class.ChannelProvider), core.string.Object)
provider?: Ref<SocialChannelProvider>
@Prop(PropCollection(chunter.class.InlineButton), core.string.Object)
inlineButtons?: number
}
@Model(chunter.class.ThreadMessage, chunter.class.ChatMessage)
@@ -145,26 +135,3 @@ export class TChatSyncInfo extends TDoc implements ChatSyncInfo {
hidden!: Ref<DocNotifyContext>[]
timestamp!: Timestamp
}
@Model(chunter.class.InlineButton, core.class.Doc, DOMAIN_CHUNTER)
export class TInlineButton extends TAttachedDoc implements InlineButton {
name!: string
titleIntl?: IntlString
title?: string
action!: Resource<InlineButtonAction>
}
@Model(chunter.class.TypingInfo, core.class.Doc, DOMAIN_TRANSIENT)
export class TTypingInfo extends TDoc implements TypingInfo {
objectId!: Ref<Doc>
objectClass!: Ref<Class<Doc>>
person!: Ref<Person>
lastTyping!: Timestamp
}
@Model(chunter.class.ChunterExtension, core.class.Doc, DOMAIN_MODEL)
export class TChunterExtension extends TDoc implements ChunterExtension {
ofClass!: Ref<Class<Doc>>
point!: ChunterExtensionPoint
component!: AnyComponent
}
+12 -38
View File
@@ -13,50 +13,15 @@
// limitations under the License.
//
import { type Builder, Mixin } from '@hcengineering/model'
import core, { type Domain, type Ref } from '@hcengineering/core'
import { type Builder } from '@hcengineering/model'
import core from '@hcengineering/core'
import serverCore from '@hcengineering/server-core'
import serverAiBot from '@hcengineering/server-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'
import chunter from '@hcengineering/chunter'
export { serverAiBotId } from '@hcengineering/server-ai-bot'
export const DOMAIN_AI_BOT = 'ai_bot' as Domain
@Mixin(aiBot.mixin.TransferredMessage, chunter.class.ChatMessage)
export class TTransferredMessage extends TChatMessage implements TransferredMessage {
messageId!: Ref<ChatMessage>
parentMessageId?: Ref<ChatMessage>
}
export function createModel (builder: Builder): void {
builder.createModel(TTransferredMessage)
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverAiBot.trigger.OnMessageSend,
isAsync: true
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverAiBot.trigger.OnMention,
txMatch: {
_class: core.class.TxCreateDoc,
objectClass: notification.class.MentionInboxNotification
},
isAsync: true
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverAiBot.trigger.OnMessageNotified,
txMatch: {
_class: core.class.TxCreateDoc,
objectClass: notification.class.ActivityInboxNotification
},
isAsync: true
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverAiBot.trigger.OnUserStatus,
txMatch: {
@@ -64,4 +29,13 @@ export function createModel (builder: Builder): void {
},
isAsync: true
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverAiBot.trigger.OnMessageSend,
txMatch: {
_class: core.class.TxCreateDoc,
objectClass: chunter.class.ChatMessage
},
isAsync: true
})
}
@@ -1,51 +0,0 @@
<script lang="ts">
import { ModernButton } from '@hcengineering/ui'
import aiBot, { OnboardingEvent, type OnboardingEventRequest, OpenChatInSidebarData } from '@hcengineering/ai-bot'
import { getMetadata } from '@hcengineering/platform'
import { concatLink } from '@hcengineering/core'
import presentation from '@hcengineering/presentation'
import { OnboardingChannel } from '@hcengineering/analytics-collector'
import chunter from '@hcengineering/chunter'
export let object: OnboardingChannel
async function handleClick (): Promise<void> {
const url = getMetadata(aiBot.metadata.EndpointURL) ?? ''
const token = getMetadata(presentation.metadata.Token) ?? ''
if (url === '' || token === '') {
return undefined
}
try {
const req: OnboardingEventRequest<OpenChatInSidebarData> = {
event: OnboardingEvent.OpenChatInSidebar,
data: {
personId: object.socialString,
workspace: object.workspaceId
}
}
await fetch(concatLink(url, '/onboarding'), {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify(req)
})
} catch (error) {
console.error(error)
return undefined
}
}
</script>
<div class="root">
<ModernButton label={chunter.string.OpenChatInSidebar} size="small" on:click={handleClick} />
</div>
<style lang="scss">
.root {
padding: var(--spacing-1) var(--spacing-2);
}
</style>
+1 -6
View File
@@ -14,12 +14,7 @@
//
import { type Resources } from '@hcengineering/platform'
import OnboardingChannelPanelExtension from './components/OnboardingChannelAsideExtension.svelte'
export * from './requests'
export default async (): Promise<Resources> => ({
component: {
OnboardingChannelPanelExtension
}
})
export default async (): Promise<Resources> => ({})
+3 -16
View File
@@ -1,5 +1,5 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
// Copyright © 2024-2025 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
@@ -12,12 +12,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { type PersonId, buildSocialIdString, type Mixin, type Ref, SocialIdType, PersonUuid } from '@hcengineering/core'
import { buildSocialIdString, SocialIdType } 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
@@ -27,22 +26,10 @@ export const aiBotEmailSocialId = buildSocialIdString({
type: SocialIdType.EMAIL,
value: aiBotAccountEmail
})
export const aiBotAccount = '5a1a5faa-582c-42a6-8613-fc80a15e3ae8' as PersonUuid
export interface TransferredMessage extends ChatMessage {
messageId: Ref<ChatMessage>
parentMessageId?: Ref<ChatMessage>
}
const aiBot = plugin(aiBotId, {
metadata: {
EndpointURL: '' as Metadata<string>
},
mixin: {
TransferredMessage: '' as Ref<Mixin<TransferredMessage>>
},
account: {
AIBot: '' as PersonId
}
})
+7 -26
View File
@@ -1,5 +1,5 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
// Copyright © 2024-2025 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
@@ -13,40 +13,21 @@
// limitations under the License.
//
import { Class, Doc, Markup, PersonId, Ref, Space, Timestamp, type WorkspaceUuid } from '@hcengineering/core'
import { ChatMessage } from '@hcengineering/chunter'
import { Class, Doc, Markup, PersonId, Ref, Space, Timestamp } from '@hcengineering/core'
import { Room, RoomLanguage } from '@hcengineering/love'
import { Person } from '@hcengineering/contact'
export enum AIEventType {
Message = 'message',
Transfer = 'transfer'
}
import { ChatMessage } from '@hcengineering/chunter'
export interface AIEventRequest {
type: AIEventType
collection: string
message: string
messageClass: Ref<Class<ChatMessage>>
messageId: Ref<ChatMessage>
message: string
createdOn: Timestamp
}
export interface AIMessageEventRequest extends AIEventRequest {
objectId: Ref<Doc>
objectClass: Ref<Class<Doc>>
objectId: Ref<Doc>
objectSpace: Ref<Space>
user: PersonId
email: string
}
export interface AITransferEventRequest extends AIEventRequest {
toPersonId: PersonId
toWorkspace: WorkspaceUuid
fromWorkspace: WorkspaceUuid
fromWorkspaceName: string
fromWorkspaceUrl: string
parentMessageId?: Ref<ChatMessage>
collection: string
createdOn: Timestamp
}
export interface TranslateRequest {
-30
View File
@@ -1,30 +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 { type WorkspaceUuid, type PersonId } from '@hcengineering/core'
export enum OnboardingEvent {
OpenChatInSidebar = 'openChatInSidebar'
}
export interface OpenChatInSidebarData {
personId: PersonId
workspace: WorkspaceUuid
}
export interface OnboardingEventRequest<T = Record<string, any>> {
event: OnboardingEvent
data: T
}
@@ -14,10 +14,5 @@
//
import { type Resources } from '@hcengineering/platform'
import { AnalyticsCollectorInlineAction } from './utils'
export default async (): Promise<Resources> => ({
function: {
AnalyticsCollectorInlineAction
}
})
export default async (): Promise<Resources> => ({})
@@ -1,46 +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 { type InlineButtonAction } from '@hcengineering/chunter'
import analyticsCollector from '@hcengineering/analytics-collector'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { concatLink } from '@hcengineering/core'
export const AnalyticsCollectorInlineAction: InlineButtonAction = async (
button,
messageId,
channelId
): Promise<void> => {
const url = getMetadata(analyticsCollector.metadata.EndpointURL) ?? ''
const token = getMetadata(presentation.metadata.Token) ?? ''
if (url === '' || token === '') {
return
}
try {
await fetch(concatLink(url, 'action'), {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ _id: button._id, name: button.name, messageId, channelId })
})
} catch (e) {
console.error(e)
}
}
+2 -5
View File
@@ -13,10 +13,10 @@
// limitations under the License.
//
import type { IntlString, Metadata, Plugin, Resource } from '@hcengineering/platform'
import type { IntlString, Metadata, Plugin } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import type { Class, Ref } from '@hcengineering/core'
import { Channel, InlineButtonAction } from '@hcengineering/chunter'
import { Channel } from '@hcengineering/chunter'
import { OnboardingChannel } from './types'
@@ -35,9 +35,6 @@ const analyticsCollector = plugin(analyticsCollectorId, {
space: {
GeneralOnboardingChannel: '' as Ref<Channel>
},
function: {
AnalyticsCollectorInlineAction: '' as Resource<InlineButtonAction>
},
string: {
OnboardingChannelDescription: '' as IntlString,
Error: '' as IntlString,
@@ -31,7 +31,6 @@ import activity, { type ActivityMessage, type ActivityReference } from '@hcengin
import attachment from '@hcengineering/attachment'
import { combineActivityMessages, sortActivityMessages } from '@hcengineering/activity-resources'
import notification, { type DocNotifyContext } from '@hcengineering/notification'
import chunter from '@hcengineering/chunter'
export type LoadMode = 'forward' | 'backward'
@@ -309,7 +308,6 @@ export class ChannelDataProvider implements IChannelDataProvider {
return {
_id: {
attachments: attachment.class.Attachment,
inlineButtons: chunter.class.InlineButton,
reactions: activity.class.Reaction
}
}
@@ -1,21 +0,0 @@
<script lang="ts">
import { Doc } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { ChunterExtension, ChunterExtensionPoint } from '@hcengineering/chunter'
import { Component } from '@hcengineering/ui'
import chunter from '../plugin'
export let object: Doc
export let point: ChunterExtensionPoint
const client = getClient()
let extensions: ChunterExtension[] = []
$: extensions = client.getModel().findAllSync(chunter.class.ChunterExtension, { ofClass: object._class, point })
</script>
{#each extensions as extension}
<Component is={extension.component} props={{ object }} />
{/each}
@@ -1,49 +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.
-->
<script lang="ts">
import { ModernButton } from '@hcengineering/ui'
import chunter, { ChatMessage, InlineButton } from '@hcengineering/chunter'
import { createQuery } from '@hcengineering/presentation'
import { getResource } from '@hcengineering/platform'
export let value: ChatMessage
export let inlineButtons: InlineButton[] = []
const query = createQuery()
$: if ((value.inlineButtons ?? 0) > 0 && inlineButtons.length === 0) {
query.query(chunter.class.InlineButton, { attachedTo: value._id, space: value.space }, (res) => {
inlineButtons = res
})
} else {
query.unsubscribe()
}
async function handleInlineButtonClick (button: InlineButton): Promise<void> {
const resource = await getResource(button.action)
await resource(button, value._id, value.attachedTo)
}
</script>
{#each inlineButtons as button}
<ModernButton
title={button.title}
label={button.titleIntl}
size="small"
on:click={() => {
void handleInlineButtonClick(button)
}}
/>
{/each}
@@ -23,13 +23,12 @@
import view from '@hcengineering/view'
import activity, { ActivityMessage, ActivityMessageViewType, DisplayActivityMessage } from '@hcengineering/activity'
import { ActivityDocLink, ActivityMessageTemplate, MessageInlineAction } from '@hcengineering/activity-resources'
import chunter, { ChatMessage, ChatMessageViewlet, InlineButton } from '@hcengineering/chunter'
import chunter, { ChatMessage, ChatMessageViewlet } from '@hcengineering/chunter'
import { Attachment } from '@hcengineering/attachment'
import { EmptyMarkup } from '@hcengineering/text'
import ChatMessageHeader from './ChatMessageHeader.svelte'
import ChatMessageInput from './ChatMessageInput.svelte'
import InlineButtons from '../InlineButtons.svelte'
import { translatedMessagesStore, translatingMessagesStore, shownTranslatedMessagesStore } from '../../stores'
export let value: WithLookup<ChatMessage> | undefined
@@ -162,8 +161,6 @@
let attachments: Attachment[] | undefined = undefined
$: attachments = value?.$lookup?.attachments as Attachment[] | undefined
let inlineButtons: InlineButton[] = []
$: inlineButtons = (value?.$lookup?.inlineButtons ?? []) as InlineButton[]
let inlineActions: MessageInlineAction[] = []
@@ -262,21 +259,19 @@
<ShowMore limit={compact ? 80 : undefined}>
<div class="clear-mins">
<MessageViewer message={displayText} />
{#if (value.attachments ?? 0) > 0 || (value.inlineButtons ?? 0) > 0}
{#if (value.attachments ?? 0) > 0}
<div class="mt-2" />
{/if}
<AttachmentDocList {value} {attachments} imageSize={attachmentImageSize} {videoPreload} {isOwn} />
<InlineButtons {value} {inlineButtons} />
</div>
</ShowMore>
{:else}
<div class="clear-mins">
<MessageViewer message={displayText} />
{#if (value.attachments ?? 0) > 0 || (value.inlineButtons ?? 0) > 0}
{#if (value.attachments ?? 0) > 0}
<div class="mt-2" />
{/if}
<AttachmentDocList {value} {attachments} imageSize={attachmentImageSize} {videoPreload} {isOwn} />
<InlineButtons {value} {inlineButtons} />
</div>
{/if}
{:else if object}
@@ -25,8 +25,6 @@
import { ClassAttributeBar, getDocMixins } from '@hcengineering/view-resources'
import { ObjectChatPanel } from '@hcengineering/chunter'
import ChunterExtensionComponent from '../ChunterExtensionComponent.svelte'
export let object: Doc
export let objectChatPanel: ObjectChatPanel | undefined
@@ -80,6 +78,5 @@
{/if}
{/each}
</div>
<ChunterExtensionComponent {object} point="aside" />
<slot />
</Scroller>
+2 -29
View File
@@ -14,7 +14,7 @@
//
import { ActivityMessage, ActivityMessageViewlet } from '@hcengineering/activity'
import type { AttachedDoc, Class, Doc, Markup, Mixin, Ref, Space, Timestamp } from '@hcengineering/core'
import type { Class, Doc, Markup, Mixin, Ref, Space, Timestamp } from '@hcengineering/core'
import { NotificationType } from '@hcengineering/notification'
import type { Asset, Plugin, Resource } from '@hcengineering/platform'
import { IntlString, plugin } from '@hcengineering/platform'
@@ -58,7 +58,6 @@ export interface ChatMessage extends ActivityMessage {
attachments?: number
editedOn?: Timestamp
provider?: Ref<SocialChannelProvider>
inlineButtons?: number
}
/**
@@ -84,22 +83,6 @@ export interface ChatSyncInfo extends Doc {
timestamp: Timestamp
}
export interface TypingInfo extends Doc {
objectId: Ref<Doc>
objectClass: Ref<Class<Doc>>
person: Ref<Person>
lastTyping: Timestamp
}
export type InlineButtonAction = (button: InlineButton, message: Ref<ChatMessage>, channel: Ref<Doc>) => Promise<void>
export interface InlineButton extends AttachedDoc {
name: string
titleIntl?: IntlString
title?: string
action: Resource<InlineButtonAction>
}
export interface ChatWidgetTab extends WidgetTab {
data: {
_id?: Ref<Doc>
@@ -111,13 +94,6 @@ export interface ChatWidgetTab extends WidgetTab {
}
}
export type ChunterExtensionPoint = 'aside'
export interface ChunterExtension extends Doc {
ofClass: Ref<Class<Doc>>
point: ChunterExtensionPoint
component: AnyComponent
}
/**
* @public
*/
@@ -162,10 +138,7 @@ export default plugin(chunterId, {
DirectMessage: '' as Ref<Class<DirectMessage>>,
ChatMessage: '' as Ref<Class<ChatMessage>>,
ChatMessageViewlet: '' as Ref<Class<ChatMessageViewlet>>,
ChatSyncInfo: '' as Ref<Class<ChatSyncInfo>>,
InlineButton: '' as Ref<Class<InlineButton>>,
TypingInfo: '' as Ref<Class<TypingInfo>>,
ChunterExtension: '' as Ref<Class<ChunterExtension>>
ChatSyncInfo: '' as Ref<Class<ChatSyncInfo>>
},
mixin: {
ObjectChatPanel: '' as Ref<Mixin<ObjectChatPanel>>
+132 -1
View File
@@ -14,15 +14,22 @@
//
import {
Account,
AccountRole,
AttachedData,
buildSocialIdString,
Class,
Client,
Doc,
FindResult,
generateId,
Hierarchy,
MeasureContext,
PersonId,
Ref
Ref,
SocialId,
TxFactory,
Person as GlobalPerson
} from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import { ColorDefinition } from '@hcengineering/ui'
@@ -370,3 +377,127 @@ export async function getAllEmployeesPrimarySocialStrings (client: Client): Prom
return Object.values(socialStringsByPerson).map((it) => pickPrimarySocialId(it))
}
export async function ensureEmployee (
ctx: MeasureContext,
me: Account,
client: Client,
socialIds: SocialId[],
getGlobalPerson: () => Promise<GlobalPerson | undefined>
): Promise<Ref<Employee> | null> {
const txFactory = new TxFactory(me.primarySocialId)
const personByUuid = await client.findOne(contact.class.Person, { personUuid: me.uuid })
let personRef: Ref<Person> | undefined = personByUuid?._id
if (personRef === undefined) {
const socialIdentity = await client.findOne(contact.class.SocialIdentity, { key: { $in: me.socialIds } })
if (socialIdentity !== undefined && !socialIdentity.confirmed) {
const updateSocialIdentityTx = txFactory.createTxUpdateDoc(
contact.class.SocialIdentity,
contact.space.Contacts,
socialIdentity._id,
{
confirmed: true
}
)
await client.tx(updateSocialIdentityTx)
}
personRef = socialIdentity?.attachedTo
}
if (personRef === undefined) {
await ctx.with('create-person', {}, async () => {
const globalPerson = await getGlobalPerson()
if (globalPerson === undefined) {
console.error('Cannot get global person')
return null
}
const data = {
personUuid: me.uuid,
name: combineName(globalPerson.firstName, globalPerson.lastName),
city: globalPerson.city,
avatarType: AvatarType.COLOR
}
personRef = generateId()
const createPersonTx = txFactory.createTxCreateDoc(contact.class.Person, contact.space.Contacts, data, personRef)
await client.tx(createPersonTx)
})
} else if (personByUuid === undefined) {
const updatePersonTx = txFactory.createTxUpdateDoc(contact.class.Person, contact.space.Contacts, personRef, {
personUuid: me.uuid
})
await client.tx(updatePersonTx)
}
if (me.role !== AccountRole.Guest) {
const employee = await client.findOne(contact.mixin.Employee, { _id: personRef as Ref<Employee> })
if (employee === undefined || !client.getHierarchy().hasMixin(employee, contact.mixin.Employee)) {
await ctx.with('create-employee', {}, async () => {
if (personRef === undefined) {
// something went wrong
console.error('Person not found')
return null
}
const createEmployeeTx = txFactory.createTxMixin(
personRef,
contact.class.Person,
contact.space.Contacts,
contact.mixin.Employee,
{
active: true
}
)
await client.tx(createEmployeeTx)
})
}
}
const existingIdentifiers = await client.findAll(contact.class.SocialIdentity, {
attachedTo: personRef,
attachedToClass: contact.class.Person
})
for (const socialId of socialIds) {
const existing = existingIdentifiers.find((it) => it.type === socialId.type && it.value === socialId.value)
if (existing === undefined) {
await ctx.with('create-social-identity', {}, async () => {
if (personRef === undefined) {
// something went wrong
console.error('Person not found')
return null
}
const createSocialIdTx = txFactory.createTxCollectionCUD(
contact.class.Person,
personRef,
contact.space.Contacts,
'socialIds',
txFactory.createTxCreateDoc(contact.class.SocialIdentity, contact.space.Contacts, {
attachedTo: personRef,
attachedToClass: contact.class.Person,
collection: 'socialIds',
type: socialId.type,
value: socialId.value,
key: buildSocialIdString(socialId), // TODO: fill it in trigger or on DB level as stored calculated column or smth?
confirmed: socialId.verifiedOn !== undefined && socialId.verifiedOn > 0
})
)
await client.tx(createSocialIdTx)
})
}
}
// TODO: check for merged persons with this one and do the merge
return personRef as Ref<Employee>
}
+5 -2
View File
@@ -113,10 +113,13 @@ export async function getToken (
}
function getTokenRoomName (roomName: string, roomId: Ref<Room>): string {
const loc = getCurrentLocation()
const currentWorkspace = get(currentWorkspaceStore)
return `${currentWorkspace?.url ?? loc.path[1]}_${roomName}_${roomId}`
if (currentWorkspace == null) {
throw new Error('Current workspace not found')
}
return `${currentWorkspace.uuid}_${roomName}_${roomId}`
}
export const lk: LKRoom = new LKRoom({
+11 -135
View File
@@ -7,27 +7,16 @@ import core, {
metricsToString,
setCurrentAccount,
versionToString,
TxFactory,
generateId,
type SocialId,
type Account,
type Client,
type MeasureContext,
type MeasureMetricsContext,
type Version,
type Ref,
buildSocialIdString,
pickPrimarySocialId,
AccountRole,
type WorkspaceDataId
type WorkspaceDataId,
type Person as GlobalPerson
} from '@hcengineering/core'
import contact, {
combineName,
setCurrentEmployee,
AvatarType,
type Person,
type Employee
} from '@hcengineering/contact'
import { setCurrentEmployee, ensureEmployee } from '@hcengineering/contact'
import login, { loginId } from '@hcengineering/login'
import { broadcastEvent, getMetadata, getResource, OK, setMetadata, translateCB } from '@hcengineering/platform'
import presentation, {
@@ -364,7 +353,7 @@ export async function connect (title: string): Promise<Client | undefined> {
}
// Ensure employee and social identifiers
const employee = await ensureEmployee(ctx, me, newClient, socialIds)
const employee = await ensureEmployee(ctx, me, newClient, socialIds, getGlobalPerson)
if (employee == null) {
console.log('Failed to ensure employee')
@@ -425,129 +414,16 @@ export async function connect (title: string): Promise<Client | undefined> {
return newClient
}
async function ensureEmployee (
ctx: MeasureContext,
me: Account,
client: Client,
socialIds: SocialId[]
): Promise<Ref<Employee> | null> {
const txFactory = new TxFactory(me.primarySocialId)
const personByUuid = await client.findOne(contact.class.Person, { personUuid: me.uuid })
let personRef: Ref<Person> | undefined = personByUuid?._id
if (personRef === undefined) {
const socialIdentity = await client.findOne(contact.class.SocialIdentity, { key: { $in: me.socialIds } })
async function getGlobalPerson (): Promise<GlobalPerson | undefined> {
const getPerson = await getResource(login.function.GetPerson)
const [status, globalPerson] = await getPerson()
if (socialIdentity !== undefined && !socialIdentity.confirmed) {
const updateSocialIdentityTx = txFactory.createTxUpdateDoc(
contact.class.SocialIdentity,
contact.space.Contacts,
socialIdentity._id,
{
confirmed: true
}
)
await client.tx(updateSocialIdentityTx)
}
personRef = socialIdentity?.attachedTo
if (status !== OK) {
console.error('Error getting global person')
return undefined
}
if (personRef === undefined) {
await ctx.with('create-person', {}, async () => {
const getPerson = await getResource(login.function.GetPerson)
const [status, globalPerson] = await getPerson()
if (status !== OK) {
console.error('Error getting global person')
return null
}
const data = {
personUuid: me.uuid,
name: combineName(globalPerson.firstName, globalPerson.lastName),
city: globalPerson.city,
avatarType: AvatarType.COLOR
}
personRef = generateId()
const createPersonTx = txFactory.createTxCreateDoc(contact.class.Person, contact.space.Contacts, data, personRef)
await client.tx(createPersonTx)
})
} else if (personByUuid === undefined) {
const updatePersonTx = txFactory.createTxUpdateDoc(contact.class.Person, contact.space.Contacts, personRef, {
personUuid: me.uuid
})
await client.tx(updatePersonTx)
}
if (me.role !== AccountRole.Guest) {
const employee = await client.findOne(contact.mixin.Employee, { _id: personRef as Ref<Employee> })
if (employee === undefined || !client.getHierarchy().hasMixin(employee, contact.mixin.Employee)) {
await ctx.with('create-employee', {}, async () => {
if (personRef === undefined) {
// something went wrong
console.error('Person not found')
return null
}
const createEmployeeTx = txFactory.createTxMixin(
personRef,
contact.class.Person,
contact.space.Contacts,
contact.mixin.Employee,
{
active: true
}
)
await client.tx(createEmployeeTx)
})
}
}
const existingIdentifiers = await client.findAll(contact.class.SocialIdentity, {
attachedTo: personRef,
attachedToClass: contact.class.Person
})
for (const socialId of socialIds) {
const existing = existingIdentifiers.find((it) => it.type === socialId.type && it.value === socialId.value)
if (existing === undefined) {
await ctx.with('create-social-identity', {}, async () => {
if (personRef === undefined) {
// something went wrong
console.error('Person not found')
return null
}
const createSocialIdTx = txFactory.createTxCollectionCUD(
contact.class.Person,
personRef,
contact.space.Contacts,
'socialIds',
txFactory.createTxCreateDoc(contact.class.SocialIdentity, contact.space.Contacts, {
attachedTo: personRef,
attachedToClass: contact.class.Person,
collection: 'socialIds',
type: socialId.type,
value: socialId.value,
key: buildSocialIdString(socialId), // TODO: fill it in trigger or on DB level as stored calculated column or smth?
confirmed: socialId.verifiedOn !== undefined && socialId.verifiedOn > 0
})
)
await client.tx(createSocialIdTx)
})
}
}
// TODO: check for merged persons with this one and do the merge
return personRef as Ref<Employee>
return globalPerson
}
export function clearMetadata (ws: string): void {
-1
View File
@@ -73,7 +73,6 @@ setMetadata(serverToken.metadata.Secret, config.serverSecret)
setMetadata(serverNotification.metadata.SesUrl, config.sesUrl ?? '')
setMetadata(serverNotification.metadata.SesAuthToken, config.sesAuthToken)
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, {
@@ -16,7 +16,8 @@ import {
TxMixin,
TxProcessor,
TxUpdateDoc,
combineAttributes
combineAttributes,
ArrOf
} from '@hcengineering/core'
import core from '@hcengineering/core/src/component'
import notification from '@hcengineering/notification'
@@ -267,6 +268,8 @@ export async function getTxAttributesUpdates (
if (clazz !== undefined && 'to' in clazz.type) {
attrClass = clazz.type.to as Ref<Class<Doc>>
} else if (clazz !== undefined && hierarchy.isDerived(clazz.type._class, core.class.ArrOf)) {
attrClass = (clazz.type as ArrOf<Doc>).of._class
} else if (clazz !== undefined && 'of' in clazz?.type) {
attrClass = (clazz.type.of as RefTo<Doc>).to
}
+2 -1
View File
@@ -46,9 +46,10 @@
"@hcengineering/platform": "^0.6.11",
"@hcengineering/server-activity-resources": "^0.6.0",
"@hcengineering/server-ai-bot": "^0.6.0",
"@hcengineering/server-contact": "^0.6.1",
"@hcengineering/server-core": "^0.6.1",
"@hcengineering/server-token": "^0.6.11",
"@hcengineering/server-templates": "^0.6.0",
"@hcengineering/server-token": "^0.6.11",
"@hcengineering/templates": "^0.6.11"
}
}
+152 -338
View File
@@ -1,5 +1,5 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
// Copyright © 2024-2025 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
@@ -13,367 +13,181 @@
// limitations under the License.
//
import { ChatMessage } from '@hcengineering/chunter'
import { AttachedDoc, Tx, TxCreateDoc, TxCUD } from '@hcengineering/core'
import { ActivityInboxNotification, MentionInboxNotification } from '@hcengineering/notification'
import core, {
Doc,
PersonUuid,
systemAccountUuid,
Tx,
TxCreateDoc,
TxCUD,
TxProcessor,
TxUpdateDoc,
UserStatus
} from '@hcengineering/core'
import { TriggerControl } from '@hcengineering/server-core'
import { getPerson, getPersons } from '@hcengineering/server-contact'
import { aiBotEmailSocialId, AIEventRequest } from '@hcengineering/ai-bot'
// async function isDirectAvailable (direct: DirectMessage, control: TriggerControl): Promise<boolean> {
// const { members } = direct
import { createAccountRequest, hasAiEndpoint, sendAIEvents } from './utils'
import chunter, { ChatMessage, DirectMessage, ThreadMessage } from '@hcengineering/chunter'
// if (!members.includes(aiBot.account.AIBot)) {
// return false
// }
async function OnUserStatus (txes: TxCUD<UserStatus>[], control: TriggerControl): Promise<Tx[]> {
if (!hasAiEndpoint()) {
return []
}
// const personAccounts = await control.modelDb.findAll(contact.class.PersonAccount, {
// _id: { $in: members as PersonId[] }
// })
// const persons = new Set(personAccounts.map((account) => account.person))
if (control.txFactory.account === aiBotEmailSocialId) {
return []
}
// return persons.size === 2
// }
for (const tx of txes) {
if (![core.class.TxCreateDoc, core.class.TxUpdateDoc].includes(tx._class)) {
continue
}
// async function getMessageDoc (message: ChatMessage, control: TriggerControl): Promise<Doc | undefined> {
// if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) {
// const thread = message as ThreadMessage
// const _id = thread.objectId
// const _class = thread.objectClass
if (tx._class === core.class.TxCreateDoc) {
const createTx = tx as TxCreateDoc<UserStatus>
const status = TxProcessor.createDoc2Doc(createTx)
if (status.user === systemAccountUuid) {
continue
}
}
// return (await control.findAll(control.ctx, _class, { _id }))[0]
// } else {
// const _id = message.attachedTo
// const _class = message.attachedToClass
if (tx._class === core.class.TxUpdateDoc) {
const updateTx = tx as TxUpdateDoc<UserStatus>
const val = updateTx.operations.online
if (val !== true) {
continue
}
// return (await control.findAll(control.ctx, _class, { _id }))[0]
// }
// }
const status = (await control.findAll(control.ctx, core.class.UserStatus, { _id: updateTx.objectId }))[0]
if (status === undefined || status.user === systemAccountUuid) {
continue
}
}
// 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,
// collection: message.collection,
// messageClass: message._class,
// messageId: message._id,
// message: message.message,
// user: message.createdBy ?? message.modifiedBy,
// email
// }
// }
const aiBotPerson = await getPerson(control, aiBotEmailSocialId)
// 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,
// collection: message.collection,
// messageClass: message._class,
// message: message.message,
// messageId: message._id,
// user: message.createdBy ?? message.modifiedBy,
// email
// }
// }
if (aiBotPerson === undefined) {
await createAccountRequest(control.workspace.uuid, control.ctx)
return []
}
}
// async function getThreadParent (control: TriggerControl, message: ChatMessage): Promise<Ref<ChatMessage> | undefined> {
// if (!control.hierarchy.isDerived(message.attachedToClass, chunter.class.ChatMessage)) {
// return undefined
// }
return []
}
// const parentInfo = (
// await control.findAll(control.ctx, message.attachedToClass, {
// _id: message.attachedTo as Ref<ChatMessage>,
// [aiBot.mixin.TransferredMessage]: { $exists: true }
// })
// )[0]
async function OnMessageSend (originTxs: TxCreateDoc<ChatMessage>[], control: TriggerControl): Promise<Tx[]> {
if (!hasAiEndpoint()) {
return []
}
// if (parentInfo !== undefined) {
// return control.hierarchy.as(parentInfo, aiBot.mixin.TransferredMessage).messageId
// }
const { hierarchy } = control
const txes = originTxs.filter((it) => it.modifiedBy !== aiBotEmailSocialId)
// return message.attachedTo as Ref<ChatMessage>
// }
if (txes.length === 0) {
return []
}
// async function createTransferEvent (
// control: TriggerControl,
// message: ChatMessage,
// account: any,
// data: AIMessageEventRequest
// ): Promise<AITransferEventRequest | undefined> {
// if (account.role !== AccountRole.Owner) {
// return
// }
for (const tx of txes) {
const message = TxProcessor.createDoc2Doc(tx)
// const supportWorkspaceId = getSupportWorkspaceId()
const isThread = hierarchy.isDerived(tx.objectClass, chunter.class.ThreadMessage)
const docClass = isThread ? (message as ThreadMessage).objectClass : message.attachedToClass
// if (supportWorkspaceId === undefined) {
// return
// }
if (!hierarchy.isDerived(docClass, chunter.class.DirectMessage)) {
continue
}
// return {
// type: AIEventType.Transfer,
// createdOn: message.createdOn ?? message.modifiedOn,
// messageClass: data.messageClass,
// message: message.message,
// collection: data.collection,
// toWorkspace: supportWorkspaceId,
// toPersonId: account,
// fromWorkspace: control.workspace.uuid,
// // fromWorkspaceName: control.workspace.workspaceName,
// fromWorkspaceUrl: control.workspace.url,
// messageId: message._id,
// parentMessageId: await getThreadParent(control, message)
// }
// }
if (docClass === chunter.class.DirectMessage) {
await onBotDirectMessageSend(control, message)
}
}
return []
}
function getMessageData (doc: Doc, message: ChatMessage): AIEventRequest {
return {
createdOn: message.createdOn ?? message.modifiedOn,
objectId: message.attachedTo,
objectClass: message.attachedToClass,
objectSpace: doc.space,
collection: message.collection,
messageClass: message._class,
messageId: message._id,
message: message.message,
user: message.createdBy ?? message.modifiedBy
}
}
function getThreadMessageData (message: ThreadMessage): AIEventRequest {
return {
createdOn: message.createdOn ?? message.modifiedOn,
objectId: message.attachedTo,
objectClass: message.attachedToClass,
objectSpace: message.space,
collection: message.collection,
messageClass: message._class,
message: message.message,
messageId: message._id,
user: message.createdBy ?? message.modifiedBy
}
}
async function getMessageDoc (message: ChatMessage, control: TriggerControl): Promise<Doc | undefined> {
if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) {
const thread = message as ThreadMessage
const _id = thread.objectId
const _class = thread.objectClass
return (await control.findAll(control.ctx, _class, { _id }))[0]
} else {
const _id = message.attachedTo
const _class = message.attachedToClass
return (await control.findAll(control.ctx, _class, { _id }))[0]
}
}
async function isDirectAvailable (direct: DirectMessage, control: TriggerControl): Promise<boolean> {
const { members } = direct
if (!members.includes(aiBotEmailSocialId)) {
return false
}
const persons = await getPersons(control, members)
const uuids = new Set(
persons.map((account) => account.personUuid).filter((uuid): uuid is PersonUuid => uuid !== undefined)
)
return uuids.size === 2
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function onBotDirectMessageSend (control: TriggerControl, message: ChatMessage): Promise<void> {
// TODO: FIXME
// const account = control.modelDb.findAllSync(contact.class.PersonAccount, {
// _id: (message.createdBy ?? message.modifiedBy) as PersonId
// })[0]
// if (account === undefined) {
// return
// }
// const direct = (await getMessageDoc(message, control)) as DirectMessage
// if (direct === undefined) {
// return
// }
// const isAvailable = await isDirectAvailable(direct, control)
// if (!isAvailable) {
// return
// }
// let messageEvent: AIMessageEventRequest
// if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) {
// messageEvent = getThreadMessageData(message as ThreadMessage, account.email)
// } else {
// messageEvent = getMessageData(direct, message, account.email)
// }
// const transferEvent = await createTransferEvent(control, message, account, messageEvent)
// const events = transferEvent !== undefined ? [messageEvent, transferEvent] : [messageEvent]
// await sendAIEvents(events, control.workspace.uuid, control.ctx)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function onSupportWorkspaceMessage (control: TriggerControl, message: ChatMessage): Promise<void> {
// TODO: FIXME
// const supportWorkspaceId = getSupportWorkspaceId()
// if (supportWorkspaceId === undefined) {
// return
// }
// if (control.workspace.uuid !== supportWorkspaceId) {
// return
// }
// if (!control.hierarchy.isDerived(message.attachedToClass, analyticsCollector.class.OnboardingChannel)) {
// return
// }
// const channel = (await getMessageDoc(message, control)) as OnboardingChannel
// if (channel === undefined) {
// return
// }
// const { workspaceId, email } = channel
// const account = control.modelDb.findAllSync(contact.class.PersonAccount, {
// _id: (message.createdBy ?? message.modifiedBy) as PersonId
// })[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 transferEvent: AITransferEventRequest = {
// type: AIEventType.Transfer,
// createdOn: data.createdOn,
// messageClass: data.messageClass,
// message: message.message,
// collection: data.collection,
// toEmail: email,
// toWorkspace: workspaceId,
// fromWorkspace: control.workspace.uuid,
// fromWorkspaceUrl: control.workspace.url,
// // fromWorkspaceName: control.workspace.workspaceName,
// messageId: message._id,
// parentMessageId: await getThreadParent(control, message)
// }
// await sendAIEvents([transferEvent], control.workspace.uuid, control.ctx)
}
export async function OnMessageSend (originTxs: TxCUD<AttachedDoc>[], control: TriggerControl): Promise<Tx[]> {
// TODO: FIXME
// const { hierarchy } = control
// const txes = originTxs.filter(
// (it) =>
// it._class === core.class.TxCreateDoc &&
// hierarchy.isDerived(it.objectClass, chunter.class.ChatMessage) &&
// !(it.modifiedBy === aiBot.account.AIBot || it.modifiedBy === core.account.System)
// )
// if (txes.length === 0) {
// return []
// }
// for (const tx of txes) {
// const isThread = hierarchy.isDerived(tx.objectClass, chunter.class.ThreadMessage)
// const message = TxProcessor.createDoc2Doc(tx as TxCreateDoc<ChatMessage>)
//
// const docClass = isThread ? (message as ThreadMessage).objectClass : message.attachedToClass
//
// if (!hierarchy.isDerived(docClass, chunter.class.ChunterSpace)) {
// continue
// }
//
// if (docClass === chunter.class.DirectMessage) {
// await onBotDirectMessageSend(control, message)
// }
//
// if (docClass === analyticsCollector.class.OnboardingChannel) {
// await onSupportWorkspaceMessage(control, message)
// }
// }
return []
}
export async function OnMention (tx: TxCreateDoc<MentionInboxNotification>[], control: TriggerControl): Promise<Tx[]> {
// Note: temporally commented until open ai will be added
// if (tx.objectClass !== notification.class.MentionInboxNotification || tx._class !== core.class.TxCreateDoc) {
// return []
// }
//
// const mention = TxProcessor.createDoc2Doc(tx)
//
// if (mention.user !== aiBot.account.AIBot) {
// return []
// }
//
// if (!control.hierarchy.isDerived(mention.mentionedInClass, chunter.class.ChatMessage)) {
// return []
// }
//
// const message = (
// await control.findAll<ChatMessage>(mention.mentionedInClass, { _id: mention.mentionedIn as Ref<ChatMessage> })
// )[0]
//
// if (message === undefined) {
// return []
// }
//
// await createResponseEvent(message, control)
return []
}
export async function OnMessageNotified (
tx: TxCreateDoc<ActivityInboxNotification>[],
control: TriggerControl
): Promise<Tx[]> {
// Note: temporally commented until open ai will be added
// if (tx.objectClass !== notification.class.ActivityInboxNotification || tx._class !== core.class.TxCreateDoc) {
// return []
// }
//
// const doc = TxProcessor.createDoc2Doc(tx)
//
// if (doc.user !== aiBot.account.AIBot) {
// return []
// }
//
// if (!control.hierarchy.isDerived(doc.attachedToClass, chunter.class.ChatMessage)) {
// return []
// }
//
// const personAccount = await control.modelDb.findOne(contact.class.PersonAccount, { email: aiBotAccountEmail })
//
// if (personAccount === undefined) {
// return []
// }
//
// const message = (
// await control.findAll<ChatMessage>(doc.attachedToClass, { _id: doc.attachedTo as Ref<ChatMessage> })
// )[0]
//
// if (message === undefined) {
// return []
// }
//
// if (isDocMentioned(personAccount.person, message.message)) {
// return await createResponseEvent(message, control)
// }
//
// if (!control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) {
// return []
// }
//
// const thread = message as ThreadMessage
// TODO: do we really need to find parent???
// const parent = (await control.findAll(thread.attachedToClass, { _id: thread.attachedTo }))[0]
//
// if (parent === undefined) {
// return []
// }
//
// if (parent.createdBy === aiBot.account.AIBot) {
// return await createResponseEvent(message, control)
// }
return []
}
export async function OnUserStatus (txes: Tx[], control: TriggerControl): Promise<Tx[]> {
// TODO: FIXME
return [] // Not implemented
// for (const originTx of txes) {
// const tx = originTx as TxCUD<UserStatus>
// if (
// tx.objectClass !== core.class.UserStatus ||
// ![core.class.TxCreateDoc, core.class.TxUpdateDoc].includes(tx._class)
// ) {
// continue
// }
// if (tx._class === core.class.TxCreateDoc) {
// const createTx = tx as TxCreateDoc<UserStatus>
// const status = TxProcessor.createDoc2Doc(createTx)
// if (status.user === aiBot.account.AIBot || status.user === core.account.System || !status.online) {
// continue
// }
// }
// if (tx._class === core.class.TxUpdateDoc) {
// const updateTx = tx as TxUpdateDoc<UserStatus>
// const val = updateTx.operations.online
// if (val !== true) {
// continue
// }
// const status = (await control.findAll(control.ctx, core.class.UserStatus, { _id: updateTx.objectId }))[0]
// if (status === undefined || status.user === aiBot.account.AIBot || status.user === core.account.System) {
// continue
// }
// }
// const account = control.modelDb.findAllSync(contact.class.PersonAccount, { email: aiBotAccountEmail })[0]
// if (account !== undefined) {
// continue
// }
// await createAccountRequest(control.workspace, control.ctx)
// }
// return []
const direct = (await getMessageDoc(message, control)) as DirectMessage
if (direct === undefined) {
return
}
const isAvailable = await isDirectAvailable(direct, control)
if (!isAvailable) {
return
}
let messageEvent: AIEventRequest
if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) {
messageEvent = getThreadMessageData(message as ThreadMessage)
} else {
messageEvent = getMessageData(direct, message)
}
await sendAIEvents([messageEvent], control.workspace.uuid, control.ctx)
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export default async () => ({
trigger: {
OnMessageSend,
OnMention,
OnMessageNotified,
OnUserStatus
OnUserStatus,
OnMessageSend
}
})
+22 -27
View File
@@ -1,5 +1,5 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
// Copyright © 2024-2025 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
@@ -15,18 +15,31 @@
import { getMetadata } from '@hcengineering/platform'
import serverAIBot from '@hcengineering/server-ai-bot'
import { AIEventRequest } from '@hcengineering/ai-bot'
import { concatLink, MeasureContext, systemAccountUuid, WorkspaceUuid } from '@hcengineering/core'
import { generateToken } from '@hcengineering/server-token'
import { AIEventRequest } from '@hcengineering/ai-bot'
export function getSupportWorkspaceId (): string | undefined {
const supportWorkspaceId = getMetadata(serverAIBot.metadata.SupportWorkspaceId)
export async function createAccountRequest (workspace: WorkspaceUuid, ctx: MeasureContext): Promise<void> {
const url = getMetadata(serverAIBot.metadata.EndpointURL) ?? ''
if (supportWorkspaceId === '') {
return undefined
if (url === '') {
return
}
return supportWorkspaceId
try {
ctx.info('Requesting AI account creation', { url, workspace })
await fetch(concatLink(url, '/connect'), {
method: 'POST',
keepalive: true,
headers: {
Authorization: 'Bearer ' + generateToken(systemAccountUuid, workspace, { service: 'aibot' }),
'Content-Type': 'application/json'
},
body: JSON.stringify({})
})
} catch (err) {
ctx.error('Could not send create ai account request', { err })
}
}
export async function sendAIEvents (
@@ -54,25 +67,7 @@ export async function sendAIEvents (
}
}
export async function createAccountRequest (workspace: WorkspaceUuid, ctx: MeasureContext): Promise<void> {
export function hasAiEndpoint (): boolean {
const url = getMetadata(serverAIBot.metadata.EndpointURL) ?? ''
if (url === '') {
return
}
try {
ctx.info('Requesting AI account creation', { url, workspace })
await fetch(concatLink(url, '/connect'), {
method: 'POST',
keepalive: true,
headers: {
Authorization: 'Bearer ' + generateToken(systemAccountUuid, workspace, { service: 'aibot' }),
'Content-Type': 'application/json'
},
body: JSON.stringify({})
})
} catch (err) {
ctx.error('Could not send create ai account request', { err })
}
return url !== ''
}
+2 -5
View File
@@ -1,5 +1,5 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
// Copyright © 2024-2025 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
@@ -22,13 +22,10 @@ export const serverAiBotId = 'server-ai-bot' as Plugin
export default plugin(serverAiBotId, {
metadata: {
SupportWorkspaceId: '' as Metadata<string>,
EndpointURL: '' as Metadata<string>
},
trigger: {
OnMessageSend: '' as Resource<TriggerFunc>,
OnMention: '' as Resource<TriggerFunc>,
OnUserStatus: '' as Resource<TriggerFunc>,
OnMessageNotified: '' as Resource<TriggerFunc>
OnMessageSend: '' as Resource<TriggerFunc>
}
})
+4
View File
@@ -17,6 +17,8 @@ import { SttProvider } from './type.js'
interface Config {
DeepgramApiKey: string
DeepgramModel: string
DeepgramEnModel: string
OpenAiModel: string
OpenaiApiKey: string
OpenaiBaseUrl: string
@@ -28,6 +30,8 @@ interface Config {
const config: Config = (() => {
const params: Partial<Config> = {
DeepgramApiKey: process.env.DEEPGRAM_API_KEY ?? '',
DeepgramModel: process.env.DEEPGRAM_MODEL ?? 'nova-2-general',
DeepgramEnModel: process.env.DEEPGRAM_EN_MODEL ?? 'nova-3-general',
OpenAiModel: process.env.OPENAI_MODEL ?? 'gpt-4o-realtime-preview-2024-12-17',
OpenaiApiKey: process.env.OPENAI_API_KEY ?? '',
OpenaiBaseUrl: process.env.OPENAI_BASE_URL ?? '',
+18 -13
View File
@@ -30,7 +30,7 @@ import config from '../config.js'
const KEEP_ALIVE_INTERVAL = 10 * 1000
const dgSchema: LiveSchema = {
model: 'nova-2-general',
model: config.DeepgramModel,
encoding: 'linear16',
smart_format: true,
endpointing: 500,
@@ -90,18 +90,22 @@ export class STT implements Stt {
}
subscribe (track: RemoteTrack, publication: RemoteTrackPublication, participant: RemoteParticipant): void {
if (this.trackBySid.has(publication.sid)) return
this.trackBySid.set(publication.sid, track)
this.participantBySid.set(publication.sid, participant)
const sid = publication.sid
if (sid === undefined) return
if (this.trackBySid.has(sid)) return
this.trackBySid.set(sid, track)
this.participantBySid.set(sid, participant)
if (this.isInProgress) {
this.processTrack(publication.sid)
this.processTrack(sid)
}
}
unsubscribe (_: RemoteTrack | undefined, publication: RemoteTrackPublication, participant: RemoteParticipant): void {
this.trackBySid.delete(publication.sid)
this.participantBySid.delete(participant.sid)
this.stopDeepgram(publication.sid)
const sid = publication.sid
if (sid === undefined) return
this.trackBySid.delete(sid)
this.participantBySid.delete(sid)
this.stopDeepgram(sid)
}
stopDeepgram (sid: string): void {
@@ -132,11 +136,13 @@ export class STT implements Stt {
if (this.dgConnectionBySid.has(sid)) return
const stream = new AudioStream(track)
const language = this.language ?? 'en'
const dgConnection = this.deepgram.listen.live({
...dgSchema,
channels: stream.numChannels,
sample_rate: stream.sampleRate,
language: this.language ?? 'en'
language,
model: language === 'en' || language === 'en-US' ? config.DeepgramEnModel : config.DeepgramModel
})
console.log('Starting deepgram for track', this.room.name, sid)
@@ -145,8 +151,8 @@ export class STT implements Stt {
}, KEEP_ALIVE_INTERVAL)
this.streamBySid.set(sid, stream)
this.dgConnectionBySid.set(track.sid, dgConnection)
this.intervalBySid.set(track.sid, interval)
this.dgConnectionBySid.set(sid, dgConnection)
this.intervalBySid.set(sid, interval)
dgConnection.on(LiveTranscriptionEvents.Open, () => {
dgConnection.on(LiveTranscriptionEvents.Transcript, (data: LiveTranscriptionEvent) => {
@@ -165,8 +171,7 @@ export class STT implements Stt {
})
dgConnection.on(LiveTranscriptionEvents.Close, (d) => {
console.log('Connection closed.', d, track.sid)
this.stopDeepgram(track.sid)
this.stopDeepgram(sid)
})
dgConnection.on(LiveTranscriptionEvents.Error, (err) => {
+6 -7
View File
@@ -55,39 +55,38 @@
},
"dependencies": {
"@hcengineering/account": "^0.6.0",
"@hcengineering/account-client": "^0.6.0",
"@hcengineering/ai-bot": "^0.6.0",
"@hcengineering/analytics-collector": "^0.6.0",
"@hcengineering/document": "^0.6.0",
"@hcengineering/attachment": "^0.6.14",
"@hcengineering/chunter": "^0.6.20",
"@hcengineering/client": "^0.6.18",
"@hcengineering/client-resources": "^0.6.27",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/core": "^0.6.32",
"@hcengineering/document": "^0.6.0",
"@hcengineering/love": "^0.6.0",
"@hcengineering/mongo": "^0.6.1",
"@hcengineering/notification": "^0.6.23",
"@hcengineering/openai": "^0.6.0",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/rank": "^0.6.4",
"@hcengineering/server-ai-bot": "^0.6.0",
"@hcengineering/server-analytics-collector-resources": "^0.6.0",
"@hcengineering/server-client": "^0.6.0",
"@hcengineering/server-core": "^0.6.1",
"@hcengineering/server-storage": "^0.6.0",
"@hcengineering/server-token": "^0.6.11",
"@hcengineering/setting": "^0.6.17",
"@hcengineering/text": "^0.6.5",
"@hcengineering/rank": "^0.6.4",
"@hcengineering/server-storage": "^0.6.0",
"@hcengineering/workbench": "^0.6.16",
"@hcengineering/love": "^0.6.0",
"cors": "^2.8.5",
"dotenv": "~16.0.0",
"express": "^4.21.2",
"fast-equals": "^5.2.2",
"form-data": "^4.0.0",
"js-tiktoken": "^1.0.14",
"uuid": "^8.3.2",
"mongodb": "^6.12.0",
"openai": "^4.56.0",
"uuid": "^8.3.2",
"ws": "^8.18.0"
}
}
-3
View File
@@ -14,7 +14,6 @@
//
import OpenAI from 'openai'
import { type WorkspaceUuid } from '@hcengineering/core'
interface Config {
AccountsURL: string
@@ -22,7 +21,6 @@ interface Config {
MongoURL: string
ServerSecret: string
ServiceID: string
SupportWorkspace: WorkspaceUuid
FirstName: string
LastName: string
AvatarPath: string
@@ -49,7 +47,6 @@ const config: Config = (() => {
MongoURL: process.env.MONGO_URL,
ServerSecret: process.env.SERVER_SECRET,
ServiceID: process.env.SERVICE_ID ?? 'ai-bot-service',
// SupportWorkspace: process.env.SUPPORT_WORKSPACE as WorkspaceUuid, // TODO: FIXME
FirstName: process.env.FIRST_NAME,
LastName: process.env.LAST_NAME,
AvatarPath: process.env.AVATAR_PATH ?? './assets/avatar.png',
+30 -124
View File
@@ -14,22 +14,15 @@
//
import {
aiBotAccount,
AIEventRequest,
AIEventType,
AIMessageEventRequest,
AITransferEventRequest,
ConnectMeetingRequest,
DisconnectMeetingRequest,
IdentityResponse,
OnboardingEvent,
OnboardingEventRequest,
OpenChatInSidebarData,
PostTranscriptRequest,
TranslateRequest,
TranslateResponse
} from '@hcengineering/ai-bot'
import { Markup, MeasureContext, Ref, type WorkspaceDataId, type WorkspaceUuid } from '@hcengineering/core'
import { MeasureContext, PersonUuid, Ref, SocialId, type WorkspaceUuid } from '@hcengineering/core'
import { Room } from '@hcengineering/love'
import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot'
import { getTransactorEndpoint } from '@hcengineering/server-client'
@@ -42,11 +35,9 @@ import { StorageAdapter } from '@hcengineering/server-core'
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
import config from './config'
import { DbStorage } from './storage'
import { AIReplyTransferData } from './types'
import { tryAssignToWorkspace } from './utils/account'
import { translateHtml } from './utils/openai'
import { SupportWsClient } from './workspace/supportWsClient'
import { WorkspaceClient } from './workspace/workspaceClient'
import { translateHtml } from './utils/openai'
import { tryAssignToWorkspace } from './utils/account'
const CLOSE_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes
@@ -55,36 +46,29 @@ export class AIControl {
private readonly closeWorkspaceTimeouts: Map<WorkspaceUuid, NodeJS.Timeout> = new Map<WorkspaceUuid, NodeJS.Timeout>()
private readonly connectingWorkspaces = new Map<WorkspaceUuid, Promise<void>>()
readonly aiClient?: OpenAI
readonly storageAdapter: StorageAdapter
readonly encoding = encodingForModel(config.OpenAIModel)
supportClient: SupportWsClient | undefined = undefined
private readonly openai?: OpenAI
private readonly openaiEncoding = encodingForModel(config.OpenAIModel)
constructor (
readonly storage: DbStorage,
readonly personUuid: PersonUuid,
readonly socialIds: SocialId[],
private readonly storage: DbStorage,
private readonly ctx: MeasureContext
) {
this.aiClient =
this.openai =
config.OpenAIKey !== ''
? new OpenAI({
apiKey: config.OpenAIKey,
baseURL: config.OpenAIBaseUrl === '' ? undefined : config.OpenAIBaseUrl
})
: undefined
void this.connectSupportWorkspace()
this.storageAdapter = buildStorageFromConfig(storageConfigFromEnv())
}
async getWorkspaceRecord (workspace: string): Promise<WorkspaceInfoRecord> {
return (await this.storage.getWorkspace(workspace)) ?? { workspace: config.SupportWorkspace }
}
async connectSupportWorkspace (): Promise<void> {
if (this.supportClient === undefined && config.SupportWorkspace !== '') {
const record = await this.getWorkspaceRecord(config.SupportWorkspace)
this.supportClient = (await this.createWorkspaceClient(config.SupportWorkspace, record)) as SupportWsClient
}
async getWorkspaceRecord (workspace: string): Promise<WorkspaceInfoRecord | undefined> {
return await this.storage.getWorkspace(workspace)
}
async closeWorkspaceClient (workspace: WorkspaceUuid): Promise<void> {
@@ -123,44 +107,31 @@ export class AIControl {
const isAssigned = await tryAssignToWorkspace(workspace, this.ctx)
if (!isAssigned) {
this.ctx.error('Cannot assign to workspace', { workspace })
return
}
const token = generateToken(aiBotAccount, workspace, { service: 'aibot' })
const token = generateToken(this.personUuid, workspace, { service: 'aibot' })
const endpoint = await getTransactorEndpoint(token)
this.ctx.info('Listen workspace: ', { workspace })
if (workspace === config.SupportWorkspace) {
return new SupportWsClient(
this.storageAdapter,
endpoint,
token,
workspace,
workspace as unknown as WorkspaceDataId, // TODO: FIXME
this,
this.ctx.newChild(workspace, {}),
info
)
}
return new WorkspaceClient(
this.storageAdapter,
this.storage,
endpoint,
token,
workspace,
workspace as unknown as WorkspaceDataId, // TODO: FIXME
this,
this.personUuid,
this.socialIds,
this.ctx.newChild(workspace, {}),
this.openai,
this.openaiEncoding,
info
)
}
async initWorkspaceClient (workspace: WorkspaceUuid): Promise<void> {
if (workspace === config.SupportWorkspace) {
return
}
if (this.connectingWorkspaces.has(workspace)) {
return await this.connectingWorkspaces.get(workspace)
}
@@ -168,7 +139,7 @@ export class AIControl {
const initPromise = (async () => {
try {
if (!this.workspaces.has(workspace)) {
const record = await this.getWorkspaceRecord(workspace)
const record = (await this.getWorkspaceRecord(workspace)) ?? { workspace }
const client = await this.createWorkspaceClient(workspace, record)
if (client === undefined) {
return
@@ -192,34 +163,6 @@ export class AIControl {
await initPromise
}
allowAiReplies (workspace: string, email: string): boolean {
if (this.supportClient === undefined) return true
return this.supportClient.allowAiReplies(workspace, email)
}
async transferAIReplyToSupport (response: Markup, data: AIReplyTransferData): Promise<void> {
if (this.supportClient === undefined) return
await this.supportClient.transferAIReply(response, data)
}
async transfer (event: AITransferEventRequest): Promise<void> {
const workspace = event.toWorkspace
if (workspace === config.SupportWorkspace) {
if (this.supportClient === undefined) return
await this.supportClient.transfer(event)
return
}
const wsClient = await this.getWorkspaceClient(workspace)
if (wsClient === undefined) return
await wsClient.transfer(event)
}
async close (): Promise<void> {
for (const workspace of this.workspaces.values()) {
await workspace.close()
@@ -230,42 +173,18 @@ export class AIControl {
this.workspaces.clear()
}
async updateAvatarInfo (workspace: string, path: string, lastModified: number): Promise<void> {
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: WorkspaceUuid): Promise<WorkspaceClient | undefined> {
await this.initWorkspaceClient(workspace)
return this.workspaces.get(workspace)
}
async openChatInSidebar (data: OpenChatInSidebarData): Promise<void> {
const wsClient = await this.getWorkspaceClient(data.workspace)
if (wsClient === undefined) return
await wsClient.openAIChatInSidebar(data.personId)
}
async processOnboardingEvent (event: OnboardingEventRequest): Promise<void> {
switch (event.event) {
case OnboardingEvent.OpenChatInSidebar:
await this.openChatInSidebar(event.data as OpenChatInSidebarData)
break
}
}
async translate (req: TranslateRequest): Promise<TranslateResponse | undefined> {
if (this.aiClient === undefined) {
if (this.openai === undefined) {
return undefined
}
const html = markupToHTML(req.text)
const result = await translateHtml(this.aiClient, html, req.lang)
const result = await translateHtml(this.openai, html, req.lang)
const text = result !== undefined ? htmlToMarkup(result) : req.text
return {
text,
@@ -273,26 +192,13 @@ export class AIControl {
}
}
async processMessageEvent (workspace: WorkspaceUuid, event: AIMessageEventRequest): Promise<void> {
const wsClient = await this.getWorkspaceClient(workspace)
if (wsClient === undefined) return
await wsClient.processMessageEvent(event)
}
async processEvent (workspace: WorkspaceUuid, events: AIEventRequest[]): Promise<void> {
if (this.openai === undefined) return
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
}
const wsClient = await this.getWorkspaceClient(workspace)
if (wsClient === undefined) continue
await wsClient.processMessageEvent(event)
}
}
@@ -331,14 +237,14 @@ export class AIControl {
async processLoveTranscript (request: PostTranscriptRequest): Promise<void> {
const parsed = request.roomName.split('_')
const workspace = parsed[0] as WorkspaceUuid
const roomId = parsed[parsed.length - 1]
const workspace = parsed[0] as WorkspaceUuid | undefined
const roomId = parsed[parsed.length - 1] as Ref<Room> | undefined
if (workspace === null || roomId === null) return
if (workspace == null || roomId == null) return
const wsClient = await this.getWorkspaceClient(workspace)
if (wsClient === undefined) return
await wsClient.processLoveTranscript(request.transcript, request.participant, roomId as Ref<Room>)
await wsClient.processLoveTranscript(request.transcript, request.participant, roomId)
}
}
+19 -39
View File
@@ -12,17 +12,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Token } from '@hcengineering/server-token'
import cors from 'cors'
import express, { type Express, type NextFunction, type Request, type Response } from 'express'
import { type Server } from 'http'
import {
TranslateRequest,
OnboardingEventRequest,
AIEventRequest,
ConnectMeetingRequest,
DisconnectMeetingRequest,
AIEventRequest,
PostTranscriptRequest
} from '@hcengineering/ai-bot'
import { extractToken } from '@hcengineering/server-client'
@@ -102,20 +100,18 @@ export function createServer (controller: AIControl, ctx: MeasureContext): Expre
app.post(
'/love/transcript',
wrapRequest(async (req, res, token) => {
// TODO: FIXME
throw new Error('Not implemented')
// if (req.body == null || Array.isArray(req.body) || typeof req.body !== 'object') {
// throw new ApiError(400)
// }
if (req.body == null || Array.isArray(req.body) || typeof req.body !== 'object') {
throw new ApiError(400)
}
// if (token.email !== aiBotAccountEmail) {
// throw new ApiError(401)
// }
if (token.account !== controller.personUuid) {
throw new ApiError(401)
}
// await controller.processLoveTranscript(req.body as PostTranscriptRequest)
await controller.processLoveTranscript(req.body as PostTranscriptRequest)
// res.status(200)
// res.json({})
res.status(200)
res.json({})
})
)
@@ -152,35 +148,19 @@ export function createServer (controller: AIControl, ctx: MeasureContext): Expre
app.get(
'/love/:roomName/identity',
wrapRequest(async (req, res, token) => {
// TODO: FIXME
throw new Error('Not implemented')
// if (token.email !== aiBotAccountEmail) {
// throw new ApiError(401)
// }
// const roomName = req.params.roomName
// const resp = await controller.getLoveIdentity(roomName)
// if (resp === undefined) {
// throw new ApiError(404)
// }
// res.status(200)
// res.json(resp)
})
)
app.post(
'/onboarding',
wrapRequest(async (req, res) => {
if (req.body == null || Array.isArray(req.body) || typeof req.body !== 'object') {
throw new ApiError(400)
if (token.account !== controller.personUuid) {
throw new ApiError(401)
}
await controller.processOnboardingEvent(req.body as OnboardingEventRequest)
const roomName = req.params.roomName
const resp = await controller.getLoveIdentity(roomName)
if (resp === undefined) {
throw new ApiError(404)
}
res.status(200)
res.json({})
res.json(resp)
})
)
+24 -23
View File
@@ -1,5 +1,5 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
// Copyright © 2024-2025 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
@@ -12,22 +12,22 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
/* eslint-disable @typescript-eslint/no-unused-vars */
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 serverClient, { withRetry } from '@hcengineering/server-client'
import serverToken, { generateToken } from '@hcengineering/server-token'
import { initStatisticsContext } from '@hcengineering/server-core'
import { AIControl } from './controller'
import config from './config'
import { closeDB, DbStorage, getDB } from './storage'
import { getPersonUuid } from './utils/account'
import { registerLoaders } from './loaders'
import { getDbStorage } from './storage'
import { AIControl } from './controller'
import { createServer, listen } from './server/server'
import type { SocialId } from '@hcengineering/core'
import { getClient as getAccountClient } from '@hcengineering/account-client'
export const start = async (): Promise<void> => {
setMetadata(serverToken.metadata.Secret, config.ServerSecret)
setMetadata(serverAiBot.metadata.SupportWorkspaceId, config.SupportWorkspace)
setMetadata(serverClient.metadata.UserAgent, config.ServiceID)
setMetadata(serverClient.metadata.Endpoint, config.AccountsURL)
@@ -36,28 +36,29 @@ export const start = async (): Promise<void> => {
const ctx = initStatisticsContext('ai-bot-service', {})
ctx.info('AI Bot Service started', { firstName: config.FirstName, lastName: config.LastName })
const db = await getDB()
const storage = new DbStorage(db)
for (let i = 0; i < 5; i++) {
ctx.info('Creating bot account', { attempt: i })
try {
// TODO: FIXME replace with signUp with getAccountClient
throw new Error('Not implemented')
// await createAccount(aiBotAccountEmail, config.Password, config.FirstName, config.LastName)
// break
} catch (e) {
ctx.error('Error during account creation', { error: e })
}
await new Promise((resolve) => setTimeout(resolve, 3000))
const personUuid = await withRetry(
async () => await getPersonUuid(ctx),
(_, attempt) => attempt >= 5,
5000
)()
if (personUuid === undefined) {
ctx.error('AI Bot Service failed to start. No person found.')
process.exit()
}
const aiControl = new AIControl(storage, ctx)
ctx.info('AI person uuid', { personUuid })
const storage = await getDbStorage()
const socialIds: SocialId[] = await getAccountClient(config.AccountsURL, generateToken(personUuid)).getSocialIds()
const aiControl = new AIControl(personUuid, socialIds, storage, ctx)
const app = createServer(aiControl, ctx)
const server = listen(app, config.Port)
const onClose = (): void => {
void aiControl.close()
void closeDB()
storage.close()
server.close(() => process.exit())
}
+8 -3
View File
@@ -24,7 +24,7 @@ import { HistoryRecord } from './types'
const clientRef: MongoClientReference = getMongoClient(config.MongoURL)
let client: MongoClient | undefined
export const getDB = (() => {
const connectDB = (() => {
return async () => {
if (client === undefined) {
client = await clientRef.getClient()
@@ -34,8 +34,9 @@ export const getDB = (() => {
}
})()
export const closeDB: () => Promise<void> = async () => {
clientRef.close()
export async function getDbStorage (): Promise<DbStorage> {
const db = await connectDB()
return new DbStorage(db)
}
export class DbStorage {
@@ -72,4 +73,8 @@ export class DbStorage {
async updateWorkspace (workspace: string, update: UpdateFilter<WorkspaceInfoRecord>): Promise<void> {
await this.workspacesInfoCollection.updateOne({ workspace }, update)
}
close (): void {
clientRef.close()
}
}
+2 -11
View File
@@ -14,8 +14,7 @@
//
import { ObjectId } from 'mongodb'
import { PersonId, Class, Doc, Ref } from '@hcengineering/core'
import { ChatMessage } from '@hcengineering/chunter'
import { Class, Doc, Ref, PersonUuid } from '@hcengineering/core'
export interface HistoryRecord {
_id?: ObjectId
@@ -24,15 +23,7 @@ export interface HistoryRecord {
objectId: Ref<Doc>
objectClass: Ref<Class<Doc>>
role: string
user: PersonId
user: PersonUuid
tokens: number
timestamp: number
}
export interface AIReplyTransferData {
messageClass: Ref<Class<ChatMessage>>
email: string
fromWorkspace: string
originalMessageId: Ref<ChatMessage>
originalParent?: Ref<ChatMessage>
}
+98 -52
View File
@@ -1,5 +1,5 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
// Copyright © 2024-2025 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
@@ -12,63 +12,86 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
/* eslint-disable @typescript-eslint/no-unused-vars */
import {
WorkspaceInfoWithStatus,
isWorkspaceCreating,
MeasureContext,
systemAccountUuid,
type WorkspaceUuid,
AccountRole
AccountRole,
Person as GlobalPerson
} from '@hcengineering/core'
import { generateToken } from '@hcengineering/server-token'
import { getAccountClient } from '@hcengineering/server-client'
import { aiBotAccountEmail } from '@hcengineering/ai-bot'
import { getAccountClient, withRetry } from '@hcengineering/server-client'
import { aiBotAccountEmail, aiBotEmailSocialId } from '@hcengineering/ai-bot'
import { MeasureContext, PersonUuid, systemAccountUuid } from '@hcengineering/core'
import config from '../config'
import { wait } from './common'
const ASSIGN_WORKSPACE_DELAY_MS = 5 * 1000 // 5 secs
const MAX_ASSIGN_ATTEMPTS = 5
const ASSIGN_WORKSPACE_DELAY = 5 * 1000
const ASSIGN_WORKSPACE_ATTEMPTS = 5
async function tryGetWorkspaceInfo (
ws: WorkspaceUuid,
ctx: MeasureContext
): Promise<WorkspaceInfoWithStatus | undefined> {
const GET_WORKSPACE_DELAY = 3 * 1000
const GET_WORKSPACE_ATTEMPTS = 3
const WAIT_WORKSPACE_CREATION_STEP = 5 * 1000
const MAX_WAIT_WORKSPACE_CREATION_TIME = 20 * 60 * 1000
async function getWorkspaceInfo (ws: WorkspaceUuid, ctx: MeasureContext): Promise<WorkspaceInfoWithStatus | undefined> {
const systemToken = generateToken(systemAccountUuid, ws, { service: 'aibot' })
const accountClient = getAccountClient(systemToken)
for (let i = 0; i < 5; i++) {
try {
const info = await accountClient.getWorkspaceInfo()
return await withRetry(
async () => await accountClient.getWorkspaceInfo(),
(_, attempt) => attempt >= GET_WORKSPACE_ATTEMPTS,
GET_WORKSPACE_DELAY
)()
}
if (info == null) {
await wait(ASSIGN_WORKSPACE_DELAY_MS)
continue
async function waitWorkspaceCreation (
workspace: WorkspaceUuid,
ctx: MeasureContext
): Promise<WorkspaceInfoWithStatus | undefined> {
let waitedTime = 0
let attempt = 0
while (waitedTime < MAX_WAIT_WORKSPACE_CREATION_TIME) {
attempt++
const info = await getWorkspaceInfo(workspace, ctx)
if (info === undefined) {
ctx.error('Workspace not found', { workspace })
return undefined
}
if (isWorkspaceCreating(info?.mode)) {
await waitWorkspaceCreation(workspace, ctx)
const delay = WAIT_WORKSPACE_CREATION_STEP * attempt
waitedTime += delay
if (waitedTime > MAX_WAIT_WORKSPACE_CREATION_TIME) {
ctx.error('Workspace creation timeout', { workspace })
return undefined
}
await wait(delay)
} else {
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: WorkspaceUuid,
ctx: MeasureContext,
clearAttempts = true
): Promise<boolean> {
if (clearAttempts) {
attemptsByWorkspace.delete(workspace)
export async function getGlobalPerson (token: string): Promise<GlobalPerson | undefined> {
try {
const accountClient = getAccountClient(token)
return await accountClient.getPerson()
} catch (err) {
console.error('Error getting global person', err)
return undefined
}
clearTimeout(timeoutByWorkspace.get(workspace))
}
export async function tryAssignToWorkspace (workspace: WorkspaceUuid, ctx: MeasureContext): Promise<boolean> {
try {
const systemToken = generateToken(systemAccountUuid, undefined, { service: 'aibot' })
const accountClient = getAccountClient(systemToken)
const info = await tryGetWorkspaceInfo(workspace, ctx)
const info = await getWorkspaceInfo(workspace, ctx)
if (info === undefined) {
ctx.error('Workspace not found', { workspace })
@@ -76,29 +99,52 @@ export async function tryAssignToWorkspace (
}
if (isWorkspaceCreating(info?.mode)) {
const t = setTimeout(() => {
void tryAssignToWorkspace(workspace, ctx, false)
}, ASSIGN_WORKSPACE_DELAY_MS)
timeoutByWorkspace.set(workspace, t)
return false
await waitWorkspaceCreation(workspace, ctx)
}
await accountClient.assignWorkspace(aiBotAccountEmail, workspace, AccountRole.User)
await withRetry(
async () => {
await accountClient.assignWorkspace(aiBotAccountEmail, workspace, AccountRole.User)
},
(_, attempt) => attempt >= ASSIGN_WORKSPACE_ATTEMPTS,
ASSIGN_WORKSPACE_DELAY
)()
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)
}
ctx.error('Error during assign workspace:', { workspace, e })
}
return false
}
async function confirmAccount (uuid: PersonUuid): Promise<void> {
const token = generateToken(uuid, undefined, { service: 'aibot', confirmEmail: aiBotAccountEmail })
const client = getAccountClient(token)
try {
await client.confirm()
} catch (error: any) {
// ignore
}
}
export async function getPersonUuid (ctx: MeasureContext): Promise<PersonUuid | undefined> {
const token = generateToken(systemAccountUuid, undefined, { service: 'aibot', confirmEmail: aiBotAccountEmail })
const accountClient = getAccountClient(token)
const personUuid = await accountClient.findPerson(aiBotEmailSocialId)
if (personUuid !== undefined) {
await confirmAccount(personUuid)
return personUuid
}
const result = await accountClient.signUp(aiBotEmailSocialId, config.Password, config.FirstName, config.LastName)
if (result !== undefined) {
await confirmAccount(result.account)
return result.account
}
return undefined
}
+18 -21
View File
@@ -16,11 +16,12 @@
import { countTokens } from '@hcengineering/openai'
import { Tiktoken } from 'js-tiktoken'
import OpenAI from 'openai'
import { PersonId } from '@hcengineering/core'
import config from '../config'
import { HistoryRecord } from '../types'
import { WorkspaceClient } from '../workspace/workspaceClient'
import { getTools } from './tools'
import { PersonId } from '@hcengineering/core'
export async function translateHtml (client: OpenAI, html: string, lang: string): Promise<string | undefined> {
const response = await client.chat.completions.create({
@@ -87,26 +88,22 @@ export async function createChatCompletionWithTools (
opt.headers = { 'cf-skip-cache': 'true' }
}
try {
const res = client.beta.chat.completions
.runTools(
{
messages: [
{
role: 'system',
content: 'Use tools if possible, don`t use previous information after success using tool for user request'
},
...history,
message
],
model: config.OpenAIModel,
user,
tools: getTools(workspaceClient, user)
},
opt
)
.on('message', (message) => {
console.log(message)
})
const res = client.beta.chat.completions.runTools(
{
messages: [
{
role: 'system',
content: 'Use tools if possible, don`t use previous information after success using tool for user request'
},
...history,
message
],
model: config.OpenAIModel,
user,
tools: getTools(workspaceClient, user)
},
opt
)
const str = await res.finalContent()
const usage = (await res.totalUsage()).completion_tokens
return {
+7 -11
View File
@@ -1,4 +1,4 @@
import { MarkupBlobRef, PersonId, Ref } from '@hcengineering/core'
import { MarkupBlobRef, PersonId, Ref, WorkspaceDataId } from '@hcengineering/core'
import document, { Document, getFirstRank, Teamspace } from '@hcengineering/document'
import { makeRank } from '@hcengineering/rank'
import { parseMessageMarkdown } from '@hcengineering/text'
@@ -37,12 +37,13 @@ async function pdfToMarkdown (
name: string | undefined
): Promise<string | undefined> {
if (config.DataLabApiKey !== '') {
const dataId = workspaceClient.workspace as any as WorkspaceDataId
try {
const stat = await workspaceClient.storage.stat(workspaceClient.ctx, workspaceClient.wsDataId, fileId)
const stat = await workspaceClient.storage.stat(workspaceClient.ctx, dataId, fileId)
if (stat?.contentType !== 'application/pdf') {
return
}
const file = await workspaceClient.storage.get(workspaceClient.ctx, workspaceClient.wsDataId, fileId)
const file = await workspaceClient.storage.get(workspaceClient.ctx, dataId, fileId)
const buffer = await stream2buffer(file)
const url = 'https://www.datalab.to/api/v1/marker'
@@ -64,7 +65,7 @@ async function pdfToMarkdown (
})
const data = await response.json()
console.log('data', data)
if (data.request_check_url !== undefined) {
for (let attempt = 0; attempt < 10; attempt++) {
const resp = await fetch(data.request_check_url, { headers })
@@ -95,13 +96,8 @@ async function saveFile (
const client = await workspaceClient.opClient
const fileId = uuid()
await workspaceClient.storage.put(
workspaceClient.ctx,
workspaceClient.wsDataId,
fileId,
converted,
'application/json'
)
const dataId = workspaceClient.workspace as any as WorkspaceDataId
await workspaceClient.storage.put(workspaceClient.ctx, dataId, fileId, converted, 'application/json')
const teamspaces = await client.findAll(document.class.Teamspace, {})
const parent = await client.findOne(document.class.Document, { _id: args.parent as Ref<Document> })
@@ -12,23 +12,23 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
/* eslint-disable @typescript-eslint/no-unused-vars */
import { ConnectMeetingRequest } from '@hcengineering/ai-bot'
import chunter from '@hcengineering/chunter'
import core, {
Ref,
TxOperations,
concatLink,
TxProcessor,
TxCUD,
Doc,
TxCreateDoc,
TxUpdateDoc,
MeasureContext,
Markup,
MeasureContext,
PersonId,
Ref,
SocialIdType,
TxCreateDoc,
TxCUD,
TxOperations,
TxProcessor,
TxUpdateDoc,
WorkspaceUuid
} from '@hcengineering/core'
import { Person } from '@hcengineering/contact'
import contact, { Person } from '@hcengineering/contact'
import love, {
getFreeRoomPlace,
MeetingMinutes,
@@ -39,6 +39,7 @@ import love, {
TranscriptionStatus
} from '@hcengineering/love'
import { jsonToMarkup, MarkupNodeType } from '@hcengineering/text'
import chunter from '@hcengineering/chunter'
import config from '../config'
@@ -47,6 +48,7 @@ export class LoveController {
private participantsInfo: ParticipantInfo[] = []
private rooms: Room[] = []
private readonly socialIdByPerson = new Map<Ref<Person>, PersonId>()
private readonly meetingMinutes: MeetingMinutes[] = []
constructor (
@@ -171,36 +173,56 @@ export class LoveController {
this.connectedRooms.delete(roomId)
}
async getSocialId (person: Ref<Person>): Promise<PersonId | undefined> {
const socialId =
this.socialIdByPerson.get(person) ??
(
await this.client.findOne(contact.class.SocialIdentity, {
attachedTo: person,
attachedToClass: contact.class.Person,
type: SocialIdType.HULY
})
)?.key
if (socialId === undefined) {
return
}
this.socialIdByPerson.set(person, socialId)
return socialId
}
async processTranscript (text: string, person: Ref<Person>, roomId: Ref<Room>): Promise<void> {
// TODO: FIXME
throw new Error('Not implemented')
// const room = await this.getRoom(roomId)
// const participant = await this.getRoomParticipant(roomId, person)
const room = await this.getRoom(roomId)
const participant = await this.getRoomParticipant(roomId, person)
// if (room === undefined || participant === undefined) {
// return
// }
if (room === undefined || participant === undefined) {
return
}
// const personAccount = this.client.getModel().getAccountByPersonId(participant.person)[0]
// const doc = await this.getMeetingMinutes(room)
const socialId = await this.getSocialId(person)
if (socialId === undefined) return
// if (doc === undefined) return
// const op = this.client.apply(undefined, undefined, true)
const doc = await this.getMeetingMinutes(room)
if (doc === undefined) return
// await op.addCollection(
// chunter.class.ChatMessage,
// core.space.Workspace,
// doc._id,
// doc._class,
// 'transcription',
// {
// message: this.transcriptToMarkup(text)
// },
// undefined,
// undefined,
// personAccount._id
// )
// await op.commit()
const op = this.client.apply(undefined, undefined, true)
await op.addCollection(
chunter.class.ChatMessage,
core.space.Workspace,
doc._id,
doc._class,
'transcription',
{
message: this.transcriptToMarkup(text)
},
undefined,
undefined,
socialId
)
await op.commit()
}
hasActiveConnections (): boolean {
@@ -269,7 +291,7 @@ export class LoveController {
}
}
function getTokenRoomName (workspace: string, roomName: string, roomId: Ref<Room>): string {
function getTokenRoomName (workspace: WorkspaceUuid, roomName: string, roomId: Ref<Room>): string {
return `${workspace}_${roomName}_${roomId}`
}
@@ -1,184 +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 core, { Class, Doc, Markup, Ref, Tx, TxCUD, TxOperations, TxUpdateDoc } from '@hcengineering/core'
import { WorkspaceClient } from './workspaceClient'
import analyticsCollector, { OnboardingChannel } from '@hcengineering/analytics-collector'
import aiBot from '@hcengineering/ai-bot'
import chunter, { ChatMessage, ThreadMessage } from '@hcengineering/chunter'
import { AIReplyTransferData } from '../types'
import { MarkupMarkType, MarkupNodeType } from '@hcengineering/text'
interface OnboardingChannelMetadata {
workspace: string
email: string
}
export class SupportWsClient extends WorkspaceClient {
readonly disableAiRepliesChannels = new Map<Ref<OnboardingChannel>, OnboardingChannelMetadata>()
readonly disableShowAiRepliesChannels = new Map<Ref<OnboardingChannel>, OnboardingChannelMetadata>()
readonly assignedChannels = new Set<Ref<OnboardingChannel>>()
async initClient (): Promise<TxOperations> {
// TODO: FIXME
throw new Error('Not implemented')
// const client = await super.initClient()
// if (this.client != null) {
// this.client.notify = (...txes) => {
// this.handleTx(client, ...txes)
// }
// const channels = await client.findAll(analyticsCollector.class.OnboardingChannel, {})
// for (const channel of channels) {
// if (channel.members.length > 0) {
// this.assignedChannels.add(channel._id)
// }
// if (channel.disableAIReplies) {
// this.disableAiRepliesChannels.set(channel._id, { workspace: channel.workspaceId, email: channel.email })
// }
// if (!channel.showAIReplies) {
// this.disableShowAiRepliesChannels.set(channel._id, { workspace: channel.workspaceId, email: channel.email })
// }
// const key = `${channel.email}-${channel.workspaceId}`
// this.channelByKey.set(key, channel._id)
// }
// }
// return client
}
allowAiReplies (workspace: string, email: string): boolean {
for (const [, { workspace: w, email: e }] of this.disableAiRepliesChannels) {
if (w === workspace && e === email) {
return false
}
}
return true
}
private handleTx (client: TxOperations, ...txes: Tx[]): void {
void super.txHandler(client, txes as TxCUD<Doc>[])
for (const tx of txes) {
switch (tx._class) {
case core.class.TxUpdateDoc: {
this.txUpdateDoc(client, tx as TxUpdateDoc<Doc>)
break
}
}
}
}
private async updateChannels (tx: TxUpdateDoc<OnboardingChannel>): Promise<void> {
// TODO: FIXME
throw new Error('Not implemented')
// if (this.client === undefined) return
// const updates = tx.operations
// if (updates.members !== undefined || updates.$push?.members !== undefined) {
// this.assignedChannels.add(tx.objectId)
// }
// if (updates.disableAIReplies === true) {
// const channel = await this.client.findOne(analyticsCollector.class.OnboardingChannel, { _id: tx.objectId })
// if (channel === undefined) return
// this.disableAiRepliesChannels.set(channel._id, { workspace: channel.workspaceId, email: channel.email })
// } else if (updates.disableAIReplies === false) {
// this.disableAiRepliesChannels.delete(tx.objectId)
// }
}
private txUpdateDoc (client: TxOperations, tx: TxUpdateDoc<Doc>): void {
const hierarchy = client.getHierarchy()
if (hierarchy.isDerived(tx.objectClass, analyticsCollector.class.OnboardingChannel)) {
void this.updateChannels(tx as TxUpdateDoc<OnboardingChannel>)
}
}
async transferAIReply (response: Markup, data: AIReplyTransferData): Promise<void> {
const channel = this.getChannelRef(data.email, data.fromWorkspace)
if (channel === undefined || this.disableShowAiRepliesChannels.has(channel)) return
const client = await this.opClient
const hierarchy = client.getHierarchy()
const op = client.apply(undefined, 'AIBotSendAIReplyToSupport')
const resp = JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: MarkupNodeType.text, text: 'AI response:', marks: [{ type: MarkupMarkType.bold, attrs: {} }] }
]
},
{
type: 'paragraph',
content: JSON.parse(response).content
}
]
})
if (hierarchy.isDerived(data.messageClass, chunter.class.ThreadMessage) && data.originalParent !== undefined) {
const parent = await this.getThreadParent(
client,
data.originalParent,
channel,
analyticsCollector.class.OnboardingChannel
)
if (parent !== undefined) {
const ref = await op.addCollection<Doc, ThreadMessage>(
chunter.class.ThreadMessage,
parent.space,
parent._id,
parent._class,
'replies',
{ message: resp, objectId: parent.attachedTo, objectClass: parent.attachedToClass }
)
await op.createMixin(
ref,
chunter.class.ThreadMessage as Ref<Class<ChatMessage>>,
channel,
aiBot.mixin.TransferredMessage,
{
messageId: data.originalMessageId,
parentMessageId: data.originalParent
}
)
}
} else {
const ref = await op.addCollection<Doc, ChatMessage>(
chunter.class.ChatMessage,
channel,
channel,
analyticsCollector.class.OnboardingChannel,
'messages',
{ message: resp }
)
await op.createMixin(ref, chunter.class.ChatMessage, channel, aiBot.mixin.TransferredMessage, {
messageId: data.originalMessageId,
parentMessageId: data.originalMessageId
})
}
await op.commit()
}
}
@@ -12,82 +12,70 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
/* eslint-disable @typescript-eslint/no-unused-vars */
import aiBot, {
aiBotAccount,
AIMessageEventRequest,
AITransferEventRequest,
import {
aiBotEmailSocialId,
AIEventRequest,
ConnectMeetingRequest,
DisconnectMeetingRequest,
IdentityResponse
} from '@hcengineering/ai-bot'
import analyticsCollector, { OnboardingChannel } from '@hcengineering/analytics-collector'
import attachment, { Attachment } from '@hcengineering/attachment'
import chunter, {
ChatMessage,
type ChatWidgetTab,
DirectMessage,
ThreadMessage,
TypingInfo
} from '@hcengineering/chunter'
import contact, { AvatarType, combineName, getFirstName, getLastName, getName, Person } from '@hcengineering/contact'
import chunter, { ChatMessage, ThreadMessage } from '@hcengineering/chunter'
import contact, {
AvatarType,
combineName,
ensureEmployee,
getFirstName,
getLastName,
Person
} from '@hcengineering/contact'
import core, {
PersonId,
type Account,
AccountRole,
Blob,
Class,
Client,
Data,
Doc,
generateId,
MeasureContext,
PersonId,
PersonUuid,
RateLimiter,
Ref,
SocialId,
Space,
Tx,
TxCUD,
TxOperations,
TxRemoveDoc,
type WorkspaceUuid,
type WorkspaceDataId
WorkspaceDataId,
type WorkspaceUuid
} from '@hcengineering/core'
import { Room } from '@hcengineering/love'
import { countTokens } from '@hcengineering/openai'
import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot'
import { getOrCreateOnboardingChannel } from '@hcengineering/server-analytics-collector-resources'
import { getAccountClient } from '@hcengineering/server-client'
import { generateToken } from '@hcengineering/server-token'
import { jsonToMarkup, MarkdownParser, markupToText } from '@hcengineering/text'
import workbench, { SidebarEvent, TxSidebarEvent } from '@hcengineering/workbench'
import fs from 'fs'
import { WithId } from 'mongodb'
import OpenAI from 'openai'
import { Tiktoken } from 'js-tiktoken'
import { StorageAdapter } from '@hcengineering/server-core'
import config from '../config'
import { AIControl } from '../controller'
import { HistoryRecord } from '../types'
import { createChatCompletionWithTools, requestSummary } from '../utils/openai'
import { connectPlatform, getDirect } from '../utils/platform'
import { connectPlatform } from '../utils/platform'
import { LoveController } from './love'
const MAX_LOGIN_DELAY_MS = 15 * 1000 // 15 ses
const UPDATE_TYPING_TIMEOUT_MS = 1000
import { DbStorage } from '../storage'
import { jsonToMarkup, MarkdownParser, markupToText } from '@hcengineering/text'
import { countTokens } from '@hcengineering/openai'
import { getAccountClient } from '@hcengineering/server-client'
import { getGlobalPerson } from '../utils/account'
export class WorkspaceClient {
client: Client | undefined
opClient: Promise<TxOperations> | TxOperations
loginTimeout: NodeJS.Timeout | undefined
loginDelayMs = 2 * 1000
channelByKey = new Map<string, Ref<OnboardingChannel>>()
rate = new RateLimiter(1)
aiPerson: Person | undefined
typingMap: Map<Ref<Doc>, TypingInfo> = new Map<Ref<Doc>, TypingInfo>()
typingTimeoutsMap: Map<Ref<Doc>, NodeJS.Timeout> = new Map<Ref<Doc>, NodeJS.Timeout>()
directByPersonId = new Map<PersonId, Ref<DirectMessage>>()
personUuidBySocialId = new Map<PersonId, PersonUuid>()
historyMap = new Map<Ref<Doc>, WithId<HistoryRecord>[]>()
@@ -97,12 +85,15 @@ export class WorkspaceClient {
constructor (
readonly storage: StorageAdapter,
readonly dbStorage: DbStorage,
readonly transactorUrl: string,
readonly token: string,
readonly workspace: WorkspaceUuid,
readonly workspaceDataId: WorkspaceDataId | undefined,
readonly controller: AIControl,
readonly personUuid: PersonUuid,
readonly socialIds: SocialId[],
readonly ctx: MeasureContext,
readonly openai: OpenAI | undefined,
readonly openaiEncoding: Tiktoken,
readonly info: WorkspaceInfoRecord | undefined
) {
this.opClient = this.initClient()
@@ -111,25 +102,27 @@ export class WorkspaceClient {
})
}
get wsDataId (): WorkspaceDataId {
return this.workspaceDataId ?? (this.workspace as unknown as WorkspaceDataId)
private async ensureEmployee (client: Client): Promise<void> {
const me: Account = {
uuid: this.personUuid,
role: AccountRole.User,
primarySocialId: aiBotEmailSocialId,
socialIds: this.socialIds.map((it) => it.key)
}
await ensureEmployee(this.ctx, me, client, this.socialIds, async () => await getGlobalPerson(this.token))
}
protected async initClient (): Promise<TxOperations> {
await this.tryLogin()
private async initClient (): Promise<TxOperations> {
this.client = await connectPlatform(this.token, this.transactorUrl)
const opClient = new TxOperations(this.client, aiBot.account.AIBot)
const opClient = new TxOperations(this.client, aiBotEmailSocialId)
await this.uploadAvatarFile(opClient)
await this.ensureEmployee(this.client)
await this.checkEmployeeInfo(opClient)
if (this.aiPerson !== undefined && config.LoveEndpoint !== '') {
const token = generateToken(aiBotAccount, this.workspace)
this.love = new LoveController(this.workspace, this.ctx.newChild('love', {}), token, opClient, this.aiPerson)
this.love = new LoveController(this.workspace, this.ctx.newChild('love', {}), this.token, opClient, this.aiPerson)
}
const typing = await opClient.findAll(chunter.class.TypingInfo, { user: aiBot.account.AIBot })
this.typingMap = new Map(typing.map((t) => [t.objectId, t]))
this.client.notify = (...txes: Tx[]) => {
void this.txHandler(opClient, txes as TxCUD<Doc>[])
}
@@ -138,7 +131,7 @@ export class WorkspaceClient {
return opClient
}
private async uploadAvatarFile (client: TxOperations): Promise<void> {
private async checkEmployeeInfo (client: TxOperations): Promise<void> {
this.ctx.info('Upload avatar file', { workspace: this.workspace })
try {
@@ -152,8 +145,15 @@ export class WorkspaceClient {
if (!isAlreadyUploaded) {
const data = fs.readFileSync(config.AvatarPath)
await this.storage.put(this.ctx, this.wsDataId, config.AvatarName, data, config.AvatarContentType, data.length)
await this.controller.updateAvatarInfo(this.workspace, config.AvatarPath, lastModified)
await this.storage.put(
this.ctx,
this.workspace as any as WorkspaceDataId,
config.AvatarName,
data,
config.AvatarContentType,
data.length
)
await this.updateAvatarInfo(this.workspace, config.AvatarPath, lastModified)
this.ctx.info('Avatar file uploaded successfully', { workspace: this.workspace, path: config.AvatarPath })
}
} catch (e) {
@@ -163,29 +163,21 @@ export class WorkspaceClient {
await this.checkPersonData(client)
}
private async tryLogin (): Promise<void> {
this.ctx.info('Logging in: ', { workspace: this.workspace })
const accountClient = getAccountClient()
const token = await accountClient.login(aiBotAccount, config.Password)
private async updateAvatarInfo (workspace: string, path: string, lastModified: number): Promise<void> {
const record = await this.dbStorage.getWorkspace(workspace)
clearTimeout(this.loginTimeout)
if (token === undefined) {
this.loginTimeout = setTimeout(() => {
if (this.loginDelayMs < MAX_LOGIN_DELAY_MS) {
this.loginDelayMs += 1000
}
this.ctx.info(`login delay ${this.loginDelayMs} millisecond`)
void this.tryLogin()
}, this.loginDelayMs)
if (record === undefined) {
await this.dbStorage.addWorkspace({ workspace, avatarPath: path, avatarLastModified: lastModified })
} else {
await this.dbStorage.updateWorkspace(workspace, { $set: { avatarPath: path, avatarLastModified: lastModified } })
}
}
private async checkPersonData (client: TxOperations): Promise<void> {
this.aiPerson = await client.findOne(contact.class.Person, { personUuid: aiBotAccount })
this.aiPerson = this.aiPerson ?? (await client.findOne(contact.class.Person, { personUuid: this.personUuid }))
if (this.aiPerson === undefined) {
this.ctx.error('Cannot find AI Person ', { personUuid: aiBotAccount })
this.ctx.error('Cannot find AI Person ', { personUuid: this.personUuid })
return
}
@@ -202,7 +194,7 @@ export class WorkspaceClient {
return
}
const exist = await this.storage.stat(this.ctx, this.wsDataId, config.AvatarName)
const exist = await this.storage.stat(this.ctx, this.workspace as any, config.AvatarName)
if (exist === undefined) {
this.ctx.error('Cannot find file', { file: config.AvatarName, workspace: this.workspace })
@@ -212,148 +204,8 @@ export class WorkspaceClient {
await client.diffUpdate(this.aiPerson, { avatar: config.AvatarName as Ref<Blob>, avatarType: AvatarType.IMAGE })
}
async getThreadParent (
client: TxOperations,
parentMessageId: Ref<ChatMessage>,
_id: Ref<Doc>,
_class: Ref<Class<Doc>>
): Promise<ChatMessage | undefined> {
const parent = await client.findOne(chunter.class.ChatMessage, {
attachedTo: _id,
attachedToClass: _class,
[aiBot.mixin.TransferredMessage]: {
messageId: parentMessageId,
parentMessageId: undefined
}
})
if (parent !== undefined) {
return parent
}
return await client.findOne(chunter.class.ChatMessage, {
_id: parentMessageId
})
}
async createTransferMessage (
client: TxOperations,
event: AITransferEventRequest,
_id: Ref<Doc>,
_class: Ref<Class<Doc>>,
space: Ref<Space>,
message: string
): Promise<void> {
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>(
chunter.class.ChatMessage,
space,
_id,
_class,
event.collection,
{ message },
undefined,
event.createdOn
)
await op.createMixin(ref, chunter.class.ChatMessage, space, aiBot.mixin.TransferredMessage, {
messageId: event.messageId,
parentMessageId: event.parentMessageId
})
await this.finishTyping(client, _id)
} else if (event.messageClass === chunter.class.ThreadMessage && event.parentMessageId !== undefined) {
const parent = await this.getThreadParent(client, event.parentMessageId, _id, _class)
if (parent !== undefined) {
await this.startTyping(client, space, parent._id, parent._class)
const ref = await op.addCollection<Doc, ThreadMessage>(
chunter.class.ThreadMessage,
parent.space,
parent._id,
parent._class,
event.collection,
{ message, objectId: parent.attachedTo, objectClass: parent.attachedToClass },
undefined,
event.createdOn
)
await op.createMixin(
ref,
chunter.class.ThreadMessage as Ref<Class<ChatMessage>>,
space,
aiBot.mixin.TransferredMessage,
{
messageId: event.messageId,
parentMessageId: event.parentMessageId
}
)
await this.finishTyping(client, parent._id)
}
}
await op.commit()
}
clearTypingTimeout (objectId: Ref<Doc>): void {
const currentTimeout = this.typingTimeoutsMap.get(objectId)
if (currentTimeout !== undefined) {
clearTimeout(currentTimeout)
this.typingTimeoutsMap.delete(objectId)
}
}
async startTyping (
client: TxOperations,
space: Ref<Space>,
objectId: Ref<Doc>,
objectClass: Ref<Class<Doc>>
): Promise<void> {
if (this.aiPerson === undefined) {
return
}
this.clearTypingTimeout(objectId)
const typingInfo = this.typingMap.get(objectId)
if (typingInfo === undefined) {
const data: Data<TypingInfo> = {
objectId,
objectClass,
person: this.aiPerson._id,
lastTyping: Date.now()
}
const _id = await client.createDoc(chunter.class.TypingInfo, space, data)
this.typingMap.set(objectId, {
...data,
_id,
_class: chunter.class.TypingInfo,
space,
modifiedOn: Date.now(),
modifiedBy: aiBot.account.AIBot
})
} else {
await client.update(typingInfo, { lastTyping: Date.now() })
}
const timeout = setTimeout(() => {
void this.startTyping(client, space, objectId, objectClass)
}, UPDATE_TYPING_TIMEOUT_MS)
this.typingTimeoutsMap.set(objectId, timeout)
}
async finishTyping (client: TxOperations, objectId: Ref<Doc>): Promise<void> {
this.clearTypingTimeout(objectId)
const typingInfo = this.typingMap.get(objectId)
if (typingInfo !== undefined) {
await client.remove(typingInfo)
this.typingMap.delete(objectId)
}
}
// TODO: In feature we also should use embeddings
toOpenAiHistory (history: HistoryRecord[], promptTokens: number): any[] {
private toOpenAiHistory (history: HistoryRecord[], promptTokens: number): any[] {
const result: OpenAI.ChatCompletionMessageParam[] = []
let totalTokens = promptTokens
@@ -370,29 +222,29 @@ export class WorkspaceClient {
return result
}
async getHistory (objectId: Ref<Doc>): Promise<WithId<HistoryRecord>[]> {
private async getHistory (objectId: Ref<Doc>): Promise<WithId<HistoryRecord>[]> {
if (this.historyMap.has(objectId)) {
return this.historyMap.get(objectId) ?? []
}
const historyRecords = await this.controller.storage.getHistoryRecords(this.workspace, objectId)
const historyRecords = await this.dbStorage.getHistoryRecords(this.workspace, objectId)
this.historyMap.set(objectId, historyRecords)
return historyRecords
}
async summarizeHistory (
private async summarizeHistory (
toSummarize: WithId<HistoryRecord>[],
user: PersonId,
user: PersonUuid,
objectId: Ref<Doc>,
objectClass: Ref<Class<Doc>>
): Promise<void> {
if (this.controller.aiClient === undefined) return
if (this.openai === undefined) return
if (this.summarizing.has(objectId)) {
return
}
this.summarizing.add(objectId)
const { summary, tokens } = await requestSummary(this.controller.aiClient, this.controller.encoding, toSummarize)
const { summary, tokens } = await requestSummary(this.openai, this.openaiEncoding, toSummarize)
if (summary === undefined) {
this.ctx.error('Failed to summarize history', { objectId, objectClass, user })
@@ -411,18 +263,18 @@ export class WorkspaceClient {
workspace: this.workspace
}
await this.controller.storage.addHistoryRecord(summaryRecord)
await this.controller.storage.removeHistoryRecords(toSummarize.map(({ _id }) => _id))
const newHistory = await this.controller.storage.getHistoryRecords(this.workspace, objectId)
await this.dbStorage.addHistoryRecord(summaryRecord)
await this.dbStorage.removeHistoryRecords(toSummarize.map(({ _id }) => _id))
const newHistory = await this.dbStorage.getHistoryRecords(this.workspace, objectId)
this.historyMap.set(objectId, newHistory)
this.summarizing.delete(objectId)
}
async pushHistory (
private async pushHistory (
message: string,
role: 'user' | 'assistant',
tokens: number,
user: PersonId,
user: PersonUuid,
objectId: Ref<Doc>,
objectClass: Ref<Class<Doc>>
): Promise<void> {
@@ -437,20 +289,29 @@ export class WorkspaceClient {
tokens,
timestamp: Date.now()
}
const _id = await this.controller.storage.addHistoryRecord(newRecord)
const _id = await this.dbStorage.addHistoryRecord(newRecord)
currentHistory.push({ ...newRecord, _id })
this.historyMap.set(objectId, currentHistory)
}
async getAttachments (client: TxOperations, objectId: Ref<Doc>): Promise<Attachment[]> {
private async getAttachments (client: TxOperations, objectId: Ref<Doc>): Promise<Attachment[]> {
return await client.findAll(attachment.class.Attachment, { attachedTo: objectId })
}
async processMessageEvent (event: AIMessageEventRequest): Promise<void> {
if (this.controller.aiClient === undefined) return
async processMessageEvent (event: AIEventRequest): Promise<void> {
if (this.openai === undefined) return
const { user, objectId, objectClass, messageClass } = event
const client = await this.opClient
const accountClient = getAccountClient(this.token)
const personUuid = this.personUuidBySocialId.get(user) ?? (await accountClient.findPerson(user))
if (personUuid === undefined) {
return
}
this.personUuidBySocialId.set(user, personUuid)
let promptText = markupToText(event.message)
const files = await this.getAttachments(client, event.messageId)
if (files.length > 0) {
@@ -460,40 +321,32 @@ export class WorkspaceClient {
}
}
const prompt: OpenAI.ChatCompletionMessageParam = { content: promptText, role: 'user' }
const promptTokens = countTokens([prompt], this.controller.encoding)
if (!this.controller.allowAiReplies(this.workspace, event.email)) {
void this.pushHistory(promptText, 'user', promptTokens, user, objectId, objectClass)
return
}
const promptTokens = countTokens([prompt], this.openaiEncoding)
const op = client.apply(undefined, 'AIMessageRequestEvent')
const hierarchy = client.getHierarchy()
const space = hierarchy.isDerived(objectClass, core.class.Space) ? (objectId as Ref<Space>) : event.objectSpace
await this.startTyping(client, space, objectId, objectClass)
const rawHistory = await this.getHistory(objectId)
const history = this.toOpenAiHistory(rawHistory, promptTokens)
if (history.length < rawHistory.length || history.length > config.MaxHistoryRecords) {
void this.summarizeHistory(rawHistory, user, objectId, objectClass)
void this.summarizeHistory(rawHistory, personUuid, objectId, objectClass)
}
void this.pushHistory(promptText, prompt.role, promptTokens, user, objectId, objectClass)
void this.pushHistory(promptText, prompt.role, promptTokens, personUuid, objectId, objectClass)
const chatCompletion = await createChatCompletionWithTools(this, this.controller.aiClient, prompt, user, history)
const chatCompletion = await createChatCompletionWithTools(this, this.openai, prompt, user, history)
const response = chatCompletion?.completion
if (response == null) {
await this.finishTyping(client, objectId)
return
}
const responseTokens =
chatCompletion?.usage ?? countTokens([{ content: response, role: 'assistant' }], this.controller.encoding)
chatCompletion?.usage ?? countTokens([{ content: response, role: 'assistant' }], this.openaiEncoding)
void this.pushHistory(response, 'assistant', responseTokens, user, objectId, objectClass)
void this.pushHistory(response, 'assistant', responseTokens, personUuid, objectId, objectClass)
const parser = new MarkdownParser([], '', '')
const parseResponse = jsonToMarkup(parser.parse(response))
@@ -523,101 +376,10 @@ export class WorkspaceClient {
)
}
}
await this.finishTyping(op, event.objectId)
await op.commit()
await this.controller.transferAIReplyToSupport(parseResponse, {
messageClass,
email: event.email,
fromWorkspace: this.workspace,
originalMessageId: event.messageId,
originalParent: hierarchy.isDerived(event.objectClass, chunter.class.ChatMessage)
? (event.objectId as Ref<ChatMessage>)
: undefined
})
}
async transferToSupport (event: AITransferEventRequest, channelRef?: Ref<OnboardingChannel>): Promise<void> {
// TODO: FIXME
throw new Error('Not implemented')
// const client = await this.opClient
// const key = `${event.toEmail}-${event.fromWorkspace}`
// const channel =
// channelRef ??
// this.channelByKey.get(key) ??
// (
// await getOrCreateOnboardingChannel(this.ctx, client, event.toEmail, {
// workspaceId: event.fromWorkspace,
// workspaceName: event.fromWorkspaceName,
// workspaceUrl: event.fromWorkspaceUrl
// })
// )[0]
// if (channel === undefined) {
// return
// }
// this.channelByKey.set(key, channel)
// await this.createTransferMessage(
// client,
// event,
// channel,
// analyticsCollector.class.OnboardingChannel,
// channel,
// event.message
// )
}
async transferToUserDirect (event: AITransferEventRequest): Promise<void> {
const client = await this.opClient
const direct =
this.directByPersonId.get(event.toPersonId) ?? (await getDirect(client, event.toPersonId, this.aiPerson?._id))
if (direct === undefined) {
return
}
this.directByPersonId.set(event.toPersonId, direct)
await this.createTransferMessage(client, event, direct, chunter.class.DirectMessage, direct, event.message)
}
getChannelRef (email: string, workspace: string): Ref<OnboardingChannel> | undefined {
const key = `${email}-${workspace}`
return this.channelByKey.get(key)
}
async transfer (event: AITransferEventRequest): Promise<void> {
// TODO: FIXME
throw new Error('Not implemented')
// if (event.toWorkspace === config.SupportWorkspace) {
// const channel = this.getChannelRef(event.toEmail, event.fromWorkspace)
// if (channel !== undefined) {
// await this.transferToSupport(event, channel)
// } else {
// // If we dont have OnboardingChannel we should call it sync to prevent multiple channel for the same user and workspace
// await this.rate.add(async () => {
// await this.transferToSupport(event)
// })
// }
// } else {
// if (this.directByPersonId.has(event.toPersonId)) {
// await this.transferToUserDirect(event)
// } else {
// // If we dont have Direct with user we should call it sync to prevent multiple directs for the same user
// await this.rate.add(async () => {
// await this.transferToUserDirect(event)
// })
// }
// }
}
async close (): Promise<void> {
clearTimeout(this.loginTimeout)
if (this.client !== undefined) {
await this.client.close()
}
@@ -633,73 +395,16 @@ export class WorkspaceClient {
this.ctx.info('Closed workspace client: ', { workspace: this.workspace })
}
private async handleRemoveTx (tx: TxRemoveDoc<Doc>): Promise<void> {
if (tx.objectClass === chunter.class.TypingInfo && this.typingMap.has(tx.objectId)) {
this.typingMap.delete(tx.objectId)
}
}
protected async txHandler (_: TxOperations, txes: TxCUD<Doc>[]): Promise<void> {
private async txHandler (_: TxOperations, txes: TxCUD<Doc>[]): Promise<void> {
if (this.love !== undefined) {
this.love.txHandler(txes)
}
for (const tx of txes) {
if (tx._class === core.class.TxRemoveDoc) {
await this.handleRemoveTx(tx as TxRemoveDoc<Doc>)
}
}
}
async openAIChatInSidebar (personId: PersonId): Promise<void> {
const client = await this.opClient
const direct = this.directByPersonId.get(personId) ?? (await getDirect(client, personId, this.aiPerson?._id))
if (direct === undefined || this.aiPerson === undefined) {
return
}
this.directByPersonId.set(personId, direct)
const hierarchy = client.getHierarchy()
const name = getName(hierarchy, this.aiPerson)
const tab: ChatWidgetTab = {
id: `chunter_${direct}`,
name,
iconComponent: chunter.component.DirectIcon,
iconProps: {
_id: direct,
size: 'tiny'
},
data: {
_id: direct,
_class: chunter.class.DirectMessage,
channelName: name
}
}
const tx: TxSidebarEvent = {
_id: generateId(),
_class: workbench.class.TxSidebarEvent,
objectSpace: core.space.DerivedTx,
space: core.space.DerivedTx,
event: SidebarEvent.OpenWidget,
params: {
widget: chunter.ids.ChatWidget,
tab
},
modifiedOn: Date.now(),
modifiedBy: aiBot.account.AIBot
}
await client.tx(tx)
}
async loveConnect (request: ConnectMeetingRequest): Promise<void> {
await this.opClient
if (this.love === undefined) {
console.error('Love is not initialized')
this.ctx.error('Love controller is not initialized')
return
}
await this.love.connect(request)
@@ -22,7 +22,7 @@ import { Db, Collection } from 'mongodb'
import { WorkspaceClient } from './workspaceClient'
import config from './config'
import { SupportWsClient } from './supportWsClient'
import { Action, OnboardingMessage } from './types'
import { OnboardingMessage } from './types'
const closeWorkspaceTimeout = 10 * 60 * 1000 // 10 minutes
@@ -194,24 +194,6 @@ export class Collector {
await client.pushEvents(events, token.workspace, person, this.onboardingMessagesCollection)
}
async processAction (action: Action, token: Token): Promise<void> {
const ws = token.workspace
if (ws !== config.SupportWorkspace) {
return
}
const person = await this.getPerson(token.account, token.workspace)
if (person === undefined) {
return
}
const client = this.getSupportWorkspaceClient()
await client.processAction(action, person, this.onboardingMessagesCollection)
}
async close (): Promise<void> {
for (const [, client] of this.workspaces) {
await client.close()
@@ -33,7 +33,6 @@ const config: Config = (() => {
MongoDb: process.env.MONGO_DB ?? '%analytics-collector',
Secret: process.env.SECRET,
ServiceID: process.env.SERVICE_ID ?? 'analytics-collector-service',
SupportWorkspace: process.env.SUPPORT_WORKSPACE,
AccountsUrl: process.env.ACCOUNTS_URL,
SentryDSN: process.env.SENTRY_DSN ?? ''
}
@@ -13,19 +13,12 @@
// limitations under the License.
//
import { setMetadata } from '@hcengineering/platform'
import serverToken from '@hcengineering/server-token'
import { Analytics } from '@hcengineering/analytics'
import { SplitLogger, configureAnalytics } from '@hcengineering/analytics-service'
import serverClient from '@hcengineering/server-client'
import { MeasureMetricsContext, newMetrics } from '@hcengineering/core'
import { join } from 'path'
import config from './config'
import { createServer, listen } from './server'
import { Collector } from './collector'
import { registerLoaders } from './loaders'
import { closeDB, getDB } from './storage'
import { initStatisticsContext } from '@hcengineering/server-core'
const ctx = initStatisticsContext('analytics-collector', {
@@ -46,35 +39,36 @@ configureAnalytics(config.SentryDSN, config)
Analytics.setTag('application', 'analytics-collector-service')
export const main = async (): Promise<void> => {
setMetadata(serverToken.metadata.Secret, config.Secret)
setMetadata(serverClient.metadata.Endpoint, config.AccountsUrl)
setMetadata(serverClient.metadata.UserAgent, config.ServiceID)
ctx.info('Analytics service started', {
accountsUrl: config.AccountsUrl,
supportWorkspace: config.SupportWorkspace
})
registerLoaders()
const db = await getDB()
const collector = new Collector(ctx, db)
const app = createServer(collector)
const server = listen(app, config.Port)
const shutdown = (): void => {
void collector.close()
void closeDB()
server.close(() => process.exit())
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
process.on('uncaughtException', (e) => {
console.error(e)
})
process.on('unhandledRejection', (e) => {
console.error(e)
})
ctx.info('Analytics collector service is not implemented yet')
process.exit()
// setMetadata(serverToken.metadata.Secret, config.Secret)
// setMetadata(serverClient.metadata.Endpoint, config.AccountsUrl)
// setMetadata(serverClient.metadata.UserAgent, config.ServiceID)
//
// ctx.info('Analytics service started', {
// accountsUrl: config.AccountsUrl
// })
//
// registerLoaders()
//
// const db = await getDB()
// const collector = new Collector(ctx, db)
//
// const app = createServer(collector)
// const server = listen(app, config.Port)
//
// const shutdown = (): void => {
// void collector.close()
// void closeDB()
// server.close(() => process.exit())
// }
//
// process.on('SIGINT', shutdown)
// process.on('SIGTERM', shutdown)
// process.on('uncaughtException', (e) => {
// console.error(e)
// })
// process.on('unhandledRejection', (e) => {
// console.error(e)
// })
}
@@ -22,7 +22,6 @@ import { extractToken } from '@hcengineering/server-client'
import { ApiError } from './error'
import { Collector } from './collector'
import { Action } from './types'
type AsyncRequestHandler = (req: Request, res: Response, token: Token, next: NextFunction) => Promise<void>
@@ -90,35 +89,6 @@ export function createServer (collector: Collector): Express {
})
)
app.post(
'/action',
wrapRequest(async (req, res, token) => {
if (req.body == null || Array.isArray(req.body)) {
throw new ApiError(400)
}
const name = req.body.name
const messageId = req.body.messageId
const channelId = req.body.channelId
const _id = req.body._id
if (name == null || messageId == null || channelId == null || _id == null) {
throw new ApiError(400)
}
const action: Action = {
_id,
name,
messageId,
channelId
}
await collector.processAction(action, token)
res.status(200)
res.json({})
})
)
app.use((err: any, _req: any, res: any, _next: any) => {
console.log(err)
if (err instanceof ApiError) {
@@ -15,7 +15,7 @@
import analyticsCollector, { AnalyticEvent, OnboardingChannel } from '@hcengineering/analytics-collector'
import chunter, { Channel, ChatMessage } from '@hcengineering/chunter'
import { includesAny, getAllSocialStringsByPersonId, getPrimarySocialId, type Person } from '@hcengineering/contact'
import { getPrimarySocialId, type Person } from '@hcengineering/contact'
import core, {
Doc,
generateId,
@@ -39,7 +39,7 @@ import { generateToken } from '@hcengineering/server-token'
import { getClient as getAccountClient } from '@hcengineering/account-client'
import { Collection } from 'mongodb'
import { eventToMarkup, getOnboardingMessage } from './format'
import { Action, MessageActions, OnboardingMessage } from './types'
import { OnboardingMessage } from './types'
import config from './config'
import { WorkspaceClient } from './workspaceClient'
@@ -142,71 +142,6 @@ export class SupportWsClient extends WorkspaceClient {
}
}
async handleAcceptAction (
action: Action,
personId: PersonId,
onboardingMessages: Collection<OnboardingMessage>
): Promise<void> {
if (action.channelId !== analyticsCollector.space.GeneralOnboardingChannel) {
return
}
const client = await this.opClient
const personIds = await getAllSocialStringsByPersonId(client, personId)
if (personIds.length === 0) {
return
}
if (this.generalChannel === undefined) {
return
}
if (!includesAny(this.generalChannel.members, personIds)) {
return
}
const message = (await onboardingMessages.findOne({ messageId: action.messageId })) ?? undefined
if (message === undefined) {
return
}
await client.updateDoc(analyticsCollector.class.OnboardingChannel, core.space.Space, message.channelId, {
$push: { members: personId }
})
await onboardingMessages.deleteOne({ messageId: action.messageId })
await client.removeCollection(
chunter.class.InlineButton,
analyticsCollector.space.GeneralOnboardingChannel,
action._id,
action.messageId,
chunter.class.ChatMessage,
'inlineButtons'
)
}
async processAction (
action: Action,
person: Person,
onboardingMessages: Collection<OnboardingMessage>
): Promise<void> {
switch (action.name) {
case MessageActions.Accept: {
const personId = await this.getPersonId(person._id)
if (personId === undefined) {
return
}
await this.handleAcceptAction(action, personId, onboardingMessages)
break
}
default:
}
}
async getPersonId (person: Ref<Person>): Promise<PersonId | undefined> {
const cachedPersonId = this.personIdByPerson.get(person)
const personId = cachedPersonId ?? (await getPrimarySocialId(await this.opClient, person))
@@ -260,19 +195,6 @@ export class SupportWsClient extends WorkspaceClient {
messageId
)
await op.addCollection(
chunter.class.InlineButton,
analyticsCollector.space.GeneralOnboardingChannel,
messageId,
chunter.class.ChatMessage,
'inlineButtons',
{
name: MessageActions.Accept,
title: 'Accept',
action: analyticsCollector.function.AnalyticsCollectorInlineAction
}
)
await onboardingMessages.insertOne({ messageId, channelId })
}
@@ -1,18 +1,7 @@
import { ChatMessage, InlineButton, Channel } from '@hcengineering/chunter'
import { ChatMessage } from '@hcengineering/chunter'
import { Ref } from '@hcengineering/core'
import { OnboardingChannel } from '@hcengineering/analytics-collector'
export interface Action {
_id: Ref<InlineButton>
name: string
messageId: Ref<ChatMessage>
channelId: Ref<Channel>
}
export enum MessageActions {
Accept = 'accept'
}
export interface OnboardingMessage {
messageId: Ref<ChatMessage>
channelId: Ref<OnboardingChannel>
-1
View File
@@ -99,7 +99,6 @@ export class Transactor extends DurableObject<Env> {
setMetadata(serverNotification.metadata.SesUrl, env.SES_URL ?? '')
setMetadata(serverNotification.metadata.SesAuthToken, env.SES_AUTH_TOKEN)
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)
registerTxAdapterFactory('postgresql', createPostgresTxAdapter, true)
-1
View File
@@ -25,7 +25,6 @@ interface Env {
SES_URL?: string
SES_AUTH_TOKEN?: string
SUPPORT_WORKSPACE?: string
TELEGRAM_BOT_URL: string
AI_BOT_URL?: string
LAST_NAME_FIRST?: string
-1
View File
@@ -33,7 +33,6 @@ ENABLE_COMPRESSION=true
# PUSH_PUBLIC_KEY
# PUSH_PRIVATE_KEY
# SENTRY_DSN
# SUPPORT_WORKSPACE
# TELEGRAM_BOT_URL
# AI_BOT_URL
# LAST_NAME_FIRST