feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Steps } from '@coze-arch/coze-design';
export enum BatchImportStep {
Upload,
Config,
Preview,
Process,
}
export interface BatchImportStepsProps {
step: BatchImportStep;
}
export function BatchImportSteps({ step }: BatchImportStepsProps) {
return (
<Steps
type="basic"
hasLine={false}
current={step}
className={classNames(
'my-[24px] justify-center',
'[&_.semi-steps-item]:flex-none',
'[&_.semi-steps-item-title]:!max-w-[unset]',
)}
>
<Steps.Step title={I18n.t('db_optimize_014')} />
<Steps.Step title={I18n.t('db_optimize_015')} />
<Steps.Step title={I18n.t('db_optimize_016')} />
<Steps.Step title={I18n.t('db_optimize_017')} />
</Steps>
);
}

View File

@@ -0,0 +1,157 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState } from 'react';
import {
type UnitItem,
UploadStatus,
} from '@coze-data/knowledge-resource-processor-core';
import { I18n } from '@coze-arch/i18n';
import { type TableType, type TableSheet } from '@coze-arch/bot-api/memory';
import { Button, Modal } from '@coze-arch/coze-design';
import { type TableFieldData } from '../database-table-data/type';
import { StepUpload } from './steps/upload';
import { StepProcess } from './steps/process';
import { StepPreview } from './steps/preview';
import { StepConfig } from './steps/config';
import { BatchImportStep, BatchImportSteps } from './import-steps';
export interface BatchImportModalProps {
visible: boolean;
databaseId: string;
tableFields: TableFieldData[];
tableType: TableType;
connectorId?: string;
onClose?: () => void;
onComplete?: () => void;
}
export function BatchImportModal({
visible,
databaseId,
tableFields,
tableType,
connectorId,
onClose,
onComplete,
}: BatchImportModalProps) {
const [currentStep, setCurrentStep] = useState(BatchImportStep.Upload);
const [unitList, setUnitList] = useState<UnitItem[]>([]);
const [tableSheet, setTableSheet] = useState<TableSheet>();
const resetSteps = () => {
setCurrentStep(BatchImportStep.Upload);
setUnitList([]);
setTableSheet(undefined);
};
const getNextDisabled = () => {
switch (currentStep) {
case BatchImportStep.Upload:
return (
unitList.length <= 0 ||
unitList.some(item => item.status !== UploadStatus.SUCCESS)
);
case BatchImportStep.Config:
return !tableSheet;
case BatchImportStep.Preview:
return false;
case BatchImportStep.Process:
return false;
default:
return false;
}
};
return (
<Modal
visible={visible}
title={I18n.t('db_optimize_013')}
onCancel={onClose}
width={1120}
className="[&_.semi-modal-content]:min-h-[520px]"
footer={
<>
{currentStep !== BatchImportStep.Process ? (
<>
<Button
color="primary"
disabled={currentStep === BatchImportStep.Upload}
onClick={() => setCurrentStep(currentStep - 1)}
>
{I18n.t('db_optimize_020')}
</Button>
<Button
disabled={getNextDisabled()}
onClick={() => setCurrentStep(currentStep + 1)}
>
{I18n.t('db_optimize_021')}
</Button>
</>
) : (
<Button
onClick={() => {
onClose?.();
onComplete?.();
resetSteps();
}}
>
{I18n.t('db2_004')}
</Button>
)}
</>
}
>
<BatchImportSteps step={currentStep} />
{currentStep === BatchImportStep.Upload ? (
<StepUpload
databaseId={databaseId}
tableType={tableType}
unitList={unitList}
onUnitListChange={setUnitList}
/>
) : null}
{currentStep === BatchImportStep.Config ? (
<StepConfig
databaseId={databaseId}
tableFields={tableFields}
tableType={tableType}
fileUri={unitList[0].uri}
onTableSheetChange={setTableSheet}
/>
) : null}
{currentStep === BatchImportStep.Preview ? (
<StepPreview
databaseId={databaseId}
tableFields={tableFields}
fileUri={unitList[0].uri}
tableSheet={tableSheet}
/>
) : null}
{currentStep === BatchImportStep.Process ? (
<StepProcess
databaseId={databaseId}
tableType={tableType}
fileItem={unitList[0]}
tableSheet={tableSheet}
connectorId={connectorId}
/>
) : null}
</Modal>
);
}

View File

@@ -0,0 +1,193 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect } from 'react';
import classNames from 'classnames';
import { useRequest } from 'ahooks';
import { type TableSettings } from '@coze-data/knowledge-resource-processor-base/types';
import { TableSettingBar } from '@coze-data/knowledge-resource-processor-base/components/table-format';
import { FIELD_TYPE_OPTIONS } from '@coze-data/database-v2-base/constants';
import { DatabaseFieldTitle } from '@coze-data/database-v2-base/components/database-field-title';
import { I18n } from '@coze-arch/i18n';
import {
type TableType,
type TableSheet,
TableDataType,
type GetDocumentTableInfoResponse,
ColumnType,
FieldItemType,
} from '@coze-arch/bot-api/memory';
import { MemoryApi } from '@coze-arch/bot-api';
import { type ColumnProps, Spin, Table } from '@coze-arch/coze-design';
import { type TableFieldData } from '../../database-table-data/type';
type TableSettingsData = Pick<
GetDocumentTableInfoResponse,
'sheet_list' | 'preview_data'
>;
function tableSettingsToSheet(tableSettings: TableSettings): TableSheet {
return {
sheet_id: tableSettings?.sheet_id?.toString() ?? '',
header_line_idx: tableSettings?.header_line_idx?.toString() ?? '',
start_line_idx: tableSettings?.start_line_idx?.toString() ?? '',
};
}
export interface StepConfigProps {
databaseId: string;
tableType: TableType;
tableFields: TableFieldData[];
fileUri: string;
onTableSheetChange: (tableSheet?: TableSheet) => void;
}
export function StepConfig({
databaseId,
tableType,
tableFields,
fileUri,
onTableSheetChange,
}: StepConfigProps) {
const [tableData, setTableData] = useState<TableSettingsData>();
// 默认值使用第1个数据表第1行是表头第2行开始是数据
const [tableSettings, setTableSettings] = useState<TableSettings>({
sheet_id: 0,
header_line_idx: 0,
start_line_idx: 1,
});
const [tableStructure, setTableStructure] = useState<TableFieldData[]>([]);
const { loading } = useRequest(
() =>
MemoryApi.GetTableSchema({
database_id: databaseId,
source_file: { tos_uri: fileUri },
table_data_type: TableDataType.OnlySchema,
table_sheet: tableSettingsToSheet(tableSettings),
}),
{
refreshDeps: [fileUri, tableSettings],
onSuccess: res => {
setTableData({
sheet_list: res.sheet_list,
preview_data: {}, // TableSettingBar 并没有读取 preview_data但是在判断它非空
});
if (res.table_meta) {
setTableStructure(
res.table_meta.map(column => {
// 表结构中有同名字段时,使用原本的类型及描述
const matchedField = tableFields.find(
field => field.fieldName === column.column_name,
);
return (
matchedField ??
({
fieldName: column.column_name ?? '-',
fieldDescription: column.desc ?? '-',
type: convertColumnType(column.column_type),
required: false,
} satisfies TableFieldData)
);
}),
);
}
},
},
);
useEffect(() => {
MemoryApi.ValidateTableSchema({
database_id: databaseId,
source_file: { tos_uri: fileUri },
table_type: tableType,
table_sheet: tableSettingsToSheet(tableSettings),
})
.then(res => {
if (!res.schema_valid_result) {
onTableSheetChange(tableSettingsToSheet(tableSettings));
} else {
onTableSheetChange();
}
})
.catch(() => {
onTableSheetChange();
});
}, [tableSettings]);
return !tableData ? (
<Spin size="large" wrapperClassName="w-full h-[288px]" />
) : (
<>
<TableSettingBar
data={tableData}
tableSettings={tableSettings}
setTableSettings={setTableSettings}
/>
<Table
tableProps={{
loading,
columns: getTableStructureColumns(),
dataSource: tableStructure,
}}
className={classNames(
'[&_.semi-table-row-head]:!border-b-[1px]',
'[&_.semi-table-row-cell]:!h-[56px]',
'[&_.semi-table-row-cell]:!border-b-0',
'[&_.semi-table-row-cell]:!bg-none',
'[&_.semi-table-row-cell]:!bg-transparent',
)}
/>
</>
);
}
function convertColumnType(type?: ColumnType): FieldItemType {
switch (type) {
case ColumnType.Text:
return FieldItemType.Text;
case ColumnType.Number:
return FieldItemType.Number;
case ColumnType.Date:
return FieldItemType.Date;
case ColumnType.Float:
return FieldItemType.Float;
case ColumnType.Boolean:
return FieldItemType.Boolean;
default:
return FieldItemType.Text;
}
}
function getTableStructureColumns(): ColumnProps<TableFieldData>[] {
return [
{
title: <DatabaseFieldTitle field={I18n.t('db_add_table_field_name')} />,
render: (_, record) => record.fieldName,
},
{
title: <DatabaseFieldTitle field={I18n.t('db_add_table_field_desc')} />,
render: (_, record) => record.fieldDescription,
},
{
title: <DatabaseFieldTitle field={I18n.t('db_add_table_field_type')} />,
render: (_, record) =>
FIELD_TYPE_OPTIONS.find(i => i.value === record.type)?.label ?? '-',
},
];
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo, useState } from 'react';
import { useRequest } from 'ahooks';
import {
type TableInfo,
type TableSettings,
} from '@coze-data/knowledge-resource-processor-base/types';
import { TablePreview } from '@coze-data/knowledge-resource-processor-base/components/table-format';
import {
type TableSheet,
type DocTableColumn,
TableDataType,
} from '@coze-arch/bot-api/memory';
import { ColumnType } from '@coze-arch/bot-api/knowledge';
import { FieldItemType } from '@coze-arch/bot-api/developer_api';
import { MemoryApi } from '@coze-arch/bot-api';
import { Spin } from '@coze-arch/coze-design';
import { type TableFieldData } from '../../database-table-data/type';
function convertFieldItemType(type?: FieldItemType): ColumnType {
switch (type) {
case FieldItemType.Text:
return ColumnType.Text;
case FieldItemType.Number:
return ColumnType.Number;
case FieldItemType.Date:
return ColumnType.Date;
case FieldItemType.Float:
return ColumnType.Float;
case FieldItemType.Boolean:
return ColumnType.Boolean;
default:
return ColumnType.Text;
}
}
function tableSheetToSettings(tableSheet: TableSheet): TableSettings {
return {
sheet_id: Number.parseInt(tableSheet?.sheet_id ?? '0'),
header_line_idx: Number.parseInt(tableSheet?.header_line_idx ?? '0'),
start_line_idx: Number.parseInt(tableSheet?.start_line_idx ?? '0'),
};
}
export interface StepPreviewProps {
databaseId: string;
tableFields: TableFieldData[];
fileUri: string;
tableSheet?: TableSheet;
}
export function StepPreview({
databaseId,
tableFields,
fileUri,
tableSheet,
}: StepPreviewProps) {
const [tableInfo, setTableInfo] = useState<TableInfo>();
const tableMeta: DocTableColumn[] = useMemo(
() =>
tableFields.map((field, index) => ({
column_name: field.fieldName,
column_type: convertFieldItemType(field.type),
desc: field.fieldDescription,
sequence: `${index}`,
is_semantic: false,
id: `${index}`,
})),
[tableFields],
);
const { loading } = useRequest(
() =>
MemoryApi.GetTableSchema({
database_id: databaseId,
source_file: { tos_uri: fileUri },
table_data_type: TableDataType.OnlyPreview,
table_sheet: tableSheet,
}),
{
onSuccess: res => {
const sheetId = tableSheet?.sheet_id;
if (sheetId) {
setTableInfo({
sheet_list: res.sheet_list,
table_meta: { [sheetId]: tableMeta },
preview_data: { [sheetId]: res.preview_data ?? [] },
});
}
},
},
);
return loading || !tableInfo || !tableSheet ? (
<Spin size="large" wrapperClassName="w-full h-[288px]" />
) : (
<TablePreview
data={tableInfo}
settings={tableSheetToSettings(tableSheet)}
/>
);
}

View File

@@ -0,0 +1,147 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect, useMemo } from 'react';
import { useRequest } from 'ahooks';
import { type UnitItem } from '@coze-data/knowledge-resource-processor-core';
import {
type ProcessProgressItemProps,
ProcessStatus,
} from '@coze-data/knowledge-resource-processor-base/types';
import { ProcessProgressItem } from '@coze-data/knowledge-resource-processor-base';
import { I18n, type I18nKeysNoOptionsType } from '@coze-arch/i18n';
import { formatBytes } from '@coze-arch/bot-utils';
import { IconUploadXLS } from '@coze-arch/bot-icons';
import { type TableType, type TableSheet } from '@coze-arch/bot-api/memory';
import { MemoryApi } from '@coze-arch/bot-api';
import { Typography } from '@coze-arch/coze-design';
type ProcessProps = Pick<
ProcessProgressItemProps,
'mainText' | 'subText' | 'tipText' | 'percent' | 'status' | 'actions'
>;
const INIT_PERCENT = 10;
const COMPLETE_PERCENT = 100;
const statusTextMap: Record<ProcessStatus, I18nKeysNoOptionsType> = {
[ProcessStatus.Processing]: 'datasets_createFileModel_step4_processing',
[ProcessStatus.Complete]: 'datasets_createFileModel_step4_Finish',
[ProcessStatus.Failed]: 'datasets_createFileModel_step4_failed',
};
export interface StepProcessProps {
databaseId: string;
tableType: TableType;
fileItem: UnitItem;
tableSheet?: TableSheet;
connectorId?: string;
}
export function StepProcess({
databaseId,
tableType,
fileItem,
tableSheet,
connectorId,
}: StepProcessProps) {
const fileSize = useMemo(
() => formatBytes(fileItem.fileInstance?.size ?? 0),
[fileItem],
);
const [progressProps, setProgressProps] = useState<ProcessProps>({
// 第一行文本(文件名)
mainText: fileItem.name,
// 第二行文本(文件大小)
subText: fileSize,
// hover 时显示的第二行文本,与上面保持一致
tipText: fileSize,
// 进度条百分比,初始 10% 与 @coze-data/knowledge-resource-processor-base/unit-progress 保持一致
percent: INIT_PERCENT,
status: ProcessStatus.Processing,
});
const { run, cancel } = useRequest(
() =>
MemoryApi.DatabaseFileProgressData({
database_id: databaseId,
table_type: tableType,
}),
{
manual: true,
pollingInterval: 3000,
onSuccess: res => {
const { data } = res;
if (data) {
// 有错误信息代表处理失败,展示错误信息,并停止轮询
if (data.status_descript) {
const msg = data.status_descript;
setProgressProps(props => ({
...props,
subText: msg,
tipText: msg,
status: ProcessStatus.Failed,
}));
cancel();
} else {
setProgressProps(props => ({
...props,
percent: data.progress ?? 0,
}));
// 进度 100 代表处理完成,更新状态并停止轮询
if (data.progress === COMPLETE_PERCENT) {
setProgressProps(props => ({
...props,
status: ProcessStatus.Complete,
actions: [I18n.t('datasets_unit_process_success')],
}));
cancel();
}
}
}
},
},
);
// 提交任务,并开始轮询进度
useEffect(() => {
MemoryApi.SubmitDatabaseInsertTask({
database_id: databaseId,
table_type: tableType,
file_uri: fileItem.uri,
table_sheet: tableSheet,
connector_id: connectorId,
}).finally(() => {
run();
});
}, []);
return (
<>
<div className="h-[32px] leading-[32px] mb-[8px]">
<Typography.Text fontSize="14px" weight={500}>
{I18n.t(statusTextMap[progressProps.status])}
</Typography.Text>
</div>
<ProcessProgressItem
avatar={<IconUploadXLS />}
{...progressProps}
className="[&_.process-progress-item-actions]:!block"
/>
</>
);
}

View File

@@ -0,0 +1,112 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
import classNames from 'classnames';
import {
type UnitItem,
UnitType,
} from '@coze-data/knowledge-resource-processor-core';
import { ActionRenderByDelete } from '@coze-data/knowledge-resource-processor-base/components/upload-unit-table';
import {
UploadUnitFile,
UploadUnitTable,
} from '@coze-data/knowledge-resource-processor-base';
import { I18n } from '@coze-arch/i18n';
import { Typography } from '@coze-arch/coze-design';
import { type TableType } from '@coze-arch/bot-api/memory';
import { MemoryApi } from '@coze-arch/bot-api';
export interface StepUploadProps {
databaseId: string;
tableType: TableType;
unitList: UnitItem[];
onUnitListChange: (list: UnitItem[]) => void;
}
export function StepUpload({
databaseId,
tableType,
unitList,
onUnitListChange,
}: StepUploadProps) {
useEffect(() => {
onUnitListChange(unitList);
}, [onUnitListChange, unitList]);
const downloadTemplate = async () => {
const res = await MemoryApi.GetDatabaseTemplate({
database_id: databaseId,
table_type: tableType,
});
if (res.TosUrl) {
window.open(res.TosUrl, '_blank');
}
};
return (
<>
<UploadUnitFile
unitList={unitList}
setUnitList={onUnitListChange}
onFinish={onUnitListChange}
limit={1}
multiple={false}
accept=".csv,.xlsx"
maxSizeMB={20}
showRetry={false}
dragMainText={I18n.t('datasets_createFileModel_step2_UploadDoc')}
dragSubText={I18n.t('datasets_unit_update_exception_tips3')}
action=""
className={classNames('[&_.semi-upload-drag-area]:!h-[290px]', {
hidden: unitList.length > 0,
})}
showIllustration={false}
/>
<Typography.Paragraph
type="secondary"
className={classNames('mt-[8px]', { hidden: unitList.length > 0 })}
>
{I18n.t('db_optimize_018')}
<Typography.Text link className="ml-[8px]" onClick={downloadTemplate}>
{I18n.t('db_optimize_019')}
</Typography.Text>
</Typography.Paragraph>
<UploadUnitTable
edit={false}
type={UnitType.TABLE_DOC}
unitList={unitList}
onChange={onUnitListChange}
disableRetry
getColumns={(record, index) => ({
actions: [
<ActionRenderByDelete
record={record}
index={index}
params={{
unitList,
onChange: onUnitListChange,
type: UnitType.TABLE_DOC,
edit: false,
}}
/>,
],
})}
/>
</>
);
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type Ref, forwardRef, type FC } from 'react';
import { type ButtonProps } from '@coze-arch/bot-semi/Button';
import { Button } from '@coze-arch/bot-semi';
export type BotDebugButtonProps = ButtonProps & {
readonly: boolean;
};
export const BotDebugButton: FC<BotDebugButtonProps> = forwardRef(
(props: BotDebugButtonProps, ref: Ref<Button>) => {
const { readonly, ...rest } = props;
if (readonly) {
return null;
}
return <Button {...rest} ref={ref} />;
},
);

View File

@@ -0,0 +1,58 @@
.tab {
overflow: hidden;
display: flex;
flex-direction: column;
height: 100%;
:global {
.semi-tabs-bar {
min-height: 56px;
}
.semi-tabs-bar-extra {
display: flex;
flex-direction: row;
align-items: center;
justify-content: end;
width: 100%;
padding: 0;
}
.semi-tabs-content {
overflow: auto;
flex-grow: 1;
padding: 0;
.coz-tab-bar-content.semi-tabs-pane-active {
height: 100%;
.semi-tabs-pane-motion-overlay {
overflow: hidden;
display: flex;
flex-direction: column;
height: 100%;
}
}
}
}
}
.th-tip-dot {
position: relative;
padding-left: 16px;
}
.th-tip-dot::before {
content: '';
position: absolute;
top: 7px;
left: 0;
width: 5px;
height: 5px;
background-color: #000;
border-radius: 50%;
}

View File

@@ -0,0 +1,411 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useEffect, useMemo } from 'react';
import { pick } from 'lodash-es';
import classNames from 'classnames';
import { userStoreService } from '@coze-studio/user-store';
import { type DatabaseInfo as DatabaseInitInfo } from '@coze-studio/bot-detail-store';
import { type WidgetUIState } from '@coze-data/knowledge-stores';
import { BotE2e } from '@coze-data/e2e';
import { DatabaseTabs } from '@coze-data/database-v2-base/types';
import { DismissibleBanner } from '@coze-data/database-v2-base/components/dismissible-banner';
import {
type FormData,
ModalMode,
} from '@coze-data/database-v2-base/components/base-info-modal';
import { DatabaseModeSelect } from '@coze-data/database-v2-adapter/components/database-mode-select';
import { DatabaseCreateTableModal } from '@coze-data/database-v2-adapter/components/create-table-modal';
import { DatabaseBaseInfoModal } from '@coze-data/database-v2-adapter/components/base-info-modal';
import { DatabaseDetailWaring } from '@coze-data/database-v2-adapter';
import { I18n } from '@coze-arch/i18n';
import {
IconCozEdit,
IconCozCross,
IconCozArrowLeft,
} from '@coze-arch/coze-design/icons';
import {
Button,
IconButton,
TabBar,
Toast,
CozAvatar,
Typography,
Space,
} from '@coze-arch/coze-design';
import {
BotTableRWMode,
TableType,
type DatabaseInfo,
type UpdateDatabaseRequest,
} from '@coze-arch/bot-api/memory';
import { MemoryApi } from '@coze-arch/bot-api';
import { DatabaseTableStructureReadonly } from '../database-table-structure-readonly';
import { DatabaseTableData } from '../database-table-data';
import styles from './index.module.less';
export interface DatabaseDetailProps {
version?: string;
needHideCloseIcon?: boolean;
databaseId: string;
enterFrom: string;
initialTab?: `${DatabaseTabs}`;
addRemoveButtonText: string;
onClose?: () => void;
onAfterEditBasicInfo?: () => void;
onAfterEditRecords?: () => void;
onIDECallback?: {
onStatusChange?: (v: WidgetUIState) => void;
onUpdateDisplayName?: (v: string) => void;
};
onClickAddRemoveButton: (databaseId?: string) => void;
}
// eslint-disable-next-line @coze-arch/max-line-per-function, max-lines-per-function, complexity
export const DatabaseDetail = ({
version,
enterFrom,
initialTab,
needHideCloseIcon = false,
addRemoveButtonText,
onClose,
onClickAddRemoveButton,
onIDECallback,
onAfterEditBasicInfo,
onAfterEditRecords,
databaseId,
}: DatabaseDetailProps) => {
const userId = userStoreService.useUserInfo()?.user_id_str;
const [basicInfoVisible, setBasicInfoVisible] = useState(false);
const [createTableVisible, setCreateTableVisible] = useState(false);
// database basicInfo
const [databaseInfo, setDatabaseInfo] = useState<DatabaseInfo>({});
// tab key
const [activeKey, setActiveKey] = useState(
version ? DatabaseTabs.Structure : (initialTab ?? DatabaseTabs.Structure),
);
// btn loading
const [btnLoading, setBtnLoading] = useState(false);
// page loading
const [loading, setLoading] = useState(true);
// fetch database basicInfo
const fetchDatabaseInfo = async () => {
try {
setLoading(true);
const res = await MemoryApi.GetDatabaseByID({
id: databaseId,
...(version ? { version } : {}),
});
if (res.database_info) {
setDatabaseInfo(res.database_info);
if (res.database_info.table_name) {
onIDECallback?.onUpdateDisplayName?.(res.database_info.table_name);
onIDECallback?.onStatusChange?.('normal');
}
} else {
onIDECallback?.onStatusChange?.('error');
}
} catch {
onIDECallback?.onStatusChange?.('error');
} finally {
setLoading(false);
}
};
// 需要一个 store后续改造
const isReadOnlyMode = databaseInfo.creator_id !== userId || !!version;
const tableInitData: DatabaseInitInfo = useMemo(
() => ({
tableId: databaseInfo.id || '',
name: databaseInfo.table_name || '',
desc: databaseInfo.table_desc || '',
icon_uri: databaseInfo.icon_uri || '',
readAndWriteMode: databaseInfo.rw_mode || BotTableRWMode.LimitedReadWrite,
tableMemoryList: databaseInfo.field_list || [],
}),
[databaseInfo],
);
const basicInitData: FormData = useMemo(
() => ({
name: databaseInfo.table_name || '',
description: databaseInfo.table_desc || '',
icon_uri: [
{
url: databaseInfo.icon_url || '',
uri: databaseInfo.icon_uri || '',
uid: databaseInfo.icon_uri || '',
isDefault: true,
},
],
}),
[databaseInfo],
);
const handleEditBasicInfo = async (obj: UpdateDatabaseRequest) => {
const res = await MemoryApi.UpdateDatabase({
...pick(databaseInfo, [
'id',
'icon_uri',
'table_name',
'table_desc',
'field_list',
'rw_mode',
'prompt_disabled',
'extra_info',
]),
...obj,
});
if (res?.database_info?.id) {
await fetchDatabaseInfo();
// update basicInfo callback
if (onAfterEditBasicInfo) {
onAfterEditBasicInfo();
}
// close basicInfo modal
if (basicInfoVisible) {
setBasicInfoVisible(false);
}
} else {
Toast.error('Update database failed');
}
};
const handleChangeDatabaseMode = async (mode: BotTableRWMode) => {
const res = await MemoryApi.UpdateDatabase({
...databaseInfo,
rw_mode: mode,
});
if (res?.database_info?.id) {
await fetchDatabaseInfo();
// update basicInfo callback
if (onAfterEditBasicInfo) {
onAfterEditBasicInfo();
}
}
};
const handleEditTable = async () => {
await fetchDatabaseInfo();
// update basicInfo callback
if (onAfterEditBasicInfo) {
onAfterEditBasicInfo();
}
};
const handleBtnAction = () => {
if (onClickAddRemoveButton) {
try {
setBtnLoading(true);
onClickAddRemoveButton(
enterFrom === 'workflow' ? databaseId : databaseInfo?.draft_id,
);
} finally {
setBtnLoading(false);
}
}
};
useEffect(() => {
fetchDatabaseInfo();
}, []);
const fromLibrary = ['create', 'library'].includes(enterFrom);
return (
<>
<div
className={classNames(
'h-full w-full max-w-[100vw] flex flex-col overflow-hidden',
enterFrom === 'project'
? 'coz-bg-max rounded-b-[8px] border-solid coz-stroke-primary'
: 'coz-bg-plus',
)}
>
{/* header */}
<div
className={classNames(
'flex flex-row items-center justify-between shrink-0',
fromLibrary
? 'h-[40px] m-[24px]'
: 'h-[64px] px-[16px] py-[12px] border-0 border-b border-solid coz-stroke-primary',
)}
>
<div className="flex items-center gap-[8px]">
{needHideCloseIcon ? null : (
<IconButton
color="secondary"
icon={fromLibrary ? <IconCozArrowLeft /> : <IconCozCross />}
onClick={onClose}
/>
)}
<CozAvatar
type="bot"
color="grey"
src={basicInitData.icon_uri?.[0]?.url}
/>
<div className="flex flex-col">
<div className="flex flex-row items-center gap-[2px] leading-none">
<Typography.Text weight={500} fontSize="14px">
{basicInitData.name}
</Typography.Text>
{isReadOnlyMode ? null : (
<IconButton
size="mini"
color="secondary"
icon={<IconCozEdit className="coz-fg-secondary" />}
onClick={() => setBasicInfoVisible(true)}
/>
)}
</div>
<Typography.Text fontSize="12px">
{basicInitData.description}
</Typography.Text>
</div>
</div>
<div className="flex items-center gap-[8px]">
<DatabaseModeSelect
disabled={isReadOnlyMode}
value={databaseInfo.rw_mode}
onChange={handleChangeDatabaseMode}
/>
{enterFrom.includes('bot') || enterFrom === 'workflow' ? (
<Button
disabled={isReadOnlyMode}
loading={btnLoading}
onClick={handleBtnAction}
>
{addRemoveButtonText}
</Button>
) : null}
</div>
</div>
{/* content */}
<div
className={classNames(
'grow overflow-hidden',
fromLibrary ? 'mx-[24px]' : 'mx-[16px]',
)}
>
<TabBar
className={styles.tab}
type="text"
align="left"
tabBarExtraContent={
<Space spacing={16}>
<DatabaseDetailWaring />
{activeKey === DatabaseTabs.Structure ? (
<Button
data-testid={BotE2e.BotDatabaseEditTableStructureBtn}
onClick={() => setCreateTableVisible(true)}
icon={<IconCozEdit />}
color="highlight"
disabled={isReadOnlyMode}
>
{I18n.t('db_new_0003')}
</Button>
) : null}
</Space>
}
tabBarClassName="flex flex-row items-center w-full"
activeKey={activeKey}
onChange={(key: string) => setActiveKey(key as DatabaseTabs)}
lazyRender
>
<TabBar.TabPanel
tab={I18n.t('db_new_0001')}
itemKey={DatabaseTabs.Structure}
>
<DatabaseTableStructureReadonly
loading={loading}
fieldList={databaseInfo.field_list ?? []}
/>
</TabBar.TabPanel>
<TabBar.TabPanel
tab={I18n.t('db_optimize_009')}
itemKey={DatabaseTabs.Draft}
disabled={!!version}
>
<DismissibleBanner
type="info"
persistentKey="_coze_database_draft_data_warning"
>
{I18n.t('db_optimize_010')}
</DismissibleBanner>
<DatabaseTableData
databaseId={databaseId}
tableType={TableType.DraftTable}
tableFields={databaseInfo.field_list || []}
// 测试数据无需控制权限,只要能看到的数据就能修改删除
isReadonlyMode={false}
enterFrom={enterFrom}
onAfterEditRecords={onAfterEditRecords}
/>
</TabBar.TabPanel>
<TabBar.TabPanel
tab={I18n.t('db_new_0002')}
itemKey={DatabaseTabs.Online}
disabled={!!version}
>
<DismissibleBanner
type="info"
persistentKey="_coze_database_online_data_warning"
>
{I18n.t('database_optimize_200')}
</DismissibleBanner>
<DatabaseTableData
databaseId={databaseId}
tableType={TableType.OnlineTable}
tableFields={databaseInfo.field_list || []}
isReadonlyMode={isReadOnlyMode}
enterFrom={enterFrom}
onAfterEditRecords={onAfterEditRecords}
/>
</TabBar.TabPanel>
</TabBar>
</div>
</div>
<DatabaseBaseInfoModal
visible={basicInfoVisible}
onSubmit={formData =>
handleEditBasicInfo({
table_name: formData.name,
icon_uri: formData.icon_uri?.[0]?.uri,
table_desc: formData.description,
})
}
initValues={basicInitData}
mode={ModalMode.EDIT}
onClose={() => setBasicInfoVisible(false)}
/>
<DatabaseCreateTableModal
visible={createTableVisible}
initValue={tableInitData}
onSubmit={handleEditTable}
showDatabaseBaseInfo={false}
onlyShowDatabaseInfoRWMode={true}
onReturn={() => setCreateTableVisible(false)}
onClose={() => setCreateTableVisible(false)}
/>
</>
);
};

View File

@@ -0,0 +1,247 @@
/* stylelint-disable block-no-empty */
/* stylelint-disable declaration-no-important */
/* stylelint-disable no-descending-specificity */
.modal-container {
min-width: 100%;
}
.modal-table-btn {
display: flex;
justify-content: flex-end;
}
.modal-temp {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.modal-temp-right {
position: relative;
display: flex;
flex-direction: column;
height: 409px;
margin-bottom: 24px;
padding: 15px 24px;
background: rgba(255, 255, 255, 100%);
border: 1px solid rgba(29, 28, 35, 8%);
border-radius: 12px;
.modal-temp-title {
font-size: 14px;
font-weight: 600;
line-height: 20px;
color: rgba(29, 28, 35, 100%);
}
.modal-temp-image {
margin-top: 16px;
margin-bottom: 12px;
border-radius: 8px;
}
.modal-temp-description {
height: 64px;
font-size: 12px;
font-weight: 400;
line-height: 16px;
color: rgba(29, 28, 35, 80%);
}
.modal-temp-btn-group {
display: flex;
gap: 12px;
align-items: center;
justify-content: flex-end;
margin-top: 16px;
padding: 8px 0;
.modal-temp-btn {
width: 120px;
}
}
.modal-temp-preview {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 329px;
padding: 16px 21px 24px;
background: rgba(241, 242, 253, 100%);
border-radius: 11px 11px 0 0;
.title {
padding-bottom: 12px;
font-size: 14px;
font-weight: 600;
line-height: 18px;
color: rgba(77, 83, 232, 100%);
}
}
}
.modal-modify-tips {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
padding: 12px;
background: var(--light-usage-warning-light-color-warning-light-default,
#fff8ea);
.tip-icon {
margin: 0 20px 0 17px;
color: rgba(252, 136, 0, 100%);
>svg {
width: 20px;
height: 20px;
}
}
.description {
display: flex;
align-items: center;
margin-right: 65px;
}
.link {
cursor: pointer;
margin-left: 20px;
color: #3370ff;
white-space: nowrap;
}
}
// 新增样式 @zhangyuanzhou.zyz
.entry {
display: flex;
gap: 64px;
align-items: center;
justify-content: center;
height: 409px;
margin-bottom: 24px;
background: rgba(255, 255, 255, 100%);
border: 1px solid rgba(29, 28, 35, 8%);
border-radius: 12px;
.entry-method {
cursor: pointer;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
color: var(--Light-usage-text---color-text-0, #1d1c23);
.entry-method-icon {
width: 24px;
height: 24px;
}
.entry-method-title {
font-size: 12px;
font-weight: 600;
font-style: normal;
line-height: 16px;
}
}
.entry-method:hover {
color: var(--Light-color-brand---brand-5, #4d53e8);
}
}
// 当窗口足够大时高度固定为641px
// 当窗口太小时高度随vh变化333px为内容区距离视窗边缘的距离
.database-table-structure-container {
overflow: auto;
width: 100%;
height: min(641px, calc(100vh - 333px));
}
.title-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.right {
display: flex;
gap: 32px;
align-items: center;
}
}
.generate-ai-popover-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
width: 560px;
padding: 24px;
background-color: rgba(247, 247, 250, 100%);
border-radius: 12px;
.title {
margin-bottom: 16px;
font-size: 18px;
font-weight: 600;
line-height: 24px;
}
.input {}
.button-wrapper {
display: flex;
justify-content: flex-end;
width: 100%;
}
.text-area {
:global {
.semi-input-textarea {
overflow: auto;
max-height: 191px;
}
}
}
}
.popover {
:global {
.semi-popover {
border-radius: 12px;
}
.semi-popover-content {
border-radius: 12px;
}
}
}
.modal-close-button {
height: 24px !important;
padding: 4px !important;
border-radius: 3px !important;
&:hover {
background-color: rgba(46, 50, 56, 5%) !important;
}
}

View File

@@ -0,0 +1,655 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable max-lines */
/* eslint-disable @coze-arch/max-line-per-function */
/* eslint-disable max-lines-per-function */
import { useRef, useMemo, useEffect, useState } from 'react';
import { nanoid } from 'nanoid';
import { useLocalStorageState } from 'ahooks';
import { type DatabaseInfo } from '@coze-studio/bot-detail-store';
import { DataNamespace, dataReporter } from '@coze-data/reporter';
import { BotE2e } from '@coze-data/e2e';
import {
CreateType,
type NL2DBInfo,
type OnSave,
} from '@coze-data/database-v2-base/types';
import { TEMPLATE_INFO } from '@coze-data/database-v2-base/constants';
import {
DatabaseTableStructure,
type DatabaseTableStructureRef,
} from '@coze-data/database-v2-base/components/database-table-structure';
import { FormDatabaseModeSelect } from '@coze-data/database-v2-adapter/components/database-mode-select';
import { REPORT_EVENTS } from '@coze-arch/report-events';
import { I18n, getUnReactiveLanguage } from '@coze-arch/i18n';
import { sendTeaEvent, EVENT_NAMES } from '@coze-arch/bot-tea';
import {
Button,
Image,
Popconfirm,
Icon,
TextArea,
Popover,
UIButton,
Modal,
Form,
Toast,
} from '@coze-arch/bot-semi';
import { IconWarningSize24 } from '@coze-arch/bot-icons';
import {
BotTableRWMode,
type RecommendDataModelResponse,
SceneType,
FieldItemType,
} from '@coze-arch/bot-api/memory';
import { MemoryApi } from '@coze-arch/bot-api';
import {
IconCozWarning as IconAlertTriangle,
IconCozCross as IconClose,
} from '@coze-arch/coze-design/icons';
import { BotDebugButton } from '../bot-debug-button';
import tableTempEN from '../../assets/table-template-en.png';
import tableTempCN from '../../assets/table-template-cn.png';
import tablePreviewEN from '../../assets/table-preview-en.png';
import tablePreviewCN from '../../assets/table-preview-cn.png';
import { ReactComponent as UpArrowSVG } from '../../assets/icon_up-arrow.svg';
import { ReactComponent as DownArrowSvg } from '../../assets/icon_down-arrow.svg';
import { ReactComponent as AddSVG } from '../../assets/icon_add_outlined.svg';
import { ReactComponent as GenerateSVG } from '../../assets/generate.svg';
import s from './index.module.less';
export interface ExpertModeConfig {
isExpertMode: boolean;
maxTableNum: number;
maxColumnNum: number;
readAndWriteModes: BotTableRWMode[];
}
export interface DatabaseModalProps {
visible: boolean;
onCancel: () => void;
database: DatabaseInfo;
botId: string;
spaceId: string;
readonly: boolean;
// eslint-disable-next-line @typescript-eslint/naming-convention -- 历史逻辑
NL2DBInfo: NL2DBInfo | null;
expertModeConfig?: ExpertModeConfig;
onSave?: OnSave;
}
export const DatabaseModal: React.FC<DatabaseModalProps> = props => {
const {
database,
botId,
// spaceId,
readonly,
onCancel,
onSave,
NL2DBInfo,
expertModeConfig,
visible,
} = props;
const [generateTableLoading, setGenerateTableLoading] = useState(false);
const [contentCheckErrorMsg, setContentCheckErrorMsg] = useState<string>('');
const [isEntry, setIsEntry] = useState<boolean>(
!database.tableId && !NL2DBInfo,
);
const [isPreview, setIsPreview] = useState<boolean>(false);
const [isDeletedField, setIsDeletedField] = useState<boolean>(false);
const [
shouldHideDatabaseTableStructureTipsForCurrent,
setShouldHideDatabaseTableStructureTipsForCurrent,
] = useState<boolean>(false);
const [createType, setCreateType] = useState<CreateType>(
NL2DBInfo ? CreateType.recommend : CreateType.custom,
);
const [data, setData] = useState<DatabaseInfo>({
tableId: '',
name: '',
desc: '',
readAndWriteMode: BotTableRWMode.LimitedReadWrite,
tableMemoryList: [],
});
const [AIPopoverVisible, setAIPopoverVisible] = useState(false);
const nlTextAreaRef = useRef<HTMLTextAreaElement>();
const tableStructureRef = useRef<DatabaseTableStructureRef>();
const [
mapOfShouldHidingDatabaseTableStructureTips,
setMapOfShouldHidingDatabaseTableStructureTips,
] = useLocalStorageState<string | undefined>(
// FIXME: 此属性名意义不明确,处为了兼容,暂不修改此属性名,但后续需要使用更明确的命名
'use-local-storage-state-modify-tips',
{
defaultValue: '',
},
);
const language = getUnReactiveLanguage();
const isEdit = Boolean(data.tableId);
const [isReadonly, setIsReadonly] = useState(false);
const handleSave = async () => {
try {
setIsReadonly(true);
// @ts-expect-error -- linter-disable-autofix
await tableStructureRef.current.submit();
} finally {
setIsReadonly(false);
}
};
const hideTableStructureTips = useMemo(() => {
const lsMap = JSON.parse(
mapOfShouldHidingDatabaseTableStructureTips || '{}',
);
return (
!isEdit ||
lsMap?.[botId] ||
shouldHideDatabaseTableStructureTipsForCurrent
);
}, [
isEdit,
shouldHideDatabaseTableStructureTipsForCurrent,
mapOfShouldHidingDatabaseTableStructureTips,
]);
const title = useMemo(() => {
if (isEdit) {
return I18n.t('db_edit_title');
}
if (createType === CreateType.excel) {
return I18n.t('db_table_0126_011');
}
return I18n.t('db_add_table_title');
}, [isEdit, createType]);
const showEntry = isEntry && !isEdit && !NL2DBInfo;
const shouldShowAIGenerate =
/**
* 1. 入口不展示
* 2. 编辑态不展示
* 3. Excel导入时不展示
*/
!showEntry && !isEdit && createType !== CreateType.excel;
const setDataToDefault = () => {
setData({
name: '',
desc: '',
tableId: '',
readAndWriteMode: BotTableRWMode.LimitedReadWrite,
tableMemoryList: [
{
nanoid: nanoid(),
name: '',
desc: '',
type: FieldItemType.Text,
must_required: false,
},
],
});
};
const setTableFieldsListToDefault = () => {
tableStructureRef.current?.setTableFieldsList([
{
nanoid: nanoid(),
name: '',
desc: '',
type: FieldItemType.Text,
must_required: false,
},
]);
};
const onUseTemplate = () => {
setCreateType(CreateType.template);
setIsEntry(false);
setData({
...TEMPLATE_INFO,
});
};
const onUseCustom = () => {
setCreateType(CreateType.custom);
setIsEntry(false);
setDataToDefault();
};
const generateTableByNL = async (text: string, type: SceneType) => {
setGenerateTableLoading(true);
let res: RecommendDataModelResponse | undefined;
try {
res = await MemoryApi.RecommendDataModel({
bot_id: botId,
scene_type: type,
text,
});
} catch (error) {
setGenerateTableLoading(false);
dataReporter.errorEvent(DataNamespace.DATABASE, {
eventName: REPORT_EVENTS.DatabaseNL2DB,
error: error as Error,
});
setDataToDefault();
setTableFieldsListToDefault();
}
if (res?.bot_table_list?.[0]) {
if (type === SceneType.BotPersona) {
setCreateType(CreateType.recommend);
}
if (type === SceneType.ModelDesc) {
setCreateType(CreateType.naturalLanguage);
}
setData({
tableId: '',
// @ts-expect-error -- linter-disable-autofix
name: res.bot_table_list[0].table_name,
// @ts-expect-error -- linter-disable-autofix
desc: res.bot_table_list[0].table_desc,
readAndWriteMode: BotTableRWMode.LimitedReadWrite,
// @ts-expect-error -- linter-disable-autofix
tableMemoryList: res.bot_table_list[0].field_list.map(i => ({
name: i.name,
desc: i.desc,
must_required: i.must_required,
type: i.type,
nanoid: nanoid(),
id: Number(i.id),
})),
});
// data 是初始值,此处需要手动 setState 更新子组件状态
// 若 Modal 已提前关闭,子组件卸载,则 ref 为空,需要加上可选链判断一下
tableStructureRef.current?.setTableFieldsList(
// @ts-expect-error -- linter-disable-autofix
res.bot_table_list[0].field_list.map(i => ({
name: i.name,
desc: i.desc,
must_required: i.must_required,
type: i.type,
nanoid: nanoid(),
id: Number(i.id),
})),
);
} else {
if (type === SceneType.BotPersona) {
Toast.info(I18n.t('recommended_failed'));
setDataToDefault();
setTableFieldsListToDefault();
}
if (type === SceneType.ModelDesc) {
Toast.warning(I18n.t('generate_failed'));
setAIPopoverVisible(true);
}
}
setGenerateTableLoading(false);
};
const handleGenerate = () => {
const generate = () => {
const { value } = nlTextAreaRef.current || {};
if (value) {
generateTableByNL(value, SceneType.ModelDesc);
}
};
sendTeaEvent(EVENT_NAMES.generate_with_ai_click, {
bot_id: botId,
need_login: true,
have_access: true,
});
setAIPopoverVisible(false);
if (
// @ts-expect-error -- linter-disable-autofix
tableStructureRef.current.tableFieldsList.filter(i => i.name).length > 0
) {
Modal.warning({
title: I18n.t('bot_database_ai_replace'),
content: I18n.t('bot_database_ai_replace_detailed'),
okButtonProps: {
type: 'warning',
},
onOk: () => {
generate();
},
maskClosable: false,
icon: <IconWarningSize24 />,
});
} else {
generate();
}
};
useEffect(() => {
setAIPopoverVisible(false);
setIsPreview(false);
setShouldHideDatabaseTableStructureTipsForCurrent(false);
setIsDeletedField(false);
setContentCheckErrorMsg('');
setIsEntry(true);
}, [visible]);
useEffect(() => {
setData(database);
}, [database]);
useEffect(() => {
setCreateType(NL2DBInfo ? CreateType.recommend : CreateType.custom);
}, [NL2DBInfo]);
useEffect(() => {
if (NL2DBInfo && visible) {
generateTableByNL(NL2DBInfo.prompt, SceneType.BotPersona);
}
}, [NL2DBInfo, visible]);
const DefaultFooter = (
<>
{contentCheckErrorMsg ? (
<Form.ErrorMessage error={contentCheckErrorMsg} />
) : null}
{hideTableStructureTips ? null : (
<div className={s['modal-modify-tips']}>
<div className={s.description}>
<IconAlertTriangle className={s['tip-icon']} />
<span style={{ textAlign: 'left' }}>{I18n.t('db_edit_tips1')}</span>
<span
className={s.link}
onClick={() => {
const lsMap = JSON.parse(
mapOfShouldHidingDatabaseTableStructureTips || '{}',
);
lsMap[botId] = true;
setMapOfShouldHidingDatabaseTableStructureTips(
JSON.stringify(lsMap),
);
}}
>
{I18n.t('db_edit_tips2')}
</span>
</div>
<IconClose
onClick={() =>
setShouldHideDatabaseTableStructureTipsForCurrent(true)
}
style={{ cursor: 'pointer' }}
/>
</div>
)}
<div className={s['modal-table-btn']}>
{isDeletedField ? (
<Popconfirm
title={I18n.t('db_del_field_confirm_title')}
content={I18n.t('db_del_field_confirm_info')}
okText={I18n.t('db_del_field_confirm_yes')}
cancelText={I18n.t('db_del_field_confirm_no')}
okType="danger"
onConfirm={handleSave}
>
<BotDebugButton
loading={isReadonly}
theme="solid"
type="primary"
readonly={readonly}
>
{I18n.t('db_edit_save')}
</BotDebugButton>
</Popconfirm>
) : (
<BotDebugButton
readonly={readonly}
loading={isReadonly}
theme="solid"
type="primary"
onClick={handleSave}
>
{I18n.t('db_edit_save')}
</BotDebugButton>
)}
</div>
</>
);
const Entry = (
<div className={s['modal-temp']}>
<div className={s.entry}>
<div
className={s['entry-method']}
onClick={onUseCustom}
data-testid={BotE2e.BotDatabaseAddModalAddCustomBtn}
>
<Icon svg={<AddSVG />} className={s['entry-method-icon']} />
<span className={s['entry-method-title']}>
{I18n.t('db_add_table_cust')}
</span>
</div>
</div>
<div className={s['modal-temp-right']}>
<div
className={s['modal-temp-title']}
data-testid={BotE2e.BotDatabaseAddModalTemplateTitle}
>
{I18n.t('db_add_table_temp_title')}
</div>
<Image
className={s['modal-temp-image']}
height={201}
src={language === 'zh-CN' ? tableTempCN : tableTempEN}
/>
<div className={s['modal-temp-description']}>
💡{I18n.t('db_add_table_temp_tips')}
</div>
{isPreview ? (
<div className={s['modal-temp-preview']}>
<div className={s.title}>
{I18n.t('db_add_table_temp_preview_tips')}
</div>
<Image
height={239}
src={language === 'zh-CN' ? tablePreviewCN : tablePreviewEN}
/>
</div>
) : null}
<div className={s['modal-temp-btn-group']}>
<Button
data-testid={BotE2e.BotDatabaseAddModalPreviewTemplateBtn}
theme="light"
type="tertiary"
onClick={() => setIsPreview(state => !state)}
className={s['modal-temp-btn']}
>
{I18n.t('db_add_table_temp_preview')}
</Button>
<Button
data-testid={BotE2e.BotDatabaseAddModalUseTemplateBtn}
theme="solid"
type="primary"
onClick={onUseTemplate}
className={s['modal-temp-btn']}
>
{I18n.t('db_add_table_temp_use')}
</Button>
</div>
</div>
</div>
);
const getFooter = () => {
if (showEntry) {
return null;
}
if (createType === CreateType.excel) {
return null;
}
return DefaultFooter;
};
const getContent = () => {
if (showEntry) {
return Entry;
}
if (createType === CreateType.excel) {
return null;
}
return (
<div className={s['database-table-structure-container']}>
<DatabaseTableStructure
data={data}
// @ts-expect-error -- linter-disable-autofix
ref={tableStructureRef}
loading={generateTableLoading}
loadingTips={I18n.t('bot_database_ai_waiting')}
botId={botId}
readAndWriteModeOptions={
// @ts-expect-error -- linter-disable-autofix
expertModeConfig.isExpertMode ? 'expert' : 'normal'
}
// @ts-expect-error -- linter-disable-autofix
maxColumnNum={expertModeConfig.maxColumnNum}
onSave={onSave}
onCancel={onCancel}
onDeleteField={list => {
setIsDeletedField(
!database.tableMemoryList.every(i =>
// TODO: 当前field id生成规则有问题故暂时使用 nanoid 替换
list.find(j => j.nanoid === i.nanoid),
),
);
}}
createType={createType}
setContentCheckErrorMsg={setContentCheckErrorMsg}
renderModeSelect={params => <FormDatabaseModeSelect {...params} />}
/>
</div>
);
};
return (
<Modal
visible={visible}
onCancel={onCancel}
closable={false}
width={1138}
centered
footer={getFooter()}
title={
<div className={s['title-wrapper']}>
<div data-testid={BotE2e.BotDatabaseAddModalTitle}>{title}</div>
<div className={s.right}>
{shouldShowAIGenerate ? (
<Popover
trigger="custom"
position="bottomRight"
content={
<div className={s['generate-ai-popover-wrapper']}>
<div
className={s.title}
data-testid={
BotE2e.BotDatabaseAddModalTitleCreateAiModalTitle
}
>
{I18n.t('bot_database_ai_create')}
</div>
<TextArea
data-testid={
BotE2e.BotDatabaseAddModalTitleCreateAiModalDesc
}
autosize
// @ts-expect-error -- linter-disable-autofix
ref={nlTextAreaRef}
rows={1}
placeholder={I18n.t('bot_database_ai_create_tip')}
className={s['text-area']}
/>
<div className={s['button-wrapper']}>
<UIButton
data-testid={
BotE2e.BotDatabaseAddModalTitleCreateAiModalCreateBtn
}
theme="borderless"
onClick={handleGenerate}
icon={<Icon svg={<GenerateSVG />} />}
>
{I18n.t('bot_database_ai_generate')}
</UIButton>
</div>
</div>
}
keepDOM
visible={AIPopoverVisible}
onVisibleChange={_v => {
setAIPopoverVisible(_v);
}}
onClickOutSide={() => {
setAIPopoverVisible(false);
}}
className={s.popover}
>
<UIButton
data-testid={BotE2e.BotDatabaseAddModalTitleCreateAiBtn}
theme="borderless"
icon={
AIPopoverVisible ? (
<Icon svg={<UpArrowSVG />} />
) : (
<Icon svg={<DownArrowSvg />} />
)
}
iconPosition="right"
onClick={() => {
sendTeaEvent(EVENT_NAMES.nl2table_create_table_click, {
bot_id: botId,
need_login: true,
have_access: true,
});
setAIPopoverVisible(true);
}}
>
{I18n.t('bot_database_ai_create')}
</UIButton>
</Popover>
) : null}
<UIButton
data-testid={BotE2e.BotDatabaseAddModalTitleCloseIcon}
icon={<IconClose />}
type="tertiary"
theme="borderless"
onClick={onCancel}
className={s['modal-close-button']}
/>
</div>
</div>
}
maskClosable={false}
>
<div className={s['modal-container']}>{getContent()}</div>
</Modal>
);
};

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozCross, IconCozTrashCan } from '@coze-arch/coze-design/icons';
import { Button, Divider, IconButton, Typography } from '@coze-arch/coze-design';
export interface BatchDeleteToolbarProps {
selectedCount?: number;
onDelete: () => void;
onCancel: () => void;
}
export function BatchDeleteToolbar({
selectedCount = 0,
onDelete,
onCancel,
}: BatchDeleteToolbarProps) {
return (
<div
className={classNames(
'flex items-center p-[8px] gap-[8px] rounded-[12px]',
'coz-bg-max border-solid coz-stroke-primary coz-shadow-default',
'fixed bottom-[8px] left-[50%] translate-x-[-50%] z-10',
{ hidden: selectedCount <= 0 },
)}
>
<Typography.Text type="secondary">
{I18n.t('db_optimize_031', { n: selectedCount })}
</Typography.Text>
<Divider layout="vertical" />
<Button color="red" icon={<IconCozTrashCan />} onClick={onDelete}>
{I18n.t('db_optimize_030')}
</Button>
<Divider layout="vertical" />
<IconButton
color="secondary"
icon={<IconCozCross />}
onClick={onCancel}
/>
</div>
);
}

View File

@@ -0,0 +1,260 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import classNames from 'classnames';
import { type TableMemoryItem } from '@coze-studio/bot-detail-store';
import {
SYSTEM_FIELDS,
SYSTEM_FIELD_ROW_INDEX,
} from '@coze-data/database-v2-base/constants';
import { DatabaseFieldTitle } from '@coze-data/database-v2-base/components/database-field-title';
import { I18n } from '@coze-arch/i18n';
import { FieldItemType } from '@coze-arch/bot-api/memory';
import { IconCozEdit, IconCozTrashCan } from '@coze-arch/coze-design/icons';
import {
type ColumnProps,
IconButton,
Popconfirm,
Space,
Typography,
} from '@coze-arch/coze-design';
import { type TableRow, type TableField, type TableFieldData } from './type';
export function formatTableStructList(
structList: TableMemoryItem[],
): TableFieldData[] {
return structList.map(item => ({
fieldName: item.name ?? '',
fieldDescription: item.desc ?? '',
required: item.must_required ?? false,
type: item.type ?? FieldItemType.Text,
}));
}
export function formatTableDataRow(
structList: TableFieldData[],
dataRow: Record<string, string>[],
): TableRow[] {
return dataRow.map(_data => {
const dataRowFieldList = Object.keys(_data);
const formattedDataRow: TableRow = {};
dataRowFieldList.forEach(_key => {
const structItem = structList.find(i => i.fieldName === _key);
if (!structItem) {
// 系统字段
formattedDataRow[_key] = {
fieldName: _key,
type: FieldItemType.Text,
required: true,
value: _data[_key as unknown as number],
};
return;
}
switch (structItem.type) {
case FieldItemType.Boolean:
formattedDataRow[_key] = {
fieldName: _key,
value: _data[_key as unknown as number] as unknown as boolean,
type: FieldItemType.Boolean,
required: structItem.required,
};
break;
case FieldItemType.Number:
formattedDataRow[_key] = {
fieldName: _key,
value: _data[_key as unknown as number] as unknown as number,
type: FieldItemType.Number,
required: structItem.required,
};
break;
case FieldItemType.Date:
formattedDataRow[_key] = {
fieldName: _key,
value: _data[_key as unknown as number] as unknown as string,
type: FieldItemType.Date,
required: structItem.required,
};
break;
case FieldItemType.Float:
formattedDataRow[_key] = {
fieldName: _key,
value: _data[_key as unknown as number] as unknown as string,
type: FieldItemType.Float,
required: structItem.required,
};
break;
case FieldItemType.Text:
formattedDataRow[_key] = {
fieldName: _key,
value: _data[_key as unknown as number] as unknown as string,
type: FieldItemType.Text,
required: structItem.required,
};
break;
default:
break;
}
});
return formattedDataRow;
});
}
const SystemFieldWidth: Record<string, number | undefined> = {
id: 200,
sys_platform: 180,
uuid: 260,
bstudio_create_time: 200,
};
interface GetTableColumnsParams {
fieldList: TableFieldData[];
connectorNames: Record<string, string>;
isReadonlyMode: boolean;
handleEditRow: (row: TableRow) => void;
handleDeleteRow: (row: TableRow) => void;
}
interface DatabaseTableCellProps {
value?: string | number | boolean;
}
function DatabaseTableCell({ value }: DatabaseTableCellProps) {
const stringValue = value?.toString() ?? '';
return (
<Typography.Text
ellipsis={{
showTooltip: {
opts: {
className: classNames(
'[&_.semi-tooltip-content]:max-h-[110px]',
'[&_.semi-tooltip-content]:line-clamp-5',
),
},
},
}}
>
{stringValue}
</Typography.Text>
);
}
/**
* 获取 Table Field 表头数据
*/
export const getTableColumns = ({
fieldList,
connectorNames,
isReadonlyMode,
handleEditRow,
handleDeleteRow,
}: GetTableColumnsParams) => {
const columns: ColumnProps<TableRow>[] = [];
// 系统字段列
columns.push(
...SYSTEM_FIELDS.map(item => ({
title: () => (
<DatabaseFieldTitle
field={item.name}
type={item.type}
tip={item.desc}
required
/>
),
dataIndex: SYSTEM_FIELD_ROW_INDEX[item.name ?? ''],
width: SystemFieldWidth[item.name ?? ''] ?? 260,
render: (field: TableField) =>
field.fieldName === 'bstudio_connector_id' ? (
<Typography.Text ellipsis>
{connectorNames[field.value as string] ?? field.value}
</Typography.Text>
) : (
<DatabaseTableCell value={field.value} />
),
})),
);
// 用户字段列
columns.push(
...fieldList.map(item => ({
title: () => (
<DatabaseFieldTitle
field={item.fieldName}
type={item.type}
tip={item.fieldDescription}
required={item.required}
/>
),
dataIndex: item.fieldName,
width: 260,
render: (field: TableField) => <DatabaseTableCell value={field?.value} />,
})),
);
// 操作列
columns.push({
title: I18n.t('db_table_0126_021'),
width: 100,
resize: false,
fixed: 'right',
render: (_: TableField, row: TableRow, _index: number) =>
isReadonlyMode ? (
<Space>
<IconButton
disabled
icon={<IconCozEdit />}
size="small"
color="secondary"
/>
<IconButton
disabled
icon={<IconCozTrashCan />}
size="small"
color="secondary"
/>
</Space>
) : (
<Space>
<IconButton
icon={<IconCozEdit />}
size="default"
color="secondary"
onClick={() => handleEditRow(row)}
/>
<Popconfirm
title={I18n.t('db_optimize_026')}
content={I18n.t('db_optimize_027')}
okText={I18n.t('db_optimize_028')}
okButtonColor="red"
cancelText={I18n.t('db_optimize_029')}
onConfirm={() => handleDeleteRow(row)}
>
<IconButton
icon={<IconCozTrashCan />}
size="default"
color="secondary"
/>
</Popconfirm>
</Space>
),
});
return columns;
};

View File

@@ -0,0 +1,149 @@
/* stylelint-disable max-nesting-depth */
/* stylelint-disable no-descending-specificity */
.table {
position: relative;
overflow: hidden;
flex-grow: 1;
.table-wrapper {
:global {
.semi-table-wrapper {
line-height: unset;
}
// 横向滚动到最左/最右边时,隐藏左/右固定列的阴影
.semi-table-scroll-position-left .semi-table-tbody>.semi-table-row>.semi-table-cell-fixed-left-last,
.semi-table-scroll-position-left .semi-table-thead>.semi-table-row>.semi-table-cell-fixed-left-last,
.semi-table-scroll-position-right .semi-table-tbody>.semi-table-row>.semi-table-cell-fixed-right-first,
.semi-table-scroll-position-right .semi-table-thead>.semi-table-row>.semi-table-cell-fixed-right-first {
box-shadow: none;
}
// 左边最后一个固定列的阴影
.semi-table-row>.semi-table-cell-fixed-left-last {
// 因为 overflow: hidden 对 display: table-row 元素无效,
// 所以使阴影 y 轴方向偏移 2px ,避免本行的阴影遮挡上一行元素,同时下一行元素的背景色可以遮挡本行的阴影
box-shadow: 2px 2px 3px rgb(0 0 0 / 8%);
}
// 右边第一个固定列的阴影
.semi-table-row>.semi-table-cell-fixed-right-first {
box-shadow: -2px 2px 3px rgb(0 0 0 / 8%);
}
// 重置表格行 hover 时的鼠标指针
.semi-table-tbody>.semi-table-row {
cursor: default;
}
// 右边固定列不需要 text-align: right
.semi-table-thead > .semi-table-row > .semi-table-row-head:last-child,
.semi-table-tbody > .semi-table-row > .semi-table-row-cell:last-child {
text-align: unset;
}
// 去掉表头高度限制
.coz-table-list .semi-table-fixed-header table {
height: unset;
}
// 表头高度对齐设计稿
.semi-table-thead>.semi-table-row>.semi-table-row-head {
height: 28px;
padding-bottom: 0;
}
/** table header样式 **/
.semi-table-thead {
// 拖拽列宽度的图标样式
.semi-table-row {
.react-resizable-handle {
background: transparent;
}
}
&:hover {
.react-resizable:not(.semi-table-cell-fixed-left, .resizing, .not-resize-handle) {
.react-resizable-handle {
width: 7px;
border-right: 2px solid var(--coz-stroke-plus);
border-left: 1px solid var(--coz-stroke-plus);
}
}
}
// 拖拽列宽时的高亮右边框
&>.semi-table-row>.semi-table-row-head.resizing {
border-right-color: var(--coz-stroke-hglt);
border-right-width: 1px;
}
// 去掉左边固定列的右边框
&>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-left-last {
border-right: 0;
}
// 去掉右边固定列的左边框
&>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-right-first {
border-left: 0;
}
}
/** table body部分样式 **/
.semi-table-tbody {
.semi-table-row {
>.semi-table-row-cell {
// 修复行高,对齐设计稿
height: 56px;
// 拖拽列宽时的高亮右边框
&.resizing {
border-right-color: var(--coz-stroke-hglt);
}
}
// 去掉左边固定列的右边框
>.semi-table-cell-fixed-left-last {
border-right: 0;
}
// 去掉右边固定列的左边框
>.semi-table-cell-fixed-right-first {
border-left: 0;
}
// 去掉固定列未在 hover 状态时的奇怪圆角
&:not(:hover) {
>.semi-table-row-cell.semi-table-cell-fixed-left {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
>.semi-table-row-cell.semi-table-cell-fixed-right {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
}
}
.semi-table-pagination-outer {
height: 48px;
min-height: unset;
}
}
}
}
.table-wrapper-project {
:global {
// 适配 Project IDE 中白色背景 table 样式
.coz-table-list .semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-left::before,
.coz-table-list .semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-right::before,
.coz-table-list .semi-table-thead>.semi-table-row>.semi-table-row-head,
.coz-table-list .semi-table-tbody>.semi-table-row:not(:hover)>.semi-table-row-cell {
background-color: var(--coz-bg-max);
}
}
}

View File

@@ -0,0 +1,288 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useMemo, useRef, useEffect } from 'react';
import classNames from 'classnames';
import { useRequest } from 'ahooks';
import { IllustrationNoContent } from '@douyinfe/semi-illustrations';
import { I18n } from '@coze-arch/i18n';
import { type TableType, type FieldItem } from '@coze-arch/bot-api/memory';
import { MemoryApi } from '@coze-arch/bot-api';
import {
Modal,
Table,
Divider,
Typography,
CozPagination,
Empty,
} from '@coze-arch/coze-design';
import { RowEditModal } from '../row-edit-modal';
import { resizeFn } from '../../utils/table';
import { useConnectorOptions } from '../../hooks/use-connector-options';
import { type TableRow } from './type';
import { ToolButtonsBar } from './tool-buttons-bar';
import {
formatTableDataRow,
formatTableStructList,
getTableColumns,
} from './formatter';
import { BatchDeleteToolbar } from './batch-delete-toolbar';
import styles from './index.module.less';
interface DatabaseTableDataProps {
databaseId: string;
tableType: TableType;
tableFields: FieldItem[];
isReadonlyMode: boolean;
enterFrom?: string;
onAfterEditRecords?: () => void;
}
// eslint-disable-next-line @coze-arch/max-line-per-function
export function DatabaseTableData({
databaseId,
tableType,
tableFields,
isReadonlyMode,
enterFrom,
onAfterEditRecords,
}: DatabaseTableDataProps) {
const fields = useMemo(
() => formatTableStructList(tableFields),
[tableFields],
);
const [pageSize, setPageSize] = useState(20);
const [currentPage, setCurrentPage] = useState(1);
const [totalRecords, setTotalRecords] = useState(0);
const [dataRows, setDataRows] = useState<Record<string, string>[]>([]);
const { loading, refresh } = useRequest(
() =>
MemoryApi.ListDatabaseRecords({
database_id: databaseId,
table_type: tableType,
offset: (currentPage - 1) * pageSize,
limit: pageSize,
}),
{
onSuccess: res => {
setTotalRecords(res.TotalNum);
setDataRows(res.data);
},
refreshDeps: [databaseId, tableType, pageSize, currentPage],
},
);
const tableDataSource = useMemo(
() => formatTableDataRow(fields, dataRows),
[fields, dataRows],
);
const afterEdit = () => {
refresh();
onAfterEditRecords?.();
};
const connectorOptions = useConnectorOptions({ includeMigrated: true });
const connectorNames = useMemo(
() =>
Object.fromEntries(
connectorOptions.map(item => [item.value, item.label]),
),
[connectorOptions],
);
const [selectedRows, setSelectedRows] = useState<TableRow[]>([]);
const handleBatchDelete = () =>
Modal.confirm({
title: I18n.t('db_optimize_026'),
content: I18n.t('db_optimize_027'),
okText: I18n.t('dialog_240305_03'),
okButtonColor: 'red',
cancelText: I18n.t('dialog_240305_04'),
onOk: async () => {
await MemoryApi.UpdateDatabaseRecords({
database_id: databaseId,
table_type: tableType,
record_data_delete: selectedRows.map(row => ({
bstudio_id: row.bstudio_id.value as string,
})),
});
setSelectedRows([]);
afterEdit();
},
});
const [rowEditModelVisible, setRowEditModelVisible] = useState(false);
const [editingRow, setEditingRow] = useState<TableRow>();
const handleEditRow = (row?: TableRow) => {
setEditingRow(row);
setRowEditModelVisible(true);
};
const handleRowEditSubmit = async (
values: Record<string, string>,
originalConnectorId?: string,
) => {
if (!originalConnectorId) {
await MemoryApi.UpdateDatabaseRecords({
database_id: databaseId,
table_type: tableType,
record_data_add: [values],
});
} else {
await MemoryApi.UpdateDatabaseRecords({
database_id: databaseId,
table_type: tableType,
record_data_alter: [values],
// 编辑行时,要带上原始的 connector_id后端需要判断数据是否来自/目标为“豆包”渠道
ori_connector_id: originalConnectorId,
});
}
setRowEditModelVisible(false);
setEditingRow(undefined);
afterEdit();
};
const handleDeleteRow = async (row: TableRow) => {
await MemoryApi.UpdateDatabaseRecords({
database_id: databaseId,
table_type: tableType,
record_data_delete: [
{
bstudio_id: row.bstudio_id.value as string,
},
],
});
afterEdit();
};
const tableFieldColumns = useMemo(
() =>
getTableColumns({
fieldList: fields,
isReadonlyMode,
connectorNames,
handleDeleteRow,
handleEditRow,
}),
[fields, isReadonlyMode, connectorNames],
);
const [tableHeight, setTableHeight] = useState(0);
const tableWrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new ResizeObserver(entires => {
for (const e of entires) {
if (e.target === tableWrapperRef.current) {
setTableHeight(e.contentRect.height);
}
}
});
if (tableWrapperRef.current) {
observer.observe(tableWrapperRef.current);
}
return () => observer.disconnect();
}, []);
return (
<div className={styles.table} ref={tableWrapperRef}>
<ToolButtonsBar
readonly={isReadonlyMode}
databaseId={databaseId}
tableType={tableType}
tableFields={fields}
onNewRow={() => handleEditRow()}
onRefresh={refresh}
/>
<Table
tableProps={{
loading,
columns: tableFieldColumns,
dataSource: tableDataSource,
rowSelection: {
fixed: true,
selectedRowKeys: selectedRows.map(
r => r.bstudio_id?.value as string,
),
onChange: (_, rows) => setSelectedRows(rows ?? []),
},
resizable: {
onResize: resizeFn,
},
rowKey: (record: TableRow) => record?.bstudio_id?.value as string,
scroll: {
// 128 = ToolButtonsBar(52) + 表头(28) + Pagination(48)
y: tableHeight > 128 ? tableHeight - 128 : 'auto',
},
pagination: {
total: totalRecords,
currentPage,
pageSize,
onChange: (current, size) => {
setCurrentPage(current);
setPageSize(size);
setSelectedRows([]);
},
},
renderPagination: paginationProps => (
<div className="w-full flex gap-[8px] items-center justify-end">
<Typography.Text type="secondary" fontSize="12px">
{I18n.t('db_optimize_032', { n: totalRecords })}
</Typography.Text>
<Divider layout="vertical" className="h-[16px]" />
<CozPagination
size="small"
showSizeChanger
pageSizeOpts={[20, 50, 100]}
{...paginationProps}
/>
</div>
),
}}
wrapperClassName={classNames(styles['table-wrapper'], {
// database 数据表格在 Project IDE 中要使用 coz-bg-max 白色背景
[styles['table-wrapper-project']]: enterFrom === 'project',
})}
empty={
<Empty
image={<IllustrationNoContent className="w-[140px] h-[140px]" />}
title={I18n.t('timecapsule_0108_003')}
/>
}
indexRowSelection
/>
<BatchDeleteToolbar
selectedCount={selectedRows.length}
onDelete={handleBatchDelete}
onCancel={() => setSelectedRows([])}
/>
<RowEditModal
fields={fields}
visible={rowEditModelVisible}
tableType={tableType}
initialValues={editingRow}
onSubmit={handleRowEditSubmit}
onCancel={() => setRowEditModelVisible(false)}
/>
</div>
);
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type CSSProperties, type HTMLAttributes } from 'react';
import { CSS } from '@dnd-kit/utilities';
import { useSortable } from '@dnd-kit/sortable';
/**
* 拆分自 packages/data/database-v2/src/components/database-table-data/index.tsx
* 原本实现基本是从 Semi 文档复制过来的排序后的数据也没有提交给服务端PM 似乎也不知道有这个功能,所以 ...
* @see
*/
export const SortableRow = (
// https://github.com/DouyinFE/semi-design/blob/v2.69.2/packages/semi-ui/table/Body/BaseRow.tsx#L396
// eslint-disable-next-line @typescript-eslint/naming-convention -- semi 没有导出 table row props 的类型
sortProps: HTMLAttributes<HTMLTableRowElement> & { 'data-row-key': string },
) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: sortProps['data-row-key'],
});
const style: CSSProperties = {
...sortProps.style,
transform: CSS.Transform.toString(transform),
transition,
cursor: isDragging ? 'grabbing' : 'grab',
zIndex: isDragging ? 1 : undefined,
position: isDragging ? 'relative' : undefined,
};
return (
<tr
{...sortProps}
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
/>
);
};

View File

@@ -0,0 +1,218 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type TableMemoryItem } from '@coze-studio/bot-detail-store';
import { FieldItemType } from '@coze-arch/bot-api/memory';
import { type TestDataRow } from './type';
export const testStructList: TableMemoryItem[] = [
{
nanoid: 'id1',
name: 'city',
desc: 'city',
must_required: true,
type: FieldItemType.Text,
},
{
nanoid: 'id2',
name: 'level',
desc: 'level',
must_required: true,
type: FieldItemType.Text,
},
{
nanoid: 'id3',
name: 't_gdp',
desc: 't_gdp',
must_required: true,
type: FieldItemType.Number,
},
{
nanoid: 'id4',
name: 'p_gdp',
desc: 'p_gdp',
must_required: true,
type: FieldItemType.Float,
},
{
nanoid: 'id7',
name: 'international_trade_gdp',
desc: 'p_gdp',
must_required: true,
type: FieldItemType.Float,
},
{
nanoid: 'id8',
name: 'international_trade_p_gdp',
desc: 'p_gdp',
must_required: true,
type: FieldItemType.Float,
},
{
nanoid: 'id5',
name: 'is_allowed',
desc: 'is_allowed',
must_required: true,
type: FieldItemType.Boolean,
},
{
nanoid: 'id6',
name: 'update_time',
desc: 'update_time',
must_required: true,
type: FieldItemType.Date,
},
];
export const testData: TestDataRow[] = [
[
{
field_name: 'city',
value: '北京',
},
{
field_name: 'level',
value: '一线',
},
{
field_name: 't_gdp',
value: 10000,
},
{
field_name: 'p_gdp',
value: 10000.1,
},
{
field_name: 'is_allowed',
value: true,
},
{
field_name: 'update_time',
value: '2023-08-23 12:00:00',
},
{
field_name: 'international_trade_gdp',
value: 10000,
},
{
field_name: 'international_trade_p_gdp',
value: 10000.1,
},
],
[
{
field_name: 'city',
value: '上海',
},
{
field_name: 'level',
value: '一线',
},
{
field_name: 't_gdp',
value: 20000,
},
{
field_name: 'p_gdp',
value: 20000.1,
},
{
field_name: 'is_allowed',
value: false,
},
{
field_name: 'update_time',
value: '2023-08-23 12:30:00',
},
{
field_name: 'international_trade_gdp',
value: 10000,
},
{
field_name: 'international_trade_p_gdp',
value: 10000.1,
},
],
[
{
field_name: 'city',
value: '深圳',
},
{
field_name: 'level',
value: '一线',
},
{
field_name: 't_gdp',
value: 30000,
},
{
field_name: 'p_gdp',
value: 30000.1,
},
{
field_name: 'is_allowed',
value: true,
},
{
field_name: 'update_time',
value: '2023-08-23 12:20:00',
},
{
field_name: 'international_trade_gdp',
value: 10000,
},
{
field_name: 'international_trade_p_gdp',
value: 10000.1,
},
],
[
{
field_name: 'city',
value: '广州',
},
{
field_name: 'level',
value: '一线',
},
{
field_name: 't_gdp',
value: 40000,
},
{
field_name: 'p_gdp',
value: 40000.1,
},
{
field_name: 'is_allowed',
value: false,
},
{
field_name: 'update_time',
value: '2023-08-23 14:00:00',
},
{
field_name: 'international_trade_gdp',
value: 10000,
},
{
field_name: 'international_trade_p_gdp',
value: 10000.1,
},
],
];

View File

@@ -0,0 +1,164 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState } from 'react';
import { I18n } from '@coze-arch/i18n';
import { TableType } from '@coze-arch/bot-api/memory';
import { MemoryApi } from '@coze-arch/bot-api';
import {
IconCozArrowDown,
IconCozImport,
IconCozPlus,
IconCozRefresh,
IconCozTrashCan,
} from '@coze-arch/coze-design/icons';
import { Button, Dropdown, Modal } from '@coze-arch/coze-design';
import { BatchImportModal } from '../batch-import-modal';
import { useConnectorOptions } from '../../hooks/use-connector-options';
import { type TableFieldData } from './type';
export interface ToolButtonsBarProps {
readonly: boolean;
databaseId: string;
tableType: TableType;
tableFields: TableFieldData[];
onNewRow: () => void;
onRefresh: () => void;
}
export function ToolButtonsBar({
readonly,
databaseId,
tableType,
tableFields,
onNewRow,
onRefresh,
}: ToolButtonsBarProps) {
const [connectorDropdownVisible, setConnectorDropdownVisible] =
useState(false);
const [batchImportVisible, setBatchImportVisible] = useState(false);
const [batchImportConnectorId, setBatchImportConnectorId] = useState<
string | undefined
>();
const connectorOptions = useConnectorOptions();
const showBatchImportModal = (connectorId?: string) => {
setConnectorDropdownVisible(false);
setBatchImportVisible(true);
setBatchImportConnectorId(connectorId);
};
const handleClearDatabase = () =>
Modal.confirm({
title: I18n.t('dialog_240305_01'),
content: I18n.t('dialog_240305_02'),
okText: I18n.t('dialog_240305_03'),
okButtonColor: 'red',
cancelText: I18n.t('dialog_240305_04'),
onOk: async () => {
await MemoryApi.ResetBotTable({
database_info_id: databaseId,
table_type: tableType,
});
onRefresh();
},
});
return (
<div className="flex gap-[8px] mt-[8px] mb-[12px]">
<Button
color="secondary"
icon={<IconCozPlus className={readonly ? '' : 'coz-fg-hglt'} />}
disabled={readonly}
onClick={onNewRow}
>
<span className={readonly ? '' : 'coz-fg-hglt'}>
{I18n.t('db_optimize_022')}
</span>
</Button>
{tableType === TableType.DraftTable ? (
<Button
color="secondary"
icon={<IconCozImport />}
disabled={readonly}
onClick={() => showBatchImportModal()}
>
{I18n.t('db_optimize_013')}
</Button>
) : (
<Dropdown
trigger="custom"
visible={connectorDropdownVisible}
onClickOutSide={() => setConnectorDropdownVisible(false)}
position="bottomLeft"
render={
<>
<Dropdown.Title className="pl-[32px] border-0 border-b border-solid coz-stroke-primary">
{I18n.t('database_optimize_100')}
</Dropdown.Title>
<div className="min-w-[170px] max-h-[220px] overflow-auto">
<Dropdown.Menu>
{connectorOptions.map(item => (
<Dropdown.Item
key={item.value}
onClick={() => showBatchImportModal(item.value)}
>
{item.label}
</Dropdown.Item>
))}
</Dropdown.Menu>
</div>
</>
}
>
<Button
color="secondary"
icon={<IconCozImport />}
disabled={readonly}
onClick={() => setConnectorDropdownVisible(true)}
>
<span>{I18n.t('db_optimize_013')}</span>
<IconCozArrowDown className="ml-[4px]" />
</Button>
</Dropdown>
)}
<div className="ml-auto"></div>
{tableType === TableType.DraftTable ? (
<Button
color="secondary"
icon={<IconCozTrashCan />}
disabled={readonly}
onClick={handleClearDatabase}
>
{I18n.t('db_optimize_011')}
</Button>
) : null}
<Button color="secondary" icon={<IconCozRefresh />} onClick={onRefresh}>
{I18n.t('db_optimize_012')}
</Button>
<BatchImportModal
visible={batchImportVisible}
databaseId={databaseId}
tableFields={tableFields}
tableType={tableType}
connectorId={batchImportConnectorId}
onClose={() => setBatchImportVisible(false)}
onComplete={onRefresh}
/>
</div>
);
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type TableMemoryItem } from '@coze-studio/bot-detail-store';
import { type FieldItemType } from '@coze-arch/bot-api/memory';
// 期待的数据结构是什么样的?
export interface TableRowCommonData {
fieldName: string;
required: boolean;
}
export type TableRowInferData =
| {
type: FieldItemType.Boolean;
value: boolean | string;
}
| {
type: FieldItemType.Date;
value: string;
}
| {
type: FieldItemType.Float;
value: string;
}
| {
type: FieldItemType.Number;
value: number;
}
| {
type: FieldItemType.Text;
value: string;
};
export type TableField = TableRowCommonData & TableRowInferData;
export type TableRow = Record<string, TableField>;
export enum RowInternalStatus {
Normal,
UnSubmit,
Error,
}
export enum RowServiceStatus {
Deleted,
Normal,
Shield,
}
export interface TableRowData {
rowData: TableRow;
status: RowServiceStatus;
internalStatus: RowInternalStatus;
}
export type TableList = TableRowData[];
export interface TableFieldData {
fieldName: string;
fieldDescription: string;
required: boolean;
type: FieldItemType;
}
export interface TableData {
fieldList: TableFieldData[];
dataList: TableList;
}
export interface FormatTableDataProps {
structList: TableMemoryItem[];
dataRow: Array<Record<string, string>>;
}
export interface TestDataStruct {
field_name: string;
value: string | number | boolean;
}
export type TestDataRow = TestDataStruct[];
export interface ChangeDataParams {
// rowKey: string;
// fieldName: string;
// value: string | number | boolean;
newRowData: TableRow;
}
export interface DeleteDataParams {
rowKey: string;
}

View File

@@ -0,0 +1,28 @@
.table-structure-wrapper {
overflow: auto;
height: 100%;
:global {
.coz-table-spin {
text-align: center;
}
.semi-table-container .semi-table-row {
.semi-table-row-head,
.semi-table-row-cell {
padding: 6px 8px;
}
.semi-table-row-head {
border-bottom-width: 1px;
}
.semi-table-row-cell {
height: 56px;
font-weight: 500;
background: none;
border-bottom: 0;
}
}
}
}

View File

@@ -0,0 +1,151 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect, useRef, useState } from 'react';
import { type TableMemoryItem } from '@coze-studio/bot-detail-store';
import {
FIELD_TYPE_OPTIONS,
SYSTEM_FIELDS,
} from '@coze-data/database-v2-base/constants';
import { DatabaseFieldTitle } from '@coze-data/database-v2-base/components/database-field-title';
import { I18n } from '@coze-arch/i18n';
import { Image, Table, type ColumnProps } from '@coze-arch/coze-design';
import keyExample from '../../assets/key-example.png';
import s from './index.module.less';
function getTableStructureColumns(): ColumnProps<TableMemoryItem>[] {
// 字段表头内容来自 ../database-table-structure/index.tsx:578
return [
{
title: (
<DatabaseFieldTitle
field={I18n.t('db_add_table_field_name')}
tip={
<article className="w-[494px]">
<p className="mb-[8px]">
{I18n.t('db_add_table_field_name_tips')}
</p>
<Image
preview={false}
width={494}
height={163}
src={keyExample}
/>
</article>
}
/>
),
dataIndex: 'name',
width: 261,
},
{
title: (
<DatabaseFieldTitle
field={I18n.t('db_add_table_field_desc')}
tip={
<article className="w-[327px]">
{I18n.t('db_add_table_field_desc_tips')}
</article>
}
/>
),
dataIndex: 'desc',
},
{
title: (
<DatabaseFieldTitle
field={I18n.t('db_add_table_field_type')}
tip={
<article className="w-[327px]">
{I18n.t('db_add_table_field_type_tips')}
</article>
}
/>
),
dataIndex: 'type',
width: 214,
render: (_, record) =>
FIELD_TYPE_OPTIONS.find(i => i.value === record.type)?.label ??
record.type,
},
{
title: (
<DatabaseFieldTitle
field={I18n.t('db_add_table_field_necessary')}
tip={
<article className="w-[327px]">
<p>{I18n.t('db_add_table_field_necessary_tips1')}</p>
<p>{I18n.t('db_add_table_field_necessary_tips2')}</p>
</article>
}
/>
),
dataIndex: 'must_required',
width: 108,
render: (_, record) =>
I18n.t(record.must_required ? 'db_optimize_037' : 'db_optimize_038'),
},
];
}
export interface DatabaseTableStructureReadonlyProps {
loading?: boolean;
fieldList: TableMemoryItem[];
}
export function DatabaseTableStructureReadonly({
loading,
fieldList,
}: DatabaseTableStructureReadonlyProps) {
const columns = getTableStructureColumns();
const dataSource = SYSTEM_FIELDS.concat(fieldList);
const [tableHeight, setTableHeight] = useState(0);
const tableWrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new ResizeObserver(entries => {
for (const e of entries) {
if (e.target === tableWrapperRef.current) {
setTableHeight(e.contentRect.height);
}
}
});
if (tableWrapperRef.current) {
observer.observe(tableWrapperRef.current);
}
return () => observer.disconnect();
}, []);
return (
<div className="h-full mt-[8px]" ref={tableWrapperRef}>
<Table
tableProps={{
loading,
columns,
dataSource,
scroll: {
// 表头的高度是 40px
y: tableHeight > 40 ? tableHeight - 40 : 'auto',
},
}}
className={s['table-structure-wrapper']}
/>
</div>
);
}

View File

@@ -0,0 +1,11 @@
.date {
:global {
.semi-select:hover {
@apply coz-bg-max !important;
}
.coz-date-picker-select {
width: 100%;
}
}
}

View File

@@ -0,0 +1,163 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC, useState, useRef } from 'react';
import { isEmpty, cloneDeep } from 'lodash-es';
import { format } from 'date-fns';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { type BaseDatePicker, DatePicker } from '@coze-arch/coze-design';
import {
type ChangeDataParams,
type TableRow,
} from '../../database-table-data/type';
import styles from './index.module.less';
interface IProps {
rowData: TableRow;
value: string | undefined;
rowKey: string;
fieldName: string;
required: boolean;
onChange?: (params: ChangeDataParams) => void;
disabled: boolean;
}
const formatValue = (dValue: string | Date | Date[] | string[] | undefined) => {
let formattedValue = '';
if (!dValue) {
return '';
}
try {
if (dValue instanceof Date) {
// 单个Date对象
formattedValue = format(dValue, 'yyyy-MM-dd HH:mm:ss');
} else if (Array.isArray(dValue)) {
// Date[] 或 string[]
formattedValue = dValue
.map(item => {
if (item instanceof Date) {
return format(item, 'yyyy-MM-dd HH:mm:ss');
} else if (typeof item === 'string') {
// 假设字符串为有效日期格式
return format(new Date(item), 'yyyy-MM-dd HH:mm:ss');
}
return '';
})
.join(', '); // 使用逗号分隔不同的日期
} else if (typeof dValue === 'string') {
// 单个字符串
formattedValue = format(new Date(dValue), 'yyyy-MM-dd HH:mm:ss');
}
} catch {
formattedValue = '';
}
return formattedValue;
};
export const EditKitDatePicker: FC<IProps> = props => {
const { value, onChange, fieldName, required, rowData, disabled } = props;
const [clicked, setClicked] = useState(false);
const [internalValue, setIntervalValue] = useState(formatValue(value));
const ref = useRef<BaseDatePicker>(null);
const handlePlaceholderClick = () => {
if (disabled) {
return;
}
setClicked(true);
setTimeout(() => {
ref.current?.focus();
ref.current?.open();
}, 50);
};
const handleInputBlur = () => {
setClicked(false);
};
const handleChange = (
newValue: string | Date | Date[] | string[] | undefined,
) => {
const formattedValue = formatValue(newValue);
setIntervalValue(formattedValue);
const newRowData = cloneDeep(rowData);
newRowData[fieldName].value = formattedValue;
onChange?.({
newRowData,
});
};
const showRequiredTips = required && isEmpty(internalValue);
if (disabled) {
return (
<div className="w-full h-[32px] cursor-not-allowed rounded-[8px] px-[8px] flex items-center border-[1px] border-solid border-transparent">
<span
className={'text-[14px] leading-[20px] truncate coz-fg-secondary'}
>
{internalValue}
</span>
</div>
);
}
if (!clicked) {
return (
<div
className="w-full h-[32px] rounded-[8px] px-[8px] flex items-center hover:coz-mg-secondary-hovered cursor-pointer border-[1px] border-solid border-transparent"
onClick={handlePlaceholderClick}
>
<span
className={classNames('text-[14px] leading-[20px] truncate', {
'coz-fg-secondary': !showRequiredTips,
'coz-fg-hglt-red': showRequiredTips,
})}
>
{showRequiredTips ? I18n.t('db2_008') : internalValue}
</span>
</div>
);
}
return (
<DatePicker
type="dateTime"
value={internalValue}
onChange={handleChange}
onBlur={handleInputBlur}
timePickerOpts={{
scrollItemProps: { cycled: false },
}}
ref={ref}
showPrefix={false}
showSuffix={false}
className={classNames(
'w-full !coz-bg-max rounded-[8px] hover:!coz-bg-max',
styles.date,
)}
disabled={disabled}
/>
);
};

View File

@@ -0,0 +1,7 @@
.input {
:global {
.semi-input-wrapper,.semi-input-wrapper:hover {
@apply coz-bg-max !important;
}
}
}

View File

@@ -0,0 +1,165 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, type FC, useRef } from 'react';
import { cloneDeep, isUndefined } from 'lodash-es';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Input, InputNumber } from '@coze-arch/coze-design';
import {
type ChangeDataParams,
type TableRow,
} from '../../database-table-data/type';
import styles from './index.module.less';
interface IProps {
rowData: TableRow;
value: React.ReactText | undefined;
type: 'string' | 'float' | 'integer';
rowKey: string;
fieldName: string;
required: boolean;
onChange?: (params: ChangeDataParams) => void;
disabled?: boolean;
}
export const EditKitInput: FC<IProps> = props => {
const {
value,
type,
fieldName,
onChange,
required,
rowData,
disabled = false,
} = props;
const [clicked, setClicked] = useState(false);
const [internalValue, setInternalValue] = useState(value);
const handleChange = (newValue: React.ReactText) => {
setInternalValue(newValue);
};
const ref = useRef<HTMLInputElement>(null);
const handlePlaceholderClick = () => {
if (disabled) {
return;
}
setClicked(true);
setTimeout(() => {
ref.current?.focus();
}, 50);
};
const handleInputBlur = () => {
const newRowData = cloneDeep(rowData);
newRowData[fieldName].value = internalValue || '';
onChange?.({
newRowData,
});
setClicked(false);
};
const showRequiredTips =
required && (isUndefined(internalValue) || internalValue === '');
if (disabled) {
return (
<div className="w-full h-[32px] rounded-[8px] cursor-not-allowed px-[8px] flex items-center border-[1px] border-solid border-transparent">
<span
className={'text-[14px] leading-[20px] truncate coz-fg-secondary'}
>
{internalValue}
</span>
</div>
);
}
if (!clicked) {
return (
<div
className={`w-full h-[32px] rounded-[8px] px-[8px] flex items-center border-[1px] border-solid border-transparent ${
disabled
? 'cursor-not-allowed'
: 'hover:coz-mg-secondary-hovered cursor-pointer'
}`}
onClick={handlePlaceholderClick}
>
<span
className={classNames('text-[14px] leading-[20px] truncate', {
'coz-fg-secondary': !showRequiredTips,
'coz-fg-dim': showRequiredTips,
})}
>
{showRequiredTips ? I18n.t('db2_008') : internalValue}
</span>
</div>
);
}
if (type === 'float') {
return (
<InputNumber
value={internalValue}
onChange={handleChange}
ref={ref}
onBlur={handleInputBlur}
keepFocus={true}
max={Number.MAX_SAFE_INTEGER}
min={Number.MIN_SAFE_INTEGER}
hideButtons={true}
className={classNames('w-full', styles.input)}
disabled={disabled}
/>
);
}
if (type === 'integer') {
return (
<InputNumber
value={internalValue}
onChange={handleChange}
precision={0}
ref={ref}
onBlur={handleInputBlur}
keepFocus={true}
max={Number.MAX_SAFE_INTEGER}
min={Number.MIN_SAFE_INTEGER}
hideButtons={true}
className={classNames('w-full', styles.input)}
disabled={disabled}
/>
);
}
return (
<Input
value={internalValue}
onChange={handleChange}
ref={ref}
onBlur={handleInputBlur}
className={classNames('w-full', styles.input)}
disabled={disabled}
/>
);
};

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC, useState } from 'react';
import { cloneDeep } from 'lodash-es';
import { Switch } from '@coze-arch/coze-design';
import {
type ChangeDataParams,
type TableRow,
} from '../../database-table-data/type';
interface IProps {
rowData: TableRow;
checked: boolean | undefined;
rowKey: string;
fieldName: string;
required: boolean;
disabled: boolean;
onChange?: (params: ChangeDataParams) => void;
}
export const EditKitSwitch: FC<IProps> = props => {
const { checked, onChange, fieldName, rowData, disabled } = props;
const [internalValue, setInternalValue] = useState(checked);
const handleChange = (isChecked: boolean) => {
setInternalValue(isChecked);
const newRowData = cloneDeep(rowData);
newRowData[fieldName].value = isChecked;
onChange?.({
newRowData,
});
};
return (
<Switch
disabled={disabled}
checked={internalValue}
onChange={handleChange}
size="small"
/>
);
};

View File

@@ -0,0 +1,117 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, type FC, useRef } from 'react';
import { isUndefined, cloneDeep } from 'lodash-es';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { TextArea } from '@coze-arch/coze-design';
import {
type ChangeDataParams,
type TableRow,
} from '../../database-table-data/type';
interface IProps {
rowData: TableRow;
value: string | undefined;
rowKey: string;
fieldName: string;
required: boolean;
disabled: boolean;
onChange?: (params: ChangeDataParams) => void;
}
export const EditKitTextarea: FC<IProps> = props => {
const { value, fieldName, onChange, required, rowData, disabled } = props;
const [clicked, setClicked] = useState(false);
const [internalValue, setInternalValue] = useState(value);
const handleChange = (newValue: string) => {
setInternalValue(newValue);
};
const ref = useRef<HTMLTextAreaElement>(null);
const handlePlaceholderClick = () => {
setClicked(true);
setTimeout(() => {
ref.current?.focus();
}, 50);
};
const handleInputBlur = () => {
const newRowData = cloneDeep(rowData);
newRowData[fieldName].value = internalValue || '';
onChange?.({
newRowData,
});
setClicked(false);
};
const showRequiredTips =
required && (isUndefined(internalValue) || internalValue === '');
if (disabled) {
return (
<div className="w-full h-[32px] cursor-not-allowed rounded-[8px] px-[8px] flex items-center border-[1px] border-solid border-transparent">
<span
className={'text-[14px] leading-[20px] truncate coz-fg-secondary'}
>
{internalValue}
</span>
</div>
);
}
if (!clicked) {
return (
<div
className="w-full h-[32px] rounded-[8px] px-[8px] flex items-center hover:coz-mg-secondary-hovered cursor-pointer border-[1px] border-solid border-transparent"
onClick={handlePlaceholderClick}
>
<span
className={classNames('text-[14px] leading-[20px] truncate', {
'coz-fg-secondary': !showRequiredTips,
'coz-fg-dim': showRequiredTips,
})}
>
{showRequiredTips ? I18n.t('db2_008') : internalValue}
</span>
</div>
);
}
return (
<TextArea
disabled={disabled}
value={internalValue}
onChange={handleChange}
ref={ref}
onBlur={handleInputBlur}
className={classNames('w-full !coz-bg-max')}
rows={1}
autosize={{
minRows: 1,
maxRows: 5,
}}
/>
);
};

View File

@@ -0,0 +1,280 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect, useRef, useState } from 'react';
import dayjs from 'dayjs';
import classNames from 'classnames';
import { type TableMemoryItem } from '@coze-studio/bot-detail-store';
import {
PLATFORM_FIELD,
SYSTEM_FIELD_ROW_INDEX,
} from '@coze-data/database-v2-base/constants';
import { DatabaseFieldTitle } from '@coze-data/database-v2-base/components/database-field-title';
import { I18n } from '@coze-arch/i18n';
import { FieldItemType, TableType } from '@coze-arch/bot-api/memory';
import {
CozInputNumber,
type DatePickerProps,
DatePicker,
Form,
TextArea,
Modal,
Select,
withField,
type CommonFieldProps,
} from '@coze-arch/coze-design';
import {
type TableRow,
type TableFieldData,
} from '../database-table-data/type';
import { isInInt64Range } from '../../utils/is-in-int64-range';
import { useConnectorOptions } from '../../hooks/use-connector-options';
const FormTextArea = withField(TextArea);
const FormInputNumber = withField(CozInputNumber);
const FormDatePicker = withField(
(
props: Omit<DatePickerProps, 'onChange'> & {
onChange?: (dateString: string) => void;
},
) => (
<DatePicker
{...props}
type="dateTime"
// Semi DatePicker 使用 date-fns 格式
format="yyyy-MM-dd HH:mm:ss"
onChange={date =>
props.onChange?.(dayjs(date as Date).format('YYYY-MM-DD HH:mm:ss'))
}
/>
),
);
const FormSelect = withField(Select);
function tableRowToFormValues(row: TableRow): Record<string, string> {
return Object.fromEntries(
Object.values(row).map(field => [
field.fieldName,
field.value?.toString() ?? '',
]),
);
}
function stringifyFormValues(
values: Record<string, string | number | boolean>,
) {
return Object.fromEntries(
Object.entries(values).map(([key, value]) => [
key,
value?.toString() ?? '',
]),
);
}
export interface RowEditModalProps {
visible: boolean;
fields: TableFieldData[];
tableType?: TableType;
initialValues?: TableRow;
onSubmit: (
values: Record<string, string>,
originalConnectorId?: string,
) => Promise<void>;
onCancel: () => void;
}
export function RowEditModal({
visible,
fields,
tableType,
initialValues,
onSubmit,
onCancel,
}: RowEditModalProps) {
const isAdd = typeof initialValues !== 'object';
const [isSubmitting, setIsSubmitting] = useState(false);
const formRef = useRef<Form>(null);
useEffect(() => {
if (visible && initialValues) {
formRef.current?.formApi?.setValues(tableRowToFormValues(initialValues));
}
}, [visible, initialValues]);
const connectorOptions = useConnectorOptions();
return (
<Modal
visible={visible}
title={I18n.t(isAdd ? 'db_optimize_022' : 'db_optimize_023')}
okText={I18n.t(isAdd ? 'db_optimize_025' : 'db_edit_save')}
okButtonProps={{ loading: isSubmitting }}
onOk={async () => {
setIsSubmitting(true);
try {
const values = await formRef.current?.formApi?.validate();
if (values) {
await onSubmit(
Object.assign(
initialValues ? tableRowToFormValues(initialValues) : {},
stringifyFormValues(values),
),
initialValues?.bstudio_connector_id?.value as string | undefined,
);
}
} finally {
setIsSubmitting(false);
}
}}
cancelText={I18n.t('db_optimize_024')}
onCancel={() => {
onCancel();
formRef.current?.formApi?.reset();
}}
>
<Form<Record<string, unknown>> allowEmpty ref={formRef}>
{tableType === TableType.OnlineTable ? (
// 只有“线上数据”支持修改“渠道”字段
<FormSelect
{...getSystemFieldCommonProps(PLATFORM_FIELD)}
optionList={connectorOptions}
className="w-full"
/>
) : null}
{fields.map(field => {
const commonProps = getUserFieldCommonProps(field);
switch (field.type) {
case FieldItemType.Text: {
return (
<FormTextArea
{...commonProps}
autosize={{ minRows: 1, maxRows: 5 }}
/>
);
}
case FieldItemType.Number: {
return (
<Form.Input
{...commonProps}
className={classNames(
'w-full',
'[&_.semi-input-wrapper]:coz-stroke-plus',
'focus-within:[&_.semi-input-wrapper]:coz-stroke-hglt',
'[&_.semi-input-wrapper.semi-input-wrapper-error]:coz-stroke-hglt-red',
)}
validate={value => {
if (!isInInt64Range(value?.toString() ?? '')) {
return 'invalid Integer';
}
return '';
}}
/>
);
}
case FieldItemType.Date: {
return (
<FormDatePicker
{...commonProps}
className={classNames(
'w-full',
'[&_.semi-datepicker-input]:w-full',
'[&_.coz-date-picker-select]:w-full',
'[&[aria-invalid]_.coz-date-picker-select]:coz-stroke-hglt-red',
)}
/>
);
}
case FieldItemType.Float: {
return (
<FormInputNumber
{...commonProps}
className={classNames(
'w-full',
'[&_.semi-input-wrapper]:coz-stroke-plus',
'focus-within:[&_.semi-input-wrapper]:coz-stroke-hglt',
'[&_.semi-input-wrapper.semi-input-wrapper-error]:coz-stroke-hglt-red',
)}
validate={value => {
if (Number.isNaN(value) || Math.abs(value) === Infinity) {
return 'invalid Float';
}
return '';
}}
/>
);
}
case FieldItemType.Boolean: {
return (
<FormSelect
{...commonProps}
optionList={[
{ value: 'true', label: 'true' },
{ value: 'false', label: 'false' },
]}
className="w-full"
/>
);
}
default: {
return null;
}
}
})}
</Form>
</Modal>
);
}
type FieldCommonProps = React.Attributes & CommonFieldProps;
function getSystemFieldCommonProps(field: TableMemoryItem): FieldCommonProps {
return {
key: field.name,
field: SYSTEM_FIELD_ROW_INDEX[field.name ?? ''] ?? '',
label: (
<DatabaseFieldTitle
field={field.name}
textType="primary"
type={field.type}
tip={field.desc}
required
/>
),
};
}
function getUserFieldCommonProps(field: TableFieldData): FieldCommonProps {
return {
key: field.fieldName,
field: field.fieldName,
rules: [{ required: field.required }],
label: {
text: (
<DatabaseFieldTitle
field={field.fieldName}
textType="primary"
type={field.type}
tip={field.fieldDescription}
required={field.required}
/>
),
// DatabaseFieldTitle 中已经显示 required * 符号
required: false,
},
};
}

View File

@@ -0,0 +1,186 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
/* stylelint-disable declaration-no-important */
/* stylelint-disable font-family-no-missing-generic-family-keyword */
.select {
:global {
.semi-select:hover,
.semi-select:active {
background-color: transparent !important;
}
.semi-select-focus, .semi-select-open {
border: none;
outline: none;
}
.semi-select-selection .semi-select-selection-text {
font-weight: 600!important;
}
}
}
.bottom-shadow {
background: linear-gradient(180deg, rgba(249, 249, 249, 0%) 0%, rgba(249, 249, 249, 100%) 100%);
}
.label {
font-size: 14px;
font-weight: 600;
font-style: normal;
line-height: 20px; /* 142.857% */
color: var(--Fg-COZ-fg-secondary, rgba(6, 7, 9, 50%));
}
.tips-wrapper {
border-radius: 12px;
}
.tip-title {
margin-bottom: 10px;
/* COZText12Bold */
font-size: 12px;
font-weight: 500;
font-style: normal;
line-height: 16px; /* 133.333% */
color: var(--Fg-COZ-fg-plus, #FFF);
}
.tip-desc {
margin: 8px 0;
/* COZText12Regular */
font-size: 12px;
font-weight: 400;
font-style: normal;
line-height: 16px; /* 133.333% */
color: rgba(255, 255, 255, 39%);
}
.bot-bg {
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
align-self: stretch;
padding: 16px 12px;
background: var(--Mg-COZ-mg-primary, rgba(255, 255, 255, 6%));
border-radius: var(--default, 8px);
}
.bot-item {
display: flex;
align-items: center;
justify-content: start;
}
.bot-img {
display: flex;
align-items: center;
justify-content: center;
width: 24px !important;
height: 24px !important;
margin-right: 8px;
padding: 8px;
font-family: "SF Pro Display";
font-size: 8px;
font-weight: 500;
font-style: normal;
line-height: 7.254px; /* 90.677% */
color: #FFF;
border-radius: 50%;
&.img-user {
background-color: var(--Fg-COZ-fg-color-blue, #0084FF);
}
&.img-bot {
background-color: var(--Fg-COZ-fg-color-cyan, #00B9B5);
}
}
.bot-content {
width: 100%;
padding: 8px 12px;
/* COZText10Regular */
font-size: 10px;
font-weight: 400;
font-style: normal;
line-height: 14px; /* 140% */
color: var(--Fg-COZ-fg-primary, rgba(255, 255, 255, 79%));
border-radius: var(--default, 8px);
&.content-user {
background: var(--Mg-COZ-mg-hglt-plus-dim, rgba(94, 94, 255, 37%));
}
&.content-bot {
background: var(--Mg-COZ-mg-plus, rgba(255, 255, 255, 9%));
}
}
.loading-more,
.no-more {
position: relative;
display: flex;
grid-column: 1 / -1;
align-items: center;
justify-content: center;
width: 100%;
padding: 13px 0;
font-size: 14px;
font-weight: 400;
line-height: 20px;
color: var(--light-usage-text-color-text-2,
var(--light-usage-text-color-text-2, rgb(28 31 35 / 60%)));
}
.database-add {
min-width: 48px;
min-height: 30px;
max-height: 30px;
font-size: 12px;
font-weight: 500;
font-style: normal;
line-height: 16.5px; /* 137.5% */
color: var(--coz-fg-primary) !important;
background-color: var(--coz-mg-primary) !important;
}
.database-added {
min-width: 48px;
min-height: 30px;
max-height: 30px;
font-size: 12px;
font-weight: 500;
font-style: normal;
line-height: 16.5px; /* 137.5% */
color: var(--coz-fg-dim) !important;
background-color: var(--coz-mg-primary) !important;
&.added-mousein {
color: var(--light-color-red-red-5, #ff441e) !important;
}
}
.list {
:global(.semi-spin-children) {
height: 100%;
}
}

View File

@@ -0,0 +1,599 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable max-lines */
import React, {
useState,
type FC,
useRef,
useEffect,
type ReactNode,
} from 'react';
import { debounce } from 'lodash-es';
import classNames from 'classnames';
import { useInfiniteScroll } from 'ahooks';
import { IllustrationNoContent } from '@douyinfe/semi-illustrations';
import { IconSpin } from '@douyinfe/semi-icons';
import { userStoreService } from '@coze-studio/user-store';
import { type DatabaseInfo as DatabaseInitInfo } from '@coze-studio/bot-detail-store';
import { DatabaseCreateTableModal } from '@coze-data/database-v2-adapter/components/create-table-modal';
import { getUnReactiveLanguage, I18n } from '@coze-arch/i18n';
import {
Image,
UICompositionModal,
UICompositionModalMain,
UICompositionModalSider,
} from '@coze-arch/bot-semi';
import { IconCozArrowDown } from '@coze-arch/bot-icons';
import {
BotTableRWMode,
type DatabaseInfo,
TableType,
SortDirection,
type SingleDatabaseResponse,
} from '@coze-arch/bot-api/memory';
import { FormatType } from '@coze-arch/bot-api/knowledge';
import { MemoryApi, KnowledgeApi } from '@coze-arch/bot-api';
import {
Button,
Dropdown,
Input,
Tag,
Popover,
Spin,
Select,
Empty,
} from '@coze-arch/coze-design';
import { useLibraryCreateDatabaseModal } from '../../hooks/use-library-create-database-modal';
import tipsTemplateEN from '../../assets/tips-template-en.png';
import tipsTemplateCN from '../../assets/tips-template-cn.png';
import SiderCategory from './sider-category';
import { DatabaseListItem } from './items';
import styles from './index.module.less';
interface SelectDatabaseModalProps {
visible: boolean;
onClose: () => void;
onAddDatabase: (id: string, addCallback?: () => void) => void;
onRemoveDatabase?: (id: string, removeCallback?: () => void) => void;
onClickDatabase: (id: string) => void;
onCreateDatabase?: (id: string, draftId: string) => void;
enterFrom: string;
botId?: string;
workflowId?: string;
spaceId: string;
workflowAddList?: string[];
projectID?: string;
tips?: ReactNode;
}
interface GetDatabaseListData {
list: DatabaseInfo[];
nextOffset: number;
total: number;
hasMore: boolean | undefined;
}
enum ModalMode {
CUSTOMIZE = 'customize',
TEMPLATE = 'template',
}
// eslint-disable-next-line @coze-arch/max-line-per-function, max-lines-per-function
export const useSelectDatabaseModal = ({
visible,
onClose,
onAddDatabase,
onRemoveDatabase,
onClickDatabase,
onCreateDatabase,
enterFrom,
botId,
spaceId,
workflowAddList = [],
projectID,
tips,
}: SelectDatabaseModalProps) => {
const language = getUnReactiveLanguage();
const userInfo = userStoreService.useUserInfo();
const scrollRef = useRef<HTMLDivElement>(null);
const [category, setCategory] = useState<'library' | 'project'>(
projectID ? 'project' : 'library',
);
// dropdown visible
const [dropdownVisible, setDropdownVisible] = useState<boolean>(false);
// whether there is a shadow on th bottom
const [showBottomShadow, setShowBottomShadow] = useState(true);
// filter creator
const [filterCreator, setFilterCreator] = useState<string>('all');
// sotr method
const [sort, setSort] = useState<string>('create_time');
// search value
const [keyword, setKeyword] = useState<string>('');
// create table init value
const [initValue, setInitValue] = useState<DatabaseInitInfo>({
tableId: '',
name: '',
desc: '',
icon_uri: '',
readAndWriteMode: BotTableRWMode.LimitedReadWrite,
tableMemoryList: [],
});
// modal visible
const [createVisible, setCreateVisible] = useState<boolean>(false);
const fetchDatabaseList = async (reqParams: {
key_word: string;
filter_creator: string;
page_offset: number;
sort_by: string;
}) => {
const { key_word, filter_creator, page_offset, sort_by } = reqParams;
const res = await MemoryApi.ListDatabase({
...(category === 'project' ? { project_id: projectID } : {}),
bot_id: enterFrom === 'bot' ? botId : '0',
space_id: spaceId,
table_type:
enterFrom === 'bot' ? TableType.DraftTable : TableType.OnlineTable,
table_name: key_word,
creator_id: filter_creator === 'all' ? '0' : filter_creator,
// 暂时不做分页加载
limit: 50,
offset: page_offset,
order_by: [
{
field: sort_by,
direction: SortDirection.Desc,
},
],
});
return {
list: res.database_info_list || [],
nextOffset: page_offset + 1,
total: res.total_count as number,
hasMore: res.has_more,
};
};
const { loading, data, loadingMore, reload } = useInfiniteScroll(
(newData?: GetDatabaseListData): Promise<GetDatabaseListData> =>
fetchDatabaseList({
key_word: keyword,
filter_creator: filterCreator,
page_offset: newData?.nextOffset || 0,
sort_by: sort,
}),
{
manual: true,
// true meas there is more data
isNoMore: newData => Boolean(!newData?.total || !newData.hasMore),
reloadDeps: [keyword, filterCreator, sort, category, projectID],
target: scrollRef,
},
);
// onScroll 判断 scrollRef 是否触底
const handleScroll = () => {
if (!scrollRef.current) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const scrollBottom = scrollHeight - (scrollTop + clientHeight);
if (scrollBottom < 1) {
setShowBottomShadow(false);
} else {
setShowBottomShadow(true);
}
};
const handleAddDatabase = (item: DatabaseInfo) => {
if (onAddDatabase && item.id) {
onAddDatabase?.(item.id, reload);
}
};
const handleRemoveDatabase = (item: DatabaseInfo) => {
if (onRemoveDatabase && item.id) {
onRemoveDatabase?.(item.id, reload);
}
};
const handleClickDatabase = (item: DatabaseInfo) => {
if (onClickDatabase && item.id) {
onClickDatabase?.(item.id);
}
};
const openCreateTableModal = (mode: ModalMode) => {
if (mode === ModalMode.TEMPLATE) {
setInitValue({
...initValue,
name: 'reading_notes',
desc: 'for saving reading notes',
readAndWriteMode: BotTableRWMode.LimitedReadWrite,
extra_info: {
prompt_disabled: 'true',
},
tableMemoryList: [
{
name: 'name',
desc: '',
type: 1,
must_required: true,
},
{
name: 'section',
desc: '',
type: 2,
must_required: true,
},
{
name: 'note',
desc: '',
type: 1,
must_required: true,
},
],
});
} else {
setInitValue({
tableId: '',
name: '',
desc: '',
readAndWriteMode: BotTableRWMode.LimitedReadWrite,
tableMemoryList: [],
});
}
setCreateVisible(true);
};
const renderTemplateTips = () => (
<div className={styles['tips-wrapper']}>
<div className={styles['tip-title']}>{I18n.t('db2_018')}:</div>
<p className="my-[8px]">
💡 <em className={styles['tip-desc']}>{I18n.t('db2_019')}:</em>
</p>
<Image
height={136}
src={language === 'zh-CN' ? tipsTemplateCN : tipsTemplateEN}
/>
<div className={styles['tip-title']}>{I18n.t('db2_020')}:</div>
<div className={styles['bot-bg']}>
<div className={classNames(styles['bot-item'], 'mb-[12px]')}>
<div className={classNames(styles['bot-img'], styles['img-user'])}>
{I18n.t('db2_021')}
</div>
<div
className={classNames(
styles['bot-content'],
styles['content-user'],
)}
>
{I18n.t('db2_022')}
</div>
</div>
<div className={styles['bot-item']}>
<div className={classNames(styles['bot-img'], styles['img-bot'])}>
{I18n.t('db2_023')}
</div>
<div
className={classNames(styles['bot-content'], styles['content-bot'])}
>
{I18n.t('db2_024')}
</div>
</div>
</div>
</div>
);
const renderInput = () => (
<Input
placeholder={I18n.t('db2_014')}
className="w-full"
value={keyword}
onChange={debounce(v => {
setKeyword(v);
}, 500)}
/>
);
const renderFilter = () => (
<div className="flex flex-row items-center w-full justify-between pr-[12px]">
<div className={classNames(styles.select, 'flex flex-row flex-1')}>
<div className="flex flex-row items-center">
<Select
showArrow
size="default"
className="border-none ml-[4px] hover:border-none bg-transparent outline-none"
value={filterCreator}
onChange={v => setFilterCreator(v as string)}
insetLabel={<p className={styles.label}>{I18n.t('db2_009')}</p>}
>
<Select.Option value={'all'} label={I18n.t('db2_010')} />
{userInfo ? (
<Select.Option
value={userInfo.user_id_str}
label={userInfo.name}
key={userInfo.user_id_str}
/>
) : null}
</Select>
</div>
<div className="flex flex-row items-center ml-[12px]">
<Select
showArrow
size="default"
className="border-none ml-[4px] hover:border-none bg-transparent outline-none"
value={sort}
onChange={v => setSort(v as string)}
insetLabel={<p className={styles.label}>{I18n.t('db2_011')}</p>}
>
<Select.Option value="create_time" label={I18n.t('db2_012')} />
<Select.Option value="update_time" label={I18n.t('db2_013')} />
</Select>
</div>
</div>
</div>
);
const renderList = () => (
<div
className="overflow-y-auto relative h-full"
ref={scrollRef}
onScroll={handleScroll}
>
{/* FIXME: 这里需要根据实际做渲染 */}
{data?.list.map((item, index) => (
<DatabaseListItem
icon={item.icon_url}
title={item.table_name}
description={item.table_desc}
isAdd={
enterFrom === 'workflow'
? Boolean(
item.id &&
workflowAddList?.length &&
workflowAddList?.includes(item.id),
)
: Boolean(item.is_added_to_bot)
}
onClick={() => handleClickDatabase(item)}
onAdd={() => handleAddDatabase(item)}
onRemove={() => handleRemoveDatabase(item)}
key={index}
/>
))}
{loadingMore ? (
<div className={styles['loading-more']}>
<IconSpin spin style={{ marginRight: '4px' }} />
<div>{I18n.t('Loading')}</div>
</div>
) : null}
</div>
);
const renderEmpty = () => (
<div className="overflow-y-auto relative w-full h-full flex justify-center items-center">
<Empty
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
/>
</div>
);
const handleClose = () => {
onClose();
};
const handleJumpDatabase = (res: SingleDatabaseResponse) => {
handleClose();
const { id, draft_id } = res.database_info ?? {};
if (id && draft_id) {
onCreateDatabase?.(id, draft_id);
}
};
const fetchDefaultIcon = async () => {
const res = await KnowledgeApi.GetIcon({
format_type: FormatType.Database,
});
if (res.icon?.uri) {
setInitValue({
...initValue,
icon_uri: res.icon?.uri,
});
}
};
useEffect(() => {
if (visible) {
reload();
fetchDefaultIcon();
}
}, [visible]);
const {
modal: createDatabaseModal,
open: openCreateDatabaseModal,
close: closeCreateDatabaseModal,
} = useLibraryCreateDatabaseModal({
projectID,
enterFrom: 'library',
onFinish: (databaseID, draftId) => {
closeCreateDatabaseModal();
onCreateDatabase?.(databaseID, draftId);
},
});
const renderContent = () => (
<>
{tips}
<Spin
spinning={loading}
wrapperClassName={classNames(['overflow-hidden', styles.list])}
>
{data?.list.length !== 0 ? renderList() : renderEmpty()}
</Spin>
{showBottomShadow ? (
<div
className={classNames(
styles['bottom-shadow'],
'w-full h-[80px] absolute left-0 bottom-0',
'pointer-events-none',
)}
></div>
) : null}
</>
);
const renderDatabase = () => (
<React.Fragment>
{createDatabaseModal}
<UICompositionModal
closable
visible={visible}
onCancel={handleClose}
header={I18n.t('db2_025')}
filter={renderFilter()}
sider={
<UICompositionModalSider className="!pt-[16px]">
<UICompositionModalSider.Header className="mb-[16px] gap-[12px]">
{renderInput()}
<Dropdown
trigger="custom"
visible={dropdownVisible}
render={
<Dropdown.Menu className="w-[196px]">
<Dropdown.Item
className="!pl-[8px]"
onClick={() => {
setDropdownVisible(false);
openCreateDatabaseModal();
}}
>
{I18n.t('db2_015')}
</Dropdown.Item>
<Dropdown.Item
className="!pl-[8px] [&_.coz-item-text]:w-full"
onClick={() => {
setDropdownVisible(false);
openCreateTableModal(ModalMode.TEMPLATE);
}}
>
<div className="flex justify-between">
<span>{I18n.t('db2_016')}</span>
<Popover
style={{
maxWidth: '460px',
backgroundColor: 'var(--Bg-COZ-bg-max, #363D4D)',
boxShadow:
'0 4px 12px 0 rgba(0, 0, 0, 8%), 0 8px 24px 0 rgba(0, 0, 0, 4%)',
}}
trigger="hover"
content={renderTemplateTips()}
zIndex={9999}
showArrow
>
<Tag
color="primary"
size="small"
className="ml-[8px]"
>
{I18n.t('db2_017')}
</Tag>
</Popover>
</div>
</Dropdown.Item>
</Dropdown.Menu>
}
onClickOutSide={() => {
setDropdownVisible(false);
}}
>
<Button
color="brand"
iconPosition="right"
icon={<IconCozArrowDown />}
onClick={() => setDropdownVisible(true)}
>
{I18n.t('db_add_table_title')}
</Button>
</Dropdown>
</UICompositionModalSider.Header>
<UICompositionModalSider.Content className="flex flex-col gap-[4px]">
<SiderCategory
label={I18n.t('project_resource_modal_library_resources', {
resource: I18n.t('resource_type_database'),
})}
onClick={() => {
setCategory('library');
}}
selected={category === 'library'}
/>
{projectID ? (
<SiderCategory
label={I18n.t('project_resource_modal_project_resources', {
resource: I18n.t('resource_type_database'),
})}
onClick={() => {
setCategory('project');
}}
selected={category === 'project'}
/>
) : null}
</UICompositionModalSider.Content>
</UICompositionModalSider>
}
content={
<UICompositionModalMain className="relative px-[12px] gap-[16px]">
{renderContent()}
</UICompositionModalMain>
}
></UICompositionModal>
<DatabaseCreateTableModal
visible={createVisible}
onClose={() => setCreateVisible(false)}
onReturn={() => setCreateVisible(false)}
onSubmit={handleJumpDatabase}
showDatabaseBaseInfo
onlyShowDatabaseInfoRWMode={false}
initValue={initValue}
extraParams={{
botId,
spaceId,
creatorId: userInfo?.user_id_str,
}}
/>
</React.Fragment>
);
return { renderDatabase, renderContent, renderInput, renderFilter };
};
export const SelectDatabaseModal: FC<SelectDatabaseModalProps> = props => {
const { renderDatabase } = useSelectDatabaseModal(props);
return <>{renderDatabase()}</>;
};

View File

@@ -0,0 +1,120 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import cn from 'classnames';
import { useBoolean } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import { type ButtonProps } from '@coze-arch/bot-semi/Button';
import { Button, Typography } from '@coze-arch/bot-semi';
import styles from './index.module.less';
interface IProps {
icon: string | undefined;
title: string | undefined;
description: string | undefined;
isAdd: boolean;
onClick: () => void;
onAdd: () => void;
onRemove?: () => void;
}
const AddedButton = (buttonProps: ButtonProps) => {
const [isMouseIn, { setFalse, setTrue }] = useBoolean(false);
const onMouseEnter = () => {
setTrue();
};
const onMouseLeave = () => {
setFalse();
};
return (
<Button
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
{...buttonProps}
className={cn(styles['database-added'], {
[styles['added-mousein']]: isMouseIn,
})}
>
{isMouseIn ? I18n.t('Remove') : I18n.t('Added')}
</Button>
);
};
export const DatabaseListItem: FC<IProps> = props => {
const { icon, title, description, isAdd, onClick, onAdd, onRemove } = props;
const operateDatabase = () => {
if (isAdd) {
onRemove?.();
return;
} else {
onAdd?.();
return;
}
};
return (
<div
onClick={onClick}
className="flex flex-row items-center p-[16px] border-t-0 border-l-0 border-r-0 border-b-[1px] border-solid coz-stroke-primary last:border-b-0 cursor-pointer"
>
<img src={icon} className="w-[36px] h-[36px] rounded-[8px]" />
<div className="flex flex-col ml-[12px] min-w-0 flex-grow">
<p className="text-[14px] font-medium leading-[20px] coz-fg-primary mb-[4px]">
{title}
</p>
<Typography.Text
ellipsis={{
showTooltip: {
opts: { content: description },
},
}}
className="text-[12px] leading-[16px] coz-fg-secondary truncate !max-w-[680px]"
>
{description}
</Typography.Text>
</div>
<div className="ml-[16px]">
{isAdd ? (
<AddedButton
onClick={e => {
e.stopPropagation();
operateDatabase();
}}
/>
) : (
<Button
data-testid="bot.database.add.modal.add.button"
className={cn(
'w-[53px] flex justify-center items-center',
styles['database-add'],
)}
onClick={e => {
e.stopPropagation();
operateDatabase();
}}
>
{I18n.t('Add_2')}
</Button>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import classNames from 'classnames';
import { IconCozKnowledgeFill } from '@coze-arch/coze-design/icons';
interface SiderCategoryProps {
label: string;
selected: boolean;
onClick?: React.MouseEventHandler<HTMLDivElement>;
}
const SiderCategory = ({ label, onClick, selected }: SiderCategoryProps) => (
<div
onClick={onClick}
className={classNames([
'flex items-center gap-[8px] px-[12px]',
'px-[12px] py-[6px] rounded-[8px]',
'cursor-pointer',
'hover:text-[var(--light-usage-text-color-text-0,#1c1f23)]',
'hover:bg-[var(--light-usage-fill-color-fill-0,rgba(46,50,56,5%))]',
selected &&
'text-[var(--light-usage-text-color-text-0,#1c1d23)] bg-[var(--light-usage-fill-color-fill-0,rgba(46,47,56,5%))]',
])}
>
<IconCozKnowledgeFill />
{label}
</div>
);
export default SiderCategory;