From 845d990a48518b33a04a166bb7e1546ecebd7ea5 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Tue, 2 Dec 2025 20:39:59 +0800 Subject: [PATCH] feat: Add tag management and FAQ management APIs, enhance knowledge base functionality with keyword search and custom file naming support --- client/example.go | 2 +- client/faq.go | 5 +- client/knowledge.go | 24 +- client/knowledgebase.go | 82 ++- client/tag.go | 157 ++++++ docs/API.md | 482 +++++++++++++++- frontend/src/api/knowledge-base/index.ts | 13 +- frontend/src/components/TenantSelector.vue | 525 +++++++++--------- frontend/src/components/menu.vue | 7 +- frontend/src/i18n/locales/en-US.ts | 2 + frontend/src/i18n/locales/ru-RU.ts | 2 + frontend/src/i18n/locales/zh-CN.ts | 18 +- .../src/views/knowledge/KnowledgeBase.vue | 93 +++- .../knowledge/components/FAQEntryManager.vue | 311 +++++++++-- frontend/src/views/settings/Settings.vue | 2 +- internal/agent/tools/get_document_info.go | 2 +- internal/agent/tools/knowledge_search.go | 3 +- internal/agent/tools/list_knowledge_chunks.go | 2 +- internal/application/repository/chunk.go | 30 +- internal/application/repository/tag.go | 45 +- internal/application/service/agent_service.go | 2 +- internal/application/service/chunk.go | 1 + internal/application/service/knowledge.go | 6 +- internal/application/service/tag.go | 15 +- internal/handler/faq.go | 3 +- internal/handler/tag.go | 12 +- internal/types/interfaces/chunk.go | 1 + internal/types/interfaces/knowledge.go | 8 +- internal/types/interfaces/tag.go | 10 +- 29 files changed, 1471 insertions(+), 394 deletions(-) create mode 100644 client/tag.go diff --git a/client/example.go b/client/example.go index 62833ae1..67a6f5de 100644 --- a/client/example.go +++ b/client/example.go @@ -64,7 +64,7 @@ func ExampleUsage() { "source": "local", "type": "document", } - knowledge, err := apiClient.CreateKnowledgeFromFile(context.Background(), createdKB.ID, filePath, metadata, nil) + knowledge, err := apiClient.CreateKnowledgeFromFile(context.Background(), createdKB.ID, filePath, metadata, nil, "") if err != nil { fmt.Printf("Failed to upload knowledge file: %v\n", err) } else { diff --git a/client/faq.go b/client/faq.go index 38bb7c11..035f596b 100644 --- a/client/faq.go +++ b/client/faq.go @@ -113,7 +113,7 @@ type faqSimpleResponse struct { // ListFAQEntries returns paginated FAQ entries under a knowledge base. func (c *Client) ListFAQEntries(ctx context.Context, - knowledgeBaseID string, page, pageSize int, tagID string, + knowledgeBaseID string, page, pageSize int, tagID string, keyword string, ) (*FAQEntriesPage, error) { path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries", knowledgeBaseID) query := url.Values{} @@ -126,6 +126,9 @@ func (c *Client) ListFAQEntries(ctx context.Context, if tagID != "" { query.Add("tag_id", tagID) } + if keyword != "" { + query.Add("keyword", keyword) + } resp, err := c.doRequest(ctx, http.MethodGet, path, nil, query) if err != nil { diff --git a/client/knowledge.go b/client/knowledge.go index 29edad97..f1faaf11 100644 --- a/client/knowledge.go +++ b/client/knowledge.go @@ -22,8 +22,9 @@ import ( // Knowledge represents knowledge information type Knowledge struct { ID string `json:"id"` - TenantID uint `json:"tenant_id"` + TenantID uint64 `json:"tenant_id"` KnowledgeBaseID string `json:"knowledge_base_id"` + TagID string `json:"tag_id"` Type string `json:"type"` Title string `json:"title"` Description string `json:"description"` @@ -34,7 +35,9 @@ type Knowledge struct { FileName string `json:"file_name"` FileType string `json:"file_type"` FileSize int64 `json:"file_size"` + FileHash string `json:"file_hash"` FilePath string `json:"file_path"` + StorageSize int64 `json:"storage_size"` Metadata map[string]string `json:"metadata"` // Extensible metadata for storing machine information, paths, etc. CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -78,8 +81,14 @@ var ErrDuplicateFile = errors.New("file already exists") var ErrDuplicateURL = errors.New("URL already exists") // CreateKnowledgeFromFile creates a knowledge entry from a local file path +// Parameters: +// - knowledgeBaseID: The ID of the knowledge base +// - filePath: The local file path +// - metadata: Optional metadata for the knowledge entry +// - enableMultimodel: Optional flag to enable multimodal processing +// - customFileName: Optional custom file name (useful for folder uploads with path) func (c *Client) CreateKnowledgeFromFile(ctx context.Context, - knowledgeBaseID string, filePath string, metadata map[string]string, enableMultimodel *bool, + knowledgeBaseID string, filePath string, metadata map[string]string, enableMultimodel *bool, customFileName string, ) (*Knowledge, error) { // Open the local file file, err := os.Open(filePath) @@ -133,6 +142,13 @@ func (c *Client) CreateKnowledgeFromFile(ctx context.Context, } } + // Add custom file name if provided + if customFileName != "" { + if err := writer.WriteField("fileName", customFileName); err != nil { + return nil, fmt.Errorf("failed to write fileName field: %w", err) + } + } + // Close the multipart writer err = writer.Close() if err != nil { @@ -249,12 +265,16 @@ func (c *Client) ListKnowledge(ctx context.Context, knowledgeBaseID string, page int, pageSize int, + tagID string, ) ([]Knowledge, int64, error) { path := fmt.Sprintf("/api/v1/knowledge-bases/%s/knowledge", knowledgeBaseID) queryParams := url.Values{} queryParams.Add("page", strconv.Itoa(page)) queryParams.Add("page_size", strconv.Itoa(pageSize)) + if tagID != "" { + queryParams.Add("tag_id", tagID) + } resp, err := c.doRequest(ctx, http.MethodGet, path, nil, queryParams) if err != nil { diff --git a/client/knowledgebase.go b/client/knowledgebase.go index 54a59c80..48808ca1 100644 --- a/client/knowledgebase.go +++ b/client/knowledgebase.go @@ -8,7 +8,6 @@ import ( "context" "fmt" "net/http" - "net/url" "time" ) @@ -17,34 +16,44 @@ type KnowledgeBase struct { ID string `json:"id"` Name string `json:"name"` // Name must be unique within the same tenant Type string `json:"type"` + IsTemporary bool `json:"is_temporary"` Description string `json:"description"` - TenantID uint `json:"tenant_id"` // Changed to uint type + TenantID uint64 `json:"tenant_id"` ChunkingConfig ChunkingConfig `json:"chunking_config"` ImageProcessingConfig ImageProcessingConfig `json:"image_processing_config"` FAQConfig *FAQConfig `json:"faq_config"` EmbeddingModelID string `json:"embedding_model_id"` - SummaryModelID string `json:"summary_model_id"` // Summary model ID + SummaryModelID string `json:"summary_model_id"` + VLMConfig VLMConfig `json:"vlm_config"` + StorageConfig StorageConfig `json:"cos_config"` + ExtractConfig *ExtractConfig `json:"extract_config"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + // Computed fields (not stored in database) + KnowledgeCount int64 `json:"knowledge_count"` + ChunkCount int64 `json:"chunk_count"` + IsProcessing bool `json:"is_processing"` + ProcessingCount int64 `json:"processing_count"` } // KnowledgeBaseConfig represents knowledge base configuration type KnowledgeBaseConfig struct { ChunkingConfig ChunkingConfig `json:"chunking_config"` ImageProcessingConfig ImageProcessingConfig `json:"image_processing_config"` + FAQConfig *FAQConfig `json:"faq_config"` } // ChunkingConfig represents document chunking configuration type ChunkingConfig struct { - ChunkSize int `json:"chunk_size"` // Chunk size - ChunkOverlap int `json:"chunk_overlap"` // Overlap size - Separators []string `json:"separators"` // Separators - EnableMultimodal bool `json:"enable_multimodal"` // Whether to enable multimodal processing + ChunkSize int `json:"chunk_size"` // Chunk size + ChunkOverlap int `json:"chunk_overlap"` // Overlap size + Separators []string `json:"separators"` // Separators } // FAQConfig represents faq-specific configuration type FAQConfig struct { - IndexMode string `json:"index_mode"` + IndexMode string `json:"index_mode"` + QuestionIndexMode string `json:"question_index_mode"` } // ImageProcessingConfig represents image processing configuration @@ -52,6 +61,44 @@ type ImageProcessingConfig struct { ModelID string `json:"model_id"` // Multimodal model ID } +// VLMConfig represents the VLM configuration +type VLMConfig struct { + Enabled bool `json:"enabled"` + ModelID string `json:"model_id"` +} + +// StorageConfig represents the storage configuration +type StorageConfig struct { + SecretID string `json:"secret_id"` + SecretKey string `json:"secret_key"` + Region string `json:"region"` + BucketName string `json:"bucket_name"` + AppID string `json:"app_id"` + PathPrefix string `json:"path_prefix"` + Provider string `json:"provider"` +} + +// ExtractConfig represents the extract configuration for a knowledge base +type ExtractConfig struct { + Enabled bool `json:"enabled"` + Text string `json:"text,omitempty"` + Tags []string `json:"tags,omitempty"` + Nodes []*GraphNode `json:"nodes,omitempty"` + Relations []*GraphRelation `json:"relations,omitempty"` +} + +// GraphNode represents a node in the graph extraction configuration +type GraphNode struct { + Name string `json:"name"` +} + +// GraphRelation represents a relation in the graph extraction configuration +type GraphRelation struct { + Node1 string `json:"node1"` + Node2 string `json:"node2"` + Type string `json:"type"` +} + // KnowledgeBaseResponse knowledge base response type KnowledgeBaseResponse struct { Success bool `json:"success"` @@ -181,14 +228,23 @@ func (c *Client) DeleteKnowledgeBase(ctx context.Context, knowledgeBaseID string return parseResponse(resp, &response) } +// SearchParams represents the search parameters for hybrid search +type SearchParams struct { + QueryText string `json:"query_text"` + VectorThreshold float64 `json:"vector_threshold"` + KeywordThreshold float64 `json:"keyword_threshold"` + MatchCount int `json:"match_count"` + DisableKeywordsMatch bool `json:"disable_keywords_match"` + DisableVectorMatch bool `json:"disable_vector_match"` +} + // HybridSearch performs hybrid search -func (c *Client) HybridSearch(ctx context.Context, knowledgeBaseID string, query string) ([]*SearchResult, error) { +// Note: The backend route is GET but expects JSON body, which is non-standard. +// This client uses POST with JSON body for better compatibility. +func (c *Client) HybridSearch(ctx context.Context, knowledgeBaseID string, params *SearchParams) ([]*SearchResult, error) { path := fmt.Sprintf("/api/v1/knowledge-bases/%s/hybrid-search", knowledgeBaseID) - queryParams := url.Values{} - queryParams.Add("query", query) - - resp, err := c.doRequest(ctx, http.MethodGet, path, nil, queryParams) + resp, err := c.doRequest(ctx, http.MethodGet, path, params, nil) if err != nil { return nil, err } diff --git a/client/tag.go b/client/tag.go new file mode 100644 index 00000000..e467b32d --- /dev/null +++ b/client/tag.go @@ -0,0 +1,157 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "time" +) + +// Tag represents a knowledge base tag. +type Tag struct { + ID string `json:"id"` + TenantID uint64 `json:"tenant_id"` + KnowledgeBaseID string `json:"knowledge_base_id"` + Name string `json:"name"` + Color string `json:"color"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TagWithStats represents tag information along with usage statistics. +type TagWithStats struct { + Tag + KnowledgeCount int64 `json:"knowledge_count"` + ChunkCount int64 `json:"chunk_count"` +} + +// CreateTagPayload is used to create a new tag. +type CreateTagPayload struct { + Name string `json:"name"` + Color string `json:"color,omitempty"` + SortOrder int `json:"sort_order,omitempty"` +} + +// UpdateTagPayload is used to update an existing tag. +type UpdateTagPayload struct { + Name *string `json:"name,omitempty"` + Color *string `json:"color,omitempty"` + SortOrder *int `json:"sort_order,omitempty"` +} + +// TagsPage contains paginated tag results. +type TagsPage struct { + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Tags []TagWithStats `json:"data"` +} + +// TagsResponse wraps the paginated tags response. +type TagsResponse struct { + Success bool `json:"success"` + Data *TagsPage `json:"data"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` +} + +// TagResponse wraps a single tag response. +type TagResponse struct { + Success bool `json:"success"` + Data *Tag `json:"data"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` +} + +type tagSimpleResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` +} + +// ListTags returns paginated tags under a knowledge base. +func (c *Client) ListTags(ctx context.Context, + knowledgeBaseID string, page, pageSize int, keyword string, +) (*TagsPage, error) { + path := fmt.Sprintf("/api/v1/knowledge-bases/%s/tags", knowledgeBaseID) + query := url.Values{} + if page > 0 { + query.Add("page", strconv.Itoa(page)) + } + if pageSize > 0 { + query.Add("page_size", strconv.Itoa(pageSize)) + } + if keyword != "" { + query.Add("keyword", keyword) + } + + resp, err := c.doRequest(ctx, http.MethodGet, path, nil, query) + if err != nil { + return nil, err + } + + var response TagsResponse + if err := parseResponse(resp, &response); err != nil { + return nil, err + } + if response.Data == nil { + return &TagsPage{}, nil + } + return response.Data, nil +} + +// CreateTag creates a new tag under a knowledge base. +func (c *Client) CreateTag(ctx context.Context, + knowledgeBaseID string, payload *CreateTagPayload, +) (*Tag, error) { + path := fmt.Sprintf("/api/v1/knowledge-bases/%s/tags", knowledgeBaseID) + resp, err := c.doRequest(ctx, http.MethodPost, path, payload, nil) + if err != nil { + return nil, err + } + + var response TagResponse + if err := parseResponse(resp, &response); err != nil { + return nil, err + } + return response.Data, nil +} + +// UpdateTag updates an existing tag. +func (c *Client) UpdateTag(ctx context.Context, + knowledgeBaseID, tagID string, payload *UpdateTagPayload, +) (*Tag, error) { + path := fmt.Sprintf("/api/v1/knowledge-bases/%s/tags/%s", knowledgeBaseID, tagID) + resp, err := c.doRequest(ctx, http.MethodPut, path, payload, nil) + if err != nil { + return nil, err + } + + var response TagResponse + if err := parseResponse(resp, &response); err != nil { + return nil, err + } + return response.Data, nil +} + +// DeleteTag deletes a tag. Set force to true to delete even if the tag is referenced. +func (c *Client) DeleteTag(ctx context.Context, + knowledgeBaseID, tagID string, force bool, +) error { + path := fmt.Sprintf("/api/v1/knowledge-bases/%s/tags/%s", knowledgeBaseID, tagID) + query := url.Values{} + if force { + query.Add("force", "true") + } + + resp, err := c.doRequest(ctx, http.MethodDelete, path, nil, query) + if err != nil { + return err + } + + var response tagSimpleResponse + return parseResponse(resp, &response) +} diff --git a/docs/API.md b/docs/API.md index e4c03e25..a289790b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -13,6 +13,8 @@ - [知识管理 API](#知识管理api) - [模型管理 API](#模型管理api) - [分块管理 API](#分块管理api) + - [标签管理 API](#标签管理api) + - [FAQ管理 API](#faq管理api) - [会话管理 API](#会话管理api) - [聊天功能 API](#聊天功能api) - [消息管理 API](#消息管理api) @@ -72,10 +74,12 @@ WeKnora API 按功能分为以下几类: 3. **知识管理**:上传、检索和管理知识内容 4. **模型管理**:配置和管理各种AI模型 5. **分块管理**:管理知识的分块内容 -6. **会话管理**:创建和管理对话会话 -7. **聊天功能**:基于知识库进行问答 -8. **消息管理**:获取和管理对话消息 -9. **评估功能**:评估模型性能 +6. **标签管理**:管理知识库的标签分类 +7. **FAQ管理**:管理FAQ问答对 +8. **会话管理**:创建和管理对话会话 +9. **聊天功能**:基于知识库进行问答 +10. **消息管理**:获取和管理对话消息 +11. **评估功能**:评估模型性能 ## API 详细说明 @@ -335,6 +339,7 @@ curl --location 'http://localhost:8080/api/v1/tenants' \ | PUT | `/knowledge-bases/:id` | 更新知识库 | | DELETE | `/knowledge-bases/:id` | 删除知识库 | | POST | `/knowledge-bases/copy` | 拷贝知识库 | +| GET | `/knowledge-bases/:id/hybrid-search` | 混合搜索(向量+关键词) | #### POST `/knowledge-bases` - 创建知识库 @@ -640,6 +645,59 @@ curl --location --request DELETE 'http://localhost:8080/api/v1/knowledge-bases/b } ``` +#### GET `/knowledge-bases/:id/hybrid-search` - 混合搜索 + +执行向量搜索和关键词搜索的混合检索。 + +**注意**:此接口使用 GET 方法但需要 JSON 请求体。 + +**请求参数**: +- `query_text`: 搜索查询文本(必填) +- `vector_threshold`: 向量相似度阈值(0-1,可选) +- `keyword_threshold`: 关键词匹配阈值(可选) +- `match_count`: 返回结果数量(可选) +- `disable_keywords_match`: 是否禁用关键词匹配(可选) +- `disable_vector_match`: 是否禁用向量匹配(可选) + +**请求**: + +```curl +curl --location --request GET 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/hybrid-search' \ +--header 'X-API-Key: sk-vQHV2NZI_LK5W7wHQvH3yGYExX8YnhaHwZipUYbiZKCYJbBQ' \ +--header 'Content-Type: application/json' \ +--data '{ + "query_text": "如何使用知识库", + "vector_threshold": 0.5, + "match_count": 10 +}' +``` + +**响应**: + +```json +{ + "data": [ + { + "id": "chunk-00000001", + "content": "知识库是用于存储和检索知识的系统...", + "knowledge_id": "knowledge-00000001", + "chunk_index": 0, + "knowledge_title": "知识库使用指南", + "start_at": 0, + "end_at": 500, + "seq": 1, + "score": 0.95, + "chunk_type": "text", + "image_info": "", + "metadata": {}, + "knowledge_filename": "guide.pdf", + "knowledge_source": "file" + } + ], + "success": true +} +``` +
返回顶部 ↑
### 知识管理API @@ -648,16 +706,25 @@ curl --location --request DELETE 'http://localhost:8080/api/v1/knowledge-bases/b | ------ | ------------------------------------- | ------------------------ | | POST | `/knowledge-bases/:id/knowledge/file` | 从文件创建知识 | | POST | `/knowledge-bases/:id/knowledge/url` | 从 URL 创建知识 | +| POST | `/knowledge-bases/:id/knowledge/manual` | 创建手工 Markdown 知识 | | GET | `/knowledge-bases/:id/knowledge` | 获取知识库下的知识列表 | | GET | `/knowledge/:id` | 获取知识详情 | | DELETE | `/knowledge/:id` | 删除知识 | | GET | `/knowledge/:id/download` | 下载知识文件 | | PUT | `/knowledge/:id` | 更新知识 | +| PUT | `/knowledge/manual/:id` | 更新手工 Markdown 知识 | | PUT | `/knowledge/image/:id/:chunk_id` | 更新图像分块信息 | +| PUT | `/knowledge/tags` | 批量更新知识标签 | | GET | `/knowledge/batch` | 批量获取知识 | #### POST `/knowledge-bases/:id/knowledge/file` - 从文件创建知识 +**表单参数**: +- `file`: 上传的文件(必填) +- `metadata`: JSON 格式的元数据(可选) +- `enable_multimodel`: 是否启用多模态处理(可选,true/false) +- `fileName`: 自定义文件名,用于文件夹上传时保留路径(可选) + **请求**: ```curl @@ -746,12 +813,17 @@ curl --location 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/knowle } ``` -#### GET `/knowledge-bases/:id/knowledge?page=&page_size` - 获取知识库下的知识列表 +#### GET `/knowledge-bases/:id/knowledge` - 获取知识库下的知识列表 + +**查询参数**: +- `page`: 页码(默认 1) +- `page_size`: 每页条数(默认 20) +- `tag_id`: 按标签ID筛选(可选) **请求**: ```curl -curl --location 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/knowledge?page_size=1&page=1' \ +curl --location 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/knowledge?page_size=1&page=1&tag_id=tag-00000001' \ --header 'X-API-Key: sk-vQHV2NZI_LK5W7wHQvH3yGYExX8YnhaHwZipUYbiZKCYJbBQ' \ --header 'Content-Type: application/json' ``` @@ -1306,6 +1378,404 @@ curl --location --request DELETE 'http://localhost:8080/api/v1/chunks/4c4e7c1a-0
返回顶部 ↑
+### 标签管理API + +| 方法 | 路径 | 描述 | +| ------ | ------------------------------------- | ------------------------ | +| GET | `/knowledge-bases/:id/tags` | 获取知识库标签列表 | +| POST | `/knowledge-bases/:id/tags` | 创建标签 | +| PUT | `/knowledge-bases/:id/tags/:tag_id` | 更新标签 | +| DELETE | `/knowledge-bases/:id/tags/:tag_id` | 删除标签 | + +#### GET `/knowledge-bases/:id/tags` - 获取知识库标签列表 + +**查询参数**: +- `page`: 页码(默认 1) +- `page_size`: 每页条数(默认 20) +- `keyword`: 标签名称关键字搜索(可选) + +**请求**: + +```curl +curl --location 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/tags?page=1&page_size=10' \ +--header 'X-API-Key: sk-vQHV2NZI_LK5W7wHQvH3yGYExX8YnhaHwZipUYbiZKCYJbBQ' \ +--header 'Content-Type: application/json' +``` + +**响应**: + +```json +{ + "data": { + "total": 2, + "page": 1, + "page_size": 10, + "data": [ + { + "id": "tag-00000001", + "tenant_id": 1, + "knowledge_base_id": "kb-00000001", + "name": "技术文档", + "color": "#1890ff", + "sort_order": 1, + "created_at": "2025-08-12T10:00:00+08:00", + "updated_at": "2025-08-12T10:00:00+08:00", + "knowledge_count": 5, + "chunk_count": 120 + }, + { + "id": "tag-00000002", + "tenant_id": 1, + "knowledge_base_id": "kb-00000001", + "name": "常见问题", + "color": "#52c41a", + "sort_order": 2, + "created_at": "2025-08-12T10:00:00+08:00", + "updated_at": "2025-08-12T10:00:00+08:00", + "knowledge_count": 3, + "chunk_count": 45 + } + ] + }, + "success": true +} +``` + +#### POST `/knowledge-bases/:id/tags` - 创建标签 + +**请求**: + +```curl +curl --location 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/tags' \ +--header 'X-API-Key: sk-vQHV2NZI_LK5W7wHQvH3yGYExX8YnhaHwZipUYbiZKCYJbBQ' \ +--header 'Content-Type: application/json' \ +--data '{ + "name": "产品手册", + "color": "#faad14", + "sort_order": 3 +}' +``` + +**响应**: + +```json +{ + "data": { + "id": "tag-00000003", + "tenant_id": 1, + "knowledge_base_id": "kb-00000001", + "name": "产品手册", + "color": "#faad14", + "sort_order": 3, + "created_at": "2025-08-12T11:00:00+08:00", + "updated_at": "2025-08-12T11:00:00+08:00" + }, + "success": true +} +``` + +#### PUT `/knowledge-bases/:id/tags/:tag_id` - 更新标签 + +**请求**: + +```curl +curl --location --request PUT 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/tags/tag-00000003' \ +--header 'X-API-Key: sk-vQHV2NZI_LK5W7wHQvH3yGYExX8YnhaHwZipUYbiZKCYJbBQ' \ +--header 'Content-Type: application/json' \ +--data '{ + "name": "产品手册更新", + "color": "#ff4d4f" +}' +``` + +**响应**: + +```json +{ + "data": { + "id": "tag-00000003", + "tenant_id": 1, + "knowledge_base_id": "kb-00000001", + "name": "产品手册更新", + "color": "#ff4d4f", + "sort_order": 3, + "created_at": "2025-08-12T11:00:00+08:00", + "updated_at": "2025-08-12T11:30:00+08:00" + }, + "success": true +} +``` + +#### DELETE `/knowledge-bases/:id/tags/:tag_id` - 删除标签 + +**查询参数**: +- `force`: 设置为 `true` 时强制删除(即使标签被引用) + +**请求**: + +```curl +curl --location --request DELETE 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/tags/tag-00000003?force=true' \ +--header 'X-API-Key: sk-vQHV2NZI_LK5W7wHQvH3yGYExX8YnhaHwZipUYbiZKCYJbBQ' \ +--header 'Content-Type: application/json' +``` + +**响应**: + +```json +{ + "success": true +} +``` + +
返回顶部 ↑
+ +### FAQ管理API + +| 方法 | 路径 | 描述 | +| ------ | ------------------------------------------- | ------------------------ | +| GET | `/knowledge-bases/:id/faq/entries` | 获取FAQ条目列表 | +| POST | `/knowledge-bases/:id/faq/entries` | 批量导入FAQ条目 | +| PUT | `/knowledge-bases/:id/faq/entries/:entry_id`| 更新单个FAQ条目 | +| PUT | `/knowledge-bases/:id/faq/entries/status` | 批量更新FAQ启用状态 | +| PUT | `/knowledge-bases/:id/faq/entries/tags` | 批量更新FAQ标签 | +| DELETE | `/knowledge-bases/:id/faq/entries` | 批量删除FAQ条目 | +| POST | `/knowledge-bases/:id/faq/search` | 混合搜索FAQ | + +#### GET `/knowledge-bases/:id/faq/entries` - 获取FAQ条目列表 + +**查询参数**: +- `page`: 页码(默认 1) +- `page_size`: 每页条数(默认 20) +- `tag_id`: 按标签ID筛选(可选) +- `keyword`: 关键字搜索(可选) + +**请求**: + +```curl +curl --location 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/faq/entries?page=1&page_size=10' \ +--header 'X-API-Key: sk-vQHV2NZI_LK5W7wHQvH3yGYExX8YnhaHwZipUYbiZKCYJbBQ' \ +--header 'Content-Type: application/json' +``` + +**响应**: + +```json +{ + "data": { + "total": 100, + "page": 1, + "page_size": 10, + "data": [ + { + "id": "faq-00000001", + "chunk_id": "chunk-00000001", + "knowledge_id": "knowledge-00000001", + "knowledge_base_id": "kb-00000001", + "tag_id": "tag-00000001", + "is_enabled": true, + "standard_question": "如何重置密码?", + "similar_questions": ["忘记密码怎么办", "密码找回"], + "negative_questions": ["如何修改用户名"], + "answers": ["您可以通过点击登录页面的'忘记密码'链接来重置密码。"], + "index_mode": "hybrid", + "chunk_type": "faq", + "created_at": "2025-08-12T10:00:00+08:00", + "updated_at": "2025-08-12T10:00:00+08:00" + } + ] + }, + "success": true +} +``` + +#### POST `/knowledge-bases/:id/faq/entries` - 批量导入FAQ条目 + +**请求参数**: +- `mode`: 导入模式,`append`(追加)或 `replace`(替换) +- `entries`: FAQ条目数组 +- `knowledge_id`: 关联的知识ID(可选) + +**请求**: + +```curl +curl --location 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/faq/entries' \ +--header 'X-API-Key: sk-vQHV2NZI_LK5W7wHQvH3yGYExX8YnhaHwZipUYbiZKCYJbBQ' \ +--header 'Content-Type: application/json' \ +--data '{ + "mode": "append", + "entries": [ + { + "standard_question": "如何联系客服?", + "similar_questions": ["客服电话", "在线客服"], + "answers": ["您可以通过拨打400-xxx-xxxx联系我们的客服。"], + "tag_id": "tag-00000001" + }, + { + "standard_question": "退款政策是什么?", + "answers": ["我们提供7天无理由退款服务。"] + } + ] +}' +``` + +**响应**: + +```json +{ + "data": { + "task_id": "task-00000001" + }, + "success": true +} +``` + +注:批量导入为异步操作,返回任务ID用于追踪进度。 + +#### PUT `/knowledge-bases/:id/faq/entries/:entry_id` - 更新单个FAQ条目 + +**请求**: + +```curl +curl --location --request PUT 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/faq/entries/faq-00000001' \ +--header 'X-API-Key: sk-vQHV2NZI_LK5W7wHQvH3yGYExX8YnhaHwZipUYbiZKCYJbBQ' \ +--header 'Content-Type: application/json' \ +--data '{ + "standard_question": "如何重置账户密码?", + "similar_questions": ["忘记密码怎么办", "密码找回", "重置密码"], + "answers": ["您可以通过以下步骤重置密码:1. 点击登录页面的"忘记密码" 2. 输入注册邮箱 3. 查收重置邮件"], + "is_enabled": true +}' +``` + +**响应**: + +```json +{ + "success": true +} +``` + +#### PUT `/knowledge-bases/:id/faq/entries/status` - 批量更新FAQ启用状态 + +**请求**: + +```curl +curl --location --request PUT 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/faq/entries/status' \ +--header 'X-API-Key: sk-vQHV2NZI_LK5W7wHQvH3yGYExX8YnhaHwZipUYbiZKCYJbBQ' \ +--header 'Content-Type: application/json' \ +--data '{ + "updates": { + "faq-00000001": true, + "faq-00000002": false, + "faq-00000003": true + } +}' +``` + +**响应**: + +```json +{ + "success": true +} +``` + +#### PUT `/knowledge-bases/:id/faq/entries/tags` - 批量更新FAQ标签 + +**请求**: + +```curl +curl --location --request PUT 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/faq/entries/tags' \ +--header 'X-API-Key: sk-vQHV2NZI_LK5W7wHQvH3yGYExX8YnhaHwZipUYbiZKCYJbBQ' \ +--header 'Content-Type: application/json' \ +--data '{ + "updates": { + "faq-00000001": "tag-00000001", + "faq-00000002": "tag-00000002", + "faq-00000003": null + } +}' +``` + +注:设置为 `null` 可清除标签关联。 + +**响应**: + +```json +{ + "success": true +} +``` + +#### DELETE `/knowledge-bases/:id/faq/entries` - 批量删除FAQ条目 + +**请求**: + +```curl +curl --location --request DELETE 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/faq/entries' \ +--header 'X-API-Key: sk-vQHV2NZI_LK5W7wHQvH3yGYExX8YnhaHwZipUYbiZKCYJbBQ' \ +--header 'Content-Type: application/json' \ +--data '{ + "ids": ["faq-00000001", "faq-00000002"] +}' +``` + +**响应**: + +```json +{ + "success": true +} +``` + +#### POST `/knowledge-bases/:id/faq/search` - 混合搜索FAQ + +**请求参数**: +- `query_text`: 搜索查询文本 +- `vector_threshold`: 向量相似度阈值(0-1) +- `match_count`: 返回结果数量(最大200) + +**请求**: + +```curl +curl --location 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/faq/search' \ +--header 'X-API-Key: sk-vQHV2NZI_LK5W7wHQvH3yGYExX8YnhaHwZipUYbiZKCYJbBQ' \ +--header 'Content-Type: application/json' \ +--data '{ + "query_text": "如何重置密码", + "vector_threshold": 0.5, + "match_count": 10 +}' +``` + +**响应**: + +```json +{ + "data": [ + { + "id": "faq-00000001", + "chunk_id": "chunk-00000001", + "knowledge_id": "knowledge-00000001", + "knowledge_base_id": "kb-00000001", + "tag_id": "tag-00000001", + "is_enabled": true, + "standard_question": "如何重置密码?", + "similar_questions": ["忘记密码怎么办", "密码找回"], + "answers": ["您可以通过点击登录页面的'忘记密码'链接来重置密码。"], + "chunk_type": "faq", + "score": 0.95, + "match_type": "vector", + "created_at": "2025-08-12T10:00:00+08:00", + "updated_at": "2025-08-12T10:00:00+08:00" + } + ], + "success": true +} +``` + +
返回顶部 ↑
+ ### 会话管理API | 方法 | 路径 | 描述 | diff --git a/frontend/src/api/knowledge-base/index.ts b/frontend/src/api/knowledge-base/index.ts index 53ce0a5b..17aac945 100644 --- a/frontend/src/api/knowledge-base/index.ts +++ b/frontend/src/api/knowledge-base/index.ts @@ -95,8 +95,12 @@ export function getChunkByIdOnly(chunkId: string) { return get(`/api/v1/chunks/by-id/${chunkId}`); } -export function listKnowledgeTags(kbId: string) { - return get(`/api/v1/knowledge-bases/${kbId}/tags`); +export function listKnowledgeTags( + kbId: string, + params?: { page?: number; page_size?: number; keyword?: string }, +) { + const query = buildQuery(params); + return get(`/api/v1/knowledge-bases/${kbId}/tags${query}`); } export function createKnowledgeBaseTag( @@ -138,7 +142,10 @@ const buildQuery = (params?: Record) => { return queryString ? `?${queryString}` : ''; }; -export function listFAQEntries(kbId: string, params?: { page?: number; page_size?: number; tag_id?: string }) { +export function listFAQEntries( + kbId: string, + params?: { page?: number; page_size?: number; tag_id?: string; keyword?: string }, +) { const query = buildQuery(params); return get(`/api/v1/knowledge-bases/${kbId}/faq/entries${query}`); } diff --git a/frontend/src/components/TenantSelector.vue b/frontend/src/components/TenantSelector.vue index 796f04bd..c4852e00 100644 --- a/frontend/src/components/TenantSelector.vue +++ b/frontend/src/components/TenantSelector.vue @@ -1,66 +1,78 @@ @@ -77,10 +89,9 @@ const authStore = useAuthStore() const showDropdown = ref(false) const searchQuery = ref('') const tenants = ref([]) -const triggerRef = ref(null) -const tenantList = ref(null) +const selectorRef = ref(null) +const tenantListRef = ref(null) const searchInput = ref(null) -const dropdownStyle = ref>({}) // 分页相关 const currentPage = ref(1) @@ -89,7 +100,6 @@ const total = ref(0) const loading = ref(false) const searchTimer = ref(null) -const canAccessAllTenants = computed(() => authStore.canAccessAllTenants) const selectedTenantId = computed(() => authStore.selectedTenantId) const defaultTenantId = computed(() => authStore.tenant?.id ? Number(authStore.tenant.id) : null) @@ -112,48 +122,15 @@ const isSelected = (tenantId: number) => { return currentTenantId.value === tenantId } -const updateDropdownPosition = () => { - if (!triggerRef.value) return - - const rect = triggerRef.value.getBoundingClientRect() - const dropdownMaxHeight = 400 // max-height - const spaceAbove = rect.top - const padding = 16 - - // 计算可用高度,确保有足够空间显示搜索框 - const availableHeight = Math.min(dropdownMaxHeight, spaceAbove - padding) - - // 确保最小高度(至少能显示搜索框和一些列表项) - const minHeight = 200 - const finalHeight = Math.max(minHeight, availableHeight) - - // 向上弹出,确保不遮挡用户头像 - dropdownStyle.value = { - bottom: `${window.innerHeight - rect.top + 8}px`, - left: `${rect.left}px`, - width: '280px', - height: `${finalHeight}px`, - maxHeight: `${finalHeight}px` - } -} - const toggleDropdown = () => { - if (!canAccessAllTenants.value) return showDropdown.value = !showDropdown.value if (showDropdown.value) { if (tenants.value.length === 0) { loadTenants() } nextTick(() => { - updateDropdownPosition() searchInput.value?.focus() }) - } else { - // 关闭时重置搜索 - searchQuery.value = '' - currentPage.value = 1 - tenants.value = [] - total.value = 0 } } @@ -161,30 +138,27 @@ const closeDropdown = () => { showDropdown.value = false searchQuery.value = '' currentPage.value = 1 - tenants.value = [] - total.value = 0 if (searchTimer.value) { clearTimeout(searchTimer.value) searchTimer.value = null } } -const close = (event: MouseEvent) => { - const target = event.target as HTMLElement - if (!target.closest('.tenant-dropdown') && !target.closest('.tenant-menu-item')) { - closeDropdown() - } +const clearSearch = () => { + searchQuery.value = '' + currentPage.value = 1 + tenants.value = [] + total.value = 0 + loadTenants() } const selectTenant = (tenantId: number) => { - // 如果选择的是默认租户,清除选择 if (tenantId === defaultTenantId.value) { authStore.setSelectedTenant(null) } else { authStore.setSelectedTenant(tenantId) } closeDropdown() - // 触发页面刷新以加载新租户的数据 MessagePlugin.success(t('tenant.switchSuccess')) setTimeout(() => { window.location.reload() @@ -196,14 +170,12 @@ const loadTenants = async (append = false) => { loading.value = true try { - // 解析搜索关键词,判断是否是租户ID let keyword = searchQuery.value.trim() let tenantID: number | undefined = undefined - // 如果搜索关键词是纯数字,尝试作为租户ID查询 if (keyword && /^\d+$/.test(keyword)) { tenantID = Number(keyword) - keyword = '' // 清空关键词,使用租户ID查询 + keyword = '' } const response = await searchTenants({ @@ -233,7 +205,6 @@ const loadTenants = async (append = false) => { } const handleSearchInput = () => { - // 防抖处理,延迟500ms后搜索 if (searchTimer.value) { clearTimeout(searchTimer.value) } @@ -243,50 +214,27 @@ const handleSearchInput = () => { tenants.value = [] total.value = 0 loadTenants() - }, 500) + }, 300) } -const loadMore = () => { - if (hasMore.value && !loading.value) { +const handleScroll = () => { + if (!tenantListRef.value) return + + const { scrollTop, scrollHeight, clientHeight } = tenantListRef.value + const isNearBottom = scrollHeight - scrollTop - clientHeight < 50 + + if (isNearBottom && hasMore.value && !loading.value) { currentPage.value++ loadTenants(true) } } -const handleClickOutside = (event: MouseEvent) => { - const target = event.target as HTMLElement - if (!target.closest('.tenant-selector-wrapper')) { - closeDropdown() - } -} - -const handleResize = () => { - if (showDropdown.value) { - updateDropdownPosition() - } -} - -watch(showDropdown, (newVal) => { - if (newVal) { - document.addEventListener('click', handleClickOutside) - window.addEventListener('resize', handleResize) - window.addEventListener('scroll', handleResize, true) - updateDropdownPosition() - } else { - document.removeEventListener('click', handleClickOutside) - window.removeEventListener('resize', handleResize) - window.removeEventListener('scroll', handleResize, true) - } -}) - onMounted(() => { - // 不再自动加载,等用户打开下拉框时再加载 + // 预加载租户列表 + loadTenants() }) onUnmounted(() => { - document.removeEventListener('click', handleClickOutside) - window.removeEventListener('resize', handleResize) - window.removeEventListener('scroll', handleResize, true) if (searchTimer.value) { clearTimeout(searchTimer.value) } @@ -295,84 +243,59 @@ onUnmounted(() => { - diff --git a/frontend/src/components/menu.vue b/frontend/src/components/menu.vue index a1cdb52d..92be3730 100644 --- a/frontend/src/components/menu.vue +++ b/frontend/src/components/menu.vue @@ -4,6 +4,9 @@ + + +