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:
@@ -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>
|
||||
|
||||
106
frontend/src/components/TransformationDAG.tsx
Normal file
106
frontend/src/components/TransformationDAG.tsx
Normal 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';
|
||||
156
frontend/src/components/TransformationInputPanel.tsx
Normal file
156
frontend/src/components/TransformationInputPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
259
frontend/src/components/TransformationPanel.tsx
Normal file
259
frontend/src/components/TransformationPanel.tsx
Normal 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';
|
||||
@@ -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) => {
|
||||
|
||||
185
frontend/src/components/transformation/ExpertConfigPanel.tsx
Normal file
185
frontend/src/components/transformation/ExpertConfigPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
29
frontend/src/components/transformation/index.ts
Normal file
29
frontend/src/components/transformation/index.ts
Normal 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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
26
frontend/src/components/transformation/nodes/DividerNode.tsx
Normal file
26
frontend/src/components/transformation/nodes/DividerNode.tsx
Normal 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';
|
||||
@@ -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';
|
||||
65
frontend/src/components/transformation/nodes/KeywordNode.tsx
Normal file
65
frontend/src/components/transformation/nodes/KeywordNode.tsx
Normal 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';
|
||||
@@ -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';
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
238
frontend/src/hooks/useExpertTransformation.ts
Normal file
238
frontend/src/hooks/useExpertTransformation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
175
frontend/src/hooks/useTransformation.ts
Normal file
175
frontend/src/hooks/useTransformation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user