diff --git a/chat2db-community-client/package.json b/chat2db-community-client/package.json index 80b981cba3..0d159049e5 100644 --- a/chat2db-community-client/package.json +++ b/chat2db-community-client/package.json @@ -12,7 +12,7 @@ "build": "yarn run build:web:community", "build:prod": "yarn run build:web:community", "build:web": "umi build", - "prebuild:web:community": "yarn test:community-boundary && yarn test:retired-ai && yarn test:tree-title-highlight && yarn test:tree-search-lifecycle && yarn test:driver-upload && yarn test:tree-loading && yarn test:tree-node-lookup && yarn test:tree-node-path && yarn test:data-source-authorization && yarn test:data-source-mutation-refresh && yarn test:ai-model-config && yarn test:ai-model-select && yarn test:export-connections && yarn test:main-page-navigation && yarn test:console-tab-name && yarn test:workspace-tab-scroll && yarn test:workspace-split-lifecycle && yarn test:sql-completion-context && yarn test:file-manager-label && yarn test:local-file-encoding && yarn test:editor-close && yarn test:result-set-ui && yarn test:result-pagination && yarn test:result-table-layout && yarn test:result-status && yarn test:data-source-identity && yarn test:data-source-execution-snapshot && yarn test:data-source-watermark && yarn test:hot-update && yarn test:task-center && yarn test:application-exit && yarn test:monaco-lifecycle && yarn test:result-set-editor", + "prebuild:web:community": "yarn test:community-boundary && yarn test:database-capabilities && yarn test:table-editor && yarn test:retired-ai && yarn test:tree-title-highlight && yarn test:tree-search-lifecycle && yarn test:driver-upload && yarn test:tree-loading && yarn test:tree-node-lookup && yarn test:tree-node-path && yarn test:data-source-authorization && yarn test:active-transactions && yarn test:data-source-mutation-refresh && yarn test:ai-model-config && yarn test:ai-model-select && yarn test:export-connections && yarn test:main-page-navigation && yarn test:shortcut && yarn test:console-tab-name && yarn test:workspace-tab-scroll && yarn test:workspace-split-lifecycle && yarn test:sql-completion-context && yarn test:file-manager-label && yarn test:local-file-encoding && yarn test:local-file-save && yarn test:local-file-tree-path && yarn test:saved-console-dirty && yarn test:editor-close && yarn test:result-set-ui && yarn test:result-pagination && yarn test:sql-execution-stream && yarn test:result-table-layout && yarn test:result-status && yarn test:data-source-identity && yarn test:data-source-execution-snapshot && yarn test:data-source-watermark && yarn test:hot-update && yarn test:task-center && yarn test:import-preview && yarn test:application-exit && yarn test:monaco-lifecycle && yarn test:result-set-editor && yarn test:account-grants-request && yarn test:connection-close-request && yarn test:ssh-connection && yarn test:operation-log-filters && yarn test:dashboard-store", "postbuild:web:community": "node ./scripts/verify-production-bundles.cjs", "build:web:2java": "yarn run build:web:community && rm -rf ../chat2db-community-server/chat2db-community-start/src/main/resources/thymeleaf/* && cp -r dist/index.html ../chat2db-community-server/chat2db-community-start/src/main/resources/thymeleaf/", "build:web:community": "cross-env UMI_ENV=community cross-env APP_NAME=chat2db-community cross-env APP_VERSION=${npm_config_app_version} cross-env PRINT_LOGS=${npm_config_print_logs} cross-env APP_PORT=${npm_config_app_port} umi build", @@ -25,8 +25,11 @@ "lint:style": "stylelint \"src/**/*.{css,less}\" --max-warnings=0", "test:i18n": "node ./scripts/validate-i18n.cjs", "test:community-boundary": "node ./scripts/verify-community-boundary.cjs", - "test:retired-ai": "tsx src/blocks/AI/retiredAiSurface.test.ts && tsx src/blocks/AI/components/AIChatInput/mentionSelection.test.ts && tsx src/blocks/AI/knowledgeSelection.test.ts", + "test:database-capabilities": "tsx src/utils/databaseJudgments.test.ts", + "test:table-editor": "tsx src/blocks/DatabaseTableEditor/ColumnList/normalizeColumn.test.ts", + "test:retired-ai": "tsx src/blocks/AI/retiredAiSurface.test.ts && tsx src/blocks/AI/components/AICascaderSource/contextTree.test.ts && tsx src/blocks/AI/components/AIChatInput/mentionSelection.test.ts && tsx src/blocks/AI/messageNavigation.test.ts", "test:hot-update": "tsx src/store/global/slices/hotUpdate/action.test.ts", + "test:active-transactions": "tsx src/blocks/NewTree/components/ActiveTransactionsContent/activeTransactionUtils.test.ts && tsx src/blocks/NewTree/treeMenuGrouping.test.ts", "test:result-pagination": "tsx src/blocks/SearchResult/components/ResultSet/pagination.test.ts", "test:tree-node-path": "tsx src/blocks/NewTree/utils/treeNodePath.test.ts", "test:ai-model-config": "tsx src/blocks/AI/components/AIModelConfigModal/modelConfigDefaults.test.ts", @@ -40,19 +43,22 @@ "test:data-source-identity": "tsx src/utils/dataSourceIdentity.test.ts && tsx src/store/tree/dataSourceIdentity.test.ts && tsx src/store/tree/dataSourceList.test.ts && tsx src/store/tree/treeNodeLoadState.test.ts && tsx src/blocks/NewTree/dataSourceIdentity.test.ts && tsx src/blocks/NewTree/dataSourceColorMenu.test.ts && tsx src/blocks/NewTree/treeMenuDanger.test.ts && tsx src/blocks/NewTree/treeMenuGrouping.test.ts && tsx src/blocks/NewTree/dataSourceIdentityColorRequest.test.ts && tsx src/components/ConnectionEdit/identityColorUpdate.test.ts && tsx src/utils/editorDataSourceLifecycle.test.ts && tsx src/components/SelectBoundInfo/cascadeRequestGuard.test.ts && tsx src/components/SelectBoundInfo/dataSourceSelection.test.ts && tsx src/blocks/AI/dataSourceContext.test.ts", "test:database-object-sorting": "tsx src/blocks/NewTree/utils/sortTreeNodes.test.ts", "test:data-source-authorization": "tsx src/utils/dataSourceAuthorization.test.ts", - "test:data-source-mutation-refresh": "tsx src/store/tree/dataSourceMutationRefresh.test.ts", + "test:data-source-mutation-refresh": "tsx src/store/tree/dataSourceMutationRefresh.test.ts && tsx src/components/ConnectionEdit/submissionGuard.test.ts && tsx src/blocks/NewTree/treePositionMutation.test.ts", + "test:dashboard-store": "tsx src/store/dashboard/slices/common/refreshCurrentDashboard.test.ts", "test:dynamic-database-registry": "tsx src/utils/dynamicDatabaseRegistry.test.ts", "test:execution-console": "tsx src/blocks/SearchResult/components/ExecutionConsole/executionConsolePreferences.test.ts", - "test:editor-close": "tsx src/utils/editorCloseGuard.test.ts", + "test:editor-close": "tsx src/utils/editorCloseGuard.test.ts && tsx src/pages/main/workspace/components/SQLExecute/liveEditorHandle.test.ts && tsx src/pages/main/workspace/components/SQLExecute/liveEditorBoundInfo.test.ts", "test:export-connections": "tsx src/pages/main/workspace/components/WorkspaceLeftActionBar/components/TreeSetting/exportConnectionsFlow.test.ts", "test:file-manager-label": "tsx src/utils/fileManagerLabel.test.ts", "test:label-rotation": "tsx src/blocks/BI/Chart/components/EChartsContainer/hooks/useLabelRotate.test.ts", "test:redis-explorer": "tsx src/blocks/RedisAllData/redisExplorer.test.ts", "test:result-markdown": "tsx src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/handleCopyAsMarkdown.test.ts", - "test:result-set-ui": "tsx src/blocks/SearchResult/components/ResultSet/resultSetUi.test.ts", + "test:result-set-ui": "tsx src/blocks/SearchResult/components/ResultSet/resultSetUi.test.ts && tsx src/blocks/SearchResult/components/SQLPreviewExecute/updateSql.test.ts", "test:result-table-layout": "tsx src/blocks/SearchResult/components/ResultSetTable/layoutOptions.test.ts && tsx src/blocks/SearchResult/components/ResultSetTable/rowHeight.test.ts", "test:result-status": "tsx src/blocks/SearchResult/components/StatusBar/resultSummary.test.ts", "test:runtime-storage": "tsx src/store/indexDB/storageKey.test.ts && tsx src/pages/main/workspace/components/WorkspaceLeft/desktopBridge.test.ts", + "test:connection-close-request": "tsx src/service/connectionCloseRequest.test.ts", + "test:ssh-connection": "tsx src/components/ConnectionEdit/testSsh.test.ts", "test:result-tab-preferences": "tsx src/blocks/SearchResult/resultTabPreferences.test.ts && tsx src/service/resultTabShortcut.test.ts", "test:result-inspector": "tsx src/store/workspace/utils/resultInspector.test.ts && yarn test:search-result-tab-selection", "test:result-operation-record": "tsx src/blocks/SearchResult/components/ResultSetTable/hooks/useOperationRecord.test.tsx && tsx src/blocks/SearchResult/components/ResultSetTable/event/onChangeCellValue/index.test.ts", @@ -62,8 +68,9 @@ "test:saved-console-lifecycle": "tsx src/store/workspace/utils/savedConsoleLifecycle.test.ts && tsx src/store/workspace/utils/workspaceTabPersistence.test.ts", "test:sse-request": "tsx src/components/SSERequest/requestOwnership.test.ts && tsx src/components/SSERequest/requestTransport.test.ts", "test:settings-layout": "tsx src/blocks/Setting/navigation.test.ts && tsx src/blocks/Setting/search.test.ts && tsx src/blocks/Setting/BaseSetting/model.test.ts && tsx src/client-extension/settingMenus.test.ts && tsx src/blocks/Setting/settingsLayout.test.ts", - "test:shortcut": "tsx src/constants/shortcut.test.ts && tsx src/utils/appTitleBarAction.test.ts && tsx src/layouts/GlobalLayout/AppTitleBar/platform.test.ts && tsx src/utils/jcefZoom.test.ts", + "test:shortcut": "tsx src/constants/shortcut.test.ts && tsx src/utils/appTitleBarAction.test.ts && tsx src/layouts/GlobalLayout/AppTitleBar/platform.test.ts && tsx src/utils/jcefZoom.test.ts && tsx src/utils/shortcutDispatch.test.ts", "test:sql-execution-log": "tsx src/service/sqlExecutionLog.test.ts", + "test:operation-log-filters": "tsx src/components/OperationLogFilters/model.test.ts && tsx src/hooks/selectDatabaseRequestLifecycle.test.ts", "test:sql-execution-batch": "tsx src/service/sqlExecutionBatch.test.ts", "test:data-source-execution-snapshot": "tsx src/service/dataSourceExecutionSnapshot.test.ts", "test:sql-execution-request-tracker": "tsx src/service/sqlExecutionRequestTracker.test.ts", @@ -71,26 +78,31 @@ "test:result-set-editor": "tsx src/blocks/SearchResult/components/ResultSetTable/utils/editorType.test.ts && tsx src/blocks/CanvasTable/editor/SelectIEditor/index.test.ts && tsx src/blocks/CanvasTable/editor/MultiSelectIEditor/index.test.ts && tsx src/blocks/CanvasTable/editor/SelectIEditor/index.dom.test.ts", "test:local-file-tab-title": "tsx src/pages/main/workspace/utils/localTextFile.test.ts", "test:local-file-tab-refresh": "tsx src/store/workspace/utils/localFileWorkspaceTab.test.ts", + "test:local-file-save": "tsx src/utils/localFileSaveCoordinator.test.ts", + "test:local-file-tree-path": "tsx src/pages/main/workspace/components/LocalSQLFileTree/path.test.ts", + "test:saved-console-dirty": "tsx src/utils/savedConsoleDirty.test.ts", "test:local-file-encoding": "tsx src/utils/localFileEncoding.test.ts && yarn test:local-file-tab-refresh && tsx src/store/workspace/utils/workspaceTabPersistence.test.ts", "test:main-page-navigation": "tsx src/utils/mainPageNavigation.test.ts && tsx src/pages/main/navigationItems.test.ts && tsx src/client-extension/extension.test.ts", - "test:monaco-lifecycle": "tsx src/utils/monaco.test.ts", + "test:monaco-lifecycle": "tsx src/utils/monaco.test.ts && tsx src/components/SingleFileMonacoEditor/refAdapter.test.ts", "test:mcp-lifecycle": "tsx src/blocks/Setting/McpSetting/mcpLifecycle.test.ts", "test:sql-completion-context": "tsx src/components/SQLEditor/core/sqlCompletionContext.test.ts && tsx src/components/SQLEditor/core/sqlCompletionRequestError.test.ts", "test:sql-in-clipboard": "tsx src/utils/sqlInClipboard.test.ts && tsx src/components/SQLEditor/helper/sqlInsertValueDefaults.test.ts", "test:console-tab-name": "tsx src/store/workspace/utils/consoleTabName.test.ts && tsx src/utils/workspaceObjectTabTitle.test.ts", - "test:workspace-tab-scroll": "tsx src/store/workspace/utils/workspaceTabScrollRequest.test.ts && tsx src/components/Tabs/activeTabScroll.test.ts && tsx src/components/Tabs/wheelScroll.test.ts", - "test:workspace-split-lifecycle": "tsx src/pages/main/workspace/components/WorkspaceTabs/workspaceTabContentLayout.test.ts", + "test:workspace-tab-scroll": "tsx src/store/workspace/utils/workspaceTabScrollRequest.test.ts && tsx src/components/Tabs/activeTabScroll.test.ts && tsx src/components/Tabs/wheelScroll.test.ts && tsx src/components/Tabs/tabAccentColor.test.ts", + "test:workspace-split-lifecycle": "tsx src/pages/main/workspace/components/WorkspaceTabs/workspaceTabContentLayout.test.ts && tsx src/pages/main/workspace/components/WorkspaceTabs/workspaceTabLayout.test.ts", "test:tree-title-highlight": "tsx src/blocks/NewTree/components/TitleRender/highlightSearchText.test.ts", "test:tree-search-lifecycle": "tsx src/pages/main/workspace/components/WorkspaceTreeSearch/lifecycle.test.ts && tsx src/pages/main/workspace/components/WorkspaceTreeSearch/refresh.test.ts", "test:tree-data-update": "tsx src/store/tree/treeDataUpdate.test.ts", - "test:tree-loading": "tsx src/store/tree/loadNamespaceTree.test.ts && tsx src/store/tree/treeDataUpdate.test.ts && tsx src/store/tree/hiddenTreeNodeState.test.ts && tsx src/blocks/NewTree/components/TitleRender/switcherAction.test.ts && tsx src/blocks/NewTree/treeScrollWidth.test.ts && tsx src/pages/main/workspace/components/WorkspaceLeft/loadDatabaseTreePath.test.ts && tsx src/store/tree/savedConsoleTreeRefresh.test.ts", + "test:tree-loading": "tsx src/store/tree/loadNamespaceTree.test.ts && tsx src/store/tree/treeDataUpdate.test.ts && tsx src/store/tree/hiddenTreeNodeState.test.ts && tsx src/blocks/NewTree/components/TitleRender/switcherAction.test.ts && tsx src/blocks/NewTree/treeScrollWidth.test.ts && tsx src/blocks/NewTree/monitorTree.test.ts && tsx src/pages/main/workspace/components/WorkspaceLeft/loadDatabaseTreePath.test.ts && tsx src/store/tree/savedConsoleTreeRefresh.test.ts", "test:tree-node-lookup": "tsx src/utils/treeNodeLookup.test.ts", "test:data-source-watermark": "tsx src/utils/dataSourceWatermark.test.ts", "test:driver-upload": "tsx src/components/ConnectionEdit/components/Driver/driverUpload.test.ts", "test:terminal": "tsx src/constants/terminal.test.ts && tsx src/pages/main/workspace/components/WorkspaceExtend/WorkspaceExtendNav/quickTerminal.test.ts && tsx src/pages/main/workspace/components/WorkspaceTabs/terminalTabPlacement.test.ts && tsx src/pages/main/workspace/components/WorkspaceTabs/terminalAttachmentLifecycle.test.ts && tsx src/pages/main/workspace/components/WorkspaceTabs/workspaceTabSelection.test.ts", - "test:task-center": "tsx src/store/importExport/taskCenterUtils.test.ts", + "test:task-center": "tsx src/store/importExport/taskCenterUtils.test.ts && tsx src/store/importExport/taskCenterStore.test.ts && tsx src/blocks/ImportAndExport/components/Log/eventMessage.test.ts", + "test:import-preview": "tsx src/components/SingleCharacterSelect/index.test.ts && tsx src/blocks/ImportAndExport/components/ImportMappingContent/mapping.test.ts && tsx src/blocks/ImportAndExport/components/ImportMappingContent/fileStaging.test.ts && tsx src/blocks/ImportAndExport/components/ImportExportFile/selection.test.ts && tsx src/blocks/ImportAndExport/components/ImportFileModal/submission.test.ts && tsx src/blocks/ImportAndExport/utils/csvOptions.test.ts", "test:workspace-tab-drag": "tsx src/pages/main/workspace/components/WorkspaceTabs/workspaceTabDrop.test.ts", "test:verification-code-countdown": "tsx src/utils/verificationCodeCountdown.test.ts && tsx src/utils/latestRequest.test.ts", + "test:account-grants-request": "tsx src/pages/main/workspace/components/WorkspaceExtend/GlobalExtendComponents/accountGrantsRequest.test.ts", "modify:package:name": "node ./scripts/modify-package-name.js", "start": "yarn run start:community:hot", "start:community:hot": "cross-env UMI_ENV=community cross-env APP_NAME=chat2db-community cross-env DISABLE_MFSU=true cross-env UMI_DEV_SERVER_COMPRESS=none cross-env HOST=127.0.0.1 cross-env PORT=8889 node --require ./scripts/bind-dev-server-loopback.cjs ./node_modules/umi/bin/umi.js dev --public_path=/", diff --git a/chat2db-community-client/scripts/i18n-source-hashes.json b/chat2db-community-client/scripts/i18n-source-hashes.json index 648acb4cf0..f52283d60c 100644 --- a/chat2db-community-client/scripts/i18n-source-hashes.json +++ b/chat2db-community-client/scripts/i18n-source-hashes.json @@ -6,9 +6,9 @@ "ai.ts": "e7a2fcbfb3ed647bd14f63aca4b6c35c8fb402c49a6a1f36bb04f76f4e8415af", "chat.ts": "f6c6ab9cbc5c1c828c35c75d83c4adca347ade590b5177e10d64fdd18cc8aef5", "common.ts": "24aa2228702754bc019f1e21dd7be9f6d1c6ff02ed9bd9227ab319f331248b11", - "connection.ts": "d70578a3444978b60ea41c670e8b180849eaab297e33325db56c891e1058e899", + "connection.ts": "24f987f3e4188c0076c1c17591b9fd5792b29c2c176b2069f50a3bdf5d661af9", "dashboard.ts": "b99f5e6b81271573a8db1a585ac879724441b7163050899c3abaf9498217bd21", - "editTable.ts": "0f7a808f50053cd52b183d156bb4fd46bb125b8058f8774deb61c7f02894a350", + "editTable.ts": "6d1233d574dbd2e038ed112bc8677b12f61a0fc831f56f77d5ce024a96a319f6", "editTableData.ts": "b6f40d2e1f612b716c0dbc2ade925069a039585a28ada997e004c33ce2fd0901", "feedback.ts": "080fbb0b2e6578d10499d47e0f23b7161feffb769c8a574048c5a737c9624eea", "login.ts": "df4c7c4b2020ef3e9a70277f364d078fc6a10ad81be31d3b648933f14c423f43", @@ -17,19 +17,19 @@ "notification.ts": "500751da2f02391622f074448eb46611fd41da2e5530d6c7d65f17a1382bd236", "plugin.ts": "01f15b6342cc5b29899ee4144d67b04bb4bc02c6bb10779806be322dc5e9a2bc", "redis.ts": "cacf9cf685bf3bf6d019f18eead0f63713d4727be741a87d8dc1d22807ebd9ad", - "setting.ts": "14aea4aebdbe5cff2ae9ba4bf955aa42a95ac23d1950ea69291b22c29330d7cf", + "setting.ts": "cdc0698420cb89407b2e91ad31e45ddd7f8eb63ca3dd58196da5d6f8739a65b8", "spaceSetting.ts": "defdf3138e5e1782f0282760bcef4ce1b8550cc80b03052fea468d97165cb980", "sqlEditor.ts": "5c41e5f24d5ba78909795bcc681cabb8460cca075233b665dc4940afe82ce0c9", - "stream.ts": "8418dd25b0fd54b2ef2b15a2573edf0a4876efb4e69936ff82a157ea5cf42be4", - "workspace.ts": "ff3ba00ee05c5e04c6ccaef4083a83e4f01b5307eff17b9afc6480525a9ef141" + "stream.ts": "2e2fca0b1ba1ecd5a1501b767be8857cf0b72d800d69a4acf9cd62a61d5ceaf5", + "workspace.ts": "e23d0f03ed0c87023f92de7a3e01a40815c5135924902304a8b8e5c809afc609" }, "ko-KR": { "ai.ts": "e7a2fcbfb3ed647bd14f63aca4b6c35c8fb402c49a6a1f36bb04f76f4e8415af", "chat.ts": "f6c6ab9cbc5c1c828c35c75d83c4adca347ade590b5177e10d64fdd18cc8aef5", "common.ts": "24aa2228702754bc019f1e21dd7be9f6d1c6ff02ed9bd9227ab319f331248b11", - "connection.ts": "d70578a3444978b60ea41c670e8b180849eaab297e33325db56c891e1058e899", + "connection.ts": "24f987f3e4188c0076c1c17591b9fd5792b29c2c176b2069f50a3bdf5d661af9", "dashboard.ts": "b99f5e6b81271573a8db1a585ac879724441b7163050899c3abaf9498217bd21", - "editTable.ts": "0f7a808f50053cd52b183d156bb4fd46bb125b8058f8774deb61c7f02894a350", + "editTable.ts": "6d1233d574dbd2e038ed112bc8677b12f61a0fc831f56f77d5ce024a96a319f6", "editTableData.ts": "b6f40d2e1f612b716c0dbc2ade925069a039585a28ada997e004c33ce2fd0901", "feedback.ts": "080fbb0b2e6578d10499d47e0f23b7161feffb769c8a574048c5a737c9624eea", "login.ts": "df4c7c4b2020ef3e9a70277f364d078fc6a10ad81be31d3b648933f14c423f43", @@ -38,11 +38,11 @@ "notification.ts": "500751da2f02391622f074448eb46611fd41da2e5530d6c7d65f17a1382bd236", "plugin.ts": "01f15b6342cc5b29899ee4144d67b04bb4bc02c6bb10779806be322dc5e9a2bc", "redis.ts": "cacf9cf685bf3bf6d019f18eead0f63713d4727be741a87d8dc1d22807ebd9ad", - "setting.ts": "14aea4aebdbe5cff2ae9ba4bf955aa42a95ac23d1950ea69291b22c29330d7cf", + "setting.ts": "cdc0698420cb89407b2e91ad31e45ddd7f8eb63ca3dd58196da5d6f8739a65b8", "spaceSetting.ts": "defdf3138e5e1782f0282760bcef4ce1b8550cc80b03052fea468d97165cb980", "sqlEditor.ts": "5c41e5f24d5ba78909795bcc681cabb8460cca075233b665dc4940afe82ce0c9", - "stream.ts": "8418dd25b0fd54b2ef2b15a2573edf0a4876efb4e69936ff82a157ea5cf42be4", - "workspace.ts": "ff3ba00ee05c5e04c6ccaef4083a83e4f01b5307eff17b9afc6480525a9ef141" + "stream.ts": "2e2fca0b1ba1ecd5a1501b767be8857cf0b72d800d69a4acf9cd62a61d5ceaf5", + "workspace.ts": "e23d0f03ed0c87023f92de7a3e01a40815c5135924902304a8b8e5c809afc609" } } } diff --git a/chat2db-community-client/src/blocks/AI/components/AIAtMetion/index.tsx b/chat2db-community-client/src/blocks/AI/components/AIAtMetion/index.tsx index 8e9c61ecbd..f6dfbbba12 100644 --- a/chat2db-community-client/src/blocks/AI/components/AIAtMetion/index.tsx +++ b/chat2db-community-client/src/blocks/AI/components/AIAtMetion/index.tsx @@ -1,11 +1,10 @@ import React, { useMemo, useState } from 'react'; import { SuggestionItem } from './interface'; import { useEvent, useMergedState } from 'rc-util'; -import { Cascader, CascaderProps, Spin } from 'antd'; +import { Cascader, CascaderProps } from 'antd'; import useActive from './useActive'; import { useStyles } from './style'; import { IconfontSvg } from '@chat2db/ui'; -import { BookOpenText } from 'lucide-react'; export interface RenderChildrenProps { /** @@ -26,9 +25,6 @@ export interface AIAtMetionProps { open?: boolean; onOpenChange?: (open: boolean) => void; onSelect?: (item: SuggestionItem) => void; - hasMore?: boolean; - loadingMore?: boolean; - onLoadMore?: () => void; children?: (props: RenderChildrenProps) => React.ReactElement; /** * list of suggestions @@ -45,9 +41,6 @@ function AIAtMetion(props: AIAtMetionProps) { open, onOpenChange, onSelect, - hasMore, - loadingMore, - onLoadMore, items, children, } = props; @@ -95,42 +88,21 @@ function AIAtMetion(props: AIAtMetionProps) { }; // =========================== Accessibility =========================== - const [activePath, onKeyDown, previewItem, setPreviewValue] = useActive( - itemList, - mergedOpen, - onInternalChange, - onClose, - ); - - const knowledgeTypeClassName = (item: SuggestionItem) => { - switch (item.knowledge?.type) { - case 'BUSINESS_LOGIC': - return styles.businessLogic; - case 'SQL_TEMPLATE': - return styles.sqlTemplate; - default: - return styles.knowledgeTerm; - } - }; + const [activePath, onKeyDown] = useActive(itemList, mergedOpen, onInternalChange, onClose); const optionRender: CascaderProps['optionRender'] = (node) => { return ( -
setPreviewValue(node.kind === 'knowledge' ? node.value : undefined)} - > +
- {node.kind === 'knowledge' ? ( - - ) : ( - - )} - {node.label} + + + {node.label} +
{node.extra}
@@ -154,50 +126,6 @@ function AIAtMetion(props: AIAtMetionProps) { value={activePath} optionRender={optionRender} onChange={onInternalChange} - dropdownRender={(menus) => - mergedOpen ? ( -
-
{ - const target = event.target as HTMLElement; - if (!target.classList.contains('ant-cascader-menu')) return; - if ( - hasMore && - !loadingMore && - target.scrollHeight - target.scrollTop - target.clientHeight <= 24 - ) { - onLoadMore?.(); - } - }} - > - {menus} - {loadingMore ? ( -
- -
- ) : null} -
- {previewItem?.knowledge && ( -
-
- - {previewItem.extra} - - {previewItem.knowledge.key} -
-
- {previewItem.knowledge.value || '暂无说明'} -
-
- )} -
- ) : null - } onDropdownVisibleChange={(nextOpen) => { if (!nextOpen) { onClose(); diff --git a/chat2db-community-client/src/blocks/AI/components/AIAtMetion/interface.ts b/chat2db-community-client/src/blocks/AI/components/AIAtMetion/interface.ts index 8fc9c6dcae..5fff0664f7 100644 --- a/chat2db-community-client/src/blocks/AI/components/AIAtMetion/interface.ts +++ b/chat2db-community-client/src/blocks/AI/components/AIAtMetion/interface.ts @@ -3,17 +3,9 @@ import React from 'react'; export interface SuggestionItem { label: string; value: string; - kind: 'table' | 'knowledge'; + kind: 'table'; tableType?: string; tableName?: string; - knowledge?: { - id: number; - type: 'KNOWLEDGE_TERM' | 'BUSINESS_LOGIC' | 'SQL_TEMPLATE'; - key: string; - value: string; - }; - - icon?: React.ReactNode; children?: SuggestionItem[]; extra?: React.ReactNode; } diff --git a/chat2db-community-client/src/blocks/AI/components/AIAtMetion/style.ts b/chat2db-community-client/src/blocks/AI/components/AIAtMetion/style.ts index 951e24daa9..13720b59c9 100644 --- a/chat2db-community-client/src/blocks/AI/components/AIAtMetion/style.ts +++ b/chat2db-community-client/src/blocks/AI/components/AIAtMetion/style.ts @@ -14,6 +14,8 @@ export const useStyles = createStyles(({ css, token }) => { .ant-cascader-menu { min-width: 280px !important; + width: min(340px, calc(100vw - 32px)) !important; + max-width: min(340px, calc(100vw - 32px)) !important; height: auto !important; max-height: 280px !important; overflow-y: auto; @@ -25,35 +27,13 @@ export const useStyles = createStyles(({ css, token }) => { padding: 6px 10px !important; font-size: 13px; } + + .ant-cascader-menu-item-content { + width: 100%; + min-width: 0; + } `, content: css``, - dropdownLayout: css` - display: flex; - align-items: stretch; - max-width: min(680px, calc(100vw - 32px)); - overflow: hidden; - `, - menuPane: css` - position: relative; - min-width: 280px; - max-width: 340px; - overflow: hidden; - `, - loadingMore: css` - position: absolute; - bottom: 8px; - left: 50%; - z-index: 1; - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border: 1px solid ${token.colorBorderSecondary}; - border-radius: 4px; - background: ${token.colorBgElevated}; - transform: translateX(-50%); - `, optionRow: css` display: flex; align-items: center; @@ -65,8 +45,10 @@ export const useStyles = createStyles(({ css, token }) => { optionTitle: css` display: flex; align-items: center; + flex: 1; min-width: 0; gap: 6px; + overflow: hidden; color: ${token.colorText}; font-weight: 500; `, @@ -77,64 +59,9 @@ export const useStyles = createStyles(({ css, token }) => { `, optionExtra: css` flex-shrink: 0; + width: 36px; color: ${token.colorTextDescription}; font-size: 11px; `, - previewPane: css` - width: 320px; - min-width: 260px; - max-height: 280px; - padding: 14px 16px; - overflow: auto; - border-inline-start: 1px solid ${token.colorBorderSecondary}; - background: ${token.colorBgElevated}; - `, - previewHeader: css` - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 8px; - margin-bottom: 10px; - `, - previewType: css` - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 2px 7px; - border-radius: 4px; - font-size: 11px; - line-height: 18px; - background: ${token.colorFillTertiary}; - `, - previewTitle: css` - color: ${token.colorText}; - font-size: 14px; - line-height: 20px; - word-break: break-word; - `, - previewContent: css` - color: ${token.colorTextSecondary}; - font-size: 13px; - line-height: 22px; - white-space: pre-wrap; - word-break: break-word; - `, - previewSql: css` - padding: 10px; - border-radius: 4px; - background: ${token.colorFillQuaternary}; - color: ${token.colorText}; - font-family: ${token.fontFamilyCode}; - line-height: 20px; - `, - knowledgeTerm: css` - color: ${token.colorInfo}; - `, - businessLogic: css` - color: ${token.colorSuccess}; - `, - sqlTemplate: css` - color: ${token.colorWarning}; - `, }; }); diff --git a/chat2db-community-client/src/blocks/AI/components/AIAtMetion/useActive.ts b/chat2db-community-client/src/blocks/AI/components/AIAtMetion/useActive.ts index 566bd5e893..9e99475b61 100644 --- a/chat2db-community-client/src/blocks/AI/components/AIAtMetion/useActive.ts +++ b/chat2db-community-client/src/blocks/AI/components/AIAtMetion/useActive.ts @@ -10,20 +10,14 @@ export default function useActive( onCancel: () => void, ) { const [activePaths, setActivePaths] = useState([]); - const [previewValue, setPreviewValue] = useState(); const activeValue = activePaths[0]; - const activeItem = items.find((item) => item.value === activeValue); - const previewItem = items.find((item) => item.value === previewValue); const offsetRow = (offset: number) => { if (!items.length) return; const currentRowIndex = items.findIndex((item) => item.value === activeValue); const nextItem = items[(currentRowIndex + offset + items.length) % items.length]; setActivePaths([nextItem.value]); - if (previewValue) { - setPreviewValue(nextItem.kind === 'knowledge' ? nextItem.value : undefined); - } // Add a delay to wait for the DOM to update before scrolling setTimeout(() => { @@ -55,23 +49,6 @@ export default function useActive( e.preventDefault(); break; } - - case 'ArrowRight': { - if (activeItem?.kind === 'knowledge') { - setPreviewValue(activeItem.value); - e.preventDefault(); - } - break; - } - - case 'ArrowLeft': { - if (previewValue) { - setPreviewValue(undefined); - e.preventDefault(); - } - break; - } - case 'Enter': { if (activeValue) { onSelect([activeValue]); @@ -96,9 +73,8 @@ export default function useActive( setActivePaths((previous) => (items.some((item) => item.value === previous[0]) ? previous : [items[0].value])); } else if (!open) { setActivePaths([]); - setPreviewValue(undefined); } }, [open, items]); - return [activePaths, onKeyDown, previewItem, setPreviewValue] as const; + return [activePaths, onKeyDown] as const; } diff --git a/chat2db-community-client/src/blocks/AI/components/AICascaderSource/contextTree.test.ts b/chat2db-community-client/src/blocks/AI/components/AICascaderSource/contextTree.test.ts new file mode 100644 index 0000000000..54dffc3270 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/components/AICascaderSource/contextTree.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { TreeNodeType } from '@/constants/tree'; +import type { TreeNodeData } from '@/typings'; +import { buildAIContextTree } from './contextTree'; + +const node = (treeNodeType: TreeNodeType, key: string, children?: TreeNodeData[]): TreeNodeData => ({ + treeNodeType, + key, + originalTitle: key, + title: key, + isLeaf: false, + children, + extraParams: {}, +}); + +const source = node(TreeNodeType.DATA_SOURCE, 'source', [ + node(TreeNodeType.DATABASE, 'app'), + node(TreeNodeType.MONITOR, 'monitor'), + node(TreeNodeType.DATABASE_ACCOUNTS, 'accounts'), +]); +const result = buildAIContextTree([source]); + +assert.deepEqual(result[0].children?.map(({ treeNodeType }) => treeNodeType), [TreeNodeType.DATABASE]); +assert.equal(result[0].isLeaf, false); +const flatSource = node(TreeNodeType.DATA_SOURCE, 'flat-source'); +flatSource.extraParams = { supportDatabase: false, supportSchema: false }; +assert.equal(buildAIContextTree([flatSource])[0].isLeaf, true); +assert.equal(buildAIContextTree([node(TreeNodeType.DATA_SOURCE, 'unloaded-source')])[0].isLeaf, false); +assert.deepEqual(buildAIContextTree([node(TreeNodeType.GROUP, 'empty-group', [node(TreeNodeType.MONITOR, 'monitor')])]), []); +assert.equal(source.children?.length, 3, 'the shared workspace tree must not be mutated'); diff --git a/chat2db-community-client/src/blocks/AI/components/AICascaderSource/contextTree.ts b/chat2db-community-client/src/blocks/AI/components/AICascaderSource/contextTree.ts new file mode 100644 index 0000000000..f84c7907d3 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/components/AICascaderSource/contextTree.ts @@ -0,0 +1,36 @@ +import { TreeNodeType } from '@/constants/tree'; +import type { TreeNodeData } from '@/typings'; + +const AI_CONTEXT_NODE_TYPES = new Set([ + TreeNodeType.GROUP, + TreeNodeType.DATA_SOURCE, + TreeNodeType.DATABASE, + TreeNodeType.SCHEMA, +]); + +export const buildAIContextTree = (data: readonly TreeNodeData[]): TreeNodeData[] => + data.flatMap((item) => { + if (!AI_CONTEXT_NODE_TYPES.has(item.treeNodeType)) { + return []; + } + + const children = buildAIContextTree(item.children || []); + if (item.treeNodeType === TreeNodeType.GROUP && !children.length) { + return []; + } + + const isLeaf = + item.treeNodeType === TreeNodeType.SCHEMA || + (item.treeNodeType === TreeNodeType.DATABASE && !item.extraParams?.supportSchema) || + (item.treeNodeType === TreeNodeType.DATA_SOURCE && + item.extraParams?.supportDatabase === false && + item.extraParams?.supportSchema === false); + + return [ + { + ...item, + isLeaf, + children: isLeaf || !children.length ? undefined : children, + }, + ]; + }); diff --git a/chat2db-community-client/src/blocks/AI/components/AICascaderSource/index.tsx b/chat2db-community-client/src/blocks/AI/components/AICascaderSource/index.tsx index f30c6293c4..02a78a2ef5 100644 --- a/chat2db-community-client/src/blocks/AI/components/AICascaderSource/index.tsx +++ b/chat2db-community-client/src/blocks/AI/components/AICascaderSource/index.tsx @@ -3,7 +3,6 @@ import { ILoadDataOptions, switchIcon, treeConfig } from '@/blocks/NewTree/treeC import { TreeNodeType, databaseMap } from '@/constants'; import i18n from '@/i18n'; import { useTreeStore } from '@/store/tree'; -import { TreeNodeData } from '@/typings'; import { IDBContextInfo } from '@/typings/database'; import { findNode } from '@/utils'; import { IconfontSvg } from '@chat2db/ui'; @@ -11,6 +10,7 @@ import { Cascader, Tooltip } from 'antd'; import { useMemo } from 'react'; import { ChevronDown } from 'lucide-react'; import { useStyles } from './style'; +import { buildAIContextTree } from './contextTree'; export type IAICascaderData = IDBContextInfo | null; @@ -43,29 +43,8 @@ const AICascaderSource = (props: IProps) => { handleLoadData: state.handleLoadData, })); - // recursively processes treeData - const handleTreeData = (data: TreeNodeData[]) => { - const list = data.map((item) => { - let isLeaf = false; - if ( - (item?.treeNodeType === TreeNodeType.DATABASE && !item?.extraParams?.supportSchema) || - item?.treeNodeType === TreeNodeType.SCHEMA - ) { - isLeaf = true; - item.children = undefined; - } - - return { - ...item, - isLeaf, - children: item.children?.length ? handleTreeData(item.children) : undefined, - }; - }); - return list.filter((item) => !(item?.treeNodeType === TreeNodeType.GROUP && !item.children?.length)); - }; - const treeDataOptions = useMemo(() => { - return handleTreeData(treeData || []); + return buildAIContextTree(treeData || []); }, [treeData]); const options = useMemo(() => { diff --git a/chat2db-community-client/src/blocks/AI/components/AIChatInput/index.tsx b/chat2db-community-client/src/blocks/AI/components/AIChatInput/index.tsx index f54659d0c2..345478d882 100644 --- a/chat2db-community-client/src/blocks/AI/components/AIChatInput/index.tsx +++ b/chat2db-community-client/src/blocks/AI/components/AIChatInput/index.tsx @@ -35,16 +35,8 @@ import aiAttachmentService, { IChatAttachment } from '@/service/aiAttachment'; import { isDesktop } from '@/utils/env'; import jcefApi from '@/jcef'; import feedback from '@/utils/feedback'; -import type { ISelectedKnowledge } from '@/service/aiStream'; -import clientExtension from '@client-extension'; -import type { - KnowledgeMentionCandidate, - KnowledgeMentionRequest, -} from '@/client-extension/types'; import { detectMentionTrigger, - filterUnselectedMentionCandidates, - normalizeMentionInput, reconcileSelectedMentions, replaceMentionTrigger, upsertSelectedMention, @@ -71,7 +63,6 @@ export interface SendParams { sql?: string; attachments?: IChatAttachment[]; - selectedKnowledge?: ISelectedKnowledge[]; } interface ChatInputProps { @@ -107,32 +98,6 @@ export interface ChatInputPropsRef { const ATTACHMENT_ACCEPT = '.pdf,.doc,.docx,.md,.txt,.json,.csv,.xlsx,.xls'; const ATTACHMENT_FILE_TYPES = ['pdf', 'doc', 'docx', 'md', 'txt', 'json', 'csv', 'xlsx', 'xls']; const ATTACHMENT_PARSE_MESSAGE_KEY = 'chat-attachment-parse'; -const KNOWLEDGE_PAGE_SIZE = 20; - -type KnowledgeSearchRequest = Pick; - -interface KnowledgeSearchCursor { - contextInfo: IAICascaderData | null | undefined; - request: KnowledgeSearchRequest; - requestSequence: number; - pageNo: number; - hasNextPage: boolean; -} - -const toKnowledgeSuggestions = (candidates: readonly KnowledgeMentionCandidate[]): SuggestionItem[] => - candidates.map((candidate) => ({ - label: candidate.key, - value: `knowledge:${candidate.type}:${candidate.id}`, - kind: 'knowledge', - knowledge: candidate, - extra: - candidate.type === 'KNOWLEDGE_TERM' - ? '知识名词' - : candidate.type === 'BUSINESS_LOGIC' - ? '业务逻辑' - : 'SQL 模板', - })); - const AIChatInput = forwardRef((props: ChatInputProps, ref: ForwardedRef) => { const { className, @@ -155,9 +120,6 @@ const AIChatInput = forwardRef((props: ChatInputProps, ref: ForwardedRef(); const [tableList, setTableList] = useState([]); - const [knowledgeList, setKnowledgeList] = useState([]); - const [knowledgeHasNextPage, setKnowledgeHasNextPage] = useState(false); - const [knowledgeLoadingMore, setKnowledgeLoadingMore] = useState(false); const [selectedMentions, setSelectedMentions] = useState([]); const [mentionTrigger, setMentionTrigger] = useState(null); const [attachments, setAttachments] = useState([]); @@ -165,9 +127,7 @@ const AIChatInput = forwardRef((props: ChatInputProps, ref: ForwardedRef(null); const fileInputRef = useRef(null); const isComposingRef = useRef(false); // IME input method combination status - const knowledgeRequestSequenceRef = useRef(0); - const knowledgeSearchCursorRef = useRef(null); - const knowledgeLoadingSequenceRef = useRef(null); + const tableRequestSequenceRef = useRef(0); // caches tables without search conditions const tableListWithoutSearchKey = useRef([]); @@ -237,23 +197,19 @@ const AIChatInput = forwardRef((props: ChatInputProps, ref: ForwardedRef { tableListWithoutSearchKey.current = []; - knowledgeRequestSequenceRef.current += 1; + tableRequestSequenceRef.current += 1; setSelectedMentions([]); setMentionTrigger(null); setTableList([]); - setKnowledgeList([]); - setKnowledgeHasNextPage(false); - knowledgeSearchCursorRef.current = null; - knowledgeLoadingSequenceRef.current = null; - setKnowledgeLoadingMore(false); if (cascaderDataMap[mainPageActiveTab]) { fetchTableList(cascaderDataMap[mainPageActiveTab], ''); } }, [cascaderDataMap[mainPageActiveTab]]); const fetchTableList = useRef( - debounce(async (_contextInfo: IAICascaderData, searchKey: string) => { + debounce(async (_contextInfo: IAICascaderData, searchKey: string, requestSequence?: number) => { if (!_contextInfo) return; + if (requestSequence !== undefined && requestSequence !== tableRequestSequenceRef.current) return; if ('dataSourceId' in _contextInfo && _contextInfo?.dataSourceId) { if (!searchKey && tableListWithoutSearchKey.current.length) { setTableList(tableListWithoutSearchKey.current); @@ -279,6 +235,7 @@ const AIChatInput = forwardRef((props: ChatInputProps, ref: ForwardedRef ({ ...s, @@ -318,76 +277,6 @@ const AIChatInput = forwardRef((props: ChatInputProps, ref: ForwardedRef fetchTableList.cancel(); }, []); - const requestKnowledgePage = ( - contextInfo: IAICascaderData | null | undefined, - request: KnowledgeSearchRequest, - pageNo: number, - ) => { - if (!clientExtension.knowledgeMentions) { - return null; - } - const dataSourceId = contextInfo && 'dataSourceId' in contextInfo ? contextInfo.dataSourceId : undefined; - const databaseName = contextInfo && 'databaseName' in contextInfo ? contextInfo.databaseName : undefined; - const schemaName = contextInfo && 'schemaName' in contextInfo ? contextInfo.schemaName : undefined; - return clientExtension.knowledgeMentions({ - ...request, - dataSourceId, - databaseName, - schemaName, - pageNo, - pageSize: KNOWLEDGE_PAGE_SIZE, - }); - }; - - const fetchKnowledgeList = async ( - contextInfo: IAICascaderData | null | undefined, - request: KnowledgeSearchRequest, - requestSequence: number, - onResolved: (hasCandidates: boolean) => void, - ) => { - const response = requestKnowledgePage(contextInfo, request, 1); - if (!response) { - setKnowledgeList([]); - setKnowledgeHasNextPage(false); - onResolved(false); - return; - } - try { - const page = await response; - if (requestSequence !== knowledgeRequestSequenceRef.current) return; - const suggestions = toKnowledgeSuggestions(page.data || []); - const hasUnselectedCandidates = filterUnselectedMentionCandidates(suggestions, selectedMentions).length > 0; - const hasNextPage = Boolean(page.hasNextPage); - setKnowledgeList(suggestions); - setKnowledgeHasNextPage(hasNextPage); - knowledgeSearchCursorRef.current = { - contextInfo, - request, - requestSequence, - pageNo: page.pageNo || 1, - hasNextPage, - }; - window.setTimeout(() => { - if (requestSequence === knowledgeRequestSequenceRef.current) { - onResolved(hasUnselectedCandidates); - } - }, 0); - } catch { - if (requestSequence !== knowledgeRequestSequenceRef.current) return; - setKnowledgeList([]); - setKnowledgeHasNextPage(false); - knowledgeSearchCursorRef.current = null; - onResolved(false); - } - }; - - useEffect( - () => () => { - knowledgeRequestSequenceRef.current += 1; - }, - [], - ); - const handleSend = async (params?: SendParams) => { if (loading || attachmentLoading) return; @@ -417,7 +306,7 @@ const AIChatInput = forwardRef((props: ChatInputProps, ref: ForwardedRef mention.kind === 'table') .map((mention) => ({ tableName: mention.tableName, tableType: mention.tableType })) as any, - selectedKnowledge: selectedMentions - .filter((mention) => mention.kind === 'knowledge' && mention.knowledge) - .map((mention) => ({ - id: mention.knowledge!.id, - type: mention.knowledge!.type, - key: mention.knowledge!.key, - value: mention.knowledge!.value, - })), attachments: finalAttachments, }; @@ -623,12 +503,7 @@ const AIChatInput = forwardRef((props: ChatInputProps, ref: ForwardedRef prev.filter((_, currentIndex) => currentIndex !== index)); }; - const removeSelectedMention = (value: string) => { - setSelectedMentions((previous) => previous.filter((mention) => mention.value !== value)); - }; - const getMentionList = (info?: MentionTrigger) => { - const availableKnowledge = filterUnselectedMentionCandidates(knowledgeList, selectedMentions); const tables: SuggestionItem[] = (tableList || []).map((table) => ({ label: table.name, value: `table:${table.tableType}:${table.name}`, @@ -637,46 +512,8 @@ const AIChatInput = forwardRef((props: ChatInputProps, ref: ForwardedRef item.label.toLowerCase().includes(info.query.toLowerCase())); - }; - - const loadMoreKnowledge = async () => { - const cursor = knowledgeSearchCursorRef.current; - if (!cursor?.hasNextPage || knowledgeLoadingSequenceRef.current === cursor.requestSequence) { - return; - } - knowledgeLoadingSequenceRef.current = cursor.requestSequence; - setKnowledgeLoadingMore(true); - try { - const nextPageNo = cursor.pageNo + 1; - const response = requestKnowledgePage(cursor.contextInfo, cursor.request, nextPageNo); - if (!response) return; - const page = await response; - if (cursor.requestSequence !== knowledgeRequestSequenceRef.current) return; - const suggestions = toKnowledgeSuggestions(page.data || []); - setKnowledgeList((previous) => { - const merged = new Map(previous.map((item) => [item.value, item])); - suggestions.forEach((item) => merged.set(item.value, item)); - return [...merged.values()]; - }); - const hasNextPage = Boolean(page.hasNextPage); - setKnowledgeHasNextPage(hasNextPage); - knowledgeSearchCursorRef.current = { - ...cursor, - pageNo: page.pageNo || nextPageNo, - hasNextPage, - }; - } catch { - // Keep the current page and allow another scroll attempt. - } finally { - if (knowledgeLoadingSequenceRef.current === cursor.requestSequence) { - knowledgeLoadingSequenceRef.current = null; - setKnowledgeLoadingMore(false); - } - } + if (!info?.query) return tables; + return tables.filter((item) => item.label.toLowerCase().includes(info.query.toLowerCase())); }; const updateMentionSuggestions = ( @@ -686,47 +523,19 @@ const AIChatInput = forwardRef((props: ChatInputProps, ref: ForwardedRef { const nextTrigger = detectMentionTrigger(value, cursor); if (!nextTrigger) { - knowledgeRequestSequenceRef.current += 1; + tableRequestSequenceRef.current += 1; setMentionTrigger(null); - setKnowledgeList([]); - setKnowledgeHasNextPage(false); - knowledgeSearchCursorRef.current = null; onTrigger(false); return; } - const requestSequence = knowledgeRequestSequenceRef.current + 1; - knowledgeRequestSequenceRef.current = requestSequence; - onTrigger(false); - setKnowledgeList([]); + const requestSequence = tableRequestSequenceRef.current + 1; + tableRequestSequenceRef.current = requestSequence; setTableList([]); - knowledgeSearchCursorRef.current = null; - knowledgeLoadingSequenceRef.current = null; - setKnowledgeHasNextPage(false); - setKnowledgeLoadingMore(false); setMentionTrigger(nextTrigger); const contextInfo = cascaderDataMap[mainPageActiveTab]; - - if (nextTrigger.mode === 'explicit') { - fetchTableList(contextInfo, nextTrigger.query); - } - - fetchKnowledgeList( - contextInfo, - nextTrigger.mode === 'explicit' - ? { searchKey: nextTrigger.query || undefined } - : { inputText: nextTrigger.inputText.slice(-200) }, - requestSequence, - (hasCandidates) => { - if (requestSequence !== knowledgeRequestSequenceRef.current) return; - if (hasCandidates) { - onTrigger(nextTrigger); - } else { - setMentionTrigger(null); - onTrigger(false); - } - }, - ); + fetchTableList(contextInfo, nextTrigger.query, requestSequence); + onTrigger(nextTrigger); }; const isSameContextInfo = (prev: IAICascaderData, next: IAICascaderData) => { @@ -744,30 +553,16 @@ const AIChatInput = forwardRef((props: ChatInputProps, ref: ForwardedRef { - switch (mention.knowledge?.type) { - case 'BUSINESS_LOGIC': - return styles.businessLogicMention; - case 'SQL_TEMPLATE': - return styles.sqlTemplateMention; - default: - return styles.knowledgeTermMention; - } - }; - return ( className={className} items={getMentionList} - hasMore={knowledgeHasNextPage} - loadingMore={knowledgeLoadingMore} - onLoadMore={loadMoreKnowledge} onSelect={(item) => { const textarea = textareaRef.current?.resizableTextArea?.textArea; const cursor = textarea?.selectionStart ?? inputValue.length; const activeTrigger = mentionTrigger || detectMentionTrigger(inputValue, cursor); const replacement = activeTrigger - ? replaceMentionTrigger(inputValue, cursor, activeTrigger, item.label) + ? replaceMentionTrigger(inputValue, activeTrigger, item.label) : { value: `${inputValue}${item.label}`, cursor: inputValue.length + item.label.length }; setInputValue(replacement.value); @@ -776,17 +571,13 @@ const AIChatInput = forwardRef((props: ChatInputProps, ref: ForwardedRef { textarea?.focus(); textarea?.setSelectionRange(replacement.cursor, replacement.cursor); @@ -821,25 +612,6 @@ const AIChatInput = forwardRef((props: ChatInputProps, ref: ForwardedRef )} - {!!selectedMentions.some((mention) => mention.kind === 'knowledge') && ( -
- {selectedMentions - .filter((mention) => mention.kind === 'knowledge') - .map((mention) => ( - - ))} -
- )} ({ + value: `table:${tableType}:${name}`, + label: name, + kind: 'table', + tableName: name, + tableType, +}); -assert.deepEqual( - reconcileSelectedMentions('查询 @销量 ', selected).map(({ value }) => value), - ['knowledge:KNOWLEDGE_TERM:1'], -); -assert.deepEqual(reconcileSelectedMentions('查询 @销量 和 @销量 ', selected), selected); -assert.equal( - normalizeMentionInput('查询 @三全水饺 的 @销量 ', [ - { - value: 'knowledge:KNOWLEDGE_TERM:3', - label: '三全水饺', - kind: 'knowledge', - knowledge: { id: 3, type: 'KNOWLEDGE_TERM', key: '三全水饺', value: '三全食品旗下产品' }, - }, - selected[1], - ]), - '查询 三全水饺 的 销量 ', -); -assert.equal(normalizeMentionInput('联系 test@example.com', selected), '联系 test@example.com'); +assert.deepEqual(detectMentionTrigger('@', 1), { query: '', start: 0, end: 1 }); +assert.deepEqual(detectMentionTrigger('query @orders', 13), { query: 'orders', start: 6, end: 13 }); +assert.equal(detectMentionTrigger('contact test@example.com', 24), null); +assert.equal(detectMentionTrigger('orders', 6), null); -assert.deepEqual(detectMentionTrigger('我要查询三全', 6), { - mode: 'natural', - query: '我要查询三全', - inputText: '我要查询三全', - start: 0, - end: 6, -}); -const punctuatedNaturalInput = '请查看生产工。'; -assert.deepEqual(detectMentionTrigger(punctuatedNaturalInput, punctuatedNaturalInput.length), { - mode: 'natural', - query: '请查看生产工', - inputText: punctuatedNaturalInput, - start: 0, - end: 6, -}); -assert.equal(detectMentionTrigger('华东 ', 3), null); -assert.deepEqual(detectMentionTrigger('我要查询 @三全', 8), { - mode: 'explicit', - query: '三全', - inputText: '我要查询 @三全', - start: 5, - end: 8, -}); -assert.equal(detectMentionTrigger('联系 test@example.com', 19)?.mode, 'natural'); -assert.deepEqual(findNaturalFragmentRange('我要查询三全', 6, '三全水饺'), { start: 4, end: 6 }); -assert.deepEqual(findNaturalFragmentRange('统计', 2, '动销商品统计'), { start: 0, end: 2 }); -assert.deepEqual(replaceMentionTrigger('我要查询三全', 6, detectMentionTrigger('我要查询三全', 6)!, '三全水饺'), { - value: '我要查询三全水饺', - cursor: 8, +assert.deepEqual(replaceMentionTrigger('query @ord', detectMentionTrigger('query @ord', 10)!, 'orders'), { + value: 'query orders ', + cursor: 13, }); -assert.deepEqual(replaceMentionTrigger('统计', 2, detectMentionTrigger('统计', 2)!, '动销商品统计'), { - value: '动销商品统计', - cursor: 6, -}); -assert.deepEqual( - replaceMentionTrigger( - '我要查询三全水饺的销量', - 11, - detectMentionTrigger('我要查询三全水饺的销量', 11)!, - '三全水饺', - ), - { - value: '我要查询三全水饺的销量', - cursor: 11, - }, -); -assert.deepEqual( - replaceMentionTrigger('我要查询未知内容', 8, detectMentionTrigger('我要查询未知内容', 8)!, '三全水饺'), - { - value: '我要查询未知内容', - cursor: 8, - }, -); -assert.deepEqual(replaceMentionTrigger('我要查询 @三全', 8, detectMentionTrigger('我要查询 @三全', 8)!, '三全水饺'), { - value: '我要查询 三全水饺 ', - cursor: 10, -}); -const punctuatedPrefixInput = '请查看生产工。'; -assert.deepEqual( - replaceMentionTrigger( - punctuatedPrefixInput, - punctuatedPrefixInput.length, - detectMentionTrigger(punctuatedPrefixInput, punctuatedPrefixInput.length)!, - '生产工单', - ), - { - value: '请查看生产工单。', - cursor: 7, - }, -); - -const knowledgeTerm: SelectedMention = { - value: 'knowledge:KNOWLEDGE_TERM:3', - label: '三全水饺', - kind: 'knowledge', - knowledge: { id: 3, type: 'KNOWLEDGE_TERM', key: '三全水饺', value: '三全食品旗下产品' }, -}; -const businessLogic: SelectedMention = { - value: 'knowledge:BUSINESS_LOGIC:2', - label: '销量', - kind: 'knowledge', - knowledge: { id: 2, type: 'BUSINESS_LOGIC', key: '销量', value: 'SUM(quantity)' }, -}; -assert.deepEqual(upsertSelectedMention(upsertSelectedMention([], knowledgeTerm), businessLogic), [ - knowledgeTerm, - businessLogic, -]); -assert.deepEqual(upsertSelectedMention([knowledgeTerm, businessLogic], knowledgeTerm), [businessLogic, knowledgeTerm]); -const selectedContribution: SelectedMention = { - value: 'knowledge:BUSINESS_LOGIC:225', - label: '重点客户贡献率', - kind: 'knowledge', - knowledge: { id: 225, type: 'BUSINESS_LOGIC', key: '重点客户贡献率', value: '重点客户销售额占比' }, -}; -assert.deepEqual( - filterUnselectedMentionCandidates( - [ - { value: 'knowledge:BUSINESS_LOGIC:225', label: '重点客户贡献率' }, - { value: 'knowledge:SQL_TEMPLATE:262', label: '重点客户贡献率' }, - { value: 'knowledge:KNOWLEDGE_TERM:209', label: '华东大区' }, - ], - [selectedContribution], - ), - [{ value: 'knowledge:KNOWLEDGE_TERM:209', label: '华东大区' }], -); +const orders = tableMention('orders'); +const customers = tableMention('customers'); +assert.deepEqual(upsertSelectedMention(upsertSelectedMention([], orders), customers), [orders, customers]); +assert.deepEqual(upsertSelectedMention([orders, customers], orders), [customers, orders]); +assert.deepEqual(reconcileSelectedMentions('query orders and customers', [orders, customers]), [orders, customers]); +assert.deepEqual(reconcileSelectedMentions('query customers', [orders, customers]), [customers]); +assert.deepEqual(reconcileSelectedMentions('query preorders', [orders]), []); +assert.deepEqual(reconcileSelectedMentions('query @orders', [orders]), [orders]); diff --git a/chat2db-community-client/src/blocks/AI/components/AIChatInput/mentionSelection.ts b/chat2db-community-client/src/blocks/AI/components/AIChatInput/mentionSelection.ts index 5a86b17869..7d6b6e5df4 100644 --- a/chat2db-community-client/src/blocks/AI/components/AIChatInput/mentionSelection.ts +++ b/chat2db-community-client/src/blocks/AI/components/AIChatInput/mentionSelection.ts @@ -1,21 +1,13 @@ export interface SelectedMention { value: string; label: string; - kind: 'table' | 'knowledge'; - tableName?: string; + kind: 'table'; + tableName: string; tableType?: string; - knowledge?: { - id: number; - type: 'KNOWLEDGE_TERM' | 'BUSINESS_LOGIC' | 'SQL_TEMPLATE'; - key: string; - value: string; - }; } export interface MentionTrigger { - mode: 'explicit' | 'natural'; query: string; - inputText: string; start: number; end: number; } @@ -26,96 +18,34 @@ export interface MentionReplacement { } const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - -const mentionPattern = (label: string, global = false) => - new RegExp(`@${escapeRegExp(label)}(?=\\s|$)`, global ? 'g' : undefined); - -const selectedLabelPattern = (label: string, global = false) => - new RegExp(`@?${escapeRegExp(label)}`, global ? 'g' : undefined); - +const MENTION_BOUNDARY = '[\\s,。!?,!?;;::()\\[\\]{}]'; +const mentionPattern = (label: string) => + new RegExp(`(^|${MENTION_BOUNDARY})@?${escapeRegExp(label)}(?=$|${MENTION_BOUNDARY})`); const EXPLICIT_TRIGGER_PATTERN = /(^|[\s,。!?,!?;;::([{])@([^@\s,。!?,!?;;::)\]}]*)$/; -const NATURAL_TRAILING_TEXT_PATTERN = /([^\s,。!?,!?;;::()[\]{}]+)(?:[\s,。!?,!?;;::()[\]{}]+)?$/; export const detectMentionTrigger = (input: string, cursor: number): MentionTrigger | null => { const safeCursor = Math.max(0, Math.min(cursor, input.length)); const inputText = input.slice(0, safeCursor); - const explicitMatch = inputText.match(EXPLICIT_TRIGGER_PATTERN); - if (explicitMatch) { - const query = explicitMatch[2]; - return { - mode: 'explicit', - query, - inputText, - start: safeCursor - query.length - 1, - end: safeCursor, - }; - } - - if (/\s$/.test(inputText)) { - return null; - } - - const trailingMatch = inputText.match(NATURAL_TRAILING_TEXT_PATTERN); - const trailingText = trailingMatch?.[1] || ''; - if (trailingText.length < 2) { - return null; - } - const triggerStart = safeCursor - (trailingMatch?.[0].length || trailingText.length); + const match = inputText.match(EXPLICIT_TRIGGER_PATTERN); + if (!match) return null; + const query = match[2]; return { - mode: 'natural', - query: trailingText, - inputText, - start: triggerStart, - end: triggerStart + trailingText.length, + query, + start: safeCursor - query.length - 1, + end: safeCursor, }; }; -export const findNaturalFragmentRange = ( - input: string, - cursor: number, - candidateKey: string, -): { start: number; end: number } | null => { - const safeCursor = Math.max(0, Math.min(cursor, input.length)); - const inputText = input.slice(0, safeCursor).toLocaleLowerCase(); - const normalizedCandidate = candidateKey.toLocaleLowerCase(); - const maximumLength = Math.min(inputText.length, normalizedCandidate.length); - for (let length = maximumLength; length >= 2; length -= 1) { - if (normalizedCandidate.includes(inputText.slice(-length))) { - return { start: safeCursor - length, end: safeCursor }; - } - } - return null; -}; - export const replaceMentionTrigger = ( input: string, - cursor: number, trigger: MentionTrigger, - candidateKey: string, + tableName: string, ): MentionReplacement => { - if (trigger.mode === 'natural') { - const normalizedInput = input.toLocaleLowerCase(); - if (normalizedInput.includes(candidateKey.toLocaleLowerCase())) { - return { value: input, cursor }; - } - - const range = findNaturalFragmentRange(input, trigger.end, candidateKey); - if (!range) { - return { value: input, cursor }; - } - - return { - value: `${input.slice(0, range.start)}${candidateKey}${input.slice(range.end)}`, - cursor: range.start + candidateKey.length, - }; - } - - const replacementRange = { start: trigger.start, end: trigger.end }; const suffix = ' '; - const value = `${input.slice(0, replacementRange.start)}${candidateKey}${suffix}${input.slice(replacementRange.end)}`; + const value = `${input.slice(0, trigger.start)}${tableName}${suffix}${input.slice(trigger.end)}`; return { value, - cursor: replacementRange.start + candidateKey.length + suffix.length, + cursor: trigger.start + tableName.length + suffix.length, }; }; @@ -127,34 +57,6 @@ export const upsertSelectedMention = ( nextMention, ]; -export const filterUnselectedMentionCandidates = >( - candidates: readonly T[], - selected: readonly Pick[], -): T[] => { - const selectedValues = new Set(selected.map((mention) => mention.value)); - const selectedLabels = new Set(selected.map((mention) => mention.label)); - return candidates.filter( - (candidate) => !selectedValues.has(candidate.value) && !selectedLabels.has(candidate.label), - ); -}; - export const reconcileSelectedMentions = (input: string, selected: readonly SelectedMention[]): SelectedMention[] => { - const remainingByLabel = new Map(); - selected.forEach(({ label }) => { - if (!remainingByLabel.has(label)) { - remainingByLabel.set(label, input.match(selectedLabelPattern(label, true))?.length || 0); - } - }); - - return selected.filter(({ label }) => { - const remaining = remainingByLabel.get(label) || 0; - if (remaining <= 0) return false; - remainingByLabel.set(label, remaining - 1); - return true; - }); -}; - -export const normalizeMentionInput = (input: string, selected: readonly SelectedMention[]): string => { - const labels = [...new Set(selected.map(({ label }) => label))].sort((left, right) => right.length - left.length); - return labels.reduce((text, label) => text.replace(mentionPattern(label, true), label), input); + return selected.filter(({ label }) => mentionPattern(label).test(input)); }; diff --git a/chat2db-community-client/src/blocks/AI/components/AIChatInput/style.ts b/chat2db-community-client/src/blocks/AI/components/AIChatInput/style.ts index 985f8c1b35..0671db60aa 100644 --- a/chat2db-community-client/src/blocks/AI/components/AIChatInput/style.ts +++ b/chat2db-community-client/src/blocks/AI/components/AIChatInput/style.ts @@ -76,52 +76,6 @@ export const useStyles = createStyles(({ css, token }) => { color: ${token.colorText}; } `, - selectedKnowledgeList: css` - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; - margin-bottom: 7px; - `, - selectedKnowledgeItem: css` - display: inline-flex; - align-items: center; - gap: 5px; - max-width: 240px; - min-height: 26px; - padding: 3px 8px; - border: 1px solid transparent; - border-radius: 5px; - font-size: 12px; - line-height: 18px; - cursor: pointer; - - > span:first-of-type { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - `, - selectedKnowledgeRemoveIcon: css` - flex-shrink: 0; - font-size: 10px; - opacity: 0.72; - `, - knowledgeTermMention: css` - color: ${token.colorInfoText}; - border-color: ${token.colorInfoBorder}; - background: ${token.colorInfoBg}; - `, - businessLogicMention: css` - color: ${token.colorSuccessText}; - border-color: ${token.colorSuccessBorder}; - background: ${token.colorSuccessBg}; - `, - sqlTemplateMention: css` - color: ${token.colorWarningText}; - border-color: ${token.colorWarningBorder}; - background: ${token.colorWarningBg}; - `, bottomAddonsRow: css` width: 100%; display: flex; diff --git a/chat2db-community-client/src/blocks/AI/components/AIModelConfigModal/index.tsx b/chat2db-community-client/src/blocks/AI/components/AIModelConfigModal/index.tsx index 0dac65cad6..ca2297b5c2 100644 --- a/chat2db-community-client/src/blocks/AI/components/AIModelConfigModal/index.tsx +++ b/chat2db-community-client/src/blocks/AI/components/AIModelConfigModal/index.tsx @@ -181,7 +181,10 @@ export default function AIModelConfigModal({ open, onClose, onChanged }: AIModel } catch (error: any) { modal.error({ title: i18n('setting.modelConfig.testFailed'), - content: error?.errorMessage || error?.message || String(error), + content: ( +
{error?.errorMessage || error?.message || String(error)}
+ ), + width: 680, }); } finally { setTesting(false); diff --git a/chat2db-community-client/src/blocks/AI/components/AIModelConfigModal/style.ts b/chat2db-community-client/src/blocks/AI/components/AIModelConfigModal/style.ts index b8232e34c1..828e820a14 100644 --- a/chat2db-community-client/src/blocks/AI/components/AIModelConfigModal/style.ts +++ b/chat2db-community-client/src/blocks/AI/components/AIModelConfigModal/style.ts @@ -117,6 +117,7 @@ export const useStyles = createStyles(({ css, token }) => { `, testResult: css` max-height: 320px; + overflow-y: auto; margin: 0; white-space: pre-wrap; word-break: break-word; diff --git a/chat2db-community-client/src/blocks/AI/components/MessageNavigationRail/index.tsx b/chat2db-community-client/src/blocks/AI/components/MessageNavigationRail/index.tsx new file mode 100644 index 0000000000..41159174ae --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/components/MessageNavigationRail/index.tsx @@ -0,0 +1,73 @@ +import { memo, useState, type CSSProperties } from 'react'; +import { cx } from 'antd-style'; + +import i18n from '@/i18n'; +import { + getMessageNavigationRailWidth, + type UserMessageNavigationItem, +} from '../../messageNavigation'; +import { useStyles } from './style'; + +interface MessageNavigationRailProps { + items: UserMessageNavigationItem[]; + onNavigate: (messageId: string) => void; +} + +const MessageNavigationRail = ({ items, onNavigate }: MessageNavigationRailProps) => { + const { styles } = useStyles(); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [railScrollTop, setRailScrollTop] = useState(0); + const activeIndex = selectedIndex < items.length ? selectedIndex : -1; + const selectedItem = activeIndex >= 0 ? items[activeIndex] : null; + + if (!items.length) { + return null; + } + + return ( +
setSelectedIndex(-1)} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setSelectedIndex(-1); + } + }} + > +
setRailScrollTop(event.currentTarget.scrollTop)} + > + {items.map((item, itemIndex) => ( + + ))} +
+ {selectedItem ? ( +
+
{selectedItem.label}
+ {selectedItem.assistantPreview ? ( +
{selectedItem.assistantPreview}
+ ) : null} +
+ ) : null} +
+ ); +}; + +export default memo(MessageNavigationRail); diff --git a/chat2db-community-client/src/blocks/AI/components/MessageNavigationRail/style.ts b/chat2db-community-client/src/blocks/AI/components/MessageNavigationRail/style.ts new file mode 100644 index 0000000000..594007d051 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/components/MessageNavigationRail/style.ts @@ -0,0 +1,106 @@ +import { createStyles } from 'antd-style'; + +export const useStyles = createStyles(({ css, token }) => ({ + rail: css` + position: absolute; + z-index: 6; + top: 50%; + left: max(8px, calc(50% - 448px)); + transform: translateY(-50%); + width: 36px; + padding: 6px 0; + opacity: 0.72; + transition: opacity 0.15s ease; + + &:hover, + &:focus-within { + opacity: 1; + } + `, + list: css` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + width: 36px; + max-height: min(240px, 60vh); + overflow-y: auto; + overflow-x: hidden; + `, + item: css` + display: flex; + align-items: center; + width: 34px; + height: 12px; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + + > span { + display: block; + width: var(--message-navigation-rail-width, 8px); + height: 2px; + border-radius: 1px; + background: ${token.colorTextQuaternary}; + opacity: 0.55; + transition: background-color 0.1s ease, opacity 0.1s ease; + } + + &:hover > span, + &:focus-visible > span { + width: 22px; + background: ${token.colorTextSecondary}; + opacity: 1; + } + + `, + itemActive: css` + > span, + &:hover > span, + &:focus-visible > span { + width: 30px; + height: 3px; + background: ${token.colorText}; + opacity: 1; + } + `, + preview: css` + position: absolute; + left: 44px; + transform: translateY(-50%); + z-index: 1; + width: min(320px, calc(100vw - 96px)); + height: 108px; + min-width: 0; + padding: 10px 12px; + border: 1px solid ${token.colorBorderSecondary}; + border-radius: 8px; + background: ${token.colorBgElevated}; + box-shadow: ${token.boxShadowSecondary}; + pointer-events: none; + box-sizing: border-box; + `, + previewQuestion: css` + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + color: ${token.colorText}; + font-size: 13px; + font-weight: 600; + line-height: 20px; + `, + previewAnswer: css` + display: -webkit-box; + overflow: hidden; + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid ${token.colorBorderSecondary}; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + color: ${token.colorTextSecondary}; + font-size: 12px; + line-height: 18px; + `, +})); diff --git a/chat2db-community-client/src/blocks/AI/index.tsx b/chat2db-community-client/src/blocks/AI/index.tsx index efa6f8ad13..eab958c473 100644 --- a/chat2db-community-client/src/blocks/AI/index.tsx +++ b/chat2db-community-client/src/blocks/AI/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Dropdown, Flex, Modal } from 'antd'; import feedback from '@/utils/feedback'; import { @@ -24,8 +24,6 @@ import aiStreamService, { IChatMessage, IChatSession, IModelOptionItem, - ISelectedKnowledge, - KnowledgeSelectionType, } from '@/service/aiStream'; import { IChatAttachment } from '@/service/aiAttachment'; import { useAIStore } from '@/store/ai'; @@ -48,10 +46,13 @@ import { listAvailableModelOptions, resolveModelRequestPayload } from '@/service import { isDesktop } from '@/utils/env'; import { usePermission } from '@/hooks/usePermission'; import { clientRuntime } from '@client-runtime'; -import { toKnowledgeSelectionReferences } from './knowledgeSelection'; import { buildWorkspaceObjectTabTitle } from '@/utils/workspaceObjectTabTitle'; import type { IConnectionEnv } from '@/typings'; import { resolveAIDataSourceContext } from './dataSourceContext'; +import { buildUserMessageNavigationItems } from './messageNavigation'; +import { Pencil } from 'lucide-react'; +import MessageNavigationRail from './components/MessageNavigationRail'; +import InlineRenameInput from '@/components/InlineRenameInput'; /** detects unclosed text in flowing text ```chart block, return chart and whether there are any unfinished diagrams */ function splitIncompleteChartBlock(text: string): { textBeforeChart: string; hasIncompleteChart: boolean } { @@ -329,16 +330,9 @@ interface IChatItem { role: ChatRole; content: string; attachments?: IChatAttachment[]; - selectedKnowledge?: ISelectedKnowledge[]; traceEntries?: ITraceEntry[]; } -const knowledgeTypeLabel: Record = { - KNOWLEDGE_TERM: '知识名词', - BUSINESS_LOGIC: '业务逻辑', - SQL_TEMPLATE: 'SQL 模板', -}; - interface IChatRound { key: string; user?: IChatItem; @@ -534,6 +528,7 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession questionType?: QuestionType; } | null>(null); const [currentRoundUserMessageId, setCurrentRoundUserMessageId] = useState(null); + const [highlightedUserMessageId, setHighlightedUserMessageId] = useState(null); const [messageListContentHeight, setMessageListContentHeight] = useState(0); // Session management. @@ -541,6 +536,7 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession const [currentSessionTitle, setCurrentSessionTitle] = useState(''); const [openSettings, setOpenSettings] = useState(false); const [sessionLoading, setSessionLoading] = useState(false); + const [panelRenamingSessionId, setPanelRenamingSessionId] = useState(null); const isEmptyState = !messages.length && !streamingText && !streamTraceEntries.length; const streamingRef = useRef(''); @@ -570,6 +566,12 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession const topAlignmentTimerRef = useRef(null); const pendingInitialBottomSyncRef = useRef(false); const initialBottomSyncTimerRef = useRef(null); + const messageHighlightTimerRef = useRef(null); + + const userMessageNavigationItems = useMemo( + () => buildUserMessageNavigationItems(messages, i18n('stream.messageNavigation.userMessage')), + [messages, language], + ); const lockScrollTracking = useCallback((behavior: ScrollBehavior = 'auto') => { suppressScrollTrackingRef.current = true; @@ -753,6 +755,22 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession [scrollMessageToTop], ); + const handleNavigateToUserMessage = useCallback( + (messageId: string) => { + setAutoFollow(false); + scrollMessageToTop(messageId); + setHighlightedUserMessageId(messageId); + if (messageHighlightTimerRef.current !== null) { + window.clearTimeout(messageHighlightTimerRef.current); + } + messageHighlightTimerRef.current = window.setTimeout(() => { + setHighlightedUserMessageId(null); + messageHighlightTimerRef.current = null; + }, 1600); + }, + [scrollMessageToTop, setAutoFollow], + ); + const flushPendingBuffer = useCallback(() => { return; }, []); @@ -945,6 +963,10 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession window.clearTimeout(initialBottomSyncTimerRef.current); initialBottomSyncTimerRef.current = null; } + if (messageHighlightTimerRef.current !== null) { + window.clearTimeout(messageHighlightTimerRef.current); + messageHighlightTimerRef.current = null; + } }; }, []); @@ -1236,6 +1258,11 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession currentSessionTitleRef.current = ''; setCurrentRoundUserMessageId(null); currentRoundUserMessageIdRef.current = null; + setHighlightedUserMessageId(null); + if (messageHighlightTimerRef.current !== null) { + window.clearTimeout(messageHighlightTimerRef.current); + messageHighlightTimerRef.current = null; + } currentRoundBlockRef.current = null; newSessionIdRef.current = null; inProgressSessionRef.current = null; @@ -1245,6 +1272,30 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession onSessionChange?.(); }, [isPanel, clearChatIdFromPath, onSessionChange, stop]); + const startPanelHistoryRename = useCallback((session: IChatSession) => { + setPanelRenamingSessionId(session.id); + }, []); + + const renamePanelHistorySession = useCallback(async (sessionId: string, title: string) => { + try { + await aiStreamService.renameChatSession({ id: sessionId, title }); + setSessionList((prev) => prev.map((item) => (item.id === sessionId ? { ...item, title } : item))); + if (currentSessionIdRef.current === sessionId) { + setCurrentSessionTitle(title); + currentSessionTitleRef.current = title; + } + window.dispatchEvent( + new CustomEvent('stream:sessionRenamed', { + detail: { sessionId, title }, + }), + ); + feedback.success(i18n('common.message.modifySuccessfully')); + } catch (error) { + feedback.error(i18n('stream.sidebar.renameFailed')); + throw error; + } + }, []); + const handleDeleteHistorySession = useCallback( async (sessionId: string) => { try { @@ -1334,6 +1385,11 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession currentSessionTitleRef.current = title || ''; setCurrentRoundUserMessageId(null); currentRoundUserMessageIdRef.current = null; + setHighlightedUserMessageId(null); + if (messageHighlightTimerRef.current !== null) { + window.clearTimeout(messageHighlightTimerRef.current); + messageHighlightTimerRef.current = null; + } currentRoundBlockRef.current = null; const inProgressSession = inProgressSessionRef.current; @@ -1362,7 +1418,6 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession role: m.role as ChatRole, content: m.content, attachments: m.attachments, - selectedKnowledge: m.selectedKnowledge, traceEntries: parseTraceEntries(m.reasoningContent), })); const latestInProgressSession = inProgressSessionRef.current; @@ -1457,6 +1512,23 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession }; }, [isPanel, handleLoadSessionById]); + useEffect(() => { + const handleSessionRenamed = (event: Event) => { + const { sessionId, title } = (event as CustomEvent<{ sessionId: string; title: string }>).detail || {}; + if (!sessionId || !title) { + return; + } + setSessionList((prev) => prev.map((session) => (session.id === sessionId ? { ...session, title } : session))); + if (currentSessionIdRef.current === sessionId) { + setCurrentSessionTitle(title); + currentSessionTitleRef.current = title; + } + }; + + window.addEventListener('stream:sessionRenamed', handleSessionRenamed); + return () => window.removeEventListener('stream:sessionRenamed', handleSessionRenamed); + }, []); + // Send a message. const handleSend = useCallback( @@ -1510,7 +1582,6 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession role: 'user' as const, content, attachments: params.attachments, - selectedKnowledge: params.selectedKnowledge, }, ]; messagesRef.current = next; @@ -1561,7 +1632,6 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession databaseType: params.databaseType, tableName: params.tableName, questionType: params.questionType, - selectedKnowledge: toKnowledgeSelectionReferences(params.selectedKnowledge), attachments: params.attachments, }); @@ -1891,7 +1961,13 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession style={isLastRound && messageListContentHeight > 0 ? { minHeight: messageListContentHeight } : undefined} > {round.user && ( -
setMessageElement(round.user!.id, node)}> +
setMessageElement(round.user!.id, node)} + >
{round.user.attachments?.length ? (
@@ -1906,27 +1982,6 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession ))}
) : null} - {round.user.selectedKnowledge?.some((knowledge) => knowledge.key) ? ( -
- {round.user.selectedKnowledge - .filter((knowledge) => knowledge.key) - .map((knowledge) => ( - - {knowledgeTypeLabel[knowledge.type]}: - {knowledge.key} - - ))} -
- ) : null}
{round.user.content}
@@ -2006,12 +2061,42 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession
handleLoadSessionById(item.id, item.title)} + onClick={() => { + if (panelRenamingSessionId !== item.id) { + handleLoadSessionById(item.id, item.title); + } + }} > - {item.title || i18n('stream.session.title')} + {panelRenamingSessionId === item.id ? ( + setPanelRenamingSessionId(null)} + onSubmit={(title) => renamePanelHistorySession(item.id, title)} + /> + ) : ( + {item.title || i18n('stream.session.title')} + )} +