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:
2026-03-09 18:45:12 +08:00
parent 50657dd81f
commit 5c5fec52c1
27 changed files with 807 additions and 100 deletions

View File

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

View File

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

View File

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

View File

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