feat: add t-SNE visualization for FAISS index and enhance knowledge base retrieval with debug mode
This commit is contained in:
@@ -248,6 +248,7 @@ class KnowledgeBaseManager:
|
||||
"chunk_index": r.metadata.get("chunk_index", 0),
|
||||
"content": r.content,
|
||||
"score": r.score,
|
||||
"char_count": r.metadata.get("char_count", 0),
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@ from quart import request
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from .route import Route, Response, RouteContext
|
||||
from ..utils import generate_tsne_visualization
|
||||
|
||||
|
||||
class KnowledgeBaseRoute(Route):
|
||||
@@ -294,7 +295,6 @@ class KnowledgeBaseRoute(Route):
|
||||
- top_k_dense: 密集检索数量 (可选)
|
||||
- top_k_sparse: 稀疏检索数量 (可选)
|
||||
- top_m_final: 最终返回数量 (可选)
|
||||
- enable_rerank: 是否启用Rerank (可选)
|
||||
"""
|
||||
try:
|
||||
kb_manager = self._get_kb_manager()
|
||||
@@ -811,7 +811,7 @@ class KnowledgeBaseRoute(Route):
|
||||
- query: 查询文本 (必填)
|
||||
- kb_ids: 知识库 ID 列表 (必填)
|
||||
- top_k: 返回结果数量 (可选, 默认 5)
|
||||
- enable_rerank: 是否启用Rerank (可选, 默认使用知识库配置)
|
||||
- debug: 是否启用调试模式,返回 t-SNE 可视化图片 (可选, 默认 False)
|
||||
"""
|
||||
try:
|
||||
kb_manager = self._get_kb_manager()
|
||||
@@ -819,6 +819,7 @@ class KnowledgeBaseRoute(Route):
|
||||
|
||||
query = data.get("query")
|
||||
kb_names = data.get("kb_names")
|
||||
debug = data.get("debug", False)
|
||||
|
||||
if not query:
|
||||
return Response().error("缺少参数 query").__dict__
|
||||
@@ -836,11 +837,26 @@ class KnowledgeBaseRoute(Route):
|
||||
if results:
|
||||
result_list = results["results"]
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok({"results": result_list, "total": len(result_list), "query": query})
|
||||
.__dict__
|
||||
)
|
||||
response_data = {
|
||||
"results": result_list,
|
||||
"total": len(result_list),
|
||||
"query": query,
|
||||
}
|
||||
|
||||
# Debug 模式:生成 t-SNE 可视化
|
||||
if debug:
|
||||
try:
|
||||
img_base64 = await generate_tsne_visualization(
|
||||
query, kb_names, kb_manager
|
||||
)
|
||||
if img_base64:
|
||||
response_data["visualization"] = img_base64
|
||||
except Exception as e:
|
||||
logger.error(f"生成 t-SNE 可视化失败: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
response_data["visualization_error"] = str(e)
|
||||
|
||||
return Response().ok(response_data).__dict__
|
||||
|
||||
except ValueError as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import base64
|
||||
import os
|
||||
import traceback
|
||||
from io import BytesIO
|
||||
from astrbot.api import logger
|
||||
from astrbot.core.knowledge_base.kb_helper import KBHelper
|
||||
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
|
||||
from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
|
||||
|
||||
|
||||
async def generate_tsne_visualization(
|
||||
query: str, kb_names: list[str], kb_manager: KnowledgeBaseManager
|
||||
) -> str | None:
|
||||
"""生成 t-SNE 可视化图片
|
||||
|
||||
Args:
|
||||
query: 查询文本
|
||||
kb_names: 知识库名称列表
|
||||
kb_manager: 知识库管理器
|
||||
|
||||
Returns:
|
||||
图片路径或 None
|
||||
"""
|
||||
try:
|
||||
import faiss
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg") # 使用非交互式后端
|
||||
import matplotlib.pyplot as plt
|
||||
from sklearn.manifold import TSNE
|
||||
except ImportError as e:
|
||||
raise Exception(
|
||||
"缺少必要的库以生成 t-SNE 可视化。请安装 matplotlib 和 scikit-learn: {e}"
|
||||
) from e
|
||||
|
||||
try:
|
||||
# 获取第一个知识库的向量数据
|
||||
kb_helper: KBHelper | None = None
|
||||
for kb_name in kb_names:
|
||||
kb_helper = await kb_manager.get_kb_by_name(kb_name)
|
||||
if kb_helper:
|
||||
break
|
||||
|
||||
if not kb_helper:
|
||||
logger.warning("未找到知识库")
|
||||
return None
|
||||
|
||||
kb = kb_helper.kb
|
||||
index_path = f"data/knowledge_base/{kb.kb_id}/index.faiss"
|
||||
|
||||
# 读取 FAISS 索引
|
||||
if not os.path.exists(index_path):
|
||||
logger.warning(f"FAISS 索引不存在: {index_path}")
|
||||
return None
|
||||
|
||||
index = faiss.read_index(index_path)
|
||||
|
||||
if index.ntotal == 0:
|
||||
logger.warning("索引为空")
|
||||
return None
|
||||
|
||||
# 提取所有向量
|
||||
logger.info(f"提取 {index.ntotal} 个向量用于可视化...")
|
||||
if isinstance(index, faiss.IndexIDMap):
|
||||
base_index = faiss.downcast_index(index.index)
|
||||
if hasattr(base_index, "reconstruct_n"):
|
||||
vectors = base_index.reconstruct_n(0, index.ntotal)
|
||||
else:
|
||||
vectors = np.zeros((index.ntotal, index.d), dtype=np.float32)
|
||||
for i in range(index.ntotal):
|
||||
base_index.reconstruct(i, vectors[i])
|
||||
elif hasattr(index, "reconstruct_n"):
|
||||
vectors = index.reconstruct_n(0, index.ntotal)
|
||||
else:
|
||||
vectors = np.zeros((index.ntotal, index.d), dtype=np.float32)
|
||||
for i in range(index.ntotal):
|
||||
index.reconstruct(i, vectors[i])
|
||||
|
||||
# 获取查询向量
|
||||
vec_db: FaissVecDB = kb_helper.vec_db # type: ignore
|
||||
embedding_provider = vec_db.embedding_provider
|
||||
query_embedding = await embedding_provider.get_embedding(query)
|
||||
query_vector = np.array([query_embedding], dtype=np.float32)
|
||||
|
||||
# 合并所有向量和查询向量
|
||||
all_vectors = np.vstack([vectors, query_vector])
|
||||
|
||||
# t-SNE 降维
|
||||
logger.info("开始 t-SNE 降维...")
|
||||
perplexity = min(30, all_vectors.shape[0] - 1)
|
||||
tsne = TSNE(n_components=2, random_state=42, perplexity=perplexity)
|
||||
vectors_2d = tsne.fit_transform(all_vectors)
|
||||
|
||||
# 分离知识库向量和查询向量
|
||||
kb_vectors_2d = vectors_2d[:-1]
|
||||
query_vector_2d = vectors_2d[-1]
|
||||
|
||||
# 可视化
|
||||
logger.info("生成可视化图表...")
|
||||
plt.figure(figsize=(14, 10))
|
||||
|
||||
# 绘制知识库向量
|
||||
scatter = plt.scatter(
|
||||
kb_vectors_2d[:, 0],
|
||||
kb_vectors_2d[:, 1],
|
||||
alpha=0.5,
|
||||
s=40,
|
||||
c=range(len(kb_vectors_2d)),
|
||||
cmap="viridis",
|
||||
label="Knowledge Base Vectors",
|
||||
)
|
||||
|
||||
# 绘制查询向量(红色 X)
|
||||
plt.scatter(
|
||||
query_vector_2d[0],
|
||||
query_vector_2d[1],
|
||||
c="red",
|
||||
s=300,
|
||||
marker="X",
|
||||
edgecolors="black",
|
||||
linewidths=2,
|
||||
label="Query",
|
||||
zorder=5,
|
||||
)
|
||||
|
||||
# 添加查询文本标注
|
||||
plt.annotate(
|
||||
"Query",
|
||||
(query_vector_2d[0], query_vector_2d[1]),
|
||||
xytext=(10, 10),
|
||||
textcoords="offset points",
|
||||
fontsize=10,
|
||||
bbox={"boxstyle": "round,pad=0.5", "fc": "yellow", "alpha": 0.7},
|
||||
arrowprops={"arrowstyle": "->", "connectionstyle": "arc3,rad=0"},
|
||||
)
|
||||
|
||||
plt.colorbar(scatter, label="Vector Index")
|
||||
plt.title(
|
||||
f"t-SNE Visualization: Query in Knowledge Base\n"
|
||||
f"({index.ntotal} vectors, {index.d} dimensions, KB: {kb.kb_name})",
|
||||
fontsize=14,
|
||||
pad=20,
|
||||
)
|
||||
plt.xlabel("t-SNE Dimension 1", fontsize=12)
|
||||
plt.ylabel("t-SNE Dimension 2", fontsize=12)
|
||||
plt.grid(True, alpha=0.3)
|
||||
plt.legend(fontsize=10, loc="upper right")
|
||||
|
||||
# base64 编码图片返回
|
||||
buffer = BytesIO()
|
||||
plt.savefig(buffer, format="png", dpi=150, bbox_inches="tight")
|
||||
plt.close()
|
||||
buffer.seek(0)
|
||||
img_base64 = base64.b64encode(buffer.read()).decode("utf-8")
|
||||
return img_base64
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成 t-SNE 可视化时出错: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
return None
|
||||
@@ -245,7 +245,6 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.kb-detail-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
animation: fadeIn 0.3s ease;
|
||||
@@ -340,10 +339,6 @@ onMounted(() => {
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.kb-detail-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.kb-title {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
:label="t('create.embeddingModelLabel')"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
:disabled="true"
|
||||
@update:model-value="handleEmbeddingProviderChange"
|
||||
>
|
||||
<template #item="{ props, item }">
|
||||
@@ -166,10 +167,6 @@
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-4" v-if="editingKB && showEmbeddingWarning">
|
||||
<strong>注意:</strong> 修改嵌入模型会导致现有的向量数据失效,建议重新上传文档。不同的嵌入模型生成的向量不兼容,可能导致检索结果不准确。
|
||||
</v-alert>
|
||||
|
||||
<v-select
|
||||
v-model="formData.rerank_provider_id"
|
||||
:items="rerankProviders"
|
||||
@@ -278,38 +275,6 @@
|
||||
{{ snackbar.text }}
|
||||
</v-snackbar>
|
||||
|
||||
<!-- Embedding Provider 修改确认对话框 -->
|
||||
<v-dialog v-model="embeddingChangeDialog" max-width="500px" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="bg-warning text-white">
|
||||
<v-icon class="mr-2">mdi-alert</v-icon>
|
||||
确认修改嵌入模型
|
||||
</v-card-title>
|
||||
<v-card-text class="pa-6">
|
||||
<v-alert type="warning" variant="tonal" class="mb-4">
|
||||
<strong>警告:</strong> 修改嵌入模型将导致以下影响:
|
||||
</v-alert>
|
||||
<ul class="text-body-2">
|
||||
<li>现有的向量数据将失效</li>
|
||||
<li>检索功能可能无法正常工作</li>
|
||||
<li>建议删除现有文档后重新上传</li>
|
||||
<li>不同嵌入模型生成的向量不兼容</li>
|
||||
</ul>
|
||||
<div class="mt-4 text-body-2">
|
||||
您确定要将嵌入模型从 <strong>{{ originalEmbeddingProvider }}</strong> 修改为 <strong>{{ pendingEmbeddingProvider }}</strong> 吗?
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="cancelEmbeddingChange">
|
||||
取消
|
||||
</v-btn>
|
||||
<v-btn color="warning" variant="elevated" @click="confirmEmbeddingChange">
|
||||
确认修改
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -452,26 +417,6 @@ const handleEmbeddingProviderChange = (newValue: string | null) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 确认修改 embedding provider
|
||||
const confirmEmbeddingChange = () => {
|
||||
if (pendingEmbeddingProvider.value) {
|
||||
formData.value.embedding_provider_id = pendingEmbeddingProvider.value
|
||||
// 更新原始值,这样下次比较时不会重复弹窗
|
||||
originalEmbeddingProvider.value = pendingEmbeddingProvider.value
|
||||
}
|
||||
embeddingChangeDialog.value = false
|
||||
showEmbeddingWarning.value = true
|
||||
}
|
||||
|
||||
// 取消修改 embedding provider
|
||||
const cancelEmbeddingChange = () => {
|
||||
// 恢复到原始值
|
||||
formData.value.embedding_provider_id = originalEmbeddingProvider.value
|
||||
embeddingChangeDialog.value = false
|
||||
showEmbeddingWarning.value = false
|
||||
pendingEmbeddingProvider.value = null
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const confirmDelete = (kb: any) => {
|
||||
deleteTarget.value = kb
|
||||
|
||||
@@ -1,59 +1,57 @@
|
||||
<template>
|
||||
<div class="retrieval-tab">
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="pa-4">{{ t('retrieval.title') }}</v-card-title>
|
||||
<v-card-subtitle class="px-4 pb-4">
|
||||
<v-card-title class="pa-4 pb-0">{{ t('retrieval.title') }}</v-card-title>
|
||||
<v-card-subtitle class="pb-4 pt-2">
|
||||
{{ t('retrieval.subtitle') }}
|
||||
</v-card-subtitle>
|
||||
|
||||
<v-divider />
|
||||
<v-progress-linear
|
||||
v-if="loading"
|
||||
indeterminate
|
||||
color="primary"
|
||||
height="2"
|
||||
/>
|
||||
<v-progress-linear v-if="loading" indeterminate color="primary" height="2" />
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<!-- 查询输入区域 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" md="8">
|
||||
<v-textarea
|
||||
v-model="query"
|
||||
:label="t('retrieval.query')"
|
||||
:placeholder="t('retrieval.queryPlaceholder')"
|
||||
variant="outlined"
|
||||
rows="3"
|
||||
auto-grow
|
||||
clearable
|
||||
/>
|
||||
<v-textarea v-model="query" :label="t('retrieval.query')" :placeholder="t('retrieval.queryPlaceholder')"
|
||||
variant="outlined" rows="3" auto-grow clearable />
|
||||
|
||||
<!-- debug -->
|
||||
<div v-if="debugVisualize" class="mt-2">
|
||||
<v-card variant="outlined">
|
||||
<v-img :src="`data:image/png;base64,${debugVisualize}`" :alt="t('retrieval.tsneVisualization')" cover>
|
||||
<template v-slot:placeholder>
|
||||
<div class="d-flex align-center justify-center fill-height">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
</div>
|
||||
</template>
|
||||
</v-img>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-card variant="outlined" class="pa-4">
|
||||
<h4 class="text-subtitle-2 mb-3">{{ t('retrieval.settings') }}</h4>
|
||||
|
||||
<v-text-field
|
||||
v-model.number="topK"
|
||||
:label="t('retrieval.topK')"
|
||||
:hint="t('retrieval.topKHint')"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
persistent-hint
|
||||
/>
|
||||
<v-text-field v-model.number="topK" :label="t('retrieval.topK')" :hint="t('retrieval.topKHint')"
|
||||
type="number" variant="outlined" density="compact" persistent-hint class="mb-3" />
|
||||
|
||||
<v-switch v-model="debugMode" :label="t('retrieval.debugMode')" color="primary" density="compact"
|
||||
hide-details>
|
||||
<template v-slot:label>
|
||||
<span class="text-caption">
|
||||
<v-icon size="small" class="mr-1">mdi-bug</v-icon>
|
||||
Debug (t-SNE)
|
||||
</span>
|
||||
</template>
|
||||
</v-switch>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div class="d-flex justify-end mb-4">
|
||||
<v-btn
|
||||
prepend-icon="mdi-magnify"
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
@click="performRetrieval"
|
||||
:loading="loading"
|
||||
:disabled="!query || query.trim() === ''"
|
||||
>
|
||||
<v-btn prepend-icon="mdi-magnify" color="primary" variant="elevated" @click="performRetrieval"
|
||||
:loading="loading" :disabled="!query || query.trim() === ''">
|
||||
{{ loading ? t('retrieval.searching') : t('retrieval.search') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
@@ -64,28 +62,33 @@
|
||||
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h3 class="text-h6">{{ t('retrieval.results') }}</h3>
|
||||
<v-chip class="ml-3" color="primary" variant="tonal">
|
||||
<v-chip class="ml-3" color="primary" variant="tonal" size="small">
|
||||
{{ results.length }} {{ t('retrieval.results') }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- 结果列表 -->
|
||||
<div v-if="results.length > 0" class="results-list">
|
||||
<v-card
|
||||
v-for="(result, index) in results"
|
||||
:key="result.chunk_id"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
>
|
||||
<v-card-title class="d-flex align-center pa-4">
|
||||
<v-chip size="small" color="primary" class="mr-2">
|
||||
<v-card v-for="(result, index) in results" :key="result.chunk_id" variant="outlined" class="mb-4">
|
||||
<v-card-title class="d-flex align-center pa-2">
|
||||
<v-chip size="x-small" color="primary" class="mr-2">
|
||||
#{{ index + 1 }}
|
||||
</v-chip>
|
||||
<span class="text-subtitle-1">
|
||||
{{ t('retrieval.chunk', { index: result.chunk_index }) }}
|
||||
</span>
|
||||
<div class="ml-4">
|
||||
<v-chip size="x-small" variant="tonal" class="mr-2">
|
||||
<v-icon start size="small">mdi-file-document</v-icon>
|
||||
{{ result.doc_name }}
|
||||
</v-chip>
|
||||
<v-chip size="x-small" variant="tonal">
|
||||
<v-icon start size="small">mdi-text</v-icon>
|
||||
{{ t('retrieval.charCount', { count: result.char_count }) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<v-chip size="small" :color="getScoreColor(result.score)">
|
||||
<v-chip size="x-small" :color="getScoreColor(result.score)">
|
||||
{{ t('retrieval.score') }}: {{ result.score.toFixed(4) }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
@@ -93,17 +96,6 @@
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<div class="mb-3">
|
||||
<v-chip size="small" variant="tonal" class="mr-2">
|
||||
<v-icon start size="small">mdi-file-document</v-icon>
|
||||
{{ result.doc_name }}
|
||||
</v-chip>
|
||||
<v-chip size="small" variant="tonal">
|
||||
<v-icon start size="small">mdi-text</v-icon>
|
||||
{{ t('retrieval.charCount', { count: result.char_count }) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div class="content-box">
|
||||
{{ result.content }}
|
||||
</div>
|
||||
@@ -144,9 +136,10 @@ const props = defineProps<{
|
||||
const loading = ref(false)
|
||||
const query = ref('')
|
||||
const topK = ref(5)
|
||||
const enableRerank = ref(false)
|
||||
const debugMode = ref(false)
|
||||
const results = ref<any[]>([])
|
||||
const hasSearched = ref(false)
|
||||
const debugVisualize = ref<string | null>(null)
|
||||
|
||||
const snackbar = ref({
|
||||
show: false,
|
||||
@@ -169,18 +162,24 @@ const performRetrieval = async () => {
|
||||
|
||||
loading.value = true
|
||||
hasSearched.value = false
|
||||
debugVisualize.value = null
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/kb/retrieve', {
|
||||
query: query.value,
|
||||
kb_names: [props.kbName],
|
||||
top_k: topK.value,
|
||||
enable_rerank: enableRerank.value
|
||||
debug: debugMode.value
|
||||
})
|
||||
|
||||
if (response.data.status === 'ok') {
|
||||
results.value = response.data.data.results || []
|
||||
hasSearched.value = true
|
||||
|
||||
if (debugMode.value && response.data.data.visualization) {
|
||||
debugVisualize.value = response.data.data.visualization
|
||||
}
|
||||
|
||||
showSnackbar(t('retrieval.searchSuccess', { count: results.value.length }))
|
||||
} else {
|
||||
showSnackbar(response.data.message || t('retrieval.searchFailed'), 'error')
|
||||
@@ -208,8 +207,13 @@ const getScoreColor = (score: number) => {
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.results-section {
|
||||
@@ -221,6 +225,7 @@ const getScoreColor = (score: number) => {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
@@ -228,7 +233,7 @@ const getScoreColor = (score: number) => {
|
||||
}
|
||||
|
||||
.content-box {
|
||||
background: rgba(var(--v-theme-surface-variant), 0.3);
|
||||
background: rgba(var(--v-theme-surface-variant), 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
white-space: pre-wrap;
|
||||
@@ -236,5 +241,8 @@ const getScoreColor = (score: number) => {
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
height: 120px;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user