From 1437a6be93d1e6f2655185bd5813b9bcdcb61bb9 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Wed, 6 May 2026 15:47:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor(mention):=20=E4=BC=98=E5=8C=96=20Agent?= =?UTF-8?q?=20=E8=BE=93=E5=85=A5=E6=A1=86=E6=96=87=E4=BB=B6=20mention?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E6=9C=AA=E5=90=AF=E5=8A=A8=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E7=9A=84=E6=97=B6=E5=80=99=20mention=20=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E7=9A=84=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yuxi/services/workspace_service.py | 19 ++++++++--- backend/server/routers/workspace_router.py | 9 ++++- docs/develop-guides/roadmap.md | 1 + web/src/apis/workspace_api.js | 4 +-- web/src/components/AgentChatComponent.vue | 34 +++++++++++++++++-- web/src/composables/useAgentMentionConfig.js | 15 ++++++++ 6 files changed, 72 insertions(+), 10 deletions(-) diff --git a/backend/package/yuxi/services/workspace_service.py b/backend/package/yuxi/services/workspace_service.py index 509a0d3d..0b64db0b 100644 --- a/backend/package/yuxi/services/workspace_service.py +++ b/backend/package/yuxi/services/workspace_service.py @@ -16,7 +16,7 @@ from yuxi.services.upload_utils import write_upload_to_buffer from yuxi.services.viewer_filesystem_service import _detect_preview_type from yuxi.storage.postgres.models_business import User from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp -from yuxi.utils.paths import WORKSPACE_DIR_NAME +from yuxi.utils.paths import VIRTUAL_PATH_WORKSPACE, WORKSPACE_DIR_NAME EDITABLE_WORKSPACE_SUFFIXES = {".md", ".markdown", ".mdx", ".txt"} MAX_WORKSPACE_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024 @@ -69,8 +69,10 @@ def _entry_for_path(root: Path, path: Path) -> dict: display_path = f"/{relative}" if relative else "/" if is_dir and display_path != "/" and not display_path.endswith("/"): display_path = f"{display_path}/" + virtual_path = VIRTUAL_PATH_WORKSPACE if display_path == "/" else f"{VIRTUAL_PATH_WORKSPACE}{display_path}" return { "path": display_path, + "virtual_path": virtual_path, "name": path.name or "工作区", "is_dir": is_dir, "size": 0 if is_dir else stat.st_size, @@ -113,19 +115,26 @@ def _resolve_new_child(root: Path, parent: Path, name: str) -> Path: return target -def _list_directory(root: Path, target: Path) -> list[dict]: - entries = [_entry_for_path(root, child) for child in target.iterdir()] +def _list_directory(root: Path, target: Path, *, recursive: bool = False, files_only: bool = False) -> list[dict]: + children = list(target.iterdir()) + entries = [_entry_for_path(root, child) for child in children if not files_only or child.is_file()] + if recursive: + for child in children: + if child.is_dir() and not child.is_symlink(): + entries.extend(_list_directory(root, child, recursive=True, files_only=files_only)) return _sort_entries(entries) -async def list_workspace_tree(*, path: str, current_user: User) -> dict: +async def list_workspace_tree( + *, path: str, recursive: bool = False, files_only: bool = False, current_user: User +) -> dict: root = _workspace_root(current_user) target = _resolve_workspace_path(current_user, path) if not target.exists(): return {"entries": []} if not target.is_dir(): raise HTTPException(status_code=400, detail="当前路径不是目录") - entries = await asyncio.to_thread(_list_directory, root, target) + entries = await asyncio.to_thread(_list_directory, root, target, recursive=recursive, files_only=files_only) return {"entries": entries} diff --git a/backend/server/routers/workspace_router.py b/backend/server/routers/workspace_router.py index 671c08a4..4fd1d70d 100644 --- a/backend/server/routers/workspace_router.py +++ b/backend/server/routers/workspace_router.py @@ -31,9 +31,16 @@ class UpdateWorkspaceFileContentRequest(BaseModel): @workspace.get("/tree", response_model=dict) async def get_workspace_tree( path: str = Query("/", description="工作区目录路径"), + recursive: bool = Query(False, description="是否递归返回子目录文件"), + files_only: bool = Query(False, description="是否仅返回文件"), current_user: User = Depends(get_required_user), ): - return await list_workspace_tree(path=path, current_user=current_user) + return await list_workspace_tree( + path=path, + recursive=recursive, + files_only=files_only, + current_user=current_user, + ) @workspace.get("/file", response_model=dict) diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index e329fc05..0ea12f66 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -37,6 +37,7 @@ ### 0.6.2 开发记录 +- 优化 Agent 输入框文件 mention:用户级 workspace 文件候选改为从独立 workspace API 递归加载,不再依赖 active thread;插入时仍转换为 `/home/gem/user-data/workspace/` 沙盒虚拟路径,并修复附件上传后未立即刷新 mention 候选的问题。 - 调整知识库思维导图后端结构:将思维导图路由文件重命名为知识库语义更明确的 router,并把文件列表整理、提示词构建、AI JSON 解析等纯逻辑下沉到知识库 utils。 - 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;工作区预览支持 Markdown/TXT 在右侧预览框内切换编辑并保存,其他格式和非工作区预览默认只读;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。 - 加固 JWT 鉴权安全:移除历史默认密钥回退,初始化脚本支持生成并持久化 `JWT_SECRET_KEY` 与 `YUXI_INSTANCE_ID`,签发和验证令牌时校验 `iss/aud`,并在鉴权阶段拒绝已删除或登录锁定用户继续使用旧令牌访问系统。 diff --git a/web/src/apis/workspace_api.js b/web/src/apis/workspace_api.js index a4cdbf03..a5a9a264 100644 --- a/web/src/apis/workspace_api.js +++ b/web/src/apis/workspace_api.js @@ -10,8 +10,8 @@ const buildQuery = (params) => { return query.toString() } -export const getWorkspaceTree = (path = '/') => { - const query = buildQuery({ path }) +export const getWorkspaceTree = (path = '/', recursive = false, filesOnly = false) => { + const query = buildQuery({ path, recursive, files_only: filesOnly }) return apiGet(`/api/workspace/tree?${query}`) } diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index 23d9042f..8bef5f15 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -252,6 +252,7 @@ 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' @@ -349,6 +350,7 @@ 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({}) @@ -454,6 +456,7 @@ const { mentionConfig } = useAgentMentionConfig({ currentAgentState, currentThreadFiles, currentThreadAttachments, + workspaceMentionFiles, configurableItems, agentConfig, availableKnowledgeBases, @@ -894,7 +897,13 @@ onMounted(() => { }) }) +let skipNextWorkspaceMentionActivation = true onActivated(() => { + if (skipNextWorkspaceMentionActivation) { + skipNextWorkspaceMentionActivation = false + } else { + void fetchWorkspaceMentionFiles() + } nextTick(() => { startChatMainResizeObserver() }) @@ -1019,7 +1028,25 @@ 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) } @@ -1088,7 +1115,10 @@ const handleAttachmentUpload = async (files) => { await threadApi.uploadThreadAttachment(threadId, file) } message.success({ content: '附件上传成功', key: 'upload-attachment', duration: 2 }) - await fetchAgentState(currentAgentId.value, threadId) + await Promise.all([ + fetchAgentState(currentAgentId.value, threadId), + refreshThreadFilesAndAttachments(threadId) + ]) } catch (error) { message.destroy('upload-attachment') handleChatError(error, 'upload') @@ -1801,7 +1831,7 @@ const initAll = async () => { } onMounted(async () => { - await initAll() + await Promise.all([initAll(), fetchWorkspaceMentionFiles()]) scrollController.enableAutoScroll() }) diff --git a/web/src/composables/useAgentMentionConfig.js b/web/src/composables/useAgentMentionConfig.js index bcc21975..3ce5442e 100644 --- a/web/src/composables/useAgentMentionConfig.js +++ b/web/src/composables/useAgentMentionConfig.js @@ -4,6 +4,7 @@ export function useAgentMentionConfig({ currentAgentState, currentThreadFiles, currentThreadAttachments, + workspaceMentionFiles, configurableItems, agentConfig, availableKnowledgeBases, @@ -15,6 +16,9 @@ export function useAgentMentionConfig({ 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 || '' @@ -62,6 +66,17 @@ 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