UX: Improve rough edges of AI usage page (#1014)
* UX: Improve rough edges of AI usage page * Ensure all text uses I18n * Change from <button> usage to <DButton> * Use <AdminConfigAreaCard> in place of custom card styles * Format numbers nicely using our number format helper, show full values on hover using title attr * Ensure 0 is always shown for counters, instead of being blank * FEATURE: Load usage data after page load Use ConditionalLoadingSpinner to hide load of usage data, this prevents us hanging on page load with a white screen. * UX: Split users table, and add empty placeholders and page subheader * DEV: Test fix
This commit is contained in:
parent
a4440c507b
commit
ae80494448
|
@ -6,6 +6,9 @@ module DiscourseAi
|
||||||
requires_plugin "discourse-ai"
|
requires_plugin "discourse-ai"
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
def report
|
||||||
render json: AiUsageSerializer.new(create_report, root: false)
|
render json: AiUsageSerializer.new(create_report, root: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,22 @@
|
||||||
import Component from "@glimmer/component";
|
import Component from "@glimmer/component";
|
||||||
import { tracked } from "@glimmer/tracking";
|
import { tracked } from "@glimmer/tracking";
|
||||||
import { fn, hash } from "@ember/helper";
|
import { fn, hash } from "@ember/helper";
|
||||||
import { on } from "@ember/modifier";
|
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
import { LinkTo } from "@ember/routing";
|
import { LinkTo } from "@ember/routing";
|
||||||
import { service } from "@ember/service";
|
import { service } from "@ember/service";
|
||||||
import { eq } from "truth-helpers";
|
import { eq, gt, lt } from "truth-helpers";
|
||||||
|
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
import DateTimeInputRange from "discourse/components/date-time-input-range";
|
import DateTimeInputRange from "discourse/components/date-time-input-range";
|
||||||
import avatar from "discourse/helpers/avatar";
|
import avatar from "discourse/helpers/avatar";
|
||||||
|
import concatClass from "discourse/helpers/concat-class";
|
||||||
import { ajax } from "discourse/lib/ajax";
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import { number } from "discourse/lib/formatter";
|
||||||
import i18n from "discourse-common/helpers/i18n";
|
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 AdminPageSubheader from "admin/components/admin-page-subheader";
|
||||||
import Chart from "admin/components/chart";
|
import Chart from "admin/components/chart";
|
||||||
import ComboBox from "select-kit/components/combo-box";
|
import ComboBox from "select-kit/components/combo-box";
|
||||||
|
|
||||||
|
@ -22,18 +29,30 @@ export default class AiUsage extends Component {
|
||||||
@tracked selectedModel;
|
@tracked selectedModel;
|
||||||
@tracked selectedPeriod = "month";
|
@tracked selectedPeriod = "month";
|
||||||
@tracked isCustomDateActive = false;
|
@tracked isCustomDateActive = false;
|
||||||
|
@tracked loadingData = true;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
this.fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async fetchData() {
|
async fetchData() {
|
||||||
const response = await ajax("/admin/plugins/discourse-ai/ai-usage.json", {
|
const response = await ajax(
|
||||||
|
"/admin/plugins/discourse-ai/ai-usage-report.json",
|
||||||
|
{
|
||||||
data: {
|
data: {
|
||||||
start_date: moment(this.startDate).format("YYYY-MM-DD"),
|
start_date: moment(this.startDate).format("YYYY-MM-DD"),
|
||||||
end_date: moment(this.endDate).format("YYYY-MM-DD"),
|
end_date: moment(this.endDate).format("YYYY-MM-DD"),
|
||||||
feature: this.selectedFeature,
|
feature: this.selectedFeature,
|
||||||
model: this.selectedModel,
|
model: this.selectedModel,
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
|
);
|
||||||
this.data = response;
|
this.data = response;
|
||||||
|
this.loadingData = false;
|
||||||
|
this._cachedFeatures = null;
|
||||||
|
this._cachedModels = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
@ -53,6 +72,11 @@ export default class AiUsage extends Component {
|
||||||
this.onFilterChange();
|
this.onFilterChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
takeUsers(start, end) {
|
||||||
|
return this.data.users.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
normalizeTimeSeriesData(data) {
|
normalizeTimeSeriesData(data) {
|
||||||
if (!data?.length) {
|
if (!data?.length) {
|
||||||
return [];
|
return [];
|
||||||
|
@ -79,16 +103,16 @@ export default class AiUsage extends Component {
|
||||||
);
|
);
|
||||||
|
|
||||||
for (
|
for (
|
||||||
let m = moment(startDate);
|
let currentMoment = moment(startDate);
|
||||||
m.isSameOrBefore(endDate);
|
currentMoment.isSameOrBefore(endDate);
|
||||||
m.add(1, interval)
|
currentMoment.add(1, interval)
|
||||||
) {
|
) {
|
||||||
const dateKey = m.format(format);
|
const dateKey = currentMoment.format(format);
|
||||||
const existingData = dataMap.get(dateKey);
|
const existingData = dataMap.get(dateKey);
|
||||||
|
|
||||||
normalized.push(
|
normalized.push(
|
||||||
existingData || {
|
existingData || {
|
||||||
period: m.format(),
|
period: currentMoment.format(),
|
||||||
total_tokens: 0,
|
total_tokens: 0,
|
||||||
total_cached_tokens: 0,
|
total_cached_tokens: 0,
|
||||||
total_request_tokens: 0,
|
total_request_tokens: 0,
|
||||||
|
@ -131,19 +155,19 @@ export default class AiUsage extends Component {
|
||||||
}),
|
}),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: "Response Tokens",
|
label: i18n("discourse_ai.usage.response_tokens"),
|
||||||
data: normalizedData.map((row) => row.total_response_tokens),
|
data: normalizedData.map((row) => row.total_response_tokens),
|
||||||
backgroundColor: colors.response,
|
backgroundColor: colors.response,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Net Request Tokens",
|
label: i18n("discourse_ai.usage.net_request_tokens"),
|
||||||
data: normalizedData.map(
|
data: normalizedData.map(
|
||||||
(row) => row.total_request_tokens - row.total_cached_tokens
|
(row) => row.total_request_tokens - row.total_cached_tokens
|
||||||
),
|
),
|
||||||
backgroundColor: colors.request,
|
backgroundColor: colors.request,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Cached Request Tokens",
|
label: i18n("discourse_ai.usage.cached_request_tokens"),
|
||||||
data: normalizedData.map((row) => row.total_cached_tokens),
|
data: normalizedData.map((row) => row.total_cached_tokens),
|
||||||
backgroundColor: colors.cached,
|
backgroundColor: colors.cached,
|
||||||
},
|
},
|
||||||
|
@ -190,9 +214,9 @@ export default class AiUsage extends Component {
|
||||||
|
|
||||||
get periodOptions() {
|
get periodOptions() {
|
||||||
return [
|
return [
|
||||||
{ id: "day", name: "Last 24 Hours" },
|
{ id: "day", name: i18n("discourse_ai.usage.periods.last_day") },
|
||||||
{ id: "week", name: "Last Week" },
|
{ id: "week", name: i18n("discourse_ai.usage.periods.last_week") },
|
||||||
{ id: "month", name: "Last Month" },
|
{ id: "month", name: i18n("discourse_ai.usage.periods.last_month") },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -253,33 +277,31 @@ export default class AiUsage extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ai-usage">
|
<div class="ai-usage admin-detail">
|
||||||
|
<AdminPageSubheader
|
||||||
|
@titleLabel="discourse_ai.usage.short_title"
|
||||||
|
@learnMoreUrl="https://meta.discourse.org/t/estimating-costs-of-using-llms-for-discourse-ai/307243"
|
||||||
|
@descriptionLabel="discourse_ai.usage.subheader_description"
|
||||||
|
/>
|
||||||
<div class="ai-usage__filters">
|
<div class="ai-usage__filters">
|
||||||
|
|
||||||
<div class="ai-usage__filters-dates">
|
<div class="ai-usage__filters-dates">
|
||||||
<div class="ai-usage__period-buttons">
|
<div class="ai-usage__period-buttons">
|
||||||
{{#each this.periodOptions as |option|}}
|
{{#each this.periodOptions as |option|}}
|
||||||
<button
|
<DButton
|
||||||
type="button"
|
class={{if
|
||||||
class="btn
|
|
||||||
{{if
|
|
||||||
(eq this.selectedPeriod option.id)
|
(eq this.selectedPeriod option.id)
|
||||||
'btn-primary'
|
"btn-primary"
|
||||||
'btn-default'
|
"btn-default"
|
||||||
}}"
|
}}
|
||||||
{{on "click" (fn this.onPeriodSelect option.id)}}
|
@action={{fn this.onPeriodSelect option.id}}
|
||||||
>
|
@translatedLabel={{option.name}}
|
||||||
{{option.name}}
|
/>
|
||||||
</button>
|
|
||||||
{{/each}}
|
{{/each}}
|
||||||
<button
|
<DButton
|
||||||
type="button"
|
class={{if this.isCustomDateActive "btn-primary" "btn-default"}}
|
||||||
class="btn
|
@action={{this.onCustomDateClick}}
|
||||||
{{if this.isCustomDateActive 'btn-primary' 'btn-default'}}"
|
@label="discourse_ai.usage.periods.custom"
|
||||||
{{on "click" this.onCustomDateClick}}
|
/>
|
||||||
>
|
|
||||||
Custom...
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if this.isCustomDateActive}}
|
{{#if this.isCustomDateActive}}
|
||||||
|
@ -293,13 +315,7 @@ export default class AiUsage extends Component {
|
||||||
@showToTime={{false}}
|
@showToTime={{false}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<DButton @action={{this.onRefreshDateRange}} @label="refresh" />
|
||||||
type="button"
|
|
||||||
class="btn btn-default"
|
|
||||||
{{on "click" this.onRefreshDateRange}}
|
|
||||||
>
|
|
||||||
{{i18n "refresh"}}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
@ -322,23 +338,30 @@ export default class AiUsage extends Component {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if this.data}}
|
<ConditionalLoadingSpinner @condition={{this.loadingData}}>
|
||||||
<div class="ai-usage__summary">
|
<AdminConfigAreaCard
|
||||||
<h3 class="ai-usage__summary-title">
|
@heading="discourse_ai.usage.summary"
|
||||||
{{i18n "discourse_ai.usage.summary"}}
|
class="ai-usage__summary"
|
||||||
</h3>
|
>
|
||||||
|
<:content>
|
||||||
<div class="ai-usage__summary-stats">
|
<div class="ai-usage__summary-stats">
|
||||||
<div class="ai-usage__summary-stat">
|
<div class="ai-usage__summary-stat">
|
||||||
<span class="label">{{i18n
|
<span class="label">{{i18n
|
||||||
"discourse_ai.usage.total_requests"
|
"discourse_ai.usage.total_requests"
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="value">{{this.data.summary.total_requests}}</span>
|
<span
|
||||||
|
class="value"
|
||||||
|
title={{this.data.summary.total_requests}}
|
||||||
|
>{{number this.data.summary.total_requests}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-usage__summary-stat">
|
<div class="ai-usage__summary-stat">
|
||||||
<span class="label">{{i18n
|
<span class="label">{{i18n
|
||||||
"discourse_ai.usage.total_tokens"
|
"discourse_ai.usage.total_tokens"
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="value">{{this.data.summary.total_tokens}}</span>
|
<span
|
||||||
|
class="value"
|
||||||
|
title={{this.data.summary.total_tokens}}
|
||||||
|
>{{number this.data.summary.total_tokens}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-usage__summary-stat">
|
<div class="ai-usage__summary-stat">
|
||||||
<span class="label">{{i18n
|
<span class="label">{{i18n
|
||||||
|
@ -346,7 +369,8 @@ export default class AiUsage extends Component {
|
||||||
}}</span>
|
}}</span>
|
||||||
<span
|
<span
|
||||||
class="value"
|
class="value"
|
||||||
>{{this.data.summary.total_request_tokens}}</span>
|
title={{this.data.summary.total_request_tokens}}
|
||||||
|
>{{number this.data.summary.total_request_tokens}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-usage__summary-stat">
|
<div class="ai-usage__summary-stat">
|
||||||
<span class="label">{{i18n
|
<span class="label">{{i18n
|
||||||
|
@ -354,7 +378,8 @@ export default class AiUsage extends Component {
|
||||||
}}</span>
|
}}</span>
|
||||||
<span
|
<span
|
||||||
class="value"
|
class="value"
|
||||||
>{{this.data.summary.total_response_tokens}}</span>
|
title={{this.data.summary.total_response_tokens}}
|
||||||
|
>{{number this.data.summary.total_response_tokens}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-usage__summary-stat">
|
<div class="ai-usage__summary-stat">
|
||||||
<span class="label">{{i18n
|
<span class="label">{{i18n
|
||||||
|
@ -362,66 +387,40 @@ export default class AiUsage extends Component {
|
||||||
}}</span>
|
}}</span>
|
||||||
<span
|
<span
|
||||||
class="value"
|
class="value"
|
||||||
>{{this.data.summary.total_cached_tokens}}</span>
|
title={{this.data.summary.total_cached_tokens}}
|
||||||
</div>
|
>{{number this.data.summary.total_cached_tokens}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</:content>
|
||||||
|
</AdminConfigAreaCard>
|
||||||
|
|
||||||
<div class="ai-usage__charts">
|
<AdminConfigAreaCard
|
||||||
|
class="ai-usage__charts"
|
||||||
|
@heading="discourse_ai.usage.tokens_over_time"
|
||||||
|
>
|
||||||
|
<:content>
|
||||||
<div class="ai-usage__chart-container">
|
<div class="ai-usage__chart-container">
|
||||||
<h3 class="ai-usage__chart-title">
|
|
||||||
{{i18n "discourse_ai.usage.tokens_over_time"}}
|
|
||||||
</h3>
|
|
||||||
<Chart
|
<Chart
|
||||||
@chartConfig={{this.chartConfig}}
|
@chartConfig={{this.chartConfig}}
|
||||||
class="ai-usage__chart"
|
class="ai-usage__chart"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</:content>
|
||||||
|
</AdminConfigAreaCard>
|
||||||
|
|
||||||
<div class="ai-usage__breakdowns">
|
<div class="ai-usage__breakdowns">
|
||||||
|
<AdminConfigAreaCard
|
||||||
<div class="ai-usage__users">
|
class="ai-usage__features"
|
||||||
<h3 class="ai-usage__users-title">
|
@heading="discourse_ai.usage.features_breakdown"
|
||||||
{{i18n "discourse_ai.usage.users_breakdown"}}
|
|
||||||
</h3>
|
|
||||||
<table class="ai-usage__users-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{{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.data.users 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"}}
|
<:content>
|
||||||
{{user.username}}
|
{{#unless this.data.features.length}}
|
||||||
</LinkTo>
|
<AdminConfigAreaEmptyList
|
||||||
</div></td>
|
@emptyLabel="discourse_ai.usage.no_features"
|
||||||
<td
|
/>
|
||||||
class="ai-usage__users-cell"
|
{{/unless}}
|
||||||
>{{user.usage_count}}</td>
|
|
||||||
<td
|
|
||||||
class="ai-usage__users-cell"
|
|
||||||
>{{user.total_tokens}}</td>
|
|
||||||
</tr>
|
|
||||||
{{/each}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ai-usage__features">
|
{{#if this.data.features.length}}
|
||||||
<h3 class="ai-usage__features-title">
|
|
||||||
{{i18n "discourse_ai.usage.features_breakdown"}}
|
|
||||||
</h3>
|
|
||||||
<table class="ai-usage__features-table">
|
<table class="ai-usage__features-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -438,20 +437,32 @@ export default class AiUsage extends Component {
|
||||||
>{{feature.feature_name}}</td>
|
>{{feature.feature_name}}</td>
|
||||||
<td
|
<td
|
||||||
class="ai-usage__features-cell"
|
class="ai-usage__features-cell"
|
||||||
>{{feature.usage_count}}</td>
|
title={{feature.usage_count}}
|
||||||
|
>{{number feature.usage_count}}</td>
|
||||||
<td
|
<td
|
||||||
class="ai-usage__features-cell"
|
class="ai-usage__features-cell"
|
||||||
>{{feature.total_tokens}}</td>
|
title={{feature.total_tokens}}
|
||||||
|
>{{number feature.total_tokens}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
{{/if}}
|
||||||
|
</:content>
|
||||||
|
</AdminConfigAreaCard>
|
||||||
|
|
||||||
<div class="ai-usage__models">
|
<AdminConfigAreaCard
|
||||||
<h3 class="ai-usage__models-title">
|
class="ai-usage__models"
|
||||||
{{i18n "discourse_ai.usage.models_breakdown"}}
|
@heading="discourse_ai.usage.models_breakdown"
|
||||||
</h3>
|
>
|
||||||
|
<: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">
|
<table class="ai-usage__models-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -466,18 +477,117 @@ export default class AiUsage extends Component {
|
||||||
<td class="ai-usage__models-cell">{{model.llm}}</td>
|
<td class="ai-usage__models-cell">{{model.llm}}</td>
|
||||||
<td
|
<td
|
||||||
class="ai-usage__models-cell"
|
class="ai-usage__models-cell"
|
||||||
>{{model.usage_count}}</td>
|
title={{model.usage_count}}
|
||||||
|
>{{number model.usage_count}}</td>
|
||||||
<td
|
<td
|
||||||
class="ai-usage__models-cell"
|
class="ai-usage__models-cell"
|
||||||
>{{model.total_tokens}}</td>
|
title={{model.total_tokens}}
|
||||||
|
>{{number model.total_tokens}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
{{/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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -60,9 +60,6 @@
|
||||||
|
|
||||||
&__summary {
|
&__summary {
|
||||||
margin: 2em 0;
|
margin: 2em 0;
|
||||||
padding: 1.5em;
|
|
||||||
background: var(--primary-very-low);
|
|
||||||
border-radius: 0.5em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__summary-title {
|
&__summary-title {
|
||||||
|
@ -81,7 +78,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
background: var(--secondary);
|
background: var(--primary-very-low);
|
||||||
border-radius: 0.25em;
|
border-radius: 0.25em;
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
|
@ -120,22 +117,38 @@
|
||||||
margin-top: 2em;
|
margin-top: 2em;
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__features,
|
&__users {
|
||||||
&__users,
|
grid-column: span 2;
|
||||||
&__models {
|
|
||||||
background: var(--primary-very-low);
|
.admin-config-area-card__content {
|
||||||
padding: 1em;
|
display: flex;
|
||||||
border-radius: 0.5em;
|
|
||||||
|
.ai-usage__users-table {
|
||||||
|
&:first-child {
|
||||||
|
margin-right: 2em;
|
||||||
|
|
||||||
|
&.-double-width {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__features-title,
|
&.-double-width {
|
||||||
&__users-title,
|
.ai-usage__users-username {
|
||||||
&__models-title {
|
width: auto;
|
||||||
margin-bottom: 1em;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-usage__users-username {
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__features-table,
|
&__features-table,
|
||||||
|
|
|
@ -146,7 +146,18 @@ en:
|
||||||
total_requests: "Total requests"
|
total_requests: "Total requests"
|
||||||
request_tokens: "Request tokens"
|
request_tokens: "Request tokens"
|
||||||
response_tokens: "Response tokens"
|
response_tokens: "Response tokens"
|
||||||
|
net_request_tokens: "Net request tokens"
|
||||||
cached_tokens: "Cached tokens"
|
cached_tokens: "Cached tokens"
|
||||||
|
cached_request_tokens: "Cached request tokens"
|
||||||
|
no_users: "No user usage data found"
|
||||||
|
no_models: "No model usage data found"
|
||||||
|
no_features: "No feature usage data found"
|
||||||
|
subheader_description: "Tokens are the basic units that LLMs use to understand and generate text, usage data may affect costs."
|
||||||
|
periods:
|
||||||
|
last_day: "Last 24 hours"
|
||||||
|
last_week: "Last week"
|
||||||
|
last_month: "Last month"
|
||||||
|
custom: "Custom..."
|
||||||
|
|
||||||
ai_persona:
|
ai_persona:
|
||||||
tool_strategies:
|
tool_strategies:
|
||||||
|
|
|
@ -79,6 +79,7 @@ Discourse::Application.routes.draw do
|
||||||
to: "discourse_ai/admin/rag_document_fragments#indexing_status_check"
|
to: "discourse_ai/admin/rag_document_fragments#indexing_status_check"
|
||||||
|
|
||||||
get "/ai-usage", to: "discourse_ai/admin/ai_usage#show"
|
get "/ai-usage", to: "discourse_ai/admin/ai_usage#show"
|
||||||
|
get "/ai-usage-report", to: "discourse_ai/admin/ai_usage#report"
|
||||||
|
|
||||||
resources :ai_llms,
|
resources :ai_llms,
|
||||||
only: %i[index create show update destroy],
|
only: %i[index create show update destroy],
|
||||||
|
|
|
@ -14,33 +14,33 @@ module DiscourseAi
|
||||||
end
|
end
|
||||||
|
|
||||||
def total_tokens
|
def total_tokens
|
||||||
stats.total_tokens
|
stats.total_tokens || 0
|
||||||
end
|
end
|
||||||
|
|
||||||
def total_cached_tokens
|
def total_cached_tokens
|
||||||
stats.total_cached_tokens
|
stats.total_cached_tokens || 0
|
||||||
end
|
end
|
||||||
|
|
||||||
def total_request_tokens
|
def total_request_tokens
|
||||||
stats.total_request_tokens
|
stats.total_request_tokens || 0
|
||||||
end
|
end
|
||||||
|
|
||||||
def total_response_tokens
|
def total_response_tokens
|
||||||
stats.total_response_tokens
|
stats.total_response_tokens || 0
|
||||||
end
|
end
|
||||||
|
|
||||||
def total_requests
|
def total_requests
|
||||||
stats.total_requests
|
stats.total_requests || 0
|
||||||
end
|
end
|
||||||
|
|
||||||
def stats
|
def stats
|
||||||
@stats ||=
|
@stats ||=
|
||||||
base_query.select(
|
base_query.select(
|
||||||
"COUNT(*) as total_requests",
|
"COUNT(*) as total_requests",
|
||||||
"SUM(request_tokens + response_tokens) as total_tokens",
|
"SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens",
|
||||||
"SUM(COALESCE(cached_tokens,0)) as total_cached_tokens",
|
"SUM(COALESCE(cached_tokens,0)) as total_cached_tokens",
|
||||||
"SUM(request_tokens) as total_request_tokens",
|
"SUM(COALESCE(request_tokens,0)) as total_request_tokens",
|
||||||
"SUM(response_tokens) as total_response_tokens",
|
"SUM(COALESCE(response_tokens,0)) as total_response_tokens",
|
||||||
)[
|
)[
|
||||||
0
|
0
|
||||||
]
|
]
|
||||||
|
@ -66,10 +66,10 @@ module DiscourseAi
|
||||||
.order("DATE_TRUNC('#{period}', created_at)")
|
.order("DATE_TRUNC('#{period}', created_at)")
|
||||||
.select(
|
.select(
|
||||||
"DATE_TRUNC('#{period}', created_at) as period",
|
"DATE_TRUNC('#{period}', created_at) as period",
|
||||||
"SUM(request_tokens + response_tokens) as total_tokens",
|
"SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens",
|
||||||
"SUM(COALESCE(cached_tokens,0)) as total_cached_tokens",
|
"SUM(COALESCE(cached_tokens,0)) as total_cached_tokens",
|
||||||
"SUM(request_tokens) as total_request_tokens",
|
"SUM(COALESCE(request_tokens,0)) as total_request_tokens",
|
||||||
"SUM(response_tokens) as total_response_tokens",
|
"SUM(COALESCE(response_tokens,0)) as total_response_tokens",
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -83,10 +83,10 @@ module DiscourseAi
|
||||||
"users.username",
|
"users.username",
|
||||||
"users.uploaded_avatar_id",
|
"users.uploaded_avatar_id",
|
||||||
"COUNT(*) as usage_count",
|
"COUNT(*) as usage_count",
|
||||||
"SUM(request_tokens + response_tokens) as total_tokens",
|
"SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens",
|
||||||
"SUM(COALESCE(cached_tokens,0)) as total_cached_tokens",
|
"SUM(COALESCE(cached_tokens,0)) as total_cached_tokens",
|
||||||
"SUM(request_tokens) as total_request_tokens",
|
"SUM(COALESCE(request_tokens,0)) as total_request_tokens",
|
||||||
"SUM(response_tokens) as total_response_tokens",
|
"SUM(COALESCE(response_tokens,0)) as total_response_tokens",
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -97,10 +97,10 @@ module DiscourseAi
|
||||||
.select(
|
.select(
|
||||||
"case when coalesce(feature_name, '') = '' then '#{UNKNOWN_FEATURE}' else feature_name end as feature_name",
|
"case when coalesce(feature_name, '') = '' then '#{UNKNOWN_FEATURE}' else feature_name end as feature_name",
|
||||||
"COUNT(*) as usage_count",
|
"COUNT(*) as usage_count",
|
||||||
"SUM(request_tokens + response_tokens) as total_tokens",
|
"SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens",
|
||||||
"SUM(COALESCE(cached_tokens,0)) as total_cached_tokens",
|
"SUM(COALESCE(cached_tokens,0)) as total_cached_tokens",
|
||||||
"SUM(request_tokens) as total_request_tokens",
|
"SUM(COALESCE(request_tokens,0)) as total_request_tokens",
|
||||||
"SUM(response_tokens) as total_response_tokens",
|
"SUM(COALESCE(response_tokens,0)) as total_response_tokens",
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -111,10 +111,10 @@ module DiscourseAi
|
||||||
.select(
|
.select(
|
||||||
"language_model as llm",
|
"language_model as llm",
|
||||||
"COUNT(*) as usage_count",
|
"COUNT(*) as usage_count",
|
||||||
"SUM(request_tokens + response_tokens) as total_tokens",
|
"SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens",
|
||||||
"SUM(COALESCE(cached_tokens,0)) as total_cached_tokens",
|
"SUM(COALESCE(cached_tokens,0)) as total_cached_tokens",
|
||||||
"SUM(request_tokens) as total_request_tokens",
|
"SUM(COALESCE(request_tokens,0)) as total_request_tokens",
|
||||||
"SUM(response_tokens) as total_response_tokens",
|
"SUM(COALESCE(response_tokens,0)) as total_response_tokens",
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ require "rails_helper"
|
||||||
RSpec.describe DiscourseAi::Admin::AiUsageController do
|
RSpec.describe DiscourseAi::Admin::AiUsageController do
|
||||||
fab!(:admin)
|
fab!(:admin)
|
||||||
fab!(:user)
|
fab!(:user)
|
||||||
let(:usage_path) { "/admin/plugins/discourse-ai/ai-usage.json" }
|
let(:usage_report_path) { "/admin/plugins/discourse-ai/ai-usage-report.json" }
|
||||||
|
|
||||||
before { SiteSetting.discourse_ai_enabled = true }
|
before { SiteSetting.discourse_ai_enabled = true }
|
||||||
|
|
||||||
|
@ -36,7 +36,7 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "returns correct data structure" do
|
it "returns correct data structure" do
|
||||||
get usage_path
|
get usage_report_path
|
||||||
|
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
|
|
||||||
|
@ -48,14 +48,18 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "respects date filters" do
|
it "respects date filters" do
|
||||||
get usage_path, params: { start_date: 3.days.ago.to_date, end_date: 1.day.ago.to_date }
|
get usage_report_path,
|
||||||
|
params: {
|
||||||
|
start_date: 3.days.ago.to_date,
|
||||||
|
end_date: 1.day.ago.to_date,
|
||||||
|
}
|
||||||
|
|
||||||
json = response.parsed_body
|
json = response.parsed_body
|
||||||
expect(json["summary"]["total_tokens"]).to eq(450) # sum of all tokens
|
expect(json["summary"]["total_tokens"]).to eq(450) # sum of all tokens
|
||||||
end
|
end
|
||||||
|
|
||||||
it "filters by feature" do
|
it "filters by feature" do
|
||||||
get usage_path, params: { feature: "summarize" }
|
get usage_report_path, params: { feature: "summarize" }
|
||||||
|
|
||||||
json = response.parsed_body
|
json = response.parsed_body
|
||||||
|
|
||||||
|
@ -66,7 +70,7 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "filters by model" do
|
it "filters by model" do
|
||||||
get usage_path, params: { model: "gpt-3.5" }
|
get usage_report_path, params: { model: "gpt-3.5" }
|
||||||
|
|
||||||
json = response.parsed_body
|
json = response.parsed_body
|
||||||
models = json["models"]
|
models = json["models"]
|
||||||
|
@ -76,10 +80,10 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "handles different period groupings" do
|
it "handles different period groupings" do
|
||||||
get usage_path, params: { period: "hour" }
|
get usage_report_path, params: { period: "hour" }
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
|
|
||||||
get usage_path, params: { period: "month" }
|
get usage_report_path, params: { period: "month" }
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -102,7 +106,11 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "returns hourly data when period is day" do
|
it "returns hourly data when period is day" do
|
||||||
get usage_path, params: { start_date: 1.day.ago.to_date, end_date: Time.current.to_date }
|
get usage_report_path,
|
||||||
|
params: {
|
||||||
|
start_date: 1.day.ago.to_date,
|
||||||
|
end_date: Time.current.to_date,
|
||||||
|
}
|
||||||
|
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
json = response.parsed_body
|
json = response.parsed_body
|
||||||
|
@ -121,7 +129,7 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do
|
||||||
before { sign_in(user) }
|
before { sign_in(user) }
|
||||||
|
|
||||||
it "blocks access" do
|
it "blocks access" do
|
||||||
get usage_path
|
get usage_report_path
|
||||||
expect(response.status).to eq(404)
|
expect(response.status).to eq(404)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -133,7 +141,7 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "returns error" do
|
it "returns error" do
|
||||||
get usage_path
|
get usage_report_path
|
||||||
expect(response.status).to eq(404)
|
expect(response.status).to eq(404)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue