872 lines
30 KiB
Python
872 lines
30 KiB
Python
"""
|
||
知识库对话相关 API
|
||
"""
|
||
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 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.schemas.response import success_response
|
||
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):
|
||
"""创建对话会话请求"""
|
||
project_id: int
|
||
llm_config_id: int
|
||
title: str = "新对话"
|
||
|
||
|
||
class ChatMessageRequest(BaseModel):
|
||
"""发送聊天消息请求"""
|
||
session_id: int
|
||
message: str
|
||
|
||
|
||
class ChatSessionUpdateRequest(BaseModel):
|
||
"""更新对话会话请求"""
|
||
title: str
|
||
|
||
|
||
class ChatResponse(BaseModel):
|
||
"""对话响应"""
|
||
session_id: int
|
||
user_message: str
|
||
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,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""创建新的对话会话"""
|
||
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,
|
||
project_id=req.project_id,
|
||
llm_config_id=req.llm_config_id,
|
||
title=req.title,
|
||
)
|
||
db.add(session)
|
||
await db.commit()
|
||
await db.refresh(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: Optional[int] = None,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""列出当前用户的对话会话,可按知识库筛选"""
|
||
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=[{
|
||
"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(),
|
||
} for s in sessions])
|
||
|
||
|
||
@router.get("/sessions/{session_id}/messages", response_model=dict)
|
||
async def get_session_messages(
|
||
session_id: int,
|
||
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="无权访问该对话")
|
||
|
||
await require_project_read_access(db, session.project_id, current_user)
|
||
|
||
msg_stmt = select(ChatMessage).where(
|
||
ChatMessage.session_id == session_id
|
||
).order_by(ChatMessage.created_at.asc())
|
||
msg_result = await db.execute(msg_stmt)
|
||
messages = msg_result.scalars().all()
|
||
|
||
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)
|
||
async def send_chat_message(
|
||
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)
|
||
|
||
query_message = ChatMessage(
|
||
session_id=req.session_id,
|
||
role="user",
|
||
content=question,
|
||
)
|
||
db.add(query_message)
|
||
await db.flush()
|
||
|
||
try:
|
||
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_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,
|
||
)
|
||
|
||
assistant_response = await rag_service.generate_response(
|
||
db,
|
||
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": 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:
|
||
await db.rollback()
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=f"生成对话回复失败: {str(e)}"
|
||
)
|
||
|
||
|
||
@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,
|
||
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="无权删除该对话")
|
||
|
||
await db.delete(session)
|
||
msg_stmt = delete(ChatMessage).where(ChatMessage.session_id == session_id)
|
||
await db.execute(msg_stmt)
|
||
await db.commit()
|
||
|
||
return success_response(message="对话会话已删除")
|
||
|
||
|
||
@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()
|
||
|
||
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})
|