fix: async document processing in background threads, improve chat UX
- Fix knowledge_base_service calling async process_document synchronously in background threads — use asyncio.new_event_loop().run_until_complete() instead of bare call that returned unawaited coroutine - Same fix for _update_document and reindex_document - Replace ModeSelector with auto-detect: RAG mode when knowledge bases selected - Convert regenerateMessage to streaming (was synchronous API call) - Show "知识库检索模式" indicator above input when KB selected - Remove loadSessions side-effect from streaming onComplete - Fix chat page loading state to avoid flash - Improve forum page spacing and sizing Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -161,11 +161,13 @@ class KnowledgeBaseService:
|
||||
|
||||
# 处理文档(向量化)- 在后台异步处理,不阻塞主流程
|
||||
try:
|
||||
# 使用同步方法,但不等待完成(在后台处理)
|
||||
import threading
|
||||
import asyncio as _asyncio
|
||||
def process_in_background():
|
||||
try:
|
||||
self.document_service.process_document(document.id)
|
||||
loop = _asyncio.new_event_loop()
|
||||
loop.run_until_complete(self.document_service.process_document(document.id))
|
||||
loop.close()
|
||||
except Exception as e:
|
||||
print(f"后台处理文档 {document.id} 失败: {e}")
|
||||
|
||||
@@ -198,9 +200,12 @@ class KnowledgeBaseService:
|
||||
# 重新处理文档 - 在后台异步处理
|
||||
try:
|
||||
import threading
|
||||
import asyncio as _asyncio
|
||||
def process_in_background():
|
||||
try:
|
||||
self.document_service.process_document(document.id)
|
||||
loop = _asyncio.new_event_loop()
|
||||
loop.run_until_complete(self.document_service.process_document(document.id))
|
||||
loop.close()
|
||||
except Exception as e:
|
||||
print(f"后台处理文档 {document.id} 失败: {e}")
|
||||
|
||||
@@ -340,7 +345,10 @@ class KnowledgeBaseService:
|
||||
self.db.query(DocumentChunk).filter(DocumentChunk.document_id == document_id).delete()
|
||||
|
||||
# 重新处理文档
|
||||
success = self.document_service.process_document(document_id)
|
||||
import asyncio as _asyncio
|
||||
success = _asyncio.get_event_loop().run_until_complete(
|
||||
self.document_service.process_document(document_id)
|
||||
)
|
||||
|
||||
if success:
|
||||
return {"success": True, "message": "文档重新索引成功"}
|
||||
|
||||
@@ -14,9 +14,10 @@ import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
export default function ChatPage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, user, isLoading: authLoading } = useAuthStore();
|
||||
const { loadSessions, isLoading: chatLoading } = useChatStore();
|
||||
const { loadSessions, sessions } = useChatStore();
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
@@ -25,12 +26,12 @@ export default function ChatPage() {
|
||||
}
|
||||
|
||||
if (isAuthenticated && !isInitialized) {
|
||||
loadSessions();
|
||||
loadSessions().then(() => setInitialLoading(false));
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [isAuthenticated, authLoading, isInitialized, router, loadSessions]);
|
||||
|
||||
if (authLoading || chatLoading) {
|
||||
if (authLoading || initialLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
|
||||
+106
-104
@@ -25,11 +25,11 @@ const CATEGORY_ORDER = ["公告", "通知", "学习", "讨论", "课程", "反
|
||||
|
||||
function getCategoryIcon(name: string) {
|
||||
if (name.includes("公告") || name.includes("通知"))
|
||||
return <Megaphone className="w-4 h-4" />;
|
||||
return <Megaphone className="w-5 h-5" />;
|
||||
if (name.includes("学习") || name.includes("讨论") || name.includes("课程"))
|
||||
return <BookOpen className="w-4 h-4" />;
|
||||
return <BookOpen className="w-5 h-5" />;
|
||||
if (name.includes("反馈") || name.includes("使用"))
|
||||
return <Lightbulb className="w-4 h-4" />;
|
||||
return <Lightbulb className="w-5 h-5" />;
|
||||
return CATEGORY_ICONS.default;
|
||||
}
|
||||
|
||||
@@ -106,110 +106,112 @@ export default function ForumHomePage() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-5">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground py-20">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在加载社区...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{[...categories]
|
||||
.sort((a, b) => {
|
||||
const getOrder = (name: string) => {
|
||||
for (let i = 0; i < CATEGORY_ORDER.length; i++) {
|
||||
if (name.includes(CATEGORY_ORDER[i])) return i;
|
||||
}
|
||||
return CATEGORY_ORDER.length;
|
||||
};
|
||||
return getOrder(a.name) - getOrder(b.name);
|
||||
})
|
||||
.map((category) => {
|
||||
const posts = categoryPosts[category.id] || [];
|
||||
return (
|
||||
<div
|
||||
key={category.id}
|
||||
className={`bg-gradient-to-r ${getCategoryGradient(
|
||||
category.name
|
||||
)} rounded-xl border border-border/40 overflow-hidden`}
|
||||
>
|
||||
{/* Category header */}
|
||||
<div className="px-4 py-3 flex items-center justify-between border-b border-border/20">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className={getCategoryAccent(category.name)}>
|
||||
{getCategoryIcon(category.name)}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
{category.name}
|
||||
</h2>
|
||||
{category.description && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0">
|
||||
{category.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{category.post_count} 条讨论
|
||||
</span>
|
||||
<Link
|
||||
href={`/forum/${category.id}`}
|
||||
className="text-[11px] font-medium text-primary hover:underline flex items-center gap-0.5"
|
||||
>
|
||||
查看全部
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Posts list */}
|
||||
<div className="divide-y divide-border/20">
|
||||
{posts.length > 0 ? (
|
||||
posts.map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/forum/post/${post.id}`}
|
||||
className="flex items-start gap-2.5 px-4 py-2.5 hover:bg-white/40 transition-colors group"
|
||||
>
|
||||
<div className="mt-0.5 w-6 h-6 rounded-full bg-muted/60 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-3 h-3 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] font-medium text-foreground group-hover:text-primary transition-colors line-clamp-1">
|
||||
{post.title}
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground py-20">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
正在加载社区...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{[...categories]
|
||||
.sort((a, b) => {
|
||||
const getOrder = (name: string) => {
|
||||
for (let i = 0; i < CATEGORY_ORDER.length; i++) {
|
||||
if (name.includes(CATEGORY_ORDER[i])) return i;
|
||||
}
|
||||
return CATEGORY_ORDER.length;
|
||||
};
|
||||
return getOrder(a.name) - getOrder(b.name);
|
||||
})
|
||||
.map((category) => {
|
||||
const posts = categoryPosts[category.id] || [];
|
||||
return (
|
||||
<div
|
||||
key={category.id}
|
||||
className={`bg-gradient-to-r ${getCategoryGradient(
|
||||
category.name
|
||||
)} rounded-xl border border-border/40 overflow-hidden`}
|
||||
>
|
||||
{/* Category header */}
|
||||
<div className="px-5 py-3.5 flex items-center justify-between border-b border-border/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={getCategoryAccent(category.name)}>
|
||||
{getCategoryIcon(category.name)}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
{category.name}
|
||||
</h2>
|
||||
{category.description && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{category.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-muted-foreground">
|
||||
<span>{post.author_name}</span>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{formatRelativeTime(post.created_at)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<MessageCircle className="w-2.5 h-2.5" />
|
||||
{post.reply_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground/0 group-hover:text-muted-foreground transition-colors mt-1 flex-shrink-0" />
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-5 text-center text-xs text-muted-foreground">
|
||||
暂无讨论,成为第一个发帖的人
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{category.post_count} 条讨论
|
||||
</span>
|
||||
<Link
|
||||
href={`/forum/${category.id}`}
|
||||
className="text-sm font-medium text-primary hover:underline flex items-center gap-0.5"
|
||||
>
|
||||
查看全部
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Posts list */}
|
||||
<div className="divide-y divide-border/20">
|
||||
{posts.length > 0 ? (
|
||||
posts.map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/forum/post/${post.id}`}
|
||||
className="flex items-start gap-3 px-5 py-3.5 hover:bg-white/40 transition-colors group"
|
||||
>
|
||||
<div className="mt-0.5 w-8 h-8 rounded-full bg-muted/60 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground group-hover:text-primary transition-colors line-clamp-1">
|
||||
{post.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-2.5 mt-1 text-xs text-muted-foreground">
|
||||
<span>{post.author_name}</span>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatRelativeTime(post.created_at)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<MessageCircle className="w-3 h-3" />
|
||||
{post.reply_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground/0 group-hover:text-muted-foreground transition-colors mt-2 flex-shrink-0" />
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="px-5 py-8 text-center text-sm text-muted-foreground">
|
||||
暂无讨论,成为第一个发帖的人
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ import { useState, useRef, useEffect } from "react";
|
||||
import { useChatStore } from "@/store/chat";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Send, Square, Bot, ChevronDown, Settings2 } from "lucide-react";
|
||||
import { Send, Square, Bot, ChevronDown, Settings2, Database } from "lucide-react";
|
||||
import MessageList from "./message-list";
|
||||
import QuickQuestions from "./quick-questions";
|
||||
import ModeSelector, { ChatMode } from "./mode-selector";
|
||||
import ModelSelector from "./model-selector";
|
||||
import KnowledgeSelector, { KnowledgeBase } from "./knowledge-selector";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -16,7 +15,6 @@ import { knowledgeBaseAPI } from "@/lib/api";
|
||||
export default function ChatInterface() {
|
||||
const [inputMessage, setInputMessage] = useState("");
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const [chatMode, setChatMode] = useState<ChatMode>("normal");
|
||||
const [selectedModel, setSelectedModel] = useState("deepseek-ai/DeepSeek-V3");
|
||||
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<string[]>([]);
|
||||
const [systemKnowledgeBases, setSystemKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
@@ -72,7 +70,8 @@ export default function ChatInterface() {
|
||||
await selectSession(newSession.id);
|
||||
}
|
||||
}
|
||||
await streamMessage(message, chatMode, selectedKnowledgeBases, selectedModel);
|
||||
const mode = selectedKnowledgeBases.length > 0 ? "rag" : "normal";
|
||||
await streamMessage(message, mode, selectedKnowledgeBases, selectedModel);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
@@ -124,7 +123,6 @@ export default function ChatInterface() {
|
||||
{/* Settings panel — collapsible */}
|
||||
{showSettings && (
|
||||
<div className="flex items-center gap-2 mb-3 pb-3 border-b border-border/30">
|
||||
<ModeSelector mode={chatMode} onModeChange={setChatMode} />
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
@@ -156,6 +154,12 @@ export default function ChatInterface() {
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
{selectedKnowledgeBases.length > 0 && (
|
||||
<div className="absolute -top-0.5 left-3 right-3 flex items-center gap-1 text-[10px] text-primary font-medium pointer-events-none">
|
||||
<Database className="w-3 h-3" />
|
||||
<span>知识库检索模式</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={inputMessage}
|
||||
@@ -167,6 +171,7 @@ export default function ChatInterface() {
|
||||
disabled={isLoading || isStreaming}
|
||||
className={cn(
|
||||
"w-full h-10 px-4 rounded-xl text-sm",
|
||||
selectedKnowledgeBases.length > 0 && "pt-4",
|
||||
"bg-muted/40 border border-border/30",
|
||||
"placeholder:text-muted-foreground/60",
|
||||
"focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30",
|
||||
|
||||
+116
-39
@@ -54,14 +54,12 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
|
||||
// 加载会话列表
|
||||
loadSessions: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const sessions = await chatAPI.getSessions();
|
||||
set({ sessions, isLoading: false });
|
||||
set({ sessions });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "加载会话失败";
|
||||
set({ error: errorMessage, isLoading: false });
|
||||
set({ error: errorMessage });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -320,7 +318,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
});
|
||||
}
|
||||
},
|
||||
(sessionId: number, messageId?: number, userMessageId?: number) => {
|
||||
(_sessionId: number, messageId?: number, userMessageId?: number) => {
|
||||
if (messageId) {
|
||||
set((state) => ({
|
||||
messages: state.messages.map(msg => {
|
||||
@@ -332,15 +330,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
abortController: null,
|
||||
}));
|
||||
} else {
|
||||
const { currentSession } = get();
|
||||
if (currentSession) {
|
||||
get().loadMessages(currentSession.id);
|
||||
}
|
||||
// 前端已通过流式获得完整内容,不需要从服务端重新加载
|
||||
set({ isStreaming: false, abortController: null });
|
||||
}
|
||||
|
||||
// 刷新会话列表以确保新会话显示在顶部
|
||||
get().loadSessions();
|
||||
},
|
||||
(error: string) => {
|
||||
// 流式错误
|
||||
@@ -414,36 +406,121 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// 重新生成消息(支持 assistant 消息 ID)
|
||||
// 重新生成消息(流式)
|
||||
regenerateMessage: async (messageId: number, model?: string) => {
|
||||
const { currentSession, messages } = get();
|
||||
if (!currentSession) return;
|
||||
|
||||
const messageIndex = messages.findIndex(msg => msg.id === messageId);
|
||||
if (messageIndex === -1) return;
|
||||
|
||||
// 找到该 assistant 消息之前的 user 消息内容
|
||||
const userContent = messages
|
||||
.slice(0, messageIndex)
|
||||
.reverse()
|
||||
.find(msg => msg.role === "user")?.content;
|
||||
if (!userContent) return;
|
||||
|
||||
// 删除该 assistant 消息及之后的所有消息,替换为流式占位符
|
||||
const trimmed = messages.slice(0, messageIndex);
|
||||
const assistantPlaceholder: ChatMessage = {
|
||||
id: Date.now() + 1,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const abortController = new AbortController();
|
||||
set({
|
||||
messages: [...trimmed, assistantPlaceholder],
|
||||
isStreaming: true,
|
||||
error: null,
|
||||
abortController,
|
||||
});
|
||||
|
||||
// 判断模式:检查之前的消息中是否有知识库相关内容
|
||||
const mode = "normal";
|
||||
|
||||
let thinkingSteps: any[] = [];
|
||||
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
const response = await chatAPI.regenerateMessage(messageId, model);
|
||||
|
||||
// 找到该消息,删除它及之后的所有消息
|
||||
const messageIndex = get().messages.findIndex(msg => msg.id === messageId);
|
||||
if (messageIndex !== -1) {
|
||||
set((state) => ({
|
||||
messages: state.messages.slice(0, messageIndex),
|
||||
}));
|
||||
}
|
||||
|
||||
// 添加新的助手回复
|
||||
const newMessage: ChatMessage = {
|
||||
id: response.new_message_id,
|
||||
role: "assistant",
|
||||
content: response.content,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
messages: [...state.messages, newMessage],
|
||||
isLoading: false,
|
||||
}));
|
||||
await chatAPI.streamMessage(
|
||||
userContent,
|
||||
currentSession.id,
|
||||
mode,
|
||||
undefined, // knowledgeBaseIds
|
||||
abortController.signal,
|
||||
(chunk: string) => {
|
||||
// 处理 chunk
|
||||
try {
|
||||
const data = JSON.parse(chunk);
|
||||
if (data.type === 'thinking') {
|
||||
thinkingSteps.push({
|
||||
stage: data.stage,
|
||||
message: data.message,
|
||||
doc_count: data.doc_count,
|
||||
time: data.time
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
set((state) => ({
|
||||
messages: state.messages.map(msg =>
|
||||
msg.id === assistantPlaceholder.id
|
||||
? { ...msg, thinking: [...thinkingSteps] }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
});
|
||||
} else if (data.type === 'chunk') {
|
||||
requestAnimationFrame(() => {
|
||||
set((state) => ({
|
||||
messages: state.messages.map(msg =>
|
||||
msg.id === assistantPlaceholder.id
|
||||
? { ...msg, content: msg.content + data.content }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 纯文本 chunk
|
||||
requestAnimationFrame(() => {
|
||||
set((state) => ({
|
||||
messages: state.messages.map(msg =>
|
||||
msg.id === assistantPlaceholder.id
|
||||
? { ...msg, content: msg.content + chunk }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
});
|
||||
}
|
||||
},
|
||||
(_sessionId: number, messageId?: number) => {
|
||||
// onComplete: 只替换 assistant 占位符 ID,不重新加载
|
||||
if (messageId) {
|
||||
set((state) => ({
|
||||
messages: state.messages.map(msg =>
|
||||
msg.id === assistantPlaceholder.id
|
||||
? { ...msg, id: messageId }
|
||||
: msg
|
||||
),
|
||||
isStreaming: false,
|
||||
abortController: null,
|
||||
}));
|
||||
} else {
|
||||
set({ isStreaming: false, abortController: null });
|
||||
}
|
||||
},
|
||||
(error: string) => {
|
||||
set({ error, isStreaming: false, abortController: null });
|
||||
},
|
||||
undefined, // onStatus
|
||||
undefined, // onThinking
|
||||
undefined, // onThinkingContent
|
||||
model
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("重新生成消息失败:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "重新生成消息失败";
|
||||
set({ error: errorMessage, isLoading: false });
|
||||
const errorMessage = error instanceof Error ? error.message : "重新生成失败";
|
||||
set({ error: errorMessage, isStreaming: false, abortController: null });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user