241 lines
8.2 KiB
TypeScript
241 lines
8.2 KiB
TypeScript
import type { QuickQuestionsResponse, SSEMessage } from './index';
|
|
|
|
const AGENT_API_BASE = '/api/v1';
|
|
const TOKEN_KEY = 'auth_token';
|
|
function getToken(): string | null { return localStorage.getItem(TOKEN_KEY); }
|
|
|
|
const _FALLBACK_QUESTIONS = [
|
|
{ id: '1', question: '请总结最新入库法规对电池安全的核心要求', category: '法规解读' },
|
|
{ id: '2', question: '我上传的制度文档与新能源法规有哪些潜在冲突?', category: '差距分析' },
|
|
{ id: '3', question: '请给出法规依据,并按条款列出整改建议', category: '整改建议' },
|
|
{ id: '4', question: '请解释 UN-ECE 与 GB 标准在网络安全方面的差异', category: '标准对比' },
|
|
];
|
|
|
|
export async function getQuickQuestions(): Promise<QuickQuestionsResponse> {
|
|
try {
|
|
const response = await fetch(`${AGENT_API_BASE}/rag/quick-questions`);
|
|
if (!response.ok) throw new Error(`status ${response.status}`);
|
|
return response.json() as Promise<QuickQuestionsResponse>;
|
|
} catch {
|
|
return { questions: _FALLBACK_QUESTIONS };
|
|
}
|
|
}
|
|
|
|
function parseSSEChunk(raw: string, onMessage: (data: SSEMessage) => void) {
|
|
const blocks = raw.split('\n\n');
|
|
for (const block of blocks) {
|
|
if (!block.trim()) continue;
|
|
|
|
let eventName = 'message';
|
|
const dataLines: string[] = [];
|
|
|
|
for (const line of block.split('\n')) {
|
|
if (line.startsWith('event:')) {
|
|
eventName = line.slice(6).trim();
|
|
} else if (line.startsWith('data:')) {
|
|
dataLines.push(line.slice(5).trim());
|
|
}
|
|
}
|
|
|
|
const joined = dataLines.join('\n');
|
|
if (!joined) continue;
|
|
|
|
// /agent/chat/stream uses named events (sources, content, done, error, session, status)
|
|
// /rag/chat wraps everything in event:message with type in JSON body — handle both
|
|
if (eventName === 'session') {
|
|
try {
|
|
const payload = JSON.parse(joined) as Record<string, unknown>;
|
|
onMessage({ type: 'session', session_id: String(payload.session_id ?? '') });
|
|
} catch { /* ignore */ }
|
|
} else if (eventName === 'sources') {
|
|
try {
|
|
const docs = JSON.parse(joined) as Array<Record<string, unknown>>;
|
|
onMessage({
|
|
type: 'retrieved',
|
|
docs: docs.map((doc, index) => ({
|
|
id: String(doc.doc_id || doc.index || index + 1),
|
|
score: Number(doc.score || 0),
|
|
preview: String(doc.content || doc.snippet || ''),
|
|
doc_name: String(doc.doc_name || doc.filename || `引用 ${index + 1}`),
|
|
clause: String(doc.clause_number || doc.section_title || '法规片段'),
|
|
doc_id: doc.doc_id ? String(doc.doc_id) : undefined,
|
|
download_url: doc.doc_id ? `${AGENT_API_BASE}/documents/download/${String(doc.doc_id)}` : undefined,
|
|
})),
|
|
});
|
|
} catch { /* ignore */ }
|
|
} else if (eventName === 'content') {
|
|
onMessage({ type: 'chunk', text: joined });
|
|
} else if (eventName === 'done') {
|
|
try {
|
|
const payload = JSON.parse(joined) as Record<string, unknown>;
|
|
onMessage({ type: 'done', session_id: payload.session_id ? String(payload.session_id) : undefined });
|
|
} catch {
|
|
onMessage({ type: 'done' });
|
|
}
|
|
} else if (eventName === 'error') {
|
|
onMessage({ type: 'error', text: joined });
|
|
} else if (eventName === 'status') {
|
|
onMessage({ type: 'status', text: joined });
|
|
} else if (eventName === 'thinking') {
|
|
// P0-1: Agentic reasoning step events from /agent/agentic/stream
|
|
try {
|
|
const payload = JSON.parse(joined) as Record<string, unknown>;
|
|
onMessage({
|
|
type: 'thinking',
|
|
step: payload.step as string | undefined,
|
|
status: payload.status as string | undefined,
|
|
intent_type: payload.intent_type as string | undefined,
|
|
requires_decomposition: payload.requires_decomposition as boolean | undefined,
|
|
reason: payload.reason as string | undefined,
|
|
sub_queries: payload.sub_queries as string[] | undefined,
|
|
query: payload.query as string | undefined,
|
|
index: payload.index as number | undefined,
|
|
total: payload.total as number | undefined,
|
|
found: payload.found as number | undefined,
|
|
retry: payload.retry as boolean | undefined,
|
|
sufficient: payload.sufficient as boolean | undefined,
|
|
confidence: payload.confidence as number | undefined,
|
|
});
|
|
} catch { /* ignore */ }
|
|
} else if (eventName === 'message') {
|
|
// /rag/chat format: event:message + JSON body with type field
|
|
try {
|
|
const payload = JSON.parse(joined) as SSEMessage;
|
|
onMessage(payload);
|
|
} catch { /* ignore */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function ragChat(
|
|
query: string,
|
|
topK: number = 5,
|
|
onMessage: (data: SSEMessage) => void,
|
|
onError?: (error: Error) => void,
|
|
onComplete?: () => void,
|
|
filters?: string,
|
|
sessionId?: string,
|
|
signal?: AbortSignal,
|
|
): Promise<void> {
|
|
try {
|
|
const response = await fetch(`${AGENT_API_BASE}/agent/chat/stream`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'text/event-stream',
|
|
...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
|
|
},
|
|
body: JSON.stringify({
|
|
query,
|
|
top_k: topK,
|
|
...(filters ? { filters } : {}),
|
|
...(sessionId ? { session_id: sessionId } : {}),
|
|
}),
|
|
signal,
|
|
});
|
|
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const reader = response.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 });
|
|
const parts = buffer.split('\n\n');
|
|
buffer = parts.pop() || '';
|
|
parseSSEChunk(parts.join('\n\n'), onMessage);
|
|
}
|
|
|
|
if (buffer.trim()) {
|
|
parseSSEChunk(buffer, onMessage);
|
|
}
|
|
|
|
if (onComplete) {
|
|
onComplete();
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof DOMException && error.name === 'AbortError') return;
|
|
if (onError) {
|
|
onError(error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
}
|
|
}
|
|
|
|
export type { QuickQuestionsResponse, SSEMessage };
|
|
|
|
/**
|
|
* P0-1 Agentic RAG chat — calls /agent/agentic/stream which runs the full
|
|
* intent-analysis → query-planning → retrieval → grounding-check → answer pipeline.
|
|
*
|
|
* The onMessage callback receives the same event types as ragChat plus
|
|
* ``type: 'thinking'`` events that carry live reasoning-step progress.
|
|
*/
|
|
export async function agenticChat(
|
|
query: string,
|
|
topK: number = 5,
|
|
onMessage: (data: SSEMessage) => void,
|
|
onError?: (error: Error) => void,
|
|
onComplete?: () => void,
|
|
filters?: string,
|
|
sessionId?: string,
|
|
signal?: AbortSignal,
|
|
contextText?: string,
|
|
contextFilename?: string,
|
|
): Promise<void> {
|
|
try {
|
|
const response = await fetch(`${AGENT_API_BASE}/agent/agentic/stream`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'text/event-stream',
|
|
...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
|
|
},
|
|
body: JSON.stringify({
|
|
query,
|
|
top_k: topK,
|
|
...(filters ? { filters } : {}),
|
|
...(sessionId ? { session_id: sessionId } : {}),
|
|
...(contextText ? { context_text: contextText, context_filename: contextFilename ?? '' } : {}),
|
|
}),
|
|
signal,
|
|
});
|
|
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const reader = response.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 });
|
|
const parts = buffer.split('\n\n');
|
|
buffer = parts.pop() || '';
|
|
parseSSEChunk(parts.join('\n\n'), onMessage);
|
|
}
|
|
|
|
if (buffer.trim()) {
|
|
parseSSEChunk(buffer, onMessage);
|
|
}
|
|
|
|
if (onComplete) {
|
|
onComplete();
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof DOMException && error.name === 'AbortError') return;
|
|
if (onError) {
|
|
onError(error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
}
|
|
}
|