fix(aia,ssa,asl,infra): harden SSE transport and stabilize attachment context
Deliver SSE protocol hardening for SAE/HTTP2 paths, add graceful shutdown health behavior, and improve SSA retry UX for transient stream failures. For AIA, persist attachment extraction results in database with cache read-through fallback, plus production cache safety guard to prevent memory-cache drift in multi-instance deployments; also restore ASL SR page scrolling behavior. Made-with: Cursor
This commit is contained in:
@@ -17,6 +17,13 @@ http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# WebSocket / SSE 兼容:仅当请求包含 Upgrade 头时设置 Connection: upgrade
|
||||
# SSE 请求无 Upgrade 头 → Connection 为空,避免 HTTP/2 帧协议错误
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' '';
|
||||
}
|
||||
|
||||
# ⚠️ 文件上传大小限制(默认只有 1MB,太小会导致 413 错误)
|
||||
client_max_body_size 50M; # 允许上传最大 50MB 文件
|
||||
|
||||
@@ -144,14 +151,15 @@ http {
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
# 缓冲配置
|
||||
proxy_buffering off; # 关闭缓冲(实时流式响应)
|
||||
proxy_request_buffering off; # 支持大文件上传
|
||||
# 缓冲配置(SSE 流式响应必须关闭所有缓冲/缓存)
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
# WebSocket 支持(如果后续需要)
|
||||
# WebSocket + SSE 兼容
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# 错误处理
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
|
||||
@@ -151,7 +151,7 @@ const ASLLayout = () => {
|
||||
|
||||
return (
|
||||
<ASLLayoutContext.Provider value={contextValue}>
|
||||
<div style={{ height: '100vh', display: 'flex', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', minHeight: 0, display: 'flex', overflow: 'hidden' }}>
|
||||
{/* ── 侧边栏 ── */}
|
||||
<div className="asl-sidebar">
|
||||
|
||||
@@ -277,7 +277,7 @@ const ASLLayout = () => {
|
||||
</div>
|
||||
|
||||
{/* ── 右侧内容区 ── */}
|
||||
<div style={{ flex: 1, overflow: 'hidden', background: '#F9FAFB' }}>
|
||||
<div style={{ flex: 1, minWidth: 0, minHeight: 0, overflow: 'auto', background: '#F9FAFB' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,6 +80,7 @@ export const SSAChatPane: React.FC = () => {
|
||||
loadHistory,
|
||||
abort: abortChat,
|
||||
clearMessages,
|
||||
retryLastMessage,
|
||||
} = useSSAChat();
|
||||
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
@@ -458,6 +459,13 @@ export const SSAChatPane: React.FC = () => {
|
||||
<div className="chat-error-msg">
|
||||
<AlertCircle size={14} className="text-red-500" />
|
||||
<span>{msg.content}</span>
|
||||
<button
|
||||
className="chat-retry-btn"
|
||||
onClick={() => retryLastMessage()}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chat-msg-content">
|
||||
|
||||
@@ -77,6 +77,38 @@ export interface UseSSAChatReturn {
|
||||
loadHistory: (sessionId: string) => Promise<void>;
|
||||
abort: () => void;
|
||||
clearMessages: () => void;
|
||||
retryLastMessage: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
const MAX_AUTO_RETRY = 2;
|
||||
|
||||
function retryDelay(attempt: number): number {
|
||||
return Math.min(1000 * (2 ** attempt), 5000);
|
||||
}
|
||||
|
||||
function toFriendlyError(err: any): string {
|
||||
const msg = (err?.message || '').toLowerCase();
|
||||
|
||||
if (msg.includes('failed to fetch') || msg.includes('networkerror') || msg.includes('network'))
|
||||
return '网络连接不稳定,请稍后重试。';
|
||||
|
||||
if (msg.includes('http2') || msg.includes('protocol'))
|
||||
return '网络链路出现瞬时波动,请重新发送消息。';
|
||||
|
||||
if (msg.includes('timeout') || msg.includes('timed out'))
|
||||
return '请求超时,服务器响应较慢,请稍后重试。';
|
||||
|
||||
if (msg.includes('502') || msg.includes('503') || msg.includes('504'))
|
||||
return '服务暂时不可用,可能正在更新中,请稍后重试。';
|
||||
|
||||
if (msg.includes('401') || msg.includes('登录'))
|
||||
return '登录已过期,请刷新页面重新登录。';
|
||||
|
||||
return err?.message || '请求失败,请重试。';
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
@@ -95,6 +127,8 @@ export function useSSAChat(): UseSSAChatReturn {
|
||||
const [pendingQuestion, setPendingQuestion] = useState<AskUserEventData | null>(null);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const lastRequestRef = useRef<{ sessionId: string; content: string; metadata?: Record<string, any> } | null>(null);
|
||||
const retryCountRef = useRef(0);
|
||||
|
||||
const ensureFreshToken = useCallback(async (): Promise<string> => {
|
||||
if (isTokenExpired()) {
|
||||
@@ -186,6 +220,7 @@ export function useSSAChat(): UseSSAChatReturn {
|
||||
* 发送消息并接收 SSE 流式响应
|
||||
*/
|
||||
const sendChatMessage = useCallback(async (sessionId: string, content: string, metadata?: Record<string, any>) => {
|
||||
lastRequestRef.current = { sessionId, content, metadata };
|
||||
setError(null);
|
||||
setIsGenerating(true);
|
||||
setThinkingContent('');
|
||||
@@ -454,11 +489,32 @@ export function useSSAChat(): UseSSAChatReturn {
|
||||
? { ...m, content: fullContent || '(已中断)', status: 'complete' }
|
||||
: m
|
||||
));
|
||||
retryCountRef.current = 0;
|
||||
} else {
|
||||
const errMsg = err.message || '请求失败';
|
||||
setError(errMsg);
|
||||
const isNetworkError = /failed to fetch|networkerror|network|http2|protocol/i.test(err.message || '');
|
||||
|
||||
// 瞬时网络错误自动重试一次
|
||||
if (isNetworkError && retryCountRef.current < MAX_AUTO_RETRY) {
|
||||
retryCountRef.current += 1;
|
||||
const delay = retryDelay(retryCountRef.current);
|
||||
setChatMessages(prev => prev.map(m =>
|
||||
m.id === assistantMsgId
|
||||
? { ...m, content: `⏳ 网络波动,${(delay / 1000).toFixed(1)}s 后自动重试(第 ${retryCountRef.current}/${MAX_AUTO_RETRY} 次)...`, status: 'generating' }
|
||||
: m
|
||||
));
|
||||
setIsGenerating(false);
|
||||
abortRef.current = null;
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
return sendChatMessage(sessionId, content, metadata);
|
||||
}
|
||||
|
||||
retryCountRef.current = 0;
|
||||
const friendlyMsg = toFriendlyError(err);
|
||||
setError(friendlyMsg);
|
||||
setChatMessages(prev => prev.map(m =>
|
||||
m.id === assistantMsgId ? { ...m, content: errMsg, status: 'error' } : m
|
||||
m.id === assistantMsgId
|
||||
? { ...m, content: `⚠️ ${friendlyMsg}`, status: 'error' }
|
||||
: m
|
||||
));
|
||||
}
|
||||
} finally {
|
||||
@@ -535,6 +591,20 @@ export function useSSAChat(): UseSSAChatReturn {
|
||||
await sendChatMessage(sessionId, '已跳过此问题', { askUserResponse: skipResponse });
|
||||
}, [sendChatMessage]);
|
||||
|
||||
const retryLastMessage = useCallback(async () => {
|
||||
const last = lastRequestRef.current;
|
||||
if (!last) return;
|
||||
retryCountRef.current = 0;
|
||||
setChatMessages(prev => {
|
||||
let lastErrIdx = -1;
|
||||
for (let i = prev.length - 1; i >= 0; i--) {
|
||||
if (prev[i].status === 'error') { lastErrIdx = i; break; }
|
||||
}
|
||||
return lastErrIdx >= 0 ? prev.filter((_: ChatMessage, i: number) => i !== lastErrIdx) : prev;
|
||||
});
|
||||
await sendChatMessage(last.sessionId, last.content, last.metadata);
|
||||
}, [sendChatMessage]);
|
||||
|
||||
return {
|
||||
chatMessages,
|
||||
isGenerating,
|
||||
@@ -552,6 +622,7 @@ export function useSSAChat(): UseSSAChatReturn {
|
||||
loadHistory,
|
||||
abort,
|
||||
clearMessages,
|
||||
retryLastMessage,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1818,3 +1818,42 @@
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ── SSE 错误提示 + 重试按钮 ── */
|
||||
|
||||
.chat-error-msg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #fca5a5;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.chat-retry-btn {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
padding: 4px 14px;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #93c5fd;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.chat-retry-btn:hover:not(:disabled) {
|
||||
background: rgba(59, 130, 246, 0.25);
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.chat-retry-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user