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:
|
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": "文档重新索引成功"}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user