pl-fe: Support local post translations using the Translator API

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2025-12-11 22:36:04 +01:00
parent 7ec902004e
commit e19a8131bf
6 changed files with 224 additions and 22 deletions

View File

@ -9,6 +9,7 @@ import Text from 'pl-fe/components/ui/text';
import Emojify from 'pl-fe/features/emoji/emojify';
import QuotedStatus from 'pl-fe/features/status/containers/quoted-status-container';
import { usePlFeConfig } from 'pl-fe/hooks/use-pl-fe-config';
import { useLocalStatusTranslation } from 'pl-fe/queries/statuses/use-local-status-translation';
import { useStatusTranslation } from 'pl-fe/queries/statuses/use-status-translation';
import { useSettings } from 'pl-fe/stores/settings';
import { useStatusMeta, useStatusMetaActions } from 'pl-fe/stores/status-meta';
@ -96,6 +97,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
const { collapseStatuses, expandStatuses } = useStatusMetaActions();
const statusMeta = useStatusMeta(status.id);
const { data: translation } = useStatusTranslation(status.id, statusMeta.targetLanguage);
const { data: localTranslation } = useLocalStatusTranslation(status.id, statusMeta.localTargetLanguage);
const withSpoiler = status.spoiler_text?.length > 0;
const expanded = !withSpoiler || statusMeta.expanded || false;
@ -134,16 +136,18 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
}, [expanded]);
const content = useMemo(
(): string => translation
? translation.content
: (status.content_map && statusMeta.currentLanguage)
? (status.content_map[statusMeta.currentLanguage] || status.content)
: status.content,
[status.content, translation, statusMeta.currentLanguage],
(): string => localTranslation
? localTranslation.content
: translation
? translation.content
: (status.content_map && statusMeta.currentLanguage)
? (status.content_map[statusMeta.currentLanguage] || status.content)
: status.content,
[status.content, localTranslation, translation, statusMeta.currentLanguage],
);
const { content: parsedContent, hashtags } = useMemo(() => {
if (renderMfm && !translation && status.content_type === 'text/x.misskeymarkdown' && status.text) {
if (renderMfm && !localTranslation && !translation && status.content_type === 'text/x.misskeymarkdown' && status.text) {
return {
content: <ParsedMfm text={status.text} emojis={status.emojis} mentions={status.mentions} />,
hashtags: [],

View File

@ -8,7 +8,9 @@ import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useFeatures } from 'pl-fe/hooks/use-features';
import { useInstance } from 'pl-fe/hooks/use-instance';
import { useTranslationLanguages } from 'pl-fe/queries/instance/use-translation-languages';
import { useLocalStatusTranslation } from 'pl-fe/queries/statuses/use-local-status-translation';
import { useStatusTranslation } from 'pl-fe/queries/statuses/use-status-translation';
import { useLanguageModelAvailability, useLanguageModelAvailabilityActions } from 'pl-fe/stores/language-model-availability';
import { useSettings } from 'pl-fe/stores/settings';
import { useStatusMeta, useStatusMetaActions } from 'pl-fe/stores/status-meta';
@ -37,6 +39,22 @@ const canRemoteTranslate = (status: ITranslateButton['status'], instance: Instan
return true;
};
type Availability = Awaited<ReturnType<typeof window.Translator.availability>>;
const localTranslationAvailability = async (status: ITranslateButton['status'], locale: string): Promise<Availability | false> => {
if (!('Translator' in window)) return 'unavailable';
if (status.content.length < 0) return false;
// TODO: support language detection
if (status.language === null || locale === status.language || status.content_map?.[locale]) return false;
return window.Translator.availability({
sourceLanguage: status.language,
targetLanguage: locale,
});
};
interface ITranslateButton {
status: Pick<Status, 'id' | 'account' | 'content' | 'content_map' | 'language' | 'visibility'>;
}
@ -52,20 +70,43 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
const me = useAppSelector((state) => state.me);
const { data: translationLanguages = {} } = useTranslationLanguages();
const { fetchTranslation, hideTranslation } = useStatusMetaActions();
const { targetLanguage } = useStatusMeta(status.id);
const { fetchLocalTranslation, hideLocalTranslation } = useStatusMetaActions();
const languageModelAvailability = useLanguageModelAvailability(status.language!, intl.locale);
const { setLanguageModelAvailability } = useLanguageModelAvailabilityActions();
const { targetLanguage, localTargetLanguage } = useStatusMeta(status.id);
const translationQuery = useStatusTranslation(status.id, targetLanguage);
const remoteTranslationQuery = useStatusTranslation(status.id, targetLanguage);
const localTranslationQuery = useLocalStatusTranslation(status.id, localTargetLanguage);
const translationQuery = localTargetLanguage ? localTranslationQuery : remoteTranslationQuery;
const [localTranslate, setLocalTranslate] = React.useState<Exclude<Availability, 'unavailable'> | false>();
const remoteTranslate = features.translations && canRemoteTranslate(status, instance, translationLanguages, intl.locale, !!me);
useEffect(() => {
localTranslationAvailability(status, intl.locale).then((availability) => {
setLocalTranslate(availability === 'unavailable' ? false : availability);
if (availability) setLanguageModelAvailability(status.language!, intl.locale, availability);
}).catch(() => {});
}, [status.language, intl.locale]);
const handleTranslate: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.stopPropagation();
if (targetLanguage) {
hideTranslation(status.id);
} else {
fetchTranslation(status.id, intl.locale);
if (localTargetLanguage) return hideLocalTranslation(status.id);
if (remoteTranslate) {
if (targetLanguage) {
hideTranslation(status.id);
} else {
fetchTranslation(status.id, intl.locale);
}
return;
}
fetchLocalTranslation(status.id, intl.locale);
};
useEffect(() => {
@ -74,19 +115,49 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
}
}, []);
if (!remoteTranslate || translationQuery.data === false) return null;
if (!remoteTranslate && !localTranslate || translationQuery.data === false) return null;
const translationLabel = () => {
if (translationQuery.data) {
return (
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
);
}
if (translationQuery.isLoading) {
if (languageModelAvailability === 'downloading') {
return (
<FormattedMessage id='status.translating.downloading' defaultMessage='Downloading model…' />
);
}
return (
<FormattedMessage id='status.translating' defaultMessage='Translating…' />
);
}
if (remoteTranslate) {
return (
<FormattedMessage id='status.translate' defaultMessage='Translate' />
);
}
if (localTranslate && languageModelAvailability !== 'downloadable') {
return (
<FormattedMessage id='status.translate.local' defaultMessage='Translate locally' />
);
}
return (
<FormattedMessage id='status.translate.download' defaultMessage='Download model and translate locally' />
);
};
const button = (
<button className='flex w-fit items-center gap-1 text-primary-600 hover:underline dark:text-gray-600' onClick={handleTranslate}>
<Icon src={require('@phosphor-icons/core/regular/translate.svg')} className='size-4' />
<span>
{translationQuery.data ? (
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
) : translationQuery.isLoading ? (
<FormattedMessage id='status.translating' defaultMessage='Translating…' />
) : (
<FormattedMessage id='status.translate' defaultMessage='Translate' />
)}
{translationLabel()}
</span>
{translationQuery.isLoading && (
<Icon src={require('@phosphor-icons/core/regular/circle-notch.svg')} className='size-4 animate-spin' />
@ -108,7 +179,11 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
defaultMessage='Translated from {lang} {provider}'
values={{
lang: languageName,
provider: provider ? <FormattedMessage id='status.translated_from_with.provider' defaultMessage='with {provider}' values={{ provider }} /> : undefined,
provider: localTargetLanguage
? <FormattedMessage id='status.translated_from_with.provider.local' defaultMessage='using local model' />
: provider
? <FormattedMessage id='status.translated_from_with.provider' defaultMessage='with {provider}' values={{ provider }} />
: undefined,
}}
/>
</Text>

View File

@ -1771,9 +1771,13 @@
"status.title": "Post details",
"status.title_direct": "Direct message",
"status.translate": "Translate",
"status.translate.download": "Download model and translate locally",
"status.translate.local": "Translate locally",
"status.translated_from_with": "Translated from {lang} {provider}",
"status.translated_from_with.provider": "with {provider}",
"status.translated_from_with.provider.local": "using local model",
"status.translating": "Translating…",
"status.translating.downloading": "Downloading model…",
"status.unbookmark": "Remove bookmark",
"status.unbookmarked": "Bookmark removed.",
"status.unmute_conversation": "Unmute conversation",

View File

@ -0,0 +1,72 @@
import { useQuery } from '@tanstack/react-query';
import { translationSchema, type Translation } from 'pl-api';
import * as v from 'valibot';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useLanguageModelAvailabilityActions } from 'pl-fe/stores/language-model-availability';
interface CreateMonitor extends EventTarget {}
interface Translator {
destroy: () => void;
measureInputUsage: (input: string, options?: { signal?: AbortSignal }) => Promise<number>;
translate: (input: string, options?: { signal?: AbortSignal }) => Promise<string>;
translateStreaming: (input: string, options?: { signal?: AbortSignal }) => ReadableStream<string>;
}
declare global {
interface Window {
Translator: {
availability: (options: { sourceLanguage: string; targetLanguage: string }) => Promise<'available' | 'downloadable' | 'downloading' | 'unavailable'>;
create: (options: {
sourceLanguage: string;
targetLanguage: string;
monitor?: (monitor: CreateMonitor) => void;
signal?: AbortSignal;
}) => Promise<Translator>;
};
}
}
const useLocalStatusTranslation = (statusId: string, targetLanguage?: string) => {
const status = useAppSelector((state) => state.statuses[statusId]);
const { setLanguageModelAvailability, setLanguageModelDownloadProgress } = useLanguageModelAvailabilityActions();
const sourceLanguage = status?.language;
return useQuery<Translation | false>({
queryKey: ['statuses', 'localTranslations', statusId, targetLanguage],
queryFn: async ({ signal }) => {
if (!window.Translator) return false;
try {
const translator = await window.Translator.create({
sourceLanguage: sourceLanguage!,
targetLanguage: targetLanguage!,
monitor: (createMonitor) => {
setLanguageModelAvailability(sourceLanguage!, targetLanguage!, 'downloading');
createMonitor.addEventListener('progress', ((e: ProgressEvent) => {
setLanguageModelDownloadProgress(sourceLanguage!, targetLanguage!, e);
if (e.loaded === e.total) {
setLanguageModelAvailability(sourceLanguage!, targetLanguage!, 'available');
}
}) as EventListener);
},
signal,
});
return translator.translate(status!.content, { signal }).then((translatedText) => v.parse(translationSchema, {
id: statusId,
content: translatedText,
detected_source_language: sourceLanguage,
}));
} catch (e) {
return false;
}
},
enabled: !!sourceLanguage && !!targetLanguage,
});
};
export { useLocalStatusTranslation };

View File

@ -0,0 +1,34 @@
import { create } from 'zustand';
import { mutative } from 'zustand-mutative';
type State = {
languageModelAvailability: Record<string, Awaited<ReturnType<typeof window.Translator.availability>>>;
languageModelDownloadProgress: Record<string, number>;
actions: {
setLanguageModelAvailability: (sourceLanguage: string, targetLanguage: string, availability: Awaited<ReturnType<typeof window.Translator.availability>>) => void;
setLanguageModelDownloadProgress: (sourceLanguage: string, targetLanguage: string, event: ProgressEvent) => void;
};
}
const useLanguageModelAvailabilityStore = create<State>()(mutative((set) => ({
languageModelAvailability: {},
languageModelDownloadProgress: {},
actions: {
setLanguageModelAvailability: (sourceLanguage, targetLanguage, availability) => set((state: State) => {
state.languageModelAvailability[`${sourceLanguage}-${targetLanguage}`] = availability;
}),
setLanguageModelDownloadProgress: (sourceLanguage, targetLanguage, event) => set((state: State) => {
state.languageModelDownloadProgress[`${sourceLanguage}-${targetLanguage}`] = event.loaded / event.total;
}),
},
}), {
enableAutoFreeze: false,
}));
const useLanguageModelAvailability = (sourceLanguage: string, targetLanguage: string) =>
useLanguageModelAvailabilityStore((state) => state.languageModelAvailability[`${sourceLanguage}-${targetLanguage}`]);
const useLanguageModelDownloadProgress = (sourceLanguage: string, targetLanguage: string) =>
useLanguageModelAvailabilityStore((state) => state.languageModelDownloadProgress[`${sourceLanguage}-${targetLanguage}`]);
const useLanguageModelAvailabilityActions = () => useLanguageModelAvailabilityStore((state) => state.actions);
export { useLanguageModelAvailability, useLanguageModelDownloadProgress, useLanguageModelAvailabilityActions };

View File

@ -7,6 +7,7 @@ type State = {
mediaVisible?: boolean;
currentLanguage?: string;
targetLanguage?: string;
localTargetLanguage?: string;
showPollResults?: boolean;
}>;
actions: {
@ -17,6 +18,8 @@ type State = {
toggleStatusesMediaHidden: (statusIds: Array<string>) => void;
fetchTranslation: (statusId: string, targetLanguage: string) => void;
hideTranslation: (statusId: string) => void;
fetchLocalTranslation: (statusId: string, targetLanguage: string) => void;
hideLocalTranslation: (statusId: string) => void;
setStatusLanguage: (statusId: string, language: string) => void;
toggleShowPollResults: (statusId: string) => void;
};
@ -70,6 +73,16 @@ const useStatusMetaStore = create<State>()(mutative((set) => ({
state.statuses[statusId].targetLanguage = undefined;
}),
fetchLocalTranslation: (statusId, targetLanguage) => set((state: State) => {
if (!state.statuses[statusId]) state.statuses[statusId] = {};
state.statuses[statusId].localTargetLanguage = targetLanguage;
}),
hideLocalTranslation: (statusId) => set((state: State) => {
if (!state.statuses[statusId]) state.statuses[statusId] = {};
state.statuses[statusId].localTargetLanguage = undefined;
}),
setStatusLanguage: (statusId, language) => set((state: State) => {
if (!state.statuses[statusId]) state.statuses[statusId] = {};