refactor(mention): 优化 Agent 输入框文件 mention,支持未启动对话的时候 mention 工作区的文件

This commit is contained in:
Wenjie Zhang 2026-05-06 15:47:01 +08:00
parent a9d05480f0
commit 1437a6be93
6 changed files with 72 additions and 10 deletions

View File

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

View File

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

View File

@ -37,6 +37,7 @@
### 0.6.2 开发记录
<!-- 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`,并在鉴权阶段拒绝已删除或登录锁定用户继续使用旧令牌访问系统。

View File

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

View File

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

View File

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