diff --git a/backend/package/yuxi/knowledge/graphs/extractors/llm.py b/backend/package/yuxi/knowledge/graphs/extractors/llm.py index c99f45fe..b7e4e2c2 100644 --- a/backend/package/yuxi/knowledge/graphs/extractors/llm.py +++ b/backend/package/yuxi/knowledge/graphs/extractors/llm.py @@ -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 必须是对象") diff --git a/backend/package/yuxi/knowledge/graphs/milvus_graph_service.py b/backend/package/yuxi/knowledge/graphs/milvus_graph_service.py index 83752cc4..3a5cd91d 100644 --- a/backend/package/yuxi/knowledge/graphs/milvus_graph_service.py +++ b/backend/package/yuxi/knowledge/graphs/milvus_graph_service.py @@ -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]: diff --git a/backend/package/yuxi/services/workspace_service.py b/backend/package/yuxi/services/workspace_service.py index 0b64db0b..bdf2556c 100644 --- a/backend/package/yuxi/services/workspace_service.py +++ b/backend/package/yuxi/services/workspace_service.py @@ -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(): diff --git a/backend/server/routers/knowledge_router.py b/backend/server/routers/knowledge_router.py index 04c9a2f9..38514b91 100644 --- a/backend/server/routers/knowledge_router.py +++ b/backend/server/routers/knowledge_router.py @@ -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(...), diff --git a/backend/test/unit/routers/test_knowledge_workspace_import.py b/backend/test/unit/routers/test_knowledge_workspace_import.py new file mode 100644 index 00000000..6d10a6e8 --- /dev/null +++ b/backend/test/unit/routers/test_knowledge_workspace_import.py @@ -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 diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 5a11366c..b05a8029 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -36,7 +36,37 @@ ### 0.6.3 开发记录 +<<<<<<< HEAD +======= + +- 删除 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}]` 数组,内部使用 `` 渲染,与知识库卡片标签风格统一;知识库列表页 `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): 新增从工作区导入文件功能,优化图谱配置面板与并发上限) --- diff --git a/web/src/apis/knowledge_api.js b/web/src/apis/knowledge_api.js index 61a2f380..4452b95f 100644 --- a/web/src/apis/knowledge_api.js +++ b/web/src/apis/knowledge_api.js @@ -321,6 +321,19 @@ export const fileApi = { }) }, + /** + * 从工作区导入文件到知识库 MinIO 暂存区 + * @param {string} dbId - 知识库 ID + * @param {Array} paths - 工作区文件路径 + * @returns {Promise} - 导入结果 + */ + importWorkspaceFiles: async (dbId, paths) => { + return apiAdminPost('/api/knowledge/files/import-workspace', { + db_id: dbId, + paths + }) + }, + /** * 上传文件 * @param {File} file - 文件对象 diff --git a/web/src/components/FileUploadModal.vue b/web/src/components/FileUploadModal.vue index c4c5c857..5e30ed8a 100644 --- a/web/src/components/FileUploadModal.vue +++ b/web/src/components/FileUploadModal.vue @@ -126,7 +126,7 @@ -
+
+ +
+
+
+ + + {{ workspaceCurrentPath }} + + 已选择 {{ selectedWorkspacePaths.length }} 个文件,注意上传会扁平化上传,不保留文件层级结构 +
+
+ + + + + + +
+
+ +
+ + + +
+ +
+ + {{ workspaceLoading ? '正在加载工作区文件' : '当前目录暂无文件' }} +
+
+
@@ -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; diff --git a/web/src/components/GraphDetailPanel.vue b/web/src/components/GraphDetailPanel.vue index 88283a48..fb5f94a1 100644 --- a/web/src/components/GraphDetailPanel.vue +++ b/web/src/components/GraphDetailPanel.vue @@ -128,7 +128,7 @@ const filteredEdgeProperties = computed(() => {