diff --git a/doc/prd/workbench.md b/doc/prd/workbench.md index c13acaa4..a4e0b483 100644 --- a/doc/prd/workbench.md +++ b/doc/prd/workbench.md @@ -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 `@` 插入与删除规则 diff --git a/doc/test-plan/workbench-test.md b/doc/test-plan/workbench-test.md index cb3e1983..16ae7bf7 100644 --- a/doc/test-plan/workbench-test.md +++ b/doc/test-plan/workbench-test.md @@ -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` diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index ddf41355..2a890aeb 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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) }, diff --git a/src/main/services/sourceScope/referenceList.ts b/src/main/services/sourceScope/referenceList.ts index f18bca0c..00d580eb 100644 --- a/src/main/services/sourceScope/referenceList.ts +++ b/src/main/services/sourceScope/referenceList.ts @@ -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 - scanWorkspaceTree: (input: { - workspaceId: string - rootPath: string - }) => Promise<{ workspace: WorkspaceSource; entries: WorkspaceTreeEntry[] }> listWebSources: () => Promise[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 | null ): boolean => !filters || filters.has(kind) -export const listReferenceItemsPage = async ( +/** 收集单个工作地址内深度 ≤ 2 的扁平候选(含根目录本身) */ +const collectWorkspaceShallowReferences = async ( deps: ReferenceListDeps, - request: ListReferenceItemsRequest, + workspace: WorkspaceSource, kindFilters: Set | null -): Promise => { - 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 => { + 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 | null +): Promise => { + 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) } diff --git a/src/renderer/src/components/workbench/WorkbenchComposer.tsx b/src/renderer/src/components/workbench/WorkbenchComposer.tsx index ee8d5150..f0f0ff39 100644 --- a/src/renderer/src/components/workbench/WorkbenchComposer.tsx +++ b/src/renderer/src/components/workbench/WorkbenchComposer.tsx @@ -12,7 +12,6 @@ import type { LLMConfig, LlmRemoteModel, ReferenceKind, - ReferenceListParent, SelectedReference, WorkbenchModelSelection } from '../../../../shared/types' @@ -132,10 +131,6 @@ const WorkbenchComposer: React.FC = ({ const attachmentPreviewUrlsRef = useRef>({}) const [mentionQuery, setMentionQuery] = useState(null) const [mentionSessionId, setMentionSessionId] = useState(null) - const [mentionParent, setMentionParent] = useState({ type: 'roots' }) - const [mentionBreadcrumbs, setMentionBreadcrumbs] = useState< - { label: string; parent: ReferenceListParent }[] - >([{ label: '资料', parent: { type: 'roots' } }]) const [mentionOptions, setMentionOptions] = useState([]) const [mentionLoading, setMentionLoading] = useState(false) const [mentionLoadingMore, setMentionLoadingMore] = useState(false) @@ -157,8 +152,6 @@ const WorkbenchComposer: React.FC = ({ 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 = ({ }, []) const fetchMentionPage = useCallback( - async (input: { - parent: ReferenceListParent - query: string - offset: number - append: boolean - }): Promise => { + async (input: { query: string; offset: number; append: boolean }): Promise => { const requestSeq = ++mentionRequestSeqRef.current if (input.append) { setMentionLoadingMore(true) @@ -197,7 +185,6 @@ const WorkbenchComposer: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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} diff --git a/src/renderer/src/components/workbench/composer/ReferenceMentionPanel.tsx b/src/renderer/src/components/workbench/composer/ReferenceMentionPanel.tsx index ce59c63f..5bba370c 100644 --- a/src/renderer/src/components/workbench/composer/ReferenceMentionPanel.tsx +++ b/src/renderer/src/components/workbench/composer/ReferenceMentionPanel.tsx @@ -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 = ({ options, activeIndex, hasMore, - breadcrumbs, - searching, onSelect, - onEnterDirectory, - onNavigate, onLoadMore }) => { const scrollRef = useRef(null) @@ -83,27 +71,6 @@ const ReferenceMentionPanel: React.FC = ({ 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 ? ( -
- {breadcrumbs.map((crumb, index) => ( - - {index > 0 ? / : null} - - - ))} -
- ) : null}
= ({ const active = activeIndex === optionIndex const parentPath = getReferenceParentDisplayPath(reference) const visualSpec = getReferenceVisualSpec(reference) - const isDirectory = reference.kind === 'directory' return ( -
{ + event.preventDefault() + onSelect(reference) + }} > - - {isDirectory && !searching ? ( - - ) : null} -
+ {reference.name} + + {parentPath ? ( + + {parentPath} + + ) : null} + + + {getKbAvailabilityLabel(reference.kbAvailability)} + + ) })}
@@ -201,4 +146,3 @@ const ReferenceMentionPanel: React.FC = ({ } export default ReferenceMentionPanel -export type { BreadcrumbItem } diff --git a/src/shared/types.ts b/src/shared/types.ts index 10d02b17..74ce0b62 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 diff --git a/tests/case/workbench/conversation/scope/reference-token-lifecycle.e2e.test.ts b/tests/case/workbench/conversation/scope/reference-token-lifecycle.e2e.test.ts index 43616d28..7f58ecd8 100644 --- a/tests/case/workbench/conversation/scope/reference-token-lifecycle.e2e.test.ts +++ b/tests/case/workbench/conversation/scope/reference-token-lifecycle.e2e.test.ts @@ -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,