feat: 修复 @ mention 搜索漏洞并重构优化 AgentPanel 自动打开策略

1. 修复 mention_router 中的 IDOR 越权安全漏洞,加入 thread 归属权校验,定义强类型 Pydantic Schema。
2. 修复 mention_search_service 中 ensure_thread_dirs 的 GET 副作用、空 Query 扫描和 MAX_SEARCH_DEPTH 深度问题,使用标准 Base64 编解码。
3. 增加前端 mention_api.js 参数的安全兜底。
4. 重构 AgentPanel 自动展开策略,彻底解决大仓库克隆时的卡死问题:
   - 将 fetchThreadFiles 改为 recursive=false 避免递归大目录。
   - 重构为只有在发生显性的物理文件写入(用户上传附件成功、用户保存交付件到工作区成功)的回调中才精准触发侧边栏自动展开,完美契合用户心智模型与性能诉求。
5. 精简 roadmap.md 文档,更新相关单元测试。
This commit is contained in:
supreme0597 2026-05-22 12:30:22 +08:00
parent 65135e2f93
commit 469e362506
6 changed files with 42 additions and 25 deletions

View File

@ -1,12 +1,12 @@
from __future__ import annotations
import asyncio
import base64
import os
from pathlib import Path
import ormsgpack
from yuxi.agents.backends.sandbox.paths import (
ensure_thread_dirs,
sandbox_outputs_dir,
sandbox_uploads_dir,
sandbox_workspace_dir,
@ -52,10 +52,10 @@ def _scan_pruned_files(root: Path, max_entries: int) -> list[tuple[str, str]]:
# 1. 剪枝黑名单和隐藏目录 (直接在 dirnames 中修改,阻止 os.walk 深入)
dirnames[:] = [d for d in dirnames if d not in MENTION_EXCLUDE_DIRS and not d.startswith(".")]
# 2. 深度保护:限制最大搜索深度
# 2. 深度保护:限制最大搜索深度root 本身为第 0 层,第 15 层时 rel.parts 长度恰好为 15
try:
rel = Path(dirpath).relative_to(root)
if len(rel.parts) > MAX_SEARCH_DEPTH:
if len(rel.parts) >= MAX_SEARCH_DEPTH:
dirnames.clear()
continue
except Exception:
@ -97,8 +97,6 @@ async def get_or_build_file_index(
"""
获取或构建当前 Workspace Thread 的提及文件索引缓存 (使用 ormsgpack 二进制序列化)
"""
ensure_thread_dirs(thread_id, user_id)
redis = await get_redis_client()
redis_key = f"{REDIS_KEY_PREFIX}{thread_id}"
@ -109,7 +107,9 @@ async def get_or_build_file_index(
cached_str = await redis.get(redis_key)
if cached_str:
try:
packed_bytes = cached_str.encode("latin1")
# 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}")
@ -138,7 +138,7 @@ async def get_or_build_file_index(
# 写入 Redis 缓存
try:
packed_bytes = ormsgpack.packb(entries)
packed_str = packed_bytes.decode("latin1")
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}")
@ -158,6 +158,10 @@ async def search_mention_files_in_index(
if not index:
return []
# NOTE: query 为空时不执行搜索,避免空字符串匹配所有条目(空串是任何字符串的子串)
if not query:
return []
query_lower = query.lower()
prefix = (config.sandbox_virtual_path_prefix or "/home/gem/user-data").rstrip("/")

View File

@ -1,23 +1,43 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from server.utils.auth_middleware import get_required_user
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from server.utils.auth_middleware import get_db, get_required_user
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.repositories.conversation_repository import ConversationRepository
from yuxi.services.mention_search_service import search_mention_files_in_index
from yuxi.storage.postgres.models_business import User
mention_router = APIRouter(prefix="/mention", tags=["mention"])
@mention_router.get("/search", response_model=list[dict])
class MentionFileItem(BaseModel):
"""提及文件搜索结果条目"""
name: str
path: str
is_dir: bool
@mention_router.get("/search", response_model=list[MentionFileItem])
async def search_mention_files(
thread_id: str = Query(..., description="当前聊天会话 ID"),
query: str = Query("", description="模糊搜索关键字"),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""
提及文件模糊搜索接口使用 Redis 二进制缓存进行极速过滤防止大文件递归卡死
调用前校验 thread 归属权防止 IDOR 越权访问他人会话文件
"""
user_id = str(current_user.id)
# NOTE: 校验 thread 归属权,防止恶意用户传入他人 thread_id 遍历文件
conv_repo = ConversationRepository(db)
conversation = await conv_repo.get_conversation_by_thread_id(thread_id)
if not conversation or conversation.user_id != user_id or conversation.status == "deleted":
raise HTTPException(status_code=404, detail="对话线程不存在")
return await search_mention_files_in_index(
thread_id=thread_id,
user_id=user_id,

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import base64
from pathlib import Path
import pytest
@ -41,7 +42,6 @@ def mock_sandbox_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(mention_service, "sandbox_workspace_dir", lambda t, u: workspace_dir)
monkeypatch.setattr(mention_service, "sandbox_uploads_dir", lambda t: uploads_dir)
monkeypatch.setattr(mention_service, "sandbox_outputs_dir", lambda t: outputs_dir)
monkeypatch.setattr(mention_service, "ensure_thread_dirs", lambda t, u: None)
return {
"workspace": workspace_dir,
@ -140,7 +140,7 @@ async def test_mention_cache_lifecycle_with_ormsgpack(mock_sandbox_paths, fake_r
assert cached_str is not None
# 反序列化校验
packed_bytes = cached_str.encode("latin1")
packed_bytes = base64.b64decode(cached_str)
decoded_entries = ormsgpack.unpackb(packed_bytes)
assert len(decoded_entries) == 2

View File

@ -42,7 +42,7 @@
- 移除知识库沙盒文件系统映射:不再通过 `/home/gem/kbs` 暴露知识库文件树Agent 继续使用 `query_kb``open_kb_document` 访问知识库内容。
- 优化评估基准自动生成:仅支持 commonrag/Milvus 知识库,默认参考 chunks 数量改为 1多 chunk 场景复用知识库向量检索选择相似 chunks不再对全量 chunks 重新计算 embedding并移除前端 Embedding 模型选择。
- 修复知识库文档入库状态回退:当已解析文件缺失 `markdown_file` 解析产物时,索引流程会将文件状态恢复为未解析,便于重新解析而不是停留在索引失败。
- 优化 Agent 输入框文件 mention 候选性能并重构为后端异步搜索与二进制极速缓存体系:后端引入 Redis + Rust 驱动的 `ormsgpack` 二进制序列化紧凑 Tuple 缓存,使用 `latin1` 单字节极速字符转换,以规避 BigKey 隐患及 `decode_responses` 解码冲突,实现基于宽度熔断 (500个/目录)、深度保护 (15层) 和黑名单剪枝的异步多线程磁盘安全扫描;提供 `/api/mention/search` 检索接口,实现微秒级内存匹配与防抖/防竞态物理截断;突破性地将 Redis 缓存元组容纳限额从 5,000 条大幅提高到 100,000 条,保证全量物理文件无遗漏扫描装载;同时引入基于匹配位置偏好、前缀/后缀特征加权和长度扣分的 IDE 级模糊匹配平滑打分算法,实现智能降序的混合展示,将搜索结果最大上限提至 50彻底解决同名短文件如 CSV、非开头匹配但高相关核心文件被海量测试脚本粗暴截断和挤出名额的痛点。前端删除对巨型 workspace 和 thread 文件数组的全量遍历与合并计算废弃初始化阶段对整个工作区的深度递归O(N) -> O(1) 浅探测),解决在超大项目下前端 CPU 冻结与卡顿;采用防抖 (250ms) + `AbortController` 抗竞态多重物理截断请求,配合 6 大核心单元测试 100% 覆盖通过;前端完美接入 100% 防 XSS 且高性能的切片式文本高亮渲染,同时重构 CSS 排版布局,去除两端对齐与右对齐拉伸,令路径信息紧跟在文件名右侧,带来极致、温润的现代微视觉感知交互
- 优化 `@` 文件 mention 候选搜索:前端废弃全量递归遍历,改为后端 `/api/mention/search` 接口 + Redis `ormsgpack` 二进制缓存TTL 60s上限 10 万条);扫描加宽度/深度/黑名单三重剪枝防卡死;搜索结果按文件名/前缀/路径加权排序,最多返回 50 条前端加防抖250ms+ `AbortController` 防竞态,高亮渲染使用 DOMPurify 防 XSS
- 调整知识库思维导图后端结构:将思维导图路由文件重命名为知识库语义更明确的 router并把文件列表整理、提示词构建、AI JSON 解析等纯逻辑下沉到知识库 utils。
- 收敛知识库评估后端结构:将评估指标、单题评估、答案生成提示词和自动基准生成算法下沉到 `knowledge/eval``EvaluationService` 保留任务、文件和持久化编排职责。
- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API并增加“工作区”页面用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;工作区预览支持 Markdown/TXT 在右侧预览框内切换编辑并保存,其他格式和非工作区预览默认只读;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。

View File

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

View File

@ -255,7 +255,6 @@ import { useAgentRunStream } from '@/composables/useAgentRunStream'
import { useAgentStreamHandler } from '@/composables/useAgentStreamHandler'
import { useStreamSmoother } from '@/composables/useStreamSmoother'
import { useAgentMentionConfig } from '@/composables/useAgentMentionConfig'
import { shouldAutoOpenAgentPanel } from '@/utils/agentPanelAutoOpen'
import AgentArtifactsCard from '@/components/AgentArtifactsCard.vue'
import AgentPanel from '@/components/AgentPanel.vue'
@ -432,17 +431,7 @@ const currentTodos = computed(() => {
return Array.isArray(todos) ? todos : []
})
const hasAgentStateContent = computed(() => {
return shouldAutoOpenAgentPanel(currentThreadFiles.value)
})
// hasAgentStateContent false true
watch(hasAgentStateContent, (newVal, oldVal) => {
if (newVal && !oldVal) {
//
isAgentPanelOpen.value = true
}
})
const { mentionConfig } = useAgentMentionConfig({
currentAgentState,
currentThreadAttachments,
@ -1014,6 +1003,7 @@ const refreshThreadFilesAndAttachments = async (threadId) => {
const handleArtifactSaved = async () => {
if (!currentChatId.value) return
await refreshThreadFilesAndAttachments(currentChatId.value)
isAgentPanelOpen.value = true
}
const fetchAgentState = async (agentId, threadId) => {
@ -1084,6 +1074,7 @@ const handleAttachmentUpload = async (files) => {
fetchAgentState(currentAgentId.value, threadId),
refreshThreadFilesAndAttachments(threadId)
])
isAgentPanelOpen.value = true
} catch (error) {
message.destroy('upload-attachment')
handleChatError(error, 'upload')