diff --git a/.gitignore b/.gitignore index 6786de1..fd65982 100644 --- a/.gitignore +++ b/.gitignore @@ -108,8 +108,8 @@ develop-eggs/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ @@ -225,4 +225,4 @@ Thumbs.db # Project specific .claude/ -.ruff_cache/ \ No newline at end of file +.ruff_cache/ diff --git a/frontend/app/mindmap/[id]/page.tsx b/frontend/app/mindmap/[id]/page.tsx index e2dee35..aad504c 100644 --- a/frontend/app/mindmap/[id]/page.tsx +++ b/frontend/app/mindmap/[id]/page.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { useParams } from "next/navigation"; import { @@ -8,10 +8,10 @@ import { useState, type MouseEvent as ReactMouseEvent, } from "react"; -import MindmapCanvas from "@/components/MindmapCanvas"; -import ChatPanel from "@/components/ChatPanel"; -import { getMindmap } from "@/lib/api"; -import type { Mindmap } from "@/types/mindmap"; +import MindmapCanvas from "../../../components/MindmapCanvas"; +import ChatPanel from "../../../components/ChatPanel"; +import { getMindmap } from "../../../lib/api"; +import type { Mindmap } from "../../../types/mindmap"; type TriggerRequest = { id: number; diff --git a/frontend/components/ChatPanel.tsx b/frontend/components/ChatPanel.tsx index 587da18..b285067 100644 --- a/frontend/components/ChatPanel.tsx +++ b/frontend/components/ChatPanel.tsx @@ -1,15 +1,15 @@ -"use client"; +"use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import MarkdownMessage from "@/components/MarkdownMessage"; -import { sendChatMessage } from "@/lib/api"; +import MarkdownMessage from "./MarkdownMessage"; +import { sendChatMessage } from "../lib/api"; import { parseJsonSafely, splitSseEvents, type TencentErrorEvent, type TencentReplyEvent, -} from "@/lib/tencentSse"; -import type { ChatMessage } from "@/types/mindmap"; +} from "../lib/tencentSse"; +import type { ChatMessage } from "../types/mindmap"; type TriggerRequest = { id: number; diff --git a/frontend/components/MindmapCanvas.tsx b/frontend/components/MindmapCanvas.tsx index 16bbfb1..396a351 100644 --- a/frontend/components/MindmapCanvas.tsx +++ b/frontend/components/MindmapCanvas.tsx @@ -1,11 +1,11 @@ -"use client"; +"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ReactFlow, { Background, Controls, MiniMap, type ReactFlowInstance } from "reactflow"; import "reactflow/dist/style.css"; -import MindmapNodeCard from "@/components/MindmapNodeCard"; -import { treeToGraph } from "@/lib/treeToGraph"; -import type { MindmapNode } from "@/types/mindmap"; +import MindmapNodeCard from "./MindmapNodeCard"; +import { treeToGraph } from "../lib/treeToGraph"; +import type { MindmapNode } from "../types/mindmap"; type MindmapCanvasProps = { tree: MindmapNode; diff --git a/frontend/components/MindmapNodeCard.tsx b/frontend/components/MindmapNodeCard.tsx index cd2ef0b..43474d5 100644 --- a/frontend/components/MindmapNodeCard.tsx +++ b/frontend/components/MindmapNodeCard.tsx @@ -1,8 +1,8 @@ -"use client"; +"use client"; import { memo, useCallback, type MouseEvent } from "react"; import { Handle, Position, type NodeProps } from "reactflow"; -import type { GraphNodeData } from "@/types/mindmap"; +import type { GraphNodeData } from "../types/mindmap"; function MindmapNodeCard({ data }: NodeProps) { const isRoot = data.level === 0; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts new file mode 100644 index 0000000..523bff5 --- /dev/null +++ b/frontend/lib/api.ts @@ -0,0 +1,60 @@ +import type { CreateMindmapPayload, Mindmap } from "../types/mindmap"; + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000"; + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + cache: "no-store", + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || "请求失败"); + } + + return (await response.json()) as T; +} + +export async function createMindmap( + payload: CreateMindmapPayload, +): Promise { + return request("/api/mindmaps", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function getMindmap(uniqueId: string): Promise { + return request(`/api/mindmaps/${uniqueId}`); +} + +export async function sendChatMessage( + sessionId: string, + content: string, + visitorBizId: string = "default_visitor", + signal?: AbortSignal, +): Promise { + const response = await fetch(`${API_BASE_URL}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal, + body: JSON.stringify({ + session_id: sessionId, + content, + visitor_biz_id: visitorBizId, + }), + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || "请求失败"); + } + + return response; +} diff --git a/frontend/lib/tencentSse.ts b/frontend/lib/tencentSse.ts new file mode 100644 index 0000000..b399e79 --- /dev/null +++ b/frontend/lib/tencentSse.ts @@ -0,0 +1,77 @@ +export type SseEvent = { + event: string; + data: string; +}; + +export type TencentReplyEvent = { + type?: "reply"; + payload?: { + content?: string; + is_evil?: boolean; + is_final?: boolean; + is_from_self?: boolean; + is_llm_generated?: boolean; + }; +}; + +export type TencentErrorEvent = { + type?: "error"; + error?: { + code?: number; + message?: string; + }; + message?: string; + status?: number; +}; + +export function parseJsonSafely(value: string): T | null { + try { + return JSON.parse(value) as T; + } catch { + return null; + } +} + +export function splitSseEvents(buffer: string): { + events: SseEvent[]; + rest: string; +} { + const blocks = buffer.split(/\r?\n\r?\n/); + const rest = blocks.pop() ?? ""; + const events = blocks + .map(parseSseEventBlock) + .filter((event): event is SseEvent => event !== null); + + return { events, rest }; +} + +function parseSseEventBlock(block: string): SseEvent | null { + const lines = block.split(/\r?\n/); + let eventName = "message"; + const dataLines: string[] = []; + + for (const rawLine of lines) { + const line = rawLine.trimEnd(); + if (!line || line.startsWith(":")) { + continue; + } + + if (line.startsWith("event:")) { + eventName = line.slice(6).trim(); + continue; + } + + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trimStart()); + } + } + + if (dataLines.length === 0) { + return null; + } + + return { + event: eventName, + data: dataLines.join("\n"), + }; +} diff --git a/frontend/lib/treeToGraph.ts b/frontend/lib/treeToGraph.ts new file mode 100644 index 0000000..70e802d --- /dev/null +++ b/frontend/lib/treeToGraph.ts @@ -0,0 +1,129 @@ +import type { Edge, Node } from "reactflow"; +import { Position } from "reactflow"; +import type { GraphData, GraphNodeData, MindmapNode } from "../types/mindmap"; + +const LEVEL_GAP = 320; +const SIBLING_GAP = 120; +const PADDING_X = 80; +const PADDING_Y = 100; + +type PositionMap = Map; + +type TreeToGraphOptions = { + expandedNodeIds?: Set; + onToggleNode?: (nodeId: string) => void; + onOpenNodeChat?: (nodeLabel: string) => void; +}; + +function getVisibleChildren( + node: MindmapNode, + expandedNodeIds: Set, +): MindmapNode[] { + if (node.children.length === 0) { + return []; + } + + return expandedNodeIds.has(node.id) ? node.children : []; +} + +function assignPositions( + node: MindmapNode, + positions: PositionMap, + leafIndexRef: { value: number }, + expandedNodeIds: Set, +): number { + const visibleChildren = getVisibleChildren(node, expandedNodeIds); + + if (visibleChildren.length === 0) { + const y = leafIndexRef.value * SIBLING_GAP; + leafIndexRef.value += 1; + positions.set(node.id, { x: node.level * LEVEL_GAP, y }); + return y; + } + + const childYs = visibleChildren.map((child) => + assignPositions(child, positions, leafIndexRef, expandedNodeIds), + ); + const y = + childYs.reduce((total, current) => total + current, 0) / childYs.length; + + positions.set(node.id, { x: node.level * LEVEL_GAP, y }); + return y; +} + +function buildGraph( + node: MindmapNode, + positions: PositionMap, + nodes: Node[], + edges: Edge[], + expandedNodeIds: Set, + options: TreeToGraphOptions, +): void { + const position = positions.get(node.id); + + if (!position) { + return; + } + + const hasChildren = node.children.length > 0; + const isExpanded = expandedNodeIds.has(node.id); + const onToggleNode = options.onToggleNode; + const onOpenNodeChat = options.onOpenNodeChat; + + nodes.push({ + id: node.id, + type: "mindmapNode", + position: { + x: position.x + PADDING_X, + y: position.y + PADDING_Y, + }, + style: { + cursor: "default", + }, + data: { + label: node.label, + level: node.level, + isLeaf: node.is_leaf, + hasChildren, + isExpanded, + onToggle: hasChildren && onToggleNode ? () => onToggleNode(node.id) : undefined, + onOpenChat: onOpenNodeChat ? () => onOpenNodeChat(node.label) : undefined, + }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + draggable: false, + selectable: false, + }); + + getVisibleChildren(node, expandedNodeIds).forEach((child) => { + edges.push({ + id: `${node.id}-${child.id}`, + source: node.id, + target: child.id, + type: "smoothstep", + animated: false, + style: { + stroke: "#60a5fa", + strokeWidth: 2, + }, + }); + + buildGraph(child, positions, nodes, edges, expandedNodeIds, options); + }); +} + +export function treeToGraph( + tree: MindmapNode, + options: TreeToGraphOptions = {}, +): GraphData { + const positions: PositionMap = new Map(); + const leafIndexRef = { value: 0 }; + const nodes: Node[] = []; + const edges: Edge[] = []; + const expandedNodeIds = options.expandedNodeIds ?? new Set([tree.id]); + + assignPositions(tree, positions, leafIndexRef, expandedNodeIds); + buildGraph(tree, positions, nodes, edges, expandedNodeIds, options); + + return { nodes, edges }; +}