Initial commit
This commit is contained in:
107
frontend/src/App.tsx
Normal file
107
frontend/src/App.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { ConfigProvider, Layout, theme, Typography } from 'antd';
|
||||
import { ThemeToggle } from './components/ThemeToggle';
|
||||
import { InputPanel } from './components/InputPanel';
|
||||
import { MindmapPanel } from './components/MindmapPanel';
|
||||
import { useAttribute } from './hooks/useAttribute';
|
||||
import type { MindmapD3Ref } from './components/MindmapD3';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Title } = Typography;
|
||||
|
||||
interface VisualSettings {
|
||||
nodeSpacing: number;
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isDark, setIsDark] = useState(true);
|
||||
const { loading, progress, error, currentResult, history, analyze, loadFromHistory } = useAttribute();
|
||||
const [visualSettings, setVisualSettings] = useState<VisualSettings>({
|
||||
nodeSpacing: 32,
|
||||
fontSize: 14,
|
||||
});
|
||||
const mindmapRef = useRef<MindmapD3Ref>(null);
|
||||
|
||||
const handleAnalyze = async (
|
||||
query: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
chainCount?: number
|
||||
) => {
|
||||
await analyze(query, model, temperature, chainCount);
|
||||
};
|
||||
|
||||
const handleExpandAll = useCallback(() => {
|
||||
mindmapRef.current?.expandAll();
|
||||
}, []);
|
||||
|
||||
const handleCollapseAll = useCallback(() => {
|
||||
mindmapRef.current?.collapseAll();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
}}
|
||||
>
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 24px',
|
||||
}}
|
||||
>
|
||||
<Title level={4} style={{ margin: 0, color: isDark ? '#fff' : '#000' }}>
|
||||
Attribute Agent
|
||||
</Title>
|
||||
<ThemeToggle isDark={isDark} onToggle={setIsDark} />
|
||||
</Header>
|
||||
<Layout>
|
||||
<Content
|
||||
style={{
|
||||
padding: 16,
|
||||
height: 'calc(100vh - 64px)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<MindmapPanel
|
||||
ref={mindmapRef}
|
||||
data={currentResult}
|
||||
loading={loading}
|
||||
error={error}
|
||||
isDark={isDark}
|
||||
visualSettings={visualSettings}
|
||||
/>
|
||||
</Content>
|
||||
<Sider
|
||||
width={350}
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
style={{
|
||||
height: 'calc(100vh - 64px)',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<InputPanel
|
||||
loading={loading}
|
||||
progress={progress}
|
||||
history={history}
|
||||
currentResult={currentResult}
|
||||
onAnalyze={handleAnalyze}
|
||||
onLoadHistory={loadFromHistory}
|
||||
onExpandAll={handleExpandAll}
|
||||
onCollapseAll={handleCollapseAll}
|
||||
visualSettings={visualSettings}
|
||||
onVisualSettingsChange={setVisualSettings}
|
||||
/>
|
||||
</Sider>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
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>
|
||||
);
|
||||
}
|
||||
355
frontend/src/components/MindmapD3.tsx
Normal file
355
frontend/src/components/MindmapD3.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import type { AttributeNode } from '../types';
|
||||
import '../styles/mindmap.css';
|
||||
|
||||
interface VisualSettings {
|
||||
nodeSpacing: number;
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
interface MindmapD3Props {
|
||||
data: AttributeNode;
|
||||
isDark: boolean;
|
||||
visualSettings: VisualSettings;
|
||||
}
|
||||
|
||||
interface TreeNode extends d3.HierarchyPointNode<AttributeNode> {
|
||||
_children?: TreeNode[];
|
||||
x0?: number;
|
||||
y0?: number;
|
||||
}
|
||||
|
||||
export interface MindmapD3Ref {
|
||||
expandAll: () => void;
|
||||
collapseAll: () => void;
|
||||
}
|
||||
|
||||
export const MindmapD3 = forwardRef<MindmapD3Ref, MindmapD3Props>(
|
||||
({ data, isDark, visualSettings }, ref) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rootRef = useRef<TreeNode | null>(null);
|
||||
const updateFnRef = useRef<((source: TreeNode) => void) | null>(null);
|
||||
|
||||
const expandAllNodes = useCallback((node: TreeNode) => {
|
||||
if (node._children) {
|
||||
node.children = node._children;
|
||||
node._children = undefined;
|
||||
}
|
||||
if (node.children) {
|
||||
node.children.forEach(expandAllNodes);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const collapseAllNodes = useCallback((node: TreeNode) => {
|
||||
if (node.children) {
|
||||
node.children.forEach(collapseAllNodes);
|
||||
if (node.depth > 0) {
|
||||
node._children = node.children;
|
||||
node.children = undefined;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
expandAll: () => {
|
||||
if (rootRef.current && updateFnRef.current) {
|
||||
expandAllNodes(rootRef.current);
|
||||
updateFnRef.current(rootRef.current);
|
||||
}
|
||||
},
|
||||
collapseAll: () => {
|
||||
if (rootRef.current && updateFnRef.current) {
|
||||
collapseAllNodes(rootRef.current);
|
||||
updateFnRef.current(rootRef.current);
|
||||
}
|
||||
},
|
||||
}), [expandAllNodes, collapseAllNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !containerRef.current || !data) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
const margin = { top: 40, right: 20, bottom: 40, left: 20 };
|
||||
const { nodeSpacing, fontSize } = visualSettings;
|
||||
|
||||
// Clear previous content
|
||||
d3.select(svgRef.current).selectAll('*').remove();
|
||||
|
||||
const svg = d3
|
||||
.select(svgRef.current)
|
||||
.attr('width', width)
|
||||
.attr('height', height);
|
||||
|
||||
const g = svg
|
||||
.append('g')
|
||||
.attr('transform', `translate(${margin.left}, ${margin.top})`);
|
||||
|
||||
// Add zoom behavior
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.3, 3])
|
||||
.on('zoom', (event) => {
|
||||
g.attr('transform', event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom);
|
||||
svg.call(zoom.transform, d3.zoomIdentity.translate(margin.left, margin.top));
|
||||
|
||||
// Create hierarchy first to calculate dynamic spacing
|
||||
const root = d3.hierarchy(data) as TreeNode;
|
||||
// For horizontal layout: x0 is vertical center, y0 is left edge
|
||||
root.x0 = height / 2;
|
||||
root.y0 = 0;
|
||||
rootRef.current = root;
|
||||
|
||||
// Create horizontal tree layout (left to right)
|
||||
const verticalNodeSpacing = Math.max(nodeSpacing, 35);
|
||||
const horizontalLevelSpacing = 180; // space between levels (left to right)
|
||||
const treeLayout = d3.tree<AttributeNode>()
|
||||
.nodeSize([verticalNodeSpacing, horizontalLevelSpacing])
|
||||
.separation((a, b) => (a.parent === b.parent ? 1 : 1.2));
|
||||
|
||||
// Initialize all nodes as expanded
|
||||
root.descendants().forEach((d: TreeNode) => {
|
||||
d._children = undefined;
|
||||
});
|
||||
|
||||
// Category labels for header
|
||||
const categoryLabels = ['', '材料', '功能', '用途', '使用族群'];
|
||||
const headerHeight = 40;
|
||||
|
||||
function update(source: TreeNode) {
|
||||
const duration = 300;
|
||||
const nodes = treeLayout(root);
|
||||
const descendants = nodes.descendants() as TreeNode[];
|
||||
const links = nodes.links();
|
||||
|
||||
// For horizontal layout: swap x and y
|
||||
// d.x becomes vertical position, d.y becomes horizontal position
|
||||
// Center the tree vertically (leave room for header)
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
descendants.forEach((d) => {
|
||||
if (d.x < minX) minX = d.x;
|
||||
if (d.x > maxX) maxX = d.x;
|
||||
});
|
||||
const treeHeight = maxX - minX;
|
||||
const offsetY = (height - margin.top - margin.bottom - treeHeight - headerHeight) / 2 - minX + headerHeight;
|
||||
const offsetX = 50; // left margin for root node
|
||||
|
||||
// Draw category headers with background
|
||||
g.selectAll('.category-header-group').remove();
|
||||
const maxDepth = Math.max(...descendants.map(d => d.depth));
|
||||
const categoryColors: Record<string, string> = {
|
||||
'材料': isDark ? '#854eca' : '#722ed1',
|
||||
'功能': isDark ? '#13a8a8' : '#13c2c2',
|
||||
'用途': isDark ? '#d87a16' : '#fa8c16',
|
||||
'使用族群': isDark ? '#49aa19' : '#52c41a',
|
||||
};
|
||||
|
||||
for (let depth = 1; depth <= Math.min(maxDepth, categoryLabels.length - 1); depth++) {
|
||||
const label = categoryLabels[depth];
|
||||
if (label) {
|
||||
const headerX = depth * horizontalLevelSpacing + offsetX;
|
||||
const headerY = offsetY + minX - 45; // Position above the first node with more space
|
||||
|
||||
const headerGroup = g.append('g')
|
||||
.attr('class', 'category-header-group');
|
||||
|
||||
// Background rectangle
|
||||
const textWidth = label.length * 16 + 16;
|
||||
headerGroup.append('rect')
|
||||
.attr('x', headerX - textWidth / 2)
|
||||
.attr('y', headerY - 14)
|
||||
.attr('width', textWidth)
|
||||
.attr('height', 24)
|
||||
.attr('rx', 4)
|
||||
.attr('fill', categoryColors[label] || (isDark ? '#444' : '#ddd'))
|
||||
.attr('opacity', 0.9);
|
||||
|
||||
// Text label
|
||||
headerGroup.append('text')
|
||||
.attr('x', headerX)
|
||||
.attr('y', headerY)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.attr('font-size', '13px')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('fill', '#fff')
|
||||
.text(label);
|
||||
}
|
||||
}
|
||||
|
||||
// Update nodes
|
||||
const node = g
|
||||
.selectAll<SVGGElement, TreeNode>('g.node')
|
||||
.data(descendants, (d) => d.data.name + d.depth);
|
||||
|
||||
// Enter new nodes - swap x,y for horizontal layout
|
||||
const nodeEnter = node
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'node')
|
||||
.attr('transform', () => `translate(${(source.y0 || 0) + offsetX},${(source.x0 || 0) + offsetY})`)
|
||||
.on('click', (_event, d: TreeNode) => {
|
||||
if (d.children) {
|
||||
d._children = d.children;
|
||||
d.children = undefined;
|
||||
} else if (d._children) {
|
||||
d.children = d._children;
|
||||
d._children = undefined;
|
||||
}
|
||||
update(d);
|
||||
});
|
||||
|
||||
// Add rounded rectangle for nodes
|
||||
nodeEnter
|
||||
.append('rect')
|
||||
.attr('width', (d) => Math.max(d.data.name.length * fontSize, 60))
|
||||
.attr('height', 28)
|
||||
.attr('x', (d) => -Math.max(d.data.name.length * fontSize, 60) / 2)
|
||||
.attr('y', -14)
|
||||
.attr('rx', 6)
|
||||
.attr('ry', 6)
|
||||
.attr('class', (d) => {
|
||||
const hasChildren = d.children || d._children;
|
||||
const isCollapsed = d._children && !d.children;
|
||||
const category = d.data.category || (d.depth === 0 ? 'root' : '');
|
||||
const categoryClass = category ? `category-${category}` : '';
|
||||
return `node-rect depth-${d.depth} ${categoryClass} ${hasChildren ? 'has-children' : ''} ${isCollapsed ? 'collapsed' : ''}`;
|
||||
})
|
||||
.style('opacity', 0);
|
||||
|
||||
// Add text labels inside rect
|
||||
nodeEnter
|
||||
.append('text')
|
||||
.attr('dy', '0.35em')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('class', (d) => {
|
||||
const category = d.data.category || (d.depth === 0 ? 'root' : '');
|
||||
const categoryClass = category ? `category-${category}` : '';
|
||||
return `node-text depth-${d.depth} ${categoryClass}`;
|
||||
})
|
||||
.style('font-size', `${fontSize}px`)
|
||||
.text((d) => d.data.name)
|
||||
.style('opacity', 0);
|
||||
|
||||
// Merge enter and update
|
||||
const nodeUpdate = nodeEnter.merge(node);
|
||||
|
||||
// Horizontal layout: y is horizontal (depth), x is vertical (spread)
|
||||
nodeUpdate
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('transform', (d) => `translate(${d.y + offsetX},${d.x + offsetY})`);
|
||||
|
||||
nodeUpdate
|
||||
.select('rect')
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('width', (d) => Math.max(d.data.name.length * fontSize, 60))
|
||||
.attr('x', (d) => -Math.max(d.data.name.length * fontSize, 60) / 2)
|
||||
.style('opacity', 1)
|
||||
.attr('class', (d) => {
|
||||
const hasChildren = d.children || d._children;
|
||||
const isCollapsed = d._children && !d.children;
|
||||
const category = d.data.category || (d.depth === 0 ? 'root' : '');
|
||||
const categoryClass = category ? `category-${category}` : '';
|
||||
return `node-rect depth-${d.depth} ${categoryClass} ${hasChildren ? 'has-children' : ''} ${isCollapsed ? 'collapsed' : ''}`;
|
||||
});
|
||||
|
||||
nodeUpdate
|
||||
.select('text')
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.style('font-size', `${fontSize}px`)
|
||||
.style('opacity', 1);
|
||||
|
||||
// Exit nodes
|
||||
const nodeExit = node
|
||||
.exit<TreeNode>()
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('transform', () => `translate(${source.y + offsetX},${source.x + offsetY})`)
|
||||
.remove();
|
||||
|
||||
nodeExit.select('rect').style('opacity', 0);
|
||||
nodeExit.select('text').style('opacity', 0);
|
||||
|
||||
// Update links - horizontal curves (left to right)
|
||||
const link = g
|
||||
.selectAll<SVGPathElement, d3.HierarchyPointLink<AttributeNode>>('path.link')
|
||||
.data(links, (d) => d.target.data.name + (d.target as TreeNode).depth);
|
||||
|
||||
const linkEnter = link
|
||||
.enter()
|
||||
.insert('path', 'g')
|
||||
.attr('class', 'link')
|
||||
.attr('d', () => {
|
||||
const o = { x: (source.y0 || 0) + offsetX, y: (source.x0 || 0) + offsetY };
|
||||
return `M${o.x},${o.y} L${o.x},${o.y}`;
|
||||
});
|
||||
|
||||
linkEnter
|
||||
.merge(link)
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('d', (d) => {
|
||||
// Horizontal layout: source.y is horizontal, source.x is vertical
|
||||
const sx = d.source.y + offsetX;
|
||||
const sy = d.source.x + offsetY;
|
||||
const tx = d.target.y + offsetX;
|
||||
const ty = d.target.x + offsetY;
|
||||
const midX = (sx + tx) / 2;
|
||||
return `M${sx},${sy} C${midX},${sy} ${midX},${ty} ${tx},${ty}`;
|
||||
});
|
||||
|
||||
link
|
||||
.exit()
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('d', () => {
|
||||
const o = { x: source.y + offsetX, y: source.x + offsetY };
|
||||
return `M${o.x},${o.y} L${o.x},${o.y}`;
|
||||
})
|
||||
.remove();
|
||||
|
||||
// Store positions for next transition (keep original x,y for d3 tree)
|
||||
descendants.forEach((d) => {
|
||||
d.x0 = d.x;
|
||||
d.y0 = d.y;
|
||||
});
|
||||
}
|
||||
|
||||
updateFnRef.current = update;
|
||||
update(root);
|
||||
|
||||
// Handle resize
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const newWidth = container.clientWidth;
|
||||
const newHeight = container.clientHeight;
|
||||
svg.attr('width', newWidth).attr('height', newHeight);
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [data, visualSettings, isDark]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`mindmap-container ${isDark ? 'mindmap-dark' : 'mindmap-light'}`}
|
||||
>
|
||||
<svg ref={svgRef} className="mindmap-svg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MindmapD3.displayName = 'MindmapD3';
|
||||
71
frontend/src/components/MindmapPanel.tsx
Normal file
71
frontend/src/components/MindmapPanel.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Empty, Spin } from 'antd';
|
||||
import type { AttributeNode } from '../types';
|
||||
import { MindmapD3 } from './MindmapD3';
|
||||
import type { MindmapD3Ref } from './MindmapD3';
|
||||
|
||||
interface VisualSettings {
|
||||
nodeSpacing: number;
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
interface MindmapPanelProps {
|
||||
data: AttributeNode | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
isDark: boolean;
|
||||
visualSettings: VisualSettings;
|
||||
}
|
||||
|
||||
export const MindmapPanel = forwardRef<MindmapD3Ref, MindmapPanelProps>(
|
||||
({ data, loading, error, isDark, visualSettings }, ref) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Spin size="large" tip="Analyzing..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Empty description={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Empty description="Enter a query to analyze attributes" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <MindmapD3 ref={ref} data={data} isDark={isDark} visualSettings={visualSettings} />;
|
||||
}
|
||||
);
|
||||
|
||||
MindmapPanel.displayName = 'MindmapPanel';
|
||||
18
frontend/src/components/ThemeToggle.tsx
Normal file
18
frontend/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Switch } from 'antd';
|
||||
import { MoonOutlined, SunOutlined } from '@ant-design/icons';
|
||||
|
||||
interface ThemeToggleProps {
|
||||
isDark: boolean;
|
||||
onToggle: (isDark: boolean) => void;
|
||||
}
|
||||
|
||||
export function ThemeToggle({ isDark, onToggle }: ThemeToggleProps) {
|
||||
return (
|
||||
<Switch
|
||||
checked={isDark}
|
||||
onChange={onToggle}
|
||||
checkedChildren={<MoonOutlined />}
|
||||
unCheckedChildren={<SunOutlined />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
163
frontend/src/hooks/useAttribute.ts
Normal file
163
frontend/src/hooks/useAttribute.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type {
|
||||
AttributeNode,
|
||||
HistoryItem,
|
||||
StreamProgress,
|
||||
StreamAnalyzeResponse,
|
||||
CausalChain
|
||||
} from '../types';
|
||||
import { analyzeAttributesStream } from '../services/api';
|
||||
|
||||
export function useAttribute() {
|
||||
const [progress, setProgress] = useState<StreamProgress>({
|
||||
step: 'idle',
|
||||
currentChainIndex: 0,
|
||||
totalChains: 0,
|
||||
completedChains: [],
|
||||
message: '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentResult, setCurrentResult] = useState<AttributeNode | null>(null);
|
||||
const [history, setHistory] = useState<HistoryItem[]>([]);
|
||||
|
||||
const analyze = useCallback(async (
|
||||
query: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
chainCount: number = 5
|
||||
) => {
|
||||
// 重置狀態
|
||||
setProgress({
|
||||
step: 'idle',
|
||||
currentChainIndex: 0,
|
||||
totalChains: chainCount,
|
||||
completedChains: [],
|
||||
message: '準備開始分析...',
|
||||
});
|
||||
setError(null);
|
||||
setCurrentResult(null);
|
||||
|
||||
try {
|
||||
await analyzeAttributesStream(
|
||||
{ query, chain_count: chainCount, model, temperature },
|
||||
{
|
||||
onStep1Start: () => {
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
step: 'step1',
|
||||
message: '正在分析物件屬性列表...',
|
||||
}));
|
||||
},
|
||||
|
||||
onStep1Complete: (step1Result) => {
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
step1Result,
|
||||
message: '屬性列表分析完成,開始生成因果鏈...',
|
||||
}));
|
||||
},
|
||||
|
||||
onChainStart: (index, total) => {
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
step: 'chains',
|
||||
currentChainIndex: index,
|
||||
totalChains: total,
|
||||
message: `正在生成第 ${index}/${total} 條因果鏈...`,
|
||||
}));
|
||||
},
|
||||
|
||||
onChainComplete: (index, chain) => {
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
completedChains: [...prev.completedChains, chain],
|
||||
message: `第 ${index} 條因果鏈生成完成`,
|
||||
}));
|
||||
},
|
||||
|
||||
onChainError: (index, errorMsg) => {
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
message: `第 ${index} 條因果鏈生成失敗: ${errorMsg}`,
|
||||
}));
|
||||
},
|
||||
|
||||
onDone: (response: StreamAnalyzeResponse) => {
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
step: 'done',
|
||||
message: '分析完成!',
|
||||
}));
|
||||
setCurrentResult(response.attributes);
|
||||
|
||||
setHistory((prev) => [
|
||||
{
|
||||
query,
|
||||
result: response.attributes,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
},
|
||||
|
||||
onError: (errorMsg) => {
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
step: 'error',
|
||||
error: errorMsg,
|
||||
message: `錯誤: ${errorMsg}`,
|
||||
}));
|
||||
setError(errorMsg);
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
step: 'error',
|
||||
error: errorMessage,
|
||||
message: `錯誤: ${errorMessage}`,
|
||||
}));
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadFromHistory = useCallback((item: HistoryItem) => {
|
||||
setCurrentResult(item.result);
|
||||
setError(null);
|
||||
setProgress({
|
||||
step: 'done',
|
||||
currentChainIndex: 0,
|
||||
totalChains: 0,
|
||||
completedChains: [],
|
||||
message: '從歷史記錄載入',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearResult = useCallback(() => {
|
||||
setCurrentResult(null);
|
||||
setError(null);
|
||||
setProgress({
|
||||
step: 'idle',
|
||||
currentChainIndex: 0,
|
||||
totalChains: 0,
|
||||
completedChains: [],
|
||||
message: '',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isLoading = progress.step === 'step1' || progress.step === 'chains';
|
||||
|
||||
return {
|
||||
loading: isLoading,
|
||||
progress,
|
||||
error,
|
||||
currentResult,
|
||||
history,
|
||||
analyze,
|
||||
loadFromHistory,
|
||||
clearResult,
|
||||
};
|
||||
}
|
||||
16
frontend/src/index.css
Normal file
16
frontend/src/index.css
Normal file
@@ -0,0 +1,16 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
106
frontend/src/services/api.ts
Normal file
106
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
ModelListResponse,
|
||||
StreamAnalyzeRequest,
|
||||
StreamAnalyzeResponse,
|
||||
Step1Result,
|
||||
CausalChain
|
||||
} from '../types';
|
||||
|
||||
// 自動使用當前瀏覽器的 hostname,支援遠端存取
|
||||
const API_BASE_URL = `http://${window.location.hostname}:8000/api`;
|
||||
|
||||
export interface SSECallbacks {
|
||||
onStep1Start?: () => void;
|
||||
onStep1Complete?: (result: Step1Result) => void;
|
||||
onChainStart?: (index: number, total: number) => void;
|
||||
onChainComplete?: (index: number, chain: CausalChain) => void;
|
||||
onChainError?: (index: number, error: string) => void;
|
||||
onDone?: (response: StreamAnalyzeResponse) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export async function analyzeAttributesStream(
|
||||
request: StreamAnalyzeRequest,
|
||||
callbacks: SSECallbacks
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/analyze`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// 解析 SSE 事件
|
||||
const lines = buffer.split('\n\n');
|
||||
buffer = lines.pop() || ''; // 保留未完成的部分
|
||||
|
||||
for (const chunk of lines) {
|
||||
if (!chunk.trim()) continue;
|
||||
|
||||
const eventMatch = chunk.match(/event: (\w+)/);
|
||||
const dataMatch = chunk.match(/data: (.+)/s);
|
||||
|
||||
if (eventMatch && dataMatch) {
|
||||
const eventType = eventMatch[1];
|
||||
try {
|
||||
const eventData = JSON.parse(dataMatch[1]);
|
||||
|
||||
switch (eventType) {
|
||||
case 'step1_start':
|
||||
callbacks.onStep1Start?.();
|
||||
break;
|
||||
case 'step1_complete':
|
||||
callbacks.onStep1Complete?.(eventData.result);
|
||||
break;
|
||||
case 'chain_start':
|
||||
callbacks.onChainStart?.(eventData.index, eventData.total);
|
||||
break;
|
||||
case 'chain_complete':
|
||||
callbacks.onChainComplete?.(eventData.index, eventData.chain);
|
||||
break;
|
||||
case 'chain_error':
|
||||
callbacks.onChainError?.(eventData.index, eventData.error);
|
||||
break;
|
||||
case 'done':
|
||||
callbacks.onDone?.(eventData);
|
||||
break;
|
||||
case 'error':
|
||||
callbacks.onError?.(eventData.error);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE event:', e, chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getModels(): Promise<ModelListResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/models`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
226
frontend/src/styles/mindmap.css
Normal file
226
frontend/src/styles/mindmap.css
Normal file
@@ -0,0 +1,226 @@
|
||||
.mindmap-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mindmap-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.node {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node rect {
|
||||
stroke-width: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.node rect:hover {
|
||||
stroke-width: 3px;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.node text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.link {
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
transition: stroke 0.3s ease;
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
.mindmap-light .node-rect {
|
||||
stroke: #333;
|
||||
}
|
||||
|
||||
.mindmap-light .node-rect.collapsed {
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
/* Light theme - Category colors */
|
||||
.mindmap-light .node-rect.category-root {
|
||||
fill: #1890ff;
|
||||
stroke: #096dd9;
|
||||
}
|
||||
.mindmap-light .node-text.category-root {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-light .node-rect.category-材料 {
|
||||
fill: #722ed1;
|
||||
stroke: #531dab;
|
||||
}
|
||||
.mindmap-light .node-text.category-材料 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-light .node-rect.category-功能 {
|
||||
fill: #13c2c2;
|
||||
stroke: #08979c;
|
||||
}
|
||||
.mindmap-light .node-text.category-功能 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-light .node-rect.category-用途 {
|
||||
fill: #fa8c16;
|
||||
stroke: #d46b08;
|
||||
}
|
||||
.mindmap-light .node-text.category-用途 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-light .node-rect.category-使用族群 {
|
||||
fill: #52c41a;
|
||||
stroke: #389e0d;
|
||||
}
|
||||
.mindmap-light .node-text.category-使用族群 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
/* Light theme - Fallback depth colors */
|
||||
.mindmap-light .node-rect.depth-0 {
|
||||
fill: #1890ff;
|
||||
stroke: #096dd9;
|
||||
}
|
||||
.mindmap-light .node-text.depth-0 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-light .node-rect.depth-1 {
|
||||
fill: #722ed1;
|
||||
stroke: #531dab;
|
||||
}
|
||||
.mindmap-light .node-text.depth-1 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-light .node-rect.depth-2 {
|
||||
fill: #13c2c2;
|
||||
stroke: #08979c;
|
||||
}
|
||||
.mindmap-light .node-text.depth-2 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-light .node-rect.depth-3 {
|
||||
fill: #fa8c16;
|
||||
stroke: #d46b08;
|
||||
}
|
||||
.mindmap-light .node-text.depth-3 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-light .node-rect.depth-4 {
|
||||
fill: #52c41a;
|
||||
stroke: #389e0d;
|
||||
}
|
||||
.mindmap-light .node-text.depth-4 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-light .link {
|
||||
stroke: #bfbfbf;
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
.mindmap-dark .node-rect {
|
||||
stroke: #444;
|
||||
}
|
||||
|
||||
.mindmap-dark .node-rect.collapsed {
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
/* Dark theme - Category colors */
|
||||
.mindmap-dark .node-rect.category-root {
|
||||
fill: #177ddc;
|
||||
stroke: #1890ff;
|
||||
}
|
||||
.mindmap-dark .node-text.category-root {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-dark .node-rect.category-材料 {
|
||||
fill: #854eca;
|
||||
stroke: #9254de;
|
||||
}
|
||||
.mindmap-dark .node-text.category-材料 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-dark .node-rect.category-功能 {
|
||||
fill: #13a8a8;
|
||||
stroke: #36cfc9;
|
||||
}
|
||||
.mindmap-dark .node-text.category-功能 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-dark .node-rect.category-用途 {
|
||||
fill: #d87a16;
|
||||
stroke: #ffa940;
|
||||
}
|
||||
.mindmap-dark .node-text.category-用途 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-dark .node-rect.category-使用族群 {
|
||||
fill: #49aa19;
|
||||
stroke: #73d13d;
|
||||
}
|
||||
.mindmap-dark .node-text.category-使用族群 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
/* Dark theme - Fallback depth colors */
|
||||
.mindmap-dark .node-rect.depth-0 {
|
||||
fill: #177ddc;
|
||||
stroke: #1890ff;
|
||||
}
|
||||
.mindmap-dark .node-text.depth-0 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-dark .node-rect.depth-1 {
|
||||
fill: #854eca;
|
||||
stroke: #9254de;
|
||||
}
|
||||
.mindmap-dark .node-text.depth-1 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-dark .node-rect.depth-2 {
|
||||
fill: #13a8a8;
|
||||
stroke: #36cfc9;
|
||||
}
|
||||
.mindmap-dark .node-text.depth-2 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-dark .node-rect.depth-3 {
|
||||
fill: #d87a16;
|
||||
stroke: #ffa940;
|
||||
}
|
||||
.mindmap-dark .node-text.depth-3 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-dark .node-rect.depth-4 {
|
||||
fill: #49aa19;
|
||||
stroke: #73d13d;
|
||||
}
|
||||
.mindmap-dark .node-text.depth-4 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.mindmap-dark .link {
|
||||
stroke: #434343;
|
||||
}
|
||||
77
frontend/src/types/index.ts
Normal file
77
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export interface AttributeNode {
|
||||
name: string;
|
||||
category?: string; // 材料, 功能, 用途, 使用族群
|
||||
children?: AttributeNode[];
|
||||
}
|
||||
|
||||
export interface AnalyzeRequest {
|
||||
query: string;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_CATEGORIES = ['材料', '功能', '用途', '使用族群', '特性'];
|
||||
|
||||
export const CATEGORY_DESCRIPTIONS: Record<string, string> = {
|
||||
'材料': '物件由什麼材料組成',
|
||||
'功能': '物件能做什麼',
|
||||
'用途': '物件在什麼場景使用',
|
||||
'使用族群': '誰會使用這個物件',
|
||||
'特性': '物件有什麼特徵',
|
||||
};
|
||||
|
||||
export interface AnalyzeResponse {
|
||||
query: string;
|
||||
attributes: AttributeNode;
|
||||
}
|
||||
|
||||
export interface ModelListResponse {
|
||||
models: string[];
|
||||
}
|
||||
|
||||
export interface HistoryItem {
|
||||
query: string;
|
||||
result: AttributeNode;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// ===== Multi-step streaming types =====
|
||||
|
||||
export interface Step1Result {
|
||||
materials: string[];
|
||||
functions: string[];
|
||||
usages: string[];
|
||||
users: string[];
|
||||
}
|
||||
|
||||
export interface CausalChain {
|
||||
material: string;
|
||||
function: string;
|
||||
usage: string;
|
||||
user: string;
|
||||
}
|
||||
|
||||
export interface StreamAnalyzeRequest {
|
||||
query: string;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
chain_count: number;
|
||||
}
|
||||
|
||||
export interface StreamProgress {
|
||||
step: 'idle' | 'step1' | 'chains' | 'done' | 'error';
|
||||
step1Result?: Step1Result;
|
||||
currentChainIndex: number;
|
||||
totalChains: number;
|
||||
completedChains: CausalChain[];
|
||||
message: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StreamAnalyzeResponse {
|
||||
query: string;
|
||||
step1_result: Step1Result;
|
||||
causal_chains: CausalChain[];
|
||||
attributes: AttributeNode;
|
||||
}
|
||||
Reference in New Issue
Block a user