2
0
Fork 0
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:
Keegan George 2025-08-15 12:19:35 -07:00 committed by GitHub
parent c002d10b11
commit 5c58a550d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 444 additions and 0 deletions

View file

@ -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");
}
}

View file

@ -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>
);

View file

@ -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

View file

@ -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(

View file

@ -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>
}

View file

@ -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",
},
]);
});
},

View file

@ -0,0 +1,4 @@
.ai-translations {
--chart-progress-color: rgb(153, 102, 255, 0.8);
--chart-remaining-color: rgb(153, 102, 255, 0.4);
}

View file

@ -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"

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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