v0.9.9
parent
c3fccd2723
commit
86b82111df
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@ const ConfirmDialog = {
|
|||
content: (
|
||||
<div>
|
||||
<p>您确定要删除以下项目吗?</p>
|
||||
<div style={{ marginTop: 12, padding: 12, background: '#f5f5f5', borderRadius: 6 }}>
|
||||
<div style={{ marginTop: 12, padding: 12, background: 'var(--bg-color-secondary)', borderRadius: 6 }}>
|
||||
<p style={{ margin: 0, fontWeight: 500 }}>{itemName}</p>
|
||||
{itemInfo && (
|
||||
<p style={{ margin: '4px 0 0 0', fontSize: 13, color: '#666' }}>{itemInfo}</p>
|
||||
<p style={{ margin: '4px 0 0 0', fontSize: 13, color: 'var(--text-color-secondary)' }}>{itemInfo}</p>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ marginTop: 12, color: '#ff4d4f', fontSize: 13 }}>
|
||||
|
|
@ -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',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 500 }}>{item.name}</span>
|
||||
{item.info && (
|
||||
<span style={{ marginLeft: 12, fontSize: 13, color: '#666' }}>
|
||||
<span style={{ marginLeft: 12, fontSize: 13, color: 'var(--text-color-secondary)' }}>
|
||||
({item.info})
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/* 响应式:小屏幕时隐藏右侧扩展区 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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] 引用编号转换为上角标 <sup>,便于与正文区分。
|
||||
// 跳过 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 (
|
||||
<div className="chat-message-actions">
|
||||
{onRegenerate && (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-message-action"
|
||||
onClick={onRegenerate}
|
||||
aria-label="重新生成"
|
||||
title="重新生成"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="chat-message-action" onClick={handleCopy} aria-label="复制">
|
||||
{copied ? <CheckOutlined /> : <CopyOutlined />}
|
||||
</button>
|
||||
|
|
@ -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 = () => (
|
||||
<>
|
||||
<div className="chat-message-list">
|
||||
<div className="chat-message-list" ref={messageListRef} onScroll={handleMessageListScroll}>
|
||||
{loadingMessages ? (
|
||||
<div className="chat-panel-loading">
|
||||
<Spin />
|
||||
|
|
@ -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() {
|
|||
<div className="chat-message-column">
|
||||
<div className={`chat-message-bubble ${isUser ? 'user' : 'assistant'}`}>
|
||||
{isUser ? (
|
||||
<div className="chat-plain-text">{item.content}</div>
|
||||
<div className="chat-plain-text">{displayContent}</div>
|
||||
) : (
|
||||
<>
|
||||
{isError && !hasContent ? (
|
||||
<div className="chat-message-error">
|
||||
{item.error || '回答生成失败,请稍后重试'}
|
||||
</div>
|
||||
) : isStopped && !hasContent ? (
|
||||
<div className="chat-message-stopped">已停止生成</div>
|
||||
) : isThinking ? (
|
||||
<div className="chat-thinking">
|
||||
<span className="chat-thinking-text">正在思考</span>
|
||||
|
|
@ -855,13 +1062,16 @@ function Chat() {
|
|||
},
|
||||
}}
|
||||
>
|
||||
{item.content}
|
||||
{displayContent}
|
||||
</ReactMarkdown>
|
||||
{isError && (
|
||||
<div className="chat-message-error chat-message-error-inline">
|
||||
{item.error || '回答生成中断,请稍后重试'}
|
||||
</div>
|
||||
)}
|
||||
{isStopped && hasContent && (
|
||||
<div className="chat-message-stopped">已停止生成</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showReferences && (
|
||||
|
|
@ -890,8 +1100,9 @@ function Chat() {
|
|||
</div>
|
||||
{(isUser || (!isThinking && !isStreaming)) && (
|
||||
<MessageActions
|
||||
content={item.content}
|
||||
content={displayContent}
|
||||
onDelete={canDelete ? () => handleDeleteMessage(item) : undefined}
|
||||
onRegenerate={!isUser && (isStopped || isError) ? () => handleRegenerate(item) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -904,10 +1115,10 @@ function Chat() {
|
|||
)
|
||||
})
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div className="chat-composer-shell">
|
||||
<TextArea
|
||||
ref={composerRef}
|
||||
className="chat-composer-input"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
|
|
@ -936,15 +1147,25 @@ function Chat() {
|
|||
{models.find((model) => model.config_id === currentSession?.llm_config_id)?.model_name || '-'}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<ArrowUpOutlined />}
|
||||
onClick={handleSend}
|
||||
loading={sending}
|
||||
disabled={!canSendMessage}
|
||||
aria-disabled={!canSendMessage || sending}
|
||||
className="chat-send-button"
|
||||
/>
|
||||
{sending ? (
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<StopOutlined />}
|
||||
onClick={handleStopGenerating}
|
||||
title="停止生成"
|
||||
aria-label="停止生成"
|
||||
className="chat-stop-button"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<ArrowUpOutlined />}
|
||||
onClick={handleSend}
|
||||
disabled={!canSendMessage}
|
||||
aria-disabled={!canSendMessage}
|
||||
className="chat-send-button"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -966,11 +1187,28 @@ function Chat() {
|
|||
<div className="chat-start-shell">
|
||||
<div className="chat-new-page-card">
|
||||
<div className="chat-start-title">你希望了解什么?</div>
|
||||
{projects.length === 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="暂无可用知识库"
|
||||
description="请先在「项目空间」创建项目,再进入这里提问。"
|
||||
/>
|
||||
)}
|
||||
{projects.length > 0 && models.length === 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="暂无可用对话模型"
|
||||
description="请先到「系统管理 - 模型配置」添加并启用对话模型。"
|
||||
/>
|
||||
)}
|
||||
<div className="chat-composer-shell chat-new-composer">
|
||||
<TextArea
|
||||
value={newQuestion}
|
||||
onChange={(e) => setNewQuestion(e.target.value)}
|
||||
className="chat-composer-input"
|
||||
autoFocus
|
||||
autoSize={{ minRows: 2, maxRows: 10 }}
|
||||
placeholder="随心输入"
|
||||
onPressEnter={(e) => {
|
||||
|
|
@ -1027,15 +1265,25 @@ function Chat() {
|
|||
optionFilterProp="label"
|
||||
className="chat-composer-model-select"
|
||||
/>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<ArrowUpOutlined />}
|
||||
onClick={handleCreateSession}
|
||||
loading={sending}
|
||||
disabled={!canCreateSession}
|
||||
aria-disabled={!canCreateSession || sending}
|
||||
className="chat-send-button"
|
||||
/>
|
||||
{sending ? (
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<StopOutlined />}
|
||||
onClick={handleStopGenerating}
|
||||
title="停止生成"
|
||||
aria-label="停止生成"
|
||||
className="chat-stop-button"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<ArrowUpOutlined />}
|
||||
onClick={handleCreateSession}
|
||||
disabled={!canCreateSession}
|
||||
aria-disabled={!canCreateSession}
|
||||
className="chat-send-button"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: #707480;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
|
@ -84,8 +84,8 @@
|
|||
}
|
||||
|
||||
.project-back-button:hover {
|
||||
background: rgba(17, 24, 39, 0.06);
|
||||
color: #2f3440;
|
||||
background: var(--item-hover-bg);
|
||||
color: var(--text-color);
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1139,7 +1139,7 @@ function DocumentEditor() {
|
|||
{/* 上传进度条 */}
|
||||
{uploading && (
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: '#666' }}>上传中...</div>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: 'var(--text-color-secondary)' }}>上传中...</div>
|
||||
<Progress percent={uploadProgress} size="small" />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: #707480;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
|
@ -79,8 +79,8 @@
|
|||
}
|
||||
|
||||
.project-back-button:hover {
|
||||
background: rgba(17, 24, 39, 0.06);
|
||||
color: #2f3440;
|
||||
background: var(--item-hover-bg);
|
||||
color: var(--text-color);
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -655,7 +655,7 @@ function DocumentPage() {
|
|||
<p style={{ color: 'red', fontWeight: 'bold', marginTop: 8 }}>
|
||||
是否强制重置到远程版本?
|
||||
</p>
|
||||
<p style={{ color: '#666', fontSize: 12 }}>
|
||||
<p style={{ color: 'var(--text-color-secondary)', fontSize: 12 }}>
|
||||
警告:这将丢失所有本地未提交的修改!
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -693,7 +693,7 @@ function DocumentPage() {
|
|||
<p style={{ color: 'red', fontWeight: 'bold', marginTop: 8 }}>
|
||||
是否强制推送到远程?
|
||||
</p>
|
||||
<p style={{ color: '#666', fontSize: 12 }}>
|
||||
<p style={{ color: 'var(--text-color-secondary)', fontSize: 12 }}>
|
||||
警告:这将覆盖远程仓库的修改!
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1118,7 +1118,7 @@ function DocumentPage() {
|
|||
className="docs-menu"
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-color-secondary)' }}>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到匹配文档" />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1267,7 +1267,7 @@ function DocumentPage() {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ color: '#8c8c8c', lineHeight: 1.7 }}>
|
||||
<div style={{ color: 'var(--text-color-secondary)', lineHeight: 1.7 }}>
|
||||
当前文件尚未创建独立分享。
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -206,6 +206,55 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== Dark 模式 ===== */
|
||||
body.dark .login-page {
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
body.dark .login-left {
|
||||
background: linear-gradient(135deg, rgba(22, 119, 255, 0.18) 0%, rgba(22, 119, 255, 0.05) 100%);
|
||||
}
|
||||
|
||||
body.dark .login-right {
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
body.dark .intro-title {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
body.dark .intro-desc,
|
||||
body.dark .footer-links span,
|
||||
body.dark .copyright,
|
||||
body.dark .form-subtitle {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
body.dark .login-form-container .ant-form-item-label > label {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
body.dark .login-form-container .ant-input-affix-wrapper,
|
||||
body.dark .login-form-container .ant-input {
|
||||
background: var(--bg-color-secondary);
|
||||
}
|
||||
|
||||
body.dark .login-form-container .ant-input-affix-wrapper:hover,
|
||||
body.dark .login-form-container .ant-input:hover,
|
||||
body.dark .login-form-container .ant-input-affix-wrapper-focused,
|
||||
body.dark .login-form-container .ant-input-affix-wrapper:focus,
|
||||
body.dark .login-form-container .ant-input:focus {
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
body.dark .test-account-info {
|
||||
background: rgba(22, 119, 255, 0.14);
|
||||
}
|
||||
|
||||
body.dark .test-account-info p {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.login-left {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: #707480;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
|
@ -58,8 +58,8 @@
|
|||
}
|
||||
|
||||
.project-back-button:hover {
|
||||
background: rgba(17, 24, 39, 0.06);
|
||||
color: #2f3440;
|
||||
background: var(--item-hover-bg);
|
||||
color: var(--text-color);
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -202,6 +202,53 @@
|
|||
font-family: Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* ===== Dark 模式 ===== */
|
||||
body.dark .profile-title {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
body.dark .avatar-section {
|
||||
background: var(--bg-color-secondary);
|
||||
}
|
||||
|
||||
body.dark .avatar-tip {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
body.dark .password-tips {
|
||||
background: rgba(22, 119, 255, 0.14);
|
||||
border-left-color: #1890ff;
|
||||
}
|
||||
|
||||
body.dark .password-tips h4 {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
body.dark .password-tips li {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
body.dark .mcp-panel-header p {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
body.dark .mcp-field-card {
|
||||
border-color: var(--border-color);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
body.dark .mcp-field-card label {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
body.dark .mcp-value {
|
||||
background: var(--bg-color-secondary);
|
||||
}
|
||||
|
||||
body.dark .mcp-config-tip {
|
||||
background: var(--bg-color-secondary);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.profile-page {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,22 @@
|
|||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* 知识库向量化弹窗:未配置向量模型时的提示条 */
|
||||
.kb-warning-banner {
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: #fff7e6;
|
||||
border: 1px solid #ffe7ba;
|
||||
color: #d46b08;
|
||||
}
|
||||
|
||||
body.dark .kb-warning-banner {
|
||||
background: rgba(250, 173, 20, 0.12);
|
||||
border-color: rgba(250, 173, 20, 0.4);
|
||||
color: #ffc53d;
|
||||
}
|
||||
|
||||
.project-empty-state {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination, Progress, Alert, List, Spin } from 'antd'
|
||||
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined } from '@ant-design/icons'
|
||||
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination, Progress, Alert, List, Spin, Tooltip } from 'antd'
|
||||
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, transferProject } from '@/api/project'
|
||||
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
|
||||
import { getUserList } from '@/api/users'
|
||||
|
|
@ -47,6 +47,7 @@ function ProjectList({ type = 'my' }) {
|
|||
vectorizing,
|
||||
openKnowledgeModal,
|
||||
closeKnowledgeModal,
|
||||
refreshProgress,
|
||||
runVectorize,
|
||||
} = useProjectKnowledge()
|
||||
|
||||
|
|
@ -565,7 +566,7 @@ function ProjectList({ type = 'my' }) {
|
|||
{/* 搜索结果 */}
|
||||
{hasSearched && searchResults.length > 0 && (
|
||||
<div style={{ marginTop: 16, marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8, color: '#666' }}>
|
||||
<div style={{ marginBottom: 8, color: 'var(--text-color-secondary)' }}>
|
||||
找到 {searchResults.length} 个结果
|
||||
</div>
|
||||
<Row gutter={[16, 16]}>
|
||||
|
|
@ -598,7 +599,7 @@ function ProjectList({ type = 'my' }) {
|
|||
</p>
|
||||
)}
|
||||
{item.type === 'file' && (
|
||||
<div style={{ fontSize: 12, color: '#666' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-color-secondary)' }}>
|
||||
<div>项目: {item.project_name}</div>
|
||||
<div>路径: {item.file_path}</div>
|
||||
</div>
|
||||
|
|
@ -622,10 +623,10 @@ function ProjectList({ type = 'my' }) {
|
|||
className="project-card"
|
||||
onClick={() => handleOpenProject(project.id)}
|
||||
actions={type === 'my' ? [
|
||||
<SettingOutlined key="settings" onClick={(e) => handleEdit(e, project)} />,
|
||||
<GithubOutlined key="git" onClick={(e) => handleGitSettings(e, project)} />,
|
||||
<DatabaseOutlined key="kb" onClick={(e) => handleKnowledge(e, project)} />,
|
||||
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
|
||||
<Tooltip key="settings" title="项目设置"><SettingOutlined onClick={(e) => handleEdit(e, project)} /></Tooltip>,
|
||||
<Tooltip key="git" title="Git 仓库"><GithubOutlined onClick={(e) => handleGitSettings(e, project)} /></Tooltip>,
|
||||
<Tooltip key="kb" title="知识库向量化"><DatabaseOutlined onClick={(e) => handleKnowledge(e, project)} /></Tooltip>,
|
||||
<Tooltip key="members" title="成员管理"><TeamOutlined onClick={(e) => handleMembers(e, project)} /></Tooltip>,
|
||||
] : [
|
||||
<EyeOutlined key="view" />,
|
||||
]}
|
||||
|
|
@ -780,7 +781,7 @@ function ProjectList({ type = 'my' }) {
|
|||
<Form.Item label="项目公开分享">
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
{currentProject?.is_public !== 1 ? (
|
||||
<div style={{ color: '#8c8c8c', lineHeight: 1.7 }}>
|
||||
<div style={{ color: 'var(--text-color-secondary)', lineHeight: 1.7 }}>
|
||||
开启“公开项目”后,可以生成项目分享链接和访问密码。
|
||||
</div>
|
||||
) : shareInfo ? (
|
||||
|
|
@ -905,7 +906,7 @@ function ProjectList({ type = 'my' }) {
|
|||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<div>
|
||||
{users.length === 0 ? (
|
||||
<div style={{ marginBottom: 16, color: '#999' }}>
|
||||
<div style={{ marginBottom: 16, color: 'var(--text-color-secondary)' }}>
|
||||
{loadingMembers ? '正在加载用户列表...' : '没有可添加的用户'}
|
||||
</div>) : null}
|
||||
<Form
|
||||
|
|
@ -1164,6 +1165,9 @@ function ProjectList({ type = 'my' }) {
|
|||
onCancel={closeKnowledgeModal}
|
||||
width={640}
|
||||
footer={[
|
||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshProgress(currentProject?.id)} disabled={vectorizing || loadingProgress}>
|
||||
刷新
|
||||
</Button>,
|
||||
<Button key="close" onClick={closeKnowledgeModal}>关闭</Button>,
|
||||
<Button
|
||||
key="incremental"
|
||||
|
|
@ -1186,7 +1190,7 @@ function ProjectList({ type = 'my' }) {
|
|||
]}
|
||||
>
|
||||
{loadingProgress && !progress ? (
|
||||
<div style={{ textAlign: 'center', padding: '32px 0', color: '#999' }}>
|
||||
<div style={{ textAlign: 'center', padding: '32px 0', color: 'var(--text-color-secondary)' }}>
|
||||
加载向量化进度...
|
||||
</div>
|
||||
) : !progress ? (
|
||||
|
|
@ -1194,7 +1198,7 @@ function ProjectList({ type = 'my' }) {
|
|||
) : (
|
||||
<div>
|
||||
{!progress.embedding_ready && (
|
||||
<div style={{ marginBottom: 16, padding: '10px 14px', borderRadius: 8, background: '#fff7e6', border: '1px solid #ffe7ba', color: '#d46b08' }}>
|
||||
<div className="kb-warning-banner">
|
||||
尚未配置可用的向量模型(Embedding),请先到「模型配置 - 向量模型」中添加并启用,再进行向量化。
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1224,19 +1228,19 @@ function ProjectList({ type = 'my' }) {
|
|||
<Row gutter={16} style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Col span={6}>
|
||||
<div style={{ fontSize: 22, fontWeight: 600 }}>{progress.total}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>MD 文件总数</div>
|
||||
<div style={{ color: 'var(--text-color-secondary)', fontSize: 12 }}>MD 文件总数</div>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#52c41a' }}>{progress.success}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>已向量化</div>
|
||||
<div style={{ color: 'var(--text-color-secondary)', fontSize: 12 }}>已向量化</div>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#faad14' }}>{progress.pending}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>待处理</div>
|
||||
<div style={{ color: 'var(--text-color-secondary)', fontSize: 12 }}>待处理</div>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#ff4d4f' }}>{progress.failed}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>失败</div>
|
||||
<div style={{ color: 'var(--text-color-secondary)', fontSize: 12 }}>失败</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
|
@ -1245,9 +1249,9 @@ function ProjectList({ type = 'my' }) {
|
|||
<div style={{ marginBottom: 8, fontWeight: 600, color: '#ff4d4f' }}>失败明细</div>
|
||||
<div style={{ maxHeight: 160, overflow: 'auto' }}>
|
||||
{progress.failed_items.map((item) => (
|
||||
<div key={item.file_path} style={{ padding: '6px 0', borderBottom: '1px solid #f0f0f0', fontSize: 13 }}>
|
||||
<div key={item.file_path} style={{ padding: '6px 0', borderBottom: '1px solid var(--border-color)', fontSize: 13 }}>
|
||||
<div style={{ wordBreak: 'break-all' }}>{item.file_path}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>{item.error}</div>
|
||||
<div style={{ color: 'var(--text-color-secondary)', fontSize: 12 }}>{item.error}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1259,7 +1263,7 @@ function ProjectList({ type = 'my' }) {
|
|||
<div style={{ marginBottom: 8, fontWeight: 600, color: '#faad14' }}>待处理文件</div>
|
||||
<div style={{ maxHeight: 140, overflow: 'auto' }}>
|
||||
{progress.pending_items.map((path) => (
|
||||
<div key={path} style={{ padding: '4px 0', fontSize: 13, wordBreak: 'break-all', color: '#666' }}>
|
||||
<div key={path} style={{ padding: '4px 0', fontSize: 13, wordBreak: 'break-all', color: 'var(--text-color-secondary)' }}>
|
||||
{path}
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -143,6 +143,17 @@
|
|||
border-color: #cfcfcf !important;
|
||||
}
|
||||
|
||||
body.dark .max-tokens-group .ant-radio-button-wrapper-checked {
|
||||
background: #1668dc !important;
|
||||
border-color: #1668dc !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
body.dark .max-tokens-group .ant-radio-button-wrapper-checked:hover {
|
||||
background: #4096ff !important;
|
||||
border-color: #4096ff !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.model-config-tabs > .ant-tabs-nav {
|
||||
min-width: 0;
|
||||
|
|
|
|||
Loading…
Reference in New Issue