fix: filter menus by deployment capabilities (#2674)

* fix: filter menus by deployment capabilities

* fix: address deployment capabilities review feedback

Restore Lite organizations hiding when the capabilities probe fails,
align embed capability detection with route registration, move the
handler into SystemHandler for Swagger/docs/client sync, and add
cross-layer capability key contract tests.

* style: gofmt deployment capabilities files

Fix App CI formatting check so the vet/test step can run.

---------

Co-authored-by: wizardchen <wizardchen@tencent.com>
This commit is contained in:
ttommybot
2026-08-13 20:18:48 +08:00
committed by GitHub
parent 2a60a7f30a
commit 1d68b4dcd7
28 changed files with 2591 additions and 16 deletions
+28
View File
@@ -63,6 +63,18 @@ type StorageCheckResponse struct {
BucketCreated bool `json:"bucket_created,omitempty"`
}
// DeploymentCapability describes whether a deployment exposes a feature route.
type DeploymentCapability struct {
Supported bool `json:"supported"`
Reason string `json:"reason,omitempty"`
}
// DeploymentCapabilitiesData is the payload of GET /system/capabilities.
type DeploymentCapabilitiesData struct {
Edition string `json:"edition"`
Capabilities map[string]DeploymentCapability `json:"capabilities"`
}
// GetSystemInfo gets system version and configuration information
func (c *Client) GetSystemInfo(ctx context.Context) (*SystemInfo, error) {
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/system/info", nil, nil)
@@ -79,6 +91,22 @@ func (c *Client) GetSystemInfo(ctx context.Context) (*SystemInfo, error) {
return result.Data, nil
}
// GetDeploymentCapabilities returns the deployment feature snapshot for SPA menu gating.
func (c *Client) GetDeploymentCapabilities(ctx context.Context) (*DeploymentCapabilitiesData, error) {
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/system/capabilities", nil, nil)
if err != nil {
return nil, err
}
var result struct {
Code int `json:"code"`
Data *DeploymentCapabilitiesData `json:"data"`
}
if err := parseResponse(resp, &result); err != nil {
return nil, err
}
return result.Data, nil
}
// ListParserEngines lists available document parser engines
func (c *Client) ListParserEngines(ctx context.Context) ([]ParserEngine, error) {
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/system/parser-engines", nil, nil)
+52
View File
@@ -4,6 +4,7 @@
| 方法 | 路径 | 描述 |
| ------ | --------------------------------- | ---------------------- |
| GET | `/system/capabilities` | 获取部署能力清单 |
| GET | `/system/info` | 获取系统信息 |
| GET | `/system/parser-engines` | 获取解析引擎列表 |
| POST | `/system/parser-engines/check` | 检查解析引擎可用性 |
@@ -11,6 +12,57 @@
| GET | `/system/storage-engine-status` | 获取存储引擎状态 |
| POST | `/system/storage-engine-check` | 检查存储引擎连通性 |
## GET `/system/capabilities` - 获取部署能力清单
返回当前部署版本,以及各功能模块是否已在后端注册对应路由。`supported: false` 表示 SPA 应隐藏相关入口;字段缺失或接口不可用时不应据此清空整个菜单(fail-open),但 Lite 版会始终将 `organizations` 标记为不支持。
**权限**:Viewer+(租户成员);任意有效 API Key 可读(`apiKeyAny`)。
**请求**:
```curl
curl --location 'http://localhost:8080/api/v1/system/capabilities' \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json'
```
**响应**:
```json
{
"code": 0,
"msg": "success",
"data": {
"edition": "standard",
"capabilities": {
"organizations": { "supported": true },
"agents": { "supported": true },
"integrations.im": { "supported": true },
"integrations.embed": { "supported": false, "reason": "route_not_registered" },
"integrations.api": { "supported": true },
"settings.mcp": { "supported": true },
"settings.websearch": { "supported": true },
"settings.vectorstore": { "supported": true },
"settings.storage": { "supported": true },
"settings.sandbox": { "supported": true }
}
}
}
```
Lite 版示例(共享空间不可用):
```json
{
"capabilities": {
"organizations": {
"supported": false,
"reason": "not_supported_in_lite"
}
}
}
```
## GET `/system/info` - 获取系统信息
**请求**:
+706
View File
@@ -1161,6 +1161,20 @@ const docTemplate = `{
}
}
},
"/auth/oidc/start": {
"get": {
"description": "与 /auth/oidc/url 不同,此端点直接 302 重定向到 OIDC Provider 的授权页,\n无需前端 JS 介入。适用于外部平台(如企业门户)直接给出一个链接即可\n触发 OIDC 授权码流程,借助 IdP 的 SSO session 实现免再次输密码。",
"tags": [
"认证"
],
"summary": "发起 OIDC 登录(直接 302",
"responses": {
"302": {
"description": "Found"
}
}
}
},
"/auth/oidc/url": {
"get": {
"description": "根据后端OIDC配置生成第三方登录跳转地址",
@@ -10605,6 +10619,329 @@ const docTemplate = `{
}
}
},
"/sandbox-configs": {
"get": {
"security": [
{
"Bearer": []
},
{
"ApiKeyAuth": []
}
],
"description": "List workspace sandbox backend configs with credentials masked.",
"produces": [
"application/json"
],
"tags": [
"SandboxConfig"
],
"summary": "List sandbox configs",
"responses": {
"200": {
"description": "Sandbox configs and defaults",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"post": {
"security": [
{
"Bearer": []
},
{
"ApiKeyAuth": []
}
],
"description": "Create a named workspace sandbox backend config. Credentials are masked in the response.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"SandboxConfig"
],
"summary": "Create sandbox config",
"parameters": [
{
"description": "Sandbox backend config",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_handler.sandboxConfigRequest"
}
}
],
"responses": {
"201": {
"description": "Created sandbox config",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Invalid request or validation failure",
"schema": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/sandbox-configs/{id}": {
"get": {
"security": [
{
"Bearer": []
},
{
"ApiKeyAuth": []
}
],
"description": "Retrieve a workspace sandbox backend config with credentials masked.",
"produces": [
"application/json"
],
"tags": [
"SandboxConfig"
],
"summary": "Get sandbox config",
"parameters": [
{
"type": "string",
"description": "Sandbox config ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Sandbox config",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Sandbox config not found",
"schema": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
}
}
}
},
"put": {
"security": [
{
"Bearer": []
},
{
"ApiKeyAuth": []
}
],
"description": "Update a sandbox backend config. Identity-field changes are refused while the config owns live or paused sandboxes.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"SandboxConfig"
],
"summary": "Update sandbox config",
"parameters": [
{
"type": "string",
"description": "Sandbox config ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Updated sandbox config",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_handler.sandboxConfigRequest"
}
}
],
"responses": {
"200": {
"description": "Updated sandbox config",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Invalid request or validation failure",
"schema": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Sandbox config not found",
"schema": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
}
},
"409": {
"description": "Live sandboxes or unverifiable inventory",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"423": {
"description": "Sandbox config is being modified by another request",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"delete": {
"security": [
{
"Bearer": []
},
{
"ApiKeyAuth": []
}
],
"description": "Soft-delete a sandbox backend config. force=true only overrides unverifiable provider inventory, never confirmed live sandboxes.",
"produces": [
"application/json"
],
"tags": [
"SandboxConfig"
],
"summary": "Delete sandbox config",
"parameters": [
{
"type": "string",
"description": "Sandbox config ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "boolean",
"description": "Force delete when inventory is unverifiable",
"name": "force",
"in": "query"
}
],
"responses": {
"200": {
"description": "Deletion success",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"409": {
"description": "Live sandboxes or unverifiable inventory",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/sandbox-configs/{id}/sandboxes": {
"get": {
"security": [
{
"Bearer": []
},
{
"ApiKeyAuth": []
}
],
"description": "Return live/paused sandbox inventory and affected agent names for one config.",
"produces": [
"application/json"
],
"tags": [
"SandboxConfig"
],
"summary": "Inspect sandbox config inventory",
"parameters": [
{
"type": "string",
"description": "Sandbox config ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Sandbox inventory",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/sessions": {
"get": {
"security": [
@@ -11139,6 +11476,57 @@ const docTemplate = `{
}
}
},
"/sessions/{session_id}/artifacts": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "返回本会话中所有 assistant 消息产生的技能产物元数据(不含 URL)",
"produces": [
"application/json"
],
"tags": [
"会话"
],
"summary": "列出会话生成的产物文件",
"parameters": [
{
"type": "string",
"description": "会话ID",
"name": "session_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
}
}
}
}
},
"/sessions/{session_id}/messages/{message_id}/artifacts": {
"get": {
"responses": {}
}
},
"/sessions/{session_id}/messages/{message_id}/artifacts/{index}/download": {
"get": {
"responses": {}
}
},
"/sessions/{session_id}/messages/{message_id}/suggestions": {
"get": {
"security": [
@@ -12696,6 +13084,27 @@ const docTemplate = `{
}
}
},
"/system/capabilities": {
"get": {
"description": "返回当前部署版本及实际注册的后端路由所对应的功能能力;仅 supported=false 表示入口应隐藏",
"produces": [
"application/json"
],
"tags": [
"系统"
],
"summary": "获取部署能力清单",
"responses": {
"200": {
"description": "标准 code/msg/data 包装,data 为 DeploymentCapabilitiesData",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/system/docreader/reconnect": {
"post": {
"consumes": [
@@ -12799,6 +13208,40 @@ const docTemplate = `{
}
}
},
"/system/sandbox-check": {
"post": {
"description": "使用当前填写的参数测试沙箱后端,不保存配置;deep=true 会执行临时脚本,远端后端还会创建并销毁一个沙箱",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"系统"
],
"summary": "测试沙箱连通性",
"parameters": [
{
"description": "沙箱配置",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_handler.SandboxCheckRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/internal_handler.SandboxCheckResponse"
}
}
}
}
},
"/system/storage-engine-check": {
"post": {
"description": "使用当前填写的参数测试 MinIO/COS 连通性,不保存配置",
@@ -15763,6 +16206,34 @@ const docTemplate = `{
}
}
},
"github_com_Tencent_WeKnora_internal_types.CubeSandboxConfig": {
"type": "object",
"properties": {
"api_key": {
"description": "加密",
"type": "string"
},
"api_url": {
"type": "string"
},
"cube_sandbox_ttl_seconds": {
"type": "integer"
},
"http_timeout_sec": {
"description": "HTTPTimeoutSec bounds each HTTP call to the sandbox control plane.\n0 means use the built-in default (30s), never the deployment's value.",
"type": "integer"
},
"proxy_url": {
"type": "string"
},
"sandbox_domain": {
"type": "string"
},
"template_id": {
"type": "string"
}
}
},
"github_com_Tencent_WeKnora_internal_types.CustomAgentConfig": {
"type": "object",
"properties": {
@@ -15969,6 +16440,10 @@ const docTemplate = `{
"description": "Rewrite prompt user message template",
"type": "string"
},
"sandbox_config_id": {
"description": "===== Sandbox Settings =====\nSandboxConfigID selects which workspace sandbox config this agent's\nskill scripts run on. Empty means sandbox execution is disabled.\n\nThis references the LOGICAL config, never a specific revision: keeping\nthe indirection here is what would let credential rotation happen\nwithout re-pointing every agent (see the spec's §4.8).",
"type": "string"
},
"selected_skills": {
"description": "Selected skill names (only used when SkillsSelectionMode is \"selected\")",
"type": "array",
@@ -16139,6 +16614,43 @@ const docTemplate = `{
}
}
},
"github_com_Tencent_WeKnora_internal_types.DockerSandboxConfig": {
"type": "object",
"properties": {
"image": {
"type": "string"
}
}
},
"github_com_Tencent_WeKnora_internal_types.E2BSandboxConfig": {
"type": "object",
"properties": {
"api_key": {
"description": "加密",
"type": "string"
},
"api_url": {
"type": "string"
},
"e2b_sandbox_ttl_seconds": {
"type": "integer"
},
"http_timeout_sec": {
"description": "HTTPTimeoutSec bounds each HTTP call to the sandbox control plane.\n0 means use the built-in default (30s), never the deployment's value.",
"type": "integer"
},
"proxy_url": {
"description": "ProxyURL is the data-plane gateway that fronts envd. E2B Cloud resolves\n\"\u003cport\u003e-\u003csandboxID\u003e.\u003csandbox_domain\u003e\" through public DNS and TLS, so it\nneeds no value here. Self-hosted E2B-compatible control planes usually\nserve every sandbox from one gateway address and expect the sandbox\nauthority in the Host header; setting this makes WeKnora dial the\ngateway directly instead of requiring wildcard DNS and a certificate\nfor the sandbox domain. An \"http://\" gateway also downgrades the\ndata-plane scheme, which the E2B SDK otherwise pins to https.",
"type": "string"
},
"sandbox_domain": {
"type": "string"
},
"template_id": {
"type": "string"
}
}
},
"github_com_Tencent_WeKnora_internal_types.EmbeddingParameters": {
"type": "object",
"properties": {
@@ -17659,6 +18171,13 @@ const docTemplate = `{
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.AgentStep"
}
},
"artifacts": {
"description": "Skill-generated files produced during this assistant turn (assistant messages only).\nPopulated by ArtifactCollector after the sandbox finishes, referenced by the\nartifact download endpoint. Empty for user messages and turns without skills.",
"type": "array",
"items": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.MessageArtifact"
}
},
"attachments": {
"description": "Attached files (documents, audio, etc., for user messages)",
"type": "array",
@@ -17745,6 +18264,39 @@ const docTemplate = `{
}
}
},
"github_com_Tencent_WeKnora_internal_types.MessageArtifact": {
"type": "object",
"properties": {
"created_at": {
"description": "When WeKnora persisted the blob",
"type": "string"
},
"file_name": {
"description": "Original filename inside the sandbox",
"type": "string"
},
"file_size": {
"description": "File size in bytes",
"type": "integer"
},
"file_type": {
"description": "File extension (e.g., \".pptx\", \".pdf\")",
"type": "string"
},
"mod_time": {
"description": "Sandbox-side modification time (used for diff)",
"type": "string"
},
"source_path": {
"description": "Absolute path inside the sandbox (used for diff)",
"type": "string"
},
"url": {
"description": "Storage URL (provider://path); persisted, not sent to client",
"type": "string"
}
}
},
"github_com_Tencent_WeKnora_internal_types.MessageAttachment": {
"type": "object",
"properties": {
@@ -19050,6 +19602,10 @@ const docTemplate = `{
"description": "PinnedAt records when the session was pinned; nil when not pinned.",
"type": "string"
},
"sandbox_config_id": {
"description": "SandboxConfigID pins which sandbox config this session's CURRENT live\nsandbox was created on. Empty means no live sandbox;\nSandboxConfigIDGlobalDefault means the deployment-wide default config.\n\nThis is an ephemeral pin that dies with the sandbox, not a permanent\nowner: sessions outlive sandboxes by months, so treating it as\npermanent would make \"no session references this config\" never true.",
"type": "string"
},
"tenant_id": {
"description": "Workspace ID",
"type": "integer"
@@ -19611,6 +20167,47 @@ const docTemplate = `{
"TenantRoleViewer"
]
},
"github_com_Tencent_WeKnora_internal_types.TenantSandboxConfig": {
"type": "object",
"properties": {
"allow_private_endpoints": {
"description": "AllowPrivateEndpoints permits this workspace config to reach RFC1918 or\nloopback cluster endpoints. Link-local/cloud-metadata addresses remain\nblocked. It is explicit in the UI instead of hidden in process env.",
"type": "boolean"
},
"cube": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.CubeSandboxConfig"
},
"default_timeout_sec": {
"description": "DefaultTimeoutSec is the per-execution timeout in seconds. 0 uses the\nprogram's built-in default.",
"type": "integer"
},
"docker": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.DockerSandboxConfig"
},
"e2b": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.E2BSandboxConfig"
},
"env_vars": {
"description": "EnvVars are additional environment variables injected into every\nsandbox created for this tenant. 🔒 Values are encrypted at rest.\nThese become visible to all scripts running in the tenant's\nsandboxes — do not place secrets here that scripts must not access.",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"sandbox_type": {
"description": "SandboxType selects the sandbox backend. Named configs may use \"cube\",\n\"e2b\", \"docker\", or \"local\". \"disabled\" is reserved for the hidden\nworkspace policy row.",
"type": "string"
},
"volume_mount": {
"description": "VolumeMount configures an optional shared volume mounted into every\nsandbox created for this tenant. Currently used for tenant-installed\nskills, but the configuration itself is skill-agnostic and can serve\nany volume-mount use case (shared datasets, pre-installed toolchains,\netc.).",
"allOf": [
{
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.VolumeMountConfig"
}
]
}
}
},
"github_com_Tencent_WeKnora_internal_types.ToolCall": {
"type": "object",
"properties": {
@@ -19902,6 +20499,35 @@ const docTemplate = `{
}
}
},
"github_com_Tencent_WeKnora_internal_types.VolumeMountConfig": {
"type": "object",
"properties": {
"enabled": {
"description": "Enabled toggles the volume mount for this tenant.",
"type": "boolean"
},
"mount_path": {
"description": "MountPath is the sandbox-internal path where the volume is mounted.\nDefault: /weknora/tenant/skills (customizable per use case).",
"type": "string"
},
"provider": {
"description": "Provider identifies the volume backend. Currently \"e2b\" or \"cube\".",
"type": "string"
},
"volume_id": {
"description": "VolumeID is the provider-specific volume identifier, populated after\nEnsureVolume / CreateVolume succeeds.",
"type": "string"
},
"volume_name": {
"description": "VolumeName is the human-readable volume name, e.g.\n\"weknora-tenant-\u003cid\u003e-skills\".",
"type": "string"
},
"volume_owner_fingerprint": {
"description": "VolumeOwnerFingerprint = sha256(provider + APIKey + APIURL).\nUsed to detect when the tenant switched to a different backend or\nAPI key, at which point the volume is no longer reachable and must\nbe recreated.",
"type": "string"
}
}
},
"github_com_Tencent_WeKnora_internal_types.WeKnoraCloudCredentials": {
"type": "object",
"properties": {
@@ -20056,6 +20682,7 @@ const docTemplate = `{
"searxng",
"keenable",
"zhipu",
"exa",
"metaso"
],
"x-enum-varnames": [
@@ -20068,6 +20695,7 @@ const docTemplate = `{
"WebSearchProviderTypeSearxng",
"WebSearchProviderTypeKeenable",
"WebSearchProviderTypeZhipu",
"WebSearchProviderTypeExa",
"WebSearchProviderTypeMetaso"
]
},
@@ -21863,6 +22491,67 @@ const docTemplate = `{
}
}
},
"internal_handler.SandboxCheckItem": {
"type": "object",
"properties": {
"latency_ms": {
"type": "integer"
},
"message": {
"description": "Message carries free-form provider detail for an executed probe.",
"type": "string"
},
"name": {
"type": "string"
},
"ok": {
"type": "boolean"
},
"reason": {
"description": "Reason is a stable code explaining why a probe was skipped. It exists so\nthe UI can phrase the skip in the operator's language instead of echoing\na server-side sentence.",
"type": "string"
}
}
},
"internal_handler.SandboxCheckRequest": {
"type": "object",
"properties": {
"config": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.TenantSandboxConfig"
},
"config_id": {
"description": "ConfigID lets an edit form test stored credentials while overriding only\nthe fields the admin changed in the drawer.",
"type": "string"
},
"deep": {
"description": "Deep additionally runs a throwaway script. For remote backends this also\ncreates and destroys one sandbox, which is the only way to validate the\ntemplate ID, data plane, in-sandbox execution, and outbound egress. It may\nconsume real sandbox time, so it is opt-in.",
"type": "boolean"
}
}
},
"internal_handler.SandboxCheckResponse": {
"type": "object",
"properties": {
"capabilities": {
"type": "object",
"additionalProperties": {
"type": "boolean"
}
},
"checks": {
"type": "array",
"items": {
"$ref": "#/definitions/internal_handler.SandboxCheckItem"
}
},
"ok": {
"type": "boolean"
},
"provider": {
"type": "string"
}
}
},
"internal_handler.SearchMessagesRequest": {
"type": "object",
"required": [
@@ -22369,6 +23058,23 @@ const docTemplate = `{
}
}
},
"internal_handler.sandboxConfigRequest": {
"type": "object",
"required": [
"name"
],
"properties": {
"config": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.TenantSandboxConfig"
},
"description": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"internal_handler.storageBackendRequest": {
"type": "object",
"required": [
+706
View File
@@ -1154,6 +1154,20 @@
}
}
},
"/auth/oidc/start": {
"get": {
"description": "与 /auth/oidc/url 不同,此端点直接 302 重定向到 OIDC Provider 的授权页,\n无需前端 JS 介入。适用于外部平台(如企业门户)直接给出一个链接即可\n触发 OIDC 授权码流程,借助 IdP 的 SSO session 实现免再次输密码。",
"tags": [
"认证"
],
"summary": "发起 OIDC 登录(直接 302",
"responses": {
"302": {
"description": "Found"
}
}
}
},
"/auth/oidc/url": {
"get": {
"description": "根据后端OIDC配置生成第三方登录跳转地址",
@@ -10598,6 +10612,329 @@
}
}
},
"/sandbox-configs": {
"get": {
"security": [
{
"Bearer": []
},
{
"ApiKeyAuth": []
}
],
"description": "List workspace sandbox backend configs with credentials masked.",
"produces": [
"application/json"
],
"tags": [
"SandboxConfig"
],
"summary": "List sandbox configs",
"responses": {
"200": {
"description": "Sandbox configs and defaults",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"post": {
"security": [
{
"Bearer": []
},
{
"ApiKeyAuth": []
}
],
"description": "Create a named workspace sandbox backend config. Credentials are masked in the response.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"SandboxConfig"
],
"summary": "Create sandbox config",
"parameters": [
{
"description": "Sandbox backend config",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_handler.sandboxConfigRequest"
}
}
],
"responses": {
"201": {
"description": "Created sandbox config",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Invalid request or validation failure",
"schema": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/sandbox-configs/{id}": {
"get": {
"security": [
{
"Bearer": []
},
{
"ApiKeyAuth": []
}
],
"description": "Retrieve a workspace sandbox backend config with credentials masked.",
"produces": [
"application/json"
],
"tags": [
"SandboxConfig"
],
"summary": "Get sandbox config",
"parameters": [
{
"type": "string",
"description": "Sandbox config ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Sandbox config",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Sandbox config not found",
"schema": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
}
}
}
},
"put": {
"security": [
{
"Bearer": []
},
{
"ApiKeyAuth": []
}
],
"description": "Update a sandbox backend config. Identity-field changes are refused while the config owns live or paused sandboxes.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"SandboxConfig"
],
"summary": "Update sandbox config",
"parameters": [
{
"type": "string",
"description": "Sandbox config ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Updated sandbox config",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_handler.sandboxConfigRequest"
}
}
],
"responses": {
"200": {
"description": "Updated sandbox config",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Invalid request or validation failure",
"schema": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Sandbox config not found",
"schema": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
}
},
"409": {
"description": "Live sandboxes or unverifiable inventory",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"423": {
"description": "Sandbox config is being modified by another request",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"delete": {
"security": [
{
"Bearer": []
},
{
"ApiKeyAuth": []
}
],
"description": "Soft-delete a sandbox backend config. force=true only overrides unverifiable provider inventory, never confirmed live sandboxes.",
"produces": [
"application/json"
],
"tags": [
"SandboxConfig"
],
"summary": "Delete sandbox config",
"parameters": [
{
"type": "string",
"description": "Sandbox config ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "boolean",
"description": "Force delete when inventory is unverifiable",
"name": "force",
"in": "query"
}
],
"responses": {
"200": {
"description": "Deletion success",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"409": {
"description": "Live sandboxes or unverifiable inventory",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/sandbox-configs/{id}/sandboxes": {
"get": {
"security": [
{
"Bearer": []
},
{
"ApiKeyAuth": []
}
],
"description": "Return live/paused sandbox inventory and affected agent names for one config.",
"produces": [
"application/json"
],
"tags": [
"SandboxConfig"
],
"summary": "Inspect sandbox config inventory",
"parameters": [
{
"type": "string",
"description": "Sandbox config ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Sandbox inventory",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/sessions": {
"get": {
"security": [
@@ -11132,6 +11469,57 @@
}
}
},
"/sessions/{session_id}/artifacts": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "返回本会话中所有 assistant 消息产生的技能产物元数据(不含 URL)",
"produces": [
"application/json"
],
"tags": [
"会话"
],
"summary": "列出会话生成的产物文件",
"parameters": [
{
"type": "string",
"description": "会话ID",
"name": "session_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
}
}
}
}
},
"/sessions/{session_id}/messages/{message_id}/artifacts": {
"get": {
"responses": {}
}
},
"/sessions/{session_id}/messages/{message_id}/artifacts/{index}/download": {
"get": {
"responses": {}
}
},
"/sessions/{session_id}/messages/{message_id}/suggestions": {
"get": {
"security": [
@@ -12689,6 +13077,27 @@
}
}
},
"/system/capabilities": {
"get": {
"description": "返回当前部署版本及实际注册的后端路由所对应的功能能力;仅 supported=false 表示入口应隐藏",
"produces": [
"application/json"
],
"tags": [
"系统"
],
"summary": "获取部署能力清单",
"responses": {
"200": {
"description": "标准 code/msg/data 包装,data 为 DeploymentCapabilitiesData",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/system/docreader/reconnect": {
"post": {
"consumes": [
@@ -12792,6 +13201,40 @@
}
}
},
"/system/sandbox-check": {
"post": {
"description": "使用当前填写的参数测试沙箱后端,不保存配置;deep=true 会执行临时脚本,远端后端还会创建并销毁一个沙箱",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"系统"
],
"summary": "测试沙箱连通性",
"parameters": [
{
"description": "沙箱配置",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_handler.SandboxCheckRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/internal_handler.SandboxCheckResponse"
}
}
}
}
},
"/system/storage-engine-check": {
"post": {
"description": "使用当前填写的参数测试 MinIO/COS 连通性,不保存配置",
@@ -15756,6 +16199,34 @@
}
}
},
"github_com_Tencent_WeKnora_internal_types.CubeSandboxConfig": {
"type": "object",
"properties": {
"api_key": {
"description": "加密",
"type": "string"
},
"api_url": {
"type": "string"
},
"cube_sandbox_ttl_seconds": {
"type": "integer"
},
"http_timeout_sec": {
"description": "HTTPTimeoutSec bounds each HTTP call to the sandbox control plane.\n0 means use the built-in default (30s), never the deployment's value.",
"type": "integer"
},
"proxy_url": {
"type": "string"
},
"sandbox_domain": {
"type": "string"
},
"template_id": {
"type": "string"
}
}
},
"github_com_Tencent_WeKnora_internal_types.CustomAgentConfig": {
"type": "object",
"properties": {
@@ -15962,6 +16433,10 @@
"description": "Rewrite prompt user message template",
"type": "string"
},
"sandbox_config_id": {
"description": "===== Sandbox Settings =====\nSandboxConfigID selects which workspace sandbox config this agent's\nskill scripts run on. Empty means sandbox execution is disabled.\n\nThis references the LOGICAL config, never a specific revision: keeping\nthe indirection here is what would let credential rotation happen\nwithout re-pointing every agent (see the spec's §4.8).",
"type": "string"
},
"selected_skills": {
"description": "Selected skill names (only used when SkillsSelectionMode is \"selected\")",
"type": "array",
@@ -16132,6 +16607,43 @@
}
}
},
"github_com_Tencent_WeKnora_internal_types.DockerSandboxConfig": {
"type": "object",
"properties": {
"image": {
"type": "string"
}
}
},
"github_com_Tencent_WeKnora_internal_types.E2BSandboxConfig": {
"type": "object",
"properties": {
"api_key": {
"description": "加密",
"type": "string"
},
"api_url": {
"type": "string"
},
"e2b_sandbox_ttl_seconds": {
"type": "integer"
},
"http_timeout_sec": {
"description": "HTTPTimeoutSec bounds each HTTP call to the sandbox control plane.\n0 means use the built-in default (30s), never the deployment's value.",
"type": "integer"
},
"proxy_url": {
"description": "ProxyURL is the data-plane gateway that fronts envd. E2B Cloud resolves\n\"\u003cport\u003e-\u003csandboxID\u003e.\u003csandbox_domain\u003e\" through public DNS and TLS, so it\nneeds no value here. Self-hosted E2B-compatible control planes usually\nserve every sandbox from one gateway address and expect the sandbox\nauthority in the Host header; setting this makes WeKnora dial the\ngateway directly instead of requiring wildcard DNS and a certificate\nfor the sandbox domain. An \"http://\" gateway also downgrades the\ndata-plane scheme, which the E2B SDK otherwise pins to https.",
"type": "string"
},
"sandbox_domain": {
"type": "string"
},
"template_id": {
"type": "string"
}
}
},
"github_com_Tencent_WeKnora_internal_types.EmbeddingParameters": {
"type": "object",
"properties": {
@@ -17652,6 +18164,13 @@
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.AgentStep"
}
},
"artifacts": {
"description": "Skill-generated files produced during this assistant turn (assistant messages only).\nPopulated by ArtifactCollector after the sandbox finishes, referenced by the\nartifact download endpoint. Empty for user messages and turns without skills.",
"type": "array",
"items": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.MessageArtifact"
}
},
"attachments": {
"description": "Attached files (documents, audio, etc., for user messages)",
"type": "array",
@@ -17738,6 +18257,39 @@
}
}
},
"github_com_Tencent_WeKnora_internal_types.MessageArtifact": {
"type": "object",
"properties": {
"created_at": {
"description": "When WeKnora persisted the blob",
"type": "string"
},
"file_name": {
"description": "Original filename inside the sandbox",
"type": "string"
},
"file_size": {
"description": "File size in bytes",
"type": "integer"
},
"file_type": {
"description": "File extension (e.g., \".pptx\", \".pdf\")",
"type": "string"
},
"mod_time": {
"description": "Sandbox-side modification time (used for diff)",
"type": "string"
},
"source_path": {
"description": "Absolute path inside the sandbox (used for diff)",
"type": "string"
},
"url": {
"description": "Storage URL (provider://path); persisted, not sent to client",
"type": "string"
}
}
},
"github_com_Tencent_WeKnora_internal_types.MessageAttachment": {
"type": "object",
"properties": {
@@ -19043,6 +19595,10 @@
"description": "PinnedAt records when the session was pinned; nil when not pinned.",
"type": "string"
},
"sandbox_config_id": {
"description": "SandboxConfigID pins which sandbox config this session's CURRENT live\nsandbox was created on. Empty means no live sandbox;\nSandboxConfigIDGlobalDefault means the deployment-wide default config.\n\nThis is an ephemeral pin that dies with the sandbox, not a permanent\nowner: sessions outlive sandboxes by months, so treating it as\npermanent would make \"no session references this config\" never true.",
"type": "string"
},
"tenant_id": {
"description": "Workspace ID",
"type": "integer"
@@ -19604,6 +20160,47 @@
"TenantRoleViewer"
]
},
"github_com_Tencent_WeKnora_internal_types.TenantSandboxConfig": {
"type": "object",
"properties": {
"allow_private_endpoints": {
"description": "AllowPrivateEndpoints permits this workspace config to reach RFC1918 or\nloopback cluster endpoints. Link-local/cloud-metadata addresses remain\nblocked. It is explicit in the UI instead of hidden in process env.",
"type": "boolean"
},
"cube": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.CubeSandboxConfig"
},
"default_timeout_sec": {
"description": "DefaultTimeoutSec is the per-execution timeout in seconds. 0 uses the\nprogram's built-in default.",
"type": "integer"
},
"docker": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.DockerSandboxConfig"
},
"e2b": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.E2BSandboxConfig"
},
"env_vars": {
"description": "EnvVars are additional environment variables injected into every\nsandbox created for this tenant. 🔒 Values are encrypted at rest.\nThese become visible to all scripts running in the tenant's\nsandboxes — do not place secrets here that scripts must not access.",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"sandbox_type": {
"description": "SandboxType selects the sandbox backend. Named configs may use \"cube\",\n\"e2b\", \"docker\", or \"local\". \"disabled\" is reserved for the hidden\nworkspace policy row.",
"type": "string"
},
"volume_mount": {
"description": "VolumeMount configures an optional shared volume mounted into every\nsandbox created for this tenant. Currently used for tenant-installed\nskills, but the configuration itself is skill-agnostic and can serve\nany volume-mount use case (shared datasets, pre-installed toolchains,\netc.).",
"allOf": [
{
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.VolumeMountConfig"
}
]
}
}
},
"github_com_Tencent_WeKnora_internal_types.ToolCall": {
"type": "object",
"properties": {
@@ -19895,6 +20492,35 @@
}
}
},
"github_com_Tencent_WeKnora_internal_types.VolumeMountConfig": {
"type": "object",
"properties": {
"enabled": {
"description": "Enabled toggles the volume mount for this tenant.",
"type": "boolean"
},
"mount_path": {
"description": "MountPath is the sandbox-internal path where the volume is mounted.\nDefault: /weknora/tenant/skills (customizable per use case).",
"type": "string"
},
"provider": {
"description": "Provider identifies the volume backend. Currently \"e2b\" or \"cube\".",
"type": "string"
},
"volume_id": {
"description": "VolumeID is the provider-specific volume identifier, populated after\nEnsureVolume / CreateVolume succeeds.",
"type": "string"
},
"volume_name": {
"description": "VolumeName is the human-readable volume name, e.g.\n\"weknora-tenant-\u003cid\u003e-skills\".",
"type": "string"
},
"volume_owner_fingerprint": {
"description": "VolumeOwnerFingerprint = sha256(provider + APIKey + APIURL).\nUsed to detect when the tenant switched to a different backend or\nAPI key, at which point the volume is no longer reachable and must\nbe recreated.",
"type": "string"
}
}
},
"github_com_Tencent_WeKnora_internal_types.WeKnoraCloudCredentials": {
"type": "object",
"properties": {
@@ -20049,6 +20675,7 @@
"searxng",
"keenable",
"zhipu",
"exa",
"metaso"
],
"x-enum-varnames": [
@@ -20061,6 +20688,7 @@
"WebSearchProviderTypeSearxng",
"WebSearchProviderTypeKeenable",
"WebSearchProviderTypeZhipu",
"WebSearchProviderTypeExa",
"WebSearchProviderTypeMetaso"
]
},
@@ -21856,6 +22484,67 @@
}
}
},
"internal_handler.SandboxCheckItem": {
"type": "object",
"properties": {
"latency_ms": {
"type": "integer"
},
"message": {
"description": "Message carries free-form provider detail for an executed probe.",
"type": "string"
},
"name": {
"type": "string"
},
"ok": {
"type": "boolean"
},
"reason": {
"description": "Reason is a stable code explaining why a probe was skipped. It exists so\nthe UI can phrase the skip in the operator's language instead of echoing\na server-side sentence.",
"type": "string"
}
}
},
"internal_handler.SandboxCheckRequest": {
"type": "object",
"properties": {
"config": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.TenantSandboxConfig"
},
"config_id": {
"description": "ConfigID lets an edit form test stored credentials while overriding only\nthe fields the admin changed in the drawer.",
"type": "string"
},
"deep": {
"description": "Deep additionally runs a throwaway script. For remote backends this also\ncreates and destroys one sandbox, which is the only way to validate the\ntemplate ID, data plane, in-sandbox execution, and outbound egress. It may\nconsume real sandbox time, so it is opt-in.",
"type": "boolean"
}
}
},
"internal_handler.SandboxCheckResponse": {
"type": "object",
"properties": {
"capabilities": {
"type": "object",
"additionalProperties": {
"type": "boolean"
}
},
"checks": {
"type": "array",
"items": {
"$ref": "#/definitions/internal_handler.SandboxCheckItem"
}
},
"ok": {
"type": "boolean"
},
"provider": {
"type": "string"
}
}
},
"internal_handler.SearchMessagesRequest": {
"type": "object",
"required": [
@@ -22362,6 +23051,23 @@
}
}
},
"internal_handler.sandboxConfigRequest": {
"type": "object",
"required": [
"name"
],
"properties": {
"config": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.TenantSandboxConfig"
},
"description": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"internal_handler.storageBackendRequest": {
"type": "object",
"required": [
+534
View File
@@ -672,6 +672,27 @@ definitions:
weknoracloud:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.WeKnoraCloudCredentials'
type: object
github_com_Tencent_WeKnora_internal_types.CubeSandboxConfig:
properties:
api_key:
description: 加密
type: string
api_url:
type: string
cube_sandbox_ttl_seconds:
type: integer
http_timeout_sec:
description: |-
HTTPTimeoutSec bounds each HTTP call to the sandbox control plane.
0 means use the built-in default (30s), never the deployment's value.
type: integer
proxy_url:
type: string
sandbox_domain:
type: string
template_id:
type: string
type: object
github_com_Tencent_WeKnora_internal_types.CustomAgentConfig:
properties:
agent_mode:
@@ -895,6 +916,16 @@ definitions:
rewrite_prompt_user:
description: Rewrite prompt user message template
type: string
sandbox_config_id:
description: |-
===== Sandbox Settings =====
SandboxConfigID selects which workspace sandbox config this agent's
skill scripts run on. Empty means sandbox execution is disabled.
This references the LOGICAL config, never a specific revision: keeping
the indirection here is what would let credential rotation happen
without re-pointing every agent (see the spec's §4.8).
type: string
selected_skills:
description: Selected skill names (only used when SkillsSelectionMode is "selected")
items:
@@ -1035,6 +1066,41 @@ definitions:
description: Last update timestamp
type: string
type: object
github_com_Tencent_WeKnora_internal_types.DockerSandboxConfig:
properties:
image:
type: string
type: object
github_com_Tencent_WeKnora_internal_types.E2BSandboxConfig:
properties:
api_key:
description: 加密
type: string
api_url:
type: string
e2b_sandbox_ttl_seconds:
type: integer
http_timeout_sec:
description: |-
HTTPTimeoutSec bounds each HTTP call to the sandbox control plane.
0 means use the built-in default (30s), never the deployment's value.
type: integer
proxy_url:
description: |-
ProxyURL is the data-plane gateway that fronts envd. E2B Cloud resolves
"<port>-<sandboxID>.<sandbox_domain>" through public DNS and TLS, so it
needs no value here. Self-hosted E2B-compatible control planes usually
serve every sandbox from one gateway address and expect the sandbox
authority in the Host header; setting this makes WeKnora dial the
gateway directly instead of requiring wildcard DNS and a certificate
for the sandbox domain. An "http://" gateway also downgrades the
data-plane scheme, which the E2B SDK otherwise pins to https.
type: string
sandbox_domain:
type: string
template_id:
type: string
type: object
github_com_Tencent_WeKnora_internal_types.EmbeddingParameters:
properties:
dimension:
@@ -2160,6 +2226,14 @@ definitions:
items:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.AgentStep'
type: array
artifacts:
description: |-
Skill-generated files produced during this assistant turn (assistant messages only).
Populated by ArtifactCollector after the sandbox finishes, referenced by the
artifact download endpoint. Empty for user messages and turns without skills.
items:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.MessageArtifact'
type: array
attachments:
description: Attached files (documents, audio, etc., for user messages)
items:
@@ -2229,6 +2303,30 @@ definitions:
description: Last update timestamp
type: string
type: object
github_com_Tencent_WeKnora_internal_types.MessageArtifact:
properties:
created_at:
description: When WeKnora persisted the blob
type: string
file_name:
description: Original filename inside the sandbox
type: string
file_size:
description: File size in bytes
type: integer
file_type:
description: File extension (e.g., ".pptx", ".pdf")
type: string
mod_time:
description: Sandbox-side modification time (used for diff)
type: string
source_path:
description: Absolute path inside the sandbox (used for diff)
type: string
url:
description: Storage URL (provider://path); persisted, not sent to client
type: string
type: object
github_com_Tencent_WeKnora_internal_types.MessageAttachment:
properties:
content:
@@ -3227,6 +3325,16 @@ definitions:
pinned_at:
description: PinnedAt records when the session was pinned; nil when not pinned.
type: string
sandbox_config_id:
description: |-
SandboxConfigID pins which sandbox config this session's CURRENT live
sandbox was created on. Empty means no live sandbox;
SandboxConfigIDGlobalDefault means the deployment-wide default config.
This is an ephemeral pin that dies with the sandbox, not a permanent
owner: sessions outlive sandboxes by months, so treating it as
permanent would make "no session references this config" never true.
type: string
tenant_id:
description: Workspace ID
type: integer
@@ -3634,6 +3742,49 @@ definitions:
- TenantRoleAdmin
- TenantRoleContributor
- TenantRoleViewer
github_com_Tencent_WeKnora_internal_types.TenantSandboxConfig:
properties:
allow_private_endpoints:
description: |-
AllowPrivateEndpoints permits this workspace config to reach RFC1918 or
loopback cluster endpoints. Link-local/cloud-metadata addresses remain
blocked. It is explicit in the UI instead of hidden in process env.
type: boolean
cube:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.CubeSandboxConfig'
default_timeout_sec:
description: |-
DefaultTimeoutSec is the per-execution timeout in seconds. 0 uses the
program's built-in default.
type: integer
docker:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.DockerSandboxConfig'
e2b:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.E2BSandboxConfig'
env_vars:
additionalProperties:
type: string
description: "EnvVars are additional environment variables injected into every\nsandbox
created for this tenant. \U0001F512 Values are encrypted at rest.\nThese
become visible to all scripts running in the tenant's\nsandboxes — do not
place secrets here that scripts must not access."
type: object
sandbox_type:
description: |-
SandboxType selects the sandbox backend. Named configs may use "cube",
"e2b", "docker", or "local". "disabled" is reserved for the hidden
workspace policy row.
type: string
volume_mount:
allOf:
- $ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.VolumeMountConfig'
description: |-
VolumeMount configures an optional shared volume mounted into every
sandbox created for this tenant. Currently used for tenant-installed
skills, but the configuration itself is skill-agnostic and can serve
any volume-mount use case (shared datasets, pre-installed toolchains,
etc.).
type: object
github_com_Tencent_WeKnora_internal_types.ToolCall:
properties:
args:
@@ -3859,6 +4010,37 @@ definitions:
Model Name
type: string
type: object
github_com_Tencent_WeKnora_internal_types.VolumeMountConfig:
properties:
enabled:
description: Enabled toggles the volume mount for this tenant.
type: boolean
mount_path:
description: |-
MountPath is the sandbox-internal path where the volume is mounted.
Default: /weknora/tenant/skills (customizable per use case).
type: string
provider:
description: Provider identifies the volume backend. Currently "e2b" or "cube".
type: string
volume_id:
description: |-
VolumeID is the provider-specific volume identifier, populated after
EnsureVolume / CreateVolume succeeds.
type: string
volume_name:
description: |-
VolumeName is the human-readable volume name, e.g.
"weknora-tenant-<id>-skills".
type: string
volume_owner_fingerprint:
description: |-
VolumeOwnerFingerprint = sha256(provider + APIKey + APIURL).
Used to detect when the tenant switched to a different backend or
API key, at which point the volume is no longer reachable and must
be recreated.
type: string
type: object
github_com_Tencent_WeKnora_internal_types.WeKnoraCloudCredentials:
properties:
app_id:
@@ -3974,6 +4156,7 @@ definitions:
- searxng
- keenable
- zhipu
- exa
- metaso
type: string
x-enum-varnames:
@@ -3986,6 +4169,7 @@ definitions:
- WebSearchProviderTypeSearxng
- WebSearchProviderTypeKeenable
- WebSearchProviderTypeZhipu
- WebSearchProviderTypeExa
- WebSearchProviderTypeMetaso
github_com_Tencent_WeKnora_internal_types.WikiConfig:
properties:
@@ -5295,6 +5479,56 @@ definitions:
description: Active / ClusterCapacity
type: number
type: object
internal_handler.SandboxCheckItem:
properties:
latency_ms:
type: integer
message:
description: Message carries free-form provider detail for an executed probe.
type: string
name:
type: string
ok:
type: boolean
reason:
description: |-
Reason is a stable code explaining why a probe was skipped. It exists so
the UI can phrase the skip in the operator's language instead of echoing
a server-side sentence.
type: string
type: object
internal_handler.SandboxCheckRequest:
properties:
config:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.TenantSandboxConfig'
config_id:
description: |-
ConfigID lets an edit form test stored credentials while overriding only
the fields the admin changed in the drawer.
type: string
deep:
description: |-
Deep additionally runs a throwaway script. For remote backends this also
creates and destroys one sandbox, which is the only way to validate the
template ID, data plane, in-sandbox execution, and outbound egress. It may
consume real sandbox time, so it is opt-in.
type: boolean
type: object
internal_handler.SandboxCheckResponse:
properties:
capabilities:
additionalProperties:
type: boolean
type: object
checks:
items:
$ref: '#/definitions/internal_handler.SandboxCheckItem'
type: array
ok:
type: boolean
provider:
type: string
type: object
internal_handler.SearchMessagesRequest:
properties:
limit:
@@ -5633,6 +5867,17 @@ definitions:
type: string
type: array
type: object
internal_handler.sandboxConfigRequest:
properties:
config:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.TenantSandboxConfig'
description:
type: string
name:
type: string
required:
- name
type: object
internal_handler.storageBackendRequest:
properties:
config:
@@ -6608,6 +6853,18 @@ paths:
summary: 获取OIDC登录配置
tags:
- 认证
/auth/oidc/start:
get:
description: |-
与 /auth/oidc/url 不同,此端点直接 302 重定向到 OIDC Provider 的授权页,
无需前端 JS 介入。适用于外部平台(如企业门户)直接给出一个链接即可
触发 OIDC 授权码流程,借助 IdP 的 SSO session 实现免再次输密码。
responses:
"302":
description: Found
summary: 发起 OIDC 登录(直接 302
tags:
- 认证
/auth/oidc/url:
get:
consumes:
@@ -12626,6 +12883,215 @@ paths:
summary: 搜索可加入的空间
tags:
- 组织管理
/sandbox-configs:
get:
description: List workspace sandbox backend configs with credentials masked.
produces:
- application/json
responses:
"200":
description: Sandbox configs and defaults
schema:
additionalProperties: true
type: object
"401":
description: Unauthorized
schema:
additionalProperties: true
type: object
security:
- Bearer: []
- ApiKeyAuth: []
summary: List sandbox configs
tags:
- SandboxConfig
post:
consumes:
- application/json
description: Create a named workspace sandbox backend config. Credentials are
masked in the response.
parameters:
- description: Sandbox backend config
in: body
name: request
required: true
schema:
$ref: '#/definitions/internal_handler.sandboxConfigRequest'
produces:
- application/json
responses:
"201":
description: Created sandbox config
schema:
additionalProperties: true
type: object
"400":
description: Invalid request or validation failure
schema:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError'
"401":
description: Unauthorized
schema:
additionalProperties: true
type: object
security:
- Bearer: []
- ApiKeyAuth: []
summary: Create sandbox config
tags:
- SandboxConfig
/sandbox-configs/{id}:
delete:
description: Soft-delete a sandbox backend config. force=true only overrides
unverifiable provider inventory, never confirmed live sandboxes.
parameters:
- description: Sandbox config ID
in: path
name: id
required: true
type: string
- description: Force delete when inventory is unverifiable
in: query
name: force
type: boolean
produces:
- application/json
responses:
"200":
description: Deletion success
schema:
additionalProperties: true
type: object
"401":
description: Unauthorized
schema:
additionalProperties: true
type: object
"409":
description: Live sandboxes or unverifiable inventory
schema:
additionalProperties: true
type: object
security:
- Bearer: []
- ApiKeyAuth: []
summary: Delete sandbox config
tags:
- SandboxConfig
get:
description: Retrieve a workspace sandbox backend config with credentials masked.
parameters:
- description: Sandbox config ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: Sandbox config
schema:
additionalProperties: true
type: object
"401":
description: Unauthorized
schema:
additionalProperties: true
type: object
"404":
description: Sandbox config not found
schema:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError'
security:
- Bearer: []
- ApiKeyAuth: []
summary: Get sandbox config
tags:
- SandboxConfig
put:
consumes:
- application/json
description: Update a sandbox backend config. Identity-field changes are refused
while the config owns live or paused sandboxes.
parameters:
- description: Sandbox config ID
in: path
name: id
required: true
type: string
- description: Updated sandbox config
in: body
name: request
required: true
schema:
$ref: '#/definitions/internal_handler.sandboxConfigRequest'
produces:
- application/json
responses:
"200":
description: Updated sandbox config
schema:
additionalProperties: true
type: object
"400":
description: Invalid request or validation failure
schema:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError'
"401":
description: Unauthorized
schema:
additionalProperties: true
type: object
"404":
description: Sandbox config not found
schema:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError'
"409":
description: Live sandboxes or unverifiable inventory
schema:
additionalProperties: true
type: object
"423":
description: Sandbox config is being modified by another request
schema:
additionalProperties: true
type: object
security:
- Bearer: []
- ApiKeyAuth: []
summary: Update sandbox config
tags:
- SandboxConfig
/sandbox-configs/{id}/sandboxes:
get:
description: Return live/paused sandbox inventory and affected agent names for
one config.
parameters:
- description: Sandbox config ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: Sandbox inventory
schema:
additionalProperties: true
type: object
"401":
description: Unauthorized
schema:
additionalProperties: true
type: object
security:
- Bearer: []
- ApiKeyAuth: []
summary: Inspect sandbox config inventory
tags:
- SandboxConfig
/sessions:
get:
consumes:
@@ -12851,6 +13317,38 @@ paths:
summary: 取消置顶会话
tags:
- 会话
/sessions/{session_id}/artifacts:
get:
description: 返回本会话中所有 assistant 消息产生的技能产物元数据(不含 URL)
parameters:
- description: 会话ID
in: path
name: session_id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"404":
description: Not Found
schema:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError'
security:
- Bearer: []
summary: 列出会话生成的产物文件
tags:
- 会话
/sessions/{session_id}/messages/{message_id}/artifacts:
get:
responses: {}
/sessions/{session_id}/messages/{message_id}/artifacts/{index}/download:
get:
responses: {}
/sessions/{session_id}/messages/{message_id}/suggestions:
get:
parameters:
@@ -14011,6 +14509,20 @@ paths:
summary: Reset another user's password
tags:
- System Admin
/system/capabilities:
get:
description: 返回当前部署版本及实际注册的后端路由所对应的功能能力;仅 supported=false 表示入口应隐藏
produces:
- application/json
responses:
"200":
description: 标准 code/msg/data 包装,data 为 DeploymentCapabilitiesData
schema:
additionalProperties: true
type: object
summary: 获取部署能力清单
tags:
- 系统
/system/docreader/reconnect:
post:
consumes:
@@ -14077,6 +14589,28 @@ paths:
summary: 使用当前参数检测解析引擎可用性
tags:
- 系统
/system/sandbox-check:
post:
consumes:
- application/json
description: 使用当前填写的参数测试沙箱后端,不保存配置;deep=true 会执行临时脚本,远端后端还会创建并销毁一个沙箱
parameters:
- description: 沙箱配置
in: body
name: body
required: true
schema:
$ref: '#/definitions/internal_handler.SandboxCheckRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/internal_handler.SandboxCheckResponse'
summary: 测试沙箱连通性
tags:
- 系统
/system/storage-engine-check:
post:
consumes:
+14
View File
@@ -45,6 +45,20 @@ export interface SystemInfo {
uptime_seconds?: number
}
export interface DeploymentCapability {
supported: boolean
reason?: string
}
export interface DeploymentCapabilitiesResponse {
edition: string
capabilities: Record<string, DeploymentCapability>
}
export function getDeploymentCapabilities(): Promise<{ data: DeploymentCapabilitiesResponse }> {
return get('/api/v1/system/capabilities')
}
export interface PlaceholderDefinition {
name: string
label: string
@@ -165,6 +165,7 @@ import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useCommandPaletteStore } from '@/stores/commandPalette'
import { useAuthStore } from '@/stores/auth'
import { useDeploymentCapabilitiesStore } from '@/stores/deploymentCapabilities'
import { useCmdkSearch, type CmdkFileGroup, type CmdkChunk, type CmdkMsgGroup } from './GlobalCommandPalette/useSearch'
import { highlightText } from './GlobalCommandPalette/useHighlight'
import { useStartChat } from './GlobalCommandPalette/useStartChat'
@@ -179,6 +180,7 @@ const route = useRoute()
const router = useRouter()
const commandPaletteStore = useCommandPaletteStore()
const authStore = useAuthStore()
const deploymentCapabilities = useDeploymentCapabilitiesStore()
const { open, initialQuery, recentQueries } = storeToRefs(commandPaletteStore)
const { startChat } = useStartChat()
@@ -200,6 +202,7 @@ const {
clearResults,
} = useCmdkSearch({
lockedKbIds: () => (activeKbScope.value ? [activeKbScope.value.id] : []),
agentsEnabled: () => deploymentCapabilities.isSupported('agents'),
})
const drawerVisible = ref(false)
@@ -256,11 +259,15 @@ const allCommands = computed(() => {
t,
close: () => commandPaletteStore.closePalette(),
})
// 共享空间入口与侧栏菜单保持一致:viewer / contributor 看不到。
if (!authStore.hasRole('admin')) {
return cmds.filter((c) => c.id !== 'open-organizations')
}
return cmds
return cmds.filter((command) => {
if (command.id === 'open-agents') {
return deploymentCapabilities.isSupported('agents')
}
if (command.id === 'open-organizations') {
return authStore.hasRole('admin') && deploymentCapabilities.isSupported('organizations')
}
return true
})
})
const filteredCommands = computed(() => filterCommands(allCommands.value, query.value))
@@ -74,6 +74,8 @@ export function useCmdkSearch(options: {
chunkLimit?: number
/** Debounce delay in ms. */
debounceMs?: number
/** 当前部署是否提供智能体路由;不提供时不发起预加载请求。 */
agentsEnabled?: () => boolean
}) {
const debounceMs = options.debounceMs ?? 350
const query = ref('')
@@ -149,6 +151,7 @@ export function useCmdkSearch(options: {
// Agents (own + shared). Lazily loaded & cached; no backend search endpoint
// exists so we always filter client-side.
const ensureAgents = async (): Promise<void> => {
if (options.agentsEnabled?.() === false) return
if (agentsLoaded.value) return
if (agentsLoadingPromise) return agentsLoadingPromise
agentsLoadingPromise = (async () => {
@@ -188,6 +191,7 @@ export function useCmdkSearch(options: {
}
const agentMatches = computed<CmdkAgent[]>(() => {
if (options.agentsEnabled?.() === false) return []
const q = query.value.trim().toLowerCase()
if (!q) return []
return agents.value
@@ -350,7 +354,9 @@ export function useCmdkSearch(options: {
// keystroke. Sessions come from the menuStore (already populated by the
// sidebar) so no extra fetch needed here.
ensureKbs()
ensureAgents()
if (options.agentsEnabled?.() !== false) {
ensureAgents()
}
})
return {
+3 -1
View File
@@ -247,6 +247,7 @@ import {
import { logout as logoutApi } from '@/api/auth';
import { useMenuStore } from '@/stores/menu';
import { useAuthStore } from '@/stores/auth';
import { useDeploymentCapabilitiesStore } from '@/stores/deploymentCapabilities';
import { useOrganizationStore } from '@/stores/organization';
import { useUIStore } from '@/stores/ui';
import { useCommandPaletteStore } from '@/stores/commandPalette';
@@ -286,6 +287,7 @@ const platformLogo = (p: string): string => (p ? PLATFORM_LOGO[p] || '' : '');
const { t } = useI18n();
const usemenuStore = useMenuStore();
const authStore = useAuthStore();
const deploymentCapabilities = useDeploymentCapabilitiesStore();
const orgStore = useOrganizationStore();
const uiStore = useUIStore();
const commandPaletteStore = useCommandPaletteStore();
@@ -992,7 +994,7 @@ onMounted(async () => {
await syncActiveBucketFromChat(initialChatId);
}
// 若组织列表未加载则拉取一次,用于侧栏「待审批」角标
if (orgStore.organizations.length === 0) {
if (deploymentCapabilities.isSupported('organizations') && orgStore.organizations.length === 0) {
orgStore.fetchOrganizations();
}
});
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
SETTINGS_SECTION_CAPABILITY,
isDeploymentCapabilitySupported,
type DeploymentCapabilityMap,
} from './deploymentCapabilities'
test('capability filtering is fail-open unless backend explicitly disables a feature', () => {
assert.equal(isDeploymentCapabilitySupported({}, 'organizations'), true)
const capabilities: DeploymentCapabilityMap = {
organizations: { supported: false, reason: 'not_supported_in_lite' },
agents: { supported: true },
}
assert.equal(isDeploymentCapabilitySupported(capabilities, 'organizations'), false)
assert.equal(isDeploymentCapabilitySupported(capabilities, 'agents'), true)
})
test('organizations stay hidden in lite even when capabilities fail open', () => {
assert.equal(
isDeploymentCapabilitySupported({}, 'organizations', { liteMode: true }),
false,
)
assert.equal(
isDeploymentCapabilitySupported({}, 'organizations', { edition: 'lite' }),
false,
)
assert.equal(
isDeploymentCapabilitySupported({}, 'agents', { liteMode: true }),
true,
)
})
test('only route-backed settings sections require deployment capabilities', () => {
assert.equal(SETTINGS_SECTION_CAPABILITY.mcp, 'settings.mcp')
assert.equal(SETTINGS_SECTION_CAPABILITY.storage, 'settings.storage')
assert.equal(SETTINGS_SECTION_CAPABILITY.parser, undefined)
assert.equal(SETTINGS_SECTION_CAPABILITY['runtime-queues'], undefined)
})
@@ -0,0 +1,48 @@
export const DEPLOYMENT_CAPABILITY_KEYS = [
'organizations',
'agents',
'integrations.im',
'integrations.embed',
'integrations.api',
'settings.mcp',
'settings.websearch',
'settings.vectorstore',
'settings.storage',
'settings.sandbox',
] as const
export type DeploymentCapabilityKey = typeof DEPLOYMENT_CAPABILITY_KEYS[number]
export interface DeploymentCapability {
supported: boolean
reason?: string
}
export type DeploymentCapabilityMap = Partial<Record<DeploymentCapabilityKey, DeploymentCapability>>
/**
* 能力接口失败或旧版后端没有返回某个键时保持可见,避免一次探测失败把整个菜单清空。
* 只有后端明确返回 supported: false 时才隐藏入口。
*/
export function isDeploymentCapabilitySupported(
capabilities: DeploymentCapabilityMap,
key?: DeploymentCapabilityKey,
options?: { liteMode?: boolean; edition?: string },
): boolean {
if (!key) return true
if (key === 'organizations') {
const isLite =
options?.liteMode === true ||
options?.edition?.trim().toLowerCase() === 'lite'
if (isLite) return false
}
return capabilities[key]?.supported !== false
}
export const SETTINGS_SECTION_CAPABILITY: Partial<Record<string, DeploymentCapabilityKey>> = {
websearch: 'settings.websearch',
vectorstore: 'settings.vectorstore',
storage: 'settings.storage',
sandbox: 'settings.sandbox',
mcp: 'settings.mcp',
}
+8
View File
@@ -1,3 +1,5 @@
import type { DeploymentCapabilityKey } from './deploymentCapabilities'
export const CHROME_EXTENSION_URL =
'https://chromewebstore.google.com/detail/jpemjbopikggjlmikmclgbmkhhopjdgd?utm_source=item-share-cb'
@@ -14,6 +16,12 @@ export const INTEGRATION_TAB_MIN_ROLE: Partial<Record<IntegrationTab, Integratio
api: 'owner',
}
export const INTEGRATION_TAB_CAPABILITY: Partial<Record<IntegrationTab, DeploymentCapabilityKey>> = {
im: 'integrations.im',
embed: 'integrations.embed',
api: 'integrations.api',
}
export type IntegrationPreviewIcon =
| { type: 'icon'; name: string }
| { type: 'emoji'; value: string }
+1
View File
@@ -1260,6 +1260,7 @@ export default {
title: 'Insufficient permissions',
desc: 'Your role can\'t access this settings page. Ask an admin of this workspace to grant the required role.'
},
capabilityUnavailable: 'This feature is not supported by the current deployment. You have been returned to an available page.',
weknoraCloud: {
title: 'WeKnora Cloud',
description: 'Configure WeKnora Cloud APPID and APPSECRET credentials. Credentials are used for model services and document parsing engine.',
+1
View File
@@ -5293,6 +5293,7 @@ export default {
title: '권한 없음',
desc: '현재 역할로는 이 설정 페이지에 접근할 수 없습니다. 이 워크스페이스의 관리자에게 필요한 역할을 요청하세요.'
},
capabilityUnavailable: '현재 배포에서는 이 기능을 지원하지 않습니다. 사용 가능한 페이지로 돌아갔습니다.',
navGroups: {
account: '계정',
workspace: '공간',
+1
View File
@@ -5293,6 +5293,7 @@ export default {
title: 'Недостаточно прав',
desc: 'Ваша роль не позволяет открыть этот раздел настроек. Обратитесь к администратору пространства, чтобы запросить нужную роль.'
},
capabilityUnavailable: 'Эта функция не поддерживается в текущем развёртывании. Выполнен переход на доступную страницу.',
navGroups: {
account: 'Аккаунт',
workspace: 'Пространство',
+1
View File
@@ -5295,6 +5295,7 @@ export default {
title: '权限不足',
desc: '你当前的角色无权访问此设置项。请联系本空间的管理员获取所需角色。'
},
capabilityUnavailable: '当前部署不支持此功能,已返回可用页面。',
navGroups: {
account: '账户',
workspace: '空间',
+17 -2
View File
@@ -1,7 +1,11 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteLocationNormalized } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useDeploymentCapabilitiesStore } from '@/stores/deploymentCapabilities'
import { autoSetup, getCurrentUser, userInfoFromApi } from '@/api/auth'
import type { DeploymentCapabilityKey } from '@/config/deploymentCapabilities'
import { MessagePlugin } from 'tdesign-vue-next'
import i18n from '@/i18n'
/** Lite /桌面 WebView 硬刷新时可能只打开 `/`,用 session 记住上次页面以便恢复 */
const LITE_LAST_PATH_KEY = 'weknora_lite_last_path'
@@ -132,7 +136,7 @@ const router = createRouter({
path: "agents",
name: "agentList",
component: () => import("../views/agent/AgentList.vue"),
meta: { requiresInit: true, requiresAuth: true }
meta: { requiresInit: true, requiresAuth: true, requiredCapability: 'agents' }
},
{
path: "integrations",
@@ -167,7 +171,7 @@ const router = createRouter({
path: "organizations",
name: "organizationList",
component: () => import("../views/organization/OrganizationList.vue"),
meta: { requiresInit: true, requiresAuth: true }
meta: { requiresInit: true, requiresAuth: true, requiredCapability: 'organizations' }
},
// Compatibility redirects for /platform/system/* URLs. System
// administration surfaces live as dedicated sections inside the
@@ -387,6 +391,17 @@ router.beforeEach(async (to, from, next) => {
return
}
// 部署能力只描述“后端是否提供该功能”,不反映服务健康或是否已配置。
// 探测失败时 Store 会 fail-open,真正的权限和可用性仍由后端接口校验。
const deploymentCapabilities = useDeploymentCapabilitiesStore()
await deploymentCapabilities.ensureLoaded()
const requiredCapability = to.meta.requiredCapability as DeploymentCapabilityKey | undefined
if (requiredCapability && !deploymentCapabilities.isSupported(requiredCapability)) {
MessagePlugin.warning(i18n.global.t('settings.capabilityUnavailable'))
next('/platform/knowledge-bases')
return
}
// SystemAdmin gate — checked AFTER auth so a non-admin who's logged
// out gets redirected to /login first (consistent with how the rest
// of the auth flow works), and only an authenticated non-admin sees
@@ -0,0 +1,57 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { getDeploymentCapabilities } from '@/api/system'
import { useAuthStore } from '@/stores/auth'
import {
isDeploymentCapabilitySupported,
type DeploymentCapabilityKey,
type DeploymentCapabilityMap,
} from '@/config/deploymentCapabilities'
export const useDeploymentCapabilitiesStore = defineStore('deploymentCapabilities', () => {
const edition = ref('')
const capabilities = ref<DeploymentCapabilityMap>({})
const loaded = ref(false)
const loadError = ref('')
let loadingPromise: Promise<void> | null = null
const ensureLoaded = async (force = false): Promise<void> => {
if (loaded.value && !force) return
if (loadingPromise) return loadingPromise
loadingPromise = (async () => {
try {
const response = await getDeploymentCapabilities()
edition.value = response.data?.edition || ''
capabilities.value = response.data?.capabilities || {}
loadError.value = ''
} catch (error) {
// 能力探测失败时保持 fail-open;权限仍由后端路由最终校验。
capabilities.value = {}
loadError.value = error instanceof Error ? error.message : String(error)
} finally {
loaded.value = true
loadingPromise = null
}
})()
return loadingPromise
}
const isSupported = (key?: DeploymentCapabilityKey) => {
const authStore = useAuthStore()
return isDeploymentCapabilitySupported(capabilities.value, key, {
liteMode: authStore.isLiteMode,
edition: edition.value,
})
}
return {
edition,
capabilities,
loaded,
loadError,
ensureLoaded,
isSupported,
}
})
+9 -2
View File
@@ -2,6 +2,8 @@ import { reactive, ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
import i18n from '@/i18n'
import { useAuthStore } from '@/stores/auth'
import { useDeploymentCapabilitiesStore } from '@/stores/deploymentCapabilities'
import type { DeploymentCapabilityKey } from '@/config/deploymentCapabilities'
type MenuChild = Record<string, any>
@@ -12,6 +14,7 @@ interface MenuItem {
path: string
childrenPath?: string
children?: MenuChild[]
requiredCapability?: DeploymentCapabilityKey
}
const createMenuChildren = () => reactive<MenuChild[]>([])
@@ -27,8 +30,8 @@ export const useMenuStore = defineStore('menuStore', () => {
children: createMenuChildren()
},
{ title: '', titleKey: 'menu.knowledgeBase', icon: 'zhishiku', path: 'knowledge-bases' },
{ title: '', titleKey: 'menu.agents', icon: 'agent', path: 'agents' },
{ title: '', titleKey: 'menu.organizations', icon: 'organization', path: 'organizations' },
{ title: '', titleKey: 'menu.agents', icon: 'agent', path: 'agents', requiredCapability: 'agents' },
{ title: '', titleKey: 'menu.organizations', icon: 'organization', path: 'organizations', requiredCapability: 'organizations' },
{ title: '', titleKey: 'menu.settings', icon: 'setting', path: 'settings' },
{ title: '', titleKey: 'menu.logout', icon: 'logout', path: 'logout' }
])
@@ -65,6 +68,7 @@ export const useMenuStore = defineStore('menuStore', () => {
// 入口在侧栏只会徒增噪音;后端 RBAC 才是权限的最终来源(见 middleware/rbac.go)。
const visibleMenuArr = computed(() => {
const authStore = useAuthStore()
const deploymentCapabilities = useDeploymentCapabilitiesStore()
return menuArr.filter(item => {
if (authStore.isLiteMode && liteHiddenPaths.has(item.path)) {
return false
@@ -72,6 +76,9 @@ export const useMenuStore = defineStore('menuStore', () => {
if (item.path === 'organizations' && !authStore.hasRole('admin')) {
return false
}
if (!deploymentCapabilities.isSupported(item.requiredCapability)) {
return false
}
return true
})
})
+43 -4
View File
@@ -207,7 +207,9 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUIStore } from '@/stores/ui'
import { useAuthStore } from '@/stores/auth'
import { useDeploymentCapabilitiesStore } from '@/stores/deploymentCapabilities'
import { useI18n } from 'vue-i18n'
import { MessagePlugin } from 'tdesign-vue-next'
import SystemInfo from './SystemInfo.vue'
import TenantInfo from './TenantInfo.vue'
import UserProfile from './UserProfile.vue'
@@ -232,6 +234,7 @@ import SystemAuditLog from '@/views/system/SystemAuditLog.vue'
import IntegrationSettingsSection from '@/views/integrations/IntegrationSettingsSection.vue'
import {
INTEGRATION_PREVIEW_ITEMS,
INTEGRATION_TAB_CAPABILITY,
INTEGRATION_TAB_MIN_ROLE,
INTEGRATION_TABS,
type IntegrationTab,
@@ -240,11 +243,13 @@ import {
SETTINGS_SECTION_MIN_ROLE,
SYSTEM_ADMIN_SETTINGS_SECTIONS,
} from '@/config/settingsAccess'
import { SETTINGS_SECTION_CAPABILITY } from '@/config/deploymentCapabilities'
const route = useRoute()
const router = useRouter()
const uiStore = useUIStore()
const authStore = useAuthStore()
const deploymentCapabilities = useDeploymentCapabilitiesStore()
const { t } = useI18n()
const currentSection = ref<string>('general')
@@ -310,6 +315,15 @@ const normalizeSettingsSection = (section: string) => {
return section
}
const isSectionSupported = (key: string): boolean => {
if (isIntegrationSection(key)) {
return deploymentCapabilities.isSupported(
INTEGRATION_TAB_CAPABILITY[integrationTabFromSection(key)],
)
}
return deploymentCapabilities.isSupported(SETTINGS_SECTION_CAPABILITY[key])
}
const canSeeSection = (key: string): boolean => {
if (isIntegrationSection(key)) {
const min = INTEGRATION_TAB_MIN_ROLE[integrationTabFromSection(key)]
@@ -367,7 +381,7 @@ const navItems = computed(() => {
if (!authStore.currentTenantRole && !authStore.canAccessAllTenants) {
return [] as NavItem[]
}
return all.filter((it) => canSeeSection(it.key))
return all.filter((it) => canSeeSection(it.key) && isSectionSupported(it.key))
})
const navGroups = computed<NavGroup[]>(() => {
@@ -506,6 +520,12 @@ const handleClose = () => {
watch(() => uiStore.settingsInitialSection, (section) => {
if (section && visible.value) {
const normalizedSection = normalizeSettingsSection(section)
if (deploymentCapabilities.loaded && !isSectionSupported(normalizedSection)) {
MessagePlugin.warning(t('settings.capabilityUnavailable'))
currentSection.value = navItems.value[0]?.key || 'general'
currentSubSection.value = ''
return
}
currentSection.value = normalizedSection
const navItem = (navItems.value as any[]).find((item) => item.key === normalizedSection)
if (navItem && navItem.children && navItem.children.length > 0) {
@@ -528,10 +548,23 @@ watch(() => uiStore.settingsInitialSection, (section) => {
}, { immediate: true })
watch(
() => [visible.value, route.query.section],
([isVisible, section]) => {
() => [visible.value, route.query.section, deploymentCapabilities.loaded] as const,
([isVisible, section, capabilitiesLoaded]) => {
if (!isVisible || typeof section !== 'string') return
currentSection.value = normalizeSettingsSection(section)
const normalizedSection = normalizeSettingsSection(section)
if (capabilitiesLoaded && !isSectionSupported(normalizedSection)) {
MessagePlugin.warning(t('settings.capabilityUnavailable'))
currentSection.value = navItems.value[0]?.key || 'general'
currentSubSection.value = ''
if (route.path === '/platform/settings') {
const query = { ...route.query }
delete query.section
delete query.tab
void router.replace({ path: route.path, query })
}
return
}
currentSection.value = normalizedSection
currentSubSection.value = ''
},
{ immediate: true },
@@ -558,6 +591,12 @@ const handleSettingsNav = (e: CustomEvent) => {
const { section, subsection } = e.detail
if (section) {
const normalizedSection = normalizeSettingsSection(section)
if (deploymentCapabilities.loaded && !isSectionSupported(normalizedSection)) {
MessagePlugin.warning(t('settings.capabilityUnavailable'))
currentSection.value = navItems.value[0]?.key || 'general'
currentSubSection.value = ''
return
}
currentSection.value = normalizedSection
// 如果有子菜单,自动展开
const navItem = (navItems.value as any[]).find((item: any) => item.key === normalizedSection)
+104
View File
@@ -0,0 +1,104 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// DeploymentCapabilityKeys is the canonical capability key list shared with
// frontend/src/config/deploymentCapabilities.ts — keep both in sync.
var DeploymentCapabilityKeys = []string{
"organizations",
"agents",
"integrations.im",
"integrations.embed",
"integrations.api",
"settings.mcp",
"settings.websearch",
"settings.vectorstore",
"settings.storage",
"settings.sandbox",
}
// DeploymentCapability describes whether a deployment exposes a feature route.
type DeploymentCapability struct {
Supported bool `json:"supported"`
Reason string `json:"reason,omitempty"`
}
// DeploymentCapabilitiesData is returned by GET /system/capabilities.
type DeploymentCapabilitiesData struct {
Edition string `json:"edition"`
Capabilities map[string]DeploymentCapability `json:"capabilities"`
}
// DeploymentFeatureAvailability mirrors injected backend handlers/services.
type DeploymentFeatureAvailability struct {
Organizations bool
Agents bool
IM bool
Embed bool
API bool
MCP bool
WebSearch bool
VectorStore bool
Storage bool
Sandbox bool
}
func supportedDeploymentCapability(supported bool) DeploymentCapability {
if supported {
return DeploymentCapability{Supported: true}
}
return DeploymentCapability{Supported: false, Reason: "route_not_registered"}
}
// BuildDeploymentCapabilities derives the deployment capability snapshot.
func BuildDeploymentCapabilities(
edition string,
available DeploymentFeatureAvailability,
) DeploymentCapabilitiesData {
isLite := strings.EqualFold(strings.TrimSpace(edition), "lite")
organizations := supportedDeploymentCapability(available.Organizations && !isLite)
if isLite {
organizations.Reason = "not_supported_in_lite"
}
return DeploymentCapabilitiesData{
Edition: edition,
Capabilities: map[string]DeploymentCapability{
"organizations": organizations,
"agents": supportedDeploymentCapability(available.Agents),
"integrations.im": supportedDeploymentCapability(available.IM),
"integrations.embed": supportedDeploymentCapability(available.Embed),
"integrations.api": supportedDeploymentCapability(available.API),
"settings.mcp": supportedDeploymentCapability(available.MCP),
"settings.websearch": supportedDeploymentCapability(available.WebSearch),
"settings.vectorstore": supportedDeploymentCapability(available.VectorStore),
"settings.storage": supportedDeploymentCapability(available.Storage),
"settings.sandbox": supportedDeploymentCapability(available.Sandbox),
},
}
}
// BindDeploymentCapabilities stores the startup snapshot used by GetDeploymentCapabilities.
func (h *SystemHandler) BindDeploymentCapabilities(data DeploymentCapabilitiesData) {
h.deploymentCapabilities = data
}
// GetDeploymentCapabilities godoc
// @Summary 获取部署能力清单
// @Description 返回当前部署版本及实际注册的后端路由所对应的功能能力;仅 supported=false 表示入口应隐藏
// @Tags 系统
// @Produce json
// @Success 200 {object} map[string]interface{} "标准 code/msg/data 包装,data 为 DeploymentCapabilitiesData"
// @Router /system/capabilities [get]
func (h *SystemHandler) GetDeploymentCapabilities(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
"data": h.deploymentCapabilities,
})
}
@@ -0,0 +1,73 @@
package handler
import (
"os"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"testing"
)
func TestDeploymentCapabilityKeysMatchFrontend(t *testing.T) {
frontendKeys, err := readFrontendDeploymentCapabilityKeys()
if err != nil {
t.Fatalf("read frontend capability keys: %v", err)
}
if !slices.Equal(DeploymentCapabilityKeys, frontendKeys) {
t.Fatalf("backend keys = %#v, frontend keys = %#v", DeploymentCapabilityKeys, frontendKeys)
}
}
func TestBuildDeploymentCapabilitiesIncludesAllKeys(t *testing.T) {
result := BuildDeploymentCapabilities("standard", DeploymentFeatureAvailability{
Organizations: true,
Agents: true,
IM: true,
Embed: true,
API: true,
MCP: true,
WebSearch: true,
VectorStore: true,
Storage: true,
Sandbox: true,
})
for _, key := range DeploymentCapabilityKeys {
if _, ok := result.Capabilities[key]; !ok {
t.Fatalf("missing capability key %q", key)
}
}
}
func readFrontendDeploymentCapabilityKeys() ([]string, error) {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
return nil, os.ErrInvalid
}
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
frontendPath := filepath.Join(repoRoot, "frontend", "src", "config", "deploymentCapabilities.ts")
content, err := os.ReadFile(frontendPath)
if err != nil {
return nil, err
}
re := regexp.MustCompile(`(?s)export const DEPLOYMENT_CAPABILITY_KEYS = \[(.*?)\]`)
match := re.FindSubmatch(content)
if len(match) < 2 {
return nil, os.ErrInvalid
}
var keys []string
for _, line := range strings.Split(string(match[1]), "\n") {
line = strings.TrimSpace(strings.TrimRight(line, ","))
if line == "" {
continue
}
line = strings.Trim(line, `'`)
keys = append(keys, line)
}
return keys, nil
}
+2
View File
@@ -63,6 +63,8 @@ type SystemHandler struct {
// unit tests, in which case only the legacy config is consulted.
storageBackendRepo interfaces.StorageBackendRepository
sandboxConfigSvc sandboxConfigService
// startup snapshot for GET /system/capabilities; bound in router.NewRouter.
deploymentCapabilities DeploymentCapabilitiesData
}
// NewSystemHandler creates a new system handler
@@ -0,0 +1,19 @@
package router
import "github.com/Tencent/WeKnora/internal/handler"
func deploymentCapabilitiesFromRouter(params RouterParams) handler.DeploymentCapabilitiesData {
return handler.BuildDeploymentCapabilities(handler.Edition, handler.DeploymentFeatureAvailability{
Organizations: params.OrganizationHandler != nil,
Agents: params.CustomAgentHandler != nil,
IM: params.IMHandler != nil,
// Match RegisterEmbedChannelRoutes: management routes depend on handler only.
Embed: params.EmbedChannelHandler != nil,
API: params.TenantHandler != nil && params.TenantAPIKeyService != nil,
MCP: params.MCPServiceHandler != nil && params.MCPCredentialsHandler != nil && params.MCPOAuthHandler != nil,
WebSearch: params.WebSearchHandler != nil && params.WebSearchProviderHandler != nil && params.WebSearchCredentialsHandler != nil,
VectorStore: params.VectorStoreHandler != nil,
Storage: params.StorageBackendHandler != nil,
Sandbox: params.SandboxConfigHandler != nil,
})
}
@@ -0,0 +1,92 @@
package router
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/handler"
"github.com/gin-gonic/gin"
)
func allDeploymentFeaturesAvailable() handler.DeploymentFeatureAvailability {
return handler.DeploymentFeatureAvailability{
Organizations: true,
Agents: true,
IM: true,
Embed: true,
API: true,
MCP: true,
WebSearch: true,
VectorStore: true,
Storage: true,
Sandbox: true,
}
}
func TestBuildDeploymentCapabilitiesHidesOrganizationsInLite(t *testing.T) {
result := handler.BuildDeploymentCapabilities("lite", allDeploymentFeaturesAvailable())
organization := result.Capabilities["organizations"]
if organization.Supported {
t.Fatal("organizations should be unsupported in lite edition")
}
if organization.Reason != "not_supported_in_lite" {
t.Fatalf("organization reason = %q, want not_supported_in_lite", organization.Reason)
}
if !result.Capabilities["agents"].Supported {
t.Fatal("agents should remain supported in lite edition")
}
}
func TestBuildDeploymentCapabilitiesReflectsMissingRoutes(t *testing.T) {
available := allDeploymentFeaturesAvailable()
available.Embed = false
available.MCP = false
result := handler.BuildDeploymentCapabilities("standard", available)
for _, key := range []string{"integrations.embed", "settings.mcp"} {
capability := result.Capabilities[key]
if capability.Supported {
t.Fatalf("%s should be unsupported", key)
}
if capability.Reason != "route_not_registered" {
t.Fatalf("%s reason = %q, want route_not_registered", key, capability.Reason)
}
}
if !result.Capabilities["settings.storage"].Supported {
t.Fatal("an available route should remain supported")
}
}
func TestGetDeploymentCapabilitiesHandlerReturnsSnapshot(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
want := handler.BuildDeploymentCapabilities("standard", allDeploymentFeaturesAvailable())
systemHandler := &handler.SystemHandler{}
systemHandler.BindDeploymentCapabilities(want)
engine.GET("/capabilities", systemHandler.GetDeploymentCapabilities)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
var body struct {
Code int `json:"code"`
Data handler.DeploymentCapabilitiesData `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Code != 0 || body.Data.Edition != "standard" {
t.Fatalf("response = %#v", body)
}
if !body.Data.Capabilities["integrations.embed"].Supported {
t.Fatal("embed capability should be returned")
}
}
+1
View File
@@ -267,6 +267,7 @@ func NewRouter(params RouterParams) *gin.Engine {
RegisterSandboxConfigRoutes(v1, params.SandboxConfigHandler, rbacGuards)
RegisterEvaluationRoutes(v1, params.EvaluationHandler, rbacGuards)
RegisterInitializationRoutes(v1, params.InitializationHandler, rbacGuards)
params.SystemHandler.BindDeploymentCapabilities(deploymentCapabilitiesFromRouter(params))
RegisterSystemRoutes(v1, params.SystemHandler, rbacGuards)
RegisterSystemAdminRoutes(v1, params.SystemHandler, params.AuditLogHandler, rbacGuards)
RegisterMCPServiceRoutes(v1, params.MCPServiceHandler, params.MCPCredentialsHandler, params.MCPOAuthHandler, rbacGuards)
@@ -348,6 +348,11 @@ func TestTenantInfrastructureRoutesDeclareSpecificCapabilities(t *testing.T) {
RegisterDataSourceRoutes(v1, &handler.DataSourceHandler{}, &handler.DataSourceCredentialsHandler{}, g)
RegisterWeKnoraCloudRoutes(v1, &handler.WeKnoraCloudHandler{}, g)
capabilitiesPolicy := mustLookupAPIKeyPolicy(t, g, http.MethodGet, "/api/v1/system/capabilities")
if capabilitiesPolicy.RequireFullAccess || len(capabilitiesPolicy.Capabilities) != 0 {
t.Fatalf("system capabilities should be readable by any valid API key: %#v", capabilitiesPolicy)
}
cases := []struct {
method string
path string
+6 -1
View File
@@ -214,9 +214,14 @@ func RegisterAuthRoutes(r *gin.RouterGroup, handler *handler.AuthHandler, g *rba
// reachable". The /*-check / /reconnect endpoints actively probe
// remote services with tenant credentials and could trigger network
// fanout, so they're Admin+.
func RegisterSystemRoutes(r *gin.RouterGroup, handler *handler.SystemHandler, g *rbacGuards) {
func RegisterSystemRoutes(
r *gin.RouterGroup,
handler *handler.SystemHandler,
g *rbacGuards,
) {
systemRoutes := g.apiKeyGroup(r.Group("/system"), apiKeyManageVectorStores(apiKeyFullAccess()))
{
systemRoutes.With(apiKeyAny()).GET("/capabilities", g.Viewer(), handler.GetDeploymentCapabilities)
systemRoutes.GET("/info", g.Viewer(), handler.GetSystemInfo)
systemRoutes.GET("/parser-engines", g.Viewer(), handler.ListParserEngines)
systemRoutes.POST("/parser-engines/check", g.Admin(), handler.CheckParserEngines)