Compare commits

...

6 Commits

Author SHA1 Message Date
Junyan Qin
101e04db6d feat(web): add Discord link to sidebar account menu
Add a "Join our Discord" entry to the account dropdown's external-links
group, opening https://discord.gg/wdNEHETs87 in a new tab. lucide-react
has no Discord brand glyph, so include a small inline Discord SVG icon
(brand color). Add the joinDiscord label to all 8 locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:26:55 +08:00
Junyan Qin
b79edda3a7 style(web): give extension cards a subtle border
The softened shadow alone left cards with no visible edge against the
page background. Add `border border-border` so each card has a clear,
restrained boundary while keeping the gentle shadow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:49:55 +08:00
Junyan Qin
a20d3d11e5 style(web): soften extension card shadow and hover effect
Reduce the marketplace card box-shadow (4px/0.2 -> 2px/0.06) and the
hover shadow (8px/0.15 -> 5px/0.08, dark proportional) for a more
restrained, understated look.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:45:35 +08:00
Junyan Qin
3b4c455813 fix(web): distinct extension-format icons (plugin/mcp/skill)
The format filter used Wrench/AudioWaveform/Book for plugin/mcp/skill,
which collided with the plugin-component icons (Tool/EventListener/
KnowledgeEngine) shown right below. Switch formats to Puzzle/Server/
Sparkles — matching the canonical getTypeIcon used by the detail badges
— across the market filter, installed filter, install-queue map and
install-progress dialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:34:23 +08:00
Junyan Qin
c967a2aa82 i18n(market): say "extensions" not "plugins" in the marketplace count
The marketplace now lists plugins, MCPs and skills, so the item count
("Total N plugins") read wrong. Update market.totalPlugins and
market.searchResults to "extensions" across all 8 locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:24:10 +08:00
Junyan Qin
79cc6da96f fix(mcp): surface real cause from TaskGroup ExceptionGroups
MCP connection failures were reported as "unhandled errors in a
TaskGroup (1 sub-exception)" because anyio/the MCP client wrap the real
error in an ExceptionGroup and we interpolated its str() directly. Add
_describe_exception() to recurse into ExceptionGroups and surface the
leaf cause (e.g. "httpx.HTTPStatusError: Client error '410 Gone'") in
both the retry warning and the final error_message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:19:18 +08:00
15 changed files with 99 additions and 36 deletions

View File

@@ -240,12 +240,13 @@ class RuntimeMCPSession:
return return
if attempt >= self._MAX_RETRIES: if attempt >= self._MAX_RETRIES:
self.status = MCPSessionStatus.ERROR self.status = MCPSessionStatus.ERROR
self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {e}' self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {self._describe_exception(e)}'
self._ready_event.set() self._ready_event.set()
return return
delay = self._RETRY_DELAYS[attempt] delay = self._RETRY_DELAYS[attempt]
self.ap.logger.warning( self.ap.logger.warning(
f'MCP session {self.server_name} failed (attempt {attempt + 1}), retrying in {delay}s: {e}' f'MCP session {self.server_name} failed (attempt {attempt + 1}), '
f'retrying in {delay}s: {self._describe_exception(e)}'
) )
await self._cleanup_box_stdio_session() await self._cleanup_box_stdio_session()
# Reset status for retry # Reset status for retry
@@ -254,6 +255,30 @@ class RuntimeMCPSession:
self.error_phase = None self.error_phase = None
await asyncio.sleep(delay) await asyncio.sleep(delay)
@staticmethod
def _describe_exception(exc: BaseException) -> str:
"""Flatten an exception into its underlying leaf messages.
anyio / the MCP client wrap real failures in a TaskGroup, whose own
message is the unhelpful "unhandled errors in a TaskGroup (N
sub-exception)". Recurse into ExceptionGroups so the actual cause
(e.g. ``httpx.HTTPStatusError: Client error '410 Gone'``) is surfaced.
"""
leaves: list[str] = []
def visit(e: BaseException) -> None:
sub = getattr(e, 'exceptions', None)
if sub: # ExceptionGroup / BaseExceptionGroup
for child in sub:
visit(child)
else:
leaves.append(f'{type(e).__name__}: {e}')
visit(exc)
seen: set[str] = set()
unique = [m for m in leaves if not (m in seen or seen.add(m))]
return '; '.join(unique) if unique else f'{type(exc).__name__}: {exc}'
_MONITOR_POLL_INTERVAL = 5 _MONITOR_POLL_INTERVAL = 5
_MONITOR_MAX_CONSECUTIVE_ERRORS = 3 _MONITOR_MAX_CONSECUTIVE_ERRORS = 3

View File

@@ -119,6 +119,22 @@ function compareVersions(v1: string, v2: string): boolean {
return false; return false;
} }
// Discord brand glyph (lucide-react has no Discord icon).
function DiscordIcon({ className }: { className?: string }) {
return (
<svg
role="img"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden="true"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
);
}
// IDs of sidebar entries that have collapsible entity sub-items // IDs of sidebar entries that have collapsible entity sub-items
const ENTITY_CATEGORY_IDS = [ const ENTITY_CATEGORY_IDS = [
'bots', 'bots',
@@ -2077,6 +2093,14 @@ export default function HomeSidebar({
</Badge> </Badge>
)} )}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
window.open('https://discord.gg/wdNEHETs87', '_blank');
}}
>
<DiscordIcon className="text-[#5865F2]" />
{t('common.joinDiscord')}
</DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />

View File

@@ -11,7 +11,7 @@ import {
Download, Download,
Package, Package,
Server, Server,
BookOpen, Sparkles,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Loader2, Loader2,
@@ -176,7 +176,7 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
// MCP / Skill don't have the plugin's download + dependency-install stages; // MCP / Skill don't have the plugin's download + dependency-install stages;
// show a single "installing → done/failed" row instead of plugin steps. // show a single "installing → done/failed" row instead of plugin steps.
const isPlugin = task.extensionType === 'plugin'; const isPlugin = task.extensionType === 'plugin';
const simpleIcon = task.extensionType === 'mcp' ? Server : BookOpen; const simpleIcon = task.extensionType === 'mcp' ? Server : Sparkles;
const simpleInstallingLabel = const simpleInstallingLabel =
task.extensionType === 'mcp' task.extensionType === 'mcp'
? t('addExtension.installStage.mcpInstalling') ? t('addExtension.installStage.mcpInstalling')

View File

@@ -9,9 +9,9 @@ import {
Loader2, Loader2,
X, X,
ListTodo, ListTodo,
Wrench, Puzzle,
AudioWaveform, Server,
Book, Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -35,9 +35,9 @@ const STAGE_ICONS: Record<string, React.ElementType> = {
}; };
const EXTENSION_TYPE_ICONS: Record<string, React.ElementType> = { const EXTENSION_TYPE_ICONS: Record<string, React.ElementType> = {
plugin: Wrench, plugin: Puzzle,
mcp: AudioWaveform, mcp: Server,
skill: Book, skill: Sparkles,
}; };
function TaskQueueItem({ function TaskQueueItem({
@@ -54,7 +54,7 @@ function TaskQueueItem({
const isError = task.stage === InstallStage.ERROR; const isError = task.stage === InstallStage.ERROR;
const isRunning = !isDone && !isError; const isRunning = !isDone && !isError;
const StageIcon = STAGE_ICONS[task.stage] || Download; const StageIcon = STAGE_ICONS[task.stage] || Download;
const TypeIcon = EXTENSION_TYPE_ICONS[task.extensionType] || Wrench; const TypeIcon = EXTENSION_TYPE_ICONS[task.extensionType] || Puzzle;
const getTypeBadgeClass = () => { const getTypeBadgeClass = () => {
switch (task.extensionType) { switch (task.extensionType) {

View File

@@ -21,8 +21,7 @@ import { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask'; import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { Loader2, Puzzle } from 'lucide-react'; import { Loader2, Puzzle, Server, Sparkles } from 'lucide-react';
import { Wrench, AudioWaveform, Book } from 'lucide-react';
export interface PluginInstalledComponentRef { export interface PluginInstalledComponentRef {
refreshPluginList: () => void; refreshPluginList: () => void;
@@ -44,14 +43,18 @@ export const FilterOptions = [
{ {
value: 'plugin' as FilterType, value: 'plugin' as FilterType,
labelKey: 'market.typePlugin', labelKey: 'market.typePlugin',
icon: Wrench, icon: Puzzle,
}, },
{ {
value: 'mcp' as FilterType, value: 'mcp' as FilterType,
labelKey: 'market.typeMCP', labelKey: 'market.typeMCP',
icon: AudioWaveform, icon: Server,
},
{
value: 'skill' as FilterType,
labelKey: 'market.typeSkill',
icon: Sparkles,
}, },
{ value: 'skill' as FilterType, labelKey: 'market.typeSkill', icon: Book },
]; ];
interface PluginInstalledComponentProps { interface PluginInstalledComponentProps {

View File

@@ -17,6 +17,9 @@ import { Separator } from '@/components/ui/separator';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { import {
Search, Search,
Puzzle,
Server,
Sparkles,
Wrench, Wrench,
AudioWaveform, AudioWaveform,
Hash, Hash,
@@ -88,9 +91,9 @@ function MarketPageContent({
const extensionTypeOptions = [ const extensionTypeOptions = [
{ value: 'all', label: t('market.filters.allFormats'), icon: null }, { value: 'all', label: t('market.filters.allFormats'), icon: null },
{ value: 'plugin', label: t('market.typePlugin'), icon: Wrench }, { value: 'plugin', label: t('market.typePlugin'), icon: Puzzle },
{ value: 'mcp', label: t('market.typeMCP'), icon: AudioWaveform }, { value: 'mcp', label: t('market.typeMCP'), icon: Server },
{ value: 'skill', label: t('market.typeSkill'), icon: Book }, { value: 'skill', label: t('market.typeSkill'), icon: Sparkles },
]; ];
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');

View File

@@ -94,7 +94,7 @@ export default function PluginMarketCardComponent({
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label={t('market.installCard', { name: cardVO.label })} aria-label={t('market.installCard', { name: cardVO.label })}
className="w-[100%] h-[10rem] cursor-pointer bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-[#1f1f22] dark:shadow-[0px_0px_4px_0_rgba(255,255,255,0.1)] dark:hover:shadow-[0px_2px_8px_0_rgba(255,255,255,0.15)] relative" className="w-[100%] h-[10rem] cursor-pointer bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] transition-shadow duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)] relative"
onClick={handleInstallClick} onClick={handleInstallClick}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {

View File

@@ -37,6 +37,7 @@ const enUS = {
helpDocs: 'Get Help', helpDocs: 'Get Help',
featureRequest: 'Feature Request', featureRequest: 'Feature Request',
starOnGitHub: 'Star on GitHub', starOnGitHub: 'Star on GitHub',
joinDiscord: 'Join our Discord',
create: 'Create', create: 'Create',
edit: 'Edit', edit: 'Edit',
delete: 'Delete', delete: 'Delete',
@@ -631,8 +632,8 @@ const enUS = {
}, },
market: { market: {
searchPlaceholder: 'Search plugins...', searchPlaceholder: 'Search plugins...',
searchResults: 'Found {{count}} plugins', searchResults: 'Found {{count}} extensions',
totalPlugins: 'Total {{count}} plugins', totalPlugins: 'Total {{count}} extensions',
noPlugins: 'No plugins available', noPlugins: 'No plugins available',
noResults: 'No relevant plugins found', noResults: 'No relevant plugins found',
loadingMore: 'Loading more...', loadingMore: 'Loading more...',

View File

@@ -40,6 +40,7 @@ const esES = {
helpDocs: 'Obtener ayuda', helpDocs: 'Obtener ayuda',
featureRequest: 'Solicitar función', featureRequest: 'Solicitar función',
starOnGitHub: 'Dar estrella en GitHub', starOnGitHub: 'Dar estrella en GitHub',
joinDiscord: 'Únete a Discord',
create: 'Crear', create: 'Crear',
edit: 'Editar', edit: 'Editar',
delete: 'Eliminar', delete: 'Eliminar',
@@ -644,8 +645,8 @@ const esES = {
}, },
market: { market: {
searchPlaceholder: 'Buscar plugins...', searchPlaceholder: 'Buscar plugins...',
searchResults: 'Se encontraron {{count}} plugins', searchResults: 'Se encontraron {{count}} extensiones',
totalPlugins: 'Total {{count}} plugins', totalPlugins: 'Total {{count}} extensiones',
noPlugins: 'No hay plugins disponibles', noPlugins: 'No hay plugins disponibles',
noResults: 'No se encontraron plugins relevantes', noResults: 'No se encontraron plugins relevantes',
loadingMore: 'Cargando más...', loadingMore: 'Cargando más...',

View File

@@ -38,6 +38,7 @@ const jaJP = {
helpDocs: 'ヘルプドキュメント', helpDocs: 'ヘルプドキュメント',
featureRequest: '機能リクエスト', featureRequest: '機能リクエスト',
starOnGitHub: 'GitHubでStarする', starOnGitHub: 'GitHubでStarする',
joinDiscord: 'Discord に参加',
create: '作成', create: '作成',
edit: '編集', edit: '編集',
delete: '削除', delete: '削除',
@@ -636,8 +637,8 @@ const jaJP = {
}, },
market: { market: {
searchPlaceholder: 'プラグインを検索...', searchPlaceholder: 'プラグインを検索...',
searchResults: '{{count}} 個のプラグインが見つかりました', searchResults: '{{count}} 個の拡張機能が見つかりました',
totalPlugins: '合計 {{count}} 個のプラグイン', totalPlugins: '合計 {{count}} 個の拡張機能',
noPlugins: '利用可能なプラグインがありません', noPlugins: '利用可能なプラグインがありません',
noResults: '関連するプラグインが見つかりません', noResults: '関連するプラグインが見つかりません',
loadingMore: 'さらに読み込み中...', loadingMore: 'さらに読み込み中...',

View File

@@ -38,6 +38,7 @@ const ruRU = {
helpDocs: 'Помощь', helpDocs: 'Помощь',
featureRequest: 'Запрос функции', featureRequest: 'Запрос функции',
starOnGitHub: 'Поставить звезду на GitHub', starOnGitHub: 'Поставить звезду на GitHub',
joinDiscord: 'Присоединиться к Discord',
create: 'Создать', create: 'Создать',
edit: 'Редактировать', edit: 'Редактировать',
delete: 'Удалить', delete: 'Удалить',
@@ -642,8 +643,8 @@ const ruRU = {
}, },
market: { market: {
searchPlaceholder: 'Поиск плагинов...', searchPlaceholder: 'Поиск плагинов...',
searchResults: 'Найдено {{count}} плагинов', searchResults: 'Найдено {{count}} расширений',
totalPlugins: 'Всего {{count}} плагинов', totalPlugins: 'Всего {{count}} расширений',
noPlugins: 'Нет доступных плагинов', noPlugins: 'Нет доступных плагинов',
noResults: 'Подходящие плагины не найдены', noResults: 'Подходящие плагины не найдены',
loadingMore: 'Загрузка ещё...', loadingMore: 'Загрузка ещё...',

View File

@@ -37,6 +37,7 @@ const thTH = {
helpDocs: 'ขอความช่วยเหลือ', helpDocs: 'ขอความช่วยเหลือ',
featureRequest: 'ขอฟีเจอร์ใหม่', featureRequest: 'ขอฟีเจอร์ใหม่',
starOnGitHub: 'ให้ดาวบน GitHub', starOnGitHub: 'ให้ดาวบน GitHub',
joinDiscord: 'เข้าร่วม Discord',
create: 'สร้าง', create: 'สร้าง',
edit: 'แก้ไข', edit: 'แก้ไข',
delete: 'ลบ', delete: 'ลบ',
@@ -623,8 +624,8 @@ const thTH = {
}, },
market: { market: {
searchPlaceholder: 'ค้นหาปลั๊กอิน...', searchPlaceholder: 'ค้นหาปลั๊กอิน...',
searchResults: 'พบ {{count}} ปลั๊กอิน', searchResults: 'พบ {{count}} ส่วนขยาย',
totalPlugins: 'ทั้งหมด {{count}} ปลั๊กอิน', totalPlugins: 'ทั้งหมด {{count}} ส่วนขยาย',
noPlugins: 'ไม่มีปลั๊กอินที่พร้อมใช้งาน', noPlugins: 'ไม่มีปลั๊กอินที่พร้อมใช้งาน',
noResults: 'ไม่พบปลั๊กอินที่เกี่ยวข้อง', noResults: 'ไม่พบปลั๊กอินที่เกี่ยวข้อง',
loadingMore: 'กำลังโหลดเพิ่มเติม...', loadingMore: 'กำลังโหลดเพิ่มเติม...',

View File

@@ -38,6 +38,7 @@ const viVN = {
helpDocs: 'Trợ giúp', helpDocs: 'Trợ giúp',
featureRequest: 'Yêu cầu tính năng', featureRequest: 'Yêu cầu tính năng',
starOnGitHub: 'Star trên GitHub', starOnGitHub: 'Star trên GitHub',
joinDiscord: 'Tham gia Discord',
create: 'Tạo', create: 'Tạo',
edit: 'Chỉnh sửa', edit: 'Chỉnh sửa',
delete: 'Xóa', delete: 'Xóa',
@@ -637,8 +638,8 @@ const viVN = {
}, },
market: { market: {
searchPlaceholder: 'Tìm kiếm plugin...', searchPlaceholder: 'Tìm kiếm plugin...',
searchResults: 'Tìm thấy {{count}} plugin', searchResults: 'Tìm thấy {{count}} tiện ích mở rộng',
totalPlugins: 'Tổng cộng {{count}} plugin', totalPlugins: 'Tổng cộng {{count}} tiện ích mở rộng',
noPlugins: 'Không có plugin nào', noPlugins: 'Không có plugin nào',
noResults: 'Không tìm thấy plugin liên quan', noResults: 'Không tìm thấy plugin liên quan',
loadingMore: 'Đang tải thêm...', loadingMore: 'Đang tải thêm...',

View File

@@ -36,6 +36,7 @@ const zhHans = {
helpDocs: '帮助文档', helpDocs: '帮助文档',
featureRequest: '需求建议', featureRequest: '需求建议',
starOnGitHub: '在 GitHub 上 Star', starOnGitHub: '在 GitHub 上 Star',
joinDiscord: '加入 Discord 社区',
create: '创建', create: '创建',
edit: '编辑', edit: '编辑',
delete: '删除', delete: '删除',
@@ -605,8 +606,8 @@ const zhHans = {
}, },
market: { market: {
searchPlaceholder: '搜索插件...', searchPlaceholder: '搜索插件...',
searchResults: '搜索到 {{count}} 个插件', searchResults: '搜索到 {{count}} 个扩展',
totalPlugins: '共 {{count}} 个插件', totalPlugins: '共 {{count}} 个扩展',
noPlugins: '暂无插件', noPlugins: '暂无插件',
noResults: '未找到相关插件', noResults: '未找到相关插件',
loadingMore: '加载更多...', loadingMore: '加载更多...',

View File

@@ -36,6 +36,7 @@ const zhHant = {
helpDocs: '輔助說明', helpDocs: '輔助說明',
featureRequest: '需求建議', featureRequest: '需求建議',
starOnGitHub: '在 GitHub 上 Star', starOnGitHub: '在 GitHub 上 Star',
joinDiscord: '加入 Discord 社群',
create: '建立', create: '建立',
edit: '編輯', edit: '編輯',
delete: '刪除', delete: '刪除',
@@ -605,8 +606,8 @@ const zhHant = {
}, },
market: { market: {
searchPlaceholder: '搜尋插件...', searchPlaceholder: '搜尋插件...',
searchResults: '搜尋到 {{count}} 個插件', searchResults: '搜尋到 {{count}} 個擴展',
totalPlugins: '共 {{count}} 個插件', totalPlugins: '共 {{count}} 個擴展',
noPlugins: '暫無插件', noPlugins: '暫無插件',
noResults: '未找到相關插件', noResults: '未找到相關插件',
loadingMore: '載入更多...', loadingMore: '載入更多...',