修改了mcp接口,增加了项目创建接口

main
mula.liu 2026-08-07 16:45:48 +08:00
parent bd7d4d3dd5
commit 9e7878b9c8
5 changed files with 133 additions and 47 deletions

View File

@ -73,5 +73,6 @@ RUN mkdir -p /data/nex_docus_store/projects /data/nex_docus_store/temp logs
# 暴露端口
EXPOSE 8000
# 启动命令
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# 启动命令:先执行幂等的数据库初始化/迁移(建表、种子数据、字段补全),再启动服务。
# 全新部署会自动建表并初始化角色/菜单/管理员;已有数据库会自动补列,均无需手动执行 SQL。
CMD ["sh", "-c", "python scripts/init_db.py && exec uvicorn main:app --host 0.0.0.0 --port 8000"]

View File

@ -37,6 +37,43 @@ logger = logging.getLogger(__name__)
CHAT_HISTORY_MESSAGE_LIMIT = 20
# 旧版本会在被中断的助手消息内容末尾追加 "interrupt" 标记。
# 自 status 字段上线后新消息不再写该标记,判断中断只以 status 为准;
# 下面两个函数仅用于读取历史消息时的只读兼容(绝不修改数据库)。
_LEGACY_INTERRUPTED_MARKER = "interrupt"
def _legacy_interrupted_content(content: str) -> bool:
"""只读兼容旧数据:判断内容是否带旧版中断标记。"""
if not content:
return False
return content == _LEGACY_INTERRUPTED_MARKER or content.endswith(
f"\n\n{_LEGACY_INTERRUPTED_MARKER}"
)
def _strip_legacy_interrupt_marker(content: str) -> str:
"""只读兼容旧数据:返回去掉旧版中断标记的展示内容(不改数据库)。"""
if content == _LEGACY_INTERRUPTED_MARKER:
return ""
suffix = f"\n\n{_LEGACY_INTERRUPTED_MARKER}"
if content.endswith(suffix):
return content[: -len(suffix)].rstrip()
return content
def _is_interrupted_message(message) -> bool:
"""判断助手消息是否已中断。
新消息以 status 字段为准旧数据status 缺失或仍为 pending仅做
只读兼容按旧版内容标记判断不修改数据库
"""
if message.role != "assistant":
return False
status = (message.status or "").strip() or "pending"
if status == "interrupted":
return True
return status == "pending" and _legacy_interrupted_content(message.content or "")
class VectorizeRequest(BaseModel):
"""批量向量化请求"""
@ -416,8 +453,14 @@ async def get_session_messages(
stored_refs = _parse_refs(message.referenced_files)
content = message.content
refs = stored_refs
status = message.status or "pending"
if message.role == "assistant":
content, refs = _canonicalize_message_citations(content, stored_refs)
# 旧数据只读兼容status 仍为 pending 且内容带旧版中断标记时,
# 按“已中断”返回并在展示内容中移除标记文本(不修改数据库)。
if status in ("", "pending") and _legacy_interrupted_content(content):
status = "interrupted"
content = _strip_legacy_interrupt_marker(content)
thinking_log = []
if message.thinking_log:
try:
@ -428,7 +471,7 @@ async def get_session_messages(
"id": message.id,
"role": message.role,
"content": content,
"status": message.status or "pending",
"status": status,
"duration_ms": message.duration_ms,
"thinking_log": thinking_log,
"referenced_files": refs,
@ -564,7 +607,7 @@ async def send_chat_message(
{"role": m.role, "content": m.content}
for m in prev_messages
# 被中断的助手消息不参与上下文,避免污染后续模型输入
if not (m.role == "assistant" and m.status == "interrupted")
if not _is_interrupted_message(m)
]
start_time = time.monotonic()
@ -686,7 +729,7 @@ async def send_chat_message_stream(
{"role": m.role, "content": m.content}
for m in prev_messages
# 被中断的助手消息不参与上下文,避免污染后续模型输入
if not (m.role == "assistant" and m.status == "interrupted")
if not _is_interrupted_message(m)
]
# 先持久化用户提问和空的助手消息占位行,再执行向量检索。

View File

@ -48,7 +48,11 @@ async def _column_exists(conn, table_name: str, column_name: str) -> bool:
async def migrate_schema() -> None:
"""为存量数据库补齐新增列,并为历史助手消息回填状态。"""
"""为存量数据库补齐新增列。
只做新增列这类非破坏性变更不修改不删除不回填任何已有数据
历史消息的中断状态由读取路径get messages按旧标记做只读兼容判断
"""
from app.core.database import engine
added = []
@ -63,37 +67,6 @@ async def migrate_schema() -> None:
await conn.execute(text(ddl))
added.append(f"{table_name}.{column_name}")
# 历史消息回填:旧的“中断”标记仅存在于 content 末尾,迁移后统一迁移到 status。
# 之后不再用内容比对判断中断status 字段作为唯一依据。
await conn.execute(
text(
"UPDATE chat_message SET status = 'interrupted' "
"WHERE role = 'assistant' "
"AND (status IS NULL OR status = '' OR status = 'pending') "
"AND (content = 'interrupt' OR content LIKE '%\\n\\ninterrupt')"
)
)
# 清理历史“中断”标记文本:迁移后 status 是唯一依据,内容不再混入标记
await conn.execute(
text(
"UPDATE chat_message SET content = '' "
"WHERE status = 'interrupted' AND content = 'interrupt'"
)
)
await conn.execute(
text(
"UPDATE chat_message SET content = TRIM("
"LEFT(content, CHAR_LENGTH(content) - CHAR_LENGTH('\\n\\ninterrupt'))) "
"WHERE status = 'interrupted' AND content LIKE '%\\n\\ninterrupt'"
)
)
await conn.execute(
text(
"UPDATE chat_message SET status = 'completed' "
"WHERE (status IS NULL OR status = '' OR status = 'pending')"
)
)
if added:
logger.info("数据库迁移完成,新增列: %s", ", ".join(added))
else:

View File

@ -80,17 +80,22 @@ async def init_roles():
async def init_menus():
"""初始化系统菜单"""
"""初始化系统菜单(幂等、增量)。
已有菜单保持原样只插入缺失的菜单并给超级管理员角色补授新菜单权限
避免在已有数据库上升级时新功能入口缺失也不影响已有菜单与授权
"""
print("正在初始化系统菜单...")
async with async_session() as session:
# 检查是否已存在菜单
result = await session.execute(text("SELECT COUNT(*) FROM system_menus"))
count = result.scalar()
if count > 0:
print(" 菜单已存在,跳过初始化")
return
# 读取已存在的菜单,只补缺失项;按 id 与 (parent_id, menu_name) 双重去重,
# 避免老库已有同名菜单时插入重复入口。
result = await session.execute(
text("SELECT id, parent_id, menu_name FROM system_menus")
)
existing_rows = result.all()
existing_ids = {row[0] for row in existing_rows}
existing_names = {(row[1], row[2]) for row in existing_rows}
# 创建系统菜单
menus = [
@ -287,13 +292,53 @@ async def init_menus():
visible=1,
status=1
),
SystemMenu(
id=17,
parent_id=20,
menu_name="模型配置",
menu_code="system:model-configs",
menu_type=2,
path="/system/model-configs",
component="System/ModelConfigs",
icon="CloudServerOutlined",
sort_order=4,
visible=1,
status=1
),
]
for menu in menus:
new_menus = [
menu
for menu in menus
if menu.id not in existing_ids
and (menu.parent_id, menu.menu_name) not in existing_names
]
if not new_menus:
print(" 菜单已完整,跳过初始化")
return
for menu in new_menus:
session.add(menu)
await session.flush()
# 为超级管理员角色补授新菜单权限(已存在的授权不受影响)
role_result = await session.execute(
text("SELECT id FROM roles WHERE role_code = 'super_admin' LIMIT 1")
)
role_id = role_result.scalar()
if role_id:
for menu in new_menus:
await session.execute(
text(
"INSERT INTO role_menus (role_id, menu_id) "
"VALUES (:role_id, :menu_id) "
"ON DUPLICATE KEY UPDATE menu_id = menu_id"
),
{"role_id": role_id, "menu_id": menu.id},
)
await session.commit()
print("✓ 系统菜单初始化成功")
print(f"✓ 系统菜单初始化成功,新增 {len(new_menus)}")
async def init_admin_user():

View File

@ -7,6 +7,30 @@ from app.services.rag_service import RAGService
class ChatCitationTest(unittest.TestCase):
def test_legacy_interrupt_marker_is_read_only_compat(self):
from app.api.v1.chat import (
_is_interrupted_message,
_legacy_interrupted_content,
_strip_legacy_interrupt_marker,
)
self.assertTrue(_legacy_interrupted_content("interrupt"))
self.assertTrue(_legacy_interrupted_content("内容\n\ninterrupt"))
self.assertFalse(_legacy_interrupted_content("正常内容"))
self.assertEqual(_strip_legacy_interrupt_marker("interrupt"), "")
self.assertEqual(_strip_legacy_interrupt_marker("内容\n\ninterrupt"), "内容")
self.assertEqual(_strip_legacy_interrupt_marker("正常内容"), "正常内容")
# 旧数据status 缺失/pending + 旧标记)按已中断处理,但不修改数据库
legacy = SimpleNamespace(role="assistant", status="pending", content="内容\n\ninterrupt")
self.assertTrue(_is_interrupted_message(legacy))
# 新数据一律以 status 为准,不再比对内容
fresh = SimpleNamespace(role="assistant", status="completed", content="内容\n\ninterrupt")
self.assertFalse(_is_interrupted_message(fresh))
interrupted = SimpleNamespace(role="assistant", status="interrupted", content="")
self.assertTrue(_is_interrupted_message(interrupted))
def test_duplicate_file_citations_are_renumbered_once(self):
content, refs = _canonicalize_message_citations(
"泰坦属于土星的卫星[2][4][5]。",