Merge remote-tracking branch 'origin/develop' into staging

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev
2024-10-30 23:20:05 +07:00
88 changed files with 1141 additions and 1062 deletions
+2 -1
View File
@@ -54,7 +54,8 @@
"MODEL_VERSION": "0.6.287",
// "VERSION": "0.6.289",
"ELASTIC_INDEX_NAME": "local_storage_index",
"UPLOAD_URL": "/files",
"UPLOAD_URL": "/files",
"AI_BOT_URL": "http://localhost:4010"
},
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
"runtimeVersion": "20",
+2
View File
@@ -257,6 +257,7 @@ services:
- ELASTIC_INDEX_NAME=local_storage_index
- BRANDING_PATH=/var/cfg/branding.json
- SUPPORT_WORKSPACE=support
- AI_BOT_URL=http://host.docker.internal:4010
restart: unless-stopped
transactor_pg:
image: hardcoreeng/transactor
@@ -384,6 +385,7 @@ services:
- AVATAR_PATH=./avatar.png
- AVATAR_CONTENT_TYPE=.png
- STATS_URL=http://host.docker.internal:4900
# - OPENAI_API_KEY=token
deploy:
resources:
limits:
+3 -78
View File
@@ -1,80 +1,5 @@
## How to Import Documents from Notion
### Supported Import Options
To import Notion documents:
1. **Notion**: see [Import from Notion Guide](./notion/README.md)
2. **ClickUp**: see [Import from ClickUp Guide](./clickup/README.md)
1. Export documents from Notion as *Markdown & CSV*
2. Extract the exported archive
3. 1. If you want your docs to be imported along with Notion teamspaces, use this command:
```
rushx run-local import-notion-with-teamspaces ${dir} \
--user ${user} \
--password ${password} \
--workspace ${workspace}
```
3. 2. If you want the docs to be imported to a new teamspace, use this command:
```
rushx run-local import-notion-to-teamspace {dir} \
--user ${user} \
--password ${password} \
--workspace ${workspace} \
--teamspace ${teamspace}
```
* *dir* - path to the root of the extracted archive
* *user* - your username or email
* *password* - password
* *workspace* - workspace url where the documents should be imported to
* *teamspace* - teamspace to be created for newly imported docs
### Examples
#### For local run:
When importing Notion workspace with teamspaces
```
rushx run-local import-notion-with-teamspaces /home/john/extracted-notion-docs \
--user john.doe@gmail.com \
--password qwe1234 \
--workspace ws1
```
When importing Notion workspace without teamspaces or a page with subpages
```
rushx run-local import-notion-to-teamspace /home/john/extracted-notion-docs \
--user john.doe@gmail.com \
--password qwe1234 \
--workspace ws1 \
--teamspace imported
```
#### For cloud deployment:
##### Here is an example for those who's using huly.app cloud:
1. Go to the root folder of the extracted archive with exported data.
2. Run import command as follow:
* To import Notion workspace with teamspaces
```
docker run \
-e FRONT_URL="https://huly.app" \
-v $(pwd):/data \
hardcoreeng/import-tool:latest \
-- bundle.js import-notion-with-teamspaces /data \
--user jane.doe@gmail.com \
--password 4321qwe \
--workspace ws1
```
* To import Notion workspace without teamspaces or a page with subpages.
```
docker run \
-e FRONT_URL="https://huly.app" \
-v $(pwd):/data \
hardcoreeng/import-tool:latest \
-- bundle.js import-notion-to-teamspace /data \
--user jane.doe@gmail.com \
--password 4321qwe \
--workspace ws1 \
--teamspace notion
```
+32
View File
@@ -0,0 +1,32 @@
## Import from ClickUp Guide
### Export Data from ClickUp
1. Follow [ClickUp's official guide](https://help.clickup.com/hc/en-us/articles/6310551109527-Task-data-export) to export your tasks as CSV
2. Save the CSV file to your local machine
### Run Import Tool
1. Place your ClickUp CSV file in a directory (e.g., /path/to/export)
2. Run the import tool using Docker:
```
docker run \
-e FRONT_URL="https://huly.app" \
-v /path/to/export:/data \
hardcoreeng/import-tool:latest \
-- bundle.js import-clickup-tasks /data/tasks.csv \
--user your.email@company.com \
--password yourpassword \
--workspace workspace-id
```
#### User Mapping
* Users must be created in the platform before import
* ClickUp assignees are mapped to platform users by full name (e.g., "Jane Doe")
* If user is not found:
* Task will be imported without assignee
* Original assignee name will be added as a comment: *ClickUp assignee: John Smith*
#### Limitations
* Checklist items are imported as unchecked since ClickUp export doesn't include checkbox states
* Failed attachment downloads are skipped with warning messages (Original attachment URL is added as a comment)
@@ -16,7 +16,7 @@ import contact, { type Person, type PersonAccount } from '@hcengineering/contact
import { type Ref, type Timestamp, type TxOperations } from '@hcengineering/core'
import { MarkupNodeType, traverseNode, type MarkupNode } from '@hcengineering/text'
import csv from 'csvtojson'
import { download } from './importer/dowloader'
import { download } from '../importer/dowloader'
import {
WorkspaceImporter,
type ImportComment,
@@ -24,8 +24,8 @@ import {
type ImportProject,
type ImportProjectType,
type MarkdownPreprocessor
} from './importer/importer'
import { type FileUploader } from './importer/uploader'
} from '../importer/importer'
import { type FileUploader } from '../importer/uploader'
interface ClickupTask {
'Task ID': string
+2 -2
View File
@@ -20,10 +20,10 @@ import serverClientPlugin, {
selectWorkspace
} from '@hcengineering/server-client'
import { program } from 'commander'
import { importNotion } from './notion'
import { importNotion } from './notion/notion'
import { setMetadata } from '@hcengineering/platform'
import { FrontFileUploader, type FileUploader } from './importer/uploader'
import { ClickupImporter } from './clickup'
import { ClickupImporter } from './clickup/clickup'
/**
* @public
+44
View File
@@ -0,0 +1,44 @@
## Import from Notion Guide
### Export Data from Notion
1. Follow [Notion's official guide](https://www.notion.so/help/export-your-content) to export your content
2. Important: Select **Markdown & CSV** export format
3. Extract the exported archive to a local directory
### Run Import Tool
1. Place your extracted Notion export in a directory (e.g., `/path/to/export`)
2. Choose one of two import options:
#### Option 1: Import with Original Teamspace Structure
```
docker run \
-e FRONT_URL="https://huly.app" \
-v /path/to/export:/data \
hardcoreeng/import-tool:latest \
-- bundle.js import-notion-with-teamspaces /data \
--user your.email@company.com \
--password yourpassword \
--workspace workspace-id
```
#### Option 2: Import to Single Teamspace
```
docker run \
-e FRONT_URL="https://huly.app" \
-v /path/to/export:/data \
hardcoreeng/import-tool:latest \
-- bundle.js import-notion-to-teamspace /data \
--user your.email@company.com \
--password yourpassword \
--workspace workspace-id \
--teamspace teamspace-name-to-be-created
```
#### Limitations
* Notion databases are not imported (CSV format not supported)
* Comments are not included in Notion exports, so they will not be imported
* Images and files must be included in the export to be imported
#### Notes
* All documents preserve their hierarchy
* Internal links between documents are maintained
* Images and attachments are imported automatically if present in export
@@ -42,7 +42,7 @@ import { type Dirent } from 'fs'
import { readdir, readFile, stat } from 'fs/promises'
import { contentType } from 'mime-types'
import { basename, join, parse } from 'path'
import { type FileUploader } from './importer/uploader'
import { type FileUploader } from '../importer/uploader'
interface DocumentMetadata {
id: string
+4 -53
View File
@@ -13,18 +13,11 @@
// limitations under the License.
//
import { type Builder, Mixin, Model, Prop, TypeRef, TypeString } from '@hcengineering/model'
import core, { type Account, type Class, type Doc, type Domain, type Ref, type Space } from '@hcengineering/core'
import { type Builder, Mixin } from '@hcengineering/model'
import core, { type Domain, type Ref } from '@hcengineering/core'
import serverCore from '@hcengineering/server-core'
import serverAiBot from '@hcengineering/server-ai-bot'
import { TDoc } from '@hcengineering/model-core'
import { getEmbeddedLabel } from '@hcengineering/platform'
import aiBot, {
type AIBotEvent,
type AIBotTransferEvent,
type AIBotResponseEvent,
type TransferredMessage
} from '@hcengineering/ai-bot'
import aiBot, { type TransferredMessage } from '@hcengineering/ai-bot'
import chunter, { type ChatMessage } from '@hcengineering/chunter'
import notification from '@hcengineering/notification'
import { TChatMessage } from '@hcengineering/model-chunter'
@@ -33,48 +26,6 @@ export { serverAiBotId } from '@hcengineering/server-ai-bot'
export const DOMAIN_AI_BOT = 'ai_bot' as Domain
@Model(aiBot.class.AIBotEvent, core.class.Doc, DOMAIN_AI_BOT)
export class TAIBotEvent extends TDoc implements AIBotEvent {
@Prop(TypeRef(chunter.class.ChatMessage), core.string.Class)
messageClass!: Ref<Class<ChatMessage>>
@Prop(TypeRef(chunter.class.ChatMessage), core.string.Ref)
messageId!: Ref<ChatMessage>
@Prop(TypeString(), getEmbeddedLabel('Collection'))
collection!: string
@Prop(TypeString(), getEmbeddedLabel('Message'))
message!: string
}
@Model(aiBot.class.AIBotResponseEvent, aiBot.class.AIBotEvent)
export class TAIBotResponseEvent extends TAIBotEvent implements AIBotResponseEvent {
@Prop(TypeRef(core.class.Doc), core.string.Object)
objectId!: Ref<Doc>
@Prop(TypeRef(core.class.Class), core.string.Class)
objectClass!: Ref<Class<Doc>>
@Prop(TypeRef(core.class.Space), core.string.Space)
objectSpace!: Ref<Space>
@Prop(TypeRef(core.class.Account), core.string.Account)
user!: Ref<Account>
email!: string
}
@Model(aiBot.class.AIBotTransferEvent, aiBot.class.AIBotEvent)
export class TAIBotTransferEvent extends TAIBotEvent implements AIBotTransferEvent {
toEmail!: string
toWorkspace!: string
fromWorkspace!: string
fromWorkspaceName!: string
fromWorkspaceUrl!: string
parentMessageId?: Ref<ChatMessage>
}
@Mixin(aiBot.mixin.TransferredMessage, chunter.class.ChatMessage)
export class TTransferredMessage extends TChatMessage implements TransferredMessage {
messageId!: Ref<ChatMessage>
@@ -82,7 +33,7 @@ export class TTransferredMessage extends TChatMessage implements TransferredMess
}
export function createModel (builder: Builder): void {
builder.createModel(TAIBotEvent, TAIBotTransferEvent, TAIBotResponseEvent, TTransferredMessage)
builder.createModel(TTransferredMessage)
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverAiBot.trigger.OnMessageSend,
+1
View File
@@ -156,6 +156,7 @@ export function createModel (builder: Builder): void {
{
label: telegram.string.Telegram,
description: telegram.string.TelegramIntegrationDesc,
descriptionComponent: telegram.component.TelegramIntegrationDescription,
icon: telegram.component.IconTelegram,
allowMultiple: false,
createComponent: telegram.component.Connect,
+2 -2
View File
@@ -31,7 +31,6 @@ export default mergeIds(telegramId, telegram, {
Incoming: '' as IntlString,
Messages: '' as IntlString,
Telegram: '' as IntlString,
TelegramIntegrationDesc: '' as IntlString,
Status: '' as IntlString,
ConfigLabel: '' as IntlString,
ConfigDescription: '' as IntlString,
@@ -53,6 +52,7 @@ export default mergeIds(telegramId, telegram, {
TelegramMessageCreated: '' as AnyComponent
},
component: {
NotificationProviderPresenter: '' as AnyComponent
NotificationProviderPresenter: '' as AnyComponent,
TelegramIntegrationDescription: '' as AnyComponent
}
})
@@ -57,6 +57,7 @@ export class MeasureMetricsContext implements MeasureContext {
private readonly fullParams: FullParamsType | (() => FullParamsType) = {}
logger: MeasureLogger
metrics: Metrics
id?: string
st = Date.now()
contextData: object = {}
@@ -125,6 +126,8 @@ export class MeasureMetricsContext implements MeasureContext {
this,
this.logParams
)
result.id = this.id
result.onEnd = this.onEnd.bind(this)
result.contextData = this.contextData
return result
}
@@ -197,6 +200,8 @@ export class MeasureMetricsContext implements MeasureContext {
end (): void {
this.done()
}
async onEnd (ctx: MeasureContext): Promise<void> {}
}
/**
+2
View File
@@ -111,4 +111,6 @@ export interface MeasureContext<Q = any> {
// Mark current context as complete
// If no value is passed, time difference will be used.
end: (value?: number) => void
onEnd?: (ctx: MeasureContext) => Promise<void>
}
+3
View File
@@ -501,6 +501,7 @@ input.search {
.ml-8 { margin-left: 2rem; }
.ml-10 { margin-left: 2.5rem; }
.ml-12 { margin-left: 3rem; }
.ml-14 { margin-left: 3.5rem; }
.ml-22 { margin-left: 5.5rem; }
.ml-auto { margin-left: auto; }
.mr-0-5 { margin-right: .125rem; }
@@ -744,6 +745,7 @@ input.search {
.min-h-16 { min-height: 4rem; }
.min-h-30 { min-height: 7.5rem; }
.min-h-60 { min-height: 15rem; }
.max-w-0 { max-width: 0; }
.max-w-2 { max-width: .5rem; }
.max-w-4 { max-width: 1rem; }
.max-w-9 { max-width: 2.25rem; }
@@ -752,6 +754,7 @@ input.search {
.max-w-40 { max-width: 10rem; }
.max-w-60 { max-width: 15rem; }
.max-w-80 { max-width: 20rem; }
.max-w-100 { max-width: 25rem; }
.max-w-120 { max-width: 30rem; }
.max-w-240 { max-width: 60rem; }
.max-h-0 { max-height: 0; }
+25 -8
View File
@@ -141,23 +141,40 @@
}
.antiPanel-navigator {
position: relative;
min-width: 12.5rem;
max-width: 22.5rem;
width: 17.5rem;
background-color: var(--theme-navpanel-color);
&:not(.right) {
min-width: 12.5rem;
max-width: 22.5rem;
width: 17.5rem;
}
&.right {
overflow: hidden;
border-radius: var(--medium-BorderRadius) 0 0 var(--medium-BorderRadius);
}
}
@media (max-width: 1024px) {
.antiPanel-navigator {
position: fixed;
top: calc(var(--status-bar-height) + 1px);
height: calc(100% - var(--status-bar-height) - 1px);
background-color: var(--theme-navpanel-color);
filter: drop-shadow(2px 0 1px rgba(0, 0, 0, .2));
z-index: 450;
&:not(.right) {
top: calc(var(--status-bar-height) + 1px);
height: calc(100% - var(--status-bar-height) - 1px);
filter: drop-shadow(2px 0 5px rgba(0, 0, 0, .2));
&.portrait { left: 0; }
&.landscape { left: var(--app-panel-width); }
&.portrait { left: 0; }
&.landscape { left: var(--app-panel-width); }
}
&.right {
top: var(--status-bar-height);
right: 0;
height: calc(100% - var(--status-bar-height) - 1px);
background-color: var(--theme-statusbar-color);
filter: drop-shadow(-2px 0 5px rgba(0, 0, 0, .2));
}
}
}
.antiPanel-component {
+5
View File
@@ -78,6 +78,7 @@
scrollbar-width: none;
--body-font-size: .875rem;
--status-bar-height: 36px;
--status-bar-normal-height: 36px;
--panel-aside-width: 25rem; // 20rem;
--font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto;
--mono-font: 'IBM Plex Mono', monospace;
@@ -89,6 +90,10 @@
&::after,
&::before { box-sizing: border-box; }
@media (max-width: 480px) {
--status-bar-height: 70px;
}
}
:root {
--app-height: 100%;
+5 -2
View File
@@ -36,7 +36,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="container main flex-row-center flex-gap-2 {orientation} {kind}"
class="container main flex-between flex-gap-2 {orientation} {kind}"
style:max-width={orientation === 'horizontal' ? maxSize : 'auto'}
style:max-height={orientation === 'vertical' ? maxSize : 'auto'}
class:active={highlighted}
@@ -52,7 +52,7 @@
</div>
{/if}
<span class="overflow-label">
<span class="overflow-label flex-grow">
{#if label}
{label}
{:else if labelIntl}
@@ -100,11 +100,14 @@
&.horizontal {
padding: 0.125rem 0.125rem 0.125rem 0.5rem;
height: 1.625rem;
min-height: 1.625rem;
min-width: 6rem;
}
&.vertical {
padding: 0.5rem 0.125rem 0.125rem 0.125rem;
width: 1.625rem;
min-height: 6rem;
writing-mode: vertical-rl;
text-orientation: sideways;
}
+10 -10
View File
@@ -168,7 +168,7 @@
<Header
type={'type-panel'}
noPrint={!printHeader}
{adaptive}
adaptive={$deviceInfo.isMobile ? 'disabled' : adaptive}
{hideBefore}
{hideSearch}
{hideActions}
@@ -261,21 +261,21 @@
</Header>
<div class="popupPanel-body {$deviceInfo.isMobile ? 'mobile' : 'main'}" class:asideShown>
{#if $deviceInfo.isMobile}
<Scroller horizontal padding={'.5rem .75rem'}>
<div
class="popupPanel-body__mobile"
use:resizeObserver={(element) => {
innerWidth = element.clientWidth
}}
>
<div
class="popupPanel-body__mobile"
use:resizeObserver={(element) => {
innerWidth = element.clientWidth
}}
>
<Scroller horizontal padding={'.5rem .75rem'}>
{#if $$slots.header && isHeader}
<div class="popupPanel-body__header mobile bottom-divider" class:max={useMaxWidth}>
<slot name="header" />
</div>
{/if}
<slot />
</div>
</Scroller>
</Scroller>
</div>
{:else}
<div
class="popupPanel-body__main"
+24 -40
View File
@@ -14,10 +14,12 @@
// limitations under the License.
-->
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import { onMount } from 'svelte'
import { resizeObserver } from '..'
export let scroller: HTMLElement
export let gap: 'none' | 'small' | 'big' = 'small'
export let padding: string | undefined = undefined
let divBar: HTMLElement
let isScrolling: boolean = false
@@ -31,7 +33,7 @@
$: stepStyle = gap === 'small' ? 'gap-1' : gap === 'big' ? 'gap-2' : ''
const checkBar = (): void => {
if (divBar && scroller) {
if (divBar !== undefined && scroller !== undefined) {
const trackW = scroller.clientWidth
const scrollW = scroller.scrollWidth
const proc = scrollW / trackW
@@ -40,13 +42,13 @@
if (mask === 'none') divBar.style.visibility = 'hidden'
else {
divBar.style.visibility = 'visible'
if (divBar) {
if (timer) {
if (divBar !== undefined) {
if (timer != null) {
clearTimeout(timer)
divBar.style.opacity = '1'
}
timer = setTimeout(() => {
if (divBar) divBar.style.opacity = '0'
if (divBar != null) divBar.style.opacity = '0'
}, 2000)
}
}
@@ -54,8 +56,8 @@
}
}
const onScroll = (event: MouseEvent): void => {
if (isScrolling && divBar && scroller) {
const onScroll = (event: PointerEvent): void => {
if (isScrolling && divBar !== undefined && scroller !== undefined) {
const rectScroll = scroller.getBoundingClientRect()
let X = event.clientX - dX
if (X < rectScroll.left) X = rectScroll.left
@@ -67,22 +69,22 @@
scroller.scrollLeft = (scroller.scrollWidth - scroller.clientWidth) * procBar
}
}
const onScrollEnd = (event: MouseEvent): void => {
const onScrollEnd = (event: PointerEvent): void => {
const el: HTMLElement = event.currentTarget as HTMLElement
if (el && isScrolling) {
document.removeEventListener('mousemove', onScroll)
if (el !== undefined && isScrolling) {
document.removeEventListener('pointermove', onScroll)
document.body.style.userSelect = 'auto'
document.body.style.webkitUserSelect = 'auto'
}
document.removeEventListener('mouseup', onScrollEnd)
document.removeEventListener('pointerup', onScrollEnd)
isScrolling = false
}
const onScrollStart = (event: MouseEvent): void => {
const onScrollStart = (event: PointerEvent): void => {
const el: HTMLElement = event.currentTarget as HTMLElement
if (el && scroller) {
if (el !== undefined && scroller !== undefined) {
dX = event.clientX - el.getBoundingClientRect().x
document.addEventListener('mouseup', onScrollEnd)
document.addEventListener('mousemove', onScroll)
document.addEventListener('pointerup', onScrollEnd)
document.addEventListener('pointermove', onScroll)
document.body.style.userSelect = 'none'
document.body.style.webkitUserSelect = 'none'
isScrolling = true
@@ -90,8 +92,8 @@
}
const checkMask = (): void => {
maskLeft = !!(scroller && scroller.scrollLeft > 1)
maskRight = !!(scroller && scroller.scrollWidth - scroller.scrollLeft - scroller.clientWidth > 1)
maskLeft = scroller !== undefined && scroller.scrollLeft > 1
maskRight = scroller !== undefined && scroller.scrollWidth - scroller.scrollLeft - scroller.clientWidth > 1
if (maskLeft || maskRight) {
if (maskLeft && maskRight) mask = 'both'
else if (maskLeft) mask = 'left'
@@ -102,30 +104,12 @@
}
onMount(() => {
if (scroller) {
const observer = new IntersectionObserver(
() => {
checkMask()
},
{ root: null, threshold: 0.1 }
)
const tempEl = scroller.querySelector('*') as HTMLElement
if (tempEl) observer.observe(tempEl)
checkMask()
scroller.addEventListener('scroll', checkMask)
}
if (scroller !== undefined) checkMask()
})
onDestroy(() => {
if (scroller) scroller.removeEventListener('scroll', checkMask)
})
const _resize = (): void => {
checkMask()
}
</script>
<svelte:window on:resize={_resize} />
<div class="scrollerbar-container">
<div bind:this={scroller} class="antiStatesBar mask-{mask} {stepStyle}">
<div class="scrollerbar-container" use:resizeObserver={checkMask}>
<div bind:this={scroller} class="antiStatesBar mask-{mask} {stepStyle}" style:padding on:scroll={checkMask}>
<slot />
</div>
<!-- svelte-ignore a11y-no-static-element-interactions -->
@@ -133,8 +117,8 @@
class="bar"
class:hovered={isScrolling}
bind:this={divBar}
on:mousedown={onScrollStart}
on:mouseleave={checkMask}
on:pointerdown={onScrollStart}
on:pointerleave={checkMask}
/>
<div class="track" class:hovered={isScrolling} />
</div>
@@ -16,7 +16,7 @@
<script lang="ts">
export let size: 'x-small' | 'small' | 'medium' | 'large'
export let filled: boolean = false
const fill: string = 'currentColor'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
@@ -15,7 +15,7 @@
-->
<script lang="ts">
export let size: 'x-small' | 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
@@ -1,6 +1,6 @@
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
@@ -5,7 +5,16 @@
import { deviceSizes, deviceWidths } from '../../types'
// import { applicationShortcutKey } from '../../utils'
import { Theme } from '@hcengineering/theme'
import { IconArrowLeft, IconArrowRight, checkMobile, deviceOptionsStore as deviceInfo } from '../../'
import {
IconArrowLeft,
IconArrowRight,
checkMobile,
deviceOptionsStore as deviceInfo,
checkAdaptiveMatching,
ButtonIcon,
IconDetailsFilled,
IconDetails
} from '../../'
import { desktopPlatform, getCurrentLocation, location, locationStorageKeyId, navigate } from '../../location'
import uiPlugin from '../../plugin'
import Component from '../Component.svelte'
@@ -129,6 +138,14 @@
}
}
updateDeviceSize()
$: secondRow = checkAdaptiveMatching($deviceInfo.size, 'xs')
$: asideFloat = $deviceInfo.navigator.float
$: asideOpen = $deviceInfo.aside.visible
$: appsMini =
$deviceInfo.isMobile &&
(($deviceInfo.isPortrait && $deviceInfo.docWidth <= 480) ||
(!$deviceInfo.isPortrait && $deviceInfo.docHeight <= 480))
</script>
<svelte:window bind:innerWidth={docWidth} bind:innerHeight={docHeight} />
@@ -161,9 +178,15 @@
</button>
</div>
{/if}
<div class="flex-row-center left-items flex-gap-0-5" style:-webkit-app-region={'no-drag'}>
<RootBarExtension position="left" />
</div>
{#if !secondRow}
<div
class="flex-row-center left-items flex-gap-0-5"
class:ml-14={appsMini}
style:-webkit-app-region={'no-drag'}
>
<RootBarExtension position="left" />
</div>
{/if}
<div
class="flex-row-center justify-center status-info"
style:margin-left={(isPortrait && docWidth <= 480) || (!isPortrait && docHeight <= 480) ? '1.5rem' : '0'}
@@ -177,17 +200,54 @@
{/if}
</div>
<div class="flex-row-reverse" style:-webkit-app-region={'no-drag'}>
{#if asideFloat && !secondRow}
<div class="antiHSpacer x2" />
<ButtonIcon
icon={asideOpen ? IconDetailsFilled : IconDetails}
iconProps={{ fill: 'var(--theme-dark-color)' }}
kind={'tertiary'}
size={'extra-small'}
hasMenu
pressed={$deviceInfo.aside.visible}
on:click={() => {
$deviceInfo.aside.visible = !$deviceInfo.aside.visible
}}
/>
{/if}
<div class="clock">
<Clock />
</div>
<div class="flex-row-center gap-statusbar">
<RootBarExtension position="right" />
{#if !secondRow}
<RootBarExtension position="right" />
{/if}
<FontSizeSelector />
<ThemeSelector />
<LangSelector />
</div>
</div>
</div>
{#if secondRow}
<div class="flex-between h-full content-color gap-3 px-2 second-row" style:-webkit-app-region={'no-drag'}>
<div class="flex-row-center flex-gap-0-5">
<RootBarExtension position="left" />
</div>
<div class="flex-row-center flex-gap-0-5">
<RootBarExtension position="right" />
<ButtonIcon
icon={asideOpen ? IconDetailsFilled : IconDetails}
iconProps={{ fill: 'var(--theme-dark-color)' }}
kind={'tertiary'}
size={'extra-small'}
hasMenu
pressed={$deviceInfo.aside.visible}
on:click={() => {
$deviceInfo.aside.visible = !$deviceInfo.aside.visible
}}
/>
</div>
</div>
{/if}
</div>
<div class="app">
{#if application}
@@ -213,6 +273,7 @@
.antiStatusBar {
-webkit-app-region: drag;
min-width: 0;
min-height: var(--status-bar-height);
height: var(--status-bar-height);
// min-width: 600px;
@@ -248,6 +309,21 @@
margin: 0 12px 0 8px;
}
.second-row {
display: none;
}
@media (max-width: 480px) {
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px 0;
width: 100%;
.second-row {
display: flex;
}
}
@media print {
display: none;
}
@@ -17,7 +17,7 @@
{#each sorted as ext (ext[1].id)}
{#if ext[0] === position}
<div id={ext[1].id} style:margin-right={'1px'}>
<div id={ext[1].id} class="clear-mins" style:margin-right={'1px'}>
<Component
is={ext[1].component}
props={ext[1].props}
+1
View File
@@ -311,6 +311,7 @@ export const deviceOptionsStore = writable<DeviceOptions>({
isPortrait: false,
isMobile: false,
navigator: { visible: true, float: false, direction: 'vertical' },
aside: { visible: true },
fontSize: 0,
size: null,
sizes: { xs: false, sm: false, md: false, lg: false, xl: false, xxl: false },
+1 -1
View File
@@ -166,7 +166,7 @@ export const settingsSeparators: DefSeparators = [
export const mainSeparators: DefSeparators = [
{ minSize: 30, size: 'auto', maxSize: 'auto' },
{ minSize: 20, size: 30, maxSize: 80, float: 'sidebar' }
{ minSize: 25, size: 30, maxSize: 80, float: 'sidebar' }
]
export const secondNavSeparators: DefSeparators = [{ minSize: 7, size: 7.5, maxSize: 15, float: 'navigator' }, null]
+1
View File
@@ -378,6 +378,7 @@ export interface DeviceOptions {
isPortrait: boolean
isMobile: boolean
navigator: { visible: boolean, float: boolean, direction: 'vertical' | 'horizontal' }
aside: { visible: boolean }
fontSize: number
size: WidthType | null
sizes: Record<WidthType, boolean>
+2 -30
View File
@@ -13,41 +13,18 @@
// limitations under the License.
//
import { Account, Class, Doc, type Mixin, Ref, Space } from '@hcengineering/core'
import { Account, type Mixin, Ref } from '@hcengineering/core'
import type { Metadata, Plugin } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import { ChatMessage } from '@hcengineering/chunter'
export * from './types'
export * from './rest'
export const aiBotId = 'ai-bot' as Plugin
export const aiBotAccountEmail = 'huly.ai.bot@hc.engineering'
export interface AIBotEvent extends Doc {
collection: string
messageClass: Ref<Class<ChatMessage>>
messageId: Ref<ChatMessage>
message: string
}
export interface AIBotResponseEvent extends AIBotEvent {
objectId: Ref<Doc>
objectClass: Ref<Class<Doc>>
objectSpace: Ref<Space>
user: Ref<Account>
email: string
}
export interface AIBotTransferEvent extends AIBotEvent {
toEmail: string
toWorkspace: string
fromWorkspace: string
fromWorkspaceName: string
fromWorkspaceUrl: string
parentMessageId?: Ref<ChatMessage>
}
export interface TransferredMessage extends ChatMessage {
messageId: Ref<ChatMessage>
parentMessageId?: Ref<ChatMessage>
@@ -57,11 +34,6 @@ const aiBot = plugin(aiBotId, {
metadata: {
EndpointURL: '' as Metadata<string>
},
class: {
AIBotEvent: '' as Ref<Class<AIBotEvent>>,
AIBotTransferEvent: '' as Ref<Class<AIBotTransferEvent>>,
AIBotResponseEvent: '' as Ref<Class<AIBotResponseEvent>>
},
mixin: {
TransferredMessage: '' as Ref<Mixin<TransferredMessage>>
},
+58
View File
@@ -0,0 +1,58 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { Account, Class, Doc, Markup, Ref, Space, Timestamp } from '@hcengineering/core'
import { ChatMessage } from '@hcengineering/chunter'
export enum AIEventType {
Message = 'message',
Transfer = 'transfer'
}
export interface AIEventRequest {
type: AIEventType
collection: string
messageClass: Ref<Class<ChatMessage>>
messageId: Ref<ChatMessage>
message: string
createdOn: Timestamp
}
export interface AIMessageEventRequest extends AIEventRequest {
objectId: Ref<Doc>
objectClass: Ref<Class<Doc>>
objectSpace: Ref<Space>
user: Ref<Account>
email: string
}
export interface AITransferEventRequest extends AIEventRequest {
toEmail: string
toWorkspace: string
fromWorkspace: string
fromWorkspaceName: string
fromWorkspaceUrl: string
parentMessageId?: Ref<ChatMessage>
}
export interface TranslateRequest {
text: Markup
lang: string
}
export interface TranslateResponse {
text: Markup
lang: string
}
-12
View File
@@ -13,18 +13,6 @@
// limitations under the License.
//
import { Markup } from '@hcengineering/core'
export interface TranslateRequest {
text: Markup
lang: string
}
export interface TranslateResponse {
text: Markup
lang: string
}
export enum OnboardingEvent {
OpenChatInSidebar = 'openChatInSidebar'
}
+6 -1
View File
@@ -5,7 +5,8 @@ import {
getLocation,
type Location,
navigate,
languageStore
languageStore,
deviceOptionsStore as deviceInfo
} from '@hcengineering/ui'
import { type Ref, type Doc, type Class, generateId } from '@hcengineering/core'
import activity, { type ActivityMessage } from '@hcengineering/activity'
@@ -179,6 +180,10 @@ export async function replyToThread (message: ActivityMessage, e: Event): Promis
const fromSidebar = isElementFromSidebar(e.target as HTMLElement)
const loc = getCurrentLocation()
const dev = get(deviceInfo)
dev.aside.visible = true
deviceInfo.set(dev)
threadMessagesStore.set(message)
if (fromSidebar) {
@@ -23,7 +23,7 @@
export let targetEmp: Person
export let key: string
export let onChange: (key: string, value: boolean) => void
export let selected = false
export let selected: boolean = false
const client = getClient()
const hierarchy = client.getHierarchy()
@@ -16,13 +16,14 @@
import { Person } from '@hcengineering/contact'
import { Doc, Mixin, Ref } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { CheckBox, Label } from '@hcengineering/ui'
import { Label, RadioButton } from '@hcengineering/ui'
import { FixedColumn } from '@hcengineering/view-resources'
export let value: Person
export let targetEmp: Person
export let cast: Ref<Mixin<Doc>> | undefined = undefined
export let key: string
export let selected = false
export let selected: boolean = false
export let onChange: (key: string, value: boolean) => void
const client = getClient()
@@ -38,51 +39,51 @@
return (value as any)[key] === (targetEmp as any)[key]
}
$: attribute = hierarchy.findAttribute(cast ?? value._class, key)
const change = (sel: boolean): void => {
selected = sel
onChange(key, sel)
}
</script>
{#if !isEqual(value, targetEmp, key)}
<div class="box flex-row-center flex-between">
<div class="ml-4">
<div class="box flex-row-center flex-gap-4 flex-grow">
<FixedColumn key={'mergeLabel'} addClass={'ml-4'}>
{#if attribute?.label}
<Label label={attribute.label} />
{:else}
{key}
{/if}
</div>
</FixedColumn>
<div class="flex-center">
<div class="mr-2">
<CheckBox
circle
checked={selected}
on:value={(e) => {
selected = false
onChange(key, false)
}}
/>
</div>
<slot name="item" item={value} />
</div>
<div class="flex-row-center" />
<div class="flex-center">
<div class="mr-2">
<CheckBox
circle
checked={!selected}
on:value={(e) => {
selected = true
onChange(key, true)
}}
/>
</div>
<slot name="item" item={targetEmp} />
</div>
<FixedColumn key={'mergeFirst'} addClass="flex-row-center flex-gap-2 cursor-pointer">
<RadioButton
bind:group={selected}
value={false}
action={() => {
if (selected) change(false)
}}
>
<slot name="item" item={value} />
</RadioButton>
</FixedColumn>
<FixedColumn key={'mergeSecond'} addClass="flex-row-center flex-gap-2 cursor-pointer">
<RadioButton
bind:group={selected}
value={true}
action={() => {
if (!selected) change(true)
}}
>
<slot name="item" item={targetEmp} />
</RadioButton>
</FixedColumn>
</div>
{/if}
<style lang="scss">
.box {
margin: 0.5rem;
margin: 0.5rem 0;
padding: 0.5rem;
flex-shrink: 0;
border: 1px dashed var(--accent-color);
@@ -225,7 +225,7 @@
function selectMixin (mixin: Ref<Mixin<Doc>>, field: string, targetValue: boolean) {
const upd = mixinUpdate[mixin] ?? {}
if (!targetValue) {
;(upd as any)[field] = (value as any)[field]
;(upd as any)[field] = (sourcePerson as any)[mixin][field]
} else {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete (upd as any)[field]
@@ -354,7 +354,7 @@
shape={'circle'}
/>
</div>
>>
<span class="mx-4">&gt;&gt;</span>
<div class="flex-row-center">
<UserBox
_class={contact.class.Person}
@@ -387,13 +387,7 @@
<Avatar person={item} size={'x-large'} icon={contact.icon.Person} name={item.name} />
</svelte:fragment>
</MergeComparer>
<MergeComparer
key="name"
value={sourcePerson}
targetEmp={targetPerson}
onChange={select}
selected={update.name !== undefined}
>
<MergeComparer key="name" value={sourcePerson} targetEmp={targetPerson} onChange={select} selected>
<svelte:fragment slot="item" let:item>
{getName(client.getHierarchy(), item)}
</svelte:fragment>
@@ -405,7 +399,7 @@
targetEmp={targetPerson}
onChange={select}
_class={contact.mixin.Employee}
selected={toAny(update)[attribute[0]] !== undefined}
selected
/>
{/each}
{#each mixins as mixin}
@@ -419,7 +413,7 @@
selectMixin(mixin, key, value)
}}
_class={mixin}
selected={toAny(mixinUpdate)?.[mixin]?.[attribute] !== undefined}
selected
/>
{/each}
{/each}
@@ -48,4 +48,4 @@
})
</script>
<div bind:this={parentElement}></div>
<div bind:this={parentElement} class="hidden"></div>
@@ -89,6 +89,8 @@
<div class="content">
{#if integration && integration.value !== ''}
{integration.value}
{:else if integrationType.descriptionComponent}
<Component is={integrationType.descriptionComponent} />
{:else}
<Label label={integrationType.description} />
{/if}
@@ -134,6 +136,7 @@
}
.header {
margin: 1.5rem 1.5rem 1rem;
min-height: fit-content;
}
.icon {
flex-shrink: 0;
@@ -158,6 +161,7 @@
align-items: center;
column-gap: 1rem;
padding: 1.5rem 1.75rem 1.25rem;
padding-top: 0.5rem;
height: 5.25rem;
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 1.25rem, rgba(0, 0, 0, 1) 2.5rem);
overflow: hidden;
+1
View File
@@ -36,6 +36,7 @@ export type Handler = Resource<(value: string) => Promise<void>>
export interface IntegrationType extends Doc {
label: IntlString
description: IntlString
descriptionComponent?: AnyComponent
icon: AnyComponent
allowMultiple: boolean
+4 -1
View File
@@ -22,7 +22,10 @@
"NewMessage": "New message",
"NewIncomingMessage": "Sent you a new message",
"Telegram": "Telegram",
"TelegramIntegrationDesc": "Use telegram integration",
"TelegramIntegrationDesc": "☠\uFE0F Experimental integration with Telegram ☠\uFE0F",
"TelegramIntegrationDesc2": "This feature is separate from regular Telegram notifications",
"ToSetupNotification": "To set up notifications:",
"TelegramNotificationPath": "Notifications > General > Telegram > Configure",
"Status": "Status",
"MessagesSelected": "messages selected",
"ConfigLabel": "Telegram",
+4 -1
View File
@@ -22,7 +22,10 @@
"NewMessage": "Nuevo mensaje",
"NewIncomingMessage": "Te ha enviado un mensaje nuevo",
"Telegram": "Telegram",
"TelegramIntegrationDesc": "Usar integración de Telegram",
"TelegramIntegrationDesc": "☠\uFE0F Integración experimental con Telegram ☠\uFE0F",
"TelegramIntegrationDesc2": "Esta función es independiente de las notificaciones regulares de Telegram",
"ToSetupNotification": "Para configurar las notificaciones:",
"TelegramNotificationPath": "Notificaciones > General > Telegram > Configurar",
"Status": "Estado",
"MessagesSelected": "mensajes seleccionados",
"ConfigLabel": "Telegram",
+4 -1
View File
@@ -22,7 +22,10 @@
"NewMessage": "Nouveau message",
"NewIncomingMessage": "Vous a envoyé un nouveau message",
"Telegram": "Telegram",
"TelegramIntegrationDesc": "Utilisez l'intégration Telegram",
"TelegramIntegrationDesc": "☠\uFE0F Intégration expérimentale avec Telegram ☠\uFE0F",
"TelegramIntegrationDesc2": "Cette fonctionnalité est indépendante des notifications régulières de Telegram",
"ToSetupNotification": "Pour configurer les notifications :",
"TelegramNotificationPath": "Notifications > Général > Telegram > Configurer",
"Status": "Statut",
"MessagesSelected": "messages sélectionnés",
"ConfigLabel": "Telegram",
+4 -1
View File
@@ -22,7 +22,10 @@
"NewMessage": "Nova mensagem",
"NewIncomingMessage": "Enviou-lhe uma nova mensagem",
"Telegram": "Telegram",
"TelegramIntegrationDesc": "Utilizar integração do Telegram",
"TelegramIntegrationDesc": "☠\uFE0F Integração experimental com o Telegram ☠\uFE0F",
"TelegramIntegrationDesc2": "Esta funcionalidade é independente das notificações regulares do Telegram",
"ToSetupNotification": "Para configurar as notificações:",
"TelegramNotificationPath": "Notificações > Geral > Telegram > Configurar",
"Status": "Estado",
"MessagesSelected": "mensagens selecionadas",
"ConfigLabel": "Telegram",
+4 -1
View File
@@ -22,7 +22,10 @@
"NewMessage": "Новое сообщение",
"NewIncomingMessage": "Прислал новое сообщение",
"Telegram": "Telegram",
"TelegramIntegrationDesc": "Подключить Telegram",
"TelegramIntegrationDesc": "☠\uFE0F Экспериментальная интеграция с Telegram ☠\uFE0F",
"TelegramIntegrationDesc2": "Эта функция независима от обычных уведомлений Telegram",
"ToSetupNotification": "Для настройки уведомлений:",
"TelegramNotificationPath": "Уведомления > Основное > Telegram > Настроить",
"Status": "Статус",
"MessagesSelected": "сообщений выбрано",
"ConfigLabel": "Telegram",
+4 -1
View File
@@ -22,7 +22,10 @@
"NewMessage": "新消息",
"NewIncomingMessage": "给您发送了一条新消息",
"Telegram": "Telegram",
"TelegramIntegrationDesc": "使用 Telegram 集成",
"TelegramIntegrationDesc": "☠\uFE0F 与 Telegram 的实验性集成 ☠\uFE0F",
"TelegramIntegrationDesc2": "此功能与常规 Telegram 通知无关",
"ToSetupNotification": "设置通知:",
"TelegramNotificationPath": "通知 > 通用 > Telegram > 配置",
"Status": "状态",
"MessagesSelected": "选中的消息",
"ConfigLabel": "Telegram",
@@ -0,0 +1,32 @@
<!--
// 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 { Label } from '@hcengineering/ui'
import telegram from '../plugin'
</script>
<div class="flex-col flex-gap-1 max-w-100">
<Label label={telegram.string.TelegramIntegrationDesc} />
<br />
<Label label={telegram.string.TelegramIntegrationDesc2} />
<span class="mt-3">
<Label label={telegram.string.ToSetupNotification} />
</span>
<span class="fs-bold">
<Label label={telegram.string.TelegramNotificationPath} />
</span>
</div>
+3 -1
View File
@@ -25,6 +25,7 @@ import IconTelegram from './components/icons/TelegramColor.svelte'
import TelegramMessageCreated from './components/activity/TelegramMessageCreated.svelte'
import MessagePresenter from './components/MessagePresenter.svelte'
import NotificationProviderPresenter from './components/NotificationProviderPresenter.svelte'
import TelegramIntegrationDescription from './components/TelegramIntegrationDescription.svelte'
import telegram from './plugin'
import { getCurrentEmployeeTG, getIntegrationOwnerTG, isTelegramNotificationsAvailable } from './utils'
@@ -38,7 +39,8 @@ export default async (): Promise<Resources> => ({
IconTelegram,
SharedMessages,
MessagePresenter,
NotificationProviderPresenter
NotificationProviderPresenter,
TelegramIntegrationDescription
},
activity: {
TelegramMessageCreated
+5 -1
View File
@@ -132,6 +132,10 @@ export default plugin(telegramId, {
ConnectTelegramBot: '' as IntlString,
DisconnectMessage: '' as IntlString,
SyncAllChannels: '' as IntlString,
SyncStarredChannels: '' as IntlString
SyncStarredChannels: '' as IntlString,
TelegramIntegrationDesc: '' as IntlString,
TelegramIntegrationDesc2: '' as IntlString,
ToSetupNotification: '' as IntlString,
TelegramNotificationPath: '' as IntlString
}
})
@@ -17,8 +17,10 @@
import { uploads } from '../store'
</script>
<div class="flex-row-center flex-gap-2">
{#each $uploads as upload}
<FileUploadStatusBar {upload} />
{/each}
</div>
{#if $uploads.length > 0}
<div class="flex-row-center flex-gap-2">
{#each $uploads as upload}
<FileUploadStatusBar {upload} />
{/each}
</div>
{/if}
@@ -7,8 +7,8 @@
let parentElement: HTMLDivElement
onMount(() => {
pushRootBarComponent('right', uploader.component.FileUploadExt)
pushRootBarComponent('right', uploader.component.FileUploadExt, 10)
})
</script>
<div bind:this={parentElement}></div>
<div bind:this={parentElement} class="hidden"></div>
@@ -1,3 +1,4 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
@@ -43,11 +44,13 @@
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="flex-no-shrink{addClass ? ` ${addClass}` : ''}"
style:text-align={justify !== '' ? justify : ''}
style:min-width={`${$fixedWidthStore[key] ?? 0}px`}
use:resizeObserver={resize}
on:click
>
<slot />
</div>
@@ -69,8 +69,8 @@
}
&.small,
&.small .icon-container {
width: calc(var(--status-bar-height) - 8px);
height: calc(var(--status-bar-height) - 8px);
width: calc(var(--status-bar-normal-height) - 8px);
height: calc(var(--status-bar-normal-height) - 8px);
border-radius: 0.25rem;
}
&.small.selected {
@@ -54,8 +54,8 @@
height: 2rem;
}
&.mini {
width: 1.5rem;
height: 1.5rem;
width: 1.75rem;
height: 1.75rem;
}
&.red {
background-color: rgb(246, 105, 77);
@@ -158,6 +158,7 @@
const linkProviders = client.getModel().findAllSync(view.mixin.LinkIdProvider, {})
$deviceInfo.navigator.visible = getMetadata(workbench.metadata.NavigationExpandedDefault) ?? true
$deviceInfo.aside.visible = getMetadata(workbench.metadata.NavigationExpandedDefault) ?? true
async function toggleNav (): Promise<void> {
$deviceInfo.navigator.visible = !$deviceInfo.navigator.visible
@@ -635,10 +636,12 @@
$: if ($deviceInfo.docWidth <= 1024 && !$deviceInfo.navigator.float) {
$deviceInfo.navigator.visible = false
$deviceInfo.navigator.float = true
$deviceInfo.aside.visible = false
} else if ($deviceInfo.docWidth > 1024 && $deviceInfo.navigator.float) {
if (getMetadata(workbench.metadata.NavigationExpandedDefault) === undefined) {
$deviceInfo.navigator.float = false
$deviceInfo.navigator.visible = true
$deviceInfo.aside.visible = true
}
}
const checkOnHide = (): void => {
@@ -975,13 +978,28 @@
</div>
{/if}
</div>
{#if $sidebarStore.variant === SidebarVariant.EXPANDED}
<Separator name={'main'} index={0} color={'transparent'} separatorSize={0} short />
{#if !$deviceInfo.navigator.float}
{#if $sidebarStore.variant === SidebarVariant.EXPANDED}
<Separator name={'main'} index={0} color={'transparent'} separatorSize={0} short />
{/if}
<WidgetsBar />
{/if}
<WidgetsBar />
</div>
<Dock />
<div bind:this={cover} class="cover" />
{#if $deviceInfo.navigator.float}
<div
class="antiPanel-navigator right no-print {$deviceInfo.navigator.direction === 'horizontal'
? 'portrait'
: 'landscape'}"
style:display={$deviceInfo.aside.visible ? 'flex' : 'none'}
>
<Separator name={'main'} index={0} color={'transparent'} separatorSize={0} short float={'sidebar'} />
<div class="antiPanel-wrap__content hulyNavPanel-container">
<WidgetsBar />
</div>
</div>
{/if}
<TooltipInstance />
<PanelInstance bind:this={panelInstance} contentPanel={elementPanel}>
<svelte:fragment slot="panel-header">
@@ -993,7 +1011,9 @@
<ActionContext context={{ mode: 'popup' }} />
</svelte:fragment>
</Popup>
<ComponentExtensions extension={workbench.extensions.WorkbenchExtensions} />
<div class="hidden max-w-0 max-h-0">
<ComponentExtensions extension={workbench.extensions.WorkbenchExtensions} />
</div>
<BrowserNotificatator />
{/if}
@@ -1071,11 +1091,11 @@
}
.logo-container.mini {
left: 4px;
width: 1.5rem;
height: 1.5rem;
width: 1.75rem;
height: 1.75rem;
}
.topmenu-container.mini {
left: calc(1.5rem + 8px);
left: calc(1.75rem + 8px);
}
}
@@ -13,26 +13,75 @@
// limitations under the License.
-->
<script lang="ts">
import { createTab, tabsStore } from '../workbench'
import { createTab, tabsStore, tabIdStore } from '../workbench'
import { WorkbenchTabs } from '../index'
import WorkbenchTabPresenter from './WorkbenchTabPresenter.svelte'
import { IconAdd, ButtonIcon } from '@hcengineering/ui'
import {
IconAdd,
IconMoreH,
ButtonIcon,
ScrollerBar,
deviceOptionsStore as deviceInfo,
checkAdaptiveMatching,
showPopup
} from '@hcengineering/ui'
export let popup: boolean = false
let scroller: HTMLElement
let element: HTMLButtonElement
let pressed: boolean = false
$: devSize = $deviceInfo.size
$: mini = checkAdaptiveMatching(devSize, 'md')
$: selectedTab = $tabsStore.find((ts) => ts._id === $tabIdStore)
const showTabs = (): void => {
pressed = true
showPopup(WorkbenchTabs, { popup: true }, element, () => {
pressed = false
})
}
</script>
<div class="root flex-gap-1">
{#each $tabsStore as tab (tab._id)}
<WorkbenchTabPresenter {tab} />
{/each}
<div class="ml-1-5 plus-button mr-1">
<ButtonIcon icon={IconAdd} size="min" kind="tertiary" on:click={createTab} />
</div>
<div
class={popup ? 'selectPopup' : 'flex-row-center flex-gap-2'}
style:padding={popup ? '.5rem' : mini ? '.25rem .25rem .25rem 0' : '0 .25rem 0 0'}
>
{#if popup}
<div class="scroll">
<div class="box flex-gap-1">
{#each $tabsStore.filter((ts) => ts._id !== $tabIdStore) as tab (tab._id)}
<WorkbenchTabPresenter {tab} />
{/each}
</div>
</div>
{:else if !mini}
<ScrollerBar bind:scroller padding={'.25rem 0'}>
{#each $tabsStore as tab (tab._id)}
<WorkbenchTabPresenter {tab} />
{/each}
</ScrollerBar>
{:else if selectedTab !== undefined}
<WorkbenchTabPresenter tab={selectedTab} />
<ButtonIcon
bind:element
icon={IconMoreH}
iconProps={{ fill: 'var(--theme-dark-color)' }}
size={'extra-small'}
kind={'tertiary'}
hasMenu
{pressed}
on:click={showTabs}
/>
{/if}
{#if !popup}
<ButtonIcon
icon={IconAdd}
iconProps={{ fill: 'var(--theme-dark-color)' }}
size={'extra-small'}
kind={'tertiary'}
on:click={createTab}
/>
{/if}
</div>
<style>
.root {
display: flex;
align-items: center;
}
.plus-button {
height: 0.875rem;
}
</style>
@@ -17,7 +17,7 @@
import { WidgetPreference, SidebarEvent, TxSidebarEvent, OpenSidebarWidgetParams } from '@hcengineering/workbench'
import { Tx } from '@hcengineering/core'
import { onMount } from 'svelte'
import { panelstore } from '@hcengineering/ui'
import { panelstore, deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
import workbench from '../../plugin'
import { createWidgetTab, openWidget, sidebarStore, SidebarVariant } from '../../sidebar'
@@ -61,7 +61,12 @@
})
</script>
<div class="antiPanel-application vertical root" class:mini id="sidebar">
<div
id="sidebar"
class="antiPanel-application vertical sidebar-container"
class:mini
class:float={$deviceInfo.navigator.float}
>
{#if mini}
<SidebarMini {widgets} {preferences} />
{:else if $sidebarStore.variant === SidebarVariant.EXPANDED}
@@ -70,15 +75,25 @@
</div>
<style lang="scss">
.root {
.sidebar-container {
flex-direction: row;
min-width: 25rem;
border-radius: 0 var(--medium-BorderRadius) var(--medium-BorderRadius) 0;
&.mini {
&.mini:not(.float) {
width: 3.5rem !important;
min-width: 3.5rem !important;
max-width: 3.5rem !important;
}
&.mini.float {
justify-content: flex-end;
}
}
@media (max-width: 1024px) {
.sidebar-container {
width: 100%;
border: 1px solid var(--theme-navpanel-divider);
border-radius: var(--medium-BorderRadius);
}
}
</style>
@@ -14,7 +14,7 @@
-->
<script lang="ts">
import { Widget, WidgetPreference, WidgetType } from '@hcengineering/workbench'
import { IconSettings, ModernButton, showPopup } from '@hcengineering/ui'
import { IconSettings, ModernButton, showPopup, deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
import { Ref } from '@hcengineering/core'
import WidgetPresenter from './/WidgetPresenter.svelte'
@@ -31,7 +31,8 @@
function handleSelectWidget (widget: Widget): void {
if (selected === widget._id) {
minimizeSidebar(true)
if ($deviceInfo.navigator.float) $deviceInfo.aside.visible = false
else minimizeSidebar(true)
} else {
openWidget(widget, $sidebarStore.widgetsState.get(widget._id)?.data, { active: true, openedByUser: true })
}
@@ -100,6 +101,7 @@
width: 3.5rem;
min-width: 3.5rem;
max-width: 3.5rem;
background-color: var(--theme-navpanel-color);
border-radius: 0 var(--medium-BorderRadius) var(--medium-BorderRadius) 0;
overflow-y: auto;
}
+1
View File
@@ -37,6 +37,7 @@ export { default as NavHeader } from './components/NavHeader.svelte'
export { default as SpecialElement } from './components/navigator/SpecialElement.svelte'
export { default as SpaceView } from './components/SpaceView.svelte'
export { default as TreeSeparator } from './components/navigator/TreeSeparator.svelte'
export { default as WorkbenchTabs } from './components/WorkbenchTabs.svelte'
export { SpecialView }
export * from './utils'
-1
View File
@@ -73,7 +73,6 @@
"@hcengineering/server-telegram": "^0.6.0",
"@hcengineering/pod-telegram-bot": "^0.6.0",
"@hcengineering/server-ai-bot": "^0.6.0",
"@hcengineering/server-ai-bot-resources": "^0.6.0",
"ws": "^8.18.0",
"bufferutil": "^4.0.8",
"msgpackr": "^1.11.0",
+1
View File
@@ -72,6 +72,7 @@ setMetadata(serverCore.metadata.ElasticIndexName, config.elasticIndexName)
setMetadata(serverCore.metadata.ElasticIndexVersion, 'v1')
setMetadata(serverTelegram.metadata.BotUrl, process.env.TELEGRAM_BOT_URL)
setMetadata(serverAiBot.metadata.SupportWorkspaceId, process.env.SUPPORT_WORKSPACE)
setMetadata(serverAiBot.metadata.EndpointURL, process.env.AI_BOT_URL)
const { shutdown, sessionManager } = start(metricsContext, config.dbUrl, {
fullTextUrl: config.elasticUrl,
+1 -13
View File
@@ -33,8 +33,6 @@ import {
} from '@hcengineering/server-core'
import { type Token } from '@hcengineering/server-token'
import { serverAiBotId } from '@hcengineering/server-ai-bot'
import { createAIBotAdapter } from '@hcengineering/server-ai-bot-resources'
import { createServerPipeline, registerServerPlugins, registerStringLoaders } from '@hcengineering/server-pipeline'
import { readFileSync } from 'node:fs'
@@ -80,17 +78,7 @@ export function start (
dbUrl,
model,
{ ...opt, externalStorage, adapterSecurity: dbUrl.startsWith('postgresql') },
opt.mongoUrl !== undefined
? {
serviceAdapters: {
[serverAiBotId]: {
factory: createAIBotAdapter,
db: '%ai-bot',
url: opt.mongoUrl
}
}
}
: {}
{}
)
const sessionFactory = (
token: Token,
+6 -7
View File
@@ -37,19 +37,18 @@
},
"dependencies": {
"@hcengineering/activity": "^0.6.0",
"@hcengineering/ai-bot": "^0.6.0",
"@hcengineering/analytics-collector": "^0.6.0",
"@hcengineering/chunter": "^0.6.20",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/core": "^0.6.32",
"@hcengineering/mongo": "^0.6.1",
"@hcengineering/notification": "^0.6.23",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/server-activity-resources": "^0.6.0",
"@hcengineering/server-core": "^0.6.1",
"@hcengineering/server-templates": "^0.6.0",
"@hcengineering/templates": "^0.6.11",
"@hcengineering/ai-bot": "^0.6.0",
"@hcengineering/server-ai-bot": "^0.6.0",
"@hcengineering/analytics-collector": "^0.6.0",
"mongodb": "6.9.0-dev.20241016.sha.3d5bd513"
"@hcengineering/server-core": "^0.6.1",
"@hcengineering/server-token": "^0.6.11",
"@hcengineering/server-templates": "^0.6.0",
"@hcengineering/templates": "^0.6.11"
}
}
@@ -1,70 +0,0 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { MeasureContext, WorkspaceId } from '@hcengineering/core'
import { getMongoClient, MongoClientReference } from '@hcengineering/mongo'
import { AIBotServiceAdapter, WorkspaceInfoRecord } from '@hcengineering/server-ai-bot'
import { Collection, Db, MongoClient } from 'mongodb'
class AIBotAdapter implements AIBotServiceAdapter {
private readonly workspacesInfoCollection: Collection<WorkspaceInfoRecord>
private readonly db: Db
closed = false
constructor (
private readonly _client: MongoClientReference,
private readonly client: MongoClient,
private readonly _metrics: MeasureContext,
private readonly dbName: string
) {
this.db = client.db(dbName)
this.workspacesInfoCollection = this.db.collection<WorkspaceInfoRecord>('workspacesInfo')
}
async processWorkspace (workspace: WorkspaceId): Promise<void> {
if (this.closed) {
return
}
const existsRecord = await this.workspacesInfoCollection.findOne({
workspace: workspace.name
})
if (existsRecord != null && !existsRecord.active) {
await this.workspacesInfoCollection.updateOne({ workspace: workspace.name }, { $set: { active: true } })
} else if (existsRecord == null) {
const record: WorkspaceInfoRecord = {
workspace: workspace.name,
active: true
}
await this.workspacesInfoCollection.insertOne(record)
}
}
async close (): Promise<void> {
this.closed = true
this._client.close()
}
metrics (): MeasureContext {
return this._metrics
}
}
export async function createAIBotAdapter (url: string, db: string, metrics: MeasureContext): Promise<any> {
const _client = getMongoClient(url)
return new AIBotAdapter(_client, await _client.getClient(), metrics, db)
}
+32 -45
View File
@@ -16,7 +16,6 @@
import core, {
AccountRole,
AttachedDoc,
Data,
Doc,
Ref,
toWorkspaceString,
@@ -30,22 +29,17 @@ import core, {
} from '@hcengineering/core'
import { TriggerControl } from '@hcengineering/server-core'
import chunter, { ChatMessage, DirectMessage, ThreadMessage } from '@hcengineering/chunter'
import aiBot, { aiBotAccountEmail, AIBotResponseEvent } from '@hcengineering/ai-bot'
import { AIBotServiceAdapter, serverAiBotId } from '@hcengineering/server-ai-bot'
import aiBot, {
aiBotAccountEmail,
AIEventType,
AIMessageEventRequest,
AITransferEventRequest
} from '@hcengineering/ai-bot'
import contact, { PersonAccount } from '@hcengineering/contact'
import { ActivityInboxNotification, MentionInboxNotification } from '@hcengineering/notification'
import analyticsCollector, { OnboardingChannel } from '@hcengineering/analytics-collector'
import { getSupportWorkspaceId } from './utils'
async function processWorkspace (control: TriggerControl): Promise<void> {
const adapter = control.serviceAdaptersManager.getAdapter(serverAiBotId) as AIBotServiceAdapter | undefined
if (adapter !== undefined) {
await adapter.processWorkspace(control.workspace)
} else {
console.error('Cannot find server adapter: ', serverAiBotId)
}
}
import { createAccountRequest, getSupportWorkspaceId, sendAIEvents } from './utils'
async function isDirectAvailable (direct: DirectMessage, control: TriggerControl): Promise<boolean> {
const { members } = direct
@@ -77,8 +71,10 @@ async function getMessageDoc (message: ChatMessage, control: TriggerControl): Pr
}
}
function getMessageData (doc: Doc, message: ChatMessage, email: string): Data<AIBotResponseEvent> {
function getMessageData (doc: Doc, message: ChatMessage, email: string): AIMessageEventRequest {
return {
type: AIEventType.Message,
createdOn: message.createdOn ?? message.modifiedOn,
objectId: message.attachedTo,
objectClass: message.attachedToClass,
objectSpace: doc.space,
@@ -91,8 +87,10 @@ function getMessageData (doc: Doc, message: ChatMessage, email: string): Data<AI
}
}
function getThreadMessageData (message: ThreadMessage, email: string): Data<AIBotResponseEvent> {
function getThreadMessageData (message: ThreadMessage, email: string): AIMessageEventRequest {
return {
type: AIEventType.Message,
createdOn: message.createdOn ?? message.modifiedOn,
objectId: message.attachedTo,
objectClass: message.attachedToClass,
objectSpace: message.space,
@@ -105,15 +103,6 @@ function getThreadMessageData (message: ThreadMessage, email: string): Data<AIBo
}
}
async function createResponseEvent (
message: ChatMessage,
control: TriggerControl,
data: Data<AIBotResponseEvent>
): Promise<void> {
const eventTx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotResponseEvent, message.space, data)
await control.apply(control.ctx, [eventTx])
}
async function getThreadParent (control: TriggerControl, message: ChatMessage): Promise<Ref<ChatMessage> | undefined> {
if (!control.hierarchy.isDerived(message.attachedToClass, chunter.class.ChatMessage)) {
return undefined
@@ -137,8 +126,8 @@ async function createTransferEvent (
control: TriggerControl,
message: ChatMessage,
account: PersonAccount,
data: Data<AIBotResponseEvent>
): Promise<void> {
data: AIMessageEventRequest
): Promise<AITransferEventRequest | undefined> {
if (account.role !== AccountRole.Owner) {
return
}
@@ -149,7 +138,9 @@ async function createTransferEvent (
return
}
const eventTx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotTransferEvent, message.space, {
return {
type: AIEventType.Transfer,
createdOn: message.createdOn ?? message.modifiedOn,
messageClass: data.messageClass,
message: message.message,
collection: data.collection,
@@ -160,9 +151,7 @@ async function createTransferEvent (
fromWorkspaceUrl: control.workspace.workspaceUrl,
messageId: message._id,
parentMessageId: await getThreadParent(control, message)
})
await control.apply(control.ctx, [eventTx])
}
}
async function onBotDirectMessageSend (control: TriggerControl, message: ChatMessage): Promise<void> {
@@ -186,18 +175,18 @@ async function onBotDirectMessageSend (control: TriggerControl, message: ChatMes
return
}
let data: Data<AIBotResponseEvent> | undefined
let messageEvent: AIMessageEventRequest
if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) {
data = getThreadMessageData(message as ThreadMessage, account.email)
messageEvent = getThreadMessageData(message as ThreadMessage, account.email)
} else {
data = getMessageData(direct, message, account.email)
messageEvent = getMessageData(direct, message, account.email)
}
await createResponseEvent(message, control, data)
await createTransferEvent(control, message, account, data)
const transferEvent = await createTransferEvent(control, message, account, messageEvent)
const events = transferEvent !== undefined ? [messageEvent, transferEvent] : [messageEvent]
await processWorkspace(control)
await sendAIEvents(events, control.workspace, control.ctx)
}
async function onSupportWorkspaceMessage (control: TriggerControl, message: ChatMessage): Promise<void> {
@@ -222,18 +211,20 @@ async function onSupportWorkspaceMessage (control: TriggerControl, message: Chat
}
const { workspaceId, email } = channel
let data: Data<AIBotResponseEvent> | undefined
const account = control.modelDb.findAllSync(contact.class.PersonAccount, {
_id: (message.createdBy ?? message.modifiedBy) as Ref<PersonAccount>
})[0]
let data: AIMessageEventRequest
if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) {
data = getThreadMessageData(message as ThreadMessage, account.email)
} else {
data = getMessageData(channel, message, account.email)
}
const tx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotTransferEvent, message.space, {
const transferEvent: AITransferEventRequest = {
type: AIEventType.Transfer,
createdOn: data.createdOn,
messageClass: data.messageClass,
message: message.message,
collection: data.collection,
@@ -244,11 +235,9 @@ async function onSupportWorkspaceMessage (control: TriggerControl, message: Chat
fromWorkspaceName: control.workspace.workspaceName,
messageId: message._id,
parentMessageId: await getThreadParent(control, message)
})
}
await control.apply(control.ctx, [tx])
await processWorkspace(control)
await sendAIEvents([transferEvent], control.workspace, control.ctx)
}
export async function OnMessageSend (
@@ -407,7 +396,7 @@ export async function OnUserStatus (originTx: Tx, control: TriggerControl): Prom
return []
}
await processWorkspace(control)
await createAccountRequest(control.workspace, control.ctx)
return []
}
@@ -421,5 +410,3 @@ export default async () => ({
OnUserStatus
}
})
export * from './adapter'
@@ -15,6 +15,9 @@
import { getMetadata } from '@hcengineering/platform'
import serverAIBot from '@hcengineering/server-ai-bot'
import { AIEventRequest } from '@hcengineering/ai-bot'
import { concatLink, MeasureContext, systemAccountEmail, WorkspaceId } from '@hcengineering/core'
import { generateToken } from '@hcengineering/server-token'
export function getSupportWorkspaceId (): string | undefined {
const supportWorkspaceId = getMetadata(serverAIBot.metadata.SupportWorkspaceId)
@@ -25,3 +28,49 @@ export function getSupportWorkspaceId (): string | undefined {
return supportWorkspaceId
}
export async function sendAIEvents (
events: AIEventRequest[],
workspace: WorkspaceId,
ctx: MeasureContext
): Promise<void> {
const url = getMetadata(serverAIBot.metadata.EndpointURL) ?? ''
if (url === '') {
return
}
try {
await fetch(concatLink(url, '/events'), {
method: 'POST',
headers: {
Authorization: 'Bearer ' + generateToken(systemAccountEmail, workspace),
'Content-Type': 'application/json'
},
body: JSON.stringify(events)
})
} catch (err) {
ctx.error('Could not send ai events', { err })
}
}
export async function createAccountRequest (workspace: WorkspaceId, ctx: MeasureContext): Promise<void> {
const url = getMetadata(serverAIBot.metadata.EndpointURL) ?? ''
if (url === '') {
return
}
try {
await fetch(concatLink(url, '/connect'), {
method: 'POST',
headers: {
Authorization: 'Bearer ' + generateToken(systemAccountEmail, workspace),
'Content-Type': 'application/json'
},
body: JSON.stringify({})
})
} catch (err) {
ctx.error('Could not send create ai account request', { err })
}
}
+2 -1
View File
@@ -22,7 +22,8 @@ export const serverAiBotId = 'server-ai-bot' as Plugin
export default plugin(serverAiBotId, {
metadata: {
SupportWorkspaceId: '' as Metadata<string>
SupportWorkspaceId: '' as Metadata<string>,
EndpointURL: '' as Metadata<string>
},
trigger: {
OnMessageSend: '' as Resource<TriggerFunc>,
-8
View File
@@ -13,16 +13,8 @@
// limitations under the License.
//
import { ServiceAdapter } from '@hcengineering/server-core'
import { WorkspaceId } from '@hcengineering/core'
export interface AIBotServiceAdapter extends ServiceAdapter {
processWorkspace: (workspace: WorkspaceId) => Promise<void>
}
export interface WorkspaceInfoRecord {
workspace: string
active: boolean
avatarPath?: string
avatarLastModified?: number
}
+1
View File
@@ -45,6 +45,7 @@
"@hcengineering/client-resources": "^0.6.27",
"@hcengineering/client": "^0.6.18",
"@hcengineering/server-core": "^0.6.1",
"@hcengineering/server-token": "^0.6.11",
"ws": "^8.18.0"
}
}
+1
View File
@@ -21,3 +21,4 @@ export default plugin
export * from './account'
export * from './blob'
export * from './client'
export * from './token'
+44
View File
@@ -0,0 +1,44 @@
import { Token, decodeToken } from '@hcengineering/server-token'
import { IncomingHttpHeaders } from 'http'
const extractCookieToken = (cookie?: string): Token | null => {
if (cookie === undefined || cookie === null) {
return null
}
const cookies = cookie.split(';')
const tokenCookie = cookies.find((cookie) => cookie.toLocaleLowerCase().includes('token'))
if (tokenCookie === undefined) {
return null
}
const encodedToken = tokenCookie.split('=')[1]
if (encodedToken === undefined) {
return null
}
return decodeToken(encodedToken)
}
const extractAuthorizationToken = (authorization?: string): Token | null => {
if (authorization === undefined || authorization === null) {
return null
}
const encodedToken = authorization.split(' ')[1]
if (encodedToken === undefined) {
return null
}
return decodeToken(encodedToken)
}
export function extractToken (headers: IncomingHttpHeaders): Token | undefined {
try {
const token = extractCookieToken(headers.cookie) ?? extractAuthorizationToken(headers.authorization)
return token ?? undefined
} catch {
return undefined
}
}
+7
View File
@@ -17,6 +17,7 @@
import core, {
TxFactory,
TxProcessor,
generateId,
groupByArray,
matchQuery,
type Class,
@@ -149,8 +150,14 @@ export class Triggers {
trigger.resource,
{},
async (ctx) => {
if (mode === 'async') {
ctx.id = generateId()
}
const tresult = await this.applyTrigger(ctx, ctrl, matches, { trigger, arrays })
result.push(...tresult)
if (ctx.onEnd !== undefined && mode === 'async') {
await ctx.onEnd(ctx)
}
},
{ count: matches.length, arrays }
)
-2
View File
@@ -160,8 +160,6 @@ export interface PipelineContext {
head?: Middleware
broadcastEvent?: (ctx: MeasureContext, tx: Tx[]) => Promise<void>
endContext?: (ctx: MeasureContext) => Promise<void>
}
/**
* @public
+5 -2
View File
@@ -23,9 +23,12 @@ export class ConnectionMgrMiddleware extends BaseMiddleware implements Middlewar
if (ctx.id === undefined) {
ctx.id = generateId()
}
ctx.onEnd = async (_ctx: MeasureContext) => {
await this.context.adapterManager?.closeContext?.(_ctx)
}
const result = await this.provideTx(ctx, tx)
this.context.endContext = async (_ctx: MeasureContext) => {
await this.context.adapterManager?.closeContext?.(ctx)
if (ctx.onEnd !== undefined) {
await ctx.onEnd(ctx)
}
return result
}
+10 -4
View File
@@ -33,6 +33,7 @@ import core, {
type TxRemoveDoc,
type TxUpdateDoc,
addOperation,
generateId,
toFindResult,
withContext
} from '@hcengineering/core'
@@ -225,11 +226,16 @@ export class TriggersMiddleware extends BaseMiddleware implements Middleware {
)
if (aresult.length > 0) {
await this.processDerivedTxes(ctx, aresult)
// We need to send all to recipients
await this.context.head?.handleBroadcast(ctx)
await ctx.with('process-aync-result', {}, async (ctx) => {
ctx.id = generateId()
await this.processDerivedTxes(ctx, aresult)
// We need to send all to recipients
await this.context.head?.handleBroadcast(ctx)
if (ctx.onEnd !== undefined) {
await ctx.onEnd(ctx)
}
})
}
await this.context.endContext?.(ctx)
}
private async processDerivedTxes (ctx: MeasureContext<SessionData>, derived: Tx[]): Promise<void> {
+42 -45
View File
@@ -79,7 +79,6 @@ import {
isDataField,
isOwner,
type JoinProps,
Mutex,
parseDoc,
parseDocWithProjection,
parseUpdate,
@@ -90,16 +89,36 @@ import {
abstract class PostgresAdapterBase implements DbAdapter {
protected readonly _helper: DBCollectionHelper
protected readonly tableFields = new Map<string, string[]>()
protected readonly mutex = new Mutex()
protected readonly connections = new Map<string, postgres.ReservedSql>()
protected readonly connections = new Map<string, postgres.ReservedSql | Promise<postgres.ReservedSql>>()
protected readonly retryTxn = async (
connection: postgres.ReservedSql,
client: postgres.ReservedSql,
fn: (client: postgres.ReservedSql) => Promise<any>
): Promise<void> => {
await this.mutex.runExclusive(async () => {
await this.processOps(connection, fn)
})
const backoffInterval = 100 // millis
const maxTries = 5
let tries = 0
while (true) {
await client.unsafe('BEGIN;')
tries++
try {
const result = await fn(client)
await client.unsafe('COMMIT;')
return result
} catch (err: any) {
await client.unsafe('ROLLBACK;')
if (err.code !== '40001' || tries === maxTries) {
throw err
} else {
console.log('Transaction failed. Retrying.')
console.log(err.message)
await new Promise((resolve) => setTimeout(resolve, tries * backoffInterval))
}
}
}
}
constructor (
@@ -133,7 +152,11 @@ abstract class PostgresAdapterBase implements DbAdapter {
if (ctx.id === undefined) return
const conn = this.connections.get(ctx.id)
if (conn !== undefined) {
conn.release()
if (conn instanceof Promise) {
;(await conn).release()
} else {
conn.release()
}
this.connections.delete(ctx.id)
}
}
@@ -141,40 +164,10 @@ abstract class PostgresAdapterBase implements DbAdapter {
protected async getConnection (ctx: MeasureContext): Promise<postgres.ReservedSql | undefined> {
if (ctx.id === undefined) return
const conn = this.connections.get(ctx.id)
if (conn !== undefined) return conn
const client = await this.client.reserve()
if (conn !== undefined) return await conn
const client = this.client.reserve()
this.connections.set(ctx.id, client)
return client
}
private async processOps (
client: postgres.ReservedSql,
operation: (client: postgres.ReservedSql) => Promise<any>
): Promise<void> {
const backoffInterval = 100 // millis
const maxTries = 5
let tries = 0
while (true) {
await client.unsafe('BEGIN;')
tries++
try {
const result = await operation(client)
await client.unsafe('COMMIT;')
return result
} catch (err: any) {
await client.unsafe('ROLLBACK;')
if (err.code !== '40001' || tries === maxTries) {
throw err
} else {
console.log('Transaction failed. Retrying.')
console.log(err.message)
await new Promise((resolve) => setTimeout(resolve, tries * backoffInterval))
}
}
}
return await client
}
async traverse<T extends Doc>(
@@ -239,9 +232,13 @@ abstract class PostgresAdapterBase implements DbAdapter {
abstract init (): Promise<void>
async close (): Promise<void> {
this.connections.forEach((c) => {
c.release()
})
for (const c of this.connections.values()) {
if (c instanceof Promise) {
;(await c).release()
} else {
c.release()
}
}
this.refClient.close()
}
@@ -1495,7 +1492,7 @@ class PostgresAdapter extends PostgresAdapterBase {
return { object }
}
} catch (err) {
console.error(err)
console.error(err, { tx, params, updates })
}
})
return {}
+4 -35
View File
@@ -241,6 +241,10 @@ export function inferType (val: any): string {
if (typeof val === 'boolean') {
return '::boolean'
}
if (Array.isArray(val)) {
const type = inferType(val[0])
return type + '[]'
}
return ''
}
@@ -409,38 +413,3 @@ export interface JoinProps {
toClass: Ref<Class<Doc>>
classes?: Ref<Class<Doc>>[] // filter by classes
}
export class Mutex {
private locked: boolean = false
private readonly waitingQueue: Array<(value: boolean) => void> = []
private async acquire (): Promise<void> {
while (this.locked) {
await new Promise<boolean>((resolve) => {
this.waitingQueue.push(resolve)
})
}
this.locked = true
}
private release (): void {
if (!this.locked) {
throw new Error('Mutex is not locked')
}
this.locked = false
const nextResolver = this.waitingQueue.shift()
if (nextResolver !== undefined) {
nextResolver(true)
}
}
async runExclusive<T>(fn: () => Promise<T> | T): Promise<T> {
await this.acquire()
try {
return await fn()
} finally {
this.release()
}
}
}
+73 -142
View File
@@ -13,10 +13,13 @@
// limitations under the License.
//
import { isWorkspaceCreating, Markup, MeasureContext, systemAccountEmail } from '@hcengineering/core'
import { Markup, MeasureContext } from '@hcengineering/core'
import {
aiBotAccountEmail,
AIBotTransferEvent,
AIEventRequest,
AIEventType,
AIMessageEventRequest,
AITransferEventRequest,
OnboardingEvent,
OnboardingEventRequest,
OpenChatInSidebarData,
@@ -26,38 +29,30 @@ import {
import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot'
import { getTransactorEndpoint } from '@hcengineering/server-client'
import { generateToken } from '@hcengineering/server-token'
import { WorkspaceLoginInfo } from '@hcengineering/account'
import OpenAI from 'openai'
import { encodingForModel } from 'js-tiktoken'
import { htmlToMarkup, markupToHTML } from '@hcengineering/text'
import { WorkspaceClient } from './workspaceClient'
import { assignBotToWorkspace, getWorkspaceInfo } from './account'
import config from './config'
import { DbStorage } from './storage'
import { SupportWsClient } from './supportWsClient'
import { AIReplyTransferData } from './types'
import { tryAssignToWorkspace } from './utils/account'
import { translateHtml } from './utils/openai'
const POLLING_INTERVAL_MS = 5 * 1000 // 5 seconds
const CLOSE_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes
const ASSIGN_WORKSPACE_DELAY_MS = 5 * 1000 // 5 secs
const MAX_ASSIGN_ATTEMPTS = 5
export class AIBotController {
export class AIControl {
private readonly workspaces: Map<string, WorkspaceClient> = new Map<string, WorkspaceClient>()
private readonly closeWorkspaceTimeouts: Map<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>()
private readonly connectingWorkspaces: Set<string> = new Set<string>()
private readonly intervalId: NodeJS.Timeout
readonly aiClient?: OpenAI
readonly encoding = encodingForModel(config.OpenAIModel)
supportClient: SupportWsClient | undefined = undefined
assignTimeout: NodeJS.Timeout | undefined
assignAttempts = 0
constructor (
readonly storage: DbStorage,
private readonly ctx: MeasureContext
@@ -70,37 +65,20 @@ export class AIBotController {
})
: undefined
this.intervalId = setInterval(() => {
void this.updateWorkspaceClients()
}, POLLING_INTERVAL_MS)
void this.connectSupportWorkspace()
}
async updateWorkspaceClients (): Promise<void> {
const activeRecords = await this.storage.getActiveWorkspaces()
async getWorkspaceRecord (workspace: string): Promise<WorkspaceInfoRecord> {
return (await this.storage.getWorkspace(workspace)) ?? { workspace: config.SupportWorkspace }
}
async connectSupportWorkspace (): Promise<void> {
if (this.supportClient === undefined && !this.connectingWorkspaces.has(config.SupportWorkspace)) {
this.connectingWorkspaces.add(config.SupportWorkspace)
const record = await this.storage.getWorkspace(config.SupportWorkspace)
this.supportClient = (await this.createWorkspaceClient(
config.SupportWorkspace,
record ?? { workspace: config.SupportWorkspace, active: true }
)) as SupportWsClient
const record = await this.getWorkspaceRecord(config.SupportWorkspace)
this.supportClient = (await this.createWorkspaceClient(config.SupportWorkspace, record)) as SupportWsClient
this.connectingWorkspaces.delete(config.SupportWorkspace)
}
for (const record of activeRecords) {
const ws = record.workspace
if (this.workspaces.has(ws)) {
continue
}
if (this.connectingWorkspaces.has(ws)) {
continue
}
await this.initWorkspaceClient(ws, record)
}
}
async closeWorkspaceClient (workspace: string): Promise<void> {
@@ -111,8 +89,6 @@ export class AIBotController {
this.closeWorkspaceTimeouts.delete(workspace)
}
await this.storage.inactiveWorkspace(workspace)
const client = this.workspaces.get(workspace)
if (client !== undefined) {
@@ -122,65 +98,18 @@ export class AIBotController {
this.connectingWorkspaces.delete(workspace)
}
private async getWorkspaceInfo (ws: string): Promise<WorkspaceLoginInfo | undefined> {
const systemToken = generateToken(systemAccountEmail, { name: ws })
for (let i = 0; i < 5; i++) {
try {
const info = await getWorkspaceInfo(systemToken)
async createWorkspaceClient (workspace: string, info: WorkspaceInfoRecord): Promise<WorkspaceClient | undefined> {
const isAssigned = await tryAssignToWorkspace(workspace, this.ctx)
if (info == null) {
this.ctx.warn('Cannot find workspace info', { workspace: ws })
await wait(ASSIGN_WORKSPACE_DELAY_MS)
continue
}
return info
} catch (e) {
this.ctx.error('Error during get workspace info:', { e })
await wait(ASSIGN_WORKSPACE_DELAY_MS)
}
if (!isAssigned) {
return
}
}
private async assignToWorkspace (workspace: string): Promise<void> {
clearTimeout(this.assignTimeout)
try {
const info = await this.getWorkspaceInfo(workspace)
if (info === undefined) {
void this.closeWorkspaceClient(workspace)
return
}
if (isWorkspaceCreating(info?.mode)) {
this.ctx.info('Workspace is creating -> waiting...', { workspace })
this.assignTimeout = setTimeout(() => {
void this.assignToWorkspace(workspace)
}, ASSIGN_WORKSPACE_DELAY_MS)
return
}
const result = await assignBotToWorkspace(workspace)
this.ctx.info('Assign to workspace result: ', { result, workspace })
} catch (e) {
this.ctx.error('Error during assign workspace:', { e })
if (this.assignAttempts < MAX_ASSIGN_ATTEMPTS) {
this.assignAttempts++
this.assignTimeout = setTimeout(() => {
void this.assignToWorkspace(workspace)
}, ASSIGN_WORKSPACE_DELAY_MS)
} else {
void this.closeWorkspaceClient(workspace)
}
}
}
async createWorkspaceClient (workspace: string, info: WorkspaceInfoRecord): Promise<WorkspaceClient> {
this.ctx.info('Listen workspace: ', { workspace })
await this.assignToWorkspace(workspace)
const token = generateToken(aiBotAccountEmail, { name: workspace })
const endpoint = await getTransactorEndpoint(token)
this.ctx.info('Listen workspace: ', { workspace })
if (workspace === config.SupportWorkspace) {
return new SupportWsClient(endpoint, token, workspace, this, this.ctx.newChild(workspace, {}), info)
}
@@ -188,14 +117,19 @@ export class AIBotController {
return new WorkspaceClient(endpoint, token, workspace, this, this.ctx.newChild(workspace, {}), info)
}
async initWorkspaceClient (workspace: string, info: WorkspaceInfoRecord): Promise<void> {
async initWorkspaceClient (workspace: string): Promise<void> {
if (workspace === config.SupportWorkspace) {
return
}
this.connectingWorkspaces.add(workspace)
if (!this.workspaces.has(workspace)) {
const client = await this.createWorkspaceClient(workspace, info)
const record = await this.getWorkspaceRecord(workspace)
const client = await this.createWorkspaceClient(workspace, record)
if (client === undefined) {
this.connectingWorkspaces.delete(workspace)
return
}
this.workspaces.set(workspace, client)
}
@@ -226,9 +160,8 @@ export class AIBotController {
await this.supportClient.transferAIReply(response, data)
}
async transfer (event: AIBotTransferEvent): Promise<void> {
async transfer (event: AITransferEventRequest): Promise<void> {
const workspace = event.toWorkspace
const info = await this.storage.getWorkspace(workspace)
if (workspace === config.SupportWorkspace) {
if (this.supportClient === undefined) return
@@ -237,25 +170,13 @@ export class AIBotController {
return
}
if (info === undefined) {
this.ctx.error('Workspace info not found -> cannot transfer event', { workspace })
return
}
await this.initWorkspaceClient(workspace, info)
const wsClient = this.workspaces.get(workspace)
if (wsClient === undefined) {
return
}
const wsClient = await this.getWorkspaceClient(workspace)
if (wsClient === undefined) return
await wsClient.transfer(event)
}
async close (): Promise<void> {
clearInterval(this.intervalId)
for (const workspace of this.workspaces.values()) {
await workspace.close()
}
@@ -266,16 +187,23 @@ export class AIBotController {
}
async updateAvatarInfo (workspace: string, path: string, lastModified: number): Promise<void> {
await this.storage.updateWorkspace(workspace, { $set: { avatarPath: path, avatarLastModified: lastModified } })
const record = await this.storage.getWorkspace(workspace)
if (record === undefined) {
await this.storage.addWorkspace({ workspace, avatarPath: path, avatarLastModified: lastModified })
} else {
await this.storage.updateWorkspace(workspace, { $set: { avatarPath: path, avatarLastModified: lastModified } })
}
}
async getWorkspaceClient (workspace: string): Promise<WorkspaceClient | undefined> {
await this.initWorkspaceClient(workspace)
return this.workspaces.get(workspace)
}
async openChatInSidebar (data: OpenChatInSidebarData): Promise<void> {
const record = await this.storage.getWorkspace(data.workspace)
await this.initWorkspaceClient(data.workspace, record ?? { workspace: data.workspace, active: true })
const wsClient = this.workspaces.get(data.workspace)
const wsClient = await this.getWorkspaceClient(data.workspace)
if (wsClient === undefined) return
await wsClient.openAIChatInSidebar(data.email)
}
@@ -293,35 +221,38 @@ export class AIBotController {
return undefined
}
const html = markupToHTML(req.text)
const start = Date.now()
const response = await this.aiClient.chat.completions.create({
model: config.OpenAITranslateModel,
messages: [
{
role: 'system',
content: `Your task is to translate the text into ${req.lang} while preserving the html structure and metadata`
},
{
role: 'user',
content: html
}
]
})
const end = Date.now()
this.ctx.info('Translation time: ', { time: end - start })
const result = response.choices[0].message.content
const text = result !== null ? htmlToMarkup(result) : req.text
const result = await translateHtml(this.aiClient, html, req.lang)
const text = result !== undefined ? htmlToMarkup(result) : req.text
return {
text,
lang: req.lang
}
}
}
async function wait (delay: number): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve()
}, delay)
})
async processMessageEvent (workspace: string, event: AIMessageEventRequest): Promise<void> {
const wsClient = await this.getWorkspaceClient(workspace)
if (wsClient === undefined) return
await wsClient.processMessageEvent(event)
}
async processEvent (workspace: string, events: AIEventRequest[]): Promise<void> {
for (const event of events) {
switch (event.type) {
case AIEventType.Transfer:
await this.transfer(event as AITransferEventRequest)
break
case AIEventType.Message:
await this.processMessageEvent(workspace, event as AIMessageEventRequest)
break
default:
this.ctx.warn('unknown event', event)
break
}
}
}
async connect (workspace: string): Promise<void> {
await this.initWorkspaceClient(workspace)
}
}
@@ -1,6 +0,0 @@
import { Client } from '@hcengineering/core'
import { createClient } from '@hcengineering/server-client'
export async function connectPlatform (token: string, endpoint: string): Promise<Client> {
return await createClient(endpoint, token)
}
@@ -13,60 +13,15 @@
// limitations under the License.
//
import { Token, decodeToken } from '@hcengineering/server-token'
import { Token } from '@hcengineering/server-token'
import cors from 'cors'
import express, { type Express, type NextFunction, type Request, type Response } from 'express'
import { IncomingHttpHeaders, type Server } from 'http'
import { TranslateRequest, OnboardingEventRequest } from '@hcengineering/ai-bot'
import { type Server } from 'http'
import { TranslateRequest, OnboardingEventRequest, AIEventRequest } from '@hcengineering/ai-bot'
import { extractToken } from '@hcengineering/server-client'
import { ApiError } from './error'
import { AIBotController } from './controller'
const extractCookieToken = (cookie?: string): Token | null => {
if (cookie === undefined || cookie === null) {
return null
}
const cookies = cookie.split(';')
const tokenCookie = cookies.find((cookie) => cookie.toLocaleLowerCase().includes('token'))
if (tokenCookie === undefined) {
return null
}
const encodedToken = tokenCookie.split('=')[1]
if (encodedToken === undefined) {
return null
}
return decodeToken(encodedToken)
}
const extractAuthorizationToken = (authorization?: string): Token | null => {
if (authorization === undefined || authorization === null) {
return null
}
const encodedToken = authorization.split(' ')[1]
if (encodedToken === undefined) {
return null
}
return decodeToken(encodedToken)
}
const extractToken = (headers: IncomingHttpHeaders): Token => {
try {
const token = extractCookieToken(headers.cookie) ?? extractAuthorizationToken(headers.authorization)
if (token === null) {
throw new ApiError(401)
}
return token
} catch {
throw new ApiError(401)
}
}
import { AIControl } from '../controller'
type AsyncRequestHandler = (req: Request, res: Response, token: Token, next: NextFunction) => Promise<void>
@@ -76,8 +31,11 @@ const handleRequest = async (
res: Response,
next: NextFunction
): Promise<void> => {
const token = extractToken(req.headers)
if (token === undefined) {
throw new ApiError(401)
}
try {
const token = extractToken(req.headers)
await fn(req, res, token, next)
} catch (err: unknown) {
next(err)
@@ -88,7 +46,7 @@ const wrapRequest = (fn: AsyncRequestHandler) => (req: Request, res: Response, n
void handleRequest(fn, req, res, next)
}
export function createServer (controller: AIBotController): Express {
export function createServer (controller: AIControl): Express {
const app = express()
app.use(cors())
app.use(express.json())
@@ -109,6 +67,32 @@ export function createServer (controller: AIBotController): Express {
})
)
app.post(
'/connect',
wrapRequest(async (_, res, token) => {
await controller.connect(token.workspace.name)
res.status(200)
res.json({})
})
)
app.post(
'/events',
wrapRequest(async (req, res, token) => {
if (req.body == null) {
throw new ApiError(400)
}
const events = Array.isArray(req.body) ? req.body : [req.body]
await controller.processEvent(token.workspace.name, events as AIEventRequest[])
res.status(200)
res.json({})
})
)
app.post(
'/onboarding',
wrapRequest(async (req, res) => {
+5 -5
View File
@@ -17,14 +17,14 @@ import { setMetadata } from '@hcengineering/platform'
import serverAiBot from '@hcengineering/server-ai-bot'
import serverClient from '@hcengineering/server-client'
import serverToken from '@hcengineering/server-token'
import { initStatisticsContext } from '@hcengineering/server-core'
import { createBotAccount } from './account'
import config from './config'
import { AIBotController } from './controller'
import { AIControl } from './controller'
import { registerLoaders } from './loaders'
import { createServer, listen } from './server'
import { closeDB, DbStorage, getDB } from './storage'
import { createBotAccount } from './utils/account'
import { createServer, listen } from './server/server'
export const start = async (): Promise<void> => {
setMetadata(serverToken.metadata.Secret, config.ServerSecret)
@@ -49,7 +49,7 @@ export const start = async (): Promise<void> => {
}
await new Promise((resolve) => setTimeout(resolve, 3000))
}
const aiController = new AIBotController(storage, ctx)
const aiController = new AIControl(storage, ctx)
const app = createServer(aiController)
const server = listen(app, config.Port)
+5 -9
View File
@@ -15,8 +15,8 @@
import { MongoClientReference, getMongoClient } from '@hcengineering/mongo'
import { Collection, Db, MongoClient, ObjectId, UpdateFilter, WithId } from 'mongodb'
import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot'
import { Doc, Ref, SortingOrder } from '@hcengineering/core'
import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot'
import config from './config'
import { HistoryRecord } from './types'
@@ -61,18 +61,14 @@ export class DbStorage {
await this.historyCollection.deleteMany({ _id: { $in: _ids } })
}
async getActiveWorkspaces (): Promise<WorkspaceInfoRecord[]> {
return await this.workspacesInfoCollection.find({ active: true }).toArray()
}
async inactiveWorkspace (workspace: string): Promise<void> {
await this.workspacesInfoCollection.updateOne({ workspace }, { $set: { active: false } })
}
async getWorkspace (workspace: string): Promise<WorkspaceInfoRecord | undefined> {
return (await this.workspacesInfoCollection.findOne({ workspace })) ?? undefined
}
async addWorkspace (record: WorkspaceInfoRecord): Promise<void> {
await this.workspacesInfoCollection.insertOne(record)
}
async updateWorkspace (workspace: string, update: UpdateFilter<WorkspaceInfoRecord>): Promise<void> {
await this.workspacesInfoCollection.updateOne({ workspace }, update)
}
@@ -15,10 +15,14 @@
import { LoginInfo, Workspace, WorkspaceLoginInfo } from '@hcengineering/account'
import aiBot, { aiBotAccountEmail } from '@hcengineering/ai-bot'
import { AccountRole, systemAccountEmail } from '@hcengineering/core'
import { AccountRole, isWorkspaceCreating, MeasureContext, systemAccountEmail } from '@hcengineering/core'
import { generateToken } from '@hcengineering/server-token'
import config from './config'
import config from '../config'
import { wait } from './common'
const ASSIGN_WORKSPACE_DELAY_MS = 5 * 1000 // 5 secs
const MAX_ASSIGN_ATTEMPTS = 5
export async function assignBotToWorkspace (workspace: string): Promise<Workspace> {
const token = generateToken(systemAccountEmail, { name: '-' }, { service: 'aibot' })
@@ -102,3 +106,69 @@ export async function getWorkspaceInfo (token: string): Promise<WorkspaceLoginIn
return workspaceInfo.result as WorkspaceLoginInfo
}
async function tryGetWorkspaceInfo (ws: string, ctx: MeasureContext): Promise<WorkspaceLoginInfo | undefined> {
const systemToken = generateToken(systemAccountEmail, { name: ws })
for (let i = 0; i < 5; i++) {
try {
const info = await getWorkspaceInfo(systemToken)
if (info == null) {
await wait(ASSIGN_WORKSPACE_DELAY_MS)
continue
}
return info
} catch (e) {
ctx.error('Error during get workspace info:', { e })
await wait(ASSIGN_WORKSPACE_DELAY_MS)
}
}
}
const timeoutByWorkspace = new Map<string, NodeJS.Timeout>()
const attemptsByWorkspace = new Map<string, number>()
export async function tryAssignToWorkspace (
workspace: string,
ctx: MeasureContext,
clearAttempts = true
): Promise<boolean> {
if (clearAttempts) {
attemptsByWorkspace.delete(workspace)
}
clearTimeout(timeoutByWorkspace.get(workspace))
try {
const info = await tryGetWorkspaceInfo(workspace, ctx)
if (info === undefined) {
return false
}
if (isWorkspaceCreating(info?.mode)) {
const t = setTimeout(() => {
void tryAssignToWorkspace(workspace, ctx, false)
}, ASSIGN_WORKSPACE_DELAY_MS)
timeoutByWorkspace.set(workspace, t)
return false
}
await assignBotToWorkspace(workspace)
ctx.info('Assigned to workspace: ', { workspace })
return true
} catch (e) {
ctx.error('Error during assign workspace:', { e })
const attempts = attemptsByWorkspace.get(workspace) ?? 0
if (attempts < MAX_ASSIGN_ATTEMPTS) {
attemptsByWorkspace.set(workspace, attempts + 1)
const t = setTimeout(() => {
void tryAssignToWorkspace(workspace, ctx, false)
}, ASSIGN_WORKSPACE_DELAY_MS)
timeoutByWorkspace.set(workspace, t)
}
}
return false
}
@@ -0,0 +1,22 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
export async function wait (delay: number): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve()
}, delay)
})
}
@@ -0,0 +1,95 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import OpenAI from 'openai'
import { countTokens } from '@hcengineering/openai'
import { Tiktoken } from 'js-tiktoken'
import config from '../config'
import { HistoryRecord } from '../types'
export async function translateHtml (client: OpenAI, html: string, lang: string): Promise<string | undefined> {
const response = await client.chat.completions.create({
model: config.OpenAITranslateModel,
messages: [
{
role: 'system',
content: `Your task is to translate the text into ${lang} while preserving the html structure and metadata`
},
{
role: 'user',
content: html
}
]
})
return response.choices[0].message.content ?? undefined
}
export async function createChatCompletion (
client: OpenAI,
message: OpenAI.ChatCompletionMessageParam,
user?: string,
history: OpenAI.ChatCompletionMessageParam[] = [],
skipCache = true
): Promise<OpenAI.ChatCompletion | undefined> {
const opt: OpenAI.RequestOptions = {}
if (skipCache) {
opt.headers = { 'cf-skip-cache': 'true' }
}
try {
return await client.chat.completions.create(
{
messages: [...history, message],
model: config.OpenAIModel,
user,
stream: false
},
opt
)
} catch (e) {
console.error(e)
}
return undefined
}
export async function requestSummary (
aiClient: OpenAI,
encoding: Tiktoken,
history: HistoryRecord[]
): Promise<{
summary?: string
tokens: number
}> {
const summaryPrompt: OpenAI.ChatCompletionMessageParam = {
content: `Summarize the following messages, keeping the key points: ${history.map((msg) => `${msg.role}: ${msg.message}`).join('\n')}`,
role: 'user'
}
const response = await createChatCompletion(aiClient, summaryPrompt, undefined, [
{ role: 'system', content: 'Make a summary of messages history' }
])
const summary = response?.choices[0].message.content
if (summary == null) {
return { tokens: 0 }
}
const tokens = response?.usage?.completion_tokens ?? countTokens([{ content: summary, role: 'assistant' }], encoding)
return { summary, tokens }
}
@@ -13,28 +13,16 @@
// limitations under the License.
//
import core, { Account, Ref, TxOperations } from '@hcengineering/core'
import core, { Account, Client, Ref, TxOperations } from '@hcengineering/core'
import { createClient } from '@hcengineering/server-client'
import contact, { PersonAccount } from '@hcengineering/contact'
import aiBot from '@hcengineering/ai-bot'
import { loginBot } from './account'
import chunter, { DirectMessage } from '@hcengineering/chunter'
import aiBot from '@hcengineering/ai-bot'
import { deepEqual } from 'fast-equals'
import notification from '@hcengineering/notification'
import OpenAI from 'openai'
import { countTokens } from '@hcengineering/openai'
import { Tiktoken } from 'js-tiktoken'
import { HistoryRecord } from './types'
import config from './config'
export async function login (): Promise<string | undefined> {
const token = (await loginBot())?.token
if (token !== undefined) {
return token
} else {
return (await loginBot())?.token
}
export async function connectPlatform (token: string, endpoint: string): Promise<Client> {
return await createClient(endpoint, token)
}
export async function getDirect (
@@ -80,59 +68,3 @@ export async function getDirect (
return dmId
}
export async function createChatCompletion (
client: OpenAI,
message: OpenAI.ChatCompletionMessageParam,
user?: string,
history: OpenAI.ChatCompletionMessageParam[] = [],
skipCache = true
): Promise<OpenAI.ChatCompletion | undefined> {
const opt: OpenAI.RequestOptions = {}
if (skipCache) {
opt.headers = { 'cf-skip-cache': 'true' }
}
try {
return await client.chat.completions.create(
{
messages: [...history, message],
model: config.OpenAIModel,
user,
stream: false
},
opt
)
} catch (e) {
console.error(e)
}
return undefined
}
export async function requestSummary (
aiClient: OpenAI,
encoding: Tiktoken,
history: HistoryRecord[]
): Promise<{
summary?: string
tokens: number
}> {
const summaryPrompt: OpenAI.ChatCompletionMessageParam = {
content: `Summarize the following messages, keeping the key points: ${history.map((msg) => `${msg.role}: ${msg.message}`).join('\n')}`,
role: 'user'
}
const response = await createChatCompletion(aiClient, summaryPrompt, undefined, [
{ role: 'system', content: 'Make a summary of messages history' }
])
const summary = response?.choices[0].message.content
if (summary == null) {
return { tokens: 0 }
}
const tokens = response?.usage?.completion_tokens ?? countTokens([{ content: summary, role: 'assistant' }], encoding)
return { summary, tokens }
}
@@ -13,7 +13,7 @@
// limitations under the License.
//
import aiBot, { aiBotAccountEmail, AIBotEvent, AIBotResponseEvent, AIBotTransferEvent } from '@hcengineering/ai-bot'
import aiBot, { aiBotAccountEmail, AIMessageEventRequest, AITransferEventRequest } from '@hcengineering/ai-bot'
import chunter, {
ChatMessage,
type ChatWidgetTab,
@@ -43,7 +43,6 @@ import core, {
Ref,
Space,
Tx,
TxCreateDoc,
TxOperations,
TxProcessor,
TxRemoveDoc
@@ -60,10 +59,11 @@ import analyticsCollector, { OnboardingChannel } from '@hcengineering/analytics-
import workbench, { SidebarEvent, TxSidebarEvent } from '@hcengineering/workbench'
import config from './config'
import { AIBotController } from './controller'
import { connectPlatform } from './platform'
import { AIControl } from './controller'
import { connectPlatform, getDirect } from './utils/platform'
import { HistoryRecord } from './types'
import { createChatCompletion, getDirect, login, requestSummary } from './utils'
import { loginBot } from './utils/account'
import { createChatCompletion, requestSummary } from './utils/openai'
const MAX_LOGIN_DELAY_MS = 15 * 1000 // 15 ses
const UPDATE_TYPING_TIMEOUT_MS = 1000
@@ -95,7 +95,7 @@ export class WorkspaceClient {
readonly transactorUrl: string,
readonly token: string,
readonly workspace: string,
readonly controller: AIBotController,
readonly controller: AIControl,
readonly ctx: MeasureContext,
readonly info: WorkspaceInfoRecord | undefined
) {
@@ -115,9 +115,6 @@ export class WorkspaceClient {
await this.uploadAvatarFile(opClient)
const typing = await opClient.findAll(chunter.class.TypingInfo, { user: aiBot.account.AIBot })
this.typingMap = new Map(typing.map((t) => [t.objectId, t]))
const events = await opClient.findAll(aiBot.class.AIBotEvent, {})
void this.processEvents(events)
this.client.notify = (...txes: Tx[]) => {
void this.txHandler(opClient, txes)
}
@@ -143,8 +140,6 @@ export class WorkspaceClient {
await this.blobClient.upload(this.ctx, config.AvatarName, data.length, config.AvatarContentType, data)
await this.controller.updateAvatarInfo(this.workspace, config.AvatarPath, lastModified)
this.ctx.info('Avatar file uploaded successfully', { workspace: this.workspace, path: config.AvatarPath })
} else {
this.ctx.info('Avatar file already uploaded', { workspace: this.workspace, path: config.AvatarPath })
}
} catch (e) {
this.ctx.error('Failed to upload avatar file', { e })
@@ -155,7 +150,7 @@ export class WorkspaceClient {
private async tryLogin (): Promise<void> {
this.ctx.info('Logging in: ', { workspace: this.workspace })
const token = await login()
const token = (await loginBot())?.token
clearTimeout(this.loginTimeout)
@@ -232,13 +227,13 @@ export class WorkspaceClient {
async createTransferMessage (
client: TxOperations,
event: AIBotTransferEvent,
event: AITransferEventRequest,
_id: Ref<Doc>,
_class: Ref<Class<Doc>>,
space: Ref<Space>,
message: string
): Promise<void> {
const op = client.apply(undefined, 'AIBotTransferEvent')
const op = client.apply(undefined, 'AITransferEventRequest')
if (event.messageClass === chunter.class.ChatMessage) {
await this.startTyping(client, space, _id, _class)
const ref = await op.addCollection<Doc, ChatMessage>(
@@ -249,7 +244,7 @@ export class WorkspaceClient {
event.collection,
{ message },
undefined,
event.modifiedOn
event.createdOn
)
await op.createMixin(ref, chunter.class.ChatMessage, space, aiBot.mixin.TransferredMessage, {
messageId: event.messageId,
@@ -269,7 +264,7 @@ export class WorkspaceClient {
event.collection,
{ message, objectId: parent.attachedTo, objectClass: parent.attachedToClass },
undefined,
event.modifiedOn
event.createdOn
)
await op.createMixin(
ref,
@@ -436,25 +431,27 @@ export class WorkspaceClient {
this.historyMap.set(objectId, currentHistory)
}
async processResponseEvent (event: AIBotResponseEvent): Promise<void> {
async processMessageEvent (event: AIMessageEventRequest): Promise<void> {
if (this.controller.aiClient === undefined) return
const client = await this.opClient
const { user, objectId, objectClass, messageClass } = event
const promptText = markupToText(event.message)
const prompt: OpenAI.ChatCompletionMessageParam = { content: promptText, role: 'user' }
const promptTokens = countTokens([prompt], this.controller.encoding)
if (!this.controller.allowAiReplies(this.workspace, event.email)) {
await client.remove(event)
void this.pushHistory(promptText, 'user', promptTokens, user, objectId, objectClass)
return
}
const client = await this.opClient
const op = client.apply(undefined, 'AIMessageRequestEvent')
const hierarchy = client.getHierarchy()
const op = client.apply(undefined, 'AIBotResponseEvent')
const { user, objectId, objectClass, messageClass } = event
const space = hierarchy.isDerived(objectClass, core.class.Space) ? (objectId as Ref<Space>) : event.objectSpace
await this.startTyping(client, space, objectId, objectClass)
const promptText = markupToText(event.message)
const prompt: OpenAI.ChatCompletionMessageParam = { content: promptText, role: 'user' }
const promptTokens = countTokens([prompt], this.controller.encoding)
const rawHistory = await this.getHistory(objectId)
const history = this.toOpenAiHistory(rawHistory, promptTokens)
@@ -464,10 +461,7 @@ export class WorkspaceClient {
void this.pushHistory(promptText, prompt.role, promptTokens, user, objectId, objectClass)
const start = Date.now()
const chatCompletion = await createChatCompletion(this.controller.aiClient, prompt, user, history)
const end = Date.now()
this.ctx.info('Chat completion time: ', { time: end - start })
const response = chatCompletion?.choices[0].message.content
if (response == null) {
@@ -509,7 +503,6 @@ export class WorkspaceClient {
}
}
await op.remove(event)
await this.finishTyping(op, event.objectId)
await op.commit()
await this.controller.transferAIReplyToSupport(parseResponse, {
@@ -523,14 +516,7 @@ export class WorkspaceClient {
})
}
async processTransferEvent (event: AIBotTransferEvent): Promise<void> {
const client = await this.opClient
await this.controller.transfer(event)
await client.remove(event)
}
async transferToSupport (event: AIBotTransferEvent, channelRef?: Ref<OnboardingChannel>): Promise<void> {
async transferToSupport (event: AITransferEventRequest, channelRef?: Ref<OnboardingChannel>): Promise<void> {
const client = await this.opClient
const key = `${event.toEmail}-${event.fromWorkspace}`
const channel =
@@ -560,7 +546,7 @@ export class WorkspaceClient {
)
}
async transferToUserDirect (event: AIBotTransferEvent): Promise<void> {
async transferToUserDirect (event: AITransferEventRequest): Promise<void> {
const client = await this.opClient
const direct = this.directByEmail.get(event.toEmail) ?? (await getDirect(client, event.toEmail, this.aiAccount))
@@ -579,7 +565,7 @@ export class WorkspaceClient {
return this.channelByKey.get(key)
}
async transfer (event: AIBotTransferEvent): Promise<void> {
async transfer (event: AITransferEventRequest): Promise<void> {
if (event.toWorkspace === config.SupportWorkspace) {
const channel = this.getChannelRef(event.toEmail, event.fromWorkspace)
@@ -603,24 +589,6 @@ export class WorkspaceClient {
}
}
async processEvents (events: AIBotEvent[]): Promise<void> {
if (events.length === 0 || this.opClient === undefined) {
return
}
for (const event of events) {
try {
if (event._class === aiBot.class.AIBotResponseEvent) {
void this.processResponseEvent(event as AIBotResponseEvent)
} else if (event._class === aiBot.class.AIBotTransferEvent) {
void this.processTransferEvent(event as AIBotTransferEvent)
}
} catch (e) {
this.ctx.error('Error processing event: ', { e })
}
}
}
async close (): Promise<void> {
clearTimeout(this.loginTimeout)
@@ -639,16 +607,6 @@ export class WorkspaceClient {
this.ctx.info('Closed workspace client: ', { workspace: this.workspace })
}
private async handleCreateTx (tx: TxCreateDoc<Doc>): Promise<void> {
if (tx.objectClass === aiBot.class.AIBotResponseEvent) {
const doc = TxProcessor.createDoc2Doc(tx as TxCreateDoc<AIBotResponseEvent>)
await this.processResponseEvent(doc)
} else if (tx.objectClass === aiBot.class.AIBotTransferEvent) {
const doc = TxProcessor.createDoc2Doc(tx as TxCreateDoc<AIBotTransferEvent>)
await this.processTransferEvent(doc)
}
}
private async handleRemoveTx (tx: TxRemoveDoc<Doc>): Promise<void> {
if (tx.objectClass === chunter.class.TypingInfo && this.typingMap.has(tx.objectId)) {
this.typingMap.delete(tx.objectId)
@@ -659,9 +617,7 @@ export class WorkspaceClient {
for (const ttx of txes) {
const tx = TxProcessor.extractTx(ttx)
if (tx._class === core.class.TxCreateDoc) {
await this.handleCreateTx(tx as TxCreateDoc<Doc>)
} else if (tx._class === core.class.TxRemoveDoc) {
if (tx._class === core.class.TxRemoveDoc) {
await this.handleRemoveTx(tx as TxRemoveDoc<Doc>)
}
}
@@ -13,62 +13,17 @@
// limitations under the License.
//
import { Token, decodeToken } from '@hcengineering/server-token'
import { Token } from '@hcengineering/server-token'
import cors from 'cors'
import express, { type Express, type NextFunction, type Request, type Response } from 'express'
import { IncomingHttpHeaders, type Server } from 'http'
import { type Server } from 'http'
import { AnalyticEvent } from '@hcengineering/analytics-collector'
import { extractToken } from '@hcengineering/server-client'
import { ApiError } from './error'
import { Collector } from './collector'
import { Action } from './types'
const extractCookieToken = (cookie?: string): Token | null => {
if (cookie === undefined || cookie === null) {
return null
}
const cookies = cookie.split(';')
const tokenCookie = cookies.find((cookie) => cookie.toLocaleLowerCase().includes('token'))
if (tokenCookie === undefined) {
return null
}
const encodedToken = tokenCookie.split('=')[1]
if (encodedToken === undefined) {
return null
}
return decodeToken(encodedToken)
}
const extractAuthorizationToken = (authorization?: string): Token | null => {
if (authorization === undefined || authorization === null) {
return null
}
const encodedToken = authorization.split(' ')[1]
if (encodedToken === undefined) {
return null
}
return decodeToken(encodedToken)
}
const extractToken = (headers: IncomingHttpHeaders): Token => {
try {
const token = extractCookieToken(headers.cookie) ?? extractAuthorizationToken(headers.authorization)
if (token === null) {
throw new ApiError(401)
}
return token
} catch {
throw new ApiError(401)
}
}
type AsyncRequestHandler = (req: Request, res: Response, token: Token, next: NextFunction) => Promise<void>
const handleRequest = async (
@@ -77,8 +32,11 @@ const handleRequest = async (
res: Response,
next: NextFunction
): Promise<void> => {
const token = extractToken(req.headers)
if (token === undefined) {
throw new ApiError(401)
}
try {
const token = extractToken(req.headers)
await fn(req, res, token, next)
} catch (err: unknown) {
next(err)
@@ -13,14 +13,15 @@
// limitations under the License.
//
import { Token, decodeToken } from '@hcengineering/server-token'
import { Token } from '@hcengineering/server-token'
import cors from 'cors'
import express, { type Express, type NextFunction, type Request, type Response } from 'express'
import { IncomingHttpHeaders, type Server } from 'http'
import { type Server } from 'http'
import { MeasureContext } from '@hcengineering/core'
import { Telegraf } from 'telegraf'
import telegram, { TelegramNotificationRequest } from '@hcengineering/telegram'
import { translate } from '@hcengineering/platform'
import { extractToken } from '@hcengineering/server-client'
import { ApiError } from './error'
import { PlatformWorker } from './worker'
@@ -29,52 +30,6 @@ import config from './config'
import { toTelegramHtml, toMediaGroups } from './utils'
import { TgContext } from './telegraf/types'
const extractCookieToken = (cookie?: string): Token | null => {
if (cookie === undefined || cookie === null) {
return null
}
const cookies = cookie.split(';')
const tokenCookie = cookies.find((cookie) => cookie.toLocaleLowerCase().includes('token'))
if (tokenCookie === undefined) {
return null
}
const encodedToken = tokenCookie.split('=')[1]
if (encodedToken === undefined) {
return null
}
return decodeToken(encodedToken)
}
const extractAuthorizationToken = (authorization?: string): Token | null => {
if (authorization === undefined || authorization === null) {
return null
}
const encodedToken = authorization.split(' ')[1]
if (encodedToken === undefined) {
return null
}
return decodeToken(encodedToken)
}
const extractToken = (headers: IncomingHttpHeaders): Token => {
try {
const token = extractCookieToken(headers.cookie) ?? extractAuthorizationToken(headers.authorization)
if (token === null) {
throw new ApiError(401)
}
return token
} catch {
throw new ApiError(401)
}
}
type AsyncRequestHandler = (req: Request, res: Response, token: Token, next: NextFunction) => Promise<void>
const handleRequest = async (
@@ -83,11 +38,14 @@ const handleRequest = async (
res: Response,
next: NextFunction
): Promise<void> => {
const token = extractToken(req.headers)
if (token === undefined) {
throw new ApiError(401)
}
try {
const token = extractToken(req.headers)
await fn(req, res, token, next)
} catch (err: unknown) {
console.error('Error during extract token', err)
console.error(err)
next(err)
}
}
@@ -23,7 +23,9 @@ export class TalentDetailsPage extends CommonRecruitingPage {
readonly buttonFinalContact = (): Locator => this.formMergeContacts().locator('button', { hasText: 'Final contact' })
readonly buttonMergeRow = (): Locator => this.formMergeContacts().locator('div.box')
readonly buttonMergeRow = (hasText: string): Locator =>
this.formMergeContacts().locator('div.box.flex-row-center div.antiRadio', { hasText }).locator('label')
readonly buttonPopupMergeContacts = (): Locator =>
this.formMergeContacts().locator('button:has-text("Merge contacts")')
@@ -66,32 +68,17 @@ export class TalentDetailsPage extends CommonRecruitingPage {
await this.buttonFinalContact().click()
await this.selectMenuItem(this.page, talentName.finalContactName)
await expect(
this.buttonMergeRow().locator('div.flex-center', { hasText: talentName.name }).locator('label.checkbox-container')
).toBeVisible()
await this.buttonMergeRow()
.locator('div.flex-center', { hasText: talentName.name })
.locator('label.checkbox-container')
.click()
await expect(this.buttonMergeRow(talentName.name)).toBeVisible()
await this.buttonMergeRow(talentName.name).click()
if (talentName.mergeLocation) {
await this.buttonMergeRow()
.locator('div.flex-center', { hasText: talentName.location })
.locator('label.checkbox-container')
.click()
await this.buttonMergeRow(talentName.location).click()
}
if (talentName.mergeTitle) {
await this.buttonMergeRow()
.locator('div.flex-center', { hasText: talentName.title })
.locator('label.checkbox-container')
.click()
await this.buttonMergeRow(talentName.title).click()
}
if (talentName.mergeSource) {
await this.buttonMergeRow()
.locator('div.flex-center', { hasText: talentName.source })
.locator('label.checkbox-container')
.click()
await this.buttonMergeRow(talentName.source).click()
}
await this.buttonPopupMergeContacts().click()