fix(knowledge): harden multi-tag data integrity and UX follow-ups

Clear tag relations on move/delete, validate tags on create, batch-load tags,
restore KB-scoped reference counts, dedupe SetKnowledgeTags, fix filter
double-fetch and ResizeObserver leaks, and regenerate Swagger for tag_ids.
This commit is contained in:
wizardchen
2026-06-26 02:18:10 +08:00
committed by lyingbug
parent c007a897cf
commit 2f29054110
14 changed files with 1613 additions and 256 deletions
+418 -12
View File
@@ -2081,9 +2081,65 @@ const docTemplate = `{
}
}
},
"/datasource/{id}/resource-ancestors": {
"post": {
"description": "Resolve the ancestor ExternalIDs that must be expanded to reveal the given (possibly deeply nested) resources in a lazily-loaded picker. Used to restore an existing selection when editing a data source.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"DataSource"
],
"summary": "Resolve resource ancestors",
"parameters": [
{
"type": "string",
"description": "Data source ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Resource IDs to resolve",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_handler.resolveAncestorsRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/datasource/{id}/resources": {
"get": {
"description": "List resources available for sync in the external system",
"description": "List resources available for sync in the external system. Pass parent_id to lazily load the direct children of a resource (used for large hierarchical sources such as Feishu wiki).",
"produces": [
"application/json"
],
@@ -2098,6 +2154,12 @@ const docTemplate = `{
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Parent resource ExternalID; empty lists the top level",
"name": "parent_id",
"in": "query"
}
],
"responses": {
@@ -4667,8 +4729,8 @@ const docTemplate = `{
},
{
"type": "string",
"description": "标签ID筛选",
"name": "tag_id",
"description": "标签ID筛选,逗号分隔(OR语义)",
"name": "tag_ids",
"in": "query"
},
{
@@ -4830,6 +4892,12 @@ const docTemplate = `{
"name": "enable_multimodel",
"in": "formData"
},
{
"type": "string",
"description": "分类ID列表,逗号分隔",
"name": "tag_ids",
"in": "formData"
},
{
"type": "string",
"description": "处理配置JSON(KnowledgeProcessOverrides)",
@@ -4963,8 +5031,11 @@ const docTemplate = `{
"file_type": {
"type": "string"
},
"tag_id": {
"type": "string"
"tag_ids": {
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string"
@@ -5668,6 +5739,61 @@ const docTemplate = `{
}
}
},
"/knowledge/batch-reparse": {
"post": {
"security": [
{
"Bearer": []
},
{
"ApiKeyAuth": []
}
],
"description": "按 ID 列表批量重新解析单个知识库下的多个知识条目",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"知识管理"
],
"summary": "批量重新解析知识",
"parameters": [
{
"description": "批量重解析请求",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_handler.batchReparseKnowledgeRequest"
}
}
],
"responses": {
"200": {
"description": "任务已提交",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "请求参数错误",
"schema": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
}
},
"403": {
"description": "权限不足",
"schema": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
}
}
}
}
},
"/knowledge/image/{id}/{chunk_id}": {
"put": {
"security": [
@@ -5893,7 +6019,7 @@ const docTemplate = `{
"ApiKeyAuth": []
}
],
"description": "Search knowledge files by keyword. When agent_id is set (shared agent), scope is the agent's configured knowledge bases.",
"description": "Search knowledge files by keyword. Pass recent=true without a keyword to browse recent files. When agent_id is set (shared agent), scope is the agent's configured knowledge bases.",
"consumes": [
"application/json"
],
@@ -5934,6 +6060,12 @@ const docTemplate = `{
"description": "Shared agent ID (search within agent's KB scope)",
"name": "agent_id",
"in": "query"
},
{
"type": "boolean",
"description": "Return recent files when keyword is empty",
"name": "recent",
"in": "query"
}
],
"responses": {
@@ -7459,6 +7591,40 @@ const docTemplate = `{
}
}
},
"/mcp-services/oauth/callback": {
"get": {
"description": "接收授权服务器回调并完成 code 交换,随后重定向回前端",
"tags": [
"MCP服务"
],
"summary": "MCP OAuth 回调",
"parameters": [
{
"type": "string",
"description": "授权码",
"name": "code",
"in": "query"
},
{
"type": "string",
"description": "状态参数",
"name": "state",
"in": "query"
},
{
"type": "string",
"description": "授权错误码",
"name": "error",
"in": "query"
}
],
"responses": {
"302": {
"description": "Found"
}
}
}
},
"/mcp-services/{id}": {
"get": {
"security": [
@@ -7721,6 +7887,126 @@ const docTemplate = `{
}
}
},
"/mcp-services/{id}/oauth/authorize-url": {
"post": {
"security": [
{
"Bearer": []
}
],
"description": "对使用 OAuth 的 MCP 服务执行发现与动态客户端注册,返回浏览器应跳转的授权地址(当前用户维度)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"MCP服务"
],
"summary": "发起 MCP OAuth 授权",
"parameters": [
{
"type": "string",
"description": "MCP 服务 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "{redirect_uri: string, frontend_redirect?: string}",
"name": "request",
"in": "body",
"required": true,
"schema": {
"type": "object",
"additionalProperties": true
}
}
],
"responses": {
"200": {
"description": "{authorization_url: string}",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
}
}
}
}
},
"/mcp-services/{id}/oauth/status": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "返回当前用户对指定 MCP 服务是否已完成 OAuth 授权",
"produces": [
"application/json"
],
"tags": [
"MCP服务"
],
"summary": "查询 MCP OAuth 授权状态",
"parameters": [
{
"type": "string",
"description": "MCP 服务 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "{authorized: bool}",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/mcp-services/{id}/oauth/token": {
"delete": {
"security": [
{
"Bearer": []
}
],
"description": "删除当前用户对指定 MCP 服务的 OAuth 令牌",
"produces": [
"application/json"
],
"tags": [
"MCP服务"
],
"summary": "撤销 MCP OAuth 授权",
"parameters": [
{
"type": "string",
"description": "MCP 服务 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/mcp-services/{id}/resources": {
"get": {
"security": [
@@ -14370,7 +14656,7 @@ const docTemplate = `{
"type": "integer"
},
"replica_number": {
"description": "Milvus: in-memory replica count (LoadCollection)",
"description": "Milvus LoadCollection / Tencent VectorDB CreateCollection replicas",
"type": "integer"
},
"replication_factor": {
@@ -14620,9 +14906,12 @@ const docTemplate = `{
"description": "Summary status for async summary generation",
"type": "string"
},
"tag_id": {
"description": "Optional tag ID for categorization within a knowledge base",
"type": "string"
"tags": {
"description": "Tags holds the tags associated with this knowledge (populated on query, not persisted directly).",
"type": "array",
"items": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.KnowledgeTag"
}
},
"tenant_id": {
"description": "Tenant ID",
@@ -14991,6 +15280,13 @@ const docTemplate = `{
"graph_enabled": {
"type": "boolean"
},
"parser_engine_overrides": {
"description": "ParserEngineOverrides passes key-value configuration to docreader parsers\n(e.g. pdf_force_scanned=true). Merged with tenant-level overrides in the\nparse pipeline; per-upload values take priority on conflict.",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"parser_engine_rules": {
"type": "array",
"items": {
@@ -15005,6 +15301,47 @@ const docTemplate = `{
}
}
},
"github_com_Tencent_WeKnora_internal_types.KnowledgeTag": {
"type": "object",
"properties": {
"color": {
"description": "Optional display color",
"type": "string"
},
"created_at": {
"description": "Creation time",
"type": "string"
},
"id": {
"description": "Unique identifier of the tag (UUID)",
"type": "string"
},
"knowledge_base_id": {
"description": "Knowledge base ID that this tag belongs to",
"type": "string"
},
"name": {
"description": "Tag name, unique within the same knowledge base",
"type": "string"
},
"seq_id": {
"description": "SeqID is an auto-increment integer ID for external API usage",
"type": "integer"
},
"sort_order": {
"description": "Sort order within the same knowledge base",
"type": "integer"
},
"tenant_id": {
"description": "Tenant ID",
"type": "integer"
},
"updated_at": {
"description": "Last updated time",
"type": "string"
}
}
},
"github_com_Tencent_WeKnora_internal_types.ListMembersResponse": {
"type": "object",
"properties": {
@@ -15137,17 +15474,51 @@ const docTemplate = `{
"api_key": {
"type": "string"
},
"auth_server_metadata_url": {
"description": "AuthServerMetadataURL optionally pins the OAuth authorization server\nmetadata URL. When empty, the server is discovered automatically from\nthe MCP URL (RFC 9728 / RFC 8414).",
"type": "string"
},
"auth_type": {
"description": "AuthType selects the authentication strategy. Empty (\"\") is treated as\nnone for backward compatibility with rows that pre-date this field.",
"allOf": [
{
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.MCPAuthType"
}
]
},
"custom_headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"scopes": {
"description": "Scopes are the OAuth scopes requested during authorization. Optional.",
"type": "array",
"items": {
"type": "string"
}
},
"token": {
"type": "string"
}
}
},
"github_com_Tencent_WeKnora_internal_types.MCPAuthType": {
"type": "string",
"enum": [
"",
"api_key",
"bearer",
"oauth"
],
"x-enum-varnames": [
"MCPAuthNone",
"MCPAuthAPIKey",
"MCPAuthBearer",
"MCPAuthOAuth"
]
},
"github_com_Tencent_WeKnora_internal_types.MCPEnvVars": {
"type": "object",
"additionalProperties": {
@@ -15279,8 +15650,11 @@ const docTemplate = `{
"status": {
"type": "string"
},
"tag_id": {
"type": "string"
"tag_ids": {
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string"
@@ -19275,6 +19649,27 @@ const docTemplate = `{
}
}
},
"internal_handler.batchReparseKnowledgeRequest": {
"type": "object",
"required": [
"ids",
"kb_id"
],
"properties": {
"ids": {
"type": "array",
"items": {
"type": "string"
}
},
"kb_id": {
"type": "string"
},
"process_config": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.KnowledgeProcessOverrides"
}
}
},
"internal_handler.createInvitationRequest": {
"type": "object",
"required": [
@@ -19376,6 +19771,17 @@ const docTemplate = `{
}
}
},
"internal_handler.resolveAncestorsRequest": {
"type": "object",
"properties": {
"resource_ids": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"internal_handler.updateLastFAQImportResultDisplayStatusRequest": {
"type": "object",
"required": [
+418 -12
View File
@@ -2074,9 +2074,65 @@
}
}
},
"/datasource/{id}/resource-ancestors": {
"post": {
"description": "Resolve the ancestor ExternalIDs that must be expanded to reveal the given (possibly deeply nested) resources in a lazily-loaded picker. Used to restore an existing selection when editing a data source.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"DataSource"
],
"summary": "Resolve resource ancestors",
"parameters": [
{
"type": "string",
"description": "Data source ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Resource IDs to resolve",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_handler.resolveAncestorsRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/datasource/{id}/resources": {
"get": {
"description": "List resources available for sync in the external system",
"description": "List resources available for sync in the external system. Pass parent_id to lazily load the direct children of a resource (used for large hierarchical sources such as Feishu wiki).",
"produces": [
"application/json"
],
@@ -2091,6 +2147,12 @@
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Parent resource ExternalID; empty lists the top level",
"name": "parent_id",
"in": "query"
}
],
"responses": {
@@ -4660,8 +4722,8 @@
},
{
"type": "string",
"description": "标签ID筛选",
"name": "tag_id",
"description": "标签ID筛选,逗号分隔(OR语义)",
"name": "tag_ids",
"in": "query"
},
{
@@ -4823,6 +4885,12 @@
"name": "enable_multimodel",
"in": "formData"
},
{
"type": "string",
"description": "分类ID列表,逗号分隔",
"name": "tag_ids",
"in": "formData"
},
{
"type": "string",
"description": "处理配置JSON(KnowledgeProcessOverrides)",
@@ -4956,8 +5024,11 @@
"file_type": {
"type": "string"
},
"tag_id": {
"type": "string"
"tag_ids": {
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string"
@@ -5661,6 +5732,61 @@
}
}
},
"/knowledge/batch-reparse": {
"post": {
"security": [
{
"Bearer": []
},
{
"ApiKeyAuth": []
}
],
"description": "按 ID 列表批量重新解析单个知识库下的多个知识条目",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"知识管理"
],
"summary": "批量重新解析知识",
"parameters": [
{
"description": "批量重解析请求",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_handler.batchReparseKnowledgeRequest"
}
}
],
"responses": {
"200": {
"description": "任务已提交",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "请求参数错误",
"schema": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
}
},
"403": {
"description": "权限不足",
"schema": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
}
}
}
}
},
"/knowledge/image/{id}/{chunk_id}": {
"put": {
"security": [
@@ -5886,7 +6012,7 @@
"ApiKeyAuth": []
}
],
"description": "Search knowledge files by keyword. When agent_id is set (shared agent), scope is the agent's configured knowledge bases.",
"description": "Search knowledge files by keyword. Pass recent=true without a keyword to browse recent files. When agent_id is set (shared agent), scope is the agent's configured knowledge bases.",
"consumes": [
"application/json"
],
@@ -5927,6 +6053,12 @@
"description": "Shared agent ID (search within agent's KB scope)",
"name": "agent_id",
"in": "query"
},
{
"type": "boolean",
"description": "Return recent files when keyword is empty",
"name": "recent",
"in": "query"
}
],
"responses": {
@@ -7452,6 +7584,40 @@
}
}
},
"/mcp-services/oauth/callback": {
"get": {
"description": "接收授权服务器回调并完成 code 交换,随后重定向回前端",
"tags": [
"MCP服务"
],
"summary": "MCP OAuth 回调",
"parameters": [
{
"type": "string",
"description": "授权码",
"name": "code",
"in": "query"
},
{
"type": "string",
"description": "状态参数",
"name": "state",
"in": "query"
},
{
"type": "string",
"description": "授权错误码",
"name": "error",
"in": "query"
}
],
"responses": {
"302": {
"description": "Found"
}
}
}
},
"/mcp-services/{id}": {
"get": {
"security": [
@@ -7714,6 +7880,126 @@
}
}
},
"/mcp-services/{id}/oauth/authorize-url": {
"post": {
"security": [
{
"Bearer": []
}
],
"description": "对使用 OAuth 的 MCP 服务执行发现与动态客户端注册,返回浏览器应跳转的授权地址(当前用户维度)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"MCP服务"
],
"summary": "发起 MCP OAuth 授权",
"parameters": [
{
"type": "string",
"description": "MCP 服务 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "{redirect_uri: string, frontend_redirect?: string}",
"name": "request",
"in": "body",
"required": true,
"schema": {
"type": "object",
"additionalProperties": true
}
}
],
"responses": {
"200": {
"description": "{authorization_url: string}",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
}
}
}
}
},
"/mcp-services/{id}/oauth/status": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "返回当前用户对指定 MCP 服务是否已完成 OAuth 授权",
"produces": [
"application/json"
],
"tags": [
"MCP服务"
],
"summary": "查询 MCP OAuth 授权状态",
"parameters": [
{
"type": "string",
"description": "MCP 服务 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "{authorized: bool}",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/mcp-services/{id}/oauth/token": {
"delete": {
"security": [
{
"Bearer": []
}
],
"description": "删除当前用户对指定 MCP 服务的 OAuth 令牌",
"produces": [
"application/json"
],
"tags": [
"MCP服务"
],
"summary": "撤销 MCP OAuth 授权",
"parameters": [
{
"type": "string",
"description": "MCP 服务 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/mcp-services/{id}/resources": {
"get": {
"security": [
@@ -14363,7 +14649,7 @@
"type": "integer"
},
"replica_number": {
"description": "Milvus: in-memory replica count (LoadCollection)",
"description": "Milvus LoadCollection / Tencent VectorDB CreateCollection replicas",
"type": "integer"
},
"replication_factor": {
@@ -14613,9 +14899,12 @@
"description": "Summary status for async summary generation",
"type": "string"
},
"tag_id": {
"description": "Optional tag ID for categorization within a knowledge base",
"type": "string"
"tags": {
"description": "Tags holds the tags associated with this knowledge (populated on query, not persisted directly).",
"type": "array",
"items": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.KnowledgeTag"
}
},
"tenant_id": {
"description": "Tenant ID",
@@ -14984,6 +15273,13 @@
"graph_enabled": {
"type": "boolean"
},
"parser_engine_overrides": {
"description": "ParserEngineOverrides passes key-value configuration to docreader parsers\n(e.g. pdf_force_scanned=true). Merged with tenant-level overrides in the\nparse pipeline; per-upload values take priority on conflict.",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"parser_engine_rules": {
"type": "array",
"items": {
@@ -14998,6 +15294,47 @@
}
}
},
"github_com_Tencent_WeKnora_internal_types.KnowledgeTag": {
"type": "object",
"properties": {
"color": {
"description": "Optional display color",
"type": "string"
},
"created_at": {
"description": "Creation time",
"type": "string"
},
"id": {
"description": "Unique identifier of the tag (UUID)",
"type": "string"
},
"knowledge_base_id": {
"description": "Knowledge base ID that this tag belongs to",
"type": "string"
},
"name": {
"description": "Tag name, unique within the same knowledge base",
"type": "string"
},
"seq_id": {
"description": "SeqID is an auto-increment integer ID for external API usage",
"type": "integer"
},
"sort_order": {
"description": "Sort order within the same knowledge base",
"type": "integer"
},
"tenant_id": {
"description": "Tenant ID",
"type": "integer"
},
"updated_at": {
"description": "Last updated time",
"type": "string"
}
}
},
"github_com_Tencent_WeKnora_internal_types.ListMembersResponse": {
"type": "object",
"properties": {
@@ -15130,17 +15467,51 @@
"api_key": {
"type": "string"
},
"auth_server_metadata_url": {
"description": "AuthServerMetadataURL optionally pins the OAuth authorization server\nmetadata URL. When empty, the server is discovered automatically from\nthe MCP URL (RFC 9728 / RFC 8414).",
"type": "string"
},
"auth_type": {
"description": "AuthType selects the authentication strategy. Empty (\"\") is treated as\nnone for backward compatibility with rows that pre-date this field.",
"allOf": [
{
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.MCPAuthType"
}
]
},
"custom_headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"scopes": {
"description": "Scopes are the OAuth scopes requested during authorization. Optional.",
"type": "array",
"items": {
"type": "string"
}
},
"token": {
"type": "string"
}
}
},
"github_com_Tencent_WeKnora_internal_types.MCPAuthType": {
"type": "string",
"enum": [
"",
"api_key",
"bearer",
"oauth"
],
"x-enum-varnames": [
"MCPAuthNone",
"MCPAuthAPIKey",
"MCPAuthBearer",
"MCPAuthOAuth"
]
},
"github_com_Tencent_WeKnora_internal_types.MCPEnvVars": {
"type": "object",
"additionalProperties": {
@@ -15272,8 +15643,11 @@
"status": {
"type": "string"
},
"tag_id": {
"type": "string"
"tag_ids": {
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string"
@@ -19268,6 +19642,27 @@
}
}
},
"internal_handler.batchReparseKnowledgeRequest": {
"type": "object",
"required": [
"ids",
"kb_id"
],
"properties": {
"ids": {
"type": "array",
"items": {
"type": "string"
}
},
"kb_id": {
"type": "string"
},
"process_config": {
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.KnowledgeProcessOverrides"
}
}
},
"internal_handler.createInvitationRequest": {
"type": "object",
"required": [
@@ -19369,6 +19764,17 @@
}
}
},
"internal_handler.resolveAncestorsRequest": {
"type": "object",
"properties": {
"resource_ids": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"internal_handler.updateLastFAQImportResultDisplayStatusRequest": {
"type": "object",
"required": [
+296 -15
View File
@@ -1071,7 +1071,7 @@ definitions:
description: ES, OpenSearch
type: integer
replica_number:
description: 'Milvus: in-memory replica count (LoadCollection)'
description: Milvus LoadCollection / Tencent VectorDB CreateCollection replicas
type: integer
replication_factor:
description: 'Qdrant, Weaviate: number of replicas'
@@ -1257,9 +1257,12 @@ definitions:
summary_status:
description: Summary status for async summary generation
type: string
tag_id:
description: Optional tag ID for categorization within a knowledge base
type: string
tags:
description: Tags holds the tags associated with this knowledge (populated
on query, not persisted directly).
items:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.KnowledgeTag'
type: array
tenant_id:
description: Tenant ID
type: integer
@@ -1529,6 +1532,14 @@ definitions:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.ExtractConfig'
graph_enabled:
type: boolean
parser_engine_overrides:
additionalProperties:
type: string
description: |-
ParserEngineOverrides passes key-value configuration to docreader parsers
(e.g. pdf_force_scanned=true). Merged with tenant-level overrides in the
parse pipeline; per-upload values take priority on conflict.
type: object
parser_engine_rules:
items:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.ParserEngineRule'
@@ -1538,6 +1549,36 @@ definitions:
vlm_config:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.VLMConfig'
type: object
github_com_Tencent_WeKnora_internal_types.KnowledgeTag:
properties:
color:
description: Optional display color
type: string
created_at:
description: Creation time
type: string
id:
description: Unique identifier of the tag (UUID)
type: string
knowledge_base_id:
description: Knowledge base ID that this tag belongs to
type: string
name:
description: Tag name, unique within the same knowledge base
type: string
seq_id:
description: SeqID is an auto-increment integer ID for external API usage
type: integer
sort_order:
description: Sort order within the same knowledge base
type: integer
tenant_id:
description: Tenant ID
type: integer
updated_at:
description: Last updated time
type: string
type: object
github_com_Tencent_WeKnora_internal_types.ListMembersResponse:
properties:
members:
@@ -1633,13 +1674,42 @@ definitions:
properties:
api_key:
type: string
auth_server_metadata_url:
description: |-
AuthServerMetadataURL optionally pins the OAuth authorization server
metadata URL. When empty, the server is discovered automatically from
the MCP URL (RFC 9728 / RFC 8414).
type: string
auth_type:
allOf:
- $ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.MCPAuthType'
description: |-
AuthType selects the authentication strategy. Empty ("") is treated as
none for backward compatibility with rows that pre-date this field.
custom_headers:
additionalProperties:
type: string
type: object
scopes:
description: Scopes are the OAuth scopes requested during authorization. Optional.
items:
type: string
type: array
token:
type: string
type: object
github_com_Tencent_WeKnora_internal_types.MCPAuthType:
enum:
- ""
- api_key
- bearer
- oauth
type: string
x-enum-varnames:
- MCPAuthNone
- MCPAuthAPIKey
- MCPAuthBearer
- MCPAuthOAuth
github_com_Tencent_WeKnora_internal_types.MCPEnvVars:
additionalProperties:
type: string
@@ -1728,8 +1798,10 @@ definitions:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.KnowledgeProcessOverrides'
status:
type: string
tag_id:
type: string
tag_ids:
items:
type: string
type: array
title:
type: string
type: object
@@ -4685,6 +4757,20 @@ definitions:
success:
type: boolean
type: object
internal_handler.batchReparseKnowledgeRequest:
properties:
ids:
items:
type: string
type: array
kb_id:
type: string
process_config:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.KnowledgeProcessOverrides'
required:
- ids
- kb_id
type: object
internal_handler.createInvitationRequest:
properties:
email:
@@ -4753,6 +4839,13 @@ definitions:
- token
- username
type: object
internal_handler.resolveAncestorsRequest:
properties:
resource_ids:
items:
type: string
type: array
type: object
internal_handler.updateLastFAQImportResultDisplayStatusRequest:
properties:
display_status:
@@ -6209,15 +6302,60 @@ paths:
summary: Pause data source
tags:
- DataSource
/datasource/{id}/resources:
get:
description: List resources available for sync in the external system
/datasource/{id}/resource-ancestors:
post:
consumes:
- application/json
description: Resolve the ancestor ExternalIDs that must be expanded to reveal
the given (possibly deeply nested) resources in a lazily-loaded picker. Used
to restore an existing selection when editing a data source.
parameters:
- description: Data source ID
in: path
name: id
required: true
type: string
- description: Resource IDs to resolve
in: body
name: request
required: true
schema:
$ref: '#/definitions/internal_handler.resolveAncestorsRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
items:
type: string
type: array
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
summary: Resolve resource ancestors
tags:
- DataSource
/datasource/{id}/resources:
get:
description: List resources available for sync in the external system. Pass
parent_id to lazily load the direct children of a resource (used for large
hierarchical sources such as Feishu wiki).
parameters:
- description: Data source ID
in: path
name: id
required: true
type: string
- description: Parent resource ExternalID; empty lists the top level
in: query
name: parent_id
type: string
produces:
- application/json
responses:
@@ -7875,9 +8013,9 @@ paths:
in: query
name: page_size
type: integer
- description: 标签ID筛选
- description: 标签ID筛选,逗号分隔(OR语义)
in: query
name: tag_id
name: tag_ids
type: string
- description: 关键词搜索
in: query
@@ -7950,6 +8088,10 @@ paths:
in: formData
name: enable_multimodel
type: boolean
- description: 分类ID列表,逗号分隔
in: formData
name: tag_ids
type: string
- description: 处理配置JSON(KnowledgeProcessOverrides)
in: formData
name: process_config
@@ -8035,8 +8177,10 @@ paths:
type: string
file_type:
type: string
tag_id:
type: string
tag_ids:
items:
type: string
type: array
title:
type: string
url:
@@ -8779,6 +8923,40 @@ paths:
summary: 批量删除知识
tags:
- 知识管理
/knowledge/batch-reparse:
post:
consumes:
- application/json
description: 按 ID 列表批量重新解析单个知识库下的多个知识条目
parameters:
- description: 批量重解析请求
in: body
name: request
required: true
schema:
$ref: '#/definitions/internal_handler.batchReparseKnowledgeRequest'
produces:
- application/json
responses:
"200":
description: 任务已提交
schema:
additionalProperties: true
type: object
"400":
description: 请求参数错误
schema:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError'
"403":
description: 权限不足
schema:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError'
security:
- Bearer: []
- ApiKeyAuth: []
summary: 批量重新解析知识
tags:
- 知识管理
/knowledge/image/{id}/{chunk_id}:
put:
consumes:
@@ -8916,8 +9094,9 @@ paths:
get:
consumes:
- application/json
description: Search knowledge files by keyword. When agent_id is set (shared
agent), scope is the agent's configured knowledge bases.
description: Search knowledge files by keyword. Pass recent=true without a keyword
to browse recent files. When agent_id is set (shared agent), scope is the
agent's configured knowledge bases.
parameters:
- description: Keyword to search
in: query
@@ -8939,6 +9118,10 @@ paths:
in: query
name: agent_id
type: string
- description: Return recent files when keyword is empty
in: query
name: recent
type: boolean
produces:
- application/json
responses:
@@ -9860,6 +10043,82 @@ paths:
summary: 移除 MCP 服务的单个凭据字段
tags:
- MCP服务
/mcp-services/{id}/oauth/authorize-url:
post:
consumes:
- application/json
description: 对使用 OAuth 的 MCP 服务执行发现与动态客户端注册,返回浏览器应跳转的授权地址(当前用户维度)
parameters:
- description: MCP 服务 ID
in: path
name: id
required: true
type: string
- description: '{redirect_uri: string, frontend_redirect?: string}'
in: body
name: request
required: true
schema:
additionalProperties: true
type: object
produces:
- application/json
responses:
"200":
description: '{authorization_url: string}'
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError'
security:
- Bearer: []
summary: 发起 MCP OAuth 授权
tags:
- MCP服务
/mcp-services/{id}/oauth/status:
get:
description: 返回当前用户对指定 MCP 服务是否已完成 OAuth 授权
parameters:
- description: MCP 服务 ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: '{authorized: bool}'
schema:
additionalProperties: true
type: object
security:
- Bearer: []
summary: 查询 MCP OAuth 授权状态
tags:
- MCP服务
/mcp-services/{id}/oauth/token:
delete:
description: 删除当前用户对指定 MCP 服务的 OAuth 令牌
parameters:
- description: MCP 服务 ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"204":
description: No Content
security:
- Bearer: []
summary: 撤销 MCP OAuth 授权
tags:
- MCP服务
/mcp-services/{id}/resources:
get:
consumes:
@@ -9992,6 +10251,28 @@ paths:
summary: 获取MCP服务工具列表
tags:
- MCP服务
/mcp-services/oauth/callback:
get:
description: 接收授权服务器回调并完成 code 交换,随后重定向回前端
parameters:
- description: 授权码
in: query
name: code
type: string
- description: 状态参数
in: query
name: state
type: string
- description: 授权错误码
in: query
name: error
type: string
responses:
"302":
description: Found
summary: MCP OAuth 回调
tags:
- MCP服务
/me/invitations:
get:
description: 返回当前登录用户的待接受邀请(默认仅 pending),用于头像入口和 /invitations 收件箱页。
@@ -0,0 +1,70 @@
import { onBeforeUnmount, reactive } from 'vue';
const TAG_EST_WIDTH = 82;
const TAG_OVERFLOW_MIN = 32;
export function useTagChipsOverflow(datasetKey: 'tagItemId' | 'listTagItemId') {
const tagVisibleLimit = reactive<Record<string, number>>({});
const tagItemTotalMap = new Map<string, number>();
const observedElements = new WeakSet<Element>();
let tagChipsRO: ResizeObserver | null = null;
const computeLimit = (width: number, total: number) => {
if (total <= 0) return 99;
const maxFit = Math.floor((width - TAG_OVERFLOW_MIN) / TAG_EST_WIDTH);
const limit = Math.max(1, Math.min(maxFit, total));
return limit >= total ? 99 : limit;
};
const ensureObserver = () => {
if (tagChipsRO) return;
tagChipsRO = new ResizeObserver((entries) => {
for (const entry of entries) {
const target = entry.target as HTMLElement;
const id = target.dataset[datasetKey];
if (!id) continue;
const total = tagItemTotalMap.get(id) ?? 0;
tagVisibleLimit[id] = computeLimit(entry.contentRect.width, total);
}
});
};
function setupTagChipsObserver(el: Element | null, itemId: string, totalCount: number) {
if (!el) return;
const htmlEl = el as HTMLElement;
htmlEl.dataset[datasetKey] = itemId;
tagItemTotalMap.set(itemId, totalCount);
ensureObserver();
if (!observedElements.has(htmlEl)) {
observedElements.add(htmlEl);
tagChipsRO!.observe(htmlEl);
}
requestAnimationFrame(() => {
tagVisibleLimit[itemId] = computeLimit(htmlEl.clientWidth, totalCount);
});
}
function getTagLimit(itemId: string): number {
return tagVisibleLimit[itemId] ?? 99;
}
function hasTagOverflow(itemId: string, total: number): boolean {
return total > getTagLimit(itemId);
}
function getOverflowCount(itemId: string, total: number): number {
return Math.max(0, total - getTagLimit(itemId));
}
onBeforeUnmount(() => {
tagChipsRO?.disconnect();
tagChipsRO = null;
});
return {
setupTagChipsObserver,
getTagLimit,
hasTagOverflow,
getOverflowCount,
};
}
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatFileSize, getFileIcon } from '@/utils/files';
import { useTagChipsOverflow } from '@/composables/useTagChipsOverflow';
interface Tag {
id: string;
@@ -43,57 +44,12 @@ const emit = defineEmits<{
const { t } = useI18n();
// 动态标签可见数量(根据列宽自适应)
const TAG_EST_WIDTH = 82;
const TAG_OVERFLOW_MIN = 32;
const tagVisibleLimit = reactive<Record<string, number>>({});
const tagItemTotalMap = new Map<string, number>();
let tagChipsRO: ResizeObserver | null = null;
function setupTagChipsObserver(el: Element | null, itemId: string, totalCount: number) {
if (!el) return;
const htmlEl = el as HTMLElement;
htmlEl.dataset.listTagItemId = itemId;
tagItemTotalMap.set(itemId, totalCount);
if (!tagChipsRO) {
tagChipsRO = new ResizeObserver((entries) => {
for (const entry of entries) {
const target = entry.target as HTMLElement;
const id = target.dataset.listTagItemId;
if (!id) continue;
const w = entry.contentRect.width;
const total = tagItemTotalMap.get(id) ?? 0;
if (total <= 0) { tagVisibleLimit[id] = 99; continue; }
const maxFit = Math.floor((w - TAG_OVERFLOW_MIN) / TAG_EST_WIDTH);
const limit = Math.max(1, Math.min(maxFit, total));
tagVisibleLimit[id] = limit >= total ? 99 : limit;
}
});
}
tagChipsRO.observe(htmlEl);
requestAnimationFrame(() => {
const w = htmlEl.clientWidth;
const total = totalCount;
if (total <= 0) { tagVisibleLimit[itemId] = 99; return; }
const maxFit = Math.floor((w - TAG_OVERFLOW_MIN) / TAG_EST_WIDTH);
const limit = Math.max(1, Math.min(maxFit, total));
tagVisibleLimit[itemId] = limit >= total ? 99 : limit;
});
}
function getTagLimit(itemId: string): number {
return tagVisibleLimit[itemId] ?? 99;
}
function hasTagOverflow(itemId: string, total: number): boolean {
const limit = tagVisibleLimit[itemId] ?? 99;
return total > limit;
}
function getOverflowCount(itemId: string, total: number): number {
const limit = tagVisibleLimit[itemId] ?? 99;
return Math.max(0, total - limit);
}
const {
setupTagChipsObserver,
getTagLimit,
hasTagOverflow,
getOverflowCount,
} = useTagChipsOverflow('listTagItemId');
const tagMap = computed(() => {
const map: Record<string, Tag> = {};
@@ -246,15 +202,8 @@ const handleAction = (action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'de
<div ref="stickySentinel" class="doc-list-sticky-sentinel" aria-hidden="true"></div>
<div class="doc-list-header" :class="{ 'is-stuck': headerStuck }" role="row">
<div class="cell cell-check" role="columnheader" @click.stop>
<t-checkbox
class="doc-list-check"
size="small"
:checked="allSelected"
:indeterminate="someSelected"
:disabled="!items.length"
:title="t('knowledgeBase.selectAll')"
@change="onHeaderCheckboxChange"
/>
<t-checkbox class="doc-list-check" size="small" :checked="allSelected" :indeterminate="someSelected"
:disabled="!items.length" :title="t('knowledgeBase.selectAll')" @change="onHeaderCheckboxChange" />
</div>
<div class="cell cell-name" role="columnheader">{{ t('knowledgeBase.columnName') }}</div>
<div class="cell cell-tag" role="columnheader">{{ t('knowledgeBase.columnTag') }}</div>
@@ -266,23 +215,12 @@ const handleAction = (action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'de
</div>
<div class="doc-list-body">
<div
v-for="item in items"
:key="item.id"
class="doc-list-row"
:class="{ selected: selectedIds.has(item.id), 'menu-open': moreOpen === item.id }"
:data-select-id="item.id"
role="row"
@click="emit('open', item)"
>
<div v-for="item in items" :key="item.id" class="doc-list-row"
:class="{ selected: selectedIds.has(item.id), 'menu-open': moreOpen === item.id }" :data-select-id="item.id"
role="row" @click="emit('open', item)">
<div class="cell cell-check" @click.stop>
<t-checkbox
class="doc-list-check"
size="small"
:checked="selectedIds.has(item.id)"
:title="item.file_name"
@change="(c, ctx) => onRowCheckboxChange(item, c, ctx)"
/>
<t-checkbox class="doc-list-check" size="small" :checked="selectedIds.has(item.id)" :title="item.file_name"
@change="(c, ctx) => onRowCheckboxChange(item, c, ctx)" />
</div>
<div class="cell cell-name">
@@ -291,54 +229,28 @@ const handleAction = (action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'de
</span>
<div class="row-file-text">
<span class="row-file-name" :title="item.file_name">{{ item.file_name }}</span>
<span
v-if="item.description"
class="row-file-desc"
:title="item.description"
>{{ item.description }}</span>
<span v-if="item.description" class="row-file-desc" :title="item.description">{{ item.description }}</span>
</div>
</div>
<div class="cell cell-tag">
<template v-if="item.tags && item.tags.length > 0">
<t-tooltip
v-if="hasTagOverflow(item.id, item.tags.length)"
:content="item.tags.map((t: any) => t.name).join(', ')"
placement="top"
>
<div
class="row-tag-chips"
:ref="(el: any) => setupTagChipsObserver(el, item.id, item.tags.length)"
:class="{ 'is-clickable': canEdit }"
@click.stop="canEdit && emit('tag-edit', item)"
>
<t-tag
v-for="tag in item.tags.slice(0, getTagLimit(item.id))"
:key="tag.id"
size="small"
variant="light-outline"
class="row-tag"
>
<t-tooltip v-if="hasTagOverflow(item.id, item.tags.length)"
:content="item.tags.map((t: any) => t.name).join(', ')" placement="top">
<div class="row-tag-chips" :ref="(el: any) => setupTagChipsObserver(el, item.id, item.tags.length)"
:class="{ 'is-clickable': canEdit }" @click.stop="canEdit && emit('tag-edit', item)">
<t-tag v-for="tag in item.tags.slice(0, getTagLimit(item.id))" :key="tag.id" size="small"
variant="light-outline" class="row-tag">
{{ tag.name }}
</t-tag>
<span class="row-tag-overflow">+{{ getOverflowCount(item.id, item.tags.length) }}</span>
</div>
</t-tooltip>
<div
v-else
class="row-tag-chips"
:ref="(el: any) => setupTagChipsObserver(el, item.id, item.tags.length)"
:class="{ 'is-clickable': canEdit }"
@click.stop="canEdit && emit('tag-edit', item)"
>
<t-tag
v-for="tag in item.tags.slice(0, getTagLimit(item.id))"
:key="tag.id"
size="small"
variant="light-outline"
class="row-tag"
>
<div v-else class="row-tag-chips" :ref="(el: any) => setupTagChipsObserver(el, item.id, item.tags.length)"
:class="{ 'is-clickable': canEdit }" @click.stop="canEdit && emit('tag-edit', item)">
<t-tag v-for="tag in item.tags.slice(0, getTagLimit(item.id))" :key="tag.id" size="small"
variant="light-outline" class="row-tag">
{{ tag.name }}
</t-tag>
</div>
@@ -359,18 +271,11 @@ const handleAction = (action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'de
<div class="cell cell-status">
<template v-if="statusByRow.get(item.id) as StatusInfo | undefined">
<t-tag
v-if="statusByRow.get(item.id)!.label !== '--'"
size="small"
:theme="statusByRow.get(item.id)!.theme"
variant="light-outline"
class="row-status-tag"
>
<t-tag v-if="statusByRow.get(item.id)!.label !== '--'" size="small" :theme="statusByRow.get(item.id)!.theme"
variant="light-outline" class="row-status-tag">
<template v-if="statusByRow.get(item.id)!.icon" #icon>
<t-icon
:name="statusByRow.get(item.id)!.icon!"
:class="{ 'icon-spin': statusByRow.get(item.id)!.spin }"
/>
<t-icon :name="statusByRow.get(item.id)!.icon!"
:class="{ 'icon-spin': statusByRow.get(item.id)!.spin }" />
</template>
{{ statusByRow.get(item.id)!.label }}
</t-tag>
@@ -383,56 +288,37 @@ const handleAction = (action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'de
</div>
<div class="cell cell-actions" v-if="canEdit" @click.stop>
<t-popup
placement="bottom-right"
trigger="click"
destroy-on-close
:on-visible-change="(v: boolean) => onMoreVisible(item.id, v)"
>
<button class="row-more-btn" :class="{ active: moreOpen === item.id }" type="button" :aria-label="t('knowledgeBase.columnActions')">
<t-popup placement="bottom-right" trigger="click" destroy-on-close
:on-visible-change="(v: boolean) => onMoreVisible(item.id, v)">
<button class="row-more-btn" :class="{ active: moreOpen === item.id }" type="button"
:aria-label="t('knowledgeBase.columnActions')">
<t-icon name="more" size="16px" />
</button>
<template #content>
<div class="row-menu">
<div
v-if="item.type === 'manual'"
class="row-menu-item"
@click.stop="handleAction('edit', item)"
>
<div v-if="item.type === 'manual'" class="row-menu-item" @click.stop="handleAction('edit', item)">
<t-icon class="icon" name="edit" />
<span>{{ t('knowledgeBase.editDocument') }}</span>
</div>
<div
v-if="isParseInFlight(item)"
class="row-menu-item"
@click.stop="handleAction('reparse', item)"
>
<div v-if="isParseInFlight(item)" class="row-menu-item" @click.stop="handleAction('reparse', item)">
<t-icon class="icon" name="refresh" />
<span>{{ t('knowledgeBase.rebuildDocument') }}</span>
</div>
<t-popconfirm
v-else
theme="warning"
<t-popconfirm v-else theme="warning"
:content="t('knowledgeBase.rebuildConfirm', { fileName: item.file_name || '' })"
:confirm-btn="{ content: t('common.confirm'), theme: 'primary' }"
:cancel-btn="{ content: t('common.cancel') }"
placement="left"
@confirm="handleAction('reparse', item)"
>
:cancel-btn="{ content: t('common.cancel') }" placement="left"
@confirm="handleAction('reparse', item)">
<div class="row-menu-item" @click.stop>
<t-icon class="icon" name="refresh" />
<span>{{ t('knowledgeBase.rebuildDocument') }}</span>
</div>
</t-popconfirm>
<t-popconfirm
v-if="canCancelParse(item)"
theme="warning"
<t-popconfirm v-if="canCancelParse(item)" theme="warning"
:content="t('knowledgeBase.cancelParseConfirmBody', { title: item.file_name || item.id })"
:confirm-btn="{ content: t('knowledgeBase.cancelParse'), theme: 'danger' }"
:cancel-btn="{ content: t('common.cancel') }"
placement="left"
@confirm="handleAction('cancel-parse', item)"
>
:cancel-btn="{ content: t('common.cancel') }" placement="left"
@confirm="handleAction('cancel-parse', item)">
<div class="row-menu-item danger" @click.stop>
<t-icon class="icon" name="close-circle" />
<span>{{ t('knowledgeBase.cancelParse') }}</span>
@@ -442,14 +328,11 @@ const handleAction = (action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'de
<t-icon class="icon" name="swap" />
<span>{{ t('knowledgeBase.moveDocument') }}</span>
</div>
<t-popconfirm
theme="warning"
<t-popconfirm theme="warning"
:content="t('knowledgeBase.confirmDeleteDocument', { fileName: item.file_name || '' })"
:confirm-btn="{ content: t('knowledgeBase.confirmDelete'), theme: 'danger' }"
:cancel-btn="{ content: t('common.cancel') }"
placement="left"
@confirm="handleAction('delete', item)"
>
:cancel-btn="{ content: t('common.cancel') }" placement="left"
@confirm="handleAction('delete', item)">
<div class="row-menu-item danger" @click.stop>
<t-icon class="icon" name="delete" />
<span>{{ t('knowledgeBase.deleteDocument') }}</span>
@@ -470,6 +353,7 @@ const handleAction = (action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'de
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -493,14 +377,14 @@ const handleAction = (action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'de
.doc-list-row {
display: grid;
grid-template-columns:
44px // checkbox
minmax(260px, 2.6fr) // name
minmax(100px, 0.9fr) // tag
minmax(96px, 0.8fr) // source
96px // size
minmax(96px, 0.7fr) // status
140px // updated_at
48px; // actions
44px // checkbox
minmax(260px, 2.6fr) // name
minmax(100px, 0.9fr) // tag
minmax(96px, 0.8fr) // source
96px // size
minmax(96px, 0.7fr) // status
140px // updated_at
48px; // actions
align-items: center;
column-gap: 0;
padding: 0 16px;
@@ -572,8 +456,14 @@ const handleAction = (action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'de
align-items: center;
min-width: 0;
padding: 0 8px;
&:first-child { padding-left: 0; }
&:last-child { padding-right: 0; }
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
.cell-check {
@@ -683,6 +573,7 @@ const handleAction = (action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'de
.row-tag {
max-width: 100%;
:deep(.t-tag__text) {
overflow: hidden;
text-overflow: ellipsis;
@@ -757,11 +648,15 @@ const handleAction = (action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'de
.row-status-tag :deep(.t-icon) {
margin-right: 2px;
}
.icon-spin {
animation: doc-list-spin 0.9s linear infinite;
}
@keyframes doc-list-spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
.row-more-btn {
@@ -21,16 +21,21 @@ func (r *knowledgeRepository) SetKnowledgeTags(
Delete(&types.KnowledgeTagRelation{}).Error; err != nil {
return err
}
// Insert new relations
// Insert new relations (skip empty and duplicate IDs)
if len(tagIDs) == 0 {
return nil
}
seen := make(map[string]struct{}, len(tagIDs))
now := time.Now()
relations := make([]types.KnowledgeTagRelation, 0, len(tagIDs))
for _, tagID := range tagIDs {
if tagID == "" {
continue
}
if _, dup := seen[tagID]; dup {
continue
}
seen[tagID] = struct{}{}
relations = append(relations, types.KnowledgeTagRelation{
KnowledgeID: knowledgeID,
TagID: tagID,
@@ -0,0 +1,203 @@
package repository
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
const knowledgeTagTestDDL = `
CREATE TABLE IF NOT EXISTS knowledges (
id VARCHAR(36) PRIMARY KEY,
tenant_id INTEGER NOT NULL,
knowledge_base_id VARCHAR(36) NOT NULL,
type VARCHAR(50) NOT NULL DEFAULT 'file',
title VARCHAR(255) NOT NULL DEFAULT '',
parse_status VARCHAR(50) NOT NULL DEFAULT 'completed',
deleted_at DATETIME
);
CREATE TABLE IF NOT EXISTS knowledge_tags (
id VARCHAR(36) PRIMARY KEY,
seq_id INTEGER NOT NULL,
tenant_id INTEGER NOT NULL,
knowledge_base_id VARCHAR(36) NOT NULL,
name VARCHAR(128) NOT NULL,
color VARCHAR(32),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS knowledge_tag_relations (
knowledge_id VARCHAR(36) NOT NULL,
tag_id VARCHAR(36) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (knowledge_id, tag_id)
);
CREATE TABLE IF NOT EXISTS chunks (
id VARCHAR(36) PRIMARY KEY,
tenant_id INTEGER NOT NULL,
knowledge_base_id VARCHAR(36) NOT NULL,
tag_id VARCHAR(36),
deleted_at DATETIME
);
`
func setupKnowledgeTagTestDB(t *testing.T) *gorm.DB {
t.Helper()
db := setupKnowledgeTestDB(t)
require.NoError(t, db.Exec(knowledgeTagTestDDL).Error)
return db
}
func seedKnowledgeTagFixture(t *testing.T, db *gorm.DB) (kbID, knowledgeID, tagA, tagB string) {
t.Helper()
kbID = uuid.New().String()
knowledgeID = uuid.New().String()
tagA = uuid.New().String()
tagB = uuid.New().String()
require.NoError(t, db.Exec(`
INSERT INTO knowledges (id, tenant_id, knowledge_base_id, type, title, parse_status)
VALUES (?, 1, ?, 'file', 'doc', 'completed')
`, knowledgeID, kbID).Error)
require.NoError(t, db.Exec(`
INSERT INTO knowledge_tags (id, seq_id, tenant_id, knowledge_base_id, name)
VALUES (?, 1, 1, ?, 'alpha'), (?, 2, 1, ?, 'beta')
`, tagA, kbID, tagB, kbID).Error)
return kbID, knowledgeID, tagA, tagB
}
func TestSetKnowledgeTags_DedupesAndReplaces(t *testing.T) {
db := setupKnowledgeTagTestDB(t)
repo := &knowledgeRepository{db: db}
_, knowledgeID, tagA, tagB := seedKnowledgeTagFixture(t, db)
ctx := context.Background()
err := repo.SetKnowledgeTags(ctx, knowledgeID, []string{tagA, tagA, tagB})
require.NoError(t, err)
var count int64
require.NoError(t, db.Model(&types.KnowledgeTagRelation{}).
Where("knowledge_id = ?", knowledgeID).Count(&count).Error)
assert.Equal(t, int64(2), count)
err = repo.SetKnowledgeTags(ctx, knowledgeID, []string{tagB})
require.NoError(t, err)
require.NoError(t, db.Model(&types.KnowledgeTagRelation{}).
Where("knowledge_id = ?", knowledgeID).Count(&count).Error)
assert.Equal(t, int64(1), count)
err = repo.SetKnowledgeTags(ctx, knowledgeID, nil)
require.NoError(t, err)
require.NoError(t, db.Model(&types.KnowledgeTagRelation{}).
Where("knowledge_id = ?", knowledgeID).Count(&count).Error)
assert.Equal(t, int64(0), count)
}
func TestGetKnowledgeTags_ReturnsTagDetails(t *testing.T) {
db := setupKnowledgeTagTestDB(t)
repo := &knowledgeRepository{db: db}
_, knowledgeID, tagA, tagB := seedKnowledgeTagFixture(t, db)
ctx := context.Background()
require.NoError(t, repo.SetKnowledgeTags(ctx, knowledgeID, []string{tagA, tagB}))
tagMap, err := repo.GetKnowledgeTags(ctx, []string{knowledgeID})
require.NoError(t, err)
require.Len(t, tagMap[knowledgeID], 2)
names := map[string]bool{}
for _, tag := range tagMap[knowledgeID] {
names[tag.Name] = true
}
assert.True(t, names["alpha"])
assert.True(t, names["beta"])
}
func TestDeleteKnowledgeTagRelations(t *testing.T) {
db := setupKnowledgeTagTestDB(t)
repo := &knowledgeRepository{db: db}
_, knowledgeID, tagA, _ := seedKnowledgeTagFixture(t, db)
ctx := context.Background()
require.NoError(t, repo.SetKnowledgeTags(ctx, knowledgeID, []string{tagA}))
require.NoError(t, repo.DeleteKnowledgeTagRelations(ctx, knowledgeID))
var count int64
require.NoError(t, db.Model(&types.KnowledgeTagRelation{}).
Where("knowledge_id = ?", knowledgeID).Count(&count).Error)
assert.Equal(t, int64(0), count)
}
func TestApplyKnowledgeListFilter_TagIDsOrSemantics(t *testing.T) {
db := setupKnowledgeTagTestDB(t)
repo := &knowledgeRepository{db: db}
ctx := context.Background()
kbID := uuid.New().String()
docA := uuid.New().String()
docB := uuid.New().String()
docC := uuid.New().String()
tagA := uuid.New().String()
tagB := uuid.New().String()
for _, row := range []struct{ id, title string }{
{docA, "a"}, {docB, "b"}, {docC, "c"},
} {
require.NoError(t, db.Exec(`
INSERT INTO knowledges (id, tenant_id, knowledge_base_id, type, title, parse_status)
VALUES (?, 1, ?, 'file', ?, 'completed')
`, row.id, kbID, row.title).Error)
}
require.NoError(t, db.Exec(`
INSERT INTO knowledge_tags (id, seq_id, tenant_id, knowledge_base_id, name)
VALUES (?, 1, 1, ?, 't-a'), (?, 2, 1, ?, 't-b')
`, tagA, kbID, tagB, kbID).Error)
require.NoError(t, repo.SetKnowledgeTags(ctx, docA, []string{tagA}))
require.NoError(t, repo.SetKnowledgeTags(ctx, docB, []string{tagB}))
require.NoError(t, repo.SetKnowledgeTags(ctx, docC, []string{tagA, tagB}))
query := db.WithContext(ctx).Model(&types.Knowledge{}).
Where("tenant_id = ? AND knowledge_base_id = ?", uint64(1), kbID)
query = applyKnowledgeListFilter(query, types.KnowledgeListFilter{
TagIDs: []string{tagA, tagB},
})
var ids []string
require.NoError(t, query.Pluck("id", &ids).Error)
assert.ElementsMatch(t, []string{docA, docB, docC}, ids)
}
func TestBatchCountReferences_ScopedToKnowledgeBase(t *testing.T) {
db := setupKnowledgeTagTestDB(t)
knowledgeRepo := &knowledgeRepository{db: db}
tagRepo := &knowledgeTagRepository{db: db}
ctx := context.Background()
kb1 := uuid.New().String()
kb2 := uuid.New().String()
doc1 := uuid.New().String()
doc2 := uuid.New().String()
tag1 := uuid.New().String()
tag2 := uuid.New().String()
require.NoError(t, db.Exec(`
INSERT INTO knowledges (id, tenant_id, knowledge_base_id, type, title, parse_status)
VALUES (?, 1, ?, 'file', 'kb1-doc', 'completed'), (?, 1, ?, 'file', 'kb2-doc', 'completed')
`, doc1, kb1, doc2, kb2).Error)
require.NoError(t, db.Exec(`
INSERT INTO knowledge_tags (id, seq_id, tenant_id, knowledge_base_id, name)
VALUES (?, 1, 1, ?, 'tag-kb1'), (?, 2, 1, ?, 'tag-kb2')
`, tag1, kb1, tag2, kb2).Error)
require.NoError(t, knowledgeRepo.SetKnowledgeTags(ctx, doc1, []string{tag1}))
// Stale relation: doc in kb2 still linked to tag1 (simulates pre-fix move bug)
require.NoError(t, knowledgeRepo.SetKnowledgeTags(ctx, doc2, []string{tag1}))
counts, err := tagRepo.BatchCountReferences(ctx, 1, kb1, []string{tag1})
require.NoError(t, err)
assert.Equal(t, int64(1), counts[tag1].KnowledgeCount)
}
+3 -3
View File
@@ -151,7 +151,7 @@ func (r *knowledgeTagRepository) CountReferences(
) (knowledgeCount int64, chunkCount int64, err error) {
if err = r.db.WithContext(ctx).
Table("knowledge_tag_relations AS ktr").
Joins("JOIN knowledges AS k ON ktr.knowledge_id = k.id AND k.deleted_at IS NULL").
Joins("JOIN knowledges AS k ON ktr.knowledge_id = k.id AND k.deleted_at IS NULL AND k.tenant_id = ? AND k.knowledge_base_id = ?", tenantID, kbID).
Where("ktr.tag_id = ?", tagID).
Count(&knowledgeCount).Error; err != nil {
return
@@ -193,7 +193,7 @@ func (r *knowledgeTagRepository) BatchCountReferences(
if err := r.db.WithContext(ctx).
Table("knowledge_tag_relations AS ktr").
Select("ktr.tag_id, COUNT(*) as count").
Joins("JOIN knowledges AS k ON ktr.knowledge_id = k.id AND k.deleted_at IS NULL").
Joins("JOIN knowledges AS k ON ktr.knowledge_id = k.id AND k.deleted_at IS NULL AND k.tenant_id = ? AND k.knowledge_base_id = ?", tenantID, kbID).
Where("ktr.tag_id IN (?)", tagIDs).
Group("ktr.tag_id").
Find(&knowledgeCounts).Error; err != nil {
@@ -230,7 +230,7 @@ func (r *knowledgeTagRepository) DeleteUnusedTags(ctx context.Context, tenantID
// Delete tags that have no references in both knowledges and chunks tables (excluding soft-deleted records)
result := r.db.WithContext(ctx).
Where("tenant_id = ? AND knowledge_base_id = ?", tenantID, kbID).
Where("id NOT IN (SELECT DISTINCT ktr.tag_id FROM knowledge_tag_relations ktr JOIN knowledges k ON ktr.knowledge_id = k.id AND k.deleted_at IS NULL)").
Where("id NOT IN (SELECT DISTINCT ktr.tag_id FROM knowledge_tag_relations ktr JOIN knowledges k ON ktr.knowledge_id = k.id AND k.deleted_at IS NULL AND k.tenant_id = ? AND k.knowledge_base_id = ?)", tenantID, kbID).
Where("id NOT IN (SELECT DISTINCT tag_id FROM chunks WHERE tenant_id = ? AND knowledge_base_id = ? AND tag_id IS NOT NULL AND tag_id != '' AND deleted_at IS NULL)", tenantID, kbID).
Delete(&types.KnowledgeTag{})
return result.RowsAffected, result.Error
+86 -16
View File
@@ -661,6 +661,84 @@ func (s *knowledgeService) SetKnowledgeTags(ctx context.Context, knowledgeID str
return s.repo.SetKnowledgeTags(ctx, knowledgeID, tagIDs)
}
// validateKnowledgeTagIDs ensures every tag exists and belongs to the given knowledge base.
func (s *knowledgeService) validateKnowledgeTagIDs(
ctx context.Context,
tenantID uint64,
kbID string,
tagIDs []string,
) error {
unique := make([]string, 0, len(tagIDs))
seen := make(map[string]struct{}, len(tagIDs))
for _, tagID := range tagIDs {
if tagID == "" {
continue
}
if _, dup := seen[tagID]; dup {
continue
}
seen[tagID] = struct{}{}
unique = append(unique, tagID)
}
if len(unique) == 0 {
return nil
}
tags, err := s.tagRepo.GetByIDs(ctx, tenantID, unique)
if err != nil {
return err
}
tagMap := make(map[string]*types.KnowledgeTag, len(tags))
for _, tag := range tags {
tagMap[tag.ID] = tag
}
for _, tagID := range unique {
tag, ok := tagMap[tagID]
if !ok {
return werrors.NewBadRequestError(fmt.Sprintf("标签 %s 不存在", tagID))
}
if tag.KnowledgeBaseID != kbID {
return werrors.NewBadRequestError("标签不属于当前知识库")
}
}
return nil
}
// attachTagsToKnowledge populates knowledge.Tags from the join table.
func (s *knowledgeService) attachTagsToKnowledge(ctx context.Context, knowledge *types.Knowledge) {
if knowledge == nil {
return
}
tagMap, err := s.repo.GetKnowledgeTags(ctx, []string{knowledge.ID})
if err != nil {
logger.Warnf(ctx, "Failed to load tags for knowledge %s: %v", knowledge.ID, err)
return
}
if tags, ok := tagMap[knowledge.ID]; ok {
knowledge.Tags = tags
}
}
// setAndAttachKnowledgeTags validates, persists, and populates tags on a knowledge entry.
func (s *knowledgeService) setAndAttachKnowledgeTags(
ctx context.Context,
tenantID uint64,
kbID string,
knowledge *types.Knowledge,
tagIDs []string,
) error {
if err := s.validateKnowledgeTagIDs(ctx, tenantID, kbID, tagIDs); err != nil {
return err
}
if len(tagIDs) > 0 {
if err := s.repo.SetKnowledgeTags(ctx, knowledge.ID, tagIDs); err != nil {
return err
}
}
s.attachTagsToKnowledge(ctx, knowledge)
return nil
}
// GetKnowledgeTags returns tags for multiple knowledge IDs.
func (s *knowledgeService) GetKnowledgeTags(ctx context.Context, knowledgeIDs []string) (map[string][]*types.KnowledgeTag, error) {
return s.repo.GetKnowledgeTags(ctx, knowledgeIDs)
@@ -675,16 +753,8 @@ func (s *knowledgeService) UpdateKnowledgeTag(ctx context.Context, knowledgeID s
}
// Validate all tag IDs
if len(tagIDs) > 0 {
for _, tagID := range tagIDs {
tag, err := s.tagRepo.GetByID(ctx, tenantID, tagID)
if err != nil {
return err
}
if tag.KnowledgeBaseID != knowledge.KnowledgeBaseID {
return werrors.NewBadRequestError("标签不属于当前知识库")
}
}
if err := s.validateKnowledgeTagIDs(ctx, tenantID, knowledge.KnowledgeBaseID, tagIDs); err != nil {
return err
}
return s.repo.SetKnowledgeTags(ctx, knowledgeID, tagIDs)
@@ -746,12 +816,12 @@ func (s *knowledgeService) UpdateKnowledgeTagBatch(ctx context.Context, authoriz
for tagID := range tagIDSet {
tagIDList = append(tagIDList, tagID)
}
for _, tagID := range tagIDList {
tag, err := s.tagRepo.GetByID(ctx, tenantID, tagID)
if err != nil {
return err
}
tagMap[tagID] = tag
tags, err := s.tagRepo.GetByIDs(ctx, tenantID, tagIDList)
if err != nil {
return err
}
for _, tag := range tags {
tagMap[tag.ID] = tag
}
}
@@ -1098,7 +1098,10 @@ func (s *knowledgeService) moveKnowledgeReuseVectors(
return fmt.Errorf("failed to move chunks: %w", err)
}
// 4. Update knowledge record
// 4. Update knowledge record (tags are KB-scoped; clear relations before moving)
if err := s.repo.DeleteKnowledgeTagRelations(ctx, knowledge.ID); err != nil {
return fmt.Errorf("failed to clear knowledge tag relations: %w", err)
}
knowledge.KnowledgeBaseID = targetKB.ID
knowledge.ParseStatus = types.ParseStatusCompleted
knowledge.UpdatedAt = time.Now()
@@ -1123,7 +1126,10 @@ func (s *knowledgeService) moveKnowledgeReparse(
// Continue - partial cleanup is acceptable
}
// 2. Update knowledge to belong to target KB
// 2. Update knowledge to belong to target KB (tags are KB-scoped; clear relations)
if err := s.repo.DeleteKnowledgeTagRelations(ctx, knowledge.ID); err != nil {
return fmt.Errorf("failed to clear knowledge tag relations: %w", err)
}
knowledge.KnowledgeBaseID = targetKB.ID
knowledge.EmbeddingModelID = targetKB.EmbeddingModelID
knowledge.ParseStatus = types.ParseStatusPending
@@ -231,11 +231,9 @@ func (s *knowledgeService) CreateKnowledgeFromFile(ctx context.Context,
}
// Set tag relations
if len(tagIDs) > 0 {
if err := s.repo.SetKnowledgeTags(ctx, knowledge.ID, tagIDs); err != nil {
logger.Errorf(ctx, "Failed to set knowledge tags, knowledge ID: %s, error: %v", knowledge.ID, err)
return nil, err
}
if err := s.setAndAttachKnowledgeTags(ctx, tenantID, kbID, knowledge, tagIDs); err != nil {
logger.Errorf(ctx, "Failed to set knowledge tags, knowledge ID: %s, error: %v", knowledge.ID, err)
return nil, err
}
// Enqueue document processing task to Asynq
@@ -418,11 +416,9 @@ func (s *knowledgeService) CreateKnowledgeFromURL(ctx context.Context,
}
// Set tag relations
if len(tagIDs) > 0 {
if err := s.repo.SetKnowledgeTags(ctx, knowledge.ID, tagIDs); err != nil {
logger.Errorf(ctx, "Failed to set knowledge tags, knowledge ID: %s, error: %v", knowledge.ID, err)
return nil, err
}
if err := s.setAndAttachKnowledgeTags(ctx, tenantID, kbID, knowledge, tagIDs); err != nil {
logger.Errorf(ctx, "Failed to set knowledge tags, knowledge ID: %s, error: %v", knowledge.ID, err)
return nil, err
}
// Enqueue URL processing task to Asynq
@@ -658,11 +654,9 @@ func (s *knowledgeService) createKnowledgeFromFileURL(
}
// Set tag relations
if len(tagIDs) > 0 {
if err := s.repo.SetKnowledgeTags(ctx, knowledge.ID, tagIDs); err != nil {
logger.Errorf(ctx, "Failed to set knowledge tags, knowledge ID: %s, error: %v", knowledge.ID, err)
return nil, err
}
if err := s.setAndAttachKnowledgeTags(ctx, tenantID, kbID, knowledge, tagIDs); err != nil {
logger.Errorf(ctx, "Failed to set knowledge tags, knowledge ID: %s, error: %v", knowledge.ID, err)
return nil, err
}
// Build async task payload
@@ -813,11 +807,9 @@ func (s *knowledgeService) CreateKnowledgeFromManual(ctx context.Context,
}
// Set tag relations
if len(payload.TagIDs) > 0 {
if err := s.repo.SetKnowledgeTags(ctx, knowledge.ID, payload.TagIDs); err != nil {
logger.Errorf(ctx, "Failed to set knowledge tags, knowledge ID: %s, error: %v", knowledge.ID, err)
return nil, err
}
if err := s.setAndAttachKnowledgeTags(ctx, tenantID, kbID, knowledge, payload.TagIDs); err != nil {
logger.Errorf(ctx, "Failed to set knowledge tags, knowledge ID: %s, error: %v", knowledge.ID, err)
return nil, err
}
if status == types.ManualKnowledgeStatusPublish {
@@ -185,6 +185,9 @@ func (s *knowledgeService) DeleteKnowledge(ctx context.Context, id string) error
if err = wg.Wait(); err != nil {
return err
}
if err := s.repo.DeleteKnowledgeTagRelations(ctx, id); err != nil {
logger.Warnf(ctx, "Failed to delete tag relations for knowledge %s: %v", id, err)
}
// Delete the knowledge entry itself from the database
return s.repo.DeleteKnowledge(ctx, ctx.Value(types.TenantIDContextKey).(uint64), id)
}
@@ -601,6 +604,11 @@ func (s *knowledgeService) DeleteKnowledgeList(ctx context.Context, ids []string
if err = wg.Wait(); err != nil {
return err
}
for _, knowledgeID := range ids {
if err := s.repo.DeleteKnowledgeTagRelations(ctx, knowledgeID); err != nil {
logger.Warnf(ctx, "Failed to delete tag relations for knowledge %s: %v", knowledgeID, err)
}
}
// 6. Delete the knowledge entry itself from the database
return s.repo.DeleteKnowledgeList(ctx, tenantInfo.ID, ids)
}
+3 -2
View File
@@ -261,6 +261,7 @@ func (h *KnowledgeHandler) enqueueKnowledgeListReparse(
// @Param fileName formData string false "自定义文件名"
// @Param metadata formData string false "元数据JSON"
// @Param enable_multimodel formData bool false "启用多模态处理"
// @Param tag_ids formData string false "分类ID列表,逗号分隔"
// @Param process_config formData string false "处理配置JSON(KnowledgeProcessOverrides)"
// @Success 200 {object} map[string]interface{} "创建的知识"
// @Failure 400 {object} errors.AppError "请求参数错误"
@@ -400,7 +401,7 @@ func (h *KnowledgeHandler) CreateKnowledgeFromFile(c *gin.Context) {
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param request body object{url=string,file_name=string,file_type=string,enable_multimodel=bool,title=string,tag_id=string} true "URL请求"
// @Param request body object{url=string,file_name=string,file_type=string,enable_multimodel=bool,title=string,tag_ids=[]string} true "URL请求"
// @Success 201 {object} map[string]interface{} "创建的知识"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 409 {object} map[string]interface{} "URL重复"
@@ -821,7 +822,7 @@ func buildSpanTree(knowledgeID string, attempt int, rows []types.KnowledgeProces
// @Param id path string true "知识库ID"
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Param tag_id query string false "标签ID筛选"
// @Param tag_ids query string false "标签ID筛选,逗号分隔(OR语义)"
// @Param keyword query string false "关键词搜索"
// @Param file_type query string false "文件类型筛选"
// @Param parse_status query string false "解析状态筛选 (pending/processing/completed/failed)"
@@ -0,0 +1,14 @@
package handler
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseCommaSeparatedTagIDs(t *testing.T) {
assert.Nil(t, parseCommaSeparatedTagIDs(""))
assert.Equal(t, []string{"a", "b"}, parseCommaSeparatedTagIDs("a,b"))
assert.Equal(t, []string{"a", "b"}, parseCommaSeparatedTagIDs(" a , b "))
assert.Equal(t, []string{"a"}, parseCommaSeparatedTagIDs("a,__untagged__,,"))
}