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. 96
      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. 11
      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. 63
      packages/nc-gui/composables/useIntegrationsStore.ts
  43. 11
      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. 18
      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. 88
      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 StarterKit from '@tiptap/starter-kit'
import Mention from '@tiptap/extension-mention' import Mention from '@tiptap/extension-mention'
import { EditorContent, useEditor } from '@tiptap/vue-3' import { EditorContent, useEditor } from '@tiptap/vue-3'
import tippy from 'tippy.js'
import { type ColumnType, UITypes } from 'nocodb-sdk' import { type ColumnType, UITypes } from 'nocodb-sdk'
import FieldList from '~/helpers/tiptapExtensions/mention/FieldList' import FieldList from '~/helpers/tiptapExtensions/mention/FieldList'
import suggestion from '~/helpers/tiptapExtensions/mention/suggestion.ts' import suggestion from '~/helpers/tiptapExtensions/mention/suggestion.ts'
@ -15,6 +16,7 @@ const props = withDefaults(
promptFieldTagClassName?: string promptFieldTagClassName?: string
suggestionIconClassName?: string suggestionIconClassName?: string
placeholder?: string placeholder?: string
readOnly?: boolean
}>(), }>(),
{ {
options: () => [], options: () => [],
@ -26,6 +28,7 @@ const props = withDefaults(
* @example: :placeholder="`Enter prompt here...\n\neg : Categorise this {Notes}`" * @example: :placeholder="`Enter prompt here...\n\neg : Categorise this {Notes}`"
*/ */
placeholder: 'Write your prompt here...', 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({ const editor = useEditor({
content: vModel.value, content: vModel.value,
@ -55,18 +60,25 @@ const editor = useEditor({
...suggestion(FieldList), ...suggestion(FieldList),
items: ({ query }) => { items: ({ query }) => {
if (query.length === 0) return props.options ?? [] 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: '{', char: '{',
allowSpaces: true, allowSpaces: true,
}, },
renderHTML: ({ node }) => { renderHTML: ({ node }) => {
const matchedOption = props.options?.find((option) => option.title === node.attrs.id)
const isAttachment = matchedOption?.uidt === UITypes.Attachment
return [ return [
'span', 'span',
{ {
class: `prompt-field-tag ${ 'class': `prompt-field-tag ${isAttachment ? '!bg-green-200' : ''} ${props.promptFieldTagClassName}`,
props.options?.find((option) => option.title === node.attrs.id)?.uidt === UITypes.Attachment ? '!bg-green-200' : '' 'style': 'max-width: 100px; white-space: nowrap; overflow: hidden; display: inline-block; text-overflow: ellipsis;', // Enforces truncation
} ${props.promptFieldTagClassName}`, 'data-tooltip': node.attrs.id, // Tooltip content
}, },
`${node.attrs.id}`, `${node.attrs.id}`,
] ]
@ -93,8 +105,10 @@ const editor = useEditor({
text = text.trim() text = text.trim()
vModel.value = text vModel.value = text
debouncedLoadMentionFieldTagTooltip()
}, },
editable: true, editable: !readOnly.value,
autofocus: autoFocus.value, autofocus: autoFocus.value,
editorProps: { scrollThreshold: 100 }, editorProps: { scrollThreshold: 100 },
}) })
@ -141,15 +155,60 @@ onMounted(async () => {
}, 100) }, 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> </script>
<template> <template>
<div class="nc-ai-prompt-with-fields w-full"> <div class="nc-ai-prompt-with-fields w-full">
<EditorContent ref="editorDom" :editor="editor" @keydown.alt.enter.stop @keydown.shift.enter.stop /> <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"> <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> </slot>
</NcButton> </NcButton>
</div> </div>
@ -160,11 +219,11 @@ onMounted(async () => {
@apply relative; @apply relative;
.nc-prompt-with-field-suggestion-btn { .nc-prompt-with-field-suggestion-btn {
@apply absolute top-[1px] right-[1px]; @apply absolute top-[2px] right-[1px];
} }
.prompt-field-tag { .prompt-field-tag {
@apply bg-gray-100 rounded-md px-1; @apply bg-gray-100 rounded-md px-1 align-middle;
} }
.ProseMirror { .ProseMirror {
@ -172,6 +231,10 @@ onMounted(async () => {
resize: vertical; resize: vertical;
min-width: 100%; min-width: 100%;
max-height: min(800px, calc(100vh - 200px)) !important; max-height: min(800px, calc(100vh - 200px)) !important;
& > p {
@apply mr-3;
}
} }
.ProseMirror-focused { .ProseMirror-focused {

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

@ -7,9 +7,11 @@ const props = withDefaults(
workspaceId: string workspaceId: string
scope?: string scope?: string
showTooltip?: boolean showTooltip?: boolean
isEditColumn?: boolean
}>(), }>(),
{ {
showTooltip: true, showTooltip: true,
isEditColumn: false,
}, },
) )
@ -19,6 +21,8 @@ const vFkIntegrationId = useVModel(props, 'fkIntegrationId', emits)
const vModel = useVModel(props, 'model', emits) const vModel = useVModel(props, 'model', emits)
const { isEditColumn } = toRefs(props)
// const vRandomness = useVModel(props, 'randomness', emits) // const vRandomness = useVModel(props, 'randomness', emits)
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
@ -29,7 +33,7 @@ const lastIntegrationId = ref<string | null>(null)
const isDropdownOpen = ref(false) const isDropdownOpen = ref(false)
const availableModels = ref<string[]>([]) const availableModels = ref<{ value: string; label: string }[]>([])
const isLoadingAvailableModels = ref<boolean>(false) const isLoadingAvailableModels = ref<boolean>(false)
@ -50,10 +54,10 @@ const onIntegrationChange = async (newFkINtegrationId?: string) => {
try { try {
const response = await $api.integrations.endpoint(newFkINtegrationId, 'availableModels', {}) 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) { if (!vModel.value && availableModels.value.length > 0) {
vModel.value = availableModels.value[0] vModel.value = availableModels.value[0].value
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@ -63,6 +67,10 @@ const onIntegrationChange = async (newFkINtegrationId?: string) => {
} }
onMounted(async () => { onMounted(async () => {
if (isEditColumn.value) {
return
}
if (!vFkIntegrationId.value) { if (!vFkIntegrationId.value) {
if (aiIntegrations.value.length > 0 && aiIntegrations.value[0].id) { if (aiIntegrations.value.length > 0 && aiIntegrations.value[0].id) {
vFkIntegrationId.value = aiIntegrations.value[0].id vFkIntegrationId.value = aiIntegrations.value[0].id
@ -72,6 +80,10 @@ onMounted(async () => {
} }
} else { } else {
lastIntegrationId.value = vFkIntegrationId.value lastIntegrationId.value = vFkIntegrationId.value
if (!vModel.value || !availableModels.value.length) {
onIntegrationChange()
}
} }
}) })
</script> </script>
@ -111,6 +123,7 @@ onMounted(async () => {
v-model:value="vFkIntegrationId" v-model:value="vFkIntegrationId"
class="w-full nc-select-shadow nc-ai-input" class="w-full nc-select-shadow nc-ai-input"
size="middle" size="middle"
placeholder="- select integration -"
@change="onIntegrationChange" @change="onIntegrationChange"
> >
<a-select-option v-for="integration in aiIntegrations" :key="integration.id" :value="integration.id"> <a-select-option v-for="integration in aiIntegrations" :key="integration.id" :value="integration.id">
@ -150,20 +163,21 @@ onMounted(async () => {
v-model:value="vModel" v-model:value="vModel"
class="w-full nc-select-shadow nc-ai-input" class="w-full nc-select-shadow nc-ai-input"
size="middle" size="middle"
placeholder="- select model -"
:disabled="!vFkIntegrationId || availableModels.length === 0" :disabled="!vFkIntegrationId || availableModels.length === 0"
:loading="isLoadingAvailableModels" :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"> <div class="w-full flex gap-2 items-center">
<NcTooltip class="flex-1 truncate" show-on-truncate-only> <NcTooltip class="flex-1 truncate" show-on-truncate-only>
<template #title> <template #title>
{{ md }} {{ md.label }}
</template> </template>
{{ md }} {{ md.label }}
</NcTooltip> </NcTooltip>
<component <component
:is="iconMap.check" :is="iconMap.check"
v-if="vModel === md" v-if="vModel === md.value"
id="nc-selected-item-icon" id="nc-selected-item-icon"
class="text-nc-content-purple-medium w-4 h-4" class="text-nc-content-purple-medium w-4 h-4"
/> />
@ -198,4 +212,10 @@ onMounted(async () => {
</NcDropdown> </NcDropdown>
</template> </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 { row } = useSmartsheetRowStoreOrThrow()
const { isUIAllowed } = useRoles()
const isPublic = inject(IsPublicInj, ref(false))
const meta = inject(MetaInj, ref()) const meta = inject(MetaInj, ref())
const column = inject(ColumnInj) as Ref< const column = inject(ColumnInj) as Ref<
@ -40,9 +44,7 @@ const isAiEdited = ref(false)
const isFieldAiIntegrationAvailable = computed(() => { const isFieldAiIntegrationAvailable = computed(() => {
const fkIntegrationId = column.value?.colOptions?.fk_integration_id const fkIntegrationId = column.value?.colOptions?.fk_integration_id
if (!fkIntegrationId) return false return !!fkIntegrationId
return ncIsArrayIncludes(aiIntegrations.value, fkIntegrationId, 'id')
}) })
const pk = computed(() => { 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 ncIsString(column.value.colOptions?.output_column_ids) && column.value.colOptions.output_column_ids.split(',').length > 1
? column.value.colOptions.output_column_ids.split(',') ? 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) generatingRows.value.push(pk.value)
generatingColumnRows.value.push(column.value.id) generatingColumnRows.value.push(column.value.id)
@ -76,11 +78,18 @@ const generate = async () => {
} }
} else { } else {
const obj: AIRecordType = resRow[column.value.title!] const obj: AIRecordType = resRow[column.value.title!]
if (obj && typeof obj === 'object') { if (obj && typeof obj === 'object') {
vModel.value = obj vModel.value = obj
setTimeout(() => { setTimeout(() => {
isAiEdited.value = false isAiEdited.value = false
}, 100) }, 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 = () => { const handleSave = () => {
vModel.value = { ...vModel.value }
emits('save') emits('save')
} }
const debouncedSave = useDebounceFn(handleSave, 1000) const debouncedSave = useDebounceFn(handleSave, 1000)
const isDisabledAiButton = computed(() => {
return !isFieldAiIntegrationAvailable.value || isLoading.value || isPublic.value || !isUIAllowed('dataEdit')
})
</script> </script>
<template> <template>
@ -113,20 +128,15 @@ const debouncedSave = useDebounceFn(handleSave, 1000)
'justify-center': isGrid && !isExpandedForm, 'justify-center': isGrid && !isExpandedForm,
}" }"
> >
<NcTooltip :disabled="isFieldAiIntegrationAvailable" class="flex"> <NcTooltip :disabled="isFieldAiIntegrationAvailable || isPublic || isUIAllowed('dataEdit')" class="flex">
<template #title> <template #title>
{{ aiIntegrations.length ? $t('tooltip.aiIntegrationReConfigure') : $t('tooltip.aiIntegrationAddAndReConfigure') }} {{ aiIntegrations.length ? $t('tooltip.aiIntegrationReConfigure') : $t('tooltip.aiIntegrationAddAndReConfigure') }}
</template> </template>
<button <button class="nc-cell-ai-button nc-cell-button h-6" size="small" :disabled="isDisabledAiButton" @click.stop="generate">
class="nc-cell-ai-button nc-cell-button h-7"
size="small"
:disabled="!isFieldAiIntegrationAvailable || isLoading"
@click.stop="generate"
>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<GeneralLoader v-if="isLoading" size="regular" class="!text-nc-content-purple-dark" /> <GeneralLoader v-if="isLoading" size="regular" />
<GeneralIcon v-else icon="ncAutoAwesome" class="text-nc-content-purple-dark h-4 w-4" /> <GeneralIcon v-else icon="ncAutoAwesome" class="h-4 w-4" />
<span class="text-sm font-semibold">Generate</span> <span class="text-small leading-[18px] truncate font-medium">Generate</span>
</div> </div>
</button> </button>
</NcTooltip> </NcTooltip>
@ -147,10 +157,19 @@ const debouncedSave = useDebounceFn(handleSave, 1000)
<style scoped lang="scss"> <style scoped lang="scss">
.nc-cell-button { .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); 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] { &[disabled] {
@apply !bg-gray-100 opacity-50; @apply !bg-gray-100 opacity-50;
} }
@ -162,6 +181,10 @@ const debouncedSave = useDebounceFn(handleSave, 1000)
&:has(.nc-cell-ai-button) { &:has(.nc-cell-ai-button) {
@apply !border-none; @apply !border-none;
box-shadow: none !important; box-shadow: none !important;
&:focus-within:not(.nc-readonly-div-data-cell):not(.nc-system-field) {
box-shadow: none !important;
}
} }
} }
</style> </style>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -366,6 +366,7 @@ onBeforeUnmount(() => {
v-model:randomness="vModel.randomness" v-model:randomness="vModel.randomness"
:workspace-id="activeWorkspaceId" :workspace-id="activeWorkspaceId"
:show-tooltip="false" :show-tooltip="false"
:is-edit-column="isEdit"
placement="bottomRight" placement="bottomRight"
> >
<NcButton size="xs" theme="ai" class="!px-1" type="text"> <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"> <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"> <div class="flex flex-row items-center gap-1 py-[2px] text-sm">
<component :is="cellIcon(op)" class="!mx-0 !mr-1 opacity-80" /> <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"> <div class="flex items-center p-0.5 mt-0.5">
<GeneralIcon <GeneralIcon
icon="close" icon="close"
@ -679,8 +684,8 @@ onBeforeUnmount(() => {
<div <div
class="flex justify-center nc-ai-button-test-generate-wrapper" class="flex justify-center nc-ai-button-test-generate-wrapper"
:class="{ :class="{
'text-nc-border-gray-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 && vModel.formula_raw), '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> <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>Preview checklist</div>
<div class="flex gap-2"> <div class="flex gap-2">
<div <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=" :class="
inputColumns.length inputColumns.length
? 'bg-nc-bg-green-dark text-nc-content-green-dark' ? 'bg-nc-bg-green-dark text-nc-content-green-dark'
@ -703,7 +708,7 @@ onBeforeUnmount(() => {
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<div <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=" :class="
outputColumnIds.length outputColumnIds.length
? 'bg-nc-bg-green-dark text-nc-content-green-dark' ? 'bg-nc-bg-green-dark text-nc-content-green-dark'
@ -716,7 +721,7 @@ onBeforeUnmount(() => {
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<div <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=" :class="
selectedRecordPk selectedRecordPk
? 'bg-nc-bg-green-dark text-nc-content-green-dark' ? '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: [ output_column_ids: [
{ {
@ -215,7 +215,7 @@ if (isEdit.value) {
vModel.value.type = vModel.value?.type || buttonTypes[0].value vModel.value.type = vModel.value?.type || buttonTypes[0].value
if (vModel.value.type === ButtonActionsType.Ai) { if (vModel.value.type === ButtonActionsType.Ai) {
vModel.value.theme = 'text' vModel.value.theme = 'light'
vModel.value.label = 'Generate data' vModel.value.label = 'Generate data'
vModel.value.color = 'purple' vModel.value.color = 'purple'
vModel.value.icon = 'ncAutoAwesome' vModel.value.icon = 'ncAutoAwesome'

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

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { type ColumnReqType, type ColumnType } from 'nocodb-sdk' import { type ColumnReqType, type ColumnType, isAIPromptCol } from 'nocodb-sdk'
import { import {
ButtonActionsType, ButtonActionsType,
UITypes, UITypes,
@ -264,7 +264,7 @@ const handleScrollDebounce = useDebounceFn(() => {
} }
}, 500) }, 500)
const onSelectType = (uidt: UITypes | typeof AIButton, fromSearchList = false) => { const onSelectType = (uidt: UITypes | typeof AIButton | typeof AIPrompt, fromSearchList = false) => {
let preload let preload
if (fromSearchList && !isEdit.value && aiAutoSuggestMode.value) { if (fromSearchList && !isEdit.value && aiAutoSuggestMode.value) {
@ -276,9 +276,17 @@ const onSelectType = (uidt: UITypes | typeof AIButton, fromSearchList = false) =
preload = { preload = {
type: ButtonActionsType.Ai, type: ButtonActionsType.Ai,
} }
} else if (uidt === AIPrompt) {
formState.value.uidt = UITypes.LongText
preload = {
meta: {
[LongTextAiMetaProp]: true,
},
}
} else { } else {
formState.value.uidt = uidt formState.value.uidt = uidt
} }
onUidtOrIdTypeChange(preload) onUidtOrIdTypeChange(preload)
nextTick(() => { nextTick(() => {
@ -618,6 +626,10 @@ const isAiButtonSelectOption = (uidt: string) => {
return uidt === UITypes.Button && formState.value.uidt === UITypes.Button && formState.value.type === ButtonActionsType.Ai 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>() const aiPromptInputRef = ref<HTMLElement>()
watch(activeAiTab, (newValue) => { watch(activeAiTab, (newValue) => {
@ -966,7 +978,7 @@ watch(activeAiTab, (newValue) => {
type="primary" type="primary"
theme="ai" theme="ai"
:loading="saving" :loading="saving"
:disabled="disableSubmitBtn || !activeTabSelectedFields.length || saving" :disabled="disableSubmitBtn || saving"
size="small" size="small"
:label="submitBtnLabel.label" :label="submitBtnLabel.label"
:loading-label="submitBtnLabel.loadingLabel" :loading-label="submitBtnLabel.loadingLabel"
@ -1080,14 +1092,20 @@ watch(activeAiTab, (newValue) => {
v-bind="validateInfos.uidt" v-bind="validateInfos.uidt"
:class="{ :class="{
'ant-select-item-option-active-selected': showHoverEffectOnSelectedType && formState.uidt === opt.name, '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" @mouseover="handleResetHoverEffect"
> >
<div class="w-full flex gap-2 items-center justify-between" :data-testid="opt.name" :data-title="formState?.type"> <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)]"> <div class="flex-1 flex gap-2 items-center max-w-[calc(100%_-_24px)]">
<component <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="nc-field-type-icon w-4 h-4"
:class="isMetaReadOnly && !readonlyMetaAllowedTypes.includes(opt.name) ? 'text-gray-300' : 'text-gray-700'" :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" /> <SmartsheetColumnQrCodeOptions v-if="formState.uidt === UITypes.QrCode" v-model="formState" />
<SmartsheetColumnBarcodeOptions v-if="formState.uidt === UITypes.Barcode" v-model="formState" /> <SmartsheetColumnBarcodeOptions v-if="formState.uidt === UITypes.Barcode" v-model="formState" />
<SmartsheetColumnCurrencyOptions v-if="formState.uidt === UITypes.Currency" v-model:value="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" /> <SmartsheetColumnDurationOptions v-if="formState.uidt === UITypes.Duration" v-model:value="formState" />
<SmartsheetColumnRatingOptions v-if="formState.uidt === UITypes.Rating" 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" /> <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"> <script setup lang="ts">
import { UITypes } from 'nocodb-sdk'
const props = defineProps<{ const props = defineProps<{
value: any modelValue: any
}>() }>()
const emit = defineEmits(['update:modelValue', 'navigateToIntegrations']) const emit = defineEmits(['update:modelValue', 'navigateToIntegrations'])
@ -81,7 +81,40 @@ const generate = async () => {
previewFieldTitle.value = vModel.value?.title || 'temp_title' 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({ const richMode = computed({
get: () => !!vModel.value.meta?.richMode, 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, () => { watch(richMode, () => {
vModel.value.cdf = null 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> </script>
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-4">
<a-form-item> <a-form-item>
<NcTooltip :disabled="!isEnabledGenerateText"> <NcTooltip :disabled="!isEnabledGenerateText">
<template #title> Rich text formatting is not supported when generate text using AI is enabled </template> <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" /> <AiIntegrationNotFound v-if="!aiIntegrationAvailable && isEnabledGenerateText" />
</div> </div>
</template> </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( options.value?.filter(
(c) => (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()) || (c.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
(UITypesName[c.name] && UITypesName[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), 'hover:bg-gray-100 cursor-pointer': !isDisabledUIType(option.name),
'bg-gray-100 nc-column-list-option-active': activeFieldIndex === index && !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-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" :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!) await copy(field.id!)
isFieldIdCopied.value = true isFieldIdCopied.value = true
await ncDelay(5000)
isFieldIdCopied.value = false
} }
const keys = useMagicKeys() const keys = useMagicKeys()

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

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

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

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

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

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
ButtonActionsType,
type ButtonType, type ButtonType,
type ColumnReqType, type ColumnReqType,
type ColumnType, type ColumnType,
@ -8,6 +7,7 @@ import {
UITypes, UITypes,
type ViewType, type ViewType,
ViewTypes, ViewTypes,
isAIPromptCol,
isCreatedOrLastModifiedByCol, isCreatedOrLastModifiedByCol,
isCreatedOrLastModifiedTimeCol, isCreatedOrLastModifiedTimeCol,
isLinksOrLTAR, isLinksOrLTAR,
@ -1024,8 +1024,8 @@ const isSelectedOnlyAI = computed(() => {
if (selectedRange.start.col === selectedRange.end.col) { if (selectedRange.start.col === selectedRange.end.col) {
const field = fields.value[selectedRange.start.col] const field = fields.value[selectedRange.start.col]
return { return {
enabled: field.uidt === UITypes.Button && (field?.colOptions as ButtonType)?.type === ButtonActionsType.Ai, enabled: isAIPromptCol(field) || isAiButton(field),
disabled: !ncIsArrayIncludes(aiIntegrations.value, (field?.colOptions as ButtonType)?.fk_integration_id, 'id'), disabled: !(field?.colOptions as ButtonType)?.fk_integration_id,
} }
} }
@ -1048,9 +1048,7 @@ const generateAIBulk = async () => {
let outputColumnIds = [field.id] let outputColumnIds = [field.id]
const isAiButton = field.uidt === UITypes.Button && (field?.colOptions as ButtonType)?.type === ButtonActionsType.Ai if (isAiButton(field)) {
if (isAiButton) {
outputColumnIds = outputColumnIds =
ncIsString(field.colOptions?.output_column_ids) && field.colOptions.output_column_ids.split(',').length > 0 ncIsString(field.colOptions?.output_column_ids) && field.colOptions.output_column_ids.split(',').length > 0
? field.colOptions.output_column_ids.split(',') ? field.colOptions.output_column_ids.split(',')
@ -2248,13 +2246,13 @@ watch(vSelectedAllRecords, (selectedAll) => {
</span> </span>
<div <div
v-else-if="!row.rowMeta?.saving && !row.rowMeta?.isLoading" 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 <component
:is="iconMap.expand" :is="iconMap.maximize"
v-if="expandForm" v-if="expandForm"
v-e="['c:row-expand:open']" 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)" @click="expandAndLooseFocus(row, state)"
/> />
</div> </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 { nextTick } from '@vue/runtime-core'
import type { ButtonType, ColumnReqType, ColumnType, PaginatedType, TableType, ViewType } from 'nocodb-sdk' import type { ButtonType, ColumnReqType, ColumnType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import { import {
ButtonActionsType,
UITypes, UITypes,
ViewTypes, ViewTypes,
isAIPromptCol,
isCreatedOrLastModifiedByCol, isCreatedOrLastModifiedByCol,
isCreatedOrLastModifiedTimeCol, isCreatedOrLastModifiedTimeCol,
isLinksOrLTAR, isLinksOrLTAR,
@ -852,8 +852,8 @@ const isSelectedOnlyAI = computed(() => {
if (selectedRange.start.col === selectedRange.end.col) { if (selectedRange.start.col === selectedRange.end.col) {
const field = fields.value[selectedRange.start.col] const field = fields.value[selectedRange.start.col]
return { return {
enabled: field.uidt === UITypes.Button && (field?.colOptions as ButtonType)?.type === ButtonActionsType.Ai, enabled: isAIPromptCol(field) || isAiButton(field),
disabled: !ncIsArrayIncludes(aiIntegrations.value, (field?.colOptions as ButtonType)?.fk_integration_id, 'id'), disabled: !(field?.colOptions as ButtonType)?.fk_integration_id,
} }
} }
@ -876,9 +876,7 @@ const generateAIBulk = async () => {
let outputColumnIds = [field.id] let outputColumnIds = [field.id]
const isAiButton = field.uidt === UITypes.Button && (field?.colOptions as ButtonType)?.type === ButtonActionsType.Ai if (isAiButton(field)) {
if (isAiButton) {
outputColumnIds = outputColumnIds =
ncIsString(field.colOptions?.output_column_ids) && field.colOptions.output_column_ids.split(',').length > 0 ncIsString(field.colOptions?.output_column_ids) && field.colOptions.output_column_ids.split(',').length > 0
? field.colOptions.output_column_ids.split(',') ? field.colOptions.output_column_ids.split(',')
@ -2139,13 +2137,13 @@ onKeyStroke('ArrowDown', onDown)
</span> </span>
<div <div
v-else-if="!row.rowMeta.saving" 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 <component
:is="iconMap.expand" :is="iconMap.maximize"
v-if="expandForm" v-if="expandForm"
v-e="['c:row-expand:open']" 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)" @click="expandAndLooseFocus(row, state)"
/> />
</div> </div>

11
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 columnOrder = ref<Pick<ColumnReqType, 'column_order'> | null>(null)
const columnTypeName = computed(() => { const columnTypeName = computed(() => {
if (column.value.uidt === UITypes.LongText && parseProp(column?.value?.meta)?.richMode) { if (column.value.uidt === UITypes.LongText) {
return UITypesName.RichText 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] : '' 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 return iconMap.cellSingleSelect
} else if (isBoolean(column, abstractType)) { } else if (isBoolean(column, abstractType)) {
return iconMap.cellCheckbox return iconMap.cellCheckbox
} else if (isAI(column)) {
return iconMap.cellAi
} else if (isTextArea(column)) { } else if (isTextArea(column)) {
return iconMap.cellLongText return iconMap.cellLongText
} else if (isEmail(column)) { } else if (isEmail(column)) {
@ -51,8 +53,6 @@ const renderIcon = (column: ColumnType, abstractType: any) => {
return iconMap.cellUser return iconMap.cellUser
} }
return iconMap.cellUser return iconMap.cellUser
} else if (isAI(column)) {
return iconMap.cellAi
} else if (isInt(column, abstractType) || isFloat(column, abstractType)) { } else if (isInt(column, abstractType) || isFloat(column, abstractType)) {
return iconMap.cellNumber return iconMap.cellNumber
} else if (isString(column, abstractType)) { } 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 { isUIAllowed, isMetaReadOnly, isDataReadOnly } = useRoles()
const { aiIntegrations } = useNocoAi()
const isLoading = ref<'' | 'hideOrShow' | 'setDisplay'>('') 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 () => { const setAsDisplayValue = async () => {
isLoading.value = 'setDisplay' isLoading.value = 'setDisplay'
try { try {
@ -415,6 +428,10 @@ const onClickCopyFieldUrl = async (field: ColumnType) => {
await copy(field.id!) await copy(field.id!)
isFieldIdCopied.value = true isFieldIdCopied.value = true
await ncDelay(5000)
isFieldIdCopied.value = false
} }
const onDeleteColumn = () => { const onDeleteColumn = () => {
@ -431,7 +448,7 @@ const onDeleteColumn = () => {
overlay-class-name="nc-dropdown-column-operations !border-1 rounded-lg !shadow-xl " overlay-class-name="nc-dropdown-column-operations !border-1 rounded-lg !shadow-xl "
@click.stop="openDropdown" @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> <div v-if="isExpandedForm" class="h-[1px]">&nbsp;</div>
<NcTooltip v-if="column?.description?.length && !isExpandedForm" class="flex"> <NcTooltip v-if="column?.description?.length && !isExpandedForm" class="flex">
<template #title> <template #title>
@ -441,14 +458,10 @@ const onDeleteColumn = () => {
</NcTooltip> </NcTooltip>
<NcTooltip class="flex items-center"> <NcTooltip class="flex items-center">
<GeneralIcon <GeneralIcon v-if="columnInvalid.isInvalid && !isExpandedForm" class="text-red-300 w-3.5 h-3.5" icon="alertTriangle" />
v-if="isColumnInvalid(column) && !isExpandedForm"
class="text-orange-500 w-3.5 h-3.5 ml-2"
icon="alertTriangle"
/>
<template #title> <template #title>
{{ $t('msg.invalidColumnConfiguration') }} {{ $t(columnInvalid.tooltip) }}
</template> </template>
</NcTooltip> </NcTooltip>
<GeneralIcon <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 columnOrder = ref<Pick<ColumnReqType, 'column_order'> | null>(null)
const columnTypeName = computed(() => { 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) { if (column.value.uidt === UITypes.LinkToAnotherRecord && column.value.colOptions?.type === RelationTypes.ONE_TO_ONE) {
return UITypesName[UITypes.Links] return UITypesName[UITypes.Links]
} }
if (isAiButton(column.value)) {
return UITypesName.AIButton
}
return column.value.uidt ? UITypesName[column.value.uidt] : '' 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 () => { const onViewIdCopy = async () => {
await copy(view.value!.id!) await copy(view.value!.id!)
isViewIdCopied.value = true isViewIdCopied.value = true
await ncDelay(5000)
isViewIdCopied.value = false
} }
const onDelete = async () => { const onDelete = async () => {

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

@ -210,7 +210,7 @@ const componentProps = computed(() => {
<style scoped lang="scss"> <style scoped lang="scss">
.nc-cell-button { .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']) { &: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); 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"> <div v-if="!isUnderLookup && !isSystemColumn(column)" class="flex justify-end gap-1 min-h-4 items-center">
<GeneralIcon <GeneralIcon
icon="expand" icon="maximize"
class="select-none transform text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand" 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" @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"> <div v-if="!isUnderLookup || isForm" class="flex justify-end gap-1 min-h-4 items-center">
<GeneralIcon <GeneralIcon
icon="expand" icon="maximize"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand" class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand h-3 w-3"
@click.stop="openChildList" @click.stop="openChildList"
/> />

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

@ -1,8 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { UITypes, isVirtualCol, parseStringDateTime } from 'nocodb-sdk' import { UITypes, isVirtualCol, parseStringDateTime } from 'nocodb-sdk'
import MaximizeIcon from '~icons/nc-icons/maximize'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
row: any 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)" 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)" @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> </button>
</NcTooltip> </NcTooltip>
</div> </div>

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

@ -20,8 +20,6 @@ const {
setDefaultIntegration, setDefaultIntegration,
} = useIntegrationStore() } = useIntegrationStore()
const { loadAiIntegrations } = useNocoAi()
const { $api, $e } = useNuxtApp() const { $api, $e } = useNuxtApp()
const { allCollaborators } = storeToRefs(useWorkspace()) const { allCollaborators } = storeToRefs(useWorkspace())
@ -164,10 +162,6 @@ const onDeleteConfirm = async () => {
sources: [...base.sources.filter((s) => s.id !== source.id)], 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, integrationsRefreshKey,
integrationsCategoryFilter, integrationsCategoryFilter,
activeViewTab, activeViewTab,
loadDynamicIntegrations,
} = useIntegrationStore() } = useIntegrationStore()
const focusTextArea: VNodeRef = (el) => el && el?.focus?.() const focusTextArea: VNodeRef = (el) => el && el?.focus?.()
@ -218,6 +219,8 @@ const toggleShowOrHideAllCategory = () => {
} }
onMounted(() => { onMounted(() => {
loadDynamicIntegrations()
if (!integrationsCategoryFilter.value.length) { if (!integrationsCategoryFilter.value.length) {
integrationsCategoryFilter.value = integrationCategoriesRef.value.map((c) => c.value) 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> <script lang="ts" setup>
import { IntegrationCategoryType, type SyncDataType } from '#imports' import type { IntegrationCategoryType, SyncDataType } from '#imports'
import { clientTypes as _clientTypes } from '#imports' import { clientTypes as _clientTypes } from '#imports'
const props = defineProps<{ const props = defineProps<{
@ -22,8 +22,6 @@ const {
updateIntegration, updateIntegration,
} = useIntegrationStore() } = useIntegrationStore()
const { loadAiIntegrations } = useNocoAi()
const isEditMode = computed(() => pageMode.value === IntegrationsPageMode.EDIT) const isEditMode = computed(() => pageMode.value === IntegrationsPageMode.EDIT)
const initState = ref({ const initState = ref({
@ -43,10 +41,6 @@ const { form, formState, isLoading, initialState, submit } = useProvideFormBuild
try { try {
if (pageMode.value === IntegrationsPageMode.ADD) { if (pageMode.value === IntegrationsPageMode.ADD) {
await saveIntegration(formState.value) await saveIntegration(formState.value)
if (props.integrationType === IntegrationCategoryType.AI) {
loadAiIntegrations()
}
} else { } else {
await updateIntegration({ await updateIntegration({
id: activeIntegration.value?.id, id: activeIntegration.value?.id,

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

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

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

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

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

@ -1,6 +1,6 @@
import rfdc from 'rfdc' import rfdc from 'rfdc'
import type { ColumnReqType, ColumnType, TableType } from 'nocodb-sdk' 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 { Ref } from 'vue'
import type { RuleObject } from 'ant-design-vue/es/form' import type { RuleObject } from 'ant-design-vue/es/form'
import { generateUniqueColumnName } from '~/helpers/parsers/parserHelpers' import { generateUniqueColumnName } from '~/helpers/parsers/parserHelpers'
@ -110,6 +110,10 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
return true return true
} }
if (isAIPromptCol(formState.value)) {
return true
}
return false 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 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 { ComputedRef, Ref } from 'vue'
import type { CellRange } from '#imports' import type { CellRange } from '#imports'
@ -286,6 +286,7 @@ export function useData(args: {
col.uidt === UITypes.Lookup || col.uidt === UITypes.Lookup ||
col.uidt === UITypes.Button || col.uidt === UITypes.Button ||
col.uidt === UITypes.Attachment || col.uidt === UITypes.Attachment ||
isAIPromptCol(col) ||
col.au || col.au ||
(isValidValue(col?.cdf) && / on update /i.test(col.cdf))) (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 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 { Api, ColumnType, LinkToAnotherRecordType, PaginatedType, RelationTypes, TableType, ViewType } from 'nocodb-sdk'
import type { Row } from '../lib/types' import type { Row } from '../lib/types'
import { validateRowFilters } from '../utils/dataUtils' import { validateRowFilters } from '../utils/dataUtils'
@ -916,6 +916,7 @@ export function useInfiniteData(args: {
col.title && col.title &&
col.title in updatedRowData && col.title in updatedRowData &&
(columnsToUpdate.has(col.uidt as UITypes) || (columnsToUpdate.has(col.uidt as UITypes) ||
isAIPromptCol(col) ||
col.au || col.au ||
(isValidValue(col?.cdf) && / on update /i.test(col.cdf as string))) (isValidValue(col?.cdf) && / on update /i.test(col.cdf as string)))
) { ) {

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

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

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

@ -3,6 +3,7 @@ import { computed } from 'vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { MaybeRef } from '@vueuse/core' import type { MaybeRef } from '@vueuse/core'
import type { import type {
AIRecordType,
AttachmentType, AttachmentType,
ColumnType, ColumnType,
LinkToAnotherRecordType, LinkToAnotherRecordType,
@ -288,7 +289,15 @@ export function useMultiSelect(
} }
if (columnObj.uidt === UITypes.LongText) { if (columnObj.uidt === UITypes.LongText) {
textToCopy = `"${textToCopy.replace(/"/g, '\\"')}"` 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 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' const aiIntegrationNotFound = 'AI integration not found'
@ -13,15 +13,19 @@ export const useNocoAi = createSharedComposable(() => {
const { activeProjectId } = storeToRefs(basesStore) const { activeProjectId } = storeToRefs(basesStore)
const aiIntegrationAvailable = ref(true)
const aiLoading = ref(false) const aiLoading = ref(false)
const aiError = ref<string>('') const aiError = ref<string>('')
const aiIntegrations = ref<IntegrationType[]>([]) const aiIntegrations = ref<Partial<IntegrationType>[]>([])
const aiIntegrationAvailable = computed(() => !!aiIntegrations.value.length)
const isAiIntegrationAvailableInList = (integrationId?: string) => {
if (!aiIntegrationAvailable.value) return false
const { listIntegrationByType } = useProvideIntegrationViewStore() return ncIsArrayIncludes(aiIntegrations.value, integrationId, 'id')
}
const callAiUtilsApi = async (operation: string, input: any, customBaseId?: string, skipMsgToast = false) => { const callAiUtilsApi = async (operation: string, input: any, customBaseId?: string, skipMsgToast = false) => {
try { 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 { return {
aiIntegrationAvailable, aiIntegrationAvailable,
isAiIntegrationAvailableInList,
aiLoading, aiLoading,
aiError, aiError,
predictFieldType, predictFieldType,
@ -381,6 +364,5 @@ export const useNocoAi = createSharedComposable(() => {
repairFormula, repairFormula,
predictViews, predictViews,
aiIntegrations, 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 isURL from 'validator/lib/isURL'
import { pluralize } from 'inflection' import { pluralize } from 'inflection'
@ -478,8 +478,16 @@ export const generateUniqueColumnName = ({
case UITypes.Button: { case UITypes.Button: {
if (formState?.type === ButtonActionsType.Ai) { if (formState?.type === ButtonActionsType.Ai) {
defaultColumnName = `AI ${defaultColumnName}` defaultColumnName = UITypesName.AIButton
} }
break
}
case UITypes.LongText: {
if (formState?.meta?.[LongTextAiMetaProp] === true) {
defaultColumnName = UITypesName.AIPrompt
}
break
} }
} }

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

@ -56,6 +56,12 @@ export default {
return true return true
} }
if (event.key === '}') {
setTimeout(() => {
this.selectItem(this.selectedIndex)
}, 250)
}
return false return false
}, },
@ -80,6 +86,7 @@ export default {
selectItem(index, _e) { selectItem(index, _e) {
const item = this.items[index] const item = this.items[index]
if (item) { if (item) {
this.command({ this.command({
id: item.title, id: item.title,
@ -92,7 +99,7 @@ export default {
<template> <template>
<div <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 @mousedown.stop
> >
<template v-if="items.length"> <template v-if="items.length">
@ -100,11 +107,16 @@ export default {
v-for="(item, index) in items" v-for="(item, index) in items"
:key="index" :key="index"
:class="{ 'is-selected': index === selectedIndex }" :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)" @click="selectItem(index, $event)"
> >
<component :is="getUIDTIcon(item.uidt)" v-if="item?.uidt" class="flex-none w-3.5 h-3.5" /> <component :is="getUIDTIcon(item.uidt)" v-if="item?.uidt" class="flex-none w-3.5 h-3.5" />
{{ item?.title || '' }} <NcTooltip class="truncate" show-on-truncate-only :tooltip-style="{ zIndex: '10000' }">
<template #title>
{{ item?.title || '' }}
</template>
{{ item?.title || '' }}
</NcTooltip>
</div> </div>
</template> </template>
<div v-else class="px-4">No field available</div> <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/core',
'@vuelidate/validators', '@vuelidate/validators',
'company-email-validator', 'company-email-validator',
'crossoriginworker',
'dayjs/plugin/utc',
'd3-scale', 'd3-scale',
'dagre', 'dagre',
'deep-object-diff', 'deep-object-diff',
@ -237,6 +239,7 @@ export default defineNuxtConfig({
'marked', 'marked',
'mime-lite', 'mime-lite',
'monaco-editor', 'monaco-editor',
'monaco-editor/esm/vs/basic-languages/javascript/javascript',
'papaparse', 'papaparse',
'prosemirror-state', 'prosemirror-state',
'rehype-sanitize', 'rehype-sanitize',
@ -252,6 +255,7 @@ export default defineNuxtConfig({
'validator/es/lib/isEmail', 'validator/es/lib/isEmail',
'validator/lib/isMobilePhone', 'validator/lib/isMobilePhone',
'vue-advanced-cropper', 'vue-advanced-cropper',
'vue-barcode-reader',
'xlsx', 'xlsx',
'youtube-vue3', 'youtube-vue3',
'vuedraggable', '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 isButton = (column: ColumnType) => column.uidt === UITypes.Button
export const isAiButton = (column: ColumnType) => export const isAiButton = (column: ColumnType) =>
column.uidt === UITypes.Button && (column?.colOptions as any)?.type === ButtonActionsType.Ai 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) => export const isAutoSaved = (column: ColumnType) =>
[ [
UITypes.SingleLineText, UITypes.SingleLineText,

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

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

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

@ -71,3 +71,47 @@ export const ncArrayFrom = <T>(
export const isUnicodeEmoji = (emoji: string) => { export const isUnicodeEmoji = (emoji: string) => {
return !!emoji?.match(/(\p{Emoji}|\p{Extended_Pictographic})/gu) 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, orgs: string,
baseName: string, baseName: string,
tableName: string, tableName: string,
data: object,
query?: { query?: {
where?: string; where?: string;
viewId?: string; viewId?: string;
@ -8758,8 +8757,6 @@ export class Api<
path: `/api/v1/db/data/bulk/${orgs}/${baseName}/${tableName}/all`, path: `/api/v1/db/data/bulk/${orgs}/${baseName}/${tableName}/all`,
method: 'DELETE', method: 'DELETE',
query: query, query: query,
body: data,
type: ContentType.Json,
format: 'json', format: 'json',
...params, ...params,
}), }),

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

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

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

@ -229,6 +229,8 @@ export enum NcErrorType {
INVALID_ATTACHMENT_UPLOAD_SCOPE = 'INVALID_ATTACHMENT_UPLOAD_SCOPE', INVALID_ATTACHMENT_UPLOAD_SCOPE = 'INVALID_ATTACHMENT_UPLOAD_SCOPE',
} }
export const LongTextAiMetaProp = 'ai';
type Roles = OrgUserRoles | ProjectRoles | WorkspaceUserRoles; type Roles = OrgUserRoles | ProjectRoles | WorkspaceUserRoles;
type RolesObj = Partial<Record<Roles, boolean>>; 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) => { export const integrationCategoryNeedDefault = (category: IntegrationsType) => {
return [IntegrationsType.Ai].includes(category); 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, UITypesName,
FieldNameFromUITypes, FieldNameFromUITypes,
numericUITypes, numericUITypes,
isAIPromptCol,
isNumericCol, isNumericCol,
isVirtualCol, isVirtualCol,
isLinksOrLTAR, isLinksOrLTAR,

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

@ -10,11 +10,13 @@ import {
AuditOperationTypes, AuditOperationTypes,
ButtonActionsType, ButtonActionsType,
extractFilterFromXwhere, extractFilterFromXwhere,
isAIPromptCol,
isCreatedOrLastModifiedByCol, isCreatedOrLastModifiedByCol,
isCreatedOrLastModifiedTimeCol, isCreatedOrLastModifiedTimeCol,
isLinksOrLTAR, isLinksOrLTAR,
isSystemColumn, isSystemColumn,
isVirtualCol, isVirtualCol,
LongTextAiMetaProp,
RelationTypes, RelationTypes,
UITypes, UITypes,
} from 'nocodb-sdk'; } from 'nocodb-sdk';
@ -5716,7 +5718,10 @@ class BaseModelSqlv2 {
await this.model.getColumns(this.context); await this.model.getColumns(this.context);
await Promise.all( 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 { } else {
await this.prepareNocoData(d, false, cookie); await this.prepareNocoData(d, false, cookie, null, { raw });
const wherePk = await this._wherePk(pkValues, true); const wherePk = await this._wherePk(pkValues, true);
@ -8628,23 +8633,25 @@ class BaseModelSqlv2 {
jsonColumns: Record<string, any>[], jsonColumns: Record<string, any>[],
d: Record<string, any>, d: Record<string, any>,
) { ) {
try { if (d) {
if (d) { for (const col of jsonColumns) {
for (const col of jsonColumns) { if (d[col.id] && typeof d[col.id] === 'string') {
if (d[col.id] && typeof d[col.id] === 'string') { try {
d[col.id] = JSON.parse(d[col.id]); d[col.id] = JSON.parse(d[col.id]);
} } catch {}
}
if (d[col.id]?.length) { if (d[col.id]?.length) {
for (let i = 0; i < d[col.id].length; i++) { for (let i = 0; i < d[col.id].length; i++) {
if (typeof d[col.id][i] === 'string') { if (typeof d[col.id][i] === 'string') {
try {
d[col.id][i] = JSON.parse(d[col.id][i]); d[col.id][i] = JSON.parse(d[col.id][i]);
} } catch {}
} }
} }
} }
} }
} catch {} }
return d; return d;
} }
@ -8671,13 +8678,16 @@ class BaseModelSqlv2 {
for (const col of columns) { for (const col of columns) {
if (col.uidt === UITypes.Lookup) { if (col.uidt === UITypes.Lookup) {
const lookupNestedCol = await this.getNestedColumn(col);
if ( if (
JSON_COLUMN_TYPES.includes((await this.getNestedColumn(col))?.uidt) JSON_COLUMN_TYPES.includes(lookupNestedCol.uidt) ||
isAIPromptCol(lookupNestedCol)
) { ) {
jsonCols.push(col); jsonCols.push(col);
} }
} else { } else {
if (JSON_COLUMN_TYPES.includes(col.uidt)) { if (JSON_COLUMN_TYPES.includes(col.uidt) || isAIPromptCol(col)) {
jsonCols.push(col); jsonCols.push(col);
} }
} }
@ -9766,7 +9776,8 @@ class BaseModelSqlv2 {
cookie?: { user?: any; system?: boolean }, cookie?: { user?: any; system?: boolean },
// oldData uses title as key where as data uses column_name as key // oldData uses title as key where as data uses column_name as key
oldData?, oldData?,
) { extra?: { raw?: boolean },
): Promise<void> {
for (const column of this.model.columns) { for (const column of this.model.columns) {
if ( if (
![ ![
@ -9777,7 +9788,10 @@ class BaseModelSqlv2 {
UITypes.LastModifiedTime, UITypes.LastModifiedTime,
UITypes.CreatedBy, UITypes.CreatedBy,
UITypes.LastModifiedBy, UITypes.LastModifiedBy,
].includes(column.uidt) UITypes.LongText,
].includes(column.uidt) ||
(column.uidt === UITypes.LongText &&
column.meta?.[LongTextAiMetaProp] !== true)
) )
continue; continue;
@ -10046,6 +10060,48 @@ class BaseModelSqlv2 {
) { ) {
data[column.column_name] = JSON.stringify(data[column.column_name]); 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 { import {
FormulaDataTypes, FormulaDataTypes,
getEquivalentUIType, getEquivalentUIType,
isAIPromptCol,
isDateMonthFormat, isDateMonthFormat,
isNumericCol, isNumericCol,
RelationTypes, RelationTypes,
@ -592,6 +593,24 @@ const parseConditionV2 = async (
? 'YYYY-MM-DD HH:mm:ss' ? 'YYYY-MM-DD HH:mm:ss'
: 'YYYY-MM-DD HH:mm:ssZ'; : '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 ( if (
(column.uidt === UITypes.Formula && (column.uidt === UITypes.Formula &&
getEquivalentUIType({ formulaColumn: column }) == getEquivalentUIType({ formulaColumn: column }) ==

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

@ -2,6 +2,7 @@ import jsep from 'jsep';
import { import {
FormulaDataTypes, FormulaDataTypes,
jsepCurlyHook, jsepCurlyHook,
LongTextAiMetaProp,
RelationTypes, RelationTypes,
UITypes, UITypes,
validateDateWithUnknownFormat, validateDateWithUnknownFormat,
@ -828,6 +829,47 @@ async function _formulaQueryBuilder(params: {
}; };
} }
break; 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: default:
aliasToColumn[col.id] = () => aliasToColumn[col.id] = () =>
Promise.resolve({ builder: col.column_name }); 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 { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type { Knex } from 'knex'; import type { Knex } from 'knex';
import type { ButtonColumn, FormulaColumn, RollupColumn } from '~/models'; import type { ButtonColumn, FormulaColumn, RollupColumn } from '~/models';
@ -199,6 +199,33 @@ export default async function sortV2(
break; 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: default:
qb.orderBy( qb.orderBy(
sanitize(column.column_name), 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_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_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_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 // Create a custom migration source class
export default class XcMigrationSourcev2 { export default class XcMigrationSourcev2 {
@ -121,6 +122,7 @@ export default class XcMigrationSourcev2 {
'nc_066_ai_button', 'nc_066_ai_button',
'nc_067_personal_view', 'nc_067_personal_view',
'nc_068_user_delete', 'nc_068_user_delete',
'nc_069_ai_prompt',
]); ]);
} }
@ -244,6 +246,8 @@ export default class XcMigrationSourcev2 {
return nc_067_personal_view; return nc_067_personal_view;
case 'nc_068_user_delete': case 'nc_068_user_delete':
return 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 { import {
AllowedColumnTypesForQrAndBarcodes, AllowedColumnTypesForQrAndBarcodes,
isAIPromptCol,
isLinksOrLTAR, isLinksOrLTAR,
LongTextAiMetaProp,
UITypes, UITypes,
} from 'nocodb-sdk'; } from 'nocodb-sdk';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
@ -17,6 +19,7 @@ import Sort from '~/models/Sort';
import Filter from '~/models/Filter'; import Filter from '~/models/Filter';
import QrCodeColumn from '~/models/QrCodeColumn'; import QrCodeColumn from '~/models/QrCodeColumn';
import BarcodeColumn from '~/models/BarcodeColumn'; import BarcodeColumn from '~/models/BarcodeColumn';
import AIColumn from '~/models/AIColumn';
import { import {
ButtonColumn, ButtonColumn,
FileReference, FileReference,
@ -446,6 +449,24 @@ export default class Column<T = any> implements ColumnType {
} }
break; 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: /* default:
{ {
@ -549,6 +570,11 @@ export default class Column<T = any> implements ColumnType {
case UITypes.Barcode: case UITypes.Barcode:
res = await BarcodeColumn.read(context, this.id, ncMeta); res = await BarcodeColumn.read(context, this.id, ncMeta);
break; break;
case UITypes.LongText:
if (this.meta?.[LongTextAiMetaProp] === true) {
res = await AIColumn.read(context, this.id, ncMeta);
}
break;
// default: // default:
// res = await DbColumn.read(this.id); // res = await DbColumn.read(this.id);
// break; // 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, [ const cachedList = await NocoCache.getList(CacheScope.COLUMN, [
col.fk_model_id, col.fk_model_id,
@ -1030,6 +1098,12 @@ export default class Column<T = any> implements ColumnType {
colOptionTableName = MetaTable.COL_BARCODE; colOptionTableName = MetaTable.COL_BARCODE;
cacheScopeName = CacheScope.COL_BARCODE; cacheScopeName = CacheScope.COL_BARCODE;
break; break;
case UITypes.LongText:
if (col.meta?.[LongTextAiMetaProp] === true) {
colOptionTableName = MetaTable.COL_LONG_TEXT;
cacheScopeName = CacheScope.COL_LONG_TEXT;
}
break;
} }
if (colOptionTableName && cacheScopeName) { if (colOptionTableName && cacheScopeName) {
@ -1276,6 +1350,23 @@ export default class Column<T = any> implements ColumnType {
); );
break; 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, [ const updateObj = extractProps(column, [
@ -1867,6 +1958,20 @@ export default class Column<T = any> implements ColumnType {
} }
break; 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), insertGroups.get(group),
); );
break; 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, data: this,
}); });
if (!Array.isArray(config?.models)) {
config.models = [];
}
return config; 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( return await ncMeta.metaDelete(
this.fk_workspace_id ? this.fk_workspace_id : RootScopes.WORKSPACE, this.fk_workspace_id ? this.fk_workspace_id : RootScopes.WORKSPACE,
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( await ncMeta.metaUpdate(
this.fk_workspace_id ? this.fk_workspace_id : RootScopes.WORKSPACE, this.fk_workspace_id ? this.fk_workspace_id : RootScopes.WORKSPACE,
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 IntegrationStore } from './IntegrationStore';
export { default as FileReference } from './FileReference'; export { default as FileReference } from './FileReference';
export { default as ButtonColumn } from './ButtonColumn'; 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 { Readable } from 'stream';
import papaparse from 'papaparse'; import papaparse from 'papaparse';
import debug from 'debug'; 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 { Injectable } from '@nestjs/common';
import type { Job } from 'bull'; import type { Job } from 'bull';
import type { NcContext, NcRequest } from '~/interface/config'; import type { NcContext, NcRequest } from '~/interface/config';
@ -435,7 +440,7 @@ export class DuplicateProcessor {
}); });
// update cdf // update cdf
if (!isVirtualCol(destColumn)) { if (!isVirtualCol(destColumn) && !isAIPromptCol(destColumn)) {
await this.columnsService.columnUpdate(context, { await this.columnsService.columnUpdate(context, {
columnId: findWithIdentifier(idMap, sourceColumn.id), columnId: findWithIdentifier(idMap, sourceColumn.id),
column: { column: {

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

@ -1,5 +1,11 @@
import { Readable } from 'stream'; 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 { unparse } from 'papaparse';
import debug from 'debug'; import debug from 'debug';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -137,8 +143,21 @@ export class ExportService {
case 'fk_rollup_column_id': case 'fk_rollup_column_id':
case 'fk_qr_value_column_id': case 'fk_qr_value_column_id':
case 'fk_barcode_value_column_id': case 'fk_barcode_value_column_id':
case 'fk_model_id':
column.colOptions[k] = idMap.get(v as string); column.colOptions[k] = idMap.get(v as string);
break; 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': case 'fk_target_view_id':
if (v) { if (v) {
const view = await View.get(context, v as string); const view = await View.get(context, v as string);
@ -158,6 +177,8 @@ export class ExportService {
} }
break; break;
case 'formula': case 'formula':
if (column.uidt === UITypes.Button) break;
// rewrite formula_raw with aliases // rewrite formula_raw with aliases
column.colOptions['formula_raw'] = column.colOptions[ column.colOptions['formula_raw'] = column.colOptions[
k k
@ -604,6 +625,15 @@ export class ExportService {
row[colId] = v; row[colId] = v;
} }
break; 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.User:
case UITypes.CreatedBy: case UITypes.CreatedBy:
case UITypes.LastModifiedBy: case UITypes.LastModifiedBy:

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

@ -1,4 +1,5 @@
import { import {
isAIPromptCol,
isLinksOrLTAR, isLinksOrLTAR,
isVirtualCol, isVirtualCol,
RelationTypes, RelationTypes,
@ -178,6 +179,7 @@ export class ImportService {
(a) => (a) =>
!isVirtualCol(a) && !isVirtualCol(a) &&
a.uidt !== UITypes.ForeignKey && a.uidt !== UITypes.ForeignKey &&
!isAIPromptCol(a) &&
(param.importColumnIds (param.importColumnIds
? param.importColumnIds.includes(getEntityIdentifier(a.id)) ? param.importColumnIds.includes(getEntityIdentifier(a.id))
: true), : true),
@ -1112,7 +1114,8 @@ export class ImportService {
a.uidt === UITypes.LastModifiedTime || a.uidt === UITypes.LastModifiedTime ||
a.uidt === UITypes.CreatedBy || a.uidt === UITypes.CreatedBy ||
a.uidt === UITypes.LastModifiedBy || a.uidt === UITypes.LastModifiedBy ||
a.uidt === UITypes.Barcode) && a.uidt === UITypes.Barcode ||
isAIPromptCol(a)) &&
(param.importColumnIds (param.importColumnIds
? param.importColumnIds.includes(getEntityIdentifier(a.id)) ? param.importColumnIds.includes(getEntityIdentifier(a.id))
: true), : true),
@ -1242,6 +1245,12 @@ export class ImportService {
} }
} }
} else if (col.uidt === UITypes.Button) { } 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, { const freshModelData = await this.columnsService.columnAdd(context, {
tableId: getIdOrExternalId(getParentIdentifier(col.id)), tableId: getIdOrExternalId(getParentIdentifier(col.id)),
column: withoutId({ column: withoutId({
@ -1254,6 +1263,40 @@ export class ImportService {
icon: colOptions?.icon, icon: colOptions?.icon,
type: colOptions?.type, type: colOptions?.type,
fk_webhook_id: getIdOrExternalId(colOptions?.fk_webhook_id), 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, }) as any,
req: param.req, req: param.req,

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

@ -3,10 +3,12 @@ import {
AppEvents, AppEvents,
ButtonActionsType, ButtonActionsType,
FormulaDataTypes, FormulaDataTypes,
isAIPromptCol,
isCreatedOrLastModifiedByCol, isCreatedOrLastModifiedByCol,
isCreatedOrLastModifiedTimeCol, isCreatedOrLastModifiedTimeCol,
isLinksOrLTAR, isLinksOrLTAR,
isVirtualCol, isVirtualCol,
LongTextAiMetaProp,
partialUpdateAllowedTypes, partialUpdateAllowedTypes,
readonlyMetaAllowedTypes, readonlyMetaAllowedTypes,
RelationTypes, RelationTypes,
@ -66,6 +68,10 @@ import Noco from '~/Noco';
import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2'; import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2';
import { MetaTable } from '~/utils/globals'; import { MetaTable } from '~/utils/globals';
import { MetaService } from '~/meta/meta.service'; import { MetaService } from '~/meta/meta.service';
import {
convertAIRecordTypeToValue,
convertValueToAIRecordType,
} from '~/utils/dataConversion';
// todo: move // todo: move
export enum Altered { export enum Altered {
@ -551,10 +557,6 @@ export class ColumnsService {
NcError.badRequest('Webhook not found'); NcError.badRequest('Webhook not found');
} }
} else if (colBody.type === ButtonActionsType.Ai) { } else if (colBody.type === ButtonActionsType.Ai) {
if (!colBody.fk_integration_id) {
NcError.badRequest('AI Integration not found');
}
/* /*
Substitute column alias with id in prompt 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); colBody = await getColumnPropsFromUIDT(colBody, source);
await this.updateMetaAndDatabase(context, { await this.updateMetaAndDatabase(context, {
@ -1914,10 +1985,6 @@ export class ColumnsService {
colBody.fk_webhook_id = null; colBody.fk_webhook_id = null;
} }
} else if (colBody.type === ButtonActionsType.Ai) { } else if (colBody.type === ButtonActionsType.Ai) {
if (!colBody.fk_integration_id) {
NcError.badRequest('AI Integration not found');
}
/* /*
Substitute column alias with id in prompt 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 = { const tableUpdateBody = {
...table, ...table,
tn: table.table_name, 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_FORMULA = 'nc_col_formula_v2',
COL_QRCODE = 'nc_col_qrcode_v2', COL_QRCODE = 'nc_col_qrcode_v2',
COL_BARCODE = 'nc_col_barcode_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', FILTER_EXP = 'nc_filter_exp_v2',
// HOOK_FILTER_EXP = 'nc_hook_filter_exp_v2', // HOOK_FILTER_EXP = 'nc_hook_filter_exp_v2',
SORT = 'nc_sort_v2', SORT = 'nc_sort_v2',
@ -145,7 +145,7 @@ export enum CacheScope {
COL_FORMULA = 'colFormula', COL_FORMULA = 'colFormula',
COL_QRCODE = 'colQRCode', COL_QRCODE = 'colQRCode',
COL_BARCODE = 'colBarcode', COL_BARCODE = 'colBarcode',
COL_AI = 'colAi', COL_LONG_TEXT = 'colLongText',
FILTER_EXP = 'filterExp', FILTER_EXP = 'filterExp',
SORT = 'sort', SORT = 'sort',
SHARED_VIEW = 'sharedView', SHARED_VIEW = 'sharedView',
@ -241,6 +241,9 @@ export const RootScopeTables = {
[RootScopes.BASE]: [MetaTable.PROJECT], [RootScopes.BASE]: [MetaTable.PROJECT],
// It's a special case and Workspace is equivalent to org in oss // It's a special case and Workspace is equivalent to org in oss
[RootScopes.WORKSPACE]: [ [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,
MetaTable.INTEGRATIONS_STORE, MetaTable.INTEGRATIONS_STORE,
], ],

Loading…
Cancel
Save