Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artem Savchenko
2025-10-27 09:20:47 +07:00
122 changed files with 2546 additions and 860 deletions
+2 -1
View File
@@ -694,7 +694,8 @@
"ACCOUNTS_URL": "http://localhost:3000",
"QUEUE_CONFIG": "localhost:19092",
"QUEUE_REGION": "cockroach",
"TEMPORAL_ADDRESS": "localhost:7233"
"TEMPORAL_ADDRESS": "localhost:7233",
"COLLABORATOR_URL": "ws://localhost:3078"
},
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
"runtimeVersion": "20",
+570 -529
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -283,7 +283,7 @@
"electron-updater": "^6.3.4",
"livekit-client": "^2.15.6",
"@hcengineering/server-backup": "^0.7.0",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/communication-types": "^0.7.9",
"ws": "^8.18.2"
},
"productName": "Huly Desktop",
+18
View File
@@ -43,6 +43,10 @@
--huly-history-box-left-indent: 0;
}
.history-box {
display: none !important;
}
[data-theme="theme-dark"] {
--bg-body: #1a1928;
--bg-secondary: #323233;
@@ -207,6 +211,15 @@
user-select: none;
}
.desktop-app-control-button.desktop-app-back,
.desktop-app-control-button.desktop-app-forward {
font-size: 16px;
font-weight: 700;
line-height: 1;
padding-bottom: 2px;
-webkit-text-stroke: 0.5px currentColor;
}
.desktop-app-control-button:hover {
background-color: var(--bg-hover);
}
@@ -229,6 +242,11 @@
<div class="desktop-app-menu-container">
</div>
<div class="desktop-app-window-controls">
<button class="desktop-app-control-button desktop-app-back" id="back-button" title="Back">←</button>
<button class="desktop-app-control-button desktop-app-forward" id="forward-button" title="Forward">→</button>
</div>
<div class="desktop-app-window-title" id="application-title-bar-caption">Huly</div>
<div class="desktop-app-window-controls">
+8
View File
@@ -365,6 +365,14 @@ class MenuBarManager {
ipcMain.closeWindow()
})
this.onButtonClick('back-button', () => {
history.back()
})
this.onButtonClick('forward-button', () => {
history.forward()
})
document.addEventListener('keydown', (e) => { this.handleKeyDown(ipcMain, e) })
document.addEventListener('keyup', (e) => { this.handleKeyUp(e) })
+1 -1
View File
@@ -183,7 +183,7 @@
"@hcengineering/api-client": "^0.7.5",
"@faker-js/faker": "^8.4.1",
"@hcengineering/hulylake-client": "^0.7.6",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/communication-types": "^0.7.9",
"@hcengineering/pod-rating": "^0.7.0"
}
}
+11 -1
View File
@@ -56,7 +56,8 @@ import {
TypeNumber,
TypeRef,
TypeString,
UX
UX,
ReadOnly
} from '@hcengineering/model'
import attachment from '@hcengineering/model-attachment'
import { TAttachedDoc, TClass, TDoc, TMixin, TSpace } from '@hcengineering/model-core'
@@ -92,6 +93,11 @@ export class TTag extends TMixin implements Tag {
@Model(card.class.Card, core.class.Doc, DOMAIN_CARD)
@UX(card.string.Card, card.icon.Card)
export class TCard extends TDoc implements Card {
@Prop(TypeRef(card.class.CardSpace), core.string.Space)
@Index(IndexKind.Indexed)
@ReadOnly()
declare space: Ref<CardSpace>
@Prop(TypeRef(card.class.MasterTag), card.string.MasterTag)
declare _class: Ref<MasterTag>
@@ -129,6 +135,10 @@ export class TCard extends TDoc implements Card {
children?: number
parentInfo!: ParentInfo[]
@Hidden()
@ReadOnly()
peerId?: string
}
@Model(card.class.CardSpace, core.class.Space, DOMAIN_SPACE)
+16 -2
View File
@@ -175,10 +175,24 @@ export function createModel (builder: Builder): void {
attachTo: chunter.class.Channel,
descriptor: view.viewlet.Table,
configOptions: {
strict: true
hiddenKeys: ['name', 'description']
},
config: ['', 'topic', 'private', 'archived', 'members'],
props: { enableChecking: false }
props: { enableChecking: false },
viewOptions: {
groupBy: [],
orderBy: [],
other: [
{
key: 'hideArchived',
type: 'toggle',
defaultValue: true,
actionTarget: 'options',
action: view.function.HideArchived,
label: view.string.HideArchived
}
]
}
},
chunter.viewlet.Channels
)
+1 -1
View File
@@ -37,7 +37,7 @@
"@hcengineering/card": "^0.7.0",
"@hcengineering/communication": "^0.7.0",
"@hcengineering/communication-resources": "^0.7.0",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/communication-types": "^0.7.9",
"@hcengineering/contact": "^0.7.0",
"@hcengineering/core": "^0.7.10",
"@hcengineering/model": "^0.7.6",
+25
View File
@@ -1,6 +1,8 @@
import { concatLink, WorkspaceUuid } from '@hcengineering/core'
import { BillingError, NetworkError } from './error'
import {
AiTokensData,
AiTranscriptData,
BillingStats,
DatalakeStats,
LiveKitEgressData,
@@ -83,6 +85,29 @@ export class BillingClient {
const body = JSON.stringify(egress)
await fetchSafe(url, { method: 'POST', headers: { ...this.headers }, body })
}
async getAiTranscriptLastData (): Promise<AiTranscriptData | undefined> {
const path = '/api/v1/ai/transcript/last'
const url = new URL(concatLink(this.endpoint, path))
const response = await fetchSafe(url, { headers: { ...this.headers } })
return (await response.json()) as AiTranscriptData | undefined
}
async postAiTranscriptData (data: AiTranscriptData[]): Promise<void> {
const path = '/api/v1/ai/transcript'
const url = new URL(concatLink(this.endpoint, path))
const body = JSON.stringify(data)
await fetchSafe(url, { method: 'POST', headers: { ...this.headers }, body })
}
async postAiTokensData (data: AiTokensData[]): Promise<void> {
const path = '/api/v1/ai/tokens'
const url = new URL(concatLink(this.endpoint, path))
const body = JSON.stringify(data)
await fetchSafe(url, { method: 'POST', headers: { ...this.headers }, body })
}
}
async function fetchSafe (url: string | URL, init?: RequestInit): Promise<Response> {
+33
View File
@@ -1,6 +1,9 @@
import { WorkspaceUuid } from '@hcengineering/core'
export interface BillingStats {
liveKitStats: LiveKitStats
datalakeStats: DatalakeStats
aiStats: AiStats
}
export interface DatalakeStats {
@@ -42,3 +45,33 @@ export interface LiveKitEgressData {
egressEnd: string
duration: number
}
export interface AiTranscriptStats {
totalDurationSeconds: number
}
export interface AiTokensStats {
reason: string
totalTokens: number
}
export interface AiStats {
transcript: AiTranscriptStats
tokens: AiTokensStats[]
}
export interface AiTranscriptData {
workspace: WorkspaceUuid
day: string
lastRequestId: string
lastStartTime: string
durationSeconds: number
usd: number
}
export interface AiTokensData {
workspace: WorkspaceUuid
reason: string
tokens: number
date: string
}
+7 -4
View File
@@ -49,10 +49,13 @@
"Undo": "Zpět",
"Redo": "Znovu",
"ClearCanvas": "Vyčistit plátno",
"PenTool": "Nástroj pero",
"EraserTool": "Nástroj guma",
"PanTool": "Nástroj posun",
"TextTool": "Nástroj text",
"PenTool": "Pero",
"EraserTool": "Guma",
"PanTool": "Posun",
"TextTool": "Text",
"LineTool": "Čára",
"RectangleTool": "Obdélník",
"EllipseTool": "Elipsa",
"PaletteManagementMenu": "Spravovat barevné předvolby"
},
"status": {
+7 -4
View File
@@ -49,10 +49,13 @@
"Undo": "Rückgängig",
"Redo": "Wiederholen",
"ClearCanvas": "Leinwand löschen",
"PenTool": "Stift-Werkzeug",
"EraserTool": "Radiergummi-Werkzeug",
"PanTool": "Verschieben-Werkzeug",
"TextTool": "Text-Werkzeug",
"PenTool": "Stift",
"EraserTool": "Radiergummi",
"PanTool": "Verschieben",
"TextTool": "Text",
"LineTool": "Linie",
"RectangleTool": "Rechteck",
"EllipseTool": "Ellipse",
"PaletteManagementMenu": "Farbpresets verwalten"
},
"status": {
+7 -4
View File
@@ -49,10 +49,13 @@
"Undo": "Undo",
"Redo": "Redo",
"ClearCanvas": "Clear canvas",
"PenTool": "Pen tool",
"EraserTool": "Eraser tool",
"PanTool": "Pan tool",
"TextTool": "Text tool",
"PenTool": "Pen",
"EraserTool": "Eraser",
"PanTool": "Pan",
"TextTool": "Text",
"LineTool": "Line",
"RectangleTool": "Rectangle",
"EllipseTool": "Ellipse",
"PaletteManagementMenu": "Manage color presets"
},
"status": {
+7 -4
View File
@@ -49,10 +49,13 @@
"Undo": "Deshacer",
"Redo": "Rehacer",
"ClearCanvas": "Limpiar lienzo",
"PenTool": "Herramienta lápiz",
"EraserTool": "Herramienta borrador",
"PanTool": "Herramienta mover",
"TextTool": "Herramienta texto",
"PenTool": "Lápiz",
"EraserTool": "Borrador",
"PanTool": "Mover",
"TextTool": "Texto",
"LineTool": "Línea",
"RectangleTool": "Rectángulo",
"EllipseTool": "Elipse",
"PaletteManagementMenu": "Gestionar preajustes de color"
},
"status": {
+7 -4
View File
@@ -49,10 +49,13 @@
"Undo": "Annuler",
"Redo": "Rétablir",
"ClearCanvas": "Effacer la toile",
"PenTool": "Outil stylo",
"EraserTool": "Outil gomme",
"PanTool": "Outil déplacement",
"TextTool": "Outil texte",
"PenTool": "Stylo",
"EraserTool": "Gomme",
"PanTool": "Déplacement",
"TextTool": "Texte",
"LineTool": "Ligne",
"RectangleTool": "Rectangle",
"EllipseTool": "Ellipse",
"PaletteManagementMenu": "Gérer les préréglages de couleur"
},
"status": {
+7 -4
View File
@@ -49,10 +49,13 @@
"Undo": "Annulla",
"Redo": "Ripristina",
"ClearCanvas": "Cancella la tela",
"PenTool": "Strumento penna",
"EraserTool": "Strumento gomma",
"PanTool": "Strumento sposta",
"TextTool": "Strumento testo",
"PenTool": "Penna",
"EraserTool": "Gomma",
"PanTool": "Sposta",
"TextTool": "Testo",
"LineTool": "Linea",
"RectangleTool": "Rettangolo",
"EllipseTool": "Ellisse",
"PaletteManagementMenu": "Gestisci i preset di colore"
},
"status": {
+7 -4
View File
@@ -49,10 +49,13 @@
"Undo": "元に戻す",
"Redo": "やり直し",
"ClearCanvas": "キャンバスをクリア",
"PenTool": "ペンツール",
"EraserTool": "消しゴムツール",
"PanTool": "パンツール",
"TextTool": "テキストツール",
"PenTool": "ペン",
"EraserTool": "消しゴム",
"PanTool": "パン",
"TextTool": "テキスト",
"LineTool": "直線",
"RectangleTool": "長方形",
"EllipseTool": "楕円",
"PaletteManagementMenu": "カラープリセットを管理"
},
"status": {
+7 -4
View File
@@ -49,10 +49,13 @@
"Undo": "Desfazer",
"Redo": "Refazer",
"ClearCanvas": "Limpar tela",
"PenTool": "Ferramenta caneta",
"EraserTool": "Ferramenta borracha",
"PanTool": "Ferramenta mover",
"TextTool": "Ferramenta texto",
"PenTool": "Caneta",
"EraserTool": "Borracha",
"PanTool": "Mover",
"TextTool": "Texto",
"LineTool": "Linha",
"RectangleTool": "Retângulo",
"EllipseTool": "Elipse",
"PaletteManagementMenu": "Gerenciar predefinições de cor"
},
"status": {
+7 -4
View File
@@ -49,10 +49,13 @@
"Undo": "Отменить",
"Redo": "Повторить",
"ClearCanvas": "Очистить холст",
"PenTool": "Инструмент перо",
"EraserTool": "Инструмент ластик",
"PanTool": "Инструмент перемещения",
"TextTool": "Инструмент текст",
"PenTool": "Перо",
"EraserTool": "Ластик",
"PanTool": "Перемещение",
"TextTool": "Текст",
"LineTool": "Линия",
"RectangleTool": "Прямоугольник",
"EllipseTool": "Эллипс",
"PaletteManagementMenu": "Управление цветовыми пресетами"
},
"status": {
+7 -4
View File
@@ -49,10 +49,13 @@
"Undo": "Geri al",
"Redo": "Yinele",
"ClearCanvas": "Tuvali temizle",
"PenTool": "Kalem aracı",
"EraserTool": "Silgi aracı",
"PanTool": "Kaydırma aracı",
"TextTool": "Metin aracı",
"PenTool": "Kalem",
"EraserTool": "Silgi",
"PanTool": "Kaydırma",
"TextTool": "Metin",
"LineTool": "Çizgi",
"RectangleTool": "Dikdörtgen",
"EllipseTool": "Elips",
"PaletteManagementMenu": "Renk önayarlarını yönet"
},
"status": {
+7 -4
View File
@@ -49,10 +49,13 @@
"Undo": "撤销",
"Redo": "重做",
"ClearCanvas": "清除画布",
"PenTool": "画笔工具",
"EraserTool": "橡皮擦工具",
"PanTool": "移动工具",
"TextTool": "文字工具",
"PenTool": "画笔",
"EraserTool": "橡皮擦",
"PanTool": "移动",
"TextTool": "文字",
"LineTool": "直线",
"RectangleTool": "矩形",
"EllipseTool": "椭圆",
"PaletteManagementMenu": "管理颜色预设"
},
"status": {
+5 -5
View File
@@ -46,10 +46,10 @@
"@hcengineering/client": "^0.7.6",
"@hcengineering/contact": "^0.7.0",
"@hcengineering/collaborator-client": "^0.7.5",
"@hcengineering/communication-client-query": "^0.7.7",
"@hcengineering/communication-sdk-types": "^0.7.7",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/communication-shared": "^0.7.7",
"@hcengineering/communication-client-query": "^0.7.8",
"@hcengineering/communication-sdk-types": "^0.7.9",
"@hcengineering/communication-types": "^0.7.9",
"@hcengineering/communication-shared": "^0.7.8",
"@hcengineering/core": "^0.7.10",
"@hcengineering/diffview": "^0.7.0",
"@hcengineering/notification": "^0.7.0",
@@ -64,7 +64,7 @@
"@hcengineering/retry": "^0.7.5",
"@hcengineering/hulylake-client": "^0.7.6",
"@hcengineering/hulypulse-client": "^0.7.0",
"@hcengineering/storage-client": "^0.7.7",
"@hcengineering/storage-client": "^0.7.8",
"fast-equals": "^5.2.2",
"png-chunks-extract": "^1.0.0",
"svelte": "^4.2.20",
@@ -26,10 +26,13 @@
eventToHTMLElement,
showPopup
} from '@hcengineering/ui'
import { createEventDispatcher, onMount } from 'svelte'
import { ComponentType, createEventDispatcher, onMount } from 'svelte'
import IconEraser from './icons/Eraser.svelte'
import IconMove from './icons/Move.svelte'
import IconText from './icons/Text.svelte'
import IconRectangle from './icons/Rectangle.svelte'
import IconEllipse from './icons/Ellipse.svelte'
import IconLine from './icons/Line.svelte'
import { DrawingTool } from '../drawing'
import presentation from '../plugin'
import { ColorMetaName, ColorMetaNameOrHex } from '../drawingUtils'
@@ -37,6 +40,23 @@
import DrawingBoardColorSelectorIcon from './DrawingBoardColorSelectorIcon.svelte'
import { ColorsList, DrawingBoardColoringSetup } from '../drawingColors'
import { Analytics } from '@hcengineering/analytics'
import type { IntlString } from '@hcengineering/platform'
interface ToolPresentation {
label: IntlString
icon: ComponentType
tool: DrawingTool
}
const tools: ToolPresentation[] = [
{ label: presentation.string.PenTool, icon: IconEdit, tool: 'pen' },
{ label: presentation.string.EraserTool, icon: IconEraser, tool: 'erase' },
{ label: presentation.string.PanTool, icon: IconMove, tool: 'pan' },
{ label: presentation.string.TextTool, icon: IconText, tool: 'text' },
{ label: presentation.string.LineTool, icon: IconLine, tool: 'shape-line' },
{ label: presentation.string.RectangleTool, icon: IconRectangle, tool: 'shape-rectangle' },
{ label: presentation.string.EllipseTool, icon: IconEllipse, tool: 'shape-ellipse' }
]
interface DrawingBoardToolbarEvents {
undo: undefined
@@ -45,6 +65,7 @@
}
const dispatch = createEventDispatcher<DrawingBoardToolbarEvents>()
const maxColors = 8
const minColors = 0
const defaultColor: ColorMetaName = 'alpha'
@@ -58,6 +79,21 @@
}
export let tool: DrawingTool = 'pen'
function evaluateToolPresentation (tool: DrawingTool): ToolPresentation {
const found = tools.find((t) => t.tool === tool)
if (found == null) {
return tools[0]
}
return found
}
let toolPresentation: ToolPresentation = evaluateToolPresentation(tool)
$: {
toolPresentation = evaluateToolPresentation(tool)
}
export let penColor: ColorMetaNameOrHex
export let penWidth: number
export let eraserWidth: number
@@ -150,6 +186,25 @@
localStorage.setItem(storageKey.color, penColor)
}
function showToolSelectionMenu (ev: MouseEvent): void {
const items: Array<Omit<SelectPopupValueType, 'id'> & { id: DrawingTool }> = []
for (const toolPresentation of tools) {
if (toolPresentation.tool === 'pan' && !showPanTool) {
continue
}
items.push({
id: toolPresentation.tool,
label: toolPresentation.label,
icon: toolPresentation.icon
})
}
showPopup(SelectPopup, { value: items }, eventToHTMLElement(ev), (id: DrawingTool | undefined) => {
if (id != null) {
tool = id
}
})
}
onMount(() => {
try {
const savedColors = localStorage.getItem(storageKey.colors)
@@ -210,61 +265,23 @@
}}
/>
<div class="divider buttons-divider" />
<Button kind="icon" showTooltip={{ label: toolPresentation.label }} noFocus on:click={showToolSelectionMenu}>
<div class="tool-button-with-indicator" slot="content">
<svelte:component this={toolPresentation.icon} size="small" />
<div class="tool-indicator" />
</div>
</Button>
<Button
icon={IconDelete}
kind="icon"
showTooltip={{ label: presentation.string.ClearCanvas }}
noFocus
on:click={() => {
tool = 'pen'
dispatch('clear')
}}
/>
<div class="divider buttons-divider" />
<Button
icon={IconEdit}
kind="icon"
showTooltip={{ label: presentation.string.PenTool }}
noFocus
selected={tool === 'pen'}
on:click={() => {
tool = 'pen'
}}
/>
<Button
icon={IconEraser}
kind="icon"
showTooltip={{ label: presentation.string.EraserTool }}
noFocus
selected={tool === 'erase'}
on:click={() => {
tool = 'erase'
}}
/>
{#if showPanTool}
<Button
icon={IconMove}
kind="icon"
showTooltip={{ label: presentation.string.PanTool }}
noFocus
selected={tool === 'pan'}
on:click={() => {
tool = 'pan'
}}
/>
{/if}
<Button
icon={IconText}
kind="icon"
showTooltip={{ label: presentation.string.TextTool }}
noFocus
selected={tool === 'text'}
on:click={() => {
tool = 'text'
}}
/>
<div class="divider buttons-divider" />
{#if tool === 'pen'}
{#if tool !== 'erase' && tool !== 'pan' && tool !== 'text'}
<input
class="widthSelector"
type="range"
@@ -336,7 +353,12 @@
&.inside {
left: 0.5rem;
top: 0.5rem;
right: auto;
bottom: unset;
display: inline-flex;
flex-wrap: wrap;
align-items: center;
max-width: calc(100% - 1rem);
background-color: var(--theme-popup-header);
border-radius: var(--small-BorderRadius);
border: 1px solid var(--theme-popup-divider);
@@ -360,4 +382,23 @@
.widthSelector {
width: 80px;
}
.tool-button-with-indicator {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.tool-indicator {
position: absolute;
bottom: -0.125rem;
right: -0.125rem;
width: 0;
height: 0;
border-style: solid;
border-width: 0 0 0.25rem 0.25rem;
border-color: transparent transparent currentColor transparent;
opacity: 0.7;
}
</style>
@@ -0,0 +1,23 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// 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">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="8" cy="8" rx="6" ry="4" stroke="currentColor" fill="none" stroke-width="1" />
</svg>
@@ -1,3 +1,18 @@
<!--
// 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">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
@@ -0,0 +1,23 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// 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">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<line x1="2" y1="14" x2="14" y2="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
@@ -1,3 +1,18 @@
<!--
// 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">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
@@ -0,0 +1,23 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// 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">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="M2 3h12v10H2V3zm1 1v8h10V4H3z" />
</svg>
@@ -1,3 +1,18 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// 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">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
+198 -21
View File
@@ -31,7 +31,16 @@ import {
offsetCanvasPoint,
type ColorMetaNameOrHex
} from './drawingUtils'
import { type DrawingCmd, type CommandUid, type DrawTextCmd, type DrawLineCmd, makeCommandUid } from './drawingCommand'
import {
type DrawingCmd,
type CommandUid,
type DrawTextCmd,
type DrawLineCmd,
type DrawRectCmd,
type DrawEllipseCmd,
type DrawStraightLineCmd,
makeCommandUid
} from './drawingCommand'
import { type ColorsList, DrawingBoardColoringSetup, metaColorNameToHex } from './drawingColors'
export interface DrawingData {
@@ -71,7 +80,7 @@ export interface DrawingProps {
toolChanged?: (tool: DrawingTool) => void
}
export type DrawingTool = 'pen' | 'erase' | 'pan' | 'text'
export type DrawingTool = 'pen' | 'erase' | 'pan' | 'text' | 'shape-rectangle' | 'shape-ellipse' | 'shape-line'
const maxTextLength = 500
@@ -129,7 +138,13 @@ class DrawState {
}
isDrawingTool = (): boolean => {
return this.tool === 'pen' || this.tool === 'erase'
return (
this.tool === 'pen' ||
this.tool === 'erase' ||
this.tool === 'shape-rectangle' ||
this.tool === 'shape-ellipse' ||
this.tool === 'shape-line'
)
}
translateCtx = (): void => {
@@ -165,6 +180,12 @@ class DrawState {
drawCommand = (cmd: DrawingCmd, currentTheme: ThemeVariantType): void => {
if (cmd.type === 'text') {
this.drawTextCommand(cmd as DrawTextCmd, currentTheme)
} else if (cmd.type === 'rectangle') {
this.drawRectCommand(cmd as DrawRectCmd, currentTheme)
} else if (cmd.type === 'ellipse') {
this.drawEllipseCommand(cmd as DrawEllipseCmd, currentTheme)
} else if (cmd.type === 'straight-line') {
this.drawStraightLineCommand(cmd as DrawStraightLineCmd, currentTheme)
} else {
this.drawLineCommand(cmd as DrawLineCmd, currentTheme)
}
@@ -206,6 +227,46 @@ class DrawState {
this.ctx.restore()
}
drawRectCommand = (cmd: DrawRectCmd, currentTheme: ThemeVariantType): void => {
this.ctx.save()
this.translateCtx()
this.ctx.beginPath()
this.ctx.strokeStyle = metaColorNameToHex(cmd.penColor, currentTheme, this.colors)
this.ctx.lineWidth = cmd.lineWidth
const width = cmd.end.x - cmd.start.x
const height = cmd.end.y - cmd.start.y
this.ctx.strokeRect(cmd.start.x, cmd.start.y, width, height)
this.ctx.restore()
}
drawEllipseCommand = (cmd: DrawEllipseCmd, currentTheme: ThemeVariantType): void => {
this.ctx.save()
this.translateCtx()
this.ctx.beginPath()
this.ctx.strokeStyle = metaColorNameToHex(cmd.penColor, currentTheme, this.colors)
this.ctx.lineWidth = cmd.lineWidth
const centerX = (cmd.start.x + cmd.end.x) / 2
const centerY = (cmd.start.y + cmd.end.y) / 2
const radiusX = Math.abs(cmd.end.x - cmd.start.x) / 2
const radiusY = Math.abs(cmd.end.y - cmd.start.y) / 2
this.ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2)
this.ctx.stroke()
this.ctx.restore()
}
drawStraightLineCommand = (cmd: DrawStraightLineCmd, currentTheme: ThemeVariantType): void => {
this.ctx.save()
this.translateCtx()
this.ctx.beginPath()
this.ctx.lineCap = 'round'
this.ctx.strokeStyle = metaColorNameToHex(cmd.penColor, currentTheme, this.colors)
this.ctx.lineWidth = cmd.lineWidth
this.ctx.moveTo(cmd.start.x, cmd.start.y)
this.ctx.lineTo(cmd.end.x, cmd.end.y)
this.ctx.stroke()
this.ctx.restore()
}
isPointInText = (p: CanvasPoint, cmd: DrawTextCmd): boolean => {
this.ctx.font = `${cmd.fontSize}px ${cmd.fontFace}`
const lines = cmd.text.split('\n').map((l) => l.trim())
@@ -311,7 +372,7 @@ export function drawing (
draw.offset = props.offset ?? draw.offset
let isOffsetAnimating = false
updateToolCursor()
updateToolCursor(false)
updateCanvasTouchAction()
interface LiveTextBox {
@@ -338,7 +399,7 @@ export function drawing (
replayCommands(currentCommands)
props.subscribeOnThemeChange(() => {
updateToolCursor()
updateToolCursor(false)
replayCommands(currentCommands)
})
@@ -431,14 +492,16 @@ export function drawing (
canvas.ontouchcancel = canvas.ontouchend
const MiddleMouseButton = 1
canvas.onpointerdown = (e) => {
if (readonly) {
return
}
const MiddleMouseButton = 1
if (e.button === MiddleMouseButton && props.enableMiddleMousePanning === true) {
e.preventDefault()
isMiddleMousePanning = true
updateToolCursor(true)
canvas.setPointerCapture(e.pointerId)
const forcePan = true
drawStart(pointerToNodePoint(e), forcePan)
@@ -470,8 +533,9 @@ export function drawing (
canvas.releasePointerCapture(e.pointerId)
const forcePan = isMiddleMousePanning
drawEnd(pointerToNodePoint(e), forcePan)
if (e.button === 1) {
if (e.button === MiddleMouseButton) {
isMiddleMousePanning = false
updateToolCursor(false)
}
}
@@ -484,6 +548,7 @@ export function drawing (
const forcePan = isMiddleMousePanning
drawEnd(pointerToNodePoint(e), forcePan)
isMiddleMousePanning = false
updateToolCursor(false)
}
canvas.onpointerenter = () => {
@@ -516,16 +581,33 @@ export function drawing (
function drawContinue (p: NodePoint, forcePan: boolean): void {
const scaledPoint = rescaleWithCss(p)
if (!forcePan && draw.isDrawingTool()) {
if (draw.isDrawingTool() || forcePan) {
const cursorSize = draw.cursorWidth()
const canvasOffsetInParent = offsetInParent(node, canvas)
const parentRelativeLocation = offsetPoint(scaledPoint, canvasOffsetInParent)
toolCursor.style.left = `${parentRelativeLocation.x - cursorSize / 2}px`
toolCursor.style.top = `${parentRelativeLocation.y - cursorSize / 2}px`
}
if (!forcePan && draw.isDrawingTool()) {
if (draw.on) {
if (Math.hypot(prevPos.x - scaledPoint.x, prevPos.y - scaledPoint.y) >= draw.minLineLength) {
if (draw.tool === 'shape-rectangle') {
requestAnimationFrame(() => {
replayCommands(currentCommands)
drawPreviewRectangle(scaledPoint)
})
} else if (draw.tool === 'shape-ellipse') {
requestAnimationFrame(() => {
replayCommands(currentCommands)
drawPreviewEllipse(scaledPoint)
})
} else if (draw.tool === 'shape-line') {
requestAnimationFrame(() => {
replayCommands(currentCommands)
drawPreviewStraightLine(scaledPoint)
})
} else if (Math.hypot(prevPos.x - scaledPoint.x, prevPos.y - scaledPoint.y) >= draw.minLineLength) {
draw.drawLine(scaledPoint, 'intermediate-point', props.getCurrentTheme())
prevPos = scaledPoint
}
@@ -548,7 +630,7 @@ export function drawing (
prevPos = scaledPoint
}
if (props.pointerMoved !== undefined && !forcePan) {
if (props.pointerMoved !== undefined) {
props.pointerMoved(draw.mouseToCanvasPoint(scaledPoint))
}
}
@@ -557,8 +639,16 @@ export function drawing (
const scaledPoint = rescaleWithCss(p)
if (draw.on) {
if (!forcePan && draw.isDrawingTool()) {
draw.drawLine(scaledPoint, 'last-point', props.getCurrentTheme())
storeLineCommand()
if (draw.tool === 'shape-rectangle') {
storeRectCommand(scaledPoint)
} else if (draw.tool === 'shape-ellipse') {
storeEllipseCommand(scaledPoint)
} else if (draw.tool === 'shape-line') {
storeStraightLineCommand(scaledPoint)
} else {
draw.drawLine(scaledPoint, 'last-point', props.getCurrentTheme())
storeLineCommand()
}
} else if (draw.tool === 'pan' || forcePan) {
props.panned?.(draw.offset)
} else if (draw.tool === 'text') {
@@ -885,24 +975,111 @@ export function drawing (
}
function storeLineCommand (): void {
if (draw.points.length > 0) {
const erasing = draw.tool === 'erase'
const cmd: DrawLineCmd = {
if (draw.points.length === 0) {
return
}
const erasing = draw.tool === 'erase'
const cmd: DrawLineCmd = {
id: makeCommandUid(),
type: 'line',
lineWidth: erasing ? draw.eraserWidth : draw.penWidth,
erasing,
penColor: draw.penColor,
points: draw.points
}
props.cmdAdded?.(cmd)
}
function drawShapePreview (
endPoint: MouseScaledPoint,
drawShape: (start: CanvasPoint, end: CanvasPoint) => void
): void {
if (draw.points.length === 0) {
return
}
const start = draw.points[0]
const end = draw.mouseToCanvasPoint(endPoint)
draw.ctx.save()
draw.translateCtx()
draw.ctx.beginPath()
draw.ctx.strokeStyle = metaColorNameToHex(draw.penColor, props.getCurrentTheme(), colorsSetup)
draw.ctx.lineWidth = draw.penWidth
drawShape(start, end)
draw.ctx.stroke()
draw.ctx.restore()
}
function drawPreviewRectangle (endPoint: MouseScaledPoint): void {
drawShapePreview(endPoint, (start, end) => {
const width = end.x - start.x
const height = end.y - start.y
draw.ctx.strokeRect(start.x, start.y, width, height)
})
}
function drawPreviewEllipse (endPoint: MouseScaledPoint): void {
drawShapePreview(endPoint, (start, end) => {
const centerX = (start.x + end.x) / 2
const centerY = (start.y + end.y) / 2
const radiusX = Math.abs(end.x - start.x) / 2
const radiusY = Math.abs(end.y - start.y) / 2
draw.ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2)
})
}
function drawPreviewStraightLine (endPoint: MouseScaledPoint): void {
drawShapePreview(endPoint, (start, end) => {
draw.ctx.lineCap = 'round'
draw.ctx.moveTo(start.x, start.y)
draw.ctx.lineTo(end.x, end.y)
})
}
function storeShapeCommand (endPoint: MouseScaledPoint, type: 'rectangle' | 'ellipse' | 'straight-line'): void {
if (draw.points.length === 0) {
return
}
const start = draw.points[0]
const end = draw.mouseToCanvasPoint(endPoint)
const minSize = 2
const nonDegenerate = Math.abs(end.x - start.x) > minSize || Math.abs(end.y - start.y) > minSize
if (nonDegenerate) {
const cmd: DrawRectCmd | DrawEllipseCmd | DrawStraightLineCmd = {
id: makeCommandUid(),
type: 'line',
lineWidth: erasing ? draw.eraserWidth : draw.penWidth,
erasing,
type,
lineWidth: draw.penWidth,
penColor: draw.penColor,
points: draw.points
start,
end
}
props.cmdAdded?.(cmd)
}
}
function updateToolCursor (): void {
function storeRectCommand (endPoint: MouseScaledPoint): void {
storeShapeCommand(endPoint, 'rectangle')
}
function storeEllipseCommand (endPoint: MouseScaledPoint): void {
storeShapeCommand(endPoint, 'ellipse')
}
function storeStraightLineCommand (endPoint: MouseScaledPoint): void {
storeShapeCommand(endPoint, 'straight-line')
}
function updateToolCursor (forcePanning: boolean): void {
if (readonly) {
toolCursor.style.visibility = 'hidden'
canvas.style.cursor = props.defaultCursor ?? 'default'
} else if (forcePanning) {
canvas.style.cursor = 'grabbing'
toolCursor.style.visibility = 'hidden'
} else if (draw.isDrawingTool()) {
canvas.style.cursor = 'none'
toolCursor.style.visibility = 'visible'
@@ -1128,7 +1305,7 @@ export function drawing (
props.toolChanged?.(draw.tool)
}
if (syncToolCursor) {
updateToolCursor()
updateToolCursor(false)
}
if (syncPersonCursor !== undefined) {
updatePersonCursor(syncPersonCursor)
+22 -1
View File
@@ -20,7 +20,7 @@ export type CommandUid = string & { readonly __brand: 'CommandUid' }
export interface DrawingCmd {
id: CommandUid
type: 'line' | 'text'
type: 'line' | 'text' | 'rectangle' | 'ellipse' | 'straight-line'
}
export interface DrawTextCmd extends DrawingCmd {
@@ -38,6 +38,27 @@ export interface DrawLineCmd extends DrawingCmd {
points: CanvasPoint[]
}
export interface DrawRectCmd extends DrawingCmd {
lineWidth: number
penColor: ColorMetaNameOrHex
start: CanvasPoint
end: CanvasPoint
}
export interface DrawEllipseCmd extends DrawingCmd {
lineWidth: number
penColor: ColorMetaNameOrHex
start: CanvasPoint
end: CanvasPoint
}
export interface DrawStraightLineCmd extends DrawingCmd {
lineWidth: number
penColor: ColorMetaNameOrHex
start: CanvasPoint
end: CanvasPoint
}
export const makeCommandUid = (): CommandUid => {
return (crypto?.randomUUID?.() ?? generateId()) as CommandUid
}
+3
View File
@@ -155,6 +155,9 @@ export default plugin(presentationId, {
EraserTool: '' as IntlString,
PanTool: '' as IntlString,
TextTool: '' as IntlString,
LineTool: '' as IntlString,
RectangleTool: '' as IntlString,
EllipseTool: '' as IntlString,
PaletteManagementMenu: '' as IntlString
},
extension: {
+37 -11
View File
@@ -15,24 +15,50 @@ import { HulypulseClient } from '@hcengineering/hulypulse-client'
import { getMetadata } from '@hcengineering/platform'
import presentation from './plugin'
let pulseClient: HulypulseClient | undefined
let currentWorkspaceUuid: string | undefined
let currentToken: string | undefined
let promise: Promise<HulypulseClient | undefined> | undefined
export async function createPulseClient (): Promise<HulypulseClient | undefined> {
const token = getMetadata(presentation.metadata.Token)
if (token !== currentToken) {
const pulseUrl = getMetadata(presentation.metadata.PulseUrl) ?? ''
const token = getMetadata(presentation.metadata.Token) ?? ''
const workspaceUuid = getMetadata(presentation.metadata.WorkspaceUuid) ?? ''
if (pulseUrl === '' || token === '' || workspaceUuid === '') {
return undefined
}
// Token or workspace changed, need to reconnect
if (token !== currentToken || workspaceUuid !== currentWorkspaceUuid) {
closePulseClient()
}
if (pulseClient === undefined) {
const wsPulseUrl = getMetadata(presentation.metadata.PulseUrl)
if (wsPulseUrl == null || wsPulseUrl.trim().length === 0) return undefined
pulseClient = await HulypulseClient.connect(`${wsPulseUrl}?token=${token}`)
currentToken = token
if (promise !== undefined) {
// eslint-disable-next-line @typescript-eslint/return-await
return promise
}
return pulseClient
promise = new Promise((resolve) => {
HulypulseClient.connect(`${pulseUrl}?token=${token}`)
.then(resolve)
.catch(() => {
resolve(undefined)
})
})
currentToken = token
currentWorkspaceUuid = workspaceUuid
// eslint-disable-next-line @typescript-eslint/return-await
return promise
}
export function closePulseClient (): void {
pulseClient?.close()
pulseClient = undefined
if (promise !== undefined) {
void promise.then((client) => {
client?.close()
})
}
promise = undefined
currentToken = undefined
currentWorkspaceUuid = undefined
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { location as locationStore } from '../../location'
import { location as locationStore, workspaceId } from '../../location'
import { rootBarExtensions } from '../../utils'
import Component from '../Component.svelte'
@@ -15,18 +15,20 @@
$: sorted = $rootBarExtensions.sort((a, b) => a[1].order - b[1].order)
</script>
{#each sorted as ext (ext[1].id)}
{#if ext[0] === position}
<div id={ext[1].id} class="clear-mins">
<Component
is={ext[1].component}
props={ext[1].props}
on:close={() => {
rootBarExtensions.update((cur) => {
return cur.filter((it) => it[1].id !== ext[1].id)
})
}}
/>
</div>
{/if}
{/each}
{#key $workspaceId}
{#each sorted as ext (ext[1].id)}
{#if ext[0] === position}
<div id={ext[1].id} class="clear-mins">
<Component
is={ext[1].component}
props={ext[1].props}
on:close={() => {
rootBarExtensions.update((cur) => {
return cur.filter((it) => it[1].id !== ext[1].id)
})
}}
/>
</div>
{/if}
{/each}
{/key}
+8
View File
@@ -355,6 +355,14 @@ export async function formatDuration (duration: number, language: string): Promi
return text
}
export function formatNumberCompact (num: number, maximumFractionDigits = 2): string {
const locale = new Intl.NumberFormat().resolvedOptions().locale
return new Intl.NumberFormat(locale, {
notation: 'compact',
maximumFractionDigits
}).format(num)
}
export function pushRootBarComponent (pos: 'left' | 'right', component: AnyComponent, order?: number): void {
rootBarExtensions.update((cur) => {
if (cur.find((p) => p[1].component === component) === undefined) {
+1 -1
View File
@@ -51,7 +51,7 @@
"@hcengineering/ui": "^0.7.0",
"@hcengineering/view": "^0.7.0",
"@hcengineering/view-resources": "^0.7.0",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/communication-types": "^0.7.9",
"@hcengineering/emoji": "^0.7.0",
"@hcengineering/emoji-resources": "^0.7.0",
"svelte": "^4.2.20"
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Počet souborů",
"OfficeSessionsDuration": "Doba schůzek",
"OfficeSessionsBandwidth": "Šířka pásma schůzek",
"OfficeEgressDuration": "Doba záznamu"
"OfficeEgressDuration": "Doba záznamu",
"AI": "AI",
"TranscriptionTime": "Doba přepisu",
"TotalTokens": "Celkem tokenů"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Dateianzahl",
"OfficeSessionsDuration": "Besprechungsdauer",
"OfficeSessionsBandwidth": "Besprechungsbandbreite",
"OfficeEgressDuration": "Aufzeichnungsdauer"
"OfficeEgressDuration": "Aufzeichnungsdauer",
"AI": "AI",
"TranscriptionTime": "Transkriptionszeit",
"TotalTokens": "Gesamte Tokens"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Files count",
"OfficeSessionsDuration": "Meetings time",
"OfficeSessionsBandwidth": "Meetings bandwidth",
"OfficeEgressDuration": "Recording time"
"OfficeEgressDuration": "Recording time",
"AI": "AI",
"TranscriptionTime": "Transcription time",
"TotalTokens": "Total tokens"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Cantidad de archivos",
"OfficeSessionsDuration": "Tiempo de reuniones",
"OfficeSessionsBandwidth": "Ancho de banda de reuniones",
"OfficeEgressDuration": "Tiempo de grabación"
"OfficeEgressDuration": "Tiempo de grabación",
"AI": "IA",
"TranscriptionTime": "Tiempo de transcripción",
"TotalTokens": "Total de tokens"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Nombre de fichiers",
"OfficeSessionsDuration": "Durée des réunions",
"OfficeSessionsBandwidth": "Bande passante des réunions",
"OfficeEgressDuration": "Durée d'enregistrement"
"OfficeEgressDuration": "Durée d'enregistrement",
"AI": "IA",
"TranscriptionTime": "Durée de transcription",
"TotalTokens": "Nombre total de jetons"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Conteggio file",
"OfficeSessionsDuration": "Durata riunioni",
"OfficeSessionsBandwidth": "Larghezza di banda riunioni",
"OfficeEgressDuration": "Durata registrazione"
"OfficeEgressDuration": "Durata registrazione",
"AI": "AI",
"TranscriptionTime": "Tempo di trascrizione",
"TotalTokens": "Totale token"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "ファイル数",
"OfficeSessionsDuration": "会議時間",
"OfficeSessionsBandwidth": "会議帯域幅",
"OfficeEgressDuration": "録画時間"
"OfficeEgressDuration": "録画時間",
"AI": "AI",
"TranscriptionTime": "文字起こし時間",
"TotalTokens": "合計トークン"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Quantidade de arquivos",
"OfficeSessionsDuration": "Tempo de reuniões",
"OfficeSessionsBandwidth": "Largura de banda de reuniões",
"OfficeEgressDuration": "Tempo de gravação"
"OfficeEgressDuration": "Tempo de gravação",
"AI": "IA",
"TranscriptionTime": "Tempo de transcrição",
"TotalTokens": "Total de tokens"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Количество файлов",
"OfficeSessionsDuration": "Время встреч",
"OfficeSessionsBandwidth": "Трафик встреч",
"OfficeEgressDuration": "Время записи"
"OfficeEgressDuration": "Время записи",
"AI": "AI",
"TranscriptionTime": "Время транскрипции",
"TotalTokens": "Всего токенов"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Dosya sayısı",
"OfficeSessionsDuration": "Toplantı süresi",
"OfficeSessionsBandwidth": "Toplantı bant genişliği",
"OfficeEgressDuration": "Kayıt süresi"
"OfficeEgressDuration": "Kayıt süresi",
"AI": "AI",
"TranscriptionTime": "Transkripsiyon süresi",
"TotalTokens": "Toplam belirteçler"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "文件数量",
"OfficeSessionsDuration": "会议时间",
"OfficeSessionsBandwidth": "会议带宽",
"OfficeEgressDuration": "录制时间"
"OfficeEgressDuration": "录制时间",
"AI": "AI",
"TranscriptionTime": "转录时间",
"TotalTokens": "总令牌数"
}
}
@@ -13,15 +13,25 @@
// limitations under the License.
-->
<script lang="ts">
import { getBillingClient } from '../utils'
import { Breadcrumb, Header, Loading, Scroller, formatDuration, themeStore } from '@hcengineering/ui'
import {
Breadcrumb,
Header,
Loading,
Scroller,
formatDuration,
themeStore,
formatNumberCompact
} from '@hcengineering/ui'
import { getCurrentWorkspaceUuid } from '@hcengineering/presentation'
import billingPlugin from '@hcengineering/billing'
import filesize from 'filesize'
import StatsCard from './StatsCard.svelte'
import drivePlugin from '@hcengineering/drive'
import Category from './Category.svelte'
import love from '@hcengineering/love'
import drivePlugin from '@hcengineering/drive'
import view from '@hcengineering/view'
import { getBillingClient } from '../utils'
import StatsCard from './StatsCard.svelte'
import Category from './Category.svelte'
import ChartCard from './ChartCard.svelte'
const billingClient = getBillingClient()
@@ -34,6 +44,8 @@
let sessionsDurationByDay: { date: number, value: number }[] = []
let sessionsBandwidthByDay: { date: number, value: number }[] = []
let egressDurationByDay: { date: number, value: number }[] = []
let totalTranscriptDuration = 0
let totalTokensCount = 0
async function loadBillingData (): Promise<void> {
if (billingClient == null) return
@@ -43,6 +55,8 @@
totalSessionsDuration = billingStats.liveKitStats.sessions.reduce((sum, s) => sum + s.minutes, 0) * 60000
totalSessionsBandwidth = billingStats.liveKitStats.sessions.reduce((sum, s) => sum + s.bandwidth, 0)
totalEgressDuration = billingStats.liveKitStats.egress.reduce((sum, e) => sum + e.minutes, 0) * 60000
totalTranscriptDuration = billingStats.aiStats.transcript.totalDurationSeconds * 1000
totalTokensCount = billingStats.aiStats.tokens.reduce((sum, s) => sum + s.totalTokens, 0)
sessionsDurationByDay = billingStats.liveKitStats.sessions.map((s) => {
const date = new Date(Date.parse(s.day))
@@ -80,6 +94,15 @@
<StatsCard label={billingPlugin.string.DriveCount} text={totalDatalakeCount.toString()} />
</div>
</Category>
<Category icon={view.icon.AiStar} label={billingPlugin.string.AI}>
<div class="row">
<StatsCard
label={billingPlugin.string.TranscriptionTime}
text={formatDuration(totalTranscriptDuration, $themeStore.language)}
/>
<StatsCard label={billingPlugin.string.TotalTokens} text={formatNumberCompact(totalTokensCount)} />
</div>
</Category>
<Category icon={love.icon.Love} label={love.string.Office}>
<div class="row">
<StatsCard
+4 -1
View File
@@ -31,7 +31,10 @@ export const billingPlugin = plugin(billingId, {
DriveCount: '' as IntlString,
OfficeSessionsDuration: '' as IntlString,
OfficeSessionsBandwidth: '' as IntlString,
OfficeEgressDuration: '' as IntlString
OfficeEgressDuration: '' as IntlString,
AI: '' as IntlString,
TotalTokens: '' as IntlString,
TranscriptionTime: '' as IntlString
},
icon: {
Billing: '' as Asset
+1 -1
View File
@@ -41,7 +41,7 @@
"dependencies": {
"@hcengineering/presence": "^0.7.0",
"@hcengineering/presentation": "^0.7.0",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/communication-types": "^0.7.9",
"@hcengineering/communication-resources": "^0.7.0",
"@hcengineering/core": "^0.7.10",
"@hcengineering/ui": "^0.7.0",
@@ -200,8 +200,14 @@
</svelte:fragment>
<svelte:fragment slot="presence">
<Component is={presence.component.Presence} props={{ object: doc }} />
<Component is={presence.component.PresenceAvatars} props={{ object: doc, size: 'x-small', limit: 5 }} />
<Component
is={presence.component.Presence}
props={{ object: doc, presenceId: doc.peerId ? `peer:${doc.peerId}` : doc._id }}
/>
<Component
is={presence.component.PresenceAvatars}
props={{ object: doc, size: 'x-small', limit: 5, presenceId: doc.peerId ? `peer:${doc.peerId}` : doc._id }}
/>
</svelte:fragment>
<svelte:fragment slot="pre-utils">
+2
View File
@@ -57,6 +57,8 @@ export interface Card extends Doc, IconProps {
parent?: Ref<Card> | null
rank: Rank
readonly?: boolean
peerId?: string
}
export interface CardSpace extends Space {
+2 -2
View File
@@ -42,7 +42,7 @@
"@hcengineering/card": "^0.7.0",
"@hcengineering/card-resources": "^0.7.0",
"@hcengineering/chat": "^0.7.0",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/communication-types": "^0.7.9",
"@hcengineering/communication": "^0.7.0",
"@hcengineering/contact": "^0.7.0",
"@hcengineering/contact-resources": "^0.7.0",
@@ -55,7 +55,7 @@
"@hcengineering/workbench": "^0.7.0",
"@hcengineering/workbench-resources": "^0.7.0",
"@hcengineering/communication-resources": "^0.7.0",
"@hcengineering/communication-shared": "^0.7.7",
"@hcengineering/communication-shared": "^0.7.8",
"@hcengineering/rank": "^0.7.5",
"@hcengineering/text": "^0.7.5",
"@hcengineering/text-markdown": "^0.7.5",
@@ -15,8 +15,8 @@
<script lang="ts">
import chunter from '@hcengineering/chunter'
import { type Doc, type PersonId, getCurrentAccount } from '@hcengineering/core'
import { getName, getPersonRefsBySocialIds } from '@hcengineering/contact'
import { getPersonsByPersonRefs } from '@hcengineering/contact-resources'
import { getName } from '@hcengineering/contact'
import { getPersonsByPersonIds } from '@hcengineering/contact-resources'
import { IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { Label } from '@hcengineering/ui'
@@ -26,7 +26,6 @@
const maxTypingPersons = 3
const acc = getCurrentAccount()
const client = getClient()
const hierarchy = getClient().getHierarchy()
interface TypingGroup {
@@ -58,21 +57,22 @@
const groups: TypingGroup[] = []
for (const [status, personIds] of groupedByStatus.entries()) {
const personRefs = await getPersonRefsBySocialIds(client, personIds)
const persons = await getPersonsByPersonRefs(Object.values(personRefs))
const persons = await getPersonsByPersonIds(personIds)
const names = Array.from(persons.values())
.map((person) => getName(hierarchy, person))
.sort((name1, name2) => name1.localeCompare(name2))
const displayNames = names.slice(0, maxTypingPersons).join(', ')
const moreCount = Math.max(names.length - maxTypingPersons, 0)
if (names.length > 0) {
const displayNames = names.slice(0, maxTypingPersons).join(', ')
const moreCount = Math.max(names.length - maxTypingPersons, 0)
groups.push({
status,
names: displayNames,
count: names.length,
moreCount
})
groups.push({
status,
names: displayNames,
count: names.length,
moreCount
})
}
}
groups.sort((a, b) => a.status.localeCompare(b.status))
+3 -2
View File
@@ -43,14 +43,15 @@
},
"dependencies": {
"@hcengineering/analytics": "^0.7.5",
"@hcengineering/activity": "^0.7.0",
"@hcengineering/ai-bot": "^0.7.0",
"@hcengineering/ai-bot-resources": "^0.7.0",
"@hcengineering/attachment-resources": "^0.7.0",
"@hcengineering/card": "^0.7.0",
"@hcengineering/chat": "^0.7.0",
"@hcengineering/communication": "^0.7.0",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/communication-shared": "^0.7.7",
"@hcengineering/communication-types": "^0.7.9",
"@hcengineering/communication-shared": "^0.7.8",
"@hcengineering/contact": "^0.7.0",
"@hcengineering/contact-resources": "^0.7.0",
"@hcengineering/core": "^0.7.10",
@@ -12,17 +12,18 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import core, { type Class, type Client, type Doc, type Mixin, type Ref } from '@hcengineering/core'
import view, { type AttributeModel } from '@hcengineering/view'
import { buildRemovedDoc, getAttributePresenter } from '@hcengineering/view-resources'
import { type Card } from '@hcengineering/card'
import {
type ActivityAttributeUpdate,
type ActivityMessage,
type ActivityUpdate,
ActivityUpdateType,
type Message,
MessageType
} from '@hcengineering/communication-types'
import core, { type Class, type Client, type Doc, type Mixin, type Ref } from '@hcengineering/core'
import view, { type AttributeModel } from '@hcengineering/view'
import { buildRemovedDoc, getAttributePresenter } from '@hcengineering/view-resources'
const valueTypes: ReadonlyArray<Ref<Class<Doc>>> = [
core.class.TypeString,
@@ -52,13 +53,18 @@ export async function getAttributeModel (
update: ActivityUpdate | undefined,
_class: Ref<Class<Card>>
): Promise<AttributeModel | undefined> {
if (update == null || update.type !== ActivityUpdateType.Attribute) return undefined
if (
update == null ||
(update.type !== ActivityUpdateType.Attribute && update.type !== ActivityUpdateType.CollaborativeChange)
) {
return undefined
}
const { attrKey } = update
const model = await getAttributePresenterSafe(
client,
update.mixin ?? _class,
(update as ActivityAttributeUpdate).mixin ?? _class,
attrKey,
view.mixin.ActivityAttributePresenter
)
@@ -67,7 +73,7 @@ export async function getAttributeModel (
return model
}
return await getAttributePresenterSafe(client, update.mixin ?? _class, attrKey)
return await getAttributePresenterSafe(client, (update as ActivityAttributeUpdate).mixin ?? _class, attrKey)
}
export async function getAttributeValues (
@@ -14,8 +14,8 @@
-->
<script lang="ts">
import { type PersonId, getCurrentAccount } from '@hcengineering/core'
import { getName, getPersonRefsBySocialIds } from '@hcengineering/contact'
import { getPersonsByPersonRefs } from '@hcengineering/contact-resources'
import { getName } from '@hcengineering/contact'
import { getPersonsByPersonIds } from '@hcengineering/contact-resources'
import { IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { Label } from '@hcengineering/ui'
@@ -25,10 +25,10 @@
import communication from '../plugin'
export let cardId: CardID
export let peerId: string | undefined
const maxTypingPersons = 3
const acc = getCurrentAccount()
const client = getClient()
const hierarchy = getClient().getHierarchy()
interface TypingGroup {
@@ -41,6 +41,8 @@
let typingInfo = new Map<string, TypingInfo>()
let typingGroups: TypingGroup[] = []
$: objectId = peerId ? `peer${peerId}` : cardId
$: void updateTypingPersons(typingInfo)
async function updateTypingPersons (typingInfo: Map<string, TypingInfo>): Promise<void> {
@@ -60,21 +62,22 @@
const groups: TypingGroup[] = []
for (const [status, personIds] of groupedByStatus.entries()) {
const personRefs = await getPersonRefsBySocialIds(client, personIds)
const persons = await getPersonsByPersonRefs(Object.values(personRefs))
const persons = await getPersonsByPersonIds(personIds)
const names = Array.from(persons.values())
.map((person) => getName(hierarchy, person))
.sort((name1, name2) => name1.localeCompare(name2))
const displayNames = names.slice(0, maxTypingPersons).join(', ')
const moreCount = Math.max(names.length - maxTypingPersons, 0)
if (names.length > 0) {
const displayNames = names.slice(0, maxTypingPersons).join(', ')
const moreCount = Math.max(names.length - maxTypingPersons, 0)
groups.push({
status,
names: displayNames,
count: names.length,
moreCount
})
groups.push({
status,
names: displayNames,
count: names.length,
moreCount
})
}
}
groups.sort((a, b) => a.status.localeCompare(b.status))
@@ -91,7 +94,7 @@
class="root h-4 mt-1 mb-1 ml-0-5 overflow-label"
use:typing={{
socialId: acc.primarySocialId,
objectId: cardId,
objectId,
onTyping: handleTyping
}}
>
@@ -461,7 +461,7 @@
if (message !== undefined) return
if (!isEmptyMarkup(draft.content)) {
throttle.call(() => {
void setTyping(acc.primarySocialId, card._id)
void setTyping(acc.primarySocialId, card.peerId ? `peer:${card.peerId}` : card._id)
})
}
}
@@ -705,7 +705,7 @@
</div>
{#if message === undefined}
<TypingPresenter cardId={card._id} />
<TypingPresenter cardId={card._id} peerId={card.peerId} />
{/if}
<style lang="scss">
@@ -0,0 +1,97 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// 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 { ActivityCollaborativeChange } from '@hcengineering/communication-types'
import { MarkupDiffPresenter } from '@hcengineering/view-resources'
import activity from '@hcengineering/activity'
import ui, { Label } from '@hcengineering/ui'
import { AttributeModel } from '@hcengineering/view'
import communication from '../../../plugin'
export let model: AttributeModel | undefined = undefined
export let update: ActivityCollaborativeChange
$: isTooLarge = update.value === activity.string.ValueTooLarge || update.prevValue === activity.string.ValueTooLarge
let isDiffShown = false
function toggleShowMore (): void {
isDiffShown = !isDiffShown
}
</script>
<div>
{#if model !== undefined}
<Label label={model.label} />
<span class="lower"><Label label={activity.string.Edited} /></span>
{/if}
{#if isTooLarge}
<div class="unset row overflow-label">
<Label label={activity.string.ValueTooLarge} />
</div>
{:else}
<div class="showMore" on:click={toggleShowMore}>
<div class="triangle" class:left={!isDiffShown} class:down={isDiffShown} />
<Label label={isDiffShown ? ui.string.ShowLess : ui.string.ShowMore} />
</div>
{/if}
</div>
{#if isDiffShown}
<MarkupDiffPresenter value={update.value} prevValue={update.prevValue} />
{/if}
<style lang="scss">
.showMore {
color: var(--global-primary-LinkColor);
cursor: pointer;
display: flex;
align-items: center;
font-weight: 500;
gap: 0.5rem;
.triangle {
width: 0;
height: 0;
&.left {
border-top: 0.25rem solid transparent;
border-bottom: 0.25rem solid transparent;
border-left: 0.25rem solid var(--global-primary-LinkColor);
border-right: none;
}
&.down {
border-left: 0.25rem solid transparent;
border-right: 0.25rem solid transparent;
border-top: 0.25rem solid var(--global-primary-LinkColor);
border-bottom: none;
}
}
&:hover {
color: var(--global-focus-BorderColor);
.triangle {
&.left {
border-left-color: var(--global-focus-BorderColor);
}
&.down {
border-top-color: var(--global-focus-BorderColor);
}
}
}
}
</style>
@@ -23,6 +23,7 @@
import { Card } from '@hcengineering/card'
import ActivityUpdateTypeViewer from './ActivityUpdateTypeViewer.svelte'
import ActivityUpdateProcessViewer from './ActivityUpdateProcessViewer.svelte'
import ActivityCollaborativeContentViewer from './ActivityCollaborativeContentViewer.svelte'
export let model: AttributeModel | undefined = undefined
export let update: ActivityUpdate
@@ -41,4 +42,6 @@
<ActivityUpdateTypeViewer {update} />
{:else if update.type === ActivityUpdateType.Process}
<ActivityUpdateProcessViewer {update} {content} />
{:else if update.type === ActivityUpdateType.CollaborativeChange}
<ActivityCollaborativeContentViewer {model} {update} />
{/if}
+1 -1
View File
@@ -38,7 +38,7 @@
},
"dependencies": {
"@hcengineering/platform": "^0.7.5",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/communication-types": "^0.7.9",
"@hcengineering/core": "^0.7.10",
"@hcengineering/contact": "^0.7.0",
"@hcengineering/ui": "^0.7.0",
+2 -2
View File
@@ -42,8 +42,8 @@
"@hcengineering/card": "^0.7.0",
"@hcengineering/communication": "^0.7.0",
"@hcengineering/communication-resources": "^0.7.0",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/communication-shared": "^0.7.7",
"@hcengineering/communication-types": "^0.7.9",
"@hcengineering/communication-shared": "^0.7.8",
"@hcengineering/contact": "^0.7.0",
"@hcengineering/contact-resources": "^0.7.0",
"@hcengineering/core": "^0.7.10",
@@ -54,7 +54,7 @@
<div class="labels">
{#if client.getHierarchy().isDerived(navItem._class, cardPlugin.class.Card)}
{@const label = client.getHierarchy().getClass(doc?._class ?? navItem._class).label}
<span class="title--bold overflow-label clear-mins" use:tooltip={{ label }}>
<span class="title--bold overflow-label clear-mins">
<Label {label} />
</span>
{#if doc}
@@ -19,8 +19,9 @@
import PresenceContext from './PresenceContext.svelte'
export let object: Doc
export let presenceId: string | undefined = undefined
</script>
{#key object._id}
<PresenceContext {object} />
<PresenceContext {object} {presenceId} />
{/key}
@@ -25,6 +25,7 @@
import { followee, toggleFollowee } from '../store'
export let object: Doc
export let presenceId: string | undefined = undefined
export let size: IconSize = 'small'
export let limit: number = 4
@@ -55,7 +56,7 @@
<div
use:presence={{
personId: me,
objectId: object._id,
objectId: presenceId ?? object._id,
objectClass: object._class,
onPresence
}}
@@ -21,18 +21,19 @@
import { updatePresence, deletePresence } from '../presence'
export let object: Doc
export let presenceId: string | undefined = undefined
export let presenceTtlSeconds: number = 5
export let presenceUpdateSeconds: number = 2
const personId = getCurrentEmployee()
async function doUpdatePresence (): Promise<void> {
const presence = { personId, objectId: object._id, objectClass: object._class }
const presence = { personId, objectId: presenceId ?? object._id, objectClass: object._class }
await updatePresence(presence, presenceTtlSeconds)
}
async function doDeletePresence (object: Doc): Promise<void> {
const presence = { personId, objectId: object._id, objectClass: object._class }
async function doDeletePresence (object: Doc, presenceId?: string): Promise<void> {
const presence = { personId, objectId: presenceId ?? object._id, objectClass: object._class }
await deletePresence(presence)
}
@@ -41,15 +42,20 @@
const interval = setInterval(doUpdatePresence, presenceUpdateSeconds * 1000)
return () => {
clearInterval(interval)
void doDeletePresence(object)
void doDeletePresence(object, presenceId)
}
})
let previousObject: Doc = object
let prevPresenceId: string | undefined = presenceId
$: if (object !== undefined && (object._id !== previousObject._id || object._class !== previousObject._class)) {
$: if (
object !== undefined &&
(object._id !== previousObject._id || object._class !== previousObject._class || presenceId !== prevPresenceId)
) {
void doDeletePresence(previousObject)
previousObject = object
prevPresenceId = presenceId
void doUpdatePresence()
}
</script>
+3 -3
View File
@@ -19,13 +19,13 @@ import presentation, { createPulseClient } from '@hcengineering/presentation'
export interface PresenceInfo {
personId: Ref<Person>
objectId: Ref<Doc>
objectId: string
objectClass: Ref<Class<Doc>>
}
export interface PresenceActionParams {
personId: Ref<Employee>
objectId: Ref<Doc>
objectId: string
objectClass: Ref<Class<Doc>>
onPresence: (presence: Map<string, Ref<Person>>) => void
}
@@ -83,7 +83,7 @@ export function presence (node: HTMLElement, params: PresenceActionParams): any
export async function subscribePresence (
objectClass: Ref<Class<Doc>>,
objectId: Ref<Doc>,
objectId: string,
callback: Callback<PresenceInfo | undefined>
): Promise<UnsubscribeCallback> {
const client = await createPulseClient()
+5 -5
View File
@@ -14,7 +14,7 @@
import { type UnsubscribeCallback, type Callback } from '@hcengineering/hulypulse-client'
import { type IntlString, getMetadata } from '@hcengineering/platform'
import presentation, { createPulseClient } from '@hcengineering/presentation'
import { type Doc, type Ref, type PersonId } from '@hcengineering/core'
import { type PersonId } from '@hcengineering/core'
const typingDelaySeconds = 2
@@ -24,13 +24,13 @@ function getWorkspace (): string {
export interface TypingInfo {
socialId: PersonId
objectId: Ref<Doc>
objectId: string
status?: IntlString
}
export interface TypingActionParams {
socialId: PersonId
objectId: Ref<Doc>
objectId: string
onTyping: (presence: Map<string, TypingInfo>) => void
}
@@ -84,7 +84,7 @@ export function typing (node: HTMLElement, params: TypingActionParams): any {
}
export async function subscribeTyping (
objectId: Ref<Doc>,
objectId: string,
callback: Callback<TypingInfo | undefined>
): Promise<UnsubscribeCallback> {
const client = await createPulseClient()
@@ -100,7 +100,7 @@ export async function subscribeTyping (
return async () => false
}
export async function setTyping (socialId: PersonId, objectId: Ref<Doc>, status?: IntlString): Promise<void> {
export async function setTyping (socialId: PersonId, objectId: string, status?: IntlString): Promise<void> {
const client = await createPulseClient()
if (client !== undefined) {
@@ -59,7 +59,7 @@
}
function getKeys (_class: Ref<Class<MasterTag>>): AnyAttribute[] {
const ignoreKeys = ['_class', 'content', 'parent', 'attachments', 'todos']
const ignoreKeys = ['_class', 'parent', 'attachments', 'todos']
const attributes = hierarchy.getAllAttributes(_class, core.class.Doc)
const res: AnyAttribute[] = []
for (const [key, attr] of attributes) {
+10 -1
View File
@@ -278,4 +278,13 @@
<symbol id="print" viewBox="0 0 32 32">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 4C7.89543 4 7 4.89543 7 6V26C7 27.1046 7.89543 28 9 28H23C24.1046 28 25 27.1046 25 26V12H21C18.7909 12 17 10.2091 17 8V4H9ZM19 4.41421V8C19 9.10457 19.8954 10 21 10H24.5858L19 4.41421ZM5 6C5 3.79086 6.79086 2 9 2H18.5858C19.1162 2 19.6249 2.21071 20 2.58579L26.4142 9C26.7893 9.37507 27 9.88378 27 10.4142V26C27 28.2091 25.2091 30 23 30H9C6.79086 30 5 28.2091 5 26V6ZM10 17C10 16.4477 10.4477 16 11 16H21C21.5523 16 22 16.4477 22 17C22 17.5523 21.5523 18 21 18H11C10.4477 18 10 17.5523 10 17ZM10 23C10 22.4477 10.4477 22 11 22H21C21.5523 22 22 22.4477 22 23C22 23.5523 21.5523 24 21 24H11C10.4477 24 10 23.5523 10 23Z" />
</symbol>
</svg>
<symbol id="ai-star" viewBox="0 0 24 24">
<path d="M19 22C19 22.5523 18.5523 23 18 23C17.4477 23 17 22.5523 17 22C17 21.4477 17.4477 21 18 21C18.5523 21 19 21.4477 19 22Z" fill="currentColor"/>
<path d="M23 18C23 18.5523 22.5523 19 22 19C21.4477 19 21 18.5523 21 18C21 17.4477 21.4477 17 22 17C22.5523 17 23 17.4477 23 18Z" fill="currentColor"/>
<path d="M3 6C3 6.55228 2.55228 7 2 7C1.44772 7 1 6.55228 1 6C1 5.44772 1.44772 5 2 5C2.55228 5 3 5.44772 3 6Z" fill="currentColor"/>
<path d="M7 2C7 2.55228 6.55228 3 6 3C5.44772 3 5 2.55228 5 2C5 1.44772 5.44772 1 6 1C6.55228 1 7 1.44772 7 2Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 6.48292L11.3478 9.17257C11.0871 10.2477 10.2477 11.0871 9.17257 11.3478L6.48292 12L9.17257 12.6522C10.2477 12.9129 11.0871 13.7523 11.3478 14.8274L12 17.5171L12.6522 14.8274C12.9129 13.7523 13.7523 12.9129 14.8274 12.6522L17.5171 12L14.8274 11.3478C13.7523 11.0871 12.9129 10.2477 12.6522 9.17257L12 6.48292ZM13.3052 5.02656C12.9733 3.65781 11.0267 3.65781 10.6948 5.02656L9.78151 8.79277C9.66301 9.28146 9.28146 9.66301 8.79277 9.78151L5.02656 10.6948C3.65781 11.0267 3.65781 12.9733 5.02656 13.3052L8.79277 14.2185C9.28146 14.337 9.66301 14.7185 9.78151 15.2072L10.6948 18.9734C11.0267 20.3422 12.9733 20.3422 13.3052 18.9734L14.2185 15.2072C14.337 14.7185 14.7185 14.337 15.2072 14.2185L18.9734 13.3052C20.3422 12.9733 20.3422 11.0267 18.9734 10.6948L15.2072 9.78151C14.7185 9.66301 14.337 9.28146 14.2185 8.79277L13.3052 5.02656Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.00462 22.3147C8.83081 22.7804 8.31241 23.017 7.84673 22.8432C4.8303 21.7173 2.40732 19.3801 1.16973 16.4228C0.977847 15.9643 1.194 15.4371 1.65252 15.2452C2.11105 15.0533 2.63831 15.2694 2.8302 15.728C3.8762 18.2274 5.92654 20.2052 8.47615 21.1568C8.94183 21.3306 9.17843 21.849 9.00462 22.3147Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.3153 9.02077C21.8498 9.19493 21.3312 8.95873 21.157 8.49319C20.1979 5.92954 18.1971 3.87072 15.6693 2.83251C15.2095 2.64366 14.9899 2.11784 15.1787 1.65806C15.3676 1.19827 15.8934 0.978632 16.3532 1.16748C19.3427 2.39533 21.7076 4.82798 22.8429 7.86246C23.0171 8.32801 22.7809 8.8466 22.3153 9.02077Z" fill="currentColor"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 64 KiB

+2 -1
View File
@@ -68,7 +68,8 @@ loadMetadata(view.icon, {
MasterDetail: `${icons}#master-detail`,
Tree: `${icons}#tree`,
Document: `${icons}#document`,
Print: `${icons}#print`
Print: `${icons}#print`,
AiStar: `${icons}#ai-star`
})
loadMetadata(core.icon, {
TypeString: `${icons}#string`,
@@ -28,6 +28,6 @@
}
</script>
<span class="overflow-label" use:tooltip={tooltipParams}>
<span class="overflow-label px-3" use:tooltip={tooltipParams}>
{value ?? ''}
</span>
+2 -1
View File
@@ -304,7 +304,8 @@ const view = plugin(viewId, {
MasterDetail: '' as Asset,
Tree: '' as Asset,
Document: '' as Asset,
Print: '' as Asset
Print: '' as Asset,
AiStar: '' as Asset
},
category: {
General: '' as Ref<ActionCategory>,
+1 -1
View File
@@ -58,7 +58,7 @@
"@hcengineering/support-resources": "^0.7.0",
"@hcengineering/view-resources": "^0.7.0",
"@hcengineering/inbox": "^0.7.0",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/communication-types": "^0.7.9",
"fast-copy": "^3.0.2",
"@hcengineering/analytics": "^0.7.5",
"@hcengineering/account-client": "^0.7.7",
+1 -1
View File
@@ -1 +1 @@
stream hardcoreeng/service_stream:0.5.7
stream hardcoreeng/service_stream:0.5.8
+2 -2
View File
@@ -76,8 +76,8 @@
"@hcengineering/postgres": "^0.7.9",
"@hcengineering/mongo": "^0.7.8",
"@hcengineering/kafka": "^0.7.8",
"@hcengineering/communication-server": "^0.7.7",
"@hcengineering/communication-sdk-types": "^0.7.7",
"@hcengineering/communication-server": "^0.7.9",
"@hcengineering/communication-sdk-types": "^0.7.9",
"@hcengineering/hulylake-client": "^0.7.6"
}
}
+2 -2
View File
@@ -64,8 +64,8 @@
"@hcengineering/server-token": "^0.7.5",
"@hcengineering/attachment": "^0.7.0",
"@hcengineering/drive": "^0.7.0",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/communication-sdk-types": "^0.7.7",
"@hcengineering/communication-types": "^0.7.9",
"@hcengineering/communication-sdk-types": "^0.7.9",
"dotenv": "~16.0.0",
"kafkajs": "^2.2.4"
}
+2 -2
View File
@@ -61,8 +61,8 @@
"dependencies": {
"@hcengineering/analytics": "^0.7.5",
"@hcengineering/analytics-service": "^0.7.5",
"@hcengineering/communication-server": "^0.7.7",
"@hcengineering/communication-sdk-types": "^0.7.7",
"@hcengineering/communication-server": "^0.7.9",
"@hcengineering/communication-sdk-types": "^0.7.9",
"@hcengineering/contact": "^0.7.0",
"@hcengineering/core": "^0.7.10",
"@hcengineering/kafka": "^0.7.8",
@@ -45,8 +45,8 @@
"@hcengineering/server-core": "^0.7.8",
"@hcengineering/server-notification-resources": "^0.7.0",
"@hcengineering/text-core": "^0.7.5",
"@hcengineering/communication-sdk-types": "^0.7.7",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/communication-sdk-types": "^0.7.9",
"@hcengineering/communication-types": "^0.7.9",
"@hcengineering/server-card": "^0.7.0"
}
}
+2 -2
View File
@@ -43,8 +43,8 @@
"@hcengineering/core": "^0.7.10",
"@hcengineering/platform": "^0.7.5",
"@hcengineering/server-core": "^0.7.8",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/communication-sdk-types": "^0.7.7",
"@hcengineering/communication-types": "^0.7.9",
"@hcengineering/communication-sdk-types": "^0.7.9",
"@hcengineering/communication": "^0.7.0",
"@hcengineering/server-contact": "^0.7.0",
"@hcengineering/contact": "^0.7.0"
+34 -13
View File
@@ -541,21 +541,29 @@ async function updateParentInfoName (
async function OnThreadCreate (ctx: TxCreateDoc<Card>[], control: TriggerControl): Promise<Tx[]> {
const res: Tx[] = []
for (const tx of ctx) {
if (tx.space === core.space.DerivedTx) continue
const doc = TxProcessor.createDoc2Doc(tx)
if (doc.peerId != null) continue
const parent = doc.parentInfo?.[0]
if (parent == null) continue
if (!control.hierarchy.isDerived(parent._class, communication.type.Direct)) continue
const direct = (await control.findAll(control.ctx, parent._class, { _id: parent._id }, { limit: 1 }))[0] as Direct
if (direct == null) continue
res.push(...(await createThreadCardPeers(direct, doc, control)))
const peerId = generateId()
res.push(control.txFactory.createTxUpdateDoc(doc._class, doc.space, doc._id, { peerId }))
res.push(...(await createThreadCardPeers(direct, doc, control, peerId)))
}
return res
}
async function createThreadCardPeers (direct: Direct, doc: Card, control: TriggerControl): Promise<Tx[]> {
async function createThreadCardPeers (
direct: Direct,
doc: Card,
control: TriggerControl,
peerId: string
): Promise<Tx[]> {
const res: Tx[] = []
const cardIds = new Map<Ref<Card>, Ref<Space>>([[doc._id, doc.space]])
const members = direct.members ?? []
@@ -600,7 +608,8 @@ async function createThreadCardPeers (direct: Direct, doc: Card, control: Trigge
_class,
personSpace._id,
{
...doc
...doc,
peerId
},
_id
)
@@ -627,18 +636,20 @@ async function createThreadCardPeers (direct: Direct, doc: Card, control: Trigge
}
if (cardIds.size > 1) {
const group = generateId()
let newValue = true
for (const [cardId, spaceId] of cardIds.entries()) {
const event: CreatePeerEvent = {
type: PeerEventType.CreatePeer,
workspaceId: control.workspace.uuid, // TODO: person_workspace
cardId,
kind: 'card',
value: group,
value: peerId,
extra: { space: spaceId },
date: new Date(doc.modifiedOn)
date: new Date(doc.modifiedOn),
options: { newValue }
}
await control.domainRequest(control.ctx, 'communication' as OperationDomain, { event })
newValue = false
}
}
@@ -656,7 +667,12 @@ function getDirectTitle (employees: Employee[], me: Ref<Person>): string {
}
}
async function createDirectCardPeers (doc: Card, members: Ref<Person>[], control: TriggerControl): Promise<Tx[]> {
async function createDirectCardPeers (
doc: Card,
members: Ref<Person>[],
control: TriggerControl,
peerId: string
): Promise<Tx[]> {
const res: Tx[] = []
const cardIds = new Map<Ref<Card>, Ref<Space>>([[doc._id, doc.space]])
if (members.length === 0) return []
@@ -683,6 +699,7 @@ async function createDirectCardPeers (doc: Card, members: Ref<Person>[], control
personSpace._id,
{
...doc,
peerId,
title
},
_id
@@ -702,18 +719,20 @@ async function createDirectCardPeers (doc: Card, members: Ref<Person>[], control
}
if (cardIds.size > 1) {
const group = generateId()
let newValue = true
for (const [cardId, spaceId] of cardIds.entries()) {
const event: CreatePeerEvent = {
type: PeerEventType.CreatePeer,
workspaceId: control.workspace.uuid, // TODO: person_workspace
cardId,
kind: 'card',
value: group,
value: peerId,
extra: { space: spaceId },
date: new Date(doc.modifiedOn)
date: new Date(doc.modifiedOn),
options: { newValue }
}
await control.domainRequest(control.ctx, 'communication' as OperationDomain, { event })
newValue = false
}
}
@@ -724,11 +743,13 @@ async function OnDirectCreate (ctx: TxCreateDoc<Direct>[], control: TriggerContr
const res: Tx[] = []
for (const tx of ctx) {
if (tx.space === core.space.DerivedTx) continue
const doc = TxProcessor.createDoc2Doc(tx)
if (doc.peerId != null) continue
const members = doc.members ?? []
const peerId = generateId()
res.push(...(await createDirectCardPeers(doc, members, control)))
res.push(control.txFactory.createTxUpdateDoc(doc._class, doc.space, doc._id, { peerId }))
res.push(...(await createDirectCardPeers(doc, members, control, peerId)))
}
return res
@@ -17,11 +17,13 @@ import cardPlugin, { Card, MasterTag, Tag } from '@hcengineering/card'
import core, {
Association,
checkMixinKey,
Class,
Data,
Doc,
findProperty,
generateId,
getObjectValue,
makeDocCollabId,
matchQuery,
Ref,
Relation,
@@ -428,12 +430,41 @@ export async function CreateToDo (
}
}
async function getContent (
control: ProcessControl,
source: string,
_id: Ref<Card>,
_class: Ref<Class<Card>>
): Promise<string> {
const collabClient = control.collaboratorFactory()
const data = source.split('-')
const sourceId = data[0]
const sourceAttr = data[1]
if (isEmpty(sourceId) || isEmpty(sourceAttr)) {
throw processError(process.error.RequiredParamsNotProvided, { params: 'content' })
}
const sourceCard = await control.client.findOne(cardPlugin.class.Card, { _id: sourceId as Ref<Card> })
if (sourceCard === undefined) {
throw processError(process.error.ObjectNotFound, { _id: sourceId })
}
const markup = await collabClient.getMarkup(makeDocCollabId(sourceCard, sourceAttr))
const ref = await collabClient.createMarkup(
{
objectClass: _class,
objectId: _id,
objectAttr: 'content'
},
markup
)
return ref
}
export async function CreateCard (
params: MethodParams<Card>,
execution: Execution,
control: ProcessControl
): Promise<ExecuteResult> {
const { _class, title, ...attrs } = params
const { _class, title, content, ...attrs } = params
for (const key in { _class, title }) {
const val = (params as any)[key]
if (isEmpty(val)) {
@@ -441,10 +472,15 @@ export async function CreateCard (
}
}
const _id = generateId<Card>()
const newContent =
content !== undefined ? await getContent(control, content, _id, _class as Ref<Class<Card>>) : undefined
const data = {
title,
...attrs
} as any
if (newContent !== undefined) {
data.content = content
}
const tx = control.client.txFactory.createTxCreateDoc(_class as Ref<MasterTag>, execution.space, data, _id)
const res: Tx[] = [tx]
const rollback: Tx[] = [control.client.txFactory.createTxRemoveDoc(_class as Ref<MasterTag>, execution.space, _id)]
+1
View File
@@ -42,6 +42,7 @@
"@hcengineering/card": "^0.7.0",
"@hcengineering/process": "^0.7.0",
"@hcengineering/platform": "^0.7.5",
"@hcengineering/collaborator-client": "^0.7.5",
"@hcengineering/server-core": "^0.7.8"
}
}
+2
View File
@@ -1,6 +1,7 @@
import { Card } from '@hcengineering/card'
import { Doc, MeasureContext, PersonId, Ref, Timestamp, Tx, TxOperations, WorkspaceUuid } from '@hcengineering/core'
import { Execution, ExecutionError, MethodParams, Trigger, UserResult } from '@hcengineering/process'
import { CollaboratorClient } from '@hcengineering/collaborator-client'
export type ExecuteFunc = (
params: MethodParams<Doc>,
@@ -41,6 +42,7 @@ export interface ProcessMessage {
export interface ProcessControl {
ctx: MeasureContext
client: TxOperations
collaboratorFactory: () => CollaboratorClient
cache: Map<string, any>
messageContext: Record<string, any>
workspace: WorkspaceUuid
+2
View File
@@ -46,6 +46,8 @@
"@types/jest": "^29.5.5"
},
"dependencies": {
"@hcengineering/communication-types": "^0.7.9",
"@hcengineering/communication-sdk-types": "^0.7.9",
"@hcengineering/activity": "^0.7.0",
"@hcengineering/analytics": "^0.7.5",
"@hcengineering/core": "^0.7.10",
+36 -4
View File
@@ -17,7 +17,9 @@ import activity, { DocUpdateMessage } from '@hcengineering/activity'
import { Analytics } from '@hcengineering/analytics'
import { loadCollabJson, loadCollabYdoc, saveCollabJson, saveCollabYdoc } from '@hcengineering/collaboration'
import { decodeDocumentId } from '@hcengineering/collaborator-client'
import core, { AttachedData, MeasureContext, Ref, Space, TxOperations } from '@hcengineering/core'
import { CreateMessageEvent, MessageEventType } from '@hcengineering/communication-sdk-types'
import { ActivityCollaborativeChange, ActivityUpdateType, MessageType } from '@hcengineering/communication-types'
import core, { AttachedData, Doc, MeasureContext, OperationDomain, Ref, Space, TxOperations } from '@hcengineering/core'
import { StorageAdapter } from '@hcengineering/server-core'
import { areEqualMarkups } from '@hcengineering/text'
import { markupToYDoc } from '@hcengineering/text-ydoc'
@@ -263,11 +265,11 @@ export class PlatformStorageAdapter implements CollabStorageAdapter {
await ctx.with(
'activity',
{},
() => {
async () => {
const space = hierarchy.isDerived(current._class, core.class.Space)
? (current._id as Ref<Space>)
: current.space
await sendEvent(client, objectAttr, prevValue, currValue, current)
const data: AttachedData<DocUpdateMessage> = {
objectId,
objectClass,
@@ -282,7 +284,7 @@ export class PlatformStorageAdapter implements CollabStorageAdapter {
isMixin: hierarchy.isMixin(objectClass)
}
}
return client.addCollection(
return await client.addCollection(
activity.class.DocUpdateMessage,
space,
current._id,
@@ -301,6 +303,36 @@ export class PlatformStorageAdapter implements CollabStorageAdapter {
}
}
async function sendEvent (
client: Omit<TxOperations, 'close'>,
attrKey: string,
prevValue: string,
value: string,
doc: Doc
): Promise<void> {
const eventData: ActivityCollaborativeChange = {
type: ActivityUpdateType.CollaborativeChange,
attrKey,
value,
prevValue
}
const event: CreateMessageEvent = {
type: MessageEventType.CreateMessage,
messageType: MessageType.Activity,
cardId: doc._id,
cardType: doc._class,
extra: {
action: 'update',
update: eventData
},
content: '',
socialId: client.txFactory.account,
date: new Date()
}
await client.domainRequest('communication' as OperationDomain, { event })
}
async function withRetry<T> (
ctx: MeasureContext,
retries: number,
+4 -4
View File
@@ -49,10 +49,10 @@
"@hcengineering/drive": "^0.7.0",
"fast-equals": "^5.2.2",
"@hcengineering/storage": "^0.7.5",
"@hcengineering/communication-rest-client": "^0.7.7",
"@hcengineering/communication-sdk-types": "^0.7.7",
"@hcengineering/communication-shared": "^0.7.7",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/communication-rest-client": "^0.7.9",
"@hcengineering/communication-sdk-types": "^0.7.9",
"@hcengineering/communication-shared": "^0.7.8",
"@hcengineering/communication-types": "^0.7.9",
"@hcengineering/hulylake-client": "^0.7.6"
}
}
+2 -2
View File
@@ -180,8 +180,8 @@
"@hcengineering/card": "^0.7.0",
"@hcengineering/mail": "^0.7.0",
"@hcengineering/kafka": "^0.7.8",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/communication-sdk-types": "^0.7.7",
"@hcengineering/communication-types": "^0.7.9",
"@hcengineering/communication-sdk-types": "^0.7.9",
"@hcengineering/communication": "^0.7.0",
"@hcengineering/communication-assets": "^0.7.0"
}
+1 -1
View File
@@ -34,7 +34,7 @@
"typescript": "^5.6.3"
},
"dependencies": {
"@deepgram/sdk": "^3.12.1",
"@deepgram/sdk": "^4.11.2",
"@livekit/agents": "^0.7.4",
"@livekit/rtc-node": "^0.13.11",
"dotenv": "^16.4.5",
+5 -5
View File
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@deepgram/sdk':
specifier: ^3.12.1
version: 3.12.1
specifier: ^4.11.2
version: 4.11.2
'@livekit/agents':
specifier: ^0.7.4
version: 0.7.4(@livekit/rtc-node@0.13.11)
@@ -70,8 +70,8 @@ packages:
resolution: {integrity: sha512-8B1C/oTxTxyHlSFubAhNRgCbQ2SQ5wwvtlByn8sDYZvdDtdn/VE2yEPZ4BvUnrKWmsbTQY6/ooLV+9Ka2qmDSQ==}
engines: {node: '>=18.0.0'}
'@deepgram/sdk@3.12.1':
resolution: {integrity: sha512-MNnCnlyxdf0IY4Dkt+YcgCb8sy14zAzJ5BCNMwzv20tSxweoj8mk6eM063zQTuHWc3x2giKvS9x6/IBtV9zJLg==}
'@deepgram/sdk@4.11.2':
resolution: {integrity: sha512-lKGxuXxlSixC8bB0BnzmIpbVjUSgYtz17cqvrgv0ZjmazgUPkuUj9egQPj6k+fbPX8wRzWEqlhrL/DXlXqeDXA==}
engines: {node: '>=18.0.0'}
'@esbuild/aix-ppc64@0.20.2':
@@ -1531,7 +1531,7 @@ snapshots:
dependencies:
dayjs: 1.11.13
'@deepgram/sdk@3.12.1':
'@deepgram/sdk@4.11.2':
dependencies:
'@deepgram/captions': 1.2.0
'@types/node': 18.19.68
+9 -1
View File
@@ -101,7 +101,15 @@ export default defineAgent({
return
}
const stt = getStt(ctx.room)
const workspace = (roomName.split('_')[0] ?? '').trim()
if (workspace === '') {
console.error('Workspace is not defined', roomName)
ctx.shutdown()
return
}
const stt = getStt(ctx.room, workspace)
if (stt === undefined) {
console.error('Transcription provider is not configured')
+1 -1
View File
@@ -35,7 +35,7 @@ interface Config {
DgVadEvents: boolean
DgPunctuate: boolean
DgSmartFormat: boolean
DgNoDelay: boolean,
DgNoDelay: boolean
DgSampleRate: number
}
@@ -44,7 +44,10 @@ export class STT implements Stt {
private transcriptionCount = 0
constructor (readonly room: Room) {
constructor (
readonly room: Room,
readonly workspace: string
) {
this.deepgram = createClient(config.DeepgramApiKey)
}
@@ -154,6 +157,7 @@ export class STT implements Stt {
return {
...options,
extra: `workspace:${this.workspace}`,
encoding: 'linear16',
channels: stream.numChannels,
sample_rate: stream.sampleRate,
+2 -2
View File
@@ -5,12 +5,12 @@ import * as openai from './openai/stt.js'
import config from './config.js'
import { Stt } from './type.js'
export function getStt (room: Room): Stt | undefined {
export function getStt (room: Room, worksapce: string): Stt | undefined {
const provider = config.SttProvider
switch (provider) {
case 'deepgram':
return new dg.STT(room)
return new dg.STT(room, worksapce)
case 'openai':
return new openai.STT(room)
}
+4 -2
View File
@@ -57,7 +57,9 @@
"@hcengineering/account": "^0.7.0",
"@hcengineering/account-client": "^0.7.7",
"@hcengineering/ai-bot": "^0.7.0",
"@hcengineering/analytics-service": "^0.7.5",
"@hcengineering/attachment": "^0.7.0",
"@hcengineering/billing-client": "^0.7.0",
"@hcengineering/chunter": "^0.7.0",
"@hcengineering/client": "^0.7.6",
"@hcengineering/client-resources": "^0.7.6",
@@ -70,6 +72,7 @@
"@hcengineering/openai": "^0.7.0",
"@hcengineering/platform": "^0.7.5",
"@hcengineering/rank": "^0.7.5",
"@hcengineering/retry": "^0.7.5",
"@hcengineering/server-ai-bot": "^0.7.0",
"@hcengineering/server-client": "^0.7.8",
"@hcengineering/server-core": "^0.7.8",
@@ -89,7 +92,6 @@
"mongodb": "^6.16.0",
"openai": "^4.56.0",
"uuid": "^8.3.2",
"ws": "^8.18.2",
"@hcengineering/analytics-service": "^0.7.5"
"ws": "^8.18.2"
}
}
+219
View File
@@ -0,0 +1,219 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// 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 { groupByArray, MeasureContext, systemAccountUuid, WorkspaceUuid } from '@hcengineering/core'
import { generateToken } from '@hcengineering/server-token'
import {
getClient as getBillingClient,
type BillingClient,
AiTranscriptData,
AiTokensData
} from '@hcengineering/billing-client'
import { withRetry } from '@hcengineering/retry'
import config from './config'
interface DeepgramRequest {
request_id: string
path: string
created: string
response?: {
details?: {
usd?: number
duration?: number
metadata?: Record<string, any>
tags?: string[]
}
code: number
completed: string
}
}
interface DeepgramRequestsResponse {
page: number
limit: number
requests?: DeepgramRequest[]
}
export interface TranscriptData {
workspace: WorkspaceUuid
day: string
requestId: string
startTime: string
durationSeconds: number
usd: number
}
async function fetchDeepgramRequests (
ctx: MeasureContext,
start?: Date,
end?: Date,
page?: number
): Promise<DeepgramRequestsResponse> {
const url = new URL(`https://api.deepgram.com/v1/projects/${config.DeepgramProjectId}/requests`)
if (start != null) {
url.searchParams.set('start', start.toISOString())
}
if (end != null) {
url.searchParams.set('end', end.toISOString())
}
if (page != null) {
url.searchParams.set('page', page.toString())
}
url.searchParams.set('limit', '100')
const res = await fetch(url, {
headers: { Authorization: `Token ${config.DeepgramApiKey}` }
})
if (!res.ok) {
const text = await res.text()
ctx.error('Failed to fetch deepgram requests', { status: res.status, text })
throw new Error(`Failed to fetch deepgram requests ${res.status}: ${text}`)
}
return await res.json()
}
function extractExtra (path: string): Record<string, any> {
try {
const query = path.split('?')[1]
if (query == null) return {}
const params = new URLSearchParams(query)
const extras = params.getAll('extra')
return Object.fromEntries(extras.map((pair) => pair.split(':', 2)).filter(([k, v]) => k != null && v != null))
} catch {
return {}
}
}
function extractWorkspace (req: DeepgramRequest): WorkspaceUuid | undefined {
const metadata = req.response?.details?.metadata ?? {}
if (metadata?.workspace != null && metadata.workspace !== '') return metadata.workspace
const extra = extractExtra(req.path ?? '')
const ws = extra.workspace
if (ws == null) return undefined
if (typeof ws !== 'string') return undefined
if (ws.trim() === '') return undefined
return ws as WorkspaceUuid
}
async function fetchLastData (ctx: MeasureContext, billingClient: BillingClient): Promise<AiTranscriptData | undefined> {
try {
return await billingClient.getAiTranscriptLastData()
} catch (e: any) {
if (e.name === 'NetworkError') {
throw e
}
return undefined
}
}
export async function updateDeepgramBilling (ctx: MeasureContext): Promise<void> {
if (config.DeepgramApiKey === '' || config.DeepgramProjectId === '' || config.DeepgramTag === '') return
ctx.info('Starting deepgram billing update')
const token = generateToken(systemAccountUuid, undefined, { service: 'ai-bot' })
const billingClient = getBillingClient(config.BillingUrl, token)
const lastData = await withRetry(() => fetchLastData(ctx, billingClient))
ctx.info('Last deepgram request', lastData)
const start = lastData != null ? new Date(lastData.lastStartTime) : undefined
const end = new Date()
let page = 0
const data: TranscriptData[] = []
while (true) {
const res = await withRetry(() => fetchDeepgramRequests(ctx, start, end, page))
const requests = res.requests ?? []
for (const req of requests) {
if (lastData != null && lastData.lastRequestId === req.request_id) continue
const tags = req.response?.details?.tags ?? []
if (!tags.includes(config.DeepgramTag)) continue
const workspace = extractWorkspace(req)
if (workspace == null) continue
const endTime = req.response?.completed
const durationSeconds = req.response?.details?.duration
const usd = req.response?.details?.usd
if (endTime == null || durationSeconds == null || usd == null) {
continue
}
const day = new Date(req.created)
day.setHours(0, 0, 0, 0)
data.push({
workspace,
day: day.toISOString(),
requestId: req.request_id,
startTime: req.created,
durationSeconds,
usd
})
}
if (requests.length < res.limit) break
page++
}
const groupped = groupByArray(data, (it) => `${it.workspace}:${it.day}`)
const requestData: AiTranscriptData[] = []
for (const [, values] of groupped.entries()) {
if (values.length === 0) continue
const last = values.reduce((a, b) => (new Date(a.startTime).getTime() > new Date(b.startTime).getTime() ? a : b))
const totalDurationSeconds = values.reduce((sum, it) => sum + (it.durationSeconds ?? 0), 0)
const totalUsd = values.reduce((sum, it) => sum + (it.usd ?? 0), 0)
const req: AiTranscriptData = {
workspace: values[0].workspace,
day: values[0].day,
lastRequestId: last.requestId,
lastStartTime: last.startTime,
durationSeconds: totalDurationSeconds,
usd: totalUsd
}
requestData.push(req)
}
if (requestData.length > 0) {
await billingClient.postAiTranscriptData(requestData)
}
ctx.info('Finished deepgram billing update')
}
export async function pushTokensData (ctx: MeasureContext, data: AiTokensData[]): Promise<void> {
if (config.BillingUrl === '') return
try {
const token = generateToken(systemAccountUuid, undefined, { service: 'ai-bot' })
const billingClient = getBillingClient(config.BillingUrl, token)
await billingClient.postAiTokensData(data)
} catch (e) {
ctx.error('Failed to push tokens data', { e })
}
}
+11 -1
View File
@@ -37,6 +37,11 @@ interface Config {
Port: number
LoveEndpoint: string
DataLabApiKey: string
BillingUrl: string
DeepgramPollIntervalMinutes: number
DeepgramApiKey: string
DeepgramProjectId: string
DeepgramTag: string
}
const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined)
@@ -63,7 +68,12 @@ const config: Config = (() => {
MaxHistoryRecords: parseNumber(process.env.MAX_HISTORY_RECORDS) ?? 500,
Port: parseNumber(process.env.PORT) ?? 4010,
LoveEndpoint: process.env.LOVE_ENDPOINT ?? '',
DataLabApiKey: process.env.DATALAB_API_KEY ?? ''
DataLabApiKey: process.env.DATALAB_API_KEY ?? '',
BillingUrl: process.env.BILLING_URL ?? '',
DeepgramPollIntervalMinutes: parseNumber(process.env.DEEPGRAM_POLL_INTERVAL_MINUTES) ?? 60,
DeepgramApiKey: process.env.DEEPGRAM_API_KEY ?? '',
DeepgramProjectId: process.env.DEEPGRAM_PROJECT_ID ?? '',
DeepgramTag: process.env.DEEPGRAM_TAG ?? ''
}
const missingEnv = (Object.keys(params) as Array<keyof Config>).filter((key) => params[key] === undefined)
+3 -3
View File
@@ -218,12 +218,12 @@ export class AIControl {
return this.workspaces.get(workspace)
}
async translate (req: TranslateRequest): Promise<TranslateResponse | undefined> {
async translate (workspace: WorkspaceUuid, req: TranslateRequest): Promise<TranslateResponse | undefined> {
if (this.openai === undefined) {
return undefined
}
const html = jsonToHTML(markupToJSON(req.text))
const result = await translateHtml(this.openai, html, req.lang)
const result = await translateHtml(this.ctx, workspace, this.openai, html, req.lang)
const text = result !== undefined ? htmlToMarkup(result) : req.text
return {
text,
@@ -302,7 +302,7 @@ export class AIControl {
}
}
const summary = await summarizeMessages(this.openai, messagesToSummarize, req.lang)
const summary = await summarizeMessages(this.ctx, workspace, this.openai, messagesToSummarize, req.lang)
if (summary === undefined) return
const summaryMarkup = jsonToMarkup(markdownToMarkup(summary))
@@ -60,11 +60,11 @@ export function createServer (controller: AIControl, ctx: MeasureContext): Expre
app.post(
'/translate',
wrapRequest(async (req, res) => {
wrapRequest(async (req, res, token) => {
if (req.body == null || Array.isArray(req.body) || typeof req.body !== 'object') {
throw new ApiError(400)
}
const response = await controller.translate(req.body as TranslateRequest)
const response = await controller.translate(token.workspace, req.body as TranslateRequest)
if (response === undefined) {
throw new ApiError(500)
}

Some files were not shown because too many files have changed in this diff Show More