chore: save local changes

This commit is contained in:
2026-01-05 22:32:08 +08:00
parent bc281b8e0a
commit ec48709755
42 changed files with 5576 additions and 254 deletions

View File

@@ -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}

View 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>
);
}

View File

@@ -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);
}
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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 }}>

View 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>
);
}

View File

@@ -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;

View File

@@ -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(() => {

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
});

View File

@@ -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,

View File

@@ -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',
});
}, []);

View 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,
};
}

View File

@@ -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 {

View 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,
};
}

View File

@@ -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(() => {

View File

@@ -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();
}

View File

@@ -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;
}

View 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`;
}