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:
wizardchen
2025-12-02 20:39:59 +08:00
parent cdf576eb18
commit 845d990a48
29 changed files with 1471 additions and 394 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
View 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)
}

View File

@@ -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
| 方法 | 路径 | 描述 |

View File

@@ -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}`);
}

View File

@@ -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>

View File

@@ -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' ||

View File

@@ -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',

View File

@@ -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: 'Запрос',

View File

@@ -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: "不可用",

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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{

View File

@@ -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,

View File

@@ -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,

View File

@@ -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").

View File

@@ -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

View File

@@ -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 {

View File

@@ -145,6 +145,7 @@ func (s *chunkService) ListPagedChunksByKnowledgeID(ctx context.Context,
page,
chunkType,
"",
"",
)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{

View File

@@ -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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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(