修改了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
|
||||
|
||||
# 启动命令
|
||||
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
|
||||
|
||||
# 旧版本会在被中断的助手消息内容末尾追加 "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)
|
||||
]
|
||||
|
||||
# 先持久化用户提问和空的助手消息占位行,再执行向量检索。
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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]。",
|
||||
|
|
|
|||
Loading…
Reference in New Issue