import base64 import json import uuid from datetime import datetime from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, UploadFile from fastapi.responses import StreamingResponse from sqlalchemy import delete, select from sqlalchemy.orm import Session from app.config import get_settings from app.db import SessionLocal, get_db from app.deps import get_current_user from app.integrations.adp_client import AdpClient from app.integrations.storage_client import StorageClient from app.models import Attachment, Conversation, Feedback, Message, MessageReference, User from app.schemas.chat import ( AttachmentResponse, ChatStreamRequest, ConversationCreate, ConversationResponse, FeedbackCreate, MessageResponse, ) from app.services.audit_service import write_audit def _attachment_data_url(att: Attachment) -> str | None: storage_dir = Path(get_settings().local_storage_dir) p = storage_dir / att.storage_key if not p.exists(): return None try: raw = p.read_bytes() except OSError: return None mime = att.file_type or "image/png" return f"data:{mime};base64,{base64.b64encode(raw).decode()}" router = APIRouter() @router.get("/sessions", response_model=list[ConversationResponse]) def list_sessions(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[Conversation]: return db.scalars( select(Conversation).where(Conversation.user_id == current_user.id).order_by(Conversation.id.desc()) ).all() @router.post("/sessions", response_model=ConversationResponse) def create_session( payload: ConversationCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> Conversation: conversation = Conversation(user_id=current_user.id, title=payload.title) db.add(conversation) write_audit(db, "chat.session.create", actor_id=current_user.id, target_type="conversation") db.commit() db.refresh(conversation) return conversation @router.delete("/sessions/{session_id}") def delete_session( session_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict: conversation = db.get(Conversation, session_id) if not conversation or conversation.user_id != current_user.id: raise HTTPException(status_code=404, detail="会话不存在") message_ids = list(db.scalars(select(Message.id).where(Message.conversation_id == session_id))) if message_ids: db.execute(delete(MessageReference).where(MessageReference.message_id.in_(message_ids))) db.execute(delete(Feedback).where(Feedback.message_id.in_(message_ids))) db.execute(delete(Message).where(Message.conversation_id == session_id)) db.delete(conversation) write_audit(db, "chat.session.delete", actor_id=current_user.id, target_type="conversation", target_id=str(session_id)) db.commit() return {"message": "会话已删除"} @router.get("/sessions/{session_id}/messages", response_model=list[MessageResponse]) def list_messages( session_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> list[MessageResponse]: conversation = db.get(Conversation, session_id) if not conversation or conversation.user_id != current_user.id: raise HTTPException(status_code=404, detail="会话不存在") messages = db.scalars(select(Message).where(Message.conversation_id == session_id).order_by(Message.id)).all() message_ids = [m.id for m in messages] atts_by_msg: dict[int, list[Attachment]] = {} if message_ids: for att in db.scalars(select(Attachment).where(Attachment.message_id.in_(message_ids))): atts_by_msg.setdefault(att.message_id, []).append(att) return [ MessageResponse( id=message.id, conversation_id=message.conversation_id, question=message.question, answer=message.answer, status=message.status, token_usage=message.token_usage, pu_usage=message.pu_usage, created_at=message.created_at, references=_dedup_refs(message.references), attachments=[ AttachmentResponse(id=att.id, file_type=att.file_type, data_url=_attachment_data_url(att)) for att in atts_by_msg.get(message.id, []) ], ) for message in messages ] def _dedup_refs(refs) -> list[dict]: seen: set[tuple[str, str]] = set() out: list[dict] = [] for ref in refs: title = (ref.source_title or "").strip() if not title: continue key = (title, (ref.snippet or "").strip()) if key in seen: continue seen.add(key) out.append({ "source_title": ref.source_title, "snippet": ref.snippet, "page_no": ref.page_no, "section": ref.section, "source_url": ref.source_url, }) return out @router.post("/upload-image") def upload_image(file: UploadFile, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: if file.content_type not in {"image/png", "image/jpeg", "image/jpg"}: raise HTTPException(status_code=400, detail="仅支持 PNG/JPG 截图") raw = file.file.read() storage = StorageClient() key = storage.save_bytes(raw, suffix=Path(file.filename or "upload.bin").suffix) try: remote_url = AdpClient().upload_chat_image( raw, mime_type=file.content_type or "image/png", original_name=file.filename or "" ) except Exception as exc: raise HTTPException(status_code=502, detail=f"图片上传到 ADP 失败: {exc}") from exc attachment = Attachment( user_id=current_user.id, file_type=file.content_type or "image", storage_key=key, remote_url=remote_url, original_name=file.filename, ) db.add(attachment) db.flush() write_audit(db, "chat.image.upload", actor_id=current_user.id, target_type="attachment", target_id=str(attachment.id)) db.commit() return {"id": attachment.id, "url": storage.temporary_url(key)} @router.post("/stream") def stream_chat( payload: ChatStreamRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> StreamingResponse: current_user_id = current_user.id allowed_tags = [] if current_user.is_superuser else [role.code for role in current_user.roles] if not (payload.question or "").strip() and not payload.attachment_ids: raise HTTPException(status_code=400, detail="问题文本和图片至少需要一项") image_urls: list[str] = [] attachments: list[Attachment] = [] if payload.attachment_ids: attachments = list(db.scalars( select(Attachment).where( Attachment.id.in_(payload.attachment_ids), Attachment.user_id == current_user_id, ) )) if len(attachments) != len(payload.attachment_ids): raise HTTPException(status_code=404, detail="附件不存在或无权访问") for att in attachments: if not att.remote_url: raise HTTPException(status_code=500, detail=f"附件缺少远端 URL,请重新上传: {att.id}") image_urls.append(att.remote_url) conversation = db.get(Conversation, payload.conversation_id) if payload.conversation_id else None if conversation and conversation.user_id != current_user_id: raise HTTPException(status_code=404, detail="会话不存在") if not conversation: title_seed = (payload.question or "").strip()[:40] or ("[图片] " + (attachments[0].original_name or "提问")) conversation = Conversation( user_id=current_user_id, title=title_seed[:60], adp_conversation_id=uuid.uuid4().hex, ) db.add(conversation) db.flush() elif not conversation.adp_conversation_id: conversation.adp_conversation_id = uuid.uuid4().hex db.flush() message = Message(conversation_id=conversation.id, user_id=current_user_id, question=payload.question, status="streaming") db.add(message) conversation.last_message_at = datetime.utcnow() db.flush() message_id = message.id conversation_id = conversation.id adp_conversation_id = conversation.adp_conversation_id for att in attachments: att.message_id = message_id db.commit() def event_stream(): answer_parts: list[str] = [] usage = {"token_usage": 0, "pu_usage": 0} references: list[dict] = [] seen_refs: set[tuple[str, str]] = set() for event in AdpClient().stream_answer( payload.question, user_id=str(current_user_id), conversation_id=adp_conversation_id, allowed_tags=allowed_tags, image_urls=image_urls, ): event_name = event["event"] if event_name == "answer.delta": answer_parts.append(event["data"]) elif event_name == "answer.reference": title = (event["data"].get("source_title") or "").strip() if not title: continue key = (title, (event["data"].get("snippet") or "").strip()) if key in seen_refs: continue seen_refs.add(key) references.append(event["data"]) elif event_name == "answer.usage": usage.update(event["data"]) yield f"event: {event_name}\ndata: {json.dumps(event['data'], ensure_ascii=False)}\n\n" save_db = SessionLocal() try: saved = save_db.get(Message, message_id) if saved: saved.answer = "".join(answer_parts) saved.status = "done" saved.token_usage = usage["token_usage"] saved.pu_usage = usage["pu_usage"] for ref in references: save_db.add(MessageReference(message_id=message_id, **ref)) write_audit(save_db, "chat.ask", actor_id=current_user_id, target_type="message", target_id=str(message_id)) save_db.commit() finally: save_db.close() return StreamingResponse( event_stream(), media_type="text/event-stream", headers={"X-Conversation-Id": str(conversation_id), "X-Message-Id": str(message_id)}, ) @router.post("/messages/{message_id}/feedback") def create_feedback( message_id: int, payload: FeedbackCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict: message = db.get(Message, message_id) if not message or message.user_id != current_user.id: raise HTTPException(status_code=404, detail="消息不存在") feedback = Feedback(message_id=message_id, user_id=current_user.id, rating=payload.rating, comment=payload.comment) db.add(feedback) write_audit(db, "chat.feedback", actor_id=current_user.id, target_type="message", target_id=str(message_id)) db.commit() return {"message": "反馈已提交"}