From 0251a5e3ffbe3e7c3ba8b15b6451476f992a31dc Mon Sep 17 00:00:00 2001 From: xiaopeng <1509442308@qq.com> Date: Wed, 27 May 2026 09:00:31 +0800 Subject: [PATCH] fix: propagate real message IDs via SSE and fix regenerate model parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add message_id and user_message_id to SSE done events so frontend can replace temp IDs with real DB IDs, fixing "消息不存在" on regenerate - Replace Body(default=None) with RegenerateRequest Pydantic model for proper JSON body parsing, fixing 422 Unprocessable Content - Frontend always sends model in regenerate request body - Pass selected model through message-item → regenerateMessage chain - Various UI refinements to chat sidebar, quick questions, and layout Co-Authored-By: Claude Opus 4.7 --- backend/src/api/chat.py | 61 ++- web/src/app/(main)/chat/page.tsx | 51 ++- web/src/app/(main)/course-content/page.tsx | 7 - web/src/components/chat/chat-interface.tsx | 461 ++++++-------------- web/src/components/chat/message-item.tsx | 300 +++++-------- web/src/components/chat/message-list.tsx | 7 +- web/src/components/chat/quick-questions.tsx | 40 +- web/src/components/chat/sidebar.tsx | 393 +++++++---------- web/src/lib/api.ts | 29 +- web/src/store/chat.ts | 58 +-- 10 files changed, 549 insertions(+), 858 deletions(-) diff --git a/backend/src/api/chat.py b/backend/src/api/chat.py index 5476f37..e7efece 100644 --- a/backend/src/api/chat.py +++ b/backend/src/api/chat.py @@ -282,9 +282,10 @@ async def stream_message( ) db.add(assistant_message) db.commit() - - yield f"data: {json.dumps({'type': 'done', 'session_id': session.id}, ensure_ascii=False)}\n\n" - + db.refresh(assistant_message) + + yield f"data: {json.dumps({'type': 'done', 'session_id': session.id, 'message_id': assistant_message.id, 'user_message_id': user_message.id}, ensure_ascii=False)}\n\n" + else: # 普通模式:使用LangChain 1.0对话链 print(f"[DEBUG-CHAT] 普通模式 - 使用LangChain流式对话链") @@ -321,9 +322,10 @@ async def stream_message( ) db.add(assistant_message) db.commit() - - yield f"data: {json.dumps({'type': 'done', 'session_id': session.id}, ensure_ascii=False)}\n\n" - + db.refresh(assistant_message) + + yield f"data: {json.dumps({'type': 'done', 'session_id': session.id, 'message_id': assistant_message.id, 'user_message_id': user_message.id}, ensure_ascii=False)}\n\n" + except Exception as e: yield f"data: {json.dumps({'error': str(e)})}\n\n" finally: @@ -555,6 +557,11 @@ class MessageFeedbackRequest(BaseModel): feedback: str # like, dislike +class RegenerateRequest(BaseModel): + """重新生成请求模型""" + model: Optional[str] = None + + @router.put("/messages/{message_id}") async def edit_message( message_id: int, @@ -588,21 +595,40 @@ async def edit_message( async def regenerate_message( message_id: int, current_user: str = Depends(get_current_user), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + request: RegenerateRequest = None, ): - """重新生成AI回复""" - # 获取原始消息 - original_message = db.query(ChatMessage).join(ChatSession).filter( + """重新生成AI回复 — 支持 user 和 assistant 消息 ID""" + model = request.model if request else None + # 查找目标消息 + target_message = db.query(ChatMessage).join(ChatSession).filter( ChatMessage.id == message_id, - ChatMessage.role == "user", ChatSession.user_id == get_user_id_by_username(db, current_user) ).first() - - if not original_message: + + if not target_message: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="消息不存在" ) + + # 如果传入的是 assistant 消息,找到同 session 中前一条 user 消息 + if target_message.role == "assistant": + original_message = db.query(ChatMessage).filter( + ChatMessage.session_id == target_message.session_id, + ChatMessage.role == "user", + ChatMessage.created_at < target_message.created_at + ).order_by(ChatMessage.created_at.desc()).first() + + if not original_message: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="找不到对应的用户消息" + ) + # 删除该 assistant 消息本身 + db.delete(target_message) + else: + original_message = target_message # 删除该消息之后的所有消息 later_messages = db.query(ChatMessage).filter( @@ -620,7 +646,8 @@ async def regenerate_message( original_message.content, original_message.session_id, db, - None # No knowledge base filtering for regeneration + None, # No knowledge base filtering for regeneration + model ) # 创建新的AI回复 @@ -679,7 +706,7 @@ async def feedback_message( return {"message": "反馈提交成功", "feedback": request.feedback} -async def run_rag_workflow_with_context(question: str, session_id: int, db: Session, knowledge_base_ids: Optional[List[int]] = None) -> Dict[str, Any]: +async def run_rag_workflow_with_context(question: str, session_id: int, db: Session, knowledge_base_ids: Optional[List[int]] = None, model: Optional[str] = None) -> Dict[str, Any]: """运行带上下文的RAG工作流""" try: # 获取会话历史消息作为上下文 @@ -696,7 +723,7 @@ async def run_rag_workflow_with_context(question: str, session_id: int, db: Sess context_messages.append({"role": "assistant", "content": msg.content}) # 运行RAG工作流 - rag_chain = create_rag_chain(knowledge_base_ids=knowledge_base_ids) + rag_chain = create_rag_chain(knowledge_base_ids=knowledge_base_ids, model=model) result = rag_chain.invoke(question) # 如果有上下文,增强回答 @@ -710,5 +737,5 @@ async def run_rag_workflow_with_context(question: str, session_id: int, db: Sess except Exception as e: print(f"RAG工作流执行失败: {str(e)}") # 降级到基础问答 - rag_chain = create_rag_chain() + rag_chain = create_rag_chain(model=model) return rag_chain.invoke(question) diff --git a/web/src/app/(main)/chat/page.tsx b/web/src/app/(main)/chat/page.tsx index bad0e80..e313653 100644 --- a/web/src/app/(main)/chat/page.tsx +++ b/web/src/app/(main)/chat/page.tsx @@ -8,12 +8,15 @@ import ChatInterface from "@/components/chat/chat-interface"; import Sidebar from "@/components/chat/sidebar"; import MobileNav from "@/components/layout/mobile-nav"; import LoadingSpinner from "@/components/ui/loading-spinner"; +import { Button } from "@/components/ui/button"; +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 [isInitialized, setIsInitialized] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(true); useEffect(() => { if (!authLoading && !isAuthenticated) { @@ -22,7 +25,6 @@ export default function ChatPage() { } if (isAuthenticated && !isInitialized) { - // 加载聊天会话 loadSessions(); setIsInitialized(true); } @@ -41,18 +43,45 @@ export default function ChatPage() { } return ( -
- {/* 侧边栏 */} -
- -
- - {/* 主聊天区域 */} -
- +
+
+ {/* Mobile overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Sidebar — persistent on desktop, overlay on mobile */} +
+ setSidebarOpen(false)} /> +
+ + {/* Main chat area */} +
+ {/* Toggle button */} + {!sidebarOpen && ( + + )} + + +
- {/* 移动端导航 */}
); diff --git a/web/src/app/(main)/course-content/page.tsx b/web/src/app/(main)/course-content/page.tsx index 7117267..1d40173 100644 --- a/web/src/app/(main)/course-content/page.tsx +++ b/web/src/app/(main)/course-content/page.tsx @@ -69,13 +69,6 @@ export default function CourseContentPage() { setIsLoading(true); setError(null); const structure = await courseContentAPI.getCourseContent(); - console.log("加载的书籍结构:", structure); - console.log("章节数量:", structure?.chapters?.length || 0); - if (structure?.chapters) { - structure.chapters.forEach((chapter, index) => { - console.log(`章节 ${index + 1}:`, chapter.title, "节数:", chapter.sections?.length || 0); - }); - } setBookStructure(structure); } catch (err) { console.error("加载课程内容失败:", err); diff --git a/web/src/components/chat/chat-interface.tsx b/web/src/components/chat/chat-interface.tsx index 76c3ca9..f46cca5 100644 --- a/web/src/components/chat/chat-interface.tsx +++ b/web/src/components/chat/chat-interface.tsx @@ -4,7 +4,7 @@ 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, Loader2, Bot, User, StopCircle, ArrowDown, Paperclip } from "lucide-react"; +import { Send, Square, Bot, ChevronDown, Settings2 } from "lucide-react"; import MessageList from "./message-list"; import QuickQuestions from "./quick-questions"; import ModeSelector, { ChatMode } from "./mode-selector"; @@ -21,9 +21,12 @@ export default function ChatInterface() { const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState([]); const [systemKnowledgeBases, setSystemKnowledgeBases] = useState([]); const [userKnowledgeBases, setUserKnowledgeBases] = useState([]); + const [showScrollDown, setShowScrollDown] = useState(false); + const [showSettings, setShowSettings] = useState(false); const inputRef = useRef(null); const messagesEndRef = useRef(null); - + const scrollContainerRef = useRef(null); + const { currentSession, messages, @@ -36,91 +39,44 @@ export default function ChatInterface() { selectSession, } = useChatStore(); - // 调试日志 - useEffect(() => { - console.log("[DEBUG-CHAT-INTERFACE] 组件状态:", { - currentSession: currentSession?.id, - messagesCount: messages.length, - isLoading, - isStreaming, - messages: messages.map(m => ({ id: m.id, role: m.role, contentLength: m.content.length })) - }); - }, [currentSession, messages, isLoading, isStreaming]); - - // 自动滚动到底部 - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ - behavior: isStreaming ? "auto" : "smooth" - }); - }, [messages, isStreaming]); - - // 加载知识库(区分系统知识库和用户知识库) useEffect(() => { const loadKnowledgeBases = async () => { try { const bases = await knowledgeBaseAPI.getKnowledgeBases(); - - console.log("[DEBUG] 加载的知识库:", bases.map(kb => ({ - id: kb.id, - name: kb.name, - is_system: kb.is_system - }))); - - // 转换为KnowledgeSelector需要的格式 const formattedBases: KnowledgeBase[] = bases.map(kb => ({ id: kb.id.toString(), name: kb.name, description: kb.description || "", documentCount: kb.document_count, - enabled: true, // 默认启用 - isSystem: kb.is_system === true, // 明确检查是否为 true + enabled: true, + isSystem: kb.is_system === true, })); - - // 分离系统知识库和用户知识库 - const systemBases = formattedBases.filter(kb => kb.isSystem === true); - const userBases = formattedBases.filter(kb => kb.isSystem !== true); - - console.log("[DEBUG] 系统知识库:", systemBases.map(kb => ({ id: kb.id, name: kb.name }))); - console.log("[DEBUG] 用户知识库:", userBases.map(kb => ({ id: kb.id, name: kb.name }))); - - setSystemKnowledgeBases(systemBases); - setUserKnowledgeBases(userBases); + setSystemKnowledgeBases(formattedBases.filter(kb => kb.isSystem)); + setUserKnowledgeBases(formattedBases.filter(kb => !kb.isSystem)); } catch (error) { console.error("加载知识库失败:", error); } }; - loadKnowledgeBases(); }, []); const handleSendMessage = async () => { if (!inputMessage.trim() || isLoading || isStreaming) return; - const message = inputMessage.trim(); setInputMessage(""); + setShowSettings(false); - console.log("[DEBUG-CHAT] 发送消息:", { - message: message, - chatMode: chatMode, - selectedKnowledgeBases: selectedKnowledgeBases, - selectedModel: selectedModel, - currentSession: currentSession?.id - }); - - // 如果没有当前会话,先创建一个新会话 if (!currentSession) { const newSession = await createSession("新对话"); if (newSession) { await selectSession(newSession.id); } } - - // 使用流式发送,传递聊天模式、选中的知识库ID和模型ID await streamMessage(message, chatMode, selectedKnowledgeBases, selectedModel); }; const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { + if (e.key === "Enter" && !e.shiftKey && !isComposing) { e.preventDefault(); handleSendMessage(); } @@ -128,295 +84,164 @@ export default function ChatInterface() { const handleQuickQuestion = async (question: string) => { setInputMessage(question); - - // 聚焦到输入框 - setTimeout(() => { - if (inputRef.current) { - inputRef.current.focus(); - } - }, 100); + setTimeout(() => inputRef.current?.focus(), 100); }; - // Debug logging - console.log("[CHAT-INTERFACE] Current state:", { - hasSession: !!currentSession, - sessionId: currentSession?.id, - messagesCount: messages.length - }); + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + const handleScroll = () => { + if (!scrollContainerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; + setShowScrollDown(scrollHeight - scrollTop - clientHeight > 120); + }; + + useEffect(() => { + if (isStreaming) { + messagesEndRef.current?.scrollIntoView({ behavior: "auto" }); + } + }, [messages, isStreaming]); + + const renderWelcome = (size: "lg" | "sm") => ( +
+
+ +
+

+ 国土空间规划课程智能体 +

+

+ 基于大模型的智能问答系统,为您提供专业的国土空间规划知识服务 +

+ +
+ ); + + const renderInputArea = () => ( +
+
+ {/* Settings panel — collapsible */} + {showSettings && ( +
+ + + +
+ )} + + {/* Input row */} +
+ + +
+ setInputMessage(e.target.value)} + onKeyDown={handleKeyPress} + onCompositionStart={() => setIsComposing(true)} + onCompositionEnd={() => setIsComposing(false)} + placeholder="输入您的问题…" + disabled={isLoading || isStreaming} + className={cn( + "w-full h-10 px-4 rounded-xl text-sm", + "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", + "transition-all duration-150" + )} + /> +
+ + +
+
+
+ ); + + // Empty state — no session selected if (!currentSession) { return (
- {/* 上部:欢迎内容区域 - 使用 flex-1 + 可滚动 */}
-
-
- -
-

- 欢迎使用国土空间规划课程智能体 -

-

- 基于大模型的智能问答系统,为您提供专业的国土空间规划知识服务 -

- -
-
-
- - {/* 底部:输入区域 - 不使用 sticky,直接作为 flex 子元素 */} -
- {/* 功能选择器 */} -
-
- - - - -
-
- - {/* 输入框和发送按钮 */} -
-
-
- setInputMessage(e.target.value)} - onKeyPress={handleKeyPress} - onCompositionStart={() => setIsComposing(true)} - onCompositionEnd={() => setIsComposing(false)} - placeholder="输入您的问题..." - disabled={isLoading || isStreaming} - className={cn( - "min-h-[48px] resize-none touch-manipulation pr-12 rounded-xl", - "border-border/50 bg-background/80 backdrop-blur-sm", - "focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500/50", - "transition-all duration-200" - )} - /> - {isStreaming && ( -
-
-
-
-
-
-
- )} -
-
- -
- - {/* 输入提示 */} -
- 按 Enter 发送,Shift + Enter 换行 - 点击发送按钮发送消息 - {isStreaming && ( - -
- 正在生成回复... - - )} + {renderWelcome("lg")}
+ {renderInputArea()}
); } return ( -
- {/* 聊天头部 - 不需要 sticky,作为 flex 子元素自然在顶部 */} -
-
-
-
- -
-
-

- {currentSession.title} -

-
-
- - {/* 操作按钮 */} -
- {isStreaming && ( - - )} - -
-
-
+
+ {/* Scroll-to-bottom */} + {showScrollDown && ( + + )} - {/* 消息列表 - 使用 flex-1 占据剩余空间 + 可滚动 */} -
+ {/* Messages */} +
{messages.length === 0 ? (
-
-
- -
-

- 开始对话 -

-

- 输入您的问题,我将为您提供专业的国土空间规划知识解答 -

- -
+ {renderWelcome("sm")}
) : ( <> - +
)}
- {/* 输入区域 - 不需要 sticky,作为 flex 子元素自然在底部 */} -
- {/* 功能选择器 */} -
-
- - - - -
-
- - {/* 输入框和发送按钮 */} -
-
-
- setInputMessage(e.target.value)} - onKeyPress={handleKeyPress} - onCompositionStart={() => setIsComposing(true)} - onCompositionEnd={() => setIsComposing(false)} - placeholder="输入您的问题..." - disabled={isLoading || isStreaming} - className={cn( - "min-h-[48px] resize-none touch-manipulation pr-12 rounded-xl", - "border-border/50 bg-background/80 backdrop-blur-sm", - "focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500/50", - "transition-all duration-200" - )} - /> - {isStreaming && ( -
-
-
-
-
-
-
- )} -
-
- -
- - {/* 输入提示 */} -
- 按 Enter 发送,Shift + Enter 换行 - 点击发送按钮发送消息 - {isStreaming && ( - -
- 正在生成回复... - - )} -
-
+ {renderInputArea()}
); } diff --git a/web/src/components/chat/message-item.tsx b/web/src/components/chat/message-item.tsx index 31a378f..be5e7b5 100644 --- a/web/src/components/chat/message-item.tsx +++ b/web/src/components/chat/message-item.tsx @@ -1,7 +1,8 @@ "use client"; import { ChatMessage, ThinkingStep } from "@/types"; -import { User, Bot, MoreVertical, ThumbsUp, ThumbsDown, Copy, Edit, RotateCcw, Trash2, Loader2, CheckCircle2, FileSearch, Brain, Sparkles } from "lucide-react"; +import { User, Bot, Copy, Edit, RotateCcw, ThumbsUp, ThumbsDown, Loader2, CheckCircle2, FileSearch, Brain, Sparkles } from "lucide-react"; +import { cn } from "@/lib/utils"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; @@ -9,12 +10,6 @@ import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism"; import { formatDistanceToNow } from "date-fns"; import { zhCN } from "date-fns/locale"; import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { useChatStore } from "@/store/chat"; import SourceReferences from "./source-references"; import { useState } from "react"; @@ -22,84 +17,64 @@ import { toast } from "sonner"; interface MessageItemProps { message: ChatMessage; + selectedModel?: string; } -// 思考过程组件 const ThinkingProcess = ({ thinking }: { thinking: ThinkingStep[] }) => { if (!thinking || thinking.length === 0) return null; - + const getStageIcon = (stage: string) => { switch (stage) { - case 'understanding': return ; - case 'retrieving': return ; - case 'retrieved': return ; - case 'generating': return ; - default: return ; + case 'understanding': return ; + case 'retrieving': return ; + case 'retrieved': return ; + case 'generating': return ; + default: return ; } }; - + return ( -
+
{thinking.map((step, index) => (
{getStageIcon(step.stage)} {step.message} - {step.time && ( - ({step.time}s) - )} + {step.time && ({step.time}s)}
))}
); }; -export default function MessageItem({ message }: MessageItemProps) { +export default function MessageItem({ message, selectedModel }: MessageItemProps) { const isUser = message.role === "user"; const isAssistant = message.role === "assistant"; - const { editMessage, regenerateMessage, feedbackMessage, isStreaming } = useChatStore(); + const { editMessage, regenerateMessage, feedbackMessage } = useChatStore(); const [isEditing, setIsEditing] = useState(false); const [editContent, setEditContent] = useState(message.content); - // 添加调试信息 - console.log(`[DEBUG-MESSAGE] 渲染消息 ${message.id}:`, { - role: message.role, - contentLength: message.content.length, - contentPreview: message.content.substring(0, 50) + "..." - }); - const handleCopy = async () => { try { await navigator.clipboard.writeText(message.content); - toast.success("已复制到剪贴板"); - } catch (error) { + toast.success("已复制"); + } catch { toast.error("复制失败"); } }; - const handleEdit = () => { - setIsEditing(true); - }; - const handleSaveEdit = async () => { try { await editMessage(message.id, editContent); setIsEditing(false); - toast.success("消息已更新"); - } catch (error) { + } catch { toast.error("编辑失败"); } }; - const handleCancelEdit = () => { - setEditContent(message.content); - setIsEditing(false); - }; - const handleRegenerate = async () => { try { - await regenerateMessage(message.id); - toast.success("正在重新生成回复"); - } catch (error) { + await regenerateMessage(message.id, selectedModel); + } catch { toast.error("重新生成失败"); } }; @@ -107,50 +82,62 @@ export default function MessageItem({ message }: MessageItemProps) { const handleFeedback = async (feedback: "like" | "dislike") => { try { await feedbackMessage(message.id, feedback); - toast.success("感谢您的反馈"); - } catch (error) { - toast.error("反馈提交失败"); + } catch { + toast.error("反馈失败"); } }; return ( -
- {isAssistant && ( -
-
- -
-
- )} - -
-
- {isEditing ? ( -
-