= (
setCurrentEditor(editor);
}}
/>
+ {bottomRightAction}
>
diff --git a/ui/src/components/index.ts b/ui/src/components/index.ts
index 5c81739bb..276a5d05c 100644
--- a/ui/src/components/index.ts
+++ b/ui/src/components/index.ts
@@ -68,6 +68,7 @@ import BubbleAi from './BubbleAi';
import BubbleUser from './BubbleUser';
import Sender from './Sender';
import TabNav from './TabNav';
+import AITranslateButton from './AITranslateButton';
export {
Avatar,
@@ -121,6 +122,7 @@ export {
AdminSideNav,
BubbleAi,
BubbleUser,
+ AITranslateButton,
Sender,
TabNav,
};
diff --git a/ui/src/pages/Admin/AiSettings/index.tsx b/ui/src/pages/Admin/AiSettings/index.tsx
index 2270aa5c5..48083e412 100644
--- a/ui/src/pages/Admin/AiSettings/index.tsx
+++ b/ui/src/pages/Admin/AiSettings/index.tsx
@@ -47,6 +47,11 @@ const Index = () => {
isInvalid: false,
errorMsg: '',
},
+ translation_enabled: {
+ value: true,
+ isInvalid: false,
+ errorMsg: '',
+ },
provider: {
value: '',
isInvalid: false,
@@ -225,6 +230,7 @@ const Index = () => {
const params = {
enabled: formData.enabled.value,
+ translation_enabled: formData.translation_enabled.value,
chosen_provider: formData.provider.value,
ai_providers: newProviders,
};
@@ -232,6 +238,7 @@ const Index = () => {
.then(() => {
aiControlStore.getState().update({
ai_enabled: formData.enabled.value,
+ ai_translation_enabled: formData.translation_enabled.value,
});
historyConfigRef.current = {
@@ -274,6 +281,11 @@ const Index = () => {
isInvalid: false,
errorMsg: '',
},
+ translation_enabled: {
+ value: aiConfig.translation_enabled ?? true,
+ isInvalid: false,
+ errorMsg: '',
+ },
provider: {
value: currentAiConfig?.provider || '',
isInvalid: false,
@@ -350,6 +362,29 @@ const Index = () => {
+
+ {t('translation_enabled.label')}
+
+ handleValueChange({
+ translation_enabled: {
+ value: e.target.checked,
+ errorMsg: '',
+ isInvalid: false,
+ },
+ })
+ }
+ />
+
+ {t('translation_enabled.text')}
+
+
+
{t('provider.label')}
{
)}
{t('form.fields.title.label')}
-
-
+
+
+
+ setFormData((previous) => ({
+ ...previous,
+ title: { ...previous.title, value },
+ }))
+ }
+ />
+
+
{formData.title.errorMsg}
{bool && }
@@ -501,9 +521,18 @@ const Ask = () => {
setForceType('');
}}
ref={editorRef}
+ bottomRightAction={
+
+ }
/>
{handleContentHint()}
-
+
{formData.content.errorMsg}
@@ -545,6 +574,13 @@ const Ask = () => {
onBlur={() => {
setForceType('');
}}
+ bottomRightAction={
+
+ }
/>
= ({ visible = false, data, callback }) => {
onBlur={() => {
setFocusType('');
}}
+ bottomRightAction={
+
+ setFormData({
+ content: {
+ value,
+ isInvalid: false,
+ errorMsg: '',
+ },
+ })
+ }
+ />
+ }
/>
{
setForceType('');
}}
ref={editorRef}
+ bottomRightAction={
+
+ }
/>
{
+ return request.post(
+ '/answer/api/v1/ai/translate',
+ params,
+ { timeout: 60000, ignoreError: '50X' },
+ );
+};
+
export const getConversationList = (params: Type.Paging) => {
return request.get<{ count: number; list: Type.ConversationListItem[] }>(
`/answer/api/v1/ai/conversation/page?${qs.stringify(params)}`,
diff --git a/ui/src/stores/aiControl.ts b/ui/src/stores/aiControl.ts
index c9f0afbc7..8d116d56b 100644
--- a/ui/src/stores/aiControl.ts
+++ b/ui/src/stores/aiControl.ts
@@ -21,20 +21,25 @@ import { create } from 'zustand';
interface AiControlStore {
ai_enabled: boolean;
- update: (params: { ai_enabled: boolean }) => void;
+ ai_translation_enabled: boolean;
+ update: (params: {
+ ai_enabled?: boolean;
+ ai_translation_enabled?: boolean;
+ }) => void;
reset: () => void;
}
const aiControlStore = create((set) => ({
ai_enabled: false,
- update: (params: { ai_enabled: boolean }) =>
+ ai_translation_enabled: true,
+ update: (params) =>
set((state) => {
return {
...state,
...params,
};
}),
- reset: () => set({ ai_enabled: false }),
+ reset: () => set({ ai_enabled: false, ai_translation_enabled: true }),
}));
export default aiControlStore;
diff --git a/ui/src/utils/guard.ts b/ui/src/utils/guard.ts
index fc78fa122..19e36cf09 100644
--- a/ui/src/utils/guard.ts
+++ b/ui/src/utils/guard.ts
@@ -389,6 +389,7 @@ export const initAppSettingsStore = async () => {
});
aiControlStore.getState().update({
ai_enabled: appSettings.ai_enabled,
+ ai_translation_enabled: appSettings.ai_translation_enabled ?? true,
});
siteSecurityStore.getState().update(appSettings.site_security);
}
diff --git a/ui/src/utils/languageDetection.test.ts b/ui/src/utils/languageDetection.test.ts
new file mode 100644
index 000000000..5b48f155e
--- /dev/null
+++ b/ui/src/utils/languageDetection.test.ts
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+ doesTextNeedTranslation,
+ normalizeLanguageDetectionText,
+} from './languageDetection';
+
+describe('doesTextNeedTranslation', () => {
+ it('detects a different script in short text', async () => {
+ await expect(doesTextNeedTranslation('你好世界', 'en_US')).resolves.toBe(
+ true,
+ );
+ });
+
+ it('does not offer translation for the target language', async () => {
+ await expect(
+ doesTextNeedTranslation(
+ 'This is a sufficiently long English question about software testing.',
+ 'en_US',
+ ),
+ ).resolves.toBe(false);
+ });
+
+ it('detects a confidently different Latin language', async () => {
+ await expect(
+ doesTextNeedTranslation(
+ 'Wie kann ich dieses Problem in meiner Anwendung zuverlässig lösen?',
+ 'en_US',
+ ),
+ ).resolves.toBe(true);
+ });
+
+ it('does not guess between Latin languages when text is too short', async () => {
+ await expect(doesTextNeedTranslation('Hello', 'de_DE')).resolves.toBe(
+ false,
+ );
+ });
+
+ it('detects a short input written in a different script', async () => {
+ await expect(doesTextNeedTranslation('Hello', 'zh_CN')).resolves.toBe(true);
+ });
+
+ it('ignores code, URLs, and Markdown links', () => {
+ expect(
+ normalizeLanguageDetectionText(
+ '```js\nconst greeting = "你好";\n``` https://example.com [docs](https://example.com)',
+ ),
+ ).toBe('docs');
+ });
+});
diff --git a/ui/src/utils/languageDetection.ts b/ui/src/utils/languageDetection.ts
new file mode 100644
index 000000000..7c23bf1e1
--- /dev/null
+++ b/ui/src/utils/languageDetection.ts
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+const localeToISO3: Record = {
+ en: 'eng',
+ es: 'spa',
+ pt: 'por',
+ de: 'deu',
+ fr: 'fra',
+ ja: 'jpn',
+ it: 'ita',
+ ru: 'rus',
+ zh: 'cmn',
+ ko: 'kor',
+ vi: 'vie',
+ sk: 'slk',
+ fa: 'pes',
+};
+
+const supportedLanguages = Array.from(new Set(Object.values(localeToISO3)));
+const minimumScore = 0.8;
+const minimumLead = 0.15;
+const minimumLatinSampleLength = 10;
+
+type Script = 'arabic' | 'cyrillic' | 'han' | 'hangul' | 'japanese' | 'latin';
+
+const localeToScript: Record = {
+ en: 'latin',
+ es: 'latin',
+ pt: 'latin',
+ de: 'latin',
+ fr: 'latin',
+ ja: 'japanese',
+ it: 'latin',
+ ru: 'cyrillic',
+ zh: 'han',
+ ko: 'hangul',
+ vi: 'latin',
+ sk: 'latin',
+ fa: 'arabic',
+};
+
+const detectScript = (
+ text: string,
+ letterCount: number,
+): Script | undefined => {
+ const japanese =
+ text.match(/[\p{Script=Hiragana}\p{Script=Katakana}]/gu)?.length || 0;
+ if (japanese > 0) {
+ return 'japanese';
+ }
+
+ const scripts: Array<[Script, number]> = [
+ ['arabic', text.match(/\p{Script=Arabic}/gu)?.length || 0],
+ ['cyrillic', text.match(/\p{Script=Cyrillic}/gu)?.length || 0],
+ ['han', text.match(/\p{Script=Han}/gu)?.length || 0],
+ ['hangul', text.match(/\p{Script=Hangul}/gu)?.length || 0],
+ ['latin', text.match(/\p{Script=Latin}/gu)?.length || 0],
+ ];
+ const [script, count] = scripts.sort((a, b) => b[1] - a[1])[0];
+ return count >= 3 && count / letterCount >= 0.5 ? script : undefined;
+};
+
+export const normalizeLanguageDetectionText = (value: string) =>
+ value
+ .replace(/```[\s\S]*?```/g, ' ')
+ .replace(/`[^`]*`/g, ' ')
+ .replace(/https?:\/\/\S+/gi, ' ')
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/!?(\[([^\]]+)\])\([^)]*\)/g, '$2')
+ .replace(/[@#][\w-]+/g, ' ')
+ .replace(/[\s*_~>|=[\]{}()-]+/g, ' ')
+ .trim();
+
+export const doesTextNeedTranslation = async (
+ value: string,
+ targetLocale: string,
+): Promise => {
+ const locale = targetLocale.split(/[-_]/)[0];
+ const targetLanguage = localeToISO3[locale];
+ const targetScript = localeToScript[locale];
+ const text = normalizeLanguageDetectionText(value);
+ const letters = text.match(/\p{L}/gu)?.length || 0;
+
+ if (!targetLanguage || letters < 3) {
+ return false;
+ }
+
+ const detectedScript = detectScript(text, letters);
+ if (
+ detectedScript &&
+ targetScript &&
+ detectedScript !== targetScript &&
+ !(targetScript === 'japanese' && detectedScript === 'han')
+ ) {
+ return true;
+ }
+ if (detectedScript === 'latin' && letters < minimumLatinSampleLength) {
+ return false;
+ }
+
+ const { francAll } = await import('franc-min');
+ const [best, second] = francAll(text, {
+ only: supportedLanguages,
+ minLength: 3,
+ });
+
+ if (!best || best[0] === 'und' || best[0] === targetLanguage) {
+ return false;
+ }
+
+ return best[1] >= minimumScore && best[1] - (second?.[1] ?? 0) >= minimumLead;
+};
diff --git a/ui/src/utils/request.ts b/ui/src/utils/request.ts
index 6f1f42acc..7792629ae 100644
--- a/ui/src/utils/request.ts
+++ b/ui/src/utils/request.ts
@@ -233,7 +233,7 @@ class Request {
public post(
url: string,
data?: any,
- config?: AxiosRequestConfig,
+ config?: ApiConfig,
): Promise {
return this.instance.post(url, data, config);
}