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: , ReadOutlined: , BookOutlined: , + 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', } } diff --git a/frontend/src/index.css b/frontend/src/index.css index c7b19d5..f380e4a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/pages/Chat/Chat.css b/frontend/src/pages/Chat/Chat.css index 8747cab..7765bcd 100644 --- a/frontend/src/pages/Chat/Chat.css +++ b/frontend/src/pages/Chat/Chat.css @@ -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); diff --git a/frontend/src/pages/Chat/Chat.jsx b/frontend/src/pages/Chat/Chat.jsx index 698f160..d85b7f0 100644 --- a/frontend/src/pages/Chat/Chat.jsx +++ b/frontend/src/pages/Chat/Chat.jsx @@ -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 ( +
+
+ 思考中 · {formatDuration(elapsed)} +
+ {log.length > 0 && ( +
+ {log.map((entry, index) => ( +
+ {entry.message} + {entry.duration_ms != null && ( + 用时 {formatDuration(entry.duration_ms)} + )} +
+ ))} +
+ )} +
+ ) } - return content + + const hasLog = log.length > 0 + const hasDuration = durationMs != null + if (!hasLog && !hasDuration) return null + return ( +
+ + {visible && ( +
+ {log.map((entry, index) => ( +
+ {entry.message} + {entry.duration_ms != null && ( + 用时 {formatDuration(entry.duration_ms)} + )} +
+ ))} + {hasDuration && ( +
+ {status === 'interrupted' ? '已停止' : (status === 'error' ? '生成失败' : '完成')} · 用时 {formatDuration(durationMs)} +
+ )} +
+ )} +
+ ) } // rehype 插件:将正文中的 [n] 引用编号转换为上角标 ,便于与正文区分。 // 跳过 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, '>') +} + +// 渲染引用片段:命中的分块区间用「证据条」标出(chat-chunk-band)。 +// 提问关键词不再高亮——检索是语义匹配,提问词与原文命中并无字面对应。 +function highlightCitation(text, chunkText) { + const escaped = escapeAngleBrackets(text) + const chunk = escapeAngleBrackets(chunkText) + // CommonMark 会在空行处断开段落,跨多段的分块不能整体包 ,否则标签错位 + if (!chunk || /\r?\n\s*\r?\n/.test(chunk) || !escaped.includes(chunk)) { + return escaped + } + const idx = escaped.indexOf(chunk) + return `${escaped.slice(0, idx)}${chunk}${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 ( + {children}, + }} + > + {children} + + ) +} + +function ReferenceCard({ reference, projectId }) { + return ( + openDocument(reference, projectId)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + openDocument(reference, projectId) + } + }} + > + [{reference.citation_id}] + + {reference.file_name || reference.file_path} + + + ) +} + 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 && } />}
{isUser ? ( @@ -1012,29 +1212,46 @@ function Chat() {
{item.error || '回答生成失败,请稍后重试'}
- ) : isStopped && !hasContent ? ( + ) : isInterrupted && !hasContent ? (
已停止生成
) : isThinking ? ( -
- 正在思考 - - - - - -
+ ) : ( -
+ <> + {isStreaming && ( + + )} + {!isLive && (thinkingLog.length > 0 || item.durationMs != null) && ( + toggleThinking(item.id)} + /> + )} +
{ 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 {children} } + 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 ( - - {ref.file_name || ref.file_path} - +
+ + {ref.file_name || ref.file_path} + +
- - {getReferenceExcerpt(ref) || '暂无引用片段'} - + {quotes.length > 0 ? ( +
+ {claim && ( +
回答:{claim}
+ )} + {quotes.map((quote, quoteIdx) => ( +
+ 原文:{quote.text} + openDocument(ref, projectId, occData)} + /> +
+ ))} +
+ ) : ( +
+ + {highlightCitation(context, hasBand ? excerpt : '') || '暂无引用片段'} + + openDocument(ref, projectId, occData)} + /> +
+ )}
)} @@ -1069,48 +1310,44 @@ function Chat() { {item.error || '回答生成中断,请稍后重试'}
)} - {isStopped && hasContent && ( + {isInterrupted && hasContent && (
已停止生成
)} -
+
+ )} {showReferences && (
- 关联文档 - +
+ 引用来源 + + {refs.length} 个来源 + +
+
{refs.map((ref) => { return ( - { - openDocument(ref, projectId) - }} - > - [{ref.citation_id}] {ref.file_name} - + reference={ref} + projectId={projectId} + /> ) })} - +
)} )} - {(isUser || (!isThinking && !isStreaming)) && ( + {(isUser || !isLive) && ( handleDeleteMessage(item) : undefined} - onRegenerate={!isUser && (isStopped || isError) ? () => handleRegenerate(item) : undefined} + onRegenerate={!isUser && (isInterrupted || isError) ? () => handleRegenerate(item) : undefined} /> )} - {isUser && ( - - {userDisplayName?.[0]?.toUpperCase() || 'U'} - - )} ) }) @@ -1308,7 +1545,11 @@ function Chat() {
- {currentSession && !newMode ? renderChatShell() : renderNewShell()} + {openingExistingSession ? ( +
+ +
+ ) : currentSession && !newMode ? renderChatShell() : renderNewShell()}
{ + // 先更新搜索状态,任何清理逻辑失败都不能阻断搜索 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)