mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-03 20:44:36 +00:00
Compare commits
15 Commits
docs/multi
...
fix/plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa4b5d6732 | ||
|
|
b251fc4b89 | ||
|
|
075c85e2bc | ||
|
|
62b63ca2ca | ||
|
|
3680a80248 | ||
|
|
6713b57d01 | ||
|
|
ea13ef87f2 | ||
|
|
59bd581e88 | ||
|
|
cba83a62e8 | ||
|
|
f412127fb0 | ||
|
|
5273bbb23f | ||
|
|
0ceab3f6a5 | ||
|
|
aedc097188 | ||
|
|
18b27dd9ef | ||
|
|
3f50a56623 |
@@ -1,858 +0,0 @@
|
|||||||
# LangBot 多租户与多用户改造方案
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
本方案面向 LangBot 从“单实例单管理员”演进到 SaaS 友好的“多 workspace、多账户、多权限”架构。
|
|
||||||
|
|
||||||
核心定义:
|
|
||||||
|
|
||||||
- Account:登录主体。一个自然人或服务账号,可加入多个 workspace。
|
|
||||||
- Workspace:租户边界。一个 workspace 内可拥有多个用户、机器人、流水线、模型、知识库、扩展、监控数据与 API Key。
|
|
||||||
- Membership:账户与 workspace 的关系,承载角色与权限。
|
|
||||||
- Role/Permission:workspace 内权限,不再用“是否是当前唯一用户”来决定访问能力。
|
|
||||||
|
|
||||||
目标体验:
|
|
||||||
|
|
||||||
- 新用户登录后可以创建 workspace、加入 workspace、切换 workspace。
|
|
||||||
- 同一个账户可加入多个 workspace,每个 workspace 权限不同。
|
|
||||||
- 一个 workspace 可邀请多个用户协作,并分别设置 owner/admin/editor/viewer 等权限。
|
|
||||||
- 所有业务资源默认属于某个 workspace,所有 API 默认在当前 workspace 下工作。
|
|
||||||
- Plugin SDK、MCP、知识库、模型调用、监控日志都能拿到稳定的 workspace 上下文,并且不跨租户泄露数据。
|
|
||||||
|
|
||||||
## 调研结论
|
|
||||||
|
|
||||||
### 当前 LangBot 的单用户假设
|
|
||||||
|
|
||||||
LangBot 现在已经有 `users` 表和 JWT 登录,但仍是单用户/单租户模型:
|
|
||||||
|
|
||||||
- `src/langbot/pkg/entity/persistence/user.py` 的 `User` 只保存 `user/password/account_type/space_*`,没有角色、状态、workspace 关系。
|
|
||||||
- `src/langbot/pkg/api/http/service/user.py` 通过 `is_initialized()` 判断系统是否已有用户;`create_or_update_space_user()` 在系统已初始化且邮箱不匹配时拒绝新用户,这直接限制了多用户登录。
|
|
||||||
- `src/langbot/pkg/api/http/controller/group.py` 的 `AuthType.USER_TOKEN` 验证后只向 handler 注入 `user_email`;JWT payload 也只有 `user`,没有 `account_id`、`workspace_id`、`role`、`permissions`。
|
|
||||||
- `src/langbot/pkg/api/http/service/apikey.py` 的 API Key 只验证 key 是否存在,没有 owner、scope、workspace。
|
|
||||||
- `web/src/app/infra/http/BaseHttpClient.ts` 从 `localStorage.token` 读取单个 token,并在所有请求上加 `Authorization`;前端没有 workspace selector,也没有当前 workspace 上下文。
|
|
||||||
|
|
||||||
当前登录流程更像“初始化一个本地管理账号”,而不是 SaaS 账户体系。要支持多用户,必须把“初始化状态”和“首个 workspace 创建”拆开。
|
|
||||||
|
|
||||||
### 业务资源当前都是全局资源
|
|
||||||
|
|
||||||
主要持久化表没有租户字段:
|
|
||||||
|
|
||||||
- Bot:`bots`
|
|
||||||
- Pipeline:`legacy_pipelines`、`pipeline_run_records`
|
|
||||||
- Model:`model_providers`、`llm_models`、`embedding_models`、`rerank_models`
|
|
||||||
- Plugin:`plugin_settings`
|
|
||||||
- MCP:`mcp_servers`
|
|
||||||
- RAG:`knowledge_bases`、`knowledge_base_files`、`knowledge_base_chunks`
|
|
||||||
- Monitoring:`monitoring_messages`、`monitoring_llm_calls`、`monitoring_sessions`、`monitoring_errors`、`monitoring_embedding_calls`、`monitoring_feedback`
|
|
||||||
- API Key:`api_keys`
|
|
||||||
- Webhook:`webhooks`
|
|
||||||
- Metadata:`metadata`
|
|
||||||
- Binary storage:`binary_storages`
|
|
||||||
|
|
||||||
对应服务也直接 select 全表,例如:
|
|
||||||
|
|
||||||
- `BotService.get_bots()` 返回所有 bot。
|
|
||||||
- `PipelineService.get_pipelines()` 返回所有 pipeline。
|
|
||||||
- `ModelProviderService.get_providers()` 返回所有 provider。
|
|
||||||
- `MCPService.get_mcp_servers()` 返回所有 MCP server。
|
|
||||||
- 插件和二进制存储没有 workspace 维度,插件 workspace storage 在 SDK 里还硬编码为 `default`。
|
|
||||||
|
|
||||||
所以改造重点不是只给用户表加字段,而是给资源访问层统一加入 workspace scope。
|
|
||||||
|
|
||||||
### 运行时也存在全局单例假设
|
|
||||||
|
|
||||||
`src/langbot/pkg/core/stages/build_app.py` 初始化的是一个全局 `Application`,其中包含单例:
|
|
||||||
|
|
||||||
- `platform_mgr`
|
|
||||||
- `pipeline_mgr`
|
|
||||||
- `model_mgr`
|
|
||||||
- `tool_mgr`
|
|
||||||
- `plugin_connector`
|
|
||||||
- `sess_mgr`
|
|
||||||
- `rag_mgr`
|
|
||||||
- `vector_db_mgr`
|
|
||||||
|
|
||||||
当前运行时把所有 bot、pipeline、model、plugin、MCP 都加载到同一套内存管理器。多租户改造需要决定:是共享运行时并在对象上带 workspace 过滤,还是每个 workspace 拆 runtime shard。第一阶段建议共享进程、强制 workspace-aware;等规模变大后再演进为按 workspace 分片。
|
|
||||||
|
|
||||||
### Plugin SDK 对 workspace 的假设
|
|
||||||
|
|
||||||
SDK 当前只认识 bot/pipeline/query/session,不认识租户:
|
|
||||||
|
|
||||||
- `src/langbot_plugin/api/entities/builtin/pipeline/query.py` 的 `Query` 有 `query_id/launcher_type/launcher_id/sender_id/bot_uuid/pipeline_uuid`,没有 `workspace_id/account_id`。
|
|
||||||
- `src/langbot_plugin/api/entities/builtin/provider/session.py` 的 `Session` 只按 `launcher_type + launcher_id` 表达会话。
|
|
||||||
- `src/langbot_plugin/api/proxies/langbot_api.py` 暴露 `get_bots/get_llm_models/invoke_llm/list_tools/vector_*` 等 Host API,都是全局语义。
|
|
||||||
- `src/langbot_plugin/runtime/io/handlers/plugin.py` 的 `set_workspace_storage/get_workspace_storage` 把 `owner_type` 设为 `workspace`,但 `owner` 固定为 `default`。
|
|
||||||
- LangBot 侧 `src/langbot/pkg/plugin/handler.py` 处理插件请求时,会把 `GET_BOTS`、`GET_LLM_MODELS`、`VECTOR_*` 等转到全局服务。
|
|
||||||
|
|
||||||
这意味着多租户落地时,不能只在 Web API 层过滤;插件可以通过 Host API 访问全局资源,所以 SDK/Runtime 通信也必须传递 workspace context。
|
|
||||||
|
|
||||||
## 开源版与商业版产品边界
|
|
||||||
|
|
||||||
LangBot 是开源软件,但多 workspace 能力本质上是 SaaS 控制面能力。如果把完整多 workspace、成员协作、订阅权益和配额代码都放进开源仓库,只靠本地 feature flag 或本地 license check,无法有效避免第三方 fork 后自建 SaaS。因此建议采用 open-core 架构:开源版保留单 workspace 执行能力,账户、订阅、权益和多 workspace 协作能力放到 LangBot Space/Cloud Control Plane 和商业模块中。
|
|
||||||
|
|
||||||
### 版本边界
|
|
||||||
|
|
||||||
推荐拆成三层:
|
|
||||||
|
|
||||||
- `LangBot Core OSS`:开源,自托管,默认只有一个隐式 `default workspace`。它可以在数据结构上兼容 workspace,但产品能力上不提供创建多个 workspace、切换 workspace、成员邀请、成员权限管理、审计和多租户配额。
|
|
||||||
- `LangBot Space / Cloud Control Plane`:托管控制面,负责 account、workspace、membership、subscription、billing、entitlement、license token、workspace quota、marketplace 权益等能力。
|
|
||||||
- `LangBot Commercial Module`:商业闭源或私有包,承载多 workspace、团队协作、RBAC、自定义角色、审计日志、SAML/SSO、企业私有化授权等能力。
|
|
||||||
|
|
||||||
企业私有化版本可以采用 `LangBot Core + Commercial Module + License Token` 的形式交付。开源 Core 仍然可独立运行,但只能作为单 workspace 自托管产品,不提供 SaaS 多租户控制面。
|
|
||||||
|
|
||||||
### OSS 中如何保留兼容但不开放多 workspace
|
|
||||||
|
|
||||||
为了让后续商业版复用同一套资源隔离模型,OSS 代码里可以保留 `workspace_uuid` 相关字段和默认 workspace 迁移,但应限制为单 workspace:
|
|
||||||
|
|
||||||
- 首次初始化时创建一个 `Default Workspace`。
|
|
||||||
- 所有资源自动归属这个 default workspace。
|
|
||||||
- 不暴露 `POST /api/v1/workspaces`。
|
|
||||||
- 不暴露 workspace switcher。
|
|
||||||
- 不暴露成员邀请和成员角色管理。
|
|
||||||
- 不支持一个 account 加入多个 workspace。
|
|
||||||
- 不支持 workspace 数量大于 1。
|
|
||||||
- 前端不展示 workspace selector。
|
|
||||||
- API 层如果收到非 default workspace 的 `X-Workspace-Id`,直接拒绝。
|
|
||||||
|
|
||||||
也就是说,OSS 可以是 workspace-aware,但不是 multi-workspace-enabled。这样做的价值是:代码结构提前适配租户隔离,未来商业版不用重写所有资源模型;同时开源版用户无法直接通过 UI/API 获得 SaaS 型多租户能力。
|
|
||||||
|
|
||||||
### 账户、订阅和权益抽到 Space
|
|
||||||
|
|
||||||
账户和订阅体系建议从 LangBot Core 中抽出,交给 Space 控制面:
|
|
||||||
|
|
||||||
```text
|
|
||||||
LangBot Space
|
|
||||||
-> Account
|
|
||||||
-> Workspace
|
|
||||||
-> Membership
|
|
||||||
-> Subscription
|
|
||||||
-> Entitlement
|
|
||||||
-> License Token
|
|
||||||
|
|
||||||
LangBot Core
|
|
||||||
-> Validate entitlement / license
|
|
||||||
-> Run bots, pipelines, plugins, MCP, RAG
|
|
||||||
-> Enforce local resource scope
|
|
||||||
-> Report usage
|
|
||||||
```
|
|
||||||
|
|
||||||
这样做有几个原因:
|
|
||||||
|
|
||||||
- 账号体系如果完全在本地,第三方容易直接改库创建 workspace/membership。
|
|
||||||
- 订阅、配额和商业权益如果完全在本地,容易绕过。
|
|
||||||
- Space 可以统一处理 OAuth、组织、邀请、付款、发票、套餐、权益、Marketplace 分发权限。
|
|
||||||
- LangBot Core 只作为执行面消费 Space 下发的 entitlement,减少商业规则暴露。
|
|
||||||
|
|
||||||
### Entitlement 设计
|
|
||||||
|
|
||||||
Space 向 LangBot Core 下发签名权益,可以是在线校验,也可以为企业版提供短期/长期离线 license token。
|
|
||||||
|
|
||||||
示例:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"edition": "oss",
|
|
||||||
"workspace_limit": 1,
|
|
||||||
"member_limit": 1,
|
|
||||||
"multi_workspace": false,
|
|
||||||
"rbac": false,
|
|
||||||
"audit_log": false,
|
|
||||||
"custom_roles": false,
|
|
||||||
"sso": false,
|
|
||||||
"commercial_use": false,
|
|
||||||
"expires_at": 1893456000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
OSS 默认权益:
|
|
||||||
|
|
||||||
- `workspace_limit = 1`
|
|
||||||
- `member_limit = 1`
|
|
||||||
- `multi_workspace = false`
|
|
||||||
- `rbac = false`
|
|
||||||
- `audit_log = false`
|
|
||||||
- `sso = false`
|
|
||||||
|
|
||||||
Cloud/Pro/Enterprise 权益:
|
|
||||||
|
|
||||||
- `workspace_limit > 1`
|
|
||||||
- `member_limit > 1`
|
|
||||||
- `multi_workspace = true`
|
|
||||||
- `rbac = true`
|
|
||||||
- 可按套餐打开 audit、custom roles、SSO、usage reporting、enterprise support 等能力。
|
|
||||||
|
|
||||||
Core 执行面需要在关键入口强制校验 entitlement:
|
|
||||||
|
|
||||||
- 创建 workspace。
|
|
||||||
- 邀请成员。
|
|
||||||
- 修改成员角色。
|
|
||||||
- 切换 workspace。
|
|
||||||
- 创建超过 quota 的资源。
|
|
||||||
- 开启商业模块功能。
|
|
||||||
|
|
||||||
### 商业模块边界
|
|
||||||
|
|
||||||
以下能力不建议进入 OSS 仓库的完整实现:
|
|
||||||
|
|
||||||
- 多 workspace 创建和切换。
|
|
||||||
- Workspace 成员邀请。
|
|
||||||
- Workspace RBAC 和自定义角色。
|
|
||||||
- Workspace 审计日志。
|
|
||||||
- Workspace 级用量和配额管理。
|
|
||||||
- 订阅、账单、发票。
|
|
||||||
- 企业 SSO/SAML/OIDC。
|
|
||||||
- 在线/离线 license 管理。
|
|
||||||
- 多租户 SaaS 运营控制台。
|
|
||||||
|
|
||||||
OSS 仓库可以保留接口占位、默认 workspace 兼容和必要的数据隔离字段,但完整交互、管理 UI、权益校验器和多 workspace policy engine 应由 Space 或商业模块提供。
|
|
||||||
|
|
||||||
### 防自建 SaaS 的现实边界
|
|
||||||
|
|
||||||
技术上无法 100% 阻止别人 fork 开源代码后自行改造。更可靠的策略是组合:
|
|
||||||
|
|
||||||
- 不把完整商业多 workspace 实现放进 OSS。
|
|
||||||
- Space 控制面提供账号、订阅、权益、Marketplace 和官方托管能力。
|
|
||||||
- 商业模块闭源或私有分发。
|
|
||||||
- 使用商标、云服务条款和商业 license 限制“自称官方 LangBot SaaS”或未经授权商用托管。
|
|
||||||
- 如果当前开源 license 对托管商用限制不足,需要单独评估 license 策略,必要时引入 open-core license 或新增商业附加条款。具体 license 调整需要法律评审。
|
|
||||||
|
|
||||||
结论:多 workspace 的底层 schema 可以在 OSS 中以 default workspace 兼容方式铺路,但多 workspace 产品能力、账户订阅权益、协作管理和 SaaS 控制面应放到 Space/商业模块,不作为开源版可直接使用的能力。
|
|
||||||
|
|
||||||
## 推荐总体架构
|
|
||||||
|
|
||||||
采用“单实例多 workspace,资源行级隔离,运行时上下文隔离”的架构:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A["Account"] --> B["WorkspaceMembership"]
|
|
||||||
B --> C["Workspace"]
|
|
||||||
C --> D["Bots"]
|
|
||||||
C --> E["Pipelines"]
|
|
||||||
C --> F["Models & Providers"]
|
|
||||||
C --> G["Knowledge Bases"]
|
|
||||||
C --> H["Extensions: Plugins / MCP"]
|
|
||||||
C --> I["API Keys & Webhooks"]
|
|
||||||
C --> J["Monitoring"]
|
|
||||||
D --> K["Runtime Query"]
|
|
||||||
E --> K
|
|
||||||
K --> L["Plugin Runtime"]
|
|
||||||
K --> M["MCP Runtime"]
|
|
||||||
L --> N["Workspace-scoped Host APIs"]
|
|
||||||
```
|
|
||||||
|
|
||||||
原则:
|
|
||||||
|
|
||||||
- 账户全局唯一,workspace 是所有业务资源的归属边界。
|
|
||||||
- 所有 HTTP handler 在进入业务服务前解析出 `RequestContext(account, workspace, membership, permissions)`。
|
|
||||||
- 所有 service 方法显式接收 `ctx` 或 `workspace_id`,禁止在业务服务里无条件 select 全表。
|
|
||||||
- 运行时对象的 key 从 `uuid` 扩展为 `(workspace_id, uuid)` 或使用全局唯一 uuid 但必须记录 workspace_id 并校验。
|
|
||||||
- 插件/MCP/知识库/模型调用都按 query 所属 workspace 过滤可用资源。
|
|
||||||
|
|
||||||
## 数据模型设计
|
|
||||||
|
|
||||||
### Account
|
|
||||||
|
|
||||||
替代现有 `users` 的语义,建议保留表名但升级字段,避免过大迁移:
|
|
||||||
|
|
||||||
字段建议:
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `uuid`
|
|
||||||
- `email`
|
|
||||||
- `password_hash`
|
|
||||||
- `display_name`
|
|
||||||
- `avatar_url`
|
|
||||||
- `account_type`: `local | space`
|
|
||||||
- `status`: `active | disabled | deleted`
|
|
||||||
- `space_account_uuid`
|
|
||||||
- `space_access_token`
|
|
||||||
- `space_refresh_token`
|
|
||||||
- `space_access_token_expires_at`
|
|
||||||
- `space_api_key`
|
|
||||||
- `created_at`
|
|
||||||
- `updated_at`
|
|
||||||
|
|
||||||
兼容策略:
|
|
||||||
|
|
||||||
- 旧字段 `user` 迁移为 `email`,可以短期保留 alias。
|
|
||||||
- 旧 `password` 迁移为 `password_hash`,也可先保持列名不变,service 层改命名。
|
|
||||||
- JWT 中不要继续只放 email,应放 `sub=account_uuid`。
|
|
||||||
|
|
||||||
### Workspace
|
|
||||||
|
|
||||||
新增 `workspaces`:
|
|
||||||
|
|
||||||
- `uuid`
|
|
||||||
- `name`
|
|
||||||
- `slug`
|
|
||||||
- `avatar_url`
|
|
||||||
- `type`: `personal | team`
|
|
||||||
- `status`: `active | suspended | deleted`
|
|
||||||
- `default_language`
|
|
||||||
- `created_by_account_uuid`
|
|
||||||
- `created_at`
|
|
||||||
- `updated_at`
|
|
||||||
|
|
||||||
每个账户首次登录时自动创建一个 personal workspace。旧单用户实例迁移时创建一个 `Default Workspace`。
|
|
||||||
|
|
||||||
### WorkspaceMembership
|
|
||||||
|
|
||||||
新增 `workspace_memberships`:
|
|
||||||
|
|
||||||
- `workspace_uuid`
|
|
||||||
- `account_uuid`
|
|
||||||
- `role`: `owner | admin | developer | operator | viewer`
|
|
||||||
- `status`: `active | invited | disabled`
|
|
||||||
- `invited_by_account_uuid`
|
|
||||||
- `joined_at`
|
|
||||||
- `created_at`
|
|
||||||
- `updated_at`
|
|
||||||
|
|
||||||
唯一索引:
|
|
||||||
|
|
||||||
- `(workspace_uuid, account_uuid)`
|
|
||||||
|
|
||||||
### WorkspaceInvitation
|
|
||||||
|
|
||||||
新增 `workspace_invitations`:
|
|
||||||
|
|
||||||
- `uuid`
|
|
||||||
- `workspace_uuid`
|
|
||||||
- `email`
|
|
||||||
- `role`
|
|
||||||
- `token_hash`
|
|
||||||
- `expires_at`
|
|
||||||
- `accepted_at`
|
|
||||||
- `created_by_account_uuid`
|
|
||||||
- `created_at`
|
|
||||||
|
|
||||||
用于邀请外部用户加入 workspace。Space OAuth 登录时可以根据 email 自动匹配未接受邀请。
|
|
||||||
|
|
||||||
### Role 与 Permission
|
|
||||||
|
|
||||||
先用固定角色,后续再做自定义角色。
|
|
||||||
|
|
||||||
建议权限:
|
|
||||||
|
|
||||||
- `workspace.manage`
|
|
||||||
- `member.view`
|
|
||||||
- `member.invite`
|
|
||||||
- `member.update_role`
|
|
||||||
- `member.remove`
|
|
||||||
- `bot.view`
|
|
||||||
- `bot.manage`
|
|
||||||
- `pipeline.view`
|
|
||||||
- `pipeline.manage`
|
|
||||||
- `model.view`
|
|
||||||
- `model.manage`
|
|
||||||
- `knowledge.view`
|
|
||||||
- `knowledge.manage`
|
|
||||||
- `extension.view`
|
|
||||||
- `extension.manage`
|
|
||||||
- `monitoring.view`
|
|
||||||
- `apikey.manage`
|
|
||||||
- `webhook.manage`
|
|
||||||
- `billing.view`
|
|
||||||
- `billing.manage`
|
|
||||||
|
|
||||||
角色映射:
|
|
||||||
|
|
||||||
| Role | 说明 | 权限 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| owner | workspace 拥有者 | 全部权限;可转让 owner;不可被其他角色移除 |
|
|
||||||
| admin | 管理员 | 除 owner 转让和删除 workspace 外的全部权限 |
|
|
||||||
| developer | 构建者 | 管理 bot、pipeline、model、knowledge、extension、webhook,可看监控 |
|
|
||||||
| operator | 运营者 | 查看和启停 bot、查看监控、查看配置,不可改密钥和删除资源 |
|
|
||||||
| viewer | 只读成员 | 只读资源和监控 |
|
|
||||||
|
|
||||||
### 业务资源加 workspace_uuid
|
|
||||||
|
|
||||||
以下表需要新增 `workspace_uuid`:
|
|
||||||
|
|
||||||
- `bots`
|
|
||||||
- `legacy_pipelines`
|
|
||||||
- `pipeline_run_records`
|
|
||||||
- `model_providers`
|
|
||||||
- `llm_models`
|
|
||||||
- `embedding_models`
|
|
||||||
- `rerank_models`
|
|
||||||
- `plugin_settings`
|
|
||||||
- `mcp_servers`
|
|
||||||
- `knowledge_bases`
|
|
||||||
- `knowledge_base_files`
|
|
||||||
- `knowledge_base_chunks`
|
|
||||||
- `monitoring_*`
|
|
||||||
- `api_keys`
|
|
||||||
- `webhooks`
|
|
||||||
- `binary_storages`
|
|
||||||
- `metadata`
|
|
||||||
|
|
||||||
索引建议:
|
|
||||||
|
|
||||||
- 所有资源表加 `(workspace_uuid, created_at)` 或 `(workspace_uuid, updated_at)`。
|
|
||||||
- 资源唯一键从单列改为 workspace 复合唯一:
|
|
||||||
- `bots.uuid` 可保持全局唯一,但查询仍必须带 workspace。
|
|
||||||
- `plugin_settings` 主键从 `(plugin_author, plugin_name)` 改为 `(workspace_uuid, plugin_author, plugin_name)`。
|
|
||||||
- `mcp_servers.name` 如果未来要求唯一,必须是 `(workspace_uuid, name)`。
|
|
||||||
- `metadata.key` 改为 `(workspace_uuid, key)`,系统级 metadata 单独放 `system_metadata` 或使用 `workspace_uuid=NULL`。
|
|
||||||
- `binary_storages.unique_key` 建议改为 `workspace_uuid + owner_type + owner + key` 的 hash。
|
|
||||||
|
|
||||||
### API Key
|
|
||||||
|
|
||||||
API Key 必须归属于 workspace:
|
|
||||||
|
|
||||||
- `workspace_uuid`
|
|
||||||
- `created_by_account_uuid`
|
|
||||||
- `scopes`
|
|
||||||
- `expires_at`
|
|
||||||
- `last_used_at`
|
|
||||||
- `status`
|
|
||||||
|
|
||||||
验证 API Key 后生成 `RequestContext`:
|
|
||||||
|
|
||||||
- `account_uuid=None` 或 service account uuid
|
|
||||||
- `workspace_uuid=key.workspace_uuid`
|
|
||||||
- `permissions=key.scopes`
|
|
||||||
|
|
||||||
这样 `/api/v1/platform/bots/<uuid>/send_message` 之类接口不会跨 workspace 操作 bot。
|
|
||||||
|
|
||||||
## 后端改造方案
|
|
||||||
|
|
||||||
### RequestContext
|
|
||||||
|
|
||||||
新增统一上下文对象,例如:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class RequestContext:
|
|
||||||
account_uuid: str | None
|
|
||||||
workspace_uuid: str
|
|
||||||
role: str | None
|
|
||||||
permissions: set[str]
|
|
||||||
auth_type: Literal["user_token", "api_key"]
|
|
||||||
```
|
|
||||||
|
|
||||||
改造 `RouterGroup.route()`:
|
|
||||||
|
|
||||||
- `USER_TOKEN`:验证 JWT,读取 `account_uuid`,再从 header/query/cookie 中解析 current workspace。
|
|
||||||
- `API_KEY`:验证 API Key,直接得到 workspace。
|
|
||||||
- `USER_TOKEN_OR_API_KEY`:两者都返回同一种 `RequestContext`。
|
|
||||||
- handler 参数从可选 `user_email` 升级为可选 `ctx`;兼容期同时支持 `user_email`。
|
|
||||||
|
|
||||||
当前 workspace 传递方式:
|
|
||||||
|
|
||||||
- 推荐 header:`X-Workspace-Id: <workspace_uuid>`
|
|
||||||
- Web 前端同时把当前 workspace 存在 localStorage。
|
|
||||||
- 如果未传,后端用账户最近使用 workspace 或第一个 active membership。
|
|
||||||
|
|
||||||
JWT payload:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sub": "account_uuid",
|
|
||||||
"email": "user@example.com",
|
|
||||||
"iss": "LangBot-...",
|
|
||||||
"exp": 1234567890
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
不要把 workspace 写死在 JWT 里,否则切换 workspace 需要刷新 token。可以额外支持短 TTL workspace token,但第一阶段不必。
|
|
||||||
|
|
||||||
### 服务层改造模式
|
|
||||||
|
|
||||||
所有 service 方法增加 `ctx` 或 `workspace_uuid`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def get_bots(self, ctx: RequestContext, include_secret: bool = True):
|
|
||||||
require(ctx, "bot.view")
|
|
||||||
query = sqlalchemy.select(Bot).where(Bot.workspace_uuid == ctx.workspace_uuid)
|
|
||||||
```
|
|
||||||
|
|
||||||
需要改造的服务:
|
|
||||||
|
|
||||||
- `UserService`:拆成 AccountService + WorkspaceService 更清晰。
|
|
||||||
- `ApiKeyService`:按 workspace 管理 key。
|
|
||||||
- `BotService`:所有 bot 查询/创建/更新/删除按 workspace。
|
|
||||||
- `PipelineService`:所有 pipeline 查询/默认 pipeline 按 workspace。
|
|
||||||
- `ModelProviderService` 和 model services:按 workspace 隔离 provider 和 model。
|
|
||||||
- `MCPService`:按 workspace 管理 MCP server,运行时按 workspace host。
|
|
||||||
- `KnowledgeService/RAGRuntimeService`:按 workspace 管理 KB、文件、collection。
|
|
||||||
- `MonitoringService`:记录和查询都带 workspace。
|
|
||||||
- `WebhookService`:按 workspace 管理 webhook。
|
|
||||||
- `PluginRuntimeConnector`:插件安装、设置、配置按 workspace。
|
|
||||||
|
|
||||||
### HTTP API 形态
|
|
||||||
|
|
||||||
保留现有路径,靠 `X-Workspace-Id` 表示当前 workspace,可减少前端和 SDK 破坏:
|
|
||||||
|
|
||||||
- `GET /api/v1/workspaces`
|
|
||||||
- `POST /api/v1/workspaces`
|
|
||||||
- `GET /api/v1/workspaces/current`
|
|
||||||
- `PUT /api/v1/workspaces/current`
|
|
||||||
- `GET /api/v1/workspaces/<workspace_uuid>/members`
|
|
||||||
- `POST /api/v1/workspaces/<workspace_uuid>/invitations`
|
|
||||||
- `PUT /api/v1/workspaces/<workspace_uuid>/members/<account_uuid>`
|
|
||||||
- `DELETE /api/v1/workspaces/<workspace_uuid>/members/<account_uuid>`
|
|
||||||
|
|
||||||
现有资源 API:
|
|
||||||
|
|
||||||
- `/api/v1/platform/bots`
|
|
||||||
- `/api/v1/pipelines`
|
|
||||||
- `/api/v1/provider/*`
|
|
||||||
- `/api/v1/plugins`
|
|
||||||
- `/api/v1/mcp`
|
|
||||||
- `/api/v1/knowledge`
|
|
||||||
|
|
||||||
继续保留,但必须从 `RequestContext.workspace_uuid` 过滤。
|
|
||||||
|
|
||||||
对外 API Key 也使用相同路径,只是由 key 决定 workspace。
|
|
||||||
|
|
||||||
### 初始化流程
|
|
||||||
|
|
||||||
现有 `/api/v1/user/init` 含义改为“创建首个账号和首个 workspace”:
|
|
||||||
|
|
||||||
1. 如果系统没有任何 account:
|
|
||||||
- 创建 account。
|
|
||||||
- 创建 personal/team workspace。
|
|
||||||
- 创建 owner membership。
|
|
||||||
- 创建默认 pipeline。
|
|
||||||
- 标记 wizard status 到该 workspace metadata。
|
|
||||||
2. 如果系统已有 account:
|
|
||||||
- 禁止无邀请注册,除非配置允许公开注册。
|
|
||||||
- Space OAuth 登录后,如果没有 membership,引导创建 workspace 或接受邀请。
|
|
||||||
|
|
||||||
`/api/v1/user/account-info` 不应再只返回 first user,应返回:
|
|
||||||
|
|
||||||
- `initialized`
|
|
||||||
- `registration_mode`
|
|
||||||
- `space_enabled`
|
|
||||||
- `default_login_methods`
|
|
||||||
|
|
||||||
登录成功后前端调用 `/api/v1/workspaces` 选择 workspace。
|
|
||||||
|
|
||||||
### 运行时隔离
|
|
||||||
|
|
||||||
第一阶段采用共享进程 + workspace-aware runtime:
|
|
||||||
|
|
||||||
- `RuntimeBot` 增加 `workspace_uuid`。
|
|
||||||
- `RuntimePipeline` 增加 `workspace_uuid`。
|
|
||||||
- `Query` 增加 `workspace_uuid`,从 bot/pipeline 派生。
|
|
||||||
- `SessionManager.get_session()` key 从 `(launcher_type, launcher_id)` 改为 `(workspace_uuid, bot_uuid, launcher_type, launcher_id)`。
|
|
||||||
- `PipelineManager.pipeline_dict` key 可保持 pipeline uuid,但所有 load/get 都校验 workspace;如果 uuid 不是全局唯一则改为 `(workspace_uuid, pipeline_uuid)`。
|
|
||||||
- `ModelManager` provider/model 加 workspace 过滤;`get_model_by_uuid` 必须确保 query workspace 可访问。
|
|
||||||
- `ToolManager` 中 MCP tools、plugin tools 按 query workspace 过滤。
|
|
||||||
|
|
||||||
后续规模化时可演进:
|
|
||||||
|
|
||||||
- workspace runtime shard:每个 workspace 一套 plugin runtime/MCP runtime。
|
|
||||||
- 大客户独立进程或独立数据库。
|
|
||||||
|
|
||||||
## Plugin SDK 与 Runtime 改造
|
|
||||||
|
|
||||||
### Query/Event 增加 workspace context
|
|
||||||
|
|
||||||
SDK `Query` 增加:
|
|
||||||
|
|
||||||
- `workspace_uuid: str`
|
|
||||||
- `workspace_slug: str | None`
|
|
||||||
- `account_uuid: str | None`,仅 Web/API 触发时可能有,聊天平台消息通常为空。
|
|
||||||
|
|
||||||
Event 模型通过 `event.query.workspace_uuid` 可拿到租户上下文;序列化时也应包含这些字段。
|
|
||||||
|
|
||||||
向后兼容:
|
|
||||||
|
|
||||||
- 字段可选,默认 `None`。
|
|
||||||
- 老插件不感知这些字段也能跑。
|
|
||||||
- 新插件可通过 `ctx.event.query.workspace_uuid` 或新增 `ctx.get_workspace()` 访问。
|
|
||||||
|
|
||||||
### Host API 默认按当前 workspace 限制
|
|
||||||
|
|
||||||
`LangBotAPIProxy` 的以下方法必须由 Host 端按 workspace 过滤:
|
|
||||||
|
|
||||||
- `get_bots`
|
|
||||||
- `get_bot_info`
|
|
||||||
- `send_message`
|
|
||||||
- `get_llm_models`
|
|
||||||
- `invoke_llm`
|
|
||||||
- `list_plugins_manifest`
|
|
||||||
- `list_commands`
|
|
||||||
- `list_tools`
|
|
||||||
- `call_tool`
|
|
||||||
- `invoke_embedding`
|
|
||||||
- `vector_*`
|
|
||||||
- `list_knowledge_bases`
|
|
||||||
- `retrieve_knowledge`
|
|
||||||
|
|
||||||
建议新增显式方法:
|
|
||||||
|
|
||||||
- `get_workspace_info()`
|
|
||||||
- `get_current_account()`
|
|
||||||
- `get_workspace_storage(...)`
|
|
||||||
|
|
||||||
但不要让插件传入任意 workspace id 来越权访问。插件请求的 workspace 应由 Runtime 根据当前 query/plugin connection 填充。
|
|
||||||
|
|
||||||
### Workspace storage 修复
|
|
||||||
|
|
||||||
当前 SDK runtime 中:
|
|
||||||
|
|
||||||
```python
|
|
||||||
data["owner_type"] = "workspace"
|
|
||||||
data["owner"] = "default"
|
|
||||||
```
|
|
||||||
|
|
||||||
必须改为:
|
|
||||||
|
|
||||||
- 如果请求来自 query/event:owner 为 `workspace_uuid`。
|
|
||||||
- 如果请求来自后台插件任务:owner 为 plugin 安装所属 workspace。
|
|
||||||
- Host 侧 `binary_storages` 加 `workspace_uuid`,并在 unique key 中包含 workspace。
|
|
||||||
|
|
||||||
Plugin storage 建议也同时加 workspace:
|
|
||||||
|
|
||||||
- 现在 plugin storage owner 是 `author/name`,这会导致同一插件在不同 workspace 的私有数据冲突。
|
|
||||||
- 应改为 `(workspace_uuid, plugin_id, key)`。
|
|
||||||
|
|
||||||
### 插件安装与配置
|
|
||||||
|
|
||||||
`plugin_settings` 从全局变为 workspace-scoped:
|
|
||||||
|
|
||||||
- 同一个插件可安装到多个 workspace。
|
|
||||||
- 每个 workspace 有自己的 enabled、priority、config、install_source、install_info。
|
|
||||||
- 插件 runtime 列表需要能按 workspace 过滤。
|
|
||||||
|
|
||||||
实现路线有两种:
|
|
||||||
|
|
||||||
1. 共享插件进程,插件代码只加载一份,设置和调用时附带 workspace。
|
|
||||||
2. 每个 workspace 一个插件容器实例,隔离最彻底但资源占用更高。
|
|
||||||
|
|
||||||
推荐第一阶段采用方案 1,但要求:
|
|
||||||
|
|
||||||
- 所有 RuntimeToLangBot/PluginToRuntime action 都能携带 `workspace_uuid`。
|
|
||||||
- 插件 config 获取时按 workspace 返回。
|
|
||||||
- 插件 page API 请求必须校验当前用户在该 workspace 有访问权限。
|
|
||||||
|
|
||||||
### MCP
|
|
||||||
|
|
||||||
MCP server 是租户资源:
|
|
||||||
|
|
||||||
- `mcp_servers.workspace_uuid`。
|
|
||||||
- MCP session key 从 `server_name` 改为 `(workspace_uuid, server_name)` 或使用全局 uuid。
|
|
||||||
- Pipeline extension preferences 中绑定 MCP server uuid 时,只能绑定同 workspace 的 server。
|
|
||||||
- MCP tool 列表在 query 执行时按 query.workspace_uuid + pipeline 绑定关系过滤。
|
|
||||||
|
|
||||||
## 前端改造
|
|
||||||
|
|
||||||
### Workspace selector
|
|
||||||
|
|
||||||
Home layout 顶部或 sidebar 增加 workspace selector:
|
|
||||||
|
|
||||||
- 当前 workspace 名称和头像。
|
|
||||||
- 切换 workspace 后写入 `localStorage.currentWorkspaceId`。
|
|
||||||
- 所有请求自动带 `X-Workspace-Id`。
|
|
||||||
- 切换后刷新 sidebar 数据和页面缓存。
|
|
||||||
|
|
||||||
`BaseHttpClient` request interceptor 增加:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const workspaceId = localStorage.getItem("currentWorkspaceId");
|
|
||||||
if (workspaceId) config.headers["X-Workspace-Id"] = workspaceId;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 用户与成员管理页面
|
|
||||||
|
|
||||||
新增页面:
|
|
||||||
|
|
||||||
- `/home/workspace/settings`
|
|
||||||
- `/home/workspace/members`
|
|
||||||
- `/home/workspace/invitations`
|
|
||||||
|
|
||||||
能力:
|
|
||||||
|
|
||||||
- owner/admin 邀请成员。
|
|
||||||
- owner/admin 修改成员角色。
|
|
||||||
- owner 移除成员、转让 owner。
|
|
||||||
- 所有人可切换 workspace。
|
|
||||||
- viewer/operator 在 UI 上隐藏不可操作按钮,但后端仍做权限校验。
|
|
||||||
|
|
||||||
### 登录与注册
|
|
||||||
|
|
||||||
登录后流程:
|
|
||||||
|
|
||||||
1. `authUser` 拿 token。
|
|
||||||
2. `initializeUserInfo()` 获取 account info。
|
|
||||||
3. `GET /api/v1/workspaces`。
|
|
||||||
4. 如果没有 workspace:进入创建 workspace 向导。
|
|
||||||
5. 如果有多个 workspace:默认进入最近使用 workspace,可切换。
|
|
||||||
|
|
||||||
注册页不再表达“初始化管理员账号”,而是:
|
|
||||||
|
|
||||||
- 首次系统启动:创建首个 owner + default workspace。
|
|
||||||
- 后续:根据配置允许公开注册,或只能接受邀请。
|
|
||||||
|
|
||||||
### 旧页面影响
|
|
||||||
|
|
||||||
需要逐个检查这些页面的数据加载是否都依赖当前 workspace:
|
|
||||||
|
|
||||||
- Bots
|
|
||||||
- Pipelines
|
|
||||||
- Plugins/Market/MCP
|
|
||||||
- Knowledge
|
|
||||||
- Monitoring
|
|
||||||
- Models dialog
|
|
||||||
- API integration dialog
|
|
||||||
- Wizard
|
|
||||||
|
|
||||||
## 迁移方案
|
|
||||||
|
|
||||||
### 迁移阶段 0:准备
|
|
||||||
|
|
||||||
- 引入 `workspaces`、`workspace_memberships`、`workspace_invitations`。
|
|
||||||
- 给 `users` 增加 `uuid/status/display_name` 等字段。
|
|
||||||
- 创建 `RequestContext`,但先不强制所有服务改完。
|
|
||||||
|
|
||||||
### 迁移阶段 1:默认 workspace
|
|
||||||
|
|
||||||
对现有实例执行迁移:
|
|
||||||
|
|
||||||
1. 创建 `Default Workspace`。
|
|
||||||
2. 找到现有第一个 user,设为 owner。
|
|
||||||
3. 所有已有资源写入 `workspace_uuid=default_workspace_uuid`。
|
|
||||||
4. `metadata` 迁入 default workspace;确实全局的配置放到 `system_metadata`。
|
|
||||||
5. `binary_storages` 中 `owner_type=workspace, owner=default` 改为 owner 为 default workspace uuid。
|
|
||||||
6. 插件 `plugin_settings` 归入 default workspace。
|
|
||||||
|
|
||||||
### 迁移阶段 2:服务层强制 scope
|
|
||||||
|
|
||||||
- 改所有 service 查询,必须要求 `workspace_uuid`。
|
|
||||||
- API Key 迁移为 workspace key。
|
|
||||||
- 所有写操作必须检查权限。
|
|
||||||
- 监控和任务查询按 workspace 过滤。
|
|
||||||
|
|
||||||
### 迁移阶段 3:运行时上下文
|
|
||||||
|
|
||||||
- `Query`、`Session`、`RuntimeBot`、`RuntimePipeline` 增加 workspace。
|
|
||||||
- Plugin/MCP/Model/RAG runtime 全部按 workspace 过滤。
|
|
||||||
- 修复 SDK workspace storage。
|
|
||||||
|
|
||||||
### 迁移阶段 4:前端多 workspace
|
|
||||||
|
|
||||||
- 登录后 workspace 选择。
|
|
||||||
- Header/sidebar workspace switcher。
|
|
||||||
- 成员和邀请管理。
|
|
||||||
- 所有 API 请求带 `X-Workspace-Id`。
|
|
||||||
|
|
||||||
### 迁移阶段 5:安全收敛
|
|
||||||
|
|
||||||
- 添加跨 workspace 越权测试。
|
|
||||||
- 添加 API Key scope 测试。
|
|
||||||
- 添加插件 Host API 过滤测试。
|
|
||||||
- 添加 MCP 和 RAG 隔离测试。
|
|
||||||
|
|
||||||
## 安全边界
|
|
||||||
|
|
||||||
必须防的场景:
|
|
||||||
|
|
||||||
- 用户 A 修改 URL 中 bot uuid,访问用户 B workspace 的 bot。
|
|
||||||
- API Key 来自 workspace A,但调用 workspace B 的 bot。
|
|
||||||
- 插件通过 `get_bots()` 枚举所有 workspace 的 bot。
|
|
||||||
- 插件通过 `workspace_storage` 读取其它 workspace 的数据。
|
|
||||||
- MCP server 名称相同导致 session 复用。
|
|
||||||
- monitoring session_id 相同导致数据串租户。
|
|
||||||
- Space OAuth 登录时,同 email 账户被错误绑定到已有本地 account。
|
|
||||||
|
|
||||||
建议策略:
|
|
||||||
|
|
||||||
- 所有资源访问都使用 `workspace_uuid + resource_id`。
|
|
||||||
- 所有 service 方法入口做权限检查。
|
|
||||||
- 插件 Host API 的 workspace 不信任插件入参,只信任 query/runtime connection 上下文。
|
|
||||||
- API Key 只授予最小 scope,默认不允许成员管理。
|
|
||||||
- owner 角色不能被普通 admin 移除或降权。
|
|
||||||
|
|
||||||
## 实施优先级
|
|
||||||
|
|
||||||
### P0:基础租户骨架
|
|
||||||
|
|
||||||
- Account uuid/status。
|
|
||||||
- Workspace / Membership / Invitation。
|
|
||||||
- RequestContext。
|
|
||||||
- JWT 改为 account uuid。
|
|
||||||
- 前端 current workspace header。
|
|
||||||
|
|
||||||
### P1:资源行级隔离
|
|
||||||
|
|
||||||
- Bots、Pipelines、Models、MCP、Plugins、Knowledge、Monitoring、API Keys 全部加 workspace_uuid。
|
|
||||||
- service 查询统一加 workspace filter。
|
|
||||||
- 权限矩阵落地。
|
|
||||||
|
|
||||||
### P2:运行时隔离
|
|
||||||
|
|
||||||
- Query、Session、RuntimeBot、RuntimePipeline 加 workspace。
|
|
||||||
- Plugin Host API 和 MCP tools 按 workspace 过滤。
|
|
||||||
- SDK workspace storage 从 `default` 改为真实 workspace。
|
|
||||||
|
|
||||||
### P3:协作体验
|
|
||||||
|
|
||||||
- 邀请成员。
|
|
||||||
- 成员列表和角色管理。
|
|
||||||
- workspace switcher。
|
|
||||||
- 最近使用 workspace。
|
|
||||||
|
|
||||||
### P4:SaaS 运维增强
|
|
||||||
|
|
||||||
- Workspace 级用量统计。
|
|
||||||
- Workspace 级限额:max_bots/max_pipelines/max_extensions/tokens/storage。
|
|
||||||
- 审计日志。
|
|
||||||
- workspace suspend/delete。
|
|
||||||
- 可选自定义角色。
|
|
||||||
|
|
||||||
## 测试计划
|
|
||||||
|
|
||||||
后端测试:
|
|
||||||
|
|
||||||
- 账户可加入多个 workspace。
|
|
||||||
- 同账户在不同 workspace 权限不同。
|
|
||||||
- viewer 不能创建/修改资源。
|
|
||||||
- API Key 只能访问所属 workspace。
|
|
||||||
- 所有资源 list/get/update/delete 都不能跨 workspace。
|
|
||||||
- 默认 workspace 迁移后旧数据可用。
|
|
||||||
|
|
||||||
运行时测试:
|
|
||||||
|
|
||||||
- 两个 workspace 使用相同 `launcher_id` 不共享 session。
|
|
||||||
- 两个 workspace 使用相同 MCP server name 不共享 MCP session。
|
|
||||||
- 插件 `get_bots()` 只能看到当前 workspace bot。
|
|
||||||
- 插件 `workspace_storage` 在不同 workspace 读写隔离。
|
|
||||||
- Pipeline 只调用当前 workspace 绑定的插件和 MCP tools。
|
|
||||||
|
|
||||||
前端测试:
|
|
||||||
|
|
||||||
- 登录后自动进入最近 workspace。
|
|
||||||
- 切换 workspace 后列表数据变化。
|
|
||||||
- 无权限按钮隐藏,直接调用 API 也被后端拒绝。
|
|
||||||
- 邀请成员流程完整。
|
|
||||||
|
|
||||||
迁移测试:
|
|
||||||
|
|
||||||
- SQLite 老实例迁移。
|
|
||||||
- PostgreSQL 老实例迁移。
|
|
||||||
- 已有 local account 迁移为 default workspace owner。
|
|
||||||
- 已有 Space account token 和 Space model provider API key 不丢失。
|
|
||||||
|
|
||||||
## 关键实现注意事项
|
|
||||||
|
|
||||||
- 不建议在第一版做数据库 schema-per-tenant。LangBot 当前 ORM 和运行时均以单库单表为主,先做 shared schema + workspace_uuid 成本更低。
|
|
||||||
- 不建议每个 workspace 立即启动独立 plugin runtime。先共享 runtime,强制 action 带 workspace;大客户隔离可作为后续部署形态。
|
|
||||||
- 不要只在前端过滤 workspace。插件、API Key、MCP、RAG 都能绕过前端,必须在后端和运行时层过滤。
|
|
||||||
- `metadata` 要拆清楚:wizard status 属于 workspace,系统版本/迁移信息属于 system。
|
|
||||||
- `users.user` 用 email 当主键语义不稳,应尽快引入 `account_uuid` 并让 JWT 以 uuid 为准。
|
|
||||||
- `plugin_settings` 当前主键没有 workspace,改造时要先改主键/唯一约束,否则同插件无法在多个 workspace 配不同配置。
|
|
||||||
|
|
||||||
## 建议落地顺序
|
|
||||||
|
|
||||||
1. 新增 workspace/account/membership 表和 RequestContext。
|
|
||||||
2. 迁移旧数据到 default workspace。
|
|
||||||
3. 改 auth 和前端请求头,让每个请求都有 current workspace。
|
|
||||||
4. 从最核心资源开始逐个加 scope:bot -> pipeline -> provider/model -> plugin/MCP -> knowledge -> monitoring。
|
|
||||||
5. 改 SDK Query/Event 和 runtime storage。
|
|
||||||
6. 上成员管理 UI 和邀请。
|
|
||||||
7. 做越权测试和迁移测试。
|
|
||||||
|
|
||||||
这个顺序的好处是可以较早让主 UI 在一个 workspace 下继续工作,同时把最危险的跨租户泄露面逐步收紧。
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.6"
|
version = "4.9.7"
|
||||||
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"]
|
||||||
@@ -22,7 +22,7 @@ dependencies = [
|
|||||||
"discord-py>=2.5.2",
|
"discord-py>=2.5.2",
|
||||||
"pynacl>=1.5.0", # Required for Discord voice support
|
"pynacl>=1.5.0", # Required for Discord voice support
|
||||||
"gewechat-client>=0.1.5",
|
"gewechat-client>=0.1.5",
|
||||||
"lark-oapi>=1.4.15",
|
"lark-oapi>=1.5.5",
|
||||||
"mcp>=1.25.0",
|
"mcp>=1.25.0",
|
||||||
"nakuru-project-idk>=0.0.2.1",
|
"nakuru-project-idk>=0.0.2.1",
|
||||||
"ollama>=0.4.8",
|
"ollama>=0.4.8",
|
||||||
@@ -35,6 +35,7 @@ dependencies = [
|
|||||||
"python-telegram-bot>=22.0",
|
"python-telegram-bot>=22.0",
|
||||||
"pyyaml>=6.0.2",
|
"pyyaml>=6.0.2",
|
||||||
"qq-botpy-rc>=1.2.1.6",
|
"qq-botpy-rc>=1.2.1.6",
|
||||||
|
"qrcode>=7.4",
|
||||||
"quart>=0.20.0",
|
"quart>=0.20.0",
|
||||||
"quart-cors>=0.8.0",
|
"quart-cors>=0.8.0",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.3",
|
||||||
@@ -69,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.3.10",
|
"langbot-plugin==0.3.11",
|
||||||
"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.9.6'
|
__version__ = '4.9.7'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import quart
|
import quart
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import asyncio
|
||||||
from ... import group
|
from ... import group
|
||||||
from langbot.pkg.utils import importutil
|
from langbot.pkg.utils import importutil
|
||||||
|
|
||||||
@@ -35,3 +36,640 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
return quart.Response(
|
return quart.Response(
|
||||||
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
|
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# In-memory session store for active registrations
|
||||||
|
_create_app_sessions: dict = {}
|
||||||
|
_SESSION_TTL = 900 # 15 minutes
|
||||||
|
|
||||||
|
def _cleanup_expired_sessions():
|
||||||
|
"""Remove sessions that have exceeded their TTL."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [sid for sid, s in _create_app_sessions.items() if now - s.get('created_at', 0) > _SESSION_TTL]
|
||||||
|
for sid in expired:
|
||||||
|
session = _create_app_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/lark/create-app', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start Feishu one-click app registration. Returns session_id + QR code URL."""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import lark_oapi as lark
|
||||||
|
from lark_oapi.scene.registration.errors import AppAccessDeniedError, AppExpiredError
|
||||||
|
|
||||||
|
_cleanup_expired_sessions()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'app_id': None,
|
||||||
|
'app_secret': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
}
|
||||||
|
_create_app_sessions[session_id] = session
|
||||||
|
|
||||||
|
def on_qr_code(info):
|
||||||
|
# May be called from a background thread by the SDK;
|
||||||
|
# use call_soon_threadsafe to safely update session state.
|
||||||
|
def _update():
|
||||||
|
session['qr_url'] = info['url']
|
||||||
|
session['expire_at'] = time.time() + 600 # 10 minutes
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
loop.call_soon_threadsafe(_update)
|
||||||
|
|
||||||
|
async def run_registration():
|
||||||
|
try:
|
||||||
|
result = await lark.aregister_app(
|
||||||
|
on_qr_code=on_qr_code,
|
||||||
|
source='langbot',
|
||||||
|
)
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['app_id'] = result['client_id']
|
||||||
|
session['app_secret'] = result['client_secret']
|
||||||
|
except AppAccessDeniedError:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'User denied authorization'
|
||||||
|
except AppExpiredError:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
except Exception as e:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(e)
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_registration())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait for QR code to be ready (max 10 seconds)
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_url']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if not session['qr_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_url': session['qr_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/lark/create-app/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll registration status."""
|
||||||
|
session = _create_app_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {'status': session['status']}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['app_id'] = session['app_id']
|
||||||
|
data['app_secret'] = session['app_secret']
|
||||||
|
_create_app_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'error':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_create_app_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/lark/create-app/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a registration session."""
|
||||||
|
session = _create_app_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# WeChat QR Code Login
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_weixin_login_sessions: dict = {}
|
||||||
|
_WEIXIN_SESSION_TTL = 600 # 10 minutes (3 retries × 3 min QR validity)
|
||||||
|
|
||||||
|
def _cleanup_expired_weixin_sessions():
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [
|
||||||
|
sid for sid, s in _weixin_login_sessions.items() if now - s.get('created_at', 0) > _WEIXIN_SESSION_TTL
|
||||||
|
]
|
||||||
|
for sid in expired:
|
||||||
|
session = _weixin_login_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/weixin/login', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL
|
||||||
|
|
||||||
|
_cleanup_expired_weixin_sessions()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_data_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'token': None,
|
||||||
|
'base_url': None,
|
||||||
|
'account_id': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
}
|
||||||
|
_weixin_login_sessions[session_id] = session
|
||||||
|
|
||||||
|
client = OpenClawWeixinClient(
|
||||||
|
base_url=DEFAULT_BASE_URL,
|
||||||
|
token='',
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_login():
|
||||||
|
try:
|
||||||
|
import qrcode as qr_lib
|
||||||
|
|
||||||
|
for _attempt in range(3):
|
||||||
|
qr_resp = await client.fetch_qrcode()
|
||||||
|
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
|
||||||
|
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'
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(e)
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_login())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait for QR code to be ready (max 10 seconds)
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_data_url']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if not session['qr_data_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_data_url': session['qr_data_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/weixin/login/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll WeChat login status."""
|
||||||
|
session = _weixin_login_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {'status': session['status']}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['token'] = session['token']
|
||||||
|
data['base_url'] = session['base_url']
|
||||||
|
data['account_id'] = session['account_id']
|
||||||
|
_weixin_login_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'error':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_weixin_login_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/weixin/login/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a WeChat login session."""
|
||||||
|
session = _weixin_login_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# DingTalk Device Flow QR Code Login
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_dingtalk_sessions: dict = {}
|
||||||
|
_DINGTALK_SESSION_TTL = 600 # 10 minutes (QR code validity window)
|
||||||
|
|
||||||
|
def _cleanup_expired_dingtalk_sessions():
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [
|
||||||
|
sid for sid, s in _dingtalk_sessions.items() if now - s.get('created_at', 0) > _DINGTALK_SESSION_TTL
|
||||||
|
]
|
||||||
|
for sid in expired:
|
||||||
|
session = _dingtalk_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/dingtalk/create-app', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start DingTalk one-click app creation via Device Flow. Returns session_id + QR code URL."""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
DINGTALK_BASE_URL = 'https://oapi.dingtalk.com'
|
||||||
|
|
||||||
|
_cleanup_expired_dingtalk_sessions()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'client_id': None,
|
||||||
|
'client_secret': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
'device_code': None,
|
||||||
|
'interval': 5,
|
||||||
|
}
|
||||||
|
_dingtalk_sessions[session_id] = session
|
||||||
|
|
||||||
|
async def run_device_flow():
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as http:
|
||||||
|
# Step 1: Init — get nonce
|
||||||
|
async with http.post(
|
||||||
|
f'{DINGTALK_BASE_URL}/app/registration/init',
|
||||||
|
json={'source': 'langbot'},
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
data = await resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Invalid response from DingTalk service'
|
||||||
|
return
|
||||||
|
if data.get('errcode', -1) != 0:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = data.get('errmsg', 'Failed to init')
|
||||||
|
return
|
||||||
|
nonce = data['nonce']
|
||||||
|
|
||||||
|
# Step 2: Begin — get device_code + QR URL
|
||||||
|
async with http.post(
|
||||||
|
f'{DINGTALK_BASE_URL}/app/registration/begin',
|
||||||
|
json={'nonce': nonce},
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
data = await resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Invalid response from DingTalk service'
|
||||||
|
return
|
||||||
|
if data.get('errcode', -1) != 0:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = data.get('errmsg', 'Failed to begin authorization')
|
||||||
|
return
|
||||||
|
|
||||||
|
device_code = data['device_code']
|
||||||
|
verification_uri_complete = data.get('verification_uri_complete', '')
|
||||||
|
expires_in = data.get('expires_in', 7200)
|
||||||
|
interval = data.get('interval', 5)
|
||||||
|
|
||||||
|
session['device_code'] = device_code
|
||||||
|
session['interval'] = interval
|
||||||
|
session['qr_url'] = verification_uri_complete
|
||||||
|
session['expire_at'] = time.time() + 600 # QR code valid for ~10 min
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
# Step 3: Poll for authorization result
|
||||||
|
deadline = time.time() + expires_in
|
||||||
|
while time.time() < deadline:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
async with http.post(
|
||||||
|
f'{DINGTALK_BASE_URL}/app/registration/poll',
|
||||||
|
json={'device_code': device_code},
|
||||||
|
) as poll_resp:
|
||||||
|
try:
|
||||||
|
poll_data = await poll_resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if poll_data.get('errcode', -1) != 0:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = poll_data.get('errmsg', 'Poll failed')
|
||||||
|
return
|
||||||
|
|
||||||
|
status = poll_data.get('status', '')
|
||||||
|
|
||||||
|
if status == 'SUCCESS':
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['client_id'] = poll_data.get('client_id', '')
|
||||||
|
session['client_secret'] = poll_data.get('client_secret', '')
|
||||||
|
return
|
||||||
|
elif status == 'FAIL':
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = poll_data.get('fail_reason', 'Authorization failed')
|
||||||
|
return
|
||||||
|
elif status == 'EXPIRED':
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
return
|
||||||
|
# status == 'WAITING': continue polling
|
||||||
|
|
||||||
|
# Timeout
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(e)
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_device_flow())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait for QR code to be ready (max 10 seconds)
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_url'] or session['error']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if session['error']:
|
||||||
|
task.cancel()
|
||||||
|
return self.http_status(502, -1, session['error'])
|
||||||
|
|
||||||
|
if not session['qr_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_url': session['qr_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/dingtalk/create-app/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll DingTalk Device Flow status."""
|
||||||
|
_cleanup_expired_dingtalk_sessions()
|
||||||
|
session = _dingtalk_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {'status': session['status']}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['client_id'] = session['client_id']
|
||||||
|
data['client_secret'] = session['client_secret']
|
||||||
|
_dingtalk_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'error':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_dingtalk_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/dingtalk/create-app/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a DingTalk Device Flow session."""
|
||||||
|
session = _dingtalk_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# WeComBot QR Code One-Click Create
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_wecombot_sessions: dict = {}
|
||||||
|
_WECOMBOT_SESSION_TTL = 300 # 5 minutes (WeCom QR validity window)
|
||||||
|
|
||||||
|
def _cleanup_expired_wecombot_sessions():
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [
|
||||||
|
sid for sid, s in _wecombot_sessions.items() if now - s.get('created_at', 0) > _WECOMBOT_SESSION_TTL
|
||||||
|
]
|
||||||
|
for sid in expired:
|
||||||
|
session = _wecombot_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/wecombot/create-bot', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start WeComBot one-click creation via QR code. Returns session_id + QR code URL."""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
WECOM_QC_GENERATE_URL = 'https://work.weixin.qq.com/ai/qc/generate'
|
||||||
|
WECOM_QC_QUERY_URL = 'https://work.weixin.qq.com/ai/qc/query_result'
|
||||||
|
|
||||||
|
_cleanup_expired_wecombot_sessions()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'botid': None,
|
||||||
|
'secret': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
'scode': None,
|
||||||
|
'task': None,
|
||||||
|
}
|
||||||
|
_wecombot_sessions[session_id] = session
|
||||||
|
|
||||||
|
async def run_qr_flow():
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as http:
|
||||||
|
# Step 1: Generate QR code
|
||||||
|
async with http.get(
|
||||||
|
f'{WECOM_QC_GENERATE_URL}?source=langbot&plat=0',
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
data = await resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Invalid response from WeCom service'
|
||||||
|
return
|
||||||
|
if not data.get('data', {}).get('scode') or not data.get('data', {}).get('auth_url'):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = data.get('errmsg', 'Failed to generate QR code')
|
||||||
|
return
|
||||||
|
|
||||||
|
scode = data['data']['scode']
|
||||||
|
auth_url = data['data']['auth_url']
|
||||||
|
|
||||||
|
session['scode'] = scode
|
||||||
|
session['qr_url'] = auth_url
|
||||||
|
session['expire_at'] = time.time() + _WECOMBOT_SESSION_TTL
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
# Step 2: Poll for scan result
|
||||||
|
deadline = time.time() + _WECOMBOT_SESSION_TTL
|
||||||
|
while time.time() < deadline:
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
async with http.get(
|
||||||
|
f'{WECOM_QC_QUERY_URL}?scode={scode}',
|
||||||
|
) as poll_resp:
|
||||||
|
try:
|
||||||
|
poll_data = await poll_resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = poll_data.get('data', {}).get('status', '')
|
||||||
|
if status == 'success':
|
||||||
|
bot_info = poll_data.get('data', {}).get('bot_info', {})
|
||||||
|
if bot_info.get('botid') and bot_info.get('secret'):
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['botid'] = bot_info['botid']
|
||||||
|
session['secret'] = bot_info['secret']
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Scan succeeded but bot info is incomplete'
|
||||||
|
return
|
||||||
|
|
||||||
|
# Timeout
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(e)
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_qr_flow())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait for QR code to be ready (max 10 seconds)
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_url'] or session['error']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if session['error']:
|
||||||
|
task.cancel()
|
||||||
|
return self.http_status(502, -1, session['error'])
|
||||||
|
|
||||||
|
if not session['qr_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_url': session['qr_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/wecombot/create-bot/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll WeComBot creation status."""
|
||||||
|
_cleanup_expired_wecombot_sessions()
|
||||||
|
session = _wecombot_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {'status': session['status']}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['botid'] = session['botid']
|
||||||
|
data['secret'] = session['secret']
|
||||||
|
_wecombot_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'error':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_wecombot_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/wecombot/create-bot/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a WeComBot creation session."""
|
||||||
|
session = _wecombot_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ def _normalize_plugin_asset_path(filepath: str) -> str | None:
|
|||||||
return f'assets/{normalized}'
|
return f'assets/{normalized}'
|
||||||
|
|
||||||
|
|
||||||
|
def _get_request_origin() -> str:
|
||||||
|
"""Return the public request origin, respecting reverse-proxy headers."""
|
||||||
|
forwarded_proto = quart.request.headers.get('X-Forwarded-Proto', '').split(',')[0].strip()
|
||||||
|
forwarded_host = quart.request.headers.get('X-Forwarded-Host', '').split(',')[0].strip()
|
||||||
|
|
||||||
|
scheme = forwarded_proto or quart.request.scheme
|
||||||
|
host = forwarded_host or quart.request.host
|
||||||
|
return f'{scheme}://{host}'
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('plugins', '/api/v1/plugins')
|
@group.group_class('plugins', '/api/v1/plugins')
|
||||||
class PluginsRouterGroup(group.RouterGroup):
|
class PluginsRouterGroup(group.RouterGroup):
|
||||||
async def _check_extensions_limit(self) -> str | None:
|
async def _check_extensions_limit(self) -> str | None:
|
||||||
@@ -189,7 +199,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
# CSP for HTML pages served to sandboxed iframes (opaque origin).
|
# CSP for HTML pages served to sandboxed iframes (opaque origin).
|
||||||
# 'self' doesn't work in sandboxed iframes — use actual server origin.
|
# 'self' doesn't work in sandboxed iframes — use actual server origin.
|
||||||
if mime_type and mime_type.startswith('text/html'):
|
if mime_type and mime_type.startswith('text/html'):
|
||||||
origin = f'{quart.request.scheme}://{quart.request.host}'
|
origin = _get_request_origin()
|
||||||
resp.headers['Content-Security-Policy'] = (
|
resp.headers['Content-Security-Policy'] = (
|
||||||
f'default-src {origin}; '
|
f'default-src {origin}; '
|
||||||
f"script-src {origin} 'unsafe-inline'; "
|
f"script-src {origin} 'unsafe-inline'; "
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
return self.fail(3, str(e))
|
return self.fail(3, str(e))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
self.ap.logger.warning(f'Space OAuth callback failed: {e}')
|
||||||
return self.fail(1, str(e))
|
return self.fail(1, str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|||||||
@@ -99,11 +99,11 @@ class BotService:
|
|||||||
# TODO: 检查配置信息格式
|
# TODO: 检查配置信息格式
|
||||||
bot_data['uuid'] = str(uuid.uuid4())
|
bot_data['uuid'] = str(uuid.uuid4())
|
||||||
|
|
||||||
# checkout the default pipeline
|
# bind the most recently updated pipeline if any exist
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline)
|
||||||
persistence_pipeline.LegacyPipeline.is_default == True
|
.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())
|
||||||
)
|
.limit(1)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
|
|||||||
@@ -31,15 +31,126 @@ class KnowledgeService:
|
|||||||
if not knowledge_engine_plugin_id:
|
if not knowledge_engine_plugin_id:
|
||||||
raise ValueError('knowledge_engine_plugin_id is required')
|
raise ValueError('knowledge_engine_plugin_id is required')
|
||||||
|
|
||||||
|
creation_settings = kb_data.get('creation_settings', {})
|
||||||
|
retrieval_settings = kb_data.get('retrieval_settings', {})
|
||||||
|
|
||||||
|
# Validate required fields based on plugin's creation_schema and retrieval_schema
|
||||||
|
await self._validate_schema_required_fields(
|
||||||
|
knowledge_engine_plugin_id,
|
||||||
|
creation_settings,
|
||||||
|
retrieval_settings,
|
||||||
|
)
|
||||||
|
|
||||||
kb = await self.ap.rag_mgr.create_knowledge_base(
|
kb = await self.ap.rag_mgr.create_knowledge_base(
|
||||||
name=kb_data.get('name', 'Untitled'),
|
name=kb_data.get('name', 'Untitled'),
|
||||||
knowledge_engine_plugin_id=knowledge_engine_plugin_id,
|
knowledge_engine_plugin_id=knowledge_engine_plugin_id,
|
||||||
creation_settings=kb_data.get('creation_settings', {}),
|
creation_settings=creation_settings,
|
||||||
retrieval_settings=kb_data.get('retrieval_settings', {}),
|
retrieval_settings=retrieval_settings,
|
||||||
description=kb_data.get('description', ''),
|
description=kb_data.get('description', ''),
|
||||||
)
|
)
|
||||||
return kb.uuid
|
return kb.uuid
|
||||||
|
|
||||||
|
async def _validate_schema_required_fields(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
creation_settings: dict,
|
||||||
|
retrieval_settings: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Validate required fields based on plugin's creation_schema and retrieval_schema.
|
||||||
|
|
||||||
|
This is a business-agnostic validation that checks all fields marked as
|
||||||
|
required in the plugin's schema, regardless of field type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: Knowledge Engine plugin ID.
|
||||||
|
creation_settings: User-provided creation settings.
|
||||||
|
retrieval_settings: User-provided retrieval settings.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any required field is missing or empty.
|
||||||
|
"""
|
||||||
|
# Validate creation_schema
|
||||||
|
try:
|
||||||
|
creation_schema = await self.ap.plugin_connector.get_rag_creation_schema(plugin_id)
|
||||||
|
self._check_required_fields(creation_schema, creation_settings, 'creation_settings')
|
||||||
|
except ValueError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to get creation_schema for validation: {e}')
|
||||||
|
|
||||||
|
# Validate retrieval_schema
|
||||||
|
try:
|
||||||
|
retrieval_schema = await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id)
|
||||||
|
self._check_required_fields(retrieval_schema, retrieval_settings, 'retrieval_settings')
|
||||||
|
except ValueError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to get retrieval_schema for validation: {e}')
|
||||||
|
|
||||||
|
def _check_required_fields(
|
||||||
|
self,
|
||||||
|
schema: dict | list,
|
||||||
|
settings: dict,
|
||||||
|
context: str,
|
||||||
|
) -> None:
|
||||||
|
"""Check required fields in schema against provided settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema: Plugin-defined schema (can be list or dict with 'schema' key).
|
||||||
|
settings: User-provided settings values.
|
||||||
|
context: Context name for error messages (e.g., 'creation_settings').
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If a required field is missing or empty.
|
||||||
|
"""
|
||||||
|
if not schema:
|
||||||
|
return
|
||||||
|
|
||||||
|
# schema can be a list directly, or a dict with 'schema' key
|
||||||
|
items = schema if isinstance(schema, list) else schema.get('schema', [])
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
field_name = item.get('name')
|
||||||
|
if not field_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_required = item.get('required', False)
|
||||||
|
if not is_required:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check show_if condition - if field is conditionally shown, only validate when condition is met
|
||||||
|
show_if = item.get('show_if')
|
||||||
|
if show_if:
|
||||||
|
depend_field = show_if.get('field')
|
||||||
|
operator = show_if.get('operator')
|
||||||
|
expected_value = show_if.get('value')
|
||||||
|
|
||||||
|
if depend_field and operator:
|
||||||
|
depend_value = settings.get(depend_field)
|
||||||
|
# If show_if condition is not met, skip validation for this field
|
||||||
|
if operator == 'eq' and depend_value != expected_value:
|
||||||
|
continue
|
||||||
|
if operator == 'neq' and depend_value == expected_value:
|
||||||
|
continue
|
||||||
|
if operator == 'in' and isinstance(expected_value, list) and depend_value not in expected_value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = settings.get(field_name)
|
||||||
|
|
||||||
|
# Validate required field has a non-empty value
|
||||||
|
if value is None or (isinstance(value, str) and value.strip() == ''):
|
||||||
|
# Get field label for friendly error message
|
||||||
|
label = item.get('label', {})
|
||||||
|
field_label = (
|
||||||
|
label.get('en_US', field_name)
|
||||||
|
or label.get('zh_Hans', field_name)
|
||||||
|
or label.get('zh_Hant', field_name)
|
||||||
|
or field_name
|
||||||
|
)
|
||||||
|
raise ValueError(f'{field_label} is required ({context}.{field_name})')
|
||||||
|
|
||||||
async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
|
async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
|
||||||
"""更新知识库"""
|
"""更新知识库"""
|
||||||
# Filter to only mutable fields
|
# Filter to only mutable fields
|
||||||
|
|||||||
@@ -17,6 +17,24 @@ class ModelProviderService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
|
||||||
|
if api_keys is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
raw_keys = [api_keys] if isinstance(api_keys, str) else list(api_keys)
|
||||||
|
normalized_keys = []
|
||||||
|
seen_keys = set()
|
||||||
|
|
||||||
|
for raw_key in raw_keys:
|
||||||
|
normalized_key = raw_key.strip() if isinstance(raw_key, str) else ''
|
||||||
|
if not normalized_key or normalized_key in seen_keys:
|
||||||
|
continue
|
||||||
|
normalized_keys.append(normalized_key)
|
||||||
|
seen_keys.add(normalized_key)
|
||||||
|
|
||||||
|
return normalized_keys
|
||||||
|
|
||||||
async def get_providers(self) -> list[dict]:
|
async def get_providers(self) -> list[dict]:
|
||||||
"""Get all providers"""
|
"""Get all providers"""
|
||||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
|
||||||
@@ -59,6 +77,7 @@ class ModelProviderService:
|
|||||||
async def create_provider(self, provider_data: dict) -> str:
|
async def create_provider(self, provider_data: dict) -> str:
|
||||||
"""Create a new provider"""
|
"""Create a new provider"""
|
||||||
provider_data['uuid'] = str(uuid.uuid4())
|
provider_data['uuid'] = str(uuid.uuid4())
|
||||||
|
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
||||||
)
|
)
|
||||||
@@ -72,6 +91,8 @@ class ModelProviderService:
|
|||||||
"""Update an existing provider"""
|
"""Update an existing provider"""
|
||||||
if 'uuid' in provider_data:
|
if 'uuid' in provider_data:
|
||||||
del provider_data['uuid']
|
del provider_data['uuid']
|
||||||
|
if 'api_keys' in provider_data:
|
||||||
|
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_model.ModelProvider)
|
sqlalchemy.update(persistence_model.ModelProvider)
|
||||||
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
||||||
@@ -141,6 +162,8 @@ class ModelProviderService:
|
|||||||
|
|
||||||
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
|
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
|
||||||
"""Find existing provider or create new one"""
|
"""Find existing provider or create new one"""
|
||||||
|
api_keys = self._normalize_api_keys(api_keys)
|
||||||
|
|
||||||
# Try to find existing provider with same config
|
# Try to find existing provider with same config
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||||
@@ -168,7 +191,7 @@ class ModelProviderService:
|
|||||||
'name': provider_name,
|
'name': provider_name,
|
||||||
'requester': requester,
|
'requester': requester,
|
||||||
'base_url': base_url,
|
'base_url': base_url,
|
||||||
'api_keys': api_keys or [],
|
'api_keys': api_keys,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -177,7 +200,7 @@ class ModelProviderService:
|
|||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_model.ModelProvider)
|
sqlalchemy.update(persistence_model.ModelProvider)
|
||||||
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
|
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
|
||||||
.values(api_keys=[api_key])
|
.values(api_keys=self._normalize_api_keys(api_key))
|
||||||
)
|
)
|
||||||
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')
|
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/dingtalk
|
en: https://link.langbot.app/en/platforms/dingtalk
|
||||||
ja: https://link.langbot.app/ja/platforms/dingtalk
|
ja: https://link.langbot.app/ja/platforms/dingtalk
|
||||||
config:
|
config:
|
||||||
|
- name: one-click-create
|
||||||
|
label:
|
||||||
|
en_US: One-Click Create App
|
||||||
|
zh_Hans: 一键创建应用
|
||||||
|
zh_Hant: 一鍵建立應用
|
||||||
|
description:
|
||||||
|
en_US: "Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be obtained automatically, you need to copy it from the DingTalk Developer Backend manually."
|
||||||
|
zh_Hans: "使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。"
|
||||||
|
zh_Hant: "使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。"
|
||||||
|
type: qr-code-login
|
||||||
|
login_platform: dingtalk
|
||||||
|
required: false
|
||||||
- name: client_id
|
- name: client_id
|
||||||
label:
|
label:
|
||||||
en_US: Client ID
|
en_US: Client ID
|
||||||
@@ -40,6 +52,10 @@ spec:
|
|||||||
en_US: Robot Code
|
en_US: Robot Code
|
||||||
zh_Hans: 机器人代码
|
zh_Hans: 机器人代码
|
||||||
zh_Hant: 機器人代碼
|
zh_Hant: 機器人代碼
|
||||||
|
description:
|
||||||
|
en_US: "Required for image recognition, file upload and other features. Get it from DingTalk Developer Backend > Robot Configuration."
|
||||||
|
zh_Hans: "识图、上传文件等功能必填。请前往钉钉开发者后台 > 机器人配置中获取。"
|
||||||
|
zh_Hant: "識圖、上傳檔案等功能必填。請前往釘釘開發者後台 > 機器人設定中取得。"
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -1025,7 +1025,90 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
return api_client
|
return api_client
|
||||||
|
|
||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
pass
|
text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client)
|
||||||
|
|
||||||
|
# Map standard target_type to Feishu receive_id_type
|
||||||
|
if target_type == 'person':
|
||||||
|
receive_id_type = 'open_id'
|
||||||
|
elif target_type == 'group':
|
||||||
|
receive_id_type = 'chat_id'
|
||||||
|
else:
|
||||||
|
receive_id_type = target_type
|
||||||
|
|
||||||
|
# Send text message if there are text elements
|
||||||
|
if text_elements:
|
||||||
|
needs_post = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph)
|
||||||
|
|
||||||
|
if needs_post:
|
||||||
|
msg_type = 'post'
|
||||||
|
final_content = json.dumps(
|
||||||
|
{
|
||||||
|
'zh_Hans': {
|
||||||
|
'title': '',
|
||||||
|
'content': text_elements,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg_type = 'text'
|
||||||
|
parts = []
|
||||||
|
for paragraph in text_elements:
|
||||||
|
para_text = ''.join(ele.get('text', '') for ele in paragraph)
|
||||||
|
if para_text:
|
||||||
|
parts.append(para_text)
|
||||||
|
final_content = json.dumps({'text': '\n\n'.join(parts)})
|
||||||
|
|
||||||
|
request: CreateMessageRequest = (
|
||||||
|
CreateMessageRequest.builder()
|
||||||
|
.receive_id_type(receive_id_type)
|
||||||
|
.request_body(
|
||||||
|
CreateMessageRequestBody.builder()
|
||||||
|
.receive_id(target_id)
|
||||||
|
.content(final_content)
|
||||||
|
.msg_type(msg_type)
|
||||||
|
.uuid(str(uuid.uuid4()))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
app_access_token = self.get_app_access_token()
|
||||||
|
req_opt: RequestOption = (
|
||||||
|
RequestOption.builder().app_ticket(self.app_ticket).app_access_token(app_access_token).build()
|
||||||
|
)
|
||||||
|
response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)
|
||||||
|
|
||||||
|
if not response.success():
|
||||||
|
raise Exception(
|
||||||
|
f'client.im.v1.message.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send media messages separately (image, audio, file, etc.)
|
||||||
|
for media in media_items:
|
||||||
|
request: CreateMessageRequest = (
|
||||||
|
CreateMessageRequest.builder()
|
||||||
|
.receive_id_type(receive_id_type)
|
||||||
|
.request_body(
|
||||||
|
CreateMessageRequestBody.builder()
|
||||||
|
.receive_id(target_id)
|
||||||
|
.content(json.dumps(media['content']))
|
||||||
|
.msg_type(media['msg_type'])
|
||||||
|
.uuid(str(uuid.uuid4()))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
app_access_token = self.get_app_access_token()
|
||||||
|
req_opt: RequestOption = (
|
||||||
|
RequestOption.builder().app_ticket(self.app_ticket).app_access_token(app_access_token).build()
|
||||||
|
)
|
||||||
|
response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)
|
||||||
|
|
||||||
|
if not response.success():
|
||||||
|
raise Exception(
|
||||||
|
f'client.im.v1.message.create ({media["msg_type"]}) failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||||
|
)
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
is_stream = False
|
is_stream = False
|
||||||
|
|||||||
@@ -23,6 +23,20 @@ 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: one-click-create
|
||||||
|
label:
|
||||||
|
en_US: One-Click Create App
|
||||||
|
zh_Hans: 一键创建应用
|
||||||
|
zh_Hant: 一鍵建立應用
|
||||||
|
ja_JP: ワンクリックでアプリ作成
|
||||||
|
description:
|
||||||
|
en_US: Scan QR code to automatically create a Feishu app and fill in credentials
|
||||||
|
zh_Hans: 扫码自动创建飞书应用并填写凭据
|
||||||
|
zh_Hant: 掃碼自動建立飛書應用並填寫憑證
|
||||||
|
ja_JP: QRコードをスキャンしてFeishuアプリを自動作成し、認証情報を入力
|
||||||
|
type: qr-code-login
|
||||||
|
login_platform: feishu
|
||||||
|
required: false
|
||||||
- name: app_id
|
- name: app_id
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://ilinkai.weixin.qq.com"
|
default: "https://ilinkai.weixin.qq.com"
|
||||||
|
- name: qr-login
|
||||||
|
label:
|
||||||
|
en_US: Scan QR Login
|
||||||
|
zh_Hans: 扫码登录
|
||||||
|
zh_Hant: 掃碼登入
|
||||||
|
ja_JP: QRコードでログイン
|
||||||
|
description:
|
||||||
|
en_US: Scan QR code with WeChat to authorize and automatically fill in the token
|
||||||
|
zh_Hans: 使用微信扫码授权,自动填写令牌
|
||||||
|
zh_Hant: 使用微信掃碼授權,自動填寫令牌
|
||||||
|
ja_JP: WeChatでQRコードをスキャンし、トークンを自動入力
|
||||||
|
type: qr-code-login
|
||||||
|
login_platform: weixin
|
||||||
|
required: false
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
|
|||||||
@@ -27,10 +27,7 @@ class WebPageBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
listeners: dict = pydantic.Field(default_factory=dict, exclude=True)
|
listeners: dict = pydantic.Field(default_factory=dict, exclude=True)
|
||||||
_ws_adapter: typing.Any = None
|
_ws_adapter: typing.Any = None
|
||||||
|
|
||||||
class Config:
|
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
|
||||||
arbitrary_types_allowed = True
|
|
||||||
# Allow private attributes
|
|
||||||
underscore_attrs_are_private = True
|
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||||
super().__init__(config=config, logger=logger, **kwargs)
|
super().__init__(config=config, logger=logger, **kwargs)
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/wecombot
|
en: https://link.langbot.app/en/platforms/wecombot
|
||||||
ja: https://link.langbot.app/ja/platforms/wecombot
|
ja: https://link.langbot.app/ja/platforms/wecombot
|
||||||
config:
|
config:
|
||||||
|
- name: one-click-create
|
||||||
|
label:
|
||||||
|
en_US: One-Click Create Bot
|
||||||
|
zh_Hans: 一键创建机器人
|
||||||
|
zh_Hant: 一鍵建立機器人
|
||||||
|
description:
|
||||||
|
en_US: "Scan QR code with WeCom to automatically create a bot and fill in BotId and Secret. Note: Robot Name needs to be filled in manually."
|
||||||
|
zh_Hans: "使用企业微信扫码自动创建机器人并填写 BotId 和 Secret。注意:机器人名称需手动填写。"
|
||||||
|
zh_Hant: "使用企業微信掃碼自動建立機器人並填寫 BotId 和 Secret。注意:機器人名稱需手動填寫。"
|
||||||
|
type: qr-code-login
|
||||||
|
login_platform: wecombot
|
||||||
|
required: false
|
||||||
- name: BotId
|
- name: BotId
|
||||||
label:
|
label:
|
||||||
en_US: BotId
|
en_US: BotId
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import httpx
|
import httpx
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
import yaml
|
||||||
from async_lru import alru_cache
|
from async_lru import alru_cache
|
||||||
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
|
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
|
||||||
|
|
||||||
@@ -195,40 +196,110 @@ class PluginRuntimeConnector:
|
|||||||
|
|
||||||
return await self.handler.ping()
|
return await self.handler.ping()
|
||||||
|
|
||||||
def _extract_deps_metadata(
|
def _inspect_plugin_package(
|
||||||
self,
|
self,
|
||||||
file_bytes: bytes,
|
file_bytes: bytes,
|
||||||
task_context: taskmgr.TaskContext | None,
|
task_context: taskmgr.TaskContext | None,
|
||||||
):
|
) -> tuple[str | None, str | None]:
|
||||||
"""Extract dependency count from requirements.txt inside plugin zip."""
|
"""Extract plugin identity and dependency metadata from a plugin package."""
|
||||||
if task_context is None:
|
plugin_author = None
|
||||||
return
|
plugin_name = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
||||||
for name in zf.namelist():
|
try:
|
||||||
if name.endswith('requirements.txt'):
|
manifest = yaml.safe_load(zf.read('manifest.yaml').decode('utf-8', errors='ignore')) or {}
|
||||||
content = zf.read(name).decode('utf-8', errors='ignore')
|
metadata = manifest.get('metadata', {})
|
||||||
deps = [
|
plugin_author = metadata.get('author')
|
||||||
line.strip()
|
plugin_name = metadata.get('name')
|
||||||
for line in content.splitlines()
|
except Exception:
|
||||||
if line.strip() and not line.strip().startswith('#')
|
pass
|
||||||
]
|
|
||||||
task_context.metadata['deps_total'] = len(deps)
|
if task_context is not None:
|
||||||
task_context.metadata['deps_list'] = deps
|
for name in zf.namelist():
|
||||||
break
|
if name.endswith('requirements.txt'):
|
||||||
|
content = zf.read(name).decode('utf-8', errors='ignore')
|
||||||
|
deps = [
|
||||||
|
line.strip()
|
||||||
|
for line in content.splitlines()
|
||||||
|
if line.strip() and not line.strip().startswith('#')
|
||||||
|
]
|
||||||
|
task_context.metadata['deps_total'] = len(deps)
|
||||||
|
task_context.metadata['deps_list'] = deps
|
||||||
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
return plugin_author, plugin_name
|
||||||
|
|
||||||
|
def _build_plugin_startup_failure_message(
|
||||||
|
self,
|
||||||
|
plugin_author: str,
|
||||||
|
plugin_name: str,
|
||||||
|
task_context: taskmgr.TaskContext | None,
|
||||||
|
) -> str:
|
||||||
|
dep_hint = ''
|
||||||
|
if task_context is not None:
|
||||||
|
current_dep = task_context.metadata.get('current_dep')
|
||||||
|
if current_dep:
|
||||||
|
dep_hint = f' Last dependency: {current_dep}.'
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'Plugin {plugin_author}/{plugin_name} failed to start after installation. '
|
||||||
|
f'Dependency installation or plugin initialization may have failed.{dep_hint} '
|
||||||
|
f'Please check the plugin requirements and runtime logs.'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _wait_for_installed_plugin_ready(
|
||||||
|
self,
|
||||||
|
plugin_author: str | None,
|
||||||
|
plugin_name: str | None,
|
||||||
|
task_context: taskmgr.TaskContext | None,
|
||||||
|
timeout: float = 30,
|
||||||
|
):
|
||||||
|
"""Wait until the installed plugin is registered by the runtime.
|
||||||
|
|
||||||
|
The plugin runtime launches plugins asynchronously. If dependency installation
|
||||||
|
fails, the plugin process exits before registration; without this check the
|
||||||
|
install task can incorrectly finish successfully.
|
||||||
|
"""
|
||||||
|
if not plugin_author or not plugin_name:
|
||||||
|
return
|
||||||
|
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
last_error: Exception | None = None
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
plugin = await self.get_plugin_info(plugin_author, plugin_name)
|
||||||
|
if plugin is not None:
|
||||||
|
status = plugin.get('status')
|
||||||
|
if status == 'initialized':
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
message = self._build_plugin_startup_failure_message(plugin_author, plugin_name, task_context)
|
||||||
|
if last_error is not None:
|
||||||
|
message = f'{message} Last runtime error: {last_error}'
|
||||||
|
raise RuntimeError(message)
|
||||||
|
|
||||||
async def install_plugin(
|
async def install_plugin(
|
||||||
self,
|
self,
|
||||||
install_source: PluginInstallSource,
|
install_source: PluginInstallSource,
|
||||||
install_info: dict[str, Any],
|
install_info: dict[str, Any],
|
||||||
task_context: taskmgr.TaskContext | None = None,
|
task_context: taskmgr.TaskContext | None = None,
|
||||||
):
|
):
|
||||||
|
plugin_author = install_info.get('plugin_author')
|
||||||
|
plugin_name = install_info.get('plugin_name')
|
||||||
|
|
||||||
if install_source == PluginInstallSource.LOCAL:
|
if install_source == PluginInstallSource.LOCAL:
|
||||||
# transfer file before install
|
# transfer file before install
|
||||||
file_bytes = install_info['plugin_file']
|
file_bytes = install_info['plugin_file']
|
||||||
self._extract_deps_metadata(file_bytes, task_context)
|
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
|
||||||
|
if task_context is not None and plugin_author and plugin_name:
|
||||||
|
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||||
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
|
||||||
del install_info['plugin_file']
|
del install_info['plugin_file']
|
||||||
@@ -265,7 +336,9 @@ class PluginRuntimeConnector:
|
|||||||
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
|
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
|
||||||
|
|
||||||
file_bytes = b''.join(chunks)
|
file_bytes = b''.join(chunks)
|
||||||
self._extract_deps_metadata(file_bytes, task_context)
|
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
|
||||||
|
if task_context is not None and plugin_author and plugin_name:
|
||||||
|
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||||
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')
|
||||||
@@ -289,6 +362,8 @@ class PluginRuntimeConnector:
|
|||||||
if metadata is not None and task_context is not None:
|
if metadata is not None and task_context is not None:
|
||||||
task_context.metadata.update(metadata)
|
task_context.metadata.update(metadata)
|
||||||
|
|
||||||
|
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
|
||||||
|
|
||||||
async def upgrade_plugin(
|
async def upgrade_plugin(
|
||||||
self,
|
self,
|
||||||
plugin_author: str,
|
plugin_author: str,
|
||||||
@@ -558,11 +633,12 @@ class PluginRuntimeConnector:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If plugin_id is not in the expected 'author/name' format.
|
ValueError: If plugin_id is not in the expected 'author/name' format.
|
||||||
"""
|
"""
|
||||||
if '/' not in plugin_id:
|
segments = plugin_id.split('/')
|
||||||
|
if len(segments) != 2 or not all(segments):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Invalid plugin_id format: '{plugin_id}'. Expected 'author/name' format (e.g. 'langbot/rag-engine')."
|
f"Invalid plugin_id format: '{plugin_id}'. Expected 'author/name' format (e.g. 'langbot/rag-engine')."
|
||||||
)
|
)
|
||||||
return plugin_id.split('/', 1)
|
return segments[0], segments[1]
|
||||||
|
|
||||||
async def call_rag_ingest(self, plugin_id: str, context_data: dict[str, Any]) -> dict[str, Any]:
|
async def call_rag_ingest(self, plugin_id: str, context_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Call plugin to ingest document.
|
"""Call plugin to ingest document.
|
||||||
|
|||||||
@@ -340,6 +340,7 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
|
|||||||
"""Provider API请求器"""
|
"""Provider API请求器"""
|
||||||
|
|
||||||
name: str = None
|
name: str = None
|
||||||
|
init_api_key: str = 'langbot-init-placeholder'
|
||||||
|
|
||||||
ap: app.Application
|
ap: app.Application
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
|
|||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
self.client = openai.AsyncClient(
|
self.client = openai.AsyncClient(
|
||||||
api_key='',
|
api_key=self.init_api_key,
|
||||||
base_url=self.requester_cfg['base_url'].replace(' ', ''),
|
base_url=self.requester_cfg['base_url'].replace(' ', ''),
|
||||||
timeout=self.requester_cfg['timeout'],
|
timeout=self.requester_cfg['timeout'],
|
||||||
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),
|
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class ModelScopeChatCompletions(requester.ProviderAPIRequester):
|
|||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
self.client = openai.AsyncClient(
|
self.client = openai.AsyncClient(
|
||||||
api_key='',
|
api_key=self.init_api_key,
|
||||||
base_url=self.requester_cfg['base_url'],
|
base_url=self.requester_cfg['base_url'],
|
||||||
timeout=self.requester_cfg['timeout'],
|
timeout=self.requester_cfg['timeout'],
|
||||||
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),
|
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ class TokenManager:
|
|||||||
|
|
||||||
def __init__(self, name: str, tokens: list[str]):
|
def __init__(self, name: str, tokens: list[str]):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.tokens = tokens
|
self.tokens = []
|
||||||
|
seen_tokens = set()
|
||||||
|
for token in tokens:
|
||||||
|
normalized_token = token.strip() if isinstance(token, str) else ''
|
||||||
|
if not normalized_token or normalized_token in seen_tokens:
|
||||||
|
continue
|
||||||
|
self.tokens.append(normalized_token)
|
||||||
|
seen_tokens.add(normalized_token)
|
||||||
self.using_token_index = 0
|
self.using_token_index = 0
|
||||||
|
|
||||||
def get_token(self) -> str:
|
def get_token(self) -> str:
|
||||||
|
|||||||
25
tests/unit_tests/plugin/test_plugin_id_parsing.py
Normal file
25
tests/unit_tests/plugin/test_plugin_id_parsing.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Test plugin ID parsing validation."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_plugin_id_accepts_author_name():
|
||||||
|
assert PluginRuntimeConnector._parse_plugin_id('langbot/rag-engine') == ('langbot', 'rag-engine')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'plugin_id',
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
'author',
|
||||||
|
'author/',
|
||||||
|
'/name',
|
||||||
|
'author/name/extra',
|
||||||
|
'/',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_plugin_id_rejects_malformed_ids(plugin_id):
|
||||||
|
with pytest.raises(ValueError, match='Expected'):
|
||||||
|
PluginRuntimeConnector._parse_plugin_id(plugin_id)
|
||||||
@@ -11,10 +11,14 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
|
|
||||||
from langbot.pkg.api.http.service.model import _runtime_model_data
|
from langbot.pkg.api.http.service.model import _runtime_model_data
|
||||||
|
from langbot.pkg.api.http.service.provider import ModelProviderService
|
||||||
from langbot.pkg.entity.persistence import model as persistence_model
|
from langbot.pkg.entity.persistence import model as persistence_model
|
||||||
from langbot.pkg.pipeline.preproc.preproc import PreProcessor
|
from langbot.pkg.pipeline.preproc.preproc import PreProcessor
|
||||||
from langbot.pkg.provider.modelmgr import requester
|
from langbot.pkg.provider.modelmgr import requester
|
||||||
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
|
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
|
||||||
|
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||||
|
from langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl import ModelScopeChatCompletions
|
||||||
|
from langbot.pkg.provider.modelmgr.token import TokenManager
|
||||||
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
|
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
|
||||||
|
|
||||||
|
|
||||||
@@ -58,6 +62,93 @@ def test_runtime_rerank_model_data_preserves_uuid_after_update_payload_uuid_remo
|
|||||||
assert runtime_entity.name == 'rerank-model'
|
assert runtime_entity.name == 'rerank-model'
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_space_provider_api_keys_filters_blank_values():
|
||||||
|
assert ModelProviderService._normalize_api_keys('space-key') == ['space-key']
|
||||||
|
assert ModelProviderService._normalize_api_keys(' trimmed-key ') == ['trimmed-key']
|
||||||
|
assert ModelProviderService._normalize_api_keys('') == []
|
||||||
|
assert ModelProviderService._normalize_api_keys(' ') == []
|
||||||
|
assert ModelProviderService._normalize_api_keys(None) == []
|
||||||
|
assert ModelProviderService._normalize_api_keys([' first-key ', '', 'first-key', 'second-key']) == [
|
||||||
|
'first-key',
|
||||||
|
'second-key',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_manager_filters_blank_and_duplicate_tokens():
|
||||||
|
token_mgr = TokenManager('provider-uuid', [' first-key ', '', 'first-key', 'second-key', ' '])
|
||||||
|
|
||||||
|
assert token_mgr.tokens == ['first-key', 'second-key']
|
||||||
|
assert token_mgr.get_token() == 'first-key'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_requester_initialize_uses_placeholder_api_key(monkeypatch):
|
||||||
|
captured_kwargs = {}
|
||||||
|
|
||||||
|
def fake_client(**kwargs):
|
||||||
|
captured_kwargs.update(kwargs)
|
||||||
|
return SimpleNamespace(**kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.chatcmpl.openai.AsyncClient', fake_client)
|
||||||
|
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.chatcmpl.httpx.AsyncClient', fake_client)
|
||||||
|
|
||||||
|
requester_inst = OpenAIChatCompletions(ap=SimpleNamespace(), config={})
|
||||||
|
await requester_inst.initialize()
|
||||||
|
|
||||||
|
assert captured_kwargs['api_key'] == OpenAIChatCompletions.init_api_key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modelscope_requester_initialize_uses_placeholder_api_key(monkeypatch):
|
||||||
|
captured_kwargs = {}
|
||||||
|
|
||||||
|
def fake_client(**kwargs):
|
||||||
|
captured_kwargs.update(kwargs)
|
||||||
|
return SimpleNamespace(**kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl.openai.AsyncClient', fake_client)
|
||||||
|
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl.httpx.AsyncClient', fake_client)
|
||||||
|
|
||||||
|
requester_inst = ModelScopeChatCompletions(ap=SimpleNamespace(), config={})
|
||||||
|
await requester_inst.initialize()
|
||||||
|
|
||||||
|
assert captured_kwargs['api_key'] == ModelScopeChatCompletions.init_api_key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_embedding_call_overrides_placeholder_api_key():
|
||||||
|
captured_request = {}
|
||||||
|
|
||||||
|
async def fake_create(**kwargs):
|
||||||
|
captured_request['api_key'] = fake_client.api_key
|
||||||
|
captured_request['kwargs'] = kwargs
|
||||||
|
return SimpleNamespace(
|
||||||
|
data=[SimpleNamespace(embedding=[0.1, 0.2])],
|
||||||
|
usage=SimpleNamespace(prompt_tokens=3, total_tokens=3),
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_client = SimpleNamespace(
|
||||||
|
api_key=OpenAIChatCompletions.init_api_key,
|
||||||
|
embeddings=SimpleNamespace(create=fake_create),
|
||||||
|
)
|
||||||
|
|
||||||
|
requester_inst = OpenAIChatCompletions(ap=SimpleNamespace(), config={})
|
||||||
|
requester_inst.client = fake_client
|
||||||
|
|
||||||
|
embeddings, usage_info = await requester_inst.invoke_embedding(
|
||||||
|
model=requester.RuntimeEmbeddingModel(
|
||||||
|
model_entity=SimpleNamespace(name='text-embedding-3-small', extra_args={}),
|
||||||
|
provider=SimpleNamespace(token_mgr=TokenManager('provider-uuid', [' runtime-key ', '', 'runtime-key'])),
|
||||||
|
),
|
||||||
|
input_text=['hello'],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured_request['api_key'] == 'runtime-key'
|
||||||
|
assert captured_request['kwargs']['model'] == 'text-embedding-3-small'
|
||||||
|
assert embeddings == [[0.1, 0.2]]
|
||||||
|
assert usage_info == {'prompt_tokens': 3, 'total_tokens': 3}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline():
|
async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline():
|
||||||
from langbot.pkg.api.http.service.model import LLMModelsService
|
from langbot.pkg.api.http.service.model import LLMModelsService
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useCallback, Suspense } from 'react';
|
import { useEffect, useState, useCallback, Suspense, useRef } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -20,10 +20,39 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||||
|
|
||||||
|
type SpaceOAuthLoginResult = {
|
||||||
|
token: string;
|
||||||
|
user: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingSpaceOAuthLogins = new Map<
|
||||||
|
string,
|
||||||
|
Promise<SpaceOAuthLoginResult>
|
||||||
|
>();
|
||||||
|
|
||||||
|
function getOrCreateSpaceOAuthLoginPromise(
|
||||||
|
authCode: string,
|
||||||
|
): Promise<SpaceOAuthLoginResult> {
|
||||||
|
const pendingRequest = pendingSpaceOAuthLogins.get(authCode);
|
||||||
|
if (pendingRequest) {
|
||||||
|
return pendingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestPromise = httpClient
|
||||||
|
.exchangeSpaceOAuthCode(authCode)
|
||||||
|
.finally(() => {
|
||||||
|
pendingSpaceOAuthLogins.delete(authCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingSpaceOAuthLogins.set(authCode, requestPromise);
|
||||||
|
return requestPromise;
|
||||||
|
}
|
||||||
|
|
||||||
function SpaceOAuthCallbackContent() {
|
function SpaceOAuthCallbackContent() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
const [status, setStatus] = useState<
|
const [status, setStatus] = useState<
|
||||||
'loading' | 'confirm' | 'success' | 'error'
|
'loading' | 'confirm' | 'success' | 'error'
|
||||||
@@ -37,7 +66,11 @@ function SpaceOAuthCallbackContent() {
|
|||||||
const handleOAuthCallback = useCallback(
|
const handleOAuthCallback = useCallback(
|
||||||
async (authCode: string) => {
|
async (authCode: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await httpClient.exchangeSpaceOAuthCode(authCode);
|
const response = await getOrCreateSpaceOAuthLoginPromise(authCode);
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
localStorage.setItem('token', response.token);
|
localStorage.setItem('token', response.token);
|
||||||
if (response.user) {
|
if (response.user) {
|
||||||
localStorage.setItem('userEmail', response.user);
|
localStorage.setItem('userEmail', response.user);
|
||||||
@@ -52,6 +85,10 @@ function SpaceOAuthCallbackContent() {
|
|||||||
navigate(redirectTo);
|
navigate(redirectTo);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
const errorObj = err as { msg?: string };
|
const errorObj = err as { msg?: string };
|
||||||
const errMsg = (errorObj?.msg || '').toLowerCase();
|
const errMsg = (errorObj?.msg || '').toLowerCase();
|
||||||
@@ -72,6 +109,10 @@ function SpaceOAuthCallbackContent() {
|
|||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
const response = await httpClient.bindSpaceAccount(authCode, state);
|
const response = await httpClient.bindSpaceAccount(authCode, state);
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
localStorage.setItem('token', response.token);
|
localStorage.setItem('token', response.token);
|
||||||
if (response.user) {
|
if (response.user) {
|
||||||
localStorage.setItem('userEmail', response.user);
|
localStorage.setItem('userEmail', response.user);
|
||||||
@@ -82,6 +123,10 @@ function SpaceOAuthCallbackContent() {
|
|||||||
navigate('/home');
|
navigate('/home');
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
const errorObj = err as { msg?: string };
|
const errorObj = err as { msg?: string };
|
||||||
const errMsg = (errorObj?.msg || '').toLowerCase();
|
const errMsg = (errorObj?.msg || '').toLowerCase();
|
||||||
@@ -91,13 +136,17 @@ function SpaceOAuthCallbackContent() {
|
|||||||
setErrorMessage(t('account.bindSpaceFailed'));
|
setErrorMessage(t('account.bindSpaceFailed'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
if (isMountedRef.current) {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[navigate, t],
|
[navigate, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
|
||||||
const authCode = searchParams.get('code');
|
const authCode = searchParams.get('code');
|
||||||
const error = searchParams.get('error');
|
const error = searchParams.get('error');
|
||||||
const errorDescription = searchParams.get('error_description');
|
const errorDescription = searchParams.get('error_description');
|
||||||
@@ -135,6 +184,9 @@ function SpaceOAuthCallbackContent() {
|
|||||||
// Normal login/register mode
|
// Normal login/register mode
|
||||||
handleOAuthCallback(authCode);
|
handleOAuthCallback(authCode);
|
||||||
}
|
}
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
}, [searchParams, handleOAuthCallback, t]);
|
}, [searchParams, handleOAuthCallback, t]);
|
||||||
|
|
||||||
const handleConfirmBind = () => {
|
const handleConfirmBind = () => {
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ export default function BotForm({
|
|||||||
type: parseDynamicFormItemType(item.type),
|
type: parseDynamicFormItemType(item.type),
|
||||||
options: item.options,
|
options: item.options,
|
||||||
show_if: item.show_if,
|
show_if: item.show_if,
|
||||||
|
login_platform: item.login_platform,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||||
|
import QrCodeLoginDialog, {
|
||||||
|
QrLoginPlatform,
|
||||||
|
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Copy, Check, Globe } from 'lucide-react';
|
import { Copy, Check, Globe, QrCode } from 'lucide-react';
|
||||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||||
import { systemInfo } from '@/app/infra/http';
|
import { systemInfo } from '@/app/infra/http';
|
||||||
|
|
||||||
@@ -195,6 +198,7 @@ export default function DynamicFormComponent({
|
|||||||
isEditing,
|
isEditing,
|
||||||
externalDependentValues,
|
externalDependentValues,
|
||||||
systemContext,
|
systemContext,
|
||||||
|
onValidate,
|
||||||
}: {
|
}: {
|
||||||
itemConfigList: IDynamicFormItemSchema[];
|
itemConfigList: IDynamicFormItemSchema[];
|
||||||
onSubmit?: (val: object) => unknown;
|
onSubmit?: (val: object) => unknown;
|
||||||
@@ -205,6 +209,9 @@ export default function DynamicFormComponent({
|
|||||||
/** Extra variables accessible via the `__system.*` namespace in show_if conditions.
|
/** Extra variables accessible via the `__system.*` namespace in show_if conditions.
|
||||||
* e.g. `{ is_wizard: true }` makes `show_if: { field: "__system.is_wizard", ... }` work. */
|
* e.g. `{ is_wizard: true }` makes `show_if: { field: "__system.is_wizard", ... }` work. */
|
||||||
systemContext?: Record<string, unknown>;
|
systemContext?: Record<string, unknown>;
|
||||||
|
/** Callback to expose validation function to parent component.
|
||||||
|
* Parent can call this function to trigger validation and get validity state. */
|
||||||
|
onValidate?: (validateFn: () => Promise<boolean>) => void;
|
||||||
}) {
|
}) {
|
||||||
const isInitialMount = useRef(true);
|
const isInitialMount = useRef(true);
|
||||||
const previousInitialValues = useRef(initialValues);
|
const previousInitialValues = useRef(initialValues);
|
||||||
@@ -251,7 +258,10 @@ export default function DynamicFormComponent({
|
|||||||
const editableItems = useMemo(
|
const editableItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
itemConfigList.filter(
|
itemConfigList.filter(
|
||||||
(item) => item.type !== 'webhook-url' && item.type !== 'embed-code',
|
(item) =>
|
||||||
|
item.type !== 'webhook-url' &&
|
||||||
|
item.type !== 'embed-code' &&
|
||||||
|
item.type !== 'qr-code-login',
|
||||||
),
|
),
|
||||||
[itemConfigList],
|
[itemConfigList],
|
||||||
);
|
);
|
||||||
@@ -352,6 +362,17 @@ export default function DynamicFormComponent({
|
|||||||
}, {} as FormValues),
|
}, {} as FormValues),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Expose validation function to parent component
|
||||||
|
const validate = async (): Promise<boolean> => {
|
||||||
|
// Trigger validation for all fields
|
||||||
|
const result = await form.trigger();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onValidate?.(validate);
|
||||||
|
}, [onValidate]);
|
||||||
|
|
||||||
// 当 initialValues 变化时更新表单值
|
// 当 initialValues 变化时更新表单值
|
||||||
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
|
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -434,9 +455,28 @@ export default function DynamicFormComponent({
|
|||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
}, [form, editableItems]);
|
}, [form, editableItems]);
|
||||||
|
|
||||||
|
// State for QR code login dialog
|
||||||
|
const [qrDialogOpen, setQrDialogOpen] = useState(false);
|
||||||
|
const [qrDialogPlatform, setQrDialogPlatform] =
|
||||||
|
useState<QrLoginPlatform>('feishu');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* QR code login dialog */}
|
||||||
|
<QrCodeLoginDialog
|
||||||
|
open={qrDialogOpen}
|
||||||
|
onOpenChange={setQrDialogOpen}
|
||||||
|
platform={qrDialogPlatform}
|
||||||
|
onSuccess={(credentials) => {
|
||||||
|
for (const [key, value] of Object.entries(credentials)) {
|
||||||
|
if (value) {
|
||||||
|
form.setValue(key as keyof FormValues, value as never);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{itemConfigList.map((config) => {
|
{itemConfigList.map((config) => {
|
||||||
if (config.show_if) {
|
if (config.show_if) {
|
||||||
const dependValue = resolveShowIfValue(
|
const dependValue = resolveShowIfValue(
|
||||||
@@ -523,6 +563,66 @@ export default function DynamicFormComponent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QR code login button (e.g. Feishu one-click create, WeChat scan login)
|
||||||
|
if (config.type === 'qr-code-login') {
|
||||||
|
return (
|
||||||
|
<FormItem key={config.id}>
|
||||||
|
<div
|
||||||
|
className="relative flex items-center gap-4 p-4 rounded-xl border-2 border-dashed cursor-pointer transition-all hover:border-solid hover:shadow-md group"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
'color-mix(in srgb, var(--primary) 25%, transparent)',
|
||||||
|
background:
|
||||||
|
'color-mix(in srgb, var(--primary) 3%, transparent)',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isEditing) {
|
||||||
|
setQrDialogPlatform(
|
||||||
|
(config.login_platform as QrLoginPlatform) || 'feishu',
|
||||||
|
);
|
||||||
|
setQrDialogOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center h-12 w-12 rounded-lg bg-primary/10 shrink-0">
|
||||||
|
<QrCode className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">
|
||||||
|
{extractI18nObject(config.label)}
|
||||||
|
</span>
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-primary text-primary-foreground">
|
||||||
|
{t('common.recommend')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{config.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||||
|
{extractI18nObject(config.description)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!!isEditing}
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setQrDialogPlatform(
|
||||||
|
(config.login_platform as QrLoginPlatform) || 'feishu',
|
||||||
|
);
|
||||||
|
setQrDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QrCode className="h-3.5 w-3.5 mr-1" />
|
||||||
|
{t('common.start')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Boolean fields use a special inline layout
|
// Boolean fields use a special inline layout
|
||||||
if (config.type === 'boolean') {
|
if (config.type === 'boolean') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
|||||||
description?: I18nObject;
|
description?: I18nObject;
|
||||||
options?: IDynamicFormItemOption[];
|
options?: IDynamicFormItemOption[];
|
||||||
show_if?: IShowIfCondition;
|
show_if?: IShowIfCondition;
|
||||||
|
login_platform?: string;
|
||||||
|
|
||||||
constructor(params: IDynamicFormItemSchema) {
|
constructor(params: IDynamicFormItemSchema) {
|
||||||
this.id = params.id;
|
this.id = params.id;
|
||||||
@@ -27,6 +28,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
|||||||
this.description = params.description;
|
this.description = params.description;
|
||||||
this.options = params.options;
|
this.options = params.options;
|
||||||
this.show_if = params.show_if;
|
this.show_if = params.show_if;
|
||||||
|
this.login_platform = params.login_platform;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
366
web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx
Normal file
366
web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Loader2, RefreshCw, CheckCircle2, XCircle } from 'lucide-react';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
|
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
||||||
|
|
||||||
|
interface PlatformConfig {
|
||||||
|
titleKey: string;
|
||||||
|
connectingKey: string;
|
||||||
|
scanQRCodeKey: string;
|
||||||
|
waitingKey: string;
|
||||||
|
successKey: string;
|
||||||
|
failedKey: string;
|
||||||
|
retryKey: string;
|
||||||
|
apiBase: string;
|
||||||
|
extractSuccess: (data: Record<string, string>) => Record<string, string>;
|
||||||
|
successNoteKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||||
|
feishu: {
|
||||||
|
titleKey: 'feishu.createApp',
|
||||||
|
connectingKey: 'feishu.connecting',
|
||||||
|
scanQRCodeKey: 'feishu.scanQRCode',
|
||||||
|
waitingKey: 'feishu.waitingForScan',
|
||||||
|
successKey: 'feishu.createSuccess',
|
||||||
|
failedKey: 'feishu.createFailed',
|
||||||
|
retryKey: 'feishu.retry',
|
||||||
|
apiBase: '/api/v1/platform/adapters/lark/create-app',
|
||||||
|
extractSuccess: (data) => ({
|
||||||
|
app_id: data.app_id,
|
||||||
|
app_secret: data.app_secret,
|
||||||
|
...(data.app_name ? { app_name: data.app_name } : {}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
weixin: {
|
||||||
|
titleKey: 'weixin.scanLogin',
|
||||||
|
connectingKey: 'feishu.connecting',
|
||||||
|
scanQRCodeKey: 'weixin.scanQRCode',
|
||||||
|
waitingKey: 'feishu.waitingForScan',
|
||||||
|
successKey: 'weixin.loginSuccess',
|
||||||
|
failedKey: 'weixin.loginFailed',
|
||||||
|
retryKey: 'feishu.retry',
|
||||||
|
apiBase: '/api/v1/platform/adapters/weixin/login',
|
||||||
|
extractSuccess: (data) => ({
|
||||||
|
token: data.token,
|
||||||
|
base_url: data.base_url,
|
||||||
|
...(data.account_id ? { account_id: data.account_id } : {}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
dingtalk: {
|
||||||
|
titleKey: 'dingtalk.createApp',
|
||||||
|
connectingKey: 'dingtalk.connecting',
|
||||||
|
scanQRCodeKey: 'dingtalk.scanQRCode',
|
||||||
|
waitingKey: 'dingtalk.waitingForScan',
|
||||||
|
successKey: 'dingtalk.createSuccess',
|
||||||
|
failedKey: 'dingtalk.createFailed',
|
||||||
|
retryKey: 'dingtalk.retry',
|
||||||
|
apiBase: '/api/v1/platform/adapters/dingtalk/create-app',
|
||||||
|
extractSuccess: (data) => ({
|
||||||
|
client_id: data.client_id,
|
||||||
|
client_secret: data.client_secret,
|
||||||
|
}),
|
||||||
|
successNoteKey: 'dingtalk.robotCodeNote',
|
||||||
|
},
|
||||||
|
wecombot: {
|
||||||
|
titleKey: 'wecombot.createBot',
|
||||||
|
connectingKey: 'wecombot.connecting',
|
||||||
|
scanQRCodeKey: 'wecombot.scanQRCode',
|
||||||
|
waitingKey: 'wecombot.waitingForScan',
|
||||||
|
successKey: 'wecombot.createSuccess',
|
||||||
|
failedKey: 'wecombot.createFailed',
|
||||||
|
retryKey: 'wecombot.retry',
|
||||||
|
apiBase: '/api/v1/platform/adapters/wecombot/create-bot',
|
||||||
|
extractSuccess: (data) => ({
|
||||||
|
BotId: data.botid,
|
||||||
|
Secret: data.secret,
|
||||||
|
}),
|
||||||
|
successNoteKey: 'wecombot.robotNameNote',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QrCodeLoginDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
platform: QrLoginPlatform;
|
||||||
|
onSuccess: (credentials: Record<string, string>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DialogState = 'connecting' | 'waiting' | 'success' | 'error';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
|
export default function QrCodeLoginDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
platform,
|
||||||
|
onSuccess,
|
||||||
|
}: QrCodeLoginDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const platformConfig = PLATFORM_CONFIGS[platform];
|
||||||
|
|
||||||
|
const [state, setState] = useState<DialogState>('connecting');
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||||
|
const [expireIn, setExpireIn] = useState(0);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const sessionIdRef = useRef<string | null>(null);
|
||||||
|
const cleanedRef = useRef(false);
|
||||||
|
|
||||||
|
const onSuccessRef = useRef(onSuccess);
|
||||||
|
onSuccessRef.current = onSuccess;
|
||||||
|
const onOpenChangeRef = useRef(onOpenChange);
|
||||||
|
onOpenChangeRef.current = onOpenChange;
|
||||||
|
const tRef = useRef(t);
|
||||||
|
tRef.current = t;
|
||||||
|
const platformConfigRef = useRef(platformConfig);
|
||||||
|
platformConfigRef.current = platformConfig;
|
||||||
|
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (cleanedRef.current) return;
|
||||||
|
cleanedRef.current = true;
|
||||||
|
|
||||||
|
if (pollTimerRef.current) {
|
||||||
|
clearInterval(pollTimerRef.current);
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (countdownRef.current) {
|
||||||
|
clearInterval(countdownRef.current);
|
||||||
|
countdownRef.current = null;
|
||||||
|
}
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
// Cancel backend session
|
||||||
|
if (sessionIdRef.current) {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const baseUrl =
|
||||||
|
import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||||
|
fetch(
|
||||||
|
`${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
keepalive: true,
|
||||||
|
},
|
||||||
|
).catch(() => {});
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startLogin = useCallback(async () => {
|
||||||
|
cleanup();
|
||||||
|
cleanedRef.current = false;
|
||||||
|
setState('connecting');
|
||||||
|
setQrDataUrl('');
|
||||||
|
setExpireIn(0);
|
||||||
|
setErrorMessage('');
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||||
|
const cfg = platformConfigRef.current;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}${cfg.apiBase}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code !== 0) throw new Error(json.msg || 'Request failed');
|
||||||
|
|
||||||
|
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
|
||||||
|
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) {
|
||||||
|
setQrDataUrl(qr_data_url);
|
||||||
|
} else if (qr_url) {
|
||||||
|
const dataUrl = await QRCode.toDataURL(qr_url, {
|
||||||
|
width: 224,
|
||||||
|
margin: 2,
|
||||||
|
});
|
||||||
|
setQrDataUrl(dataUrl);
|
||||||
|
}
|
||||||
|
setState('waiting');
|
||||||
|
|
||||||
|
// Calculate remaining seconds
|
||||||
|
const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000));
|
||||||
|
setExpireIn(remaining);
|
||||||
|
|
||||||
|
// Start countdown
|
||||||
|
countdownRef.current = setInterval(() => {
|
||||||
|
setExpireIn((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
if (countdownRef.current) {
|
||||||
|
clearInterval(countdownRef.current);
|
||||||
|
countdownRef.current = null;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Start polling
|
||||||
|
pollTimerRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const pollRes = await fetch(
|
||||||
|
`${baseUrl}${cfg.apiBase}/status/${session_id}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
if (!pollRes.ok) return;
|
||||||
|
|
||||||
|
const pollJson = await pollRes.json();
|
||||||
|
if (pollJson.code !== 0) return;
|
||||||
|
|
||||||
|
const { status, error, ...rest } = pollJson.data;
|
||||||
|
|
||||||
|
if (status === 'success') {
|
||||||
|
sessionIdRef.current = null; // backend already cleaned up
|
||||||
|
cleanup();
|
||||||
|
setState('success');
|
||||||
|
setTimeout(() => {
|
||||||
|
onSuccessRef.current(cfg.extractSuccess(rest));
|
||||||
|
onOpenChangeRef.current(false);
|
||||||
|
}, 1500);
|
||||||
|
} else if (status === 'error') {
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
cleanup();
|
||||||
|
setState('error');
|
||||||
|
setErrorMessage(error || tRef.current(cfg.failedKey));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore poll errors, will retry next interval
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') return;
|
||||||
|
setState('error');
|
||||||
|
setErrorMessage(
|
||||||
|
err instanceof Error ? err.message : tRef.current(cfg.failedKey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [cleanup]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
startLogin();
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
}, [open, startLogin, cleanup]);
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
|
if (!newOpen) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
onOpenChange(newOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
if (m > 0) {
|
||||||
|
return `${m}m${s.toString().padStart(2, '0')}s`;
|
||||||
|
}
|
||||||
|
return `${s}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t(platformConfig.titleKey)}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center py-4 space-y-4">
|
||||||
|
{/* Connecting */}
|
||||||
|
{state === 'connecting' && (
|
||||||
|
<div className="flex flex-col items-center space-y-3 py-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(platformConfig.connectingKey)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR code area */}
|
||||||
|
{state === 'waiting' && qrDataUrl && (
|
||||||
|
<div className="flex flex-col items-center space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{t(platformConfig.scanQRCodeKey)}
|
||||||
|
</p>
|
||||||
|
<div className="border rounded-lg p-2 bg-white">
|
||||||
|
<img src={qrDataUrl} alt="QR Code" className="w-56 h-56" />
|
||||||
|
</div>
|
||||||
|
{expireIn > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(platformConfig.waitingKey)} ({formatTime(expireIn)})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success */}
|
||||||
|
{state === 'success' && (
|
||||||
|
<div className="flex flex-col items-center space-y-3 py-8">
|
||||||
|
<CheckCircle2 className="h-12 w-12 text-green-500" />
|
||||||
|
<p className="text-sm text-green-600 font-medium">
|
||||||
|
{t(platformConfig.successKey)}
|
||||||
|
</p>
|
||||||
|
{platformConfig.successNoteKey && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center max-w-xs">
|
||||||
|
{t(platformConfig.successNoteKey)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{state === 'error' && (
|
||||||
|
<div className="flex flex-col items-center space-y-3 py-8">
|
||||||
|
<XCircle className="h-12 w-12 text-red-500" />
|
||||||
|
<p className="text-sm text-red-600 text-center">
|
||||||
|
{errorMessage || t(platformConfig.failedKey)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state === 'error' && (
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => startLogin()}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||||
|
{t(platformConfig.retryKey)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
ScanLine,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
|
||||||
|
export type QrLoginPlatform = 'feishu' | 'weixin';
|
||||||
|
|
||||||
|
interface PlatformConfig {
|
||||||
|
titleKey: string;
|
||||||
|
connectingKey: string;
|
||||||
|
scanQRCodeKey: string;
|
||||||
|
waitingKey: string;
|
||||||
|
successKey: string;
|
||||||
|
failedKey: string;
|
||||||
|
retryKey: string;
|
||||||
|
apiBase: string;
|
||||||
|
brandColor: string;
|
||||||
|
adapterName: string;
|
||||||
|
extractSuccess: (data: Record<string, string>) => Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||||
|
feishu: {
|
||||||
|
titleKey: 'feishu.createApp',
|
||||||
|
connectingKey: 'feishu.connecting',
|
||||||
|
scanQRCodeKey: 'feishu.scanQRCode',
|
||||||
|
waitingKey: 'feishu.waitingForScan',
|
||||||
|
successKey: 'feishu.createSuccess',
|
||||||
|
failedKey: 'feishu.createFailed',
|
||||||
|
retryKey: 'feishu.retry',
|
||||||
|
apiBase: '/api/v1/platform/adapters/lark/create-app',
|
||||||
|
brandColor: '#3370ff',
|
||||||
|
adapterName: 'lark',
|
||||||
|
extractSuccess: (data) => ({
|
||||||
|
app_id: data.app_id,
|
||||||
|
app_secret: data.app_secret,
|
||||||
|
...(data.app_name ? { app_name: data.app_name } : {}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
weixin: {
|
||||||
|
titleKey: 'weixin.scanLogin',
|
||||||
|
connectingKey: 'feishu.connecting',
|
||||||
|
scanQRCodeKey: 'weixin.scanQRCode',
|
||||||
|
waitingKey: 'feishu.waitingForScan',
|
||||||
|
successKey: 'weixin.loginSuccess',
|
||||||
|
failedKey: 'weixin.loginFailed',
|
||||||
|
retryKey: 'feishu.retry',
|
||||||
|
apiBase: '/api/v1/platform/adapters/weixin/login',
|
||||||
|
brandColor: '#07c160',
|
||||||
|
adapterName: 'openclaw-weixin',
|
||||||
|
extractSuccess: (data) => ({
|
||||||
|
token: data.token,
|
||||||
|
base_url: data.base_url,
|
||||||
|
...(data.account_id ? { account_id: data.account_id } : {}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QrCodeLoginDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
platform: QrLoginPlatform;
|
||||||
|
onSuccess: (credentials: Record<string, string>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DialogState = 'connecting' | 'waiting' | 'success' | 'error';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
|
export default function QrCodeLoginDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
platform,
|
||||||
|
onSuccess,
|
||||||
|
}: QrCodeLoginDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const platformConfig = PLATFORM_CONFIGS[platform];
|
||||||
|
|
||||||
|
const [state, setState] = useState<DialogState>('connecting');
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||||
|
const [expireIn, setExpireIn] = useState(0);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const sessionIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const onSuccessRef = useRef(onSuccess);
|
||||||
|
onSuccessRef.current = onSuccess;
|
||||||
|
const onOpenChangeRef = useRef(onOpenChange);
|
||||||
|
onOpenChangeRef.current = onOpenChange;
|
||||||
|
const tRef = useRef(t);
|
||||||
|
tRef.current = t;
|
||||||
|
const platformConfigRef = useRef(platformConfig);
|
||||||
|
platformConfigRef.current = platformConfig;
|
||||||
|
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (pollTimerRef.current) {
|
||||||
|
clearInterval(pollTimerRef.current);
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (countdownRef.current) {
|
||||||
|
clearInterval(countdownRef.current);
|
||||||
|
countdownRef.current = null;
|
||||||
|
}
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
if (sessionIdRef.current) {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const baseUrl =
|
||||||
|
import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||||
|
fetch(
|
||||||
|
`${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
},
|
||||||
|
).catch(() => {});
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startLogin = useCallback(async () => {
|
||||||
|
cleanup();
|
||||||
|
setState('connecting');
|
||||||
|
setQrDataUrl('');
|
||||||
|
setExpireIn(0);
|
||||||
|
setErrorMessage('');
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||||
|
const cfg = platformConfigRef.current;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}${cfg.apiBase}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code !== 0) throw new Error(json.msg || 'Request failed');
|
||||||
|
|
||||||
|
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
|
||||||
|
sessionIdRef.current = session_id;
|
||||||
|
|
||||||
|
if (qr_data_url) {
|
||||||
|
setQrDataUrl(qr_data_url);
|
||||||
|
} else if (qr_url) {
|
||||||
|
const dataUrl = await QRCode.toDataURL(qr_url, {
|
||||||
|
width: 280,
|
||||||
|
margin: 2,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setQrDataUrl(dataUrl);
|
||||||
|
}
|
||||||
|
setState('waiting');
|
||||||
|
|
||||||
|
const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000));
|
||||||
|
setExpireIn(remaining);
|
||||||
|
|
||||||
|
countdownRef.current = setInterval(() => {
|
||||||
|
setExpireIn((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
if (countdownRef.current) {
|
||||||
|
clearInterval(countdownRef.current);
|
||||||
|
countdownRef.current = null;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
pollTimerRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const pollRes = await fetch(
|
||||||
|
`${baseUrl}${cfg.apiBase}/status/${session_id}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
if (!pollRes.ok) return;
|
||||||
|
|
||||||
|
const pollJson = await pollRes.json();
|
||||||
|
if (pollJson.code !== 0) return;
|
||||||
|
|
||||||
|
const { status, error, ...rest } = pollJson.data;
|
||||||
|
|
||||||
|
if (status === 'success') {
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
cleanup();
|
||||||
|
setState('success');
|
||||||
|
setTimeout(() => {
|
||||||
|
onSuccessRef.current(cfg.extractSuccess(rest));
|
||||||
|
onOpenChangeRef.current(false);
|
||||||
|
}, 1500);
|
||||||
|
} else if (status === 'error') {
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
cleanup();
|
||||||
|
setState('error');
|
||||||
|
setErrorMessage(error || tRef.current(cfg.failedKey));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore poll errors
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') return;
|
||||||
|
setState('error');
|
||||||
|
setErrorMessage(
|
||||||
|
err instanceof Error ? err.message : tRef.current(cfg.failedKey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [cleanup]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
startLogin();
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
}, [open, startLogin, cleanup]);
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
|
if (!newOpen) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
onOpenChange(newOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
if (m > 0) {
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `0:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md p-0 overflow-hidden">
|
||||||
|
{/* Brand header */}
|
||||||
|
<div className="flex items-center gap-3 px-6 pt-6 pb-2">
|
||||||
|
<img
|
||||||
|
src={httpClient.getAdapterIconURL(platformConfig.adapterName)}
|
||||||
|
alt={platform}
|
||||||
|
className="h-10 w-10 rounded-lg"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-lg">
|
||||||
|
{t(platformConfig.titleKey)}
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center px-6 pb-6 space-y-4">
|
||||||
|
{/* Connecting */}
|
||||||
|
{state === 'connecting' && (
|
||||||
|
<div className="flex flex-col items-center space-y-4 py-12">
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-full animate-ping opacity-20"
|
||||||
|
style={{ backgroundColor: platformConfig.brandColor }}
|
||||||
|
/>
|
||||||
|
<Loader2
|
||||||
|
className="h-10 w-10 animate-spin relative"
|
||||||
|
style={{ color: platformConfig.brandColor }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground font-medium">
|
||||||
|
{t(platformConfig.connectingKey)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR code area */}
|
||||||
|
{state === 'waiting' && qrDataUrl && (
|
||||||
|
<div className="flex flex-col items-center space-y-4 py-2">
|
||||||
|
{/* Instruction */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${platformConfig.brandColor}10`,
|
||||||
|
color: platformConfig.brandColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ScanLine className="h-4 w-4" />
|
||||||
|
{t(platformConfig.scanQRCodeKey)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR Code with border animation */}
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className="absolute -inset-1 rounded-2xl opacity-30 animate-pulse"
|
||||||
|
style={{ backgroundColor: platformConfig.brandColor }}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl p-3 shadow-lg">
|
||||||
|
<img src={qrDataUrl} alt="QR Code" className="w-64 h-64" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Countdown */}
|
||||||
|
{expireIn > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 rounded-full animate-pulse"
|
||||||
|
style={{ backgroundColor: platformConfig.brandColor }}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t(platformConfig.waitingKey)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-mono font-semibold tabular-nums"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
expireIn < 60 ? '#ef4444' : platformConfig.brandColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTime(expireIn)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success */}
|
||||||
|
{state === 'success' && (
|
||||||
|
<div className="flex flex-col items-center space-y-3 py-12">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-green-100 animate-ping opacity-30" />
|
||||||
|
<CheckCircle2 className="h-16 w-16 text-green-500 relative" />
|
||||||
|
</div>
|
||||||
|
<p className="text-base text-green-600 font-semibold">
|
||||||
|
{t(platformConfig.successKey)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{state === 'error' && (
|
||||||
|
<div className="flex flex-col items-center space-y-3 py-12">
|
||||||
|
<XCircle className="h-16 w-16 text-red-400" />
|
||||||
|
<p className="text-sm text-red-500 text-center max-w-xs">
|
||||||
|
{errorMessage || t(platformConfig.failedKey)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error footer with retry */}
|
||||||
|
{state === 'error' && (
|
||||||
|
<DialogFooter className="px-6 pb-6 pt-0">
|
||||||
|
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => startLogin()}
|
||||||
|
style={{ backgroundColor: platformConfig.brandColor }}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||||
|
{t(platformConfig.retryKey)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,7 +57,6 @@ const getFormSchema = (t: (key: string) => string) =>
|
|||||||
* Parse creation schema from Knowledge Engine to IDynamicFormItemSchema[]
|
* Parse creation schema from Knowledge Engine to IDynamicFormItemSchema[]
|
||||||
*/
|
*/
|
||||||
function parseCreationSchema(
|
function parseCreationSchema(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
schemaItems: any | any[] | undefined,
|
schemaItems: any | any[] | undefined,
|
||||||
): IDynamicFormItemSchema[] {
|
): IDynamicFormItemSchema[] {
|
||||||
if (!schemaItems) return [];
|
if (!schemaItems) return [];
|
||||||
@@ -107,6 +106,10 @@ export default function KBForm({
|
|||||||
const savedSnapshotRef = useRef<string>('');
|
const savedSnapshotRef = useRef<string>('');
|
||||||
const isInitializing = useRef(true);
|
const isInitializing = useRef(true);
|
||||||
|
|
||||||
|
// Refs to store validation functions from dynamic forms
|
||||||
|
const configValidateRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||||
|
const retrievalValidateRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||||
|
|
||||||
const formSchema = getFormSchema(t);
|
const formSchema = getFormSchema(t);
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
@@ -235,7 +238,24 @@ export default function KBForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (data: z.infer<typeof formSchema>) => {
|
const onSubmit = async (data: z.infer<typeof formSchema>) => {
|
||||||
|
// Validate dynamic forms before submission
|
||||||
|
if (configValidateRef.current) {
|
||||||
|
const configValid = await configValidateRef.current();
|
||||||
|
if (!configValid) {
|
||||||
|
toast.error(t('knowledge.engineSettingsInvalid'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retrievalValidateRef.current) {
|
||||||
|
const retrievalValid = await retrievalValidateRef.current();
|
||||||
|
if (!retrievalValid) {
|
||||||
|
toast.error(t('knowledge.retrievalSettingsInvalid'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const kbData: KnowledgeBase = {
|
const kbData: KnowledgeBase = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description ?? '',
|
description: data.description ?? '',
|
||||||
@@ -490,6 +510,9 @@ export default function KBForm({
|
|||||||
}
|
}
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
externalDependentValues={retrievalSettings}
|
externalDependentValues={retrievalSettings}
|
||||||
|
onValidate={(validateFn) =>
|
||||||
|
(configValidateRef.current = validateFn)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -512,6 +535,9 @@ export default function KBForm({
|
|||||||
setRetrievalSettings(val as Record<string, unknown>)
|
setRetrievalSettings(val as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
externalDependentValues={configSettings}
|
externalDependentValues={configSettings}
|
||||||
|
onValidate={(validateFn) =>
|
||||||
|
(retrievalValidateRef.current = validateFn)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -76,10 +76,12 @@ function MarketplaceContent() {
|
|||||||
|
|
||||||
// Register task completion callback for toast and plugin list refresh
|
// Register task completion callback for toast and plugin list refresh
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onComplete = (_taskId: number, success: boolean) => {
|
const onComplete = (_taskId: number, success: boolean, error?: string) => {
|
||||||
if (success) {
|
if (success) {
|
||||||
toast.success(t('plugins.installSuccess'));
|
toast.success(t('plugins.installSuccess'));
|
||||||
refreshPlugins();
|
refreshPlugins();
|
||||||
|
} else {
|
||||||
|
toast.error(error || t('plugins.installFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
registerOnTaskComplete(onComplete);
|
registerOnTaskComplete(onComplete);
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ export interface PluginInstallTask {
|
|||||||
currentAction: string; // raw backend action string
|
currentAction: string; // raw backend action string
|
||||||
}
|
}
|
||||||
|
|
||||||
type OnTaskCompleteCallback = (taskId: number, success: boolean) => void;
|
type OnTaskCompleteCallback = (
|
||||||
|
taskId: number,
|
||||||
|
success: boolean,
|
||||||
|
error?: string,
|
||||||
|
) => void;
|
||||||
|
|
||||||
interface PluginInstallTaskContextValue {
|
interface PluginInstallTaskContextValue {
|
||||||
tasks: PluginInstallTask[];
|
tasks: PluginInstallTask[];
|
||||||
@@ -224,13 +228,16 @@ export function PluginInstallTaskProvider({
|
|||||||
onTaskCompleteCallbacks.current.delete(cb);
|
onTaskCompleteCallbacks.current.delete(cb);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const notifyTaskComplete = useCallback((taskId: number, success: boolean) => {
|
const notifyTaskComplete = useCallback(
|
||||||
if (notifiedTaskIds.current.has(taskId)) return;
|
(taskId: number, success: boolean, error?: string) => {
|
||||||
notifiedTaskIds.current.add(taskId);
|
if (notifiedTaskIds.current.has(taskId)) return;
|
||||||
onTaskCompleteCallbacks.current.forEach((cb) => {
|
notifiedTaskIds.current.add(taskId);
|
||||||
cb(taskId, success);
|
onTaskCompleteCallbacks.current.forEach((cb) => {
|
||||||
});
|
cb(taskId, success, error);
|
||||||
}, []);
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const pollTask = useCallback(
|
const pollTask = useCallback(
|
||||||
(taskKey: string, taskId: number) => {
|
(taskKey: string, taskId: number) => {
|
||||||
@@ -289,7 +296,7 @@ export function PluginInstallTaskProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (exception) {
|
if (exception) {
|
||||||
notifyTaskComplete(taskId, false);
|
notifyTaskComplete(taskId, false, exception);
|
||||||
return {
|
return {
|
||||||
...t,
|
...t,
|
||||||
stage: InstallStage.ERROR,
|
stage: InstallStage.ERROR,
|
||||||
|
|||||||
@@ -167,11 +167,13 @@ function PluginListView() {
|
|||||||
|
|
||||||
// Register task completion callback for toast and plugin list refresh
|
// Register task completion callback for toast and plugin list refresh
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onComplete = (_taskId: number, success: boolean) => {
|
const onComplete = (_taskId: number, success: boolean, error?: string) => {
|
||||||
if (success) {
|
if (success) {
|
||||||
toast.success(t('plugins.installSuccess'));
|
toast.success(t('plugins.installSuccess'));
|
||||||
pluginInstalledRef.current?.refreshPluginList();
|
pluginInstalledRef.current?.refreshPluginList();
|
||||||
refreshPlugins();
|
refreshPlugins();
|
||||||
|
} else {
|
||||||
|
toast.error(error || t('plugins.installFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
registerOnTaskComplete(onComplete);
|
registerOnTaskComplete(onComplete);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface IDynamicFormItemSchema {
|
|||||||
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
|
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
accept?: string; // For file type: accepted MIME types
|
accept?: string; // For file type: accepted MIME types
|
||||||
|
login_platform?: string; // For qr-code-login type: platform identifier (e.g. 'feishu', 'weixin')
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum DynamicFormItemType {
|
export enum DynamicFormItemType {
|
||||||
@@ -46,6 +47,7 @@ export enum DynamicFormItemType {
|
|||||||
TOOLS_SELECTOR = 'tools-selector',
|
TOOLS_SELECTOR = 'tools-selector',
|
||||||
WEBHOOK_URL = 'webhook-url',
|
WEBHOOK_URL = 'webhook-url',
|
||||||
EMBED_CODE = 'embed-code',
|
EMBED_CODE = 'embed-code',
|
||||||
|
QR_CODE_LOGIN = 'qr-code-login',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFileConfig {
|
export interface IFileConfig {
|
||||||
|
|||||||
@@ -590,6 +590,9 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
name: string,
|
name: string,
|
||||||
filepath: string,
|
filepath: string,
|
||||||
): string {
|
): string {
|
||||||
|
if (this.instance.defaults.baseURL === '/') {
|
||||||
|
return `${window.location.origin}/api/v1/plugins/${author}/${name}/assets/${filepath}`;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
this.instance.defaults.baseURL +
|
this.instance.defaults.baseURL +
|
||||||
`/api/v1/plugins/${author}/${name}/assets/${filepath}`
|
`/api/v1/plugins/${author}/${name}/assets/${filepath}`
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ export default function WizardPage() {
|
|||||||
type: parseDynamicFormItemType(item.type),
|
type: parseDynamicFormItemType(item.type),
|
||||||
options: item.options,
|
options: item.options,
|
||||||
show_if: item.show_if,
|
show_if: item.show_if,
|
||||||
|
login_platform: item.login_platform,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [adapters, selectedAdapter]);
|
}, [adapters, selectedAdapter]);
|
||||||
@@ -247,6 +248,7 @@ export default function WizardPage() {
|
|||||||
type: parseDynamicFormItemType(item.type),
|
type: parseDynamicFormItemType(item.type),
|
||||||
options: item.options,
|
options: item.options,
|
||||||
show_if: item.show_if,
|
show_if: item.show_if,
|
||||||
|
login_platform: item.login_platform,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [selectedRunnerConfigStage]);
|
}, [selectedRunnerConfigStage]);
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ const enUS = {
|
|||||||
success: 'Success',
|
success: 'Success',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
saving: 'Saving...',
|
saving: 'Saving...',
|
||||||
|
recommend: 'Recommended',
|
||||||
|
start: 'Start',
|
||||||
confirm: 'Confirm',
|
confirm: 'Confirm',
|
||||||
confirmDelete: 'Confirm Delete',
|
confirmDelete: 'Confirm Delete',
|
||||||
deleteConfirmation: 'Are you sure you want to delete this?',
|
deleteConfirmation: 'Are you sure you want to delete this?',
|
||||||
@@ -928,6 +930,10 @@ const enUS = {
|
|||||||
engineSettingsDescription:
|
engineSettingsDescription:
|
||||||
'Configuration for the selected knowledge engine',
|
'Configuration for the selected knowledge engine',
|
||||||
engineSettingsReadonly: 'read-only in edit mode',
|
engineSettingsReadonly: 'read-only in edit mode',
|
||||||
|
engineSettingsInvalid:
|
||||||
|
'Engine settings validation failed, please check required fields',
|
||||||
|
retrievalSettingsInvalid:
|
||||||
|
'Retrieval settings validation failed, please check required fields',
|
||||||
retrievalSettings: 'Retrieval Settings',
|
retrievalSettings: 'Retrieval Settings',
|
||||||
retrievalSettingsDescription:
|
retrievalSettingsDescription:
|
||||||
'Configure how documents are retrieved from this knowledge base',
|
'Configure how documents are retrieved from this knowledge base',
|
||||||
@@ -1332,6 +1338,51 @@ const enUS = {
|
|||||||
backToWorkbench: 'Back to Workbench',
|
backToWorkbench: 'Back to Workbench',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
feishu: {
|
||||||
|
createApp: 'One-Click Create Feishu App',
|
||||||
|
scanQRCode:
|
||||||
|
'Scan the QR code below with Feishu to authorize and automatically create the app',
|
||||||
|
waitingForScan: 'Waiting for scan',
|
||||||
|
createSuccess: 'App created successfully! Credentials have been filled in',
|
||||||
|
createFailed: 'Creation failed',
|
||||||
|
connecting: 'Connecting to Feishu service...',
|
||||||
|
expired: 'QR code expired, please try again',
|
||||||
|
denied: 'Authorization denied by user',
|
||||||
|
connectionLost: 'Connection lost, please try again',
|
||||||
|
reconnecting: 'Reconnecting...',
|
||||||
|
retry: 'Retry',
|
||||||
|
},
|
||||||
|
weixin: {
|
||||||
|
scanLogin: 'Scan QR Login',
|
||||||
|
scanQRCode:
|
||||||
|
'Scan the QR code below with WeChat to authorize and automatically fill in the token',
|
||||||
|
loginSuccess: 'Login successful! Token has been filled in',
|
||||||
|
loginFailed: 'Login failed',
|
||||||
|
},
|
||||||
|
dingtalk: {
|
||||||
|
createApp: 'One-Click Create DingTalk App',
|
||||||
|
scanQRCode:
|
||||||
|
'Scan the QR code below with DingTalk to authorize and automatically create the app',
|
||||||
|
waitingForScan: 'Waiting for scan',
|
||||||
|
createSuccess: 'App created successfully! Credentials have been filled in',
|
||||||
|
createFailed: 'Creation failed',
|
||||||
|
connecting: 'Connecting to DingTalk service...',
|
||||||
|
retry: 'Retry',
|
||||||
|
robotCodeNote:
|
||||||
|
'Robot Code cannot be obtained automatically. Please go to DingTalk Developer Backend > Robot Configuration to copy it manually. It is required for features like image recognition and file upload.',
|
||||||
|
},
|
||||||
|
wecombot: {
|
||||||
|
createBot: 'One-Click Create WeCom Bot',
|
||||||
|
scanQRCode:
|
||||||
|
'Scan the QR code below with WeCom to authorize and automatically create the bot',
|
||||||
|
waitingForScan: 'Waiting for scan',
|
||||||
|
createSuccess: 'Bot created successfully! Credentials have been filled in',
|
||||||
|
createFailed: 'Creation failed',
|
||||||
|
connecting: 'Connecting to WeCom service...',
|
||||||
|
retry: 'Retry',
|
||||||
|
robotNameNote:
|
||||||
|
'Robot Name cannot be obtained automatically. Please fill it in manually.',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'Select a plugin page from the sidebar',
|
selectFromSidebar: 'Select a plugin page from the sidebar',
|
||||||
invalidPage: 'Invalid plugin page',
|
invalidPage: 'Invalid plugin page',
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ const esES = {
|
|||||||
success: 'Éxito',
|
success: 'Éxito',
|
||||||
save: 'Guardar',
|
save: 'Guardar',
|
||||||
saving: 'Guardando...',
|
saving: 'Guardando...',
|
||||||
|
recommend: 'Recomendado',
|
||||||
|
start: 'Iniciar',
|
||||||
confirm: 'Confirmar',
|
confirm: 'Confirmar',
|
||||||
confirmDelete: 'Confirmar eliminación',
|
confirmDelete: 'Confirmar eliminación',
|
||||||
deleteConfirmation: '¿Estás seguro de que deseas eliminar esto?',
|
deleteConfirmation: '¿Estás seguro de que deseas eliminar esto?',
|
||||||
@@ -951,6 +953,10 @@ const esES = {
|
|||||||
engineSettingsDescription:
|
engineSettingsDescription:
|
||||||
'Configuración del motor de conocimiento seleccionado',
|
'Configuración del motor de conocimiento seleccionado',
|
||||||
engineSettingsReadonly: 'solo lectura en modo de edición',
|
engineSettingsReadonly: 'solo lectura en modo de edición',
|
||||||
|
engineSettingsInvalid:
|
||||||
|
'La configuración del motor no es válida, verifique los campos obligatorios',
|
||||||
|
retrievalSettingsInvalid:
|
||||||
|
'La configuración de recuperación no es válida, verifique los campos obligatorios',
|
||||||
retrievalSettings: 'Configuración de recuperación',
|
retrievalSettings: 'Configuración de recuperación',
|
||||||
retrievalSettingsDescription:
|
retrievalSettingsDescription:
|
||||||
'Configura cómo se recuperan los documentos de esta base de conocimiento',
|
'Configura cómo se recuperan los documentos de esta base de conocimiento',
|
||||||
@@ -1371,6 +1377,55 @@ const esES = {
|
|||||||
backToWorkbench: 'Volver al panel de trabajo',
|
backToWorkbench: 'Volver al panel de trabajo',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
feishu: {
|
||||||
|
createApp: 'Crear aplicación de Feishu con un clic',
|
||||||
|
scanQRCode:
|
||||||
|
'Escanea el código QR de abajo con Feishu para autorizar y crear la aplicación automáticamente',
|
||||||
|
waitingForScan: 'Esperando escaneo',
|
||||||
|
createSuccess:
|
||||||
|
'¡Aplicación creada correctamente! Las credenciales se han rellenado automáticamente',
|
||||||
|
createFailed: 'Error al crear la aplicación',
|
||||||
|
connecting: 'Conectando con el servicio de Feishu...',
|
||||||
|
expired: 'El código QR ha caducado. Inténtalo de nuevo',
|
||||||
|
denied: 'El usuario rechazó la autorización',
|
||||||
|
connectionLost: 'Se perdió la conexión. Inténtalo de nuevo',
|
||||||
|
reconnecting: 'Reconectando...',
|
||||||
|
retry: 'Reintentar',
|
||||||
|
},
|
||||||
|
weixin: {
|
||||||
|
scanLogin: 'Iniciar sesión en WeChat con QR',
|
||||||
|
scanQRCode:
|
||||||
|
'Escanea el código QR de abajo con WeChat para autorizar e introducir el token automáticamente',
|
||||||
|
loginSuccess:
|
||||||
|
'¡Inicio de sesión correcto! El token se ha rellenado automáticamente',
|
||||||
|
loginFailed: 'Error al iniciar sesión',
|
||||||
|
},
|
||||||
|
dingtalk: {
|
||||||
|
createApp: 'Crear aplicación de DingTalk con un clic',
|
||||||
|
scanQRCode:
|
||||||
|
'Escanea el código QR de abajo con DingTalk para autorizar y crear la aplicación automáticamente',
|
||||||
|
waitingForScan: 'Esperando escaneo',
|
||||||
|
createSuccess:
|
||||||
|
'¡Aplicación creada correctamente! Las credenciales se han rellenado automáticamente',
|
||||||
|
createFailed: 'Error al crear la aplicación',
|
||||||
|
connecting: 'Conectando con el servicio de DingTalk...',
|
||||||
|
retry: 'Reintentar',
|
||||||
|
robotCodeNote:
|
||||||
|
'El código del robot no puede obtenerse automáticamente. Ve al panel de desarrolladores de DingTalk > Configuración del robot para copiarlo manualmente. Es necesario para funciones como reconocimiento de imágenes y carga de archivos.',
|
||||||
|
},
|
||||||
|
wecombot: {
|
||||||
|
createBot: 'Crear bot de WeCom con un clic',
|
||||||
|
scanQRCode:
|
||||||
|
'Escanea el código QR de abajo con WeCom para autorizar y crear el bot automáticamente',
|
||||||
|
waitingForScan: 'Esperando escaneo',
|
||||||
|
createSuccess:
|
||||||
|
'¡Bot creado correctamente! Las credenciales se han rellenado automáticamente',
|
||||||
|
createFailed: 'Error al crear el bot',
|
||||||
|
connecting: 'Conectando con el servicio de WeCom...',
|
||||||
|
retry: 'Reintentar',
|
||||||
|
robotNameNote:
|
||||||
|
'El nombre del robot no puede obtenerse automáticamente. Introdúcelo manualmente.',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
|
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
|
||||||
invalidPage: 'Página de plugin no válida',
|
invalidPage: 'Página de plugin no válida',
|
||||||
|
|||||||
@@ -45,6 +45,8 @@
|
|||||||
success: '成功',
|
success: '成功',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
saving: '保存中...',
|
saving: '保存中...',
|
||||||
|
recommend: 'おすすめ',
|
||||||
|
start: '開始',
|
||||||
confirm: '確認',
|
confirm: '確認',
|
||||||
confirmDelete: '削除の確認',
|
confirmDelete: '削除の確認',
|
||||||
deleteConfirmation: '本当に削除しますか?',
|
deleteConfirmation: '本当に削除しますか?',
|
||||||
@@ -924,6 +926,10 @@
|
|||||||
engineSettings: 'エンジン設定',
|
engineSettings: 'エンジン設定',
|
||||||
engineSettingsDescription: '選択したナレッジエンジンの設定',
|
engineSettingsDescription: '選択したナレッジエンジンの設定',
|
||||||
engineSettingsReadonly: '編集モードでは変更できません',
|
engineSettingsReadonly: '編集モードでは変更できません',
|
||||||
|
engineSettingsInvalid:
|
||||||
|
'エンジン設定の検証に失敗しました、必須項目を確認してください',
|
||||||
|
retrievalSettingsInvalid:
|
||||||
|
'検索設定の検証に失敗しました、必須項目を確認してください',
|
||||||
retrievalSettings: '検索設定',
|
retrievalSettings: '検索設定',
|
||||||
retrievalSettingsDescription: 'このナレッジベースからの文書検索方法を設定',
|
retrievalSettingsDescription: 'このナレッジベースからの文書検索方法を設定',
|
||||||
dangerZone: '危険ゾーン',
|
dangerZone: '危険ゾーン',
|
||||||
@@ -1339,6 +1345,46 @@
|
|||||||
backToWorkbench: 'ワークベンチに戻る',
|
backToWorkbench: 'ワークベンチに戻る',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
feishu: {
|
||||||
|
createApp: 'ワンクリックでFeishuアプリ作成',
|
||||||
|
scanQRCode: '以下のQRコードをFeishuでスキャンし、アプリを自動作成',
|
||||||
|
waitingForScan: 'スキャン待ち',
|
||||||
|
createSuccess: 'アプリ作成成功!認証情報が自動入力されました',
|
||||||
|
createFailed: '作成失敗',
|
||||||
|
connecting: 'Feishuサービスに接続中...',
|
||||||
|
expired: 'QRコードの有効期限が切れました。もう一度お試しください',
|
||||||
|
denied: 'ユーザーが承認を拒否しました',
|
||||||
|
connectionLost: '接続が切断されました。もう一度お試しください',
|
||||||
|
reconnecting: '再接続中...',
|
||||||
|
retry: '再試行',
|
||||||
|
},
|
||||||
|
weixin: {
|
||||||
|
scanLogin: 'QRコードでWeChatログイン',
|
||||||
|
scanQRCode: '以下のQRコードをWeChatでスキャンし、トークンを自動入力',
|
||||||
|
loginSuccess: 'ログイン成功!トークンが自動入力されました',
|
||||||
|
loginFailed: 'ログイン失敗',
|
||||||
|
},
|
||||||
|
dingtalk: {
|
||||||
|
createApp: 'ワンクリックでDingTalkアプリ作成',
|
||||||
|
scanQRCode: '以下のQRコードをDingTalkでスキャンし、アプリを自動作成',
|
||||||
|
waitingForScan: 'スキャン待ち',
|
||||||
|
createSuccess: 'アプリ作成成功!認証情報が自動入力されました',
|
||||||
|
createFailed: '作成失敗',
|
||||||
|
connecting: 'DingTalkサービスに接続中...',
|
||||||
|
retry: '再試行',
|
||||||
|
robotCodeNote:
|
||||||
|
'ロボットコードは自動取得できません。DingTalk開発者バックエンド > ロボット設定から手動でコピーしてください。画像認識やファイルアップロードなどの機能に必要です。',
|
||||||
|
},
|
||||||
|
wecombot: {
|
||||||
|
createBot: 'ワンクリックでWeComボット作成',
|
||||||
|
scanQRCode: '以下のQRコードをWeComでスキャンし、ボットを自動作成',
|
||||||
|
waitingForScan: 'スキャン待ち',
|
||||||
|
createSuccess: 'ボット作成成功!認証情報が自動入力されました',
|
||||||
|
createFailed: '作成失敗',
|
||||||
|
connecting: 'WeComサービスに接続中...',
|
||||||
|
retry: '再試行',
|
||||||
|
robotNameNote: 'ロボット名は自動取得できません。手動で入力してください。',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
|
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
|
||||||
invalidPage: '無効なプラグインページ',
|
invalidPage: '無効なプラグインページ',
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ const ruRU = {
|
|||||||
success: 'Успешно',
|
success: 'Успешно',
|
||||||
save: 'Сохранить',
|
save: 'Сохранить',
|
||||||
saving: 'Сохранение...',
|
saving: 'Сохранение...',
|
||||||
|
recommend: 'Рекомендуется',
|
||||||
|
start: 'Начать',
|
||||||
confirm: 'Подтвердить',
|
confirm: 'Подтвердить',
|
||||||
confirmDelete: 'Подтвердить удаление',
|
confirmDelete: 'Подтвердить удаление',
|
||||||
deleteConfirmation: 'Вы уверены, что хотите удалить это?',
|
deleteConfirmation: 'Вы уверены, что хотите удалить это?',
|
||||||
@@ -936,6 +938,10 @@ const ruRU = {
|
|||||||
engineSettings: 'Настройки движка',
|
engineSettings: 'Настройки движка',
|
||||||
engineSettingsDescription: 'Конфигурация выбранного движка знаний',
|
engineSettingsDescription: 'Конфигурация выбранного движка знаний',
|
||||||
engineSettingsReadonly: 'только чтение в режиме редактирования',
|
engineSettingsReadonly: 'только чтение в режиме редактирования',
|
||||||
|
engineSettingsInvalid:
|
||||||
|
'Настройки движка недействительны, проверьте обязательные поля',
|
||||||
|
retrievalSettingsInvalid:
|
||||||
|
'Настройки извлечения недействительны, проверьте обязательные поля',
|
||||||
retrievalSettings: 'Настройки извлечения',
|
retrievalSettings: 'Настройки извлечения',
|
||||||
retrievalSettingsDescription:
|
retrievalSettingsDescription:
|
||||||
'Настройте способ извлечения документов из базы знаний',
|
'Настройте способ извлечения документов из базы знаний',
|
||||||
@@ -1342,6 +1348,53 @@ const ruRU = {
|
|||||||
backToWorkbench: 'Вернуться к рабочей панели',
|
backToWorkbench: 'Вернуться к рабочей панели',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
feishu: {
|
||||||
|
createApp: 'Создать приложение Feishu в один клик',
|
||||||
|
scanQRCode:
|
||||||
|
'Отсканируйте QR-код ниже в Feishu, чтобы авторизоваться и автоматически создать приложение',
|
||||||
|
waitingForScan: 'Ожидание сканирования',
|
||||||
|
createSuccess:
|
||||||
|
'Приложение успешно создано! Учётные данные заполнены автоматически',
|
||||||
|
createFailed: 'Не удалось создать приложение',
|
||||||
|
connecting: 'Подключение к сервису Feishu...',
|
||||||
|
expired: 'Срок действия QR-кода истёк. Повторите попытку',
|
||||||
|
denied: 'Пользователь отклонил авторизацию',
|
||||||
|
connectionLost: 'Соединение потеряно. Повторите попытку',
|
||||||
|
reconnecting: 'Переподключение...',
|
||||||
|
retry: 'Повторить',
|
||||||
|
},
|
||||||
|
weixin: {
|
||||||
|
scanLogin: 'Войти в WeChat по QR-коду',
|
||||||
|
scanQRCode:
|
||||||
|
'Отсканируйте QR-код ниже в WeChat, чтобы авторизоваться и автоматически заполнить токен',
|
||||||
|
loginSuccess: 'Вход выполнен успешно! Токен заполнен автоматически',
|
||||||
|
loginFailed: 'Не удалось выполнить вход',
|
||||||
|
},
|
||||||
|
dingtalk: {
|
||||||
|
createApp: 'Создать приложение DingTalk в один клик',
|
||||||
|
scanQRCode:
|
||||||
|
'Отсканируйте QR-код ниже в DingTalk, чтобы авторизоваться и автоматически создать приложение',
|
||||||
|
waitingForScan: 'Ожидание сканирования',
|
||||||
|
createSuccess:
|
||||||
|
'Приложение успешно создано! Учётные данные заполнены автоматически',
|
||||||
|
createFailed: 'Не удалось создать приложение',
|
||||||
|
connecting: 'Подключение к сервису DingTalk...',
|
||||||
|
retry: 'Повторить',
|
||||||
|
robotCodeNote:
|
||||||
|
'Код робота нельзя получить автоматически. Перейдите в консоль разработчика DingTalk > Настройки робота и скопируйте его вручную. Он нужен для таких функций, как распознавание изображений и загрузка файлов.',
|
||||||
|
},
|
||||||
|
wecombot: {
|
||||||
|
createBot: 'Создать бота WeCom в один клик',
|
||||||
|
scanQRCode:
|
||||||
|
'Отсканируйте QR-код ниже в WeCom, чтобы авторизоваться и автоматически создать бота',
|
||||||
|
waitingForScan: 'Ожидание сканирования',
|
||||||
|
createSuccess: 'Бот успешно создан! Учётные данные заполнены автоматически',
|
||||||
|
createFailed: 'Не удалось создать бота',
|
||||||
|
connecting: 'Подключение к сервису WeCom...',
|
||||||
|
retry: 'Повторить',
|
||||||
|
robotNameNote:
|
||||||
|
'Имя бота нельзя получить автоматически. Пожалуйста, введите его вручную.',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
|
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
|
||||||
invalidPage: 'Недопустимая страница плагина',
|
invalidPage: 'Недопустимая страница плагина',
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ const thTH = {
|
|||||||
success: 'สำเร็จ',
|
success: 'สำเร็จ',
|
||||||
save: 'บันทึก',
|
save: 'บันทึก',
|
||||||
saving: 'กำลังบันทึก...',
|
saving: 'กำลังบันทึก...',
|
||||||
|
recommend: 'แนะนำ',
|
||||||
|
start: 'เริ่ม',
|
||||||
confirm: 'ยืนยัน',
|
confirm: 'ยืนยัน',
|
||||||
confirmDelete: 'ยืนยันการลบ',
|
confirmDelete: 'ยืนยันการลบ',
|
||||||
deleteConfirmation: 'คุณแน่ใจหรือไม่ว่าต้องการลบสิ่งนี้?',
|
deleteConfirmation: 'คุณแน่ใจหรือไม่ว่าต้องการลบสิ่งนี้?',
|
||||||
@@ -915,6 +917,10 @@ const thTH = {
|
|||||||
engineSettings: 'การตั้งค่าเครื่องมือ',
|
engineSettings: 'การตั้งค่าเครื่องมือ',
|
||||||
engineSettingsDescription: 'การกำหนดค่าสำหรับเครื่องมือความรู้ที่เลือก',
|
engineSettingsDescription: 'การกำหนดค่าสำหรับเครื่องมือความรู้ที่เลือก',
|
||||||
engineSettingsReadonly: 'อ่านอย่างเดียวในโหมดแก้ไข',
|
engineSettingsReadonly: 'อ่านอย่างเดียวในโหมดแก้ไข',
|
||||||
|
engineSettingsInvalid:
|
||||||
|
'การตั้งค่าเครื่องมือไม่ถูกต้อง โปรดตรวจสอบฟิลด์ที่จำเป็น',
|
||||||
|
retrievalSettingsInvalid:
|
||||||
|
'การตั้งค่าการดึงข้อมูลไม่ถูกต้อง โปรดตรวจสอบฟิลด์ที่จำเป็น',
|
||||||
retrievalSettings: 'การตั้งค่าการดึงข้อมูล',
|
retrievalSettings: 'การตั้งค่าการดึงข้อมูล',
|
||||||
retrievalSettingsDescription: 'กำหนดค่าวิธีดึงเอกสารจากฐานความรู้นี้',
|
retrievalSettingsDescription: 'กำหนดค่าวิธีดึงเอกสารจากฐานความรู้นี้',
|
||||||
dangerZone: 'โซนอันตราย',
|
dangerZone: 'โซนอันตราย',
|
||||||
@@ -1311,6 +1317,50 @@ const thTH = {
|
|||||||
backToWorkbench: 'กลับไปหน้าทำงาน',
|
backToWorkbench: 'กลับไปหน้าทำงาน',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
feishu: {
|
||||||
|
createApp: 'สร้างแอป Feishu ด้วยคลิกเดียว',
|
||||||
|
scanQRCode:
|
||||||
|
'สแกนคิวอาร์โค้ดด้านล่างด้วย Feishu เพื่ออนุญาตและสร้างแอปโดยอัตโนมัติ',
|
||||||
|
waitingForScan: 'กำลังรอสแกน',
|
||||||
|
createSuccess: 'สร้างแอปสำเร็จแล้ว และกรอกข้อมูลรับรองให้อัตโนมัติ',
|
||||||
|
createFailed: 'สร้างแอปไม่สำเร็จ',
|
||||||
|
connecting: 'กำลังเชื่อมต่อบริการ Feishu...',
|
||||||
|
expired: 'คิวอาร์โค้ดหมดอายุแล้ว กรุณาลองใหม่',
|
||||||
|
denied: 'ผู้ใช้ปฏิเสธการอนุญาต',
|
||||||
|
connectionLost: 'การเชื่อมต่อขาดหาย กรุณาลองใหม่',
|
||||||
|
reconnecting: 'กำลังเชื่อมต่อใหม่...',
|
||||||
|
retry: 'ลองใหม่',
|
||||||
|
},
|
||||||
|
weixin: {
|
||||||
|
scanLogin: 'เข้าสู่ระบบ WeChat ด้วยคิวอาร์โค้ด',
|
||||||
|
scanQRCode:
|
||||||
|
'สแกนคิวอาร์โค้ดด้านล่างด้วย WeChat เพื่ออนุญาตและกรอกโทเคนอัตโนมัติ',
|
||||||
|
loginSuccess: 'เข้าสู่ระบบสำเร็จ และกรอกโทเคนอัตโนมัติแล้ว',
|
||||||
|
loginFailed: 'เข้าสู่ระบบไม่สำเร็จ',
|
||||||
|
},
|
||||||
|
dingtalk: {
|
||||||
|
createApp: 'สร้างแอป DingTalk ด้วยคลิกเดียว',
|
||||||
|
scanQRCode:
|
||||||
|
'สแกนคิวอาร์โค้ดด้านล่างด้วย DingTalk เพื่ออนุญาตและสร้างแอปโดยอัตโนมัติ',
|
||||||
|
waitingForScan: 'กำลังรอสแกน',
|
||||||
|
createSuccess: 'สร้างแอปสำเร็จแล้ว และกรอกข้อมูลรับรองให้อัตโนมัติ',
|
||||||
|
createFailed: 'สร้างแอปไม่สำเร็จ',
|
||||||
|
connecting: 'กำลังเชื่อมต่อบริการ DingTalk...',
|
||||||
|
retry: 'ลองใหม่',
|
||||||
|
robotCodeNote:
|
||||||
|
'ไม่สามารถดึงรหัส Robot ได้โดยอัตโนมัติ กรุณาไปที่หลังบ้านนักพัฒนา DingTalk > การตั้งค่า Robot เพื่อคัดลอกด้วยตนเอง ฟิลด์นี้จำเป็นสำหรับฟังก์ชันอย่างการรู้จำภาพและการอัปโหลดไฟล์',
|
||||||
|
},
|
||||||
|
wecombot: {
|
||||||
|
createBot: 'สร้างบอต WeCom ด้วยคลิกเดียว',
|
||||||
|
scanQRCode:
|
||||||
|
'สแกนคิวอาร์โค้ดด้านล่างด้วย WeCom เพื่ออนุญาตและสร้างบอตโดยอัตโนมัติ',
|
||||||
|
waitingForScan: 'กำลังรอสแกน',
|
||||||
|
createSuccess: 'สร้างบอตสำเร็จแล้ว และกรอกข้อมูลรับรองให้อัตโนมัติ',
|
||||||
|
createFailed: 'สร้างบอตไม่สำเร็จ',
|
||||||
|
connecting: 'กำลังเชื่อมต่อบริการ WeCom...',
|
||||||
|
retry: 'ลองใหม่',
|
||||||
|
robotNameNote: 'ไม่สามารถดึงชื่อบอตได้โดยอัตโนมัติ กรุณากรอกด้วยตนเอง',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
|
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
|
||||||
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
|
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ const viVN = {
|
|||||||
success: 'Thành công',
|
success: 'Thành công',
|
||||||
save: 'Lưu',
|
save: 'Lưu',
|
||||||
saving: 'Đang lưu...',
|
saving: 'Đang lưu...',
|
||||||
|
recommend: 'Đề xuất',
|
||||||
|
start: 'Bắt đầu',
|
||||||
confirm: 'Xác nhận',
|
confirm: 'Xác nhận',
|
||||||
confirmDelete: 'Xác nhận xóa',
|
confirmDelete: 'Xác nhận xóa',
|
||||||
deleteConfirmation: 'Bạn có chắc chắn muốn xóa mục này không?',
|
deleteConfirmation: 'Bạn có chắc chắn muốn xóa mục này không?',
|
||||||
@@ -928,6 +930,10 @@ const viVN = {
|
|||||||
engineSettings: 'Cài đặt công cụ',
|
engineSettings: 'Cài đặt công cụ',
|
||||||
engineSettingsDescription: 'Cấu hình cho công cụ tri thức đã chọn',
|
engineSettingsDescription: 'Cấu hình cho công cụ tri thức đã chọn',
|
||||||
engineSettingsReadonly: 'chỉ đọc trong chế độ chỉnh sửa',
|
engineSettingsReadonly: 'chỉ đọc trong chế độ chỉnh sửa',
|
||||||
|
engineSettingsInvalid:
|
||||||
|
'Cài đặt công cụ không hợp lệ, vui lòng kiểm tra các trường bắt buộc',
|
||||||
|
retrievalSettingsInvalid:
|
||||||
|
'Cài đặt truy xuất không hợp lệ, vui lòng kiểm tra các trường bắt buộc',
|
||||||
retrievalSettings: 'Cài đặt truy xuất',
|
retrievalSettings: 'Cài đặt truy xuất',
|
||||||
retrievalSettingsDescription:
|
retrievalSettingsDescription:
|
||||||
'Cấu hình cách truy xuất tài liệu từ cơ sở tri thức này',
|
'Cấu hình cách truy xuất tài liệu từ cơ sở tri thức này',
|
||||||
@@ -1333,6 +1339,52 @@ const viVN = {
|
|||||||
backToWorkbench: 'Quay lại bàn làm việc',
|
backToWorkbench: 'Quay lại bàn làm việc',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
feishu: {
|
||||||
|
createApp: 'Tạo ứng dụng Feishu chỉ với một lần nhấp',
|
||||||
|
scanQRCode:
|
||||||
|
'Quét mã QR bên dưới bằng Feishu để ủy quyền và tự động tạo ứng dụng',
|
||||||
|
waitingForScan: 'Đang chờ quét',
|
||||||
|
createSuccess:
|
||||||
|
'Tạo ứng dụng thành công! Thông tin xác thực đã được điền tự động',
|
||||||
|
createFailed: 'Tạo ứng dụng thất bại',
|
||||||
|
connecting: 'Đang kết nối tới dịch vụ Feishu...',
|
||||||
|
expired: 'Mã QR đã hết hạn, vui lòng thử lại',
|
||||||
|
denied: 'Người dùng đã từ chối ủy quyền',
|
||||||
|
connectionLost: 'Kết nối đã bị mất, vui lòng thử lại',
|
||||||
|
reconnecting: 'Đang kết nối lại...',
|
||||||
|
retry: 'Thử lại',
|
||||||
|
},
|
||||||
|
weixin: {
|
||||||
|
scanLogin: 'Đăng nhập WeChat bằng mã QR',
|
||||||
|
scanQRCode:
|
||||||
|
'Quét mã QR bên dưới bằng WeChat để ủy quyền và tự động điền token',
|
||||||
|
loginSuccess: 'Đăng nhập thành công! Token đã được điền tự động',
|
||||||
|
loginFailed: 'Đăng nhập thất bại',
|
||||||
|
},
|
||||||
|
dingtalk: {
|
||||||
|
createApp: 'Tạo ứng dụng DingTalk chỉ với một lần nhấp',
|
||||||
|
scanQRCode:
|
||||||
|
'Quét mã QR bên dưới bằng DingTalk để ủy quyền và tự động tạo ứng dụng',
|
||||||
|
waitingForScan: 'Đang chờ quét',
|
||||||
|
createSuccess:
|
||||||
|
'Tạo ứng dụng thành công! Thông tin xác thực đã được điền tự động',
|
||||||
|
createFailed: 'Tạo ứng dụng thất bại',
|
||||||
|
connecting: 'Đang kết nối tới dịch vụ DingTalk...',
|
||||||
|
retry: 'Thử lại',
|
||||||
|
robotCodeNote:
|
||||||
|
'Không thể tự động lấy Robot Code. Vui lòng vào trang quản trị nhà phát triển DingTalk > Cấu hình robot để sao chép thủ công. Trường này là bắt buộc cho các tính năng như nhận diện hình ảnh và tải tệp lên.',
|
||||||
|
},
|
||||||
|
wecombot: {
|
||||||
|
createBot: 'Tạo bot WeCom chỉ với một lần nhấp',
|
||||||
|
scanQRCode: 'Quét mã QR bên dưới bằng WeCom để ủy quyền và tự động tạo bot',
|
||||||
|
waitingForScan: 'Đang chờ quét',
|
||||||
|
createSuccess:
|
||||||
|
'Tạo bot thành công! Thông tin xác thực đã được điền tự động',
|
||||||
|
createFailed: 'Tạo bot thất bại',
|
||||||
|
connecting: 'Đang kết nối tới dịch vụ WeCom...',
|
||||||
|
retry: 'Thử lại',
|
||||||
|
robotNameNote: 'Không thể tự động lấy tên bot. Vui lòng điền thủ công.',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
|
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
|
||||||
invalidPage: 'Trang plugin không hợp lệ',
|
invalidPage: 'Trang plugin không hợp lệ',
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ const zhHans = {
|
|||||||
success: '成功',
|
success: '成功',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
saving: '保存中...',
|
saving: '保存中...',
|
||||||
|
recommend: '推荐',
|
||||||
|
start: '开始',
|
||||||
confirm: '确认',
|
confirm: '确认',
|
||||||
confirmDelete: '确认删除',
|
confirmDelete: '确认删除',
|
||||||
deleteConfirmation: '你确定要删除这个吗?',
|
deleteConfirmation: '你确定要删除这个吗?',
|
||||||
@@ -886,6 +888,8 @@ const zhHans = {
|
|||||||
engineSettings: '引擎设置',
|
engineSettings: '引擎设置',
|
||||||
engineSettingsDescription: '所选知识引擎的配置',
|
engineSettingsDescription: '所选知识引擎的配置',
|
||||||
engineSettingsReadonly: '编辑模式下不可修改',
|
engineSettingsReadonly: '编辑模式下不可修改',
|
||||||
|
engineSettingsInvalid: '引擎设置中存在无效项,请检查必填字段',
|
||||||
|
retrievalSettingsInvalid: '检索设置中存在无效项,请检查必填字段',
|
||||||
retrievalSettings: '检索设置',
|
retrievalSettings: '检索设置',
|
||||||
retrievalSettingsDescription: '配置从此知识库检索文档的方式',
|
retrievalSettingsDescription: '配置从此知识库检索文档的方式',
|
||||||
dangerZone: '危险区域',
|
dangerZone: '危险区域',
|
||||||
@@ -1274,6 +1278,47 @@ const zhHans = {
|
|||||||
backToWorkbench: '返回工作台',
|
backToWorkbench: '返回工作台',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
feishu: {
|
||||||
|
createApp: '一键创建飞书应用',
|
||||||
|
scanQRCode: '请使用飞书扫描以下二维码,授权后将自动创建应用并填写凭据',
|
||||||
|
waitingForScan: '等待扫码中',
|
||||||
|
createSuccess: '应用创建成功!凭据已自动填入',
|
||||||
|
createFailed: '创建失败',
|
||||||
|
connecting: '正在连接飞书服务...',
|
||||||
|
expired: '二维码已过期,请重试',
|
||||||
|
denied: '用户已拒绝授权',
|
||||||
|
connectionLost: '连接已断开,请重试',
|
||||||
|
reconnecting: '正在重新连接...',
|
||||||
|
retry: '重试',
|
||||||
|
},
|
||||||
|
weixin: {
|
||||||
|
scanLogin: '扫码登录微信',
|
||||||
|
scanQRCode: '请使用微信扫描以下二维码,授权后将自动登录并填写令牌',
|
||||||
|
loginSuccess: '登录成功!令牌已自动填入',
|
||||||
|
loginFailed: '登录失败',
|
||||||
|
},
|
||||||
|
dingtalk: {
|
||||||
|
createApp: '一键创建钉钉应用',
|
||||||
|
scanQRCode: '请使用钉钉扫描以下二维码,授权后将自动创建应用并填写凭据',
|
||||||
|
waitingForScan: '等待扫码中',
|
||||||
|
createSuccess: '应用创建成功!凭据已自动填入',
|
||||||
|
createFailed: '创建失败',
|
||||||
|
connecting: '正在连接钉钉服务...',
|
||||||
|
retry: '重试',
|
||||||
|
robotCodeNote:
|
||||||
|
'机器人代码无法自动获取,请前往钉钉开发者后台 > 机器人配置中手动复制。识图、上传文件等功能需要填写此字段。',
|
||||||
|
},
|
||||||
|
wecombot: {
|
||||||
|
createBot: '一键创建企业微信机器人',
|
||||||
|
scanQRCode:
|
||||||
|
'请使用企业微信扫描以下二维码,授权后将自动创建机器人并填写凭据',
|
||||||
|
waitingForScan: '等待扫码中',
|
||||||
|
createSuccess: '机器人创建成功!凭据已自动填入',
|
||||||
|
createFailed: '创建失败',
|
||||||
|
connecting: '正在连接企业微信服务...',
|
||||||
|
retry: '重试',
|
||||||
|
robotNameNote: '机器人名称无法自动获取,请手动填写。',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: '从侧边栏选择一个插件页面',
|
selectFromSidebar: '从侧边栏选择一个插件页面',
|
||||||
invalidPage: '无效的插件页面',
|
invalidPage: '无效的插件页面',
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ const zhHant = {
|
|||||||
success: '成功',
|
success: '成功',
|
||||||
save: '儲存',
|
save: '儲存',
|
||||||
saving: '儲存中...',
|
saving: '儲存中...',
|
||||||
|
recommend: '推薦',
|
||||||
|
start: '開始',
|
||||||
confirm: '確認',
|
confirm: '確認',
|
||||||
confirmDelete: '確認刪除',
|
confirmDelete: '確認刪除',
|
||||||
deleteConfirmation: '您確定要刪除這個嗎?',
|
deleteConfirmation: '您確定要刪除這個嗎?',
|
||||||
@@ -880,6 +882,8 @@ const zhHant = {
|
|||||||
engineSettings: '引擎設定',
|
engineSettings: '引擎設定',
|
||||||
engineSettingsDescription: '所選知識引擎的設定',
|
engineSettingsDescription: '所選知識引擎的設定',
|
||||||
engineSettingsReadonly: '編輯模式下不可修改',
|
engineSettingsReadonly: '編輯模式下不可修改',
|
||||||
|
engineSettingsInvalid: '引擎設定中存在無效項,請檢查必填欄位',
|
||||||
|
retrievalSettingsInvalid: '檢索設定中存在無效項,請檢查必填欄位',
|
||||||
retrievalSettings: '檢索設定',
|
retrievalSettings: '檢索設定',
|
||||||
retrievalSettingsDescription: '設定從此知識庫檢索文件的方式',
|
retrievalSettingsDescription: '設定從此知識庫檢索文件的方式',
|
||||||
dangerZone: '危險區域',
|
dangerZone: '危險區域',
|
||||||
@@ -1274,6 +1278,47 @@ const zhHant = {
|
|||||||
backToWorkbench: '返回工作台',
|
backToWorkbench: '返回工作台',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
feishu: {
|
||||||
|
createApp: '一鍵建立飛書應用',
|
||||||
|
scanQRCode: '請使用飛書掃描以下 QR Code,授權後將自動建立應用並填寫憑證',
|
||||||
|
waitingForScan: '等待掃描中',
|
||||||
|
createSuccess: '應用建立成功!憑證已自動填入',
|
||||||
|
createFailed: '建立失敗',
|
||||||
|
connecting: '正在連線飛書服務...',
|
||||||
|
expired: 'QR Code 已過期,請重試',
|
||||||
|
denied: '使用者已拒絕授權',
|
||||||
|
connectionLost: '連線已斷開,請重試',
|
||||||
|
reconnecting: '正在重新連線...',
|
||||||
|
retry: '重試',
|
||||||
|
},
|
||||||
|
weixin: {
|
||||||
|
scanLogin: '掃碼登入微信',
|
||||||
|
scanQRCode: '請使用微信掃描以下 QR Code,授權後將自動登入並填寫令牌',
|
||||||
|
loginSuccess: '登入成功!令牌已自動填入',
|
||||||
|
loginFailed: '登入失敗',
|
||||||
|
},
|
||||||
|
dingtalk: {
|
||||||
|
createApp: '一鍵建立釘釘應用',
|
||||||
|
scanQRCode: '請使用釘釘掃描以下 QR Code,授權後將自動建立應用並填寫憑證',
|
||||||
|
waitingForScan: '等待掃碼中',
|
||||||
|
createSuccess: '應用建立成功!憑證已自動填入',
|
||||||
|
createFailed: '建立失敗',
|
||||||
|
connecting: '正在連線釘釘服務...',
|
||||||
|
retry: '重試',
|
||||||
|
robotCodeNote:
|
||||||
|
'機器人代碼無法自動取得,請前往釘釘開發者後台 > 機器人設定中手動複製。識圖、上傳檔案等功能需要填寫此欄位。',
|
||||||
|
},
|
||||||
|
wecombot: {
|
||||||
|
createBot: '一鍵建立企業微信機器人',
|
||||||
|
scanQRCode:
|
||||||
|
'請使用企業微信掃描以下 QR Code,授權後將自動建立機器人並填寫憑證',
|
||||||
|
waitingForScan: '等待掃碼中',
|
||||||
|
createSuccess: '機器人建立成功!憑證已自動填入',
|
||||||
|
createFailed: '建立失敗',
|
||||||
|
connecting: '正在連線企業微信服務...',
|
||||||
|
retry: '重試',
|
||||||
|
robotNameNote: '機器人名稱無法自動取得,請手動填寫。',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: '從側邊欄選擇一個插件頁面',
|
selectFromSidebar: '從側邊欄選擇一個插件頁面',
|
||||||
invalidPage: '無效的插件頁面',
|
invalidPage: '無效的插件頁面',
|
||||||
|
|||||||
Reference in New Issue
Block a user