feat(mention): 优化@提及搜索排序与高亮展示,修复唤醒交互及路径硬编码问题

This commit is contained in:
supreme0597 2026-05-21 23:06:33 +08:00 committed by Wenjie Zhang
parent 575c34b160
commit d0c60d9d82
26 changed files with 920 additions and 221 deletions

View File

@ -3,10 +3,11 @@ RAG评估指标计算工具
简化版只保留Recall/F1检索 LLM Judge答案准确性
"""
import json_repair
import textwrap
from typing import Any
import json_repair
from yuxi.utils import logger

View File

@ -4,7 +4,6 @@ import json
import textwrap
from typing import Any
MINDMAP_SYSTEM_PROMPT = """你是一个专业的知识整理助手。
你的任务是分析用户提供的文件列表生成一个层次分明的思维导图结构

View File

@ -0,0 +1,240 @@
from __future__ import annotations
import asyncio
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,
)
from yuxi.config.app import config
from yuxi.services.run_queue_service import get_redis_client
from yuxi.utils.logging_config import logger
MENTION_EXCLUDE_DIRS = {
".git",
"node_modules",
".venv",
"venv",
"__pycache__",
".idea",
".vscode",
"dist",
"build",
".tox",
".mypy_cache",
".pytest_cache",
".ruff_cache",
}
MAX_MENTION_RESULTS = 50
MAX_ENTRIES_PER_DIR = 500
MAX_SEARCH_DEPTH = 15
CACHE_TTL = 60 # 缓存有效期 60 秒
MAX_CACHED_ENTRIES = 100000
REDIS_KEY_PREFIX = "yuxi:mention:cache:"
def _scan_pruned_files(root: Path, max_entries: int) -> list[tuple[str, str]]:
"""
同步扫描磁盘文件目录并进行多重限额剪枝保护 (防止大文件仓库卡死)
"""
results: list[tuple[str, str]] = []
if not root.exists():
return results
root_str = str(root)
for dirpath, dirnames, filenames in os.walk(root_str):
# 1. 剪枝黑名单和隐藏目录 (直接在 dirnames 中修改,阻止 os.walk 深入)
dirnames[:] = [d for d in dirnames if d not in MENTION_EXCLUDE_DIRS and not d.startswith(".")]
# 2. 深度保护:限制最大搜索深度
try:
rel = Path(dirpath).relative_to(root)
if len(rel.parts) > MAX_SEARCH_DEPTH:
dirnames.clear()
continue
except Exception:
pass
# 3. 宽度与全局限额保护下的合格“子目录实体”收集
for dirname in dirnames:
full_dir_path = Path(dirpath) / dirname
rel_dir_path = full_dir_path.relative_to(root).as_posix()
# 使用以 '/' 结尾的虚拟相对路径,代表这是一个目录
virtual_dir_path = f"{rel_dir_path}/"
results.append((dirname, virtual_dir_path))
if len(results) >= max_entries:
return results
# 4. 宽度限额保护:单层目录限制最多只读取 500 个文件,防止扁平超宽目录卡死
scan_filenames = filenames[:MAX_ENTRIES_PER_DIR]
for filename in scan_filenames:
full_path = Path(dirpath) / filename
# 计算相对于根路径的相对路径
rel_path = full_path.relative_to(root).as_posix()
# 存为紧凑型元组 (filename, relative_path)
results.append((filename, rel_path))
# 5. 全局上限保护:如果总文件数已达上限,熔断退出
if len(results) >= max_entries:
return results
return results
async def get_or_build_file_index(
thread_id: str,
user_id: str,
) -> list[tuple[str, str]]:
"""
获取或构建当前 Workspace Thread 的提及文件索引缓存 (使用 ormsgpack 二进制序列化)
"""
ensure_thread_dirs(thread_id, user_id)
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)
if cached_str:
try:
packed_bytes = cached_str.encode("latin1")
return ormsgpack.unpackb(packed_bytes)
except Exception as e:
logger.warning(f"Failed to unpack mention cache for thread {thread_id}: {e}")
# 缓存未命中,在 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 缓存
try:
packed_bytes = ormsgpack.packb(entries)
packed_str = packed_bytes.decode("latin1")
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}")
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 []
query_lower = query.lower()
prefix = (config.sandbox_virtual_path_prefix or "/home/gem/user-data").rstrip("/")
# 存储加权匹配结果
name_matched = [] # 文件名/目录名直接匹配的项 (高分)
path_matched = [] # 仅路径匹配的项 (低分,作为兜底)
for name, virtual_path 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 # 完全匹配
else:
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. 其次判定是否为纯路径匹配 (名称不匹配,但路径中包含)
elif query_lower in path_lower:
score = 10.0
# 路径长度惩罚
score -= 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_matched.sort(key=lambda x: -x["score"])
# 智能融合:如果名称匹配项不足 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 属性以识别目录
return [
{"name": item["name"], "path": item["path"], "is_dir": item["is_dir"]}
for item in merged_results[:MAX_MENTION_RESULTS]
]
async def invalidate_mention_cache(thread_id: str) -> None:
"""
轻量级缓存清理工具函数主动清除指定 thread 的提及缓存
"""
try:
redis = await get_redis_client()
redis_key = f"{REDIS_KEY_PREFIX}{thread_id}"
await redis.delete(redis_key)
except Exception as e:
logger.warning(f"Failed to invalidate mention cache for thread {thread_id}: {e}")

View File

@ -16,6 +16,7 @@ from server.routers.tool_router import tools
from server.routers.auth_apikey_router import apikey_router
from server.routers.filesystem_router import filesystem_router
from server.routers.workspace_router import workspace
from server.routers.mention_router import mention_router
_LITE_MODE = os.environ.get("LITE_MODE", "").lower() in ("true", "1")
@ -38,6 +39,7 @@ router.include_router(tools) # /api/system/tools/* 工具列表与配置
router.include_router(apikey_router) # /api/apikey/* API Key 管理
router.include_router(filesystem_router) # /api/viewer/filesystem/* 工作台文件系统视图
router.include_router(workspace) # /api/workspace/* 用户个人工作区
router.include_router(mention_router) # /api/mention/* 提及文件搜索接口
if not _LITE_MODE:
from server.routers.graph_router import graph

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from server.utils.auth_middleware import get_required_user
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])
async def search_mention_files(
thread_id: str = Query(..., description="当前聊天会话 ID"),
query: str = Query("", description="模糊搜索关键字"),
current_user: User = Depends(get_required_user),
):
"""
提及文件模糊搜索接口使用 Redis 二进制缓存进行极速过滤防止大文件递归卡死
"""
user_id = str(current_user.id)
return await search_mention_files_in_index(
thread_id=thread_id,
user_id=user_id,
query=query,
)

View File

@ -0,0 +1,212 @@
from __future__ import annotations
from pathlib import Path
import pytest
import ormsgpack
import yuxi.services.mention_search_service as mention_service
class _FakeRedis:
def __init__(self):
self.data: dict[str, str] = {}
self.expire_calls: dict[str, int] = {}
self.delete_calls: list[str] = []
async def get(self, key: str) -> str | None:
return self.data.get(key)
async def set(self, key: str, value: str, ex: int | None = None) -> None:
self.data[key] = value
if ex is not None:
self.expire_calls[key] = ex
async def delete(self, key: str) -> None:
self.delete_calls.append(key)
self.data.pop(key, None)
@pytest.fixture
def mock_sandbox_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
# 创建模拟的工作区、上传、输出目录
workspace_dir = tmp_path / "shared" / "user_1" / "workspace"
uploads_dir = tmp_path / "threads" / "thread_1" / "user-data" / "uploads"
outputs_dir = tmp_path / "threads" / "thread_1" / "user-data" / "outputs"
workspace_dir.mkdir(parents=True, exist_ok=True)
uploads_dir.mkdir(parents=True, exist_ok=True)
outputs_dir.mkdir(parents=True, exist_ok=True)
# Mock sandbox_paths 的函数
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,
"uploads": uploads_dir,
"outputs": outputs_dir,
}
@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
@pytest.mark.asyncio
async def test_scan_pruned_files_and_exclude_dirs(mock_sandbox_paths):
workspace = mock_sandbox_paths["workspace"]
# 创建常规文件
(workspace / "main.py").write_text("print('hello')")
(workspace / "utils.py").write_text("def run(): pass")
# 创建被排除的目录和文件
git_dir = workspace / ".git"
git_dir.mkdir()
(git_dir / "config").write_text("[core]")
node_modules = workspace / "node_modules"
node_modules.mkdir()
(node_modules / "express.js").write_text("module.exports = {}")
# 扫描
results = mention_service._scan_pruned_files(workspace, 100)
# 校验
files = {name for name, _ in results}
assert "main.py" in files
assert "utils.py" in files
assert "config" not in files
assert "express.js" not in files
@pytest.mark.asyncio
async def test_scan_depth_protection(mock_sandbox_paths):
workspace = mock_sandbox_paths["workspace"]
# 创建超深的文件树路径:超过 15 层
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")
# 扫描
results = mention_service._scan_pruned_files(workspace, 100)
files = {name for name, _ in results}
# 深度限制应成功剪枝拦截该超深文件
assert "deep_file.py" not in files
@pytest.mark.asyncio
async def test_scan_width_limit(mock_sandbox_paths):
workspace = mock_sandbox_paths["workspace"]
# 创建 600 个扁平的小文件
for i in range(600):
(workspace / f"file_{i}.py").write_text(str(i))
# 扫描,设置 max_entries = 1000看看单目录 500 的宽度限额是否起作用)
results = mention_service._scan_pruned_files(workspace, 1000)
# 限制单目录 MAX_ENTRIES_PER_DIR = 500 熔断
assert len(results) == 500
@pytest.mark.asyncio
async def test_mention_cache_lifecycle_with_ormsgpack(mock_sandbox_paths, fake_redis):
workspace = mock_sandbox_paths["workspace"]
uploads = mock_sandbox_paths["uploads"]
(workspace / "main.py").write_text("main")
(uploads / "data.csv").write_text("csv")
# 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
# 反序列化校验
packed_bytes = cached_str.encode("latin1")
decoded_entries = ormsgpack.unpackb(packed_bytes)
assert len(decoded_entries) == 2
# 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
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)
@pytest.mark.asyncio
async def test_search_mention_files_in_index(mock_sandbox_paths, fake_redis):
workspace = mock_sandbox_paths["workspace"]
(workspace / "agent_config.json").write_text("config")
(workspace / "main.py").write_text("main")
# 搜索匹配测试
results = await mention_service.search_mention_files_in_index("thread_1", "user_1", "config")
assert len(results) == 1
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
# 大小写不敏感匹配
results_case = await mention_service.search_mention_files_in_index("thread_1", "user_1", "MAIN")
assert len(results_case) == 1
assert results_case[0]["name"] == "main.py"
@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
# 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

@ -132,7 +132,9 @@ export async function apiRequest(url, options = {}, requiresAuth = true, respons
return response
}
} catch (error) {
console.error('API请求错误:', error)
if (error.name !== 'AbortError') {
console.error('API请求错误:', error)
}
throw error
}
}

View File

@ -15,6 +15,7 @@ export * from './mcp_api' // MCP API
export * from './skill_api' // Skills API
export * from './subagent_api' // SubAgent API
export * from './tool_api' // 工具 API
export * from './mention_api' // 提及搜索 API
// 导出基础工具函数
export {

View File

@ -0,0 +1,8 @@
import { apiGet } from './base'
export const searchMentionFiles = (threadId, query, signal) => {
const params = new URLSearchParams()
if (threadId) params.set('thread_id', threadId)
if (query) params.set('query', query)
return apiGet(`/api/mention/search?${params.toString()}`, { signal })
}

View File

@ -153,6 +153,7 @@
:disabled="!currentAgent"
:send-button-disabled="isSendButtonDisabled"
:mention="mentionConfig"
:thread-id="currentChatId"
:supports-file-upload="supportsFileUpload"
:has-active-thread="!!currentChatId"
:todos="currentTodos"
@ -204,7 +205,6 @@
<AgentPanel
v-if="isAgentPanelOpen"
:agent-state="currentAgentState"
:thread-files="currentThreadFiles"
:thread-id="currentChatId"
:agent-id="currentThread?.agent_id || currentAgentId"
:agent-config-id="selectedAgentConfigId"
@ -248,7 +248,6 @@ import { useConfigStore } from '@/stores/config'
import { storeToRefs } from 'pinia'
import { MessageProcessor } from '@/utils/messageProcessor'
import { agentApi, threadApi } from '@/apis'
import { getWorkspaceTree } from '@/apis/workspace_api'
import HumanApprovalModal from '@/components/HumanApprovalModal.vue'
import { useApproval } from '@/composables/useApproval'
import { useAgentThreadState } from '@/composables/useAgentThreadState'
@ -344,7 +343,6 @@ const { getThreadState, resetOnGoingConv, stopThreadStream } = useAgentThreadSta
const threadMessages = ref({})
const threadFilesMap = ref({})
const threadAttachmentsMap = ref({})
const workspaceMentionFiles = ref([])
const threadConfigNoticeMap = ref({})
const threadPendingConfigNoticeMap = ref({})
const threadConfigSnapshotMap = ref({})
@ -447,9 +445,7 @@ watch(hasAgentStateContent, (newVal, oldVal) => {
})
const { mentionConfig } = useAgentMentionConfig({
currentAgentState,
currentThreadFiles,
currentThreadAttachments,
workspaceMentionFiles,
configurableItems,
agentConfig,
availableKnowledgeBases,
@ -890,13 +886,7 @@ onMounted(() => {
})
})
let skipNextWorkspaceMentionActivation = true
onActivated(() => {
if (skipNextWorkspaceMentionActivation) {
skipNextWorkspaceMentionActivation = false
} else {
void fetchWorkspaceMentionFiles()
}
nextTick(() => {
startChatMainResizeObserver()
})
@ -994,7 +984,7 @@ const fetchThreadMessages = async ({ agentId, threadId, delay = 0 }) => {
const fetchThreadFiles = async (threadId) => {
if (!threadId) return
try {
const response = await threadApi.listThreadFiles(threadId, '/home/gem/user-data', true)
const response = await threadApi.listThreadFiles(threadId, '/home/gem/user-data', false)
const entries = Array.isArray(response?.files) ? response.files : []
threadFilesMap.value[threadId] = entries
} catch (error) {
@ -1021,25 +1011,7 @@ const refreshThreadFilesAndAttachments = async (threadId) => {
await Promise.all([fetchThreadFiles(threadId), fetchThreadAttachments(threadId)])
}
let workspaceMentionFilesRequest = null
const fetchWorkspaceMentionFiles = async () => {
if (workspaceMentionFilesRequest) return workspaceMentionFilesRequest
workspaceMentionFilesRequest = (async () => {
try {
const response = await getWorkspaceTree('/', true, true)
workspaceMentionFiles.value = Array.isArray(response?.entries) ? response.entries : []
} catch (error) {
console.warn('Failed to fetch workspace mention files:', error)
workspaceMentionFiles.value = []
} finally {
workspaceMentionFilesRequest = null
}
})()
return workspaceMentionFilesRequest
}
const handleArtifactSaved = async () => {
await fetchWorkspaceMentionFiles()
if (!currentChatId.value) return
await refreshThreadFilesAndAttachments(currentChatId.value)
}
@ -1652,10 +1624,10 @@ const hasVisibleAssistantBody = (message) => {
const { content, reasoningContent } = extractAssistantMessageBody(message)
return Boolean(
content ||
reasoningContent ||
message.error_type ||
message.extra_metadata?.error_type ||
message.isStoppedByUser
reasoningContent ||
message.error_type ||
message.extra_metadata?.error_type ||
message.isStoppedByUser
)
}
@ -1821,7 +1793,7 @@ const initAll = async () => {
}
onMounted(async () => {
await Promise.all([initAll(), fetchWorkspaceMentionFiles()])
await initAll()
scrollController.enableAutoScroll()
})
@ -2007,7 +1979,6 @@ watch(currentChatId, (threadId, oldThreadId) => {
will-change: flex-basis;
}
/* Workbench transition animations */
.agent-panel-wrapper {
transition: flex-basis 0.3s cubic-bezier(0.4, 0, 0.2, 1);

View File

@ -824,7 +824,10 @@ const handleModelChange = (key, spec) => {
//
const ensureArray = (key) => {
const config = agentConfig.value || {}
if (config[key] === null && configurableItems.value[key]?.template_metadata?.kind === 'knowledges') {
if (
config[key] === null &&
configurableItems.value[key]?.template_metadata?.kind === 'knowledges'
) {
return getConfigOptions(configurableItems.value[key]).map((option) => getOptionValue(option))
}
if (!config[key] || !Array.isArray(config[key])) {

View File

@ -706,5 +706,4 @@ onUnmounted(() => {
.fullscreen-file-content .image-preview {
max-height: calc(100vh - 48px);
}
</style>

View File

@ -8,6 +8,7 @@
:send-button-disabled="sendButtonDisabled"
:placeholder="placeholder"
:mention="mention"
:thread-id="threadId"
@send="handleSend"
@keydown="handleKeyDown"
>
@ -113,6 +114,7 @@ const props = defineProps({
disabled: { type: Boolean, default: false },
sendButtonDisabled: { type: Boolean, default: false },
mention: { type: Object, default: () => null },
threadId: { type: String, default: '' },
supportsFileUpload: { type: Boolean, default: false },
hasActiveThread: { type: Boolean, default: true },
todos: {

View File

@ -38,7 +38,12 @@
</div>
<!-- 消息内容 -->
<MarkdownPreview v-if="parsedData.content" :key="message.id" :content="parsedData.content" class="message-md" />
<MarkdownPreview
v-if="parsedData.content"
:key="message.id"
:content="parsedData.content"
class="message-md"
/>
<div v-else-if="parsedData.reasoning_content" class="empty-block"></div>
@ -265,7 +270,6 @@ const parsedData = computed(() => {
reasoning_content
}
})
</script>
<style lang="less" scoped>
@ -531,7 +535,7 @@ const parsedData = computed(() => {
}
}
.message-md {
margin: 8px 0;
}
.message-md {
margin: 8px 0;
}
</style>

View File

@ -130,10 +130,6 @@ const props = defineProps({
type: Object,
default: () => ({})
},
threadFiles: {
type: Array,
default: () => []
},
threadId: {
type: String,
default: null
@ -723,7 +719,6 @@ watch(useInlinePreview, (isInline) => {
flex-shrink: 0;
}
.tab-content {
flex: 1;
overflow-y: auto;

View File

@ -88,7 +88,10 @@
<span v-else-if="benchmark.has_gold_chunks" class="card-tag benchmark-tag tag-blue">
检索评估
</span>
<span v-else-if="benchmark.has_gold_answers" class="card-tag benchmark-tag tag-gold">
<span
v-else-if="benchmark.has_gold_answers"
class="card-tag benchmark-tag tag-gold"
>
问答评估
</span>
<span v-else class="card-tag benchmark-tag">仅查询</span>

View File

@ -33,6 +33,7 @@
@keyup="handleKeyUp"
@input="handleInput"
@focus="focusInput"
@click="handleTextareaClick"
:placeholder="placeholder"
:disabled="disabled"
/>
@ -42,7 +43,6 @@
v-if="mentionPopupVisible"
ref="mentionDropdownRef"
class="mention-dropdown-wrapper"
:style="mentionDropdownStyle"
>
<div class="mention-popup">
<!-- 文件列表 -->
@ -55,10 +55,39 @@
<div
v-for="(item, index) in mentionItems.files"
:key="'file-' + item.value"
:class="['mention-item', { active: isItemSelected('file', index) }]"
:class="['mention-item', 'file-item', { active: isItemSelected('file', index) }]"
@click="insertMention(item)"
>
{{ item.label }}
<div class="file-info-left">
<component
:is="item.is_dir ? FolderFilled : getFileIcon(item.label)"
:style="{ color: item.is_dir ? '#ffa940' : getFileIconColor(item.label) }"
class="file-type-icon"
/>
<span class="file-name" :title="item.label">
<span
v-for="(part, pIdx) in splitTextByQuery(item.label, mentionQuery)"
:key="pIdx"
:class="{ 'query-match': part.isMatch }"
>{{ part.text }}</span
>
</span>
</div>
<span
v-if="formatMentionPath(item.description)"
class="file-parent-dir"
:title="formatMentionPath(item.description)"
>
<span
v-for="(part, pIdx) in splitTextByQuery(
formatMentionPath(item.description),
mentionQuery
)"
:key="pIdx"
:class="{ 'query-match': part.isMatch }"
>{{ part.text }}</span
>
</span>
</div>
</template>
</div>
@ -72,7 +101,12 @@
:class="['mention-item', { active: isItemSelected('knowledge', index) }]"
@click="insertMention(item)"
>
{{ item.label }}
<span
v-for="(part, pIdx) in splitTextByQuery(item.label, mentionQuery)"
:key="pIdx"
:class="{ 'query-match': part.isMatch }"
>{{ part.text }}</span
>
</div>
</div>
@ -85,7 +119,12 @@
:class="['mention-item', { active: isItemSelected('mcp', index) }]"
@click="insertMention(item)"
>
{{ item.label }}
<span
v-for="(part, pIdx) in splitTextByQuery(item.label, mentionQuery)"
:key="pIdx"
:class="{ 'query-match': part.isMatch }"
>{{ part.text }}</span
>
</div>
</div>
@ -98,7 +137,12 @@
:class="['mention-item', { active: isItemSelected('skill', index) }]"
@click="insertMention(item)"
>
{{ item.label }}
<span
v-for="(part, pIdx) in splitTextByQuery(item.label, mentionQuery)"
:key="pIdx"
:class="{ 'query-match': part.isMatch }"
>{{ part.text }}</span
>
</div>
</div>
@ -111,7 +155,12 @@
:class="['mention-item', { active: isItemSelected('subagent', index) }]"
@click="insertMention(item)"
>
{{ item.label }}
<span
v-for="(part, pIdx) in splitTextByQuery(item.label, mentionQuery)"
:key="pIdx"
:class="{ 'query-match': part.isMatch }"
>{{ part.text }}</span
>
</div>
</div>
@ -144,8 +193,10 @@
<script setup>
import { ref, computed, onMounted, nextTick, watch, onBeforeUnmount, useSlots } from 'vue'
import { SendOutlined, ArrowUpOutlined, PauseOutlined } from '@ant-design/icons-vue'
import { SendOutlined, ArrowUpOutlined, PauseOutlined, FolderFilled } from '@ant-design/icons-vue'
import { Paperclip } from 'lucide-vue-next'
import { searchMentionFiles } from '@/apis/mention_api'
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
//
const mentionDropdownRef = ref(null)
@ -196,6 +247,10 @@ const props = defineProps({
mention: {
type: Object,
default: () => null
},
threadId: {
type: String,
default: ''
}
})
@ -228,6 +283,38 @@ const formatMentionToken = (type, value) => {
return `@${prefix}:${value}`
}
const formatMentionPath = (path) => {
if (!path) return ''
let cleanPath = path.replace(/^\/?home\/gem\/user-data\/?/, '')
if (cleanPath.startsWith('/')) {
cleanPath = cleanPath.substring(1)
}
// / /
let isDir = cleanPath.endsWith('/')
let pathForParent = isDir ? cleanPath.substring(0, cleanPath.length - 1) : cleanPath
const lastSlashIndex = pathForParent.lastIndexOf('/')
if (lastSlashIndex === -1) {
return ''
}
return pathForParent.substring(0, lastSlashIndex + 1)
}
// (100% XSS v-html)
const splitTextByQuery = (text, query) => {
if (!text) return []
if (!query) return [{ text, isMatch: false }]
const escapedQuery = query.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')
const regex = new RegExp(`(${escapedQuery})`, 'gi')
const parts = text.split(regex)
return parts.map((part) => ({
text: part,
isMatch: part.toLowerCase() === query.toLowerCase()
}))
}
// @
const checkMentionTrigger = (textarea) => {
if (!textarea || !mentionEnabled.value) return false
@ -276,14 +363,8 @@ const updateMentionItems = (query = '') => {
)
})
const filterFileItems = (list) => {
if (!query) {
return []
}
return filterItems(list)
}
const fileItems = files.map((f) => {
// /
const localFileItems = files.map((f) => {
const path = f.path || ''
const fileName = path.split('/').pop() || path
return {
@ -296,6 +377,8 @@ const updateMentionItems = (query = '') => {
}
})
const filteredLocalFiles = query ? filterItems(localFileItems) : []
const knowledgeItems = knowledgeBases.map((kb) => {
const kbName = kb.name || ''
return {
@ -345,13 +428,76 @@ const updateMentionItems = (query = '') => {
}
})
// mentionItems 使
mentionItems.value = {
files: filterFileItems(fileItems),
files: filteredLocalFiles,
knowledgeBases: filterItems(knowledgeItems),
mcps: filterItems(mcpItems),
skills: filterItems(skillItems),
subagents: filterItems(subagentItems)
}
//
if (query && props.threadId) {
clearTimeout(mentionSearchTimer)
mentionSearchTimer = setTimeout(async () => {
// HTTP
if (activeAbortController) {
activeAbortController.abort()
}
activeAbortController = new AbortController()
searchRequestId.value++
const currentId = searchRequestId.value
try {
const responseData = await searchMentionFiles(
props.threadId,
query,
activeAbortController.signal
)
//
if (currentId === searchRequestId.value && Array.isArray(responseData)) {
const remoteFileItems = responseData.map((f) => {
const path = f.path || ''
const fileName = f.name || path.split('/').pop() || path
return {
value: path,
label: fileName,
type: 'file',
insertValue: path || fileName,
tokenLabel: formatMentionToken('file', fileName),
description: path,
is_dir: f.is_dir
}
})
// 使 Set
const seenValues = new Set(filteredLocalFiles.map((x) => x.value))
const mergedFiles = [...filteredLocalFiles]
remoteFileItems.forEach((item) => {
if (!seenValues.has(item.value)) {
seenValues.add(item.value)
mergedFiles.push(item)
}
})
mentionItems.value.files = mergedFiles
}
} catch (error) {
//
if (error.name !== 'AbortError') {
console.error('Mention search error:', error)
}
} finally {
if (currentId === searchRequestId.value) {
activeAbortController = null
}
}
}, 250) // 250ms
}
}
//
@ -393,21 +539,7 @@ const hasAnyItems = computed(() => {
)
})
//
const mentionDropdownStyle = computed(() => {
if (!inputRef.value) return {}
const textarea = inputRef.value
const rect = textarea.getBoundingClientRect()
const parentRect = textarea.parentElement.getBoundingClientRect()
return {
position: 'absolute',
bottom: `${parentRect.bottom - rect.top + 4}px`,
left: `${rect.left - parentRect.left}px`,
zIndex: 1000
}
})
const insertMention = (item) => {
if (!inputRef.value) return
@ -550,17 +682,8 @@ const handleInput = (e) => {
const value = e.target.value
emit('update:modelValue', value)
// @
if (mentionEnabled.value && !mentionPopupVisible.value) {
const cursorPos = e.target.selectionStart
const textBeforeCursor = value.slice(0, cursorPos)
if (textBeforeCursor.endsWith('@')) {
checkMentionTrigger(e.target)
}
}
//
if (mentionPopupVisible.value) {
// 使
if (mentionEnabled.value) {
nextTick(() => {
checkMentionTrigger(e.target)
})
@ -577,6 +700,9 @@ const mentionPopupVisible = ref(false)
const mentionQuery = ref('')
const mentionItems = ref({ files: [], knowledgeBases: [], mcps: [], skills: [], subagents: [] })
const mentionSelectedIndex = ref(0)
const searchRequestId = ref(0)
let activeAbortController = null
let mentionSearchTimer = null
const adjustTextareaHeight = () => {
if (!inputRef.value) {
@ -592,6 +718,21 @@ const adjustTextareaHeight = () => {
const focusInput = () => {
if (inputRef.value && !props.disabled) {
inputRef.value.focus()
// @
if (mentionEnabled.value) {
nextTick(() => {
checkMentionTrigger(inputRef.value)
})
}
}
}
// @
const handleTextareaClick = (e) => {
if (mentionEnabled.value) {
nextTick(() => {
checkMentionTrigger(e.target)
})
}
}
@ -622,6 +763,12 @@ onBeforeUnmount(() => {
if (debounceTimer.value) {
clearTimeout(debounceTimer.value)
}
if (mentionSearchTimer) {
clearTimeout(mentionSearchTimer)
}
if (activeAbortController) {
activeAbortController.abort()
}
document.removeEventListener('click', closeMentionPopup)
})
@ -840,16 +987,20 @@ defineExpose({
// @
.mention-dropdown-wrapper {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin-bottom: 8px;
z-index: 1000;
}
.mention-popup {
min-width: 240px;
width: 100%;
max-height: 280px;
overflow-y: auto;
background: var(--gray-0);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.08), 0 4px 16px rgba(0, 0, 0, 0.12);
border: 1px solid var(--gray-200);
.mention-group {
@ -880,13 +1031,71 @@ defineExpose({
margin: 1px 4px;
border-radius: 4px;
&.file-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0;
padding: 6px 10px;
.file-info-left {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 1;
min-width: 0;
.file-type-icon {
font-size: 15px;
flex-shrink: 0;
display: flex;
align-items: center;
}
.file-name {
font-weight: 500;
color: var(--gray-800);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
}
.file-parent-dir {
font-size: 11px;
color: var(--gray-400);
margin-left: 8px;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.15s ease;
}
}
&:hover,
&.active {
background-color: var(--main-10);
color: var(--main-600);
&.file-item {
.file-info-left .file-name {
color: var(--main-600);
}
.file-parent-dir {
color: var(--main-400);
}
}
}
}
.query-match {
color: #fa8c16; /* 明亮温润的金橘色 */
font-weight: 700;
}
.mention-empty {
text-align: center;
padding: 12px 8px;

View File

@ -389,7 +389,6 @@
</a-empty>
</div>
</a-modal>
</template>
<script setup>

View File

@ -447,7 +447,6 @@ defineExpose({
padding: 0;
background: transparent;
}
}
.remote-install-panel {

View File

@ -699,7 +699,6 @@ onMounted(() => {
min-width: 0;
min-height: 0;
.editor-main {
flex: 1;
min-height: 0;

View File

@ -97,7 +97,10 @@ const emit = defineEmits(['update:visible', 'success'])
//
const defaultBenchmarkName = () => {
const today = new Date().toISOString().slice(0, 10)
const suffix = Array.from({ length: 4 }, () => '0123456789abcdefghijklmnopqrstuvwxyz'[Math.floor(Math.random() * 36)]).join('')
const suffix = Array.from(
{ length: 4 },
() => '0123456789abcdefghijklmnopqrstuvwxyz'[Math.floor(Math.random() * 36)]
).join('')
return `Test-${today}-${suffix}`
}
@ -208,5 +211,3 @@ watch(visible, (val) => {
}
})
</script>

View File

@ -17,7 +17,11 @@
>
</div>
<MarkdownPreview v-if="chunk?.content" :content="chunk.content" class="chunk-markdown-content" />
<MarkdownPreview
v-if="chunk?.content"
:content="chunk.content"
class="chunk-markdown-content"
/>
<div v-else class="empty-text">暂无内容</div>
</a-modal>
</template>

View File

@ -2,9 +2,7 @@ import { computed } from 'vue'
export function useAgentMentionConfig({
currentAgentState,
currentThreadFiles,
currentThreadAttachments,
workspaceMentionFiles,
configurableItems,
agentConfig,
availableKnowledgeBases,
@ -15,10 +13,6 @@ export function useAgentMentionConfig({
const rawFiles = currentAgentState.value?.files || {}
const files = []
const seenPaths = new Set()
const workspaceFiles = Array.isArray(currentThreadFiles?.value) ? currentThreadFiles.value : []
const userWorkspaceFiles = Array.isArray(workspaceMentionFiles?.value)
? workspaceMentionFiles.value
: []
const pushFile = (entry) => {
const path = entry?.path || ''
@ -66,29 +60,6 @@ export function useAgentMentionConfig({
})
})
userWorkspaceFiles.forEach((entry) => {
const path = entry?.virtual_path || ''
if (!path || entry?.is_dir) return
pushFile({
path,
size: entry.size,
modified_at: entry.modified_at,
file_name: entry.name
})
})
workspaceFiles.forEach((entry) => {
const path = entry?.path || ''
if (!path.startsWith('/home/gem/user-data/workspace/') || entry?.is_dir) return
pushFile({
path,
size: entry.size,
modified_at: entry.modified_at,
artifact_url: entry.artifact_url,
file_name: entry.name
})
})
const configItems = configurableItems.value || {}
const currentConfig = agentConfig.value || {}
let includeAllKnowledgeBases = false

View File

@ -1,72 +1,76 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useChatUIStore = defineStore('chatUI', () => {
// ==================== 聊天界面 UI 状态 ====================
// 加载状态
const isLoadingMessages = ref(false)
export const useChatUIStore = defineStore(
'chatUI',
() => {
// ==================== 聊天界面 UI 状态 ====================
// 加载状态
const isLoadingMessages = ref(false)
// ==================== AgentView UI 状态 ====================
// 智能体选择弹窗
const agentModalOpen = ref(false)
// ==================== AgentView UI 状态 ====================
// 智能体选择弹窗
const agentModalOpen = ref(false)
// 配置侧边栏
const isConfigSidebarOpen = ref(false)
// 配置侧边栏
const isConfigSidebarOpen = ref(false)
// 应用侧边栏折叠态
const sidebarCollapsed = ref(false)
// 应用侧边栏折叠态
const sidebarCollapsed = ref(false)
// 更多菜单
const moreMenuOpen = ref(false)
const moreMenuPosition = ref({ x: 0, y: 0 })
// 更多菜单
const moreMenuOpen = ref(false)
const moreMenuPosition = ref({ x: 0, y: 0 })
// ==================== 方法 ====================
/**
* 打开更多菜单
* @param {number} x - X 坐标
* @param {number} y - Y 坐标
*/
function openMoreMenu(x, y) {
moreMenuPosition.value = { x, y }
moreMenuOpen.value = true
// ==================== 方法 ====================
/**
* 打开更多菜单
* @param {number} x - X 坐标
* @param {number} y - Y 坐标
*/
function openMoreMenu(x, y) {
moreMenuPosition.value = { x, y }
moreMenuOpen.value = true
}
/**
* 关闭更多菜单
*/
function closeMoreMenu() {
moreMenuOpen.value = false
}
/**
* 重置所有 UI 状态不包括持久化状态
*/
function reset() {
isLoadingMessages.value = false
agentModalOpen.value = false
isConfigSidebarOpen.value = false
moreMenuOpen.value = false
moreMenuPosition.value = { x: 0, y: 0 }
}
return {
// 状态
isLoadingMessages,
agentModalOpen,
isConfigSidebarOpen,
sidebarCollapsed,
moreMenuOpen,
moreMenuPosition,
// 方法
openMoreMenu,
closeMoreMenu,
reset
}
},
{
persist: {
key: 'chat-ui-store',
storage: localStorage,
pick: ['sidebarCollapsed']
}
}
/**
* 关闭更多菜单
*/
function closeMoreMenu() {
moreMenuOpen.value = false
}
/**
* 重置所有 UI 状态不包括持久化状态
*/
function reset() {
isLoadingMessages.value = false
agentModalOpen.value = false
isConfigSidebarOpen.value = false
moreMenuOpen.value = false
moreMenuPosition.value = { x: 0, y: 0 }
}
return {
// 状态
isLoadingMessages,
agentModalOpen,
isConfigSidebarOpen,
sidebarCollapsed,
moreMenuOpen,
moreMenuPosition,
// 方法
openMoreMenu,
closeMoreMenu,
reset
}
}, {
persist: {
key: 'chat-ui-store',
storage: localStorage,
pick: ['sidebarCollapsed']
}
})
)

View File

@ -8,13 +8,15 @@ import {
FileImageFilled,
FileUnknownFilled,
FilePptFilled,
LinkOutlined
LinkOutlined,
CodeFilled,
FileFilled
} from '@ant-design/icons-vue'
import { formatRelative, parseToShanghai } from '@/utils/time'
// 根据文件扩展名获取文件图标
export const getFileIcon = (filename) => {
if (!filename) return FileUnknownFilled
if (!filename) return FileFilled
// Check if it's a URL
if (filename.startsWith('http://') || filename.startsWith('https://')) {
@ -24,31 +26,23 @@ export const getFileIcon = (filename) => {
const extension = filename.toLowerCase().split('.').pop()
const iconMap = {
// 文本文件
// 文本文件与常规文档
txt: FileTextFilled,
text: FileTextFilled,
log: FileTextFilled,
pdf: FilePdfFilled,
doc: FileWordFilled,
docx: FileWordFilled,
xls: FileExcelFilled,
xlsx: FileExcelFilled,
csv: FileExcelFilled,
ppt: FilePptFilled,
pptx: FilePptFilled,
// Markdown文件
md: FileMarkdownFilled,
markdown: FileMarkdownFilled,
// PDF文件
pdf: FilePdfFilled,
// Word文档
doc: FileWordFilled,
docx: FileWordFilled,
// Excel文档
xls: FileExcelFilled,
xlsx: FileExcelFilled,
csv: FileExcelFilled,
// PPT文档
ppt: FilePptFilled,
pptx: FilePptFilled,
// 图片文件
jpg: FileImageFilled,
jpeg: FileImageFilled,
@ -58,12 +52,35 @@ export const getFileIcon = (filename) => {
svg: FileImageFilled,
webp: FileImageFilled,
// HTML文件
html: FileTextFilled,
htm: FileTextFilled
// 代码文件
py: CodeFilled,
js: CodeFilled,
ts: CodeFilled,
vue: CodeFilled,
sh: CodeFilled,
go: CodeFilled,
cpp: CodeFilled,
c: CodeFilled,
h: CodeFilled,
java: CodeFilled,
html: CodeFilled,
htm: CodeFilled,
css: CodeFilled,
less: CodeFilled,
scss: CodeFilled,
sql: CodeFilled,
// 配置文件与数据结构
json: FileTextFilled,
yaml: FileTextFilled,
yml: FileTextFilled,
toml: FileTextFilled,
ini: FileTextFilled,
conf: FileTextFilled,
env: FileTextFilled
}
return iconMap[extension] || FileUnknownFilled
return iconMap[extension] || FileFilled
}
// 根据文件扩展名获取文件图标颜色
@ -112,9 +129,36 @@ export const getFileIconColor = (filename) => {
svg: '#722ed1',
webp: '#722ed1',
// HTML文件 - 橙色
// 前端与样式文件 - 橙黄色
js: '#fa8c16',
ts: '#fa8c16',
vue: '#fa8c16',
html: '#fa8c16',
htm: '#fa8c16'
htm: '#fa8c16',
css: '#fa8c16',
less: '#fa8c16',
scss: '#fa8c16',
// 后端核心代码文件 - 亮天蓝
py: '#1890ff',
go: '#1890ff',
java: '#1890ff',
cpp: '#1890ff',
c: '#1890ff',
h: '#1890ff',
// 配置文件 - 青色
json: '#13c2c2',
yaml: '#13c2c2',
yml: '#13c2c2',
toml: '#13c2c2',
ini: '#13c2c2',
conf: '#13c2c2',
env: '#13c2c2',
// 脚本文件 - 中灰
sh: '#595959',
sql: '#595959'
}
return colorMap[extension] || '#8c8c8c'

View File

@ -117,7 +117,9 @@
<footer class="footer">
<div class="footer-content">
<p class="copyright">{{ infoStore.footer?.copyright || '© 2025 All rights reserved' }}</p>
<p class="copyright">
{{ infoStore.footer?.copyright || '© 2025 All rights reserved' }}
</p>
</div>
</footer>
</template>