This commit is contained in:
zhangyonghao
2026-03-20 23:09:51 +08:00
commit 8400fb6127
41 changed files with 9348 additions and 0 deletions

228
.gitignore vendored Normal file
View File

@@ -0,0 +1,228 @@
# Dependencies
node_modules/
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.venv/
pip-log.txt
pip-delete-this-directory.txt
# Build outputs
dist/
build/
*.egg-info/
*.egg
.next/
out/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
*.env
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
# Gatsby files
.cache/
public
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# Database files
*.db
*.sqlite
*.sqlite3
# Python specific
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.whl
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Scrapy stuff
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
Pipfile.lock
# PEP 582
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Project specific
.claude/
.ruff_cache/

121
README.md Normal file
View File

@@ -0,0 +1,121 @@
# Interactive Mindmap
基于 Next.js、FastAPI 和 SQLite 的交互式思维导图应用,支持通过腾讯云 AI Agent 对话生成思维导图。
## 项目结构
```
mindmap/
├── backend/ # FastAPI 后端
│ ├── app/
│ │ ├── main.py # 应用入口
│ │ ├── config.py # 配置
│ │ ├── database.py # 数据库
│ │ ├── models.py # 数据模型
│ │ ├── schemas.py # Pydantic schemas
│ │ └── routers/
│ │ ├── mindmaps.py # 思维导图 CRUD API
│ │ └── chat.py # AI 对话代理 (SSE)
│ ├── data/ # SQLite 数据库文件
│ ├── requirements.txt
│ └── .env.example
├── frontend/ # Next.js 前端
│ ├── app/
│ │ ├── page.tsx # 首页
│ │ └── mindmap/
│ │ ├── [id]/page.tsx # 思维导图详情页
│ │ └── chat/page.tsx # AI 对话 + 思维导图页
│ ├── components/
│ │ ├── MindmapCanvas.tsx # 思维导图画布
│ │ ├── MindmapNodeCard.tsx # 节点卡片
│ │ ├── ChatPanel.tsx # 聊天面板
│ │ └── CreateMindmapForm.tsx # 创建表单
│ ├── lib/
│ │ ├── api.ts # API 调用
│ │ └── treeToGraph.ts # 树转图布局
│ └── types/
│ └── mindmap.ts # TypeScript 类型
└── mind_prompt.md # AI Agent 系统提示词
```
## 环境配置
### 后端
1. 安装依赖:
```bash
cd backend
pip install -r requirements.txt
```
2. 配置环境变量:
```bash
# 复制示例配置
cp .env.example .env
# 编辑 .env填入腾讯云 AI Agent 的 bot_app_key
TENCENT_BOT_APP_KEY=your-key-here
```
3. 启动后端:
```bash
TENCENT_BOT_APP_KEY=your-key-here uvicorn app.main:app --reload
```
### 前端
1. 安装依赖:
```bash
cd frontend
npm install
```
2. 启动开发服务器:
```bash
npm run dev
```
## 页面说明
### 首页 `/`
输入主题创建思维导图(使用 mock 数据)。
### 思维导图详情 `/mindmap/[id]`
查看已保存的思维导图,支持节点展开/收缩。
### AI 对话生成 `/mindmap/chat?sessionId=xxx`
通过与腾讯云 AI Agent 对话生成思维导图:
- **左侧**: 思维导图画布初始为空AI 返回有效数据后自动渲染
- **右侧**: 聊天面板,支持实时 SSE 流式响应
URL 参数:
- `sessionId` (必填): 会话 ID用于标识对话会话
## 数据流
```
用户输入 → ChatPanel → POST /api/chat → 后端代理 → 腾讯云 SSE API
画布更新 ← onMindmapUpdate ← JSON 解析 ← SSE 流式响应 ←─┘
```
1. 用户在聊天面板输入消息
2. 前端发送 POST 请求到后端 `/api/chat`
3. 后端将请求代理到腾讯云 AI Agent SSE 接口
4. SSE 流式响应逐步返回到前端
5. 前端解析 SSE 事件,逐步显示 AI 回复
6. 当 AI 回复完成后,尝试从回复中提取思维导图 JSON
7. 如果提取成功,更新左侧画布
## AI Agent 配置
参见 [mind_prompt.md](./mind_prompt.md) 获取 AI Agent 的系统提示词配置。

6
backend/.env.example Normal file
View File

@@ -0,0 +1,6 @@
# 腾讯云智能体平台 AppKey
# 获取方式:应用管理 → 找到运行中的应用 → 点击"调用" → 复制AppKey
BOT_APP_KEY=your-app-key-here
# 前端基础URL用于生成思维导图链接
FRONTEND_BASE_URL=http://localhost:3000

4
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
__pycache__
*.pyc
data/*.db
.venv

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
__all__ = []

22
backend/app/config.py Normal file
View File

@@ -0,0 +1,22 @@
import os
from pathlib import Path
from dotenv import load_dotenv
backend_dir = Path(__file__).resolve().parent.parent
load_dotenv(backend_dir / ".env")
class Settings:
app_name = "Interactive Mindmap API"
api_prefix = "/api"
backend_dir = backend_dir
data_dir = backend_dir / "data"
database_path = data_dir / "mindmap.db"
database_url = f"sqlite:///{database_path.as_posix()}"
allowed_origins = ["*"]
bot_app_key = os.environ.get("BOT_APP_KEY", "")
frontend_base_url = os.environ.get("FRONTEND_BASE_URL", "http://localhost:3000")
settings = Settings()

23
backend/app/database.py Normal file
View File

@@ -0,0 +1,23 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from app.config import settings
settings.data_dir.mkdir(parents=True, exist_ok=True)
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db() -> Generator:
db = SessionLocal()
try:
yield db
finally:
db.close()

44
backend/app/main.py Normal file
View File

@@ -0,0 +1,44 @@
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.database import Base, engine, SessionLocal
from app.models import Mindmap
from app.routers import chat, mindmaps
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
Base.metadata.create_all(bind=engine)
# 删除过期记录
db = SessionLocal()
try:
deleted_count = Mindmap.delete_expired_records(db)
if deleted_count > 0:
logger.info(f"Deleted {deleted_count} expired mindmap records")
finally:
db.close()
app = FastAPI(title=settings.app_name)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allowed_origins,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(mindmaps.router, prefix=settings.api_prefix)
app.include_router(chat.router, prefix=settings.api_prefix)
@app.get("/")
def health_check() -> dict[str, str]:
return {"message": "Interactive Mindmap API is running"}

32
backend/app/models.py Normal file
View File

@@ -0,0 +1,32 @@
from datetime import datetime, timedelta
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, Session
from app.database import Base
class Mindmap(Base):
__tablename__ = "mindmaps"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
unique_id: Mapped[str] = mapped_column(
String(32), unique=True, index=True, nullable=False
)
session_id: Mapped[str] = mapped_column(String(255), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
raw_json: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
@classmethod
def delete_expired_records(cls, db: Session, days: int = 5) -> int:
"""删除超过指定天数的记录"""
cutoff_date = datetime.utcnow() - timedelta(days=days)
deleted = db.query(cls).filter(cls.created_at < cutoff_date).delete()
db.commit()
return deleted

View File

@@ -0,0 +1 @@
__all__ = []

125
backend/app/routers/chat.py Normal file
View File

@@ -0,0 +1,125 @@
import json
import logging
import uuid
from typing import AsyncGenerator
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/chat", tags=["chat"])
TENCENT_SSE_URL = "https://wss.lke.cloud.tencent.com/v1/qbot/chat/sse"
class ChatRequest(BaseModel):
session_id: str
content: str
visitor_biz_id: str = "default_visitor"
async def forward_events(
response: httpx.Response, request_id: str
) -> AsyncGenerator[bytes, None]:
"""Read the upstream SSE stream and forward it as-is."""
async for line in response.aiter_lines():
stripped = line.strip()
if not stripped:
yield b"\n"
continue
logger.info("[%s] Forwarding: %s", request_id, stripped[:200])
yield (stripped + "\n").encode("utf-8")
def build_error_event(message: str, request_id: str, code: int = 500) -> bytes:
event = {
"type": "error",
"request_id": request_id,
"error": {
"code": code,
"message": message,
},
}
return (
f"event: error\n"
f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
).encode("utf-8")
@router.post("")
async def chat(payload: ChatRequest):
request_id = str(uuid.uuid4())[:8]
logger.info(
"[%s] Chat request received: session_id=%s, content=%s",
request_id,
payload.session_id,
payload.content[:50],
)
if not settings.bot_app_key:
logger.error("[%s] BOT_APP_KEY is not configured", request_id)
raise HTTPException(
status_code=500,
detail="BOT_APP_KEY is not configured",
)
request_body = {
"request_id": request_id,
"content": payload.content,
"bot_app_key": settings.bot_app_key,
"visitor_biz_id": payload.visitor_biz_id,
"session_id": payload.session_id,
"stream": "enable",
}
logger.info("[%s] Sending to Tencent: %s", request_id, TENCENT_SSE_URL)
async def stream_generator() -> AsyncGenerator[bytes, None]:
async with httpx.AsyncClient(timeout=120.0) as client:
try:
async with client.stream(
"POST",
TENCENT_SSE_URL,
json=request_body,
headers={"Accept": "text/event-stream"},
) as response:
logger.info(
"[%s] Tencent response status: %s",
request_id,
response.status_code,
)
if response.status_code != 200:
body = await response.aread()
error_msg = body.decode("utf-8", errors="replace")
logger.error("[%s] Tencent error: %s", request_id, error_msg)
yield build_error_event(
error_msg,
request_id,
response.status_code,
)
return
logger.info("[%s] Starting to forward events", request_id)
async for chunk in forward_events(response, request_id):
yield chunk
logger.info("[%s] Stream completed", request_id)
except httpx.RequestError as exc:
logger.exception("[%s] Request to Tencent failed: %s", request_id, exc)
yield build_error_event(str(exc), request_id)
return StreamingResponse(
stream_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)

View File

@@ -0,0 +1,82 @@
import json
import logging
import secrets
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models import Mindmap
from app.schemas import MindmapCreateRequest, MindmapNode, MindmapResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/mindmaps", tags=["mindmaps"])
def generate_unique_id() -> str:
return secrets.token_urlsafe(16)
def extract_title_from_json(mindmap_json: dict) -> str:
label = mindmap_json.get("label", "")
return label.strip() if label else "未命名思维导图"
def to_response(mindmap: Mindmap) -> MindmapResponse:
tree_data = json.loads(mindmap.raw_json)
tree = MindmapNode.model_validate(tree_data)
url = f"{settings.frontend_base_url}/mindmap/{mindmap.unique_id}"
return MindmapResponse(
id=mindmap.id,
unique_id=mindmap.unique_id,
session_id=mindmap.session_id,
title=mindmap.title,
raw_json=mindmap.raw_json,
tree=tree,
url=url,
created_at=mindmap.created_at,
updated_at=mindmap.updated_at,
)
@router.post("", response_model=MindmapResponse, status_code=status.HTTP_201_CREATED)
def create_mindmap(
payload: MindmapCreateRequest,
db: Session = Depends(get_db),
) -> MindmapResponse:
# 先删除过期记录
deleted_count = Mindmap.delete_expired_records(db)
if deleted_count > 0:
logger.info(f"Deleted {deleted_count} expired mindmap records")
title = extract_title_from_json(payload.mindmap_json)
raw_json = json.dumps(payload.mindmap_json, ensure_ascii=False)
unique_id = generate_unique_id()
mindmap = Mindmap(
unique_id=unique_id,
session_id=payload.session_id,
title=title,
raw_json=raw_json,
)
db.add(mindmap)
db.commit()
db.refresh(mindmap)
return to_response(mindmap)
@router.get("/{unique_id}", response_model=MindmapResponse)
def get_mindmap(
unique_id: str,
db: Session = Depends(get_db),
) -> MindmapResponse:
mindmap = db.query(Mindmap).filter(Mindmap.unique_id == unique_id).first()
if not mindmap:
raise HTTPException(status_code=404, detail="脑图不存在")
return to_response(mindmap)

34
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,34 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class MindmapNode(BaseModel):
id: str
label: str
parent_id: str | None = None
level: int
is_leaf: bool
children: list["MindmapNode"] = Field(default_factory=list)
MindmapNode.model_rebuild()
class MindmapCreateRequest(BaseModel):
session_id: str = Field(min_length=1, max_length=255)
mindmap_json: dict
class MindmapResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
unique_id: str
session_id: str
title: str
raw_json: str
tree: MindmapNode
url: str
created_at: datetime
updated_at: datetime

1
backend/data/.gitkeep Normal file
View File

@@ -0,0 +1 @@

6
backend/package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "backend",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

6
backend/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
pydantic==2.10.4
httpx>=0.27.0
python-dotenv>=1.0.0

3
frontend/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}

3
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.next
node_modules
dist

30
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,30 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
height: 100%;
}
body {
min-height: 100%;
margin: 0;
color: #111827;
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.12), transparent 30%),
radial-gradient(circle at top right, rgba(15, 118, 110, 0.12), transparent 24%),
linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%);
}
* {
box-sizing: border-box;
}
.react-flow__attribution {
display: none;
}
.react-flow__node:focus,
.react-flow__node:focus-visible {
outline: none;
}

19
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "交互式思维导图",
description: "基于 Next.js、FastAPI 和 SQLite 的思维导图 MVP",
};
type RootLayoutProps = {
children: React.ReactNode;
};
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}

View File

@@ -0,0 +1,220 @@
"use client";
import { useParams } from "next/navigation";
import {
useCallback,
useEffect,
useRef,
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";
type TriggerRequest = {
id: number;
content: string;
};
const MIN_CHAT_PANEL_WIDTH = 320;
const DEFAULT_CHAT_PANEL_WIDTH = 420;
const MAX_CHAT_PANEL_WIDTH_RATIO = 0.55;
export default function MindmapDetailPage() {
const params = useParams<{ id: string }>();
const uniqueId = params?.id ?? "";
const containerRef = useRef<HTMLElement | null>(null);
const [mindmap, setMindmap] = useState<Mindmap | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showChat, setShowChat] = useState(false);
const [isResizingChatPanel, setIsResizingChatPanel] = useState(false);
const [chatPanelWidth, setChatPanelWidth] = useState(DEFAULT_CHAT_PANEL_WIDTH);
const [triggerRequest, setTriggerRequest] = useState<TriggerRequest | undefined>();
const clampChatPanelWidth = useCallback((nextWidth: number) => {
const containerWidth =
containerRef.current?.clientWidth ??
(typeof window === "undefined" ? DEFAULT_CHAT_PANEL_WIDTH : window.innerWidth);
const maxWidth = Math.max(
MIN_CHAT_PANEL_WIDTH,
Math.floor(containerWidth * MAX_CHAT_PANEL_WIDTH_RATIO),
);
return Math.min(Math.max(nextWidth, MIN_CHAT_PANEL_WIDTH), maxWidth);
}, []);
useEffect(() => {
async function loadPageData() {
if (!uniqueId) {
setLoading(false);
setError("缺少脑图 ID");
return;
}
try {
setLoading(true);
setError("");
const mindmapData = await getMindmap(uniqueId);
setMindmap(mindmapData);
} catch (pageError) {
const message =
pageError instanceof Error ? pageError.message : "加载脑图失败";
setError(message);
} finally {
setLoading(false);
}
}
void loadPageData();
}, [uniqueId]);
const handleNodeChat = useCallback(
(nodeLabel: string) => {
setShowChat(true);
setChatPanelWidth((currentWidth) => clampChatPanelWidth(currentWidth));
setTriggerRequest({
id: Date.now() + Math.floor(Math.random() * 1000),
content: `帮我解释一下 ${nodeLabel}`,
});
},
[clampChatPanelWidth],
);
const handleCloseChat = useCallback(() => {
setIsResizingChatPanel(false);
setShowChat(false);
setTriggerRequest(undefined);
}, []);
const handleResizeStart = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
event.preventDefault();
setChatPanelWidth((currentWidth) => clampChatPanelWidth(currentWidth));
setIsResizingChatPanel(true);
},
[clampChatPanelWidth],
);
useEffect(() => {
if (!showChat) {
setIsResizingChatPanel(false);
return;
}
const handleWindowResize = () => {
setChatPanelWidth((currentWidth) => clampChatPanelWidth(currentWidth));
};
handleWindowResize();
window.addEventListener("resize", handleWindowResize);
return () => window.removeEventListener("resize", handleWindowResize);
}, [clampChatPanelWidth, showChat]);
useEffect(() => {
if (!showChat || !isResizingChatPanel) {
return;
}
const handleMouseMove = (event: MouseEvent) => {
if (!containerRef.current) {
return;
}
const bounds = containerRef.current.getBoundingClientRect();
const nextWidth = bounds.right - event.clientX;
setChatPanelWidth(clampChatPanelWidth(nextWidth));
};
const stopResizing = () => {
setIsResizingChatPanel(false);
};
const { style } = document.body;
const previousCursor = style.cursor;
const previousUserSelect = style.userSelect;
style.cursor = "col-resize";
style.userSelect = "none";
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", stopResizing);
window.addEventListener("blur", stopResizing);
return () => {
style.cursor = previousCursor;
style.userSelect = previousUserSelect;
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", stopResizing);
window.removeEventListener("blur", stopResizing);
};
}, [clampChatPanelWidth, isResizingChatPanel, showChat]);
if (loading) {
return (
<main className="flex h-screen w-screen items-center justify-center bg-white">
<div className="rounded-3xl bg-white px-8 py-6 shadow-panel">
...
</div>
</main>
);
}
if (error || !mindmap) {
return (
<main className="flex h-screen w-screen flex-col items-center justify-center bg-white px-6 text-center">
<div className="rounded-3xl bg-white px-8 py-8 shadow-panel">
<p className="text-lg font-semibold text-rose-600">
{error || "未找到对应脑图"}
</p>
</div>
</main>
);
}
return (
<main ref={containerRef} className="flex h-screen w-screen overflow-hidden bg-white">
<section className="min-w-0 flex-1">
<MindmapCanvas tree={mindmap.tree} onNodeChat={handleNodeChat} />
</section>
{showChat && (
<>
<div
role="separator"
aria-label="调整聊天区域宽度"
aria-orientation="vertical"
onMouseDown={handleResizeStart}
className="group relative w-3 shrink-0 cursor-col-resize bg-transparent"
>
<div
className={`absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors ${
isResizingChatPanel
? "bg-blue-500"
: "bg-slate-200 group-hover:bg-blue-300"
}`}
/>
<div
className={`absolute left-1/2 top-1/2 h-16 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors ${
isResizingChatPanel
? "bg-blue-500"
: "bg-slate-300 group-hover:bg-blue-400"
}`}
/>
</div>
<aside
style={{ width: `${chatPanelWidth}px` }}
className="min-w-0 shrink-0 overflow-hidden bg-slate-50 shadow-[-18px_0_36px_rgba(15,23,42,0.06)]"
>
<ChatPanel
sessionId={mindmap.session_id}
triggerRequest={triggerRequest}
onClose={handleCloseChat}
/>
</aside>
</>
)}
</main>
);
}

20
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,20 @@
export default function HomePage() {
return (
<main className="mx-auto flex min-h-screen max-w-6xl flex-col justify-center px-6 py-16">
<div className="text-center">
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-blue-700">
Interactive Mindmap
</p>
<h1 className="mt-4 text-4xl font-extrabold leading-tight text-slate-950 md:text-6xl">
</h1>
<p className="mt-6 mx-auto max-w-2xl text-lg leading-8 text-slate-600">
AI
</p>
<p className="mt-4 text-sm text-slate-500">
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1,406 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import MarkdownMessage from "@/components/MarkdownMessage";
import { sendChatMessage } from "@/lib/api";
import {
parseJsonSafely,
splitSseEvents,
type TencentErrorEvent,
type TencentReplyEvent,
} from "@/lib/tencentSse";
import type { ChatMessage } from "@/types/mindmap";
type TriggerRequest = {
id: number;
content: string;
};
type ChatPanelProps = {
sessionId: string;
triggerRequest?: TriggerRequest;
onClose?: () => void;
};
const TYPING_INTERVAL_MS = 24;
export default function ChatPanel({
sessionId,
triggerRequest,
onClose,
}: ChatPanelProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const typingTimerRef = useRef<number | null>(null);
const pendingCleanupTimerRef = useRef<number | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const assistantTargetRef = useRef("");
const assistantDisplayedRef = useRef("");
const streamCompleteRef = useRef(false);
const handledTriggerIdRef = useRef<number | null>(null);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({
behavior: loading ? "auto" : "smooth",
block: "end",
});
}, [loading]);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
const cancelPendingCleanup = useCallback(() => {
if (pendingCleanupTimerRef.current !== null) {
window.clearTimeout(pendingCleanupTimerRef.current);
pendingCleanupTimerRef.current = null;
}
}, []);
const abortInFlightRequest = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}, []);
const updateAssistantMessage = useCallback((content: string) => {
setMessages((prev) => {
const next = [...prev];
const lastMessage = next[next.length - 1];
if (!lastMessage || lastMessage.role !== "assistant") {
next.push({ role: "assistant", content });
return next;
}
next[next.length - 1] = {
...lastMessage,
content,
};
return next;
});
}, []);
const stopTypingAnimation = useCallback(() => {
if (typingTimerRef.current !== null) {
window.clearInterval(typingTimerRef.current);
typingTimerRef.current = null;
}
}, []);
const getTypingStep = useCallback((remaining: number) => {
if (remaining > 160) return 10;
if (remaining > 80) return 7;
if (remaining > 36) return 4;
if (remaining > 12) return 2;
return 1;
}, []);
const ensureTypingAnimation = useCallback(() => {
if (typingTimerRef.current !== null) {
return;
}
typingTimerRef.current = window.setInterval(() => {
const current = assistantDisplayedRef.current;
const target = assistantTargetRef.current;
if (current === target) {
if (streamCompleteRef.current) {
stopTypingAnimation();
}
return;
}
const remaining = target.length - current.length;
const nextLength = current.length + getTypingStep(remaining);
const nextContent = target.slice(0, nextLength);
assistantDisplayedRef.current = nextContent;
updateAssistantMessage(nextContent);
if (nextContent === target && streamCompleteRef.current) {
stopTypingAnimation();
}
}, TYPING_INTERVAL_MS);
}, [getTypingStep, stopTypingAnimation, updateAssistantMessage]);
useEffect(() => {
cancelPendingCleanup();
return () => {
pendingCleanupTimerRef.current = window.setTimeout(() => {
abortInFlightRequest();
stopTypingAnimation();
pendingCleanupTimerRef.current = null;
}, 0);
};
}, [abortInFlightRequest, cancelPendingCleanup, stopTypingAnimation]);
const resetStreamingState = useCallback(() => {
cancelPendingCleanup();
abortInFlightRequest();
stopTypingAnimation();
assistantTargetRef.current = "";
assistantDisplayedRef.current = "";
streamCompleteRef.current = false;
}, [abortInFlightRequest, cancelPendingCleanup, stopTypingAnimation]);
const mergeIncomingContent = useCallback((currentTarget: string, incomingContent: string) => {
if (!currentTarget) {
return incomingContent;
}
if (!incomingContent) {
return currentTarget;
}
if (incomingContent.startsWith(currentTarget)) {
return incomingContent;
}
if (currentTarget.startsWith(incomingContent)) {
return currentTarget;
}
const maxOverlap = Math.min(currentTarget.length, incomingContent.length);
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
if (currentTarget.slice(-overlap) === incomingContent.slice(0, overlap)) {
return `${currentTarget}${incomingContent.slice(overlap)}`;
}
}
return incomingContent.length > currentTarget.length ? incomingContent : currentTarget;
}, []);
const setAssistantTarget = useCallback(
(incomingContent: string) => {
assistantTargetRef.current = mergeIncomingContent(
assistantTargetRef.current,
incomingContent,
);
ensureTypingAnimation();
},
[ensureTypingAnimation, mergeIncomingContent],
);
const processStreamResponse = useCallback(
async (response: Response) => {
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body");
}
const decoder = new TextDecoder("utf-8");
let buffer = "";
const handleReplyEvent = (event: TencentReplyEvent | null) => {
const payload = event?.payload;
if (!payload || payload.is_from_self || typeof payload.content !== "string") {
return;
}
setAssistantTarget(payload.content);
if (payload.is_final) {
streamCompleteRef.current = true;
ensureTypingAnimation();
}
};
const handleErrorEvent = (event: TencentErrorEvent | null) => {
const errorMessage =
event?.error?.message ?? event?.message ?? "Unknown streaming error";
assistantTargetRef.current = `错误: ${errorMessage}`;
assistantDisplayedRef.current = assistantTargetRef.current;
streamCompleteRef.current = true;
updateAssistantMessage(assistantTargetRef.current);
stopTypingAnimation();
};
const handleSseEvent = (eventName: string, data: string) => {
const normalizedEventName =
parseJsonSafely<{ type?: string }>(data)?.type ?? eventName;
if (normalizedEventName === "reply") {
handleReplyEvent(parseJsonSafely<TencentReplyEvent>(data));
return;
}
if (normalizedEventName === "error") {
handleErrorEvent(parseJsonSafely<TencentErrorEvent>(data));
}
};
while (true) {
const { done, value } = await reader.read();
if (done) {
buffer += decoder.decode();
} else {
buffer += decoder.decode(value, { stream: true });
}
const { events, rest } = splitSseEvents(buffer);
buffer = rest;
for (const event of events) {
handleSseEvent(event.event, event.data);
}
if (done) {
if (buffer.trim()) {
const { events: tailEvents } = splitSseEvents(`${buffer}\n\n`);
for (const event of tailEvents) {
handleSseEvent(event.event, event.data);
}
}
streamCompleteRef.current = true;
ensureTypingAnimation();
break;
}
}
},
[ensureTypingAnimation, setAssistantTarget, stopTypingAnimation, updateAssistantMessage],
);
const sendMessage = useCallback(
async (content: string) => {
if (!content.trim() || loading) return;
resetStreamingState();
const abortController = new AbortController();
abortControllerRef.current = abortController;
const userMessage: ChatMessage = { role: "user", content };
const assistantMessage: ChatMessage = { role: "assistant", content: "" };
setMessages((prev) => [...prev, userMessage, assistantMessage]);
setInput("");
setLoading(true);
try {
const response = await sendChatMessage(
sessionId,
content,
"default_visitor",
abortController.signal,
);
await processStreamResponse(response);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
return;
}
const errorMessage = err instanceof Error ? err.message : "请求失败";
assistantTargetRef.current = `错误: ${errorMessage}`;
assistantDisplayedRef.current = assistantTargetRef.current;
streamCompleteRef.current = true;
updateAssistantMessage(assistantTargetRef.current);
stopTypingAnimation();
} finally {
if (abortControllerRef.current === abortController) {
abortControllerRef.current = null;
}
setLoading(false);
}
},
[loading, processStreamResponse, resetStreamingState, sessionId, stopTypingAnimation, updateAssistantMessage],
);
useEffect(() => {
if (!triggerRequest) {
return;
}
if (handledTriggerIdRef.current === triggerRequest.id) {
return;
}
handledTriggerIdRef.current = triggerRequest.id;
void sendMessage(triggerRequest.content);
}, [triggerRequest, sendMessage]);
const handleSend = useCallback(async () => {
await sendMessage(input);
}, [input, sendMessage]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
},
[handleSend],
);
return (
<div className="flex h-full min-h-0 w-full flex-col bg-slate-50">
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<h2 className="text-sm font-semibold text-slate-700">AI </h2>
{onClose ? (
<button
type="button"
onClick={onClose}
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900"
>
</button>
) : null}
</div>
<div className="flex-1 min-h-0 space-y-3 overflow-y-auto px-4 py-4">
{messages.length === 0 && (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-slate-400"></p>
</div>
)}
{messages.map((msg, idx) => (
<div
key={idx}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
msg.role === "user"
? "bg-blue-600 text-white"
: "border border-slate-100 bg-white text-slate-700 shadow-sm"
}`}
>
{msg.role === "assistant" ? (
msg.content || (loading && idx === messages.length - 1 ? "思考中..." : "") ? (
<MarkdownMessage
content={msg.content || (loading && idx === messages.length - 1 ? "思考中..." : "")}
/>
) : null
) : (
<div className="whitespace-pre-wrap break-words">{msg.content}</div>
)}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
<div className="border-t border-slate-200 px-4 py-3">
<div className="flex items-end gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息..."
rows={1}
className="flex-1 resize-none rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-700 placeholder-slate-400 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400"
/>
<button
onClick={() => void handleSend()}
disabled={loading || !input.trim()}
className="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,278 @@
import React from "react";
type MarkdownMessageProps = {
content: string;
};
type MarkdownBlock =
| { type: "paragraph"; content: string }
| { type: "heading"; level: number; content: string }
| { type: "unordered-list"; items: string[] }
| { type: "ordered-list"; items: string[] }
| { type: "blockquote"; content: string }
| { type: "code"; language: string; content: string };
export default function MarkdownMessage({ content }: MarkdownMessageProps) {
const blocks = parseMarkdown(content);
return (
<div className="space-y-3">
{blocks.map((block, index) => renderBlock(block, index))}
</div>
);
}
function parseMarkdown(source: string): MarkdownBlock[] {
const normalized = source.replace(/\r\n/g, "\n");
const lines = normalized.split("\n");
const blocks: MarkdownBlock[] = [];
let paragraphLines: string[] = [];
const flushParagraph = () => {
if (!paragraphLines.length) return;
blocks.push({
type: "paragraph",
content: paragraphLines.join("\n").trim(),
});
paragraphLines = [];
};
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
continue;
}
const codeFenceMatch = trimmed.match(/^```([^`]*)$/);
if (codeFenceMatch) {
flushParagraph();
const codeLines: string[] = [];
let cursor = index + 1;
while (cursor < lines.length && !lines[cursor].trim().startsWith("```")) {
codeLines.push(lines[cursor]);
cursor += 1;
}
blocks.push({
type: "code",
language: codeFenceMatch[1].trim(),
content: codeLines.join("\n"),
});
index = cursor;
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
flushParagraph();
blocks.push({
type: "heading",
level: headingMatch[1].length,
content: headingMatch[2].trim(),
});
continue;
}
const blockquoteMatch = line.match(/^>\s?(.*)$/);
if (blockquoteMatch) {
flushParagraph();
const quoteLines = [blockquoteMatch[1]];
let cursor = index + 1;
while (cursor < lines.length) {
const nextMatch = lines[cursor].match(/^>\s?(.*)$/);
if (!nextMatch) break;
quoteLines.push(nextMatch[1]);
cursor += 1;
}
blocks.push({
type: "blockquote",
content: quoteLines.join("\n").trim(),
});
index = cursor - 1;
continue;
}
const unorderedMatch = line.match(/^[-*+]\s+(.+)$/);
if (unorderedMatch) {
flushParagraph();
const items = [unorderedMatch[1].trim()];
let cursor = index + 1;
while (cursor < lines.length) {
const nextMatch = lines[cursor].match(/^[-*+]\s+(.+)$/);
if (!nextMatch) break;
items.push(nextMatch[1].trim());
cursor += 1;
}
blocks.push({ type: "unordered-list", items });
index = cursor - 1;
continue;
}
const orderedMatch = line.match(/^\d+\.\s+(.+)$/);
if (orderedMatch) {
flushParagraph();
const items = [orderedMatch[1].trim()];
let cursor = index + 1;
while (cursor < lines.length) {
const nextMatch = lines[cursor].match(/^\d+\.\s+(.+)$/);
if (!nextMatch) break;
items.push(nextMatch[1].trim());
cursor += 1;
}
blocks.push({ type: "ordered-list", items });
index = cursor - 1;
continue;
}
paragraphLines.push(line);
}
flushParagraph();
return blocks;
}
function renderBlock(block: MarkdownBlock, index: number) {
if (block.type === "paragraph") {
return (
<p key={index} className="whitespace-pre-wrap leading-7 text-inherit">
{renderInline(block.content, `p-${index}`)}
</p>
);
}
if (block.type === "heading") {
const sizeClass =
block.level === 1
? "text-xl font-bold"
: block.level === 2
? "text-lg font-bold"
: "text-base font-semibold";
return (
<div key={index} className={`${sizeClass} leading-7 text-slate-900`}>
{renderInline(block.content, `h-${index}`)}
</div>
);
}
if (block.type === "unordered-list") {
return (
<ul key={index} className="list-disc space-y-1 pl-5 leading-7 text-inherit">
{block.items.map((item, itemIndex) => (
<li key={`${index}-${itemIndex}`}>{renderInline(item, `ul-${index}-${itemIndex}`)}</li>
))}
</ul>
);
}
if (block.type === "ordered-list") {
return (
<ol key={index} className="list-decimal space-y-1 pl-5 leading-7 text-inherit">
{block.items.map((item, itemIndex) => (
<li key={`${index}-${itemIndex}`}>{renderInline(item, `ol-${index}-${itemIndex}`)}</li>
))}
</ol>
);
}
if (block.type === "blockquote") {
return (
<blockquote
key={index}
className="border-l-4 border-slate-300 bg-slate-50/80 px-4 py-2 text-slate-600"
>
<div className="whitespace-pre-wrap leading-7">
{renderInline(block.content, `q-${index}`)}
</div>
</blockquote>
);
}
return (
<div key={index} className="overflow-hidden rounded-xl border border-slate-200 bg-slate-950">
{block.language && (
<div className="border-b border-slate-800 px-3 py-2 text-xs uppercase tracking-[0.18em] text-slate-400">
{block.language}
</div>
)}
<pre className="overflow-x-auto px-4 py-3 text-sm leading-6 text-slate-100">
<code>{block.content}</code>
</pre>
</div>
);
}
function renderInline(text: string, keyPrefix: string): React.ReactNode[] {
const pattern = /(\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|`([^`]+)`|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_)/g;
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
let matchIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
const plainText = text.slice(lastIndex, match.index);
if (plainText) {
nodes.push(...renderPlainText(plainText, `${keyPrefix}-plain-${matchIndex}`));
}
if (match[2] && match[3]) {
nodes.push(
<a
key={`${keyPrefix}-link-${matchIndex}`}
href={match[3]}
target="_blank"
rel="noreferrer"
className="font-medium text-blue-600 underline decoration-blue-300 underline-offset-2"
>
{match[2]}
</a>,
);
} else if (match[4]) {
nodes.push(
<code
key={`${keyPrefix}-code-${matchIndex}`}
className="rounded bg-slate-900/90 px-1.5 py-0.5 font-mono text-[0.92em] text-amber-200"
>
{match[4]}
</code>,
);
} else if (match[5] || match[6]) {
nodes.push(
<strong key={`${keyPrefix}-strong-${matchIndex}`} className="font-semibold text-slate-900">
{match[5] ?? match[6]}
</strong>,
);
} else if (match[7] || match[8]) {
nodes.push(
<em key={`${keyPrefix}-em-${matchIndex}`} className="italic">
{match[7] ?? match[8]}
</em>,
);
}
lastIndex = match.index + match[0].length;
matchIndex += 1;
}
const trailingText = text.slice(lastIndex);
if (trailingText) {
nodes.push(...renderPlainText(trailingText, `${keyPrefix}-tail`));
}
return nodes;
}
function renderPlainText(text: string, keyPrefix: string): React.ReactNode[] {
return text.split("\n").flatMap((segment, index, array) => {
const nodes: React.ReactNode[] = [
<React.Fragment key={`${keyPrefix}-${index}`}>{segment}</React.Fragment>,
];
if (index < array.length - 1) {
nodes.push(<br key={`${keyPrefix}-br-${index}`} />);
}
return nodes;
});
}

View File

@@ -0,0 +1,155 @@
"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";
type MindmapCanvasProps = {
tree: MindmapNode;
className?: string;
onNodeChat?: (nodeLabel: string) => void;
};
const NODE_WIDTH = 220;
const NODE_HEIGHT = 84;
const nodeTypes = {
mindmapNode: MindmapNodeCard,
};
function createInitialExpandedSet(tree: MindmapNode): Set<string> {
return new Set([tree.id]);
}
export default function MindmapCanvas({ tree, className, onNodeChat }: MindmapCanvasProps) {
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(() =>
createInitialExpandedSet(tree),
);
const [reactFlowInstance, setReactFlowInstance] =
useState<ReactFlowInstance | null>(null);
const hasFittedInitiallyRef = useRef(false);
useEffect(() => {
setExpandedNodeIds(createInitialExpandedSet(tree));
hasFittedInitiallyRef.current = false;
}, [tree]);
const panToNode = useCallback(
(nodeId: string, nextExpandedNodeIds: Set<string>) => {
if (!reactFlowInstance) {
return;
}
const nextGraph = treeToGraph(tree, {
expandedNodeIds: nextExpandedNodeIds,
});
const focusNode = nextGraph.nodes.find((node) => node.id === nodeId);
if (!focusNode) {
return;
}
const viewport = reactFlowInstance.getViewport();
const revealOffset = nextExpandedNodeIds.has(nodeId)
? NODE_WIDTH * 0.9
: NODE_WIDTH * 0.45;
window.requestAnimationFrame(() => {
reactFlowInstance.setCenter(
focusNode.position.x + revealOffset,
focusNode.position.y + NODE_HEIGHT / 2,
{
zoom: viewport.zoom,
duration: 320,
},
);
});
},
[reactFlowInstance, tree],
);
const handleToggle = useCallback(
(nodeId: string) => {
setExpandedNodeIds((currentExpandedNodeIds) => {
const nextExpandedNodeIds = new Set(currentExpandedNodeIds);
if (nextExpandedNodeIds.has(nodeId)) {
nextExpandedNodeIds.delete(nodeId);
} else {
nextExpandedNodeIds.add(nodeId);
}
panToNode(nodeId, nextExpandedNodeIds);
return nextExpandedNodeIds;
});
},
[panToNode],
);
const graph = useMemo(
() =>
treeToGraph(tree, {
expandedNodeIds,
onToggleNode: handleToggle,
onOpenNodeChat: onNodeChat,
}),
[expandedNodeIds, handleToggle, onNodeChat, tree],
);
const keepNodeInteractive = useCallback(() => {
// React Flow only enables pointer events for nodes when at least one node handler exists.
}, []);
useEffect(() => {
if (!reactFlowInstance || hasFittedInitiallyRef.current || graph.nodes.length === 0) {
return;
}
const frame = window.requestAnimationFrame(() => {
reactFlowInstance.fitView({
padding: 0.16,
duration: 420,
maxZoom: 1,
});
hasFittedInitiallyRef.current = true;
});
return () => window.cancelAnimationFrame(frame);
}, [graph.nodes, reactFlowInstance]);
return (
<div className={`h-full w-full overflow-hidden bg-white ${className ?? ""}`}>
<ReactFlow
nodes={graph.nodes}
edges={graph.edges}
nodeTypes={nodeTypes}
onInit={setReactFlowInstance}
onNodeClick={keepNodeInteractive}
panOnDrag
zoomOnScroll
zoomOnPinch
zoomOnDoubleClick={false}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
nodesFocusable={false}
edgesFocusable={false}
selectNodesOnDrag={false}
minZoom={0.2}
maxZoom={2}
proOptions={{ hideAttribution: true }}
>
<MiniMap
pannable
zoomable
nodeBorderRadius={12}
maskColor="rgba(148, 163, 184, 0.12)"
/>
<Controls showInteractive={false} />
<Background gap={20} color="#dbeafe" />
</ReactFlow>
</div>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
import { memo, useCallback, type MouseEvent } from "react";
import { Handle, Position, type NodeProps } from "reactflow";
import type { GraphNodeData } from "@/types/mindmap";
function MindmapNodeCard({ data }: NodeProps<GraphNodeData>) {
const isRoot = data.level === 0;
const canOpenChat = typeof data.onOpenChat === "function";
const handleToggleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
data.onToggle?.();
},
[data],
);
const handleContentDoubleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
data.onOpenChat?.();
},
[data],
);
return (
<div
className={`relative flex h-[84px] w-[220px] items-center justify-center rounded-[18px] border px-5 py-4 ${
isRoot
? "border-blue-500 bg-blue-600 text-white shadow-[0_18px_40px_rgba(37,99,235,0.24)]"
: "border-slate-200 bg-white text-slate-900 shadow-[0_12px_28px_rgba(15,23,42,0.08)]"
} cursor-default`}
>
<Handle
type="target"
position={Position.Left}
className={`!pointer-events-none !left-[-7px] !h-3 !w-3 !border ${
isRoot ? "!border-blue-200 !bg-blue-100" : "!border-slate-300 !bg-white"
}`}
/>
<button
type="button"
onDoubleClick={handleContentDoubleClick}
disabled={!canOpenChat}
title={canOpenChat ? "双击发起聊天" : undefined}
className={`nodrag nopan max-w-[156px] select-none rounded-xl px-2 py-1 text-center text-sm font-semibold leading-6 transition-colors ${
canOpenChat
? isRoot
? "cursor-pointer hover:bg-white/10"
: "cursor-pointer hover:bg-slate-50"
: "cursor-default"
} disabled:pointer-events-none disabled:opacity-100`}
>
{data.label}
</button>
{data.hasChildren ? (
<>
<Handle
type="source"
position={Position.Right}
className={`!pointer-events-none !right-[-7px] !h-3 !w-3 !border ${
isRoot ? "!border-blue-200 !bg-blue-100" : "!border-blue-300 !bg-white"
}`}
/>
<button
type="button"
aria-label={data.isExpanded ? "收起子节点" : "展开子节点"}
onClick={handleToggleClick}
className="nodrag nopan absolute right-0 top-1/2 z-20 flex h-9 w-9 translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-blue-200 bg-white text-lg font-semibold text-blue-700 shadow-[0_8px_18px_rgba(37,99,235,0.14)] transition-all hover:border-blue-300 hover:bg-blue-50 active:scale-95"
>
{data.isExpanded ? "-" : "+"}
</button>
</>
) : (
<span
className={`pointer-events-none absolute -right-[7px] top-1/2 h-3 w-3 -translate-y-1/2 rounded-full border ${
isRoot ? "border-blue-200 bg-blue-100" : "border-slate-300 bg-white"
}`}
/>
)}
</div>
);
}
export default memo(MindmapNodeCard);

6
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

6
frontend/next.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
export default nextConfig;

View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
};
export default nextConfig;

6705
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "interactive-mindmap-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.26",
"react": "18.3.1",
"react-dom": "18.3.1",
"reactflow": "11.11.4"
},
"devDependencies": {
"@types/node": "22.10.1",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"autoprefixer": "10.4.20",
"eslint": "8.57.1",
"eslint-config-next": "14.2.26",
"postcss": "8.4.49",
"tailwindcss": "3.4.16",
"typescript": "5.7.2"
}
}

5
frontend/pages/_app.tsx Normal file
View File

@@ -0,0 +1,5 @@
import type { AppProps } from "next/app";
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}

View File

@@ -0,0 +1,13 @@
import { Head, Html, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="zh-CN">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,28 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./lib/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
ink: "#111827",
mist: "#eff6ff",
brand: "#2563eb",
accent: "#0f766e",
},
boxShadow: {
panel: "0 20px 45px rgba(15, 23, 42, 0.08)",
},
fontFamily: {
sans: ["Manrope", "Segoe UI", "sans-serif"],
},
},
},
plugins: [],
};
export default config;

27
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

File diff suppressed because one or more lines are too long

47
frontend/types/mindmap.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { Edge, Node } from "reactflow";
export interface MindmapNode {
id: string;
label: string;
parent_id: string | null;
level: number;
is_leaf: boolean;
children: MindmapNode[];
}
export interface Mindmap {
id: number;
unique_id: string;
session_id: string;
title: string;
raw_json: string;
tree: MindmapNode;
url: string;
created_at: string;
updated_at: string;
}
export interface CreateMindmapPayload {
session_id: string;
mindmap_json: Record<string, unknown>;
}
export interface GraphNodeData {
label: string;
level: number;
isLeaf: boolean;
hasChildren: boolean;
isExpanded: boolean;
onToggle?: () => void;
onOpenChat?: () => void;
}
export interface GraphData {
nodes: Node<GraphNodeData>[];
edges: Edge[];
}
export interface ChatMessage {
role: "user" | "assistant";
content: string;
}

176
help.md Normal file
View File

@@ -0,0 +1,176 @@
# 思维导图工具使用说明
## 概述
本工具用于对接腾讯云智能体平台,实现思维导图的可视化展示和交互式对话。
## 工作流程
```
腾讯云智能体平台 → 发送sessionID和思维导图JSON → 本系统存储并返回URL → 用户访问URL查看思维导图 → 点击节点触发AI对话
```
## API 接口
### 1. 创建思维导图
**请求**
```
POST /api/mindmaps
Content-Type: application/json
{
"session_id": "腾讯云对话sessionID",
"mindmap_json": {
"id": "node_0",
"label": "机器学习",
"parent_id": null,
"level": 0,
"is_leaf": false,
"children": [
{
"id": "node_1",
"label": "监督学习",
"parent_id": "node_0",
"level": 1,
"is_leaf": false,
"children": []
}
]
}
}
```
**响应**
```json
{
"id": 1,
"unique_id": "abc123xyz",
"session_id": "腾讯云对话sessionID",
"title": "机器学习",
"raw_json": "{...}",
"tree": {...},
"url": "http://localhost:3000/mindmap/abc123xyz",
"created_at": "2026-03-20T12:00:00",
"updated_at": "2026-03-20T12:00:00"
}
```
**说明**
- `session_id`: 腾讯云智能体平台的对话sessionID用于后续节点点击时的对话
- `mindmap_json`: 思维导图的JSON数据标题从根节点的`label`字段自动提取
- `url`: 返回的唯一访问链接,可直接发送给用户
### 2. 获取思维导图
**请求**
```
GET /api/mindmaps/{unique_id}
```
**响应**
与创建接口返回格式相同
### 3. 对话接口
**请求**
```
POST /api/chat
Content-Type: application/json
{
"session_id": "腾讯云对话sessionID",
"content": "帮我解释一下监督学习"
}
```
**响应**
SSE流式响应转发腾讯云智能体平台的对话结果
## 思维导图JSON格式
```json
{
"id": "node_0",
"label": "根节点标题",
"parent_id": null,
"level": 0,
"is_leaf": false,
"children": [
{
"id": "node_1",
"label": "子节点标题",
"parent_id": "node_0",
"level": 1,
"is_leaf": true,
"children": []
}
]
}
```
**字段说明**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | string | 节点唯一标识 |
| `label` | string | 节点显示文本 |
| `parent_id` | string \| null | 父节点ID根节点为null |
| `level` | number | 节点层级根节点为0 |
| `is_leaf` | boolean | 是否为叶子节点 |
| `children` | array | 子节点数组 |
## 前端交互
1. **查看思维导图**: 用户访问返回的URL即可查看思维导图
2. **展开/折叠节点**: 点击有子节点的节点可展开或折叠
3. **触发AI对话**: 点击任意节点,右侧聊天面板会自动显示并发送"帮我解释一下{节点内容}"到腾讯云智能体平台
4. **继续对话**: 用户可在聊天面板中继续与AI对话
## 环境变量配置
后端 `.env` 文件:
```env
# 腾讯云智能体平台 AppKey
# 获取方式:应用管理 → 找到运行中的应用 → 点击"调用" → 复制AppKey
BOT_APP_KEY=your-app-key-here
# 前端基础URL用于生成思维导图链接
FRONTEND_BASE_URL=http://localhost:3000
```
前端 `.env.local` 文件:
```env
# 后端API地址
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000
```
## 启动方式
### 后端
```bash
cd backend
pip install -r requirements.txt
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
### 前端
```bash
cd frontend
npm install
npm run dev
```
## 腾讯云智能体平台配置
在腾讯云智能体平台的系统提示词中指导AI返回符合上述格式的思维导图JSON。参考 `mind_prompt.md` 文件中的示例提示词。

178
mind_prompt.md Normal file
View File

@@ -0,0 +1,178 @@
# 思维导图 AI Agent 系统提示词
将以下内容配置为腾讯云 AI Agent 的系统提示词System Prompt
---
你是一个思维导图生成助手。当用户提出一个主题时,你需要生成一个结构化的思维导图 JSON 数据。
## 输出格式
你必须严格按照以下 JSON Schema 输出思维导图数据,不要包含任何额外的解释文字,只返回 JSON
```json
{
"id": "node_0",
"label": "根节点标题",
"parent_id": null,
"level": 0,
"is_leaf": false,
"children": [
{
"id": "node_1",
"label": "子节点标题",
"parent_id": "node_0",
"level": 1,
"is_leaf": false,
"children": [
{
"id": "node_4",
"label": "叶子节点",
"parent_id": "node_1",
"level": 2,
"is_leaf": true,
"children": []
}
]
}
]
}
```
## 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | string | 节点唯一标识,格式为 `node_N`N 从 0 开始递增) |
| `label` | string | 节点显示文本 |
| `parent_id` | string \| null | 父节点 ID根节点为 null |
| `level` | number | 节点层级,根节点为 0 |
| `is_leaf` | boolean | 是否为叶子节点(无子节点时为 true |
| `children` | array | 子节点数组,叶子节点为空数组 `[]` |
## 规则
1. 根节点的 `label` 应为用户提出的主题
2. 建议生成 3-5 个一级子节点
3. 每个一级子节点下建议生成 2-4 个二级子节点
4. 最多不超过 3 层level 0, 1, 2
5. **必须** 将整个 JSON 放在 ` ```json ``` ` 代码块内返回
6. 除了 JSON 代码块外,不要输出任何其他文字
## 示例
用户输入:`机器学习`
你应该返回:
```json
{
"id": "node_0",
"label": "机器学习",
"parent_id": null,
"level": 0,
"is_leaf": false,
"children": [
{
"id": "node_1",
"label": "监督学习",
"parent_id": "node_0",
"level": 1,
"is_leaf": false,
"children": [
{
"id": "node_5",
"label": "线性回归",
"parent_id": "node_1",
"level": 2,
"is_leaf": true,
"children": []
},
{
"id": "node_6",
"label": "决策树",
"parent_id": "node_1",
"level": 2,
"is_leaf": true,
"children": []
}
]
},
{
"id": "node_2",
"label": "无监督学习",
"parent_id": "node_0",
"level": 1,
"is_leaf": false,
"children": [
{
"id": "node_7",
"label": "聚类分析",
"parent_id": "node_2",
"level": 2,
"is_leaf": true,
"children": []
},
{
"id": "node_8",
"label": "降维",
"parent_id": "node_2",
"level": 2,
"is_leaf": true,
"children": []
}
]
},
{
"id": "node_3",
"label": "强化学习",
"parent_id": "node_0",
"level": 1,
"is_leaf": false,
"children": [
{
"id": "node_9",
"label": "Q-Learning",
"parent_id": "node_3",
"level": 2,
"is_leaf": true,
"children": []
},
{
"id": "node_10",
"label": "策略梯度",
"parent_id": "node_3",
"level": 2,
"is_leaf": true,
"children": []
}
]
},
{
"id": "node_4",
"label": "深度学习",
"parent_id": "node_0",
"level": 1,
"is_leaf": false,
"children": [
{
"id": "node_11",
"label": "卷积神经网络",
"parent_id": "node_4",
"level": 2,
"is_leaf": true,
"children": []
},
{
"id": "node_12",
"label": "循环神经网络",
"parent_id": "node_4",
"level": 2,
"is_leaf": true,
"children": []
}
]
}
]
}
```

155
test_api.py Normal file
View File

@@ -0,0 +1,155 @@
import requests
BASE_URL = "http://127.0.0.1:8000"
def test_create_mindmap():
payload = {
"session_id": "test-session-123456",
"mindmap_json": {
"id": "node_0",
"label": "Python编程",
"parent_id": None,
"level": 0,
"is_leaf": False,
"children": [
{
"id": "node_1",
"label": "基础语法",
"parent_id": "node_0",
"level": 1,
"is_leaf": False,
"children": [
{
"id": "node_5",
"label": "变量与数据类型",
"parent_id": "node_1",
"level": 2,
"is_leaf": True,
"children": [],
},
{
"id": "node_6",
"label": "控制流程",
"parent_id": "node_1",
"level": 2,
"is_leaf": True,
"children": [],
},
{
"id": "node_7",
"label": "函数定义",
"parent_id": "node_1",
"level": 2,
"is_leaf": True,
"children": [],
},
],
},
{
"id": "node_2",
"label": "面向对象",
"parent_id": "node_0",
"level": 1,
"is_leaf": False,
"children": [
{
"id": "node_8",
"label": "类与对象",
"parent_id": "node_2",
"level": 2,
"is_leaf": True,
"children": [],
},
{
"id": "node_9",
"label": "继承与多态",
"parent_id": "node_2",
"level": 2,
"is_leaf": True,
"children": [],
},
],
},
{
"id": "node_3",
"label": "常用库",
"parent_id": "node_0",
"level": 1,
"is_leaf": False,
"children": [
{
"id": "node_10",
"label": "NumPy",
"parent_id": "node_3",
"level": 2,
"is_leaf": True,
"children": [],
},
{
"id": "node_11",
"label": "Pandas",
"parent_id": "node_3",
"level": 2,
"is_leaf": True,
"children": [],
},
{
"id": "node_12",
"label": "Requests",
"parent_id": "node_3",
"level": 2,
"is_leaf": True,
"children": [],
},
],
},
{
"id": "node_4",
"label": "应用场景",
"parent_id": "node_0",
"level": 1,
"is_leaf": False,
"children": [
{
"id": "node_13",
"label": "Web开发",
"parent_id": "node_4",
"level": 2,
"is_leaf": True,
"children": [],
},
{
"id": "node_14",
"label": "数据分析",
"parent_id": "node_4",
"level": 2,
"is_leaf": True,
"children": [],
},
],
},
],
},
}
response = requests.post(f"{BASE_URL}/api/mindmaps", json=payload)
print("=== 创建思维导图 ===")
print(f"状态码: {response.status_code}")
result = response.json()
print(f"访问链接: {result['url']}")
print(f"标题: {result['title']}")
print(f"unique_id: {result['unique_id']}")
return result
def test_get_mindmap(unique_id):
response = requests.get(f"{BASE_URL}/api/mindmaps/{unique_id}")
print("\n=== 获取思维导图 ===")
print(f"状态码: {response.status_code}")
print(f"标题: {response.json()['title']}")
if __name__ == "__main__":
result = test_create_mindmap()
test_get_mindmap(result["unique_id"])