From 8400fb61271328723770adff4ca441f0c3d17314 Mon Sep 17 00:00:00 2001 From: zhangyonghao <2413683360.qq.com> Date: Fri, 20 Mar 2026 23:09:51 +0800 Subject: [PATCH] 1 --- .gitignore | 228 + README.md | 121 + backend/.env.example | 6 + backend/.gitignore | 4 + backend/app/__init__.py | 1 + backend/app/config.py | 22 + backend/app/database.py | 23 + backend/app/main.py | 44 + backend/app/models.py | 32 + backend/app/routers/__init__.py | 1 + backend/app/routers/chat.py | 125 + backend/app/routers/mindmaps.py | 82 + backend/app/schemas.py | 34 + backend/data/.gitkeep | 1 + backend/package-lock.json | 6 + backend/requirements.txt | 6 + frontend/.eslintrc.json | 3 + frontend/.gitignore | 3 + frontend/app/globals.css | 30 + frontend/app/layout.tsx | 19 + frontend/app/mindmap/[id]/page.tsx | 220 + frontend/app/page.tsx | 20 + frontend/components/ChatPanel.tsx | 406 ++ frontend/components/MarkdownMessage.tsx | 278 + frontend/components/MindmapCanvas.tsx | 155 + frontend/components/MindmapNodeCard.tsx | 90 + frontend/next-env.d.ts | 6 + frontend/next.config.mjs | 6 + frontend/next.config.ts.disabled | 7 + frontend/package-lock.json | 6705 +++++++++++++++++++++++ frontend/package.json | 28 + frontend/pages/_app.tsx | 5 + frontend/pages/_document.tsx | 13 + frontend/postcss.config.js | 6 + frontend/tailwind.config.ts | 28 + frontend/tsconfig.json | 27 + frontend/tsconfig.tsbuildinfo | 1 + frontend/types/mindmap.ts | 47 + help.md | 176 + mind_prompt.md | 178 + test_api.py | 155 + 41 files changed, 9348 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/chat.py create mode 100644 backend/app/routers/mindmaps.py create mode 100644 backend/app/schemas.py create mode 100644 backend/data/.gitkeep create mode 100644 backend/package-lock.json create mode 100644 backend/requirements.txt create mode 100644 frontend/.eslintrc.json create mode 100644 frontend/.gitignore create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/mindmap/[id]/page.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/components/ChatPanel.tsx create mode 100644 frontend/components/MarkdownMessage.tsx create mode 100644 frontend/components/MindmapCanvas.tsx create mode 100644 frontend/components/MindmapNodeCard.tsx create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.mjs create mode 100644 frontend/next.config.ts.disabled create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/pages/_app.tsx create mode 100644 frontend/pages/_document.tsx create mode 100644 frontend/postcss.config.js create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.tsbuildinfo create mode 100644 frontend/types/mindmap.ts create mode 100644 help.md create mode 100644 mind_prompt.md create mode 100644 test_api.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6786de1 --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e53668 --- /dev/null +++ b/README.md @@ -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 的系统提示词配置。 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..888f8c7 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,6 @@ +# 腾讯云智能体平台 AppKey +# 获取方式:应用管理 → 找到运行中的应用 → 点击"调用" → 复制AppKey +BOT_APP_KEY=your-app-key-here + +# 前端基础URL(用于生成思维导图链接) +FRONTEND_BASE_URL=http://localhost:3000 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..6e91de9 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +*.pyc +data/*.db +.venv diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..cb85fbf --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..b5dbc6e --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..508696e --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..5a9465d --- /dev/null +++ b/backend/app/models.py @@ -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 diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/backend/app/routers/chat.py b/backend/app/routers/chat.py new file mode 100644 index 0000000..39f84dd --- /dev/null +++ b/backend/app/routers/chat.py @@ -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", + }, + ) diff --git a/backend/app/routers/mindmaps.py b/backend/app/routers/mindmaps.py new file mode 100644 index 0000000..0071bd2 --- /dev/null +++ b/backend/app/routers/mindmaps.py @@ -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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..1bc62a0 --- /dev/null +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/data/.gitkeep b/backend/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..dfb18f1 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "backend", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..d0a1651 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 0000000..957cd15 --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals"] +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..50ad4e5 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,3 @@ +.next +node_modules +dist diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..85e8987 --- /dev/null +++ b/frontend/app/globals.css @@ -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; +} \ No newline at end of file diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..2035f74 --- /dev/null +++ b/frontend/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/frontend/app/mindmap/[id]/page.tsx b/frontend/app/mindmap/[id]/page.tsx new file mode 100644 index 0000000..e2dee35 --- /dev/null +++ b/frontend/app/mindmap/[id]/page.tsx @@ -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(null); + const [mindmap, setMindmap] = useState(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(); + + 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) => { + 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 ( +
+
+ 正在加载思维导图... +
+
+ ); + } + + if (error || !mindmap) { + return ( +
+
+

+ {error || "未找到对应脑图"} +

+
+
+ ); + } + + return ( +
+
+ +
+ {showChat && ( + <> +
+
+
+
+ + + )} +
+ ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..b9e1319 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,20 @@ +export default function HomePage() { + return ( +
+
+

+ Interactive Mindmap +

+

+ 思维导图可视化工具 +

+

+ 通过腾讯云智能体平台生成思维导图,点击节点可与 AI 进行深入对话。 +

+

+ 请通过腾讯云智能体平台获取思维导图链接 +

+
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/ChatPanel.tsx b/frontend/components/ChatPanel.tsx new file mode 100644 index 0000000..587da18 --- /dev/null +++ b/frontend/components/ChatPanel.tsx @@ -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([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const messagesEndRef = useRef(null); + const typingTimerRef = useRef(null); + const pendingCleanupTimerRef = useRef(null); + const abortControllerRef = useRef(null); + const assistantTargetRef = useRef(""); + const assistantDisplayedRef = useRef(""); + const streamCompleteRef = useRef(false); + const handledTriggerIdRef = useRef(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(data)); + return; + } + + if (normalizedEventName === "error") { + handleErrorEvent(parseJsonSafely(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) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void handleSend(); + } + }, + [handleSend], + ); + + return ( +
+
+

AI 对话

+ {onClose ? ( + + ) : null} +
+ +
+ {messages.length === 0 && ( +
+

双击思维导图节点开始对话

+
+ )} + + {messages.map((msg, idx) => ( +
+
+ {msg.role === "assistant" ? ( + msg.content || (loading && idx === messages.length - 1 ? "思考中..." : "") ? ( + + ) : null + ) : ( +
{msg.content}
+ )} +
+
+ ))} + +
+
+ +
+
+