mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
Add ai usage to billing (#10138)
Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Generated
+3
-2
@@ -5642,7 +5642,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/pod-ai-bot@file:projects/pod-ai-bot.tgz':
|
||||
resolution: {integrity: sha512-i7ARoUNEidIH+C+xWIrmtTsSR88zlHArLoCwIjeuC5NjjAgd5erkDkPL9IyJ3hwSFjAZ+V0TTtOVLJavcs03ZA==, tarball: file:projects/pod-ai-bot.tgz}
|
||||
resolution: {integrity: sha512-LQ42nJT9Obn8nZgE5e5XKErxbtZj2LxZs3IvE9xPMW179nVDdQFAc9fGlZ7adrmCvYpRPpNIgXV2O9rimBeL5g==, tarball: file:projects/pod-ai-bot.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/pod-analytics-collector@file:projects/pod-analytics-collector.tgz':
|
||||
@@ -5754,7 +5754,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/pod-translate@file:projects/pod-translate.tgz':
|
||||
resolution: {integrity: sha512-YWZPxkubAeu8Se4YwOfZept8ufirQy4ndQ1N+QD9EUAla6VUuRPC0zhcG1Vl3LGKAp9hUR9bZTy0EIPdFHY/AA==, tarball: file:projects/pod-translate.tgz}
|
||||
resolution: {integrity: sha512-cdak2NAfd+s4CAv3kGdBPeAgM5o/QJCH2OYfS6NI8GhTL0BsdmbiLbn2hQxzpRlpCBFnvOaUzO6HcFUa0owrcA==, tarball: file:projects/pod-translate.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/pod-worker@file:projects/pod-worker.tgz':
|
||||
@@ -27302,6 +27302,7 @@ snapshots:
|
||||
'@hcengineering/platform': 0.7.5
|
||||
'@hcengineering/platform-rig': 0.7.19(@babel/core@7.23.9)(postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.9.3)))(postcss@8.5.3)(sass@1.93.2)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.9.3))
|
||||
'@hcengineering/rank': 0.7.5
|
||||
'@hcengineering/retry': 0.7.5
|
||||
'@hcengineering/server-client': 0.7.8(bufferutil@4.0.8)(utf-8-validate@6.0.4)
|
||||
'@hcengineering/server-core': 0.7.8
|
||||
'@hcengineering/server-storage': 0.7.9
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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ů"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"DriveCount": "Dateianzahl",
|
||||
"OfficeSessionsDuration": "Besprechungsdauer",
|
||||
"OfficeSessionsBandwidth": "Besprechungsbandbreite",
|
||||
"OfficeEgressDuration": "Aufzeichnungsdauer"
|
||||
"OfficeEgressDuration": "Aufzeichnungsdauer",
|
||||
"AI": "AI",
|
||||
"TranscriptionTime": "Transkriptionszeit",
|
||||
"TotalTokens": "Gesamte Tokens"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"DriveCount": "ファイル数",
|
||||
"OfficeSessionsDuration": "会議時間",
|
||||
"OfficeSessionsBandwidth": "会議帯域幅",
|
||||
"OfficeEgressDuration": "録画時間"
|
||||
"OfficeEgressDuration": "録画時間",
|
||||
"AI": "AI",
|
||||
"TranscriptionTime": "文字起こし時間",
|
||||
"TotalTokens": "合計トークン"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"DriveCount": "Количество файлов",
|
||||
"OfficeSessionsDuration": "Время встреч",
|
||||
"OfficeSessionsBandwidth": "Трафик встреч",
|
||||
"OfficeEgressDuration": "Время записи"
|
||||
"OfficeEgressDuration": "Время записи",
|
||||
"AI": "AI",
|
||||
"TranscriptionTime": "Время транскрипции",
|
||||
"TotalTokens": "Всего токенов"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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`,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { registerLoaders } from './loaders'
|
||||
import { createServer, listen } from './server/server'
|
||||
import { getDbStorage } from './storage'
|
||||
import { getAccountUuid } from './utils/account'
|
||||
import { updateDeepgramBilling } from './billing'
|
||||
|
||||
export const start = async (): Promise<void> => {
|
||||
setMetadata(serverToken.metadata.Secret, config.ServerSecret)
|
||||
@@ -74,7 +75,25 @@ export const start = async (): Promise<void> => {
|
||||
const app = createServer(aiControl, ctx)
|
||||
const server = listen(app, config.Port)
|
||||
|
||||
let billingIntervalId: any | undefined
|
||||
if (config.BillingUrl !== '') {
|
||||
billingIntervalId = setInterval(
|
||||
() => {
|
||||
try {
|
||||
void updateDeepgramBilling(ctx)
|
||||
} catch {}
|
||||
},
|
||||
config.DeepgramPollIntervalMinutes * 60 * 1000
|
||||
)
|
||||
try {
|
||||
void updateDeepgramBilling(ctx)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const onClose = (): void => {
|
||||
if (billingIntervalId !== undefined) {
|
||||
clearInterval(billingIntervalId)
|
||||
}
|
||||
void aiControl.close()
|
||||
storage.close()
|
||||
server.close(() => process.exit())
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { AccountUuid, Ref } from '@hcengineering/core'
|
||||
import { AccountUuid, MeasureContext, Ref, WorkspaceUuid } from '@hcengineering/core'
|
||||
import { countTokens } from '@hcengineering/openai'
|
||||
import { Tiktoken } from 'js-tiktoken'
|
||||
import OpenAI from 'openai'
|
||||
@@ -24,8 +24,15 @@ import config from '../config'
|
||||
import { HistoryRecord } from '../types'
|
||||
import { WorkspaceClient } from '../workspace/workspaceClient'
|
||||
import { getTools } from './tools'
|
||||
import { pushTokensData } from '../billing'
|
||||
|
||||
export async function translateHtml (client: OpenAI, html: string, lang: string): Promise<string | undefined> {
|
||||
export async function translateHtml (
|
||||
ctx: MeasureContext,
|
||||
workspace: WorkspaceUuid,
|
||||
client: OpenAI,
|
||||
html: string,
|
||||
lang: string
|
||||
): Promise<string | undefined> {
|
||||
const response = await client.chat.completions.create({
|
||||
model: config.OpenAISummaryModel,
|
||||
messages: [
|
||||
@@ -40,10 +47,25 @@ export async function translateHtml (client: OpenAI, html: string, lang: string)
|
||||
]
|
||||
})
|
||||
|
||||
return response.choices[0].message.content ?? undefined
|
||||
const responseText = response.choices[0].message.content ?? undefined
|
||||
|
||||
if (response.usage != null) {
|
||||
void pushTokensData(ctx, [
|
||||
{
|
||||
workspace,
|
||||
reason: 'manual-translate',
|
||||
tokens: response.usage.total_tokens,
|
||||
date: new Date(response.created * 1000).toISOString()
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
return responseText
|
||||
}
|
||||
|
||||
export async function summarizeMessages (
|
||||
ctx: MeasureContext,
|
||||
workspace: WorkspaceUuid,
|
||||
client: OpenAI,
|
||||
messages: PersonMessage[],
|
||||
lang: string
|
||||
@@ -95,6 +117,17 @@ export async function summarizeMessages (
|
||||
]
|
||||
})
|
||||
|
||||
if (response.usage != null) {
|
||||
void pushTokensData(ctx, [
|
||||
{
|
||||
workspace,
|
||||
reason: 'summarize',
|
||||
tokens: response.usage.total_tokens,
|
||||
date: new Date(response.created * 1000).toISOString()
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
let responseText = response.choices[0].message.content ?? undefined
|
||||
if (responseText === undefined) return
|
||||
|
||||
@@ -110,18 +143,21 @@ export async function summarizeMessages (
|
||||
}
|
||||
|
||||
export async function createChatCompletion (
|
||||
ctx: MeasureContext,
|
||||
workspace: WorkspaceUuid,
|
||||
client: OpenAI,
|
||||
message: OpenAI.ChatCompletionMessageParam,
|
||||
user?: string,
|
||||
history: OpenAI.ChatCompletionMessageParam[] = [],
|
||||
skipCache = true
|
||||
skipCache = true,
|
||||
reason = 'chat'
|
||||
): Promise<OpenAI.ChatCompletion | undefined> {
|
||||
const opt: OpenAI.RequestOptions = {}
|
||||
if (skipCache) {
|
||||
opt.headers = { 'cf-skip-cache': 'true' }
|
||||
}
|
||||
try {
|
||||
return await client.chat.completions.create(
|
||||
const response = await client.chat.completions.create(
|
||||
{
|
||||
messages: [...history, message],
|
||||
model: config.OpenAIModel,
|
||||
@@ -130,6 +166,19 @@ export async function createChatCompletion (
|
||||
},
|
||||
opt
|
||||
)
|
||||
|
||||
if (response.usage != null) {
|
||||
void pushTokensData(ctx, [
|
||||
{
|
||||
workspace,
|
||||
reason,
|
||||
tokens: response.usage.total_tokens,
|
||||
date: new Date(response.created * 1000).toISOString()
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
@@ -143,7 +192,8 @@ export async function createChatCompletionWithTools (
|
||||
message: OpenAI.ChatCompletionMessageParam,
|
||||
user?: AccountUuid,
|
||||
history: OpenAI.ChatCompletionMessageParam[] = [],
|
||||
skipCache = true
|
||||
skipCache = true,
|
||||
reason = 'chat'
|
||||
): Promise<
|
||||
| {
|
||||
completion: string | undefined
|
||||
@@ -152,6 +202,7 @@ export async function createChatCompletionWithTools (
|
||||
| undefined
|
||||
> {
|
||||
const opt: OpenAI.RequestOptions = {}
|
||||
const date = new Date()
|
||||
if (skipCache) {
|
||||
opt.headers = { 'cf-skip-cache': 'true' }
|
||||
}
|
||||
@@ -172,11 +223,24 @@ export async function createChatCompletionWithTools (
|
||||
},
|
||||
opt
|
||||
)
|
||||
|
||||
const str = await res.finalContent()
|
||||
const usage = (await res.totalUsage()).completion_tokens
|
||||
const usage = await res.totalUsage()
|
||||
|
||||
if (usage != null) {
|
||||
void pushTokensData(workspaceClient.ctx, [
|
||||
{
|
||||
workspace: workspaceClient.wsIds.uuid,
|
||||
reason,
|
||||
tokens: usage.total_tokens,
|
||||
date: date.toISOString()
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
completion: str ?? undefined,
|
||||
usage
|
||||
usage: usage.completion_tokens
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
@@ -186,6 +250,8 @@ export async function createChatCompletionWithTools (
|
||||
}
|
||||
|
||||
export async function requestSummary (
|
||||
ctx: MeasureContext,
|
||||
workspace: WorkspaceUuid,
|
||||
aiClient: OpenAI,
|
||||
encoding: Tiktoken,
|
||||
history: HistoryRecord[]
|
||||
@@ -198,7 +264,7 @@ export async function requestSummary (
|
||||
role: 'user'
|
||||
}
|
||||
|
||||
const response = await createChatCompletion(aiClient, summaryPrompt, undefined, [
|
||||
const response = await createChatCompletion(ctx, workspace, aiClient, summaryPrompt, undefined, [
|
||||
{ role: 'system', content: 'Make a summary of messages history' }
|
||||
])
|
||||
|
||||
|
||||
@@ -248,7 +248,13 @@ export class WorkspaceClient {
|
||||
}
|
||||
|
||||
this.summarizing.add(objectId)
|
||||
const { summary, tokens } = await requestSummary(this.openai, this.openaiEncoding, toSummarize)
|
||||
const { summary, tokens } = await requestSummary(
|
||||
this.ctx,
|
||||
this.wsIds.uuid,
|
||||
this.openai,
|
||||
this.openaiEncoding,
|
||||
toSummarize
|
||||
)
|
||||
|
||||
if (summary === undefined) {
|
||||
this.ctx.error('Failed to summarize history', { objectId, objectClass, user })
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
import type { Request, Response } from 'express'
|
||||
import { MeasureContext, systemAccountUuid, WorkspaceUuid } from '@hcengineering/core'
|
||||
import { LiveKitSessionData, BillingDB, LiveKitEgressData } from './types'
|
||||
import { LiveKitSessionData, BillingDB, LiveKitEgressData, AiUsageData, AiTranscriptData, AiTokensData } from './types'
|
||||
import { generateToken } from '@hcengineering/server-token'
|
||||
import { StorageConfig } from '@hcengineering/server-core'
|
||||
import { createDatalakeClient, DatalakeConfig, WorkspaceStats } from '@hcengineering/datalake'
|
||||
@@ -78,7 +78,12 @@ export async function handleGetStats (
|
||||
const { fromDate, toDate } = parseDateParameters(req)
|
||||
const liveKitStats = await db.getLiveKitStats(ctx, workspace, fromDate, toDate)
|
||||
const datalakeStats = await collectDatalakeStats(ctx, workspace, storageConfigs)
|
||||
res.status(200).json({ liveKitStats, datalakeStats })
|
||||
|
||||
const aiStats: AiUsageData = {
|
||||
transcript: await db.getAiTranscriptStats(ctx, workspace, fromDate, toDate),
|
||||
tokens: await db.getAiTokensStats(ctx, workspace, fromDate, toDate)
|
||||
}
|
||||
res.status(200).json({ liveKitStats, datalakeStats, aiStats })
|
||||
}
|
||||
|
||||
export async function handleGetLiveKitStats (
|
||||
@@ -104,6 +109,63 @@ export async function handleGetDatalakeStats (
|
||||
res.status(200).json(await collectDatalakeStats(ctx, workspace, storageConfigs))
|
||||
}
|
||||
|
||||
export async function handleGetAiStats (
|
||||
ctx: MeasureContext,
|
||||
db: BillingDB,
|
||||
storageConfigs: StorageConfig[],
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const workspace = getWorkspaceUuid(req)
|
||||
const { fromDate, toDate } = parseDateParameters(req)
|
||||
|
||||
const usage: AiUsageData = {
|
||||
transcript: await db.getAiTranscriptStats(ctx, workspace, fromDate, toDate),
|
||||
tokens: await db.getAiTokensStats(ctx, workspace, fromDate, toDate)
|
||||
}
|
||||
|
||||
res.status(200).json(usage)
|
||||
}
|
||||
|
||||
export async function handleGetAiTranscriptLastData (
|
||||
ctx: MeasureContext,
|
||||
db: BillingDB,
|
||||
storageConfigs: StorageConfig[],
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const last = await db.getAiTranscriptLastData(ctx)
|
||||
if (last === undefined) {
|
||||
res.status(404).send()
|
||||
return
|
||||
}
|
||||
res.status(200).json(last)
|
||||
}
|
||||
|
||||
export async function handlePushAiTranscriptData (
|
||||
ctx: MeasureContext,
|
||||
db: BillingDB,
|
||||
storageConfigs: StorageConfig[],
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const data = (await req.body) as AiTranscriptData[]
|
||||
await db.pushAiTranscriptData(ctx, data)
|
||||
res.status(204).send()
|
||||
}
|
||||
|
||||
export async function handlePushAiTokensData (
|
||||
ctx: MeasureContext,
|
||||
db: BillingDB,
|
||||
storageConfigs: StorageConfig[],
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const data = (await req.body) as AiTokensData[]
|
||||
await db.pushAiTokensData(ctx, data)
|
||||
res.status(204).send()
|
||||
}
|
||||
|
||||
async function collectDatalakeStats (
|
||||
ctx: MeasureContext,
|
||||
workspace: WorkspaceUuid,
|
||||
|
||||
@@ -14,7 +14,16 @@
|
||||
//
|
||||
|
||||
import { MeasureContext, type WorkspaceUuid } from '@hcengineering/core'
|
||||
import { BillingDB, LiveKitEgressData, LiveKitSessionData, LiveKitUsageData } from '../types'
|
||||
import {
|
||||
AiTokensData,
|
||||
AiTokensUsage,
|
||||
AiTranscriptData,
|
||||
AiTranscriptUsage,
|
||||
BillingDB,
|
||||
LiveKitEgressData,
|
||||
LiveKitSessionData,
|
||||
LiveKitUsageData
|
||||
} from '../types'
|
||||
|
||||
export class LoggedDB implements BillingDB {
|
||||
constructor (
|
||||
@@ -46,4 +55,36 @@ export class LoggedDB implements BillingDB {
|
||||
async setLiveKitEgress (ctx: MeasureContext, data: LiveKitEgressData[]): Promise<void> {
|
||||
await ctx.with('db.setLiveKitEgress', {}, () => this.db.setLiveKitEgress(this.ctx, data))
|
||||
}
|
||||
|
||||
async pushAiTranscriptData (ctx: MeasureContext, data: AiTranscriptData[]): Promise<void> {
|
||||
await ctx.with('db.pushAiTranscriptData', {}, () => this.db.pushAiTranscriptData(this.ctx, data))
|
||||
}
|
||||
|
||||
async getAiTranscriptLastData (ctx: MeasureContext): Promise<AiTranscriptData | undefined> {
|
||||
return await ctx.with('db.getAiTranscriptLastData', {}, () => this.db.getAiTranscriptLastData(this.ctx))
|
||||
}
|
||||
|
||||
async getAiTranscriptStats (
|
||||
ctx: MeasureContext,
|
||||
workspace: WorkspaceUuid,
|
||||
start?: Date,
|
||||
end?: Date
|
||||
): Promise<AiTranscriptUsage> {
|
||||
return await ctx.with('db.getAiTranscriptStats', {}, () =>
|
||||
this.db.getAiTranscriptStats(this.ctx, workspace, start, end)
|
||||
)
|
||||
}
|
||||
|
||||
async pushAiTokensData (ctx: MeasureContext, data: AiTokensData[]): Promise<void> {
|
||||
await ctx.with('db.pushAiTokensData', {}, () => this.db.pushAiTokensData(ctx, data))
|
||||
}
|
||||
|
||||
async getAiTokensStats (
|
||||
ctx: MeasureContext,
|
||||
workspace: WorkspaceUuid,
|
||||
start?: Date,
|
||||
end?: Date
|
||||
): Promise<AiTokensUsage[]> {
|
||||
return await ctx.with('db.getAiTokensStats', {}, () => this.db.getAiTokensStats(ctx, workspace, start, end))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//
|
||||
|
||||
export function getMigrations (): [string, string][] {
|
||||
return [migrationV1()]
|
||||
return [migrationV1(), migrationV2()]
|
||||
}
|
||||
|
||||
function migrationV1 (): [string, string] {
|
||||
@@ -50,3 +50,30 @@ function migrationV1 (): [string, string] {
|
||||
`
|
||||
return ['init_tables_01', sql]
|
||||
}
|
||||
|
||||
function migrationV2 (): [string, string] {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS billing.ai_transcript_usage (
|
||||
workspace UUID NOT NULL,
|
||||
day DATE NOT NULL,
|
||||
last_request_id STRING(255) NOT NULL,
|
||||
last_start_time TIMESTAMP NOT NULL,
|
||||
total_duration_seconds FLOAT NOT NULL,
|
||||
total_usd DECIMAL(12,6) NOT NULL,
|
||||
PRIMARY KEY (workspace, day)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_transcript_usage_day ON billing.ai_transcript_usage (day);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS billing.ai_tokens_usage (
|
||||
workspace UUID NOT NULL,
|
||||
day DATE NOT NULL,
|
||||
reason STRING(255) NOT NULL,
|
||||
total_tokens INT8 NOT NULL,
|
||||
PRIMARY KEY (workspace, day, reason)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_tokens_usage_day ON billing.ai_transcript_usage (day);
|
||||
`
|
||||
return ['init_ai_usage_tables_02', sql]
|
||||
}
|
||||
|
||||
@@ -14,12 +14,16 @@
|
||||
//
|
||||
|
||||
import {
|
||||
LiveKitEgressData,
|
||||
LiveKitSessionData,
|
||||
LiveKitUsageData,
|
||||
AiTokensData,
|
||||
AiTokensUsage,
|
||||
AiTranscriptData,
|
||||
AiTranscriptUsage,
|
||||
BillingDB,
|
||||
LiveKitEgressData,
|
||||
LiveKitEgressUsageData,
|
||||
LiveKitSessionData,
|
||||
LiveKitSessionsUsageData,
|
||||
LiveKitEgressUsageData
|
||||
LiveKitUsageData
|
||||
} from '../types'
|
||||
import postgres, { type Row, Sql } from 'postgres'
|
||||
import { MeasureContext, type WorkspaceUuid } from '@hcengineering/core'
|
||||
@@ -52,7 +56,7 @@ export async function createDb (ctx: MeasureContext, connectionString: string):
|
||||
return new LoggedDB(ctx, new RetryDB(db, { retries: 5 }))
|
||||
}
|
||||
|
||||
export class PostgresDB implements BillingDB {
|
||||
class PostgresDB implements BillingDB {
|
||||
private constructor (private readonly sql: Sql) {}
|
||||
|
||||
static async create (ctx: MeasureContext, sql: Sql): Promise<PostgresDB> {
|
||||
@@ -242,8 +246,187 @@ export class PostgresDB implements BillingDB {
|
||||
await this.execute(query, params)
|
||||
}
|
||||
}
|
||||
|
||||
async pushAiTranscriptData (ctx: MeasureContext, data: AiTranscriptData[]): Promise<void> {
|
||||
for (let i = 0; i < data.length; i += BATCH_SIZE) {
|
||||
const batch = data.slice(i, i + BATCH_SIZE)
|
||||
if (batch.length === 0) continue
|
||||
|
||||
const values: string[] = []
|
||||
const params: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
for (const item of batch) {
|
||||
const { workspace, lastRequestId, lastStartTime, durationSeconds, usd, day } = item
|
||||
values.push(
|
||||
`($${paramIndex++}::uuid, DATE($${paramIndex++}::timestamp), $${paramIndex++}::string, $${paramIndex++}::timestamp, $${paramIndex++}::float, $${paramIndex++}::decimal)`
|
||||
)
|
||||
params.push(workspace, day, lastRequestId, lastStartTime, durationSeconds, usd)
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO billing.ai_transcript_usage
|
||||
(workspace, day, last_request_id, last_start_time, total_duration_seconds, total_usd)
|
||||
VALUES ${values.join(',')}
|
||||
ON CONFLICT (workspace, day)
|
||||
DO UPDATE SET
|
||||
total_duration_seconds = billing.ai_transcript_usage.total_duration_seconds + EXCLUDED.total_duration_seconds,
|
||||
total_usd = billing.ai_transcript_usage.total_usd + EXCLUDED.total_usd,
|
||||
last_request_id = CASE
|
||||
WHEN EXCLUDED.last_start_time > billing.ai_transcript_usage.last_start_time
|
||||
THEN EXCLUDED.last_request_id
|
||||
ELSE billing.ai_transcript_usage.last_request_id
|
||||
END,
|
||||
last_start_time = GREATEST(billing.ai_transcript_usage.last_start_time, EXCLUDED.last_start_time);
|
||||
`
|
||||
|
||||
await this.execute(query, params)
|
||||
}
|
||||
}
|
||||
|
||||
async getAiTranscriptStats (
|
||||
ctx: MeasureContext,
|
||||
workspace: WorkspaceUuid,
|
||||
start?: Date,
|
||||
end?: Date
|
||||
): Promise<AiTranscriptUsage> {
|
||||
const baseSql = `
|
||||
SELECT
|
||||
SUM(total_duration_seconds) AS total_duration_seconds
|
||||
FROM billing.ai_transcript_usage
|
||||
`
|
||||
|
||||
let where = 'WHERE workspace = $1::uuid'
|
||||
const params: any[] = [workspace]
|
||||
let paramIndex = params.length + 1
|
||||
|
||||
if (start != null) {
|
||||
const s = new Date(start)
|
||||
s.setHours(0, 0, 0, 0)
|
||||
|
||||
where += ` AND day >= $${paramIndex++}::date`
|
||||
params.push(s)
|
||||
}
|
||||
|
||||
if (end != null) {
|
||||
const e = new Date(end)
|
||||
e.setHours(23, 59, 59, 999)
|
||||
|
||||
where += ` AND day <= $${paramIndex++}::date`
|
||||
params.push(e)
|
||||
}
|
||||
|
||||
const sql = [baseSql, where].join(' ')
|
||||
const result = await this.execute(sql, params)
|
||||
|
||||
return {
|
||||
totalDurationSeconds: Number(result[0]?.total_duration_seconds ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
async getAiTranscriptLastData (ctx: MeasureContext): Promise<AiTranscriptData | undefined> {
|
||||
const sql = `
|
||||
SELECT *
|
||||
FROM billing.ai_transcript_usage
|
||||
ORDER BY day DESC
|
||||
LIMIT 1`
|
||||
|
||||
const result = await this.execute(sql)
|
||||
const last = result[0]
|
||||
|
||||
if (last == null) return undefined
|
||||
|
||||
return {
|
||||
workspace: last.workspace,
|
||||
day: last.day,
|
||||
lastRequestId: last.last_request_id,
|
||||
lastStartTime: last.last_start_time,
|
||||
durationSeconds: Number(last?.total_duration_seconds ?? 0),
|
||||
usd: Number(last.usd)
|
||||
}
|
||||
}
|
||||
|
||||
async pushAiTokensData (ctx: MeasureContext, data: AiTokensData[]): Promise<void> {
|
||||
const BATCH_SIZE = 100
|
||||
|
||||
for (let i = 0; i < data.length; i += BATCH_SIZE) {
|
||||
const batch = data.slice(i, i + BATCH_SIZE)
|
||||
if (batch.length === 0) continue
|
||||
|
||||
const values: string[] = []
|
||||
const params: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
for (const item of batch) {
|
||||
const { workspace, reason, tokens, date } = item
|
||||
|
||||
values.push(
|
||||
`($${paramIndex++}::uuid, $${paramIndex++}::date, $${paramIndex++}::string, $${paramIndex++}::int8)`
|
||||
)
|
||||
|
||||
params.push(workspace, date, reason, tokens)
|
||||
}
|
||||
|
||||
const sql = `
|
||||
INSERT INTO billing.ai_tokens_usage (workspace, day, reason, total_tokens)
|
||||
VALUES ${values.join(',')}
|
||||
ON CONFLICT (workspace, day, reason)
|
||||
DO UPDATE SET
|
||||
total_tokens = billing.ai_tokens_usage.total_tokens + EXCLUDED.total_tokens;
|
||||
`
|
||||
|
||||
await this.execute(sql, params)
|
||||
}
|
||||
}
|
||||
|
||||
async getAiTokensStats (
|
||||
ctx: MeasureContext,
|
||||
workspace: WorkspaceUuid,
|
||||
start?: Date,
|
||||
end?: Date
|
||||
): Promise<AiTokensUsage[]> {
|
||||
const baseSql = `
|
||||
SELECT
|
||||
reason,
|
||||
SUM(total_tokens) AS total_tokens
|
||||
FROM billing.ai_tokens_usage
|
||||
`
|
||||
|
||||
let where = 'WHERE workspace = $1::uuid'
|
||||
const params: any[] = [workspace]
|
||||
let paramIndex = 2
|
||||
|
||||
if (start != null) {
|
||||
const s = new Date(start)
|
||||
s.setHours(0, 0, 0, 0)
|
||||
|
||||
where += ` AND day >= $${paramIndex++}::date`
|
||||
params.push(s)
|
||||
}
|
||||
|
||||
if (end != null) {
|
||||
const e = new Date(end)
|
||||
e.setHours(23, 59, 59, 999)
|
||||
|
||||
where += ` AND day <= $${paramIndex++}::date`
|
||||
params.push(e)
|
||||
}
|
||||
|
||||
const groupBy = 'GROUP BY reason'
|
||||
const orderBy = 'ORDER BY reason ASC'
|
||||
|
||||
const sql = [baseSql, where, groupBy, orderBy].join(' ')
|
||||
const result = await this.execute(sql, params)
|
||||
|
||||
return result.map((row: any) => ({
|
||||
reason: row.reason,
|
||||
totalTokens: Number(row.total_tokens ?? 0)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export default PostgresDB
|
||||
|
||||
function injectVars (sql: string, values: any[]): string {
|
||||
return sql.replaceAll(/(\$\d+)/g, (_, idx) => {
|
||||
return escape(values[parseInt(idx.substring(1)) - 1])
|
||||
|
||||
@@ -14,7 +14,16 @@
|
||||
//
|
||||
|
||||
import { MeasureContext, type WorkspaceUuid } from '@hcengineering/core'
|
||||
import { BillingDB, LiveKitEgressData, LiveKitSessionData, LiveKitUsageData } from '../types'
|
||||
import {
|
||||
AiTokensData,
|
||||
AiTokensUsage,
|
||||
AiTranscriptData,
|
||||
AiTranscriptUsage,
|
||||
BillingDB,
|
||||
LiveKitEgressData,
|
||||
LiveKitSessionData,
|
||||
LiveKitUsageData
|
||||
} from '../types'
|
||||
|
||||
interface RetryOptions {
|
||||
retries: number
|
||||
@@ -67,4 +76,34 @@ export class RetryDB implements BillingDB {
|
||||
async setLiveKitEgress (ctx: MeasureContext, data: LiveKitEgressData[]): Promise<void> {
|
||||
await retry(() => this.db.setLiveKitEgress(ctx, data), this.options)
|
||||
}
|
||||
|
||||
async pushAiTranscriptData (ctx: MeasureContext, data: AiTranscriptData[]): Promise<void> {
|
||||
await retry(() => this.db.pushAiTranscriptData(ctx, data), this.options)
|
||||
}
|
||||
|
||||
async getAiTranscriptLastData (ctx: MeasureContext): Promise<AiTranscriptData | undefined> {
|
||||
return await retry(() => this.db.getAiTranscriptLastData(ctx), this.options)
|
||||
}
|
||||
|
||||
async getAiTranscriptStats (
|
||||
ctx: MeasureContext,
|
||||
workspace: WorkspaceUuid,
|
||||
start?: Date,
|
||||
end?: Date
|
||||
): Promise<AiTranscriptUsage> {
|
||||
return await retry(() => this.db.getAiTranscriptStats(ctx, workspace, start, end), this.options)
|
||||
}
|
||||
|
||||
async pushAiTokensData (ctx: MeasureContext, data: AiTokensData[]): Promise<void> {
|
||||
await retry(() => this.db.pushAiTokensData(ctx, data), this.options)
|
||||
}
|
||||
|
||||
async getAiTokensStats (
|
||||
ctx: MeasureContext,
|
||||
workspace: WorkspaceUuid,
|
||||
start?: Date,
|
||||
end?: Date
|
||||
): Promise<AiTokensUsage[]> {
|
||||
return await retry(() => this.db.getAiTokensStats(ctx, workspace, start, end), this.options)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
handleListLiveKitEgress,
|
||||
handleGetLiveKitStats,
|
||||
handleGetDatalakeStats,
|
||||
handleGetStats
|
||||
handleGetStats,
|
||||
handlePushAiTranscriptData,
|
||||
handleGetAiTranscriptLastData,
|
||||
handlePushAiTokensData
|
||||
} from './billing'
|
||||
import { BillingDB } from './types'
|
||||
import { createDb } from './db/postgres'
|
||||
@@ -140,6 +143,22 @@ export async function createServer (ctx: MeasureContext, config: Config): Promis
|
||||
)
|
||||
app.get('/api/v1/:workspace/stats', withToken, withOwner, wrapRequest(ctx, 'getStats', handleGetStats))
|
||||
|
||||
app.post(
|
||||
'/api/v1/ai/transcript',
|
||||
withToken,
|
||||
withAdmin,
|
||||
wrapRequest(ctx, 'pushAiTranscriptData', handlePushAiTranscriptData)
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/api/v1/ai/transcript/last',
|
||||
withToken,
|
||||
withAdmin,
|
||||
wrapRequest(ctx, 'getAiTranscriptLastData', handleGetAiTranscriptLastData)
|
||||
)
|
||||
|
||||
app.post('/api/v1/ai/tokens', withToken, withAdmin, wrapRequest(ctx, 'pushAiTokensData', handlePushAiTokensData))
|
||||
|
||||
app.use((_req, res) => {
|
||||
res.status(404).json({ message: 'Not Found' })
|
||||
})
|
||||
|
||||
@@ -50,10 +50,57 @@ export interface LiveKitEgressData {
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface AiTranscriptUsage {
|
||||
totalDurationSeconds: number
|
||||
}
|
||||
|
||||
export interface AiTokensUsage {
|
||||
reason: string
|
||||
totalTokens: number
|
||||
}
|
||||
|
||||
export interface AiUsageData {
|
||||
transcript: AiTranscriptUsage
|
||||
tokens: AiTokensUsage[]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface BillingDB {
|
||||
getLiveKitStats: (ctx: MeasureContext, workspace: WorkspaceUuid, start: Date, end: Date) => Promise<LiveKitUsageData>
|
||||
listLiveKitSessions: (ctx: MeasureContext, workspace: WorkspaceUuid) => Promise<LiveKitSessionData[] | null>
|
||||
listLiveKitEgress: (ctx: MeasureContext, workspace: WorkspaceUuid) => Promise<LiveKitEgressData[] | null>
|
||||
setLiveKitSessions: (ctx: MeasureContext, data: LiveKitSessionData[]) => Promise<void>
|
||||
setLiveKitEgress: (ctx: MeasureContext, data: LiveKitEgressData[]) => Promise<void>
|
||||
|
||||
pushAiTranscriptData: (ctx: MeasureContext, data: AiTranscriptData[]) => Promise<void>
|
||||
getAiTranscriptLastData: (ctx: MeasureContext) => Promise<AiTranscriptData | undefined>
|
||||
getAiTranscriptStats: (
|
||||
ctx: MeasureContext,
|
||||
workspace: WorkspaceUuid,
|
||||
start?: Date,
|
||||
end?: Date
|
||||
) => Promise<AiTranscriptUsage>
|
||||
|
||||
pushAiTokensData: (ctx: MeasureContext, data: AiTokensData[]) => Promise<void>
|
||||
getAiTokensStats: (
|
||||
ctx: MeasureContext,
|
||||
workspace: WorkspaceUuid,
|
||||
start?: Date,
|
||||
end?: Date
|
||||
) => Promise<AiTokensUsage[]>
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@hcengineering/analytics": "^0.7.5",
|
||||
"@hcengineering/analytics-service": "^0.7.5",
|
||||
"@hcengineering/api-client": "^0.7.5",
|
||||
"@hcengineering/billing-client": "^0.7.0",
|
||||
"@hcengineering/card": "^0.7.0",
|
||||
"@hcengineering/communication-sdk-types": "^0.7.7",
|
||||
"@hcengineering/communication-shared": "^0.7.7",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// 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 { AiTokensData, getClient as getBillingClient } from '@hcengineering/billing-client'
|
||||
import { MeasureContext, systemAccountUuid } from '@hcengineering/core'
|
||||
import { generateToken } from '@hcengineering/server-token'
|
||||
|
||||
import config from './config'
|
||||
|
||||
export async function pushTokensData (ctx: MeasureContext, data: AiTokensData[]): Promise<void> {
|
||||
if (config.BillingUrl === '') return
|
||||
try {
|
||||
const token = generateToken(systemAccountUuid, undefined, { service: 'translate' })
|
||||
const billingClient = getBillingClient(config.BillingUrl, token)
|
||||
await billingClient.postAiTokensData(data)
|
||||
} catch (e) {
|
||||
ctx.error('Failed to push tokens data', { e })
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export interface Config {
|
||||
Secret: string
|
||||
ServiceId: string
|
||||
HulylakeUrl: string
|
||||
BillingUrl: string
|
||||
}
|
||||
|
||||
const config: Config = (() => {
|
||||
@@ -39,7 +40,8 @@ const config: Config = (() => {
|
||||
ServiceId: process.env.SERVICE_ID ?? 'translate',
|
||||
OpenAIKey: process.env.OPENAI_API_KEY,
|
||||
OpenAIModel: (process.env.OPENAI_MODEL ?? 'gpt-4o-mini') as OpenAI.ChatModel,
|
||||
OpenAIBaseUrl: process.env.OPENAI_BASE_URL ?? ''
|
||||
OpenAIBaseUrl: process.env.OPENAI_BASE_URL ?? '',
|
||||
BillingUrl: process.env.BILLING_URL ?? ''
|
||||
}
|
||||
|
||||
const missingEnv = (Object.keys(params) as Array<keyof Config>).filter((key) => params[key] === undefined)
|
||||
|
||||
@@ -37,6 +37,7 @@ import { MessageEventType, TranslateMessageEvent, UpdatePatchEvent } from '@hcen
|
||||
|
||||
import { Storage } from './storage'
|
||||
import config from './config'
|
||||
import { pushTokensData } from './billing'
|
||||
|
||||
export class Controller {
|
||||
private readonly languagesByWorkspace = new Map<WorkspaceUuid, string[]>()
|
||||
@@ -146,7 +147,7 @@ export class Controller {
|
||||
const txes: Tx[] = []
|
||||
for (const lang of translateTo) {
|
||||
try {
|
||||
const result = await withRetry(() => this.translate(message.content, lang))
|
||||
const result = await withRetry(() => this.translate(workspace, message.content, lang))
|
||||
if (result == null) continue
|
||||
const translation = result?.translation ?? ''
|
||||
|
||||
@@ -211,7 +212,7 @@ export class Controller {
|
||||
|
||||
for (const lang of translateTo) {
|
||||
try {
|
||||
const result = await withRetry(() => this.translate(content, lang))
|
||||
const result = await withRetry(() => this.translate(workspace, content, lang))
|
||||
if (result == null) continue
|
||||
const translation = result?.translation ?? ''
|
||||
if (result?.original_language != null && result.original_language !== '') {
|
||||
@@ -278,6 +279,7 @@ export class Controller {
|
||||
}
|
||||
|
||||
private async translate (
|
||||
workspace: WorkspaceUuid,
|
||||
markdown: string,
|
||||
lang: string
|
||||
): Promise<{ original_language?: string, translation?: string } | undefined> {
|
||||
@@ -312,6 +314,17 @@ Do not add any explanations, comments, or extra text outside the JSON.
|
||||
]
|
||||
})
|
||||
|
||||
if (response.usage != null) {
|
||||
void pushTokensData(this.ctx, [
|
||||
{
|
||||
workspace,
|
||||
reason: 'auto-translate',
|
||||
tokens: response.usage.total_tokens,
|
||||
date: new Date(response.created * 1000).toISOString()
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
const res = response.choices[0]?.message.content ?? ''
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user