diff --git a/.env.example b/.env.example index 4e78a29..534b4bc 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,11 @@ REDIS_PASSWORD=redis_password_change_me REDIS_PORT=6379 REDIS_DB=8 +# ==================== RAG配置 ==================== +# 文档分块大小(字符数)与相邻分块的重叠字符数 +CHUNK_SIZE=800 +CHUNK_OVERLAP=150 + # ==================== 应用配置 ==================== # JWT 密钥(请务必修改为随机字符串) SECRET_KEY=your-secret-key-change-me-in-production-use-openssl-rand-hex-32 @@ -44,3 +49,5 @@ ADMIN_USERNAME=admin ADMIN_PASSWORD=admin@123 ADMIN_EMAIL=admin@unisspace.com ADMIN_NICKNAME=系统管理员 +# 开发环境跳过 SSL 证书验证(生产环境请勿开启) +DISABLE_SSL_VERIFY=true diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index eff7d5f..83b8f98 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -25,7 +25,7 @@ - 修改MD文件时重新向量化 - 删除MD文件时清理向量数据 - PDF文件不触发向量化 -**Status**: In Progress (核心服务完成,待数据库创建) +**Status**: Complete ## Stage 3: 知识库对话功能 **Goal**: 基于ZVec向量化的文档,实现大模型知识库对话 diff --git a/backend/app/api/v1/chat.py b/backend/app/api/v1/chat.py index c823ae6..c354fc1 100644 --- a/backend/app/api/v1/chat.py +++ b/backend/app/api/v1/chat.py @@ -1,26 +1,126 @@ """ 知识库对话相关 API """ -from fastapi import APIRouter, Depends, HTTPException, status +import asyncio +import json +import logging +import re +from typing import Optional + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete -from typing import List -import json from app.core.database import get_db from app.core.deps import get_current_user +from app.models.llm_model_config import LLMModelConfig +from app.models.project import Project from app.models.user import User from app.models.chat_session import ChatSession, ChatMessage -from app.models.project import Project -from app.models.document_vector import DocumentVector from app.schemas.response import success_response -from app.services.project_service import get_project_or_404, require_project_read_access +from app.services.project_service import ( + get_project_or_404, + require_project_read_access, + require_project_write_access, +) from app.services.rag_service import rag_service +from app.services.zvec_service import zvec_service +from app.services.llm_provider_service import LLMProviderService +from app.services.project_vectorization_task_service import project_vectorization_task_service from pydantic import BaseModel router = APIRouter() +logger = logging.getLogger(__name__) + +CHAT_HISTORY_MESSAGE_LIMIT = 20 + + +class VectorizeRequest(BaseModel): + """批量向量化请求""" + force: bool = False # False=增量(跳过未变更文件), True=全量重建 + + +@router.get("/projects/{project_id}/vectorize/progress", response_model=dict) +async def get_vectorize_progress( + project_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """查询项目向量化进度""" + project = await get_project_or_404(db, project_id) + await require_project_read_access(db, project_id, current_user) + + progress = await zvec_service.get_progress(db, project_id, project.storage_key) + return success_response(data=progress) + + +@router.post("/projects/{project_id}/vectorize", response_model=dict) +async def vectorize_project( + project_id: int, + req: VectorizeRequest, + background_tasks: BackgroundTasks, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """创建项目文件夹向量化后台任务(增量或全量)""" + await get_project_or_404(db, project_id) + await require_project_write_access(db, project_id, current_user) + + if await zvec_service.get_embedding_config(db) is None: + raise HTTPException( + status_code=400, + detail="尚未配置可用的向量模型,请先在「模型配置 - 向量模型」中添加并启用。", + ) + + running_task = await project_vectorization_task_service.get_running_task(db, project_id) + if running_task: + task = running_task + else: + task = await project_vectorization_task_service.create_task( + db, + project_id, + current_user.id, + force=req.force, + ) + background_tasks.add_task(project_vectorization_task_service.run_task, task.task_id) + + return success_response( + data=project_vectorization_task_service.serialize_task(task), + message="向量化任务已提交", + ) + + +@router.get("/projects/{project_id}/vectorize/tasks/latest", response_model=dict) +async def get_latest_vectorize_task( + project_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """查询项目最近一次向量化任务""" + await require_project_read_access(db, project_id, current_user) + task = await project_vectorization_task_service.get_latest_task(db, project_id) + return success_response( + data=project_vectorization_task_service.serialize_task(task) if task else None + ) + + +@router.get("/projects/{project_id}/vectorize/tasks/{task_id}", response_model=dict) +async def get_vectorize_task( + project_id: int, + task_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """查询项目向量化任务状态""" + await require_project_read_access(db, project_id, current_user) + task = await project_vectorization_task_service.get_task(db, project_id, task_id) + if not task: + raise HTTPException(status_code=404, detail="向量化任务不存在") + return success_response(data=project_vectorization_task_service.serialize_task(task)) + class ChatCreateRequest(BaseModel): """创建对话会话请求""" @@ -35,6 +135,11 @@ class ChatMessageRequest(BaseModel): message: str +class ChatSessionUpdateRequest(BaseModel): + """更新对话会话请求""" + title: str + + class ChatResponse(BaseModel): """对话响应""" session_id: int @@ -42,6 +147,181 @@ class ChatResponse(BaseModel): assistant_message: str +def _parse_refs(value): + if not value: + return [] + try: + return json.loads(value) + except (ValueError, TypeError): + return [] + + +def _normalize_refs(refs): + """将存储的引用归一化为 [{citation_id, file_path, anchor_text, excerpt}] 形式。 + + 兼容旧格式(纯文件路径数组)与新格式(对象数组)。 + """ + normalized = [] + for idx, ref in enumerate(refs or [], 1): + if isinstance(ref, dict): + file_path = ref.get("file_path") + citation_id = ref.get("citation_id", idx) + anchor_text = ref.get("anchor_text") or "" + excerpt = ref.get("excerpt") or "" + else: + file_path = ref + citation_id = idx + anchor_text = "" + excerpt = "" + if file_path: + normalized.append({ + "citation_id": citation_id, + "file_path": file_path, + "anchor_text": anchor_text, + "excerpt": excerpt, + }) + return normalized + + +def _build_reference_items(refs, project_id=None): + return [ + { + "citation_id": ref["citation_id"], + "file_path": ref["file_path"], + "file_name": ref["file_path"].rsplit("/", 1)[-1], + "anchor_text": ref.get("anchor_text") or "", + "excerpt": ref.get("excerpt") or "", + "project_id": project_id, + } + for ref in _normalize_refs(refs) + ] + + +def _canonicalize_message_citations(content: str, refs): + """按文件合并引用并将正文引用重新编号,兼容历史重复分块数据。""" + normalized = _normalize_refs(refs) + if not normalized: + return content, [] + + canonical_refs = [] + file_to_id = {} + old_to_new = {} + for ref in normalized: + file_path = ref["file_path"] + old_id = int(ref["citation_id"]) + new_id = file_to_id.get(file_path) + if new_id is None: + new_id = len(canonical_refs) + 1 + file_to_id[file_path] = new_id + canonical_refs.append({**ref, "citation_id": new_id}) + else: + canonical_ref = canonical_refs[new_id - 1] + current_excerpt = canonical_ref.get("excerpt", "").strip() + incoming_excerpt = ref.get("excerpt", "").strip() + if incoming_excerpt and incoming_excerpt not in current_excerpt: + canonical_ref["excerpt"] = ( + f"{current_excerpt}\n\n{incoming_excerpt}" + if current_excerpt else incoming_excerpt + ) + old_to_new[old_id] = new_id + + def replace_citation(match): + old_id = int(match.group(1)) + return f"[{old_to_new.get(old_id, old_id)}]" + + normalized_content = _CITATION_PATTERN.sub(replace_citation, content or "") + normalized_content = re.sub(r"\[(\d+)\](?:\s*\[\1\])+", r"[\1]", normalized_content) + return normalized_content, canonical_refs + + +def _stream_event(event: str, data) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + +_CITATION_PATTERN = re.compile(r"\[(\d+)\]") + + +def _compact_cited_refs(answer: str, retrieved_docs): + """仅保留实际使用的文档,并按正文首次引用顺序连续编号。""" + docs_by_id = { + int(doc.get("citation_id", index)): doc + for index, doc in enumerate(retrieved_docs, 1) + } + old_to_new = {} + file_to_new = {} + refs = [] + + for match in _CITATION_PATTERN.finditer(answer or ""): + old_id = int(match.group(1)) + doc = docs_by_id.get(old_id) + if doc is None or old_id in old_to_new: + continue + file_path = doc.get("file_path") + if not file_path: + continue + + new_id = file_to_new.get(file_path) + if new_id is None: + new_id = len(refs) + 1 + file_to_new[file_path] = new_id + refs.append({ + "citation_id": new_id, + "file_path": file_path, + "anchor_text": doc.get("anchor_text") or "", + "excerpt": doc.get("excerpt") or doc.get("content") or doc.get("anchor_text") or "", + }) + old_to_new[old_id] = new_id + + def replace_citation(match): + old_id = int(match.group(1)) + return f"[{old_to_new.get(old_id, old_id)}]" + + normalized_answer = _CITATION_PATTERN.sub(replace_citation, answer or "") + normalized_answer = re.sub( + r"\[(\d+)\](?:\s*\[\1\])+", r"[\1]", normalized_answer + ) + return normalized_answer, refs + + +async def _generate_session_title(db, llm_config_id: int, question: str, answer: str) -> Optional[str]: + """根据首轮问答,用 LLM 生成简洁的会话标题。失败时返回 None。""" + stmt = select(LLMModelConfig).where(LLMModelConfig.config_id == llm_config_id) + result = await db.execute(stmt) + llm_config = result.scalar_one_or_none() + if not llm_config: + return None + + system_prompt = ( + "你是一个会话标题生成器。请根据用户的问题和助手的回答," + "用一句不超过 16 个字的中文短语概括对话主题,作为标题。" + "只输出标题本身,不要引号、标点结尾或任何解释。" + ) + user_content = f"用户问题:{question}\n\n助手回答:{(answer or '')[:500]}" + + try: + title = await LLMProviderService.generate_text( + provider=llm_config.provider, + endpoint_url=llm_config.endpoint_url, + api_key=llm_config.api_key, + llm_model_name=llm_config.llm_model_name, + timeout=min(int(llm_config.llm_timeout or 60), 60), + temperature=0.3, + top_p=float(llm_config.llm_top_p or 0.9), + max_tokens=32, + system_prompt=system_prompt, + messages=[{"role": "user", "content": user_content}], + ) + except Exception as exc: # noqa: BLE001 - 标题生成失败不应影响主流程 + logger.warning("生成会话标题失败: %s", exc) + return None + + title = (title or "").strip().strip('"').strip("「」").strip() + title = title.splitlines()[0].strip() if title else "" + if not title: + return None + return title[:60] + + @router.post("/sessions", response_model=dict) async def create_chat_session( req: ChatCreateRequest, @@ -49,8 +329,17 @@ async def create_chat_session( db: AsyncSession = Depends(get_db), ): """创建新的对话会话""" - project = await get_project_or_404(db, req.project_id) - _, user_role = await require_project_read_access(db, req.project_id, current_user) + await get_project_or_404(db, req.project_id) + await require_project_read_access(db, req.project_id, current_user) + model_result = await db.execute( + select(LLMModelConfig).where( + LLMModelConfig.config_id == req.llm_config_id, + LLMModelConfig.model_type == "chat", + LLMModelConfig.is_active == True, + ) + ) + if model_result.scalar_one_or_none() is None: + raise HTTPException(status_code=400, detail="请选择有效的对话模型") session = ChatSession( user_id=current_user.id, @@ -64,29 +353,33 @@ async def create_chat_session( return success_response(data={ "session_id": session.id, + "project_id": session.project_id, + "llm_config_id": session.llm_config_id, "title": session.title, "created_at": session.created_at.isoformat(), + "updated_at": session.updated_at.isoformat(), }) @router.get("/sessions", response_model=dict) async def list_chat_sessions( - project_id: int, + project_id: Optional[int] = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """列出项目的所有对话会话""" - await require_project_read_access(db, project_id, current_user) - - stmt = select(ChatSession).where( - ChatSession.project_id == project_id, - ChatSession.user_id == current_user.id, - ).order_by(ChatSession.created_at.desc()) + """列出当前用户的对话会话,可按知识库筛选""" + stmt = select(ChatSession).where(ChatSession.user_id == current_user.id) + if project_id is not None: + await require_project_read_access(db, project_id, current_user) + stmt = stmt.where(ChatSession.project_id == project_id) + stmt = stmt.order_by(ChatSession.updated_at.desc(), ChatSession.created_at.desc()) result = await db.execute(stmt) sessions = result.scalars().all() return success_response(data=[{ - "id": s.id, + "session_id": s.id, + "project_id": s.project_id, + "llm_config_id": s.llm_config_id, "title": s.title, "created_at": s.created_at.isoformat(), "updated_at": s.updated_at.isoformat(), @@ -118,12 +411,99 @@ async def get_session_messages( msg_result = await db.execute(msg_stmt) messages = msg_result.scalars().all() - return success_response(data=[{ - "id": m.id, - "role": m.role, - "content": m.content, - "created_at": m.created_at.isoformat(), - } for m in messages]) + data = [] + for message in messages: + stored_refs = _parse_refs(message.referenced_files) + content = message.content + refs = stored_refs + if message.role == "assistant": + content, refs = _canonicalize_message_citations(content, stored_refs) + data.append({ + "id": message.id, + "role": message.role, + "content": content, + "referenced_files": refs, + "references": _build_reference_items(refs, session.project_id) if message.role == "assistant" else [], + "created_at": message.created_at.isoformat(), + }) + + return success_response(data=data) + + +@router.get("/search", response_model=dict) +async def search_chat_messages( + keyword: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """搜索聊天消息内容""" + keyword = (keyword or "").strip() + if not keyword: + return success_response(data=[]) + + escaped_keyword = ( + keyword.replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + ) + stmt = ( + select( + ChatMessage.id, + ChatMessage.session_id, + ChatMessage.role, + ChatMessage.content, + ChatMessage.created_at, + ChatSession.title, + ChatSession.project_id, + ChatSession.llm_config_id, + Project.name.label("project_name"), + LLMModelConfig.model_name.label("model_name"), + ) + .join(ChatSession, ChatMessage.session_id == ChatSession.id) + .join(Project, Project.id == ChatSession.project_id) + .join(LLMModelConfig, LLMModelConfig.config_id == ChatSession.llm_config_id) + .where( + ChatSession.user_id == current_user.id, + ChatMessage.content.ilike(f"%{escaped_keyword}%", escape="\\"), + ) + .order_by(ChatMessage.created_at.desc()) + .limit(50) + ) + result = await db.execute(stmt) + rows = result.all() + + def _build_snippet(content: str, term: str) -> str: + text = content or "" + if not text: + return "" + lowered = text.lower() + needle = term.lower() + idx = lowered.find(needle) + if idx < 0: + return text[:120] + start = max(0, idx - 24) + end = min(len(text), idx + len(term) + 48) + prefix = "..." if start > 0 else "" + suffix = "..." if end < len(text) else "" + return f"{prefix}{text[start:end].strip()}{suffix}" + + return success_response(data=[ + { + "result_id": row.id, + "session_id": row.session_id, + "message_id": row.id, + "role": row.role, + "content": row.content, + "snippet": _build_snippet(row.content, keyword), + "session_title": row.title, + "project_id": row.project_id, + "project_name": row.project_name, + "llm_config_id": row.llm_config_id, + "model_name": row.model_name, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in rows + ]) @router.post("/send", response_model=dict) @@ -133,6 +513,10 @@ async def send_chat_message( db: AsyncSession = Depends(get_db), ): """发送对话消息并获取回复""" + question = (req.message or "").strip() + if not question: + raise HTTPException(status_code=400, detail="消息内容不能为空") + stmt = select(ChatSession).where(ChatSession.id == req.session_id) result = await db.execute(stmt) session = result.scalar_one_or_none() @@ -148,55 +532,69 @@ async def send_chat_message( query_message = ChatMessage( session_id=req.session_id, role="user", - content=req.message, + content=question, ) db.add(query_message) await db.flush() try: - query_vector = await _get_query_vector(req.message) - - retrieved_docs = await rag_service.retrieve_documents( - db, - session.project_id, - query_vector, - top_k=5, + msg_stmt = ( + select(ChatMessage) + .where( + ChatMessage.session_id == req.session_id, + ChatMessage.id != query_message.id, + ) + .order_by(ChatMessage.created_at.desc()) + .limit(CHAT_HISTORY_MESSAGE_LIMIT) ) - - msg_stmt = select(ChatMessage).where( - ChatMessage.session_id == req.session_id, - ).order_by(ChatMessage.created_at.asc()) msg_result = await db.execute(msg_stmt) - prev_messages = msg_result.scalars().all() + prev_messages = list(reversed(msg_result.scalars().all())) conversation_history = [ {"role": m.role, "content": m.content} for m in prev_messages - if m.id != query_message.id ] + retrieved_docs = await rag_service.retrieve_documents( + db, + session.project_id, + question, + top_k=5, + ) + assistant_response = await rag_service.generate_response( db, - req.message, - query_vector, + question, session.project_id, session.llm_config_id, retrieved_docs, conversation_history, ) + # 仅保留实际引用的文档,并把引用编号压缩为从 1 开始的连续序列。 + assistant_response, cited_refs = _compact_cited_refs( + assistant_response, retrieved_docs + ) + assistant_message = ChatMessage( session_id=req.session_id, role="assistant", content=assistant_response, + referenced_files=json.dumps(cited_refs, ensure_ascii=False) if cited_refs else None, ) db.add(assistant_message) + + # 累加会话消息计数(用户 + 助手 共 2 条) + session.message_count = (session.message_count or 0) + 2 + await db.commit() return success_response(data={ "session_id": req.session_id, - "user_message": req.message, + "user_message": question, "assistant_message": assistant_response, + "referenced_files": [ref["file_path"] for ref in cited_refs], + "references": _build_reference_items(cited_refs, session.project_id), }) except Exception as e: @@ -207,6 +605,189 @@ async def send_chat_message( ) +@router.post("/send/stream", response_model=dict) +async def send_chat_message_stream( + req: ChatMessageRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """发送对话消息并流式返回回复""" + question = (req.message or "").strip() + if not question: + raise HTTPException(status_code=400, detail="消息内容不能为空") + + stmt = select(ChatSession).where(ChatSession.id == req.session_id) + result = await db.execute(stmt) + session = result.scalar_one_or_none() + + if not session: + raise HTTPException(status_code=404, detail="对话会话不存在") + + if session.user_id != current_user.id: + raise HTTPException(status_code=403, detail="无权访问该对话") + + await require_project_read_access(db, session.project_id, current_user) + + # 先读取历史消息(不含本轮提问),用于多轮上下文与判断是否首轮 + msg_stmt = ( + select(ChatMessage) + .where(ChatMessage.session_id == req.session_id) + .order_by(ChatMessage.created_at.desc()) + .limit(CHAT_HISTORY_MESSAGE_LIMIT) + ) + msg_result = await db.execute(msg_stmt) + prev_messages = list(reversed(msg_result.scalars().all())) + + conversation_history = [ + {"role": m.role, "content": m.content} + for m in prev_messages + ] + + retrieved_docs = await rag_service.retrieve_documents( + db, + session.project_id, + question, + top_k=5, + ) + + # 检索成功后立即持久化用户提问,并同时创建一条空的助手消息占位行。 + # 二者都即时入库:这样即便流式输出中途刷新页面,刷新后也能看到提问, + # 以及助手已经生成的部分内容(边生成边回写到该占位行)。 + query_message = ChatMessage( + session_id=req.session_id, + role="user", + content=question, + ) + assistant_message = ChatMessage( + session_id=req.session_id, + role="assistant", + content="", + ) + db.add_all([query_message, assistant_message]) + session.message_count = (session.message_count or 0) + 2 + await db.commit() + + user_message_id = query_message.id + assistant_message_id = assistant_message.id + + # 是否首轮对话(生成会话标题用) + is_first_exchange = len(conversation_history) == 0 + llm_config_id = session.llm_config_id + project_id = session.project_id + # 每累计这么多字符就回写一次占位行,平衡「刷新可见性」与「写库频率」 + 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 + ) + + new_title = None + if completed and is_first_exchange: + new_title = await _generate_session_title( + db, llm_config_id, question, assistant_response + ) + # LLM 生成失败时回退为截断后的问题,确保侧边栏拿到有意义的标题 + if not new_title: + fallback = (question or "").strip().splitlines()[0] if question else "" + new_title = (fallback[:24] + "...") if len(fallback) > 24 else fallback + if new_title: + sess = await db.get(ChatSession, req.session_id) + if sess is not None: + sess.title = new_title + + await db.commit() + return assistant_response, references, new_title + + async def event_generator(): + assistant_response_parts = [] + chars_since_flush = 0 + finished = False + try: + # 先回传两条消息的真实 id,使前端无需刷新即可获得删除入口等能力 + yield _stream_event("ids", { + "session_id": req.session_id, + "user_message_id": user_message_id, + "assistant_message_id": assistant_message_id, + }) + + async for chunk in rag_service.generate_response_stream( + db, + question, + project_id, + llm_config_id, + retrieved_docs, + conversation_history, + ): + assistant_response_parts.append(chunk) + chars_since_flush += len(chunk) + yield _stream_event("chunk", {"content": chunk}) + + # 边生成边落库:达到阈值就把当前进度回写到占位行 + if chars_since_flush >= FLUSH_EVERY_CHARS: + chars_since_flush = 0 + await _persist_assistant(assistant_response_parts, completed=False) + + assistant_response, references, new_title = await _persist_assistant( + assistant_response_parts, completed=True + ) + finished = True + + yield _stream_event("references", references or []) + if new_title: + yield _stream_event("title", { + "session_id": req.session_id, + "title": new_title, + }) + yield _stream_event("done", { + "session_id": req.session_id, + "user_message_id": user_message_id, + "assistant_message_id": assistant_message_id, + "content": assistant_response, + "references": references or [], + "title": new_title, + }) + except (asyncio.CancelledError, GeneratorExit): + # 客户端中途断开(如刷新页面):尽力把已生成的部分回写到占位行。 + # 由于提问与占位行已入库,刷新后至少能看到提问与已生成内容。 + if not finished: + if not assistant_response_parts: + assistant_response_parts.append("回答生成已中断,请重新提问。") + try: + await db.rollback() + await _persist_assistant(assistant_response_parts, completed=False) + except Exception as save_exc: # noqa: BLE001 + logger.warning("保存中断的助手回复失败: %s", save_exc) + raise + except Exception as exc: + await db.rollback() + if not assistant_response_parts: + assistant_response_parts.append("回答生成失败,请稍后重试。") + try: + await _persist_assistant(assistant_response_parts, completed=False) + except Exception as save_exc: # noqa: BLE001 + logger.warning("保存失败的助手回复状态失败: %s", save_exc) + yield _stream_event("error", {"detail": f"生成对话回复失败: {str(exc)}"}) + + return StreamingResponse(event_generator(), media_type="text/event-stream") + + @router.delete("/sessions/{session_id}", response_model=dict) async def delete_chat_session( session_id: int, @@ -232,14 +813,59 @@ async def delete_chat_session( return success_response(message="对话会话已删除") -async def _get_query_vector(query: str) -> List[float]: - """从查询文本生成向量""" - try: - import numpy as np - from sklearn.feature_extraction.text import TfidfVectorizer +@router.delete("/messages/{message_id}", response_model=dict) +async def delete_chat_message( + message_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """删除单条对话消息""" + stmt = ( + select(ChatMessage, ChatSession) + .join(ChatSession, ChatMessage.session_id == ChatSession.id) + .where(ChatMessage.id == message_id) + ) + result = await db.execute(stmt) + row = result.first() - vectorizer = TfidfVectorizer(max_features=768) - query_vector = vectorizer.fit_transform([query]).toarray()[0] - return query_vector.tolist() - except Exception: - return [0.0] * 768 + if not row: + raise HTTPException(status_code=404, detail="消息不存在") + + msg, session = row + if session.user_id != current_user.id: + raise HTTPException(status_code=403, detail="无权删除该消息") + + await db.delete(msg) + if session.message_count and session.message_count > 0: + session.message_count = max(0, session.message_count - 1) + await db.commit() + + return success_response(message="消息已删除") + + +@router.put("/sessions/{session_id}", response_model=dict) +async def update_chat_session( + session_id: int, + req: ChatSessionUpdateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """更新对话会话标题""" + stmt = select(ChatSession).where(ChatSession.id == session_id) + result = await db.execute(stmt) + session = result.scalar_one_or_none() + + if not session: + raise HTTPException(status_code=404, detail="对话会话不存在") + + if session.user_id != current_user.id: + raise HTTPException(status_code=403, detail="无权修改该对话") + + title = (req.title or "").strip() + if not title: + raise HTTPException(status_code=400, detail="标题不能为空") + + session.title = title + await db.commit() + + return success_response(message="标题已更新", data={"session_id": session_id, "title": title}) diff --git a/backend/app/api/v1/files.py b/backend/app/api/v1/files.py index 6751102..0a4f423 100644 --- a/backend/app/api/v1/files.py +++ b/backend/app/api/v1/files.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request from fastapi.responses import StreamingResponse, FileResponse from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select from typing import List import os import zipfile @@ -18,6 +19,7 @@ from app.core.deps import get_current_user, get_user_from_token_or_query from app.models.user import User from app.models.log import OperationLog from app.models.share import ShareLink +from app.models.project import Project, ProjectMember from app.schemas.file import ( FileTreeNode, FileSaveRequest, @@ -98,8 +100,6 @@ async def get_project_tree( # 生成目录树 tree = storage_service.generate_tree(project_root) -<<<<<<< HEAD -======= share_result = await db.execute( select(ShareLink.file_path).where( ShareLink.project_id == project_id, @@ -128,7 +128,6 @@ async def get_project_tree( if member: user_role = member.role ->>>>>>> origin/main return success_response(data={ "tree": tree, "user_role": user_role, diff --git a/backend/app/api/v1/llm_model_configs.py b/backend/app/api/v1/llm_model_configs.py index bc854d7..f842ade 100644 --- a/backend/app/api/v1/llm_model_configs.py +++ b/backend/app/api/v1/llm_model_configs.py @@ -24,6 +24,7 @@ class LLMModelConfigUpsertRequest(BaseModel): model_code: Optional[str] = None model_name: Optional[str] = None + model_type: str = Field("chat", pattern="^(chat|embedding)$") provider: str = Field(..., min_length=1, max_length=64) endpoint_url: Optional[str] = Field(None, max_length=512) api_key: Optional[str] = Field(None, max_length=512) @@ -31,8 +32,9 @@ class LLMModelConfigUpsertRequest(BaseModel): llm_timeout: int = Field(120, ge=5, le=600) llm_temperature: float = Field(0.70, ge=0, le=2) llm_top_p: float = Field(0.90, ge=0, le=1) - llm_max_tokens: int = Field(2048, ge=1, le=32768) + llm_max_tokens: int = Field(8192, ge=1, le=32768) llm_system_prompt: Optional[str] = None + embedding_dimension: Optional[int] = Field(None, ge=1, le=8192) description: Optional[str] = Field(None, max_length=500) is_active: bool = True is_default: bool = False @@ -67,6 +69,7 @@ def serialize_model_config(config: LLMModelConfig, include_api_key: bool = False "config_id": config.config_id, "model_code": config.model_code, "model_name": config.model_name, + "model_type": config.model_type or "chat", "provider": config.provider, "endpoint_url": config.endpoint_url, "llm_model_name": config.llm_model_name, @@ -75,6 +78,7 @@ def serialize_model_config(config: LLMModelConfig, include_api_key: bool = False "llm_top_p": float(config.llm_top_p or 0), "llm_max_tokens": config.llm_max_tokens, "llm_system_prompt": config.llm_system_prompt, + "embedding_dimension": config.embedding_dimension, "description": config.description, "is_active": bool(config.is_active), "is_default": bool(config.is_default), @@ -102,13 +106,22 @@ def normalize_payload(payload: LLMModelConfigUpsertRequest) -> dict: data = payload.model_dump() provider = data["provider"] llm_model_name = data["llm_model_name"] + model_type = data.get("model_type") or "chat" + data["model_type"] = model_type data["model_name"] = data.get("model_name") or LLMProviderService.build_model_name(provider, llm_model_name) - data["model_code"] = data.get("model_code") or LLMProviderService.build_model_code(provider, llm_model_name) + base_code = data.get("model_code") or LLMProviderService.build_model_code(provider, llm_model_name) + # embedding 类型加前缀,避免与同名对话模型编码冲突 + if model_type == "embedding" and not base_code.startswith("emb_"): + base_code = f"emb_{base_code}" + data["model_code"] = base_code data["endpoint_url"] = data.get("endpoint_url") or LLMProviderService.get_default_endpoint_url(provider) if data["is_default"]: data["is_active"] = True data["llm_temperature"] = Decimal(str(data["llm_temperature"])) data["llm_top_p"] = Decimal(str(data["llm_top_p"])) + # embedding 不需要对话采样参数,但列非空,保留默认值即可 + if model_type == "embedding": + data["llm_system_prompt"] = None return data @@ -167,6 +180,7 @@ async def get_llm_model_configs( page_size: int = Query(10, ge=1, le=100), keyword: Optional[str] = Query(None, description="搜索关键词(模型名称、编码、模型名)"), provider: Optional[str] = Query(None, description="提供方筛选"), + model_type: Optional[str] = Query(None, description="模型类型筛选: chat/embedding"), is_active: Optional[bool] = Query(None, description="启用状态筛选"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), @@ -184,6 +198,8 @@ async def get_llm_model_configs( ) if provider: conditions.append(LLMModelConfig.provider == provider) + if model_type: + conditions.append(LLMModelConfig.model_type == model_type) if is_active is not None: conditions.append(LLMModelConfig.is_active == is_active) @@ -406,7 +422,13 @@ async def test_llm_model_config( request_data: LLMModelConfigTestRequest, current_user: User = Depends(get_current_user), ): - """测试模型连接""" + """测试模型连接(按类型分流:chat 走对话补全,embedding 走向量接口)""" payload = normalize_payload(request_data) - test_result = await LLMProviderService.test_model_connection(payload) + try: + if payload.get("model_type") == "embedding": + test_result = await LLMProviderService.test_embedding_connection(payload) + else: + test_result = await LLMProviderService.test_model_connection(payload) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc return success_response(data=test_result, message="模型测试成功") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 22bcd62..2e8ee13 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -69,6 +69,10 @@ class Settings(BaseSettings): # 头像上传配置 AVATAR_MAX_SIZE: int = 1 * 1024 * 1024 # 1MB + # RAG 分块配置 + CHUNK_SIZE: int = 800 # 单个文档分块的字符数 + CHUNK_OVERLAP: int = 150 # 相邻分块之间的重叠字符数 + # 跨域配置 CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"] diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index fcab335..4f33205 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -13,6 +13,7 @@ from app.models.log import OperationLog from app.models.mcp_bot import MCPBot from app.models.llm_model_config import LLMModelConfig from app.models.chat_session import ChatSession, ChatMessage +from app.models.project_vectorization_task import ProjectVectorizationTask __all__ = [ "Base", @@ -32,4 +33,5 @@ __all__ = [ "LLMModelConfig", "ChatSession", "ChatMessage", + "ProjectVectorizationTask", ] diff --git a/backend/app/models/document_vector.py b/backend/app/models/document_vector.py index 5c3f408..557bb2a 100644 --- a/backend/app/models/document_vector.py +++ b/backend/app/models/document_vector.py @@ -1,7 +1,7 @@ """ 文档向量化模型 """ -from sqlalchemy import Column, BigInteger, String, DateTime, Index, Text +from sqlalchemy import Column, BigInteger, Integer, String, DateTime, Index, Text from sqlalchemy.sql import func from app.core.database import Base @@ -14,8 +14,10 @@ class DocumentVector(Base): id = Column(BigInteger, primary_key=True, autoincrement=True, comment="向量ID") project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID") file_path = Column(String(500), nullable=False, comment="文件相对路径") - content_hash = Column(String(64), comment="内容哈希值,用于判断文件是否变更") - zvec_id = Column(String(256), comment="ZVec返回的向量ID") + chunk_index = Column(Integer, nullable=False, default=0, comment="分块序号(0起),同一文件可有多个分块") + chunk_text = Column(Text, comment="分块首段文本,作为点击引用时的定位锚点") + content_hash = Column(String(64), comment="整个文件内容哈希值,用于判断文件是否变更") + zvec_id = Column(String(256), comment="ZVec返回的向量ID(每个分块独立)") zvec_response = Column(Text, comment="ZVec完整响应JSON") status = Column(String(32), nullable=False, default="success", comment="向量化状态:success/failed/pending") error_message = Column(String(500), comment="错误信息") @@ -24,7 +26,11 @@ class DocumentVector(Base): __table_args__ = ( Index("idx_project_file", "project_id", "file_path"), + Index("idx_project_file_chunk", "project_id", "file_path", "chunk_index"), ) def __repr__(self): - return f"" + return ( + f"" + ) diff --git a/backend/app/models/llm_model_config.py b/backend/app/models/llm_model_config.py index b5bb32c..d86e639 100644 --- a/backend/app/models/llm_model_config.py +++ b/backend/app/models/llm_model_config.py @@ -15,6 +15,7 @@ class LLMModelConfig(Base): config_id = Column(BigInteger, primary_key=True, autoincrement=True, comment="配置ID") model_code = Column(String(128), nullable=False, unique=True, index=True, comment="模型编码") model_name = Column(String(255), nullable=False, comment="模型名称") + model_type = Column(String(32), nullable=False, default="chat", index=True, comment="模型类型: chat/embedding") provider = Column(String(64), comment="模型提供方") endpoint_url = Column(String(512), comment="接口地址") api_key = Column(String(512), comment="API Key") @@ -22,8 +23,9 @@ class LLMModelConfig(Base): llm_timeout = Column(Integer, nullable=False, default=120, comment="超时时间(秒)") llm_temperature = Column(Numeric(5, 2), nullable=False, default=0.70, comment="温度") llm_top_p = Column(Numeric(5, 2), nullable=False, default=0.90, comment="Top P") - llm_max_tokens = Column(Integer, nullable=False, default=2048, comment="最大输出 Token") + llm_max_tokens = Column(Integer, nullable=False, default=8192, comment="最大输出 Token") llm_system_prompt = Column(Text, comment="系统提示词") + embedding_dimension = Column(Integer, nullable=True, default=None, comment="向量维度(仅 embedding 类型)") description = Column(String(500), comment="描述") is_active = Column(Boolean, nullable=False, default=True, index=True, comment="是否启用") is_default = Column(Boolean, nullable=False, default=False, comment="是否默认") diff --git a/backend/app/models/project_vectorization_task.py b/backend/app/models/project_vectorization_task.py new file mode 100644 index 0000000..261ddb8 --- /dev/null +++ b/backend/app/models/project_vectorization_task.py @@ -0,0 +1,37 @@ +""" +项目向量化任务模型 +""" +from sqlalchemy import BigInteger, Column, DateTime, Index, Integer, String, Text +from sqlalchemy.sql import func + +from app.core.database import Base + + +class ProjectVectorizationTask(Base): + """项目向量化后台任务表""" + + __tablename__ = "project_vectorization_task" + + id = Column(BigInteger, primary_key=True, autoincrement=True, comment="任务ID") + task_id = Column(String(64), nullable=False, unique=True, index=True, comment="任务唯一标识") + project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID") + user_id = Column(BigInteger, nullable=False, index=True, comment="触发用户ID") + task_type = Column(String(32), nullable=False, comment="任务类型:incremental/full") + status = Column(String(32), nullable=False, default="pending", index=True, comment="任务状态:pending/running/success/failed") + total = Column(Integer, nullable=False, default=0, comment="文件总数") + processed = Column(Integer, nullable=False, default=0, comment="处理成功数") + skipped = Column(Integer, nullable=False, default=0, comment="跳过数") + failed = Column(Integer, nullable=False, default=0, comment="失败数") + error_message = Column(Text, comment="错误信息") + started_at = Column(DateTime, comment="开始时间") + finished_at = Column(DateTime, comment="完成时间") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + __table_args__ = ( + Index("idx_vector_task_project_status", "project_id", "status"), + Index("idx_vector_task_created_at", "created_at"), + ) + + def __repr__(self): + return f"" diff --git a/backend/app/services/llm_provider_service.py b/backend/app/services/llm_provider_service.py index 0d39bfd..18be359 100644 --- a/backend/app/services/llm_provider_service.py +++ b/backend/app/services/llm_provider_service.py @@ -2,13 +2,17 @@ LLM 提供方测试服务 """ import asyncio +import os +import queue +import ssl import json import socket +import threading import time import urllib.error import urllib.parse import urllib.request -from typing import Any, Dict, List, Optional +from typing import Any, AsyncIterator, Dict, Iterator, List, Optional PROVIDER_CATALOG: List[Dict[str, str]] = [ @@ -156,10 +160,6 @@ class LLMProviderService: raise ValueError("缺少 API Key,请填写后再测试") timeout = int(payload.get("llm_timeout") or 120) - temperature = float(payload.get("llm_temperature") or 0.7) - top_p = float(payload.get("llm_top_p") or 0.9) - max_tokens = int(payload.get("llm_max_tokens") or 2048) - system_prompt = (payload.get("llm_system_prompt") or "").strip() started_at = time.perf_counter() preview = await asyncio.to_thread( @@ -170,10 +170,6 @@ class LLMProviderService: api_key, llm_model_name, timeout, - temperature, - top_p, - max_tokens, - system_prompt, ) latency_ms = int((time.perf_counter() - started_at) * 1000) @@ -194,10 +190,6 @@ class LLMProviderService: api_key: str, llm_model_name: str, timeout: int, - temperature: float, - top_p: float, - max_tokens: int, - system_prompt: str, ) -> str: if protocol == "anthropic": return cls._test_anthropic( @@ -205,10 +197,6 @@ class LLMProviderService: api_key, llm_model_name, timeout, - temperature, - top_p, - max_tokens, - system_prompt, ) if protocol == "gemini": @@ -217,10 +205,6 @@ class LLMProviderService: api_key, llm_model_name, timeout, - temperature, - top_p, - max_tokens, - system_prompt, ) return cls._test_openai_compatible( @@ -229,10 +213,6 @@ class LLMProviderService: api_key, llm_model_name, timeout, - temperature, - top_p, - max_tokens, - system_prompt, ) @classmethod @@ -243,10 +223,6 @@ class LLMProviderService: api_key: str, llm_model_name: str, timeout: int, - temperature: float, - top_p: float, - max_tokens: int, - system_prompt: str, ) -> str: url = cls._join_endpoint(endpoint_url, "/chat/completions") headers = { @@ -257,11 +233,7 @@ class LLMProviderService: payload = { "model": llm_model_name, - "messages": cls._build_openai_messages(system_prompt), - "temperature": temperature, - "top_p": top_p, - "max_tokens": min(max_tokens, 256), - "stream": False, + "messages": cls._build_openai_test_messages(), } response = cls._request_json(url, headers, payload, timeout) choices = response.get("choices") or [] @@ -269,6 +241,8 @@ class LLMProviderService: raise ValueError("测试请求已发送,但未收到模型返回内容") content = choices[0].get("message", {}).get("content", "") + if not content: + content = choices[0].get("text", "") if isinstance(content, list): content = "".join( item.get("text", "") if isinstance(item, dict) else str(item) @@ -286,10 +260,6 @@ class LLMProviderService: api_key: str, llm_model_name: str, timeout: int, - temperature: float, - top_p: float, - max_tokens: int, - system_prompt: str, ) -> str: url = cls._join_endpoint(endpoint_url, "/v1/messages") headers = { @@ -299,9 +269,7 @@ class LLMProviderService: } payload = { "model": llm_model_name, - "max_tokens": min(max_tokens, 256), - "temperature": temperature, - "top_p": top_p, + "max_tokens": 32, "messages": [ { "role": "user", @@ -309,8 +277,6 @@ class LLMProviderService: } ], } - if system_prompt: - payload["system"] = system_prompt response = cls._request_json(url, headers, payload, timeout) content = response.get("content") or [] @@ -330,10 +296,6 @@ class LLMProviderService: api_key: str, llm_model_name: str, timeout: int, - temperature: float, - top_p: float, - max_tokens: int, - system_prompt: str, ) -> str: model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}" encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/")) @@ -347,17 +309,8 @@ class LLMProviderService: "role": "user", "parts": [{"text": "请只回复“连接测试成功”。"}], } - ], - "generationConfig": { - "temperature": temperature, - "topP": top_p, - "maxOutputTokens": min(max_tokens, 256), - }, + ] } - if system_prompt: - payload["systemInstruction"] = { - "parts": [{"text": system_prompt}], - } response = cls._request_json(url, headers, payload, timeout) candidates = response.get("candidates") or [] @@ -373,12 +326,8 @@ class LLMProviderService: return preview @staticmethod - def _build_openai_messages(system_prompt: str) -> List[Dict[str, str]]: - messages: List[Dict[str, str]] = [] - if system_prompt: - messages.append({"role": "system", "content": system_prompt}) - messages.append({"role": "user", "content": "请只回复“连接测试成功”。"}) - return messages + def _build_openai_test_messages() -> List[Dict[str, str]]: + return [{"role": "user", "content": "请只回复“连接测试成功”。"}] @staticmethod def _join_endpoint(base_url: str, suffix: str) -> str: @@ -387,6 +336,38 @@ class LLMProviderService: return normalized return f"{normalized}{suffix}" + @staticmethod + def _build_ssl_context() -> ssl.SSLContext: + """构建 SSL 上下文。 + + 默认用 certifi 提供的 CA 证书包,解决部分系统(如 macOS) + 找不到本地根证书导致的 CERTIFICATE_VERIFY_FAILED。 + 设置 DISABLE_SSL_VERIFY=1 可临时关闭校验(仅用于自签名/调试)。 + """ + if os.getenv("DISABLE_SSL_VERIFY", "").lower() in ("1", "true", "yes"): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + # 优先使用显式配置的 CA 文件,其次 certifi,最后系统默认 + ca_bundle = ( + os.getenv("LLM_SSL_CA_FILE") + or os.getenv("SSL_CERT_FILE") + or os.getenv("REQUESTS_CA_BUNDLE") + ) + if ca_bundle: + try: + return ssl.create_default_context(cafile=ca_bundle) + except (FileNotFoundError, OSError, ssl.SSLError): + pass + + try: + import certifi + return ssl.create_default_context(cafile=certifi.where()) + except ImportError: + return ssl.create_default_context() + @classmethod def _request_json( cls, @@ -402,8 +383,10 @@ class LLMProviderService: method="POST", ) + ctx = cls._build_ssl_context() + try: - with urllib.request.urlopen(request, timeout=timeout) as response: + with urllib.request.urlopen(request, timeout=timeout, context=ctx) as response: body = response.read().decode("utf-8") if not body: return {} @@ -439,3 +422,503 @@ class LLMProviderService: return None + # ==================== 对话文本生成(RAG 调用) ==================== + + @classmethod + async def generate_text( + cls, + provider: Optional[str], + endpoint_url: str, + api_key: str, + llm_model_name: str, + timeout: int, + temperature: float, + top_p: float, + max_tokens: int, + system_prompt: str, + messages: List[Dict[str, Any]], + ) -> str: + """根据多轮对话消息生成回复文本(供知识库 RAG 使用)""" + 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() + + if not endpoint_url: + raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url") + if not llm_model_name: + raise ValueError("缺少模型名称,请填写 llm_model_name") + if provider not in {"ollama"} and not api_key: + raise ValueError("缺少 API Key,请填写后再调用") + + normalized = cls._normalize_messages(messages) + if not normalized: + raise ValueError("缺少有效的对话消息") + + protocol = provider_meta.get("protocol", "openai_compatible") + return await asyncio.to_thread( + cls._chat_completion, + protocol, + endpoint_url, + api_key, + llm_model_name, + int(timeout or 120), + float(temperature or 0.7), + float(top_p or 0.9), + int(max_tokens or 2048), + (system_prompt or "").strip(), + normalized, + ) + + @classmethod + async def generate_text_stream( + cls, + provider: Optional[str], + endpoint_url: str, + api_key: str, + llm_model_name: str, + timeout: int, + temperature: float, + top_p: float, + max_tokens: int, + system_prompt: str, + messages: List[Dict[str, Any]], + ) -> AsyncIterator[str]: + """流式生成回复文本。OpenAI-compatible 使用原生流,其它协议回退为分块输出。""" + 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() + + if not endpoint_url: + raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url") + if not llm_model_name: + raise ValueError("缺少模型名称,请填写 llm_model_name") + if provider not in {"ollama"} and not api_key: + raise ValueError("缺少 API Key,请填写后再调用") + + normalized = cls._normalize_messages(messages) + if not normalized: + raise ValueError("缺少有效的对话消息") + + protocol = provider_meta.get("protocol", "openai_compatible") + if protocol != "openai_compatible": + text = await cls.generate_text( + provider, + endpoint_url, + api_key, + llm_model_name, + timeout, + temperature, + top_p, + max_tokens, + system_prompt, + normalized, + ) + for index in range(0, len(text), 24): + yield text[index:index + 24] + return + + result_queue: "queue.Queue[tuple[str, Any]]" = queue.Queue() + + def worker(): + try: + for chunk in cls._chat_openai_compatible_stream( + endpoint_url, + api_key, + llm_model_name, + int(timeout or 120), + float(temperature or 0.7), + float(top_p or 0.9), + int(max_tokens or 2048), + (system_prompt or "").strip(), + normalized, + ): + result_queue.put(("chunk", chunk)) + except Exception as exc: # pragma: no cover - surfaced through stream + result_queue.put(("error", exc)) + finally: + result_queue.put(("done", None)) + + threading.Thread(target=worker, daemon=True).start() + + while True: + kind, payload = await asyncio.to_thread(result_queue.get) + if kind == "chunk": + yield payload + elif kind == "error": + raise payload + else: + break + + @staticmethod + def _normalize_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, str]]: + normalized: List[Dict[str, str]] = [] + for item in messages or []: + if not isinstance(item, dict): + continue + role = str(item.get("role") or "").strip().lower() + if role not in {"system", "user", "assistant"}: + continue + content = item.get("content", "") + if isinstance(content, list): + content = "".join( + part.get("text", "") if isinstance(part, dict) else str(part) + for part in content + ) + content = str(content).strip() + if not content: + continue + normalized.append({"role": role, "content": content}) + return normalized + + @classmethod + def _chat_completion( + cls, + protocol: str, + endpoint_url: str, + api_key: str, + llm_model_name: str, + timeout: int, + temperature: float, + top_p: float, + max_tokens: int, + system_prompt: str, + messages: List[Dict[str, str]], + ) -> str: + if protocol == "anthropic": + return cls._chat_anthropic( + endpoint_url, api_key, llm_model_name, timeout, + temperature, top_p, max_tokens, system_prompt, messages, + ) + if protocol == "gemini": + return cls._chat_gemini( + endpoint_url, api_key, llm_model_name, timeout, + temperature, top_p, max_tokens, system_prompt, messages, + ) + return cls._chat_openai_compatible( + endpoint_url, api_key, llm_model_name, timeout, + temperature, top_p, max_tokens, system_prompt, messages, + ) + + @classmethod + def _chat_openai_compatible( + cls, + endpoint_url: str, + api_key: str, + llm_model_name: str, + timeout: int, + temperature: float, + top_p: float, + max_tokens: int, + system_prompt: str, + messages: List[Dict[str, str]], + ) -> str: + url = cls._join_endpoint(endpoint_url, "/chat/completions") + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + payload_messages: List[Dict[str, str]] = [] + if system_prompt: + payload_messages.append({"role": "system", "content": system_prompt}) + payload_messages.extend(messages) + + payload = { + "model": llm_model_name, + "messages": payload_messages, + "temperature": temperature, + "top_p": top_p, + "max_tokens": max_tokens, + "stream": False, + } + response = cls._request_json(url, headers, payload, timeout) + choices = response.get("choices") or [] + if not choices: + raise ValueError("请求已发送,但未收到模型返回内容") + content = choices[0].get("message", {}).get("content", "") + if isinstance(content, list): + content = "".join( + item.get("text", "") if isinstance(item, dict) else str(item) + for item in content + ) + content = str(content).strip() + if not content: + raise ValueError("模型返回成功,但内容为空") + return content + + @classmethod + def _chat_openai_compatible_stream( + cls, + endpoint_url: str, + api_key: str, + llm_model_name: str, + timeout: int, + temperature: float, + top_p: float, + max_tokens: int, + system_prompt: str, + messages: List[Dict[str, str]], + ) -> Iterator[str]: + url = cls._join_endpoint(endpoint_url, "/chat/completions") + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + payload_messages: List[Dict[str, str]] = [] + if system_prompt: + payload_messages.append({"role": "system", "content": system_prompt}) + payload_messages.extend(messages) + + payload = { + "model": llm_model_name, + "messages": payload_messages, + "temperature": temperature, + "top_p": top_p, + "max_tokens": max_tokens, + "stream": True, + } + request = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers=headers, + method="POST", + ) + ctx = cls._build_ssl_context() + + try: + with urllib.request.urlopen(request, timeout=timeout, context=ctx) as response: + for raw_line in response: + line = raw_line.decode("utf-8", errors="ignore").strip() + if not line or not line.startswith("data:"): + continue + data = line[5:].strip() + if data == "[DONE]": + break + try: + payload = json.loads(data) + except json.JSONDecodeError: + continue + for choice in payload.get("choices") or []: + delta = choice.get("delta") or {} + content = delta.get("content") + if isinstance(content, list): + content = "".join( + item.get("text", "") if isinstance(item, dict) else str(item) + for item in content + ) + if content: + yield str(content) + except urllib.error.HTTPError as exc: + error_body = exc.read().decode("utf-8", errors="ignore") + message = cls._extract_error_message(error_body) or error_body[:300] or str(exc) + raise ValueError(f"模型调用失败(HTTP {exc.code}):{message}") from exc + except urllib.error.URLError as exc: + reason = exc.reason + if isinstance(reason, socket.timeout): + raise ValueError("模型调用超时,请检查网络或调大超时时间") from exc + raise ValueError(f"模型调用失败:{reason}") from exc + except socket.timeout as exc: + raise ValueError("模型调用超时,请检查网络或调大超时时间") from exc + + @classmethod + def _chat_anthropic( + cls, + endpoint_url: str, + api_key: str, + llm_model_name: str, + timeout: int, + temperature: float, + top_p: float, + max_tokens: int, + system_prompt: str, + messages: List[Dict[str, str]], + ) -> str: + url = cls._join_endpoint(endpoint_url, "/v1/messages") + headers = { + "Content-Type": "application/json", + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + } + payload = { + "model": llm_model_name, + "max_tokens": max_tokens, + "temperature": temperature, + "top_p": top_p, + "messages": [ + {"role": m["role"], "content": [{"type": "text", "text": m["content"]}]} + for m in messages + if m["role"] in {"user", "assistant"} + ], + } + if system_prompt: + payload["system"] = system_prompt + + response = cls._request_json(url, headers, payload, timeout) + content = response.get("content") or [] + texts = [ + item.get("text", "") + for item in content + if isinstance(item, dict) and item.get("type") == "text" + ] + preview = "".join(texts).strip() + if not preview: + raise ValueError("模型返回成功,但内容为空") + return preview + + @classmethod + def _chat_gemini( + cls, + endpoint_url: str, + api_key: str, + llm_model_name: str, + timeout: int, + temperature: float, + top_p: float, + max_tokens: int, + system_prompt: str, + messages: List[Dict[str, str]], + ) -> str: + model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}" + encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/")) + url = f"{endpoint_url.rstrip('/')}/{encoded_model_path}:generateContent?key={urllib.parse.quote(api_key)}" + headers = {"Content-Type": "application/json"} + + role_map = {"user": "user", "assistant": "model"} + contents = [ + {"role": role_map[m["role"]], "parts": [{"text": m["content"]}]} + for m in messages + if m["role"] in role_map + ] + payload = { + "contents": contents, + "generationConfig": { + "temperature": temperature, + "topP": top_p, + "maxOutputTokens": max_tokens, + }, + } + if system_prompt: + payload["systemInstruction"] = {"parts": [{"text": system_prompt}]} + + response = cls._request_json(url, headers, payload, timeout) + candidates = response.get("candidates") or [] + if not candidates: + raise ValueError("请求已发送,但未收到模型返回内容") + parts = candidates[0].get("content", {}).get("parts", []) + preview = "".join( + part.get("text", "") for part in parts if isinstance(part, dict) + ).strip() + if not preview: + raise ValueError("模型返回成功,但内容为空") + return preview + + # ==================== Embedding 向量生成与测试 ==================== + + @classmethod + async def generate_embedding( + cls, + provider: Optional[str], + endpoint_url: str, + api_key: str, + llm_model_name: str, + text: str, + timeout: int = 60, + dimension: Optional[int] = None, + ) -> List[float]: + """调用 OpenAI 兼容的 /embeddings 接口生成单条文本向量""" + 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() + text = (text or "").strip() + + 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,请填写后再调用") + if not text: + raise ValueError("待向量化文本为空") + + vectors = await asyncio.to_thread( + cls._embeddings_request, + endpoint_url, + api_key, + llm_model_name, + [text[:8000]], + int(timeout or 60), + dimension, + ) + if not vectors: + raise ValueError("Embedding 接口返回为空") + return vectors[0] + + @classmethod + def _embeddings_request( + cls, + endpoint_url: str, + api_key: str, + llm_model_name: str, + inputs: List[str], + timeout: int, + dimension: Optional[int], + ) -> List[List[float]]: + url = cls._join_endpoint(endpoint_url, "/embeddings") + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + payload: Dict[str, Any] = { + "model": llm_model_name, + "input": inputs, + } + if dimension: + payload["dimensions"] = dimension + + response = cls._request_json(url, headers, payload, timeout) + data = response.get("data") or [] + if not data: + raise ValueError("Embedding 请求已发送,但未收到向量数据") + + vectors: List[List[float]] = [] + for item in data: + embedding = item.get("embedding") if isinstance(item, dict) else None + if embedding: + vectors.append([float(x) for x in embedding]) + if not vectors: + raise ValueError("Embedding 返回结果中未解析到向量") + return vectors + + @classmethod + async def test_embedding_connection(cls, payload: Dict[str, Any]) -> Dict[str, Any]: + """测试 embedding 模型连通性,并返回实际向量维度""" + provider = payload.get("provider") + provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"]) + endpoint_url = (payload.get("endpoint_url") or provider_meta.get("default_endpoint_url") or "").strip() + llm_model_name = (payload.get("llm_model_name") or "").strip() + api_key = (payload.get("api_key") or "").strip() + timeout = int(payload.get("llm_timeout") or 60) + dimension = payload.get("embedding_dimension") + + started_at = time.perf_counter() + vector = await cls.generate_embedding( + provider, + endpoint_url, + api_key, + llm_model_name, + "连接测试", + timeout=timeout, + dimension=int(dimension) if dimension else None, + ) + latency_ms = int((time.perf_counter() - started_at) * 1000) + + return { + "provider": provider, + "endpoint_url": endpoint_url, + "llm_model_name": llm_model_name, + "latency_ms": latency_ms, + "dimension": len(vector), + "preview": f"成功生成 {len(vector)} 维向量", + } diff --git a/backend/app/services/llm_provider_service.py.bak b/backend/app/services/llm_provider_service.py.bak new file mode 100644 index 0000000..8d842f4 --- /dev/null +++ b/backend/app/services/llm_provider_service.py.bak @@ -0,0 +1,794 @@ +""" +LLM 提供方测试服务 +""" +import asyncio +import json +import socket +import time +import urllib.error +import urllib.parse +import urllib.request +import ssl +from typing import Any, Dict, List, Optional + + +PROVIDER_CATALOG: List[Dict[str, str]] = [ + { + "value": "openai", + "label": "OpenAI", + "default_endpoint_url": "https://api.openai.com/v1", + "protocol": "openai_compatible", + }, + { + "value": "deepseek", + "label": "DeepSeek", + "default_endpoint_url": "https://api.deepseek.com/v1", + "protocol": "openai_compatible", + }, + { + "value": "anthropic", + "label": "Anthropic", + "default_endpoint_url": "https://api.anthropic.com", + "protocol": "anthropic", + }, + { + "value": "gemini", + "label": "Google Gemini", + "default_endpoint_url": "https://generativelanguage.googleapis.com/v1beta", + "protocol": "gemini", + }, + { + "value": "dashscope", + "label": "阿里百炼", + "default_endpoint_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "protocol": "openai_compatible", + }, + { + "value": "zhipu", + "label": "智谱 AI", + "default_endpoint_url": "https://open.bigmodel.cn/api/paas/v4", + "protocol": "openai_compatible", + }, + { + "value": "moonshot", + "label": "Moonshot AI", + "default_endpoint_url": "https://api.moonshot.cn/v1", + "protocol": "openai_compatible", + }, + { + "value": "groq", + "label": "Groq", + "default_endpoint_url": "https://api.groq.com/openai/v1", + "protocol": "openai_compatible", + }, + { + "value": "openrouter", + "label": "OpenRouter", + "default_endpoint_url": "https://openrouter.ai/api/v1", + "protocol": "openai_compatible", + }, + { + "value": "siliconflow", + "label": "SiliconFlow", + "default_endpoint_url": "https://api.siliconflow.cn/v1", + "protocol": "openai_compatible", + }, + { + "value": "ollama", + "label": "Ollama", + "default_endpoint_url": "http://localhost:11434/v1", + "protocol": "openai_compatible", + }, + { + "value": "ark", + "label": "火山方舟", + "default_endpoint_url": "https://ark.cn-beijing.volces.com/api/v3", + "protocol": "openai_compatible", + }, + { + "value": "custom", + "label": "自定义兼容接口", + "default_endpoint_url": "", + "protocol": "openai_compatible", + }, +] + +PROVIDER_MAP = {item["value"]: item for item in PROVIDER_CATALOG} + + +class LLMProviderService: + """LLM 提供方测试服务""" + + @staticmethod + def get_provider_catalog() -> List[Dict[str, str]]: + return PROVIDER_CATALOG + + @staticmethod + def get_provider_label(provider: Optional[str]) -> str: + if not provider: + return "自定义模型" + return PROVIDER_MAP.get(provider, {}).get("label", provider) + + @staticmethod + def get_default_endpoint_url(provider: Optional[str]) -> str: + if not provider: + return "" + return PROVIDER_MAP.get(provider, {}).get("default_endpoint_url", "") + + @classmethod + def build_model_name(cls, provider: Optional[str], llm_model_name: str) -> str: + model_name = (llm_model_name or "").strip() + label = cls.get_provider_label(provider) + if not model_name: + return label + return f"{label} {model_name}" + + @staticmethod + def build_model_code(provider: Optional[str], llm_model_name: str) -> str: + provider_part = (provider or "custom").strip().lower() + model_part = (llm_model_name or "").strip().lower() + sanitized = [] + previous_is_separator = False + for char in model_part: + if char.isalnum(): + sanitized.append(char) + previous_is_separator = False + else: + if not previous_is_separator: + sanitized.append("_") + previous_is_separator = True + + model_slug = "".join(sanitized).strip("_") or "model" + return f"llm_{provider_part}_{model_slug}" + + @classmethod + async def test_model_connection(cls, payload: Dict[str, Any]) -> Dict[str, Any]: + provider = payload.get("provider") + provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"]) + endpoint_url = (payload.get("endpoint_url") or provider_meta.get("default_endpoint_url") or "").strip() + llm_model_name = (payload.get("llm_model_name") or "").strip() + api_key = (payload.get("api_key") or "").strip() + + if not endpoint_url: + raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url") + if not llm_model_name: + raise ValueError("缺少模型名称,请填写 llm_model_name") + if provider not in {"ollama"} and not api_key: + raise ValueError("缺少 API Key,请填写后再测试") + + timeout = int(payload.get("llm_timeout") or 120) + temperature = float(payload.get("llm_temperature") or 0.7) + top_p = float(payload.get("llm_top_p") or 0.9) + max_tokens = int(payload.get("llm_max_tokens") or 2048) + system_prompt = (payload.get("llm_system_prompt") or "").strip() + + started_at = time.perf_counter() + preview = await asyncio.to_thread( + cls._send_test_request, + provider_meta.get("protocol", "openai_compatible"), + provider, + endpoint_url, + api_key, + llm_model_name, + timeout, + temperature, + top_p, + max_tokens, + system_prompt, + ) + latency_ms = int((time.perf_counter() - started_at) * 1000) + + return { + "provider": provider, + "endpoint_url": endpoint_url, + "llm_model_name": llm_model_name, + "latency_ms": latency_ms, + "preview": preview[:200], + } + + @classmethod + def _send_test_request( + cls, + protocol: str, + provider: Optional[str], + endpoint_url: str, + api_key: str, + llm_model_name: str, + timeout: int, + temperature: float, + top_p: float, + max_tokens: int, + system_prompt: str, + ) -> str: + if protocol == "anthropic": + return cls._test_anthropic( + endpoint_url, + api_key, + llm_model_name, + timeout, + temperature, + top_p, + max_tokens, + system_prompt, + ) + + if protocol == "gemini": + return cls._test_gemini( + endpoint_url, + api_key, + llm_model_name, + timeout, + temperature, + top_p, + max_tokens, + system_prompt, + ) + + return cls._test_openai_compatible( + provider, + endpoint_url, + api_key, + llm_model_name, + timeout, + temperature, + top_p, + max_tokens, + system_prompt, + ) + + @classmethod + def _test_openai_compatible( + cls, + provider: Optional[str], + endpoint_url: str, + api_key: str, + llm_model_name: str, + timeout: int, + temperature: float, + top_p: float, + max_tokens: int, + system_prompt: str, + ) -> str: + url = cls._join_endpoint(endpoint_url, "/chat/completions") + headers = { + "Content-Type": "application/json", + } + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + payload = { + "model": llm_model_name, + "messages": cls._build_openai_messages(system_prompt), + "temperature": temperature, + "top_p": top_p, + "max_tokens": min(max_tokens, 256), + "stream": False, + } + response = cls._request_json(url, headers, payload, timeout) + choices = response.get("choices") or [] + if not choices: + raise ValueError("测试请求已发送,但未收到模型返回内容") + + content = choices[0].get("message", {}).get("content", "") + if isinstance(content, list): + content = "".join( + item.get("text", "") if isinstance(item, dict) else str(item) + for item in content + ) + content = str(content).strip() + if not content: + raise ValueError("模型返回成功,但内容为空") + return content + + @classmethod + def _test_anthropic( + cls, + endpoint_url: str, + api_key: str, + llm_model_name: str, + timeout: int, + temperature: float, + top_p: float, + max_tokens: int, + system_prompt: str, + ) -> str: + url = cls._join_endpoint(endpoint_url, "/v1/messages") + headers = { + "Content-Type": "application/json", + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + } + payload = { + "model": llm_model_name, + "max_tokens": min(max_tokens, 256), + "temperature": temperature, + "top_p": top_p, + "messages": [ + { + "role": "user", + "content": "请只回复“连接测试成功”。", + } + ], + } + if system_prompt: + payload["system"] = system_prompt + + response = cls._request_json(url, headers, payload, timeout) + content = response.get("content") or [] + texts = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + texts.append(item.get("text", "")) + preview = "".join(texts).strip() + if not preview: + raise ValueError("模型返回成功,但内容为空") + return preview + + @classmethod + def _test_gemini( + cls, + endpoint_url: str, + api_key: str, + llm_model_name: str, + timeout: int, + temperature: float, + top_p: float, + max_tokens: int, + system_prompt: str, + ) -> str: + model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}" + encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/")) + url = f"{endpoint_url.rstrip('/')}/{encoded_model_path}:generateContent?key={urllib.parse.quote(api_key)}" + headers = { + "Content-Type": "application/json", + } + payload = { + "contents": [ + { + "role": "user", + "parts": [{"text": "请只回复“连接测试成功”。"}], + } + ], + "generationConfig": { + "temperature": temperature, + "topP": top_p, + "maxOutputTokens": min(max_tokens, 256), + }, + } + if system_prompt: + payload["systemInstruction"] = { + "parts": [{"text": system_prompt}], + } + + response = cls._request_json(url, headers, payload, timeout) + candidates = response.get("candidates") or [] + if not candidates: + raise ValueError("测试请求已发送,但未收到模型返回内容") + + parts = candidates[0].get("content", {}).get("parts", []) + preview = "".join( + part.get("text", "") for part in parts if isinstance(part, dict) + ).strip() + if not preview: + raise ValueError("模型返回成功,但内容为空") + return preview + + @staticmethod + def _build_openai_messages(system_prompt: str) -> List[Dict[str, str]]: + messages: List[Dict[str, str]] = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": "请只回复“连接测试成功”。"}) + return messages + + @staticmethod + def _join_endpoint(base_url: str, suffix: str) -> str: + normalized = base_url.rstrip("/") + if normalized.endswith(suffix): + return normalized + return f"{normalized}{suffix}" + + @classmethod + def _request_json( + cls, + url: str, + headers: Dict[str, str], + payload: Dict[str, Any], + timeout: int, + ) -> Dict[str, Any]: + request = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers=headers, + method="POST", + ) + + ctx = None + if os.getenv("DISABLE_SSL_VERIFY", "").lower() in ("1", "true", "yes"): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + try: + with urllib.request.urlopen(request, timeout=timeout, context=ctx) as response: + body = response.read().decode("utf-8") + if not body: + return {} + return json.loads(body) + except urllib.error.HTTPError as exc: + error_body = exc.read().decode("utf-8", errors="ignore") + message = cls._extract_error_message(error_body) or error_body[:300] or str(exc) + raise ValueError(f"模型测试失败(HTTP {exc.code}):{message}") from exc + except urllib.error.URLError as exc: + reason = exc.reason + if isinstance(reason, socket.timeout): + raise ValueError("模型测试超时,请检查网络或调大超时时间") from exc + raise ValueError(f"模型测试失败:{reason}") from exc + except socket.timeout as exc: + raise ValueError("模型测试超时,请检查网络或调大超时时间") from exc + except json.JSONDecodeError as exc: + raise ValueError("模型服务返回了无法解析的响应,请检查接口地址是否正确") from exc + + @staticmethod + def _extract_error_message(error_body: str) -> Optional[str]: + if not error_body: + return None + + try: + payload = json.loads(error_body) + except json.JSONDecodeError: + return error_body.strip() + + if isinstance(payload, dict): + if isinstance(payload.get("error"), dict): + return payload["error"].get("message") or payload["error"].get("type") + return payload.get("message") or payload.get("detail") + + return None + + # ==================== 对话文本生成(RAG 调用) ==================== + + @classmethod + async def generate_text( + cls, + provider: Optional[str], + endpoint_url: str, + api_key: str, + llm_model_name: str, + timeout: int, + temperature: float, + top_p: float, + max_tokens: int, + system_prompt: str, + messages: List[Dict[str, Any]], + ) -> str: + """根据多轮对话消息生成回复文本(供知识库 RAG 使用)""" + 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() + + if not endpoint_url: + raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url") + if not llm_model_name: + raise ValueError("缺少模型名称,请填写 llm_model_name") + if provider not in {"ollama"} and not api_key: + raise ValueError("缺少 API Key,请填写后再调用") + + normalized = cls._normalize_messages(messages) + if not normalized: + raise ValueError("缺少有效的对话消息") + + protocol = provider_meta.get("protocol", "openai_compatible") + return await asyncio.to_thread( + cls._chat_completion, + protocol, + endpoint_url, + api_key, + llm_model_name, + int(timeout or 120), + float(temperature or 0.7), + float(top_p or 0.9), + int(max_tokens or 2048), + (system_prompt or "").strip(), + normalized, + ) + + @staticmethod + def _normalize_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, str]]: + normalized: List[Dict[str, str]] = [] + for item in messages or []: + if not isinstance(item, dict): + continue + role = str(item.get("role") or "").strip().lower() + if role not in {"system", "user", "assistant"}: + continue + content = item.get("content", "") + if isinstance(content, list): + content = "".join( + part.get("text", "") if isinstance(part, dict) else str(part) + for part in content + ) + content = str(content).strip() + if not content: + continue + normalized.append({"role": role, "content": content}) + return normalized + + @classmethod + def _chat_completion( + cls, + protocol: str, + endpoint_url: str, + api_key: str, + llm_model_name: str, + timeout: int, + temperature: float, + top_p: float, + max_tokens: int, + system_prompt: str, + messages: List[Dict[str, str]], + ) -> str: + if protocol == "anthropic": + return cls._chat_anthropic( + endpoint_url, api_key, llm_model_name, timeout, + temperature, top_p, max_tokens, system_prompt, messages, + ) + if protocol == "gemini": + return cls._chat_gemini( + endpoint_url, api_key, llm_model_name, timeout, + temperature, top_p, max_tokens, system_prompt, messages, + ) + return cls._chat_openai_compatible( + endpoint_url, api_key, llm_model_name, timeout, + temperature, top_p, max_tokens, system_prompt, messages, + ) + + @classmethod + def _chat_openai_compatible( + cls, + endpoint_url: str, + api_key: str, + llm_model_name: str, + timeout: int, + temperature: float, + top_p: float, + max_tokens: int, + system_prompt: str, + messages: List[Dict[str, str]], + ) -> str: + url = cls._join_endpoint(endpoint_url, "/chat/completions") + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + payload_messages: List[Dict[str, str]] = [] + if system_prompt: + payload_messages.append({"role": "system", "content": system_prompt}) + payload_messages.extend(messages) + + payload = { + "model": llm_model_name, + "messages": payload_messages, + "temperature": temperature, + "top_p": top_p, + "max_tokens": max_tokens, + "stream": False, + } + response = cls._request_json(url, headers, payload, timeout) + choices = response.get("choices") or [] + if not choices: + raise ValueError("请求已发送,但未收到模型返回内容") + content = choices[0].get("message", {}).get("content", "") + if isinstance(content, list): + content = "".join( + item.get("text", "") if isinstance(item, dict) else str(item) + for item in content + ) + content = str(content).strip() + if not content: + raise ValueError("模型返回成功,但内容为空") + return content + + @classmethod + def _chat_anthropic( + cls, + endpoint_url: str, + api_key: str, + llm_model_name: str, + timeout: int, + temperature: float, + top_p: float, + max_tokens: int, + system_prompt: str, + messages: List[Dict[str, str]], + ) -> str: + url = cls._join_endpoint(endpoint_url, "/v1/messages") + headers = { + "Content-Type": "application/json", + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + } + payload = { + "model": llm_model_name, + "max_tokens": max_tokens, + "temperature": temperature, + "top_p": top_p, + "messages": [ + {"role": m["role"], "content": [{"type": "text", "text": m["content"]}]} + for m in messages + if m["role"] in {"user", "assistant"} + ], + } + if system_prompt: + payload["system"] = system_prompt + + response = cls._request_json(url, headers, payload, timeout) + content = response.get("content") or [] + texts = [ + item.get("text", "") + for item in content + if isinstance(item, dict) and item.get("type") == "text" + ] + preview = "".join(texts).strip() + if not preview: + raise ValueError("模型返回成功,但内容为空") + return preview + + @classmethod + def _chat_gemini( + cls, + endpoint_url: str, + api_key: str, + llm_model_name: str, + timeout: int, + temperature: float, + top_p: float, + max_tokens: int, + system_prompt: str, + messages: List[Dict[str, str]], + ) -> str: + model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}" + encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/")) + url = f"{endpoint_url.rstrip('/')}/{encoded_model_path}:generateContent?key={urllib.parse.quote(api_key)}" + headers = {"Content-Type": "application/json"} + + role_map = {"user": "user", "assistant": "model"} + contents = [ + {"role": role_map[m["role"]], "parts": [{"text": m["content"]}]} + for m in messages + if m["role"] in role_map + ] + payload = { + "contents": contents, + "generationConfig": { + "temperature": temperature, + "topP": top_p, + "maxOutputTokens": max_tokens, + }, + } + if system_prompt: + payload["systemInstruction"] = {"parts": [{"text": system_prompt}]} + + response = cls._request_json(url, headers, payload, timeout) + candidates = response.get("candidates") or [] + if not candidates: + raise ValueError("请求已发送,但未收到模型返回内容") + parts = candidates[0].get("content", {}).get("parts", []) + preview = "".join( + part.get("text", "") for part in parts if isinstance(part, dict) + ).strip() + if not preview: + raise ValueError("模型返回成功,但内容为空") + return preview + + # ==================== Embedding 向量生成与测试 ==================== + + @classmethod + async def generate_embedding( + cls, + provider: Optional[str], + endpoint_url: str, + api_key: str, + llm_model_name: str, + text: str, + timeout: int = 60, + dimension: Optional[int] = None, + ) -> List[float]: + """调用 OpenAI 兼容的 /embeddings 接口生成单条文本向量""" + 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() + text = (text or "").strip() + + 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,请填写后再调用") + if not text: + raise ValueError("待向量化文本为空") + + vectors = await asyncio.to_thread( + cls._embeddings_request, + endpoint_url, + api_key, + llm_model_name, + [text[:8000]], + int(timeout or 60), + dimension, + ) + if not vectors: + raise ValueError("Embedding 接口返回为空") + return vectors[0] + + @classmethod + def _embeddings_request( + cls, + endpoint_url: str, + api_key: str, + llm_model_name: str, + inputs: List[str], + timeout: int, + dimension: Optional[int], + ) -> List[List[float]]: + url = cls._join_endpoint(endpoint_url, "/embeddings") + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + payload: Dict[str, Any] = { + "model": llm_model_name, + "input": inputs, + } + if dimension: + payload["dimensions"] = dimension + + response = cls._request_json(url, headers, payload, timeout) + data = response.get("data") or [] + if not data: + raise ValueError("Embedding 请求已发送,但未收到向量数据") + + vectors: List[List[float]] = [] + for item in data: + embedding = item.get("embedding") if isinstance(item, dict) else None + if embedding: + vectors.append([float(x) for x in embedding]) + if not vectors: + raise ValueError("Embedding 返回结果中未解析到向量") + return vectors + + @classmethod + async def test_embedding_connection(cls, payload: Dict[str, Any]) -> Dict[str, Any]: + """测试 embedding 模型连通性,并返回实际向量维度""" + provider = payload.get("provider") + provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"]) + endpoint_url = (payload.get("endpoint_url") or provider_meta.get("default_endpoint_url") or "").strip() + llm_model_name = (payload.get("llm_model_name") or "").strip() + api_key = (payload.get("api_key") or "").strip() + timeout = int(payload.get("llm_timeout") or 60) + dimension = payload.get("embedding_dimension") + + started_at = time.perf_counter() + vector = await cls.generate_embedding( + provider, + endpoint_url, + api_key, + llm_model_name, + "连接测试", + timeout=timeout, + dimension=int(dimension) if dimension else None, + ) + latency_ms = int((time.perf_counter() - started_at) * 1000) + + return { + "provider": provider, + "endpoint_url": endpoint_url, + "llm_model_name": llm_model_name, + "latency_ms": latency_ms, + "dimension": len(vector), + "preview": f"成功生成 {len(vector)} 维向量", + } + diff --git a/backend/app/services/project_vectorization_task_service.py b/backend/app/services/project_vectorization_task_service.py new file mode 100644 index 0000000..add9397 --- /dev/null +++ b/backend/app/services/project_vectorization_task_service.py @@ -0,0 +1,161 @@ +""" +项目向量化后台任务服务 +""" +import logging +import uuid +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql import func + +from app.core.database import AsyncSessionLocal +from app.models.project import Project +from app.models.project_vectorization_task import ProjectVectorizationTask +from app.services.zvec_service import zvec_service + +logger = logging.getLogger(__name__) + + +class ProjectVectorizationTaskService: + """管理项目文件夹向量化任务的创建、执行和查询。""" + + RUNNING_STATUSES = ("pending", "running") + + @staticmethod + def serialize_task(task: ProjectVectorizationTask) -> dict: + return { + "task_id": task.task_id, + "project_id": task.project_id, + "task_type": task.task_type, + "status": task.status, + "total": task.total, + "processed": task.processed, + "skipped": task.skipped, + "failed": task.failed, + "error_message": task.error_message, + "created_at": task.created_at.isoformat() if task.created_at else None, + "started_at": task.started_at.isoformat() if task.started_at else None, + "finished_at": task.finished_at.isoformat() if task.finished_at else None, + "updated_at": task.updated_at.isoformat() if task.updated_at else None, + } + + async def create_task( + self, + db: AsyncSession, + project_id: int, + user_id: int, + *, + force: bool, + ) -> ProjectVectorizationTask: + task = ProjectVectorizationTask( + task_id=uuid.uuid4().hex, + project_id=project_id, + user_id=user_id, + task_type="full" if force else "incremental", + status="pending", + ) + db.add(task) + await db.commit() + await db.refresh(task) + return task + + async def get_running_task( + self, + db: AsyncSession, + project_id: int, + ) -> Optional[ProjectVectorizationTask]: + result = await db.execute( + select(ProjectVectorizationTask) + .where( + ProjectVectorizationTask.project_id == project_id, + ProjectVectorizationTask.status.in_(self.RUNNING_STATUSES), + ) + .order_by(ProjectVectorizationTask.created_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + async def get_task( + self, + db: AsyncSession, + project_id: int, + task_id: str, + ) -> Optional[ProjectVectorizationTask]: + result = await db.execute( + select(ProjectVectorizationTask).where( + ProjectVectorizationTask.project_id == project_id, + ProjectVectorizationTask.task_id == task_id, + ) + ) + return result.scalar_one_or_none() + + async def get_latest_task( + self, + db: AsyncSession, + project_id: int, + ) -> Optional[ProjectVectorizationTask]: + result = await db.execute( + select(ProjectVectorizationTask) + .where(ProjectVectorizationTask.project_id == project_id) + .order_by(ProjectVectorizationTask.created_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + async def run_task(self, task_id: str) -> None: + async with AsyncSessionLocal() as db: + task = None + try: + result = await db.execute( + select(ProjectVectorizationTask).where( + ProjectVectorizationTask.task_id == task_id + ) + ) + task = result.scalar_one_or_none() + if not task: + return + + project_result = await db.execute( + select(Project).where(Project.id == task.project_id) + ) + project = project_result.scalar_one_or_none() + if not project: + task.status = "failed" + task.error_message = "项目不存在" + task.finished_at = func.now() + await db.commit() + return + + task.status = "running" + task.started_at = func.now() + await db.commit() + + result_data = await zvec_service.vectorize_project( + db, + task.project_id, + project.storage_key, + force=task.task_type == "full", + ) + + task.status = "success" if result_data.get("failed", 0) == 0 else "failed" + task.total = result_data.get("total", 0) + task.processed = result_data.get("processed", 0) + task.skipped = result_data.get("skipped", 0) + task.failed = result_data.get("failed", 0) + task.error_message = ( + f"{task.failed} 个文件向量化失败" if task.failed else None + ) + task.finished_at = func.now() + await db.commit() + except Exception as exc: + logger.exception("Project vectorization task failed: %s", task_id) + await db.rollback() + if task is not None: + task.status = "failed" + task.error_message = str(exc)[:2000] + task.finished_at = func.now() + await db.commit() + + +project_vectorization_task_service = ProjectVectorizationTaskService() diff --git a/backend/app/services/rag_service.py b/backend/app/services/rag_service.py index 49103f7..93e3938 100644 --- a/backend/app/services/rag_service.py +++ b/backend/app/services/rag_service.py @@ -1,14 +1,25 @@ """ -知识库 RAG 服务 - 向量检索 + 大模型生成 +知识库 RAG 服务 - ZVec 向量检索 + 大模型生成 """ -from typing import List, Dict, Any, Optional +import logging +from typing import AsyncIterator, List, Dict, Any +from pathlib import Path + from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select -import json -from app.models.document_vector import DocumentVector from app.models.llm_model_config import LLMModelConfig +from app.core.config import settings from app.services.llm_provider_service import LLMProviderService +from app.services.zvec_service import zvec_service +from app.services.storage import storage_service + +logger = logging.getLogger(__name__) + +# 单个命中分块注入上下文时,围绕命中位置向前后各扩展的字符数。 +# 分块后每次只注入命中段落及其上下文,而非整篇文档,从根本上避免长文档被截断。 +CHUNK_CONTEXT_WINDOW = 1200 +MAX_CHUNKS_PER_DOCUMENT = 3 class RAGService: @@ -18,87 +29,177 @@ class RAGService: async def retrieve_documents( db: AsyncSession, project_id: int, - query_vector: List[float], + query: str, top_k: int = 5, - similarity_threshold: float = 0.3, ) -> List[Dict[str, Any]]: - """向量检索相关文档""" - if not query_vector or len(query_vector) == 0: + """通过 ZVec 向量检索相关文档,并读取文档内容""" + # 多取一些分块,再按文件去重,避免高频命中文件挤掉其他相关文档。 + matched = await zvec_service.search_similar(db, project_id, query, top_k * 4) + + if not matched: return [] - stmt = select(DocumentVector).where( - DocumentVector.project_id == project_id, - DocumentVector.vector.isnot(None), - ) + from app.models.project import Project + stmt = select(Project).where(Project.id == project_id) result = await db.execute(stmt) - all_vectors = result.scalars().all() - - if not all_vectors: + project = result.scalar_one_or_none() + if not project: return [] - scored_docs = [] - for doc in all_vectors: + # 读取文件内容做缓存,避免同一文件多个分块命中时重复读盘 + file_content_cache: Dict[str, str] = {} + + async def _read_content(path: str) -> str: + if path in file_content_cache: + return file_content_cache[path] + full_path = storage_service.get_secure_path(project.storage_key, path) + text = await storage_service.read_file(full_path) + file_content_cache[path] = text + return text + + docs_by_path: Dict[str, Dict[str, Any]] = {} + for item in matched: + file_path = item["file_path"] + chunk_text = item.get("chunk_text") or "" try: - doc_vector = json.loads(doc.vector) if isinstance(doc.vector, str) else doc.vector - similarity = RAGService._cosine_similarity(query_vector, doc_vector) - if similarity >= similarity_threshold: - scored_docs.append({ - "doc": doc, - "similarity": similarity, - "file_path": doc.file_path, - "content_preview": doc.content[:500] if doc.content else "", - }) + content = await _read_content(file_path) + excerpt = RAGService._resolve_excerpt( + content=content, + chunk_text=chunk_text, + chunk_index=item.get("chunk_index", 0), + ) + snippet = RAGService._extract_chunk_context(content, chunk_text, excerpt) + doc = { + "file_path": file_path, + "file_name": Path(file_path).name, + "chunk_index": item.get("chunk_index", 0), + "anchor_text": RAGService._build_anchor(excerpt or chunk_text), + "score": item.get("score", 0.0), + "excerpt": excerpt, + "content": snippet, + "_merged_chunks": 1, + } except Exception: + doc = { + "file_path": file_path, + "file_name": Path(file_path).name, + "chunk_index": item.get("chunk_index", 0), + "anchor_text": RAGService._build_anchor(chunk_text), + "score": item.get("score", 0.0), + "excerpt": "", + "content": "", + "_merged_chunks": 1, + } + + existing = docs_by_path.get(file_path) + if existing is None: + docs_by_path[file_path] = doc + continue + if existing["_merged_chunks"] >= MAX_CHUNKS_PER_DOCUMENT: continue - scored_docs.sort(key=lambda x: x["similarity"], reverse=True) - return scored_docs[:top_k] + existing["score"] = max(existing.get("score", 0.0), doc.get("score", 0.0)) + existing["excerpt"] = RAGService._merge_text_blocks( + existing.get("excerpt", ""), doc.get("excerpt", "") + ) + existing["content"] = RAGService._merge_text_blocks( + existing.get("content", ""), doc.get("content", "") + ) + existing["_merged_chunks"] += 1 + + docs_with_content = list(docs_by_path.values()) + docs_with_content = docs_with_content[:top_k] + for citation_id, doc in enumerate(docs_with_content, 1): + doc.pop("_merged_chunks", None) + doc["citation_id"] = citation_id + + return docs_with_content @staticmethod - def _cosine_similarity(vec1: List[float], vec2: List[float]) -> float: - """计算余弦相似度""" - if len(vec1) != len(vec2) or len(vec1) == 0: - return 0.0 + def _merge_text_blocks(existing: str, incoming: str) -> str: + """合并同一文件的命中内容,避免重复分块生成多个引用。""" + current = (existing or "").strip() + candidate = (incoming or "").strip() + if not candidate or candidate in current: + return current + if not current or current in candidate: + return candidate + return f"{current}\n\n{candidate}" - dot_product = sum(a * b for a, b in zip(vec1, vec2)) - magnitude1 = sum(a * a for a in vec1) ** 0.5 - magnitude2 = sum(b * b for b in vec2) ** 0.5 + @staticmethod + def _resolve_excerpt(content: str, chunk_text: str, chunk_index: int) -> str: + """返回引用预览片段。 - if magnitude1 == 0 or magnitude2 == 0: - return 0.0 + document_vector.chunk_text 是向量命中的分块文本,应直接作为引用预览。 + 仅在 chunk_text 为空时,才按 chunk_index 从原文反推作为兜底。 + """ + text = (chunk_text or "").strip() + if text: + return text + return RAGService._extract_chunk_by_index( + content, + chunk_index, + settings.CHUNK_SIZE, + settings.CHUNK_OVERLAP, + ) or text - return dot_product / (magnitude1 * magnitude2) + @staticmethod + def _build_anchor(text: str, max_chars: int = 120) -> str: + for raw_line in (text or "").splitlines(): + line = raw_line.strip() + if line: + return line[:max_chars] + return (text or "").strip()[:max_chars] + + @staticmethod + def _extract_chunk_by_index( + content: str, + chunk_index: int, + chunk_size: int, + overlap: int, + ) -> str: + """按向量化时的分块参数,从原文切出命中的原始分块。""" + text = content or "" + if not text: + return "" + chunk_size = max(1, int(chunk_size or 1)) + overlap = max(0, min(int(overlap or 0), chunk_size - 1)) + step = chunk_size - overlap + start = max(0, int(chunk_index or 0)) * step + if start >= len(text): + return "" + return text[start:start + chunk_size] + + @staticmethod + def _extract_chunk_context(content: str, anchor: str, fallback: str = "") -> str: + """围绕命中锚点截取上下文窗口。 + + 用锚点在原文中定位命中位置,向前后各扩展 CHUNK_CONTEXT_WINDOW 字符, + 避免注入整篇长文档。定位失败时回退为命中的原始分块。 + """ + text = content or "" + if not text: + return "" + pos = text.find(anchor) if anchor else -1 + if pos < 0: + return fallback or text[:CHUNK_CONTEXT_WINDOW * 2] + start = max(0, pos - CHUNK_CONTEXT_WINDOW) + end = min(len(text), pos + len(anchor) + CHUNK_CONTEXT_WINDOW) + return text[start:end] @staticmethod async def generate_response( db: AsyncSession, query: str, - query_vector: List[float], project_id: int, llm_config_id: int, retrieved_docs: List[Dict[str, Any]], conversation_history: List[Dict[str, str]], ) -> str: """基于检索文档生成对话回复""" - stmt = select(LLMModelConfig).where(LLMModelConfig.config_id == llm_config_id) - result = await db.execute(stmt) - llm_config = result.scalar_one_or_none() - - if not llm_config: - raise ValueError(f"LLM配置不存在: {llm_config_id}") - - context_text = RAGService._build_context(retrieved_docs) - system_prompt = f"""你是一个知识库助手。基于用户提供的知识库文档,回答用户的问题。 - -如果知识库中没有相关信息,请明确说明。 - -知识库文档内容: -{context_text} - -请基于以上知识库内容,用中文回答用户的问题。""" - - messages = list(conversation_history) - messages.append({"role": "user", "content": query}) + llm_config, system_prompt, messages = await RAGService._prepare_generation( + db, query, llm_config_id, retrieved_docs, conversation_history + ) response_text = await LLMProviderService.generate_text( provider=llm_config.provider, @@ -115,6 +216,72 @@ class RAGService: return response_text + @staticmethod + async def generate_response_stream( + db: AsyncSession, + query: str, + project_id: int, + llm_config_id: int, + retrieved_docs: List[Dict[str, Any]], + conversation_history: List[Dict[str, str]], + ) -> AsyncIterator[str]: + """基于检索文档流式生成对话回复""" + llm_config, system_prompt, messages = await RAGService._prepare_generation( + db, query, llm_config_id, retrieved_docs, conversation_history + ) + + async for chunk in LLMProviderService.generate_text_stream( + provider=llm_config.provider, + endpoint_url=llm_config.endpoint_url, + api_key=llm_config.api_key, + llm_model_name=llm_config.llm_model_name, + timeout=llm_config.llm_timeout, + temperature=float(llm_config.llm_temperature), + top_p=float(llm_config.llm_top_p), + max_tokens=llm_config.llm_max_tokens, + system_prompt=system_prompt, + messages=messages, + ): + yield chunk + + @staticmethod + async def _prepare_generation( + db: AsyncSession, + query: str, + llm_config_id: int, + retrieved_docs: List[Dict[str, Any]], + conversation_history: List[Dict[str, str]], + ): + stmt = select(LLMModelConfig).where(LLMModelConfig.config_id == llm_config_id) + result = await db.execute(stmt) + llm_config = result.scalar_one_or_none() + + if not llm_config: + raise ValueError(f"LLM配置不存在: {llm_config_id}") + + context_text = RAGService._build_context(retrieved_docs) + citation_hint = RAGService._build_citation_hint(retrieved_docs) + system_prompt = f"""你是一个知识库助手。基于用户提供的知识库文档,回答用户的问题。 + +如果知识库中没有相关信息,请明确说明。 + +知识库文档内容: +{context_text} + +引用规则: +{citation_hint} + +回答要求: +1. 仅基于知识库内容回答,不要编造。 +2. 如果使用了某条知识,请在对应句子后标注引用编号,例如 [1] 或 [1][3]。 +3. 如果没有找到依据,请直接说明未检索到相关内容。 + +请基于以上知识库内容,用中文回答用户的问题。""" + + messages = list(conversation_history) + messages.append({"role": "user", "content": query}) + return llm_config, system_prompt, messages + @staticmethod def _build_context(retrieved_docs: List[Dict[str, Any]]) -> str: """构建上下文文本""" @@ -123,12 +290,26 @@ class RAGService: context_parts = [] for i, doc_info in enumerate(retrieved_docs, 1): + content = doc_info.get("content", "").strip() + if not content: + continue + citation_id = doc_info.get("citation_id", i) context_parts.append( - f"文档 {i}: {doc_info['file_path']} (相似度: {doc_info['similarity']:.2%})\n" - f"{doc_info['content_preview']}..." + f"--- [{citation_id}] 文档: {doc_info['file_path']} ---\n{content}" ) - return "\n\n".join(context_parts) + return "\n\n".join(context_parts) if context_parts else "(未找到相关知识库内容)" + + @staticmethod + def _build_citation_hint(retrieved_docs: List[Dict[str, Any]]) -> str: + """构建引用提示""" + if not retrieved_docs: + return "未检索到文档时不要标注引用。" + + return "\n".join( + f"[{doc.get('citation_id', index)}] {doc.get('file_name') or doc.get('file_path')}" + for index, doc in enumerate(retrieved_docs, 1) + ) rag_service = RAGService() diff --git a/backend/app/services/zvec_service.py b/backend/app/services/zvec_service.py index bee623c..75e2640 100644 --- a/backend/app/services/zvec_service.py +++ b/backend/app/services/zvec_service.py @@ -1,135 +1,407 @@ """ -ZVec 向量化服务 - 调用阿里云ZVec API进行文档向量化 +ZVec 本地向量化服务 - 使用本地 zvec 库 + 模型配置中的 embedding 模型 """ -import json import os -import httpx +import hashlib +import logging +from pathlib import Path from typing import Dict, List, Optional, Any + +import zvec from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete +from app.models.llm_model_config import LLMModelConfig from app.models.document_vector import DocumentVector -from app.core.database import get_db +from app.services.llm_provider_service import LLMProviderService + +logger = logging.getLogger(__name__) + +def _resolve_zvec_data_dir() -> str: + """解析 ZVec 向量库根目录。 + + 优先用 ZVEC_DATA_DIR 环境变量;否则放到 storage 存储区下的 + vector_index 目录,与 search_index 并列,便于统一备份/管理。 + """ + env_dir = os.getenv("ZVEC_DATA_DIR") + if env_dir: + return env_dir + + from app.core.config import settings + + storage_root = Path(settings.STORAGE_ROOT) + if not storage_root.is_absolute(): + backend_dir = Path(__file__).parent.parent.parent + storage_root = (backend_dir / storage_root).resolve() + return str(storage_root / "vector_index") + + +ZVEC_DATA_DIR = _resolve_zvec_data_dir() +EMBEDDING_DIMENSION = int(os.getenv("ZVEC_EMBEDDING_DIM", "1536")) class ZVecService: - """ZVec 向量化服务""" + """基于本地 zvec 库的向量存储和检索服务""" - ZVEC_API_ENDPOINT = os.getenv("ZVEC_API_ENDPOINT", "https://api.aliyun.com/zvec") - ZVEC_API_KEY = os.getenv("ZVEC_API_KEY", "") - ZVEC_MODEL = os.getenv("ZVEC_MODEL", "text-embedding-v3") + _collections: Dict[int, zvec.Collection] = {} @classmethod - async def is_configured(cls) -> bool: - """检查ZVec是否已配置""" - return bool(cls.ZVEC_API_KEY and cls.ZVEC_API_ENDPOINT) + def _get_collection_path(cls, project_id: int) -> str: + """返回项目 collection 路径。 + + 只确保父目录存在;叶子目录由 zvec.create_and_open 自行创建, + 预先创建叶子目录会让 create_and_open 报 "path exists"。 + """ + path = Path(ZVEC_DATA_DIR) / str(project_id) + path.parent.mkdir(parents=True, exist_ok=True) + return str(path) @classmethod - async def vectorize_text(cls, text: str, doc_id: Optional[int] = None) -> Optional[List[float]]: - """ - 调用ZVec API对文本进行向量化 + def _open_collection(cls, project_id: int) -> Optional[zvec.Collection]: + """打开已存在的 collection;不存在则返回 None""" + if project_id in cls._collections: + return cls._collections[project_id] - Args: - text: 要向量化的文本内容 - doc_id: 文档ID(用于日志追踪) - - Returns: - 向量列表,或如果API调用失败则返回None - """ - if not await cls.is_configured(): + collection_path = cls._get_collection_path(project_id) + try: + collection = zvec.open(path=collection_path) + except Exception: return None + cls._collections[project_id] = collection + return collection + + @classmethod + def _get_or_create_collection(cls, project_id: int, dimension: int) -> zvec.Collection: + """按指定维度获取或创建 collection。 + + ZVec 的 collection 维度创建后固定,若已存在的维度与当前 embedding + 模型不一致,则重建该项目的 collection(旧向量作废,需重新向量化)。 + """ + collection = cls._open_collection(project_id) + if collection is not None: + existing_dim = cls._collection_dimension(collection) + if existing_dim is not None and existing_dim != dimension: + logger.warning( + "Project %s collection dim %s != model dim %s, rebuilding", + project_id, existing_dim, dimension, + ) + cls._drop_collection(project_id) + collection = None + else: + return collection + + collection_path = cls._get_collection_path(project_id) + # ZVec 的 create_and_open 要求目标路径不存在,清掉可能残留的空/损坏目录,保证幂等 + leaf = Path(ZVEC_DATA_DIR) / str(project_id) + if leaf.exists(): + import shutil + shutil.rmtree(leaf, ignore_errors=True) + schema = zvec.CollectionSchema( + name=f"project_{project_id}", + vectors=zvec.VectorSchema( + "embedding", zvec.DataType.VECTOR_FP32, dimension + ), + ) + collection = zvec.create_and_open(path=collection_path, schema=schema) + cls._collections[project_id] = collection + return collection + + @staticmethod + def _collection_dimension(collection: zvec.Collection) -> Optional[int]: + """尽力读取 collection 的向量维度,读取失败返回 None""" + try: + schema = collection.schema + vectors = getattr(schema, "vectors", None) + if vectors is None: + return None + vector_schema = vectors[0] if isinstance(vectors, (list, tuple)) else vectors + return getattr(vector_schema, "dimension", None) + except Exception: + return None + + @classmethod + def _drop_collection(cls, project_id: int) -> None: + """删除项目 collection 的本地数据并清理缓存""" + cls._collections.pop(project_id, None) + import shutil + + path = Path(ZVEC_DATA_DIR) / str(project_id) + if path.exists(): + shutil.rmtree(path, ignore_errors=True) + + @classmethod + async def get_embedding_config(cls, db: AsyncSession) -> Optional[LLMModelConfig]: + """获取系统配置的 embedding 模型(按 model_type 精确查询)""" + stmt = ( + select(LLMModelConfig) + .where( + LLMModelConfig.is_active == True, + LLMModelConfig.model_type == "embedding", + ) + .order_by( + LLMModelConfig.is_default.desc(), + LLMModelConfig.updated_at.desc(), + LLMModelConfig.config_id.desc(), + ) + ) + result = await db.execute(stmt) + return result.scalars().first() + + @classmethod + async def generate_embedding( + cls, db: AsyncSession, text: str + ) -> Optional[List[float]]: + """调用配置的 embedding 模型生成向量""" if not text or not text.strip(): return None + config = await cls.get_embedding_config(db) + if not config: + logger.warning("No embedding model configured, skipping vectorization") + return None + try: - # 截断超长文本(ZVec有输入限制) - text = text[:8000] - - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {cls.ZVEC_API_KEY}", - } - - payload = { - "model": cls.ZVEC_MODEL, - "input": text, - } - - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post( - f"{cls.ZVEC_API_ENDPOINT}/embeddings", - headers=headers, - json=payload, - ) - response.raise_for_status() - - data = response.json() - embeddings = data.get("data", []) - if embeddings: - return embeddings[0].get("embedding", []) - - return None - + return await LLMProviderService.generate_embedding( + provider=config.provider, + endpoint_url=config.endpoint_url, + api_key=config.api_key, + llm_model_name=config.llm_model_name, + text=text, + timeout=config.llm_timeout or 60, + dimension=config.embedding_dimension, + ) except Exception as exc: - print(f"ZVec vectorization failed for doc_id={doc_id}: {exc}") + logger.error(f"Embedding generation failed: {exc}") return None + @staticmethod + def _doc_id(file_path: str, chunk_index: int = 0) -> str: + """生成 ZVec 合法的 doc id。 + + ZVec 的 doc id 不允许中文等非 ASCII 字符,故对文件路径做 MD5 哈希。 + 分块后每个 chunk 需独立 doc id,故追加 chunk 序号。 + file_path 的反查通过 document_vector 表的 zvec_id 字段完成。 + """ + base = hashlib.md5(file_path.encode("utf-8")).hexdigest() + return f"{base}#{chunk_index}" + + @staticmethod + def _content_hash(content: str) -> str: + return hashlib.sha256((content or "").encode("utf-8")).hexdigest() + + @staticmethod + def _make_anchor(text: str, max_chars: int = 120) -> str: + """从 chunk 文本中提取定位锚点:首个非空文本行、去除常见 markdown 标记。 + + 锚点供旧引用定位兼容使用,故取纯文本片段。 + """ + import re + + for raw_line in (text or "").splitlines(): + line = raw_line.strip() + if not line: + continue + # 去除标题井号、列表符号、引用符号等行首标记 + line = re.sub(r"^\s*#{1,6}\s+", "", line) + line = re.sub(r"^\s*[-*+]\s+", "", line) + line = re.sub(r"^\s*>\s+", "", line) + line = re.sub(r"^\s*\d+\.\s+", "", line) + # 去除行内强调/代码标记 + line = re.sub(r"[*_`~]", "", line) + line = line.strip() + if line: + return line[:max_chars] + return (text or "").strip()[:max_chars] + @classmethod - async def store_vector( + def _chunk_text( + cls, + content: str, + chunk_size: int, + overlap: int, + ) -> List[Dict[str, Any]]: + """按字符滑动窗口分块。 + + 返回 [{index, text, anchor}]。相邻分块重叠 overlap 个字符, + 以避免语义在分块边界被切断。空内容返回空列表。 + """ + text = content or "" + if not text.strip(): + return [] + + chunk_size = max(1, int(chunk_size)) + overlap = max(0, min(int(overlap), chunk_size - 1)) + step = chunk_size - overlap + + chunks: List[Dict[str, Any]] = [] + start = 0 + length = len(text) + index = 0 + while start < length: + piece = text[start:start + chunk_size] + if piece.strip(): + chunks.append({ + "index": index, + "text": piece, + "anchor": cls._make_anchor(piece), + }) + index += 1 + start += step + + return chunks + + @classmethod + async def _upsert_vector_record( + cls, + db: AsyncSession, + project_id: int, + file_path: str, + *, + status: str, + chunk_index: int = 0, + chunk_text: Optional[str] = None, + content_hash: Optional[str] = None, + zvec_id: Optional[str] = None, + error_message: Optional[str] = None, + ) -> None: + """写入/更新 document_vector 表中某个分块的向量化状态。 + + 按 (project_id, file_path, chunk_index) 定位记录。失败状态通常写 + chunk_index=0 的一条即可,成功状态则逐分块写入。 + """ + stmt = select(DocumentVector).where( + DocumentVector.project_id == project_id, + DocumentVector.file_path == file_path, + DocumentVector.chunk_index == chunk_index, + ) + result = await db.execute(stmt) + record = result.scalar_one_or_none() + + if record is None: + record = DocumentVector( + project_id=project_id, + file_path=file_path, + chunk_index=chunk_index, + chunk_text=chunk_text, + status=status, + content_hash=content_hash, + zvec_id=zvec_id, + error_message=(error_message or "")[:500] or None, + ) + db.add(record) + else: + record.status = status + if chunk_text is not None: + record.chunk_text = chunk_text + if content_hash is not None: + record.content_hash = content_hash + if zvec_id is not None: + record.zvec_id = zvec_id + record.error_message = (error_message or "")[:500] or None + + @classmethod + async def _purge_file_chunks( + cls, + db: AsyncSession, + project_id: int, + file_path: str, + ) -> None: + """删除某文件在 ZVec 与 document_vector 表中的所有分块记录(不 commit)。""" + # 先查出该文件所有分块的 zvec_id,用于从 ZVec 集合中删除 + result = await db.execute( + select(DocumentVector.zvec_id).where( + DocumentVector.project_id == project_id, + DocumentVector.file_path == file_path, + ) + ) + zvec_ids = [z for (z,) in result.all() if z] + if zvec_ids: + try: + collection = cls._open_collection(project_id) + if collection is not None: + collection.delete(zvec_ids) + except Exception as exc: + logger.error(f"ZVec delete chunks failed for {file_path}: {exc}") + + await db.execute( + delete(DocumentVector).where( + DocumentVector.project_id == project_id, + DocumentVector.file_path == file_path, + ) + ) + + @classmethod + async def vectorize_markdown( cls, db: AsyncSession, project_id: int, file_path: str, content: str, - doc_id: Optional[int] = None, ) -> bool: + """对 MD 文件分块向量化并存入 ZVec,同时逐分块记录状态到 document_vector。 + + 流程:删除旧分块 → 分块 → 逐块生成 embedding、写入 ZVec、写记录。 """ - 向量化文本并存储到数据库 + from app.core.config import settings - Args: - db: 数据库会话 - project_id: 项目ID - file_path: 文件路径 - content: 文件内容 - doc_id: 文档元数据ID + content_hash = cls._content_hash(content) - Returns: - 是否成功存储 - """ - # 调用ZVec获取向量 - embedding = await cls.vectorize_text(content, doc_id) - if not embedding: - return False + # 先清理该文件的所有旧分块(增量重建),保证不残留过期向量 + await cls._purge_file_chunks(db, project_id, file_path) - try: - # 先删除旧的向量记录 - await db.execute( - delete(DocumentVector).where( - (DocumentVector.project_id == project_id) - & (DocumentVector.file_path == file_path) - ) - ) - - # 创建新的向量记录 - vector_record = DocumentVector( - project_id=project_id, - file_path=file_path, - doc_id=doc_id, - embedding=json.dumps(embedding), - embedding_model=cls.ZVEC_MODEL, - chunk_count=1, # 单个文件作为一个chunk - ) - - db.add(vector_record) + chunks = cls._chunk_text(content, settings.CHUNK_SIZE, settings.CHUNK_OVERLAP) + if not chunks: + # 空文件:清理后直接提交,视为成功(无可向量化内容) await db.commit() return True - except Exception as exc: - print(f"Failed to store vector for {file_path}: {exc}") - await db.rollback() + success_count = 0 + last_error: Optional[str] = None + for chunk in chunks: + embedding = await cls.generate_embedding(db, chunk["text"]) + if not embedding: + last_error = "未配置可用的 embedding 模型或向量生成失败" + continue + + doc_id = cls._doc_id(file_path, chunk["index"]) + try: + collection = cls._get_or_create_collection(project_id, len(embedding)) + collection.insert([ + zvec.Doc(id=doc_id, vectors={"embedding": embedding}) + ]) + except Exception as exc: + logger.error( + f"ZVec insert failed for {file_path} chunk {chunk['index']}: {exc}" + ) + last_error = str(exc) + continue + + await cls._upsert_vector_record( + db, project_id, file_path, + status="success", + chunk_index=chunk["index"], + chunk_text=chunk["text"], + content_hash=content_hash, + zvec_id=doc_id, + ) + success_count += 1 + + if success_count == 0: + # 全部分块失败:写一条 failed 记录(chunk_index=0)便于进度展示 + await cls._upsert_vector_record( + db, project_id, file_path, + status="failed", + chunk_index=0, + content_hash=content_hash, + error_message=last_error or "向量化失败", + ) + await db.commit() return False + await db.commit() + return True + @classmethod async def delete_vector( cls, @@ -137,30 +409,248 @@ class ZVecService: project_id: int, file_path: str, ) -> bool: - """ - 删除文档的向量记录 - - Args: - db: 数据库会话 - project_id: 项目ID - file_path: 文件路径 - - Returns: - 是否成功删除 - """ + """从 ZVec 删除文档的所有分块向量,并清除 document_vector 记录""" try: + await cls._purge_file_chunks(db, project_id, file_path) + await db.commit() + except Exception as exc: + logger.error(f"Delete document_vector record failed for {file_path}: {exc}") + await db.rollback() + return True + + # 兼容 project_file_service 中的调用别名 + @classmethod + async def delete_vectors( + cls, + db: AsyncSession, + project_id: int, + file_path: str, + ) -> bool: + """delete_vector 的别名(兼容文件服务调用)""" + return await cls.delete_vector(db, project_id, file_path) + + @classmethod + async def sync_vector_move( + cls, + db: AsyncSession, + project_id: int, + old_path: str, + new_path: str, + content: Optional[str] = None, + ) -> bool: + """文件移动/重命名时同步向量。 + + content 为空时会尝试读取新路径文件内容再向量化。 + """ + await cls.delete_vector(db, project_id, old_path) + if content is None: + return await cls.revectorize_path(db, project_id, new_path) + return await cls.vectorize_markdown(db, project_id, new_path, content) + + @classmethod + async def update_vector_path( + cls, + db: AsyncSession, + project_id: int, + old_path: str, + new_path: str, + content: str, + ) -> bool: + """文件重命名/移动时更新向量(删除旧的,插入新的)""" + await cls.delete_vector(db, project_id, old_path) + return await cls.vectorize_markdown(db, project_id, new_path, content) + + @classmethod + async def revectorize_path( + cls, + db: AsyncSession, + project_id: int, + file_path: str, + ) -> bool: + """读取项目中指定文件内容并重新向量化""" + content = await cls._read_project_file(db, project_id, file_path) + if content is None: + return False + return await cls.vectorize_markdown(db, project_id, file_path, content) + + @staticmethod + async def _read_project_file( + db: AsyncSession, + project_id: int, + file_path: str, + ) -> Optional[str]: + """读取项目内某个相对路径文件的文本内容""" + from app.models.project import Project + from app.services.storage import storage_service + + result = await db.execute(select(Project).where(Project.id == project_id)) + project = result.scalar_one_or_none() + if not project: + return None + try: + full_path = storage_service.get_secure_path(project.storage_key, file_path) + return await storage_service.read_file(full_path) + except Exception as exc: + logger.error(f"Read project file failed {file_path}: {exc}") + return None + + @classmethod + def _list_markdown_files(cls, storage_key: str) -> List[str]: + """列出项目下所有 MD 文件的相对路径(排除 _assets 资源目录)""" + from app.services.storage import storage_service + + root = storage_service.get_secure_path(storage_key) + if not root.exists(): + return [] + files: List[str] = [] + for md_path in root.rglob("*.md"): + try: + rel = md_path.relative_to(root).as_posix() + except ValueError: + continue + # 跳过 _assets 资源目录 + if rel.startswith("_assets/") or "/_assets/" in rel: + continue + # 跳过 . 开头的隐藏文件或位于隐藏目录下的文件(如 .git/.obsidian 等) + if any(part.startswith(".") for part in rel.split("/")): + continue + files.append(rel) + return files + + @staticmethod + def _aggregate_file_records( + rows: List[DocumentVector], + ) -> Dict[str, Dict[str, Any]]: + """将多分块记录按文件聚合为文件级状态。 + + 返回 {file_path: {status, content_hash, error_message}}。 + 任一分块 failed 则该文件视为 failed;否则若有 success 则 success。 + """ + by_file: Dict[str, Dict[str, Any]] = {} + for r in rows: + agg = by_file.get(r.file_path) + if agg is None: + agg = {"status": r.status, "content_hash": r.content_hash, "error_message": r.error_message} + by_file[r.file_path] = agg + else: + # failed 优先级最高,用于暴露问题 + if r.status == "failed": + agg["status"] = "failed" + agg["error_message"] = r.error_message or agg.get("error_message") + elif agg["status"] != "failed" and r.status == "success": + agg["status"] = "success" + if agg.get("content_hash") is None and r.content_hash: + agg["content_hash"] = r.content_hash + return by_file + + @classmethod + async def get_progress( + cls, + db: AsyncSession, + project_id: int, + storage_key: str, + ) -> Dict[str, Any]: + """统计项目向量化进度:已成功 / 失败 / 待处理 / 总 MD 文件数""" + md_files = cls._list_markdown_files(storage_key) + total = len(md_files) + + result = await db.execute( + select(DocumentVector).where(DocumentVector.project_id == project_id) + ) + records = cls._aggregate_file_records(list(result.scalars().all())) + + success = 0 + failed_items: List[Dict[str, str]] = [] + pending_items: List[str] = [] + for path in md_files: + record = records.get(path) + if record and record["status"] == "success": + success += 1 + elif record and record["status"] == "failed": + failed_items.append({ + "file_path": path, + "error": record.get("error_message") or "向量化失败", + }) + else: + pending_items.append(path) + + percent = int(success / total * 100) if total else 0 + return { + "total": total, + "success": success, + "failed": len(failed_items), + "pending": len(pending_items), + "percent": percent, + "failed_items": failed_items[:50], + "pending_items": pending_items[:50], + "embedding_ready": await cls.get_embedding_config(db) is not None, + } + + @classmethod + async def vectorize_project( + cls, + db: AsyncSession, + project_id: int, + storage_key: str, + *, + force: bool = False, + ) -> Dict[str, Any]: + """批量向量化项目下所有 MD 文件。 + + force=False 时跳过内容未变化且已成功的文件(增量); + force=True 时全部重新向量化(全量)。 + """ + md_files = cls._list_markdown_files(storage_key) + md_file_set = set(md_files) + + result = await db.execute( + select(DocumentVector).where(DocumentVector.project_id == project_id) + ) + records = cls._aggregate_file_records(list(result.scalars().all())) + + if force: + cls._drop_collection(project_id) await db.execute( - delete(DocumentVector).where( - (DocumentVector.project_id == project_id) - & (DocumentVector.file_path == file_path) - ) + delete(DocumentVector).where(DocumentVector.project_id == project_id) ) await db.commit() - return True - except Exception as exc: - print(f"Failed to delete vector for {file_path}: {exc}") - await db.rollback() - return False + records = {} + else: + for stale_path in set(records) - md_file_set: + await cls.delete_vector(db, project_id, stale_path) + records.pop(stale_path, None) + + processed = 0 + skipped = 0 + failed = 0 + for path in md_files: + content = await cls._read_project_file(db, project_id, path) + if content is None: + failed += 1 + continue + + if not force: + record = records.get(path) + if ( + record + and record["status"] == "success" + and record.get("content_hash") == cls._content_hash(content) + ): + skipped += 1 + continue + + ok = await cls.vectorize_markdown(db, project_id, path, content) + if ok: + processed += 1 + else: + failed += 1 + + return { + "total": len(md_files), + "processed": processed, + "skipped": skipped, + "failed": failed, + } @classmethod async def search_similar( @@ -170,71 +660,57 @@ class ZVecService: query: str, top_k: int = 5, ) -> List[Dict[str, Any]]: - """ - 基于查询文本检索相似文档 - - Args: - db: 数据库会话 - project_id: 项目ID - query: 查询文本 - top_k: 返回最相似的文档数量 - - Returns: - 相似文档列表,按相似度排序 - """ - # 获取查询文本的向量 - query_embedding = await cls.vectorize_text(query) + """基于查询文本检索相似文档""" + query_embedding = await cls.generate_embedding(db, query) if not query_embedding: return [] try: - # 获取项目中所有文档的向量 - result = await db.execute( - select(DocumentVector).where( - DocumentVector.project_id == project_id - ) + collection = cls._open_collection(project_id) + if collection is None: + return [] # 项目尚未向量化 + results = collection.query( + zvec.VectorQuery("embedding", vector=query_embedding), + topk=top_k, ) - vectors = result.scalars().all() - if not vectors: + # doc id 是「路径哈希#分块序号」,通过 document_vector 表的 zvec_id + # 反查真实 file_path、chunk_index 及分块文本 chunk_text + zvec_ids = [r.id for r in results] + if not zvec_ids: return [] - # 计算相似度(余弦相似度) - similarities = [] - for vector_record in vectors: - try: - doc_embedding = json.loads(vector_record.embedding) - similarity = cls._cosine_similarity(query_embedding, doc_embedding) - similarities.append({ - "file_path": vector_record.file_path, - "doc_id": vector_record.doc_id, - "similarity": similarity, - }) - except json.JSONDecodeError: - continue + db_result = await db.execute( + select(DocumentVector).where( + DocumentVector.project_id == project_id, + DocumentVector.zvec_id.in_(zvec_ids), + ) + ) + record_by_id = {r.zvec_id: r for r in db_result.scalars().all()} - # 按相似度排序并返回top-k - similarities.sort(key=lambda x: x["similarity"], reverse=True) - return similarities[:top_k] + matched_docs = [] + for result in results: + record = record_by_id.get(result.id) + if not record: + continue + matched_docs.append({ + "file_path": record.file_path, + "chunk_index": record.chunk_index, + "chunk_text": record.chunk_text or "", + "score": result.score if hasattr(result, "score") else 0.0, + }) + + return matched_docs except Exception as exc: - print(f"Failed to search similar documents: {exc}") + logger.error(f"ZVec search failed: {exc}") return [] - @staticmethod - def _cosine_similarity(vec1: List[float], vec2: List[float]) -> float: - """计算两个向量的余弦相似度""" - if not vec1 or not vec2 or len(vec1) != len(vec2): - return 0.0 - - dot_product = sum(a * b for a, b in zip(vec1, vec2)) - magnitude1 = sum(a * a for a in vec1) ** 0.5 - magnitude2 = sum(b * b for b in vec2) ** 0.5 - - if magnitude1 == 0 or magnitude2 == 0: - return 0.0 - - return dot_product / (magnitude1 * magnitude2) + @classmethod + def close_collection(cls, project_id: int) -> None: + """关闭项目的 ZVec collection""" + if project_id in cls._collections: + del cls._collections[project_id] zvec_service = ZVecService() diff --git a/backend/scripts/create_chat_tables.sql b/backend/scripts/create_chat_tables.sql index 970308b..1eba4bf 100644 --- a/backend/scripts/create_chat_tables.sql +++ b/backend/scripts/create_chat_tables.sql @@ -1,25 +1,33 @@ -- 创建知识库对话会话表 -CREATE TABLE IF NOT EXISTS chat_session ( - session_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '会话ID', - project_id BIGINT NOT NULL COMMENT '项目ID', - user_id BIGINT NOT NULL COMMENT '创建用户ID', - model_config_id BIGINT NOT NULL COMMENT '使用的LLM模型配置ID', - title VARCHAR(255) NOT NULL COMMENT '会话标题', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - INDEX idx_project_user (project_id, user_id), - INDEX idx_created_at (created_at) +CREATE TABLE IF NOT EXISTS `chat_session` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '会话ID', + `project_id` BIGINT NOT NULL COMMENT '项目ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `llm_config_id` BIGINT NOT NULL COMMENT 'LLM配置ID', + `title` VARCHAR(255) NOT NULL COMMENT '会话标题', + `description` TEXT DEFAULT NULL COMMENT '会话描述', + `is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否激活', + `message_count` INT NOT NULL DEFAULT 0 COMMENT '消息数', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX `idx_project_id` (`project_id`), + INDEX `idx_user_id` (`user_id`), + INDEX `idx_project_user` (`project_id`, `user_id`), + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`llm_config_id`) REFERENCES `llm_model_config`(`config_id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库对话会话表'; -- 创建知识库对话消息表 -CREATE TABLE IF NOT EXISTS chat_message ( - message_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '消息ID', - session_id BIGINT NOT NULL COMMENT '所属会话ID', - role VARCHAR(20) NOT NULL COMMENT '角色(user/assistant)', - content LONGTEXT NOT NULL COMMENT '消息内容', - retrieved_docs JSON COMMENT '检索到的相关文档(JSON数组)', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - FOREIGN KEY (session_id) REFERENCES chat_session(session_id) ON DELETE CASCADE, - INDEX idx_session_id (session_id), - INDEX idx_created_at (created_at) +CREATE TABLE IF NOT EXISTS `chat_message` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID', + `session_id` BIGINT NOT NULL COMMENT '会话ID', + `role` VARCHAR(32) NOT NULL COMMENT '角色(user/assistant)', + `content` TEXT NOT NULL COMMENT '消息内容', + `referenced_files` TEXT DEFAULT NULL COMMENT '参考文件(JSON数组)', + `tokens_used` INT DEFAULT NULL COMMENT '消耗的token数', + `is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已删除', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX `idx_session_id` (`session_id`), + FOREIGN KEY (`session_id`) REFERENCES `chat_session`(`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库对话消息表'; diff --git a/backend/scripts/create_document_vector_table.sql b/backend/scripts/create_document_vector_table.sql index 303aed4..ecaf81f 100644 --- a/backend/scripts/create_document_vector_table.sql +++ b/backend/scripts/create_document_vector_table.sql @@ -1,12 +1,17 @@ -CREATE TABLE IF NOT EXISTS document_vector ( - id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '向量ID', - project_id BIGINT NOT NULL COMMENT '项目ID', - file_path VARCHAR(500) NOT NULL COMMENT '文件相对路径', - content_hash VARCHAR(64) COMMENT '内容哈希', - vector JSON COMMENT '向量数据(JSON数组)', - metadata JSON COMMENT '向量元数据', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - INDEX idx_project_file (project_id, file_path), - INDEX idx_created_at (created_at) +CREATE TABLE IF NOT EXISTS `document_vector` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '向量ID', + `project_id` BIGINT NOT NULL COMMENT '项目ID', + `file_path` VARCHAR(500) NOT NULL COMMENT '文件相对路径', + `chunk_index` INT NOT NULL DEFAULT 0 COMMENT '分块序号(0起),同一文件可有多个分块', + `chunk_text` TEXT DEFAULT NULL COMMENT '分块首段文本,作为点击引用时的定位锚点', + `content_hash` VARCHAR(64) DEFAULT NULL COMMENT '整个文件内容哈希值,用于判断文件是否变更', + `zvec_id` VARCHAR(256) DEFAULT NULL COMMENT 'ZVec返回的向量ID(每个分块独立)', + `zvec_response` TEXT DEFAULT NULL COMMENT 'ZVec完整响应JSON', + `status` VARCHAR(32) NOT NULL DEFAULT 'success' COMMENT '向量化状态:success/failed/pending', + `error_message` VARCHAR(500) DEFAULT NULL COMMENT '错误信息', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX `idx_project_file` (`project_id`, `file_path`), + INDEX `idx_project_file_chunk` (`project_id`, `file_path`, `chunk_index`), + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文档向量表'; diff --git a/backend/scripts/create_project_vectorization_task_table.sql b/backend/scripts/create_project_vectorization_task_table.sql new file mode 100644 index 0000000..4ed1380 --- /dev/null +++ b/backend/scripts/create_project_vectorization_task_table.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS `project_vectorization_task` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '任务ID', + `task_id` VARCHAR(64) NOT NULL COMMENT '任务唯一标识', + `project_id` BIGINT NOT NULL COMMENT '项目ID', + `user_id` BIGINT NOT NULL COMMENT '触发用户ID', + `task_type` VARCHAR(32) NOT NULL COMMENT '任务类型:incremental/full', + `status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态:pending/running/success/failed', + `total` INT NOT NULL DEFAULT 0 COMMENT '文件总数', + `processed` INT NOT NULL DEFAULT 0 COMMENT '处理成功数', + `skipped` INT NOT NULL DEFAULT 0 COMMENT '跳过数', + `failed` INT NOT NULL DEFAULT 0 COMMENT '失败数', + `error_message` TEXT DEFAULT NULL COMMENT '错误信息', + `started_at` DATETIME DEFAULT NULL COMMENT '开始时间', + `finished_at` DATETIME DEFAULT NULL COMMENT '完成时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_vector_task_id` (`task_id`), + INDEX `idx_vector_task_project_status` (`project_id`, `status`), + INDEX `idx_vector_task_user_id` (`user_id`), + INDEX `idx_vector_task_created_at` (`created_at`), + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目向量化任务表'; diff --git a/backend/scripts/init_database.sql b/backend/scripts/init_database.sql index 0c2d770..15a0deb 100644 --- a/backend/scripts/init_database.sql +++ b/backend/scripts/init_database.sql @@ -202,6 +202,108 @@ CREATE TABLE IF NOT EXISTS `share_links` ( FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享链接表'; +-- 12. LLM 模型配置表 +CREATE TABLE IF NOT EXISTS `llm_model_config` ( + `config_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID', + `model_code` VARCHAR(128) NOT NULL COMMENT '模型编码', + `model_name` VARCHAR(255) NOT NULL COMMENT '模型名称', + `model_type` VARCHAR(32) NOT NULL DEFAULT 'chat' COMMENT '模型类型: chat/embedding', + `provider` VARCHAR(64) DEFAULT NULL COMMENT '模型提供方', + `endpoint_url` VARCHAR(512) DEFAULT NULL COMMENT '接口地址', + `api_key` VARCHAR(512) DEFAULT NULL COMMENT 'API Key', + `llm_model_name` VARCHAR(128) NOT NULL COMMENT '模型名称/部署名', + `llm_timeout` INT NOT NULL DEFAULT 120 COMMENT '超时时间(秒)', + `llm_temperature` DECIMAL(5,2) NOT NULL DEFAULT 0.70 COMMENT '温度', + `llm_top_p` DECIMAL(5,2) NOT NULL DEFAULT 0.90 COMMENT 'Top P', + `llm_max_tokens` INT NOT NULL DEFAULT 2048 COMMENT '最大输出 Token', + `llm_system_prompt` TEXT DEFAULT NULL COMMENT '系统提示词', + `embedding_dimension` INT DEFAULT NULL COMMENT '向量维度(仅 embedding 类型)', + `description` VARCHAR(500) DEFAULT NULL COMMENT '描述', + `is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + `is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_model_code` (`model_code`), + INDEX `idx_model_code` (`model_code`), + INDEX `idx_model_type` (`model_type`), + INDEX `idx_is_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='LLM 模型配置表'; + +-- 13. 文档向量表 +CREATE TABLE IF NOT EXISTS `document_vector` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '向量ID', + `project_id` BIGINT NOT NULL COMMENT '项目ID', + `file_path` VARCHAR(500) NOT NULL COMMENT '文件相对路径', + `content_hash` VARCHAR(64) DEFAULT NULL COMMENT '内容哈希值,用于判断文件是否变更', + `zvec_id` VARCHAR(256) DEFAULT NULL COMMENT 'ZVec返回的向量ID', + `zvec_response` TEXT DEFAULT NULL COMMENT 'ZVec完整响应JSON', + `status` VARCHAR(32) NOT NULL DEFAULT 'success' COMMENT '向量化状态:success/failed/pending', + `error_message` VARCHAR(500) DEFAULT NULL COMMENT '错误信息', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX `idx_project_file` (`project_id`, `file_path`), + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文档向量表'; + +-- 14. 知识库对话会话表 +CREATE TABLE IF NOT EXISTS `chat_session` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '会话ID', + `project_id` BIGINT NOT NULL COMMENT '项目ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `llm_config_id` BIGINT NOT NULL COMMENT 'LLM配置ID', + `title` VARCHAR(255) NOT NULL COMMENT '会话标题', + `description` TEXT DEFAULT NULL COMMENT '会话描述', + `is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否激活', + `message_count` INT NOT NULL DEFAULT 0 COMMENT '消息数', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX `idx_project_id` (`project_id`), + INDEX `idx_user_id` (`user_id`), + INDEX `idx_project_user` (`project_id`, `user_id`), + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`llm_config_id`) REFERENCES `llm_model_config`(`config_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库对话会话表'; + +-- 15. 知识库对话消息表 +CREATE TABLE IF NOT EXISTS `chat_message` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID', + `session_id` BIGINT NOT NULL COMMENT '会话ID', + `role` VARCHAR(32) NOT NULL COMMENT '角色(user/assistant)', + `content` TEXT NOT NULL COMMENT '消息内容', + `referenced_files` TEXT DEFAULT NULL COMMENT '参考文件(JSON数组)', + `tokens_used` INT DEFAULT NULL COMMENT '消耗的token数', + `is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已删除', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX `idx_session_id` (`session_id`), + FOREIGN KEY (`session_id`) REFERENCES `chat_session`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库对话消息表'; + +-- 16. 项目向量化任务表 +CREATE TABLE IF NOT EXISTS `project_vectorization_task` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '任务ID', + `task_id` VARCHAR(64) NOT NULL COMMENT '任务唯一标识', + `project_id` BIGINT NOT NULL COMMENT '项目ID', + `user_id` BIGINT NOT NULL COMMENT '触发用户ID', + `task_type` VARCHAR(32) NOT NULL COMMENT '任务类型:incremental/full', + `status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态:pending/running/success/failed', + `total` INT NOT NULL DEFAULT 0 COMMENT '文件总数', + `processed` INT NOT NULL DEFAULT 0 COMMENT '处理成功数', + `skipped` INT NOT NULL DEFAULT 0 COMMENT '跳过数', + `failed` INT NOT NULL DEFAULT 0 COMMENT '失败数', + `error_message` TEXT DEFAULT NULL COMMENT '错误信息', + `started_at` DATETIME DEFAULT NULL COMMENT '开始时间', + `finished_at` DATETIME DEFAULT NULL COMMENT '完成时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_vector_task_id` (`task_id`), + INDEX `idx_vector_task_project_status` (`project_id`, `status`), + INDEX `idx_vector_task_user_id` (`user_id`), + INDEX `idx_vector_task_created_at` (`created_at`), + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目向量化任务表'; + -- 插入初始角色数据 INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES ('超级管理员', 'super_admin', '拥有系统所有权限', 1), @@ -216,7 +318,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', 3, '/knowledges', NULL, 1, 'knowledge:view'), +(11, 10, '我的知识库', 'knowledge:view', 2, '/chat', NULL, 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/migrate_document_vector_add_chunks.sql b/backend/scripts/migrate_document_vector_add_chunks.sql new file mode 100644 index 0000000..254b391 --- /dev/null +++ b/backend/scripts/migrate_document_vector_add_chunks.sql @@ -0,0 +1,19 @@ +-- 迁移脚本:为 document_vector 表增加分块(chunk)支持 +-- 背景:RAG 由「整篇文档单向量」升级为「按分块向量化」, +-- 一个文件可对应多条 chunk 记录。 +-- +-- 用法(已有数据库升级): +-- mysql -u -p < migrate_document_vector_add_chunks.sql +-- +-- 注意:升级后建议对所有项目执行一次「全量重建」向量化, +-- 以将旧的整篇文档向量替换为分块向量(旧记录 chunk_index 默认为 0)。 + +ALTER TABLE `document_vector` + ADD COLUMN `chunk_index` INT NOT NULL DEFAULT 0 + COMMENT '分块序号(0起),同一文件可有多个分块' AFTER `file_path`, + ADD COLUMN `chunk_text` TEXT DEFAULT NULL + COMMENT '分块首段文本,作为点击引用时的定位锚点' AFTER `chunk_index`; + +-- 新增按 (project_id, file_path, chunk_index) 的复合索引,加速按分块查询/删除 +ALTER TABLE `document_vector` + ADD INDEX `idx_project_file_chunk` (`project_id`, `file_path`, `chunk_index`); diff --git a/backend/tests/test_chat_citations.py b/backend/tests/test_chat_citations.py new file mode 100644 index 0000000..acde182 --- /dev/null +++ b/backend/tests/test_chat_citations.py @@ -0,0 +1,65 @@ +import unittest + +from app.api.v1.chat import _canonicalize_message_citations, _compact_cited_refs +from app.services.rag_service import RAGService + + +class ChatCitationTest(unittest.TestCase): + def test_duplicate_file_citations_are_renumbered_once(self): + content, refs = _canonicalize_message_citations( + "泰坦属于土星的卫星[2][4][5]。", + [ + {"citation_id": 2, "file_path": "内容导航.md", "excerpt": "片段一"}, + {"citation_id": 4, "file_path": "内容导航.md", "excerpt": "片段二"}, + {"citation_id": 5, "file_path": "内容导航.md", "excerpt": "片段三"}, + ], + ) + + self.assertEqual(content, "泰坦属于土星的卫星[1]。") + self.assertEqual(len(refs), 1) + self.assertEqual(refs[0]["citation_id"], 1) + self.assertEqual(refs[0]["excerpt"], "片段一\n\n片段二\n\n片段三") + + def test_overlapping_blocks_are_not_duplicated(self): + self.assertEqual( + RAGService._merge_text_blocks("泰坦属于土星", "泰坦属于土星"), + "泰坦属于土星", + ) + + def test_single_used_reference_is_compacted_to_one(self): + content, refs = _compact_cited_refs( + "泰坦是土星的卫星[3],土卫二也属于土星[3]。", + [ + {"citation_id": 1, "file_path": "其他一.md"}, + {"citation_id": 2, "file_path": "其他二.md"}, + {"citation_id": 3, "file_path": "内容导航.md"}, + ], + ) + + self.assertEqual(content, "泰坦是土星的卫星[1],土卫二也属于土星[1]。") + self.assertEqual(refs, [{ + "citation_id": 1, + "file_path": "内容导航.md", + "anchor_text": "", + "excerpt": "", + }]) + + def test_used_references_follow_first_appearance_order(self): + content, refs = _compact_cited_refs( + "先使用第三份[3],再使用第一份[1]。", + [ + {"citation_id": 1, "file_path": "第一份.md"}, + {"citation_id": 2, "file_path": "第二份.md"}, + {"citation_id": 3, "file_path": "第三份.md"}, + ], + ) + + self.assertEqual(content, "先使用第三份[1],再使用第一份[2]。") + self.assertEqual( + [(ref["citation_id"], ref["file_path"]) for ref in refs], + [(1, "第三份.md"), (2, "第一份.md")], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4867e33..ddae2b7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -86,6 +86,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/api/chat.js b/frontend/src/api/chat.js index 27b84e2..621210e 100644 --- a/frontend/src/api/chat.js +++ b/frontend/src/api/chat.js @@ -1,33 +1,119 @@ import request from '@/utils/request' +import { getAccessToken } from '@/utils/authStorage' -export const createChatSession = (projectId, llmConfigId) => { +export const createChatSession = (projectId, llmConfigId, title = '新对话') => { return request.post('/chat/sessions', { project_id: projectId, llm_config_id: llmConfigId, + title, }) } -export const getChatSessions = (projectId) => { - return request.get(`/chat/sessions?project_id=${projectId}`) +export const getChatSessions = (projectId = null) => { + return request.get('/chat/sessions', { + params: projectId ? { project_id: projectId } : undefined, + }) } -export const getChatMessages = (sessionId, limit = 50, offset = 0) => { - return request.get(`/chat/messages?session_id=${sessionId}&limit=${limit}&offset=${offset}`) +export const getChatMessages = (sessionId) => { + return request.get(`/chat/sessions/${sessionId}/messages`) } -export const sendChatMessage = (sessionId, userMessage) => { - return request.post('/chat/messages', { +export const sendChatMessage = (sessionId, message) => { + return request.post('/chat/send', { session_id: sessionId, - user_message: userMessage, + message, + }, { + timeout: 180000, }) } +export const sendChatMessageStream = async (sessionId, message, handlers = {}) => { + const token = getAccessToken() + const response = await fetch('/api/v1/chat/send/stream', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + session_id: sessionId, + message, + }), + }) + + if (!response.ok || !response.body) { + let detail = '发送消息失败' + try { + const payload = await response.json() + detail = payload?.detail || payload?.message || detail + } catch { + // 响应不是 JSON 时保留通用错误信息 + } + throw new Error(detail) + } + + const reader = response.body.getReader() + const decoder = new TextDecoder('utf-8') + let buffer = '' + let completed = false + + const dispatchEvent = (block) => { + const lines = block.split('\n') + const eventLine = lines.find((line) => line.startsWith('event:')) + const dataLine = lines.find((line) => line.startsWith('data:')) + if (!eventLine || !dataLine) return + const event = eventLine.slice(6).trim() + const dataText = dataLine.slice(5).trim() + const data = dataText ? JSON.parse(dataText) : null + + if (event === 'ids') handlers.onIds?.(data) + if (event === 'references') handlers.onReferences?.(data || []) + if (event === 'chunk') handlers.onChunk?.(data?.content || '') + if (event === 'title') handlers.onTitle?.(data) + if (event === 'done') { + completed = true + handlers.onDone?.(data) + } + if (event === 'error') { + throw new Error(data?.detail || '发送消息失败') + } + } + + while (true) { + const { value, done } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const blocks = buffer.split('\n\n') + buffer = blocks.pop() || '' + for (const block of blocks) { + dispatchEvent(block) + } + } + if (buffer.trim()) { + dispatchEvent(buffer) + } + if (!completed) { + throw new Error('回答生成连接意外中断,请稍后重试') + } +} + export const deleteChatSession = (sessionId) => { return request.delete(`/chat/sessions/${sessionId}`) } +export const deleteChatMessage = (messageId) => { + return request.delete(`/chat/messages/${messageId}`) +} + export const updateChatSessionTitle = (sessionId, title) => { return request.put(`/chat/sessions/${sessionId}`, { title, }) } + +export const searchChatMessages = (keyword) => { + return request.get('/chat/search', { + params: { keyword }, + }) +} diff --git a/frontend/src/api/knowledgeBase.js b/frontend/src/api/knowledgeBase.js new file mode 100644 index 0000000..bb4c696 --- /dev/null +++ b/frontend/src/api/knowledgeBase.js @@ -0,0 +1,21 @@ +import request from '@/utils/request' + +// 查询项目向量化进度 +export const getVectorizeProgress = (projectId) => { + return request.get(`/chat/projects/${projectId}/vectorize/progress`) +} + +// 触发项目批量向量化(force=false 增量;force=true 全量重建) +export const vectorizeProject = (projectId, force = false) => { + return request.post(`/chat/projects/${projectId}/vectorize`, { force }) +} + +// 查询最近一次向量化任务 +export const getLatestVectorizeTask = (projectId) => { + return request.get(`/chat/projects/${projectId}/vectorize/tasks/latest`) +} + +// 查询指定向量化任务 +export const getVectorizeTask = (projectId, taskId) => { + return request.get(`/chat/projects/${projectId}/vectorize/tasks/${taskId}`) +} diff --git a/frontend/src/api/llmModelConfigs.js b/frontend/src/api/llmModelConfigs.js index 6149849..866b809 100644 --- a/frontend/src/api/llmModelConfigs.js +++ b/frontend/src/api/llmModelConfigs.js @@ -61,9 +61,11 @@ export function deleteLLMModelConfig(configId) { } export function testLLMModelConfig(data) { + const timeoutSeconds = Number(data?.llm_timeout) || 30 return request({ url: '/llm-model-configs/test', method: 'post', data, + timeout: Math.max(timeoutSeconds * 1000 + 5000, 30000), }) } diff --git a/frontend/src/components/MainLayout/AppSider.jsx b/frontend/src/components/MainLayout/AppSider.jsx index 322ec81..0ed13a2 100644 --- a/frontend/src/components/MainLayout/AppSider.jsx +++ b/frontend/src/components/MainLayout/AppSider.jsx @@ -62,7 +62,7 @@ const builtInMenuMetaMap = { icon: 'TeamOutlined', }, 'knowledge:my': { - path: '/knowledge', + path: '/chat', icon: 'ReadOutlined', }, 'system:users': { @@ -128,7 +128,7 @@ const resolveBuiltInMenuMeta = (item) => { if (item.path === '/knowledge' || item.path === '/knowledge/my' || item.menu_name === '我的知识库') { return { - path: '/knowledge', + path: '/chat', icon: 'ReadOutlined', } } diff --git a/frontend/src/components/MainLayout/MainLayout.jsx b/frontend/src/components/MainLayout/MainLayout.jsx index 2603908..1d26601 100644 --- a/frontend/src/components/MainLayout/MainLayout.jsx +++ b/frontend/src/components/MainLayout/MainLayout.jsx @@ -1,5 +1,4 @@ -import { useState, useEffect } from 'react' -import { useLocation } from 'react-router-dom' +import { useState } from 'react' import { Layout } from 'antd' import AppSider from './AppSider' import AppHeader from './AppHeader' @@ -7,18 +6,8 @@ import './MainLayout.css' const { Content } = Layout -// 进入项目文档页/编辑页时自动折叠全局侧边栏,给文档内容腾出空间 -const COLLAPSE_PATTERNS = [/^\/projects\/[^/]+\/docs/, /^\/projects\/[^/]+\/editor/] - function MainLayout({ children }) { - const location = useLocation() - const [collapsed, setCollapsed] = useState(false) - - useEffect(() => { - if (COLLAPSE_PATTERNS.some((pattern) => pattern.test(location.pathname))) { - setCollapsed(true) - } - }, [location.pathname]) + const [collapsed, setCollapsed] = useState(true) const toggleCollapsed = () => { setCollapsed(!collapsed) diff --git a/frontend/src/index.css b/frontend/src/index.css index 4aab631..c7b19d5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -65,6 +65,22 @@ body { border-radius: 2px; } +/* 从知识库引用跳转定位到的段落,短暂强调后淡出 */ +.search-highlight.cited-highlight-flash { + animation: cited-flash 2.4s ease-out; +} + +@keyframes cited-flash { + 0%, 30% { + background-color: #ff9800 !important; + box-shadow: 0 0 0 4px rgba(255, 152, 0, 0.35); + } + 100% { + background-color: #ffd54f !important; + box-shadow: 0 0 0 0 rgba(255, 152, 0, 0); + } +} + /* Dark mode overrides for highlight.js */ body.dark .hljs { display: block; diff --git a/frontend/src/pages/Chat/Chat.css b/frontend/src/pages/Chat/Chat.css index 37a3c67..f1b3a99 100644 --- a/frontend/src/pages/Chat/Chat.css +++ b/frontend/src/pages/Chat/Chat.css @@ -1,109 +1,894 @@ -/* Chat 页面样式 */ - -.chat-container { - display: flex; - height: 100vh; +.chat-page-shell { + height: calc(100vh - 96px); + min-height: 0; + display: grid; + grid-template-columns: 320px minmax(0, 1fr); + gap: 12px; } -.chat-sidebar { - width: 280px; - border-right: 1px solid #e8e8e8; - overflow-y: auto; - background: #fafafa; - padding: 16px; +.chat-left-panel, +.chat-main-panel { + min-height: 0; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--card-bg); + overflow: hidden; } -.chat-header { - padding: 16px; - border-bottom: 1px solid #e8e8e8; +.chat-left-panel { + display: flex; + flex-direction: column; +} + +.chat-left-actions { + padding: 14px 16px 12px; + display: grid; + gap: 10px; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.chat-action-entry { + width: 100%; + border: 0; + background: transparent; + padding: 10px 6px; display: flex; - justify-content: space-between; align-items: center; + gap: 10px; + font-size: 16px; + line-height: 1; + cursor: pointer; + color: var(--text-color); } -.chat-header-title { - font-size: 16px; +.chat-action-entry:hover { + color: #1677ff; +} + +.chat-action-entry span { + font-size: 15px; font-weight: 600; } -.chat-content { +.chat-history-panel { flex: 1; + min-height: 0; + overflow: auto; + padding: 10px 10px 12px; +} + +.chat-panel-loading, +.chat-empty-message { + min-height: 180px; display: flex; - flex-direction: column; - background: #fff; + align-items: center; + justify-content: center; } -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 16px; - display: flex; - flex-direction: column; +.chat-session-item { + padding: 9px 10px; + margin-bottom: 4px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-color-secondary); + cursor: pointer; + transition: all 0.2s ease; } -.chat-message { - margin-bottom: 12px; - display: flex; - justify-content: flex-start; +.chat-session-item:hover { + border-color: #1677ff; } -.chat-message.user { - justify-content: flex-end; +.chat-session-item.active { + border-color: #1677ff; + background: color-mix(in srgb, #1677ff 10%, var(--bg-color-secondary)); } -.chat-message-bubble { - max-width: 70%; - padding: 8px 12px; - border-radius: 6px; - word-break: break-word; - white-space: pre-wrap; -} - -.chat-message-bubble.user { - background: #1890ff; - color: #fff; -} - -.chat-message-bubble.assistant { - background: #f0f0f0; - color: #000; -} - -.chat-input-area { - padding: 16px; - border-top: 1px solid #e8e8e8; +.chat-session-item-top { display: flex; gap: 8px; } -.chat-session-item { - padding: 8px; - margin-bottom: 8px; - border-radius: 4px; - cursor: pointer; - border: 1px solid #d9d9d9; - transition: all 0.3s; -} - -.chat-session-item:hover { - background: #f5f5f5; -} - -.chat-session-item.active { - background: #e6f7ff; - border: 1px solid #1890ff; +.chat-session-title-area { + flex: 1; + min-width: 0; } .chat-session-title { + flex: 1; + min-width: 0; font-size: 13px; - font-weight: 500; + line-height: 1.45; + font-weight: 600; + color: var(--text-color); + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; +} + +.chat-session-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +} + +.chat-session-more { + width: 24px; + height: 24px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-color-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + flex-shrink: 0; +} + +.chat-session-item:hover .chat-session-more, +.chat-session-item.active .chat-session-more { + opacity: 1; +} + +.chat-session-more:hover { + background: color-mix(in srgb, var(--border-color) 45%, transparent); + color: var(--text-color); +} + +.chat-session-meta { + margin-top: 5px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.chat-session-meta-text { + margin: 0; + font-size: 11px; + line-height: 18px; +} + +.chat-session-group { + margin-bottom: 14px; +} + +.chat-session-group-title { + padding: 4px 4px 8px; + font-size: 12px; + font-weight: 600; + color: var(--text-color-secondary); +} + +.chat-main-panel { + min-width: 0; +} + +.chat-shell, +.chat-start-shell { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; +} + +.chat-start-shell { + align-items: center; + justify-content: center; + padding: 24px; +} + +.chat-start-card { + width: min(740px, 100%); + padding: 30px 28px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-color-secondary); + display: flex; + flex-direction: column; + gap: 14px; + align-items: center; + text-align: center; +} + +.chat-start-title { + font-size: 32px; + font-weight: 600; + color: var(--text-color); +} + +.chat-start-desc { + color: var(--text-color-secondary); + line-height: 1.6; +} + +.chat-header { + padding: 16px 20px 14px; + border-bottom: 1px solid var(--border-color); + background: var(--card-bg); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.chat-header-title { + font-size: 18px; + font-weight: 600; +} + +.chat-header-subtitle { + margin-top: 4px; + display: flex; + flex-wrap: wrap; + gap: 12px; + color: var(--text-color-secondary); + font-size: 12px; +} + +.chat-message-list { + flex: 1; + min-height: 0; + overflow: auto; + padding: 18px 20px 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.chat-message-row { + display: flex; + align-items: flex-end; + gap: 10px; +} + +.chat-message-row.user { + 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); + color: var(--text-color); + word-break: break-word; +} + +.chat-message-bubble.user { + background: #1677ff; + color: #fff; + border-color: #1677ff; +} + +.chat-markdown { + font-size: 14px; + line-height: 1.75; +} + +.chat-markdown > :first-child { + margin-top: 0; +} + +.chat-markdown > :last-child { + margin-bottom: 0; +} + +.chat-markdown h1, +.chat-markdown h2, +.chat-markdown h3, +.chat-markdown h4 { + margin: 16px 0 8px; + font-weight: 600; + line-height: 1.4; +} + +.chat-markdown h1 { font-size: 1.5em; } +.chat-markdown h2 { font-size: 1.3em; } +.chat-markdown h3 { font-size: 1.15em; } +.chat-markdown h4 { font-size: 1em; } + +.chat-markdown p { + margin: 8px 0; +} + +.chat-markdown ul, +.chat-markdown ol { + margin: 8px 0; + padding-left: 24px; +} + +.chat-markdown li { + margin: 4px 0; +} + +.chat-markdown li > p { + margin: 0; +} + +.chat-markdown a { + color: #1677ff; + text-decoration: none; +} + +.chat-markdown a:hover { + text-decoration: underline; +} + +.chat-markdown blockquote { + margin: 10px 0; + padding: 4px 14px; + border-left: 3px solid var(--border-color); + color: var(--text-color-secondary); + background: color-mix(in srgb, var(--border-color) 16%, transparent); + border-radius: 0 6px 6px 0; +} + +.chat-markdown code { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 0.88em; + padding: 2px 6px; + border-radius: 4px; + background: color-mix(in srgb, var(--border-color) 32%, transparent); +} + +.chat-markdown pre { + margin: 12px 0; + padding: 14px 16px; + border-radius: 8px; + overflow: auto; + background: #f6f8fa; + border: 1px solid var(--border-color); +} + +.chat-markdown pre code { + padding: 0; + background: transparent; + font-size: 0.85em; + line-height: 1.6; +} + +.chat-markdown table { + width: 100%; + margin: 12px 0; + border-collapse: collapse; + font-size: 0.92em; +} + +.chat-markdown th, +.chat-markdown td { + padding: 8px 12px; + border: 1px solid var(--border-color); + text-align: left; +} + +.chat-markdown th { + background: color-mix(in srgb, var(--border-color) 22%, transparent); + font-weight: 600; +} + +.chat-markdown tr:nth-child(even) td { + background: color-mix(in srgb, var(--border-color) 10%, transparent); +} + +.chat-markdown img { + max-width: 100%; + border-radius: 6px; +} + +.chat-markdown hr { + margin: 16px 0; + border: 0; + border-top: 1px solid var(--border-color); +} + +.chat-citation { + font-size: 0.72em; + line-height: 0; + vertical-align: super; + color: #1677ff; + font-weight: 600; + margin: 0 1px; + cursor: pointer; +} + +.chat-citation:hover { + text-decoration: underline; +} + +.chat-markdown.streaming > :last-child::after { + content: ''; + display: inline-block; + width: 7px; + height: 1.05em; + margin-left: 2px; + vertical-align: text-bottom; + background: #1677ff; + border-radius: 1px; + animation: chat-caret-blink 1s steps(2, start) infinite; +} + +@keyframes chat-caret-blink { + to { + visibility: hidden; + } +} + +.chat-thinking { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text-color-secondary); + font-size: 14px; + line-height: 1.75; +} + +.chat-thinking-text { + animation: chat-thinking-pulse 1.6s ease-in-out infinite; +} + +@keyframes chat-thinking-pulse { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 1; } +} + +.chat-thinking-dots { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.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-dots i:nth-child(2) { + animation-delay: 0.18s; +} + +.chat-thinking-dots i:nth-child(3) { + animation-delay: 0.36s; +} + +@keyframes chat-thinking-bounce { + 0%, 80%, 100% { + transform: translateY(0); + opacity: 0.4; + } + 40% { + transform: translateY(-4px); + opacity: 1; + } +} + +.chat-message-column { + display: flex; + flex-direction: column; + gap: 4px; + max-width: min(760px, 78%); + min-width: 0; +} + +.chat-message-row.user .chat-message-column { + align-items: flex-end; +} + +.chat-message-actions { + display: flex; + align-items: center; + gap: 4px; + height: 24px; + opacity: 0; + transition: opacity 0.15s ease; +} + +.chat-message-row:hover .chat-message-actions { + opacity: 1; +} + +.chat-message-action { + width: 26px; + height: 24px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-color-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 14px; + transition: all 0.15s ease; +} + +.chat-message-action:hover { + background: color-mix(in srgb, var(--border-color) 45%, transparent); + color: var(--text-color); +} + +.chat-message-action.danger:hover { + background: color-mix(in srgb, #ff4d4f 14%, transparent); + color: #ff4d4f; +} + +.chat-plain-text { + white-space: pre-wrap; + line-height: 1.7; +} + +.chat-references { + margin-top: 12px; + padding-top: 12px; + border-top: 1px dashed var(--border-color); +} + +.chat-references-label { + display: block; + margin-bottom: 8px; + font-size: 12px; +} + +.chat-reference-link { + cursor: pointer; +} + +.chat-reference-link:hover { + opacity: 0.82; +} + +.chat-message-error { + color: #cf1322; + line-height: 1.7; +} + +.chat-message-error-inline { + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed color-mix(in srgb, #ff4d4f 35%, transparent); + font-size: 13px; +} + +.chat-citation-preview { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 460px; +} + +.chat-citation-preview-file { + display: block; + word-break: break-all; +} + +.chat-citation-preview-content { + max-height: 260px; + overflow: auto; + word-break: break-word; + line-height: 1.65; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: color-mix(in srgb, var(--border-color) 10%, transparent); +} + +.chat-citation-preview-content > :first-child { + margin-top: 0; +} + +.chat-citation-preview-content > :last-child { + margin-bottom: 0; +} + +.chat-citation-preview-content pre { + overflow: auto; + padding: 8px; + border-radius: 6px; + background: var(--code-bg); +} + +.chat-citation-preview-content code { + font-size: 0.92em; +} + +.chat-citation-popover .ant-popover-inner { + max-width: min(520px, calc(100vw - 40px)); +} + +.chat-new-page-card { + width: min(1180px, 92%); + display: flex; + flex-direction: column; + gap: 28px; + align-items: stretch; +} + +.chat-new-page-card .chat-start-title { + font-size: 34px; + text-align: center; +} + +.chat-composer-shell { + padding: 12px 12px 8px; + border: 1px solid #9bbcff; + border-radius: 8px; + background: var(--card-bg); + box-shadow: 0 18px 40px rgba(64, 111, 255, 0.08); + display: flex; + flex-direction: column; + gap: 24px; +} + +.chat-shell .chat-composer-shell { + margin: 0 24px 24px; +} + +.chat-new-composer { + min-height: 150px; + padding-top: 10px; + padding-bottom: 10px; +} + +.chat-composer-input { + border: 0; + box-shadow: none !important; + resize: none; + padding: 0; + font-size: 16px; + line-height: 1.7; + background: transparent; + flex: none; +} + +.chat-composer-input:focus { + border: 0; + box-shadow: none; +} + +.chat-composer-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-width: 0; +} + +.chat-composer-left, +.chat-composer-right { + display: flex; + align-items: center; + gap: 16px; + min-width: 0; + flex-wrap: nowrap; +} + +.chat-composer-left { + flex: 1; +} + +.chat-composer-right { + flex-shrink: 0; +} + +.chat-composer-plus { + width: 44px; + height: 44px; + min-width: 44px; + border: 1px solid #f0f0f0; + border-radius: 50%; + background: #fff; + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08); + color: var(--text-color-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 24px; + cursor: pointer; + flex-shrink: 0; +} + +.chat-composer-plus:hover { + color: var(--text-color); + border-color: #e6e6e6; +} + +.chat-composer-pill { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px 18px; + border: 1px solid #f0f0f0; + border-radius: 999px; + background: #fff; + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06); + color: var(--text-color); + line-height: 1.2; + white-space: nowrap; +} + +.chat-composer-pill-icon { + color: var(--text-color-secondary); + flex-shrink: 0; +} + +.chat-composer-pill-text { overflow: hidden; text-overflow: ellipsis; } -.chat-session-date { - font-size: 11px; - color: #999; - margin-top: 4px; +.chat-composer-kb { + max-width: 280px; +} + +.chat-project-picker { + width: 260px; +} + +.chat-project-picker-select { + width: 100%; +} + +.chat-composer-model, +.chat-composer-model-select { + width: 220px; + flex-shrink: 0; +} + +.chat-composer-model { + max-width: 220px; +} + +.chat-composer-model-select .ant-select-selector { + border-radius: 999px !important; + border-color: #f0f0f0 !important; + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06) !important; +} + +.chat-composer-model-select .ant-select-selection-placeholder, +.chat-composer-model-select .ant-select-selection-item { + display: inline-flex; + align-items: center; +} + +.chat-send-button { + width: 40px !important; + height: 40px !important; + min-width: 40px !important; + border: 1px solid #bebebe !important; + background: #d1d1d1 !important; + color: #fff !important; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.28); + font-size: 24px !important; + line-height: 1 !important; + transform: scale(1.08); + transform-origin: center; +} + +.chat-send-button .anticon { + font-size: 22px; +} + +.chat-send-button:not(:disabled):hover { + background: #4096ff !important; + border-color: #4096ff !important; + color: #fff !important; +} + +.chat-send-button:not(:disabled) { + background: #1677ff !important; + border-color: #1677ff !important; + color: #fff !important; +} + +.chat-send-button:disabled { + background: #d8d8d8 !important; + border-color: #c8c8c8 !important; + color: #fff !important; + opacity: 1; +} + +.chat-search-bar { + width: 100%; +} + +.chat-search-result-list { + margin-top: 16px; + max-height: 60vh; + overflow: auto; +} + +.chat-search-result-item { + cursor: pointer; +} + +.chat-search-result-snippet { + color: var(--text-color); + line-height: 1.7; +} + +.chat-search-highlight { + padding: 0 2px; + border-radius: 3px; + background: #ffe58f; + color: #ad4e00; +} + +body.dark .chat-search-highlight { + background: #614700; + color: #ffe58f; +} + +.chat-search-result-time { + margin-top: 4px; + font-size: 12px; + color: var(--text-color-secondary); +} + +@media (max-width: 1200px) { + .chat-page-shell { + grid-template-columns: 300px minmax(0, 1fr); + } +} + +@media (max-width: 992px) { + .chat-page-shell { + grid-template-columns: 1fr; + height: auto; + } + + .chat-left-panel { + max-height: 360px; + } + + .chat-composer-actions { + align-items: stretch; + flex-direction: column; + } + + .chat-composer-left, + .chat-composer-right { + width: 100%; + justify-content: space-between; + } + + .chat-project-picker-select, + .chat-composer-model-select { + flex: 1; + min-width: 0; + } + + .chat-send-button { + width: 48px !important; + height: 48px !important; + min-width: 48px !important; + font-size: 24px !important; + transform: scale(1.08); + } } diff --git a/frontend/src/pages/Chat/Chat.jsx b/frontend/src/pages/Chat/Chat.jsx index d539c38..298e77f 100644 --- a/frontend/src/pages/Chat/Chat.jsx +++ b/frontend/src/pages/Chat/Chat.jsx @@ -1,385 +1,1124 @@ -import { useState, useEffect, useRef } from 'react' -import { Layout, Select, Button, Input, Empty, Card, Space, Spin, Message, Divider, Tag, Modal, Form } from 'antd' -import { SendOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' +import { + Avatar, + Button, + Dropdown, + Empty, + Input, + List, + Modal, + Popover, + Select, + Space, + Spin, + Tag, + Typography, + message, +} from 'antd' +import { + FolderOutlined, + EditOutlined, + DeleteOutlined, + MoreOutlined, + PlusOutlined, + RobotOutlined, + SearchOutlined, + ArrowUpOutlined, + UserOutlined, + DownOutlined, + CopyOutlined, + CheckOutlined, +} 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 'highlight.js/styles/github.css' +import { createChatSession, deleteChatMessage, deleteChatSession, getChatMessages, getChatSessions, searchChatMessages, sendChatMessageStream, updateChatSessionTitle } from '@/api/chat' import { getMyProjects } from '@/api/project' import { getLLMModelConfigs } from '@/api/llmModelConfigs' -import { - createChatSession, - getChatSessions, - getChatMessages, - sendChatMessage, - deleteChatSession, - updateChatSessionTitle, -} from '@/api/chat' import './Chat.css' -const { Sider, Content } = Layout +const { TextArea } = Input +const { Text } = Typography + +const CITATION_RE = /\[(\d+)\]/g + +// rehype 插件:将正文中的 [n] 引用编号转换为上角标 ,便于与正文区分。 +// 跳过 code/pre 节点,避免破坏代码块中的方括号内容。 +function rehypeCitationSup() { + 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)) { + CITATION_RE.lastIndex = 0 + let lastIndex = 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) }) + } + 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) }) + } + } else { + if (child.type === 'element') walk(child, childInCode) + nextChildren.push(child) + } + } + node.children = nextChildren + } + return (tree) => walk(tree, false) +} + +function formatTime(value) { + if (!value) return '' + return new Date(value).toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +function getDateKey(value) { + if (!value) return 'unknown' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return 'unknown' + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} + +function formatDateGroup(value) { + if (!value || value === 'unknown') return '未知日期' + const date = new Date(`${value}T00:00:00`) + const today = new Date() + const yesterday = new Date() + yesterday.setDate(today.getDate() - 1) + const todayKey = getDateKey(today) + const yesterdayKey = getDateKey(yesterday) + + if (value === todayKey) return '今天' + if (value === yesterdayKey) return '昨天' + if (date.getFullYear() === today.getFullYear()) { + return `${date.getMonth() + 1}月${date.getDate()}日` + } + return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日` +} + +function stripMarkdown(text) { + return (text || '') + .replace(/```[\s\S]*?```/g, ' ') + .replace(/[#>*_`~\-\[\]()]/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function buildDocumentPreviewUrl(projectId, filePath, anchorText = '') { + if (!projectId || !filePath) return '' + const base = `/projects/${projectId}/docs?file=${encodeURIComponent(filePath)}` + // 携带锚点文本作为 keyword,文档页据此高亮并滚动到被引用的段落 + const anchor = (anchorText || '').trim() + return anchor ? `${base}&keyword=${encodeURIComponent(anchor)}` : base +} + +function openDocument(ref, projectId) { + const anchorText = ref?.anchor_text || getReferenceExcerpt(ref) + const previewUrl = buildDocumentPreviewUrl( + ref?.project_id || projectId, + ref?.file_path, + anchorText + ) + if (previewUrl) window.open(previewUrl, '_blank', 'noopener,noreferrer') +} + +function getReferenceExcerpt(ref) { + return (ref?.excerpt || ref?.anchor_text || '').trim() +} + +function SearchHighlight({ text, keyword }) { + const value = text || '' + if (!keyword) return value + return ( + + ) +} + +async function copyText(text) { + const value = text || '' + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value) + return true + } + } catch { + // 回退到 execCommand + } + try { + const textarea = document.createElement('textarea') + textarea.value = value + textarea.style.position = 'fixed' + textarea.style.opacity = '0' + document.body.appendChild(textarea) + textarea.select() + const ok = document.execCommand('copy') + document.body.removeChild(textarea) + return ok + } catch { + return false + } +} + +function MessageActions({ content, onCopy, onDelete }) { + const [copied, setCopied] = useState(false) + const handleCopy = async () => { + const ok = await copyText(content) + if (ok) { + setCopied(true) + window.setTimeout(() => setCopied(false), 1500) + } else { + message.error('复制失败') + } + onCopy?.(ok) + } + return ( +
+ + {onDelete && ( + + )} +
+ ) +} function Chat() { - const [projects, setProjects] = useState([]) - const [llmConfigs, setLlmConfigs] = useState([]) - const [selectedProject, setSelectedProject] = useState(null) - const [selectedModel, setSelectedModel] = useState(null) + const navigate = useNavigate() + const location = useLocation() + 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 [loading, setLoading] = useState(false) - const [sessionLoading, setSessionLoading] = useState(false) - const [messageLoading, setMessageLoading] = useState(false) + const [loadingSessions, setLoadingSessions] = useState(false) + const [loadingMessages, setLoadingMessages] = useState(false) + const [sending, setSending] = useState(false) const [inputValue, setInputValue] = useState('') - const messagesEndRef = useRef(null) - const [editingSessionId, setEditingSessionId] = useState(null) - const [editingTitle, setEditingTitle] = useState('') + const [searchVisible, setSearchVisible] = useState(false) + const [searchKeyword, setSearchKeyword] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [searchLoading, setSearchLoading] = useState(false) + const [searchedKeyword, setSearchedKeyword] = useState('') + const [hasSearched, setHasSearched] = useState(false) + const [newQuestion, setNewQuestion] = useState('') + const [newProjectId, setNewProjectId] = useState(undefined) + const [newModelId, setNewModelId] = useState(undefined) + const [projectPickerOpen, setProjectPickerOpen] = useState(false) + const [renameVisible, setRenameVisible] = useState(false) + const [renameSession, setRenameSession] = useState(null) + const [renameTitle, setRenameTitle] = useState('') + const messageRefs = useRef(new Map()) + const skipNextOpenSessionRef = useRef(null) + const searchRequestIdRef = useRef(0) + const newMode = location.pathname === '/chat/new' - useEffect(() => { - fetchProjects() - fetchLlmConfigs() - }, []) + const sessionId = useMemo(() => { + const value = searchParams.get('session_id') || searchParams.get('sessionId') + return value ? Number(value) : null + }, [searchParams]) - const fetchProjects = async () => { + const currentProject = useMemo( + () => projects.find((item) => item.id === newProjectId), + [projects, newProjectId] + ) + const currentModel = useMemo( + () => models.find((item) => item.config_id === newModelId), + [models, newModelId] + ) + const canCreateSession = Boolean(newQuestion.trim() && currentProject && currentModel) + const canSendMessage = Boolean(inputValue.trim() && currentSession) + + const groupedSessions = useMemo(() => { + const groups = [] + const groupMap = new Map() + sessions.forEach((item) => { + const key = getDateKey(item.updated_at || item.created_at) + if (!groupMap.has(key)) { + const group = { + key, + label: formatDateGroup(key), + items: [], + } + groupMap.set(key, group) + groups.push(group) + } + groupMap.get(key).items.push(item) + }) + return groups + }, [sessions]) + + const loadProjects = async () => { try { const res = await getMyProjects() - setProjects(res.data?.data || []) + setProjects(res.data || []) } catch (error) { - console.error('Failed to fetch projects:', error) + console.error(error) } } - const fetchLlmConfigs = async () => { + const loadModels = async () => { try { - const res = await getLLMModelConfigs() - setLlmConfigs(res.data?.data || []) - if (res.data?.data?.length > 0) { - setSelectedModel(res.data.data[0].config_id) - } + const res = await getLLMModelConfigs({ page: 1, page_size: 100, model_type: 'chat', is_active: true }) + setModels(res.data || []) } catch (error) { - console.error('Failed to fetch LLM configs:', error) + console.error(error) } } - const fetchSessions = async (projectId) => { - if (!projectId) return - setSessionLoading(true) + const loadSessions = async () => { + setLoadingSessions(true) try { - const res = await getChatSessions(projectId) - setSessions(res.data?.data || []) - if (res.data?.data?.length > 0) { - setCurrentSession(res.data.data[0]) - fetchMessages(res.data.data[0].session_id) - } else { - setMessages([]) + const res = await getChatSessions() + setSessions(res.data || []) + } catch (error) { + message.error('加载对话列表失败') + } finally { + setLoadingSessions(false) + } + } + + const openSession = async (id, extraQuery = {}) => { + if (!id) return + const nextQuery = new URLSearchParams() + nextQuery.set('session_id', String(id)) + if (extraQuery.message_id) { + nextQuery.set('message_id', String(extraQuery.message_id)) + } + navigate({ pathname: '/chat', search: `?${nextQuery.toString()}` }, { replace: true }) + setLoadingMessages(true) + try { + const res = await getChatMessages(id) + setMessages(res.data || []) + setCurrentSession(sessions.find((item) => item.session_id === id) || null) + } catch (error) { + message.error('加载对话失败') + } finally { + setLoadingMessages(false) + } + } + + useEffect(() => { + loadProjects() + loadModels() + loadSessions() + }, []) + + useEffect(() => { + if (newMode) { + setCurrentSession(null) + setMessages([]) + } + }, [newMode]) + + useEffect(() => { + if (!sessionId || newMode) { + if (!newMode) { setCurrentSession(null) + setMessages([]) } - } catch (error) { - console.error('Failed to fetch sessions:', error) - } finally { - setSessionLoading(false) + return } - } - - const fetchMessages = async (sessionId) => { - setMessageLoading(true) - try { - const res = await getChatMessages(sessionId) - setMessages(res.data?.data || []) - setTimeout(scrollToBottom, 100) - } catch (error) { - console.error('Failed to fetch messages:', error) - } finally { - setMessageLoading(false) + if (skipNextOpenSessionRef.current === sessionId) { + skipNextOpenSessionRef.current = null + return } - } + openSession(sessionId) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId, sessions.length, newMode]) - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - } + useEffect(() => { + const target = searchParams.get('message_id') + if (!target) return + const targetMessage = messageRefs.current.get(String(target)) + if (!targetMessage) return - const handleProjectChange = (projectId) => { - setSelectedProject(projectId) - setCurrentSession(null) - setMessages([]) - fetchSessions(projectId) + targetMessage.scrollIntoView({ behavior: 'smooth', block: 'center' }) + const nextSearchParams = new URLSearchParams(searchParams) + nextSearchParams.delete('message_id') + setSearchParams(nextSearchParams, { replace: true }) + }, [messages, searchParams, setSearchParams]) + + const handleStartNew = () => { + setNewQuestion('') + setNewProjectId(undefined) + setNewModelId(undefined) + navigate('/chat/new') } const handleCreateSession = async () => { - if (!selectedProject || !selectedModel) { - Message.warning('请先选择项目和模型') + if (sending) return + const question = newQuestion.trim() + if (!question) { + message.warning('请输入问题') return } - setLoading(true) + if (!newProjectId) { + message.warning('请选择项目') + return + } + if (!newModelId) { + message.warning('请选择模型') + return + } + const title = question.length > 24 ? `${question.slice(0, 24)}...` : question + setSending(true) + let session = null try { - const res = await createChatSession(selectedProject, selectedModel) - const newSession = res.data?.data - setSessions([newSession, ...sessions]) - setCurrentSession(newSession) - setMessages([]) + const res = await createChatSession(newProjectId, newModelId, title) + session = res.data } catch (error) { - Message.error('创建会话失败') - console.error(error) + message.error(error.response?.data?.detail || error.message || '新建对话失败') + setSending(false) + return + } + + const nextSession = { + session_id: session.session_id, + project_id: session.project_id ?? newProjectId, + llm_config_id: session.llm_config_id ?? newModelId, + title: session.title || title, + created_at: session.created_at, + updated_at: session.updated_at || session.created_at, + } + const optimisticMessage = { + id: `tmp-${Date.now()}`, + role: 'user', + content: question, + created_at: new Date().toISOString(), + } + + setSessions((prev) => [ + nextSession, + ...prev.filter((item) => item.session_id !== nextSession.session_id), + ]) + setCurrentSession(nextSession) + setNewQuestion('') + skipNextOpenSessionRef.current = session.session_id + navigate(`/chat?session_id=${session.session_id}`, { replace: true }) + + try { + await sendMessageWithStream(nextSession, question, { + initialMessages: [optimisticMessage], + initialUserMessageId: optimisticMessage.id, + includeUserMessage: false, + }) + await loadSessions() + } catch (error) { + message.error(error.response?.data?.detail || error.message || '对话已创建,但首条消息发送失败') } finally { - setLoading(false) + setSending(false) } } - const handleSendMessage = async () => { - if (!inputValue.trim() || !currentSession) { - return - } - const userMsg = inputValue + const handleSend = async () => { + if (sending) return + const content = inputValue.trim() + if (!content || !currentSession) return + setSending(true) setInputValue('') - setMessageLoading(true) - try { - const res = await sendChatMessage(currentSession.session_id, userMsg) - const { user_message, assistant_message } = res.data?.data || {} - - const newMessages = [ - ...messages, - { - id: messages.length + 1, - role: 'user', - content: user_message, - created_at: new Date().toISOString(), - }, - { - id: messages.length + 2, - role: 'assistant', - content: assistant_message, - created_at: new Date().toISOString(), - }, - ] - setMessages(newMessages) - setTimeout(scrollToBottom, 100) + await sendMessageWithStream(currentSession, content) + await loadSessions() } catch (error) { - Message.error('发送消息失败') - setInputValue(userMsg) - console.error(error) + message.error(error.response?.data?.detail || error.message || '发送消息失败') } finally { - setMessageLoading(false) + setSending(false) } } - const handleDeleteSession = (sessionId) => { + const sendMessageWithStream = async (session, content, options = {}) => { + const userMessage = { + id: `tmp-user-${Date.now()}`, + role: 'user', + content, + created_at: new Date().toISOString(), + } + const assistantMessage = { + id: `tmp-assistant-${Date.now()}`, + role: 'assistant', + content: '', + references: [], + status: 'thinking', + referencesVisible: false, + created_at: new Date().toISOString(), + } + const baseMessages = options.initialMessages ?? messages + const nextMessages = options.includeUserMessage === false + ? [...baseMessages, assistantMessage] + : [...baseMessages, userMessage, assistantMessage] + + setMessages(nextMessages) + + // 流式过程中后端会回传真实消息 id(ids 事件),这里用可变变量持续追踪当前 + // 助手消息的 id:替换为真实 id 后,后续 onChunk/onDone 仍能命中同一条消息。 + let assistantMsgId = assistantMessage.id + const tmpUserId = options.initialUserMessageId ?? userMessage.id + + try { + await sendChatMessageStream(session.session_id, content, { + onIds: (data) => { + const realUserId = data?.user_message_id + const realAssistantId = data?.assistant_message_id + if (realAssistantId != null) assistantMsgId = realAssistantId + setMessages((prev) => prev.map((item) => { + if (realUserId != null && item.id === tmpUserId) { + return { ...item, id: realUserId } + } + if (realAssistantId != null && item.id === assistantMessage.id) { + return { ...item, id: realAssistantId } + } + return item + })) + }, + onReferences: (refs) => { + setMessages((prev) => prev.map((item) => ( + item.id === assistantMsgId ? { ...item, references: refs || [] } : item + ))) + }, + onChunk: (chunk) => { + setMessages((prev) => prev.map((item) => ( + item.id === assistantMsgId ? { ...item, content: `${item.content}${chunk}`, status: 'streaming' } : item + ))) + }, + onTitle: (data) => { + const nextTitle = data?.title + if (!nextTitle) return + const targetId = data.session_id ?? session.session_id + setSessions((prev) => prev.map((item) => ( + item.session_id === targetId ? { ...item, title: nextTitle } : item + ))) + setCurrentSession((prev) => ( + prev && prev.session_id === targetId ? { ...prev, title: nextTitle } : prev + )) + }, + onDone: (data) => { + setMessages((prev) => prev.map((item) => ( + item.id === assistantMsgId + ? { + ...item, + content: data?.content ?? item.content, + status: 'done', + referencesVisible: true, + } + : item + ))) + }, + }) + } catch (error) { + setMessages((prev) => prev.map((item) => ( + item.id === assistantMsgId + ? { + ...item, + status: 'error', + error: error.message || '回答生成失败,请稍后重试', + referencesVisible: true, + } + : item + ))) + throw error + } + } + + const handleDeleteMessage = (item) => { + const isTemp = typeof item.id === 'string' && item.id.startsWith('tmp-') + if (isTemp) { + setMessages((prev) => prev.filter((m) => m.id !== item.id)) + return + } Modal.confirm({ - title: '确认删除', - content: '确定要删除这个对话会话吗?', + title: '删除消息', + content: '确定删除这条消息吗?删除后无法恢复。', + okText: '删除', + okType: 'danger', + cancelText: '取消', onOk: async () => { try { - await deleteChatSession(sessionId) - setSessions(sessions.filter(s => s.session_id !== sessionId)) - if (currentSession?.session_id === sessionId) { - setCurrentSession(null) - setMessages([]) - } - Message.success('会话已删除') + await deleteChatMessage(item.id) + setMessages((prev) => prev.filter((m) => m.id !== item.id)) } catch (error) { - Message.error('删除失败') - console.error(error) + message.error(error.response?.data?.detail || '删除失败') } }, }) } - const handleEditTitle = (session) => { - setEditingSessionId(session.session_id) - setEditingTitle(session.title || `对话 ${session.session_id}`) + const handleSearch = async () => { + const keyword = searchKeyword.trim() + if (!keyword) { + searchRequestIdRef.current += 1 + setSearchResults([]) + setSearchedKeyword('') + setHasSearched(false) + setSearchLoading(false) + return + } + const requestId = ++searchRequestIdRef.current + setSearchLoading(true) + try { + const res = await searchChatMessages(keyword) + if (requestId !== searchRequestIdRef.current) return + setSearchResults((res.data || []).filter((item) => item.role === 'user' || item.role === 'assistant')) + setSearchedKeyword(keyword) + setHasSearched(true) + } catch (error) { + if (requestId === searchRequestIdRef.current) { + message.error(error.response?.data?.detail || error.message || '搜索失败') + } + } finally { + if (requestId === searchRequestIdRef.current) { + setSearchLoading(false) + } + } } - const handleSaveTitle = async () => { - if (!editingSessionId || !editingTitle.trim()) { + const handleSearchKeywordChange = (event) => { + const value = event.target.value + setSearchKeyword(value) + if (!value.trim()) { + searchRequestIdRef.current += 1 + setSearchResults([]) + setSearchedKeyword('') + setHasSearched(false) + setSearchLoading(false) + } + } + + const handleSelectSearchResult = async (item) => { + setSearchVisible(false) + skipNextOpenSessionRef.current = item.session_id + await openSession(item.session_id, { message_id: item.message_id }) + } + + const handleRenameSession = async () => { + const title = renameTitle.trim() + if (!renameSession || !title) { + message.warning('请输入对话名称') return } try { - await updateChatSessionTitle(editingSessionId, editingTitle) - setSessions(sessions.map(s => - s.session_id === editingSessionId - ? { ...s, title: editingTitle } - : s - )) - setEditingSessionId(null) - Message.success('标题已更新') + await updateChatSessionTitle(renameSession.session_id, title) + setSessions((prev) => prev.map((item) => ( + item.session_id === renameSession.session_id ? { ...item, title } : item + ))) + if (currentSession?.session_id === renameSession.session_id) { + setCurrentSession((prev) => ({ ...prev, title })) + } + setRenameVisible(false) + setRenameSession(null) + setRenameTitle('') } catch (error) { - Message.error('更新失败') - console.error(error) + message.error(error.response?.data?.detail || '重命名失败') } } - const currentModel = llmConfigs.find(m => m.config_id === selectedModel) + const handleDeleteSession = (session) => { + Modal.confirm({ + title: '删除对话', + content: `确定删除「${session.title}」吗?删除后无法恢复。`, + okText: '删除', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + try { + await deleteChatSession(session.session_id) + await loadSessions() + if (currentSession?.session_id === session.session_id) { + setCurrentSession(null) + setMessages([]) + navigate('/chat', { replace: true }) + } + } catch (error) { + message.error(error.response?.data?.detail || '删除失败') + } + }, + }) + } - return ( - - -
-
-
项目
- ({ - label: m.model_name, - value: m.config_id - }))} - /> -
- - + const renderSessionList = () => ( +
+ {loadingSessions ? ( +
+
- - - -
- {sessionLoading ? ( - - ) : sessions.length === 0 ? ( - - ) : ( - sessions.map(session => ( - { - setCurrentSession(session) - fetchMessages(session.session_id) - }} - style={{ - marginBottom: '8px', - cursor: 'pointer', - background: currentSession?.session_id === session.session_id ? '#e6f7ff' : '#fff', - border: currentSession?.session_id === session.session_id ? '1px solid #1890ff' : '1px solid #d9d9d9', - }} - > -
-
- {editingSessionId === session.session_id ? ( - setEditingTitle(e.target.value)} - size="small" - onBlur={handleSaveTitle} - onPressEnter={handleSaveTitle} - autoFocus - /> - ) : ( -
- {session.title || `对话 ${session.session_id}`} + ) : sessions.length === 0 ? ( + + ) : ( + groupedSessions.map((group) => ( +
+
{group.label}
+ { + const active = currentSession?.session_id === item.session_id + const project = projects.find((project) => project.id === item.project_id) + const menuItems = [ + { + key: 'rename', + icon: , + label: '重命名', + }, + { + key: 'delete', + icon: , + label: '删除', + danger: true, + }, + ] + return ( +
openSession(item.session_id)} + > +
+
+
+
{item.title}
+ { + domEvent.stopPropagation() + if (key === 'rename') { + setRenameSession(item) + setRenameTitle(item.title) + setRenameVisible(true) + } + if (key === 'delete') { + handleDeleteSession(item) + } + }, + }} + trigger={['click']} + > + + +
+
+ }>{project?.name || '未命名项目'} +
- )} -
- {new Date(session.created_at).toLocaleDateString()}
- - { - e.stopPropagation() - handleEditTitle(session) - }} - /> - { - e.stopPropagation() - handleDeleteSession(session.session_id) - }} - /> - -
- - )) - )} -
- + ) + }} + /> +
+ )) + )} +
+ ) - - {!currentSession ? ( - + const renderMessages = () => ( + <> +
+ {loadingMessages ? ( +
+ +
+ ) : messages.length === 0 ? ( + ) : ( - <> -
-
-
- {currentSession.title || `对话 ${currentSession.session_id}`} -
- {currentModel && ( - {currentModel.model_name} - )} -
-
- -
- {messageLoading ? ( - - ) : messages.length === 0 ? ( - - ) : ( - messages.map((msg) => ( -
-
- {msg.content} -
+ messages.map((item) => { + const isUser = item.role === 'user' + const refs = item.references || [] + const hasContent = Boolean((item.content || '').trim()) + // 流式中的占位(status=thinking)或刷新后读到的空助手消息都显示「思考中」 + const isThinking = !isUser && !hasContent && !['streaming', 'error'].includes(item.status) + const isStreaming = !isUser && item.status === 'streaming' + const isError = !isUser && item.status === 'error' + 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 + return ( +
{ + if (node) { + messageRefs.current.set(String(item.id), node) + } else { + messageRefs.current.delete(String(item.id)) + } + }} + className={`chat-message-row ${isUser ? 'user' : 'assistant'}`} + > + {!isUser && } />} +
+
+ {isUser ? ( +
{item.content}
+ ) : ( + <> + {isError && !hasContent ? ( +
+ {item.error || '回答生成失败,请稍后重试'} +
+ ) : isThinking ? ( +
+ 正在思考 + + + + + +
+ ) : ( +
+ { + const citationId = Number(props['data-citation-id']) + const ref = refs.find((itemRef) => Number(itemRef.citation_id) === citationId) + if (!ref) { + return {children} + } + return ( + + + {ref.file_name || ref.file_path} + +
+ + {getReferenceExcerpt(ref) || '暂无引用片段'} + +
+
+ )} + > + {children} + + ) + }, + }} + > + {item.content} + + {isError && ( +
+ {item.error || '回答生成中断,请稍后重试'} +
+ )} +
+ )} + {showReferences && ( +
+ 关联文档 + + {refs.map((ref) => { + return ( + { + openDocument(ref, projectId) + }} + > + [{ref.citation_id}] {ref.file_name} + + ) + })} + +
+ )} + + )}
- )) - )} -
-
+ {(isUser || (!isThinking && !isStreaming)) && ( + handleDeleteMessage(item) : undefined} + /> + )} +
+ {isUser && } />} +
+ ) + }) + )} +
+
+