feat: add pinning functionality for knowledge bases

- Introduced a new API endpoint to toggle the pin status of knowledge bases, allowing users to pin important entries to the top of the list.
- Updated the frontend to include UI elements for pinning and unpinning knowledge bases, with corresponding success and error messages.
- Enhanced localization support by adding translations for pinning actions in English, Korean, Russian, and Chinese.
- Modified the database schema to include `is_pinned` and `pinned_at` fields for knowledge bases, enabling persistent pinning status.

These changes improve the organization and accessibility of knowledge bases, enhancing user experience by allowing quick access to prioritized entries.
This commit is contained in:
wizardchen
2026-03-05 18:14:14 +08:00
committed by lyingbug
parent a1c0682aa3
commit 00bf0460cc
14 changed files with 193 additions and 5 deletions
+4
View File
@@ -45,6 +45,10 @@ export function copyKnowledgeBase(data: { source_id: string; target_id?: string
return post(`/api/v1/knowledge-bases/copy`, data);
}
export function togglePinKnowledgeBase(id: string) {
return put(`/api/v1/knowledge-bases/${id}/pin`);
}
// 知识文件 API(基于具体知识库)
// data.tag_id: 可选,指定知识所属的分类ID
export function uploadKnowledgeFile(kbId: string, data: { file: File; tag_id?: string; [key: string]: any } = { file: new File([], '') }, onProgress?: (progressEvent: any) => void) {
+7
View File
@@ -1173,6 +1173,13 @@ export default {
menu: {
viewDetails: 'View Details',
},
pin: {
pin: 'Pin to Top',
unpin: 'Unpin',
pinSuccess: 'Pinned',
unpinSuccess: 'Unpinned',
failed: 'Operation failed',
},
messages: {
deleted: 'Knowledge base deleted',
deleteFailed: 'Failed to delete knowledge base'
+7
View File
@@ -1603,6 +1603,13 @@ export default {
menu: {
viewDetails: "세부 사항을 확인하세요",
},
pin: {
pin: "상단 고정",
unpin: "고정 해제",
pinSuccess: "상단에 고정됨",
unpinSuccess: "고정 해제됨",
failed: "작업 실패",
},
detail: {
title: "공유 지식베이스",
overview: "개요",
+7
View File
@@ -1045,6 +1045,13 @@ export default {
confirmMessage: 'Удалить базу знаний «{name}»? Отменить действие будет невозможно.',
confirmButton: 'Удалить'
},
pin: {
pin: 'Закрепить',
unpin: 'Открепить',
pinSuccess: 'Закреплено',
unpinSuccess: 'Откреплено',
failed: 'Операция не удалась',
},
messages: {
deleted: 'База знаний удалена',
deleteFailed: 'Не удалось удалить базу знаний'
+7
View File
@@ -1581,6 +1581,13 @@ export default {
menu: {
viewDetails: "查看详情",
},
pin: {
pin: "置顶",
unpin: "取消置顶",
pinSuccess: "已置顶",
unpinSuccess: "已取消置顶",
failed: "操作失败",
},
detail: {
title: "共享知识库",
overview: "概览",
@@ -93,6 +93,10 @@
:ref="el => { if (highlightedKbId !== null && highlightedKbId === kb.id && el) highlightedCardRef = el as HTMLElement }"
@click="handleCardClick(kb)"
>
<!-- 置顶标识 -->
<div v-if="kb.is_pinned" class="pin-indicator">
<t-icon name="pin-filled" size="14px" />
</div>
<!-- 卡片头部 -->
<div class="card-header">
<span class="card-title" :title="kb.name">{{ kb.name }}</span>
@@ -107,6 +111,10 @@
</div>
<template #content>
<div class="popup-menu" @click.stop>
<div class="popup-menu-item" @click.stop="handleTogglePinById(kb.id)">
<t-icon class="menu-icon" :name="kb.is_pinned ? 'pin-filled' : 'pin'" />
<span>{{ kb.is_pinned ? $t('knowledgeList.pin.unpin') : $t('knowledgeList.pin.pin') }}</span>
</div>
<div class="popup-menu-item" @click.stop="handleSettingsById(kb.id)">
<t-icon class="menu-icon" name="setting" />
<span>{{ $t('knowledgeBase.settings') }}</span>
@@ -251,6 +259,10 @@
:ref="el => { if (highlightedKbId !== null && highlightedKbId === kb.id && el) highlightedCardRef = el as HTMLElement }"
@click="handleCardClick(kb)"
>
<!-- 置顶标识 -->
<div v-if="kb.is_pinned" class="pin-indicator">
<t-icon name="pin-filled" size="14px" />
</div>
<!-- 卡片头部 -->
<div class="card-header">
<span class="card-title" :title="kb.name">{{ kb.name }}</span>
@@ -272,6 +284,10 @@
</div>
<template #content>
<div class="popup-menu" @click.stop>
<div class="popup-menu-item" @click.stop="handleTogglePin(kb)">
<t-icon class="menu-icon" :name="kb.is_pinned ? 'pin-filled' : 'pin'" />
<span>{{ kb.is_pinned ? $t('knowledgeList.pin.unpin') : $t('knowledgeList.pin.pin') }}</span>
</div>
<div class="popup-menu-item" @click.stop="handleSettings(kb)">
<t-icon class="menu-icon" name="setting" />
<span>{{ $t('knowledgeBase.settings') }}</span>
@@ -586,7 +602,7 @@
import { onMounted, onUnmounted, ref, computed, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { MessagePlugin, Icon as TIcon } from 'tdesign-vue-next'
import { listKnowledgeBases, deleteKnowledgeBase } from '@/api/knowledge-base'
import { listKnowledgeBases, deleteKnowledgeBase, togglePinKnowledgeBase } from '@/api/knowledge-base'
import { formatStringDate } from '@/utils/index'
import { useUIStore } from '@/stores/ui'
import { useOrganizationStore } from '@/stores/organization'
@@ -620,9 +636,10 @@ interface KB {
question_generation_config?: { enabled?: boolean; question_count?: number };
knowledge_count?: number;
chunk_count?: number;
isProcessing?: boolean; //
processing_count?: number; //
share_count?: number; //
isProcessing?: boolean;
processing_count?: number;
share_count?: number;
is_pinned?: boolean;
}
const kbs = ref<KB[]>([])
@@ -850,6 +867,35 @@ const handleDeleteById = (id: string) => {
}
}
const handleTogglePin = async (kb: KB) => {
kb.showMore = false
try {
const res: any = await togglePinKnowledgeBase(kb.id)
if (res.success) {
MessagePlugin.success(
res.data.is_pinned ? t('knowledgeList.pin.pinSuccess') : t('knowledgeList.pin.unpinSuccess')
)
fetchList()
}
} catch {
MessagePlugin.error(t('knowledgeList.pin.failed'))
}
}
const handleTogglePinById = async (id: string) => {
try {
const res: any = await togglePinKnowledgeBase(id)
if (res.success) {
MessagePlugin.success(
res.data.is_pinned ? t('knowledgeList.pin.pinSuccess') : t('knowledgeList.pin.unpinSuccess')
)
fetchList()
}
} catch {
MessagePlugin.error(t('knowledgeList.pin.failed'))
}
}
const handleShare = (kb: KB) => {
//
kb.showMore = false
@@ -1616,6 +1662,15 @@ const handleUploadFinishedEvent = (event: Event) => {
}
}
.pin-indicator {
position: absolute;
top: 8px;
left: 8px;
color: var(--td-brand-color);
z-index: 2;
opacity: 0.7;
}
//
.card-header,
.card-content,
@@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
@@ -77,12 +78,40 @@ func (r *knowledgeBaseRepository) ListKnowledgeBasesByTenantID(
) ([]*types.KnowledgeBase, error) {
var kbs []*types.KnowledgeBase
if err := r.db.WithContext(ctx).Where("tenant_id = ? AND is_temporary = ?", tenantID, false).
Order("created_at DESC").Find(&kbs).Error; err != nil {
Order("is_pinned DESC, pinned_at DESC, created_at DESC").Find(&kbs).Error; err != nil {
return nil, err
}
return kbs, nil
}
// TogglePinKnowledgeBase toggles the pin status of a knowledge base
func (r *knowledgeBaseRepository) TogglePinKnowledgeBase(ctx context.Context, id string, tenantID uint64) (*types.KnowledgeBase, error) {
var kb types.KnowledgeBase
if err := r.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", id, tenantID).First(&kb).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrKnowledgeBaseNotFound
}
return nil, err
}
if kb.IsPinned {
kb.IsPinned = false
kb.PinnedAt = nil
} else {
kb.IsPinned = true
now := time.Now()
kb.PinnedAt = &now
}
if err := r.db.WithContext(ctx).Model(&kb).Updates(map[string]interface{}{
"is_pinned": kb.IsPinned,
"pinned_at": kb.PinnedAt,
}).Error; err != nil {
return nil, err
}
return &kb, nil
}
// UpdateKnowledgeBase updates a knowledge base
func (r *knowledgeBaseRepository) UpdateKnowledgeBase(ctx context.Context, kb *types.KnowledgeBase) error {
return r.db.WithContext(ctx).Save(kb).Error
@@ -312,6 +312,23 @@ func (s *knowledgeBaseService) UpdateKnowledgeBase(ctx context.Context,
return kb, nil
}
// TogglePinKnowledgeBase toggles the pin status of a knowledge base
func (s *knowledgeBaseService) TogglePinKnowledgeBase(ctx context.Context, id string) (*types.KnowledgeBase, error) {
if id == "" {
return nil, errors.New("knowledge base ID cannot be empty")
}
tenantID := types.MustTenantIDFromContext(ctx)
kb, err := s.repo.TogglePinKnowledgeBase(ctx, id, tenantID)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_base_id": id,
})
return nil, err
}
logger.Infof(ctx, "Knowledge base pin toggled, ID: %s, is_pinned: %v", id, kb.IsPinned)
return kb, nil
}
// DeleteKnowledgeBase deletes a knowledge base by its ID
// This method marks the knowledge base as deleted and enqueues an async task
// to handle the heavy cleanup operations (embeddings, chunks, files, graph data)
+37
View File
@@ -386,6 +386,43 @@ func (h *KnowledgeBaseHandler) ListKnowledgeBases(c *gin.Context) {
})
}
// TogglePinKnowledgeBase godoc
// @Summary 置顶/取消置顶知识库
// @Description 切换知识库的置顶状态
// @Tags 知识库
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Success 200 {object} map[string]interface{} "更新后的知识库"
// @Failure 404 {object} errors.AppError "知识库不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/pin [put]
func (h *KnowledgeBaseHandler) TogglePinKnowledgeBase(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
if id == "" {
c.Error(apperrors.NewBadRequestError("knowledge base ID is required"))
return
}
kb, err := h.service.TogglePinKnowledgeBase(ctx, id)
if err != nil {
if stderrors.Is(err, repository.ErrKnowledgeBaseNotFound) {
c.Error(apperrors.NewNotFoundError("knowledge base not found"))
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(apperrors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": kb,
})
}
// UpdateKnowledgeBaseRequest defines the request body structure for updating a knowledge base
type UpdateKnowledgeBaseRequest struct {
Name string `json:"name" binding:"required"`
+2
View File
@@ -246,6 +246,8 @@ func RegisterKnowledgeBaseRoutes(r *gin.RouterGroup, handler *handler.KnowledgeB
kb.PUT("/:id", handler.UpdateKnowledgeBase)
// 删除知识库
kb.DELETE("/:id", handler.DeleteKnowledgeBase)
// 置顶/取消置顶知识库
kb.PUT("/:id/pin", handler.TogglePinKnowledgeBase)
// 混合搜索
kb.GET("/:id/hybrid-search", handler.HybridSearch)
// 拷贝知识库
@@ -80,6 +80,9 @@ type KnowledgeBaseService interface {
// - Possible errors such as not existing, insufficient permissions, etc.
DeleteKnowledgeBase(ctx context.Context, id string) error
// TogglePinKnowledgeBase toggles the pin status of a knowledge base
TogglePinKnowledgeBase(ctx context.Context, id string) (*types.KnowledgeBase, error)
// HybridSearch performs hybrid search (vector + keywords) in the knowledge base
// Parameters:
// - ctx: Context information
@@ -190,4 +193,7 @@ type KnowledgeBaseRepository interface {
// Returns:
// - Possible errors such as record not existing, database errors, etc.
DeleteKnowledgeBase(ctx context.Context, id string) error
// TogglePinKnowledgeBase toggles the pin status of a knowledge base
TogglePinKnowledgeBase(ctx context.Context, id string, tenantID uint64) (*types.KnowledgeBase, error)
}
+4
View File
@@ -70,6 +70,10 @@ type KnowledgeBase struct {
FAQConfig *FAQConfig `yaml:"faq_config" json:"faq_config" gorm:"column:faq_config;type:json"`
// QuestionGenerationConfig stores question generation configuration for document knowledge bases
QuestionGenerationConfig *QuestionGenerationConfig `yaml:"question_generation_config" json:"question_generation_config" gorm:"column:question_generation_config;type:json"`
// Whether this knowledge base is pinned to the top of the list
IsPinned bool `yaml:"is_pinned" json:"is_pinned" gorm:"default:false"`
// Time when the knowledge base was pinned (nil if not pinned)
PinnedAt *time.Time `yaml:"pinned_at" json:"pinned_at"`
// Creation time of the knowledge base
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
// Last updated time of the knowledge base
@@ -0,0 +1,3 @@
-- Remove pin (置顶) support from knowledge bases
ALTER TABLE knowledge_bases DROP COLUMN IF EXISTS pinned_at;
ALTER TABLE knowledge_bases DROP COLUMN IF EXISTS is_pinned;
@@ -0,0 +1,3 @@
-- Add pin (置顶) support for knowledge bases
ALTER TABLE knowledge_bases ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE knowledge_bases ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMP WITH TIME ZONE NULL;