Initial commit
This commit is contained in:
386
frontend/src/components/InputPanel.tsx
Normal file
386
frontend/src/components/InputPanel.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
Select,
|
||||
List,
|
||||
Typography,
|
||||
Space,
|
||||
message,
|
||||
Slider,
|
||||
Divider,
|
||||
Collapse,
|
||||
Progress,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
HistoryOutlined,
|
||||
DownloadOutlined,
|
||||
ExpandAltOutlined,
|
||||
ShrinkOutlined,
|
||||
LoadingOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { HistoryItem, AttributeNode, StreamProgress } from '../types';
|
||||
import { getModels } from '../services/api';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text } = Typography;
|
||||
|
||||
interface VisualSettings {
|
||||
nodeSpacing: number;
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
interface InputPanelProps {
|
||||
loading: boolean;
|
||||
progress: StreamProgress;
|
||||
history: HistoryItem[];
|
||||
currentResult: AttributeNode | null;
|
||||
onAnalyze: (query: string, model?: string, temperature?: number, chainCount?: number) => Promise<void>;
|
||||
onLoadHistory: (item: HistoryItem) => void;
|
||||
onExpandAll?: () => void;
|
||||
onCollapseAll?: () => void;
|
||||
visualSettings: VisualSettings;
|
||||
onVisualSettingsChange: (settings: VisualSettings) => void;
|
||||
}
|
||||
|
||||
export function InputPanel({
|
||||
loading,
|
||||
progress,
|
||||
history,
|
||||
currentResult,
|
||||
onAnalyze,
|
||||
onLoadHistory,
|
||||
onExpandAll,
|
||||
onCollapseAll,
|
||||
visualSettings,
|
||||
onVisualSettingsChange,
|
||||
}: InputPanelProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string | undefined>();
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [temperature, setTemperature] = useState(0.7);
|
||||
const [chainCount, setChainCount] = useState(5);
|
||||
|
||||
useEffect(() => {
|
||||
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();
|
||||
}, []);
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!query.trim()) {
|
||||
message.warning('Please enter a query');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onAnalyze(query.trim(), selectedModel, temperature, chainCount);
|
||||
setQuery('');
|
||||
} catch {
|
||||
message.error('Analysis failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleAnalyze();
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportJSON = () => {
|
||||
if (!currentResult) {
|
||||
message.warning('No data to export');
|
||||
return;
|
||||
}
|
||||
const json = JSON.stringify(currentResult, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${currentResult.name || 'mindmap'}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleExportMarkdown = () => {
|
||||
if (!currentResult) {
|
||||
message.warning('No data to export');
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeToMarkdown = (node: AttributeNode, level: number = 0): string => {
|
||||
const indent = ' '.repeat(level);
|
||||
let md = `${indent}- ${node.name}\n`;
|
||||
if (node.children) {
|
||||
node.children.forEach((child) => {
|
||||
md += nodeToMarkdown(child, level + 1);
|
||||
});
|
||||
}
|
||||
return md;
|
||||
};
|
||||
|
||||
const markdown = `# ${currentResult.name}\n\n${currentResult.children?.map((c) => nodeToMarkdown(c, 0)).join('') || ''}`;
|
||||
const blob = new Blob([markdown], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${currentResult.name || 'mindmap'}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleExportSVG = () => {
|
||||
const svg = document.querySelector('.mindmap-svg');
|
||||
if (!svg) {
|
||||
message.warning('No mindmap to export');
|
||||
return;
|
||||
}
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${currentResult?.name || 'mindmap'}.svg`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleExportPNG = () => {
|
||||
const svg = document.querySelector('.mindmap-svg') as SVGSVGElement;
|
||||
if (!svg) {
|
||||
message.warning('No mindmap to export');
|
||||
return;
|
||||
}
|
||||
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
canvas.width = svg.clientWidth * 2;
|
||||
canvas.height = svg.clientHeight * 2;
|
||||
ctx?.scale(2, 2);
|
||||
ctx?.drawImage(img, 0, 0);
|
||||
const pngUrl = canvas.toDataURL('image/png');
|
||||
const a = document.createElement('a');
|
||||
a.href = pngUrl;
|
||||
a.download = `${currentResult?.name || 'mindmap'}.png`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
|
||||
};
|
||||
|
||||
const renderProgressIndicator = () => {
|
||||
if (progress.step === 'idle' || progress.step === 'done') return null;
|
||||
|
||||
const percent = progress.step === 'step1'
|
||||
? 10
|
||||
: progress.step === 'chains'
|
||||
? 10 + (progress.currentChainIndex / progress.totalChains) * 90
|
||||
: 100;
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 16, padding: 12, background: 'rgba(0,0,0,0.04)', borderRadius: 8 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space>
|
||||
{progress.step === 'error' ? (
|
||||
<Tag color="error">Error</Tag>
|
||||
) : (
|
||||
<LoadingOutlined spin />
|
||||
)}
|
||||
<Text>{progress.message}</Text>
|
||||
</Space>
|
||||
<Progress percent={Math.round(percent)} size="small" status={progress.step === 'error' ? 'exception' : 'active'} />
|
||||
|
||||
{progress.completedChains.length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>Completed chains:</Text>
|
||||
<div style={{ maxHeight: 120, overflow: 'auto', marginTop: 4 }}>
|
||||
{progress.completedChains.map((chain, i) => (
|
||||
<div key={i} style={{ fontSize: 11, padding: '2px 0' }}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: 4 }} />
|
||||
{chain.material} → {chain.function} → {chain.usage} → {chain.user}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const collapseItems = [
|
||||
{
|
||||
key: 'llm',
|
||||
label: 'LLM Parameters',
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text>Temperature: {temperature}</Text>
|
||||
<Slider
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={temperature}
|
||||
onChange={setTemperature}
|
||||
marks={{ 0: '0', 0.5: '0.5', 1: '1' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text>Chain Count: {chainCount}</Text>
|
||||
<Slider
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
value={chainCount}
|
||||
onChange={setChainCount}
|
||||
marks={{ 1: '1', 5: '5', 10: '10' }}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'visual',
|
||||
label: 'Visual Settings',
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text>Node Spacing: {visualSettings.nodeSpacing}</Text>
|
||||
<Slider
|
||||
min={20}
|
||||
max={80}
|
||||
value={visualSettings.nodeSpacing}
|
||||
onChange={(v) => onVisualSettingsChange({ ...visualSettings, nodeSpacing: v })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text>Font Size: {visualSettings.fontSize}px</Text>
|
||||
<Slider
|
||||
min={10}
|
||||
max={18}
|
||||
value={visualSettings.fontSize}
|
||||
onChange={(v) => onVisualSettingsChange({ ...visualSettings, fontSize: v })}
|
||||
/>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<ExpandAltOutlined />} onClick={onExpandAll} disabled={!currentResult}>
|
||||
Expand All
|
||||
</Button>
|
||||
<Button icon={<ShrinkOutlined />} onClick={onCollapseAll} disabled={!currentResult}>
|
||||
Collapse All
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'export',
|
||||
label: 'Export',
|
||||
children: (
|
||||
<Space wrap>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExportPNG} disabled={!currentResult}>
|
||||
PNG
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExportSVG} disabled={!currentResult}>
|
||||
SVG
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExportJSON} disabled={!currentResult}>
|
||||
JSON
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExportMarkdown} disabled={!currentResult}>
|
||||
Markdown
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 16 }}>
|
||||
<Space direction="vertical" style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Text strong>Model</Text>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={selectedModel}
|
||||
onChange={setSelectedModel}
|
||||
loading={loadingModels}
|
||||
placeholder="Select a model"
|
||||
options={models.map((m) => ({ label: m, value: m }))}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Text strong>Input</Text>
|
||||
<TextArea
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Enter an object to analyze (e.g., umbrella)"
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleAnalyze}
|
||||
loading={loading}
|
||||
block
|
||||
>
|
||||
Analyze
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{renderProgressIndicator()}
|
||||
|
||||
<Collapse items={collapseItems} defaultActiveKey={['llm']} size="small" style={{ marginBottom: 16 }} />
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<Text strong>
|
||||
<HistoryOutlined /> History
|
||||
</Text>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={history}
|
||||
locale={{ emptyText: 'No history yet' }}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
style={{ cursor: 'pointer', padding: '8px 0' }}
|
||||
onClick={() => onLoadHistory(item)}
|
||||
>
|
||||
<Text ellipsis style={{ maxWidth: '100%' }}>
|
||||
{item.query}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
|
||||
{item.timestamp.toLocaleTimeString()}
|
||||
</Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user