main
mula.liu 2026-03-19 15:43:55 +08:00
parent b64ade0525
commit d298174439
2 changed files with 223 additions and 7 deletions

View File

@ -0,0 +1,184 @@
# NexDocs v0.9.6 升级日志
## 版本信息
- 版本号:`v0.9.6`
- 更新时间:`2026-03-11`
## 本次升级摘要
`v0.9.6` 主要完成了以下升级:
1. 新增后端内集成的 MCP Server 能力
2. 新增用户级 MCP 凭证管理
3. 打通通过 Nginx 统一入口访问 MCP
4. 调整部署运行环境到 Python 3.12
5. 将 MySQL、Redis 数据目录改为项目根目录 `storage/` 持久化
6. 优化个人中心与文档页面部分交互体验
## 功能升级
### 1. MCP Server 内集成
系统已支持直接在 backend 中提供 MCP `streamableHttp` 服务,不再依赖项目根目录下的独立 `mcp_server/` 进程。
当前 MCP 入口:
```text
/mcp/
```
当前支持的工具:
1. `list_created_projects`:列出当前用户的项目
2. `get_project_tree`:列出项目文件树结构
3. `get_file`:获取指定文件
4. `create_file`:在项目中创建新文件
5. `update_file`:修改指定文件
6. `delete_file`:删除指定文件
### 2. MCP 认证与用户凭证管理
新增基于 `X-Bot-Id``X-Bot-Secret` 的 MCP 认证模型。
能力包括:
- 每个用户可拥有自己的 MCP 凭证
- 通过数据库表 `mcp_bots` 维护 bot 与用户的映射
- 支持在个人中心查看和重新生成凭证
- 服务端自动按凭证映射到对应 NexDocs 用户身份执行工具
相关接口:
- `GET /api/v1/auth/mcp-credentials`
- `POST /api/v1/auth/mcp-credentials/rotate-secret`
### 3. Nginx 统一入口支持 MCP
前端 Nginx 已新增 `/mcp/` 反向代理配置。
现在在只暴露一个公网入口端口的部署模式下,可以通过统一入口访问:
```text
http(s)://<host>/mcp/
```
不再要求调用端直连 backend 容器端口。
## 前端与交互优化
### 1. 个人中心布局调整
个人中心由上下结构调整为左右结构:
- 左侧为纵向导航
- 右侧为内容区域
- 移动端会自动回退为上下布局
### 2. 文档浏览/编辑切换优化
本轮已对项目文档页做过一组交互统一:
- 浏览/编辑模式切换样式统一
- 页面头部高度与操作区对齐
- 切换时保留当前文件上下文
- 登录跳转回原目标页的逻辑补齐
### 3. 认证异常提示优化
对未登录和 token 失效场景做了重复错误提示抑制:
- 避免并发请求弹出多条重复 Toast
- 统一 401 处理与跳转逻辑
## 部署与运行环境变更
### 1. Python 运行环境升级
backend 运行环境已调整为:
```text
Python 3.12
```
原因:
- MCP Python SDK 需要 Python 3.10+
- 当前版本已按 Python 3.12 完成调试与验证
### 2. Docker 构建链调整
backend Docker 镜像构建已做以下处理:
- 基础镜像切换为 `python:3.12-slim`
- 增加 pip 构建工具升级
- pip 安装支持国内源失败后回退官方源
- Nginx 已补充 `/mcp/` 代理配置
### 3. 数据持久化目录调整
本次升级将 MySQL、Redis 的数据目录改为挂载到项目根目录下的 `storage/`
```text
storage/
├── mysql/
├── redis/
├── projects/
└── temp/
```
当前挂载关系:
- `storage/mysql -> /var/lib/mysql`
- `storage/redis -> /data`
- `storage -> /data/nex_docus_store`
这样做的目的:
- 宿主机可直接看到数据库与缓存数据目录
- 便于整体备份
- 避免数据只留在 Docker named volume 中
## 升级迁移注意事项
### 1. MCP 调用地址调整
如果原先通过 backend 端口直连 MCP可以继续使用。
如果当前部署是通过前端 Nginx 暴露统一入口,推荐改为:
```text
http(s)://<host>/mcp/
```
调用头保持不变:
- `X-Bot-Id`
- `X-Bot-Secret`
### 部署与文档
- [docker-compose.yml](/Users/jiliu/工作/projects/NexDocus/docker-compose.yml)
- [MCP_HTTP_INTEGRATION.md](/Users/jiliu/工作/projects/NexDocus/docs/MCP_HTTP_INTEGRATION.md)
## 升级建议
建议升级到 `v0.9.6` 后按以下顺序验证:
1. 验证 backend `/health` 是否正常
2. 验证统一入口 `/api/``/mcp/` 是否可访问
3. 验证 MCP client 是否能正常完成 `initialize`
4. 验证个人中心的 MCP 凭证展示与轮换功能

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Space, Dropdown, Empty } from 'antd'
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd'
import { VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, CloseOutlined, MenuOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@ -41,6 +41,7 @@ function DocumentPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [fileTree, setFileTree] = useState([])
const [selectedFile, setSelectedFile] = useState('')
const [selectedNodeKey, setSelectedNodeKey] = useState('')
const [markdownContent, setMarkdownContent] = useState('')
const [loading, setLoading] = useState(false)
const [openKeys, setOpenKeys] = useState([])
@ -122,6 +123,7 @@ function DocumentPage() {
if (fileParam) {
if (fileParam !== selectedFile) {
setSelectedFile(fileParam)
setSelectedNodeKey(fileParam)
//
const parts = fileParam.split('/')
@ -156,6 +158,7 @@ function DocumentPage() {
const readmeNode = findReadme(fileTree)
if (readmeNode) {
setSelectedFile(readmeNode.key)
setSelectedNodeKey(readmeNode.key)
updateFileParam(readmeNode.key)
loadMarkdown(readmeNode.key)
}
@ -258,6 +261,21 @@ function DocumentPage() {
return null
}
const findNodeByKey = (nodes, key) => {
for (const node of nodes) {
if (node.key === key) {
return node
}
if (node.children?.length) {
const found = findNodeByKey(node.children, key)
if (found) {
return found
}
}
}
return null
}
//
const convertTreeToMenuItems = (nodes) => {
return nodes.map((node) => {
@ -270,6 +288,7 @@ function DocumentPage() {
key: node.key,
label: node.title,
icon: <FolderOutlined />,
onTitleClick: () => setSelectedNodeKey(node.key),
children: node.children ? convertTreeToMenuItems(node.children) : [],
}
} else if (node.title && node.title.endsWith('.md')) {
@ -342,6 +361,7 @@ function DocumentPage() {
//
const handleMenuClick = ({ key }) => {
setSelectedFile(key)
setSelectedNodeKey(key)
updateFileParam(key)
// PDF
@ -443,6 +463,7 @@ function DocumentPage() {
//
setSelectedFile(targetPath)
setSelectedNodeKey(targetPath)
updateFileParam(targetPath)
if (isPdf) {
@ -635,6 +656,12 @@ function DocumentPage() {
//
const handleShare = async () => {
const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null
if (selectedNode && !selectedNode.isLeaf) {
Toast.warning('提示', '当前选中的是文件夹,不能直接分享,请选择具体文件后再试')
return
}
try {
const res = await getProjectShareInfo(projectId)
setShareInfo(res.data)
@ -650,9 +677,14 @@ function DocumentPage() {
//
const handleCopyLink = async () => {
if (!shareInfo) return
const shareTargetFile = selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf
? selectedNodeKey
: ''
let fullUrl = `${window.location.origin}${shareInfo.share_url}`
if (selectedFile) {
fullUrl += `?file=${encodeURIComponent(selectedFile)}`
if (shareTargetFile) {
fullUrl += `?file=${encodeURIComponent(shareTargetFile)}`
}
try {
@ -867,7 +899,7 @@ function DocumentPage() {
{filteredTreeData.length > 0 ? (
<Menu
mode="inline"
selectedKeys={[selectedFile]}
selectedKeys={selectedNodeKey ? [selectedNodeKey] : []}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
@ -993,11 +1025,11 @@ function DocumentPage() {
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div>
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>
{selectedFile ? '当前文件分享链接' : '项目分享链接'}
{selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf ? '当前文件分享链接' : '项目分享链接'}
</label>
<Input
value={selectedFile
? `${window.location.origin}${shareInfo.share_url}?file=${encodeURIComponent(selectedFile)}`
value={selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf
? `${window.location.origin}${shareInfo.share_url}?file=${encodeURIComponent(selectedNodeKey)}`
: `${window.location.origin}${shareInfo.share_url}`
}
readOnly