from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Body, Depends, HTTPException, Query, status from fastapi.responses import FileResponse from sqlalchemy.orm import Session from app.api.deps import CurrentUserContext, get_current_user, get_db, require_admin_user from app.core.agent_enums import AgentName, AgentRunSource from app.schemas.common import ErrorResponse from app.schemas.knowledge import ( KnowledgeActionResponse, KnowledgeDocumentDetailRead, KnowledgeLibraryRead, KnowledgeOnlyOfficeCallbackRead, KnowledgeOnlyOfficeCallbackWrite, KnowledgeOnlyOfficeConfigRead, LlmWikiDocumentDetailRead, LlmWikiIndexRead, LlmWikiSummaryUpdateWrite, LlmWikiSyncTaskRead, LlmWikiSyncWrite, ) from app.services.agent_runs import AgentRunService from app.services.knowledge import ( KnowledgeService, ) from app.services.knowledge_onlyoffice_callback import ( handle_onlyoffice_callback, resolve_onlyoffice_content, ) from app.services.knowledge_onlyoffice_security import ( OnlyOfficeReplayError, OnlyOfficeSecurityError, ) from app.services.knowledge_sync import KnowledgeSyncDispatchService router = APIRouter(prefix="/knowledge") @router.get( "/library", response_model=KnowledgeLibraryRead, summary="查询知识库目录", description="返回固定知识库目录与当前已上传文档列表。", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", } }, ) def get_knowledge_library( current_user: Annotated[CurrentUserContext, Depends(get_current_user)], db: Annotated[Session, Depends(get_db)], ) -> KnowledgeLibraryRead: return KnowledgeService(db=db, tenant_id=current_user.tenant_id).list_library() @router.get( "/llm-wiki", response_model=LlmWikiIndexRead, summary="查询知识索引兼容视图", description="兼容旧前端字段。当前版本已切换到 LightRAG,不再暴露旧 LLM Wiki 草稿索引。", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_403_FORBIDDEN: { "model": ErrorResponse, "description": "只有管理员可以查看索引信息。", }, }, ) def get_llm_wiki_index( current_user: Annotated[CurrentUserContext, Depends(require_admin_user)], db: Annotated[Session, Depends(get_db)], ) -> LlmWikiIndexRead: run_service = AgentRunService(db) sync_runs = [ item for item in run_service.list_runs_for_tenant( tenant_id=current_user.tenant_id, agent=AgentName.HERMES.value, limit=200, ) if str(item.route_json.get("job_type") or "").strip() == "knowledge_index_sync" ] return LlmWikiIndexRead(documents=[], sync_run_count=len(sync_runs)) @router.get( "/llm-wiki/documents/{document_id}", response_model=LlmWikiDocumentDetailRead, summary="读取旧版 LLM Wiki 文档结果", description="兼容旧接口。当前版本知识库已切换到 LightRAG,不再提供旧 LLM Wiki 文档明细。", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_403_FORBIDDEN: { "model": ErrorResponse, "description": "只有管理员可以查看索引信息。", }, status.HTTP_410_GONE: { "model": ErrorResponse, "description": "旧版 LLM Wiki 明细已下线。", }, }, ) def get_llm_wiki_document_detail( document_id: str, _: Annotated[CurrentUserContext, Depends(require_admin_user)], ) -> LlmWikiDocumentDetailRead: raise HTTPException( status_code=status.HTTP_410_GONE, detail=( f"文档 {document_id} 的旧版 LLM Wiki 明细已下线。" "当前版本使用 LightRAG 进行知识索引与检索,不再提供该兼容明细。" ), ) @router.patch( "/llm-wiki/documents/{document_id}", response_model=LlmWikiDocumentDetailRead, summary="更新旧版 LLM Wiki 知识总结", description="兼容旧接口。当前版本不再支持修改旧版 LLM Wiki 草稿。", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_403_FORBIDDEN: { "model": ErrorResponse, "description": "只有管理员可以修改索引信息。", }, status.HTTP_410_GONE: { "model": ErrorResponse, "description": "旧版 LLM Wiki 编辑能力已下线。", }, }, ) def update_llm_wiki_document_summary( document_id: str, payload: LlmWikiSummaryUpdateWrite, _: Annotated[CurrentUserContext, Depends(require_admin_user)], ) -> LlmWikiDocumentDetailRead: del payload raise HTTPException( status_code=status.HTTP_410_GONE, detail=( f"文档 {document_id} 的旧版 LLM Wiki 草稿编辑能力已下线。" "当前版本请通过重新同步知识库来更新 LightRAG 索引。" ), ) @router.post( "/sync", response_model=LlmWikiSyncTaskRead, summary="????????", description="??????????? LightRAG ???????", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "????????????", }, status.HTTP_403_FORBIDDEN: { "model": ErrorResponse, "description": "??????????????", }, }, ) @router.post( "/llm-wiki/sync", response_model=LlmWikiSyncTaskRead, summary="????????????", description="??????????????? LightRAG ???????", ) def sync_knowledge_library( payload: LlmWikiSyncWrite, current_user: Annotated[CurrentUserContext, Depends(require_admin_user)], db: Annotated[Session, Depends(get_db)], ) -> LlmWikiSyncTaskRead: try: result = KnowledgeSyncDispatchService(db).queue_sync( current_user=current_user, folder=payload.folder, document_ids=payload.document_ids, source=AgentRunSource.USER_MESSAGE.value, force=payload.force, changed_only=not payload.force, ) return LlmWikiSyncTaskRead( ok=True, agent_run_id=result.agent_run_id, folder=result.folder, document_ids=result.document_ids, queued_at=result.queued_at, status=result.status, summary=result.summary, ) except Exception as exc: if isinstance(exc, ValueError): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc if isinstance(exc, FileNotFoundError): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc), ) from exc @router.get( "/documents/{document_id}", response_model=KnowledgeDocumentDetailRead, summary="读取知识库文档详情", description="返回单个知识库文档的元信息、预览类型和预览内容。", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "description": "知识库文件不存在。", }, }, ) def get_knowledge_document( document_id: str, current_user: Annotated[CurrentUserContext, Depends(get_current_user)], db: Annotated[Session, Depends(get_db)], ) -> KnowledgeDocumentDetailRead: try: return KnowledgeService( db=db, tenant_id=current_user.tenant_id, ).get_document_detail(document_id) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。", ) from exc @router.get( "/documents/{document_id}/onlyoffice-config", response_model=KnowledgeOnlyOfficeConfigRead, summary="读取 ONLYOFFICE 预览配置", description="为支持的 Office 文档生成 ONLYOFFICE 前端配置和临时访问令牌。", responses={ status.HTTP_400_BAD_REQUEST: { "model": ErrorResponse, "description": "ONLYOFFICE 未启用、配置不完整或文件格式不支持。", }, status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "description": "知识库文件不存在。", }, }, ) def get_knowledge_document_onlyoffice_config( document_id: str, current_user: Annotated[CurrentUserContext, Depends(get_current_user)], db: Annotated[Session, Depends(get_db)], editable: Annotated[ bool, Query(description="是否申请租户知识文档编辑会话;默认仅预览。"), ] = False, ) -> KnowledgeOnlyOfficeConfigRead: try: return KnowledgeService( db=db, tenant_id=current_user.tenant_id, ).build_onlyoffice_config( document_id, current_user, editable=editable, ) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。", ) from exc except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc @router.post( "/documents", response_model=KnowledgeDocumentDetailRead, status_code=status.HTTP_201_CREATED, summary="上传知识库文档", description="上传原始文件二进制内容到指定知识库目录。已有同名文件会覆盖并提升版本号。", responses={ status.HTTP_400_BAD_REQUEST: { "model": ErrorResponse, "description": "目录、文件名或文件内容不合法。", }, status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_403_FORBIDDEN: { "model": ErrorResponse, "description": "只有管理员可以上传知识库文件。", }, }, ) def upload_knowledge_document( content: Annotated[ bytes, Body( media_type="application/octet-stream", description="待上传的文件二进制内容。", ), ], folder: Annotated[str, Query(min_length=1, description="目标知识库目录名称。")], filename: Annotated[str, Query(min_length=1, description="原始文件名。")], current_user: Annotated[CurrentUserContext, Depends(require_admin_user)], db: Annotated[Session, Depends(get_db)], ) -> KnowledgeDocumentDetailRead: try: return KnowledgeService( db=db, tenant_id=current_user.tenant_id, ).upload_document(folder, filename, content, current_user) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc @router.delete( "/documents/{document_id}", response_model=KnowledgeActionResponse, summary="删除知识库文档", description="删除知识库文档及其索引记录。", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_403_FORBIDDEN: { "model": ErrorResponse, "description": "只有管理员可以删除知识库文件。", }, status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "description": "知识库文件不存在。", }, }, ) def delete_knowledge_document( document_id: str, current_user: Annotated[CurrentUserContext, Depends(require_admin_user)], db: Annotated[Session, Depends(get_db)], ) -> KnowledgeActionResponse: try: KnowledgeService( db=db, tenant_id=current_user.tenant_id, ).delete_document(document_id) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。", ) from exc return KnowledgeActionResponse(detail="知识库文件已删除。") @router.get( "/documents/{document_id}/content", response_class=FileResponse, summary="下载或预览知识库原文", description="根据文档 ID 返回原始文件内容,可用于浏览器内联预览或下载。", responses={ status.HTTP_200_OK: { "description": "文件内容。", "content": {"application/octet-stream": {}}, }, status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "description": "知识库文件不存在。", }, }, ) def get_knowledge_document_content( document_id: str, current_user: Annotated[CurrentUserContext, Depends(get_current_user)], db: Annotated[Session, Depends(get_db)], disposition: Annotated[ str, Query( pattern="^(inline|attachment)$", description="内容展示方式,支持 `inline` 或 `attachment`。", ), ] = "inline", ) -> FileResponse: try: file_path, media_type, filename = KnowledgeService( db=db, tenant_id=current_user.tenant_id, ).get_document_content(document_id) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。", ) from exc _ = disposition return FileResponse(file_path, media_type=media_type, filename=filename) @router.get( "/documents/{document_id}/onlyoffice/content", response_class=FileResponse, summary="读取 ONLYOFFICE 文档源文件", description="供 ONLYOFFICE 服务通过短时访问令牌拉取原始文件内容。", responses={ status.HTTP_200_OK: { "description": "文件内容。", "content": {"application/octet-stream": {}}, }, status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "ONLYOFFICE 访问令牌无效。", }, status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "description": "知识库文件不存在。", }, }, ) def get_knowledge_document_onlyoffice_content( document_id: str, access_token: Annotated[ str, Query(min_length=1, description="ONLYOFFICE 临时访问令牌。"), ], db: Annotated[Session, Depends(get_db)], ) -> FileResponse: try: file_path, media_type, filename = resolve_onlyoffice_content( db=db, storage_root=None, document_id=document_id, access_token=access_token, ) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。", ) from exc except (OnlyOfficeSecurityError, OnlyOfficeReplayError) as exc: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc return FileResponse(file_path, media_type=media_type, filename=filename) @router.post( "/documents/{document_id}/onlyoffice/callback", response_model=KnowledgeOnlyOfficeCallbackRead, summary="接收 ONLYOFFICE 回调", description="接收 ONLYOFFICE 文档回写回调,在状态满足要求时更新知识库文档内容。", responses={ status.HTTP_400_BAD_REQUEST: { "model": ErrorResponse, "description": "回调载荷不合法。", }, status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "description": "知识库文件不存在。", }, }, ) def handle_knowledge_document_onlyoffice_callback( document_id: str, payload: KnowledgeOnlyOfficeCallbackWrite, callback_token: Annotated[ str, Query(min_length=1, description="绑定租户与文档的一次性回调会话令牌。"), ], db: Annotated[Session, Depends(get_db)], ) -> KnowledgeOnlyOfficeCallbackRead: try: handle_onlyoffice_callback( db=db, storage_root=None, document_id=document_id, callback_token=callback_token, payload=payload.model_dump(), ) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。", ) from exc except OnlyOfficeReplayError as exc: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc except OnlyOfficeSecurityError as exc: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc return KnowledgeOnlyOfficeCallbackRead()