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

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4959
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@types/d3": "^7.4.3",
"antd": "^6.0.0",
"d3": "^7.9.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

107
frontend/src/App.tsx Normal file
View 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;

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

View 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';

View 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';

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

View 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
View 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
View 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>,
)

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

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

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

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

11
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
},
})