Initial commit

This commit is contained in:
2025-12-02 02:06:51 +08:00
commit eb6c0c51fa
37 changed files with 7454 additions and 0 deletions

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