- Backend: Add expert transformation router with 3-step SSE pipeline - Step 0: Generate diverse expert team (random domains) - Step 1: Each expert generates keywords for attributes - Step 2: Batch generate descriptions for expert keywords - Backend: Add simplified prompts for reliable JSON output - Frontend: Add TransformationPanel with React Flow visualization - Frontend: Add TransformationInputPanel for expert configuration - Expert count (2-8), keywords per expert (1-3) - Custom expert domains support - Frontend: Add expert keyword nodes with expert badges - Frontend: Improve description card layout (wider cards, more spacing) - Frontend: Add fallback for missing descriptions with visual indicators 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
302 lines
8.5 KiB
TypeScript
302 lines
8.5 KiB
TypeScript
import type {
|
||
ModelListResponse,
|
||
StreamAnalyzeRequest,
|
||
Step1Result,
|
||
Step0Result,
|
||
CategoryDefinition,
|
||
DynamicStep1Result,
|
||
DAGStreamAnalyzeResponse,
|
||
TransformationRequest,
|
||
TransformationCategoryResult,
|
||
ExpertTransformationRequest,
|
||
ExpertTransformationCategoryResult,
|
||
ExpertProfile
|
||
} from '../types';
|
||
|
||
// 自動使用當前瀏覽器的 hostname,支援遠端存取
|
||
const API_BASE_URL = `http://${window.location.hostname}:8000/api`;
|
||
|
||
export interface SSECallbacks {
|
||
onStep0Start?: () => void;
|
||
onStep0Complete?: (result: Step0Result) => void;
|
||
onCategoriesResolved?: (categories: CategoryDefinition[]) => void;
|
||
onStep1Start?: () => void;
|
||
onStep1Complete?: (result: Step1Result | DynamicStep1Result) => void;
|
||
onRelationshipsStart?: () => void;
|
||
onRelationshipsComplete?: (count: number) => void;
|
||
onDone?: (response: DAGStreamAnalyzeResponse) => void;
|
||
onError?: (error: string) => void;
|
||
}
|
||
|
||
export async function analyzeAttributesStream(
|
||
request: StreamAnalyzeRequest,
|
||
callbacks: SSECallbacks
|
||
): Promise<void> {
|
||
const response = await fetch(`${API_BASE_URL}/analyze`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(request),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`API error: ${response.statusText}`);
|
||
}
|
||
|
||
const reader = response.body?.getReader();
|
||
if (!reader) {
|
||
throw new Error('No response body');
|
||
}
|
||
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
|
||
// 解析 SSE 事件
|
||
const lines = buffer.split('\n\n');
|
||
buffer = lines.pop() || ''; // 保留未完成的部分
|
||
|
||
for (const chunk of lines) {
|
||
if (!chunk.trim()) continue;
|
||
|
||
const eventMatch = chunk.match(/event: (\w+)/);
|
||
const dataMatch = chunk.match(/data: (.+)/s);
|
||
|
||
if (eventMatch && dataMatch) {
|
||
const eventType = eventMatch[1];
|
||
try {
|
||
const eventData = JSON.parse(dataMatch[1]);
|
||
|
||
switch (eventType) {
|
||
case 'step0_start':
|
||
callbacks.onStep0Start?.();
|
||
break;
|
||
case 'step0_complete':
|
||
callbacks.onStep0Complete?.(eventData.result);
|
||
break;
|
||
case 'categories_resolved':
|
||
callbacks.onCategoriesResolved?.(eventData.categories);
|
||
break;
|
||
case 'step1_start':
|
||
callbacks.onStep1Start?.();
|
||
break;
|
||
case 'step1_complete':
|
||
callbacks.onStep1Complete?.(eventData.result);
|
||
break;
|
||
case 'relationships_start':
|
||
callbacks.onRelationshipsStart?.();
|
||
break;
|
||
case 'relationships_complete':
|
||
callbacks.onRelationshipsComplete?.(eventData.count);
|
||
break;
|
||
case 'done':
|
||
callbacks.onDone?.(eventData);
|
||
break;
|
||
case 'error':
|
||
callbacks.onError?.(eventData.error);
|
||
break;
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to parse SSE event:', e, chunk);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function getModels(): Promise<ModelListResponse> {
|
||
const response = await fetch(`${API_BASE_URL}/models`);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`API error: ${response.statusText}`);
|
||
}
|
||
|
||
return response.json();
|
||
}
|
||
|
||
// ===== Transformation Agent API =====
|
||
|
||
export interface TransformationSSECallbacks {
|
||
onKeywordStart?: () => void;
|
||
onKeywordComplete?: (keywords: string[]) => void;
|
||
onDescriptionStart?: () => void;
|
||
onDescriptionComplete?: (count: number) => void;
|
||
onDone?: (result: TransformationCategoryResult) => void;
|
||
onError?: (error: string) => void;
|
||
}
|
||
|
||
export async function transformCategoryStream(
|
||
request: TransformationRequest,
|
||
callbacks: TransformationSSECallbacks
|
||
): Promise<void> {
|
||
const response = await fetch(`${API_BASE_URL}/transformation/category`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(request),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`API error: ${response.statusText}`);
|
||
}
|
||
|
||
const reader = response.body?.getReader();
|
||
if (!reader) {
|
||
throw new Error('No response body');
|
||
}
|
||
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
|
||
// 解析 SSE 事件
|
||
const lines = buffer.split('\n\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
for (const chunk of lines) {
|
||
if (!chunk.trim()) continue;
|
||
|
||
const eventMatch = chunk.match(/event: (\w+)/);
|
||
const dataMatch = chunk.match(/data: (.+)/s);
|
||
|
||
if (eventMatch && dataMatch) {
|
||
const eventType = eventMatch[1];
|
||
try {
|
||
const eventData = JSON.parse(dataMatch[1]);
|
||
|
||
switch (eventType) {
|
||
case 'keyword_start':
|
||
callbacks.onKeywordStart?.();
|
||
break;
|
||
case 'keyword_complete':
|
||
callbacks.onKeywordComplete?.(eventData.keywords);
|
||
break;
|
||
case 'description_start':
|
||
callbacks.onDescriptionStart?.();
|
||
break;
|
||
case 'description_complete':
|
||
callbacks.onDescriptionComplete?.(eventData.count);
|
||
break;
|
||
case 'done':
|
||
callbacks.onDone?.(eventData.result);
|
||
break;
|
||
case 'error':
|
||
callbacks.onError?.(eventData.error);
|
||
break;
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to parse SSE event:', e, chunk);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== Expert Transformation Agent API =====
|
||
|
||
export interface ExpertTransformationSSECallbacks {
|
||
onExpertStart?: () => void;
|
||
onExpertComplete?: (experts: ExpertProfile[]) => void;
|
||
onKeywordStart?: () => void;
|
||
onKeywordProgress?: (data: { attribute: string; count: number }) => void;
|
||
onKeywordComplete?: (totalKeywords: number) => void;
|
||
onDescriptionStart?: () => void;
|
||
onDescriptionComplete?: (count: number) => void;
|
||
onDone?: (data: { result: ExpertTransformationCategoryResult; experts: ExpertProfile[] }) => void;
|
||
onError?: (error: string) => void;
|
||
}
|
||
|
||
export async function expertTransformCategoryStream(
|
||
request: ExpertTransformationRequest,
|
||
callbacks: ExpertTransformationSSECallbacks
|
||
): Promise<void> {
|
||
const response = await fetch(`${API_BASE_URL}/expert-transformation/category`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(request),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`API error: ${response.statusText}`);
|
||
}
|
||
|
||
const reader = response.body?.getReader();
|
||
if (!reader) {
|
||
throw new Error('No response body');
|
||
}
|
||
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
|
||
// 解析 SSE 事件
|
||
const lines = buffer.split('\n\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
for (const chunk of lines) {
|
||
if (!chunk.trim()) continue;
|
||
|
||
const eventMatch = chunk.match(/event: (\w+)/);
|
||
const dataMatch = chunk.match(/data: (.+)/s);
|
||
|
||
if (eventMatch && dataMatch) {
|
||
const eventType = eventMatch[1];
|
||
try {
|
||
const eventData = JSON.parse(dataMatch[1]);
|
||
|
||
switch (eventType) {
|
||
case 'expert_start':
|
||
callbacks.onExpertStart?.();
|
||
break;
|
||
case 'expert_complete':
|
||
callbacks.onExpertComplete?.(eventData.experts);
|
||
break;
|
||
case 'keyword_start':
|
||
callbacks.onKeywordStart?.();
|
||
break;
|
||
case 'keyword_progress':
|
||
callbacks.onKeywordProgress?.(eventData);
|
||
break;
|
||
case 'keyword_complete':
|
||
callbacks.onKeywordComplete?.(eventData.total_keywords);
|
||
break;
|
||
case 'description_start':
|
||
callbacks.onDescriptionStart?.();
|
||
break;
|
||
case 'description_complete':
|
||
callbacks.onDescriptionComplete?.(eventData.count);
|
||
break;
|
||
case 'done':
|
||
callbacks.onDone?.(eventData);
|
||
break;
|
||
case 'error':
|
||
callbacks.onError?.(eventData.error);
|
||
break;
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to parse SSE event:', e, chunk);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|