From 4f02b4d35e2d296284e8158d5e99038fd88fbce6 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Sat, 30 May 2026 16:04:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20mention=20?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF=E6=8C=81=E5=9C=A8=E6=9C=AA?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=20Thread=20=E7=9A=84=E6=97=B6=E5=80=99=20@?= =?UTF-8?q?=20=E5=B7=A5=E4=BD=9C=E5=8C=BA=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yuxi/services/mention_search_service.py | 249 ++++++++++-------- .../yuxi/services/thread_files_service.py | 3 +- .../yuxi/services/workspace_service.py | 4 + backend/server/routers/mention_router.py | 44 ++-- .../services/test_mention_search_service.py | 83 ++++-- docs/develop-guides/roadmap.md | 1 + web/src/apis/mention_api.js | 4 +- web/src/components/MessageInputComponent.vue | 6 +- 8 files changed, 238 insertions(+), 156 deletions(-) diff --git a/backend/package/yuxi/services/mention_search_service.py b/backend/package/yuxi/services/mention_search_service.py index 5e885c8c..933dd3d4 100644 --- a/backend/package/yuxi/services/mention_search_service.py +++ b/backend/package/yuxi/services/mention_search_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import base64 import os +from collections.abc import Sequence from pathlib import Path import ormsgpack @@ -37,6 +38,10 @@ MAX_SEARCH_DEPTH = 15 CACHE_TTL = 60 # 缓存有效期 60 秒 MAX_CACHED_ENTRIES = 100000 REDIS_KEY_PREFIX = "yuxi:mention:cache:" +WORKSPACE_CACHE_PREFIX = f"{REDIS_KEY_PREFIX}workspace:" +THREAD_CACHE_PREFIX = f"{REDIS_KEY_PREFIX}thread:" +WORKSPACE_THREAD_PLACEHOLDER = "_workspace" +MENTION_SOURCES = {"workspace", "thread"} def _scan_pruned_files(root: Path, max_entries: int) -> list[tuple[str, str]]: @@ -90,155 +95,189 @@ def _scan_pruned_files(root: Path, max_entries: int) -> list[tuple[str, str]]: return results -async def get_or_build_file_index( - thread_id: str, - user_id: str, -) -> list[tuple[str, str]]: - """ - 获取或构建当前 Workspace 和 Thread 的提及文件索引缓存 (使用 ormsgpack 二进制序列化) - """ - 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 连接。 +async def _read_cached_index(redis, redis_key: str) -> list[tuple[str, str]] | None: cached_str = await redis.get(redis_key) - if cached_str: - try: - # NOTE: decode_responses=True 的 Redis 客户端只能存 str, - # 使用 base64 对 ormsgpack 的二进制输出进行无损编码后存储。 - packed_bytes = base64.b64decode(cached_str) - return ormsgpack.unpackb(packed_bytes) - except Exception as e: - logger.warning(f"Failed to unpack mention cache for thread {thread_id}: {e}") + if not cached_str: + return None + try: + packed_bytes = base64.b64decode(cached_str) + return ormsgpack.unpackb(packed_bytes) + except Exception as e: + logger.warning(f"Failed to unpack mention cache {redis_key}: {e}") + return None - # 缓存未命中,在 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 缓存 +async def _write_cached_index(redis, redis_key: str, entries: list[tuple[str, str]]) -> None: try: packed_bytes = ormsgpack.packb(entries) packed_str = base64.b64encode(packed_bytes).decode("ascii") 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}") + logger.warning(f"Failed to write mention cache {redis_key}: {e}") + + +def _normalize_sources(sources: Sequence[str] | None, *, has_thread: bool) -> tuple[str, ...]: + if not sources: + return ("thread", "workspace") if has_thread else ("workspace",) + + normalized = [] + for source in sources: + value = str(source or "").strip().lower() + if value in MENTION_SOURCES and value not in normalized: + normalized.append(value) + + if not has_thread: + normalized = [source for source in normalized if source == "workspace"] + return tuple(normalized or (["workspace"] if not has_thread else ["thread", "workspace"])) + + +def _workspace_root(uid: str) -> Path: + return sandbox_workspace_dir(WORKSPACE_THREAD_PLACEHOLDER, uid) + + +async def _scan_virtual_root(root: Path, virtual_prefix: str, max_entries: int) -> list[tuple[str, str]]: + scan_results = await asyncio.to_thread(_scan_pruned_files, root, max_entries) + return [ + (name, f"{virtual_prefix}/{rel_path}" if rel_path and rel_path != "." else virtual_prefix) + for name, rel_path in scan_results + ] + + +async def get_or_build_workspace_index(uid: str) -> list[tuple[str, str]]: + redis = await get_redis_client() + redis_key = f"{WORKSPACE_CACHE_PREFIX}{uid}" + cached = await _read_cached_index(redis, redis_key) + if cached is not None: + return cached + + entries = await _scan_virtual_root(_workspace_root(uid), "workspace", MAX_CACHED_ENTRIES) + await _write_cached_index(redis, redis_key, entries) + return entries + + +async def get_or_build_thread_index(thread_id: str) -> list[tuple[str, str]]: + redis = await get_redis_client() + redis_key = f"{THREAD_CACHE_PREFIX}{thread_id}" + cached = await _read_cached_index(redis, redis_key) + if cached is not None: + return cached + + entries: list[tuple[str, str]] = [] + for virtual_prefix, root in ( + ("uploads", sandbox_uploads_dir(thread_id)), + ("outputs", sandbox_outputs_dir(thread_id)), + ): + needed = MAX_CACHED_ENTRIES - len(entries) + if needed <= 0: + break + entries.extend(await _scan_virtual_root(root, virtual_prefix, needed)) + + await _write_cached_index(redis, redis_key, entries) + return entries + + +async def get_or_build_file_index( + thread_id: str | None, + uid: str, + sources: Sequence[str] | None = None, +) -> list[tuple[str, str, str]]: + """获取或构建当前可提及文件索引,workspace 与 thread 缓存分离。""" + selected_sources = _normalize_sources(sources, has_thread=bool(thread_id)) + entries: list[tuple[str, str, str]] = [] + + for source in selected_sources: + if source == "thread" and thread_id: + entries.extend( + (name, virtual_path, "thread") for name, virtual_path in await get_or_build_thread_index(thread_id) + ) + elif source == "workspace": + entries.extend( + (name, virtual_path, "workspace") for name, virtual_path in await get_or_build_workspace_index(uid) + ) 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 [] - - # NOTE: query 为空时不执行搜索,避免空字符串匹配所有条目(空串是任何字符串的子串) - if not query: - return [] - +def _rank_mention_entries(index: list[tuple[str, str, str]], query: str) -> list[dict]: query_lower = query.lower() - prefix = (config.sandbox_virtual_path_prefix or "/home/gem/user-data").rstrip("/") + name_matched = [] + path_matched = [] - # 存储加权匹配结果 - name_matched = [] # 文件名/目录名直接匹配的项 (高分) - path_matched = [] # 仅路径匹配的项 (低分,作为兜底) - - for name, virtual_path in index: + for name, virtual_path, source 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 # 完全匹配 + score = 1000.0 else: - score = 500.0 # 基础名称匹配分 - - # 附加前缀优势 + 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. 其次判定是否为纯路径匹配 (名称不匹配,但路径中包含) + name_matched.append( + {"name": name, "path": f"{prefix}/{virtual_path}", "is_dir": is_dir, "source": source, "score": score} + ) elif query_lower in path_lower: - score = 10.0 - # 路径长度惩罚 - score -= min(len(virtual_path) * 0.1, 5.0) + score = 10.0 - 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": name, "path": f"{prefix}/{virtual_path}", "is_dir": is_dir, "source": source, "score": score} ) - # 对名称直接匹配项按照打分降序进行精准排序 (打分已融合位置与长度惩罚) name_matched.sort(key=lambda x: -x["score"]) + path_matched.sort(key=lambda x: len(x["path"])) + return [*name_matched, *path_matched] - # 智能融合:如果名称匹配项不足 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 属性以识别目录 +async def search_mention_files_in_index( + thread_id: str | None, + uid: str, + query: str, + sources: Sequence[str] | None = None, +) -> list[dict]: + """搜索可提及文件;未绑定 thread 时只搜索用户 workspace。""" + if not query: + return [] + + selected_sources = _normalize_sources(sources, has_thread=bool(thread_id)) + results: list[dict] = [] + + for source in selected_sources: + source_index = await get_or_build_file_index(thread_id, uid, [source]) + source_results = _rank_mention_entries(source_index, query) + remaining = MAX_MENTION_RESULTS - len(results) + if remaining <= 0: + break + results.extend(source_results[:remaining]) + return [ - {"name": item["name"], "path": item["path"], "is_dir": item["is_dir"]} - for item in merged_results[:MAX_MENTION_RESULTS] + {"name": item["name"], "path": item["path"], "is_dir": item["is_dir"], "source": item["source"]} + for item in results[:MAX_MENTION_RESULTS] ] async def invalidate_mention_cache(thread_id: str) -> None: - """ - 轻量级缓存清理工具函数,主动清除指定 thread 的提及缓存 - """ + """清理指定 thread 的提及文件缓存。""" try: redis = await get_redis_client() - redis_key = f"{REDIS_KEY_PREFIX}{thread_id}" - await redis.delete(redis_key) + await redis.delete(f"{THREAD_CACHE_PREFIX}{thread_id}") + await redis.delete(f"{REDIS_KEY_PREFIX}{thread_id}") except Exception as e: logger.warning(f"Failed to invalidate mention cache for thread {thread_id}: {e}") + + +async def invalidate_workspace_mention_cache(uid: str) -> None: + """清理指定用户 workspace 的提及文件缓存。""" + try: + redis = await get_redis_client() + await redis.delete(f"{WORKSPACE_CACHE_PREFIX}{uid}") + except Exception as e: + logger.warning(f"Failed to invalidate workspace mention cache for uid {uid}: {e}") diff --git a/backend/package/yuxi/services/thread_files_service.py b/backend/package/yuxi/services/thread_files_service.py index 05e5a6d6..5b3ab153 100644 --- a/backend/package/yuxi/services/thread_files_service.py +++ b/backend/package/yuxi/services/thread_files_service.py @@ -17,7 +17,7 @@ from yuxi.agents.backends.sandbox import ( ) from yuxi.repositories.conversation_repository import ConversationRepository from yuxi.services.conversation_service import require_user_conversation -from yuxi.services.mention_search_service import invalidate_mention_cache +from yuxi.services.mention_search_service import invalidate_mention_cache, invalidate_workspace_mention_cache from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp @@ -230,6 +230,7 @@ async def save_thread_artifact_to_workspace_view( shutil.copyfileobj(src, dst) await invalidate_mention_cache(thread_id) + await invalidate_workspace_mention_cache(uid) saved_virtual_path = virtual_path_for_thread_file(thread_id, target_path, uid=uid) return { diff --git a/backend/package/yuxi/services/workspace_service.py b/backend/package/yuxi/services/workspace_service.py index 7bf7254d..7b5bc6a6 100644 --- a/backend/package/yuxi/services/workspace_service.py +++ b/backend/package/yuxi/services/workspace_service.py @@ -13,6 +13,7 @@ from fastapi import HTTPException, UploadFile from fastapi.responses import FileResponse, StreamingResponse from yuxi.agents.backends.sandbox.paths import _global_user_data_dir, ensure_workspace_default_files from yuxi.services.file_preview import detect_preview_type +from yuxi.services.mention_search_service import invalidate_workspace_mention_cache from yuxi.utils.upload_utils import MAX_UPLOAD_SIZE_BYTES, write_upload_to_buffer from yuxi.storage.postgres.models_business import User from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp @@ -227,6 +228,7 @@ async def delete_workspace_path(*, path: str, current_user: User) -> dict: except PermissionError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + await invalidate_workspace_mention_cache(str(current_user.uid)) return {"success": True, "path": _normalize_workspace_path(path).as_posix()} @@ -243,6 +245,7 @@ async def create_workspace_directory(*, parent_path: str, name: str, current_use except PermissionError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + await invalidate_workspace_mention_cache(str(current_user.uid)) return {"success": True, "entry": _entry_for_path(root, target)} @@ -275,6 +278,7 @@ async def upload_workspace_file(*, parent_path: str, file: UploadFile, current_u with contextlib.suppress(OSError): await asyncio.to_thread(target.unlink) + await invalidate_workspace_mention_cache(str(current_user.uid)) return {"success": True, "entry": _entry_for_path(root, target)} diff --git a/backend/server/routers/mention_router.py b/backend/server/routers/mention_router.py index 3cfc3b57..56ade184 100644 --- a/backend/server/routers/mention_router.py +++ b/backend/server/routers/mention_router.py @@ -17,40 +17,42 @@ class MentionFileItem(BaseModel): name: str path: str is_dir: bool + source: str @mention_router.get("/search", response_model=list[MentionFileItem]) async def search_mention_files( - thread_id: str = Query(..., description="当前聊天会话 ID"), + thread_id: str | None = Query(None, description="当前聊天会话 ID;为空时仅搜索用户工作区"), query: str = Query("", description="模糊搜索关键字"), + sources: str | None = Query(None, description="搜索来源:workspace,thread;为空时自动选择"), current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): """ - 提及文件模糊搜索接口:使用 Redis 二进制缓存进行极速过滤,防止大文件递归卡死。 - 调用前校验 thread 归属权,防止 IDOR 越权访问他人会话文件。 + 提及文件模糊搜索接口:未创建 thread 时只搜索用户 workspace;已有 thread 时可搜索当前对话文件。 """ - user_id = str(current_user.id) + uid = str(current_user.uid) + effective_thread_id: str | None = None - # NOTE: 校验 thread 归属权,防止恶意用户传入他人 thread_id 遍历文件 - conv_repo = ConversationRepository(db) - conversation = await conv_repo.get_conversation_by_thread_id(thread_id) - if conversation: - if conversation.user_id != user_id or conversation.status == "deleted": - raise HTTPException(status_code=404, detail="对话线程不存在") - else: - # NOTE: 如果是尚未在数据库记录的全新对话(还未发送首条消息),在格式校验安全的前提下放行。 - # 此时该 thread 专属的 uploads/outputs 目录还没创建或为空, - # 用户仅能安全地搜索到自己全局的工作区 (workspace) 文件。 - try: - from yuxi.agents.backends.sandbox.paths import validate_thread_id + if thread_id: + conv_repo = ConversationRepository(db) + conversation = await conv_repo.get_conversation_by_thread_id(thread_id) + if conversation: + if conversation.uid != uid or conversation.status == "deleted": + raise HTTPException(status_code=404, detail="对话线程不存在") + effective_thread_id = thread_id + else: + try: + from yuxi.agents.backends.sandbox.paths import validate_thread_id - validate_thread_id(thread_id) - except ValueError: - raise HTTPException(status_code=400, detail="非法的 thread_id 格式") + validate_thread_id(thread_id) + except ValueError: + raise HTTPException(status_code=400, detail="非法的 thread_id 格式") + source_list = [item.strip() for item in sources.split(",")] if sources else None return await search_mention_files_in_index( - thread_id=thread_id, - user_id=user_id, + thread_id=effective_thread_id, + uid=uid, query=query, + sources=source_list, ) diff --git a/backend/test/unit/services/test_mention_search_service.py b/backend/test/unit/services/test_mention_search_service.py index 8baf370f..14420b4b 100644 --- a/backend/test/unit/services/test_mention_search_service.py +++ b/backend/test/unit/services/test_mention_search_service.py @@ -53,8 +53,10 @@ def mock_sandbox_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): @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 @@ -95,7 +97,7 @@ async def test_scan_depth_protection(mock_sandbox_paths): 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") @@ -133,29 +135,64 @@ async def test_mention_cache_lifecycle_with_ormsgpack(mock_sandbox_paths, fake_r # 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 - + + # 验证 Redis 中已按 workspace/thread 分别缓存 + workspace_redis_key = f"{mention_service.WORKSPACE_CACHE_PREFIX}user_1" + thread_redis_key = f"{mention_service.THREAD_CACHE_PREFIX}thread_1" + cached_workspace = fake_redis.data.get(workspace_redis_key) + cached_thread = fake_redis.data.get(thread_redis_key) + assert cached_workspace is not None + assert cached_thread is not None + # 反序列化校验 - packed_bytes = base64.b64decode(cached_str) - decoded_entries = ormsgpack.unpackb(packed_bytes) - assert len(decoded_entries) == 2 + workspace_entries = ormsgpack.unpackb(base64.b64decode(cached_workspace)) + thread_entries = ormsgpack.unpackb(base64.b64decode(cached_thread)) + assert len(workspace_entries) == 1 + assert len(thread_entries) == 1 # 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 - + # 3. 清理 workspace 缓存后重新读取,应成功更新磁盘扫描内容 + await mention_service.invalidate_workspace_mention_cache("user_1") + assert fake_redis.data.get(workspace_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) + assert any(name == "new_file.py" for name, _, source in index_3 if source == "workspace") + + +@pytest.mark.asyncio +async def test_search_workspace_without_thread_id(mock_sandbox_paths, fake_redis): + workspace = mock_sandbox_paths["workspace"] + uploads = mock_sandbox_paths["uploads"] + (workspace / "guide.md").write_text("workspace") + (uploads / "guide.csv").write_text("thread") + + results = await mention_service.search_mention_files_in_index(None, "user_1", "guide") + + assert len(results) == 1 + assert results[0]["name"] == "guide.md" + assert results[0]["path"] == "/home/gem/user-data/workspace/guide.md" + assert results[0]["source"] == "workspace" + + +@pytest.mark.asyncio +async def test_search_thread_source_before_workspace(mock_sandbox_paths, fake_redis): + workspace = mock_sandbox_paths["workspace"] + uploads = mock_sandbox_paths["uploads"] + (workspace / "report.md").write_text("workspace") + (uploads / "report.md").write_text("thread") + + results = await mention_service.search_mention_files_in_index("thread_1", "user_1", "report") + + assert len(results) == 2 + assert results[0]["path"] == "/home/gem/user-data/uploads/report.md" + assert results[0]["source"] == "thread" + assert results[1]["path"] == "/home/gem/user-data/workspace/report.md" + assert results[1]["source"] == "workspace" @pytest.mark.asyncio @@ -170,6 +207,7 @@ async def test_search_mention_files_in_index(mock_sandbox_paths, fake_redis): 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 + assert results[0]["source"] == "workspace" # 大小写不敏感匹配 results_case = await mention_service.search_mention_files_in_index("thread_1", "user_1", "MAIN") @@ -180,33 +218,32 @@ async def test_search_mention_files_in_index(mock_sandbox_paths, fake_redis): @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 + (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/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 035c8c85..41a8af0e 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -70,6 +70,7 @@ - 标准化 Agent run/SSE 执行链路:run 创建时持久化输入消息并提交后入队,worker 统一写入 Redis Stream envelope,SSE 输出 `event/data/id`、心跳注释、`Last-Event-ID` 回放和终止 `end` 事件;前端强制使用 run API 并支持 ask_user_question 中断后以 resume run 恢复。 - 收敛后端模块边界:文档解析从 `plugins.parser` 移动到 `knowledge.parser`,内容审查从 `plugins.guard` 移动到 `services.guard`。 - 收敛文件服务边界:文件预览判断抽为独立服务,Viewer 文件系统的 workspace 分支复用用户 workspace 服务,线程运行时上下文解析从泛化 `filesystem_service` 拆出为 agent runtime helper。 +- 优化聊天输入 @ 文件提及:未创建 Thread 时可搜索用户 workspace,创建 Thread 后按当前对话文件优先、workspace 兜底的来源顺序搜索,并拆分 workspace/thread 缓存避免假 thread 与跨用户缓存污染。 --- diff --git a/web/src/apis/mention_api.js b/web/src/apis/mention_api.js index 4b5d8eef..8d410bd9 100644 --- a/web/src/apis/mention_api.js +++ b/web/src/apis/mention_api.js @@ -1,10 +1,8 @@ import { apiGet } from './base' export const searchMentionFiles = (threadId, query, signal) => { - // NOTE: threadId 是后端必填参数,为空时直接返回空数组,不发请求 - if (!threadId) return Promise.resolve([]) const params = new URLSearchParams() - params.set('thread_id', threadId) + 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/MessageInputComponent.vue b/web/src/components/MessageInputComponent.vue index 24b1dfd5..9e766db9 100644 --- a/web/src/components/MessageInputComponent.vue +++ b/web/src/components/MessageInputComponent.vue @@ -425,9 +425,8 @@ const updateMentionItems = (query = '') => { subagents: filterItems(subagentItems) } - // NOTE: 如果是尚未开始对话的全新会话,此时 threadId 为空,允许使用临时占位 ID 检索用户全局的工作区文件 if (query) { - const activeThreadId = props.threadId || 'new_thread_placeholder' + const activeThreadId = props.threadId || '' clearTimeout(mentionSearchTimer) mentionSearchTimer = setTimeout(async () => { // 物理中断之前的未完成 HTTP 请求 @@ -458,7 +457,8 @@ const updateMentionItems = (query = '') => { insertValue: path || fileName, tokenLabel: formatMentionToken('file', fileName), description: path, - is_dir: f.is_dir + is_dir: f.is_dir, + source: f.source } })