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 (
是否强制重置到远程版本?
+
警告:这将丢失所有本地未提交的修改!
是否强制推送到远程?
+
警告:这将覆盖远程仓库的修改!