Files
KiloStar/frontend/src/api/sse.ts
T
zhaoxi a53ffebe0e feat: 新增工具插件、系统日志、workflow配置及前端优化
1. 新增工具插件(edit_file, python_executor, search_file, shell_executor, write_file)
2. 新增系统事件日志模块和API
3. 新增workflow配置文件和详情API
4. 前端增加SSE、错误边界、设置引导等组件
5. 优化认证加密、速率限制、配置加载等工具模块
6. 删除废弃的cluster和health API
7. 补充单元测试和集成测试

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 07:34:43 +00:00

131 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 基于 fetch + ReadableStream 的轻量 SSE 客户端,带指数退避自动重连。
//
// 原生 EventSource 无法携带自定义 header,只能把 token 放进 URL query
// 而 token 进 URL 会被网关/浏览器历史/Referer 记录,存在泄露风险。
// 这里用 fetch 手动读取 text/event-streamtoken 走标准 Authorization header。
export interface SSEHandlers {
onOpen?: () => void;
onMessage?: (data: string) => void;
onError?: (err: unknown) => void;
// 连接断开、准备重连时回调,附带本次退避延迟(毫秒)
onReconnect?: (delayMs: number) => void;
}
export interface SSEOptions {
// 初始重连延迟(毫秒),默认 1000
baseDelayMs?: number;
// 最大重连延迟(毫秒),默认 30000
maxDelayMs?: number;
// 鉴权失败(401/403)时是否停止重连,默认 true
stopOnAuthError?: boolean;
}
export interface SSEConnection {
close: () => void;
}
const AUTH_ERROR_STATUSES = new Set([401, 403]);
export function connectSSE(
url: string,
token: string,
handlers: SSEHandlers,
options: SSEOptions = {},
): SSEConnection {
const baseDelay = options.baseDelayMs ?? 1000;
const maxDelay = options.maxDelayMs ?? 30000;
const stopOnAuthError = options.stopOnAuthError ?? true;
let controller = new AbortController();
let closed = false;
let attempt = 0;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleReconnect = () => {
if (closed) return;
// 指数退避 + 抖动,封顶 maxDelay
const backoff = Math.min(baseDelay * 2 ** attempt, maxDelay);
const delay = backoff / 2 + Math.random() * (backoff / 2);
attempt += 1;
handlers.onReconnect?.(delay);
retryTimer = setTimeout(() => {
if (closed) return;
controller = new AbortController();
void run();
}, delay);
};
const run = async () => {
try {
const resp = await fetch(url, {
method: 'GET',
headers: {
Accept: 'text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal: controller.signal,
});
if (!resp.ok || !resp.body) {
handlers.onError?.(new Error(`SSE connect failed: ${resp.status}`));
if (stopOnAuthError && AUTH_ERROR_STATUSES.has(resp.status)) {
closed = true;
return;
}
scheduleReconnect();
return;
}
// 连接成功,重置退避计数
attempt = 0;
handlers.onOpen?.();
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep: number;
while ((sep = buffer.indexOf('\n\n')) !== -1) {
const rawEvent = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const data = parseEventData(rawEvent);
if (data !== null) handlers.onMessage?.(data);
}
}
// 流正常结束(服务端关闭),非主动 close 则尝试重连
if (!closed) scheduleReconnect();
} catch (err) {
if (controller.signal.aborted || closed) return;
handlers.onError?.(err);
scheduleReconnect();
}
};
void run();
return {
close: () => {
closed = true;
if (retryTimer) clearTimeout(retryTimer);
controller.abort();
},
};
}
function parseEventData(rawEvent: string): string | null {
// 只关心 data: 行,多行 data 用 \n 拼接,忽略注释(:)与其他字段
const dataLines = rawEvent
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).replace(/^ /, ''));
if (dataLines.length === 0) return null;
return dataLines.join('\n');
}