diff --git a/config/config.example.yaml b/config/config.example.yaml index 2f13af3..58fe904 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -1,3 +1,5 @@ +language: zh-CN + server: port: 8083 cors: diff --git a/docker/agent-desk-lancedb.yaml b/docker/agent-desk-lancedb.yaml index 73c53c7..9fdb643 100644 --- a/docker/agent-desk-lancedb.yaml +++ b/docker/agent-desk-lancedb.yaml @@ -1,3 +1,5 @@ +language: zh-CN + server: port: 8083 cors: diff --git a/docker/agent-desk-sqlite-lancedb.yaml b/docker/agent-desk-sqlite-lancedb.yaml index 2593bd1..95b4eb7 100644 --- a/docker/agent-desk-sqlite-lancedb.yaml +++ b/docker/agent-desk-sqlite-lancedb.yaml @@ -1,3 +1,5 @@ +language: zh-CN + server: port: 8083 cors: diff --git a/docker/agent-desk.yaml b/docker/agent-desk.yaml index 137b512..cce0540 100644 --- a/docker/agent-desk.yaml +++ b/docker/agent-desk.yaml @@ -1,3 +1,5 @@ +language: zh-CN + server: port: 8083 cors: diff --git a/internal/bootstrap/init.go b/internal/bootstrap/init.go index 4773a80..21d1bae 100644 --- a/internal/bootstrap/init.go +++ b/internal/bootstrap/init.go @@ -4,6 +4,7 @@ import ( "agent-desk/internal/ai/rag/vectordb" "agent-desk/internal/oidcclient" "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/i18nx" "agent-desk/internal/pkg/logx" "agent-desk/internal/services/cronx" "agent-desk/internal/wxwork" @@ -20,6 +21,7 @@ func Init(configPath string) error { return err } config.SetCurrent(cfg) + i18nx.SetDefaultLocale(cfg.LanguageOrDefault()) logx.Init(logx.Config{ Level: cfg.Logger.Level, diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 1712958..138092d 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -11,7 +11,6 @@ import ( func registerApiAuthRoutes(group *gin.RouterGroup) { group.POST("/login", api.Login) group.POST("/logout", api.Logout) - group.GET("/options", api.AuthOptions) group.GET("/profile", api.Profile) group.GET("/wxwork_callback", api.WxWorkCallback) group.POST("/wxwork_exchange", api.WxWorkExchange) diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index 11c38ce..abcebba 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -26,6 +26,7 @@ import ( func NewServer() (*gin.Engine, error) { cfg := config.Current() + i18nx.SetDefaultLocale(cfg.LanguageOrDefault()) gin.SetMode(gin.ReleaseMode) printBanner() @@ -156,6 +157,7 @@ func addRouter(app *gin.Engine) { apiGroup := app.Group("/api") apiGroup.GET("/health", api.Health) + apiGroup.GET("/config", api.PublicConfig) registerApiAuthRoutes(apiGroup.Group("/auth")) registerApiChannelRoutes(apiGroup.Group("/channel")) registerApiCustomerRoutes(apiGroup.Group("/customer")) diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go index 94b554a..592e6b1 100644 --- a/internal/bootstrap/server_route_test.go +++ b/internal/bootstrap/server_route_test.go @@ -32,6 +32,7 @@ func TestNewServerRegistersGinRoutes(t *testing.T) { expected := []string{ http.MethodPost + " /api/auth/login", + http.MethodGet + " /api/config", http.MethodGet + " /api/health", http.MethodGet + " /api/auth/oidc_login", http.MethodGet + " /api/auth/oidc_callback", @@ -93,8 +94,9 @@ func TestNewServerHealthEndpointIsPublic(t *testing.T) { } } -func TestNewServerExposesPublicAuthOptions(t *testing.T) { +func TestNewServerExposesPublicConfig(t *testing.T) { config.SetCurrent(&config.Config{ + Language: "zh-CN", Storage: config.StorageConfig{ Local: config.LocalStorageConfig{ Root: "storage", @@ -116,7 +118,7 @@ func TestNewServerExposesPublicAuthOptions(t *testing.T) { } rec := httptest.NewRecorder() - app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/options", nil)) + app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/config", nil)) if rec.Code != http.StatusOK { t.Fatalf("status=%d want %d", rec.Code, http.StatusOK) @@ -125,8 +127,9 @@ func TestNewServerExposesPublicAuthOptions(t *testing.T) { var body struct { Success bool `json:"success"` Data struct { - WxWorkEnabled bool `json:"wxworkEnabled"` - OIDCEnabled bool `json:"oidcEnabled"` + Language string `json:"language"` + WxWorkEnabled bool `json:"wxworkEnabled"` + OIDCEnabled bool `json:"oidcEnabled"` } `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { @@ -135,6 +138,9 @@ func TestNewServerExposesPublicAuthOptions(t *testing.T) { if !body.Success { t.Fatalf("success=false, body=%s", rec.Body.String()) } + if body.Data.Language != "zh-CN" { + t.Fatalf("language=%q want zh-CN", body.Data.Language) + } if !body.Data.WxWorkEnabled { t.Fatalf("wxworkEnabled=false want true") } @@ -146,6 +152,29 @@ func TestNewServerExposesPublicAuthOptions(t *testing.T) { } } +func TestNewServerDoesNotExposeLegacyAuthOptions(t *testing.T) { + config.SetCurrent(&config.Config{ + Storage: config.StorageConfig{ + Local: config.LocalStorageConfig{ + Root: "storage", + BaseURL: "/storage", + }, + }, + }) + + app, err := NewServer() + if err != nil { + t.Fatalf("NewServer() error = %v", err) + } + + rec := httptest.NewRecorder() + app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/options", nil)) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status=%d want %d, body=%s", rec.Code, http.StatusNotFound, rec.Body.String()) + } +} + func TestNewServerSeparatesAPIStaticAndSPA(t *testing.T) { config.SetCurrent(&config.Config{ Storage: config.StorageConfig{ diff --git a/internal/handlers/api/auth_handler.go b/internal/handlers/api/auth_handler.go index 95d9a6b..9207315 100644 --- a/internal/handlers/api/auth_handler.go +++ b/internal/handlers/api/auth_handler.go @@ -30,9 +30,10 @@ func Login(ctx *gin.Context) { httpx.WriteJSON(ctx, ret) } -func AuthOptions(ctx *gin.Context) { +func PublicConfig(ctx *gin.Context) { cfg := config.Current() - httpx.WriteJSON(ctx, &response.AuthOptionsResponse{ + httpx.WriteJSON(ctx, &response.PublicConfigResponse{ + Language: cfg.LanguageOrDefault(), WxWorkEnabled: cfg.WxWork.Enabled, OIDCEnabled: cfg.OIDC.Enabled, }) diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index cb7722f..0e181fc 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -9,6 +9,7 @@ import ( ) type Config struct { + Language string `yaml:"language"` Server ServerConfig `yaml:"server"` DB DBConfig `yaml:"db"` Logger LoggerConfig `yaml:"logger"` @@ -21,6 +22,17 @@ type Config struct { CustomerSession CustomerSessionConfig `yaml:"customerSession"` } +func (c Config) LanguageOrDefault() string { + switch strings.ToLower(strings.TrimSpace(c.Language)) { + case "zh", "zh-cn", "zh_cn", "zh-hans": + return "zh-CN" + case "en", "en-us", "en_us": + return "en-US" + default: + return "zh-CN" + } +} + type WxWorkNotifyConfig struct { Enabled bool `yaml:"enabled"` ToUsers []int64 `yaml:"toUsers"` diff --git a/internal/pkg/dto/response/auth_response.go b/internal/pkg/dto/response/auth_response.go index f9e373b..c2c6e29 100644 --- a/internal/pkg/dto/response/auth_response.go +++ b/internal/pkg/dto/response/auth_response.go @@ -19,7 +19,8 @@ type LoginResponse struct { Roles []string `json:"roles"` } -type AuthOptionsResponse struct { - WxWorkEnabled bool `json:"wxworkEnabled"` - OIDCEnabled bool `json:"oidcEnabled"` +type PublicConfigResponse struct { + Language string `json:"language"` + WxWorkEnabled bool `json:"wxworkEnabled"` + OIDCEnabled bool `json:"oidcEnabled"` } diff --git a/internal/pkg/errorsx/errors.go b/internal/pkg/errorsx/errors.go index ac561a4..f957c33 100644 --- a/internal/pkg/errorsx/errors.go +++ b/internal/pkg/errorsx/errors.go @@ -86,7 +86,7 @@ func (e *I18nError) Error() string { if e == nil { return "" } - return e.Message(i18nx.LocaleZhCN) + return e.Message(i18nx.DefaultLocale) } func (e *I18nError) Unwrap() error { diff --git a/internal/pkg/i18nx/error.go b/internal/pkg/i18nx/error.go index 53730e3..6b62483 100644 --- a/internal/pkg/i18nx/error.go +++ b/internal/pkg/i18nx/error.go @@ -20,7 +20,7 @@ func (e *Error) Error() string { if e == nil { return "" } - return e.Message(LocaleZhCN) + return e.Message(DefaultLocale) } func (e *Error) Message(locale string) string { diff --git a/internal/pkg/i18nx/i18nx_test.go b/internal/pkg/i18nx/i18nx_test.go index a02a6d5..b4772ed 100644 --- a/internal/pkg/i18nx/i18nx_test.go +++ b/internal/pkg/i18nx/i18nx_test.go @@ -9,27 +9,24 @@ import ( ) func TestNormalizeLocale(t *testing.T) { - t.Parallel() - tests := []struct { name string in string want string }{ - {name: "default for blank", in: "", want: LocaleEnUS}, + {name: "default for blank", in: "", want: LocaleZhCN}, {name: "exact chinese", in: "zh-CN", want: LocaleZhCN}, {name: "underscore chinese", in: "zh_CN", want: LocaleZhCN}, {name: "short chinese", in: "zh", want: LocaleZhCN}, {name: "exact english", in: "en-US", want: LocaleEnUS}, {name: "underscore english", in: "en_US", want: LocaleEnUS}, {name: "short english", in: "en", want: LocaleEnUS}, - {name: "unsupported falls back", in: "fr-FR", want: LocaleEnUS}, + {name: "unsupported falls back", in: "fr-FR", want: LocaleZhCN}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - t.Parallel() if got := NormalizeLocale(tt.in); got != tt.want { t.Fatalf("NormalizeLocale(%q) = %q, want %q", tt.in, got, tt.want) } @@ -37,36 +34,33 @@ func TestNormalizeLocale(t *testing.T) { } } -func TestResolveLocaleFromHeaders(t *testing.T) { - t.Parallel() - +func TestResolveLocaleUsesDefaultLocale(t *testing.T) { + SetDefaultLocale(LocaleZhCN) req := httptest.NewRequest(http.MethodGet, "/api/dashboard/user/list", nil) req.Header.Set("Accept-Language", "fr-FR, en-US;q=0.9, zh-CN;q=0.8") - if got := ResolveRequestLocale(req); got != LocaleEnUS { - t.Fatalf("ResolveRequestLocale() = %q, want %q", got, LocaleEnUS) + if got := ResolveRequestLocale(req); got != LocaleZhCN { + t.Fatalf("ResolveRequestLocale() = %q, want %q", got, LocaleZhCN) } } -func TestResolveLocalePrefersXLocale(t *testing.T) { - t.Parallel() - +func TestResolveLocaleIgnoresRequestLocaleHeaders(t *testing.T) { + SetDefaultLocale(LocaleZhCN) req := httptest.NewRequest(http.MethodGet, "/api/dashboard/user/list", nil) req.Header.Set("X-Locale", "en-US") req.Header.Set("Accept-Language", "zh-CN") - if got := ResolveRequestLocale(req); got != LocaleEnUS { - t.Fatalf("ResolveRequestLocale() = %q, want %q", got, LocaleEnUS) + if got := ResolveRequestLocale(req); got != LocaleZhCN { + t.Fatalf("ResolveRequestLocale() = %q, want %q", got, LocaleZhCN) } } -func TestTranslateFallsBackToEnglish(t *testing.T) { - t.Parallel() - +func TestTranslateUsesConfiguredDefaultForUnsupportedLocale(t *testing.T) { + SetDefaultLocale(LocaleZhCN) if got := TLocale(LocaleEnUS, "error.auth.expired"); got != "Your session has expired. Please sign in again." { t.Fatalf("english translation = %q", got) } - if got := TLocale("fr-FR", "error.auth.expired"); got != "Your session has expired. Please sign in again." { + if got := TLocale("fr-FR", "error.auth.expired"); got != "未登录或登录已过期" { t.Fatalf("fallback translation = %q", got) } } @@ -143,6 +137,7 @@ func TestGetfFallsBackToKey(t *testing.T) { } func TestMiddlewareStoresLocale(t *testing.T) { + SetDefaultLocale(LocaleZhCN) gin.SetMode(gin.TestMode) router := gin.New() router.Use(Middleware()) @@ -159,7 +154,7 @@ func TestMiddlewareStoresLocale(t *testing.T) { if recorder.Code != http.StatusOK { t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) } - if got := recorder.Body.String(); got != LocaleEnUS { - t.Fatalf("middleware locale = %q, want %q", got, LocaleEnUS) + if got := recorder.Body.String(); got != LocaleZhCN { + t.Fatalf("middleware locale = %q, want %q", got, LocaleZhCN) } } diff --git a/internal/pkg/i18nx/middleware.go b/internal/pkg/i18nx/middleware.go index f067ba2..435ed8f 100644 --- a/internal/pkg/i18nx/middleware.go +++ b/internal/pkg/i18nx/middleware.go @@ -5,13 +5,11 @@ import ( "strings" "github.com/gin-gonic/gin" - "golang.org/x/text/language" ) const ( - LocaleZhCN = "zh-CN" - LocaleEnUS = "en-US" - DefaultLocale = LocaleEnUS + LocaleZhCN = "zh-CN" + LocaleEnUS = "en-US" ) var supportedLocales = map[string]string{ @@ -24,6 +22,12 @@ var supportedLocales = map[string]string{ "en_us": LocaleEnUS, } +var DefaultLocale = LocaleZhCN + +func SetDefaultLocale(locale string) { + DefaultLocale = NormalizeLocale(locale) +} + func NormalizeLocale(value string) string { key := strings.ToLower(strings.TrimSpace(value)) if key == "" { @@ -35,19 +39,7 @@ func NormalizeLocale(value string) string { return DefaultLocale } -func ResolveRequestLocale(req *http.Request) string { - if req == nil { - return DefaultLocale - } - if locale := normalizeSupportedLocale(req.Header.Get("X-Locale")); locale != "" { - return locale - } - if locale := resolveAcceptLanguage(req.Header.Get("Accept-Language")); locale != "" { - return locale - } - if locale := normalizeSupportedLocale(req.URL.Query().Get("locale")); locale != "" { - return locale - } +func ResolveRequestLocale(_ *http.Request) string { return DefaultLocale } @@ -57,31 +49,3 @@ func Middleware() gin.HandlerFunc { ctx.Next() } } - -func normalizeSupportedLocale(value string) string { - key := strings.ToLower(strings.TrimSpace(value)) - if key == "" { - return "" - } - if locale, ok := supportedLocales[key]; ok { - return locale - } - return "" -} - -func resolveAcceptLanguage(value string) string { - tags, _, err := language.ParseAcceptLanguage(value) - if err != nil { - return "" - } - for _, tag := range tags { - if locale := normalizeSupportedLocale(tag.String()); locale != "" { - return locale - } - base, _ := tag.Base() - if locale := normalizeSupportedLocale(base.String()); locale != "" { - return locale - } - } - return "" -} diff --git a/web/app/dashboard/login/page.tsx b/web/app/dashboard/login/page.tsx index 6e1fa4b..2106163 100644 --- a/web/app/dashboard/login/page.tsx +++ b/web/app/dashboard/login/page.tsx @@ -1,4 +1,3 @@ -import { LocaleSwitcher } from "@/components/locale-switcher" import { LoginForm } from "@/components/login-form" import { Suspense } from "react" @@ -6,9 +5,6 @@ export default function LoginPage() { return (
-
- -
}> diff --git a/web/components/legal-document-page.tsx b/web/components/legal-document-page.tsx index 7543c24..67e1305 100644 --- a/web/components/legal-document-page.tsx +++ b/web/components/legal-document-page.tsx @@ -3,7 +3,6 @@ import Image from "next/image" import Link from "next/link" -import { LocaleSwitcher } from "@/components/locale-switcher" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { useAppLocale, useI18n } from "@/i18n/provider" import enUSMessages from "@/messages/en-US.json" @@ -39,7 +38,7 @@ export function LegalDocumentPage({ type }: { type: LegalPageType }) { return (
-
+
{t("app.brand")}
-
diff --git a/web/components/locale-switcher.tsx b/web/components/locale-switcher.tsx deleted file mode 100644 index f7a75f3..0000000 --- a/web/components/locale-switcher.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client" - -import { LanguagesIcon } from "lucide-react" - -import { useAppLocale, useI18n } from "@/i18n/provider" -import { SUPPORTED_LOCALES, type AppLocale } from "@/i18n/config" -import { Button } from "@/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" - -export function LocaleSwitcher() { - const t = useI18n() - const { locale, setLocale } = useAppLocale() - - return ( - - } - aria-label={t("common.language")} - > - - - - setLocale(value as AppLocale)} - > - {SUPPORTED_LOCALES.map((option) => ( - - {t(`locale.${option}`)} - - ))} - - - - ) -} diff --git a/web/components/login-form.tsx b/web/components/login-form.tsx index bf3cf13..8129e6e 100644 --- a/web/components/login-form.tsx +++ b/web/components/login-form.tsx @@ -7,7 +7,8 @@ import { startTransition, useEffect, useState } from "react" import { toast } from "sonner" import { useAuth } from "@/components/auth-provider" -import { fetchAuthOptions, loginWithPassword, type AuthOptions } from "@/lib/api/auth" +import { loginWithPassword } from "@/lib/api/auth" +import { fetchPublicConfig, type PublicConfig } from "@/lib/api/config" import { useI18n } from "@/i18n/provider" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -40,15 +41,15 @@ export function LoginForm({ const { session } = useAuth() const [isPending, setIsPending] = useState(false) const [isWxWorkEnv, setIsWxWorkEnv] = useState(false) - const [authOptions, setAuthOptions] = useState(null) - const [authOptionsError, setAuthOptionsError] = useState(null) + const [publicConfig, setPublicConfig] = useState(null) + const [publicConfigError, setPublicConfigError] = useState(null) const nextPath = searchParams.get("next") const wxworkError = searchParams.get("wxworkError") const oidcError = searchParams.get("oidcError") const redirectPath = nextPath && nextPath.startsWith("/") ? nextPath : "/dashboard" const enabledProviderCount = - Number(authOptions?.wxworkEnabled) + Number(authOptions?.oidcEnabled) + Number(publicConfig?.wxworkEnabled) + Number(publicConfig?.oidcEnabled) useEffect(() => { if (session) { @@ -75,17 +76,17 @@ export function LoginForm({ useEffect(() => { let cancelled = false - void fetchAuthOptions() + void fetchPublicConfig() .then((options) => { if (!cancelled) { - setAuthOptions(options) - setAuthOptionsError(null) + setPublicConfig(options) + setPublicConfigError(null) } }) .catch((error) => { if (!cancelled) { - setAuthOptions(null) - setAuthOptionsError(error instanceof Error ? error.message : "") + setPublicConfig(null) + setPublicConfigError(error instanceof Error ? error.message : "") } }) @@ -115,7 +116,7 @@ export function LoginForm({ } } - if (authOptionsError) { + if (publicConfigError) { return (
@@ -124,7 +125,7 @@ export function LoginForm({

{t("auth.optionsLoadFailed")}

- {authOptionsError || t("api.requestFailed")} + {publicConfigError || t("api.requestFailed")}

@@ -133,7 +134,7 @@ export function LoginForm({ ) } - if (!authOptions) { + if (!publicConfig) { return (
@@ -206,7 +207,7 @@ export function LoginForm({ enabledProviderCount === 1 ? "grid-cols-1" : "grid-cols-2" )} > - {authOptions.wxworkEnabled ? ( + {publicConfig.wxworkEnabled ? ( ) : null} - {authOptions.oidcEnabled ? ( + {publicConfig.oidcEnabled ? (
-
diff --git a/web/components/workbench-header.tsx b/web/components/workbench-header.tsx index df0888b..db73e2c 100644 --- a/web/components/workbench-header.tsx +++ b/web/components/workbench-header.tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/navigation" import { useState } from "react" import { ChangePasswordDialog } from "@/components/change-password-dialog" -import { LocaleSwitcher } from "@/components/locale-switcher" import { PaletteToggle } from "@/components/palette-toggle" import { RealtimeConnectionStatus } from "@/components/realtime-connection-status" import { ThemeToggle } from "@/components/theme-toggle" @@ -36,7 +35,6 @@ export function WorkbenchHeader() {
- diff --git a/web/i18n/config.test.mjs b/web/i18n/config.test.mjs index cc670cf..2eb6e9a 100644 --- a/web/i18n/config.test.mjs +++ b/web/i18n/config.test.mjs @@ -25,7 +25,7 @@ async function loadConfig() { test("normalizes supported locale aliases", async () => { const { DEFAULT_LOCALE, normalizeLocale } = await loadConfig() - assert.equal(DEFAULT_LOCALE, "en-US") + assert.equal(DEFAULT_LOCALE, "zh-CN") assert.equal(normalizeLocale("zh-CN"), "zh-CN") assert.equal(normalizeLocale("zh_CN"), "zh-CN") assert.equal(normalizeLocale("zh"), "zh-CN") @@ -35,26 +35,11 @@ test("normalizes supported locale aliases", async () => { assert.equal(normalizeLocale("fr-FR"), DEFAULT_LOCALE) }) -test("resolves browser locale from stored value before navigator languages", async () => { - const { resolveBrowserLocale } = await loadConfig() +test("reads the configured locale without browser language detection", async () => { + const { configureLocale, readStoredLocale } = await loadConfig() - assert.equal( - resolveBrowserLocale({ - storedLocale: "en-US", - navigatorLanguages: ["zh-CN"], - }), - "en-US" - ) -}) - -test("falls back through navigator languages", async () => { - const { resolveBrowserLocale } = await loadConfig() - - assert.equal( - resolveBrowserLocale({ - storedLocale: "", - navigatorLanguages: ["fr-FR", "en"], - }), - "en-US" - ) + assert.equal(readStoredLocale(), "zh-CN") + + configureLocale("en-US") + assert.equal(readStoredLocale(), "en-US") }) diff --git a/web/i18n/config.ts b/web/i18n/config.ts index 021d967..260b33b 100644 --- a/web/i18n/config.ts +++ b/web/i18n/config.ts @@ -1,8 +1,7 @@ export const SUPPORTED_LOCALES = ["zh-CN", "en-US"] as const export type AppLocale = (typeof SUPPORTED_LOCALES)[number] -export const DEFAULT_LOCALE: AppLocale = "en-US" -export const LOCALE_STORAGE_KEY = "cs_ai_agent_locale" +export const DEFAULT_LOCALE: AppLocale = "zh-CN" const LOCALE_ALIASES: Record = { zh: "zh-CN", @@ -37,40 +36,13 @@ export function isSupportedLocale( return SUPPORTED_LOCALES.includes(value as AppLocale) } -export function resolveBrowserLocale({ - storedLocale, - navigatorLanguages, -}: { - storedLocale?: string | null - navigatorLanguages?: readonly string[] | null -}): AppLocale { - if (isSupportedLocale(storedLocale)) { - return storedLocale - } - - for (const locale of navigatorLanguages ?? []) { - const normalized = normalizeSupportedLocale(locale) - if (normalized) { - return normalized - } - } - - return DEFAULT_LOCALE -} +let configuredLocale: AppLocale = DEFAULT_LOCALE export function readStoredLocale(): AppLocale { - if (typeof window === "undefined") { - return DEFAULT_LOCALE - } - return resolveBrowserLocale({ - storedLocale: window.localStorage.getItem(LOCALE_STORAGE_KEY), - navigatorLanguages: window.navigator.languages, - }) + return configuredLocale } -export function writeStoredLocale(locale: AppLocale) { - if (typeof window === "undefined") { - return - } - window.localStorage.setItem(LOCALE_STORAGE_KEY, locale) +export function configureLocale(locale: string | null | undefined): AppLocale { + configuredLocale = normalizeLocale(locale) + return configuredLocale } diff --git a/web/i18n/provider.tsx b/web/i18n/provider.tsx index d345ffb..527bec8 100644 --- a/web/i18n/provider.tsx +++ b/web/i18n/provider.tsx @@ -12,10 +12,10 @@ import { import { DEFAULT_LOCALE, type AppLocale, - readStoredLocale, - writeStoredLocale, + configureLocale, } from "@/i18n/config" import { translateMessage } from "@/i18n/messages" +import { fetchPublicConfig } from "@/lib/api/config" type LocaleContextValue = { locale: AppLocale @@ -34,11 +34,24 @@ export function AppI18nProvider({ children }: { children: ReactNode }) { const [isLocaleReady, setIsLocaleReady] = useState(false) useEffect(() => { - const storedLocale = readStoredLocale() - setLocaleState(storedLocale) - document.documentElement.lang = storedLocale - document.title = translateMessage(storedLocale, "app.metadataTitle") - setIsLocaleReady(true) + let cancelled = false + + fetchPublicConfig() + .then((config) => configureLocale(config.language)) + .catch(() => configureLocale(DEFAULT_LOCALE)) + .then((configuredLocale) => { + if (cancelled) { + return + } + setLocaleState(configuredLocale) + document.documentElement.lang = configuredLocale + document.title = translateMessage(configuredLocale, "app.metadataTitle") + setIsLocaleReady(true) + }) + + return () => { + cancelled = true + } }, []) useEffect(() => { @@ -49,12 +62,7 @@ export function AppI18nProvider({ children }: { children: ReactNode }) { () => ({ locale, t: (key, values) => translateMessage(locale, key, values), - setLocale: (nextLocale) => { - setLocaleState(nextLocale) - writeStoredLocale(nextLocale) - document.documentElement.lang = nextLocale - document.title = translateMessage(nextLocale, "app.metadataTitle") - }, + setLocale: () => {}, }), [locale] ) diff --git a/web/lib/api/auth.ts b/web/lib/api/auth.ts index f520bbb..65ae6d1 100644 --- a/web/lib/api/auth.ts +++ b/web/lib/api/auth.ts @@ -6,17 +6,6 @@ export type LoginRequest = { password: string } -export type AuthOptions = { - wxworkEnabled: boolean - oidcEnabled: boolean -} - -export async function fetchAuthOptions() { - return request("/api/auth/options", { - skipAuth: true, - }) -} - export async function loginWithPassword(payload: LoginRequest) { const data = await request("/api/auth/login", { method: "POST", diff --git a/web/lib/api/client.ts b/web/lib/api/client.ts index 3360357..70728f4 100644 --- a/web/lib/api/client.ts +++ b/web/lib/api/client.ts @@ -1,5 +1,4 @@ import { expireSession, readSession } from "@/lib/auth" -import { readStoredLocale } from "@/i18n/config" import { translateCurrentMessage } from "@/i18n/messages" const API_BASE_URL = @@ -50,9 +49,6 @@ function buildRequestHeaders(headers: HeadersInit | undefined, skipAuth?: boolea ) { authHeaders.set("Content-Type", "application/json") } - const locale = readStoredLocale() - authHeaders.set("Accept-Language", locale) - authHeaders.set("X-Locale", locale) return authHeaders } diff --git a/web/lib/sdk/agent-desk-sdk.test.mjs b/web/lib/sdk/agent-desk-sdk.test.mjs index ddc7854..696ed29 100644 --- a/web/lib/sdk/agent-desk-sdk.test.mjs +++ b/web/lib/sdk/agent-desk-sdk.test.mjs @@ -50,14 +50,21 @@ async function loadSdk(config) { const sandbox = { URL, console, - fetch: async () => ({ - json: async () => ({ - success: true, - data: { - title: "\u5728\u7ebf\u5ba2\u670d", - themeColor: "#2563eb", - }, - }), + fetch: async (url) => ({ + json: async () => + String(url).endsWith("/api/config") + ? { + success: true, + data: { + language: "en-US", + }, + } + : { + success: true, + data: { + themeColor: "#2563eb", + }, + }, }), document: { body, @@ -95,7 +102,7 @@ async function loadSdk(config) { return sandbox } -async function flushPromises(count = 5) { +async function flushPromises(count = 10) { for (let i = 0; i < count; i += 1) { await Promise.resolve() } @@ -133,6 +140,7 @@ test("launcher click creates chat iframe with a freshly resolved userToken", asy ) assert.ok(launcher) + assert.equal(launcher.children.at(-1)?.textContent, "Support") launcher.click() await flushPromises() diff --git a/web/lib/sdk/agent-desk-sdk.ts b/web/lib/sdk/agent-desk-sdk.ts index a9fe92f..a74b6ec 100644 --- a/web/lib/sdk/agent-desk-sdk.ts +++ b/web/lib/sdk/agent-desk-sdk.ts @@ -7,6 +7,7 @@ import type { type NormalizedAgentDeskConfig = AgentDeskConfig & { baseUrl: string channelId: string + language: string position: "left" | "right" themeColor: string width: string @@ -38,22 +39,23 @@ type WidgetConfigResponse = { >> } -function getWidgetLocale() { - try { - const stored = window.localStorage?.getItem("cs_ai_agent_locale") - const language = stored || document.documentElement.lang || window.navigator?.language || "" - return language.toLowerCase().startsWith("zh") ? "zh-CN" : "en-US" - } catch { - return "en-US" +type PublicConfigResponse = { + success?: boolean + data?: { + language?: string } } -function getDefaultWidgetTitle() { - return getWidgetLocale() === "en-US" ? "Support" : "\u5728\u7ebf\u5ba2\u670d" +function normalizeWidgetLanguage(language: string | undefined) { + return String(language || "").toLowerCase().startsWith("en") ? "en-US" : "zh-CN" } -function getLauncherText() { - return getWidgetLocale() === "en-US" ? "Support" : "\u5ba2\u670d" +function getDefaultWidgetTitle(config?: NormalizedAgentDeskConfig | null) { + return normalizeWidgetLanguage(config?.language) === "en-US" ? "Support" : "\u5728\u7ebf\u5ba2\u670d" +} + +function getLauncherText(config?: NormalizedAgentDeskConfig | null) { + return normalizeWidgetLanguage(config?.language) === "en-US" ? "Support" : "\u5ba2\u670d" } type FrameMessage = @@ -65,8 +67,9 @@ type FrameMessage = (function () { const DEFAULT_CONFIG: Pick< NormalizedAgentDeskConfig, - "position" | "themeColor" | "width" + "language" | "position" | "themeColor" | "width" > = { + language: "zh-CN", position: "right", themeColor: "#0f6cbd", width: "380px", @@ -103,6 +106,7 @@ type FrameMessage = delete merged.apiBaseUrl } merged.channelId = String(merged.channelId || "") + merged.language = normalizeWidgetLanguage(String(merged.language || "zh-CN")) if (merged.externalId) { merged.externalId = String(merged.externalId) } @@ -209,6 +213,28 @@ type FrameMessage = .catch(() => config) } + function fetchPublicConfig(config: NormalizedAgentDeskConfig) { + const baseUrl = String(config.apiBaseUrl || config.baseUrl || "").replace(/\/$/, "") + if (!baseUrl || typeof fetch !== "function") { + return Promise.resolve(config) + } + return fetch(`${baseUrl}/api/config`, { + method: "GET", + cache: "no-store", + }) + .then((response) => response.json() as Promise) + .then((payload) => { + if (!payload || payload.success === false) { + return config + } + return normalizeConfig({ + ...config, + language: payload.data?.language || config.language, + }) + }) + .catch(() => config) + } + function clearFrameTimers() { if (state.frameHideTimer) { window.clearTimeout(state.frameHideTimer) @@ -369,7 +395,7 @@ type FrameMessage = state.frame = document.createElement("iframe") state.frame.dataset.agentDeskWidget = "frame" - state.frame.title = state.config.title || getDefaultWidgetTitle() + state.frame.title = state.config.title || getDefaultWidgetTitle(state.config) state.frame.src = state.frameUrl.toString() applyFrameLayout() state.frame.style.display = "block" @@ -435,7 +461,7 @@ type FrameMessage = const text = document.createElement("span") button.type = "button" button.dataset.agentDeskWidget = "launcher" - button.setAttribute("aria-label", config.title || getDefaultWidgetTitle()) + button.setAttribute("aria-label", config.title || getDefaultWidgetTitle(config)) icon.setAttribute("viewBox", "0 0 24 24") icon.setAttribute("fill", "none") icon.setAttribute("stroke", "currentColor") @@ -451,7 +477,7 @@ type FrameMessage = path.setAttribute("d", pathData) icon.appendChild(path) }) - text.textContent = getLauncherText() + text.textContent = getLauncherText(config) text.style.display = "block" button.style.position = "fixed" button.style.bottom = "24px" @@ -504,15 +530,17 @@ type FrameMessage = } state.configLoading = true - fetchWidgetConfig(state.config).then((nextConfig) => { - state.configLoading = false - state.config = normalizeConfig(nextConfig) - if (state.button?.parentNode) { - state.button.parentNode.removeChild(state.button) - state.button = null - } - createLauncher() - }) + fetchPublicConfig(state.config) + .then((nextConfig) => fetchWidgetConfig(nextConfig)) + .then((nextConfig) => { + state.configLoading = false + state.config = normalizeConfig(nextConfig) + if (state.button?.parentNode) { + state.button.parentNode.removeChild(state.button) + state.button = null + } + createLauncher() + }) } function destroy() { diff --git a/web/lib/sdk/config-types.ts b/web/lib/sdk/config-types.ts index caf6a55..0fb2138 100644 --- a/web/lib/sdk/config-types.ts +++ b/web/lib/sdk/config-types.ts @@ -11,6 +11,7 @@ export type AgentDeskConfig = { getUserToken?: () => string | Promise title?: string subtitle?: string + language?: string position?: "left" | "right" themeColor?: string width?: string diff --git a/web/public/sdk/agent-desk-sdk.min.js b/web/public/sdk/agent-desk-sdk.min.js index bff2098..2d557b3 100644 --- a/web/public/sdk/agent-desk-sdk.min.js +++ b/web/public/sdk/agent-desk-sdk.min.js @@ -1 +1 @@ -var __rest=this&&this.__rest||function(e,t){var n={};for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&t.indexOf(i)<0&&(n[i]=e[i]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols){var r=0;for(i=Object.getOwnPropertySymbols(e);rString(e||"").trim())}catch(e){return Promise.reject(e)}}().then(e=>{if(!n.config)throw new Error("channelId is required");return n.frameUrl=function(e,t){const n=r(e),i=new URL(`${n}/support/chat/`);return i.searchParams.set("channelId",e.channelId),i.searchParams.set("baseUrl",e.baseUrl),e.apiBaseUrl&&i.searchParams.set("apiBaseUrl",e.apiBaseUrl),e.externalId&&i.searchParams.set("externalId",e.externalId),e.externalName&&i.searchParams.set("externalName",e.externalName),t&&i.searchParams.set("userToken",t),i}(n.config,e),n.frameConfig=a(n.config,e),n.frameUrl})}function s(){n.frameHideTimer&&(window.clearTimeout(n.frameHideTimer),n.frameHideTimer=null),n.frameDestroyTimer&&(window.clearTimeout(n.frameDestroyTimer),n.frameDestroyTimer=null)}function l(){const e=n.frame,t=n.config;if(e&&t){if(e.style.position="fixed",e.style.border="0",e.style.overflow="hidden",e.style.background="#fff",e.style.zIndex="2147483000",e.style.boxShadow="0 28px 80px rgba(15, 35, 65, 0.28)",e.style.willChange="top,right,bottom,left,width,height,opacity,transform,border-radius",e.style.transition="top 260ms cubic-bezier(0.22, 1, 0.36, 1), right 260ms cubic-bezier(0.22, 1, 0.36, 1), bottom 260ms cubic-bezier(0.22, 1, 0.36, 1), left 260ms cubic-bezier(0.22, 1, 0.36, 1), width 260ms cubic-bezier(0.22, 1, 0.36, 1), height 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms ease, transform 260ms cubic-bezier(0.22, 1, 0.36, 1), border-radius 260ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 260ms ease",e.style.transformOrigin="left"===t.position?"left bottom":"right bottom",n.isMaximized)return e.style.top="max(12px, env(safe-area-inset-top))",e.style.right="max(12px, env(safe-area-inset-right))",e.style.bottom="max(12px, env(safe-area-inset-bottom))",e.style.left="max(12px, env(safe-area-inset-left))",e.style.width="calc(100vw - max(12px, env(safe-area-inset-left)) - max(12px, env(safe-area-inset-right)))",e.style.maxWidth="none",e.style.height="calc(100dvh - max(12px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-bottom)))",void(e.style.borderRadius="20px");e.style.top="",e.style.bottom="max(88px, calc(72px + env(safe-area-inset-bottom)))",e.style.right="left"===t.position?"":"max(12px, env(safe-area-inset-right))",e.style.left="left"===t.position?"max(12px, env(safe-area-inset-left))":"",e.style.width=t.width||"380px",e.style.maxWidth="calc(100vw - max(12px, env(safe-area-inset-left)) - max(12px, env(safe-area-inset-right)))",e.style.height="min(760px, calc(100dvh - max(104px, calc(88px + env(safe-area-inset-bottom))) - max(12px, env(safe-area-inset-top))))",e.style.borderRadius="24px"}}function d(e){var t;if((null===(t=n.frame)||void 0===t?void 0:t.contentWindow)&&n.frameUrl)try{n.frame.contentWindow.postMessage(e,n.frameUrl.origin)}catch(e){console.error("[agent-desk-widget] postMessage failed",e)}}function c(){n.frame&&n.frameLoaded&&n.frameReady&&n.config&&(n.initSent||(n.initSent=!0,d({type:"agent-desk:init",payload:n.frameConfig||a(n.config,"")})),d({type:n.isOpen?"agent-desk:open":"agent-desk:minimize"}),d({type:"agent-desk:maximized",payload:{isMaximized:n.isMaximized}}))}function f(){const e=n.frame;if(e){if(s(),l(),e.style.display="block",n.isOpen)return e.style.visibility="visible",e.style.pointerEvents="auto",n.frameHideTimer=window.setTimeout(()=>{n.frame&&(n.frame.style.opacity="1",n.frame.style.transform="translate3d(0, 0, 0) scale(1)")},16),void c();e.style.pointerEvents="none",e.style.opacity="0",e.style.transform=n.isMaximized?"translate3d(0, 10px, 0) scale(0.985)":"translate3d(0, 16px, 0) scale(0.96)",n.frameHideTimer=window.setTimeout(()=>{n.frame&&!n.isOpen&&(n.frame.style.visibility="hidden")},n.animationDuration),c()}}function m(e){const t=e||window.AgentDeskConfig||{channelId:""};n.config=i(t);const a=r(n.config);t.baseUrl||(n.config.baseUrl=a),n.config.channelId?(n.configLoading=!0,function(e){const t=String(e.apiBaseUrl||e.baseUrl||"").replace(/\/$/,"");if(!t||!e.channelId||"function"!=typeof fetch)return Promise.resolve(e);const n=`${t}/api/channel/config?channelId=${encodeURIComponent(e.channelId)}`;return fetch(n,{method:"GET",cache:"no-store",headers:{"X-Channel-Id":e.channelId}}).then(e=>e.json()).then(t=>t&&!1!==t.success?function(e,t){if(!t)return e;const n=Object.assign({},e);return["title","subtitle","themeColor","position","width"].forEach(e=>{const i=t[e];null!=i&&(n[e]=i)}),n}(e,t.data||{}):e).catch(()=>e)}(n.config).then(e=>{var t;n.configLoading=!1,n.config=i(e),(null===(t=n.button)||void 0===t?void 0:t.parentNode)&&(n.button.parentNode.removeChild(n.button),n.button=null),function(){if(n.button)return n.button;const e=n.config;if(!e)return null;const t=document.createElement("button"),i=document.createElementNS("http://www.w3.org/2000/svg","svg"),r=document.createElement("span");t.type="button",t.dataset.agentDeskWidget="launcher",t.setAttribute("aria-label",e.title||getDefaultWidgetTitle()),i.setAttribute("viewBox","0 0 24 24"),i.setAttribute("fill","none"),i.setAttribute("stroke","currentColor"),i.setAttribute("stroke-width","2"),i.setAttribute("stroke-linecap","round"),i.setAttribute("stroke-linejoin","round"),i.setAttribute("aria-hidden","true"),i.style.width="24px",i.style.height="24px",i.style.flex="0 0 auto",["M3 11a9 9 0 1 1 18 0","M3 11h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z","M21 11h-3a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2z","M21 16v2a4 4 0 0 1-4 4h-5"].forEach(e=>{const t=document.createElementNS("http://www.w3.org/2000/svg","path");t.setAttribute("d",e),i.appendChild(t)}),r.textContent=getLauncherText(),r.style.display="block",t.style.position="fixed",t.style.bottom="24px",t.style.right="left"===e.position?"":"24px",t.style.left="left"===e.position?"24px":"",t.style.zIndex="2147483000",t.style.display="inline-flex",t.style.flexDirection="column",t.style.alignItems="center",t.style.justifyContent="center",t.style.gap="4px",t.style.width="64px",t.style.height="64px",t.style.border="0",t.style.borderRadius="999px",t.style.padding="0",t.style.background=e.themeColor||"#0f6cbd",t.style.color="#fff",t.style.font="600 13px/1 sans-serif",t.style.boxShadow="0 18px 40px rgba(15, 35, 65, 0.24)",t.style.cursor="pointer",t.appendChild(i),t.appendChild(r),t.addEventListener("click",()=>{if(n.isOpen)return n.isOpen=!1,void f();u()}),document.body.appendChild(t),n.button=t}()})):console.error("[agent-desk-widget] channelId is required")}function u(){return o().then(()=>{n.frame||(n.frame?n.frame:n.frameUrl&&n.config&&(n.frame=document.createElement("iframe"),n.frame.dataset.agentDeskWidget="frame",n.frame.title=n.config.title||getDefaultWidgetTitle(),n.frame.src=n.frameUrl.toString(),l(),n.frame.style.display="block",n.frame.style.visibility="hidden",n.frame.style.pointerEvents="none",n.frame.style.opacity="0",n.frame.style.transform="translate3d(0, 18px, 0) scale(0.96)",n.frame.addEventListener("load",()=>{n.frameLoaded=!0,f()}),document.body.appendChild(n.frame),n.frame)),n.frame&&(n.isOpen=!0,f())}).catch(e=>{console.error("[agent-desk-widget] open failed",e)})}t||(window.__CS_AI_AGENT_WIDGET_STATE__=n),window.AgentDeskWidget={mount:m,destroy:function(){var e,t;s(),(null===(e=n.frame)||void 0===e?void 0:e.parentNode)&&n.frame.parentNode.removeChild(n.frame),(null===(t=n.button)||void 0===t?void 0:t.parentNode)&&n.button.parentNode.removeChild(n.button),n.button=null,n.frame=null,n.frameLoaded=!1,n.frameReady=!1,n.initSent=!1,n.isOpen=!1,n.isMaximized=!1,n.configLoading=!1,n.frameConfig=null,n.frameUrl=null},open:()=>u(),close:()=>{n.isOpen=!1,f()},getChatUrl:()=>{var e;return n.config||m(window.AgentDeskConfig||{channelId:""}),(null===(e=n.config)||void 0===e?void 0:e.channelId)?o().then(e=>e.toString()):Promise.reject(new Error("channelId is required"))}},n.listenerBound||(window.addEventListener("message",function(e){if(!n.frame||e.source!==n.frame.contentWindow)return;const t=e.data||{};return"agent-desk:ready"===t.type?(n.frameReady=!0,void c()):"agent-desk:request-minimize"===t.type?(n.isOpen=!1,void f()):void("agent-desk:request-close"!==t.type?"agent-desk:request-toggle-maximize"===t.type&&(n.isMaximized=!n.isMaximized,f()):n.frame&&(s(),n.frame.style.pointerEvents="none",n.frame.style.opacity="0",n.frame.style.transform="translate3d(0, 18px, 0) scale(0.94)",n.frame.style.visibility="hidden",n.frameDestroyTimer=window.setTimeout(()=>{n.frame&&(n.frame.parentNode&&n.frame.parentNode.removeChild(n.frame),n.frame=null,n.frameLoaded=!1,n.frameReady=!1,n.initSent=!1,n.isOpen=!1,n.isMaximized=!1,s())},n.animationDuration)))}),n.listenerBound=!0),window.AgentDeskConfig&&m(window.AgentDeskConfig)}(); +var __rest=this&&this.__rest||function(e,t){var n={};for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&t.indexOf(i)<0&&(n[i]=e[i]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols){var a=0;for(i=Object.getOwnPropertySymbols(e);aString(e||"").trim())}catch(e){return Promise.reject(e)}}().then(e=>{if(!n.config)throw new Error("channelId is required");return n.frameUrl=function(e,t){const n=a(e),i=new URL(`${n}/support/chat/`);return i.searchParams.set("channelId",e.channelId),i.searchParams.set("baseUrl",e.baseUrl),e.apiBaseUrl&&i.searchParams.set("apiBaseUrl",e.apiBaseUrl),e.externalId&&i.searchParams.set("externalId",e.externalId),e.externalName&&i.searchParams.set("externalName",e.externalName),t&&i.searchParams.set("userToken",t),i}(n.config,e),n.frameConfig=r(n.config,e),n.frameUrl})}function s(){n.frameHideTimer&&(window.clearTimeout(n.frameHideTimer),n.frameHideTimer=null),n.frameDestroyTimer&&(window.clearTimeout(n.frameDestroyTimer),n.frameDestroyTimer=null)}function l(){const e=n.frame,t=n.config;if(e&&t){if(e.style.position="fixed",e.style.border="0",e.style.overflow="hidden",e.style.background="#fff",e.style.zIndex="2147483000",e.style.boxShadow="0 28px 80px rgba(15, 35, 65, 0.28)",e.style.willChange="top,right,bottom,left,width,height,opacity,transform,border-radius",e.style.transition="top 260ms cubic-bezier(0.22, 1, 0.36, 1), right 260ms cubic-bezier(0.22, 1, 0.36, 1), bottom 260ms cubic-bezier(0.22, 1, 0.36, 1), left 260ms cubic-bezier(0.22, 1, 0.36, 1), width 260ms cubic-bezier(0.22, 1, 0.36, 1), height 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms ease, transform 260ms cubic-bezier(0.22, 1, 0.36, 1), border-radius 260ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 260ms ease",e.style.transformOrigin="left"===t.position?"left bottom":"right bottom",n.isMaximized)return e.style.top="max(12px, env(safe-area-inset-top))",e.style.right="max(12px, env(safe-area-inset-right))",e.style.bottom="max(12px, env(safe-area-inset-bottom))",e.style.left="max(12px, env(safe-area-inset-left))",e.style.width="calc(100vw - max(12px, env(safe-area-inset-left)) - max(12px, env(safe-area-inset-right)))",e.style.maxWidth="none",e.style.height="calc(100dvh - max(12px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-bottom)))",void(e.style.borderRadius="20px");e.style.top="",e.style.bottom="max(88px, calc(72px + env(safe-area-inset-bottom)))",e.style.right="left"===t.position?"":"max(12px, env(safe-area-inset-right))",e.style.left="left"===t.position?"max(12px, env(safe-area-inset-left))":"",e.style.width=t.width||"380px",e.style.maxWidth="calc(100vw - max(12px, env(safe-area-inset-left)) - max(12px, env(safe-area-inset-right)))",e.style.height="min(760px, calc(100dvh - max(104px, calc(88px + env(safe-area-inset-bottom))) - max(12px, env(safe-area-inset-top))))",e.style.borderRadius="24px"}}function d(e){var t;if((null===(t=n.frame)||void 0===t?void 0:t.contentWindow)&&n.frameUrl)try{n.frame.contentWindow.postMessage(e,n.frameUrl.origin)}catch(e){console.error("[agent-desk-widget] postMessage failed",e)}}function c(){n.frame&&n.frameLoaded&&n.frameReady&&n.config&&(n.initSent||(n.initSent=!0,d({type:"agent-desk:init",payload:n.frameConfig||r(n.config,"")})),d({type:n.isOpen?"agent-desk:open":"agent-desk:minimize"}),d({type:"agent-desk:maximized",payload:{isMaximized:n.isMaximized}}))}function f(){const e=n.frame;if(e){if(s(),l(),e.style.display="block",n.isOpen)return e.style.visibility="visible",e.style.pointerEvents="auto",n.frameHideTimer=window.setTimeout(()=>{n.frame&&(n.frame.style.opacity="1",n.frame.style.transform="translate3d(0, 0, 0) scale(1)")},16),void c();e.style.pointerEvents="none",e.style.opacity="0",e.style.transform=n.isMaximized?"translate3d(0, 10px, 0) scale(0.985)":"translate3d(0, 16px, 0) scale(0.96)",n.frameHideTimer=window.setTimeout(()=>{n.frame&&!n.isOpen&&(n.frame.style.visibility="hidden")},n.animationDuration),c()}}function m(e){const t=e||window.AgentDeskConfig||{channelId:""};n.config=i(t);const r=a(n.config);t.baseUrl||(n.config.baseUrl=r),n.config.channelId?(n.configLoading=!0,function(e){const t=String(e.apiBaseUrl||e.baseUrl||"").replace(/\/$/,"");return t&&"function"==typeof fetch?fetch(`${t}/api/config`,{method:"GET",cache:"no-store"}).then(e=>e.json()).then(t=>{var n;return t&&!1!==t.success?i(Object.assign(Object.assign({},e),{language:(null===(n=t.data)||void 0===n?void 0:n.language)||e.language})):e}).catch(()=>e):Promise.resolve(e)}(n.config).then(e=>function(e){const t=String(e.apiBaseUrl||e.baseUrl||"").replace(/\/$/,"");if(!t||!e.channelId||"function"!=typeof fetch)return Promise.resolve(e);const n=`${t}/api/channel/config?channelId=${encodeURIComponent(e.channelId)}`;return fetch(n,{method:"GET",cache:"no-store",headers:{"X-Channel-Id":e.channelId}}).then(e=>e.json()).then(t=>t&&!1!==t.success?function(e,t){if(!t)return e;const n=Object.assign({},e);return["title","subtitle","themeColor","position","width"].forEach(e=>{const i=t[e];null!=i&&(n[e]=i)}),n}(e,t.data||{}):e).catch(()=>e)}(e)).then(e=>{var t;n.configLoading=!1,n.config=i(e),(null===(t=n.button)||void 0===t?void 0:t.parentNode)&&(n.button.parentNode.removeChild(n.button),n.button=null),function(){if(n.button)return n.button;const e=n.config;if(!e)return null;const t=document.createElement("button"),i=document.createElementNS("http://www.w3.org/2000/svg","svg"),a=document.createElement("span");t.type="button",t.dataset.agentDeskWidget="launcher",t.setAttribute("aria-label",e.title||getDefaultWidgetTitle(e)),i.setAttribute("viewBox","0 0 24 24"),i.setAttribute("fill","none"),i.setAttribute("stroke","currentColor"),i.setAttribute("stroke-width","2"),i.setAttribute("stroke-linecap","round"),i.setAttribute("stroke-linejoin","round"),i.setAttribute("aria-hidden","true"),i.style.width="24px",i.style.height="24px",i.style.flex="0 0 auto",["M3 11a9 9 0 1 1 18 0","M3 11h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z","M21 11h-3a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2z","M21 16v2a4 4 0 0 1-4 4h-5"].forEach(e=>{const t=document.createElementNS("http://www.w3.org/2000/svg","path");t.setAttribute("d",e),i.appendChild(t)}),a.textContent=getLauncherText(e),a.style.display="block",t.style.position="fixed",t.style.bottom="24px",t.style.right="left"===e.position?"":"24px",t.style.left="left"===e.position?"24px":"",t.style.zIndex="2147483000",t.style.display="inline-flex",t.style.flexDirection="column",t.style.alignItems="center",t.style.justifyContent="center",t.style.gap="4px",t.style.width="64px",t.style.height="64px",t.style.border="0",t.style.borderRadius="999px",t.style.padding="0",t.style.background=e.themeColor||"#0f6cbd",t.style.color="#fff",t.style.font="600 13px/1 sans-serif",t.style.boxShadow="0 18px 40px rgba(15, 35, 65, 0.24)",t.style.cursor="pointer",t.appendChild(i),t.appendChild(a),t.addEventListener("click",()=>{if(n.isOpen)return n.isOpen=!1,void f();u()}),document.body.appendChild(t),n.button=t}()})):console.error("[agent-desk-widget] channelId is required")}function u(){return o().then(()=>{n.frame||(n.frame?n.frame:n.frameUrl&&n.config&&(n.frame=document.createElement("iframe"),n.frame.dataset.agentDeskWidget="frame",n.frame.title=n.config.title||getDefaultWidgetTitle(n.config),n.frame.src=n.frameUrl.toString(),l(),n.frame.style.display="block",n.frame.style.visibility="hidden",n.frame.style.pointerEvents="none",n.frame.style.opacity="0",n.frame.style.transform="translate3d(0, 18px, 0) scale(0.96)",n.frame.addEventListener("load",()=>{n.frameLoaded=!0,f()}),document.body.appendChild(n.frame),n.frame)),n.frame&&(n.isOpen=!0,f())}).catch(e=>{console.error("[agent-desk-widget] open failed",e)})}t||(window.__CS_AI_AGENT_WIDGET_STATE__=n),window.AgentDeskWidget={mount:m,destroy:function(){var e,t;s(),(null===(e=n.frame)||void 0===e?void 0:e.parentNode)&&n.frame.parentNode.removeChild(n.frame),(null===(t=n.button)||void 0===t?void 0:t.parentNode)&&n.button.parentNode.removeChild(n.button),n.button=null,n.frame=null,n.frameLoaded=!1,n.frameReady=!1,n.initSent=!1,n.isOpen=!1,n.isMaximized=!1,n.configLoading=!1,n.frameConfig=null,n.frameUrl=null},open:()=>u(),close:()=>{n.isOpen=!1,f()},getChatUrl:()=>{var e;return n.config||m(window.AgentDeskConfig||{channelId:""}),(null===(e=n.config)||void 0===e?void 0:e.channelId)?o().then(e=>e.toString()):Promise.reject(new Error("channelId is required"))}},n.listenerBound||(window.addEventListener("message",function(e){if(!n.frame||e.source!==n.frame.contentWindow)return;const t=e.data||{};return"agent-desk:ready"===t.type?(n.frameReady=!0,void c()):"agent-desk:request-minimize"===t.type?(n.isOpen=!1,void f()):void("agent-desk:request-close"!==t.type?"agent-desk:request-toggle-maximize"===t.type&&(n.isMaximized=!n.isMaximized,f()):n.frame&&(s(),n.frame.style.pointerEvents="none",n.frame.style.opacity="0",n.frame.style.transform="translate3d(0, 18px, 0) scale(0.94)",n.frame.style.visibility="hidden",n.frameDestroyTimer=window.setTimeout(()=>{n.frame&&(n.frame.parentNode&&n.frame.parentNode.removeChild(n.frame),n.frame=null,n.frameLoaded=!1,n.frameReady=!1,n.initSent=!1,n.isOpen=!1,n.isMaximized=!1,s())},n.animationDuration)))}),n.listenerBound=!0),window.AgentDeskConfig&&m(window.AgentDeskConfig)}();