feat: 优化 mention 逻辑,支持在未创建 Thread 的时候 @ 工作区文件

This commit is contained in:
Wenjie Zhang 2026-05-30 16:04:39 +08:00
parent 932f990841
commit 4f02b4d35e
8 changed files with 238 additions and 156 deletions

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import os import os
from collections.abc import Sequence
from pathlib import Path from pathlib import Path
import ormsgpack import ormsgpack
@ -37,6 +38,10 @@ MAX_SEARCH_DEPTH = 15
CACHE_TTL = 60 # 缓存有效期 60 秒 CACHE_TTL = 60 # 缓存有效期 60 秒
MAX_CACHED_ENTRIES = 100000 MAX_CACHED_ENTRIES = 100000
REDIS_KEY_PREFIX = "yuxi:mention:cache:" 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]]: 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 return results
async def get_or_build_file_index( async def _read_cached_index(redis, redis_key: str) -> list[tuple[str, str]] | None:
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 连接。
cached_str = await redis.get(redis_key) cached_str = await redis.get(redis_key)
if cached_str: if not cached_str:
try: return None
# NOTE: decode_responses=True 的 Redis 客户端只能存 str try:
# 使用 base64 对 ormsgpack 的二进制输出进行无损编码后存储。 packed_bytes = base64.b64decode(cached_str)
packed_bytes = base64.b64decode(cached_str) return ormsgpack.unpackb(packed_bytes)
return ormsgpack.unpackb(packed_bytes) except Exception as e:
except Exception as e: logger.warning(f"Failed to unpack mention cache {redis_key}: {e}")
logger.warning(f"Failed to unpack mention cache for thread {thread_id}: {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]] = [] async def _write_cached_index(redis, redis_key: str, entries: list[tuple[str, str]]) -> None:
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: try:
packed_bytes = ormsgpack.packb(entries) packed_bytes = ormsgpack.packb(entries)
packed_str = base64.b64encode(packed_bytes).decode("ascii") packed_str = base64.b64encode(packed_bytes).decode("ascii")
await redis.set(redis_key, packed_str, ex=CACHE_TTL) await redis.set(redis_key, packed_str, ex=CACHE_TTL)
except Exception as e: 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 return entries
async def search_mention_files_in_index( def _rank_mention_entries(index: list[tuple[str, str, str]], query: str) -> list[dict]:
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 []
query_lower = query.lower() query_lower = query.lower()
prefix = (config.sandbox_virtual_path_prefix or "/home/gem/user-data").rstrip("/") prefix = (config.sandbox_virtual_path_prefix or "/home/gem/user-data").rstrip("/")
name_matched = []
path_matched = []
# 存储加权匹配结果 for name, virtual_path, source in index:
name_matched = [] # 文件名/目录名直接匹配的项 (高分)
path_matched = [] # 仅路径匹配的项 (低分,作为兜底)
for name, virtual_path in index:
name_lower = name.lower() name_lower = name.lower()
path_lower = virtual_path.lower() path_lower = virtual_path.lower()
is_dir = virtual_path.endswith("/") is_dir = virtual_path.endswith("/")
# 1. 优先判定名称是否包含关键字 (置顶)
if query_lower in name_lower: if query_lower in name_lower:
if name_lower == query_lower: if name_lower == query_lower:
score = 1000.0 # 完全匹配 score = 1000.0
else: else:
score = 500.0 # 基础名称匹配分 score = 500.0
# 附加前缀优势
if name_lower.startswith(query_lower): if name_lower.startswith(query_lower):
score += 50.0 score += 50.0
# 附加后缀优势
if name_lower.endswith(query_lower): if name_lower.endswith(query_lower):
score += 20.0 score += 20.0
# 位置惩罚:匹配位置越靠后,给与轻微扣分 (最高扣 30 分)
start_idx = name_lower.find(query_lower) start_idx = name_lower.find(query_lower)
if start_idx != -1: if start_idx != -1:
score -= min(start_idx, 30.0) score -= min(start_idx, 30.0)
# 长度惩罚:文件名越长,扣分越多 (最高扣 50 分,以优先展示简短、高信息密度的核心文件)
score -= min(len(name) * 0.5, 50.0) score -= min(len(name) * 0.5, 50.0)
name_matched.append({"name": name, "path": f"{prefix}/{virtual_path}", "is_dir": is_dir, "score": score}) name_matched.append(
{"name": name, "path": f"{prefix}/{virtual_path}", "is_dir": is_dir, "source": source, "score": score}
# 2. 其次判定是否为纯路径匹配 (名称不匹配,但路径中包含) )
elif query_lower in path_lower: elif query_lower in path_lower:
score = 10.0 score = 10.0 - min(len(virtual_path) * 0.1, 5.0)
# 路径长度惩罚
score -= min(len(virtual_path) * 0.1, 5.0)
path_matched.append( path_matched.append(
{ {"name": name, "path": f"{prefix}/{virtual_path}", "is_dir": is_dir, "source": source, "score": score}
"name": name,
"path": f"{prefix}/{virtual_path}",
"is_dir": is_dir,
"score": score,
}
) )
# 对名称直接匹配项按照打分降序进行精准排序 (打分已融合位置与长度惩罚)
name_matched.sort(key=lambda x: -x["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 [ return [
{"name": item["name"], "path": item["path"], "is_dir": item["is_dir"]} {"name": item["name"], "path": item["path"], "is_dir": item["is_dir"], "source": item["source"]}
for item in merged_results[:MAX_MENTION_RESULTS] for item in results[:MAX_MENTION_RESULTS]
] ]
async def invalidate_mention_cache(thread_id: str) -> None: async def invalidate_mention_cache(thread_id: str) -> None:
""" """清理指定 thread 的提及文件缓存。"""
轻量级缓存清理工具函数主动清除指定 thread 的提及缓存
"""
try: try:
redis = await get_redis_client() redis = await get_redis_client()
redis_key = f"{REDIS_KEY_PREFIX}{thread_id}" await redis.delete(f"{THREAD_CACHE_PREFIX}{thread_id}")
await redis.delete(redis_key) await redis.delete(f"{REDIS_KEY_PREFIX}{thread_id}")
except Exception as e: except Exception as e:
logger.warning(f"Failed to invalidate mention cache for thread {thread_id}: {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}")

View File

@ -17,7 +17,7 @@ from yuxi.agents.backends.sandbox import (
) )
from yuxi.repositories.conversation_repository import ConversationRepository from yuxi.repositories.conversation_repository import ConversationRepository
from yuxi.services.conversation_service import require_user_conversation 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 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) shutil.copyfileobj(src, dst)
await invalidate_mention_cache(thread_id) 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) saved_virtual_path = virtual_path_for_thread_file(thread_id, target_path, uid=uid)
return { return {

View File

@ -13,6 +13,7 @@ from fastapi import HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from yuxi.agents.backends.sandbox.paths import _global_user_data_dir, ensure_workspace_default_files 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.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.utils.upload_utils import MAX_UPLOAD_SIZE_BYTES, write_upload_to_buffer
from yuxi.storage.postgres.models_business import User from yuxi.storage.postgres.models_business import User
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp 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: except PermissionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from 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()} 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: except PermissionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from 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)} 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): with contextlib.suppress(OSError):
await asyncio.to_thread(target.unlink) await asyncio.to_thread(target.unlink)
await invalidate_workspace_mention_cache(str(current_user.uid))
return {"success": True, "entry": _entry_for_path(root, target)} return {"success": True, "entry": _entry_for_path(root, target)}

View File

@ -17,40 +17,42 @@ class MentionFileItem(BaseModel):
name: str name: str
path: str path: str
is_dir: bool is_dir: bool
source: str
@mention_router.get("/search", response_model=list[MentionFileItem]) @mention_router.get("/search", response_model=list[MentionFileItem])
async def search_mention_files( async def search_mention_files(
thread_id: str = Query(..., description="当前聊天会话 ID"), thread_id: str | None = Query(None, description="当前聊天会话 ID;为空时仅搜索用户工作区"),
query: str = Query("", description="模糊搜索关键字"), query: str = Query("", description="模糊搜索关键字"),
sources: str | None = Query(None, description="搜索来源workspace,thread为空时自动选择"),
current_user: User = Depends(get_required_user), current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
提及文件模糊搜索接口使用 Redis 二进制缓存进行极速过滤防止大文件递归卡死 提及文件模糊搜索接口未创建 thread 时只搜索用户 workspace已有 thread 时可搜索当前对话文件
调用前校验 thread 归属权防止 IDOR 越权访问他人会话文件
""" """
user_id = str(current_user.id) uid = str(current_user.uid)
effective_thread_id: str | None = None
# NOTE: 校验 thread 归属权,防止恶意用户传入他人 thread_id 遍历文件 if thread_id:
conv_repo = ConversationRepository(db) conv_repo = ConversationRepository(db)
conversation = await conv_repo.get_conversation_by_thread_id(thread_id) conversation = await conv_repo.get_conversation_by_thread_id(thread_id)
if conversation: if conversation:
if conversation.user_id != user_id or conversation.status == "deleted": if conversation.uid != uid or conversation.status == "deleted":
raise HTTPException(status_code=404, detail="对话线程不存在") raise HTTPException(status_code=404, detail="对话线程不存在")
else: effective_thread_id = thread_id
# NOTE: 如果是尚未在数据库记录的全新对话(还未发送首条消息),在格式校验安全的前提下放行。 else:
# 此时该 thread 专属的 uploads/outputs 目录还没创建或为空, try:
# 用户仅能安全地搜索到自己全局的工作区 (workspace) 文件。 from yuxi.agents.backends.sandbox.paths import validate_thread_id
try:
from yuxi.agents.backends.sandbox.paths import validate_thread_id
validate_thread_id(thread_id) validate_thread_id(thread_id)
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="非法的 thread_id 格式") 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( return await search_mention_files_in_index(
thread_id=thread_id, thread_id=effective_thread_id,
user_id=user_id, uid=uid,
query=query, query=query,
sources=source_list,
) )

View File

@ -53,8 +53,10 @@ def mock_sandbox_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
@pytest.fixture @pytest.fixture
def fake_redis(monkeypatch: pytest.MonkeyPatch) -> _FakeRedis: def fake_redis(monkeypatch: pytest.MonkeyPatch) -> _FakeRedis:
redis = _FakeRedis() redis = _FakeRedis()
async def mock_get_redis(): async def mock_get_redis():
return redis return redis
monkeypatch.setattr(mention_service, "get_redis_client", mock_get_redis) monkeypatch.setattr(mention_service, "get_redis_client", mock_get_redis)
return redis return redis
@ -134,28 +136,63 @@ async def test_mention_cache_lifecycle_with_ormsgpack(mock_sandbox_paths, fake_r
index_1 = await mention_service.get_or_build_file_index("thread_1", "user_1") index_1 = await mention_service.get_or_build_file_index("thread_1", "user_1")
assert len(index_1) == 2 assert len(index_1) == 2
# 验证 Redis 中已存有缓存 # 验证 Redis 中已按 workspace/thread 分别缓存
redis_key = f"{mention_service.REDIS_KEY_PREFIX}thread_1" workspace_redis_key = f"{mention_service.WORKSPACE_CACHE_PREFIX}user_1"
cached_str = fake_redis.data.get(redis_key) thread_redis_key = f"{mention_service.THREAD_CACHE_PREFIX}thread_1"
assert cached_str is not None 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) workspace_entries = ormsgpack.unpackb(base64.b64decode(cached_workspace))
decoded_entries = ormsgpack.unpackb(packed_bytes) thread_entries = ormsgpack.unpackb(base64.b64decode(cached_thread))
assert len(decoded_entries) == 2 assert len(workspace_entries) == 1
assert len(thread_entries) == 1
# 2. 修改磁盘文件,但在 TTL 内应仍然走 Redis 缓存,内容不更新 # 2. 修改磁盘文件,但在 TTL 内应仍然走 Redis 缓存,内容不更新
(workspace / "new_file.py").write_text("new") (workspace / "new_file.py").write_text("new")
index_2 = await mention_service.get_or_build_file_index("thread_1", "user_1") index_2 = await mention_service.get_or_build_file_index("thread_1", "user_1")
assert len(index_2) == 2 # 仍然命中缓存,没有扫描出 new_file.py assert len(index_2) == 2 # 仍然命中缓存,没有扫描出 new_file.py
# 3. 清理缓存后重新读取,应成功更新磁盘扫描内容 # 3. 清理 workspace 缓存后重新读取,应成功更新磁盘扫描内容
await mention_service.invalidate_mention_cache("thread_1") await mention_service.invalidate_workspace_mention_cache("user_1")
assert fake_redis.data.get(redis_key) is None assert fake_redis.data.get(workspace_redis_key) is None
index_3 = await mention_service.get_or_build_file_index("thread_1", "user_1") index_3 = await mention_service.get_or_build_file_index("thread_1", "user_1")
assert len(index_3) == 3 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 @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]["name"] == "agent_config.json"
assert results[0]["path"] == "/home/gem/user-data/workspace/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]["is_dir"] is False
assert results[0]["source"] == "workspace"
# 大小写不敏感匹配 # 大小写不敏感匹配
results_case = await mention_service.search_mention_files_in_index("thread_1", "user_1", "MAIN") results_case = await mention_service.search_mention_files_in_index("thread_1", "user_1", "MAIN")
@ -187,7 +225,7 @@ async def test_search_mention_directories_and_weighted_ranking(mock_sandbox_path
# 2. 在子目录下创建一些包含关键字的文件 # 2. 在子目录下创建一些包含关键字的文件
(test_dir / "test_auth.py").write_text("auth") (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" # 3. 搜索 "@test"
results = await mention_service.search_mention_files_in_index("thread_1", "user_1", "test") results = await mention_service.search_mention_files_in_index("thread_1", "user_1", "test")
@ -209,4 +247,3 @@ async def test_search_mention_directories_and_weighted_ranking(mock_sandbox_path
# "conftest.py" 文件名不含 test为纯路径匹配兜底 (10分),必须排在最后 # "conftest.py" 文件名不含 test为纯路径匹配兜底 (10分),必须排在最后
assert results[2]["name"] == "conftest.py" assert results[2]["name"] == "conftest.py"
assert results[2]["is_dir"] is False assert results[2]["is_dir"] is False

View File

@ -70,6 +70,7 @@
- 标准化 Agent run/SSE 执行链路run 创建时持久化输入消息并提交后入队worker 统一写入 Redis Stream envelopeSSE 输出 `event/data/id`、心跳注释、`Last-Event-ID` 回放和终止 `end` 事件;前端强制使用 run API 并支持 ask_user_question 中断后以 resume run 恢复。 - 标准化 Agent run/SSE 执行链路run 创建时持久化输入消息并提交后入队worker 统一写入 Redis Stream envelopeSSE 输出 `event/data/id`、心跳注释、`Last-Event-ID` 回放和终止 `end` 事件;前端强制使用 run API 并支持 ask_user_question 中断后以 resume run 恢复。
- 收敛后端模块边界:文档解析从 `plugins.parser` 移动到 `knowledge.parser`,内容审查从 `plugins.guard` 移动到 `services.guard` - 收敛后端模块边界:文档解析从 `plugins.parser` 移动到 `knowledge.parser`,内容审查从 `plugins.guard` 移动到 `services.guard`
- 收敛文件服务边界文件预览判断抽为独立服务Viewer 文件系统的 workspace 分支复用用户 workspace 服务,线程运行时上下文解析从泛化 `filesystem_service` 拆出为 agent runtime helper。 - 收敛文件服务边界文件预览判断抽为独立服务Viewer 文件系统的 workspace 分支复用用户 workspace 服务,线程运行时上下文解析从泛化 `filesystem_service` 拆出为 agent runtime helper。
- 优化聊天输入 @ 文件提及:未创建 Thread 时可搜索用户 workspace创建 Thread 后按当前对话文件优先、workspace 兜底的来源顺序搜索,并拆分 workspace/thread 缓存避免假 thread 与跨用户缓存污染。
--- ---

View File

@ -1,10 +1,8 @@
import { apiGet } from './base' import { apiGet } from './base'
export const searchMentionFiles = (threadId, query, signal) => { export const searchMentionFiles = (threadId, query, signal) => {
// NOTE: threadId 是后端必填参数,为空时直接返回空数组,不发请求
if (!threadId) return Promise.resolve([])
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('thread_id', threadId) if (threadId) params.set('thread_id', threadId)
if (query) params.set('query', query) if (query) params.set('query', query)
return apiGet(`/api/mention/search?${params.toString()}`, { signal }) return apiGet(`/api/mention/search?${params.toString()}`, { signal })
} }

View File

@ -425,9 +425,8 @@ const updateMentionItems = (query = '') => {
subagents: filterItems(subagentItems) subagents: filterItems(subagentItems)
} }
// NOTE: threadId 使 ID
if (query) { if (query) {
const activeThreadId = props.threadId || 'new_thread_placeholder' const activeThreadId = props.threadId || ''
clearTimeout(mentionSearchTimer) clearTimeout(mentionSearchTimer)
mentionSearchTimer = setTimeout(async () => { mentionSearchTimer = setTimeout(async () => {
// HTTP // HTTP
@ -458,7 +457,8 @@ const updateMentionItems = (query = '') => {
insertValue: path || fileName, insertValue: path || fileName,
tokenLabel: formatMentionToken('file', fileName), tokenLabel: formatMentionToken('file', fileName),
description: path, description: path,
is_dir: f.is_dir is_dir: f.is_dir,
source: f.source
} }
}) })