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

main
mula.liu 2026-08-07 16:31:54 +08:00
parent 86b82111df
commit bd7d4d3dd5
22 changed files with 1573 additions and 297 deletions

View File

@ -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:
# 路线 Bembedding 对齐,为每个引用回填原文支撑句
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:
# 路线 Bembedding 对齐,为每个引用回填原文支撑句
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,
})

View File

@ -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("数据库结构已是最新,无需迁移")

View File

@ -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)

View File

@ -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="是否已删除")

View File

@ -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,

View File

@ -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)

View File

@ -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. 禁止复述总结或继续回答先前问题也不要重复先前助手的答案
请基于以上知识库内容用中文回答用户的问题"""

View File

@ -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
})

View File

@ -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。

View File

@ -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:

View File

@ -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

View File

@ -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),

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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') {

View File

@ -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)

View File

@ -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: <ProjectOutlined />,
ReadOutlined: <ReadOutlined />,
BookOutlined: <BookOutlined />,
CommentOutlined: <CommentOutlined />,
}
const builtInMenuMetaMap = {
@ -63,7 +65,7 @@ const builtInMenuMetaMap = {
},
'knowledge:my': {
path: '/chat',
icon: 'ReadOutlined',
icon: 'CommentOutlined',
},
'system:users': {
path: '/system/users',
@ -129,7 +131,7 @@ const resolveBuiltInMenuMeta = (item) => {
if (item.path === '/knowledge' || item.path === '/knowledge/my' || item.menu_name === '我的知识库') {
return {
path: '/chat',
icon: 'ReadOutlined',
icon: 'CommentOutlined',
}
}

View File

@ -65,11 +65,24 @@ body {
border-radius: 2px;
}
/* 知识库引用跳转定位mark.js 生成),样式与搜索高亮保持一致 */
.citation-highlight {
background-color: #ffd54f !important;
color: black !important;
font-weight: bold;
padding: 0 2px;
border-radius: 2px;
}
/* 从知识库引用跳转定位到的段落,短暂强调后淡出 */
.search-highlight.cited-highlight-flash {
animation: cited-flash 2.4s ease-out;
}
.citation-highlight.cited-highlight-flash {
animation: cited-flash 2.4s ease-out;
}
@keyframes cited-flash {
0%, 30% {
background-color: #ff9800 !important;

View File

@ -234,7 +234,7 @@
flex: 1;
min-height: 0;
overflow: auto;
padding: 18px 20px 20px;
padding: 18px 32px 20px;
display: flex;
flex-direction: column;
gap: 16px;
@ -250,37 +250,20 @@
justify-content: flex-end;
}
.chat-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
flex-shrink: 0;
}
.chat-avatar.user {
background: #1677ff;
}
.chat-avatar.assistant {
background: color-mix(in srgb, #1677ff 10%, var(--card-bg));
color: #1677ff;
}
.chat-message-bubble {
max-width: 100%;
width: fit-content;
padding: 14px 16px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--card-bg);
padding: 0;
border: 0;
background: transparent;
color: var(--text-color);
word-break: break-word;
}
.chat-message-bubble.user {
background: #1677ff;
color: #fff;
border-color: #1677ff;
background: color-mix(in srgb, var(--text-color) 8%, transparent);
border-radius: 10px;
padding: 10px 14px;
}
.chat-markdown {
@ -405,13 +388,13 @@
}
.chat-citation {
font-size: 0.72em;
font-size: 0.6em;
line-height: 0;
vertical-align: super;
color: #1677ff;
font-weight: 600;
margin: 0 3px;
padding: 0 1px;
margin: 0 2px;
padding: 0;
cursor: pointer;
}
@ -437,56 +420,90 @@
}
}
.chat-thinking {
.chat-thinking-panel {
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-thinking-panel.active {
margin: 2px 0;
}
.chat-thinking-timer {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-color-secondary);
font-size: 14px;
line-height: 1.75;
font-size: 13px;
line-height: 1.6;
}
.chat-thinking-text {
animation: chat-thinking-pulse 1.6s ease-in-out infinite;
.chat-thinking-log {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 12px;
margin-left: 2px;
border-left: 2px solid color-mix(in srgb, var(--border-color) 70%, transparent);
}
@keyframes chat-thinking-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
.chat-thinking-log-item {
display: flex;
align-items: baseline;
gap: 8px;
color: var(--text-color-secondary);
font-size: 13px;
line-height: 1.6;
}
.chat-thinking-dots {
.chat-thinking-log-time {
font-size: 12px;
color: color-mix(in srgb, var(--text-color-secondary) 75%, transparent);
white-space: nowrap;
}
.chat-thinking-log-final {
color: var(--text-color);
}
.chat-thinking-panel.collapsed {
margin-top: 10px;
}
.chat-thinking-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
gap: 6px;
border: 0;
background: transparent;
padding: 2px 0;
color: var(--text-color-secondary);
font-size: 13px;
cursor: pointer;
line-height: 1.6;
}
.chat-thinking-dots i {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
display: inline-block;
animation: chat-thinking-bounce 1.2s ease-in-out infinite;
.chat-thinking-toggle:hover {
color: #1677ff;
}
.chat-thinking-dots i:nth-child(2) {
animation-delay: 0.18s;
.chat-thinking-chevron {
font-size: 10px;
transition: transform 0.15s ease;
}
.chat-thinking-dots i:nth-child(3) {
animation-delay: 0.36s;
.chat-thinking-badge {
font-size: 12px;
line-height: 1.5;
color: #d4380d;
background: color-mix(in srgb, #d4380d 10%, transparent);
padding: 0 6px;
border-radius: 8px;
}
@keyframes chat-thinking-bounce {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.4;
}
40% {
transform: translateY(-4px);
opacity: 1;
}
.chat-thinking-duration {
color: var(--text-color-secondary);
}
.chat-message-column {
@ -545,23 +562,67 @@
}
.chat-references {
margin-top: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px dashed var(--border-color);
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-references-label {
display: block;
margin-bottom: 8px;
.chat-references-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.chat-references-label,
.chat-references-count {
font-size: 12px;
}
.chat-reference-link {
cursor: pointer;
.chat-references-label {
font-weight: 600;
}
.chat-reference-link:hover {
opacity: 0.82;
.chat-references-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chat-reference-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 3px 10px;
border: 1px solid var(--border-color);
border-radius: 999px;
background: color-mix(in srgb, var(--border-color) 6%, transparent);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
font-size: 12px;
}
.chat-reference-chip:hover {
border-color: color-mix(in srgb, #1677ff 45%, var(--border-color));
background: color-mix(in srgb, #1677ff 8%, transparent);
}
.chat-reference-number {
color: #1677ff;
font-weight: 600;
font-size: 11px;
flex-shrink: 0;
}
.chat-reference-file {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-message-error {
@ -583,9 +644,16 @@
max-width: 460px;
}
.chat-citation-preview-head {
display: flex;
align-items: center;
gap: 10px;
}
.chat-citation-preview-file {
display: block;
word-break: break-all;
flex: 1;
min-width: 0;
font-size: 12px;
}
.chat-citation-preview-content {
@ -599,6 +667,53 @@
background: color-mix(in srgb, var(--border-color) 10%, transparent);
}
.chat-citation-quotes {
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-citation-quote-row {
font-size: 13px;
line-height: 1.7;
}
.chat-citation-claim {
font-size: 13px;
line-height: 1.7;
color: var(--text-color);
background: color-mix(in srgb, #1677ff 8%, transparent);
border-radius: 6px;
padding: 6px 10px;
}
.chat-citation-quote {
font-size: 13px;
line-height: 1.7;
color: var(--text-color);
border-left: 3px solid #52c41a;
padding-left: 10px;
}
.chat-citation-quote-open {
color: #1677ff;
font-size: 13px;
cursor: pointer;
margin-left: 6px;
vertical-align: -1px;
}
.chat-citation-quote-open:hover {
color: #0958d9;
}
.chat-citation-fallback {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-end;
}
.chat-citation-preview-content > :first-child {
margin-top: 0;
}
@ -618,6 +733,13 @@
font-size: 0.92em;
}
.chat-citation-preview-content .chat-chunk-band {
border-radius: 3px;
background: color-mix(in srgb, #1677ff 14%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, #1677ff 14%, transparent);
color: inherit;
}
.chat-citation-popover .ant-popover-inner {
max-width: min(520px, calc(100vw - 40px));
}
@ -869,6 +991,11 @@ body.dark .chat-search-highlight {
color: #ffe58f;
}
body.dark .chat-citation-preview-content .chat-chunk-band {
background: color-mix(in srgb, #1677ff 26%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, #1677ff 26%, transparent);
}
body.dark .chat-composer-shell {
border-color: rgba(22, 119, 255, 0.45);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25);

View File

@ -32,64 +32,145 @@ import {
CheckOutlined,
StopOutlined,
ReloadOutlined,
LinkOutlined,
LoadingOutlined,
} from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
import Highlighter from 'react-highlight-words'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import rehypeRaw from 'rehype-raw'
import 'highlight.js/styles/github.css'
import { createChatSession, deleteChatMessage, deleteChatSession, getChatMessages, getChatSessions, markMessageInterrupted, searchChatMessages, sendChatMessageStream, updateChatSessionTitle } from '@/api/chat'
import { getMyProjects } from '@/api/project'
import { getLLMModelConfigs } from '@/api/llmModelConfigs'
import useUserStore from '@/stores/userStore'
import './Chat.css'
const { TextArea } = Input
const { Text } = Typography
const CITATION_RE = /\[(\d+)\]/g
// INTERRUPTED_RESPONSE_MARKER
const STOPPED_MARKER = 'interrupt'
const STOPPED_SUFFIX = `\n\n${STOPPED_MARKER}`
function isStoppedContent(content) {
return content === STOPPED_MARKER || content.endsWith(STOPPED_SUFFIX)
function formatDuration(ms) {
if (ms == null) return ''
const totalSeconds = Math.max(0, ms) / 1000
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}`
const minutes = Math.floor(totalSeconds / 60)
const seconds = Math.round(totalSeconds % 60)
return `${minutes}${seconds}`
}
function stripStoppedContent(content) {
if (content === STOPPED_MARKER) return ''
if (content.endsWith(STOPPED_SUFFIX)) {
return content.slice(0, -STOPPED_SUFFIX.length).replace(/\s+$/g, '')
function useElapsedTimer(startedAt, active) {
const [now, setNow] = useState(Date.now())
useEffect(() => {
if (!active) return undefined
setNow(Date.now())
const timer = window.setInterval(() => setNow(Date.now()), 500)
return () => window.clearInterval(timer)
}, [active, startedAt])
return active ? Math.max(0, now - (startedAt || now)) : null
}
function ThinkingPanel({ active, log = [], startedAt, durationMs, status, visible, onToggle }) {
const elapsed = useElapsedTimer(startedAt, active)
if (active) {
return (
<div className="chat-thinking-panel active">
<div className="chat-thinking-timer">
<LoadingOutlined spin /> 思考中 · {formatDuration(elapsed)}
</div>
{log.length > 0 && (
<div className="chat-thinking-log">
{log.map((entry, index) => (
<div key={index} className="chat-thinking-log-item">
<span>{entry.message}</span>
{entry.duration_ms != null && (
<span className="chat-thinking-log-time">用时 {formatDuration(entry.duration_ms)}</span>
)}
</div>
))}
</div>
)}
</div>
)
}
return content
const hasLog = log.length > 0
const hasDuration = durationMs != null
if (!hasLog && !hasDuration) return null
return (
<div className="chat-thinking-panel collapsed">
<button
type="button"
className="chat-thinking-toggle"
onClick={onToggle}
aria-expanded={visible}
>
<DownOutlined rotate={visible ? 180 : 0} className="chat-thinking-chevron" />
<span>思考过程</span>
{status === 'interrupted' && <span className="chat-thinking-badge">已停止</span>}
{hasDuration && <span className="chat-thinking-duration">· {formatDuration(durationMs)}</span>}
</button>
{visible && (
<div className="chat-thinking-log">
{log.map((entry, index) => (
<div key={index} className="chat-thinking-log-item">
<span>{entry.message}</span>
{entry.duration_ms != null && (
<span className="chat-thinking-log-time">用时 {formatDuration(entry.duration_ms)}</span>
)}
</div>
))}
{hasDuration && (
<div className="chat-thinking-log-item chat-thinking-log-final">
{status === 'interrupted' ? '已停止' : (status === 'error' ? '生成失败' : '完成')} · 用时 {formatDuration(durationMs)}
</div>
)}
</div>
)}
</div>
)
}
// rehype [n] <sup>便
// code/pre
function rehypeCitationSup() {
// 使
const occurrenceCounters = {}
const walk = (node, inCode) => {
if (!node.children) return
const nextChildren = []
for (const child of node.children) {
const childInCode = inCode || child.tagName === 'code' || child.tagName === 'pre'
if (child.type === 'text' && !childInCode && CITATION_RE.test(child.value)) {
if (child.type === 'text' && CITATION_RE.test(child.value)) {
CITATION_RE.lastIndex = 0
let lastIndex = 0
let lastPush = 0
let match
while ((match = CITATION_RE.exec(child.value)) !== null) {
if (match.index > lastIndex) {
nextChildren.push({ type: 'text', value: child.value.slice(lastIndex, match.index) })
const citationId = match[1]
// 使
const occIndex = occurrenceCounters[citationId] = (occurrenceCounters[citationId] ?? -1) + 1
if (!childInCode) {
if (match.index > lastPush) {
nextChildren.push({ type: 'text', value: child.value.slice(lastPush, match.index) })
}
nextChildren.push({
type: 'element',
tagName: 'sup',
properties: {
className: ['chat-citation'],
'data-citation-id': citationId,
'data-citation-occ': occIndex,
},
children: [{ type: 'text', value: `[${citationId}]` }],
})
lastPush = match.index + match[0].length
}
nextChildren.push({
type: 'element',
tagName: 'sup',
properties: { className: ['chat-citation'], 'data-citation-id': match[1] },
children: [{ type: 'text', value: `[${match[1]}]` }],
})
lastIndex = match.index + match[0].length
}
if (lastIndex < child.value.length) {
nextChildren.push({ type: 'text', value: child.value.slice(lastIndex) })
if (childInCode) {
nextChildren.push(child)
} else if (lastPush < child.value.length) {
nextChildren.push({ type: 'text', value: child.value.slice(lastPush) })
}
} else {
if (child.type === 'element') walk(child, childInCode)
@ -143,15 +224,46 @@ function stripMarkdown(text) {
.trim()
}
function buildDocumentPreviewUrl(projectId, filePath) {
if (!projectId || !filePath) return ''
return `/projects/${projectId}/docs?file=${encodeURIComponent(filePath)}`
// markdown 使
function stripMarkdownForHighlight(text) {
return String(text || '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]*)`/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/__([^_]+)__/g, '$1')
.replace(/_([^_]+)_/g, '$1')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/^#{1,6}\s+/gm, '')
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/^\s*\d+\.\s+/gm, '')
.replace(/^\s*>\s+/gm, '')
.replace(/~~([^~]+)~~/g, '$1')
.trim()
}
function openDocument(ref, projectId) {
function buildDocumentPreviewUrl(projectId, filePath, highlight) {
if (!projectId || !filePath) return ''
const params = new URLSearchParams()
params.set('file', filePath)
if (highlight) params.set('hl', highlight)
return `/projects/${projectId}/docs?${params.toString()}`
}
//
function getDocumentJumpKeyword(ref, occData) {
const quoteText = (occData?.quotes?.[0]?.text || ref?.quotes?.[0]?.text || '').trim()
if (quoteText) return stripMarkdownForHighlight(quoteText)
const anchor = (ref?.anchor_text || '').trim()
return anchor ? stripMarkdownForHighlight(anchor).slice(0, 32) : ''
}
function openDocument(ref, projectId, occData) {
const previewUrl = buildDocumentPreviewUrl(
ref?.project_id || projectId,
ref?.file_path
ref?.file_path,
getDocumentJumpKeyword(ref, occData)
)
if (previewUrl) window.open(previewUrl, '_blank', 'noopener,noreferrer')
}
@ -160,6 +272,27 @@ function getReferenceExcerpt(ref) {
return (ref?.excerpt || ref?.anchor_text || '').trim()
}
function getReferenceContext(ref) {
return (ref?.content || ref?.excerpt || ref?.anchor_text || '').trim()
}
function escapeAngleBrackets(value) {
return String(value ?? '').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
// chat-chunk-band
//
function highlightCitation(text, chunkText) {
const escaped = escapeAngleBrackets(text)
const chunk = escapeAngleBrackets(chunkText)
// CommonMark <mark>
if (!chunk || /\r?\n\s*\r?\n/.test(chunk) || !escaped.includes(chunk)) {
return escaped
}
const idx = escaped.indexOf(chunk)
return `${escaped.slice(0, idx)}<mark class="chat-chunk-band">${chunk}</mark>${escaped.slice(idx + chunk.length)}`
}
function loadPendingStops() {
try {
return JSON.parse(sessionStorage.getItem('nex-chat-pending-stops') || '{}')
@ -189,6 +322,43 @@ function SearchHighlight({ text, keyword }) {
)
}
function CitationMarkdown({ children }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight, rehypeRaw]}
components={{
mark: ({ children, node: _node, ...props }) => <mark {...props}>{children}</mark>,
}}
>
{children}
</ReactMarkdown>
)
}
function ReferenceCard({ reference, projectId }) {
return (
<span
className="chat-reference-chip"
role="button"
tabIndex={0}
title="打开原文"
onClick={() => openDocument(reference, projectId)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openDocument(reference, projectId)
}
}}
>
<span className="chat-reference-number">[{reference.citation_id}]</span>
<span className="chat-reference-file" title={reference.file_path || reference.file_name}>
{reference.file_name || reference.file_path}
</span>
</span>
)
}
async function copyText(text) {
const value = text || ''
try {
@ -254,14 +424,16 @@ function MessageActions({ content, onCopy, onDelete, onRegenerate }) {
function Chat() {
const navigate = useNavigate()
const location = useLocation()
const { user: currentUser } = useUserStore()
const [searchParams, setSearchParams] = useSearchParams()
const [sessions, setSessions] = useState([])
const [projects, setProjects] = useState([])
const [models, setModels] = useState([])
const [currentSession, setCurrentSession] = useState(null)
const [messages, setMessages] = useState([])
const [loadingSessions, setLoadingSessions] = useState(false)
//
const [loadingSessions, setLoadingSessions] = useState(
() => /[?&](session_id|sessionId)=/.test(window.location.search)
)
const [loadingMessages, setLoadingMessages] = useState(false)
const [sending, setSending] = useState(false)
const [inputValue, setInputValue] = useState('')
@ -292,6 +464,10 @@ function Chat() {
const value = searchParams.get('session_id') || searchParams.get('sessionId')
return value ? Number(value) : null
}, [searchParams])
// /
const openingExistingSession = Boolean(
sessionId && !newMode && !currentSession && (loadingSessions || loadingMessages)
)
const currentProject = useMemo(
() => projects.find((item) => item.id === newProjectId),
@ -316,19 +492,6 @@ function Chat() {
}
}
// /avatar API
const userAvatarUrl = useMemo(() => {
const avatar = currentUser?.avatar
if (!avatar) return null
if (avatar.startsWith('http')) return avatar
const parts = avatar.split('/')
if (parts.length >= 3) {
return `/api/v1/auth/avatar/${parts[0]}/${parts[2]}`
}
return null
}, [currentUser])
const userDisplayName = currentUser?.nickname || currentUser?.username
const groupedSessions = useMemo(() => {
const groups = []
const groupMap = new Map()
@ -410,22 +573,29 @@ function Chat() {
const res = await getChatMessages(id)
const rawMessages = res.data || []
const nextMessages = rawMessages.map((item) => {
const contentStopped = item.role === 'assistant' && isStoppedContent(item.content || '')
return contentStopped
? { ...item, stopped: true, status: 'done' }
: item
// completed/interrupted/error/pending
let status = item.status || 'pending'
if (status === 'completed') status = 'done'
if (item.role !== 'assistant') status = 'done'
return {
...item,
status,
durationMs: item.duration_ms ?? null,
thinkingLog: Array.isArray(item.thinking_log) ? item.thinking_log : [],
thinkingVisible: false,
}
})
//
//
//
//
if (pendingStopsRef.current[String(id)]) {
const lastAssistantIndex = [...nextMessages].reverse().findIndex((m) => m.role === 'assistant')
if (lastAssistantIndex !== -1) {
const idx = nextMessages.length - 1 - lastAssistantIndex
const lastAssistant = nextMessages[idx]
if (!(lastAssistant.content || '').trim()) {
nextMessages[idx] = { ...lastAssistant, stopped: true, status: 'done' }
//
nextMessages[idx] = { ...lastAssistant, status: 'interrupted' }
//
} else {
clearPendingStop(id)
}
@ -647,6 +817,10 @@ function Chat() {
references: [],
status: 'thinking',
referencesVisible: false,
thinkingLog: [],
thinkingStartedAt: Date.now(),
durationMs: null,
thinkingVisible: false,
created_at: new Date().toISOString(),
}
const baseMessages = options.initialMessages ?? messages
@ -687,6 +861,17 @@ function Chat() {
item.id === assistantMsgId ? { ...item, references: refs || [] } : item
)))
},
onThinking: (entry) => {
setMessages((prev) => prev.map((item) => (
item.id === assistantMsgId
? {
...item,
thinkingLog: [...(item.thinkingLog || []), entry],
thinkingStartedAt: item.thinkingStartedAt || Date.now(),
}
: item
)))
},
onChunk: (chunk) => {
setMessages((prev) => prev.map((item) => (
item.id === assistantMsgId ? { ...item, content: `${item.content}${chunk}`, status: 'streaming' } : item
@ -710,6 +895,11 @@ function Chat() {
...item,
content: data?.content ?? item.content,
status: 'done',
durationMs: data?.duration_ms ?? item.durationMs,
thinkingLog: Array.isArray(data?.thinking_log) && data.thinking_log.length > 0
? data.thinking_log
: item.thinkingLog,
thinkingVisible: false,
referencesVisible: true,
}
: item
@ -738,7 +928,7 @@ function Chat() {
await markMessageInterrupted(realId)
setMessages((prev) => prev.map((item) => (
item.id === assistantMessage.id || item.id === realId
? { ...item, id: realId, stopped: true, status: 'done' }
? { ...item, id: realId, status: 'interrupted' }
: item
)))
}
@ -752,9 +942,12 @@ function Chat() {
item.id === assistantMsgId
? {
...item,
status: aborted ? 'done' : 'error',
stopped: aborted,
status: aborted ? 'interrupted' : 'error',
error: aborted ? undefined : (error.message || '回答生成失败,请稍后重试'),
durationMs: aborted
? Date.now() - (item.thinkingStartedAt || Date.now())
: item.durationMs,
thinkingVisible: false,
referencesVisible: true,
}
: item
@ -792,6 +985,12 @@ function Chat() {
})
}
const toggleThinking = (id) => {
setMessages((prev) => prev.map((item) => (
item.id === id ? { ...item, thinkingVisible: !item.thinkingVisible } : item
)))
}
const handleSearch = async () => {
const keyword = searchKeyword.trim()
if (!keyword) {
@ -978,14 +1177,16 @@ function Chat() {
const isUser = item.role === 'user'
const refs = item.references || []
const rawContent = item.content || ''
//
const isStopped = !isUser && (item.stopped === true || isStoppedContent(rawContent))
const displayContent = isStopped ? stripStoppedContent(rawContent) : rawContent
const displayContent = rawContent
// / status
const status = item.status || 'pending'
const isInterrupted = !isUser && status === 'interrupted'
const isError = !isUser && status === 'error'
const isLive = !isUser && (status === 'thinking' || status === 'streaming' || status === 'pending')
const hasContent = Boolean(displayContent.trim())
// status=thinking
const isThinking = !isUser && !hasContent && (item.status === 'thinking' || item.status === undefined)
const isStreaming = !isUser && item.status === 'streaming'
const isError = !isUser && item.status === 'error'
const isThinking = !isUser && !hasContent && isLive
const isStreaming = !isUser && status === 'streaming'
const thinkingLog = Array.isArray(item.thinkingLog) ? item.thinkingLog : []
const showReferences = !isUser && item.referencesVisible !== false && refs.length > 0
const canDelete = typeof item.id === 'number' || /^\d+$/.test(String(item.id))
const projectId = currentSession?.project_id
@ -1001,7 +1202,6 @@ function Chat() {
}}
className={`chat-message-row ${isUser ? 'user' : 'assistant'}`}
>
{!isUser && <Avatar className="chat-avatar assistant" icon={<RobotOutlined />} />}
<div className="chat-message-column">
<div className={`chat-message-bubble ${isUser ? 'user' : 'assistant'}`}>
{isUser ? (
@ -1012,29 +1212,46 @@ function Chat() {
<div className="chat-message-error">
{item.error || '回答生成失败,请稍后重试'}
</div>
) : isStopped && !hasContent ? (
) : isInterrupted && !hasContent ? (
<div className="chat-message-stopped">已停止生成</div>
) : isThinking ? (
<div className="chat-thinking">
<span className="chat-thinking-text">正在思考</span>
<span className="chat-thinking-dots">
<i />
<i />
<i />
</span>
</div>
<ThinkingPanel active log={thinkingLog} startedAt={item.thinkingStartedAt} />
) : (
<div className={`chat-markdown${isStreaming ? ' streaming' : ''}`}>
<>
{isStreaming && (
<ThinkingPanel active log={thinkingLog} startedAt={item.thinkingStartedAt} />
)}
{!isLive && (thinkingLog.length > 0 || item.durationMs != null) && (
<ThinkingPanel
log={thinkingLog}
durationMs={item.durationMs}
status={isInterrupted ? 'interrupted' : (isError ? 'error' : 'done')}
visible={item.thinkingVisible}
onToggle={() => toggleThinking(item.id)}
/>
)}
<div className={`chat-markdown${isStreaming ? ' streaming' : ''}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight, rehypeCitationSup]}
components={{
sup: ({ children, ...props }) => {
const citationId = Number(props['data-citation-id'])
const occIndex = Number(props['data-citation-occ'] ?? 0)
const ref = refs.find((itemRef) => Number(itemRef.citation_id) === citationId)
if (!ref) {
return <sup {...props}>{children}</sup>
}
const occData = Array.isArray(ref?.quote_occurrences)
? ref.quote_occurrences[occIndex]
: null
const quotes = occData?.quotes?.length
? occData.quotes
: (Array.isArray(ref?.quotes) ? ref.quotes : [])
const claim = occData?.claim || ''
const excerpt = getReferenceExcerpt(ref)
const context = getReferenceContext(ref)
const hasBand = Boolean(ref?.content && excerpt && context.includes(excerpt))
return (
<Popover
trigger="hover"
@ -1042,16 +1259,40 @@ function Chat() {
overlayClassName="chat-citation-popover"
content={(
<div className="chat-citation-preview">
<Text type="secondary" className="chat-citation-preview-file">
{ref.file_name || ref.file_path}
</Text>
<div className="chat-citation-preview-head">
<Text type="secondary" className="chat-citation-preview-file" ellipsis={{ tooltip: ref.file_path || ref.file_name }}>
{ref.file_name || ref.file_path}
</Text>
</div>
<div className="chat-citation-preview-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
>
{getReferenceExcerpt(ref) || '暂无引用片段'}
</ReactMarkdown>
{quotes.length > 0 ? (
<div className="chat-citation-quotes">
{claim && (
<div className="chat-citation-claim">回答{claim}</div>
)}
{quotes.map((quote, quoteIdx) => (
<div key={quoteIdx} className="chat-citation-quote-row">
<span className="chat-citation-quote">原文{quote.text}</span>
<LinkOutlined
className="chat-citation-quote-open"
title="查看原文"
onClick={() => openDocument(ref, projectId, occData)}
/>
</div>
))}
</div>
) : (
<div className="chat-citation-fallback">
<CitationMarkdown>
{highlightCitation(context, hasBand ? excerpt : '') || '暂无引用片段'}
</CitationMarkdown>
<LinkOutlined
className="chat-citation-quote-open"
title="查看原文"
onClick={() => openDocument(ref, projectId, occData)}
/>
</div>
)}
</div>
</div>
)}
@ -1069,48 +1310,44 @@ function Chat() {
{item.error || '回答生成中断,请稍后重试'}
</div>
)}
{isStopped && hasContent && (
{isInterrupted && hasContent && (
<div className="chat-message-stopped">已停止生成</div>
)}
</div>
</div>
</>
)}
{showReferences && (
<div className="chat-references">
<Text type="secondary" className="chat-references-label">关联文档</Text>
<Space wrap>
<div className="chat-references-head">
<Text type="secondary" className="chat-references-label">引用来源</Text>
<Text type="secondary" className="chat-references-count">
{refs.length} 个来源
</Text>
</div>
<div className="chat-references-list">
{refs.map((ref) => {
return (
<Tag
<ReferenceCard
key={`${ref.citation_id}-${ref.file_path || ref.file_name}`}
color="blue"
className="chat-reference-link"
onClick={() => {
openDocument(ref, projectId)
}}
>
[{ref.citation_id}] {ref.file_name}
</Tag>
reference={ref}
projectId={projectId}
/>
)
})}
</Space>
</div>
</div>
)}
</>
)}
</div>
{(isUser || (!isThinking && !isStreaming)) && (
{(isUser || !isLive) && (
<MessageActions
content={displayContent}
onDelete={canDelete ? () => handleDeleteMessage(item) : undefined}
onRegenerate={!isUser && (isStopped || isError) ? () => handleRegenerate(item) : undefined}
onRegenerate={!isUser && (isInterrupted || isError) ? () => handleRegenerate(item) : undefined}
/>
)}
</div>
{isUser && (
<Avatar className="chat-avatar user" src={userAvatarUrl} style={{ backgroundColor: '#1677ff' }}>
{userDisplayName?.[0]?.toUpperCase() || 'U'}
</Avatar>
)}
</div>
)
})
@ -1308,7 +1545,11 @@ function Chat() {
</aside>
<main className="chat-main-panel">
{currentSession && !newMode ? renderChatShell() : renderNewShell()}
{openingExistingSession ? (
<div className="chat-panel-loading">
<Spin />
</div>
) : currentSession && !newMode ? renderChatShell() : renderNewShell()}
</main>
<Modal

View File

@ -9,6 +9,7 @@ import rehypeSlug from 'rehype-slug'
import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/github.css'
import Highlighter from 'react-highlight-words'
import Mark from 'mark.js'
import GithubSlugger from 'github-slugger'
import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file'
import { gitPull, gitPush, getGitRepos } from '@/api/project'
@ -62,6 +63,8 @@ function DocumentPage() {
//
const [searchKeyword, setSearchKeyword] = useState('')
//
const [highlightKeyword, setHighlightKeyword] = useState('')
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
const [isSearching, setIsSearching] = useState(false)
const [modeSwitchValue, setModeSwitchValue] = useState('view')
@ -200,6 +203,14 @@ function DocumentPage() {
const fileParam = searchParams.get('file')
const selectedParam = searchParams.get('selected')
const keywordParam = searchParams.get('keyword')
const highlightParam = searchParams.get('hl')
//
if (highlightParam && highlightParam !== highlightKeyword) {
setHighlightKeyword(highlightParam)
} else if (!highlightParam && highlightKeyword) {
setHighlightKeyword('')
}
//
if (keywordParam && keywordParam !== searchKeyword) {
@ -232,7 +243,23 @@ function DocumentPage() {
//
const handleSearch = async (value) => {
//
setSearchKeyword(value)
setHighlightKeyword('')
try {
// mark.js
if (contentRef.current) {
new Mark(contentRef.current).unmark({ className: 'citation-highlight' })
}
//
if (searchParams.get('hl')) {
const nextParams = new URLSearchParams(searchParams)
nextParams.delete('hl')
setSearchParams(nextParams, { replace: true })
}
} catch (error) {
console.warn('清理引用高亮失败:', error)
}
if (!value.trim()) {
setMatchedFilePaths(new Set())
return
@ -454,7 +481,7 @@ function DocumentPage() {
}
}, [markdownContent, isLargeMarkdown])
// URL keyword
//
useEffect(() => {
if (loading || !searchKeyword || !markdownContent) return
if (viewMode !== 'markdown') return
@ -478,6 +505,61 @@ function DocumentPage() {
}
}, [loading, markdownContent, searchKeyword, viewMode])
// URL hl mark.js
// markdown DOM
// mark.js
useEffect(() => {
if (!highlightKeyword || loading || !markdownContent || viewMode !== 'markdown') {
// hl mark.js
if (!isLargeMarkdown && contentRef.current) {
try {
new Mark(contentRef.current).unmark({ className: 'citation-highlight' })
} catch (error) {
console.warn('清理引用高亮失败:', error)
}
}
return
}
//
if (isLargeMarkdown) {
largeMarkdownRef.current?.scrollToKeyword?.(highlightKeyword)
return
}
const container = contentRef.current
if (!container) return
let canceled = false
const timer = window.setTimeout(() => {
if (canceled) return
try {
const instance = new Mark(container)
instance.unmark({ className: 'citation-highlight' })
instance.mark(highlightKeyword, {
className: 'citation-highlight',
separateWordSearch: false,
acrossElements: true,
done: () => {
const target = container.querySelector('.citation-highlight')
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
target.classList.add('cited-highlight-flash')
window.setTimeout(() => target.classList.remove('cited-highlight-flash'), 2400)
}
},
})
} catch (error) {
console.warn('引用高亮失败:', error)
}
}, 260)
return () => {
canceled = true
window.clearTimeout(timer)
}
}, [loading, highlightKeyword, markdownContent, viewMode, isLargeMarkdown])
//
const handleMenuClick = ({ key }) => {
const node = findNodeByKey(fileTree, key)