nex_docus/backend/app/api/v1/chat.py

872 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
知识库对话相关 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})