feat(mention): 优化@提及搜索排序与高亮展示,修复唤醒交互及路径硬编码问题
This commit is contained in:
parent
575c34b160
commit
d0c60d9d82
@ -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
|
||||
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@ import json
|
||||
import textwrap
|
||||
from typing import Any
|
||||
|
||||
|
||||
MINDMAP_SYSTEM_PROMPT = """你是一个专业的知识整理助手。
|
||||
|
||||
你的任务是分析用户提供的文件列表,生成一个层次分明的思维导图结构。
|
||||
|
||||
240
backend/package/yuxi/services/mention_search_service.py
Normal file
240
backend/package/yuxi/services/mention_search_service.py
Normal 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}")
|
||||
@ -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
|
||||
|
||||
25
backend/server/routers/mention_router.py
Normal file
25
backend/server/routers/mention_router.py
Normal 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,
|
||||
)
|
||||
212
backend/test/unit/services/test_mention_search_service.py
Normal file
212
backend/test/unit/services/test_mention_search_service.py
Normal 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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
8
web/src/apis/mention_api.js
Normal file
8
web/src/apis/mention_api.js
Normal 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 })
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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])) {
|
||||
|
||||
@ -706,5 +706,4 @@ onUnmounted(() => {
|
||||
.fullscreen-file-content .image-preview {
|
||||
max-height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -389,7 +389,6 @@
|
||||
</a-empty>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@ -447,7 +447,6 @@ defineExpose({
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.remote-install-panel {
|
||||
|
||||
@ -699,7 +699,6 @@ onMounted(() => {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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']
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user