mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-12 00:36:03 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface IChooseAdapterEntity {
|
||||
label: string;
|
||||
value: string;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
export interface IPipelineEntity {
|
||||
|
||||
88
web/src/app/infra/entities/adapter-categories.ts
Normal file
88
web/src/app/infra/entities/adapter-categories.ts
Normal 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}`);
|
||||
}
|
||||
@@ -117,6 +117,7 @@ export interface Adapter {
|
||||
description: I18nObject;
|
||||
icon?: string;
|
||||
spec: {
|
||||
categories?: string[];
|
||||
config: IDynamicFormItemSchema[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -326,6 +326,12 @@
|
||||
webhookSaasHint:
|
||||
'Webhook には公開アクセス可能なドメインが必要です。LangBot Cloud では、ボット用のパブリックエンドポイントをすぐにご利用いただけます。',
|
||||
webhookSaasLink: 'LangBot Cloud の詳細はこちら',
|
||||
adapterCategory: {
|
||||
popular: '人気',
|
||||
china: '中国',
|
||||
global: 'グローバル',
|
||||
protocol: 'プロトコル',
|
||||
},
|
||||
logLevel: 'ログレベル',
|
||||
allLevels: 'すべてのレベル',
|
||||
selectLevel: 'レベルを選択',
|
||||
|
||||
@@ -306,6 +306,12 @@ const zhHans = {
|
||||
webhookSaasHint:
|
||||
'Webhook 需要公网可访问的域名。LangBot Cloud 为你的机器人提供开箱即用的公网地址。',
|
||||
webhookSaasLink: '了解 LangBot Cloud',
|
||||
adapterCategory: {
|
||||
popular: '热门',
|
||||
china: '中国',
|
||||
global: '全球',
|
||||
protocol: '协议',
|
||||
},
|
||||
logLevel: '日志级别',
|
||||
allLevels: '全部级别',
|
||||
selectLevel: '选择级别',
|
||||
|
||||
@@ -305,6 +305,12 @@ const zhHant = {
|
||||
webhookSaasHint:
|
||||
'Webhook 需要公網可存取的網域。LangBot Cloud 為你的機器人提供即開即用的公網位址。',
|
||||
webhookSaasLink: '了解 LangBot Cloud',
|
||||
adapterCategory: {
|
||||
popular: '熱門',
|
||||
china: '中國',
|
||||
global: '全球',
|
||||
protocol: '協定',
|
||||
},
|
||||
logLevel: '日誌級別',
|
||||
allLevels: '全部級別',
|
||||
selectLevel: '選擇級別',
|
||||
|
||||
Reference in New Issue
Block a user