chore: save local changes
This commit is contained in:
@@ -1,17 +1,24 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { ConfigProvider, Layout, theme, Typography, Space, Tabs, Slider, Radio } from 'antd';
|
||||
import { ApartmentOutlined, ThunderboltOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import { ConfigProvider, Layout, theme, Typography, Space, Tabs, Slider, Radio, Switch, Segmented } from 'antd';
|
||||
import { ApartmentOutlined, ThunderboltOutlined, FilterOutlined, SwapOutlined, FileSearchOutlined, GlobalOutlined } 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 { DeduplicationPanel } from './components/DeduplicationPanel';
|
||||
import { PatentSearchPanel } from './components/PatentSearchPanel';
|
||||
import { DualPathInputPanel } from './components/DualPathInputPanel';
|
||||
import { DualPathMindmapPanel } from './components/DualPathMindmapPanel';
|
||||
import { CrossoverPanel } from './components/CrossoverPanel';
|
||||
import { useAttribute } from './hooks/useAttribute';
|
||||
import { useDualPathAttribute } from './hooks/useDualPathAttribute';
|
||||
import { getModels } from './services/api';
|
||||
import { crossoverPairsToDAGs, type CrossoverDAGResult } from './utils/crossoverToDAG';
|
||||
import { DualTransformationPanel } from './components/DualTransformationPanel';
|
||||
import type { MindmapDAGRef } from './components/MindmapDAG';
|
||||
import type { TransformationDAGRef } from './components/TransformationDAG';
|
||||
import type { CategoryMode, ExpertSource, ExpertTransformationDAGResult, DeduplicationMethod } from './types';
|
||||
import type { CategoryMode, ExpertSource, ExpertTransformationDAGResult, DeduplicationMethod, ExpertMode, CrossoverPair, PromptLanguage } from './types';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Title } = Typography;
|
||||
@@ -24,7 +31,15 @@ interface VisualSettings {
|
||||
function App() {
|
||||
const [isDark, setIsDark] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<string>('attribute');
|
||||
const [dualPathMode, setDualPathMode] = useState(false);
|
||||
const [promptLanguage, setPromptLanguage] = useState<PromptLanguage>('zh');
|
||||
|
||||
// Single path hook
|
||||
const { loading, progress, error, currentResult, history, analyze, loadFromHistory } = useAttribute();
|
||||
|
||||
// Dual path hook
|
||||
const dualPath = useDualPathAttribute();
|
||||
|
||||
const [visualSettings, setVisualSettings] = useState<VisualSettings>({
|
||||
nodeSpacing: 32,
|
||||
fontSize: 14,
|
||||
@@ -32,6 +47,21 @@ function App() {
|
||||
const mindmapRef = useRef<MindmapDAGRef>(null);
|
||||
const transformationRef = useRef<TransformationDAGRef>(null);
|
||||
|
||||
// Dual path expert mode
|
||||
const [expertMode, setExpertMode] = useState<ExpertMode>('shared');
|
||||
const [selectedCrossoverPairs, setSelectedCrossoverPairs] = useState<CrossoverPair[]>([]);
|
||||
|
||||
// Convert selected crossover pairs to two separate DAGs for dual transformation
|
||||
const crossoverDAGs = useMemo((): CrossoverDAGResult | null => {
|
||||
if (selectedCrossoverPairs.length === 0) return null;
|
||||
if (!dualPath.pathA.result || !dualPath.pathB.result) return null;
|
||||
return crossoverPairsToDAGs(
|
||||
selectedCrossoverPairs,
|
||||
dualPath.pathA.result,
|
||||
dualPath.pathB.result
|
||||
);
|
||||
}, [selectedCrossoverPairs, dualPath.pathA.result, dualPath.pathB.result]);
|
||||
|
||||
// Transformation Agent settings
|
||||
const [transformModel, setTransformModel] = useState<string>('');
|
||||
const [transformTemperature, setTransformTemperature] = useState<number>(0.95);
|
||||
@@ -83,9 +113,10 @@ function App() {
|
||||
chainCount?: number,
|
||||
categoryMode?: CategoryMode,
|
||||
customCategories?: string[],
|
||||
suggestedCategoryCount?: number
|
||||
suggestedCategoryCount?: number,
|
||||
lang?: PromptLanguage
|
||||
) => {
|
||||
await analyze(query, model, temperature, chainCount, categoryMode, customCategories, suggestedCategoryCount);
|
||||
await analyze(query, model, temperature, chainCount, categoryMode, customCategories, suggestedCategoryCount, lang || promptLanguage);
|
||||
};
|
||||
|
||||
const handleResetView = useCallback(() => {
|
||||
@@ -96,6 +127,30 @@ function App() {
|
||||
setShouldStartTransform(true);
|
||||
}, []);
|
||||
|
||||
// Dual path analysis handler
|
||||
const handleDualPathAnalyze = useCallback(async (
|
||||
queryA: string,
|
||||
queryB: string,
|
||||
options?: {
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
chainCount?: number;
|
||||
categoryMode?: CategoryMode;
|
||||
customCategories?: string[];
|
||||
suggestedCategoryCount?: number;
|
||||
lang?: PromptLanguage;
|
||||
}
|
||||
) => {
|
||||
await dualPath.analyzeParallel(queryA, queryB, { ...options, lang: options?.lang || promptLanguage });
|
||||
}, [dualPath, promptLanguage]);
|
||||
|
||||
// Handle mode switch
|
||||
const handleModeSwitch = useCallback((checked: boolean) => {
|
||||
setDualPathMode(checked);
|
||||
// Reset to attribute tab when switching modes
|
||||
setActiveTab('attribute');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
@@ -140,7 +195,31 @@ function App() {
|
||||
Novelty Seeking
|
||||
</Title>
|
||||
</Space>
|
||||
<ThemeToggle isDark={isDark} onToggle={setIsDark} />
|
||||
<Space align="center" size="middle">
|
||||
<Space size="small">
|
||||
<Typography.Text type="secondary">Single</Typography.Text>
|
||||
<Switch
|
||||
checked={dualPathMode}
|
||||
onChange={handleModeSwitch}
|
||||
checkedChildren={<SwapOutlined />}
|
||||
unCheckedChildren={<ApartmentOutlined />}
|
||||
/>
|
||||
<Typography.Text type="secondary">Dual</Typography.Text>
|
||||
</Space>
|
||||
<Space size="small">
|
||||
<GlobalOutlined style={{ color: isDark ? '#177ddc' : '#1890ff' }} />
|
||||
<Segmented
|
||||
size="small"
|
||||
value={promptLanguage}
|
||||
onChange={(value) => setPromptLanguage(value as PromptLanguage)}
|
||||
options={[
|
||||
{ label: '中文', value: 'zh' },
|
||||
{ label: 'EN', value: 'en' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<ThemeToggle isDark={isDark} onToggle={setIsDark} />
|
||||
</Space>
|
||||
</Header>
|
||||
<Layout>
|
||||
<Content
|
||||
@@ -155,7 +234,98 @@ function App() {
|
||||
onChange={setActiveTab}
|
||||
style={{ height: '100%' }}
|
||||
tabBarStyle={{ marginBottom: 8 }}
|
||||
items={[
|
||||
items={dualPathMode ? [
|
||||
// ===== Dual Path Mode Tabs =====
|
||||
{
|
||||
key: 'attribute',
|
||||
label: (
|
||||
<span>
|
||||
<SwapOutlined style={{ marginRight: 8 }} />
|
||||
Dual Path Attribute
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div style={{ height: 'calc(100vh - 140px)' }}>
|
||||
<DualPathMindmapPanel
|
||||
pathA={dualPath.pathA}
|
||||
pathB={dualPath.pathB}
|
||||
isDark={isDark}
|
||||
visualSettings={visualSettings}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'crossover',
|
||||
label: (
|
||||
<span>
|
||||
<SwapOutlined style={{ marginRight: 8 }} />
|
||||
Crossover
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div style={{ height: 'calc(100vh - 140px)', padding: 16 }}>
|
||||
<CrossoverPanel
|
||||
pathAResult={dualPath.pathA.result}
|
||||
pathBResult={dualPath.pathB.result}
|
||||
isDark={isDark}
|
||||
expertMode={expertMode}
|
||||
onExpertModeChange={setExpertMode}
|
||||
onCrossoverReady={setSelectedCrossoverPairs}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'transformation',
|
||||
label: (
|
||||
<span>
|
||||
<ThunderboltOutlined style={{ marginRight: 8 }} />
|
||||
Transformation Agent
|
||||
{crossoverDAGs && (
|
||||
<span style={{ marginLeft: 4, fontSize: 10, opacity: 0.7 }}>
|
||||
(A:{crossoverDAGs.pathA.nodes.length} / B:{crossoverDAGs.pathB.nodes.length})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div style={{ height: 'calc(100vh - 140px)' }}>
|
||||
<DualTransformationPanel
|
||||
crossoverDAGA={crossoverDAGs?.pathA ?? null}
|
||||
crossoverDAGB={crossoverDAGs?.pathB ?? null}
|
||||
isDark={isDark}
|
||||
model={transformModel}
|
||||
temperature={transformTemperature}
|
||||
expertConfig={expertConfig}
|
||||
expertSource={expertSource}
|
||||
expertLanguage={expertLanguage}
|
||||
lang={promptLanguage}
|
||||
shouldStartTransform={shouldStartTransform}
|
||||
onTransformComplete={() => setShouldStartTransform(false)}
|
||||
onLoadingChange={setTransformLoading}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'patent',
|
||||
label: (
|
||||
<span>
|
||||
<FileSearchOutlined style={{ marginRight: 8 }} />
|
||||
Patent Search
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div style={{ height: 'calc(100vh - 140px)' }}>
|
||||
<PatentSearchPanel
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] : [
|
||||
// ===== Single Path Mode Tabs =====
|
||||
{
|
||||
key: 'attribute',
|
||||
label: (
|
||||
@@ -196,6 +366,7 @@ function App() {
|
||||
expertConfig={expertConfig}
|
||||
expertSource={expertSource}
|
||||
expertLanguage={expertLanguage}
|
||||
lang={promptLanguage}
|
||||
shouldStartTransform={shouldStartTransform}
|
||||
onTransformComplete={() => setShouldStartTransform(false)}
|
||||
onLoadingChange={setTransformLoading}
|
||||
@@ -221,6 +392,24 @@ function App() {
|
||||
onThresholdChange={setDeduplicationThreshold}
|
||||
method={deduplicationMethod}
|
||||
onMethodChange={setDeduplicationMethod}
|
||||
lang={promptLanguage}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'patent',
|
||||
label: (
|
||||
<span>
|
||||
<FileSearchOutlined style={{ marginRight: 8 }} />
|
||||
Patent Search
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div style={{ height: 'calc(100vh - 140px)' }}>
|
||||
<PatentSearchPanel
|
||||
descriptions={transformationResult?.results.flatMap(r => r.descriptions)}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@@ -236,24 +425,54 @@ function App() {
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{activeTab === 'attribute' && (
|
||||
{activeTab === 'attribute' && !dualPathMode && (
|
||||
<InputPanel
|
||||
loading={loading}
|
||||
progress={progress}
|
||||
history={history}
|
||||
currentResult={currentResult}
|
||||
onAnalyze={handleAnalyze}
|
||||
onLoadHistory={loadFromHistory}
|
||||
onLoadHistory={(item, lang) => loadFromHistory(item, lang || promptLanguage)}
|
||||
onResetView={handleResetView}
|
||||
visualSettings={visualSettings}
|
||||
onVisualSettingsChange={setVisualSettings}
|
||||
lang={promptLanguage}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'attribute' && dualPathMode && (
|
||||
<DualPathInputPanel
|
||||
onAnalyze={handleDualPathAnalyze}
|
||||
loadingA={dualPath.pathA.loading}
|
||||
loadingB={dualPath.pathB.loading}
|
||||
progressA={dualPath.pathA.progress}
|
||||
progressB={dualPath.pathB.progress}
|
||||
availableModels={availableModels}
|
||||
lang={promptLanguage}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'crossover' && dualPathMode && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<Typography.Title level={5} style={{ marginBottom: 16 }}>
|
||||
<SwapOutlined style={{ marginRight: 8 }} />
|
||||
Crossover Settings
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
Select attribute pairs in the main panel to create crossover combinations.
|
||||
{selectedCrossoverPairs.length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Typography.Text strong>
|
||||
{selectedCrossoverPairs.length} pairs selected
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'transformation' && (
|
||||
<TransformationInputPanel
|
||||
onTransform={handleTransform}
|
||||
loading={transformLoading}
|
||||
hasData={!!currentResult}
|
||||
hasData={dualPathMode ? !!crossoverDAGs : !!currentResult}
|
||||
isDark={isDark}
|
||||
model={transformModel}
|
||||
temperature={transformTemperature}
|
||||
|
||||
298
frontend/src/components/CrossoverPanel.tsx
Normal file
298
frontend/src/components/CrossoverPanel.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Empty,
|
||||
Card,
|
||||
Button,
|
||||
Statistic,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Space,
|
||||
Badge,
|
||||
Collapse,
|
||||
Checkbox,
|
||||
Radio,
|
||||
} from 'antd';
|
||||
import {
|
||||
SwapOutlined,
|
||||
CheckCircleOutlined,
|
||||
ReloadOutlined,
|
||||
UnorderedListOutlined,
|
||||
TableOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { AttributeDAG, CrossoverPair, ExpertMode } from '../types';
|
||||
import { useAttributeCrossover } from '../hooks/useAttributeCrossover';
|
||||
import { CrossoverCard } from './crossover/CrossoverCard';
|
||||
import { CrossoverMatrix } from './crossover/CrossoverMatrix';
|
||||
import { CrossoverPreview } from './crossover/CrossoverPreview';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface CrossoverPanelProps {
|
||||
pathAResult: AttributeDAG | null;
|
||||
pathBResult: AttributeDAG | null;
|
||||
isDark: boolean;
|
||||
expertMode: ExpertMode;
|
||||
onExpertModeChange: (mode: ExpertMode) => void;
|
||||
onCrossoverReady?: (selectedPairs: CrossoverPair[]) => void;
|
||||
}
|
||||
|
||||
type ViewMode = 'list' | 'matrix';
|
||||
|
||||
export function CrossoverPanel({
|
||||
pathAResult,
|
||||
pathBResult,
|
||||
isDark,
|
||||
expertMode,
|
||||
onExpertModeChange,
|
||||
onCrossoverReady,
|
||||
}: CrossoverPanelProps) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
|
||||
const {
|
||||
pairs,
|
||||
selectedPairs,
|
||||
pairsByType,
|
||||
crossTypeStats,
|
||||
applyPairs,
|
||||
togglePairSelection,
|
||||
selectPairsByType,
|
||||
selectAll,
|
||||
clearPairs,
|
||||
} = useAttributeCrossover();
|
||||
|
||||
// Generate pairs when both results are available
|
||||
useEffect(() => {
|
||||
if (pathAResult && pathBResult) {
|
||||
applyPairs(pathAResult, pathBResult);
|
||||
} else {
|
||||
clearPairs();
|
||||
}
|
||||
}, [pathAResult, pathBResult, applyPairs, clearPairs]);
|
||||
|
||||
// Notify parent when selection changes
|
||||
useEffect(() => {
|
||||
onCrossoverReady?.(selectedPairs);
|
||||
}, [selectedPairs, onCrossoverReady]);
|
||||
|
||||
// Render when no data
|
||||
if (!pathAResult || !pathBResult) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}>
|
||||
<Empty
|
||||
description={
|
||||
<Space direction="vertical" align="center">
|
||||
<Text>Complete both Path A and Path B analysis first</Text>
|
||||
<Text type="secondary">
|
||||
{!pathAResult && !pathBResult
|
||||
? 'Neither path has been analyzed'
|
||||
: !pathAResult
|
||||
? 'Path A has not been analyzed'
|
||||
: 'Path B has not been analyzed'}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generate cross type labels dynamically
|
||||
const getCrossTypeLabel = (crossType: string): string => {
|
||||
if (crossType.startsWith('same-')) {
|
||||
const category = crossType.replace('same-', '');
|
||||
return `Same Category: ${category}`;
|
||||
}
|
||||
if (crossType.startsWith('cross-')) {
|
||||
const parts = crossType.replace('cross-', '').split('-');
|
||||
if (parts.length >= 2) {
|
||||
return `Cross: ${parts[0]} × ${parts.slice(1).join('-')}`;
|
||||
}
|
||||
}
|
||||
return crossType;
|
||||
};
|
||||
|
||||
const renderListView = () => {
|
||||
const crossTypes = Object.keys(pairsByType);
|
||||
|
||||
if (crossTypes.length === 0) {
|
||||
return <Empty description="No crossover pairs generated" />;
|
||||
}
|
||||
|
||||
const collapseItems = crossTypes.map(type => {
|
||||
const typePairs = pairsByType[type];
|
||||
const stats = crossTypeStats[type];
|
||||
const label = getCrossTypeLabel(type);
|
||||
|
||||
return {
|
||||
key: type,
|
||||
label: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Checkbox
|
||||
checked={stats.selected === stats.total}
|
||||
indeterminate={stats.selected > 0 && stats.selected < stats.total}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => selectPairsByType(type, e.target.checked)}
|
||||
/>
|
||||
<Text>{label}</Text>
|
||||
<Badge
|
||||
count={`${stats.selected}/${stats.total}`}
|
||||
style={{
|
||||
backgroundColor: stats.selected > 0 ? '#52c41a' : '#d9d9d9',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
gap: 8,
|
||||
}}>
|
||||
{typePairs.map(pair => (
|
||||
<CrossoverCard
|
||||
key={pair.id}
|
||||
pair={pair}
|
||||
onToggle={togglePairSelection}
|
||||
isDark={isDark}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
items={collapseItems}
|
||||
defaultActiveKey={crossTypes.filter(t => t.startsWith('same-'))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMatrixView = () => {
|
||||
return (
|
||||
<CrossoverMatrix
|
||||
dagA={pathAResult}
|
||||
dagB={pathBResult}
|
||||
pairs={pairs}
|
||||
onTogglePair={togglePairSelection}
|
||||
isDark={isDark}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Statistics Header */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Total Pairs"
|
||||
value={pairs.length}
|
||||
prefix={<SwapOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Selected"
|
||||
value={selectedPairs.length}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Path A Attrs"
|
||||
value={pathAResult.nodes.length}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Path B Attrs"
|
||||
value={pathBResult.nodes.length}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Selection Preview */}
|
||||
<CrossoverPreview
|
||||
selectedPairs={selectedPairs}
|
||||
dagA={pathAResult}
|
||||
dagB={pathBResult}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{/* Expert Mode Selection */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text strong>Expert Team Mode</Text>
|
||||
<Radio.Group
|
||||
value={expertMode}
|
||||
onChange={(e) => onExpertModeChange(e.target.value)}
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="shared">
|
||||
Shared Experts
|
||||
</Radio.Button>
|
||||
<Radio.Button value="independent">
|
||||
Independent Experts
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{expertMode === 'shared'
|
||||
? 'Both paths use the same expert team for crossover transformation'
|
||||
: 'Each path uses its own expert team, combined for crossover'}
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={() => selectAll(true)}
|
||||
>
|
||||
Select All
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => selectAll(false)}
|
||||
>
|
||||
Deselect All
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => applyPairs(pathAResult, pathBResult)}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Radio.Group
|
||||
value={viewMode}
|
||||
onChange={(e) => setViewMode(e.target.value)}
|
||||
buttonStyle="solid"
|
||||
size="small"
|
||||
>
|
||||
<Radio.Button value="list">
|
||||
<UnorderedListOutlined /> List
|
||||
</Radio.Button>
|
||||
<Radio.Button value="matrix">
|
||||
<TableOutlined /> Matrix
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{viewMode === 'list' ? renderListView() : renderMatrixView()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
ExpertTransformationDAGResult,
|
||||
ExpertTransformationDescription,
|
||||
DeduplicationMethod,
|
||||
PromptLanguage,
|
||||
} from '../types';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
@@ -37,6 +38,7 @@ interface DeduplicationPanelProps {
|
||||
onThresholdChange: (value: number) => void;
|
||||
method: DeduplicationMethod;
|
||||
onMethodChange?: (method: DeduplicationMethod) => void; // Optional, handled in App.tsx sidebar
|
||||
lang?: PromptLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,6 +50,7 @@ export const DeduplicationPanel: React.FC<DeduplicationPanelProps> = ({
|
||||
threshold,
|
||||
onThresholdChange,
|
||||
method,
|
||||
lang = 'zh',
|
||||
// onMethodChange is handled in App.tsx sidebar
|
||||
}) => {
|
||||
const { loading, result, error, progress, deduplicate, clearResult } = useDeduplication();
|
||||
@@ -70,7 +73,7 @@ export const DeduplicationPanel: React.FC<DeduplicationPanelProps> = ({
|
||||
|
||||
const handleDeduplicate = () => {
|
||||
if (allDescriptions.length > 0) {
|
||||
deduplicate(allDescriptions, threshold, method);
|
||||
deduplicate(allDescriptions, threshold, method, lang);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
312
frontend/src/components/DualPathInputPanel.tsx
Normal file
312
frontend/src/components/DualPathInputPanel.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
Select,
|
||||
Typography,
|
||||
Space,
|
||||
message,
|
||||
Slider,
|
||||
Collapse,
|
||||
Progress,
|
||||
Card,
|
||||
Alert,
|
||||
Tag,
|
||||
Divider,
|
||||
} from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
LoadingOutlined,
|
||||
SwapOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { CategoryMode, DAGProgress, PromptLanguage } from '../types';
|
||||
import { getModels } from '../services/api';
|
||||
import { CategorySelector } from './CategorySelector';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text } = Typography;
|
||||
|
||||
interface DualPathInputPanelProps {
|
||||
onAnalyze: (queryA: string, queryB: string, options?: {
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
chainCount?: number;
|
||||
categoryMode?: CategoryMode;
|
||||
customCategories?: string[];
|
||||
suggestedCategoryCount?: number;
|
||||
lang?: PromptLanguage;
|
||||
}) => Promise<void>;
|
||||
loadingA: boolean;
|
||||
loadingB: boolean;
|
||||
progressA: DAGProgress;
|
||||
progressB: DAGProgress;
|
||||
availableModels?: string[];
|
||||
lang?: PromptLanguage;
|
||||
}
|
||||
|
||||
export function DualPathInputPanel({
|
||||
onAnalyze,
|
||||
loadingA,
|
||||
loadingB,
|
||||
progressA,
|
||||
progressB,
|
||||
availableModels: propModels,
|
||||
lang = 'zh',
|
||||
}: DualPathInputPanelProps) {
|
||||
const [queryA, setQueryA] = useState('');
|
||||
const [queryB, setQueryB] = useState('');
|
||||
const [models, setModels] = useState<string[]>(propModels || []);
|
||||
const [selectedModel, setSelectedModel] = useState<string | undefined>();
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [temperature, setTemperature] = useState(0.7);
|
||||
const [chainCount, setChainCount] = useState(5);
|
||||
// Category settings
|
||||
const [categoryMode, setCategoryMode] = useState<CategoryMode>('dynamic_auto' as CategoryMode);
|
||||
const [customCategories, setCustomCategories] = useState<string[]>([]);
|
||||
const [suggestedCategoryCount, setSuggestedCategoryCount] = useState(3);
|
||||
|
||||
const isLoading = loadingA || loadingB;
|
||||
|
||||
useEffect(() => {
|
||||
if (propModels && propModels.length > 0) {
|
||||
setModels(propModels);
|
||||
if (!selectedModel) {
|
||||
const defaultModel = propModels.find((m) => m.includes('qwen3')) || propModels[0];
|
||||
setSelectedModel(defaultModel);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async function fetchModels() {
|
||||
setLoadingModels(true);
|
||||
try {
|
||||
const response = await getModels();
|
||||
setModels(response.models);
|
||||
if (response.models.length > 0 && !selectedModel) {
|
||||
const defaultModel = response.models.find((m) => m.includes('qwen3')) || response.models[0];
|
||||
setSelectedModel(defaultModel);
|
||||
}
|
||||
} catch {
|
||||
message.error('Failed to fetch models');
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchModels();
|
||||
}, [propModels]);
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!queryA.trim() || !queryB.trim()) {
|
||||
message.warning(lang === 'zh' ? '請輸入兩個路徑的查詢內容' : 'Please enter queries for both paths');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onAnalyze(queryA.trim(), queryB.trim(), {
|
||||
model: selectedModel,
|
||||
temperature,
|
||||
chainCount,
|
||||
categoryMode,
|
||||
customCategories: customCategories.length > 0 ? customCategories : undefined,
|
||||
suggestedCategoryCount,
|
||||
lang,
|
||||
});
|
||||
} catch {
|
||||
message.error(lang === 'zh' ? '分析失敗' : 'Analysis failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwapQueries = () => {
|
||||
const temp = queryA;
|
||||
setQueryA(queryB);
|
||||
setQueryB(temp);
|
||||
};
|
||||
|
||||
const renderProgressIndicator = (label: string, progress: DAGProgress, loading: boolean) => {
|
||||
if (progress.step === 'idle' && !loading) return null;
|
||||
if (progress.step === 'done') return null;
|
||||
|
||||
const percent = progress.step === 'step0'
|
||||
? 15
|
||||
: progress.step === 'step1'
|
||||
? 50
|
||||
: progress.step === 'relationships'
|
||||
? 85
|
||||
: 100;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{label}: {progress.message}</Text>
|
||||
<Progress
|
||||
percent={Math.round(percent)}
|
||||
size="small"
|
||||
status={progress.step === 'error' ? 'exception' : 'active'}
|
||||
strokeColor={{ from: '#108ee9', to: '#87d068' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const collapseItems = [
|
||||
{
|
||||
key: 'categories',
|
||||
label: 'Category Settings',
|
||||
children: (
|
||||
<CategorySelector
|
||||
mode={categoryMode}
|
||||
onModeChange={setCategoryMode}
|
||||
customCategories={customCategories}
|
||||
onCustomCategoriesChange={setCustomCategories}
|
||||
suggestedCount={suggestedCategoryCount}
|
||||
onSuggestedCountChange={setSuggestedCategoryCount}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'llm',
|
||||
label: 'LLM Parameters',
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>Temperature: {temperature}</Text>
|
||||
<Slider
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={temperature}
|
||||
onChange={setTemperature}
|
||||
marks={{ 0: '0', 0.5: '0.5', 1: '1' }}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>Chain Count: {chainCount}</Text>
|
||||
<Slider
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
value={chainCount}
|
||||
onChange={setChainCount}
|
||||
marks={{ 1: '1', 5: '5', 10: '10' }}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
}}>
|
||||
{/* Dual Path Input Card */}
|
||||
<Card
|
||||
size="small"
|
||||
title={<Text strong>Dual Path Analysis</Text>}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
{/* Model Selection */}
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={selectedModel}
|
||||
onChange={setSelectedModel}
|
||||
loading={loadingModels}
|
||||
placeholder="Select a model"
|
||||
options={models.map((m) => ({ label: m, value: m }))}
|
||||
size="middle"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{/* Path A Input */}
|
||||
<div>
|
||||
<Tag color="blue" style={{ marginBottom: 4 }}>Path A</Tag>
|
||||
<TextArea
|
||||
value={queryA}
|
||||
onChange={(e) => setQueryA(e.target.value)}
|
||||
placeholder="Enter first object (e.g., umbrella)"
|
||||
autoSize={{ minRows: 1, maxRows: 2 }}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{renderProgressIndicator('Path A', progressA, loadingA)}
|
||||
</div>
|
||||
|
||||
{/* Swap Button */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
icon={<SwapOutlined rotate={90} />}
|
||||
size="small"
|
||||
onClick={handleSwapQueries}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Swap
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Path B Input */}
|
||||
<div>
|
||||
<Tag color="green" style={{ marginBottom: 4 }}>Path B</Tag>
|
||||
<TextArea
|
||||
value={queryB}
|
||||
onChange={(e) => setQueryB(e.target.value)}
|
||||
placeholder="Enter second object (e.g., bicycle)"
|
||||
autoSize={{ minRows: 1, maxRows: 2 }}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{renderProgressIndicator('Path B', progressB, loadingB)}
|
||||
</div>
|
||||
|
||||
{/* Analyze Button */}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleAnalyze}
|
||||
loading={isLoading}
|
||||
block
|
||||
size="large"
|
||||
disabled={!queryA.trim() || !queryB.trim()}
|
||||
>
|
||||
{isLoading ? 'Analyzing...' : 'Analyze Both'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Combined Progress Alert */}
|
||||
{isLoading && (
|
||||
<Alert
|
||||
type="info"
|
||||
icon={<LoadingOutlined spin />}
|
||||
message="Parallel Analysis in Progress"
|
||||
description={
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Tag color="blue">A</Tag> {progressA.message || 'Waiting...'}
|
||||
</div>
|
||||
<div>
|
||||
<Tag color="green">B</Tag> {progressB.message || 'Waiting...'}
|
||||
</div>
|
||||
</Space>
|
||||
}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '4px 0' }} />
|
||||
|
||||
{/* Settings Collapse */}
|
||||
<Collapse
|
||||
items={collapseItems}
|
||||
defaultActiveKey={[]}
|
||||
size="small"
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
frontend/src/components/DualPathMindmapPanel.tsx
Normal file
191
frontend/src/components/DualPathMindmapPanel.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Empty, Spin, Tag, Typography } from 'antd';
|
||||
import type { PathState } from '../types';
|
||||
import { MindmapDAG } from './MindmapDAG';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface VisualSettings {
|
||||
nodeSpacing: number;
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
interface DualPathMindmapPanelProps {
|
||||
pathA: PathState;
|
||||
pathB: PathState;
|
||||
isDark: boolean;
|
||||
visualSettings: VisualSettings;
|
||||
}
|
||||
|
||||
interface SinglePathViewProps {
|
||||
path: PathState;
|
||||
label: string;
|
||||
color: 'blue' | 'green';
|
||||
isDark: boolean;
|
||||
visualSettings: VisualSettings;
|
||||
}
|
||||
|
||||
function SinglePathView({ path, label, color, isDark, visualSettings }: SinglePathViewProps) {
|
||||
const { result, loading, error, query, progress } = path;
|
||||
|
||||
// Header with label
|
||||
const headerStyle: React.CSSProperties = {
|
||||
padding: '6px 12px',
|
||||
background: isDark ? '#1f1f1f' : '#fafafa',
|
||||
borderBottom: `1px solid ${isDark ? '#303030' : '#f0f0f0'}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
minHeight: 36,
|
||||
};
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
gap: 8,
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
<Text type="secondary">{progress.message || 'Analyzing...'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}>
|
||||
<Empty description={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}>
|
||||
<Empty description={`Enter a query for ${label}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MindmapDAG
|
||||
data={result}
|
||||
isDark={isDark}
|
||||
visualSettings={visualSettings}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={headerStyle}>
|
||||
<Tag color={color}>{label}</Tag>
|
||||
{result && (
|
||||
<Text strong style={{ flex: 1 }}>
|
||||
{result.query}
|
||||
</Text>
|
||||
)}
|
||||
{!result && query && (
|
||||
<Text type="secondary" style={{ flex: 1 }}>
|
||||
{query}
|
||||
</Text>
|
||||
)}
|
||||
{result && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{result.nodes.length} attributes
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div style={contentStyle}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DualPathMindmapPanel({
|
||||
pathA,
|
||||
pathB,
|
||||
isDark,
|
||||
visualSettings,
|
||||
}: DualPathMindmapPanelProps) {
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
gap: 2,
|
||||
};
|
||||
|
||||
const pathContainerStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${isDark ? '#303030' : '#f0f0f0'}`,
|
||||
};
|
||||
|
||||
const dividerStyle: React.CSSProperties = {
|
||||
height: 4,
|
||||
background: isDark ? '#303030' : '#f0f0f0',
|
||||
cursor: 'row-resize',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
{/* Path A - Top Half */}
|
||||
<div style={pathContainerStyle}>
|
||||
<SinglePathView
|
||||
path={pathA}
|
||||
label="Path A"
|
||||
color="blue"
|
||||
isDark={isDark}
|
||||
visualSettings={visualSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={dividerStyle}>
|
||||
<div style={{
|
||||
width: 40,
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: isDark ? '#505050' : '#d0d0d0',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Path B - Bottom Half */}
|
||||
<div style={pathContainerStyle}>
|
||||
<SinglePathView
|
||||
path={pathB}
|
||||
label="Path B"
|
||||
color="green"
|
||||
isDark={isDark}
|
||||
visualSettings={visualSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
356
frontend/src/components/DualTransformationPanel.tsx
Normal file
356
frontend/src/components/DualTransformationPanel.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Empty, Spin, Card, Space, Typography, Tag, Button, Progress } from 'antd';
|
||||
import { ThunderboltOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import type { AttributeDAG, ExpertSource, ExpertTransformationDAGResult, PromptLanguage } from '../types';
|
||||
import { TransformationDAG } from './TransformationDAG';
|
||||
import { useExpertTransformation } from '../hooks/useExpertTransformation';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface DualTransformationPanelProps {
|
||||
crossoverDAGA: AttributeDAG | null; // Path A with attributes from B
|
||||
crossoverDAGB: AttributeDAG | null; // Path B with attributes from A
|
||||
isDark: boolean;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
expertConfig: {
|
||||
expert_count: number;
|
||||
keywords_per_expert: number;
|
||||
custom_experts?: string[];
|
||||
};
|
||||
expertSource: ExpertSource;
|
||||
expertLanguage: 'en' | 'zh';
|
||||
lang?: PromptLanguage;
|
||||
shouldStartTransform: boolean;
|
||||
onTransformComplete: () => void;
|
||||
onLoadingChange: (loading: boolean) => void;
|
||||
onResultsChange?: (results: { pathA: ExpertTransformationDAGResult | null; pathB: ExpertTransformationDAGResult | null }) => void;
|
||||
}
|
||||
|
||||
interface SinglePathTransformProps {
|
||||
label: string;
|
||||
color: 'blue' | 'green';
|
||||
attributeData: AttributeDAG | null;
|
||||
crossoverSource: string; // The query from which attributes were crossed over
|
||||
isDark: boolean;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
expertConfig: {
|
||||
expert_count: number;
|
||||
keywords_per_expert: number;
|
||||
custom_experts?: string[];
|
||||
};
|
||||
expertSource: ExpertSource;
|
||||
expertLanguage: 'en' | 'zh';
|
||||
lang?: PromptLanguage;
|
||||
shouldStart: boolean;
|
||||
onComplete: () => void;
|
||||
onResultChange: (result: ExpertTransformationDAGResult | null) => void;
|
||||
}
|
||||
|
||||
function SinglePathTransform({
|
||||
label,
|
||||
color,
|
||||
attributeData,
|
||||
crossoverSource,
|
||||
isDark,
|
||||
model,
|
||||
temperature,
|
||||
expertConfig,
|
||||
expertSource,
|
||||
expertLanguage,
|
||||
lang = 'zh',
|
||||
shouldStart,
|
||||
onComplete,
|
||||
onResultChange,
|
||||
}: SinglePathTransformProps) {
|
||||
const {
|
||||
loading,
|
||||
progress,
|
||||
results,
|
||||
transformAll,
|
||||
clearResults,
|
||||
} = useExpertTransformation({ model, temperature, expertSource, expertLanguage, lang });
|
||||
|
||||
// Notify parent of results changes
|
||||
useEffect(() => {
|
||||
onResultChange(results);
|
||||
}, [results, onResultChange]);
|
||||
|
||||
// Build transformation input
|
||||
const transformationInput = useMemo(() => {
|
||||
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]);
|
||||
|
||||
// Handle transform trigger
|
||||
useEffect(() => {
|
||||
if (shouldStart && transformationInput && !loading && !results) {
|
||||
transformAll(transformationInput).then(() => onComplete());
|
||||
}
|
||||
}, [shouldStart, transformationInput, loading, results, transformAll, onComplete]);
|
||||
|
||||
// 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]);
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
padding: '8px 12px',
|
||||
background: isDark ? '#1f1f1f' : '#fafafa',
|
||||
borderBottom: `1px solid ${isDark ? '#303030' : '#f0f0f0'}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
if (!attributeData || attributeData.nodes.length === 0) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={headerStyle}>
|
||||
<Tag color={color}>{label}</Tag>
|
||||
<Text type="secondary">No crossover attributes selected</Text>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Empty description="Select crossover pairs first" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={headerStyle}>
|
||||
<Tag color={color}>{label}</Tag>
|
||||
<Text strong>{attributeData.query}</Text>
|
||||
<Text type="secondary">← receiving {attributeData.nodes.length} attributes</Text>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', gap: 16 }}>
|
||||
<Spin size="large" />
|
||||
<Text>{progress.message}</Text>
|
||||
<Progress percent={progressPercent} style={{ width: 200 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (results) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={headerStyle}>
|
||||
<Tag color={color}>{label}</Tag>
|
||||
<Text strong>{attributeData.query}</Text>
|
||||
<Text type="secondary">
|
||||
{results.results.reduce((sum, r) => sum + r.descriptions.length, 0)} descriptions
|
||||
</Text>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={clearResults}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<TransformationDAG
|
||||
data={results}
|
||||
categories={attributeData.categories}
|
||||
isDark={isDark}
|
||||
crossoverSource={crossoverSource}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ready state - show attributes to transform
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={headerStyle}>
|
||||
<Tag color={color}>{label}</Tag>
|
||||
<Text strong>{attributeData.query}</Text>
|
||||
<Text type="secondary">← {attributeData.nodes.length} attributes to transform</Text>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
|
||||
<Card size="small" title="Crossover Attributes">
|
||||
<Space wrap>
|
||||
{attributeData.nodes.map(node => (
|
||||
<Tag key={node.id} color={color === 'blue' ? 'green' : 'blue'}>
|
||||
{node.name}
|
||||
<Text type="secondary" style={{ marginLeft: 4, fontSize: 10 }}>
|
||||
({node.category})
|
||||
</Text>
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 12, fontSize: 12 }}>
|
||||
These attributes will be transformed with "{attributeData.query}" as context.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DualTransformationPanel({
|
||||
crossoverDAGA,
|
||||
crossoverDAGB,
|
||||
isDark,
|
||||
model,
|
||||
temperature,
|
||||
expertConfig,
|
||||
expertSource,
|
||||
expertLanguage,
|
||||
lang = 'zh',
|
||||
shouldStartTransform,
|
||||
onTransformComplete,
|
||||
onLoadingChange,
|
||||
onResultsChange,
|
||||
}: DualTransformationPanelProps) {
|
||||
const [resultA, setResultA] = useState<ExpertTransformationDAGResult | null>(null);
|
||||
const [resultB, setResultB] = useState<ExpertTransformationDAGResult | null>(null);
|
||||
const [triggerA, setTriggerA] = useState(false);
|
||||
const [triggerB, setTriggerB] = useState(false);
|
||||
|
||||
// Handle external transform trigger
|
||||
useEffect(() => {
|
||||
if (shouldStartTransform) {
|
||||
setTriggerA(true);
|
||||
setTriggerB(true);
|
||||
onLoadingChange(true);
|
||||
}
|
||||
}, [shouldStartTransform, onLoadingChange]);
|
||||
|
||||
// Notify parent of results
|
||||
useEffect(() => {
|
||||
onResultsChange?.({ pathA: resultA, pathB: resultB });
|
||||
}, [resultA, resultB, onResultsChange]);
|
||||
|
||||
const handleCompleteA = useCallback(() => {
|
||||
setTriggerA(false);
|
||||
}, []);
|
||||
|
||||
const handleCompleteB = useCallback(() => {
|
||||
setTriggerB(false);
|
||||
}, []);
|
||||
|
||||
// When both are done, notify parent
|
||||
useEffect(() => {
|
||||
if (!triggerA && !triggerB && (resultA || resultB)) {
|
||||
onLoadingChange(false);
|
||||
onTransformComplete();
|
||||
}
|
||||
}, [triggerA, triggerB, resultA, resultB, onLoadingChange, onTransformComplete]);
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
gap: 2,
|
||||
};
|
||||
|
||||
const pathContainerStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${isDark ? '#303030' : '#f0f0f0'}`,
|
||||
};
|
||||
|
||||
const dividerStyle: React.CSSProperties = {
|
||||
height: 4,
|
||||
background: isDark ? '#303030' : '#f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
};
|
||||
|
||||
// Show empty state if no crossover data
|
||||
if (!crossoverDAGA && !crossoverDAGB) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Empty
|
||||
description={
|
||||
<Space direction="vertical" align="center">
|
||||
<ThunderboltOutlined style={{ fontSize: 32, color: isDark ? '#444' : '#ccc' }} />
|
||||
<Text>Select crossover pairs first</Text>
|
||||
<Text type="secondary">
|
||||
Go to the Crossover tab and select attribute pairs to transform
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
{/* Path A - Top (receives attributes from Path B) */}
|
||||
<div style={pathContainerStyle}>
|
||||
<SinglePathTransform
|
||||
label="Path A"
|
||||
color="blue"
|
||||
attributeData={crossoverDAGA}
|
||||
crossoverSource={crossoverDAGB?.query || 'Path B'}
|
||||
isDark={isDark}
|
||||
model={model}
|
||||
temperature={temperature}
|
||||
expertConfig={expertConfig}
|
||||
expertSource={expertSource}
|
||||
expertLanguage={expertLanguage}
|
||||
lang={lang}
|
||||
shouldStart={triggerA}
|
||||
onComplete={handleCompleteA}
|
||||
onResultChange={setResultA}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={dividerStyle}>
|
||||
<div style={{
|
||||
width: 40,
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: isDark ? '#505050' : '#d0d0d0',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Path B - Bottom (receives attributes from Path A) */}
|
||||
<div style={pathContainerStyle}>
|
||||
<SinglePathTransform
|
||||
label="Path B"
|
||||
color="green"
|
||||
attributeData={crossoverDAGB}
|
||||
crossoverSource={crossoverDAGA?.query || 'Path A'}
|
||||
isDark={isDark}
|
||||
model={model}
|
||||
temperature={temperature}
|
||||
expertConfig={expertConfig}
|
||||
expertSource={expertSource}
|
||||
expertLanguage={expertLanguage}
|
||||
lang={lang}
|
||||
shouldStart={triggerB}
|
||||
onComplete={handleCompleteB}
|
||||
onResultChange={setResultB}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
FileTextOutlined,
|
||||
CodeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { AttributeDAG, CategoryMode } from '../types';
|
||||
import type { AttributeDAG, CategoryMode, PromptLanguage } from '../types';
|
||||
import { getModels } from '../services/api';
|
||||
import { CategorySelector } from './CategorySelector';
|
||||
|
||||
@@ -59,12 +59,14 @@ interface InputPanelProps {
|
||||
chainCount?: number,
|
||||
categoryMode?: CategoryMode,
|
||||
customCategories?: string[],
|
||||
suggestedCategoryCount?: number
|
||||
suggestedCategoryCount?: number,
|
||||
lang?: PromptLanguage
|
||||
) => Promise<void>;
|
||||
onLoadHistory: (item: DAGHistoryItem) => void;
|
||||
onLoadHistory: (item: DAGHistoryItem, lang?: PromptLanguage) => void;
|
||||
onResetView?: () => void;
|
||||
visualSettings: VisualSettings;
|
||||
onVisualSettingsChange: (settings: VisualSettings) => void;
|
||||
lang?: PromptLanguage;
|
||||
}
|
||||
|
||||
export function InputPanel({
|
||||
@@ -77,6 +79,7 @@ export function InputPanel({
|
||||
onResetView,
|
||||
visualSettings,
|
||||
onVisualSettingsChange,
|
||||
lang = 'zh',
|
||||
}: InputPanelProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
@@ -111,7 +114,7 @@ export function InputPanel({
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!query.trim()) {
|
||||
message.warning('Please enter a query');
|
||||
message.warning(lang === 'zh' ? '請輸入查詢內容' : 'Please enter a query');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -123,11 +126,12 @@ export function InputPanel({
|
||||
chainCount,
|
||||
categoryMode,
|
||||
customCategories.length > 0 ? customCategories : undefined,
|
||||
suggestedCategoryCount
|
||||
suggestedCategoryCount,
|
||||
lang
|
||||
);
|
||||
setQuery('');
|
||||
} catch {
|
||||
message.error('Analysis failed');
|
||||
message.error(lang === 'zh' ? '分析失敗' : 'Analysis failed');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -489,7 +493,7 @@ export function InputPanel({
|
||||
marginBottom: 4,
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onClick={() => onLoadHistory(item)}
|
||||
onClick={() => onLoadHistory(item, lang)}
|
||||
className="history-item"
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
|
||||
344
frontend/src/components/PatentSearchPanel.tsx
Normal file
344
frontend/src/components/PatentSearchPanel.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Empty,
|
||||
Input,
|
||||
Tag,
|
||||
List,
|
||||
Tooltip,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
LinkOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
GlobalOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type {
|
||||
ExpertTransformationDescription,
|
||||
} from '../types';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface PatentSearchPanelProps {
|
||||
descriptions?: ExpertTransformationDescription[];
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
interface SearchItem {
|
||||
id: string;
|
||||
query: string;
|
||||
searchUrl: string;
|
||||
expertName?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
// Generate Google Patents search URL
|
||||
function generatePatentSearchUrl(query: string): string {
|
||||
// Extract key terms and create a search-friendly query
|
||||
const encodedQuery = encodeURIComponent(query);
|
||||
return `https://patents.google.com/?q=${encodedQuery}`;
|
||||
}
|
||||
|
||||
// Generate Lens.org search URL (alternative)
|
||||
function generateLensSearchUrl(query: string): string {
|
||||
const encodedQuery = encodeURIComponent(query);
|
||||
return `https://www.lens.org/lens/search/patent/list?q=${encodedQuery}`;
|
||||
}
|
||||
|
||||
export function PatentSearchPanel({ descriptions, isDark }: PatentSearchPanelProps) {
|
||||
const [customQuery, setCustomQuery] = useState('');
|
||||
const [searchItems, setSearchItems] = useState<SearchItem[]>([]);
|
||||
const [selectedDescriptions, setSelectedDescriptions] = useState<Set<number>>(new Set());
|
||||
|
||||
// Add custom query to search list
|
||||
const handleAddCustomQuery = useCallback(() => {
|
||||
if (!customQuery.trim()) return;
|
||||
|
||||
const newItem: SearchItem = {
|
||||
id: `custom-${Date.now()}`,
|
||||
query: customQuery.trim(),
|
||||
searchUrl: generatePatentSearchUrl(customQuery.trim()),
|
||||
};
|
||||
|
||||
setSearchItems(prev => [newItem, ...prev]);
|
||||
setCustomQuery('');
|
||||
message.success('Added to search list');
|
||||
}, [customQuery]);
|
||||
|
||||
// Add selected descriptions to search list
|
||||
const handleAddSelected = useCallback(() => {
|
||||
if (!descriptions || selectedDescriptions.size === 0) return;
|
||||
|
||||
const newItems: SearchItem[] = Array.from(selectedDescriptions).map(idx => {
|
||||
const desc = descriptions[idx];
|
||||
return {
|
||||
id: `desc-${idx}-${Date.now()}`,
|
||||
query: desc.description,
|
||||
searchUrl: generatePatentSearchUrl(desc.description),
|
||||
expertName: desc.expert_name,
|
||||
keyword: desc.keyword,
|
||||
};
|
||||
});
|
||||
|
||||
setSearchItems(prev => [...newItems, ...prev]);
|
||||
setSelectedDescriptions(new Set());
|
||||
message.success(`Added ${newItems.length} items to search list`);
|
||||
}, [descriptions, selectedDescriptions]);
|
||||
|
||||
// Remove item from list
|
||||
const handleRemoveItem = useCallback((id: string) => {
|
||||
setSearchItems(prev => prev.filter(item => item.id !== id));
|
||||
}, []);
|
||||
|
||||
// Copy URL to clipboard
|
||||
const handleCopyUrl = useCallback((url: string) => {
|
||||
navigator.clipboard.writeText(url);
|
||||
message.success('URL copied to clipboard');
|
||||
}, []);
|
||||
|
||||
// Toggle description selection
|
||||
const toggleDescription = useCallback((index: number) => {
|
||||
setSelectedDescriptions(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Clear all
|
||||
const handleClearAll = useCallback(() => {
|
||||
setSearchItems([]);
|
||||
}, []);
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
padding: 16,
|
||||
overflow: 'auto',
|
||||
};
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: isDark ? '#1f1f1f' : '#fff',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
{/* Info banner */}
|
||||
<Card size="small" style={cardStyle}>
|
||||
<Space>
|
||||
<GlobalOutlined style={{ color: '#1890ff' }} />
|
||||
<Text>
|
||||
Generate search links to check for similar patents on Google Patents or Lens.org
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Custom search input */}
|
||||
<Card size="small" title="Add Custom Search" style={cardStyle}>
|
||||
<TextArea
|
||||
placeholder="Enter a description to search for similar patents..."
|
||||
value={customQuery}
|
||||
onChange={e => setCustomQuery(e.target.value)}
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleAddCustomQuery}
|
||||
disabled={!customQuery.trim()}
|
||||
>
|
||||
Add to Search List
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* Description selection (if available) */}
|
||||
{descriptions && descriptions.length > 0 && (
|
||||
<Card
|
||||
size="small"
|
||||
title={`Generated Descriptions (${descriptions.length})`}
|
||||
style={cardStyle}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleAddSelected}
|
||||
disabled={selectedDescriptions.size === 0}
|
||||
>
|
||||
Add Selected ({selectedDescriptions.size})
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div style={{ maxHeight: 200, overflow: 'auto' }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{descriptions.slice(0, 20).map((desc, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => toggleDescription(idx)}
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
background: selectedDescriptions.has(idx)
|
||||
? (isDark ? '#177ddc22' : '#1890ff11')
|
||||
: (isDark ? '#141414' : '#fafafa'),
|
||||
border: selectedDescriptions.has(idx)
|
||||
? `1px solid ${isDark ? '#177ddc' : '#1890ff'}`
|
||||
: `1px solid ${isDark ? '#303030' : '#f0f0f0'}`,
|
||||
}}
|
||||
>
|
||||
<Space size={4}>
|
||||
<Tag color="blue" style={{ fontSize: 10 }}>{desc.expert_name}</Tag>
|
||||
<Tag style={{ fontSize: 10 }}>{desc.keyword}</Tag>
|
||||
</Space>
|
||||
<Paragraph
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ marginBottom: 0, marginTop: 4, fontSize: 12 }}
|
||||
>
|
||||
{desc.description}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
{descriptions.length > 20 && (
|
||||
<Text type="secondary">
|
||||
And {descriptions.length - 20} more descriptions...
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Search list */}
|
||||
{searchItems.length > 0 && (
|
||||
<Card
|
||||
size="small"
|
||||
title={`Search List (${searchItems.length})`}
|
||||
style={{ ...cardStyle, flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}
|
||||
extra={
|
||||
<Button size="small" danger onClick={handleClearAll}>
|
||||
Clear All
|
||||
</Button>
|
||||
}
|
||||
bodyStyle={{ flex: 1, overflow: 'auto', padding: 0 }}
|
||||
>
|
||||
<List
|
||||
dataSource={searchItems}
|
||||
renderItem={item => (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: `1px solid ${isDark ? '#303030' : '#f0f0f0'}`,
|
||||
}}
|
||||
actions={[
|
||||
<Tooltip title="Open in Google Patents" key="google">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<LinkOutlined />}
|
||||
href={item.searchUrl}
|
||||
target="_blank"
|
||||
>
|
||||
Google
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
<Tooltip title="Open in Lens.org" key="lens">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<GlobalOutlined />}
|
||||
href={generateLensSearchUrl(item.query)}
|
||||
target="_blank"
|
||||
>
|
||||
Lens
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
<Tooltip title="Copy URL" key="copy">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => handleCopyUrl(item.searchUrl)}
|
||||
/>
|
||||
</Tooltip>,
|
||||
<Tooltip title="Remove" key="remove">
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
/>
|
||||
</Tooltip>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space size={4}>
|
||||
{item.expertName && (
|
||||
<Tag color="blue" style={{ fontSize: 10 }}>{item.expertName}</Tag>
|
||||
)}
|
||||
{item.keyword && (
|
||||
<Tag style={{ fontSize: 10 }}>{item.keyword}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Paragraph
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ marginBottom: 0, fontSize: 12 }}
|
||||
>
|
||||
{item.query}
|
||||
</Paragraph>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{searchItems.length === 0 && (!descriptions || descriptions.length === 0) && (
|
||||
<Card style={cardStyle}>
|
||||
<Empty
|
||||
description={
|
||||
<Space direction="vertical">
|
||||
<Text>Enter a description or run transformations first</Text>
|
||||
<Text type="secondary">
|
||||
Search links will open in Google Patents or Lens.org
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Empty state with descriptions but no search items */}
|
||||
{searchItems.length === 0 && descriptions && descriptions.length > 0 && (
|
||||
<Card style={cardStyle}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<Space direction="vertical">
|
||||
<Text>Select descriptions above to add to search list</Text>
|
||||
<Text type="secondary">
|
||||
Then click the links to search on Google Patents or Lens.org
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ interface TransformationDAGProps {
|
||||
data: TransformationDAGResult | ExpertTransformationDAGResult;
|
||||
categories: CategoryDefinition[];
|
||||
isDark: boolean;
|
||||
crossoverSource?: string; // If set, marks all attributes as crossed over from this source
|
||||
}
|
||||
|
||||
export interface TransformationDAGRef {
|
||||
@@ -30,7 +31,7 @@ export interface TransformationDAGRef {
|
||||
}
|
||||
|
||||
const TransformationDAGInner = forwardRef<TransformationDAGRef, TransformationDAGProps>(
|
||||
({ data, categories, isDark }, ref) => {
|
||||
({ data, categories, isDark, crossoverSource }, ref) => {
|
||||
const { setViewport } = useReactFlow();
|
||||
|
||||
// Check if data is ExpertTransformationDAGResult by checking for 'experts' property
|
||||
@@ -46,7 +47,7 @@ const TransformationDAGInner = forwardRef<TransformationDAGRef, TransformationDA
|
||||
const expertLayout = useExpertTransformationLayout(
|
||||
isExpertTransformation ? (data as ExpertTransformationDAGResult) : null,
|
||||
categories,
|
||||
{ isDark, fontSize: 13 }
|
||||
{ isDark, fontSize: 13, crossoverSource }
|
||||
);
|
||||
|
||||
const { nodes, edges } = isExpertTransformation ? expertLayout : regularLayout;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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, ExpertSource, ExpertTransformationDAGResult } from '../types';
|
||||
import type { AttributeDAG, ExpertTransformationInput, ExpertSource, ExpertTransformationDAGResult, PromptLanguage } from '../types';
|
||||
import { TransformationDAG } from './TransformationDAG';
|
||||
import type { TransformationDAGRef } from './TransformationDAG';
|
||||
import { useExpertTransformation } from '../hooks/useExpertTransformation';
|
||||
@@ -20,6 +20,7 @@ interface TransformationPanelProps {
|
||||
};
|
||||
expertSource: ExpertSource;
|
||||
expertLanguage: 'en' | 'zh';
|
||||
lang?: PromptLanguage;
|
||||
shouldStartTransform: boolean;
|
||||
onTransformComplete: () => void;
|
||||
onLoadingChange: (loading: boolean) => void;
|
||||
@@ -27,14 +28,14 @@ interface TransformationPanelProps {
|
||||
}
|
||||
|
||||
export const TransformationPanel = forwardRef<TransformationDAGRef, TransformationPanelProps>(
|
||||
({ attributeData, isDark, model, temperature, expertConfig, expertSource, expertLanguage, shouldStartTransform, onTransformComplete, onLoadingChange, onResultsChange }, ref) => {
|
||||
({ attributeData, isDark, model, temperature, expertConfig, expertSource, expertLanguage, lang = 'zh', shouldStartTransform, onTransformComplete, onLoadingChange, onResultsChange }, ref) => {
|
||||
const {
|
||||
loading,
|
||||
progress,
|
||||
results,
|
||||
transformAll,
|
||||
clearResults,
|
||||
} = useExpertTransformation({ model, temperature, expertSource, expertLanguage });
|
||||
} = useExpertTransformation({ model, temperature, expertSource, expertLanguage, lang });
|
||||
|
||||
// Notify parent of loading state changes
|
||||
useEffect(() => {
|
||||
|
||||
76
frontend/src/components/crossover/CrossoverCard.tsx
Normal file
76
frontend/src/components/crossover/CrossoverCard.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Card, Tag, Checkbox, Typography, Space } from 'antd';
|
||||
import { SwapOutlined } from '@ant-design/icons';
|
||||
import type { CrossoverPair } from '../../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface CrossoverCardProps {
|
||||
pair: CrossoverPair;
|
||||
onToggle: (pairId: string) => void;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
export function CrossoverCard({ pair, onToggle, isDark }: CrossoverCardProps) {
|
||||
const sourceColor = pair.sourcePathId === 'A' ? 'blue' : 'green';
|
||||
const targetColor = pair.targetPathId === 'A' ? 'blue' : 'green';
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
hoverable
|
||||
onClick={() => onToggle(pair.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
border: pair.selected
|
||||
? `2px solid ${isDark ? '#1890ff' : '#1890ff'}`
|
||||
: `1px solid ${isDark ? '#303030' : '#f0f0f0'}`,
|
||||
background: pair.selected
|
||||
? (isDark ? '#111d2c' : '#e6f4ff')
|
||||
: (isDark ? '#1f1f1f' : '#fff'),
|
||||
}}
|
||||
styles={{
|
||||
body: { padding: '8px 12px' },
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
{/* Header with checkbox */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Checkbox
|
||||
checked={pair.selected}
|
||||
onChange={() => onToggle(pair.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Tag style={{ margin: 0, fontSize: 10 }}>{pair.crossType}</Tag>
|
||||
</div>
|
||||
|
||||
{/* Crossover visualization */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{/* Source */}
|
||||
<div style={{ flex: 1, textAlign: 'right' }}>
|
||||
<Tag color={sourceColor} style={{ margin: 0 }}>
|
||||
{pair.sourcePathId}
|
||||
</Tag>
|
||||
<Text style={{ marginLeft: 4 }}>{pair.sourceNode.name}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 10, display: 'block' }}>
|
||||
{pair.sourceNode.category}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Cross icon */}
|
||||
<SwapOutlined style={{ color: isDark ? '#888' : '#999' }} />
|
||||
|
||||
{/* Target */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<Tag color={targetColor} style={{ margin: 0 }}>
|
||||
{pair.targetPathId}
|
||||
</Tag>
|
||||
<Text style={{ marginLeft: 4 }}>{pair.targetNode.name}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 10, display: 'block' }}>
|
||||
{pair.targetNode.category}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
163
frontend/src/components/crossover/CrossoverMatrix.tsx
Normal file
163
frontend/src/components/crossover/CrossoverMatrix.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Table, Tag, Checkbox, Typography, Tooltip } from 'antd';
|
||||
import type { AttributeDAG, CrossoverPair, DAGNode } from '../../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface CrossoverMatrixProps {
|
||||
dagA: AttributeDAG;
|
||||
dagB: AttributeDAG;
|
||||
pairs: CrossoverPair[];
|
||||
onTogglePair: (pairId: string) => void;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
interface MatrixCell {
|
||||
nodeA: DAGNode;
|
||||
nodeB: DAGNode;
|
||||
pair: CrossoverPair | null;
|
||||
}
|
||||
|
||||
export function CrossoverMatrix({
|
||||
dagA,
|
||||
dagB,
|
||||
pairs,
|
||||
onTogglePair,
|
||||
isDark,
|
||||
}: CrossoverMatrixProps) {
|
||||
// Group nodes by category
|
||||
const groupByCategory = (nodes: DAGNode[]) => {
|
||||
return nodes.reduce((acc, node) => {
|
||||
if (!acc[node.category]) acc[node.category] = [];
|
||||
acc[node.category].push(node);
|
||||
return acc;
|
||||
}, {} as Record<string, DAGNode[]>);
|
||||
};
|
||||
|
||||
const nodesByCategoryA = useMemo(() => groupByCategory(dagA.nodes), [dagA.nodes]);
|
||||
const nodesByCategoryB = useMemo(() => groupByCategory(dagB.nodes), [dagB.nodes]);
|
||||
|
||||
// Create a lookup map for pairs
|
||||
const pairLookup = useMemo(() => {
|
||||
const lookup = new Map<string, CrossoverPair>();
|
||||
for (const pair of pairs) {
|
||||
// Key format: sourceNodeId-targetNodeId
|
||||
lookup.set(`${pair.sourceNode.id}-${pair.targetNode.id}`, pair);
|
||||
}
|
||||
return lookup;
|
||||
}, [pairs]);
|
||||
|
||||
// Get all categories from both DAGs
|
||||
const categoriesA = Object.keys(nodesByCategoryA);
|
||||
const categoriesB = Object.keys(nodesByCategoryB);
|
||||
|
||||
// Render a matrix for each category pair
|
||||
const renderCategoryMatrix = (categoryA: string, categoryB: string) => {
|
||||
const nodesA = nodesByCategoryA[categoryA] || [];
|
||||
const nodesB = nodesByCategoryB[categoryB] || [];
|
||||
|
||||
if (nodesA.length === 0 || nodesB.length === 0) return null;
|
||||
|
||||
// Create matrix data
|
||||
const matrixData = nodesA.map((nodeA) => {
|
||||
const row: Record<string, MatrixCell | DAGNode> = { nodeA };
|
||||
for (const nodeB of nodesB) {
|
||||
const pair = pairLookup.get(`${nodeA.id}-${nodeB.id}`) ||
|
||||
pairLookup.get(`${nodeB.id}-${nodeA.id}`) ||
|
||||
null;
|
||||
row[nodeB.id] = { nodeA, nodeB, pair };
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
// Create columns
|
||||
const columns = [
|
||||
{
|
||||
title: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Tag color="blue">A</Tag>
|
||||
<Text strong>{categoryA}</Text>
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'nodeA',
|
||||
key: 'nodeA',
|
||||
width: 120,
|
||||
fixed: 'left' as const,
|
||||
render: (node: DAGNode) => (
|
||||
<Text ellipsis style={{ maxWidth: 100 }}>{node.name}</Text>
|
||||
),
|
||||
},
|
||||
...nodesB.map((nodeB) => ({
|
||||
title: (
|
||||
<Tooltip title={nodeB.name}>
|
||||
<Text ellipsis style={{ maxWidth: 80 }}>{nodeB.name}</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: nodeB.id,
|
||||
key: nodeB.id,
|
||||
width: 60,
|
||||
align: 'center' as const,
|
||||
render: (cell: MatrixCell) => {
|
||||
if (!cell || !cell.pair) {
|
||||
return <div style={{ color: isDark ? '#555' : '#ddd' }}>-</div>;
|
||||
}
|
||||
return (
|
||||
<Checkbox
|
||||
checked={cell.pair.selected}
|
||||
onChange={() => onTogglePair(cell.pair!.id)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div key={`${categoryA}-${categoryB}`} style={{ marginBottom: 16 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
padding: '4px 8px',
|
||||
background: isDark ? '#1f1f1f' : '#fafafa',
|
||||
borderRadius: 4,
|
||||
}}>
|
||||
<Tag color="blue">A: {categoryA}</Tag>
|
||||
<Text type="secondary">×</Text>
|
||||
<Tag color="green">B: {categoryB}</Tag>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={matrixData}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey={(row) => (row.nodeA as DAGNode).id}
|
||||
bordered
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render matrices for matching categories (same category in both paths)
|
||||
const matchingCategories = categoriesA.filter(cat => categoriesB.includes(cat));
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
{matchingCategories.length > 0 ? (
|
||||
<>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
Select attribute pairs to cross between Path A and Path B
|
||||
</Text>
|
||||
{matchingCategories.map(category =>
|
||||
renderCategoryMatrix(category, category)
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text type="secondary">
|
||||
No matching categories found between the two paths
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
frontend/src/components/crossover/CrossoverPreview.tsx
Normal file
189
frontend/src/components/crossover/CrossoverPreview.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Card, Tag, Typography, Space, Empty, Divider } from 'antd';
|
||||
import { SwapOutlined, ArrowRightOutlined } from '@ant-design/icons';
|
||||
import type { CrossoverPair, AttributeDAG } from '../../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface CrossoverPreviewProps {
|
||||
selectedPairs: CrossoverPair[];
|
||||
dagA: AttributeDAG | null;
|
||||
dagB: AttributeDAG | null;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
interface GroupedAttribute {
|
||||
category: string;
|
||||
attributes: string[];
|
||||
}
|
||||
|
||||
export function CrossoverPreview({
|
||||
selectedPairs,
|
||||
dagA,
|
||||
dagB,
|
||||
isDark,
|
||||
}: CrossoverPreviewProps) {
|
||||
// Group selected attributes by path and category
|
||||
const { pathAGroups, pathBGroups, crossPairs } = useMemo(() => {
|
||||
const pathAMap = new Map<string, Set<string>>();
|
||||
const pathBMap = new Map<string, Set<string>>();
|
||||
const crosses: Array<{
|
||||
sourceAttr: string;
|
||||
sourceCategory: string;
|
||||
sourcePath: string;
|
||||
targetAttr: string;
|
||||
targetCategory: string;
|
||||
targetPath: string;
|
||||
}> = [];
|
||||
|
||||
for (const pair of selectedPairs) {
|
||||
// Track source attributes
|
||||
const sourceKey = pair.sourceNode.category;
|
||||
if (!pathAMap.has(sourceKey)) pathAMap.set(sourceKey, new Set());
|
||||
pathAMap.get(sourceKey)!.add(pair.sourceNode.name);
|
||||
|
||||
// Track target attributes
|
||||
const targetKey = pair.targetNode.category;
|
||||
if (!pathBMap.has(targetKey)) pathBMap.set(targetKey, new Set());
|
||||
pathBMap.get(targetKey)!.add(pair.targetNode.name);
|
||||
|
||||
// Track cross pairs for visualization
|
||||
crosses.push({
|
||||
sourceAttr: pair.sourceNode.name,
|
||||
sourceCategory: pair.sourceNode.category,
|
||||
sourcePath: pair.sourcePathId,
|
||||
targetAttr: pair.targetNode.name,
|
||||
targetCategory: pair.targetNode.category,
|
||||
targetPath: pair.targetPathId,
|
||||
});
|
||||
}
|
||||
|
||||
const pathAGroups: GroupedAttribute[] = Array.from(pathAMap.entries()).map(
|
||||
([category, attrs]) => ({ category, attributes: Array.from(attrs) })
|
||||
);
|
||||
const pathBGroups: GroupedAttribute[] = Array.from(pathBMap.entries()).map(
|
||||
([category, attrs]) => ({ category, attributes: Array.from(attrs) })
|
||||
);
|
||||
|
||||
return { pathAGroups, pathBGroups, crossPairs: crosses };
|
||||
}, [selectedPairs]);
|
||||
|
||||
if (selectedPairs.length === 0) {
|
||||
return (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Empty
|
||||
description="No pairs selected"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const cardStyle = {
|
||||
background: isDark ? '#1f1f1f' : '#fafafa',
|
||||
borderRadius: 8,
|
||||
};
|
||||
|
||||
const renderPathSummary = (
|
||||
label: string,
|
||||
color: 'blue' | 'green',
|
||||
groups: GroupedAttribute[],
|
||||
query: string | undefined
|
||||
) => (
|
||||
<div style={{ flex: 1, minWidth: 150 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Tag color={color}>{label}</Tag>
|
||||
{query && (
|
||||
<Text strong style={{ marginLeft: 4 }}>
|
||||
{query}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
{groups.map(({ category, attributes }) => (
|
||||
<div key={category}>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{category}:
|
||||
</Text>
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
{attributes.map((attr, i) => (
|
||||
<Tag
|
||||
key={i}
|
||||
style={{
|
||||
margin: '2px 4px 2px 0',
|
||||
fontSize: 11,
|
||||
padding: '0 6px',
|
||||
}}
|
||||
>
|
||||
{attr}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Get unique cross pairs for display (limit to first 5)
|
||||
const uniqueCrosses = crossPairs.slice(0, 5);
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<SwapOutlined />
|
||||
<Text strong>Selection Preview</Text>
|
||||
<Tag>{selectedPairs.length} pairs</Tag>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
{/* Side by side path summary */}
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
|
||||
{renderPathSummary('Path A', 'blue', pathAGroups, dagA?.query)}
|
||||
<Divider type="vertical" style={{ height: 'auto' }} />
|
||||
{renderPathSummary('Path B', 'green', pathBGroups, dagB?.query)}
|
||||
</div>
|
||||
|
||||
{/* Cross visualization */}
|
||||
<div style={cardStyle}>
|
||||
<div style={{ padding: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>
|
||||
Sample Crossovers:
|
||||
</Text>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
{uniqueCrosses.map((cross, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
background: isDark ? '#141414' : '#fff',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<Tag color="blue" style={{ margin: 0 }}>A</Tag>
|
||||
<Text ellipsis style={{ maxWidth: 100 }}>{cross.sourceAttr}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 10 }}>({cross.sourceCategory})</Text>
|
||||
<ArrowRightOutlined style={{ color: isDark ? '#666' : '#999' }} />
|
||||
<Tag color="green" style={{ margin: 0 }}>B</Tag>
|
||||
<Text ellipsis style={{ maxWidth: 100 }}>{cross.targetAttr}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 10 }}>({cross.targetCategory})</Text>
|
||||
</div>
|
||||
))}
|
||||
{crossPairs.length > 5 && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
... and {crossPairs.length - 5} more pairs
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,72 @@
|
||||
import { memo } from 'react';
|
||||
import { SwapOutlined } from '@ant-design/icons';
|
||||
|
||||
interface OriginalAttributeNodeProps {
|
||||
data: {
|
||||
label: string;
|
||||
color: string;
|
||||
isDark: boolean;
|
||||
crossoverSource?: string; // The query from which this attribute was crossed over
|
||||
};
|
||||
}
|
||||
|
||||
export const OriginalAttributeNode = memo(({ data }: OriginalAttributeNodeProps) => {
|
||||
const { label, color, isDark } = data;
|
||||
const { label, color, isDark, crossoverSource } = data;
|
||||
const isCrossover = !!crossoverSource;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
position: 'relative',
|
||||
padding: isCrossover ? '8px 16px 8px 12px' : '8px 16px',
|
||||
borderRadius: 8,
|
||||
background: isDark
|
||||
? `linear-gradient(135deg, ${color}22 0%, ${color}0a 100%)`
|
||||
: `linear-gradient(135deg, ${color}1a 0%, ${color}05 100%)`,
|
||||
border: `1px solid ${color}33`,
|
||||
border: isCrossover
|
||||
? `2px dashed ${isDark ? '#faad14' : '#d48806'}`
|
||||
: `1px solid ${color}33`,
|
||||
color: isDark ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.85)',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.05)',
|
||||
boxShadow: isCrossover
|
||||
? '0 2px 8px rgba(250, 173, 20, 0.3)'
|
||||
: '0 2px 6px rgba(0,0,0,0.05)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{isCrossover && (
|
||||
<SwapOutlined
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
left: -8,
|
||||
fontSize: 14,
|
||||
color: isDark ? '#faad14' : '#d48806',
|
||||
background: isDark ? '#1f1f1f' : '#fff',
|
||||
borderRadius: '50%',
|
||||
padding: 2,
|
||||
border: `1px solid ${isDark ? '#faad14' : '#d48806'}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{label}
|
||||
</div>
|
||||
{isCrossover && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: isDark ? '#faad14' : '#d48806',
|
||||
marginTop: 2,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
← from {crossoverSource}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ExpertTransformationDAGResult, CategoryDefinition } from '../../ty
|
||||
interface LayoutConfig {
|
||||
isDark: boolean;
|
||||
fontSize?: number;
|
||||
crossoverSource?: string; // If set, marks all attributes as crossed over from this source
|
||||
}
|
||||
|
||||
const COLOR_PALETTE = [
|
||||
@@ -39,7 +40,7 @@ export function useExpertTransformationLayout(
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
|
||||
const { isDark, fontSize = 13 } = config;
|
||||
const { isDark, fontSize = 13, crossoverSource } = config;
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
|
||||
@@ -233,6 +234,7 @@ export function useExpertTransformationLayout(
|
||||
label: group.attribute,
|
||||
color,
|
||||
isDark,
|
||||
crossoverSource, // Mark as crossover if source is provided
|
||||
},
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type {
|
||||
AttributeDAG,
|
||||
DAGStreamAnalyzeResponse
|
||||
DAGStreamAnalyzeResponse,
|
||||
PromptLanguage
|
||||
} from '../types';
|
||||
import { CategoryMode } from '../types';
|
||||
import { analyzeAttributesStream } from '../services/api';
|
||||
@@ -34,12 +35,13 @@ export function useAttribute() {
|
||||
chainCount: number = 5,
|
||||
categoryMode: CategoryMode = CategoryMode.DYNAMIC_AUTO,
|
||||
customCategories?: string[],
|
||||
suggestedCategoryCount: number = 3
|
||||
suggestedCategoryCount: number = 3,
|
||||
lang: PromptLanguage = 'zh'
|
||||
) => {
|
||||
// 重置狀態
|
||||
// Reset state
|
||||
setProgress({
|
||||
step: 'idle',
|
||||
message: '準備開始分析...',
|
||||
message: lang === 'zh' ? '準備開始分析...' : 'Preparing analysis...',
|
||||
});
|
||||
setError(null);
|
||||
setCurrentResult(null);
|
||||
@@ -53,62 +55,63 @@ export function useAttribute() {
|
||||
temperature,
|
||||
category_mode: categoryMode,
|
||||
custom_categories: customCategories,
|
||||
suggested_category_count: suggestedCategoryCount
|
||||
suggested_category_count: suggestedCategoryCount,
|
||||
lang
|
||||
},
|
||||
{
|
||||
onStep0Start: () => {
|
||||
setProgress({
|
||||
step: 'step0',
|
||||
message: '正在分析類別...',
|
||||
message: lang === 'zh' ? '正在分析類別...' : 'Analyzing categories...',
|
||||
});
|
||||
},
|
||||
|
||||
onStep0Complete: () => {
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
message: '類別分析完成',
|
||||
message: lang === 'zh' ? '類別分析完成' : 'Category analysis complete',
|
||||
}));
|
||||
},
|
||||
|
||||
onCategoriesResolved: (categories) => {
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
message: `使用 ${categories.length} 個類別`,
|
||||
message: lang === 'zh' ? `使用 ${categories.length} 個類別` : `Using ${categories.length} categories`,
|
||||
}));
|
||||
},
|
||||
|
||||
onStep1Start: () => {
|
||||
setProgress({
|
||||
step: 'step1',
|
||||
message: '正在分析物件屬性列表...',
|
||||
message: lang === 'zh' ? '正在分析物件屬性列表...' : 'Analyzing object attributes...',
|
||||
});
|
||||
},
|
||||
|
||||
onStep1Complete: () => {
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
message: '屬性列表分析完成',
|
||||
message: lang === 'zh' ? '屬性列表分析完成' : 'Attribute analysis complete',
|
||||
}));
|
||||
},
|
||||
|
||||
onRelationshipsStart: () => {
|
||||
setProgress({
|
||||
step: 'relationships',
|
||||
message: '正在生成關係...',
|
||||
message: lang === 'zh' ? '正在生成關係...' : 'Generating relationships...',
|
||||
});
|
||||
},
|
||||
|
||||
onRelationshipsComplete: (count) => {
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
message: `生成 ${count} 個關係`,
|
||||
message: lang === 'zh' ? `生成 ${count} 個關係` : `Generated ${count} relationships`,
|
||||
}));
|
||||
},
|
||||
|
||||
onDone: (response: DAGStreamAnalyzeResponse) => {
|
||||
setProgress({
|
||||
step: 'done',
|
||||
message: '分析完成!',
|
||||
message: lang === 'zh' ? '分析完成!' : 'Analysis complete!',
|
||||
});
|
||||
setCurrentResult(response.dag);
|
||||
|
||||
@@ -126,7 +129,7 @@ export function useAttribute() {
|
||||
setProgress({
|
||||
step: 'error',
|
||||
error: errorMsg,
|
||||
message: `錯誤: ${errorMsg}`,
|
||||
message: lang === 'zh' ? `錯誤: ${errorMsg}` : `Error: ${errorMsg}`,
|
||||
});
|
||||
setError(errorMsg);
|
||||
},
|
||||
@@ -137,19 +140,19 @@ export function useAttribute() {
|
||||
setProgress({
|
||||
step: 'error',
|
||||
error: errorMessage,
|
||||
message: `錯誤: ${errorMessage}`,
|
||||
message: lang === 'zh' ? `錯誤: ${errorMessage}` : `Error: ${errorMessage}`,
|
||||
});
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadFromHistory = useCallback((item: DAGHistoryItem) => {
|
||||
const loadFromHistory = useCallback((item: DAGHistoryItem, lang: PromptLanguage = 'zh') => {
|
||||
setCurrentResult(item.result);
|
||||
setError(null);
|
||||
setProgress({
|
||||
step: 'done',
|
||||
message: '從歷史記錄載入',
|
||||
message: lang === 'zh' ? '從歷史記錄載入' : 'Loaded from history',
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
182
frontend/src/hooks/useAttributeCrossover.ts
Normal file
182
frontend/src/hooks/useAttributeCrossover.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
AttributeDAG,
|
||||
DAGNode,
|
||||
CrossoverPair,
|
||||
CrossoverConfig,
|
||||
CrossoverResult,
|
||||
} from '../types';
|
||||
|
||||
const DEFAULT_CONFIG: CrossoverConfig = {
|
||||
enabledCrossTypes: ['same-category', 'cross-category'],
|
||||
maxPairsPerType: 10,
|
||||
autoGenerate: true,
|
||||
};
|
||||
|
||||
export function useAttributeCrossover() {
|
||||
const [pairs, setPairs] = useState<CrossoverPair[]>([]);
|
||||
const [config, setConfig] = useState<CrossoverConfig>(DEFAULT_CONFIG);
|
||||
|
||||
// Group nodes by category
|
||||
const groupNodesByCategory = useCallback((nodes: DAGNode[]): Record<string, DAGNode[]> => {
|
||||
return nodes.reduce((acc, node) => {
|
||||
if (!acc[node.category]) acc[node.category] = [];
|
||||
acc[node.category].push(node);
|
||||
return acc;
|
||||
}, {} as Record<string, DAGNode[]>);
|
||||
}, []);
|
||||
|
||||
// Generate crossover pairs from two DAGs
|
||||
const generatePairs = useCallback((
|
||||
dagA: AttributeDAG,
|
||||
dagB: AttributeDAG
|
||||
): CrossoverPair[] => {
|
||||
const newPairs: CrossoverPair[] = [];
|
||||
|
||||
const nodesByCategoryA = groupNodesByCategory(dagA.nodes);
|
||||
const nodesByCategoryB = groupNodesByCategory(dagB.nodes);
|
||||
|
||||
// Get all unique categories from both DAGs
|
||||
const categoriesA = Object.keys(nodesByCategoryA);
|
||||
const categoriesB = Object.keys(nodesByCategoryB);
|
||||
|
||||
// Strategy 1: Cross matching categories (A's category X with B's category X)
|
||||
// This helps find how different objects approach the same category differently
|
||||
for (const category of categoriesA) {
|
||||
if (nodesByCategoryB[category]) {
|
||||
const nodesA = nodesByCategoryA[category].slice(0, config.maxPairsPerType);
|
||||
const nodesB = nodesByCategoryB[category].slice(0, config.maxPairsPerType);
|
||||
|
||||
for (const nodeA of nodesA) {
|
||||
for (const nodeB of nodesB) {
|
||||
newPairs.push({
|
||||
id: `same-${nodeA.id}-${nodeB.id}`,
|
||||
sourcePathId: 'A',
|
||||
sourceNode: nodeA,
|
||||
targetPathId: 'B',
|
||||
targetNode: nodeB,
|
||||
crossType: `same-${category}`,
|
||||
selected: true, // Default selected for same-category pairs
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Cross different categories between A and B
|
||||
// This creates innovative combinations (e.g., A's materials with B's functions)
|
||||
for (const categoryA of categoriesA) {
|
||||
for (const categoryB of categoriesB) {
|
||||
// Skip if same category (already handled above)
|
||||
if (categoryA === categoryB) continue;
|
||||
|
||||
const nodesA = nodesByCategoryA[categoryA].slice(0, 3);
|
||||
const nodesB = nodesByCategoryB[categoryB].slice(0, 3);
|
||||
|
||||
if (nodesA.length > 0 && nodesB.length > 0) {
|
||||
for (const nodeA of nodesA) {
|
||||
for (const nodeB of nodesB) {
|
||||
newPairs.push({
|
||||
id: `cross-${nodeA.id}-${nodeB.id}`,
|
||||
sourcePathId: 'A',
|
||||
sourceNode: nodeA,
|
||||
targetPathId: 'B',
|
||||
targetNode: nodeB,
|
||||
crossType: `cross-${categoryA}-${categoryB}`,
|
||||
selected: false, // Cross-category pairs default to not selected
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newPairs;
|
||||
}, [groupNodesByCategory, config.maxPairsPerType]);
|
||||
|
||||
// Apply generated pairs to state
|
||||
const applyPairs = useCallback((dagA: AttributeDAG, dagB: AttributeDAG) => {
|
||||
const newPairs = generatePairs(dagA, dagB);
|
||||
setPairs(newPairs);
|
||||
return newPairs;
|
||||
}, [generatePairs]);
|
||||
|
||||
// Toggle pair selection
|
||||
const togglePairSelection = useCallback((pairId: string) => {
|
||||
setPairs(prev => prev.map(pair =>
|
||||
pair.id === pairId
|
||||
? { ...pair, selected: !pair.selected }
|
||||
: pair
|
||||
));
|
||||
}, []);
|
||||
|
||||
// Select all pairs of a specific type
|
||||
const selectPairsByType = useCallback((crossType: string, selected: boolean) => {
|
||||
setPairs(prev => prev.map(pair =>
|
||||
pair.crossType === crossType
|
||||
? { ...pair, selected }
|
||||
: pair
|
||||
));
|
||||
}, []);
|
||||
|
||||
// Select all pairs
|
||||
const selectAll = useCallback((selected: boolean) => {
|
||||
setPairs(prev => prev.map(pair => ({ ...pair, selected })));
|
||||
}, []);
|
||||
|
||||
// Clear all pairs
|
||||
const clearPairs = useCallback(() => {
|
||||
setPairs([]);
|
||||
}, []);
|
||||
|
||||
// Get pairs grouped by cross type
|
||||
const pairsByType = useMemo(() => {
|
||||
return pairs.reduce((acc, pair) => {
|
||||
if (!acc[pair.crossType]) acc[pair.crossType] = [];
|
||||
acc[pair.crossType].push(pair);
|
||||
return acc;
|
||||
}, {} as Record<string, CrossoverPair[]>);
|
||||
}, [pairs]);
|
||||
|
||||
// Get selected pairs only
|
||||
const selectedPairs = useMemo(() => {
|
||||
return pairs.filter(p => p.selected);
|
||||
}, [pairs]);
|
||||
|
||||
// Get cross type statistics
|
||||
const crossTypeStats = useMemo(() => {
|
||||
const stats: Record<string, { total: number; selected: number }> = {};
|
||||
for (const pair of pairs) {
|
||||
if (!stats[pair.crossType]) {
|
||||
stats[pair.crossType] = { total: 0, selected: 0 };
|
||||
}
|
||||
stats[pair.crossType].total++;
|
||||
if (pair.selected) {
|
||||
stats[pair.crossType].selected++;
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}, [pairs]);
|
||||
|
||||
// Get result summary
|
||||
const result: CrossoverResult = useMemo(() => ({
|
||||
pairs,
|
||||
totalPairs: pairs.length,
|
||||
}), [pairs]);
|
||||
|
||||
return {
|
||||
pairs,
|
||||
selectedPairs,
|
||||
pairsByType,
|
||||
crossTypeStats,
|
||||
config,
|
||||
result,
|
||||
setConfig,
|
||||
generatePairs,
|
||||
applyPairs,
|
||||
togglePairSelection,
|
||||
selectPairsByType,
|
||||
selectAll,
|
||||
clearPairs,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
DeduplicationResult,
|
||||
DeduplicationProgress,
|
||||
DeduplicationMethod,
|
||||
PromptLanguage,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
@@ -25,14 +26,16 @@ export function useDeduplication() {
|
||||
* @param descriptions - List of descriptions to deduplicate
|
||||
* @param threshold - Similarity threshold (only used for embedding method)
|
||||
* @param method - Deduplication method: 'embedding' (fast) or 'llm' (accurate but slow)
|
||||
* @param lang - Prompt language for LLM method
|
||||
*/
|
||||
const deduplicate = useCallback(async (
|
||||
descriptions: ExpertTransformationDescription[],
|
||||
threshold: number = 0.85,
|
||||
method: DeduplicationMethod = 'embedding'
|
||||
method: DeduplicationMethod = 'embedding',
|
||||
lang: PromptLanguage = 'zh'
|
||||
) => {
|
||||
if (!descriptions || descriptions.length === 0) {
|
||||
setError('No descriptions to deduplicate');
|
||||
setError(lang === 'zh' ? '沒有可去重的描述' : 'No descriptions to deduplicate');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -40,12 +43,15 @@ export function useDeduplication() {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
// 根據方法顯示不同的進度訊息
|
||||
const methodLabel = method === 'embedding' ? 'Embedding' : 'LLM';
|
||||
const pairCount = (descriptions.length * (descriptions.length - 1)) / 2;
|
||||
const progressMessage = method === 'llm'
|
||||
? `Processing ${descriptions.length} descriptions with LLM (${pairCount} comparisons)...`
|
||||
: `Processing ${descriptions.length} descriptions with ${methodLabel}...`;
|
||||
? (lang === 'zh'
|
||||
? `正在使用 LLM 處理 ${descriptions.length} 個描述(${pairCount} 次比較)...`
|
||||
: `Processing ${descriptions.length} descriptions with LLM (${pairCount} comparisons)...`)
|
||||
: (lang === 'zh'
|
||||
? `正在使用 ${methodLabel} 處理 ${descriptions.length} 個描述...`
|
||||
: `Processing ${descriptions.length} descriptions with ${methodLabel}...`);
|
||||
|
||||
setProgress({
|
||||
step: 'processing',
|
||||
@@ -57,19 +63,22 @@ export function useDeduplication() {
|
||||
descriptions,
|
||||
similarity_threshold: threshold,
|
||||
method,
|
||||
lang,
|
||||
});
|
||||
|
||||
setResult(deduplicationResult);
|
||||
setProgress({
|
||||
step: 'done',
|
||||
message: `Found ${deduplicationResult.total_groups} unique groups, ${deduplicationResult.total_duplicates} duplicates (${methodLabel})`,
|
||||
message: lang === 'zh'
|
||||
? `發現 ${deduplicationResult.total_groups} 個獨特群組,${deduplicationResult.total_duplicates} 個重複(${methodLabel})`
|
||||
: `Found ${deduplicationResult.total_groups} unique groups, ${deduplicationResult.total_duplicates} duplicates (${methodLabel})`,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(errorMessage);
|
||||
setProgress({
|
||||
step: 'error',
|
||||
message: 'Deduplication failed',
|
||||
message: lang === 'zh' ? '去重失敗' : 'Deduplication failed',
|
||||
error: errorMessage,
|
||||
});
|
||||
} finally {
|
||||
|
||||
218
frontend/src/hooks/useDualPathAttribute.ts
Normal file
218
frontend/src/hooks/useDualPathAttribute.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type {
|
||||
AttributeDAG,
|
||||
DAGStreamAnalyzeResponse,
|
||||
PathId,
|
||||
PathState,
|
||||
DualPathState,
|
||||
DAGProgress,
|
||||
CategoryMode,
|
||||
PromptLanguage,
|
||||
} from '../types';
|
||||
import { CategoryMode as CategoryModeValues } from '../types';
|
||||
import { analyzeAttributesStream } from '../services/api';
|
||||
|
||||
const initialProgress: DAGProgress = {
|
||||
step: 'idle',
|
||||
message: '',
|
||||
};
|
||||
|
||||
const initialPathState: PathState = {
|
||||
query: '',
|
||||
result: null,
|
||||
loading: false,
|
||||
progress: initialProgress,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export interface AnalyzeOptions {
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
chainCount?: number;
|
||||
categoryMode?: CategoryMode;
|
||||
customCategories?: string[];
|
||||
suggestedCategoryCount?: number;
|
||||
lang?: PromptLanguage;
|
||||
}
|
||||
|
||||
export function useDualPathAttribute() {
|
||||
const [state, setState] = useState<DualPathState>({
|
||||
pathA: { ...initialPathState },
|
||||
pathB: { ...initialPathState },
|
||||
});
|
||||
|
||||
const getPathKey = (pathId: PathId): 'pathA' | 'pathB' =>
|
||||
pathId === 'A' ? 'pathA' : 'pathB';
|
||||
|
||||
const updatePathState = useCallback((
|
||||
pathId: PathId,
|
||||
updates: Partial<PathState>
|
||||
) => {
|
||||
const pathKey = getPathKey(pathId);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
[pathKey]: {
|
||||
...prev[pathKey],
|
||||
...updates,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updatePathProgress = useCallback((
|
||||
pathId: PathId,
|
||||
step: DAGProgress['step'],
|
||||
message: string,
|
||||
error?: string
|
||||
) => {
|
||||
updatePathState(pathId, {
|
||||
progress: { step, message, error },
|
||||
});
|
||||
}, [updatePathState]);
|
||||
|
||||
const analyzePath = useCallback(async (
|
||||
pathId: PathId,
|
||||
query: string,
|
||||
options: AnalyzeOptions = {}
|
||||
) => {
|
||||
const {
|
||||
model,
|
||||
temperature,
|
||||
chainCount = 5,
|
||||
categoryMode = CategoryModeValues.DYNAMIC_AUTO,
|
||||
customCategories,
|
||||
suggestedCategoryCount = 3,
|
||||
lang = 'zh',
|
||||
} = options;
|
||||
|
||||
// Reset state for this path
|
||||
updatePathState(pathId, {
|
||||
query,
|
||||
result: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
progress: { step: 'idle', message: lang === 'zh' ? '準備分析中...' : 'Preparing analysis...' },
|
||||
});
|
||||
|
||||
try {
|
||||
await analyzeAttributesStream(
|
||||
{
|
||||
query,
|
||||
chain_count: chainCount,
|
||||
model,
|
||||
temperature,
|
||||
category_mode: categoryMode,
|
||||
custom_categories: customCategories,
|
||||
suggested_category_count: suggestedCategoryCount,
|
||||
lang,
|
||||
},
|
||||
{
|
||||
onStep0Start: () => {
|
||||
updatePathProgress(pathId, 'step0', lang === 'zh' ? '正在分析類別...' : 'Analyzing categories...');
|
||||
},
|
||||
|
||||
onStep0Complete: () => {
|
||||
updatePathProgress(pathId, 'step0', lang === 'zh' ? '類別分析完成' : 'Categories analyzed');
|
||||
},
|
||||
|
||||
onCategoriesResolved: (categories) => {
|
||||
updatePathProgress(pathId, 'step0', lang === 'zh' ? `使用 ${categories.length} 個類別` : `Using ${categories.length} categories`);
|
||||
},
|
||||
|
||||
onStep1Start: () => {
|
||||
updatePathProgress(pathId, 'step1', lang === 'zh' ? '正在分析屬性...' : 'Analyzing attributes...');
|
||||
},
|
||||
|
||||
onStep1Complete: () => {
|
||||
updatePathProgress(pathId, 'step1', lang === 'zh' ? '屬性分析完成' : 'Attributes analyzed');
|
||||
},
|
||||
|
||||
onRelationshipsStart: () => {
|
||||
updatePathProgress(pathId, 'relationships', lang === 'zh' ? '正在生成關係...' : 'Generating relationships...');
|
||||
},
|
||||
|
||||
onRelationshipsComplete: (count) => {
|
||||
updatePathProgress(pathId, 'relationships', lang === 'zh' ? `生成 ${count} 個關係` : `Generated ${count} relationships`);
|
||||
},
|
||||
|
||||
onDone: (response: DAGStreamAnalyzeResponse) => {
|
||||
updatePathState(pathId, {
|
||||
result: response.dag,
|
||||
loading: false,
|
||||
progress: { step: 'done', message: lang === 'zh' ? '分析完成!' : 'Analysis complete!' },
|
||||
});
|
||||
},
|
||||
|
||||
onError: (errorMsg) => {
|
||||
updatePathState(pathId, {
|
||||
loading: false,
|
||||
error: errorMsg,
|
||||
progress: { step: 'error', message: lang === 'zh' ? `錯誤: ${errorMsg}` : `Error: ${errorMsg}`, error: errorMsg },
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
updatePathState(pathId, {
|
||||
loading: false,
|
||||
error: errorMessage,
|
||||
progress: { step: 'error', message: lang === 'zh' ? `錯誤: ${errorMessage}` : `Error: ${errorMessage}`, error: errorMessage },
|
||||
});
|
||||
}
|
||||
}, [updatePathState, updatePathProgress]);
|
||||
|
||||
const analyzeParallel = useCallback(async (
|
||||
queryA: string,
|
||||
queryB: string,
|
||||
options: AnalyzeOptions = {}
|
||||
) => {
|
||||
// Run both analyses in parallel
|
||||
await Promise.all([
|
||||
analyzePath('A', queryA, options),
|
||||
analyzePath('B', queryB, options),
|
||||
]);
|
||||
}, [analyzePath]);
|
||||
|
||||
const clearPath = useCallback((pathId: PathId) => {
|
||||
updatePathState(pathId, { ...initialPathState });
|
||||
}, [updatePathState]);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setState({
|
||||
pathA: { ...initialPathState },
|
||||
pathB: { ...initialPathState },
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setPathResult = useCallback((pathId: PathId, result: AttributeDAG) => {
|
||||
updatePathState(pathId, {
|
||||
result,
|
||||
loading: false,
|
||||
progress: { step: 'done', message: 'Loaded from history' },
|
||||
});
|
||||
}, [updatePathState]);
|
||||
|
||||
// Computed properties
|
||||
const pathA = state.pathA;
|
||||
const pathB = state.pathB;
|
||||
const bothLoading = pathA.loading && pathB.loading;
|
||||
const anyLoading = pathA.loading || pathB.loading;
|
||||
const bothComplete = pathA.result !== null && pathB.result !== null;
|
||||
const bothIdle = pathA.progress.step === 'idle' && pathB.progress.step === 'idle';
|
||||
|
||||
return {
|
||||
state,
|
||||
pathA,
|
||||
pathB,
|
||||
analyzePath,
|
||||
analyzeParallel,
|
||||
clearPath,
|
||||
clearAll,
|
||||
setPathResult,
|
||||
// Status flags
|
||||
bothLoading,
|
||||
anyLoading,
|
||||
bothComplete,
|
||||
bothIdle,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ExpertProfile,
|
||||
CategoryDefinition,
|
||||
ExpertSource,
|
||||
PromptLanguage,
|
||||
} from '../types';
|
||||
|
||||
interface UseExpertTransformationOptions {
|
||||
@@ -15,6 +16,7 @@ interface UseExpertTransformationOptions {
|
||||
temperature?: number;
|
||||
expertSource?: ExpertSource;
|
||||
expertLanguage?: 'en' | 'zh';
|
||||
lang?: PromptLanguage;
|
||||
}
|
||||
|
||||
export function useExpertTransformation(options: UseExpertTransformationOptions = {}) {
|
||||
@@ -48,11 +50,12 @@ export function useExpertTransformation(options: UseExpertTransformationOptions
|
||||
return new Promise((resolve) => {
|
||||
let categoryExperts: ExpertProfile[] = [];
|
||||
|
||||
const lang = options.lang || 'zh';
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
step: 'expert',
|
||||
currentCategory: category.name,
|
||||
message: `組建專家團隊...`,
|
||||
message: lang === 'zh' ? '組建專家團隊...' : 'Building expert team...',
|
||||
}));
|
||||
|
||||
expertTransformCategoryStream(
|
||||
@@ -67,13 +70,14 @@ export function useExpertTransformation(options: UseExpertTransformationOptions
|
||||
expert_language: options.expertLanguage,
|
||||
model: options.model,
|
||||
temperature: options.temperature,
|
||||
lang,
|
||||
},
|
||||
{
|
||||
onExpertStart: () => {
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
step: 'expert',
|
||||
message: `正在組建專家團隊...`,
|
||||
message: lang === 'zh' ? '正在組建專家團隊...' : 'Building expert team...',
|
||||
}));
|
||||
},
|
||||
onExpertComplete: (expertsData) => {
|
||||
@@ -82,40 +86,40 @@ export function useExpertTransformation(options: UseExpertTransformationOptions
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
experts: expertsData,
|
||||
message: `專家團隊組建完成(${expertsData.length}位專家)`,
|
||||
message: lang === 'zh' ? `專家團隊組建完成(${expertsData.length}位專家)` : `Expert team ready (${expertsData.length} experts)`,
|
||||
}));
|
||||
},
|
||||
onKeywordStart: () => {
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
step: 'keyword',
|
||||
message: `專家團隊為「${category.name}」的屬性生成關鍵字...`,
|
||||
message: lang === 'zh' ? `專家團隊為「${category.name}」的屬性生成關鍵字...` : `Experts generating keywords for "${category.name}"...`,
|
||||
}));
|
||||
},
|
||||
onKeywordProgress: (data) => {
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
currentAttribute: data.attribute,
|
||||
message: `為「${data.attribute}」生成了 ${data.count} 個關鍵字`,
|
||||
message: lang === 'zh' ? `為「${data.attribute}」生成了 ${data.count} 個關鍵字` : `Generated ${data.count} keywords for "${data.attribute}"`,
|
||||
}));
|
||||
},
|
||||
onKeywordComplete: (totalKeywords) => {
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
message: `共生成了 ${totalKeywords} 個專家關鍵字`,
|
||||
message: lang === 'zh' ? `共生成了 ${totalKeywords} 個專家關鍵字` : `Generated ${totalKeywords} expert keywords`,
|
||||
}));
|
||||
},
|
||||
onDescriptionStart: () => {
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
step: 'description',
|
||||
message: `為「${category.name}」的專家關鍵字生成創新描述...`,
|
||||
message: lang === 'zh' ? `為「${category.name}」的專家關鍵字生成創新描述...` : `Generating descriptions for "${category.name}" keywords...`,
|
||||
}));
|
||||
},
|
||||
onDescriptionComplete: (count) => {
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
message: `生成了 ${count} 個創新描述`,
|
||||
message: lang === 'zh' ? `生成了 ${count} 個創新描述` : `Generated ${count} descriptions`,
|
||||
}));
|
||||
},
|
||||
onDone: (data) => {
|
||||
@@ -123,7 +127,7 @@ export function useExpertTransformation(options: UseExpertTransformationOptions
|
||||
...prev,
|
||||
step: 'done',
|
||||
processedCategories: [...prev.processedCategories, category.name],
|
||||
message: `「${category.name}」處理完成`,
|
||||
message: lang === 'zh' ? `「${category.name}」處理完成` : `"${category.name}" complete`,
|
||||
}));
|
||||
resolve({
|
||||
result: data.result,
|
||||
@@ -135,7 +139,7 @@ export function useExpertTransformation(options: UseExpertTransformationOptions
|
||||
...prev,
|
||||
step: 'error',
|
||||
error: err,
|
||||
message: `處理「${category.name}」時發生錯誤`,
|
||||
message: lang === 'zh' ? `處理「${category.name}」時發生錯誤` : `Error processing "${category.name}"`,
|
||||
}));
|
||||
resolve({
|
||||
result: null,
|
||||
@@ -148,7 +152,7 @@ export function useExpertTransformation(options: UseExpertTransformationOptions
|
||||
...prev,
|
||||
step: 'error',
|
||||
error: err.message,
|
||||
message: `處理「${category.name}」時發生錯誤`,
|
||||
message: lang === 'zh' ? `處理「${category.name}」時發生錯誤` : `Error processing "${category.name}"`,
|
||||
}));
|
||||
resolve({
|
||||
result: null,
|
||||
@@ -157,11 +161,12 @@ export function useExpertTransformation(options: UseExpertTransformationOptions
|
||||
});
|
||||
});
|
||||
},
|
||||
[options.model, options.temperature, options.expertSource, options.expertLanguage]
|
||||
[options.model, options.temperature, options.expertSource, options.expertLanguage, options.lang]
|
||||
);
|
||||
|
||||
const transformAll = useCallback(
|
||||
async (input: ExpertTransformationInput) => {
|
||||
const lang = options.lang || 'zh';
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResults(null);
|
||||
@@ -170,7 +175,7 @@ export function useExpertTransformation(options: UseExpertTransformationOptions
|
||||
step: 'idle',
|
||||
currentCategory: '',
|
||||
processedCategories: [],
|
||||
message: '開始處理...',
|
||||
message: lang === 'zh' ? '開始處理...' : 'Starting...',
|
||||
});
|
||||
|
||||
const categoryResults: ExpertTransformationCategoryResult[] = [];
|
||||
@@ -210,12 +215,12 @@ export function useExpertTransformation(options: UseExpertTransformationOptions
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
step: 'done',
|
||||
message: '所有類別處理完成',
|
||||
message: lang === 'zh' ? '所有類別處理完成' : 'All categories complete',
|
||||
}));
|
||||
|
||||
return finalResult;
|
||||
},
|
||||
[transformCategory]
|
||||
[transformCategory, options.lang]
|
||||
);
|
||||
|
||||
const clearResults = useCallback(() => {
|
||||
|
||||
@@ -12,11 +12,15 @@ import type {
|
||||
ExpertTransformationCategoryResult,
|
||||
ExpertProfile,
|
||||
DeduplicationRequest,
|
||||
DeduplicationResult
|
||||
DeduplicationResult,
|
||||
PatentSearchRequest,
|
||||
PatentSearchResponse,
|
||||
BatchPatentSearchRequest,
|
||||
BatchPatentSearchResponse,
|
||||
} from '../types';
|
||||
|
||||
// 自動使用當前瀏覽器的 hostname,支援遠端存取
|
||||
const API_BASE_URL = `http://${window.location.hostname}:8000/api`;
|
||||
const API_BASE_URL = `http://${window.location.hostname}:8001/api`;
|
||||
|
||||
export interface SSECallbacks {
|
||||
onStep0Start?: () => void;
|
||||
@@ -322,3 +326,43 @@ export async function deduplicateDescriptions(
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ===== Patent Search API =====
|
||||
|
||||
export async function searchPatents(
|
||||
request: PatentSearchRequest
|
||||
): Promise<PatentSearchResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/patent/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function batchSearchPatents(
|
||||
request: BatchPatentSearchRequest
|
||||
): Promise<BatchPatentSearchResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/patent/search/batch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// ===== Language type =====
|
||||
export type PromptLanguage = 'zh' | 'en';
|
||||
|
||||
export interface AttributeNode {
|
||||
name: string;
|
||||
category?: string; // 材料, 功能, 用途, 使用族群
|
||||
category?: string; // Materials, Functions, Usages, User Groups
|
||||
children?: AttributeNode[];
|
||||
}
|
||||
|
||||
@@ -9,16 +12,29 @@ export interface AnalyzeRequest {
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
categories?: string[];
|
||||
lang?: PromptLanguage;
|
||||
}
|
||||
|
||||
export const DEFAULT_CATEGORIES = ['材料', '功能', '用途', '使用族群', '特性'];
|
||||
export const DEFAULT_CATEGORIES = {
|
||||
zh: ['材料', '功能', '用途', '使用族群', '特性'],
|
||||
en: ['Materials', 'Functions', 'Usages', 'User Groups', 'Characteristics'],
|
||||
};
|
||||
|
||||
export const CATEGORY_DESCRIPTIONS: Record<string, string> = {
|
||||
'材料': '物件由什麼材料組成',
|
||||
'功能': '物件能做什麼',
|
||||
'用途': '物件在什麼場景使用',
|
||||
'使用族群': '誰會使用這個物件',
|
||||
'特性': '物件有什麼特徵',
|
||||
export const CATEGORY_DESCRIPTIONS: Record<PromptLanguage, Record<string, string>> = {
|
||||
zh: {
|
||||
'材料': '物件由什麼材料組成',
|
||||
'功能': '物件能做什麼',
|
||||
'用途': '物件在什麼場景使用',
|
||||
'使用族群': '誰會使用這個物件',
|
||||
'特性': '物件有什麼特徵',
|
||||
},
|
||||
en: {
|
||||
'Materials': 'What materials the object is made of',
|
||||
'Functions': 'What the object can do',
|
||||
'Usages': 'In what scenarios the object is used',
|
||||
'User Groups': 'Who uses this object',
|
||||
'Characteristics': 'What features the object has',
|
||||
},
|
||||
};
|
||||
|
||||
export interface AnalyzeResponse {
|
||||
@@ -92,6 +108,8 @@ export interface StreamAnalyzeRequest {
|
||||
category_mode?: CategoryMode;
|
||||
custom_categories?: string[];
|
||||
suggested_category_count?: number;
|
||||
// Language setting
|
||||
lang?: PromptLanguage;
|
||||
}
|
||||
|
||||
export interface StreamProgress {
|
||||
@@ -161,6 +179,7 @@ export interface TransformationRequest {
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
keyword_count?: number;
|
||||
lang?: PromptLanguage;
|
||||
}
|
||||
|
||||
export interface TransformationDescription {
|
||||
@@ -238,11 +257,12 @@ export interface ExpertTransformationRequest {
|
||||
attributes: string[];
|
||||
expert_count: number; // 2-8
|
||||
keywords_per_expert: number; // 1-3
|
||||
custom_experts?: string[]; // ["藥師", "工程師"]
|
||||
expert_source?: ExpertSource; // 專家來源 (default: 'llm')
|
||||
expert_language?: string; // 外部來源語言 (default: 'en')
|
||||
custom_experts?: string[]; // User-specified experts
|
||||
expert_source?: ExpertSource; // Expert source (default: 'llm')
|
||||
expert_language?: string; // External source language (default: 'en')
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
lang?: PromptLanguage; // Prompt language
|
||||
}
|
||||
|
||||
export interface ExpertTransformationProgress {
|
||||
@@ -272,9 +292,10 @@ export type DeduplicationMethod = 'embedding' | 'llm';
|
||||
|
||||
export interface DeduplicationRequest {
|
||||
descriptions: ExpertTransformationDescription[];
|
||||
method?: DeduplicationMethod; // 去重方法,default: 'embedding'
|
||||
similarity_threshold?: number; // 0.0-1.0, default 0.85,僅 embedding 使用
|
||||
method?: DeduplicationMethod; // Deduplication method, default: 'embedding'
|
||||
similarity_threshold?: number; // 0.0-1.0, default 0.85, only for embedding
|
||||
model?: string; // Embedding/LLM model
|
||||
lang?: PromptLanguage; // Prompt language (for LLM method)
|
||||
}
|
||||
|
||||
export interface DescriptionGroup {
|
||||
@@ -299,3 +320,127 @@ export interface DeduplicationProgress {
|
||||
message: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ===== Dual-Path types =====
|
||||
|
||||
export type PathId = 'A' | 'B';
|
||||
|
||||
export interface PathState {
|
||||
query: string;
|
||||
result: AttributeDAG | null;
|
||||
loading: boolean;
|
||||
progress: DAGProgress;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface DualPathState {
|
||||
pathA: PathState;
|
||||
pathB: PathState;
|
||||
}
|
||||
|
||||
export interface DAGProgress {
|
||||
step: 'idle' | 'step0' | 'step1' | 'relationships' | 'done' | 'error';
|
||||
message: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ===== Attribute Crossover types =====
|
||||
|
||||
export interface CrossoverPair {
|
||||
id: string;
|
||||
sourcePathId: PathId;
|
||||
sourceNode: DAGNode;
|
||||
targetPathId: PathId;
|
||||
targetNode: DAGNode;
|
||||
crossType: string; // 'material-function' | 'function-usage' | etc.
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface CrossoverConfig {
|
||||
enabledCrossTypes: string[];
|
||||
maxPairsPerType: number;
|
||||
autoGenerate: boolean;
|
||||
}
|
||||
|
||||
export interface CrossoverResult {
|
||||
pairs: CrossoverPair[];
|
||||
totalPairs: number;
|
||||
}
|
||||
|
||||
// ===== Expert Mode types =====
|
||||
|
||||
export type ExpertMode = 'shared' | 'independent';
|
||||
|
||||
export interface ExpertConfig {
|
||||
expert_count: number;
|
||||
keywords_per_expert: number;
|
||||
custom_experts?: string[];
|
||||
expert_source: ExpertSource;
|
||||
expert_language?: 'en' | 'zh';
|
||||
}
|
||||
|
||||
export interface DualPathExpertConfig {
|
||||
mode: ExpertMode;
|
||||
sharedConfig?: ExpertConfig;
|
||||
independentConfig?: {
|
||||
pathA: ExpertConfig;
|
||||
pathB: ExpertConfig;
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Dual-Path Transformation types =====
|
||||
|
||||
export interface DualPathTransformationState {
|
||||
pathA: ExpertTransformationDAGResult | null;
|
||||
pathB: ExpertTransformationDAGResult | null;
|
||||
crossover: CrossoverTransformationResult | null;
|
||||
}
|
||||
|
||||
export interface CrossoverTransformationResult {
|
||||
crossoverPairs: CrossoverPair[];
|
||||
experts: ExpertProfile[];
|
||||
transformedIdeas: ExpertTransformationDescription[];
|
||||
}
|
||||
|
||||
// ===== Patent Search types =====
|
||||
|
||||
export interface PatentResult {
|
||||
publication_number: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
publication_date: string | null;
|
||||
assignee: string | null;
|
||||
inventor: string | null;
|
||||
status: 'ACTIVE' | 'NOT_ACTIVE' | 'UNKNOWN';
|
||||
pdf_url: string | null;
|
||||
thumbnail_url: string | null;
|
||||
}
|
||||
|
||||
export interface PatentSearchRequest {
|
||||
query: string;
|
||||
max_results?: number;
|
||||
}
|
||||
|
||||
export interface PatentSearchResponse {
|
||||
query: string;
|
||||
total_results: number;
|
||||
patents: PatentResult[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchPatentSearchRequest {
|
||||
queries: string[];
|
||||
max_results_per_query?: number;
|
||||
}
|
||||
|
||||
export interface BatchPatentSearchResult {
|
||||
query: string;
|
||||
total_results: number;
|
||||
patents: PatentResult[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchPatentSearchResponse {
|
||||
results: BatchPatentSearchResult[];
|
||||
total_queries: number;
|
||||
}
|
||||
|
||||
197
frontend/src/utils/crossoverToDAG.ts
Normal file
197
frontend/src/utils/crossoverToDAG.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { CrossoverPair, AttributeDAG, CategoryDefinition, DAGNode } from '../types';
|
||||
|
||||
/**
|
||||
* Result of crossover transformation - two separate DAGs
|
||||
* Each path receives attributes from the other path
|
||||
*/
|
||||
export interface CrossoverDAGResult {
|
||||
// Path A receives attributes from Path B
|
||||
pathA: AttributeDAG;
|
||||
// Path B receives attributes from Path A
|
||||
pathB: AttributeDAG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert selected crossover pairs into two separate AttributeDAGs.
|
||||
*
|
||||
* The crossover logic:
|
||||
* - When pair (A's "防水布" × B's "鋁合金") is selected:
|
||||
* - Path A gets "鋁合金" (from B) to transform with A's context
|
||||
* - Path B gets "防水布" (from A) to transform with B's context
|
||||
*
|
||||
* This allows experts to think about:
|
||||
* - "What if umbrella had aluminum alloy?" (A gets B's attribute)
|
||||
* - "What if bicycle had waterproof fabric?" (B gets A's attribute)
|
||||
*/
|
||||
export function crossoverPairsToDAGs(
|
||||
pairs: CrossoverPair[],
|
||||
dagA: AttributeDAG,
|
||||
dagB: AttributeDAG
|
||||
): CrossoverDAGResult {
|
||||
// Collect attributes that each path receives from the other
|
||||
const attributesForA: Map<string, Set<string>> = new Map(); // category -> attributes from B
|
||||
const attributesForB: Map<string, Set<string>> = new Map(); // category -> attributes from A
|
||||
|
||||
for (const pair of pairs) {
|
||||
// Path A receives the target node (from B)
|
||||
// Path B receives the source node (from A)
|
||||
if (pair.sourcePathId === 'A' && pair.targetPathId === 'B') {
|
||||
// A's attribute crossed with B's attribute
|
||||
// A gets B's attribute, B gets A's attribute
|
||||
const categoryForA = pair.targetNode.category;
|
||||
const categoryForB = pair.sourceNode.category;
|
||||
|
||||
if (!attributesForA.has(categoryForA)) attributesForA.set(categoryForA, new Set());
|
||||
if (!attributesForB.has(categoryForB)) attributesForB.set(categoryForB, new Set());
|
||||
|
||||
attributesForA.get(categoryForA)!.add(pair.targetNode.name);
|
||||
attributesForB.get(categoryForB)!.add(pair.sourceNode.name);
|
||||
} else if (pair.sourcePathId === 'B' && pair.targetPathId === 'A') {
|
||||
// B's attribute crossed with A's attribute
|
||||
// A gets B's attribute, B gets A's attribute (reverse direction)
|
||||
const categoryForA = pair.sourceNode.category;
|
||||
const categoryForB = pair.targetNode.category;
|
||||
|
||||
if (!attributesForA.has(categoryForA)) attributesForA.set(categoryForA, new Set());
|
||||
if (!attributesForB.has(categoryForB)) attributesForB.set(categoryForB, new Set());
|
||||
|
||||
attributesForA.get(categoryForA)!.add(pair.sourceNode.name);
|
||||
attributesForB.get(categoryForB)!.add(pair.targetNode.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Build DAG for Path A (receives attributes from B)
|
||||
const pathAResult = buildCrossoverDAG(
|
||||
dagA.query,
|
||||
attributesForA,
|
||||
`Crossover: ${dagB.query} → ${dagA.query}`
|
||||
);
|
||||
|
||||
// Build DAG for Path B (receives attributes from A)
|
||||
const pathBResult = buildCrossoverDAG(
|
||||
dagB.query,
|
||||
attributesForB,
|
||||
`Crossover: ${dagA.query} → ${dagB.query}`
|
||||
);
|
||||
|
||||
return {
|
||||
pathA: pathAResult,
|
||||
pathB: pathBResult,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AttributeDAG from crossover attributes
|
||||
*/
|
||||
function buildCrossoverDAG(
|
||||
originalQuery: string,
|
||||
attributesByCategory: Map<string, Set<string>>,
|
||||
_crossoverDescription: string
|
||||
): AttributeDAG {
|
||||
const categories: CategoryDefinition[] = [];
|
||||
const nodes: DAGNode[] = [];
|
||||
let nodeIndex = 0;
|
||||
let categoryIndex = 0;
|
||||
|
||||
for (const [categoryName, attributes] of attributesByCategory.entries()) {
|
||||
categories.push({
|
||||
name: categoryName,
|
||||
description: `Crossover attributes for ${categoryName}`,
|
||||
is_fixed: false,
|
||||
order: categoryIndex,
|
||||
});
|
||||
categoryIndex++;
|
||||
|
||||
for (const attrName of attributes) {
|
||||
nodes.push({
|
||||
id: `crossover-${nodeIndex}`,
|
||||
name: attrName,
|
||||
category: categoryName,
|
||||
order: nodeIndex,
|
||||
});
|
||||
nodeIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
query: originalQuery,
|
||||
categories,
|
||||
nodes,
|
||||
edges: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy function - converts to single DAG (deprecated)
|
||||
* Kept for backwards compatibility
|
||||
*/
|
||||
export function crossoverPairsToDAG(
|
||||
pairs: CrossoverPair[],
|
||||
queryA: string,
|
||||
queryB: string
|
||||
): AttributeDAG {
|
||||
// Group pairs by crossType
|
||||
const pairsByType = pairs.reduce((acc, pair) => {
|
||||
if (!acc[pair.crossType]) acc[pair.crossType] = [];
|
||||
acc[pair.crossType].push(pair);
|
||||
return acc;
|
||||
}, {} as Record<string, CrossoverPair[]>);
|
||||
|
||||
// Create categories from crossTypes
|
||||
const categories: CategoryDefinition[] = Object.keys(pairsByType).map((crossType, index) => ({
|
||||
name: formatCrossTypeName(crossType),
|
||||
description: getCrossTypeDescription(crossType),
|
||||
is_fixed: false,
|
||||
order: index,
|
||||
}));
|
||||
|
||||
// Create nodes from pairs
|
||||
const nodes: DAGNode[] = [];
|
||||
let nodeIndex = 0;
|
||||
|
||||
for (const [crossType, typePairs] of Object.entries(pairsByType)) {
|
||||
const categoryName = formatCrossTypeName(crossType);
|
||||
|
||||
for (const pair of typePairs) {
|
||||
nodes.push({
|
||||
id: `crossover-${nodeIndex}`,
|
||||
name: `${pair.sourceNode.name} × ${pair.targetNode.name}`,
|
||||
category: categoryName,
|
||||
order: nodeIndex,
|
||||
});
|
||||
nodeIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
query: `${queryA} × ${queryB}`,
|
||||
categories,
|
||||
nodes,
|
||||
edges: [],
|
||||
};
|
||||
}
|
||||
|
||||
function formatCrossTypeName(crossType: string): string {
|
||||
if (crossType.startsWith('same-')) {
|
||||
const category = crossType.replace('same-', '');
|
||||
return `Same: ${category}`;
|
||||
}
|
||||
if (crossType.startsWith('cross-')) {
|
||||
const parts = crossType.replace('cross-', '').split('-');
|
||||
if (parts.length >= 2) {
|
||||
return `Cross: ${parts[0]} × ${parts.slice(1).join('-')}`;
|
||||
}
|
||||
}
|
||||
return crossType;
|
||||
}
|
||||
|
||||
function getCrossTypeDescription(crossType: string): string {
|
||||
if (crossType.startsWith('same-')) {
|
||||
const category = crossType.replace('same-', '');
|
||||
return `Crossover pairs from the same category: ${category}`;
|
||||
}
|
||||
if (crossType.startsWith('cross-')) {
|
||||
return `Crossover pairs from different categories`;
|
||||
}
|
||||
return `Crossover pairs`;
|
||||
}
|
||||
Reference in New Issue
Block a user