mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-29 19:56:18 +00:00
Merge remote-tracking branch 'origin/develop' into staging
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
commit
5bf3fc0176
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@ -584,7 +584,8 @@
|
||||
"PASSWORD": "password",
|
||||
"AVATAR_PATH": "./assets/avatar.png",
|
||||
"AVATAR_CONTENT_TYPE": ".png",
|
||||
"LOVE_ENDPOINT": "http://localhost:8096"
|
||||
"STORAGE_CONFIG": "minio|localhost?accessKey=minioadmin&secretKey=minioadmin",
|
||||
"LOVE_ENDPOINT": "http://localhost:8096",
|
||||
},
|
||||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
||||
"sourceMaps": true,
|
||||
|
@ -238,8 +238,8 @@
|
||||
"summary": "Build docker with platform",
|
||||
"description": "use to build all docker containers required for platform",
|
||||
"safeForSimultaneousRushProcesses": true,
|
||||
"shellCommand": "rush docker:build -p 20 --to @hcengineering/pod-server --to @hcengineering/pod-front --to @hcengineering/prod --to @hcengineering/pod-account --to @hcengineering/pod-workspace --to @hcengineering/pod-collaborator --to @hcengineering/tool --to @hcengineering/pod-print --to @hcengineering/pod-sign --to @hcengineering/pod-analytics-collector --to @hcengineering/rekoni-service --to @hcengineering/pod-ai-bot --to @hcengineering/import-tool --to @hcengineering/pod-stats --to @hcengineering/pod-fulltext"
|
||||
},
|
||||
"shellCommand": "rush docker:build -p 20 --to @hcengineering/pod-server --to @hcengineering/pod-front --to @hcengineering/prod --to @hcengineering/pod-account --to @hcengineering/pod-workspace --to @hcengineering/pod-collaborator --to @hcengineering/tool --to @hcengineering/pod-print --to @hcengineering/pod-sign --to @hcengineering/pod-analytics-collector --to @hcengineering/rekoni-service --to @hcengineering/pod-ai-bot --to @hcengineering/import-tool --to @hcengineering/pod-stats --to @hcengineering/pod-fulltext --to @hcengineering/pod-love"
|
||||
},
|
||||
{
|
||||
"commandKind": "global",
|
||||
"name": "docker:up",
|
||||
|
@ -1235,6 +1235,9 @@ dependencies:
|
||||
'@tiptap/extension-task-list':
|
||||
specifier: ^2.6.6
|
||||
version: 2.6.6(@tiptap/core@2.6.6)
|
||||
'@tiptap/extension-text-align':
|
||||
specifier: ~2.11.0
|
||||
version: 2.11.0(@tiptap/core@2.6.6)
|
||||
'@tiptap/extension-typography':
|
||||
specifier: ^2.6.6
|
||||
version: 2.6.6(@tiptap/core@2.6.6)
|
||||
@ -1634,9 +1637,6 @@ dependencies:
|
||||
googleapis:
|
||||
specifier: ^122.0.0
|
||||
version: 122.0.0
|
||||
got:
|
||||
specifier: ^11.8.3
|
||||
version: 11.8.6
|
||||
graphql:
|
||||
specifier: ^16.8.0
|
||||
version: 16.9.0
|
||||
@ -6252,6 +6252,14 @@ packages:
|
||||
'@tiptap/core': 2.6.6(@tiptap/pm@2.6.6)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-text-align@2.11.0(@tiptap/core@2.6.6):
|
||||
resolution: {integrity: sha512-VRXBqO17po6ddqhoWLBa2aCX/tqHdzdKPLfjnBy1fF8hjQKbidzjMWhb4CMm31ApvJjKK/DTkM3EnyYS/XDhng==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
dependencies:
|
||||
'@tiptap/core': 2.6.6(@tiptap/pm@2.6.6)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-text@2.6.6(@tiptap/core@2.6.6):
|
||||
resolution: {integrity: sha512-e84uILnRzNzcwK1DVQNpXVmBG1Cq3BJipTOIDl1LHifOok7MBjhI/X+/NR0bd3N2t6gmDTWi63+4GuJ5EeDmsg==}
|
||||
peerDependencies:
|
||||
@ -24760,7 +24768,7 @@ packages:
|
||||
dev: false
|
||||
|
||||
file:projects/importer.tgz(esbuild@0.24.2)(ts-node@10.9.2):
|
||||
resolution: {integrity: sha512-2BSUFKldNxsA3oJ/xjVKMJc975re0WnY1T24b0yscLWNGfMchaPaHNLBMIpd3d4rXywTX0qqBNPt+aWkp2XVBA==, tarball: file:projects/importer.tgz}
|
||||
resolution: {integrity: sha512-nd4QEoFM7LFj37X/9PCtKl2HTaQl3xnpCbJL+FBuYPJhimHzG4KTvb3E5vZ31OZxgAzYBBLZb1KsswqqlXAJ9A==, tarball: file:projects/importer.tgz}
|
||||
id: file:projects/importer.tgz
|
||||
name: '@rush-temp/importer'
|
||||
version: 0.0.0
|
||||
@ -27290,7 +27298,7 @@ packages:
|
||||
dev: false
|
||||
|
||||
file:projects/pod-ai-bot.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4)(zod@3.23.8):
|
||||
resolution: {integrity: sha512-zCUFPyseaS/JuRQ5JMtR1n8ybdRgxQzRn2aP528X1x+jOBVEH2L7VjDGBx1jsJGuUKvCJBCoOAQlCHDgfRambQ==, tarball: file:projects/pod-ai-bot.tgz}
|
||||
resolution: {integrity: sha512-OPo+KhRKsPQhO1eqOIgL30ef9s/TAnEf21bAwzxLLcB70AIwMyjfPRzzJHDOIbVjCzA8t4YFQ5MWWoLUveylFg==, tarball: file:projects/pod-ai-bot.tgz}
|
||||
id: file:projects/pod-ai-bot.tgz
|
||||
name: '@rush-temp/pod-ai-bot'
|
||||
version: 0.0.0
|
||||
@ -27301,6 +27309,7 @@ packages:
|
||||
'@types/jest': 29.5.12
|
||||
'@types/node': 20.11.19
|
||||
'@types/node-fetch': 2.6.11
|
||||
'@types/uuid': 8.3.4
|
||||
'@types/ws': 8.5.11
|
||||
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3)
|
||||
'@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3)
|
||||
@ -27325,6 +27334,7 @@ packages:
|
||||
ts-jest: 29.1.2(esbuild@0.24.2)(jest@29.7.0)(typescript@5.3.3)
|
||||
ts-node: 10.9.2(@types/node@20.11.19)(typescript@5.3.3)
|
||||
typescript: 5.3.3
|
||||
uuid: 8.3.2
|
||||
ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4)
|
||||
transitivePeerDependencies:
|
||||
- '@aws-sdk/credential-providers'
|
||||
@ -27401,7 +27411,7 @@ packages:
|
||||
dev: false
|
||||
|
||||
file:projects/pod-backup.tgz:
|
||||
resolution: {integrity: sha512-f8l7TT88HfNQ8lRgFe4lA5Zbzb3nPF+9dBmaOAd1SFLWAnbp959dyN4CxGPWQDu4VeQ50vKe3wg7FxoYpgQRyg==, tarball: file:projects/pod-backup.tgz}
|
||||
resolution: {integrity: sha512-Ccg90DAJu+vNRMm00Z8W768WT8M6AKzjUFktH9/RBbDGQSF+UYQNm7Ah/cMJnJ0GMOg/dQ//r7Tob7WL4kaBeg==, tarball: file:projects/pod-backup.tgz}
|
||||
name: '@rush-temp/pod-backup'
|
||||
version: 0.0.0
|
||||
dependencies:
|
||||
@ -27895,7 +27905,7 @@ packages:
|
||||
dev: false
|
||||
|
||||
file:projects/pod-ses.tgz:
|
||||
resolution: {integrity: sha512-Z+IlSWDXbE1r5gAGoFhvdyRXh8qZUlANdrO4jWmT0HuDHWeDibkjK5/YXnqztHDmMbz3QI60RotnYOdJwkqSLA==, tarball: file:projects/pod-ses.tgz}
|
||||
resolution: {integrity: sha512-P+HVwQR4SO6F4EXl5ReGR6hbng8CKsl4IK6Ww6u7yhNh1x7YGKzmV7UMRjSUc7qsNu4CzOhF7AYatCB602ANjA==, tarball: file:projects/pod-ses.tgz}
|
||||
name: '@rush-temp/pod-ses'
|
||||
version: 0.0.0
|
||||
dependencies:
|
||||
@ -32508,7 +32518,7 @@ packages:
|
||||
dev: false
|
||||
|
||||
file:projects/text-editor-resources.tgz(@types/node@20.11.19)(bufferutil@4.0.8)(esbuild@0.24.2)(highlight.js@11.8.0)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2)(utf-8-validate@6.0.4):
|
||||
resolution: {integrity: sha512-F0E6R31dkSA4Oa+lx7pYoKeHmMI1N0wMDKomkWOhwtChvWo3EZRj9xySEthXFV8au0htnsi+rKeywFOCcGdmGg==, tarball: file:projects/text-editor-resources.tgz}
|
||||
resolution: {integrity: sha512-7dR/DxZ3NRHi1qbcIIwu94XiC5aklrmIsXSjx/Nd8lsNy4Tyc0+1nB8lM6di7SF89cG/xwk3JokDbL3tlYQKbw==, tarball: file:projects/text-editor-resources.tgz}
|
||||
id: file:projects/text-editor-resources.tgz
|
||||
name: '@rush-temp/text-editor-resources'
|
||||
version: 0.0.0
|
||||
@ -32531,6 +32541,7 @@ packages:
|
||||
'@tiptap/extension-table-cell': 2.6.6(@tiptap/core@2.6.6)
|
||||
'@tiptap/extension-table-header': 2.6.6(@tiptap/core@2.6.6)
|
||||
'@tiptap/extension-table-row': 2.6.6(@tiptap/core@2.6.6)
|
||||
'@tiptap/extension-text-align': 2.11.0(@tiptap/core@2.6.6)
|
||||
'@tiptap/extension-typography': 2.6.6(@tiptap/core@2.6.6)
|
||||
'@tiptap/extension-underline': 2.6.6(@tiptap/core@2.6.6)
|
||||
'@tiptap/pm': 2.6.6
|
||||
@ -32675,7 +32686,7 @@ packages:
|
||||
dev: false
|
||||
|
||||
file:projects/text.tgz(@types/node@20.11.19)(bufferutil@4.0.8)(esbuild@0.24.2)(ts-node@10.9.2)(utf-8-validate@6.0.4):
|
||||
resolution: {integrity: sha512-1V1MwGEZv1UxhJ4js4mIMG6U87MbA6jlzPZyE6EmtNpYbQ12DfVTp2XpiK3JlZKs95itkJcFCSTQIMsYSrw4yw==, tarball: file:projects/text.tgz}
|
||||
resolution: {integrity: sha512-zmM6GDzeNuMbMA7iWwhg8hBc43fylNW7cEmk0fSWw9+G9NqD2MLv2dVb5dnAcPAioj9WBdDakWcrLAAHUBJyMw==, tarball: file:projects/text.tgz}
|
||||
id: file:projects/text.tgz
|
||||
name: '@rush-temp/text'
|
||||
version: 0.0.0
|
||||
@ -32695,6 +32706,7 @@ packages:
|
||||
'@tiptap/extension-table-row': 2.6.6(@tiptap/core@2.6.6)
|
||||
'@tiptap/extension-task-item': 2.6.6(@tiptap/core@2.6.6)(@tiptap/pm@2.6.6)
|
||||
'@tiptap/extension-task-list': 2.6.6(@tiptap/core@2.6.6)
|
||||
'@tiptap/extension-text-align': 2.11.0(@tiptap/core@2.6.6)
|
||||
'@tiptap/extension-typography': 2.6.6(@tiptap/core@2.6.6)
|
||||
'@tiptap/extension-underline': 2.6.6(@tiptap/core@2.6.6)
|
||||
'@tiptap/html': 2.6.6(@tiptap/core@2.6.6)(@tiptap/pm@2.6.6)
|
||||
@ -32893,7 +32905,7 @@ packages:
|
||||
dev: false
|
||||
|
||||
file:projects/tool.tgz:
|
||||
resolution: {integrity: sha512-nyx2osnJq6th2Ttrw5B2TIHUc2j8nTnB4tvx0jIXQLjiq/FEUxBluKLb2eEoHntQ5TxCWEC9a69jkO/QnNWfwA==, tarball: file:projects/tool.tgz}
|
||||
resolution: {integrity: sha512-Cw36G6uROkPkNIJDl7WeHjhHKmbQth6wmjMu4vBQGz+DfuPf9Eg63SFKq29rOssZ4LlJB1d22Y7XnxilCuXK5g==, tarball: file:projects/tool.tgz}
|
||||
name: '@rush-temp/tool'
|
||||
version: 0.0.0
|
||||
dependencies:
|
||||
|
@ -17,6 +17,7 @@
|
||||
"bundle": "node ../../common/scripts/esbuild.js --entry=src/__start.ts --keep-names=true --sourcemap=external --define:MODEL_VERSION --define:GIT_REVISION",
|
||||
"docker:build": "../../common/scripts/docker_build.sh hardcoreeng/tool",
|
||||
"docker:tbuild": "docker build -t hardcoreeng/tool . --platform=linux/amd64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/tool",
|
||||
"docker:abuild": "docker build -t hardcoreeng/tool . --platform=linux/arm64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/tool",
|
||||
"docker:staging": "../../common/scripts/docker_tag.sh hardcoreeng/tool staging",
|
||||
"docker:push": "../../common/scripts/docker_tag.sh hardcoreeng/tool",
|
||||
"run-local": "rush bundle --to @hcengineering/tool >/dev/null && cross-env SERVER_SECRET=secret ACCOUNTS_URL=http://localhost:3000 TRANSACTOR_URL=ws://localhost:3333 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_ENDPOINT=localhost ACCOUNT_DB_URL=mongodb://localhost:27017 DB_URL=mongodb://localhost:27017 TELEGRAM_DATABASE=telegram-service REKONI_URL=http://localhost:4004 MODEL_VERSION=$(node ../../common/scripts/show_version.js) GIT_REVISION=$(git describe --all --long) node --expose-gc --max-old-space-size=18000 ./bundle/bundle.js",
|
||||
|
@ -347,7 +347,7 @@ export async function updateDataWorkspaceIdToUuid (
|
||||
throw new Error('workspace uuid is required but not defined')
|
||||
}
|
||||
|
||||
await client`UPDATE ${client(table)} SET "workspaceId" = ${uuid} WHERE "workspaceIdOld" = ${ws.workspace}`
|
||||
await client`UPDATE ${client(table)} SET "workspaceId" = ${uuid} WHERE "workspaceIdOld" = ${ws.workspace} OR "workspaceIdOld" = ${uuid}`
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -518,11 +518,6 @@ export function createModel (builder: Builder): void {
|
||||
grouppingManager: contact.aggregation.GrouppingPersonManager
|
||||
})
|
||||
|
||||
builder.mixin(contact.mixin.Employee, core.class.Class, view.mixin.ObjectEditor, {
|
||||
editor: contact.component.EditEmployee,
|
||||
pinned: true
|
||||
})
|
||||
|
||||
builder.mixin(contact.class.Organization, core.class.Class, view.mixin.ObjectEditor, {
|
||||
editor: contact.component.EditOrganization,
|
||||
pinned: true
|
||||
|
@ -35,7 +35,6 @@ export default mergeIds(contactId, contact, {
|
||||
ContactRefPresenter: '' as AnyComponent,
|
||||
ContactPresenter: '' as AnyComponent,
|
||||
EditPerson: '' as AnyComponent,
|
||||
EditEmployee: '' as AnyComponent,
|
||||
EditOrganization: '' as AnyComponent,
|
||||
OrganizationPresenter: '' as AnyComponent,
|
||||
Contacts: '' as AnyComponent,
|
||||
|
@ -147,6 +147,42 @@ function createImageAlignmentAction (builder: Builder, align: 'center' | 'left'
|
||||
})
|
||||
}
|
||||
|
||||
function createTextAlignmentAction (builder: Builder, align: 'center' | 'left' | 'right'): void {
|
||||
let icon: Asset
|
||||
let label: IntlString
|
||||
let index: number
|
||||
switch (align) {
|
||||
case 'left':
|
||||
icon = textEditor.icon.AlignLeft
|
||||
label = textEditor.string.AlignLeft
|
||||
index = 5
|
||||
break
|
||||
case 'center':
|
||||
icon = textEditor.icon.AlignCenter
|
||||
label = textEditor.string.AlignCenter
|
||||
index = 10
|
||||
break
|
||||
case 'right':
|
||||
icon = textEditor.icon.AlignRight
|
||||
label = textEditor.string.AlignRight
|
||||
index = 15
|
||||
break
|
||||
}
|
||||
|
||||
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
|
||||
kind: 'text',
|
||||
action: {
|
||||
command: 'setTextAlign',
|
||||
params: align
|
||||
},
|
||||
visibilityTester: textEditor.function.IsEditable,
|
||||
icon,
|
||||
label,
|
||||
category: 45,
|
||||
index
|
||||
})
|
||||
}
|
||||
|
||||
export function createModel (builder: Builder): void {
|
||||
builder.createModel(TRefInputActionItem, TTextEditorExtensionFactory, TTextEditorAction)
|
||||
|
||||
@ -253,6 +289,11 @@ export function createModel (builder: Builder): void {
|
||||
index: 10
|
||||
})
|
||||
|
||||
// Text align category
|
||||
createTextAlignmentAction(builder, 'left')
|
||||
createTextAlignmentAction(builder, 'center')
|
||||
createTextAlignmentAction(builder, 'right')
|
||||
|
||||
// Quote category
|
||||
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
|
||||
action: {
|
||||
|
@ -329,6 +329,7 @@ async function tryLoadModel (
|
||||
|
||||
if (conn.getLastHash !== undefined && (await conn.getLastHash(ctx)) === current.hash) {
|
||||
// We have same model hash.
|
||||
current.full = false // Since we load, no need to send full
|
||||
return current
|
||||
}
|
||||
const lastTxTime = getLastTxTime(current.transactions)
|
||||
|
@ -63,7 +63,8 @@
|
||||
"@tiptap/suggestion": "^2.6.6",
|
||||
"prosemirror-codemark": "^0.4.2",
|
||||
"markdown-it": "^14.0.0",
|
||||
"fast-equals": "^5.0.1"
|
||||
"fast-equals": "^5.0.1",
|
||||
"@tiptap/extension-text-align": "~2.11.0"
|
||||
},
|
||||
"repository": "https://github.com/hcengineering/platform",
|
||||
"publishConfig": {
|
||||
|
@ -33,6 +33,7 @@ import { DefaultKit, DefaultKitOptions } from './default-kit'
|
||||
import { CodeExtension, codeOptions } from '../marks/code'
|
||||
import { NoteBaseExtension } from '../marks/noteBase'
|
||||
import { CommentNode } from '../nodes/comment'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
|
||||
const headingLevels: Level[] = [1, 2, 3, 4, 5, 6]
|
||||
|
||||
@ -86,6 +87,11 @@ export const ServerKit = Extension.create<ServerKitOptions>({
|
||||
...taskListExtensions,
|
||||
...fileExtensions,
|
||||
...imageExtensions,
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
alignments: ['left', 'center', 'right'],
|
||||
defaultAlignment: null
|
||||
}),
|
||||
TodoItemNode,
|
||||
TodoListNode,
|
||||
ReferenceNode,
|
||||
|
@ -61,17 +61,19 @@ describe('EmptyMarkup', () => {
|
||||
describe('getMarkup', () => {
|
||||
it('with empty content', async () => {
|
||||
const editor = new Editor({ extensions })
|
||||
expect(getMarkup(editor)).toEqual('{"type":"doc","content":[{"type":"paragraph"}]}')
|
||||
expect(getMarkup(editor)).toEqual('{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null}}]}')
|
||||
})
|
||||
it('with some content', async () => {
|
||||
const editor = new Editor({ extensions, content: '<p>hello</p>' })
|
||||
expect(getMarkup(editor)).toEqual(
|
||||
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}'
|
||||
'{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null},"content":[{"type":"text","text":"hello"}]}]}'
|
||||
)
|
||||
})
|
||||
it('with empty paragraphs as content', async () => {
|
||||
const editor = new Editor({ extensions, content: '<p></p><p></p>' })
|
||||
expect(getMarkup(editor)).toEqual('{"type":"doc","content":[{"type":"paragraph"},{"type":"paragraph"}]}')
|
||||
expect(getMarkup(editor)).toEqual(
|
||||
'{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null}},{"type":"paragraph","attrs":{"textAlign":null}}]}'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -250,13 +252,15 @@ describe('pmNodeToMarkup', () => {
|
||||
const schema = getSchema(extensions)
|
||||
const node = schema.node('paragraph', {}, [schema.text('Hello, world!')])
|
||||
|
||||
expect(pmNodeToMarkup(node)).toEqual('{"type":"paragraph","content":[{"type":"text","text":"Hello, world!"}]}')
|
||||
expect(pmNodeToMarkup(node)).toEqual(
|
||||
'{"type":"paragraph","attrs":{"textAlign":null},"content":[{"type":"text","text":"Hello, world!"}]}'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('markupToPmNode', () => {
|
||||
it('converts markup to ProseMirrorNode', () => {
|
||||
const markup = '{"type":"paragraph","content":[{"type":"text","text":"Hello, world!"}]}'
|
||||
const markup = '{"type":"paragraph","attrs":{"textAlign":null},"content":[{"type":"text","text":"Hello, world!"}]}'
|
||||
const node = markupToPmNode(markup)
|
||||
|
||||
expect(node.type.name).toEqual('paragraph')
|
||||
@ -306,7 +310,11 @@ describe('pmNodeToJSON', () => {
|
||||
const schema = getSchema(extensions)
|
||||
const node = schema.node('paragraph', {}, [schema.text('Hello, world!')])
|
||||
|
||||
const json = nodeParagraph(nodeText('Hello, world!'))
|
||||
const json: MarkupNode = {
|
||||
type: MarkupNodeType.paragraph,
|
||||
attrs: { textAlign: null as any },
|
||||
content: [nodeText('Hello, world!')]
|
||||
}
|
||||
expect(pmNodeToJSON(node)).toEqual(json)
|
||||
})
|
||||
})
|
||||
|
@ -44,6 +44,7 @@ import core, {
|
||||
TxApplyIf,
|
||||
TxHandler,
|
||||
TxResult,
|
||||
clone,
|
||||
generateId,
|
||||
toFindResult,
|
||||
type MeasureContext
|
||||
@ -108,6 +109,8 @@ class Connection implements ClientConnection {
|
||||
|
||||
private helloRecieved: boolean = false
|
||||
|
||||
private account: Account | undefined
|
||||
|
||||
onConnect?: (event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise<void>
|
||||
|
||||
rpcHandler = new RPCHandler()
|
||||
@ -303,7 +306,7 @@ class Connection implements ClientConnection {
|
||||
this.websocket?.close()
|
||||
return
|
||||
}
|
||||
|
||||
this.account = helloResp.account
|
||||
this.helloRecieved = true
|
||||
if (this.upgrading) {
|
||||
// We need to call upgrade since connection is upgraded
|
||||
@ -322,8 +325,8 @@ class Connection implements ClientConnection {
|
||||
}
|
||||
|
||||
void this.onConnect?.(
|
||||
(resp as HelloResponse).reconnect === true ? ClientConnectEvent.Reconnected : ClientConnectEvent.Connected,
|
||||
(resp as HelloResponse).lastTx,
|
||||
helloResp.reconnect === true ? ClientConnectEvent.Reconnected : ClientConnectEvent.Connected,
|
||||
helloResp.lastTx,
|
||||
this.sessionId
|
||||
)
|
||||
this.schedulePing(socketId)
|
||||
@ -635,6 +638,9 @@ class Connection implements ClientConnection {
|
||||
}
|
||||
|
||||
getAccount (): Promise<Account> {
|
||||
if (this.account !== undefined) {
|
||||
return clone(this.account)
|
||||
}
|
||||
return this.sendRequest({ method: 'getAccount', params: [] })
|
||||
}
|
||||
|
||||
|
@ -1,193 +0,0 @@
|
||||
<!--
|
||||
// Copyright © 2020, 2021 Anticrm Platform Contributors.
|
||||
// Copyright © 2021 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 {
|
||||
Channel,
|
||||
Employee,
|
||||
Person,
|
||||
PersonAccount,
|
||||
combineName,
|
||||
getFirstName,
|
||||
getLastName
|
||||
} from '@hcengineering/contact'
|
||||
import { AccountRole, Ref, getCurrentAccount, hasAccountRole } from '@hcengineering/core'
|
||||
import { AttributeEditor, createQuery, getClient } from '@hcengineering/presentation'
|
||||
import setting, { IntegrationType } from '@hcengineering/setting'
|
||||
import { EditBox, FocusHandler, Scroller, createFocusManager } from '@hcengineering/ui'
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
import { ChannelsDropdown } from '..'
|
||||
import contact from '../plugin'
|
||||
import Avatar from './Avatar.svelte'
|
||||
import ChannelsEditor from './ChannelsEditor.svelte'
|
||||
import EditableAvatar from './EditableAvatar.svelte'
|
||||
|
||||
export let object: Person
|
||||
export let readonly = false
|
||||
|
||||
export let channels: Channel[] | undefined = undefined
|
||||
|
||||
const client = getClient()
|
||||
|
||||
const account = getCurrentAccount() as PersonAccount
|
||||
|
||||
let avatarEditor: EditableAvatar
|
||||
|
||||
$: owner = account.person === object._id
|
||||
$: editable = !readonly && (hasAccountRole(account, AccountRole.Maintainer) || owner)
|
||||
let firstName = getFirstName(object.name)
|
||||
let lastName = getLastName(object.name)
|
||||
|
||||
$: setName(object)
|
||||
|
||||
let email: string | undefined
|
||||
$: if (editable) {
|
||||
client.findOne(contact.class.PersonAccount, { person: (object as Employee)._id }).then((acc) => {
|
||||
email = acc?.email
|
||||
})
|
||||
}
|
||||
|
||||
function setName (object: Person) {
|
||||
firstName = getFirstName(object.name)
|
||||
lastName = getLastName(object.name)
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
async function firstNameChange () {
|
||||
await client.update(object, {
|
||||
name: combineName(firstName, getLastName(object.name))
|
||||
})
|
||||
}
|
||||
|
||||
async function lastNameChange () {
|
||||
await client.update(object, {
|
||||
name: combineName(getFirstName(object.name), lastName)
|
||||
})
|
||||
}
|
||||
|
||||
let integrations: Set<Ref<IntegrationType>> = new Set<Ref<IntegrationType>>()
|
||||
const settingsQuery = createQuery()
|
||||
$: settingsQuery.query(setting.class.Integration, { createdBy: account._id, disabled: false }, (res) => {
|
||||
integrations = new Set(res.map((p) => p.type))
|
||||
})
|
||||
|
||||
const sendOpen = () => dispatch('open', { ignoreKeys: ['comments', 'name', 'channels', 'city'] })
|
||||
onMount(sendOpen)
|
||||
|
||||
async function onAvatarDone () {
|
||||
if (object.avatar != null) {
|
||||
await avatarEditor.removeAvatar(object.avatar)
|
||||
}
|
||||
const avatar = await avatarEditor.createAvatar()
|
||||
await client.diffUpdate(object, avatar)
|
||||
}
|
||||
|
||||
const manager = createFocusManager()
|
||||
</script>
|
||||
|
||||
<FocusHandler {manager} />
|
||||
|
||||
{#if object !== undefined}
|
||||
<div class="flex-row-stretch flex-grow">
|
||||
<div class="flex-no-shrink mr-8">
|
||||
{#key object}
|
||||
{#if editable}
|
||||
<EditableAvatar
|
||||
person={object}
|
||||
{email}
|
||||
size={'x-large'}
|
||||
name={object.name}
|
||||
bind:this={avatarEditor}
|
||||
on:done={onAvatarDone}
|
||||
/>
|
||||
{:else}
|
||||
<Avatar person={object} size={'x-large'} name={object.name} />
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
<div class="flex-grow flex-col">
|
||||
<div class="name select-text">
|
||||
{#if owner}
|
||||
<EditBox
|
||||
placeholder={contact.string.PersonFirstNamePlaceholder}
|
||||
bind:value={firstName}
|
||||
disabled={!editable}
|
||||
on:change={firstNameChange}
|
||||
focusIndex={1}
|
||||
/>
|
||||
{:else}
|
||||
{firstName}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="name select-text">
|
||||
{#if owner}
|
||||
<EditBox
|
||||
placeholder={contact.string.PersonLastNamePlaceholder}
|
||||
bind:value={lastName}
|
||||
on:change={lastNameChange}
|
||||
disabled={!editable}
|
||||
focusIndex={2}
|
||||
/>
|
||||
{:else}
|
||||
{lastName}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="location">
|
||||
<AttributeEditor maxWidth="20rem" _class={contact.class.Person} {editable} {object} key="city" focusIndex={3} />
|
||||
</div>
|
||||
<div class="separator" />
|
||||
<Scroller contentDirection={'horizontal'} padding={'.125rem .125rem .5rem'} stickedScrollBars thinScrollBars>
|
||||
{#if channels === undefined}
|
||||
<ChannelsEditor
|
||||
attachedTo={object._id}
|
||||
attachedClass={object._class}
|
||||
{editable}
|
||||
bind:integrations
|
||||
shape={'circle'}
|
||||
focusIndex={10}
|
||||
/>
|
||||
{:else}
|
||||
<ChannelsDropdown
|
||||
value={channels}
|
||||
editable={false}
|
||||
kind={'link-bordered'}
|
||||
size={'small'}
|
||||
length={'full'}
|
||||
shape={'circle'}
|
||||
/>
|
||||
{/if}
|
||||
</Scroller>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.name {
|
||||
font-weight: 500;
|
||||
font-size: 1.25rem;
|
||||
color: var(--caption-color);
|
||||
}
|
||||
.location {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 1rem 0;
|
||||
height: 1px;
|
||||
background-color: var(--divider-color);
|
||||
}
|
||||
</style>
|
@ -14,8 +14,8 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Person, PersonAccount, combineName, getFirstName, getLastName } from '@hcengineering/contact'
|
||||
import { Ref, getCurrentAccount } from '@hcengineering/core'
|
||||
import { Channel, Person, PersonAccount, combineName, getFirstName, getLastName } from '@hcengineering/contact'
|
||||
import { AccountRole, Ref, getCurrentAccount, hasAccountRole } from '@hcengineering/core'
|
||||
import { AttributeEditor, createQuery, getClient } from '@hcengineering/presentation'
|
||||
import setting, { IntegrationType } from '@hcengineering/setting'
|
||||
import { EditBox, FocusHandler, Scroller, createFocusManager } from '@hcengineering/ui'
|
||||
@ -23,12 +23,25 @@
|
||||
import contact from '../plugin'
|
||||
import ChannelsEditor from './ChannelsEditor.svelte'
|
||||
import EditableAvatar from './EditableAvatar.svelte'
|
||||
import Avatar from './Avatar.svelte'
|
||||
import ChannelsDropdown from './ChannelsDropdown.svelte'
|
||||
|
||||
export let object: Person
|
||||
export let readonly: boolean = false
|
||||
export let channels: Channel[] | undefined = undefined
|
||||
|
||||
const client = getClient()
|
||||
const h = client.getHierarchy()
|
||||
|
||||
const account = getCurrentAccount() as PersonAccount
|
||||
$: owner = account.person === object._id
|
||||
|
||||
function isEditable (owner: boolean, object: Person): boolean {
|
||||
if (owner) return true
|
||||
if (!h.hasMixin(object, contact.mixin.Employee)) return true
|
||||
return hasAccountRole(account, AccountRole.Maintainer) && !h.as(object, contact.mixin.Employee).active
|
||||
}
|
||||
$: editable = !readonly && isEditable(owner, object)
|
||||
|
||||
let avatarEditor: EditableAvatar
|
||||
|
||||
@ -37,21 +50,21 @@
|
||||
|
||||
$: setName(object)
|
||||
|
||||
function setName (object: Person) {
|
||||
function setName (object: Person): void {
|
||||
firstName = getFirstName(object.name)
|
||||
lastName = getLastName(object.name)
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
function firstNameChange () {
|
||||
client.update(object, {
|
||||
async function firstNameChange (): Promise<void> {
|
||||
await client.update(object, {
|
||||
name: combineName(firstName, getLastName(object.name))
|
||||
})
|
||||
}
|
||||
|
||||
function lastNameChange () {
|
||||
client.update(object, {
|
||||
async function lastNameChange (): Promise<void> {
|
||||
await client.update(object, {
|
||||
name: combineName(getFirstName(object.name), lastName)
|
||||
})
|
||||
}
|
||||
@ -65,7 +78,7 @@
|
||||
const sendOpen = () => dispatch('open', { ignoreKeys: ['comments', 'name', 'channels', 'city'] })
|
||||
onMount(sendOpen)
|
||||
|
||||
async function onAvatarDone () {
|
||||
async function onAvatarDone (): Promise<void> {
|
||||
if (object.avatar != null) {
|
||||
await avatarEditor.removeAvatar(object.avatar)
|
||||
}
|
||||
@ -82,21 +95,24 @@
|
||||
<div class="flex-row-stretch flex-grow">
|
||||
<div class="flex-no-shrink mr-8">
|
||||
{#key object}
|
||||
<EditableAvatar
|
||||
disabled={readonly}
|
||||
person={object}
|
||||
size={'x-large'}
|
||||
name={object.name}
|
||||
bind:this={avatarEditor}
|
||||
on:done={onAvatarDone}
|
||||
/>
|
||||
{#if editable}
|
||||
<EditableAvatar
|
||||
person={object}
|
||||
size={'x-large'}
|
||||
name={object.name}
|
||||
bind:this={avatarEditor}
|
||||
on:done={onAvatarDone}
|
||||
/>
|
||||
{:else}
|
||||
<Avatar person={object} size={'x-large'} name={object.name} />
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
<div class="flex-grow flex-col">
|
||||
<div class="name">
|
||||
<EditBox
|
||||
disabled={readonly}
|
||||
placeholder={contact.string.PersonFirstNamePlaceholder}
|
||||
disabled={!editable}
|
||||
bind:value={firstName}
|
||||
on:change={firstNameChange}
|
||||
focusIndex={1}
|
||||
@ -104,7 +120,7 @@
|
||||
</div>
|
||||
<div class="name">
|
||||
<EditBox
|
||||
disabled={readonly}
|
||||
disabled={!editable}
|
||||
placeholder={contact.string.PersonLastNamePlaceholder}
|
||||
bind:value={lastName}
|
||||
on:change={lastNameChange}
|
||||
@ -112,14 +128,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="location">
|
||||
<AttributeEditor
|
||||
maxWidth="20rem"
|
||||
_class={contact.class.Person}
|
||||
{object}
|
||||
editable={!readonly}
|
||||
key="city"
|
||||
focusIndex={3}
|
||||
/>
|
||||
<AttributeEditor maxWidth="20rem" _class={contact.class.Person} {object} {editable} key="city" focusIndex={3} />
|
||||
</div>
|
||||
|
||||
<div class="separator" />
|
||||
@ -130,14 +139,25 @@
|
||||
stickedScrollBars
|
||||
thinScrollBars
|
||||
>
|
||||
<ChannelsEditor
|
||||
attachedTo={object._id}
|
||||
attachedClass={object._class}
|
||||
editable={!readonly}
|
||||
bind:integrations
|
||||
shape={'circle'}
|
||||
focusIndex={10}
|
||||
/>
|
||||
{#if channels === undefined}
|
||||
<ChannelsEditor
|
||||
attachedTo={object._id}
|
||||
attachedClass={object._class}
|
||||
{editable}
|
||||
bind:integrations
|
||||
shape={'circle'}
|
||||
focusIndex={10}
|
||||
/>
|
||||
{:else}
|
||||
<ChannelsDropdown
|
||||
value={channels}
|
||||
editable={false}
|
||||
kind={'link-bordered'}
|
||||
size={'small'}
|
||||
length={'full'}
|
||||
shape={'circle'}
|
||||
/>
|
||||
{/if}
|
||||
</Scroller>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,6 +2,7 @@
|
||||
import { Employee, Person } from '@hcengineering/contact'
|
||||
import { Ref, WithLookup } from '@hcengineering/core'
|
||||
import { IntlString } from '@hcengineering/platform'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import ui, { IconSize, LabelAndProps } from '@hcengineering/ui'
|
||||
import { PersonLabelTooltip, employeeByIdStore, personByIdStore } from '..'
|
||||
import PersonPresenter from '../components/PersonPresenter.svelte'
|
||||
@ -26,9 +27,14 @@
|
||||
export let compact: boolean = false
|
||||
export let showStatus: boolean = false
|
||||
|
||||
$: employeeValue = typeof value === 'string' ? ($personByIdStore.get(value) as Employee) : (value as Employee)
|
||||
const client = getClient()
|
||||
const h = client.getHierarchy()
|
||||
|
||||
$: active = employeeValue !== undefined ? $employeeByIdStore.get(employeeValue?._id)?.active ?? false : false
|
||||
$: person = typeof value === 'string' ? ($personByIdStore.get(value) as Person) : (value as Person)
|
||||
|
||||
$: employeeValue = person != null ? h.as(person, contact.mixin.Employee) : undefined
|
||||
|
||||
$: active = employeeValue?.active ?? false
|
||||
|
||||
function getPreviewPopup (active: boolean, value: Employee | undefined): LabelAndProps | undefined {
|
||||
if (!active || value === undefined || !showPopup) {
|
||||
|
@ -34,10 +34,10 @@
|
||||
import Avatar from './Avatar.svelte'
|
||||
import ChannelPresenter from './ChannelPresenter.svelte'
|
||||
import ChannelsDropdown from './ChannelsDropdown.svelte'
|
||||
import EditEmployee from './EditEmployee.svelte'
|
||||
import MergeAttributeComparer from './MergeAttributeComparer.svelte'
|
||||
import MergeComparer from './MergeComparer.svelte'
|
||||
import UserBox from './UserBox.svelte'
|
||||
import EditPerson from './EditPerson.svelte'
|
||||
|
||||
export let value: Person
|
||||
const dispatch = createEventDispatcher()
|
||||
@ -434,7 +434,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex-col-center antiPopup p-4">
|
||||
<EditEmployee object={result} readonly channels={resultChannels} />
|
||||
<EditPerson object={result} readonly channels={resultChannels} />
|
||||
</div>
|
||||
{/if}
|
||||
{/key}
|
||||
|
@ -77,7 +77,6 @@ import CreateGuest from './components/CreateGuest.svelte'
|
||||
import CreateOrganization from './components/CreateOrganization.svelte'
|
||||
import CreatePerson from './components/CreatePerson.svelte'
|
||||
import DeleteConfirmationPopup from './components/DeleteConfirmationPopup.svelte'
|
||||
import EditEmployee from './components/EditEmployee.svelte'
|
||||
import EditMember from './components/EditMember.svelte'
|
||||
import EditOrganization from './components/EditOrganization.svelte'
|
||||
import EditOrganizationPanel from './components/EditOrganizationPanel.svelte'
|
||||
@ -365,7 +364,6 @@ export default async (): Promise<Resources> => ({
|
||||
CollaborationUserAvatar,
|
||||
CreateOrganization,
|
||||
EditPerson,
|
||||
EditEmployee,
|
||||
EditOrganization,
|
||||
SocialEditor,
|
||||
Contacts,
|
||||
|
@ -275,11 +275,12 @@ async function generateLocation (loc: Location, id: Ref<Contact>): Promise<Resol
|
||||
console.error(`Could not find contact ${id}.`)
|
||||
return undefined
|
||||
}
|
||||
const isEmployee = client.getHierarchy().hasMixin(doc, contact.mixin.Employee)
|
||||
const appComponent = loc.path[0] ?? ''
|
||||
const workspace = loc.path[1] ?? ''
|
||||
const special = client.getHierarchy().isDerived(doc._class, contact.class.Organization)
|
||||
? 'companies'
|
||||
: client.getHierarchy().isDerived(doc._class, contact.mixin.Employee)
|
||||
: isEmployee
|
||||
? 'employees'
|
||||
: 'persons'
|
||||
|
||||
@ -289,11 +290,11 @@ async function generateLocation (loc: Location, id: Ref<Contact>): Promise<Resol
|
||||
return {
|
||||
loc: {
|
||||
path: [appComponent, workspace],
|
||||
fragment: getPanelURI(component, doc._id, doc._class, 'content')
|
||||
fragment: getPanelURI(component, doc._id, isEmployee ? contact.mixin.Employee : doc._class, 'content')
|
||||
},
|
||||
defaultLocation: {
|
||||
path: [appComponent, workspace, contactId, special],
|
||||
fragment: getPanelURI(component, doc._id, doc._class, 'content')
|
||||
fragment: getPanelURI(component, doc._id, isEmployee ? contact.mixin.Employee : doc._class, 'content')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -307,7 +307,7 @@
|
||||
id={'vacancy.talant.selector'}
|
||||
focusIndex={1}
|
||||
readonly={preserveCandidate}
|
||||
_class={recruit.mixin.Candidate}
|
||||
_class={contact.class.Person}
|
||||
options={{ sort: { modifiedOn: -1 } }}
|
||||
excluded={existingApplicants}
|
||||
label={recruit.string.Talent}
|
||||
|
@ -87,6 +87,7 @@
|
||||
"mermaid": "~11.4.1",
|
||||
"@hcengineering/theme": "^0.6.5",
|
||||
"tippy.js": "~6.3.7",
|
||||
"@hcengineering/chunter": "^0.6.20"
|
||||
"@hcengineering/chunter": "^0.6.20",
|
||||
"@tiptap/extension-text-align": "~2.11.0"
|
||||
}
|
||||
}
|
||||
|
@ -175,6 +175,7 @@
|
||||
}
|
||||
: false,
|
||||
drawingBoard: false,
|
||||
textAlign: false,
|
||||
submit: supportSubmit ? { submit } : false,
|
||||
toolbar: {
|
||||
element: textToolbarElement,
|
||||
|
@ -60,7 +60,6 @@
|
||||
const { command } = action.action
|
||||
|
||||
if ((editor.commands as any)[command] === undefined) {
|
||||
console.error(`Command ${command} not found`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ export const InlinePopupExtension: Extension<BubbleMenuOptions> = BubbleMenu.ext
|
||||
pluginKey: 'inline-popup',
|
||||
element: null,
|
||||
tippyOptions: {
|
||||
maxWidth: '38rem',
|
||||
maxWidth: '46rem',
|
||||
zIndex: 500,
|
||||
appendTo: () => document.body
|
||||
}
|
||||
|
@ -38,6 +38,7 @@ import { DefaultKit, type DefaultKitOptions } from './default-kit'
|
||||
import { MermaidExtension, type MermaidOptions, mermaidOptions } from '../components/extension/mermaid'
|
||||
import { DrawingBoardExtension, type DrawingBoardOptions } from '../components/extension/drawingBoard'
|
||||
import { type IndendOptions, IndentExtension, indentExtensionOptions } from '../components/extension/indent'
|
||||
import TextAlign, { type TextAlignOptions } from '@tiptap/extension-text-align'
|
||||
|
||||
export interface EditorKitOptions extends DefaultKitOptions {
|
||||
history?: false
|
||||
@ -55,6 +56,7 @@ export interface EditorKitOptions extends DefaultKitOptions {
|
||||
drawingBoard?: DrawingBoardOptions | false
|
||||
mermaid?: MermaidOptions | false
|
||||
indent?: IndendOptions | false
|
||||
textAlign?: TextAlignOptions | false
|
||||
mode?: 'full' | 'compact'
|
||||
note?: NoteOptions | false
|
||||
submit?: SubmitOptions | false
|
||||
@ -269,6 +271,19 @@ async function buildEditorKit (): Promise<Extension<EditorKitOptions, any>> {
|
||||
])
|
||||
}
|
||||
|
||||
if (this.options.textAlign !== false) {
|
||||
staticKitExtensions.push([
|
||||
870,
|
||||
TextAlign.configure(
|
||||
this.options.textAlign ?? {
|
||||
types: ['heading', 'paragraph'],
|
||||
alignments: ['left', 'center', 'right'],
|
||||
defaultAlignment: null
|
||||
}
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
if (this.options.toolbar !== false) {
|
||||
staticKitExtensions.push([
|
||||
900,
|
||||
|
@ -14,7 +14,7 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Class, Doc, Mixin, Ref } from '@hcengineering/core'
|
||||
import { Class, Doc, Hierarchy, Mixin, Ref } from '@hcengineering/core'
|
||||
import notification from '@hcengineering/notification'
|
||||
import { Panel } from '@hcengineering/panel'
|
||||
import { getResource } from '@hcengineering/platform'
|
||||
@ -86,7 +86,7 @@
|
||||
query.query(_class, { _id }, (result) => {
|
||||
object = result[0]
|
||||
if (object != null) {
|
||||
realObjectClass = object._class
|
||||
realObjectClass = Hierarchy.mixinOrClass(object)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
|
@ -227,7 +227,7 @@ export async function connect (title: string): Promise<Client | undefined> {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (event === ClientConnectEvent.Connected) {
|
||||
if (event === ClientConnectEvent.Connected || event === ClientConnectEvent.Reconnected) {
|
||||
setMetadata(presentation.metadata.SessionId, data)
|
||||
}
|
||||
if ((_clientSet && event === ClientConnectEvent.Connected) || event === ClientConnectEvent.Refresh) {
|
||||
|
@ -64,43 +64,47 @@ export class DbAdapterManagerImpl implements DBAdapterManager {
|
||||
return this.defaultAdapter
|
||||
}
|
||||
|
||||
async registerHelper (helper: DomainHelper): Promise<void> {
|
||||
async registerHelper (ctx: MeasureContext, helper: DomainHelper): Promise<void> {
|
||||
this.domainHelper = helper
|
||||
await this.initDomains()
|
||||
await this.initDomains(ctx)
|
||||
}
|
||||
|
||||
async initDomains (): Promise<void> {
|
||||
async initDomains (ctx: MeasureContext): Promise<void> {
|
||||
const adapterDomains = new Map<DbAdapter, Set<Domain>>()
|
||||
for (const d of this.context.hierarchy.domains()) {
|
||||
// We need to init domain info
|
||||
const info = this.getDomainInfo(d)
|
||||
await this.updateInfo(d, adapterDomains, info)
|
||||
await ctx.with('update-info', { domain: d }, async (ctx) => {
|
||||
const info = this.getDomainInfo(d)
|
||||
await this.updateInfo(d, adapterDomains, info)
|
||||
})
|
||||
}
|
||||
for (const adapter of this.adapters.values()) {
|
||||
adapter.on?.((domain, event, count, helper) => {
|
||||
const info = this.getDomainInfo(domain)
|
||||
const oldDocuments = info.documents
|
||||
switch (event) {
|
||||
case 'add':
|
||||
info.documents += count
|
||||
break
|
||||
case 'update':
|
||||
break
|
||||
case 'delete':
|
||||
info.documents -= count
|
||||
break
|
||||
case 'read':
|
||||
break
|
||||
}
|
||||
for (const [name, adapter] of this.adapters.entries()) {
|
||||
await ctx.with('domain-helper', { name }, async (ctx) => {
|
||||
adapter.on?.((domain, event, count, helper) => {
|
||||
const info = this.getDomainInfo(domain)
|
||||
const oldDocuments = info.documents
|
||||
switch (event) {
|
||||
case 'add':
|
||||
info.documents += count
|
||||
break
|
||||
case 'update':
|
||||
break
|
||||
case 'delete':
|
||||
info.documents -= count
|
||||
break
|
||||
case 'read':
|
||||
break
|
||||
}
|
||||
|
||||
if (oldDocuments < 50 && info.documents > 50) {
|
||||
// We have more 50 documents, we need to check for indexes
|
||||
void this.domainHelper?.checkDomain(this.metrics, domain, info.documents, helper)
|
||||
}
|
||||
if (oldDocuments > 50 && info.documents < 50) {
|
||||
// We have more 50 documents, we need to check for indexes
|
||||
void this.domainHelper?.checkDomain(this.metrics, domain, info.documents, helper)
|
||||
}
|
||||
if (oldDocuments < 50 && info.documents > 50) {
|
||||
// We have more 50 documents, we need to check for indexes
|
||||
void this.domainHelper?.checkDomain(this.metrics, domain, info.documents, helper)
|
||||
}
|
||||
if (oldDocuments > 50 && info.documents < 50) {
|
||||
// We have more 50 documents, we need to check for indexes
|
||||
void this.domainHelper?.checkDomain(this.metrics, domain, info.documents, helper)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,7 @@
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import {
|
||||
toFindResult,
|
||||
withContext,
|
||||
type Class,
|
||||
type Doc,
|
||||
type DocumentQuery,
|
||||
@ -66,6 +67,7 @@ class PipelineImpl implements Pipeline {
|
||||
return pipeline
|
||||
}
|
||||
|
||||
@withContext('build-chain')
|
||||
private async buildChain (
|
||||
ctx: MeasureContext,
|
||||
constructors: MiddlewareCreator[],
|
||||
|
@ -151,7 +151,7 @@ export interface DBAdapterManager {
|
||||
|
||||
close: () => Promise<void>
|
||||
|
||||
registerHelper: (helper: DomainHelper) => Promise<void>
|
||||
registerHelper: (ctx: MeasureContext, helper: DomainHelper) => Promise<void>
|
||||
|
||||
initAdapters: (ctx: MeasureContext) => Promise<void>
|
||||
|
||||
|
@ -73,6 +73,11 @@ interface MultipartUploadPart {
|
||||
etag: string
|
||||
}
|
||||
|
||||
export interface R2UploadParams {
|
||||
location: string
|
||||
bucket: string
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export class DatalakeClient {
|
||||
constructor (private readonly endpoint: string) {}
|
||||
@ -320,6 +325,8 @@ export class DatalakeClient {
|
||||
return await this.signObjectComplete(ctx, workspace, objectName)
|
||||
}
|
||||
|
||||
// S3
|
||||
|
||||
async uploadFromS3 (
|
||||
ctx: MeasureContext,
|
||||
workspace: WorkspaceId,
|
||||
@ -342,6 +349,37 @@ export class DatalakeClient {
|
||||
})
|
||||
}
|
||||
|
||||
// R2
|
||||
|
||||
async getR2UploadParams (ctx: MeasureContext, workspace: WorkspaceId): Promise<R2UploadParams> {
|
||||
const path = `/upload/r2/${workspace.name}`
|
||||
const url = concatLink(this.endpoint, path)
|
||||
|
||||
const response = await fetchSafe(ctx, url)
|
||||
const json = (await response.json()) as R2UploadParams
|
||||
return json
|
||||
}
|
||||
|
||||
async uploadFromR2 (
|
||||
ctx: MeasureContext,
|
||||
workspace: WorkspaceId,
|
||||
objectName: string,
|
||||
params: {
|
||||
filename: string
|
||||
}
|
||||
): Promise<void> {
|
||||
const path = `/upload/r2/${workspace.name}/${encodeURIComponent(objectName)}`
|
||||
const url = concatLink(this.endpoint, path)
|
||||
|
||||
await fetchSafe(ctx, url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
}
|
||||
|
||||
// Signed URL
|
||||
|
||||
private async signObjectSign (ctx: MeasureContext, workspace: WorkspaceId, objectName: string): Promise<string> {
|
||||
|
@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { type MeasureContext } from '@hcengineering/core'
|
||||
import { withContext, type MeasureContext } from '@hcengineering/core'
|
||||
import type { Middleware, PipelineContext } from '@hcengineering/server-core'
|
||||
import { BaseMiddleware, DomainIndexHelperImpl } from '@hcengineering/server-core'
|
||||
|
||||
@ -21,14 +21,19 @@ import { BaseMiddleware, DomainIndexHelperImpl } from '@hcengineering/server-cor
|
||||
* @public
|
||||
*/
|
||||
export class DBAdapterInitMiddleware extends BaseMiddleware implements Middleware {
|
||||
@withContext('db-adapter-init')
|
||||
static async create (
|
||||
ctx: MeasureContext,
|
||||
context: PipelineContext,
|
||||
next?: Middleware
|
||||
): Promise<Middleware | undefined> {
|
||||
await context.adapterManager?.initAdapters?.(ctx)
|
||||
await ctx.with('init-adapters', {}, async (ctx) => {
|
||||
await context.adapterManager?.initAdapters?.(ctx)
|
||||
})
|
||||
const domainHelper = new DomainIndexHelperImpl(ctx, context.hierarchy, context.modelDb, context.workspace)
|
||||
await context.adapterManager?.registerHelper?.(domainHelper)
|
||||
await ctx.with('register-helper', {}, async (ctx) => {
|
||||
await context.adapterManager?.registerHelper?.(ctx, domainHelper)
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
@ -17,6 +17,7 @@ import core, {
|
||||
Domain,
|
||||
groupByArray,
|
||||
TxProcessor,
|
||||
withContext,
|
||||
type Doc,
|
||||
type MeasureContext,
|
||||
type SessionData,
|
||||
@ -41,6 +42,7 @@ import { BaseMiddleware } from '@hcengineering/server-core'
|
||||
export class DomainTxMiddleware extends BaseMiddleware implements Middleware {
|
||||
adapterManager!: DBAdapterManager
|
||||
|
||||
@withContext('domainTx-middleware')
|
||||
static async create (ctx: MeasureContext, context: PipelineContext, next?: Middleware): Promise<Middleware> {
|
||||
const middleware = new DomainTxMiddleware(context, next)
|
||||
if (context.adapterManager == null) {
|
||||
|
@ -20,7 +20,8 @@ import core, {
|
||||
type Timestamp,
|
||||
type Tx,
|
||||
type TxCUD,
|
||||
DOMAIN_TX
|
||||
DOMAIN_TX,
|
||||
withContext
|
||||
} from '@hcengineering/core'
|
||||
import { PlatformError, unknownError } from '@hcengineering/platform'
|
||||
import type {
|
||||
@ -51,6 +52,7 @@ export class ModelMiddleware extends BaseMiddleware implements Middleware {
|
||||
super(context, next)
|
||||
}
|
||||
|
||||
@withContext('modelAdapter-middleware')
|
||||
static async doCreate (
|
||||
ctx: MeasureContext,
|
||||
context: PipelineContext,
|
||||
|
@ -450,12 +450,8 @@ export class DBCollectionHelper implements DomainHelperOperations {
|
||||
async create (domain: Domain): Promise<void> {}
|
||||
|
||||
async exists (domain: Domain): Promise<boolean> {
|
||||
const exists = await this.client`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = '${this.client(translateDomain(domain))}'
|
||||
`
|
||||
return exists.length > 0
|
||||
// Always exists. We don't need to check for index existence
|
||||
return true
|
||||
}
|
||||
|
||||
async listDomains (): Promise<Set<Domain>> {
|
||||
@ -469,10 +465,8 @@ export class DBCollectionHelper implements DomainHelperOperations {
|
||||
}
|
||||
|
||||
async estimatedCount (domain: Domain): Promise<number> {
|
||||
const res = await this
|
||||
.client`SELECT COUNT(_id) FROM ${this.client(translateDomain(domain))} WHERE "workspaceId" = ${this.workspaceId.name}`
|
||||
|
||||
return res.count
|
||||
// We should always return 0, since no controlled index stuff is required for postgres driver
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import type { Account } from '@hcengineering/core'
|
||||
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
|
||||
import { Packr } from 'msgpackr'
|
||||
|
||||
@ -48,6 +49,7 @@ export interface HelloResponse extends Response<any> {
|
||||
serverVersion: string
|
||||
lastTx?: string
|
||||
lastHash?: string // Last model hash
|
||||
account: Account
|
||||
}
|
||||
|
||||
function replacer (key: string, value: any): any {
|
||||
|
@ -418,6 +418,7 @@ class TSessionManager implements SessionManager {
|
||||
})
|
||||
workspace = this.createWorkspace(
|
||||
ctx.parent ?? ctx,
|
||||
ctx,
|
||||
pipelineFactory,
|
||||
token,
|
||||
workspaceInfo.workspaceUrl ?? workspaceInfo.workspaceId,
|
||||
@ -435,7 +436,7 @@ class TSessionManager implements SessionManager {
|
||||
workspace: workspaceInfo.workspaceId,
|
||||
wsUrl: workspaceInfo.workspaceUrl
|
||||
})
|
||||
pipeline = await ctx.with('💤 wait', { workspaceName }, () => (workspace as Workspace).pipeline)
|
||||
pipeline = await ctx.with('💤 wait-pipeline', {}, () => (workspace as Workspace).pipeline)
|
||||
} else {
|
||||
ctx.warn('reconnect workspace in upgrade switch', {
|
||||
email: token.email,
|
||||
@ -466,9 +467,10 @@ class TSessionManager implements SessionManager {
|
||||
})
|
||||
return { upgrade: true }
|
||||
}
|
||||
|
||||
try {
|
||||
if (workspace.pipeline instanceof Promise) {
|
||||
pipeline = await workspace.pipeline
|
||||
pipeline = await ctx.with('💤 wait-pipeline', {}, () => (workspace as Workspace).pipeline)
|
||||
workspace.pipeline = pipeline
|
||||
} else {
|
||||
pipeline = workspace.pipeline
|
||||
@ -645,6 +647,7 @@ class TSessionManager implements SessionManager {
|
||||
|
||||
private createWorkspace (
|
||||
ctx: MeasureContext,
|
||||
pipelineCtx: MeasureContext,
|
||||
pipelineFactory: PipelineFactory,
|
||||
token: Token,
|
||||
workspaceUrl: string,
|
||||
@ -655,7 +658,6 @@ class TSessionManager implements SessionManager {
|
||||
const wsId = toWorkspaceString(token.workspace)
|
||||
const upgrade = token.extra?.model === 'upgrade'
|
||||
const context = ctx.newChild('🧲 session', {})
|
||||
const pipelineCtx = context.newChild('🧲 pipeline-factory', {})
|
||||
const workspace: Workspace = {
|
||||
context,
|
||||
id: generateId(),
|
||||
@ -1106,7 +1108,8 @@ class TSessionManager implements SessionManager {
|
||||
reconnect,
|
||||
serverVersion: this.serverVersion,
|
||||
lastTx: pipeline.context.lastTx,
|
||||
lastHash: pipeline.context.lastHash
|
||||
lastHash: pipeline.context.lastHash,
|
||||
account: service.getRawAccount(pipeline)
|
||||
}
|
||||
ws.send(requestCtx, helloResponse, false, false)
|
||||
}
|
||||
|
@ -46,6 +46,7 @@
|
||||
"eslint-plugin-n": "^15.4.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"@types/uuid": "^8.3.1",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.1.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
@ -56,6 +57,8 @@
|
||||
"@hcengineering/account": "^0.6.0",
|
||||
"@hcengineering/ai-bot": "^0.6.0",
|
||||
"@hcengineering/analytics-collector": "^0.6.0",
|
||||
"@hcengineering/document": "^0.6.0",
|
||||
"@hcengineering/attachment": "^0.6.14",
|
||||
"@hcengineering/chunter": "^0.6.20",
|
||||
"@hcengineering/client": "^0.6.18",
|
||||
"@hcengineering/client-resources": "^0.6.27",
|
||||
@ -72,6 +75,8 @@
|
||||
"@hcengineering/server-token": "^0.6.11",
|
||||
"@hcengineering/setting": "^0.6.17",
|
||||
"@hcengineering/text": "^0.6.5",
|
||||
"@hcengineering/rank": "^0.6.4",
|
||||
"@hcengineering/server-storage": "^0.6.0",
|
||||
"@hcengineering/workbench": "^0.6.16",
|
||||
"@hcengineering/love": "^0.6.0",
|
||||
"cors": "^2.8.5",
|
||||
@ -80,6 +85,7 @@
|
||||
"fast-equals": "^5.0.1",
|
||||
"form-data": "^4.0.0",
|
||||
"js-tiktoken": "^1.0.14",
|
||||
"uuid": "^8.3.2",
|
||||
"mongodb": "^6.12.0",
|
||||
"openai": "^4.56.0",
|
||||
"ws": "^8.18.0"
|
||||
|
@ -36,6 +36,7 @@ interface Config {
|
||||
MaxHistoryRecords: number
|
||||
Port: number
|
||||
LoveEndpoint: string
|
||||
DataLabApiKey: string
|
||||
}
|
||||
|
||||
const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined)
|
||||
@ -61,7 +62,8 @@ const config: Config = (() => {
|
||||
MaxContentTokens: parseNumber(process.env.MAX_CONTENT_TOKENS) ?? 128 * 100,
|
||||
MaxHistoryRecords: parseNumber(process.env.MAX_HISTORY_RECORDS) ?? 500,
|
||||
Port: parseNumber(process.env.PORT) ?? 4010,
|
||||
LoveEndpoint: process.env.LOVE_ENDPOINT ?? ''
|
||||
LoveEndpoint: process.env.LOVE_ENDPOINT ?? '',
|
||||
DataLabApiKey: process.env.DATALAB_API_KEY ?? ''
|
||||
}
|
||||
|
||||
const missingEnv = (Object.keys(params) as Array<keyof Config>).filter((key) => params[key] === undefined)
|
||||
|
@ -29,22 +29,24 @@ import {
|
||||
TranslateRequest,
|
||||
TranslateResponse
|
||||
} from '@hcengineering/ai-bot'
|
||||
import { Markup, MeasureContext, Ref, WorkspaceId } from '@hcengineering/core'
|
||||
import { Room } from '@hcengineering/love'
|
||||
import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot'
|
||||
import { getTransactorEndpoint } from '@hcengineering/server-client'
|
||||
import { generateToken } from '@hcengineering/server-token'
|
||||
import OpenAI from 'openai'
|
||||
import { encodingForModel } from 'js-tiktoken'
|
||||
import { htmlToMarkup, markupToHTML } from '@hcengineering/text'
|
||||
import { Markup, MeasureContext, Ref, WorkspaceId } from '@hcengineering/core'
|
||||
import { Room } from '@hcengineering/love'
|
||||
import { encodingForModel } from 'js-tiktoken'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
import { WorkspaceClient } from './workspace/workspaceClient'
|
||||
import { StorageAdapter } from '@hcengineering/server-core'
|
||||
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
|
||||
import config from './config'
|
||||
import { DbStorage } from './storage'
|
||||
import { SupportWsClient } from './workspace/supportWsClient'
|
||||
import { AIReplyTransferData } from './types'
|
||||
import { tryAssignToWorkspace } from './utils/account'
|
||||
import { translateHtml } from './utils/openai'
|
||||
import { SupportWsClient } from './workspace/supportWsClient'
|
||||
import { WorkspaceClient } from './workspace/workspaceClient'
|
||||
|
||||
const CLOSE_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes
|
||||
|
||||
@ -54,6 +56,7 @@ export class AIControl {
|
||||
private readonly connectingWorkspaces = new Map<string, Promise<void>>()
|
||||
|
||||
readonly aiClient?: OpenAI
|
||||
readonly storageAdapter: StorageAdapter
|
||||
readonly encoding = encodingForModel(config.OpenAIModel)
|
||||
|
||||
supportClient: SupportWsClient | undefined = undefined
|
||||
@ -70,6 +73,7 @@ export class AIControl {
|
||||
})
|
||||
: undefined
|
||||
void this.connectSupportWorkspace()
|
||||
this.storageAdapter = buildStorageFromConfig(storageConfigFromEnv())
|
||||
}
|
||||
|
||||
async getWorkspaceRecord (workspace: string): Promise<WorkspaceInfoRecord> {
|
||||
@ -125,10 +129,26 @@ export class AIControl {
|
||||
this.ctx.info('Listen workspace: ', { workspace })
|
||||
|
||||
if (workspace === config.SupportWorkspace) {
|
||||
return new SupportWsClient(endpoint, token, workspace, this, this.ctx.newChild(workspace, {}), info)
|
||||
return new SupportWsClient(
|
||||
this.storageAdapter,
|
||||
endpoint,
|
||||
token,
|
||||
workspace,
|
||||
this,
|
||||
this.ctx.newChild(workspace, {}),
|
||||
info
|
||||
)
|
||||
}
|
||||
|
||||
return new WorkspaceClient(endpoint, token, workspace, this, this.ctx.newChild(workspace, {}), info)
|
||||
return new WorkspaceClient(
|
||||
this.storageAdapter,
|
||||
endpoint,
|
||||
token,
|
||||
workspace,
|
||||
this,
|
||||
this.ctx.newChild(workspace, {}),
|
||||
info
|
||||
)
|
||||
}
|
||||
|
||||
async initWorkspaceClient (workspace: string): Promise<void> {
|
||||
|
@ -13,12 +13,13 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import OpenAI from 'openai'
|
||||
import { countTokens } from '@hcengineering/openai'
|
||||
import { Tiktoken } from 'js-tiktoken'
|
||||
|
||||
import OpenAI from 'openai'
|
||||
import config from '../config'
|
||||
import { HistoryRecord } from '../types'
|
||||
import { WorkspaceClient } from '../workspace/workspaceClient'
|
||||
import { getTools } from './tools'
|
||||
|
||||
export async function translateHtml (client: OpenAI, html: string, lang: string): Promise<string | undefined> {
|
||||
const response = await client.chat.completions.create({
|
||||
@ -66,6 +67,58 @@ export async function createChatCompletion (
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function createChatCompletionWithTools (
|
||||
workspaceClient: WorkspaceClient,
|
||||
client: OpenAI,
|
||||
message: OpenAI.ChatCompletionMessageParam,
|
||||
user?: string,
|
||||
history: OpenAI.ChatCompletionMessageParam[] = [],
|
||||
skipCache = true
|
||||
): Promise<
|
||||
| {
|
||||
completion: string | undefined
|
||||
usage: number
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
const opt: OpenAI.RequestOptions = {}
|
||||
if (skipCache) {
|
||||
opt.headers = { 'cf-skip-cache': 'true' }
|
||||
}
|
||||
try {
|
||||
const res = client.beta.chat.completions
|
||||
.runTools(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Use tools if possible, don`t use previous information after success using tool for user request'
|
||||
},
|
||||
...history,
|
||||
message
|
||||
],
|
||||
model: config.OpenAIModel,
|
||||
user,
|
||||
tools: getTools(workspaceClient, user)
|
||||
},
|
||||
opt
|
||||
)
|
||||
.on('message', (message) => {
|
||||
console.log(message)
|
||||
})
|
||||
const str = await res.finalContent()
|
||||
const usage = (await res.totalUsage()).completion_tokens
|
||||
return {
|
||||
completion: str ?? undefined,
|
||||
usage
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function requestSummary (
|
||||
aiClient: OpenAI,
|
||||
encoding: Tiktoken,
|
||||
|
243
services/ai-bot/pod-ai-bot/src/utils/tools.ts
Normal file
243
services/ai-bot/pod-ai-bot/src/utils/tools.ts
Normal file
@ -0,0 +1,243 @@
|
||||
import { Account, MarkupBlobRef, Ref } from '@hcengineering/core'
|
||||
import document, { Document, getFirstRank, Teamspace } from '@hcengineering/document'
|
||||
import { makeRank } from '@hcengineering/rank'
|
||||
import { parseMessageMarkdown } from '@hcengineering/text'
|
||||
import {
|
||||
BaseFunctionsArgs,
|
||||
RunnableFunctionWithoutParse,
|
||||
RunnableFunctionWithParse,
|
||||
RunnableToolFunction,
|
||||
RunnableToolFunctionWithoutParse,
|
||||
RunnableToolFunctionWithParse,
|
||||
RunnableTools
|
||||
} from 'openai/lib/RunnableFunction'
|
||||
import { Stream } from 'stream'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import config from '../config'
|
||||
import { WorkspaceClient } from '../workspace/workspaceClient'
|
||||
|
||||
async function stream2buffer (stream: Stream): Promise<Buffer> {
|
||||
return await new Promise<Buffer>((resolve, reject) => {
|
||||
const _buf = Array<any>()
|
||||
stream.on('data', (chunk) => {
|
||||
_buf.push(chunk)
|
||||
})
|
||||
stream.on('end', () => {
|
||||
resolve(Buffer.concat(_buf))
|
||||
})
|
||||
stream.on('error', (err) => {
|
||||
reject(new Error(`error converting stream - ${err}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function pdfToMarkdown (
|
||||
workspaceClient: WorkspaceClient,
|
||||
fileId: string,
|
||||
name: string | undefined
|
||||
): Promise<string | undefined> {
|
||||
if (config.DataLabApiKey !== '') {
|
||||
try {
|
||||
const stat = await workspaceClient.storage.stat(workspaceClient.ctx, { name: workspaceClient.workspace }, fileId)
|
||||
if (stat?.contentType !== 'application/pdf') {
|
||||
return
|
||||
}
|
||||
const file = await workspaceClient.storage.get(workspaceClient.ctx, { name: workspaceClient.workspace }, fileId)
|
||||
const buffer = await stream2buffer(file)
|
||||
|
||||
const url = 'https://www.datalab.to/api/v1/marker'
|
||||
const formData = new FormData()
|
||||
formData.append('file', new Blob([buffer], { type: 'application/pdf' }), name ?? 'test.pdf')
|
||||
formData.append('force_ocr', 'false')
|
||||
formData.append('paginate', 'false')
|
||||
formData.append('output_format', 'markdown')
|
||||
formData.append('use_llm', 'false')
|
||||
formData.append('strip_existing_ocr', 'false')
|
||||
formData.append('disable_image_extraction', 'false')
|
||||
|
||||
const headers = { 'X-Api-Key': config.DataLabApiKey }
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
console.log('data', data)
|
||||
if (data.request_check_url !== undefined) {
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const resp = await fetch(data.request_check_url, { headers })
|
||||
const result = await resp.json()
|
||||
if (result.status === 'complete' && result.markdown !== undefined) {
|
||||
return result.markdown
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile (
|
||||
workspaceClient: WorkspaceClient,
|
||||
user: string | undefined,
|
||||
args: { fileId: string, folder: string | undefined, parent: string | undefined, name: string }
|
||||
): Promise<string> {
|
||||
console.log('Save file', args)
|
||||
const content = await pdfToMarkdown(workspaceClient, args.fileId, args.name)
|
||||
if (content === undefined) {
|
||||
return 'Error while converting pdf to markdown'
|
||||
}
|
||||
const converted = JSON.stringify(parseMessageMarkdown(content, 'image://'))
|
||||
|
||||
const client = await workspaceClient.opClient
|
||||
const fileId = uuid()
|
||||
await workspaceClient.storage.put(
|
||||
workspaceClient.ctx,
|
||||
{ name: workspaceClient.workspace },
|
||||
fileId,
|
||||
converted,
|
||||
'application/json'
|
||||
)
|
||||
|
||||
const teamspaces = await client.findAll(document.class.Teamspace, {})
|
||||
const parent = await client.findOne(document.class.Document, { _id: args.parent as Ref<Document> })
|
||||
const teamspaceId = getTeamspace(args.folder, parent, teamspaces)
|
||||
const parentId = parent?._id ?? document.ids.NoParent
|
||||
const lastRank = await getFirstRank(client, teamspaceId, parentId)
|
||||
const rank = makeRank(lastRank, undefined)
|
||||
const _id = await client.createDoc(document.class.Document, teamspaceId, {
|
||||
title: args.name,
|
||||
parent: parentId,
|
||||
content: fileId as MarkupBlobRef,
|
||||
rank
|
||||
})
|
||||
|
||||
return `File saved as ${args.name} with id ${_id}, always provide mention link as: [](ref://?_class=document%3Aclass%3ADocument&_id=${_id}&label=${args.name})`
|
||||
}
|
||||
|
||||
function getTeamspace (
|
||||
folder: string | undefined,
|
||||
parent: Document | undefined,
|
||||
teamspaces: Teamspace[]
|
||||
): Ref<Teamspace> {
|
||||
if (parent !== undefined) return parent.space
|
||||
if (folder !== undefined) {
|
||||
const teamspace = teamspaces.find(
|
||||
(p) => p.name.trim().toLowerCase() === folder.trim().toLowerCase() || p._id === folder
|
||||
)
|
||||
if (teamspace !== undefined) return teamspace._id
|
||||
}
|
||||
return teamspaces[0]._id
|
||||
}
|
||||
|
||||
async function getFoldersForDocuments (
|
||||
workspaceClient: WorkspaceClient,
|
||||
user: string | undefined,
|
||||
args: Record<string, any>
|
||||
): Promise<string> {
|
||||
const client = await workspaceClient.opClient
|
||||
const spaces = await client.findAll(
|
||||
document.class.Teamspace,
|
||||
user !== undefined ? { members: user as Ref<Account>, archived: false } : { archived: false }
|
||||
)
|
||||
let res = 'Folders:\n'
|
||||
for (const space of spaces) {
|
||||
res += `Id: ${space._id} Name: ${space.name}\n`
|
||||
}
|
||||
res += 'Parents:\n'
|
||||
const parents = await client.findAll(document.class.Document, { space: { $in: spaces.map((p) => p._id) } })
|
||||
for (const parent of parents) {
|
||||
res += `Id: ${parent._id} Name: ${parent.title}\n`
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
type ChangeFields<T, R> = Omit<T, keyof R> & R
|
||||
type PredefinedTool<T extends object | string> = ChangeFields<
|
||||
RunnableToolFunction<T>,
|
||||
{
|
||||
function: PredefinedToolFunction<T>
|
||||
}
|
||||
>
|
||||
type PredefinedToolFunction<T extends object | string> = Omit<
|
||||
T extends string ? RunnableFunctionWithoutParse : RunnableFunctionWithParse<any>,
|
||||
'function'
|
||||
>
|
||||
type ToolFunc = (workspaceClient: WorkspaceClient, user: string | undefined, args: any) => Promise<string> | string
|
||||
|
||||
const tools: [PredefinedTool<any>, ToolFunc][] = []
|
||||
|
||||
export function registerTool<T extends object | string> (tool: PredefinedTool<T>, func: ToolFunc): void {
|
||||
tools.push([tool, func])
|
||||
}
|
||||
|
||||
registerTool(
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'getDataBeforeImport',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
},
|
||||
description:
|
||||
'Get folders and parents for documents. This step necessery before saveFile tool. YOU MUST USE IT BEFORE import file.'
|
||||
}
|
||||
},
|
||||
getFoldersForDocuments
|
||||
)
|
||||
|
||||
registerTool<object>(
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'saveFile',
|
||||
parse: JSON.parse,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['fileId, folder, name'],
|
||||
properties: {
|
||||
fileId: { type: 'string', description: 'File id to parse' },
|
||||
folder: {
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Folder, id from getDataBeforeImport. If not provided you can guess by file name and folder name, or by another file names, if you can`t, just ask user. Don`t provide empty, this field is required. If no folders at all, you should stop pipeline execution and ask user to create teamspace'
|
||||
},
|
||||
parent: {
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Parent document, use id from getDataBeforeImport, leave empty string if not provided, it is not necessery, please feel free to pass empty string'
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Name for file, try to recognize from user input, if not provided use attached file name'
|
||||
}
|
||||
}
|
||||
},
|
||||
description:
|
||||
'Parse pdf to markdown and save it, using for import files. Use only if provide file in current message and user require to import/save, if file not provided ask user to attach it. You MUST call getDataBeforeImport tool before for get ids. Use file name as name if user not provide it, don`t use old parameters. You can ask user about folder if you have not enough data to get folder id'
|
||||
}
|
||||
},
|
||||
saveFile
|
||||
)
|
||||
|
||||
export function getTools (workspaceClient: WorkspaceClient, user: string | undefined): RunnableTools<BaseFunctionsArgs> {
|
||||
const result: (RunnableToolFunctionWithoutParse | RunnableToolFunctionWithParse<any>)[] = []
|
||||
for (const tool of tools) {
|
||||
const res: RunnableToolFunctionWithoutParse | RunnableToolFunctionWithParse<any> = {
|
||||
...tool[0],
|
||||
function: {
|
||||
...tool[0].function,
|
||||
function: (args: any) => tool[1](workspaceClient, user, args)
|
||||
}
|
||||
}
|
||||
result.push(res)
|
||||
}
|
||||
return result
|
||||
}
|
@ -22,6 +22,7 @@ import aiBot, {
|
||||
IdentityResponse
|
||||
} from '@hcengineering/ai-bot'
|
||||
import analyticsCollector, { OnboardingChannel } from '@hcengineering/analytics-collector'
|
||||
import attachment, { Attachment } from '@hcengineering/attachment'
|
||||
import chunter, {
|
||||
ChatMessage,
|
||||
type ChatWidgetTab,
|
||||
@ -59,7 +60,7 @@ import { Room } from '@hcengineering/love'
|
||||
import { countTokens } from '@hcengineering/openai'
|
||||
import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot'
|
||||
import { getOrCreateOnboardingChannel } from '@hcengineering/server-analytics-collector-resources'
|
||||
import { BlobClient, login } from '@hcengineering/server-client'
|
||||
import { login } from '@hcengineering/server-client'
|
||||
import { generateToken } from '@hcengineering/server-token'
|
||||
import { jsonToMarkup, MarkdownParser, markupToText } from '@hcengineering/text'
|
||||
import workbench, { SidebarEvent, TxSidebarEvent } from '@hcengineering/workbench'
|
||||
@ -67,10 +68,11 @@ import fs from 'fs'
|
||||
import { WithId } from 'mongodb'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
import { StorageAdapter } from '@hcengineering/server-core'
|
||||
import config from '../config'
|
||||
import { AIControl } from '../controller'
|
||||
import { HistoryRecord } from '../types'
|
||||
import { createChatCompletion, requestSummary } from '../utils/openai'
|
||||
import { createChatCompletionWithTools, requestSummary } from '../utils/openai'
|
||||
import { connectPlatform, getDirect } from '../utils/platform'
|
||||
import { LoveController } from './love'
|
||||
|
||||
@ -81,8 +83,6 @@ export class WorkspaceClient {
|
||||
client: Client | undefined
|
||||
opClient: Promise<TxOperations> | TxOperations
|
||||
|
||||
blobClient: BlobClient
|
||||
|
||||
loginTimeout: NodeJS.Timeout | undefined
|
||||
loginDelayMs = 2 * 1000
|
||||
|
||||
@ -103,6 +103,7 @@ export class WorkspaceClient {
|
||||
love: LoveController | undefined
|
||||
|
||||
constructor (
|
||||
readonly storage: StorageAdapter,
|
||||
readonly transactorUrl: string,
|
||||
readonly token: string,
|
||||
readonly workspace: string,
|
||||
@ -110,7 +111,6 @@ export class WorkspaceClient {
|
||||
readonly ctx: MeasureContext,
|
||||
readonly info: WorkspaceInfoRecord | undefined
|
||||
) {
|
||||
this.blobClient = new BlobClient(transactorUrl, token, { name: this.workspace })
|
||||
this.opClient = this.initClient()
|
||||
void this.opClient.then((opClient) => {
|
||||
this.opClient = opClient
|
||||
@ -154,7 +154,14 @@ export class WorkspaceClient {
|
||||
if (!isAlreadyUploaded) {
|
||||
const data = fs.readFileSync(config.AvatarPath)
|
||||
|
||||
await this.blobClient.upload(this.ctx, config.AvatarName, data.length, config.AvatarContentType, data)
|
||||
await this.storage.put(
|
||||
this.ctx,
|
||||
{ name: this.workspace },
|
||||
config.AvatarName,
|
||||
data,
|
||||
config.AvatarContentType,
|
||||
data.length
|
||||
)
|
||||
await this.controller.updateAvatarInfo(this.workspace, config.AvatarPath, lastModified)
|
||||
this.ctx.info('Avatar file uploaded successfully', { workspace: this.workspace, path: config.AvatarPath })
|
||||
}
|
||||
@ -209,9 +216,9 @@ export class WorkspaceClient {
|
||||
return
|
||||
}
|
||||
|
||||
const exist = await this.blobClient.checkFile(this.ctx, config.AvatarName)
|
||||
const exist = await this.storage.stat(this.ctx, { name: this.workspace }, config.AvatarName)
|
||||
|
||||
if (!exist) {
|
||||
if (exist === undefined) {
|
||||
this.ctx.error('Cannot find file', { file: config.AvatarName, workspace: this.workspace })
|
||||
return
|
||||
}
|
||||
@ -449,11 +456,23 @@ export class WorkspaceClient {
|
||||
this.historyMap.set(objectId, currentHistory)
|
||||
}
|
||||
|
||||
async getAttachments (client: TxOperations, objectId: Ref<Doc>): Promise<Attachment[]> {
|
||||
return await client.findAll(attachment.class.Attachment, { attachedTo: objectId })
|
||||
}
|
||||
|
||||
async processMessageEvent (event: AIMessageEventRequest): Promise<void> {
|
||||
if (this.controller.aiClient === undefined) return
|
||||
|
||||
const { user, objectId, objectClass, messageClass } = event
|
||||
const promptText = markupToText(event.message)
|
||||
const client = await this.opClient
|
||||
let promptText = markupToText(event.message)
|
||||
const files = await this.getAttachments(client, event.messageId)
|
||||
if (files.length > 0) {
|
||||
promptText += '\n\nAttachments:'
|
||||
for (const file of files) {
|
||||
promptText += `\nName:${file.name} FileId:${file.file} Type:${file.type}`
|
||||
}
|
||||
}
|
||||
const prompt: OpenAI.ChatCompletionMessageParam = { content: promptText, role: 'user' }
|
||||
const promptTokens = countTokens([prompt], this.controller.encoding)
|
||||
|
||||
@ -462,7 +481,6 @@ export class WorkspaceClient {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await this.opClient
|
||||
const op = client.apply(undefined, 'AIMessageRequestEvent')
|
||||
const hierarchy = client.getHierarchy()
|
||||
|
||||
@ -479,16 +497,15 @@ export class WorkspaceClient {
|
||||
|
||||
void this.pushHistory(promptText, prompt.role, promptTokens, user, objectId, objectClass)
|
||||
|
||||
const chatCompletion = await createChatCompletion(this.controller.aiClient, prompt, user, history)
|
||||
const response = chatCompletion?.choices[0].message.content
|
||||
const chatCompletion = await createChatCompletionWithTools(this, this.controller.aiClient, prompt, user, history)
|
||||
const response = chatCompletion?.completion
|
||||
|
||||
if (response == null) {
|
||||
await this.finishTyping(client, objectId)
|
||||
return
|
||||
}
|
||||
const responseTokens =
|
||||
chatCompletion?.usage?.completion_tokens ??
|
||||
countTokens([{ content: response, role: 'assistant' }], this.controller.encoding)
|
||||
chatCompletion?.usage ?? countTokens([{ content: response, role: 'assistant' }], this.controller.encoding)
|
||||
|
||||
void this.pushHistory(response, 'assistant', responseTokens, user, objectId, objectClass)
|
||||
|
||||
|
@ -660,6 +660,7 @@ A list of closed updated issues`
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
attrs: { textAlign: null },
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
@ -689,7 +690,7 @@ A list of closed updated issues`
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.heading,
|
||||
attrs: { level: 1 },
|
||||
attrs: { level: 1, textAlign: null },
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
@ -699,6 +700,7 @@ A list of closed updated issues`
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
attrs: { textAlign: null },
|
||||
content: [
|
||||
{
|
||||
text: '\n',
|
||||
@ -708,7 +710,7 @@ A list of closed updated issues`
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.heading,
|
||||
attrs: { level: 2 },
|
||||
attrs: { level: 2, textAlign: null },
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
|
@ -65,6 +65,8 @@
|
||||
"@hcengineering/platform": "^0.6.11",
|
||||
"@hcengineering/server-client": "^0.6.0",
|
||||
"@hcengineering/server-token": "^0.6.11",
|
||||
"@hcengineering/datalake": "^0.6.0",
|
||||
"@hcengineering/s3": "^0.6.0",
|
||||
"livekit-server-sdk": "^2.0.10",
|
||||
"jwt-simple": "^0.5.6",
|
||||
"uuid": "^8.3.2",
|
||||
|
@ -24,6 +24,7 @@ interface Config {
|
||||
|
||||
StorageConfig: string
|
||||
StorageProviderName: string
|
||||
S3StorageConfig: string
|
||||
Secret: string
|
||||
|
||||
MongoUrl: string
|
||||
@ -39,6 +40,7 @@ const envMap: { [key in keyof Config]: string } = {
|
||||
|
||||
StorageConfig: 'STORAGE_CONFIG',
|
||||
StorageProviderName: 'STORAGE_PROVIDER_NAME',
|
||||
S3StorageConfig: 'S3_STORAGE_CONFIG',
|
||||
Secret: 'SECRET',
|
||||
ServiceID: 'SERVICE_ID',
|
||||
MongoUrl: 'MONGO_URL'
|
||||
@ -55,12 +57,13 @@ const config: Config = (() => {
|
||||
ApiSecret: process.env[envMap.ApiSecret],
|
||||
StorageConfig: process.env[envMap.StorageConfig],
|
||||
StorageProviderName: process.env[envMap.StorageProviderName] ?? 's3',
|
||||
S3StorageConfig: process.env[envMap.S3StorageConfig],
|
||||
Secret: process.env[envMap.Secret],
|
||||
ServiceID: process.env[envMap.ServiceID] ?? 'love-service',
|
||||
MongoUrl: process.env[envMap.MongoUrl]
|
||||
}
|
||||
|
||||
const optional = ['StorageConfig']
|
||||
const optional = ['StorageConfig', 'S3StorageConfig']
|
||||
|
||||
const missingEnv = (Object.keys(params) as Array<keyof Config>)
|
||||
.filter((key) => !optional.includes(key))
|
||||
|
@ -13,11 +13,11 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { Ref, toWorkspaceString, WorkspaceId } from '@hcengineering/core'
|
||||
import { MeasureContext, Ref, WorkspaceId } from '@hcengineering/core'
|
||||
import { setMetadata } from '@hcengineering/platform'
|
||||
import serverClient from '@hcengineering/server-client'
|
||||
import { initStatisticsContext, StorageConfig, StorageConfiguration } from '@hcengineering/server-core'
|
||||
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
|
||||
import { storageConfigFromEnv } from '@hcengineering/server-storage'
|
||||
import serverToken, { decodeToken } from '@hcengineering/server-token'
|
||||
import { RoomMetadata, TranscriptionStatus, MeetingMinutes } from '@hcengineering/love'
|
||||
import cors from 'cors'
|
||||
@ -32,8 +32,8 @@ import {
|
||||
S3Upload,
|
||||
WebhookReceiver
|
||||
} from 'livekit-server-sdk'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import config from './config'
|
||||
import { getS3UploadParams, saveFile } from './storage'
|
||||
import { WorkspaceClient } from './workspaceClient'
|
||||
|
||||
const extractToken = (header: IncomingHttpHeaders): any => {
|
||||
@ -50,11 +50,14 @@ export const main = async (): Promise<void> => {
|
||||
setMetadata(serverToken.metadata.Secret, config.Secret)
|
||||
|
||||
const storageConfigs: StorageConfiguration = storageConfigFromEnv()
|
||||
const s3StorageConfigs: StorageConfiguration | undefined =
|
||||
config.S3StorageConfig !== undefined ? storageConfigFromEnv(config.S3StorageConfig) : undefined
|
||||
|
||||
const ctx = initStatisticsContext('love', {})
|
||||
|
||||
const storageConfig = storageConfigs.storages.findLast((p) => p.name === config.StorageProviderName)
|
||||
const storageAdapter = buildStorageFromConfig(storageConfigs)
|
||||
const s3storageConfig = s3StorageConfigs?.storages.findLast((p) => p.kind === 's3')
|
||||
|
||||
const app = express()
|
||||
const port = config.Port
|
||||
app.use(cors())
|
||||
@ -81,13 +84,11 @@ export const main = async (): Promise<void> => {
|
||||
if (event.event === 'egress_ended' && event.egressInfo !== undefined) {
|
||||
for (const res of event.egressInfo.fileResults) {
|
||||
const data = dataByUUID.get(res.filename)
|
||||
if (data !== undefined) {
|
||||
const prefix = rootPrefix(storageConfig, data.workspaceId)
|
||||
const filename = stripPrefix(prefix, res.filename)
|
||||
const storedBlob = await storageAdapter.stat(ctx, data.workspaceId, filename)
|
||||
if (data !== undefined && storageConfig !== undefined) {
|
||||
const storedBlob = await saveFile(ctx, data.workspaceId, storageConfig, s3storageConfig, res.filename)
|
||||
if (storedBlob !== undefined) {
|
||||
const client = await WorkspaceClient.create(data.workspace, ctx)
|
||||
await client.saveFile(filename, data.name, storedBlob, data.meetingMinutes)
|
||||
await client.saveFile(storedBlob._id, data.name, storedBlob, data.meetingMinutes)
|
||||
await client.close()
|
||||
}
|
||||
dataByUUID.delete(res.filename)
|
||||
@ -135,7 +136,7 @@ export const main = async (): Promise<void> => {
|
||||
try {
|
||||
const dateStr = new Date().toISOString().replace('T', '_').slice(0, 19)
|
||||
const name = `${room}_${dateStr}.mp4`
|
||||
const id = await startRecord(storageConfig, egressClient, roomClient, roomName, workspace)
|
||||
const id = await startRecord(ctx, storageConfig, s3storageConfig, egressClient, roomClient, roomName, workspace)
|
||||
dataByUUID.set(id, { name, workspace: workspace.name, workspaceId: workspace, meetingMinutes })
|
||||
ctx.info('Start recording', { workspace: workspace.name, roomName, meetingMinutes })
|
||||
res.send()
|
||||
@ -257,50 +258,26 @@ const checkRecordAvailable = async (storageConfig: StorageConfig | undefined): P
|
||||
return storageConfig !== undefined
|
||||
}
|
||||
|
||||
function getBucket (storageConfig: any, workspaceId: WorkspaceId): string {
|
||||
return storageConfig.rootBucket ?? (storageConfig.bucketPrefix ?? '') + toWorkspaceString(workspaceId)
|
||||
}
|
||||
|
||||
function getBucketFolder (workspaceId: WorkspaceId): string {
|
||||
return toWorkspaceString(workspaceId)
|
||||
}
|
||||
|
||||
function getDocumentKey (storageConfig: any, workspace: WorkspaceId, name: string): string {
|
||||
return storageConfig.rootBucket === undefined ? name : `${getBucketFolder(workspace)}/${name}`
|
||||
}
|
||||
|
||||
function stripPrefix (prefix: string | undefined, key: string): string {
|
||||
if (prefix !== undefined && key.startsWith(prefix)) {
|
||||
return key.slice(prefix.length)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
function rootPrefix (storageConfig: any, workspaceId: WorkspaceId): string | undefined {
|
||||
return storageConfig.rootBucket !== undefined ? getBucketFolder(workspaceId) + '/' : undefined
|
||||
}
|
||||
|
||||
const startRecord = async (
|
||||
ctx: MeasureContext,
|
||||
storageConfig: StorageConfig | undefined,
|
||||
s3StorageConfig: StorageConfig | undefined,
|
||||
egressClient: EgressClient,
|
||||
roomClient: RoomServiceClient,
|
||||
roomName: string,
|
||||
workspaceId: WorkspaceId
|
||||
): Promise<string> => {
|
||||
if (storageConfig === undefined) {
|
||||
console.error('please provide s3 storage configuration')
|
||||
throw new Error('please provide s3 storage configuration')
|
||||
console.error('please provide storage configuration')
|
||||
throw new Error('please provide storage configuration')
|
||||
}
|
||||
const endpoint = storageConfig.endpoint
|
||||
const accessKey = (storageConfig as any).accessKey
|
||||
const secret = (storageConfig as any).secretKey
|
||||
const region = (storageConfig as any).region ?? 'auto'
|
||||
const bucket = getBucket(storageConfig, workspaceId)
|
||||
const name = uuid()
|
||||
const filepath = getDocumentKey(storageConfig, workspaceId, `${name}.mp4`)
|
||||
const uploadParams = await getS3UploadParams(ctx, workspaceId, storageConfig, s3StorageConfig)
|
||||
|
||||
const { filepath, endpoint, accessKey, secret, region, bucket } = uploadParams
|
||||
const output = new EncodedFileOutput({
|
||||
fileType: EncodedFileType.MP4,
|
||||
filepath,
|
||||
disableManifest: true,
|
||||
output: {
|
||||
case: 's3',
|
||||
value: new S3Upload({
|
||||
|
185
services/love/src/storage.ts
Normal file
185
services/love/src/storage.ts
Normal file
@ -0,0 +1,185 @@
|
||||
//
|
||||
// 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 { Blob, MeasureContext, toWorkspaceString, WorkspaceId } from '@hcengineering/core'
|
||||
import { DatalakeConfig, DatalakeService, createDatalakeClient } from '@hcengineering/datalake'
|
||||
import { S3Config, S3Service } from '@hcengineering/s3'
|
||||
import { StorageConfig } from '@hcengineering/server-core'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
export interface S3UploadParams {
|
||||
filepath: string
|
||||
endpoint: string
|
||||
accessKey: string
|
||||
region: string
|
||||
secret: string
|
||||
bucket: string
|
||||
}
|
||||
|
||||
export async function getS3UploadParams (
|
||||
ctx: MeasureContext,
|
||||
workspaceId: WorkspaceId,
|
||||
storageConfig: StorageConfig,
|
||||
s3StorageConfig: StorageConfig | undefined
|
||||
): Promise<S3UploadParams> {
|
||||
if (storageConfig.kind === 's3') {
|
||||
if (storageConfig.kind !== 's3') {
|
||||
throw new Error('Please provide S3 storage config')
|
||||
}
|
||||
return await getS3UploadParamsS3(ctx, workspaceId, storageConfig as S3Config)
|
||||
} else if (storageConfig.kind === 'datalake') {
|
||||
if (s3StorageConfig === undefined || s3StorageConfig.kind !== 's3') {
|
||||
throw new Error('Please provide S3 storage config')
|
||||
}
|
||||
return await getS3UploadParamsDatalake(
|
||||
ctx,
|
||||
workspaceId,
|
||||
storageConfig as DatalakeConfig,
|
||||
s3StorageConfig as S3Config
|
||||
)
|
||||
} else {
|
||||
throw new Error('Unknown storage kind: ' + storageConfig.kind)
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveFile (
|
||||
ctx: MeasureContext,
|
||||
workspaceId: WorkspaceId,
|
||||
storageConfig: StorageConfig,
|
||||
s3StorageConfig: StorageConfig | undefined,
|
||||
filename: string
|
||||
): Promise<Blob | undefined> {
|
||||
if (storageConfig.kind === 's3') {
|
||||
if (storageConfig.kind !== 's3') {
|
||||
throw new Error('Please provide S3 storage config')
|
||||
}
|
||||
return await saveFileToS3(ctx, workspaceId, storageConfig as S3Config, filename)
|
||||
} else if (storageConfig.kind === 'datalake') {
|
||||
if (s3StorageConfig === undefined || s3StorageConfig.kind !== 's3') {
|
||||
throw new Error('Please provide S3 storage config')
|
||||
}
|
||||
return await saveFileToDatalake(
|
||||
ctx,
|
||||
workspaceId,
|
||||
storageConfig as DatalakeConfig,
|
||||
s3StorageConfig as S3Config,
|
||||
filename
|
||||
)
|
||||
} else {
|
||||
throw new Error('Unknown storage kind: ' + storageConfig.kind)
|
||||
}
|
||||
}
|
||||
|
||||
async function getS3UploadParamsS3 (
|
||||
ctx: MeasureContext,
|
||||
workspaceId: WorkspaceId,
|
||||
storageConfig: S3Config
|
||||
): Promise<S3UploadParams> {
|
||||
const endpoint = storageConfig.endpoint
|
||||
const accessKey = storageConfig.accessKey
|
||||
const secret = storageConfig.secretKey
|
||||
const region = storageConfig.region ?? 'auto'
|
||||
const bucket = getBucket(storageConfig, workspaceId)
|
||||
const name = uuid()
|
||||
const filepath = getDocumentKey(storageConfig, workspaceId, `${name}.mp4`)
|
||||
|
||||
return {
|
||||
filepath,
|
||||
endpoint,
|
||||
accessKey,
|
||||
region,
|
||||
secret,
|
||||
bucket
|
||||
}
|
||||
}
|
||||
|
||||
async function getS3UploadParamsDatalake (
|
||||
ctx: MeasureContext,
|
||||
workspaceId: WorkspaceId,
|
||||
config: DatalakeConfig,
|
||||
s3config: S3Config
|
||||
): Promise<S3UploadParams> {
|
||||
const client = createDatalakeClient(config)
|
||||
const { bucket } = await client.getR2UploadParams(ctx, workspaceId)
|
||||
|
||||
const endpoint = s3config.endpoint
|
||||
const accessKey = s3config.accessKey
|
||||
const secret = s3config.secretKey
|
||||
const region = s3config.region ?? 'auto'
|
||||
const name = uuid()
|
||||
const filepath = getDocumentKey(s3config, workspaceId, `${name}.mp4`)
|
||||
|
||||
return {
|
||||
filepath,
|
||||
endpoint,
|
||||
accessKey,
|
||||
region,
|
||||
secret,
|
||||
bucket
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFileToS3 (
|
||||
ctx: MeasureContext,
|
||||
workspaceId: WorkspaceId,
|
||||
config: S3Config,
|
||||
filename: string
|
||||
): Promise<Blob | undefined> {
|
||||
const storageAdapter = new S3Service(config)
|
||||
const prefix = rootPrefix(config, workspaceId)
|
||||
const uuid = stripPrefix(prefix, filename)
|
||||
return await storageAdapter.stat(ctx, workspaceId, uuid)
|
||||
}
|
||||
|
||||
async function saveFileToDatalake (
|
||||
ctx: MeasureContext,
|
||||
workspaceId: WorkspaceId,
|
||||
config: DatalakeConfig,
|
||||
s3config: S3Config,
|
||||
filename: string
|
||||
): Promise<Blob | undefined> {
|
||||
const client = createDatalakeClient(config)
|
||||
const storageAdapter = new DatalakeService(config)
|
||||
|
||||
const prefix = rootPrefix(s3config, workspaceId)
|
||||
const uuid = stripPrefix(prefix, filename)
|
||||
|
||||
await client.uploadFromR2(ctx, workspaceId, uuid, { filename: uuid })
|
||||
|
||||
return await storageAdapter.stat(ctx, workspaceId, uuid)
|
||||
}
|
||||
|
||||
function getBucket (storageConfig: S3Config, workspaceId: WorkspaceId): string {
|
||||
return storageConfig.rootBucket ?? (storageConfig.bucketPrefix ?? '') + toWorkspaceString(workspaceId)
|
||||
}
|
||||
|
||||
function getBucketFolder (workspaceId: WorkspaceId): string {
|
||||
return toWorkspaceString(workspaceId)
|
||||
}
|
||||
|
||||
function getDocumentKey (storageConfig: any, workspace: WorkspaceId, name: string): string {
|
||||
return storageConfig.rootBucket === undefined ? name : `${getBucketFolder(workspace)}/${name}`
|
||||
}
|
||||
|
||||
function stripPrefix (prefix: string | undefined, key: string): string {
|
||||
if (prefix !== undefined && key.startsWith(prefix)) {
|
||||
return key.slice(prefix.length)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
function rootPrefix (storageConfig: S3Config, workspaceId: WorkspaceId): string | undefined {
|
||||
return storageConfig.rootBucket !== undefined ? getBucketFolder(workspaceId) + '/' : undefined
|
||||
}
|
Loading…
Reference in New Issue
Block a user