mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 13:51:25 +08:00
feat(knowledge): align GitHub preview in space creation
(cherry picked from commit 21367f10ce)
This commit is contained in:
1 parent
9c0791c367
commit
c19d01e759
8 files changed
+640
-5
No files matched your search
@@ -66,6 +66,13 @@ class KnowledgeFSInitialSourcePreviewService:
|
||||
payload: KnowledgeFSInitialSourcePreviewPayload | KnowledgeFSInitialWebsiteSourcePreviewPayload,
|
||||
is_canceled: Callable[[], bool] | None = None,
|
||||
) -> KnowledgeFSInitialSourcePreviewResponse:
|
||||
"""Preview a visible credential without creating a Space.
|
||||
|
||||
The caller supplies valid plugin parameters and an account in the tenant. Each
|
||||
online-document invocation returns only the pages emitted for that request;
|
||||
optional tree ancestry and cursor metadata remain intact. Previewing does not
|
||||
persist a source or alter credentials, and plugin failures propagate to the caller.
|
||||
"""
|
||||
_raise_if_canceled(is_canceled)
|
||||
self.require_visible_credential(tenant_id=tenant_id, account=account, payload=payload)
|
||||
from extensions.application_services.data_sources import build_data_source_credentials
|
||||
@@ -117,19 +124,24 @@ class KnowledgeFSInitialSourcePreviewService:
|
||||
if payload.kind == "online_document":
|
||||
document_runtime = cast(OnlineDocumentDatasourcePlugin, runtime)
|
||||
documents: list[KnowledgeFSInitialSourcePreviewDocumentResponse] = []
|
||||
next_cursor: str | None = None
|
||||
for document_message in document_runtime.get_online_document_pages(
|
||||
user_id=account.id,
|
||||
datasource_parameters=parameters,
|
||||
provider_type=document_runtime.datasource_provider_type(),
|
||||
):
|
||||
next_cursor = document_message.next_cursor or next_cursor
|
||||
for workspace in document_message.result:
|
||||
next_cursor = workspace.next_cursor or next_cursor
|
||||
workspace_id = workspace.workspace_id or payload.provider
|
||||
for document_page in workspace.pages:
|
||||
documents.append(
|
||||
KnowledgeFSInitialSourcePreviewDocumentResponse(
|
||||
has_children=document_page.has_children,
|
||||
last_edited_time=document_page.last_edited_time,
|
||||
name=document_page.page_name,
|
||||
page_id=document_page.page_id,
|
||||
parent_id=document_page.parent_id,
|
||||
provider_item_id=json.dumps(
|
||||
[workspace_id, document_page.page_id], separators=(",", ":")
|
||||
),
|
||||
@@ -142,8 +154,11 @@ class KnowledgeFSInitialSourcePreviewService:
|
||||
return KnowledgeFSInitialSourcePreviewResponse(
|
||||
documents=documents,
|
||||
kind=payload.kind,
|
||||
next_cursor=next_cursor,
|
||||
)
|
||||
return KnowledgeFSInitialSourcePreviewResponse(documents=documents, kind=payload.kind)
|
||||
return KnowledgeFSInitialSourcePreviewResponse(
|
||||
documents=documents, kind=payload.kind, next_cursor=next_cursor
|
||||
)
|
||||
|
||||
drive_runtime = cast(OnlineDriveDatasourcePlugin, runtime)
|
||||
files: list[KnowledgeFSInitialSourcePreviewFileResponse] = []
|
||||
|
||||
@@ -312,11 +312,13 @@ class KnowledgeFSInitialSourcePreviewPageResponse(ResponseModel):
|
||||
|
||||
|
||||
class KnowledgeFSInitialSourcePreviewDocumentResponse(ResponseModel):
|
||||
has_children: bool | None = Field(default=None, validation_alias=AliasChoices("has_children", "hasChildren"))
|
||||
last_edited_time: str | None = Field(
|
||||
default=None, validation_alias=AliasChoices("last_edited_time", "lastEditedTime")
|
||||
)
|
||||
name: str
|
||||
page_id: str = Field(validation_alias=AliasChoices("page_id", "pageId"))
|
||||
parent_id: str | None = Field(default=None, validation_alias=AliasChoices("parent_id", "parentId"))
|
||||
provider_item_id: str = Field(validation_alias=AliasChoices("provider_item_id", "providerItemId"))
|
||||
type: str
|
||||
workspace_id: str = Field(validation_alias=AliasChoices("workspace_id", "workspaceId"))
|
||||
@@ -341,6 +343,7 @@ class KnowledgeFSInitialSourcePreviewResponse(ResponseModel):
|
||||
documents: list[KnowledgeFSInitialSourcePreviewDocumentResponse] = Field(default_factory=list)
|
||||
files: list[KnowledgeFSInitialSourcePreviewFileResponse] = Field(default_factory=list)
|
||||
kind: Literal["online_document", "online_drive", "website_crawl"]
|
||||
next_cursor: str | None = Field(default=None, validation_alias=AliasChoices("next_cursor", "nextCursor"))
|
||||
next_page_parameters: dict[str, JsonValue] | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("next_page_parameters", "nextPageParameters"),
|
||||
|
||||
@@ -164,6 +164,61 @@ def test_preview_lists_online_documents_with_stable_import_identity(
|
||||
assert runtime.runtime.credentials == {"token": "secret"}
|
||||
|
||||
|
||||
def test_preview_preserves_github_tree_branch_and_cursor(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
service = _service(sqlite_session_factory)
|
||||
runtime = FakeDatasourceRuntime(
|
||||
provider_type="online_document",
|
||||
messages=[
|
||||
OnlineDocumentPagesMessage(
|
||||
next_cursor="2",
|
||||
result=[
|
||||
OnlineDocumentInfo(
|
||||
next_cursor="2",
|
||||
pages=[
|
||||
OnlineDocumentPage(
|
||||
has_children=True,
|
||||
last_edited_time="2026-11-10T00:00:00Z",
|
||||
page_id="dir:FFXN/dify:src",
|
||||
page_name="src",
|
||||
parent_id="contents:FFXN/dify",
|
||||
type="github_directory",
|
||||
)
|
||||
],
|
||||
total=1,
|
||||
workspace_id="github-user-1",
|
||||
workspace_name="FFXN's GitHub",
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
payload = _payload("online_document")
|
||||
payload.parameters = {"view": "tree", "parent_id": "contents:FFXN/dify", "limit": 100}
|
||||
with (
|
||||
patch(
|
||||
"services.data_source.provider_service.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"token": "secret"},
|
||||
),
|
||||
patch(
|
||||
"services.knowledge_fs.initial_source_preview.DatasourceManager.get_datasource_runtime",
|
||||
return_value=runtime,
|
||||
),
|
||||
):
|
||||
response = service.preview(
|
||||
tenant_id="tenant-1",
|
||||
account=_account(),
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
assert runtime.online_document_calls[0]["datasource_parameters"] == payload.parameters
|
||||
assert response.next_cursor == "2"
|
||||
assert response.documents[0].has_children is True
|
||||
assert response.documents[0].parent_id == "contents:FFXN/dify"
|
||||
assert response.documents[0].provider_item_id == '["github-user-1","dir:FFXN/dify:src"]'
|
||||
|
||||
|
||||
def test_preview_browses_online_drive_and_preserves_pagination(sqlite_session_factory: sessionmaker[Session]) -> None:
|
||||
service = _service(sqlite_session_factory)
|
||||
runtime = FakeDatasourceRuntime(
|
||||
|
||||
@@ -80,6 +80,7 @@ export type KnowledgeFsInitialSourcePreviewResponse = {
|
||||
documents?: Array<KnowledgeFsInitialSourcePreviewDocumentResponse>
|
||||
files?: Array<KnowledgeFsInitialSourcePreviewFileResponse>
|
||||
kind: 'online_document' | 'online_drive' | 'website_crawl'
|
||||
next_cursor?: string | null
|
||||
next_page_parameters?: {
|
||||
[key: string]: JsonValue
|
||||
} | null
|
||||
@@ -1535,9 +1536,11 @@ export type KnowledgeFsPublicFailureResponse = {
|
||||
export type JsonValue = unknown
|
||||
|
||||
export type KnowledgeFsInitialSourcePreviewDocumentResponse = {
|
||||
has_children?: boolean | null
|
||||
last_edited_time?: string | null
|
||||
name: string
|
||||
page_id: string
|
||||
parent_id?: string | null
|
||||
provider_item_id: string
|
||||
type: string
|
||||
workspace_id: string
|
||||
|
||||
@@ -1240,9 +1240,11 @@ export const zKnowledgeFsInitialWebsiteSourcePreviewPayload = z.object({
|
||||
* KnowledgeFSInitialSourcePreviewDocumentResponse
|
||||
*/
|
||||
export const zKnowledgeFsInitialSourcePreviewDocumentResponse = z.object({
|
||||
has_children: z.boolean().nullish(),
|
||||
last_edited_time: z.string().nullish(),
|
||||
name: z.string(),
|
||||
page_id: z.string(),
|
||||
parent_id: z.string().nullish(),
|
||||
provider_item_id: z.string(),
|
||||
type: z.string(),
|
||||
workspace_id: z.string(),
|
||||
@@ -1280,6 +1282,7 @@ export const zKnowledgeFsInitialSourcePreviewResponse = z.object({
|
||||
documents: z.array(zKnowledgeFsInitialSourcePreviewDocumentResponse).optional(),
|
||||
files: z.array(zKnowledgeFsInitialSourcePreviewFileResponse).optional(),
|
||||
kind: z.enum(['online_document', 'online_drive', 'website_crawl']),
|
||||
next_cursor: z.string().nullish(),
|
||||
next_page_parameters: z.record(z.string(), zJsonValue).nullish(),
|
||||
pages: z.array(zKnowledgeFsInitialSourcePreviewPageResponse).optional(),
|
||||
})
|
||||
|
||||
@@ -403,6 +403,48 @@ const notionDatasourceAuth = {
|
||||
provider: 'notion',
|
||||
}
|
||||
|
||||
const githubDatasourcePlugin = {
|
||||
...notionDatasourcePlugin,
|
||||
declaration: {
|
||||
...notionDatasourcePlugin.declaration,
|
||||
datasources: [
|
||||
{
|
||||
...notionDatasourcePlugin.declaration.datasources[0],
|
||||
identity: {
|
||||
...notionDatasourcePlugin.declaration.datasources[0]?.identity,
|
||||
label: { en_US: 'GitHub Repository', zh_Hans: 'GitHub Repository' },
|
||||
name: 'github',
|
||||
provider: 'github',
|
||||
},
|
||||
},
|
||||
],
|
||||
identity: {
|
||||
...notionDatasourcePlugin.declaration.identity,
|
||||
label: { en_US: 'GitHub Repository', zh_Hans: 'GitHub Repository' },
|
||||
name: 'github',
|
||||
},
|
||||
},
|
||||
plugin_id: 'langgenius/github_datasource',
|
||||
plugin_unique_identifier: 'langgenius/github_datasource:0.4.12@local',
|
||||
provider: 'github',
|
||||
}
|
||||
|
||||
const githubDatasourceAuth = {
|
||||
...notionDatasourceAuth,
|
||||
credentials_list: [
|
||||
{
|
||||
...notionDatasourceAuth.credentials_list[0],
|
||||
id: 'github-credential-1',
|
||||
name: 'GitHub token',
|
||||
},
|
||||
],
|
||||
label: { en_US: 'GitHub Repository' },
|
||||
name: 'github',
|
||||
plugin_id: 'langgenius/github_datasource',
|
||||
plugin_unique_identifier: 'langgenius/github_datasource:0.4.12@local',
|
||||
provider: 'github',
|
||||
}
|
||||
|
||||
const outlineDatasourcePlugin = {
|
||||
...notionDatasourcePlugin,
|
||||
declaration: {
|
||||
@@ -2382,6 +2424,100 @@ describe('CreateKnowledgePage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('previews GitHub branches lazily and imports only explicitly selected pages', async () => {
|
||||
const user = userEvent.setup()
|
||||
navigationMock.startMode = 'source'
|
||||
datasourceQueryMock.plugins.data = [
|
||||
firecrawlDatasourcePlugin,
|
||||
notionDatasourcePlugin,
|
||||
githubDatasourcePlugin,
|
||||
]
|
||||
datasourceQueryMock.auth.data = {
|
||||
result: [firecrawlDatasourceAuth, notionDatasourceAuth, githubDatasourceAuth],
|
||||
}
|
||||
const page = (pageId: string, name: string, type: string, parentId?: string) => ({
|
||||
has_children:
|
||||
type === 'repository' || type === 'github_collection' || type === 'github_directory',
|
||||
last_edited_time: '2026-11-10T00:00:00Z',
|
||||
name,
|
||||
page_id: pageId,
|
||||
parent_id: parentId ?? null,
|
||||
provider_item_id: JSON.stringify(['github-user-1', pageId]),
|
||||
type,
|
||||
workspace_id: 'github-user-1',
|
||||
workspace_name: "FFXN's GitHub",
|
||||
})
|
||||
serviceMock.previewInitialSource.mockImplementation(
|
||||
async ({ body }: { body: { parameters: Record<string, unknown> } }) => {
|
||||
const { parent_id: parentId, cursor } = body.parameters
|
||||
const documents =
|
||||
parentId === 'repo:FFXN/dify'
|
||||
? [page('contents:FFXN/dify', 'Contents', 'github_collection', parentId)]
|
||||
: parentId === 'contents:FFXN/dify'
|
||||
? [page('dir:FFXN/dify:src', 'src', 'github_directory', parentId)]
|
||||
: parentId === 'dir:FFXN/dify:src'
|
||||
? cursor
|
||||
? [page('file:FFXN/dify:src/extra.ts', 'extra.ts', 'file', parentId)]
|
||||
: [page('file:FFXN/dify:src/index.ts', 'index.ts', 'file', parentId)]
|
||||
: [page('repo:FFXN/dify', 'dify', 'repository')]
|
||||
return {
|
||||
documents,
|
||||
kind: 'online_document',
|
||||
next_cursor: parentId === 'dir:FFXN/dify:src' && !cursor ? '100' : null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await renderPage()
|
||||
await fillRequiredFields(user)
|
||||
await user.click(screen.getByRole('radio', { name: 'knowledgeSpace.onlineDocuments' }))
|
||||
await user.click(screen.getByRole('radio', { name: 'GitHub Repository' }))
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('knowledgeSources.sourceNamePlaceholder'),
|
||||
'GitHub code',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'knowledgeSpace.preview' }))
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'dify' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'Contents' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'src' }))
|
||||
await user.click(await screen.findByRole('checkbox', { name: 'src' }))
|
||||
await user.click(screen.getByRole('button', { name: 'knowledgeSpace.loadMore' }))
|
||||
await screen.findByRole('checkbox', { name: 'extra.ts' })
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: 'index.ts' })).toBeChecked()
|
||||
expect(screen.getByRole('checkbox', { name: 'extra.ts' })).not.toBeChecked()
|
||||
expect(
|
||||
serviceMock.previewInitialSource.mock.calls.map(([request]) => request.body.parameters),
|
||||
).toEqual([
|
||||
{ limit: 10, view: 'tree' },
|
||||
{ limit: 30, parent_id: 'repo:FFXN/dify', view: 'tree' },
|
||||
{ limit: 100, parent_id: 'contents:FFXN/dify', view: 'tree' },
|
||||
{ limit: 100, parent_id: 'dir:FFXN/dify:src', view: 'tree' },
|
||||
{ cursor: '100', limit: 100, parent_id: 'dir:FFXN/dify:src', view: 'tree' },
|
||||
])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'knowledgeCreate.createTitle' }))
|
||||
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledOnce())
|
||||
expect(serviceMock.create).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
initial_source: expect.objectContaining({
|
||||
datasource: 'github',
|
||||
kind: 'online_document',
|
||||
name: 'GitHub code',
|
||||
parameters: {},
|
||||
pluginId: 'langgenius/github_datasource',
|
||||
selection: [
|
||||
expect.objectContaining({
|
||||
pageId: 'file:FFXN/dify:src/index.ts',
|
||||
type: 'file',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('does not inject website defaults when switching connected document providers', async () => {
|
||||
const user = userEvent.setup()
|
||||
navigationMock.startMode = 'source'
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
withDatasourceParameterDefaults,
|
||||
} from './datasource-parameter-model'
|
||||
import { SourceNameField, SourceSyncPolicyField } from './fields'
|
||||
import { InitialGitHubTreePreview } from './initial-github-tree-preview'
|
||||
|
||||
type NextPageRequest = {
|
||||
bucket?: string
|
||||
@@ -160,6 +161,9 @@ function ConnectedSourceConfigurationFields({
|
||||
// previews start a new session so pending root or pagination responses are discarded.
|
||||
const previewSessionRef = useRef(0)
|
||||
const driveTransport = providerOption.providerType === 'online_drive'
|
||||
const githubTreePreview =
|
||||
providerOption.plugin.plugin_id === 'langgenius/github_datasource' &&
|
||||
providerOption.datasource.identity.name === 'github'
|
||||
const parameterSchemas = useMemo(
|
||||
() => datasourceParameterSchemas(providerOption.datasource),
|
||||
[providerOption.datasource],
|
||||
@@ -371,7 +375,17 @@ function ConnectedSourceConfigurationFields({
|
||||
onDraftChange({ ...draft, parameters: nextParameters })
|
||||
}}
|
||||
/>
|
||||
{!previewed && (
|
||||
{githubTreePreview && (
|
||||
<InitialGitHubTreePreview
|
||||
key={JSON.stringify(parameters)}
|
||||
binding={previewBinding}
|
||||
disabled={disabled || !parametersValid}
|
||||
parameters={parameters}
|
||||
selectedResources={selectedResources}
|
||||
onSelectionChange={onSelectionChange ?? setLocalSelection}
|
||||
/>
|
||||
)}
|
||||
{!githubTreePreview && !previewed && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
@@ -383,12 +397,12 @@ function ConnectedSourceConfigurationFields({
|
||||
{t(($) => $.preview)}
|
||||
</Button>
|
||||
)}
|
||||
{loading && (
|
||||
{!githubTreePreview && loading && (
|
||||
<div className="flex min-h-40 items-center justify-center rounded-lg border border-divider-subtle">
|
||||
<LoadingPlaceholder />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
{!githubTreePreview && error && (
|
||||
<div role="alert" className="rounded-lg bg-background-section p-4">
|
||||
<p className="system-sm-semibold text-text-primary">{t(($) => $.providerLoadFailed)}</p>
|
||||
<Button className="mt-3" type="button" onClick={() => void requestPreview()}>
|
||||
@@ -396,7 +410,7 @@ function ConnectedSourceConfigurationFields({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{previewed && !loading && (
|
||||
{!githubTreePreview && previewed && !loading && (
|
||||
<section className="overflow-hidden rounded-lg border border-divider-subtle bg-background-default">
|
||||
<div className="flex items-center gap-2 border-b border-divider-subtle px-3 py-2">
|
||||
<Checkbox
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
ConnectedSourceConfigurationBinding,
|
||||
ConnectedSourceSelection,
|
||||
PreviewResource,
|
||||
} from './connected-source-selection'
|
||||
import type { DatasourceParameters } from './datasource-parameter-model'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
|
||||
import { consoleClient } from '@/service/console'
|
||||
|
||||
type DocumentResource = Extract<PreviewResource, { kind: 'document' }>
|
||||
type Branch = {
|
||||
error: boolean
|
||||
loaded: boolean
|
||||
loading: boolean
|
||||
nextCursor: string | null
|
||||
requestedCursors: string[]
|
||||
resources: DocumentResource[]
|
||||
}
|
||||
type Row =
|
||||
| { kind: 'document'; resource: DocumentResource }
|
||||
| { kind: 'loadMore'; depth: number; parentKey: string }
|
||||
|
||||
const ROOT = 'root'
|
||||
const ROOT_PAGE_SIZE = 10
|
||||
const CHILD_PAGE_SIZE = 30
|
||||
const DIRECTORY_PAGE_SIZE = 100
|
||||
const MAX_SELECTION = 200
|
||||
const SELECTION_LIMIT_ID = 'create-github-source-selection-limit'
|
||||
|
||||
function isNavigationOnly(resource: DocumentResource) {
|
||||
return (
|
||||
resource.document.type === 'github_collection' || resource.document.type === 'github_directory'
|
||||
)
|
||||
}
|
||||
|
||||
function resourceIcon(resource: DocumentResource) {
|
||||
return resource.document.type === 'repository' || isNavigationOnly(resource)
|
||||
? 'i-ri-folder-3-fill text-text-warning'
|
||||
: 'i-ri-file-text-line text-text-tertiary'
|
||||
}
|
||||
|
||||
export function InitialGitHubTreePreview({
|
||||
binding,
|
||||
disabled,
|
||||
parameters,
|
||||
selectedResources,
|
||||
onSelectionChange,
|
||||
}: {
|
||||
binding: ConnectedSourceConfigurationBinding
|
||||
disabled: boolean
|
||||
parameters: DatasourceParameters
|
||||
selectedResources: ConnectedSourceSelection
|
||||
onSelectionChange: (selection: ConnectedSourceSelection) => void
|
||||
}) {
|
||||
const { t } = useTranslation(['knowledgeSpace', 'knowledgeSources', 'common'])
|
||||
const [branches, setBranches] = useState<ReadonlyMap<string, Branch>>(() => new Map())
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const root = branches.get(ROOT)
|
||||
const allResources = [
|
||||
...new Map(
|
||||
[...branches.values()]
|
||||
.flatMap((branch) => branch.resources)
|
||||
.map((resource) => [resource.key, resource]),
|
||||
).values(),
|
||||
]
|
||||
const selectable = allResources.filter((resource) => !isNavigationOnly(resource))
|
||||
const selected = new Set(selectedResources.map((resource) => resource.key))
|
||||
const selectionAtLimit = selected.size >= MAX_SELECTION
|
||||
|
||||
const updateSelection = (keys: Set<string>) => {
|
||||
onSelectionChange(allResources.filter((resource) => keys.has(resource.key)))
|
||||
}
|
||||
|
||||
const loadBranch = async (parent?: DocumentResource, cursor?: string) => {
|
||||
const scope = parent?.key ?? ROOT
|
||||
const prior = branches.get(scope)
|
||||
if (prior?.loading || (cursor && prior?.requestedCursors.includes(cursor))) return
|
||||
if (!parent && !cursor) {
|
||||
setExpanded(new Set())
|
||||
onSelectionChange([])
|
||||
}
|
||||
setBranches((current) =>
|
||||
new Map(current).set(scope, {
|
||||
error: false,
|
||||
loaded: prior?.loaded ?? false,
|
||||
loading: true,
|
||||
nextCursor: prior?.nextCursor ?? null,
|
||||
requestedCursors: prior?.requestedCursors ?? [],
|
||||
resources: prior?.resources ?? [],
|
||||
}),
|
||||
)
|
||||
try {
|
||||
const response = await consoleClient.knowledgeFs.sourceProviderPreview.post({
|
||||
body: {
|
||||
credentialId: binding.credentialId,
|
||||
datasource: binding.datasource,
|
||||
kind: 'online_document',
|
||||
parameters: {
|
||||
...parameters,
|
||||
view: 'tree',
|
||||
limit: parent
|
||||
? parent.document.type === 'github_directory' ||
|
||||
parent.document.page_id.startsWith('contents:')
|
||||
? DIRECTORY_PAGE_SIZE
|
||||
: CHILD_PAGE_SIZE
|
||||
: ROOT_PAGE_SIZE,
|
||||
...(parent ? { parent_id: parent.document.page_id } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
},
|
||||
pluginId: binding.pluginId,
|
||||
provider: binding.provider,
|
||||
},
|
||||
})
|
||||
const resources: DocumentResource[] = (response.documents ?? []).map((document) => ({
|
||||
depth: parent ? parent.depth + 1 : 0,
|
||||
document,
|
||||
key: `document:${document.provider_item_id}`,
|
||||
kind: 'document',
|
||||
parentKey: parent?.key,
|
||||
}))
|
||||
setBranches((current) => {
|
||||
const existing = current.get(scope)
|
||||
const merged = new Map(
|
||||
(cursor ? (existing?.resources ?? []) : []).map((item) => [item.key, item]),
|
||||
)
|
||||
for (const resource of resources) merged.set(resource.key, resource)
|
||||
const requestedCursors = cursor ? [...(existing?.requestedCursors ?? []), cursor] : []
|
||||
const nextCursor = response.next_cursor
|
||||
return new Map(current).set(scope, {
|
||||
error: false,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
nextCursor: nextCursor && !requestedCursors.includes(nextCursor) ? nextCursor : null,
|
||||
requestedCursors,
|
||||
resources: [...merged.values()],
|
||||
})
|
||||
})
|
||||
} catch {
|
||||
setBranches((current) => {
|
||||
const existing = current.get(scope)
|
||||
return new Map(current).set(scope, {
|
||||
error: true,
|
||||
loaded: existing?.loaded ?? false,
|
||||
loading: false,
|
||||
nextCursor: existing?.nextCursor ?? null,
|
||||
requestedCursors: existing?.requestedCursors ?? [],
|
||||
resources: existing?.resources ?? [],
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const loadedDescendants = (resource: DocumentResource) => {
|
||||
const result: DocumentResource[] = []
|
||||
const pending = [...(branches.get(resource.key)?.resources ?? [])]
|
||||
const seen = new Set<string>()
|
||||
while (pending.length) {
|
||||
const child = pending.shift()
|
||||
if (!child || seen.has(child.key)) continue
|
||||
seen.add(child.key)
|
||||
result.push(child)
|
||||
pending.push(...(branches.get(child.key)?.resources ?? []))
|
||||
}
|
||||
return result.filter((child) => !isNavigationOnly(child))
|
||||
}
|
||||
|
||||
const toggle = (resource: DocumentResource) => {
|
||||
const targets = [
|
||||
...(!isNavigationOnly(resource) ? [resource] : []),
|
||||
...loadedDescendants(resource),
|
||||
]
|
||||
if (!targets.length) return
|
||||
const next = new Set(selected)
|
||||
if (targets.every((target) => next.has(target.key))) {
|
||||
for (const target of targets) next.delete(target.key)
|
||||
} else {
|
||||
for (const target of targets) {
|
||||
if (next.size >= MAX_SELECTION) break
|
||||
next.add(target.key)
|
||||
}
|
||||
}
|
||||
updateSelection(next)
|
||||
}
|
||||
|
||||
const toggleAll = () => {
|
||||
const next = new Set(selected)
|
||||
if (selectable.length && selectable.every((resource) => next.has(resource.key))) {
|
||||
for (const resource of selectable) next.delete(resource.key)
|
||||
} else {
|
||||
for (const resource of selectable) {
|
||||
if (next.size >= MAX_SELECTION) break
|
||||
next.add(resource.key)
|
||||
}
|
||||
}
|
||||
updateSelection(next)
|
||||
}
|
||||
|
||||
const toggleExpanded = (resource: DocumentResource) => {
|
||||
const opening = !expanded.has(resource.key)
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current)
|
||||
if (opening) next.add(resource.key)
|
||||
else next.delete(resource.key)
|
||||
return next
|
||||
})
|
||||
if (opening && !branches.get(resource.key)?.loaded && !branches.get(resource.key)?.loading)
|
||||
void loadBranch(resource)
|
||||
}
|
||||
|
||||
const rows: Row[] = []
|
||||
const appendRows = (scope: string, depth: number) => {
|
||||
const branch = branches.get(scope)
|
||||
for (const resource of branch?.resources ?? []) {
|
||||
rows.push({ kind: 'document', resource })
|
||||
if (expanded.has(resource.key)) appendRows(resource.key, depth + 1)
|
||||
}
|
||||
if (scope !== ROOT && branch?.nextCursor && !branch.loading && !branch.error)
|
||||
rows.push({ kind: 'loadMore', depth, parentKey: scope })
|
||||
}
|
||||
appendRows(ROOT, 0)
|
||||
|
||||
const allSelected =
|
||||
selectable.length > 0 && selectable.every((resource) => selected.has(resource.key))
|
||||
const providerName = root?.resources[0]?.document.workspace_name || binding.providerDisplayName
|
||||
|
||||
return (
|
||||
<>
|
||||
{!root?.loaded && !root?.loading && !root?.error && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
onClick={() => void loadBranch()}
|
||||
>
|
||||
{t(($) => $.preview)}
|
||||
</Button>
|
||||
)}
|
||||
{root?.loading && !root.loaded && (
|
||||
<div className="flex min-h-40 items-center justify-center rounded-lg border border-divider-subtle">
|
||||
<LoadingPlaceholder />
|
||||
</div>
|
||||
)}
|
||||
{root?.error && !root.loaded && (
|
||||
<div role="alert" className="rounded-lg bg-background-section p-4">
|
||||
<p className="system-sm-semibold text-text-primary">{t(($) => $.providerLoadFailed)}</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => void loadBranch()}
|
||||
>
|
||||
{t(($) => $.retryProviderLoad)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{root?.loaded && (
|
||||
<section className="flex h-78 flex-col overflow-hidden rounded-xl border border-divider-regular">
|
||||
<div className="flex min-h-7.5 shrink-0 items-center gap-2 border-b border-divider-subtle px-3 py-1">
|
||||
<Checkbox
|
||||
aria-label={t(($) => $.selectAll, { ns: 'knowledgeSources' })}
|
||||
aria-describedby={selectionAtLimit && !allSelected ? SELECTION_LIMIT_ID : undefined}
|
||||
checked={allSelected}
|
||||
disabled={disabled || !selectable.length || (selectionAtLimit && !allSelected)}
|
||||
indeterminate={!allSelected && selected.size > 0}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
<span className="system-xs-medium text-text-primary">
|
||||
{t(($) => $.selectAll, { ns: 'knowledgeSources' })}
|
||||
</span>
|
||||
<span className="ms-auto min-w-0 flex-1 text-end system-xs-regular break-words text-text-tertiary">
|
||||
{providerName}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="min-h-0 flex-1 overflow-y-auto py-1" aria-live="polite">
|
||||
{rows.map((row) => {
|
||||
if (row.kind === 'loadMore') {
|
||||
const parent = allResources.find((resource) => resource.key === row.parentKey)
|
||||
return (
|
||||
<li
|
||||
key={`load-more:${row.parentKey}`}
|
||||
className="flex h-8.5 items-center pe-3"
|
||||
style={{ paddingInlineStart: `${12 + row.depth * 26}px` }}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (parent)
|
||||
void loadBranch(parent, branches.get(parent.key)?.nextCursor ?? undefined)
|
||||
}}
|
||||
>
|
||||
{t(($) => $.loadMore)}
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
const { resource } = row
|
||||
const branch = branches.get(resource.key)
|
||||
const targets = [
|
||||
...(!isNavigationOnly(resource) ? [resource] : []),
|
||||
...loadedDescendants(resource),
|
||||
]
|
||||
const checked =
|
||||
targets.length > 0 && targets.every((target) => selected.has(target.key))
|
||||
const indeterminate = !checked && targets.some((target) => selected.has(target.key))
|
||||
const expandable = Boolean(resource.document.has_children)
|
||||
const isExpanded = expanded.has(resource.key)
|
||||
return (
|
||||
<li
|
||||
key={resource.key}
|
||||
className={`flex items-center gap-2 pe-3 ${expandable ? 'h-8.5' : 'h-7.5'}`}
|
||||
style={{ paddingInlineStart: `${12 + resource.depth * 26}px` }}
|
||||
>
|
||||
{expandable ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={resource.document.name}
|
||||
aria-expanded={isExpanded}
|
||||
className="flex size-5 items-center justify-center rounded text-text-tertiary hover:bg-state-base-hover"
|
||||
disabled={disabled}
|
||||
onClick={() => toggleExpanded(resource)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`i-ri-arrow-right-s-line size-4 transition-transform motion-reduce:transition-none ${isExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="size-5" />
|
||||
)}
|
||||
<Checkbox
|
||||
aria-label={resource.document.name}
|
||||
aria-describedby={selectionAtLimit && !checked ? SELECTION_LIMIT_ID : undefined}
|
||||
checked={checked}
|
||||
indeterminate={indeterminate}
|
||||
disabled={disabled || !targets.length || (selectionAtLimit && !checked)}
|
||||
onCheckedChange={() => toggle(resource)}
|
||||
/>
|
||||
<span aria-hidden className={`${resourceIcon(resource)} size-4 shrink-0`} />
|
||||
<span className="min-w-0 flex-1 truncate system-xs-regular text-text-primary">
|
||||
{resource.document.name}
|
||||
</span>
|
||||
{isExpanded && branch?.loading && (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={t(($) => $.loading, { ns: 'common' })}
|
||||
className="i-ri-loader-4-line size-4 animate-spin text-text-tertiary"
|
||||
/>
|
||||
)}
|
||||
{isExpanded && branch?.error && (
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
void loadBranch(
|
||||
resource,
|
||||
branch.loaded ? (branch.nextCursor ?? undefined) : undefined,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(($) => $.retryProviderLoad)}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{root.nextCursor && !root.loading && (
|
||||
<div className="border-t border-divider-subtle px-3 py-2 text-center">
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
onClick={() => void loadBranch(undefined, root.nextCursor ?? undefined)}
|
||||
>
|
||||
{t(($) => (root.error ? $.retryProviderLoad : $.loadMore))}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{root?.loaded && (
|
||||
<span role="status" className="system-xs-regular text-text-tertiary">
|
||||
{t(($) => $.pagesSelected, { ns: 'knowledgeSources', count: selected.size })}
|
||||
{selectionAtLimit && (
|
||||
<span id={SELECTION_LIMIT_ID} className="ms-2 text-text-destructive">
|
||||
{t(($) => $.maxPages, { ns: 'knowledgeSources' })}: {MAX_SELECTION}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user