diff --git a/backend/app/api/v1/chat.py b/backend/app/api/v1/chat.py
index eebcec1..fc877d1 100644
--- a/backend/app/api/v1/chat.py
+++ b/backend/app/api/v1/chat.py
@@ -5,7 +5,8 @@ import asyncio
import json
import logging
import re
-from typing import Optional
+import time
+from typing import Optional, List, Dict, Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi.responses import StreamingResponse
@@ -35,20 +36,6 @@ router = APIRouter()
logger = logging.getLogger(__name__)
CHAT_HISTORY_MESSAGE_LIMIT = 20
-# 生成被客户端中断时写入助手消息的统一标记,前端据此识别“已停止生成”
-INTERRUPTED_RESPONSE_MARKER = "interrupt"
-
-
-def _is_interrupted_content(content: str) -> bool:
- """判断助手消息内容是否带中断标记。
-
- 中断标记要么单独成文(内容为空时),要么以 \\n\\n 追加在内容末尾。
- """
- if not content:
- return False
- return content == INTERRUPTED_RESPONSE_MARKER or content.endswith(
- f"\n\n{INTERRUPTED_RESPONSE_MARKER}"
- )
class VectorizeRequest(BaseModel):
@@ -183,17 +170,32 @@ def _normalize_refs(refs):
citation_id = ref.get("citation_id", idx)
anchor_text = ref.get("anchor_text") or ""
excerpt = ref.get("excerpt") or ""
+ content = ref.get("content") or ""
+ chunk_index = ref.get("chunk_index")
+ hit_terms = ref.get("hit_terms") or []
+ quotes = ref.get("quotes") or []
+ quote_occurrences = ref.get("quote_occurrences") or []
else:
file_path = ref
citation_id = idx
anchor_text = ""
excerpt = ""
+ content = ""
+ chunk_index = None
+ hit_terms = []
+ quotes = []
+ quote_occurrences = []
if file_path:
normalized.append({
"citation_id": citation_id,
"file_path": file_path,
"anchor_text": anchor_text,
"excerpt": excerpt,
+ "content": content,
+ "chunk_index": chunk_index,
+ "hit_terms": hit_terms,
+ "quotes": quotes,
+ "quote_occurrences": quote_occurrences,
})
return normalized
@@ -206,6 +208,11 @@ def _build_reference_items(refs, project_id=None):
"file_name": ref["file_path"].rsplit("/", 1)[-1],
"anchor_text": ref.get("anchor_text") or "",
"excerpt": ref.get("excerpt") or "",
+ "content": ref.get("content") or "",
+ "chunk_index": ref.get("chunk_index"),
+ "hit_terms": ref.get("hit_terms") or [],
+ "quotes": ref.get("quotes") or [],
+ "quote_occurrences": ref.get("quote_occurrences") or [],
"project_id": project_id,
}
for ref in _normalize_refs(refs)
@@ -283,9 +290,12 @@ def _compact_cited_refs(answer: str, retrieved_docs):
"citation_id": new_id,
"file_path": file_path,
"anchor_text": doc.get("anchor_text") or "",
- # 优先使用命中文档的上下文片段(命中点前后扩展),避免分块边界
- # 的短文本(如文档末尾残余块)导致引用预览内容过少
- "excerpt": doc.get("content") or doc.get("excerpt") or doc.get("anchor_text") or "",
+ # excerpt 保留精确命中的分块文本;content 保留命中点前后的
+ # 上下文窗口(前端据此标出"命中区间"并支持跳转原文)
+ "excerpt": doc.get("excerpt") or doc.get("anchor_text") or "",
+ "content": doc.get("content") or "",
+ "chunk_index": doc.get("chunk_index"),
+ "hit_terms": doc.get("hit_terms") or [],
})
old_to_new[old_id] = new_id
@@ -408,10 +418,19 @@ async def get_session_messages(
refs = stored_refs
if message.role == "assistant":
content, refs = _canonicalize_message_citations(content, stored_refs)
+ thinking_log = []
+ if message.thinking_log:
+ try:
+ thinking_log = json.loads(message.thinking_log) or []
+ except (TypeError, ValueError):
+ thinking_log = []
data.append({
"id": message.id,
"role": message.role,
"content": content,
+ "status": message.status or "pending",
+ "duration_ms": message.duration_ms,
+ "thinking_log": thinking_log,
"referenced_files": refs,
"references": _build_reference_items(refs, session.project_id) if message.role == "assistant" else [],
"created_at": message.created_at.isoformat(),
@@ -523,6 +542,7 @@ async def send_chat_message(
session_id=req.session_id,
role="user",
content=question,
+ status="completed",
)
db.add(query_message)
await db.flush()
@@ -543,19 +563,35 @@ async def send_chat_message(
conversation_history = [
{"role": m.role, "content": m.content}
for m in prev_messages
- # 被中断的助手消息(含统一中断标记)不参与上下文,避免污染后续模型输入
- if not (
- m.role == "assistant"
- and _is_interrupted_content(m.content or "")
- )
+ # 被中断的助手消息不参与上下文,避免污染后续模型输入
+ if not (m.role == "assistant" and m.status == "interrupted")
]
+ start_time = time.monotonic()
+ thinking_log = [
+ {
+ "stage": "retrieval",
+ "message": "正在检索知识库…",
+ "elapsed_ms": 0,
+ }
+ ]
retrieved_docs = await rag_service.retrieve_documents(
db,
session.project_id,
question,
top_k=5,
)
+ thinking_log.append({
+ "stage": "retrieved",
+ "message": f"已检索到 {len(retrieved_docs)} 个相关文档",
+ "count": len(retrieved_docs),
+ "elapsed_ms": int((time.monotonic() - start_time) * 1000),
+ })
+ thinking_log.append({
+ "stage": "generate",
+ "message": "正在生成回答…",
+ "elapsed_ms": int((time.monotonic() - start_time) * 1000),
+ })
assistant_response = await rag_service.generate_response(
db,
@@ -565,16 +601,29 @@ async def send_chat_message(
retrieved_docs,
conversation_history,
)
+ thinking_log.append({
+ "stage": "align",
+ "message": "正在整理引用…",
+ "elapsed_ms": int((time.monotonic() - start_time) * 1000),
+ })
# 仅保留实际引用的文档,并把引用编号压缩为从 1 开始的连续序列。
assistant_response, cited_refs = _compact_cited_refs(
assistant_response, retrieved_docs
)
+ if cited_refs:
+ # 路线 B:embedding 对齐,为每个引用回填原文支撑句
+ await rag_service.align_citation_quotes(
+ db, assistant_response, cited_refs
+ )
assistant_message = ChatMessage(
session_id=req.session_id,
role="assistant",
content=assistant_response,
+ status="completed",
+ duration_ms=int((time.monotonic() - start_time) * 1000),
+ thinking_log=json.dumps(thinking_log, ensure_ascii=False),
referenced_files=json.dumps(cited_refs, ensure_ascii=False) if cited_refs else None,
)
db.add(assistant_message)
@@ -636,11 +685,8 @@ async def send_chat_message_stream(
conversation_history = [
{"role": m.role, "content": m.content}
for m in prev_messages
- # 被中断的助手消息(含统一中断标记)不参与上下文,避免污染后续模型输入
- if not (
- m.role == "assistant"
- and _is_interrupted_content(m.content or "")
- )
+ # 被中断的助手消息不参与上下文,避免污染后续模型输入
+ if not (m.role == "assistant" and m.status == "interrupted")
]
# 先持久化用户提问和空的助手消息占位行,再执行向量检索。
@@ -656,6 +702,7 @@ async def send_chat_message_stream(
session_id=req.session_id,
role="user",
content=question,
+ status="completed",
)
db.add(query_message)
db.add(assistant_message)
@@ -665,71 +712,76 @@ async def send_chat_message_stream(
user_message_id = query_message.id if req.insert_user_message else None
assistant_message_id = assistant_message.id
- async def _mark_interrupted_assistant():
- """把中断标记写入仍为空的助手占位行(断开连接时任务可能正被取消)。"""
- try:
- msg = await db.get(ChatMessage, assistant_message_id)
- if msg is not None and not (msg.content or "").strip():
- msg.content = INTERRUPTED_RESPONSE_MARKER
- await db.commit()
- except Exception as save_exc: # noqa: BLE001
- logger.warning("保存中断状态失败: %s", save_exc)
-
- try:
- retrieved_docs = await rag_service.retrieve_documents(
- db,
- session.project_id,
- question,
- top_k=5,
- )
- except asyncio.CancelledError:
- # 客户端在向量检索阶段断开(停止生成):任务被取消,占位行需写入中断标记。
- await _mark_interrupted_assistant()
- raise
- except Exception:
- # 占位行已入库:把失败原因写进助手消息,避免留下永远显示“正在思考”的空行。
- try:
- msg = await db.get(ChatMessage, assistant_message_id)
- if msg is not None:
- msg.content = "回答生成失败,请稍后重试。"
- await db.commit()
- except Exception as save_exc: # noqa: BLE001
- logger.warning("保存检索失败状态失败: %s", save_exc)
- raise
-
llm_config_id = session.llm_config_id
project_id = session.project_id
+ # 生成总耗时起点:从消息占位行入库后开始计时,用于“思考过程”展示
+ start_time = time.monotonic()
# 每累计这么多字符就回写一次占位行,平衡「刷新可见性」与「写库频率」
FLUSH_EVERY_CHARS = 120
- async def _persist_assistant(parts, *, completed):
- """把已生成内容回写到占位的助手消息行。
-
- completed=True 时附带引用解析;False 表示流式中途的增量回写。
- """
- assistant_response = "".join(parts)
- cited_refs = []
- if completed:
- assistant_response, cited_refs = _compact_cited_refs(
- assistant_response, retrieved_docs
- )
- references = _build_reference_items(cited_refs, project_id)
-
- msg = await db.get(ChatMessage, assistant_message_id)
- if msg is not None:
- msg.content = assistant_response
- if completed:
- msg.referenced_files = (
- json.dumps(cited_refs, ensure_ascii=False) if cited_refs else None
- )
-
- await db.commit()
- return assistant_response, references
-
async def event_generator():
assistant_response_parts = []
+ thinking_log: List[Dict[str, Any]] = []
chars_since_flush = 0
finished = False
+
+ def _log(
+ stage: str,
+ message: str,
+ *,
+ duration_ms: Optional[int] = None,
+ count: Optional[int] = None,
+ ) -> Dict[str, Any]:
+ """记录一个思考过程条目。
+
+ duration_ms 表示该阶段已完成的实际耗时;进行中的阶段不填,
+ 由前端显示为进度状态,避免“开始时间点”造成的误导。
+ """
+ entry = {
+ "stage": stage,
+ "message": message,
+ }
+ if duration_ms is not None:
+ entry["duration_ms"] = duration_ms
+ if count is not None:
+ entry["count"] = count
+ thinking_log.append(entry)
+ return entry
+
+ async def _persist_assistant(parts, *, completed):
+ """把已生成内容回写到占位的助手消息行。
+
+ completed=True 时附带引用解析与最终状态;False 表示流式中途的增量回写。
+ """
+ assistant_response = "".join(parts)
+ cited_refs = []
+ if completed:
+ assistant_response, cited_refs = _compact_cited_refs(
+ assistant_response, retrieved_docs
+ )
+ if cited_refs:
+ # 路线 B:embedding 对齐,为每个引用回填原文支撑句
+ await rag_service.align_citation_quotes(
+ db, assistant_response, cited_refs
+ )
+ references = _build_reference_items(cited_refs, project_id)
+
+ msg = await db.get(ChatMessage, assistant_message_id)
+ if msg is not None:
+ msg.content = assistant_response
+ if completed:
+ msg.status = "completed"
+ msg.duration_ms = int((time.monotonic() - start_time) * 1000)
+ msg.thinking_log = (
+ json.dumps(thinking_log, ensure_ascii=False)
+ if thinking_log else None
+ )
+ msg.referenced_files = (
+ json.dumps(cited_refs, ensure_ascii=False) if cited_refs else None
+ )
+ await db.commit()
+ return assistant_response, references
+
try:
# 先回传两条消息的真实 id,使前端无需刷新即可获得删除入口等能力
yield _stream_event("ids", {
@@ -738,6 +790,27 @@ async def send_chat_message_stream(
"assistant_message_id": assistant_message_id,
})
+ # 检索阶段放在流内执行,客户端可以在检索过程中实时看到思考进度
+ yield _stream_event("thinking", _log("retrieval", "正在检索知识库…"))
+ retrieval_start = time.monotonic()
+ retrieved_docs = await rag_service.retrieve_documents(
+ db,
+ session.project_id,
+ question,
+ top_k=5,
+ )
+ yield _stream_event(
+ "thinking",
+ _log(
+ "retrieved",
+ f"已检索到 {len(retrieved_docs)} 个相关文档",
+ duration_ms=int((time.monotonic() - retrieval_start) * 1000),
+ count=len(retrieved_docs),
+ ),
+ )
+
+ yield _stream_event("thinking", _log("generate", "正在生成回答…"))
+ generate_start = time.monotonic()
async for chunk in rag_service.generate_response_stream(
db,
question,
@@ -755,10 +828,30 @@ async def send_chat_message_stream(
chars_since_flush = 0
await _persist_assistant(assistant_response_parts, completed=False)
+ yield _stream_event(
+ "thinking",
+ _log(
+ "generated",
+ "回答生成完成",
+ duration_ms=int((time.monotonic() - generate_start) * 1000),
+ ),
+ )
+
+ align_start = time.monotonic()
+ yield _stream_event("thinking", _log("align", "正在整理引用…"))
assistant_response, references = await _persist_assistant(
assistant_response_parts, completed=True
)
finished = True
+ yield _stream_event(
+ "thinking",
+ _log(
+ "aligned",
+ "引用整理完成",
+ duration_ms=int((time.monotonic() - align_start) * 1000),
+ ),
+ )
+ duration_ms = int((time.monotonic() - start_time) * 1000)
yield _stream_event("references", references or [])
yield _stream_event("done", {
@@ -767,18 +860,25 @@ async def send_chat_message_stream(
"assistant_message_id": assistant_message_id,
"content": assistant_response,
"references": references or [],
+ "status": "completed",
+ "duration_ms": duration_ms,
+ "thinking_log": thinking_log,
})
except (asyncio.CancelledError, GeneratorExit):
- # 客户端中途断开(如刷新页面):尽力把已生成的部分回写到占位行。
- # 由于提问与占位行已入库,刷新后至少能看到提问与已生成内容。
+ # 客户端中途断开(停止生成/刷新页面):把已生成内容与中断状态回写占位行。
if not finished:
- if not assistant_response_parts:
- assistant_response_parts.append(INTERRUPTED_RESPONSE_MARKER)
- else:
- assistant_response_parts.append(f"\n\n{INTERRUPTED_RESPONSE_MARKER}")
try:
await db.rollback()
- await _persist_assistant(assistant_response_parts, completed=False)
+ msg = await db.get(ChatMessage, assistant_message_id)
+ if msg is not None:
+ msg.content = "".join(assistant_response_parts)
+ msg.status = "interrupted"
+ msg.duration_ms = int((time.monotonic() - start_time) * 1000)
+ msg.thinking_log = (
+ json.dumps(thinking_log, ensure_ascii=False)
+ if thinking_log else None
+ )
+ await db.commit()
except Exception as save_exc: # noqa: BLE001
logger.warning("保存中断的助手回复失败: %s", save_exc)
raise
@@ -787,7 +887,16 @@ async def send_chat_message_stream(
if not assistant_response_parts:
assistant_response_parts.append("回答生成失败,请稍后重试。")
try:
- await _persist_assistant(assistant_response_parts, completed=False)
+ msg = await db.get(ChatMessage, assistant_message_id)
+ if msg is not None:
+ msg.content = "".join(assistant_response_parts)
+ msg.status = "error"
+ msg.duration_ms = int((time.monotonic() - start_time) * 1000)
+ msg.thinking_log = (
+ json.dumps(thinking_log, ensure_ascii=False)
+ if thinking_log else None
+ )
+ await db.commit()
except Exception as save_exc: # noqa: BLE001
logger.warning("保存失败的助手回复状态失败: %s", save_exc)
yield _stream_event("error", {"detail": f"生成对话回复失败: {str(exc)}"})
@@ -879,16 +988,14 @@ async def mark_message_interrupted(
if msg.role != "assistant":
raise HTTPException(status_code=400, detail="仅助手消息可标记为已停止")
- content = msg.content or ""
- if not content.strip():
- msg.content = INTERRUPTED_RESPONSE_MARKER
- elif not _is_interrupted_content(content):
- msg.content = f"{content.rstrip()}\n\n{INTERRUPTED_RESPONSE_MARKER}"
+ # 只更新状态标志,不再向回复内容里追加标记文本
+ msg.status = "interrupted"
await db.commit()
return success_response(data={
"message_id": msg.id,
- "content": msg.content,
+ "status": msg.status,
+ "duration_ms": msg.duration_ms,
})
diff --git a/backend/app/core/migrations.py b/backend/app/core/migrations.py
new file mode 100644
index 0000000..341f8d9
--- /dev/null
+++ b/backend/app/core/migrations.py
@@ -0,0 +1,100 @@
+"""
+轻量级数据库结构迁移
+
+项目没有引入 Alembic,新增列通过幂等的 ALTER TABLE 完成:
+先查询 information_schema 判断列是否已存在,不存在才执行 ALTER,
+保证多次启动/执行也不会报错或重复加列。
+"""
+import logging
+
+from sqlalchemy import text
+
+logger = logging.getLogger(__name__)
+
+
+# (表名, 列名, 建列语句)
+CHAT_MESSAGE_COLUMNS = [
+ (
+ "chat_message",
+ "status",
+ "ALTER TABLE chat_message ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT 'pending' "
+ "COMMENT '消息状态: pending/completed/interrupted/error'",
+ ),
+ (
+ "chat_message",
+ "duration_ms",
+ "ALTER TABLE chat_message ADD COLUMN duration_ms INT DEFAULT NULL "
+ "COMMENT '生成耗时(毫秒)'",
+ ),
+ (
+ "chat_message",
+ "thinking_log",
+ "ALTER TABLE chat_message ADD COLUMN thinking_log TEXT DEFAULT NULL "
+ "COMMENT '思考过程(JSON数组)'",
+ ),
+]
+
+
+async def _column_exists(conn, table_name: str, column_name: str) -> bool:
+ result = await conn.execute(
+ text(
+ "SELECT COUNT(*) FROM information_schema.COLUMNS "
+ "WHERE TABLE_SCHEMA = DATABASE() "
+ "AND TABLE_NAME = :table_name AND COLUMN_NAME = :column_name"
+ ),
+ {"table_name": table_name, "column_name": column_name},
+ )
+ return bool(result.scalar())
+
+
+async def migrate_schema() -> None:
+ """为存量数据库补齐新增列,并为历史助手消息回填状态。"""
+ from app.core.database import engine
+
+ added = []
+ async with engine.begin() as conn:
+ for table_name, column_name, ddl in CHAT_MESSAGE_COLUMNS:
+ try:
+ exists = await _column_exists(conn, table_name, column_name)
+ except Exception as exc: # noqa: BLE001
+ logger.warning("检查列 %s.%s 失败,跳过迁移: %s", table_name, column_name, exc)
+ return
+ if not exists:
+ 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:
+ logger.info("数据库结构已是最新,无需迁移")
diff --git a/backend/app/mcp/server.py b/backend/app/mcp/server.py
index 4763cba..3ee3bb8 100644
--- a/backend/app/mcp/server.py
+++ b/backend/app/mcp/server.py
@@ -7,6 +7,7 @@ from datetime import datetime
import hmac
from pathlib import Path
from typing import Any, Dict, List
+import uuid
from fastapi import HTTPException, Response
from sqlalchemy import select
@@ -75,7 +76,7 @@ def _ensure_file_not_exists(file_path: Path, path: str) -> None:
raise HTTPException(status_code=400, detail=f"文件已存在: {path}")
if mcp is not None:
- @mcp.tool(name="list_created_projects", description="Get projects created by the authenticated user.")
+ @mcp.tool(name="list_created_projects", description="获取当前用户创建的项目列表。")
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)
@@ -98,7 +99,73 @@ if mcp is not None:
return items[: max(limit, 0)]
- @mcp.tool(name="get_project_tree", description="Get the directory tree of a specific project.")
+ @mcp.tool(name="create_project", description="创建新项目(按项目名称创建,创建者自动成为项目管理员)。")
+ async def create_project(name: str, description: str = "") -> Dict[str, Any]:
+ """按项目名称创建新项目,返回项目 ID 与存储标识。"""
+ name = (name or "").strip()
+ if not name:
+ raise HTTPException(status_code=400, detail="项目名称不能为空")
+ if len(name) > 100:
+ raise HTTPException(status_code=400, detail="项目名称不能超过 100 个字符")
+
+ async with AsyncSessionLocal() as db:
+ current_user = await _get_current_user(db)
+
+ # 生成 UUID 作为存储键
+ storage_key = str(uuid.uuid4())
+ db_project = Project(
+ name=name,
+ description=(description or "").strip() or None,
+ storage_key=storage_key,
+ owner_id=current_user.id,
+ is_public=0,
+ status=1,
+ )
+ db.add(db_project)
+ await db.commit()
+ await db.refresh(db_project)
+
+ # 创建物理文件夹结构,失败则回滚数据库记录
+ try:
+ storage_service.create_project_structure(storage_key)
+ except Exception as exc: # noqa: BLE001
+ await db.delete(db_project)
+ await db.commit()
+ raise HTTPException(status_code=500, detail=f"项目文件夹创建失败: {exc}")
+
+ # 项目创建者自动成为管理员成员
+ db_member = ProjectMember(
+ project_id=db_project.id,
+ user_id=current_user.id,
+ role="admin",
+ )
+ db.add(db_member)
+ await db.commit()
+
+ # 记录操作日志(MCP 无 HTTP 请求对象,跳过 request 字段)
+ try:
+ from app.core.enums import OperationType
+ from app.services.log_service import log_service
+
+ await log_service.log_project_operation(
+ db=db,
+ operation_type=OperationType.CREATE_PROJECT,
+ project_id=db_project.id,
+ user=current_user,
+ detail={"project_name": name, "source": "mcp"},
+ )
+ except Exception as exc: # noqa: BLE001
+ pass
+
+ return {
+ "message": "项目创建成功",
+ "project_id": db_project.id,
+ "name": db_project.name,
+ "storage_key": db_project.storage_key,
+ }
+
+
+ @mcp.tool(name="get_project_tree", description="获取指定项目的目录树。")
async def get_project_tree(project_id: int) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
@@ -114,7 +181,7 @@ if mcp is not None:
}
- @mcp.tool(name="get_file", description="Read a file from a specific project.")
+ @mcp.tool(name="get_file", description="读取指定项目中的文件内容。")
async def get_file(project_id: int, path: str) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
@@ -125,7 +192,7 @@ if mcp is not None:
return {"path": path, "content": content}
- @mcp.tool(name="create_file", description="Create a new file in a specific project path.")
+ @mcp.tool(name="create_file", description="在指定项目的路径下创建新文件。")
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)
@@ -150,7 +217,7 @@ if mcp is not None:
}
- @mcp.tool(name="update_file", description="Update an existing file in a specific project path.")
+ @mcp.tool(name="update_file", description="更新指定项目中已有文件的内容。")
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)
@@ -174,7 +241,7 @@ if mcp is not None:
}
- @mcp.tool(name="delete_file", description="Delete an existing file in a specific project path.")
+ @mcp.tool(name="delete_file", description="删除指定项目中的文件。")
async def delete_file(project_id: int, path: str) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
diff --git a/backend/app/models/chat_session.py b/backend/app/models/chat_session.py
index 714cb9e..ba35f9e 100644
--- a/backend/app/models/chat_session.py
+++ b/backend/app/models/chat_session.py
@@ -35,6 +35,9 @@ class ChatMessage(Base):
session_id = Column(BigInteger, nullable=False, index=True, comment="会话ID")
role = Column(String(32), nullable=False, comment="角色(user/assistant)")
content = Column(Text, nullable=False, comment="消息内容")
+ status = Column(String(32), nullable=False, default="pending", comment="消息状态: pending/completed/interrupted/error")
+ duration_ms = Column(Integer, comment="生成耗时(毫秒)")
+ thinking_log = Column(Text, comment="思考过程(JSON数组)")
referenced_files = Column(Text, comment="参考文件(JSON数组)")
tokens_used = Column(Integer, comment="消耗的token数")
is_deleted = Column(Boolean, nullable=False, default=False, comment="是否已删除")
diff --git a/backend/app/services/llm_provider_service.py b/backend/app/services/llm_provider_service.py
index 9ce336d..487c309 100644
--- a/backend/app/services/llm_provider_service.py
+++ b/backend/app/services/llm_provider_service.py
@@ -879,6 +879,56 @@ class LLMProviderService:
)
return vector
+ @classmethod
+ async def generate_embeddings(
+ cls,
+ provider: Optional[str],
+ endpoint_url: str,
+ api_key: str,
+ llm_model_name: str,
+ texts: List[str],
+ timeout: int = 60,
+ dimension: Optional[int] = None,
+ ) -> List[List[float]]:
+ """批量生成多条文本向量,尽量在一次请求内完成。"""
+ provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
+ endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip()
+ llm_model_name = (llm_model_name or "").strip()
+ api_key = (api_key or "").strip()
+ inputs = [t[:8000] for t in (texts or []) if t and t.strip()]
+ if not inputs:
+ return []
+
+ if provider == "local":
+ from app.services.local_embedding_service import LocalEmbeddingService
+
+ vectors = await LocalEmbeddingService.generate_embeddings(llm_model_name, inputs)
+ if dimension:
+ for vector in vectors:
+ if len(vector) != int(dimension):
+ raise ValueError(
+ f"模型实际输出 {len(vector)} 维,与配置的 {dimension} 维不一致"
+ )
+ return vectors
+
+ if not endpoint_url:
+ raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
+ if not llm_model_name:
+ raise ValueError("缺少模型名称,请填写 embedding 模型标识")
+ if provider not in {"ollama"} and not api_key:
+ raise ValueError("缺少 API Key,请填写后再调用")
+
+ vectors = await asyncio.to_thread(
+ cls._embeddings_request,
+ endpoint_url,
+ api_key,
+ llm_model_name,
+ inputs,
+ int(timeout or 60),
+ dimension,
+ )
+ return vectors
+
@classmethod
def _embeddings_request(
cls,
diff --git a/backend/app/services/local_embedding_service.py b/backend/app/services/local_embedding_service.py
index b538d77..f5068d1 100644
--- a/backend/app/services/local_embedding_service.py
+++ b/backend/app/services/local_embedding_service.py
@@ -88,3 +88,19 @@ class LocalEmbeddingService:
async def generate_embedding(cls, model_name: str, text: str) -> List[float]:
model_path = cls.resolve_model_path(model_name)
return await asyncio.to_thread(cls._encode, model_path, text)
+
+ @classmethod
+ def _encode_batch(cls, model_path: Path, texts: List[str]) -> List[List[float]]:
+ model = cls._get_model(model_path)
+ vectors = model.encode(
+ texts,
+ normalize_embeddings=True,
+ convert_to_numpy=True,
+ show_progress_bar=False,
+ )
+ return [[float(value) for value in vector.tolist()] for vector in vectors]
+
+ @classmethod
+ async def generate_embeddings(cls, model_name: str, texts: List[str]) -> List[List[float]]:
+ model_path = cls.resolve_model_path(model_name)
+ return await asyncio.to_thread(cls._encode_batch, model_path, texts)
diff --git a/backend/app/services/rag_service.py b/backend/app/services/rag_service.py
index f80a616..688b439 100644
--- a/backend/app/services/rag_service.py
+++ b/backend/app/services/rag_service.py
@@ -1,12 +1,15 @@
"""
知识库 RAG 服务 - ZVec 向量检索 + 大模型生成
"""
+import re
+import math
import logging
from typing import AsyncIterator, List, Dict, Any
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
+import jieba
from app.models.llm_model_config import LLMModelConfig
from app.core.config import settings
@@ -23,10 +26,55 @@ MAX_CHUNKS_PER_DOCUMENT = 3
HISTORY_USER_QUESTION_LIMIT = 3
HISTORY_USER_QUESTION_MAX_CHARS = 300
+# 查询关键词提取时过滤的常见提问词/虚词,避免命中高亮时噪声过大
+QUERY_STOPWORDS = {
+ "的", "了", "吗", "呢", "啊", "哦", "嗯", "吧", "是", "在", "有", "和",
+ "与", "或", "及", "都", "也", "很", "这", "那", "你", "我", "他", "她",
+ "它", "们", "什么", "怎么", "如何", "怎样", "哪些", "哪个", "哪几",
+ "是否", "为什么", "一个", "这个", "那个", "你们", "我们", "他们",
+ "请问", "一下", "可以", "没有", "不是", "就是", "还是", "或者",
+ "包括", "包含", "关于", "对于", "以及", "其中", "请",
+ "what", "which", "who", "whom", "whose", "when", "where", "why",
+ "how", "is", "are", "was", "were", "do", "does", "did", "will",
+ "would", "should", "could", "can", "may", "might", "the", "a",
+ "an", "of", "to", "in", "for", "on", "with", "and", "or", "not",
+ "please", "tell", "show", "list", "about",
+}
+
+# 引用支撑句对齐参数
+CITATION_QUOTE_MIN_SCORE = 0.45
+CITATION_QUOTE_MAX_PER_REF = 3
+CITATION_QUOTE_MAX_PER_OCCURRENCE = 1
+CITATION_CANDIDATES_PER_REF = 4
+CITATION_CLAIM_MAX_CHARS = 200
+
class RAGService:
"""RAG 知识库检索和生成服务"""
+ @staticmethod
+ def _extract_query_terms(query: str, max_terms: int = 8) -> List[str]:
+ """从用户提问中提取用于命中高亮的关键词(jieba 分词 + 停用词过滤)。"""
+ text = (query or "").strip()
+ if not text:
+ return []
+ terms: List[str] = []
+ seen = set()
+ for word in jieba.cut_for_search(text):
+ word = word.strip()
+ if not word or word in QUERY_STOPWORDS or word.lower() in QUERY_STOPWORDS:
+ continue
+ if len(word) < 2:
+ continue
+ if not re.search(r"[\u4e00-\u9fffA-Za-z0-9]", word):
+ continue
+ if word not in seen:
+ seen.add(word)
+ terms.append(word)
+ if len(terms) >= max_terms:
+ break
+ return terms
+
@staticmethod
async def retrieve_documents(
db: AsyncSession,
@@ -41,6 +89,8 @@ class RAGService:
if not matched:
return []
+ hit_terms = RAGService._extract_query_terms(query)
+
from app.models.project import Project
stmt = select(Project).where(Project.id == project_id)
result = await db.execute(stmt)
@@ -79,6 +129,7 @@ class RAGService:
"score": item.get("score", 0.0),
"excerpt": excerpt,
"content": snippet,
+ "hit_terms": hit_terms,
"_merged_chunks": 1,
}
except Exception:
@@ -90,6 +141,7 @@ class RAGService:
"score": item.get("score", 0.0),
"excerpt": "",
"content": "",
+ "hit_terms": hit_terms,
"_merged_chunks": 1,
}
@@ -189,6 +241,192 @@ class RAGService:
end = min(len(text), pos + len(anchor) + CHUNK_CONTEXT_WINDOW)
return text[start:end]
+ @staticmethod
+ def _split_sentences(text: str) -> List[str]:
+ """按句末标点与换行切分句子,过滤过短片段。"""
+ parts = re.split(r"(?<=[。!?!?;;])\s*|\r?\n+", text or "")
+ sentences = []
+ for part in parts:
+ part = part.strip()
+ if len(part) >= 4:
+ sentences.append(part)
+ return sentences
+
+ @staticmethod
+ def _extract_citation_claims(answer: str) -> Dict[int, List[str]]:
+ """按出现顺序提取答案中每个 [n] 标记前的一句作为待对齐的论断文本。
+
+ 保留全部出现(不做去重),保证列表下标与答案中标记的出现顺序一一对应,
+ 供前端按出现位置精确定位每条引用的支撑句。
+ """
+ claims: Dict[int, List[str]] = {}
+ answer = answer or ""
+ for match in re.finditer(r"\[\d+\]", answer):
+ citation_id = int(match.group()[1:-1])
+ prefix = answer[:match.start()]
+ # 从上一个引用标记或最近的句末标点之后取论断句,支持句中标注
+ delimiter_matches = list(re.finditer(r"[。!?!?;;\n]", prefix))
+ last_delim_end = delimiter_matches[-1].end() if delimiter_matches else 0
+ last_marker_end = prefix.rfind("]") + 1 if "]" in prefix else 0
+ start = max(last_delim_end, last_marker_end)
+ claim = prefix[start:].strip(" \t,,、::")
+ if len(claim) < 4:
+ tail = re.sub(r"\[\d+\]", "", prefix).strip()
+ claim = tail[-CITATION_CLAIM_MAX_CHARS:] if tail else ""
+ if not claim:
+ continue
+ claim = re.sub(r"\[\d+\]", "", claim).strip()[:CITATION_CLAIM_MAX_CHARS]
+ bucket = claims.setdefault(citation_id, [])
+ bucket.append(claim)
+ return claims
+
+ @staticmethod
+ def _sentence_candidates(
+ ref_text: str,
+ claims: List[str],
+ max_candidates: int = CITATION_CANDIDATES_PER_REF,
+ ) -> List[str]:
+ """从引用文本中筛选候选支撑句:优先取与论断词重叠多的句子。"""
+ sentences = RAGService._split_sentences(ref_text)
+ if not sentences:
+ return []
+
+ claim_terms = set()
+ for claim in claims or []:
+ for word in jieba.cut_for_search(claim):
+ word = word.strip()
+ if len(word) >= 2 and word not in QUERY_STOPWORDS:
+ claim_terms.add(word)
+ if not claim_terms:
+ return sentences[:max_candidates]
+
+ scored = []
+ for sentence in sentences:
+ terms = set()
+ for word in jieba.cut_for_search(sentence):
+ word = word.strip()
+ if len(word) >= 2 and word not in QUERY_STOPWORDS:
+ terms.add(word)
+ overlap = len(terms & claim_terms)
+ if overlap > 0:
+ scored.append((overlap, sentence))
+ scored.sort(key=lambda item: item[0], reverse=True)
+ chosen = [sentence for _, sentence in scored[:max_candidates]]
+ # 词重叠为空时兜底取前几句,交给语义对齐判断
+ return chosen or sentences[:max_candidates]
+
+ @staticmethod
+ def _cosine_similarity(a: List[float], b: List[float]) -> float:
+ if not a or not b or len(a) != len(b):
+ return 0.0
+ dot = sum(x * y for x, y in zip(a, b))
+ norm_a = math.sqrt(sum(x * x for x in a))
+ norm_b = math.sqrt(sum(y * y for y in b))
+ if norm_a == 0 or norm_b == 0:
+ return 0.0
+ return dot / (norm_a * norm_b)
+
+ @classmethod
+ async def align_citation_quotes(
+ cls,
+ db: AsyncSession,
+ answer: str,
+ refs: List[Dict[str, Any]],
+ ) -> List[Dict[str, Any]]:
+ """为引用回填支撑句:答案论断句 ↔ 原文候选句做向量对齐。
+
+ 检索命中是语义匹配,答案又是 LLM 重写的,因此无法用词法精确对应。
+ 这里把答案中每个 [n] 出现位置前的答案句子与对应引用分块内的句子
+ 分别向量化,取相似度最高且超过阈值的原文句子作为「支撑句」。
+
+ 同文件多处引用会合并为同一个 citation_id,因此支撑句必须按「出现
+ 顺序」逐一回填:ref.quote_occurrences[k] 对应答案中该引用编号的第
+ k 次出现,供前端按出现位置精确展示;ref.quotes 保留聚合结果作兜底。
+ """
+ if not refs:
+ return refs
+ try:
+ claims_by_id = cls._extract_citation_claims(answer)
+ if not claims_by_id:
+ return refs
+
+ ref_by_id = {
+ int(ref.get("citation_id")): ref
+ for ref in refs
+ if ref.get("citation_id") is not None
+ }
+ if not ref_by_id:
+ return refs
+
+ # 去重后用于向量化,occurrence 级别的对齐仍按下标一一对应
+ all_claims: List[str] = []
+ claim_index: Dict[str, int] = {}
+ for bucket in claims_by_id.values():
+ for claim in bucket:
+ if claim not in claim_index:
+ claim_index[claim] = len(all_claims)
+ all_claims.append(claim)
+
+ candidates_by_id: Dict[int, List[str]] = {}
+ for citation_id, ref in ref_by_id.items():
+ ref_text = ref.get("excerpt") or ref.get("content") or ""
+ candidates = cls._sentence_candidates(
+ ref_text, claims_by_id.get(citation_id, [])
+ )
+ if candidates:
+ candidates_by_id[citation_id] = candidates
+ if not candidates_by_id:
+ return refs
+
+ texts_to_embed: List[str] = list(all_claims)
+ for candidates in candidates_by_id.values():
+ for candidate in candidates:
+ if candidate not in texts_to_embed:
+ texts_to_embed.append(candidate)
+
+ vectors = await zvec_service.generate_embeddings(db, texts_to_embed)
+ if not vectors or len(vectors) != len(texts_to_embed):
+ logger.warning(
+ "Citation quote alignment skipped: embedding unavailable"
+ )
+ return refs
+
+ text_index = {text: idx for idx, text in enumerate(texts_to_embed)}
+ for citation_id, ref in ref_by_id.items():
+ occurrences = []
+ all_quotes = []
+ candidates = candidates_by_id.get(citation_id, [])
+ for claim in claims_by_id.get(citation_id, []):
+ claim_vec = vectors[claim_index[claim]]
+ scored = []
+ for candidate in candidates:
+ score = cls._cosine_similarity(
+ claim_vec, vectors[text_index[candidate]]
+ )
+ if score >= CITATION_QUOTE_MIN_SCORE:
+ scored.append((score, candidate))
+ scored.sort(key=lambda item: item[0], reverse=True)
+ occ_quotes = []
+ for score, candidate_text in scored[:CITATION_QUOTE_MAX_PER_OCCURRENCE]:
+ if not any(q["text"] == candidate_text for q in occ_quotes):
+ occ_quotes.append({
+ "text": candidate_text,
+ "score": round(score, 4),
+ })
+ if not any(q["text"] == candidate_text for q in all_quotes):
+ all_quotes.append({
+ "text": candidate_text,
+ "score": round(score, 4),
+ })
+ occurrences.append({"claim": claim, "quotes": occ_quotes})
+ all_quotes.sort(key=lambda q: q["score"], reverse=True)
+ ref["quote_occurrences"] = occurrences
+ ref["quotes"] = all_quotes[:CITATION_QUOTE_MAX_PER_REF]
+ return refs
+ except Exception as exc: # noqa: BLE001
+ logger.warning(f"Citation quote alignment failed: {exc}")
+ return refs
+
@staticmethod
async def generate_response(
db: AsyncSession,
@@ -282,10 +520,11 @@ class RAGService:
回答要求:
1. 仅基于知识库内容回答,不要编造。
-2. 如果使用了某条知识,请在对应句子后标注引用编号,例如 [1] 或 [1][3]。
-3. 如果没有找到依据,请直接说明未检索到相关内容。
-4. 只回答消息列表中最后一个用户问题;先前用户问题仅用于理解指代。
-5. 禁止复述、总结或继续回答先前问题,也不要重复先前助手的答案。
+2. 每使用一条来自文档的知识,都必须在该句末尾紧跟标注引用编号,格式为半角方括号数字(如 [1]),多个来源连写(如 [1][3])。
+3. 引用编号必须与「知识库文档内容」中的 [编号] 一一对应,只标注实际引用的文档。
+4. 如果没有找到依据,请直接说明未检索到相关内容,此时不要标注引用。
+5. 只回答消息列表中最后一个用户问题;先前用户问题仅用于理解指代。
+6. 禁止复述、总结或继续回答先前问题,也不要重复先前助手的答案。
请基于以上知识库内容,用中文回答用户的问题。"""
diff --git a/backend/app/services/search_service.py b/backend/app/services/search_service.py
index b864439..2e2ce28 100644
--- a/backend/app/services/search_service.py
+++ b/backend/app/services/search_service.py
@@ -143,21 +143,39 @@ class SearchService:
try:
with self.ix.searcher() as searcher:
- parser = MultifieldParser(["title", "content"], schema=self.ix.schema)
- query = parser.parse(keyword)
-
filter_query = None
if project_id:
filter_parser = QueryParser("project_id", schema=self.ix.schema)
filter_query = filter_parser.parse(str(project_id))
+
+ # 中文分词后(如"标签表" -> [标签, 表])用 Phrase 做相邻短语匹配:
+ # 只有原文中确实连续出现"标签表"才算命中,避免"标签"与"表"
+ # 分散在不同位置也被匹配出来。whoosh3 修复了 2.7.4 在 Python 3.12
+ # 下 Phrase 查询失效的问题,因此这里使用标准的 Phrase 查询。
+ analyzer = ChineseAnalyzer()
+ tokens = [token.text for token in analyzer(keyword or "") if token.text.strip()]
+ if tokens:
+ from whoosh.query import Or, Phrase
+
+ query = Or([
+ Phrase("title", tokens),
+ Phrase("content", tokens),
+ ])
+ else:
+ parser = MultifieldParser(["title", "content"], schema=self.ix.schema)
+ query = parser.parse(keyword)
- results = searcher.search(query, filter=filter_query, limit=limit)
- results.formatter = HtmlFormatter(tagname="mark", classname="search-highlight", termclass="search-term")
+ results = searcher.search(
+ query, filter=filter_query, limit=limit
+ )
+ results.formatter = HtmlFormatter(
+ tagname="mark", classname="search-highlight", termclass="search-term"
+ )
search_results = []
for hit in results:
# 提取原始路径 (去掉 project_id 前缀)
- full_path = hit.get("path", "")
+ full_path = hit.fields().get("path", "")
if ":" in full_path:
_, real_path = full_path.split(":", 1)
else:
@@ -165,14 +183,14 @@ class SearchService:
# 安全获取高亮
try:
- highlights = hit.highlights("content") or hit.highlights("title") or hit.get("title", "")
+ highlights = hit.highlights("content") or hit.highlights("title") or hit.fields().get("title", "")
except:
- highlights = hit.get("title", "")
+ highlights = hit.fields().get("title", "")
search_results.append({
- "project_id": hit.get("project_id"),
+ "project_id": hit.fields().get("project_id"),
"path": real_path,
- "title": hit.get("title"),
+ "title": hit.fields().get("title"),
"highlights": highlights,
"score": hit.score
})
diff --git a/backend/app/services/zvec_service.py b/backend/app/services/zvec_service.py
index 0a2a476..b4e3b6e 100644
--- a/backend/app/services/zvec_service.py
+++ b/backend/app/services/zvec_service.py
@@ -178,6 +178,37 @@ class ZVecService:
logger.error(f"Embedding generation failed: {exc}")
return None
+ @classmethod
+ async def generate_embeddings(
+ cls,
+ db: AsyncSession,
+ texts: List[str],
+ config: Optional[LLMModelConfig] = None,
+ ) -> Optional[List[List[float]]]:
+ """批量生成多条文本向量,调用一次模型服务。"""
+ inputs = [t.strip() for t in (texts or []) if t and t.strip()]
+ if not inputs:
+ return []
+
+ config = config or await cls.get_embedding_config(db)
+ if not config:
+ logger.warning("No embedding model configured, skipping vectorization")
+ return None
+
+ try:
+ return await LLMProviderService.generate_embeddings(
+ provider=config.provider,
+ endpoint_url=config.endpoint_url,
+ api_key=config.api_key,
+ llm_model_name=config.llm_model_name,
+ texts=inputs,
+ timeout=config.llm_timeout or 60,
+ dimension=config.embedding_dimension,
+ )
+ except Exception as exc:
+ logger.error(f"Batch embedding generation failed: {exc}")
+ return None
+
@staticmethod
def _doc_id(file_path: str, chunk_index: int = 0) -> str:
"""生成 ZVec 合法的 doc id。
diff --git a/backend/main.py b/backend/main.py
index 703690d..93bd4fa 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -6,6 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.core.config import settings
from app.core.redis_client import init_redis, close_redis
+from app.core.migrations import migrate_schema
from app.api.v1 import api_router
from app.mcp import create_mcp_http_app, get_mcp_session_manager, MCPHeaderAuthApp
@@ -21,6 +22,8 @@ except RuntimeError:
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
+ # 启动时补齐数据库新增列(幂等),避免存量库缺少新字段
+ await migrate_schema()
# 启动时初始化 Redis
await init_redis()
if mcp_session_manager is not None:
diff --git a/backend/requirements.txt b/backend/requirements.txt
index a2329be..4a8f7bc 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -43,7 +43,7 @@ uvicorn>=0.31.1
uvloop==0.22.1
watchfiles==1.1.1
websockets==15.0.1
-Whoosh==2.7.4
+whoosh3==3.33.1
markdown==3.5.2
sentence-transformers>=3.0,<6
weasyprint==61.2
diff --git a/backend/scripts/init_database.sql b/backend/scripts/init_database.sql
index 730153b..1557a77 100644
--- a/backend/scripts/init_database.sql
+++ b/backend/scripts/init_database.sql
@@ -267,6 +267,9 @@ CREATE TABLE IF NOT EXISTS `chat_message` (
`session_id` BIGINT NOT NULL COMMENT '会话ID',
`role` VARCHAR(32) NOT NULL COMMENT '角色(user/assistant)',
`content` TEXT NOT NULL COMMENT '消息内容',
+ `status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '消息状态: pending/completed/interrupted/error',
+ `duration_ms` INT DEFAULT NULL COMMENT '生成耗时(毫秒)',
+ `thinking_log` TEXT DEFAULT NULL COMMENT '思考过程(JSON数组)',
`referenced_files` TEXT DEFAULT NULL COMMENT '参考文件(JSON数组)',
`tokens_used` INT DEFAULT NULL COMMENT '消耗的token数',
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已删除',
@@ -314,7 +317,7 @@ INSERT INTO `system_menus` (`id`, `parent_id`, `menu_name`, `menu_code`, `menu_t
(4, 1, '编辑项目', 'project:edit', 3, NULL, NULL, 3, 'project:edit'),
(5, 1, '删除项目', 'project:delete', 3, NULL, NULL, 4, 'project:delete'),
(10, 0, '知识库管理', 'knowledge', 1, NULL, 'FileTextOutlined', 2, NULL),
-(11, 10, '我的知识库', 'knowledge:view', 2, '/chat', NULL, 1, 'knowledge:view'),
+(11, 10, '我的知识库', 'knowledge:view', 2, '/chat', 'CommentOutlined', 1, 'knowledge:view'),
(12, 10, '编辑知识库', 'knowledge:edit', 3, NULL, NULL, 2, 'knowledge:edit'),
(13, 10, '删除知识库', 'knowledge:delete', 3, NULL, NULL, 3, 'knowledge:delete'),
(20, 0, '系统管理', 'system', 1, '/system', 'SettingOutlined', 3, NULL),
diff --git a/backend/scripts/init_db.py b/backend/scripts/init_db.py
index d67d10a..5924b42 100644
--- a/backend/scripts/init_db.py
+++ b/backend/scripts/init_db.py
@@ -17,6 +17,7 @@ from app.models.role import Role
from app.models.menu import SystemMenu
from app.models.project import Project, ProjectMember
from app.core.security import get_password_hash
+from app.core.migrations import migrate_schema
async def init_tables():
@@ -31,6 +32,9 @@ async def init_tables():
await conn.run_sync(Base.metadata.create_all)
print("✓ 数据库表创建成功")
+ print("正在执行数据库结构迁移...")
+ await migrate_schema()
+ print("✓ 数据库结构迁移完成")
async def init_roles():
@@ -213,7 +217,7 @@ async def init_menus():
menu_type=1,
path="/knowledge",
component="MyKnowledge",
- icon="ReadOutlined",
+ icon="CommentOutlined",
sort_order=1,
visible=1,
status=1
diff --git a/backend/tests/test_chat_citations.py b/backend/tests/test_chat_citations.py
index 6e57dd9..686a294 100644
--- a/backend/tests/test_chat_citations.py
+++ b/backend/tests/test_chat_citations.py
@@ -1,6 +1,6 @@
import unittest
from types import SimpleNamespace
-from unittest.mock import AsyncMock
+from unittest.mock import AsyncMock, patch
from app.api.v1.chat import _canonicalize_message_citations, _compact_cited_refs
from app.services.rag_service import RAGService
@@ -44,6 +44,9 @@ class ChatCitationTest(unittest.TestCase):
"file_path": "内容导航.md",
"anchor_text": "",
"excerpt": "",
+ "content": "",
+ "chunk_index": None,
+ "hit_terms": [],
}])
def test_used_references_follow_first_appearance_order(self):
@@ -91,5 +94,91 @@ class RAGConversationContextTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("禁止复述、总结或继续回答先前问题", system_prompt)
+class CitationQuoteExtractionTest(unittest.TestCase):
+ def test_split_sentences(self):
+ self.assertEqual(
+ RAGService._split_sentences("第一句。第二句!第三句?\n第四句;第五句话。"),
+ ["第一句。", "第二句!", "第三句?", "第四句;", "第五句话。"],
+ )
+
+ def test_extract_citation_claims(self):
+ answer = (
+ "土星拥有众多卫星[1]。泰坦是其中最大的一颗[1]。\n"
+ "关键参数:直径约 120536 公里[1]。\n"
+ "该产品型号为 X200[1],电池容量 5000mAh[1]。"
+ )
+ claims = RAGService._extract_citation_claims(answer)
+ self.assertEqual(claims[1], [
+ "土星拥有众多卫星",
+ "泰坦是其中最大的一颗",
+ "关键参数:直径约 120536 公里",
+ "该产品型号为 X200",
+ "电池容量 5000mAh",
+ ])
+
+ def test_sentence_candidates_prefer_term_overlap(self):
+ candidates = RAGService._sentence_candidates(
+ "土星是太阳系第二大行星。\n土卫六又称泰坦,是土星最大的卫星。\n"
+ "木星拥有最多卫星。",
+ ["泰坦是最大的卫星"],
+ max_candidates=3,
+ )
+ self.assertIn("土卫六又称泰坦,是土星最大的卫星。", candidates[:1])
+
+ def test_cosine_similarity(self):
+ self.assertAlmostEqual(RAGService._cosine_similarity([1.0, 0.0], [1.0, 0.0]), 1.0)
+ self.assertAlmostEqual(RAGService._cosine_similarity([1.0, 0.0], [0.0, 1.0]), 0.0)
+ self.assertEqual(RAGService._cosine_similarity([], [1.0]), 0.0)
+
+
+class CitationQuoteAlignmentTest(unittest.IsolatedAsyncioTestCase):
+ async def test_align_citation_quotes_picks_similar_sentence(self):
+ refs = [{
+ "citation_id": 1,
+ "file_path": "产品资料.md",
+ "anchor_text": "型号 X200",
+ "excerpt": "型号 X200 支持 5G。\n电池容量为 5000mAh。\n屏幕尺寸 6.7 英寸。",
+ "content": "",
+ }]
+ answer = "该产品型号为 X200[1],电池续航出色[1]。"
+
+ async def fake_embeddings(db, texts, config=None):
+ return [
+ [1.0, 0.0, 0.0] if ("电池" in text or "5000mAh" in text) else [0.0, 1.0, 0.0]
+ for text in texts
+ ]
+
+ with patch("app.services.rag_service.zvec_service") as mock_zvec:
+ mock_zvec.generate_embeddings = fake_embeddings
+ result = await RAGService.align_citation_quotes(None, answer, refs)
+
+ self.assertIn("quotes", result[0])
+ self.assertTrue(any(
+ "5000mAh" in q["text"] or "电池" in q["text"]
+ for q in result[0]["quotes"]
+ ))
+ # 按出现顺序回填:第一处对应型号,第二处对应电池
+ occurrences = result[0]["quote_occurrences"]
+ self.assertEqual(len(occurrences), 2)
+ self.assertEqual(occurrences[0]["claim"], "该产品型号为 X200")
+ self.assertEqual(occurrences[1]["claim"], "电池续航出色")
+ self.assertTrue(any("电池" in q["text"] for q in occurrences[1]["quotes"]))
+
+ async def test_align_citation_quotes_falls_back_when_embedding_unavailable(self):
+ refs = [{
+ "citation_id": 1,
+ "file_path": "a.md",
+ "excerpt": "某句支撑内容。",
+ "content": "",
+ }]
+
+ with patch("app.services.rag_service.zvec_service") as mock_zvec:
+ mock_zvec.generate_embeddings = AsyncMock(return_value=None)
+ result = await RAGService.align_citation_quotes(None, "这是论断[1]。", refs)
+
+ self.assertEqual(result, refs)
+ self.assertNotIn("quotes", result[0])
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/backend/tests/test_search_service.py b/backend/tests/test_search_service.py
new file mode 100644
index 0000000..44a025f
--- /dev/null
+++ b/backend/tests/test_search_service.py
@@ -0,0 +1,72 @@
+import asyncio
+import tempfile
+import unittest
+
+from whoosh import index
+from whoosh.fields import Schema, TEXT, ID
+
+from app.services.search_service import SearchService, ChineseAnalyzer
+
+
+class SearchServiceTest(unittest.TestCase):
+ def _build_service(self):
+ tmp = tempfile.mkdtemp()
+ schema = Schema(
+ project_id=ID(stored=True),
+ path=ID(unique=True, stored=True),
+ title=TEXT(stored=True, analyzer=ChineseAnalyzer()),
+ content=TEXT(stored=True, analyzer=ChineseAnalyzer()),
+ )
+ ix = index.create_in(tmp, schema)
+ writer = ix.writer()
+ writer.add_document(
+ project_id="1",
+ path="1:标签表.md",
+ title="标签表.md",
+ content="本文档介绍标签表的使用方法。",
+ )
+ writer.add_document(
+ project_id="1",
+ path="1:标签分类.md",
+ title="标签分类.md",
+ content="文档按标签进行分类,分类表存储记录。",
+ )
+ writer.add_document(
+ project_id="1",
+ path="1:标签说明.md",
+ title="标签说明.md",
+ content="标签用于内容分类。",
+ )
+ writer.add_document(
+ project_id="1",
+ path="1:配置表.md",
+ title="配置表.md",
+ content="该表用于系统配置。",
+ )
+ writer.commit()
+
+ service = SearchService()
+ service.ix = ix
+ return service
+
+ def test_phrase_keyword_matches_only_exact_content(self):
+ """搜索「标签表」只应命中原文确实包含该短语的文件,
+ 而不是「标签」+「表」分散出现在不同位置的文件。"""
+ service = self._build_service()
+ result = asyncio.run(service.search("标签表", "1"))
+ self.assertEqual([item["path"] for item in result], ["标签表.md"])
+
+ def test_single_token_search_returns_all_matches(self):
+ service = self._build_service()
+ result = asyncio.run(service.search("标签", "1"))
+ paths = {item["path"] for item in result}
+ self.assertEqual(paths, {"标签表.md", "标签分类.md", "标签说明.md"})
+
+ def test_no_match_returns_empty(self):
+ service = self._build_service()
+ result = asyncio.run(service.search("接口文档", "1"))
+ self.assertEqual(result, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/frontend/src/api/chat.js b/frontend/src/api/chat.js
index de49dd0..e65c5fe 100644
--- a/frontend/src/api/chat.js
+++ b/frontend/src/api/chat.js
@@ -71,6 +71,7 @@ export const sendChatMessageStream = async (sessionId, message, handlers = {}) =
if (event === 'ids') handlers.onIds?.(data)
if (event === 'references') handlers.onReferences?.(data || [])
+ if (event === 'thinking') handlers.onThinking?.(data)
if (event === 'chunk') handlers.onChunk?.(data?.content || '')
if (event === 'title') handlers.onTitle?.(data)
if (event === 'done') {
diff --git a/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.jsx b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.jsx
index 5076589..1930c42 100644
--- a/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.jsx
+++ b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.jsx
@@ -113,7 +113,15 @@ const LargeMarkdownViewer = forwardRef(function LargeMarkdownViewer(
useImperativeHandle(ref, () => ({
scrollToTop: () => scrollToIndex(0),
- }), [])
+ // 引用跳转/搜索定位:找到包含关键词的文档块并滚动到该块
+ scrollToKeyword: (keyword) => {
+ if (!keyword) return
+ const index = markdownBlocks.findIndex((block) => block.includes(keyword))
+ if (index >= 0) {
+ scrollToIndex(index)
+ }
+ },
+ }), [markdownBlocks])
const handleTocNavigate = (link) => {
const index = hrefToIndex.get(link)
diff --git a/frontend/src/components/MainLayout/AppSider.jsx b/frontend/src/components/MainLayout/AppSider.jsx
index 0ed13a2..6a670d7 100644
--- a/frontend/src/components/MainLayout/AppSider.jsx
+++ b/frontend/src/components/MainLayout/AppSider.jsx
@@ -18,6 +18,7 @@ import {
RocketOutlined,
ReadOutlined,
BookOutlined,
+ CommentOutlined,
} from '@ant-design/icons'
import { message } from 'antd'
import { getUserMenus } from '@/api/menu'
@@ -42,6 +43,7 @@ const iconMap = {
ProjectOutlined: