Compare commits

...

10 Commits

Author SHA1 Message Date
Junyan Qin
763c1a885c perf: url display in webhook dialog 2025-11-20 16:48:06 +08:00
Junyan Qin
dbc09f46f4 perf: provider icon rounded in hovercard 2025-11-20 10:25:29 +08:00
Junyan Qin
cf43f09aff perf: auto refresh logic in market 2025-11-20 10:18:28 +08:00
Copilot
c3c51b0fbf perf: Add "Select All" checkbox to Plugin and MCP Server selection dialogs (#1790)
* Initial plan

* Add "Select All" checkbox to Plugin and MCP Server selection dialogs

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Make "Select All" text clickable by adding onClick handler to container

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-18 17:00:05 +08:00
Duke
8a42daa63f Fix wecom image message send fail issue (#1789)
* Fix wecom image upload issue

* Fix log
2025-11-18 16:02:13 +08:00
Junyan Qin
d91d98c9d4 chore: bump version 4.5.3 2025-11-18 11:31:28 +08:00
Copilot
2e82f2b2d1 fix: plugin pages scroll entire viewport instead of content area only (#1788)
* Initial plan

* Fix scroll behavior in plugin pages - only content areas scroll now

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-18 11:16:41 +08:00
Junyan Qin
f459c7017a chore: update pr template 2025-11-17 16:02:39 +08:00
Copilot
c27ccb8475 feat(web): Add centered empty state messages to pipeline extension dialogs (#1784)
* Initial plan

* feat: add empty state messages in pipeline extension dialogs

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* fix: center empty state messages in dialog content area

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-16 23:37:40 +08:00
Copilot
abb2f7ae05 feat(web): Move Get Help button to account menu (#1782)
* Initial plan

* feat: Move Get Help button to account options menu

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-16 22:44:46 +08:00
18 changed files with 410 additions and 250 deletions

View File

@@ -2,6 +2,17 @@
> 请在此部分填写你实现/解决/优化的内容:
> Summary of what you implemented/solved/optimized:
>
### 更改前后对比截图 / Screenshots
> 请在此部分粘贴更改前后对比截图(可以是界面截图、控制台输出、对话截图等):
> Please paste the screenshots of changes before and after here (can be interface screenshots, console output, conversation screenshots, etc.):
>
> 修改前 / Before:
>
> 修改后 / After:
>
## 检查清单 / Checklist

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.5.2"
version = "4.5.3"
description = "Easy-to-use global IM bot platform designed for LLM era"
readme = "README.md"
license-files = ["LICENSE"]

View File

@@ -1,3 +1,3 @@
"""LangBot - Easy-to-use global IM bot platform designed for LLM era"""
__version__ = '4.5.2'
__version__ = '4.5.3'

View File

@@ -109,14 +109,13 @@ class WecomClient:
async def send_image(self, user_id: str, agent_id: int, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/media/upload?access_token=' + self.access_token
url = self.base_url + '/message/send?access_token=' + self.access_token
async with httpx.AsyncClient() as client:
params = {
'touser': user_id,
'toparty': '',
'totag': '',
'agentid': agent_id,
'msgtype': 'image',
'agentid': agent_id,
'image': {
'media_id': media_id,
},
@@ -125,19 +124,13 @@ class WecomClient:
'enable_duplicate_check': 0,
'duplicate_check_interval': 1800,
}
try:
response = await client.post(url, json=params)
data = response.json()
except Exception as e:
await self.logger.error(f'发送图片失败:{data}')
raise Exception('Failed to send image: ' + str(e))
# 企业微信错误码40014和42001代表accesstoken问题
response = await client.post(url, json=params)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_image(user_id, agent_id, media_id)
if data['errcode'] != 0:
await self.logger.error(f'发送图片失败:{data}')
raise Exception('Failed to send image: ' + str(data))
async def send_private_msg(self, user_id: str, agent_id: int, content: str):

View File

@@ -1,4 +1,6 @@
semantic_version = 'v4.5.2'
import langbot
semantic_version = f'v{langbot.__version__}'
required_database_version = 11
"""Tag the version of the database schema, used to check if the database needs to be migrated"""

View File

@@ -289,12 +289,16 @@ export default function ApiIntegrationDialog({
{t('common.noApiKeys')}
</div>
) : (
<div className="border rounded-md">
<div className="border rounded-md overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.apiKeyValue')}</TableHead>
<TableHead className="min-w-[120px]">
{t('common.name')}
</TableHead>
<TableHead className="min-w-[200px]">
{t('common.apiKeyValue')}
</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
@@ -372,16 +376,20 @@ export default function ApiIntegrationDialog({
{t('common.noWebhooks')}
</div>
) : (
<div className="border rounded-md">
<Table>
<div className="border rounded-md overflow-x-auto max-w-full">
<Table className="table-fixed w-full">
<TableHeader>
<TableRow>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.webhookUrl')}</TableHead>
<TableHead className="w-[150px]">
{t('common.name')}
</TableHead>
<TableHead className="w-[380px]">
{t('common.webhookUrl')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.webhookEnabled')}
</TableHead>
<TableHead className="w-[100px]">
<TableHead className="w-[80px]">
{t('common.actions')}
</TableHead>
</TableRow>
@@ -389,20 +397,30 @@ export default function ApiIntegrationDialog({
<TableBody>
{webhooks.map((webhook) => (
<TableRow key={webhook.id}>
<TableCell>
<div>
<div className="font-medium">{webhook.name}</div>
<TableCell className="truncate">
<div className="truncate">
<div
className="font-medium truncate"
title={webhook.name}
>
{webhook.name}
</div>
{webhook.description && (
<div className="text-sm text-muted-foreground">
<div
className="text-sm text-muted-foreground truncate"
title={webhook.description}
>
{webhook.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded break-all">
{webhook.url}
</code>
<div className="overflow-x-auto max-w-[380px]">
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
{webhook.url}
</code>
</div>
</TableCell>
<TableCell>
<Switch

View File

@@ -240,7 +240,7 @@ export default function DynamicFormItemComponent({
model.requester,
)}
alt="icon"
className="w-8 h-8 rounded-full"
className="w-8 h-8 rounded-[8%]"
/>
<h4 className="font-medium">{model.name}</h4>
</div>

View File

@@ -188,40 +188,6 @@ export default function HomeSidebar({
</div>
)}
<SidebarChild
onClick={() => {
// open docs.langbot.app
const language = localStorage.getItem('langbot_language');
if (language === 'zh-Hans') {
window.open(
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else if (language === 'zh-Hant') {
window.open(
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else {
window.open(
'https://docs.langbot.app/en/insight/guide.html',
'_blank',
);
}
}}
isSelected={false}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM13 13.3551V14H11V12.5C11 11.9477 11.4477 11.5 12 11.5C12.8284 11.5 13.5 10.8284 13.5 10C13.5 9.17157 12.8284 8.5 12 8.5C11.2723 8.5 10.6656 9.01823 10.5288 9.70577L8.56731 9.31346C8.88637 7.70919 10.302 6.5 12 6.5C13.933 6.5 15.5 8.067 15.5 10C15.5 11.5855 14.4457 12.9248 13 13.3551Z"></path>
</svg>
}
name={t('common.helpDocs')}
/>
<SidebarChild
onClick={() => {
setApiKeyDialogOpen(true);
@@ -302,6 +268,41 @@ export default function HomeSidebar({
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-medium">{t('common.account')}</span>
<Button
variant="ghost"
className="w-full justify-start font-normal"
onClick={() => {
// open docs.langbot.app
const language = localStorage.getItem('langbot_language');
if (language === 'zh-Hans') {
window.open(
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else if (language === 'zh-Hant') {
window.open(
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else {
window.open(
'https://docs.langbot.app/en/insight/guide.html',
'_blank',
);
}
setPopoverOpen(false);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4 mr-2"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM13 13.3551V14H11V12.5C11 11.9477 11.4477 11.5 12 11.5C12.8284 11.5 13.5 10.8284 13.5 10C13.5 9.17157 12.8284 8.5 12 8.5C11.2723 8.5 10.6656 9.01823 10.5288 9.70577L8.56731 9.31346C8.88637 7.70919 10.302 6.5 12 6.5C13.933 6.5 15.5 8.067 15.5 10C15.5 11.5855 14.4457 12.9248 13 13.3551Z"></path>
</svg>
{t('common.helpDocs')}
</Button>
<Button
variant="ghost"
className="w-full justify-start font-normal"

View File

@@ -235,7 +235,7 @@ export default function KBForm({
model.requester,
)}
alt="icon"
className="w-8 h-8 rounded-full"
className="w-8 h-8 rounded-[8%]"
/>
<h4 className="font-medium">
{model.name}

View File

@@ -146,6 +146,26 @@ export default function PipelineExtension({
);
};
const handleToggleAllPlugins = () => {
if (tempSelectedPluginIds.length === allPlugins.length) {
// Deselect all
setTempSelectedPluginIds([]);
} else {
// Select all
setTempSelectedPluginIds(allPlugins.map((p) => getPluginId(p)));
}
};
const handleToggleAllMCPServers = () => {
if (tempSelectedMCPIds.length === allMCPServers.length) {
// Deselect all
setTempSelectedMCPIds([]);
} else {
// Select all
setTempSelectedMCPIds(allMCPServers.map((s) => s.uuid || ''));
}
};
const handleConfirmPluginSelection = async () => {
const newSelected = allPlugins.filter((p) =>
tempSelectedPluginIds.includes(getPluginId(p)),
@@ -330,49 +350,74 @@ export default function PipelineExtension({
<DialogHeader>
<DialogTitle>{t('pipelines.extensions.selectPlugins')}</DialogTitle>
</DialogHeader>
{allPlugins.length > 0 && (
<div
className="flex items-center gap-3 px-1 py-2 border-b cursor-pointer"
onClick={handleToggleAllPlugins}
>
<Checkbox
checked={
tempSelectedPluginIds.length === allPlugins.length &&
allPlugins.length > 0
}
onCheckedChange={handleToggleAllPlugins}
/>
<span className="text-sm font-medium">
{t('pipelines.extensions.selectAll')}
</span>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{allPlugins.map((plugin) => {
const pluginId = getPluginId(plugin);
const metadata = plugin.manifest.manifest.metadata;
const isSelected = tempSelectedPluginIds.includes(pluginId);
return (
<div
key={pluginId}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleTogglePlugin(pluginId)}
>
<Checkbox checked={isSelected} />
<img
src={backendClient.getPluginIconURL(
metadata.author || '',
metadata.name,
{allPlugins.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noPluginsInstalled')}
</p>
</div>
) : (
allPlugins.map((plugin) => {
const pluginId = getPluginId(plugin);
const metadata = plugin.manifest.manifest.metadata;
const isSelected = tempSelectedPluginIds.includes(pluginId);
return (
<div
key={pluginId}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleTogglePlugin(pluginId)}
>
<Checkbox checked={isSelected} />
<img
src={backendClient.getPluginIconURL(
metadata.author || '',
metadata.name,
)}
alt={metadata.name}
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
/>
<div className="flex-1">
<div className="font-medium">{metadata.name}</div>
<div className="text-sm text-muted-foreground">
{metadata.author} v{metadata.version}
</div>
<div className="flex gap-1 mt-1">
<PluginComponentList
components={plugin.components}
showComponentName={true}
showTitle={false}
useBadge={true}
t={t}
/>
</div>
</div>
{!plugin.enabled && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
)}
alt={metadata.name}
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
/>
<div className="flex-1">
<div className="font-medium">{metadata.name}</div>
<div className="text-sm text-muted-foreground">
{metadata.author} v{metadata.version}
</div>
<div className="flex gap-1 mt-1">
<PluginComponentList
components={plugin.components}
showComponentName={true}
showTitle={false}
useBadge={true}
t={t}
/>
</div>
</div>
{!plugin.enabled && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
)}
</div>
);
})}
);
})
)}
</div>
<DialogFooter>
<Button
@@ -396,47 +441,74 @@ export default function PipelineExtension({
{t('pipelines.extensions.selectMCPServers')}
</DialogTitle>
</DialogHeader>
{allMCPServers.length > 0 && (
<div
className="flex items-center gap-3 px-1 py-2 border-b cursor-pointer"
onClick={handleToggleAllMCPServers}
>
<Checkbox
checked={
tempSelectedMCPIds.length === allMCPServers.length &&
allMCPServers.length > 0
}
onCheckedChange={handleToggleAllMCPServers}
/>
<span className="text-sm font-medium">
{t('pipelines.extensions.selectAll')}
</span>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{allMCPServers.map((server) => {
const isSelected = tempSelectedMCPIds.includes(server.uuid || '');
return (
<div
key={server.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleToggleMCPServer(server.uuid || '')}
>
<Checkbox checked={isSelected} />
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
<Server className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="font-medium">{server.name}</div>
<div className="text-sm text-muted-foreground">
{server.mode}
{allMCPServers.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noMCPServersConfigured')}
</p>
</div>
) : (
allMCPServers.map((server) => {
const isSelected = tempSelectedMCPIds.includes(
server.uuid || '',
);
return (
<div
key={server.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleToggleMCPServer(server.uuid || '')}
>
<Checkbox checked={isSelected} />
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
<Server className="h-5 w-5 text-muted-foreground" />
</div>
{server.runtime_info &&
server.runtime_info.status === 'connected' && (
<Badge
variant="outline"
className="flex items-center gap-1 mt-1"
>
<Wrench className="h-3 w-3 text-black dark:text-white" />
<span className="text-xs text-black dark:text-white">
{t('pipelines.extensions.toolCount', {
count: server.runtime_info.tool_count || 0,
})}
</span>
</Badge>
)}
<div className="flex-1">
<div className="font-medium">{server.name}</div>
<div className="text-sm text-muted-foreground">
{server.mode}
</div>
{server.runtime_info &&
server.runtime_info.status === 'connected' && (
<Badge
variant="outline"
className="flex items-center gap-1 mt-1"
>
<Wrench className="h-3 w-3 text-black dark:text-white" />
<span className="text-xs text-black dark:text-white">
{t('pipelines.extensions.toolCount', {
count: server.runtime_info.tool_count || 0,
})}
</span>
</Badge>
)}
</div>
{!server.enable && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
)}
</div>
{!server.enable && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
)}
</div>
);
})}
);
})
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setMcpDialogOpen(false)}>

View File

@@ -274,7 +274,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
</Dialog>
{pluginList.length === 0 ? (
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"

View File

@@ -57,6 +57,7 @@ function MarketPageContent({
const pageSize = 16; // 每页16个4行x4列
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
// 排序选项
const sortOptions: SortOption[] = [
@@ -262,120 +263,163 @@ function MarketPageContent({
}
}, [currentPage, isLoadingMore, hasMore, fetchPlugins, searchQuery]);
// 监听滚动事件
// Check if content fills the viewport and load more if needed
const checkAndLoadMore = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer || isLoading || isLoadingMore || !hasMore) return;
const { scrollHeight, clientHeight } = scrollContainer;
// If content doesn't fill the viewport (no scrollbar), load more
if (scrollHeight <= clientHeight) {
loadMore();
}
}, [loadMore, isLoading, isLoadingMore, hasMore]);
// Listen to scroll events on the scroll container
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;
const handleScroll = () => {
if (
window.innerHeight + document.documentElement.scrollTop >=
document.documentElement.offsetHeight - 100
) {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
// Load more when scrolled to within 100px of the bottom
if (scrollTop + clientHeight >= scrollHeight - 100) {
loadMore();
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
scrollContainer.addEventListener('scroll', handleScroll);
return () => scrollContainer.removeEventListener('scroll', handleScroll);
}, [loadMore]);
// Check if we need to load more after content changes or initial load
useEffect(() => {
// Small delay to ensure DOM has updated
const timer = setTimeout(() => {
checkAndLoadMore();
}, 100);
return () => clearTimeout(timer);
}, [plugins, checkAndLoadMore]);
// Also check on window resize
useEffect(() => {
const handleResize = () => {
checkAndLoadMore();
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [checkAndLoadMore]);
// 安装插件
// const handleInstallPlugin = (plugin: PluginV4) => {
// console.log('install plugin', plugin);
// };
return (
<div className="container mx-auto px-3 sm:px-4 py-4 sm:py-6 space-y-4 sm:space-y-6">
{/* 搜索框 */}
<div className="flex items-center justify-center">
<div className="relative w-full max-w-2xl">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder={t('market.searchPlaceholder')}
value={searchQuery}
onChange={(e) => handleSearchInputChange(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
// 立即搜索,清除防抖定时器
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
<div className="h-full flex flex-col">
{/* Fixed header with search and sort controls */}
<div className="flex-shrink-0 space-y-4 px-3 sm:px-4 py-4 sm:py-6">
{/* Search box */}
<div className="flex items-center justify-center">
<div className="relative w-full max-w-2xl">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder={t('market.searchPlaceholder')}
value={searchQuery}
onChange={(e) => handleSearchInputChange(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
// Immediately search, clear debounce timer
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
handleSearch(searchQuery);
}
handleSearch(searchQuery);
}
}}
className="pl-10 pr-4 text-sm sm:text-base"
/>
</div>
</div>
{/* 排序下拉框 */}
<div className="flex items-center justify-center">
<div className="w-full max-w-2xl flex items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.sortBy')}:
</span>
<Select value={sortOption} onValueChange={handleSortChange}>
<SelectTrigger className="w-40 sm:w-48 text-xs sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 搜索结果统计 */}
{total > 0 && (
<div className="text-center text-muted-foreground text-sm">
{searchQuery
? t('market.searchResults', { count: total })
: t('market.totalPlugins', { count: total })}
</div>
)}
{/* 插件列表 */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">{t('market.loading')}</span>
</div>
) : plugins.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">
{searchQuery ? t('market.noResults') : t('market.noPlugins')}
}}
className="pl-10 pr-4 text-sm sm:text-base"
/>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6">
{plugins.map((plugin) => (
<PluginMarketCardComponent
key={plugin.pluginId}
cardVO={plugin}
onPluginClick={handlePluginClick}
/>
))}
</div>
)}
{/* 加载更多指示器 */}
{isLoadingMore && (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="ml-2">{t('market.loadingMore')}</span>
{/* Sort dropdown */}
<div className="flex items-center justify-center">
<div className="w-full max-w-2xl flex items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.sortBy')}:
</span>
<Select value={sortOption} onValueChange={handleSortChange}>
<SelectTrigger className="w-40 sm:w-48 text-xs sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* 没有更多数据提示 */}
{!hasMore && plugins.length > 0 && (
<div className="text-center text-muted-foreground py-6">
{t('market.allLoaded')}
</div>
)}
{/* Search results stats */}
{total > 0 && (
<div className="text-center text-muted-foreground text-sm">
{searchQuery
? t('market.searchResults', { count: total })
: t('market.totalPlugins', { count: total })}
</div>
)}
</div>
{/* 插件详情对话框 */}
{/* Scrollable content area */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto px-3 sm:px-4"
>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">{t('market.loading')}</span>
</div>
) : plugins.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">
{searchQuery ? t('market.noResults') : t('market.noPlugins')}
</div>
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6 pb-6 pt-4">
{plugins.map((plugin) => (
<PluginMarketCardComponent
key={plugin.pluginId}
cardVO={plugin}
onPluginClick={handlePluginClick}
/>
))}
</div>
{/* Loading more indicator */}
{isLoadingMore && (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="ml-2">{t('market.loadingMore')}</span>
</div>
)}
{/* No more data hint */}
{!hasMore && plugins.length > 0 && (
<div className="text-center text-muted-foreground py-6">
{t('market.allLoaded')}
</div>
)}
</>
)}
</div>
{/* Plugin detail dialog */}
<PluginDetailDialog
open={dialogOpen}
onOpenChange={handleDialogClose}

View File

@@ -73,14 +73,14 @@ export default function MCPComponent({
return (
<div className="w-full h-full">
{/* 已安装的服务器列表 */}
<div className="w-full px-[0.8rem] pt-[0rem] gap-4">
{/* Server list */}
<div className="w-full h-full px-[0.8rem] pt-[0rem]">
{loading ? (
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
{t('mcp.loading')}
</div>
) : installedServers.length === 0 ? (
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
@@ -92,7 +92,7 @@ export default function MCPComponent({
<div className="text-lg mb-2">{t('mcp.noServerInstalled')}</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pt-[2rem]">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pt-[2rem] pb-6">
{installedServers.map((server, index) => (
<div key={`${server.name}-${index}`}>
<MCPCardComponent

View File

@@ -431,7 +431,7 @@ export default function PluginConfigPage() {
return (
<div
className={`${styles.pageContainer} ${isDragOver ? 'bg-blue-50' : ''}`}
className={`${styles.pageContainer} h-full flex flex-col ${isDragOver ? 'bg-blue-50' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -443,8 +443,12 @@ export default function PluginConfigPage() {
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<div className="flex flex-row justify-between items-center px-[0.8rem]">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full h-full flex flex-col"
>
<div className="flex flex-row justify-between items-center px-[0.8rem] flex-shrink-0">
<TabsList className="shadow-md py-5 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger value="installed" className="px-6 py-4 cursor-pointer">
{t('plugins.installed')}
@@ -522,10 +526,10 @@ export default function PluginConfigPage() {
</DropdownMenu>
</div>
</div>
<TabsContent value="installed">
<TabsContent value="installed" className="flex-1 overflow-y-auto mt-0">
<PluginInstalledComponent ref={pluginInstalledRef} />
</TabsContent>
<TabsContent value="market">
<TabsContent value="market" className="flex-1 overflow-y-auto mt-0">
<MarketPage
installPlugin={(plugin: PluginV4) => {
setInstallSource('marketplace');
@@ -539,7 +543,10 @@ export default function PluginConfigPage() {
}}
/>
</TabsContent>
<TabsContent value="mcp-servers">
<TabsContent
value="mcp-servers"
className="flex-1 overflow-y-auto mt-0"
>
<MCPServerComponent
key={refreshKey}
onEditServer={(serverName) => {

View File

@@ -482,6 +482,9 @@ const enUS = {
addMCPServer: 'Add MCP Server',
selectMCPServers: 'Select MCP Servers',
toolCount: '{{count}} tools',
noPluginsInstalled: 'No installed plugins',
noMCPServersConfigured: 'No configured MCP servers',
selectAll: 'Select All',
},
debugDialog: {
title: 'Pipeline Chat',

View File

@@ -485,6 +485,9 @@ const jaJP = {
addMCPServer: 'MCPサーバーを追加',
selectMCPServers: 'MCPサーバーを選択',
toolCount: '{{count}}個のツール',
noPluginsInstalled: 'インストールされているプラグインがありません',
noMCPServersConfigured: '設定されているMCPサーバーがありません',
selectAll: 'すべて選択',
},
debugDialog: {
title: 'パイプラインのチャット',

View File

@@ -464,6 +464,9 @@ const zhHans = {
addMCPServer: '添加 MCP 服务器',
selectMCPServers: '选择 MCP 服务器',
toolCount: '{{count}} 个工具',
noPluginsInstalled: '无已安装的插件',
noMCPServersConfigured: '无已配置的 MCP 服务器',
selectAll: '全选',
},
debugDialog: {
title: '流水线对话',

View File

@@ -462,6 +462,9 @@ const zhHant = {
addMCPServer: '新增 MCP 伺服器',
selectMCPServers: '選擇 MCP 伺服器',
toolCount: '{{count}} 個工具',
noPluginsInstalled: '無已安裝的插件',
noMCPServersConfigured: '無已配置的 MCP 伺服器',
selectAll: '全選',
},
debugDialog: {
title: '流程線對話',