mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-06-04 13:30:32 +08:00
feat: Add tag management and FAQ management APIs, enhance knowledge base functionality with keyword search and custom file naming support
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
157
client/tag.go
Normal file
157
client/tag.go
Normal file
@@ -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)
|
||||
}
|
||||
482
docs/API.md
482
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
|
||||
}
|
||||
```
|
||||
|
||||
<div align="right"><a href="#weknora-api-文档">返回顶部 ↑</a></div>
|
||||
|
||||
### 知识管理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
|
||||
|
||||
<div align="right"><a href="#weknora-api-文档">返回顶部 ↑</a></div>
|
||||
|
||||
### 标签管理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
|
||||
}
|
||||
```
|
||||
|
||||
<div align="right"><a href="#weknora-api-文档">返回顶部 ↑</a></div>
|
||||
|
||||
### 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
|
||||
}
|
||||
```
|
||||
|
||||
<div align="right"><a href="#weknora-api-文档">返回顶部 ↑</a></div>
|
||||
|
||||
### 会话管理API
|
||||
|
||||
| 方法 | 路径 | 描述 |
|
||||
|
||||
@@ -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<string, any>) => {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -1,66 +1,78 @@
|
||||
<template>
|
||||
<div class="tenant-selector">
|
||||
<div v-if="canAccessAllTenants" class="tenant-selector-wrapper">
|
||||
<div class="tenant-menu-item" @click="toggleDropdown" ref="triggerRef">
|
||||
<div class="tenant-item-box">
|
||||
<div class="tenant-icon">
|
||||
<t-icon name="usergroup" size="16px" />
|
||||
</div>
|
||||
<span class="tenant-title">{{ currentTenantName }}</span>
|
||||
<div class="tenant-selector" ref="selectorRef">
|
||||
<div class="tenant-trigger" @click="toggleDropdown">
|
||||
<div class="tenant-info">
|
||||
<div class="tenant-label">{{ $t('tenant.currentTenant') }}</div>
|
||||
<div class="tenant-name-row">
|
||||
<span class="tenant-name">{{ currentTenantName }}</span>
|
||||
<t-icon name="swap" class="tenant-switch-icon" />
|
||||
</div>
|
||||
<t-icon name="chevron-up" class="tenant-arrow" :class="{ rotated: showDropdown }" />
|
||||
</div>
|
||||
<div v-if="showDropdown" class="tenant-overlay" @click="close">
|
||||
<div class="tenant-dropdown" @click.stop :style="dropdownStyle">
|
||||
<div class="tenant-list" ref="tenantList">
|
||||
</div>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<div v-if="showDropdown" class="tenant-dropdown" @click.stop>
|
||||
<div class="dropdown-header">
|
||||
<span class="dropdown-title">{{ $t('tenant.switchTenant') }}</span>
|
||||
<div class="search-box">
|
||||
<t-icon name="search" class="search-icon" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="$t('tenant.searchPlaceholder')"
|
||||
class="search-input"
|
||||
@keydown.esc="closeDropdown"
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
<t-icon
|
||||
v-if="searchQuery"
|
||||
name="close-circle-filled"
|
||||
class="clear-icon"
|
||||
@click="clearSearch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tenant-list" ref="tenantListRef" @scroll="handleScroll">
|
||||
<div v-if="loading && tenants.length === 0" class="tenant-loading">
|
||||
<t-loading size="small" />
|
||||
<span>{{ $t('tenant.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="tenants.length > 0">
|
||||
<div
|
||||
v-for="tenant in tenants"
|
||||
:key="tenant.id"
|
||||
:class="['tenant-item', { selected: isSelected(tenant.id) }]"
|
||||
@click="selectTenant(tenant.id)"
|
||||
>
|
||||
<div class="tenant-item-info">
|
||||
<span class="tenant-item-name">{{ tenant.name }}</span>
|
||||
<span v-if="tenant.description" class="tenant-item-desc">{{ tenant.description }}</span>
|
||||
<span class="tenant-item-id">ID: {{ tenant.id }}</span>
|
||||
<div class="tenant-item-content">
|
||||
<div class="tenant-item-avatar" :class="{ active: isSelected(tenant.id) }">
|
||||
{{ tenant.name.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="tenant-item-info">
|
||||
<span class="tenant-item-name">{{ tenant.name }}</span>
|
||||
<span class="tenant-item-id">ID: {{ tenant.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<t-icon v-if="isSelected(tenant.id)" name="check" size="16px" class="tenant-check-icon" />
|
||||
</div>
|
||||
<div v-if="tenants.length === 0 && !loading" class="tenant-empty">
|
||||
{{ $t('tenant.noMatch') }}
|
||||
</div>
|
||||
<div v-if="loading" class="tenant-loading">
|
||||
{{ $t('tenant.loading') }}
|
||||
</div>
|
||||
<div v-if="hasMore && !loading" class="tenant-load-more" @click="loadMore">
|
||||
{{ $t('tenant.loadMore') }}
|
||||
<t-icon v-if="isSelected(tenant.id)" name="check" size="16px" class="check-icon" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="tenant-empty">
|
||||
<span>{{ $t('tenant.noMatch') }}</span>
|
||||
</div>
|
||||
<div class="tenant-search">
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="$t('tenant.searchPlaceholder')"
|
||||
class="tenant-search-input"
|
||||
@keydown.esc="closeDropdown"
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
<div class="tenant-search-hint">
|
||||
{{ $t('tenant.searchHint') }}
|
||||
</div>
|
||||
|
||||
<div v-if="loading && tenants.length > 0" class="tenant-loading-more">
|
||||
<t-loading size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="tenant-menu-item readonly">
|
||||
<div class="tenant-item-box">
|
||||
<div class="tenant-icon">
|
||||
<t-icon name="usergroup" size="16px" />
|
||||
</div>
|
||||
<span class="tenant-title">{{ currentTenantName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 遮罩层 -->
|
||||
<div v-if="showDropdown" class="tenant-overlay" @click="closeDropdown"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -77,10 +89,9 @@ const authStore = useAuthStore()
|
||||
const showDropdown = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const tenants = ref<TenantInfo[]>([])
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const tenantList = ref<HTMLElement | null>(null)
|
||||
const selectorRef = ref<HTMLElement | null>(null)
|
||||
const tenantListRef = ref<HTMLElement | null>(null)
|
||||
const searchInput = ref<HTMLInputElement | null>(null)
|
||||
const dropdownStyle = ref<Record<string, string>>({})
|
||||
|
||||
// 分页相关
|
||||
const currentPage = ref(1)
|
||||
@@ -89,7 +100,6 @@ const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const searchTimer = ref<number | null>(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(() => {
|
||||
|
||||
<style scoped lang="less">
|
||||
.tenant-selector {
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tenant-selector-wrapper {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
margin: 0 8px 12px;
|
||||
}
|
||||
|
||||
.tenant-menu-item {
|
||||
cursor: pointer;
|
||||
.tenant-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
padding: 13px 8px 13px 16px;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: #f8faf9;
|
||||
border: 1px solid #e8ebe9;
|
||||
|
||||
&:hover {
|
||||
border-radius: 4px;
|
||||
background: #30323605;
|
||||
color: #00000099;
|
||||
|
||||
.tenant-icon,
|
||||
.tenant-title {
|
||||
color: #00000099;
|
||||
}
|
||||
}
|
||||
|
||||
&.readonly {
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
background: #f0f5f2;
|
||||
border-color: #d0d8d3;
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-item-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.tenant-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tenant-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
color: #00000099;
|
||||
flex-shrink: 0;
|
||||
.tenant-label {
|
||||
font-size: 11px;
|
||||
color: #8b9196;
|
||||
margin-bottom: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tenant-title {
|
||||
.tenant-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tenant-name {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #000000e6;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tenant-arrow {
|
||||
font-size: 16px;
|
||||
color: #00000066;
|
||||
.tenant-switch-icon {
|
||||
font-size: 14px;
|
||||
color: #07c05f;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-overlay {
|
||||
@@ -385,72 +308,123 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.tenant-dropdown {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #e7e9eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transform-origin: bottom left;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tenant-search {
|
||||
padding: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
.dropdown-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.tenant-search-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e7e9eb;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
.dropdown-title {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:focus-within {
|
||||
background: #fff;
|
||||
border-color: #07c05f;
|
||||
box-shadow: 0 0 0 2px rgba(7, 192, 95, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
min-width: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-list {
|
||||
flex: 1;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 0;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #e0e0e0;
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
background: #ccc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
transition: all 0.15s;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: #07c05f1a;
|
||||
background: rgba(7, 192, 95, 0.08);
|
||||
|
||||
.tenant-item-name {
|
||||
color: #07c05f;
|
||||
@@ -459,26 +433,45 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-item-info {
|
||||
flex: 1;
|
||||
.tenant-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tenant-item-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
.tenant-item-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(135deg, #07C05F 0%, #05A34E 100%);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-item-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
.tenant-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.tenant-item-name {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -487,48 +480,46 @@ onUnmounted(() => {
|
||||
.tenant-item-id {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tenant-loading {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tenant-load-more {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: #07c05f;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-search-hint {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.tenant-check-icon {
|
||||
.check-icon {
|
||||
color: #07c05f;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.tenant-loading,
|
||||
.tenant-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 12px;
|
||||
gap: 8px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tenant-loading-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
// 下拉动画
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
.dropdown-enter-to,
|
||||
.dropdown-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<img class="logo" src="@/assets/img/weknora.png" alt="">
|
||||
</div>
|
||||
|
||||
<!-- 租户选择器:仅在用户可切换租户时显示 -->
|
||||
<TenantSelector v-if="canAccessAllTenants" />
|
||||
|
||||
<!-- 上半部分:知识库和对话 -->
|
||||
<div class="menu_top">
|
||||
<div v-if="showKbActions" class="kb-action-wrapper">
|
||||
@@ -161,7 +164,6 @@
|
||||
|
||||
<!-- 下半部分:用户菜单 -->
|
||||
<div class="menu_bottom">
|
||||
<TenantSelector />
|
||||
<UserMenu />
|
||||
</div>
|
||||
|
||||
@@ -218,6 +220,9 @@ type MenuItem = { title: string; icon: string; path: string; childrenPath?: stri
|
||||
const { menuArr } = storeToRefs(usemenuStore);
|
||||
let activeSubmenu = ref<string>('');
|
||||
|
||||
// 是否可以访问所有租户
|
||||
const canAccessAllTenants = computed(() => authStore.canAccessAllTenants);
|
||||
|
||||
// 是否处于知识库详情页(不包括全局聊天)
|
||||
const isInKnowledgeBase = computed<boolean>(() => {
|
||||
return route.name === 'knowledgeBaseDetail' ||
|
||||
|
||||
@@ -905,6 +905,7 @@ export default {
|
||||
noNegative: 'No negative examples',
|
||||
emptyTitle: 'No FAQ entries',
|
||||
emptyDesc: 'Click "Create FAQ Entry" above to get started',
|
||||
searchPlaceholder: 'Search standard questions...',
|
||||
searchTest: 'Search Test',
|
||||
searchTestTitle: 'FAQ Search Test',
|
||||
queryLabel: 'Query',
|
||||
@@ -1150,6 +1151,7 @@ export default {
|
||||
tenant: {
|
||||
title: 'Tenant Information',
|
||||
currentTenant: 'Current Tenant',
|
||||
switchTenant: 'Switch Tenant',
|
||||
sectionDescription: 'View detailed configuration for the tenant',
|
||||
apiDocument: 'API Document',
|
||||
name: 'Tenant Name',
|
||||
|
||||
@@ -641,6 +641,7 @@ export default {
|
||||
tenant: {
|
||||
title: 'Информация об арендаторе',
|
||||
currentTenant: 'Текущий арендатор',
|
||||
switchTenant: 'Сменить арендатора',
|
||||
sectionDescription: 'Просмотр детальной конфигурации арендатора',
|
||||
apiDocument: 'Документация API',
|
||||
name: 'Имя арендатора',
|
||||
@@ -1001,6 +1002,7 @@ export default {
|
||||
noNegative: 'Нет негативных примеров',
|
||||
emptyTitle: 'Нет записей FAQ',
|
||||
emptyDesc: 'Нажмите "Создать FAQ запись" выше, чтобы начать',
|
||||
searchPlaceholder: 'Поиск стандартных вопросов...',
|
||||
searchTest: 'Тест поиска',
|
||||
searchTestTitle: 'Тест поиска FAQ',
|
||||
queryLabel: 'Запрос',
|
||||
|
||||
@@ -37,7 +37,7 @@ export default {
|
||||
tagCreateSuccess: "标签创建成功",
|
||||
tagEditSuccess: "标签更新成功",
|
||||
tagDeleteTitle: "删除标签",
|
||||
tagDeleteDesc: "确定删除标签“{name}”?已关联的 FAQ 将被标记为未分类",
|
||||
tagDeleteDesc: '确定删除标签"{name}"?已关联的 FAQ 将被标记为未分类',
|
||||
tagDeleteSuccess: "标签已删除",
|
||||
tagEditAction: "重命名",
|
||||
tagDeleteAction: "删除",
|
||||
@@ -733,6 +733,7 @@ export default {
|
||||
tenant: {
|
||||
title: "租户信息",
|
||||
currentTenant: "当前租户",
|
||||
switchTenant: "切换租户",
|
||||
sectionDescription: "查看租户的详细配置信息",
|
||||
apiDocument: "API文档",
|
||||
name: "租户名称",
|
||||
@@ -939,7 +940,7 @@ export default {
|
||||
dimensionDetected: "检测成功,向量维度:{value}",
|
||||
dimensionFailed: "检测失败,请手动输入维度",
|
||||
remoteDimensionDetected: "检测到向量维度:{value}",
|
||||
dimensionHint: "模型已选择,点击“检测维度”按钮自动获取向量维度",
|
||||
dimensionHint: '模型已选择,点击"检测维度"按钮自动获取向量维度',
|
||||
loadModelListFailed: "加载模型列表失败",
|
||||
listRefreshed: "列表已刷新",
|
||||
fillModelAndUrl: "请先填写模型标识和 Base URL",
|
||||
@@ -1123,11 +1124,11 @@ export default {
|
||||
"部分知识库尚未初始化,需要先在设置中配置模型信息才能添加知识文档",
|
||||
empty: {
|
||||
title: "暂无知识库",
|
||||
description: "点击左侧快捷操作“新建知识库”按钮创建第一个知识库",
|
||||
description: '点击左侧快捷操作"新建知识库"按钮创建第一个知识库',
|
||||
},
|
||||
delete: {
|
||||
confirmTitle: "删除确认",
|
||||
confirmMessage: "确认要删除知识库“{name}”?删除后不可恢复",
|
||||
confirmMessage: '确认要删除知识库"{name}"?删除后不可恢复',
|
||||
confirmButton: "确认删除",
|
||||
},
|
||||
messages: {
|
||||
@@ -1239,7 +1240,8 @@ export default {
|
||||
noSimilar: "暂无相似问",
|
||||
noNegative: "暂无反例",
|
||||
emptyTitle: "暂无 FAQ 条目",
|
||||
emptyDesc: "点击上方“新增 FAQ 条目”按钮开始创建",
|
||||
emptyDesc: '点击上方"新增 FAQ 条目"按钮开始创建',
|
||||
searchPlaceholder: "搜索问题和答案...",
|
||||
searchTest: "检索测试",
|
||||
searchTestTitle: "FAQ 检索测试",
|
||||
queryLabel: "查询内容",
|
||||
@@ -1441,7 +1443,7 @@ export default {
|
||||
label: "Agent 状态",
|
||||
ready: "可用",
|
||||
notReady: "未就绪",
|
||||
hint: "配置完成后,Agent 状态将自动变为“可用”,此时可在对话界面开启 Agent 模式",
|
||||
hint: '配置完成后,Agent 状态将自动变为"可用",此时可在对话界面开启 Agent 模式',
|
||||
missingThinkingModel: "思考模型",
|
||||
missingSummaryModel: "对话模型(Summary Model)",
|
||||
missingRerankModel: "Rerank 模型",
|
||||
@@ -1690,7 +1692,7 @@ export default {
|
||||
deleted: "MCP 服务已删除",
|
||||
deleteFailed: "删除 MCP 服务失败",
|
||||
},
|
||||
deleteConfirmBody: "确定要删除 MCP 服务“{name}”吗?此操作无法撤销。",
|
||||
deleteConfirmBody: '确定要删除 MCP 服务"{name}"吗?此操作无法撤销。',
|
||||
unnamed: "未命名",
|
||||
},
|
||||
|
||||
@@ -1748,7 +1750,7 @@ export default {
|
||||
description: "管理本地 Ollama 服务,查看和下载模型",
|
||||
status: {
|
||||
label: "Ollama 服务状态",
|
||||
desc: "自动检测本地 Ollama 服务是否可用。如果服务未运行或地址配置错误,将显示“不可用”状态",
|
||||
desc: '自动检测本地 Ollama 服务是否可用。如果服务未运行或地址配置错误,将显示"不可用"状态',
|
||||
testing: "检测中",
|
||||
available: "可用",
|
||||
unavailable: "不可用",
|
||||
|
||||
@@ -51,6 +51,12 @@ const tagList = ref<any[]>([]);
|
||||
const tagLoading = ref(false);
|
||||
const overallKnowledgeTotal = ref(0);
|
||||
const tagSearchQuery = ref('');
|
||||
const TAG_PAGE_SIZE = 50;
|
||||
const tagPage = ref(1);
|
||||
const tagHasMore = ref(false);
|
||||
const tagLoadingMore = ref(false);
|
||||
const tagTotal = ref(0);
|
||||
let tagSearchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
type TagInputInstance = ComponentPublicInstance<{ focus: () => void; select: () => void }>;
|
||||
const tagDropdownOptions = computed(() => {
|
||||
const options = [
|
||||
@@ -146,22 +152,57 @@ const loadKnowledgeFiles = (kbIdValue: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
const loadTags = async (kbIdValue: string) => {
|
||||
const loadTags = async (kbIdValue: string, reset = false) => {
|
||||
if (!kbIdValue) {
|
||||
tagList.value = [];
|
||||
tagTotal.value = 0;
|
||||
tagHasMore.value = false;
|
||||
tagPage.value = 1;
|
||||
return;
|
||||
}
|
||||
tagLoading.value = true;
|
||||
|
||||
if (reset) {
|
||||
tagPage.value = 1;
|
||||
tagList.value = [];
|
||||
tagTotal.value = 0;
|
||||
tagHasMore.value = false;
|
||||
}
|
||||
|
||||
const currentPage = tagPage.value || 1;
|
||||
tagLoading.value = currentPage === 1;
|
||||
tagLoadingMore.value = currentPage > 1;
|
||||
|
||||
try {
|
||||
const res: any = await listKnowledgeTags(kbIdValue);
|
||||
tagList.value = (res?.data || []).map((tag: any) => ({
|
||||
const res: any = await listKnowledgeTags(kbIdValue, {
|
||||
page: currentPage,
|
||||
page_size: TAG_PAGE_SIZE,
|
||||
keyword: tagSearchQuery.value || undefined,
|
||||
});
|
||||
const pageData = (res?.data || {}) as {
|
||||
data?: any[];
|
||||
total?: number;
|
||||
};
|
||||
const pageTags = (pageData.data || []).map((tag: any) => ({
|
||||
...tag,
|
||||
id: String(tag.id),
|
||||
}));
|
||||
|
||||
if (currentPage === 1) {
|
||||
tagList.value = pageTags;
|
||||
} else {
|
||||
tagList.value = [...tagList.value, ...pageTags];
|
||||
}
|
||||
|
||||
tagTotal.value = pageData.total || tagList.value.length;
|
||||
tagHasMore.value = tagList.value.length < tagTotal.value;
|
||||
if (tagHasMore.value) {
|
||||
tagPage.value = currentPage + 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load tags', error);
|
||||
} finally {
|
||||
tagLoading.value = false;
|
||||
tagLoadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -345,7 +386,7 @@ const loadKnowledgeBaseInfo = async (targetKbId: string) => {
|
||||
cardList.value = [];
|
||||
total.value = 0;
|
||||
}
|
||||
loadTags(targetKbId);
|
||||
loadTags(targetKbId, true);
|
||||
overallKnowledgeTotal.value = total.value;
|
||||
} catch (error) {
|
||||
console.error('Failed to load knowledge base info:', error);
|
||||
@@ -371,6 +412,8 @@ const loadKnowledgeList = async () => {
|
||||
// 监听路由参数变化,重新获取知识库内容
|
||||
watch(() => kbId.value, (newKbId, oldKbId) => {
|
||||
if (newKbId && newKbId !== oldKbId) {
|
||||
tagSearchQuery.value = '';
|
||||
tagPage.value = 1;
|
||||
loadKnowledgeBaseInfo(newKbId);
|
||||
}
|
||||
}, { immediate: false });
|
||||
@@ -388,6 +431,18 @@ watch(total, (val) => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(tagSearchQuery, (newVal, oldVal) => {
|
||||
if (newVal === oldVal) return;
|
||||
if (tagSearchDebounce) {
|
||||
clearTimeout(tagSearchDebounce);
|
||||
}
|
||||
tagSearchDebounce = window.setTimeout(() => {
|
||||
if (kbId.value) {
|
||||
loadTags(kbId.value, true);
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// 监听文件上传事件
|
||||
const handleFileUploaded = (event: CustomEvent) => {
|
||||
const uploadedKbId = event.detail.kbId;
|
||||
@@ -1033,6 +1088,16 @@ async function createNewSession(value: string): Promise<void> {
|
||||
<div v-else class="tag-empty-state">
|
||||
{{ $t('knowledgeBase.tagEmptyResult') }}
|
||||
</div>
|
||||
<div v-if="tagHasMore" class="tag-load-more">
|
||||
<t-button
|
||||
variant="text"
|
||||
size="small"
|
||||
:loading="tagLoadingMore"
|
||||
@click.stop="kbId && loadTags(kbId)"
|
||||
>
|
||||
{{ $t('tenant.loadMore') }}
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</t-loading>
|
||||
</aside>
|
||||
@@ -1321,6 +1386,8 @@ async function createNewSession(value: string): Promise<void> {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
max-height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
@@ -1387,7 +1454,21 @@ async function createNewSession(value: string): Promise<void> {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
.tag-load-more {
|
||||
padding: 8px 0 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
:deep(.t-button) {
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: #00a870;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-list-item {
|
||||
display: flex;
|
||||
|
||||
@@ -58,36 +58,40 @@
|
||||
<div v-if="importState.taskId && importState.taskStatus" class="faq-import-progress-bar">
|
||||
<div class="progress-bar-content">
|
||||
<div class="progress-bar-header">
|
||||
<t-icon
|
||||
:name="importState.taskStatus.status === 'running' ? 'loading' :
|
||||
importState.taskStatus.status === 'success' ? 'check-circle' :
|
||||
importState.taskStatus.status === 'failed' ? 'error-circle' : 'time'"
|
||||
size="16px"
|
||||
class="progress-icon"
|
||||
:class="{
|
||||
'icon-loading': importState.taskStatus.status === 'running',
|
||||
'icon-success': importState.taskStatus.status === 'success',
|
||||
'icon-error': importState.taskStatus.status === 'failed'
|
||||
}"
|
||||
/>
|
||||
<span class="progress-title">
|
||||
{{ importState.taskStatus.status === 'running' ? '导入中...' :
|
||||
importState.taskStatus.status === 'success' ? '导入完成' :
|
||||
importState.taskStatus.status === 'failed' ? '导入失败' : '等待中...' }}
|
||||
</span>
|
||||
<span class="progress-count">
|
||||
{{ importState.taskStatus.processed }}/{{ importState.taskStatus.total }}
|
||||
</span>
|
||||
<t-button
|
||||
v-if="importState.taskStatus.status === 'success' || importState.taskStatus.status === 'failed'"
|
||||
variant="text"
|
||||
theme="default"
|
||||
size="small"
|
||||
class="progress-close-btn"
|
||||
@click="handleCloseProgress"
|
||||
>
|
||||
<t-icon name="close" size="14px" />
|
||||
</t-button>
|
||||
<div class="progress-left">
|
||||
<t-icon
|
||||
:name="importState.taskStatus.status === 'running' ? 'loading' :
|
||||
importState.taskStatus.status === 'success' ? 'check-circle' :
|
||||
importState.taskStatus.status === 'failed' ? 'error-circle' : 'time'"
|
||||
size="18px"
|
||||
class="progress-icon"
|
||||
:class="{
|
||||
'icon-loading': importState.taskStatus.status === 'running',
|
||||
'icon-success': importState.taskStatus.status === 'success',
|
||||
'icon-error': importState.taskStatus.status === 'failed'
|
||||
}"
|
||||
/>
|
||||
<span class="progress-title">
|
||||
{{ importState.taskStatus.status === 'running' ? '导入中...' :
|
||||
importState.taskStatus.status === 'success' ? '导入完成' :
|
||||
importState.taskStatus.status === 'failed' ? '导入失败' : '等待中...' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress-right">
|
||||
<span class="progress-count">
|
||||
{{ importState.taskStatus.processed }}/{{ importState.taskStatus.total }} 条
|
||||
</span>
|
||||
<t-button
|
||||
v-if="importState.taskStatus.status === 'success' || importState.taskStatus.status === 'failed'"
|
||||
variant="text"
|
||||
theme="default"
|
||||
size="small"
|
||||
class="progress-close-btn"
|
||||
@click="handleCloseProgress"
|
||||
>
|
||||
<t-icon name="close" size="14px" />
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
<t-progress
|
||||
:percentage="importState.taskStatus.progress"
|
||||
@@ -103,7 +107,7 @@
|
||||
</div>
|
||||
|
||||
<div class="faq-main">
|
||||
<aside class="faq-tag-panel">
|
||||
<aside class="faq-tag-panel">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-title">
|
||||
<span>{{ $t('knowledgeBase.faqCategoryTitle') }}</span>
|
||||
@@ -135,7 +139,7 @@
|
||||
</t-input>
|
||||
</div>
|
||||
<t-loading :loading="tagLoading" size="small">
|
||||
<div class="faq-tag-list">
|
||||
<div ref="tagListRef" class="faq-tag-list" @scroll="handleTagListScroll">
|
||||
<div
|
||||
class="faq-tag-item"
|
||||
:class="{ active: selectedTagId === '' }"
|
||||
@@ -260,11 +264,28 @@
|
||||
<div v-else class="tag-empty-state">
|
||||
{{ $t('knowledgeBase.tagEmptyResult') }}
|
||||
</div>
|
||||
<div v-if="tagLoadingMore" class="tag-loading-more">
|
||||
<t-loading size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</t-loading>
|
||||
</aside>
|
||||
|
||||
<div class="faq-card-area">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="faq-search-bar">
|
||||
<t-input
|
||||
v-model.trim="entrySearchKeyword"
|
||||
:placeholder="$t('knowledgeEditor.faq.searchPlaceholder')"
|
||||
clearable
|
||||
@clear="loadEntries()"
|
||||
@keydown.enter="loadEntries()"
|
||||
>
|
||||
<template #prefix-icon>
|
||||
<t-icon name="search" size="16px" />
|
||||
</template>
|
||||
</t-input>
|
||||
</div>
|
||||
<!-- Card List Container with Scroll -->
|
||||
<div ref="scrollContainer" class="faq-scroll-container" @scroll="handleScroll">
|
||||
<t-loading :loading="loading && entries.length === 0" size="medium">
|
||||
@@ -1132,13 +1153,22 @@ const cardListRef = ref<HTMLElement | null>(null)
|
||||
const hasMore = ref(true)
|
||||
const pageSize = 20
|
||||
let currentPage = 1
|
||||
const entrySearchKeyword = ref('')
|
||||
let entrySearchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
type TagInputInstance = ComponentPublicInstance<{ focus: () => void; select: () => void }>
|
||||
|
||||
const tagList = ref<any[]>([])
|
||||
const tagLoading = ref(false)
|
||||
const tagListRef = ref<HTMLElement | null>(null)
|
||||
const selectedTagId = ref<string>('')
|
||||
const overallFAQTotal = ref(0)
|
||||
const tagSearchQuery = ref('')
|
||||
const TAG_PAGE_SIZE = 20
|
||||
const tagPage = ref(1)
|
||||
const tagHasMore = ref(false)
|
||||
const tagLoadingMore = ref(false)
|
||||
const tagTotal = ref(0)
|
||||
let tagSearchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
const editingTagInputRefs = new Map<string, TagInputInstance | null>()
|
||||
const setEditingTagInputRef = (el: TagInputInstance | null, tagId: string) => {
|
||||
if (el) {
|
||||
@@ -1338,22 +1368,70 @@ const handleToolbarAction = (data: { value: string }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadTags = async () => {
|
||||
// 标签列表滚动加载更多
|
||||
const handleTagListScroll = () => {
|
||||
const container = tagListRef.value
|
||||
if (!container) return
|
||||
if (tagLoadingMore.value || !tagHasMore.value) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
// 距离底部 50px 时触发加载
|
||||
if (scrollTop + clientHeight >= scrollHeight - 50) {
|
||||
loadTags()
|
||||
}
|
||||
}
|
||||
|
||||
const loadTags = async (reset = false) => {
|
||||
if (!props.kbId) {
|
||||
tagList.value = []
|
||||
tagTotal.value = 0
|
||||
tagHasMore.value = false
|
||||
tagPage.value = 1
|
||||
return
|
||||
}
|
||||
tagLoading.value = true
|
||||
|
||||
if (reset) {
|
||||
tagPage.value = 1
|
||||
tagList.value = []
|
||||
tagTotal.value = 0
|
||||
tagHasMore.value = false
|
||||
}
|
||||
|
||||
const currentPage = tagPage.value || 1
|
||||
tagLoading.value = currentPage === 1
|
||||
tagLoadingMore.value = currentPage > 1
|
||||
|
||||
try {
|
||||
const res: any = await listKnowledgeTags(props.kbId)
|
||||
tagList.value = (res?.data || []).map((tag: any) => ({
|
||||
const res: any = await listKnowledgeTags(props.kbId, {
|
||||
page: currentPage,
|
||||
page_size: TAG_PAGE_SIZE,
|
||||
keyword: tagSearchQuery.value || undefined,
|
||||
})
|
||||
const pageData = (res?.data || {}) as {
|
||||
data?: any[]
|
||||
total?: number
|
||||
}
|
||||
const pageTags = (pageData.data || []).map((tag: any) => ({
|
||||
...tag,
|
||||
id: String(tag.id),
|
||||
}))
|
||||
|
||||
if (currentPage === 1) {
|
||||
tagList.value = pageTags
|
||||
} else {
|
||||
tagList.value = [...tagList.value, ...pageTags]
|
||||
}
|
||||
|
||||
tagTotal.value = pageData.total || tagList.value.length
|
||||
tagHasMore.value = tagList.value.length < tagTotal.value
|
||||
if (tagHasMore.value) {
|
||||
tagPage.value = currentPage + 1
|
||||
}
|
||||
} catch (error: any) {
|
||||
MessagePlugin.error(error?.message || t('common.operationFailed'))
|
||||
} finally {
|
||||
tagLoading.value = false
|
||||
tagLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1645,6 +1723,7 @@ const loadEntries = async (append = false) => {
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
tag_id: selectedTagId.value || undefined,
|
||||
keyword: entrySearchKeyword.value ? entrySearchKeyword.value.trim() : undefined,
|
||||
})
|
||||
const pageData = (res.data || {}) as {
|
||||
data: FAQEntry[]
|
||||
@@ -1653,7 +1732,7 @@ const loadEntries = async (append = false) => {
|
||||
const newEntries = (pageData.data || []).map(entry => ({
|
||||
...entry,
|
||||
showMore: false,
|
||||
similarCollapsed: false, // 相似问默认展开
|
||||
similarCollapsed: true, // 相似问默认折叠
|
||||
negativeCollapsed: true, // 反例默认折叠
|
||||
answersCollapsed: true, // 答案默认折叠
|
||||
tag_id: entry.tag_id ? String(entry.tag_id) : '',
|
||||
@@ -1680,6 +1759,12 @@ const loadEntries = async (append = false) => {
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
|
||||
// 检查是否需要继续加载以填满可视区域
|
||||
// 延迟执行以确保 arrangeCards 的 requestAnimationFrame 完成
|
||||
setTimeout(() => {
|
||||
checkAndLoadMore()
|
||||
}, 350)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1697,6 +1782,22 @@ const handleScroll = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查内容是否填满可视区域,如果没有且还有更多数据,继续加载
|
||||
const checkAndLoadMore = () => {
|
||||
if (!scrollContainer.value) return
|
||||
if (loadingMore.value || loading.value) return
|
||||
if (!hasMore.value) return
|
||||
|
||||
const container = scrollContainer.value
|
||||
const scrollHeight = container.scrollHeight
|
||||
const clientHeight = container.clientHeight
|
||||
|
||||
// 如果内容高度小于容器高度 + 50px 的缓冲,说明可能没有滚动条或接近底部,需要继续加载
|
||||
if (scrollHeight <= clientHeight + 50) {
|
||||
loadEntries(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCardSelect = (entryId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!selectedRowKeys.value.includes(entryId)) {
|
||||
@@ -2097,6 +2198,9 @@ const startPolling = (taskId: string) => {
|
||||
// 保存taskId到localStorage,以便刷新后恢复
|
||||
saveTaskIdToStorage(taskId)
|
||||
|
||||
// 记录上次已处理数量,用于判断是否需要刷新列表
|
||||
let lastProcessed = 0
|
||||
|
||||
importState.pollingInterval = setInterval(async () => {
|
||||
try {
|
||||
const res: any = await getKnowledgeDetails(taskId)
|
||||
@@ -2126,6 +2230,12 @@ const startPolling = (taskId: string) => {
|
||||
error: error,
|
||||
}
|
||||
|
||||
// 进度更新时刷新FAQ列表(每增加一些条目就刷新一次)
|
||||
if (processed > lastProcessed) {
|
||||
lastProcessed = processed
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
// 任务完成或失败,停止轮询(但不自动关闭进度条,让用户手动关闭)
|
||||
if (status === 'success' || status === 'failed') {
|
||||
stopPolling()
|
||||
@@ -2477,7 +2587,7 @@ watch(
|
||||
}
|
||||
|
||||
loadEntries()
|
||||
loadTags()
|
||||
loadTags(true)
|
||||
// 恢复导入任务状态(如果存在)
|
||||
await restoreImportTask()
|
||||
},
|
||||
@@ -2494,6 +2604,27 @@ watch(selectedTagId, (newVal, oldVal) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(tagSearchQuery, (newVal, oldVal) => {
|
||||
if (newVal === oldVal) return
|
||||
if (tagSearchDebounce) {
|
||||
clearTimeout(tagSearchDebounce)
|
||||
}
|
||||
tagSearchDebounce = window.setTimeout(() => {
|
||||
loadTags(true)
|
||||
}, 300)
|
||||
})
|
||||
|
||||
// 监听FAQ搜索关键词变化
|
||||
watch(entrySearchKeyword, (newVal, oldVal) => {
|
||||
if (newVal === oldVal) return
|
||||
if (entrySearchDebounce) {
|
||||
clearTimeout(entrySearchDebounce)
|
||||
}
|
||||
entrySearchDebounce = window.setTimeout(() => {
|
||||
loadEntries()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchForm.query.trim()) {
|
||||
MessagePlugin.warning(t('knowledgeEditor.faq.queryPlaceholder'))
|
||||
@@ -2510,7 +2641,7 @@ const handleSearch = async () => {
|
||||
})
|
||||
const results = (res.data || []).map((entry: FAQEntry) => ({
|
||||
...entry,
|
||||
similarCollapsed: false, // 相似问默认展开
|
||||
similarCollapsed: true, // 相似问默认折叠
|
||||
negativeCollapsed: true, // 反例默认折叠
|
||||
answersCollapsed: true, // 答案默认折叠
|
||||
expanded: false,
|
||||
@@ -2656,6 +2787,10 @@ const handleResize = () => {
|
||||
}
|
||||
resizeTimer = setTimeout(() => {
|
||||
arrangeCards()
|
||||
// 窗口变大时可能需要加载更多,延迟执行确保布局完成
|
||||
setTimeout(() => {
|
||||
checkAndLoadMore()
|
||||
}, 350)
|
||||
resizeTimer = null
|
||||
}, 150)
|
||||
}
|
||||
@@ -2832,6 +2967,19 @@ watch(() => entries.value.map(e => ({
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
// t-loading 包裹容器需要撑满剩余空间
|
||||
> .t-loading__parent,
|
||||
> .t-loading {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
@@ -2913,7 +3061,17 @@ watch(() => entries.value.map(e => ({
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
.tag-loading-more {
|
||||
padding: 12px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.faq-tag-item {
|
||||
display: flex;
|
||||
@@ -3144,6 +3302,25 @@ watch(() => entries.value.map(e => ({
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.faq-search-bar {
|
||||
padding: 0px 0px 10px 0px;
|
||||
flex-shrink: 0;
|
||||
|
||||
:deep(.t-input) {
|
||||
font-size: 13px;
|
||||
background-color: #f7f9fc;
|
||||
border-color: #e5e9f2;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.t-is-focused {
|
||||
background-color: #fff;
|
||||
border-color: #00a870;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.tag-menu) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -3199,25 +3376,37 @@ watch(() => entries.value.map(e => ({
|
||||
// 导入进度条样式(显示在列表页面顶部)
|
||||
.faq-import-progress-bar {
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e7ebf0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
background: linear-gradient(135deg, #f8fffe 0%, #f5fff9 100%);
|
||||
border: 1px solid #d4f0e0;
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
box-shadow: 0 2px 12px rgba(0, 168, 112, 0.08);
|
||||
|
||||
.progress-bar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.progress-bar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
color: #000000e6;
|
||||
|
||||
.progress-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.progress-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.progress-icon {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -3236,19 +3425,29 @@ watch(() => entries.value.map(e => ({
|
||||
}
|
||||
|
||||
.progress-title {
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.progress-count {
|
||||
color: #86909c;
|
||||
color: #4e5969;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: rgba(0, 168, 112, 0.1);
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.progress-close-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 4px;
|
||||
margin-left: 8px;
|
||||
margin-left: 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3262,6 +3461,13 @@ watch(() => entries.value.map(e => ({
|
||||
|
||||
:deep(.t-progress__bar) {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 168, 112, 0.15);
|
||||
}
|
||||
|
||||
:deep(.t-progress__inner) {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3270,6 +3476,9 @@ watch(() => entries.value.map(e => ({
|
||||
font-size: 13px;
|
||||
color: #fa5151;
|
||||
line-height: 1.5;
|
||||
background: rgba(250, 81, 81, 0.08);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -294,7 +294,7 @@ onUnmounted(() => {
|
||||
.settings-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -139,7 +139,7 @@ func (t *GetDocumentInfoTool) Execute(ctx context.Context, args map[string]inter
|
||||
ListPagedChunksByKnowledgeID(ctx, t.tenantID, id, &types.Pagination{
|
||||
Page: 1,
|
||||
PageSize: 1000,
|
||||
}, []types.ChunkType{"text"}, "")
|
||||
}, []types.ChunkType{"text"}, "", "")
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
results[id] = &docInfo{
|
||||
|
||||
@@ -1091,7 +1091,8 @@ func (t *KnowledgeSearchTool) formatOutput(
|
||||
_, total, err := t.chunkService.GetRepository().ListPagedChunksByKnowledgeID(ctx,
|
||||
t.tenantID, result.KnowledgeID,
|
||||
&types.Pagination{Page: 1, PageSize: 1},
|
||||
[]types.ChunkType{types.ChunkTypeText}, "")
|
||||
[]types.ChunkType{types.ChunkTypeText}, "", "",
|
||||
)
|
||||
if err != nil {
|
||||
logger.Warnf(
|
||||
ctx,
|
||||
|
||||
@@ -116,7 +116,7 @@ func (t *ListKnowledgeChunksTool) Execute(ctx context.Context, args map[string]i
|
||||
}
|
||||
|
||||
chunks, total, err := t.chunkService.GetRepository().ListPagedChunksByKnowledgeID(ctx,
|
||||
t.tenantID, knowledgeID, pagination, []types.ChunkType{types.ChunkTypeText, types.ChunkTypeFAQ}, "")
|
||||
t.tenantID, knowledgeID, pagination, []types.ChunkType{types.ChunkTypeText, types.ChunkTypeFAQ}, "", "")
|
||||
if err != nil {
|
||||
return &types.ToolResult{
|
||||
Success: false,
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/common"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
@@ -76,30 +77,37 @@ func (r *chunkRepository) ListPagedChunksByKnowledgeID(
|
||||
page *types.Pagination,
|
||||
chunkType []types.ChunkType,
|
||||
tagID string,
|
||||
keyword string,
|
||||
) ([]*types.Chunk, int64, error) {
|
||||
var chunks []*types.Chunk
|
||||
var total int64
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&types.Chunk{}).
|
||||
Where("tenant_id = ? AND knowledge_id = ? AND chunk_type IN (?) AND status in (?)",
|
||||
baseFilter := func(db *gorm.DB) *gorm.DB {
|
||||
db = db.Where("tenant_id = ? AND knowledge_id = ? AND chunk_type IN (?) AND status in (?)",
|
||||
tenantID, knowledgeID, chunkType, []types.ChunkStatus{types.ChunkStatusIndexed, types.ChunkStatusDefault})
|
||||
if tagID != "" {
|
||||
query = query.Where("tag_id = ?", tagID)
|
||||
if tagID != "" {
|
||||
db = db.Where("tag_id = ?", tagID)
|
||||
}
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
db = db.Where("(content LIKE ? OR metadata::text LIKE ?)", like, like)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
query := baseFilter(r.db.WithContext(ctx).Model(&types.Chunk{}))
|
||||
|
||||
// First query the total count
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Then query the paginated data
|
||||
dataQuery := r.db.WithContext(ctx).
|
||||
Select("id, content, knowledge_id, knowledge_base_id, start_at, end_at, chunk_index, is_enabled, chunk_type, parent_chunk_id, image_info, metadata, tag_id")
|
||||
dataQuery = dataQuery.Where("tenant_id = ? AND knowledge_id = ? AND chunk_type IN (?) AND status in (?)",
|
||||
tenantID, knowledgeID, chunkType, []types.ChunkStatus{types.ChunkStatusIndexed, types.ChunkStatusDefault})
|
||||
if tagID != "" {
|
||||
dataQuery = dataQuery.Where("tag_id = ?", tagID)
|
||||
}
|
||||
dataQuery := baseFilter(
|
||||
r.db.WithContext(ctx).
|
||||
Select("id, content, knowledge_id, knowledge_base_id, start_at, end_at, chunk_index, is_enabled, chunk_type, parent_chunk_id, image_info, metadata, tag_id"),
|
||||
)
|
||||
|
||||
if err := dataQuery.
|
||||
Order("chunk_index ASC").
|
||||
|
||||
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
@@ -39,20 +40,46 @@ func (r *knowledgeTagRepository) GetByID(ctx context.Context, tenantID uint64, i
|
||||
return &tag, nil
|
||||
}
|
||||
|
||||
// ListByKB lists knowledge tags by knowledge base ID
|
||||
// ListByKB lists knowledge tags by knowledge base ID with pagination and optional keyword filtering.
|
||||
func (r *knowledgeTagRepository) ListByKB(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
kbID string,
|
||||
) ([]*types.KnowledgeTag, error) {
|
||||
var tags []*types.KnowledgeTag
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("tenant_id = ? AND knowledge_base_id = ?", tenantID, kbID).
|
||||
Order("sort_order ASC, created_at ASC").
|
||||
Find(&tags).Error; err != nil {
|
||||
return nil, err
|
||||
page *types.Pagination,
|
||||
keyword string,
|
||||
) ([]*types.KnowledgeTag, int64, error) {
|
||||
if page == nil {
|
||||
page = &types.Pagination{}
|
||||
}
|
||||
return tags, nil
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
|
||||
var total int64
|
||||
baseQuery := r.db.WithContext(ctx).Model(&types.KnowledgeTag{}).
|
||||
Where("tenant_id = ? AND knowledge_base_id = ?", tenantID, kbID)
|
||||
if keyword != "" {
|
||||
baseQuery = baseQuery.Where("name LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
if err := baseQuery.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
dataQuery := r.db.WithContext(ctx).
|
||||
Where("tenant_id = ? AND knowledge_base_id = ?", tenantID, kbID)
|
||||
if keyword != "" {
|
||||
dataQuery = dataQuery.Where("name LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
var tags []*types.KnowledgeTag
|
||||
if err := dataQuery.
|
||||
Order("sort_order ASC, created_at ASC").
|
||||
Offset(page.Offset()).
|
||||
Limit(page.Limit()).
|
||||
Find(&tags).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return tags, total, nil
|
||||
}
|
||||
|
||||
// Delete deletes a knowledge tag
|
||||
|
||||
@@ -300,7 +300,7 @@ func (s *agentService) getKnowledgeBaseInfos(ctx context.Context, kbIDs []string
|
||||
pageResult, err := s.knowledgeService.ListFAQEntries(ctx, kbID, &types.Pagination{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
}, "")
|
||||
}, "", "")
|
||||
if err == nil && pageResult != nil {
|
||||
docCount = int(pageResult.Total)
|
||||
if entries, ok := pageResult.Data.([]*types.FAQEntry); ok {
|
||||
|
||||
@@ -145,6 +145,7 @@ func (s *chunkService) ListPagedChunksByKnowledgeID(ctx context.Context,
|
||||
page,
|
||||
chunkType,
|
||||
"",
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
|
||||
@@ -1875,6 +1875,7 @@ func (s *knowledgeService) CloneChunk(ctx context.Context, src, dst *types.Knowl
|
||||
},
|
||||
chunkType,
|
||||
"",
|
||||
"",
|
||||
)
|
||||
chunkPage++
|
||||
if err != nil {
|
||||
@@ -1949,11 +1950,12 @@ func (s *knowledgeService) CloneChunk(ctx context.Context, src, dst *types.Knowl
|
||||
|
||||
// ListFAQEntries lists FAQ entries under a FAQ knowledge base.
|
||||
func (s *knowledgeService) ListFAQEntries(ctx context.Context,
|
||||
kbID string, page *types.Pagination, tagID string,
|
||||
kbID string, page *types.Pagination, tagID string, keyword string,
|
||||
) (*types.PageResult, error) {
|
||||
if page == nil {
|
||||
page = &types.Pagination{}
|
||||
}
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
kb, err := s.validateFAQKnowledgeBase(ctx, kbID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1968,7 +1970,7 @@ func (s *knowledgeService) ListFAQEntries(ctx context.Context,
|
||||
}
|
||||
chunkType := []types.ChunkType{types.ChunkTypeFAQ}
|
||||
chunks, total, err := s.chunkRepo.ListPagedChunksByKnowledgeID(
|
||||
ctx, tenantID, faqKnowledge.ID, page, chunkType, tagID,
|
||||
ctx, tenantID, faqKnowledge.ID, page, chunkType, tagID, keyword,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -30,10 +30,19 @@ func NewKnowledgeTagService(
|
||||
}
|
||||
|
||||
// ListTags lists all tags for a knowledge base with usage stats.
|
||||
func (s *knowledgeTagService) ListTags(ctx context.Context, kbID string) ([]*types.KnowledgeTagWithStats, error) {
|
||||
func (s *knowledgeTagService) ListTags(
|
||||
ctx context.Context,
|
||||
kbID string,
|
||||
page *types.Pagination,
|
||||
keyword string,
|
||||
) (*types.PageResult, error) {
|
||||
if kbID == "" {
|
||||
return nil, werrors.NewBadRequestError("知识库ID不能为空")
|
||||
}
|
||||
if page == nil {
|
||||
page = &types.Pagination{}
|
||||
}
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
// Ensure KB exists and belongs to current tenant
|
||||
kb, err := s.kbService.GetKnowledgeBaseByID(ctx, kbID)
|
||||
if err != nil {
|
||||
@@ -41,7 +50,7 @@ func (s *knowledgeTagService) ListTags(ctx context.Context, kbID string) ([]*typ
|
||||
}
|
||||
tenantID := kb.TenantID
|
||||
|
||||
tags, err := s.repo.ListByKB(ctx, tenantID, kbID)
|
||||
tags, total, err := s.repo.ListByKB(ctx, tenantID, kbID, page, keyword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -65,7 +74,7 @@ func (s *knowledgeTagService) ListTags(ctx context.Context, kbID string) ([]*typ
|
||||
ChunkCount: cCount,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
return types.NewPageResult(total, page, results), nil
|
||||
}
|
||||
|
||||
// CreateTag creates a new tag under a KB.
|
||||
|
||||
@@ -33,8 +33,9 @@ func (h *FAQHandler) ListEntries(c *gin.Context) {
|
||||
}
|
||||
|
||||
tagID := secutils.SanitizeForLog(c.Query("tag_id"))
|
||||
keyword := secutils.SanitizeForLog(c.Query("keyword"))
|
||||
|
||||
result, err := h.knowledgeService.ListFAQEntries(ctx, secutils.SanitizeForLog(c.Param("id")), &page, tagID)
|
||||
result, err := h.knowledgeService.ListFAQEntries(ctx, secutils.SanitizeForLog(c.Param("id")), &page, tagID, keyword)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, nil)
|
||||
c.Error(err)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/errors"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
@@ -26,7 +27,16 @@ func (h *TagHandler) ListTags(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
kbID := secutils.SanitizeForLog(c.Param("id"))
|
||||
|
||||
tags, err := h.tagService.ListTags(ctx, kbID)
|
||||
var page types.Pagination
|
||||
if err := c.ShouldBindQuery(&page); err != nil {
|
||||
logger.Error(ctx, "Failed to bind pagination query", err)
|
||||
c.Error(errors.NewBadRequestError("分页参数不合法").WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
keyword := secutils.SanitizeForLog(c.Query("keyword"))
|
||||
|
||||
tags, err := h.tagService.ListTags(ctx, kbID, &page, keyword)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, nil)
|
||||
c.Error(err)
|
||||
|
||||
@@ -25,6 +25,7 @@ type ChunkRepository interface {
|
||||
page *types.Pagination,
|
||||
chunkType []types.ChunkType,
|
||||
tagID string,
|
||||
keyword string,
|
||||
) ([]*types.Chunk, int64, error)
|
||||
ListChunkByParentID(ctx context.Context, tenantID uint64, parentID string) ([]*types.Chunk, error)
|
||||
// UpdateChunk updates a chunk
|
||||
|
||||
@@ -69,7 +69,13 @@ type KnowledgeService interface {
|
||||
UpdateImageInfo(ctx context.Context, knowledgeID string, chunkID string, imageInfo string) error
|
||||
// ListFAQEntries lists FAQ entries under a FAQ knowledge base.
|
||||
// When tagID is non-empty, results are filtered by tag_id on FAQ chunks.
|
||||
ListFAQEntries(ctx context.Context, kbID string, page *types.Pagination, tagID string) (*types.PageResult, error)
|
||||
ListFAQEntries(
|
||||
ctx context.Context,
|
||||
kbID string,
|
||||
page *types.Pagination,
|
||||
tagID string,
|
||||
keyword string,
|
||||
) (*types.PageResult, error)
|
||||
// UpsertFAQEntries imports or appends FAQ entries asynchronously.
|
||||
// Returns task ID (Knowledge ID) for tracking import progress.
|
||||
UpsertFAQEntries(ctx context.Context, kbID string, payload *types.FAQBatchUpsertPayload) (string, error)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
// KnowledgeTagService defines operations on knowledge base scoped tags.
|
||||
type KnowledgeTagService interface {
|
||||
// ListTags lists all tags under a knowledge base with associated statistics.
|
||||
ListTags(ctx context.Context, kbID string) ([]*types.KnowledgeTagWithStats, error)
|
||||
ListTags(ctx context.Context, kbID string, page *types.Pagination, keyword string) (*types.PageResult, error)
|
||||
// CreateTag creates a new tag under a knowledge base.
|
||||
CreateTag(ctx context.Context, kbID string, name string, color string, sortOrder int) (*types.KnowledgeTag, error)
|
||||
// UpdateTag updates tag basic information.
|
||||
@@ -23,7 +23,13 @@ type KnowledgeTagRepository interface {
|
||||
Create(ctx context.Context, tag *types.KnowledgeTag) error
|
||||
Update(ctx context.Context, tag *types.KnowledgeTag) error
|
||||
GetByID(ctx context.Context, tenantID uint64, id string) (*types.KnowledgeTag, error)
|
||||
ListByKB(ctx context.Context, tenantID uint64, kbID string) ([]*types.KnowledgeTag, error)
|
||||
ListByKB(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
kbID string,
|
||||
page *types.Pagination,
|
||||
keyword string,
|
||||
) ([]*types.KnowledgeTag, int64, error)
|
||||
Delete(ctx context.Context, tenantID uint64, id string) error
|
||||
// CountReferences returns number of knowledges and chunks that reference the tag.
|
||||
CountReferences(
|
||||
|
||||
Reference in New Issue
Block a user