Compare commits

...

10 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
Junyan Qin
fee7d48dc3 refactor(web): drop redundant Manual/Scan tabs in model add popover
The model add/scan popover nested a second Manual/Scan tab row inside
the Chat/Embedding/Rerank type tabs. But ProviderCard already opens the
popover from two distinct entry points (Add -> manual, Scan -> scan via
initialMode), so the inner tabs were redundant. Render the manual form
or scan UI directly off `mode` and remove the inner Tabs/TabsList,
leaving a single clean tab row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:36:59 +08:00
Junyan Qin
8811fb647f fix(plugin): call _inspect_plugin_package in marketplace install path
Marketplace plugin install referenced self._extract_deps_metadata,
which no longer exists (renamed to _inspect_plugin_package), raising
AttributeError and failing every plugin install from Space. Use the
current method name; it extracts identity + dependency metadata as
the local-install path already does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:17:01 +08:00
Junyan Qin
37b017459d fix(modelmgr): upsert Space-managed models instead of insert-only
sync_new_models_from_space() skipped any model whose uuid already
existed. LangBot Space reuses a model's uuid across renames/re-specs
(e.g. the uuid that was claude-opus-4-6 later becomes claude-opus-4-7),
so renamed models never propagated locally — the stale local name was
also sent to the models gateway, causing model_not_found at inference.

Now upsert: create new uuids, and for existing models owned by the
Space provider, update name/abilities/ranking to track Space (models
from other providers are left untouched). Logs added/updated counts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:11:26 +08:00
Junyan Qin
4889a3881b chore(release): bump version to 4.10.0
Version-only bump from 4.10.0-beta.3. No release/tag/publish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:26:03 +08:00
21 changed files with 176 additions and 91 deletions

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.10.0-beta.3" version = "4.10.0"
description = "Production-grade platform for building agentic IM bots" description = "Production-grade platform for building agentic IM bots"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"] license-files = ["LICENSE"]

View File

@@ -1,3 +1,3 @@
"""LangBot - Production-grade platform for building agentic IM bots""" """LangBot - Production-grade platform for building agentic IM bots"""
__version__ = '4.10.0-beta.3' __version__ = '4.10.0'

View File

@@ -459,7 +459,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
) )
file_bytes = download_resp.content file_bytes = download_resp.content
self._extract_deps_metadata(file_bytes, task_context) self._inspect_plugin_package(file_bytes, task_context)
file_key = await self.handler.send_file(file_bytes, 'lbpkg') file_key = await self.handler.send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key install_info['plugin_file_key'] = file_key
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime') self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')

View File

@@ -143,49 +143,83 @@ class ModelManager:
# get the latest models from space # get the latest models from space
space_models = await self.ap.space_service.get_models() space_models = await self.ap.space_service.get_models()
exists_llm_models_uuids = [m['uuid'] for m in await self.ap.llm_model_service.get_llm_models()] # Index existing models by uuid. Space reuses a model's uuid across
exists_embedding_models_uuids = [ # renames / re-specs (e.g. the uuid that used to be ``claude-opus-4-6``
m['uuid'] for m in await self.ap.embedding_models_service.get_embedding_models() # may later become ``claude-opus-4-7``). So for Space-managed models we
] # upsert: create when the uuid is new, otherwise update name/abilities/
# ranking to track Space. Models owned by other providers are never
# touched, even on an (unexpected) uuid collision.
existing_llm_models = {m['uuid']: m for m in await self.ap.llm_model_service.get_llm_models()}
existing_embedding_models = {
m['uuid']: m for m in await self.ap.embedding_models_service.get_embedding_models()
}
created = 0
updated = 0
for space_model in space_models: for space_model in space_models:
if space_model.category == 'chat': if space_model.category == 'chat':
uuid = space_model.uuid existing = existing_llm_models.get(space_model.uuid)
if existing is None:
if uuid in exists_llm_models_uuids: # model will be automatically loaded
continue await self.ap.llm_model_service.create_llm_model(
{
# model will be automatically loaded 'uuid': space_model.uuid,
await self.ap.llm_model_service.create_llm_model( 'name': space_model.model_id,
{ 'provider_uuid': space_model_provider.uuid,
'uuid': space_model.uuid, 'abilities': space_model.llm_abilities or [],
'extra_args': {},
'prefered_ranking': space_model.featured_order,
},
preserve_uuid=True,
auto_set_to_default_pipeline=False,
)
created += 1
elif existing.get('provider_uuid') == space_model_provider.uuid:
desired = {
'name': space_model.model_id, 'name': space_model.model_id,
'provider_uuid': space_model_provider.uuid, 'provider_uuid': space_model_provider.uuid,
'abilities': space_model.llm_abilities or [], 'abilities': space_model.llm_abilities or [],
'extra_args': {},
'prefered_ranking': space_model.featured_order, 'prefered_ranking': space_model.featured_order,
}, }
preserve_uuid=True, if (
auto_set_to_default_pipeline=False, existing.get('name') != desired['name']
) or list(existing.get('abilities') or []) != list(desired['abilities'])
or existing.get('prefered_ranking') != desired['prefered_ranking']
):
await self.ap.llm_model_service.update_llm_model(space_model.uuid, dict(desired))
updated += 1
elif space_model.category == 'embedding': elif space_model.category == 'embedding':
uuid = space_model.uuid existing = existing_embedding_models.get(space_model.uuid)
if existing is None:
if uuid in exists_embedding_models_uuids: # model will be automatically loaded
continue await self.ap.embedding_models_service.create_embedding_model(
{
# model will be automatically loaded 'uuid': space_model.uuid,
await self.ap.embedding_models_service.create_embedding_model( 'name': space_model.model_id,
{ 'provider_uuid': space_model_provider.uuid,
'uuid': space_model.uuid, 'extra_args': {},
'prefered_ranking': space_model.featured_order,
},
preserve_uuid=True,
)
created += 1
elif existing.get('provider_uuid') == space_model_provider.uuid:
desired = {
'name': space_model.model_id, 'name': space_model.model_id,
'provider_uuid': space_model_provider.uuid, 'provider_uuid': space_model_provider.uuid,
'extra_args': {},
'prefered_ranking': space_model.featured_order, 'prefered_ranking': space_model.featured_order,
}, }
preserve_uuid=True, if (
) existing.get('name') != desired['name']
or existing.get('prefered_ranking') != desired['prefered_ranking']
):
await self.ap.embedding_models_service.update_embedding_model(space_model.uuid, dict(desired))
updated += 1
if created or updated:
self.ap.logger.info(f'Synced models from LangBot Space: {created} added, {updated} updated.')
async def init_temporary_runtime_llm_model( async def init_temporary_runtime_llm_model(
self, self,

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

2
uv.lock generated
View File

@@ -1899,7 +1899,7 @@ wheels = [
[[package]] [[package]]
name = "langbot" name = "langbot"
version = "4.10.0b3" version = "4.10.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocqhttp" }, { name = "aiocqhttp" },

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

@@ -18,7 +18,7 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ScannedProviderModel } from '@/app/infra/entities/api'; import { ScannedProviderModel } from '@/app/infra/entities/api';
import { import {
@@ -298,20 +298,8 @@ export default function AddModelPopover({
</div> </div>
<div className="overflow-y-auto flex-1 min-h-0"> <div className="overflow-y-auto flex-1 min-h-0">
<Tabs {mode === 'manual' ? (
value={mode} <div className="mt-3">
onValueChange={(v) => setMode(v as 'manual' | 'scan')}
>
{!trigger && (
<TabsList className="grid w-full grid-cols-2 mt-3">
<TabsTrigger value="manual">
{t('models.manualAdd')}
</TabsTrigger>
<TabsTrigger value="scan">{t('models.scanAdd')}</TabsTrigger>
</TabsList>
)}
<TabsContent value="manual" className="mt-3">
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('models.modelName')}</Label> <Label>{t('models.modelName')}</Label>
@@ -390,9 +378,9 @@ export default function AddModelPopover({
</Button> </Button>
</div> </div>
</div> </div>
</TabsContent> </div>
) : (
<TabsContent value="scan" className="space-y-2 mt-0 pt-0"> <div className="space-y-2 mt-3">
{scanLoading ? ( {scanLoading ? (
<div className="flex items-center justify-center py-4"> <div className="flex items-center justify-center py-4">
<RefreshCw className="h-4 w-4 mr-2 animate-spin text-muted-foreground" /> <RefreshCw className="h-4 w-4 mr-2 animate-spin text-muted-foreground" />
@@ -565,8 +553,8 @@ export default function AddModelPopover({
/> />
</Button> </Button>
</div> </div>
</TabsContent> </div>
</Tabs> )}
</div> </div>
</Tabs> </Tabs>
</PopoverContent> </PopoverContent>

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: '載入更多...',