mirror of https://github.com/nocodb/nocodb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
578 lines
17 KiB
578 lines
17 KiB
import { UITypes } from 'nocodb-sdk' |
|
import type { WritableComputedRef } from '@vue/reactivity' |
|
import type { RuleObject } from 'ant-design-vue/es/form' |
|
import { AiWizardTabsType, type PredictedFieldType } from '#imports' |
|
|
|
enum AiStep { |
|
init = 'init', |
|
pick = 'pick', |
|
} |
|
|
|
const maxSelectionCount = 100 |
|
|
|
const useForm = Form.useForm |
|
|
|
export const usePredictFields = createSharedComposable( |
|
(isFromTableExplorer?: Ref<boolean>, fields?: WritableComputedRef<Record<string, any>[]>) => { |
|
const { t } = useI18n() |
|
|
|
const { aiLoading, aiError, predictNextFields: _predictNextFields, predictNextFormulas } = useNocoAi() |
|
|
|
const { meta, view } = useSmartsheetStoreOrThrow() |
|
|
|
const isForm = inject(IsFormInj, ref(false)) |
|
|
|
const columnsHash = ref<string>() |
|
|
|
const { $api } = useNuxtApp() |
|
|
|
const aiMode = ref(false) |
|
|
|
const localIsFromFieldModal = ref<boolean>(false) |
|
|
|
const isAiModeFieldModal = computed(() => { |
|
return aiMode.value && localIsFromFieldModal.value |
|
}) |
|
|
|
const isFormulaPredictionMode = ref(false) |
|
|
|
const aiModeStep = ref<AiStep | null>(null) |
|
|
|
const calledFunction = ref<string>('') |
|
|
|
const prompt = ref<string>('') |
|
|
|
const oldPrompt = ref<string>('') |
|
|
|
const isPromtAlreadyGenerated = ref<boolean>(false) |
|
|
|
const activeAiTabLocal = ref<AiWizardTabsType>(AiWizardTabsType.AUTO_SUGGESTIONS) |
|
|
|
const failedToSaveFields = ref<boolean>(false) |
|
|
|
const temporaryAddCount = ref<number>(0) |
|
|
|
const activeAiTab = computed({ |
|
get: () => { |
|
return activeAiTabLocal.value |
|
}, |
|
set: (value: AiWizardTabsType) => { |
|
activeAiTabLocal.value = value |
|
|
|
aiError.value = '' |
|
}, |
|
}) |
|
|
|
const predicted = ref<PredictedFieldType[]>([]) |
|
|
|
const activeTabPredictedFields = computed(() => predicted.value.filter((f) => f.tab === activeAiTab.value)) |
|
|
|
const removedFromPredicted = ref<PredictedFieldType[]>([]) |
|
|
|
const predictHistory = ref<PredictedFieldType[]>([]) |
|
|
|
const activeTabPredictHistory = computed(() => predictHistory.value.filter((f) => f.tab === activeAiTab.value)) |
|
|
|
const activeSelectedField = ref<string | null>(null) |
|
|
|
const selected = ref<PredictedFieldType[]>([]) |
|
|
|
const activeTabSelectedFields = computed(() => { |
|
return predicted.value.filter((field) => !!field.selected && field.tab === activeAiTab.value) |
|
}) |
|
|
|
const isPredictFromPromptLoading = computed(() => { |
|
return aiLoading.value && calledFunction.value === 'predictFromPrompt' |
|
}) |
|
|
|
const validators = computed(() => { |
|
const rulesObj: Record<string, RuleObject[]> = {} |
|
|
|
if (!activeTabSelectedFields.value.length) return rulesObj |
|
|
|
for (const column of activeTabSelectedFields.value) { |
|
const rules: RuleObject[] = [ |
|
{ |
|
validator: (_rule: RuleObject, value: any) => { |
|
return new Promise((resolve, reject) => { |
|
const isAiFieldExist = isAiModeFieldModal.value |
|
? activeTabSelectedFields.value.some((c) => { |
|
return ( |
|
c.ai_temp_id !== value?.ai_temp_id && |
|
value?.title && |
|
(value.title.toLowerCase().trim() === (c.formState?.column_name || '').toLowerCase().trim() || |
|
value.title.toLowerCase().trim() === (c.formState?.title || '').toLowerCase().trim() || |
|
value.title.toLowerCase().trim() === (c?.title || '').toLowerCase().trim()) |
|
) |
|
}) |
|
: false |
|
if (isAiFieldExist) { |
|
return reject(new Error(`${t('msg.error.duplicateColumnName')} "${value?.title}"`)) |
|
} |
|
|
|
return resolve() |
|
}) |
|
}, |
|
}, |
|
] |
|
|
|
switch (column.type) { |
|
case UITypes.Formula: { |
|
rules.push({ |
|
validator: (_rule: RuleObject, value: any) => { |
|
return new Promise((resolve, reject) => { |
|
if (!value?.formula_raw?.trim()) { |
|
return reject(new Error('Formula is required')) |
|
} |
|
|
|
return resolve() |
|
}) |
|
}, |
|
}) |
|
} |
|
} |
|
|
|
if (rules.length) { |
|
rulesObj[column.ai_temp_id] = rules |
|
} |
|
} |
|
|
|
return rulesObj |
|
}) |
|
|
|
const fieldMappingFormState = computed(() => { |
|
if (!activeTabSelectedFields.value.length) return {} |
|
|
|
return activeTabSelectedFields.value.reduce((acc, col) => { |
|
acc[col.ai_temp_id] = col.formState |
|
return acc |
|
}, {} as Record<string, any>) |
|
}) |
|
|
|
// Form field validation |
|
const { validate } = useForm(fieldMappingFormState, validators) |
|
|
|
const getFieldWithDefautlValue = (field: PredictedFieldType) => { |
|
if ([UITypes.SingleSelect, UITypes.MultiSelect].includes(field.type)) { |
|
if (field.options) { |
|
const options: { |
|
title: string |
|
index: number |
|
color?: string |
|
}[] = [] |
|
for (const option of field.options) { |
|
// skip if option already exists |
|
if (options.find((el) => el.title === option)) continue |
|
|
|
options.push({ |
|
title: option, |
|
index: options.length, |
|
color: enumColor.light[options.length % enumColor.light.length], |
|
}) |
|
} |
|
|
|
field.colOptions = { |
|
options, |
|
} |
|
} |
|
} |
|
|
|
return { |
|
title: field.title, |
|
uidt: isFormulaPredictionMode.value ? UITypes.Formula : field.type, |
|
column_name: field.title.toLowerCase().replace(/\\W/g, '_'), |
|
...(field.formula ? { formula_raw: field.formula } : {}), |
|
...(field.colOptions ? { colOptions: field.colOptions } : {}), |
|
meta: { |
|
...(field.type in columnDefaultMeta ? columnDefaultMeta[field.type as keyof typeof columnDefaultMeta] : {}), |
|
}, |
|
description: field?.description || null, |
|
is_ai_field: true, |
|
ai_temp_id: field.ai_temp_id, |
|
} |
|
} |
|
|
|
const predictNextFields = async (): Promise<PredictedFieldType[]> => { |
|
const fieldHistory = Array.from( |
|
new Set( |
|
activeTabPredictHistory.value |
|
.map((f) => f.title) |
|
.concat( |
|
isFromTableExplorer?.value |
|
? fields?.value?.filter((f) => !!f?.title && !!f?.temp_id)?.map((f) => f.title) || [] |
|
: [], |
|
), |
|
), |
|
) |
|
|
|
return ( |
|
await (isFormulaPredictionMode.value |
|
? predictNextFormulas( |
|
meta.value?.id as string, |
|
fieldHistory, |
|
meta.value?.base_id, |
|
activeAiTab.value === AiWizardTabsType.PROMPT ? prompt.value : undefined, |
|
) |
|
: _predictNextFields( |
|
meta.value?.id as string, |
|
fieldHistory, |
|
meta.value?.base_id, |
|
activeAiTab.value === AiWizardTabsType.PROMPT ? prompt.value : undefined, |
|
isForm.value ? formViewHiddenColTypes : [], |
|
)) |
|
) |
|
.filter( |
|
(f) => |
|
!ncIsArrayIncludes( |
|
[ |
|
...activeTabPredictedFields.value, |
|
...(isFromTableExplorer?.value |
|
? fields?.value?.filter((f) => !!f?.title && !!f?.temp_id).map((f) => ({ title: f.title })) || [] |
|
: []), |
|
], |
|
|
|
f.title, |
|
'title', |
|
), |
|
) |
|
.map((f) => { |
|
const state = { |
|
...f, |
|
tab: activeAiTab.value, |
|
ai_temp_id: `temp_${++temporaryAddCount.value}`, |
|
selected: false, |
|
} |
|
|
|
if (isFromTableExplorer?.value) { |
|
return state |
|
} |
|
|
|
return { |
|
...state, |
|
formState: getFieldWithDefautlValue(state), |
|
} |
|
}) |
|
} |
|
|
|
const disableAiMode = () => { |
|
onInit() |
|
} |
|
|
|
const predictMore = async () => { |
|
calledFunction.value = 'predictMore' |
|
|
|
const predictions = await predictNextFields() |
|
|
|
if (predictions.length) { |
|
predicted.value.push(...predictions) |
|
predictHistory.value.push(...predictions) |
|
} else if (!aiError.value) { |
|
message.info(`No more auto suggestions were found for ${meta.value?.title || 'the current table'}`) |
|
} |
|
} |
|
|
|
const predictRefresh = async (callback?: (field?: PredictedFieldType | undefined) => void) => { |
|
calledFunction.value = 'predictRefresh' |
|
|
|
const predictions = await predictNextFields() |
|
|
|
if (predictions.length) { |
|
predicted.value = [ |
|
...predicted.value.filter( |
|
(t) => t.tab !== activeAiTab.value || (isFromTableExplorer?.value && t.tab === activeAiTab.value && !!t.selected), |
|
), |
|
...predictions, |
|
] |
|
predictHistory.value.push(...predictions) |
|
|
|
if (ncIsFunction(callback)) { |
|
callback() |
|
} |
|
} else if (!aiError.value) { |
|
message.info(`No auto suggestions were found for ${meta.value?.title || 'the current table'}`) |
|
} |
|
aiModeStep.value = AiStep.pick |
|
} |
|
|
|
const predictFromPrompt = async (callback?: (field?: PredictedFieldType | undefined) => void) => { |
|
calledFunction.value = 'predictFromPrompt' |
|
|
|
const predictions = await predictNextFields() |
|
|
|
if (predictions.length) { |
|
predicted.value = [ |
|
...predicted.value.filter( |
|
(t) => t.tab !== activeAiTab.value || (isFromTableExplorer?.value && t.tab === activeAiTab.value && !!t.selected), |
|
), |
|
...predictions, |
|
] |
|
predictHistory.value.push(...predictions) |
|
|
|
oldPrompt.value = prompt.value |
|
|
|
if (ncIsFunction(callback)) { |
|
callback() |
|
} |
|
} else if (!aiError.value) { |
|
message.info('No suggestions were found with the given prompt. Try again after modifying the prompt.') |
|
} |
|
aiModeStep.value = AiStep.pick |
|
isPromtAlreadyGenerated.value = true |
|
} |
|
|
|
// Todo: update logic |
|
const onToggleTag = (field: PredictedFieldType) => { |
|
if ( |
|
field.selected !== true && |
|
(activeTabSelectedFields.value.length >= maxSelectionCount || |
|
ncIsArrayIncludes( |
|
predicted.value.filter((f) => !!f.selected), |
|
field.title, |
|
'title', |
|
)) |
|
) { |
|
return |
|
} |
|
|
|
if (isFromTableExplorer?.value && field.selected) { |
|
const fieldIndex = predicted.value.findIndex((f) => f.ai_temp_id === field.ai_temp_id) |
|
if (fieldIndex === -1) return |
|
|
|
const fieldToDeselect = predicted.value.splice(fieldIndex, 1)[0] |
|
|
|
fieldToDeselect.selected = false |
|
|
|
predicted.value.push(fieldToDeselect) |
|
} else { |
|
predicted.value = predicted.value.map((t) => { |
|
if (t.ai_temp_id === field.ai_temp_id) { |
|
if (!isFromTableExplorer?.value && !field.selected) { |
|
activeSelectedField.value = field.ai_temp_id |
|
} |
|
|
|
t.selected = !field.selected |
|
} |
|
return t |
|
}) |
|
} |
|
|
|
return true |
|
} |
|
|
|
const onSelectedTagClick = (field: PredictedFieldType) => { |
|
activeSelectedField.value = field.ai_temp_id |
|
} |
|
|
|
const onSelectAll = () => { |
|
if (selected.value.length >= maxSelectionCount) return |
|
let count = selected.value.length |
|
|
|
const remainingPredictedFields: PredictedFieldType[] = [] |
|
const fieldsToAdd: PredictedFieldType[] = [] |
|
|
|
predicted.value.forEach((pv) => { |
|
// Check if the item can be selected |
|
if ( |
|
count < maxSelectionCount && |
|
!ncIsArrayIncludes(removedFromPredicted.value, pv.title, 'title') && |
|
!ncIsArrayIncludes(selected.value, pv.title, 'title') |
|
) { |
|
fieldsToAdd.push(pv) // Add to selected field if it meets the criteria |
|
count++ |
|
} else { |
|
remainingPredictedFields.push(pv) // Keep in predicted field if it doesn't meet the criteria |
|
} |
|
}) |
|
|
|
// Add selected items to the selected view array |
|
selected.value.push( |
|
...fieldsToAdd.map((f) => { |
|
if (!isFromTableExplorer?.value) { |
|
f.formState = getFieldWithDefautlValue(f) |
|
} |
|
|
|
return f |
|
}), |
|
) |
|
|
|
// Update predicted with the remaining ones |
|
predicted.value = remainingPredictedFields |
|
|
|
return fieldsToAdd |
|
} |
|
|
|
const handleRefreshOnError = () => { |
|
switch (calledFunction.value) { |
|
case 'predictMore': |
|
return predictMore() |
|
case 'predictRefresh': |
|
return predictRefresh() |
|
case 'predictFromPrompt': |
|
return predictFromPrompt() |
|
|
|
default: |
|
} |
|
} |
|
|
|
const validateAllFields = async () => { |
|
try { |
|
await validate() |
|
return true |
|
} catch (e: any) { |
|
console.error(e) |
|
|
|
if (e?.errorFields?.length) { |
|
const errorMsg = e?.errorFields |
|
.map((field, idx) => (field?.errors?.length ? `${idx + 1}. ${field?.errors?.join(',')}` : '')) |
|
.join(', ') |
|
|
|
message.error(errorMsg || t('msg.error.someOfTheRequiredFieldsAreEmpty')) |
|
|
|
return false |
|
} |
|
} |
|
} |
|
|
|
const saveFields = async (onSuccess: () => Promise<void>) => { |
|
const isValid = await validateAllFields() |
|
|
|
if (!isValid) return false |
|
|
|
failedToSaveFields.value = false |
|
const payload = activeTabSelectedFields.value |
|
.filter((f) => f.formState) |
|
.map((field) => { |
|
return { |
|
op: 'add', |
|
column: { |
|
...field.formState, |
|
title: field.formState?.title || field.title, |
|
column_name: (field.formState?.title || field.title).toLowerCase().replace(/\\W/g, '_'), |
|
column_order: { |
|
// order: order, |
|
view_id: view.value?.id, |
|
}, |
|
view_id: view.value?.id, |
|
}, |
|
} |
|
}) |
|
|
|
try { |
|
const res = await $api.dbTableColumn.bulk(meta.value?.id, { |
|
hash: columnsHash.value, |
|
ops: payload, |
|
}) |
|
|
|
if (res && res.failedOps?.length) { |
|
const failedColumnTitle = res.failedOps.filter((o) => o?.column?.ai_temp_id).map((o) => o.column.ai_temp_id) |
|
predicted.value = predicted.value.filter((f) => { |
|
if (failedColumnTitle.includes(f.formState?.ai_temp_id)) return true |
|
|
|
return false |
|
}) |
|
|
|
failedToSaveFields.value = true |
|
|
|
return false |
|
} else { |
|
await onSuccess?.() |
|
|
|
return true |
|
} |
|
} catch (e: any) { |
|
console.error(e) |
|
return false |
|
} |
|
} |
|
const toggleAiMode = async (isFormulaMode: boolean = false, fromFieldModal = false) => { |
|
if (isFormulaMode) { |
|
isFormulaPredictionMode.value = true |
|
} else { |
|
isFormulaPredictionMode.value = false |
|
} |
|
|
|
localIsFromFieldModal.value = !!fromFieldModal |
|
|
|
aiError.value = '' |
|
|
|
aiMode.value = true |
|
aiModeStep.value = AiStep.init |
|
predicted.value = [] |
|
predictHistory.value = [] |
|
prompt.value = '' |
|
oldPrompt.value = '' |
|
isPromtAlreadyGenerated.value = false |
|
|
|
const predictions = await predictNextFields() |
|
|
|
predicted.value = predictions |
|
predictHistory.value.push(...predictions) |
|
aiModeStep.value = AiStep.pick |
|
} |
|
|
|
function onInit() { |
|
activeSelectedField.value = null |
|
isFormulaPredictionMode.value = false |
|
aiMode.value = false |
|
localIsFromFieldModal.value = false |
|
aiModeStep.value = null |
|
predicted.value = [] |
|
removedFromPredicted.value = [] |
|
predictHistory.value = [] |
|
selected.value = [] |
|
calledFunction.value = '' |
|
prompt.value = '' |
|
oldPrompt.value = '' |
|
isPromtAlreadyGenerated.value = false |
|
|
|
activeAiTabLocal.value = AiWizardTabsType.AUTO_SUGGESTIONS |
|
|
|
failedToSaveFields.value = false |
|
} |
|
|
|
watch( |
|
meta, |
|
async (newMeta) => { |
|
if (newMeta?.id) { |
|
columnsHash.value = (await $api.dbTableColumn.hash(newMeta.id)).hash |
|
predictHistory.value = [] |
|
} |
|
}, |
|
{ deep: true, immediate: true }, |
|
) |
|
|
|
return { |
|
aiMode, |
|
isAiModeFieldModal, |
|
aiModeStep, |
|
predicted, |
|
activeTabPredictedFields, |
|
removedFromPredicted, |
|
predictHistory, |
|
activeTabPredictHistory, |
|
activeSelectedField, |
|
selected, |
|
activeTabSelectedFields, |
|
calledFunction, |
|
prompt, |
|
oldPrompt, |
|
isPromtAlreadyGenerated, |
|
maxSelectionCount, |
|
activeAiTab, |
|
isPredictFromPromptLoading, |
|
isFormulaPredictionMode, |
|
failedToSaveFields, |
|
onInit, |
|
toggleAiMode, |
|
disableAiMode, |
|
predictMore, |
|
predictRefresh, |
|
predictFromPrompt, |
|
onToggleTag, |
|
onSelectedTagClick, |
|
onSelectAll, |
|
handleRefreshOnError, |
|
saveFields, |
|
} |
|
}, |
|
)
|
|
|