Commit f373f43c by YeYuheng Committed by GitHub

feat: persist share chat language preference (#6982)

* feat: persist share chat language preference

* fix: simplify language switching persistence

* doc

* fix: isolate share language preference

---------

Co-authored-by: archer <545436317@qq.com>
parent 33d77cf3
...@@ -6,6 +6,7 @@ description: 'FastGPT V4.15.0-beta3 更新说明' ...@@ -6,6 +6,7 @@ description: 'FastGPT V4.15.0-beta3 更新说明'
## 🚀 新增内容 ## 🚀 新增内容
1. 多模态模型支持音视频输入。 1. 多模态模型支持音视频输入。
2. 分享链接/门户页,支持语言切换,不再强制自动识别浏览器语言切换。
## ⚙️ 优化 ## ⚙️ 优化
......
...@@ -281,7 +281,7 @@ ...@@ -281,7 +281,7 @@
"content/self-host/upgrading/4-15/41502.en.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/upgrading/4-15/41502.en.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/upgrading/4-15/41502.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/upgrading/4-15/41502.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/upgrading/4-15/41503.en.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/upgrading/4-15/41503.en.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/upgrading/4-15/41503.mdx": "2026-05-26T11:57:40+08:00", "content/self-host/upgrading/4-15/41503.mdx": "2026-05-26T13:09:31+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......
...@@ -70,6 +70,7 @@ export const iconPaths = { ...@@ -70,6 +70,7 @@ export const iconPaths = {
'common/language/America': () => import('./icons/common/language/America.svg'), 'common/language/America': () => import('./icons/common/language/America.svg'),
'common/language/China': () => import('./icons/common/language/China.svg'), 'common/language/China': () => import('./icons/common/language/China.svg'),
'common/language/en': () => import('./icons/common/language/en.svg'), 'common/language/en': () => import('./icons/common/language/en.svg'),
'common/language/translate': () => import('./icons/common/language/translate.svg'),
'common/language/zh': () => import('./icons/common/language/zh.svg'), 'common/language/zh': () => import('./icons/common/language/zh.svg'),
'common/latest_page': () => import('./icons/common/latest_page.svg'), 'common/latest_page': () => import('./icons/common/latest_page.svg'),
'common/layer': () => import('./icons/common/layer.svg'), 'common/layer': () => import('./icons/common/layer.svg'),
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" fill="none">
<g clip-path="url(#clip0_common_language_translate)">
<path d="M12.75 8.25C13.0341 8.25 13.2939 8.41046 13.4209 8.66455L17.1709 16.1646C17.3561 16.535 17.2059 16.9857 16.8354 17.1709C16.465 17.3561 16.0143 17.2059 15.8291 16.8354L14.5364 14.25H10.9636L9.6709 16.8354C9.48566 17.2059 9.03503 17.3561 8.66455 17.1709C8.29407 16.9857 8.14386 16.535 8.3291 16.1646L12.0791 8.66455C12.2061 8.41046 12.4659 8.25 12.75 8.25ZM11.7136 12.75H13.7864L12.75 10.6772L11.7136 12.75ZM10.5 3C10.9142 3 11.25 3.33579 11.25 3.75C11.25 4.16421 10.9142 4.5 10.5 4.5H9.40137L8.12402 6.41602C8.09663 6.45711 8.0652 6.49535 8.03027 6.53027L6.68555 7.875L8.78027 9.96973C9.07317 10.2626 9.07317 10.7374 8.78027 11.0303C8.48738 11.3232 8.01262 11.3232 7.71973 11.0303L5.625 8.93555L3.53027 11.0303C3.23738 11.3232 2.76262 11.3232 2.46973 11.0303C2.17683 10.7374 2.17683 10.2626 2.46973 9.96973L4.56445 7.875L3.21973 6.53027C2.92683 6.23738 2.92683 5.76262 3.21973 5.46973C3.51262 5.17683 3.98738 5.17683 4.28027 5.46973L5.625 6.81445L6.91699 5.52246L7.59888 4.5H1.5C1.08579 4.5 0.75 4.16421 0.75 3.75C0.75 3.33579 1.08579 3 1.5 3H10.5ZM6 0.75C6.41421 0.75 6.75 1.08579 6.75 1.5C6.75 1.91421 6.41421 2.25 6 2.25H5.25C4.83579 2.25 4.5 1.91421 4.5 1.5C4.5 1.08579 4.83579 0.75 5.25 0.75H6Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_common_language_translate">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>
import React from 'react';
import {
Box,
Drawer,
DrawerBody,
DrawerContent,
DrawerOverlay,
Flex,
type DrawerContentProps,
type DrawerProps
} from '@chakra-ui/react';
type PhoneDrawerProps = Omit<DrawerProps, 'children' | 'placement'> & {
children: React.ReactNode;
bodyProps?: React.ComponentProps<typeof DrawerBody>;
contentProps?: DrawerContentProps;
overlayProps?: React.ComponentProps<typeof DrawerOverlay>;
showHandle?: boolean;
};
/**
* 移动端底部小抽屉容器。
* 只负责统一底部弹出、遮罩、圆角和拖拽提示条;具体内容由 children 传入。
*/
const PhoneDrawer = ({
children,
bodyProps,
contentProps,
overlayProps,
showHandle = true,
size = 'xs',
...props
}: PhoneDrawerProps) => {
return (
<Drawer placement="bottom" size={size} {...props}>
<DrawerOverlay backgroundColor="rgba(0, 0, 0, 0.16)" {...overlayProps} />
<DrawerContent
bg="white"
borderTopRadius="16px"
overflow="hidden"
pt={0}
pb="50px"
{...contentProps}
>
{showHandle && (
<Flex justifyContent="center" py={4}>
<Box w="32px" h="4px" borderRadius="100px" bg="myGray.400" />
</Flex>
)}
<DrawerBody px={4} py={0} {...bodyProps}>
{children}
</DrawerBody>
</DrawerContent>
</Drawer>
);
};
export default React.memo(PhoneDrawer);
import { LangEnum } from '@fastgpt/global/common/i18n/type'; import {
import Cookies from 'js-cookie'; getLangFromCookie,
getLangFromLocalStorage,
getLangMapping,
getPersistedLang,
LANG_KEY,
SHARE_LANG_KEY,
setLangToStorage
} from '../i18n/utils';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
const LANG_KEY = 'NEXT_LOCALE'; type ChangeLngOptions = {
const isInIframe = () => { reloadOnChange?: boolean;
try { storageKey?: string;
return window.self !== window.top;
} catch (e) {
return true;
}
};
const setLang = (value: string) => {
if (isInIframe()) {
localStorage.setItem(LANG_KEY, value);
} else {
// 不在 iframe 中,同时使用 Cookie 和 localStorage
Cookies.set(LANG_KEY, value, { expires: 30 });
localStorage.setItem(LANG_KEY, value);
}
};
const getLang = () => {
return localStorage.getItem(LANG_KEY) || Cookies.get(LANG_KEY);
}; };
/**
* 提供客户端语言切换能力,并负责把手动选择和首次访问初始化写入统一语言偏好。
*/
export const useI18nLng = () => { export const useI18nLng = () => {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const languageMap: Record<string, string> = {
zh: LangEnum.zh_CN,
'zh-CN': LangEnum.zh_CN,
'zh-Hans': LangEnum.zh_CN,
'zh-HK': LangEnum.zh_Hant,
'zh-TW': LangEnum.zh_Hant,
'zh-Hant': LangEnum.zh_Hant,
en: LangEnum.en,
'en-US': LangEnum.en
};
const onChangeLng = async (lng: string) => { /**
let lang = languageMap[lng]; * 切换并持久化当前语言。
* `reloadOnChange` 只给用户主动切换入口使用,确保 SSR 数据、页面命名空间和客户端状态重新按新语言初始化。
*/
const onChangeLng = async (lng: string, options?: ChangeLngOptions) => {
const lang = getLangMapping(lng);
const storageKey = options?.storageKey || LANG_KEY;
const prevLang = getPersistedLang(storageKey);
const currentLang = getLangMapping(i18n?.language || prevLang || lang);
// 如果没有直接映射,尝试智能回退 setLangToStorage(lang, storageKey);
if (!lang) {
const langPrefix = lng.split('-')[0];
// 中文相关语言优先回退到简体中文
if (langPrefix === 'zh') {
lang = LangEnum.zh_CN;
}
}
const prevLang = getLang();
setLang(lang);
await i18n?.changeLanguage?.(lang); await i18n?.changeLanguage?.(lang);
if (!i18n?.hasResourceBundle?.(lang, 'common') && prevLang !== lang) { if (options?.reloadOnChange && (prevLang !== lang || currentLang !== lang)) {
window?.location?.reload?.(); if (typeof window !== 'undefined') {
window.location.reload();
}
} }
}; };
const setUserDefaultLng = (forceGetDefaultLng: boolean = false) => { const setUserDefaultLng = () => {
if (!navigator || !localStorage) return; if (typeof navigator === 'undefined' || typeof localStorage === 'undefined') return;
// 有 Cookie 时以服务端渲染语言为准;没有 Cookie 时先迁移旧的本地偏好。
if (getLangFromCookie(LANG_KEY)) return;
if (getLang() && !forceGetDefaultLng) return onChangeLng(getLang() as string); return onChangeLng(getLangFromLocalStorage(LANG_KEY) || navigator.language);
};
// 尝试精确匹配浏览器语言 /**
let lang = languageMap[navigator.language]; * 分享页使用独立语言 Cookie;首次没有分享页偏好时,继承 NEXT_LOCALE 作为初始值。
*/
const setShareDefaultLng = () => {
if (typeof navigator === 'undefined' || typeof localStorage === 'undefined') return;
// 如果没有精确匹配,尝试匹配语言前缀 return onChangeLng(
if (!lang) { getLangFromCookie(SHARE_LANG_KEY) ||
const browserLangPrefix = navigator.language.split('-')[0]; getLangFromCookie(LANG_KEY) ||
// 中文语言环境下优先回退到简体中文 getLangFromLocalStorage(SHARE_LANG_KEY) ||
if (browserLangPrefix === 'zh') { getLangFromLocalStorage(LANG_KEY) ||
lang = LangEnum.zh_CN; navigator.language,
} {
storageKey: SHARE_LANG_KEY
} }
);
// currentLng not in userLang
return onChangeLng(lang);
}; };
return { return {
onChangeLng, onChangeLng,
setUserDefaultLng setUserDefaultLng,
setShareDefaultLng
}; };
}; };
...@@ -299,6 +299,7 @@ ...@@ -299,6 +299,7 @@
"core.app.outLink.Default open": "Default Open", "core.app.outLink.Default open": "Default Open",
"core.app.outLink.Iframe block title": "Copy the iframe below to add to your website", "core.app.outLink.Iframe block title": "Copy the iframe below to add to your website",
"core.app.outLink.Link block title": "Copy the link below to open in the browser", "core.app.outLink.Link block title": "Copy the link below to open in the browser",
"core.app.outLink.Language isolated tip": "Language changes inside login-free links will not affect the system language.",
"core.app.outLink.Script Close Icon": "Close Icon", "core.app.outLink.Script Close Icon": "Close Icon",
"core.app.outLink.Script Open Icon": "Open Icon", "core.app.outLink.Script Open Icon": "Open Icon",
"core.app.outLink.Script block title": "Add the code below to your website", "core.app.outLink.Script block title": "Add the code below to your website",
...@@ -1179,6 +1180,7 @@ ...@@ -1179,6 +1180,7 @@
"upload_file": "Upload File", "upload_file": "Upload File",
"upload_file_error": "File Upload Failed", "upload_file_error": "File Upload Failed",
"usage_records": "Usage records", "usage_records": "Usage records",
"language_switch_failed": "Failed to switch language",
"user.Account": "Account", "user.Account": "Account",
"user.Application Name": "Project Name", "user.Application Name": "Project Name",
"user.Laf Account Setting": "Laf Account Configuration", "user.Laf Account Setting": "Laf Account Configuration",
......
import { LangEnum } from '@fastgpt/global/common/i18n/type'; import { LangEnum, type localeType } from '@fastgpt/global/common/i18n/type';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
const LANG_KEY = 'NEXT_LOCALE'; export const LANG_KEY = 'NEXT_LOCALE';
export const SHARE_LANG_KEY = 'FASTGPT_SHARE_LOCALE';
const PERSISTENT_LANG_COOKIE_EXPIRES_DAYS = 36500;
const languageMap: Record<string, localeType> = {
zh: LangEnum.zh_CN,
'zh-CN': LangEnum.zh_CN,
'zh-Hans': LangEnum.zh_CN,
'zh-HK': LangEnum.zh_Hant,
'zh-TW': LangEnum.zh_Hant,
'zh-Hant': LangEnum.zh_Hant,
en: LangEnum.en,
'en-US': LangEnum.en
};
const isInIframe = () => { const isInIframe = () => {
if (typeof window === 'undefined') return false;
try { try {
return window.self !== window.top; return window.self !== window.top;
} catch { } catch {
...@@ -11,32 +26,59 @@ const isInIframe = () => { ...@@ -11,32 +26,59 @@ const isInIframe = () => {
} }
}; };
export const setLangToStorage = (value: string) => { /**
if (isInIframe()) { * 持久化语言偏好。
localStorage.setItem(LANG_KEY, value); * 普通页面写统一语言 Cookie;分享页使用专用 Cookie,避免覆盖平台登录态语言。
} else { */
// 不在 iframe 中,同时使用 Cookie 和 localStorage export const setLangToStorage = (value: string, key = LANG_KEY) => {
Cookies.set(LANG_KEY, value, { expires: 30 }); const lang = getLangMapping(value);
localStorage.setItem(LANG_KEY, value); const inIframe = isInIframe();
const isShareLang = key === SHARE_LANG_KEY;
if (!inIframe || isShareLang) {
// 语言偏好按长期设置处理;iframe 内仅允许分享页专用 Cookie,避免污染平台登录态语言。
Cookies.set(key, lang, {
expires: PERSISTENT_LANG_COOKIE_EXPIRES_DAYS,
...(inIframe && isShareLang && window.location.protocol === 'https:'
? { sameSite: 'none' as const, secure: true }
: {})
});
}
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, lang);
} }
}; };
export const getLangFromStorage = () => { /**
return localStorage.getItem(LANG_KEY) || Cookies.get(LANG_KEY); * 读取服务端和客户端共享的语言 Cookie。
*/
export const getLangFromCookie = (key = LANG_KEY) => {
const lang = Cookies.get(key);
return lang ? getLangMapping(lang) : undefined;
}; };
export const getLangMapping = (lng: string): string => { /**
const languageMap: Record<string, string> = { * 读取旧版或 iframe 场景下的本地语言偏好。
zh: LangEnum.zh_CN, */
'zh-CN': LangEnum.zh_CN, export const getLangFromLocalStorage = (key = LANG_KEY) => {
'zh-Hans': LangEnum.zh_CN, if (typeof localStorage === 'undefined') return undefined;
'zh-HK': LangEnum.zh_Hant,
'zh-TW': LangEnum.zh_Hant, const lang = localStorage.getItem(key);
'zh-Hant': LangEnum.zh_Hant, return lang ? getLangMapping(lang) : undefined;
en: LangEnum.en, };
'en-US': LangEnum.en
}; /**
* 获取客户端可用的持久化语言,优先使用 Cookie,localStorage 仅作为兼容兜底。
*/
export const getPersistedLang = (key = LANG_KEY) => {
return getLangFromCookie(key) || getLangFromLocalStorage(key);
};
/**
* 将浏览器语言或历史存储值归一化成系统支持的 locale。
*/
export const getLangMapping = (lng: string): localeType => {
let lang = languageMap[lng]; let lang = languageMap[lng];
// 如果没有直接映射,尝试智能回退 // 如果没有直接映射,尝试智能回退
......
...@@ -299,6 +299,7 @@ ...@@ -299,6 +299,7 @@
"core.app.outLink.Default open": "默认打开", "core.app.outLink.Default open": "默认打开",
"core.app.outLink.Iframe block title": "复制下面 iframe 加入到你的网站中", "core.app.outLink.Iframe block title": "复制下面 iframe 加入到你的网站中",
"core.app.outLink.Link block title": "将下面链接复制到浏览器打开", "core.app.outLink.Link block title": "将下面链接复制到浏览器打开",
"core.app.outLink.Language isolated tip": "免登录链接内语言切换不会影响系统语言",
"core.app.outLink.Script Close Icon": "关闭图标", "core.app.outLink.Script Close Icon": "关闭图标",
"core.app.outLink.Script Open Icon": "打开图标", "core.app.outLink.Script Open Icon": "打开图标",
"core.app.outLink.Script block title": "将下面代码加入到你的网站中", "core.app.outLink.Script block title": "将下面代码加入到你的网站中",
...@@ -1180,6 +1181,7 @@ ...@@ -1180,6 +1181,7 @@
"upload_file": "上传文件", "upload_file": "上传文件",
"upload_file_error": "上传文件失败", "upload_file_error": "上传文件失败",
"usage_records": "使用记录", "usage_records": "使用记录",
"language_switch_failed": "语言切换失败",
"user.Account": "账号", "user.Account": "账号",
"user.Application Name": "项目名", "user.Application Name": "项目名",
"user.Laf Account Setting": "laf 账号配置", "user.Laf Account Setting": "laf 账号配置",
......
...@@ -296,6 +296,7 @@ ...@@ -296,6 +296,7 @@
"core.app.outLink.Default open": "預設開啟", "core.app.outLink.Default open": "預設開啟",
"core.app.outLink.Iframe block title": "複製下方的 iframe 加入到您的網站中", "core.app.outLink.Iframe block title": "複製下方的 iframe 加入到您的網站中",
"core.app.outLink.Link block title": "複製下方連結在瀏覽器中開啟", "core.app.outLink.Link block title": "複製下方連結在瀏覽器中開啟",
"core.app.outLink.Language isolated tip": "免登入連結內的語言切換不會影響系統語言",
"core.app.outLink.Script Close Icon": "關閉圖示", "core.app.outLink.Script Close Icon": "關閉圖示",
"core.app.outLink.Script Open Icon": "開啟圖示", "core.app.outLink.Script Open Icon": "開啟圖示",
"core.app.outLink.Script block title": "將下方程式碼加入到您的網站中", "core.app.outLink.Script block title": "將下方程式碼加入到您的網站中",
...@@ -1168,6 +1169,7 @@ ...@@ -1168,6 +1169,7 @@
"upgrade": "升級", "upgrade": "升級",
"upload_file": "上傳檔案", "upload_file": "上傳檔案",
"upload_file_error": "上傳檔案失敗", "upload_file_error": "上傳檔案失敗",
"language_switch_failed": "語言切換失敗",
"user.Account": "帳戶", "user.Account": "帳戶",
"user.Application Name": "專案名稱", "user.Application Name": "專案名稱",
"user.Laf Account Setting": "LAF 帳戶設定", "user.Laf Account Setting": "LAF 帳戶設定",
......
Subproject commit 9da5483d73f77ea36854b7c7630657fa21bdf592 Subproject commit 2b5e6b3501cbbef8f435a02f4f0a4ef4f49b4dcb
...@@ -76,7 +76,7 @@ const Layout = ({ children }: { children: JSX.Element }) => { ...@@ -76,7 +76,7 @@ const Layout = ({ children }: { children: JSX.Element }) => {
const { setLastRoute, loading, feConfigs, llmModelList, embeddingModelList } = useSystemStore(); const { setLastRoute, loading, feConfigs, llmModelList, embeddingModelList } = useSystemStore();
const { isPc } = useSystem(); const { isPc } = useSystem();
const { userInfo, isUpdateNotification, setIsUpdateNotification } = useUserStore(); const { userInfo, isUpdateNotification, setIsUpdateNotification } = useUserStore();
const { setUserDefaultLng } = useI18nLng(); const { setUserDefaultLng, setShareDefaultLng } = useI18nLng();
// Auto redeem coupon // Auto redeem coupon
useCheckCoupon(); useCheckCoupon();
...@@ -103,6 +103,11 @@ const Layout = ({ children }: { children: JSX.Element }) => { ...@@ -103,6 +103,11 @@ const Layout = ({ children }: { children: JSX.Element }) => {
!!userInfo?.team.permission.isOwner; !!userInfo?.team.permission.isOwner;
useMount(() => { useMount(() => {
if (router.pathname === '/chat/share') {
setShareDefaultLng();
return;
}
setUserDefaultLng(); setUserDefaultLng();
}); });
......
...@@ -21,7 +21,7 @@ const I18nLngSelector = () => { ...@@ -21,7 +21,7 @@ const I18nLngSelector = () => {
language: lng language: lng
}); });
} }
await onChangeLngI18n(lng); await onChangeLngI18n(lng, { reloadOnChange: true });
}, },
[userInfo?.username, onChangeLngI18n, updateUserInfo] [userInfo?.username, onChangeLngI18n, updateUserInfo]
); );
......
...@@ -145,11 +145,27 @@ const SelectUsingWayModal = ({ ...@@ -145,11 +145,27 @@ const SelectUsingWayModal = ({
}} }}
/> />
<Flex
alignItems={'center'}
gap={2}
mt={4}
px={3}
py={2}
borderRadius={'md'}
bg={'primary.50'}
color={'myGray.600'}
fontSize={'sm'}
>
<MyIcon name={'common/info'} w={'16px'} color={'primary.600'} flexShrink={0} />
<Box>{t('common:core.app.outLink.Language isolated tip')}</Box>
</Flex>
{/* config */} {/* config */}
<Grid <Grid
gridTemplateColumns={['repeat(2,1fr)', 'repeat(3,1fr)']} gridTemplateColumns={['repeat(2,1fr)', 'repeat(3,1fr)']}
gridGap={4} gridGap={4}
my={5} mt={5}
mb={5}
fontSize={'sm'} fontSize={'sm'}
> >
<Flex {...gridItemStyle}> <Flex {...gridItemStyle}>
......
import React from 'react';
import { Box, Flex, MenuItem } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { langMap, type localeType } from '@fastgpt/global/common/i18n/type';
import { chatLanguageList } from './useChatLanguageSwitch';
export type LanguageMenuItemsVariant = 'menuItem' | 'list' | 'mobileList';
type Props = {
currentLang: localeType;
variant?: LanguageMenuItemsVariant;
onSelect: (lng: localeType) => void | Promise<void>;
};
/**
* 聊天语言选项列表。
* 同一组选项在 Popover、MenuList 和 PhoneDrawer 中复用,外层容器由调用方决定。
*/
const LanguageMenuItems = ({ currentLang, variant = 'menuItem', onSelect }: Props) => {
const isPlainList = variant !== 'menuItem';
const isMobileList = variant === 'mobileList';
const items = chatLanguageList.map((lng) => {
const isSelected = currentLang === lng;
const optionContent = (
<Flex alignItems="center" justifyContent="space-between" w="100%">
<Box>{langMap[lng].label}</Box>
{isSelected && <MyIcon name="check" w="16px" />}
</Flex>
);
if (isPlainList) {
return (
<Flex
key={lng}
alignItems="center"
justifyContent="space-between"
h={isMobileList ? '44px' : '30px'}
px={2}
py={1}
borderRadius="4px"
cursor="pointer"
bg={isSelected ? 'rgba(17, 24, 36, 0.05)' : 'transparent'}
color={isSelected ? 'primary.600' : 'myGray.600'}
fontSize={isMobileList ? '16px' : '14px'}
fontWeight={isMobileList ? 500 : undefined}
lineHeight={isMobileList ? '24px' : '20px'}
letterSpacing={isMobileList ? '0.15px' : undefined}
_hover={{ bg: 'rgba(17, 24, 36, 0.05)' }}
onClick={() => onSelect(lng)}
>
{optionContent}
</Flex>
);
}
return (
<MenuItem
key={lng}
h="44px"
borderRadius="6px"
bg={isSelected ? 'rgba(17, 24, 36, 0.05)' : 'transparent'}
color={isSelected ? 'primary.600' : 'myGray.900'}
fontSize="14px"
lineHeight="20px"
_hover={{ bg: 'rgba(17, 24, 36, 0.05)' }}
onClick={() => onSelect(lng)}
>
{optionContent}
</MenuItem>
);
});
if (isPlainList) {
return (
<Flex flexDirection="column" w="100%">
{items}
</Flex>
);
}
return <>{items}</>;
};
export default React.memo(LanguageMenuItems);
import React from 'react';
import { Box, Flex } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import type { IconNameType } from '@fastgpt/web/components/common/Icon/type';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
type Props = {
iconName: IconNameType;
label: string;
isCollapsed?: boolean;
};
const LanguageMenuTrigger = ({ iconName, label, isCollapsed = false }: Props) => {
const trigger = (
<Flex
alignItems="center"
justifyContent="flex-start"
gap={1}
h="40px"
px={isCollapsed ? 0 : 2}
py={1}
borderRadius="4px"
cursor="pointer"
color="myGray.600"
title={label}
>
<Flex alignItems="center" gap={1} flex="0 1 auto" minW={0} maxW="100%">
<MyIcon name={iconName} w="18px" color="currentColor" flexShrink={0} />
{!isCollapsed && (
<Box fontSize="14px" lineHeight="20px" className="textEllipsis">
{label}
</Box>
)}
</Flex>
</Flex>
);
return (
<MyTooltip label={label} isDisabled={!isCollapsed} shouldWrapChildren={false}>
{trigger}
</MyTooltip>
);
};
export default React.memo(LanguageMenuTrigger);
import React, { useCallback } from 'react';
import { Box, Flex, useDisclosure } from '@chakra-ui/react';
import PhoneDrawer from '@fastgpt/web/components/common/PhoneDrawer';
import type { localeType } from '@fastgpt/global/common/i18n/type';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import MyPopover from '@fastgpt/web/components/common/MyPopover';
import LanguageMenuItems from './LanguageMenuItems';
import LanguageMenuTrigger from './LanguageMenuTrigger';
import { type ChatLanguageSelectorMode, useChatLanguageSwitch } from './useChatLanguageSwitch';
type Props = {
mode: ChatLanguageSelectorMode;
isCollapsed?: boolean;
onSelected?: () => void;
};
const ChatLanguageSelector = ({ mode, isCollapsed = false, onSelected }: Props) => {
const { isPc } = useSystem();
const { isOpen, onOpen, onClose: onCloseDrawer } = useDisclosure();
const { currentLang, currentLabel, onChangeLanguage } = useChatLanguageSwitch(mode);
const entryIcon = mode === 'share' ? 'common/language/translate' : 'common/globalLine';
const triggerBoxProps =
mode === 'share' ? { display: 'inline-flex', w: 'fit-content', maxW: '100%' } : { w: '100%' };
const onSelect = useCallback(
(lng: localeType, close?: () => void) => {
return onChangeLanguage(lng, () => {
onSelected?.();
close?.();
});
},
[onChangeLanguage, onSelected]
);
const entry = (
<LanguageMenuTrigger iconName={entryIcon} label={currentLabel} isCollapsed={isCollapsed} />
);
if (isPc) {
return (
<MyPopover
Trigger={
<Box cursor="pointer" {...triggerBoxProps}>
{entry}
</Box>
}
trigger="click"
placement={isCollapsed ? 'right-end' : 'top-start'}
w="178px"
closeOnBlur
>
{({ onClose }) => (
<Flex p={2} direction="column" gap={1}>
<LanguageMenuItems
currentLang={currentLang}
variant="list"
onSelect={(lng) => onSelect(lng, onClose)}
/>
</Flex>
)}
</MyPopover>
);
}
return (
<>
<Box cursor="pointer" onClick={onOpen} {...triggerBoxProps}>
{entry}
</Box>
<PhoneDrawer isOpen={isOpen} onClose={onCloseDrawer}>
<Flex flexDirection="column" w="100%">
<LanguageMenuItems
currentLang={currentLang}
variant="mobileList"
onSelect={(lng) => onSelect(lng, onCloseDrawer)}
/>
</Flex>
</PhoneDrawer>
</>
);
};
export default React.memo(ChatLanguageSelector);
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'next-i18next';
import { langMap, LangEnum, type localeType } from '@fastgpt/global/common/i18n/type';
import { useI18nLng } from '@fastgpt/web/hooks/useI18n';
import { getLangMapping, SHARE_LANG_KEY } from '@fastgpt/web/i18n/utils';
import { useUserStore } from '@/web/support/user/useUserStore';
import { useToast } from '@fastgpt/web/hooks/useToast';
export type ChatLanguageSelectorMode = 'account' | 'share';
export const chatLanguageList: localeType[] = [LangEnum.zh_CN, LangEnum.zh_Hant, LangEnum.en];
/**
* 聊天页语言切换业务逻辑。
* account 模式需要同步用户语言偏好;share 模式只写分享页专用语言 Cookie。
*/
export const useChatLanguageSwitch = (mode: ChatLanguageSelectorMode) => {
const { i18n, t } = useTranslation();
const { toast } = useToast();
const { onChangeLng } = useI18nLng();
const { userInfo, updateUserInfo } = useUserStore();
const username = userInfo?.username;
const currentLang = getLangMapping(i18n.language) as localeType;
const currentLabel = useMemo(() => {
return langMap[currentLang]?.label || langMap[LangEnum.zh_CN].label;
}, [currentLang]);
const onChangeLanguage = useCallback(
async (lng: localeType, onSelected?: () => void) => {
try {
if (mode === 'account' && username) {
await updateUserInfo({
language: lng
});
}
await onChangeLng(lng, {
reloadOnChange: true,
storageKey: mode === 'share' ? SHARE_LANG_KEY : undefined
});
onSelected?.();
} catch {
toast({
status: 'error',
title: t('common:language_switch_failed')
});
}
},
[mode, onChangeLng, t, toast, updateUserInfo, username]
);
return {
currentLang,
currentLabel,
onChangeLanguage
};
};
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { Box, Flex, Text } from '@chakra-ui/react'; import { Box, Flex, Text, useDisclosure } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useUserStore } from '@/web/support/user/useUserStore'; import { useUserStore } from '@/web/support/user/useUserStore';
import { clearToken } from '@/web/support/user/auth'; import { clearToken } from '@/web/support/user/auth';
...@@ -7,6 +7,10 @@ import { useConfirm } from '@fastgpt/web/hooks/useConfirm'; ...@@ -7,6 +7,10 @@ import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import MyPopover from '@fastgpt/web/components/common/MyPopover'; import MyPopover from '@fastgpt/web/components/common/MyPopover';
import MyIcon from '@fastgpt/web/components/common/Icon'; import MyIcon from '@fastgpt/web/components/common/Icon';
import Avatar from '@fastgpt/web/components/common/Avatar'; import Avatar from '@fastgpt/web/components/common/Avatar';
import LanguageMenuItems from '@/pageComponents/chat/LanguageSelector/LanguageMenuItems';
import { useChatLanguageSwitch } from '@/pageComponents/chat/LanguageSelector/useChatLanguageSwitch';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import PhoneDrawer from '@fastgpt/web/components/common/PhoneDrawer';
type UserAvatarPopoverProps = { type UserAvatarPopoverProps = {
isCollapsed: boolean; isCollapsed: boolean;
...@@ -22,6 +26,9 @@ const UserAvatarPopover = ({ ...@@ -22,6 +26,9 @@ const UserAvatarPopover = ({
}: UserAvatarPopoverProps) => { }: UserAvatarPopoverProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setUserInfo, userInfo } = useUserStore(); const { setUserInfo, userInfo } = useUserStore();
const { isPc } = useSystem();
const { isOpen, onOpen, onClose } = useDisclosure();
const { currentLang, onChangeLanguage } = useChatLanguageSwitch('account');
const { openConfirm, ConfirmModal } = useConfirm({ content: t('common:confirm_logout') }); const { openConfirm, ConfirmModal } = useConfirm({ content: t('common:confirm_logout') });
...@@ -30,6 +37,69 @@ const UserAvatarPopover = ({ ...@@ -30,6 +37,69 @@ const UserAvatarPopover = ({
clearToken(); clearToken();
}, [setUserInfo]); }, [setUserInfo]);
const onLogout = useCallback(
(closeMenu?: () => void) => {
closeMenu?.();
openConfirm({ onConfirm: handleLogout })();
},
[handleLogout, openConfirm]
);
const logoutContent = (
<>
<MyIcon name="core/chat/sidebar/logout" w="18px" />
<Text fontSize="14px">{t('common:logout')}</Text>
</>
);
// 移动端没有 hover,头像点击后复用通用底部抽屉,并额外挂载登出操作。
if (!isPc) {
return (
<>
<Box cursor="pointer" w="full" onClick={onOpen}>
{children}
</Box>
<PhoneDrawer
isOpen={isOpen}
onClose={onClose}
bodyProps={{ display: 'flex', flexDirection: 'column', gap: 3 }}
>
<LanguageMenuItems
currentLang={currentLang}
variant="mobileList"
onSelect={(lng) => onChangeLanguage(lng, onClose)}
/>
<Box borderTop="1px solid" borderColor="myGray.150" w="100%" />
<Flex
alignItems="center"
cursor="pointer"
_hover={{ bg: 'myGray.100' }}
py={1}
px={2}
borderRadius="4px"
gap={1}
h="44px"
color="myGray.600"
fontWeight={500}
letterSpacing="0.15px"
onClick={() => onLogout(onClose)}
w="100%"
>
<MyIcon name="core/chat/sidebar/logout" w="16px" />
<Text fontSize="16px" lineHeight="24px">
{t('common:logout')}
</Text>
</Flex>
</PhoneDrawer>
<ConfirmModal />
</>
);
}
return ( return (
<> <>
<MyPopover <MyPopover
...@@ -40,17 +110,11 @@ const UserAvatarPopover = ({ ...@@ -40,17 +110,11 @@ const UserAvatarPopover = ({
} }
trigger="hover" trigger="hover"
placement={placement} placement={placement}
w="160px" w="178px"
{...props} {...props}
> >
{({ onClose }) => { {({ onClose }) => (
const onLogout = useCallback(() => { <Flex p={2} direction="column" gap={1}>
onClose();
openConfirm({ onConfirm: handleLogout })();
}, [onClose]);
return (
<Flex p={2} direction="column" gap={3}>
{!!isCollapsed && ( {!!isCollapsed && (
<Flex <Flex
borderBottom="1px solid" borderBottom="1px solid"
...@@ -69,6 +133,14 @@ const UserAvatarPopover = ({ ...@@ -69,6 +133,14 @@ const UserAvatarPopover = ({
</Flex> </Flex>
)} )}
<LanguageMenuItems
currentLang={currentLang}
variant="list"
onSelect={(lng) => onChangeLanguage(lng, onClose)}
/>
<Box borderTop="1px solid" borderColor="myGray.100" w="100%" />
<Flex <Flex
alignItems="center" alignItems="center"
cursor="pointer" cursor="pointer"
...@@ -77,15 +149,14 @@ const UserAvatarPopover = ({ ...@@ -77,15 +149,14 @@ const UserAvatarPopover = ({
px={2} px={2}
borderRadius="4px" borderRadius="4px"
gap={1} gap={1}
onClick={onLogout} h="30px"
onClick={() => onLogout(onClose)}
w="100%" w="100%"
> >
<MyIcon name="core/chat/sidebar/logout" /> {logoutContent}
<Text fontSize="14px">{t('common:logout')}</Text>
</Flex> </Flex>
</Flex> </Flex>
); )}
}}
</MyPopover> </MyPopover>
<ConfirmModal /> <ConfirmModal />
......
...@@ -22,7 +22,8 @@ const ChatSliderFooter = () => { ...@@ -22,7 +22,8 @@ const ChatSliderFooter = () => {
const isSettingPane = pane === ChatSidebarPaneEnum.SETTING; const isSettingPane = pane === ChatSidebarPaneEnum.SETTING;
return ( return (
<Flex flexShrink={0} gap={2} alignItems="center" justifyContent="space-between" p={2} mt="auto"> <Box flexShrink={0} p={2} mt="auto">
<Flex gap={2} alignItems="center" justifyContent="space-between">
<UserAvatarPopover isCollapsed={false} placement="top-end"> <UserAvatarPopover isCollapsed={false} placement="top-end">
<Flex alignItems="center" gap={2} borderRadius="50%" p={2}> <Flex alignItems="center" gap={2} borderRadius="50%" p={2}>
<Avatar src={userInfo?.avatar} w={8} h={8} borderRadius="50%" bg="myGray.200" /> <Avatar src={userInfo?.avatar} w={8} h={8} borderRadius="50%" bg="myGray.200" />
...@@ -56,6 +57,7 @@ const ChatSliderFooter = () => { ...@@ -56,6 +57,7 @@ const ChatSliderFooter = () => {
</Flex> </Flex>
)} )}
</Flex> </Flex>
</Box>
); );
}; };
......
...@@ -103,8 +103,9 @@ const ChatSliderList = () => { ...@@ -103,8 +103,9 @@ const ChatSliderList = () => {
return ( return (
<> <>
{/* 移动端侧栏只需要纵向滚动;隐藏横向滚动条,避免底部语言入口上方出现灰线。 */}
{/* eslint-disable-next-line react-hooks/static-components -- ScrollData is supplied by useScrollPagination. */} {/* eslint-disable-next-line react-hooks/static-components -- ScrollData is supplied by useScrollPagination. */}
<ScrollData flex={'1 0 0'} h={0} px={[2, 5]} overflow={'overlay'}> <ScrollData flex={'1 0 0'} h={0} px={[2, 5]} overflowY={'auto'} overflowX={'hidden'}>
{concatHistory.map((item, i) => ( {concatHistory.map((item, i) => (
<Flex <Flex
position={'relative'} position={'relative'}
......
...@@ -17,6 +17,7 @@ type Props = { ...@@ -17,6 +17,7 @@ type Props = {
showFooter?: boolean; showFooter?: boolean;
showList?: boolean; showList?: boolean;
showMenu?: boolean; showMenu?: boolean;
footerSlot?: React.ReactNode;
}; };
const ChatSliderMobileDrawer = ({ const ChatSliderMobileDrawer = ({
...@@ -26,7 +27,8 @@ const ChatSliderMobileDrawer = ({ ...@@ -26,7 +27,8 @@ const ChatSliderMobileDrawer = ({
showHeader = true, showHeader = true,
showFooter = true, showFooter = true,
showList = true, showList = true,
showMenu = true showMenu = true,
footerSlot
}: Props) => { }: Props) => {
const theme = useTheme(); const theme = useTheme();
...@@ -60,6 +62,8 @@ const ChatSliderMobileDrawer = ({ ...@@ -60,6 +62,8 @@ const ChatSliderMobileDrawer = ({
{showList && <ChatSliderList />} {showList && <ChatSliderList />}
{footerSlot}
{showFooter && <ChatSliderFooter />} {showFooter && <ChatSliderFooter />}
</MyBox> </MyBox>
</DrawerContent> </DrawerContent>
......
...@@ -9,9 +9,10 @@ type Props = { ...@@ -9,9 +9,10 @@ type Props = {
title?: string; title?: string;
banner?: string; banner?: string;
menuConfirmButtonText?: string; menuConfirmButtonText?: string;
footerSlot?: React.ReactNode;
}; };
const ChatHistorySidebar = ({ title, banner, menuConfirmButtonText }: Props) => { const ChatHistorySidebar = ({ title, banner, menuConfirmButtonText, footerSlot }: Props) => {
const theme = useTheme(); const theme = useTheme();
return ( return (
...@@ -27,6 +28,7 @@ const ChatHistorySidebar = ({ title, banner, menuConfirmButtonText }: Props) => ...@@ -27,6 +28,7 @@ const ChatHistorySidebar = ({ title, banner, menuConfirmButtonText }: Props) =>
<ChatSliderHeader title={title} banner={banner} /> <ChatSliderHeader title={title} banner={banner} />
<ChatSliderMenu menuConfirmButtonText={menuConfirmButtonText} /> <ChatSliderMenu menuConfirmButtonText={menuConfirmButtonText} />
<ChatSliderList /> <ChatSliderList />
{footerSlot}
</MyBox> </MyBox>
); );
}; };
......
import React, { useCallback } from 'react'; import React from 'react';
import type { BoxProps } from '@chakra-ui/react'; import type { BoxProps } from '@chakra-ui/react';
import { Flex, Box, HStack, Image } from '@chakra-ui/react'; import { Flex, Box, HStack, Image } from '@chakra-ui/react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import Avatar from '@fastgpt/web/components/common/Avatar'; import Avatar from '@fastgpt/web/components/common/Avatar';
import { type AppListItemType } from '@fastgpt/global/core/app/type';
import MyDivider from '@fastgpt/web/components/common/MyDivider'; import MyDivider from '@fastgpt/web/components/common/MyDivider';
import { useUserStore } from '@/web/support/user/useUserStore'; import { useUserStore } from '@/web/support/user/useUserStore';
import UserAvatarPopover from '@/pageComponents/chat/UserAvatarPopover'; import UserAvatarPopover from '@/pageComponents/chat/UserAvatarPopover';
......
...@@ -11,6 +11,7 @@ import type { StartChatFnProps } from '@/components/core/chat/ChatContainer/type ...@@ -11,6 +11,7 @@ import type { StartChatFnProps } from '@/components/core/chat/ChatContainer/type
import PageContainer from '@/components/PageContainer'; import PageContainer from '@/components/PageContainer';
import ChatHeader from '@/pageComponents/chat/ChatHeader'; import ChatHeader from '@/pageComponents/chat/ChatHeader';
import { serviceSideProps } from '@/web/common/i18n/utils'; import { serviceSideProps } from '@/web/common/i18n/utils';
import { LANG_KEY, SHARE_LANG_KEY } from '@fastgpt/web/i18n/utils';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { getInitOutLinkChatInfo } from '@/web/core/chat/api'; import { getInitOutLinkChatInfo } from '@/web/core/chat/api';
import { getChatTitleFromChatMessage } from '@fastgpt/global/core/chat/utils'; import { getChatTitleFromChatMessage } from '@fastgpt/global/core/chat/utils';
...@@ -34,7 +35,6 @@ import ChatRecordContextProvider, { ...@@ -34,7 +35,6 @@ import ChatRecordContextProvider, {
} from '@/web/core/chat/context/chatRecordContext'; } from '@/web/core/chat/context/chatRecordContext';
import { useChatStore } from '@/web/core/chat/context/useChatStore'; import { useChatStore } from '@/web/core/chat/context/useChatStore';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants'; import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { useI18nLng } from '@fastgpt/web/hooks/useI18n';
import { type AppSchemaType } from '@fastgpt/global/core/app/type'; import { type AppSchemaType } from '@fastgpt/global/core/app/type';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList'; import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
...@@ -43,6 +43,7 @@ import { ChatSidebarPaneEnum } from '@/pageComponents/chat/constants'; ...@@ -43,6 +43,7 @@ import { ChatSidebarPaneEnum } from '@/pageComponents/chat/constants';
import ChatHistorySidebar from '@/pageComponents/chat/slider/ChatSliderSidebar'; import ChatHistorySidebar from '@/pageComponents/chat/slider/ChatSliderSidebar';
import ChatSliderMobileDrawer from '@/pageComponents/chat/slider/ChatSliderMobileDrawer'; import ChatSliderMobileDrawer from '@/pageComponents/chat/slider/ChatSliderMobileDrawer';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
import ChatLanguageSelector from '@/pageComponents/chat/LanguageSelector';
const logger = getLogger(LogCategories.MODULE.CHAT.ITEM); const logger = getLogger(LogCategories.MODULE.CHAT.ITEM);
...@@ -92,9 +93,10 @@ const OutLink = (props: Props) => { ...@@ -92,9 +93,10 @@ const OutLink = (props: Props) => {
return Object.fromEntries(Object.entries(customVariables).filter(([_, value]) => value !== '')); return Object.fromEntries(Object.entries(customVariables).filter(([_, value]) => value !== ''));
}, [customVariables]); }, [customVariables]);
const forbidLoadChat = useContextSelector(ChatContext, (v) => v.forbidLoadChat); const forbidLoadChatRef = useContextSelector(ChatContext, (v) => v.forbidLoadChat);
const onChangeChatId = useContextSelector(ChatContext, (v) => v.onChangeChatId); const onChangeChatId = useContextSelector(ChatContext, (v) => v.onChangeChatId);
const onUpdateHistoryTitle = useContextSelector(ChatContext, (v) => v.onUpdateHistoryTitle); const onUpdateHistoryTitle = useContextSelector(ChatContext, (v) => v.onUpdateHistoryTitle);
const onCloseSlider = useContextSelector(ChatContext, (v) => v.onCloseSlider);
const resetVariables = useContextSelector(ChatItemContext, (v) => v.resetVariables); const resetVariables = useContextSelector(ChatItemContext, (v) => v.resetVariables);
const isPlugin = useContextSelector(ChatItemContext, (v) => v.isPlugin); const isPlugin = useContextSelector(ChatItemContext, (v) => v.isPlugin);
...@@ -112,7 +114,7 @@ const OutLink = (props: Props) => { ...@@ -112,7 +114,7 @@ const OutLink = (props: Props) => {
async () => { async () => {
const shareId = outLinkAuthData.shareId; const shareId = outLinkAuthData.shareId;
const outLinkUid = outLinkAuthData.outLinkUid; const outLinkUid = outLinkAuthData.outLinkUid;
if (!outLinkUid || !shareId || forbidLoadChat.current) return; if (!outLinkUid || !shareId || forbidLoadChatRef.current) return;
const res = await getInitOutLinkChatInfo({ const res = await getInitOutLinkChatInfo({
chatId, chatId,
...@@ -136,7 +138,7 @@ const OutLink = (props: Props) => { ...@@ -136,7 +138,7 @@ const OutLink = (props: Props) => {
manual: false, manual: false,
refreshDeps: [shareId, outLinkAuthData, chatId], refreshDeps: [shareId, outLinkAuthData, chatId],
onFinally() { onFinally() {
forbidLoadChat.current = false; forbidLoadChatRef.current = false;
} }
} }
); );
...@@ -214,7 +216,7 @@ const OutLink = (props: Props) => { ...@@ -214,7 +216,7 @@ const OutLink = (props: Props) => {
'*' '*'
); );
return { responseText, isNewChat: forbidLoadChat.current }; return { responseText, isNewChat: forbidLoadChatRef.current };
}, },
[ [
chatId, chatId,
...@@ -224,7 +226,7 @@ const OutLink = (props: Props) => { ...@@ -224,7 +226,7 @@ const OutLink = (props: Props) => {
props.showSkillReferences, props.showSkillReferences,
onUpdateHistoryTitle, onUpdateHistoryTitle,
setChatBoxData, setChatBoxData,
forbidLoadChat, forbidLoadChatRef,
onChangeChatId onChangeChatId
] ]
); );
...@@ -236,8 +238,18 @@ const OutLink = (props: Props) => { ...@@ -236,8 +238,18 @@ const OutLink = (props: Props) => {
}); });
const RenderHistoryList = useMemo(() => { const RenderHistoryList = useMemo(() => {
// 语言入口跟随历史侧栏挂载:PC 放侧栏底部,移动端放抽屉底部且选择后关闭抽屉。
const footerSlot = (
<Box flexShrink={0} p={3} mt="auto">
<ChatLanguageSelector mode="share" onSelected={isPc ? undefined : onCloseSlider} />
</Box>
);
const Children = ( const Children = (
<ChatHistorySidebar menuConfirmButtonText={t('chat:confirm_to_clear_share_chat_history')} /> <ChatHistorySidebar
menuConfirmButtonText={t('chat:confirm_to_clear_share_chat_history')}
footerSlot={footerSlot}
/>
); );
if (showHistory !== '1') return null; if (showHistory !== '1') return null;
...@@ -248,10 +260,11 @@ const OutLink = (props: Props) => { ...@@ -248,10 +260,11 @@ const OutLink = (props: Props) => {
<ChatSliderMobileDrawer <ChatSliderMobileDrawer
showHeader={false} showHeader={false}
showFooter={false} showFooter={false}
footerSlot={footerSlot}
menuConfirmButtonText={t('common:core.chat.Confirm to clear history')} menuConfirmButtonText={t('common:core.chat.Confirm to clear history')}
/> />
); );
}, [isPc, datasetCiteData, showHistory, t]); }, [isPc, datasetCiteData, onCloseSlider, showHistory, t]);
return ( return (
<> <>
...@@ -339,7 +352,6 @@ const Render = (props: Props) => { ...@@ -339,7 +352,6 @@ const Render = (props: Props) => {
const { shareId, authToken, customUid, appId } = props; const { shareId, authToken, customUid, appId } = props;
const { localUId, setLocalUId, loaded } = useShareChatStore(); const { localUId, setLocalUId, loaded } = useShareChatStore();
const { source, chatId, setSource, setAppId, setOutLinkAuthData } = useChatStore(); const { source, chatId, setSource, setAppId, setOutLinkAuthData } = useChatStore();
const { setUserDefaultLng } = useI18nLng();
const chatHistoryProviderParams = useMemoEnhance(() => { const chatHistoryProviderParams = useMemoEnhance(() => {
return { shareId, outLinkUid: authToken || customUid || localUId || '' }; return { shareId, outLinkUid: authToken || customUid || localUId || '' };
...@@ -356,7 +368,6 @@ const Render = (props: Props) => { ...@@ -356,7 +368,6 @@ const Render = (props: Props) => {
useMount(() => { useMount(() => {
setSource('share'); setSource('share');
setUserDefaultLng(true);
}); });
// Set default localUId // Set default localUId
...@@ -455,7 +466,10 @@ export async function getServerSideProps(context: any) { ...@@ -455,7 +466,10 @@ export async function getServerSideProps(context: any) {
shareId: shareId ?? '', shareId: shareId ?? '',
authToken: authToken ?? '', authToken: authToken ?? '',
customUid, customUid,
...(await serviceSideProps(context, ['file', 'app', 'chat', 'workflow'])) ...(await serviceSideProps(context, ['file', 'app', 'chat', 'workflow'], {
langCookieKey: SHARE_LANG_KEY,
fallbackLangCookieKey: LANG_KEY
}))
} }
}; };
} }
import { type I18nNsType } from '@fastgpt/web/i18n/i18next'; import { type I18nNsType } from '@fastgpt/web/i18n/i18next';
import { getLangMapping, LANG_KEY } from '@fastgpt/web/i18n/utils';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
export const serviceSideProps = async (content: any, ns: I18nNsType = []) => { type ServiceSidePropsOptions = {
const lang = content.req?.cookies?.NEXT_LOCALE || content.locale; langCookieKey?: string;
const extraLng = content.req?.cookies?.NEXT_LOCALE ? undefined : content.locales; fallbackLangCookieKey?: string;
};
export const serviceSideProps = async (
content: any,
ns: I18nNsType = [],
options: ServiceSidePropsOptions = {}
) => {
const langCookieKey = options.langCookieKey || LANG_KEY;
const lang = getLangMapping(
content.req?.cookies?.[langCookieKey] ||
(options.fallbackLangCookieKey
? content.req?.cookies?.[options.fallbackLangCookieKey]
: undefined) ||
content.locale ||
''
);
// 预加载同级语言资源,首次自动初始化语言时可以直接完成客户端切换。
const extraLng = content.locales?.filter((locale: string) => locale !== lang);
// Device size // Device size
const deviceSize = content.req?.cookies?.NEXT_DEVICE_SIZE || null; const deviceSize = content.req?.cookies?.NEXT_DEVICE_SIZE || null;
......
...@@ -28,7 +28,7 @@ const I18nLngSelector = () => { ...@@ -28,7 +28,7 @@ const I18nLngSelector = () => {
list={list} list={list}
onChange={(val: any) => { onChange={(val: any) => {
const lang = val; const lang = val;
onChangeLng(lang); onChangeLng(lang, { reloadOnChange: true });
}} }}
/> />
); );
......
import { type I18nNsType } from '@fastgpt/web/i18n/i18next'; import { type I18nNsType } from '@fastgpt/web/i18n/i18next';
import { getLangMapping } from '@fastgpt/web/i18n/utils';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
export const serviceSideProps = async (content: any, ns: I18nNsType = []) => { export const serviceSideProps = async (content: any, ns: I18nNsType = []) => {
const lang = content.req?.cookies?.NEXT_LOCALE || content.locale; const cookieLang = content.req?.cookies?.NEXT_LOCALE;
const lang = getLangMapping(cookieLang || content.locale || '');
const extraLng = content.req?.cookies?.NEXT_LOCALE ? undefined : content.locales; const extraLng = content.req?.cookies?.NEXT_LOCALE ? undefined : content.locales;
// Device size // Device size
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment