mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-12 12:47:45 +02:00
Merge remote-tracking branch 'origin/develop' into staging
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Vendored
+2
-1
@@ -54,7 +54,8 @@
|
||||
"MODEL_VERSION": "0.6.287",
|
||||
// "VERSION": "0.6.289",
|
||||
"ELASTIC_INDEX_NAME": "local_storage_index",
|
||||
"UPLOAD_URL": "/files",
|
||||
"UPLOAD_URL": "/files",
|
||||
"AI_BOT_URL": "http://localhost:4010"
|
||||
},
|
||||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
||||
"runtimeVersion": "20",
|
||||
|
||||
@@ -257,6 +257,7 @@ services:
|
||||
- ELASTIC_INDEX_NAME=local_storage_index
|
||||
- BRANDING_PATH=/var/cfg/branding.json
|
||||
- SUPPORT_WORKSPACE=support
|
||||
- AI_BOT_URL=http://host.docker.internal:4010
|
||||
restart: unless-stopped
|
||||
transactor_pg:
|
||||
image: hardcoreeng/transactor
|
||||
@@ -384,6 +385,7 @@ services:
|
||||
- AVATAR_PATH=./avatar.png
|
||||
- AVATAR_CONTENT_TYPE=.png
|
||||
- STATS_URL=http://host.docker.internal:4900
|
||||
# - OPENAI_API_KEY=token
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>>
|
||||
},
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { Account, Class, Doc, Markup, Ref, Space, Timestamp } from '@hcengineering/core'
|
||||
import { ChatMessage } from '@hcengineering/chunter'
|
||||
|
||||
export enum AIEventType {
|
||||
Message = 'message',
|
||||
Transfer = 'transfer'
|
||||
}
|
||||
|
||||
export interface AIEventRequest {
|
||||
type: AIEventType
|
||||
collection: string
|
||||
messageClass: Ref<Class<ChatMessage>>
|
||||
messageId: Ref<ChatMessage>
|
||||
message: string
|
||||
createdOn: Timestamp
|
||||
}
|
||||
|
||||
export interface AIMessageEventRequest extends AIEventRequest {
|
||||
objectId: Ref<Doc>
|
||||
objectClass: Ref<Class<Doc>>
|
||||
objectSpace: Ref<Space>
|
||||
user: Ref<Account>
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface AITransferEventRequest extends AIEventRequest {
|
||||
toEmail: string
|
||||
toWorkspace: string
|
||||
fromWorkspace: string
|
||||
fromWorkspaceName: string
|
||||
fromWorkspaceUrl: string
|
||||
parentMessageId?: Ref<ChatMessage>
|
||||
}
|
||||
|
||||
export interface TranslateRequest {
|
||||
text: Markup
|
||||
lang: string
|
||||
}
|
||||
|
||||
export interface TranslateResponse {
|
||||
text: Markup
|
||||
lang: string
|
||||
}
|
||||
@@ -13,18 +13,6 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { Markup } from '@hcengineering/core'
|
||||
|
||||
export interface TranslateRequest {
|
||||
text: Markup
|
||||
lang: string
|
||||
}
|
||||
|
||||
export interface TranslateResponse {
|
||||
text: Markup
|
||||
lang: string
|
||||
}
|
||||
|
||||
export enum OnboardingEvent {
|
||||
OpenChatInSidebar = 'openChatInSidebar'
|
||||
}
|
||||
|
||||
@@ -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">>></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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
"NewMessage": "Новое сообщение",
|
||||
"NewIncomingMessage": "Прислал новое сообщение",
|
||||
"Telegram": "Telegram",
|
||||
"TelegramIntegrationDesc": "Подключить Telegram",
|
||||
"TelegramIntegrationDesc": "☠\uFE0F Экспериментальная интеграция с Telegram ☠\uFE0F",
|
||||
"TelegramIntegrationDesc2": "Эта функция независима от обычных уведомлений Telegram",
|
||||
"ToSetupNotification": "Для настройки уведомлений:",
|
||||
"TelegramNotificationPath": "Уведомления > Основное > Telegram > Настроить",
|
||||
"Status": "Статус",
|
||||
"MessagesSelected": "сообщений выбрано",
|
||||
"ConfigLabel": "Telegram",
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"@hcengineering/server-telegram": "^0.6.0",
|
||||
"@hcengineering/pod-telegram-bot": "^0.6.0",
|
||||
"@hcengineering/server-ai-bot": "^0.6.0",
|
||||
"@hcengineering/server-ai-bot-resources": "^0.6.0",
|
||||
"ws": "^8.18.0",
|
||||
"bufferutil": "^4.0.8",
|
||||
"msgpackr": "^1.11.0",
|
||||
|
||||
@@ -72,6 +72,7 @@ setMetadata(serverCore.metadata.ElasticIndexName, config.elasticIndexName)
|
||||
setMetadata(serverCore.metadata.ElasticIndexVersion, 'v1')
|
||||
setMetadata(serverTelegram.metadata.BotUrl, process.env.TELEGRAM_BOT_URL)
|
||||
setMetadata(serverAiBot.metadata.SupportWorkspaceId, process.env.SUPPORT_WORKSPACE)
|
||||
setMetadata(serverAiBot.metadata.EndpointURL, process.env.AI_BOT_URL)
|
||||
|
||||
const { shutdown, sessionManager } = start(metricsContext, config.dbUrl, {
|
||||
fullTextUrl: config.elasticUrl,
|
||||
|
||||
@@ -33,8 +33,6 @@ import {
|
||||
} from '@hcengineering/server-core'
|
||||
import { type Token } from '@hcengineering/server-token'
|
||||
|
||||
import { serverAiBotId } from '@hcengineering/server-ai-bot'
|
||||
import { createAIBotAdapter } from '@hcengineering/server-ai-bot-resources'
|
||||
import { createServerPipeline, registerServerPlugins, registerStringLoaders } from '@hcengineering/server-pipeline'
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
@@ -80,17 +78,7 @@ export function start (
|
||||
dbUrl,
|
||||
model,
|
||||
{ ...opt, externalStorage, adapterSecurity: dbUrl.startsWith('postgresql') },
|
||||
opt.mongoUrl !== undefined
|
||||
? {
|
||||
serviceAdapters: {
|
||||
[serverAiBotId]: {
|
||||
factory: createAIBotAdapter,
|
||||
db: '%ai-bot',
|
||||
url: opt.mongoUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
: {}
|
||||
{}
|
||||
)
|
||||
const sessionFactory = (
|
||||
token: Token,
|
||||
|
||||
@@ -37,19 +37,18 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hcengineering/activity": "^0.6.0",
|
||||
"@hcengineering/ai-bot": "^0.6.0",
|
||||
"@hcengineering/analytics-collector": "^0.6.0",
|
||||
"@hcengineering/chunter": "^0.6.20",
|
||||
"@hcengineering/contact": "^0.6.24",
|
||||
"@hcengineering/core": "^0.6.32",
|
||||
"@hcengineering/mongo": "^0.6.1",
|
||||
"@hcengineering/notification": "^0.6.23",
|
||||
"@hcengineering/platform": "^0.6.11",
|
||||
"@hcengineering/server-activity-resources": "^0.6.0",
|
||||
"@hcengineering/server-core": "^0.6.1",
|
||||
"@hcengineering/server-templates": "^0.6.0",
|
||||
"@hcengineering/templates": "^0.6.11",
|
||||
"@hcengineering/ai-bot": "^0.6.0",
|
||||
"@hcengineering/server-ai-bot": "^0.6.0",
|
||||
"@hcengineering/analytics-collector": "^0.6.0",
|
||||
"mongodb": "6.9.0-dev.20241016.sha.3d5bd513"
|
||||
"@hcengineering/server-core": "^0.6.1",
|
||||
"@hcengineering/server-token": "^0.6.11",
|
||||
"@hcengineering/server-templates": "^0.6.0",
|
||||
"@hcengineering/templates": "^0.6.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { MeasureContext, WorkspaceId } from '@hcengineering/core'
|
||||
import { getMongoClient, MongoClientReference } from '@hcengineering/mongo'
|
||||
import { AIBotServiceAdapter, WorkspaceInfoRecord } from '@hcengineering/server-ai-bot'
|
||||
import { Collection, Db, MongoClient } from 'mongodb'
|
||||
|
||||
class AIBotAdapter implements AIBotServiceAdapter {
|
||||
private readonly workspacesInfoCollection: Collection<WorkspaceInfoRecord>
|
||||
private readonly db: Db
|
||||
closed = false
|
||||
|
||||
constructor (
|
||||
private readonly _client: MongoClientReference,
|
||||
private readonly client: MongoClient,
|
||||
private readonly _metrics: MeasureContext,
|
||||
private readonly dbName: string
|
||||
) {
|
||||
this.db = client.db(dbName)
|
||||
this.workspacesInfoCollection = this.db.collection<WorkspaceInfoRecord>('workspacesInfo')
|
||||
}
|
||||
|
||||
async processWorkspace (workspace: WorkspaceId): Promise<void> {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
const existsRecord = await this.workspacesInfoCollection.findOne({
|
||||
workspace: workspace.name
|
||||
})
|
||||
|
||||
if (existsRecord != null && !existsRecord.active) {
|
||||
await this.workspacesInfoCollection.updateOne({ workspace: workspace.name }, { $set: { active: true } })
|
||||
} else if (existsRecord == null) {
|
||||
const record: WorkspaceInfoRecord = {
|
||||
workspace: workspace.name,
|
||||
active: true
|
||||
}
|
||||
|
||||
await this.workspacesInfoCollection.insertOne(record)
|
||||
}
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
this.closed = true
|
||||
this._client.close()
|
||||
}
|
||||
|
||||
metrics (): MeasureContext {
|
||||
return this._metrics
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAIBotAdapter (url: string, db: string, metrics: MeasureContext): Promise<any> {
|
||||
const _client = getMongoClient(url)
|
||||
|
||||
return new AIBotAdapter(_client, await _client.getClient(), metrics, db)
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
import core, {
|
||||
AccountRole,
|
||||
AttachedDoc,
|
||||
Data,
|
||||
Doc,
|
||||
Ref,
|
||||
toWorkspaceString,
|
||||
@@ -30,22 +29,17 @@ import core, {
|
||||
} from '@hcengineering/core'
|
||||
import { TriggerControl } from '@hcengineering/server-core'
|
||||
import chunter, { ChatMessage, DirectMessage, ThreadMessage } from '@hcengineering/chunter'
|
||||
import aiBot, { aiBotAccountEmail, AIBotResponseEvent } from '@hcengineering/ai-bot'
|
||||
import { AIBotServiceAdapter, serverAiBotId } from '@hcengineering/server-ai-bot'
|
||||
import aiBot, {
|
||||
aiBotAccountEmail,
|
||||
AIEventType,
|
||||
AIMessageEventRequest,
|
||||
AITransferEventRequest
|
||||
} from '@hcengineering/ai-bot'
|
||||
import contact, { PersonAccount } from '@hcengineering/contact'
|
||||
import { ActivityInboxNotification, MentionInboxNotification } from '@hcengineering/notification'
|
||||
import analyticsCollector, { OnboardingChannel } from '@hcengineering/analytics-collector'
|
||||
import { getSupportWorkspaceId } from './utils'
|
||||
|
||||
async function processWorkspace (control: TriggerControl): Promise<void> {
|
||||
const adapter = control.serviceAdaptersManager.getAdapter(serverAiBotId) as AIBotServiceAdapter | undefined
|
||||
|
||||
if (adapter !== undefined) {
|
||||
await adapter.processWorkspace(control.workspace)
|
||||
} else {
|
||||
console.error('Cannot find server adapter: ', serverAiBotId)
|
||||
}
|
||||
}
|
||||
import { createAccountRequest, getSupportWorkspaceId, sendAIEvents } from './utils'
|
||||
|
||||
async function isDirectAvailable (direct: DirectMessage, control: TriggerControl): Promise<boolean> {
|
||||
const { members } = direct
|
||||
@@ -77,8 +71,10 @@ async function getMessageDoc (message: ChatMessage, control: TriggerControl): Pr
|
||||
}
|
||||
}
|
||||
|
||||
function getMessageData (doc: Doc, message: ChatMessage, email: string): Data<AIBotResponseEvent> {
|
||||
function getMessageData (doc: Doc, message: ChatMessage, email: string): AIMessageEventRequest {
|
||||
return {
|
||||
type: AIEventType.Message,
|
||||
createdOn: message.createdOn ?? message.modifiedOn,
|
||||
objectId: message.attachedTo,
|
||||
objectClass: message.attachedToClass,
|
||||
objectSpace: doc.space,
|
||||
@@ -91,8 +87,10 @@ function getMessageData (doc: Doc, message: ChatMessage, email: string): Data<AI
|
||||
}
|
||||
}
|
||||
|
||||
function getThreadMessageData (message: ThreadMessage, email: string): Data<AIBotResponseEvent> {
|
||||
function getThreadMessageData (message: ThreadMessage, email: string): AIMessageEventRequest {
|
||||
return {
|
||||
type: AIEventType.Message,
|
||||
createdOn: message.createdOn ?? message.modifiedOn,
|
||||
objectId: message.attachedTo,
|
||||
objectClass: message.attachedToClass,
|
||||
objectSpace: message.space,
|
||||
@@ -105,15 +103,6 @@ function getThreadMessageData (message: ThreadMessage, email: string): Data<AIBo
|
||||
}
|
||||
}
|
||||
|
||||
async function createResponseEvent (
|
||||
message: ChatMessage,
|
||||
control: TriggerControl,
|
||||
data: Data<AIBotResponseEvent>
|
||||
): Promise<void> {
|
||||
const eventTx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotResponseEvent, message.space, data)
|
||||
await control.apply(control.ctx, [eventTx])
|
||||
}
|
||||
|
||||
async function getThreadParent (control: TriggerControl, message: ChatMessage): Promise<Ref<ChatMessage> | undefined> {
|
||||
if (!control.hierarchy.isDerived(message.attachedToClass, chunter.class.ChatMessage)) {
|
||||
return undefined
|
||||
@@ -137,8 +126,8 @@ async function createTransferEvent (
|
||||
control: TriggerControl,
|
||||
message: ChatMessage,
|
||||
account: PersonAccount,
|
||||
data: Data<AIBotResponseEvent>
|
||||
): Promise<void> {
|
||||
data: AIMessageEventRequest
|
||||
): Promise<AITransferEventRequest | undefined> {
|
||||
if (account.role !== AccountRole.Owner) {
|
||||
return
|
||||
}
|
||||
@@ -149,7 +138,9 @@ async function createTransferEvent (
|
||||
return
|
||||
}
|
||||
|
||||
const eventTx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotTransferEvent, message.space, {
|
||||
return {
|
||||
type: AIEventType.Transfer,
|
||||
createdOn: message.createdOn ?? message.modifiedOn,
|
||||
messageClass: data.messageClass,
|
||||
message: message.message,
|
||||
collection: data.collection,
|
||||
@@ -160,9 +151,7 @@ async function createTransferEvent (
|
||||
fromWorkspaceUrl: control.workspace.workspaceUrl,
|
||||
messageId: message._id,
|
||||
parentMessageId: await getThreadParent(control, message)
|
||||
})
|
||||
|
||||
await control.apply(control.ctx, [eventTx])
|
||||
}
|
||||
}
|
||||
|
||||
async function onBotDirectMessageSend (control: TriggerControl, message: ChatMessage): Promise<void> {
|
||||
@@ -186,18 +175,18 @@ async function onBotDirectMessageSend (control: TriggerControl, message: ChatMes
|
||||
return
|
||||
}
|
||||
|
||||
let data: Data<AIBotResponseEvent> | undefined
|
||||
let messageEvent: AIMessageEventRequest
|
||||
|
||||
if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) {
|
||||
data = getThreadMessageData(message as ThreadMessage, account.email)
|
||||
messageEvent = getThreadMessageData(message as ThreadMessage, account.email)
|
||||
} else {
|
||||
data = getMessageData(direct, message, account.email)
|
||||
messageEvent = getMessageData(direct, message, account.email)
|
||||
}
|
||||
|
||||
await createResponseEvent(message, control, data)
|
||||
await createTransferEvent(control, message, account, data)
|
||||
const transferEvent = await createTransferEvent(control, message, account, messageEvent)
|
||||
const events = transferEvent !== undefined ? [messageEvent, transferEvent] : [messageEvent]
|
||||
|
||||
await processWorkspace(control)
|
||||
await sendAIEvents(events, control.workspace, control.ctx)
|
||||
}
|
||||
|
||||
async function onSupportWorkspaceMessage (control: TriggerControl, message: ChatMessage): Promise<void> {
|
||||
@@ -222,18 +211,20 @@ async function onSupportWorkspaceMessage (control: TriggerControl, message: Chat
|
||||
}
|
||||
|
||||
const { workspaceId, email } = channel
|
||||
let data: Data<AIBotResponseEvent> | undefined
|
||||
const account = control.modelDb.findAllSync(contact.class.PersonAccount, {
|
||||
_id: (message.createdBy ?? message.modifiedBy) as Ref<PersonAccount>
|
||||
})[0]
|
||||
|
||||
let data: AIMessageEventRequest
|
||||
if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) {
|
||||
data = getThreadMessageData(message as ThreadMessage, account.email)
|
||||
} else {
|
||||
data = getMessageData(channel, message, account.email)
|
||||
}
|
||||
|
||||
const tx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotTransferEvent, message.space, {
|
||||
const transferEvent: AITransferEventRequest = {
|
||||
type: AIEventType.Transfer,
|
||||
createdOn: data.createdOn,
|
||||
messageClass: data.messageClass,
|
||||
message: message.message,
|
||||
collection: data.collection,
|
||||
@@ -244,11 +235,9 @@ async function onSupportWorkspaceMessage (control: TriggerControl, message: Chat
|
||||
fromWorkspaceName: control.workspace.workspaceName,
|
||||
messageId: message._id,
|
||||
parentMessageId: await getThreadParent(control, message)
|
||||
})
|
||||
}
|
||||
|
||||
await control.apply(control.ctx, [tx])
|
||||
|
||||
await processWorkspace(control)
|
||||
await sendAIEvents([transferEvent], control.workspace, control.ctx)
|
||||
}
|
||||
|
||||
export async function OnMessageSend (
|
||||
@@ -407,7 +396,7 @@ export async function OnUserStatus (originTx: Tx, control: TriggerControl): Prom
|
||||
return []
|
||||
}
|
||||
|
||||
await processWorkspace(control)
|
||||
await createAccountRequest(control.workspace, control.ctx)
|
||||
|
||||
return []
|
||||
}
|
||||
@@ -421,5 +410,3 @@ export default async () => ({
|
||||
OnUserStatus
|
||||
}
|
||||
})
|
||||
|
||||
export * from './adapter'
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import serverAIBot from '@hcengineering/server-ai-bot'
|
||||
import { AIEventRequest } from '@hcengineering/ai-bot'
|
||||
import { concatLink, MeasureContext, systemAccountEmail, WorkspaceId } from '@hcengineering/core'
|
||||
import { generateToken } from '@hcengineering/server-token'
|
||||
|
||||
export function getSupportWorkspaceId (): string | undefined {
|
||||
const supportWorkspaceId = getMetadata(serverAIBot.metadata.SupportWorkspaceId)
|
||||
@@ -25,3 +28,49 @@ export function getSupportWorkspaceId (): string | undefined {
|
||||
|
||||
return supportWorkspaceId
|
||||
}
|
||||
|
||||
export async function sendAIEvents (
|
||||
events: AIEventRequest[],
|
||||
workspace: WorkspaceId,
|
||||
ctx: MeasureContext
|
||||
): Promise<void> {
|
||||
const url = getMetadata(serverAIBot.metadata.EndpointURL) ?? ''
|
||||
|
||||
if (url === '') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(concatLink(url, '/events'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + generateToken(systemAccountEmail, workspace),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(events)
|
||||
})
|
||||
} catch (err) {
|
||||
ctx.error('Could not send ai events', { err })
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAccountRequest (workspace: WorkspaceId, ctx: MeasureContext): Promise<void> {
|
||||
const url = getMetadata(serverAIBot.metadata.EndpointURL) ?? ''
|
||||
|
||||
if (url === '') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(concatLink(url, '/connect'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + generateToken(systemAccountEmail, workspace),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
} catch (err) {
|
||||
ctx.error('Could not send create ai account request', { err })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ export const serverAiBotId = 'server-ai-bot' as Plugin
|
||||
|
||||
export default plugin(serverAiBotId, {
|
||||
metadata: {
|
||||
SupportWorkspaceId: '' as Metadata<string>
|
||||
SupportWorkspaceId: '' as Metadata<string>,
|
||||
EndpointURL: '' as Metadata<string>
|
||||
},
|
||||
trigger: {
|
||||
OnMessageSend: '' as Resource<TriggerFunc>,
|
||||
|
||||
@@ -13,16 +13,8 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { ServiceAdapter } from '@hcengineering/server-core'
|
||||
import { WorkspaceId } from '@hcengineering/core'
|
||||
|
||||
export interface AIBotServiceAdapter extends ServiceAdapter {
|
||||
processWorkspace: (workspace: WorkspaceId) => Promise<void>
|
||||
}
|
||||
|
||||
export interface WorkspaceInfoRecord {
|
||||
workspace: string
|
||||
active: boolean
|
||||
avatarPath?: string
|
||||
avatarLastModified?: number
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"@hcengineering/client-resources": "^0.6.27",
|
||||
"@hcengineering/client": "^0.6.18",
|
||||
"@hcengineering/server-core": "^0.6.1",
|
||||
"@hcengineering/server-token": "^0.6.11",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,4 @@ export default plugin
|
||||
export * from './account'
|
||||
export * from './blob'
|
||||
export * from './client'
|
||||
export * from './token'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -160,8 +160,6 @@ export interface PipelineContext {
|
||||
head?: Middleware
|
||||
|
||||
broadcastEvent?: (ctx: MeasureContext, tx: Tx[]) => Promise<void>
|
||||
|
||||
endContext?: (ctx: MeasureContext) => Promise<void>
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+36
-52
@@ -13,60 +13,15 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { Token, decodeToken } from '@hcengineering/server-token'
|
||||
import { Token } from '@hcengineering/server-token'
|
||||
import cors from 'cors'
|
||||
import express, { type Express, type NextFunction, type Request, type Response } from 'express'
|
||||
import { IncomingHttpHeaders, type Server } from 'http'
|
||||
import { TranslateRequest, OnboardingEventRequest } from '@hcengineering/ai-bot'
|
||||
import { type Server } from 'http'
|
||||
import { TranslateRequest, OnboardingEventRequest, AIEventRequest } from '@hcengineering/ai-bot'
|
||||
import { extractToken } from '@hcengineering/server-client'
|
||||
|
||||
import { ApiError } from './error'
|
||||
import { AIBotController } from './controller'
|
||||
|
||||
const extractCookieToken = (cookie?: string): Token | null => {
|
||||
if (cookie === undefined || cookie === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cookies = cookie.split(';')
|
||||
const tokenCookie = cookies.find((cookie) => cookie.toLocaleLowerCase().includes('token'))
|
||||
if (tokenCookie === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const encodedToken = tokenCookie.split('=')[1]
|
||||
if (encodedToken === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
return decodeToken(encodedToken)
|
||||
}
|
||||
|
||||
const extractAuthorizationToken = (authorization?: string): Token | null => {
|
||||
if (authorization === undefined || authorization === null) {
|
||||
return null
|
||||
}
|
||||
const encodedToken = authorization.split(' ')[1]
|
||||
|
||||
if (encodedToken === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
return decodeToken(encodedToken)
|
||||
}
|
||||
|
||||
const extractToken = (headers: IncomingHttpHeaders): Token => {
|
||||
try {
|
||||
const token = extractCookieToken(headers.cookie) ?? extractAuthorizationToken(headers.authorization)
|
||||
|
||||
if (token === null) {
|
||||
throw new ApiError(401)
|
||||
}
|
||||
|
||||
return token
|
||||
} catch {
|
||||
throw new ApiError(401)
|
||||
}
|
||||
}
|
||||
import { AIControl } from '../controller'
|
||||
|
||||
type AsyncRequestHandler = (req: Request, res: Response, token: Token, next: NextFunction) => Promise<void>
|
||||
|
||||
@@ -76,8 +31,11 @@ const handleRequest = async (
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
const token = extractToken(req.headers)
|
||||
if (token === undefined) {
|
||||
throw new ApiError(401)
|
||||
}
|
||||
try {
|
||||
const token = extractToken(req.headers)
|
||||
await fn(req, res, token, next)
|
||||
} catch (err: unknown) {
|
||||
next(err)
|
||||
@@ -88,7 +46,7 @@ const wrapRequest = (fn: AsyncRequestHandler) => (req: Request, res: Response, n
|
||||
void handleRequest(fn, req, res, next)
|
||||
}
|
||||
|
||||
export function createServer (controller: AIBotController): Express {
|
||||
export function createServer (controller: AIControl): Express {
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
@@ -109,6 +67,32 @@ export function createServer (controller: AIBotController): Express {
|
||||
})
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/connect',
|
||||
wrapRequest(async (_, res, token) => {
|
||||
await controller.connect(token.workspace.name)
|
||||
|
||||
res.status(200)
|
||||
res.json({})
|
||||
})
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/events',
|
||||
wrapRequest(async (req, res, token) => {
|
||||
if (req.body == null) {
|
||||
throw new ApiError(400)
|
||||
}
|
||||
|
||||
const events = Array.isArray(req.body) ? req.body : [req.body]
|
||||
|
||||
await controller.processEvent(token.workspace.name, events as AIEventRequest[])
|
||||
|
||||
res.status(200)
|
||||
res.json({})
|
||||
})
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/onboarding',
|
||||
wrapRequest(async (req, res) => {
|
||||
@@ -17,14 +17,14 @@ import { setMetadata } from '@hcengineering/platform'
|
||||
import serverAiBot from '@hcengineering/server-ai-bot'
|
||||
import serverClient from '@hcengineering/server-client'
|
||||
import serverToken from '@hcengineering/server-token'
|
||||
|
||||
import { initStatisticsContext } from '@hcengineering/server-core'
|
||||
import { createBotAccount } from './account'
|
||||
|
||||
import config from './config'
|
||||
import { AIBotController } from './controller'
|
||||
import { AIControl } from './controller'
|
||||
import { registerLoaders } from './loaders'
|
||||
import { createServer, listen } from './server'
|
||||
import { closeDB, DbStorage, getDB } from './storage'
|
||||
import { createBotAccount } from './utils/account'
|
||||
import { createServer, listen } from './server/server'
|
||||
|
||||
export const start = async (): Promise<void> => {
|
||||
setMetadata(serverToken.metadata.Secret, config.ServerSecret)
|
||||
@@ -49,7 +49,7 @@ export const start = async (): Promise<void> => {
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
}
|
||||
const aiController = new AIBotController(storage, ctx)
|
||||
const aiController = new AIControl(storage, ctx)
|
||||
const app = createServer(aiController)
|
||||
const server = listen(app, config.Port)
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
|
||||
import { MongoClientReference, getMongoClient } from '@hcengineering/mongo'
|
||||
import { Collection, Db, MongoClient, ObjectId, UpdateFilter, WithId } from 'mongodb'
|
||||
import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot'
|
||||
import { Doc, Ref, SortingOrder } from '@hcengineering/core'
|
||||
import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot'
|
||||
|
||||
import config from './config'
|
||||
import { HistoryRecord } from './types'
|
||||
@@ -61,18 +61,14 @@ export class DbStorage {
|
||||
await this.historyCollection.deleteMany({ _id: { $in: _ids } })
|
||||
}
|
||||
|
||||
async getActiveWorkspaces (): Promise<WorkspaceInfoRecord[]> {
|
||||
return await this.workspacesInfoCollection.find({ active: true }).toArray()
|
||||
}
|
||||
|
||||
async inactiveWorkspace (workspace: string): Promise<void> {
|
||||
await this.workspacesInfoCollection.updateOne({ workspace }, { $set: { active: false } })
|
||||
}
|
||||
|
||||
async getWorkspace (workspace: string): Promise<WorkspaceInfoRecord | undefined> {
|
||||
return (await this.workspacesInfoCollection.findOne({ workspace })) ?? undefined
|
||||
}
|
||||
|
||||
async addWorkspace (record: WorkspaceInfoRecord): Promise<void> {
|
||||
await this.workspacesInfoCollection.insertOne(record)
|
||||
}
|
||||
|
||||
async updateWorkspace (workspace: string, update: UpdateFilter<WorkspaceInfoRecord>): Promise<void> {
|
||||
await this.workspacesInfoCollection.updateOne({ workspace }, update)
|
||||
}
|
||||
|
||||
+72
-2
@@ -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 }
|
||||
}
|
||||
+5
-73
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user