feat(i18n): add zh_Hant and ja_JP translations to all adapter YAML files

- Add zh_Hant (Traditional Chinese) to all 17 adapter YAML metadata and config fields
- Add ja_JP translations to global adapters (Telegram, Discord, Slack, Lark, LINE)
- Fix buggy zh_Hant in line.yaml and slack.yaml (contained simplified Chinese)
- Add zh_Hant field to backend I18nString model
- Add adapter category grouping with locale-aware ordering
- Add webhook Cloud CTA for community edition users
- Fix wizard progress not clearing on skip/complete
This commit is contained in:
Junyan Qin
2026-03-28 19:41:27 +08:00
parent 71e44f0e54
commit 99e2976826
28 changed files with 612 additions and 214 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
IChooseAdapterEntity,
IPipelineEntity,
@@ -35,6 +35,7 @@ import {
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
@@ -47,6 +48,10 @@ import {
} from '@/components/ui/card';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { CustomApiError } from '@/app/infra/entities/common';
import {
groupByCategory,
getCategoryLabel,
} from '@/app/infra/entities/adapter-categories';
const getFormSchema = (t: (key: string) => string) =>
z.object({
@@ -113,6 +118,12 @@ export default function BotForm({
const currentAdapter = form.watch('adapter');
const currentAdapterConfig = form.watch('adapter_config');
// Group adapters by category for the Select dropdown
const groupedAdapters = useMemo(
() => groupByCategory(adapterNameList),
[adapterNameList],
);
// Notify parent when dirty state changes
const { isDirty } = form.formState;
useEffect(() => {
@@ -183,6 +194,7 @@ export default function BotForm({
return {
label: extractI18nObject(item.label),
value: item.name,
categories: item.spec.categories,
};
}),
);
@@ -483,20 +495,31 @@ export default function BotForm({
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{adapterNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(item.value)}
alt=""
className="h-5 w-5 rounded"
/>
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
{groupedAdapters.map((group) => (
<SelectGroup
key={group.categoryId ?? 'uncategorized'}
>
{group.categoryId && (
<SelectLabel>
{getCategoryLabel(t, group.categoryId)}
</SelectLabel>
)}
{group.items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(
item.value,
)}
alt=""
className="h-5 w-5 rounded"
/>
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</FormControl>

View File

@@ -1,6 +1,7 @@
export interface IChooseAdapterEntity {
label: string;
value: string;
categories?: string[];
}
export interface IPipelineEntity {

View File

@@ -0,0 +1,88 @@
import i18n from 'i18next';
/**
* All known adapter category IDs.
*/
export const ADAPTER_CATEGORIES = [
'popular',
'china',
'global',
'protocol',
] as const;
export type AdapterCategoryId = (typeof ADAPTER_CATEGORIES)[number];
/**
* Returns the ordered list of category IDs based on the current locale.
*
* - zh-Hans: popular -> china -> global -> protocol
* - All other locales: popular -> global -> china -> protocol
*
* `popular` is always first.
*/
export function getOrderedCategories(): AdapterCategoryId[] {
const lang = i18n.language;
if (lang === 'zh-Hans') {
return ['popular', 'china', 'global', 'protocol'];
}
return ['popular', 'global', 'china', 'protocol'];
}
/**
* Groups items that have a `categories` string array into ordered category
* buckets. An item can appear in multiple groups if it belongs to multiple
* categories. Items without any recognised category are collected into a
* trailing "uncategorized" group (null key).
*/
export function groupByCategory<T extends { categories?: string[] }>(
items: T[],
): { categoryId: AdapterCategoryId | null; items: T[] }[] {
const ordered = getOrderedCategories();
const buckets = new Map<AdapterCategoryId | null, T[]>();
// Initialise buckets in display order
for (const cat of ordered) {
buckets.set(cat, []);
}
buckets.set(null, []);
for (const item of items) {
const cats = item.categories;
if (!cats || cats.length === 0) {
buckets.get(null)!.push(item);
continue;
}
let placed = false;
for (const cat of cats) {
if (ordered.includes(cat as AdapterCategoryId)) {
buckets.get(cat as AdapterCategoryId)!.push(item);
placed = true;
}
}
if (!placed) {
buckets.get(null)!.push(item);
}
}
// Build result, skipping empty buckets
const result: { categoryId: AdapterCategoryId | null; items: T[] }[] = [];
for (const [categoryId, groupItems] of buckets) {
if (groupItems.length > 0) {
result.push({ categoryId, items: groupItems });
}
}
return result;
}
/**
* Resolve the i18n display name for a category ID using the
* `bots.adapterCategory.*` translation keys.
*/
export function getCategoryLabel(
t: (key: string) => string,
categoryId: AdapterCategoryId | null,
): string {
if (categoryId === null) return '';
return t(`bots.adapterCategory.${categoryId}`);
}

View File

@@ -117,6 +117,7 @@ export interface Adapter {
description: I18nObject;
icon?: string;
spec: {
categories?: string[];
config: IDynamicFormItemSchema[];
};
}

View File

@@ -41,6 +41,10 @@ import {
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
import { extractI18nObject } from '@/i18n/I18nProvider';
import {
groupByCategory,
getCategoryLabel,
} from '@/app/infra/entities/adapter-categories';
import { Button } from '@/components/ui/button';
import {
@@ -490,18 +494,27 @@ export default function WizardPage() {
const [isSkipping, setIsSkipping] = useState(false);
const handleSkipConfirm = useCallback(async () => {
if (systemInfo.wizard_status === 'none') {
setIsSkipping(true);
try {
setIsSkipping(true);
try {
if (systemInfo.wizard_status === 'none') {
await httpClient.updateWizardStatus('skipped');
systemInfo.wizard_status = 'skipped';
} catch {
toast.error(t('wizard.skipSaveError'));
setIsSkipping(false);
return; // Dialog stays open — user can retry
}
// Always clear persisted progress so re-entering starts fresh
await httpClient.saveWizardProgress({
step: 0,
selected_adapter: null,
created_bot_uuid: null,
bot_saved: false,
selected_runner: null,
});
systemInfo.wizard_progress = null;
} catch {
toast.error(t('wizard.skipSaveError'));
setIsSkipping(false);
return;
}
setIsSkipping(false);
setShowSkipConfirm(false);
router.push('/home');
}, [router, t]);
@@ -727,6 +740,14 @@ function StepPlatform({
}) {
const { t } = useTranslation();
const groupedAdapters = useMemo(() => {
const withCategories = adapters.map((a) => ({
...a,
categories: a.spec.categories,
}));
return groupByCategory(withCategories);
}, [adapters]);
return (
<div className="space-y-6 max-w-4xl mx-auto">
<div className="text-center">
@@ -735,45 +756,54 @@ function StepPlatform({
{t('wizard.platform.description')}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{adapters.map((adapter) => (
<Card
key={adapter.name}
className={cn(
'cursor-pointer transition-all hover:shadow-md',
selected === adapter.name
? 'ring-2 ring-primary shadow-md'
: 'hover:border-primary/50',
)}
onClick={() => onSelect(adapter.name)}
>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<img
src={httpClient.getAdapterIconURL(adapter.name)}
alt=""
className="w-10 h-10 rounded-lg shrink-0"
/>
<div className="min-w-0">
<CardTitle className="text-base truncate">
{extractI18nObject(adapter.label)}
</CardTitle>
</div>
{selected === adapter.name && (
<div className="ml-auto shrink-0">
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center">
<Check className="w-3 h-3 text-primary-foreground" />
{groupedAdapters.map((group) => (
<div key={group.categoryId ?? 'uncategorized'} className="space-y-3">
{group.categoryId && (
<h3 className="text-sm font-medium text-muted-foreground">
{getCategoryLabel(t, group.categoryId)}
</h3>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{group.items.map((adapter) => (
<Card
key={adapter.name}
className={cn(
'cursor-pointer transition-all hover:shadow-md',
selected === adapter.name
? 'ring-2 ring-primary shadow-md'
: 'hover:border-primary/50',
)}
onClick={() => onSelect(adapter.name)}
>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<img
src={httpClient.getAdapterIconURL(adapter.name)}
alt=""
className="w-10 h-10 rounded-lg shrink-0"
/>
<div className="min-w-0">
<CardTitle className="text-base truncate">
{extractI18nObject(adapter.label)}
</CardTitle>
</div>
</div>
)}
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground line-clamp-2">
{extractI18nObject(adapter.description)}
</p>
</CardContent>
</Card>
))}
</div>
{selected === adapter.name && (
<div className="ml-auto shrink-0">
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center">
<Check className="w-3 h-3 text-primary-foreground" />
</div>
</div>
)}
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground line-clamp-2">
{extractI18nObject(adapter.description)}
</p>
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
);
}
@@ -1118,18 +1148,27 @@ function StepDone() {
const [isCompleting, setIsCompleting] = useState(false);
const handleBack = useCallback(async () => {
if (systemInfo.wizard_status === 'none') {
setIsCompleting(true);
try {
setIsCompleting(true);
try {
if (systemInfo.wizard_status === 'none') {
await httpClient.updateWizardStatus('completed');
systemInfo.wizard_status = 'completed';
} catch {
toast.error(t('wizard.completeSaveError'));
setIsCompleting(false);
return; // Don't navigate — let user retry
}
// Always clear persisted progress so re-entering starts fresh
await httpClient.saveWizardProgress({
step: 0,
selected_adapter: null,
created_bot_uuid: null,
bot_saved: false,
selected_runner: null,
});
systemInfo.wizard_progress = null;
} catch {
toast.error(t('wizard.completeSaveError'));
setIsCompleting(false);
return;
}
setIsCompleting(false);
router.push('/home/bots');
}, [router, t]);

View File

@@ -321,6 +321,12 @@ const enUS = {
webhookSaasHint:
'Webhook requires a publicly accessible domain. LangBot Cloud provides a ready-to-use public endpoint for your bot.',
webhookSaasLink: 'Learn more about LangBot Cloud',
adapterCategory: {
popular: 'Popular',
china: 'China',
global: 'Global',
protocol: 'Protocol',
},
logLevel: 'Log Level',
allLevels: 'All Levels',
selectLevel: 'Select Level',

View File

@@ -326,6 +326,12 @@
webhookSaasHint:
'Webhook には公開アクセス可能なドメインが必要です。LangBot Cloud では、ボット用のパブリックエンドポイントをすぐにご利用いただけます。',
webhookSaasLink: 'LangBot Cloud の詳細はこちら',
adapterCategory: {
popular: '人気',
china: '中国',
global: 'グローバル',
protocol: 'プロトコル',
},
logLevel: 'ログレベル',
allLevels: 'すべてのレベル',
selectLevel: 'レベルを選択',

View File

@@ -306,6 +306,12 @@ const zhHans = {
webhookSaasHint:
'Webhook 需要公网可访问的域名。LangBot Cloud 为你的机器人提供开箱即用的公网地址。',
webhookSaasLink: '了解 LangBot Cloud',
adapterCategory: {
popular: '热门',
china: '中国',
global: '全球',
protocol: '协议',
},
logLevel: '日志级别',
allLevels: '全部级别',
selectLevel: '选择级别',

View File

@@ -305,6 +305,12 @@ const zhHant = {
webhookSaasHint:
'Webhook 需要公網可存取的網域。LangBot Cloud 為你的機器人提供即開即用的公網位址。',
webhookSaasLink: '了解 LangBot Cloud',
adapterCategory: {
popular: '熱門',
china: '中國',
global: '全球',
protocol: '協定',
},
logLevel: '日誌級別',
allLevels: '全部級別',
selectLevel: '選擇級別',