refactor: limit reference path depth to two levels and streamline mention handling in workbench components
This commit is contained in:
1 parent
6c6c50eb7a
commit
86c04ea931
8 files changed
+142
-330
No files matched your search
@@ -419,11 +419,11 @@ Composer 只有智能模式,不再提供 AI / 搜索切换。
|
||||
|
||||
#### WB-SCOPE-001 `@` 候选来源规则
|
||||
|
||||
- 系统行为:输入 `@` 后,根据当前关键字返回目录、文件和网页资料候选。浏览态(无关键字)以层级下钻展示:首屏为各工作地址根与全局网页;点击目录进入该层子项(不做树形展开箭头),顶部面包屑可返回上级;目录行提供「引用」以插入目录 token,文件/网页点击即插入。每一层与搜索结果均按页懒加载,单次最多 50 条,滚到底加载下一批。有关键字时改为全局搜索扁平结果(仍每页最多 50),副文案保留路径。本地索引只补充索引状态,不决定候选是否出现。候选浮层按“文件”和“网页”两组展示(下钻进目录后通常仅“文件”组)。键盘上下移动高亮项时,列表滚动跟随使当前项可见;Enter 在浏览态对目录为进入,对文件/网页为插入;搜索态对目录亦为插入。未创建或未选中伙伴时 Composer 为空态,不开放 `@` 发送路径。
|
||||
- 系统行为:输入 `@` 后,根据当前关键字返回目录、文件和网页资料候选。本地候选相对每个工作地址根**最多收录两级路径**(根为第 0 级,可引用工作地址本身;其下第一层、第二层的目录与文件进入列表;第三层及更深不出现)。列表为「文件 / 网页」两组**平铺**,无展开、无点进目录;点击任一项即插入对应 token。工作地址根、目录、文件、网页均可选中。候选按页懒加载,单次最多 50 条,滚到底加载下一批;有关键字时在上述扁平集合内子串过滤后再分页。本地索引只补充索引状态,不决定候选是否出现。键盘上下移动高亮项时列表滚动跟随使当前项可见;Enter 插入当前高亮项。未创建或未选中伙伴时 Composer 为空态,不开放 `@` 发送路径。
|
||||
- 验收:
|
||||
- `WB-SCOPE-001-AC1` 未选中伙伴时不把资料选择当作正常发送路径暴露给用户。
|
||||
- `WB-SCOPE-001-AC2` 已选中伙伴后,首屏可见工作地址根与全局网页;点进目录后可见该层子项;只展示“文件”和“网页”两组,目录通过图标与「引用」识别。
|
||||
- `WB-SCOPE-001-AC3` 在上一次索引或候选打开后新增、修改或删除本地文件时,再次进入对应目录或搜索必须反映真实目录;新增或变更但未索引的文件仍可选择,并显示“未索引”。
|
||||
- `WB-SCOPE-001-AC2` 已选中伙伴后,候选可同时出现工作地址根、根下两级内的目录/文件与全局网页;只展示“文件”和“网页”两组;点击目录与点击文件一样直接插入,不进入子层。
|
||||
- `WB-SCOPE-001-AC3` 在上一次索引或候选打开后新增、修改或删除本地文件时,再次打开 `@`(或搜索)必须反映真实目录;深度超过两级的路径不出现;新增或变更但未索引的文件仍可选择,并显示“未索引”。
|
||||
- `WB-SCOPE-001-AC4` 浏览与搜索均按页加载且单次不超过 50 条;滚到底可继续加载;键盘高亮超出可视区时滚动条跟随。
|
||||
|
||||
#### WB-SCOPE-002 `@` 插入与删除规则
|
||||
|
||||
@@ -248,8 +248,8 @@ tests/case/workbench/
|
||||
- 测试类型:`e2e`
|
||||
- What:`@` 参考资料候选覆盖全部文件地址的当前真实本地目录 / 文件与全局网页资料、目录 / 文件 / 网页 token 插入、多 token 共存、键盘删除、去重和空候选提示。
|
||||
- Why:显式参考资料是用户控制 AI 上下文的核心入口;未 `@` 时知识库默认全部已索引文件地址 + 全局网页。
|
||||
- How:创建伙伴;通过设置页添加 `tests/fixtures/workspace` 为文件地址,等待真实文件扫描并准备索引状态;添加真实 web source 并等待真实网页索引完成;随后直接在该目录磁盘中新建文件,不触发“检查更新”或“更新索引”;输入 `@` 先见工作地址根与网页,点进目录下钻查看子项(单页最多 50);搜索关键字走全局分页;选择文件、目录(引用)、网页;选中即追加,同一资料才去重;通过 Backspace 删除 token。对话里不再切换工作空间,因此不测跨工作区清理。
|
||||
- Oracle:候选来源和发送范围对齐 `WB-SCOPE-001`(含下钻与分页 `AC2`/`AC4`)、`WB-CORE-002`、`WB-CORE-004-AC1`;真实目录与新增未索引文件对齐 `WB-SCOPE-001-AC3`;token 插入、共存、发送参数、键盘删除和图标对齐 `WB-SCOPE-002`;去重和空候选对齐 `WB-SCOPE-003`、`WB-SCOPE-004`。废弃的 `WB-BLOCKER-002` 不测。
|
||||
- How:创建伙伴;通过设置页添加 `tests/fixtures/workspace` 为文件地址,等待真实文件扫描并准备索引状态;添加真实 web source 并等待真实网页索引完成;随后直接在该目录磁盘中新建文件,不触发“检查更新”或“更新索引”;输入 `@` 触发最多两级路径的平铺候选(每页最多 50);选择文件、目录、网页均为点击即插入;同一资料才去重;通过 Backspace 删除 token。对话里不再切换工作空间,因此不测跨工作区清理。
|
||||
- Oracle:候选来源和发送范围对齐 `WB-SCOPE-001`(含两级平铺与分页 `AC2`/`AC4`)、`WB-CORE-002`、`WB-CORE-004-AC1`;真实目录与新增未索引文件对齐 `WB-SCOPE-001-AC3`;token 插入、共存、发送参数、键盘删除和图标对齐 `WB-SCOPE-002`;去重和空候选对齐 `WB-SCOPE-003`、`WB-SCOPE-004`。废弃的 `WB-BLOCKER-002` 不测。
|
||||
- Not:不验证底层 KB 检索排序质量,也不验证 Settings 文件地址配置细节。`WB-CORE-004-AC2`、`WB-CORE-004-AC3`(宽松补充 vs 严谨缺口)由 AI live 承担。
|
||||
|
||||
#### `right-panel-layout-and-tabs`
|
||||
|
||||
@@ -2231,10 +2231,6 @@ export function registerHandlers(services: {
|
||||
listWorkspaceFileIndexRecords: (workspaceId) =>
|
||||
workspaceStore.listWorkspaceFileIndexRecords(workspaceId),
|
||||
listDirectory: (input) => workspaceService.listDirectory(input),
|
||||
scanWorkspaceTree: async (input) => {
|
||||
const scanResult = await workspaceService.scanWorkspaceTree(input)
|
||||
return { workspace: scanResult.workspace, entries: scanResult.entries }
|
||||
},
|
||||
listWebSources: () => webSourceStore.listWebSources(),
|
||||
normalizeWorkspaceRoot: (rootPath) => workspaceService.normalizeWorkspaceRoot(rootPath)
|
||||
},
|
||||
|
||||
@@ -5,17 +5,16 @@ import type {
|
||||
KbAvailability,
|
||||
ListReferenceItemsRequest,
|
||||
ReferenceItemListResponse,
|
||||
ReferenceListParent,
|
||||
SelectedReference,
|
||||
WorkspaceFileIndexRecord,
|
||||
WorkspaceSource,
|
||||
WorkspaceTreeEntry
|
||||
} from '../../../shared/types'
|
||||
import { REFERENCE_ITEM_PAGE_SIZE as DEFAULT_PAGE_SIZE } from '../../../shared/types'
|
||||
import {
|
||||
buildWebReferenceItem,
|
||||
buildWorkspaceReferenceItemsFromCurrentTree
|
||||
} from './selection'
|
||||
import { buildWebReferenceItem } from './selection'
|
||||
|
||||
/** 相对工作地址根的最大路径深度(根为 0,根下第一层为 1,再下一层为 2) */
|
||||
const MAX_REFERENCE_PATH_DEPTH = 2
|
||||
|
||||
const isIndexedWorkspaceFile = (record?: WorkspaceFileIndexRecord): boolean =>
|
||||
Boolean(record && record.index_status === 'indexed' && record.document_id)
|
||||
@@ -37,6 +36,9 @@ const normalizeRelativePath = (value: string): string =>
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
|
||||
const pathDepth = (relativePath: string): number =>
|
||||
normalizeRelativePath(relativePath).split('/').filter(Boolean).length
|
||||
|
||||
const matchesQuery = (reference: SelectedReference, query: string): boolean => {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
if (!normalized) return true
|
||||
@@ -104,32 +106,20 @@ const buildFileReferenceFromEntry = (input: {
|
||||
}
|
||||
}
|
||||
|
||||
const buildReferencesFromEntries = (input: {
|
||||
const entryToReference = (input: {
|
||||
workspace: WorkspaceSource
|
||||
entries: WorkspaceTreeEntry[]
|
||||
entry: WorkspaceTreeEntry
|
||||
records: WorkspaceFileIndexRecord[]
|
||||
}): SelectedReference[] => {
|
||||
const directories = input.entries.filter((entry) => entry.kind === 'directory')
|
||||
const files = input.entries.filter((entry) => entry.kind === 'file')
|
||||
return [
|
||||
...directories.map((entry) =>
|
||||
buildDirectoryReferenceFromRecords({
|
||||
}): SelectedReference =>
|
||||
input.entry.kind === 'directory'
|
||||
? buildDirectoryReferenceFromRecords({
|
||||
workspace: input.workspace,
|
||||
name: entry.name,
|
||||
relativePath: entry.relative_path,
|
||||
absolutePath: entry.absolute_path,
|
||||
name: input.entry.name,
|
||||
relativePath: input.entry.relative_path,
|
||||
absolutePath: input.entry.absolute_path,
|
||||
records: input.records
|
||||
})
|
||||
),
|
||||
...files.map((entry) =>
|
||||
buildFileReferenceFromEntry({
|
||||
workspace: input.workspace,
|
||||
entry,
|
||||
records: input.records
|
||||
})
|
||||
)
|
||||
]
|
||||
}
|
||||
: buildFileReferenceFromEntry(input)
|
||||
|
||||
const paginate = (
|
||||
items: SelectedReference[],
|
||||
@@ -156,10 +146,6 @@ export type ReferenceListDeps = {
|
||||
rootPath: string
|
||||
relativePath?: string
|
||||
}) => Promise<WorkspaceTreeEntry[]>
|
||||
scanWorkspaceTree: (input: {
|
||||
workspaceId: string
|
||||
rootPath: string
|
||||
}) => Promise<{ workspace: WorkspaceSource; entries: WorkspaceTreeEntry[] }>
|
||||
listWebSources: () => Promise<Parameters<typeof buildWebReferenceItem>[0][]>
|
||||
normalizeWorkspaceRoot: (rootPath: string) => Promise<{
|
||||
normalizedRootPath: string
|
||||
@@ -168,111 +154,43 @@ export type ReferenceListDeps = {
|
||||
}>
|
||||
}
|
||||
|
||||
const resolveParent = (parent?: ReferenceListParent): ReferenceListParent =>
|
||||
parent && typeof parent === 'object' && 'type' in parent ? parent : { type: 'roots' }
|
||||
|
||||
const allowKind = (
|
||||
kind: SelectedReference['kind'],
|
||||
filters: Set<SelectedReference['kind']> | null
|
||||
): boolean => !filters || filters.has(kind)
|
||||
|
||||
export const listReferenceItemsPage = async (
|
||||
/** 收集单个工作地址内深度 ≤ 2 的扁平候选(含根目录本身) */
|
||||
const collectWorkspaceShallowReferences = async (
|
||||
deps: ReferenceListDeps,
|
||||
request: ListReferenceItemsRequest,
|
||||
workspace: WorkspaceSource,
|
||||
kindFilters: Set<SelectedReference['kind']> | null
|
||||
): Promise<ReferenceItemListResponse> => {
|
||||
const query = String(request.query || '').trim()
|
||||
const offset = Number(request.offset) || 0
|
||||
const limit = Number(request.limit) || DEFAULT_PAGE_SIZE
|
||||
const parent = resolveParent(request.parent)
|
||||
): Promise<SelectedReference[]> => {
|
||||
if (!workspace.root_path) return []
|
||||
const normalized = await deps.normalizeWorkspaceRoot(workspace.root_path)
|
||||
const records = await deps.listWorkspaceFileIndexRecords(workspace.id)
|
||||
|
||||
if (query) {
|
||||
const workspaces = await deps.listWorkspaces()
|
||||
const local: SelectedReference[] = []
|
||||
for (const workspace of workspaces) {
|
||||
if (!workspace.root_path) continue
|
||||
if (!allowKind('directory', kindFilters) && !allowKind('file', kindFilters)) break
|
||||
const [scanResult, records] = await Promise.all([
|
||||
deps.scanWorkspaceTree({ workspaceId: workspace.id, rootPath: workspace.root_path }),
|
||||
deps.listWorkspaceFileIndexRecords(workspace.id)
|
||||
])
|
||||
local.push(
|
||||
...buildWorkspaceReferenceItemsFromCurrentTree({
|
||||
workspace: scanResult.workspace,
|
||||
entries: scanResult.entries,
|
||||
records
|
||||
})
|
||||
)
|
||||
if (normalized.kind === 'file') {
|
||||
if (!allowKind('file', kindFilters)) return []
|
||||
const name = path.basename(normalized.normalizedRootPath)
|
||||
const record = records.find((item) => item.relative_path === name)
|
||||
const file: FileReference = {
|
||||
kind: 'file',
|
||||
workspaceId: workspace.id,
|
||||
nodeId: toEntryId(workspace.id, 'file', name),
|
||||
name,
|
||||
relativePath: name,
|
||||
absolutePath: normalized.normalizedRootPath,
|
||||
fileExt: path.extname(name).slice(1) || undefined,
|
||||
kbDocumentIds:
|
||||
isIndexedWorkspaceFile(record) && record?.document_id ? [record.document_id] : [],
|
||||
kbAvailability: isIndexedWorkspaceFile(record) ? 'indexed' : 'not_indexed'
|
||||
}
|
||||
const web = allowKind('web', kindFilters)
|
||||
? (await deps.listWebSources()).map(buildWebReferenceItem)
|
||||
: []
|
||||
const matched = [...local, ...web]
|
||||
.filter((item) => allowKind(item.kind, kindFilters))
|
||||
.filter((item) => matchesQuery(item, query))
|
||||
return paginate(matched, offset, limit)
|
||||
return [file]
|
||||
}
|
||||
|
||||
if (parent.type === 'web') {
|
||||
if (!allowKind('web', kindFilters)) {
|
||||
return paginate([], offset, limit)
|
||||
}
|
||||
const web = (await deps.listWebSources()).map(buildWebReferenceItem)
|
||||
return paginate(web, offset, limit)
|
||||
}
|
||||
|
||||
if (parent.type === 'workspace') {
|
||||
const workspaces = await deps.listWorkspaces()
|
||||
const workspace = workspaces.find((item) => item.id === parent.workspaceId)
|
||||
if (!workspace?.root_path) return paginate([], offset, limit)
|
||||
const relativePath = normalizeRelativePath(parent.relativePath)
|
||||
const [entries, records] = await Promise.all([
|
||||
deps.listDirectory({
|
||||
workspaceId: workspace.id,
|
||||
rootPath: workspace.root_path,
|
||||
relativePath
|
||||
}),
|
||||
deps.listWorkspaceFileIndexRecords(workspace.id)
|
||||
])
|
||||
const items = buildReferencesFromEntries({ workspace, entries, records }).filter((item) =>
|
||||
allowKind(item.kind, kindFilters)
|
||||
)
|
||||
return paginate(items, offset, limit)
|
||||
}
|
||||
|
||||
// roots: workspace roots then web
|
||||
const workspaces = await deps.listWorkspaces()
|
||||
const roots: SelectedReference[] = []
|
||||
for (const workspace of workspaces) {
|
||||
if (!workspace.root_path) continue
|
||||
const normalized = await deps.normalizeWorkspaceRoot(workspace.root_path)
|
||||
const records = await deps.listWorkspaceFileIndexRecords(workspace.id)
|
||||
if (normalized.kind === 'file') {
|
||||
if (!allowKind('file', kindFilters)) continue
|
||||
const name = path.basename(normalized.normalizedRootPath)
|
||||
roots.push({
|
||||
kind: 'file',
|
||||
workspaceId: workspace.id,
|
||||
nodeId: toEntryId(workspace.id, 'file', name),
|
||||
name,
|
||||
relativePath: name,
|
||||
absolutePath: normalized.normalizedRootPath,
|
||||
fileExt: path.extname(name).slice(1) || undefined,
|
||||
kbDocumentIds: [],
|
||||
kbAvailability: 'not_indexed'
|
||||
})
|
||||
const record = records.find((item) => item.relative_path === name)
|
||||
if (isIndexedWorkspaceFile(record) && record?.document_id) {
|
||||
const last = roots[roots.length - 1]
|
||||
if (last && last.kind === 'file') {
|
||||
last.kbDocumentIds = [record.document_id]
|
||||
last.kbAvailability = 'indexed'
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!allowKind('directory', kindFilters)) continue
|
||||
roots.push(
|
||||
const items: SelectedReference[] = []
|
||||
if (allowKind('directory', kindFilters)) {
|
||||
items.push(
|
||||
buildDirectoryReferenceFromRecords({
|
||||
workspace,
|
||||
name: normalized.displayName,
|
||||
@@ -282,8 +200,53 @@ export const listReferenceItemsPage = async (
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const depth1 = await deps.listDirectory({
|
||||
workspaceId: workspace.id,
|
||||
rootPath: workspace.root_path,
|
||||
relativePath: ''
|
||||
})
|
||||
const depth1Dirs: WorkspaceTreeEntry[] = []
|
||||
for (const entry of depth1) {
|
||||
if (pathDepth(entry.relative_path) > MAX_REFERENCE_PATH_DEPTH) continue
|
||||
if (!allowKind(entry.kind, kindFilters)) continue
|
||||
items.push(entryToReference({ workspace, entry, records }))
|
||||
if (entry.kind === 'directory') depth1Dirs.push(entry)
|
||||
}
|
||||
|
||||
for (const dir of depth1Dirs) {
|
||||
const depth2 = await deps.listDirectory({
|
||||
workspaceId: workspace.id,
|
||||
rootPath: workspace.root_path,
|
||||
relativePath: dir.relative_path
|
||||
})
|
||||
for (const entry of depth2) {
|
||||
if (pathDepth(entry.relative_path) > MAX_REFERENCE_PATH_DEPTH) continue
|
||||
if (!allowKind(entry.kind, kindFilters)) continue
|
||||
items.push(entryToReference({ workspace, entry, records }))
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export const listReferenceItemsPage = async (
|
||||
deps: ReferenceListDeps,
|
||||
request: ListReferenceItemsRequest,
|
||||
kindFilters: Set<SelectedReference['kind']> | null
|
||||
): Promise<ReferenceItemListResponse> => {
|
||||
const query = String(request.query || '').trim()
|
||||
const offset = Number(request.offset) || 0
|
||||
const limit = Number(request.limit) || DEFAULT_PAGE_SIZE
|
||||
|
||||
const workspaces = await deps.listWorkspaces()
|
||||
const local: SelectedReference[] = []
|
||||
for (const workspace of workspaces) {
|
||||
local.push(...(await collectWorkspaceShallowReferences(deps, workspace, kindFilters)))
|
||||
}
|
||||
const web = allowKind('web', kindFilters)
|
||||
? (await deps.listWebSources()).map(buildWebReferenceItem)
|
||||
: []
|
||||
return paginate([...roots, ...web], offset, limit)
|
||||
const all = [...local, ...web].filter((item) => matchesQuery(item, query))
|
||||
return paginate(all, offset, limit)
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
LLMConfig,
|
||||
LlmRemoteModel,
|
||||
ReferenceKind,
|
||||
ReferenceListParent,
|
||||
SelectedReference,
|
||||
WorkbenchModelSelection
|
||||
} from '../../../../shared/types'
|
||||
@@ -132,10 +131,6 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
|
||||
const attachmentPreviewUrlsRef = useRef<Record<string, string>>({})
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null)
|
||||
const [mentionSessionId, setMentionSessionId] = useState<number | null>(null)
|
||||
const [mentionParent, setMentionParent] = useState<ReferenceListParent>({ type: 'roots' })
|
||||
const [mentionBreadcrumbs, setMentionBreadcrumbs] = useState<
|
||||
{ label: string; parent: ReferenceListParent }[]
|
||||
>([{ label: '资料', parent: { type: 'roots' } }])
|
||||
const [mentionOptions, setMentionOptions] = useState<SelectedReference[]>([])
|
||||
const [mentionLoading, setMentionLoading] = useState(false)
|
||||
const [mentionLoadingMore, setMentionLoadingMore] = useState(false)
|
||||
@@ -157,8 +152,6 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
|
||||
mentionQueryRef.current = null
|
||||
setMentionQuery(null)
|
||||
setMentionSessionId(null)
|
||||
setMentionParent({ type: 'roots' })
|
||||
setMentionBreadcrumbs([{ label: '资料', parent: { type: 'roots' } }])
|
||||
setMentionOptions([])
|
||||
setMentionHasMore(false)
|
||||
mentionHasMoreRef.current = false
|
||||
@@ -180,12 +173,7 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
|
||||
}, [])
|
||||
|
||||
const fetchMentionPage = useCallback(
|
||||
async (input: {
|
||||
parent: ReferenceListParent
|
||||
query: string
|
||||
offset: number
|
||||
append: boolean
|
||||
}): Promise<void> => {
|
||||
async (input: { query: string; offset: number; append: boolean }): Promise<void> => {
|
||||
const requestSeq = ++mentionRequestSeqRef.current
|
||||
if (input.append) {
|
||||
setMentionLoadingMore(true)
|
||||
@@ -197,7 +185,6 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
|
||||
const referenceKinds: ReferenceKind[] = COMPOSER_REFERENCE_KINDS
|
||||
const response = await window.api.referenceItemList({
|
||||
referenceKinds,
|
||||
parent: input.query ? undefined : input.parent,
|
||||
query: input.query || undefined,
|
||||
offset: input.offset,
|
||||
limit: REFERENCE_ITEM_PAGE_SIZE
|
||||
@@ -384,8 +371,6 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
|
||||
setMentionQuery(context.query)
|
||||
if (isNewMentionSession) {
|
||||
mentionSessionSequenceRef.current += 1
|
||||
setMentionParent({ type: 'roots' })
|
||||
setMentionBreadcrumbs([{ label: '资料', parent: { type: 'roots' } }])
|
||||
setMentionOptions([])
|
||||
setMentionSessionId(mentionSessionSequenceRef.current)
|
||||
}
|
||||
@@ -483,57 +468,21 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
|
||||
useEffect(() => {
|
||||
if (mentionSessionId === null || mentionQuery === null) return
|
||||
void fetchMentionPage({
|
||||
parent: mentionParent,
|
||||
query: mentionQuery,
|
||||
offset: 0,
|
||||
append: false
|
||||
})
|
||||
}, [fetchMentionPage, mentionParent, mentionQuery, mentionSessionId])
|
||||
|
||||
const navigateMentionParent = useCallback(
|
||||
(parent: ReferenceListParent, breadcrumbs: { label: string; parent: ReferenceListParent }[]) => {
|
||||
setMentionParent(parent)
|
||||
setMentionBreadcrumbs(breadcrumbs)
|
||||
setMentionOptions([])
|
||||
setMentionActiveIndex(0)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const enterMentionDirectory = useCallback(
|
||||
(reference: SelectedReference & { kind: 'directory' }) => {
|
||||
navigateMentionParent(
|
||||
{
|
||||
type: 'workspace',
|
||||
workspaceId: reference.workspaceId,
|
||||
relativePath: reference.relativePath
|
||||
},
|
||||
[
|
||||
...mentionBreadcrumbs,
|
||||
{
|
||||
label: reference.name,
|
||||
parent: {
|
||||
type: 'workspace',
|
||||
workspaceId: reference.workspaceId,
|
||||
relativePath: reference.relativePath
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
},
|
||||
[mentionBreadcrumbs, navigateMentionParent]
|
||||
)
|
||||
}, [fetchMentionPage, mentionQuery, mentionSessionId])
|
||||
|
||||
const loadMoreMentionOptions = useCallback(() => {
|
||||
if (mentionQuery === null) return
|
||||
if (!mentionHasMoreRef.current || mentionLoadingMoreRef.current || mentionLoading) return
|
||||
void fetchMentionPage({
|
||||
parent: mentionParent,
|
||||
query: mentionQuery,
|
||||
offset: mentionNextOffsetRef.current,
|
||||
append: true
|
||||
})
|
||||
}, [fetchMentionPage, mentionLoading, mentionParent, mentionQuery])
|
||||
}, [fetchMentionPage, mentionLoading, mentionQuery])
|
||||
|
||||
useEffect(() => {
|
||||
const handleSelectionChange = (): void => {
|
||||
@@ -748,12 +697,7 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const nextOption = mentionOptions[mentionActiveIndex]
|
||||
if (!nextOption) return
|
||||
if (nextOption.kind === 'directory' && !(mentionQuery || '').trim()) {
|
||||
enterMentionDirectory(nextOption)
|
||||
return
|
||||
}
|
||||
insertMentionScope(nextOption)
|
||||
if (nextOption) insertMentionScope(nextOption)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
@@ -896,24 +840,7 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
|
||||
options={mentionOptions}
|
||||
activeIndex={mentionActiveIndex}
|
||||
hasMore={mentionHasMore}
|
||||
breadcrumbs={mentionBreadcrumbs}
|
||||
searching={Boolean(mentionQuery.trim())}
|
||||
onSelect={insertMentionScope}
|
||||
onEnterDirectory={enterMentionDirectory}
|
||||
onNavigate={(parent) => {
|
||||
const index = mentionBreadcrumbs.findIndex(
|
||||
(crumb) =>
|
||||
crumb.parent.type === parent.type &&
|
||||
(parent.type !== 'workspace' ||
|
||||
(crumb.parent.type === 'workspace' &&
|
||||
crumb.parent.workspaceId === parent.workspaceId &&
|
||||
crumb.parent.relativePath === parent.relativePath))
|
||||
)
|
||||
navigateMentionParent(
|
||||
parent,
|
||||
index >= 0 ? mentionBreadcrumbs.slice(0, index + 1) : mentionBreadcrumbs
|
||||
)
|
||||
}}
|
||||
onLoadMore={loadMoreMentionOptions}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { Empty, Spin } from 'antd'
|
||||
import type { ReferenceListParent, SelectedReference } from '../../../../../shared/types'
|
||||
import type { SelectedReference } from '../../../../../shared/types'
|
||||
import { workbenchSelectors } from '../../../testing/selectors'
|
||||
import { getKbAvailabilityLabel } from '../../../utils/reference'
|
||||
import ReferenceIcon from './ReferenceIcon'
|
||||
import { getReferenceVisualSpec } from './referenceVisuals'
|
||||
import { getScopeKey } from './utils'
|
||||
|
||||
type BreadcrumbItem = {
|
||||
label: string
|
||||
parent: ReferenceListParent
|
||||
}
|
||||
|
||||
type ReferenceMentionPanelProps = {
|
||||
loading: boolean
|
||||
loadingMore: boolean
|
||||
options: SelectedReference[]
|
||||
activeIndex: number
|
||||
hasMore: boolean
|
||||
breadcrumbs: BreadcrumbItem[]
|
||||
searching: boolean
|
||||
onSelect: (reference: SelectedReference) => void
|
||||
onEnterDirectory: (reference: SelectedReference & { kind: 'directory' }) => void
|
||||
onNavigate: (parent: ReferenceListParent) => void
|
||||
onLoadMore: () => void
|
||||
}
|
||||
|
||||
@@ -33,6 +24,7 @@ type ReferenceMentionGroup = {
|
||||
|
||||
const getReferenceParentDisplayPath = (reference: SelectedReference): string => {
|
||||
if (reference.kind === 'web') return reference.url
|
||||
if (!reference.relativePath) return ''
|
||||
const pathParts = reference.relativePath.split('/').filter(Boolean)
|
||||
pathParts.pop()
|
||||
return pathParts.join('/')
|
||||
@@ -44,11 +36,7 @@ const ReferenceMentionPanel: React.FC<ReferenceMentionPanelProps> = ({
|
||||
options,
|
||||
activeIndex,
|
||||
hasMore,
|
||||
breadcrumbs,
|
||||
searching,
|
||||
onSelect,
|
||||
onEnterDirectory,
|
||||
onNavigate,
|
||||
onLoadMore
|
||||
}) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
@@ -83,27 +71,6 @@ const ReferenceMentionPanel: React.FC<ReferenceMentionPanelProps> = ({
|
||||
className="absolute inset-x-0 bottom-full mb-2 overflow-hidden rounded-[14px] border border-[#E5E4E0] bg-white px-2 py-2 shadow-[0_16px_36px_rgba(15,15,15,0.1)]"
|
||||
data-testid={workbenchSelectors.mentionPanel}
|
||||
>
|
||||
{!searching && breadcrumbs.length > 0 ? (
|
||||
<div className="mb-1 flex min-w-0 items-center gap-1 overflow-x-auto px-2.5 pb-1 text-[11px] text-[#9B9A97]">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<React.Fragment key={`${crumb.parent.type}-${index}`}>
|
||||
{index > 0 ? <span className="text-[#D0CFCB]">/</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`shrink-0 rounded px-0.5 hover:bg-[#F4F4F2] hover:text-[#57554F] ${
|
||||
index === breadcrumbs.length - 1 ? 'font-medium text-[#787774]' : ''
|
||||
}`}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
onNavigate(crumb.parent)
|
||||
}}
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-[180px] overflow-y-auto pr-1"
|
||||
@@ -130,59 +97,37 @@ const ReferenceMentionPanel: React.FC<ReferenceMentionPanelProps> = ({
|
||||
const active = activeIndex === optionIndex
|
||||
const parentPath = getReferenceParentDisplayPath(reference)
|
||||
const visualSpec = getReferenceVisualSpec(reference)
|
||||
const isDirectory = reference.kind === 'directory'
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={getScopeKey(reference)}
|
||||
className={`flex h-8 w-full items-center gap-0.5 rounded-[8px] px-1.5 transition-colors ${
|
||||
type="button"
|
||||
className={`flex h-8 w-full items-center gap-[2px] rounded-[8px] px-2.5 text-left transition-colors ${
|
||||
active ? 'bg-[#F1F1F0]' : 'hover:bg-[#F7F7F5]'
|
||||
}`}
|
||||
data-reference-kind={reference.kind}
|
||||
data-mention-active={active ? 'true' : undefined}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
onSelect(reference)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-[2px] rounded-[6px] px-1 text-left"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
if (isDirectory && !searching) {
|
||||
onEnterDirectory(reference)
|
||||
return
|
||||
}
|
||||
onSelect(reference)
|
||||
}}
|
||||
>
|
||||
<ReferenceIcon reference={reference} size="sm" />
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span
|
||||
className={`truncate text-[13px] font-normal leading-5 ${visualSpec.labelClassName}`}
|
||||
>
|
||||
{reference.name}
|
||||
</span>
|
||||
{parentPath ? (
|
||||
<span className="truncate text-[11px] font-normal leading-4 text-[#B1B0AD]">
|
||||
{parentPath}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-[#B1B0AD]">
|
||||
{getKbAvailabilityLabel(reference.kbAvailability)}
|
||||
</span>
|
||||
</button>
|
||||
{isDirectory && !searching ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] text-[#787774] hover:bg-[#EBEBEA] hover:text-[#37352F]"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onSelect(reference)
|
||||
}}
|
||||
<ReferenceIcon reference={reference} size="sm" />
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span
|
||||
className={`truncate text-[13px] font-normal leading-5 ${visualSpec.labelClassName}`}
|
||||
>
|
||||
引用
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{reference.name}
|
||||
</span>
|
||||
{parentPath ? (
|
||||
<span className="truncate text-[11px] font-normal leading-4 text-[#B1B0AD]">
|
||||
{parentPath}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-[#B1B0AD]">
|
||||
{getKbAvailabilityLabel(reference.kbAvailability)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -201,4 +146,3 @@ const ReferenceMentionPanel: React.FC<ReferenceMentionPanelProps> = ({
|
||||
}
|
||||
|
||||
export default ReferenceMentionPanel
|
||||
export type { BreadcrumbItem }
|
||||
+2
-7
@@ -299,16 +299,11 @@ export interface ReferenceItemListResponse {
|
||||
/** @ 候选单次拉取上限 */
|
||||
export const REFERENCE_ITEM_PAGE_SIZE = 50
|
||||
|
||||
/** 浏览父级:根(工作地址 + 网页)/ 某工作地址目录 / 仅网页 */
|
||||
export type ReferenceListParent =
|
||||
| { type: 'roots' }
|
||||
| { type: 'workspace'; workspaceId: string; relativePath: string }
|
||||
| { type: 'web' }
|
||||
/** 相对工作地址根最多收录的路径深度(根=0,其下两级=2) */
|
||||
export const REFERENCE_ITEM_MAX_PATH_DEPTH = 2
|
||||
|
||||
export interface ListReferenceItemsRequest {
|
||||
referenceKinds?: ReferenceKind[]
|
||||
parent?: ReferenceListParent
|
||||
/** 非空时全局搜索,忽略 parent 浏览位置 */
|
||||
query?: string
|
||||
offset?: number
|
||||
limit?: number
|
||||
|
||||
@@ -121,21 +121,6 @@ describe('workbench conversation e2e', () => {
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
await mentionPanel
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'Workbench Web Fixture' })
|
||||
.click()
|
||||
const afterWeb = await getWorkbenchReferenceTokenTexts(page)
|
||||
|
||||
await appendWorkbenchInputText(page, '@')
|
||||
await waitForWorkbenchMentionPanelText(page)
|
||||
await mentionPanel
|
||||
.locator('[data-reference-kind="directory"]')
|
||||
.getByRole('button')
|
||||
.filter({ hasText: setup.workspaceName })
|
||||
.first()
|
||||
.click()
|
||||
await waitForWorkbenchMentionPanelText(page)
|
||||
const docxIconPresent =
|
||||
(await mentionPanel.locator('[data-reference-icon-kind="docx"]').count()) > 0
|
||||
const pdfIconPresent =
|
||||
@@ -144,13 +129,13 @@ describe('workbench conversation e2e', () => {
|
||||
(await mentionPanel.locator('[data-reference-icon-kind="markdown"]').count()) > 0
|
||||
const genericFileIconPresent =
|
||||
(await mentionPanel
|
||||
.locator('[data-reference-kind="file"]')
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'human-input.txt' })
|
||||
.locator('[data-reference-icon-kind="file"]')
|
||||
.count()) > 0
|
||||
const markdownUsesGenericFileIcon =
|
||||
(await mentionPanel
|
||||
.locator('[data-reference-kind="file"]')
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'workbench-preview.md' })
|
||||
.locator('[data-reference-icon-kind="file"]')
|
||||
.count()) > 0
|
||||
@@ -159,42 +144,44 @@ describe('workbench conversation e2e', () => {
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const textFileIndexedVisible = await mentionPanel
|
||||
.locator('[data-reference-kind="file"]')
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'human-input.txt' })
|
||||
.filter({ hasText: '已索引' })
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const markdownFileIndexedVisible = await mentionPanel
|
||||
.locator('[data-reference-kind="file"]')
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'workbench-preview.md' })
|
||||
.filter({ hasText: '已索引' })
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
// 点进「工作」子目录查看索引后新增文件
|
||||
await mentionPanel
|
||||
.locator('[data-reference-kind="directory"]')
|
||||
.filter({ hasText: '工作' })
|
||||
.getByRole('button')
|
||||
.filter({ hasText: '工作' })
|
||||
.first()
|
||||
.click()
|
||||
await waitForWorkbenchMentionPanelText(page)
|
||||
const addedAfterIndexVisible = await mentionPanel
|
||||
.locator('[data-reference-kind="file"]')
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'added-after-index.md' })
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const addedAfterIndexUnindexedVisible = await mentionPanel
|
||||
.locator('[data-reference-kind="file"]')
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'added-after-index.md' })
|
||||
.filter({ hasText: '未索引' })
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const localTextFileStillUnindexed = false
|
||||
const localTextFileStillUnindexed = await mentionPanel
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'human-input.txt' })
|
||||
.filter({ hasText: '未索引' })
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
await mentionPanel
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'Workbench Web Fixture' })
|
||||
.click()
|
||||
const afterWeb = await getWorkbenchReferenceTokenTexts(page)
|
||||
const tokensBeforeSubmit = await getWorkbenchReferenceTokenTexts(page)
|
||||
const composerReferenceIconKinds = await getWorkbenchLocator(
|
||||
page,
|
||||
|
||||
Reference in new issue
Block a user