diff --git a/README.md b/README.md index 90d4788..546e0e8 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ To use an existing database, set `DATABASE_URL` in `.env` and skip the Docker st - **Hosted MCP connectors (24):** click **Enable** in the console. Connany registers an OAuth client with the provider (RFC 7591), using a public client with PKCE or a confidential client as the provider requires. - **GitHub:** create a GitHub App and enter its Client ID, Client Secret and App slug in the console. Users also install the App and choose repositories. -The list is also available to agents through `GET /v1/connectors` (enabled connectors with localized categories and descriptions, `lang=en|zh-CN`). +The list is also available to agents through `GET /v1/connectors` (enabled connectors with localized categories and descriptions, `lang=en|zh-CN|zh-HK`). After enabling a connector, click **Sync tool catalog** on its card once: upstream MCP servers list tools only to signed-in users, so you authorize with your own account and Connany keeps only the tool definitions. Users must still authorize their own accounts. GitHub repository access also requires installing the App and selecting repositories. See the [connector setup guide (Chinese)](docs/connector-setup.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 2cc6ea7..3bcd845 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -58,7 +58,7 @@ npm run dev # 或:make dev - **托管 MCP 连接器(24 个):** 后台点「启用」即可。Connany 按平台要求自动注册 OAuth 客户端(RFC 7591),使用公开客户端 + PKCE 或带密钥的机密客户端。 - **GitHub:** 创建 GitHub App,在后台填写 Client ID、Client Secret 和 App slug;用户还需安装 App 并选择仓库。 -Agent 也可以通过 `GET /v1/connectors` 获取已启用的连接器,包含多语言的分类和说明(`lang=en|zh-CN`)。 +Agent 也可以通过 `GET /v1/connectors` 获取已启用的连接器,包含多语言的分类和说明(`lang=en|zh-CN|zh-HK`)。 启用连接器后,在卡片上点一次「同步工具目录」:上游 MCP 只向已登录用户提供工具列表,所以用你自己的账号授权一次,Connany 只保存工具定义。用户仍需授权自己的账号;GitHub 仓库访问还需要安装 App 并选择仓库。详见 [连接器配置](docs/connector-setup.md)。 diff --git a/docs/agent-integration.md b/docs/agent-integration.md index d370854..e695b2e 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -64,7 +64,7 @@ const { categories, data: connectors } = await connany.connectors({ lang: 'zh-CN // [{ name: 'notion', title: 'Notion', description: '页面、数据库与工作区搜索', avatar_url: 'https://…/connectors/notion/avatar.svg', tools_synced_at: … }, …] ``` -对应 `GET /v1/connectors?lang=zh-CN`,只返回管理员已启用的连接器。`categories` 是这些连接器所属的分类(按推荐顺序),可以用来分组展示;分类名称和连接器描述按 `lang` 返回中文或英文,不传时按 `Accept-Language`,默认英文。用 `title`、`description` 和 `avatar_url` 渲染「连接账号」入口(`avatar_url` 可直接放进 ``)。如果想在用户连接之前介绍某个连接器能做什么,可以读工具目录 `connany.toolCatalog({ connector: 'notion' })`(`GET /v1/tools?connector=notion`)。 +对应 `GET /v1/connectors?lang=zh-CN`,只返回管理员已启用的连接器。`categories` 是这些连接器所属的分类(按推荐顺序),可以用来分组展示;分类名称和连接器描述按 `lang` 返回英文(`en`)、简体中文(`zh-CN`)或香港繁体中文(`zh-HK`),不传时按 `Accept-Language`,默认英文。用 `title`、`description` 和 `avatar_url` 渲染「连接账号」入口(`avatar_url` 可直接放进 ``)。如果想在用户连接之前介绍某个连接器能做什么,可以读工具目录 `connany.toolCatalog({ connector: 'notion' })`(`GET /v1/tools?connector=notion`)。 ### 2.2 创建授权会话,把链接交给用户 diff --git a/docs/api.en.md b/docs/api.en.md index 35bd8b5..dffab2a 100644 --- a/docs/api.en.md +++ b/docs/api.en.md @@ -47,7 +47,7 @@ Returns the connectors an administrator has enabled, with their categories, read | Query parameter | Type | Description | | --- | --- | --- | -| lang | string, optional | Language of returned text: en or zh-CN. Without it the Accept-Language header decides; English otherwise | +| lang | string, optional | Language of returned text: en, zh-CN or zh-HK (Traditional Chinese, Hong Kong). Without it the Accept-Language header decides: zh-TW, zh-MO and zh-Hant return zh-HK, other Chinese tags return zh-CN; English otherwise | Request example: diff --git a/docs/api.md b/docs/api.md index e7b7084..0f7c9e9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -47,7 +47,7 @@ JSON 请求使用 Content-Type: application/json,请求体上限 32 KB。每 | Query 参数 | 类型 | 说明 | | --- | --- | --- | -| lang | string,可选 | 返回文字的语言:en 或 zh-CN。不传时按请求头 Accept-Language 选择,都没有时为英文 | +| lang | string,可选 | 返回文字的语言:en、zh-CN 或 zh-HK(香港繁体中文)。不传时按请求头 Accept-Language 选择:zh-TW、zh-MO、zh-Hant 返回 zh-HK,其他中文返回 zh-CN,都没有时为英文 | 请求示例: diff --git a/package-lock.json b/package-lock.json index e065ccc..233f408 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,11 @@ "": { "name": "connany", "version": "0.1.0", + "license": "MIT", "dependencies": { "@hono/node-server": "^2.1.1", "hono": "^4.13.9", + "opencc-js": "^1.4.2", "pg": "^8.23.0", "zod": "^4.6.5" }, @@ -22,8 +24,7 @@ }, "engines": { "node": ">=22" - }, - "license": "MIT" + } }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", @@ -907,6 +908,12 @@ "node": ">=16.9.0" } }, + "node_modules/opencc-js": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.4.2.tgz", + "integrity": "sha512-EdcspgdROiCCZDM+jZR6Pa670xW+EW2QhctPBQ8gRjg6lrx4IHEl+2s+kt5izk/TkxVRgdWuFgaGkaEdIUfdaw==", + "license": "MIT AND Apache-2.0" + }, "node_modules/pg": { "version": "8.23.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", diff --git a/package.json b/package.json index 552eeea..14dab08 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "dependencies": { "@hono/node-server": "^2.1.1", "hono": "^4.13.9", + "opencc-js": "^1.4.2", "pg": "^8.23.0", "zod": "^4.6.5" }, diff --git a/public/admin-i18n.js b/public/admin-i18n.js index c19fb31..52ea5c2 100644 --- a/public/admin-i18n.js +++ b/public/admin-i18n.js @@ -15,46 +15,67 @@ '当前密码不正确。':'The current password is incorrect.','两次输入的新密码不一致。':'The new passwords do not match.','新密码不能与当前密码相同。':'Choose a password different from your current password.','尝试过多,请在 15 分钟后重试。':'Too many attempts. Try again in 15 minutes.','登录尝试过多,请在 15 分钟后重试。':'Too many sign-in attempts. Try again in 15 minutes.','邮箱或密码不正确。':'Incorrect email or password.','请登录管理员账号。':'Sign in with an administrator account.','页面已失效,请刷新后重试。':'This page has expired. Refresh and try again.','管理操作必须从本站页面发起。':'Administrator actions must originate from this site.','请使用 JSON 请求。':'Send a JSON request.','API Key 接入记录不存在。':'API key integration not found.','连接不存在。':'Connection not found.', '操作失败,请重试。':'Action failed. Please try again.','操作已完成':'Action completed','网络异常,请重试。':'Network error. Please try again.','请求失败':'Request failed','请求失败,请重试。':'Request failed. Please try again.','未知错误':'Unknown error','Connany 已断开连接,但平台撤销失败,可重试撤销。':'Disconnected in Connany, but upstream revocation failed. You can retry revocation.','正在验证API Key并创建会话…':'Validating API key and creating session…','授权链接已生成,15 分钟内有效。打开授权页面,完成后回来检查结果。':'Link created, valid for 15 minutes. Open it, authorize, then return to check the result.','正在查询授权结果…':'Checking authorization…','等待打开授权页面。':'Waiting for the authorization page to open.','等待完成平台授权;完成后再次检查。':'Waiting for upstream authorization. Check again when finished.','正在处理授权,请稍后再次检查。':'Processing authorization. Check again shortly.','授权成功,可以试读数据。':'Authorized. You can now test a read.','链接已过期,请重新创建授权链接。':'Link expired. Create a new authorization link.','正在读取已授权的数据…':'Reading authorized data…','添加组织 / 仓库 ↗':'Add organization / repositories ↗','管理仓库权限 ↗':'Manage repository access ↗','试读此组织仓库':'List repositories in this organization','仓库读取成功。':'Repositories retrieved.','已获取组织列表,请选择组织试读。管理入口可能需要组织管理员权限。':'Organizations retrieved. Choose one to list repositories. Management may require organization administrator access.','账号已连接,尚未添加仓库。可以点击添加组织 / 仓库,完成后再次试读刷新列表。':'Account connected, but no repositories added. Add an organization or repositories, then repeat the read.','读取成功。空列表表示当前授权范围内没有可见数据。':'Read succeeded. An empty list means no visible data within the current permissions.' }; + // Hong Kong Traditional Chinese. Strings that read the same in both scripts are left out. + const hongKong = { + '总览':'總覽','协作与办公':'協作與辦公','代码与部署':'程式碼與部署','数据库':'數據庫','监控与分析':'監控與分析','设计与建站':'設計與建站','全部分类':'全部分類','启用状态':'啟用狀態','连接器分类':'連接器分類','搜索连接器':'搜尋連接器','没有匹配的连接器':'沒有匹配的連接器','连接器配置一次,工作空间里的所有项目共用。':'連接器配置一次,工作空間裡的所有項目共用。','错误监控、Issue 与性能追踪':'錯誤監控、Issue 與效能追蹤','产品分析、功能开关与会话回放':'產品分析、功能開關與會話回放','Jira 与 Confluence 的事项、页面与项目':'Jira 與 Confluence 的事項、頁面與項目','项目、部署与日志':'項目、部署與日誌','数据库、项目与 Edge Functions':'數據庫、項目與 Edge Functions','Serverless Postgres 数据库与分支':'Serverless Postgres 數據庫與分支','站点、部署与环境变量':'站點、部署與環境變數','代码仓库、Issue 与合并请求':'程式碼倉庫、Issue 與合併請求','Workers、KV、R2 与 D1':'Workers、KV、R2 與 D1','Prisma Postgres 数据库与迁移':'Prisma Postgres 數據庫與遷移','支付、客户、订阅与账单':'支付、客戶、訂閱與帳單','订单、发票与交易':'訂單、發票與交易','支付、订单、商品与客户':'支付、訂單、商品與客戶','任务、列表与文档':'任務、列表與文件','看板、项目与工作流':'看板、項目與工作流','数据表、记录与工作区':'數據表、記錄與工作區','任务、项目与提醒':'任務、項目與提醒','白板、便签与图表':'白板、便籤與圖表','设计、模板与素材':'設計、模板與素材','客服会话、联系人与工单':'客服會話、聯絡人與工單','站点、CMS 集合与页面':'站點、CMS 集合與頁面','站点、商店与预约':'站點、商店與預約','注册':'註冊','还没有账号?':'還沒有帳戶?','已有账号?':'已有帳戶?','确认密码':'確認密碼','至少 8 个字符。':'至少 8 個字元。','注册并进入工作台 →':'註冊並進入工作台 →','开放注册':'開放註冊','开启后,登录页显示「注册」入口,任何人都可以注册普通用户账号,各自拥有独立的工作空间。':'開啟後,登入頁顯示「註冊」入口,任何人都可以註冊普通用戶帳戶,各自擁有獨立的工作空間。','当前未开放注册,请联系管理员创建账号。':'目前未開放註冊,請聯絡管理員建立帳戶。','注册请求过多,请在 15 分钟后重试。':'註冊請求過多,請在 15 分鐘後重試。','该邮箱已注册,请直接登录。':'該電郵已註冊,請直接登入。','两次输入的密码不一致。':'兩次輸入的密碼不一致。','登录并继续 →':'登入並繼續 →','注册并继续 →':'註冊並繼續 →','授权访问':'授權訪問','请求访问你的 Connany 账号':'請求訪問你的 Connany 帳戶','查看可用的连接器和你已连接的账号':'查看可用的連接器和你已連接的帳戶','生成连接链接,由你在浏览器中授权新账号':'生成連接連結,由你在瀏覽器中授權新帳戶','以你已连接的账号调用工具(会修改数据的操作由 agent 先向你确认)':'以你已連接的帳戶調用工具(會修改數據的操作由 agent 先向你確認)','授权后返回':'授權後返回','。可随时在「MCP 接入」中撤销。':'。可隨時在「MCP 接入」中撤銷。','拒绝':'拒絕','允许':'允許','无法授权':'無法授權','授权请求缺少 client_id 或 redirect_uri。':'授權請求缺少 client_id 或 redirect_uri。','未知的客户端,请在 agent 中重新连接 Connany。':'未知的客戶端,請在 agent 中重新連接 Connany。','回调地址与客户端注册的不一致。':'回調地址與客戶端註冊的不一致。','请回到 agent 重新发起连接。':'請回到 agent 重新發起連接。','授权不存在。':'授權不存在。','在 Claude Code、Codex 等 agent 中添加 Connany,即可用你自己的账号使用所有连接器。':'在 Claude Code、Codex 等 agent 中新增 Connany,即可用你自己的帳戶使用所有連接器。','1. 添加 MCP 服务':'1. 新增 MCP 服務','首次使用时,agent 会打开浏览器让你登录 Connany 并授权。':'首次使用時,agent 會開啟瀏覽器讓你登入 Connany 並授權。','添加后在 Claude Code 中运行 /mcp,选择 connany 完成登录。':'新增後在 Claude Code 中執行 /mcp,選擇 connany 完成登入。','其他客户端(Cursor 等)':'其他客戶端(Cursor 等)','2. 安装 Skill(推荐)':'2. 安裝 Skill(推薦)','Skill 告诉 agent 何时使用 Connany、如何引导你连接账号、调用前如何确认,回答更稳定。':'Skill 告訴 agent 何時使用 Connany、如何引導你連接帳戶、調用前如何確認,回答更穩定。','3. 在对话中使用':'3. 在對話中使用','例如「看看我 Linear 里这周的 issue」「查一下 PostHog 昨天的活跃用户」。未连接的服务,agent 会给出连接链接;连接后的账号显示在「用户连接」的「个人 MCP」项目下。':'例如「看看我 Linear 裡這週的 issue」「查一下 PostHog 昨天的活躍用戶」。未連接的服務,agent 會給出連接連結;連接後的帳戶顯示在「用戶連接」的「個人 MCP」項目下。','当前可连接:':'目前可連接:','暂无可用连接器,请先在「连接器」中启用。':'暫無可用連接器,請先在「連接器」中啟用。','已授权的客户端':'已授權的客戶端','客户端':'客戶端','授权时间(UTC+8)':'授權時間(UTC+8)','撤销':'撤銷','已撤销':'已撤銷','还没有客户端授权。按上面的步骤在 agent 中添加 Connany。':'還沒有客戶端授權。按上面的步驟在 agent 中新增 Connany。','该客户端将无法再通过 Connany 使用你的连接,需要重新登录授权。你已连接的账号不受影响。':'該客戶端將無法再透過 Connany 使用你的連接,需要重新登入授權。你已連接的帳戶不受影響。','个人 MCP':'個人 MCP','授权应用':'授權應用','通过 MCP 使用你的 Connany 账号的客户端,如 Claude Code、Codex。撤销后需重新登录授权,你已连接的账号不受影响。':'透過 MCP 使用你的 Connany 帳戶的客戶端,如 Claude Code、Codex。撤銷後需重新登入授權,你已連接的帳戶不受影響。','还没有客户端授权。在 Claude Code、Codex 等 agent 中添加 Connany 后会显示在这里。':'還沒有客戶端授權。在 Claude Code、Codex 等 agent 中新增 Connany 後會顯示在這裡。','MCP 接入文档 ↗':'MCP 接入文件 ↗','登录':'登入','系统':'系統','用户管理':'用戶管理','系统管理员':'系統管理員','普通用户':'普通用戶','重置密码':'重置密碼','当前账号':'目前帳戶','添加用户':'新增用戶','用户已添加':'用戶已新增','添加':'新增','初始密码':'初始密碼','最近登录(UTC+8)':'最近登入(UTC+8)','密码已重置':'密碼已重置','系统管理员可以管理所有用户;普通用户可以使用工作台,看不到系统设置。':'系統管理員可以管理所有用戶;普通用戶可以使用工作台,看不到系統設定。','8–256 个字符。保存后该用户的所有登录会话都会失效,需要用新密码重新登录。':'8–256 個字元。儲存後該用戶的所有登入會話都會失效,需要用新密碼重新登入。','8–256 个字符。请通过安全渠道告知对方,对方可登录后在「设置 → 账户」中修改。':'8–256 個字元。請透過安全渠道告知對方,對方可登入後在「設定 → 帳戶」中修改。','将同时删除该用户工作空间中的所有数据:项目、API Key、连接器配置和用户连接(会尝试撤销平台授权),无法恢复。操作记录会保留。':'將同時刪除該用戶工作空間中的所有數據:項目、API Key、連接器配置和用戶連接(會嘗試撤銷平台授權),無法恢復。操作記錄會保留。','不能删除自己的账号。':'不能刪除自己的帳戶。','至少需要保留一个系统管理员。':'至少需要保留一個系統管理員。','该邮箱已存在。':'該電郵已存在。','用户不存在。':'用戶不存在。','需要系统管理员权限。':'需要系統管理員權限。','工具目录':'工具目錄','启用后可同步':'啟用後可同步','文档':'文件','下载 Markdown':'下載 Markdown','文档类型':'文件類型','文档目录':'文件目錄','把「复制给开发 Agent」的内容交给负责开发的 agent,它包含服务地址、实现要求和下面的完整指南。API Key 请通过后端环境变量另行配置。':'把「複製給開發 Agent」的內容交給負責開發的 agent,它包含服務地址、實現要求和下面的完整指南。API Key 請透過後端環境變數另行配置。','对话中调用':'對話中調用',' 列出该连接可用的工具交给模型,再用 ':' 列出該連接可用的工具交給模型,再用 ','列出该连接可用的工具交给模型,再用':'列出該連接可用的工具交給模型,再用',' 执行模型选择的工具。':' 執行模型選擇的工具。','执行模型选择的工具。':'執行模型選擇的工具。','工具目录:尚未同步':'工具目錄:尚未同步','同步工具目录':'同步工具目錄','去授权':'去授權','项目':'項目','连接器凭证集中配置;每个项目独立接入,每位用户单独授权。':'連接器憑證集中配置;每個項目獨立接入,每位用戶單獨授權。','多个项目':'多個項目','连接器配置一次,所有项目共用;不同项目的用户与连接相互隔离。':'連接器配置一次,所有項目共用;不同項目的用戶與連接相互隔離。','已启用连接器':'已啟用連接器','已启用项目':'已啟用項目','创建项目':'建立項目','为每个 agent 产品创建项目,获取它的 API Key。':'為每個 agent 產品建立項目,取得它的 API Key。','提供服务地址、项目的 API Key 和文档,完成一次真实账号授权。':'提供服務地址、項目的 API Key 和文件,完成一次真實帳戶授權。','每个项目对应一个接入 Connany 的 agent 产品,拥有独立的 API Key 和用户连接。':'每個項目對應一個接入 Connany 的 agent 產品,擁有獨立的 API Key 和用戶連接。','暂无项目':'暫無項目','创建后会生成第一个 API Key,仅显示一次。':'建立後會生成第一個 API Key,僅顯示一次。','名称 / 前缀':'名稱 / 前綴','创建时间(UTC+8)':'建立時間(UTC+8)','最后使用(UTC+8)':'最後使用(UTC+8)','吊销':'吊銷','已吊销':'已吊銷','轮换方式:新建 API Key,部署到 agent 后端后,再吊销旧 Key,期间两把 Key 同时有效。':'輪換方式:新建 API Key,部署到 agent 後端後,再吊銷舊 Key,期間兩把 Key 同時有效。','危险操作':'危險操作','删除项目':'刪除項目','备注名(可选)':'備註名(可選)','例如 production、staging,便于区分。':'例如 production、staging,便於區分。','集中管理连接器凭证,为每个项目分发独立密钥,让用户授权自己的账号。':'集中管理連接器憑證,為每個項目分發獨立密鑰,讓用戶授權自己的帳戶。','查看各个项目下用户授权的账号。断开连接会立即阻止 Connany 后续调用。':'查看各個項目下用戶授權的帳戶。斷開連接會立即阻止 Connany 後續調用。','全部连接器':'全部連接器','全部项目':'全部項目','项目 / 用户':'項目 / 用戶','连接器':'連接器','暂无用户连接。把项目的 API Key 交给 agent,完成一次用户授权后会显示在这里。':'暫無用戶連接。把項目的 API Key 交給 agent,完成一次用戶授權後會顯示在這裡。','使用项目的 API Key 验证授权与只读调用。建议创建测试专用项目,避免混入正式用户数据。':'使用項目的 API Key 驗證授權與唯讀調用。建議建立測試專用項目,避免混入正式用戶數據。','测试连接器':'測試連接器','在「项目」中创建测试项目并保存 API Key。':'在「項目」中建立測試項目並儲存 API Key。','创建测试项目':'建立測試項目','吊销此 API Key?':'吊銷此 API Key?','这是最后一个有效 API Key,吊销后此项目将无法调用 Connany。':'這是最後一個有效 API Key,吊銷後此項目將無法調用 Connany。','使用此 Key 的请求会立即失败,请确认 agent 已切换到新 Key。':'使用此 Key 的請求會立即失敗,請確認 agent 已切換到新 Key。','停用后此项目的所有 API Key 都无法调用 Connany,用户连接保留。':'停用後此項目的所有 API Key 都無法調用 Connany,用戶連接保留。','在「项目」中为 agent 创建项目,安全发送生成的 API Key 给对应开发者。密钥只放在 agent 后端。':'在「項目」中為 agent 建立項目,安全發送生成的 API Key 給對應開發者。密鑰只放在 agent 後端。',',传入已认证用户的 external_user_id。':',傳入已認證用戶的 external_user_id。','同一套连接器可供多个项目使用。每个项目的 API Key 只能访问本项目的用户连接。':'同一套連接器可供多個項目使用。每個項目的 API Key 只能訪問本項目的用戶連接。','API 文档':'API 文件','连接器':'連接器','管理导航':'管理導航','设置':'設定','退出登录':'登出','打开账号菜单':'開啟帳戶選單','用户':'用戶','账户':'帳戶','关于':'關於','设置分类':'設定分類','关闭设置':'關閉設定', + '管理你的管理员账号和登录密码。':'管理你的管理員帳戶和登入密碼。','管理员账号':'管理員帳戶','修改密码':'修改密碼','保存后会退出所有登录会话,请使用新密码重新登录。':'儲存後會退出所有登入會話,請使用新密碼重新登入。','当前密码':'目前密碼','新密码':'新密碼','确认新密码':'確認新密碼','8–256 个字符。':'8–256 個字元。','保存密码':'儲存密碼', + '外观与当前设备偏好设置。':'外觀與目前裝置偏好設定。','主题':'主題','选择控制台配色。跟随系统会使用操作系统设置。':'選擇控制台配色。跟隨系統會使用作業系統設定。','浅色':'淺色','跟随系统':'跟隨系統','界面语言':'介面語言','选择控制台使用的界面语言。':'選擇控制台使用的介面語言。','Connany · Agent 连接器服务':'Connany · Agent 連接器服務','连接工具与用户数据':'連接工具與用戶數據','统一管理账号授权、连接与凭证,让 Agent 在用户授权范围内使用工具。':'統一管理帳戶授權、連接與憑證,讓 Agent 在用戶授權範圍內使用工具。','Agent 接入文档 ↗':'Agent 接入文件 ↗', + '确定':'確定','确认操作':'確認操作','仅显示一次,请立即保存。':'僅顯示一次,請立即儲存。','复制':'複製','我已保存':'我已儲存','已复制':'已複製','已选中文本,请手动复制':'已選取文字,請手動複製','已保存':'已儲存','保存':'儲存','创建':'建立','名称':'名稱','状态':'狀態','用户连接':'用戶連接','编辑':'編輯','轮换':'輪換','启用':'啟用','删除':'刪除','暂停':'暫停','官网 ↗':'官網 ↗','未启用':'未啟用','已启用':'已啟用','已暂停':'已暫停','已删除':'已刪除','创建 API Key':'建立 API Key','暂无 API Key':'暫無 API Key','下一页 →':'下一頁 →','旧版密钥':'舊版密鑰', + '旧 key 会立即失效,需同步更新 Agent 后端。':'舊 key 會立即失效,需同步更新 Agent 後端。','停用后此 key 无法调用 Connany,用户连接保留。':'停用後此 key 無法調用 Connany,用戶連接保留。', + '服务总览':'服務總覽','连接,从这里开始。':'連接,從這裡開始。','共享连接器':'共享連接器','独立 API Key':'獨立 API Key','一次配置,所有接入共用。不同 API Key 的接入空间与用户数据相互隔离。':'一次配置,所有接入共用。不同 API Key 的接入空間與用戶數據相互隔離。','已启用平台':'已啟用平台','已授权用户连接':'已授權用戶連接','开始接入':'開始接入','从配置到调用':'從配置到調用','启用连接器':'啟用連接器','启用官方 MCP,配置 GitHub App 和授权回调。':'啟用官方 MCP,配置 GitHub App 和授權回調。','管理连接器':'管理連接器','启用第一个连接器':'啟用第一個連接器','为每个产品生成独立 API key,并登记授权返回地址。':'為每個產品生成獨立 API key,並登記授權返回地址。','交给 Agent 接入':'交給 Agent 接入','提供服务地址、API Key和文档,完成一次真实账号授权。':'提供服務地址、API Key和文件,完成一次真實帳戶授權。','查看接入说明 ↗':'查看接入說明 ↗', + '一个连接服务,多个 Agent':'一個連接服務,多個 Agent','连接每个 agent。':'連接每個 agent。','集中管理平台凭证,为不同 agent 分发独立密钥,让用户授权自己的工作空间。':'集中管理平台憑證,為不同 agent 分發獨立密鑰,讓用戶授權自己的工作空間。','管理员登录':'管理員登入','邮箱':'電郵','密码':'密碼','进入工作台 →':'進入工作台 →','首次使用或忘记密码?':'首次使用或忘記密碼?','在服务端运行以下命令创建管理员或重置密码:':'在服務端執行以下命令建立管理員或重置密碼:','密码至少 8 位。重置后其他登录会话会失效。':'密碼至少 8 位。重置後其他登入會話會失效。', + '已保存,留空不变':'已儲存,留空不變','创建 GitHub App ↗':'建立 GitHub App ↗','管理已有应用 ↗':'管理已有應用 ↗','填写应用地址最后一段,例如 my-app':'填寫應用地址最後一段,例如 my-app','填写 GitHub App 公开地址的最后一段。例如地址为 github.com/apps/my-app,则填写 my-app。用于生成安装链接,不是 App ID。':'填寫 GitHub App 公開地址的最後一段。例如地址為 github.com/apps/my-app,則填寫 my-app。用於生成安裝連結,不是 App ID。','回调地址':'回調地址','复制此地址到 GitHub App 设置中的 Callback URL,需保持完全一致。':'複製此地址到 GitHub App 設定中的 Callback URL,需保持完全一致。','页面、数据库与工作区搜索':'頁面、數據庫與工作區搜尋','仓库、Issue 与 Pull Request':'倉庫、Issue 與 Pull Request','Issue、项目与团队协作':'Issue、項目與團隊協作','仓库、代码、Issue 与 Pull Request':'倉庫、程式碼、Issue 與 Pull Request','问题、项目与团队协作':'問題、項目與團隊協作', + '授权管理':'授權管理','查看各个 agent 用户授权的账号。断开连接会立即阻止 Connany 后续调用。':'查看各個 agent 用戶授權的帳戶。斷開連接會立即阻止 Connany 後續調用。','筛选':'篩選','账号 / 工作区':'帳戶 / 工作區','API Key / 用户':'API Key / 用戶','已连接':'已連接','需要重连':'需要重連','已断开':'已斷開','平台撤销失败,可重试':'平台撤銷失敗,可重試','确定断开此用户连接?这会撤销对应平台授权,共享的上游授权也可能受到影响。':'確定斷開此用戶連接?這會撤銷對應平台授權,共享的上游授權也可能受到影響。','重试撤销':'重試撤銷','断开连接':'斷開連接','暂无用户连接。将API Key交给 agent,完成一次用户授权后会显示在这里。':'暫無用戶連接。將API Key交給 agent,完成一次用戶授權後會顯示在這裡。', + '运行记录':'執行記錄','操作记录':'操作記錄','最近 100 条用户调用与管理员操作,不记录 token、凭证或业务正文。':'最近 100 條用戶調用與管理員操作,不記錄 token、憑證或業務正文。','用户连接与调用':'用戶連接與調用','时间(UTC+8)':'時間(UTC+8)','操作 / 错误':'操作 / 錯誤','暂无用户调用记录':'暫無用戶調用記錄','管理员操作':'管理員操作','管理员':'管理員','对象':'對象','暂无管理员操作':'暫無管理員操作', + '开发者接入':'開發者接入','把这些交给你的 Agent':'把這些交給你的 Agent','复制给开发 Agent':'複製給開發 Agent','把下面的提示交给负责开发的 agent,密钥通过后端环境变量另行配置。':'把下面的提示交給負責開發的 agent,密鑰透過後端環境變數另行配置。','复制完整接入提示':'複製完整接入提示','服务地址':'服務地址','复制服务地址':'複製服務地址','在「API Keys」创建密钥,安全发送生成的 API key 给对应开发者。密钥只放在 agent 后端。':'在「API Keys」建立密鑰,安全發送生成的 API key 給對應開發者。密鑰只放在 agent 後端。','后端调用':'後端調用','向该用户展示返回的 connect_url,让用户在浏览器中完成授权。':'向該用戶展示返回的 connect_url,讓用戶在瀏覽器中完成授權。','后端查询会话结果,获得 connection_id。':'後端查詢會話結果,獲得 connection_id。','调用':'調用','读取已授权的数据。':'讀取已授權的數據。','完整接入文档':'完整接入文件','下载 Agent 接入文档':'下載 Agent 接入文件','下载 TypeScript SDK':'下載 TypeScript SDK','应用凭证共用不代表用户授权共用。用户须在各个 Agent 中授权自己的账号;上游平台共享应用的权限和撤销语义仍然适用。':'應用憑證共用不代表用戶授權共用。用戶須在各個 Agent 中授權自己的帳戶;上游平台共享應用的權限和撤銷語義仍然適用。', + '真实 API 联调':'真實 API 聯調','测试一个用户连接':'測試一個用戶連接','使用API Key 验证授权与只读调用。建议创建测试专用 API Key,避免混入正式用户数据。':'使用API Key 驗證授權與唯讀調用。建議建立測試專用 API Key,避免混入正式用戶數據。','仅在当前页面内存中使用,刷新后清空。此管理员测试工具直接调用 API;产品接入必须通过 agent 后端。':'僅在目前頁面記憶體中使用,刷新後清空。此管理員測試工具直接調用 API;產品接入必須透過 agent 後端。','测试用户 ID':'測試用戶 ID','测试平台':'測試平台','1. 创建授权链接':'1. 建立授權連結','2. 打开授权页面 ↗':'2. 開啟授權頁面 ↗','3. 检查授权结果':'3. 檢查授權結果','4. 试读数据':'4. 試讀數據','等待创建测试会话。':'等待建立測試會話。','如何完成测试':'如何完成測試','在「API Keys」创建密钥并安全保存。':'在「API Keys」建立密鑰並安全儲存。','生成链接,在新标签页完成自己的平台授权。':'生成連結,在新分頁完成自己的平台授權。','回到这里检查结果,再试读数据。':'回到這裡檢查結果,再試讀數據。','Notion:读取官方 MCP 工具目录(不写入内容);GitHub:列出所有可访问安装,由你选择组织试读仓库;Linear:读取官方 MCP 工具目录。空列表表示 API 调用成功,但没有可见数据。':'Notion:讀取官方 MCP 工具目錄(不寫入內容);GitHub:列出所有可訪問安裝,由你選擇組織試讀倉庫;Linear:讀取官方 MCP 工具目錄。空列表表示 API 調用成功,但沒有可見數據。','GitHub 账号授权后,点击试读数据可查看组织、添加安装或管理仓库权限。修改后重新试读以刷新列表。测试结果仅展示在当前页面。':'GitHub 帳戶授權後,點擊試讀數據可查看組織、新增安裝或管理倉庫權限。修改後重新試讀以刷新列表。測試結果僅展示在目前頁面。','创建测试 API Key ↗':'建立測試 API Key ↗','测试完成后':'測試完成後','连接会保留在「用户连接」中,可按测试用户 ID 识别并按需断开。断开可能撤销同一平台应用的共享授权。':'連接會保留在「用戶連接」中,可按測試用戶 ID 識別並按需斷開。斷開可能撤銷同一平台應用的共享授權。','查看用户连接 ↗':'查看用戶連接 ↗','复制 Agent 接入提示 ↗':'複製 Agent 接入提示 ↗', + '当前密码不正确。':'目前密碼不正確。','两次输入的新密码不一致。':'兩次輸入的新密碼不一致。','新密码不能与当前密码相同。':'新密碼不能與目前密碼相同。','尝试过多,请在 15 分钟后重试。':'嘗試過多,請在 15 分鐘後重試。','登录尝试过多,请在 15 分钟后重试。':'登入嘗試過多,請在 15 分鐘後重試。','邮箱或密码不正确。':'電郵或密碼不正確。','请登录管理员账号。':'請登入管理員帳戶。','页面已失效,请刷新后重试。':'頁面已失效,請刷新後重試。','管理操作必须从本站页面发起。':'管理操作必須從本站頁面發起。','请使用 JSON 请求。':'請使用 JSON 請求。','API Key 接入记录不存在。':'API Key 接入記錄不存在。','连接不存在。':'連接不存在。', + '操作失败,请重试。':'操作失敗,請重試。','网络异常,请重试。':'網絡異常,請重試。','请求失败':'請求失敗','请求失败,请重试。':'請求失敗,請重試。','未知错误':'未知錯誤','Connany 已断开连接,但平台撤销失败,可重试撤销。':'Connany 已斷開連接,但平台撤銷失敗,可重試撤銷。','正在验证API Key并创建会话…':'正在驗證API Key並建立會話…','授权链接已生成,15 分钟内有效。打开授权页面,完成后回来检查结果。':'授權連結已生成,15 分鐘內有效。開啟授權頁面,完成後回來檢查結果。','正在查询授权结果…':'正在查詢授權結果…','等待打开授权页面。':'等待開啟授權頁面。','等待完成平台授权;完成后再次检查。':'等待完成平台授權;完成後再次檢查。','正在处理授权,请稍后再次检查。':'正在處理授權,請稍後再次檢查。','授权成功,可以试读数据。':'授權成功,可以試讀數據。','链接已过期,请重新创建授权链接。':'連結已過期,請重新建立授權連結。','正在读取已授权的数据…':'正在讀取已授權的數據…','添加组织 / 仓库 ↗':'新增組織 / 倉庫 ↗','管理仓库权限 ↗':'管理倉庫權限 ↗','试读此组织仓库':'試讀此組織倉庫','仓库读取成功。':'倉庫讀取成功。','已获取组织列表,请选择组织试读。管理入口可能需要组织管理员权限。':'已取得組織列表,請選擇組織試讀。管理入口可能需要組織管理員權限。','账号已连接,尚未添加仓库。可以点击添加组织 / 仓库,完成后再次试读刷新列表。':'帳戶已連接,尚未新增倉庫。可以點擊新增組織 / 倉庫,完成後再次試讀刷新列表。','读取成功。空列表表示当前授权范围内没有可见数据。':'讀取成功。空列表表示目前授權範圍內沒有可見數據。' + }; + const languages=['en','zh-CN','zh-HK']; function translate(text, language) { if (language === 'zh-CN') return text; + const hk=language==='zh-HK',dictionary=hk?hongKong:messages,word=key=>dictionary[key]??key; const trimmed=text.trim(); - let translated=messages[trimmed]; + let translated=dictionary[trimmed]; if (translated===undefined) { + // [pattern, English, Hong Kong] const patterns=[ - [/^(轮换|停用|删除) (.+)?$/,(_,action,name)=>`${messages[action]} ${name}?`], - [/^key 立即失效,(\d+) 个用户连接将被断开并删除,无法恢复。$/,(_,count)=>`The key stops working immediately. ${count} user connections will be disconnected and permanently deleted.`], - [/^(Notion|GitHub|Linear) 已(暂停|启用)$/,(_,connector,state)=>`${connector} ${state==='暂停'?'paused':'enabled'}`], - [/^访问 (.+) 官网(新标签页)$/,(_,name)=>`Visit ${name} website (new tab)`], - [/^(Notion|GitHub|Linear)(未启用)$/,(_,name)=>`${name} (not enabled)`], - [/^授权失败:(.*)。检查配置后重新创建链接。$/,(_,code)=>`Authorization failed: ${code}. Check configuration and create a new link.`], - [/^(管理连接器|启用第一个连接器|创建 API Key|创建项目|创建测试项目|项目|查看) ↗$/,(_,label)=>`${messages[label]} ↗`], - [/^删除项目会吊销所有 API Key,断开并删除 (\d+) 个用户连接,无法恢复。$/,(_,count)=>`Deleting the project revokes all API keys and disconnects and deletes ${count} user connections. This cannot be undone.`], - [/^所有 API Key 立即失效,(\d+) 个用户连接将被断开并删除,无法恢复。$/,(_,count)=>`All API keys stop working immediately. ${count} user connections will be disconnected and permanently deleted.`], - [/^每个项目最多 (\d+) 个有效 API Key,请先吊销旧的。$/,(_,count)=>`Each project can have up to ${count} active API keys. Revoke an old key first.`], - [/^最多 (\d+) 个有效 Key,吊销旧 Key 后可新建$/,(_,count)=>`Up to ${count} active keys. Revoke an old key to create a new one.`], - [/^工具目录:(\d+) 个工具 · (.+) 同步$/,(_,count,time)=>`Tool catalog: ${count} tools · synced ${time}`], - [/^同步 (.+) 工具目录?$/,(_,name)=>`Sync the ${name} tool catalog?`], - [/^将跳转到 (.+) 授权页,用你的账号读取官方工具列表。本次授权只用于读取工具目录,完成后凭证会立即撤销,不会创建用户连接。$/,(_,name)=>`You will be redirected to ${name} to authorize with your account and read the official tool list. This authorization is used only for the catalog: the credential is revoked immediately and no user connection is created.`], - [/^(\d+) 个工具$/,(_,count)=>`${count} tools`], - [/^(\d+\/\d+ \d+:\d+) 同步$/,(_,time)=>`Synced ${time}`], - [/^删除用户 (.+)?$/,(_,email)=>`Delete user ${email}?`], - [/^重置 (.+) 的密码$/,(_,email)=>`Reset password for ${email}`], - [/^\+(\d+) 个已启用 ↗$/,(_,count)=>`+${count} more enabled ↗`], - [/^全部 (\d+) 个连接器 ↗$/,(_,count)=>`All ${count} connectors ↗`], - [/^(.+) 账号$/,(_,name)=>`${name} account`], - [/^(.+) 拒绝了客户端注册((.+))。$/,(_,name,reason)=>`${name} rejected the client registration (${reason}).`], - [/^服务器返回了非预期的响应(HTTP (\d+)),请稍后重试或查看服务日志。$/,(_,status)=>`The server returned an unexpected response (HTTP ${status}). Retry later or check the service logs.`], - [/^授权于 (.+) · 最近使用 (.+)$/,(_,granted,used)=>`Authorized ${granted} · last used ${used}`], - [/^撤销 (.+) 的授权?$/,(_,name)=>`Revoke access for ${name}?`], - [/^(\d+) 个$/,(_,count)=>`${count}`], - [/^(.+) · Connany$/,(_,title)=>`${messages[title]||title} · Connany`] + [/^(轮换|停用|删除) (.+)?$/,(_,action,name)=>`${word(action)} ${name}?`,(_,action,name)=>`${word(action)} ${name}?`], + [/^key 立即失效,(\d+) 个用户连接将被断开并删除,无法恢复。$/,(_,count)=>`The key stops working immediately. ${count} user connections will be disconnected and permanently deleted.`,(_,count)=>`key 立即失效,${count} 個用戶連接將被斷開並刪除,無法恢復。`], + [/^(Notion|GitHub|Linear) 已(暂停|启用)$/,(_,connector,state)=>`${connector} ${state==='暂停'?'paused':'enabled'}`,(_,connector,state)=>`${connector} 已${state==='暂停'?'暫停':'啟用'}`], + [/^访问 (.+) 官网(新标签页)$/,(_,name)=>`Visit ${name} website (new tab)`,(_,name)=>`訪問 ${name} 官網(新分頁)`], + [/^(Notion|GitHub|Linear)(未启用)$/,(_,name)=>`${name} (not enabled)`,(_,name)=>`${name}(未啟用)`], + [/^授权失败:(.*)。检查配置后重新创建链接。$/,(_,code)=>`Authorization failed: ${code}. Check configuration and create a new link.`,(_,code)=>`授權失敗:${code}。檢查配置後重新建立連結。`], + [/^(管理连接器|启用第一个连接器|创建 API Key|创建项目|创建测试项目|项目|查看) ↗$/,(_,label)=>`${word(label)} ↗`,(_,label)=>`${word(label)} ↗`], + [/^删除项目会吊销所有 API Key,断开并删除 (\d+) 个用户连接,无法恢复。$/,(_,count)=>`Deleting the project revokes all API keys and disconnects and deletes ${count} user connections. This cannot be undone.`,(_,count)=>`刪除項目會吊銷所有 API Key,斷開並刪除 ${count} 個用戶連接,無法恢復。`], + [/^所有 API Key 立即失效,(\d+) 个用户连接将被断开并删除,无法恢复。$/,(_,count)=>`All API keys stop working immediately. ${count} user connections will be disconnected and permanently deleted.`,(_,count)=>`所有 API Key 立即失效,${count} 個用戶連接將被斷開並刪除,無法恢復。`], + [/^每个项目最多 (\d+) 个有效 API Key,请先吊销旧的。$/,(_,count)=>`Each project can have up to ${count} active API keys. Revoke an old key first.`,(_,count)=>`每個項目最多 ${count} 個有效 API Key,請先吊銷舊的。`], + [/^最多 (\d+) 个有效 Key,吊销旧 Key 后可新建$/,(_,count)=>`Up to ${count} active keys. Revoke an old key to create a new one.`,(_,count)=>`最多 ${count} 個有效 Key,吊銷舊 Key 後可新建`], + [/^工具目录:(\d+) 个工具 · (.+) 同步$/,(_,count,time)=>`Tool catalog: ${count} tools · synced ${time}`,(_,count,time)=>`工具目錄:${count} 個工具 · ${time} 同步`], + [/^同步 (.+) 工具目录?$/,(_,name)=>`Sync the ${name} tool catalog?`,(_,name)=>`同步 ${name} 工具目錄?`], + [/^将跳转到 (.+) 授权页,用你的账号读取官方工具列表。本次授权只用于读取工具目录,完成后凭证会立即撤销,不会创建用户连接。$/,(_,name)=>`You will be redirected to ${name} to authorize with your account and read the official tool list. This authorization is used only for the catalog: the credential is revoked immediately and no user connection is created.`,(_,name)=>`將跳轉到 ${name} 授權頁,用你的帳戶讀取官方工具列表。本次授權只用於讀取工具目錄,完成後憑證會立即撤銷,不會建立用戶連接。`], + [/^(\d+) 个工具$/,(_,count)=>`${count} tools`,(_,count)=>`${count} 個工具`], + [/^(\d+\/\d+ \d+:\d+) 同步$/,(_,time)=>`Synced ${time}`,(_,time)=>`${time} 同步`], + [/^删除用户 (.+)?$/,(_,email)=>`Delete user ${email}?`,(_,email)=>`刪除用戶 ${email}?`], + [/^重置 (.+) 的密码$/,(_,email)=>`Reset password for ${email}`,(_,email)=>`重置 ${email} 的密碼`], + [/^\+(\d+) 个已启用 ↗$/,(_,count)=>`+${count} more enabled ↗`,(_,count)=>`+${count} 個已啟用 ↗`], + [/^全部 (\d+) 个连接器 ↗$/,(_,count)=>`All ${count} connectors ↗`,(_,count)=>`全部 ${count} 個連接器 ↗`], + [/^(.+) 账号$/,(_,name)=>`${name} account`,(_,name)=>`${name} 帳戶`], + [/^(.+) 拒绝了客户端注册((.+))。$/,(_,name,reason)=>`${name} rejected the client registration (${reason}).`,(_,name,reason)=>`${name} 拒絕了客戶端註冊(${reason})。`], + [/^服务器返回了非预期的响应(HTTP (\d+)),请稍后重试或查看服务日志。$/,(_,status)=>`The server returned an unexpected response (HTTP ${status}). Retry later or check the service logs.`,(_,status)=>`伺服器返回了非預期的回應(HTTP ${status}),請稍後重試或查看服務日誌。`], + [/^授权于 (.+) · 最近使用 (.+)$/,(_,granted,used)=>`Authorized ${granted} · last used ${used}`,(_,granted,used)=>`授權於 ${granted} · 最近使用 ${used}`], + [/^撤销 (.+) 的授权?$/,(_,name)=>`Revoke access for ${name}?`,(_,name)=>`撤銷 ${name} 的授權?`], + [/^(\d+) 个$/,(_,count)=>`${count}`,(_,count)=>`${count} 個`], + [/^(.+) · Connany$/,(_,title)=>`${word(title)} · Connany`,(_,title)=>`${word(title)} · Connany`] ]; - for(const [pattern,replacement] of patterns) if(pattern.test(trimmed)){translated=trimmed.replace(pattern,replacement);break;} + for(const [pattern,english,hongKongText] of patterns) if(pattern.test(trimmed)){translated=trimmed.replace(pattern,hk?hongKongText:english);break;} } return translated===undefined ? text : text.replace(trimmed,translated); } + const supported=value=>languages.includes(value)?value:'en'; let language='en'; - try { if(localStorage.getItem('connany.language')==='zh-CN')language='zh-CN'; } catch {} + try { language=supported(localStorage.getItem('connany.language')); } catch {} const originals=new WeakMap(); const attributes=new WeakMap(); const excluded='script,style,code,pre,textarea,[translate="no"]'; @@ -95,11 +116,11 @@ observer=new MutationObserver(render);render(); document.addEventListener('click',event=>{ const button=event.target.closest?.('[data-language-choice]');if(!button)return; - language=button.dataset.languageChoice==='zh-CN'?'zh-CN':'en'; + language=supported(button.dataset.languageChoice); try { localStorage.setItem('connany.language',language); } catch {} render(); }); - window.addEventListener('storage',event=>{if(event.key==='connany.language'){language=event.newValue==='zh-CN'?'zh-CN':'en';render();}}); + window.addEventListener('storage',event=>{if(event.key==='connany.language'){language=supported(event.newValue);render();}}); } document.documentElement.lang=language; if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',start);else start(); diff --git a/public/admin.css b/public/admin.css index 45ace15..303a77a 100644 --- a/public/admin.css +++ b/public/admin.css @@ -38,7 +38,7 @@ :root[data-theme=dark]{--surface-soft:#25272e} [data-theme=dark] th,[data-theme=dark] .callout,[data-theme=dark] .code-field,[data-theme=dark] .test-result{background:var(--sidebar);color:var(--muted)} [data-theme=dark] .connector-icon{color:var(--ink)} -.language-options{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:20px}.language-options button{padding:13px 16px;background:var(--paper);border:1px solid var(--line);border-radius:8px;color:var(--ink);text-align:left}.language-options button[aria-pressed=true]{background:var(--tint);border-color:var(--blue);color:var(--blue)} +.language-options{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:20px}.language-options button{padding:13px 16px;background:var(--paper);border:1px solid var(--line);border-radius:8px;color:var(--ink);text-align:left}.language-options button[aria-pressed=true]{background:var(--tint);border-color:var(--blue);color:var(--blue)} html[lang="zh-CN"] [data-docs-lang="en"],html:not([lang="zh-CN"]) [data-docs-lang="zh-CN"]{display:none} /* legacy */.admin-docs-bar{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin:-8px 0 24px}.admin-docs-bar code{padding:5px 10px;border:1px solid var(--line);border-radius:6px;background:var(--surface-soft,#f8f9fb)}.admin-docs-bar .button.small{margin:0}.admin-docs-layout{display:grid;grid-template-columns:200px minmax(0,960px);gap:32px}.admin-docs-layout>nav{position:sticky;top:24px;align-self:start;max-height:calc(100dvh - 48px);overflow:auto}.admin-docs-layout>nav a{display:block;padding:6px 12px;border-left:2px solid var(--line);color:var(--muted);font-size:12px}.admin-docs-layout>nav a:hover{color:var(--ink);text-decoration:none}.admin-docs-layout>nav a[aria-current]{border-color:var(--blue);color:var(--blue);background:var(--tint)} .admin-docs .docs-section{border:1px solid var(--line);border-radius:var(--radius);background:var(--paper);padding:26px;margin-bottom:20px;scroll-margin-top:24px}.admin-docs .docs-section h2{font-size:17px;margin-bottom:14px}.admin-docs .docs-section p{font-size:13px;line-height:1.8;margin:10px 0;overflow-wrap:anywhere}.admin-docs p code{padding:2px 5px;border-radius:4px;background:var(--surface-soft,#f5f6fa)}.admin-docs .docs-code{border:1px solid var(--line);border-radius:8px;background:var(--surface-soft,#f9f9fb);margin:16px 0;overflow:hidden}.admin-docs .docs-code>div{display:flex;justify-content:space-between;padding:7px 14px;border-bottom:1px solid var(--line);color:var(--muted);font-size:11px}.admin-docs .docs-code button{border:0;background:transparent;color:var(--blue);font-size:11px}.admin-docs .docs-code pre{overflow:auto;padding:14px 16px;margin:0}.admin-docs .docs-code code{overflow-wrap:normal}.admin-docs .docs-table{overflow:auto;margin:14px 0;border:1px solid var(--line);border-radius:8px}.admin-docs .docs-table td{padding:11px 16px}.admin-docs .docs-table td:first-child{font-family:var(--mono)} diff --git a/public/docs.js b/public/docs.js index 67f43a5..9a4017e 100644 --- a/public/docs.js +++ b/public/docs.js @@ -1,9 +1,15 @@ (() => { const language = document.documentElement.lang; + const messages = { + 'zh-CN': {copied:'已复制',selected:'已选中,请手动复制',failed:'复制失败,请下载 Markdown'}, + 'zh-HK': {copied:'已複製',selected:'已選取,請手動複製',failed:'複製失敗,請下載 Markdown'}, + en: {copied:'Copied',selected:'Text selected. Copy it manually.',failed:'Copy failed. Download the Markdown instead.'} + }; + const t = key => (messages[language] || messages.en)[key]; const picker = document.querySelector('#docs-language'); const url = new URL(location.href); if (!url.searchParams.has('lang')) { - try { if(localStorage.getItem('connany.language')==='zh-CN'){url.searchParams.set('lang','zh-CN');location.replace(url);return;} } catch {} + try { const saved=localStorage.getItem('connany.language');if(['zh-CN','zh-HK'].includes(saved)){url.searchParams.set('lang',saved);location.replace(url);return;} } catch {} } picker?.addEventListener('change',()=>{ try { localStorage.setItem('connany.language',picker.value); } catch {} @@ -13,15 +19,15 @@ document.querySelectorAll('[data-copy-code]').forEach(button => button.addEventListener('click',async()=>{ const code=button.closest('.docs-code').querySelector('pre code'); const status=document.getElementById('copy-status'); - try { await navigator.clipboard.writeText(code.textContent);status.textContent=language==='zh-CN'?'已复制':'Copied'; } - catch { const range=document.createRange();range.selectNodeContents(code);getSelection().removeAllRanges();getSelection().addRange(range);status.textContent=language==='zh-CN'?'已选中,请手动复制':'Text selected. Copy it manually.'; } + try { await navigator.clipboard.writeText(code.textContent);status.textContent=t('copied'); } + catch { const range=document.createRange();range.selectNodeContents(code);getSelection().removeAllRanges();getSelection().addRange(range);status.textContent=t('selected'); } clearTimeout(timer);timer=setTimeout(()=>status.textContent='',2500); })); document.querySelectorAll('[data-copy-target]').forEach(button => button.addEventListener('click',async()=>{ const source=document.getElementById(button.dataset.copyTarget); const status=document.getElementById('copy-status'); - try { await navigator.clipboard.writeText(source.value);status.textContent=language==='zh-CN'?'已复制':'Copied'; } - catch { status.textContent=language==='zh-CN'?'复制失败,请下载 Markdown':'Copy failed. Download the Markdown instead.'; } + try { await navigator.clipboard.writeText(source.value);status.textContent=t('copied'); } + catch { status.textContent=t('failed'); } clearTimeout(timer);timer=setTimeout(()=>status.textContent='',2500); })); const links=[...document.querySelectorAll('.docs-layout>nav a')]; diff --git a/sdk/client.ts b/sdk/client.ts index 90a6f5e..b34f816 100644 --- a/sdk/client.ts +++ b/sdk/client.ts @@ -37,7 +37,7 @@ export class Connany { return result as T; } /** Enabled connectors and their categories. Descriptions and category titles follow `lang` (default English). */ - connectors(options: { lang?: 'en' | 'zh-CN' } = {}) { + connectors(options: { lang?: 'en' | 'zh-CN' | 'zh-HK' } = {}) { return this.request<{ categories: { name: ConnectorCategory; title: string }[]; data: { name: ConnectorName; title: string; category: ConnectorCategory; description: string; avatar_url: string; tools_synced_at: string | null }[] }>(`/v1/connectors${options.lang ? `?${new URLSearchParams({ lang: options.lang })}` : ''}`); } /** Tools the user can use now through an authorized connection. Use this in agent conversations. */ diff --git a/src/admin/pages.ts b/src/admin/pages.ts index f3f26f3..61eb876 100644 --- a/src/admin/pages.ts +++ b/src/admin/pages.ts @@ -35,7 +35,7 @@ function accountSettings(email: string, grants: McpGrant[]) { const icon = (path: string) => ``; const icons = {account:icon(''),general:icon(''),about:icon(''),apps:icon('')}; const themes = [ ['light','浅色',icon('')], ['dark','深色',icon('')], ['system','跟随系统',icon('')] ]; - return `

通用

外观与当前设备偏好设置。

主题

选择控制台配色。跟随系统会使用操作系统设置。

${themes.map(([value,label,glyph])=>``).join('')}

界面语言

选择控制台使用的界面语言。

`; + return `

通用

外观与当前设备偏好设置。

主题

选择控制台配色。跟随系统会使用操作系统设置。

${themes.map(([value,label,glyph])=>``).join('')}

界面语言

选择控制台使用的界面语言。

`; } export function layout(title: string, active: string, email: string, csrf: string, content: string, role: AdminRole = 'member', grants: McpGrant[] = []) { const workbench = [['overview','/admin','总览'],['connectors','/admin/connectors','连接器'],['projects','/admin/projects','项目'],['connections','/admin/connections','用户连接'],['guide','/docs','文档']]; diff --git a/src/app.ts b/src/app.ts index b356f75..95a3bc2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -18,7 +18,8 @@ import { returnUrlSchema } from './projects.js'; import { searchTools } from './tool-search.js'; import { OAuthError, OAuthServer, mcpScope } from './mcp/oauth.js'; import { McpServer } from './mcp/server.js'; -import { categoryTitles, connectorCatalog, connectorCategories, connector as connectorSpec, locales, pickLocale, type ConnectorDefinition } from './connectors/catalog.js'; +import { categoryTitles, connectorCatalog, connectorCategories, connector as connectorSpec, type ConnectorDefinition } from './connectors/catalog.js'; +import { locales, localize, pickLocale } from './i18n.js'; const userSchema = z.string().min(1).max(200); const sessionInput = z.object({ external_user_id: userSchema, return_url: returnUrlSchema.optional() }).strict(); @@ -97,9 +98,9 @@ export function createApp(service: Service) { const { lang } = z.object({ lang: z.enum(locales).optional() }).strict().parse(c.req.query()); const locale = pickLocale(lang, c.req.header('Accept-Language')); const enabled = (await service.connectorStore.in(c.get('project').workspace_id).list()).filter(item => item.enabled); - const data = enabled.map(item => { const spec = connectorCatalog[item.name] as ConnectorDefinition; return { name: item.name, title: spec.label, category: spec.category, description: spec.description[locale], avatar_url: `${base}/connectors/${item.name}/avatar.svg`, tools_synced_at: item.tools_synced_at }; }); + const data = enabled.map(item => { const spec = connectorCatalog[item.name] as ConnectorDefinition; return { name: item.name, title: spec.label, category: spec.category, description: localize(spec.description, locale), avatar_url: `${base}/connectors/${item.name}/avatar.svg`, tools_synced_at: item.tools_synced_at }; }); // Only categories that contain an enabled connector, in display order. - const categories = connectorCategories.filter(category => data.some(item => item.category === category)).map(name => ({ name, title: categoryTitles[name][locale] })); + const categories = connectorCategories.filter(category => data.some(item => item.category === category)).map(name => ({ name, title: localize(categoryTitles[name], locale) })); c.header('Content-Language', locale); c.header('Vary', 'Accept-Language'); return c.json({ categories, data }); }); diff --git a/src/connectors/catalog.ts b/src/connectors/catalog.ts index c04f4fb..0b1397d 100644 --- a/src/connectors/catalog.ts +++ b/src/connectors/catalog.ts @@ -3,6 +3,7 @@ import type { HostedMcp } from './hosted-mcp.js'; import type { ConnectorRuntime, Credentials, Identity } from './index.js'; import { githubAccess } from './github-access.js'; import { linearIdentity, notionIdentity } from './mcp-identity.js'; +import type { Localized } from '../i18n.js'; /** * Built-in connector catalog. A standard hosted MCP connector (dynamic client registration, @@ -45,19 +46,6 @@ export interface McpSpec { /** RFC 8707 resource indicator: true for origin + endpoint, or the exact resource from the protected resource metadata. */ resource?: boolean | string; } -/** Languages for connector text returned by the API and shown in the console. */ -export const locales = ['en', 'zh-CN'] as const; -export type Locale = typeof locales[number]; -export type Localized = Record; -/** Chooses a locale from an explicit `lang` value, then an Accept-Language header; English otherwise. */ -export function pickLocale(lang?: string | null, acceptLanguage?: string | null): Locale { - for (const candidate of [lang, ...(acceptLanguage || '').split(',').map(part => part.split(';')[0].trim())]) { - const value = candidate?.toLowerCase(); - if (value?.startsWith('zh')) return 'zh-CN'; - if (value?.startsWith('en')) return 'en'; - } - return 'en'; -} /** Groups connectors in the console and in GET /v1/connectors. */ export const connectorCategories = ['collaboration', 'development', 'data', 'analytics', 'payments', 'design'] as const; export type ConnectorCategory = typeof connectorCategories[number]; diff --git a/src/docs.ts b/src/docs.ts index 0c97ae8..a65694e 100644 --- a/src/docs.ts +++ b/src/docs.ts @@ -1,5 +1,6 @@ import {readFile} from 'node:fs/promises'; import {brandMark, faviconLink} from './brand.js'; +import {locales, localize, pickLocale, toHongKong, type Locale} from './i18n.js'; const escape = (value:string) => value.replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!)); /** Inline code, bold and http(s) links. Everything is escaped first; code spans are left unformatted. */ const inline = (value:string) => value.split(/(`[^`]+`)/).map(part => part.length > 1 && part.startsWith('`') && part.endsWith('`') ? `${escape(part.slice(1,-1))}` : @@ -7,7 +8,7 @@ const inline = (value:string) => value.split(/(`[^`]+`)/).map(part => part.lengt const listItem = /^(\s*)(?:[-*]|\d+\.)\s+(?:\[[ x]\]\s+)?(.*)$/; const formatCode = (lang:string, code:string) => { if(lang!=='json')return code; try { return JSON.stringify(JSON.parse(code),null,2); } catch { return code; } }; /** Small, escaped renderer for the documented subset: headings, paragraphs, lists, tables and fences. */ -export function renderDocs(markdown:string, language = 'en', idPrefix = 'section') { +export function renderDocs(markdown:string, language: Locale = 'en', idPrefix = 'section') { const lines=markdown.split('\n'); const nav:{id:string;title:string}[]=[];const html:string[]=[]; let section=false; for(let i=0;i
${escape(lang)}
${escape(formatCode(lang,code.join('\n')))}
`);continue; + html.push(`
${escape(lang)}
${escape(formatCode(lang,code.join('\n')))}
`);continue; } if(line.startsWith('### ')) { html.push(`

${inline(line.slice(4))}

`); continue; } if(listItem.test(line) && !line.startsWith(' ')) { @@ -46,13 +47,21 @@ const sources: Record = { agent: { 'zh-CN': 'docs/agent-integration.md' }, mcp: { en: 'docs/mcp.en.md', 'zh-CN': 'docs/mcp.md' }, }; -/** A docs page as Markdown with this deployment's address. Links between docs point to the public pages. */ -export async function docsMarkdown(page: DocsPageName, base: string, language = 'en') { - const file = language === 'zh-CN' ? sources[page]['zh-CN'] : sources[page].en ?? sources[page]['zh-CN']; - return (await readFile(file, 'utf8')).replaceAll('{{BASE_URL}}', base).replaceAll('https://connect.your-domain.com', base).replaceAll('https://connany.example.com', base) +/** zh-HK pages are converted from the zh-CN source once per file. */ +const hongKongDocs = new Map(); +async function source(page: DocsPageName, locale: Locale) { + if (locale === 'en') return readFile(sources[page].en ?? sources[page]['zh-CN'], 'utf8'); + const file = sources[page]['zh-CN']; + if (locale === 'zh-CN') return readFile(file, 'utf8'); + if (!hongKongDocs.has(file)) hongKongDocs.set(file, toHongKong(await readFile(file, 'utf8'))); + return hongKongDocs.get(file)!; +} +/** A docs page as Markdown with this deployment's address. Links between docs point to the public pages. `language` is any BCP 47 tag. */ +export async function docsMarkdown(page: DocsPageName, base: string, language?: string) { + return (await source(page, pickLocale(language))).replaceAll('{{BASE_URL}}', base).replaceAll('https://connect.your-domain.com', base).replaceAll('https://connany.example.com', base) .replaceAll('](agent-integration.md)', `](${base}/docs/agent)`).replaceAll('](mcp.md)', `](${base}/docs/mcp)`).replaceAll('](api.md)', `](${base}/docs)`).replaceAll('](api.en.md)', `](${base}/docs)`); } -export async function apiMarkdown(base: string, language = 'en') { return docsMarkdown('api', base, language); } +export async function apiMarkdown(base: string, language?: string) { return docsMarkdown('api', base, language); } /** Hand-off for a coding agent building a product integration: instructions plus the full guide. */ export async function agentPrompt(base: string) { const local = ['localhost', '127.0.0.1'].includes(new URL(base).hostname); @@ -71,16 +80,20 @@ const pages: Record zh ? `${path}?lang=zh-CN` : path; - const [, title, intro] = zh ? pages[page].zh : pages[page].en; - const {nav, html} = renderDocs(await docsMarkdown(page, base, language), language); - const tabs = (Object.keys(pages) as DocsPageName[]).map(name => `${(zh ? pages[name].zh : pages[name].en)[0]}`).join(''); +export async function docsPage(base: string, language?: string, page: DocsPageName = 'api') { + const locale = pickLocale(language); + const t = (en: string, zh: string) => localize({en, 'zh-CN': zh}, locale); + const href = (path: string, target: Locale = locale) => target === 'en' ? path : `${path}?lang=${target}`; + const text = (name: DocsPageName, i: number) => t(pages[name].en[i], pages[name].zh[i]); + const [title, intro] = [text(page, 1), text(page, 2)]; + const {nav, html} = renderDocs(await docsMarkdown(page, base, locale), locale); + const tabs = (Object.keys(pages) as DocsPageName[]).map(name => `${text(name, 0)}`).join(''); + const alternates = [...locales.map(target => ``), ``].join(''); + const languageNames: Record = {en: 'English', 'zh-CN': '简体中文', 'zh-HK': '繁體中文(香港)'}; const markdown = { api: '/docs/api.md', agent: '/docs/agent.md', mcp: '/docs/mcp.md' }[page]; const actions = page === 'agent' - ? `` + ? `` : page === 'mcp' ? `SKILL.md` : ''; - const note = page === 'agent' && !zh ? '

This guide is currently available in Chinese.

' : ''; - return `${escape(title)} · Connany${faviconLink}
${brandMark}connany Docs
DEVELOPER DOCUMENTATION

${escape(title)}

${escape(intro)}

${escape(page === 'mcp' ? `${base}/mcp` : base)}${actions}
${note}
${html}
`; + const note = page === 'agent' && locale === 'en' ? '

This guide is currently available in Chinese.

' : ''; + return `${escape(title)} · Connany${faviconLink}${alternates}
${brandMark}connany Docs
MarkdownTypeScript SDK${t('Console ↗', '控制台 ↗')}
DEVELOPER DOCUMENTATION

${escape(title)}

${escape(intro)}

${escape(page === 'mcp' ? `${base}/mcp` : base)}${actions}
${note}
${html}
`; } diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..0a63d76 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,40 @@ +import * as OpenCC from 'opencc-js/cn2t'; +/** + * Supported locales. Tags carry a region, not only a script: zh-HK copy uses Hong Kong wording, + * not only Traditional glyphs. A later zh-TW gets its own term list on the same glyph set. + */ +export const locales = ['en', 'zh-CN', 'zh-HK'] as const; +export type Locale = typeof locales[number]; +/** Text written by hand. zh-HK is derived from zh-CN, see toHongKong. */ +export type Localized = Record<'en' | 'zh-CN', string>; +/** Hong Kong wording, applied after the Simplified to Traditional conversion. */ +const hongKongTerms: [string, string][] = [ + ['賬號', '帳戶'], ['賬戶', '帳戶'], ['賬單', '帳單'], ['退出登錄', '登出'], ['登錄', '登入'], ['鏈接', '連結'], ['郵箱', '電郵'], + ['創建', '建立'], ['文檔', '文件'], ['代碼', '程式碼'], ['設置', '設定'], ['默認', '預設'], ['當前', '目前'], ['字符串', '字串'], + ['字符', '字元'], ['內存', '記憶體'], ['通過', '透過'], ['運行', '執行'], ['臺', '台'], ['菜單', '選單'], ['打開', '開啟'], + ['選中', '選取'], ['文本', '文字'], ['聯繫', '聯絡'], ['信息', '資訊'], ['支持', '支援'], ['兼容', '相容'], ['字段', '欄位'], + ['接口', '介面'], ['響應', '回應'], ['示例', '範例'], ['集成', '整合'], ['協議', '協定'], ['哈希', '雜湊'], ['搜索', '搜尋'], + ['用戶名', '用戶名稱'], ['端口', '連接埠'], ['自定義', '自訂'], ['標籤頁', '分頁'], ['環境變量', '環境變數'], ['獲取', '取得'], ['保存', '儲存'], + ['界面', '介面'], ['操作系統', '作業系統'], ['設備', '裝置'], ['添加', '新增'], ['性能', '效能'], ['只讀', '唯讀'], ['服務器', '伺服器'], ['弔銷', '吊銷'], +]; +/** zh-CN text in Traditional glyphs (Taiwan glyph set, e.g. 戶 and 說) with Hong Kong wording. */ +export const toHongKong = OpenCC.ConverterFactory(OpenCC.Locale.from.cn, OpenCC.Locale.to.tw, [hongKongTerms]); +export function localize(text: Localized, locale: Locale) { return locale === 'zh-HK' ? toHongKong(text['zh-CN']) : text[locale]; } +/** Maps a BCP 47 tag to the closest supported locale through its likely script: zh-TW, zh-MO and zh-Hant read zh-HK; zh, zh-SG and zh-Hans read zh-CN. */ +export function matchLocale(tag?: string | null): Locale | undefined { + if (!tag) return; + let locale: Intl.Locale; + try { locale = new Intl.Locale(tag).maximize(); } catch { return; } + if (locale.language === 'en') return 'en'; + if (locale.language === 'zh') return locale.script === 'Hant' ? 'zh-HK' : 'zh-CN'; +} +/** Chooses a locale from an explicit `lang` value, then Accept-Language by q-value; English otherwise. */ +export function pickLocale(lang?: string | null, acceptLanguage?: string | null): Locale { + const ranges = (acceptLanguage || '').split(',').map(part => { + const [tag, ...params] = part.split(';').map(value => value.trim()); + const q = params.find(param => param.startsWith('q=')); + return { tag, q: q ? Number(q.slice(2)) : 1 }; + }).filter(range => range.q > 0).sort((a, b) => b.q - a.q); + for (const tag of [lang, ...ranges.map(range => range.tag)]) { const locale = matchLocale(tag); if (locale) return locale; } + return 'en'; +} diff --git a/tests/connection-api.test.ts b/tests/connection-api.test.ts index 0d1f8cb..cae4a8e 100644 --- a/tests/connection-api.test.ts +++ b/tests/connection-api.test.ts @@ -63,7 +63,9 @@ test('connectors list only enabled connectors with localized descriptions, their assert.deepEqual(body.categories,[{name:'collaboration',title:'Collaboration'},{name:'development',title:'Code & deploy'}]); const zh=await app.request('/v1/connectors?lang=zh-CN');assert.equal(zh.headers.get('Content-Language'),'zh-CN'); const zhBody=await zh.json() as any;assert.equal(zhBody.data[0].description,'页面、数据库与工作区搜索');assert.equal(zhBody.categories[0].title,'协作与办公');assert.equal(zhBody.data[0].title,'Notion'); - const header=await (await app.request('/v1/connectors',{headers:{'Accept-Language':'zh-TW,zh;q=0.9,en;q=0.8'}})).json() as any;assert.equal(header.categories[1].title,'代码与部署'); + const header=await (await app.request('/v1/connectors',{headers:{'Accept-Language':'zh-TW,zh;q=0.9,en;q=0.8'}})).json() as any;assert.equal(header.categories[1].title,'程式碼與部署'); + const hk=await app.request('/v1/connectors?lang=zh-HK');assert.equal(hk.headers.get('Content-Language'),'zh-HK'); + const hkBody=await hk.json() as any;assert.equal(hkBody.data[0].description,'頁面、數據庫與工作區搜尋');assert.equal(hkBody.categories[0].title,'協作與辦公'); const explicit=await (await app.request('/v1/connectors?lang=en',{headers:{'Accept-Language':'zh-CN'}})).json() as any;assert.equal(explicit.categories[0].title,'Collaboration'); assert.equal((await app.request('/v1/connectors?lang=fr')).status,400); const avatar=await app.request('/connectors/linear/avatar.svg'); diff --git a/tests/hosted-connectors.test.ts b/tests/hosted-connectors.test.ts index 4546936..b8ac6c9 100644 --- a/tests/hosted-connectors.test.ts +++ b/tests/hosted-connectors.test.ts @@ -88,14 +88,23 @@ test('the SDK names every catalog connector and every connector has a category', }); test('connector descriptions and category titles exist in every supported language',async()=>{ - const {categoryTitles,connectorCategories,locales,pickLocale}=await import('../src/connectors/catalog.js'); + const {categoryTitles,connectorCategories}=await import('../src/connectors/catalog.js'); + const {locales,localize,pickLocale}=await import('../src/i18n.js'); for(const locale of locales){ - for(const name of connectorNames)assert(connector(name).description[locale]?.trim(),`${name} ${locale}`); - for(const category of connectorCategories)assert(categoryTitles[category][locale]?.trim(),`${category} ${locale}`); + for(const name of connectorNames)assert(localize(connector(name).description,locale).trim(),`${name} ${locale}`); + for(const category of connectorCategories)assert(localize(categoryTitles[category],locale).trim(),`${category} ${locale}`); } assert.equal(pickLocale(undefined,'fr-FR,zh-CN;q=0.8'),'zh-CN');assert.equal(pickLocale('en','zh-CN'),'en');assert.equal(pickLocale(undefined,undefined),'en'); }); +test('locale negotiation maps region and script subtags to the closest supported locale',async()=>{ + const {pickLocale}=await import('../src/i18n.js'); + for(const tag of ['zh-HK','zh-hk','zh-MO','zh-TW','zh-Hant','zh-Hant-CN'])assert.equal(pickLocale(tag),'zh-HK',tag); + for(const tag of ['zh','zh-CN','zh-SG','zh-Hans','zh-Hans-HK'])assert.equal(pickLocale(tag),'zh-CN',tag); + assert.equal(pickLocale(undefined,'en;q=0.5,zh-HK'),'zh-HK');assert.equal(pickLocale(undefined,'zh-HK;q=0,en'),'en'); + assert.equal(pickLocale(undefined,'*,fr;q=0.9'),'en');assert.equal(pickLocale('not a tag','zh-TW'),'zh-HK'); +}); + test('connector MCP headers are sent on every request and large tool catalogs are accepted',async()=>{ const seen:(string|null)[]=[]; const big='x'.repeat(5*1024*1024); diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index 87bae1b..8284949 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -4,6 +4,7 @@ import {readFile} from 'node:fs/promises'; import vm from 'node:vm'; import {layout,connectorList,projectList,projectDetail,connectionsPage,testPage,loginPage,overview,usersPage,signupPage,consentPage,consentErrorPage} from '../src/admin/pages.js'; import {docsPage,apiMarkdown} from '../src/docs.js'; +import {toHongKong} from '../src/i18n.js'; async function languageRuntime(saved?:string) { const context:any={window:{},localStorage:{getItem:()=>saved},document:{documentElement:{},readyState:'loading',addEventListener(){}},WeakMap}; @@ -11,12 +12,15 @@ async function languageRuntime(saved?:string) { return {language:context.document.documentElement.lang,t:context.window.ConnanyI18n.translate}; } test('language defaults to English, accepts Chinese preference and translates dynamic UI messages',async()=>{ - const {language,t}=await languageRuntime();assert.equal(language,'en');assert.equal((await languageRuntime('zh-CN')).language,'zh-CN');assert.equal((await languageRuntime('invalid')).language,'en'); + const {language,t}=await languageRuntime();assert.equal(language,'en');assert.equal((await languageRuntime('zh-CN')).language,'zh-CN');assert.equal((await languageRuntime('invalid')).language,'en');assert.equal((await languageRuntime('zh-HK')).language,'zh-HK'); assert.equal(t('账户','en'),'Account');assert.equal(t('账户','zh-CN'),'账户'); assert.equal(t('删除 我的 Agent?','en'),'Delete 我的 Agent?'); assert.equal(t('key 立即失效,3 个用户连接将被断开并删除,无法恢复。','en'),'The key stops working immediately. 3 user connections will be disconnected and permanently deleted.'); assert.equal(t('当前密码不正确。','en'),'The current password is incorrect.'); assert.equal(t('user_private_name','en'),'user_private_name'); + assert.equal(t('账户','zh-HK'),'帳戶');assert.equal(t('停用','zh-HK'),'停用');assert.equal(t('删除 我的 Agent?','zh-HK'),'刪除 我的 Agent?'); + assert.equal(t('删除项目会吊销所有 API Key,断开并删除 3 个用户连接,无法恢复。','zh-HK'),'刪除項目會吊銷所有 API Key,斷開並刪除 3 個用戶連接,無法恢復。'); + assert.equal(t('创建 API Key ↗','zh-HK'),'建立 API Key ↗');assert.equal(t('API 文档 · Connany','zh-HK'),'API 文件 · Connany'); }); test('primary screens have English translations and user supplied key names opt out',async()=>{ const {t}=await languageRuntime(); @@ -27,13 +31,23 @@ test('primary screens have English translations and user supplied key names opt assert(projectHtml.includes('translate="no">设置'));assert(projectHtml.includes('生产')); const html=layout('总览','overview','admin@example.com','',overview(runtime,{projects:1,active_projects:1,connections:3}))+connectorList(runtime)+projectList([],null)+projectHtml.replaceAll('translate="no">设置','translate="no">x').replaceAll('translate="no">生产','translate="no">x')+loginPage(true,'/admin/oauth2/authorize?x=1')+signupPage('/admin/oauth2/authorize?x=1')+consentPage({client_name:'x'},{client_id:'c',redirect_uri:'http://localhost:3000/callback'},'a@example.com','')+consentErrorPage('回调地址与客户端注册的不一致。')+layout('x','overview','a@example.com','','','member',[{id:'g',client_name:'x',created_at:new Date(),last_used_at:null}])+connectionsPage([],[{id:'proj_a',name:'x'}],'','',null)+usersPage([{id:'admin_a',email:'a@example.com',role:'admin',created_at:new Date(),last_login_at:null},{id:'admin_b',email:'b@example.com',role:'member',created_at:new Date(),last_login_at:new Date()}] as any,{id:'admin_a',email:'a@example.com',role:'admin',workspace_id:'ws_a'})+layout('x','users','a@example.com','','','admin')+testPage(runtime,'notion'); const text=html.replace(/<(script|style|textarea|code)\b[^>]*>[\s\S]*?<\/\1>/g,'').replace(/<[^>]+>/g,'\n'); - const missing=[...new Set(text.split('\n').map(v=>v.trim()).filter(v=>/[\u4e00-\u9fff]/.test(t(v,'en'))&&v!=='简体中文'))]; - assert.deepEqual(missing,[]); + const strings=[...new Set(text.split('\n').map(v=>v.trim()).filter(v=>/[\u4e00-\u9fff]/.test(v)&&!['简体中文','繁體中文(香港)'].includes(v)))]; + assert.deepEqual(strings.filter(v=>/[\u4e00-\u9fff]/.test(t(v,'en'))),[]); + // zh-HK follows the same conversion as the server, so console and docs use the same Hong Kong wording. + assert.deepEqual(strings.filter(v=>t(v,'zh-HK')!==toHongKong(v)),[]); +}); +test('every console message has a zh-HK translation that matches the server conversion',async()=>{ + const {t}=await languageRuntime(); + const keys=[...(await readFile('public/admin-i18n.js','utf8')).matchAll(/'([^']*[\u4e00-\u9fff][^']*)':'/g)].map(m=>m[1]); + assert(keys.length>300);assert.deepEqual(keys.filter(key=>t(key,'zh-HK')!==toHongKong(key)),[]); }); test('public docs support matching English and Chinese API coverage',async()=>{ const english=await docsPage('https://example.com');const chinese=await docsPage('https://example.com','zh-CN'); assert(english.includes(''));assert(english.includes('Quick start'));assert(chinese.includes(''));assert(chinese.includes('快速开始')); - for(const language of ['en','zh-CN']){ + const hongKong=await docsPage('https://example.com','zh-HK','mcp');assert(hongKong.includes(''));assert(hongKong.includes('