[前端+RAG] 修复PDF文字重叠;上传异步化(LLM+向量化后台执行);摘要关键词模型改为deepseek-v3
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pdf-viewer" ref="containerRef">
|
<div class="pdf-viewer" ref="containerRef">
|
||||||
<div v-for="page in pages" :key="page" class="pdf-page-wrapper">
|
<div v-for="page in pages" :key="page" class="pdf-page-wrapper">
|
||||||
<div class="pdf-page" :id="'pdf-page-' + page" :style="{ position: 'relative' }">
|
<div class="pdf-page" :style="{ width: pageWidths[page] + 'px', height: pageHeights[page] + 'px' }">
|
||||||
<canvas :ref="el => setCanvasRef(el, page)"></canvas>
|
<canvas :ref="el => setCanvasRef(el, page)"></canvas>
|
||||||
<div class="text-layer" :ref="el => setTextLayerRef(el, page)"></div>
|
<div class="text-layer" :ref="el => setTextLayerRef(el, page)"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="loading" class="pdf-loading">加载中...</div>
|
<div v-if="loading" class="pdf-loading">PDF 加载中...</div>
|
||||||
<div v-if="error" class="pdf-error">{{ error }}</div>
|
<div v-if="error" class="pdf-error">{{ error }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -14,8 +14,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
import { renderTextLayer } from 'pdfjs-dist';
|
||||||
|
|
||||||
// Set worker
|
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
'pdfjs-dist/build/pdf.worker.min.js',
|
'pdfjs-dist/build/pdf.worker.min.js',
|
||||||
import.meta.url
|
import.meta.url
|
||||||
@@ -28,18 +28,16 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const containerRef = ref<HTMLElement | null>(null);
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
const pages = ref<number[]>([]);
|
const pages = ref<number[]>([]);
|
||||||
|
const pageWidths = ref<Record<number, number>>({});
|
||||||
|
const pageHeights = ref<Record<number, number>>({});
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const canvasRefs: Record<number, HTMLCanvasElement> = {};
|
const canvasRefs: Record<number, HTMLCanvasElement> = {};
|
||||||
const textLayerRefs: Record<number, HTMLElement> = {};
|
const textLayerRefs: Record<number, HTMLElement> = {};
|
||||||
let pdfDoc: any = null;
|
let pdfDoc: any = null;
|
||||||
|
|
||||||
const setCanvasRef = (el: any, page: number) => {
|
const setCanvasRef = (el: any, page: number) => { if (el) canvasRefs[page] = el; };
|
||||||
if (el) canvasRefs[page] = el;
|
const setTextLayerRef = (el: any, page: number) => { if (el) textLayerRefs[page] = el; };
|
||||||
};
|
|
||||||
const setTextLayerRef = (el: any, page: number) => {
|
|
||||||
if (el) textLayerRefs[page] = el;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderPage = async (pageNum: number) => {
|
const renderPage = async (pageNum: number) => {
|
||||||
if (!pdfDoc) return;
|
if (!pdfDoc) return;
|
||||||
@@ -47,43 +45,28 @@ const renderPage = async (pageNum: number) => {
|
|||||||
const scale = props.scale || 1.5;
|
const scale = props.scale || 1.5;
|
||||||
const viewport = page.getViewport({ scale });
|
const viewport = page.getViewport({ scale });
|
||||||
|
|
||||||
|
// Canvas rendering
|
||||||
const canvas = canvasRefs[pageNum];
|
const canvas = canvasRefs[pageNum];
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const context = canvas.getContext('2d');
|
const context = canvas.getContext('2d');
|
||||||
canvas.height = viewport.height;
|
canvas.height = viewport.height;
|
||||||
canvas.width = viewport.width;
|
canvas.width = viewport.width;
|
||||||
|
pageWidths.value[pageNum] = viewport.width;
|
||||||
|
pageHeights.value[pageNum] = viewport.height;
|
||||||
|
|
||||||
await page.render({ canvasContext: context, viewport }).promise;
|
await page.render({ canvasContext: context, viewport }).promise;
|
||||||
|
|
||||||
// Text layer for text selection
|
// Text layer using pdfjs built-in API
|
||||||
const textLayerDiv = textLayerRefs[pageNum];
|
const textLayerDiv = textLayerRefs[pageNum];
|
||||||
if (textLayerDiv) {
|
if (textLayerDiv) {
|
||||||
textLayerDiv.style.width = viewport.width + 'px';
|
|
||||||
textLayerDiv.style.height = viewport.height + 'px';
|
|
||||||
textLayerDiv.innerHTML = '';
|
textLayerDiv.innerHTML = '';
|
||||||
|
|
||||||
const textContent = await page.getTextContent();
|
const textContent = await page.getTextContent();
|
||||||
const textItems = textContent.items;
|
renderTextLayer({
|
||||||
|
textContentSource: textContent,
|
||||||
for (const item of textItems) {
|
container: textLayerDiv,
|
||||||
if (!item.str) continue;
|
viewport: viewport,
|
||||||
const tx = pdfjsLib.Util.transform(viewport.transform, item.transform);
|
textDivs: []
|
||||||
const span = document.createElement('span');
|
});
|
||||||
span.textContent = item.str;
|
|
||||||
span.style.position = 'absolute';
|
|
||||||
span.style.left = tx[4] + 'px';
|
|
||||||
span.style.top = (viewport.height - tx[5]) + 'px';
|
|
||||||
span.style.fontSize = Math.abs(tx[0]) + 'px';
|
|
||||||
span.style.fontFamily = item.fontName || 'sans-serif';
|
|
||||||
span.style.transformOrigin = '0% 0%';
|
|
||||||
// Width matching
|
|
||||||
if (item.width) {
|
|
||||||
const textWidth = item.width * scale;
|
|
||||||
span.style.width = textWidth + 'px';
|
|
||||||
span.style.display = 'inline-block';
|
|
||||||
}
|
|
||||||
textLayerDiv.appendChild(span);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,33 +80,20 @@ const loadPdf = async () => {
|
|||||||
pdfDoc = await loadingTask.promise;
|
pdfDoc = await loadingTask.promise;
|
||||||
const numPages = pdfDoc.numPages;
|
const numPages = pdfDoc.numPages;
|
||||||
pages.value = Array.from({ length: numPages }, (_, i) => i + 1);
|
pages.value = Array.from({ length: numPages }, (_, i) => i + 1);
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
for (let i = 1; i <= numPages; i++) {
|
for (let i = 1; i <= numPages; i++) {
|
||||||
await renderPage(i);
|
await renderPage(i);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = 'PDF 加载失败: ' + (e.message || e);
|
error.value = 'PDF 加载失败: ' + (e.message || e);
|
||||||
console.error('PDF load error:', e);
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(() => props.src, () => {
|
watch(() => props.src, () => { if (props.src) loadPdf(); });
|
||||||
if (props.src) loadPdf();
|
onMounted(() => { if (props.src) loadPdf(); });
|
||||||
});
|
onBeforeUnmount(() => { if (pdfDoc) { pdfDoc.destroy(); pdfDoc = null; } });
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (props.src) loadPdf();
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (pdfDoc) {
|
|
||||||
pdfDoc.destroy();
|
|
||||||
pdfDoc = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -131,54 +101,51 @@ onBeforeUnmount(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #f5f5f5;
|
background: #e8e8e8;
|
||||||
|
padding: 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-page-wrapper {
|
.pdf-page-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-page {
|
.pdf-page {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: white;
|
background: white;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-page canvas {
|
.pdf-page canvas {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-layer {
|
.text-layer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
opacity: 0.3;
|
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
/* pdfjs renderTextLayer creates spans with absolute positioning */
|
||||||
.text-layer span {
|
.text-layer :deep(span) {
|
||||||
color: transparent;
|
color: transparent;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
cursor: text;
|
cursor: text;
|
||||||
|
transform-origin: 0% 0%;
|
||||||
}
|
}
|
||||||
|
.text-layer :deep(span::selection) {
|
||||||
.text-layer span::selection {
|
|
||||||
background: rgba(0, 78, 160, 0.3);
|
background: rgba(0, 78, 160, 0.3);
|
||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
.text-layer :deep(br) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.pdf-loading, .pdf-error {
|
.pdf-loading, .pdf-error {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
color: #999;
|
color: #999;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
.pdf-error { color: #c00; }
|
||||||
.pdf-error {
|
|
||||||
color: #c00;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -269,6 +269,62 @@ def upload_docs(
|
|||||||
return BaseResponse(code=200, msg="文件上传与向量化完成", data={"failed_files": failed_files})
|
return BaseResponse(code=200, msg="文件上传与向量化完成", data={"failed_files": failed_files})
|
||||||
|
|
||||||
|
|
||||||
|
def _background_llm_and_vectorize(
|
||||||
|
knowledge_base_name: str,
|
||||||
|
file_names: List[str],
|
||||||
|
chunk_size: int,
|
||||||
|
chunk_overlap: int,
|
||||||
|
zh_title_enhance: bool,
|
||||||
|
docs: dict,
|
||||||
|
not_refresh_vs_cache: bool,
|
||||||
|
):
|
||||||
|
"""后台线程:执行 LLM 导读生成 + 向量化,不阻塞上传响应。"""
|
||||||
|
import time
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
kb = KBServiceFactory.get_service_by_name(knowledge_base_name)
|
||||||
|
|
||||||
|
# 1. 生成 LLM 导读(摘要、关键词、章节速览)
|
||||||
|
for filename in file_names:
|
||||||
|
try:
|
||||||
|
knowledge_file = KnowledgeFile(filename=filename, knowledge_base_name=knowledge_base_name)
|
||||||
|
new_loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(new_loop)
|
||||||
|
try:
|
||||||
|
llm_result = new_loop.run_until_complete(knowledge_file.get_llm_result())
|
||||||
|
finally:
|
||||||
|
new_loop.close()
|
||||||
|
|
||||||
|
# 将 LLM 结果写入缓存文件,供 Java 后端轮询读取
|
||||||
|
import json
|
||||||
|
cache_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "knowledge_base", knowledge_base_name)
|
||||||
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
|
cache_file = os.path.join(cache_dir, f"{filename}.llm_result.json")
|
||||||
|
with open(cache_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(llm_result, f, ensure_ascii=False)
|
||||||
|
logger.info(f"[后台] LLM 导读生成完成: {filename}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[后台] LLM 导读生成失败 {filename}: {e}")
|
||||||
|
|
||||||
|
# 2. 向量化
|
||||||
|
try:
|
||||||
|
_update_docs_impl(
|
||||||
|
knowledge_base_name=knowledge_base_name,
|
||||||
|
file_names=file_names,
|
||||||
|
override_custom_docs=True,
|
||||||
|
chunk_size=chunk_size,
|
||||||
|
chunk_overlap=chunk_overlap,
|
||||||
|
zh_title_enhance=zh_title_enhance,
|
||||||
|
docs=docs,
|
||||||
|
not_refresh_vs_cache=True,
|
||||||
|
)
|
||||||
|
if kb and not not_refresh_vs_cache:
|
||||||
|
kb.save_vector_store()
|
||||||
|
logger.info(f"[后台] 向量化完成,总耗时: {time.time() - start_time:.2f}s")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[后台] 向量化失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
def upload_docs_new(
|
def upload_docs_new(
|
||||||
files: List[UploadFile] = File(..., description="上传文件,支持多文件"),
|
files: List[UploadFile] = File(..., description="上传文件,支持多文件"),
|
||||||
knowledge_base_name: str = Form(..., description="知识库名称", examples=["samples"]),
|
knowledge_base_name: str = Form(..., description="知识库名称", examples=["samples"]),
|
||||||
@@ -282,16 +338,15 @@ def upload_docs_new(
|
|||||||
not_refresh_vs_cache: bool = Form(False, description="暂不保存向量库(用于FAISS)"),
|
not_refresh_vs_cache: bool = Form(False, description="暂不保存向量库(用于FAISS)"),
|
||||||
) -> BaseResponse:
|
) -> BaseResponse:
|
||||||
"""
|
"""
|
||||||
API接口:上传文件,并/或向量化
|
API接口:上传文件,先提取全文快速返回,LLM导读+向量化后台异步执行
|
||||||
"""
|
"""
|
||||||
import time # 添加计时模块
|
import time
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
if not validate_kb_name(knowledge_base_name):
|
if not validate_kb_name(knowledge_base_name):
|
||||||
return BaseResponse(code=403, msg="Don't attack me")
|
return BaseResponse(code=403, msg="Don't attack me")
|
||||||
|
|
||||||
kb = KBServiceFactory.get_service_by_name(knowledge_base_name)
|
kb = KBServiceFactory.get_service_by_name(knowledge_base_name)
|
||||||
if kb is None:
|
if kb is None:
|
||||||
# 自动创建知识库
|
|
||||||
kb = KBServiceFactory.get_service(knowledge_base_name, DEFAULT_VS_TYPE, EMBEDDING_MODEL)
|
kb = KBServiceFactory.get_service(knowledge_base_name, DEFAULT_VS_TYPE, EMBEDDING_MODEL)
|
||||||
try:
|
try:
|
||||||
kb.create_kb()
|
kb.create_kb()
|
||||||
@@ -303,68 +358,52 @@ def upload_docs_new(
|
|||||||
|
|
||||||
failed_files = {}
|
failed_files = {}
|
||||||
file_names = list(docs.keys())
|
file_names = list(docs.keys())
|
||||||
|
|
||||||
# 生成摘要、关键词、章节速览的结果存储
|
|
||||||
llm_results = {}
|
llm_results = {}
|
||||||
|
|
||||||
# 先将上传的文件保存到磁盘
|
# 保存文件到磁盘 + 提取全文(快速操作)
|
||||||
for result in _save_files_in_thread(files, knowledge_base_name=knowledge_base_name, override=override):
|
for result in _save_files_in_thread(files, knowledge_base_name=knowledge_base_name, override=override):
|
||||||
filename = result["data"]["file_name"]
|
filename = result["data"]["file_name"]
|
||||||
if result["code"] != 200:
|
if result["code"] != 200:
|
||||||
failed_files[filename] = result["msg"]
|
failed_files[filename] = result["msg"]
|
||||||
|
|
||||||
if filename not in file_names:
|
if filename not in file_names:
|
||||||
file_names.append(filename)
|
file_names.append(filename)
|
||||||
|
|
||||||
# 针对成功上传的文件,生成摘要、关键词、章节速览
|
# 仅提取全文(快速),不调用 LLM
|
||||||
try:
|
try:
|
||||||
knowledge_file = KnowledgeFile(filename=filename, knowledge_base_name=knowledge_base_name)
|
knowledge_file = KnowledgeFile(filename=filename, knowledge_base_name=knowledge_base_name)
|
||||||
# 使用线程池运行异步函数,避免事件循环冲突
|
full_text_data = knowledge_file.get_full_text()
|
||||||
import concurrent.futures
|
import json as _json
|
||||||
def run_async_in_thread():
|
|
||||||
new_loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(new_loop)
|
|
||||||
try:
|
try:
|
||||||
return new_loop.run_until_complete(knowledge_file.get_llm_result())
|
full_text = _json.loads(full_text_data).get("full_text", "")
|
||||||
finally:
|
except:
|
||||||
new_loop.close()
|
full_text = ""
|
||||||
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
||||||
future = executor.submit(run_async_in_thread)
|
|
||||||
llm_result = future.result()
|
|
||||||
llm_results[filename] = {
|
llm_results[filename] = {
|
||||||
"full_text": llm_result.get("full_text", "获取全文失败"),
|
"full_text": full_text,
|
||||||
"article_abstract": llm_result.get("article_abstract", "生成摘要失败"),
|
"article_abstract": "导读生成中...",
|
||||||
"article_keywords": llm_result.get("article_keywords", "生成关键词失败"),
|
"article_keywords": "导读生成中...",
|
||||||
"article_paragraph": llm_result.get("article_paragraph", "生成章节速览失败")
|
"article_paragraph": "导读生成中..."
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"生成LLM结果时出错:{e}", exc_info=e if log_verbose else None)
|
logger.error(f"提取全文失败 {filename}: {e}")
|
||||||
llm_results[filename] = {
|
llm_results[filename] = {
|
||||||
"article_abstract": "生成摘要失败",
|
"full_text": "",
|
||||||
"article_keywords": "生成关键词失败",
|
"article_abstract": "导读生成中...",
|
||||||
"article_paragraph": "生成章节速览失败"
|
"article_keywords": "导读生成中...",
|
||||||
|
"article_paragraph": "导读生成中..."
|
||||||
}
|
}
|
||||||
|
|
||||||
# 对保存的文件进行向量化
|
# 后台异步执行 LLM 导读 + 向量化(不阻塞响应)
|
||||||
if to_vector_store:
|
import threading
|
||||||
update_st = time.time()
|
bg_thread = threading.Thread(
|
||||||
result = _update_docs_impl(
|
target=_background_llm_and_vectorize,
|
||||||
knowledge_base_name=knowledge_base_name,
|
args=(knowledge_base_name, file_names, chunk_size, chunk_overlap,
|
||||||
file_names=file_names,
|
zh_title_enhance, docs, not_refresh_vs_cache),
|
||||||
override_custom_docs=True,
|
daemon=True
|
||||||
chunk_size=chunk_size,
|
|
||||||
chunk_overlap=chunk_overlap,
|
|
||||||
zh_title_enhance=zh_title_enhance,
|
|
||||||
docs=docs,
|
|
||||||
not_refresh_vs_cache=True,
|
|
||||||
)
|
)
|
||||||
failed_files.update(result.data["failed_files"])
|
bg_thread.start()
|
||||||
if not not_refresh_vs_cache:
|
|
||||||
kb.save_vector_store()
|
logger.info(f"文件上传+全文提取用时: {time.time() - start_time:.2f}s,LLM+向量化已转后台")
|
||||||
logger.info(f'向量化用时:{time.time() - update_st}')
|
return BaseResponse(code=200, msg="文件上传完成,导读生成中", data={
|
||||||
logger.info(f"总执行时间: {time.time() - start_time:.2f}s")
|
|
||||||
return BaseResponse(code=200, msg="文件上传与向量化完成", data={
|
|
||||||
"failed_files": failed_files,
|
"failed_files": failed_files,
|
||||||
"llm_results": llm_results
|
"llm_results": llm_results
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ class KnowledgeFile:
|
|||||||
llm_time = time.time()
|
llm_time = time.time()
|
||||||
abstract_task = get_llm_model_response_async(
|
abstract_task = get_llm_model_response_async(
|
||||||
strategy_name="gen_abstract",
|
strategy_name="gen_abstract",
|
||||||
llm_model_name=LLM_MODELS[1],
|
llm_model_name=LLM_MODELS[0],
|
||||||
template_prompt_name="gen_abstract",
|
template_prompt_name="gen_abstract",
|
||||||
prompt_param_dict={"context": full_text},
|
prompt_param_dict={"context": full_text},
|
||||||
temperature=0.7,
|
temperature=0.7,
|
||||||
@@ -399,7 +399,7 @@ class KnowledgeFile:
|
|||||||
|
|
||||||
keywords_task = get_llm_model_response_async(
|
keywords_task = get_llm_model_response_async(
|
||||||
strategy_name="gen_keywords",
|
strategy_name="gen_keywords",
|
||||||
llm_model_name=LLM_MODELS[1],
|
llm_model_name=LLM_MODELS[0],
|
||||||
template_prompt_name="gen_keywords",
|
template_prompt_name="gen_keywords",
|
||||||
prompt_param_dict={"context": full_text},
|
prompt_param_dict={"context": full_text},
|
||||||
temperature=0.7,
|
temperature=0.7,
|
||||||
|
|||||||
Reference in New Issue
Block a user