Compare commits

..

25 Commits

Author SHA1 Message Date
Junyan Qin
4011a302af chore: bump version 4.6.0b2 for testing 2025-11-16 19:28:52 +08:00
Junyan Qin
deb725a2e2 fix: send adapters and requesters icons 2025-11-16 19:26:30 +08:00
Junyan Qin
33eb866660 chore: add templates/** 2025-11-16 19:20:43 +08:00
Junyan Qin
34e2fa03ce chore: bump version 4.6.0-beta.1 for testing 2025-11-16 19:11:02 +08:00
Junyan Qin
7b63bcdc39 ci: publish pypi 2025-11-16 19:09:24 +08:00
Junyan Qin
d26e81620d fix: tests 2025-11-16 18:39:45 +08:00
Junyan Qin
e7885539a7 fix: read default-pipeline-config.json 2025-11-16 18:13:10 +08:00
Junyan Qin
f216505237 fix: read default-pipeline-config.json 2025-11-16 18:12:29 +08:00
Junyan Qin
8b11eefd0c Merge branch 'master' into copilot/create-langbot-python-package 2025-11-16 17:50:37 +08:00
Junyan Qin
418cddd657 chore: fix imports 2025-11-16 17:44:18 +08:00
Junyan Qin
75edeb7a01 chore: adjust dir structure 2025-11-16 16:28:04 +08:00
Junyan Qin
c5aa5be4d8 chore: update 2025-11-07 23:19:51 +08:00
Junyan Qin
92614062cc chore: update 2025-11-07 23:10:57 +08:00
Junyan Qin
09307d8c6d chore: update 2025-11-07 23:04:49 +08:00
Junyan Qin
894db240ae chore: update 2025-11-07 23:02:50 +08:00
Junyan Qin
f79cde5b0c chore: update 2025-11-07 22:55:33 +08:00
Junyan Qin
d43c2c498c chore: try pack templates in langbot/ 2025-11-07 22:51:30 +08:00
Junyan Qin
5f6036c5a8 chore: update pyproject.toml 2025-11-07 22:19:15 +08:00
copilot-swe-agent[bot]
dead0794b1 Simplify package configuration and document behavioral differences
- Removed redundant package-data configuration, relying on MANIFEST.in
- Added documentation about behavioral differences between package and source installation
- Clarified that include-package-data=true uses MANIFEST.in for data files

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-07 14:08:57 +00:00
copilot-swe-agent[bot]
f784bad08b Fix code review issues
- Use specific exception types instead of bare except
- Fix misleading comments about directory levels
- Remove redundant existence check before makedirs with exist_ok=True
- Use context manager for file opening to ensure proper cleanup

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-07 14:06:49 +00:00
copilot-swe-agent[bot]
4e86e1c93d Address code review feedback
- Made package-data configuration more specific to langbot package only
- Improved path detection with caching to avoid repeated file I/O
- Removed sys.path searching which was incorrect for package data
- Removed interactive input() call for non-interactive environment compatibility
- Simplified error messages for version check

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-07 14:04:47 +00:00
copilot-swe-agent[bot]
c0eec966ac Add PyPI installation documentation
- Created PYPI_INSTALLATION.md with detailed installation and usage instructions
- Updated README.md to feature uvx/pip installation as recommended method
- Updated README_EN.md with same changes for English documentation

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-07 14:02:49 +00:00
copilot-swe-agent[bot]
62d6dae4f5 Add PyPI publishing workflow and update license
- Created GitHub Actions workflow to build frontend and publish to PyPI
- Added license field to pyproject.toml to fix deprecation warning
- Updated .gitignore to exclude build artifacts
- Tested package building successfully

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-07 14:01:07 +00:00
copilot-swe-agent[bot]
cab573f3e2 Add package structure and resource path utilities
- Created langbot/ package with __init__.py and __main__.py entry point
- Added paths utility to find frontend and resource files from package installation
- Updated config loading to use resource paths
- Updated frontend serving to use resource paths
- Added MANIFEST.in for package data inclusion
- Updated pyproject.toml with build system and entry points

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-07 13:58:18 +00:00
copilot-swe-agent[bot]
8fe59da302 Initial plan 2025-11-07 13:48:46 +00:00
23 changed files with 274 additions and 438 deletions

View File

@@ -2,17 +2,6 @@
> 请在此部分填写你实现/解决/优化的内容:
> 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

@@ -31,16 +31,25 @@ LangBot 是一个开源的大语言模型原生即时通信机器人开发平台
## 📦 开始使用
#### 快速部署
#### 快速体验(推荐)
使用 `uvx` 一键启动(需要先安装 [uv](https://docs.astral.sh/uv/getting-started/installation/)
使用 `uvx` 一键启动(无需安装
```bash
uvx langbot
```
或使用 `pip` 安装后运行:
```bash
pip install langbot
langbot
```
访问 http://localhost:5300 即可开始使用。
详细文档[PyPI 安装](docs/PYPI_INSTALLATION.md)。
#### Docker Compose 部署
```bash

View File

@@ -25,16 +25,25 @@ LangBot is an open-source LLM native instant messaging robot development platfor
## 📦 Getting Started
#### Quick Start
#### Quick Start (Recommended)
Use `uvx` to start with one command (need to install [uv](https://docs.astral.sh/uv/getting-started/installation/)):
Use `uvx` to start with one command (no installation required):
```bash
uvx langbot
```
Or install with `pip` and run:
```bash
pip install langbot
langbot
```
Visit http://localhost:5300 to start using it.
Detailed documentation [PyPI Installation](docs/PYPI_INSTALLATION.md).
#### Docker Compose Deployment
```bash

View File

@@ -25,16 +25,6 @@ LangBot は、エージェント、RAG、MCP などの LLM アプリケーショ
## 📦 始め方
#### クイックスタート
`uvx` を使用した迅速なデプロイ([uv](https://docs.astral.sh/uv/getting-started/installation/) が必要です):
```bash
uvx langbot
```
http://localhost:5300 にアクセスして使用を開始します。
#### Docker Compose デプロイ
```bash

View File

@@ -27,16 +27,6 @@ LangBot 是一個開源的大語言模型原生即時通訊機器人開發平台
## 📦 開始使用
#### 快速部署
使用 `uvx` 一鍵啟動(需要先安裝 [uv](https://docs.astral.sh/uv/getting-started/installation/)
```bash
uvx langbot
```
訪問 http://localhost:5300 即可開始使用。
#### Docker Compose 部署
```bash

View File

@@ -7,7 +7,6 @@ services:
langbot_plugin_runtime:
image: rockchin/langbot:latest
container_name: langbot_plugin_runtime
platform: linux/amd64 # For Apple Silicon compatibility
volumes:
- ./data/plugins:/app/data/plugins
ports:
@@ -22,7 +21,6 @@ services:
langbot:
image: rockchin/langbot:latest
container_name: langbot
platform: linux/amd64 # For Apple Silicon compatibility
volumes:
- ./data:/app/data
- ./plugins:/app/plugins

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.5.3"
version = "4.6.0-beta.2"
description = "Easy-to-use global IM bot platform designed for LLM era"
readme = "README.md"
license-files = ["LICENSE"]
@@ -63,7 +63,7 @@ dependencies = [
"langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.1.11",
"langbot-plugin==0.1.11b1",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",

View File

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

View File

@@ -109,13 +109,14 @@ 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 + '/message/send?access_token=' + self.access_token
url = self.base_url + '/media/upload?access_token=' + self.access_token
async with httpx.AsyncClient() as client:
params = {
'touser': user_id,
'msgtype': 'image',
'toparty': '',
'totag': '',
'agentid': agent_id,
'msgtype': 'image',
'image': {
'media_id': media_id,
},
@@ -124,13 +125,19 @@ class WecomClient:
'enable_duplicate_check': 0,
'duplicate_check_interval': 1800,
}
response = await client.post(url, json=params)
data = response.json()
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问题
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,6 +1,4 @@
import langbot
semantic_version = f'v{langbot.__version__}'
semantic_version = 'v4.6.0-beta.2'
required_database_version = 11
"""Tag the version of the database schema, used to check if the database needs to be migrated"""

View File

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

View File

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

View File

@@ -188,6 +188,40 @@ 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);
@@ -268,41 +302,6 @@ 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-[8%]"
className="w-8 h-8 rounded-full"
/>
<h4 className="font-medium">
{model.name}

View File

@@ -146,26 +146,6 @@ 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)),
@@ -350,74 +330,49 @@ 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.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>
{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>
)}
</div>
);
})}
</div>
<DialogFooter>
<Button
@@ -441,74 +396,47 @@ 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.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>
<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>
)}
{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}
</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>
);
})}
</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 min-h-[60vh] w-full gap-2">
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"

View File

@@ -57,7 +57,6 @@ function MarketPageContent({
const pageSize = 16; // 每页16个4行x4列
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
// 排序选项
const sortOptions: SortOption[] = [
@@ -263,163 +262,120 @@ 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 = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
// Load more when scrolled to within 100px of the bottom
if (scrollTop + clientHeight >= scrollHeight - 100) {
if (
window.innerHeight + document.documentElement.scrollTop >=
document.documentElement.offsetHeight - 100
) {
loadMore();
}
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => scrollContainer.removeEventListener('scroll', handleScroll);
window.addEventListener('scroll', handleScroll);
return () => window.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="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);
<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);
}
}}
className="pl-10 pr-4 text-sm sm:text-base"
/>
</div>
handleSearch(searchQuery);
}
}}
className="pl-10 pr-4 text-sm sm:text-base"
/>
</div>
{/* 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>
{/* 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 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>
))}
</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>
)}
</>
)}
</SelectContent>
</Select>
</div>
</div>
{/* Plugin detail dialog */}
{/* 搜索结果统计 */}
{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')}
</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>
</div>
)}
{/* 没有更多数据提示 */}
{!hasMore && plugins.length > 0 && (
<div className="text-center text-muted-foreground py-6">
{t('market.allLoaded')}
</div>
)}
{/* 插件详情对话框 */}
<PluginDetailDialog
open={dialogOpen}
onOpenChange={handleDialogClose}

View File

@@ -73,14 +73,14 @@ export default function MCPComponent({
return (
<div className="w-full h-full">
{/* Server list */}
<div className="w-full h-full px-[0.8rem] pt-[0rem]">
{/* 已安装的服务器列表 */}
<div className="w-full px-[0.8rem] pt-[0rem] gap-4">
{loading ? (
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
{t('mcp.loading')}
</div>
) : installedServers.length === 0 ? (
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] 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] pb-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pt-[2rem]">
{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} h-full flex flex-col ${isDragOver ? 'bg-blue-50' : ''}`}
className={`${styles.pageContainer} ${isDragOver ? 'bg-blue-50' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -443,12 +443,8 @@ export default function PluginConfigPage() {
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<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">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<div className="flex flex-row justify-between items-center px-[0.8rem]">
<TabsList className="shadow-md py-5 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger value="installed" className="px-6 py-4 cursor-pointer">
{t('plugins.installed')}
@@ -526,10 +522,10 @@ export default function PluginConfigPage() {
</DropdownMenu>
</div>
</div>
<TabsContent value="installed" className="flex-1 overflow-y-auto mt-0">
<TabsContent value="installed">
<PluginInstalledComponent ref={pluginInstalledRef} />
</TabsContent>
<TabsContent value="market" className="flex-1 overflow-y-auto mt-0">
<TabsContent value="market">
<MarketPage
installPlugin={(plugin: PluginV4) => {
setInstallSource('marketplace');
@@ -543,10 +539,7 @@ export default function PluginConfigPage() {
}}
/>
</TabsContent>
<TabsContent
value="mcp-servers"
className="flex-1 overflow-y-auto mt-0"
>
<TabsContent value="mcp-servers">
<MCPServerComponent
key={refreshKey}
onEditServer={(serverName) => {

View File

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

View File

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

View File

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