Compare commits

..

2 Commits

Author SHA1 Message Date
mula.liu c005964b5d v0.9.6 2026-03-11 15:44:31 +08:00
mula.liu b78fc3b269 0.9.6 2026-03-11 15:27:52 +08:00
28 changed files with 1559 additions and 136 deletions

View File

@ -1,5 +1,5 @@
# 使用国内镜像加速 # 使用国内镜像加速
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/python:3.9-slim FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/python:3.12-slim
# 设置工作目录 # 设置工作目录
WORKDIR /app WORKDIR /app

View File

@ -11,6 +11,7 @@ import os
import uuid import uuid
from pathlib import Path from pathlib import Path
import aiofiles import aiofiles
import secrets
from app.core.database import get_db from app.core.database import get_db
from app.core.security import verify_password, get_password_hash, create_access_token from app.core.security import verify_password, get_password_hash, create_access_token
@ -18,8 +19,17 @@ from app.core.deps import get_current_user
from app.core.redis_client import TokenCache from app.core.redis_client import TokenCache
from app.core.config import settings from app.core.config import settings
from app.models.user import User from app.models.user import User
from app.models.mcp_bot import MCPBot
from app.models.role import Role, UserRole from app.models.role import Role, UserRole
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token, ChangePassword, UserUpdate from app.schemas.user import (
UserCreate,
UserLogin,
UserResponse,
Token,
ChangePassword,
UserUpdate,
MCPBotCredentialResponse,
)
from app.schemas.response import success_response, error_response from app.schemas.response import success_response, error_response
from app.services.log_service import log_service from app.services.log_service import log_service
from app.core.enums import OperationType, ResourceType from app.core.enums import OperationType, ResourceType
@ -28,6 +38,14 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def generate_bot_id() -> str:
return f"nexbot_{uuid.uuid4().hex[:16]}"
def generate_bot_secret() -> str:
return f"nxbotsec_{secrets.token_urlsafe(24)}"
@router.post("/register", response_model=dict) @router.post("/register", response_model=dict)
async def register( async def register(
user_in: UserCreate, user_in: UserCreate,
@ -215,6 +233,58 @@ async def logout(
return success_response(message="退出成功") return success_response(message="退出成功")
@router.get("/mcp-credentials", response_model=dict)
async def get_or_create_mcp_credentials(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get or create MCP credentials for the current user."""
result = await db.execute(select(MCPBot).where(MCPBot.user_id == current_user.id))
mcp_bot = result.scalar_one_or_none()
if not mcp_bot:
mcp_bot = MCPBot(
user_id=current_user.id,
bot_id=generate_bot_id(),
bot_secret=generate_bot_secret(),
status=1,
)
db.add(mcp_bot)
await db.commit()
await db.refresh(mcp_bot)
data = MCPBotCredentialResponse.model_validate(mcp_bot)
return success_response(data=data.model_dump(), message="获取 MCP 凭证成功")
@router.post("/mcp-credentials/rotate-secret", response_model=dict)
async def rotate_mcp_secret(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Rotate MCP secret for the current user."""
result = await db.execute(select(MCPBot).where(MCPBot.user_id == current_user.id))
mcp_bot = result.scalar_one_or_none()
if not mcp_bot:
mcp_bot = MCPBot(
user_id=current_user.id,
bot_id=generate_bot_id(),
bot_secret=generate_bot_secret(),
status=1,
)
db.add(mcp_bot)
else:
mcp_bot.bot_secret = generate_bot_secret()
mcp_bot.status = 1
await db.commit()
await db.refresh(mcp_bot)
data = MCPBotCredentialResponse.model_validate(mcp_bot)
return success_response(data=data.model_dump(), message="MCP Secret 已重新生成")
@router.post("/upload-avatar", response_model=dict) @router.post("/upload-avatar", response_model=dict)
async def upload_avatar( async def upload_avatar(
file: UploadFile = File(...), file: UploadFile = File(...),

View File

@ -0,0 +1,3 @@
from app.mcp.server import create_mcp_http_app, get_mcp_session_manager, MCPHeaderAuthApp
__all__ = ["create_mcp_http_app", "get_mcp_session_manager", "MCPHeaderAuthApp"]

View File

@ -0,0 +1,20 @@
"""
Context for MCP requests authenticated via bot headers.
"""
from __future__ import annotations
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Optional
@dataclass
class MCPRequestContext:
bot_id: str
user_id: int
current_mcp_request: ContextVar[Optional[MCPRequestContext]] = ContextVar(
"current_mcp_request",
default=None,
)

View File

@ -0,0 +1,344 @@
"""
Backend-integrated MCP Streamable HTTP server.
"""
from __future__ import annotations
from datetime import datetime
import hmac
from pathlib import Path
from typing import Any, Dict, List
from fastapi import HTTPException, Response
from sqlalchemy import select
try:
from mcp.server.fastmcp import FastMCP
except ImportError: # pragma: no cover - runtime dependency
FastMCP = None
from app.core.database import AsyncSessionLocal
from app.models.mcp_bot import MCPBot
from app.models.project import Project, ProjectMember
from app.models.user import User
from app.schemas.project import ProjectResponse
from app.services.notification_service import notification_service
from app.services.search_service import search_service
from app.services.storage import storage_service
from app.services.log_service import log_service
from app.api.v1.projects import get_document_count
from app.api.v1.files import check_project_access
from app.core.config import settings
from app.core.enums import OperationType
from app.mcp.context import MCPRequestContext, current_mcp_request
mcp = (
FastMCP(
"NexDocs MCP",
host=settings.HOST,
port=settings.PORT,
stateless_http=True,
json_response=True,
streamable_http_path="/",
)
if FastMCP
else None
)
async def _get_current_user(db) -> User:
ctx = current_mcp_request.get()
if ctx is None:
raise RuntimeError("MCP request context is missing.")
result = await db.execute(select(User).where(User.id == ctx.user_id, User.status == 1))
user = result.scalar_one_or_none()
if not user:
raise RuntimeError("Authenticated MCP user does not exist or is disabled.")
return user
async def _get_project_with_write_access(project_id: int, current_user: User, db):
return await check_project_access(project_id, current_user, db, require_write=True)
def _ensure_file_exists(file_path: Path, path: str) -> None:
if not file_path.exists():
raise HTTPException(status_code=404, detail=f"文件不存在: {path}")
if not file_path.is_file():
raise HTTPException(status_code=400, detail=f"目标不是文件: {path}")
def _ensure_file_not_exists(file_path: Path, path: str) -> None:
if file_path.exists():
raise HTTPException(status_code=400, detail=f"文件已存在: {path}")
async def _update_markdown_index(project_id: int, path: str, content: str) -> None:
if path.endswith(".md"):
await search_service.update_doc(project_id, path, Path(path).stem, content)
async def _remove_markdown_index(project_id: int, path: str) -> None:
if path.endswith(".md"):
await search_service.remove_doc(project_id, path)
if mcp is not None:
@mcp.tool(name="list_created_projects", description="Get projects created by the authenticated user.")
async def list_created_projects(keyword: str = "", limit: int = 100) -> List[Dict[str, Any]]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
result = await db.execute(
select(Project).where(Project.owner_id == current_user.id, Project.status == 1)
)
projects = result.scalars().all()
items = []
keyword_lower = keyword.strip().lower()
for project in projects:
project_dict = ProjectResponse.from_orm(project).dict()
project_dict["doc_count"] = get_document_count(project.storage_key)
if keyword_lower:
haystack = f"{project.name} {project.description or ''}".lower()
if keyword_lower not in haystack:
continue
items.append(project_dict)
return items[: max(limit, 0)]
@mcp.tool(name="get_project_tree", description="Get the directory tree of a specific project.")
async def get_project_tree(project_id: int) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project = await check_project_access(project_id, current_user, db)
project_root = storage_service.get_secure_path(project.storage_key)
tree = storage_service.generate_tree(project_root)
user_role = "owner"
if project.owner_id != current_user.id:
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id,
)
)
member = member_result.scalar_one_or_none()
if member:
user_role = member.role
return {
"tree": [item.model_dump() for item in tree],
"user_role": user_role,
"project_name": project.name,
"project_description": project.description,
}
@mcp.tool(name="get_file", description="Read a file from a specific project.")
async def get_file(project_id: int, path: str) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project = await check_project_access(project_id, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, path)
_ensure_file_exists(file_path, path)
content = await storage_service.read_file(file_path)
return {"path": path, "content": content}
@mcp.tool(name="create_file", description="Create a new file in a specific project path.")
async def create_file(project_id: int, path: str, content: str = "") -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project = await _get_project_with_write_access(project_id, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, path)
_ensure_file_not_exists(file_path, path)
await storage_service.write_file(file_path, content)
await _update_markdown_index(project_id, path, content)
await log_service.log_file_operation(
db=db,
operation_type=OperationType.CREATE_FILE,
project_id=project_id,
file_path=path,
user=current_user,
detail={"content_length": len(content), "source": "mcp"},
request=None,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title="项目文档创建",
content=(
f"项目 [{project.name}] 中的文档 [{path}] "
f"已被 {current_user.nickname or current_user.username} 通过 MCP 创建。"
),
link=f"/projects/{project_id}/docs?file={path}",
category="project",
)
return {
"message": "文件创建成功",
"project_id": project_id,
"path": path,
}
@mcp.tool(name="update_file", description="Update an existing file in a specific project path.")
async def update_file(project_id: int, path: str, content: str) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project = await _get_project_with_write_access(project_id, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, path)
_ensure_file_exists(file_path, path)
await storage_service.write_file(file_path, content)
await _update_markdown_index(project_id, path, content)
await log_service.log_file_operation(
db=db,
operation_type=OperationType.SAVE_FILE,
project_id=project_id,
file_path=path,
user=current_user,
detail={"content_length": len(content), "source": "mcp"},
request=None,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title="项目文档更新",
content=(
f"项目 [{project.name}] 中的文档 [{path}] "
f"已被 {current_user.nickname or current_user.username} 通过 MCP 更新。"
),
link=f"/projects/{project_id}/docs?file={path}",
category="project",
)
return {
"message": "文件更新成功",
"project_id": project_id,
"path": path,
}
@mcp.tool(name="delete_file", description="Delete an existing file in a specific project path.")
async def delete_file(project_id: int, path: str) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project = await _get_project_with_write_access(project_id, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, path)
_ensure_file_exists(file_path, path)
await storage_service.delete_file(file_path)
await _remove_markdown_index(project_id, path)
await log_service.log_file_operation(
db=db,
operation_type=OperationType.DELETE_FILE,
project_id=project_id,
file_path=path,
user=current_user,
detail={"source": "mcp"},
request=None,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title="项目文档删除",
content=(
f"项目 [{project.name}] 中的文档 [{path}] "
f"已被 {current_user.nickname or current_user.username} 通过 MCP 删除。"
),
category="project",
)
return {
"message": "文件删除成功",
"project_id": project_id,
"path": path,
}
def create_mcp_http_app():
"""Return the MCP streamable HTTP ASGI app."""
if mcp is None:
raise RuntimeError("Package 'mcp' is required to run the MCP endpoint.")
return mcp.streamable_http_app()
def get_mcp_session_manager():
"""Return the MCP streamable HTTP session manager."""
if mcp is None:
raise RuntimeError("Package 'mcp' is required to run the MCP endpoint.")
return mcp.session_manager
class MCPHeaderAuthApp:
"""ASGI wrapper that authenticates incoming MCP requests via bot headers."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
headers = {
key.decode("latin-1").lower(): value.decode("latin-1")
for key, value in scope.get("headers", [])
}
bot_id = headers.get("x-bot-id", "").strip()
bot_secret = headers.get("x-bot-secret", "").strip()
if not bot_id or not bot_secret:
response = Response(
content='{"error":"Missing X-Bot-Id or X-Bot-Secret"}',
status_code=401,
media_type="application/json",
)
await response(scope, receive, send)
return
async with AsyncSessionLocal() as db:
result = await db.execute(
select(MCPBot, User)
.join(User, User.id == MCPBot.user_id)
.where(MCPBot.bot_id == bot_id, MCPBot.status == 1, User.status == 1)
)
row = result.first()
if not row:
response = Response(
content='{"error":"Invalid MCP bot"}',
status_code=403,
media_type="application/json",
)
await response(scope, receive, send)
return
mcp_bot, user = row
if not hmac.compare_digest(mcp_bot.bot_secret, bot_secret):
response = Response(
content='{"error":"Invalid MCP secret"}',
status_code=403,
media_type="application/json",
)
await response(scope, receive, send)
return
mcp_bot.last_used_at = datetime.utcnow()
await db.commit()
token = current_mcp_request.set(MCPRequestContext(bot_id=bot_id, user_id=user.id))
try:
await self.app(scope, receive, send)
finally:
current_mcp_request.reset(token)

View File

@ -8,6 +8,7 @@ from app.models.menu import SystemMenu, RoleMenu
from app.models.project import Project, ProjectMember, ProjectMemberRole from app.models.project import Project, ProjectMember, ProjectMemberRole
from app.models.document import DocumentMeta from app.models.document import DocumentMeta
from app.models.log import OperationLog from app.models.log import OperationLog
from app.models.mcp_bot import MCPBot
__all__ = [ __all__ = [
"Base", "Base",
@ -21,4 +22,5 @@ __all__ = [
"ProjectMemberRole", "ProjectMemberRole",
"DocumentMeta", "DocumentMeta",
"OperationLog", "OperationLog",
"MCPBot",
] ]

View File

@ -0,0 +1,25 @@
"""
MCP bot credential model.
"""
from sqlalchemy import Column, BigInteger, String, DateTime, SmallInteger
from sqlalchemy.sql import func
from app.core.database import Base
class MCPBot(Base):
"""Stores MCP access credentials mapped to a NexDocs user."""
__tablename__ = "mcp_bots"
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="Bot credential ID")
user_id = Column(BigInteger, nullable=False, unique=True, index=True, comment="Owner user ID")
bot_id = Column(String(64), nullable=False, unique=True, index=True, comment="External MCP bot id")
bot_secret = Column(String(255), nullable=False, comment="External MCP bot secret")
status = Column(SmallInteger, default=1, index=True, comment="Status: 0-disabled 1-enabled")
last_used_at = Column(DateTime, comment="Last successful MCP access time")
created_at = Column(DateTime, server_default=func.now(), comment="Created at")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="Updated at")
def __repr__(self):
return f"<MCPBot(user_id={self.user_id}, bot_id='{self.bot_id}')>"

View File

@ -9,6 +9,8 @@ from app.schemas.user import (
UserLogin, UserLogin,
Token, Token,
ChangePassword, ChangePassword,
MCPBotCredentialResponse,
MCPBotCredentialCreateResponse,
) )
from app.schemas.project import ( from app.schemas.project import (
ProjectBase, ProjectBase,
@ -42,6 +44,8 @@ __all__ = [
"UserLogin", "UserLogin",
"Token", "Token",
"ChangePassword", "ChangePassword",
"MCPBotCredentialResponse",
"MCPBotCredentialCreateResponse",
# Project # Project
"ProjectBase", "ProjectBase",
"ProjectCreate", "ProjectCreate",

View File

@ -57,3 +57,21 @@ class ChangePassword(BaseModel):
"""修改密码 Schema""" """修改密码 Schema"""
old_password: str = Field(..., description="旧密码") old_password: str = Field(..., description="旧密码")
new_password: str = Field(..., min_length=6, max_length=50, description="新密码") new_password: str = Field(..., min_length=6, max_length=50, description="新密码")
class MCPBotCredentialResponse(BaseModel):
"""MCP bot credential response."""
bot_id: str
bot_secret: str
status: int
last_used_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class MCPBotCredentialCreateResponse(MCPBotCredentialResponse):
"""Returned when credential is created or rotated."""
message: Optional[str] = None

View File

@ -7,6 +7,15 @@ from contextlib import asynccontextmanager
from app.core.config import settings from app.core.config import settings
from app.core.redis_client import init_redis, close_redis from app.core.redis_client import init_redis, close_redis
from app.api.v1 import api_router from app.api.v1 import api_router
from app.mcp import create_mcp_http_app, get_mcp_session_manager, MCPHeaderAuthApp
try:
mcp_http_app = create_mcp_http_app()
mcp_session_manager = get_mcp_session_manager()
except RuntimeError:
mcp_http_app = None
mcp_session_manager = None
@asynccontextmanager @asynccontextmanager
@ -14,7 +23,11 @@ async def lifespan(app: FastAPI):
"""应用生命周期管理""" """应用生命周期管理"""
# 启动时初始化 Redis # 启动时初始化 Redis
await init_redis() await init_redis()
yield if mcp_session_manager is not None:
async with mcp_session_manager.run():
yield
else:
yield
# 关闭时清理 Redis 连接 # 关闭时清理 Redis 连接
await close_redis() await close_redis()
@ -40,6 +53,10 @@ app.add_middleware(
# 注册 API 路由 # 注册 API 路由
app.include_router(api_router, prefix="/api/v1") app.include_router(api_router, prefix="/api/v1")
# 挂载 MCP Streamable HTTP 入口
if mcp_http_app is not None:
app.mount("/mcp", MCPHeaderAuthApp(mcp_http_app))
@app.get("/") @app.get("/")
async def root(): async def root():

View File

@ -25,14 +25,13 @@ MarkupSafe==3.0.3
passlib==1.7.4 passlib==1.7.4
pyasn1==0.6.1 pyasn1==0.6.1
pycparser==2.23 pycparser==2.23
pydantic==2.5.3 pydantic>=2.11,<3
pydantic-settings==2.1.0 pydantic-settings>=2.6,<3
pydantic_core==2.14.6
PyMySQL==1.1.0 PyMySQL==1.1.0
python-dotenv==1.0.0 python-dotenv==1.0.0
python-jose==3.3.0 python-jose==3.3.0
python-magic==0.4.27 python-magic==0.4.27
python-multipart==0.0.6 python-multipart>=0.0.9
PyYAML==6.0.1 PyYAML==6.0.1
redis==5.0.1 redis==5.0.1
rsa==4.9.1 rsa==4.9.1
@ -40,7 +39,7 @@ six==1.17.0
SQLAlchemy==2.0.25 SQLAlchemy==2.0.25
starlette==0.35.1 starlette==0.35.1
typing_extensions==4.15.0 typing_extensions==4.15.0
uvicorn==0.27.0 uvicorn>=0.31.1
uvloop==0.22.1 uvloop==0.22.1
watchfiles==1.1.1 watchfiles==1.1.1
websockets==15.0.1 websockets==15.0.1
@ -48,3 +47,4 @@ Whoosh==2.7.4
markdown==3.5.2 markdown==3.5.2
weasyprint==61.2 weasyprint==61.2
pydyf<0.11.0 pydyf<0.11.0
mcp==1.26.0

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS `mcp_bots` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Bot credential ID',
`user_id` BIGINT NOT NULL COMMENT 'Owner user ID',
`bot_id` VARCHAR(64) NOT NULL COMMENT 'External MCP bot id',
`bot_secret` VARCHAR(255) NOT NULL COMMENT 'External MCP bot secret',
`status` TINYINT DEFAULT 1 COMMENT 'Status: 0-disabled 1-enabled',
`last_used_at` DATETIME DEFAULT NULL COMMENT 'Last successful MCP access time',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Created at',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Updated at',
UNIQUE KEY `uk_mcp_bots_user_id` (`user_id`),
UNIQUE KEY `uk_mcp_bots_bot_id` (`bot_id`),
INDEX `idx_mcp_bots_status` (`status`),
CONSTRAINT `fk_mcp_bots_user_id` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MCP bot credentials';

View File

@ -166,6 +166,22 @@ CREATE TABLE IF NOT EXISTS `operation_logs` (
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';
-- 10. MCP Bot 凭证表
CREATE TABLE IF NOT EXISTS `mcp_bots` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Bot credential ID',
`user_id` BIGINT NOT NULL COMMENT 'Owner user ID',
`bot_id` VARCHAR(64) NOT NULL COMMENT 'External MCP bot id',
`bot_secret` VARCHAR(255) NOT NULL COMMENT 'External MCP bot secret',
`status` TINYINT DEFAULT 1 COMMENT 'Status: 0-disabled 1-enabled',
`last_used_at` DATETIME DEFAULT NULL COMMENT 'Last successful MCP access time',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Created at',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Updated at',
UNIQUE KEY `uk_mcp_bots_user_id` (`user_id`),
UNIQUE KEY `uk_mcp_bots_bot_id` (`bot_id`),
INDEX `idx_mcp_bots_status` (`status`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MCP bot credentials';
-- 插入初始角色数据 -- 插入初始角色数据
INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES
('超级管理员', 'super_admin', '拥有系统所有权限', 1), ('超级管理员', 'super_admin', '拥有系统所有权限', 1),

View File

@ -0,0 +1,295 @@
# NexDocs MCP 使用文档
## 1. 方案说明
NexDocs 现在不再使用项目根目录下的独立 `mcp_server/`
当前方案为后端内集成 MCP
- 业务 REST API 继续走 `/api/v1/...`
- MCP 入口挂载在同一个 backend 服务上
- 传输协议为 `streamableHttp`
- MCP 地址为 `/mcp/`
- 认证方式为 `X-Bot-Id` + `X-Bot-Secret`
这意味着:
- 不需要单独部署一套 MCP 服务
- 不需要让 MCP client 传业务账号密码
- 不需要让远程 agent 访问本地脚本进程
## 2. 接口地址
MCP 对外地址:
```text
http://<backend-host>:<backend-port>/mcp/
```
例如:
```text
http://backend.internal:8000/mcp/
```
说明:
- 请直接使用带尾部 `/` 的地址
- 请求 `/mcp` 会被后端重定向到 `/mcp/`
- 未携带 bot 凭证时,访问 `/mcp/` 会返回 `401`
## 3. 认证模型
MCP client 只需要传两个 header
- `X-Bot-Id`
- `X-Bot-Secret`
后端会执行以下逻辑:
1. 根据 `X-Bot-Id` 查询 `mcp_bots`
2. 校验 `X-Bot-Secret`
3. 将该 bot 映射到 NexDocs 用户
4. 以该用户身份执行 MCP 工具
因此 client 不需要再传:
- NexDocs 用户名
- NexDocs 密码
- NexDocs access token
## 4. 用户如何获取凭证
每个用户可在个人中心管理自己的 MCP 凭证。
页面位置:
1. 登录 NexDocs
2. 打开个人中心
3. 进入 `MCP 接入` 标签页
可执行操作:
- 查看 `X-Bot-Id`
- 查看并复制 `X-Bot-Secret`
- 重新生成 `X-Bot-Secret`
后端接口:
- `GET /api/v1/auth/mcp-credentials`
- `POST /api/v1/auth/mcp-credentials/rotate-secret`
## 5. 当前支持的 MCP Tools
### 5.1 获取当前用户创建的项目列表
工具名:
```text
list_created_projects
```
参数:
- `keyword`: 可选,按项目名/描述过滤
- `limit`: 可选,默认 100
### 5.2 获取指定项目文件树
工具名:
```text
get_project_tree
```
参数:
- `project_id`: 必填
### 5.3 获取指定文件内容
工具名:
```text
get_file
```
参数:
- `project_id`: 必填
- `path`: 必填,项目内相对路径
### 5.4 在项目中创建新文件
工具名:
```text
create_file
```
参数:
- `project_id`: 必填
- `path`: 必填,项目内相对路径
- `content`: 可选,文件初始内容,默认空字符串
说明:
- 目标路径已存在时会报错
- 会自动创建缺失的上级目录
- 会校验项目写权限
- 写入 Markdown 文件时会更新搜索索引
- 会记录操作日志
- 会通知项目成员
### 5.5 修改指定文件
工具名:
```text
update_file
```
参数:
- `project_id`: 必填
- `path`: 必填,项目内相对路径
- `content`: 必填,新的文件内容
说明:
- 目标文件不存在时会报错
- 只允许更新文件,不允许更新目录
- 会校验项目写权限
- 写入 Markdown 文件时会更新搜索索引
- 会记录操作日志
- 会通知项目成员
### 5.6 删除指定文件
工具名:
```text
delete_file
```
参数:
- `project_id`: 必填
- `path`: 必填,项目内相对路径
说明:
- 目标文件不存在时会报错
- 只允许删除文件,不允许删除目录
- 删除 Markdown 文件时会同步删除搜索索引
- 会记录操作日志
- 会通知项目成员
## 6. 调用端配置示例
如果调用端内置的是 MCP client并支持 `streamableHttp`,可以这样配置:
```json
{
"tools": {
"mcpServers": {
"biz_mcp": {
"type": "streamableHttp",
"url": "http://backend.internal:8000/mcp/",
"headers": {
"X-Bot-Id": "nexbot_xxxxxxxxxxxxxxxx",
"X-Bot-Secret": "nxbotsec_xxxxxxxxxxxxxxxxxxxxxxxx"
},
"toolTimeout": 60
}
}
}
}
```
注意:
- `url` 建议使用 `/mcp/`
- `headers` 中不需要再放业务用户名密码
- `X-Bot-Secret` 只应发给受信任的调用端
## 7. 后端部署要求
### 7.1 Python 版本
后端运行环境需要 Python 3.12。
本地开发建议:
```bash
cd backend
/opt/homebrew/bin/python3.12 -m venv venv312
env -u HTTP_PROXY -u HTTPS_PROXY ./venv312/bin/pip install -r requirements.txt
./venv312/bin/uvicorn main:app --host 0.0.0.0 --port 8000
```
说明:
- 当前 backend 的 MCP 依赖要求 Python 3.10+
- 本项目已在本地按 Python 3.12 调试通过
### 7.2 Docker
`backend/Dockerfile` 已切换到 Python 3.12 基线。
### 7.3 数据表
`mcp_bots` 表需要存在。
如果你已经执行过建表脚本,这一步可以跳过。
相关文件:
- [create_mcp_bots_table.sql](/Users/jiliu/工作/projects/NexDocus/backend/scripts/create_mcp_bots_table.sql)
- [init_database.sql](/Users/jiliu/工作/projects/NexDocus/backend/scripts/init_database.sql)
## 8. 已验证结果
本地调试已验证:
- Python 3.12 环境可正常启动 backend
- `/health` 返回 `200`
- `/mcp/` 已挂载成功
- 未传 `X-Bot-Id` / `X-Bot-Secret` 时返回 `401`
调试使用地址:
```text
http://127.0.0.1:8012/mcp/
```
生产或测试环境请替换为你的 backend 实际域名或 IP。
## 9. 相关代码位置
- MCP 挂载入口:
[main.py](/Users/jiliu/工作/projects/NexDocus/backend/main.py)
- MCP 服务实现:
[server.py](/Users/jiliu/工作/projects/NexDocus/backend/app/mcp/server.py)
- MCP Bot 模型:
[mcp_bot.py](/Users/jiliu/工作/projects/NexDocus/backend/app/models/mcp_bot.py)
- 用户凭证管理接口:
[auth.py](/Users/jiliu/工作/projects/NexDocus/backend/app/api/v1/auth.py)
- 个人中心凭证管理页面:
[ProfilePage.jsx](/Users/jiliu/工作/projects/NexDocus/frontend/src/pages/Profile/ProfilePage.jsx)

View File

@ -57,6 +57,26 @@ export function changePassword(data) {
}) })
} }
/**
* 获取当前用户的 MCP 凭证
*/
export function getMcpCredentials() {
return request({
url: '/auth/mcp-credentials',
method: 'get',
})
}
/**
* 重新生成当前用户的 MCP Secret
*/
export function rotateMcpSecret() {
return request({
url: '/auth/mcp-credentials/rotate-secret',
method: 'post',
})
}
/** /**
* 上传用户头像 * 上传用户头像
*/ */

View File

@ -200,4 +200,4 @@ function AppHeader({ collapsed, onToggle, showLogo = true }) {
) )
} }
export default AppHeader export default AppHeader

View File

@ -0,0 +1,77 @@
.mode-switch {
position: relative;
display: inline-grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
align-items: center;
padding: 2px;
border-radius: 999px;
background: linear-gradient(180deg, #eef1f6 0%, #e7ebf2 100%);
border: 1px solid rgba(73, 104, 149, 0.2);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.72),
0 1px 2px rgba(10, 24, 46, 0.08);
flex-shrink: 0;
overflow: hidden;
}
.mode-switch-thumb {
position: absolute;
top: 2px;
left: 2px;
height: calc(100% - 4px);
width: calc((100% - 4px) / var(--option-count));
border-radius: 999px;
background: #ffffff;
border: 1px solid rgba(94, 128, 181, 0.22);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
transform: translateX(calc(var(--active-index) * 100%));
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
}
.mode-switch-option {
min-width: 44px;
height: 30px;
border: none;
border-radius: 999px;
background: transparent;
color: #4b5f7f;
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0 10px;
transition: color 0.2s ease, opacity 0.2s ease;
line-height: 1;
position: relative;
z-index: 1;
opacity: 0.95;
}
.mode-switch-option:hover {
color: #2c4467;
opacity: 1;
}
.mode-switch-option.active {
color: #1f3558;
opacity: 1;
}
body.dark .mode-switch {
background: linear-gradient(180deg, #2e3748 0%, #252d3b 100%);
border-color: rgba(137, 156, 186, 0.24);
}
body.dark .mode-switch-thumb {
background: #3a455a;
border-color: rgba(145, 174, 220, 0.28);
}
body.dark .mode-switch-option {
color: #9bb2d8;
}
body.dark .mode-switch-option:hover,
body.dark .mode-switch-option.active {
color: #e5eeff;
}

View File

@ -0,0 +1,46 @@
import './ModeSwitch.css'
function ModeSwitch({
value = 'view',
onChange,
viewLabel = '浏览',
editLabel = '编辑',
options,
ariaLabel = '模式切换',
}) {
const finalOptions = options || [
{ label: viewLabel, value: 'view' },
{ label: editLabel, value: 'edit' },
]
const activeIndex = Math.max(
0,
finalOptions.findIndex((item) => item.value === value)
)
return (
<div
className="mode-switch"
role="tablist"
aria-label={ariaLabel}
style={{
'--option-count': String(finalOptions.length),
'--active-index': String(activeIndex),
}}
>
<span className="mode-switch-thumb" aria-hidden="true" />
{finalOptions.map((item) => (
<button
key={item.value}
type="button"
className={`mode-switch-option ${value === item.value ? 'active' : ''}`}
aria-selected={value === item.value}
onClick={() => onChange?.(item.value)}
>
{item.label}
</button>
))}
</div>
)
}
export default ModeSwitch

View File

@ -1,11 +1,12 @@
import { Navigate } from 'react-router-dom' import { Navigate, useLocation } from 'react-router-dom'
import useUserStore from '@/stores/userStore'
function ProtectedRoute({ children }) { function ProtectedRoute({ children }) {
const location = useLocation()
const token = localStorage.getItem('access_token') const token = localStorage.getItem('access_token')
if (!token) { if (!token) {
return <Navigate to="/login" replace /> const returnTo = encodeURIComponent(`${location.pathname}${location.search}${location.hash}`)
return <Navigate to={`/login?redirect=${returnTo}`} replace />
} }
return children return children

View File

@ -14,11 +14,21 @@ notification.config({
maxCount: 3, maxCount: 3,
}) })
let muteErrorsUntil = 0
function shouldMuteError() {
return Date.now() < muteErrorsUntil
}
/** /**
* 标准通知反馈组件 * 标准通知反馈组件
* 从右上角滑出默认3秒后消失 * 从右上角滑出默认3秒后消失
*/ */
const Toast = { const Toast = {
muteErrors: (durationMs = 2000) => {
muteErrorsUntil = Date.now() + durationMs
},
/** /**
* 成功通知 * 成功通知
* @param {string} message - 消息内容 * @param {string} message - 消息内容
@ -44,7 +54,11 @@ const Toast = {
* @param {string} description - 详细描述可选 * @param {string} description - 详细描述可选
* @param {number} duration - 显示时长默认3秒 * @param {number} duration - 显示时长默认3秒
*/ */
error: (message, description = '', duration = 3) => { error: (message, description = '', duration = 3, options = {}) => {
if (!options.force && shouldMuteError()) {
return
}
notification.error({ notification.error({
message, message,
description, description,
@ -63,7 +77,11 @@ const Toast = {
* @param {string} description - 详细描述可选 * @param {string} description - 详细描述可选
* @param {number} duration - 显示时长默认3秒 * @param {number} duration - 显示时长默认3秒
*/ */
warning: (message, description = '', duration = 3) => { warning: (message, description = '', duration = 3, options = {}) => {
if (!options.force && shouldMuteError()) {
return
}
notification.warning({ notification.warning({
message, message,
description, description,

View File

@ -44,33 +44,20 @@
align-items: center; align-items: center;
} }
.mode-toggle-btn { .mode-actions-row {
font-weight: 500 !important; width: 100%;
height: 32px !important; display: flex;
display: flex !important; align-items: center;
align-items: center !important; justify-content: space-between;
justify-content: center !important; gap: 12px;
padding: 4px 15px !important;
border: none !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
} }
.mode-toggle-btn.exit-edit { .mode-actions-group {
background: linear-gradient(135deg, #00b96b 0%, #52c41a 100%) !important; display: inline-flex;
color: white !important; align-items: center;
box-shadow: 0 2px 6px rgba(0, 185, 107, 0.2);
} }
.mode-toggle-btn:hover { .sider-actions .mode-actions-group .ant-btn {
transform: translateY(-1px);
filter: brightness(1.05);
}
.mode-toggle-btn.exit-edit:hover {
box-shadow: 0 4px 10px rgba(0, 185, 107, 0.3);
}
.sider-actions .ant-btn:not(.mode-toggle-btn) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -79,7 +66,7 @@
color: var(--text-color-secondary); color: var(--text-color-secondary);
} }
.sider-actions .ant-btn:not(.mode-toggle-btn):hover { .sider-actions .mode-actions-group .ant-btn:hover {
background: var(--item-hover-bg); background: var(--item-hover-bg);
border-color: var(--border-color); border-color: var(--border-color);
color: var(--link-color); color: var(--link-color);
@ -182,6 +169,7 @@
.content-header { .content-header {
padding: 16px 24px; padding: 16px 24px;
min-height: 57px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Layout, Menu, Button, Modal, Input, Space, Tooltip, Dropdown, Upload, Select, Progress, TreeSelect } from 'antd' import { Layout, Menu, Button, Modal, Input, Space, Tooltip, Dropdown, Upload, Select, Progress, TreeSelect } from 'antd'
import { import {
FileOutlined, FileOutlined,
@ -8,7 +8,6 @@ import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
SaveOutlined, SaveOutlined,
EyeOutlined,
FileAddOutlined, FileAddOutlined,
FolderAddOutlined, FolderAddOutlined,
UploadOutlined, UploadOutlined,
@ -39,6 +38,7 @@ import {
uploadDocument, uploadDocument,
} from '@/api/file' } from '@/api/file'
import Toast from '@/components/Toast/Toast' import Toast from '@/components/Toast/Toast'
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
import './DocumentEditor.css' import './DocumentEditor.css'
const { Sider, Content } = Layout const { Sider, Content } = Layout
@ -46,6 +46,7 @@ const { Sider, Content } = Layout
function DocumentEditor() { function DocumentEditor() {
const { projectId } = useParams() const { projectId } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const [treeData, setTreeData] = useState([]) const [treeData, setTreeData] = useState([])
const [selectedFile, setSelectedFile] = useState(null) const [selectedFile, setSelectedFile] = useState(null)
@ -72,7 +73,28 @@ function DocumentEditor() {
const [linkModalVisible, setLinkModalVisible] = useState(false) const [linkModalVisible, setLinkModalVisible] = useState(false)
const [linkTarget, setLinkTarget] = useState(null) const [linkTarget, setLinkTarget] = useState(null)
const [projectName, setProjectName] = useState('') // const [projectName, setProjectName] = useState('') //
const [userRole, setUserRole] = useState('viewer')
const [modeSwitchValue, setModeSwitchValue] = useState('edit')
const editorCtxRef = useRef(null) const editorCtxRef = useRef(null)
const modeSwitchingRef = useRef(false)
const navigateWithTransition = (to) => {
if (document.startViewTransition) {
document.startViewTransition(() => navigate(to))
return
}
navigate(to)
}
const updateFileParam = (filePath) => {
const nextParams = new URLSearchParams(searchParams)
if (filePath) {
nextParams.set('file', filePath)
} else {
nextParams.delete('file')
}
setSearchParams(nextParams, { replace: true })
}
// //
const handleInsertLink = () => { const handleInsertLink = () => {
@ -126,13 +148,31 @@ function DocumentEditor() {
const data = res.data || {} const data = res.data || {}
const tree = data.tree || data || [] // const tree = data.tree || data || [] //
const name = data.project_name const name = data.project_name
const role = data.user_role || 'viewer'
setTreeData(tree) setTreeData(tree)
setProjectName(name) setProjectName(name)
setUserRole(role)
} catch (error) { } catch (error) {
Toast.error('加载失败', '加载文件树失败') Toast.error('加载失败', '加载文件树失败')
} }
} }
const handleClose = () => {
Modal.confirm({
title: '确认退出',
content: '确定要退出编辑页面吗?未保存的修改可能会丢失。',
okText: '退出',
cancelText: '取消',
onOk: () => {
if (userRole === 'owner') {
navigate('/projects/my')
} else {
navigate('/projects/share')
}
},
})
}
const handleSelectFile = async (selectedKeys, info) => { const handleSelectFile = async (selectedKeys, info) => {
// //
setSelectedNode(info.node) setSelectedNode(info.node)
@ -173,39 +213,76 @@ function DocumentEditor() {
return null return null
} }
// MenuMenu const expandParentFolders = (path) => {
const handleMenuClick = async ({ key, domEvent }) => { const parts = path.split('/')
const node = findNodeByKey(treeData, key) if (parts.length <= 1) return
if (!node) return
// const parentKeys = []
setSelectedNode(node) let currentPath = ''
setSelectedMenuKey(key) // for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
parentKeys.push(currentPath)
}
setOpenKeys(prev => Array.from(new Set([...prev, ...parentKeys])))
}
// const openNodeByKey = async (key, node = null, syncUrl = false) => {
if (node.isLeaf) { const targetNode = node || findNodeByKey(treeData, key)
// PDF if (!targetNode) return
if (key.toLowerCase().endsWith('.pdf')) {
setSelectedFile(key) setSelectedNode(targetNode)
setIsPdfSelected(true) setSelectedMenuKey(key)
setFileContent('')
return if (!targetNode.isLeaf) {
if (syncUrl) {
updateFileParam(null)
} }
return
}
// PDF if (syncUrl) {
setIsPdfSelected(false) updateFileParam(key)
}
expandParentFolders(key)
setLoading(true) if (key.toLowerCase().endsWith('.pdf')) {
try { setSelectedFile(key)
const res = await getFileContent(projectId, key) setIsPdfSelected(true)
setSelectedFile(key) setFileContent('')
setFileContent(res.data.content) return
} catch (error) { }
Toast.error('加载失败', '加载文件失败')
setIsPdfSelected(false)
setLoading(true)
try {
const res = await getFileContent(projectId, key)
setSelectedFile(key)
setFileContent(res.data.content)
} catch (error) {
Toast.error('加载失败', '加载文件失败')
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
useEffect(() => {
if (treeData.length === 0) return
const fileParam = searchParams.get('file')
if (!fileParam) return
if (fileParam === selectedFile) return
const targetNode = findNodeByKey(treeData, fileParam)
if (!targetNode) return
openNodeByKey(fileParam, targetNode, false)
}, [treeData, searchParams])
// MenuMenu
const handleMenuClick = async ({ key, domEvent }) => {
const node = findNodeByKey(treeData, key)
if (!node) return
await openNodeByKey(key, node, true)
} }
// //
@ -894,19 +971,35 @@ function DocumentEditor() {
<h2 style={{ margin: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={projectName}> <h2 style={{ margin: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={projectName}>
{projectName} {projectName}
</h2> </h2>
<Tooltip title="关闭">
<Button
type="text"
icon={<CloseOutlined />}
onClick={handleClose}
style={{ marginLeft: 8 }}
/>
</Tooltip>
</div> </div>
<div className="sider-actions"> <div className="sider-actions">
<Space size={8}> <div className="mode-actions-row">
<Button <ModeSwitch
type="primary" value={modeSwitchValue}
size="middle" onChange={(mode) => {
className="mode-toggle-btn exit-edit" if (mode === 'view' && !modeSwitchingRef.current) {
icon={<EyeOutlined />} modeSwitchingRef.current = true
onClick={() => navigate(`/projects/${projectId}/docs`)} setModeSwitchValue('view')
> setTimeout(() => {
退出 const params = new URLSearchParams()
</Button> if (selectedFile) {
<Space.Compact> params.set('file', selectedFile)
}
const query = params.toString()
navigateWithTransition(`/projects/${projectId}/docs${query ? `?${query}` : ''}`)
}, 160)
}
}}
/>
<Space.Compact className="mode-actions-group">
<Tooltip title="添加文件"> <Tooltip title="添加文件">
<Button <Button
size="middle" size="middle"
@ -944,7 +1037,7 @@ function DocumentEditor() {
/> />
</Tooltip> </Tooltip>
</Space.Compact> </Space.Compact>
</Space> </div>
</div> </div>
</div> </div>
{/* 上传进度条 */} {/* 上传进度条 */}

View File

@ -40,26 +40,20 @@
align-items: center; align-items: center;
} }
.mode-toggle-btn { .mode-actions-row {
font-weight: 500 !important; width: 100%;
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.2); display: flex;
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%) !important; align-items: center;
border: none !important; justify-content: space-between;
height: 32px !important; gap: 12px;
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 4px 15px !important;
color: white !important;
} }
.mode-toggle-btn:hover { .mode-actions-group {
transform: translateY(-1px); display: inline-flex;
box-shadow: 0 4px 10px rgba(22, 119, 255, 0.3); align-items: center;
filter: brightness(1.1);
} }
.docs-sider-actions .ant-btn:not(.mode-toggle-btn) { .docs-sider-actions .mode-actions-group .ant-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -68,7 +62,7 @@
color: var(--text-color-secondary); color: var(--text-color-secondary);
} }
.docs-sider-actions .ant-btn-text:hover { .docs-sider-actions .mode-actions-group .ant-btn:hover {
background: var(--item-hover-bg); background: var(--item-hover-bg);
border-color: var(--border-color); border-color: var(--border-color);
color: var(--link-color); color: var(--link-color);
@ -176,6 +170,29 @@
background: var(--bg-color); background: var(--bg-color);
} }
.docs-content-header {
position: sticky;
top: 0;
z-index: 5;
min-height: 57px;
padding: 16px 24px;
border-bottom: 1px solid var(--border-color);
background: var(--header-bg);
display: flex;
align-items: center;
}
.docs-content-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.docs-content-wrapper { .docs-content-wrapper {
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, Space, Dropdown, Empty } from 'antd' import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Space, Dropdown, Empty } from 'antd'
import { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, CloseOutlined, MenuOutlined } from '@ant-design/icons' 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 ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw' import rehypeRaw from 'rehype-raw'
@ -17,6 +17,7 @@ import { searchDocuments } from '@/api/search'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions' import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
import Toast from '@/components/Toast/Toast' import Toast from '@/components/Toast/Toast'
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
import './DocumentPage.css' import './DocumentPage.css'
const { Sider, Content } = Layout const { Sider, Content } = Layout
@ -37,7 +38,7 @@ const HighlightText = ({ text, keyword }) => {
function DocumentPage() { function DocumentPage() {
const { projectId } = useParams() const { projectId } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const [fileTree, setFileTree] = useState([]) const [fileTree, setFileTree] = useState([])
const [selectedFile, setSelectedFile] = useState('') const [selectedFile, setSelectedFile] = useState('')
const [markdownContent, setMarkdownContent] = useState('') const [markdownContent, setMarkdownContent] = useState('')
@ -61,19 +62,47 @@ function DocumentPage() {
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set()) const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
const [isSearching, setIsSearching] = useState(false) const [isSearching, setIsSearching] = useState(false)
const [modeSwitchValue, setModeSwitchValue] = useState('view')
const contentRef = useRef(null) const contentRef = useRef(null)
const modeSwitchingRef = useRef(false)
const navigateWithTransition = (to) => {
if (document.startViewTransition) {
document.startViewTransition(() => navigate(to))
return
}
navigate(to)
}
const updateFileParam = (filePath) => {
const nextParams = new URLSearchParams(searchParams)
if (filePath) {
nextParams.set('file', filePath)
} else {
nextParams.delete('file')
}
setSearchParams(nextParams, { replace: true })
}
useEffect(() => { useEffect(() => {
loadFileTree() loadFileTree()
}, [projectId]) }, [projectId])
const handleClose = () => { const handleClose = () => {
if (userRole === 'owner') { Modal.confirm({
navigate('/projects/my') title: '确认退出',
} else { content: '确定要退出当前项目页面吗?',
navigate('/projects/share') okText: '退出',
} cancelText: '取消',
onOk: () => {
if (userRole === 'owner') {
navigate('/projects/my')
} else {
navigate('/projects/share')
}
},
})
} }
// URL // URL
@ -127,6 +156,7 @@ function DocumentPage() {
const readmeNode = findReadme(fileTree) const readmeNode = findReadme(fileTree)
if (readmeNode) { if (readmeNode) {
setSelectedFile(readmeNode.key) setSelectedFile(readmeNode.key)
updateFileParam(readmeNode.key)
loadMarkdown(readmeNode.key) loadMarkdown(readmeNode.key)
} }
} }
@ -312,6 +342,7 @@ function DocumentPage() {
// //
const handleMenuClick = ({ key }) => { const handleMenuClick = ({ key }) => {
setSelectedFile(key) setSelectedFile(key)
updateFileParam(key)
// PDF // PDF
if (key.toLowerCase().endsWith('.pdf')) { if (key.toLowerCase().endsWith('.pdf')) {
@ -412,6 +443,7 @@ function DocumentPage() {
// //
setSelectedFile(targetPath) setSelectedFile(targetPath)
updateFileParam(targetPath)
if (isPdf) { if (isPdf) {
// PDFPDF // PDFPDF
@ -593,7 +625,12 @@ function DocumentPage() {
// //
const handleEdit = () => { const handleEdit = () => {
navigate(`/projects/${projectId}/editor`) const params = new URLSearchParams()
if (selectedFile) {
params.set('file', selectedFile)
}
const query = params.toString()
navigateWithTransition(`/projects/${projectId}/editor${query ? `?${query}` : ''}`)
} }
// //
@ -782,20 +819,25 @@ function DocumentPage() {
</Tooltip> </Tooltip>
</div> </div>
<div className="docs-sider-actions"> <div className="docs-sider-actions">
<Space size={8}> <div className="mode-actions-row">
{/* 只有 owner/admin/editor 可以编辑和Git操作 */} {/* 只有 owner/admin/editor 可以编辑和Git操作 */}
{userRole !== 'viewer' && ( {userRole !== 'viewer' ? (
<Button <ModeSwitch
type="primary" value={modeSwitchValue}
size="middle" onChange={(mode) => {
className="mode-toggle-btn" if (mode === 'edit' && !modeSwitchingRef.current) {
icon={<EditOutlined />} modeSwitchingRef.current = true
onClick={handleEdit} setModeSwitchValue('edit')
> setTimeout(() => {
编辑 handleEdit()
</Button> }, 160)
}
}}
/>
) : (
<div />
)} )}
<Space.Compact> <Space.Compact className="mode-actions-group">
{userRole !== 'viewer' && renderGitActions()} {userRole !== 'viewer' && renderGitActions()}
<Tooltip title="分享"> <Tooltip title="分享">
<Button <Button
@ -805,7 +847,7 @@ function DocumentPage() {
/> />
</Tooltip> </Tooltip>
</Space.Compact> </Space.Compact>
</Space> </div>
</div> </div>
</div> </div>
@ -842,6 +884,9 @@ function DocumentPage() {
{/* 右侧内容区 */} {/* 右侧内容区 */}
<Layout className="docs-content-layout"> <Layout className="docs-content-layout">
<Content className="docs-content" ref={contentRef}> <Content className="docs-content" ref={contentRef}>
<div className="docs-content-header">
<h3>{selectedFile || 'README.md'}</h3>
</div>
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}> <div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
{loading ? ( {loading ? (
<div className="docs-loading"> <div className="docs-loading">
@ -992,4 +1037,4 @@ function DocumentPage() {
) )
} }
export default DocumentPage export default DocumentPage

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { Form, Input, Button, Tabs } from 'antd' import { Form, Input, Button, Tabs } from 'antd'
import { UserOutlined, LockOutlined, MailOutlined, ArrowRightOutlined } from '@ant-design/icons' import { UserOutlined, LockOutlined, MailOutlined, ArrowRightOutlined } from '@ant-design/icons'
import { login, register } from '@/api/auth' import { login, register } from '@/api/auth'
@ -13,6 +13,7 @@ function Login() {
const [activeTab, setActiveTab] = useState('login') const [activeTab, setActiveTab] = useState('login')
const [loginForm] = Form.useForm() const [loginForm] = Form.useForm()
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { setUser, setToken } = useUserStore() const { setUser, setToken } = useUserStore()
const handleLogin = async (values) => { const handleLogin = async (values) => {
@ -27,6 +28,12 @@ function Login() {
setToken(res.data.access_token) setToken(res.data.access_token)
setUser(res.data.user) setUser(res.data.user)
const redirect = searchParams.get('redirect')
if (redirect) {
navigate(decodeURIComponent(redirect), { replace: true })
return
}
// //
try { try {
const menuRes = await getUserMenus() const menuRes = await getUserMenus()

View File

@ -8,6 +8,10 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
} }
.profile-card .ant-card-body {
padding: 28px 32px;
}
.profile-title { .profile-title {
margin: 0 0 24px 0; margin: 0 0 24px 0;
font-size: 24px; font-size: 24px;
@ -15,9 +19,45 @@
color: rgba(0, 0, 0, 0.88); color: rgba(0, 0, 0, 0.88);
} }
.profile-tabs > .ant-tabs-nav {
min-width: 180px;
margin-right: 32px;
}
.profile-tabs > .ant-tabs-nav .ant-tabs-nav-list {
gap: 8px;
}
.profile-tabs > .ant-tabs-nav .ant-tabs-tab {
margin: 0;
padding: 12px 16px;
border-radius: 12px;
transition: background-color 0.2s ease, color 0.2s ease;
}
.profile-tabs > .ant-tabs-nav .ant-tabs-tab-active {
background: rgba(22, 119, 255, 0.08);
}
.profile-tabs > .ant-tabs-nav .ant-tabs-tab-btn {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.profile-tabs > .ant-tabs-content-holder {
min-width: 0;
}
.profile-tabs > .ant-tabs-content-holder > .ant-tabs-content > .ant-tabs-tabpane {
padding-left: 8px;
}
.profile-tab-content, .profile-tab-content,
.password-tab-content { .password-tab-content,
padding: 24px 0; .mcp-tab-content {
padding: 4px 0;
} }
/* 头像部分 */ /* 头像部分 */
@ -80,16 +120,116 @@
margin-bottom: 0; margin-bottom: 0;
} }
.mcp-panel {
display: flex;
flex-direction: column;
gap: 20px;
}
.mcp-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px 24px;
border: 1px solid rgba(22, 119, 255, 0.14);
border-radius: 16px;
background: linear-gradient(180deg, rgba(22, 119, 255, 0.06) 0%, rgba(22, 119, 255, 0.02) 100%);
}
.mcp-panel-header h3 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
}
.mcp-panel-header p {
margin: 0;
color: rgba(0, 0, 0, 0.6);
}
.mcp-field-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.mcp-field-card {
padding: 20px;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 14px;
background: #fff;
}
.mcp-field-card label {
display: block;
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(0, 0, 0, 0.5);
}
.mcp-value {
margin-bottom: 16px !important;
padding: 12px 14px;
border-radius: 10px;
background: #f6f8fb;
font-family: Menlo, Monaco, Consolas, monospace;
word-break: break-all;
}
.mcp-config-tip {
padding: 18px 20px;
border-radius: 14px;
background: #f7f9fc;
border: 1px dashed rgba(22, 119, 255, 0.24);
}
.mcp-config-tip h4 {
margin: 0 0 10px;
font-size: 14px;
font-weight: 600;
}
.mcp-config-tip pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.6;
font-family: Menlo, Monaco, Consolas, monospace;
}
/* 响应式 */ /* 响应式 */
@media (max-width: 768px) { @media (max-width: 768px) {
.profile-page { .profile-page {
padding: 16px; padding: 16px;
} }
.profile-card .ant-card-body {
padding: 20px 16px;
}
.profile-title { .profile-title {
font-size: 20px; font-size: 20px;
} }
.profile-tabs.ant-tabs-left {
display: block;
}
.profile-tabs > .ant-tabs-nav {
min-width: 0;
margin-right: 0;
margin-bottom: 16px;
}
.profile-tabs > .ant-tabs-content-holder > .ant-tabs-content > .ant-tabs-tabpane {
padding-left: 0;
}
.profile-form, .profile-form,
.password-form { .password-form {
max-width: 100%; max-width: 100%;
@ -98,4 +238,12 @@
.avatar-section { .avatar-section {
padding: 16px; padding: 16px;
} }
.mcp-panel-header {
flex-direction: column;
}
.mcp-field-list {
grid-template-columns: 1fr;
}
} }

View File

@ -1,21 +1,25 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Card, Tabs, Form, Input, Button, Avatar, Upload, message } from 'antd' import { Card, Tabs, Form, Input, Button, Avatar, Upload, message, Space, Typography, Modal } from 'antd'
import { UserOutlined, LockOutlined, UploadOutlined } from '@ant-design/icons' import { UserOutlined, LockOutlined, UploadOutlined, ApiOutlined, CopyOutlined, ReloadOutlined } from '@ant-design/icons'
import ImgCrop from 'antd-img-crop' import ImgCrop from 'antd-img-crop'
import { getCurrentUser, updateProfile, changePassword, uploadAvatar } from '@/api/auth' import { getCurrentUser, updateProfile, changePassword, uploadAvatar, getMcpCredentials, rotateMcpSecret } from '@/api/auth'
import useUserStore from '@/stores/userStore' import useUserStore from '@/stores/userStore'
import Toast from '@/components/Toast/Toast' import Toast from '@/components/Toast/Toast'
import './ProfilePage.css' import './ProfilePage.css'
const { Paragraph, Text } = Typography
function ProfilePage() { function ProfilePage() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [userInfo, setUserInfo] = useState(null) const [userInfo, setUserInfo] = useState(null)
const [mcpCredentials, setMcpCredentials] = useState(null)
const [profileForm] = Form.useForm() const [profileForm] = Form.useForm()
const [passwordForm] = Form.useForm() const [passwordForm] = Form.useForm()
const { user, setUser } = useUserStore() const { setUser } = useUserStore()
useEffect(() => { useEffect(() => {
loadUserInfo() loadUserInfo()
loadMcpCredentials()
}, []) }, [])
// //
@ -35,6 +39,16 @@ function ProfilePage() {
} }
} }
const loadMcpCredentials = async () => {
try {
const res = await getMcpCredentials()
setMcpCredentials(res.data)
} catch (error) {
console.error('Load MCP credentials error:', error)
Toast.error('加载失败', '获取 MCP 凭证失败')
}
}
// //
const handleUpdateProfile = async (values) => { const handleUpdateProfile = async (values) => {
setLoading(true) setLoading(true)
@ -124,6 +138,41 @@ function ProfilePage() {
return null return null
} }
const handleCopy = async (value, label) => {
if (!value) return
try {
await navigator.clipboard.writeText(value)
Toast.success('复制成功', `${label} 已复制到剪贴板`)
} catch (error) {
console.error('Copy credential error:', error)
Toast.error('复制失败', `无法复制 ${label}`)
}
}
const handleRotateSecret = () => {
Modal.confirm({
title: '重新生成 MCP Secret',
content: '旧 Secret 会立即失效,依赖该凭证的远程客户端需要同步更新。确认继续?',
okText: '重新生成',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
setLoading(true)
try {
const res = await rotateMcpSecret()
setMcpCredentials(res.data)
Toast.success('已生成', '新的 MCP Secret 已生效')
} catch (error) {
console.error('Rotate MCP secret error:', error)
Toast.error('生成失败', error.response?.data?.detail || '重新生成 MCP Secret 失败')
} finally {
setLoading(false)
}
},
})
}
const tabItems = [ const tabItems = [
{ {
key: 'profile', key: 'profile',
@ -289,17 +338,86 @@ function ProfilePage() {
</div> </div>
), ),
}, },
{
key: 'mcp',
label: (
<span>
<ApiOutlined />
MCP 接入
</span>
),
children: (
<div className="mcp-tab-content">
<div className="mcp-panel">
<div className="mcp-panel-header">
<div>
<h3>MCP 访问凭证</h3>
<p>用于远程 MCP Client 通过 `X-Bot-Id` `X-Bot-Secret` 接入你的账号</p>
</div>
<Button
icon={<ReloadOutlined />}
onClick={handleRotateSecret}
loading={loading}
>
重新生成 Secret
</Button>
</div>
<div className="mcp-field-list">
<div className="mcp-field-card">
<label>X-Bot-Id</label>
<Paragraph copyable={false} className="mcp-value">
{mcpCredentials?.bot_id || '-'}
</Paragraph>
<Button
icon={<CopyOutlined />}
onClick={() => handleCopy(mcpCredentials?.bot_id, 'X-Bot-Id')}
disabled={!mcpCredentials?.bot_id}
>
复制 Bot ID
</Button>
</div>
<div className="mcp-field-card">
<label>X-Bot-Secret</label>
<Paragraph copyable={false} className="mcp-value mcp-secret">
{mcpCredentials?.bot_secret || '-'}
</Paragraph>
<Space>
<Button
icon={<CopyOutlined />}
onClick={() => handleCopy(mcpCredentials?.bot_secret, 'X-Bot-Secret')}
disabled={!mcpCredentials?.bot_secret}
>
复制 Secret
</Button>
<Text type="secondary">变更后旧 Secret 立即失效</Text>
</Space>
</div>
</div>
<div className="mcp-config-tip">
<h4>客户端请求头</h4>
<pre>{`X-Bot-Id: ${mcpCredentials?.bot_id || 'your_bot_id'}\nX-Bot-Secret: ${mcpCredentials?.bot_secret || 'your_bot_secret'}`}</pre>
</div>
</div>
</div>
),
},
] ]
return ( return (
<div className="profile-page"> <div className="profile-page">
<Card className="profile-card"> <Card className="profile-card">
<h2 className="profile-title">个人中心</h2> <h2 className="profile-title">个人中心</h2>
<Tabs items={tabItems} defaultActiveKey="profile" /> <Tabs
items={tabItems}
defaultActiveKey="profile"
tabPosition="left"
className="profile-tabs"
/>
</Card> </Card>
</div> </div>
) )
} }

View File

@ -4,6 +4,14 @@
import axios from 'axios' import axios from 'axios'
import Toast from '@/components/Toast/Toast' import Toast from '@/components/Toast/Toast'
let isHandlingUnauthorized = false
function redirectToLoginWithReturnTo() {
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`
const encodedReturnTo = encodeURIComponent(currentPath)
window.location.href = `/login?redirect=${encodedReturnTo}`
}
// 创建 axios 实例 // 创建 axios 实例
const request = axios.create({ const request = axios.create({
baseURL: '/api/v1', // 使用相对路径开发环境通过vite proxy代理生产环境通过nginx代理 baseURL: '/api/v1', // 使用相对路径开发环境通过vite proxy代理生产环境通过nginx代理
@ -40,15 +48,22 @@ request.interceptors.response.use(
// 如果返回的状态码不是 200说明有错误 // 如果返回的状态码不是 200说明有错误
if (res.code !== 200) { if (res.code !== 200) {
Toast.error('请求失败', res.message || '请求失败')
// 401: 未登录或 token 过期 // 401: 未登录或 token 过期
if (res.code === 401) { if (res.code === 401) {
localStorage.removeItem('access_token') if (!window.location.pathname.includes('/login') && !isHandlingUnauthorized) {
localStorage.removeItem('user_info') isHandlingUnauthorized = true
window.location.href = '/login' Toast.muteErrors(2500)
Toast.error('认证失败', res.message || '未登录或登录已过期', 3, { force: true })
localStorage.removeItem('access_token')
localStorage.removeItem('user_info')
setTimeout(() => {
redirectToLoginWithReturnTo()
}, 600)
}
return Promise.reject(new Error(res.message || '未登录或登录已过期'))
} }
Toast.error('请求失败', res.message || '请求失败')
return Promise.reject(new Error(res.message || '请求失败')) return Promise.reject(new Error(res.message || '请求失败'))
} }
@ -64,13 +79,15 @@ request.interceptors.response.use(
switch (status) { switch (status) {
case 401: case 401:
// 只有不在登录页时才显示Toast和跳转 // 只有不在登录页时才显示Toast和跳转
if (!window.location.pathname.includes('/login')) { if (!window.location.pathname.includes('/login') && !isHandlingUnauthorized) {
Toast.error('认证失败', data?.detail || '未登录或登录已过期') isHandlingUnauthorized = true
Toast.muteErrors(2500)
Toast.error('认证失败', data?.detail || '未登录或登录已过期', 3, { force: true })
localStorage.removeItem('access_token') localStorage.removeItem('access_token')
localStorage.removeItem('user_info') localStorage.removeItem('user_info')
setTimeout(() => { setTimeout(() => {
window.location.href = '/login' redirectToLoginWithReturnTo()
}, 1000) // 延迟1秒让Toast有时间显 }, 600) // 延迟一小段时间,让提示先展
} }
break break
case 403: case 403: