589 lines
19 KiB
Plaintext
589 lines
19 KiB
Plaintext
import Component from "@glimmer/component";
|
|
import { tracked } from "@glimmer/tracking";
|
|
import { fn, hash } from "@ember/helper";
|
|
import { action } from "@ember/object";
|
|
import { LinkTo } from "@ember/routing";
|
|
import { service } from "@ember/service";
|
|
import { eq, gt, lt } from "truth-helpers";
|
|
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
|
|
import DButton from "discourse/components/d-button";
|
|
import DPageSubheader from "discourse/components/d-page-subheader";
|
|
import DStatTiles from "discourse/components/d-stat-tiles";
|
|
import DateTimeInputRange from "discourse/components/date-time-input-range";
|
|
import avatar from "discourse/helpers/avatar";
|
|
import concatClass from "discourse/helpers/concat-class";
|
|
import { ajax } from "discourse/lib/ajax";
|
|
import { number } from "discourse/lib/formatter";
|
|
import i18n from "discourse-common/helpers/i18n";
|
|
import { bind } from "discourse-common/utils/decorators";
|
|
import AdminConfigAreaCard from "admin/components/admin-config-area-card";
|
|
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
|
|
import Chart from "admin/components/chart";
|
|
import ComboBox from "select-kit/components/combo-box";
|
|
|
|
export default class AiUsage extends Component {
|
|
@service store;
|
|
@tracked startDate = moment().subtract(30, "days").toDate();
|
|
@tracked endDate = new Date();
|
|
@tracked data = this.args.model;
|
|
@tracked selectedFeature;
|
|
@tracked selectedModel;
|
|
@tracked selectedPeriod = "month";
|
|
@tracked isCustomDateActive = false;
|
|
@tracked loadingData = true;
|
|
|
|
constructor() {
|
|
super(...arguments);
|
|
this.fetchData();
|
|
}
|
|
|
|
@action
|
|
async fetchData() {
|
|
const response = await ajax(
|
|
"/admin/plugins/discourse-ai/ai-usage-report.json",
|
|
{
|
|
data: {
|
|
start_date: moment(this.startDate).format("YYYY-MM-DD"),
|
|
end_date: moment(this.endDate).format("YYYY-MM-DD"),
|
|
feature: this.selectedFeature,
|
|
model: this.selectedModel,
|
|
},
|
|
}
|
|
);
|
|
this.data = response;
|
|
this.loadingData = false;
|
|
this._cachedFeatures = null;
|
|
this._cachedModels = null;
|
|
}
|
|
|
|
@action
|
|
async onFilterChange() {
|
|
await this.fetchData();
|
|
}
|
|
|
|
@action
|
|
onFeatureChanged(value) {
|
|
this.selectedFeature = value;
|
|
this.onFilterChange();
|
|
}
|
|
|
|
@action
|
|
onModelChanged(value) {
|
|
this.selectedModel = value;
|
|
this.onFilterChange();
|
|
}
|
|
|
|
@bind
|
|
takeUsers(start, end) {
|
|
return this.data.users.slice(start, end);
|
|
}
|
|
|
|
normalizeTimeSeriesData(data) {
|
|
if (!data?.length) {
|
|
return [];
|
|
}
|
|
|
|
const startDate = moment(this.startDate);
|
|
const endDate = moment(this.endDate);
|
|
const normalized = [];
|
|
let interval;
|
|
let format;
|
|
|
|
if (this.data.period === "hour") {
|
|
interval = "hour";
|
|
format = "YYYY-MM-DD HH:00:00";
|
|
} else if (this.data.period === "day") {
|
|
interval = "day";
|
|
format = "YYYY-MM-DD";
|
|
} else {
|
|
interval = "month";
|
|
format = "YYYY-MM";
|
|
}
|
|
const dataMap = new Map(
|
|
data.map((d) => [moment(d.period).format(format), d])
|
|
);
|
|
|
|
for (
|
|
let currentMoment = moment(startDate);
|
|
currentMoment.isSameOrBefore(endDate);
|
|
currentMoment.add(1, interval)
|
|
) {
|
|
const dateKey = currentMoment.format(format);
|
|
const existingData = dataMap.get(dateKey);
|
|
|
|
normalized.push(
|
|
existingData || {
|
|
period: currentMoment.format(),
|
|
total_tokens: 0,
|
|
total_cached_tokens: 0,
|
|
total_request_tokens: 0,
|
|
total_response_tokens: 0,
|
|
}
|
|
);
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
get metrics() {
|
|
return [
|
|
{
|
|
label: i18n("discourse_ai.usage.total_requests"),
|
|
value: this.data.summary.total_requests,
|
|
tooltip: i18n("discourse_ai.usage.stat_tooltips.total_requests"),
|
|
},
|
|
{
|
|
label: i18n("discourse_ai.usage.total_tokens"),
|
|
value: this.data.summary.total_tokens,
|
|
tooltip: i18n("discourse_ai.usage.stat_tooltips.total_tokens"),
|
|
},
|
|
{
|
|
label: i18n("discourse_ai.usage.request_tokens"),
|
|
value: this.data.summary.total_request_tokens,
|
|
tooltip: i18n("discourse_ai.usage.stat_tooltips.request_tokens"),
|
|
},
|
|
{
|
|
label: i18n("discourse_ai.usage.response_tokens"),
|
|
value: this.data.summary.total_response_tokens,
|
|
tooltip: i18n("discourse_ai.usage.stat_tooltips.response_tokens"),
|
|
},
|
|
{
|
|
label: i18n("discourse_ai.usage.cached_tokens"),
|
|
value: this.data.summary.total_cached_tokens,
|
|
tooltip: i18n("discourse_ai.usage.stat_tooltips.cached_tokens"),
|
|
},
|
|
];
|
|
}
|
|
|
|
get chartConfig() {
|
|
if (!this.data?.data) {
|
|
return;
|
|
}
|
|
|
|
const normalizedData = this.normalizeTimeSeriesData(this.data.data);
|
|
|
|
const chartEl = document.querySelector(".ai-usage__chart");
|
|
const computedStyle = getComputedStyle(chartEl);
|
|
|
|
const colors = {
|
|
response: computedStyle.getPropertyValue("--chart-response-color").trim(),
|
|
request: computedStyle.getPropertyValue("--chart-request-color").trim(),
|
|
cached: computedStyle.getPropertyValue("--chart-cached-color").trim(),
|
|
};
|
|
|
|
return {
|
|
type: "bar",
|
|
data: {
|
|
labels: normalizedData.map((row) => {
|
|
const date = moment(row.period);
|
|
if (this.data.period === "hour") {
|
|
return date.format("HH:00");
|
|
} else if (this.data.period === "day") {
|
|
return date.format("DD-MMM");
|
|
} else {
|
|
return date.format("MMM-YY");
|
|
}
|
|
}),
|
|
datasets: [
|
|
{
|
|
label: i18n("discourse_ai.usage.response_tokens"),
|
|
data: normalizedData.map((row) => row.total_response_tokens),
|
|
backgroundColor: colors.response,
|
|
},
|
|
{
|
|
label: i18n("discourse_ai.usage.net_request_tokens"),
|
|
data: normalizedData.map(
|
|
(row) => row.total_request_tokens - row.total_cached_tokens
|
|
),
|
|
backgroundColor: colors.request,
|
|
},
|
|
{
|
|
label: i18n("discourse_ai.usage.cached_request_tokens"),
|
|
data: normalizedData.map((row) => row.total_cached_tokens),
|
|
backgroundColor: colors.cached,
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
scales: {
|
|
x: {
|
|
stacked: true,
|
|
},
|
|
y: {
|
|
stacked: true,
|
|
beginAtZero: true,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
get availableFeatures() {
|
|
// when you switch we don't want the list to change
|
|
// only when you switch durations
|
|
this._cachedFeatures =
|
|
this._cachedFeatures ||
|
|
(this.data?.features || []).map((f) => ({
|
|
id: f.feature_name,
|
|
name: f.feature_name,
|
|
}));
|
|
|
|
return this._cachedFeatures;
|
|
}
|
|
|
|
get availableModels() {
|
|
this._cachedModels =
|
|
this._cachedModels ||
|
|
(this.data?.models || []).map((m) => ({
|
|
id: m.llm,
|
|
name: m.llm,
|
|
}));
|
|
|
|
return this._cachedModels;
|
|
}
|
|
|
|
get periodOptions() {
|
|
return [
|
|
{ id: "day", name: i18n("discourse_ai.usage.periods.last_day") },
|
|
{ id: "week", name: i18n("discourse_ai.usage.periods.last_week") },
|
|
{ id: "month", name: i18n("discourse_ai.usage.periods.last_month") },
|
|
];
|
|
}
|
|
|
|
@action
|
|
setPeriodDates(period) {
|
|
const now = moment();
|
|
|
|
switch (period) {
|
|
case "day":
|
|
this.startDate = now.clone().subtract(1, "day").toDate();
|
|
this.endDate = now.toDate();
|
|
break;
|
|
case "week":
|
|
this.startDate = now.clone().subtract(7, "days").toDate();
|
|
this.endDate = now.toDate();
|
|
break;
|
|
case "month":
|
|
this.startDate = now.clone().subtract(30, "days").toDate();
|
|
this.endDate = now.toDate();
|
|
break;
|
|
}
|
|
}
|
|
|
|
@action
|
|
onPeriodSelect(period) {
|
|
this.selectedPeriod = period;
|
|
this.isCustomDateActive = false;
|
|
this.setPeriodDates(period);
|
|
this.fetchData();
|
|
}
|
|
|
|
@action
|
|
onCustomDateClick() {
|
|
this.isCustomDateActive = !this.isCustomDateActive;
|
|
if (this.isCustomDateActive) {
|
|
this.selectedPeriod = null;
|
|
}
|
|
}
|
|
|
|
@action
|
|
onDateChange() {
|
|
this.isCustomDateActive = true;
|
|
this.selectedPeriod = null;
|
|
this.fetchData();
|
|
}
|
|
|
|
@action
|
|
onChangeDateRange({ from, to }) {
|
|
this._startDate = from;
|
|
this._endDate = to;
|
|
}
|
|
|
|
@action
|
|
onRefreshDateRange() {
|
|
this.startDate = this._startDate;
|
|
this.endDate = this._endDate;
|
|
this.fetchData();
|
|
}
|
|
|
|
<template>
|
|
<div class="ai-usage admin-detail">
|
|
<DPageSubheader
|
|
@titleLabel={{i18n "discourse_ai.usage.short_title"}}
|
|
@learnMoreUrl="https://meta.discourse.org/t/-/348677"
|
|
@descriptionLabel={{i18n "discourse_ai.usage.subheader_description"}}
|
|
/>
|
|
<div class="ai-usage__filters">
|
|
<div class="ai-usage__filters-dates">
|
|
<div class="ai-usage__period-buttons">
|
|
{{#each this.periodOptions as |option|}}
|
|
<DButton
|
|
class={{if
|
|
(eq this.selectedPeriod option.id)
|
|
"btn-primary"
|
|
"btn-default"
|
|
}}
|
|
@action={{fn this.onPeriodSelect option.id}}
|
|
@translatedLabel={{option.name}}
|
|
/>
|
|
{{/each}}
|
|
<DButton
|
|
class={{if this.isCustomDateActive "btn-primary" "btn-default"}}
|
|
@action={{this.onCustomDateClick}}
|
|
@label="discourse_ai.usage.periods.custom"
|
|
/>
|
|
</div>
|
|
|
|
{{#if this.isCustomDateActive}}
|
|
<div class="ai-usage__custom-date-pickers">
|
|
|
|
<DateTimeInputRange
|
|
@from={{this.startDate}}
|
|
@to={{this.endDate}}
|
|
@onChange={{this.onChangeDateRange}}
|
|
@showFromTime={{false}}
|
|
@showToTime={{false}}
|
|
/>
|
|
|
|
<DButton @action={{this.onRefreshDateRange}} @label="refresh" />
|
|
</div>
|
|
{{/if}}
|
|
</div>
|
|
|
|
<div class="ai-usage__filters-row">
|
|
<ComboBox
|
|
@value={{this.selectedFeature}}
|
|
@content={{this.availableFeatures}}
|
|
@onChange={{this.onFeatureChanged}}
|
|
@options={{hash none="discourse_ai.usage.all_features"}}
|
|
class="ai-usage__feature-selector"
|
|
/>
|
|
|
|
<ComboBox
|
|
@value={{this.selectedModel}}
|
|
@content={{this.availableModels}}
|
|
@onChange={{this.onModelChanged}}
|
|
@options={{hash none="discourse_ai.usage.all_models"}}
|
|
class="ai-usage__model-selector"
|
|
/>
|
|
</div>
|
|
|
|
<ConditionalLoadingSpinner @condition={{this.loadingData}}>
|
|
<AdminConfigAreaCard
|
|
@heading="discourse_ai.usage.summary"
|
|
class="ai-usage__summary"
|
|
>
|
|
<:content>
|
|
<DStatTiles as |tiles|>
|
|
{{#each this.metrics as |metric|}}
|
|
<tiles.Tile
|
|
@label={{metric.label}}
|
|
@href={{metric.href}}
|
|
@value={{metric.value}}
|
|
@tooltip={{metric.tooltip}}
|
|
/>
|
|
{{/each}}
|
|
</DStatTiles>
|
|
</:content>
|
|
</AdminConfigAreaCard>
|
|
|
|
<AdminConfigAreaCard
|
|
class="ai-usage__charts"
|
|
@heading="discourse_ai.usage.tokens_over_time"
|
|
>
|
|
<:content>
|
|
<div class="ai-usage__chart-container">
|
|
<Chart
|
|
@chartConfig={{this.chartConfig}}
|
|
class="ai-usage__chart"
|
|
/>
|
|
</div>
|
|
</:content>
|
|
</AdminConfigAreaCard>
|
|
|
|
<div class="ai-usage__breakdowns">
|
|
<AdminConfigAreaCard
|
|
class="ai-usage__features"
|
|
@heading="discourse_ai.usage.features_breakdown"
|
|
>
|
|
<:content>
|
|
{{#unless this.data.features.length}}
|
|
<AdminConfigAreaEmptyList
|
|
@emptyLabel="discourse_ai.usage.no_features"
|
|
/>
|
|
{{/unless}}
|
|
|
|
{{#if this.data.features.length}}
|
|
<table class="ai-usage__features-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{{i18n "discourse_ai.usage.feature"}}</th>
|
|
<th>{{i18n "discourse_ai.usage.usage_count"}}</th>
|
|
<th>{{i18n "discourse_ai.usage.total_tokens"}}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{#each this.data.features as |feature|}}
|
|
<tr class="ai-usage__features-row">
|
|
<td
|
|
class="ai-usage__features-cell"
|
|
>{{feature.feature_name}}</td>
|
|
<td
|
|
class="ai-usage__features-cell"
|
|
title={{feature.usage_count}}
|
|
>{{number feature.usage_count}}</td>
|
|
<td
|
|
class="ai-usage__features-cell"
|
|
title={{feature.total_tokens}}
|
|
>{{number feature.total_tokens}}</td>
|
|
</tr>
|
|
{{/each}}
|
|
</tbody>
|
|
</table>
|
|
{{/if}}
|
|
</:content>
|
|
</AdminConfigAreaCard>
|
|
|
|
<AdminConfigAreaCard
|
|
class="ai-usage__models"
|
|
@heading="discourse_ai.usage.models_breakdown"
|
|
>
|
|
<:content>
|
|
{{#unless this.data.models.length}}
|
|
<AdminConfigAreaEmptyList
|
|
@emptyLabel="discourse_ai.usage.no_models"
|
|
/>
|
|
{{/unless}}
|
|
|
|
{{#if this.data.models.length}}
|
|
<table class="ai-usage__models-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{{i18n "discourse_ai.usage.model"}}</th>
|
|
<th>{{i18n "discourse_ai.usage.usage_count"}}</th>
|
|
<th>{{i18n "discourse_ai.usage.total_tokens"}}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{#each this.data.models as |model|}}
|
|
<tr class="ai-usage__models-row">
|
|
<td class="ai-usage__models-cell">{{model.llm}}</td>
|
|
<td
|
|
class="ai-usage__models-cell"
|
|
title={{model.usage_count}}
|
|
>{{number model.usage_count}}</td>
|
|
<td
|
|
class="ai-usage__models-cell"
|
|
title={{model.total_tokens}}
|
|
>{{number model.total_tokens}}</td>
|
|
</tr>
|
|
{{/each}}
|
|
</tbody>
|
|
</table>
|
|
{{/if}}
|
|
</:content>
|
|
</AdminConfigAreaCard>
|
|
|
|
<AdminConfigAreaCard
|
|
class="ai-usage__users"
|
|
@heading="discourse_ai.usage.users_breakdown"
|
|
>
|
|
<:content>
|
|
{{#unless this.data.users.length}}
|
|
<AdminConfigAreaEmptyList
|
|
@emptyLabel="discourse_ai.usage.no_users"
|
|
/>
|
|
{{/unless}}
|
|
|
|
{{#if this.data.users.length}}
|
|
<table
|
|
class={{concatClass
|
|
"ai-usage__users-table"
|
|
(if (lt this.data.users.length 25) "-double-width")
|
|
}}
|
|
>
|
|
<thead>
|
|
<tr>
|
|
<th class="ai-usage__users-username">{{i18n
|
|
"discourse_ai.usage.username"
|
|
}}</th>
|
|
<th>{{i18n "discourse_ai.usage.usage_count"}}</th>
|
|
<th>{{i18n "discourse_ai.usage.total_tokens"}}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{#each (this.takeUsers 0 24) as |user|}}
|
|
<tr class="ai-usage__users-row">
|
|
<td class="ai-usage__users-cell">
|
|
<div class="user-info">
|
|
<LinkTo
|
|
@route="user"
|
|
@model={{user.username}}
|
|
class="username"
|
|
>
|
|
{{avatar user imageSize="tiny"}}
|
|
{{user.username}}
|
|
</LinkTo>
|
|
</div></td>
|
|
<td
|
|
class="ai-usage__users-cell"
|
|
title={{user.usage_count}}
|
|
>{{number user.usage_count}}</td>
|
|
<td
|
|
class="ai-usage__users-cell"
|
|
title={{user.total_tokens}}
|
|
>{{number user.total_tokens}}</td>
|
|
</tr>
|
|
{{/each}}
|
|
</tbody>
|
|
</table>
|
|
|
|
{{#if (gt this.data.users.length 25)}}
|
|
<table class="ai-usage__users-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="ai-usage__users-username">{{i18n
|
|
"discourse_ai.usage.username"
|
|
}}</th>
|
|
<th>{{i18n "discourse_ai.usage.usage_count"}}</th>
|
|
<th>{{i18n "discourse_ai.usage.total_tokens"}}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{#each (this.takeUsers 25 49) as |user|}}
|
|
<tr class="ai-usage__users-row">
|
|
<td class="ai-usage__users-cell">
|
|
<div class="user-info">
|
|
<LinkTo
|
|
@route="user"
|
|
@model={{user.username}}
|
|
class="username"
|
|
>
|
|
{{avatar user imageSize="tiny"}}
|
|
{{user.username}}
|
|
</LinkTo>
|
|
</div></td>
|
|
<td
|
|
class="ai-usage__users-cell"
|
|
title={{user.usage_count}}
|
|
>{{number user.usage_count}}</td>
|
|
<td
|
|
class="ai-usage__users-cell"
|
|
title={{user.total_tokens}}
|
|
>{{number user.total_tokens}}</td>
|
|
</tr>
|
|
{{/each}}
|
|
</tbody>
|
|
</table>
|
|
{{/if}}
|
|
{{/if}}
|
|
</:content>
|
|
</AdminConfigAreaCard>
|
|
</div>
|
|
</ConditionalLoadingSpinner>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
}
|