mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-05 05:16:03 +00:00
Compare commits
129 Commits
master
...
v4.10.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb79a6df23 | ||
|
|
7cf4e58ed8 | ||
|
|
a39c4d5665 | ||
|
|
34302213ae | ||
|
|
d1ddff9cdb | ||
|
|
e65f851b2a | ||
|
|
2cddc7efad | ||
|
|
a2a9f426fa | ||
|
|
68bd786f39 | ||
|
|
42855cf4cc | ||
|
|
cc072be7f7 | ||
|
|
49064ffc2d | ||
|
|
aa8d53dde6 | ||
|
|
216b1b9f03 | ||
|
|
9f9b112526 | ||
|
|
f7ee2c0961 | ||
|
|
446099ecda | ||
|
|
ec2d21fe63 | ||
|
|
99328cf4c0 | ||
|
|
28c00cb8d1 | ||
|
|
18ad51e21e | ||
|
|
5773e8aa27 | ||
|
|
6351730891 | ||
|
|
d80972417e | ||
|
|
257d9d3a65 | ||
|
|
747ea069aa | ||
|
|
9e62227104 | ||
|
|
971cc3f675 | ||
|
|
651904a5d4 | ||
|
|
bf8b51569f | ||
|
|
e814f359cb | ||
|
|
c1f5ba1927 | ||
|
|
e8c7147d34 | ||
|
|
98a106d3b5 | ||
|
|
ae11bce8b6 | ||
|
|
d5ce3b302e | ||
|
|
656dafb07a | ||
|
|
fd03b202a8 | ||
|
|
d786b3475f | ||
|
|
17ae6950aa | ||
|
|
b9e8827c7f | ||
|
|
77a85c5c23 | ||
|
|
892556da2a | ||
|
|
7145447bcb | ||
|
|
4db0f20dc4 | ||
|
|
a565f3e022 | ||
|
|
e4c674a9f0 | ||
|
|
afc37958c1 | ||
|
|
b73900718a | ||
|
|
3f7031b6f0 | ||
|
|
3db2ddd2c7 | ||
|
|
dd809d36f8 | ||
|
|
6f97877a5a | ||
|
|
14c2da4d29 | ||
|
|
8ff60c5b98 | ||
|
|
46a9ed3da6 | ||
|
|
f3d45eeeab | ||
|
|
fffc862fe6 | ||
|
|
f306c762c8 | ||
|
|
ad9aa39281 | ||
|
|
e412ed5527 | ||
|
|
188511a911 | ||
|
|
58f9ff94d3 | ||
|
|
80911a3d91 | ||
|
|
f9347811b1 | ||
|
|
db135f217f | ||
|
|
fe9aed4ec9 | ||
|
|
f19cd4032d | ||
|
|
e955b3d6e8 | ||
|
|
f196cbc79d | ||
|
|
dfd4ab791e | ||
|
|
e0510bca6b | ||
|
|
2dfd9d5dce | ||
|
|
3e2190a153 | ||
|
|
7e0a1974b6 | ||
|
|
d47803db2c | ||
|
|
7858d17008 | ||
|
|
eaffde0f89 | ||
|
|
b71f690886 | ||
|
|
29eadcb5ab | ||
|
|
5a4ec62b14 | ||
|
|
cbb36139f4 | ||
|
|
cee5e9e0e2 | ||
|
|
7e50063731 | ||
|
|
ec00e49ef1 | ||
|
|
e2d555a945 | ||
|
|
aa40151964 | ||
|
|
f4406cd972 | ||
|
|
1b4107a90a | ||
|
|
c7e8f19f0d | ||
|
|
94da5bf05d | ||
|
|
f6e7983890 | ||
|
|
3340e984ed | ||
|
|
b2ae4a6a82 | ||
|
|
bae6535005 | ||
|
|
fad69c70b6 | ||
|
|
2697d82286 | ||
|
|
a8eb6e6984 | ||
|
|
51fcf26571 | ||
|
|
fd68c16056 | ||
|
|
4b8a8c5e31 | ||
|
|
fcf74c3b6c | ||
|
|
0f00269a08 | ||
|
|
93104a947a | ||
|
|
3f368c5764 | ||
|
|
2911220054 | ||
|
|
63d22b1f8e | ||
|
|
bfeb8315aa | ||
|
|
9e0fa375e9 | ||
|
|
b64a23f9ac | ||
|
|
c095e830c7 | ||
|
|
42fa75331b | ||
|
|
a7664d1665 | ||
|
|
76fbd08680 | ||
|
|
fbe6e145ec | ||
|
|
14057d1722 | ||
|
|
791d052687 | ||
|
|
e8aa7b2e6d | ||
|
|
c802dc8029 | ||
|
|
55fc0caf2b | ||
|
|
6391678fdb | ||
|
|
eaae31edd0 | ||
|
|
15c03fe96b | ||
|
|
86b2d517f2 | ||
|
|
70c56af4ee | ||
|
|
ba7a45713d | ||
|
|
3b3deec080 | ||
|
|
58ec377413 | ||
|
|
7c50aabe65 |
9
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
9
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -10,15 +10,6 @@ body:
|
|||||||
placeholder: 例如:v3.3.0、CentOS x64 Python 3.10.3、Docker
|
placeholder: 例如:v3.3.0、CentOS x64 Python 3.10.3、Docker
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
|
||||||
attributes:
|
|
||||||
label: 部署版本
|
|
||||||
description: 请选择您使用的 LangBot 部署版本。
|
|
||||||
options:
|
|
||||||
- 社区版
|
|
||||||
- 云服务
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 异常情况
|
label: 异常情况
|
||||||
|
|||||||
9
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
9
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
@@ -10,15 +10,6 @@ body:
|
|||||||
placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker"
|
placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
|
||||||
attributes:
|
|
||||||
label: Deployment version
|
|
||||||
description: Please select the LangBot deployment version you are using.
|
|
||||||
options:
|
|
||||||
- Community Edition
|
|
||||||
- Cloud Service
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Exception
|
label: Exception
|
||||||
|
|||||||
16
Dockerfile
16
Dockerfile
@@ -14,22 +14,10 @@ COPY . .
|
|||||||
|
|
||||||
COPY --from=node /app/web/dist ./web/dist
|
COPY --from=node /app/web/dist ./web/dist
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt update \
|
||||||
&& apt-get install -y --no-install-recommends gcc ca-certificates curl gnupg \
|
&& apt install gcc -y \
|
||||||
# Install the Docker CLI (client only) so the optional langbot_box
|
|
||||||
# service can drive the mounted host Docker socket and create sandbox
|
|
||||||
# containers. The same image powers langbot / plugin_runtime / box; only
|
|
||||||
# box uses the client. Arch-aware via dpkg so multi-arch builds work.
|
|
||||||
&& install -m 0755 -d /etc/apt/keyrings \
|
|
||||||
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
|
|
||||||
&& chmod a+r /etc/apt/keyrings/docker.asc \
|
|
||||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" > /etc/apt/sources.list.d/docker.list \
|
|
||||||
&& apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends docker-ce-cli \
|
|
||||||
&& python -m pip install --no-cache-dir uv \
|
&& python -m pip install --no-cache-dir uv \
|
||||||
&& uv sync \
|
&& uv sync \
|
||||||
&& apt-get purge -y --auto-remove curl gnupg \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& touch /.dockerenv
|
&& touch /.dockerenv
|
||||||
|
|
||||||
CMD [ "uv", "run", "--no-sync", "main.py" ]
|
CMD [ "uv", "run", "--no-sync", "main.py" ]
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
# Box 系统架构深度分析
|
# Box 系统架构深度分析
|
||||||
|
|
||||||
> 更新日期: 2026-06-02
|
> 更新日期: 2026-05-19
|
||||||
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
|
||||||
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
> 相关文档: [SaaS 阻塞项](./box-issues.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md)
|
> 相关文档: [问题清单](./box-issues.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -164,7 +163,7 @@ BoxService
|
|||||||
|
|
||||||
### 2.4 policy.py (`pkg/box/policy.py`, 98 行) — 仍是死代码
|
### 2.4 policy.py (`pkg/box/policy.py`, 98 行) — 仍是死代码
|
||||||
|
|
||||||
三层安全策略设计(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy`),全项目无任何导入或调用。详见 [SaaS 阻塞项 S2](./box-issues.md)。
|
三层安全策略设计(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy`),全项目无任何导入或调用。详见 [问题清单 #1](./box-issues.md)。
|
||||||
|
|
||||||
### 2.5 SkillManager (`pkg/skill/manager.py`, 186 行)
|
### 2.5 SkillManager (`pkg/skill/manager.py`, 186 行)
|
||||||
|
|
||||||
@@ -365,7 +364,7 @@ GitHub 安装路径:HTTP 层(`api/http/service/skill.py`)先 `git clone`
|
|||||||
|
|
||||||
`validate_sandbox_security()`: 黑名单校验 host_path,阻止挂载 `/etc`/`/proc`/`/sys`/`/dev`/`/root`/`/boot` 及 Docker/Podman socket。
|
`validate_sandbox_security()`: 黑名单校验 host_path,阻止挂载 `/etc`/`/proc`/`/sys`/`/dev`/`/root`/`/boot` 及 Docker/Podman socket。
|
||||||
|
|
||||||
**已知缺陷**: 根路径 `/` 未拦截,用户 home 目录未拦截,是 denylist 而非 allowlist 策略。详见 [SaaS 阻塞项 S5](./box-issues.md)。
|
**已知缺陷**: 根路径 `/` 未拦截,用户 home 目录未拦截,是 denylist 而非 allowlist 策略。详见 [问题清单 #5](./box-issues.md)。
|
||||||
|
|
||||||
### 3.9 Errors (`box/errors.py`, 33 行)
|
### 3.9 Errors (`box/errors.py`, 33 行)
|
||||||
|
|
||||||
@@ -513,7 +512,7 @@ box:
|
|||||||
# - skill 列表/读取保持只读可用
|
# - skill 列表/读取保持只读可用
|
||||||
# BOX__ENABLED 环境变量可覆盖(统一约定)
|
# BOX__ENABLED 环境变量可覆盖(统一约定)
|
||||||
backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b'
|
backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b'
|
||||||
# 由 box.backend / BOX__BACKEND 选择后端
|
# BOX_BACKEND 环境变量优先级更高
|
||||||
runtime:
|
runtime:
|
||||||
endpoint: '' # 外部 Runtime 的 WS 基地址 'ws://host:5410'
|
endpoint: '' # 外部 Runtime 的 WS 基地址 'ws://host:5410'
|
||||||
# 留空 = 本地自管 Runtime
|
# 留空 = 本地自管 Runtime
|
||||||
|
|||||||
@@ -1,76 +1,157 @@
|
|||||||
# Box 系统 — SaaS 发布前阻塞项
|
# Box 系统架构问题清单
|
||||||
|
|
||||||
> 更新日期: 2026-06-02
|
> 更新日期: 2026-05-19
|
||||||
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
> 相关文档: [架构分析](./box-architecture.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md)
|
|
||||||
|
|
||||||
## 范围说明
|
|
||||||
|
|
||||||
**自部署社区版已具备发布条件**:默认 stdio 模式、box 为可选项;box 关闭 / 不可用时后端、前端、工具、skill、stdio-MCP 均能干净降级(清晰报错、不崩溃);配置向后兼容(旧 `data/config.yaml` 可直接启动);无新增 ORM 模型、无迁移欠债;市场安装失败不会破坏实例。CI 全绿。
|
|
||||||
|
|
||||||
本清单**只保留发布 SaaS / 多租户 / 公网暴露前必须处理的阻塞项**。社区版(可信、单运营者、内网)不受这些项阻塞——它们的风险面在"不可信调用方能直接触达 Box 控制面"或"多租户共享资源"的场景才成立。
|
|
||||||
|
|
||||||
## 已解决(社区版发布前)
|
|
||||||
|
|
||||||
| 项 | 处理 |
|
|
||||||
|----|------|
|
|
||||||
| 工具调用循环无上限 (原 #13) | `localagent.py` 增加 `MAX_TOOL_CALL_ROUNDS=128`,超限优雅终止(`cafef1a3`) |
|
|
||||||
| 配额校验同步遍历阻塞事件循环 (原 #10) | `_enforce_workspace_quota` 改 async,工作区遍历走 `asyncio.to_thread`(`cafef1a3`) |
|
|
||||||
| `host_path` 挂载白名单 (原 #3 的 LangBot 侧) | `pkg/box/service.py` `allowed_mount_roots` 白名单,空列表时拒绝一切宿主挂载 |
|
|
||||||
| 重复的 `_is_path_under` (原 #12) | 已去重,仅保留一处定义 |
|
|
||||||
| 重连 / 心跳 / Windows 兼容 / nsjail image 字段 / 前端 Box 状态接入 | 见上一轮 review 记录,均已合入 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## SaaS 阻塞项
|
## 已解决(自上一轮 review)
|
||||||
|
|
||||||
### S1. Box 控制面无认证 — Critical
|
下列原 P0/P1 项在最新分支已被修复,仅作记录:
|
||||||
|
|
||||||
- **位置**: SDK `box/server.py` — Action RPC WS (`/rpc/ws`) 与 managed-process relay (`/v1/sessions/{id}/managed-process/{pid}/ws`)
|
| 原编号 | 问题 | 处理 commit / 说明 |
|
||||||
- **现状**: 两个 WS handler 在 `ws.prepare` 后直接服务,无任何 token / 鉴权;box 默认绑定 `0.0.0.0:5410`。任何能触达该端口者可发起 `EXEC`、创建 session、attach 任意 session 的 managed-process stdin/stdout、甚至 `SHUTDOWN`。LangBot→box 的 INIT 也未下发任何凭证。
|
|--------|------|---------------------|
|
||||||
- **缓解现状**: 默认 `docker-compose.yaml` 的 `langbot_box` 未把 5410 发布到宿主(爆炸半径限于内网 bridge);但 box 挂载了 `/var/run/docker.sock`,同网络的任意服务(含被攻破的插件)→ 宿主 root。若运营者把 5410 发布到宿主或独立以 `0.0.0.0` 起 box,则完全裸奔。
|
| #3 | Box 无重连机制 | `_make_connection_callback` 已接入 `runtime_disconnect_callback`;`BoxService._reconnect_loop()` 实现指数退避重连 (`2dfd9d5d`、`c6882cf`) |
|
||||||
- **要求**: INIT 时下发 token,两个 WS 路由按连接校验(query/header)。这是 SaaS 的**头号**阻塞项。
|
| #4 | Box 无心跳 | `BoxRuntimeConnector._heartbeat_loop()`,间隔 20s(沿用 Plugin 模式) |
|
||||||
|
| #10 | Windows 兼容 | connector 增加 Windows 分支 (subprocess + WS),backend 适配 Windows Docker (`120817a`、`fafb7a4`) |
|
||||||
|
| #12 | nsjail image 字段冲突 | `_assert_session_compatible()` 在不支持自定义镜像的 backend 跳过 image 字段 |
|
||||||
|
| #22 | 前端无 Box UI | 监控页 `SystemStatusCards.tsx` 已接入 `/api/v1/box/status`;Skill 管理页接入了全部 skill API(sessions/errors API 仍未接入) |
|
||||||
|
|
||||||
### S2. 无 exec 授权模型(policy.py 死代码) — High
|
---
|
||||||
|
|
||||||
- **位置**: LangBot `pkg/box/policy.py`(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy` 全项目无引用);`pkg/provider/tools/loaders/native.py`;`pkg/provider/tools/toolmgr.py`
|
## P0 — 合并前建议修复
|
||||||
- **现状**: 原生工具(`exec/read/write/edit/glob/grep`)按"box 是否可用"全有或全无地暴露,**无 per-pipeline 的 exec 网关 / 工具白名单 / 沙箱模式 / 权限提升控制**。只要 box 可用,任何使用 local-agent + 函数调用模型的 pipeline 都能跑任意 shell。
|
|
||||||
- **要求**: 接入 policy.py(或等价机制),按 pipeline 控制是否暴露 `exec`、可用工具白名单、沙箱网络/只读模式。
|
|
||||||
|
|
||||||
### S3. 会话资源无界(DoS) — High
|
### 1. policy.py 是死代码
|
||||||
|
|
||||||
- **#5 session 数量无上限**: SDK `box/runtime.py` `_get_or_create_session` 的 `_sessions` dict 无容量限制——可变 `session_id` 的恶意调用可无限创建容器,耗尽宿主 CPU/内存/PID/磁盘。
|
- **位置**: `pkg/box/policy.py` (98 行)
|
||||||
- **#8 无定时回收**: 过期 session 仅在 `_get_or_create_session` 时机会性清理,无独立周期任务;一波创建后转静默会永久泄漏容器。
|
- **现状**: `SandboxPolicy`、`ToolPolicy`、`ElevatedPolicy` 三个类已定义,但全项目无任何导入或调用
|
||||||
- **要求**: `max_sessions` 上限(拒绝或 LRU),加独立周期 reaper(如 60s)。
|
- **影响**: 三层安全策略(沙箱模式 / 工具白名单 / 权限提升)完全未生效。当前实际策略仍是"Box 可用就暴露全部 6 个 native tool,不可用就全部隐藏"
|
||||||
|
- **建议**: 要么删除死代码,要么接入 NativeToolLoader 的工具暴露 / exec 调用链。如果短期不会接入,至少在 `pkg/box/__init__.py` 显式标注其状态
|
||||||
|
|
||||||
### S4. 工作区配额无内核级限制(TOCTOU) — Med-High
|
### 2. WebSocket relay 无认证
|
||||||
|
|
||||||
- **位置**: LangBot `pkg/box/service.py` `_enforce_workspace_quota`(应用层 read-then-check);SDK 侧 `workspace_quota_mb` 仅记录/透传,无 `--storage-opt size=` 等内核/FS 限额
|
- **位置**: SDK `box/server.py` — Action RPC 路径 `/rpc/ws` 与 managed-process relay `/v1/sessions/{id}/managed-process/{pid}/ws`
|
||||||
- **现状**: 执行前后两次检查之间存在竞态窗口;单条命令(`dd`/`fallocate`)可在检查间隙撑爆磁盘,事后检查只能补救。
|
- **现状**: 任何能访问 5410 端口的客户端都可以连接,attach 任意 session 的 managed process stdin/stdout,或直接发起 EXEC
|
||||||
- **要求**: Docker `--storage-opt size=` 做内核级限制,或 Redis 原子计数预留式配额。
|
- **影响**: 容器化 / Docker compose 部署中,若 Box runtime 端口外暴露,网络内的攻击者可直接控制沙箱
|
||||||
|
- **建议**: 至少加 token 认证(INIT 时下发,WS 连接 query string 或 header 校验);多 process 后 attach 面更大,更不能裸奔
|
||||||
|
|
||||||
### S5. 挂载校验缺口 — Med-High
|
### 3. security.py 根路径未拦截
|
||||||
|
|
||||||
- **位置**: SDK `box/security.py` `_BLOCKED_HOST_PATHS_POSIX`;`box/backend.py` 的 `extra_mounts` 处理
|
- **位置**: SDK `box/security.py` `BLOCKED_HOST_PATHS_POSIX`
|
||||||
- **现状**: ① SDK 黑名单仍不含 `/`(前缀匹配,`host_path="/"` 可通过,挂载整个宿主 fs);用户 home、`/usr`、`/opt`、`/tmp` 也未拦截。② `validate_sandbox_security` 只校验 `spec.host_path`,**从不遍历 `spec.extra_mounts`**——LangBot 侧 `allowed_mount_roots` 也只校验 `host_path`。当前 `extra_mounts` 仅由 `build_skill_extra_mounts` 内部填充(agent 不可达),但缺乏纵深防御:一旦 S1 的无认证 RPC 被触达,extra_mounts 可挂任意宿主路径,两层都不拦。
|
- **现状**: 黑名单中没有 `/`,`host_path="/"` 可通过校验并挂载整个主机文件系统;用户 home 目录、`/var` 等也未拦截
|
||||||
- **要求**: SDK 黑名单加入 `/`(或改白名单);`extra_mounts` 在 SDK 与 LangBot 两侧都纳入挂载校验。
|
- **建议**: 将 `/` 加入黑名单,或改用白名单策略与 LangBot 侧 `allowed_mount_roots` 二次拦截
|
||||||
|
|
||||||
### S6. 容器加固缺失 — Med
|
### 4. INIT 与 backend 初始化的竞态
|
||||||
|
|
||||||
- **位置**: SDK `box/backend.py` 的 `docker run` 组装
|
- **位置**: SDK `box/runtime.py` `init()` 在握手后才下发实际配置;`backend` 在 INIT 之前可能已经按默认值实例化
|
||||||
- **现状**: 未设置 `--cap-drop=ALL`、`--security-opt=no-new-privileges`、非 root `--user`;叠加挂载 docker.sock,逃逸面偏大。
|
- **现状**: commit `5029d9c` 修复了 "init config before backend reuse" 的部分场景,但 backend 重新实例化时若有正在执行的 session,可能命中旧 backend
|
||||||
- **要求**: 默认加上上述加固 flag(需回归常用 skill 不被破坏)。
|
- **建议**: 整理 init/handshake 顺序——要么 INIT 完成前不接受任何业务 action,要么允许 backend 配置变更时显式清理现有 session
|
||||||
|
|
||||||
### S7. 全局锁内执行慢操作(扩展性) — Med
|
---
|
||||||
|
|
||||||
- **位置**: SDK `box/runtime.py` `_get_or_create_session`:`self._lock` 持有期间调用 `backend.start_session()`(`docker run` / nsjail 启动 / E2B `Sandbox.create`)
|
## P1 — 合并后优先跟进
|
||||||
- **影响**: 冷启动(镜像拉取数秒、E2B >1s)期间串行阻塞所有并发请求——多租户负载下整个 Box runtime 停顿。降级表现是延迟而非失败。
|
|
||||||
- **要求**: 锁内只做状态检查与注册,容器创建移到锁外。
|
|
||||||
|
|
||||||
### S8. 其他硬化 / 跟进 — Low
|
### 5. Session 数量无上限
|
||||||
|
|
||||||
- **#9** SDK `box/server.py` 直接读 `runtime._sessions` 私有字段、绕过锁,并发下可能读到不一致状态——应加公共访问方法。
|
- **位置**: SDK `box/runtime.py` `_get_or_create_session()`
|
||||||
- **#16** `pkg/provider/tools/toolmgr.py` `execute_func_call` 按优先级分发,plugin/MCP 若有同名 `exec/read/write/...` 工具会被静默遮蔽——应加命名空间或冲突告警。
|
- **现状**: `_sessions` dict 无容量限制,恶意或异常调用可创建无限 session
|
||||||
- **#4** SDK `box/runtime.py` INIT/handshake 与 backend 实例化的残留竞态(仅"纯远程 WS box 先启动、LangBot 后连"场景成立;stdio/compose 路径下 config 经 env 在 spawn 时已就位,无竞态)——应在 INIT 完成前拒绝业务 action。
|
- **建议**: 加 `max_sessions` 配置项,达到上限时拒绝新建或按 LRU 清理
|
||||||
- **#11** `extra_mounts` 在容器创建时固定(SDK `runtime.py` 兼容性检查不含 extra_mounts);长生命周期共享 session 后续新激活的 skill 不会挂上(当前缓解:创建时挂上 pipeline 绑定的全部 skill)——动态绑定场景需销毁重建或文档说明。
|
|
||||||
- **#21** 集成测试未进 CI:容器实际执行、E2B 真机、managed-process WS attach 仅本地可跑。安全关键路径缺自动化覆盖——SaaS 前建议加 Docker-in-Docker CI stage 或合并前手动 checklist。
|
### 6. Quota 检查存在 TOCTOU
|
||||||
|
|
||||||
|
- **位置**: `pkg/box/service.py` `_enforce_workspace_quota()`
|
||||||
|
- **现状**: 应用层先读磁盘大小再执行命令,两步之间有竞态窗口
|
||||||
|
- **建议**: 短期用 Docker `--storage-opt size=` 做内核级限制;长期用 Redis 原子计数器做预留式配额
|
||||||
|
|
||||||
|
### 7. 全局锁持有期间执行慢操作
|
||||||
|
|
||||||
|
- **位置**: SDK `box/runtime.py` `_get_or_create_session()` — `self._lock` 下调用 `backend.start_session()` (即 `docker run` / `nsjail` 进程启动 / E2B `Sandbox.create`)
|
||||||
|
- **影响**: `docker run` 可能耗时数秒(含镜像拉取)、E2B 冷启动通常 > 1s,期间阻塞所有并发请求
|
||||||
|
- **建议**: 在 `_lock` 下仅做状态检查和 session 注册,容器创建在锁外执行
|
||||||
|
|
||||||
|
### 8. Session 清理是机会性的
|
||||||
|
|
||||||
|
- **位置**: SDK `box/runtime.py` `_reap_expired_sessions_locked()` — 仅在 `_get_or_create_session()` 时调用
|
||||||
|
- **影响**: 如果长时间无新 session 请求,过期 session(含容器)不会被清理
|
||||||
|
- **建议**: 加一个独立的 `asyncio.create_task` 定时清理(如每 60s 一次)
|
||||||
|
|
||||||
|
### 9. server.py 直接访问 runtime 私有字段
|
||||||
|
|
||||||
|
- **位置**: SDK `box/server.py` — managed-process WS handler 直接读 `runtime._sessions`
|
||||||
|
- **影响**: 绕过锁和封装,在并发场景下可能读到不一致状态
|
||||||
|
- **建议**: 在 BoxRuntime 上增加公共方法(如 `get_session_managed_process(session_id, process_id)`)
|
||||||
|
|
||||||
|
### 10. workspace quota 检查阻塞事件循环
|
||||||
|
|
||||||
|
- **位置**: `pkg/box/service.py` `_get_workspace_size_bytes()` — 使用同步 `os.scandir` 递归遍历
|
||||||
|
- **影响**: 大工作区可能阻塞 asyncio event loop
|
||||||
|
- **建议**: 用 `asyncio.to_thread()` 包装,或用 `aiofiles` 异步扫描
|
||||||
|
|
||||||
|
### 11. extra_mounts 一旦容器创建即固定
|
||||||
|
|
||||||
|
- **位置**: SDK `box/runtime.py` 的兼容性检查;`pkg/box/service.py:build_skill_extra_mounts()`
|
||||||
|
- **现状**: Skill 挂载在容器创建时一次性写入;同一 session 后续 pipeline 切换 skill 列表时,新挂载不会生效(除非销毁重建)
|
||||||
|
- **影响**: 用户长时间共享 session 的场景下,新激活的 skill 可能挂不上
|
||||||
|
- **建议**: 要么在创建时把 pipeline 绑定的所有 skill 都挂上(实际现状)+ 写入文档;要么变更挂载时强制销毁 session 重建(已被 commit `5029d9c` 部分覆盖,需校验)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2 — 后续迭代
|
||||||
|
|
||||||
|
### 12. 重复的 `_is_path_under` 函数
|
||||||
|
|
||||||
|
- **位置**: `pkg/box/service.py` 行 30 附近 — 同名函数定义两次
|
||||||
|
- **建议**: 删除重复定义
|
||||||
|
|
||||||
|
### 13. localagent.py 工具循环无迭代上限
|
||||||
|
|
||||||
|
- **位置**: `pkg/provider/runners/localagent.py` `while pending_tool_calls` 循环
|
||||||
|
- **影响**: 恶意或混乱的 LLM 可无限产生 tool call,消耗资源
|
||||||
|
- **建议**: 加 `max_tool_iterations` 配置项(如默认 50 次)
|
||||||
|
|
||||||
|
### 14. localagent.py 中的死代码
|
||||||
|
|
||||||
|
- **位置**: `pkg/provider/runners/localagent.py:29-35` 附近 — 旧命名 `SANDBOX_EXEC_TOOL_NAME` 和 `SANDBOX_EXEC_SYSTEM_GUIDANCE`
|
||||||
|
- **现状**: 旧命名方案的遗留常量,从未被引用(实际使用 `EXEC_TOOL_NAME` from native.py)
|
||||||
|
- **建议**: 删除
|
||||||
|
|
||||||
|
### 15. @loader_class 装饰器未使用
|
||||||
|
|
||||||
|
- **位置**: `pkg/provider/tools/loader.py` — `preregistered_loaders` 列表和 `@loader_class` 装饰器
|
||||||
|
- **现状**: 各 loader 的 `@loader_class` 多数被注释掉,ToolManager 手动实例化所有 loader
|
||||||
|
- **建议**: 要么启用装饰器自动注册,要么删除未用的机制
|
||||||
|
|
||||||
|
### 16. 工具名冲突风险
|
||||||
|
|
||||||
|
- **位置**: `pkg/provider/tools/toolmgr.py` `execute_func_call()` — 按优先级 native → plugin → mcp → skill → skill_authoring 分发
|
||||||
|
- **影响**: 如果 plugin 或 MCP 有名为 `exec`/`read`/`write`/`edit`/`glob`/`grep`/`activate` 的工具,会被前序 loader 静默遮蔽
|
||||||
|
- **建议**: 加命名空间前缀或冲突检测告警
|
||||||
|
|
||||||
|
### 17. client.py 反序列化不一致
|
||||||
|
|
||||||
|
- **位置**: SDK `box/client.py` — `execute()` 与其他方法对返回值的反序列化方式不统一(部分手动构造 model,部分用 `model_validate`)
|
||||||
|
- **建议**: 统一使用 `model_validate`
|
||||||
|
|
||||||
|
### 18. 错误类型还原基于字符串前缀匹配
|
||||||
|
|
||||||
|
- **位置**: SDK `box/client.py` `_translate_action_error()`
|
||||||
|
- **影响**: 如果 server 端错误消息格式变化,client 会回退到通用 `BoxError`,丢失类型信息
|
||||||
|
- **建议**: 在 ActionResponse 中增加结构化的错误类型字段(如 `error_code` 枚举)
|
||||||
|
|
||||||
|
### 19. 前端只用到了 status
|
||||||
|
|
||||||
|
- **位置**: `web/src/app/home/monitoring/...` 已接入 `/api/v1/box/status`
|
||||||
|
- **现状**: `/api/v1/box/sessions` 与 `/api/v1/box/errors` 后端可用、前端未消费
|
||||||
|
- **建议**: 在监控页或独立 Box 详情页展示活跃 session 列表与最近错误,提升运维体感
|
||||||
|
|
||||||
|
### 20. skill_store 测试覆盖偏薄
|
||||||
|
|
||||||
|
- **位置**: SDK `tests/box/test_skill_store.py` 仅 88 行
|
||||||
|
- **现状**: 相对 `skill_store.py` 的 647 行实现,单测覆盖度不够;GitHub 安装路径、`source_subdir` / `target_suffix` 组合、损坏 zip 的错误处理等场景未覆盖
|
||||||
|
- **建议**: 至少补到核心 path 覆盖(preview/install/list/file CRUD 各 2~3 个 case)
|
||||||
|
|
||||||
|
### 21. 集成测试未进 CI
|
||||||
|
|
||||||
|
- **位置**: LangBot `tests/integration_tests/box/test_box_integration.py`、`test_box_mcp_integration.py`,SDK 端的 E2B 真机测试
|
||||||
|
- **现状**: 容器实际执行、E2B 真实 sandbox、Managed process WS attach 均仅本地能跑
|
||||||
|
- **建议**: 加一个可选的 Docker-in-Docker CI stage,或在合并前手动跑 checklist
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Box Session Scope Design
|
# Box Session Scope Design
|
||||||
|
|
||||||
> Date: 2026-04-18 (last reviewed 2026-06-02)
|
> Date: 2026-04-18 (last reviewed 2026-05-19)
|
||||||
> Status (2026-06-02): the self-hosted community edition is release-ready (box optional, clean degradation, no migration debt). Tool-call loop cap, async quota scan, and the host_path mount allowlist have landed. Remaining multi-tenant / security hardening is tracked in [box-issues.md](./box-issues.md).
|
|
||||||
> Branch: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
> Branch: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
> Related: [Box Architecture](./box-architecture.md) | [Box vs Plugin Runtime](./box-vs-plugin-runtime.md)
|
> Related: [Box Architecture](./box-architecture.md) | [Box vs Plugin Runtime](./box-vs-plugin-runtime.md)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Box 系统测试覆盖分析
|
# Box 系统测试覆盖分析
|
||||||
|
|
||||||
> 更新日期: 2026-06-02
|
> 更新日期: 2026-05-19
|
||||||
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
|
||||||
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Box 系统 toB 商业化分析
|
# Box 系统 toB 商业化分析
|
||||||
|
|
||||||
> 更新日期: 2026-06-02
|
> 更新日期: 2026-05-19
|
||||||
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
|
||||||
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Box Runtime vs Plugin Runtime: 连接架构对比
|
# Box Runtime vs Plugin Runtime: 连接架构对比
|
||||||
|
|
||||||
> 更新日期: 2026-06-02
|
> 更新日期: 2026-05-19
|
||||||
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
|
||||||
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.0"
|
version = "4.10.0-beta.1"
|
||||||
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"]
|
||||||
@@ -70,7 +70,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.4.1",
|
"langbot-plugin==0.4.0b1",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
|
|||||||
@@ -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'
|
__version__ = '4.10.0-beta.1'
|
||||||
|
|||||||
@@ -43,12 +43,8 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Dashboard pipeline-debug sessions must always run under the
|
# Find the owning bot for this pipeline (e.g. a web_page_bot)
|
||||||
# built-in websocket_proxy_bot identity. We deliberately do NOT
|
owner_bot = self._find_owner_bot(pipeline_uuid)
|
||||||
# resolve a web_page_bot owner here — even if one is bound to
|
|
||||||
# the same pipeline, debug requests must not be attributed to
|
|
||||||
# it. The embed widget path (`/api/v1/embed/<bot>/ws/connect`)
|
|
||||||
# is the one that carries the page-bot identity.
|
|
||||||
|
|
||||||
# 注册连接
|
# 注册连接
|
||||||
connection = await ws_connection_manager.add_connection(
|
connection = await ws_connection_manager.add_connection(
|
||||||
@@ -77,7 +73,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 创建接收和发送任务
|
# 创建接收和发送任务
|
||||||
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
|
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, owner_bot))
|
||||||
send_task = asyncio.create_task(self._handle_send(connection))
|
send_task = asyncio.create_task(self._handle_send(connection))
|
||||||
|
|
||||||
# 等待任务完成
|
# 等待任务完成
|
||||||
@@ -185,7 +181,14 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||||
|
|
||||||
async def _handle_receive(self, connection, websocket_adapter):
|
def _find_owner_bot(self, pipeline_uuid: str):
|
||||||
|
"""Find a user-created bot (e.g. web_page_bot) that owns this pipeline."""
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.use_pipeline_uuid == pipeline_uuid:
|
||||||
|
return bot
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _handle_receive(self, connection, websocket_adapter, owner_bot=None):
|
||||||
"""处理接收消息的任务"""
|
"""处理接收消息的任务"""
|
||||||
try:
|
try:
|
||||||
while connection.is_active:
|
while connection.is_active:
|
||||||
@@ -210,10 +213,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
||||||
|
|
||||||
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
||||||
# owner_bot is intentionally NOT passed: the dashboard
|
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
|
||||||
# debug WebSocket must always run under the proxy bot,
|
|
||||||
# never under a coincidentally-bound web_page_bot.
|
|
||||||
await websocket_adapter.handle_websocket_message(connection, data)
|
|
||||||
|
|
||||||
elif message_type == 'disconnect':
|
elif message_type == 'disconnect':
|
||||||
# 客户端主动断开
|
# 客户端主动断开
|
||||||
|
|||||||
@@ -179,6 +179,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
|
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
|
||||||
from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL
|
from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL
|
||||||
|
|
||||||
@@ -206,32 +208,60 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
async def run_login():
|
async def run_login():
|
||||||
try:
|
try:
|
||||||
|
import qrcode as qr_lib
|
||||||
|
|
||||||
def on_qrcode(qr_data_url: str, _qr_url: str):
|
for _attempt in range(3):
|
||||||
def _update():
|
qr_resp = await client.fetch_qrcode()
|
||||||
session['qr_data_url'] = qr_data_url
|
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
|
||||||
session['expire_at'] = time.time() + 180
|
raise Exception('Failed to get QR code from server')
|
||||||
|
|
||||||
|
# Generate QR code image locally
|
||||||
|
qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L)
|
||||||
|
qr.add_data(qr_resp.qrcode_img_content)
|
||||||
|
qr.make(fit=True)
|
||||||
|
img = qr.make_image(fill_color='black', back_color='white')
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format='PNG')
|
||||||
|
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
data_url = f'data:image/png;base64,{b64}'
|
||||||
|
|
||||||
|
def _update_qr():
|
||||||
|
session['qr_data_url'] = data_url
|
||||||
|
session['expire_at'] = time.time() + 480 # 8 minutes
|
||||||
session['status'] = 'waiting'
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
loop.call_soon_threadsafe(_update)
|
loop.call_soon_threadsafe(_update_qr)
|
||||||
|
|
||||||
|
# Poll for scan status
|
||||||
|
deadline = loop.time() + 180
|
||||||
|
while loop.time() < deadline:
|
||||||
|
try:
|
||||||
|
status_resp = await client.poll_qrcode_status(qr_resp.qrcode)
|
||||||
|
except Exception:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if status_resp.status == 'confirmed' and status_resp.bot_token:
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['token'] = status_resp.bot_token
|
||||||
|
session['base_url'] = status_resp.baseurl or client.base_url
|
||||||
|
session['account_id'] = status_resp.ilink_bot_id or ''
|
||||||
|
return
|
||||||
|
|
||||||
|
if status_resp.status == 'expired':
|
||||||
|
break # retry with new QR code
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
else:
|
||||||
|
pass # timeout, retry
|
||||||
|
|
||||||
|
# All retries exhausted
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code login failed: max retries exceeded'
|
||||||
|
|
||||||
result = await client.login(
|
|
||||||
max_retries=1,
|
|
||||||
poll_timeout_ms=180_000,
|
|
||||||
on_qrcode=on_qrcode,
|
|
||||||
)
|
|
||||||
session['status'] = 'success'
|
|
||||||
session['token'] = result.token
|
|
||||||
session['base_url'] = result.base_url
|
|
||||||
session['account_id'] = result.account_id
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_message = str(e)
|
session['status'] = 'error'
|
||||||
if 'expired' in error_message.lower() or 'max retries exceeded' in error_message.lower():
|
session['error'] = str(e)
|
||||||
session['status'] = 'expired'
|
|
||||||
session['error'] = 'QR code expired'
|
|
||||||
else:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = error_message
|
|
||||||
finally:
|
finally:
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
@@ -265,11 +295,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
if not session:
|
if not session:
|
||||||
return self.http_status(404, -1, 'Session not found')
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
data = {
|
data = {'status': session['status']}
|
||||||
'status': session['status'],
|
|
||||||
'qr_data_url': session['qr_data_url'],
|
|
||||||
'expire_at': session['expire_at'],
|
|
||||||
}
|
|
||||||
|
|
||||||
if session['status'] == 'success':
|
if session['status'] == 'success':
|
||||||
data['token'] = session['token']
|
data['token'] = session['token']
|
||||||
@@ -279,9 +305,6 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
elif session['status'] == 'error':
|
elif session['status'] == 'error':
|
||||||
data['error'] = session['error']
|
data['error'] = session['error']
|
||||||
_weixin_login_sessions.pop(session_id, None)
|
_weixin_login_sessions.pop(session_id, None)
|
||||||
elif session['status'] == 'expired':
|
|
||||||
data['error'] = session['error']
|
|
||||||
_weixin_login_sessions.pop(session_id, None)
|
|
||||||
|
|
||||||
return self.success(data=data)
|
return self.success(data=data)
|
||||||
|
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ class BoxService:
|
|||||||
f'spec={json.dumps(self._summarize_spec(spec), ensure_ascii=False)}'
|
f'spec={json.dumps(self._summarize_spec(spec), ensure_ascii=False)}'
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await self._enforce_workspace_quota(spec, phase='before execution')
|
self._enforce_workspace_quota(spec, phase='before execution')
|
||||||
except BoxError as exc:
|
except BoxError as exc:
|
||||||
self._record_error(exc, query)
|
self._record_error(exc, query)
|
||||||
raise
|
raise
|
||||||
@@ -178,7 +178,7 @@ class BoxService:
|
|||||||
self._record_error(exc, query)
|
self._record_error(exc, query)
|
||||||
raise
|
raise
|
||||||
try:
|
try:
|
||||||
await self._enforce_workspace_quota(spec, phase='after execution')
|
self._enforce_workspace_quota(spec, phase='after execution')
|
||||||
except BoxError as exc:
|
except BoxError as exc:
|
||||||
await self._cleanup_exceeded_session(spec)
|
await self._cleanup_exceeded_session(spec)
|
||||||
self._record_error(exc, query)
|
self._record_error(exc, query)
|
||||||
@@ -683,7 +683,7 @@ class BoxService:
|
|||||||
_walk(root)
|
_walk(root)
|
||||||
return total
|
return total
|
||||||
|
|
||||||
async def _enforce_workspace_quota(self, spec: BoxSpec, *, phase: str) -> None:
|
def _enforce_workspace_quota(self, spec: BoxSpec, *, phase: str) -> None:
|
||||||
if spec.host_path is None or spec.workspace_quota_mb <= 0:
|
if spec.host_path is None or spec.workspace_quota_mb <= 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -691,10 +691,7 @@ class BoxService:
|
|||||||
if not os.path.isdir(host_path):
|
if not os.path.isdir(host_path):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Walk the workspace off the event loop — this runs on every
|
used_bytes = self._get_workspace_size_bytes(host_path)
|
||||||
# quota-enforced exec, and a large tree would otherwise block the whole
|
|
||||||
# asyncio runtime (all bots/pipelines) for the duration of the scan.
|
|
||||||
used_bytes = await asyncio.to_thread(self._get_workspace_size_bytes, host_path)
|
|
||||||
limit_bytes = spec.workspace_quota_mb * _MIB
|
limit_bytes = spec.workspace_quota_mb * _MIB
|
||||||
if used_bytes <= limit_bytes:
|
if used_bytes <= limit_bytes:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -881,8 +881,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
bot_account_id = config['bot_name']
|
bot_account_id = config['bot_name']
|
||||||
|
|
||||||
domain = self._resolve_domain(config)
|
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler)
|
||||||
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler, domain=domain)
|
|
||||||
api_client = self.build_api_client(config)
|
api_client = self.build_api_client(config)
|
||||||
cipher = AESCipher(config.get('encrypt-key', ''))
|
cipher = AESCipher(config.get('encrypt-key', ''))
|
||||||
self.request_app_ticket(api_client, config)
|
self.request_app_ticket(api_client, config)
|
||||||
@@ -1015,28 +1014,13 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _resolve_domain(config) -> str:
|
|
||||||
domain = config.get('domain', lark_oapi.FEISHU_DOMAIN)
|
|
||||||
if domain == 'custom':
|
|
||||||
domain = config.get('custom_domain', '')
|
|
||||||
if not domain:
|
|
||||||
raise ValueError('Custom domain is required when domain is set to "custom"')
|
|
||||||
return domain.rstrip('/')
|
|
||||||
|
|
||||||
def build_api_client(self, config):
|
def build_api_client(self, config):
|
||||||
app_id = config['app_id']
|
app_id = config['app_id']
|
||||||
app_secret = config['app_secret']
|
app_secret = config['app_secret']
|
||||||
domain = self._resolve_domain(config)
|
api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).build()
|
||||||
api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).domain(domain).build()
|
|
||||||
if 'isv' == config.get('app_type', 'self'):
|
if 'isv' == config.get('app_type', 'self'):
|
||||||
api_client = (
|
api_client = (
|
||||||
lark_oapi.Client.builder()
|
lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).app_type(lark_oapi.AppType.ISV).build()
|
||||||
.app_id(app_id)
|
|
||||||
.app_secret(app_secret)
|
|
||||||
.app_type(lark_oapi.AppType.ISV)
|
|
||||||
.domain(domain)
|
|
||||||
.build()
|
|
||||||
)
|
)
|
||||||
return api_client
|
return api_client
|
||||||
|
|
||||||
|
|||||||
@@ -23,57 +23,6 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/lark
|
en: https://link.langbot.app/en/platforms/lark
|
||||||
ja: https://link.langbot.app/ja/platforms/lark
|
ja: https://link.langbot.app/ja/platforms/lark
|
||||||
config:
|
config:
|
||||||
- name: domain
|
|
||||||
label:
|
|
||||||
en_US: Platform Domain
|
|
||||||
zh_Hans: 平台域名
|
|
||||||
zh_Hant: 平台域名
|
|
||||||
ja_JP: プラットフォームドメイン
|
|
||||||
description:
|
|
||||||
en_US: Select the open platform domain. Use Feishu for Chinese mainland, Lark for international
|
|
||||||
zh_Hans: 选择开放平台域名,国内使用飞书,海外使用 Lark
|
|
||||||
zh_Hant: 選擇開放平台域名,國內使用飛書,海外使用 Lark
|
|
||||||
ja_JP: オープンプラットフォームのドメインを選択。中国国内は飛書、海外は Lark を使用
|
|
||||||
type: select
|
|
||||||
options:
|
|
||||||
- name: https://open.feishu.cn
|
|
||||||
label:
|
|
||||||
en_US: Feishu (open.feishu.cn)
|
|
||||||
zh_Hans: 飞书 (open.feishu.cn)
|
|
||||||
zh_Hant: 飛書 (open.feishu.cn)
|
|
||||||
ja_JP: 飛書 (open.feishu.cn)
|
|
||||||
- name: https://open.larksuite.com
|
|
||||||
label:
|
|
||||||
en_US: Lark (open.larksuite.com)
|
|
||||||
zh_Hans: Lark (open.larksuite.com)
|
|
||||||
zh_Hant: Lark (open.larksuite.com)
|
|
||||||
ja_JP: Lark (open.larksuite.com)
|
|
||||||
- name: custom
|
|
||||||
label:
|
|
||||||
en_US: Custom
|
|
||||||
zh_Hans: 自定义
|
|
||||||
zh_Hant: 自定義
|
|
||||||
ja_JP: カスタム
|
|
||||||
required: false
|
|
||||||
default: https://open.feishu.cn
|
|
||||||
- name: custom_domain
|
|
||||||
label:
|
|
||||||
en_US: Custom Domain
|
|
||||||
zh_Hans: 自定义域名
|
|
||||||
zh_Hant: 自定義域名
|
|
||||||
ja_JP: カスタムドメイン
|
|
||||||
description:
|
|
||||||
en_US: "Enter the full domain URL, e.g. https://open.example.com"
|
|
||||||
zh_Hans: "输入完整的域名 URL,例如 https://open.example.com"
|
|
||||||
zh_Hant: "輸入完整的域名 URL,例如 https://open.example.com"
|
|
||||||
ja_JP: "完全なドメイン URL を入力(例: https://open.example.com)"
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
show_if:
|
|
||||||
field: domain
|
|
||||||
operator: eq
|
|
||||||
value: custom
|
|
||||||
- name: one-click-create
|
- name: one-click-create
|
||||||
label:
|
label:
|
||||||
en_US: One-Click Create App
|
en_US: One-Click Create App
|
||||||
@@ -191,10 +140,10 @@ spec:
|
|||||||
zh_Hant: 應用類型
|
zh_Hant: 應用類型
|
||||||
ja_JP: アプリタイプ
|
ja_JP: アプリタイプ
|
||||||
description:
|
description:
|
||||||
en_US: "Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview"
|
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
|
||||||
zh_Hans: "默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview"
|
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||||
zh_Hant: "預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview"
|
zh_Hant: 預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||||
ja_JP: "デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください"
|
ja_JP: デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください
|
||||||
type: select
|
type: select
|
||||||
options:
|
options:
|
||||||
- name: self
|
- name: self
|
||||||
|
|||||||
@@ -103,16 +103,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
|
|
||||||
self.handler_task = asyncio.create_task(self.handler.run())
|
self.handler_task = asyncio.create_task(self.handler.run())
|
||||||
_ = await self.handler.ping()
|
_ = await self.handler.ping()
|
||||||
# Push the configured marketplace (Space) URL to the runtime so it
|
|
||||||
# downloads plugins from the same Space LangBot is bound to, rather
|
|
||||||
# than relying on the runtime's own env/default.
|
|
||||||
space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/')
|
|
||||||
if space_url:
|
|
||||||
try:
|
|
||||||
await self.handler.set_runtime_config(cloud_service_url=space_url)
|
|
||||||
self.ap.logger.info(f'Pushed marketplace URL to plugin runtime: {space_url}')
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to push runtime config: {e}')
|
|
||||||
self.ap.logger.info('Connected to plugin runtime.')
|
self.ap.logger.info('Connected to plugin runtime.')
|
||||||
await self.handler_task
|
await self.handler_task
|
||||||
|
|
||||||
@@ -234,23 +224,30 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
mcp_data: dict[str, Any],
|
mcp_data: dict[str, Any],
|
||||||
task_context: taskmgr.TaskContext | None = None,
|
task_context: taskmgr.TaskContext | None = None,
|
||||||
):
|
):
|
||||||
"""Install an MCP server from marketplace data.
|
"""Install an MCP server from marketplace data."""
|
||||||
|
|
||||||
Marketplace MCP records carry the runtime-ready ``mode`` and
|
|
||||||
``extra_args`` directly (the same shape LangBot stores in
|
|
||||||
``mcp_servers``), so they are used as-is rather than reconstructed.
|
|
||||||
For ``stdio`` this preserves ``command``/``args``/``env``/``box``;
|
|
||||||
for ``http``/``sse`` it preserves ``url``/``headers``/``timeout``/
|
|
||||||
``ssereadtimeout``.
|
|
||||||
"""
|
|
||||||
from ..entity.persistence import mcp as persistence_mcp
|
from ..entity.persistence import mcp as persistence_mcp
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
mode = mcp_data.get('mode') or 'stdio'
|
config = mcp_data.get('config', {})
|
||||||
extra_args = mcp_data.get('extra_args') or {}
|
url = config.get('url', '')
|
||||||
# Use __ instead of / to avoid URL routing issues with slashes
|
# Use __ instead of / to avoid URL routing issues with slashes
|
||||||
name = f'{mcp_data.get("author", "")}__{mcp_data.get("name", "")}'
|
name = f'{mcp_data.get("author", "")}__{mcp_data.get("name", "")}'
|
||||||
|
|
||||||
|
# Determine mode from URL
|
||||||
|
if 'sse' in url.lower():
|
||||||
|
mode = 'sse'
|
||||||
|
elif url.startswith('http'):
|
||||||
|
mode = 'http'
|
||||||
|
else:
|
||||||
|
mode = 'stdio'
|
||||||
|
|
||||||
|
# Build extra_args from config
|
||||||
|
extra_args = {
|
||||||
|
'url': url,
|
||||||
|
'timeout': config.get('timeout', 30),
|
||||||
|
'sse_read_timeout': config.get('sse_read_timeout', 300),
|
||||||
|
}
|
||||||
|
|
||||||
# Check if MCP server already exists
|
# Check if MCP server already exists
|
||||||
existing = await self.ap.persistence_mgr.execute_async(
|
existing = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == name)
|
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == name)
|
||||||
@@ -379,22 +376,15 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
mcp_resp = await client.get(f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}')
|
mcp_resp = await client.get(f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}')
|
||||||
if mcp_resp.status_code == 200:
|
if mcp_resp.status_code == 200:
|
||||||
mcp_data = mcp_resp.json().get('data', {}).get('mcp', {})
|
mcp_data = mcp_resp.json().get('data', {}).get('mcp', {})
|
||||||
if mcp_data.get('mode'):
|
if mcp_data.get('config'):
|
||||||
# It's an MCP - create server locally
|
# It's an MCP - create server locally
|
||||||
self.ap.logger.info(f'Installing MCP from marketplace: {plugin_author}/{plugin_name}')
|
self.ap.logger.info(f'Installing MCP from marketplace: {plugin_author}/{plugin_name}')
|
||||||
if task_context:
|
if task_context:
|
||||||
task_context.set_current_action('installing mcp server')
|
task_context.set_current_action('installing mcp server')
|
||||||
await self._install_mcp_from_marketplace(mcp_data, task_context)
|
await self._install_mcp_from_marketplace(mcp_data, task_context)
|
||||||
# Best-effort install report (bumps marketplace install_count).
|
|
||||||
try:
|
|
||||||
await client.post(
|
|
||||||
f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}/install'
|
|
||||||
)
|
|
||||||
except Exception as report_err:
|
|
||||||
self.ap.logger.debug(f'Failed to report MCP install: {report_err}')
|
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
raise Exception(f'MCP {plugin_author}/{plugin_name} has no mode')
|
raise Exception(f'MCP {plugin_author}/{plugin_name} has no config')
|
||||||
elif mcp_resp.status_code == 404:
|
elif mcp_resp.status_code == 404:
|
||||||
# Try skill endpoint - download ZIP and install
|
# Try skill endpoint - download ZIP and install
|
||||||
self.ap.logger.info(f'Trying skill endpoint for: {plugin_author}/{plugin_name}')
|
self.ap.logger.info(f'Trying skill endpoint for: {plugin_author}/{plugin_name}')
|
||||||
@@ -459,7 +449,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
)
|
)
|
||||||
|
|
||||||
file_bytes = download_resp.content
|
file_bytes = download_resp.content
|
||||||
self._inspect_plugin_package(file_bytes, task_context)
|
self._extract_deps_metadata(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')
|
||||||
|
|||||||
@@ -779,16 +779,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_runtime_config(self, cloud_service_url: str) -> dict[str, Any]:
|
|
||||||
"""Push runtime configuration (e.g. marketplace URL) to the runtime."""
|
|
||||||
return await self.call_action(
|
|
||||||
LangBotToRuntimeAction.SET_RUNTIME_CONFIG,
|
|
||||||
{
|
|
||||||
'cloud_service_url': cloud_service_url,
|
|
||||||
},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def install_plugin(
|
async def install_plugin(
|
||||||
self, install_source: str, install_info: dict[str, Any]
|
self, install_source: str, install_info: dict[str, Any]
|
||||||
) -> typing.AsyncGenerator[dict[str, Any], None]:
|
) -> typing.AsyncGenerator[dict[str, Any], None]:
|
||||||
|
|||||||
@@ -143,83 +143,49 @@ 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()
|
||||||
|
|
||||||
# Index existing models by uuid. Space reuses a model's uuid across
|
exists_llm_models_uuids = [m['uuid'] for m in await self.ap.llm_model_service.get_llm_models()]
|
||||||
# renames / re-specs (e.g. the uuid that used to be ``claude-opus-4-6``
|
exists_embedding_models_uuids = [
|
||||||
# may later become ``claude-opus-4-7``). So for Space-managed models we
|
m['uuid'] for m in await self.ap.embedding_models_service.get_embedding_models()
|
||||||
# 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':
|
||||||
existing = existing_llm_models.get(space_model.uuid)
|
uuid = space_model.uuid
|
||||||
if existing is None:
|
|
||||||
# model will be automatically loaded
|
if uuid in exists_llm_models_uuids:
|
||||||
await self.ap.llm_model_service.create_llm_model(
|
continue
|
||||||
{
|
|
||||||
'uuid': space_model.uuid,
|
# model will be automatically loaded
|
||||||
'name': space_model.model_id,
|
await self.ap.llm_model_service.create_llm_model(
|
||||||
'provider_uuid': space_model_provider.uuid,
|
{
|
||||||
'abilities': space_model.llm_abilities or [],
|
'uuid': space_model.uuid,
|
||||||
'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,
|
||||||
}
|
},
|
||||||
if (
|
preserve_uuid=True,
|
||||||
existing.get('name') != desired['name']
|
auto_set_to_default_pipeline=False,
|
||||||
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':
|
||||||
existing = existing_embedding_models.get(space_model.uuid)
|
uuid = space_model.uuid
|
||||||
if existing is None:
|
|
||||||
# model will be automatically loaded
|
if uuid in exists_embedding_models_uuids:
|
||||||
await self.ap.embedding_models_service.create_embedding_model(
|
continue
|
||||||
{
|
|
||||||
'uuid': space_model.uuid,
|
# model will be automatically loaded
|
||||||
'name': space_model.model_id,
|
await self.ap.embedding_models_service.create_embedding_model(
|
||||||
'provider_uuid': space_model_provider.uuid,
|
{
|
||||||
'extra_args': {},
|
'uuid': space_model.uuid,
|
||||||
'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,
|
||||||
}
|
},
|
||||||
if (
|
preserve_uuid=True,
|
||||||
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,
|
||||||
|
|||||||
@@ -34,13 +34,6 @@ SANDBOX_EXEC_SYSTEM_GUIDANCE = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Hard cap on tool-call rounds within a single agent turn. A looping or
|
|
||||||
# adversarial model can otherwise emit tool calls indefinitely (each potentially
|
|
||||||
# a sandbox exec), yielding a non-terminating request and runaway cost. Set
|
|
||||||
# generously so it never interrupts legitimate multi-step agentic workflows.
|
|
||||||
MAX_TOOL_CALL_ROUNDS = 128
|
|
||||||
|
|
||||||
|
|
||||||
@runner.runner_class('local-agent')
|
@runner.runner_class('local-agent')
|
||||||
class LocalAgentRunner(runner.RequestRunner):
|
class LocalAgentRunner(runner.RequestRunner):
|
||||||
"""Local agent request runner"""
|
"""Local agent request runner"""
|
||||||
@@ -370,15 +363,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
|
|
||||||
# Once a model succeeds, commit to it for the tool call loop
|
# Once a model succeeds, commit to it for the tool call loop
|
||||||
# (no fallback mid-conversation — different models may interpret tool results differently)
|
# (no fallback mid-conversation — different models may interpret tool results differently)
|
||||||
tool_call_round = 0
|
|
||||||
while pending_tool_calls:
|
while pending_tool_calls:
|
||||||
tool_call_round += 1
|
|
||||||
if tool_call_round > MAX_TOOL_CALL_ROUNDS:
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Tool-call loop reached the {MAX_TOOL_CALL_ROUNDS}-round cap '
|
|
||||||
f'(query_id={query.query_id}); stopping to avoid a non-terminating request.'
|
|
||||||
)
|
|
||||||
break
|
|
||||||
for tool_call in pending_tool_calls:
|
for tool_call in pending_tool_calls:
|
||||||
try:
|
try:
|
||||||
func = tool_call.function
|
func = tool_call.function
|
||||||
|
|||||||
@@ -240,13 +240,12 @@ 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: {self._describe_exception(e)}'
|
self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {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}), '
|
f'MCP session {self.server_name} failed (attempt {attempt + 1}), retrying in {delay}s: {e}'
|
||||||
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
|
||||||
@@ -255,30 +254,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ box:
|
|||||||
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
|
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
|
||||||
# be listed read-only and http/sse MCP servers continue to work.
|
# be listed read-only and http/sse MCP servers continue to work.
|
||||||
enabled: true
|
enabled: true
|
||||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
|
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. BOX_BACKEND env var takes precedence.
|
||||||
runtime:
|
runtime:
|
||||||
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
|
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
|
||||||
local:
|
local:
|
||||||
|
|||||||
10
uv.lock
generated
10
uv.lock
generated
@@ -1899,7 +1899,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.0"
|
version = "4.10.0b1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
@@ -2013,7 +2013,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.4.1" },
|
{ name = "langbot-plugin", specifier = "==0.4.0b1" },
|
||||||
{ name = "langchain", specifier = ">=0.2.0" },
|
{ name = "langchain", specifier = ">=0.2.0" },
|
||||||
{ name = "langchain-core", specifier = ">=1.2.28" },
|
{ name = "langchain-core", specifier = ">=1.2.28" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||||
@@ -2076,7 +2076,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.4.1"
|
version = "0.4.0b1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2096,9 +2096,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/c1/b11ce66fb2537b257ff387b8b5b708e616e5a072ae04440e24807eb3b1cf/langbot_plugin-0.4.1.tar.gz", hash = "sha256:57d3f8cd6b6c33316792ebfa0c907b2240834a84f2b8c8034c6be7721b425059", size = 289249, upload-time = "2026-06-04T05:19:08.747Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/8a/e0/4bb2fd08813879d3da390f588b2927ae626edcfd45dca36900e2e54fb23c/langbot_plugin-0.4.0b1.tar.gz", hash = "sha256:f523c197ff9f5aa3db737e29765ebe1f7a8c96f973240ce3769ccccd0bfddde7", size = 216965, upload-time = "2026-05-21T05:23:27.682Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/e8/335023bb5e1310621c7b7d8ae4fcac179f119709eee9a8ba65b681f66a8e/langbot_plugin-0.4.1-py3-none-any.whl", hash = "sha256:a9c319a4abb6944ae3d9a491edbeb703842a87b42b4e3b1eafba666ec2beeee7", size = 203412, upload-time = "2026-06-04T05:19:09.936Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/59/9c6df7cd652d3434d1139ee8392e170108e5f980046b9a55bff324e094fe/langbot_plugin-0.4.0b1-py3-none-any.whl", hash = "sha256:b533407296399c7693255678a4d1390be957dabffa21ca2982e56d28a728854b", size = 194310, upload-time = "2026-05-21T05:23:26.215Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
FileArchive,
|
FileArchive,
|
||||||
Loader2,
|
Loader2,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
Package,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
@@ -34,8 +33,6 @@ import {
|
|||||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PluginV4 } from '@/app/infra/entities/plugin';
|
import { PluginV4 } from '@/app/infra/entities/plugin';
|
||||||
@@ -154,14 +151,6 @@ function AddExtensionContent() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
|
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
|
||||||
|
|
||||||
// Localized label for an extension type, used in the install dialog.
|
|
||||||
const extensionTypeLabel = (type: string) =>
|
|
||||||
type === 'mcp'
|
|
||||||
? t('market.typeMCP')
|
|
||||||
: type === 'skill'
|
|
||||||
? t('market.typeSkill')
|
|
||||||
: t('market.typePlugin');
|
|
||||||
const {
|
const {
|
||||||
addTask,
|
addTask,
|
||||||
setSelectedTaskId,
|
setSelectedTaskId,
|
||||||
@@ -177,20 +166,6 @@ function AddExtensionContent() {
|
|||||||
const [pluginInstallStatus, setPluginInstallStatus] =
|
const [pluginInstallStatus, setPluginInstallStatus] =
|
||||||
useState<PluginInstallStatus>(PluginInstallStatus.ASK_CONFIRM);
|
useState<PluginInstallStatus>(PluginInstallStatus.ASK_CONFIRM);
|
||||||
const [installError, setInstallError] = useState<string | null>(null);
|
const [installError, setInstallError] = useState<string | null>(null);
|
||||||
const [installIconFailed, setInstallIconFailed] = useState(false);
|
|
||||||
|
|
||||||
// Marketplace icon URL for the extension being installed, by type.
|
|
||||||
const installIconURL = (() => {
|
|
||||||
const cloud = getCloudServiceClientSync();
|
|
||||||
const a = installInfo.plugin_author || '';
|
|
||||||
const n = installInfo.plugin_name || '';
|
|
||||||
if (installExtensionType === 'mcp')
|
|
||||||
return cloud.getMCPMarketplaceIconURL(a, n);
|
|
||||||
if (installExtensionType === 'skill')
|
|
||||||
return cloud.getSkillMarketplaceIconURL(a, n);
|
|
||||||
return cloud.getPluginIconURL(a, n);
|
|
||||||
})();
|
|
||||||
|
|
||||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
const [popoverView, setPopoverView] = useState<PopoverView>('menu');
|
const [popoverView, setPopoverView] = useState<PopoverView>('menu');
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
@@ -249,88 +224,28 @@ function AddExtensionContent() {
|
|||||||
);
|
);
|
||||||
}, [searchParams, setSearchParams]);
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
// One-click install deep link from LangBot Space:
|
|
||||||
// /home/add-extension?install=1&extension_type=mcp&author=X&name=Y&version=Z
|
|
||||||
// Opens the install confirm dialog directly, then strips the params.
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchParams.get('install') !== '1') return;
|
|
||||||
const author = searchParams.get('author');
|
|
||||||
const name = searchParams.get('name');
|
|
||||||
if (!author || !name) return;
|
|
||||||
const rawType =
|
|
||||||
searchParams.get('extension_type') ||
|
|
||||||
searchParams.get('type') ||
|
|
||||||
'plugin';
|
|
||||||
const extType = (
|
|
||||||
['plugin', 'mcp', 'skill'].includes(rawType) ? rawType : 'plugin'
|
|
||||||
) as 'plugin' | 'mcp' | 'skill';
|
|
||||||
const version = searchParams.get('version') || '';
|
|
||||||
|
|
||||||
setInstallInfo({
|
|
||||||
plugin_author: author,
|
|
||||||
plugin_name: name,
|
|
||||||
plugin_version: version,
|
|
||||||
plugin_label: name,
|
|
||||||
});
|
|
||||||
setInstallExtensionType(extType);
|
|
||||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
|
||||||
setInstallError(null);
|
|
||||||
setInstallIconFailed(false);
|
|
||||||
setModalOpen(true);
|
|
||||||
|
|
||||||
setSearchParams(
|
|
||||||
(current) => {
|
|
||||||
const next = new URLSearchParams(current);
|
|
||||||
[
|
|
||||||
'install',
|
|
||||||
'extension_type',
|
|
||||||
'type',
|
|
||||||
'author',
|
|
||||||
'name',
|
|
||||||
'version',
|
|
||||||
].forEach((k) => next.delete(k));
|
|
||||||
return next;
|
|
||||||
},
|
|
||||||
{ replace: true },
|
|
||||||
);
|
|
||||||
}, [searchParams, setSearchParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onComplete = (_taskId: number, success: boolean) => {
|
const onComplete = (_taskId: number, success: boolean) => {
|
||||||
if (success) {
|
if (success) {
|
||||||
toast.success(t('addExtension.installSuccess'));
|
toast.success(t('plugins.installSuccess'));
|
||||||
// Refresh every sidebar extension list so the newly-installed
|
|
||||||
// plugin / MCP / skill shows up immediately, regardless of type.
|
|
||||||
refreshPlugins();
|
refreshPlugins();
|
||||||
refreshMCPServers();
|
|
||||||
refreshSkills();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
registerOnTaskComplete(onComplete);
|
registerOnTaskComplete(onComplete);
|
||||||
return () => {
|
return () => {
|
||||||
unregisterOnTaskComplete(onComplete);
|
unregisterOnTaskComplete(onComplete);
|
||||||
};
|
};
|
||||||
}, [
|
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
|
||||||
registerOnTaskComplete,
|
|
||||||
unregisterOnTaskComplete,
|
|
||||||
refreshPlugins,
|
|
||||||
refreshMCPServers,
|
|
||||||
refreshSkills,
|
|
||||||
t,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleInstallPlugin = useCallback(async (plugin: PluginV4) => {
|
const handleInstallPlugin = useCallback(async (plugin: PluginV4) => {
|
||||||
setInstallInfo({
|
setInstallInfo({
|
||||||
plugin_author: plugin.author,
|
plugin_author: plugin.author,
|
||||||
plugin_name: plugin.name,
|
plugin_name: plugin.name,
|
||||||
plugin_version: plugin.latest_version,
|
plugin_version: plugin.latest_version,
|
||||||
plugin_label: extractI18nObject(plugin.label) || plugin.name,
|
|
||||||
plugin_description: extractI18nObject(plugin.description) || '',
|
|
||||||
});
|
});
|
||||||
setInstallExtensionType(plugin.type || 'plugin');
|
setInstallExtensionType(plugin.type || 'plugin');
|
||||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||||
setInstallError(null);
|
setInstallError(null);
|
||||||
setInstallIconFailed(false);
|
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1230,52 +1145,22 @@ function AddExtensionContent() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-4">
|
<DialogTitle className="flex items-center gap-4">
|
||||||
<Download className="size-6" />
|
<Download className="size-6" />
|
||||||
<span>
|
<span>{t('plugins.installPlugin')}</span>
|
||||||
{t('addExtension.installTitle', {
|
|
||||||
type: extensionTypeLabel(installExtensionType),
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
|
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4">
|
||||||
<p>
|
<p className="mb-2">
|
||||||
{t('addExtension.installConfirm', {
|
{installInfo.plugin_version
|
||||||
type: extensionTypeLabel(installExtensionType),
|
? t('plugins.askConfirm', {
|
||||||
name: installInfo.plugin_label || installInfo.plugin_name,
|
name: installInfo.plugin_name,
|
||||||
})}
|
version: installInfo.plugin_version,
|
||||||
|
})
|
||||||
|
: t('plugins.askConfirmNoVersion', {
|
||||||
|
name: installInfo.plugin_name,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3 rounded-md bg-muted/40 p-3">
|
|
||||||
{installIconFailed ? (
|
|
||||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg border bg-background text-muted-foreground">
|
|
||||||
<Package className="size-6" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={installIconURL}
|
|
||||||
alt={installInfo.plugin_name}
|
|
||||||
className="size-12 shrink-0 rounded-lg border bg-background object-cover"
|
|
||||||
onError={() => setInstallIconFailed(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1 space-y-0.5">
|
|
||||||
<div className="truncate font-medium">
|
|
||||||
{installInfo.plugin_label || installInfo.plugin_name}
|
|
||||||
</div>
|
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
|
||||||
{installInfo.plugin_author}/{installInfo.plugin_name}
|
|
||||||
{installInfo.plugin_version
|
|
||||||
? ` · v${installInfo.plugin_version}`
|
|
||||||
: ''}
|
|
||||||
</div>
|
|
||||||
{installInfo.plugin_description && (
|
|
||||||
<div className="line-clamp-3 pt-0.5 text-xs text-muted-foreground">
|
|
||||||
{installInfo.plugin_description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
HardDrive,
|
HardDrive,
|
||||||
Server,
|
Server,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
RefreshCcw,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTheme } from '@/components/providers/theme-provider';
|
import { useTheme } from '@/components/providers/theme-provider';
|
||||||
|
|
||||||
@@ -119,22 +118,6 @@ 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',
|
||||||
@@ -342,23 +325,6 @@ function NavItems({
|
|||||||
);
|
);
|
||||||
// Track popover open state for collapsed sidebar entity categories
|
// Track popover open state for collapsed sidebar entity categories
|
||||||
const [popoverOpen, setPopoverOpen] = useState<Record<string, boolean>>({});
|
const [popoverOpen, setPopoverOpen] = useState<Record<string, boolean>>({});
|
||||||
// Spin state for the installed-extensions refresh button
|
|
||||||
const [extRefreshing, setExtRefreshing] = useState(false);
|
|
||||||
|
|
||||||
const handleRefreshExtensions = async (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (extRefreshing) return;
|
|
||||||
setExtRefreshing(true);
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
sidebarData.refreshPlugins(),
|
|
||||||
sidebarData.refreshMCPServers(),
|
|
||||||
sidebarData.refreshSkills(),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
setExtRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Plugin operation state
|
// Plugin operation state
|
||||||
const [showPluginOpModal, setShowPluginOpModal] = useState(false);
|
const [showPluginOpModal, setShowPluginOpModal] = useState(false);
|
||||||
@@ -728,21 +694,17 @@ function NavItems({
|
|||||||
</a>
|
</a>
|
||||||
</SidebarMenuSubButton>
|
</SidebarMenuSubButton>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent
|
{item.description && (
|
||||||
side="right"
|
<TooltipContent
|
||||||
align="center"
|
side="right"
|
||||||
className="max-w-64"
|
align="center"
|
||||||
>
|
className="max-w-64"
|
||||||
{/* Full name — so truncated sidebar items are readable on hover */}
|
>
|
||||||
<div className="break-words font-medium">{item.name}</div>
|
{item.description.length > 80
|
||||||
{item.description && (
|
? item.description.slice(0, 80) + '…'
|
||||||
<div className="mt-0.5 break-words text-xs text-muted-foreground">
|
: item.description}
|
||||||
{item.description.length > 80
|
</TooltipContent>
|
||||||
? item.description.slice(0, 80) + '…'
|
)}
|
||||||
: item.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/* Plugin context menu - shown on hover (not for debug plugins) */}
|
{/* Plugin context menu - shown on hover (not for debug plugins) */}
|
||||||
{itemIsPluginType && !item.debug && (
|
{itemIsPluginType && !item.debug && (
|
||||||
@@ -1011,21 +973,6 @@ function NavItems({
|
|||||||
{config.name}
|
{config.name}
|
||||||
</span>
|
</span>
|
||||||
<div className="ml-auto flex items-center gap-0.5 -mr-1">
|
<div className="ml-auto flex items-center gap-0.5 -mr-1">
|
||||||
{isExtensionsCategory && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={t('common.refresh', '刷新')}
|
|
||||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
|
||||||
onClick={handleRefreshExtensions}
|
|
||||||
>
|
|
||||||
<RefreshCcw
|
|
||||||
className={cn(
|
|
||||||
'size-3.5',
|
|
||||||
extRefreshing && 'animate-spin',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canCreate &&
|
{canCreate &&
|
||||||
(isPlugin ? (
|
(isPlugin ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -1817,21 +1764,7 @@ export default function HomeSidebar({
|
|||||||
className="size-8 rounded-lg"
|
className="size-8 rounded-lg"
|
||||||
/>
|
/>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<div className="flex items-center gap-1.5">
|
<span className="truncate font-semibold">LangBot</span>
|
||||||
<span className="truncate font-semibold">LangBot</span>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className={`shrink-0 px-1 py-0 h-3.5 text-[0.55rem] font-medium ${
|
|
||||||
systemInfo?.edition === 'cloud'
|
|
||||||
? 'border-transparent bg-blue-500 text-white'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{systemInfo?.edition === 'cloud'
|
|
||||||
? t('sidebar.editionCloud')
|
|
||||||
: t('sidebar.editionCommunity')}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
{systemInfo?.version}
|
{systemInfo?.version}
|
||||||
@@ -2093,14 +2026,6 @@ 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 />
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover';
|
} from '@/components/ui/popover';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, 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,8 +298,20 @@ 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">
|
||||||
{mode === 'manual' ? (
|
<Tabs
|
||||||
<div className="mt-3">
|
value={mode}
|
||||||
|
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>
|
||||||
@@ -378,9 +390,9 @@ export default function AddModelPopover({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsContent>
|
||||||
) : (
|
|
||||||
<div className="space-y-2 mt-3">
|
<TabsContent value="scan" className="space-y-2 mt-0 pt-0">
|
||||||
{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" />
|
||||||
@@ -553,8 +565,8 @@ export default function AddModelPopover({
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsContent>
|
||||||
)}
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|||||||
@@ -4,16 +4,11 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import { Loader2, RefreshCw, CheckCircle2, XCircle } from 'lucide-react';
|
||||||
Loader2,
|
|
||||||
RefreshCw,
|
|
||||||
RotateCw,
|
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
||||||
@@ -101,7 +96,7 @@ interface QrCodeLoginDialogProps {
|
|||||||
onSuccess: (credentials: Record<string, string>) => void;
|
onSuccess: (credentials: Record<string, string>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DialogState = 'connecting' | 'waiting' | 'expired' | 'success' | 'error';
|
type DialogState = 'connecting' | 'waiting' | 'success' | 'error';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 3000;
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
@@ -120,10 +115,8 @@ export default function QrCodeLoginDialog({
|
|||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const sessionIdRef = useRef<string | null>(null);
|
const sessionIdRef = useRef<string | null>(null);
|
||||||
const baseUrlRef = useRef('');
|
|
||||||
const cleanedRef = useRef(false);
|
const cleanedRef = useRef(false);
|
||||||
|
|
||||||
const onSuccessRef = useRef(onSuccess);
|
const onSuccessRef = useRef(onSuccess);
|
||||||
@@ -147,14 +140,11 @@ export default function QrCodeLoginDialog({
|
|||||||
clearInterval(countdownRef.current);
|
clearInterval(countdownRef.current);
|
||||||
countdownRef.current = null;
|
countdownRef.current = null;
|
||||||
}
|
}
|
||||||
if (checkExpiredRef.current) {
|
|
||||||
clearInterval(checkExpiredRef.current);
|
|
||||||
checkExpiredRef.current = null;
|
|
||||||
}
|
|
||||||
if (abortRef.current) {
|
if (abortRef.current) {
|
||||||
abortRef.current.abort();
|
abortRef.current.abort();
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
}
|
}
|
||||||
|
// Cancel backend session
|
||||||
if (sessionIdRef.current) {
|
if (sessionIdRef.current) {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
@@ -181,7 +171,6 @@ export default function QrCodeLoginDialog({
|
|||||||
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||||
baseUrlRef.current = baseUrl;
|
|
||||||
const cfg = platformConfigRef.current;
|
const cfg = platformConfigRef.current;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -202,6 +191,8 @@ export default function QrCodeLoginDialog({
|
|||||||
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
|
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
|
||||||
sessionIdRef.current = session_id;
|
sessionIdRef.current = session_id;
|
||||||
|
|
||||||
|
// qr_data_url is a pre-rendered data URL (WeChat);
|
||||||
|
// qr_url is a plain URL string (Feishu) that needs local QR generation.
|
||||||
if (qr_data_url) {
|
if (qr_data_url) {
|
||||||
setQrDataUrl(qr_data_url);
|
setQrDataUrl(qr_data_url);
|
||||||
} else if (qr_url) {
|
} else if (qr_url) {
|
||||||
@@ -213,9 +204,11 @@ export default function QrCodeLoginDialog({
|
|||||||
}
|
}
|
||||||
setState('waiting');
|
setState('waiting');
|
||||||
|
|
||||||
|
// Calculate remaining seconds
|
||||||
const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000));
|
const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000));
|
||||||
setExpireIn(remaining);
|
setExpireIn(remaining);
|
||||||
|
|
||||||
|
// Start countdown
|
||||||
countdownRef.current = setInterval(() => {
|
countdownRef.current = setInterval(() => {
|
||||||
setExpireIn((prev) => {
|
setExpireIn((prev) => {
|
||||||
if (prev <= 1) {
|
if (prev <= 1) {
|
||||||
@@ -229,35 +222,7 @@ export default function QrCodeLoginDialog({
|
|||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
// When countdown hits 0, stop polling and show expired state
|
// Start polling
|
||||||
checkExpiredRef.current = setInterval(() => {
|
|
||||||
setExpireIn((current) => {
|
|
||||||
if (current <= 0) {
|
|
||||||
if (checkExpiredRef.current) {
|
|
||||||
clearInterval(checkExpiredRef.current);
|
|
||||||
checkExpiredRef.current = null;
|
|
||||||
}
|
|
||||||
if (pollTimerRef.current) {
|
|
||||||
clearInterval(pollTimerRef.current);
|
|
||||||
pollTimerRef.current = null;
|
|
||||||
}
|
|
||||||
if (sessionIdRef.current) {
|
|
||||||
fetch(
|
|
||||||
`${baseUrlRef.current}${cfg.apiBase}/${sessionIdRef.current}`,
|
|
||||||
{
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
keepalive: true,
|
|
||||||
},
|
|
||||||
).catch(() => {});
|
|
||||||
sessionIdRef.current = null;
|
|
||||||
}
|
|
||||||
setState('expired');
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
});
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
pollTimerRef.current = setInterval(async () => {
|
pollTimerRef.current = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const pollRes = await fetch(
|
const pollRes = await fetch(
|
||||||
@@ -272,7 +237,7 @@ export default function QrCodeLoginDialog({
|
|||||||
const { status, error, ...rest } = pollJson.data;
|
const { status, error, ...rest } = pollJson.data;
|
||||||
|
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
sessionIdRef.current = null;
|
sessionIdRef.current = null; // backend already cleaned up
|
||||||
cleanup();
|
cleanup();
|
||||||
setState('success');
|
setState('success');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -284,14 +249,9 @@ export default function QrCodeLoginDialog({
|
|||||||
cleanup();
|
cleanup();
|
||||||
setState('error');
|
setState('error');
|
||||||
setErrorMessage(error || tRef.current(cfg.failedKey));
|
setErrorMessage(error || tRef.current(cfg.failedKey));
|
||||||
} else if (status === 'expired') {
|
|
||||||
sessionIdRef.current = null;
|
|
||||||
cleanup();
|
|
||||||
setExpireIn(0);
|
|
||||||
setState('expired');
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore poll errors
|
// ignore poll errors, will retry next interval
|
||||||
}
|
}
|
||||||
}, POLL_INTERVAL_MS);
|
}, POLL_INTERVAL_MS);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -363,31 +323,6 @@ export default function QrCodeLoginDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* QR code expired — click overlay to refresh */}
|
|
||||||
{state === 'expired' && qrDataUrl && (
|
|
||||||
<div className="flex flex-col items-center space-y-3">
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
{t(platformConfig.scanQRCodeKey)}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative border rounded-lg p-2 bg-white cursor-pointer group"
|
|
||||||
onClick={() => startLogin()}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={qrDataUrl}
|
|
||||||
alt="QR Code"
|
|
||||||
className="w-56 h-56 opacity-40"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/60 rounded-lg group-hover:bg-white/70 transition-colors">
|
|
||||||
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-black/5 group-hover:bg-black/10 transition-colors">
|
|
||||||
<RotateCw className="h-8 w-8 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Success */}
|
{/* Success */}
|
||||||
{state === 'success' && (
|
{state === 'success' && (
|
||||||
<div className="flex flex-col items-center space-y-3 py-8">
|
<div className="flex flex-col items-center space-y-3 py-8">
|
||||||
@@ -415,7 +350,7 @@ export default function QrCodeLoginDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state === 'error' && (
|
{state === 'error' && (
|
||||||
<div className="flex justify-end gap-2">
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -423,7 +358,7 @@ export default function QrCodeLoginDialog({
|
|||||||
<RefreshCw className="h-4 w-4 mr-1.5" />
|
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||||
{t(platformConfig.retryKey)}
|
{t(platformConfig.retryKey)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -71,13 +71,7 @@ function StatusDisplay({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CONNECTING, or any not-yet-resolved status (initial/null while the box is
|
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
|
||||||
// still bringing the session up) — show "connecting" rather than failing.
|
|
||||||
if (
|
|
||||||
runtimeInfo.status === MCPSessionStatus.CONNECTING ||
|
|
||||||
(runtimeInfo.status !== MCPSessionStatus.ERROR &&
|
|
||||||
runtimeInfo.error_phase !== 'box_unavailable')
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-blue-600">
|
<div className="flex items-center gap-2 text-blue-600">
|
||||||
<Loader2 className="size-5 animate-spin" />
|
<Loader2 className="size-5 animate-spin" />
|
||||||
@@ -264,13 +258,6 @@ function RuntimePanel({
|
|||||||
|
|
||||||
const isConnected =
|
const isConnected =
|
||||||
!mcpTesting && runtimeInfo.status === MCPSessionStatus.CONNECTED;
|
!mcpTesting && runtimeInfo.status === MCPSessionStatus.CONNECTED;
|
||||||
// Only treat an explicit error (or box-unavailable) as failed; while testing,
|
|
||||||
// connecting, or in an initial/unresolved state, show "connecting" so we
|
|
||||||
// don't flash "connection failed" during a normal connection attempt.
|
|
||||||
const isFailed =
|
|
||||||
!mcpTesting &&
|
|
||||||
(runtimeInfo.status === MCPSessionStatus.ERROR ||
|
|
||||||
runtimeInfo.error_phase === 'box_unavailable');
|
|
||||||
const tools = runtimeInfo.tools || [];
|
const tools = runtimeInfo.tools || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -281,9 +268,7 @@ function RuntimePanel({
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{isConnected
|
{isConnected
|
||||||
? t('mcp.toolCount', { count: tools.length })
|
? t('mcp.toolCount', { count: tools.length })
|
||||||
: isFailed
|
: t('mcp.connectionFailedStatus')}
|
||||||
? t('mcp.connectionFailedStatus')
|
|
||||||
: t('mcp.connecting')}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isConnected && (
|
{isConnected && (
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import { Button } from '@/components/ui/button';
|
|||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Package,
|
Package,
|
||||||
Server,
|
|
||||||
Sparkles,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -173,15 +171,6 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
|||||||
const isDone = task.stage === InstallStage.DONE;
|
const isDone = task.stage === InstallStage.DONE;
|
||||||
const isError = task.stage === InstallStage.ERROR;
|
const isError = task.stage === InstallStage.ERROR;
|
||||||
|
|
||||||
// MCP / Skill don't have the plugin's download + dependency-install stages;
|
|
||||||
// show a single "installing → done/failed" row instead of plugin steps.
|
|
||||||
const isPlugin = task.extensionType === 'plugin';
|
|
||||||
const simpleIcon = task.extensionType === 'mcp' ? Server : Sparkles;
|
|
||||||
const simpleInstallingLabel =
|
|
||||||
task.extensionType === 'mcp'
|
|
||||||
? t('addExtension.installStage.mcpInstalling')
|
|
||||||
: t('addExtension.installStage.skillInstalling');
|
|
||||||
|
|
||||||
/** Build detail node for a stage */
|
/** Build detail node for a stage */
|
||||||
const getStageDetail = (
|
const getStageDetail = (
|
||||||
stageKey: InstallStage,
|
stageKey: InstallStage,
|
||||||
@@ -318,60 +307,42 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
|||||||
|
|
||||||
{/* Stage display */}
|
{/* Stage display */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{!isPlugin ? (
|
{isDone
|
||||||
/* MCP / Skill: single installing → done/failed row */
|
? /* When done: show all stages with completed style */
|
||||||
<StageRow
|
STAGES.map((stageConfig) => (
|
||||||
icon={simpleIcon}
|
<StageRow
|
||||||
label={
|
key={stageConfig.key}
|
||||||
isDone
|
icon={stageConfig.icon}
|
||||||
? t('addExtension.installStage.installed')
|
label={t(stageConfig.i18nKey)}
|
||||||
: isError
|
isActive={false}
|
||||||
? t('plugins.installProgress.failed')
|
isCompleted={true}
|
||||||
: simpleInstallingLabel
|
isError={false}
|
||||||
}
|
detail={getStageDetail(stageConfig.key, true)}
|
||||||
isActive={!isDone}
|
/>
|
||||||
isCompleted={isDone}
|
))
|
||||||
isError={isError}
|
: isError
|
||||||
detail={isError ? task.error : undefined}
|
? /* Error: show the failed stage */
|
||||||
/>
|
currentStageIndex >= 0 && (
|
||||||
) : isDone ? (
|
<StageRow
|
||||||
/* When done: show all stages with completed style */
|
icon={STAGES[currentStageIndex].icon}
|
||||||
STAGES.map((stageConfig) => (
|
label={t(STAGES[currentStageIndex].i18nKey)}
|
||||||
<StageRow
|
isActive={true}
|
||||||
key={stageConfig.key}
|
isCompleted={false}
|
||||||
icon={stageConfig.icon}
|
isError={true}
|
||||||
label={t(stageConfig.i18nKey)}
|
detail={task.error}
|
||||||
isActive={false}
|
/>
|
||||||
isCompleted={true}
|
)
|
||||||
isError={false}
|
: /* In progress: only show the current active stage */
|
||||||
detail={getStageDetail(stageConfig.key, true)}
|
currentStageIndex >= 0 && (
|
||||||
/>
|
<StageRow
|
||||||
))
|
icon={STAGES[currentStageIndex].icon}
|
||||||
) : isError ? (
|
label={t(STAGES[currentStageIndex].i18nKey)}
|
||||||
/* Error: show the failed stage */
|
isActive={true}
|
||||||
currentStageIndex >= 0 && (
|
isCompleted={false}
|
||||||
<StageRow
|
isError={false}
|
||||||
icon={STAGES[currentStageIndex].icon}
|
detail={getStageDetail(STAGES[currentStageIndex].key, false)}
|
||||||
label={t(STAGES[currentStageIndex].i18nKey)}
|
/>
|
||||||
isActive={true}
|
)}
|
||||||
isCompleted={false}
|
|
||||||
isError={true}
|
|
||||||
detail={task.error}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
/* In progress: only show the current active stage */
|
|
||||||
currentStageIndex >= 0 && (
|
|
||||||
<StageRow
|
|
||||||
icon={STAGES[currentStageIndex].icon}
|
|
||||||
label={t(STAGES[currentStageIndex].i18nKey)}
|
|
||||||
isActive={true}
|
|
||||||
isCompleted={false}
|
|
||||||
isError={false}
|
|
||||||
detail={getStageDetail(STAGES[currentStageIndex].key, false)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Done banner */}
|
{/* Done banner */}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
X,
|
X,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
Puzzle,
|
Wrench,
|
||||||
Server,
|
AudioWaveform,
|
||||||
Sparkles,
|
Book,
|
||||||
} 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: Puzzle,
|
plugin: Wrench,
|
||||||
mcp: Server,
|
mcp: AudioWaveform,
|
||||||
skill: Sparkles,
|
skill: Book,
|
||||||
};
|
};
|
||||||
|
|
||||||
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] || Puzzle;
|
const TypeIcon = EXTENSION_TYPE_ICONS[task.extensionType] || Wrench;
|
||||||
|
|
||||||
const getTypeBadgeClass = () => {
|
const getTypeBadgeClass = () => {
|
||||||
switch (task.extensionType) {
|
switch (task.extensionType) {
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ 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, Server, Sparkles } from 'lucide-react';
|
import { Loader2, Puzzle } from 'lucide-react';
|
||||||
|
import { Wrench, AudioWaveform, Book } from 'lucide-react';
|
||||||
|
|
||||||
export interface PluginInstalledComponentRef {
|
export interface PluginInstalledComponentRef {
|
||||||
refreshPluginList: () => void;
|
refreshPluginList: () => void;
|
||||||
@@ -43,18 +44,14 @@ export const FilterOptions = [
|
|||||||
{
|
{
|
||||||
value: 'plugin' as FilterType,
|
value: 'plugin' as FilterType,
|
||||||
labelKey: 'market.typePlugin',
|
labelKey: 'market.typePlugin',
|
||||||
icon: Puzzle,
|
icon: Wrench,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'mcp' as FilterType,
|
value: 'mcp' as FilterType,
|
||||||
labelKey: 'market.typeMCP',
|
labelKey: 'market.typeMCP',
|
||||||
icon: Server,
|
icon: AudioWaveform,
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'skill' as FilterType,
|
|
||||||
labelKey: 'market.typeSkill',
|
|
||||||
icon: Sparkles,
|
|
||||||
},
|
},
|
||||||
|
{ value: 'skill' as FilterType, labelKey: 'market.typeSkill', icon: Book },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface PluginInstalledComponentProps {
|
interface PluginInstalledComponentProps {
|
||||||
|
|||||||
@@ -17,32 +17,17 @@ 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,
|
||||||
Book,
|
Book,
|
||||||
FileText,
|
FileText,
|
||||||
AppWindow,
|
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
X,
|
X,
|
||||||
Info,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
||||||
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
|
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
|
||||||
import { RecommendationLists } from './RecommendationLists';
|
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||||
import type { RecommendationList } from './RecommendationLists';
|
|
||||||
import {
|
|
||||||
getCloudServiceClient,
|
|
||||||
getCloudServiceClientSync,
|
|
||||||
} from '@/app/infra/http';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PluginV4, PluginV4Status } from '@/app/infra/entities/plugin';
|
import { PluginV4, PluginV4Status } from '@/app/infra/entities/plugin';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
@@ -59,23 +44,6 @@ interface SortOption {
|
|||||||
sortOrder: string;
|
sortOrder: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the market filter conditions (type / component / tags / sort) across
|
|
||||||
// visits via localStorage.
|
|
||||||
const MARKET_FILTERS_KEY = 'langbot_market_filters';
|
|
||||||
interface MarketFilters {
|
|
||||||
typeFilter?: string;
|
|
||||||
componentFilter?: string;
|
|
||||||
selectedTags?: string[];
|
|
||||||
sortOption?: string;
|
|
||||||
}
|
|
||||||
function loadMarketFilters(): MarketFilters {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(MARKET_FILTERS_KEY) || '{}');
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内部组件,用于处理搜索参数
|
// 内部组件,用于处理搜索参数
|
||||||
function MarketPageContent({
|
function MarketPageContent({
|
||||||
installPlugin,
|
installPlugin,
|
||||||
@@ -91,59 +59,32 @@ 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: Puzzle },
|
{ value: 'plugin', label: t('market.typePlugin'), icon: Wrench },
|
||||||
{ value: 'mcp', label: t('market.typeMCP'), icon: Server },
|
{ value: 'mcp', label: t('market.typeMCP'), icon: AudioWaveform },
|
||||||
{ value: 'skill', label: t('market.typeSkill'), icon: Sparkles },
|
{ value: 'skill', label: t('market.typeSkill'), icon: Book },
|
||||||
];
|
];
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [componentFilter, setComponentFilter] = useState<string>(
|
const [componentFilter, setComponentFilter] = useState('all');
|
||||||
() => loadMarketFilters().componentFilter ?? 'all',
|
|
||||||
);
|
|
||||||
const [typeFilter, setTypeFilter] = useState<string>(() => {
|
const [typeFilter, setTypeFilter] = useState<string>(() => {
|
||||||
const type = searchParams.get('type');
|
const type = searchParams.get('type');
|
||||||
if (type && validTypes.includes(type)) {
|
if (type && validTypes.includes(type)) {
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
const saved = loadMarketFilters().typeFilter;
|
return 'all';
|
||||||
return saved && validTypes.includes(saved) ? saved : 'all';
|
|
||||||
});
|
});
|
||||||
const activeAdvancedFilters =
|
const activeAdvancedFilters =
|
||||||
(typeFilter === 'all' ? 0 : 1) + (componentFilter === 'all' ? 0 : 1);
|
(typeFilter === 'all' ? 0 : 1) + (componentFilter === 'all' ? 0 : 1);
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>(
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
() => loadMarketFilters().selectedTags ?? [],
|
|
||||||
);
|
|
||||||
const [availableTags, setAvailableTags] = useState<PluginTag[]>([]);
|
const [availableTags, setAvailableTags] = useState<PluginTag[]>([]);
|
||||||
const [tagNames, setTagNames] = useState<Record<string, string>>({});
|
const [tagNames, setTagNames] = useState<Record<string, string>>({});
|
||||||
const [recommendationLists, setRecommendationLists] = useState<
|
|
||||||
RecommendationList[]
|
|
||||||
>([]);
|
|
||||||
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
|
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [sortOption, setSortOption] = useState<string>(
|
const [sortOption, setSortOption] = useState('install_count_desc');
|
||||||
() => loadMarketFilters().sortOption ?? 'install_count_desc',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Persist filter conditions so they survive navigation / reload.
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
MARKET_FILTERS_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
typeFilter,
|
|
||||||
componentFilter,
|
|
||||||
selectedTags,
|
|
||||||
sortOption,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// ignore storage errors
|
|
||||||
}
|
|
||||||
}, [typeFilter, componentFilter, selectedTags, sortOption]);
|
|
||||||
|
|
||||||
const pageSize = 12; // 每页12个
|
const pageSize = 12; // 每页12个
|
||||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -197,11 +138,6 @@ function MarketPageContent({
|
|||||||
label: t('market.componentName.Parser'),
|
label: t('market.componentName.Parser'),
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: 'Page',
|
|
||||||
label: t('market.componentName.Page'),
|
|
||||||
icon: AppWindow,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 获取当前排序参数
|
// 获取当前排序参数
|
||||||
@@ -312,29 +248,11 @@ function MarketPageContent({
|
|||||||
|
|
||||||
// 初始加载
|
// 初始加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Resolve the cloud service base URL (from system info) before any
|
fetchPlugins(1, false, true);
|
||||||
// marketplace fetch — otherwise the sync client may still hold the default
|
fetchAvailableTags();
|
||||||
// URL and hit space.langbot.app instead of the configured instance.
|
|
||||||
(async () => {
|
|
||||||
await getCloudServiceClient();
|
|
||||||
fetchPlugins(1, false, true);
|
|
||||||
fetchAvailableTags();
|
|
||||||
fetchRecommendationLists();
|
|
||||||
})();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 获取推荐列表(精选,混合插件/MCP/Skill)
|
|
||||||
const fetchRecommendationLists = async () => {
|
|
||||||
try {
|
|
||||||
const client = await getCloudServiceClient();
|
|
||||||
const { lists } = await client.getRecommendationLists();
|
|
||||||
setRecommendationLists(lists || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch recommendation lists:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取可用标签
|
// 获取可用标签
|
||||||
const fetchAvailableTags = async () => {
|
const fetchAvailableTags = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -698,22 +616,8 @@ function MarketPageContent({
|
|||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1 text-xs font-medium text-muted-foreground">
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
{t('market.filterByComponent')}
|
{t('market.filterByComponent')}
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex text-muted-foreground/70 hover:text-foreground"
|
|
||||||
aria-label={t('market.filterByComponentHint')}
|
|
||||||
>
|
|
||||||
<Info className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="max-w-64">
|
|
||||||
{t('market.filterByComponentHint')}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
type="single"
|
type="single"
|
||||||
@@ -797,18 +701,6 @@ function MarketPageContent({
|
|||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className="flex-1 overflow-y-auto px-3 sm:px-4 pb-6 container mx-auto"
|
className="flex-1 overflow-y-auto px-3 sm:px-4 pb-6 container mx-auto"
|
||||||
>
|
>
|
||||||
{/* 推荐列表(仅在无搜索/筛选时展示,混合插件/MCP/Skill) */}
|
|
||||||
{!searchQuery &&
|
|
||||||
typeFilter === 'all' &&
|
|
||||||
componentFilter === 'all' &&
|
|
||||||
selectedTags.length === 0 && (
|
|
||||||
<RecommendationLists
|
|
||||||
lists={recommendationLists}
|
|
||||||
tagNames={tagNames}
|
|
||||||
onInstall={handleInstallPlugin}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<LoadingSpinner text={t('market.loading')} />
|
<LoadingSpinner text={t('market.loading')} />
|
||||||
|
|||||||
@@ -22,15 +22,6 @@ function pluginToVO(
|
|||||||
plugin: PluginV4,
|
plugin: PluginV4,
|
||||||
t: (key: string) => string,
|
t: (key: string) => string,
|
||||||
): PluginMarketCardVO {
|
): PluginMarketCardVO {
|
||||||
const cloudClient = getCloudServiceClientSync();
|
|
||||||
// Recommendation lists are mixed-type; resolve the icon per extension type.
|
|
||||||
const iconURL =
|
|
||||||
plugin.type === 'mcp'
|
|
||||||
? cloudClient.getMCPMarketplaceIconURL(plugin.author, plugin.name)
|
|
||||||
: plugin.type === 'skill'
|
|
||||||
? cloudClient.getSkillMarketplaceIconURL(plugin.author, plugin.name)
|
|
||||||
: cloudClient.getPluginIconURL(plugin.author, plugin.name);
|
|
||||||
|
|
||||||
return new PluginMarketCardVO({
|
return new PluginMarketCardVO({
|
||||||
pluginId: plugin.author + ' / ' + plugin.name,
|
pluginId: plugin.author + ' / ' + plugin.name,
|
||||||
author: plugin.author,
|
author: plugin.author,
|
||||||
@@ -39,7 +30,10 @@ function pluginToVO(
|
|||||||
description:
|
description:
|
||||||
extractI18nObject(plugin.description) || t('market.noDescription'),
|
extractI18nObject(plugin.description) || t('market.noDescription'),
|
||||||
installCount: plugin.install_count,
|
installCount: plugin.install_count,
|
||||||
iconURL,
|
iconURL: getCloudServiceClientSync().getPluginIconURL(
|
||||||
|
plugin.author,
|
||||||
|
plugin.name,
|
||||||
|
),
|
||||||
githubURL: plugin.repository,
|
githubURL: plugin.repository,
|
||||||
version: plugin.latest_version,
|
version: plugin.latest_version,
|
||||||
components: plugin.components,
|
components: plugin.components,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function TagsFilter({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Select open={open} onOpenChange={setOpen}>
|
<Select open={open} onOpenChange={setOpen}>
|
||||||
<SelectTrigger className="w-[140px] cursor-pointer">
|
<SelectTrigger className="w-[140px]">
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<TagIcon className="h-4 w-4 flex-shrink-0" />
|
<TagIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
{selectedTags.length === 0 ? (
|
{selectedTags.length === 0 ? (
|
||||||
|
|||||||
@@ -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] 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"
|
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"
|
||||||
onClick={handleInstallClick}
|
onClick={handleInstallClick}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
|||||||
@@ -129,22 +129,22 @@ export default function MCPCardComponent({
|
|||||||
{t('mcp.toolCount', { count: toolsCount })}
|
{t('mcp.toolCount', { count: toolsCount })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : status === MCPSessionStatus.ERROR ? (
|
) : status === MCPSessionStatus.CONNECTING ? (
|
||||||
// 连接失败 - 红色(仅在明确报错时)
|
// 连接中 - 蓝色加载
|
||||||
<div className="flex flex-row items-center gap-[0.4rem]">
|
|
||||||
<AlertCircle className="w-4 h-4 text-red-500 dark:text-red-400" />
|
|
||||||
<div className="text-sm text-red-500 dark:text-red-400 font-medium">
|
|
||||||
{t('mcp.connectionFailedStatus')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// 连接中 - 蓝色加载(CONNECTING 或初始/未知状态,避免误报失败)
|
|
||||||
<div className="flex flex-row items-center gap-[0.4rem]">
|
<div className="flex flex-row items-center gap-[0.4rem]">
|
||||||
<Loader2 className="w-4 h-4 text-blue-500 dark:text-blue-400 animate-spin" />
|
<Loader2 className="w-4 h-4 text-blue-500 dark:text-blue-400 animate-spin" />
|
||||||
<div className="text-sm text-blue-500 dark:text-blue-400 font-medium">
|
<div className="text-sm text-blue-500 dark:text-blue-400 font-medium">
|
||||||
{t('mcp.connecting')}
|
{t('mcp.connecting')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
// 连接失败 - 红色
|
||||||
|
<div className="flex flex-row items-center gap-[0.4rem]">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-500 dark:text-red-400" />
|
||||||
|
<div className="text-sm text-red-500 dark:text-red-400 font-medium">
|
||||||
|
{t('mcp.connectionFailedStatus')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ const enUS = {
|
|||||||
pluginPagesTooltip: 'Visual pages provided by installed plugins',
|
pluginPagesTooltip: 'Visual pages provided by installed plugins',
|
||||||
quickStart: 'Quick Start',
|
quickStart: 'Quick Start',
|
||||||
scrollToBottom: 'Scroll to bottom',
|
scrollToBottom: 'Scroll to bottom',
|
||||||
editionCommunity: 'Community',
|
|
||||||
editionCloud: 'Cloud',
|
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
login: 'Login',
|
login: 'Login',
|
||||||
@@ -37,7 +35,6 @@ 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',
|
||||||
@@ -457,7 +454,7 @@ const enUS = {
|
|||||||
noPluginInstalled: 'No plugins installed',
|
noPluginInstalled: 'No plugins installed',
|
||||||
noExtensionInstalled: 'No extensions installed',
|
noExtensionInstalled: 'No extensions installed',
|
||||||
loadingExtensions: 'Loading extensions...',
|
loadingExtensions: 'Loading extensions...',
|
||||||
groupByType: 'Group by format',
|
groupByType: 'Group by type',
|
||||||
pluginConfig: 'Plugin Configuration',
|
pluginConfig: 'Plugin Configuration',
|
||||||
pluginSort: 'Plugin Sort',
|
pluginSort: 'Plugin Sort',
|
||||||
pluginSortDescription:
|
pluginSortDescription:
|
||||||
@@ -632,8 +629,8 @@ const enUS = {
|
|||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: 'Search plugins...',
|
searchPlaceholder: 'Search plugins...',
|
||||||
searchResults: 'Found {{count}} extensions',
|
searchResults: 'Found {{count}} plugins',
|
||||||
totalPlugins: 'Total {{count}} extensions',
|
totalPlugins: 'Total {{count}} plugins',
|
||||||
noPlugins: 'No plugins available',
|
noPlugins: 'No plugins available',
|
||||||
noResults: 'No relevant plugins found',
|
noResults: 'No relevant plugins found',
|
||||||
loadingMore: 'Loading more...',
|
loadingMore: 'Loading more...',
|
||||||
@@ -673,18 +670,8 @@ const enUS = {
|
|||||||
markAsRead: 'Mark as Read',
|
markAsRead: 'Mark as Read',
|
||||||
markAsReadSuccess: 'Marked as read',
|
markAsReadSuccess: 'Marked as read',
|
||||||
markAsReadFailed: 'Mark as read failed',
|
markAsReadFailed: 'Mark as read failed',
|
||||||
filterByComponent: 'Plugin Component',
|
filterByComponent: 'Component',
|
||||||
filterByComponentHint:
|
|
||||||
'The capability types a plugin provides — Tool, Command, EventListener, etc. — used to extend LangBot in various ways. Filter by component to show only plugins offering that capability.',
|
|
||||||
allComponents: 'All Components',
|
allComponents: 'All Components',
|
||||||
componentName: {
|
|
||||||
Tool: 'Tool',
|
|
||||||
EventListener: 'Event Listener',
|
|
||||||
Command: 'Command',
|
|
||||||
KnowledgeEngine: 'Knowledge Engine',
|
|
||||||
Parser: 'Parser',
|
|
||||||
Page: 'Page',
|
|
||||||
},
|
|
||||||
filterByType: 'Type',
|
filterByType: 'Type',
|
||||||
allTypes: 'All Types',
|
allTypes: 'All Types',
|
||||||
typePlugin: 'Plugin',
|
typePlugin: 'Plugin',
|
||||||
@@ -1544,17 +1531,6 @@ const enUS = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
addExtension: {
|
addExtension: {
|
||||||
installTitle: 'Install {{type}}',
|
|
||||||
installConfirm: 'Install {{type}} "{{name}}"?',
|
|
||||||
installInfoType: 'Type',
|
|
||||||
installInfoId: 'ID',
|
|
||||||
installInfoVersion: 'Version',
|
|
||||||
installSuccess: 'Installed successfully',
|
|
||||||
installStage: {
|
|
||||||
mcpInstalling: 'Adding and connecting the MCP server…',
|
|
||||||
skillInstalling: 'Installing the skill…',
|
|
||||||
installed: 'Done',
|
|
||||||
},
|
|
||||||
manualAdd: 'Manual Add',
|
manualAdd: 'Manual Add',
|
||||||
uploadExtension: 'Drag & drop or click to upload',
|
uploadExtension: 'Drag & drop or click to upload',
|
||||||
uploadHint: 'Supports .zip (skills) and .lbpkg (plugins) files',
|
uploadHint: 'Supports .zip (skills) and .lbpkg (plugins) files',
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ const esES = {
|
|||||||
'Páginas visuales proporcionadas por los plugins instalados',
|
'Páginas visuales proporcionadas por los plugins instalados',
|
||||||
quickStart: 'Inicio rápido',
|
quickStart: 'Inicio rápido',
|
||||||
scrollToBottom: 'Desplazar al final',
|
scrollToBottom: 'Desplazar al final',
|
||||||
editionCommunity: 'Comunidad',
|
|
||||||
editionCloud: 'Cloud',
|
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
login: 'Iniciar sesión',
|
login: 'Iniciar sesión',
|
||||||
@@ -40,7 +38,6 @@ 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',
|
||||||
@@ -469,7 +466,7 @@ const esES = {
|
|||||||
noPluginInstalled: 'No hay plugins instalados',
|
noPluginInstalled: 'No hay plugins instalados',
|
||||||
noExtensionInstalled: 'No hay extensiones instaladas',
|
noExtensionInstalled: 'No hay extensiones instaladas',
|
||||||
loadingExtensions: 'Cargando extensiones...',
|
loadingExtensions: 'Cargando extensiones...',
|
||||||
groupByType: 'Agrupar por formato',
|
groupByType: 'Agrupar por tipo',
|
||||||
pluginConfig: 'Configuración del plugin',
|
pluginConfig: 'Configuración del plugin',
|
||||||
pluginSort: 'Orden de plugins',
|
pluginSort: 'Orden de plugins',
|
||||||
pluginSortDescription:
|
pluginSortDescription:
|
||||||
@@ -645,8 +642,8 @@ const esES = {
|
|||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: 'Buscar plugins...',
|
searchPlaceholder: 'Buscar plugins...',
|
||||||
searchResults: 'Se encontraron {{count}} extensiones',
|
searchResults: 'Se encontraron {{count}} plugins',
|
||||||
totalPlugins: 'Total {{count}} extensiones',
|
totalPlugins: 'Total {{count}} plugins',
|
||||||
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...',
|
||||||
@@ -686,18 +683,8 @@ const esES = {
|
|||||||
markAsRead: 'Marcar como leído',
|
markAsRead: 'Marcar como leído',
|
||||||
markAsReadSuccess: 'Marcado como leído',
|
markAsReadSuccess: 'Marcado como leído',
|
||||||
markAsReadFailed: 'Error al marcar como leído',
|
markAsReadFailed: 'Error al marcar como leído',
|
||||||
filterByComponent: 'Componente del plugin',
|
filterByComponent: 'Componente',
|
||||||
filterByComponentHint:
|
|
||||||
'Los tipos de capacidad que ofrece un plugin: herramienta (Tool), comando (Command), escucha de eventos (EventListener), etc., usados para ampliar las capacidades de LangBot. Filtra por componente para ver solo los plugins que ofrecen esa capacidad.',
|
|
||||||
allComponents: 'Todos los componentes',
|
allComponents: 'Todos los componentes',
|
||||||
componentName: {
|
|
||||||
Tool: 'Herramienta',
|
|
||||||
EventListener: 'Listener de eventos',
|
|
||||||
Command: 'Comando',
|
|
||||||
KnowledgeEngine: 'Motor de conocimiento',
|
|
||||||
Parser: 'Analizador',
|
|
||||||
Page: 'Página',
|
|
||||||
},
|
|
||||||
filterByType: 'Tipo',
|
filterByType: 'Tipo',
|
||||||
allTypes: 'Todos los tipos',
|
allTypes: 'Todos los tipos',
|
||||||
typePlugin: 'Plugin',
|
typePlugin: 'Plugin',
|
||||||
@@ -1651,17 +1638,6 @@ const esES = {
|
|||||||
saveFileError: 'Error al guardar el archivo: ',
|
saveFileError: 'Error al guardar el archivo: ',
|
||||||
},
|
},
|
||||||
addExtension: {
|
addExtension: {
|
||||||
installTitle: 'Instalar {{type}}',
|
|
||||||
installConfirm: '¿Instalar {{type}} "{{name}}"?',
|
|
||||||
installInfoType: 'Tipo',
|
|
||||||
installInfoId: 'ID',
|
|
||||||
installInfoVersion: 'Versión',
|
|
||||||
installSuccess: 'Instalado correctamente',
|
|
||||||
installStage: {
|
|
||||||
mcpInstalling: 'Añadiendo y conectando el servidor MCP…',
|
|
||||||
skillInstalling: 'Instalando la skill…',
|
|
||||||
installed: 'Listo',
|
|
||||||
},
|
|
||||||
manualAdd: 'Añadir manualmente',
|
manualAdd: 'Añadir manualmente',
|
||||||
uploadExtension: 'Arrastra y suelta o haz clic para subir',
|
uploadExtension: 'Arrastra y suelta o haz clic para subir',
|
||||||
uploadHint: 'Admite archivos .zip (skills) y .lbpkg (plugins)',
|
uploadHint: 'Admite archivos .zip (skills) y .lbpkg (plugins)',
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ const jaJP = {
|
|||||||
pluginPagesTooltip: 'インストール済みプラグインが提供するビジュアルページ',
|
pluginPagesTooltip: 'インストール済みプラグインが提供するビジュアルページ',
|
||||||
quickStart: 'クイックスタート',
|
quickStart: 'クイックスタート',
|
||||||
scrollToBottom: '一番下までスクロール',
|
scrollToBottom: '一番下までスクロール',
|
||||||
editionCommunity: 'コミュニティ版',
|
|
||||||
editionCloud: 'Cloud',
|
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
login: 'ログイン',
|
login: 'ログイン',
|
||||||
@@ -38,7 +36,6 @@ const jaJP = {
|
|||||||
helpDocs: 'ヘルプドキュメント',
|
helpDocs: 'ヘルプドキュメント',
|
||||||
featureRequest: '機能リクエスト',
|
featureRequest: '機能リクエスト',
|
||||||
starOnGitHub: 'GitHubでStarする',
|
starOnGitHub: 'GitHubでStarする',
|
||||||
joinDiscord: 'Discord に参加',
|
|
||||||
create: '作成',
|
create: '作成',
|
||||||
edit: '編集',
|
edit: '編集',
|
||||||
delete: '削除',
|
delete: '削除',
|
||||||
@@ -462,7 +459,7 @@ const jaJP = {
|
|||||||
noPluginInstalled: 'プラグインがインストールされていません',
|
noPluginInstalled: 'プラグインがインストールされていません',
|
||||||
noExtensionInstalled: '拡張機能がインストールされていません',
|
noExtensionInstalled: '拡張機能がインストールされていません',
|
||||||
loadingExtensions: '拡張機能を読み込み中...',
|
loadingExtensions: '拡張機能を読み込み中...',
|
||||||
groupByType: '形式でグループ化',
|
groupByType: '種類でグループ化',
|
||||||
pluginConfig: 'プラグイン設定',
|
pluginConfig: 'プラグイン設定',
|
||||||
pluginSort: 'プラグインの並び替え',
|
pluginSort: 'プラグインの並び替え',
|
||||||
pluginSortDescription:
|
pluginSortDescription:
|
||||||
@@ -637,8 +634,8 @@ const jaJP = {
|
|||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: 'プラグインを検索...',
|
searchPlaceholder: 'プラグインを検索...',
|
||||||
searchResults: '{{count}} 個の拡張機能が見つかりました',
|
searchResults: '{{count}} 個のプラグインが見つかりました',
|
||||||
totalPlugins: '合計 {{count}} 個の拡張機能',
|
totalPlugins: '合計 {{count}} 個のプラグイン',
|
||||||
noPlugins: '利用可能なプラグインがありません',
|
noPlugins: '利用可能なプラグインがありません',
|
||||||
noResults: '関連するプラグインが見つかりません',
|
noResults: '関連するプラグインが見つかりません',
|
||||||
loadingMore: 'さらに読み込み中...',
|
loadingMore: 'さらに読み込み中...',
|
||||||
@@ -678,18 +675,8 @@ const jaJP = {
|
|||||||
markAsRead: '既読',
|
markAsRead: '既読',
|
||||||
markAsReadSuccess: '既読に設定しました',
|
markAsReadSuccess: '既読に設定しました',
|
||||||
markAsReadFailed: '既読に設定に失敗しました',
|
markAsReadFailed: '既読に設定に失敗しました',
|
||||||
filterByComponent: 'プラグインコンポーネント',
|
filterByComponent: 'コンポーネント',
|
||||||
filterByComponentHint:
|
|
||||||
'プラグインが提供する機能の種類です(ツール、コマンド、イベントリスナーなど)。LangBot のさまざまな機能を拡張するために使われます。コンポーネントで絞り込むと、その機能を提供するプラグインのみを表示できます。',
|
|
||||||
allComponents: '全部コンポーネント',
|
allComponents: '全部コンポーネント',
|
||||||
componentName: {
|
|
||||||
Tool: 'ツール',
|
|
||||||
EventListener: 'イベント監視器',
|
|
||||||
Command: 'コマンド',
|
|
||||||
KnowledgeEngine: '知識エンジン',
|
|
||||||
Parser: 'パーサー',
|
|
||||||
Page: 'ページ',
|
|
||||||
},
|
|
||||||
filterByType: 'タイプ',
|
filterByType: 'タイプ',
|
||||||
allTypes: '全部',
|
allTypes: '全部',
|
||||||
typePlugin: 'プラグイン',
|
typePlugin: 'プラグイン',
|
||||||
@@ -1458,17 +1445,6 @@ const jaJP = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
addExtension: {
|
addExtension: {
|
||||||
installTitle: '{{type}}をインストール',
|
|
||||||
installConfirm: '{{type}}「{{name}}」をインストールしますか?',
|
|
||||||
installInfoType: 'タイプ',
|
|
||||||
installInfoId: 'ID',
|
|
||||||
installInfoVersion: 'バージョン',
|
|
||||||
installSuccess: 'インストールに成功しました',
|
|
||||||
installStage: {
|
|
||||||
mcpInstalling: 'MCPサーバーを追加して接続しています…',
|
|
||||||
skillInstalling: 'スキルをインストールしています…',
|
|
||||||
installed: '完了',
|
|
||||||
},
|
|
||||||
manualAdd: '手動追加',
|
manualAdd: '手動追加',
|
||||||
uploadExtension: 'ドラッグ&ドロップまたはクリックしてアップロード',
|
uploadExtension: 'ドラッグ&ドロップまたはクリックしてアップロード',
|
||||||
uploadHint: '.zip(スキル)と.lbpkg(プラグイン)ファイルに対応',
|
uploadHint: '.zip(スキル)と.lbpkg(プラグイン)ファイルに対応',
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ const ruRU = {
|
|||||||
'Визуальные страницы, предоставляемые установленными плагинами',
|
'Визуальные страницы, предоставляемые установленными плагинами',
|
||||||
quickStart: 'Быстрый старт',
|
quickStart: 'Быстрый старт',
|
||||||
scrollToBottom: 'Прокрутить вниз',
|
scrollToBottom: 'Прокрутить вниз',
|
||||||
editionCommunity: 'Сообщество',
|
|
||||||
editionCloud: 'Cloud',
|
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
login: 'Войти',
|
login: 'Войти',
|
||||||
@@ -38,7 +36,6 @@ const ruRU = {
|
|||||||
helpDocs: 'Помощь',
|
helpDocs: 'Помощь',
|
||||||
featureRequest: 'Запрос функции',
|
featureRequest: 'Запрос функции',
|
||||||
starOnGitHub: 'Поставить звезду на GitHub',
|
starOnGitHub: 'Поставить звезду на GitHub',
|
||||||
joinDiscord: 'Присоединиться к Discord',
|
|
||||||
create: 'Создать',
|
create: 'Создать',
|
||||||
edit: 'Редактировать',
|
edit: 'Редактировать',
|
||||||
delete: 'Удалить',
|
delete: 'Удалить',
|
||||||
@@ -467,7 +464,7 @@ const ruRU = {
|
|||||||
noPluginInstalled: 'Плагины не установлены',
|
noPluginInstalled: 'Плагины не установлены',
|
||||||
noExtensionInstalled: 'Расширения не установлены',
|
noExtensionInstalled: 'Расширения не установлены',
|
||||||
loadingExtensions: 'Загрузка расширений...',
|
loadingExtensions: 'Загрузка расширений...',
|
||||||
groupByType: 'Группировать по формату',
|
groupByType: 'Группировать по типу',
|
||||||
pluginConfig: 'Настройка плагина',
|
pluginConfig: 'Настройка плагина',
|
||||||
pluginSort: 'Порядок плагинов',
|
pluginSort: 'Порядок плагинов',
|
||||||
pluginSortDescription:
|
pluginSortDescription:
|
||||||
@@ -643,8 +640,8 @@ const ruRU = {
|
|||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: 'Поиск плагинов...',
|
searchPlaceholder: 'Поиск плагинов...',
|
||||||
searchResults: 'Найдено {{count}} расширений',
|
searchResults: 'Найдено {{count}} плагинов',
|
||||||
totalPlugins: 'Всего {{count}} расширений',
|
totalPlugins: 'Всего {{count}} плагинов',
|
||||||
noPlugins: 'Нет доступных плагинов',
|
noPlugins: 'Нет доступных плагинов',
|
||||||
noResults: 'Подходящие плагины не найдены',
|
noResults: 'Подходящие плагины не найдены',
|
||||||
loadingMore: 'Загрузка ещё...',
|
loadingMore: 'Загрузка ещё...',
|
||||||
@@ -683,18 +680,8 @@ const ruRU = {
|
|||||||
markAsRead: 'Отметить как прочитанное',
|
markAsRead: 'Отметить как прочитанное',
|
||||||
markAsReadSuccess: 'Отмечено как прочитанное',
|
markAsReadSuccess: 'Отмечено как прочитанное',
|
||||||
markAsReadFailed: 'Не удалось отметить как прочитанное',
|
markAsReadFailed: 'Не удалось отметить как прочитанное',
|
||||||
filterByComponent: 'Компонент плагина',
|
filterByComponent: 'Компонент',
|
||||||
filterByComponentHint:
|
|
||||||
'Типы возможностей, которые предоставляет плагин — инструмент (Tool), команда (Command), обработчик событий (EventListener) и т. д., — расширяющие функции LangBot. Фильтруйте по компоненту, чтобы видеть только плагины с нужной возможностью.',
|
|
||||||
allComponents: 'Все компоненты',
|
allComponents: 'Все компоненты',
|
||||||
componentName: {
|
|
||||||
Tool: 'Инструмент',
|
|
||||||
EventListener: 'Обработчик событий',
|
|
||||||
Command: 'Команда',
|
|
||||||
KnowledgeEngine: 'Движок знаний',
|
|
||||||
Parser: 'Парсер',
|
|
||||||
Page: 'Страница',
|
|
||||||
},
|
|
||||||
filterByType: 'Тип',
|
filterByType: 'Тип',
|
||||||
allTypes: 'Все типы',
|
allTypes: 'Все типы',
|
||||||
typePlugin: 'Плагин',
|
typePlugin: 'Плагин',
|
||||||
@@ -1619,17 +1606,6 @@ const ruRU = {
|
|||||||
saveFileError: 'Не удалось сохранить файл: ',
|
saveFileError: 'Не удалось сохранить файл: ',
|
||||||
},
|
},
|
||||||
addExtension: {
|
addExtension: {
|
||||||
installTitle: 'Установить {{type}}',
|
|
||||||
installConfirm: 'Установить {{type}} «{{name}}»?',
|
|
||||||
installInfoType: 'Тип',
|
|
||||||
installInfoId: 'ID',
|
|
||||||
installInfoVersion: 'Версия',
|
|
||||||
installSuccess: 'Успешно установлено',
|
|
||||||
installStage: {
|
|
||||||
mcpInstalling: 'Добавление и подключение сервера MCP…',
|
|
||||||
skillInstalling: 'Установка навыка…',
|
|
||||||
installed: 'Готово',
|
|
||||||
},
|
|
||||||
manualAdd: 'Добавить вручную',
|
manualAdd: 'Добавить вручную',
|
||||||
uploadExtension: 'Перетащите файл сюда или нажмите для загрузки',
|
uploadExtension: 'Перетащите файл сюда или нажмите для загрузки',
|
||||||
uploadHint: 'Поддерживаются файлы .zip (навыки) и .lbpkg (плагины)',
|
uploadHint: 'Поддерживаются файлы .zip (навыки) и .lbpkg (плагины)',
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ const thTH = {
|
|||||||
pluginPagesTooltip: 'หน้าเว็บที่จัดทำโดยปลั๊กอินที่ติดตั้ง',
|
pluginPagesTooltip: 'หน้าเว็บที่จัดทำโดยปลั๊กอินที่ติดตั้ง',
|
||||||
quickStart: 'เริ่มต้นอย่างรวดเร็ว',
|
quickStart: 'เริ่มต้นอย่างรวดเร็ว',
|
||||||
scrollToBottom: 'เลื่อนไปด้านล่าง',
|
scrollToBottom: 'เลื่อนไปด้านล่าง',
|
||||||
editionCommunity: 'รุ่นชุมชน',
|
|
||||||
editionCloud: 'Cloud',
|
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
login: 'เข้าสู่ระบบ',
|
login: 'เข้าสู่ระบบ',
|
||||||
@@ -37,7 +35,6 @@ const thTH = {
|
|||||||
helpDocs: 'ขอความช่วยเหลือ',
|
helpDocs: 'ขอความช่วยเหลือ',
|
||||||
featureRequest: 'ขอฟีเจอร์ใหม่',
|
featureRequest: 'ขอฟีเจอร์ใหม่',
|
||||||
starOnGitHub: 'ให้ดาวบน GitHub',
|
starOnGitHub: 'ให้ดาวบน GitHub',
|
||||||
joinDiscord: 'เข้าร่วม Discord',
|
|
||||||
create: 'สร้าง',
|
create: 'สร้าง',
|
||||||
edit: 'แก้ไข',
|
edit: 'แก้ไข',
|
||||||
delete: 'ลบ',
|
delete: 'ลบ',
|
||||||
@@ -453,7 +450,7 @@ const thTH = {
|
|||||||
noPluginInstalled: 'ยังไม่มีปลั๊กอินที่ติดตั้ง',
|
noPluginInstalled: 'ยังไม่มีปลั๊กอินที่ติดตั้ง',
|
||||||
noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง',
|
noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง',
|
||||||
loadingExtensions: 'กำลังโหลดส่วนขยาย...',
|
loadingExtensions: 'กำลังโหลดส่วนขยาย...',
|
||||||
groupByType: 'จัดกลุ่มตามรูปแบบ',
|
groupByType: 'จัดกลุ่มตามประเภท',
|
||||||
pluginConfig: 'การกำหนดค่าปลั๊กอิน',
|
pluginConfig: 'การกำหนดค่าปลั๊กอิน',
|
||||||
pluginSort: 'เรียงลำดับปลั๊กอิน',
|
pluginSort: 'เรียงลำดับปลั๊กอิน',
|
||||||
pluginSortDescription:
|
pluginSortDescription:
|
||||||
@@ -624,8 +621,8 @@ const thTH = {
|
|||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: 'ค้นหาปลั๊กอิน...',
|
searchPlaceholder: 'ค้นหาปลั๊กอิน...',
|
||||||
searchResults: 'พบ {{count}} ส่วนขยาย',
|
searchResults: 'พบ {{count}} ปลั๊กอิน',
|
||||||
totalPlugins: 'ทั้งหมด {{count}} ส่วนขยาย',
|
totalPlugins: 'ทั้งหมด {{count}} ปลั๊กอิน',
|
||||||
noPlugins: 'ไม่มีปลั๊กอินที่พร้อมใช้งาน',
|
noPlugins: 'ไม่มีปลั๊กอินที่พร้อมใช้งาน',
|
||||||
noResults: 'ไม่พบปลั๊กอินที่เกี่ยวข้อง',
|
noResults: 'ไม่พบปลั๊กอินที่เกี่ยวข้อง',
|
||||||
loadingMore: 'กำลังโหลดเพิ่มเติม...',
|
loadingMore: 'กำลังโหลดเพิ่มเติม...',
|
||||||
@@ -664,18 +661,8 @@ const thTH = {
|
|||||||
markAsRead: 'ทำเครื่องหมายว่าอ่านแล้ว',
|
markAsRead: 'ทำเครื่องหมายว่าอ่านแล้ว',
|
||||||
markAsReadSuccess: 'ทำเครื่องหมายว่าอ่านแล้ว',
|
markAsReadSuccess: 'ทำเครื่องหมายว่าอ่านแล้ว',
|
||||||
markAsReadFailed: 'ทำเครื่องหมายว่าอ่านแล้วล้มเหลว',
|
markAsReadFailed: 'ทำเครื่องหมายว่าอ่านแล้วล้มเหลว',
|
||||||
filterByComponent: 'ส่วนประกอบปลั๊กอิน',
|
filterByComponent: 'ส่วนประกอบ',
|
||||||
filterByComponentHint:
|
|
||||||
'ประเภทความสามารถที่ปลั๊กอินมีให้ เช่น เครื่องมือ (Tool) คำสั่ง (Command) ตัวรับฟังเหตุการณ์ (EventListener) เป็นต้น ใช้เพื่อขยายความสามารถต่าง ๆ ของ LangBot กรองตามส่วนประกอบเพื่อแสดงเฉพาะปลั๊กอินที่มีความสามารถนั้น',
|
|
||||||
allComponents: 'ส่วนประกอบทั้งหมด',
|
allComponents: 'ส่วนประกอบทั้งหมด',
|
||||||
componentName: {
|
|
||||||
Tool: 'เครื่องมือ',
|
|
||||||
EventListener: 'ตัวรับฟังเหตุการณ์',
|
|
||||||
Command: 'คำสั่ง',
|
|
||||||
KnowledgeEngine: 'เครื่องมือความรู้',
|
|
||||||
Parser: 'ตัวแยกวิเคราะห์',
|
|
||||||
Page: 'หน้า',
|
|
||||||
},
|
|
||||||
filterByType: 'ประเภท',
|
filterByType: 'ประเภท',
|
||||||
allTypes: 'ทุกประเภท',
|
allTypes: 'ทุกประเภท',
|
||||||
typePlugin: 'ปลั๊กอิน',
|
typePlugin: 'ปลั๊กอิน',
|
||||||
@@ -1582,17 +1569,6 @@ const thTH = {
|
|||||||
saveFileError: 'บันทึกไฟล์ไม่สำเร็จ: ',
|
saveFileError: 'บันทึกไฟล์ไม่สำเร็จ: ',
|
||||||
},
|
},
|
||||||
addExtension: {
|
addExtension: {
|
||||||
installTitle: 'ติดตั้ง {{type}}',
|
|
||||||
installConfirm: 'ติดตั้ง {{type}} "{{name}}" หรือไม่?',
|
|
||||||
installInfoType: 'ประเภท',
|
|
||||||
installInfoId: 'ID',
|
|
||||||
installInfoVersion: 'เวอร์ชัน',
|
|
||||||
installSuccess: 'ติดตั้งสำเร็จ',
|
|
||||||
installStage: {
|
|
||||||
mcpInstalling: 'กำลังเพิ่มและเชื่อมต่อเซิร์ฟเวอร์ MCP…',
|
|
||||||
skillInstalling: 'กำลังติดตั้งสกิล…',
|
|
||||||
installed: 'เสร็จสิ้น',
|
|
||||||
},
|
|
||||||
manualAdd: 'เพิ่มด้วยตนเอง',
|
manualAdd: 'เพิ่มด้วยตนเอง',
|
||||||
uploadExtension: 'ลากแล้ววางหรือคลิกเพื่ออัปโหลด',
|
uploadExtension: 'ลากแล้ววางหรือคลิกเพื่ออัปโหลด',
|
||||||
uploadHint: 'รองรับไฟล์ .zip (สกิล) และ .lbpkg (ปลั๊กอิน)',
|
uploadHint: 'รองรับไฟล์ .zip (สกิล) และ .lbpkg (ปลั๊กอิน)',
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ const viVN = {
|
|||||||
'Các trang trực quan được cung cấp bởi plugin đã cài đặt',
|
'Các trang trực quan được cung cấp bởi plugin đã cài đặt',
|
||||||
quickStart: 'Bắt đầu nhanh',
|
quickStart: 'Bắt đầu nhanh',
|
||||||
scrollToBottom: 'Cuộn xuống cuối',
|
scrollToBottom: 'Cuộn xuống cuối',
|
||||||
editionCommunity: 'Bản cộng đồng',
|
|
||||||
editionCloud: 'Cloud',
|
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
login: 'Đăng nhập',
|
login: 'Đăng nhập',
|
||||||
@@ -38,7 +36,6 @@ 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',
|
||||||
@@ -463,7 +460,7 @@ const viVN = {
|
|||||||
noPluginInstalled: 'Chưa cài đặt plugin nào',
|
noPluginInstalled: 'Chưa cài đặt plugin nào',
|
||||||
noExtensionInstalled: 'Chưa cài đặt tiện ích mở rộng nào',
|
noExtensionInstalled: 'Chưa cài đặt tiện ích mở rộng nào',
|
||||||
loadingExtensions: 'Đang tải tiện ích mở rộng...',
|
loadingExtensions: 'Đang tải tiện ích mở rộng...',
|
||||||
groupByType: 'Nhóm theo định dạng',
|
groupByType: 'Nhóm theo loại',
|
||||||
pluginConfig: 'Cấu hình Plugin',
|
pluginConfig: 'Cấu hình Plugin',
|
||||||
pluginSort: 'Sắp xếp Plugin',
|
pluginSort: 'Sắp xếp Plugin',
|
||||||
pluginSortDescription:
|
pluginSortDescription:
|
||||||
@@ -638,8 +635,8 @@ const viVN = {
|
|||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: 'Tìm kiếm plugin...',
|
searchPlaceholder: 'Tìm kiếm plugin...',
|
||||||
searchResults: 'Tìm thấy {{count}} tiện ích mở rộng',
|
searchResults: 'Tìm thấy {{count}} plugin',
|
||||||
totalPlugins: 'Tổng cộng {{count}} tiện ích mở rộng',
|
totalPlugins: 'Tổng cộng {{count}} plugin',
|
||||||
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...',
|
||||||
@@ -678,18 +675,8 @@ const viVN = {
|
|||||||
markAsRead: 'Đánh dấu đã đọc',
|
markAsRead: 'Đánh dấu đã đọc',
|
||||||
markAsReadSuccess: 'Đã đánh dấu đã đọc',
|
markAsReadSuccess: 'Đã đánh dấu đã đọc',
|
||||||
markAsReadFailed: 'Đánh dấu đã đọc thất bại',
|
markAsReadFailed: 'Đánh dấu đã đọc thất bại',
|
||||||
filterByComponent: 'Thành phần plugin',
|
filterByComponent: 'Thành phần',
|
||||||
filterByComponentHint:
|
|
||||||
'Các loại năng lực mà plugin cung cấp — Công cụ (Tool), Lệnh (Command), Trình lắng nghe sự kiện (EventListener), v.v. — dùng để mở rộng các khả năng của LangBot. Lọc theo thành phần để chỉ xem những plugin cung cấp năng lực đó.',
|
|
||||||
allComponents: 'Tất cả thành phần',
|
allComponents: 'Tất cả thành phần',
|
||||||
componentName: {
|
|
||||||
Tool: 'Công cụ',
|
|
||||||
EventListener: 'Trình lắng nghe sự kiện',
|
|
||||||
Command: 'Lệnh',
|
|
||||||
KnowledgeEngine: 'Công cụ tri thức',
|
|
||||||
Parser: 'Trình phân tích',
|
|
||||||
Page: 'Trang',
|
|
||||||
},
|
|
||||||
filterByType: 'Loại',
|
filterByType: 'Loại',
|
||||||
allTypes: 'Tất cả loại',
|
allTypes: 'Tất cả loại',
|
||||||
typePlugin: 'Plugin',
|
typePlugin: 'Plugin',
|
||||||
@@ -1611,17 +1598,6 @@ const viVN = {
|
|||||||
saveFileError: 'Lưu tệp thất bại: ',
|
saveFileError: 'Lưu tệp thất bại: ',
|
||||||
},
|
},
|
||||||
addExtension: {
|
addExtension: {
|
||||||
installTitle: 'Cài đặt {{type}}',
|
|
||||||
installConfirm: 'Cài đặt {{type}} "{{name}}"?',
|
|
||||||
installInfoType: 'Loại',
|
|
||||||
installInfoId: 'ID',
|
|
||||||
installInfoVersion: 'Phiên bản',
|
|
||||||
installSuccess: 'Cài đặt thành công',
|
|
||||||
installStage: {
|
|
||||||
mcpInstalling: 'Đang thêm và kết nối máy chủ MCP…',
|
|
||||||
skillInstalling: 'Đang cài đặt kỹ năng…',
|
|
||||||
installed: 'Hoàn tất',
|
|
||||||
},
|
|
||||||
manualAdd: 'Thêm thủ công',
|
manualAdd: 'Thêm thủ công',
|
||||||
uploadExtension: 'Kéo thả hoặc nhấp để tải lên',
|
uploadExtension: 'Kéo thả hoặc nhấp để tải lên',
|
||||||
uploadHint: 'Hỗ trợ tệp .zip (kỹ năng) và .lbpkg (plugin)',
|
uploadHint: 'Hỗ trợ tệp .zip (kỹ năng) và .lbpkg (plugin)',
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ const zhHans = {
|
|||||||
pluginPagesTooltip: '由已安装的插件提供的可视化页面',
|
pluginPagesTooltip: '由已安装的插件提供的可视化页面',
|
||||||
quickStart: '快速开始向导',
|
quickStart: '快速开始向导',
|
||||||
scrollToBottom: '滚动到底部',
|
scrollToBottom: '滚动到底部',
|
||||||
editionCommunity: '社区版',
|
|
||||||
editionCloud: 'Cloud',
|
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
login: '登录',
|
login: '登录',
|
||||||
@@ -36,7 +34,6 @@ const zhHans = {
|
|||||||
helpDocs: '帮助文档',
|
helpDocs: '帮助文档',
|
||||||
featureRequest: '需求建议',
|
featureRequest: '需求建议',
|
||||||
starOnGitHub: '在 GitHub 上 Star',
|
starOnGitHub: '在 GitHub 上 Star',
|
||||||
joinDiscord: '加入 Discord 社区',
|
|
||||||
create: '创建',
|
create: '创建',
|
||||||
edit: '编辑',
|
edit: '编辑',
|
||||||
delete: '删除',
|
delete: '删除',
|
||||||
@@ -441,7 +438,7 @@ const zhHans = {
|
|||||||
noPluginInstalled: '暂未安装任何插件',
|
noPluginInstalled: '暂未安装任何插件',
|
||||||
noExtensionInstalled: '暂未安装任何扩展',
|
noExtensionInstalled: '暂未安装任何扩展',
|
||||||
loadingExtensions: '正在加载扩展...',
|
loadingExtensions: '正在加载扩展...',
|
||||||
groupByType: '按格式分组',
|
groupByType: '按类型分组',
|
||||||
pluginSort: '插件排序',
|
pluginSort: '插件排序',
|
||||||
pluginSortDescription:
|
pluginSortDescription:
|
||||||
'插件顺序会影响同一事件内的处理顺序,请拖动插件卡片排序',
|
'插件顺序会影响同一事件内的处理顺序,请拖动插件卡片排序',
|
||||||
@@ -606,8 +603,8 @@ const zhHans = {
|
|||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: '搜索插件...',
|
searchPlaceholder: '搜索插件...',
|
||||||
searchResults: '搜索到 {{count}} 个扩展',
|
searchResults: '搜索到 {{count}} 个插件',
|
||||||
totalPlugins: '共 {{count}} 个扩展',
|
totalPlugins: '共 {{count}} 个插件',
|
||||||
noPlugins: '暂无插件',
|
noPlugins: '暂无插件',
|
||||||
noResults: '未找到相关插件',
|
noResults: '未找到相关插件',
|
||||||
loadingMore: '加载更多...',
|
loadingMore: '加载更多...',
|
||||||
@@ -646,18 +643,8 @@ const zhHans = {
|
|||||||
markAsRead: '已读',
|
markAsRead: '已读',
|
||||||
markAsReadSuccess: '已标记为已读',
|
markAsReadSuccess: '已标记为已读',
|
||||||
markAsReadFailed: '标记为已读失败',
|
markAsReadFailed: '标记为已读失败',
|
||||||
filterByComponent: '插件组件',
|
filterByComponent: '组件',
|
||||||
filterByComponentHint:
|
|
||||||
'插件提供的能力类型,如工具(Tool)、命令(Command)、事件监听器(EventListener)等,用于扩展 LangBot 的各项能力。按组件筛选可只看提供对应能力的插件。',
|
|
||||||
allComponents: '全部组件',
|
allComponents: '全部组件',
|
||||||
componentName: {
|
|
||||||
Tool: '工具',
|
|
||||||
EventListener: '事件监听器',
|
|
||||||
Command: '命令',
|
|
||||||
KnowledgeEngine: '知识引擎',
|
|
||||||
Parser: '解析器',
|
|
||||||
Page: '页面',
|
|
||||||
},
|
|
||||||
filterByType: '类型',
|
filterByType: '类型',
|
||||||
allTypes: '全部类型',
|
allTypes: '全部类型',
|
||||||
typePlugin: '插件',
|
typePlugin: '插件',
|
||||||
@@ -1480,17 +1467,6 @@ const zhHans = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
addExtension: {
|
addExtension: {
|
||||||
installTitle: '安装{{type}}',
|
|
||||||
installConfirm: '确定要安装{{type}} "{{name}}" 吗?',
|
|
||||||
installInfoType: '类型',
|
|
||||||
installInfoId: '标识',
|
|
||||||
installInfoVersion: '版本',
|
|
||||||
installSuccess: '安装成功',
|
|
||||||
installStage: {
|
|
||||||
mcpInstalling: '正在添加并连接 MCP 服务器…',
|
|
||||||
skillInstalling: '正在安装技能…',
|
|
||||||
installed: '已完成',
|
|
||||||
},
|
|
||||||
manualAdd: '手动添加',
|
manualAdd: '手动添加',
|
||||||
uploadExtension: '拖拽或点击上传扩展包',
|
uploadExtension: '拖拽或点击上传扩展包',
|
||||||
uploadHint: '支持 .zip(技能)和 .lbpkg(插件)文件',
|
uploadHint: '支持 .zip(技能)和 .lbpkg(插件)文件',
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ const zhHant = {
|
|||||||
pluginPagesTooltip: '由已安裝的插件提供的視覺化頁面',
|
pluginPagesTooltip: '由已安裝的插件提供的視覺化頁面',
|
||||||
quickStart: '快速開始',
|
quickStart: '快速開始',
|
||||||
scrollToBottom: '捲動到底部',
|
scrollToBottom: '捲動到底部',
|
||||||
editionCommunity: '社區版',
|
|
||||||
editionCloud: 'Cloud',
|
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
login: '登入',
|
login: '登入',
|
||||||
@@ -36,7 +34,6 @@ const zhHant = {
|
|||||||
helpDocs: '輔助說明',
|
helpDocs: '輔助說明',
|
||||||
featureRequest: '需求建議',
|
featureRequest: '需求建議',
|
||||||
starOnGitHub: '在 GitHub 上 Star',
|
starOnGitHub: '在 GitHub 上 Star',
|
||||||
joinDiscord: '加入 Discord 社群',
|
|
||||||
create: '建立',
|
create: '建立',
|
||||||
edit: '編輯',
|
edit: '編輯',
|
||||||
delete: '刪除',
|
delete: '刪除',
|
||||||
@@ -442,7 +439,7 @@ const zhHant = {
|
|||||||
noPluginInstalled: '暫未安裝任何外掛',
|
noPluginInstalled: '暫未安裝任何外掛',
|
||||||
noExtensionInstalled: '暫未安裝任何擴充功能',
|
noExtensionInstalled: '暫未安裝任何擴充功能',
|
||||||
loadingExtensions: '正在載入擴充功能...',
|
loadingExtensions: '正在載入擴充功能...',
|
||||||
groupByType: '依格式分組',
|
groupByType: '依類型分組',
|
||||||
pluginSort: '外掛排序',
|
pluginSort: '外掛排序',
|
||||||
pluginSortDescription:
|
pluginSortDescription:
|
||||||
'外掛順序會影響同一事件內的處理順序,請拖曳外掛卡片排序',
|
'外掛順序會影響同一事件內的處理順序,請拖曳外掛卡片排序',
|
||||||
@@ -606,8 +603,8 @@ const zhHant = {
|
|||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: '搜尋插件...',
|
searchPlaceholder: '搜尋插件...',
|
||||||
searchResults: '搜尋到 {{count}} 個擴展',
|
searchResults: '搜尋到 {{count}} 個插件',
|
||||||
totalPlugins: '共 {{count}} 個擴展',
|
totalPlugins: '共 {{count}} 個插件',
|
||||||
noPlugins: '暫無插件',
|
noPlugins: '暫無插件',
|
||||||
noResults: '未找到相關插件',
|
noResults: '未找到相關插件',
|
||||||
loadingMore: '載入更多...',
|
loadingMore: '載入更多...',
|
||||||
@@ -646,18 +643,8 @@ const zhHant = {
|
|||||||
markAsRead: '已讀',
|
markAsRead: '已讀',
|
||||||
markAsReadSuccess: '已標記為已讀',
|
markAsReadSuccess: '已標記為已讀',
|
||||||
markAsReadFailed: '標記為已讀失敗',
|
markAsReadFailed: '標記為已讀失敗',
|
||||||
filterByComponent: '插件組件',
|
filterByComponent: '組件',
|
||||||
filterByComponentHint:
|
|
||||||
'插件提供的能力類型,如工具(Tool)、命令(Command)、事件監聽器(EventListener)等,用於擴展 LangBot 的各項能力。按組件篩選可只看提供對應能力的插件。',
|
|
||||||
allComponents: '全部組件',
|
allComponents: '全部組件',
|
||||||
componentName: {
|
|
||||||
Tool: '工具',
|
|
||||||
EventListener: '事件監聽器',
|
|
||||||
Command: '命令',
|
|
||||||
KnowledgeEngine: '知識引擎',
|
|
||||||
Parser: '解析器',
|
|
||||||
Page: '擴展頁',
|
|
||||||
},
|
|
||||||
filterByType: '類型',
|
filterByType: '類型',
|
||||||
allTypes: '全部類型',
|
allTypes: '全部類型',
|
||||||
typePlugin: '插件',
|
typePlugin: '插件',
|
||||||
@@ -1390,17 +1377,6 @@ const zhHant = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
addExtension: {
|
addExtension: {
|
||||||
installTitle: '安裝{{type}}',
|
|
||||||
installConfirm: '確定要安裝{{type}}「{{name}}」嗎?',
|
|
||||||
installInfoType: '類型',
|
|
||||||
installInfoId: 'ID',
|
|
||||||
installInfoVersion: '版本',
|
|
||||||
installSuccess: '安裝成功',
|
|
||||||
installStage: {
|
|
||||||
mcpInstalling: '正在新增並連接 MCP 伺服器…',
|
|
||||||
skillInstalling: '正在安裝技能…',
|
|
||||||
installed: '完成',
|
|
||||||
},
|
|
||||||
manualAdd: '手動新增',
|
manualAdd: '手動新增',
|
||||||
uploadExtension: '拖拽或點擊上傳擴充套件',
|
uploadExtension: '拖拽或點擊上傳擴充套件',
|
||||||
uploadHint: '支援 .zip(技能)和 .lbpkg(插件)檔案',
|
uploadHint: '支援 .zip(技能)和 .lbpkg(插件)檔案',
|
||||||
|
|||||||
Reference in New Issue
Block a user