diff --git a/.vscode/launch.json b/.vscode/launch.json index 5d8c9333ed..44cf45ec02 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -384,7 +384,8 @@ "LAST_NAME": "AI", "PASSWORD": "password", "AVATAR_PATH": "./assets/avatar.png", - "AVATAR_CONTENT_TYPE": ".png" + "AVATAR_CONTENT_TYPE": ".png", + "OPENAI_API_KEY": "token" }, "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], "sourceMaps": true, diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 345e8450ed..a5b120e96f 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -566,6 +566,9 @@ dependencies: '@rush-temp/onboard-resources': specifier: file:./projects/onboard-resources.tgz version: file:projects/onboard-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(file-loader@6.2.0)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2)(webpack@5.90.3) + '@rush-temp/openai': + specifier: file:./projects/openai.tgz + version: file:projects/openai.tgz(esbuild@0.20.1)(svelte@4.2.12)(ts-node@10.9.2)(zod@3.23.8) '@rush-temp/panel': specifier: file:./projects/panel.tgz version: file:projects/panel.tgz(@types/node@20.11.19)(esbuild@0.20.1)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2) @@ -580,7 +583,7 @@ dependencies: version: file:projects/pod-account.tgz '@rush-temp/pod-ai-bot': specifier: file:./projects/pod-ai-bot.tgz - version: file:projects/pod-ai-bot.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4) + version: file:projects/pod-ai-bot.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4)(zod@3.23.8) '@rush-temp/pod-analytics-collector': specifier: file:./projects/pod-analytics-collector.tgz version: file:projects/pod-analytics-collector.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4) @@ -1664,6 +1667,9 @@ dependencies: octokit: specifier: ^3.1.1 version: 3.2.1 + openai: + specifier: ^4.56.0 + version: 4.56.0(zod@3.23.8) otp-generator: specifier: ^4.0.1 version: 4.0.1 @@ -1790,6 +1796,9 @@ dependencies: telegram: specifier: 2.22.2 version: 2.22.2 + tiktoken: + specifier: ^1.0.16 + version: 1.0.16 toposort: specifier: ^2.0.2 version: 2.0.2 @@ -1847,6 +1856,9 @@ dependencies: ws: specifier: ^8.18.0 version: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) + y-indexeddb: + specifier: ^9.0.12 + version: 9.0.12(yjs@13.6.12) y-prosemirror: specifier: ^1.2.1 version: 1.2.2(prosemirror-model@1.19.4)(y-protocols@1.0.6)(yjs@13.6.12) @@ -10283,6 +10295,13 @@ packages: - supports-color dev: false + /agentkeepalive@4.5.0: + resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==} + engines: {node: '>= 8.0.0'} + dependencies: + humanize-ms: 1.2.1 + dev: false + /aggregate-error@3.1.0: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} @@ -14767,6 +14786,10 @@ packages: webpack: 5.90.3(@swc/core@1.4.2)(esbuild@0.20.1)(webpack-cli@5.1.4) dev: false + /form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + dev: false + /form-data@2.5.1: resolution: {integrity: sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==} engines: {node: '>= 0.12'} @@ -14785,6 +14808,14 @@ packages: mime-types: 2.1.35 dev: false + /formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + dev: false + /formdata@0.10.4: resolution: {integrity: sha512-IsHa+GYLLXHx0RmpUmzQTdwxDjNinxD+1zKOYPLaRwiqTfex5caQhOzgPIjFgJkL0O884Ers76BSHzXJxHvPLw==} dependencies: @@ -15780,6 +15811,12 @@ packages: engines: {node: '>=16.17.0'} dev: false + /humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + dependencies: + ms: 2.1.3 + dev: false + /iconv-corefoundation@1.1.7: resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} engines: {node: ^8.11.2 || >=10} @@ -18379,6 +18416,11 @@ packages: minimatch: 3.1.2 dev: false + /node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + dev: false + /node-fetch-native@1.6.2: resolution: {integrity: sha512-69mtXOFZ6hSkYiXAVB5SqaRvrbITC/NPyqv7yuu/qw0nmgPyYbIMYYNIDhNtwPrzk0ptrimrLz/hhjvm4w5Z+w==} dev: false @@ -18744,6 +18786,27 @@ packages: is-wsl: 2.2.0 dev: false + /openai@4.56.0(zod@3.23.8): + resolution: {integrity: sha512-zcag97+3bG890MNNa0DQD9dGmmTWL8unJdNkulZzWRXrl+QeD+YkBI4H58rJcwErxqGK6a0jVPZ4ReJjhDGcmw==} + hasBin: true + peerDependencies: + zod: ^3.23.8 + peerDependenciesMeta: + zod: + optional: true + dependencies: + '@types/node': 18.19.17 + '@types/node-fetch': 2.6.11 + abort-controller: 3.0.0 + agentkeepalive: 4.5.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + zod: 3.23.8 + transitivePeerDependencies: + - encoding + dev: false + /opener@1.5.2: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true @@ -22101,6 +22164,10 @@ packages: resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} dev: false + /tiktoken@1.0.16: + resolution: {integrity: sha512-hRcORIGF2YlAgWx3nzrGJOrKSJwLoc81HpXmMQk89632XAgURc7IeV2FgQ2iXo9z/J96fCvpsHg2kWoHcbj9fg==} + dev: false + /timm@1.7.1: resolution: {integrity: sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==} dev: false @@ -23061,6 +23128,11 @@ packages: - supports-color dev: false + /web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + dev: false + /webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} dev: false @@ -28877,6 +28949,42 @@ packages: - ts-node dev: false + file:projects/openai.tgz(esbuild@0.20.1)(svelte@4.2.12)(ts-node@10.9.2)(zod@3.23.8): + resolution: {integrity: sha512-jWsGOjlco80/7CIZ2Ogs7lCWr23lHUDQIB2hkPgYu2rbvM7iwSz5jhPjTPmR2y9gkVTSyyd9ozTo9LuxPpI6Kg==, tarball: file:projects/openai.tgz} + id: file:projects/openai.tgz + name: '@rush-temp/openai' + version: 0.0.0 + dependencies: + '@types/jest': 29.5.12 + '@types/node': 20.11.19 + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + eslint: 8.56.0 + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3) + eslint-plugin-import: 2.29.1(eslint@8.56.0) + eslint-plugin-n: 15.7.0(eslint@8.56.0) + eslint-plugin-promise: 6.1.1(eslint@8.56.0) + jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) + openai: 4.56.0(zod@3.23.8) + prettier: 3.2.5 + prettier-plugin-svelte: 3.2.2(prettier@3.2.5)(svelte@4.2.12) + tiktoken: 1.0.16 + ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3) + typescript: 5.3.3 + transitivePeerDependencies: + - '@babel/core' + - '@jest/types' + - babel-jest + - babel-plugin-macros + - encoding + - esbuild + - node-notifier + - supports-color + - svelte + - ts-node + - zod + dev: false + file:projects/panel.tgz(@types/node@20.11.19)(esbuild@0.20.1)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2): resolution: {integrity: sha512-1v1IuSTwDSjPgaZsKWG4tEUINvY3KtFNYXDHvUVkefqJm280nZg5G4QnphrKdOseOxHN7zaSq5CoVxURn1hV2g==, tarball: file:projects/panel.tgz} id: file:projects/panel.tgz @@ -29026,8 +29134,8 @@ packages: - supports-color dev: false - file:projects/pod-ai-bot.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4): - resolution: {integrity: sha512-5Upjw52FvxxuZY9eSN8N5FUKoNMhqME7DwEAp4TTQEKR/Rs+Fnxi7S5ZQ6G2G40RmZ0F0mWfXfWTcyhFlyio5Q==, tarball: file:projects/pod-ai-bot.tgz} + file:projects/pod-ai-bot.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4)(zod@3.23.8): + resolution: {integrity: sha512-/UB67RfT0fI4XW6vu6sYFLs57ciAWNBNDq5Au/5qgN1ilwh/VvgqJNgwiGTPkqt4b1Q9AKfpy7AIRy2FoAk1/A==, tarball: file:projects/pod-ai-bot.tgz} id: file:projects/pod-ai-bot.tgz name: '@rush-temp/pod-ai-bot' version: 0.0.0 @@ -29056,7 +29164,9 @@ packages: jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) mongodb: 6.8.0 node-fetch: 2.7.0 + openai: 4.56.0(zod@3.23.8) prettier: 3.2.5 + tiktoken: 1.0.16 ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3) ts-node: 10.9.2(@types/node@20.11.19)(typescript@5.3.3) typescript: 5.3.3 @@ -29080,6 +29190,7 @@ packages: - socks - supports-color - utf-8-validate + - zod dev: false file:projects/pod-analytics-collector.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4): @@ -30035,7 +30146,7 @@ packages: dev: false file:projects/prod.tgz(bufferutil@4.0.8)(sass@1.71.1)(ts-node@10.9.2)(utf-8-validate@6.0.4): - resolution: {integrity: sha512-lYEzH4pmYid/iB0WNGOZXt3y9nyDbs70r3jYZJ6qIQEyvQVqWXnAd8Rw+nqcEbI/QtTou81FrvAK2Fnp5Imeww==, tarball: file:projects/prod.tgz} + resolution: {integrity: sha512-BKzsLd9ugM4rEyUkJyeMG3QjOS0RLA/ipkLIIRC+NUUQi/zMuHMEumvoSok539kpPsVgRrtJdnDBa+3tja5fSA==, tarball: file:projects/prod.tgz} id: file:projects/prod.tgz name: '@rush-temp/prod' version: 0.0.0 @@ -30932,6 +31043,7 @@ packages: '@types/node': 20.11.19 '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + bson: 6.8.0 eslint: 8.56.0 eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3) eslint-plugin-import: 2.29.1(eslint@8.56.0) diff --git a/models/server-ai-bot/src/index.ts b/models/server-ai-bot/src/index.ts index d81b0c21d3..c7c8d0c999 100644 --- a/models/server-ai-bot/src/index.ts +++ b/models/server-ai-bot/src/index.ts @@ -45,7 +45,7 @@ export class TAIBotEvent extends TDoc implements AIBotEvent { message!: string } -@Model(aiBot.class.AIBotResponseEvent, core.class.Doc) +@Model(aiBot.class.AIBotResponseEvent, aiBot.class.AIBotEvent) export class TAIBotResponseEvent extends TAIBotEvent implements AIBotResponseEvent { @Prop(TypeRef(core.class.Doc), core.string.Object) objectId!: Ref diff --git a/plugins/openai/.eslintrc.js b/plugins/openai/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/plugins/openai/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/plugins/openai/.npmignore b/plugins/openai/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/plugins/openai/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/plugins/openai/config/rig.json b/plugins/openai/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/plugins/openai/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/plugins/openai/jest.config.js b/plugins/openai/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/plugins/openai/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/plugins/openai/package.json b/plugins/openai/package.json new file mode 100644 index 0000000000..d297548c5d --- /dev/null +++ b/plugins/openai/package.json @@ -0,0 +1,47 @@ +{ + "name": "@hcengineering/openai", + "version": "0.6.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Copyright © Hardcore Engineering Inc.", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.6.0", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.3.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "prettier-plugin-svelte": "^3.2.2", + "@types/node": "~20.11.16" + }, + "dependencies": { + "@hcengineering/core": "^0.6.32", + "@hcengineering/platform": "^0.6.11", + "openai": "^4.56.0", + "tiktoken": "^1.0.16" + } +} diff --git a/plugins/analytics-collector/src/plugin.ts b/plugins/openai/src/index.ts similarity index 66% rename from plugins/analytics-collector/src/plugin.ts rename to plugins/openai/src/index.ts index 4c2fda8bad..175dcab5f0 100644 --- a/plugins/analytics-collector/src/plugin.ts +++ b/plugins/openai/src/index.ts @@ -13,14 +13,13 @@ // limitations under the License. // -import { type Metadata, type Plugin, plugin } from '@hcengineering/platform' +import type { Plugin } from '@hcengineering/platform' +import { plugin } from '@hcengineering/platform' -export const analyticsCollectorId = 'analyticsCollector' as Plugin +export const openaiId = 'openai' as Plugin -export const analyticsCollector = plugin(analyticsCollectorId, { - metadata: { - EndpointURL: '' as Metadata - } -}) +export * from './utils' -export default analyticsCollector +const openai = plugin(openaiId, {}) + +export default openai diff --git a/plugins/openai/src/utils.ts b/plugins/openai/src/utils.ts new file mode 100644 index 0000000000..650bb038c2 --- /dev/null +++ b/plugins/openai/src/utils.ts @@ -0,0 +1,41 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import OpenAI from 'openai' +import { Tiktoken } from 'tiktoken' + +// Based on https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb +export function countTokens (messages: OpenAI.ChatCompletionMessageParam[], encoding: Tiktoken): number { + const tokensPerMessage = 3 // every message follows <|start|>{role/name}\n{content}<|end|>\n + const tokensPerName = 1 // every name follows <|name|>{name}<|end|>\n + + let result = 0 + + for (const message of messages) { + result += tokensPerMessage + for (const key in message) { + const value = message[key as keyof OpenAI.ChatCompletionMessageParam] as string + if (value == null) continue + result += encoding.encode(value).length + if (key === 'name') { + result += tokensPerName + } + } + } + + result += 3 // every reply is primed with <|start|>assistant<|message|> + + return result +} diff --git a/plugins/openai/tsconfig.json b/plugins/openai/tsconfig.json new file mode 100644 index 0000000000..59e4fd4297 --- /dev/null +++ b/plugins/openai/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + } +} \ No newline at end of file diff --git a/rush.json b/rush.json index f4db1abb66..360672ad8e 100644 --- a/rush.json +++ b/rush.json @@ -2121,6 +2121,11 @@ "packageName": "@hcengineering/pod-telegram-bot", "projectFolder": "services/telegram-bot/pod-telegram-bot", "shouldPublish": false + }, + { + "packageName": "@hcengineering/openai", + "projectFolder": "plugins/openai", + "shouldPublish": false } ] } diff --git a/server-plugins/ai-bot-resources/src/index.ts b/server-plugins/ai-bot-resources/src/index.ts index 2683aeb511..6629a724cc 100644 --- a/server-plugins/ai-bot-resources/src/index.ts +++ b/server-plugins/ai-bot-resources/src/index.ts @@ -31,11 +31,11 @@ import core, { import { TriggerControl } from '@hcengineering/server-core' import chunter, { ChatMessage, DirectMessage, ThreadMessage } from '@hcengineering/chunter' import aiBot, { aiBotAccountEmail, AIBotResponseEvent } from '@hcengineering/ai-bot' -import serverAIBot, { AIBotServiceAdapter, serverAiBotId } from '@hcengineering/server-ai-bot' +import { AIBotServiceAdapter, serverAiBotId } from '@hcengineering/server-ai-bot' import contact, { PersonAccount } from '@hcengineering/contact' import { ActivityInboxNotification, MentionInboxNotification } from '@hcengineering/notification' -import { getMetadata } from '@hcengineering/platform' import analyticsCollector, { OnboardingChannel } from '@hcengineering/analytics-collector' +import { getSupportWorkspaceId } from './utils' async function processWorkspace (control: TriggerControl): Promise { const adapter = control.serviceAdaptersManager.getAdapter(serverAiBotId) as AIBotServiceAdapter | undefined @@ -62,14 +62,6 @@ async function isDirectAvailable (direct: DirectMessage, control: TriggerControl return persons.size === 2 } -async function isAvailableDoc (doc: Doc, control: TriggerControl): Promise { - if (doc._class === chunter.class.DirectMessage) { - return await isDirectAvailable(doc as DirectMessage, control) - } - - return true -} - async function getMessageDoc (message: ChatMessage, control: TriggerControl): Promise { if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { const thread = message as ThreadMessage @@ -85,15 +77,7 @@ async function getMessageDoc (message: ChatMessage, control: TriggerControl): Pr } } -async function getMessageData ( - doc: Doc, - message: ChatMessage, - control: TriggerControl -): Promise | undefined> { - if (!(await isAvailableDoc(doc, control))) { - return - } - +function getMessageData (doc: Doc, message: ChatMessage): Data { return { objectId: message.attachedTo, objectClass: message.attachedToClass, @@ -105,15 +89,7 @@ async function getMessageData ( } } -async function getThreadMessageData ( - doc: Doc, - message: ThreadMessage, - control: TriggerControl -): Promise | undefined> { - if (!(await isAvailableDoc(doc, control))) { - return - } - +function getThreadMessageData (message: ThreadMessage): Data { return { objectId: message.attachedTo, objectClass: message.attachedToClass, @@ -125,33 +101,14 @@ async function getThreadMessageData ( } } -// Note: temporally commented until open ai will be added -// async function createAIBotEvent (message: ChatMessage, control: TriggerControl): Promise { -// const doc = await getMessageDoc(message, control) -// -// if (doc === undefined) { -// return [] -// } -// -// let data: Data | undefined -// -// if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { -// data = await getThreadMessageData(doc, message as ThreadMessage, control) -// } else { -// data = await getMessageData(doc, message, control) -// } -// -// if (data === undefined) { -// return [] -// } -// -// const eventTx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotResponseEvent, message.space, data) -// -// await processWorkspace(control) -// -// return [eventTx] -// return [] -// } +async function createResponseEvent ( + message: ChatMessage, + control: TriggerControl, + data: Data +): Promise { + const eventTx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotResponseEvent, message.space, data) + await control.apply([eventTx]) +} async function getThreadParent (control: TriggerControl, message: ChatMessage): Promise | undefined> { if (!control.hierarchy.isDerived(message.attachedToClass, chunter.class.ChatMessage)) { @@ -172,28 +129,44 @@ async function getThreadParent (control: TriggerControl, message: ChatMessage): return message.attachedTo as Ref } -function getSupportWorkspaceId (): string | undefined { - const supportWorkspaceId = getMetadata(serverAIBot.metadata.SupportWorkspaceId) - - if (supportWorkspaceId === '') { - return undefined +async function createTransferEvent ( + control: TriggerControl, + message: ChatMessage, + account: PersonAccount, + data: Data +): Promise { + if (account.role !== AccountRole.Owner) { + return } - return supportWorkspaceId -} - -async function onBotDirectMessageSend (control: TriggerControl, message: ChatMessage): Promise { const supportWorkspaceId = getSupportWorkspaceId() if (supportWorkspaceId === undefined) { return } + const eventTx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotTransferEvent, message.space, { + messageClass: data.messageClass, + message: message.message, + collection: data.collection, + toWorkspace: supportWorkspaceId, + toEmail: account.email, + fromWorkspace: toWorkspaceString(control.workspace), + fromWorkspaceName: control.workspace.workspaceName, + fromWorkspaceUrl: control.workspace.workspaceUrl, + messageId: message._id, + parentMessageId: await getThreadParent(control, message) + }) + + await control.apply([eventTx]) +} + +async function onBotDirectMessageSend (control: TriggerControl, message: ChatMessage): Promise { const account = control.modelDb.findAllSync(contact.class.PersonAccount, { _id: (message.createdBy ?? message.modifiedBy) as Ref })[0] - if (account === undefined || account.role !== AccountRole.Owner) { + if (account === undefined) { return } @@ -212,29 +185,14 @@ async function onBotDirectMessageSend (control: TriggerControl, message: ChatMes let data: Data | undefined if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { - data = await getThreadMessageData(direct, message as ThreadMessage, control) + data = getThreadMessageData(message as ThreadMessage) } else { - data = await getMessageData(direct, message, control) + data = getMessageData(direct, message) } - if (data === undefined) { - return - } + await createResponseEvent(message, control, data) + await createTransferEvent(control, message, account, data) - const eventTx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotTransferEvent, message.space, { - messageClass: data.messageClass, - message: message.message, - collection: data.collection, - toWorkspace: supportWorkspaceId, - toEmail: account.email, - fromWorkspace: toWorkspaceString(control.workspace), - fromWorkspaceName: control.workspace.workspaceName, - fromWorkspaceUrl: control.workspace.workspaceUrl, - messageId: message._id, - parentMessageId: await getThreadParent(control, message) - }) - - await control.apply([eventTx]) await processWorkspace(control) } @@ -263,13 +221,9 @@ async function onSupportWorkspaceMessage (control: TriggerControl, message: Chat let data: Data | undefined if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { - data = await getThreadMessageData(channel, message as ThreadMessage, control) + data = getThreadMessageData(message as ThreadMessage) } else { - data = await getMessageData(channel, message, control) - } - - if (data === undefined) { - return + data = getMessageData(channel, message) } const tx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotTransferEvent, message.space, { @@ -348,7 +302,7 @@ export async function OnMention (tx: TxCreateDoc, cont // return [] // } // - // await createAIBotEvent(message, control) + // await createResponseEvent(message, control) return [] } @@ -387,7 +341,7 @@ export async function OnMessageNotified ( // } // // if (isDocMentioned(personAccount.person, message.message)) { - // return await createAIBotEvent(message, control) + // return await createResponseEvent(message, control) // } // // if (!control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { @@ -403,7 +357,7 @@ export async function OnMessageNotified ( // } // // if (parent.createdBy === aiBot.account.AIBot) { - // return await createAIBotEvent(message, control) + // return await createResponseEvent(message, control) // } return [] diff --git a/server-plugins/ai-bot-resources/src/utils.ts b/server-plugins/ai-bot-resources/src/utils.ts new file mode 100644 index 0000000000..321d3963ad --- /dev/null +++ b/server-plugins/ai-bot-resources/src/utils.ts @@ -0,0 +1,27 @@ +// +// 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 { getMetadata } from '@hcengineering/platform' +import serverAIBot from '@hcengineering/server-ai-bot' + +export function getSupportWorkspaceId (): string | undefined { + const supportWorkspaceId = getMetadata(serverAIBot.metadata.SupportWorkspaceId) + + if (supportWorkspaceId === '') { + return undefined + } + + return supportWorkspaceId +} diff --git a/services/ai-bot/pod-ai-bot/package.json b/services/ai-bot/pod-ai-bot/package.json index 5243e8189e..5524a6ea9c 100644 --- a/services/ai-bot/pod-ai-bot/package.json +++ b/services/ai-bot/pod-ai-bot/package.json @@ -64,6 +64,7 @@ "@hcengineering/core": "^0.6.32", "@hcengineering/mongo": "^0.6.1", "@hcengineering/notification": "^0.6.23", + "@hcengineering/openai": "^0.6.0", "@hcengineering/platform": "^0.6.11", "@hcengineering/server-ai-bot": "^0.6.0", "@hcengineering/server-analytics-collector-resources": "^0.6.0", @@ -72,6 +73,7 @@ "@hcengineering/server-token": "^0.6.11", "@hcengineering/server-ws": "^0.6.11", "@hcengineering/setting": "^0.6.17", + "@hcengineering/text": "^0.6.5", "cors": "^2.8.5", "dotenv": "~16.0.0", "express": "^4.19.2", @@ -79,6 +81,8 @@ "form-data": "^4.0.0", "mongodb": "^6.8.0", "node-fetch": "^2.6.6", + "openai": "^4.56.0", + "tiktoken": "^1.0.16", "ws": "^8.18.0" } } diff --git a/services/ai-bot/pod-ai-bot/src/account.ts b/services/ai-bot/pod-ai-bot/src/account.ts index 186a0b905e..a162f6f766 100644 --- a/services/ai-bot/pod-ai-bot/src/account.ts +++ b/services/ai-bot/pod-ai-bot/src/account.ts @@ -15,13 +15,13 @@ import { LoginInfo, Workspace, WorkspaceLoginInfo } from '@hcengineering/account' import aiBot, { aiBotAccountEmail } from '@hcengineering/ai-bot' -import { AccountRole, WorkspaceId } from '@hcengineering/core' +import { AccountRole } from '@hcengineering/core' import config from './config' -export async function assignBotToWorkspace (workspaceId: WorkspaceId): Promise { +export async function assignBotToWorkspace (workspace: string): Promise { const accountsUrl = config.AccountsURL - const workspace = await ( + const res = await ( await fetch(accountsUrl, { method: 'POST', headers: { @@ -29,20 +29,12 @@ export async function assignBotToWorkspace (workspaceId: WorkspaceId): Promise { diff --git a/services/ai-bot/pod-ai-bot/src/config.ts b/services/ai-bot/pod-ai-bot/src/config.ts index 860d50aa1c..7bc92386e4 100644 --- a/services/ai-bot/pod-ai-bot/src/config.ts +++ b/services/ai-bot/pod-ai-bot/src/config.ts @@ -13,6 +13,8 @@ // limitations under the License. // +import OpenAI from 'openai' + interface Config { AccountsURL: string ConfigurationDB: string @@ -26,6 +28,10 @@ interface Config { AvatarName: string AvatarContentType: string Password: string + OpenAIKey: string + OpenAIModel: OpenAI.ChatModel + MaxContentTokens: number + MaxHistoryRecords: number } const envMap: { [key in keyof Config]: string } = { @@ -40,9 +46,15 @@ const envMap: { [key in keyof Config]: string } = { AvatarPath: 'AVATAR_PATH', AvatarName: 'AVATAR_NAME', AvatarContentType: 'AVATAR_CONTENT_TYPE', - Password: 'PASSWORD' + Password: 'PASSWORD', + OpenAIKey: 'OPENAI_API_KEY', + OpenAIModel: 'OPENAI_MODEL', + MaxContentTokens: 'MAX_CONTENT_TOKENS', + MaxHistoryRecords: 'MAX_HISTORY_RECORDS' } +const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined) + const config: Config = (() => { const params: Partial = { AccountsURL: process.env[envMap.AccountsURL], @@ -56,7 +68,11 @@ const config: Config = (() => { AvatarPath: process.env[envMap.AvatarPath] ?? './assets/avatar.png', AvatarName: process.env[envMap.AvatarName] ?? 'huly_ai_bot_avatar', AvatarContentType: process.env[envMap.AvatarContentType] ?? '.png', - Password: process.env[envMap.Password] ?? 'password' + Password: process.env[envMap.Password] ?? 'password', + OpenAIKey: process.env[envMap.OpenAIKey], + OpenAIModel: (process.env[envMap.OpenAIModel] ?? 'gpt-4o-mini') as OpenAI.ChatModel, + MaxContentTokens: parseNumber(process.env[envMap.MaxContentTokens]) ?? 128 * 100, + MaxHistoryRecords: parseNumber(process.env[envMap.MaxHistoryRecords]) ?? 500 } const missingEnv = (Object.keys(params) as Array) diff --git a/services/ai-bot/pod-ai-bot/src/controller.ts b/services/ai-bot/pod-ai-bot/src/controller.ts index f663ee459b..727a3e2bce 100644 --- a/services/ai-bot/pod-ai-bot/src/controller.ts +++ b/services/ai-bot/pod-ai-bot/src/controller.ts @@ -13,16 +13,19 @@ // limitations under the License. // -import { Db, Collection } from 'mongodb' -import { getWorkspaceId, MeasureContext, systemAccountEmail, toWorkspaceString, WorkspaceId } from '@hcengineering/core' +import { MeasureContext, systemAccountEmail } from '@hcengineering/core' import { aiBotAccountEmail, AIBotTransferEvent } from '@hcengineering/ai-bot' import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot' import { getTransactorEndpoint } from '@hcengineering/server-client' import { generateToken } from '@hcengineering/server-token' import { WorkspaceLoginInfo } from '@hcengineering/account' +import OpenAI from 'openai' +import { encoding_for_model } from 'tiktoken' import { WorkspaceClient } from './workspaceClient' import { assignBotToWorkspace, getWorkspaceInfo } from './account' +import config from './config' +import { DbStorage } from './storage' const POLLING_INTERVAL_MS = 5 * 1000 // 5 seconds const CLOSE_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes @@ -34,19 +37,19 @@ export class AIBotController { private readonly closeWorkspaceTimeouts: Map = new Map() private readonly connectingWorkspaces: Set = new Set() - private readonly db: Db - private readonly ctx: MeasureContext - private readonly workspacesInfoCollection: Collection - private readonly intervalId: NodeJS.Timeout + readonly aiClient: OpenAI + readonly encoding = encoding_for_model(config.OpenAIModel) + assignTimeout: NodeJS.Timeout | undefined assignAttempts = 0 - constructor (mongoDb: Db, ctx: MeasureContext) { - this.db = mongoDb - this.ctx = ctx - this.workspacesInfoCollection = this.db.collection('workspacesInfo') + constructor ( + readonly storage: DbStorage, + private readonly ctx: MeasureContext + ) { + this.aiClient = new OpenAI({ apiKey: config.OpenAIKey }) this.intervalId = setInterval(() => { void this.updateWorkspaceClients() @@ -54,12 +57,10 @@ export class AIBotController { } async updateWorkspaceClients (): Promise { - const activeRecords = await this.workspacesInfoCollection.find({ active: true }).toArray() + const activeRecords = await this.storage.getActiveWorkspaces() for (const record of activeRecords) { - const id: WorkspaceId = { name: record.workspace } - - const ws = toWorkspaceString(id) + const ws = record.workspace if (this.workspaces.has(ws)) { continue @@ -69,13 +70,11 @@ export class AIBotController { continue } - await this.initWorkspaceClient(id, record) + await this.initWorkspaceClient(ws, record) } } - async closeWorkspaceClient (workspaceId: WorkspaceId): Promise { - const workspace = toWorkspaceString(workspaceId) - + async closeWorkspaceClient (workspace: string): Promise { this.ctx.info('Closing workspace client: ', { workspace }) const timeoutId = this.closeWorkspaceTimeouts.get(workspace) @@ -85,7 +84,7 @@ export class AIBotController { this.closeWorkspaceTimeouts.delete(workspace) } - await this.workspacesInfoCollection.updateOne({ workspace: workspaceId.name }, { $set: { active: false } }) + await this.storage.inactiveWorkspace(workspace) const client = this.workspaces.get(workspace) @@ -96,14 +95,14 @@ export class AIBotController { this.connectingWorkspaces.delete(workspace) } - private async getWorkspaceInfo (ws: WorkspaceId): Promise { - const systemToken = generateToken(systemAccountEmail, ws) + private async getWorkspaceInfo (ws: string): Promise { + const systemToken = generateToken(systemAccountEmail, { name: ws }) for (let i = 0; i < 5; i++) { try { const info = await getWorkspaceInfo(systemToken) if (info == null) { - this.ctx.warn('Cannot find workspace info', ws) + this.ctx.warn('Cannot find workspace info', { workspace: ws }) await wait(ASSIGN_WORKSPACE_DELAY_MS) continue } @@ -116,51 +115,50 @@ export class AIBotController { } } - private async assignToWorkspace (ws: WorkspaceId): Promise { + private async assignToWorkspace (workspace: string): Promise { clearTimeout(this.assignTimeout) try { - const info = await this.getWorkspaceInfo(ws) + const info = await this.getWorkspaceInfo(workspace) if (info === undefined) { - void this.closeWorkspaceClient(ws) + void this.closeWorkspaceClient(workspace) return } if (info.creating === true) { - this.ctx.info('Workspace is creating -> waiting...', ws) + this.ctx.info('Workspace is creating -> waiting...', { workspace }) this.assignTimeout = setTimeout(() => { - void this.assignToWorkspace(ws) + void this.assignToWorkspace(workspace) }, ASSIGN_WORKSPACE_DELAY_MS) return } - const result = await assignBotToWorkspace(ws) - this.ctx.info('Assign to workspace result: ', { result, workspace: ws.name }) + const result = await assignBotToWorkspace(workspace) + this.ctx.info('Assign to workspace result: ', { result, workspace }) } catch (e) { this.ctx.error('Error during assign workspace:', { e }) if (this.assignAttempts < MAX_ASSIGN_ATTEMPTS) { this.assignAttempts++ this.assignTimeout = setTimeout(() => { - void this.assignToWorkspace(ws) + void this.assignToWorkspace(workspace) }, ASSIGN_WORKSPACE_DELAY_MS) } else { - void this.closeWorkspaceClient(ws) + void this.closeWorkspaceClient(workspace) } } } - async initWorkspaceClient (workspaceId: WorkspaceId, info: WorkspaceInfoRecord): Promise { - const workspace = toWorkspaceString(workspaceId) + async initWorkspaceClient (workspace: string, info: WorkspaceInfoRecord): Promise { this.connectingWorkspaces.add(workspace) if (!this.workspaces.has(workspace)) { this.ctx.info('Listen workspace: ', { workspace }) - await this.assignToWorkspace(workspaceId) - const token = generateToken(aiBotAccountEmail, workspaceId) + await this.assignToWorkspace(workspace) + const token = generateToken(aiBotAccountEmail, { name: workspace }) const endpoint = await getTransactorEndpoint(token) this.workspaces.set( workspace, - new WorkspaceClient(endpoint, token, workspaceId, this, this.ctx.newChild(workspace, {}), info) + new WorkspaceClient(endpoint, token, workspace, this, this.ctx.newChild(workspace, {}), info) ) } @@ -171,7 +169,7 @@ export class AIBotController { } const newTimeoutId = setTimeout(() => { - void this.closeWorkspaceClient(workspaceId) + void this.closeWorkspaceClient(workspace) }, CLOSE_INTERVAL_MS) this.closeWorkspaceTimeouts.set(workspace, newTimeoutId) @@ -179,12 +177,17 @@ export class AIBotController { } async transfer (event: AIBotTransferEvent): Promise { - const workspaceId = getWorkspaceId(event.toWorkspace) - const info = await this.workspacesInfoCollection.find({ workspace: workspaceId.name }).toArray() + const workspace = event.toWorkspace + const info = await this.storage.getWorkspace(workspace) - await this.initWorkspaceClient(workspaceId, info[0]) + if (info === undefined) { + this.ctx.error('Workspace info not found -> cannot transfer event', { workspace }) + return + } - const wsClient = this.workspaces.get(event.toWorkspace) + await this.initWorkspaceClient(workspace, info) + + const wsClient = this.workspaces.get(workspace) if (wsClient === undefined) { return @@ -195,6 +198,9 @@ export class AIBotController { async close (): Promise { clearInterval(this.intervalId) + + this.encoding.free() + for (const workspace of this.workspaces.values()) { await workspace.close() } @@ -204,11 +210,8 @@ export class AIBotController { this.workspaces.clear() } - async updateAvatarInfo (workspace: WorkspaceId, path: string, lastModified: number): Promise { - await this.workspacesInfoCollection.updateOne( - { workspace: workspace.name }, - { $set: { avatarPath: path, avatarLastModified: lastModified } } - ) + async updateAvatarInfo (workspace: string, path: string, lastModified: number): Promise { + await this.storage.updateWorkspace(workspace, { $set: { avatarPath: path, avatarLastModified: lastModified } }) } } diff --git a/services/ai-bot/pod-ai-bot/src/start.ts b/services/ai-bot/pod-ai-bot/src/start.ts index 3d3003a8d7..ed0c8f4bcf 100644 --- a/services/ai-bot/pod-ai-bot/src/start.ts +++ b/services/ai-bot/pod-ai-bot/src/start.ts @@ -20,7 +20,7 @@ import { MeasureMetricsContext } from '@hcengineering/core' import serverClient from '@hcengineering/server-client' import config from './config' -import { closeDB, getDB } from './storage' +import { closeDB, DbStorage, getDB } from './storage' import { AIBotController } from './controller' import { createBotAccount } from './account' import { registerLoaders } from './loaders' @@ -37,6 +37,7 @@ export const start = async (): Promise => { 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 { @@ -47,7 +48,7 @@ export const start = async (): Promise => { } await new Promise((resolve) => setTimeout(resolve, 3000)) } - const aiController = new AIBotController(db, ctx) + const aiController = new AIBotController(storage, ctx) const onClose = (): void => { void aiController.close() diff --git a/services/ai-bot/pod-ai-bot/src/storage.ts b/services/ai-bot/pod-ai-bot/src/storage.ts index 5549accc00..583db8b8a9 100644 --- a/services/ai-bot/pod-ai-bot/src/storage.ts +++ b/services/ai-bot/pod-ai-bot/src/storage.ts @@ -14,9 +14,12 @@ // import { MongoClientReference, getMongoClient } from '@hcengineering/mongo' -import { MongoClient } from 'mongodb' +import { Collection, Db, MongoClient, ObjectId, UpdateFilter, WithId } from 'mongodb' +import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot' +import { Doc, Ref, SortingOrder } from '@hcengineering/core' import config from './config' +import { HistoryRecord } from './types' const clientRef: MongoClientReference = getMongoClient(config.MongoURL) let client: MongoClient | undefined @@ -34,3 +37,43 @@ export const getDB = (() => { export const closeDB: () => Promise = async () => { clientRef.close() } + +export class DbStorage { + private readonly workspacesInfoCollection: Collection + private readonly historyCollection: Collection + + constructor (private readonly db: Db) { + this.workspacesInfoCollection = this.db.collection('workspacesInfo') + this.historyCollection = this.db.collection('history') + } + + async addHistoryRecord (record: HistoryRecord): Promise { + return (await this.historyCollection.insertOne(record)).insertedId + } + + async getHistoryRecords (workspace: string, objectId: Ref): Promise[]> { + return await this.historyCollection + .find({ workspace, objectId }, { sort: { timestamp: SortingOrder.Ascending } }) + .toArray() + } + + async removeHistoryRecords (_ids: ObjectId[]): Promise { + await this.historyCollection.deleteMany({ _id: { $in: _ids } }) + } + + async getActiveWorkspaces (): Promise { + return await this.workspacesInfoCollection.find({ active: true }).toArray() + } + + async inactiveWorkspace (workspace: string): Promise { + await this.workspacesInfoCollection.updateOne({ workspace }, { $set: { active: false } }) + } + + async getWorkspace (workspace: string): Promise { + return (await this.workspacesInfoCollection.findOne({ workspace })) ?? undefined + } + + async updateWorkspace (workspace: string, update: UpdateFilter): Promise { + await this.workspacesInfoCollection.updateOne({ workspace }, update) + } +} diff --git a/services/ai-bot/pod-ai-bot/src/types.ts b/services/ai-bot/pod-ai-bot/src/types.ts new file mode 100644 index 0000000000..87c4eb7f9a --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/types.ts @@ -0,0 +1,29 @@ +// +// 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 { ObjectId } from 'mongodb' +import { Account, Class, Doc, Ref } from '@hcengineering/core' + +export interface HistoryRecord { + _id?: ObjectId + workspace: string + message: string + objectId: Ref + objectClass: Ref> + role: string + user: Ref + tokens: number + timestamp: number +} diff --git a/services/ai-bot/pod-ai-bot/src/utils.ts b/services/ai-bot/pod-ai-bot/src/utils.ts new file mode 100644 index 0000000000..7046e8189e --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/utils.ts @@ -0,0 +1,129 @@ +// +// 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, { Account, Ref, TxOperations } from '@hcengineering/core' +import contact, { PersonAccount } from '@hcengineering/contact' +import aiBot from '@hcengineering/ai-bot' +import { loginBot } from './account' +import chunter, { DirectMessage } from '@hcengineering/chunter' +import { deepEqual } from 'fast-equals' +import notification from '@hcengineering/notification' +import OpenAI from 'openai' +import { countTokens } from '@hcengineering/openai' +import { Tiktoken } from 'tiktoken' + +import { HistoryRecord } from './types' +import config from './config' + +export async function login (): Promise { + const token = (await loginBot())?.token + + if (token !== undefined) { + return token + } else { + return (await loginBot())?.token + } +} + +export async function getDirect ( + client: TxOperations, + email: string, + aiAccount?: PersonAccount +): Promise | undefined> { + const personAccount = await client.getModel().findOne(contact.class.PersonAccount, { email }) + + if (personAccount === undefined) { + return + } + + const allAccounts = await client.findAll(contact.class.PersonAccount, { person: personAccount.person }) + const accIds: Ref[] = [aiBot.account.AIBot, ...allAccounts.map(({ _id }) => _id)].sort() + const existingDms = await client.findAll(chunter.class.DirectMessage, {}) + + for (const dm of existingDms) { + if (deepEqual(dm.members.sort(), accIds)) { + return dm._id + } + } + + const dmId = await client.createDoc(chunter.class.DirectMessage, core.space.Space, { + name: '', + description: '', + private: true, + archived: false, + members: accIds + }) + + if (aiAccount === undefined) return dmId + const space = await client.findOne(contact.class.PersonSpace, { person: aiAccount.person }) + if (space === undefined) return dmId + await client.createDoc(notification.class.DocNotifyContext, space._id, { + user: aiBot.account.AIBot, + objectId: dmId, + objectClass: chunter.class.DirectMessage, + objectSpace: core.space.Space, + isPinned: false + }) + + return dmId +} + +export async function createChatCompletion ( + client: OpenAI, + message: OpenAI.ChatCompletionMessageParam, + user?: string, + history: OpenAI.ChatCompletionMessageParam[] = [] +): Promise { + try { + return await client.chat.completions.create({ + messages: [...history, message], + model: config.OpenAIModel, + user, + stream: false + }) + } catch (e) { + console.error(e) + } + + return undefined +} + +export async function requestSummary ( + aiClient: OpenAI, + encoding: Tiktoken, + history: HistoryRecord[] +): Promise<{ + summary?: string + tokens: number + }> { + const summaryPrompt: OpenAI.ChatCompletionMessageParam = { + content: `Summarize the following messages, keeping the key points: ${history.map((msg) => `${msg.role}: ${msg.message}`).join('\n')}`, + role: 'user' + } + + const response = await createChatCompletion(aiClient, summaryPrompt, undefined, [ + { role: 'system', content: 'Make a summary of messages history' } + ]) + + const summary = response?.choices[0].message.content + + if (summary == null) { + return { tokens: 0 } + } + + const tokens = response?.usage?.completion_tokens ?? countTokens([{ content: summary, role: 'assistant' }], encoding) + + return { summary, tokens } +} diff --git a/services/ai-bot/pod-ai-bot/src/workspaceClient.ts b/services/ai-bot/pod-ai-bot/src/workspaceClient.ts index 5986d7ec55..b307d31726 100644 --- a/services/ai-bot/pod-ai-bot/src/workspaceClient.ts +++ b/services/ai-bot/pod-ai-bot/src/workspaceClient.ts @@ -25,26 +25,39 @@ import core, { TxCreateDoc, TxOperations, TxProcessor, - WorkspaceId, Blob, - RateLimiter + RateLimiter, + generateId, + TxRemoveDoc, + Data } from '@hcengineering/core' import aiBot, { AIBotEvent, aiBotAccountEmail, AIBotResponseEvent, AIBotTransferEvent } from '@hcengineering/ai-bot' -import chunter, { Channel, ChatMessage, DirectMessage, ThreadMessage } from '@hcengineering/chunter' -import contact, { AvatarType, combineName, getFirstName, getLastName, PersonAccount } from '@hcengineering/contact' -import notification from '@hcengineering/notification' +import chunter, { Channel, ChatMessage, DirectMessage, ThreadMessage, TypingInfo } from '@hcengineering/chunter' +import contact, { + AvatarType, + combineName, + getFirstName, + getLastName, + Person, + PersonAccount +} from '@hcengineering/contact' import { getOrCreateOnboardingChannel } from '@hcengineering/server-analytics-collector-resources' -import { deepEqual } from 'fast-equals' import { BlobClient } from '@hcengineering/server-client' import fs from 'fs' import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot' +import { countTokens } from '@hcengineering/openai' +import { jsonToMarkup, MarkdownParser, markupToText } from '@hcengineering/text' +import { WithId } from 'mongodb' +import OpenAI from 'openai' import config from './config' -import { loginBot } from './account' import { AIBotController } from './controller' import { connectPlatform } from './platform' +import { HistoryRecord } from './types' +import { createChatCompletion, getDirect, login, requestSummary } from './utils' const MAX_LOGIN_DELAY_MS = 15 * 1000 // 15 ses +const UPDATE_TYPING_TIMEOUT_MS = 1000 export class WorkspaceClient { client: Client | undefined @@ -56,39 +69,56 @@ export class WorkspaceClient { loginDelayMs = 2 * 1000 channelByKey = new Map>() - aiAccount: PersonAccount | undefined rate = new RateLimiter(1) + aiAccount: PersonAccount | undefined + aiPerson: Person | undefined + + typingMap: Map, TypingInfo> = new Map, TypingInfo>() + typingTimeoutsMap: Map, NodeJS.Timeout> = new Map, NodeJS.Timeout>() directByEmail = new Map>() + historyMap = new Map, WithId[]>() + + summarizing = new Set>() + constructor ( readonly transactorUrl: string, readonly token: string, - readonly workspace: WorkspaceId, + readonly workspace: string, readonly controller: AIBotController, readonly ctx: MeasureContext, readonly info: WorkspaceInfoRecord | undefined ) { - this.blobClient = new BlobClient(transactorUrl, token, this.workspace) + this.blobClient = new BlobClient(transactorUrl, token, { name: this.workspace }) this.opClient = this.initClient() void this.opClient.then((opClient) => { this.opClient = opClient }) } - private async login (): Promise { - this.ctx.info('Logging in: ', this.workspace) - const token = (await loginBot())?.token + private async initClient (): Promise { + await this.tryLogin() - if (token !== undefined) { - return token - } else { - return (await loginBot())?.token + this.client = await connectPlatform(this.token, this.transactorUrl) + const opClient = new TxOperations(this.client, aiBot.account.AIBot) + + await this.uploadAvatarFile(opClient) + const typing = await opClient.findAll(chunter.class.TypingInfo, { user: aiBot.account.AIBot }) + this.typingMap = new Map(typing.map((t) => [t.objectId, t])) + const events = await opClient.findAll(aiBot.class.AIBotEvent, {}) + void this.processEvents(events) + + this.client.notify = (...txes: Tx[]) => { + void this.txHandler(opClient, txes) } + this.ctx.info('Initialized workspace', { workspace: this.workspace }) + + return opClient } private async uploadAvatarFile (client: TxOperations): Promise { - this.ctx.info('Upload avatar file', { workspace: this.workspace.name }) + this.ctx.info('Upload avatar file', { workspace: this.workspace }) try { await this.checkPersonData(client) @@ -101,21 +131,22 @@ export class WorkspaceClient { this.info.avatarPath === config.AvatarPath && this.info.avatarLastModified === lastModified ) { - this.ctx.info('Avatar file already uploaded', { workspace: this.workspace.name, path: config.AvatarPath }) + this.ctx.info('Avatar file already uploaded', { workspace: this.workspace, path: config.AvatarPath }) return } const data = fs.readFileSync(config.AvatarPath) await this.blobClient.upload(this.ctx, config.AvatarName, data.length, config.AvatarContentType, data) await this.controller.updateAvatarInfo(this.workspace, config.AvatarPath, lastModified) - this.ctx.info('Uploaded avatar file', { workspace: this.workspace.name, path: config.AvatarPath }) + this.ctx.info('Uploaded avatar file', { workspace: this.workspace, path: config.AvatarPath }) } catch (e) { this.ctx.error('Failed to upload avatar file', { e }) } } private async tryLogin (): Promise { - const token = await this.login() + this.ctx.info('Logging in: ', { workspace: this.workspace }) + const token = await login() clearTimeout(this.loginTimeout) @@ -136,52 +167,34 @@ export class WorkspaceClient { this.ctx.error('Cannot find AI PersonAccount', { email: aiBotAccountEmail }) return } - const person = await client.findOne(contact.class.Person, { _id: this.aiAccount.person }) + this.aiPerson = await client.findOne(contact.class.Person, { _id: this.aiAccount.person }) - if (person === undefined) { + if (this.aiPerson === undefined) { this.ctx.error('Cannot find AI Person ', { _id: this.aiAccount.person }) return } - const firstName = getFirstName(person.name) - const lastName = getLastName(person.name) + const firstName = getFirstName(this.aiPerson.name) + const lastName = getLastName(this.aiPerson.name) if (lastName !== config.LastName || firstName !== config.FirstName) { - await client.update(person, { + await client.update(this.aiPerson, { name: combineName(config.FirstName, config.LastName) }) } - if (person.avatar === config.AvatarName) { + if (this.aiPerson.avatar === config.AvatarName) { return } const exist = await this.blobClient.checkFile(this.ctx, config.AvatarName) if (!exist) { - this.ctx.error('Cannot find file', { file: config.AvatarName, workspace: this.workspace.name }) + this.ctx.error('Cannot find file', { file: config.AvatarName, workspace: this.workspace }) return } - await client.diffUpdate(person, { avatar: config.AvatarName as Ref, avatarType: AvatarType.IMAGE }) - } - - private async initClient (): Promise { - await this.tryLogin() - - this.client = await connectPlatform(this.token, this.transactorUrl) - const opClient = new TxOperations(this.client, aiBot.account.AIBot) - - await this.uploadAvatarFile(opClient) - const events = await opClient.findAll(aiBot.class.AIBotTransferEvent, {}) - void this.processEvents(events) - - this.client.notify = (...txes: Tx[]) => { - void this.txHandler(opClient, txes) - } - this.ctx.info('Initialized workspace', this.workspace) - - return opClient + await client.diffUpdate(this.aiPerson, { avatar: config.AvatarName as Ref, avatarType: AvatarType.IMAGE }) } async getThreadParent ( @@ -216,8 +229,10 @@ export class WorkspaceClient { space: Ref, message: string ): Promise { + const op = client.apply(generateId(), 'AIBotTransferEvent') if (event.messageClass === chunter.class.ChatMessage) { - const ref = await client.addCollection( + await this.startTyping(client, space, _id, _class) + const ref = await op.addCollection( chunter.class.ChatMessage, space, _id, @@ -225,15 +240,17 @@ export class WorkspaceClient { event.collection, { message } ) - await client.createMixin(ref, chunter.class.ChatMessage, space, aiBot.mixin.TransferredMessage, { + 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, _id, _class) - if (parent !== undefined) { - const ref = await client.addCollection( + await this.startTyping(client, space, parent._id, parent._class) + const ref = await op.addCollection( chunter.class.ThreadMessage, parent.space, parent._id, @@ -241,7 +258,7 @@ export class WorkspaceClient { event.collection, { message, objectId: parent.attachedTo, objectClass: parent.attachedToClass } ) - await client.createMixin( + await op.createMixin( ref, chunter.class.ThreadMessage as Ref>, space, @@ -251,40 +268,231 @@ export class WorkspaceClient { parentMessageId: event.parentMessageId } ) + await this.finishTyping(client, parent._id) } } + + await op.commit() + } + + clearTypingTimeout (objectId: Ref): void { + const currentTimeout = this.typingTimeoutsMap.get(objectId) + + if (currentTimeout !== undefined) { + clearTimeout(currentTimeout) + this.typingTimeoutsMap.delete(objectId) + } + } + + async startTyping ( + client: TxOperations, + space: Ref, + objectId: Ref, + objectClass: Ref> + ): Promise { + if (this.aiPerson === undefined) { + return + } + + this.clearTypingTimeout(objectId) + const typingInfo = this.typingMap.get(objectId) + + if (typingInfo === undefined) { + const data: Data = { + 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): Promise { + 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[] { + const result: OpenAI.ChatCompletionMessageParam[] = [] + let totalTokens = promptTokens + + for (let i = history.length - 1; i >= 0; i--) { + const record = history[i] + const tokens = record.tokens + + if (totalTokens + tokens > config.MaxContentTokens) break + + result.unshift({ content: record.message, role: record.role as 'user' | 'assistant' }) + totalTokens += tokens + } + + return result + } + + async getHistory (objectId: Ref): Promise[]> { + if (this.historyMap.has(objectId)) { + return this.historyMap.get(objectId) ?? [] + } + + const historyRecords = await this.controller.storage.getHistoryRecords(this.workspace, objectId) + this.historyMap.set(objectId, historyRecords) + return historyRecords + } + + async summarizeHistory ( + toSummarize: WithId[], + user: Ref, + objectId: Ref, + objectClass: Ref> + ): Promise { + if (this.summarizing.has(objectId)) { + return + } + + this.summarizing.add(objectId) + const { summary, tokens } = await requestSummary(this.controller.aiClient, this.controller.encoding, toSummarize) + + if (summary === undefined) { + this.ctx.error('Failed to summarize history', { objectId, objectClass, user }) + this.summarizing.delete(objectId) + return + } + + const summaryRecord: HistoryRecord = { + message: summary, + role: 'assistant', + timestamp: toSummarize[0].timestamp, + user, + objectId, + objectClass, + tokens, + 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) + this.historyMap.set(objectId, newHistory) + this.summarizing.delete(objectId) + } + + async pushHistory ( + message: string, + role: 'user' | 'assistant', + tokens: number, + user: Ref, + objectId: Ref, + objectClass: Ref> + ): Promise { + const currentHistory = (await this.getHistory(objectId)) ?? [] + const newRecord: HistoryRecord = { + workspace: this.workspace, + message, + objectId, + objectClass, + role, + user, + tokens, + timestamp: Date.now() + } + const _id = await this.controller.storage.addHistoryRecord(newRecord) + currentHistory.push({ ...newRecord, _id }) + this.historyMap.set(objectId, currentHistory) } async processResponseEvent (event: AIBotResponseEvent): Promise { const client = await this.opClient + const hierarchy = client.getHierarchy() - if (event.messageClass === chunter.class.ChatMessage) { - await client.addCollection( + const op = client.apply(generateId(), 'AIBotResponseEvent') + const { user, objectId, objectClass, messageClass } = event + const space = hierarchy.isDerived(objectClass, core.class.Space) ? (objectId as Ref) : event.objectSpace + + await this.startTyping(client, space, objectId, objectClass) + + const promptText = markupToText(event.message) + const prompt: OpenAI.ChatCompletionMessageParam = { content: promptText, role: 'user' } + + const promptTokens = countTokens([prompt], this.controller.encoding) + const rawHistory = await this.getHistory(objectId) + const history = this.toOpenAiHistory(rawHistory, promptTokens) + + if (history.length < rawHistory.length || history.length > config.MaxHistoryRecords) { + void this.summarizeHistory(rawHistory, user, objectId, objectClass) + } + + void this.pushHistory(promptText, prompt.role, promptTokens, user, objectId, objectClass) + + const start = Date.now() + const chatCompletion = await createChatCompletion(this.controller.aiClient, prompt, user, history) + const end = Date.now() + this.ctx.info('Chat completion time: ', { time: end - start }) + const response = chatCompletion?.choices[0].message.content + + if (response == null) { + await this.finishTyping(client, objectId) + return + } + const responseTokens = + chatCompletion?.usage?.completion_tokens ?? + countTokens([{ content: response, role: 'assistant' }], this.controller.encoding) + + void this.pushHistory(response, 'assistant', responseTokens, user, objectId, objectClass) + + const parser = new MarkdownParser([], '', '') + const parseResponse = jsonToMarkup(parser.parse(response)) + + if (messageClass === chunter.class.ChatMessage) { + await op.addCollection( chunter.class.ChatMessage, - event.objectSpace, - event.objectId, - event.objectClass, + space, + objectId, + objectClass, event.collection, - { message: 'You said: ' + event.message } + { message: parseResponse } ) - } else if (event.messageClass === chunter.class.ThreadMessage) { + } else if (messageClass === chunter.class.ThreadMessage) { const parent = await client.findOne(chunter.class.ChatMessage, { - _id: event.objectId as Ref + _id: objectId as Ref }) if (parent !== undefined) { - await client.addCollection( + await op.addCollection( chunter.class.ThreadMessage, - event.objectSpace, - event.objectId, - event.objectClass, + space, + objectId, + objectClass, event.collection, - { message: 'You said: ' + event.message, objectId: parent.attachedTo, objectClass: parent.attachedToClass } + { message: parseResponse, objectId: parent.attachedTo, objectClass: parent.attachedToClass } ) } } - await client.remove(event) + await op.remove(event) + await this.finishTyping(op, event.objectId) + await op.commit() } async processTransferEvent (event: AIBotTransferEvent): Promise { @@ -294,53 +502,6 @@ export class WorkspaceClient { await client.remove(event) } - async getAccount (email: string): Promise { - const client = await this.opClient - - return await client.findOne(contact.class.PersonAccount, { email }) - } - - async getDirect (email: string): Promise | undefined> { - const client = await this.opClient - - const personAccount = await this.getAccount(email) - - if (personAccount === undefined) { - return - } - - const allAccounts = await client.findAll(contact.class.PersonAccount, { person: personAccount.person }) - const accIds: Ref[] = [aiBot.account.AIBot, ...allAccounts.map(({ _id }) => _id)].sort() - const existingDms = await client.findAll(chunter.class.DirectMessage, {}) - - for (const dm of existingDms) { - if (deepEqual(dm.members.sort(), accIds)) { - return dm._id - } - } - - const dmId = await client.createDoc(chunter.class.DirectMessage, core.space.Space, { - name: '', - description: '', - private: true, - archived: false, - members: accIds - }) - - if (this.aiAccount === undefined) return dmId - const space = await client.findOne(contact.class.PersonSpace, { person: this.aiAccount.person }) - if (space === undefined) return dmId - await client.createDoc(notification.class.DocNotifyContext, space._id, { - user: aiBot.account.AIBot, - objectId: dmId, - objectClass: chunter.class.DirectMessage, - objectSpace: core.space.Space, - isPinned: false - }) - - return dmId - } - async transferToSupport (event: AIBotTransferEvent, channelRef?: Ref): Promise { const client = await this.opClient const key = `${event.toEmail}-${event.fromWorkspace}` @@ -365,14 +526,14 @@ export class WorkspaceClient { } async transferToUserDirect (event: AIBotTransferEvent): Promise { - const direct = this.directByEmail.get(event.toEmail) ?? (await this.getDirect(event.toEmail)) + const client = await this.opClient + const direct = this.directByEmail.get(event.toEmail) ?? (await getDirect(client, event.toEmail, this.aiAccount)) if (direct === undefined) { return } this.directByEmail.set(event.toEmail, direct) - const client = await this.opClient await this.createTransferMessage(client, event, direct, chunter.class.DirectMessage, direct, event.message) } @@ -440,19 +601,34 @@ export class WorkspaceClient { await this.opClient.close() } - this.ctx.info('Closed workspace client: ', this.workspace) + this.ctx.info('Closed workspace client: ', { workspace: this.workspace }) } - private async txHandler (client: TxOperations, txes: Tx[]): Promise { - const hierarchy = client.getHierarchy() + private async handleCreateTx (tx: TxCreateDoc): Promise { + if (tx.objectClass === aiBot.class.AIBotResponseEvent) { + const doc = TxProcessor.createDoc2Doc(tx as TxCreateDoc) + await this.processResponseEvent(doc) + } else if (tx.objectClass === aiBot.class.AIBotTransferEvent) { + const doc = TxProcessor.createDoc2Doc(tx as TxCreateDoc) + await this.processTransferEvent(doc) + } + } - const resultTxes = txes - .map((a) => TxProcessor.extractTx(a) as TxCreateDoc) - .filter( - (tx) => tx._class === core.class.TxCreateDoc && hierarchy.isDerived(tx.objectClass, aiBot.class.AIBotEvent) - ) - .map((tx) => TxProcessor.createDoc2Doc(tx)) + private async handleRemoveTx (tx: TxRemoveDoc): Promise { + if (tx.objectClass === chunter.class.TypingInfo && this.typingMap.has(tx.objectId)) { + this.typingMap.delete(tx.objectId) + } + } - await this.processEvents(resultTxes) + private async txHandler (_: TxOperations, txes: Tx[]): Promise { + for (const ttx of txes) { + const tx = TxProcessor.extractTx(ttx) + + if (tx._class === core.class.TxCreateDoc) { + await this.handleCreateTx(tx as TxCreateDoc) + } else if (tx._class === core.class.TxRemoveDoc) { + await this.handleRemoveTx(tx as TxRemoveDoc) + } + } } }