feat(kb): 新增从工作区导入文件功能,优化图谱配置面板与并发上限
- 后端新增 /files/import-workspace 接口,将工作区文件上传至 MinIO 并返回预处理结果 - 后端新增 resolve_workspace_file_path 工具函数,校验文件存在性与类型 - 前端 FileUploadModal 新增工作区文件选择模式,支持目录浏览与多选文件 - 前端 knowledge_api 新增 importWorkspaceFiles API - 图谱抽取器并发上限从 20 提升至 1000 - 图谱配置面板浮动层级与样式微调(阴影、边框、告警文案精简) - 知识库类型 milvus 标签颜色从 red 改为 blue - 新增工作区导入后端单元测试
This commit is contained in:
parent
5634ac3e27
commit
78acc19a88
@ -41,8 +41,8 @@ class LLMGraphExtractor(GraphExtractor):
|
||||
concurrency_count = int(concurrency_count)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("LLM 抽取器 concurrency_count 必须是整数") from exc
|
||||
if concurrency_count < 1 or concurrency_count > 20:
|
||||
raise ValueError("LLM 抽取器 concurrency_count 必须在 1 到 20 之间")
|
||||
if concurrency_count < 1 or concurrency_count > 1000:
|
||||
raise ValueError("LLM 抽取器 concurrency_count 必须在 1 到 1000 之间")
|
||||
if self.options.get("model_params") is not None and not isinstance(self.options["model_params"], dict):
|
||||
raise ValueError("LLM 抽取器 model_params 必须是对象")
|
||||
|
||||
|
||||
@ -236,7 +236,7 @@ class MilvusGraphService:
|
||||
worker_count = int((config.get("extractor_options") or {}).get("concurrency_count") or 1)
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
return max(1, min(worker_count, 20))
|
||||
return max(1, min(worker_count, 1000))
|
||||
|
||||
@staticmethod
|
||||
def _runtime_extractor_options(config: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@ -138,6 +138,15 @@ async def list_workspace_tree(
|
||||
return {"entries": entries}
|
||||
|
||||
|
||||
def resolve_workspace_file_path(*, path: str, current_user: User) -> Path:
|
||||
target = _resolve_workspace_path(current_user, path)
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail=f"工作区文件不存在: {path}")
|
||||
if not target.is_file():
|
||||
raise HTTPException(status_code=400, detail=f"当前路径不是文件: {path}")
|
||||
return target
|
||||
|
||||
|
||||
async def read_workspace_file_content(*, path: str, current_user: User) -> dict:
|
||||
target = _resolve_workspace_path(current_user, path)
|
||||
if not target.exists():
|
||||
|
||||
@ -3,6 +3,7 @@ import json
|
||||
import os
|
||||
import textwrap
|
||||
import traceback
|
||||
import time
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
import aiofiles
|
||||
@ -20,6 +21,7 @@ from yuxi.plugins.parser import Parser, SUPPORTED_FILE_EXTENSIONS, is_supported_
|
||||
from yuxi.knowledge.utils import calculate_content_hash
|
||||
from yuxi.knowledge.utils.kb_utils import is_minio_url, parse_minio_url
|
||||
from yuxi.services.model_cache import model_cache
|
||||
from yuxi.services.workspace_service import MAX_WORKSPACE_UPLOAD_SIZE_BYTES, resolve_workspace_file_path
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
from yuxi.storage.minio.client import MinIOClient, StorageError, aupload_file_to_minio, get_minio_client
|
||||
from yuxi.utils import logger
|
||||
@ -38,6 +40,11 @@ class UpdateDatabaseRequest(BaseModel):
|
||||
share_config: dict | None = None
|
||||
|
||||
|
||||
class WorkspaceImportRequest(BaseModel):
|
||||
db_id: str
|
||||
paths: list[str]
|
||||
|
||||
|
||||
media_types = {
|
||||
".pdf": "application/pdf",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
@ -1349,6 +1356,72 @@ async def fetch_url(
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch URL: {str(e)}")
|
||||
|
||||
|
||||
@knowledge.post("/files/import-workspace")
|
||||
async def import_workspace_files(
|
||||
payload: WorkspaceImportRequest,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""将当前用户工作区文件导入 MinIO,返回与普通文件上传一致的预处理结果。"""
|
||||
db_id = payload.db_id.strip()
|
||||
paths = [path for path in payload.paths if str(path or "").strip()]
|
||||
if not db_id:
|
||||
raise HTTPException(status_code=400, detail="db_id is required")
|
||||
if not paths:
|
||||
raise HTTPException(status_code=400, detail="请选择至少一个工作区文件")
|
||||
|
||||
await _ensure_database_not_dify(db_id, "文档添加/解析/入库")
|
||||
|
||||
bucket_name = MinIOClient.KB_BUCKETS["documents"]
|
||||
results = []
|
||||
for workspace_path in paths:
|
||||
target = resolve_workspace_file_path(path=workspace_path, current_user=current_user)
|
||||
|
||||
filename = target.name
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext == ".jsonl" or not (is_supported_file_extension(filename) or ext == ".zip"):
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported file type: {ext}")
|
||||
|
||||
size = target.stat().st_size
|
||||
if size > MAX_WORKSPACE_UPLOAD_SIZE_BYTES:
|
||||
raise HTTPException(status_code=400, detail="文件过大,当前仅支持 100 MB 以内的工作区文件")
|
||||
|
||||
file_bytes = await asyncio.to_thread(target.read_bytes)
|
||||
content_hash = await calculate_content_hash(file_bytes)
|
||||
|
||||
file_exists = await knowledge_base.file_existed_in_db(db_id, content_hash)
|
||||
if file_exists:
|
||||
raise HTTPException(status_code=409, detail=f"数据库中已经存在了相同内容文件: {filename}")
|
||||
|
||||
basename, ext = os.path.splitext(filename)
|
||||
timestamp = int(time.time() * 1000)
|
||||
minio_filename = f"{basename}_{timestamp}{ext}"
|
||||
object_name = f"{db_id}/upload/{minio_filename}"
|
||||
minio_url = await aupload_file_to_minio(bucket_name, object_name, file_bytes)
|
||||
|
||||
normalized_filename = filename.lower()
|
||||
same_name_files = await knowledge_base.get_same_name_files(db_id, normalized_filename)
|
||||
results.append(
|
||||
{
|
||||
"message": "Workspace file successfully imported",
|
||||
"file_path": minio_url,
|
||||
"minio_path": minio_url,
|
||||
"db_id": db_id,
|
||||
"content_hash": content_hash,
|
||||
"filename": normalized_filename,
|
||||
"original_filename": basename,
|
||||
"size": len(file_bytes),
|
||||
"minio_filename": minio_filename,
|
||||
"object_name": object_name,
|
||||
"bucket_name": bucket_name,
|
||||
"workspace_path": workspace_path,
|
||||
"same_name_files": same_name_files,
|
||||
"has_same_name": len(same_name_files) > 0,
|
||||
}
|
||||
)
|
||||
|
||||
return {"status": "success", "items": results}
|
||||
|
||||
|
||||
@knowledge.post("/files/upload")
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
|
||||
77
backend/test/unit/routers/test_knowledge_workspace_import.py
Normal file
77
backend/test/unit/routers/test_knowledge_workspace_import.py
Normal file
@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from server.routers import knowledge_router
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_import_workspace_files_uploads_workspace_file_to_minio(tmp_path, monkeypatch):
|
||||
source = tmp_path / "note.md"
|
||||
source.write_text("# workspace note\n", encoding="utf-8")
|
||||
|
||||
async def fake_ensure_database_not_dify(db_id: str, operation: str) -> None:
|
||||
assert db_id == "db_1"
|
||||
assert "文档添加" in operation
|
||||
|
||||
async def fake_file_existed_in_db(db_id: str, content_hash: str) -> bool:
|
||||
assert db_id == "db_1"
|
||||
assert content_hash
|
||||
return False
|
||||
|
||||
async def fake_get_same_name_files(db_id: str, filename: str) -> list:
|
||||
assert db_id == "db_1"
|
||||
assert filename == "note.md"
|
||||
return []
|
||||
|
||||
async def fake_upload(bucket_name: str, file_name: str, data: bytes) -> str:
|
||||
assert bucket_name == knowledge_router.MinIOClient.KB_BUCKETS["documents"]
|
||||
assert file_name.startswith("db_1/upload/note_")
|
||||
assert data == b"# workspace note\n"
|
||||
return f"http://minio/{bucket_name}/{file_name}"
|
||||
|
||||
monkeypatch.setattr(knowledge_router, "_ensure_database_not_dify", fake_ensure_database_not_dify)
|
||||
monkeypatch.setattr(knowledge_router, "resolve_workspace_file_path", lambda **_kwargs: source)
|
||||
monkeypatch.setattr(knowledge_router.knowledge_base, "file_existed_in_db", fake_file_existed_in_db)
|
||||
monkeypatch.setattr(knowledge_router.knowledge_base, "get_same_name_files", fake_get_same_name_files)
|
||||
monkeypatch.setattr(knowledge_router, "aupload_file_to_minio", fake_upload)
|
||||
|
||||
result = await knowledge_router.import_workspace_files(
|
||||
knowledge_router.WorkspaceImportRequest(db_id="db_1", paths=["/note.md"]),
|
||||
current_user=SimpleNamespace(id="user_1"),
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert len(result["items"]) == 1
|
||||
item = result["items"][0]
|
||||
assert item["file_path"].startswith(
|
||||
f"http://minio/{knowledge_router.MinIOClient.KB_BUCKETS['documents']}/db_1/upload/note_"
|
||||
)
|
||||
assert item["content_hash"]
|
||||
assert item["filename"] == "note.md"
|
||||
assert item["size"] == len(b"# workspace note\n")
|
||||
assert item["workspace_path"] == "/note.md"
|
||||
|
||||
|
||||
async def test_import_workspace_files_rejects_directory(tmp_path, monkeypatch):
|
||||
async def fake_ensure_database_not_dify(db_id: str, operation: str) -> None:
|
||||
return None
|
||||
|
||||
def fake_resolve_workspace_file_path(**_kwargs):
|
||||
raise HTTPException(status_code=400, detail="当前路径不是文件: /folder")
|
||||
|
||||
monkeypatch.setattr(knowledge_router, "_ensure_database_not_dify", fake_ensure_database_not_dify)
|
||||
monkeypatch.setattr(knowledge_router, "resolve_workspace_file_path", fake_resolve_workspace_file_path)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await knowledge_router.import_workspace_files(
|
||||
knowledge_router.WorkspaceImportRequest(db_id="db_1", paths=["/folder"]),
|
||||
current_user=SimpleNamespace(id="user_1"),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "不是文件" in exc_info.value.detail
|
||||
@ -36,7 +36,37 @@
|
||||
|
||||
### 0.6.3 开发记录
|
||||
|
||||
<<<<<<< HEAD
|
||||
<!-- 0.6.3 的内容请放在这里 -->
|
||||
=======
|
||||
<!-- 0.6.2 的内容请放在这里 -->
|
||||
- 删除 Upload 与 LightRAG 图谱/知识库能力:知识库类型收敛为 Milvus 与 Dify,只保留 Milvus 知识库内图谱构建/展示/检索,移除独立 `/graph` 页面和默认上传图谱工具。
|
||||
- 新增知识库 Chunk 持久化:Milvus 知识库索引/更新流程会将 chunks 双写到 PostgreSQL `knowledge_chunks` 表与 Milvus,文件内容查看优先查询 PostgreSQL,并为位置信息、图谱实体关联、标签和抽取结果预留结构化字段。
|
||||
- 完善 Milvus 知识库图谱构建:修复 Chunk 图谱写入返回值、Neo4j 同步写入阻塞事件循环、重复构建任务竞态、图谱查询提前终止、Neo4j 连接复用、LLM 抽取超时重试和前端错误详情展示等问题;图谱构建会将 entity/triple 本体与 chunk 引用写入 PostgreSQL,并为唯一 entity/triple 建立 Milvus 语义索引,单文件删除时同步清理图谱引用和孤儿向量。
|
||||
- 优化图谱抽取器配置:未配置时在图谱中心展示配置入口,抽取器类型改为卡片式选择;LLM 抽取器收敛为固定 Prompt + 自定义 Schema,并支持模型参数与并发队列数;已配置后允许修改同类型参数并提示重置重抽风险,类型本身保持锁定。修复上传并入库新文件时旧内存 metadata 覆盖数据库图谱配置的问题。
|
||||
- 新增 Milvus 图谱检索链路:Query 可召回图谱实体和三元组,结合 Chunk 命中实体构造 seed entity,读取 Neo4j 2-hop 子图后用 igraph 执行 PPR,最终以 Chunk 为产物并通过 RRF 与原 Chunk 召回融合;检索配置改为 dataclass 元数据生成,支持 `depend_on` 控制重排序和图检索参数展示。
|
||||
- 下放扩展管理权限:普通管理员现在可进入扩展管理并完整管理 Tools、MCP、SubAgent、Skills;同步放开 Skill 管理接口权限并补充权限测试。
|
||||
- 调整 Agent 知识库默认选择:未显式配置知识库时默认启用当前用户可访问的全部知识库,显式保存空列表仍表示不启用知识库。
|
||||
- 移除知识库沙盒文件系统映射:不再通过 `/home/gem/kbs` 暴露知识库文件树,Agent 继续使用 `query_kb` 与 `open_kb_document` 访问知识库内容。
|
||||
- 收敛知识库分块配置:分块预设仅表达策略选择,通用分块参数统一通过 `chunk_parser_config` 传递;移除 `chunk_size`、`chunk_overlap`、`qa_separator` 等旧 root 字段兼容。
|
||||
- 收敛知识库文件解析参数:文件级 `processing_params` 统一保存 `ocr_engine` 与 `ocr_engine_config`,解析阶段直接使用该结构并保留分块参数快照。
|
||||
- 修复知识库文件大小显示为 0 的问题:文件上传时 `file_sizes` 参数未正确传播或历史数据缺失导致 DB 中 `file_size` 为 `None`;新增 `MinIOClient.stat_file/astat_file` 获取文件大小方法,`add_file_record` 在 `size` 缺失时从 MinIO 回补,`_load_metadata` 加载元数据后自动为缺少 `size` 的文件从 MinIO 补全并持久化。
|
||||
- 优化评估基准自动生成:仅支持 commonrag/Milvus 知识库,默认参考 chunks 数量改为 1;多 chunk 场景复用知识库向量检索选择相似 chunks,不再对全量 chunks 重新计算 embedding,并移除前端 Embedding 模型选择。
|
||||
- 修复知识库文档入库状态回退:当已解析文件缺失 `markdown_file` 解析产物时,索引流程会将文件状态恢复为未解析,便于重新解析而不是停留在索引失败。
|
||||
- 优化 Agent 输入框文件 mention:用户级 workspace 文件候选改为从独立 workspace API 递归加载,不再依赖 active thread;插入时仍转换为 `/home/gem/user-data/workspace/` 沙盒虚拟路径,并修复附件上传后未立即刷新 mention 候选的问题。
|
||||
- 调整知识库思维导图后端结构:将思维导图路由文件重命名为知识库语义更明确的 router,并把文件列表整理、提示词构建、AI JSON 解析等纯逻辑下沉到知识库 utils。
|
||||
- 收敛知识库评估后端结构:将评估指标、单题评估、答案生成提示词和自动基准生成算法下沉到 `knowledge/eval`,`EvaluationService` 保留任务、文件和持久化编排职责。
|
||||
- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;工作区预览支持 Markdown/TXT 在右侧预览框内切换编辑并保存,其他格式和非工作区预览默认只读;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。
|
||||
- 扩展知识库上传来源:添加“从工作区上传”模式,后端将当前用户工作区文件预处理上传到 MinIO,前端沿用现有 `addDocuments` 入库链路提交 MinIO URL、内容哈希和文件大小。
|
||||
- 加固 JWT 鉴权安全:移除历史默认密钥回退,初始化脚本支持生成并持久化 `JWT_SECRET_KEY` 与 `YUXI_INSTANCE_ID`,签发和验证令牌时校验 `iss/aud`,并在鉴权阶段拒绝已删除或登录锁定用户继续使用旧令牌访问系统。
|
||||
- 扩展管理界面交互逻辑重构:将 MCP / Subagents / Skills 三个标签页从「左侧边栏 + 右侧详情面板」布局重构为「卡片式网格布局 + 路由跳转二级页面」布局,工具标签页改为卡片网格布局 + 弹窗详情(保持弹窗内容不变)。新增共享组件 `ExtensionCard`、`ExtensionCardGrid`、`ExtensionToolbar`、`ExtensionDetailLayout`,详情页(`McpDetailView`、`SubagentDetailView`、`SkillDetailView`)使用居中宽度限制,路由规划为 `/extensions/mcp/:name`、`/extensions/subagent/:name`、`/extensions/skill/:slug`。
|
||||
- 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `<a-tag bordered=false size=small>` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。
|
||||
- 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度,并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开,避免空白区域误触发。
|
||||
- 合并智能体对话导航:移除 `AgentChatComponent` 内部聊天侧边栏,将新建对话入口和对话历史移动到 `AppLayout` 主侧边栏,并通过共享线程 store 统一管理历史列表、当前线程、重命名、删除、置顶和分页加载。
|
||||
- 新增并收敛独立模型配置模块:增加 `model_providers` 表、独立管理接口和”模型配置”页面,运行时 chat / embedding / rerank 均统一从 provider 模块与模型缓存读取 `provider_id:model_id`;旧版静态模型配置、v1 slash spec、旧模型列表接口和 Ollama 适配已移除。远端模型加载默认使用 `/models` 获取 chat/通用模型,provider 声明 `embedding` 能力时使用 `/embeddings/models` 获取 embedding 候选,rerank 模型列表端点按供应商文档显式配置后加载;修复路由请求模型未接收 `embedding_base_url`/`rerank_base_url` 导致前端已填写仍被后端校验拦截的问题。补充手动添加模型能力:`enabled_models[i]` 新增可选 `source: "manual"|"remote"` 字段(默认 `remote`),管理员可通过”+ 手动添加”入口录入远端清单未覆盖的模型(典型:自部署 embedding/rerank),手动模型在前端跳过”远端不存在”的 stale 警告并显示「手动」标签;type 选项受 `provider.capabilities` 约束,后端在 `_normalize_payload` 与 `update_provider_config` 双层一致性校验中拦截越权写入。
|
||||
- 统一前端 Markdown 预览渲染:新增共享 `MarkdownPreview` 组件与 `markdown_preview` 渲染工具,替换 Agent 消息、文件预览、知识库 chunk、任务工具结果、聊天导出等场景中的旧 `md-editor-v3/marked` 预览;支持 KaTeX、任务列表、frontmatter 卡片、Shiki 代码高亮、DOMPurify 清洗和浅层渲染缓存,并抽取 HTML 转义与代码语言归一化工具。Skill 详情页复用 `AgentFilePreview`,统一文件预览、编辑、保存和全屏交互。
|
||||
- 优化远程 Skill 批量安装:`remote_skill_install_service.py` 新增 `install_remote_skills_batch()`,利用 `npx skills add --skill A --skill B --skill C` 原生多 skill 支持,将安装 N 个 skill 的仓库克隆次数从 2N 降至 1;配套新增路由 `POST /remote/install-batch`、前端 `installRemoteSkillsBatch()` API 方法和批处理 UI 逻辑
|
||||
>>>>>>> cc0b2186 (feat(kb): 新增从工作区导入文件功能,优化图谱配置面板与并发上限)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -321,6 +321,19 @@ export const fileApi = {
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 从工作区导入文件到知识库 MinIO 暂存区
|
||||
* @param {string} dbId - 知识库 ID
|
||||
* @param {Array<string>} paths - 工作区文件路径
|
||||
* @returns {Promise} - 导入结果
|
||||
*/
|
||||
importWorkspaceFiles: async (dbId, paths) => {
|
||||
return apiAdminPost('/api/knowledge/files/import-workspace', {
|
||||
db_id: dbId,
|
||||
paths
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param {File} file - 文件对象
|
||||
|
||||
@ -126,7 +126,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 文件上传区域 -->
|
||||
<div class="upload-area" v-if="uploadMode !== 'url'">
|
||||
<div class="upload-area" v-if="uploadMode === 'file' || uploadMode === 'folder'">
|
||||
<a-upload-dragger
|
||||
class="custom-dragger"
|
||||
v-model:fileList="fileList"
|
||||
@ -198,6 +198,73 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工作区文件选择区域 -->
|
||||
<div class="workspace-area" v-if="uploadMode === 'workspace'">
|
||||
<div class="workspace-toolbar">
|
||||
<div class="workspace-summary">
|
||||
<FolderOpen :size="16" />
|
||||
<span class="workspace-current-path" :title="workspaceCurrentPath">
|
||||
{{ workspaceCurrentPath }}
|
||||
</span>
|
||||
<span>已选择 {{ selectedWorkspacePaths.length }} 个文件,注意上传会扁平化上传,不保留文件层级结构</span>
|
||||
</div>
|
||||
<div class="workspace-actions">
|
||||
<a-button
|
||||
size="small"
|
||||
class="lucide-icon-btn"
|
||||
:disabled="workspaceCurrentPath === '/' || workspaceLoading"
|
||||
@click="openWorkspaceParent"
|
||||
>
|
||||
<ArrowLeft :size="14" />
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
@click="loadWorkspaceFiles()"
|
||||
:loading="workspaceLoading"
|
||||
class="lucide-icon-btn"
|
||||
>
|
||||
<RotateCw :size="14" />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workspace-list" v-if="workspaceItems.length > 0">
|
||||
<button
|
||||
v-for="item in workspaceDirectoryItems"
|
||||
:key="item.path"
|
||||
type="button"
|
||||
class="workspace-item workspace-directory"
|
||||
:disabled="chunkLoading"
|
||||
@click="openWorkspaceDirectory(item.path)"
|
||||
>
|
||||
<a-checkbox disabled />
|
||||
<Folder :size="14" class="workspace-file-icon" />
|
||||
<span class="workspace-file-name" :title="item.path">{{ item.name }}</span>
|
||||
</button>
|
||||
|
||||
<label
|
||||
v-for="item in workspaceFileItems"
|
||||
:key="item.path"
|
||||
class="workspace-item"
|
||||
:class="{ disabled: !item.supported }"
|
||||
>
|
||||
<a-checkbox
|
||||
:checked="selectedWorkspacePathSet.has(item.path)"
|
||||
:disabled="!item.supported || chunkLoading"
|
||||
@change="toggleWorkspacePath(item.path, $event.target.checked)"
|
||||
/>
|
||||
<FileText :size="14" class="workspace-file-icon" />
|
||||
<span class="workspace-file-name" :title="item.path">{{ item.path }}</span>
|
||||
<span class="workspace-file-size">{{ formatFileSize(item.size) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="url-empty-tip" v-else>
|
||||
<Info :size="16" />
|
||||
<span>{{ workspaceLoading ? '正在加载工作区文件' : '当前目录暂无文件' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL 输入区域 -->
|
||||
<div class="url-area" v-if="uploadMode === 'url'">
|
||||
<div class="url-input-wrapper">
|
||||
@ -296,10 +363,15 @@ import { useUserStore } from '@/stores/user'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import { ocrApi } from '@/apis/system_api'
|
||||
import { fileApi, documentApi } from '@/apis/knowledge_api'
|
||||
import { getWorkspaceTree } from '@/apis/workspace_api'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import {
|
||||
FileUp,
|
||||
FolderUp,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
ArrowLeft,
|
||||
RotateCw,
|
||||
CircleHelp,
|
||||
Info,
|
||||
@ -367,6 +439,9 @@ watch(
|
||||
selectedFolderId.value = props.currentFolderId
|
||||
isFolderUpload.value = props.isFolderMode
|
||||
uploadMode.value = props.mode || (props.isFolderMode ? 'folder' : 'file')
|
||||
if (uploadMode.value === 'workspace') {
|
||||
loadWorkspaceFiles()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -515,6 +590,9 @@ const canSubmit = computed(() => {
|
||||
if (uploadMode.value === 'url') {
|
||||
return urlList.value.some((item) => item.status === 'success')
|
||||
}
|
||||
if (uploadMode.value === 'workspace') {
|
||||
return selectedWorkspacePaths.value.length > 0 && !workspaceLoading.value
|
||||
}
|
||||
return successUploadCount.value > 0 && !hasPendingUploads.value
|
||||
})
|
||||
|
||||
@ -539,6 +617,13 @@ const uploadModeOptions = computed(() => [
|
||||
h(Link, { size: 16, class: 'option-icon' }),
|
||||
h('span', { class: 'option-text' }, '解析 URL')
|
||||
])
|
||||
},
|
||||
{
|
||||
value: 'workspace',
|
||||
label: h('div', { class: 'segmented-option' }, [
|
||||
h(FolderOpen, { size: 16, class: 'option-icon' }),
|
||||
h('span', { class: 'option-text' }, '工作区')
|
||||
])
|
||||
}
|
||||
])
|
||||
|
||||
@ -549,6 +634,9 @@ watch(uploadMode, (val) => {
|
||||
sameNameFiles.value = []
|
||||
urlList.value = []
|
||||
newUrl.value = ''
|
||||
selectedWorkspacePaths.value = []
|
||||
workspaceCurrentPath.value = '/'
|
||||
workspaceItems.value = []
|
||||
for (const task of uploadQueue.value) {
|
||||
task.canceled = true
|
||||
}
|
||||
@ -556,6 +644,9 @@ watch(uploadMode, (val) => {
|
||||
uploadTaskStatus.value = {}
|
||||
uploadTaskProgress.value = {}
|
||||
progressExpanded.value = false
|
||||
if (val === 'workspace') {
|
||||
loadWorkspaceFiles('/')
|
||||
}
|
||||
})
|
||||
|
||||
watch(fileList, (newFileList) => {
|
||||
@ -671,6 +762,69 @@ const removeUrl = (index) => {
|
||||
urlList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 工作区文件选择
|
||||
const workspaceLoading = ref(false)
|
||||
const workspaceItems = ref([])
|
||||
const workspaceCurrentPath = ref('/')
|
||||
const selectedWorkspacePaths = ref([])
|
||||
const selectedWorkspacePathSet = computed(() => new Set(selectedWorkspacePaths.value))
|
||||
const workspaceDirectoryItems = computed(() => workspaceItems.value.filter((entry) => entry.is_dir))
|
||||
const workspaceFileItems = computed(() =>
|
||||
workspaceItems.value
|
||||
.filter((entry) => !entry.is_dir)
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
supported: isSupportedExtension(entry.name || entry.path)
|
||||
}))
|
||||
)
|
||||
|
||||
const formatFileSize = (size) => {
|
||||
if (!Number.isFinite(size)) return '-'
|
||||
if (size < 1024) return `${size} B`
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const loadWorkspaceFiles = async (path = workspaceCurrentPath.value) => {
|
||||
if (workspaceLoading.value) return
|
||||
const targetPath = typeof path === 'string' ? path : workspaceCurrentPath.value
|
||||
|
||||
workspaceLoading.value = true
|
||||
try {
|
||||
const data = await getWorkspaceTree(targetPath, false, false)
|
||||
const entries = Array.isArray(data?.entries) ? data.entries : []
|
||||
workspaceCurrentPath.value = targetPath
|
||||
workspaceItems.value = entries
|
||||
} catch (error) {
|
||||
console.error('加载工作区文件失败:', error)
|
||||
message.error('加载工作区文件失败: ' + (error.message || '未知错误'))
|
||||
} finally {
|
||||
workspaceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openWorkspaceDirectory = (path) => {
|
||||
loadWorkspaceFiles(path)
|
||||
}
|
||||
|
||||
const openWorkspaceParent = () => {
|
||||
if (workspaceCurrentPath.value === '/') return
|
||||
const normalized = workspaceCurrentPath.value.replace(/\/$/, '')
|
||||
const index = normalized.lastIndexOf('/')
|
||||
const parentPath = index <= 0 ? '/' : normalized.slice(0, index)
|
||||
loadWorkspaceFiles(parentPath)
|
||||
}
|
||||
|
||||
const toggleWorkspacePath = (path, checked) => {
|
||||
if (checked) {
|
||||
if (!selectedWorkspacePaths.value.includes(path)) {
|
||||
selectedWorkspacePaths.value = [...selectedWorkspacePaths.value, path]
|
||||
}
|
||||
return
|
||||
}
|
||||
selectedWorkspacePaths.value = selectedWorkspacePaths.value.filter((item) => item !== path)
|
||||
}
|
||||
|
||||
// OCR服务健康状态
|
||||
const ocrHealthStatus = ref({
|
||||
rapid_ocr: { status: 'unknown', message: '' },
|
||||
@ -1204,6 +1358,68 @@ const chunkData = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (uploadMode.value === 'workspace') {
|
||||
if (selectedWorkspacePaths.value.length === 0) {
|
||||
message.error('请先选择工作区文件')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
store.state.chunkLoading = true
|
||||
const res = await fileApi.importWorkspaceFiles(databaseId.value, selectedWorkspacePaths.value)
|
||||
const importedItems = Array.isArray(res?.items) ? res.items : []
|
||||
if (importedItems.length === 0) {
|
||||
message.error('工作区文件导入失败')
|
||||
return
|
||||
}
|
||||
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif']
|
||||
const items = []
|
||||
const content_hashes = {}
|
||||
const file_sizes = {}
|
||||
for (const item of importedItems) {
|
||||
const filePath = item.file_path
|
||||
if (!filePath) continue
|
||||
items.push(filePath)
|
||||
if (item.content_hash) content_hashes[filePath] = item.content_hash
|
||||
if (Number.isFinite(item.size)) file_sizes[filePath] = item.size
|
||||
mergeSameNameFiles(item.same_name_files)
|
||||
|
||||
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase()
|
||||
if (imageExtensions.includes(ext) && !isOcrEnabled.value) {
|
||||
message.error({
|
||||
content: '检测到图片文件,必须启用 OCR 才能提取文本内容。',
|
||||
duration: 5
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const params = { ...processingParams.value, content_hashes, file_sizes }
|
||||
if (autoIndex.value) {
|
||||
params.auto_index = true
|
||||
Object.assign(params, buildAutoIndexParams())
|
||||
}
|
||||
|
||||
await store.addFiles({
|
||||
items,
|
||||
contentType: 'file',
|
||||
params,
|
||||
parentId: selectedFolderId.value
|
||||
})
|
||||
|
||||
emit('success')
|
||||
handleCancel()
|
||||
selectedWorkspacePaths.value = []
|
||||
} catch (error) {
|
||||
console.error('工作区文件导入失败:', error)
|
||||
message.error('工作区文件导入失败: ' + (error.message || '未知错误'))
|
||||
} finally {
|
||||
store.state.chunkLoading = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// URL 模式处理
|
||||
if (uploadMode.value === 'url') {
|
||||
// 过滤出成功的项
|
||||
@ -1777,6 +1993,107 @@ const chunkData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/* Workspace Area */
|
||||
.workspace-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.workspace-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.workspace-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--gray-700);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-current-path {
|
||||
max-width: 360px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.workspace-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workspace-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background: var(--gray-0);
|
||||
}
|
||||
|
||||
.workspace-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-directory {
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
.workspace-file-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--main-500);
|
||||
}
|
||||
|
||||
.workspace-file-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: var(--gray-700);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-file-size {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* URL Area */
|
||||
.url-area {
|
||||
flex: 1;
|
||||
|
||||
@ -128,7 +128,7 @@ const filteredEdgeProperties = computed(() => {
|
||||
<style scoped lang="less">
|
||||
.detail-panel {
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
top: 60px;
|
||||
left: 10px;
|
||||
width: 280px;
|
||||
max-height: calc(100% - 60px);
|
||||
@ -139,6 +139,7 @@ const filteredEdgeProperties = computed(() => {
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--gray-100);
|
||||
box-shadow: 0 0 4px 0px var(--shadow-2);
|
||||
font-size: 13px;
|
||||
|
||||
.panel-header {
|
||||
|
||||
@ -121,12 +121,12 @@
|
||||
<!-- 索引管理浮动面板 -->
|
||||
<transition name="slide-fade">
|
||||
<div v-if="isMilvus && showBuildPanel" class="floating-panel build-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">索引管理</span>
|
||||
<a-button size="small" type="text" :disabled="graphBuildLoading" @click="loadGraphBuildStatus" class="panel-refresh-btn">
|
||||
<RefreshCw :size="14" :class="{ spin: graphBuildLoading }" />
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">索引管理</span>
|
||||
<a-button size="small" type="text" :disabled="graphBuildLoading" @click="loadGraphBuildStatus" class="panel-refresh-btn">
|
||||
<RefreshCw :size="14" :class="{ spin: graphBuildLoading }" />
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="status-row">
|
||||
<span class="status-label">状态</span>
|
||||
@ -216,8 +216,7 @@
|
||||
class="config-warning"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="修改抽取配置会影响后续图谱构建质量"
|
||||
description="已经构建的图谱不会自动按新配置重算。如果希望整体一致,建议先重置图谱并重新抽取。抽取器类型创建后不可修改。"
|
||||
message="修改配置仅影响后续构建;已构建的图谱不会自动重算,如需一致请重置后重新抽取。抽取器类型创建后不可修改。"
|
||||
/>
|
||||
<a-form-item label="抽取器类型">
|
||||
<div class="extractor-type-cards">
|
||||
@ -256,7 +255,7 @@
|
||||
<a-input-number
|
||||
v-model:value="graphConfigForm.concurrency_count"
|
||||
:min="1"
|
||||
:max="20"
|
||||
:max="1000"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
@ -490,6 +489,8 @@ const buildExtractorOptions = () => {
|
||||
|
||||
const configureGraphBuild = async () => {
|
||||
try {
|
||||
document.activeElement?.blur()
|
||||
await nextTick()
|
||||
await graphBuildApi.configure(databaseId.value, {
|
||||
extractor_type: graphConfigForm.extractor_type,
|
||||
extractor_options: buildExtractorOptions()
|
||||
@ -720,6 +721,7 @@ onUnmounted(() => {
|
||||
padding: 2px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 4px 0px var(--shadow-2);
|
||||
border: 1px solid var(--gray-100);
|
||||
}
|
||||
|
||||
:deep(.ant-input-affix-wrapper) {
|
||||
@ -804,7 +806,7 @@ onUnmounted(() => {
|
||||
|
||||
.floating-panel {
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
top: 60px;
|
||||
right: 10px;
|
||||
width: 300px;
|
||||
max-height: calc(100% - 60px);
|
||||
@ -815,6 +817,7 @@ onUnmounted(() => {
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--gray-100);
|
||||
box-shadow: 0 0 4px 0px var(--shadow-2);
|
||||
font-size: 13px;
|
||||
|
||||
.panel-header {
|
||||
|
||||
@ -18,7 +18,7 @@ export const getKbTypeIcon = (type) => {
|
||||
|
||||
export const getKbTypeColor = (type) => {
|
||||
const colors = {
|
||||
milvus: 'red',
|
||||
milvus: 'blue',
|
||||
dify: 'gold'
|
||||
}
|
||||
return colors[type] || 'blue'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user