mirror of
https://github.com/discourse/discourse.git
synced 2025-10-04 17:32:34 +08:00
FEATURE: Translation progress admin UI (#34239)
## 🔍 Overview This update adds a tab to the Discourse AI admin panel where admins can see automatic translation progress for selected locales on the forum as well as manage the relevant translation settings. ## 📷 Screenshots <img width="930" height="806" alt="01-in-use" src="https://github.com/user-attachments/assets/b470f687-fd4b-49c8-93d8-226e4234a4c7" /> --- <img width="924" height="397" alt="02-disabled" src="https://github.com/user-attachments/assets/92a847ca-12a2-4bba-b022-f9d47174bef6" />
This commit is contained in:
parent
c002d10b11
commit
5c58a550d6
12 changed files with 444 additions and 0 deletions
|
@ -0,0 +1,11 @@
|
|||
import { service } from "@ember/service";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default class DiscourseAiTranslationsRoute extends DiscourseRoute {
|
||||
@service store;
|
||||
|
||||
model() {
|
||||
return ajax("/admin/plugins/discourse-ai/ai-translations.json");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import RouteTemplate from "ember-route-template";
|
||||
import AiTranslations from "../../../../discourse/components/ai-translations";
|
||||
|
||||
export default RouteTemplate(
|
||||
<template><AiTranslations @model={{@controller.model}} /></template>
|
||||
);
|
|
@ -0,0 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Admin
|
||||
class AiTranslationsController < ::Admin::AdminController
|
||||
requires_plugin "discourse-ai"
|
||||
|
||||
def show
|
||||
supported_locales =
|
||||
SiteSetting.content_localization_supported_locales.presence&.split("|") || []
|
||||
|
||||
result =
|
||||
supported_locales.map do |locale|
|
||||
completion_data =
|
||||
DiscourseAi::Translation::PostCandidates.get_completion_per_locale(locale)
|
||||
|
||||
total = completion_data[:total].to_f
|
||||
done = completion_data[:done].to_f
|
||||
remaining = total - done
|
||||
|
||||
completion_percentage = safe_percentage(done, total)
|
||||
remaining_percentage = safe_percentage(remaining, total)
|
||||
|
||||
{
|
||||
locale: locale,
|
||||
completion_percentage: completion_percentage,
|
||||
remaining_percentage: remaining_percentage,
|
||||
}
|
||||
end
|
||||
|
||||
render json: {
|
||||
translation_progress: result,
|
||||
translation_id: DiscourseAi::Configuration::Module::TRANSLATION_ID,
|
||||
enabled: DiscourseAi::Translation.backfill_enabled?,
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def safe_percentage(part, total)
|
||||
return 0.0 if total <= 0
|
||||
(part / total) * 100
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -19,6 +19,7 @@ export default {
|
|||
this.route("edit", { path: "/:id/edit" });
|
||||
});
|
||||
this.route("discourse-ai-spam", { path: "ai-spam" });
|
||||
this.route("discourse-ai-translations", { path: "ai-translations" });
|
||||
this.route("discourse-ai-usage", { path: "ai-usage" });
|
||||
|
||||
this.route(
|
||||
|
|
|
@ -0,0 +1,144 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { service } from "@ember/service";
|
||||
import DPageSubheader from "discourse/components/d-page-subheader";
|
||||
import { i18n } from "discourse-i18n";
|
||||
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";
|
||||
|
||||
export default class AiTranslations extends Component {
|
||||
@service siteSettings;
|
||||
@service store;
|
||||
|
||||
@tracked data = this.args.model?.translation_progress;
|
||||
|
||||
getLanguageName(locale) {
|
||||
try {
|
||||
const availableLocales = this.siteSettings.available_locales;
|
||||
const localeObj = availableLocales.find((l) => l.value === locale);
|
||||
return localeObj ? localeObj.name : locale;
|
||||
} catch {
|
||||
return locale; // Fallback to the locale code if not found
|
||||
}
|
||||
}
|
||||
|
||||
get chartConfig() {
|
||||
if (!this.data || !this.data.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const chartEl = document.querySelector(".ai-translations");
|
||||
const computedStyle = getComputedStyle(chartEl);
|
||||
const colors = {
|
||||
progress: computedStyle.getPropertyValue("--chart-progress-color").trim(),
|
||||
remaining: computedStyle
|
||||
.getPropertyValue("--chart-remaining-color")
|
||||
.trim(),
|
||||
};
|
||||
|
||||
const processedData = this.data.map((item) => {
|
||||
return {
|
||||
locale: this.getLanguageName(item.locale),
|
||||
completionPercentage: item.completion_percentage,
|
||||
remainingPercentage: item.remaining_percentage,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: processedData.map((item) => item.locale),
|
||||
datasets: [
|
||||
{
|
||||
label: i18n("discourse_ai.translations.progress_chart.completed"),
|
||||
data: processedData.map((item) => item.completionPercentage),
|
||||
backgroundColor: colors.progress,
|
||||
},
|
||||
{
|
||||
label: i18n("discourse_ai.translations.progress_chart.remaining"),
|
||||
data: processedData.map((item) => item.remainingPercentage),
|
||||
backgroundColor: colors.remaining,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
indexAxis: "y",
|
||||
responsive: true,
|
||||
barThickness: 40,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
ticks: {
|
||||
callback: (value) => `${value}%`,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) =>
|
||||
`${context.dataset.label}: ${context.raw.toFixed(1)}%`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="ai-translations admin-detail">
|
||||
<DPageSubheader
|
||||
@titleLabel={{i18n "discourse_ai.translations.title"}}
|
||||
@descriptionLabel={{i18n "discourse_ai.translations.description"}}
|
||||
@learnMoreUrl="https://meta.discourse.org/t/-/370969"
|
||||
>
|
||||
<:actions as |actions|>
|
||||
{{#if @model.enabled}}
|
||||
<actions.Default
|
||||
@label="discourse_ai.translations.admin_actions.translation_settings"
|
||||
@route="adminPlugins.show.discourse-ai-features.edit"
|
||||
@routeModels={{@model.translation_id}}
|
||||
class="ai-translation-settings-button"
|
||||
/>
|
||||
<actions.Default
|
||||
@label="discourse_ai.translations.admin_actions.localization_settings"
|
||||
@route="adminConfig.localization.settings"
|
||||
class="ai-localization-settings-button"
|
||||
/>
|
||||
{{/if}}
|
||||
</:actions>
|
||||
</DPageSubheader>
|
||||
|
||||
{{#if @model.enabled}}
|
||||
<AdminConfigAreaCard
|
||||
class="ai-translation__charts"
|
||||
@heading="discourse_ai.translations.progress_chart.title"
|
||||
>
|
||||
<:content>
|
||||
<div class="ai-translation__chart-container">
|
||||
<Chart
|
||||
@chartConfig={{this.chartConfig}}
|
||||
class="ai-translation__chart"
|
||||
/>
|
||||
</div>
|
||||
</:content>
|
||||
</AdminConfigAreaCard>
|
||||
{{else}}
|
||||
<AdminConfigAreaEmptyList
|
||||
@ctaLabel="discourse_ai.translations.admin_actions.disabled_state.configure"
|
||||
@ctaRoute="adminPlugins.show.discourse-ai-features.edit"
|
||||
@ctaRouteModels={{@model.translation_id}}
|
||||
@ctaClass="ai-translations__configure-button"
|
||||
@emptyLabel="discourse_ai.translations.admin_actions.disabled_state.empty_label"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
</template>
|
||||
}
|
|
@ -46,6 +46,11 @@ export default {
|
|||
route: "adminPlugins.show.discourse-ai-spam",
|
||||
description: "discourse_ai.spam.spam_description",
|
||||
},
|
||||
{
|
||||
label: "discourse_ai.translations.title",
|
||||
route: "adminPlugins.show.discourse-ai-translations",
|
||||
description: "discourse_ai.translations.description",
|
||||
},
|
||||
]);
|
||||
});
|
||||
},
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
.ai-translations {
|
||||
--chart-progress-color: rgb(153, 102, 255, 0.8);
|
||||
--chart-remaining-color: rgb(153, 102, 255, 0.4);
|
||||
}
|
|
@ -286,6 +286,21 @@ en:
|
|||
table: "Table"
|
||||
card: "Card"
|
||||
|
||||
translations:
|
||||
title: "Translations"
|
||||
description: "Automatically translate all content on your forum to the user's preferred language"
|
||||
admin_actions:
|
||||
translation_settings: "Translation settings"
|
||||
localization_settings: "Localization settings"
|
||||
disabled_state:
|
||||
configure: "Configure translations"
|
||||
empty_label: "Automatic translations are disabled, click below to configure"
|
||||
progress_chart:
|
||||
title: "Translation progress"
|
||||
completed: "Completed"
|
||||
remaining: "Remaining"
|
||||
|
||||
|
||||
spam:
|
||||
short_title: "Spam"
|
||||
title: "Configure spam handling"
|
||||
|
|
|
@ -115,6 +115,8 @@ Discourse::Application.routes.draw do
|
|||
post "/ai-spam/test", to: "discourse_ai/admin/ai_spam#test"
|
||||
post "/ai-spam/fix-errors", to: "discourse_ai/admin/ai_spam#fix_errors"
|
||||
|
||||
get "/ai-translations", to: "discourse_ai/admin/ai_translations#show"
|
||||
|
||||
resources :ai_llms,
|
||||
only: %i[index new create edit update destroy],
|
||||
path: "ai-llms",
|
||||
|
|
|
@ -19,6 +19,8 @@ register_asset "stylesheets/common/ai-blinking-animation.scss"
|
|||
register_asset "stylesheets/common/ai-user-settings.scss"
|
||||
register_asset "stylesheets/common/ai-features.scss"
|
||||
|
||||
register_asset "stylesheets/modules/translation/common/admin-translations.scss"
|
||||
|
||||
register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss"
|
||||
register_asset "stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss", :desktop
|
||||
register_asset "stylesheets/modules/ai-helper/mobile/ai-helper.scss", :mobile
|
||||
|
|
|
@ -0,0 +1,115 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe DiscourseAi::Admin::AiTranslationsController do
|
||||
fab!(:admin)
|
||||
fab!(:user)
|
||||
|
||||
before do
|
||||
enable_current_plugin
|
||||
assign_fake_provider_to(:ai_default_llm_model)
|
||||
end
|
||||
|
||||
describe "#show" do
|
||||
context "when logged in as admin" do
|
||||
before do
|
||||
sign_in(admin)
|
||||
SiteSetting.discourse_ai_enabled = true
|
||||
SiteSetting.ai_translation_enabled = true
|
||||
SiteSetting.content_localization_supported_locales = "en|fr|es"
|
||||
end
|
||||
|
||||
it "returns translation progress data" do
|
||||
# Mock the translation candidate methods
|
||||
allow(DiscourseAi::Translation::PostCandidates).to receive(
|
||||
:get_completion_per_locale,
|
||||
).and_return({ total: 100, done: 50 })
|
||||
|
||||
get "/admin/plugins/discourse-ai/ai-translations.json"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = response.parsed_body
|
||||
|
||||
expect(json["translation_progress"]).to be_an(Array)
|
||||
expect(json["translation_progress"].length).to eq(3) # en, fr, es
|
||||
expect(json["translation_id"]).to eq(DiscourseAi::Configuration::Module::TRANSLATION_ID)
|
||||
expect(json["enabled"]).to be_in([true, false])
|
||||
|
||||
# Check structure of first locale data
|
||||
locale_data = json["translation_progress"].first
|
||||
expect(locale_data["locale"]).to be_present
|
||||
expect(locale_data["completion_percentage"]).to be_present
|
||||
expect(locale_data["remaining_percentage"]).to be_present
|
||||
end
|
||||
|
||||
it "returns empty array when no locales are supported" do
|
||||
SiteSetting.content_localization_supported_locales = ""
|
||||
|
||||
get "/admin/plugins/discourse-ai/ai-translations.json"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = response.parsed_body
|
||||
|
||||
expect(json["translation_progress"]).to eq([])
|
||||
end
|
||||
|
||||
it "correctly indicates if backfill is enabled" do
|
||||
# Enable backfill
|
||||
SiteSetting.ai_translation_backfill_hourly_rate = 10
|
||||
SiteSetting.ai_translation_backfill_max_age_days = 30
|
||||
|
||||
get "/admin/plugins/discourse-ai/ai-translations.json"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
expect(response.parsed_body["enabled"]).to eq(true)
|
||||
|
||||
# Disable backfill
|
||||
SiteSetting.ai_translation_backfill_hourly_rate = 0
|
||||
|
||||
get "/admin/plugins/discourse-ai/ai-translations.json"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
expect(response.parsed_body["enabled"]).to eq(false)
|
||||
end
|
||||
end
|
||||
|
||||
context "when not logged in as admin" do
|
||||
it "returns 404 for anonymous users" do
|
||||
get "/admin/plugins/discourse-ai/ai-translations.json"
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
|
||||
it "returns 404 for regular users" do
|
||||
sign_in(user)
|
||||
get "/admin/plugins/discourse-ai/ai-translations.json"
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
|
||||
context "when plugin is disabled" do
|
||||
before do
|
||||
sign_in(admin)
|
||||
SiteSetting.discourse_ai_enabled = false
|
||||
end
|
||||
|
||||
it "returns 404" do
|
||||
get "/admin/plugins/discourse-ai/ai-translations.json"
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
|
||||
context "when AI translation is disabled" do
|
||||
before do
|
||||
sign_in(admin)
|
||||
SiteSetting.discourse_ai_enabled = true
|
||||
SiteSetting.ai_translation_enabled = false
|
||||
end
|
||||
|
||||
it "still returns data but with enabled flag set to false" do
|
||||
get "/admin/plugins/discourse-ai/ai-translations.json"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
expect(response.parsed_body["enabled"]).to eq(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,93 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe "Admin AI translations", type: :system do
|
||||
fab!(:admin)
|
||||
let(:page_header) { PageObjects::Components::DPageHeader.new }
|
||||
|
||||
before do
|
||||
enable_current_plugin
|
||||
assign_fake_provider_to(:ai_default_llm_model)
|
||||
|
||||
allow(DiscourseAi::Translation).to receive(:has_llm_model?).and_return(true)
|
||||
allow(DiscourseAi::Translation::PostCandidates).to receive(
|
||||
:get_completion_per_locale,
|
||||
).and_return({ total: 100, done: 50 })
|
||||
|
||||
sign_in(admin)
|
||||
end
|
||||
|
||||
describe "when translations are enabled" do
|
||||
before do
|
||||
SiteSetting.discourse_ai_enabled = true
|
||||
SiteSetting.ai_translation_enabled = true
|
||||
SiteSetting.content_localization_supported_locales = "en|fr|es"
|
||||
SiteSetting.ai_translation_backfill_hourly_rate = 10
|
||||
SiteSetting.ai_translation_backfill_max_age_days = 30
|
||||
|
||||
visit "/admin/plugins/discourse-ai/ai-translations"
|
||||
end
|
||||
|
||||
it "displays the translations page with chart" do
|
||||
expect(page).to have_content(I18n.t("js.discourse_ai.translations.title"))
|
||||
expect(page).to have_content(I18n.t("js.discourse_ai.translations.description"))
|
||||
|
||||
# Verify the action buttons are present with their CSS classes
|
||||
expect(page).to have_css(".ai-translation-settings-button")
|
||||
expect(page).to have_css(".ai-localization-settings-button")
|
||||
|
||||
# Verify chart container is present
|
||||
expect(page).to have_css(".ai-translation__charts")
|
||||
expect(page).to have_content(I18n.t("js.discourse_ai.translations.progress_chart.title"))
|
||||
expect(page).to have_css(".ai-translation__chart")
|
||||
end
|
||||
|
||||
it "navigates to translation settings when clicking the settings button" do
|
||||
translation_id = DiscourseAi::Configuration::Module::TRANSLATION_ID
|
||||
find(".ai-translation-settings-button").click
|
||||
|
||||
# Verify we navigated to the correct route
|
||||
expect(page).to have_current_path(
|
||||
"/admin/plugins/discourse-ai/ai-features/#{translation_id}/edit",
|
||||
)
|
||||
end
|
||||
|
||||
it "navigates to localization settings when clicking the localization button" do
|
||||
find(".ai-localization-settings-button").click
|
||||
|
||||
# Verify we navigated to the correct route
|
||||
expect(page).to have_current_path("/admin/config/localization")
|
||||
end
|
||||
end
|
||||
|
||||
describe "when translations are disabled" do
|
||||
before do
|
||||
SiteSetting.discourse_ai_enabled = true
|
||||
SiteSetting.ai_translation_enabled = true
|
||||
SiteSetting.content_localization_supported_locales = "en|fr|es"
|
||||
# Disable backfill
|
||||
SiteSetting.ai_translation_backfill_hourly_rate = 0
|
||||
|
||||
visit "/admin/plugins/discourse-ai/ai-translations"
|
||||
end
|
||||
|
||||
it "displays the disabled state message and configure button" do
|
||||
expect(page).to have_content(
|
||||
I18n.t("js.discourse_ai.translations.admin_actions.disabled_state.empty_label"),
|
||||
)
|
||||
expect(page).to have_css(".ai-translations__configure-button")
|
||||
|
||||
# Verify chart is NOT shown
|
||||
expect(page).to have_no_css(".ai-translation__chart")
|
||||
end
|
||||
|
||||
it "navigates to translation settings when clicking the configure button" do
|
||||
translation_id = DiscourseAi::Configuration::Module::TRANSLATION_ID
|
||||
find(".ai-translations__configure-button").click
|
||||
|
||||
# Verify we navigated to the correct route
|
||||
expect(page).to have_current_path(
|
||||
"/admin/plugins/discourse-ai/ai-features/#{translation_id}/edit",
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue