Browse Source

chore: preps (#9994)

* chore: preps

Signed-off-by: mertmit <mertmit99@gmail.com>

* test: fix unit

Signed-off-by: mertmit <mertmit99@gmail.com>

---------

Signed-off-by: mertmit <mertmit99@gmail.com>
pull/9999/head
Mert E. 3 days ago committed by GitHub
parent
commit
818d9344e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 83
      packages/nc-gui/components/ai/PromptWithFields.vue
  2. 36
      packages/nc-gui/components/ai/Settings.vue
  3. 53
      packages/nc-gui/components/cell/AI.vue
  4. 78
      packages/nc-gui/components/cell/TextArea.vue
  5. 2
      packages/nc-gui/components/cell/attachment/index.vue
  6. 2
      packages/nc-gui/components/general/IntegrationIcon.vue
  7. 6
      packages/nc-gui/components/general/Loader.vue
  8. 9
      packages/nc-gui/components/nc/Button.vue
  9. 2
      packages/nc-gui/components/nc/List/RecordItem.vue
  10. 4
      packages/nc-gui/components/nc/Switch.vue
  11. 82
      packages/nc-gui/components/smartsheet/Cell.vue
  12. 2
      packages/nc-gui/components/smartsheet/Gallery.vue
  13. 2
      packages/nc-gui/components/smartsheet/Kanban.vue
  14. 7
      packages/nc-gui/components/smartsheet/PlainCell.vue
  15. 17
      packages/nc-gui/components/smartsheet/column/AiButtonOptions.vue
  16. 4
      packages/nc-gui/components/smartsheet/column/ButtonOptions.vue
  17. 34
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  18. 109
      packages/nc-gui/components/smartsheet/column/LongTextOptions.vue
  19. 4
      packages/nc-gui/components/smartsheet/column/UITypesOptionsWithSearch.vue
  20. 4
      packages/nc-gui/components/smartsheet/details/Fields.vue
  21. 4
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  22. 1
      packages/nc-gui/components/smartsheet/grid/GroupByTable.vue
  23. 16
      packages/nc-gui/components/smartsheet/grid/InfiniteTable.vue
  24. 16
      packages/nc-gui/components/smartsheet/grid/Table.vue
  25. 9
      packages/nc-gui/components/smartsheet/header/Cell.vue
  26. 4
      packages/nc-gui/components/smartsheet/header/CellIcon.ts
  27. 27
      packages/nc-gui/components/smartsheet/header/Menu.vue
  28. 7
      packages/nc-gui/components/smartsheet/header/VirtualCell.vue
  29. 4
      packages/nc-gui/components/smartsheet/toolbar/ViewActionMenu.vue
  30. 2
      packages/nc-gui/components/virtual-cell/Button.vue
  31. 4
      packages/nc-gui/components/virtual-cell/HasMany.vue
  32. 4
      packages/nc-gui/components/virtual-cell/ManyToMany.vue
  33. 4
      packages/nc-gui/components/virtual-cell/components/ListItem.vue
  34. 6
      packages/nc-gui/components/workspace/integrations/ConnectionsTab.vue
  35. 3
      packages/nc-gui/components/workspace/integrations/IntegrationsTab.vue
  36. 8
      packages/nc-gui/components/workspace/integrations/forms/EditOrAddCommon.vue
  37. 12
      packages/nc-gui/components/workspace/integrations/forms/EditOrAddDatabase.vue
  38. 31
      packages/nc-gui/components/workspace/project/AiCreateProject.vue
  39. 6
      packages/nc-gui/composables/useColumnCreateStore.ts
  40. 3
      packages/nc-gui/composables/useData.ts
  41. 3
      packages/nc-gui/composables/useInfiniteData.ts
  42. 57
      packages/nc-gui/composables/useIntegrationsStore.ts
  43. 9
      packages/nc-gui/composables/useMultiSelect/index.ts
  44. 38
      packages/nc-gui/composables/useNocoAi.ts
  45. 12
      packages/nc-gui/helpers/parsers/parserHelpers.ts
  46. 16
      packages/nc-gui/helpers/tiptapExtensions/mention/FieldList.vue
  47. 4
      packages/nc-gui/nuxt.config.ts
  48. 4
      packages/nc-gui/utils/cell.ts
  49. 58
      packages/nc-gui/utils/columnUtils.ts
  50. 44
      packages/nc-gui/utils/commonUtils.ts
  51. 3
      packages/nocodb-sdk/src/lib/Api.ts
  52. 12
      packages/nocodb-sdk/src/lib/UITypes.ts
  53. 2
      packages/nocodb-sdk/src/lib/globals.ts
  54. 16
      packages/nocodb-sdk/src/lib/helperFunctions.ts
  55. 1
      packages/nocodb-sdk/src/lib/index.ts
  56. 72
      packages/nocodb/src/db/BaseModelSqlv2.ts
  57. 19
      packages/nocodb/src/db/conditionV2.ts
  58. 42
      packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts
  59. 29
      packages/nocodb/src/db/sortV2.ts
  60. 4
      packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts
  61. 29
      packages/nocodb/src/meta/migrations/v2/nc_069_ai_prompt.ts
  62. 48
      packages/nocodb/src/models/AIColumn.ts
  63. 113
      packages/nocodb/src/models/Column.ts
  64. 58
      packages/nocodb/src/models/Integration.ts
  65. 113
      packages/nocodb/src/models/LongTextColumn.ts
  66. 2
      packages/nocodb/src/models/index.ts
  67. 9
      packages/nocodb/src/modules/jobs/jobs/export-import/duplicate.processor.ts
  68. 32
      packages/nocodb/src/modules/jobs/jobs/export-import/export.service.ts
  69. 45
      packages/nocodb/src/modules/jobs/jobs/export-import/import.service.ts
  70. 106
      packages/nocodb/src/services/columns.service.ts
  71. 150
      packages/nocodb/src/utils/dataConversion.ts
  72. 7
      packages/nocodb/src/utils/globals.ts

83
packages/nc-gui/components/ai/PromptWithFields.vue

@ -3,6 +3,7 @@ import Placeholder from '@tiptap/extension-placeholder'
import StarterKit from '@tiptap/starter-kit'
import Mention from '@tiptap/extension-mention'
import { EditorContent, useEditor } from '@tiptap/vue-3'
import tippy from 'tippy.js'
import { type ColumnType, UITypes } from 'nocodb-sdk'
import FieldList from '~/helpers/tiptapExtensions/mention/FieldList'
import suggestion from '~/helpers/tiptapExtensions/mention/suggestion.ts'
@ -15,6 +16,7 @@ const props = withDefaults(
promptFieldTagClassName?: string
suggestionIconClassName?: string
placeholder?: string
readOnly?: boolean
}>(),
{
options: () => [],
@ -26,6 +28,7 @@ const props = withDefaults(
* @example: :placeholder="`Enter prompt here...\n\neg : Categorise this {Notes}`"
*/
placeholder: 'Write your prompt here...',
readOnly: false,
},
)
@ -38,7 +41,9 @@ const vModel = computed({
},
})
const { autoFocus } = toRefs(props)
const { autoFocus, readOnly } = toRefs(props)
const debouncedLoadMentionFieldTagTooltip = useDebounceFn(loadMentionFieldTagTooltip, 1000)
const editor = useEditor({
content: vModel.value,
@ -55,18 +60,25 @@ const editor = useEditor({
...suggestion(FieldList),
items: ({ query }) => {
if (query.length === 0) return props.options ?? []
return props.options?.filter((o) => o.title?.toLowerCase()?.includes(query.toLowerCase())) ?? []
return (
props.options?.filter(
(o) =>
o.title?.toLowerCase()?.includes(query.toLowerCase()) || `${o.title?.toLowerCase()}}` === query.toLowerCase(),
) ?? []
)
},
char: '{',
allowSpaces: true,
},
renderHTML: ({ node }) => {
const matchedOption = props.options?.find((option) => option.title === node.attrs.id)
const isAttachment = matchedOption?.uidt === UITypes.Attachment
return [
'span',
{
class: `prompt-field-tag ${
props.options?.find((option) => option.title === node.attrs.id)?.uidt === UITypes.Attachment ? '!bg-green-200' : ''
} ${props.promptFieldTagClassName}`,
'class': `prompt-field-tag ${isAttachment ? '!bg-green-200' : ''} ${props.promptFieldTagClassName}`,
'style': 'max-width: 100px; white-space: nowrap; overflow: hidden; display: inline-block; text-overflow: ellipsis;', // Enforces truncation
'data-tooltip': node.attrs.id, // Tooltip content
},
`${node.attrs.id}`,
]
@ -93,8 +105,10 @@ const editor = useEditor({
text = text.trim()
vModel.value = text
debouncedLoadMentionFieldTagTooltip()
},
editable: true,
editable: !readOnly.value,
autofocus: autoFocus.value,
editorProps: { scrollThreshold: 100 },
})
@ -141,15 +155,60 @@ onMounted(async () => {
}, 100)
}
})
const tooltipInstances: any[] = []
function loadMentionFieldTagTooltip() {
document.querySelectorAll('.nc-ai-prompt-with-fields .prompt-field-tag').forEach((el) => {
const tooltip = Object.values(el.attributes).find((attr) => attr.name === 'data-tooltip')
if (!tooltip || el.scrollWidth <= el.clientWidth) return
// Show tooltip only on truncate
const instance = tippy(el, {
content: `<div class="tooltip text-xs">${tooltip.value}</div>`,
placement: 'top',
allowHTML: true,
arrow: true,
animation: 'fade',
duration: 0,
maxWidth: '200px',
})
tooltipInstances.push(instance)
})
}
onMounted(() => {
debouncedLoadMentionFieldTagTooltip()
})
onBeforeUnmount(() => {
tooltipInstances.forEach((instance) => instance?.destroy())
tooltipInstances.length = 0
})
</script>
<template>
<div class="nc-ai-prompt-with-fields w-full">
<EditorContent ref="editorDom" :editor="editor" @keydown.alt.enter.stop @keydown.shift.enter.stop />
<NcButton size="xs" type="text" class="nc-prompt-with-field-suggestion-btn !px-1" @click.stop="newFieldSuggestionNode">
<NcButton
size="xs"
type="text"
class="nc-prompt-with-field-suggestion-btn !px-1"
:disabled="readOnly"
@click.stop="newFieldSuggestionNode"
>
<slot name="triggerIcon">
<GeneralIcon icon="ncPlusSquareSolid" class="text-nc-content-brand" :class="`${suggestionIconClassName}`" />
<GeneralIcon
icon="ncPlusSquareSolid"
class="text-nc-content-brand"
:class="[
`${suggestionIconClassName}`,
{
'opacity-75': readOnly,
},
]"
/>
</slot>
</NcButton>
</div>
@ -160,11 +219,11 @@ onMounted(async () => {
@apply relative;
.nc-prompt-with-field-suggestion-btn {
@apply absolute top-[1px] right-[1px];
@apply absolute top-[2px] right-[1px];
}
.prompt-field-tag {
@apply bg-gray-100 rounded-md px-1;
@apply bg-gray-100 rounded-md px-1 align-middle;
}
.ProseMirror {
@ -172,6 +231,10 @@ onMounted(async () => {
resize: vertical;
min-width: 100%;
max-height: min(800px, calc(100vh - 200px)) !important;
& > p {
@apply mr-3;
}
}
.ProseMirror-focused {

36
packages/nc-gui/components/ai/Settings.vue

@ -7,9 +7,11 @@ const props = withDefaults(
workspaceId: string
scope?: string
showTooltip?: boolean
isEditColumn?: boolean
}>(),
{
showTooltip: true,
isEditColumn: false,
},
)
@ -19,6 +21,8 @@ const vFkIntegrationId = useVModel(props, 'fkIntegrationId', emits)
const vModel = useVModel(props, 'model', emits)
const { isEditColumn } = toRefs(props)
// const vRandomness = useVModel(props, 'randomness', emits)
const { $api } = useNuxtApp()
@ -29,7 +33,7 @@ const lastIntegrationId = ref<string | null>(null)
const isDropdownOpen = ref(false)
const availableModels = ref<string[]>([])
const availableModels = ref<{ value: string; label: string }[]>([])
const isLoadingAvailableModels = ref<boolean>(false)
@ -50,10 +54,10 @@ const onIntegrationChange = async (newFkINtegrationId?: string) => {
try {
const response = await $api.integrations.endpoint(newFkINtegrationId, 'availableModels', {})
availableModels.value = response as string[]
availableModels.value = (response || []) as { value: string; label: string }[]
if (!vModel.value && availableModels.value.length > 0) {
vModel.value = availableModels.value[0]
vModel.value = availableModels.value[0].value
}
} catch (error) {
console.error(error)
@ -63,6 +67,10 @@ const onIntegrationChange = async (newFkINtegrationId?: string) => {
}
onMounted(async () => {
if (isEditColumn.value) {
return
}
if (!vFkIntegrationId.value) {
if (aiIntegrations.value.length > 0 && aiIntegrations.value[0].id) {
vFkIntegrationId.value = aiIntegrations.value[0].id
@ -72,6 +80,10 @@ onMounted(async () => {
}
} else {
lastIntegrationId.value = vFkIntegrationId.value
if (!vModel.value || !availableModels.value.length) {
onIntegrationChange()
}
}
})
</script>
@ -111,6 +123,7 @@ onMounted(async () => {
v-model:value="vFkIntegrationId"
class="w-full nc-select-shadow nc-ai-input"
size="middle"
placeholder="- select integration -"
@change="onIntegrationChange"
>
<a-select-option v-for="integration in aiIntegrations" :key="integration.id" :value="integration.id">
@ -150,20 +163,21 @@ onMounted(async () => {
v-model:value="vModel"
class="w-full nc-select-shadow nc-ai-input"
size="middle"
placeholder="- select model -"
:disabled="!vFkIntegrationId || availableModels.length === 0"
:loading="isLoadingAvailableModels"
>
<a-select-option v-for="md in availableModels" :key="md" :value="md">
<a-select-option v-for="md in availableModels" :key="md.label" :value="md.value">
<div class="w-full flex gap-2 items-center">
<NcTooltip class="flex-1 truncate" show-on-truncate-only>
<template #title>
{{ md }}
{{ md.label }}
</template>
{{ md }}
{{ md.label }}
</NcTooltip>
<component
:is="iconMap.check"
v-if="vModel === md"
v-if="vModel === md.value"
id="nc-selected-item-icon"
class="text-nc-content-purple-medium w-4 h-4"
/>
@ -198,4 +212,10 @@ onMounted(async () => {
</NcDropdown>
</template>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
:deep(.nc-select.ant-select) {
.ant-select-selector {
@apply !rounded-lg;
}
}
</style>

53
packages/nc-gui/components/cell/AI.vue

@ -13,6 +13,10 @@ const { generateRows, generatingRows, generatingColumnRows, aiIntegrations } = u
const { row } = useSmartsheetRowStoreOrThrow()
const { isUIAllowed } = useRoles()
const isPublic = inject(IsPublicInj, ref(false))
const meta = inject(MetaInj, ref())
const column = inject(ColumnInj) as Ref<
@ -40,9 +44,7 @@ const isAiEdited = ref(false)
const isFieldAiIntegrationAvailable = computed(() => {
const fkIntegrationId = column.value?.colOptions?.fk_integration_id
if (!fkIntegrationId) return false
return ncIsArrayIncludes(aiIntegrations.value, fkIntegrationId, 'id')
return !!fkIntegrationId
})
const pk = computed(() => {
@ -58,7 +60,7 @@ const generate = async () => {
ncIsString(column.value.colOptions?.output_column_ids) && column.value.colOptions.output_column_ids.split(',').length > 1
? column.value.colOptions.output_column_ids.split(',')
: []
const outputColumns = outputColumnIds.map((id) => meta.value?.columnsById[id])
const outputColumns = outputColumnIds.map((id) => meta.value?.columnsById?.[id]).filter(Boolean)
generatingRows.value.push(pk.value)
generatingColumnRows.value.push(column.value.id)
@ -76,11 +78,18 @@ const generate = async () => {
}
} else {
const obj: AIRecordType = resRow[column.value.title!]
if (obj && typeof obj === 'object') {
vModel.value = obj
setTimeout(() => {
isAiEdited.value = false
}, 100)
} else {
vModel.value = {
...(ncIsObject(vModel.value) ? vModel.value : {}),
isStale: false,
value: resRow[column.value.title!],
}
}
}
}
@ -99,10 +108,16 @@ const isLoading = computed(() => {
})
const handleSave = () => {
vModel.value = { ...vModel.value }
emits('save')
}
const debouncedSave = useDebounceFn(handleSave, 1000)
const isDisabledAiButton = computed(() => {
return !isFieldAiIntegrationAvailable.value || isLoading.value || isPublic.value || !isUIAllowed('dataEdit')
})
</script>
<template>
@ -113,20 +128,15 @@ const debouncedSave = useDebounceFn(handleSave, 1000)
'justify-center': isGrid && !isExpandedForm,
}"
>
<NcTooltip :disabled="isFieldAiIntegrationAvailable" class="flex">
<NcTooltip :disabled="isFieldAiIntegrationAvailable || isPublic || isUIAllowed('dataEdit')" class="flex">
<template #title>
{{ aiIntegrations.length ? $t('tooltip.aiIntegrationReConfigure') : $t('tooltip.aiIntegrationAddAndReConfigure') }}
</template>
<button
class="nc-cell-ai-button nc-cell-button h-7"
size="small"
:disabled="!isFieldAiIntegrationAvailable || isLoading"
@click.stop="generate"
>
<button class="nc-cell-ai-button nc-cell-button h-6" size="small" :disabled="isDisabledAiButton" @click.stop="generate">
<div class="flex items-center gap-1">
<GeneralLoader v-if="isLoading" size="regular" class="!text-nc-content-purple-dark" />
<GeneralIcon v-else icon="ncAutoAwesome" class="text-nc-content-purple-dark h-4 w-4" />
<span class="text-sm font-semibold">Generate</span>
<GeneralLoader v-if="isLoading" size="regular" />
<GeneralIcon v-else icon="ncAutoAwesome" class="h-4 w-4" />
<span class="text-small leading-[18px] truncate font-medium">Generate</span>
</div>
</button>
</NcTooltip>
@ -147,10 +157,19 @@ const debouncedSave = useDebounceFn(handleSave, 1000)
<style scoped lang="scss">
.nc-cell-button {
@apply rounded-lg px-2 flex items-center gap-2 transition-all justify-center border-1 border-nc-border-gray-medium;
@apply rounded-md px-2 flex items-center gap-2 transition-all justify-center bg-purple-100 hover:bg-purple-200 text-gray-700;
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.06), 0px 5px 3px -2px rgba(0, 0, 0, 0.02);
.nc-loader {
@apply !text-purple-600;
}
&:focus-within {
@apply outline-none ring-0;
box-shadow: 0px 0px 0px 2px #fff, 0px 0px 0px 4px #3069fe;
}
&[disabled] {
@apply !bg-gray-100 opacity-50;
}
@ -162,6 +181,10 @@ const debouncedSave = useDebounceFn(handleSave, 1000)
&:has(.nc-cell-ai-button) {
@apply !border-none;
box-shadow: none !important;
&:focus-within:not(.nc-readonly-div-data-cell):not(.nc-system-field) {
box-shadow: none !important;
}
}
}
</style>

78
packages/nc-gui/components/cell/TextArea.vue

@ -14,6 +14,8 @@ const props = defineProps<{
const emits = defineEmits(['update:modelValue', 'update:isAiEdited', 'generate', 'close'])
const meta = inject(MetaInj, ref())
const column = inject(ColumnInj)
const editEnabled = inject(EditModeInj, ref(false))
@ -34,7 +36,9 @@ const readOnly = inject(ReadonlyInj, ref(false))
const { showNull, user } = useGlobal()
const { aiLoading, aiIntegrations } = useNocoAi()
const { currentRow } = useSmartsheetRowStoreOrThrow()
const { aiLoading, aiIntegrations, generatingRows, generatingColumnRows } = useNocoAi()
const baseStore = useBase()
@ -93,6 +97,19 @@ const aiWarningRef = ref<HTMLDivElement>()
const { height: aiWarningRefHeight } = useElementSize(aiWarningRef)
const rowId = computed(() => {
return extractPkFromRow(currentRow.value?.row, meta.value!.columns!)
})
const isAiGenerating = computed(() => {
return !!(
rowId.value &&
column?.value.id &&
generatingRows.value.includes(rowId.value) &&
generatingColumnRows.value.includes(column.value.id)
)
})
watch(isVisible, () => {
if (isVisible.value) {
setTimeout(() => {
@ -406,8 +423,8 @@ watch(
@selectstart.capture.stop
@mousedown.stop
/>
<div v-if="!readOnly" class="-mt-1">
<div v-if="props.isAi && props.aiMeta?.isStale" ref="aiWarningRef">
<div v-if="!readOnly && props.isAi && isExpandedFormOpen" class="-mt-1">
<div v-if="props.aiMeta?.isStale" ref="aiWarningRef">
<div class="flex items-start p-3 bg-nc-bg-purple-light gap-4">
<GeneralIcon icon="alertTriangleSolid" class="text-nc-content-purple-medium h-4 w-4 flex-none" />
<div class="flex flex-col">
@ -419,8 +436,8 @@ watch(
</div>
</div>
<div v-if="props.isAi && !isEditColumn" class="flex items-center gap-2 px-3 py-0.5 !text-small leading-[18px]">
<span class="text-nc-content-purple-dark truncate">Generated by AI</span>
<div v-if="!isEditColumn" class="flex items-center gap-2 px-3 py-0.5 !text-small leading-[18px]">
<span class="text-nc-content-purple-light truncate">Generated by AI</span>
<NcTooltip v-if="isAiEdited" class="text-nc-content-green-dark flex-1 truncate" show-on-truncate-only>
<template #title> Edited by you </template>
Edited by you
@ -458,12 +475,13 @@ watch(
theme="ai"
size="xs"
:disabled="!isFieldAiIntegrationAvailable"
:loading="aiLoading"
:loading="isAiGenerating"
@click.stop="generate"
>
<template #icon>
<GeneralIcon icon="ncAutoAwesome" />
<GeneralIcon icon="ncAutoAwesome" class="h-4 w-4" />
</template>
<template #loading> Re-generating... </template>
Re-generate
</NcButton>
</NcTooltip>
@ -488,10 +506,8 @@ watch(
<span v-else>{{ vModel }}</span>
<NcTooltip
v-if="!isVisible && !isForm"
placement="bottom"
class="nc-action-icon !absolute !hidden nc-text-area-expand-btn group-hover:block z-3"
<div
class="!absolute !hidden nc-text-area-expand-btn group-hover:block z-3 flex items-center gap-1"
:class="{
'right-1': isForm,
'right-0': !isForm,
@ -504,6 +520,33 @@ watch(
: undefined
"
>
<NcTooltip
v-if="!isVisible && !isForm && !readOnly && props.isAi && !isExpandedFormOpen && !isEditColumn"
placement="bottom"
class="nc-action-icon"
>
<template #title>
{{ isAiGenerating ? 'Re-generating...' : 'Re-generate' }}
</template>
<NcButton
type="secondary"
size="xsmall"
class="!p-0 !w-5 !h-5 !min-w-[fit-content]"
:disabled="isAiGenerating"
loader-size="small"
icon-only
@click.stop="generate"
>
<template #icon>
<GeneralIcon
icon="refresh"
class="transform group-hover:(!text-grey-800) text-gray-700 w-3 h-3"
:class="{ 'animate-infinite animate-spin': isAiGenerating }"
/>
</template>
</NcButton>
</NcTooltip>
<NcTooltip v-if="!isVisible && !isForm" placement="bottom" class="nc-action-icon">
<template #title>{{ $t('title.expand') }}</template>
<NcButton
type="secondary"
@ -512,10 +555,11 @@ watch(
class="!p-0 !w-5 !h-5 !min-w-[fit-content]"
@click.stop="onExpand"
>
<component :is="iconMap.expand" class="transform group-hover:(!text-grey-800) text-gray-700 text-xs" />
<component :is="iconMap.maximize" class="transform group-hover:(!text-grey-800) text-gray-700 w-3 h-3" />
</NcButton>
</NcTooltip>
</div>
</div>
<a-modal
v-if="isVisible"
v-model:visible="isVisible"
@ -600,15 +644,15 @@ watch(
@click.stop="generate"
>
<div class="flex items-center gap-2">
<GeneralIcon icon="refresh" :class="{ 'animate-infinite animate-spin': aiLoading }" />
<span class="text-sm font-bold">Re-generate</span>
<GeneralIcon icon="refresh" :class="{ 'animate-infinite animate-spin': isAiGenerating }" />
<span class="text-sm font-bold"> {{ isAiGenerating ? 'Re-generating...' : 'Re-generate' }} </span>
</div>
</NcButton>
</NcTooltip>
</div>
</template>
</div>
<div v-if="props.isAi && props.aiMeta?.isStale && !readOnly" ref="aiWarningRef">
<div v-if="props.isAi && props.aiMeta?.isStale && !readOnly" ref="aiWarningRef" class="border-b-1 border-gray-100">
<div class="flex items-center p-4 bg-nc-bg-purple-light gap-4">
<GeneralIcon icon="alertTriangleSolid" class="text-nc-content-purple-medium h-6 w-6 flex-none" />
<div class="flex flex-col">
@ -679,7 +723,7 @@ textarea:focus {
<style lang="scss">
.cell:hover .nc-text-area-expand-btn,
.long-text-wrapper:hover .nc-text-area-expand-btn {
@apply !block cursor-pointer;
@apply !flex cursor-pointer;
}
.nc-grid-cell {
@ -695,7 +739,7 @@ textarea:focus {
}
.nc-data-cell {
&:has(.nc-cell-ai .nc-expanded-form-open) {
&:has(.nc-cell-longtext-ai .nc-expanded-form-open) {
@apply !border-none -mx-1 -my-1;
box-shadow: none !important;

2
packages/nc-gui/components/cell/attachment/index.vue

@ -424,7 +424,7 @@ defineExpose({
>
<component :is="iconMap.reload" v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />
<component :is="iconMap.expand" v-else class="transform group-hover:(!text-grey-800) text-gray-700 text-xs" />
<component :is="iconMap.maximize" v-else class="transform group-hover:(!text-grey-800) text-gray-700 w-3 h-3" />
</NcButton>
</NcTooltip>

2
packages/nc-gui/components/general/IntegrationIcon.vue

@ -2,7 +2,7 @@
const props = withDefaults(
defineProps<{
type: string
size: 'sx' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'
size?: 'sx' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'
}>(),
{
size: 'sm',

6
packages/nc-gui/components/general/Loader.vue

@ -1,10 +1,12 @@
<script lang="ts" setup>
import { LoadingOutlined } from '@ant-design/icons-vue'
const props = defineProps<{
export interface GeneralLoaderProps {
size?: 'small' | 'medium' | 'large' | 'xlarge' | 'regular' | number
loaderClass?: string
}>()
}
const props = defineProps<GeneralLoaderProps>()
function getFontSize() {
const { size = 'medium' } = props

9
packages/nc-gui/components/nc/Button.vue

@ -1,6 +1,7 @@
<script lang="ts" setup>
import type { ButtonType } from 'ant-design-vue/lib/button'
import { useSlots } from 'vue'
import type { GeneralLoaderProps } from '../general/Loader.vue'
/**
* @description
@ -19,6 +20,7 @@ interface Props {
showAsDisabled?: boolean
type?: ButtonType | 'danger' | 'secondary' | undefined
size?: NcButtonSize
loaderSize?: GeneralLoaderProps['size']
centered?: boolean
fullWidth?: boolean
iconOnly?: boolean
@ -32,6 +34,7 @@ const props = withDefaults(defineProps<Props>(), {
disabled: false,
showAsDisabled: false,
size: 'medium',
loaderSize: 'medium',
type: 'primary',
fullWidth: false,
centered: true,
@ -47,7 +50,7 @@ const slots = useSlots()
const NcButton = ref<HTMLElement | null>(null)
const { size, type, theme, bordered } = toRefs(props)
const { size, loaderSize, type, theme, bordered } = toRefs(props)
const loading = useVModel(props, 'loading', emits)
@ -112,7 +115,7 @@ useEventListener(NcButton, 'mousedown', () => {
>
<template v-if="iconPosition === 'left'">
<slot v-if="loading" name="loadingIcon">
<GeneralLoader class="flex !bg-inherit !text-inherit" size="medium" />
<GeneralLoader class="flex !bg-inherit !text-inherit" :size="loaderSize" />
</slot>
<slot v-else name="icon" />
@ -131,7 +134,7 @@ useEventListener(NcButton, 'mousedown', () => {
</div>
<template v-if="iconPosition === 'right'">
<slot v-if="loading" name="loadingIcon">
<GeneralLoader v-if="loading" class="flex !bg-inherit !text-inherit" size="medium" />
<GeneralLoader class="flex !bg-inherit !text-inherit" :size="loaderSize" />
</slot>
<slot v-else name="icon" />

2
packages/nc-gui/components/nc/List/RecordItem.vue

@ -142,7 +142,7 @@ const columnsToRender = computed(() => {
<div v-if="columnsToRender.length > 0" class="flex ml-[-0.25rem] sm:flex-row xs:(flex-col mt-2) gap-4 min-h-5">
<div v-for="column in columnsToRender" :key="column.id" class="sm:(w-1/3 max-w-1/3 overflow-hidden)">
<div v-if="!isRowEmpty({ row }, column)" class="flex flex-col gap-[-1]">
<div v-if="!isRowEmpty(currentRow, column)" class="flex flex-col gap-[-1]">
<NcTooltip class="z-10 flex" placement="bottomLeft" :arrow-point-at-center="false">
<template #title>
<LazySmartsheetHeaderVirtualCell

4
packages/nc-gui/components/nc/Switch.vue

@ -42,7 +42,7 @@ const onChange = (e: boolean, updateValue = false) => {
:class="[
contentWrapperClass,
{
'cursor-not-allowed': disabled,
'cursor-not-allowed opacity-60': disabled,
'cursor-pointer': !disabled,
},
]"
@ -69,7 +69,7 @@ const onChange = (e: boolean, updateValue = false) => {
:class="[
contentWrapperClass,
{
'cursor-not-allowed': disabled,
'cursor-not-allowed opacity-60': disabled,
'cursor-pointer': !disabled,
},
]"

82
packages/nc-gui/components/smartsheet/Cell.vue

@ -146,6 +146,34 @@ const showCurrentDateOption = computed(() => {
const currentDate = () => {
vModel.value = sqlUi.value?.getCurrentDateDefault?.(column.value)
}
const cellType = computed(() => {
if (isAI(column.value)) return 'ai'
if (isTextArea(column.value)) return 'textarea'
if (isGeoData(column.value)) return 'geoData'
if (isBoolean(column.value, abstractType.value)) return 'checkbox'
if (isAttachment(column.value)) return 'attachment'
if (isSingleSelect(column.value)) return 'singleSelect'
if (isMultiSelect(column.value)) return 'multiSelect'
if (isDate(column.value, abstractType.value)) return 'datePicker'
if (isYear(column.value, abstractType.value)) return 'yearPicker'
if (isDateTime(column.value, abstractType.value)) return 'dateTimePicker'
if (isTime(column.value, abstractType.value)) return 'timePicker'
if (isRating(column.value)) return 'rating'
if (isDuration(column.value)) return 'duration'
if (isEmail(column.value)) return 'email'
if (isURL(column.value)) return 'url'
if (isPhoneNumber(column.value)) return 'phoneNumber'
if (isPercent(column.value)) return 'percent'
if (isCurrency(column.value)) return 'currency'
if (isUser(column.value)) return 'user'
if (isDecimal(column.value)) return 'decimal'
if (isFloat(column.value, abstractType.value)) return 'float'
if (isString(column.value, abstractType.value)) return 'text'
if (isInt(column.value, abstractType.value)) return 'integer'
if (isJSON(column.value)) return 'json'
return 'text'
})
</script>
<template>
@ -165,6 +193,7 @@ const currentDate = () => {
'h-10': !isEditColumnMenu && isForm && !isAttachment(column) && !isTextArea(column) && !isJSON(column) && !props.virtual,
'nc-grid-numeric-cell-left': (isForm && isNumericField && isExpandedFormOpen) || isEditColumnMenu,
'!min-h-30': isTextArea(column) && (isForm || isSurveyForm),
'nc-cell-longtext-ai': cellType === 'ai',
},
]"
class="nc-cell w-full h-full relative"
@ -180,53 +209,58 @@ const currentDate = () => {
{{ $t('general.generating') }}
</NcTooltip>
</div>
<LazyCellTextArea v-else-if="isTextArea(column)" v-model="vModel" :virtual="props.virtual" />
<LazyCellGeoData v-else-if="isGeoData(column)" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" />
<LazyCellAttachment v-else-if="isAttachment(column)" ref="attachmentCell" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellAI v-else-if="cellType === 'ai'" v-model="vModel" @save="emit('save')" />
<LazyCellTextArea v-else-if="cellType === 'textarea'" v-model="vModel" :virtual="props.virtual" />
<LazyCellGeoData v-else-if="cellType === 'geoData'" v-model="vModel" />
<LazyCellCheckbox v-else-if="cellType === 'checkbox'" v-model="vModel" />
<LazyCellAttachment
v-else-if="cellType === 'attachment'"
ref="attachmentCell"
v-model="vModel"
:row-index="props.rowIndex"
/>
<LazyCellSingleSelect
v-else-if="isSingleSelect(column)"
v-else-if="cellType === 'singleSelect'"
v-model="vModel"
:disable-option-creation="!!isEditColumnMenu"
:row-index="props.rowIndex"
/>
<LazyCellMultiSelect
v-else-if="isMultiSelect(column)"
v-else-if="cellType === 'multiSelect'"
v-model="vModel"
:disable-option-creation="!!isEditColumnMenu"
:row-index="props.rowIndex"
/>
<LazyCellDatePicker
v-else-if="isDate(column, abstractType)"
v-else-if="cellType === 'datePicker'"
v-model="vModel"
:is-pk="isPrimaryKey(column)"
:show-current-date-option="showCurrentDateOption"
@current-date="currentDate"
/>
<LazyCellYearPicker v-else-if="isYear(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellYearPicker v-else-if="cellType === 'yearPicker'" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellDateTimePicker
v-else-if="isDateTime(column, abstractType)"
v-else-if="cellType === 'dateTimePicker'"
v-model="vModel"
:is-pk="isPrimaryKey(column)"
:is-updated-from-copy-n-paste="currentRow.rowMeta.isUpdatedFromCopyNPaste"
:show-current-date-option="showCurrentDateOption"
@current-date="currentDate"
/>
<LazyCellTimePicker v-else-if="isTime(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellRating v-else-if="isRating(column)" v-model="vModel" />
<LazyCellDuration v-else-if="isDuration(column)" v-model="vModel" />
<LazyCellEmail v-else-if="isEmail(column)" v-model="vModel" />
<LazyCellUrl v-else-if="isURL(column)" v-model="vModel" />
<LazyCellPhoneNumber v-else-if="isPhoneNumber(column)" v-model="vModel" />
<LazyCellPercent v-else-if="isPercent(column)" v-model="vModel" />
<LazyCellCurrency v-else-if="isCurrency(column)" v-model="vModel" @save="emit('save')" />
<LazyCellUser v-else-if="isUser(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellAI v-else-if="isAI(column)" v-model="vModel" @save="emit('save')" />
<LazyCellDecimal v-else-if="isDecimal(column)" v-model="vModel" />
<LazyCellFloat v-else-if="isFloat(column, abstractType)" v-model="vModel" />
<LazyCellText v-else-if="isString(column, abstractType)" v-model="vModel" />
<LazyCellInteger v-else-if="isInt(column, abstractType)" v-model="vModel" />
<LazyCellJson v-else-if="isJSON(column)" v-model="vModel" />
<LazyCellTimePicker v-else-if="cellType === 'timePicker'" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellRating v-else-if="cellType === 'rating'" v-model="vModel" />
<LazyCellDuration v-else-if="cellType === 'duration'" v-model="vModel" />
<LazyCellEmail v-else-if="cellType === 'email'" v-model="vModel" />
<LazyCellUrl v-else-if="cellType === 'url'" v-model="vModel" />
<LazyCellPhoneNumber v-else-if="cellType === 'phoneNumber'" v-model="vModel" />
<LazyCellPercent v-else-if="cellType === 'percent'" v-model="vModel" />
<LazyCellCurrency v-else-if="cellType === 'currency'" v-model="vModel" @save="emit('save')" />
<LazyCellUser v-else-if="cellType === 'user'" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellDecimal v-else-if="cellType === 'decimal'" v-model="vModel" />
<LazyCellFloat v-else-if="cellType === 'float'" v-model="vModel" />
<LazyCellText v-else-if="cellType === 'text'" v-model="vModel" />
<LazyCellInteger v-else-if="cellType === 'integer'" v-model="vModel" />
<LazyCellJson v-else-if="cellType === 'json'" v-model="vModel" />
<LazyCellText v-else v-model="vModel" />
<div
v-if="

2
packages/nc-gui/components/smartsheet/Gallery.vue

@ -373,7 +373,7 @@ reloadViewDataHook?.on(async () => {
<NcMenu @click="contextMenu = false">
<NcMenuItem v-if="contextMenuTarget" @click="expandForm(contextMenuTarget.row)">
<div v-e="['a:row:expand-record']" class="flex items-center gap-2">
<component :is="iconMap.expand" class="flex" />
<component :is="iconMap.maximize" class="flex" />
{{ $t('activity.expandRecord') }}
</div>
</NcMenuItem>

2
packages/nc-gui/components/smartsheet/Kanban.vue

@ -1143,7 +1143,7 @@ const handleSubmitRenameOrNewStack = async (loadMeta: boolean, stack?: any, stac
<NcMenu @click="contextMenu = false">
<NcMenuItem v-if="contextMenuTarget" @click="expandForm(contextMenuTarget)">
<div v-e="['a:kanban:expand-record']" class="flex items-center gap-2 nc-kanban-context-menu-item">
<component :is="iconMap.expand" class="flex" />
<component :is="iconMap.maximize" class="flex" />
<!-- Expand Record -->
{{ $t('activity.expandRecord') }}
</div>

7
packages/nc-gui/components/smartsheet/PlainCell.vue

@ -1,11 +1,13 @@
<script lang="ts" setup>
import {
type AIRecordType,
type BoolType,
type ButtonType,
type ColumnType,
type LookupType,
type RollupType,
dateFormats,
isAIPromptCol,
isCreatedOrLastModifiedByCol,
isCreatedOrLastModifiedTimeCol,
timeFormats,
@ -202,6 +204,11 @@ const getTextAreaValue = (modelValue: string | null, col: ColumnType) => {
if (isRichMode) {
return modelValue?.replace(/[*_~\[\]]|<\/?[^>]+(>|$)/g, '') || ''
}
if (isAIPromptCol(col)) {
return (modelValue as AIRecordType)?.value || ''
}
return modelValue || ''
}

17
packages/nc-gui/components/smartsheet/column/AiButtonOptions.vue

@ -366,6 +366,7 @@ onBeforeUnmount(() => {
v-model:randomness="vModel.randomness"
:workspace-id="activeWorkspaceId"
:show-tooltip="false"
:is-edit-column="isEdit"
placement="bottomRight"
>
<NcButton size="xs" theme="ai" class="!px-1" type="text">
@ -467,7 +468,11 @@ onBeforeUnmount(() => {
<a-tag v-if="outputColumnIds.includes(op.id)" :key="op.id" class="nc-ai-button-output-field">
<div class="flex flex-row items-center gap-1 py-[2px] text-sm">
<component :is="cellIcon(op)" class="!mx-0 !mr-1 opacity-80" />
<span>{{ op.title }}</span>
<NcTooltip show-on-truncate-only class="truncate max-w-[150px]">
<template #title>{{ op.title }}</template>
{{ op.title }}
</NcTooltip>
<div class="flex items-center p-0.5 mt-0.5">
<GeneralIcon
icon="close"
@ -679,8 +684,8 @@ onBeforeUnmount(() => {
<div
class="flex justify-center nc-ai-button-test-generate-wrapper"
:class="{
'text-nc-border-gray-dark': !(selectedRecordPk && outputColumnIds.length && vModel.formula_raw),
'text-nc-content-purple-dark': !!(selectedRecordPk && outputColumnIds.length && vModel.formula_raw),
'text-nc-border-gray-dark': !(selectedRecordPk && outputColumnIds.length && inputColumns.length),
'text-nc-content-purple-dark': !!(selectedRecordPk && outputColumnIds.length && inputColumns.length),
}"
>
<div class="h-2.5 w-2.5 flex-none absolute -top-[30px] border-1 border-current rounded-full bg-current"></div>
@ -690,7 +695,7 @@ onBeforeUnmount(() => {
<div>Preview checklist</div>
<div class="flex gap-2">
<div
class="h-4 w-4 mt-0.5 rounded-full grid place-items-center children:(h-3.5 w-3.5 flex-none)"
class="h-4 w-4 rounded-full grid place-items-center children:(h-3.5 w-3.5 flex-none)"
:class="
inputColumns.length
? 'bg-nc-bg-green-dark text-nc-content-green-dark'
@ -703,7 +708,7 @@ onBeforeUnmount(() => {
</div>
<div class="flex gap-2">
<div
class="h-4 w-4 mt-0.5 rounded-full grid place-items-center children:(h-3.5 w-3.5 flex-none)"
class="h-4 w-4 rounded-full grid place-items-center children:(h-3.5 w-3.5 flex-none)"
:class="
outputColumnIds.length
? 'bg-nc-bg-green-dark text-nc-content-green-dark'
@ -716,7 +721,7 @@ onBeforeUnmount(() => {
</div>
<div class="flex gap-2">
<div
class="h-4 w-4 mt-0.5 rounded-full grid place-items-center children:(h-3.5 w-3.5 flex-none)"
class="h-4 w-4 rounded-full grid place-items-center children:(h-3.5 w-3.5 flex-none)"
:class="
selectedRecordPk
? 'bg-nc-bg-green-dark text-nc-content-green-dark'

4
packages/nc-gui/components/smartsheet/column/ButtonOptions.vue

@ -177,7 +177,7 @@ const validators = {
},
],
...((isEdit.value ? vModel.value.colOptions : vModel.value.type) === ButtonActionsType.Ai
...((isEdit.value ? vModel.value.colOptions?.type : vModel.value.type) === ButtonActionsType.Ai
? {
output_column_ids: [
{
@ -215,7 +215,7 @@ if (isEdit.value) {
vModel.value.type = vModel.value?.type || buttonTypes[0].value
if (vModel.value.type === ButtonActionsType.Ai) {
vModel.value.theme = 'text'
vModel.value.theme = 'light'
vModel.value.label = 'Generate data'
vModel.value.color = 'purple'
vModel.value.icon = 'ncAutoAwesome'

34
packages/nc-gui/components/smartsheet/column/EditOrAdd.vue

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { type ColumnReqType, type ColumnType } from 'nocodb-sdk'
import { type ColumnReqType, type ColumnType, isAIPromptCol } from 'nocodb-sdk'
import {
ButtonActionsType,
UITypes,
@ -264,7 +264,7 @@ const handleScrollDebounce = useDebounceFn(() => {
}
}, 500)
const onSelectType = (uidt: UITypes | typeof AIButton, fromSearchList = false) => {
const onSelectType = (uidt: UITypes | typeof AIButton | typeof AIPrompt, fromSearchList = false) => {
let preload
if (fromSearchList && !isEdit.value && aiAutoSuggestMode.value) {
@ -276,9 +276,17 @@ const onSelectType = (uidt: UITypes | typeof AIButton, fromSearchList = false) =
preload = {
type: ButtonActionsType.Ai,
}
} else if (uidt === AIPrompt) {
formState.value.uidt = UITypes.LongText
preload = {
meta: {
[LongTextAiMetaProp]: true,
},
}
} else {
formState.value.uidt = uidt
}
onUidtOrIdTypeChange(preload)
nextTick(() => {
@ -618,6 +626,10 @@ const isAiButtonSelectOption = (uidt: string) => {
return uidt === UITypes.Button && formState.value.uidt === UITypes.Button && formState.value.type === ButtonActionsType.Ai
}
const isAiPromptSelectOption = (uidt: string) => {
return uidt === UITypes.LongText && isAIPromptCol(formState.value)
}
const aiPromptInputRef = ref<HTMLElement>()
watch(activeAiTab, (newValue) => {
@ -966,7 +978,7 @@ watch(activeAiTab, (newValue) => {
type="primary"
theme="ai"
:loading="saving"
:disabled="disableSubmitBtn || !activeTabSelectedFields.length || saving"
:disabled="disableSubmitBtn || saving"
size="small"
:label="submitBtnLabel.label"
:loading-label="submitBtnLabel.loadingLabel"
@ -1080,14 +1092,20 @@ watch(activeAiTab, (newValue) => {
v-bind="validateInfos.uidt"
:class="{
'ant-select-item-option-active-selected': showHoverEffectOnSelectedType && formState.uidt === opt.name,
'!text-nc-content-purple-dark': [AIButton].includes(opt.name),
'!text-nc-content-purple-dark': [AIPrompt, AIButton].includes(opt.name),
}"
@mouseover="handleResetHoverEffect"
>
<div class="w-full flex gap-2 items-center justify-between" :data-testid="opt.name" :data-title="formState?.type">
<div class="flex-1 flex gap-2 items-center max-w-[calc(100%_-_24px)]">
<component
:is="isAiButtonSelectOption(opt.name) && !isColumnTypeOpen ? iconMap.cellAiButton : opt.icon"
:is="
isAiButtonSelectOption(opt.name) && !isColumnTypeOpen
? iconMap.cellAiButton
: isAiPromptSelectOption(opt.name) && !isColumnTypeOpen
? iconMap.cellAi
: opt.icon
"
class="nc-field-type-icon w-4 h-4"
:class="isMetaReadOnly && !readonlyMetaAllowedTypes.includes(opt.name) ? 'text-gray-300' : 'text-gray-700'"
/>
@ -1148,7 +1166,11 @@ watch(activeAiTab, (newValue) => {
<SmartsheetColumnQrCodeOptions v-if="formState.uidt === UITypes.QrCode" v-model="formState" />
<SmartsheetColumnBarcodeOptions v-if="formState.uidt === UITypes.Barcode" v-model="formState" />
<SmartsheetColumnCurrencyOptions v-if="formState.uidt === UITypes.Currency" v-model:value="formState" />
<SmartsheetColumnLongTextOptions v-if="formState.uidt === UITypes.LongText" v-model:value="formState" />
<SmartsheetColumnLongTextOptions
v-if="formState.uidt === UITypes.LongText"
v-model="formState"
@navigate-to-integrations="handleNavigateToIntegrations"
/>
<SmartsheetColumnDurationOptions v-if="formState.uidt === UITypes.Duration" v-model:value="formState" />
<SmartsheetColumnRatingOptions v-if="formState.uidt === UITypes.Rating" v-model:value="formState" />
<SmartsheetColumnCheckboxOptions v-if="formState.uidt === UITypes.Checkbox" v-model:value="formState" />

109
packages/nc-gui/components/smartsheet/column/LongTextOptions.vue

@ -1,8 +1,8 @@
<!-- File not in use for now -->
<script setup lang="ts">
import { UITypes } from 'nocodb-sdk'
const props = defineProps<{
value: any
modelValue: any
}>()
const emit = defineEmits(['update:modelValue', 'navigateToIntegrations'])
@ -81,7 +81,40 @@ const generate = async () => {
previewFieldTitle.value = vModel.value?.title || 'temp_title'
const vModel = useVModel(props, 'value', emit)
const res = await generateRows(
meta.value.id!,
{
title: previewFieldTitle.value,
prompt_raw: vModel.value.prompt_raw,
fk_integration_id: vModel.value.fk_integration_id,
uidt: UITypes.LongText,
},
[pk],
)
if (res?.length && res[0]?.[previewFieldTitle.value]) {
previewRow.value.row = {
...res[0],
[previewFieldTitle.value]: {
value: res[0]?.[previewFieldTitle.value],
},
}
isAlreadyGenerated.value = true
}
generatingPreview.value = false
}
onMounted(() => {
// set default value
vModel.value.prompt_raw = (column?.value?.colOptions as Record<string, any>)?.prompt_raw || ''
})
if (isEdit.value) {
vModel.value.fk_integration_id = vModel.value?.colOptions?.fk_integration_id
}
provide(EditColumnInj, ref(true))
const richMode = computed({
get: () => !!vModel.value.meta?.richMode,
@ -92,13 +125,51 @@ const richMode = computed({
},
})
const handleDisableSubmitBtn = () => {
if (!isEnabledGenerateText.value) {
if (disableSubmitBtn.value) {
disableSubmitBtn.value = false
}
return
}
if (isPreviewEnabled.value) {
disableSubmitBtn.value = false
} else {
disableSubmitBtn.value = true
}
}
watch(richMode, () => {
vModel.value.cdf = null
})
watch(isPreviewEnabled, handleDisableSubmitBtn, {
immediate: true,
})
watch(
isEnabledGenerateText,
(newValue) => {
if (newValue) {
setAdditionalValidations({
fk_integration_id: [{ required: true, message: t('title.aiIntegrationMissing') }],
})
} else {
setAdditionalValidations({
fk_integration_id: [{ required: false }],
})
}
},
{
immediate: true,
},
)
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-4">
<a-form-item>
<NcTooltip :disabled="!isEnabledGenerateText">
<template #title> Rich text formatting is not supported when generate text using AI is enabled </template>
@ -260,3 +331,31 @@ watch(richMode, () => {
<AiIntegrationNotFound v-if="!aiIntegrationAvailable && isEnabledGenerateText" />
</div>
</template>
<style lang="scss" scoped>
:deep(.ant-form-item-control-input-content) {
@apply flex items-center;
}
.nc-prompt-input-wrapper {
@apply border-1 border-nc-border-gray-medium;
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08);
}
.nc-ai-options-preview {
@apply rounded-lg border-1 border-nc-border-gray-medium;
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08);
:deep(.nc-text-area-expand-btn) {
@apply right-1;
}
}
.nc-aioptions-preview-generate-btn {
&:not(.nc-is-already-generated) {
&.nc-preview-enabled {
@apply !border-transparent;
}
}
}
</style>

4
packages/nc-gui/components/smartsheet/column/UITypesOptionsWithSearch.vue

@ -20,7 +20,7 @@ const filteredOptions = computed(
() =>
options.value?.filter(
(c) =>
!(c.name === 'AIButton' && !isFeatureEnabled(FEATURE_FLAG.AI_FEATURES)) &&
!((c.name === 'AIButton' || c.name === 'AIPrompt') && !isFeatureEnabled(FEATURE_FLAG.AI_FEATURES)) &&
(c.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
(UITypesName[c.name] && UITypesName[c.name].toLowerCase().includes(searchQuery.value.toLowerCase()))),
) ?? [],
@ -126,7 +126,7 @@ watch(
'hover:bg-gray-100 cursor-pointer': !isDisabledUIType(option.name),
'bg-gray-100 nc-column-list-option-active': activeFieldIndex === index && !isDisabledUIType(option.name),
'!text-gray-400 cursor-not-allowed': isDisabledUIType(option.name),
'!text-nc-content-purple-dark': option.name === 'AIButton',
'!text-nc-content-purple-dark': option.name === 'AIButton' || option.name === 'AIPrompt',
},
]"
:data-testid="option.name"

4
packages/nc-gui/components/smartsheet/details/Fields.vue

@ -1037,6 +1037,10 @@ const onClickCopyFieldUrl = async (field: ColumnType) => {
await copy(field.id!)
isFieldIdCopied.value = true
await ncDelay(5000)
isFieldIdCopied.value = false
}
const keys = useMagicKeys()

4
packages/nc-gui/components/smartsheet/expanded-form/index.vue

@ -327,6 +327,10 @@ const copyRecordUrl = async () => {
)
isRecordLinkCopied.value = true
await ncDelay(5000)
isRecordLinkCopied.value = false
}
const saveChanges = async () => {

1
packages/nc-gui/components/smartsheet/grid/GroupByTable.vue

@ -276,6 +276,7 @@ async function deleteSelectedRowsWrapper() {
</script>
<template>
<!-- eslint-disable vue/no-restricted-v-bind -->
<Table
v-if="vGroup.rows"
v-model:selected-all-records="selectedAllRecords"

16
packages/nc-gui/components/smartsheet/grid/InfiniteTable.vue

@ -1,6 +1,5 @@
<script setup lang="ts">
import {
ButtonActionsType,
type ButtonType,
type ColumnReqType,
type ColumnType,
@ -8,6 +7,7 @@ import {
UITypes,
type ViewType,
ViewTypes,
isAIPromptCol,
isCreatedOrLastModifiedByCol,
isCreatedOrLastModifiedTimeCol,
isLinksOrLTAR,
@ -1024,8 +1024,8 @@ const isSelectedOnlyAI = computed(() => {
if (selectedRange.start.col === selectedRange.end.col) {
const field = fields.value[selectedRange.start.col]
return {
enabled: field.uidt === UITypes.Button && (field?.colOptions as ButtonType)?.type === ButtonActionsType.Ai,
disabled: !ncIsArrayIncludes(aiIntegrations.value, (field?.colOptions as ButtonType)?.fk_integration_id, 'id'),
enabled: isAIPromptCol(field) || isAiButton(field),
disabled: !(field?.colOptions as ButtonType)?.fk_integration_id,
}
}
@ -1048,9 +1048,7 @@ const generateAIBulk = async () => {
let outputColumnIds = [field.id]
const isAiButton = field.uidt === UITypes.Button && (field?.colOptions as ButtonType)?.type === ButtonActionsType.Ai
if (isAiButton) {
if (isAiButton(field)) {
outputColumnIds =
ncIsString(field.colOptions?.output_column_ids) && field.colOptions.output_column_ids.split(',').length > 0
? field.colOptions.output_column_ids.split(',')
@ -2248,13 +2246,13 @@ watch(vSelectedAllRecords, (selectedAll) => {
</span>
<div
v-else-if="!row.rowMeta?.saving && !row.rowMeta?.isLoading"
class="cursor-pointer flex items-center border-1 border-gray-100 active:ring rounded p-1 hover:(bg-gray-50)"
class="cursor-pointer flex items-center border-1 border-gray-100 active:ring rounded-md p-1 hover:(bg-white border-nc-border-gray-medium)"
>
<component
:is="iconMap.expand"
:is="iconMap.maximize"
v-if="expandForm"
v-e="['c:row-expand:open']"
class="select-none transform hover:(text-black scale-120) nc-row-expand"
class="select-none transform nc-row-expand opacity-90 w-4 h-4"
@click="expandAndLooseFocus(row, state)"
/>
</div>

16
packages/nc-gui/components/smartsheet/grid/Table.vue

@ -3,9 +3,9 @@ import axios from 'axios'
import { nextTick } from '@vue/runtime-core'
import type { ButtonType, ColumnReqType, ColumnType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import {
ButtonActionsType,
UITypes,
ViewTypes,
isAIPromptCol,
isCreatedOrLastModifiedByCol,
isCreatedOrLastModifiedTimeCol,
isLinksOrLTAR,
@ -852,8 +852,8 @@ const isSelectedOnlyAI = computed(() => {
if (selectedRange.start.col === selectedRange.end.col) {
const field = fields.value[selectedRange.start.col]
return {
enabled: field.uidt === UITypes.Button && (field?.colOptions as ButtonType)?.type === ButtonActionsType.Ai,
disabled: !ncIsArrayIncludes(aiIntegrations.value, (field?.colOptions as ButtonType)?.fk_integration_id, 'id'),
enabled: isAIPromptCol(field) || isAiButton(field),
disabled: !(field?.colOptions as ButtonType)?.fk_integration_id,
}
}
@ -876,9 +876,7 @@ const generateAIBulk = async () => {
let outputColumnIds = [field.id]
const isAiButton = field.uidt === UITypes.Button && (field?.colOptions as ButtonType)?.type === ButtonActionsType.Ai
if (isAiButton) {
if (isAiButton(field)) {
outputColumnIds =
ncIsString(field.colOptions?.output_column_ids) && field.colOptions.output_column_ids.split(',').length > 0
? field.colOptions.output_column_ids.split(',')
@ -2139,13 +2137,13 @@ onKeyStroke('ArrowDown', onDown)
</span>
<div
v-else-if="!row.rowMeta.saving"
class="cursor-pointer flex items-center border-1 border-gray-100 active:ring rounded p-1 hover:(bg-gray-50)"
class="cursor-pointer flex items-center border-1 border-gray-100 active:ring rounded-md p-1 hover:(bg-white border-nc-border-gray-medium)"
>
<component
:is="iconMap.expand"
:is="iconMap.maximize"
v-if="expandForm"
v-e="['c:row-expand:open']"
class="select-none transform hover:(text-black scale-120) nc-row-expand"
class="select-none nc-row-expand opacity-90 w-4 h-4"
@click="expandAndLooseFocus(row, state)"
/>
</div>

9
packages/nc-gui/components/smartsheet/header/Cell.vue

@ -43,9 +43,16 @@ const editColumnDropdown = ref(false)
const columnOrder = ref<Pick<ColumnReqType, 'column_order'> | null>(null)
const columnTypeName = computed(() => {
if (column.value.uidt === UITypes.LongText && parseProp(column?.value?.meta)?.richMode) {
if (column.value.uidt === UITypes.LongText) {
if (parseProp(column.value?.meta)?.richMode) {
return UITypesName.RichText
}
if (parseProp(column.value?.meta)?.[LongTextAiMetaProp]) {
return UITypesName.AIPrompt
}
}
return column.value.uidt ? UITypesName[column.value.uidt] : ''
})

4
packages/nc-gui/components/smartsheet/header/CellIcon.ts

@ -20,6 +20,8 @@ const renderIcon = (column: ColumnType, abstractType: any) => {
return iconMap.cellSingleSelect
} else if (isBoolean(column, abstractType)) {
return iconMap.cellCheckbox
} else if (isAI(column)) {
return iconMap.cellAi
} else if (isTextArea(column)) {
return iconMap.cellLongText
} else if (isEmail(column)) {
@ -51,8 +53,6 @@ const renderIcon = (column: ColumnType, abstractType: any) => {
return iconMap.cellUser
}
return iconMap.cellUser
} else if (isAI(column)) {
return iconMap.cellAi
} else if (isInt(column, abstractType) || isFloat(column, abstractType)) {
return iconMap.cellNumber
} else if (isString(column, abstractType)) {

27
packages/nc-gui/components/smartsheet/header/Menu.vue

@ -45,8 +45,21 @@ const { fieldsToGroupBy, groupByLimit } = useViewGroupByOrThrow(view)
const { isUIAllowed, isMetaReadOnly, isDataReadOnly } = useRoles()
const { aiIntegrations } = useNocoAi()
const isLoading = ref<'' | 'hideOrShow' | 'setDisplay'>('')
const columnInvalid = computed<{ isInvalid: boolean; tooltip: string }>(() => {
if (!column?.value) {
return {
isInvalid: false,
tooltip: '',
}
}
return isColumnInvalid(column.value, aiIntegrations.value, isPublic.value || !isUIAllowed('dataEdit'))
})
const setAsDisplayValue = async () => {
isLoading.value = 'setDisplay'
try {
@ -415,6 +428,10 @@ const onClickCopyFieldUrl = async (field: ColumnType) => {
await copy(field.id!)
isFieldIdCopied.value = true
await ncDelay(5000)
isFieldIdCopied.value = false
}
const onDeleteColumn = () => {
@ -431,7 +448,7 @@ const onDeleteColumn = () => {
overlay-class-name="nc-dropdown-column-operations !border-1 rounded-lg !shadow-xl "
@click.stop="openDropdown"
>
<div class="flex gap-0.5 items-center" @dblclick.stop>
<div class="flex gap-1 items-center" @dblclick.stop>
<div v-if="isExpandedForm" class="h-[1px]">&nbsp;</div>
<NcTooltip v-if="column?.description?.length && !isExpandedForm" class="flex">
<template #title>
@ -441,14 +458,10 @@ const onDeleteColumn = () => {
</NcTooltip>
<NcTooltip class="flex items-center">
<GeneralIcon
v-if="isColumnInvalid(column) && !isExpandedForm"
class="text-orange-500 w-3.5 h-3.5 ml-2"
icon="alertTriangle"
/>
<GeneralIcon v-if="columnInvalid.isInvalid && !isExpandedForm" class="text-red-300 w-3.5 h-3.5" icon="alertTriangle" />
<template #title>
{{ $t('msg.invalidColumnConfiguration') }}
{{ $t(columnInvalid.tooltip) }}
</template>
</NcTooltip>
<GeneralIcon

7
packages/nc-gui/components/smartsheet/header/VirtualCell.vue

@ -126,13 +126,14 @@ const showTooltipAlways = computed(() => {
const columnOrder = ref<Pick<ColumnReqType, 'column_order'> | null>(null)
const columnTypeName = computed(() => {
if (column.value.uidt === UITypes.LongText && parseProp(column?.value?.meta)?.richMode) {
return UITypesName.RichText
}
if (column.value.uidt === UITypes.LinkToAnotherRecord && column.value.colOptions?.type === RelationTypes.ONE_TO_ONE) {
return UITypesName[UITypes.Links]
}
if (isAiButton(column.value)) {
return UITypesName.AIButton
}
return column.value.uidt ? UITypesName[column.value.uidt] : ''
})

4
packages/nc-gui/components/smartsheet/toolbar/ViewActionMenu.vue

@ -152,6 +152,10 @@ const { copy } = useCopy()
const onViewIdCopy = async () => {
await copy(view.value!.id!)
isViewIdCopied.value = true
await ncDelay(5000)
isViewIdCopied.value = false
}
const onDelete = async () => {

2
packages/nc-gui/components/virtual-cell/Button.vue

@ -210,7 +210,7 @@ const componentProps = computed(() => {
<style scoped lang="scss">
.nc-cell-button {
@apply rounded-lg px-2 flex items-center gap-2 transition-all justify-center;
@apply rounded-md px-2 flex items-center gap-2 transition-all justify-center;
&:not([class*='text']) {
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.06), 0px 5px 3px -2px rgba(0, 0, 0, 0.02);
}

4
packages/nc-gui/components/virtual-cell/HasMany.vue

@ -146,8 +146,8 @@ watch(
<div v-if="!isUnderLookup && !isSystemColumn(column)" class="flex justify-end gap-1 min-h-4 items-center">
<GeneralIcon
icon="expand"
class="select-none transform text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand"
icon="maximize"
class="select-none transform text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand h-3 w-3"
@click.stop="openChildList"
/>

4
packages/nc-gui/components/virtual-cell/ManyToMany.vue

@ -145,8 +145,8 @@ watch(
<div v-if="!isUnderLookup || isForm" class="flex justify-end gap-1 min-h-4 items-center">
<GeneralIcon
icon="expand"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand"
icon="maximize"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand h-3 w-3"
@click.stop="openChildList"
/>

4
packages/nc-gui/components/virtual-cell/components/ListItem.vue

@ -1,8 +1,6 @@
<script lang="ts" setup>
import { UITypes, isVirtualCol, parseStringDateTime } from 'nocodb-sdk'
import MaximizeIcon from '~icons/nc-icons/maximize'
const props = withDefaults(
defineProps<{
row: any
@ -161,7 +159,7 @@ const displayValue = computed(() => {
class="z-10 flex items-center justify-center nc-expand-item !group-hover:visible !invisible !h-7 !w-7 transition-all !hover:children:(w-4.5 h-4.5)"
@click.stop="$emit('expand', row)"
>
<MaximizeIcon class="flex-none w-4 h-4 scale-125" />
<GeneralIcon icon="maximize" class="flex-none w-4 h-4 scale-125" />
</button>
</NcTooltip>
</div>

6
packages/nc-gui/components/workspace/integrations/ConnectionsTab.vue

@ -20,8 +20,6 @@ const {
setDefaultIntegration,
} = useIntegrationStore()
const { loadAiIntegrations } = useNocoAi()
const { $api, $e } = useNuxtApp()
const { allCollaborators } = storeToRefs(useWorkspace())
@ -164,10 +162,6 @@ const onDeleteConfirm = async () => {
sources: [...base.sources.filter((s) => s.id !== source.id)],
})
}
if (toBeDeletedIntegration.value?.type && toBeDeletedIntegration.value.type === IntegrationCategoryType.AI) {
loadAiIntegrations()
}
}
}

3
packages/nc-gui/components/workspace/integrations/IntegrationsTab.vue

@ -44,6 +44,7 @@ const {
integrationsRefreshKey,
integrationsCategoryFilter,
activeViewTab,
loadDynamicIntegrations,
} = useIntegrationStore()
const focusTextArea: VNodeRef = (el) => el && el?.focus?.()
@ -218,6 +219,8 @@ const toggleShowOrHideAllCategory = () => {
}
onMounted(() => {
loadDynamicIntegrations()
if (!integrationsCategoryFilter.value.length) {
integrationsCategoryFilter.value = integrationCategoriesRef.value.map((c) => c.value)
}

8
packages/nc-gui/components/workspace/integrations/forms/EditOrAddCommon.vue

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { IntegrationCategoryType, type SyncDataType } from '#imports'
import type { IntegrationCategoryType, SyncDataType } from '#imports'
import { clientTypes as _clientTypes } from '#imports'
const props = defineProps<{
@ -22,8 +22,6 @@ const {
updateIntegration,
} = useIntegrationStore()
const { loadAiIntegrations } = useNocoAi()
const isEditMode = computed(() => pageMode.value === IntegrationsPageMode.EDIT)
const initState = ref({
@ -43,10 +41,6 @@ const { form, formState, isLoading, initialState, submit } = useProvideFormBuild
try {
if (pageMode.value === IntegrationsPageMode.ADD) {
await saveIntegration(formState.value)
if (props.integrationType === IntegrationCategoryType.AI) {
loadAiIntegrations()
}
} else {
await updateIntegration({
id: activeIntegration.value?.id,

12
packages/nc-gui/components/workspace/integrations/forms/EditOrAddDatabase.vue

@ -658,12 +658,12 @@ watch(
>
<NcButton
type="text"
size="xsmall"
class="nc-extdb-btn-import-url !rounded-md !h-6 !px-2 flex-none"
size="small"
class="nc-extdb-btn-import-url !rounded-md !px-2 flex-none -my-1.5"
@click.stop="importURLDlg = true"
>
<div class="flex items-center gap-2">
<GeneralIcon icon="magic" class="flex-none text-yellow-500" />
<GeneralIcon icon="ncLink" class="flex-none" />
{{ $t('activity.useConnectionUrl') }}
</div>
</NcButton>
@ -672,7 +672,13 @@ watch(
<div class="text-sm text-gray-700">
Auto populate connection configuration using database connection URL
</div>
<a-textarea
:ref="
(el) => {
el?.$el?.focus()
}
"
v-model:value="importURL"
class="!rounded-lg !min-h-[120px] !max-h-[250px] nc-scrollbar-thin"
></a-textarea>

31
packages/nc-gui/components/workspace/project/AiCreateProject.vue

@ -28,6 +28,8 @@ const { workspaceId, baseType } = toRefs(props)
const { navigateToProject } = useGlobal()
const { $e } = useNuxtApp()
const { clone } = useUndoRedo()
const { aiIntegrationAvailable, aiError, aiLoading, createSchema, predictSchema } = useNocoAi()
@ -145,6 +147,21 @@ const onPredictSchema = async () => {
let currentMessageIndex = 0
let currentCharIndex = 0
let prompt = `${aiFormState.value.prompt}`
// Append optional information if provided
if (aiFormState.value.organization?.trim()) {
prompt += ` | Organization: ${aiFormState.value.organization}`
}
if (aiFormState.value.industry?.trim()) {
prompt += ` | Industry: ${aiFormState.value.industry}`
}
if (aiFormState.value.audience?.trim()) {
prompt += ` | Audience: ${aiFormState.value.audience}`
}
$e('a:base:ai:generate', prompt)
try {
const displayCharByChar = () => {
const currentMessage = loadingMessages[currentMessageIndex]
@ -169,19 +186,6 @@ const onPredictSchema = async () => {
// Set interval to display characters one by one
timerId = setInterval(displayCharByChar, 40) // Adjust the speed as needed (100ms)
let prompt = `${aiFormState.value.prompt}`
// Append optional information if provided
if (aiFormState.value.organization?.trim()) {
prompt += ` | Organization: ${aiFormState.value.organization}`
}
if (aiFormState.value.industry?.trim()) {
prompt += ` | Industry: ${aiFormState.value.industry}`
}
if (aiFormState.value.audience?.trim()) {
prompt += ` | Audience: ${aiFormState.value.audience}`
}
const res = await predictSchema(prompt)
if (!res?.tables) {
@ -465,7 +469,6 @@ onMounted(() => {
</div>
<div v-if="aiIntegrationAvailable" class="flex items-center gap-3">
<NcButton
v-e="['a:base:ai:generate']"
size="small"
:type="aiStep !== AI_STEP.MODIFY || isOldPromptChanged ? 'primary' : 'secondary'"
theme="ai"

6
packages/nc-gui/composables/useColumnCreateStore.ts

@ -1,6 +1,6 @@
import rfdc from 'rfdc'
import type { ColumnReqType, ColumnType, TableType } from 'nocodb-sdk'
import { ButtonActionsType, UITypes, isLinksOrLTAR } from 'nocodb-sdk'
import { ButtonActionsType, UITypes, isAIPromptCol, isLinksOrLTAR } from 'nocodb-sdk'
import type { Ref } from 'vue'
import type { RuleObject } from 'ant-design-vue/es/form'
import { generateUniqueColumnName } from '~/helpers/parsers/parserHelpers'
@ -110,6 +110,10 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
return true
}
if (isAIPromptCol(formState.value)) {
return true
}
return false
})

3
packages/nc-gui/composables/useData.ts

@ -1,5 +1,5 @@
import type { ColumnType, LinkToAnotherRecordType, PaginatedType, RelationTypes, TableType, ViewType } from 'nocodb-sdk'
import { UITypes, isCreatedOrLastModifiedByCol, isCreatedOrLastModifiedTimeCol } from 'nocodb-sdk'
import { UITypes, isAIPromptCol, isCreatedOrLastModifiedByCol, isCreatedOrLastModifiedTimeCol } from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue'
import type { CellRange } from '#imports'
@ -286,6 +286,7 @@ export function useData(args: {
col.uidt === UITypes.Lookup ||
col.uidt === UITypes.Button ||
col.uidt === UITypes.Attachment ||
isAIPromptCol(col) ||
col.au ||
(isValidValue(col?.cdf) && / on update /i.test(col.cdf)))
)

3
packages/nc-gui/composables/useInfiniteData.ts

@ -1,5 +1,5 @@
import type { ComputedRef, Ref } from 'vue'
import { UITypes, extractFilterFromXwhere } from 'nocodb-sdk'
import { UITypes, extractFilterFromXwhere, isAIPromptCol } from 'nocodb-sdk'
import type { Api, ColumnType, LinkToAnotherRecordType, PaginatedType, RelationTypes, TableType, ViewType } from 'nocodb-sdk'
import type { Row } from '../lib/types'
import { validateRowFilters } from '../utils/dataUtils'
@ -916,6 +916,7 @@ export function useInfiniteData(args: {
col.title &&
col.title in updatedRowData &&
(columnsToUpdate.has(col.uidt as UITypes) ||
isAIPromptCol(col) ||
col.au ||
(isValidValue(col?.cdf) && / on update /i.test(col.cdf as string)))
) {

57
packages/nc-gui/composables/useIntegrationsStore.ts

@ -90,6 +90,8 @@ const [useProvideIntegrationViewStore, _useIntegrationStore] = useInjectionState
const { t } = useI18n()
const { aiIntegrations } = useNocoAi()
const isFromIntegrationPage = ref(false)
const integrationsRefreshKey = ref(0)
@ -186,12 +188,17 @@ const [useProvideIntegrationViewStore, _useIntegrationStore] = useInjectionState
const deleteIntegration = async (integration: IntegrationType, force = false) => {
if (!integration?.id) return
$e('a:integration:delete')
try {
await api.integration.delete(integration.id, {
query: force ? { force: 'true' } : {},
})
$e('a:integration:delete')
if (integration.type === IntegrationsType.Ai) {
aiIntegrations.value = aiIntegrations.value.filter((i) => i.id !== integration.id)
}
await loadIntegrations()
// await message.success(`Connection ${integration.title} deleted successfully`)
@ -214,10 +221,21 @@ const [useProvideIntegrationViewStore, _useIntegrationStore] = useInjectionState
const updateIntegration = async (integration: IntegrationType) => {
if (!integration.id) return
$e('a:integration:update')
try {
await api.integration.update(integration.id, integration)
$e('a:integration:update')
if (integration.type === IntegrationsType.Ai) {
aiIntegrations.value = aiIntegrations.value.map((i) => {
if (i.id === integration.id) {
i.title = integration.title
}
return i
})
}
await loadIntegrations()
pageMode.value = null
@ -232,10 +250,23 @@ const [useProvideIntegrationViewStore, _useIntegrationStore] = useInjectionState
const setDefaultIntegration = async (integration: IntegrationType) => {
if (!integration.id) return
$e('a:integration:set-default')
try {
await api.integration.setDefault(integration.id)
$e('a:integration:set-default')
if (integration.type === IntegrationsType.Ai) {
aiIntegrations.value = aiIntegrations.value.map((i) => {
if (i.id === integration.id) {
i.is_default = true
} else {
i.is_default = false
}
return i
})
}
await loadIntegrations()
pageMode.value = null
@ -253,18 +284,29 @@ const [useProvideIntegrationViewStore, _useIntegrationStore] = useInjectionState
loadDatasourceInfo = false,
baseId: string | undefined = undefined,
) => {
try {
const response = await api.integration.create(integration)
if (mode === 'create') {
$e('a:integration:create')
} else {
$e('a:integration:duplicate')
}
try {
const response = await api.integration.create(integration)
if (response && response?.id) {
if (!loadDatasourceInfo) {
integrations.value.push(response)
}
if (response.type === IntegrationsType.Ai) {
aiIntegrations.value.push({
id: response.id,
title: response.title,
is_default: response.is_default,
type: response.type,
sub_type: response.sub_type,
})
}
}
await loadIntegrations(loadDatasourceInfo, baseId)
@ -402,7 +444,7 @@ const [useProvideIntegrationViewStore, _useIntegrationStore] = useInjectionState
return list
}
onMounted(async () => {
const loadDynamicIntegrations = async () => {
if (integrationsInitialized.value) return
integrationsInitialized.value = true
@ -451,7 +493,7 @@ const [useProvideIntegrationViewStore, _useIntegrationStore] = useInjectionState
integrationsRefreshKey.value++
}
})
}
const integrationsIconMap = computed(() => {
// eslint-disable-next-line no-unused-expressions
@ -496,6 +538,7 @@ const [useProvideIntegrationViewStore, _useIntegrationStore] = useInjectionState
setDefaultIntegration,
integrationsIconMap,
listIntegrationByType,
loadDynamicIntegrations,
}
}, 'integrations-store')

9
packages/nc-gui/composables/useMultiSelect/index.ts

@ -3,6 +3,7 @@ import { computed } from 'vue'
import dayjs from 'dayjs'
import type { MaybeRef } from '@vueuse/core'
import type {
AIRecordType,
AttachmentType,
ColumnType,
LinkToAnotherRecordType,
@ -288,8 +289,16 @@ export function useMultiSelect(
}
if (columnObj.uidt === UITypes.LongText) {
if (parseProp(columnObj.meta)?.[LongTextAiMetaProp] === true) {
const aiCell: AIRecordType = (columnObj.title && rowObj.row[columnObj.title]) || null
if (aiCell) {
textToCopy = aiCell.value
}
} else {
textToCopy = `"${textToCopy.replace(/"/g, '\\"')}"`
}
}
return textToCopy
}

38
packages/nc-gui/composables/useNocoAi.ts

@ -1,4 +1,4 @@
import { type IntegrationType, IntegrationsType, type SerializedAiViewType, type TableType } from 'nocodb-sdk'
import type { IntegrationType, SerializedAiViewType, TableType } from 'nocodb-sdk'
const aiIntegrationNotFound = 'AI integration not found'
@ -13,15 +13,19 @@ export const useNocoAi = createSharedComposable(() => {
const { activeProjectId } = storeToRefs(basesStore)
const aiIntegrationAvailable = ref(true)
const aiLoading = ref(false)
const aiError = ref<string>('')
const aiIntegrations = ref<IntegrationType[]>([])
const aiIntegrations = ref<Partial<IntegrationType>[]>([])
const aiIntegrationAvailable = computed(() => !!aiIntegrations.value.length)
const { listIntegrationByType } = useProvideIntegrationViewStore()
const isAiIntegrationAvailableInList = (integrationId?: string) => {
if (!aiIntegrationAvailable.value) return false
return ncIsArrayIncludes(aiIntegrations.value, integrationId, 'id')
}
const callAiUtilsApi = async (operation: string, input: any, customBaseId?: string, skipMsgToast = false) => {
try {
@ -338,30 +342,9 @@ export const useNocoAi = createSharedComposable(() => {
}
}
const loadAiIntegrations = async () => {
aiIntegrations.value = (await listIntegrationByType(IntegrationsType.Ai)) || []
if (aiIntegrations.value.length) {
aiIntegrationAvailable.value = true
} else {
aiIntegrationAvailable.value = false
}
}
const { signedIn } = useGlobal()
watch(
signedIn,
(val) => {
if (val) {
loadAiIntegrations()
}
},
{ immediate: true },
)
return {
aiIntegrationAvailable,
isAiIntegrationAvailableInList,
aiLoading,
aiError,
predictFieldType,
@ -381,6 +364,5 @@ export const useNocoAi = createSharedComposable(() => {
repairFormula,
predictViews,
aiIntegrations,
loadAiIntegrations,
}
})

12
packages/nc-gui/helpers/parsers/parserHelpers.ts

@ -1,4 +1,4 @@
import { ButtonActionsType, type ColumnType, FieldNameFromUITypes, UITypes } from 'nocodb-sdk'
import { ButtonActionsType, type ColumnType, FieldNameFromUITypes, UITypes, UITypesName } from 'nocodb-sdk'
import isURL from 'validator/lib/isURL'
import { pluralize } from 'inflection'
@ -478,8 +478,16 @@ export const generateUniqueColumnName = ({
case UITypes.Button: {
if (formState?.type === ButtonActionsType.Ai) {
defaultColumnName = `AI ${defaultColumnName}`
defaultColumnName = UITypesName.AIButton
}
break
}
case UITypes.LongText: {
if (formState?.meta?.[LongTextAiMetaProp] === true) {
defaultColumnName = UITypesName.AIPrompt
}
break
}
}

16
packages/nc-gui/helpers/tiptapExtensions/mention/FieldList.vue

@ -56,6 +56,12 @@ export default {
return true
}
if (event.key === '}') {
setTimeout(() => {
this.selectItem(this.selectedIndex)
}, 250)
}
return false
},
@ -80,6 +86,7 @@ export default {
selectItem(index, _e) {
const item = this.items[index]
if (item) {
this.command({
id: item.title,
@ -92,7 +99,7 @@ export default {
<template>
<div
class="w-64 bg-white scroll-smooth nc-mention-list nc-scrollbar-md border-1 border-gray-200 rounded-lg max-h-64 !py-2 shadow-lg"
class="w-64 bg-white scroll-smooth nc-mention-list nc-scrollbar-thin border-1 border-gray-200 rounded-lg max-h-64 !py-2 px-2 shadow-lg"
@mousedown.stop
>
<template v-if="items.length">
@ -100,11 +107,16 @@ export default {
v-for="(item, index) in items"
:key="index"
:class="{ 'is-selected': index === selectedIndex }"
class="py-2 flex hover:bg-gray-100 transition-all cursor-pointer items-center gap-2 text-gray-800 pl-4"
class="py-2 flex hover:bg-gray-100 rounded-md transition-all cursor-pointer items-center gap-2 text-gray-800 pl-4"
@click="selectItem(index, $event)"
>
<component :is="getUIDTIcon(item.uidt)" v-if="item?.uidt" class="flex-none w-3.5 h-3.5" />
<NcTooltip class="truncate" show-on-truncate-only :tooltip-style="{ zIndex: '10000' }">
<template #title>
{{ item?.title || '' }}
</template>
{{ item?.title || '' }}
</NcTooltip>
</div>
</template>
<div v-else class="px-4">No field available</div>

4
packages/nc-gui/nuxt.config.ts

@ -226,6 +226,8 @@ export default defineNuxtConfig({
'@vuelidate/core',
'@vuelidate/validators',
'company-email-validator',
'crossoriginworker',
'dayjs/plugin/utc',
'd3-scale',
'dagre',
'deep-object-diff',
@ -237,6 +239,7 @@ export default defineNuxtConfig({
'marked',
'mime-lite',
'monaco-editor',
'monaco-editor/esm/vs/basic-languages/javascript/javascript',
'papaparse',
'prosemirror-state',
'rehype-sanitize',
@ -252,6 +255,7 @@ export default defineNuxtConfig({
'validator/es/lib/isEmail',
'validator/lib/isMobilePhone',
'vue-advanced-cropper',
'vue-barcode-reader',
'xlsx',
'youtube-vue3',
'vuedraggable',

4
packages/nc-gui/utils/cell.ts

@ -40,7 +40,9 @@ export const isUser = (column: ColumnType) => column.uidt === UITypes.User
export const isButton = (column: ColumnType) => column.uidt === UITypes.Button
export const isAiButton = (column: ColumnType) =>
column.uidt === UITypes.Button && (column?.colOptions as any)?.type === ButtonActionsType.Ai
export const isAI = (_column: ColumnType) => false
export const isAI = (column: ColumnType) =>
column.uidt === UITypes.LongText && parseProp(column?.meta)?.[LongTextAiMetaProp] === true
export const isAutoSaved = (column: ColumnType) =>
[
UITypes.SingleLineText,

58
packages/nc-gui/utils/columnUtils.ts

@ -1,6 +1,6 @@
import type { FunctionalComponent, SVGAttributes } from 'vue'
import type { ButtonType, ColumnType, FormulaType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { ButtonActionsType, RelationTypes, UITypes } from 'nocodb-sdk'
import type { ButtonType, ColumnType, FormulaType, IntegrationType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { ButtonActionsType, RelationTypes, UITypes, LongTextAiMetaProp as _LongTextAiMetaProp } from 'nocodb-sdk'
export interface UiTypesType {
name: UITypes | string
@ -12,6 +12,10 @@ export interface UiTypesType {
export const AIButton = 'AIButton'
export const AIPrompt = 'AIPrompt'
export const LongTextAiMetaProp = _LongTextAiMetaProp
const uiTypes: UiTypesType[] = [
{
name: AIButton,
@ -20,6 +24,12 @@ const uiTypes: UiTypesType[] = [
isNew: 1,
deprecated: 0,
},
{
name: AIPrompt,
icon: iconMap.cellAi,
isNew: 1,
deprecated: 0,
},
{
name: UITypes.Links,
icon: iconMap.cellLinks,
@ -256,23 +266,53 @@ const isColumnSupportsGroupBySettings = (colOrUidt: ColumnType) => {
return [UITypes.SingleSelect, UITypes.User, UITypes.CreatedBy, UITypes.Checkbox, UITypes.Rating].includes(uidt)
}
const isColumnInvalid = (col: ColumnType) => {
const isColumnInvalid = (
col: ColumnType,
aiIntegrations: Partial<IntegrationType>[] = [],
isReadOnly: boolean = false,
): { isInvalid: boolean; tooltip: string } => {
const result = {
isInvalid: false,
tooltip: 'msg.invalidColumnConfiguration',
}
switch (col.uidt) {
case UITypes.Formula:
return !!(col.colOptions as FormulaType).error
result.isInvalid = !!(col.colOptions as FormulaType).error
break
case UITypes.Button: {
const colOptions = col.colOptions as ButtonType
if (colOptions.type === ButtonActionsType.Webhook) {
return !colOptions.fk_webhook_id
result.isInvalid = !colOptions.fk_webhook_id
} else if (colOptions.type === ButtonActionsType.Url) {
return !!colOptions.error
}
result.isInvalid = !!colOptions.error
} else if (colOptions.type === ButtonActionsType.Ai) {
result.isInvalid =
!colOptions.fk_integration_id ||
(isReadOnly
? false
: !!colOptions.fk_integration_id && !ncIsArrayIncludes(aiIntegrations, colOptions.fk_integration_id, 'id'))
result.tooltip = 'title.aiIntegrationMissing'
}
break
}
case UITypes.LongText: {
if (parseProp(col.meta)[LongTextAiMetaProp]) {
const colOptions = col.colOptions as ButtonType
result.isInvalid =
!colOptions.fk_integration_id ||
(isReadOnly
? false
: !!colOptions.fk_integration_id && !ncIsArrayIncludes(aiIntegrations, colOptions.fk_integration_id, 'id'))
if (col.uidt === UITypes.Formula) {
return !!(col.colOptions as FormulaType).error
result.tooltip = 'title.aiIntegrationMissing'
}
break
}
}
return result
}
// cater existing v1 cases

44
packages/nc-gui/utils/commonUtils.ts

@ -71,3 +71,47 @@ export const ncArrayFrom = <T>(
export const isUnicodeEmoji = (emoji: string) => {
return !!emoji?.match(/(\p{Emoji}|\p{Extended_Pictographic})/gu)
}
/**
* Performs a case-insensitive search to check if the `query` exists within the `source`.
*
* - If `source` is an array, the function checks if any element (converted to a string) contains the `query`.
* - If `source` is a string or number, it checks if the `query` exists within `source` (case-insensitively).
* - If `source` or `query` is `undefined`, they are treated as empty strings.
*
* @param source - The value to search within. Can be a string, number, or an array of strings/numbers.
* @param query - The value to search for. Treated as an empty string if `undefined`.
* @returns `true` if the `query` is found within the `source` (case-insensitively), otherwise `false`.
*
* @example
* ```typescript
* // Single string or number search
* searchCompare("Hello World", "world"); // true
* searchCompare("OpenAI ChatGPT", "gpt"); // true
* searchCompare("TypeScript", "JavaScript"); // false
*
* // Array search
* searchCompare(["apple", "banana", "cherry"], "Banana"); // true
* searchCompare([123, 456, 789], "456"); // true
* searchCompare([null, undefined, "test"], "TEST"); // true
*
* // Handling undefined
* searchCompare(undefined, "test"); // false
* searchCompare("test", undefined); // true
* ```
*/
export const searchCompare = (source?: string | number | (string | number | undefined)[], query?: string): boolean => {
if (ncIsArray(source)) {
return source.some((item) => {
return (item || '')
.toString()
.toLowerCase()
.includes((query || '').toLowerCase())
})
}
return (source || '')
.toString()
.toLowerCase()
.includes((query || '').toLowerCase())
}

3
packages/nocodb-sdk/src/lib/Api.ts

@ -8741,7 +8741,6 @@ export class Api<
orgs: string,
baseName: string,
tableName: string,
data: object,
query?: {
where?: string;
viewId?: string;
@ -8758,8 +8757,6 @@ export class Api<
path: `/api/v1/db/data/bulk/${orgs}/${baseName}/${tableName}/all`,
method: 'DELETE',
query: query,
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),

12
packages/nocodb-sdk/src/lib/UITypes.ts

@ -1,6 +1,7 @@
import { ColumnReqType, ColumnType, TableType } from './Api';
import { FormulaDataTypes } from './formulaHelpers';
import { RelationTypes } from '~/lib/globals';
import { LongTextAiMetaProp, RelationTypes } from '~/lib/globals';
import { parseHelper } from './helperFunctions';
enum UITypes {
ID = 'ID',
@ -90,6 +91,7 @@ export const UITypesName = {
[UITypes.CreatedBy]: 'Created by',
[UITypes.LastModifiedBy]: 'Last modified by',
AIButton: 'AI Button',
AIPrompt: 'AI Prompt',
};
export const FieldNameFromUITypes: Record<UITypes, string> = {
@ -186,6 +188,14 @@ export function isVirtualCol(
].includes(<UITypes>(typeof col === 'object' ? col?.uidt : col));
}
export function isAIPromptCol(
col:
| ColumnReqType
| ColumnType
) {
return col.uidt === UITypes.LongText && parseHelper((col as any)?.meta)?.[LongTextAiMetaProp];
}
export function isCreatedOrLastModifiedTimeCol(
col:
| UITypes

2
packages/nocodb-sdk/src/lib/globals.ts

@ -229,6 +229,8 @@ export enum NcErrorType {
INVALID_ATTACHMENT_UPLOAD_SCOPE = 'INVALID_ATTACHMENT_UPLOAD_SCOPE',
}
export const LongTextAiMetaProp = 'ai';
type Roles = OrgUserRoles | ProjectRoles | WorkspaceUserRoles;
type RolesObj = Partial<Record<Roles, boolean>>;

16
packages/nocodb-sdk/src/lib/helperFunctions.ts

@ -226,4 +226,20 @@ export const getTestDatabaseName = (db: {
export const integrationCategoryNeedDefault = (category: IntegrationsType) => {
return [IntegrationsType.Ai].includes(category);
};
export function parseHelper(v: any): any {
try {
return typeof v === 'string' ? JSON.parse(v) : v;
} catch {
return v;
}
}
export function stringifyHelper(v: any): string {
try {
return JSON.stringify(v);
} catch {
return v;
}
}

1
packages/nocodb-sdk/src/lib/index.ts

@ -12,6 +12,7 @@ export {
UITypesName,
FieldNameFromUITypes,
numericUITypes,
isAIPromptCol,
isNumericCol,
isVirtualCol,
isLinksOrLTAR,

72
packages/nocodb/src/db/BaseModelSqlv2.ts

@ -10,11 +10,13 @@ import {
AuditOperationTypes,
ButtonActionsType,
extractFilterFromXwhere,
isAIPromptCol,
isCreatedOrLastModifiedByCol,
isCreatedOrLastModifiedTimeCol,
isLinksOrLTAR,
isSystemColumn,
isVirtualCol,
LongTextAiMetaProp,
RelationTypes,
UITypes,
} from 'nocodb-sdk';
@ -5716,7 +5718,10 @@ class BaseModelSqlv2 {
await this.model.getColumns(this.context);
await Promise.all(
insertDatas.map((d) => this.prepareNocoData(d, true, cookie)),
insertDatas.map(
async (d) =>
await this.prepareNocoData(d, true, cookie, null, { raw }),
),
);
}
@ -5935,7 +5940,7 @@ class BaseModelSqlv2 {
}
}
} else {
await this.prepareNocoData(d, false, cookie);
await this.prepareNocoData(d, false, cookie, null, { raw });
const wherePk = await this._wherePk(pkValues, true);
@ -8628,23 +8633,25 @@ class BaseModelSqlv2 {
jsonColumns: Record<string, any>[],
d: Record<string, any>,
) {
try {
if (d) {
for (const col of jsonColumns) {
if (d[col.id] && typeof d[col.id] === 'string') {
try {
d[col.id] = JSON.parse(d[col.id]);
} catch {}
}
if (d[col.id]?.length) {
for (let i = 0; i < d[col.id].length; i++) {
if (typeof d[col.id][i] === 'string') {
try {
d[col.id][i] = JSON.parse(d[col.id][i]);
} catch {}
}
}
}
}
}
} catch {}
return d;
}
@ -8671,13 +8678,16 @@ class BaseModelSqlv2 {
for (const col of columns) {
if (col.uidt === UITypes.Lookup) {
const lookupNestedCol = await this.getNestedColumn(col);
if (
JSON_COLUMN_TYPES.includes((await this.getNestedColumn(col))?.uidt)
JSON_COLUMN_TYPES.includes(lookupNestedCol.uidt) ||
isAIPromptCol(lookupNestedCol)
) {
jsonCols.push(col);
}
} else {
if (JSON_COLUMN_TYPES.includes(col.uidt)) {
if (JSON_COLUMN_TYPES.includes(col.uidt) || isAIPromptCol(col)) {
jsonCols.push(col);
}
}
@ -9766,7 +9776,8 @@ class BaseModelSqlv2 {
cookie?: { user?: any; system?: boolean },
// oldData uses title as key where as data uses column_name as key
oldData?,
) {
extra?: { raw?: boolean },
): Promise<void> {
for (const column of this.model.columns) {
if (
![
@ -9777,7 +9788,10 @@ class BaseModelSqlv2 {
UITypes.LastModifiedTime,
UITypes.CreatedBy,
UITypes.LastModifiedBy,
].includes(column.uidt)
UITypes.LongText,
].includes(column.uidt) ||
(column.uidt === UITypes.LongText &&
column.meta?.[LongTextAiMetaProp] !== true)
)
continue;
@ -10046,6 +10060,48 @@ class BaseModelSqlv2 {
) {
data[column.column_name] = JSON.stringify(data[column.column_name]);
}
} else if (isAIPromptCol(column) && !extra?.raw) {
if (data[column.column_name]) {
let value = data[column.column_name];
if (typeof value === 'object') {
value = value.value;
}
const obj: {
value?: string;
lastModifiedBy?: string;
lastModifiedTime?: string;
isStale?: string;
} = {};
if (cookie?.system === true) {
Object.assign(obj, {
value,
lastModifiedBy: null,
lastModifiedTime: null,
isStale: false,
});
} else {
const oldObj = oldData?.[column.title];
const isStale = oldObj ? oldObj.isStale : false;
const isModified = oldObj?.value !== value;
Object.assign(obj, {
value,
lastModifiedBy: isModified
? cookie?.user?.id
: oldObj?.lastModifiedBy,
lastModifiedTime: isModified
? this.now()
: oldObj?.lastModifiedTime,
isStale: isModified ? false : isStale,
});
}
data[column.column_name] = JSON.stringify(obj);
}
}
}
}

19
packages/nocodb/src/db/conditionV2.ts

@ -1,6 +1,7 @@
import {
FormulaDataTypes,
getEquivalentUIType,
isAIPromptCol,
isDateMonthFormat,
isNumericCol,
RelationTypes,
@ -592,6 +593,24 @@ const parseConditionV2 = async (
? 'YYYY-MM-DD HH:mm:ss'
: 'YYYY-MM-DD HH:mm:ssZ';
if (isAIPromptCol(column)) {
if (knex.clientType() === 'pg') {
field = knex.raw(`TRIM('"' FROM (??::jsonb->>'value'))`, [
column.column_name,
]);
} else if (knex.clientType().startsWith('mysql')) {
field = knex.raw(`JSON_UNQUOTE(JSON_EXTRACT(??, '$.value'))`, [
column.column_name,
]);
} else if (knex.clientType() === 'sqlite3') {
field = knex.raw(`json_extract(??, '$.value')`, [
column.column_name,
]);
} else if (knex.clientType() === 'mssql') {
field = knex.raw(`JSON_VALUE(??, '$.value')`, [column.column_name]);
}
}
if (
(column.uidt === UITypes.Formula &&
getEquivalentUIType({ formulaColumn: column }) ==

42
packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts

@ -2,6 +2,7 @@ import jsep from 'jsep';
import {
FormulaDataTypes,
jsepCurlyHook,
LongTextAiMetaProp,
RelationTypes,
UITypes,
validateDateWithUnknownFormat,
@ -828,6 +829,47 @@ async function _formulaQueryBuilder(params: {
};
}
break;
case UITypes.LongText: {
if (col.meta?.[LongTextAiMetaProp] === true) {
if (knex.clientType() === 'pg') {
aliasToColumn[col.id] = async (): Promise<any> => {
return {
builder: knex.raw(`TRIM('"' FROM (??::jsonb->>'value'))`, [
col.column_name,
]),
};
};
} else if (knex.clientType().startsWith('mysql')) {
aliasToColumn[col.id] = async (): Promise<any> => {
return {
builder: knex.raw(`JSON_UNQUOTE(JSON_EXTRACT(??, '$.value'))`, [
col.column_name,
]),
};
};
} else if (knex.clientType() === 'sqlite3') {
aliasToColumn[col.id] = async (): Promise<any> => {
return {
builder: knex.raw(`json_extract(??, '$.value')`, [
col.column_name,
]),
};
};
} else if (knex.clientType() === 'mssql') {
aliasToColumn[col.id] = async (): Promise<any> => {
return {
builder: knex.raw(`JSON_VALUE(??, '$.value')`, [
col.column_name,
]),
};
};
}
} else {
aliasToColumn[col.id] = () =>
Promise.resolve({ builder: col.column_name });
}
break;
}
default:
aliasToColumn[col.id] = () =>
Promise.resolve({ builder: col.column_name });

29
packages/nocodb/src/db/sortV2.ts

@ -1,4 +1,4 @@
import { UITypes } from 'nocodb-sdk';
import { isAIPromptCol, UITypes } from 'nocodb-sdk';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type { Knex } from 'knex';
import type { ButtonColumn, FormulaColumn, RollupColumn } from '~/models';
@ -199,6 +199,33 @@ export default async function sortV2(
break;
}
case UITypes.LongText: {
if (isAIPromptCol(column)) {
let col;
if (knex.clientType() === 'pg') {
col = knex.raw(`TRIM('"' FROM (??::jsonb->>'value'))`, [
column.column_name,
]);
} else if (knex.clientType().startsWith('mysql')) {
col = knex.raw(`JSON_UNQUOTE(JSON_EXTRACT(??, '$.value'))`, [
column.column_name,
]);
} else if (knex.clientType() === 'sqlite3') {
col = knex.raw(`json_extract(??, '$.value')`, [column.column_name]);
} else if (knex.clientType() === 'mssql') {
col = knex.raw(`JSON_VALUE(??, '$.value')`, [column.column_name]);
}
qb.orderBy(col, sort.direction || 'asc', nulls);
} else {
qb.orderBy(
sanitize(column.column_name),
sort.direction || 'asc',
nulls,
);
}
break;
}
default:
qb.orderBy(
sanitize(column.column_name),

4
packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts

@ -55,6 +55,7 @@ import * as nc_065_encrypt_flag from '~/meta/migrations/v2/nc_065_encrypt_flag';
import * as nc_066_ai_button from '~/meta/migrations/v2/nc_066_ai_button';
import * as nc_067_personal_view from '~/meta/migrations/v2/nc_067_personal_view';
import * as nc_068_user_delete from '~/meta/migrations/v2/nc_068_user_delete';
import * as nc_069_ai_prompt from '~/meta/migrations/v2/nc_069_ai_prompt';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -121,6 +122,7 @@ export default class XcMigrationSourcev2 {
'nc_066_ai_button',
'nc_067_personal_view',
'nc_068_user_delete',
'nc_069_ai_prompt',
]);
}
@ -244,6 +246,8 @@ export default class XcMigrationSourcev2 {
return nc_067_personal_view;
case 'nc_068_user_delete':
return nc_068_user_delete;
case 'nc_069_ai_prompt':
return nc_069_ai_prompt;
}
}
}

29
packages/nocodb/src/meta/migrations/v2/nc_069_ai_prompt.ts

@ -0,0 +1,29 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const up = async (knex: Knex) => {
await knex.schema.createTable(MetaTable.COL_LONG_TEXT, (table) => {
table.string('id', 20).primary();
table.string('fk_workspace_id', 20);
table.string('base_id', 20);
table.string('fk_model_id', 20);
table.string('fk_column_id', 20);
table.string('fk_integration_id', 20);
table.string('model', 255);
table.text('prompt');
table.text('prompt_raw');
table.text('error');
table.timestamps(true, true);
table.index('fk_column_id');
});
};
const down = async (knex: Knex) => {
await knex.schema.dropTable(MetaTable.COL_LONG_TEXT);
};
export { up, down };

48
packages/nocodb/src/models/AIColumn.ts

@ -0,0 +1,48 @@
import type { NcContext } from '~/interface/config';
import Noco from '~/Noco';
import LongTextColumn from '~/models/LongTextColumn';
export default class AIColumn extends LongTextColumn {
id: string;
fk_integration_id: string;
model: string;
prompt: string;
prompt_raw: string;
error?: string;
public static castType(data: AIColumn): AIColumn {
return data && new AIColumn(data);
}
public static async insert(
context: NcContext,
aiColumn: Partial<AIColumn> & {
fk_model_id: string;
fk_column_id: string;
},
ncMeta = Noco.ncMeta,
) {
return this._insert(
context,
aiColumn,
['fk_integration_id', 'model', 'prompt', 'prompt_raw', 'error'],
ncMeta,
);
}
public static async update(
context: NcContext,
columnId: string,
aiColumn: Partial<AIColumn>,
ncMeta = Noco.ncMeta,
) {
return this._update(
context,
columnId,
aiColumn,
['fk_integration_id', 'model', 'prompt', 'prompt_raw', 'error'],
ncMeta,
);
}
}

113
packages/nocodb/src/models/Column.ts

@ -1,6 +1,8 @@
import {
AllowedColumnTypesForQrAndBarcodes,
isAIPromptCol,
isLinksOrLTAR,
LongTextAiMetaProp,
UITypes,
} from 'nocodb-sdk';
import { Logger } from '@nestjs/common';
@ -17,6 +19,7 @@ import Sort from '~/models/Sort';
import Filter from '~/models/Filter';
import QrCodeColumn from '~/models/QrCodeColumn';
import BarcodeColumn from '~/models/BarcodeColumn';
import AIColumn from '~/models/AIColumn';
import {
ButtonColumn,
FileReference,
@ -446,6 +449,24 @@ export default class Column<T = any> implements ColumnType {
}
break;
}
case UITypes.LongText: {
if (column.meta?.[LongTextAiMetaProp] === true) {
await AIColumn.insert(
context,
{
fk_model_id: column.fk_model_id,
fk_column_id: colId,
fk_integration_id: column.fk_integration_id,
model: column.model,
prompt: column.prompt,
prompt_raw: column.prompt_raw,
error: column.error,
},
ncMeta,
);
}
break;
}
/* default:
{
@ -549,6 +570,11 @@ export default class Column<T = any> implements ColumnType {
case UITypes.Barcode:
res = await BarcodeColumn.read(context, this.id, ncMeta);
break;
case UITypes.LongText:
if (this.meta?.[LongTextAiMetaProp] === true) {
res = await AIColumn.read(context, this.id, ncMeta);
}
break;
// default:
// res = await DbColumn.read(this.id);
// break;
@ -857,6 +883,48 @@ export default class Column<T = any> implements ColumnType {
}
}
{
const cachedList = await NocoCache.getList(CacheScope.COLUMN, [
col.fk_model_id,
]);
let { list: aiColumns } = cachedList;
const { isNoneList } = cachedList;
if (!isNoneList && !aiColumns.length) {
aiColumns = await ncMeta.metaList2(
context.workspace_id,
context.base_id,
MetaTable.COLUMNS,
{
condition: {
fk_model_id: col.fk_model_id,
uidt: UITypes.LongText,
},
},
);
}
parseMetaProp(col);
aiColumns = aiColumns.filter((c) => isAIPromptCol(c));
for (const aiCol of aiColumns) {
const ai = await new Column(aiCol).getColOptions<AIColumn>(
context,
ncMeta,
);
if (!ai) continue;
/*
if prompt includes deleted column id {column_id}, add error and update
*/
if (ai.prompt && ai.prompt.match(/{column_id}/)) {
ai.error = `Field '${col.title}' not found`;
await AIColumn.update(context, aiCol.id, ai, ncMeta);
}
}
}
{
const cachedList = await NocoCache.getList(CacheScope.COLUMN, [
col.fk_model_id,
@ -1030,6 +1098,12 @@ export default class Column<T = any> implements ColumnType {
colOptionTableName = MetaTable.COL_BARCODE;
cacheScopeName = CacheScope.COL_BARCODE;
break;
case UITypes.LongText:
if (col.meta?.[LongTextAiMetaProp] === true) {
colOptionTableName = MetaTable.COL_LONG_TEXT;
cacheScopeName = CacheScope.COL_LONG_TEXT;
}
break;
}
if (colOptionTableName && cacheScopeName) {
@ -1276,6 +1350,23 @@ export default class Column<T = any> implements ColumnType {
);
break;
}
case UITypes.LongText: {
await ncMeta.metaDelete(
context.workspace_id,
context.base_id,
MetaTable.COL_LONG_TEXT,
{
fk_column_id: colId,
},
);
await NocoCache.deepDel(
`${CacheScope.COL_LONG_TEXT}:${colId}`,
CacheDelDirection.CHILD_TO_PARENT,
);
break;
}
}
}
const updateObj = extractProps(column, [
@ -1867,6 +1958,20 @@ export default class Column<T = any> implements ColumnType {
}
break;
}
case UITypes.LongText: {
if (column.meta?.[LongTextAiMetaProp] === true) {
insertArr.push({
fk_model_id: column.fk_model_id,
fk_column_id: column.id,
fk_integration_id: column.fk_integration_id,
model: column.model,
prompt: column.prompt,
prompt_raw: column.prompt_raw,
error: column.error,
});
}
break;
}
}
}
@ -1933,6 +2038,14 @@ export default class Column<T = any> implements ColumnType {
insertGroups.get(group),
);
break;
case UITypes.LongText:
await ncMeta.bulkMetaInsert(
context.workspace_id,
context.base_id,
MetaTable.COL_LONG_TEXT,
insertGroups.get(group),
);
break;
}
}
}

58
packages/nocodb/src/models/Integration.ts

@ -451,6 +451,10 @@ export default class Integration implements IntegrationType {
data: this,
});
if (!Array.isArray(config?.models)) {
config.models = [];
}
return config;
}
@ -467,6 +471,33 @@ export default class Integration implements IntegrationType {
);
}
// unbind all buttons and long texts associated with this integration
await ncMeta.metaUpdate(
this.fk_workspace_id ? this.fk_workspace_id : RootScopes.WORKSPACE,
RootScopes.WORKSPACE,
MetaTable.COL_BUTTON,
{
fk_integration_id: null,
model: null,
},
{
fk_integration_id: this.id,
},
);
await ncMeta.metaUpdate(
this.fk_workspace_id ? this.fk_workspace_id : RootScopes.WORKSPACE,
RootScopes.WORKSPACE,
MetaTable.COL_LONG_TEXT,
{
fk_integration_id: null,
model: null,
},
{
fk_integration_id: this.id,
},
);
return await ncMeta.metaDelete(
this.fk_workspace_id ? this.fk_workspace_id : RootScopes.WORKSPACE,
RootScopes.WORKSPACE,
@ -488,6 +519,33 @@ export default class Integration implements IntegrationType {
);
}
// unbind all buttons and long texts associated with this integration
await ncMeta.metaUpdate(
this.fk_workspace_id ? this.fk_workspace_id : RootScopes.WORKSPACE,
RootScopes.WORKSPACE,
MetaTable.COL_BUTTON,
{
fk_integration_id: null,
model: null,
},
{
fk_integration_id: this.id,
},
);
await ncMeta.metaUpdate(
this.fk_workspace_id ? this.fk_workspace_id : RootScopes.WORKSPACE,
RootScopes.WORKSPACE,
MetaTable.COL_LONG_TEXT,
{
fk_integration_id: null,
model: null,
},
{
fk_integration_id: this.id,
},
);
await ncMeta.metaUpdate(
this.fk_workspace_id ? this.fk_workspace_id : RootScopes.WORKSPACE,
RootScopes.WORKSPACE,

113
packages/nocodb/src/models/LongTextColumn.ts

@ -0,0 +1,113 @@
import type { NcContext } from '~/interface/config';
import Noco from '~/Noco';
import NocoCache from '~/cache/NocoCache';
import { extractProps } from '~/helpers/extractProps';
import { CacheGetType, CacheScope, MetaTable } from '~/utils/globals';
import { Column } from '~/models/index';
import { NcError } from '~/helpers/catchError';
export default abstract class LongTextColumn {
id: string;
fk_workspace_id?: string;
base_id: string;
fk_model_id: string;
fk_column_id: string;
constructor(data: Partial<LongTextColumn>) {
Object.assign(this, data);
}
public static castType(data: LongTextColumn): LongTextColumn {
return data;
}
protected static async _insert(
context: NcContext,
longTextColumn: Partial<LongTextColumn> & {
fk_model_id: string;
fk_column_id: string;
},
props: string[],
ncMeta = Noco.ncMeta,
) {
const insertObj = extractProps(longTextColumn, [
'fk_workspace_id',
'base_id',
'fk_model_id',
'fk_column_id',
...(props || []),
]);
const column = await Column.get(
context,
{
colId: insertObj.fk_column_id,
},
ncMeta,
);
if (!column) {
NcError.fieldNotFound(insertObj.fk_column_id);
}
await ncMeta.metaInsert2(
context.workspace_id,
context.base_id,
MetaTable.COL_LONG_TEXT,
insertObj,
);
return this.read(context, longTextColumn.fk_column_id, ncMeta);
}
public static async read(
context: NcContext,
columnId: string,
ncMeta = Noco.ncMeta,
) {
let column =
columnId &&
(await NocoCache.get(
`${CacheScope.COL_LONG_TEXT}:${columnId}`,
CacheGetType.TYPE_OBJECT,
));
if (!column) {
column = await ncMeta.metaGet2(
context.workspace_id,
context.base_id,
MetaTable.COL_LONG_TEXT,
{ fk_column_id: columnId },
);
await NocoCache.set(`${CacheScope.COL_LONG_TEXT}:${columnId}`, column);
}
return column ? this.castType(column) : null;
}
protected static async _update(
context: NcContext,
columnId: string,
longTextColumn: Partial<LongTextColumn>,
props: string[],
ncMeta = Noco.ncMeta,
) {
const updateObj = extractProps(longTextColumn, [...(props || [])]);
// set meta
await ncMeta.metaUpdate(
context.workspace_id,
context.base_id,
MetaTable.COL_LONG_TEXT,
updateObj,
{
fk_column_id: columnId,
},
);
await NocoCache.update(
`${CacheScope.COL_LONG_TEXT}:${columnId}`,
updateObj,
);
}
}

2
packages/nocodb/src/models/index.ts

@ -48,3 +48,5 @@ export { default as Integration } from './Integration';
export { default as IntegrationStore } from './IntegrationStore';
export { default as FileReference } from './FileReference';
export { default as ButtonColumn } from './ButtonColumn';
export { default as LongTextColumn } from './LongTextColumn';
export { default as AIColumn } from './AIColumn';

9
packages/nocodb/src/modules/jobs/jobs/export-import/duplicate.processor.ts

@ -1,7 +1,12 @@
import { Readable } from 'stream';
import papaparse from 'papaparse';
import debug from 'debug';
import { isLinksOrLTAR, isVirtualCol, RelationTypes } from 'nocodb-sdk';
import {
isAIPromptCol,
isLinksOrLTAR,
isVirtualCol,
RelationTypes,
} from 'nocodb-sdk';
import { Injectable } from '@nestjs/common';
import type { Job } from 'bull';
import type { NcContext, NcRequest } from '~/interface/config';
@ -435,7 +440,7 @@ export class DuplicateProcessor {
});
// update cdf
if (!isVirtualCol(destColumn)) {
if (!isVirtualCol(destColumn) && !isAIPromptCol(destColumn)) {
await this.columnsService.columnUpdate(context, {
columnId: findWithIdentifier(idMap, sourceColumn.id),
column: {

32
packages/nocodb/src/modules/jobs/jobs/export-import/export.service.ts

@ -1,5 +1,11 @@
import { Readable } from 'stream';
import { isLinksOrLTAR, RelationTypes, UITypes, ViewTypes } from 'nocodb-sdk';
import {
isLinksOrLTAR,
LongTextAiMetaProp,
RelationTypes,
UITypes,
ViewTypes,
} from 'nocodb-sdk';
import { unparse } from 'papaparse';
import debug from 'debug';
import { Injectable } from '@nestjs/common';
@ -137,8 +143,21 @@ export class ExportService {
case 'fk_rollup_column_id':
case 'fk_qr_value_column_id':
case 'fk_barcode_value_column_id':
case 'fk_model_id':
column.colOptions[k] = idMap.get(v as string);
break;
// Preserve the values on export
// We will keep these only within same workspace as integration is only available within same workspace
case 'fk_workspace_id':
case 'fk_integrations_id':
case 'model':
column.colOptions[k] = v;
break;
case 'output_column_ids':
column.colOptions[k] = ((v as string)?.split(',') || [])
.map((id) => idMap.get(id))
.join(',');
break;
case 'fk_target_view_id':
if (v) {
const view = await View.get(context, v as string);
@ -158,6 +177,8 @@ export class ExportService {
}
break;
case 'formula':
if (column.uidt === UITypes.Button) break;
// rewrite formula_raw with aliases
column.colOptions['formula_raw'] = column.colOptions[
k
@ -604,6 +625,15 @@ export class ExportService {
row[colId] = v;
}
break;
case UITypes.LongText:
if (col.meta?.[LongTextAiMetaProp] && v) {
try {
row[colId] = JSON.stringify(v);
} catch (e) {
row[colId] = v;
}
}
break;
case UITypes.User:
case UITypes.CreatedBy:
case UITypes.LastModifiedBy:

45
packages/nocodb/src/modules/jobs/jobs/export-import/import.service.ts

@ -1,4 +1,5 @@
import {
isAIPromptCol,
isLinksOrLTAR,
isVirtualCol,
RelationTypes,
@ -178,6 +179,7 @@ export class ImportService {
(a) =>
!isVirtualCol(a) &&
a.uidt !== UITypes.ForeignKey &&
!isAIPromptCol(a) &&
(param.importColumnIds
? param.importColumnIds.includes(getEntityIdentifier(a.id))
: true),
@ -1112,7 +1114,8 @@ export class ImportService {
a.uidt === UITypes.LastModifiedTime ||
a.uidt === UITypes.CreatedBy ||
a.uidt === UITypes.LastModifiedBy ||
a.uidt === UITypes.Barcode) &&
a.uidt === UITypes.Barcode ||
isAIPromptCol(a)) &&
(param.importColumnIds
? param.importColumnIds.includes(getEntityIdentifier(a.id))
: true),
@ -1242,6 +1245,12 @@ export class ImportService {
}
}
} else if (col.uidt === UITypes.Button) {
if (base.fk_workspace_id !== colOptions.fk_workspace_id) {
colOptions.fk_workspace_id = null;
colOptions.fk_integration_id = null;
colOptions.model = null;
}
const freshModelData = await this.columnsService.columnAdd(context, {
tableId: getIdOrExternalId(getParentIdentifier(col.id)),
column: withoutId({
@ -1254,6 +1263,40 @@ export class ImportService {
icon: colOptions?.icon,
type: colOptions?.type,
fk_webhook_id: getIdOrExternalId(colOptions?.fk_webhook_id),
output_column_ids: (
colOptions?.output_column_ids?.split(',') || []
)
.map((a) => getIdOrExternalId(a))
.join(','),
fk_integration_id: colOptions?.fk_integration_id,
model: colOptions?.model,
},
}) as any,
req: param.req,
user: param.user,
});
for (const nColumn of freshModelData.columns) {
if (nColumn.title === col.title) {
idMap.set(col.id, nColumn.id);
break;
}
}
} else if (isAIPromptCol(col)) {
if (base.fk_workspace_id !== colOptions.fk_workspace_id) {
colOptions.fk_workspace_id = null;
colOptions.fk_integration_id = null;
colOptions.model = null;
}
const freshModelData = await this.columnsService.columnAdd(context, {
tableId: getIdOrExternalId(getParentIdentifier(col.id)),
column: withoutId({
...flatCol,
...{
fk_integration_id: colOptions.fk_integration_id,
model: colOptions.model,
prompt_raw: colOptions.prompt_raw,
},
}) as any,
req: param.req,

106
packages/nocodb/src/services/columns.service.ts

@ -3,10 +3,12 @@ import {
AppEvents,
ButtonActionsType,
FormulaDataTypes,
isAIPromptCol,
isCreatedOrLastModifiedByCol,
isCreatedOrLastModifiedTimeCol,
isLinksOrLTAR,
isVirtualCol,
LongTextAiMetaProp,
partialUpdateAllowedTypes,
readonlyMetaAllowedTypes,
RelationTypes,
@ -66,6 +68,10 @@ import Noco from '~/Noco';
import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2';
import { MetaTable } from '~/utils/globals';
import { MetaService } from '~/meta/meta.service';
import {
convertAIRecordTypeToValue,
convertValueToAIRecordType,
} from '~/utils/dataConversion';
// todo: move
export enum Altered {
@ -551,10 +557,6 @@ export class ColumnsService {
NcError.badRequest('Webhook not found');
}
} else if (colBody.type === ButtonActionsType.Ai) {
if (!colBody.fk_integration_id) {
NcError.badRequest('AI Integration not found');
}
/*
Substitute column alias with id in prompt
*/
@ -1573,6 +1575,75 @@ export class ColumnsService {
);
}
if (
isAIPromptCol(column) &&
(colBody.uidt !== UITypes.LongText ||
(colBody.uidt === UITypes.LongText &&
colBody.meta?.[LongTextAiMetaProp] !== true))
) {
const baseModel = await reuseOrSave('baseModel', reuse, async () =>
Model.getBaseModelSQL(context, {
id: table.id,
dbDriver: await reuseOrSave('dbDriver', reuse, async () =>
NcConnectionMgrv2.get(source),
),
}),
);
await convertAIRecordTypeToValue({
source,
table,
column,
baseModel,
sqlClient,
});
} else if (isAIPromptCol(colBody)) {
let prompt = '';
/*
Substitute column alias with id in prompt
*/
if (colBody.prompt_raw) {
await table.getColumns(context);
prompt = colBody.prompt_raw.replace(/{(.*?)}/g, (match, p1) => {
const column = table.columns.find((c) => c.title === p1);
if (!column) {
NcError.badRequest(`Field '${p1}' not found`);
}
return `{${column.id}}`;
});
}
colBody.prompt = prompt;
// If column wasn't AI before, convert the data to AIRecordType format
if (
column.uidt !== UITypes.LongText ||
column.meta?.[LongTextAiMetaProp] !== true
) {
const baseModel = await reuseOrSave('baseModel', reuse, async () =>
Model.getBaseModelSQL(context, {
id: table.id,
dbDriver: await reuseOrSave('dbDriver', reuse, async () =>
NcConnectionMgrv2.get(source),
),
}),
);
await convertValueToAIRecordType({
source,
table,
column,
baseModel,
sqlClient,
user: param.user,
});
}
}
colBody = await getColumnPropsFromUIDT(colBody, source);
await this.updateMetaAndDatabase(context, {
@ -1914,10 +1985,6 @@ export class ColumnsService {
colBody.fk_webhook_id = null;
}
} else if (colBody.type === ButtonActionsType.Ai) {
if (!colBody.fk_integration_id) {
NcError.badRequest('AI Integration not found');
}
/*
Substitute column alias with id in prompt
*/
@ -2212,6 +2279,29 @@ export class ColumnsService {
}
}
if (isAIPromptCol(colBody)) {
let prompt = '';
/*
Substitute column alias with id in prompt
*/
if (colBody.prompt_raw) {
await table.getColumns(context);
prompt = colBody.prompt_raw.replace(/{(.*?)}/g, (match, p1) => {
const column = table.columns.find((c) => c.title === p1);
if (!column) {
NcError.badRequest(`Field '${p1}' not found`);
}
return `{${column.id}}`;
});
}
colBody.prompt = prompt;
}
const tableUpdateBody = {
...table,
tn: table.table_name,

150
packages/nocodb/src/utils/dataConversion.ts

@ -0,0 +1,150 @@
import type { AIRecordType } from 'nocodb-sdk';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type KnexClient from '~/db/sql-client/lib/KnexClient';
import type { Column, Model, Source, User } from '~/models';
export const convertAIRecordTypeToValue = async (args: {
source: Source;
table: Model;
column: Column;
baseModel: BaseModelSqlv2;
sqlClient: KnexClient;
}) => {
const { source, table, column, baseModel, sqlClient } = args;
if (source.type === 'pg') {
await sqlClient.raw(
`UPDATE ??
SET ?? = TRIM('"' FROM (??::jsonb->>'value'))
WHERE ?? ~ '^\\s*\\{.*\\}\\s*$' AND jsonb_typeof(??::jsonb) = 'object' AND (??::jsonb->'value') IS NOT NULL;`,
[
baseModel.getTnPath(table.table_name),
column.column_name,
column.column_name,
column.column_name,
column.column_name,
column.column_name,
],
);
} else if (source.type === 'mysql' || source.type === 'mysql2') {
await sqlClient.raw(
`UPDATE ??
SET ?? = JSON_UNQUOTE(JSON_EXTRACT(??, '$.value'))
WHERE JSON_VALID(??) AND JSON_TYPE(??) = 'OBJECT' AND JSON_EXTRACT(??, '$.value') IS NOT NULL;`,
[
baseModel.getTnPath(table.table_name),
column.column_name,
column.column_name,
column.column_name,
column.column_name,
column.column_name,
],
);
} else if (source.type === 'sqlite3') {
await sqlClient.raw(
`UPDATE ??
SET ?? = json_extract(??, '$.value')
WHERE json_valid(??) AND json_extract(??, '$.value') IS NOT NULL;`,
[
baseModel.getTnPath(table.table_name),
column.column_name,
column.column_name,
column.column_name,
column.column_name,
],
);
} else if (source.type === 'mssql') {
await sqlClient.raw(
`UPDATE ??
SET ?? = JSON_VALUE(??, '$.value')
WHERE ISJSON(??) = 1 AND JSON_VALUE(??, '$.value') IS NOT NULL;`,
[
baseModel.getTnPath(table.table_name),
column.column_name,
column.column_name,
column.column_name,
column.column_name,
],
);
}
};
export const convertValueToAIRecordType = async (args: {
source: Source;
table: Model;
column: Column;
baseModel: BaseModelSqlv2;
sqlClient: KnexClient;
user: User;
}) => {
const { source, table, column, baseModel, sqlClient, user } = args;
const commonRecord: Omit<AIRecordType, 'value'> = {
lastModifiedBy: user.id,
lastModifiedTime: baseModel.now(),
isStale: true,
};
// update every record with json which holds old value in value prop & commonRecord props in lastModifiedBy, lastModifiedTime, isStale
if (source.type === 'pg') {
await sqlClient.raw(
`UPDATE ??
SET ?? = jsonb_build_object('value', ??, 'lastModifiedBy', ?::text, 'lastModifiedTime', ?::text, 'isStale', ?::boolean)
WHERE ?? is not null;`,
[
baseModel.getTnPath(table.table_name),
column.column_name,
column.column_name,
commonRecord.lastModifiedBy.toString(),
commonRecord.lastModifiedTime.toString(),
commonRecord.isStale,
column.column_name,
],
);
} else if (source.type === 'mysql' || source.type === 'mysql2') {
await sqlClient.raw(
`UPDATE ??
SET ?? = JSON_OBJECT('value', ??, 'lastModifiedBy', ?, 'lastModifiedTime', ?, 'isStale', ?)
WHERE ?? is not null;`,
[
baseModel.getTnPath(table.table_name),
column.column_name,
column.column_name,
commonRecord.lastModifiedBy.toString(),
commonRecord.lastModifiedTime.toString(),
commonRecord.isStale,
column.column_name,
],
);
} else if (source.type === 'sqlite3') {
await sqlClient.raw(
`UPDATE ??
SET ?? = json_object('value', ??, 'lastModifiedBy', ?, 'lastModifiedTime', ?, 'isStale', ?)
WHERE ?? is not null;`,
[
baseModel.getTnPath(table.table_name),
column.column_name,
column.column_name,
commonRecord.lastModifiedBy.toString(),
commonRecord.lastModifiedTime.toString(),
commonRecord.isStale,
column.column_name,
],
);
} else if (source.type === 'mssql') {
await sqlClient.raw(
`UPDATE ??
SET ?? = JSON_QUERY('{"value":' + ?? + ',"lastModifiedBy":' + ? + ',"lastModifiedTime":' + ? + ',"isStale":' + ? + '}')
WHERE ?? is not null;`,
[
baseModel.getTnPath(table.table_name),
column.column_name,
column.column_name,
commonRecord.lastModifiedBy.toString(),
commonRecord.lastModifiedTime.toString(),
commonRecord.isStale,
column.column_name,
],
);
}
};

7
packages/nocodb/src/utils/globals.ts

@ -12,7 +12,7 @@ export enum MetaTable {
COL_FORMULA = 'nc_col_formula_v2',
COL_QRCODE = 'nc_col_qrcode_v2',
COL_BARCODE = 'nc_col_barcode_v2',
COL_AI = 'nc_col_ai_v2',
COL_LONG_TEXT = 'nc_col_long_text_v2',
FILTER_EXP = 'nc_filter_exp_v2',
// HOOK_FILTER_EXP = 'nc_hook_filter_exp_v2',
SORT = 'nc_sort_v2',
@ -145,7 +145,7 @@ export enum CacheScope {
COL_FORMULA = 'colFormula',
COL_QRCODE = 'colQRCode',
COL_BARCODE = 'colBarcode',
COL_AI = 'colAi',
COL_LONG_TEXT = 'colLongText',
FILTER_EXP = 'filterExp',
SORT = 'sort',
SHARED_VIEW = 'sharedView',
@ -241,6 +241,9 @@ export const RootScopeTables = {
[RootScopes.BASE]: [MetaTable.PROJECT],
// It's a special case and Workspace is equivalent to org in oss
[RootScopes.WORKSPACE]: [
// COL_BUTTON & COL_LONG_TEXT have integration references which we need to clean
MetaTable.COL_BUTTON,
MetaTable.COL_LONG_TEXT,
MetaTable.INTEGRATIONS,
MetaTable.INTEGRATIONS_STORE,
],

Loading…
Cancel
Save