Compare commits

..

6 Commits

Author SHA1 Message Date
3378bfc61d 优化文档 2026-03-21 10:29:55 +08:00
0c3166060d 更新各类文档 2026-03-21 10:11:14 +08:00
190cee20bd Fix frontend imports and track frontend lib files 2026-03-21 09:22:10 +08:00
83f7a66e32 fix:Liunx系统下部署问题 2026-03-21 09:05:06 +08:00
81beb914ee Merge remote-tracking branch 'origin/master' 2026-03-20 23:41:03 +08:00
zhangyonghao
8400fb6127 1 2026-03-20 23:09:51 +08:00
15 changed files with 1378 additions and 112 deletions

4
.gitignore vendored
View File

@@ -108,8 +108,8 @@ develop-eggs/
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/
lib/ /lib/
lib64/ /lib64/
parts/ parts/
sdist/ sdist/
var/ var/

375
README.md
View File

@@ -1,121 +1,318 @@
# Interactive Mindmap # Interactive Mindmap
基于 Next.jsFastAPI 和 SQLite 的交互式思维导图应用,支持通过腾讯云 AI Agent 对话生成思维导图 基于 `Next.js 14``FastAPI``SQLite` 的思维导图项目
当前项目的实际使用方式是:
- 腾讯云智能体工作流生成思维导图 JSON
- 后端 `POST /api/mindmaps` 保存脑图并返回访问链接
- 前端通过 `/mindmap/[id]` 展示脑图
- 在脑图详情页中,双击节点可打开右侧聊天面板,继续通过腾讯云智能体进行对话
## 当前功能
- 保存思维导图 JSON并生成可访问链接
- 展示思维导图详情页
- 支持节点展开/收缩
- 支持在详情页中对节点发起对话
- 后端代理腾讯云智能体 SSE 接口
## 项目结构 ## 项目结构
``` ```text
mindmap/ mindmap/
├── backend/ # FastAPI 后端 ├── backend/
│ ├── app/ │ ├── app/
│ │ ├── main.py # 应用入口 │ │ ├── main.py
│ │ ├── config.py # 配置 │ │ ├── config.py
│ │ ├── database.py # 数据库 │ │ ├── database.py
│ │ ├── models.py # 数据模型 │ │ ├── models.py
│ │ ├── schemas.py # Pydantic schemas │ │ ├── schemas.py
│ │ └── routers/ │ │ └── routers/
│ │ ├── mindmaps.py # 思维导图 CRUD API │ │ ├── chat.py
│ │ └── chat.py # AI 对话代理 (SSE) │ │ └── mindmaps.py
│ ├── data/ # SQLite 数据库文件 │ ├── data/
│ ├── requirements.txt │ ├── requirements.txt
│ └── .env.example │ └── .env.example
├── frontend/ # Next.js 前端 ├── frontend/
│ ├── app/ │ ├── app/
│ │ ├── page.tsx # 首页 │ │ ├── page.tsx
│ │ └── mindmap/ │ │ └── mindmap/
│ │ ── [id]/page.tsx # 思维导图详情页 │ │ ── [id]/page.tsx
│ │ └── chat/page.tsx # AI 对话 + 思维导图页
│ ├── components/ │ ├── components/
│ │ ├── MindmapCanvas.tsx # 思维导图画布 │ │ ├── ChatPanel.tsx
│ │ ├── MindmapNodeCard.tsx # 节点卡片 │ │ ├── MarkdownMessage.tsx
│ │ ├── ChatPanel.tsx # 聊天面板 │ │ ├── MindmapCanvas.tsx
│ │ └── CreateMindmapForm.tsx # 创建表单 │ │ └── MindmapNodeCard.tsx
│ ├── lib/ │ ├── lib/
│ │ ├── api.ts # API 调用 │ │ ├── api.ts
│ │ ── treeToGraph.ts # 树转图布局 │ │ ── tencentSse.ts
│ └── types/ │ └── treeToGraph.ts
└── mindmap.ts # TypeScript 类型 ├── types/
└── mind_prompt.md # AI Agent 系统提示词 │ │ └── mindmap.ts
``` │ └── package.json
├── mind_prompt.md
## 环境配置 ├── 宝塔面板部署指南.md
└── 腾讯云工作流说明.md
### 后端
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]` ### 思维导图详情 `/mindmap/[id]`
查看已保存的思维导图,支持节点展开/收缩。 该页面会:
### AI 对话生成 `/mindmap/chat?sessionId=xxx` - 从后端读取指定 `unique_id` 的脑图数据
- 渲染交互式脑图
- 双击节点后打开右侧聊天面板
- 聊天面板通过 `/api/chat` 与腾讯云智能体交互
通过与腾讯云 AI Agent 对话生成思维导图: ## 后端接口
- **左侧**: 思维导图画布初始为空AI 返回有效数据后自动渲染 ### `GET /`
- **右侧**: 聊天面板,支持实时 SSE 流式响应
URL 参数: 健康检查接口。
- `sessionId` (必填): 会话 ID用于标识对话会话
## 数据流 返回:
``` ```json
用户输入 → ChatPanel → POST /api/chat → 后端代理 → 腾讯云 SSE API {"message":"Interactive Mindmap API is running"}
画布更新 ← onMindmapUpdate ← JSON 解析 ← SSE 流式响应 ←─┘
``` ```
1. 用户在聊天面板输入消息 ### `POST /api/mindmaps`
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 的系统提示词配置。 请求体:
```json
{
"session_id": "session_001",
"mindmap_json": {
"id": "node_0",
"label": "主题",
"parent_id": null,
"level": 0,
"is_leaf": false,
"children": []
}
}
```
返回中会包含:
- `unique_id`
- `title`
- `url`
- `tree`
### `GET /api/mindmaps/{unique_id}`
读取指定脑图详情。
### `POST /api/chat`
后端代理腾讯云智能体 SSE 接口。
请求体:
```json
{
"session_id": "session_001",
"content": "帮我解释一下这个节点",
"visitor_biz_id": "default_visitor"
}
```
返回:
- `text/event-stream`
## 环境变量
## 后端 `backend/.env`
复制:
```bash
cp backend/.env.example backend/.env
```
实际使用的变量名是:
```env
BOT_APP_KEY=your-app-key
FRONTEND_BASE_URL=http://localhost:3000
```
说明:
- `BOT_APP_KEY`:腾讯云智能体平台 AppKey
- `FRONTEND_BASE_URL`:后端返回脑图链接时使用的前端基地址
注意:当前代码读取的是 `BOT_APP_KEY`,不是 `TENCENT_BOT_APP_KEY`
## 前端 `frontend/.env.production`
如果前后端分端口直连,推荐写成:
```env
NEXT_PUBLIC_API_BASE_URL=http://你的公网IP:8000
```
如果后面切到统一入口反向代理,可以改成:
```env
NEXT_PUBLIC_API_BASE_URL=
```
## 本地开发
## 启动后端
```bash
cd backend
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
```
默认地址:
```text
http://127.0.0.1:8000
```
## 启动前端
```bash
cd frontend
npm install
npm run dev
```
默认地址:
```text
http://127.0.0.1:3000
```
## 生产构建
```bash
cd frontend
npm ci
rm -rf .next
npm run build
npm run start -- -p 3000
```
## 测试一条脑图创建链路
后端启动后,可以直接调用:
```bash
curl -X POST http://127.0.0.1:8000/api/mindmaps \
-H "Content-Type: application/json" \
-d '{
"session_id": "test-session",
"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": true,
"children": []
}
]
}
}'
```
成功后会返回一个 `url`,打开即可查看脑图详情页。
## 腾讯云智能体接入说明
当前项目更适合由腾讯云工作流或插件来创建脑图,而不是由前端首页直接生成。
建议查看:
- [mind_prompt.md](./mind_prompt.md)
- [腾讯云工作流说明.md](./腾讯云工作流说明.md)
如果你走当前工作流方案,关键链路是:
```text
开始
-> 大模型生成内容
-> 大模型整理 JSON
-> 大模型检查 JSON
-> 变量转换(JSON 反序列化)
-> 插件调用 /api/mindmaps
-> 回复脑图链接
```
## 部署说明
当前实际部署文档见:
- [宝塔面板部署指南.md](./宝塔面板部署指南.md)
当前推荐部署方式与实际落地一致:
- 前端放在宝塔 `Node 项目`
- 后端放在宝塔 `Python 项目`
- 通过公网 IP + `3000/8000` 直接访问
## 已知注意事项
### 1. `frontend/lib` 不能被 `.gitignore` 误伤
根目录 `.gitignore` 如果写成:
```gitignore
lib/
```
会把 `frontend/lib/` 一起忽略掉,导致服务器缺少:
- `frontend/lib/api.ts`
- `frontend/lib/tencentSse.ts`
- `frontend/lib/treeToGraph.ts`
正确写法应该是:
```gitignore
/lib/
/lib64/
```
### 2. 前端接口地址改动后必须重建
只要改了:
- `NEXT_PUBLIC_API_BASE_URL`
就必须重新执行:
```bash
cd frontend
rm -rf .next
npm run build
```
### 3. 后端返回链接依赖 `FRONTEND_BASE_URL`
如果这个值不对,后端返回的 `url` 就会不对。

View File

@@ -4,7 +4,8 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.database import Base, engine from app.database import Base, engine, SessionLocal
from app.models import Mindmap
from app.routers import chat, mindmaps from app.routers import chat, mindmaps
logging.basicConfig( logging.basicConfig(
@@ -15,6 +16,15 @@ logger = logging.getLogger(__name__)
Base.metadata.create_all(bind=engine) 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 = FastAPI(title=settings.app_name)
app.add_middleware( app.add_middleware(

View File

@@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime, timedelta
from sqlalchemy import DateTime, Integer, String, Text from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, Session
from app.database import Base from app.database import Base
@@ -22,3 +22,11 @@ class Mindmap(Base):
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False 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

@@ -1,4 +1,5 @@
import json import json
import logging
import secrets import secrets
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -9,6 +10,8 @@ from app.database import get_db
from app.models import Mindmap from app.models import Mindmap
from app.schemas import MindmapCreateRequest, MindmapNode, MindmapResponse from app.schemas import MindmapCreateRequest, MindmapNode, MindmapResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/mindmaps", tags=["mindmaps"]) router = APIRouter(prefix="/mindmaps", tags=["mindmaps"])
@@ -44,6 +47,11 @@ def create_mindmap(
payload: MindmapCreateRequest, payload: MindmapCreateRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> MindmapResponse: ) -> 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) title = extract_title_from_json(payload.mindmap_json)
raw_json = json.dumps(payload.mindmap_json, ensure_ascii=False) raw_json = json.dumps(payload.mindmap_json, ensure_ascii=False)
unique_id = generate_unique_id() unique_id = generate_unique_id()

View File

@@ -1,4 +1,4 @@
"use client"; "use client";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { import {
@@ -8,10 +8,10 @@ import {
useState, useState,
type MouseEvent as ReactMouseEvent, type MouseEvent as ReactMouseEvent,
} from "react"; } from "react";
import MindmapCanvas from "@/components/MindmapCanvas"; import MindmapCanvas from "../../../components/MindmapCanvas";
import ChatPanel from "@/components/ChatPanel"; import ChatPanel from "../../../components/ChatPanel";
import { getMindmap } from "@/lib/api"; import { getMindmap } from "../../../lib/api";
import type { Mindmap } from "@/types/mindmap"; import type { Mindmap } from "../../../types/mindmap";
type TriggerRequest = { type TriggerRequest = {
id: number; id: number;

View File

@@ -1,15 +1,15 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import MarkdownMessage from "@/components/MarkdownMessage"; import MarkdownMessage from "./MarkdownMessage";
import { sendChatMessage } from "@/lib/api"; import { sendChatMessage } from "../lib/api";
import { import {
parseJsonSafely, parseJsonSafely,
splitSseEvents, splitSseEvents,
type TencentErrorEvent, type TencentErrorEvent,
type TencentReplyEvent, type TencentReplyEvent,
} from "@/lib/tencentSse"; } from "../lib/tencentSse";
import type { ChatMessage } from "@/types/mindmap"; import type { ChatMessage } from "../types/mindmap";
type TriggerRequest = { type TriggerRequest = {
id: number; id: number;

View File

@@ -1,11 +1,11 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactFlow, { Background, Controls, MiniMap, type ReactFlowInstance } from "reactflow"; import ReactFlow, { Background, Controls, MiniMap, type ReactFlowInstance } from "reactflow";
import "reactflow/dist/style.css"; import "reactflow/dist/style.css";
import MindmapNodeCard from "@/components/MindmapNodeCard"; import MindmapNodeCard from "./MindmapNodeCard";
import { treeToGraph } from "@/lib/treeToGraph"; import { treeToGraph } from "../lib/treeToGraph";
import type { MindmapNode } from "@/types/mindmap"; import type { MindmapNode } from "../types/mindmap";
type MindmapCanvasProps = { type MindmapCanvasProps = {
tree: MindmapNode; tree: MindmapNode;

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { memo, useCallback, type MouseEvent } from "react"; import { memo, useCallback, type MouseEvent } from "react";
import { Handle, Position, type NodeProps } from "reactflow"; import { Handle, Position, type NodeProps } from "reactflow";
import type { GraphNodeData } from "@/types/mindmap"; import type { GraphNodeData } from "../types/mindmap";
function MindmapNodeCard({ data }: NodeProps<GraphNodeData>) { function MindmapNodeCard({ data }: NodeProps<GraphNodeData>) {
const isRoot = data.level === 0; const isRoot = data.level === 0;

60
frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,60 @@
import type { CreateMindmapPayload, Mindmap } from "../types/mindmap";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
cache: "no-store",
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || "请求失败");
}
return (await response.json()) as T;
}
export async function createMindmap(
payload: CreateMindmapPayload,
): Promise<Mindmap> {
return request<Mindmap>("/api/mindmaps", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function getMindmap(uniqueId: string): Promise<Mindmap> {
return request<Mindmap>(`/api/mindmaps/${uniqueId}`);
}
export async function sendChatMessage(
sessionId: string,
content: string,
visitorBizId: string = "default_visitor",
signal?: AbortSignal,
): Promise<Response> {
const response = await fetch(`${API_BASE_URL}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal,
body: JSON.stringify({
session_id: sessionId,
content,
visitor_biz_id: visitorBizId,
}),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || "请求失败");
}
return response;
}

View File

@@ -0,0 +1,77 @@
export type SseEvent = {
event: string;
data: string;
};
export type TencentReplyEvent = {
type?: "reply";
payload?: {
content?: string;
is_evil?: boolean;
is_final?: boolean;
is_from_self?: boolean;
is_llm_generated?: boolean;
};
};
export type TencentErrorEvent = {
type?: "error";
error?: {
code?: number;
message?: string;
};
message?: string;
status?: number;
};
export function parseJsonSafely<T>(value: string): T | null {
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
export function splitSseEvents(buffer: string): {
events: SseEvent[];
rest: string;
} {
const blocks = buffer.split(/\r?\n\r?\n/);
const rest = blocks.pop() ?? "";
const events = blocks
.map(parseSseEventBlock)
.filter((event): event is SseEvent => event !== null);
return { events, rest };
}
function parseSseEventBlock(block: string): SseEvent | null {
const lines = block.split(/\r?\n/);
let eventName = "message";
const dataLines: string[] = [];
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!line || line.startsWith(":")) {
continue;
}
if (line.startsWith("event:")) {
eventName = line.slice(6).trim();
continue;
}
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart());
}
}
if (dataLines.length === 0) {
return null;
}
return {
event: eventName,
data: dataLines.join("\n"),
};
}

129
frontend/lib/treeToGraph.ts Normal file
View File

@@ -0,0 +1,129 @@
import type { Edge, Node } from "reactflow";
import { Position } from "reactflow";
import type { GraphData, GraphNodeData, MindmapNode } from "../types/mindmap";
const LEVEL_GAP = 320;
const SIBLING_GAP = 120;
const PADDING_X = 80;
const PADDING_Y = 100;
type PositionMap = Map<string, { x: number; y: number }>;
type TreeToGraphOptions = {
expandedNodeIds?: Set<string>;
onToggleNode?: (nodeId: string) => void;
onOpenNodeChat?: (nodeLabel: string) => void;
};
function getVisibleChildren(
node: MindmapNode,
expandedNodeIds: Set<string>,
): MindmapNode[] {
if (node.children.length === 0) {
return [];
}
return expandedNodeIds.has(node.id) ? node.children : [];
}
function assignPositions(
node: MindmapNode,
positions: PositionMap,
leafIndexRef: { value: number },
expandedNodeIds: Set<string>,
): number {
const visibleChildren = getVisibleChildren(node, expandedNodeIds);
if (visibleChildren.length === 0) {
const y = leafIndexRef.value * SIBLING_GAP;
leafIndexRef.value += 1;
positions.set(node.id, { x: node.level * LEVEL_GAP, y });
return y;
}
const childYs = visibleChildren.map((child) =>
assignPositions(child, positions, leafIndexRef, expandedNodeIds),
);
const y =
childYs.reduce((total, current) => total + current, 0) / childYs.length;
positions.set(node.id, { x: node.level * LEVEL_GAP, y });
return y;
}
function buildGraph(
node: MindmapNode,
positions: PositionMap,
nodes: Node<GraphNodeData>[],
edges: Edge[],
expandedNodeIds: Set<string>,
options: TreeToGraphOptions,
): void {
const position = positions.get(node.id);
if (!position) {
return;
}
const hasChildren = node.children.length > 0;
const isExpanded = expandedNodeIds.has(node.id);
const onToggleNode = options.onToggleNode;
const onOpenNodeChat = options.onOpenNodeChat;
nodes.push({
id: node.id,
type: "mindmapNode",
position: {
x: position.x + PADDING_X,
y: position.y + PADDING_Y,
},
style: {
cursor: "default",
},
data: {
label: node.label,
level: node.level,
isLeaf: node.is_leaf,
hasChildren,
isExpanded,
onToggle: hasChildren && onToggleNode ? () => onToggleNode(node.id) : undefined,
onOpenChat: onOpenNodeChat ? () => onOpenNodeChat(node.label) : undefined,
},
sourcePosition: Position.Right,
targetPosition: Position.Left,
draggable: false,
selectable: false,
});
getVisibleChildren(node, expandedNodeIds).forEach((child) => {
edges.push({
id: `${node.id}-${child.id}`,
source: node.id,
target: child.id,
type: "smoothstep",
animated: false,
style: {
stroke: "#60a5fa",
strokeWidth: 2,
},
});
buildGraph(child, positions, nodes, edges, expandedNodeIds, options);
});
}
export function treeToGraph(
tree: MindmapNode,
options: TreeToGraphOptions = {},
): GraphData {
const positions: PositionMap = new Map();
const leafIndexRef = { value: 0 };
const nodes: Node<GraphNodeData>[] = [];
const edges: Edge[] = [];
const expandedNodeIds = options.expandedNodeIds ?? new Set([tree.id]);
assignPositions(tree, positions, leafIndexRef, expandedNodeIds);
buildGraph(tree, positions, nodes, edges, expandedNodeIds, options);
return { nodes, edges };
}

View File

@@ -18,8 +18,9 @@
"name": "next" "name": "next"
} }
], ],
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./*"] "@/*": ["*"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],

393
宝塔面板部署指南.md Normal file
View File

@@ -0,0 +1,393 @@
# Interactive Mindmap 宝塔部署指南
## 1. 当前部署形态
这份文档按当前实际部署方式整理:
- 前端放在宝塔 `Node 项目`
- 后端放在宝塔 `Python 项目`
- 当前直接通过公网 IP + 原始端口访问
- 前端地址:`http://公网IP:3000`
- 后端地址:`http://公网IP:8000`
以下示例以当前服务器目录为例:
```bash
/mindmap/minimap
```
如果你的实际目录不同,把文档里的路径替换成你的真实路径即可。
## 2. 当前推荐结构
### 前端
- 项目目录:`/mindmap/minimap/frontend`
- 运行方式:宝塔 `Node 项目`
- 运行端口:`3000`
- Node 版本:建议 `20`
- 启动命令:`npm run start -- -p 3000`
### 后端
- 项目目录:`/mindmap/minimap/backend`
- 运行方式:宝塔 `Python 项目`
- Python 版本:建议 `3.11`
- 运行端口:`8000`
- 启动命令:`uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers`
### 数据库
- 使用 SQLite
- 数据文件:`/mindmap/minimap/backend/data/mindmap.db`
## 3. 部署前需要确认的代码行为
这个项目里有几个配置不能写错:
- 后端环境变量名是 `BOT_APP_KEY`
- 后端返回脑图链接依赖 `FRONTEND_BASE_URL`
- 前端请求后端依赖 `NEXT_PUBLIC_API_BASE_URL`
- 前端详情页路径是 `/mindmap/[id]`
- 后端创建脑图接口是 `POST /api/mindmaps`
- 后端对话接口是 `POST /api/chat`
对应代码文件:
- [backend/app/config.py](./backend/app/config.py)
- [backend/app/routers/mindmaps.py](./backend/app/routers/mindmaps.py)
- [frontend/lib/api.ts](./frontend/lib/api.ts)
## 4. 前端部署到宝塔 Node 项目
## 4.1 前端目录
前端目录:
```bash
/mindmap/minimap/frontend
```
## 4.2 前端环境变量
在前端目录创建或修改 `.env.production`
```env
NEXT_PUBLIC_API_BASE_URL=http://你的公网IP:8000
```
说明:
- 当前前端直接访问后端 `8000` 端口,所以这里不能留空
- 只要改过这个值,就必须重新构建前端
## 4.3 安装依赖和构建
宝塔终端执行:
```bash
cd /mindmap/minimap/frontend
npm ci
rm -rf .next
npm run build
```
如果构建失败,先看本文末尾的“常见问题”。
## 4.4 在宝塔中配置 Node 项目
进入宝塔:
- `网站`
- `Node 项目`
- 找到或新建前端项目
建议配置:
- 项目名称:`mindmap-frontend`
- 项目目录:`/mindmap/minimap/frontend`
- Node 版本:`20`
- 运行端口:`3000`
- 启动方式:`PM2`
如果界面需要填写启动命令,建议使用:
```bash
npm run start -- -p 3000
```
如果界面要求先执行构建,请先完成:
```bash
npm run build
```
## 4.5 前端验证
浏览器访问:
```text
http://公网IP:3000
```
如果首页能打开,说明 Node 项目启动正常。
## 5. 后端部署到宝塔 Python 项目
## 5.1 后端目录
后端目录:
```bash
/mindmap/minimap/backend
```
## 5.2 后端环境变量
复制配置文件:
```bash
cd /mindmap/minimap/backend
cp .env.example .env
```
然后将 `.env` 改成类似这样:
```env
BOT_APP_KEY=你的腾讯云智能体AppKey
FRONTEND_BASE_URL=http://你的公网IP:3000
```
说明:
- `BOT_APP_KEY` 是腾讯云智能体平台 AppKey
- `FRONTEND_BASE_URL` 必须指向前端真实访问地址
- 当前前端在 `3000` 端口,所以这里必须带 `:3000`
## 5.3 在宝塔中配置 Python 项目
进入宝塔:
- `Python 项目`
- 找到或新建后端项目
建议配置:
- 项目名称:`mindmap-backend`
- 项目目录:`/mindmap/minimap/backend`
- Python 版本:`3.11`
- 监听地址:`0.0.0.0`
- 监听端口:`8000`
如果界面支持自定义启动命令,建议填写:
```bash
uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers
```
如果宝塔 Python 项目需要先准备虚拟环境和依赖,执行:
```bash
cd /mindmap/minimap/backend
python3.11 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
```
## 5.4 后端验证
浏览器访问:
```text
http://公网IP:8000
```
或在终端执行:
```bash
curl http://公网IP:8000/
```
正常应返回:
```json
{"message":"Interactive Mindmap API is running"}
```
## 6. 宝塔防火墙与云安全组
当前部署方式需要对公网放行:
- `3000`
- `8000`
必须同时检查两层:
- 宝塔防火墙
- 云服务器安全组
只放行其中一层是不够的。
## 7. 当前部署方式下的验证步骤
## 7.1 验证首页
```text
http://公网IP:3000
```
## 7.2 验证后端接口
```bash
curl http://公网IP:8000/api/mindmaps/not-found
```
这里预期是后端返回 `404` JSON而不是超时或 HTML 页面。
## 7.3 创建一条测试思维导图
```bash
curl -X POST http://公网IP:8000/api/mindmaps \
-H "Content-Type: application/json" \
-d '{
"session_id": "deploy-test-session",
"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": true,
"children": []
},
{
"id": "node_2",
"label": "后端",
"parent_id": "node_0",
"level": 1,
"is_leaf": true,
"children": []
}
]
}
}'
```
成功后返回的 `url` 应类似:
```text
http://公网IP:3000/mindmap/xxxxxx
```
直接打开该链接,能正常看到脑图页,说明前后端都通了。
## 8. 后续如果想切到宝塔网站统一入口
你当前不需要这一步,但后续可以这样演进:
1. 前端 Node 项目改为监听 `127.0.0.1:3000`
2. 后端 Python 项目改为监听 `127.0.0.1:8000`
3. 再使用宝塔 `网站 -> 添加站点`
4. 用站点反向代理把 `/` 转到前端,把 `/api/` 转到后端
如果切到这种模式:
- 前端 `.env.production` 应改为 `NEXT_PUBLIC_API_BASE_URL=`
- 后端 `.env``FRONTEND_BASE_URL` 应改成统一入口地址
- 前端需要重新构建
## 9. 常见问题
## 9.1 前端能打开,但接口请求错地址
检查:
```env
NEXT_PUBLIC_API_BASE_URL=http://你的公网IP:8000
```
只要这个值改过,就重新执行:
```bash
cd /mindmap/minimap/frontend
rm -rf .next
npm run build
```
然后在宝塔里重启 Node 项目。
## 9.2 后端返回的脑图链接不对
检查:
```env
FRONTEND_BASE_URL=http://你的公网IP:3000
```
如果这里错了API 返回的 `url` 就会错。
## 9.3 前端构建时报 `Can't resolve ../lib/api`
这通常不是 Next.js 本身问题,而是服务器代码不完整。
先确认这些文件在服务器上存在:
```text
/mindmap/minimap/frontend/lib/api.ts
/mindmap/minimap/frontend/lib/tencentSse.ts
/mindmap/minimap/frontend/lib/treeToGraph.ts
/mindmap/minimap/frontend/types/mindmap.ts
```
如果缺文件,优先检查 Git 同步是否完整。
## 9.4 Git push 后服务器代码不完整
这个项目之前踩过一个典型问题:
- 根目录 `.gitignore` 中如果写了 `lib/`
- 会把 `frontend/lib/` 一起忽略掉
正确写法应该是:
```gitignore
/lib/
/lib64/
```
这样只忽略仓库根目录的 `lib`,不会误伤 `frontend/lib`
## 9.5 公网访问不到
优先检查:
- 宝塔防火墙是否放行 `3000``8000`
- 云安全组是否放行 `3000``8000`
- Node 项目是否运行中
- Python 项目是否运行中
## 9.6 浏览器提示不安全
当前是:
- 公网 IP
- HTTP
- 非 80/443 端口
浏览器提示“不安全”是正常现象,不代表服务没有跑起来。
## 10. 当前最稳的运维方式
按你现在的实际情况,最稳的方式就是保持:
1. 前端继续放在宝塔 `Node 项目`
2. 后端继续放在宝塔 `Python 项目`
3. 继续用 `3000/8000` 直接验证
4. 等功能稳定后,再考虑加宝塔网站做统一入口

383
腾讯云工作流说明.md Normal file
View File

@@ -0,0 +1,383 @@
# 腾讯云思维导图工作流说明
## 1. 工作流目标
当前工作流的目标是:
1. 接收用户主题与会话 ID
2. 通过大模型生成思维导图内容
3. 将内容整理为合法 JSON 字符串
4. 对 JSON 做校验和修复
5. 将 JSON 字符串反序列化为对象
6. 调用后端工具保存脑图
7. 将生成后的访问链接回复给用户
这条链路的关键点是:
- 大模型节点输出保持为 `string`
- 插件节点接收的 `mindmap_json` 必须是 `object`
- 所以中间必须经过一次 `变量转换节点 -> JSON 反序列化`
## 2. 当前工作流节点
根据当前画布,节点顺序为:
1. `开始`
2. `生成思维导图内容`
3. `生成思维导图内容json`
4. `检查json`
5. `变量转换1`
6. `生成前端页面`
7. `回复1`
8. `结束`
## 3. 节点职责说明
## 3.1 开始
作用:
- 接收工作流启动输入
建议输入变量:
- `topic`
- `session_id`
说明:
- `topic` 用于告诉大模型本次脑图主题
- `session_id` 用于插件调用时传给后端接口
## 3.2 生成思维导图内容
节点类型:
- 大模型节点
作用:
- 根据用户主题先生成一版结构化脑图内容
建议输出:
- `Output.Content`:文本内容
建议:
- 这一层可以先输出脑图提纲或中间描述
- 不要求它直接输出最终可落库的 JSON
## 3.3 生成思维导图内容json
节点类型:
- 大模型节点
作用:
- 将上一节点的内容转换成思维导图 JSON 字符串
建议输出:
- `Output.Content`JSON 字符串
这一节点必须满足:
- 只输出纯 JSON
- 不要输出解释文字
- 不要输出 markdown 代码块
- 不要输出前后缀说明
建议要求它输出的格式为:
```json
{
"id": "node_0",
"label": "主题",
"parent_id": null,
"level": 0,
"is_leaf": false,
"children": []
}
```
## 3.4 检查json
节点类型:
- 大模型节点
作用:
- 对上一节点生成的 JSON 字符串做检查和修复
建议输出:
- `Output.Content`:修复后的 JSON 字符串
建议规则:
1. 如果 JSON 合法且结构正确,原样返回
2. 如果不合法,修复后返回
3. 始终只输出 JSON 字符串
4. 不输出解释文字
这一节点输出仍然应该是 `string`,不要直接改成 `JSON` 类型输出。
## 3.5 变量转换1
节点类型:
- 变量转换节点
当前建议配置:
- 转换类型:`JSON`
- 转换方式:`JSON 反序列化`
- 转换变量:`检查json.Output.Content`
作用:
-`检查json` 节点输出的 JSON 字符串反序列化为对象
为什么必须有这一步:
- 大模型节点输出的是字符串
- 插件节点里的 `mindmap_json` 入参需要的是 `object`
该节点默认输出:
- `Output`
- `Error`
后续插件节点应引用:
- `变量转换1.Output`
不要引用:
- `变量转换1.Error`
## 3.6 生成前端页面
节点类型:
- 插件节点
作用:
- 调用后端 API 创建并保存思维导图
- 返回脑图访问链接
当前插件接口建议:
- 调用地址:`http://公网IP:8000/api/mindmaps`
- 请求方式:`POST`
- Content-Type`application/json`
插件输入参数应配置为:
- `session_id`
- `mindmap_json`
工作流内的输入映射建议如下:
### 输入参数 `session_id`
来源建议:
- `开始.session_id`
如果你是通过应用 API 调工作流,也可以使用:
- `API.session_id`
### 输入参数 `mindmap_json`
来源必须是:
- `变量转换1.Output`
也就是反序列化后的对象,而不是大模型输出的字符串。
## 3.7 回复1
节点类型:
- 回复节点
作用:
- 将生成后的脑图链接返回给用户
当前文案可以写成:
```text
思维导图已生成!请打开以下链接查看:{{url}}
```
如果你在插件节点里直接暴露的是完整路径变量,也可以写成:
```text
思维导图已生成!请打开以下链接查看:{{生成前端页面.Output.Body.url}}
```
如果还想带标题,可以改成:
```text
思维导图已生成!
标题:{{生成前端页面.Output.Body.title}}
链接:{{生成前端页面.Output.Body.url}}
```
## 3.8 结束
作用:
- 标记工作流结束
## 4. 当前工作流的数据流
可以理解为:
```text
开始
-> 生成思维导图内容
-> 生成思维导图内容json
-> 检查json
-> 变量转换1(JSON反序列化)
-> 生成前端页面(插件/API落库)
-> 回复1
-> 结束
```
其中最关键的一段是:
```text
检查json.Output.Content (string)
-> 变量转换1.Output (object)
-> 生成前端页面.mindmap_json
```
## 5. 插件节点推荐配置
如果你使用当前项目现有后端接口,插件建议如下:
### 工具基础信息
- 工具名称:`create_mindmap`
- 调用地址:`http://公网IP:8000/api/mindmaps`
- 请求方法:`POST`
- 请求体格式:`JSON`
### 输入参数
- `body.session_id``string`
- `body.mindmap_json``object`
### 输出参数
- `url``string`
- `title``string`
- `unique_id``string`
如果平台输出变量是以返回体路径显示,也可以直接使用:
- `Output.Body.url`
- `Output.Body.title`
- `Output.Body.unique_id`
## 6. 当前工作流下各节点变量关系
| 节点 | 关键输入 | 关键输出 | 备注 |
|---|---|---|---|
| 开始 | `topic``session_id` | 输入变量本身 | 作为整条链路源头 |
| 生成思维导图内容 | `topic` | `Output.Content` | 生成脑图内容 |
| 生成思维导图内容json | 上一节点内容 | `Output.Content` | 输出 JSON 字符串 |
| 检查json | 上一节点 JSON 字符串 | `Output.Content` | 修复后的 JSON 字符串 |
| 变量转换1 | `检查json.Output.Content` | `Output``Error` | `Output` 为 object |
| 生成前端页面 | `session_id``mindmap_json` | `Output.Body.url` 等 | 调用插件/API |
| 回复1 | `url` | 文本回复 | 回复用户链接 |
## 7. 当前工作流的关键注意点
## 7.1 不要让大模型节点直接输出 object 给插件
当前这条链里,最稳的方式仍然是:
- 大模型输出 `string`
- 变量转换节点做 `JSON 反序列化`
- 插件节点接收 `object`
## 7.2 插件节点的 `mindmap_json` 必须吃反序列化后的结果
正确:
- `变量转换1.Output`
错误:
- `检查json.Output.Content`
- `生成思维导图内容json.Output.Content`
因为这两者还是字符串。
## 7.3 插件节点的 `session_id` 不要写死
建议引用:
- `开始.session_id`
-`API.session_id`
这样才能保证每次生成的脑图都能和对应会话关联。
## 7.4 当前工作流还缺少显式异常分支
建议后续补一个条件判断:
1. 如果 `变量转换1.Error` 不为空直接回复“JSON 解析失败”
2. 如果 `生成前端页面.Error` 不为空,直接回复“脑图保存失败”
当前不加也能跑,但可维护性会差一些。
## 8. 建议的后续优化
如果你后面想继续提升稳定性,优先做这几件事:
1.`检查json` 节点里进一步收紧输出约束,只允许返回纯 JSON 字符串
2. 在工作流里增加错误分支,处理反序列化失败和插件调用失败
3. 插件输出中除了 `url` 外,再将 `title` 一并回复给用户
4. 如果后面模型经常输出不稳定,可以增加代码节点做 schema 校验
## 9. 当前工作流与项目接口的对应关系
当前插件节点 `生成前端页面` 对应后端接口:
- [backend/app/routers/mindmaps.py](./backend/app/routers/mindmaps.py)
接口实际请求体结构为:
```json
{
"session_id": "string",
"mindmap_json": {
"id": "node_0",
"label": "主题",
"parent_id": null,
"level": 0,
"is_leaf": false,
"children": []
}
}
```
接口实际返回中至少会包含:
```json
{
"unique_id": "string",
"title": "string",
"url": "string"
}
```