From d0c60d9d82f2c323c924e7a62785067431b91f6e Mon Sep 17 00:00:00 2001 From: supreme0597 Date: Thu, 21 May 2026 23:06:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(mention):=20=E4=BC=98=E5=8C=96@=E6=8F=90?= =?UTF-8?q?=E5=8F=8A=E6=90=9C=E7=B4=A2=E6=8E=92=E5=BA=8F=E4=B8=8E=E9=AB=98?= =?UTF-8?q?=E4=BA=AE=E5=B1=95=E7=A4=BA=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=94=A4?= =?UTF-8?q?=E9=86=92=E4=BA=A4=E4=BA=92=E5=8F=8A=E8=B7=AF=E5=BE=84=E7=A1=AC?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../package/yuxi/knowledge/eval/metrics.py | 3 +- .../yuxi/knowledge/utils/mindmap_utils.py | 1 - .../yuxi/services/mention_search_service.py | 240 ++++++++++++++ backend/server/routers/__init__.py | 2 + backend/server/routers/mention_router.py | 25 ++ .../services/test_mention_search_service.py | 212 +++++++++++++ web/src/apis/base.js | 4 +- web/src/apis/index.js | 1 + web/src/apis/mention_api.js | 8 + web/src/components/AgentChatComponent.vue | 43 +-- web/src/components/AgentConfigSidebar.vue | 5 +- web/src/components/AgentFilePreview.vue | 1 - web/src/components/AgentInputArea.vue | 2 + web/src/components/AgentMessageComponent.vue | 14 +- web/src/components/AgentPanel.vue | 5 - web/src/components/EvaluationBenchmarks.vue | 5 +- web/src/components/MessageInputComponent.vue | 297 +++++++++++++++--- web/src/components/RAGEvaluationTab.vue | 1 - .../components/extensions/SkillCardList.vue | 1 - .../components/extensions/SkillDetailView.vue | 1 - .../modals/BenchmarkGenerateModal.vue | 7 +- .../components/sources/KbChunkDetailModal.vue | 6 +- web/src/composables/useAgentMentionConfig.js | 29 -- web/src/stores/chatUI.js | 130 ++++---- web/src/utils/file_utils.js | 94 ++++-- web/src/views/HomeView.vue | 4 +- 26 files changed, 920 insertions(+), 221 deletions(-) create mode 100644 backend/package/yuxi/services/mention_search_service.py create mode 100644 backend/server/routers/mention_router.py create mode 100644 backend/test/unit/services/test_mention_search_service.py create mode 100644 web/src/apis/mention_api.js diff --git a/backend/package/yuxi/knowledge/eval/metrics.py b/backend/package/yuxi/knowledge/eval/metrics.py index 55b0c09c..b8342584 100644 --- a/backend/package/yuxi/knowledge/eval/metrics.py +++ b/backend/package/yuxi/knowledge/eval/metrics.py @@ -3,10 +3,11 @@ RAG评估指标计算工具 简化版:只保留Recall/F1(检索)和 LLM Judge(答案准确性) """ -import json_repair import textwrap from typing import Any +import json_repair + from yuxi.utils import logger diff --git a/backend/package/yuxi/knowledge/utils/mindmap_utils.py b/backend/package/yuxi/knowledge/utils/mindmap_utils.py index beee0ca3..bfe71b40 100644 --- a/backend/package/yuxi/knowledge/utils/mindmap_utils.py +++ b/backend/package/yuxi/knowledge/utils/mindmap_utils.py @@ -4,7 +4,6 @@ import json import textwrap from typing import Any - MINDMAP_SYSTEM_PROMPT = """你是一个专业的知识整理助手。 你的任务是分析用户提供的文件列表,生成一个层次分明的思维导图结构。 diff --git a/backend/package/yuxi/services/mention_search_service.py b/backend/package/yuxi/services/mention_search_service.py new file mode 100644 index 00000000..272f2a29 --- /dev/null +++ b/backend/package/yuxi/services/mention_search_service.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import asyncio +import os +from pathlib import Path + +import ormsgpack +from yuxi.agents.backends.sandbox.paths import ( + ensure_thread_dirs, + sandbox_outputs_dir, + sandbox_uploads_dir, + sandbox_workspace_dir, +) +from yuxi.config.app import config +from yuxi.services.run_queue_service import get_redis_client +from yuxi.utils.logging_config import logger + +MENTION_EXCLUDE_DIRS = { + ".git", + "node_modules", + ".venv", + "venv", + "__pycache__", + ".idea", + ".vscode", + "dist", + "build", + ".tox", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", +} + +MAX_MENTION_RESULTS = 50 +MAX_ENTRIES_PER_DIR = 500 +MAX_SEARCH_DEPTH = 15 +CACHE_TTL = 60 # 缓存有效期 60 秒 +MAX_CACHED_ENTRIES = 100000 +REDIS_KEY_PREFIX = "yuxi:mention:cache:" + + +def _scan_pruned_files(root: Path, max_entries: int) -> list[tuple[str, str]]: + """ + 同步扫描磁盘文件目录并进行多重限额剪枝保护 (防止大文件仓库卡死) + """ + results: list[tuple[str, str]] = [] + if not root.exists(): + return results + + root_str = str(root) + for dirpath, dirnames, filenames in os.walk(root_str): + # 1. 剪枝黑名单和隐藏目录 (直接在 dirnames 中修改,阻止 os.walk 深入) + dirnames[:] = [d for d in dirnames if d not in MENTION_EXCLUDE_DIRS and not d.startswith(".")] + + # 2. 深度保护:限制最大搜索深度 + try: + rel = Path(dirpath).relative_to(root) + if len(rel.parts) > MAX_SEARCH_DEPTH: + dirnames.clear() + continue + except Exception: + pass + + # 3. 宽度与全局限额保护下的合格“子目录实体”收集 + for dirname in dirnames: + full_dir_path = Path(dirpath) / dirname + rel_dir_path = full_dir_path.relative_to(root).as_posix() + + # 使用以 '/' 结尾的虚拟相对路径,代表这是一个目录 + virtual_dir_path = f"{rel_dir_path}/" + results.append((dirname, virtual_dir_path)) + + if len(results) >= max_entries: + return results + + # 4. 宽度限额保护:单层目录限制最多只读取 500 个文件,防止扁平超宽目录卡死 + scan_filenames = filenames[:MAX_ENTRIES_PER_DIR] + for filename in scan_filenames: + full_path = Path(dirpath) / filename + # 计算相对于根路径的相对路径 + rel_path = full_path.relative_to(root).as_posix() + + # 存为紧凑型元组 (filename, relative_path) + results.append((filename, rel_path)) + + # 5. 全局上限保护:如果总文件数已达上限,熔断退出 + if len(results) >= max_entries: + return results + + return results + + +async def get_or_build_file_index( + thread_id: str, + user_id: str, +) -> list[tuple[str, str]]: + """ + 获取或构建当前 Workspace 和 Thread 的提及文件索引缓存 (使用 ormsgpack 二进制序列化) + """ + ensure_thread_dirs(thread_id, user_id) + + redis = await get_redis_client() + redis_key = f"{REDIS_KEY_PREFIX}{thread_id}" + + # NOTE: 项目全局 Redis 客户端配置了 decode_responses=True, + # 为了在上面安全地存储 ormsgpack 产生的二进制 bytes, + # 我们使用极速且无损的 latin1 (ISO-8859-1) 进行单字节字符互转。 + # 这在 Python 底层由 C 引擎执行,体积完全不膨胀,速度极快,且不需要新建不带 decode 限制的 Redis 连接。 + cached_str = await redis.get(redis_key) + if cached_str: + try: + packed_bytes = cached_str.encode("latin1") + return ormsgpack.unpackb(packed_bytes) + except Exception as e: + logger.warning(f"Failed to unpack mention cache for thread {thread_id}: {e}") + + # 缓存未命中,在 asyncio.to_thread 线程池中执行阻塞的 os.walk 磁盘扫描 + roots_with_prefixes = [ + ("workspace", sandbox_workspace_dir(thread_id, user_id)), + ("uploads", sandbox_uploads_dir(thread_id)), + ("outputs", sandbox_outputs_dir(thread_id)), + ] + + entries: list[tuple[str, str]] = [] + for prefix, root in roots_with_prefixes: + needed = MAX_CACHED_ENTRIES - len(entries) + if needed <= 0: + break + + # 使用 to_thread 避免 os.walk 阻塞 FastAPI 事件循环 + scan_results = await asyncio.to_thread(_scan_pruned_files, root, needed) + + # 加上虚拟文件系统前缀,例如 "workspace/src/main.py" + for name, rel_path in scan_results: + virtual_rel_path = f"{prefix}/{rel_path}" if rel_path and rel_path != "." else prefix + entries.append((name, virtual_rel_path)) + + # 写入 Redis 缓存 + try: + packed_bytes = ormsgpack.packb(entries) + packed_str = packed_bytes.decode("latin1") + await redis.set(redis_key, packed_str, ex=CACHE_TTL) + except Exception as e: + logger.warning(f"Failed to write mention cache for thread {thread_id}: {e}") + + return entries + + +async def search_mention_files_in_index( + thread_id: str, + user_id: str, + query: str, +) -> list[dict]: + """ + 高效的基于文件名/目录名权重与排序的模糊搜索算法 (彻底消除纯路径抢占,置顶核心匹配项) + """ + index = await get_or_build_file_index(thread_id, user_id) + if not index: + return [] + + query_lower = query.lower() + + prefix = (config.sandbox_virtual_path_prefix or "/home/gem/user-data").rstrip("/") + + # 存储加权匹配结果 + name_matched = [] # 文件名/目录名直接匹配的项 (高分) + path_matched = [] # 仅路径匹配的项 (低分,作为兜底) + + for name, virtual_path in index: + name_lower = name.lower() + path_lower = virtual_path.lower() + is_dir = virtual_path.endswith("/") + + # 1. 优先判定名称是否包含关键字 (置顶) + if query_lower in name_lower: + if name_lower == query_lower: + score = 1000.0 # 完全匹配 + else: + score = 500.0 # 基础名称匹配分 + + # 附加前缀优势 + if name_lower.startswith(query_lower): + score += 50.0 + + # 附加后缀优势 + if name_lower.endswith(query_lower): + score += 20.0 + + # 位置惩罚:匹配位置越靠后,给与轻微扣分 (最高扣 30 分) + start_idx = name_lower.find(query_lower) + if start_idx != -1: + score -= min(start_idx, 30.0) + + # 长度惩罚:文件名越长,扣分越多 (最高扣 50 分,以优先展示简短、高信息密度的核心文件) + score -= min(len(name) * 0.5, 50.0) + + name_matched.append({"name": name, "path": f"{prefix}/{virtual_path}", "is_dir": is_dir, "score": score}) + + # 2. 其次判定是否为纯路径匹配 (名称不匹配,但路径中包含) + elif query_lower in path_lower: + score = 10.0 + # 路径长度惩罚 + score -= min(len(virtual_path) * 0.1, 5.0) + path_matched.append( + { + "name": name, + "path": f"{prefix}/{virtual_path}", + "is_dir": is_dir, + "score": score, + } + ) + + # 对名称直接匹配项按照打分降序进行精准排序 (打分已融合位置与长度惩罚) + name_matched.sort(key=lambda x: -x["score"]) + + # 智能融合:如果名称匹配项不足 MAX_MENTION_RESULTS,用路径匹配项兜底填补 + merged_results = name_matched + if len(merged_results) < MAX_MENTION_RESULTS: + # 对路径匹配项按路径长度进行升序排序 (通常短路径更直观) + path_matched.sort(key=lambda x: len(x["path"])) + needed = MAX_MENTION_RESULTS - len(merged_results) + merged_results.extend(path_matched[:needed]) + + # 截取前 MAX_MENTION_RESULTS 项并还原为前端格式,附加 is_dir 属性以识别目录 + return [ + {"name": item["name"], "path": item["path"], "is_dir": item["is_dir"]} + for item in merged_results[:MAX_MENTION_RESULTS] + ] + + +async def invalidate_mention_cache(thread_id: str) -> None: + """ + 轻量级缓存清理工具函数,主动清除指定 thread 的提及缓存 + """ + try: + redis = await get_redis_client() + redis_key = f"{REDIS_KEY_PREFIX}{thread_id}" + await redis.delete(redis_key) + except Exception as e: + logger.warning(f"Failed to invalidate mention cache for thread {thread_id}: {e}") diff --git a/backend/server/routers/__init__.py b/backend/server/routers/__init__.py index fefc6ce8..bf998c29 100644 --- a/backend/server/routers/__init__.py +++ b/backend/server/routers/__init__.py @@ -16,6 +16,7 @@ from server.routers.tool_router import tools from server.routers.auth_apikey_router import apikey_router from server.routers.filesystem_router import filesystem_router from server.routers.workspace_router import workspace +from server.routers.mention_router import mention_router _LITE_MODE = os.environ.get("LITE_MODE", "").lower() in ("true", "1") @@ -38,6 +39,7 @@ router.include_router(tools) # /api/system/tools/* 工具列表与配置 router.include_router(apikey_router) # /api/apikey/* API Key 管理 router.include_router(filesystem_router) # /api/viewer/filesystem/* 工作台文件系统视图 router.include_router(workspace) # /api/workspace/* 用户个人工作区 +router.include_router(mention_router) # /api/mention/* 提及文件搜索接口 if not _LITE_MODE: from server.routers.graph_router import graph diff --git a/backend/server/routers/mention_router.py b/backend/server/routers/mention_router.py new file mode 100644 index 00000000..bc0d66b4 --- /dev/null +++ b/backend/server/routers/mention_router.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +from server.utils.auth_middleware import get_required_user +from yuxi.services.mention_search_service import search_mention_files_in_index +from yuxi.storage.postgres.models_business import User + +mention_router = APIRouter(prefix="/mention", tags=["mention"]) + + +@mention_router.get("/search", response_model=list[dict]) +async def search_mention_files( + thread_id: str = Query(..., description="当前聊天会话 ID"), + query: str = Query("", description="模糊搜索关键字"), + current_user: User = Depends(get_required_user), +): + """ + 提及文件模糊搜索接口:使用 Redis 二进制缓存进行极速过滤,防止大文件递归卡死。 + """ + user_id = str(current_user.id) + return await search_mention_files_in_index( + thread_id=thread_id, + user_id=user_id, + query=query, + ) diff --git a/backend/test/unit/services/test_mention_search_service.py b/backend/test/unit/services/test_mention_search_service.py new file mode 100644 index 00000000..602425c5 --- /dev/null +++ b/backend/test/unit/services/test_mention_search_service.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from pathlib import Path +import pytest + +import ormsgpack +import yuxi.services.mention_search_service as mention_service + + +class _FakeRedis: + def __init__(self): + self.data: dict[str, str] = {} + self.expire_calls: dict[str, int] = {} + self.delete_calls: list[str] = [] + + async def get(self, key: str) -> str | None: + return self.data.get(key) + + async def set(self, key: str, value: str, ex: int | None = None) -> None: + self.data[key] = value + if ex is not None: + self.expire_calls[key] = ex + + async def delete(self, key: str) -> None: + self.delete_calls.append(key) + self.data.pop(key, None) + + +@pytest.fixture +def mock_sandbox_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + # 创建模拟的工作区、上传、输出目录 + workspace_dir = tmp_path / "shared" / "user_1" / "workspace" + uploads_dir = tmp_path / "threads" / "thread_1" / "user-data" / "uploads" + outputs_dir = tmp_path / "threads" / "thread_1" / "user-data" / "outputs" + + workspace_dir.mkdir(parents=True, exist_ok=True) + uploads_dir.mkdir(parents=True, exist_ok=True) + outputs_dir.mkdir(parents=True, exist_ok=True) + + # Mock sandbox_paths 的函数 + monkeypatch.setattr(mention_service, "sandbox_workspace_dir", lambda t, u: workspace_dir) + monkeypatch.setattr(mention_service, "sandbox_uploads_dir", lambda t: uploads_dir) + monkeypatch.setattr(mention_service, "sandbox_outputs_dir", lambda t: outputs_dir) + monkeypatch.setattr(mention_service, "ensure_thread_dirs", lambda t, u: None) + + return { + "workspace": workspace_dir, + "uploads": uploads_dir, + "outputs": outputs_dir, + } + + +@pytest.fixture +def fake_redis(monkeypatch: pytest.MonkeyPatch) -> _FakeRedis: + redis = _FakeRedis() + async def mock_get_redis(): + return redis + monkeypatch.setattr(mention_service, "get_redis_client", mock_get_redis) + return redis + + +@pytest.mark.asyncio +async def test_scan_pruned_files_and_exclude_dirs(mock_sandbox_paths): + workspace = mock_sandbox_paths["workspace"] + + # 创建常规文件 + (workspace / "main.py").write_text("print('hello')") + (workspace / "utils.py").write_text("def run(): pass") + + # 创建被排除的目录和文件 + git_dir = workspace / ".git" + git_dir.mkdir() + (git_dir / "config").write_text("[core]") + + node_modules = workspace / "node_modules" + node_modules.mkdir() + (node_modules / "express.js").write_text("module.exports = {}") + + # 扫描 + results = mention_service._scan_pruned_files(workspace, 100) + + # 校验 + files = {name for name, _ in results} + assert "main.py" in files + assert "utils.py" in files + assert "config" not in files + assert "express.js" not in files + + +@pytest.mark.asyncio +async def test_scan_depth_protection(mock_sandbox_paths): + workspace = mock_sandbox_paths["workspace"] + + # 创建超深的文件树路径:超过 15 层 + deep_dir = workspace + for i in range(18): + deep_dir = deep_dir / f"dir_{i}" + + deep_dir.mkdir(parents=True, exist_ok=True) + (deep_dir / "deep_file.py").write_text("deep") + + # 扫描 + results = mention_service._scan_pruned_files(workspace, 100) + files = {name for name, _ in results} + + # 深度限制应成功剪枝拦截该超深文件 + assert "deep_file.py" not in files + + +@pytest.mark.asyncio +async def test_scan_width_limit(mock_sandbox_paths): + workspace = mock_sandbox_paths["workspace"] + + # 创建 600 个扁平的小文件 + for i in range(600): + (workspace / f"file_{i}.py").write_text(str(i)) + + # 扫描,设置 max_entries = 1000(看看单目录 500 的宽度限额是否起作用) + results = mention_service._scan_pruned_files(workspace, 1000) + + # 限制单目录 MAX_ENTRIES_PER_DIR = 500 熔断 + assert len(results) == 500 + + +@pytest.mark.asyncio +async def test_mention_cache_lifecycle_with_ormsgpack(mock_sandbox_paths, fake_redis): + workspace = mock_sandbox_paths["workspace"] + uploads = mock_sandbox_paths["uploads"] + + (workspace / "main.py").write_text("main") + (uploads / "data.csv").write_text("csv") + + # 1. 首次查询:构建缓存并存入 Redis + index_1 = await mention_service.get_or_build_file_index("thread_1", "user_1") + assert len(index_1) == 2 + + # 验证 Redis 中已存有缓存 + redis_key = f"{mention_service.REDIS_KEY_PREFIX}thread_1" + cached_str = fake_redis.data.get(redis_key) + assert cached_str is not None + + # 反序列化校验 + packed_bytes = cached_str.encode("latin1") + decoded_entries = ormsgpack.unpackb(packed_bytes) + assert len(decoded_entries) == 2 + + # 2. 修改磁盘文件,但在 TTL 内应仍然走 Redis 缓存,内容不更新 + (workspace / "new_file.py").write_text("new") + index_2 = await mention_service.get_or_build_file_index("thread_1", "user_1") + assert len(index_2) == 2 # 仍然命中缓存,没有扫描出 new_file.py + + # 3. 清理缓存后重新读取,应成功更新磁盘扫描内容 + await mention_service.invalidate_mention_cache("thread_1") + assert fake_redis.data.get(redis_key) is None + + index_3 = await mention_service.get_or_build_file_index("thread_1", "user_1") + assert len(index_3) == 3 + assert any(name == "new_file.py" for name, _ in index_3) + + +@pytest.mark.asyncio +async def test_search_mention_files_in_index(mock_sandbox_paths, fake_redis): + workspace = mock_sandbox_paths["workspace"] + (workspace / "agent_config.json").write_text("config") + (workspace / "main.py").write_text("main") + + # 搜索匹配测试 + results = await mention_service.search_mention_files_in_index("thread_1", "user_1", "config") + assert len(results) == 1 + assert results[0]["name"] == "agent_config.json" + assert results[0]["path"] == "/home/gem/user-data/workspace/agent_config.json" + assert results[0]["is_dir"] is False + + # 大小写不敏感匹配 + results_case = await mention_service.search_mention_files_in_index("thread_1", "user_1", "MAIN") + assert len(results_case) == 1 + assert results_case[0]["name"] == "main.py" + + +@pytest.mark.asyncio +async def test_search_mention_directories_and_weighted_ranking(mock_sandbox_paths, fake_redis): + workspace = mock_sandbox_paths["workspace"] + + # 1. 创建合格的子目录 "test" + test_dir = workspace / "test" + test_dir.mkdir(exist_ok=True) + + # 2. 在子目录下创建一些包含关键字的文件 + (test_dir / "test_auth.py").write_text("auth") + (test_dir / "conftest.py").write_text("conf") # 文件名不含 test,但路径含 test + + # 3. 搜索 "@test" + results = await mention_service.search_mention_files_in_index("thread_1", "user_1", "test") + + # 4. 校验结果 + # 必须包含 3 个项:目录 "test/",文件 "test_auth.py",文件 "conftest.py" (路径匹配兜底) + assert len(results) == 3 + + # 5. 校验置顶排序和 is_dir 属性 + # 由于目录名字 "test" 与搜索词 "test" 100% 完全一致,得分为最高 (1000分),必须排在第 1 位 + assert results[0]["name"] == "test" + assert results[0]["is_dir"] is True + assert results[0]["path"] == "/home/gem/user-data/workspace/test/" + + # "test_auth.py" 文件名以 "test" 开头,为前缀匹配 (500分),必须排在第 2 位 + assert results[1]["name"] == "test_auth.py" + assert results[1]["is_dir"] is False + + # "conftest.py" 文件名不含 test,为纯路径匹配兜底 (10分),必须排在最后 + assert results[2]["name"] == "conftest.py" + assert results[2]["is_dir"] is False + diff --git a/web/src/apis/base.js b/web/src/apis/base.js index b4f369dd..9ee3cc8f 100644 --- a/web/src/apis/base.js +++ b/web/src/apis/base.js @@ -132,7 +132,9 @@ export async function apiRequest(url, options = {}, requiresAuth = true, respons return response } } catch (error) { - console.error('API请求错误:', error) + if (error.name !== 'AbortError') { + console.error('API请求错误:', error) + } throw error } } diff --git a/web/src/apis/index.js b/web/src/apis/index.js index 14644fc9..b7f66092 100644 --- a/web/src/apis/index.js +++ b/web/src/apis/index.js @@ -15,6 +15,7 @@ export * from './mcp_api' // MCP API export * from './skill_api' // Skills API export * from './subagent_api' // SubAgent API export * from './tool_api' // 工具 API +export * from './mention_api' // 提及搜索 API // 导出基础工具函数 export { diff --git a/web/src/apis/mention_api.js b/web/src/apis/mention_api.js new file mode 100644 index 00000000..8d410bd9 --- /dev/null +++ b/web/src/apis/mention_api.js @@ -0,0 +1,8 @@ +import { apiGet } from './base' + +export const searchMentionFiles = (threadId, query, signal) => { + const params = new URLSearchParams() + if (threadId) params.set('thread_id', threadId) + if (query) params.set('query', query) + return apiGet(`/api/mention/search?${params.toString()}`, { signal }) +} diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index 644c37a6..7fd068f4 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -153,6 +153,7 @@ :disabled="!currentAgent" :send-button-disabled="isSendButtonDisabled" :mention="mentionConfig" + :thread-id="currentChatId" :supports-file-upload="supportsFileUpload" :has-active-thread="!!currentChatId" :todos="currentTodos" @@ -204,7 +205,6 @@ { }) const { mentionConfig } = useAgentMentionConfig({ currentAgentState, - currentThreadFiles, currentThreadAttachments, - workspaceMentionFiles, configurableItems, agentConfig, availableKnowledgeBases, @@ -890,13 +886,7 @@ onMounted(() => { }) }) -let skipNextWorkspaceMentionActivation = true onActivated(() => { - if (skipNextWorkspaceMentionActivation) { - skipNextWorkspaceMentionActivation = false - } else { - void fetchWorkspaceMentionFiles() - } nextTick(() => { startChatMainResizeObserver() }) @@ -994,7 +984,7 @@ const fetchThreadMessages = async ({ agentId, threadId, delay = 0 }) => { const fetchThreadFiles = async (threadId) => { if (!threadId) return try { - const response = await threadApi.listThreadFiles(threadId, '/home/gem/user-data', true) + const response = await threadApi.listThreadFiles(threadId, '/home/gem/user-data', false) const entries = Array.isArray(response?.files) ? response.files : [] threadFilesMap.value[threadId] = entries } catch (error) { @@ -1021,25 +1011,7 @@ const refreshThreadFilesAndAttachments = async (threadId) => { await Promise.all([fetchThreadFiles(threadId), fetchThreadAttachments(threadId)]) } -let workspaceMentionFilesRequest = null -const fetchWorkspaceMentionFiles = async () => { - if (workspaceMentionFilesRequest) return workspaceMentionFilesRequest - workspaceMentionFilesRequest = (async () => { - try { - const response = await getWorkspaceTree('/', true, true) - workspaceMentionFiles.value = Array.isArray(response?.entries) ? response.entries : [] - } catch (error) { - console.warn('Failed to fetch workspace mention files:', error) - workspaceMentionFiles.value = [] - } finally { - workspaceMentionFilesRequest = null - } - })() - return workspaceMentionFilesRequest -} - const handleArtifactSaved = async () => { - await fetchWorkspaceMentionFiles() if (!currentChatId.value) return await refreshThreadFilesAndAttachments(currentChatId.value) } @@ -1652,10 +1624,10 @@ const hasVisibleAssistantBody = (message) => { const { content, reasoningContent } = extractAssistantMessageBody(message) return Boolean( content || - reasoningContent || - message.error_type || - message.extra_metadata?.error_type || - message.isStoppedByUser + reasoningContent || + message.error_type || + message.extra_metadata?.error_type || + message.isStoppedByUser ) } @@ -1821,7 +1793,7 @@ const initAll = async () => { } onMounted(async () => { - await Promise.all([initAll(), fetchWorkspaceMentionFiles()]) + await initAll() scrollController.enableAutoScroll() }) @@ -2007,7 +1979,6 @@ watch(currentChatId, (threadId, oldThreadId) => { will-change: flex-basis; } - /* Workbench transition animations */ .agent-panel-wrapper { transition: flex-basis 0.3s cubic-bezier(0.4, 0, 0.2, 1); diff --git a/web/src/components/AgentConfigSidebar.vue b/web/src/components/AgentConfigSidebar.vue index dcf1a53f..f8152906 100644 --- a/web/src/components/AgentConfigSidebar.vue +++ b/web/src/components/AgentConfigSidebar.vue @@ -824,7 +824,10 @@ const handleModelChange = (key, spec) => { // 多选相关方法 const ensureArray = (key) => { const config = agentConfig.value || {} - if (config[key] === null && configurableItems.value[key]?.template_metadata?.kind === 'knowledges') { + if ( + config[key] === null && + configurableItems.value[key]?.template_metadata?.kind === 'knowledges' + ) { return getConfigOptions(configurableItems.value[key]).map((option) => getOptionValue(option)) } if (!config[key] || !Array.isArray(config[key])) { diff --git a/web/src/components/AgentFilePreview.vue b/web/src/components/AgentFilePreview.vue index daa5fd9f..48df3117 100644 --- a/web/src/components/AgentFilePreview.vue +++ b/web/src/components/AgentFilePreview.vue @@ -706,5 +706,4 @@ onUnmounted(() => { .fullscreen-file-content .image-preview { max-height: calc(100vh - 48px); } - diff --git a/web/src/components/AgentInputArea.vue b/web/src/components/AgentInputArea.vue index a1888596..768e2ec7 100644 --- a/web/src/components/AgentInputArea.vue +++ b/web/src/components/AgentInputArea.vue @@ -8,6 +8,7 @@ :send-button-disabled="sendButtonDisabled" :placeholder="placeholder" :mention="mention" + :thread-id="threadId" @send="handleSend" @keydown="handleKeyDown" > @@ -113,6 +114,7 @@ const props = defineProps({ disabled: { type: Boolean, default: false }, sendButtonDisabled: { type: Boolean, default: false }, mention: { type: Object, default: () => null }, + threadId: { type: String, default: '' }, supportsFileUpload: { type: Boolean, default: false }, hasActiveThread: { type: Boolean, default: true }, todos: { diff --git a/web/src/components/AgentMessageComponent.vue b/web/src/components/AgentMessageComponent.vue index 85de54b3..60b56aae 100644 --- a/web/src/components/AgentMessageComponent.vue +++ b/web/src/components/AgentMessageComponent.vue @@ -38,7 +38,12 @@ - +
@@ -265,7 +270,6 @@ const parsedData = computed(() => { reasoning_content } }) - diff --git a/web/src/components/AgentPanel.vue b/web/src/components/AgentPanel.vue index 3e927dbd..bff2c9b3 100644 --- a/web/src/components/AgentPanel.vue +++ b/web/src/components/AgentPanel.vue @@ -130,10 +130,6 @@ const props = defineProps({ type: Object, default: () => ({}) }, - threadFiles: { - type: Array, - default: () => [] - }, threadId: { type: String, default: null @@ -723,7 +719,6 @@ watch(useInlinePreview, (isInline) => { flex-shrink: 0; } - .tab-content { flex: 1; overflow-y: auto; diff --git a/web/src/components/EvaluationBenchmarks.vue b/web/src/components/EvaluationBenchmarks.vue index 961cc864..9122e582 100644 --- a/web/src/components/EvaluationBenchmarks.vue +++ b/web/src/components/EvaluationBenchmarks.vue @@ -88,7 +88,10 @@ 检索评估 - + 问答评估 仅查询 diff --git a/web/src/components/MessageInputComponent.vue b/web/src/components/MessageInputComponent.vue index 91bec405..dd551283 100644 --- a/web/src/components/MessageInputComponent.vue +++ b/web/src/components/MessageInputComponent.vue @@ -33,6 +33,7 @@ @keyup="handleKeyUp" @input="handleInput" @focus="focusInput" + @click="handleTextareaClick" :placeholder="placeholder" :disabled="disabled" /> @@ -42,7 +43,6 @@ v-if="mentionPopupVisible" ref="mentionDropdownRef" class="mention-dropdown-wrapper" - :style="mentionDropdownStyle" >
@@ -55,10 +55,39 @@
- {{ item.label }} +
+ + + {{ part.text }} + +
+ + {{ part.text }} +
@@ -72,7 +101,12 @@ :class="['mention-item', { active: isItemSelected('knowledge', index) }]" @click="insertMention(item)" > - {{ item.label }} + {{ part.text }} @@ -85,7 +119,12 @@ :class="['mention-item', { active: isItemSelected('mcp', index) }]" @click="insertMention(item)" > - {{ item.label }} + {{ part.text }} @@ -98,7 +137,12 @@ :class="['mention-item', { active: isItemSelected('skill', index) }]" @click="insertMention(item)" > - {{ item.label }} + {{ part.text }} @@ -111,7 +155,12 @@ :class="['mention-item', { active: isItemSelected('subagent', index) }]" @click="insertMention(item)" > - {{ item.label }} + {{ part.text }} @@ -144,8 +193,10 @@ - - diff --git a/web/src/components/sources/KbChunkDetailModal.vue b/web/src/components/sources/KbChunkDetailModal.vue index 68e999c4..ae802e2e 100644 --- a/web/src/components/sources/KbChunkDetailModal.vue +++ b/web/src/components/sources/KbChunkDetailModal.vue @@ -17,7 +17,11 @@ > - +
暂无内容
diff --git a/web/src/composables/useAgentMentionConfig.js b/web/src/composables/useAgentMentionConfig.js index 6db965fe..6f347dce 100644 --- a/web/src/composables/useAgentMentionConfig.js +++ b/web/src/composables/useAgentMentionConfig.js @@ -2,9 +2,7 @@ import { computed } from 'vue' export function useAgentMentionConfig({ currentAgentState, - currentThreadFiles, currentThreadAttachments, - workspaceMentionFiles, configurableItems, agentConfig, availableKnowledgeBases, @@ -15,10 +13,6 @@ export function useAgentMentionConfig({ const rawFiles = currentAgentState.value?.files || {} const files = [] const seenPaths = new Set() - const workspaceFiles = Array.isArray(currentThreadFiles?.value) ? currentThreadFiles.value : [] - const userWorkspaceFiles = Array.isArray(workspaceMentionFiles?.value) - ? workspaceMentionFiles.value - : [] const pushFile = (entry) => { const path = entry?.path || '' @@ -66,29 +60,6 @@ export function useAgentMentionConfig({ }) }) - userWorkspaceFiles.forEach((entry) => { - const path = entry?.virtual_path || '' - if (!path || entry?.is_dir) return - pushFile({ - path, - size: entry.size, - modified_at: entry.modified_at, - file_name: entry.name - }) - }) - - workspaceFiles.forEach((entry) => { - const path = entry?.path || '' - if (!path.startsWith('/home/gem/user-data/workspace/') || entry?.is_dir) return - pushFile({ - path, - size: entry.size, - modified_at: entry.modified_at, - artifact_url: entry.artifact_url, - file_name: entry.name - }) - }) - const configItems = configurableItems.value || {} const currentConfig = agentConfig.value || {} let includeAllKnowledgeBases = false diff --git a/web/src/stores/chatUI.js b/web/src/stores/chatUI.js index b132c01b..abb4c913 100644 --- a/web/src/stores/chatUI.js +++ b/web/src/stores/chatUI.js @@ -1,72 +1,76 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -export const useChatUIStore = defineStore('chatUI', () => { - // ==================== 聊天界面 UI 状态 ==================== - // 加载状态 - const isLoadingMessages = ref(false) +export const useChatUIStore = defineStore( + 'chatUI', + () => { + // ==================== 聊天界面 UI 状态 ==================== + // 加载状态 + const isLoadingMessages = ref(false) - // ==================== AgentView UI 状态 ==================== - // 智能体选择弹窗 - const agentModalOpen = ref(false) + // ==================== AgentView UI 状态 ==================== + // 智能体选择弹窗 + const agentModalOpen = ref(false) - // 配置侧边栏 - const isConfigSidebarOpen = ref(false) + // 配置侧边栏 + const isConfigSidebarOpen = ref(false) - // 应用侧边栏折叠态 - const sidebarCollapsed = ref(false) + // 应用侧边栏折叠态 + const sidebarCollapsed = ref(false) - // 更多菜单 - const moreMenuOpen = ref(false) - const moreMenuPosition = ref({ x: 0, y: 0 }) + // 更多菜单 + const moreMenuOpen = ref(false) + const moreMenuPosition = ref({ x: 0, y: 0 }) - // ==================== 方法 ==================== - /** - * 打开更多菜单 - * @param {number} x - X 坐标 - * @param {number} y - Y 坐标 - */ - function openMoreMenu(x, y) { - moreMenuPosition.value = { x, y } - moreMenuOpen.value = true + // ==================== 方法 ==================== + /** + * 打开更多菜单 + * @param {number} x - X 坐标 + * @param {number} y - Y 坐标 + */ + function openMoreMenu(x, y) { + moreMenuPosition.value = { x, y } + moreMenuOpen.value = true + } + + /** + * 关闭更多菜单 + */ + function closeMoreMenu() { + moreMenuOpen.value = false + } + + /** + * 重置所有 UI 状态(不包括持久化状态) + */ + function reset() { + isLoadingMessages.value = false + agentModalOpen.value = false + isConfigSidebarOpen.value = false + moreMenuOpen.value = false + moreMenuPosition.value = { x: 0, y: 0 } + } + + return { + // 状态 + isLoadingMessages, + agentModalOpen, + isConfigSidebarOpen, + sidebarCollapsed, + moreMenuOpen, + moreMenuPosition, + + // 方法 + openMoreMenu, + closeMoreMenu, + reset + } + }, + { + persist: { + key: 'chat-ui-store', + storage: localStorage, + pick: ['sidebarCollapsed'] + } } - - /** - * 关闭更多菜单 - */ - function closeMoreMenu() { - moreMenuOpen.value = false - } - - /** - * 重置所有 UI 状态(不包括持久化状态) - */ - function reset() { - isLoadingMessages.value = false - agentModalOpen.value = false - isConfigSidebarOpen.value = false - moreMenuOpen.value = false - moreMenuPosition.value = { x: 0, y: 0 } - } - - return { - // 状态 - isLoadingMessages, - agentModalOpen, - isConfigSidebarOpen, - sidebarCollapsed, - moreMenuOpen, - moreMenuPosition, - - // 方法 - openMoreMenu, - closeMoreMenu, - reset - } -}, { - persist: { - key: 'chat-ui-store', - storage: localStorage, - pick: ['sidebarCollapsed'] - } -}) +) diff --git a/web/src/utils/file_utils.js b/web/src/utils/file_utils.js index 6a8f0b76..4fb3abd5 100644 --- a/web/src/utils/file_utils.js +++ b/web/src/utils/file_utils.js @@ -8,13 +8,15 @@ import { FileImageFilled, FileUnknownFilled, FilePptFilled, - LinkOutlined + LinkOutlined, + CodeFilled, + FileFilled } from '@ant-design/icons-vue' import { formatRelative, parseToShanghai } from '@/utils/time' // 根据文件扩展名获取文件图标 export const getFileIcon = (filename) => { - if (!filename) return FileUnknownFilled + if (!filename) return FileFilled // Check if it's a URL if (filename.startsWith('http://') || filename.startsWith('https://')) { @@ -24,31 +26,23 @@ export const getFileIcon = (filename) => { const extension = filename.toLowerCase().split('.').pop() const iconMap = { - // 文本文件 + // 文本文件与常规文档 txt: FileTextFilled, text: FileTextFilled, log: FileTextFilled, + pdf: FilePdfFilled, + doc: FileWordFilled, + docx: FileWordFilled, + xls: FileExcelFilled, + xlsx: FileExcelFilled, + csv: FileExcelFilled, + ppt: FilePptFilled, + pptx: FilePptFilled, // Markdown文件 md: FileMarkdownFilled, markdown: FileMarkdownFilled, - // PDF文件 - pdf: FilePdfFilled, - - // Word文档 - doc: FileWordFilled, - docx: FileWordFilled, - - // Excel文档 - xls: FileExcelFilled, - xlsx: FileExcelFilled, - csv: FileExcelFilled, - - // PPT文档 - ppt: FilePptFilled, - pptx: FilePptFilled, - // 图片文件 jpg: FileImageFilled, jpeg: FileImageFilled, @@ -58,12 +52,35 @@ export const getFileIcon = (filename) => { svg: FileImageFilled, webp: FileImageFilled, - // HTML文件 - html: FileTextFilled, - htm: FileTextFilled + // 代码文件 + py: CodeFilled, + js: CodeFilled, + ts: CodeFilled, + vue: CodeFilled, + sh: CodeFilled, + go: CodeFilled, + cpp: CodeFilled, + c: CodeFilled, + h: CodeFilled, + java: CodeFilled, + html: CodeFilled, + htm: CodeFilled, + css: CodeFilled, + less: CodeFilled, + scss: CodeFilled, + sql: CodeFilled, + + // 配置文件与数据结构 + json: FileTextFilled, + yaml: FileTextFilled, + yml: FileTextFilled, + toml: FileTextFilled, + ini: FileTextFilled, + conf: FileTextFilled, + env: FileTextFilled } - return iconMap[extension] || FileUnknownFilled + return iconMap[extension] || FileFilled } // 根据文件扩展名获取文件图标颜色 @@ -112,9 +129,36 @@ export const getFileIconColor = (filename) => { svg: '#722ed1', webp: '#722ed1', - // HTML文件 - 橙色 + // 前端与样式文件 - 橙黄色 + js: '#fa8c16', + ts: '#fa8c16', + vue: '#fa8c16', html: '#fa8c16', - htm: '#fa8c16' + htm: '#fa8c16', + css: '#fa8c16', + less: '#fa8c16', + scss: '#fa8c16', + + // 后端核心代码文件 - 亮天蓝 + py: '#1890ff', + go: '#1890ff', + java: '#1890ff', + cpp: '#1890ff', + c: '#1890ff', + h: '#1890ff', + + // 配置文件 - 青色 + json: '#13c2c2', + yaml: '#13c2c2', + yml: '#13c2c2', + toml: '#13c2c2', + ini: '#13c2c2', + conf: '#13c2c2', + env: '#13c2c2', + + // 脚本文件 - 中灰 + sh: '#595959', + sql: '#595959' } return colorMap[extension] || '#8c8c8c' diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue index ac0df456..41a81adf 100644 --- a/web/src/views/HomeView.vue +++ b/web/src/views/HomeView.vue @@ -117,7 +117,9 @@