diff --git a/backend/app/api/v1/chat.py b/backend/app/api/v1/chat.py index 5dd32d3..eebcec1 100644 --- a/backend/app/api/v1/chat.py +++ b/backend/app/api/v1/chat.py @@ -35,6 +35,20 @@ router = APIRouter() logger = logging.getLogger(__name__) CHAT_HISTORY_MESSAGE_LIMIT = 20 +# 生成被客户端中断时写入助手消息的统一标记,前端据此识别“已停止生成” +INTERRUPTED_RESPONSE_MARKER = "interrupt" + + +def _is_interrupted_content(content: str) -> bool: + """判断助手消息内容是否带中断标记。 + + 中断标记要么单独成文(内容为空时),要么以 \\n\\n 追加在内容末尾。 + """ + if not content: + return False + return content == INTERRUPTED_RESPONSE_MARKER or content.endswith( + f"\n\n{INTERRUPTED_RESPONSE_MARKER}" + ) class VectorizeRequest(BaseModel): @@ -132,6 +146,8 @@ class ChatMessageRequest(BaseModel): """发送聊天消息请求""" session_id: int message: str + # 是否同时写入一条新的用户消息。重新生成旧回复时应传 false,避免用户提问重复入库。 + insert_user_message: bool = True class ChatSessionUpdateRequest(BaseModel): @@ -527,6 +543,11 @@ async def send_chat_message( conversation_history = [ {"role": m.role, "content": m.content} for m in prev_messages + # 被中断的助手消息(含统一中断标记)不参与上下文,避免污染后续模型输入 + if not ( + m.role == "assistant" + and _is_interrupted_content(m.content or "") + ) ] retrieved_docs = await rag_service.retrieve_documents( @@ -615,35 +636,67 @@ async def send_chat_message_stream( conversation_history = [ {"role": m.role, "content": m.content} for m in prev_messages + # 被中断的助手消息(含统一中断标记)不参与上下文,避免污染后续模型输入 + if not ( + m.role == "assistant" + and _is_interrupted_content(m.content or "") + ) ] - retrieved_docs = await rag_service.retrieve_documents( - db, - session.project_id, - question, - top_k=5, - ) - - # 检索成功后立即持久化用户提问,并同时创建一条空的助手消息占位行。 - # 二者都即时入库:这样即便流式输出中途刷新页面,刷新后也能看到提问, - # 以及助手已经生成的部分内容(边生成边回写到该占位行)。 - query_message = ChatMessage( - session_id=req.session_id, - role="user", - content=question, - ) + # 先持久化用户提问和空的助手消息占位行,再执行向量检索。 + # 占位行先入库后,即使客户端在检索阶段断开(停止生成), + # 前端也能拿到真实消息 id 并正确标记“已停止”,而不会留下悬空占位。 assistant_message = ChatMessage( session_id=req.session_id, role="assistant", content="", ) - db.add_all([query_message, assistant_message]) - session.message_count = (session.message_count or 0) + 2 + if req.insert_user_message: + query_message = ChatMessage( + session_id=req.session_id, + role="user", + content=question, + ) + db.add(query_message) + db.add(assistant_message) + session.message_count = (session.message_count or 0) + (2 if req.insert_user_message else 1) await db.commit() - user_message_id = query_message.id + user_message_id = query_message.id if req.insert_user_message else None assistant_message_id = assistant_message.id + async def _mark_interrupted_assistant(): + """把中断标记写入仍为空的助手占位行(断开连接时任务可能正被取消)。""" + try: + msg = await db.get(ChatMessage, assistant_message_id) + if msg is not None and not (msg.content or "").strip(): + msg.content = INTERRUPTED_RESPONSE_MARKER + await db.commit() + except Exception as save_exc: # noqa: BLE001 + logger.warning("保存中断状态失败: %s", save_exc) + + try: + retrieved_docs = await rag_service.retrieve_documents( + db, + session.project_id, + question, + top_k=5, + ) + except asyncio.CancelledError: + # 客户端在向量检索阶段断开(停止生成):任务被取消,占位行需写入中断标记。 + await _mark_interrupted_assistant() + raise + except Exception: + # 占位行已入库:把失败原因写进助手消息,避免留下永远显示“正在思考”的空行。 + try: + msg = await db.get(ChatMessage, assistant_message_id) + if msg is not None: + msg.content = "回答生成失败,请稍后重试。" + await db.commit() + except Exception as save_exc: # noqa: BLE001 + logger.warning("保存检索失败状态失败: %s", save_exc) + raise + llm_config_id = session.llm_config_id project_id = session.project_id # 每累计这么多字符就回写一次占位行,平衡「刷新可见性」与「写库频率」 @@ -720,7 +773,9 @@ async def send_chat_message_stream( # 由于提问与占位行已入库,刷新后至少能看到提问与已生成内容。 if not finished: if not assistant_response_parts: - assistant_response_parts.append("回答生成已中断,请重新提问。") + assistant_response_parts.append(INTERRUPTED_RESPONSE_MARKER) + else: + assistant_response_parts.append(f"\n\n{INTERRUPTED_RESPONSE_MARKER}") try: await db.rollback() await _persist_assistant(assistant_response_parts, completed=False) @@ -795,6 +850,48 @@ async def delete_chat_message( return success_response(message="消息已删除") +@router.post("/messages/{message_id}/interrupt", response_model=dict) +async def mark_message_interrupted( + message_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """把生成被中断的助手消息标记为已停止。 + + 前端在点击“停止生成”后立即调用本接口,把统一中断标记写入数据库, + 避免依赖连接断开后生成器的兜底写入(存在时间差),保证切换会话或刷新后 + 仍能识别该消息为“已停止生成”。 + """ + stmt = ( + select(ChatMessage, ChatSession) + .join(ChatSession, ChatMessage.session_id == ChatSession.id) + .where(ChatMessage.id == message_id) + ) + result = await db.execute(stmt) + row = result.first() + + if not row: + raise HTTPException(status_code=404, detail="消息不存在") + + msg, session = row + if session.user_id != current_user.id: + raise HTTPException(status_code=403, detail="无权操作该消息") + if msg.role != "assistant": + raise HTTPException(status_code=400, detail="仅助手消息可标记为已停止") + + content = msg.content or "" + if not content.strip(): + msg.content = INTERRUPTED_RESPONSE_MARKER + elif not _is_interrupted_content(content): + msg.content = f"{content.rstrip()}\n\n{INTERRUPTED_RESPONSE_MARKER}" + await db.commit() + + return success_response(data={ + "message_id": msg.id, + "content": msg.content, + }) + + @router.put("/sessions/{session_id}", response_model=dict) async def update_chat_session( session_id: int, diff --git a/frontend/src/api/chat.js b/frontend/src/api/chat.js index 621210e..de49dd0 100644 --- a/frontend/src/api/chat.js +++ b/frontend/src/api/chat.js @@ -39,7 +39,9 @@ export const sendChatMessageStream = async (sessionId, message, handlers = {}) = body: JSON.stringify({ session_id: sessionId, message, + ...(handlers.insertUserMessage === false ? { insert_user_message: false } : {}), }), + signal: handlers.signal, }) if (!response.ok || !response.body) { @@ -106,6 +108,10 @@ export const deleteChatMessage = (messageId) => { return request.delete(`/chat/messages/${messageId}`) } +export const markMessageInterrupted = (messageId) => { + return request.post(`/chat/messages/${messageId}/interrupt`) +} + export const updateChatSessionTitle = (sessionId, title) => { return request.put(`/chat/sessions/${sessionId}`, { title, diff --git a/frontend/src/components/ActionHelpPanel/ActionHelpPanel.css b/frontend/src/components/ActionHelpPanel/ActionHelpPanel.css index cd554aa..7a6c645 100644 --- a/frontend/src/components/ActionHelpPanel/ActionHelpPanel.css +++ b/frontend/src/components/ActionHelpPanel/ActionHelpPanel.css @@ -71,7 +71,7 @@ /* 帮助区块样式 */ .help-section { padding: 16px; - background: #f8f9fa; + background: var(--bg-color-secondary); border-radius: 8px; border-left: 3px solid #1677ff; } @@ -87,7 +87,7 @@ gap: 6px; font-size: 14px; font-weight: 600; - color: rgba(0, 0, 0, 0.88); + color: var(--text-color); margin-bottom: 12px; } @@ -207,23 +207,23 @@ flex: 1; font-size: 14px; font-weight: 500; - color: rgba(0, 0, 0, 0.88); + color: var(--text-color); } .help-action-item-shortcut { padding: 2px 6px; - background: #f0f0f0; + background: var(--bg-color-secondary); border: 1px solid var(--border-color-strong); border-radius: 4px; font-size: 11px; font-family: 'Monaco', 'Consolas', monospace; - color: rgba(0, 0, 0, 0.65); + color: var(--text-color-secondary); } .help-action-item-desc { font-size: 12px; line-height: 1.6; - color: rgba(0, 0, 0, 0.45); + color: var(--text-color-secondary); padding-left: 24px; } @@ -234,11 +234,16 @@ .action-help-panel .ant-collapse-ghost > .ant-collapse-item > .ant-collapse-header { padding: 12px 16px; - background: #fafafa; + background: var(--bg-color-secondary); border-radius: 8px; font-weight: 500; } +body.dark .help-section-warning { + background: rgba(250, 173, 20, 0.12); + border-left-color: #faad14; +} + .action-help-panel .ant-collapse-ghost > .ant-collapse-item > .ant-collapse-content { padding-top: 12px; } diff --git a/frontend/src/components/ButtonWithGuide/ButtonWithGuide.css b/frontend/src/components/ButtonWithGuide/ButtonWithGuide.css index 8a5bae1..1d5ba9a 100644 --- a/frontend/src/components/ButtonWithGuide/ButtonWithGuide.css +++ b/frontend/src/components/ButtonWithGuide/ButtonWithGuide.css @@ -70,7 +70,7 @@ .guide-section { margin-bottom: 20px; padding: 16px; - background: #f8f9fa; + background: var(--bg-color-secondary); border-radius: 8px; border-left: 3px solid #1677ff; } @@ -90,10 +90,15 @@ gap: 8px; font-size: 14px; font-weight: 600; - color: rgba(0, 0, 0, 0.88); + color: var(--text-color); margin-bottom: 12px; } +body.dark .guide-section-warning { + background: rgba(250, 173, 20, 0.12); + border-left-color: #faad14; +} + .guide-section-icon { font-size: 16px; color: #1677ff; diff --git a/frontend/src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.css b/frontend/src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.css index cd15b96..ca901bf 100644 --- a/frontend/src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.css +++ b/frontend/src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.css @@ -117,7 +117,7 @@ .guide-section { margin-bottom: 20px; padding: 16px; - background: #f8f9fa; + background: var(--bg-color-secondary); border-radius: 8px; border-left: 3px solid #1677ff; } @@ -137,7 +137,7 @@ gap: 8px; font-size: 14px; font-weight: 600; - color: rgba(0, 0, 0, 0.88); + color: var(--text-color); margin-bottom: 12px; } @@ -150,6 +150,11 @@ color: #faad14; } +body.dark .guide-section-warning { + background: rgba(250, 173, 20, 0.12); + border-left-color: #faad14; +} + .guide-section-content { margin: 0; font-size: 14px; diff --git a/frontend/src/components/ButtonWithHoverCard/ButtonWithHoverCard.css b/frontend/src/components/ButtonWithHoverCard/ButtonWithHoverCard.css index 6b27c0b..390ccba 100644 --- a/frontend/src/components/ButtonWithHoverCard/ButtonWithHoverCard.css +++ b/frontend/src/components/ButtonWithHoverCard/ButtonWithHoverCard.css @@ -84,14 +84,14 @@ margin: 0; font-size: 13px; line-height: 1.6; - color: rgba(0, 0, 0, 0.65); + color: var(--text-color-secondary); } /* 卡片区块 */ .hover-card-section { margin-top: 12px; padding: 10px; - background: #f8f9fa; + background: var(--bg-color-secondary); border-radius: 8px; border-left: 3px solid #1677ff; } @@ -107,10 +107,15 @@ gap: 6px; font-size: 12px; font-weight: 600; - color: rgba(0, 0, 0, 0.88); + color: var(--text-color); margin-bottom: 8px; } +body.dark .hover-card-warning { + background: rgba(250, 173, 20, 0.12); + border-left-color: #faad14; +} + .section-icon { font-size: 12px; color: #1677ff; diff --git a/frontend/src/components/ConfirmDialog/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog/ConfirmDialog.jsx index d7095b0..528ef63 100644 --- a/frontend/src/components/ConfirmDialog/ConfirmDialog.jsx +++ b/frontend/src/components/ConfirmDialog/ConfirmDialog.jsx @@ -22,10 +22,10 @@ const ConfirmDialog = { content: (

您确定要删除以下项目吗?

-
+

{itemName}

{itemInfo && ( -

{itemInfo}

+

{itemInfo}

)}

@@ -56,7 +56,7 @@ const ConfirmDialog = { style={{ marginTop: 12, padding: 12, - background: '#f5f5f5', + background: 'var(--bg-color-secondary)', borderRadius: 6, maxHeight: 200, overflowY: 'auto', @@ -67,12 +67,12 @@ const ConfirmDialog = { key={index} style={{ padding: '6px 0', - borderBottom: index < items.length - 1 ? '1px solid #e8e8e8' : 'none', + borderBottom: index < items.length - 1 ? '1px solid var(--border-color)' : 'none', }} > {item.name} {item.info && ( - + ({item.info}) )} diff --git a/frontend/src/components/DetailDrawer/DetailDrawer.css b/frontend/src/components/DetailDrawer/DetailDrawer.css index 0cf2fee..062d149 100644 --- a/frontend/src/components/DetailDrawer/DetailDrawer.css +++ b/frontend/src/components/DetailDrawer/DetailDrawer.css @@ -11,7 +11,7 @@ justify-content: space-between; align-items: center; padding: 16px; - background: #fafafa; + background: var(--bg-color-secondary); border-bottom: 1px solid var(--border-color); flex-shrink: 0; } @@ -24,7 +24,7 @@ .detail-drawer-close-button { font-size: 18px; - color: #666; + color: var(--text-color-secondary); } .detail-drawer-close-button:hover { @@ -70,7 +70,7 @@ /* 标签页区域 */ .detail-drawer-tabs { - background: #ffffff; + background: var(--card-bg); padding: 0; min-height: 400px; } @@ -115,5 +115,5 @@ .detail-drawer-tab-content { padding: 0; - background: #ffffff; + background: var(--card-bg); } diff --git a/frontend/src/components/ExtendInfoPanel/ExtendInfoPanel.css b/frontend/src/components/ExtendInfoPanel/ExtendInfoPanel.css index adcd4cb..9a5dd69 100644 --- a/frontend/src/components/ExtendInfoPanel/ExtendInfoPanel.css +++ b/frontend/src/components/ExtendInfoPanel/ExtendInfoPanel.css @@ -18,7 +18,7 @@ /* 信息区块 */ .extend-info-section { - background: #ffffff; + background: var(--card-bg); border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); overflow: hidden; diff --git a/frontend/src/components/InfoPanel/InfoPanel.css b/frontend/src/components/InfoPanel/InfoPanel.css index b2f5a3f..8d2a14f 100644 --- a/frontend/src/components/InfoPanel/InfoPanel.css +++ b/frontend/src/components/InfoPanel/InfoPanel.css @@ -1,13 +1,13 @@ /* 信息面板 */ .info-panel { padding: 0; - background: #ffffff; + background: var(--card-bg); } /* 信息区域容器 */ .info-panel > :global(.ant-row) { padding: 24px; - background: #ffffff; + background: var(--card-bg); border-bottom: 1px solid var(--border-color); } @@ -92,4 +92,3 @@ } - diff --git a/frontend/src/components/PDFViewer/PDFViewer.css b/frontend/src/components/PDFViewer/PDFViewer.css index 3ae75a2..7344f71 100644 --- a/frontend/src/components/PDFViewer/PDFViewer.css +++ b/frontend/src/components/PDFViewer/PDFViewer.css @@ -3,7 +3,7 @@ flex-direction: column; height: 100%; width: 100%; - background: #f5f5f5; + background: var(--bg-color-secondary); flex: 1; min-height: 0; } @@ -13,7 +13,7 @@ justify-content: space-between; align-items: center; padding: 12px 16px; - background: #fff; + background: var(--card-bg); border-bottom: 1px solid var(--border-color); flex-shrink: 0; } diff --git a/frontend/src/components/PageTitleBar/PageTitleBar.css b/frontend/src/components/PageTitleBar/PageTitleBar.css index f362ffb..619c864 100644 --- a/frontend/src/components/PageTitleBar/PageTitleBar.css +++ b/frontend/src/components/PageTitleBar/PageTitleBar.css @@ -138,7 +138,7 @@ z-index: 1; margin-top: 8px; padding: 8px; - background: #ffffff; + background: var(--card-bg); border: 1px solid rgba(139, 92, 246, 0.1); animation: expandContent 0.3s ease-out; } diff --git a/frontend/src/components/SideInfoPanel/SideInfoPanel.css b/frontend/src/components/SideInfoPanel/SideInfoPanel.css index 84509d3..aec0f7b 100644 --- a/frontend/src/components/SideInfoPanel/SideInfoPanel.css +++ b/frontend/src/components/SideInfoPanel/SideInfoPanel.css @@ -7,7 +7,7 @@ /* 信息区块 */ .side-info-section { - background: #ffffff; + background: var(--card-bg); border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); overflow: hidden; diff --git a/frontend/src/components/SplitLayout/SplitLayout.css b/frontend/src/components/SplitLayout/SplitLayout.css index 097fa77..18e7fc2 100644 --- a/frontend/src/components/SplitLayout/SplitLayout.css +++ b/frontend/src/components/SplitLayout/SplitLayout.css @@ -27,7 +27,7 @@ /* 扩展信息区 */ .split-layout-extend { flex-shrink: 0; - background: #ffffff; + background: var(--card-bg); } /* 右侧扩展区(横向布局) */ @@ -51,17 +51,17 @@ } .split-layout-extend-right::-webkit-scrollbar-track { - background: #f5f5f5; + background: var(--bg-color-secondary); border-radius: 3px; } .split-layout-extend-right::-webkit-scrollbar-thumb { - background: #d9d9d9; + background: var(--border-color); border-radius: 3px; } .split-layout-extend-right::-webkit-scrollbar-thumb:hover { - background: #bfbfbf; + background: var(--border-color-strong); } /* 响应式:小屏幕时隐藏右侧扩展区 */ diff --git a/frontend/src/components/TreeFilterPanel/TreeFilterPanel.css b/frontend/src/components/TreeFilterPanel/TreeFilterPanel.css index e471b8c..463dc92 100644 --- a/frontend/src/components/TreeFilterPanel/TreeFilterPanel.css +++ b/frontend/src/components/TreeFilterPanel/TreeFilterPanel.css @@ -9,7 +9,7 @@ .tree-filter-selected { min-height: 40px; padding: 12px; - background: #f5f7fa; + background: var(--bg-color-secondary); border-radius: 6px; border: 1px dashed var(--border-color-strong); } @@ -22,7 +22,7 @@ .tree-filter-label { font-size: 13px; - color: rgba(0, 0, 0, 0.65); + color: var(--text-color-secondary); font-weight: 500; } diff --git a/frontend/src/pages/Chat/Chat.css b/frontend/src/pages/Chat/Chat.css index 7f7088a..8747cab 100644 --- a/frontend/src/pages/Chat/Chat.css +++ b/frontend/src/pages/Chat/Chat.css @@ -359,7 +359,7 @@ padding: 14px 16px; border-radius: 8px; overflow: auto; - background: #f6f8fa; + background: var(--code-bg); border: 1px solid var(--border-color); } @@ -699,9 +699,9 @@ width: 32px; height: 32px; min-width: 32px; - border: 1px solid #f0f0f0; + border: 1px solid var(--border-color); border-radius: 50%; - background: #fff; + background: var(--card-bg); box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); color: var(--text-color-secondary); display: inline-flex; @@ -714,7 +714,7 @@ .chat-composer-plus:hover { color: var(--text-color); - border-color: #e6e6e6; + border-color: var(--border-color-strong); } .chat-composer-pill { @@ -723,9 +723,9 @@ gap: 8px; min-width: 0; padding: 8px 18px; - border: 1px solid #f0f0f0; + border: 1px solid var(--border-color); border-radius: 999px; - background: #fff; + background: var(--card-bg); box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06); color: var(--text-color); line-height: 1.2; @@ -766,7 +766,7 @@ .chat-composer-model-select .ant-select-selector { border-radius: 999px !important; - border-color: #f0f0f0 !important; + border-color: var(--border-color) !important; box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06) !important; } @@ -811,6 +811,33 @@ opacity: 1; } +.chat-stop-button { + width: 32px !important; + height: 32px !important; + min-width: 32px !important; + border: 0 !important; + border-radius: 50% !important; + background: #ff4d4f !important; + color: #fff !important; + font-size: 14px !important; + line-height: 1 !important; + box-shadow: 0 2px 8px rgba(255, 77, 79, 0.35); +} + +.chat-stop-button:hover { + background: #ff7875 !important; + color: #fff !important; +} + +.chat-message-stopped { + margin-top: 8px; + font-size: 12px; + color: var(--text-color-secondary); + display: flex; + align-items: center; + gap: 6px; +} + .chat-search-bar { width: 100%; } @@ -842,6 +869,45 @@ body.dark .chat-search-highlight { color: #ffe58f; } +body.dark .chat-composer-shell { + border-color: rgba(22, 119, 255, 0.45); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25); +} + +body.dark .chat-send-button { + border-color: #4d4d4d !important; + background: #3a3a3a !important; +} + +body.dark .chat-send-button:not(:disabled) { + background: #1668dc !important; + border-color: #1668dc !important; + color: #fff !important; +} + +body.dark .chat-send-button:not(:disabled):hover { + background: #4096ff !important; + border-color: #4096ff !important; +} + +body.dark .chat-send-button:disabled { + background: #303030 !important; + border-color: #454545 !important; + color: rgba(255, 255, 255, 0.35) !important; +} + +body.dark .chat-stop-button { + background: #d84a4a !important; +} + +body.dark .chat-stop-button:hover { + background: #ff4d4f !important; +} + +body.dark .chat-message-error { + color: #ff7875; +} + .chat-search-result-time { margin-top: 4px; font-size: 12px; diff --git a/frontend/src/pages/Chat/Chat.jsx b/frontend/src/pages/Chat/Chat.jsx index 436eb30..698f160 100644 --- a/frontend/src/pages/Chat/Chat.jsx +++ b/frontend/src/pages/Chat/Chat.jsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { Avatar, + Alert, Button, Dropdown, Empty, @@ -29,13 +30,15 @@ import { DownOutlined, CopyOutlined, CheckOutlined, + StopOutlined, + ReloadOutlined, } from '@ant-design/icons' import ReactMarkdown from 'react-markdown' import Highlighter from 'react-highlight-words' import remarkGfm from 'remark-gfm' import rehypeHighlight from 'rehype-highlight' import 'highlight.js/styles/github.css' -import { createChatSession, deleteChatMessage, deleteChatSession, getChatMessages, getChatSessions, searchChatMessages, sendChatMessageStream, updateChatSessionTitle } from '@/api/chat' +import { createChatSession, deleteChatMessage, deleteChatSession, getChatMessages, getChatSessions, markMessageInterrupted, searchChatMessages, sendChatMessageStream, updateChatSessionTitle } from '@/api/chat' import { getMyProjects } from '@/api/project' import { getLLMModelConfigs } from '@/api/llmModelConfigs' import useUserStore from '@/stores/userStore' @@ -45,6 +48,21 @@ const { TextArea } = Input const { Text } = Typography const CITATION_RE = /\[(\d+)\]/g +// 与后端 INTERRUPTED_RESPONSE_MARKER 保持一致,用于识别“已停止生成”的消息 +const STOPPED_MARKER = 'interrupt' +const STOPPED_SUFFIX = `\n\n${STOPPED_MARKER}` + +function isStoppedContent(content) { + return content === STOPPED_MARKER || content.endsWith(STOPPED_SUFFIX) +} + +function stripStoppedContent(content) { + if (content === STOPPED_MARKER) return '' + if (content.endsWith(STOPPED_SUFFIX)) { + return content.slice(0, -STOPPED_SUFFIX.length).replace(/\s+$/g, '') + } + return content +} // rehype 插件:将正文中的 [n] 引用编号转换为上角标 ,便于与正文区分。 // 跳过 code/pre 节点,避免破坏代码块中的方括号内容。 @@ -142,6 +160,22 @@ function getReferenceExcerpt(ref) { return (ref?.excerpt || ref?.anchor_text || '').trim() } +function loadPendingStops() { + try { + return JSON.parse(sessionStorage.getItem('nex-chat-pending-stops') || '{}') + } catch { + return {} + } +} + +function savePendingStops(map) { + try { + sessionStorage.setItem('nex-chat-pending-stops', JSON.stringify(map)) + } catch { + // 忽略存储不可用的情况 + } +} + function SearchHighlight({ text, keyword }) { const value = text || '' if (!keyword) return value @@ -180,7 +214,7 @@ async function copyText(text) { } } -function MessageActions({ content, onCopy, onDelete }) { +function MessageActions({ content, onCopy, onDelete, onRegenerate }) { const [copied, setCopied] = useState(false) const handleCopy = async () => { const ok = await copyText(content) @@ -194,6 +228,17 @@ function MessageActions({ content, onCopy, onDelete }) { } return (

+ {onRegenerate && ( + + )} @@ -234,7 +279,11 @@ function Chat() { const [renameSession, setRenameSession] = useState(null) const [renameTitle, setRenameTitle] = useState('') const messageRefs = useRef(new Map()) - const messagesEndRef = useRef(null) + const messageListRef = useRef(null) + const nearBottomRef = useRef(true) + const composerRef = useRef(null) + const abortControllerRef = useRef(null) + const pendingStopsRef = useRef(loadPendingStops()) const skipNextOpenSessionRef = useRef(null) const searchRequestIdRef = useRef(0) const newMode = location.pathname === '/chat/new' @@ -255,6 +304,18 @@ function Chat() { const canCreateSession = Boolean(newQuestion.trim() && currentProject && currentModel) const canSendMessage = Boolean(inputValue.trim() && currentSession) + const markPendingStop = (sessionId) => { + pendingStopsRef.current[String(sessionId)] = true + savePendingStops(pendingStopsRef.current) + } + + const clearPendingStop = (sessionId) => { + if (pendingStopsRef.current[String(sessionId)]) { + delete pendingStopsRef.current[String(sessionId)] + savePendingStops(pendingStopsRef.current) + } + } + // 与全站侧边栏/个人资料一致的用户头像:avatar 字段是相对路径时转换为 API 端点 const userAvatarUrl = useMemo(() => { const avatar = currentUser?.avatar @@ -320,6 +381,19 @@ function Chat() { } } + // 会话产生新活动后,本地更新 updated_at 并重排,避免整表重载导致的闪烁/跳动 + const touchSession = (sessionId) => { + const now = new Date().toISOString() + setSessions((prev) => { + const next = prev.map((item) => ( + item.session_id === sessionId ? { ...item, updated_at: now } : item + )) + return next.sort((a, b) => ( + new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime() + )) + }) + } + const openSession = async (id, extraQuery = {}) => { if (!id) return const nextQuery = new URLSearchParams() @@ -328,10 +402,39 @@ function Chat() { nextQuery.set('message_id', String(extraQuery.message_id)) } navigate({ pathname: '/chat', search: `?${nextQuery.toString()}` }, { replace: true }) + } + + const loadSessionMessages = async (id) => { setLoadingMessages(true) try { const res = await getChatMessages(id) - setMessages(res.data || []) + const rawMessages = res.data || [] + const nextMessages = rawMessages.map((item) => { + const contentStopped = item.role === 'assistant' && isStoppedContent(item.content || '') + return contentStopped + ? { ...item, stopped: true, status: 'done' } + : item + }) + + // 停止后后端写入中断标记需要一点时间:若该会话存在未确认的停止, + // 且最后一条助手消息仍为空,则把它标记为“已停止”,避免显示“正在思考”。 + if (pendingStopsRef.current[String(id)]) { + const lastAssistantIndex = [...nextMessages].reverse().findIndex((m) => m.role === 'assistant') + if (lastAssistantIndex !== -1) { + const idx = nextMessages.length - 1 - lastAssistantIndex + const lastAssistant = nextMessages[idx] + if (!(lastAssistant.content || '').trim()) { + nextMessages[idx] = { ...lastAssistant, stopped: true, status: 'done' } + // 后端中断标记尚未写入:保留待确认标记,下次加载继续兜底 + } else { + clearPendingStop(id) + } + } else { + clearPendingStop(id) + } + } + + setMessages(nextMessages) setCurrentSession(sessions.find((item) => item.session_id === id) || null) } catch (error) { message.error('加载对话失败') @@ -365,15 +468,30 @@ function Chat() { skipNextOpenSessionRef.current = null return } - openSession(sessionId) + loadSessionMessages(sessionId) // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, sessions.length, newMode]) - // 新会话打开或消息生成/更新时,自动滚动到对话底部 + // 新会话打开或消息生成/更新时自动滚动到对话底部;用户向上翻阅时保持不动 useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) + const el = messageListRef.current + if (!el || !nearBottomRef.current) return + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }) }, [messages, currentSession]) + const handleMessageListScroll = () => { + const el = messageListRef.current + if (!el) return + nearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 120 + } + + // 切换会话后自动聚焦输入框 + useEffect(() => { + if (currentSession && !newMode) { + composerRef.current?.focus() + } + }, [currentSession?.session_id, newMode]) + useEffect(() => { const target = searchParams.get('message_id') if (!target) return @@ -444,13 +562,14 @@ function Chat() { skipNextOpenSessionRef.current = session.session_id navigate(`/chat?session_id=${session.session_id}`, { replace: true }) + clearPendingStop(nextSession.session_id) try { await sendMessageWithStream(nextSession, question, { initialMessages: [optimisticMessage], initialUserMessageId: optimisticMessage.id, includeUserMessage: false, }) - await loadSessions() + touchSession(nextSession.session_id) } catch (error) { message.error(error.response?.data?.detail || error.message || '对话已创建,但首条消息发送失败') } finally { @@ -464,9 +583,10 @@ function Chat() { if (!content || !currentSession) return setSending(true) setInputValue('') + clearPendingStop(currentSession.session_id) try { await sendMessageWithStream(currentSession, content) - await loadSessions() + touchSession(currentSession.session_id) } catch (error) { message.error(error.response?.data?.detail || error.message || '发送消息失败') } finally { @@ -474,6 +594,45 @@ function Chat() { } } + const handleStopGenerating = () => { + abortControllerRef.current?.abort() + } + + const handleRegenerate = async (item) => { + if (sending) return + const index = messages.findIndex((m) => m.id === item.id) + if (index < 0) return + const userMsg = [...messages.slice(0, index)].reverse().find((m) => m.role === 'user') + if (!userMsg || !currentSession) return + + const isRealId = typeof item.id === 'number' || /^\d+$/.test(String(item.id)) + if (isRealId) { + try { + await deleteChatMessage(item.id) + } catch (error) { + message.error(error.response?.data?.detail || '删除旧回复失败') + return + } + } + + const baseMessages = messages.filter((m) => m.id !== item.id) + setMessages(baseMessages) + clearPendingStop(currentSession.session_id) + setSending(true) + try { + await sendMessageWithStream(currentSession, userMsg.content, { + initialMessages: baseMessages, + includeUserMessage: false, + insertUserMessage: false, + }) + touchSession(currentSession.session_id) + } catch (error) { + message.error(error.response?.data?.detail || error.message || '重新生成失败') + } finally { + setSending(false) + } + } + const sendMessageWithStream = async (session, content, options = {}) => { const userMessage = { id: `tmp-user-${Date.now()}`, @@ -502,8 +661,13 @@ function Chat() { let assistantMsgId = assistantMessage.id const tmpUserId = options.initialUserMessageId ?? userMessage.id + const controller = new AbortController() + abortControllerRef.current = controller + try { await sendChatMessageStream(session.session_id, content, { + signal: controller.signal, + insertUserMessage: options.insertUserMessage, onIds: (data) => { const realUserId = data?.user_message_id const realAssistantId = data?.assistant_message_id @@ -553,17 +717,55 @@ function Chat() { }, }) } catch (error) { + const aborted = error?.name === 'AbortError' + if (aborted) { + // 会话级待确认标记:兜底覆盖后端中断标记写入前的时间窗口 + markPendingStop(session.session_id) + + // 立即把“已停止”写入数据库,保证切换会话/刷新后依然可识别 + const stoppedId = String(assistantMsgId) + if (/^\d+$/.test(stoppedId)) { + markMessageInterrupted(Number(stoppedId)).catch(() => {}) + } else { + // ids 事件尚未到达(例如在检索阶段就点了停止):先从服务端解析真实 id + ;(async () => { + try { + const res = await getChatMessages(session.session_id) + const freshMessages = res.data || [] + const lastAssistant = [...freshMessages].reverse().find((m) => m.role === 'assistant') + if (lastAssistant) { + const realId = Number(lastAssistant.id) + await markMessageInterrupted(realId) + setMessages((prev) => prev.map((item) => ( + item.id === assistantMessage.id || item.id === realId + ? { ...item, id: realId, stopped: true, status: 'done' } + : item + ))) + } + } catch (err) { + // 标记失败不阻塞停止流程,会话级待确认标记继续兜底 + } + })() + } + } setMessages((prev) => prev.map((item) => ( item.id === assistantMsgId ? { ...item, - status: 'error', - error: error.message || '回答生成失败,请稍后重试', + status: aborted ? 'done' : 'error', + stopped: aborted, + error: aborted ? undefined : (error.message || '回答生成失败,请稍后重试'), referencesVisible: true, } : item ))) - throw error + if (!aborted) { + throw error + } + } finally { + if (abortControllerRef.current === controller) { + abortControllerRef.current = null + } } } @@ -633,7 +835,6 @@ function Chat() { const handleSelectSearchResult = async (item) => { setSearchVisible(false) - skipNextOpenSessionRef.current = item.session_id await openSession(item.session_id, { message_id: item.message_id }) } @@ -669,7 +870,7 @@ function Chat() { onOk: async () => { try { await deleteChatSession(session.session_id) - await loadSessions() + setSessions((prev) => prev.filter((item) => item.session_id !== session.session_id)) if (currentSession?.session_id === session.session_id) { setCurrentSession(null) setMessages([]) @@ -765,7 +966,7 @@ function Chat() { const renderMessages = () => ( <> -
+
{loadingMessages ? (
@@ -776,9 +977,13 @@ function Chat() { messages.map((item) => { const isUser = item.role === 'user' const refs = item.references || [] - const hasContent = Boolean((item.content || '').trim()) - // 流式中的占位(status=thinking)或刷新后读到的空助手消息都显示「思考中」 - const isThinking = !isUser && !hasContent && !['streaming', 'error'].includes(item.status) + const rawContent = item.content || '' + // 后端会在被中断的内容中追加统一标记,展示时剥离,仅保留“已停止生成”状态 + const isStopped = !isUser && (item.stopped === true || isStoppedContent(rawContent)) + const displayContent = isStopped ? stripStoppedContent(rawContent) : rawContent + const hasContent = Boolean(displayContent.trim()) + // 仅真正等待生成的消息(status=thinking 或刷新后无状态的空占位)显示「思考中」 + const isThinking = !isUser && !hasContent && (item.status === 'thinking' || item.status === undefined) const isStreaming = !isUser && item.status === 'streaming' const isError = !isUser && item.status === 'error' const showReferences = !isUser && item.referencesVisible !== false && refs.length > 0 @@ -800,13 +1005,15 @@ function Chat() {
{isUser ? ( -
{item.content}
+
{displayContent}
) : ( <> {isError && !hasContent ? (
{item.error || '回答生成失败,请稍后重试'}
+ ) : isStopped && !hasContent ? ( +
已停止生成
) : isThinking ? (
正在思考 @@ -855,13 +1062,16 @@ function Chat() { }, }} > - {item.content} + {displayContent} {isError && (
{item.error || '回答生成中断,请稍后重试'}
)} + {isStopped && hasContent && ( +
已停止生成
+ )}
)} {showReferences && ( @@ -890,8 +1100,9 @@ function Chat() {
{(isUser || (!isThinking && !isStreaming)) && ( handleDeleteMessage(item) : undefined} + onRegenerate={!isUser && (isStopped || isError) ? () => handleRegenerate(item) : undefined} /> )}
@@ -904,10 +1115,10 @@ function Chat() { ) }) )} -