pl-fe: Support local post translations using the Translator API
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
@ -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: [],
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 };
|
||||
34
packages/pl-fe/src/stores/language-model-availability.ts
Normal file
34
packages/pl-fe/src/stores/language-model-availability.ts
Normal 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 };
|
||||
@ -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] = {};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user