feat: 优化 mention 逻辑,支持在未创建 Thread 的时候 @ 工作区文件
This commit is contained in:
parent
932f990841
commit
4f02b4d35e
@ -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}")
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)}
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 与跨用户缓存污染。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user