修改了mcp接口,增加了项目创建接口
parent
bd7d4d3dd5
commit
9e7878b9c8
|
|
@ -73,5 +73,6 @@ RUN mkdir -p /data/nex_docus_store/projects /data/nex_docus_store/temp logs
|
||||||
# 暴露端口
|
# 暴露端口
|
||||||
EXPOSE 8000
|
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"]
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,43 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
CHAT_HISTORY_MESSAGE_LIMIT = 20
|
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):
|
class VectorizeRequest(BaseModel):
|
||||||
"""批量向量化请求"""
|
"""批量向量化请求"""
|
||||||
|
|
@ -416,8 +453,14 @@ async def get_session_messages(
|
||||||
stored_refs = _parse_refs(message.referenced_files)
|
stored_refs = _parse_refs(message.referenced_files)
|
||||||
content = message.content
|
content = message.content
|
||||||
refs = stored_refs
|
refs = stored_refs
|
||||||
|
status = message.status or "pending"
|
||||||
if message.role == "assistant":
|
if message.role == "assistant":
|
||||||
content, refs = _canonicalize_message_citations(content, stored_refs)
|
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 = []
|
thinking_log = []
|
||||||
if message.thinking_log:
|
if message.thinking_log:
|
||||||
try:
|
try:
|
||||||
|
|
@ -428,7 +471,7 @@ async def get_session_messages(
|
||||||
"id": message.id,
|
"id": message.id,
|
||||||
"role": message.role,
|
"role": message.role,
|
||||||
"content": content,
|
"content": content,
|
||||||
"status": message.status or "pending",
|
"status": status,
|
||||||
"duration_ms": message.duration_ms,
|
"duration_ms": message.duration_ms,
|
||||||
"thinking_log": thinking_log,
|
"thinking_log": thinking_log,
|
||||||
"referenced_files": refs,
|
"referenced_files": refs,
|
||||||
|
|
@ -564,7 +607,7 @@ async def send_chat_message(
|
||||||
{"role": m.role, "content": m.content}
|
{"role": m.role, "content": m.content}
|
||||||
for m in prev_messages
|
for m in prev_messages
|
||||||
# 被中断的助手消息不参与上下文,避免污染后续模型输入
|
# 被中断的助手消息不参与上下文,避免污染后续模型输入
|
||||||
if not (m.role == "assistant" and m.status == "interrupted")
|
if not _is_interrupted_message(m)
|
||||||
]
|
]
|
||||||
|
|
||||||
start_time = time.monotonic()
|
start_time = time.monotonic()
|
||||||
|
|
@ -686,7 +729,7 @@ async def send_chat_message_stream(
|
||||||
{"role": m.role, "content": m.content}
|
{"role": m.role, "content": m.content}
|
||||||
for m in prev_messages
|
for m in prev_messages
|
||||||
# 被中断的助手消息不参与上下文,避免污染后续模型输入
|
# 被中断的助手消息不参与上下文,避免污染后续模型输入
|
||||||
if not (m.role == "assistant" and m.status == "interrupted")
|
if not _is_interrupted_message(m)
|
||||||
]
|
]
|
||||||
|
|
||||||
# 先持久化用户提问和空的助手消息占位行,再执行向量检索。
|
# 先持久化用户提问和空的助手消息占位行,再执行向量检索。
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,11 @@ async def _column_exists(conn, table_name: str, column_name: str) -> bool:
|
||||||
|
|
||||||
|
|
||||||
async def migrate_schema() -> None:
|
async def migrate_schema() -> None:
|
||||||
"""为存量数据库补齐新增列,并为历史助手消息回填状态。"""
|
"""为存量数据库补齐新增列。
|
||||||
|
|
||||||
|
只做“新增列”这类非破坏性变更:不修改、不删除、不回填任何已有数据。
|
||||||
|
历史消息的中断状态由读取路径(get messages)按旧标记做只读兼容判断。
|
||||||
|
"""
|
||||||
from app.core.database import engine
|
from app.core.database import engine
|
||||||
|
|
||||||
added = []
|
added = []
|
||||||
|
|
@ -63,37 +67,6 @@ async def migrate_schema() -> None:
|
||||||
await conn.execute(text(ddl))
|
await conn.execute(text(ddl))
|
||||||
added.append(f"{table_name}.{column_name}")
|
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:
|
if added:
|
||||||
logger.info("数据库迁移完成,新增列: %s", ", ".join(added))
|
logger.info("数据库迁移完成,新增列: %s", ", ".join(added))
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -80,17 +80,22 @@ async def init_roles():
|
||||||
|
|
||||||
|
|
||||||
async def init_menus():
|
async def init_menus():
|
||||||
"""初始化系统菜单"""
|
"""初始化系统菜单(幂等、增量)。
|
||||||
|
|
||||||
|
已有菜单保持原样,只插入缺失的菜单,并给超级管理员角色补授新菜单权限;
|
||||||
|
避免在已有数据库上升级时新功能入口缺失,也不影响已有菜单与授权。
|
||||||
|
"""
|
||||||
print("正在初始化系统菜单...")
|
print("正在初始化系统菜单...")
|
||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
# 检查是否已存在菜单
|
# 读取已存在的菜单,只补缺失项;按 id 与 (parent_id, menu_name) 双重去重,
|
||||||
result = await session.execute(text("SELECT COUNT(*) FROM system_menus"))
|
# 避免老库已有同名菜单时插入重复入口。
|
||||||
count = result.scalar()
|
result = await session.execute(
|
||||||
|
text("SELECT id, parent_id, menu_name FROM system_menus")
|
||||||
if count > 0:
|
)
|
||||||
print(" 菜单已存在,跳过初始化")
|
existing_rows = result.all()
|
||||||
return
|
existing_ids = {row[0] for row in existing_rows}
|
||||||
|
existing_names = {(row[1], row[2]) for row in existing_rows}
|
||||||
|
|
||||||
# 创建系统菜单
|
# 创建系统菜单
|
||||||
menus = [
|
menus = [
|
||||||
|
|
@ -287,13 +292,53 @@ async def init_menus():
|
||||||
visible=1,
|
visible=1,
|
||||||
status=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)
|
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()
|
await session.commit()
|
||||||
print("✓ 系统菜单初始化成功")
|
print(f"✓ 系统菜单初始化成功,新增 {len(new_menus)} 项")
|
||||||
|
|
||||||
|
|
||||||
async def init_admin_user():
|
async def init_admin_user():
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,30 @@ from app.services.rag_service import RAGService
|
||||||
|
|
||||||
|
|
||||||
class ChatCitationTest(unittest.TestCase):
|
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):
|
def test_duplicate_file_citations_are_renumbered_once(self):
|
||||||
content, refs = _canonicalize_message_citations(
|
content, refs = _canonicalize_message_citations(
|
||||||
"泰坦属于土星的卫星[2][4][5]。",
|
"泰坦属于土星的卫星[2][4][5]。",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue