chatai/chatbot-ui/components/utility/profile-settings.tsx

845 lines
28 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ChatbotUIContext } from "@/context/context"
import {
PROFILE_CONTEXT_MAX,
PROFILE_DISPLAY_NAME_MAX,
PROFILE_USERNAME_MAX,
PROFILE_USERNAME_MIN
} from "@/db/limits"
import { updateProfile } from "@/db/profile"
import { uploadProfileImage } from "@/db/storage/profile-images"
import { exportLocalStorageAsJSON } from "@/lib/export-old-data"
import { fetchOpenRouterModels } from "@/lib/models/fetch-models"
import { LLM_LIST_MAP } from "@/lib/models/llm/llm-list"
import { supabase } from "@/lib/supabase/browser-client"
import { cn } from "@/lib/utils"
import { OpenRouterLLM } from "@/types"
import {
IconCircleCheckFilled,
IconCircleXFilled,
IconFileDownload,
IconLoader2,
IconLogout,
IconUser
} from "@tabler/icons-react"
import Image from "next/image"
import { useRouter } from "next/navigation"
import { FC, useCallback, useContext, useRef, useState } from "react"
import { toast } from "sonner"
import { SIDEBAR_ICON_SIZE } from "../sidebar/sidebar-switcher"
import { Button } from "../ui/button"
import ImagePicker from "../ui/image-picker"
import { Input } from "../ui/input"
import { Label } from "../ui/label"
import { LimitDisplay } from "../ui/limit-display"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger
} from "../ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
import { TextareaAutosize } from "../ui/textarea-autosize"
import { WithTooltip } from "../ui/with-tooltip"
import { ThemeSwitcher } from "./theme-switcher"
import { usePathname } from "next/navigation" // 导入 usePathname
import { useTranslation } from "react-i18next"; // 引入useTranslation用于国际化
import i18nConfig from "@/i18nConfig"
interface ProfileSettingsProps {}
export const ProfileSettings: FC<ProfileSettingsProps> = ({}) => {
const { t } = useTranslation(); // 使用t函数来获取翻译文本
const {
profile,
setProfile,
envKeyMap,
setAvailableHostedModels,
setAvailableOpenRouterModels,
availableOpenRouterModels
} = useContext(ChatbotUIContext)
const router = useRouter()
const pathname = usePathname() // 获取当前路径
// 提取当前路径中的 locale 部分
const locale = pathname.split("/")[1] || "en"
const buttonRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [displayName, setDisplayName] = useState(profile?.display_name || "")
const [username, setUsername] = useState(profile?.username || "")
const [usernameAvailable, setUsernameAvailable] = useState(true)
const [loadingUsername, setLoadingUsername] = useState(false)
const [profileImageSrc, setProfileImageSrc] = useState(
profile?.image_url || ""
)
const [profileImageFile, setProfileImageFile] = useState<File | null>(null)
const [profileInstructions, setProfileInstructions] = useState(
profile?.profile_context || ""
)
const [useAzureOpenai, setUseAzureOpenai] = useState(
profile?.use_azure_openai
)
const [openaiAPIKey, setOpenaiAPIKey] = useState(
profile?.openai_api_key || ""
)
const [openaiOrgID, setOpenaiOrgID] = useState(
profile?.openai_organization_id || ""
)
const [azureOpenaiAPIKey, setAzureOpenaiAPIKey] = useState(
profile?.azure_openai_api_key || ""
)
const [azureOpenaiEndpoint, setAzureOpenaiEndpoint] = useState(
profile?.azure_openai_endpoint || ""
)
const [azureOpenai35TurboID, setAzureOpenai35TurboID] = useState(
profile?.azure_openai_35_turbo_id || ""
)
const [azureOpenai45TurboID, setAzureOpenai45TurboID] = useState(
profile?.azure_openai_45_turbo_id || ""
)
const [azureOpenai45VisionID, setAzureOpenai45VisionID] = useState(
profile?.azure_openai_45_vision_id || ""
)
const [azureEmbeddingsID, setAzureEmbeddingsID] = useState(
profile?.azure_openai_embeddings_id || ""
)
const [anthropicAPIKey, setAnthropicAPIKey] = useState(
profile?.anthropic_api_key || ""
)
const [googleGeminiAPIKey, setGoogleGeminiAPIKey] = useState(
profile?.google_gemini_api_key || ""
)
const [mistralAPIKey, setMistralAPIKey] = useState(
profile?.mistral_api_key || ""
)
const [groqAPIKey, setGroqAPIKey] = useState(profile?.groq_api_key || "")
const [perplexityAPIKey, setPerplexityAPIKey] = useState(
profile?.perplexity_api_key || ""
)
const [openrouterAPIKey, setOpenrouterAPIKey] = useState(
profile?.openrouter_api_key || ""
)
// const handleSignOut = async () => {
// await supabase.auth.signOut()
// // ✅ 清除 localStorage 中的语言偏好
// if (typeof window !== "undefined") {
// localStorage.removeItem("preferred-language")
// }
// // ✅ 清除 Cookie 中的语言偏好(设置 max-age=0 立即过期)
// document.cookie = "preferred-language=; path=/; max-age=0"
// const pathSegments = pathname.split("/").filter(Boolean)
// const locales = i18nConfig.locales
// const defaultLocale = i18nConfig.defaultLocale
// let locale: (typeof locales)[number] = defaultLocale
// const segment = pathSegments[0] as (typeof locales)[number]
// if (locales.includes(segment)) {
// locale = segment
// }
// const homePath = locale === defaultLocale ? "/" : `/${locale}`
// router.push(homePath)
// router.refresh()
// return
// }
const handleSignOut = async () => {
try {
// 调用 Supabase 注销
await supabase.auth.signOut();
} catch (error) {
console.error("Sign-out error:", error);
// 在错误发生时显示反馈或进行其他处理
return;
}
// ✅ 清除 localStorage 中的语言偏好
if (typeof window !== "undefined") {
localStorage.removeItem("preferred-language");
}
// ✅ 清除 Cookie 中的语言偏好(设置 max-age=0 立即过期)
document.cookie = "preferred-language=; path=/; max-age=0";
// 清除相关 cookies例如 access_token 和 refresh_token
document.cookie = "access_token=; path=/; max-age=0";
document.cookie = "refresh_token=; path=/; max-age=0";
const pathSegments = pathname.split("/").filter(Boolean)
const locales = i18nConfig.locales
const defaultLocale = i18nConfig.defaultLocale
let locale: (typeof locales)[number] = defaultLocale
const segment = pathSegments[0] as (typeof locales)[number]
if (locales.includes(segment)) {
locale = segment
}
const homePath = locale === defaultLocale ? "/" : `/${locale}`
console.log("...........[profile-setting.tsx]")
router.push(homePath)
router.refresh()
return
};
const handleSave = async () => {
if (!profile) return
let profileImageUrl = profile.image_url
let profileImagePath = ""
if (profileImageFile) {
const { path, url } = await uploadProfileImage(profile, profileImageFile)
profileImageUrl = url ?? profileImageUrl
profileImagePath = path
}
const updatedProfile = await updateProfile(profile.id, {
...profile,
display_name: displayName,
username,
profile_context: profileInstructions,
image_url: profileImageUrl,
image_path: profileImagePath,
openai_api_key: openaiAPIKey,
openai_organization_id: openaiOrgID,
anthropic_api_key: anthropicAPIKey,
google_gemini_api_key: googleGeminiAPIKey,
mistral_api_key: mistralAPIKey,
groq_api_key: groqAPIKey,
perplexity_api_key: perplexityAPIKey,
use_azure_openai: useAzureOpenai,
azure_openai_api_key: azureOpenaiAPIKey,
azure_openai_endpoint: azureOpenaiEndpoint,
azure_openai_35_turbo_id: azureOpenai35TurboID,
azure_openai_45_turbo_id: azureOpenai45TurboID,
azure_openai_45_vision_id: azureOpenai45VisionID,
azure_openai_embeddings_id: azureEmbeddingsID,
openrouter_api_key: openrouterAPIKey
})
setProfile(updatedProfile)
// toast.success("Profile updated!"){t("profile.profileUpdated")}
toast.success(t("profile.profileUpdated"))
const providers = [
"openai",
"google",
"azure",
"anthropic",
"mistral",
"groq",
"perplexity",
"openrouter"
]
providers.forEach(async provider => {
let providerKey: keyof typeof profile
if (provider === "google") {
providerKey = "google_gemini_api_key"
} else if (provider === "azure") {
providerKey = "azure_openai_api_key"
} else {
providerKey = `${provider}_api_key` as keyof typeof profile
}
const models = LLM_LIST_MAP[provider]
const envKeyActive = envKeyMap[provider]
if (!envKeyActive) {
const hasApiKey = !!updatedProfile[providerKey]
if (provider === "openrouter") {
if (hasApiKey && availableOpenRouterModels.length === 0) {
const openrouterModels: OpenRouterLLM[] =
await fetchOpenRouterModels()
setAvailableOpenRouterModels(prev => {
const newModels = openrouterModels.filter(
model =>
!prev.some(prevModel => prevModel.modelId === model.modelId)
)
return [...prev, ...newModels]
})
} else {
setAvailableOpenRouterModels([])
}
} else {
if (hasApiKey && Array.isArray(models)) {
setAvailableHostedModels(prev => {
const newModels = models.filter(
model =>
!prev.some(prevModel => prevModel.modelId === model.modelId)
)
return [...prev, ...newModels]
})
} else if (!hasApiKey && Array.isArray(models)) {
setAvailableHostedModels(prev =>
prev.filter(model => !models.includes(model))
)
}
}
}
})
setIsOpen(false)
}
const debounce = (func: (...args: any[]) => void, wait: number) => {
let timeout: NodeJS.Timeout | null
return (...args: any[]) => {
const later = () => {
if (timeout) clearTimeout(timeout)
func(...args)
}
if (timeout) clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
const checkUsernameAvailability = useCallback(
debounce(async (username: string) => {
if (!username) return
if (username.length < PROFILE_USERNAME_MIN) {
setUsernameAvailable(false)
return
}
if (username.length > PROFILE_USERNAME_MAX) {
setUsernameAvailable(false)
return
}
const usernameRegex = /^[a-zA-Z0-9_]+$/
if (!usernameRegex.test(username)) {
setUsernameAvailable(false)
toast.error(
"Username must be letters, numbers, or underscores only - no other characters or spacing allowed."
)
return
}
setLoadingUsername(true)
const response = await fetch(`/api/username/available`, {
method: "POST",
body: JSON.stringify({ username })
})
const data = await response.json()
const isAvailable = data.isAvailable
setUsernameAvailable(isAvailable)
if (username === profile?.username) {
setUsernameAvailable(true)
}
setLoadingUsername(false)
}, 500),
[]
)
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") {
buttonRef.current?.click()
}
}
if (!profile) return null
return (
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger asChild>
{profile.image_url ? (
<Image
className="mt-2 size-[34px] cursor-pointer rounded hover:opacity-50"
src={profile.image_url + "?" + new Date().getTime()}
height={34}
width={34}
alt={t("profile.imageAlt")}
//alt={"Image"}
/>
) : (
<Button size="icon" variant="ghost">
<IconUser size={SIDEBAR_ICON_SIZE} />
</Button>
)}
</SheetTrigger>
<SheetContent
className="flex flex-col justify-between"
side="left"
onKeyDown={handleKeyDown}
>
<div className="grow overflow-auto">
<SheetHeader>
<SheetTitle className="flex items-center justify-between space-x-2">
<div>{t("profile.settingsTitle")}</div>
<Button
tabIndex={-1}
className="text-xs"
size="sm"
onClick={handleSignOut}
>
<IconLogout className="mr-1" size={20} />
{t("profile.logout")}
</Button>
</SheetTitle>
</SheetHeader>
<Tabs defaultValue="profile">
<TabsList className="mt-4 grid w-full grid-cols-2">
<TabsTrigger value="profile">{t("profile.profileTab")}</TabsTrigger>
<TabsTrigger value="keys">{t("profile.apiKeysTab")}</TabsTrigger>
</TabsList>
<TabsContent className="mt-4 space-y-4" value="profile">
<div className="space-y-1">
<div className="flex items-center space-x-2">
<Label>{t("profile.usernameLabel")}</Label>
<div className="text-xs">
{username !== profile.username ? (
usernameAvailable ? (
<div className="text-green-500">{t("profile.available")}</div>
) : (
<div className="text-red-500">{t("profile.unavailable")}</div>
)
) : null}
</div>
</div>
<div className="relative">
<Input
className="pr-10"
placeholder={t("profile.usernamePlaceholder")}
value={username}
onChange={e => {
setUsername(e.target.value)
checkUsernameAvailability(e.target.value)
}}
minLength={PROFILE_USERNAME_MIN}
maxLength={PROFILE_USERNAME_MAX}
/>
{username !== profile.username ? (
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
{loadingUsername ? (
<IconLoader2 className="animate-spin" />
) : usernameAvailable ? (
<IconCircleCheckFilled className="text-green-500" />
) : (
<IconCircleXFilled className="text-red-500" />
)}
</div>
) : null}
</div>
<LimitDisplay
used={username.length}
limit={PROFILE_USERNAME_MAX}
/>
</div>
<div className="space-y-1">
<Label>{t("profile.profileImageLabel")}</Label>
<ImagePicker
src={profileImageSrc}
image={profileImageFile}
height={50}
width={50}
onSrcChange={setProfileImageSrc}
onImageChange={setProfileImageFile}
/>
</div>
<div className="space-y-1">
<Label>{t("profile.chatDisplayName")}</Label>
<Input
placeholder={t("profile.chatDisplayNamePlaceholder")}
value={displayName}
onChange={e => setDisplayName(e.target.value)}
maxLength={PROFILE_DISPLAY_NAME_MAX}
/>
</div>
<div className="space-y-1">
<Label className="text-sm">
{t("profile.instructionsLabel")}
</Label>
<TextareaAutosize
value={profileInstructions}
onValueChange={setProfileInstructions}
placeholder={t("profile.instructionsPlaceholder")}
minRows={6}
maxRows={10}
/>
<LimitDisplay
used={profileInstructions.length}
limit={PROFILE_CONTEXT_MAX}
/>
</div>
</TabsContent>
<TabsContent className="mt-4 space-y-4" value="keys">
<div className="mt-5 space-y-2">
<Label className="flex items-center">
{useAzureOpenai
? envKeyMap["azure"]
? ""
: t("setup.azureOpenaiApiKey")
: envKeyMap["openai"]
? ""
: t("setup.openaiApiKey")}
<Button
className={cn(
"h-[18px] w-[150px] text-[11px]",
(useAzureOpenai && !envKeyMap["azure"]) ||
(!useAzureOpenai && !envKeyMap["openai"])
? "ml-3"
: "mb-3"
)}
onClick={() => setUseAzureOpenai(!useAzureOpenai)}
>
{useAzureOpenai
? t("profile.switchToStandardOpenAI")
: t("profile.switchToAzureOpenAI")}
</Button>
</Label>
{useAzureOpenai ? (
<>
{envKeyMap["azure"] ? (
<Label>{t("profile.azureOpenAIKeySetByAdmin")}</Label>
) : (
<Input
placeholder={t("setup.azureOpenaiApiKey")}
type="password"
value={azureOpenaiAPIKey}
onChange={e => setAzureOpenaiAPIKey(e.target.value)}
/>
)}
</>
) : (
<>
{envKeyMap["openai"] ? (
<Label>{t("profile.openAIAPIKeySetByAdmin")}</Label>
) : (
<Input
placeholder={t("setup.openaiApiKey")}
type="password"
value={openaiAPIKey}
onChange={e => setOpenaiAPIKey(e.target.value)}
/>
)}
</>
)}
</div>
<div className="ml-8 space-y-3">
{useAzureOpenai ? (
<>
{
<div className="space-y-1">
{envKeyMap["azure_openai_endpoint"] ? (
<Label className="text-xs">
<Label>{t("profile.azureEndpointSetByAdmin")}</Label>
</Label>
) : (
<>
<Label>{t("profile.azureEndpointLabel")}</Label>
<Input
placeholder={t("profile.azureEndpointPlaceholder")}
value={azureOpenaiEndpoint}
onChange={e =>
setAzureOpenaiEndpoint(e.target.value)
}
/>
</>
)}
</div>
}
{
<div className="space-y-1">
{envKeyMap["azure_gpt_35_turbo_name"] ? (
<Label className="text-xs">
{t("profile.azureGpt35TurboDeploymentNameSetByAdmin")}
</Label>
) : (
<>
<Label>{t("profile.azureGpt35TurboDeploymentName")}</Label>
<Input
placeholder={t("profile.azureGpt35TurboDeploymentNamePlaceholder")}
value={azureOpenai35TurboID}
onChange={e =>
setAzureOpenai35TurboID(e.target.value)
}
/>
</>
)}
</div>
}
{
<div className="space-y-1">
{envKeyMap["azure_gpt_45_turbo_name"] ? (
<Label className="text-xs">
{t("profile.azureGpt45TurboDeploymentNameSetByAdmin")}
</Label>
) : (
<>
<Label>{t("profile.azureGpt45TurboDeploymentName")}</Label>
<Input
placeholder={t("profile.azureGpt45TurboDeploymentNamePlaceholder")}
value={azureOpenai45TurboID}
onChange={e =>
setAzureOpenai45TurboID(e.target.value)
}
/>
</>
)}
</div>
}
{
<div className="space-y-1">
{envKeyMap["azure_gpt_45_vision_name"] ? (
<Label className="text-xs">
{t("profile.azureGpt45VisionDeploymentNameSetByAdmin")}
</Label>
) : (
<>
<Label>{t("profile.azureGpt45VisionDeploymentName")}</Label>
<Input
placeholder={t("profile.azureGpt45VisionDeploymentNamePlaceholder")}
value={azureOpenai45VisionID}
onChange={e =>
setAzureOpenai45VisionID(e.target.value)
}
/>
</>
)}
</div>
}
{
<div className="space-y-1">
{envKeyMap["azure_embeddings_name"] ? (
<Label className="text-xs">
{t("profile.azureEmbeddingsDeploymentNameSetByAdmin")}
</Label>
) : (
<>
<Label>{t("profile.azureEmbeddingsDeploymentName")}</Label>
<Input
placeholder={t("profile.azureEmbeddingsDeploymentNamePlaceholder")}
value={azureEmbeddingsID}
onChange={e =>
setAzureEmbeddingsID(e.target.value)
}
/>
</>
)}
</div>
}
</>
) : (
<>
<div className="space-y-1">
{envKeyMap["openai_organization_id"] ? (
<Label className="text-xs">
{t("profile.openaiOrgIdSetByAdmin")}
</Label>
) : (
<>
<Label>{t("profile.openaiOrgIdLabel")}</Label>
<Input
placeholder={t("profile.openaiOrgIdPlaceholder")}
disabled={
!!process.env.NEXT_PUBLIC_OPENAI_ORGANIZATION_ID
}
type="password"
value={openaiOrgID}
onChange={e => setOpenaiOrgID(e.target.value)}
/>
</>
)}
</div>
</>
)}
</div>
<div className="space-y-1">
{envKeyMap["anthropic"] ? (
<Label>{t("profile.anthropicApiKeySetByAdmin")}</Label>
) : (
<>
<Label>{t("profile.anthropicApiKeyLabel")}</Label>
<Input
placeholder={t("profile.anthropicApiKeyPlaceholder")}
type="password"
value={anthropicAPIKey}
onChange={e => setAnthropicAPIKey(e.target.value)}
/>
</>
)}
</div>
<div className="space-y-1">
{envKeyMap["google"] ? (
<Label>{t("profile.geminiAPIKeySetByAdmin")}</Label>
) : (
<>
<Label>{t("profile.googleGeminiApiKeyLabel")}</Label>
<Input
placeholder={t("profile.googleGeminiApiKeyPlaceholder")}
type="password"
value={googleGeminiAPIKey}
onChange={e => setGoogleGeminiAPIKey(e.target.value)}
/>
</>
)}
</div>
<div className="space-y-1">
{envKeyMap["mistral"] ? (
<Label>{t("profile.mistralAPIKeySetByAdmin")}</Label>
) : (
<>
<Label>{t("profile.mistralApiKeyLabel")}</Label>
<Input
placeholder={t("profile.mistralApiKeyPlaceholder")}
type="password"
value={mistralAPIKey}
onChange={e => setMistralAPIKey(e.target.value)}
/>
</>
)}
</div>
<div className="space-y-1">
{envKeyMap["groq"] ? (
<Label>{t("profile.groqAPIKeySetByAdmin")}</Label>
) : (
<>
<Label>{t("profile.groqApiKeyLabel")}</Label>
<Input
placeholder={t("profile.groqApiKeyPlaceholder")}
type="password"
value={groqAPIKey}
onChange={e => setGroqAPIKey(e.target.value)}
/>
</>
)}
</div>
<div className="space-y-1">
{envKeyMap["perplexity"] ? (
<Label>{t("profile.perplexityAPIKeySetByAdmin")}</Label>
) : (
<>
<Label>{t("profile.perplexityApiKeyLabel")}</Label>
<Input
placeholder={t("profile.perplexityApiKeyPlaceholder")}
type="password"
value={perplexityAPIKey}
onChange={e => setPerplexityAPIKey(e.target.value)}
/>
</>
)}
</div>
<div className="space-y-1">
{envKeyMap["openrouter"] ? (
<Label>{t("profile.openRouterAPIKeySetByAdmin")}</Label>
) : (
<>
<Label>{t("profile.openRouterApiKeyLabel")}</Label>
<Input
placeholder={t("profile.openRouterApiKeyPlaceholder")}
type="password"
value={openrouterAPIKey}
onChange={e => setOpenrouterAPIKey(e.target.value)}
/>
</>
)}
</div>
</TabsContent>
</Tabs>
</div>
<div className="mt-6 flex items-center">
<div className="flex items-center space-x-1">
<ThemeSwitcher />
<WithTooltip
display={
<div>
{t("profile.downloadTooltip")}
</div>
}
trigger={
<IconFileDownload
className="cursor-pointer hover:opacity-50"
size={32}
onClick={exportLocalStorageAsJSON}
/>
}
/>
</div>
<div className="ml-auto space-x-2">
<Button variant="ghost" onClick={() => setIsOpen(false)}>
{t("profile.cancel")} {/* 取消按钮 */}
</Button>
<Button ref={buttonRef} onClick={handleSave}>
{t("profile.save")} {/* 保存按钮 */}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
)
}