feat: Add Expert Transformation Agent with multi-expert perspective system

- 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>
This commit is contained in:
2025-12-03 16:26:17 +08:00
parent 1ed1dab78f
commit 534fdbbcc4
25 changed files with 3114 additions and 27 deletions

View File

@@ -1,11 +1,15 @@
import { useState, useRef, useCallback } from 'react';
import { ConfigProvider, Layout, theme, Typography, Space } from 'antd';
import { ApartmentOutlined } from '@ant-design/icons';
import { useState, useRef, useCallback, useEffect } from 'react';
import { ConfigProvider, Layout, theme, Typography, Space, Tabs } from 'antd';
import { ApartmentOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { ThemeToggle } from './components/ThemeToggle';
import { InputPanel } from './components/InputPanel';
import { TransformationInputPanel } from './components/TransformationInputPanel';
import { MindmapPanel } from './components/MindmapPanel';
import { TransformationPanel } from './components/TransformationPanel';
import { useAttribute } from './hooks/useAttribute';
import { getModels } from './services/api';
import type { MindmapDAGRef } from './components/MindmapDAG';
import type { TransformationDAGRef } from './components/TransformationDAG';
import type { CategoryMode } from './types';
const { Header, Sider, Content } = Layout;
@@ -18,12 +22,51 @@ interface VisualSettings {
function App() {
const [isDark, setIsDark] = useState(true);
const [activeTab, setActiveTab] = useState<string>('attribute');
const { loading, progress, error, currentResult, history, analyze, loadFromHistory } = useAttribute();
const [visualSettings, setVisualSettings] = useState<VisualSettings>({
nodeSpacing: 32,
fontSize: 14,
});
const mindmapRef = useRef<MindmapDAGRef>(null);
const transformationRef = useRef<TransformationDAGRef>(null);
// Transformation Agent settings
const [transformModel, setTransformModel] = useState<string>('');
const [transformTemperature, setTransformTemperature] = useState<number>(0.7);
const [expertConfig, setExpertConfig] = useState<{
expert_count: number;
keywords_per_expert: number;
custom_experts?: string[];
}>({
expert_count: 3,
keywords_per_expert: 1,
custom_experts: undefined,
});
const [customExpertsInput, setCustomExpertsInput] = useState('');
const [shouldStartTransform, setShouldStartTransform] = useState(false);
const [transformLoading, setTransformLoading] = useState(false);
// Available models from API
const [availableModels, setAvailableModels] = useState<string[]>([]);
// Fetch models on mount
useEffect(() => {
async function fetchModels() {
try {
const response = await getModels();
setAvailableModels(response.models);
// Set default model for transformation if not set
if (response.models.length > 0 && !transformModel) {
const defaultModel = response.models.find((m) => m.includes('qwen3')) || response.models[0];
setTransformModel(defaultModel);
}
} catch (err) {
console.error('Failed to fetch models:', err);
}
}
fetchModels();
}, []);
const handleAnalyze = async (
query: string,
@@ -41,6 +84,10 @@ function App() {
mindmapRef.current?.resetView();
}, []);
const handleTransform = useCallback(() => {
setShouldStartTransform(true);
}, []);
return (
<ConfigProvider
theme={{
@@ -82,7 +129,7 @@ function App() {
backgroundClip: 'text',
}}
>
Attribute Agent
Novelty Seeking
</Title>
</Space>
<ThemeToggle isDark={isDark} onToggle={setIsDark} />
@@ -95,13 +142,58 @@ function App() {
overflow: 'hidden',
}}
>
<MindmapPanel
ref={mindmapRef}
data={currentResult}
loading={loading}
error={error}
isDark={isDark}
visualSettings={visualSettings}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
style={{ height: '100%' }}
tabBarStyle={{ marginBottom: 8 }}
items={[
{
key: 'attribute',
label: (
<span>
<ApartmentOutlined style={{ marginRight: 8 }} />
Attribute Agent
</span>
),
children: (
<div style={{ height: 'calc(100vh - 140px)' }}>
<MindmapPanel
ref={mindmapRef}
data={currentResult}
loading={loading}
error={error}
isDark={isDark}
visualSettings={visualSettings}
/>
</div>
),
},
{
key: 'transformation',
label: (
<span>
<ThunderboltOutlined style={{ marginRight: 8 }} />
Transformation Agent
</span>
),
children: (
<div style={{ height: 'calc(100vh - 140px)' }}>
<TransformationPanel
ref={transformationRef}
attributeData={currentResult}
isDark={isDark}
model={transformModel}
temperature={transformTemperature}
expertConfig={expertConfig}
shouldStartTransform={shouldStartTransform}
onTransformComplete={() => setShouldStartTransform(false)}
onLoadingChange={setTransformLoading}
/>
</div>
),
},
]}
/>
</Content>
<Sider
@@ -112,17 +204,35 @@ function App() {
overflow: 'auto',
}}
>
<InputPanel
loading={loading}
progress={progress}
history={history}
currentResult={currentResult}
onAnalyze={handleAnalyze}
onLoadHistory={loadFromHistory}
onResetView={handleResetView}
visualSettings={visualSettings}
onVisualSettingsChange={setVisualSettings}
/>
{activeTab === 'attribute' ? (
<InputPanel
loading={loading}
progress={progress}
history={history}
currentResult={currentResult}
onAnalyze={handleAnalyze}
onLoadHistory={loadFromHistory}
onResetView={handleResetView}
visualSettings={visualSettings}
onVisualSettingsChange={setVisualSettings}
/>
) : (
<TransformationInputPanel
onTransform={handleTransform}
loading={transformLoading}
hasData={!!currentResult}
isDark={isDark}
model={transformModel}
temperature={transformTemperature}
expertConfig={expertConfig}
customExpertsInput={customExpertsInput}
onModelChange={setTransformModel}
onTemperatureChange={setTransformTemperature}
onExpertConfigChange={setExpertConfig}
onCustomExpertsInputChange={setCustomExpertsInput}
availableModels={availableModels}
/>
)}
</Sider>
</Layout>
</Layout>

View File

@@ -0,0 +1,106 @@
import { forwardRef, useImperativeHandle } from 'react';
import {
ReactFlow,
useReactFlow,
ReactFlowProvider,
Background,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type {
TransformationDAGResult,
ExpertTransformationDAGResult,
CategoryDefinition
} from '../types';
import {
transformationNodeTypes,
useTransformationLayout,
useExpertTransformationLayout
} from './transformation';
import '../styles/mindmap.css';
interface TransformationDAGProps {
data: TransformationDAGResult | ExpertTransformationDAGResult;
categories: CategoryDefinition[];
isDark: boolean;
}
export interface TransformationDAGRef {
resetView: () => void;
}
const TransformationDAGInner = forwardRef<TransformationDAGRef, TransformationDAGProps>(
({ data, categories, isDark }, ref) => {
const { setViewport } = useReactFlow();
// Check if data is ExpertTransformationDAGResult by checking for 'experts' property
const isExpertTransformation = 'experts' in data;
// Use appropriate layout hook based on data type
const regularLayout = useTransformationLayout(
!isExpertTransformation ? (data as TransformationDAGResult) : null,
categories,
{ isDark, fontSize: 13 }
);
const expertLayout = useExpertTransformationLayout(
isExpertTransformation ? (data as ExpertTransformationDAGResult) : null,
categories,
{ isDark, fontSize: 13 }
);
const { nodes, edges } = isExpertTransformation ? expertLayout : regularLayout;
useImperativeHandle(
ref,
() => ({
resetView: () => {
setViewport({ x: 50, y: 50, zoom: 1 }, { duration: 300 });
},
}),
[setViewport]
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={transformationNodeTypes}
fitView
fitViewOptions={{ padding: 0.3 }}
minZoom={0.3}
maxZoom={2}
defaultViewport={{ x: 50, y: 50, zoom: 1 }}
proOptions={{ hideAttribution: true }}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnDrag
zoomOnScroll
zoomOnPinch
>
<Background
color={isDark ? '#333' : '#e0e0e0'}
gap={20}
size={1}
/>
</ReactFlow>
);
}
);
TransformationDAGInner.displayName = 'TransformationDAGInner';
export const TransformationDAG = forwardRef<TransformationDAGRef, TransformationDAGProps>(
(props, ref) => (
<div
className={`mindmap-container ${props.isDark ? 'mindmap-dark' : 'mindmap-light'}`}
>
<ReactFlowProvider>
<TransformationDAGInner {...props} ref={ref} />
</ReactFlowProvider>
</div>
)
);
TransformationDAG.displayName = 'TransformationDAG';

View File

@@ -0,0 +1,156 @@
import { Card, Select, Slider, Typography, Space, Button, Divider } from 'antd';
import { ThunderboltOutlined } from '@ant-design/icons';
import { ExpertConfigPanel } from './transformation';
const { Title, Text } = Typography;
interface TransformationInputPanelProps {
onTransform: () => void;
loading: boolean;
hasData: boolean;
isDark: boolean;
model: string;
temperature: number;
expertConfig: {
expert_count: number;
keywords_per_expert: number;
custom_experts?: string[];
};
customExpertsInput: string;
onModelChange: (model: string) => void;
onTemperatureChange: (temperature: number) => void;
onExpertConfigChange: (config: {
expert_count: number;
keywords_per_expert: number;
custom_experts?: string[];
}) => void;
onCustomExpertsInputChange: (value: string) => void;
availableModels: string[];
}
export const TransformationInputPanel: React.FC<TransformationInputPanelProps> = ({
onTransform,
loading,
hasData,
isDark,
model,
temperature,
expertConfig,
customExpertsInput,
onModelChange,
onTemperatureChange,
onExpertConfigChange,
onCustomExpertsInputChange,
availableModels,
}) => {
return (
<div style={{ padding: 16 }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* Header */}
<div>
<Title level={4} style={{ margin: 0 }}>
<ThunderboltOutlined style={{ marginRight: 8 }} />
Transformation Agent
</Title>
<Text type="secondary" style={{ fontSize: 12 }}>
使
</Text>
</div>
<Divider style={{ margin: 0 }} />
{/* LLM Settings */}
<Card
size="small"
title="LLM 設定"
style={{
background: isDark ? '#1f1f1f' : '#fafafa',
border: `1px solid ${isDark ? '#434343' : '#d9d9d9'}`,
}}
>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div>
<Text style={{ fontSize: 12 }}></Text>
<Select
value={model}
onChange={onModelChange}
style={{ width: '100%', marginTop: 8 }}
options={availableModels.map((m) => ({ label: m, value: m }))}
/>
</div>
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 8,
}}
>
<Text style={{ fontSize: 12 }}>Temperature</Text>
<Text style={{ fontSize: 12, color: isDark ? '#1890ff' : '#1890ff' }}>
{temperature.toFixed(1)}
</Text>
</div>
<Slider
min={0}
max={1}
step={0.1}
value={temperature}
onChange={onTemperatureChange}
marks={{
0: '0',
0.5: '0.5',
1: '1',
}}
/>
</div>
</Space>
</Card>
{/* Expert Configuration */}
<ExpertConfigPanel
expertCount={expertConfig.expert_count}
keywordsPerExpert={expertConfig.keywords_per_expert}
customExperts={customExpertsInput}
onChange={(config) => {
onExpertConfigChange(config);
onCustomExpertsInputChange(config.custom_experts?.join(', ') || '');
}}
isDark={isDark}
/>
<Divider style={{ margin: 0 }} />
{/* Transform Button */}
<Button
type="primary"
size="large"
block
icon={<ThunderboltOutlined />}
onClick={onTransform}
loading={loading}
disabled={!hasData || loading}
>
{loading ? '轉換中...' : '開始轉換'}
</Button>
{!hasData && (
<div
style={{
padding: 12,
background: isDark ? '#2b1d16' : '#fff7e6',
border: `1px solid ${isDark ? '#594214' : '#ffd591'}`,
borderRadius: 4,
textAlign: 'center',
}}
>
<Text type="warning" style={{ fontSize: 12 }}>
Attribute Agent
</Text>
</div>
)}
</Space>
</div>
);
};

View File

@@ -0,0 +1,259 @@
import { forwardRef, useMemo, useCallback, useEffect } from 'react';
import { Empty, Spin, Button, Progress, Card, Space, Typography, Tag } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import type { AttributeDAG, ExpertTransformationInput } from '../types';
import { TransformationDAG } from './TransformationDAG';
import type { TransformationDAGRef } from './TransformationDAG';
import { useExpertTransformation } from '../hooks/useExpertTransformation';
const { Text } = Typography;
interface TransformationPanelProps {
attributeData: AttributeDAG | null;
isDark: boolean;
model?: string;
temperature?: number;
expertConfig: {
expert_count: number;
keywords_per_expert: number;
custom_experts?: string[];
};
shouldStartTransform: boolean;
onTransformComplete: () => void;
onLoadingChange: (loading: boolean) => void;
}
export const TransformationPanel = forwardRef<TransformationDAGRef, TransformationPanelProps>(
({ attributeData, isDark, model, temperature, expertConfig, shouldStartTransform, onTransformComplete, onLoadingChange }, ref) => {
const {
loading,
progress,
results,
transformAll,
clearResults,
} = useExpertTransformation({ model, temperature });
// Notify parent of loading state changes
useEffect(() => {
onLoadingChange(loading);
}, [loading, onLoadingChange]);
// Build expert transformation input from attribute data
const transformationInput = useMemo((): ExpertTransformationInput | null => {
if (!attributeData) return null;
const attributesByCategory: Record<string, string[]> = {};
for (const node of attributeData.nodes) {
if (!attributesByCategory[node.category]) {
attributesByCategory[node.category] = [];
}
attributesByCategory[node.category].push(node.name);
}
return {
query: attributeData.query,
categories: attributeData.categories,
attributesByCategory,
expertConfig,
};
}, [attributeData, expertConfig]);
const handleTransform = useCallback(async () => {
if (transformationInput) {
await transformAll(transformationInput);
onTransformComplete();
}
}, [transformationInput, transformAll, onTransformComplete]);
const handleClear = useCallback(() => {
clearResults();
}, [clearResults]);
// Handle shouldStartTransform trigger from control panel
useEffect(() => {
if (shouldStartTransform && transformationInput && !loading && !results) {
handleTransform();
}
}, [shouldStartTransform, transformationInput, loading, results, handleTransform]);
// Calculate progress percentage
const progressPercent = useMemo(() => {
if (!transformationInput || progress.step === 'idle') return 0;
const totalCategories = transformationInput.categories.length;
if (totalCategories === 0) return 0;
const completed = progress.processedCategories.length;
return Math.round((completed / totalCategories) * 100);
}, [transformationInput, progress]);
// No attribute data - show empty state
if (!attributeData) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
flexDirection: 'column',
gap: 16,
}}
>
<Empty
description="請先在 Attribute Agent 分頁執行分析"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
<Text type="secondary">
Transformation Agent Attribute Agent
</Text>
</div>
);
}
// Has attribute data but no results yet - show ready message
if (!results && !loading) {
const categoryCount = attributeData.categories.length;
const nodeCount = attributeData.nodes.length;
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
flexDirection: 'column',
gap: 24,
}}
>
<Card
style={{
textAlign: 'center',
maxWidth: 400,
background: isDark ? '#1f1f1f' : '#fff',
}}
>
<Space direction="vertical" size="large">
<div>
<Text strong style={{ fontSize: 16 }}>
{attributeData.query}
</Text>
</div>
<Space wrap>
<Tag color="blue">{categoryCount} </Tag>
<Tag color="green">{nodeCount} </Tag>
</Space>
<Text type="secondary">
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
</Space>
</Card>
</div>
);
}
// Loading state - show progress with expert info
if (loading) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
flexDirection: 'column',
gap: 24,
}}
>
<Card
style={{
textAlign: 'center',
maxWidth: 450,
background: isDark ? '#1f1f1f' : '#fff',
}}
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Spin size="large" />
<div>
<Text strong style={{ fontSize: 16 }}>
{progress.message || '處理中...'}
</Text>
</div>
{progress.currentCategory && (
<Tag color="processing">{progress.currentCategory}</Tag>
)}
{progress.experts && progress.experts.length > 0 && (
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<div style={{ marginTop: 8 }}>
<Space wrap size="small">
{progress.experts.map((expert) => (
<Tag key={expert.id} color="blue" style={{ fontSize: 11 }}>
{expert.name}
</Tag>
))}
</Space>
</div>
</div>
)}
{progress.currentAttribute && (
<Text type="secondary" style={{ fontSize: 12 }}>
{progress.currentAttribute}
</Text>
)}
<Progress
percent={progressPercent}
status="active"
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
/>
<Text type="secondary">
{progress.processedCategories.length} / {transformationInput?.categories.length || 0}
</Text>
</Space>
</Card>
</div>
);
}
// Has results - show DAG visualization
if (results) {
return (
<div style={{ height: '100%', position: 'relative' }}>
<div
style={{
position: 'absolute',
top: 16,
right: 16,
zIndex: 10,
}}
>
<Button
icon={<ReloadOutlined />}
onClick={handleClear}
>
</Button>
</div>
<TransformationDAG
ref={ref}
data={results}
categories={attributeData.categories}
isDark={isDark}
/>
</div>
);
}
// Fallback - should not reach here
return null;
}
);
TransformationPanel.displayName = 'TransformationPanel';

View File

@@ -50,9 +50,6 @@ export function useDAGLayout(
const nodes: Node[] = [];
const sortedCategories = [...data.categories].sort((a, b) => a.order - b.order);
// Unified column spacing
const columnGap = 60; // Consistent gap between ALL adjacent elements
// Build category color map
const categoryColors: Record<string, { fill: string; stroke: string }> = {};
sortedCategories.forEach((cat, index) => {

View File

@@ -0,0 +1,185 @@
import { Card, Slider, Input, Space, Typography, Divider, Tag } from 'antd';
import { TeamOutlined, BulbOutlined } from '@ant-design/icons';
const { Text, Title } = Typography;
interface ExpertConfigPanelProps {
expertCount: number;
keywordsPerExpert: number;
customExperts: string;
onChange: (config: {
expert_count: number;
keywords_per_expert: number;
custom_experts?: string[];
}) => void;
isDark: boolean;
}
export const ExpertConfigPanel: React.FC<ExpertConfigPanelProps> = ({
expertCount,
keywordsPerExpert,
customExperts,
onChange,
isDark,
}) => {
const handleExpertCountChange = (value: number) => {
onChange({
expert_count: value,
keywords_per_expert: keywordsPerExpert,
custom_experts: customExperts ? customExperts.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
});
};
const handleKeywordsPerExpertChange = (value: number) => {
onChange({
expert_count: expertCount,
keywords_per_expert: value,
custom_experts: customExperts ? customExperts.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
});
};
const handleCustomExpertsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
onChange({
expert_count: expertCount,
keywords_per_expert: keywordsPerExpert,
custom_experts: value ? value.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
});
};
// Calculate expected keywords per attribute
const expectedKeywordsPerAttribute = expertCount * keywordsPerExpert;
return (
<Card
size="small"
style={{
background: isDark ? '#1f1f1f' : '#fafafa',
border: `1px solid ${isDark ? '#434343' : '#d9d9d9'}`,
}}
>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div>
<Title level={5} style={{ margin: 0 }}>
<TeamOutlined style={{ marginRight: 8 }} />
</Title>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
</div>
<Divider style={{ margin: '8px 0' }} />
<div>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text></Text>
<Tag color="blue">{expertCount} </Tag>
</div>
<Slider
min={2}
max={8}
value={expertCount}
onChange={handleExpertCountChange}
marks={{
2: '2',
4: '4',
6: '6',
8: '8',
}}
tooltip={{ formatter: (value) => `${value} 位專家` }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
2-8
</Text>
</Space>
</div>
<div>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text></Text>
<Tag color="green">{keywordsPerExpert} /</Tag>
</div>
<Slider
min={1}
max={3}
value={keywordsPerExpert}
onChange={handleKeywordsPerExpertChange}
marks={{
1: '1',
2: '2',
3: '3',
}}
tooltip={{ formatter: (value) => `${value} 個關鍵字` }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
1-3
</Text>
</Space>
</div>
<div>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Text></Text>
<Input
placeholder="例:藥師, 工程師, 政治家(以逗號分隔)"
value={customExperts}
onChange={handleCustomExpertsChange}
allowClear
/>
<Text type="secondary" style={{ fontSize: 12 }}>
使
</Text>
</Space>
</div>
<Divider style={{ margin: '8px 0' }} />
<div
style={{
padding: '12px',
background: isDark ? '#141414' : '#f0f2f5',
borderRadius: 4,
}}
>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<BulbOutlined style={{ color: '#faad14' }} />
<Text strong></Text>
</div>
<div style={{ paddingLeft: 24 }}>
<Text>
{' '}
<Text strong style={{ color: '#1890ff' }}>
{expectedKeywordsPerAttribute}
</Text>{' '}
</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
{expertCount} × {keywordsPerExpert} /
</Text>
</div>
</Space>
</div>
{expectedKeywordsPerAttribute > 10 && (
<div
style={{
padding: '8px 12px',
background: isDark ? '#2b1d16' : '#fff7e6',
border: `1px solid ${isDark ? '#594214' : '#ffd591'}`,
borderRadius: 4,
}}
>
<Text type="warning" style={{ fontSize: 12 }}>
</Text>
</div>
)}
</Space>
</Card>
);
};

View File

@@ -0,0 +1,29 @@
import { KeywordNode } from './nodes/KeywordNode';
import { ExpertKeywordNode } from './nodes/ExpertKeywordNode';
import { DescriptionNode } from './nodes/DescriptionNode';
import { CategoryNode } from './nodes/CategoryNode';
import { OriginalAttributeNode } from './nodes/OriginalAttributeNode';
import { DividerNode } from './nodes/DividerNode';
import { QueryNode } from '../dag/nodes/QueryNode';
export const transformationNodeTypes = {
query: QueryNode,
category: CategoryNode,
keyword: KeywordNode,
expertKeyword: ExpertKeywordNode,
description: DescriptionNode,
originalAttribute: OriginalAttributeNode,
divider: DividerNode,
};
export {
KeywordNode,
ExpertKeywordNode,
DescriptionNode,
CategoryNode,
OriginalAttributeNode,
DividerNode
};
export { useTransformationLayout } from './useTransformationLayout';
export { useExpertTransformationLayout } from './useExpertTransformationLayout';
export { ExpertConfigPanel } from './ExpertConfigPanel';

View File

@@ -0,0 +1,38 @@
import { memo } from 'react';
interface CategoryNodeProps {
data: {
label: string;
color: string;
attributeCount: number;
isDark: boolean;
};
}
export const CategoryNode = memo(({ data }: CategoryNodeProps) => {
const { label, color, isDark } = data;
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '10px 16px',
borderRadius: 8,
background: `linear-gradient(135deg, ${color} 0%, ${color}dd 100%)`,
color: '#fff',
fontSize: '14px',
fontWeight: 'bold',
textAlign: 'center',
whiteSpace: 'nowrap',
userSelect: 'none',
boxShadow: `0 3px 10px ${isDark ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.2)'}`,
}}
>
<span>{label}</span>
</div>
);
});
CategoryNode.displayName = 'CategoryNode';

View File

@@ -0,0 +1,84 @@
import { memo, useState } from 'react';
interface DescriptionNodeProps {
data: {
keyword: string;
description: string;
color: string;
isDark: boolean;
};
}
export const DescriptionNode = memo(({ data }: DescriptionNodeProps) => {
const { keyword, description, isDark } = data;
const [isHovered, setIsHovered] = useState(false);
return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
padding: '10px 14px',
borderRadius: 8,
background: isDark
? 'linear-gradient(135deg, rgba(82, 196, 26, 0.2) 0%, rgba(82, 196, 26, 0.1) 100%)'
: 'linear-gradient(135deg, rgba(82, 196, 26, 0.15) 0%, rgba(82, 196, 26, 0.05) 100%)',
border: `2px solid ${isHovered ? '#52c41a' : '#52c41a80'}`,
color: isDark ? '#fff' : '#333',
fontSize: '12px',
width: 400,
minHeight: 50,
cursor: 'pointer',
transition: 'all 0.2s ease',
userSelect: 'none',
boxShadow: isHovered
? `0 4px 12px ${isDark ? 'rgba(82, 196, 26, 0.4)' : 'rgba(82, 196, 26, 0.25)'}`
: `0 2px 6px ${isDark ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.1)'}`,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 6,
paddingBottom: 6,
borderBottom: `1px solid ${isDark ? 'rgba(82, 196, 26, 0.3)' : 'rgba(82, 196, 26, 0.2)'}`,
}}
>
<span
style={{
background: 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)',
color: '#fff',
padding: '2px 6px',
borderRadius: 4,
fontSize: '10px',
fontWeight: 700,
}}
>
</span>
<span
style={{
fontWeight: 600,
color: '#52c41a',
fontSize: '11px',
}}
>
{keyword}
</span>
</div>
<div
style={{
lineHeight: 1.5,
wordBreak: 'break-word',
fontSize: '13px',
}}
>
{description}
</div>
</div>
);
});
DescriptionNode.displayName = 'DescriptionNode';

View File

@@ -0,0 +1,26 @@
import { memo } from 'react';
interface DividerNodeProps {
data: {
isDark: boolean;
};
}
export const DividerNode = memo(({ data }: DividerNodeProps) => {
const { isDark } = data;
return (
<div
style={{
width: '100%',
height: 2,
background: isDark
? 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.1) 50%, transparent 100%)'
: 'linear-gradient(90deg, transparent 0%, rgba(0,0,0,0.08) 50%, transparent 100%)',
pointerEvents: 'none',
}}
/>
);
});
DividerNode.displayName = 'DividerNode';

View File

@@ -0,0 +1,92 @@
import { memo, useState } from 'react';
interface ExpertKeywordNodeProps {
data: {
label: string;
expertName: string;
expertId: string;
color: string;
isDark: boolean;
};
}
export const ExpertKeywordNode = memo(({ data }: ExpertKeywordNodeProps) => {
const { label, expertName, color, isDark } = data;
const [isHovered, setIsHovered] = useState(false);
return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: 4,
padding: '6px 12px',
paddingRight: '36px',
marginTop: 18,
borderRadius: 6,
background: isDark
? 'linear-gradient(135deg, rgba(24, 144, 255, 0.15) 0%, rgba(255, 255, 255, 0.1) 100%)'
: 'linear-gradient(135deg, rgba(24, 144, 255, 0.1) 0%, rgba(0, 0, 0, 0.03) 100%)',
border: `2px solid ${color}`,
borderWidth: isHovered ? 3 : 2,
color: isDark ? '#fff' : '#333',
fontSize: '13px',
fontWeight: 500,
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
whiteSpace: 'nowrap',
userSelect: 'none',
filter: isHovered ? 'brightness(1.1)' : 'none',
boxShadow: isDark
? '0 2px 8px rgba(24, 144, 255, 0.2)'
: '0 2px 8px rgba(24, 144, 255, 0.15)',
}}
>
{/* Expert Badge - positioned above the node */}
<div
style={{
position: 'absolute',
top: -18,
left: 0,
padding: '2px 6px',
borderRadius: 3,
background: isDark
? 'rgba(24, 144, 255, 0.8)'
: 'rgba(24, 144, 255, 0.9)',
color: '#fff',
fontSize: '10px',
fontWeight: 500,
whiteSpace: 'nowrap',
}}
>
{expertName}
</div>
{/* Keyword Label */}
<div>{label}</div>
{/* NEW Badge - positioned below the node */}
<span
style={{
position: 'absolute',
bottom: -14,
right: 0,
padding: '2px 5px',
borderRadius: 3,
background: 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)',
color: '#fff',
fontSize: '9px',
fontWeight: 600,
}}
>
NEW
</span>
</div>
);
});
ExpertKeywordNode.displayName = 'ExpertKeywordNode';

View File

@@ -0,0 +1,65 @@
import { memo, useState } from 'react';
interface KeywordNodeProps {
data: {
label: string;
color: string;
isDark: boolean;
};
}
export const KeywordNode = memo(({ data }: KeywordNodeProps) => {
const { label, color, isDark } = data;
const [isHovered, setIsHovered] = useState(false);
return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
position: 'relative',
padding: '6px 12px',
paddingRight: '36px',
borderRadius: 6,
background: isDark
? 'linear-gradient(135deg, rgba(250, 173, 20, 0.15) 0%, rgba(255, 255, 255, 0.1) 100%)'
: 'linear-gradient(135deg, rgba(250, 173, 20, 0.1) 0%, rgba(0, 0, 0, 0.03) 100%)',
border: `2px solid ${color}`,
borderWidth: isHovered ? 3 : 2,
color: isDark ? '#fff' : '#333',
fontSize: '13px',
fontWeight: 500,
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
whiteSpace: 'nowrap',
userSelect: 'none',
filter: isHovered ? 'brightness(1.1)' : 'none',
boxShadow: isDark
? '0 2px 8px rgba(250, 173, 20, 0.2)'
: '0 2px 8px rgba(250, 173, 20, 0.15)',
}}
>
{label}
<span
style={{
position: 'absolute',
top: -6,
right: -6,
padding: '2px 5px',
borderRadius: 4,
background: 'linear-gradient(135deg, #faad14 0%, #ffc53d 100%)',
color: '#000',
fontSize: '9px',
fontWeight: 700,
letterSpacing: '0.5px',
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
}}
>
NEW
</span>
</div>
);
});
KeywordNode.displayName = 'KeywordNode';

View File

@@ -0,0 +1,39 @@
import { memo } from 'react';
interface OriginalAttributeNodeProps {
data: {
label: string;
color: string;
isDark: boolean;
};
}
export const OriginalAttributeNode = memo(({ data }: OriginalAttributeNodeProps) => {
const { label, color, isDark } = data;
return (
<div
style={{
padding: '6px 14px',
borderRadius: 6,
background: isDark
? `linear-gradient(135deg, ${color}33 0%, ${color}1a 100%)`
: `linear-gradient(135deg, ${color}22 0%, ${color}11 100%)`,
border: `2px solid ${color}`,
color: isDark ? 'rgba(255,255,255,0.95)' : 'rgba(0,0,0,0.85)',
fontSize: '13px',
fontWeight: 500,
textAlign: 'center',
whiteSpace: 'nowrap',
userSelect: 'none',
boxShadow: isDark
? '0 2px 6px rgba(0,0,0,0.3)'
: '0 2px 6px rgba(0,0,0,0.1)',
}}
>
{label}
</div>
);
});
OriginalAttributeNode.displayName = 'OriginalAttributeNode';

View File

@@ -0,0 +1,332 @@
import { useMemo } from 'react';
import type { Node, Edge } from '@xyflow/react';
import type { ExpertTransformationDAGResult, CategoryDefinition } from '../../types';
interface LayoutConfig {
isDark: boolean;
fontSize?: number;
}
const COLOR_PALETTE = [
{ dark: '#177ddc', light: '#1890ff' }, // blue
{ dark: '#854eca', light: '#722ed1' }, // purple
{ dark: '#13a8a8', light: '#13c2c2' }, // cyan
{ dark: '#d87a16', light: '#fa8c16' }, // orange
{ dark: '#49aa19', light: '#52c41a' }, // green
{ dark: '#1677ff', light: '#1890ff' }, // blue
{ dark: '#eb2f96', light: '#f759ab' }, // magenta
{ dark: '#faad14', light: '#ffc53d' }, // gold
];
// Estimate description card height based on text length
function estimateDescriptionHeight(description: string): number {
const cardWidth = 400;
const padding = 24;
const headerHeight = 32;
const charPerLine = Math.floor(cardWidth / 14);
const lineHeight = 20;
const lines = Math.ceil(description.length / charPerLine);
return Math.min(padding + headerHeight + lines * lineHeight, 140);
}
export function useExpertTransformationLayout(
data: ExpertTransformationDAGResult | null,
categories: CategoryDefinition[],
config: LayoutConfig
): { nodes: Node[]; edges: Edge[] } {
return useMemo(() => {
if (!data || data.results.length === 0) {
return { nodes: [], edges: [] };
}
const { isDark, fontSize = 13 } = config;
const nodes: Node[] = [];
const edges: Edge[] = [];
// Layout constants
const colStep = 140;
const categoryRowGap = 120;
const minItemGap = 12;
const expertKeywordGap = 24; // gap between expert keywords
const queryX = 0;
const categoryX = colStep;
const originalAttrX = colStep * 2;
const keywordX = colStep * 3.2;
const descriptionX = colStep * 4.8;
// Build category color map
const categoryColors: Record<string, string> = {};
categories.forEach((cat, index) => {
const paletteIndex = index % COLOR_PALETTE.length;
categoryColors[cat.name] = isDark
? COLOR_PALETTE[paletteIndex].dark
: COLOR_PALETTE[paletteIndex].light;
});
// Pre-calculate layouts for each category
interface CategoryLayout {
attributeGroups: Array<{
attribute: string;
expertKeywords: Array<{
keyword: string;
expertName: string;
expertId: string;
yOffset: number;
}>;
descriptionYPositions: number[];
totalHeight: number;
}>;
totalHeight: number;
}
const categoryLayouts: CategoryLayout[] = data.results.map((result) => {
// Group expert keywords by source_attribute
const attributeMap = new Map<string, typeof result.expert_keywords>();
result.expert_keywords.forEach((kw) => {
if (!attributeMap.has(kw.source_attribute)) {
attributeMap.set(kw.source_attribute, []);
}
attributeMap.get(kw.source_attribute)!.push(kw);
});
const attributeGroups: CategoryLayout['attributeGroups'] = [];
let categoryTotalHeight = 0;
// Process each source attribute group
result.original_attributes.forEach((attr) => {
const expertKeywords = attributeMap.get(attr) || [];
const keywordPositions: typeof attributeGroups[0]['expertKeywords'] = [];
const descYPositions: number[] = [];
let currentY = 0;
expertKeywords.forEach((kw) => {
keywordPositions.push({
keyword: kw.keyword,
expertName: kw.expert_name,
expertId: kw.expert_id,
yOffset: currentY,
});
// Find matching description
const matchingDesc = result.descriptions.find(
(d) => d.keyword === kw.keyword && d.expert_id === kw.expert_id
);
const descHeight = matchingDesc
? estimateDescriptionHeight(matchingDesc.description)
: 50;
descYPositions.push(currentY);
currentY += descHeight + expertKeywordGap;
});
const groupHeight = currentY > 0 ? currentY - expertKeywordGap + minItemGap : 0;
attributeGroups.push({
attribute: attr,
expertKeywords: keywordPositions,
descriptionYPositions: descYPositions,
totalHeight: groupHeight,
});
categoryTotalHeight += groupHeight;
});
return { attributeGroups, totalHeight: categoryTotalHeight };
});
// Calculate total height for query centering
const totalHeight = categoryLayouts.reduce(
(sum, layout, i) =>
sum + layout.totalHeight + (i < categoryLayouts.length - 1 ? categoryRowGap : 0),
0
);
// Add Query node (centered vertically)
const queryY = totalHeight / 2 - 20;
nodes.push({
id: 'query-node',
type: 'query',
position: { x: queryX, y: queryY },
data: {
label: data.query,
isDark,
fontSize,
},
draggable: false,
selectable: false,
});
// Track current Y position
let currentY = 0;
// Process each category result
data.results.forEach((result, catIndex) => {
const categoryId = `category-${catIndex}`;
const color = categoryColors[result.category] || '#666';
const layout = categoryLayouts[catIndex];
// Category Y position (centered within its group)
const categoryY = currentY + layout.totalHeight / 2 - 20;
// Add category node
nodes.push({
id: categoryId,
type: 'category',
position: { x: categoryX, y: categoryY },
data: {
label: result.category,
color,
attributeCount: result.original_attributes.length,
isDark,
},
draggable: false,
selectable: false,
});
// Edge from query to category
edges.push({
id: `edge-query-${categoryId}`,
source: 'query-node',
target: categoryId,
type: 'smoothstep',
style: { stroke: color, strokeWidth: 2, opacity: 0.6 },
animated: false,
});
// Process each attribute group
layout.attributeGroups.forEach((group, attrIndex) => {
const attrId = `orig-${catIndex}-${attrIndex}`;
const attrY = currentY + group.totalHeight / 2 - 12;
// Add original attribute node
nodes.push({
id: attrId,
type: 'originalAttribute',
position: { x: originalAttrX, y: attrY },
data: {
label: group.attribute,
color,
isDark,
},
draggable: false,
selectable: false,
});
// Edge from category to original attribute
edges.push({
id: `edge-${categoryId}-${attrId}`,
source: categoryId,
target: attrId,
type: 'smoothstep',
style: { stroke: color, strokeWidth: 1, opacity: 0.3 },
animated: false,
});
// Add expert keyword and description nodes
group.expertKeywords.forEach((kw, kwIndex) => {
const keywordId = `keyword-${catIndex}-${attrIndex}-${kwIndex}`;
const keywordY = currentY + kw.yOffset;
// Expert keyword node
nodes.push({
id: keywordId,
type: 'expertKeyword',
position: { x: keywordX, y: keywordY },
data: {
label: kw.keyword,
expertName: kw.expertName,
expertId: kw.expertId,
color,
isDark,
},
draggable: false,
selectable: false,
});
// Edge from original attribute to expert keyword
edges.push({
id: `edge-${attrId}-${keywordId}`,
source: attrId,
target: keywordId,
type: 'smoothstep',
style: {
stroke: '#1890ff',
strokeWidth: 2,
opacity: 0.7,
strokeDasharray: '5,5',
},
animated: true,
});
// Find matching description - match by expert_id and source_attribute
// (more lenient than exact keyword match since LLM may return slightly different text)
const matchingDesc = result.descriptions.find(
(d) => d.expert_id === kw.expertId &&
(d.keyword === kw.keyword ||
d.keyword.includes(kw.keyword) ||
kw.keyword.includes(d.keyword))
);
// Always show a description node (use fallback if not found)
const descId = `desc-${catIndex}-${attrIndex}-${kwIndex}`;
const descY = currentY + group.descriptionYPositions[kwIndex];
nodes.push({
id: descId,
type: 'description',
position: { x: descriptionX, y: descY },
data: {
keyword: matchingDesc?.keyword || kw.keyword,
description: matchingDesc?.description || '(描述生成中...',
color,
isDark,
},
draggable: false,
selectable: false,
});
// Edge from expert keyword to description (always show)
edges.push({
id: `edge-${keywordId}-${descId}`,
source: keywordId,
target: descId,
type: 'smoothstep',
style: {
stroke: matchingDesc ? '#52c41a' : '#999',
strokeWidth: 2,
opacity: matchingDesc ? 0.6 : 0.3,
strokeDasharray: matchingDesc ? undefined : '4,4',
},
animated: !matchingDesc,
});
});
currentY += group.totalHeight;
});
// Add divider line between categories (except after last one)
if (catIndex < data.results.length - 1) {
const dividerY = currentY + categoryRowGap / 2 - 1;
nodes.push({
id: `divider-${catIndex}`,
type: 'divider',
position: { x: queryX, y: dividerY },
data: {
isDark,
},
draggable: false,
selectable: false,
style: {
width: descriptionX + 400,
zIndex: -1,
},
});
currentY += categoryRowGap;
}
});
return { nodes, edges };
}, [data, categories, config]);
}

View File

@@ -0,0 +1,294 @@
import { useMemo } from 'react';
import type { Node, Edge } from '@xyflow/react';
import type { TransformationDAGResult, CategoryDefinition } from '../../types';
interface LayoutConfig {
isDark: boolean;
fontSize?: number;
}
const COLOR_PALETTE = [
{ dark: '#177ddc', light: '#1890ff' }, // blue
{ dark: '#854eca', light: '#722ed1' }, // purple
{ dark: '#13a8a8', light: '#13c2c2' }, // cyan
{ dark: '#d87a16', light: '#fa8c16' }, // orange
{ dark: '#49aa19', light: '#52c41a' }, // green
{ dark: '#1677ff', light: '#1890ff' }, // blue
{ dark: '#eb2f96', light: '#f759ab' }, // magenta
{ dark: '#faad14', light: '#ffc53d' }, // gold
];
// Estimate description card height based on text length
function estimateDescriptionHeight(description: string): number {
const cardWidth = 320; // increased card width
const padding = 20; // top + bottom padding
const headerHeight = 30; // header section with keyword
const charPerLine = Math.floor(cardWidth / 13); // ~13px per Chinese char
const lineHeight = 18; // line height in px
const lines = Math.ceil(description.length / charPerLine);
// Cap at reasonable height to prevent huge gaps
return Math.min(padding + headerHeight + lines * lineHeight, 120);
}
export function useTransformationLayout(
data: TransformationDAGResult | null,
categories: CategoryDefinition[],
config: LayoutConfig
): { nodes: Node[]; edges: Edge[] } {
return useMemo(() => {
if (!data || data.results.length === 0) {
return { nodes: [], edges: [] };
}
const { isDark, fontSize = 13 } = config;
const nodes: Node[] = [];
const edges: Edge[] = [];
// Layout constants
const colStep = 140;
const categoryRowGap = 120; // large gap between different categories
const minItemGap = 12; // minimum gap between transformation items
const origAttrRowStep = 36; // step for original attributes (same visual rhythm)
const queryX = 0;
const categoryX = colStep;
const originalAttrX = colStep * 2;
const keywordX = colStep * 3.2; // increased gap from original attributes
const descriptionX = colStep * 4.8; // moved further right for wider cards
// Build category color map
const categoryColors: Record<string, string> = {};
categories.forEach((cat, index) => {
const paletteIndex = index % COLOR_PALETTE.length;
categoryColors[cat.name] = isDark
? COLOR_PALETTE[paletteIndex].dark
: COLOR_PALETTE[paletteIndex].light;
});
// Pre-calculate all description heights and keyword Y positions for each category
interface CategoryLayout {
keywordYPositions: number[];
origAttrYPositions: number[];
totalHeight: number;
}
const categoryLayouts: CategoryLayout[] = data.results.map((result) => {
// Calculate keyword/description Y positions based on description heights
const keywordYPositions: number[] = [];
let currentKeywordY = 0;
result.new_keywords.forEach((keyword) => {
keywordYPositions.push(currentKeywordY);
// Find matching description to calculate height
const matchingDesc = result.descriptions.find((d) => d.keyword === keyword);
const descHeight = matchingDesc
? estimateDescriptionHeight(matchingDesc.description)
: 50;
// Next keyword starts after this description
currentKeywordY += descHeight + minItemGap;
});
const keywordTotalHeight = currentKeywordY > 0 ? currentKeywordY - minItemGap : 0;
// Calculate original attribute positions to match the same total height
const origCount = result.original_attributes.length;
const origAttrYPositions: number[] = [];
if (origCount > 0) {
// Distribute original attributes evenly across the same height as keywords
const effectiveHeight = Math.max(keywordTotalHeight, origCount * origAttrRowStep);
const origStep = origCount > 1 ? effectiveHeight / (origCount - 1) : 0;
for (let i = 0; i < origCount; i++) {
origAttrYPositions.push(i * (origCount > 1 ? origStep : 0));
}
}
const totalHeight = Math.max(keywordTotalHeight, origCount * origAttrRowStep);
return { keywordYPositions, origAttrYPositions, totalHeight };
});
// Calculate total height for query centering
const totalHeight = categoryLayouts.reduce(
(sum, layout, i) =>
sum + layout.totalHeight + (i < categoryLayouts.length - 1 ? categoryRowGap : 0),
0
);
// Add Query node (centered vertically)
const queryY = totalHeight / 2 - 20;
nodes.push({
id: 'query-node',
type: 'query',
position: { x: queryX, y: queryY },
data: {
label: data.query,
isDark,
fontSize,
},
draggable: false,
selectable: false,
});
// Track current Y position
let currentY = 0;
// Process each category result
data.results.forEach((result, catIndex) => {
const categoryId = `category-${catIndex}`;
const color = categoryColors[result.category] || '#666';
const layout = categoryLayouts[catIndex];
// Category Y position (centered within its group)
const categoryY = currentY + layout.totalHeight / 2 - 20;
// Add category node
nodes.push({
id: categoryId,
type: 'category',
position: { x: categoryX, y: categoryY },
data: {
label: result.category,
color,
attributeCount: result.original_attributes.length,
isDark,
},
draggable: false,
selectable: false,
});
// Edge from query to category
edges.push({
id: `edge-query-${categoryId}`,
source: 'query-node',
target: categoryId,
type: 'smoothstep',
style: { stroke: color, strokeWidth: 2, opacity: 0.6 },
animated: false,
});
// Add original attribute nodes (distributed to match keyword spacing)
result.original_attributes.forEach((attr, attrIndex) => {
const attrId = `orig-${catIndex}-${attrIndex}`;
const attrY = currentY + (layout.origAttrYPositions[attrIndex] || 0);
nodes.push({
id: attrId,
type: 'originalAttribute',
position: { x: originalAttrX, y: attrY },
data: {
label: attr,
color,
isDark,
},
draggable: false,
selectable: false,
});
// Edge from category to original attribute
edges.push({
id: `edge-${categoryId}-${attrId}`,
source: categoryId,
target: attrId,
type: 'smoothstep',
style: { stroke: color, strokeWidth: 1, opacity: 0.3 },
animated: false,
});
});
// Add keyword and description nodes with smart Y positions
result.new_keywords.forEach((keyword, kwIndex) => {
const keywordId = `keyword-${catIndex}-${kwIndex}`;
const keywordY = currentY + layout.keywordYPositions[kwIndex];
// Keyword node
nodes.push({
id: keywordId,
type: 'keyword',
position: { x: keywordX, y: keywordY },
data: {
label: keyword,
color,
isDark,
},
draggable: false,
selectable: false,
});
// Edge from category to keyword
edges.push({
id: `edge-${categoryId}-${keywordId}`,
source: categoryId,
target: keywordId,
type: 'smoothstep',
style: {
stroke: '#faad14',
strokeWidth: 2,
opacity: 0.7,
strokeDasharray: '5,5',
},
animated: true,
});
// Find matching description
const matchingDesc = result.descriptions.find((d) => d.keyword === keyword);
if (matchingDesc) {
const descId = `desc-${catIndex}-${kwIndex}`;
nodes.push({
id: descId,
type: 'description',
position: { x: descriptionX, y: keywordY },
data: {
keyword: matchingDesc.keyword,
description: matchingDesc.description,
color,
isDark,
},
draggable: false,
selectable: false,
});
// Edge from keyword to description
edges.push({
id: `edge-${keywordId}-${descId}`,
source: keywordId,
target: descId,
type: 'smoothstep',
style: { stroke: '#52c41a', strokeWidth: 2, opacity: 0.6 },
animated: false,
});
}
});
// Move Y position for next category
currentY += layout.totalHeight;
// Add divider line between categories (except after last one)
if (catIndex < data.results.length - 1) {
const dividerY = currentY + categoryRowGap / 2 - 1;
nodes.push({
id: `divider-${catIndex}`,
type: 'divider',
position: { x: queryX, y: dividerY },
data: {
isDark,
},
draggable: false,
selectable: false,
style: {
width: descriptionX + 400,
zIndex: -1,
},
});
}
currentY += categoryRowGap;
});
return { nodes, edges };
}, [data, categories, config]);
}

View File

@@ -0,0 +1,238 @@
import { useState, useCallback } from 'react';
import { expertTransformCategoryStream } from '../services/api';
import type {
ExpertTransformationInput,
ExpertTransformationProgress,
ExpertTransformationCategoryResult,
ExpertTransformationDAGResult,
ExpertProfile,
CategoryDefinition,
} from '../types';
interface UseExpertTransformationOptions {
model?: string;
temperature?: number;
}
export function useExpertTransformation(options: UseExpertTransformationOptions = {}) {
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState<ExpertTransformationProgress>({
step: 'idle',
currentCategory: '',
processedCategories: [],
message: '',
});
const [results, setResults] = useState<ExpertTransformationDAGResult | null>(null);
const [error, setError] = useState<string | null>(null);
// Global expert team - generated once and shared across all categories
const [experts, setExperts] = useState<ExpertProfile[] | null>(null);
const transformCategory = useCallback(
async (
query: string,
category: CategoryDefinition,
attributes: string[],
expertConfig: {
expert_count: number;
keywords_per_expert: number;
custom_experts?: string[];
}
): Promise<{
result: ExpertTransformationCategoryResult | null;
experts: ExpertProfile[];
}> => {
return new Promise((resolve) => {
let categoryExperts: ExpertProfile[] = [];
setProgress((prev) => ({
...prev,
step: 'expert',
currentCategory: category.name,
message: `組建專家團隊...`,
}));
expertTransformCategoryStream(
{
query,
category: category.name,
attributes,
expert_count: expertConfig.expert_count,
keywords_per_expert: expertConfig.keywords_per_expert,
custom_experts: expertConfig.custom_experts,
model: options.model,
temperature: options.temperature,
},
{
onExpertStart: () => {
setProgress((prev) => ({
...prev,
step: 'expert',
message: `正在組建專家團隊...`,
}));
},
onExpertComplete: (expertsData) => {
categoryExperts = expertsData;
setExperts(expertsData);
setProgress((prev) => ({
...prev,
experts: expertsData,
message: `專家團隊組建完成(${expertsData.length}位專家)`,
}));
},
onKeywordStart: () => {
setProgress((prev) => ({
...prev,
step: 'keyword',
message: `專家團隊為「${category.name}」的屬性生成關鍵字...`,
}));
},
onKeywordProgress: (data) => {
setProgress((prev) => ({
...prev,
currentAttribute: data.attribute,
message: `為「${data.attribute}」生成了 ${data.count} 個關鍵字`,
}));
},
onKeywordComplete: (totalKeywords) => {
setProgress((prev) => ({
...prev,
message: `共生成了 ${totalKeywords} 個專家關鍵字`,
}));
},
onDescriptionStart: () => {
setProgress((prev) => ({
...prev,
step: 'description',
message: `為「${category.name}」的專家關鍵字生成創新描述...`,
}));
},
onDescriptionComplete: (count) => {
setProgress((prev) => ({
...prev,
message: `生成了 ${count} 個創新描述`,
}));
},
onDone: (data) => {
setProgress((prev) => ({
...prev,
step: 'done',
processedCategories: [...prev.processedCategories, category.name],
message: `${category.name}」處理完成`,
}));
resolve({
result: data.result,
experts: data.experts,
});
},
onError: (err) => {
setProgress((prev) => ({
...prev,
step: 'error',
error: err,
message: `處理「${category.name}」時發生錯誤`,
}));
resolve({
result: null,
experts: categoryExperts,
});
},
}
).catch((err) => {
setProgress((prev) => ({
...prev,
step: 'error',
error: err.message,
message: `處理「${category.name}」時發生錯誤`,
}));
resolve({
result: null,
experts: categoryExperts,
});
});
});
},
[options.model, options.temperature]
);
const transformAll = useCallback(
async (input: ExpertTransformationInput) => {
setLoading(true);
setError(null);
setResults(null);
setExperts(null);
setProgress({
step: 'idle',
currentCategory: '',
processedCategories: [],
message: '開始處理...',
});
const categoryResults: ExpertTransformationCategoryResult[] = [];
let globalExperts: ExpertProfile[] = [];
// Process each category sequentially
for (const category of input.categories) {
const attributes = input.attributesByCategory[category.name] || [];
if (attributes.length === 0) continue;
const { result, experts: categoryExperts } = await transformCategory(
input.query,
category,
attributes,
input.expertConfig
);
// Store global experts from first category
if (globalExperts.length === 0 && categoryExperts.length > 0) {
globalExperts = categoryExperts;
}
if (result) {
categoryResults.push(result);
}
}
// Build final result
const finalResult: ExpertTransformationDAGResult = {
query: input.query,
experts: globalExperts,
results: categoryResults,
};
setResults(finalResult);
setLoading(false);
setProgress((prev) => ({
...prev,
step: 'done',
message: '所有類別處理完成',
}));
return finalResult;
},
[transformCategory]
);
const clearResults = useCallback(() => {
setResults(null);
setError(null);
setExperts(null);
setProgress({
step: 'idle',
currentCategory: '',
processedCategories: [],
message: '',
});
}, []);
return {
loading,
progress,
results,
error,
experts,
transformCategory,
transformAll,
clearResults,
};
}

View File

@@ -0,0 +1,175 @@
import { useState, useCallback } from 'react';
import { transformCategoryStream } from '../services/api';
import type {
TransformationInput,
TransformationProgress,
TransformationCategoryResult,
TransformationDAGResult,
CategoryDefinition,
} from '../types';
interface UseTransformationOptions {
model?: string;
temperature?: number;
keywordCount?: number;
}
export function useTransformation(options: UseTransformationOptions = {}) {
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState<TransformationProgress>({
step: 'idle',
currentCategory: '',
processedCategories: [],
message: '',
});
const [results, setResults] = useState<TransformationDAGResult | null>(null);
const [error, setError] = useState<string | null>(null);
const transformCategory = useCallback(
async (
query: string,
category: CategoryDefinition,
attributes: string[]
): Promise<TransformationCategoryResult | null> => {
return new Promise((resolve) => {
setProgress((prev) => ({
...prev,
step: 'keyword',
currentCategory: category.name,
message: `為「${category.name}」生成新關鍵字...`,
}));
transformCategoryStream(
{
query,
category: category.name,
attributes,
model: options.model,
temperature: options.temperature,
keyword_count: options.keywordCount || 3,
},
{
onKeywordStart: () => {
setProgress((prev) => ({
...prev,
step: 'keyword',
message: `為「${category.name}」生成新關鍵字...`,
}));
},
onKeywordComplete: (keywords) => {
setProgress((prev) => ({
...prev,
message: `生成了 ${keywords.length} 個新關鍵字`,
}));
},
onDescriptionStart: () => {
setProgress((prev) => ({
...prev,
step: 'description',
message: `為「${category.name}」生成創新描述...`,
}));
},
onDescriptionComplete: (count) => {
setProgress((prev) => ({
...prev,
message: `生成了 ${count} 個創新描述`,
}));
},
onDone: (result) => {
setProgress((prev) => ({
...prev,
step: 'done',
processedCategories: [...prev.processedCategories, category.name],
message: `${category.name}」處理完成`,
}));
resolve(result);
},
onError: (err) => {
setProgress((prev) => ({
...prev,
step: 'error',
error: err,
message: `處理「${category.name}」時發生錯誤`,
}));
resolve(null);
},
}
).catch((err) => {
setProgress((prev) => ({
...prev,
step: 'error',
error: err.message,
message: `處理「${category.name}」時發生錯誤`,
}));
resolve(null);
});
});
},
[options.model, options.temperature, options.keywordCount]
);
const transformAll = useCallback(
async (input: TransformationInput) => {
setLoading(true);
setError(null);
setResults(null);
setProgress({
step: 'idle',
currentCategory: '',
processedCategories: [],
message: '開始處理...',
});
const categoryResults: TransformationCategoryResult[] = [];
// Process each category sequentially
for (const category of input.categories) {
const attributes = input.attributesByCategory[category.name] || [];
if (attributes.length === 0) continue;
const result = await transformCategory(input.query, category, attributes);
if (result) {
categoryResults.push(result);
}
}
// Build final result
const finalResult: TransformationDAGResult = {
query: input.query,
results: categoryResults,
};
setResults(finalResult);
setLoading(false);
setProgress((prev) => ({
...prev,
step: 'done',
message: '所有類別處理完成',
}));
return finalResult;
},
[transformCategory]
);
const clearResults = useCallback(() => {
setResults(null);
setError(null);
setProgress({
step: 'idle',
currentCategory: '',
processedCategories: [],
message: '',
});
}, []);
return {
loading,
progress,
results,
error,
transformCategory,
transformAll,
clearResults,
};
}

View File

@@ -5,7 +5,12 @@ import type {
Step0Result,
CategoryDefinition,
DynamicStep1Result,
DAGStreamAnalyzeResponse
DAGStreamAnalyzeResponse,
TransformationRequest,
TransformationCategoryResult,
ExpertTransformationRequest,
ExpertTransformationCategoryResult,
ExpertProfile
} from '../types';
// 自動使用當前瀏覽器的 hostname支援遠端存取
@@ -114,3 +119,183 @@ export async function getModels(): Promise<ModelListResponse> {
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);
}
}
}
}
}

View File

@@ -151,3 +151,113 @@ export interface DAGStreamAnalyzeResponse {
relationships: DAGRelationship[];
dag: AttributeDAG;
}
// ===== Transformation Agent types =====
export interface TransformationRequest {
query: string;
category: string;
attributes: string[];
model?: string;
temperature?: number;
keyword_count?: number;
}
export interface TransformationDescription {
keyword: string;
description: string;
}
export interface TransformationCategoryResult {
category: string;
original_attributes: string[];
new_keywords: string[];
descriptions: TransformationDescription[];
}
export interface TransformationDAGResult {
query: string;
results: TransformationCategoryResult[];
}
export interface TransformationProgress {
step: 'idle' | 'keyword' | 'description' | 'done' | 'error';
currentCategory: string;
processedCategories: string[];
message: string;
error?: string;
}
export interface TransformationInput {
query: string;
categories: CategoryDefinition[];
attributesByCategory: Record<string, string[]>;
}
// ===== Expert Transformation Agent types =====
export interface ExpertProfile {
id: string; // "expert-0"
name: string; // "藥師"
domain: string; // "醫療與健康"
perspective?: string; // "從藥物與健康管理角度思考"
}
export interface ExpertKeyword {
keyword: string;
expert_id: string;
expert_name: string;
source_attribute: string; // 來自哪個原始屬性
}
export interface ExpertTransformationDescription {
keyword: string;
expert_id: string;
expert_name: string;
description: string;
}
export interface ExpertTransformationCategoryResult {
category: string;
original_attributes: string[];
expert_keywords: ExpertKeyword[];
descriptions: ExpertTransformationDescription[];
}
export interface ExpertTransformationDAGResult {
query: string;
experts: ExpertProfile[];
results: ExpertTransformationCategoryResult[];
}
export interface ExpertTransformationRequest {
query: string;
category: string;
attributes: string[];
expert_count: number; // 2-8
keywords_per_expert: number; // 1-3
custom_experts?: string[]; // ["藥師", "工程師"]
model?: string;
temperature?: number;
}
export interface ExpertTransformationProgress {
step: 'idle' | 'expert' | 'keyword' | 'description' | 'done' | 'error';
currentCategory: string;
processedCategories: string[];
experts?: ExpertProfile[];
currentAttribute?: string;
message: string;
error?: string;
}
export interface ExpertTransformationInput {
query: string;
categories: CategoryDefinition[];
attributesByCategory: Record<string, string[]>;
expertConfig: {
expert_count: number;
keywords_per_expert: number;
custom_experts?: string[];
};
}