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>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
// 基于 fetch + ReadableStream 的轻量 SSE 客户端,带指数退避自动重连。
|
||||
//
|
||||
// 原生 EventSource 无法携带自定义 header,只能把 token 放进 URL query,
|
||||
// 而 token 进 URL 会被网关/浏览器历史/Referer 记录,存在泄露风险。
|
||||
// 这里用 fetch 手动读取 text/event-stream,token 走标准 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');
|
||||
}
|
||||
Reference in New Issue
Block a user