diff --git a/apps/controller/openapi.json b/apps/controller/openapi.json index 92afcf21e..8472709be 100644 --- a/apps/controller/openapi.json +++ b/apps/controller/openapi.json @@ -1259,7 +1259,8 @@ "nullable": true, "enum": [ "en", - "zh-CN" + "zh-CN", + "zh-TW" ] }, "analyticsEnabled": { @@ -1291,7 +1292,8 @@ "type": "string", "enum": [ "en", - "zh-CN" + "zh-CN", + "zh-TW" ] }, "analyticsEnabled": { @@ -1315,7 +1317,8 @@ "nullable": true, "enum": [ "en", - "zh-CN" + "zh-CN", + "zh-TW" ] }, "analyticsEnabled": { @@ -11691,4 +11694,4 @@ } } } -} \ No newline at end of file +} diff --git a/apps/controller/src/routes/channel-routes.ts b/apps/controller/src/routes/channel-routes.ts index a8084c011..745d0d61e 100644 --- a/apps/controller/src/routes/channel-routes.ts +++ b/apps/controller/src/routes/channel-routes.ts @@ -35,7 +35,7 @@ import type { ControllerBindings } from "../types.js"; const channelIdParamSchema = z.object({ channelId: z.string() }); const errorSchema = z.object({ message: z.string() }); -type ControllerLocale = "en" | "zh-CN"; +type ControllerLocale = "en" | "zh-CN" | "zh-TW"; function getOpenclawOrigin(container: ControllerContainer): string | null { try { @@ -60,12 +60,12 @@ function localizeChannelConnectMessage( locale: ControllerLocale, ): string { if (!isChannelConnectError(error)) { - return locale === "zh-CN" + return locale !== "en" ? "连接失败,请稍后重试。" : "Connection failed. Please try again."; } - if (locale === "zh-CN") { + if (locale !== "en") { switch (error.code) { case "invalid_credentials": return "凭证无效,请检查后重试。"; diff --git a/apps/controller/src/routes/desktop-routes.ts b/apps/controller/src/routes/desktop-routes.ts index 9304a3b4e..e3e61fa85 100644 --- a/apps/controller/src/routes/desktop-routes.ts +++ b/apps/controller/src/routes/desktop-routes.ts @@ -59,13 +59,13 @@ const fallbackEventsQuerySchema = z.object({ }); const desktopPreferencesResponseSchema = z.object({ - locale: z.enum(["en", "zh-CN"]).nullable(), + locale: z.enum(["en", "zh-CN", "zh-TW"]).nullable(), analyticsEnabled: z.boolean(), }); const desktopPreferencesUpdateSchema = z .object({ - locale: z.enum(["en", "zh-CN"]).optional(), + locale: z.enum(["en", "zh-CN", "zh-TW"]).optional(), analyticsEnabled: z.boolean().optional(), }) .refine( @@ -346,7 +346,9 @@ export function registerDesktopRoutes( const message = locale === "en" ? "⏳ Compacting conversation history, estimated ~30s..." - : "⏳ 正在整理对话记录,预计30秒内完成..."; + : locale === "zh-TW" + ? "⏳ 正在整理對話記錄,預計 30 秒內完成..." + : "⏳ 正在整理对话记录,预计30秒内完成..."; try { await container.gatewayService.sendChannelMessage({ diff --git a/apps/controller/src/runtime/credit-guard-state-writer.ts b/apps/controller/src/runtime/credit-guard-state-writer.ts index 28f2ad9c5..a166082ef 100644 --- a/apps/controller/src/runtime/credit-guard-state-writer.ts +++ b/apps/controller/src/runtime/credit-guard-state-writer.ts @@ -5,7 +5,7 @@ import type { ControllerEnv } from "../app/env.js"; export class CreditGuardStateWriter { constructor(private readonly env: ControllerEnv) {} - async write(locale: "en" | "zh-CN"): Promise { + async write(locale: "en" | "zh-CN" | "zh-TW"): Promise { await mkdir(path.dirname(this.env.creditGuardStatePath), { recursive: true, }); diff --git a/apps/controller/src/runtime/slimclaw-runtime-model-writer.ts b/apps/controller/src/runtime/slimclaw-runtime-model-writer.ts index 171ad8684..a2fa3acdd 100644 --- a/apps/controller/src/runtime/slimclaw-runtime-model-writer.ts +++ b/apps/controller/src/runtime/slimclaw-runtime-model-writer.ts @@ -11,12 +11,14 @@ export interface OpenClawRuntimeModelState { } const RUNTIME_MODEL_FALLBACK = "anthropic/claude-opus-4-6"; -export type NoModelMessageLocale = "en" | "zh-CN"; +export type NoModelMessageLocale = "en" | "zh-CN" | "zh-TW"; const NO_MODEL_CONFIGURED_MESSAGES: Record = { en: "No model is available right now. Please sign in to your Nexu Official account, or add your own API key or OAuth provider under Settings → Models to enable a model.", "zh-CN": "当前没有可用的模型。请登录 Nexu 官方账号,或在 设置 → 模型 中配置您自己的 API Key 或 OAuth 服务商以启用模型。", + "zh-TW": + "當前沒有可用的模型。請登入 Nexu 官方帳號,或在「設定 → 模型」中設定你自己的 API Key 或 OAuth 服務商以啟用模型。", }; export const NO_MODEL_CONFIGURED_MESSAGE = NO_MODEL_CONFIGURED_MESSAGES.en; diff --git a/apps/controller/src/services/channel-fallback-service.ts b/apps/controller/src/services/channel-fallback-service.ts index 9cb945365..2d2111879 100644 --- a/apps/controller/src/services/channel-fallback-service.ts +++ b/apps/controller/src/services/channel-fallback-service.ts @@ -50,7 +50,7 @@ export interface ChannelFallbackMessageSender { } export interface ChannelFallbackLocaleProvider { - getLocale(): Promise<"en" | "zh-CN"> | "en" | "zh-CN"; + getLocale(): Promise<"en" | "zh-CN" | "zh-TW"> | "en" | "zh-CN" | "zh-TW"; } export class ChannelFallbackService { @@ -203,7 +203,7 @@ export class ChannelFallbackService { const template = selectFallbackTemplate( adapter.getTemplateMap() as Record< FallbackErrorCode, - Partial> + Partial> >, normalized.errorCode as FallbackErrorCode, lang, diff --git a/apps/controller/src/services/channel-fallback/adapters/feishu-fallback-adapter.ts b/apps/controller/src/services/channel-fallback/adapters/feishu-fallback-adapter.ts index c3a001c45..57916dc28 100644 --- a/apps/controller/src/services/channel-fallback/adapters/feishu-fallback-adapter.ts +++ b/apps/controller/src/services/channel-fallback/adapters/feishu-fallback-adapter.ts @@ -11,22 +11,28 @@ const FEISHU_FALLBACK_TEMPLATES: FallbackTemplateMap = { en: "🤖 Sorry, I can't handle your request right now. Please try again later, or contact the NexU team for support: https://docs.nexu.io/guide/contact", "zh-CN": "🤖 抱歉,我暂时无法处理你的请求,请稍后重试,或联系 NexU 工作人员获取支持:https://docs.nexu.io/zh/guide/contact", + "zh-TW": + "🤖 抱歉,我暫時無法處理你的請求,請稍後再試,或聯絡 NexU 團隊取得支援:https://docs.nexu.io/zh/guide/contact", }, internal_error: { en: "Sorry, I hit an internal error while replying. Please try again in a moment.", "zh-CN": "抱歉,我刚刚回复时遇到内部错误。请稍后再试。", + "zh-TW": "抱歉,我剛剛回覆時遇到內部錯誤。請稍後再試。", }, reply_delivery_failed: { en: "Sorry, I couldn't deliver the previous reply successfully. Please try again in a moment.", "zh-CN": "抱歉,我刚刚没有成功送达上一条回复。请稍后再试。", + "zh-TW": "抱歉,我剛剛沒有成功送達上一條回覆。請稍後再試。", }, no_final_reply: { en: "Sorry, I couldn't finish the previous reply. Please try again in a moment.", "zh-CN": "抱歉,我刚刚没有完整完成上一条回复。请稍后再试。", + "zh-TW": "抱歉,我剛剛沒有完整完成上一條回覆。請稍後再試。", }, synthetic_pre_llm_failure: { en: "Sorry, Nexu intentionally interrupted this reply for diagnostics.", "zh-CN": "抱歉,这条回复被 Nexu 为诊断目的主动中断。", + "zh-TW": "抱歉,這條回覆被 Nexu 為了診斷目的主動中斷。", }, }; @@ -94,7 +100,7 @@ export class FeishuFallbackAdapter toSendInput(input: { normalized: NormalizedFallback; - lang: "en" | "zh-CN"; + lang: "en" | "zh-CN" | "zh-TW"; message: string; }) { const message = appendOptionalDiagnosticHint( @@ -118,7 +124,7 @@ function appendOptionalDiagnosticHint( message: string, hint: string | undefined, errorCode: FallbackErrorCode, - lang: "en" | "zh-CN", + lang: "en" | "zh-CN" | "zh-TW", ): string { if (errorCode !== "unknown") { return message; @@ -133,6 +139,9 @@ function appendOptionalDiagnosticHint( case "zh-CN": suffix = `诊断提示:${trimmedHint}`; break; + case "zh-TW": + suffix = `診斷提示:${trimmedHint}`; + break; default: suffix = `Diagnostic hint: ${trimmedHint}`; break; diff --git a/apps/controller/src/services/channel-fallback/core/channel-fallback-types.ts b/apps/controller/src/services/channel-fallback/core/channel-fallback-types.ts index 4a764e6a5..d7fe3c1dc 100644 --- a/apps/controller/src/services/channel-fallback/core/channel-fallback-types.ts +++ b/apps/controller/src/services/channel-fallback/core/channel-fallback-types.ts @@ -1,6 +1,6 @@ import type { SendChannelMessageInput } from "../../openclaw-gateway-service.js"; -export type FallbackLang = "en" | "zh-CN"; +export type FallbackLang = "en" | "zh-CN" | "zh-TW"; export type FallbackErrorCode = | "unknown" diff --git a/apps/controller/src/services/openclaw-sync-service.ts b/apps/controller/src/services/openclaw-sync-service.ts index 5ee52d574..56eb8ed51 100644 --- a/apps/controller/src/services/openclaw-sync-service.ts +++ b/apps/controller/src/services/openclaw-sync-service.ts @@ -372,8 +372,11 @@ export class OpenClawSyncService { // Write locale state for the credit-guard patch in OpenClaw runtime. // Match the controller's own locale default: unset → "en" (not "zh-CN"). const locale = - (config.desktop as Record).locale === "zh-CN" - ? "zh-CN" + (config.desktop as Record).locale === "zh-CN" || + (config.desktop as Record).locale === "zh-TW" + ? ((config.desktop as Record).locale as + | "zh-CN" + | "zh-TW") : "en"; if (runtimeModelRef) { await this.runtimeModelWriter.write(runtimeModelRef); diff --git a/apps/controller/src/store/nexu-config-store.ts b/apps/controller/src/store/nexu-config-store.ts index 953ac9b7d..efe1b353c 100644 --- a/apps/controller/src/store/nexu-config-store.ts +++ b/apps/controller/src/store/nexu-config-store.ts @@ -244,11 +244,16 @@ function readDesktopCloudSessions( ); } -function readDesktopLocale(config: NexuConfig): "en" | "zh-CN" | null { +function readDesktopLocale( + config: NexuConfig, +): "en" | "zh-CN" | "zh-TW" | null { const desktop = config.desktop as Record; if (desktop.locale === "zh-CN") { return "zh-CN"; } + if (desktop.locale === "zh-TW") { + return "zh-TW"; + } if (desktop.locale === "en") { return "en"; } @@ -2024,16 +2029,18 @@ export class NexuConfigStore { return this.getDesktopRewardsStatus(); } - async getStoredDesktopLocale(): Promise<"en" | "zh-CN" | null> { + async getStoredDesktopLocale(): Promise<"en" | "zh-CN" | "zh-TW" | null> { const config = await this.getConfig(); return readDesktopLocale(config); } - async getDesktopLocale(): Promise<"en" | "zh-CN"> { + async getDesktopLocale(): Promise<"en" | "zh-CN" | "zh-TW"> { return (await this.getStoredDesktopLocale()) ?? "en"; } - async setDesktopLocale(locale: "en" | "zh-CN"): Promise<"en" | "zh-CN"> { + async setDesktopLocale( + locale: "en" | "zh-CN" | "zh-TW", + ): Promise<"en" | "zh-CN" | "zh-TW"> { await this.store.update((config) => ({ ...config, desktop: { diff --git a/apps/controller/src/store/schemas.ts b/apps/controller/src/store/schemas.ts index b338f35cc..c90260e38 100644 --- a/apps/controller/src/store/schemas.ts +++ b/apps/controller/src/store/schemas.ts @@ -557,7 +557,7 @@ const nexuConfigObjectSchema = z.object({ .object({ localProfile: z.unknown().optional(), cloud: z.unknown().optional(), - locale: z.enum(["en", "zh-CN"]).optional(), + locale: z.enum(["en", "zh-CN", "zh-TW"]).optional(), analyticsEnabled: z.boolean().optional(), }) .catchall(z.unknown()) diff --git a/apps/controller/static/runtime-plugins/nexu-credit-guard/index.js b/apps/controller/static/runtime-plugins/nexu-credit-guard/index.js index 3e29cf930..0bd2ed59d 100644 --- a/apps/controller/static/runtime-plugins/nexu-credit-guard/index.js +++ b/apps/controller/static/runtime-plugins/nexu-credit-guard/index.js @@ -58,7 +58,11 @@ function loadState() { // ── i18n messages ─────────────────────────────────────────────────── -const CONTACT_LABEL = { "zh-CN": "联系我们", en: "Contact us" }; +const CONTACT_LABEL = { + "zh-CN": "联系我们", + "zh-TW": "聯絡我們", + en: "Contact us", +}; function t(locale, zhMsg, enMsg, contactUrl) { const msg = locale === "en" ? enMsg : zhMsg; diff --git a/apps/web/lib/api/types.gen.ts b/apps/web/lib/api/types.gen.ts index 17162222f..b837cabc0 100644 --- a/apps/web/lib/api/types.gen.ts +++ b/apps/web/lib/api/types.gen.ts @@ -491,7 +491,7 @@ export type GetApiInternalDesktopPreferencesResponses = { * Desktop preferences */ 200: { - locale: 'en' | 'zh-CN'; + locale: 'en' | 'zh-CN' | 'zh-TW'; analyticsEnabled: boolean; }; }; @@ -500,7 +500,7 @@ export type GetApiInternalDesktopPreferencesResponse = GetApiInternalDesktopPref export type PatchApiInternalDesktopPreferencesData = { body: { - locale?: 'en' | 'zh-CN'; + locale?: 'en' | 'zh-CN' | 'zh-TW'; analyticsEnabled?: boolean; }; path?: never; @@ -513,7 +513,7 @@ export type PatchApiInternalDesktopPreferencesResponses = { * Updated desktop preferences */ 200: { - locale: 'en' | 'zh-CN'; + locale: 'en' | 'zh-CN' | 'zh-TW'; analyticsEnabled: boolean; }; }; @@ -3965,4 +3965,4 @@ export type PutApiInternalWorkspaceTemplatesByNameResponse = PutApiInternalWorks export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}); -}; \ No newline at end of file +}; diff --git a/apps/web/src/components/language-switcher.tsx b/apps/web/src/components/language-switcher.tsx index 7da81363d..bc9c5ab34 100644 --- a/apps/web/src/components/language-switcher.tsx +++ b/apps/web/src/components/language-switcher.tsx @@ -38,7 +38,8 @@ export function LanguageSwitcher({ variant = "light", size = "sm" }: Props) { const options: Array<{ value: Locale; label: string }> = [ { value: "en", label: "English" }, - { value: "zh", label: "中文" }, + { value: "zh-CN", label: "简体中文" }, + { value: "zh-TW", label: "繁體中文" }, ]; const currentLabel = diff --git a/apps/web/src/hooks/use-locale.tsx b/apps/web/src/hooks/use-locale.tsx index 0376a4bbb..b217f1b85 100644 --- a/apps/web/src/hooks/use-locale.tsx +++ b/apps/web/src/hooks/use-locale.tsx @@ -13,7 +13,7 @@ import { patchApiInternalDesktopPreferences, } from "../../lib/api/sdk.gen"; -export type Locale = "en" | "zh"; +export type Locale = "en" | "zh-CN" | "zh-TW"; interface LocaleCtx { locale: Locale; @@ -26,12 +26,20 @@ const STORAGE_KEY = "nexu_locale"; function detectDefault(): Locale { try { const stored = localStorage.getItem(STORAGE_KEY); - if (stored === "en" || stored === "zh") return stored; + if (stored === "en" || stored === "zh-CN" || stored === "zh-TW") { + return stored; + } + if (stored === "zh") { + return "zh-CN"; + } } catch { /* ignore */ } const lang = navigator.language || ""; - return lang.startsWith("zh") ? "zh" : "en"; + if (/^zh-(TW|HK|MO)$/i.test(lang) || /Hant/i.test(lang)) { + return "zh-TW"; + } + return lang.startsWith("zh") ? "zh-CN" : "en"; } const LocaleContext = createContext({ @@ -49,7 +57,7 @@ export function LocaleProvider({ children }: { children: ReactNode }) { const userInteractedRef = useRef(false); useEffect(() => { - document.documentElement.lang = locale === "zh" ? "zh-CN" : "en"; + document.documentElement.lang = locale; }, [locale]); useEffect(() => { @@ -94,7 +102,7 @@ export function useLocale() { async function syncDesktopLocale(locale: Locale): Promise { await patchApiInternalDesktopPreferences({ body: { - locale: locale === "zh" ? "zh-CN" : "en", + locale, }, }).catch(() => { // Best-effort sync only; local UI language should still work offline. @@ -118,8 +126,12 @@ async function bootstrapLocale( return; } - if (storedLocale === "en" || storedLocale === "zh-CN") { - const nextLocale = storedLocale === "zh-CN" ? "zh" : "en"; + if ( + storedLocale === "en" || + storedLocale === "zh-CN" || + storedLocale === "zh-TW" + ) { + const nextLocale = storedLocale; await i18n.changeLanguage(nextLocale); setLocaleState(nextLocale); try { diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 4364ba28d..b5287d0a6 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -2,24 +2,34 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import en from "./locales/en"; import zhCN from "./locales/zh-CN"; +import zhTW from "./locales/zh-TW"; const STORAGE_KEY = "nexu_locale"; function detectLocale(): string { try { const stored = localStorage.getItem(STORAGE_KEY); - if (stored === "en" || stored === "zh") return stored; + if (stored === "en" || stored === "zh-CN" || stored === "zh-TW") { + return stored; + } + if (stored === "zh") { + return "zh-CN"; + } } catch { /* ignore */ } const lang = navigator.language || ""; - return lang.startsWith("zh") ? "zh" : "en"; + if (/^zh-(TW|HK|MO)$/i.test(lang) || /Hant/i.test(lang)) { + return "zh-TW"; + } + return lang.startsWith("zh") ? "zh-CN" : "en"; } i18n.use(initReactI18next).init({ resources: { en: { translation: en }, - zh: { translation: zhCN }, + "zh-CN": { translation: zhCN }, + "zh-TW": { translation: zhTW }, }, lng: detectLocale(), fallbackLng: "en", diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts new file mode 100644 index 000000000..f1db14dbd --- /dev/null +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -0,0 +1,7 @@ +import zhCN from "./zh-CN"; + +const zhTW = { + ...zhCN, +} as const; + +export default zhTW; diff --git a/apps/web/src/lib/skill-translations.ts b/apps/web/src/lib/skill-translations.ts index 05db7a0b9..d2a40e573 100644 --- a/apps/web/src/lib/skill-translations.ts +++ b/apps/web/src/lib/skill-translations.ts @@ -110,6 +110,10 @@ const tagTranslationsZh: Record = { }; export function getTagLabel(tag: string, locale: string): string { - if (locale !== "zh") return tag; + if (!isChineseLocale(locale)) return tag; return tagTranslationsZh[tag] ?? tag; } + +function isChineseLocale(locale: string): boolean { + return locale === "zh" || locale.startsWith("zh-"); +} diff --git a/apps/web/src/pages/models.tsx b/apps/web/src/pages/models.tsx index 27c7fa76f..e1d311804 100644 --- a/apps/web/src/pages/models.tsx +++ b/apps/web/src/pages/models.tsx @@ -985,11 +985,17 @@ function _GeneralSettings() {