387 lines
11 KiB
TypeScript
387 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|