Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ Yuxi 只交付完整知识能力路径。API 始终注册 `external_kb`、`knowl

- AgentRun:拥有 Conversation、Message、LangGraph interrupt、线程 FIFO 和 execution tree 语义,通过专用 Run/Attempt 表维护状态、输出与租约。
- 用户定时 Agent:任务定义和 occurrence 独立持久化;worker 锁定到期任务后复用统一 AgentRun Request/Run 链路,排队与执行状态仍由 AgentRunRequest 和 AgentRun 拥有。
- Durable Task:用于知识库解析、评估和图谱构建。API 只提交持久 `task_type + handler_version + payload`;`worker` 从 registry 惰性加载领域 Handler,并通过 Task 行的唯一 owner、heartbeat 和 lease 执行。知识文件中间态绑定 Task/attempt owner,失联 failure hook 与 Task 终态同事务收敛文件错误态;PG pending 行由启动与周期 publisher 补发。共享 ARQ worker 的执行槽由 Compose 配置,Durable Task 的 PG claim 上限为 4,不能占满 AgentRun 容量。
- Durable Task:用于知识库解析、Office 文件预览、评估和图谱构建。API 只提交持久 `task_type + handler_version + payload`;`worker` 从 registry 惰性加载领域 Handler,并通过 Task 行的唯一 owner、heartbeat 和 lease 执行。知识文件中间态绑定 Task/attempt owner,失联 failure hook 与 Task 终态同事务收敛文件错误态;PG pending 行由启动与周期 publisher 补发。共享 ARQ worker 的执行槽由 Compose 配置,Durable Task 的 PG claim 上限为 4,不能占满 AgentRun 容量。

`knowledge_preview` 在原件注册后生成知识库 Office 文件的共享 PDF,预览 Task 的终态与文件解析、索引状态独立。转换、缓存与读取链路见[Office 文件预览](docs/mechanisms/knowledge-base.md#office-文件预览)。

测试代码位于 `backend/test`,按 `unit`、`integration`、`e2e` 分层。新增或修改后端行为时,测试应放在最能覆盖真实风险的层级。

Expand Down
37 changes: 34 additions & 3 deletions backend/package/yuxi/knowledge/preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from collections.abc import Awaitable, Callable

from yuxi.knowledge.utils.kb_utils import is_minio_url, parse_minio_url
from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository
from yuxi.storage.minio import get_minio_client
Expand All @@ -15,7 +17,13 @@
)


async def read_knowledge_file_preview(kb_id: str, file_id: str) -> dict:
async def read_knowledge_file_preview(
kb_id: str,
file_id: str,
*,
prepare_office: bool = True,
before_cache_write: Callable[[], Awaitable[None]] | None = None,
) -> dict:
"""从 Knowledge metadata 与 MinIO 对象生成只读文件预览。"""
file_record = await KnowledgeFileRepository().get_by_file_id(file_id)
if file_record is None or file_record.kb_id != kb_id:
Expand Down Expand Up @@ -48,7 +56,23 @@ async def read_knowledge_file_preview(kb_id: str, file_id: str) -> dict:
return {**response, **preview_too_large().payload()}

if is_office_pdf_preview_file(filename):
pdf_content = await _read_office_pdf_preview(kb_id, file_id, filename, original_path)
pdf_content = await _read_office_pdf_preview(
kb_id,
file_id,
filename,
original_path,
prepare_office=prepare_office,
before_cache_write=before_cache_write,
)
if pdf_content is None:
return {
**response,
"content": None,
"preview_type": "office",
"supported": True,
"status": "loading",
"message": "正在生成预览,完成后将自动显示...",
}
return {
**response,
"content": pdf_content,
Expand Down Expand Up @@ -82,10 +106,15 @@ async def _read_office_pdf_preview(
file_id: str,
filename: str,
original_path: str,
) -> bytes:
*,
prepare_office: bool = True,
before_cache_write: Callable[[], Awaitable[None]] | None = None,
) -> bytes | None:
minio_client = get_minio_client()
bucket_name = minio_client.KB_BUCKETS["parsed"]
object_name = f"{kb_id}/preview/{file_id}.pdf"
if not prepare_office:
return await minio_client.adownload_file(bucket_name, object_name, missing_ok=True)
if await minio_client.astat_file(bucket_name, object_name) is not None:
return await minio_client.adownload_file(bucket_name, object_name)

Expand All @@ -94,6 +123,8 @@ async def _read_office_pdf_preview(
pdf_content = await convert_office_to_pdf(filename, raw_content)
except OfficePreviewConversionError as exc:
raise ValueError(str(exc)) from exc
if before_cache_write is not None:
await before_cache_write()
await minio_client.aupload_file(
bucket_name=bucket_name,
object_name=object_name,
Expand Down
11 changes: 5 additions & 6 deletions backend/package/yuxi/services/file_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@

from __future__ import annotations

import io
from urllib.parse import quote

from fastapi.responses import StreamingResponse
from fastapi.responses import Response
from yuxi.utils.filepreview import PreviewResult
from yuxi.workspace.preview import preview_workspace_file

Expand All @@ -15,18 +14,18 @@ async def render_file_preview(
raw_content: bytes,
*,
office_cache_key: str,
) -> dict | StreamingResponse:
) -> dict | Response:
"""把持久文件预览结果转换为 Workspace/Viewer HTTP 响应。"""
result = await preview_workspace_file(path, raw_content, office_cache_key=office_cache_key)
return _preview_response(result)


def _preview_response(result: PreviewResult) -> dict | StreamingResponse:
def _preview_response(result: PreviewResult) -> dict | Response:
if not isinstance(result.content, bytes):
return result.payload()
filename = result.filename or "preview"
return StreamingResponse(
io.BytesIO(result.content),
return Response(
result.content,
media_type=result.media_type or "application/octet-stream",
headers={
"Content-Disposition": f"inline; filename*=UTF-8''{quote(filename)}",
Expand Down
77 changes: 77 additions & 0 deletions backend/package/yuxi/services/knowledge_preview_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""知识库文件注册、后台预览与只读预览状态用例。"""

from __future__ import annotations

from yuxi.knowledge.preview import read_knowledge_file_preview
from yuxi.knowledge.runtime import knowledge_base
from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository
from yuxi.services.task_service import Task, TaskContext, tasker
from yuxi.utils.filepreview import MAX_BINARY_PREVIEW_SIZE_BYTES, is_office_pdf_preview_file
from yuxi.utils.logging_config import logger


async def register_knowledge_file(kb_id: str, item: str, *, params: dict, operator_id: str) -> dict:
"""注册原件后立即提交独立预览任务,解析与预览各自保留状态。"""
file_meta = await knowledge_base.add_file_record(kb_id, item, params=params, operator_id=operator_id)
size = file_meta.get("size")
if is_office_pdf_preview_file(file_meta["filename"]) and (
size is None or int(size) <= MAX_BINARY_PREVIEW_SIZE_BYTES
):
try:
await enqueue_knowledge_preview(kb_id, file_meta["file_id"])
except Exception:
logger.exception(
"预览任务提交失败,原件注册已完成: kb_id={}, file_id={}",
kb_id,
file_meta["file_id"],
)
return file_meta


async def enqueue_knowledge_preview(kb_id: str, file_id: str) -> Task:
"""按知识库和文件去重,复用持久 Task 的提交后投递与恢复。"""
payload = {"kb_id": kb_id, "file_id": file_id}
task, _ = await tasker.enqueue_unique_by_payload(
name="知识库文件预览",
task_type="knowledge_preview",
payload=payload,
payload_match=payload,
)
return task


async def read_workspace_knowledge_preview(kb_id: str, file_id: str) -> dict:
"""读取现成缓存,缺失时呈现后台任务状态而不在请求中转换。"""
response = await read_knowledge_file_preview(kb_id, file_id, prepare_office=False)
if response.get("status") != "loading":
return response

task = await tasker.find_task_by_payload(
task_type="knowledge_preview", payload_match={"kb_id": kb_id, "file_id": file_id}
)
if task is not None and task.status in {"failed", "cancelled"}:
return {**response, "status": "error", "supported": False, "message": "预览生成失败,请下载原件查看"}
if task is None or task.status == "success":
task = await enqueue_knowledge_preview(kb_id, file_id)
return response


async def run_knowledge_preview(context: TaskContext) -> dict:
"""由真实 worker 生成共享 PDF,发布前核对租约和原件记录。"""
kb_id, file_id = context.payload["kb_id"], context.payload["file_id"]
await context.raise_if_cancelled()
await context.set_progress(10, "正在生成文件预览")

async def before_cache_write():
"""转换可以较慢,发布缓存前重新检查执行权与文件存在性。"""
await context.raise_if_cancelled()
record = await KnowledgeFileRepository().get_by_file_id(file_id)
if record is None or record.kb_id != kb_id:
raise ValueError("原件已删除,停止发布预览")

preview = await read_knowledge_file_preview(kb_id, file_id, before_cache_write=before_cache_write)
if not preview.get("binary") or preview.get("preview_type") != "pdf":
raise ValueError("当前文件无法生成 PDF 预览")
result = {"kb_id": kb_id, "file_id": file_id, "preview_type": "pdf", "size": len(preview["content"])}
await context.set_progress(100, "文件预览已就绪")
return result
3 changes: 2 additions & 1 deletion backend/package/yuxi/services/knowledge_task_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from yuxi.knowledge.utils import params_for_uploaded_document
from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository
from yuxi.services.knowledge_folder_service import knowledge_folder_service
from yuxi.services.knowledge_preview_service import register_knowledge_file
from yuxi.services.task_service import TaskContext
from yuxi.utils.logging_config import logger

Expand Down Expand Up @@ -60,7 +61,7 @@ async def run_knowledge_ingest(context: TaskContext) -> dict:
await context.raise_if_cancelled()
await context.set_progress(5.0 + (index / total) * 25.0, f"[1/3] 添加记录 {index}/{total}")
try:
file_meta = await knowledge_base.add_file_record(
file_meta = await register_knowledge_file(
kb_id,
item,
params=params_for_uploaded_document(item, params),
Expand Down
5 changes: 5 additions & 0 deletions backend/package/yuxi/services/task_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ def _load(self, function: str, label: str):
"run_knowledge_ingest",
failure_function="fail_knowledge_file_task",
),
TaskDefinition(
"knowledge_preview",
"yuxi.services.knowledge_preview_service",
"run_knowledge_preview",
),
TaskDefinition(
"knowledge_parse",
"yuxi.services.knowledge_task_service",
Expand Down
4 changes: 2 additions & 2 deletions backend/package/yuxi/services/viewer_filesystem_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from urllib.parse import quote

from fastapi import HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.responses import FileResponse, Response
from starlette.background import BackgroundTask
from yuxi.agents.backends.paths import is_runtime_path, runtime_path_for_workdir_scope
from yuxi.services.file_preview import render_file_preview
Expand Down Expand Up @@ -123,7 +123,7 @@ async def _download_to_temp(workdir, path: str, max_bytes: int) -> str:
return temp_path


async def read_viewer_file_content(*, thread_id: str, path: str, current_user, db) -> dict | StreamingResponse:
async def read_viewer_file_content(*, thread_id: str, path: str, current_user, db) -> dict | Response:
"""从实时 Workdir 读取预览,不经过 MinIO revision。"""
access = await _viewer_state(thread_id=thread_id, current_user=current_user, db=db)
_validate_viewer_path(access, path)
Expand Down
4 changes: 2 additions & 2 deletions backend/package/yuxi/services/workspace_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from urllib.parse import quote

from fastapi import HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.responses import FileResponse, Response
from starlette.background import BackgroundTask
from yuxi.agents.backends.paths import runtime_user_data_path
from yuxi.repositories.project_repository import ProjectRepository
Expand Down Expand Up @@ -141,7 +141,7 @@ async def read_workspace_file_bytes(*, path: str, current_user: User) -> tuple[s
return PurePosixPath(workspace_path).name, content


async def read_workspace_file_content(*, path: str, current_user: User) -> dict | StreamingResponse:
async def read_workspace_file_content(*, path: str, current_user: User) -> dict | Response:
backend = _workspace_backend(current_user)
workspace_path = _workspace_path(path)
try:
Expand Down
6 changes: 4 additions & 2 deletions backend/package/yuxi/storage/minio/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,8 @@ async def adownload_response(self, bucket_name: str, object_name: str) -> BaseHT
raise StorageError(f"对象 '{object_name}' 在存储桶 '{bucket_name}' 中不存在")
raise StorageError(f"下载文件失败: {e}")

async def adownload_file(self, bucket_name: str, object_name: str) -> bytes:
"""异步下载文件"""
async def adownload_file(self, bucket_name: str, object_name: str, *, missing_ok: bool = False) -> bytes | None:
"""异步下载文件,派生缓存可显式允许对象缺失。"""
response = None
try:
response = await asyncio.to_thread(self.client.get_object, bucket_name=bucket_name, object_name=object_name)
Expand All @@ -242,6 +242,8 @@ async def adownload_file(self, bucket_name: str, object_name: str) -> bytes:

except S3Error as e:
if e.code == "NoSuchKey":
if missing_ok:
return None
raise StorageError(f"对象 '{object_name}' 在存储桶 '{bucket_name}' 中不存在")
raise StorageError(f"下载文件失败: {e}")
finally:
Expand Down
3 changes: 2 additions & 1 deletion backend/package/yuxi/utils/filepreview.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
_MARKDOWN_EXTENSIONS = frozenset({".md", ".markdown", ".mdx"})
_PDF_EXTENSIONS = frozenset({".pdf"})
_HTML_EXTENSIONS = frozenset({".html", ".htm"})
_OFFICE_PDF_PREVIEW_EXTENSIONS = frozenset({".docx", ".pptx"})
_OFFICE_PDF_PREVIEW_EXTENSIONS = frozenset({".docx", ".pptx", ".xlsx", ".xls"})
_OFFICE_MEDIA_TYPES = {
".xls": "application/vnd.ms-excel",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
Expand Down
11 changes: 7 additions & 4 deletions backend/server/routers/knowledge_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import StreamingResponse
from yuxi.config.options import system_options
from yuxi.knowledge.base import KBNameConflictError, KBNotFoundError
from yuxi.knowledge.chunking.ragflow_like.presets import get_chunk_preset_options
from yuxi.knowledge.graphs.milvus_graph_service import GRAPH_TASK_TYPE, MilvusGraphService
from yuxi.knowledge.read_models import KnowledgeBaseDetail
from yuxi.knowledge.parser.capabilities import SUPPORTED_FILE_EXTENSIONS, is_supported_file_extension
from yuxi.knowledge.read_models import KnowledgeBaseDetail
from yuxi.knowledge.runtime import knowledge_base
from yuxi.knowledge.utils import calculate_content_hash, is_minio_url, params_for_uploaded_document, parse_minio_url
from yuxi.knowledge.utils.mindmap_utils import (
Expand All @@ -37,6 +38,7 @@
resolve_knowledge_base_permission,
)
from yuxi.services.knowledge_folder_service import knowledge_folder_service
from yuxi.services.knowledge_preview_service import register_knowledge_file
from yuxi.services.ocr_service import parse_document
from yuxi.services.task_service import tasker
from yuxi.services.workspace_service import read_workspace_file_bytes
Expand All @@ -46,13 +48,14 @@
from yuxi.utils.upload_utils import MAX_UPLOAD_SIZE_BYTES, read_upload_with_limit, write_upload_to_path

from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
from sqlalchemy.ext.asyncio import AsyncSession
from server.utils.knowledge_response import serialize_knowledge_base, serialize_knowledge_base_list
from server.utils.knowledge_permissions import (
ensure_knowledge_base_permission as _ensure_database_permission,
)
from server.utils.knowledge_permissions import (
require_knowledge_base_manage,
require_knowledge_base_read,
)
from server.utils.knowledge_response import serialize_knowledge_base, serialize_knowledge_base_list

knowledge = APIRouter(prefix="/knowledge", tags=["knowledge"])

Expand Down Expand Up @@ -780,7 +783,7 @@ async def add_uploaded_documents(
failed_items: list[dict] = []
for index, item in enumerate(payload.items):
try:
file_meta = await knowledge_base.add_file_record(
file_meta = await register_knowledge_file(
kb_id,
item,
params=params_for_uploaded_document(item, params),
Expand Down
15 changes: 8 additions & 7 deletions backend/server/routers/workspace_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@
from urllib.parse import quote

from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import StreamingResponse
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from server.utils.auth_middleware import get_db, get_required_user
from yuxi.services.workspace_service import (
create_workspace_directory,
delete_workspace_path,
Expand All @@ -20,6 +19,8 @@
)
from yuxi.storage.postgres.models_business import User

from server.utils.auth_middleware import get_db, get_required_user

workspace = APIRouter(prefix="/workspace", tags=["workspace"])
workspace_knowledge = APIRouter(prefix="/workspace", tags=["workspace"])

Expand Down Expand Up @@ -129,11 +130,11 @@ async def search_workspace_files_route(
return await search_workspace_files(query=query, current_user=current_user)


def _binary_preview_response(data: dict) -> StreamingResponse:
def _binary_preview_response(data: dict) -> Response:
filename = data.get("filename") or "preview"
preview_type = data.get("preview_type") or "unsupported"
return StreamingResponse(
io.BytesIO(data.get("content") or b""),
return Response(
data.get("content") or b"",
media_type=data.get("media_type") or "application/octet-stream",
headers={
"Content-Disposition": f"inline; filename*=UTF-8''{quote(filename)}",
Expand Down Expand Up @@ -204,9 +205,9 @@ async def get_workspace_knowledge_file(
await _ensure_knowledge_read_access(current_user, kb_id)
await _ensure_knowledge_supports_documents(kb_id)
try:
from yuxi.knowledge.preview import read_knowledge_file_preview
from yuxi.services.knowledge_preview_service import read_workspace_knowledge_preview

return _preview_response(await read_knowledge_file_preview(kb_id=kb_id, file_id=file_id))
return _preview_response(await read_workspace_knowledge_preview(kb_id=kb_id, file_id=file_id))
except ValueError as error:
_raise_knowledge_read_error(error)

Expand Down
Loading