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 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}")

View File

@ -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 {

View File

@ -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)}

View File

@ -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,
)

View File

@ -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

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 恢复。
- 收敛后端模块边界:文档解析从 `plugins.parser` 移动到 `knowledge.parser`,内容审查从 `plugins.guard` 移动到 `services.guard`
- 收敛文件服务边界文件预览判断抽为独立服务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'
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 })
}

View File

@ -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
}
})