main
mula.liu 2026-08-05 22:18:25 +08:00
parent c3fccd2723
commit 86b82111df
27 changed files with 699 additions and 136 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>
)}

View File

@ -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);
}

View File

@ -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;

View File

@ -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 @@
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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);
}
/* 响应式:小屏幕时隐藏右侧扩展区 */

View File

@ -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;
}

View File

@ -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;

View File

@ -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>

View File

@ -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);
}

View File

@ -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>
)}

View File

@ -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);
}

View File

@ -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>

View File

@ -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 {

View File

@ -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);
}

View File

@ -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 {

View File

@ -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;
}

View File

@ -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>
))}

View File

@ -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;