feat: Migrate to React Flow and add Fixed + Dynamic category mode

Frontend:
- Migrate MindmapDAG from D3.js to React Flow (@xyflow/react)
- Add custom node components (QueryNode, CategoryHeaderNode, AttributeNode)
- Add useDAGLayout hook for column-based layout
- Add "AI" badge for LLM-suggested categories
- Update CategorySelector with Fixed + Dynamic mode option
- Improve dark/light theme support

Backend:
- Add FIXED_PLUS_DYNAMIC category mode
- Filter duplicate category names in LLM suggestions
- Update prompts to exclude fixed categories when suggesting new ones
- Improve LLM service with better error handling and logging
- Auto-remove /no_think prefix for non-Qwen models
- Add smart JSON format detection for model compatibility
- Improve JSON extraction with multiple parsing strategies

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-03 01:22:57 +08:00
parent 91f7f41bc1
commit 1ed1dab78f
21 changed files with 1254 additions and 614 deletions

View File

@@ -0,0 +1,155 @@
import { useMemo } from 'react';
import type { Node } from '@xyflow/react';
import type { AttributeDAG, DAGNode } from '../../types';
interface LayoutConfig {
nodeHeight: number;
headerGap: number;
headerHeight: number;
nodeSpacing: number;
fontSize: number;
isDark: boolean;
}
const COLOR_PALETTE = [
{ dark: '#177ddc', light: '#1890ff' }, // blue
{ dark: '#854eca', light: '#722ed1' }, // purple
{ dark: '#13a8a8', light: '#13c2c2' }, // cyan
{ dark: '#d87a16', light: '#fa8c16' }, // orange
{ dark: '#49aa19', light: '#52c41a' }, // green
{ dark: '#1677ff', light: '#1890ff' }, // blue
{ dark: '#eb2f96', light: '#f759ab' }, // magenta
{ dark: '#faad14', light: '#ffc53d' }, // gold
{ dark: '#a0d911', light: '#bae637' }, // lime
];
function darken(hex: string, amount: number): string {
const num = parseInt(hex.slice(1), 16);
const r = Math.max(0, ((num >> 16) & 0xff) - Math.floor(255 * amount));
const g = Math.max(0, ((num >> 8) & 0xff) - Math.floor(255 * amount));
const b = Math.max(0, (num & 0xff) - Math.floor(255 * amount));
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}
export function useDAGLayout(
data: AttributeDAG | null,
config: LayoutConfig
): Node[] {
return useMemo(() => {
if (!data) return [];
const {
nodeHeight,
headerGap,
headerHeight,
nodeSpacing,
fontSize,
isDark,
} = config;
const nodes: Node[] = [];
const sortedCategories = [...data.categories].sort((a, b) => a.order - b.order);
// Unified column spacing
const columnGap = 60; // Consistent gap between ALL adjacent elements
// Build category color map
const categoryColors: Record<string, { fill: string; stroke: string }> = {};
sortedCategories.forEach((cat, index) => {
const paletteIndex = index % COLOR_PALETTE.length;
const color = isDark
? COLOR_PALETTE[paletteIndex].dark
: COLOR_PALETTE[paletteIndex].light;
categoryColors[cat.name] = {
fill: color,
stroke: darken(color, 0.15),
};
});
// Group nodes by category
const nodesByCategory: Record<string, DAGNode[]> = {};
for (const node of data.nodes) {
if (!nodesByCategory[node.category]) {
nodesByCategory[node.category] = [];
}
nodesByCategory[node.category].push(node);
}
// Sort nodes within each category by order, then re-index locally
for (const cat of Object.keys(nodesByCategory)) {
nodesByCategory[cat].sort((a, b) => a.order - b.order);
}
// Calculate max column height for centering
const maxNodesInColumn = Math.max(
...sortedCategories.map((cat) => (nodesByCategory[cat.name] || []).length),
1
);
const maxTotalHeight = maxNodesInColumn * (nodeHeight + nodeSpacing) - nodeSpacing;
const contentStartY = headerHeight + headerGap;
// Layout constants - UNIFORM spacing for all columns
const colStep = 160; // Distance between column left edges (uniform for all)
// Helper function for column X position
const getColumnX = (colIndex: number) => colIndex * colStep;
// 1. Add Query Node (column 0, centered vertically)
const queryY = contentStartY + (maxTotalHeight - nodeHeight) / 2;
nodes.push({
id: 'query-node',
type: 'query',
position: { x: getColumnX(0), y: queryY },
data: {
label: data.query,
isDark,
fontSize,
},
draggable: false,
selectable: false,
});
// 2. Add Category Headers (starting from column 1)
sortedCategories.forEach((cat, colIndex) => {
const columnX = getColumnX(colIndex + 1); // +1 because column 0 is query
nodes.push({
id: `header-${cat.name}`,
type: 'categoryHeader',
position: { x: columnX, y: 0 },
data: {
label: cat.name,
color: categoryColors[cat.name]?.fill || '#666',
isFixed: cat.is_fixed,
isDark,
},
draggable: false,
selectable: false,
});
});
// 3. Add Attribute Nodes
sortedCategories.forEach((cat, colIndex) => {
const categoryNodes = nodesByCategory[cat.name] || [];
const columnX = getColumnX(colIndex + 1); // +1 because column 0 is query
categoryNodes.forEach((node, rowIndex) => {
const y = contentStartY + rowIndex * (nodeHeight + nodeSpacing);
nodes.push({
id: node.id,
type: 'attribute',
position: { x: columnX, y },
data: {
label: node.name,
fillColor: categoryColors[node.category]?.fill || '#666',
strokeColor: categoryColors[node.category]?.stroke || '#444',
fontSize,
},
draggable: false,
selectable: false,
});
});
});
return nodes;
}, [data, config]);
}