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:
2026-05-27 11:33:32 +08:00
parent 50faf7ad4e
commit 62da8b3c58
5 changed files with 250 additions and 157 deletions
+12 -4
View File
@@ -161,11 +161,13 @@ class KnowledgeBaseService:
# 处理文档(向量化)- 在后台异步处理,不阻塞主流程 # 处理文档(向量化)- 在后台异步处理,不阻塞主流程
try: try:
# 使用同步方法,但不等待完成(在后台处理)
import threading import threading
import asyncio as _asyncio
def process_in_background(): def process_in_background():
try: 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: except Exception as e:
print(f"后台处理文档 {document.id} 失败: {e}") print(f"后台处理文档 {document.id} 失败: {e}")
@@ -198,9 +200,12 @@ class KnowledgeBaseService:
# 重新处理文档 - 在后台异步处理 # 重新处理文档 - 在后台异步处理
try: try:
import threading import threading
import asyncio as _asyncio
def process_in_background(): def process_in_background():
try: 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: except Exception as e:
print(f"后台处理文档 {document.id} 失败: {e}") print(f"后台处理文档 {document.id} 失败: {e}")
@@ -340,7 +345,10 @@ class KnowledgeBaseService:
self.db.query(DocumentChunk).filter(DocumentChunk.document_id == document_id).delete() 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: if success:
return {"success": True, "message": "文档重新索引成功"} return {"success": True, "message": "文档重新索引成功"}
+4 -3
View File
@@ -14,9 +14,10 @@ import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
export default function ChatPage() { export default function ChatPage() {
const router = useRouter(); const router = useRouter();
const { isAuthenticated, user, isLoading: authLoading } = useAuthStore(); const { isAuthenticated, user, isLoading: authLoading } = useAuthStore();
const { loadSessions, isLoading: chatLoading } = useChatStore(); const { loadSessions, sessions } = useChatStore();
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [initialLoading, setInitialLoading] = useState(true);
useEffect(() => { useEffect(() => {
if (!authLoading && !isAuthenticated) { if (!authLoading && !isAuthenticated) {
@@ -25,12 +26,12 @@ export default function ChatPage() {
} }
if (isAuthenticated && !isInitialized) { if (isAuthenticated && !isInitialized) {
loadSessions(); loadSessions().then(() => setInitialLoading(false));
setIsInitialized(true); setIsInitialized(true);
} }
}, [isAuthenticated, authLoading, isInitialized, router, loadSessions]); }, [isAuthenticated, authLoading, isInitialized, router, loadSessions]);
if (authLoading || chatLoading) { if (authLoading || initialLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
<LoadingSpinner size="lg" /> <LoadingSpinner size="lg" />
+106 -104
View File
@@ -25,11 +25,11 @@ const CATEGORY_ORDER = ["公告", "通知", "学习", "讨论", "课程", "反
function getCategoryIcon(name: string) { function getCategoryIcon(name: string) {
if (name.includes("公告") || name.includes("通知")) 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("课程")) 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("使用")) if (name.includes("反馈") || name.includes("使用"))
return <Lightbulb className="w-4 h-4" />; return <Lightbulb className="w-5 h-5" />;
return CATEGORY_ICONS.default; return CATEGORY_ICONS.default;
} }
@@ -106,110 +106,112 @@ export default function ForumHomePage() {
}, []); }, []);
return ( return (
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-5"> <div className="min-h-screen bg-background">
{isLoading ? ( <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex items-center justify-center gap-2 text-muted-foreground py-20"> {isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" /> <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>
<div className="text-center py-16"> ) : error ? (
<p className="text-muted-foreground">{error}</p> <div className="text-center py-16">
</div> <p className="text-muted-foreground">{error}</p>
) : ( </div>
<div className="space-y-3"> ) : (
{[...categories] <div className="space-y-5">
.sort((a, b) => { {[...categories]
const getOrder = (name: string) => { .sort((a, b) => {
for (let i = 0; i < CATEGORY_ORDER.length; i++) { const getOrder = (name: string) => {
if (name.includes(CATEGORY_ORDER[i])) return i; for (let i = 0; i < CATEGORY_ORDER.length; i++) {
} if (name.includes(CATEGORY_ORDER[i])) return i;
return CATEGORY_ORDER.length; }
}; return CATEGORY_ORDER.length;
return getOrder(a.name) - getOrder(b.name); };
}) return getOrder(a.name) - getOrder(b.name);
.map((category) => { })
const posts = categoryPosts[category.id] || []; .map((category) => {
return ( const posts = categoryPosts[category.id] || [];
<div return (
key={category.id} <div
className={`bg-gradient-to-r ${getCategoryGradient( key={category.id}
category.name className={`bg-gradient-to-r ${getCategoryGradient(
)} rounded-xl border border-border/40 overflow-hidden`} 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"> {/* Category header */}
<div className="flex items-center gap-2.5"> <div className="px-5 py-3.5 flex items-center justify-between border-b border-border/20">
<span className={getCategoryAccent(category.name)}> <div className="flex items-center gap-3">
{getCategoryIcon(category.name)} <span className={getCategoryAccent(category.name)}>
</span> {getCategoryIcon(category.name)}
<div> </span>
<h2 className="text-sm font-semibold text-foreground"> <div>
{category.name} <h2 className="text-base font-semibold text-foreground">
</h2> {category.name}
{category.description && ( </h2>
<p className="text-[11px] text-muted-foreground mt-0"> {category.description && (
{category.description} <p className="text-sm text-muted-foreground mt-0.5">
</p> {category.description}
)}
</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}
</p> </p>
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-muted-foreground"> )}
<span>{post.author_name}</span> </div>
<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>
</div> </div>
); );
} }
+10 -5
View File
@@ -4,10 +4,9 @@ import { useState, useRef, useEffect } from "react";
import { useChatStore } from "@/store/chat"; import { useChatStore } from "@/store/chat";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 MessageList from "./message-list";
import QuickQuestions from "./quick-questions"; import QuickQuestions from "./quick-questions";
import ModeSelector, { ChatMode } from "./mode-selector";
import ModelSelector from "./model-selector"; import ModelSelector from "./model-selector";
import KnowledgeSelector, { KnowledgeBase } from "./knowledge-selector"; import KnowledgeSelector, { KnowledgeBase } from "./knowledge-selector";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -16,7 +15,6 @@ import { knowledgeBaseAPI } from "@/lib/api";
export default function ChatInterface() { export default function ChatInterface() {
const [inputMessage, setInputMessage] = useState(""); const [inputMessage, setInputMessage] = useState("");
const [isComposing, setIsComposing] = useState(false); const [isComposing, setIsComposing] = useState(false);
const [chatMode, setChatMode] = useState<ChatMode>("normal");
const [selectedModel, setSelectedModel] = useState("deepseek-ai/DeepSeek-V3"); const [selectedModel, setSelectedModel] = useState("deepseek-ai/DeepSeek-V3");
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<string[]>([]); const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<string[]>([]);
const [systemKnowledgeBases, setSystemKnowledgeBases] = useState<KnowledgeBase[]>([]); const [systemKnowledgeBases, setSystemKnowledgeBases] = useState<KnowledgeBase[]>([]);
@@ -72,7 +70,8 @@ export default function ChatInterface() {
await selectSession(newSession.id); 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) => { const handleKeyPress = (e: React.KeyboardEvent) => {
@@ -124,7 +123,6 @@ export default function ChatInterface() {
{/* Settings panel — collapsible */} {/* Settings panel — collapsible */}
{showSettings && ( {showSettings && (
<div className="flex items-center gap-2 mb-3 pb-3 border-b border-border/30"> <div className="flex items-center gap-2 mb-3 pb-3 border-b border-border/30">
<ModeSelector mode={chatMode} onModeChange={setChatMode} />
<ModelSelector <ModelSelector
selectedModel={selectedModel} selectedModel={selectedModel}
onModelChange={setSelectedModel} onModelChange={setSelectedModel}
@@ -156,6 +154,12 @@ export default function ChatInterface() {
</Button> </Button>
<div className="flex-1 relative"> <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 <input
ref={inputRef} ref={inputRef}
value={inputMessage} value={inputMessage}
@@ -167,6 +171,7 @@ export default function ChatInterface() {
disabled={isLoading || isStreaming} disabled={isLoading || isStreaming}
className={cn( className={cn(
"w-full h-10 px-4 rounded-xl text-sm", "w-full h-10 px-4 rounded-xl text-sm",
selectedKnowledgeBases.length > 0 && "pt-4",
"bg-muted/40 border border-border/30", "bg-muted/40 border border-border/30",
"placeholder:text-muted-foreground/60", "placeholder:text-muted-foreground/60",
"focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30", "focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30",
+116 -39
View File
@@ -54,14 +54,12 @@ export const useChatStore = create<ChatStore>((set, get) => ({
// 加载会话列表 // 加载会话列表
loadSessions: async () => { loadSessions: async () => {
set({ isLoading: true, error: null });
try { try {
const sessions = await chatAPI.getSessions(); const sessions = await chatAPI.getSessions();
set({ sessions, isLoading: false }); set({ sessions });
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "加载会话失败"; 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) { if (messageId) {
set((state) => ({ set((state) => ({
messages: state.messages.map(msg => { messages: state.messages.map(msg => {
@@ -332,15 +330,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
abortController: null, abortController: null,
})); }));
} else { } else {
const { currentSession } = get(); // 前端已通过流式获得完整内容,不需要从服务端重新加载
if (currentSession) {
get().loadMessages(currentSession.id);
}
set({ isStreaming: false, abortController: null }); set({ isStreaming: false, abortController: null });
} }
// 刷新会话列表以确保新会话显示在顶部
get().loadSessions();
}, },
(error: string) => { (error: string) => {
// 流式错误 // 流式错误
@@ -414,36 +406,121 @@ export const useChatStore = create<ChatStore>((set, get) => ({
} }
}, },
// 重新生成消息(支持 assistant 消息 ID // 重新生成消息(流式
regenerateMessage: async (messageId: number, model?: string) => { 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 { try {
set({ isLoading: true, error: null }); await chatAPI.streamMessage(
const response = await chatAPI.regenerateMessage(messageId, model); userContent,
currentSession.id,
// 找到该消息,删除它及之后的所有消息 mode,
const messageIndex = get().messages.findIndex(msg => msg.id === messageId); undefined, // knowledgeBaseIds
if (messageIndex !== -1) { abortController.signal,
set((state) => ({ (chunk: string) => {
messages: state.messages.slice(0, messageIndex), // 处理 chunk
})); try {
} const data = JSON.parse(chunk);
if (data.type === 'thinking') {
// 添加新的助手回复 thinkingSteps.push({
const newMessage: ChatMessage = { stage: data.stage,
id: response.new_message_id, message: data.message,
role: "assistant", doc_count: data.doc_count,
content: response.content, time: data.time
created_at: new Date().toISOString(), });
}; requestAnimationFrame(() => {
set((state) => ({
set((state) => ({ messages: state.messages.map(msg =>
messages: [...state.messages, newMessage], msg.id === assistantPlaceholder.id
isLoading: false, ? { ...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) { } catch (error) {
console.error("重新生成消息失败:", error); const errorMessage = error instanceof Error ? error.message : "重新生成失败";
const errorMessage = error instanceof Error ? error.message : "重新生成消息失败"; set({ error: errorMessage, isStreaming: false, abortController: null });
set({ error: errorMessage, isLoading: false });
} }
}, },