From 5bb21b077e59f7899b4be259e4c40d45f275ff7a Mon Sep 17 00:00:00 2001 From: hahaha Date: Thu, 23 Jul 2026 15:40:06 +0800 Subject: [PATCH 01/13] feat: support multi-level folders for knowledge management Signed-off-by: hahaha --- client/agent.go | 1 + client/folder_scope_json_test.go | 41 + client/knowledge.go | 46 +- client/session.go | 46 +- frontend/src/api/chat/streamBody.ts | 80 ++ .../src/api/chat/streamFolderScope.test.ts | 100 ++ frontend/src/api/chat/streame.ts | 67 +- frontend/src/api/knowledge-base/create.ts | 26 + frontend/src/api/knowledge-base/folder.ts | 48 + frontend/src/api/knowledge-base/index.ts | 121 ++- frontend/src/api/knowledge-base/move.ts | 25 + frontend/src/api/knowledge-base/query.ts | 58 ++ .../src/components/FolderScopeSelector.vue | 425 +++++++++ frontend/src/components/Input-field.vue | 260 +++++- frontend/src/components/MentionSelector.vue | 305 ++++-- frontend/src/components/UploadConfirmHost.vue | 1 + frontend/src/components/empty-knowledge.vue | 3 +- .../components/manual-knowledge-editor.vue | 25 +- .../composables/useResizableSidebar.test.ts | 131 +++ .../src/composables/useResizableSidebar.ts | 237 +++++ frontend/src/hooks/useKnowledgeBase.ts | 25 +- frontend/src/i18n/folderScopeKeys.test.ts | 60 ++ frontend/src/i18n/locales/en-US.ts | 74 ++ frontend/src/i18n/locales/ko-KR.ts | 74 ++ frontend/src/i18n/locales/ru-RU.ts | 74 ++ frontend/src/i18n/locales/zh-CN.ts | 74 ++ frontend/src/stores/settings.ts | 135 ++- frontend/src/stores/settingsStorage.test.mjs | 17 + frontend/src/stores/settingsStorage.ts | 16 +- frontend/src/stores/ui.ts | 12 +- frontend/src/stores/uploadConfirm.ts | 4 + frontend/src/types/folder.ts | 19 + frontend/src/types/knowledge.ts | 11 + frontend/src/utils/folderScope.test.ts | 135 +++ frontend/src/utils/folderScope.ts | 156 ++++ frontend/src/utils/request.ts | 4 + frontend/src/utils/sourceFolderSelection.ts | 41 + frontend/src/views/chat/index.vue | 5 + .../src/views/knowledge/KnowledgeBase.vue | 882 +++++++++++++++++- .../components/DocumentActionMenu.vue | 9 +- .../knowledge/components/DocumentBatchBar.vue | 13 +- .../knowledge/components/DocumentCardView.vue | 101 +- .../knowledge/components/DocumentListView.vue | 80 +- .../knowledge/components/FolderMoveDialog.vue | 268 ++++++ .../components/KnowledgeFolderSidebar.vue | 610 ++++++++++++ .../components/UploadConfirmDialog.vue | 15 + .../src/views/knowledge/utils/folderDrag.ts | 128 +++ .../src/views/knowledge/utils/folderRoute.ts | 34 + .../views/knowledge/utils/folderTree.test.ts | 79 ++ .../src/views/knowledge/utils/folderTree.ts | 209 +++++ .../knowledge/utils/knowledgeBatchMove.ts | 37 + .../utils/knowledgeFolderRegression.test.ts | 58 ++ internal/agent/tools/grep_chunks.go | 41 + internal/agent/tools/knowledge_search.go | 3 + internal/agent/tools/scope_authorization.go | 21 +- internal/application/repository/folder.go | 256 +++++ .../application/repository/folder_test.go | 100 ++ internal/application/repository/knowledge.go | 242 ++++- .../repository/knowledge_batch_folder_test.go | 53 ++ .../repository/knowledge_finalize_test.go | 1 + .../repository/knowledge_folder_scope_test.go | 123 +++ .../repository/knowledge_tag_test.go | 2 +- .../service/chat_pipeline/merge_history.go | 62 ++ .../merge_history_folder_scope_test.go | 36 + .../application/service/datasource_service.go | 2 + internal/application/service/folder.go | 437 +++++++++ internal/application/service/folder_test.go | 93 ++ internal/application/service/knowledge.go | 368 +++++++- .../application/service/knowledge_create.go | 79 +- .../service/knowledge_create_test.go | 132 ++- .../application/service/session_agent_qa.go | 10 +- .../service/session_folder_scope_test.go | 104 +++ .../service/session_knowledge_qa.go | 337 ++++++- .../service/session_tag_targets_test.go | 24 + internal/container/container.go | 3 + internal/database/folder_migration_test.go | 83 ++ internal/handler/dto/folder.go | 44 + internal/handler/folder.go | 409 ++++++++ internal/handler/knowledge.go | 380 +++++++- .../session/folder_scope_multi_test.go | 73 ++ internal/handler/session/qa.go | 56 +- internal/handler/session/types.go | 2 + internal/im/cmd_search.go | 2 +- internal/im/service.go | 13 +- internal/router/rbac.go | 4 + internal/router/router.go | 73 ++ internal/types/agent.go | 19 +- internal/types/chat_manage.go | 18 +- internal/types/folder.go | 29 + internal/types/folder_scope_state_test.go | 35 + internal/types/interfaces/folder.go | 128 +++ internal/types/interfaces/knowledge.go | 70 ++ internal/types/interfaces/session.go | 2 +- internal/types/knowledge.go | 15 + internal/types/message.go | 1 + internal/types/qa_request.go | 1 + internal/types/search.go | 199 ++++ internal/types/session.go | 1 + migrations/sqlite/000071_add_folders.down.sql | 16 + migrations/sqlite/000071_add_folders.up.sql | 98 ++ .../versioned/000071_add_folders.down.sql | 13 + .../versioned/000071_add_folders.up.sql | 50 + 102 files changed, 9546 insertions(+), 322 deletions(-) create mode 100644 client/folder_scope_json_test.go create mode 100644 frontend/src/api/chat/streamBody.ts create mode 100644 frontend/src/api/chat/streamFolderScope.test.ts create mode 100644 frontend/src/api/knowledge-base/create.ts create mode 100644 frontend/src/api/knowledge-base/folder.ts create mode 100644 frontend/src/api/knowledge-base/move.ts create mode 100644 frontend/src/api/knowledge-base/query.ts create mode 100644 frontend/src/components/FolderScopeSelector.vue create mode 100644 frontend/src/composables/useResizableSidebar.test.ts create mode 100644 frontend/src/composables/useResizableSidebar.ts create mode 100644 frontend/src/i18n/folderScopeKeys.test.ts create mode 100644 frontend/src/types/folder.ts create mode 100644 frontend/src/types/knowledge.ts create mode 100644 frontend/src/utils/folderScope.test.ts create mode 100644 frontend/src/utils/folderScope.ts create mode 100644 frontend/src/utils/sourceFolderSelection.ts create mode 100644 frontend/src/views/knowledge/components/FolderMoveDialog.vue create mode 100644 frontend/src/views/knowledge/components/KnowledgeFolderSidebar.vue create mode 100644 frontend/src/views/knowledge/utils/folderDrag.ts create mode 100644 frontend/src/views/knowledge/utils/folderRoute.ts create mode 100644 frontend/src/views/knowledge/utils/folderTree.test.ts create mode 100644 frontend/src/views/knowledge/utils/folderTree.ts create mode 100644 frontend/src/views/knowledge/utils/knowledgeBatchMove.ts create mode 100644 frontend/src/views/knowledge/utils/knowledgeFolderRegression.test.ts create mode 100644 internal/application/repository/folder.go create mode 100644 internal/application/repository/folder_test.go create mode 100644 internal/application/repository/knowledge_batch_folder_test.go create mode 100644 internal/application/repository/knowledge_folder_scope_test.go create mode 100644 internal/application/service/chat_pipeline/merge_history_folder_scope_test.go create mode 100644 internal/application/service/folder.go create mode 100644 internal/application/service/folder_test.go create mode 100644 internal/application/service/session_folder_scope_test.go create mode 100644 internal/database/folder_migration_test.go create mode 100644 internal/handler/dto/folder.go create mode 100644 internal/handler/folder.go create mode 100644 internal/handler/session/folder_scope_multi_test.go create mode 100644 internal/types/folder.go create mode 100644 internal/types/folder_scope_state_test.go create mode 100644 internal/types/interfaces/folder.go create mode 100644 migrations/sqlite/000071_add_folders.down.sql create mode 100644 migrations/sqlite/000071_add_folders.up.sql create mode 100644 migrations/versioned/000071_add_folders.down.sql create mode 100644 migrations/versioned/000071_add_folders.up.sql diff --git a/client/agent.go b/client/agent.go index 0f560cdea8..1480790486 100644 --- a/client/agent.go +++ b/client/agent.go @@ -27,6 +27,7 @@ type AgentQARequest struct { Query string `json:"query"` // Required query text KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"` // Optional KBs for this query KnowledgeIDs []string `json:"knowledge_ids,omitempty"` // Optional specific knowledge IDs for this query + FolderScopes []FolderScope `json:"folder_scopes,omitempty"` // Optional folder scopes paired with KB IDs AgentEnabled bool `json:"agent_enabled"` // Whether to run in agent mode AgentID string `json:"agent_id,omitempty"` // Optional custom agent ID WebSearchEnabled bool `json:"web_search_enabled"` // Whether to enable web search diff --git a/client/folder_scope_json_test.go b/client/folder_scope_json_test.go new file mode 100644 index 0000000000..fc13d4ae14 --- /dev/null +++ b/client/folder_scope_json_test.go @@ -0,0 +1,41 @@ +package client + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestFolderScopeSDKJSONShapes(t *testing.T) { + kbID := "11111111-1111-1111-1111-111111111111" + folderID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + multi, err := json.Marshal(KnowledgeQARequest{ + Query: "q", KnowledgeBaseIDs: []string{kbID}, + FolderScopes: []FolderScope{{KnowledgeBaseID: kbID, FolderIDs: []string{folderID, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"}}}, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(multi), `"folder_ids":["`+folderID) || strings.Contains(string(multi), `"folder_id"`) { + t.Fatalf("unexpected multi-folder payload: %s", multi) + } + + legacy, err := json.Marshal(SearchKnowledgeRequest{ + Query: "q", KnowledgeBaseIDs: []string{kbID}, + FolderScopes: []FolderScope{{KnowledgeBaseID: kbID, FolderID: &folderID}}, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(legacy), `"folder_id":"`+folderID+`"`) { + t.Fatalf("legacy folder_id missing: %s", legacy) + } + + omitted, err := json.Marshal(AgentQARequest{Query: "q"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(omitted), "folder_scopes") { + t.Fatalf("empty scopes must be omitted: %s", omitted) + } +} diff --git a/client/knowledge.go b/client/knowledge.go index 8bfbe299a5..91e14aef32 100644 --- a/client/knowledge.go +++ b/client/knowledge.go @@ -25,6 +25,7 @@ type Knowledge struct { ID string `json:"id"` TenantID uint64 `json:"tenant_id"` KnowledgeBaseID string `json:"knowledge_base_id"` + FolderID *string `json:"folder_id"` TagID string `json:"tag_id"` Type string `json:"type"` Title string `json:"title"` @@ -108,6 +109,33 @@ type KnowledgeProcessOverrides struct { func (c *Client) CreateKnowledgeFromFile(ctx context.Context, knowledgeBaseID string, filePath string, metadata map[string]string, enableMultimodel *bool, customFileName string, channel string, processConfig *KnowledgeProcessOverrides, +) (*Knowledge, error) { + return c.CreateKnowledgeFromFileInFolder( + ctx, + knowledgeBaseID, + filePath, + metadata, + enableMultimodel, + customFileName, + channel, + processConfig, + nil, + ) +} + +// CreateKnowledgeFromFileInFolder creates a knowledge entry from a local file +// and optionally places it directly in folderID. A nil folderID preserves the +// legacy behavior and creates the knowledge in the knowledge-base root. +func (c *Client) CreateKnowledgeFromFileInFolder( + ctx context.Context, + knowledgeBaseID string, + filePath string, + metadata map[string]string, + enableMultimodel *bool, + customFileName string, + channel string, + processConfig *KnowledgeProcessOverrides, + folderID *string, ) (*Knowledge, error) { // Open the local file file, err := os.Open(filePath) @@ -174,6 +202,12 @@ func (c *Client) CreateKnowledgeFromFile(ctx context.Context, } } + if folderID != nil { + if err := writer.WriteField("folder_id", *folderID); err != nil { + return nil, fmt.Errorf("failed to write folder_id field: %w", err) + } + } + if processConfig != nil { processConfigBytes, err := json.Marshal(processConfig) if err != nil { @@ -236,6 +270,9 @@ type CreateKnowledgeFromURLRequest struct { Channel string `json:"channel,omitempty"` // ProcessConfig is optional per-upload parse config overrides (KnowledgeProcessOverrides). ProcessConfig *KnowledgeProcessOverrides `json:"process_config,omitempty"` + // FolderID optionally places the new knowledge directly in a folder. + // Nil or omitted means the knowledge-base root. + FolderID *string `json:"folder_id,omitempty"` } // CreateKnowledgeFromURL creates a knowledge entry from a URL. @@ -693,10 +730,11 @@ func (c *Client) UpdateImageInfo(ctx context.Context, // CreateManualKnowledgeRequest contains the parameters for creating a manual Markdown knowledge entry. type CreateManualKnowledgeRequest struct { - Title string `json:"title"` - Content string `json:"content"` - TagID string `json:"tag_id,omitempty"` - Channel string `json:"channel,omitempty"` + Title string `json:"title"` + Content string `json:"content"` + TagID string `json:"tag_id,omitempty"` + Channel string `json:"channel,omitempty"` + FolderID *string `json:"folder_id,omitempty"` } // UpdateManualKnowledgeRequest contains the parameters for updating a manual Markdown knowledge entry. diff --git a/client/session.go b/client/session.go index 37ff2ecce1..8cf2aeab03 100644 --- a/client/session.go +++ b/client/session.go @@ -207,18 +207,43 @@ type ImageAttachment struct { Caption string `json:"caption,omitempty"` // VLM analysis result } +// FolderScope represents a union of folder subtree boundaries for one knowledge +// base. FolderID remains available for legacy callers; new callers should use +// FolderIDs and omit the entire scope for whole-KB retrieval. +type FolderScope struct { + KnowledgeBaseID string `json:"knowledge_base_id"` + FolderIDs []string `json:"folder_ids,omitempty"` + FolderID *string `json:"folder_id,omitempty"` +} + +func (s FolderScope) MarshalJSON() ([]byte, error) { + if len(s.FolderIDs) > 0 { + return json.Marshal(struct { + KnowledgeBaseID string `json:"knowledge_base_id"` + FolderIDs []string `json:"folder_ids"` + FolderID *string `json:"folder_id,omitempty"` + }{s.KnowledgeBaseID, s.FolderIDs, s.FolderID}) + } + // Preserve the old SDK's ability to send explicit folder_id:null. + return json.Marshal(struct { + KnowledgeBaseID string `json:"knowledge_base_id"` + FolderID *string `json:"folder_id"` + }{s.KnowledgeBaseID, s.FolderID}) +} + // KnowledgeQARequest knowledge Q&A request type KnowledgeQARequest struct { - Query string `json:"query"` // Query text for knowledge base search - KnowledgeBaseIDs []string `json:"knowledge_base_ids"` // Selected knowledge base IDs for this request - KnowledgeIDs []string `json:"knowledge_ids"` // Selected knowledge IDs for this request - AgentEnabled bool `json:"agent_enabled"` // Whether agent mode is enabled for this request - AgentID string `json:"agent_id"` // Selected custom agent ID for this request - WebSearchEnabled bool `json:"web_search_enabled"` // Whether web search is enabled for this request - SummaryModelID string `json:"summary_model_id"` // Optional summary model ID (overrides session default) - DisableTitle bool `json:"disable_title"` // Whether to disable auto title generation - Images []ImageAttachment `json:"images,omitempty"` // Attached images for multimodal chat - Channel string `json:"channel,omitempty"` // Source channel: "web", "api", "im", etc. + Query string `json:"query"` // Query text for knowledge base search + KnowledgeBaseIDs []string `json:"knowledge_base_ids"` // Selected knowledge base IDs for this request + KnowledgeIDs []string `json:"knowledge_ids"` // Selected knowledge IDs for this request + FolderScopes []FolderScope `json:"folder_scopes,omitempty"` // Folder scopes paired with KB IDs + AgentEnabled bool `json:"agent_enabled"` // Whether agent mode is enabled for this request + AgentID string `json:"agent_id"` // Selected custom agent ID for this request + WebSearchEnabled bool `json:"web_search_enabled"` // Whether web search is enabled for this request + SummaryModelID string `json:"summary_model_id"` // Optional summary model ID (overrides session default) + DisableTitle bool `json:"disable_title"` // Whether to disable auto title generation + Images []ImageAttachment `json:"images,omitempty"` // Attached images for multimodal chat + Channel string `json:"channel,omitempty"` // Source channel: "web", "api", "im", etc. } // LLMToolCall represents a function/tool call from the LLM @@ -453,6 +478,7 @@ type SearchKnowledgeRequest struct { KnowledgeBaseID string `json:"knowledge_base_id,omitempty"` // Single knowledge base ID (for backward compatibility) KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"` // Knowledge base IDs (multi-KB support) KnowledgeIDs []string `json:"knowledge_ids,omitempty"` // Specific knowledge (file) IDs + FolderScopes []FolderScope `json:"folder_scopes,omitempty"` // Folder scopes paired with KB IDs TagIDs []string `json:"tag_ids,omitempty"` // Tag IDs for filtering within a single KB MentionedItems []MentionedItem `json:"mentioned_items,omitempty"` // Optional scoped tag mentions } diff --git a/frontend/src/api/chat/streamBody.ts b/frontend/src/api/chat/streamBody.ts new file mode 100644 index 0000000000..dd8791dba6 --- /dev/null +++ b/frontend/src/api/chat/streamBody.ts @@ -0,0 +1,80 @@ +export interface StreamFolderScope { + knowledge_base_id: string + folder_ids: string[] +} + +export interface StartStreamParams { + session_id: any + query: any + knowledge_base_ids?: string[] + knowledge_ids?: string[] + folder_scopes?: StreamFolderScope[] + tag_ids?: string[] + agent_enabled?: boolean + agent_id?: string + web_search_enabled?: boolean + summary_model_id?: string + mcp_service_ids?: string[] + skill_names?: string[] + mentioned_items?: Array<{id: string; name: string; type: string; kb_type?: string; kb_id?: string; kb_name?: string; service_id?: string; skill_name?: string}> + images?: Array<{data: string}> + attachment_uploads?: Array<{data: string; file_name: string; file_size: number}> + attachment_ids?: string[] + suggestion_attribution?: { suggestion_set_id: string; question_id: string } + method: string + url: string + embed_token?: string + embed_session_sig?: string + embed_visitor_id?: string +} + +export function buildStreamPostBody(params: StartStreamParams): Record { + const postBody: Record = { + query: params.query, + agent_enabled: params.agent_enabled !== undefined ? params.agent_enabled : true, + } + if (params.knowledge_base_ids !== undefined && params.knowledge_base_ids.length > 0) { + postBody.knowledge_base_ids = params.knowledge_base_ids + } + if (params.knowledge_ids !== undefined && params.knowledge_ids.length > 0) { + postBody.knowledge_ids = params.knowledge_ids + } + if (params.folder_scopes !== undefined && params.folder_scopes.length > 0) { + postBody.folder_scopes = params.folder_scopes + } + if (params.agent_id) { + postBody.agent_id = params.agent_id + } + if (params.web_search_enabled !== undefined) { + postBody.web_search_enabled = params.web_search_enabled + } + if (params.summary_model_id) { + postBody.summary_model_id = params.summary_model_id + } + if (params.mcp_service_ids !== undefined && params.mcp_service_ids.length > 0) { + postBody.mcp_service_ids = params.mcp_service_ids + } + if (params.skill_names !== undefined && params.skill_names.length > 0) { + postBody.skill_names = params.skill_names + } + if (params.tag_ids !== undefined && params.tag_ids.length > 0) { + postBody.tag_ids = params.tag_ids + } + if (params.mentioned_items !== undefined && params.mentioned_items.length > 0) { + postBody.mentioned_items = params.mentioned_items + } + if (params.images !== undefined && params.images.length > 0) { + postBody.images = params.images + } + if (params.attachment_uploads !== undefined && params.attachment_uploads.length > 0) { + postBody.attachment_uploads = params.attachment_uploads + } + if (params.attachment_ids !== undefined && params.attachment_ids.length > 0) { + postBody.attachment_ids = params.attachment_ids + } + if (params.suggestion_attribution) { + postBody.suggestion_attribution = params.suggestion_attribution + } + postBody.channel = params.embed_token ? 'embed' : 'web' + return postBody +} diff --git a/frontend/src/api/chat/streamFolderScope.test.ts b/frontend/src/api/chat/streamFolderScope.test.ts new file mode 100644 index 0000000000..aa784f9167 --- /dev/null +++ b/frontend/src/api/chat/streamFolderScope.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +function installMockLocalStorage() { + const store: Record = {} + Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = value + }, + removeItem: (key: string) => { + delete store[key] + }, + }, + configurable: true, + }) +} + +installMockLocalStorage() +const { buildStreamPostBody } = await import('./streamBody.ts') + +test('knowledge chat payload includes folder_scopes paired by KB', () => { + const body = buildStreamPostBody({ + session_id: 'session-1', + query: 'What can the product do?', + method: 'POST', + url: '/api/v1/knowledge-chat', + knowledge_base_ids: ['kb-1', 'kb-2'], + knowledge_ids: ['file-1'], + folder_scopes: [ + { knowledge_base_id: 'kb-1', folder_ids: ['folder-a', 'folder-b'] }, + ], + tag_ids: ['tag-1'], + agent_enabled: false, + }) + + assert.deepEqual(body.folder_scopes, [ + { knowledge_base_id: 'kb-1', folder_ids: ['folder-a', 'folder-b'] }, + ]) + assert.deepEqual(body.knowledge_base_ids, ['kb-1', 'kb-2']) + assert.deepEqual(body.knowledge_ids, ['file-1']) + assert.deepEqual(body.tag_ids, ['tag-1']) + assert.equal('tenant_id' in body, false) + assert.equal('folder_path' in body, false) + assert.equal('folder_name' in body, false) +}) + +test('agent chat payload uses the same folder_scopes shape', () => { + const body = buildStreamPostBody({ + session_id: 'session-1', + query: 'Investigate this', + method: 'POST', + url: '/api/v1/agent-chat', + knowledge_base_ids: ['kb-1', 'kb-2'], + folder_scopes: [ + { knowledge_base_id: 'kb-1', folder_ids: ['folder-a', 'folder-b'] }, + { knowledge_base_id: 'kb-2', folder_ids: ['folder-c'] }, + ], + agent_enabled: true, + agent_id: 'agent-1', + mcp_service_ids: ['svc-1'], + skill_names: ['skill-a'], + }) + + assert.equal(body.agent_enabled, true) + assert.equal(body.agent_id, 'agent-1') + assert.deepEqual(body.folder_scopes, [ + { knowledge_base_id: 'kb-1', folder_ids: ['folder-a', 'folder-b'] }, + { knowledge_base_id: 'kb-2', folder_ids: ['folder-c'] }, + ]) + assert.equal(JSON.stringify(body.folder_scopes).includes('folder_id"'), false) +}) + +test('empty folder scopes are omitted so old payloads remain unchanged', () => { + const body = buildStreamPostBody({ + session_id: 'session-1', + query: 'Plain chat', + method: 'POST', + url: '/api/v1/knowledge-chat', + folder_scopes: [], + agent_enabled: false, + }) + + assert.equal('folder_scopes' in body, false) +}) + +test('embed channel still serializes without folder scopes unless provided', () => { + const body = buildStreamPostBody({ + session_id: 'session-1', + query: 'Embedded', + method: 'POST', + url: '/api/v1/embed-chat', + embed_token: 'embed-token', + agent_enabled: false, + }) + + assert.equal(body.channel, 'embed') + assert.equal('folder_scopes' in body, false) +}) diff --git a/frontend/src/api/chat/streame.ts b/frontend/src/api/chat/streame.ts index c042af9bbb..631533e111 100644 --- a/frontend/src/api/chat/streame.ts +++ b/frontend/src/api/chat/streame.ts @@ -1,12 +1,13 @@ import { fetchEventSource } from '@microsoft/fetch-event-source'; import { ref, onUnmounted } from 'vue'; -import { generateRandomString } from '@/utils/index'; -import i18n from '@/i18n'; -import { getApiBaseUrl } from '@/utils/api-base'; +import { generateRandomString } from '../../utils/index'; +import i18n from '../../i18n'; +import { getApiBaseUrl } from '../../utils/api-base'; import { sanitizeStreamRequestBody, type StreamRequestMeta, -} from '@/utils/chatRequestDebug'; +} from '../../utils/chatRequestDebug'; +import { buildStreamPostBody, type StartStreamParams } from './streamBody'; @@ -36,7 +37,7 @@ export function useStream() { let renderTimer: number | null = null // 启动流式请求 - const startStream = async (params: { session_id: any; query: any; knowledge_base_ids?: string[]; knowledge_ids?: string[]; tag_ids?: string[]; agent_enabled?: boolean; agent_id?: string; web_search_enabled?: boolean; summary_model_id?: string; mcp_service_ids?: string[]; skill_names?: string[]; mentioned_items?: Array<{id: string; name: string; type: string; kb_type?: string; kb_id?: string; kb_name?: string; service_id?: string; skill_name?: string}>; images?: Array<{data: string}>; attachment_uploads?: Array<{data: string; file_name: string; file_size: number}>; attachment_ids?: string[]; suggestion_attribution?: { suggestion_set_id: string; question_id: string }; method: string; url: string; embed_token?: string; embed_session_sig?: string; embed_visitor_id?: string }) => { + const startStream = async (params: StartStreamParams) => { const myGeneration = ++streamGeneration // 重置状态 output.value = ''; @@ -81,61 +82,7 @@ export function useStream() { : `${apiUrl}${params.url}/${params.session_id}?message_id=${params.query}`; console.log(`[TTFB] request:start request_id=${requestID} url=${url} sent_at=${Date.now()}`); - // Prepare POST body with required fields for agent-chat - // knowledge_base_ids array and agent_enabled can update Session's SessionAgentConfig - const postBody: any = { - query: params.query, - agent_enabled: params.agent_enabled !== undefined ? params.agent_enabled : true - }; - // Always include knowledge_base_ids for agent-chat (already validated above) - if (params.knowledge_base_ids !== undefined && params.knowledge_base_ids.length > 0) { - postBody.knowledge_base_ids = params.knowledge_base_ids; - } - // Include knowledge_ids if provided - if (params.knowledge_ids !== undefined && params.knowledge_ids.length > 0) { - postBody.knowledge_ids = params.knowledge_ids; - } - // Include agent_id if provided (backend resolves shared agent and tenant from share relation) - if (params.agent_id) { - postBody.agent_id = params.agent_id; - } - // Include web_search_enabled if provided - if (params.web_search_enabled !== undefined) { - postBody.web_search_enabled = params.web_search_enabled; - } - // Include summary_model_id if provided (for non-Agent mode) - if (params.summary_model_id) { - postBody.summary_model_id = params.summary_model_id; - } - // Include mcp_service_ids if provided (for Agent mode) - if (params.mcp_service_ids !== undefined && params.mcp_service_ids.length > 0) { - postBody.mcp_service_ids = params.mcp_service_ids; - } - if (params.skill_names !== undefined && params.skill_names.length > 0) { - postBody.skill_names = params.skill_names; - } - if (params.tag_ids !== undefined && params.tag_ids.length > 0) { - postBody.tag_ids = params.tag_ids; - } - // Include mentioned_items if provided (for displaying @mentions in chat) - if (params.mentioned_items !== undefined && params.mentioned_items.length > 0) { - postBody.mentioned_items = params.mentioned_items; - } - // Include images if provided (base64 data URIs for multimodal chat) - if (params.images !== undefined && params.images.length > 0) { - postBody.images = params.images; - } - // Include attachment_uploads if provided (documents, audio, etc.) - if (params.attachment_uploads !== undefined && params.attachment_uploads.length > 0) { - postBody.attachment_uploads = params.attachment_uploads; - } - if (params.attachment_ids !== undefined && params.attachment_ids.length > 0) { - postBody.attachment_ids = params.attachment_ids; - } - if (params.suggestion_attribution) { - postBody.suggestion_attribution = params.suggestion_attribution; - } - postBody.channel = embedToken ? "embed" : "web"; + const postBody = buildStreamPostBody(params); lastStreamRequest.value = { requestId: requestID, diff --git a/frontend/src/api/knowledge-base/create.ts b/frontend/src/api/knowledge-base/create.ts new file mode 100644 index 0000000000..c6a0a9baa9 --- /dev/null +++ b/frontend/src/api/knowledge-base/create.ts @@ -0,0 +1,26 @@ +import type { KnowledgeProcessOverrides } from '@/types/knowledgeProcess' + +export interface UploadKnowledgeFileData { + file: File + tag_ids?: string[] + fileName?: string + process_config?: KnowledgeProcessOverrides | string + folder_id?: string | null + [key: string]: any +} + +export function buildKnowledgeUploadFormData(data: UploadKnowledgeFileData): FormData { + const formData = new FormData() + Object.keys(data).forEach((key) => { + const value = data[key] + if (value === undefined || value === null) return + if (key === 'tag_ids' && Array.isArray(value)) { + formData.append(key, value.join(',')) + } else if (key === 'process_config' && value && typeof value !== 'string') { + formData.append(key, JSON.stringify(value)) + } else { + formData.append(key, value) + } + }) + return formData +} diff --git a/frontend/src/api/knowledge-base/folder.ts b/frontend/src/api/knowledge-base/folder.ts new file mode 100644 index 0000000000..62ef34fdd0 --- /dev/null +++ b/frontend/src/api/knowledge-base/folder.ts @@ -0,0 +1,48 @@ +import { del, get, patch, post } from '@/utils/request' +import type { Folder } from '@/types/folder' +import { buildFolderListSearchParams, type FolderListQueryOptions } from './query' +import { buildFolderMovePayload } from './move' + +export interface FolderResponse { + success: boolean + data: Folder +} + +export interface FolderListResponse { + success: boolean + data: Folder[] +} + +export function listFolders(kbId: string, options: FolderListQueryOptions = {}) { + const query = buildFolderListSearchParams(options).toString() + const path = `/api/v1/knowledge-bases/${kbId}/folders` + return get(query ? `${path}?${query}` : path) +} + +export function getFolder(kbId: string, folderId: string) { + return get(`/api/v1/knowledge-bases/${kbId}/folders/${folderId}`) +} + +export function createFolder(kbId: string, name: string, parentId: string | null) { + return post(`/api/v1/knowledge-bases/${kbId}/folders`, { + name, + parent_id: parentId, + }) +} + +export function renameFolder(kbId: string, folderId: string, name: string) { + return patch(`/api/v1/knowledge-bases/${kbId}/folders/${folderId}/name`, { + name, + }) +} + +export function moveFolder(kbId: string, folderId: string, parentId: string | null) { + return patch( + `/api/v1/knowledge-bases/${kbId}/folders/${folderId}/parent`, + buildFolderMovePayload(parentId), + ) +} + +export function deleteFolder(kbId: string, folderId: string) { + return del(`/api/v1/knowledge-bases/${kbId}/folders/${folderId}`) +} diff --git a/frontend/src/api/knowledge-base/index.ts b/frontend/src/api/knowledge-base/index.ts index f1d422fd97..91b281468e 100644 --- a/frontend/src/api/knowledge-base/index.ts +++ b/frontend/src/api/knowledge-base/index.ts @@ -1,6 +1,36 @@ -import { get, post, put, del, postUpload, getDown } from "../../utils/request"; +import { get, post, put, patch, del, postUpload, getDown } from "../../utils/request"; import type { KnowledgeProcessOverrides } from '@/types/knowledgeProcess'; +import type { Knowledge } from '@/types/knowledge'; import type { AuditLog, AuditOutcome, ListAuditLogResponse } from '@/api/tenant/audit-log'; +import { + buildKnowledgeListSearchParams, + type KnowledgeListQueryParams, +} from './query'; +import { + buildKnowledgeUploadFormData, + type UploadKnowledgeFileData, +} from './create'; +import { + buildBatchMoveKnowledgePayload, + buildKnowledgeFolderMovePayload, + type BatchMoveKnowledgePayload, +} from './move'; + +export * from './folder'; +export { buildKnowledgeUploadFormData } from './create'; +export type { UploadKnowledgeFileData } from './create'; +export { + buildBatchMoveKnowledgePayload, + buildFolderMovePayload, + buildKnowledgeFolderMovePayload, +} from './move'; +export type { BatchMoveKnowledgePayload } from './move'; +export { + buildFolderListSearchParams, + buildKnowledgeListScopeKey, + buildKnowledgeListSearchParams, +} from './query'; +export type { FolderListQueryOptions, KnowledgeListQueryParams } from './query'; export type KnowledgeBaseActivity = AuditLog; @@ -23,7 +53,9 @@ export async function listKnowledgeBaseActivity( if (params.outcome) query.set('outcome', params.outcome); if (params.actor) query.set('actor', params.actor); const qs = query.toString(); - return (await get(`/api/v1/knowledge-bases/${id}/activity${qs ? `?${qs}` : ''}`)) as unknown as ListAuditLogResponse; + return (await get( + `/api/v1/knowledge-bases/${id}/activity${qs ? `?${qs}` : ''}`, + )) as unknown as ListAuditLogResponse; } // 知识库管理 API(列表、创建、获取、更新、删除、复制) @@ -201,27 +233,10 @@ export function togglePinKnowledgeBase(id: string) { // data.tag_ids: 可选,指定知识所属的多个标签 ID export function uploadKnowledgeFile( kbId: string, - data: { - file: File - tag_ids?: string[] - fileName?: string - process_config?: KnowledgeProcessOverrides | string - [key: string]: any - } = { file: new File([], '') }, + data: UploadKnowledgeFileData = { file: new File([], '') }, onProgress?: (progressEvent: any) => void, ) { - const formData = new FormData(); - Object.keys(data).forEach(key => { - const value = data[key]; - if (value === undefined) return; - if (key === 'tag_ids' && Array.isArray(value)) { - formData.append(key, value.join(',')); - } else if (key === 'process_config' && value && typeof value !== 'string') { - formData.append(key, JSON.stringify(value)); - } else { - formData.append(key, value); - } - }); + const formData = buildKnowledgeUploadFormData(data); return postUpload(`/api/v1/knowledge-bases/${kbId}/knowledge/file`, formData, onProgress); } @@ -229,7 +244,13 @@ export function uploadKnowledgeFile( // data.tag_ids: 可选,指定知识所属的多个标签 ID export function createKnowledgeFromURL( kbId: string, - data: { url: string; enable_multimodel?: boolean; tag_ids?: string[]; process_config?: KnowledgeProcessOverrides }, + data: { + url: string + enable_multimodel?: boolean + tag_ids?: string[] + process_config?: KnowledgeProcessOverrides + folder_id?: string | null + }, ) { return post(`/api/v1/knowledge-bases/${kbId}/knowledge/url`, data); } @@ -244,6 +265,7 @@ export function createManualKnowledge( status: string tag_ids?: string[] process_config?: KnowledgeProcessOverrides + folder_id?: string | null }, ) { return post(`/api/v1/knowledge-bases/${kbId}/knowledge/manual`, data); @@ -251,32 +273,43 @@ export function createManualKnowledge( export function listKnowledgeFiles( kbId: string, - params: { - page: number; - page_size: number; - tag_ids?: string; - keyword?: string; - file_type?: string; - parse_status?: string; - source?: string; - start_time?: string; - end_time?: string; - }, + params: KnowledgeListQueryParams, ) { - const query = new URLSearchParams(); - query.append('page', String(params.page)); - query.append('page_size', String(params.page_size)); - if (params.tag_ids) query.append('tag_ids', params.tag_ids); - if (params.keyword) query.append('keyword', params.keyword); - if (params.file_type) query.append('file_type', params.file_type); - if (params.parse_status) query.append('parse_status', params.parse_status); - if (params.source) query.append('source', params.source); - if (params.start_time) query.append('start_time', params.start_time); - if (params.end_time) query.append('end_time', params.end_time); - const qs = query.toString(); + const qs = buildKnowledgeListSearchParams(params).toString(); return get(`/api/v1/knowledge-bases/${kbId}/knowledge?${qs}`); } +export interface KnowledgeResponse { + success: boolean + data: Knowledge +} + +export function moveKnowledgeToFolder( + kbId: string, + knowledgeId: string, + folderId: string | null, +) { + return patch( + `/api/v1/knowledge-bases/${kbId}/knowledge/${knowledgeId}/folder`, + buildKnowledgeFolderMovePayload(folderId), + ); +} + +export interface BatchMoveKnowledgeResponse { + success: boolean + message: string + data: { + moved_count: number + } +} + +export function batchMoveKnowledgeToFolder(payload: BatchMoveKnowledgePayload) { + return post( + '/api/v1/knowledge/batch-move-folder', + buildBatchMoveKnowledgePayload(payload.kb_id, payload.ids, payload.folder_id), + ); +} + export function getKnowledgeDetails(id: string, options?: { agent_id?: string }) { const query = new URLSearchParams(); if (options?.agent_id) query.set('agent_id', options.agent_id); diff --git a/frontend/src/api/knowledge-base/move.ts b/frontend/src/api/knowledge-base/move.ts new file mode 100644 index 0000000000..d133c6c7de --- /dev/null +++ b/frontend/src/api/knowledge-base/move.ts @@ -0,0 +1,25 @@ +export function buildFolderMovePayload(parentId: string | null) { + return { parent_id: parentId } +} + +export function buildKnowledgeFolderMovePayload(folderId: string | null) { + return { folder_id: folderId } +} + +export interface BatchMoveKnowledgePayload { + kb_id: string + ids: string[] + folder_id: string | null +} + +export function buildBatchMoveKnowledgePayload( + kbId: string, + ids: Iterable, + folderId: string | null, +): BatchMoveKnowledgePayload { + return { + kb_id: kbId, + ids: [...new Set(ids)], + folder_id: folderId, + } +} diff --git a/frontend/src/api/knowledge-base/query.ts b/frontend/src/api/knowledge-base/query.ts new file mode 100644 index 0000000000..4e00980a66 --- /dev/null +++ b/frontend/src/api/knowledge-base/query.ts @@ -0,0 +1,58 @@ +export interface KnowledgeListQueryParams { + page: number + page_size: number + tag_ids?: string + keyword?: string + file_type?: string + parse_status?: string + source?: string + start_time?: string + end_time?: string + folder_id?: string | null +} + +export interface FolderListQueryOptions { + all?: boolean + parentId?: string | null +} + +function appendOptionalValue(query: URLSearchParams, key: string, value?: string) { + if (value) query.append(key, value) +} + +export function buildKnowledgeListSearchParams(params: KnowledgeListQueryParams): URLSearchParams { + const query = new URLSearchParams() + query.append('page', String(params.page)) + query.append('page_size', String(params.page_size)) + appendOptionalValue(query, 'tag_ids', params.tag_ids) + appendOptionalValue(query, 'keyword', params.keyword) + appendOptionalValue(query, 'file_type', params.file_type) + appendOptionalValue(query, 'parse_status', params.parse_status) + appendOptionalValue(query, 'source', params.source) + appendOptionalValue(query, 'start_time', params.start_time) + appendOptionalValue(query, 'end_time', params.end_time) + + if (params.folder_id === null) { + query.append('folder_id', '') + } else if (params.folder_id !== undefined) { + query.append('folder_id', params.folder_id) + } + return query +} +export function buildKnowledgeListScopeKey( + kbId: string, + params: KnowledgeListQueryParams, +): string { + const scopeParams = buildKnowledgeListSearchParams({ ...params, page: 1 }) + return `${kbId}?${scopeParams.toString()}` +} + +export function buildFolderListSearchParams(options: FolderListQueryOptions = {}): URLSearchParams { + const query = new URLSearchParams() + if (options.all) { + query.append('all', 'true') + } else if (typeof options.parentId === 'string') { + query.append('parent_id', options.parentId) + } + return query +} diff --git a/frontend/src/components/FolderScopeSelector.vue b/frontend/src/components/FolderScopeSelector.vue new file mode 100644 index 0000000000..4e3da54b32 --- /dev/null +++ b/frontend/src/components/FolderScopeSelector.vue @@ -0,0 +1,425 @@ + + + + + diff --git a/frontend/src/components/Input-field.vue b/frontend/src/components/Input-field.vue index ea4c92fa9f..5b1979a072 100644 --- a/frontend/src/components/Input-field.vue +++ b/frontend/src/components/Input-field.vue @@ -7,7 +7,7 @@ import { MessagePlugin } from "tdesign-vue-next"; import { useSettingsStore } from '@/stores/settings'; import { useUIStore } from '@/stores/ui'; import { useMenuStore } from '@/stores/menu'; -import { listKnowledgeBases, searchKnowledge, batchQueryKnowledge, listKnowledgeTags } from '@/api/knowledge-base'; +import { listKnowledgeBases, searchKnowledge, batchQueryKnowledge, listKnowledgeTags, listFolders } from '@/api/knowledge-base'; import { listMCPServices, type MCPService } from '@/api/mcp-service'; import { stopSession } from '@/api/chat'; import { useOrganizationStore } from '@/stores/organization'; @@ -22,12 +22,15 @@ import { useChatResourcesStore } from '@/stores/chatResources'; import { useEditorResourcesStore } from '@/stores/editorResources'; import { useI18n } from 'vue-i18n'; import AttachmentUpload, { type AttachmentFile } from './AttachmentUpload.vue'; +import type { Folder } from '@/types/folder'; +import { resolveFolderScopeState, type ResolvedFolderScopeState } from '@/utils/folderScope'; import { kbSatisfiesAgentRequirements, deriveKbFilterForAgent, toolsConsumeFiles, type ScopeCapabilities, } from '@/utils/tool-capabilities'; +import { collectFolderSubtreeIds } from '@/views/knowledge/utils/folderTree'; import { isAgentWebSearchEnabled, isAgentWebSearchReady, @@ -42,6 +45,10 @@ import { } from '@/utils/agent-readiness'; import { formatLocalizedList } from '@/utils/format-list'; import type { MentionItem, MentionItemType, MentionRequestItem } from '@/types/mention'; +import { + selectEntireKnowledgeBaseScope, + toggleSourceFolderScope, +} from '@/utils/sourceFolderSelection'; const route = useRoute(); const router = useRouter(); @@ -470,6 +477,16 @@ const mentionLoading = ref(false); const mentionOffset = ref(0); const MENTION_PAGE_SIZE = 20; +interface FolderScopeCacheEntry { + folders?: Folder[]; + loading: boolean; + error?: string | null; + requestId: number; +} + +const folderScopeCache = ref>({}); +let folderScopeRequestSeq = 0; + // 共享智能体时用于标识「共享空间」的展示名(组织名或共享者),供 @ 列表与已选标签显示角标 const sharedAgentOrgName = computed(() => { const sourceTenantId = settingsStore.selectedAgentSourceTenantId; @@ -503,6 +520,7 @@ const props = defineProps({ const isAgentEnabled = computed(() => settingsStore.isAgentEnabled); const isWebSearchEnabled = computed(() => settingsStore.isWebSearchEnabled); const selectedKbIds = computed(() => settingsStore.settings.selectedKnowledgeBases || []); +const selectedFolderScopes = computed(() => settingsStore.settings.selectedFolderScopes || {}); const selectedFileIds = computed(() => settingsStore.settings.selectedFiles || []); const selectedTags = computed(() => settingsStore.settings.selectedTags || []); const selectedMCPServiceIds = computed(() => settingsStore.settings.selectedMCPServices || []); @@ -542,6 +560,126 @@ const selectedKbs = computed(() => { return [...own, ...sharedOnly, ...sharedFromAgent]; }); +const extractFolderList = (response: any): Folder[] => { + const payload = response?.data ?? response; + const list = Array.isArray(payload?.data) ? payload.data : (Array.isArray(payload) ? payload : []); + return list.filter((folder: any): folder is Folder => folder && typeof folder.id === 'string'); +}; + +const ensureFolderScopeFolders = async (kbId: string, force = false) => { + if (!kbId) return; + const cached = folderScopeCache.value[kbId]; + if (!force && (cached?.loading || cached?.folders)) return; + + const requestId = ++folderScopeRequestSeq; + folderScopeCache.value = { + ...folderScopeCache.value, + [kbId]: { + folders: cached?.folders, + loading: true, + error: null, + requestId, + }, + }; + + try { + const response: any = await listFolders(kbId, { all: true }); + if (folderScopeCache.value[kbId]?.requestId !== requestId) return; + const folders = extractFolderList(response) + .filter(folder => !folder.knowledge_base_id || String(folder.knowledge_base_id) === kbId); + folderScopeCache.value = { + ...folderScopeCache.value, + [kbId]: { + folders, + loading: false, + error: null, + requestId, + }, + }; + } catch (error: any) { + if (folderScopeCache.value[kbId]?.requestId !== requestId) return; + folderScopeCache.value = { + ...folderScopeCache.value, + [kbId]: { + folders: cached?.folders, + loading: false, + error: error?.message || String(error), + requestId, + }, + }; + } +}; + +const getFolderScopeState = (kbId: string): ResolvedFolderScopeState => { + const folderIds = selectedFolderScopes.value[kbId]; + const cached = folderScopeCache.value[kbId]; + return resolveFolderScopeState(folderIds, cached?.folders, !!cached?.loading, cached?.error); +}; + +const getFolderScopeLabel = (kbId: string) => { + const state = getFolderScopeState(kbId); + if (state.status === 'valid') { + return state.folders.length === 1 + ? t('input.folderScope.selectedFolderSingle') + : t('input.folderScope.selectedFolderCount', { count: state.folders.length }); + } + if (state.status === 'invalid') return t('input.folderScope.partialInvalidShort'); + if (state.status === 'load-error') return t('input.folderScope.loadFailedShort'); + if (state.status === 'loading') return t('input.folderScope.loadingShort'); + return t('input.folderScope.entireKnowledgeBase'); +}; + +const getFolderScopeTitle = (kbId: string) => { + const state = getFolderScopeState(kbId); + if (state.status === 'valid') return state.paths.join(', '); + if (state.status === 'invalid') return t('input.folderScope.partialInvalid'); + if (state.status === 'load-error') return t('input.folderScope.loadFailed'); + if (state.status === 'loading') return t('input.folderScope.loading'); + return t('input.folderScope.entireKnowledgeBase'); +}; + +const getFolderScopeClass = (kbId: string) => { + const status = getFolderScopeState(kbId).status; + return `mention-chip__scope--${status}`; +}; + +const getScopedFolderIds = (kbId: string): Set | null => { + const folderIds = selectedFolderScopes.value[kbId]; + if (!folderIds?.length) return null; + const folders = folderScopeCache.value[kbId]?.folders; + if (!folders) return null; + const union = new Set(); + folderIds.forEach(folderId => { + collectFolderSubtreeIds(folders, folderId).forEach(id => union.add(id)); + }); + return union; +}; + +const isKnowledgeInCurrentFolderScope = (knowledge: any): boolean => { + const kbId = knowledge?.knowledge_base_id ?? knowledge?.kb_id; + if (!kbId) return true; + const knowledgeBaseId = String(kbId); + if (!selectedKbIds.value.includes(knowledgeBaseId)) return true; + const scopedFolderIds = getScopedFolderIds(knowledgeBaseId); + if (!scopedFolderIds) return true; + const folderId = knowledge?.folder_id; + return typeof folderId === 'string' && scopedFolderIds.has(folderId); +}; + +const invalidFolderScopeKbNames = computed(() => { + return selectedKbs.value + .filter(kb => getFolderScopeState(kb.id).status === 'invalid') + .map(kb => kb.name); +}); + +const toggleFolderScopeFromMention = (item: MentionItem, folderId: string) => { + toggleSourceFolderScope(settingsStore, item.id, folderId, isKnowledgeBaseDisabledByAgent.value); +}; + +const clearFolderScopeFromMention = (item: MentionItem) => { + selectEntireKnowledgeBaseScope(settingsStore, item.id, isKnowledgeBaseDisabledByAgent.value); +}; + const selectedFiles = computed(() => { // If we have file details in fileList, use them. // Otherwise we might show ID or Loading... @@ -799,6 +937,15 @@ watch(selectedFileIds, () => { loadFiles(); }, { immediate: true }); +watch([selectedKbIds, () => ({ ...selectedFolderScopes.value })], () => { + const selected = new Set(selectedKbIds.value); + Object.entries(selectedFolderScopes.value).forEach(([kbId, folderIds]) => { + if (selected.has(kbId) && folderIds?.length) { + void ensureFolderScopeFolders(kbId); + } + }); +}, { immediate: true, deep: true }); + const isWebSearchConfigured = computed(() => { if (hasAgentConfig.value) { return isAgentWebSearchReady(currentAgentConfig.value, webSearchProviders.value); @@ -1344,6 +1491,8 @@ const loadMentionItems = async (q: string, resetIndex = true, append = false) => const fileTypesParam = agentSupportedFileTypes.value.length > 0 ? agentSupportedFileTypes.value : undefined; const sourceTenantId = settingsStore.selectedAgentSourceTenantId; const agentId = selectedAgentId.value; + const hasFolderScopes = Object.values(selectedFolderScopes.value) + .some(folderIDs => Array.isArray(folderIDs) && folderIDs.length > 0); const searchOptions = { ...(sourceTenantId && agentId ? { agent_id: agentId } : {}), recent: !fileSearchKeyword, @@ -1356,10 +1505,12 @@ const loadMentionItems = async (q: string, resetIndex = true, append = false) => searchOptions ); console.log('[Mention] searchKnowledge response:', res); + let globalHasMore = false; if (res.data && Array.isArray(res.data)) { let files = res.data; const rawTotal = typeof res.total === 'number' ? res.total : undefined; const apiPageSize = res.data.length; + globalHasMore = !!res.has_more; // 按当前 @ 会话的兼容 KB 集合过滤: // - 非智能体场景:`mentionAllowedKbIds` 为 null,跳过; // - 智能体场景(含 shared agent):'selected' 会把 ID 收敛到用户勾的 KB, @@ -1372,6 +1523,11 @@ const loadMentionItems = async (q: string, resetIndex = true, append = false) => return kbId != null && allowed.has(String(kbId)); }); } + // The list API exposes only one folder_id. For multi-folder scopes we + // keep a single global request and apply the loaded folder-tree union + // locally. When the tree is not loaded, candidates remain visible; + // the backend folder_scopes boundary is authoritative at send time. + files = files.filter((f: any) => isKnowledgeInCurrentFolderScope(f)); const sharedKbOrgMap: Record = {}; (orgStore.sharedKnowledgeBases || []).forEach((s: any) => { if (s.knowledge_base?.id != null && s.org_name) { @@ -1379,7 +1535,13 @@ const loadMentionItems = async (q: string, resetIndex = true, append = false) => } }); const agentOrgLabel = sourceTenantId && agentId ? sharedAgentOrgName.value : ''; - fileItems = files.map((f: any) => { + const seenFileIds = new Set(); + fileItems = files.filter((f: any) => { + const id = f?.id != null ? String(f.id) : ''; + if (!id || seenFileIds.has(id)) return false; + seenFileIds.add(id); + return true; + }).map((f: any) => { const kbId = f.knowledge_base_id ?? f.kb_id; const kbIdStr = kbId != null ? String(kbId) : ''; const fileOrgName = agentOrgLabel || (kbIdStr ? sharedKbOrgMap[kbIdStr] : undefined); @@ -1393,7 +1555,7 @@ const loadMentionItems = async (q: string, resetIndex = true, append = false) => }; }); if (!append) { - const clientFiltered = !!mentionAllowedKbIds.value && fileItems.length < apiPageSize; + const clientFiltered = (!!mentionAllowedKbIds.value || hasFolderScopes) && fileItems.length < apiPageSize; if (!clientFiltered && rawTotal != null) { mentionGroupCounts.value.file = rawTotal; } else { @@ -1401,7 +1563,7 @@ const loadMentionItems = async (q: string, resetIndex = true, append = false) => } } } - mentionHasMore.value = res.has_more || false; + mentionHasMore.value = globalHasMore; mentionOffset.value += fileItems.length; } catch (e) { console.error('[Mention] searchKnowledge error:', e); @@ -1676,7 +1838,13 @@ const onMentionSelect = (item: any) => { } } - showMention.value = false; + if (isMentionTriggeredByButton.value) { + showMention.value = true; + mentionQuery.value = ''; + void loadMentionItems(''); + } else { + showMention.value = false; + } }; const removeFile = (id: string) => { @@ -1917,6 +2085,12 @@ const createSession = async (val: string) => { ); return; } + if (invalidFolderScopeKbNames.value.length > 0) { + MessagePlugin.warning(t('input.folderScope.sendBlockedInvalid', { + names: formatLocalizedList(invalidFolderScopeKbNames.value, locale.value), + })); + return; + } // 获取@提及的知识库和文件信息 const mentionedItems: MentionRequestItem[] = allSelectedItems.value.map(item => ({ id: item.id, @@ -2498,7 +2672,18 @@ defineExpose({ class="mention-chip__org-img" alt="" aria-hidden="true" /> - {{ item.name }} + + {{ item.name }} + + + {{ getFolderScopeLabel(item.id) }} + + × @@ -2699,14 +2884,20 @@ defineExpose({ + :loading="mentionLoading" :emptyHint="mentionEmptyHint" :query="mentionQuery" :group-counts="mentionGroupCounts" + :selected-knowledge-base-ids="selectedKbIds" :selected-folder-scopes="selectedFolderScopes" + :folder-scope-cache="folderScopeCache" :folder-scope-disabled="isKnowledgeBaseDisabledByAgent" + v-model:activeIndex="mentionActiveIndex" + @select="onMentionSelect" @loadMore="loadMoreMentionItems" + @load-folder-scopes="ensureFolderScopeFolders" @toggle-folder-scope="toggleFolderScopeFromMention" + @clear-folder-scope="clearFolderScopeFromMention" /> + {{ $t('knowledgeEditor.document.title') }} +
@@ -2054,6 +2741,24 @@ async function createNewSession(value: string): Promise { diff --git a/frontend/src/views/knowledge/components/KnowledgeFolderSidebar.vue b/frontend/src/views/knowledge/components/KnowledgeFolderSidebar.vue new file mode 100644 index 0000000000..74e1b1c6ce --- /dev/null +++ b/frontend/src/views/knowledge/components/KnowledgeFolderSidebar.vue @@ -0,0 +1,610 @@ + + + + + diff --git a/frontend/src/views/knowledge/components/UploadConfirmDialog.vue b/frontend/src/views/knowledge/components/UploadConfirmDialog.vue index 0b1a1be745..889575dd60 100644 --- a/frontend/src/views/knowledge/components/UploadConfirmDialog.vue +++ b/frontend/src/views/knowledge/components/UploadConfirmDialog.vue @@ -81,6 +81,10 @@