feat(web): SSE 分析 API 封装 + 化验报告页 AI 解读按钮
- 新增 analysisSse.ts SSE 流式分析 API 封装(ReadableStream 解析) - 化验报告页操作列添加 AI 解读按钮(SSE 实时流式输出) - 分析结果展示在 Table 下方的 Card 中
This commit is contained in:
96
apps/web/src/api/ai/analysisSse.ts
Normal file
96
apps/web/src/api/ai/analysisSse.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export type AnalysisType = 'lab-report' | 'trends' | 'checkup-plan' | 'report-summary';
|
||||
|
||||
interface AnalyzeBody {
|
||||
report_id?: string;
|
||||
patient_id?: string;
|
||||
metrics?: string[];
|
||||
}
|
||||
|
||||
const ENDPOINT_MAP: Record<AnalysisType, string> = {
|
||||
'lab-report': '/ai/analyze/lab-report',
|
||||
'trends': '/ai/analyze/trends',
|
||||
'checkup-plan': '/ai/analyze/checkup-plan',
|
||||
'report-summary': '/ai/analyze/report-summary',
|
||||
};
|
||||
|
||||
export interface SseCallbacks {
|
||||
onChunk: (content: string, index: number) => void;
|
||||
onError: (message: string) => void;
|
||||
onDone: (analysisId: string) => void;
|
||||
}
|
||||
|
||||
export async function startAnalysis(
|
||||
type: AnalysisType,
|
||||
body: AnalyzeBody,
|
||||
callbacks: SseCallbacks,
|
||||
): Promise<AbortController> {
|
||||
const controller = new AbortController();
|
||||
const endpoint = ENDPOINT_MAP[type];
|
||||
|
||||
const token = localStorage.getItem('hms-token');
|
||||
const resp = await fetch(`/api/v1${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ message: '分析请求失败' }));
|
||||
callbacks.onError(err?.message || `HTTP ${resp.status}`);
|
||||
return controller;
|
||||
}
|
||||
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) {
|
||||
callbacks.onError('无法读取响应流');
|
||||
return controller;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let chunkIndex = 0;
|
||||
let buffer = '';
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const event = JSON.parse(data);
|
||||
if (event.type === 'chunk' && event.content) {
|
||||
callbacks.onChunk(event.content, chunkIndex++);
|
||||
} else if (event.type === 'done' && event.analysis_id) {
|
||||
callbacks.onDone(event.analysis_id);
|
||||
} else if (event.type === 'error') {
|
||||
callbacks.onError(event.message || '分析出错');
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON 行,跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) {
|
||||
callbacks.onError(err instanceof Error ? err.message : '连接中断');
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return controller;
|
||||
}
|
||||
Reference in New Issue
Block a user