diff --git a/apps/nestjs-backend/package.json b/apps/nestjs-backend/package.json index 815a1d1654..b015e4a188 100644 --- a/apps/nestjs-backend/package.json +++ b/apps/nestjs-backend/package.json @@ -123,17 +123,18 @@ "webpack": "5.91.0" }, "dependencies": { - "@ai-sdk/amazon-bedrock": "4.0.69", - "@ai-sdk/anthropic": "3.0.50", - "@ai-sdk/azure": "3.0.38", - "@ai-sdk/cohere": "3.0.22", - "@ai-sdk/deepseek": "2.0.21", - "@ai-sdk/google": "3.0.34", - "@ai-sdk/mistral": "3.0.21", - "@ai-sdk/openai": "3.0.37", - "@ai-sdk/openai-compatible": "2.0.31", - "@ai-sdk/togetherai": "2.0.35", - "@ai-sdk/xai": "3.0.60", + "@ai-sdk/amazon-bedrock": "4.0.97", + "@ai-sdk/anthropic": "3.0.72", + "@ai-sdk/azure": "3.0.55", + "@ai-sdk/cohere": "3.0.31", + "@ai-sdk/deepseek": "2.0.30", + "@ai-sdk/google": "3.0.65", + "@ai-sdk/mistral": "3.0.31", + "@ai-sdk/openai": "3.0.54", + "@ai-sdk/openai-compatible": "2.0.42", + "@ai-sdk/provider": "3.0.9", + "@ai-sdk/togetherai": "2.0.46", + "@ai-sdk/xai": "3.0.84", "@an-epiphany/websocket-json-stream": "1.2.0", "@aws-sdk/client-s3": "3.609.0", "@aws-sdk/lib-storage": "3.609.0", @@ -154,7 +155,7 @@ "@nestjs/swagger": "7.3.0", "@nestjs/terminus": "10.2.3", "@nestjs/websockets": "10.3.5", - "@openrouter/ai-sdk-provider": "2.2.3", + "@openrouter/ai-sdk-provider": "2.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.5.0", "@opentelemetry/exporter-logs-otlp-http": "0.201.1", @@ -193,7 +194,7 @@ "@teable/v2-di": "workspace:*", "@teable/v2-import": "workspace:*", "@valibot/to-json-schema": "1.3.0", - "ai": "6.0.105", + "ai": "6.0.169", "ajv": "8.12.0", "archiver": "7.0.1", "axios": "1.7.7", @@ -241,7 +242,7 @@ "oauth2orize": "1.12.0", "oauth2orize-pkce": "0.1.2", "object-sizeof": "2.6.4", - "ollama-ai-provider-v2": "3.0.2", + "ollama-ai-provider-v2": "3.5.0", "p-limit": "3.1.0", "papaparse": "5.4.1", "passport": "0.7.0", diff --git a/apps/nestjs-backend/src/cache/types.ts b/apps/nestjs-backend/src/cache/types.ts index c200ccd5cd..942cc59623 100644 --- a/apps/nestjs-backend/src/cache/types.ts +++ b/apps/nestjs-backend/src/cache/types.ts @@ -36,6 +36,7 @@ export interface ICacheStore { [key: `oauth:token-rate:${string}:${string}`]: number; [key: `automation:email:rate:${string}:${number}`]: number; [key: `automation:email-att:${string}`]: string[]; + [key: `automation:fail-notify-count:${string}`]: number; // Distributed lock keys [key: `lock:${string}`]: string; [key: `import:result:manifest:${string}`]: { diff --git a/apps/nestjs-backend/src/db-provider/filter-query/__tests__/invalid-filter-skip.spec.ts b/apps/nestjs-backend/src/db-provider/filter-query/__tests__/invalid-filter-skip.spec.ts new file mode 100644 index 0000000000..b058bbdc73 --- /dev/null +++ b/apps/nestjs-backend/src/db-provider/filter-query/__tests__/invalid-filter-skip.spec.ts @@ -0,0 +1,306 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { + CellValueType, + DateFieldCore, + DateFormattingPreset, + DriverClient, + FieldType, + NumberFieldCore, + SingleLineTextFieldCore, + TimeFormatting, +} from '@teable/core'; +import type { FieldCore, IFilter } from '@teable/core'; +import knex from 'knex'; +import type { IRecordQueryFilterContext } from '../../../features/record/query-builder/record-query-builder.interface'; +import type { IDbProvider } from '../../db.provider.interface'; +import type { AbstractCellValueFilter } from '../cell-value-filter.abstract'; +import { AbstractFilterQuery } from '../filter-query.abstract'; +import { FilterQueryPostgres } from '../postgres/filter-query.postgres'; + +const knexBuilder = knex({ client: 'pg' }); +const dbProviderStub = { driver: DriverClient.Pg } as unknown as IDbProvider; +const mainTableAlias = 'main_table as main'; + +function assignBaseField( + field: T, + params: { + id: string; + name?: string; + dbFieldName: string; + type: FieldType; + cellValueType: CellValueType; + options: T['options']; + } +): T { + field.id = params.id; + field.name = params.name ?? params.id; + field.dbFieldName = params.dbFieldName; + field.type = params.type; + field.options = params.options; + field.cellValueType = params.cellValueType; + field.isMultipleCellValue = false; + field.isLookup = false; + field.updateDbFieldType(); + return field; +} + +function createNumberField(id: string, dbFieldName: string): NumberFieldCore { + return assignBaseField(new NumberFieldCore(), { + id, + dbFieldName, + type: FieldType.Number, + cellValueType: CellValueType.Number, + options: NumberFieldCore.defaultOptions(), + }); +} + +function createTextField(id: string, dbFieldName: string, name?: string): SingleLineTextFieldCore { + return assignBaseField(new SingleLineTextFieldCore(), { + id, + name, + dbFieldName, + type: FieldType.SingleLineText, + cellValueType: CellValueType.String, + options: SingleLineTextFieldCore.defaultOptions(), + }); +} + +function createDateField(id: string, dbFieldName: string): DateFieldCore { + const options = DateFieldCore.defaultOptions(); + options.formatting = { + date: DateFormattingPreset.ISO, + time: TimeFormatting.None, + timeZone: 'UTC', + }; + return assignBaseField(new DateFieldCore(), { + id, + dbFieldName, + type: FieldType.Date, + cellValueType: CellValueType.DateTime, + options, + }); +} + +class ThrowingFilterQuery extends AbstractFilterQuery { + private createThrowingFilter(): AbstractCellValueFilter { + return { + compiler: () => { + throw new Error('unexpected adapter failure'); + }, + } as unknown as AbstractCellValueFilter; + } + + booleanFilter(_field: FieldCore, _context?: IRecordQueryFilterContext): AbstractCellValueFilter { + return this.createThrowingFilter(); + } + + numberFilter(_field: FieldCore, _context?: IRecordQueryFilterContext): AbstractCellValueFilter { + return this.createThrowingFilter(); + } + + dateTimeFilter(_field: FieldCore, _context?: IRecordQueryFilterContext): AbstractCellValueFilter { + return this.createThrowingFilter(); + } + + stringFilter(_field: FieldCore, _context?: IRecordQueryFilterContext): AbstractCellValueFilter { + return this.createThrowingFilter(); + } + + jsonFilter(_field: FieldCore, _context?: IRecordQueryFilterContext): AbstractCellValueFilter { + return this.createThrowingFilter(); + } +} + +describe('filter-query invalid filter skip', () => { + it('skips filter item with invalid operator instead of throwing', () => { + const numberField = createNumberField('fld_num', 'num_col'); + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: numberField.id, + operator: 'contains', + value: 'whatever', + }, + ], + } as unknown as IFilter; + + const qb = knexBuilder(mainTableAlias); + const filterQuery = new FilterQueryPostgres( + qb, + { [numberField.id]: numberField }, + filter, + undefined, + dbProviderStub + ); + + expect(() => filterQuery.appendQueryBuilder()).not.toThrow(); + expect(qb.toQuery()).not.toContain('num_col'); + }); + + it('preserves valid filter items alongside skipped invalid ones', () => { + const numberField = createNumberField('fld_num', 'num_col'); + const textField = createTextField('fld_text', 'text_col'); + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: numberField.id, + operator: 'contains', + value: 'whatever', + }, + { + fieldId: textField.id, + operator: 'contains', + value: 'hello', + }, + ], + } as unknown as IFilter; + + const qb = knexBuilder(mainTableAlias); + const filterQuery = new FilterQueryPostgres( + qb, + { [numberField.id]: numberField, [textField.id]: textField }, + filter, + undefined, + dbProviderStub + ); + + expect(() => filterQuery.appendQueryBuilder()).not.toThrow(); + const sql = qb.toQuery(); + expect(sql).toContain('text_col'); + expect(sql).not.toContain('num_col'); + }); + + it('keeps filter items keyed by field name when fields map supports name keys', () => { + const textField = createTextField('fld_text_name', 'text_name_col', 'Display Name'); + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: textField.name, + operator: 'contains', + value: 'hello', + }, + ], + } as unknown as IFilter; + + const qb = knexBuilder(mainTableAlias); + const filterQuery = new FilterQueryPostgres( + qb, + { [textField.id]: textField, [textField.name]: textField }, + filter, + undefined, + dbProviderStub + ); + + expect(() => filterQuery.appendQueryBuilder()).not.toThrow(); + expect(qb.toQuery()).toContain('text_name_col'); + }); + + it('skips filter item with invalid sub-operator mode instead of throwing', () => { + const dateField = createDateField('fld_date', 'date_col'); + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: dateField.id, + operator: 'isWithIn', + value: { mode: 'invalidMode', exactDate: null, timeZone: 'UTC' }, + }, + ], + } as unknown as IFilter; + + const qb = knexBuilder(mainTableAlias); + const filterQuery = new FilterQueryPostgres( + qb, + { [dateField.id]: dateField }, + filter, + undefined, + dbProviderStub + ); + + expect(() => filterQuery.appendQueryBuilder()).not.toThrow(); + expect(qb.toQuery()).not.toContain('date_col'); + }); + + it('skips filter item whose value shape fails inside the adapter compiler', () => { + const dateField = createDateField('fld_date_shape', 'date_shape_col'); + // value is a string, but isWithIn requires an object { mode, ... } + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: dateField.id, + operator: 'isWithIn', + value: 'today', + }, + ], + } as unknown as IFilter; + + const qb = knexBuilder(mainTableAlias).select('id'); + const filterQuery = new FilterQueryPostgres( + qb, + { [dateField.id]: dateField }, + filter, + undefined, + dbProviderStub + ); + + expect(() => filterQuery.appendQueryBuilder()).not.toThrow(); + expect(() => qb.toQuery()).not.toThrow(); + }); + + it('rethrows non-user compiler errors instead of swallowing them', () => { + const numberField = createNumberField('fld_num_system', 'num_system_col'); + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: numberField.id, + operator: 'is', + value: 1, + }, + ], + } as unknown as IFilter; + + const qb = knexBuilder(mainTableAlias).select('id'); + const filterQuery = new ThrowingFilterQuery( + qb, + { [numberField.id]: numberField }, + filter, + undefined, + dbProviderStub + ); + + expect(() => filterQuery.appendQueryBuilder()).not.toThrow(); + expect(() => qb.toQuery()).toThrow(); + }); + + it('rethrows field-reference context errors instead of skipping them', () => { + const textField = createTextField('fld_text_ref_context', 'text_ref_context_col'); + const refField = createTextField('fld_ref_context', 'ref_context_col'); + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: textField.id, + operator: 'is', + value: { type: 'field', fieldId: refField.id }, + }, + ], + } as unknown as IFilter; + + const qb = knexBuilder(mainTableAlias).select('id'); + const filterQuery = new FilterQueryPostgres( + qb, + { [textField.id]: textField, [refField.id]: refField }, + filter, + undefined, + dbProviderStub + ); + + expect(() => filterQuery.appendQueryBuilder()).not.toThrow(); + expect(() => qb.toQuery()).toThrow('not available for reference comparisons'); + }); +}); diff --git a/apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts b/apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts index 6910423874..d05f322b32 100644 --- a/apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts +++ b/apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts @@ -1,8 +1,8 @@ -import { Logger } from '@nestjs/common'; +import { BadRequestException, Logger } from '@nestjs/common'; import type { FieldCore, IConjunction, - IDateTimeFieldOperator, + IFilterValidationError, IFilter, IFilterItem, IFilterOperator, @@ -14,17 +14,16 @@ import { CellValueType, DbFieldType, FieldType, + analyzeFilterValidationIssues, getFilterOperatorMapping, - getValidFilterSubOperators, - HttpErrorCode, isEmpty, isMeTag, isNotEmpty, isFieldReferenceValue, } from '@teable/core'; import type { Knex } from 'knex'; -import { includes, invert, isObject } from 'lodash'; -import { CustomHttpException } from '../../custom.exception'; +import { includes, invert } from 'lodash'; +import { ZodError } from 'zod'; import type { IRecordQueryFilterContext } from '../../features/record/query-builder/record-query-builder.interface'; import type { IDbProvider, IFilterQueryExtra } from '../db.provider.interface'; import type { AbstractCellValueFilter } from './cell-value-filter.abstract'; @@ -33,6 +32,7 @@ import type { IFilterQueryInterface } from './filter-query.interface'; export abstract class AbstractFilterQuery implements IFilterQueryInterface { private logger = new Logger(AbstractFilterQuery.name); + private filterValidationIssueMap = new Map(); constructor( protected readonly originQueryBuilder: Knex.QueryBuilder, @@ -45,6 +45,7 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface { appendQueryBuilder(): Knex.QueryBuilder { this.preProcessRemoveNullAndReplaceMe(this.filter); + this.filterValidationIssueMap = this.collectFilterValidationIssues(this.filter); return this.parseFilters(this.originQueryBuilder, this.filter); } @@ -52,20 +53,22 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface { private parseFilters( queryBuilder: Knex.QueryBuilder, filter?: IFilter, - parentConjunction?: IConjunction + parentConjunction?: IConjunction, + path: number[] = [] ): Knex.QueryBuilder { if (!filter || !filter.filterSet) { return queryBuilder; } const { filterSet, conjunction } = filter; queryBuilder.where((filterBuilder) => { - filterSet.forEach((filterItem) => { + filterSet.forEach((filterItem, index) => { + const itemPath = [...path, index]; if ('fieldId' in filterItem) { - this.parseFilter(filterBuilder, filterItem as IFilterItem, conjunction); + this.parseFilter(filterBuilder, filterItem as IFilterItem, conjunction, itemPath); } else { filterBuilder = filterBuilder[parentConjunction || conjunction]; filterBuilder.where((builder) => { - this.parseFilters(builder, filterItem as IFilterSet, conjunction); + this.parseFilters(builder, filterItem as IFilterSet, conjunction, itemPath); }); } }); @@ -77,7 +80,8 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface { private parseFilter( queryBuilder: Knex.QueryBuilder, filterMeta: IFilterItem, - conjunction: IConjunction + conjunction: IConjunction, + path: number[] ) { const { fieldId, operator, value, isSymbol } = filterMeta; @@ -86,71 +90,153 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface { return queryBuilder; } - let convertOperator = operator; - const filterOperatorMapping = getFilterOperatorMapping(field); - const validFilterOperators = Object.keys(filterOperatorMapping); - if (isSymbol) { - convertOperator = invert(filterOperatorMapping)[operator] as IFilterOperator; + if (this.shouldSkipInvalidFilterItem(field, filterMeta, path)) { + return queryBuilder; } + const convertOperator = this.getConvertedOperator(field, operator, isSymbol); + const validFilterOperators = Object.keys(getFilterOperatorMapping(field)); + if (!includes(validFilterOperators, convertOperator)) { - let referenceFieldId: string | undefined; - if (isFieldReferenceValue(value)) { - referenceFieldId = value.fieldId; - } else if (Array.isArray(value)) { - referenceFieldId = ( - value.find((entry) => isFieldReferenceValue(entry)) as IFieldReferenceValue | undefined - )?.fieldId; - } + this.throwIfFilterReferencesInvalidOperator(field, value); + this.logger.warn( + `Skip filter item: field=${field.id}(${field.name}) operator='${convertOperator}' not in [${validFilterOperators.join(',')}]` + ); + return queryBuilder; + } - if (referenceFieldId) { - const referenceName = this.fields?.[referenceFieldId]?.name ?? referenceFieldId; - const sourceName = field.name ?? field.id; - throw new FieldReferenceCompatibilityException(sourceName, referenceName); - } + queryBuilder = queryBuilder[conjunction]; - throw new CustomHttpException( - `The '${convertOperator}' operation provided for the '${field.name}' filter is invalid. Only the following types are allowed: [${validFilterOperators}]`, - HttpErrorCode.VALIDATION_ERROR, - { - localization: { - i18nKey: 'httpErrors.view.filterInvalidOperator', - }, - } + try { + this.getFilterAdapter(field).compiler( + queryBuilder, + convertOperator as IFilterOperator, + value, + this.dbProvider! ); + } catch (error) { + this.handleCompilerError(error, field, convertOperator, value); + } + return queryBuilder; + } + + private shouldSkipInvalidFilterItem(field: FieldCore, filterMeta: IFilterItem, path: number[]) { + const validationIssues = this.getFilterItemValidationIssues(path); + if (validationIssues.length === 0) { + return false; } - const validFilterSubOperators = getValidFilterSubOperators( - field.type, - convertOperator as IDateTimeFieldOperator + const hasInvalidOperator = validationIssues.some( + (issue) => issue.code === 'OPERATOR_NOT_ALLOWED' ); + if (hasInvalidOperator) { + this.throwIfFilterReferencesInvalidOperator(field, filterMeta.value); + } - if ( - validFilterSubOperators && - isObject(value) && - 'mode' in value && - !includes(validFilterSubOperators, value.mode) - ) { - throw new CustomHttpException( - `The '${convertOperator}' operation provided for the '${field.name}' filter is invalid. Only the following subtypes are allowed: [${validFilterSubOperators}]`, - HttpErrorCode.VALIDATION_ERROR, - { - localization: { - i18nKey: 'httpErrors.view.filterInvalidOperatorMode', - }, - } - ); + this.logger.warn( + `Skip filter item: field=${field.id}(${field.name}) path=${path.join('.')} issues=[${validationIssues + .map((issue) => issue.code) + .join(',')}]` + ); + return true; + } + + private getConvertedOperator(field: FieldCore, operator: string, isSymbol?: boolean) { + if (!isSymbol) { + return operator as IFilterOperator; } - queryBuilder = queryBuilder[conjunction]; + return invert(getFilterOperatorMapping(field))[operator] as IFilterOperator; + } - this.getFilterAdapter(field).compiler( - queryBuilder, - convertOperator as IFilterOperator, - value, - this.dbProvider! + private throwIfFilterReferencesInvalidOperator(field: FieldCore, value: unknown) { + const referenceFieldId = this.extractFieldReferenceFieldId(value); + if (!referenceFieldId) { + return; + } + + const referenceName = this.fields?.[referenceFieldId]?.name ?? referenceFieldId; + const sourceName = field.name ?? field.id; + throw new FieldReferenceCompatibilityException(sourceName, referenceName); + } + + private handleCompilerError( + error: unknown, + field: FieldCore, + convertOperator: IFilterOperator, + value: unknown + ) { + if (error instanceof FieldReferenceCompatibilityException) { + throw error; + } + if (this.extractFieldReferenceFieldId(value)) { + throw error; + } + if (!this.isSkippableCompilerError(error)) { + throw error; + } + const reason = error instanceof Error ? error.message : String(error); + this.logger.warn( + `Skip filter item: field=${field.id}(${field.name}) operator='${convertOperator}' ` + + `value=${JSON.stringify(value)} compile error: ${reason}` ); - return queryBuilder; + } + + private collectFilterValidationIssues(filter?: IFilter) { + const issueMap = new Map(); + if (!filter || !this.fields) { + return issueMap; + } + + const fieldMetaMap = Object.entries(this.fields).reduce( + (acc, [fieldKey, field]) => { + const fieldMeta = { + type: field.type, + cellValueType: field.cellValueType, + isMultipleCellValue: Boolean(field.isMultipleCellValue), + }; + acc[fieldKey] = fieldMeta; + acc[field.id] = fieldMeta; + return acc; + }, + {} as Record< + string, + { + type: FieldType; + cellValueType: CellValueType; + isMultipleCellValue: boolean; + } + > + ); + + const issues = analyzeFilterValidationIssues(filter, fieldMetaMap); + issues.forEach((issue) => { + const key = issue.path.join('.'); + const issueList = issueMap.get(key) ?? []; + issueList.push(issue); + issueMap.set(key, issueList); + }); + return issueMap; + } + + private getFilterItemValidationIssues(path: number[]) { + return this.filterValidationIssueMap.get(path.join('.')) ?? []; + } + + private extractFieldReferenceFieldId(value: unknown): string | undefined { + if (isFieldReferenceValue(value)) { + return value.fieldId; + } + if (Array.isArray(value)) { + return ( + value.find((entry) => isFieldReferenceValue(entry)) as IFieldReferenceValue | undefined + )?.fieldId; + } + return undefined; + } + + private isSkippableCompilerError(error: unknown) { + return error instanceof BadRequestException || error instanceof ZodError; } private getFilterAdapter(field: FieldCore): AbstractCellValueFilter { diff --git a/apps/nestjs-backend/src/db-provider/formula-default-unit.spec.ts b/apps/nestjs-backend/src/db-provider/formula-default-unit.spec.ts index 1909991cf4..dd2c9259a2 100644 --- a/apps/nestjs-backend/src/db-provider/formula-default-unit.spec.ts +++ b/apps/nestjs-backend/src/db-provider/formula-default-unit.spec.ts @@ -69,3 +69,35 @@ describe('convertFormulaToGeneratedColumn blank numeric comparisons', () => { expect(result.sql).toContain("= ''"); }); }); + +describe('convertFormulaToSelectQuery blank numeric comparisons', () => { + it('keeps spaced BLANK() as a blank operand when comparing number fields', () => { + const numberField = createFieldInstanceByVo({ + id: 'fldWeight', + dbFieldName: 'weight', + name: 'Weight', + type: FieldType.Number, + cellValueType: CellValueType.Number, + dbFieldType: DbFieldType.Real, + }); + const table = new TableDomain({ + id: 'tblFormulaUnit', + name: 'Formula Unit', + dbTableName: 'public.tbl_formula_unit', + lastModifiedTime: '2026-04-08T00:00:00.000Z', + fields: [numberField], + }); + const provider = new PostgresProvider(knex({ client: 'pg' })); + const sql = toSql( + provider.convertFormulaToSelectQuery('{fldWeight} != BLANK()', { + ...context, + table, + }) + ); + + expect(sql).toContain('COALESCE(NULLIF'); + expect(sql).toContain('"weight"'); + expect(sql).not.toContain('::numeric'); + expect(sql).toContain("<> ''"); + }); +}); diff --git a/apps/nestjs-backend/src/db-provider/search-query/date-search-range.util.ts b/apps/nestjs-backend/src/db-provider/search-query/date-search-range.util.ts new file mode 100644 index 0000000000..3e85cf721f --- /dev/null +++ b/apps/nestjs-backend/src/db-provider/search-query/date-search-range.util.ts @@ -0,0 +1,80 @@ +import { DateFormattingPreset, type IDateFieldOptions, TimeFormatting } from '@teable/core'; +import dayjs from 'dayjs'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(customParseFormat); + +type IDateSearchUnit = 'year' | 'month' | 'day' | 'minute'; + +export interface IDateSearchRange { + start: string; + end: string; +} + +const dateSearchPatterns: Array<{ pattern: RegExp; format: string; unit: IDateSearchUnit }> = [ + { pattern: /^\d{4}$/, format: 'YYYY', unit: 'year' }, + { pattern: /^\d{4}-\d{2}$/, format: 'YYYY-MM', unit: 'month' }, + { pattern: /^\d{4}-\d{2}-\d{2}$/, format: 'YYYY-MM-DD', unit: 'day' }, + { pattern: /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}$/, format: 'YYYY-MM-DD HH:mm', unit: 'minute' }, +]; + +const isUnitAllowed = ( + unit: IDateSearchUnit, + formatting: IDateFieldOptions['formatting'] +): boolean => { + const dateFormat = formatting.date ?? DateFormattingPreset.ISO; + const hasTime = formatting.time != null && formatting.time !== TimeFormatting.None; + + switch (unit) { + case 'year': + return true; + case 'month': + return dateFormat !== DateFormattingPreset.Y; + case 'day': + return dateFormat !== DateFormattingPreset.Y && dateFormat !== DateFormattingPreset.YM; + case 'minute': + return hasTime; + default: + return false; + } +}; + +export const getDateSearchRange = ( + rawSearchValue: string, + dateFieldOptions: IDateFieldOptions +): IDateSearchRange | null => { + const searchValue = rawSearchValue.trim(); + if (!searchValue) { + return null; + } + + const formatting = dateFieldOptions.formatting; + const timeZone = formatting.timeZone; + + for (const candidate of dateSearchPatterns) { + if (!candidate.pattern.test(searchValue) || !isUnitAllowed(candidate.unit, formatting)) { + continue; + } + + const normalizedSearchValue = + candidate.unit === 'minute' ? searchValue.replace('T', ' ') : searchValue; + const parsed = dayjs.tz(normalizedSearchValue, candidate.format, timeZone); + if (!parsed.isValid() || parsed.format(candidate.format) !== normalizedSearchValue) { + continue; + } + + const start = parsed.startOf(candidate.unit); + const end = start.add(1, candidate.unit); + + return { + start: start.toISOString(), + end: end.toISOString(), + }; + } + + return null; +}; diff --git a/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.spec.ts b/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.spec.ts index e8544e1a62..e4756350b4 100644 --- a/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.spec.ts +++ b/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.spec.ts @@ -1,9 +1,9 @@ import { CellValueType, FieldType } from '@teable/core'; import type { IFieldInstance } from '../../features/field/model/factory'; -import { FieldFormatter } from './search-index-builder.postgres'; +import { FieldFormatter, IndexBuilderPostgres } from './search-index-builder.postgres'; describe('FieldFormatter', () => { - it('formats date fields for search without creating a trigram index expression', () => { + it('does not expose trigram search expressions for date fields, but still builds a btree index spec', () => { const field = { cellValueType: CellValueType.DateTime, dbFieldName: 'Due_Date', @@ -17,9 +17,32 @@ describe('FieldFormatter', () => { type: FieldType.Date, } as IFieldInstance; - expect(FieldFormatter.getSearchableExpression(field)).toBe( - "TO_CHAR(TIMEZONE('Asia/Singapore', \"Due_Date\"), 'YYYY-MM-DD HH24:MI')" + expect(FieldFormatter.getSearchableExpression(field)).toBeNull(); + expect(FieldFormatter.getIndexSpec(field)).toEqual({ + kind: 'btree', + expression: '"Due_Date"', + }); + }); + + it('creates a btree index sql for single datetime fields', () => { + const builder = new IndexBuilderPostgres(); + const field = { + id: 'fldDateField000001', + cellValueType: CellValueType.DateTime, + dbFieldName: 'Due_Date', + isMultipleCellValue: false, + isStructuredCellValue: false, + options: { + formatting: { + timeZone: 'Asia/Singapore', + }, + }, + type: FieldType.Date, + } as IFieldInstance; + + expect(builder.createSingleIndexSql('base_table.records', field)).toContain( + 'ON "base_table"."records" USING btree ("Due_Date")' ); - expect(FieldFormatter.getIndexExpression(field)).toBeNull(); + expect(builder.createSingleIndexSql('base_table.records', field)).not.toContain('gin_trgm_ops'); }); }); diff --git a/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.ts b/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.ts index a8a522ae10..c6f852136c 100644 --- a/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.ts +++ b/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.ts @@ -1,7 +1,6 @@ /* eslint-disable regexp/no-unused-capturing-group */ /* eslint-disable sonarjs/no-duplicate-string */ import { assertNever, CellValueType, FieldType } from '@teable/core'; -import type { IDateFieldOptions } from '@teable/core'; import type { IFieldInstance } from '../../features/field/model/factory'; import { IndexBuilderAbstract } from '../index-query/index-abstract-builder'; @@ -14,7 +13,17 @@ interface IPgIndex { indexdef: string; } -const unSupportCellValueType = [CellValueType.DateTime, CellValueType.Boolean]; +const unSupportCellValueType = [CellValueType.Boolean]; + +type ISearchIndexSpec = + | { + kind: 'btree'; + expression: string; + } + | { + kind: 'trgm'; + expression: string; + }; export class FieldFormatter { static getSearchableExpression(field: IFieldInstance, isArray = false): string | null { @@ -29,8 +38,8 @@ export class FieldFormatter { return `ROUND(value::numeric, ${precision})::text`; } case CellValueType.DateTime: { - const timeZone = (options as IDateFieldOptions).formatting.timeZone.replace(/'/g, "''"); - return `TO_CHAR(TIMEZONE('${timeZone}', value), 'YYYY-MM-DD HH24:MI')`; + // date type not support full text search + return null; } case CellValueType.Boolean: { // date type not support full text search @@ -67,11 +76,27 @@ export class FieldFormatter { } // expression for generating index - static getIndexExpression(field: IFieldInstance): string | null { + static getIndexSpec(field: IFieldInstance): ISearchIndexSpec | null { if (field.cellValueType === CellValueType.DateTime) { + if (field.isMultipleCellValue) { + return null; + } + + return { + kind: 'btree', + expression: `"${field.dbFieldName}"`, + }; + } + + const expression = this.getSearchableExpression(field, field.isMultipleCellValue); + if (!expression) { return null; } - return this.getSearchableExpression(field, field.isMultipleCellValue); + + return { + kind: 'trgm', + expression, + }; } } @@ -112,12 +137,16 @@ export class IndexBuilderPostgres extends IndexBuilderAbstract { createSingleIndexSql(dbTableName: string, field: IFieldInstance): string | null { const [schema, table] = dbTableName.split('.'); const indexName = this.getIndexName(table, field); - const expression = FieldFormatter.getIndexExpression(field); - if (expression === null) { + const indexSpec = FieldFormatter.getIndexSpec(field); + if (indexSpec === null) { return null; } - return `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${schema}"."${table}" USING gin ((${expression}) gin_trgm_ops)`; + if (indexSpec.kind === 'btree') { + return `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${schema}"."${table}" USING btree (${indexSpec.expression})`; + } + + return `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${schema}"."${table}" USING gin ((${indexSpec.expression}) gin_trgm_ops)`; } getDropIndexSql(dbTableName: string): string { @@ -145,8 +174,7 @@ export class IndexBuilderPostgres extends IndexBuilderAbstract { const fieldSql = searchFields .filter(({ cellValueType }) => !unSupportCellValueType.includes(cellValueType)) .map((field) => { - const expression = FieldFormatter.getIndexExpression(field); - return expression ? this.createSingleIndexSql(dbTableName, field) : null; + return this.createSingleIndexSql(dbTableName, field); }) .filter((sql): sql is string => sql !== null); @@ -202,9 +230,8 @@ export class IndexBuilderPostgres extends IndexBuilderAbstract { const [, table] = dbTableName.split('.'); const expectExistIndex = fields .filter(({ cellValueType }) => !unSupportCellValueType.includes(cellValueType)) - .map((field) => { - return this.getIndexName(table, field); - }); + .filter((field) => this.createSingleIndexSql(dbTableName, field) !== null) + .map((field) => this.getIndexName(table, field)); // 1: find the lack or redundant index const lackingIndex = expectExistIndex.filter( @@ -223,11 +250,16 @@ export class IndexBuilderPostgres extends IndexBuilderAbstract { // 2: find the abnormal index definition const expectIndexDef = fields .filter(({ cellValueType }) => !unSupportCellValueType.includes(cellValueType)) - .map((f) => { - return { - indexName: this.getIndexName(table, f), - indexDef: this.createSingleIndexSql(dbTableName, f) as string, - }; + .flatMap((f) => { + const indexDef = this.createSingleIndexSql(dbTableName, f); + return indexDef + ? [ + { + indexName: this.getIndexName(table, f), + indexDef, + }, + ] + : []; }); return expectIndexDef diff --git a/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.spec.ts b/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.spec.ts new file mode 100644 index 0000000000..069a3cf712 --- /dev/null +++ b/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.spec.ts @@ -0,0 +1,55 @@ +import { CellValueType, DateFormattingPreset, FieldType, TimeFormatting } from '@teable/core'; +import { TableIndex } from '@teable/openapi'; +import knex from 'knex'; +import { describe, expect, it } from 'vitest'; +import type { IFieldInstance } from '../../features/field/model/factory'; +import { SearchQueryPostgres } from './search-query.postgres'; + +const buildDateField = (): IFieldInstance => + ({ + id: 'fldDateSearch00001', + dbFieldName: 'Due_Date', + cellValueType: CellValueType.DateTime, + isMultipleCellValue: false, + isStructuredCellValue: false, + type: FieldType.Date, + options: { + formatting: { + date: DateFormattingPreset.ISO, + time: TimeFormatting.None, + timeZone: 'Asia/Shanghai', + }, + }, + }) as IFieldInstance; + +describe('SearchQueryPostgres', () => { + const db = knex({ client: 'pg' }); + + it('uses a datetime range for date-like search values when search index is enabled', () => { + const field = buildDateField(); + const builder = new SearchQueryPostgres( + db.queryBuilder(), + field, + ['2022-03-02', '', true], + [TableIndex.search] + ); + + const compiled = builder.getQuery()?.toSQL(); + expect(compiled?.sql).toContain('"Due_Date" >= ?::timestamptz AND "Due_Date" < ?::timestamptz'); + expect(compiled?.sql).not.toContain('TO_CHAR'); + expect(compiled?.bindings).toEqual(['2022-03-01T16:00:00.000Z', '2022-03-02T16:00:00.000Z']); + }); + + it('skips date-field scans for non-date-like search values', () => { + const field = buildDateField(); + const builder = new SearchQueryPostgres( + db.queryBuilder(), + field, + ['not-a-date', '', true], + [TableIndex.search] + ); + + const compiled = builder.getQuery()?.toSQL(); + expect(compiled?.sql).toBe('FALSE'); + }); +}); diff --git a/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.ts b/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.ts index aba34644a7..53283be572 100644 --- a/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.ts +++ b/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.ts @@ -9,6 +9,7 @@ import type { IRecordQueryFilterContext } from '../../features/record/query-buil import { escapePostgresRegex } from '../../utils/postgres-regex-escape'; import { escapeLikeWildcards } from '../../utils/sql-like-escape'; import { SearchQueryAbstract } from './abstract'; +import { getDateSearchRange } from './date-search-range.util'; import { FieldFormatter } from './search-index-builder.postgres'; import type { ISearchCellValueType } from './types'; @@ -53,6 +54,9 @@ export class SearchQueryPostgres extends SearchQueryAbstract { const { isMultipleCellValue } = field; const isSearchAllFields = !search[1]; if (isSearchAllFields) { + if (field.cellValueType === CellValueType.DateTime) { + return isMultipleCellValue ? this.multipleDate() : this.date(); + } const searchValue = search[0]; const escapedSearchValue = escapeLikeWildcards(searchValue); const expression = FieldFormatter.getSearchableExpression(field, isMultipleCellValue); @@ -136,17 +140,15 @@ export class SearchQueryPostgres extends SearchQueryAbstract { } protected date() { - const { - search, - knex, - field: { options }, - } = this; - const searchValue = search[0]; - const escapedSearchValue = escapeLikeWildcards(searchValue); - const timeZone = (options as IDateFieldOptions).formatting.timeZone; + const { search, knex } = this; + const range = getDateSearchRange(search[0], this.field.options as IDateFieldOptions); + if (!range) { + return knex.raw('FALSE'); + } + return knex.raw( - `TO_CHAR(TIMEZONE(?, ${this.fieldName}), 'YYYY-MM-DD HH24:MI') ILIKE ? ESCAPE '\\'`, - [timeZone, `%${escapedSearchValue}%`] + `(${this.fieldName} >= ?::timestamptz AND ${this.fieldName} < ?::timestamptz)`, + [range.start, range.end] ); } @@ -199,20 +201,24 @@ export class SearchQueryPostgres extends SearchQueryAbstract { protected multipleDate() { const { search, knex } = this; - const searchValue = search[0]; - const escapedSearchValue = escapeLikeWildcards(searchValue); - const timeZone = (this.field.options as IDateFieldOptions).formatting.timeZone; + const range = getDateSearchRange(search[0], this.field.options as IDateFieldOptions); + if (!range) { + return knex.raw('FALSE'); + } + return knex.raw( ` EXISTS ( SELECT 1 FROM ( - SELECT string_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), 'YYYY-MM-DD HH24:MI'), ', ') as aggregated + SELECT 1 FROM jsonb_array_elements_text(${this.fieldName}::jsonb) as elem + WHERE CAST(elem AS timestamp with time zone) >= ?::timestamptz + AND CAST(elem AS timestamp with time zone) < ?::timestamptz + LIMIT 1 ) as sub - WHERE sub.aggregated ILIKE ? ESCAPE '\\' ) `, - [timeZone, `%${escapedSearchValue}%`] + [range.start, range.end] ); } @@ -298,8 +304,11 @@ export class SearchQueryPostgresBuilder { return conditions .filter(({ field }) => { - // global search does not support checkbox - if (isSearchAllFields && field.cellValueType === CellValueType.Boolean) { + // global search does not support date time and checkbox + if ( + isSearchAllFields && + [CellValueType.DateTime, CellValueType.Boolean].includes(field.cellValueType) + ) { return false; } return true; diff --git a/apps/nestjs-backend/src/features/ai/ai.service.spec.ts b/apps/nestjs-backend/src/features/ai/ai.service.spec.ts new file mode 100644 index 0000000000..3c0f7e8b6f --- /dev/null +++ b/apps/nestjs-backend/src/features/ai/ai.service.spec.ts @@ -0,0 +1,63 @@ +import { LLMProviderType } from '@teable/openapi'; +import { describe, expect, it } from 'vitest'; +import { AiService } from './ai.service'; + +const openAIProviderName = 'custom-openai'; +const openRouterProviderName = 'custom-openrouter'; +const gptImage2Model = 'gpt-image-2'; +const openRouterModel = `openai/${gptImage2Model}`; +const imageGenerationTag = 'image-generation'; + +describe('AiService.getModelTags', () => { + const service = Object.create(AiService.prototype) as AiService; + + it('does not infer tags for direct OpenAI GPT image models without explicit config', async () => { + const tags = await service.getModelTags( + `${LLMProviderType.OPENAI}@${gptImage2Model}@${openAIProviderName}`, + [ + { + type: LLMProviderType.OPENAI, + name: openAIProviderName, + models: gptImage2Model, + }, + ] + ); + + expect(tags).toEqual([]); + }); + + it('returns explicit direct OpenAI GPT image tags without inference', async () => { + const tags = await service.getModelTags( + `${LLMProviderType.OPENAI}@${gptImage2Model}@${openAIProviderName}`, + [ + { + type: LLMProviderType.OPENAI, + name: openAIProviderName, + models: gptImage2Model, + modelConfigs: { + [gptImage2Model]: { + tags: [imageGenerationTag], + }, + }, + }, + ] + ); + + expect(tags).toEqual([imageGenerationTag]); + }); + + it('does not infer tags for OpenRouter models without explicit config', async () => { + const tags = await service.getModelTags( + `${LLMProviderType.OPENROUTER}@${openRouterModel}@${openRouterProviderName}`, + [ + { + type: LLMProviderType.OPENROUTER, + name: openRouterProviderName, + models: openRouterModel, + }, + ] + ); + + expect(tags).toEqual([]); + }); +}); diff --git a/apps/nestjs-backend/src/features/ai/ai.service.ts b/apps/nestjs-backend/src/features/ai/ai.service.ts index 016db3ae36..89a84d0fad 100644 --- a/apps/nestjs-backend/src/features/ai/ai.service.ts +++ b/apps/nestjs-backend/src/features/ai/ai.service.ts @@ -10,6 +10,7 @@ import { Task, convertGatewayApiModel, normalizeGatewayPricing, + supportsImageInputForImageGeneration, } from '@teable/openapi'; import type { IAIConfig, @@ -558,18 +559,8 @@ export class AiService { if (type === LLMProviderType.AI_GATEWAY) { try { const gatewayModel = await this.getGatewayModelConfig(model); - if (gatewayModel?.tags?.length) { - const tags = [...gatewayModel.tags]; - // Patch: Google models with image-generation capability also support vision (image-to-image) - // This is because Gemini image models can accept images as input for image generation - if ( - model.startsWith('google/') && - tags.includes('image-generation') && - !tags.includes('vision') - ) { - tags.push('vision'); - } - return tags; + if (gatewayModel) { + return this.addImageInputTagForImageGeneration(model, gatewayModel.tags ?? []); } } catch (error) { this.logger.warn(`[getModelTags] Failed to get gateway config for ${model}: ${error}`); @@ -594,6 +585,19 @@ export class AiService { return []; } + private addImageInputTagForImageGeneration( + modelId: string, + tags: readonly GatewayModelTag[] + ): GatewayModelTag[] { + const nextTags = [...tags]; + // Some image generation models accept image inputs but Gateway may only report + // image-generation. Add vision so AI fields forward attachment source images. + if (supportsImageInputForImageGeneration(modelId, nextTags) && !nextTags.includes('vision')) { + nextTags.push('vision'); + } + return nextTags; + } + /** * Convert deprecated IChatModelAbility to GatewayModelTag[] * Used for backward compatibility with old ability format diff --git a/apps/nestjs-backend/src/features/attachments/attachments-crop.processor.ts b/apps/nestjs-backend/src/features/attachments/attachments-crop.processor.ts index 0783666b7b..0b93b38a0c 100644 --- a/apps/nestjs-backend/src/features/attachments/attachments-crop.processor.ts +++ b/apps/nestjs-backend/src/features/attachments/attachments-crop.processor.ts @@ -5,6 +5,7 @@ import { isImage, isPdf } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { Queue } from 'bullmq'; import type { Job } from 'bullmq'; +import sharp from 'sharp'; import { EventEmitterService } from '../../event-emitter/event-emitter.service'; import { Events } from '../../event-emitter/events'; import { AttachmentsStorageService } from '../attachments/attachments-storage.service'; @@ -59,7 +60,11 @@ export class AttachmentsCropQueueProcessor extends WorkerHost { where: { token }, select: { thumbnailPath: true }, }); - if (existing?.thumbnailPath) { + if (!existing) { + this.logger.log(`Attachment with token(${token}) does not exist.`); + return; + } + if (existing.thumbnailPath) { this.logger.log(`path(${path}) already has thumbnail`); return; } @@ -83,6 +88,12 @@ export class AttachmentsCropQueueProcessor extends WorkerHost { const pdfBuffer = Buffer.concat(chunks); const { buffer, height: imgHeight } = await renderPdfFirstPageAsImage(pdfBuffer); + const isBlank = await this.isBlankImage(buffer); + if (isBlank) { + this.logger.warn(`PDF thumbnail for ${path} is blank, skipping storage`); + return; + } + ({ lgThumbnailPath, smThumbnailPath } = await this.attachmentsStorageService.uploadTableImageThumbnailsFromBuffer( bucket, @@ -91,7 +102,6 @@ export class AttachmentsCropQueueProcessor extends WorkerHost { imgHeight )); } catch (error) { - console.error(`Failed to render PDF thumbnail for ${path}`, error); this.logger.error(`PDF thumbnail failed for ${path}`, error); // Non-fatal: frontend falls back to PDF icon return; @@ -109,4 +119,9 @@ export class AttachmentsCropQueueProcessor extends WorkerHost { }); this.logger.log(`path(${path}) crop thumbnails success`); } + + private async isBlankImage(pngBuffer: Buffer): Promise { + const { channels } = await sharp(pngBuffer).stats(); + return channels.slice(0, 3).every((ch) => ch.min >= 250); + } } diff --git a/apps/nestjs-backend/src/features/auth/guard/base-node-permission.guard.ts b/apps/nestjs-backend/src/features/auth/guard/base-node-permission.guard.ts index 5af107d066..e43d519cf6 100644 --- a/apps/nestjs-backend/src/features/auth/guard/base-node-permission.guard.ts +++ b/apps/nestjs-backend/src/features/auth/guard/base-node-permission.guard.ts @@ -1,7 +1,7 @@ import type { ExecutionContext } from '@nestjs/common'; import { Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { HttpErrorCode } from '@teable/core'; +import { HttpErrorCode, IdPrefix, identify } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { BaseNodeResourceType } from '@teable/openapi'; import { ClsService } from 'nestjs-cls'; @@ -53,6 +53,7 @@ export class BaseNodePermissionGuard extends PermissionGuard { }, }); } + await this.resolveRequestNodeIds(context, baseId); const permissionContext = await this.getPermissionContext(); return this.checkActivate(context, baseId, permissionContext); } @@ -147,6 +148,30 @@ export class BaseNodePermissionGuard extends PermissionGuard { } } + async resolveRequestNodeIds(context: ExecutionContext, baseId: string) { + const req = context.switchToHttp().getRequest(); + const needsResolve = (id?: string): id is string => + typeof id === 'string' && identify(id) !== IdPrefix.BaseNode; + + const { nodeId } = req.params; + const { parentId, anchorId } = req.body ?? {}; + + const resourceIds = [nodeId, parentId, anchorId].filter(needsResolve); + if (!resourceIds.length) { + return; + } + + const nodes = await this.prismaService.baseNode.findMany({ + where: { baseId, resourceId: { in: [...new Set(resourceIds)] } }, + select: { id: true, resourceId: true }, + }); + const resolved = new Map(nodes.map((n) => [n.resourceId, n.id])); + + if (resolved.has(nodeId)) req.params.nodeId = resolved.get(nodeId)!; + if (resolved.has(parentId)) req.body.parentId = resolved.get(parentId)!; + if (resolved.has(anchorId)) req.body.anchorId = resolved.get(anchorId)!; + } + private async getPermissionContext() { const permissions = this.clsInner.get('permissions'); const permissionSet = new Set(permissions); diff --git a/apps/nestjs-backend/src/features/base-node/base-node.service.spec.ts b/apps/nestjs-backend/src/features/base-node/base-node.service.spec.ts index 9bbb40858b..3aaaa70932 100644 --- a/apps/nestjs-backend/src/features/base-node/base-node.service.spec.ts +++ b/apps/nestjs-backend/src/features/base-node/base-node.service.spec.ts @@ -240,4 +240,43 @@ describe('BaseNodeService', () => { }); }); }); + + describe('getCreateTableV2Decision', () => { + it('uses the base v2 marker when deciding table creation routing', async () => { + const canaryService = { + shouldUseV2ForBaseWithReason: vi + .fn() + .mockResolvedValue({ useV2: true, reason: 'new_base' }), + }; + const prismaService = { + txClient: vi.fn(() => ({ + base: { + findUnique: vi.fn().mockResolvedValue({ spaceId: 'spc1', v2Enabled: true }), + }, + })), + }; + const routingService = new BaseNodeService( + {} as never, + {} as never, + prismaService as never, + {} as never, + {} as never, + { get: vi.fn(), set: vi.fn() } as never, + {} as never, + canaryService as never, + {} as never, + {} as never, + {} as never, + {} as never + ); + + const decision = await routingService.getCreateTableV2Decision(baseId); + + expect(canaryService.shouldUseV2ForBaseWithReason).toHaveBeenCalledWith( + { spaceId: 'spc1', v2Enabled: true }, + 'createTable' + ); + expect(decision).toEqual({ useV2: true, reason: 'new_base' }); + }); + }); }); diff --git a/apps/nestjs-backend/src/features/base-node/base-node.service.ts b/apps/nestjs-backend/src/features/base-node/base-node.service.ts index 551c9c5ba0..1f533440f8 100644 --- a/apps/nestjs-backend/src/features/base-node/base-node.service.ts +++ b/apps/nestjs-backend/src/features/base-node/base-node.service.ts @@ -141,14 +141,10 @@ export class BaseNodeService { const base = await this.prismaService.txClient().base.findUnique({ where: { id: baseId, deletedTime: null }, - select: { spaceId: true }, + select: { spaceId: true, v2Enabled: true }, }); - if (!base?.spaceId) { - return { useV2: false, reason: 'disabled' }; - } - - return this.canaryService.shouldUseV2WithReason(base.spaceId, feature); + return this.canaryService.shouldUseV2ForBaseWithReason(base, feature); } async getDeleteTableV2Decision(baseId: string, nodeId: string): Promise { @@ -158,14 +154,10 @@ export class BaseNodeService { async getCreateTableV2Decision(baseId: string): Promise { const base = await this.prismaService.txClient().base.findUnique({ where: { id: baseId, deletedTime: null }, - select: { spaceId: true }, + select: { spaceId: true, v2Enabled: true }, }); - if (!base?.spaceId) { - return { useV2: false, reason: 'disabled' }; - } - - return this.canaryService.shouldUseV2WithReason(base.spaceId, 'createTable'); + return this.canaryService.shouldUseV2ForBaseWithReason(base, 'createTable'); } private generateDefaultUrl( @@ -969,22 +961,8 @@ export class BaseNodeService { ); } - if (node.resourceType === BaseNodeResourceType.Folder && parentId) { - await this.assertFolderDepth(baseId, parentId); - } - - // Check for circular reference - const isCircular = await this.isCircularReference(baseId, nodeId, parentId); - if (isCircular) { - throw new CustomHttpException( - 'Cannot move node to its own child (circular reference)', - HttpErrorCode.VALIDATION_ERROR, - { - localization: { - i18nKey: 'httpErrors.baseNode.circularReference', - }, - } - ); + if (node.resourceType === BaseNodeResourceType.Folder) { + await this.assertFolderMoveDepth(baseId, parentId, nodeId); } const maxOrder = await this.getMaxOrder(baseId); @@ -1032,7 +1010,7 @@ export class BaseNodeService { }); if (node.resourceType === BaseNodeResourceType.Folder && anchor.parentId) { - await this.assertFolderDepth(baseId, anchor.parentId); + await this.assertFolderMoveDepth(baseId, anchor.parentId, nodeId); } await updateOrder({ @@ -1125,33 +1103,45 @@ export class BaseNodeService { } } - private async getFolderDepth(baseId: string, id: string) { - const prisma = this.prismaService.txClient(); - const allFolders = await prisma.baseNode.findMany({ + private async assertFolderMoveDepth(baseId: string, parentId: string, nodeId: string) { + const allFolders = await this.getAllFolders(baseId); + + const parentDepth = this.getFolderDepthFromList(allFolders, parentId, nodeId); + const subtreeDepth = this.getFolderSubtreeDepth(allFolders, nodeId); + + if (parentDepth + subtreeDepth >= this.maxFolderDepth) { + throw new CustomHttpException('Folder depth limit exceeded', HttpErrorCode.VALIDATION_ERROR, { + localization: { + i18nKey: 'httpErrors.baseNode.folderDepthLimitExceeded', + }, + }); + } + } + + private async getAllFolders(baseId: string) { + return this.prismaService.txClient().baseNode.findMany({ where: { baseId, resourceType: BaseNodeResourceType.Folder }, select: { id: true, parentId: true }, }); + } + private getFolderDepthFromList( + allFolders: { id: string; parentId: string | null }[], + id: string, + circularCheckNodeId?: string + ) { let depth = 0; if (allFolders.length === 0) { return depth; } const folderMap = keyBy(allFolders, 'id'); + const visited = new Set(); let current = id; while (current) { - depth++; - const folder = folderMap[current]; - if (!folder) { - throw new CustomHttpException('Folder not found', HttpErrorCode.NOT_FOUND, { - localization: { - i18nKey: 'httpErrors.baseNode.folderNotFound', - }, - }); - } - if (folder.parentId === id) { + if ((circularCheckNodeId && current === circularCheckNodeId) || visited.has(current)) { throw new CustomHttpException( - 'A folder cannot be its own parent', + 'Circular reference detected in folder hierarchy', HttpErrorCode.VALIDATION_ERROR, { localization: { @@ -1160,52 +1150,42 @@ export class BaseNodeService { } ); } + visited.add(current); + depth++; + const folder = folderMap[current]; + if (!folder) { + throw new CustomHttpException('Folder not found', HttpErrorCode.NOT_FOUND, { + localization: { + i18nKey: 'httpErrors.baseNode.folderNotFound', + }, + }); + } current = folder.parentId ?? ''; } return depth; } - private async isCircularReference( - baseId: string, - nodeId: string, - parentId: string - ): Promise { - const knex = this.knex; - - // Non-recursive query: Start with the parent node - const nonRecursiveQuery = knex - .select('id', 'parent_id', 'base_id') - .from('base_node') - .where('id', parentId) - .andWhere('base_id', baseId); - - // Recursive query: Traverse up the parent chain - const recursiveQuery = knex - .select('bn.id', 'bn.parent_id', 'bn.base_id') - .from('base_node as bn') - .innerJoin('ancestors as a', function () { - // Join condition: bn.id = a.parent_id (get parent of current ancestor) - this.on('bn.id', '=', 'a.parent_id').andOn('bn.base_id', '=', knex.raw('?', [baseId])); - }); - - // Combine non-recursive and recursive queries - const cteQuery = nonRecursiveQuery.union(recursiveQuery); - - // Build final query with recursive CTE - const finalQuery = knex - .withRecursive('ancestors', ['id', 'parent_id', 'base_id'], cteQuery) - .select('id') - .from('ancestors') - .where('id', nodeId) - .limit(1) - .toQuery(); - - // Execute query - const result = await this.prismaService - .txClient() - .$queryRawUnsafe>(finalQuery); + private getFolderSubtreeDepth( + allFolders: { id: string; parentId: string | null }[], + nodeId: string + ) { + const childrenMap: Record = {}; + for (const folder of allFolders) { + if (folder.parentId) { + (childrenMap[folder.parentId] ??= []).push(folder.id); + } + } + const calc = (id: string): number => { + const children = childrenMap[id]; + if (!children?.length) return 0; + return 1 + Math.max(...children.map(calc)); + }; + return calc(nodeId); + } - return result.length > 0; + private async getFolderDepth(baseId: string, id: string) { + const allFolders = await this.getAllFolders(baseId); + return this.getFolderDepthFromList(allFolders, id); } async batchUpdateBaseNodes(data: { id: string; values: { [key: string]: unknown } }[]) { diff --git a/apps/nestjs-backend/src/features/base-sql-executor/utils.spec.ts b/apps/nestjs-backend/src/features/base-sql-executor/utils.spec.ts index 2458f4ad13..dc37594fc4 100644 --- a/apps/nestjs-backend/src/features/base-sql-executor/utils.spec.ts +++ b/apps/nestjs-backend/src/features/base-sql-executor/utils.spec.ts @@ -1,5 +1,6 @@ /* eslint-disable sonarjs/no-duplicate-string */ import { DriverClient } from '@teable/core'; +import { CustomHttpException } from '../../custom.exception'; import { validateRoleOperations, checkTableAccess } from './utils'; describe('base sql executor utils', () => { @@ -116,6 +117,16 @@ describe('base sql executor utils', () => { ); }); + it('should throw CustomHttpException for SQL syntax errors instead of SyntaxError', () => { + const invalidSql = 'SELEC * FORM users'; + expect(() => + checkTableAccess(invalidSql, { + tableNames: ['users'], + database: DriverClient.Pg, + }) + ).toThrow(CustomHttpException); + }); + it('correctly-split "schema"."table" form passes the whitelist', () => { const sql = 'SELECT count(*) FROM "bseXXX"."tblYYY"'; expect(() => diff --git a/apps/nestjs-backend/src/features/base-sql-executor/utils.ts b/apps/nestjs-backend/src/features/base-sql-executor/utils.ts index b0999ca713..5266615464 100644 --- a/apps/nestjs-backend/src/features/base-sql-executor/utils.ts +++ b/apps/nestjs-backend/src/features/base-sql-executor/utils.ts @@ -64,52 +64,60 @@ export const checkTableAccess = ( const opt = { database: databaseTypeMap[database], }; - const { ast } = parser.parse(sql, opt); - const withNames = Array.isArray(ast) ? ast.map(collectWithNames).flat() : collectWithNames(ast); - const allWithNames = new Set([...withNames, ...tableNames]); - const whiteColumnList = Array.from(allWithNames).map((table) => { + const { ast } = (() => { + try { + return parser.parse(sql, opt); + } catch { + throw new CustomHttpException( + 'SQL syntax error, please check your query', + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: 'httpErrors.baseSqlExecutor.sqlSyntaxError', + }, + } + ); + } + })(); + const withNames = Array.isArray(ast) ? ast.flatMap(collectWithNames) : collectWithNames(ast); + const allowedTables = new Set([...withNames, ...tableNames]); + const whiteColumnList = Array.from(allowedTables).map((table) => { const [schema, tableName] = table.includes('.') ? table.split('.') : [null, table]; return `select::${schema}::${tableName}`; }); + let whiteListError: Error | undefined; try { - const error = parser.whiteListCheck(sql, whiteColumnList, opt); - if (error) { - throw error; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - const sqlTableList = parser.tableList(sql, opt); - const invalidEntries = sqlTableList.filter((t: string) => !whiteColumnList.includes(t)); - const invalidTableNames = invalidEntries.map((t: string) => { - const parts = t.split('::'); - return parts[parts.length - 1]; - }); + whiteListError = parser.whiteListCheck(sql, whiteColumnList, opt); + if (!whiteListError) return; + } catch (e) { + whiteListError = e as Error; + } + + const sqlTableList = parser.tableList(sql, opt); + const invalidTableNames = sqlTableList + .filter((t: string) => !whiteColumnList.includes(t)) + .map((t: string) => t.split('::').pop()!); - let message: string; - if (invalidTableNames.length > 0) { - const invalidList = invalidTableNames.map((n: string) => `'${n}'`).join(', '); - message = - `Table ${invalidList} not found. ` + + const message = + invalidTableNames.length > 0 + ? `Table ${invalidTableNames.map((n: string) => `'${n}'`).join(', ')} not found. ` + `dbTableName from get-tables-meta is already \`schema.table\` (e.g. \`bseXXX.tblYYY\`); ` + - `use it in SQL as \`FROM "bseXXX"."tblYYY"\`.`; - } else { - message = error?.message as string; - } + `use it in SQL as \`FROM "bseXXX"."tblYYY"\`.` + : String(whiteListError?.message ?? whiteListError); - throw new CustomHttpException( - `An error occurred while checking table access: ${message}`, - HttpErrorCode.VALIDATION_ERROR, - { - localization: { - i18nKey: 'httpErrors.baseSqlExecutor.whiteListCheckError', - context: { - message, - }, + throw new CustomHttpException( + `An error occurred while checking table access: ${message}`, + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: 'httpErrors.baseSqlExecutor.whiteListCheckError', + context: { + message, }, - } - ); - } + }, + } + ); }; export const getTableNames = (sql: string) => { diff --git a/apps/nestjs-backend/src/features/base/base-export.service.ts b/apps/nestjs-backend/src/features/base/base-export.service.ts index 11ad3730af..be038b1125 100644 --- a/apps/nestjs-backend/src/features/base/base-export.service.ts +++ b/apps/nestjs-backend/src/features/base/base-export.service.ts @@ -28,6 +28,7 @@ import { EventEmitterService } from '../../event-emitter/event-emitter.service'; import { Events } from '../../event-emitter/events'; import type { IClsStore } from '../../types/cls'; import type { I18nPath } from '../../types/i18n.generated'; +import { resolveBuildVersion } from '../../utils/build-version'; import { second } from '../../utils/second'; import StorageAdapter from '../attachments/plugins/adapter'; import { InjectStorageAdapter } from '../attachments/plugins/storage'; @@ -460,7 +461,7 @@ export class BaseExportService { id: baseId, name: baseName, icon: baseIcon, - version: process.env.NEXT_PUBLIC_BUILD_VERSION!, + version: resolveBuildVersion(), tables, plugins, folders, diff --git a/apps/nestjs-backend/src/features/base/base-import.service.ts b/apps/nestjs-backend/src/features/base/base-import.service.ts index 2601cf993a..f6cc7207e8 100644 --- a/apps/nestjs-backend/src/features/base/base-import.service.ts +++ b/apps/nestjs-backend/src/features/base/base-import.service.ts @@ -87,6 +87,7 @@ export class BaseImportService { spaceId, order, icon, + v2Enabled: true, createdBy: userId, }, select: { diff --git a/apps/nestjs-backend/src/features/base/base.service.spec.ts b/apps/nestjs-backend/src/features/base/base.service.spec.ts index 58d97dcce2..bf82bc9cb9 100644 --- a/apps/nestjs-backend/src/features/base/base.service.spec.ts +++ b/apps/nestjs-backend/src/features/base/base.service.spec.ts @@ -1,5 +1,7 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; +import { Role } from '@teable/core'; +import { CollaboratorType } from '@teable/openapi'; import { GlobalModule } from '../../global/global.module'; import { BaseModule } from './base.module'; import { BaseService } from './base.service'; @@ -18,4 +20,127 @@ describe('BaseService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('getBaseById', () => { + const createService = (params: { + base: { + id: string; + name: string; + icon: string | null; + spaceId: string; + v2Enabled: boolean; + createdBy: string; + }; + decision: { useV2: boolean; reason: 'new_base' | 'space_feature' | 'feature_not_enabled' }; + }) => { + const prismaService = { + base: { + findFirstOrThrow: vi.fn().mockResolvedValue(params.base), + }, + }; + const cls = { + get: vi.fn((key: string) => { + if (key === 'template') { + return { id: 'tpl1', baseId: params.base.id }; + } + return undefined; + }), + }; + const permissionService = { + generateTemplateHeader: vi.fn().mockReturnValue('template-header'), + }; + const canaryService = { + shouldUseV2ForBaseWithReason: vi.fn().mockResolvedValue(params.decision), + isSpaceInCanary: vi.fn().mockResolvedValue(params.decision.reason === 'space_feature'), + }; + + return { + service: new BaseService( + prismaService as never, + cls as never, + {} as never, + {} as never, + permissionService as never, + {} as never, + {} as never, + {} as never, + canaryService as never, + {} as never, + {} as never + ), + canaryService, + }; + }; + + it('returns the unified v2 status for new bases without exposing v2Enabled', async () => { + const base = { + id: 'bse1', + name: 'Base', + icon: null, + spaceId: 'spc1', + v2Enabled: true, + createdBy: 'usr1', + }; + const { service, canaryService } = createService({ + base, + decision: { useV2: true, reason: 'new_base' }, + }); + + const result = await service.getBaseById(base.id); + + expect(canaryService.shouldUseV2ForBaseWithReason).toHaveBeenCalledWith( + expect.objectContaining({ spaceId: 'spc1', v2Enabled: true }), + 'getRecords' + ); + expect(canaryService.isSpaceInCanary).toHaveBeenCalledWith('spc1'); + expect(result).toMatchObject({ + id: base.id, + role: Role.Viewer, + collaboratorType: CollaboratorType.Base, + v2Status: { useV2: true, reason: 'new_base' }, + }); + expect(result.isCanary).toBeUndefined(); + expect(result).not.toHaveProperty('v2Enabled'); + }); + + it('returns a v1 decision reason when the unified decision disables v2', async () => { + const base = { + id: 'bse1', + name: 'Base', + icon: null, + spaceId: 'spc1', + v2Enabled: false, + createdBy: 'usr1', + }; + const { service } = createService({ + base, + decision: { useV2: false, reason: 'feature_not_enabled' }, + }); + + const result = await service.getBaseById(base.id); + + expect(result.isCanary).toBeUndefined(); + expect(result.v2Status).toEqual({ useV2: false, reason: 'feature_not_enabled' }); + }); + + it('keeps the legacy isCanary flag for canary rollout decisions', async () => { + const base = { + id: 'bse1', + name: 'Base', + icon: null, + spaceId: 'spc1', + v2Enabled: false, + createdBy: 'usr1', + }; + const { service } = createService({ + base, + decision: { useV2: true, reason: 'space_feature' }, + }); + + const result = await service.getBaseById(base.id); + + expect(result.isCanary).toBe(true); + expect(result.v2Status).toEqual({ useV2: true, reason: 'space_feature' }); + }); + }); }); diff --git a/apps/nestjs-backend/src/features/base/base.service.ts b/apps/nestjs-backend/src/features/base/base.service.ts index 22fef1b101..0dc93325b6 100644 --- a/apps/nestjs-backend/src/features/base/base.service.ts +++ b/apps/nestjs-backend/src/features/base/base.service.ts @@ -103,6 +103,7 @@ export class BaseService { name: true, icon: true, spaceId: true, + v2Enabled: true, createdBy: true, }, where: { @@ -124,18 +125,25 @@ export class BaseService { ? { role: Role.Viewer, collaboratorType: CollaboratorType.Base } : await this.getRoleByBaseId(baseId, base.spaceId); - // Check if this base's space is in canary release - const isCanary = await this.canaryService.isSpaceInCanary(base.spaceId); + const [v2Status, isCanary] = await Promise.all([ + this.canaryService.shouldUseV2ForBaseWithReason(base, 'getRecords'), + this.canaryService.isSpaceInCanary(base.spaceId), + ]); return { - ...base, + id: base.id, + name: base.name, + icon: base.icon, + spaceId: base.spaceId, + createdBy: base.createdBy, role, collaboratorType, template: template?.baseId === baseId ? { id: template.id, headers: this.permissionService.generateTemplateHeader(template.id) } : undefined, - isCanary: isCanary || undefined, // Only include if true + isCanary: isCanary || undefined, + v2Status, }; } @@ -227,6 +235,7 @@ export class BaseService { spaceId, order, icon, + v2Enabled: true, createdBy: userId, }, select: { diff --git a/apps/nestjs-backend/src/features/canary/canary.service.spec.ts b/apps/nestjs-backend/src/features/canary/canary.service.spec.ts new file mode 100644 index 0000000000..a098e02046 --- /dev/null +++ b/apps/nestjs-backend/src/features/canary/canary.service.spec.ts @@ -0,0 +1,133 @@ +import { SettingKey } from '@teable/openapi'; +import { CanaryService } from './canary.service'; + +describe('CanaryService', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + const createService = (params?: { + canaryHeader?: string; + config?: { enabled: boolean; spaceIds?: string[]; forceV2All?: boolean }; + }) => { + const settingService = { + getSetting: vi.fn().mockResolvedValue({ + [SettingKey.CANARY_CONFIG]: params?.config ?? null, + }), + }; + const cls = { + get: vi.fn((key: string) => (key === 'canaryHeader' ? params?.canaryHeader : undefined)), + }; + + return { + service: new CanaryService(settingService as never, cls as never), + settingService, + cls, + }; + }; + + it('forces v2 for a marked new base before disabled canary, config, or header decisions', async () => { + process.env.ENABLE_CANARY_FEATURE = 'false'; + process.env.FORCE_V2_ALL = 'false'; + const { service, settingService, cls } = createService({ + canaryHeader: 'false', + config: { enabled: false, spaceIds: [], forceV2All: false }, + }); + + const decision = await service.shouldUseV2ForBaseWithReason( + { spaceId: 'spc1', v2Enabled: true }, + 'createRecord' + ); + + expect(decision).toEqual({ useV2: true, reason: 'new_base' }); + expect(settingService.getSetting).not.toHaveBeenCalled(); + expect(cls.get).not.toHaveBeenCalled(); + }); + + it('reports new_base for a marked new base even when force v2 all is enabled', async () => { + process.env.ENABLE_CANARY_FEATURE = 'true'; + process.env.FORCE_V2_ALL = 'true'; + const { service, settingService, cls } = createService({ + canaryHeader: 'false', + config: { enabled: true, spaceIds: ['spc1'], forceV2All: true }, + }); + + const decision = await service.shouldUseV2ForBaseWithReason( + { spaceId: 'spc1', v2Enabled: true }, + 'createRecord' + ); + + expect(decision).toEqual({ useV2: true, reason: 'new_base' }); + expect(settingService.getSetting).not.toHaveBeenCalled(); + expect(cls.get).not.toHaveBeenCalled(); + }); + + it('falls back to rollout decisions for unmarked bases', async () => { + process.env.ENABLE_CANARY_FEATURE = 'true'; + process.env.FORCE_V2_ALL = 'false'; + const { service } = createService({ + config: { enabled: true, spaceIds: ['spc1'], forceV2All: false }, + }); + + const decision = await service.shouldUseV2ForBaseWithReason( + { spaceId: 'spc1', v2Enabled: false }, + 'createRecord' + ); + + expect(decision).toEqual({ useV2: true, reason: 'space_feature' }); + }); + + it('reports env_force_v2_all before rollout config for unmarked bases', async () => { + process.env.ENABLE_CANARY_FEATURE = 'false'; + process.env.FORCE_V2_ALL = 'true'; + const { service } = createService({ + config: { enabled: false, spaceIds: [], forceV2All: false }, + }); + + const decision = await service.shouldUseV2ForBaseWithReason( + { spaceId: 'spc1', v2Enabled: false }, + 'getRecords' + ); + + expect(decision).toEqual({ useV2: true, reason: 'env_force_v2_all' }); + }); + + it('reports config_force_v2_all before request header override for unmarked bases', async () => { + process.env.ENABLE_CANARY_FEATURE = 'true'; + process.env.FORCE_V2_ALL = 'false'; + const { service } = createService({ + canaryHeader: 'false', + config: { enabled: true, spaceIds: [], forceV2All: true }, + }); + + const decision = await service.shouldUseV2ForBaseWithReason( + { spaceId: 'spc1', v2Enabled: false }, + 'getRecords' + ); + + expect(decision).toEqual({ useV2: true, reason: 'config_force_v2_all' }); + }); + + it('reports header_override when request header controls an unmarked base', async () => { + process.env.ENABLE_CANARY_FEATURE = 'true'; + process.env.FORCE_V2_ALL = 'false'; + const { service } = createService({ + canaryHeader: 'true', + config: { enabled: true, spaceIds: [], forceV2All: false }, + }); + + const decision = await service.shouldUseV2ForBaseWithReason( + { spaceId: 'spc1', v2Enabled: false }, + 'getRecords' + ); + + expect(decision).toEqual({ useV2: true, reason: 'header_override' }); + }); +}); diff --git a/apps/nestjs-backend/src/features/canary/canary.service.ts b/apps/nestjs-backend/src/features/canary/canary.service.ts index fdbf9dbd20..9ef17a6a1b 100644 --- a/apps/nestjs-backend/src/features/canary/canary.service.ts +++ b/apps/nestjs-backend/src/features/canary/canary.service.ts @@ -10,6 +10,11 @@ export interface IV2Decision { reason: V2Reason; } +export interface IBaseV2DecisionContext { + spaceId?: string | null; + v2Enabled?: boolean | null; +} + @Injectable() export class CanaryService { constructor( @@ -34,7 +39,7 @@ export class CanaryService { /** * Check if V2 is forced globally via environment variable (FORCE_V2_ALL=true) - * This has the highest priority over all other settings + * This is the fallback priority for bases that are not explicitly marked as new-base V2. */ isForceV2AllEnabled(): boolean { return process.env.FORCE_V2_ALL === 'true'; @@ -86,7 +91,7 @@ export class CanaryService { /** * Determine if V2 implementation should be used for a specific feature * Priority: - * 1. FORCE_V2_ALL env var (highest priority, bypasses all checks) + * 1. FORCE_V2_ALL env var (highest priority for space-only checks) * 2. If canary feature is disabled globally, return false * 3. forceV2All in config (database setting) * 4. x-canary header override @@ -127,12 +132,29 @@ export class CanaryService { return config.spaceIds?.includes(spaceId) ?? false; } + /** + * New bases are V2-first regardless of canary, request headers, or rollout config. + * Unsupported features still do not call this path because they have no @UseV2Feature marker. + */ + async shouldUseV2ForBaseWithReason( + base: IBaseV2DecisionContext | null | undefined, + feature: V2Feature + ): Promise { + if (base?.v2Enabled) { + return { useV2: true, reason: 'new_base' }; + } + + return base?.spaceId + ? this.shouldUseV2WithReason(base.spaceId, feature) + : this.shouldUseV2WithReason('', feature); + } + /** * Determine if V2 implementation should be used for a specific feature, * with detailed reason information. * * Priority: - * 1. FORCE_V2_ALL env var (highest priority, bypasses all checks) + * 1. FORCE_V2_ALL env var (highest priority for space-only checks) * 2. If canary feature is disabled globally, return false * 3. forceV2All in config (database setting) * 4. x-canary header override diff --git a/apps/nestjs-backend/src/features/canary/guards/v2-feature.guard.ts b/apps/nestjs-backend/src/features/canary/guards/v2-feature.guard.ts index 5669ba2b6e..f3fe88b325 100644 --- a/apps/nestjs-backend/src/features/canary/guards/v2-feature.guard.ts +++ b/apps/nestjs-backend/src/features/canary/guards/v2-feature.guard.ts @@ -5,7 +5,7 @@ import { PrismaService } from '@teable/db-main-prisma'; import type { V2Feature } from '@teable/openapi'; import { ClsService } from 'nestjs-cls'; import type { IClsStore } from '../../../types/cls'; -import { CanaryService } from '../canary.service'; +import { CanaryService, type IBaseV2DecisionContext } from '../canary.service'; import { USE_V2_FEATURE_KEY } from '../decorators/use-v2-feature.decorator'; /** @@ -65,26 +65,16 @@ export class V2FeatureGuard implements CanActivate { return true; } - // 2. Check FORCE_V2_ALL first (highest priority) - if (this.canaryService.isForceV2AllEnabled()) { - this.cls.set('useV2', true); - this.cls.set('v2Feature', feature); - this.cls.set('v2Reason', 'env_force_v2_all'); - return true; - } - - // 3. Get spaceId from request context - const spaceId = await this.getSpaceIdFromContext(context); - - if (!spaceId) { + if (this.isUnsupportedV2Payload(req.body, feature)) { this.cls.set('useV2', false); this.cls.set('v2Feature', feature); - this.cls.set('v2Reason', 'disabled'); + this.cls.set('v2Reason', 'unsupported_feature'); return true; } - // 4. Determine if V2 should be used with reason - const decision = await this.canaryService.shouldUseV2WithReason(spaceId, feature); + // 2. Resolve base context when possible. Marked new bases are V2-first and bypass rollout config. + const base = await this.getBaseV2DecisionContext(context); + const decision = await this.canaryService.shouldUseV2ForBaseWithReason(base, feature); this.cls.set('useV2', decision.useV2); this.cls.set('v2Feature', feature); this.cls.set('v2Reason', decision.reason); @@ -92,11 +82,30 @@ export class V2FeatureGuard implements CanActivate { return true; } + private isUnsupportedV2Payload(body: unknown, feature: V2Feature): boolean { + if (feature !== 'updateRecord') { + return false; + } + + const updateRecordBody = body as + | { + record?: { fields?: Record | null } | null; + order?: unknown; + } + | undefined; + const fields = updateRecordBody?.record?.fields ?? {}; + + // V2 does not yet support updateRecord calls that only reorder a record. + return Boolean(updateRecordBody?.order) && Object.keys(fields).length === 0; + } + /** - * Extract spaceId from request context. + * Extract base V2 decision context from request context. * Supports: spaceId (direct), baseId (lookup), tableId (lookup via base) */ - private async getSpaceIdFromContext(context: ExecutionContext): Promise { + private async getBaseV2DecisionContext( + context: ExecutionContext + ): Promise { const req = context.switchToHttp().getRequest(); const resourceId = req.params.spaceId || req.params.baseId || req.params.tableId; @@ -106,16 +115,16 @@ export class V2FeatureGuard implements CanActivate { // Direct spaceId if (resourceId.startsWith(IdPrefix.Space)) { - return resourceId; + return { spaceId: resourceId }; } // BaseId -> lookup spaceId if (resourceId.startsWith(IdPrefix.Base)) { const base = await this.prismaService.txClient().base.findUnique({ where: { id: resourceId, deletedTime: null }, - select: { spaceId: true }, + select: { spaceId: true, v2Enabled: true }, }); - return base?.spaceId; + return base ?? undefined; } // TableId -> lookup baseId -> lookup spaceId @@ -129,9 +138,9 @@ export class V2FeatureGuard implements CanActivate { const base = await this.prismaService.txClient().base.findUnique({ where: { id: table.baseId, deletedTime: null }, - select: { spaceId: true }, + select: { spaceId: true, v2Enabled: true }, }); - return base?.spaceId; + return base ?? undefined; } return undefined; diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts index cf6f63ed64..99e51fe35e 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts @@ -7,10 +7,8 @@ import type { IConvertFieldRo, ILinkFieldOptions, FieldCore, - LinkFieldCore, } from '@teable/core'; import { - CellValueType, ColorUtils, DbFieldType, FIELD_VO_PROPERTIES, @@ -1488,10 +1486,7 @@ export class FieldConvertingService { select: { dbTableName: true, name: true }, }); - // index do not support date cell value type - if (newField.cellValueType !== CellValueType.DateTime) { - await this.tableIndexService.createSearchFieldSingleIndex(tableId, newField); - } + await this.tableIndexService.createSearchFieldSingleIndex(tableId, newField); if (!this.needTempleCloseFieldConstraint(newField, oldField)) { return; @@ -1594,6 +1589,28 @@ export class FieldConvertingService { ); } + // Primary fields must stay as regular (editable) fields. Converting a primary to a + // lookup / conditional-lookup produces a computed primary whose cell value is derived + // from a link, which in turn breaks base duplication (findFirstOrThrow for the foreign + // table's primary can't locate a valid static primary). See T3367. + // lookupOptions is included for symmetry with the createField guard — leaving stray + // lookupOptions on a primary is the same semantic corruption even without isLookup=true. + if ( + oldField.isPrimary && + (updateFieldRo.isLookup || updateFieldRo.isConditionalLookup || updateFieldRo.lookupOptions) + ) { + throw new CustomHttpException( + 'Primary field cannot be configured as a lookup field', + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: 'httpErrors.field.primaryCannotBeLookup', + context: {}, + }, + } + ); + } + const newFieldVo = await this.fieldSupplementService.prepareUpdateField( tableId, updateFieldRo, diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts index c75b7d158f..f4a3382325 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts @@ -29,6 +29,7 @@ import { LastModifiedTimeFieldCore, LongTextFieldCore, NumberFieldCore, + PRIMARY_SUPPORTED_TYPES, RatingFieldCore, Relationship, RelationshipRevert, @@ -1764,10 +1765,70 @@ export class FieldSupplementService { this.validateFormattingShowAs(fieldVo); this.validateAiConfig(fieldVo); + await this.validatePrimaryConfigurations(tableId, [fieldVo]); return fieldVo; } + // Primary fields must be a static, supported type with no lookup configuration, and a table + // can have at most one primary. Bulk paths (table/base/field duplicate, .tea import, AI tools) + // historically passed isPrimary=true on raw field VOs, allowing link/checkbox/attachment/rollup + // primaries to slip in and tables to end up with multiple primaries. See T3367 follow-up. + // Aligns with v2's CreateFieldCommand guard (community/packages/v2/.../CreateFieldCommand.ts:52). + private async validatePrimaryConfigurations(tableId: string, fieldVos: IFieldVo[]) { + const newPrimaries = fieldVos.filter((f) => f.isPrimary); + if (newPrimaries.length === 0) return; + + if (newPrimaries.length > 1) { + throw new CustomHttpException( + 'Cannot create more than one primary field in a single batch', + HttpErrorCode.VALIDATION_ERROR, + { + localization: { i18nKey: 'httpErrors.field.primaryFieldAlreadyExists', context: {} }, + } + ); + } + + for (const fieldVo of newPrimaries) { + if (!PRIMARY_SUPPORTED_TYPES.has(fieldVo.type)) { + throw new CustomHttpException( + `Field type ${fieldVo.type} is not supported as primary field`, + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: 'httpErrors.field.unsupportedPrimaryFieldType', + context: { type: fieldVo.type }, + }, + } + ); + } + + if (fieldVo.isLookup || fieldVo.isConditionalLookup || fieldVo.lookupOptions) { + throw new CustomHttpException( + 'Primary field cannot be configured as a lookup field', + HttpErrorCode.VALIDATION_ERROR, + { + localization: { i18nKey: 'httpErrors.field.primaryCannotBeLookup', context: {} }, + } + ); + } + } + + const existing = await this.prismaService.txClient().field.findFirst({ + where: { tableId, isPrimary: true, deletedTime: null }, + select: { id: true }, + }); + if (existing) { + throw new CustomHttpException( + 'Table already has a primary field', + HttpErrorCode.VALIDATION_ERROR, + { + localization: { i18nKey: 'httpErrors.field.primaryFieldAlreadyExists', context: {} }, + } + ); + } + } + async prepareCreateFields(tableId: string, fieldRos: IFieldRo[], batchFieldVos?: IFieldVo[]) { // throw error when dbFieldName is duplicated const fieldRoDbFieldNames = fieldRos @@ -1807,7 +1868,7 @@ export class FieldSupplementService { const dbFieldNames = await this.fieldService.generateDbFieldNames(tableId, uniqFieldNames); - return fieldRos.map((fieldRo, index) => { + const fieldVos = fieldRos.map((fieldRo, index) => { const field = fields[index]; const fieldId = field.id || generateFieldId(); const fieldName = uniqFieldNames[index]; @@ -1823,6 +1884,8 @@ export class FieldSupplementService { this.validateAiConfig(fieldVo); return fieldVo; }); + await this.validatePrimaryConfigurations(tableId, fieldVos); + return fieldVos; } async prepareUpdateField( diff --git a/apps/nestjs-backend/src/features/integrity/integrity-v2.service.spec.ts b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.spec.ts new file mode 100644 index 0000000000..6165ee5650 --- /dev/null +++ b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.spec.ts @@ -0,0 +1,253 @@ +import * as Sentry from '@sentry/nestjs'; +import type * as V2AdapterTableRepositoryPostgres from '@teable/v2-adapter-table-repository-postgres'; +import type { ITracer, Table } from '@teable/v2-core'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { IntegrityV2Service } from './integrity-v2.service'; + +type IMetaValidationIssue = V2AdapterTableRepositoryPostgres.MetaValidationIssue; +type ISchemaRepairResult = V2AdapterTableRepositoryPostgres.SchemaRepairResult; + +const schemaIntegrityRepairFeatureTag = 'schema-integrity-repair'; +const repairRuleId = 'column:fldIntegrity0001'; +const metaFieldId = 'fldIntegrity0001'; +const metaReferenceRuleId = 'meta:reference'; +const repairFailureSpanName = 'teable.IntegrityV2Service.reportRepairFailure'; +const integrityFailureKindAttribute = 'teable.integrity.failure_kind'; +const integrityRuleIdAttribute = 'teable.integrity.rule_id'; +const integrityScopeAttribute = 'teable.integrity.scope'; +const integrityTargetIdAttribute = 'teable.integrity.target_id'; +const repairStreamCrashedMessage = 'repair stream crashed'; +const createThrowingRepairStream = (message: string): AsyncGenerator => + ({ + async next() { + throw new Error(message); + }, + async return() { + return { + done: true, + value: undefined, + }; + }, + async throw(error) { + throw error; + }, + [Symbol.asyncIterator]() { + return this; + }, + }) as AsyncGenerator; + +class FakeSpan { + name: string; + attributes?: Record; + errors: string[] = []; + ended = false; + + constructor(name: string, attributes?: Record) { + this.name = name; + this.attributes = attributes; + } + + setAttribute(key: string, value: string | number | boolean): void { + this.attributes = { + ...(this.attributes ?? {}), + [key]: value, + }; + } + + setAttributes(attributes: Record): void { + this.attributes = { + ...(this.attributes ?? {}), + ...attributes, + }; + } + + recordError(message: string): void { + this.errors.push(message); + } + + end(): void { + this.ended = true; + } +} + +class FakeTracer implements ITracer { + spans: FakeSpan[] = []; + + startSpan(name: string, attributes?: Record) { + const span = new FakeSpan(name, attributes); + this.spans.push(span); + return span; + } + + async withSpan(_span: FakeSpan, callback: () => Promise): Promise { + return await callback(); + } + + getActiveSpan(): FakeSpan | undefined { + return this.spans.at(-1); + } +} + +const sentryScope = { + setLevel: vi.fn(), + setTag: vi.fn(), + setContext: vi.fn(), +}; + +vi.mock('@sentry/nestjs', () => ({ + withScope: (callback: (scope: typeof sentryScope) => void) => callback(sentryScope), + captureException: vi.fn(), +})); + +const createTable = (fields: unknown[] = []): Table => + ({ + id: () => ({ toString: () => 'tblIntegrity000001' }), + name: () => ({ toString: () => 'Tasks' }), + baseId: () => ({ toString: () => 'baseIntegrity0001' }), + getFields: () => fields, + }) as unknown as Table; + +const createMetaIssue = (): IMetaValidationIssue => ({ + fieldId: metaFieldId, + fieldName: 'Status', + fieldType: 'lookup', + category: 'reference', + severity: 'error', + message: 'Link field not found: fldMissing', + details: { + relatedFieldId: 'fldMissing', + }, +}); + +const createMetaIssueStream = (issue: IMetaValidationIssue): AsyncGenerator => + (async function* () { + yield issue; + })(); + +const createRepairResult = (): ISchemaRepairResult => ({ + id: 'tblIntegrity000001:rule:error', + fieldId: metaFieldId, + fieldName: 'Status', + ruleId: repairRuleId, + ruleDescription: 'Physical column "Status" (text)', + status: 'error', + outcome: 'unchanged', + message: 'Schema repair failed', + required: true, + timestamp: Date.now(), + dependencies: [], + depth: 0, + details: { + statementCount: 1, + }, +}); + +const collect = async (stream: AsyncGenerator): Promise => { + const results: T[] = []; + for await (const item of stream) { + results.push(item); + } + return results; +}; + +describe('IntegrityV2Service repair telemetry', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('marks metadata reference check results as auto repairable', async () => { + const service = new IntegrityV2Service({} as never, {} as never); + const table = createTable(); + const issue = createMetaIssue(); + + const stream = service['decorateMetaCheckStream']( + table, + createMetaIssueStream(issue), + undefined + ); + + const results = await collect(stream); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + fieldId: issue.fieldId, + ruleId: metaReferenceRuleId, + status: 'error', + repair: { + available: true, + mode: 'auto', + }, + }); + }); + + it('captures result-level repair failures to sentry and trace spans', async () => { + const service = new IntegrityV2Service({} as never, {} as never); + const tracer = new FakeTracer(); + const table = createTable(); + const result = createRepairResult(); + + const stream = service['decorateRepairStream']( + table, + (async function* () { + yield result; + })(), + undefined, + { + tracer, + scope: 'table', + targetId: table.id().toString(), + } + ); + + const serialized = await collect(stream); + + expect(serialized).toHaveLength(1); + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(sentryScope.setTag).toHaveBeenCalledWith('feature', schemaIntegrityRepairFeatureTag); + expect(sentryScope.setTag).toHaveBeenCalledWith('integrity.failure_kind', 'result_error'); + expect(sentryScope.setContext).toHaveBeenCalledWith( + 'schema-integrity-repair', + expect.objectContaining({ + tableId: 'tblIntegrity000001', + ruleId: repairRuleId, + failureKind: 'result_error', + }) + ); + expect(tracer.spans[0]?.name).toBe(repairFailureSpanName); + expect(tracer.spans[0]?.attributes).toMatchObject({ + [integrityFailureKindAttribute]: 'result_error', + [integrityRuleIdAttribute]: repairRuleId, + [integrityScopeAttribute]: 'table', + }); + expect(tracer.spans[0]?.errors).toContain('Schema repair failed'); + }); + + it('captures thrown repair stream exceptions to sentry and trace spans', async () => { + const service = new IntegrityV2Service({} as never, {} as never); + const tracer = new FakeTracer(); + const table = createTable(); + + const stream = service['decorateRepairStream']( + table, + createThrowingRepairStream(repairStreamCrashedMessage), + undefined, + { + tracer, + scope: 'base', + targetId: table.baseId().toString(), + } + ); + + await expect(collect(stream)).rejects.toThrow(repairStreamCrashedMessage); + + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(sentryScope.setTag).toHaveBeenCalledWith('integrity.failure_kind', 'stream_exception'); + expect(tracer.spans[0]?.attributes).toMatchObject({ + [integrityFailureKindAttribute]: 'stream_exception', + [integrityScopeAttribute]: 'base', + [integrityTargetIdAttribute]: 'baseIntegrity0001', + }); + expect(tracer.spans[0]?.errors).toContain(repairStreamCrashedMessage); + }); +}); diff --git a/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts index 2f8fa2b09a..498cd18bda 100644 --- a/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts +++ b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts @@ -1,4 +1,5 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; import type { IV2BaseSchemaIntegrityRepairRo, IV2SchemaIntegrityFilterStatus, @@ -12,20 +13,30 @@ import type { } from '@teable/openapi'; import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; import { + checkTableMetaWithTables, + createMetaRepairer, createSchemaChecker, createSchemaRepairer, + getMetaIssueDetails, + getMetaRepairHint, + getMetaRuleId, + isMetaRuleId, + metaRuleDescription, PostgresSchemaIntrospector, + type MetaValidationIssue, type SchemaCheckResult, type SchemaRepairResult, type SchemaRuleRepairHint, } from '@teable/v2-adapter-table-repository-postgres'; import { BaseId, + TeableSpanAttributes, TableByBaseIdSpec, TableByIdSpec, TableId, v2CoreTokens, type IBaseRepository, + type ITracer, type ITableRepository, type Table, } from '@teable/v2-core'; @@ -33,6 +44,17 @@ import { V2ContainerService } from '../v2/v2-container.service'; import { V2ExecutionContextFactory } from '../v2/v2-execution-context.factory'; type ISchemaIntegrityDb = Parameters[0]['db']; +type IRepairTelemetryScope = 'table' | 'base'; +type IRepairTelemetryKind = 'result_error' | 'stream_exception'; + +const schemaIntegrityRepairFeatureTag = 'schema-integrity-repair'; +const teableBaseIdAttribute = 'teable.base_id'; +const integrityScopeAttribute = 'teable.integrity.scope'; +const integrityTargetIdAttribute = 'teable.integrity.target_id'; +const integrityFailureKindAttribute = 'teable.integrity.failure_kind'; +const integrityRuleIdAttribute = 'teable.integrity.rule_id'; +const integrityOutcomeAttribute = 'teable.integrity.outcome'; +const integrityRequiredAttribute = 'teable.integrity.required'; @Injectable() export class IntegrityV2Service { @@ -45,27 +67,48 @@ export class IntegrityV2Service { tableId: string, statuses?: IV2SchemaIntegrityFilterStatus[] ): Promise> { - const { table, db, schema } = await this.resolveSchemaTarget(tableId); + const { table, tables, db, schema } = await this.resolveSchemaTarget(tableId, { + includeBaseTables: true, + }); const checker = createSchemaChecker({ db, introspector: new PostgresSchemaIntrospector(db), schema, }); - return this.decorateCheckStream(table, checker.checkTable(table), statuses); + return this.streamTableChecks(table, tables, checker, statuses); } async createRepairStream( tableId: string, repairRo: IV2SchemaIntegrityRepairRo ): Promise> { - const { table, db, schema } = await this.resolveSchemaTarget(tableId); + const { table, tables, db, schema, context } = await this.resolveSchemaTarget(tableId, { + includeBaseTables: true, + }); const repairer = createSchemaRepairer({ db, introspector: new PostgresSchemaIntrospector(db), schema, }); + const metaRepairer = createMetaRepairer({ db }); + + if (repairRo.fieldId && isMetaRuleId(repairRo.ruleId)) { + return this.decorateRepairStream( + table, + metaRepairer.repairRule(table, tables, repairRo.fieldId, repairRo.ruleId, { + dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, + }), + repairRo.statuses, + { + tracer: context.tracer, + scope: 'table', + targetId: tableId, + } + ); + } if (repairRo.fieldId && repairRo.ruleId) { return this.decorateRepairStream( @@ -75,28 +118,55 @@ export class IntegrityV2Service { manualRepairValues: repairRo.manualRepairValues, targetStatuses: repairRo.targetStatuses, }), - repairRo.statuses + repairRo.statuses, + { + tracer: context.tracer, + scope: 'table', + targetId: tableId, + } ); } if (repairRo.fieldId) { return this.decorateRepairStream( table, - repairer.repairField(table, repairRo.fieldId, { - dryRun: repairRo.dryRun, - targetStatuses: repairRo.targetStatuses, - }), - repairRo.statuses + this.combineRepairStreams( + repairer.repairField(table, repairRo.fieldId, { + dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, + }), + metaRepairer.repairField(table, tables, repairRo.fieldId, { + dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, + }) + ), + repairRo.statuses, + { + tracer: context.tracer, + scope: 'table', + targetId: tableId, + } ); } return this.decorateRepairStream( table, - repairer.repairTable(table, { - dryRun: repairRo.dryRun, - targetStatuses: repairRo.targetStatuses, - }), - repairRo.statuses + this.combineRepairStreams( + repairer.repairTable(table, { + dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, + }), + metaRepairer.repairTable(table, tables, { + dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, + }) + ), + repairRo.statuses, + { + tracer: context.tracer, + scope: 'table', + targetId: tableId, + } ); } @@ -118,17 +188,27 @@ export class IntegrityV2Service { baseId: string, repairRo: IV2BaseSchemaIntegrityRepairRo ): Promise> { - const { tables, db, schema } = await this.resolveBaseTarget(baseId); + const { tables, db, schema, context } = await this.resolveBaseTarget(baseId); const repairer = createSchemaRepairer({ db, introspector: new PostgresSchemaIntrospector(db), schema, }); + const metaRepairer = createMetaRepairer({ db }); - return this.streamBaseRepairs(tables, repairer, repairRo); + return this.streamBaseRepairs(tables, repairer, metaRepairer, repairRo, { + tracer: context.tracer, + scope: 'base', + targetId: baseId, + }); } - private async resolveSchemaTarget(tableId: string) { + private async resolveSchemaTarget( + tableId: string, + options?: { + includeBaseTables?: boolean; + } + ) { const parsedTableId = TableId.create(tableId); if (parsedTableId.isErr()) { throw new HttpException(parsedTableId.error.message, HttpStatus.BAD_REQUEST); @@ -148,11 +228,27 @@ export class IntegrityV2Service { const db = container.resolve(v2PostgresDbTokens.db); const table = tableResult.value; + let tables: ReadonlyArray = [table]; + + if (options?.includeBaseTables) { + const tablesResult = await tableRepository.find( + context, + TableByBaseIdSpec.create(table.baseId()) + ); + + if (tablesResult.isErr()) { + throw new HttpException(tablesResult.error.message, HttpStatus.INTERNAL_SERVER_ERROR); + } + + tables = tablesResult.value; + } return { table, + tables, db, schema: table.baseId().toString(), + context, }; } @@ -194,32 +290,60 @@ export class IntegrityV2Service { tables, db, schema: parsedBaseId.value.toString(), + context, }; } + private async *streamTableChecks( + table: Table, + allTables: ReadonlyArray
, + checker: ReturnType, + statuses?: IV2SchemaIntegrityFilterStatus[] + ): AsyncGenerator { + yield* this.decorateCheckStream(table, checker.checkTable(table), statuses); + yield* this.decorateMetaCheckStream( + table, + checkTableMetaWithTables(table, table.baseId(), allTables), + statuses + ); + } + private async *streamBaseChecks( tables: ReadonlyArray
, checker: ReturnType, statuses?: IV2SchemaIntegrityFilterStatus[] ): AsyncGenerator { for (const table of tables) { - yield* this.decorateCheckStream(table, checker.checkTable(table), statuses); + yield* this.streamTableChecks(table, tables, checker, statuses); } } private async *streamBaseRepairs( tables: ReadonlyArray
, repairer: ReturnType, - repairRo: IV2BaseSchemaIntegrityRepairRo + metaRepairer: ReturnType, + repairRo: IV2BaseSchemaIntegrityRepairRo, + telemetry: { + tracer?: ITracer; + scope: IRepairTelemetryScope; + targetId: string; + } ): AsyncGenerator { for (const table of tables) { yield* this.decorateRepairStream( table, - repairer.repairTable(table, { - dryRun: repairRo.dryRun, - targetStatuses: repairRo.targetStatuses, - }), - repairRo.statuses + this.combineRepairStreams( + repairer.repairTable(table, { + dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, + }), + metaRepairer.repairTable(table, tables, { + dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, + }) + ), + repairRo.statuses, + telemetry ); } } @@ -243,16 +367,176 @@ export class IntegrityV2Service { private async *decorateRepairStream( table: Table, stream: AsyncGenerator, - statuses?: IV2SchemaIntegrityFilterStatus[] + statuses?: IV2SchemaIntegrityFilterStatus[], + telemetry?: { + tracer?: ITracer; + scope: IRepairTelemetryScope; + targetId: string; + } ): AsyncGenerator { const statusFilter = this.createStatusFilterSet(statuses); - for await (const result of stream) { - const serialized = this.serializeRepairResult(table, result); - if (!this.shouldIncludeResult(serialized.status, statusFilter)) { + try { + for await (const result of stream) { + const serialized = this.serializeRepairResult(table, result); + if (serialized.status === 'error' && telemetry) { + await this.captureRepairFailure( + table, + result, + new Error(result.message), + telemetry, + 'result_error' + ); + } + + if (!this.shouldIncludeResult(serialized.status, statusFilter)) { + continue; + } + + yield serialized; + } + } catch (error) { + if (telemetry) { + await this.captureRepairFailure(table, undefined, error, telemetry, 'stream_exception'); + } + throw error; + } + } + + private async *combineRepairStreams( + ...streams: ReadonlyArray> + ): AsyncGenerator { + for (const stream of streams) { + yield* stream; + } + } + + private async captureRepairFailure( + table: Table, + result: SchemaRepairResult | undefined, + error: unknown, + telemetry: { + tracer?: ITracer; + scope: IRepairTelemetryScope; + targetId: string; + }, + kind: IRepairTelemetryKind + ): Promise { + const err = error instanceof Error ? error : new Error(String(error)); + const tableId = table.id().toString(); + const baseId = table.baseId().toString(); + const spanAttributes: Record = { + [TeableSpanAttributes.VERSION]: 'v2', + [TeableSpanAttributes.COMPONENT]: 'service', + [TeableSpanAttributes.OPERATION]: 'integrity.repair.failure', + [TeableSpanAttributes.TABLE_ID]: tableId, + [teableBaseIdAttribute]: baseId, + [integrityScopeAttribute]: telemetry.scope, + [integrityTargetIdAttribute]: telemetry.targetId, + [integrityFailureKindAttribute]: kind, + }; + + if (result?.fieldId && result.fieldId !== '__system__') { + spanAttributes[TeableSpanAttributes.FIELD_ID] = result.fieldId; + } + if (result?.ruleId) { + spanAttributes[integrityRuleIdAttribute] = result.ruleId; + } + if (result?.outcome) { + spanAttributes[integrityOutcomeAttribute] = result.outcome; + } + if (result?.required != null) { + spanAttributes[integrityRequiredAttribute] = result.required; + } + + const reportToSentry = () => { + Sentry.withScope((scope) => { + scope.setLevel?.('error'); + scope.setTag('feature', schemaIntegrityRepairFeatureTag); + scope.setTag('integrity.scope', telemetry.scope); + scope.setTag('integrity.target_id', telemetry.targetId); + scope.setTag('integrity.failure_kind', kind); + scope.setTag('base.id', baseId); + scope.setTag('table.id', tableId); + + if (result?.ruleId) { + scope.setTag('integrity.rule_id', result.ruleId); + } + if (result?.fieldId && result.fieldId !== '__system__') { + scope.setTag('field.id', result.fieldId); + } + + scope.setContext('schema-integrity-repair', { + baseId, + tableId, + tableName: table.name().toString(), + scope: telemetry.scope, + targetId: telemetry.targetId, + failureKind: kind, + resultId: result?.id, + fieldId: result?.fieldId, + fieldName: result?.fieldName, + ruleId: result?.ruleId, + ruleDescription: result?.ruleDescription, + outcome: result?.outcome, + required: result?.required, + details: result?.details, + }); + + Sentry.captureException(err); + }); + }; + + const tracer = telemetry.tracer; + if (!tracer) { + reportToSentry(); + return; + } + + const span = tracer.startSpan('teable.IntegrityV2Service.reportRepairFailure', spanAttributes); + try { + span.recordError(err.message); + await tracer.withSpan(span, async () => { + reportToSentry(); + }); + } finally { + span.end(); + } + } + + private async *decorateMetaCheckStream( + table: Table, + stream: AsyncGenerator, + statuses?: IV2SchemaIntegrityFilterStatus[] + ): AsyncGenerator { + const statusFilter = this.createStatusFilterSet(statuses); + + for await (const issue of stream) { + const status = this.toMetaCheckStatus(issue.severity); + if (!status || !this.shouldIncludeResult(status, statusFilter)) { continue; } - yield serialized; + yield { + id: this.createScopedResultId(table, `${issue.fieldId}:${getMetaRuleId(issue)}`), + baseId: table.baseId().toString(), + tableId: table.id().toString(), + tableName: table.name().toString(), + fieldId: issue.fieldId, + fieldName: issue.fieldName, + ruleId: getMetaRuleId(issue), + ruleDescription: metaRuleDescription, + status, + message: issue.message, + details: this.toMutableDetails(getMetaIssueDetails(issue)), + repair: + status === 'error' || status === 'warn' + ? this.toMutableRepairHint(getMetaRepairHint(issue)) + : undefined, + required: true, + timestamp: Date.now(), + dependencies: [], + depth: 0, + }; } } @@ -262,6 +546,7 @@ export class IntegrityV2Service { ): IV2SchemaIntegrityCheckResult { return { id: this.createScopedResultId(table, result.id), + baseId: table.baseId().toString(), tableId: table.id().toString(), tableName: table.name().toString(), fieldId: result.fieldId, @@ -270,14 +555,7 @@ export class IntegrityV2Service { ruleDescription: result.ruleDescription, status: result.status, message: result.message, - details: result.details - ? { - missing: this.toMutableArray(result.details.missing), - missingItems: this.toMutableDetailItems(result.details.missingItems), - extra: this.toMutableArray(result.details.extra), - extraItems: this.toMutableDetailItems(result.details.extraItems), - } - : undefined, + details: this.toMutableDetails(result.details), repair: result.repair ? this.toMutableRepairHint(result.repair) : undefined, required: result.required, timestamp: result.timestamp, @@ -292,6 +570,7 @@ export class IntegrityV2Service { ): IV2SchemaIntegrityRepairResult { return { id: this.createScopedResultId(table, result.id), + baseId: table.baseId().toString(), tableId: table.id().toString(), tableName: table.name().toString(), fieldId: result.fieldId, @@ -301,15 +580,7 @@ export class IntegrityV2Service { status: result.status, outcome: result.outcome, message: result.message, - details: result.details - ? { - missing: this.toMutableArray(result.details.missing), - missingItems: this.toMutableDetailItems(result.details.missingItems), - extra: this.toMutableArray(result.details.extra), - extraItems: this.toMutableDetailItems(result.details.extraItems), - statementCount: result.details.statementCount, - } - : undefined, + details: this.toMutableDetails(result.details), repair: result.repair ? this.toMutableRepairHint(result.repair) : undefined, required: result.required, timestamp: result.timestamp, @@ -322,6 +593,22 @@ export class IntegrityV2Service { return `${table.id().toString()}:${id}`; } + private toMutableDetails(details?: SchemaRepairResult['details']) { + return details + ? { + missing: this.toMutableArray(details.missing), + missingItems: this.toMutableDetailItems(details.missingItems), + extra: this.toMutableArray(details.extra), + extraItems: this.toMutableDetailItems(details.extraItems), + statementCount: details.statementCount, + statements: details.statements?.map((statement) => ({ + sql: statement.sql, + parameters: [...statement.parameters], + })), + } + : undefined; + } + private toMutableArray(values?: ReadonlyArray): string[] | undefined { return values ? [...values] : undefined; } @@ -461,4 +748,12 @@ export class IntegrityV2Service { return statusFilter.has(status as IV2SchemaIntegrityFilterStatus); } + + private toMetaCheckStatus( + severity: MetaValidationIssue['severity'] + ): IV2SchemaIntegrityCheckResult['status'] | undefined { + if (severity === 'error') return 'error'; + if (severity === 'warning') return 'warn'; + return undefined; + } } diff --git a/apps/nestjs-backend/src/features/integrity/integrity.module.ts b/apps/nestjs-backend/src/features/integrity/integrity.module.ts index aa045bd587..5eff36fec6 100644 --- a/apps/nestjs-backend/src/features/integrity/integrity.module.ts +++ b/apps/nestjs-backend/src/features/integrity/integrity.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { CanaryModule } from '../canary/canary.module'; +import { FieldOpenApiModule } from '../field/open-api/field-open-api.module'; import { FieldModule } from '../field/field.module'; import { TableDomainQueryModule } from '../table-domain'; import { V2Module } from '../v2/v2.module'; @@ -12,7 +13,7 @@ import { LinkIntegrityService } from './link-integrity.service'; import { UniqueIndexService } from './unique-index.service'; @Module({ - imports: [FieldModule, TableDomainQueryModule, V2Module, CanaryModule], + imports: [FieldModule, FieldOpenApiModule, TableDomainQueryModule, V2Module, CanaryModule], controllers: [IntegrityController, IntegrityV2Controller], providers: [ ForeignKeyIntegrityService, diff --git a/apps/nestjs-backend/src/features/integrity/link-integrity.service.ts b/apps/nestjs-backend/src/features/integrity/link-integrity.service.ts index f86ae63fbd..967849cd50 100644 --- a/apps/nestjs-backend/src/features/integrity/link-integrity.service.ts +++ b/apps/nestjs-backend/src/features/integrity/link-integrity.service.ts @@ -4,6 +4,7 @@ import { FieldType, CellValueType, DbFieldType, + PRIMARY_SUPPORTED_TYPES, Relationship, DriverClient, getValidFilterOperators, @@ -27,6 +28,7 @@ import { LinkFieldQueryService } from '../field/field-calculate/link-field-query import { FieldService } from '../field/field.service'; import { createFieldInstanceByRaw } from '../field/model/factory'; import type { LinkFieldDto } from '../field/model/field-dto/link-field.dto'; +import { FieldOpenApiService } from '../field/open-api/field-open-api.service'; import { TableDomainQueryService } from '../table-domain'; import { ForeignKeyIntegrityService } from './foreign-key.service'; import { LinkFieldIntegrityService } from './link-field.service'; @@ -44,6 +46,7 @@ export class LinkIntegrityService { private readonly tableDomainQueryService: TableDomainQueryService, private readonly linkFieldQueryService: LinkFieldQueryService, private readonly fieldService: FieldService, + private readonly fieldOpenApiService: FieldOpenApiService, @InjectDbProvider() private readonly dbProvider: IDbProvider, @InjectModel('CUSTOM_KNEX') private readonly knex: Knex ) {} @@ -161,12 +164,102 @@ export class LinkIntegrityService { }); } + const invalidPrimaryIssues = await this.checkInvalidPrimary(baseId); + if (invalidPrimaryIssues.length > 0) { + linkFieldIssues.push({ + baseId: mainBase.id, + baseName: mainBase.name, + issues: invalidPrimaryIssues, + }); + } + + const missingPrimaryIssues = await this.checkMissingPrimary(baseId); + if (missingPrimaryIssues.length > 0) { + linkFieldIssues.push({ + baseId: mainBase.id, + baseName: mainBase.name, + issues: missingPrimaryIssues, + }); + } + return { hasIssues: linkFieldIssues.length > 0, linkFieldIssues, }; } + // Detect primary fields that break base duplication / symmetric link generation: + // 1. Lookup-ish primaries — isLookup, isConditionalLookup, or stray lookupOptions. + // Origin: convertField path before T3367 (e.g. AI flipped Employee→lookup). + // 2. Unsupported-type primaries — link/checkbox/attachment/rollup as primary. + // Origin: bulk createFieldsByRo (duplicate/import/AI), now blocked at the source. + // Both states make `findFirstOrThrow({tableId, isPrimary: true})` return a field that can't + // serve as a static lookupFieldId for symmetric links. + private async checkInvalidPrimary(baseId: string): Promise { + const fields = await this.prismaService.field.findMany({ + where: { + deletedTime: null, + isPrimary: true, + table: { baseId, deletedTime: null }, + OR: [ + { isLookup: true }, + { isConditionalLookup: true }, + { lookupOptions: { not: null } }, + { type: { notIn: Array.from(PRIMARY_SUPPORTED_TYPES) } }, + ], + }, + select: { + id: true, + name: true, + type: true, + isLookup: true, + isConditionalLookup: true, + lookupOptions: true, + tableId: true, + table: { select: { name: true } }, + }, + }); + + return fields.map((f) => { + const isLookupish = f.isLookup || f.isConditionalLookup || f.lookupOptions !== null; + const type = isLookupish + ? IntegrityIssueType.InvalidPrimaryLookup + : IntegrityIssueType.InvalidPrimaryType; + const reason = isLookupish + ? 'is incorrectly configured as a lookup field' + : `has unsupported type "${f.type}"`; + return { + fieldId: f.id, + tableId: f.tableId, + type, + message: `Primary field "${f.name}" in table "${f.table.name}" ${reason}, which breaks base duplication. Fixing will demote it and promote an existing eligible field as primary; if no candidate qualifies, a new formula field mirroring the current value is added and the bad primary is renamed with a "(before-fix)" suffix.`, + }; + }); + } + + // Detect tables that have no primary field at all. `field-supplement.generateSymmetricField` + // does `findFirstOrThrow({tableId, isPrimary: true})` when creating link fields during base + // duplication; a missing primary makes that throw. + private async checkMissingPrimary(baseId: string): Promise { + const tables = await this.prismaService.tableMeta.findMany({ + where: { + baseId, + deletedTime: null, + fields: { none: { isPrimary: true, deletedTime: null } }, + }, + select: { id: true, name: true }, + }); + + return tables.map((t) => ({ + // fieldId is required by the schema; use tableId as a stable placeholder so the fix + // dispatcher can locate the table without a real field reference. + fieldId: t.id, + tableId: t.id, + type: IntegrityIssueType.MissingPrimary, + message: `Table "${t.name}" has no primary field, which breaks base duplication. Fixing will promote the first existing eligible field as primary, or add a new "Name" text field if none qualifies.`, + })); + } + private async checkReferenceField(baseId: string): Promise { const tables = await this.prismaService.tableMeta.findMany({ where: { baseId, deletedTime: null }, @@ -859,6 +952,18 @@ export class LinkIntegrityService { result && fixResults.push(result); break; } + case IntegrityIssueType.InvalidPrimaryLookup: + case IntegrityIssueType.InvalidPrimaryType: { + const result = await this.fixInvalidPrimary(issue.fieldId, issue.type); + result && fixResults.push(result); + break; + } + case IntegrityIssueType.MissingPrimary: { + // For missing-primary issues fieldId carries the tableId (see checkMissingPrimary). + const result = await this.fixMissingPrimary(issue.fieldId); + result && fixResults.push(result); + break; + } default: break; } @@ -886,6 +991,189 @@ export class LinkIntegrityService { }; } + async fixInvalidPrimary( + fieldId: string, + issueType: IntegrityIssueType + ): Promise { + const oldField = await this.prismaService.field.findFirst({ + where: { + id: fieldId, + deletedTime: null, + isPrimary: true, + }, + select: { id: true, name: true, tableId: true }, + }); + if (!oldField) return; + + // Strategy: atomic via outer $tx — inner $tx calls from updateField / createField reuse + // the same transaction, so any failure rolls back all partial mutations. + // 1. Demote the bad primary (direct DB — no service for primary toggle). + // 2. If the table still has a separate valid primary → keep it as-is (defensive). + // 3. Else if any field qualifies as primary → promote it directly. + // 4. Else fall back: rename the bad primary to "(before-fix)" so the original name + // is free, then create + promote a formula field mirroring the old value. + // The old bad primary is always preserved so existing references (link preview + // `options.lookupFieldId`, downstream lookups/rollups/formulas) keep working. + + const primaryFieldFilter = { + deletedTime: null, + isLookup: null, + isConditionalLookup: null, + lookupOptions: null, + type: { in: Array.from(PRIMARY_SUPPORTED_TYPES) }, + }; + + const result = await this.prismaService.$tx(async (prisma) => { + // Demote the bad primary first. Rename is deferred — only the formula fallback path + // needs to free up the original name for the new field. + await prisma.field.update({ + where: { id: oldField.id }, + data: { isPrimary: null }, + }); + + // Defensive: if a separate valid primary already exists in the table, leave it alone. + // Avoids leaving the table with multiple primaries (which the integrity check doesn't + // detect today). Production has zero such tables and validatePrimaryConfigurations + // blocks new ones, but this guards races / direct SQL writes / future regressions. + const existingValidPrimary = await prisma.field.findFirst({ + where: { tableId: oldField.tableId, isPrimary: true, ...primaryFieldFilter }, + select: { id: true, name: true }, + }); + + if (existingValidPrimary) { + return { kind: 'kept' as const, field: existingValidPrimary }; + } + + // Prefer promoting an existing eligible candidate over creating a new formula field. + // Mirrors fixMissingPrimary's behavior — fewer artifact fields, simpler table shape. + // The promoted field's displayed value will replace the bad primary's value; the bad + // primary itself stays untouched (no rename needed since no name collision). + const candidate = await prisma.field.findFirst({ + where: { tableId: oldField.tableId, ...primaryFieldFilter }, + orderBy: { order: 'asc' }, + select: { id: true, name: true }, + }); + + if (candidate) { + await prisma.field.update({ + where: { id: candidate.id }, + data: { isPrimary: true }, + }); + return { kind: 'promoted' as const, field: candidate }; + } + + // Fallback: no eligible candidate exists. Rename the bad primary so the new formula + // field can take its name, then create the formula mirroring the original value. + const legacyName = `${oldField.name} (before-fix)`; + await this.fieldOpenApiService.updateField(oldField.tableId, oldField.id, { + name: legacyName, + }); + const newField = await this.fieldOpenApiService.createField(oldField.tableId, { + type: FieldType.Formula, + name: oldField.name, + options: { + expression: `{${oldField.id}}`, + }, + }); + + await prisma.field.update({ + where: { id: newField.id }, + data: { isPrimary: true }, + }); + + return { + kind: 'created' as const, + field: { id: newField.id, name: oldField.name }, + legacyName, + }; + }); + + const baseMsg = `Demoted invalid primary "${oldField.name}" (id ${oldField.id}).`; + if (result.kind === 'kept') { + return { + type: issueType, + fieldId, + message: `${baseMsg} Existing valid primary "${result.field.name}" (${result.field.id}) preserved.`, + }; + } + if (result.kind === 'promoted') { + return { + type: issueType, + fieldId, + message: `${baseMsg} Promoted existing field "${result.field.name}" (${result.field.id}) to primary.`, + }; + } + return { + type: issueType, + fieldId, + message: `Demoted invalid primary "${oldField.name}" (renamed to "${result.legacyName}", id ${oldField.id}). Added new formula field "${result.field.name}" (${result.field.id}) as primary, mirroring the original value.`, + }; + } + + async fixMissingPrimary(tableId: string): Promise { + const table = await this.prismaService.tableMeta.findFirst({ + where: { id: tableId, deletedTime: null }, + select: { id: true, name: true }, + }); + if (!table) return; + + // Re-check inside the transaction to avoid racing with a concurrent promotion. + return this.prismaService.$tx(async () => { + const prisma = this.prismaService.txClient(); + const existing = await prisma.field.findFirst({ + where: { tableId, isPrimary: true, deletedTime: null }, + select: { id: true }, + }); + if (existing) return undefined; + + // Prefer promoting an existing valid candidate. Avoids leaving a stray "Name 2" + // alongside the user's existing fields and matches the natural intuition that + // the first usable column should be primary. + const candidate = await prisma.field.findFirst({ + where: { + tableId, + deletedTime: null, + isLookup: null, + isConditionalLookup: null, + lookupOptions: null, + type: { in: Array.from(PRIMARY_SUPPORTED_TYPES) }, + }, + orderBy: { order: 'asc' }, + select: { id: true, name: true }, + }); + + if (candidate) { + await prisma.field.update({ + where: { id: candidate.id }, + data: { isPrimary: true }, + }); + return { + type: IntegrityIssueType.MissingPrimary, + fieldId: candidate.id, + tableId, + message: `Promoted existing field "${candidate.name}" (${candidate.id}) to primary in table "${table.name}".`, + }; + } + + // Fallback: no usable candidate (every field is link / checkbox / attachment / + // rollup / lookup-ish). Create a new "Name" text field as primary. + const newField = await this.fieldOpenApiService.createField(tableId, { + type: FieldType.SingleLineText, + name: 'Name', + }); + await prisma.field.update({ + where: { id: newField.id }, + data: { isPrimary: true }, + }); + return { + type: IntegrityIssueType.MissingPrimary, + fieldId: newField.id, + tableId, + message: `Added "Name" text field (${newField.id}) as primary in table "${table.name}".`, + }; + }); + } + async fixOneWayLinkField(fieldId: string): Promise { const field = await this.prismaService.field.findFirstOrThrow({ where: { id: fieldId, deletedTime: null }, diff --git a/apps/nestjs-backend/src/features/record/query-builder/sql-conversion.visitor.ts b/apps/nestjs-backend/src/features/record/query-builder/sql-conversion.visitor.ts index 5d40cc615d..7d38463274 100644 --- a/apps/nestjs-backend/src/features/record/query-builder/sql-conversion.visitor.ts +++ b/apps/nestjs-backend/src/features/record/query-builder/sql-conversion.visitor.ts @@ -1621,9 +1621,26 @@ abstract class BaseSqlConversionVisitor< return `((${valueSql}))::boolean`; } + private unwrapExpression(ctx: ExprContext): ExprContext { + if (ctx instanceof BracketsContext) { + return this.unwrapExpression(ctx.expr()); + } + + if ( + ctx instanceof LeftWhitespaceOrCommentsContext || + ctx instanceof RightWhitespaceOrCommentsContext + ) { + return this.unwrapExpression(ctx.expr()); + } + + return ctx; + } + private isBlankLikeExpression(ctx: ExprContext): boolean { - if (ctx instanceof StringLiteralContext) { - const raw = ctx.text; + const normalizedCtx = this.unwrapExpression(ctx); + + if (normalizedCtx instanceof StringLiteralContext) { + const raw = normalizedCtx.text; if (raw.startsWith("'") && raw.endsWith("'")) { const unescaped = unescapeString(raw.slice(1, -1)); return unescaped === ''; @@ -1631,8 +1648,8 @@ abstract class BaseSqlConversionVisitor< return false; } - if (ctx instanceof FunctionCallContext) { - const rawName = ctx.func_name().text.toUpperCase(); + if (normalizedCtx instanceof FunctionCallContext) { + const rawName = normalizedCtx.func_name().text.toUpperCase(); const fnName = normalizeFunctionNameAlias(rawName) as FunctionName; return fnName === FunctionName.Blank; } diff --git a/apps/nestjs-backend/src/features/record/record.service.ts b/apps/nestjs-backend/src/features/record/record.service.ts index deb96b682f..15d623b81f 100644 --- a/apps/nestjs-backend/src/features/record/record.service.ts +++ b/apps/nestjs-backend/src/features/record/record.service.ts @@ -2140,6 +2140,9 @@ export class RecordService { return false; } if (isSearchAllFields) { + if (field.cellValueType === CellValueType.DateTime) { + return false; + } if (field.cellValueType === CellValueType.Number && isNaN(Number(search[0]))) { return false; } diff --git a/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts b/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts index 3a74d1f632..6c0f57752f 100644 --- a/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts +++ b/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable sonarjs/no-duplicate-string */ import { Session } from 'node:inspector'; import { Readable } from 'node:stream'; import { @@ -41,11 +42,11 @@ export class AdminOpenApiService { } async repairTableAttachmentThumbnail() { - // once handle 1000 attachments const take = 1000; let total = 0; - for (let skip = 0; ; skip += take) { - const sqlNative = this.knex('attachments_table') + let lastToken: string | null = null; + for (;;) { + const query = this.knex('attachments_table') .select( 'attachments.token', 'attachments.height', @@ -53,13 +54,29 @@ export class AdminOpenApiService { 'attachments.path' ) .leftJoin('attachments', 'attachments_table.token', 'attachments.token') - .whereNotNull('attachments.height') + .where((qb) => + qb + .where((image) => + image + .where('attachments.mimetype', 'like', 'image/%') + .whereNotNull('attachments.height') + ) + .orWhereIn('attachments.mimetype', ['application/pdf', 'application/x-pdf']) + ) .whereNull('attachments.deleted_time') .whereNull('attachments.thumbnail_path') - .limit(take) - .offset(skip) - .toSQL() - .toNative(); + .groupBy( + 'attachments.token', + 'attachments.height', + 'attachments.mimetype', + 'attachments.path' + ) + .orderBy('attachments.token') + .limit(take); + if (lastToken) { + query.where('attachments.token', '>', lastToken); + } + const sqlNative = query.toSQL().toNative(); const attachments = await this.prismaService.$queryRawUnsafe< { token: string; height?: number; mimetype: string; path: string }[] >(sqlNative.sql, ...sqlNative.bindings); @@ -67,6 +84,7 @@ export class AdminOpenApiService { if (attachments.length === 0) { break; } + lastToken = attachments[attachments.length - 1].token; total += attachments.length; await this.attachmentsCropQueueProcessor.queue.addBulk( attachments.map((attachment) => ({ diff --git a/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.spec.ts b/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.spec.ts new file mode 100644 index 0000000000..75085ce3d7 --- /dev/null +++ b/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.spec.ts @@ -0,0 +1,180 @@ +import { LLMProviderType } from '@teable/openapi'; +import { generateImage as aiGenerateImage } from 'ai'; +import axios from 'axios'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SettingOpenApiService } from './setting-open-api.service'; + +vi.mock('ai', () => ({ + createGateway: vi.fn(), + generateImage: vi.fn(), + generateText: vi.fn(), + tool: vi.fn((config) => config), +})); + +const providerName = 'custom-openai'; +const apiKey = 'sk-test'; +const openAIBaseUrl = 'https://api.openai.com/v1'; +const gptImage2Model = 'gpt-image-2'; +const gptImage2ModelKey = `${LLMProviderType.OPENAI}@${gptImage2Model}@${providerName}`; +const testImageBuffer = Buffer.from([1, 2, 3]); + +describe('SettingOpenApiService', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.BUILD_VERSION; + delete process.env.NEXT_PUBLIC_BUILD_VERSION; + delete process.env.APP_VERSION; + vi.restoreAllMocks(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + const createService = () => + new SettingOpenApiService( + undefined as never, + undefined as never, + { provider: 'local' } as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never + ); + + it('sends runtime build version to public access checker', async () => { + process.env.BUILD_VERSION = '20260429.1'; + process.env.NEXT_PUBLIC_BUILD_VERSION = 'legacy-build'; + const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({ + data: { + success: true, + statusCode: 200, + latencyMs: 10, + checkedFrom: 'test', + }, + }); + + await ( + createService() as unknown as { + checkUrlAccessible: ( + url: string, + setting: { instanceId?: string; createdTime?: string } + ) => Promise; + } + ).checkUrlAccessible('https://teable.ai/health', { + instanceId: 'ins_123', + createdTime: '2026-04-29T00:00:00.000Z', + }); + + expect(getSpy).toHaveBeenCalledWith( + 'https://access-checker.teable.ai/check', + expect.objectContaining({ + params: { + url: 'https://teable.ai/health', + instanceId: 'ins_123', + version: '20260429.1', + deployedAt: '2026-04-29T00:00:00.000Z', + }, + }) + ); + }); +}); + +describe('SettingOpenApiService.testLLM image generation', () => { + const service = Object.create(SettingOpenApiService.prototype) as SettingOpenApiService; + let getTestFileBufferMock: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + getTestFileBufferMock = vi.fn().mockResolvedValue(testImageBuffer); + ( + service as unknown as { + getTestFileBuffer: typeof getTestFileBufferMock; + } + ).getTestFileBuffer = getTestFileBufferMock; + vi.mocked(aiGenerateImage).mockResolvedValue({ + image: { mediaType: 'image/png', uint8Array: new Uint8Array([1]) }, + images: [{ mediaType: 'image/png', uint8Array: new Uint8Array([1]) }], + warnings: [], + responses: [], + providerMetadata: {}, + usage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + }, + } as never); + }); + + it('uses the catalog default size when testing GPT image text-to-image generation', async () => { + const result = await service.testLLM({ + type: LLMProviderType.OPENAI, + name: providerName, + apiKey, + baseUrl: openAIBaseUrl, + models: gptImage2Model, + modelKey: gptImage2ModelKey, + testImageGeneration: true, + }); + + expect(result.success).toBe(true); + expect(aiGenerateImage).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: 'A simple test: draw a small red circle', + n: 1, + size: '1024x1024', + }) + ); + }); + + it('infers image generation testing from catalog when testImageGeneration is omitted', async () => { + const result = await service.testLLM({ + type: LLMProviderType.OPENAI, + name: providerName, + apiKey, + baseUrl: openAIBaseUrl, + models: gptImage2Model, + modelKey: gptImage2ModelKey, + }); + + expect(result.success).toBe(true); + expect(aiGenerateImage).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: 'A simple test: draw a small red circle', + n: 1, + size: '1024x1024', + }) + ); + }); + + it('uses prompt images when testing GPT image image-to-image generation', async () => { + const result = await service.testLLM({ + type: LLMProviderType.OPENAI, + name: providerName, + apiKey, + baseUrl: openAIBaseUrl, + models: gptImage2Model, + modelKey: gptImage2ModelKey, + testImageGeneration: true, + testImageToImage: true, + }); + + expect(result.success).toBe(true); + expect(aiGenerateImage).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: { + text: 'Create a very simple variation of this image.', + images: [testImageBuffer], + }, + n: 1, + size: '1024x1024', + }) + ); + expect(getTestFileBufferMock).toHaveBeenCalledWith('static/test/test-image.png'); + expect(vi.mocked(aiGenerateImage).mock.calls[0][0]).not.toHaveProperty( + 'providerOptions.openai.image' + ); + }); +}); diff --git a/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts b/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts index 31f79eae45..229480ac78 100644 --- a/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts +++ b/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts @@ -24,9 +24,17 @@ import type { GatewayModelType, GatewayModelTag, GatewayModelProvider, + IImageSize, } from '@teable/openapi'; -import { chatModelAbilityType, UploadType, LLMProviderType, SettingKey } from '@teable/openapi'; -import { createGateway, generateText, tool, experimental_generateImage } from 'ai'; +import { + chatModelAbilityType, + getImageModelConfig, + getImageModelConfigByGatewayId, + UploadType, + LLMProviderType, + SettingKey, +} from '@teable/openapi'; +import { createGateway, generateText, tool, generateImage } from 'ai'; import type { LanguageModel, TextPart, FilePart } from 'ai'; import axios from 'axios'; import { uniq } from 'lodash'; @@ -36,13 +44,14 @@ import { BaseConfig, IBaseConfig } from '../../../configs/base.config'; import { type IStorageConfig, StorageConfig } from '../../../configs/storage'; import { CustomHttpException } from '../../../custom.exception'; import type { IClsStore } from '../../../types/cls'; +import { resolveBuildVersion } from '../../../utils/build-version'; import { INSTANCE_PROVIDER_NAME } from '../../ai/ai.service'; import { getAdaptedProviderOptions, modelProviders } from '../../ai/util'; import { AttachmentsStorageService } from '../../attachments/attachments-storage.service'; import StorageAdapter from '../../attachments/plugins/adapter'; import { InjectStorageAdapter } from '../../attachments/plugins/storage'; import { getPublicFullStorageUrl } from '../../attachments/plugins/utils'; -import { EMAIL_LOGO_TOKEN } from '../../builtin-assets-init/builtin-assets-init.service'; +import { EMAIL_LOGO_TOKEN } from '../../builtin-assets-init'; import { verifyTransport } from '../../mail-sender/mail-helpers'; import { SettingService } from '../setting.service'; @@ -324,20 +333,29 @@ export class SettingOpenApiService { } /** - * Get base64 data URL for a test file + * Get test file buffer */ - private async getTestFileBase64(filePath: string, contentType: string): Promise { + private async getTestFileBuffer(filePath: string): Promise { try { const fullPath = resolve(process.cwd(), filePath); - const fileBuffer = await readFile(fullPath); - const base64 = fileBuffer.toString('base64'); - return `data:${contentType};base64,${base64}`; + return await readFile(fullPath); } catch (error) { - this.logger.error(`Failed to read file for base64 ${filePath}: ${error}`); + this.logger.error(`Failed to read test file ${filePath}: ${error}`); return null; } } + /** + * Get base64 data URL for a test file + */ + private async getTestFileBase64(filePath: string, contentType: string): Promise { + const fileBuffer = await this.getTestFileBuffer(filePath); + if (!fileBuffer) return null; + + const base64 = fileBuffer.toString('base64'); + return `data:${contentType};base64,${base64}`; + } + /** * Test image or PDF support with both URL and base64 forms in parallel * Returns detailed support info: { url: boolean, base64: boolean } @@ -504,7 +522,7 @@ export class SettingOpenApiService { // Handle image generation model testing if (testImageGeneration) { - // Gemini image models via Gateway use generateText, not experimental_generateImage + // Gemini image models via Gateway use generateText, not generateImage throw new CustomHttpException( 'Image generation testing not supported for AI Gateway models yet', HttpErrorCode.VALIDATION_ERROR @@ -527,6 +545,11 @@ export class SettingOpenApiService { }; } + const shouldTestImageGeneration = this.shouldTestImageGenerationModel( + type, + model, + testImageGeneration + ); const provider = modelProviders[type as keyof typeof modelProviders]; const providerOptions = getAdaptedProviderOptions(type, { name: model, @@ -538,7 +561,7 @@ export class SettingOpenApiService { } as never) as OpenAIProvider; // Handle image generation model testing - if (testImageGeneration) { + if (shouldTestImageGeneration) { return await this.testImageGenerationModel(modelProvider, model, type, testImageToImage); } @@ -570,6 +593,20 @@ export class SettingOpenApiService { } } + private shouldTestImageGenerationModel( + providerType: LLMProviderType, + model: string, + testImageGeneration?: boolean + ): boolean { + if (testImageGeneration != null) return testImageGeneration; + + const config = + providerType === LLMProviderType.AI_GATEWAY + ? getImageModelConfigByGatewayId(model) + : getImageModelConfig(providerType, model); + return config?.modelType === 'image'; + } + private async testImageGenerationModel( modelProvider: OpenAIProvider, model: string, @@ -582,33 +619,38 @@ export class SettingOpenApiService { return await this.testGoogleImageGeneration(modelProvider, model, testImageToImage); } - // OpenAI-style image generation (DALL-E, etc.) - + // OpenAI-style image generation (DALL-E, GPT Image, etc.) const imageModel = modelProvider.image(model); + const size = this.getImageGenerationTestSize(providerType, model); + const sizeOptions = size ? { size } : {}; if (testImageToImage) { + const testImageBuffer = await this.getTestFileBuffer(testImagePath); + if (!testImageBuffer) { + return { + success: false, + response: 'Test image file not found', + }; + } + // Test image-to-image: provide an image as input // Note: Not all image models support this, so we catch errors gracefully - const testImageUrl = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; - await experimental_generateImage({ + await generateImage({ model: imageModel, - prompt: 'A simple test image', - n: 1, - size: '256x256', - providerOptions: { - openai: { - image: testImageUrl, - }, + prompt: { + text: 'Create a very simple variation of this image.', + images: [testImageBuffer], }, + n: 1, + ...sizeOptions, }); } else { // Test basic text-to-image generation - await experimental_generateImage({ + await generateImage({ model: imageModel, prompt: 'A simple test: draw a small red circle', n: 1, - size: '256x256', + ...sizeOptions, }); } @@ -619,6 +661,7 @@ export class SettingOpenApiService { : 'Image generation successful', }; } catch (error) { + this.logger.error('testImageGenerationModel Error', (error as Error)?.stack); const message = error instanceof Error ? error.message : 'Image generation failed'; return { success: false, @@ -627,6 +670,15 @@ export class SettingOpenApiService { } } + private getImageGenerationTestSize( + providerType: LLMProviderType, + model: string + ): IImageSize | undefined { + const config = getImageModelConfig(providerType, model); + if (!config || config.modelType !== 'image') return undefined; + return config.defaultSize ?? config.supportedSizes?.[0]; + } + /** * Test Google Gemini native image generation models * These models use generateText with responseModalities: ['TEXT', 'IMAGE'] @@ -949,7 +1001,7 @@ export class SettingOpenApiService { params: { url, instanceId: setting.instanceId || '', - version: process.env.NEXT_PUBLIC_BUILD_VERSION || '', + version: resolveBuildVersion(), deployedAt, }, headers: { diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts index c0b9a799fc..9233684794 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts @@ -199,13 +199,15 @@ export class TableOpenApiService { const tableVo = await this.createTableMeta(baseId, tableRo); const tableId = tableVo.id; - const preparedFields = await this.prepareFields(tableId, tableRo.fields); - - // set the first field to be the primary field if not set - if (!preparedFields.find((field) => field.isPrimary)) { - preparedFields[0].isPrimary = true; + // Mark the first field as primary BEFORE prepareFields so the validation in + // prepareCreateFields catches bad-type / lookup-ish primaries from internal callers + // (template/import/AI) that don't go through the prepareCreateTableRo pipe. + if (tableRo.fields.length && !tableRo.fields.find((field) => (field as IFieldVo).isPrimary)) { + (tableRo.fields[0] as IFieldVo).isPrimary = true; } + const preparedFields = await this.prepareFields(tableId, tableRo.fields); + // create teable should not set computed field isPending, because noting need to calculate when create preparedFields.forEach((field) => delete field.isPending); await this.createFields(tableId, preparedFields); diff --git a/apps/nestjs-backend/src/features/table/table-index.service.ts b/apps/nestjs-backend/src/features/table/table-index.service.ts index 2c4a79e1b2..fe7f4bcdd7 100644 --- a/apps/nestjs-backend/src/features/table/table-index.service.ts +++ b/apps/nestjs-backend/src/features/table/table-index.service.ts @@ -1,6 +1,6 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; -import { CellValueType, FieldType, HttpErrorCode } from '@teable/core'; +import { Injectable, Logger } from '@nestjs/common'; +import { FieldType, HttpErrorCode } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { TableIndex } from '@teable/openapi'; import type { IGetAbnormalVo, ITableIndexType, IToggleIndexRo } from '@teable/openapi'; @@ -15,8 +15,6 @@ import type { IClsStore } from '../../types/cls'; import type { IFieldInstance } from '../field/model/factory'; import { createFieldInstanceByRaw } from '../field/model/factory'; -const unSupportTableIndex = 'Unsupport table index type'; - @Injectable() export class TableIndexService { private logger = new Logger(TableIndexService.name); @@ -175,10 +173,7 @@ export class TableIndexService { } async createSearchFieldSingleIndex(tableId: string, fieldInstance: IFieldInstance) { - if ( - fieldInstance.cellValueType === CellValueType.DateTime || - fieldInstance.type === FieldType.Button - ) { + if (fieldInstance.type === FieldType.Button) { return; } const tableRaw = await this.prismaService.txClient().tableMeta.findFirstOrThrow({ diff --git a/apps/nestjs-backend/src/features/trash/trash.service.ts b/apps/nestjs-backend/src/features/trash/trash.service.ts index 323bbc4613..ab456e179b 100644 --- a/apps/nestjs-backend/src/features/trash/trash.service.ts +++ b/apps/nestjs-backend/src/features/trash/trash.service.ts @@ -686,14 +686,14 @@ export class TrashService { const base = await this.prismaService.txClient().base.findUnique({ where: { id: baseId, deletedTime: null }, - select: { spaceId: true }, + select: { spaceId: true, v2Enabled: true }, }); if (!base?.spaceId) { return { useV2: false, reason: 'disabled', baseId, tableId: trash.resourceId }; } - const decision = await this.canaryService.shouldUseV2WithReason(base.spaceId, 'restoreTable'); + const decision = await this.canaryService.shouldUseV2ForBaseWithReason(base, 'restoreTable'); return { ...decision, baseId, diff --git a/apps/nestjs-backend/src/features/v2/v2-base-node-compat.service.ts b/apps/nestjs-backend/src/features/v2/v2-base-node-compat.service.ts index 6f496a594f..2828bc815f 100644 --- a/apps/nestjs-backend/src/features/v2/v2-base-node-compat.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-base-node-compat.service.ts @@ -13,9 +13,11 @@ import { type Result, } from '@teable/v2-core'; import type { DependencyContainer } from '@teable/v2-di'; +import { ClsService } from 'nestjs-cls'; import { PerformanceCacheService } from '../../performance-cache'; import { generateBaseNodeListCacheKey } from '../../performance-cache/generate-keys'; import { ShareDbService } from '../../share-db/share-db.service'; +import type { IClsStore } from '../../types/cls'; import { presenceHandler } from '../base-node/helper'; import { V2ProjectionRegistrar, type IV2ProjectionRegistrar } from './v2-projection-registrar'; @@ -28,13 +30,18 @@ export class V2TableBaseNodeProjection { constructor( private readonly performanceCacheService: PerformanceCacheService, - private readonly shareDbService: ShareDbService + private readonly shareDbService: ShareDbService, + private readonly cls: ClsService ) {} async handle( _context: IExecutionContext, event: TableCreated | TableTrashed | TableDeleted | TableRestored ): Promise> { + const ignoreBaseNodeListener = this.cls.get('ignoreBaseNodeListener'); + if (ignoreBaseNodeListener) { + return ok(undefined); + } const baseId = event.baseId.toString(); this.performanceCacheService.del(generateBaseNodeListCacheKey(baseId)); @@ -59,7 +66,8 @@ export class V2BaseNodeCompatService implements IV2ProjectionRegistrar { constructor( private readonly performanceCacheService: PerformanceCacheService, - private readonly shareDbService: ShareDbService + private readonly shareDbService: ShareDbService, + private readonly cls: ClsService ) {} registerProjections(container: DependencyContainer): void { @@ -67,7 +75,7 @@ export class V2BaseNodeCompatService implements IV2ProjectionRegistrar { container.registerInstance( V2TableBaseNodeProjection, - new V2TableBaseNodeProjection(this.performanceCacheService, this.shareDbService) + new V2TableBaseNodeProjection(this.performanceCacheService, this.shareDbService, this.cls) ); } } diff --git a/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts b/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts index b7feaf5f49..c6dd4f8ee6 100644 --- a/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts +++ b/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts @@ -12,6 +12,7 @@ import type { ILinkFieldOptions, IPluginViewOptions, IViewPropertyKeys, + CellValueType, ISort, IGroup, TableDomain, @@ -28,7 +29,7 @@ import { generatePluginInstallId, generateOperationId, extractFieldIdsFromFilter, - validateFilterOperatorModeCompatibility, + analyzeFilterValidationIssues, HttpErrorCode, } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; @@ -289,7 +290,7 @@ export class ViewOpenApiService { if (fieldIds.length > 0) { const fields = await this.prismaService.field.findMany({ where: { tableId, id: { in: fieldIds } }, - select: { id: true, type: true }, + select: { id: true, type: true, cellValueType: true, isMultipleCellValue: true }, }); // Check for unsupported Button type fields @@ -306,15 +307,26 @@ export class ViewOpenApiService { ); } - // Validate operator + mode compatibility for date fields - const fieldTypeMap = fields.reduce( + // Validate filter compatibility with the same shared analyzer used by SDK/query execution. + const fieldMetaMap = fields.reduce( (acc, f) => { - acc[f.id] = f.type as FieldType; + acc[f.id] = { + type: f.type as FieldType, + cellValueType: f.cellValueType as CellValueType, + isMultipleCellValue: Boolean(f.isMultipleCellValue), + }; return acc; }, - {} as Record + {} as Record< + string, + { + type: FieldType; + cellValueType: CellValueType; + isMultipleCellValue: boolean; + } + > ); - const validationErrors = validateFilterOperatorModeCompatibility(filter, fieldTypeMap); + const validationErrors = analyzeFilterValidationIssues(filter, fieldMetaMap); if (validationErrors.length > 0) { throw new CustomHttpException(validationErrors[0].message, HttpErrorCode.VALIDATION_ERROR, { localization: { diff --git a/apps/nestjs-backend/src/instrument.ts b/apps/nestjs-backend/src/instrument.ts index 12b97fc61d..0d3fea43c3 100644 --- a/apps/nestjs-backend/src/instrument.ts +++ b/apps/nestjs-backend/src/instrument.ts @@ -1,5 +1,6 @@ import { Logger } from '@nestjs/common'; import * as Sentry from '@sentry/nestjs'; +import { resolveBuildVersion } from './utils/build-version'; if (process.env.BACKEND_SENTRY_DSN) { const traceRate = Number(process.env.BACKEND_SENTRY_TRACE_SAMPLING_RATE ?? 0.1); @@ -11,7 +12,7 @@ if (process.env.BACKEND_SENTRY_DSN) { _experiments: { enableMetrics: true, }, - release: process.env.NEXT_PUBLIC_BUILD_VERSION || 'development', + release: resolveBuildVersion() || 'development', environment: process.env.NODE_ENV || 'development', defaultIntegrations: false, // Only keep error-related integrations, tracing is handled by OTEL diff --git a/apps/nestjs-backend/src/share-db/share-db.adapter.ts b/apps/nestjs-backend/src/share-db/share-db.adapter.ts index 1f66f51f57..3e2519a5cb 100644 --- a/apps/nestjs-backend/src/share-db/share-db.adapter.ts +++ b/apps/nestjs-backend/src/share-db/share-db.adapter.ts @@ -195,6 +195,21 @@ export class ShareDbAdapter extends ShareDb.DB { }, {}); } + private snapshots2MapWithMissing( + ids: string[], + snapshotData: ISnapshotBase[] + ): Record { + const snapshotDataMap = new Map(snapshotData.map((snapshot) => [snapshot.id, snapshot])); + const snapshots = ids.map((id) => { + const snapshot = snapshotDataMap.get(id); + if (!snapshot) { + return new Snapshot(id, 0, null, undefined, null); + } + return new Snapshot(snapshot.id, snapshot.v, snapshot.type, snapshot.data, null); + }); + return this.snapshots2Map(snapshots); + } + // Get the named document from the database. The callback is called with (err, // snapshot). A snapshot with a version of zero is returned if the document // has never been created in the database. @@ -216,16 +231,7 @@ export class ShareDbAdapter extends ShareDb.DB { // For internal (server-side) connections without auth, resolve field docs directly if (docType === IdPrefix.Field && this.fieldServiceInner) { const snapshotData = await this.fieldServiceInner.getSnapshotBulk(collectionId, ids); - if (snapshotData.length) { - const snapshots = snapshotData.map( - (snapshot) => - new Snapshot(snapshot.id, snapshot.v, snapshot.type, snapshot.data, null) - ); - callback(null, this.snapshots2Map(snapshots)); - } else { - const snapshots = ids.map((id) => new Snapshot(id, 0, null, undefined, null)); - callback(null, this.snapshots2Map(snapshots)); - } + callback(null, this.snapshots2MapWithMissing(ids, snapshotData)); return; } throw new UnauthorizedException('Unauthorized request not authorized'); @@ -243,22 +249,7 @@ export class ShareDbAdapter extends ShareDb.DB { ); } ); - if (snapshotData.length) { - const snapshots = snapshotData.map( - (snapshot) => - new Snapshot( - snapshot.id, - snapshot.v, - snapshot.type, - snapshot.data, - null // TODO: metadata - ) - ); - callback(null, this.snapshots2Map(snapshots)); - } else { - const snapshots = ids.map((id) => new Snapshot(id, 0, null, undefined, null)); - callback(null, this.snapshots2Map(snapshots)); - } + callback(null, this.snapshots2MapWithMissing(ids, snapshotData)); } catch (err) { this.logger.error(err); callback(exceptionParse(err as Error)); diff --git a/apps/nestjs-backend/src/share-db/share-db.spec.ts b/apps/nestjs-backend/src/share-db/share-db.spec.ts index 649cd2fb41..1820036315 100644 --- a/apps/nestjs-backend/src/share-db/share-db.spec.ts +++ b/apps/nestjs-backend/src/share-db/share-db.spec.ts @@ -1,6 +1,9 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; +import { IdPrefix } from '@teable/core'; +import { vi } from 'vitest'; import { GlobalModule } from '../global/global.module'; +import { ShareDbAdapter } from './share-db.adapter'; import { ShareDbModule } from './share-db.module'; import { ShareDbService } from './share-db.service'; @@ -19,6 +22,56 @@ describe('ShareDb', () => { expect(provider).toBeDefined(); }); + it('returns empty snapshots for stale query ids missing from snapshot bulk', async () => { + const cls = { + get: vi.fn(() => undefined), + runWith: vi.fn((_store, fn) => fn()), + }; + const recordService = { + getSnapshotBulk: vi.fn().mockResolvedValue([ + { + id: 'recExisting', + v: 2, + type: 'json0', + data: { id: 'recExisting', fields: {} }, + }, + ]), + }; + const adapter = new ShareDbAdapter( + cls as never, + {} as never, + recordService as never, + {} as never, + {} as never, + {} as never + ); + + const snapshots = await new Promise< + Record + >((resolve, reject) => { + adapter.getSnapshotBulk( + `${IdPrefix.Record}_tblTest`, + ['recExisting', 'recDeleted'], + undefined, + { cookie: 'teable-session=test' }, + (error, data) => { + if (error) { + reject(error); + return; + } + resolve(data as Record); + } + ); + }); + + expect(snapshots.recExisting.v).toBe(2); + expect(snapshots.recDeleted).toMatchObject({ + v: 0, + type: null, + data: undefined, + }); + }); + // it('create simple document', (done) => { // const randomTitle = `B:${Math.floor(Math.random() * 1000)}`; // const doc = provider.connect().get('books', randomTitle); diff --git a/apps/nestjs-backend/src/types/cls.ts b/apps/nestjs-backend/src/types/cls.ts index 788968b86e..40f6dfa245 100644 --- a/apps/nestjs-backend/src/types/cls.ts +++ b/apps/nestjs-backend/src/types/cls.ts @@ -10,8 +10,10 @@ import type { IDataLoaderCache } from './data-loader'; export type V2Reason = | 'env_force_v2_all' | 'config_force_v2_all' + | 'new_base' | 'header_override' | 'space_feature' + | 'unsupported_feature' | 'disabled' | 'feature_not_enabled' | 'no_feature'; @@ -51,6 +53,7 @@ export interface IClsStore extends ClsStore { rawOpMaps?: IRawOpMap[]; }; shareViewId?: string; + baseShareId?: string; permissions: Action[]; // this is used to check if the user is in the space when the user operate in a space spaceId?: string; diff --git a/apps/nestjs-backend/src/types/i18n.generated.ts b/apps/nestjs-backend/src/types/i18n.generated.ts index d990617ec4..fff4d1895b 100644 --- a/apps/nestjs-backend/src/types/i18n.generated.ts +++ b/apps/nestjs-backend/src/types/i18n.generated.ts @@ -358,6 +358,7 @@ export type I18nTranslations = { }; "settings": { "title": string; + "allSetting": string; "personal": { "title": string; }; @@ -1414,6 +1415,10 @@ export type I18nTranslations = { "title": string; "message": string; }; + "failedSummary": { + "title": string; + "message": string; + }; }; "billing": { "title": string; @@ -2087,6 +2092,7 @@ export type I18nTranslations = { }; "invalidateSelected": string; "invalidateSelectedTips": string; + "invalidConditionTip": string; "default": { "empty": string; "placeholder": string; @@ -2195,6 +2201,7 @@ export type I18nTranslations = { "showAll": string; "hideAll": string; "primaryKey": string; + "notInCurrentView": string; }; "expandRecord": { "copy": string; @@ -3060,6 +3067,7 @@ export type I18nTranslations = { "whiteListCheckError": string; "databaseConnectionFailed": string; "executeQuerySqlFailed": string; + "sqlSyntaxError": string; "readOnlyCheckFailed": string; }; "permission": { @@ -3163,6 +3171,8 @@ export type I18nTranslations = { "clickCountReachedMaxCount": string; "notSupportReset": string; }; + "primaryCannotBeLookup": string; + "primaryFieldAlreadyExists": string; }; "view": { "notFound": string; @@ -3361,6 +3371,7 @@ export type I18nTranslations = { "zipFileTooLarge": string; "invalidZip": string; "domainAlreadyInUse": string; + "domainReserved": string; }; "reward": { "notFound": string; @@ -3384,6 +3395,7 @@ export type I18nTranslations = { "linkedInAuthorNotFound": string; "fetchLinkedInUserFailed": string; "domainAlreadyInUse": string; + "domainReserved": string; }; }; "aiError": { @@ -3982,6 +3994,7 @@ export type I18nTranslations = { "sortMissingWarningTitle": string; "sortMissingWarningDescription": string; }; + "fieldUnavailable": string; "lastModifiedScope": string; "lastModifiedAll": string; "lastModifiedSpecific": string; @@ -4432,6 +4445,7 @@ export type I18nTranslations = { "foreignKeyOrphanRows": string; "junctionForeignKeyTargetTableMissing": string; "junctionForeignKeyOrphanRows": string; + "autoRule": string; }; "manual": { "apply": string; @@ -4450,10 +4464,25 @@ export type I18nTranslations = { }; "manualRepairPreview": string; "manualRepairPreviewTip": string; + "repairPreviewTitle": string; + "repairPreviewDescription": string; + "repairPreviewTooltip": string; + "repairPreviewMissingTable": string; + "repairPreviewUnavailableStatus": string; + "repairPreviewWhat": string; + "repairPreviewTarget": string; + "repairPreviewPrinciple": string; + "repairPreviewNoPrinciple": string; + "repairPreviewSql": string; + "repairPreviewNoSql": string; + "repairPreviewCannotConfirm": string; + "repairPreviewParameters": string; + "repairPreviewConfirm": string; }; - "type": string; - "message": string; "errorType": { + "InvalidPrimaryLookup": string; + "InvalidPrimaryType": string; + "MissingPrimary": string; "ForeignTableNotFound": string; "ForeignKeyNotFound": string; "SelfKeyNotFound": string; @@ -4466,6 +4495,8 @@ export type I18nTranslations = { "EmptyString": string; "InvalidFilterOperator": string; }; + "type": string; + "message": string; }; "index": { "description": string; @@ -4854,6 +4885,7 @@ export type I18nTranslations = { "expand": string; "history": string; "close": string; + "noModel": string; "addAttachment": string; "noHistory": string; "noFoundHistory": string; @@ -4871,7 +4903,14 @@ export type I18nTranslations = { "emptyContext": string; "selectionRows": string; }; + "mention": { + "tables": string; + "apps": string; + "workflows": string; + "folders": string; + }; "inputPlaceholder": string; + "inputPlaceholderFiles": string; "thought": string; "meta": { "input": string; @@ -4913,6 +4952,8 @@ export type I18nTranslations = { "clearChatConfirmTitle": string; "clearChatConfirmDesc": string; "dontShowAgain": string; + "modelSwitchTitle": string; + "modelSwitchHint": string; "sandboxExpiry": { "expiresIn": string; "reset": string; @@ -4959,6 +5000,9 @@ export type I18nTranslations = { "retry": { "interrupted": string; "button": string; + "offline": string; + "pausedHidden": string; + "maxAttemptsReached": string; }; "guide": { "goToScenario": string; @@ -4994,6 +5038,8 @@ export type I18nTranslations = { "advancedOptions": string; "namingFieldLabel": string; "selectField": string; + "noPrefixOption": string; + "noPrefixOptionDesc": string; "groupByRow": string; "groupByRowTip": string; }; diff --git a/apps/nestjs-backend/src/utils/build-version.ts b/apps/nestjs-backend/src/utils/build-version.ts new file mode 100644 index 0000000000..bed1651f7e --- /dev/null +++ b/apps/nestjs-backend/src/utils/build-version.ts @@ -0,0 +1,11 @@ +const buildVersionEnvKeys = ['BUILD_VERSION', 'NEXT_PUBLIC_BUILD_VERSION', 'APP_VERSION'] as const; + +export const resolveBuildVersion = () => { + for (const key of buildVersionEnvKeys) { + const value = process.env[key]?.trim(); + if (value) { + return value; + } + } + return ''; +}; diff --git a/apps/nestjs-backend/test/access-token.e2e-spec.ts b/apps/nestjs-backend/test/access-token.e2e-spec.ts index 3f587c79e9..6a0c748300 100644 --- a/apps/nestjs-backend/test/access-token.e2e-spec.ts +++ b/apps/nestjs-backend/test/access-token.e2e-spec.ts @@ -1,5 +1,6 @@ /* eslint-disable sonarjs/no-duplicate-string */ import type { INestApplication } from '@nestjs/common'; +import type { Action } from '@teable/core'; import { Role } from '@teable/core'; import type { CreateAccessTokenRo, @@ -97,6 +98,16 @@ describe('OpenAPI AccessTokenController (e2e)', () => { expect(error?.message).contain('expiredTime'); }); + it('create access token with invalid scopes', async () => { + const error = await getError(() => + createAccessToken({ + ...defaultCreateRo, + scopes: ['table|write', 'invalid_scope'] as unknown as CreateAccessTokenRo['scopes'], + }) + ); + expect(error?.status).toEqual(400); + }); + it('check access token', async () => { const accessToken = '1234567890'; const res = splitAccessToken(accessToken); diff --git a/apps/nestjs-backend/test/auth.e2e-spec.ts b/apps/nestjs-backend/test/auth.e2e-spec.ts index 149c4044cc..34d46280bd 100644 --- a/apps/nestjs-backend/test/auth.e2e-spec.ts +++ b/apps/nestjs-backend/test/auth.e2e-spec.ts @@ -426,7 +426,7 @@ describe('Auth Controller (e2e)', () => { // token const tokenRes = await userAxios.post(CREATE_ACCESS_TOKEN, { name: 'test-delete-user-token', - scopes: ['record:read'], + scopes: ['record|read'], expiredTime: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(), }); const accessTokenId = tokenRes.data.id; diff --git a/apps/nestjs-backend/test/base-node.e2e-spec.ts b/apps/nestjs-backend/test/base-node.e2e-spec.ts index 70c30673ab..90c4d0040c 100644 --- a/apps/nestjs-backend/test/base-node.e2e-spec.ts +++ b/apps/nestjs-backend/test/base-node.e2e-spec.ts @@ -39,7 +39,6 @@ const testFolder = 'Test Folder'; const updatedName = 'Updated Name'; const testTableName = 'Test Table'; const windowIdHeader = 'x-window-id'; -const isForceV2 = process.env.FORCE_V2_ALL === 'true'; describe('BaseNodeController (e2e) /api/base/:baseId/node', () => { let app: INestApplication; @@ -185,9 +184,9 @@ describe('BaseNodeController (e2e) /api/base/:baseId/node', () => { ); expect(response.status).toBe(201); - expect(response.headers['x-teable-v2']).toBe(isForceV2 ? 'true' : 'false'); + expect(response.headers['x-teable-v2']).toBe('true'); expect(response.headers['x-teable-v2-feature']).toBe('createTable'); - expect(response.headers['x-teable-v2-reason']).toBeTruthy(); + expect(response.headers['x-teable-v2-reason']).toBe('new_base'); nodesToCleanup.push(response.data.id); }); @@ -384,9 +383,9 @@ describe('BaseNodeController (e2e) /api/base/:baseId/node', () => { ); expect(response.status).toBe(201); - expect(response.headers['x-teable-v2']).toBe(isForceV2 ? 'true' : 'false'); + expect(response.headers['x-teable-v2']).toBe('true'); expect(response.headers['x-teable-v2-feature']).toBe('createTable'); - expect(response.headers['x-teable-v2-reason']).toBeTruthy(); + expect(response.headers['x-teable-v2-reason']).toBe('new_base'); nodesToCleanup.push(response.data.id); @@ -673,9 +672,9 @@ describe('BaseNodeController (e2e) /api/base/:baseId/node', () => { ); expect(response.status).toBe(200); - expect(response.headers['x-teable-v2']).toBe(isForceV2 ? 'true' : 'false'); + expect(response.headers['x-teable-v2']).toBe('true'); expect(response.headers['x-teable-v2-feature']).toBe('deleteTable'); - expect(response.headers['x-teable-v2-reason']).toBeTruthy(); + expect(response.headers['x-teable-v2-reason']).toBe('new_base'); const error = await getError(() => getBaseNode(baseId, table.data.id)); expect(error?.status).toBeGreaterThanOrEqual(400); @@ -1032,9 +1031,9 @@ describe('BaseNodeController (e2e) /api/base/:baseId/node', () => { ); expect(response.status).toBe(201); - expect(response.headers['x-teable-v2']).toBe(isForceV2 ? 'true' : 'false'); + expect(response.headers['x-teable-v2']).toBe('true'); expect(response.headers['x-teable-v2-feature']).toBe('duplicateTable'); - expect(response.headers['x-teable-v2-reason']).toBeTruthy(); + expect(response.headers['x-teable-v2-reason']).toBe('new_base'); nodesToCleanup.push(response.data.id); expect(response.data.resourceMeta?.name).toBe('Duplicated Table Via Node Route'); @@ -1410,6 +1409,101 @@ describe('BaseNodeController (e2e) /api/base/:baseId/node', () => { expect(response.data).toHaveProperty('maxFolderDepth'); expect(response.data.maxFolderDepth).toBe(2); }); + + it('should fail when moving folder-with-subfolder into another root folder via parentId', async () => { + const folderA = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Folder, + name: 'Subtree Depth A', + }); + nodesToCleanup.push(folderA.data.id); + + const subfolderB = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Folder, + name: 'Subtree Depth B', + parentId: folderA.data.id, + }); + nodesToCleanup.push(subfolderB.data.id); + + const folderC = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Folder, + name: 'Subtree Depth C', + }); + nodesToCleanup.push(folderC.data.id); + + // Moving folderA (which contains subfolderB) into folderC + // Result would be: C(1) > A(2) > B(3) — depth 3 exceeds maxFolderDepth=2 + const error = await getError(() => + moveBaseNode(baseId, folderA.data.id, { + parentId: folderC.data.id, + }) + ); + + expect(error?.status).toBe(400); + }); + + it('should fail when moving folder-with-subfolder via anchorId inside a folder', async () => { + const folderD = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Folder, + name: 'Subtree Anchor D', + }); + nodesToCleanup.push(folderD.data.id); + + const subfolderE = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Folder, + name: 'Subtree Anchor E', + parentId: folderD.data.id, + }); + nodesToCleanup.push(subfolderE.data.id); + + const folderF = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Folder, + name: 'Subtree Anchor F', + }); + nodesToCleanup.push(folderF.data.id); + + // Create a child inside folderF to use as anchor + const childInF = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Table in F', + parentId: folderF.data.id, + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(childInF.data.id); + + // Moving folderD (with subfolderE) next to childInF (inside folderF) + // Result would be: F(1) > D(2) > E(3) — depth 3 exceeds maxFolderDepth=2 + const error = await getError(() => + moveBaseNode(baseId, folderD.data.id, { + anchorId: childInF.data.id, + position: 'after', + }) + ); + + expect(error?.status).toBe(400); + }); + + it('should allow moving leaf folder into another root folder', async () => { + const targetFolder = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Folder, + name: 'Target Folder', + }); + nodesToCleanup.push(targetFolder.data.id); + + const leafFolder = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Folder, + name: 'Leaf Folder', + }); + nodesToCleanup.push(leafFolder.data.id); + + // Moving a leaf folder (no children) into targetFolder — depth=2, within limit + const response = await moveBaseNode(baseId, leafFolder.data.id, { + parentId: targetFolder.data.id, + }); + + expect(response.data.id).toBe(leafFolder.data.id); + expect(response.data.parentId).toBe(targetFolder.data.id); + }); }); describe('Permission tests', () => { @@ -1907,4 +2001,170 @@ describe('BaseNodeController (e2e) /api/base/:baseId/node', () => { }); }); }); + + describe('Resource ID resolution (using resourceId instead of nodeId)', () => { + const nodesToCleanup: string[] = []; + + afterEach(async () => { + for (const nodeId of [...nodesToCleanup].reverse()) { + await deleteBaseNode(baseId, nodeId); + } + nodesToCleanup.length = 0; + }); + + it('should get node by resourceId (tableId)', async () => { + const node = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Resolve Get Test', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(node.data.id); + + const response = await getBaseNode(baseId, node.data.resourceId); + + expect(response.data.id).toBe(node.data.id); + expect(response.data.resourceId).toBe(node.data.resourceId); + expect(response.data.resourceMeta?.name).toBe('Resolve Get Test'); + }); + + it('should update node by resourceId', async () => { + const node = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Resolve Update Test', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(node.data.id); + + const response = await updateBaseNode(baseId, node.data.resourceId, { + name: 'Resolve Updated', + }); + + expect(response.data.id).toBe(node.data.id); + expect(response.data.resourceMeta?.name).toBe('Resolve Updated'); + }); + + it('should duplicate node by resourceId', async () => { + const node = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Resolve Duplicate Test', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(node.data.id); + + const duplicate = await duplicateBaseNode(baseId, node.data.resourceId, { + name: 'Resolve Duplicated', + }); + nodesToCleanup.push(duplicate.data.id); + + expect(duplicate.data.id).not.toBe(node.data.id); + expect(duplicate.data.resourceMeta?.name).toBe('Resolve Duplicated'); + }); + + it('should move node by resourceId', async () => { + const folder = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Folder, + name: 'Resolve Move Folder', + }); + nodesToCleanup.push(folder.data.id); + + const node = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Resolve Move Test', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(node.data.id); + + const response = await moveBaseNode(baseId, node.data.resourceId, { + parentId: folder.data.id, + }); + + expect(response.data.id).toBe(node.data.id); + expect(response.data.parentId).toBe(folder.data.id); + }); + + it('should delete node by resourceId', async () => { + const node = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Resolve Delete Test', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + + await deleteBaseNode(baseId, node.data.resourceId); + + const error = await getError(() => getBaseNode(baseId, node.data.id)); + expect(error?.status).toBeGreaterThanOrEqual(400); + }); + + it('should move node with resourceId as parentId', async () => { + const folder = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Folder, + name: 'Resolve Parent Folder', + }); + nodesToCleanup.push(folder.data.id); + + const node = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Resolve Parent Move Test', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(node.data.id); + + const response = await moveBaseNode(baseId, node.data.id, { + parentId: folder.data.resourceId, + }); + + expect(response.data.id).toBe(node.data.id); + expect(response.data.parentId).toBe(folder.data.id); + }); + + it('should create node with resourceId as parentId', async () => { + const folder = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Folder, + name: 'Resolve Create Parent Folder', + }); + nodesToCleanup.push(folder.data.id); + + const node = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Resolve Create In Folder Test', + parentId: folder.data.resourceId, + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(node.data.id); + + expect(node.data.parentId).toBe(folder.data.id); + }); + + it('should move node with resourceId as anchorId', async () => { + const anchor = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Anchor Table', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(anchor.data.id); + + const node = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Movable Table', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(node.data.id); + + const response = await moveBaseNode(baseId, node.data.id, { + anchorId: anchor.data.resourceId, + position: 'before', + }); + + expect(response.data.id).toBe(node.data.id); + }); + }); }); diff --git a/apps/nestjs-backend/test/computed-orchestrator.e2e-spec.ts b/apps/nestjs-backend/test/computed-orchestrator.e2e-spec.ts index 422a6081f0..efdad47b33 100644 --- a/apps/nestjs-backend/test/computed-orchestrator.e2e-spec.ts +++ b/apps/nestjs-backend/test/computed-orchestrator.e2e-spec.ts @@ -56,6 +56,7 @@ dayjs.extend(utc); dayjs.extend(timezone); const isForceV2 = process.env.FORCE_V2_ALL === 'true'; +let isV2Mode = isForceV2; describe('Computed Orchestrator (e2e)', () => { let app: INestApplication; @@ -78,6 +79,11 @@ describe('Computed Orchestrator (e2e)', () => { tableDomainQueryService = app.get(TableDomainQueryService); recordDialect = app.get(RECORD_QUERY_DIALECT_SYMBOL as any); v2ContainerService = app.get(V2ContainerService); + const base = await prisma.base.findUnique({ + where: { id: baseId }, + select: { v2Enabled: true }, + }); + isV2Mode = isForceV2 || Boolean(base?.v2Enabled); }); afterAll(async () => { @@ -89,7 +95,7 @@ describe('Computed Orchestrator (e2e)', () => { * This ensures all async computed updates are completed before assertions. */ async function processV2Outbox(times = 1): Promise { - if (!isForceV2) return; + if (!isV2Mode) return; const container = await v2ContainerService.getContainer(); const drainService = container.resolve( @@ -132,7 +138,7 @@ describe('Computed Orchestrator (e2e)', () => { _count: number = 1 ) { return async function fn(fn: () => Promise) { - if (isForceV2) { + if (isV2Mode) { // In v2 mode, execute and process outbox to ensure async updates complete const result = await fn(); await processV2Outbox(); @@ -143,11 +149,17 @@ describe('Computed Orchestrator (e2e)', () => { }; } - async function runAndCaptureRecordUpdates(fn: () => Promise): Promise<{ + async function runAndCaptureRecordUpdates( + fn: () => Promise, + options?: { + isComplete?: (events: any[]) => boolean; + timeoutMs?: number; + } + ): Promise<{ result: T; events: any[]; }> { - if (isForceV2) { + if (isV2Mode) { // In v2 mode, execute and process outbox to ensure async updates complete // Events are not emitted in V2 mode, so we return an empty array const result = await fn(); @@ -160,8 +172,31 @@ describe('Computed Orchestrator (e2e)', () => { eventEmitterService.eventEmitter.on(Events.TABLE_RECORD_UPDATE, handler); try { const result = await fn(); - // allow async emission to flush - await new Promise((r) => setTimeout(r, 50)); + // Computed updates may emit a short burst of async record.update events after + // the originating mutation resolves. Keep listening until the stream settles. + const stableWindowMs = 100; + const pollIntervalMs = 25; + const deadline = Date.now() + (options?.timeoutMs ?? 2000); + let stableSince = Date.now(); + let lastCount = events.length; + + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, pollIntervalMs)); + + if (events.length !== lastCount) { + lastCount = events.length; + stableSince = Date.now(); + continue; + } + + if ( + Date.now() - stableSince >= stableWindowMs && + (!options?.isComplete || options.isComplete(events)) + ) { + break; + } + } + return { result, events }; } finally { eventEmitterService.eventEmitter.off(Events.TABLE_RECORD_UPDATE, handler); @@ -279,7 +314,7 @@ describe('Computed Orchestrator (e2e)', () => { })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const event = payloads[0] as any; // RecordUpdateEvent expect(event.payload.tableId).toBe(table.id); const changes = event.payload.record.fields as Record< @@ -498,7 +533,7 @@ IF( })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const event = payloads[0] as any; const recs = Array.isArray(event.payload.record) ? event.payload.record @@ -556,7 +591,7 @@ IF( })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const event = payloads[0] as any; expect(event.payload.tableId).toBe(table.id); const rec = Array.isArray(event.payload.record) @@ -976,7 +1011,7 @@ IF( }); // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const changes = findLatestRecordChangeMap(events, t2.id, t2.records[0].id); const lkpChange = assertChange(changes[lkp.id]); expectNoOldValue(lkpChange); @@ -1039,7 +1074,7 @@ IF( const symmetricFieldId = symmetric.id; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const evtOnT2 = events.find((e) => e.payload?.tableId === t2.id); expect(evtOnT2).toBeDefined(); const recT2 = Array.isArray(evtOnT2!.payload.record) @@ -1105,7 +1140,7 @@ IF( }); // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const changes = findLatestRecordChangeMap(events, t1.id, t1.records[0].id); const lkpChange = assertChange(changes[lkp.id]); expectNoOldValue(lkpChange); @@ -1160,7 +1195,7 @@ IF( }); // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const changes = findLatestRecordChangeMap(events, t1.id, t1.records[0].id); const lkpChange = assertChange(changes[lkp.id]); expectNoOldValue(lkpChange); @@ -1212,7 +1247,7 @@ IF( }); // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const changes = findLatestRecordChangeMap(events, t2.id, t2.records[0].id); const lkpChange = assertChange(changes[lkp.id]); expectNoOldValue(lkpChange); @@ -1270,7 +1305,7 @@ IF( })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { // Find T2 event const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const changes = t2Event.payload.record.fields as Record< @@ -1337,7 +1372,7 @@ IF( }); // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const t2Event = [...events] .reverse() .find((event) => event.payload.tableId === t2.id && toChangeMap(event)[roll2.id])!; @@ -1425,7 +1460,7 @@ IF( })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { // T1 const t1Event = (payloads as any[]).find((e) => e.payload.tableId === t1.id)!; const t1Changes = ( @@ -1554,7 +1589,7 @@ IF( })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const t2Changes = ( Array.isArray(t2Event.payload.record) ? t2Event.payload.record[0] : t2Event.payload.record @@ -1667,7 +1702,7 @@ IF( })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const t2Changes = ( Array.isArray(t2Event.payload.record) ? t2Event.payload.record[0] : t2Event.payload.record @@ -1762,7 +1797,7 @@ IF( } as IFieldRo); }); - if (!isForceV2) { + if (!isV2Mode) { const hostCreateEvent = creationEvents.find((e) => e.payload.tableId === host.id); expect(hostCreateEvent).toBeDefined(); const createRecordPayload = Array.isArray(hostCreateEvent!.payload.record) @@ -1804,7 +1839,7 @@ IF( (await getRow(hostDbTable, host.records[0].id))[hostFieldVo.dbFieldName] ); expect(valueAfterStatus).toEqual(2); - if (!isForceV2) { + if (!isV2Mode) { const hostFilterEvent = filterEvents.find((e) => e.payload.tableId === host.id); expect(hostFilterEvent).toBeDefined(); const filterRecordPayload = Array.isArray(hostFilterEvent!.payload.record) @@ -1825,7 +1860,7 @@ IF( (await getRow(hostDbTable, host.records[0].id))[hostFieldVo.dbFieldName] ); expect(valueAfterLookupColumnChange).toEqual(1); - if (!isForceV2) { + if (!isV2Mode) { const hostLookupEvent = lookupColumnEvents.find((e) => e.payload.tableId === host.id); expect(hostLookupEvent).toBeDefined(); const lookupRecordPayload = Array.isArray(hostLookupEvent!.payload.record) @@ -1901,21 +1936,31 @@ IF( filterSet.push(...additionalFilterItems); } - const { result: rollupField, events } = await runAndCaptureRecordUpdates(async () => { - return await createField(host.id, { - name: `Equality ${expression}`, - type: FieldType.ConditionalRollup, - options: { - foreignTableId: foreign.id, - lookupFieldId: foreignAmountId, - expression, - filter: { - conjunction: 'and', - filterSet, + const { result: rollupField, events } = await runAndCaptureRecordUpdates( + async () => { + return await createField(host.id, { + name: `Equality ${expression}`, + type: FieldType.ConditionalRollup, + options: { + foreignTableId: foreign.id, + lookupFieldId: foreignAmountId, + expression, + filter: { + conjunction: 'and', + filterSet, + }, }, - }, - } as IFieldRo); - }); + } as IFieldRo); + }, + { + isComplete: (events) => + Boolean( + findRecordChangeMap(events, host.id, aliceRecordId) && + findRecordChangeMap(events, host.id, nobodyRecordId) + ), + timeoutMs: 5000, + } + ); const hostDbTable = await getDbTableName(host.id); const hostFieldVo = (await getFields(host.id)).find((f) => f.id === rollupField.id)! as any; @@ -2106,7 +2151,7 @@ IF( const ctx = await setupEqualityConditionalRollup(expression); const { cleanup } = ctx; try { - if (!isForceV2) { + if (!isV2Mode) { const createAliceChange = findRecordChangeMap( ctx.creationEvents, ctx.host.id, @@ -2146,7 +2191,7 @@ IF( await update(ctx); }); - if (!isForceV2) { + if (!isV2Mode) { const updateAliceChange = findRecordChangeMap( updateEvents, ctx.host.id, @@ -2192,7 +2237,7 @@ IF( }); const { cleanup } = ctx; try { - if (!isForceV2) { + if (!isV2Mode) { const createAliceChange = findRecordChangeMap( ctx.creationEvents, ctx.host.id, @@ -2233,7 +2278,7 @@ IF( }); }); - if (!isForceV2) { + if (!isV2Mode) { const updateAliceChange = findRecordChangeMap( updateEvents, ctx.host.id, @@ -2310,7 +2355,7 @@ IF( } ); - if (!isForceV2) { + if (!isV2Mode) { const createAliceChange = findRecordChangeMap(creationEvents, host.id, aliceId); expect(createAliceChange).toBeDefined(); expect(createAliceChange?.[rollupField.id]?.newValue).toEqual(30); @@ -2327,7 +2372,7 @@ IF( const { events: updateEvents } = await runAndCaptureRecordUpdates(async () => { await updateRecordByApi(foreign.id, foreign.records[0].id, foreignAmountId, 15); }); - if (!isForceV2) { + if (!isV2Mode) { const updateAliceChange = findRecordChangeMap(updateEvents, host.id, aliceId); expect(updateAliceChange).toBeDefined(); expect(updateAliceChange?.[rollupField.id]?.newValue).toEqual(35); @@ -2422,7 +2467,7 @@ IF( } ); - if (!isForceV2) { + if (!isV2Mode) { const createAChange = findRecordChangeMap(creationEvents, host.id, hostAId); expect(createAChange).toBeDefined(); expect(createAChange?.[rollupField.id]?.newValue).toEqual(15); @@ -2571,7 +2616,7 @@ IF( } as IFieldRo); }); - if (!isForceV2) { + if (!isV2Mode) { const createChange = findRecordChangeMap(creationEvents, host.id, hostRecordId); expect(createChange).toBeDefined(); expect(createChange?.[conditionalRollupField.id]?.newValue).toEqual(1); @@ -2588,7 +2633,7 @@ IF( const { events: hostFieldChangeEvents } = await runAndCaptureRecordUpdates(async () => { await updateRecordByApi(host.id, hostRecordId, targetFieldId, 'B'); }); - if (!isForceV2) { + if (!isV2Mode) { const hostFieldChange = findRecordChangeMap(hostFieldChangeEvents, host.id, hostRecordId); expect(hostFieldChange).toBeDefined(); const hostFieldLookupChange = assertChange(hostFieldChange?.[conditionalRollupField.id]); @@ -2603,7 +2648,7 @@ IF( const { events: foreignFieldChangeEvents } = await runAndCaptureRecordUpdates(async () => { await updateRecordByApi(foreign.id, foreign.records[1].id, statusId, 'B'); }); - if (!isForceV2) { + if (!isV2Mode) { const foreignDrivenChange = findRecordChangeMap( foreignFieldChangeEvents, host.id, @@ -2687,7 +2732,7 @@ IF( (f) => f.id === conditionalRollupField.id )! as any; - if (!isForceV2) { + if (!isV2Mode) { const createChangeA = findRecordChangeMap(createEvents, host.id, hostRecordAId); expect(createChangeA).toBeDefined(); expect(createChangeA?.[conditionalRollupField.id]?.newValue).toEqual(1); @@ -2729,7 +2774,7 @@ IF( } as IFieldRo); }); - if (!isForceV2) { + if (!isV2Mode) { const updatedChangeA = findRecordChangeMap(filterChangeEvents, host.id, hostRecordAId); if (updatedChangeA?.[conditionalRollupField.id]) { const change = assertChange(updatedChangeA[conditionalRollupField.id]); @@ -2870,7 +2915,7 @@ IF( })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const event = payloads[0] as any; expect(event.payload.tableId).toBe(table.id); const rec = Array.isArray(event.payload.record) @@ -2925,7 +2970,7 @@ IF( })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const evt = payloads[0]; const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record; const changes = rec.fields as FieldChangeMap; @@ -3010,7 +3055,7 @@ IF( await deleteField(t1.id, aId); })) as any; - if (!isForceV2) { + if (!isV2Mode) { // T2 const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const t2Changes = ( @@ -3086,7 +3131,7 @@ IF( await deleteField(t1.id, aId); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const changes = ( Array.isArray(t2Event.payload.record) ? t2Event.payload.record[0] : t2Event.payload.record @@ -3179,7 +3224,7 @@ IF( const { events } = await runAndCaptureRecordUpdates(async () => { await createField(table.id, { name: 'B', type: FieldType.SingleLineText } as IFieldRo); }); - if (!isForceV2) { + if (!isV2Mode) { expect(events.length).toBe(1); const baseField = (await getFields(table.id)).find((f) => f.name === 'B')!; const changeMap = toChangeMap(events[0]); @@ -3199,7 +3244,7 @@ IF( } as IFieldRo); }); const fId = (await getFields(table.id)).find((f) => f.name === 'F')!.id; - if (!isForceV2) { + if (!isV2Mode) { expect(events.length).toBe(1); const changeMap = toChangeMap(events[0]); const fChange = assertChange(changeMap[fId]); @@ -3254,7 +3299,7 @@ IF( } as any); }); const lkpField = (await getFields(t2.id)).find((f) => f.name === 'LK')!; - if (!isForceV2) { + if (!isV2Mode) { expect(events.length).toBe(1); const changeMap = toChangeMap(events[0]); const lkpChange = assertChange(changeMap[lkpField.id]); @@ -3285,7 +3330,7 @@ IF( } as any); }); const rId = (await getFields(t2.id)).find((f) => f.name === 'R')!.id; - if (!isForceV2) { + if (!isV2Mode) { expect(events.length).toBe(1); const changeMap = toChangeMap(events[0]); const rChange = assertChange(changeMap[rId]); @@ -3327,7 +3372,7 @@ IF( options: { expression: `{${aId}} + 5` }, } as any); }); - if (!isForceV2) { + if (!isV2Mode) { expect(events.length).toBe(1); const changeMap = toChangeMap(events[0]); const fChange = assertChange(changeMap[f.id]); @@ -3362,7 +3407,7 @@ IF( const { events } = await runAndCaptureRecordUpdates(async () => { await duplicateField(table.id, textField.id, { name: 'Text_copy' }); }); - if (!isForceV2) { + if (!isV2Mode) { expect(events.length).toBe(1); const textCopyField = (await getFields(table.id)).find((f) => f.name === 'Text_copy')!; const changeMap = toChangeMap(events[0]); @@ -3383,7 +3428,7 @@ IF( await duplicateField(table.id, f.id, { name: 'F_copy' }); }); const fCopyId = (await getFields(table.id)).find((x) => x.name === 'F_copy')!.id; - if (!isForceV2) { + if (!isV2Mode) { expect(events.length).toBe(1); const changeMap = toChangeMap(events[0]); const fCopyChange = assertChange(changeMap[fCopyId]); @@ -3437,7 +3482,7 @@ IF( await updateRecordByApi(t1.id, t1.records[0].id, titleId, 'Bar'); })) as any; - if (!isForceV2) { + if (!isV2Mode) { // Find T2 event const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const changes = t2Event.payload.record.fields as FieldChangeMap; @@ -3581,7 +3626,7 @@ IF( .map((x: any) => x?.id) .filter(Boolean); - if (!isForceV2) { + if (!isV2Mode) { // Expect: one event on T1[1-1] and one symmetric event on T2[2-1] const t1Event = (payloads as any[]).find((e) => e.payload.tableId === t1.id)!; const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; @@ -3679,7 +3724,7 @@ IF( await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB1 }]); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const change = assertChange(getChangeFromEvent(t2Event, linkOnT2.id, rB1)); expectNoOldValue(change); @@ -3697,7 +3742,7 @@ IF( await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB1 }, { id: rB2 }]); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const change = assertChange(getChangeFromEvent(t2Event, linkOnT2.id, rB2)); expectNoOldValue(change); @@ -3715,7 +3760,7 @@ IF( await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB2 }]); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const change = assertChange( getChangeFromEvent(t2Event, linkOnT2.id, rB1) || @@ -3791,7 +3836,7 @@ IF( )(async () => { await updateRecordByApi(t1.id, rA1, linkOnT1.id, { id: rB1 }); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const recs = Array.isArray(t2Event.payload.record) ? t2Event.payload.record @@ -3814,7 +3859,7 @@ IF( )(async () => { await updateRecordByApi(t1.id, rA1, linkOnT1.id, { id: rB2 }); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const recs = Array.isArray(t2Event.payload.record) ? t2Event.payload.record @@ -3892,7 +3937,7 @@ IF( )(async () => { await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB1 }]); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const recs = Array.isArray(t2Event.payload.record) ? t2Event.payload.record @@ -3915,7 +3960,7 @@ IF( )(async () => { await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB1 }, { id: rB2 }]); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const recs = Array.isArray(t2Event.payload.record) ? t2Event.payload.record @@ -3938,7 +3983,7 @@ IF( )(async () => { await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB2 }]); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const recs = Array.isArray(t2Event.payload.record) ? t2Event.payload.record @@ -4037,7 +4082,7 @@ IF( await updateRecordByApi(t2.id, r2_1, linkOnT2.id, [{ id: r1_1 }]); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t1Event = (payloads as any[]).find((e) => e.payload.tableId === t1.id)!; const recs = Array.isArray(t1Event.payload.record) ? t1Event.payload.record diff --git a/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts b/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts index b917f242c9..5d15663950 100644 --- a/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts +++ b/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts @@ -1,6 +1,7 @@ import type { INestApplication } from '@nestjs/common'; import type { IFieldRo, IFieldVo, ILinkFieldOptionsRo, ILookupOptionsRo } from '@teable/core'; import { FieldKeyType, FieldType, Relationship, Role } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; import { deleteSpaceCollaborator, emailSpaceInvitation, @@ -38,16 +39,24 @@ import { describe('Computed user field (e2e)', () => { let app: INestApplication; let v2ContainerService: V2ContainerService; + let prisma: PrismaService; const spaceId = globalThis.testConfig.spaceId; const userName = globalThis.testConfig.userName; const isForceV2 = process.env.FORCE_V2_ALL === 'true'; + let isV2Mode = isForceV2; let baseId: string; beforeAll(async () => { const appCtx = await initApp(); app = appCtx.app; v2ContainerService = app.get(V2ContainerService); + prisma = app.get(PrismaService); const base = await createBase({ name: 'base1', spaceId }); baseId = base.id; + const baseMeta = await prisma.base.findUnique({ + where: { id: baseId }, + select: { v2Enabled: true }, + }); + isV2Mode = isForceV2 || Boolean(baseMeta?.v2Enabled); }); afterAll(async () => { @@ -56,7 +65,7 @@ describe('Computed user field (e2e)', () => { }); async function processV2Outbox(): Promise { - if (!isForceV2) return; + if (!isV2Mode) return; const container = await v2ContainerService.getContainer(); const drainService = container.resolve( @@ -132,7 +141,7 @@ describe('Computed user field (e2e)', () => { title: userName, }); - if (isForceV2) { + if (isV2Mode) { expect(records.data.records[1].fields[lastModifiedByField.id]).toMatchObject({ title: userName, }); @@ -191,7 +200,7 @@ describe('Computed user field (e2e)', () => { expect(records.data.records[0].fields[formulaField.id]).toEqual(userName); - if (isForceV2) { + if (isV2Mode) { expect(records.data.records[1].fields[lastModifiedByField.id]).toMatchObject({ title: userName, }); @@ -255,7 +264,7 @@ describe('Computed user field (e2e)', () => { records.data.records[0].lastModifiedTime ); - if (isForceV2) { + if (isV2Mode) { expect(records.data.records[1].fields[lastModifiedTimeField.id]).toEqual( records.data.records[1].lastModifiedTime ); @@ -317,7 +326,7 @@ describe('Computed user field (e2e)', () => { }); let record = await getRecord(table1.id, recordId, { fieldKeyType: FieldKeyType.Id }); - if (isForceV2) { + if (isV2Mode) { expect(record.data.fields[lastModifiedByField.id]).toMatchObject({ id: globalThis.testConfig.userId, title: globalThis.testConfig.userName, @@ -371,7 +380,7 @@ describe('Computed user field (e2e)', () => { }); let record = await getRecord(table1.id, recordId, { fieldKeyType: FieldKeyType.Id }); - if (isForceV2) { + if (isV2Mode) { expect(record.data.fields[lastModifiedByField.id]).toMatchObject({ id: globalThis.testConfig.userId, title: globalThis.testConfig.userName, diff --git a/apps/nestjs-backend/test/field-converting.e2e-spec.ts b/apps/nestjs-backend/test/field-converting.e2e-spec.ts index a5990a4d75..7916738bba 100644 --- a/apps/nestjs-backend/test/field-converting.e2e-spec.ts +++ b/apps/nestjs-backend/test/field-converting.e2e-spec.ts @@ -848,6 +848,32 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { await expect(convertField(table1.id, table1.fields[0].id, newFieldRo)).rejects.toThrow(); }); + it('should not convert primary field to a lookup field (T3367)', async () => { + const linkFieldRo: IFieldRo = { + name: 'link', + type: FieldType.Link, + options: { + relationship: Relationship.ManyOne, + foreignTableId: table2.id, + }, + }; + const linkField = await createField(table1.id, linkFieldRo); + + const toLookupRo: IFieldRo = { + type: FieldType.SingleLineText, + isLookup: true, + lookupOptions: { + foreignTableId: table2.id, + lookupFieldId: table2.fields[0].id, + linkFieldId: linkField.id, + }, + }; + + await expect(convertField(table1.id, table1.fields[0].id, toLookupRo)).rejects.toThrow( + /primary/i + ); + }); + it('should convert text to date', async () => { const newFieldRo: IFieldRo = { type: FieldType.Date, diff --git a/apps/nestjs-backend/test/field-duplicate.e2e-spec.ts b/apps/nestjs-backend/test/field-duplicate.e2e-spec.ts index d501b5434b..30673deb5f 100644 --- a/apps/nestjs-backend/test/field-duplicate.e2e-spec.ts +++ b/apps/nestjs-backend/test/field-duplicate.e2e-spec.ts @@ -905,6 +905,158 @@ describe('OpenAPI FieldOpenApiController for duplicate field (e2e)', () => { }); }); + describe('duplicate filtered lookup fields that target conditional lookups', () => { + let issuesTable: ITableFullVo; + let releasesTable: ITableFullVo; + let launchesTable: ITableFullVo; + let originalForceV2All: string | undefined; + let issueTitleFieldId: string; + let issuePrFieldId: string; + let releasePrFieldId: string; + let releaseEditionFieldId: string; + let issuesTitleLookupFieldId: string; + let relatedReleasesFieldId: string; + let releaseIssuesFieldId: string; + + beforeAll(async () => { + originalForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'false'; + + issuesTable = await createTable(baseId, { + name: 'duplicate_nested_lookup_issues', + fields: [ + { name: 'Title', type: FieldType.SingleLineText }, + { name: 'PR', type: FieldType.SingleLineText }, + ], + }); + + const issueFields = (await getFields(issuesTable.id)).data; + issueTitleFieldId = issueFields.find((field) => field.name === 'Title')!.id; + issuePrFieldId = issueFields.find((field) => field.name === 'PR')!.id; + + releasesTable = await createTable(baseId, { + name: 'duplicate_nested_lookup_releases', + fields: [ + { name: 'Tag', type: FieldType.SingleLineText }, + { name: 'PR', type: FieldType.SingleLineText }, + { + name: 'Edition', + type: FieldType.SingleSelect, + options: { + choices: [{ name: 'cloud' }, { name: 'ee' }], + }, + }, + ], + }); + + const releaseFields = (await getFields(releasesTable.id)).data; + releasePrFieldId = releaseFields.find((field) => field.name === 'PR')!.id; + releaseEditionFieldId = releaseFields.find((field) => field.name === 'Edition')!.id; + + issuesTitleLookupFieldId = ( + await createField(releasesTable.id, { + name: 'Issues title', + type: FieldType.SingleLineText, + isLookup: true, + isConditionalLookup: true, + lookupOptions: { + foreignTableId: issuesTable.id, + lookupFieldId: issueTitleFieldId, + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: issuePrFieldId, + operator: 'is', + value: { + type: 'field', + fieldId: releasePrFieldId, + tableId: releasesTable.id, + }, + }, + ], + }, + }, + }) + ).data.id; + + launchesTable = await createTable(baseId, { + name: 'duplicate_nested_lookup_launches', + fields: [{ name: 'Launch', type: FieldType.SingleLineText }], + }); + + relatedReleasesFieldId = ( + await createField(launchesTable.id, { + name: 'Related Releases', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: releasesTable.id, + isOneWay: false, + }, + }) + ).data.id; + + releaseIssuesFieldId = ( + await createField(launchesTable.id, { + name: 'Release Issues', + type: FieldType.SingleLineText, + isLookup: true, + lookupOptions: { + foreignTableId: releasesTable.id, + linkFieldId: relatedReleasesFieldId, + lookupFieldId: issuesTitleLookupFieldId, + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: releaseEditionFieldId, + operator: 'is', + value: 'cloud', + }, + ], + }, + }, + }) + ).data.id; + + process.env.FORCE_V2_ALL = originalForceV2All; + }); + + afterAll(async () => { + process.env.FORCE_V2_ALL = originalForceV2All; + await permanentDeleteTable(baseId, launchesTable.id); + await permanentDeleteTable(baseId, releasesTable.id); + await permanentDeleteTable(baseId, issuesTable.id); + }); + + it('should duplicate filtered lookups whose source field is conditional lookup', async () => { + const duplicated = ( + await duplicateField(launchesTable.id, releaseIssuesFieldId, { + name: 'Release Issues Copy', + }) + ).data; + + expect(duplicated.isLookup).toBe(true); + expect(duplicated.isConditionalLookup).not.toBe(true); + expect(duplicated.lookupOptions).toMatchObject({ + foreignTableId: releasesTable.id, + linkFieldId: relatedReleasesFieldId, + lookupFieldId: issuesTitleLookupFieldId, + filter: { + conjunction: 'and', + filterSet: [ + expect.objectContaining({ + fieldId: releaseEditionFieldId, + operator: 'is', + value: 'cloud', + }), + ], + }, + }); + }); + }); + describe('duplicate rollup fields', () => { let table: ITableFullVo; let subTable: ITableFullVo; diff --git a/apps/nestjs-backend/test/formula.e2e-spec.ts b/apps/nestjs-backend/test/formula.e2e-spec.ts index e8eed84a34..26ac3825b9 100644 --- a/apps/nestjs-backend/test/formula.e2e-spec.ts +++ b/apps/nestjs-backend/test/formula.e2e-spec.ts @@ -19,7 +19,13 @@ import { Relationship, TimeFormatting, } from '@teable/core'; -import { getRecord, updateRecords, type ITableFullVo } from '@teable/openapi'; +import { + X_CANARY_HEADER, + axios, + getRecord, + updateRecords, + type ITableFullVo, +} from '@teable/openapi'; import { createField, createFields, @@ -692,6 +698,69 @@ describe('OpenAPI formula (e2e)', () => { expect(clearedRecord.fields[equalsEmptyField.name]).toEqual(1); }); + const expectBlankSpacingComparison = async (canaryHeader: 'true' | 'false') => { + const previousCanaryHeader = axios.defaults.headers.common[X_CANARY_HEADER]; + axios.defaults.headers.common[X_CANARY_HEADER] = canaryHeader; + + try { + const localizedNumberField = await createField(table1Id, { + id: generateFieldId(), + name: '入职体重(kg)', + type: FieldType.Number, + options: { + formatting: { type: NumberFormattingType.Decimal, precision: 0 }, + }, + }); + + const compactBlankField = await createField(table1Id, { + id: generateFieldId(), + name: 'blank-compact', + type: FieldType.Formula, + options: { + expression: `{${localizedNumberField.id}} !=BLANK()`, + }, + }); + + const spacedBlankField = await createField(table1Id, { + id: generateFieldId(), + name: 'blank-spaced', + type: FieldType.Formula, + options: { + expression: `{${localizedNumberField.id}} != BLANK()`, + }, + }); + + const { records } = await createRecords(table1Id, { + fieldKeyType: FieldKeyType.Name, + records: [ + { + fields: { + [localizedNumberField.name]: 70, + }, + }, + ], + }); + + const { data: record } = await getRecord(table1Id, records[0].id); + expect(record.fields?.[compactBlankField.name]).toBe(true); + expect(record.fields?.[spacedBlankField.name]).toBe(true); + } finally { + if (previousCanaryHeader == null) { + delete axios.defaults.headers.common[X_CANARY_HEADER]; + } else { + axios.defaults.headers.common[X_CANARY_HEADER] = previousCanaryHeader; + } + } + }; + + it('should keep BLANK() comparisons stable with spaced function calls in v1 mode', async () => { + await expectBlankSpacingComparison('false'); + }); + + it('should keep BLANK() comparisons stable with spaced function calls in canary mode', async () => { + await expectBlankSpacingComparison('true'); + }); + it('should calculate formula containing question mark literal', async () => { const urlFormulaField = await createField(table1Id, { name: 'url formula', diff --git a/apps/nestjs-backend/test/integrity.e2e-spec.ts b/apps/nestjs-backend/test/integrity.e2e-spec.ts index 1461a678eb..c8e96ba8a2 100644 --- a/apps/nestjs-backend/test/integrity.e2e-spec.ts +++ b/apps/nestjs-backend/test/integrity.e2e-spec.ts @@ -1079,4 +1079,172 @@ describe('OpenAPI integrity (e2e)', () => { expect(integrity3.data.hasIssues).toEqual(false); }); }); + + describe('fix invalid primary', () => { + let baseId1: string; + let base1table: ITableFullVo; + + beforeEach(async () => { + baseId1 = (await createBase({ spaceId, name: 'base1' })).data.id; + base1table = await createTable(baseId1, { name: 'base1table' }); + }); + + afterEach(async () => { + await permanentDeleteTable(baseId1, base1table.id); + await deleteBase(baseId1); + }); + + it('detects and fixes a primary field with unsupported type by promoting existing candidate', async () => { + // Inject the corrupt state directly: bulk paths historically allowed isPrimary=true on a + // checkbox field. Real-world examples in prod were created by AI / .tea import bypassing + // the source guards we added. + const originalPrimaryId = base1table.fields.find((f) => f.isPrimary)!.id; + await prisma.txClient().field.update({ + where: { id: originalPrimaryId }, + data: { isPrimary: null }, + }); + const checkboxField = await createField(base1table.id, { + name: 'broken primary', + type: FieldType.Checkbox, + }); + await prisma.txClient().field.update({ + where: { id: checkboxField.id }, + data: { isPrimary: true }, + }); + + const integrity = await checkBaseIntegrity(baseId1, base1table.id); + const issues = integrity.data.linkFieldIssues.flatMap((i) => i.issues); + expect(issues.some((i) => i.type === IntegrityIssueType.InvalidPrimaryType)).toEqual(true); + + await fixBaseIntegrity(baseId1, base1table.id); + + const integrity2 = await checkBaseIntegrity(baseId1, base1table.id); + expect(integrity2.data.hasIssues).toEqual(false); + + // Promote-existing path: the demoted original SingleLineText is the first eligible + // candidate by order, so it gets re-promoted. No new formula field is created. + const primaries = await prisma.txClient().field.findMany({ + where: { tableId: base1table.id, isPrimary: true, deletedTime: null }, + select: { id: true }, + }); + expect(primaries).toHaveLength(1); + expect(primaries[0].id).toEqual(originalPrimaryId); + + // Bad checkbox is demoted but NOT renamed — rename only happens in the formula + // fallback path where the new field needs to take the original name. + const checkboxAfter = await prisma.txClient().field.findFirst({ + where: { id: checkboxField.id }, + select: { isPrimary: true, name: true }, + }); + expect(checkboxAfter?.isPrimary).toBeNull(); + expect(checkboxAfter?.name).toEqual('broken primary'); + }); + + it('falls back to a new formula primary when no eligible candidate exists', async () => { + // Make every non-bad field ineligible so promote-existing has nothing to promote, then + // verify the formula fallback path runs and mirrors the bad primary's value. + const originalPrimary = base1table.fields.find((f) => f.isPrimary)!; + const otherFields = base1table.fields.filter((f) => !f.isPrimary); + + // Mutate every non-primary field to attachment (not in PRIMARY_SUPPORTED_TYPES) so + // they can't be promoted. + for (const field of otherFields) { + await prisma.txClient().field.update({ + where: { id: field.id }, + data: { type: FieldType.Attachment }, + }); + } + + // Replace the original SingleLineText primary with a checkbox bad primary. + await prisma.txClient().field.update({ + where: { id: originalPrimary.id }, + data: { isPrimary: null, type: FieldType.Attachment }, + }); + const checkboxField = await createField(base1table.id, { + name: 'broken primary', + type: FieldType.Checkbox, + }); + await prisma.txClient().field.update({ + where: { id: checkboxField.id }, + data: { isPrimary: true }, + }); + + await fixBaseIntegrity(baseId1, base1table.id); + + const primaries = await prisma.txClient().field.findMany({ + where: { tableId: base1table.id, isPrimary: true, deletedTime: null }, + select: { id: true, type: true, name: true, options: true }, + }); + expect(primaries).toHaveLength(1); + expect(primaries[0].type).toEqual(FieldType.Formula); + expect(primaries[0].name).toEqual('broken primary'); + // Formula expression references the old field id so the displayed value stays continuous. + expect(primaries[0].options).toContain(checkboxField.id); + }); + + it('preserves existing valid primary when an extra invalid primary is fixed', async () => { + // Defensive scenario: a valid primary already exists, but somehow a second isPrimary=true + // field with bad type sneaks in (race / direct SQL / future regression). + // Fix should demote the invalid one and keep the valid primary as-is, without + // creating a third "formula" primary. + const validPrimaryId = base1table.fields.find((f) => f.isPrimary)!.id; + + const checkboxField = await createField(base1table.id, { + name: 'rogue checkbox primary', + type: FieldType.Checkbox, + }); + await prisma.txClient().field.update({ + where: { id: checkboxField.id }, + data: { isPrimary: true }, + }); + + const before = await prisma.txClient().field.findMany({ + where: { tableId: base1table.id, isPrimary: true, deletedTime: null }, + }); + expect(before).toHaveLength(2); + + await fixBaseIntegrity(baseId1, base1table.id); + + const after = await prisma.txClient().field.findMany({ + where: { tableId: base1table.id, isPrimary: true, deletedTime: null }, + select: { id: true, type: true }, + }); + expect(after).toHaveLength(1); + expect(after[0].id).toEqual(validPrimaryId); + + const checkboxAfter = await prisma.txClient().field.findFirst({ + where: { id: checkboxField.id }, + select: { isPrimary: true, name: true }, + }); + expect(checkboxAfter?.isPrimary).toBeNull(); + // Kept-existing path: bad primary is demoted but NOT renamed. + expect(checkboxAfter?.name).toEqual('rogue checkbox primary'); + }); + + it('detects and fixes a table missing its primary field by promoting existing candidate', async () => { + const primary = base1table.fields.find((f) => f.isPrimary)!; + await prisma.txClient().field.update({ + where: { id: primary.id }, + data: { isPrimary: null }, + }); + + const integrity = await checkBaseIntegrity(baseId1, base1table.id); + const issues = integrity.data.linkFieldIssues.flatMap((i) => i.issues); + expect(issues.some((i) => i.type === IntegrityIssueType.MissingPrimary)).toEqual(true); + + await fixBaseIntegrity(baseId1, base1table.id); + + const integrity2 = await checkBaseIntegrity(baseId1, base1table.id); + expect(integrity2.data.hasIssues).toEqual(false); + + // Should re-promote the existing primary, not create a duplicate "Name 2". + const primaries = await prisma.txClient().field.findMany({ + where: { tableId: base1table.id, isPrimary: true, deletedTime: null }, + select: { id: true, name: true }, + }); + expect(primaries).toHaveLength(1); + expect(primaries[0].id).toEqual(primary.id); + expect(primaries[0].name).toEqual(primary.name); + }); + }); }); diff --git a/apps/nestjs-backend/test/record-filter-query.e2e-spec.ts b/apps/nestjs-backend/test/record-filter-query.e2e-spec.ts index 7748f50604..21ff2a5894 100644 --- a/apps/nestjs-backend/test/record-filter-query.e2e-spec.ts +++ b/apps/nestjs-backend/test/record-filter-query.e2e-spec.ts @@ -157,8 +157,8 @@ describe('OpenAPI Record-Filter-Query (e2e)', () => { test.each(MULTIPLE_SELECT_FIELD_CASES)(testDesc, async (param) => doTest(table, param)); }); - describe('dateRange filter error cases', () => { - it('should throw error when start > end (invalid range)', async () => { + describe('dateRange invalid filters are skipped instead of crashing the query', () => { + it('skips when start > end (compiler-level validation)', async () => { const { fieldIndex, operator, queryValue } = DATE_RANGE_ERROR_CASES.invalidRange; const filter: IFilter = { filterSet: [ @@ -170,10 +170,11 @@ describe('OpenAPI Record-Filter-Query (e2e)', () => { ], conjunction: and.value, }; - await expect(getFilterRecord(table.id, table.views[0].id, filter)).rejects.toThrow(); + const result = await getFilterRecord(table.id, table.views[0].id, filter); + expect(result.records.length).toBeGreaterThan(0); }); - it('should throw error when dateRange is used with isNot operator', async () => { + it('skips when dateRange is used with isNot operator (analyzer-level validation)', async () => { const { fieldIndex, operator, queryValue } = DATE_RANGE_ERROR_CASES.invalidOperator; const filter: IFilter = { filterSet: [ @@ -185,7 +186,8 @@ describe('OpenAPI Record-Filter-Query (e2e)', () => { ], conjunction: and.value, }; - await expect(getFilterRecord(table.id, table.views[0].id, filter)).rejects.toThrow(); + const result = await getFilterRecord(table.id, table.views[0].id, filter); + expect(result.records.length).toBeGreaterThan(0); }); }); }); diff --git a/apps/nestjs-backend/test/record-search-query.e2e-spec.ts b/apps/nestjs-backend/test/record-search-query.e2e-spec.ts index 62918c88fd..a0f65a530a 100644 --- a/apps/nestjs-backend/test/record-search-query.e2e-spec.ts +++ b/apps/nestjs-backend/test/record-search-query.e2e-spec.ts @@ -927,35 +927,6 @@ describe('OpenAPI Record-Search-Query (e2e)', async () => { }); }); - it('should search date fields globally when search index is enabled', async () => { - await toggleTableIndex(baseId, table.id, { type: TableIndex.search }); - - const dateField = table.fields.find( - (f) => f.cellValueType === CellValueType.DateTime - )! as IFieldInstance; - - const { records } = ( - await apiGetRecords(table.id, { - fieldKeyType: FieldKeyType.Id, - viewId: table.views[0].id, - search: ['2022-03-02', '', true], - }) - ).data; - - expect(records.length).toBe(1); - expect(records[0].fields[dateField.id]).toBe('2022-03-01T16:00:00.000Z'); - - const searchIndex = await getSearchIndex(table.id, { - viewId: table.views[0].id, - take: 10, - search: ['2022-03-02', '', true], - }); - - expect(searchIndex.data).toEqual( - expect.arrayContaining([expect.objectContaining({ fieldId: dateField.id })]) - ); - }); - it('should repair abnormal index', async () => { const textfield = table.fields.find( (f) => f.cellValueType === CellValueType.String diff --git a/apps/nestjs-backend/test/record-typecast.e2e-spec.ts b/apps/nestjs-backend/test/record-typecast.e2e-spec.ts index accdf62ec8..6c900ace3b 100644 --- a/apps/nestjs-backend/test/record-typecast.e2e-spec.ts +++ b/apps/nestjs-backend/test/record-typecast.e2e-spec.ts @@ -279,7 +279,8 @@ describe('Record Typecast', () => { typecast: true, }).then((res) => res.data); - expect(record.fields[table.fields[1].id]).toBeUndefined(); + const emptySelectValue = record.fields[table.fields[1].id]; + expect(emptySelectValue === null || emptySelectValue === undefined).toBe(true); }); }); }); diff --git a/apps/nestjs-backend/test/table.e2e-spec.ts b/apps/nestjs-backend/test/table.e2e-spec.ts index 019afa2b2c..5388ef0d74 100644 --- a/apps/nestjs-backend/test/table.e2e-spec.ts +++ b/apps/nestjs-backend/test/table.e2e-spec.ts @@ -375,6 +375,20 @@ describe('OpenAPI TableController (e2e)', () => { expect(fields[2].type).toEqual(FieldType.LongText); }); + it('should reject createTable when first field has unsupported primary type', async () => { + // Without the fix, the service would auto-promote a checkbox first field to primary + // (bypassing prepareCreateFields validation), persisting a bad-type primary. + await expect( + createTable(baseId, { + name: 'bad primary table', + fields: [ + { name: 'Done', type: FieldType.Checkbox }, + { name: 'Note', type: FieldType.SingleLineText }, + ], + }) + ).rejects.toThrow(/primary/i); + }); + it('should update table simple properties', async () => { const result = await createTable(baseId, { name: 'table', diff --git a/apps/nestjs-backend/test/trash.e2e-spec.ts b/apps/nestjs-backend/test/trash.e2e-spec.ts index 1638299e70..72ecf990ee 100644 --- a/apps/nestjs-backend/test/trash.e2e-spec.ts +++ b/apps/nestjs-backend/test/trash.e2e-spec.ts @@ -1,6 +1,7 @@ /* eslint-disable sonarjs/no-duplicate-string */ import type { INestApplication } from '@nestjs/common'; import { FieldType, Relationship } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; import type { ITrashItemVo } from '@teable/openapi'; import { getTrash, @@ -43,18 +44,32 @@ const waitForBaseTrashItems = async (baseId: string, expectedCount = 1, maxRetri describe('Trash (e2e)', () => { let app: INestApplication; let eventEmitterService: EventEmitterService; + let prisma: PrismaService; let awaitWithSpaceEvent: (fn: () => Promise) => Promise; let awaitWithBaseEvent: (fn: () => Promise) => Promise; let awaitWithTableEvent: (fn: () => Promise) => Promise; - const awaitWithTableDeleteSync = async (fn: () => Promise) => - isForceV2 ? await fn() : awaitWithTableEvent(fn); + const isBaseV2Mode = async (baseId: string) => { + if (isForceV2) { + return true; + } + + const base = await prisma.base.findUnique({ + where: { id: baseId }, + select: { v2Enabled: true }, + }); + return Boolean(base?.v2Enabled); + }; + + const awaitWithTableDeleteSync = async (baseId: string, fn: () => Promise) => + (await isBaseV2Mode(baseId)) ? await fn() : awaitWithTableEvent(fn); beforeAll(async () => { const appCtx = await initApp(); app = appCtx.app; eventEmitterService = app.get(EventEmitterService); + prisma = app.get(PrismaService); awaitWithSpaceEvent = createAwaitWithEvent(eventEmitterService, Events.SPACE_DELETE); awaitWithBaseEvent = createAwaitWithEvent(eventEmitterService, Events.BASE_DELETE); @@ -100,7 +115,7 @@ describe('Trash (e2e)', () => { it('should retrieve trash items for base when a table is deleted', async () => { const tableId = (await createTable(baseId, {})).id; - await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId)); + await awaitWithTableDeleteSync(baseId, () => deleteTable(baseId, tableId)); const res = await waitForBaseTrashItems(baseId, 1); @@ -120,7 +135,7 @@ describe('Trash (e2e)', () => { }, }); - await awaitWithTableDeleteSync(() => deleteTable(baseId, foreignTableId)); + await awaitWithTableDeleteSync(baseId, () => deleteTable(baseId, foreignTableId)); const res = await waitForBaseTrashItems(baseId, 1); @@ -167,7 +182,7 @@ describe('Trash (e2e)', () => { }); it('should restore table successfully', async () => { - await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId)); + await awaitWithTableDeleteSync(baseId, () => deleteTable(baseId, tableId)); const trash = (await waitForBaseTrashItems(baseId, 1)).data; const restored = await restoreTrash(trash.trashItems[0].id); @@ -176,15 +191,27 @@ describe('Trash (e2e)', () => { }); it('should expose restore-table canary headers when restoring a table trash item', async () => { - await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId)); + await awaitWithTableDeleteSync(baseId, () => deleteTable(baseId, tableId)); const trash = (await waitForBaseTrashItems(baseId, 1)).data; - const restored = await restoreTrash(trash.trashItems[0].id); + const previousForceV2All = process.env.FORCE_V2_ALL; + const restored = await (async () => { + process.env.FORCE_V2_ALL = 'true'; + try { + return await restoreTrash(trash.trashItems[0].id); + } finally { + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + } + })(); expect(restored.status).toEqual(201); - expect(restored.headers['x-teable-v2']).toBe(isForceV2 ? 'true' : 'false'); + expect(restored.headers['x-teable-v2']).toBe('true'); expect(restored.headers['x-teable-v2-feature']).toBe('restoreTable'); - expect(restored.headers['x-teable-v2-reason']).toBeTruthy(); + expect(restored.headers['x-teable-v2-reason']).toBe('new_base'); }); }); @@ -210,9 +237,9 @@ describe('Trash (e2e)', () => { const tableId2 = (await createTable(baseId, {})).id; const tableId3 = (await createTable(baseId, {})).id; - await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId1)); - await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId2)); - await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId3)); + await awaitWithTableDeleteSync(baseId, () => deleteTable(baseId, tableId1)); + await awaitWithTableDeleteSync(baseId, () => deleteTable(baseId, tableId2)); + await awaitWithTableDeleteSync(baseId, () => deleteTable(baseId, tableId3)); const trash = (await waitForBaseTrashItems(baseId, 3)).data; diff --git a/apps/nestjs-backend/test/undo-redo.e2e-spec.ts b/apps/nestjs-backend/test/undo-redo.e2e-spec.ts index 5f7dc12ebb..245856727b 100644 --- a/apps/nestjs-backend/test/undo-redo.e2e-spec.ts +++ b/apps/nestjs-backend/test/undo-redo.e2e-spec.ts @@ -72,6 +72,23 @@ const waitForTableTrashCount = async (tableId: string, expectedCount: number, ma return await getTrashItems({ resourceId: tableId, resourceType: ResourceType.Table }); }; +const waitForViewVisibility = async ( + tableId: string, + viewId: string, + visible: boolean, + maxRetries = 100 +) => { + for (let i = 0; i < maxRetries; i++) { + const views = (await getViewList(tableId)).data; + const view = views.find((v) => v.id === viewId); + if (Boolean(view) === visible) { + return view; + } + await sleep(100); + } + + return (await getViewList(tableId)).data.find((v) => v.id === viewId); +}; describe('Undo Redo (e2e)', () => { let app: INestApplication; @@ -1254,8 +1271,7 @@ describe('Undo Redo (e2e)', () => { await undo(table.id); - const viewsAfterUndo = (await getViewList(table.id)).data; - expect(viewsAfterUndo.find((v) => v.id === view.id)).toMatchObject({ + expect(await waitForViewVisibility(table.id, view.id, true)).toMatchObject({ id: view.id, name: view.name, type: view.type, @@ -1263,8 +1279,7 @@ describe('Undo Redo (e2e)', () => { await redo(table.id); - const viewsAfterRedo = (await getViewList(table.id)).data; - expect(viewsAfterRedo.find((v) => v.id === view.id)).toBeUndefined(); + expect(await waitForViewVisibility(table.id, view.id, false)).toBeUndefined(); }); it('should undo / redo update view property', async () => { diff --git a/apps/nextjs-app/src/components/changelog/ChangelogNotification.tsx b/apps/nextjs-app/src/components/changelog/ChangelogNotification.tsx index 453b1c105e..6d32e10e7f 100644 --- a/apps/nextjs-app/src/components/changelog/ChangelogNotification.tsx +++ b/apps/nextjs-app/src/components/changelog/ChangelogNotification.tsx @@ -1,6 +1,6 @@ import { ArrowUpRight, X } from '@teable/icons'; import { LocalStorageKeys } from '@teable/sdk/config'; -import { useIsHydrated, useShareId } from '@teable/sdk/hooks'; +import { useIsHydrated, useIsReadOnlyPreview } from '@teable/sdk/hooks'; import { Button } from '@teable/ui-lib/shadcn'; import { Rocket } from 'lucide-react'; import { useTranslation } from 'next-i18next'; @@ -11,7 +11,7 @@ export const ChangelogNotification = () => { const { t } = useTranslation('common'); const isHydrated = useIsHydrated(); const isCloud = useIsCloud(); - const shareId = useShareId(); + const isReadOnlyPreview = useIsReadOnlyPreview(); const [visible, setVisible] = useState(false); const changelogId = t('changelog.id'); @@ -39,7 +39,7 @@ export const ChangelogNotification = () => { } }, [changelogId]); - if (!isCloud || !isHydrated || !visible || shareId) { + if (!isCloud || !isHydrated || !visible || isReadOnlyPreview) { return null; } diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx index e18804e356..91a3b49afa 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx @@ -30,10 +30,11 @@ import { scrollToTarget } from './utils'; export interface ISettingPageProps { settingServerData?: ISettingVo; rewardManage?: React.ReactNode; + canarySettings?: React.ReactNode; } export const SettingPage = (props: ISettingPageProps) => { - const { settingServerData, rewardManage } = props; + const { settingServerData, rewardManage, canarySettings } = props; const queryClient = useQueryClient(); const { t } = useTranslation('common'); @@ -283,7 +284,7 @@ export const SettingPage = (props: ISettingPageProps) => { {rewardManage} - + {canarySettings ?? } {/* email config */}
diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/AiFormWizard.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/AiFormWizard.tsx index e0b5289845..a9238d05b9 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/AiFormWizard.tsx +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/AiFormWizard.tsx @@ -22,7 +22,12 @@ import { GatewayModelsStep } from './GatewayModelsStep'; import { LLMApiConfigStep } from './LLMApiConfigStep'; import type { IModelTestResult } from './LlmproviderManage'; import { SetupStepCard } from './SetupStepCard'; -import { generateModelKeyList, generateGatewayModelKeyList, parseModelKey } from './utils'; +import { + generateModelKeyList, + generateGatewayModelKeyList, + normalizeLLMProviderModelConfigs, + parseModelKey, +} from './utils'; // Props to control whether to show pricing-related UI interface IAIConfigFormWizardProps { @@ -125,20 +130,28 @@ export function AIConfigFormWizard({ reset(defaultValues); }, [defaultValues, reset]); + const normalizeAiConfig = useCallback((data: NonNullable) => { + return { + ...data, + llmProviders: data.llmProviders?.map(normalizeLLMProviderModelConfigs) ?? [], + }; + }, []); + const onSubmit = useCallback( (data: NonNullable) => { - console.log('onSubmit', data); - setAiConfig(data); + const normalizedData = normalizeAiConfig(data); + setAiConfig(normalizedData); toast.success(t('admin.setting.ai.configUpdated')); }, - [setAiConfig, t] + [normalizeAiConfig, setAiConfig, t] ); const updateProviders = useCallback( (providers: LLMProvider[]) => { - form.setValue('llmProviders', providers); + const normalizedProviders = providers.map(normalizeLLMProviderModelConfigs); + form.setValue('llmProviders', normalizedProviders); form.trigger('llmProviders'); - onSubmit(form.getValues()); + onSubmit({ ...form.getValues(), llmProviders: normalizedProviders }); }, [form, onSubmit] ); @@ -175,7 +188,7 @@ export function AIConfigFormWizard({ if (providerIndex === -1) return; const provider = currentProviders[providerIndex]; - const updatedProvider = { + const updatedProvider = normalizeLLMProviderModelConfigs({ ...provider, modelConfigs: { ...provider.modelConfigs, @@ -186,15 +199,15 @@ export function AIConfigFormWizard({ testedAt: Date.now(), }, }, - }; + }); const newProviders = [...currentProviders]; newProviders[providerIndex] = updatedProvider; form.setValue('llmProviders', newProviders); - setAiConfig(form.getValues()); + setAiConfig(normalizeAiConfig({ ...form.getValues(), llmProviders: newProviders })); }, - [form, setAiConfig] + [form, normalizeAiConfig, setAiConfig] ); const onToggleImageModel = useCallback( @@ -209,26 +222,26 @@ export function AIConfigFormWizard({ if (providerIndex === -1) return; const provider = currentProviders[providerIndex]; - const updatedProvider = { + const currentConfig = provider.modelConfigs?.[model]; + const updatedProvider = normalizeLLMProviderModelConfigs({ ...provider, modelConfigs: { ...provider.modelConfigs, [model]: { - ...provider.modelConfigs?.[model], + ...currentConfig, isImageModel, ability: isImageModel ? undefined : provider.modelConfigs?.[model]?.ability, - imageAbility: isImageModel ? provider.modelConfigs?.[model]?.imageAbility : undefined, }, }, - }; + }); const newProviders = [...currentProviders]; newProviders[providerIndex] = updatedProvider; form.setValue('llmProviders', newProviders); - setAiConfig(form.getValues()); + setAiConfig(normalizeAiConfig({ ...form.getValues(), llmProviders: newProviders })); }, - [form, setAiConfig] + [form, normalizeAiConfig, setAiConfig] ); // Handler for updating gateway-related fields in aiConfig @@ -241,9 +254,9 @@ export function AIConfigFormWizard({ form.setValue(key as keyof typeof updates, value); }); // Save to backend - setAiConfig(updatedConfig); + setAiConfig(normalizeAiConfig(updatedConfig)); }, - [form, setAiConfig] + [form, normalizeAiConfig, setAiConfig] ); // Unified wizard view for both Cloud and EE diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/BatchTestModels.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/BatchTestModels.tsx index 27bf135674..cdd62128b5 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/BatchTestModels.tsx +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/BatchTestModels.tsx @@ -11,6 +11,7 @@ import { Button, Progress } from '@teable/ui-lib/shadcn'; import { useTranslation } from 'next-i18next'; import { useState, useCallback, useRef, useEffect } from 'react'; import type { IModelTestResult } from './LlmproviderManage'; +import { testImageModelCapability, TEXT_MODEL_TIMEOUT_MS, withTimeout } from './model-test-utils'; import { generateModelKeyList, parseModelKey } from './utils'; interface IBatchTestModelsProps { @@ -33,16 +34,6 @@ interface IBatchTestModelsProps { } const CONCURRENCY = 5; -const TEXT_MODEL_TIMEOUT_MS = 120000; // 2 minutes timeout for text models -const IMAGE_MODEL_TIMEOUT_MS = 120000; // 2 minutes timeout for image models - -// Helper to wrap promise with timeout -const withTimeout = (promise: Promise, ms: number, errorMessage: string): Promise => { - return Promise.race([ - promise, - new Promise((_, reject) => setTimeout(() => reject(new Error(errorMessage)), ms)), - ]); -}; export const BatchTestModels = ({ providers, @@ -170,71 +161,11 @@ export const BatchTestModels = ({ modelKey: string, provider: Required ): Promise> => { - try { - const { type, name, apiKey, baseUrl, models } = provider; - - // Test image generation (text-to-image) - const generationResult = await withTimeout( - onTest({ - type, - name, - apiKey, - baseUrl, - models, - modelKey, - testImageGeneration: true, - }), - IMAGE_MODEL_TIMEOUT_MS, - `Timeout after ${IMAGE_MODEL_TIMEOUT_MS / 1000}s` - ); - - // Test image-to-image if generation works - let imageToImage = false; - if (generationResult.success) { - try { - const i2iResult = await withTimeout( - onTest({ - type, - name, - apiKey, - baseUrl, - models, - modelKey, - testImageGeneration: true, - testImageToImage: true, - }), - IMAGE_MODEL_TIMEOUT_MS, - `Timeout` - ); - imageToImage = i2iResult.success; - } catch { - // Image-to-image not supported, that's ok - } - } - - if (!generationResult.success) { - return { - status: 'failed', - error: generationResult.response || 'Image generation test failed', - isImageModel: true, - }; - } - - return { - status: 'success', - isImageModel: true, - imageAbility: { - generation: true, - imageToImage, - }, - }; - } catch (error) { - return { - status: 'failed', - isImageModel: true, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } + return testImageModelCapability({ + modelKey, + provider, + onTest, + }); }, [onTest] ); diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/LlmProviderForm.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/LlmProviderForm.tsx index ed40795316..7337393711 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/LlmProviderForm.tsx +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/LlmProviderForm.tsx @@ -1,7 +1,12 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { zodResolver } from '@hookform/resolvers/zod'; import { AlertCircle, Check, Loader2, Plus, X, Eye, Image, HelpCircle } from '@teable/icons'; -import { llmProviderSchema, LLMProviderType, chatModelAbilityType } from '@teable/openapi'; +import { + getImageModelTagsFromAbility, + llmProviderSchema, + LLMProviderType, + chatModelAbilityType, +} from '@teable/openapi'; import type { ITestLLMVo, ITestLLMRo, @@ -42,6 +47,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useIsCloud } from '@/features/app/hooks/useIsCloud'; import { LLM_PROVIDERS } from './constant'; +import { testImageModelCapability, TEXT_MODEL_TIMEOUT_MS, withTimeout } from './model-test-utils'; const CUSTOM_MODEL_DOC_URL = 'https://help.teable.ai/en/basic/ai/custom-model'; @@ -61,18 +67,8 @@ interface IModelTestStatus { isImageModel?: boolean; } -const TEXT_MODEL_TIMEOUT_MS = 120000; // 2 minutes timeout for text models -const IMAGE_MODEL_TIMEOUT_MS = 120000; // 2 minutes timeout for image models const CONCURRENCY = 3; // Concurrent test count -// Helper to wrap promise with timeout -const withTimeout = (promise: Promise, ms: number, errorMessage: string): Promise => { - return Promise.race([ - promise, - new Promise((_, reject) => setTimeout(() => reject(new Error(errorMessage)), ms)), - ]); -}; - type ErrorPattern = { keywords: string[]; suggestion: string; @@ -565,72 +561,11 @@ export const LLMProviderForm = ({ if (!onTest) { return { status: 'failed', error: 'Test function not provided', isImageModel: true }; } - try { - const { type, name, apiKey, baseUrl, models } = provider; - const modelKey = `${type}@${model}@${name}`; - - // Test image generation (text-to-image) - const generationResult = await withTimeout( - onTest({ - type, - name, - apiKey, - baseUrl, - models, - modelKey, - testImageGeneration: true, - }), - IMAGE_MODEL_TIMEOUT_MS, - `Timeout after ${IMAGE_MODEL_TIMEOUT_MS / 1000}s` - ); - - // Test image-to-image if generation works - let imageToImage = false; - if (generationResult.success) { - try { - const i2iResult = await withTimeout( - onTest({ - type, - name, - apiKey, - baseUrl, - models, - modelKey, - testImageGeneration: true, - testImageToImage: true, - }), - IMAGE_MODEL_TIMEOUT_MS, - `Timeout` - ); - imageToImage = i2iResult.success; - } catch { - // Image-to-image not supported, that's ok - } - } - - if (!generationResult.success) { - return { - status: 'failed', - error: generationResult.response || 'Image generation test failed', - isImageModel: true, - }; - } - - return { - status: 'success', - isImageModel: true, - imageAbility: { - generation: true, - imageToImage, - }, - }; - } catch (error) { - return { - status: 'failed', - isImageModel: true, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } + return testImageModelCapability({ + modelKey: `${provider.type}@${model}@${provider.name}`, + provider, + onTest, + }); }, [onTest] ); @@ -723,12 +658,14 @@ export const LLMProviderForm = ({ successCount++; // Save test result to form's modelConfigs so it persists on submit const currentConfigs = form.getValues('modelConfigs') ?? {}; + const tags = getImageModelTagsFromAbility(result.imageAbility, currentConfigs[model]?.tags); form.setValue('modelConfigs', { ...currentConfigs, [model]: { ...currentConfigs[model], ability: result.ability, imageAbility: result.imageAbility, + ...(result.imageAbility ? { tags } : {}), testedAt: Date.now(), }, }); diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/constant.ts b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/constant.ts index d5cb4f32cd..74b41926a6 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/constant.ts +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/constant.ts @@ -32,6 +32,8 @@ import { Morph, Inception, Stealth, + Prodia, + Recraft, } from '@teable/icons'; import type { GatewayModelProvider } from '@teable/openapi'; import { LLMProviderType } from '@teable/openapi'; @@ -199,6 +201,8 @@ export const GATEWAY_PROVIDER_ICONS: Record< openai: Openai, perplexity: Perplexity, 'prime-intellect': PrimeIntellect, + prodia: Prodia, + recraft: Recraft, stealth: Stealth, vercel: Vercel, voyage: Voyage, diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/model-test-utils.ts b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/model-test-utils.ts new file mode 100644 index 0000000000..37cf239f59 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/model-test-utils.ts @@ -0,0 +1,109 @@ +import type { IImageModelAbility, ITestLLMRo, ITestLLMVo, LLMProvider } from '@teable/openapi'; +import { supportsKnownImageInputForImageModel } from '@teable/openapi'; +import { parseModelKey } from './utils'; + +export const TEXT_MODEL_TIMEOUT_MS = 120000; +export const IMAGE_MODEL_TIMEOUT_MS = 120000; + +export const withTimeout = async ( + promise: Promise, + ms: number, + errorMessage: string +): Promise => { + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(errorMessage)), ms); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +}; + +interface IImageModelTestResult { + status: 'success' | 'failed'; + error?: string; + isImageModel: true; + imageAbility?: IImageModelAbility; +} + +interface ITestImageModelCapabilityParams { + modelKey: string; + provider: Required; + onTest: (data: ITestLLMRo) => Promise; +} + +export const testImageModelCapability = async ({ + modelKey, + provider, + onTest, +}: ITestImageModelCapabilityParams): Promise => { + try { + const { type, name, apiKey, baseUrl, models } = provider; + const { model } = parseModelKey(modelKey); + + const generationResult = await withTimeout( + onTest({ + type, + name, + apiKey, + baseUrl, + models, + modelKey, + testImageGeneration: true, + }), + IMAGE_MODEL_TIMEOUT_MS, + `Timeout after ${IMAGE_MODEL_TIMEOUT_MS / 1000}s` + ); + + if (!generationResult.success) { + return { + status: 'failed', + error: generationResult.response || 'Image generation test failed', + isImageModel: true, + }; + } + + let imageToImage = supportsKnownImageInputForImageModel(type, model); + if (!imageToImage) { + try { + const i2iResult = await withTimeout( + onTest({ + type, + name, + apiKey, + baseUrl, + models, + modelKey, + testImageGeneration: true, + testImageToImage: true, + }), + IMAGE_MODEL_TIMEOUT_MS, + 'Timeout' + ); + imageToImage = i2iResult.success; + } catch { + // Image-to-image support remains optional for unknown image models. + } + } + + return { + status: 'success', + isImageModel: true, + imageAbility: { + generation: true, + imageToImage, + }, + }; + } catch (error) { + return { + status: 'failed', + isImageModel: true, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +}; diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/utils.spec.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/utils.spec.tsx new file mode 100644 index 0000000000..59f506ff4d --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/utils.spec.tsx @@ -0,0 +1,129 @@ +import type { LLMProvider } from '@teable/openapi'; +import { LLMProviderType } from '@teable/openapi'; +import { describe, expect, it } from 'vitest'; +import { normalizeLLMProviderModelConfigs } from './utils'; + +describe('normalizeLLMProviderModelConfigs', () => { + it('removes stale hidden configs and backfills known OpenAI image abilities', () => { + const provider: LLMProvider = { + type: LLMProviderType.OPENAI, + name: 'teable', + apiKey: 'test', + models: 'gpt-image-1,gpt-image-1.5,gpt-image-2', + modelConfigs: { + 'gpt-5.4': { + ability: { + image: true, + }, + testedAt: 1, + }, + 'gpt-image-1': { + isImageModel: true, + testedAt: 2, + }, + 'gpt-image-1.5': { + isImageModel: true, + testedAt: 3, + }, + 'gpt-image-2': { + isImageModel: true, + imageAbility: { + generation: true, + imageToImage: true, + }, + tags: ['image-generation', 'vision'], + testedAt: 4, + }, + }, + }; + + const normalized = normalizeLLMProviderModelConfigs(provider); + + expect(Object.keys(normalized.modelConfigs ?? {})).toEqual([ + 'gpt-image-1', + 'gpt-image-1.5', + 'gpt-image-2', + ]); + expect(normalized.modelConfigs?.['gpt-image-1']?.imageAbility).toEqual({ + generation: true, + imageToImage: true, + }); + expect(normalized.modelConfigs?.['gpt-image-1']?.modelType).toBe('image'); + expect(normalized.modelConfigs?.['gpt-image-1']?.tags).toEqual(['image-generation', 'vision']); + expect(normalized.modelConfigs?.['gpt-image-1.5']?.imageAbility).toEqual({ + generation: true, + imageToImage: true, + }); + }); + + it('does not infer image ability for OpenAI-compatible custom providers', () => { + const provider: LLMProvider = { + type: LLMProviderType.OPENAI_COMPATIBLE, + name: 'custom', + apiKey: 'test', + models: 'gpt-image-2', + modelConfigs: { + 'gpt-image-2': { + isImageModel: true, + }, + }, + }; + + const normalized = normalizeLLMProviderModelConfigs(provider); + + expect(normalized.modelConfigs?.['gpt-image-2']?.imageAbility).toBeUndefined(); + expect(normalized.modelConfigs?.['gpt-image-2']?.tags).toBeUndefined(); + }); + + it('cleans image ability tags when a model is no longer marked as an image model', () => { + const provider: LLMProvider = { + type: LLMProviderType.OPENAI, + name: 'teable', + apiKey: 'test', + models: 'gpt-image-2', + modelConfigs: { + 'gpt-image-2': { + isImageModel: false, + modelType: 'image', + imageAbility: { + generation: true, + imageToImage: true, + }, + tags: ['image-generation', 'vision', 'tool-use'], + }, + }, + }; + + const normalized = normalizeLLMProviderModelConfigs(provider); + + expect(normalized.modelConfigs?.['gpt-image-2']?.imageAbility).toBeUndefined(); + expect(normalized.modelConfigs?.['gpt-image-2']?.modelType).toBeUndefined(); + expect(normalized.modelConfigs?.['gpt-image-2']?.tags).toEqual(['vision', 'tool-use']); + }); + + it('backfills model type for known Google image-generation language models', () => { + const provider: LLMProvider = { + type: LLMProviderType.GOOGLE, + name: 'google', + apiKey: 'test', + models: 'gemini-3-pro-image', + modelConfigs: { + 'gemini-3-pro-image': { + isImageModel: true, + }, + }, + }; + + const normalized = normalizeLLMProviderModelConfigs(provider); + + expect(normalized.modelConfigs?.['gemini-3-pro-image']?.modelType).toBe('language'); + expect(normalized.modelConfigs?.['gemini-3-pro-image']?.imageAbility).toEqual({ + generation: true, + imageToImage: true, + }); + expect(normalized.modelConfigs?.['gemini-3-pro-image']?.tags).toEqual([ + 'image-generation', + 'vision', + ]); + }); +}); diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/utils.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/utils.tsx index 7c7ab905cf..c2e27e6257 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/utils.tsx +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/utils.tsx @@ -2,11 +2,17 @@ import { DeepThinking, Eye, ImageGeneration, Audio } from '@teable/icons'; import type { IGatewayModel, IImageModelDefination, + IModelConfig, ISimpleLLMProvider, ITextModelDefination, LLMProvider, } from '@teable/openapi'; -import { LLMProviderType } from '@teable/openapi'; +import { + getImageModelTagsFromAbility, + getImageModelConfig, + getKnownImageModelAbility, + LLMProviderType, +} from '@teable/openapi'; import type { TFunction } from 'next-i18next'; import type { ReactNode } from 'react'; import { Trans } from 'react-i18next'; @@ -14,12 +20,81 @@ import { Trans } from 'react-i18next'; // Fixed name for AI Gateway provider in modelKey export const AI_GATEWAY_PROVIDER_NAME = 'teable'; +export const parseProviderModels = (models: string | undefined): string[] => + models + ?.split(',') + .map((model) => model.trim()) + .filter(Boolean) ?? []; + +const compactModelConfig = (config: IModelConfig): IModelConfig => + Object.fromEntries( + Object.entries(config).filter(([, value]) => value !== undefined) + ) as IModelConfig; + +const hasModelConfigValue = (config: IModelConfig): boolean => Object.keys(config).length > 0; + +const clearImageGenerationTag = ( + tags: IModelConfig['tags'] | undefined +): IModelConfig['tags'] | undefined => { + const nextTags = tags?.filter((tag) => tag !== 'image-generation'); + return nextTags?.length ? nextTags : undefined; +}; + +export const normalizeLLMProviderModelConfigs = (provider: LLMProvider): LLMProvider => { + const models = parseProviderModels(provider.models); + const currentConfigs = provider.modelConfigs ?? {}; + const modelConfigs = models.reduce>((acc, model) => { + const currentConfig = currentConfigs[model]; + if (!currentConfig) return acc; + + if (!currentConfig.isImageModel) { + const nextConfig = compactModelConfig({ + ...currentConfig, + imageAbility: undefined, + modelType: undefined, + tags: clearImageGenerationTag(currentConfig.tags), + }); + + if (hasModelConfigValue(nextConfig)) { + acc[model] = nextConfig; + } + + return acc; + } + + const imageAbility = + currentConfig.imageAbility ?? getKnownImageModelAbility(provider.type, model); + const knownImageModelConfig = getImageModelConfig(provider.type, model); + const tags = imageAbility + ? getImageModelTagsFromAbility(imageAbility, currentConfig.tags) + : currentConfig.tags; + const nextConfig = compactModelConfig({ + ...currentConfig, + ability: undefined, + imageAbility, + modelType: currentConfig.modelType ?? knownImageModelConfig?.modelType, + tags, + }); + + if (hasModelConfigValue(nextConfig)) { + acc[model] = nextConfig; + } + + return acc; + }, {}); + + return { + ...provider, + modelConfigs: Object.keys(modelConfigs).length ? modelConfigs : undefined, + }; +}; + export const generateModelKeyList = (llmProviders: ISimpleLLMProvider[] | LLMProvider[]) => { return llmProviders .map((provider) => { const { models, type, name, isInstance } = provider; const modelConfigs = 'modelConfigs' in provider ? provider.modelConfigs : undefined; - return models.split(',').map((model) => { + return parseProviderModels(models).map((model) => { const config = modelConfigs?.[model]; return { modelKey: `${type}@${model}@${name}`, diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx index b3d681406e..1c794a9ea1 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx @@ -186,6 +186,7 @@ export const BaseNodeTree = (props: IBaseNodeTreeProps) => { const draggedItemsRef = useRef[]>([]); const treeItemsRef = useRef(treeItems); const viewportRef = useRef(null); + const focusedNodeIdRef = useRef(null); const [selectedItems, setSelectedItems] = useState([]); const [expandedItemsMap, setExpandedItemsMap] = useLocalStorage>( LocalStorageKeys.BaseNodeTreeExpandedItems, @@ -328,6 +329,7 @@ export const BaseNodeTree = (props: IBaseNodeTreeProps) => { getItemName: (item) => getNodeName(item.getItemData()), isItemFolder: (item) => item.getItemData().resourceType === BaseNodeResourceType.Folder, canReorder: true, + canDrag: () => !editingNodeId, canDrop: (items, target) => { // Basic validation if (editingNodeId || !canMoveNode || items.length !== 1) return false; @@ -344,13 +346,15 @@ export const BaseNodeTree = (props: IBaseNodeTreeProps) => { } // === Folder items === + const subtreeDepth = getMaxFolderSubtreeDepth(items[0].getId(), treeItemsRef.current); + if (isReordering) { - // Reorder at level 0, 1: ✅ | Reorder at level >= 2: ❌ - return target.dragLineLevel < maxFolderDepth; + return target.dragLineLevel + subtreeDepth < maxFolderDepth; } - // Drop into level 0 folder: ✅ | Drop into level 1+ folder or non-folder: ❌ - return target.item.isFolder() && getItemLevel(target.item) < maxFolderDepth - 1; + return ( + target.item.isFolder() && getItemLevel(target.item) + subtreeDepth < maxFolderDepth - 1 + ); }, onDrop: handleDrop, onPrimaryAction: handlePrimaryAction, @@ -447,41 +451,55 @@ export const BaseNodeTree = (props: IBaseNodeTreeProps) => { } }, [baseResource]); - useEffect(() => { - if (Object.keys(treeItems).length === 0) return; + const currentRouteNodeId = useMemo(() => { const nodes = Object.values(treeItems); const { resourceType } = baseResource; - const node = nodes.find( - (node) => node.resourceType === resourceType && node.resourceId === currentResourceId + return ( + nodes.find( + (node) => node.resourceType === resourceType && node.resourceId === currentResourceId + )?.id ?? null ); - if (!node) { + }, [treeItems, baseResource, currentResourceId]); + + useEffect(() => { + if (Object.keys(treeItems).length === 0) return; + if (!currentRouteNodeId) { setSelectedItems([]); return; } - const parentIds = getAllParentIds(node.id); + const parentIds = getAllParentIds(currentRouteNodeId); if (parentIds.length > 0) { setExpandedItems((prev) => [...new Set([...(prev ?? []), ...parentIds])]); } - setSelectedItems([node.id]); - }, [ - treeItems, - baseResource, - currentResourceId, - getAllParentIds, - setExpandedItems, - setSelectedItems, - ]); + setSelectedItems([currentRouteNodeId]); + }, [treeItems, currentRouteNodeId, getAllParentIds, setExpandedItems, setSelectedItems]); useEffect(() => { - if (selectedItems.length === 0) return; if (Object.keys(treeItems).length === 0) return; - const focusItem = tree.getItemInstance(selectedItems[0]); - if (focusItem) { - focusItem.setFocused(); + if (selectedItems.length === 0) { + focusedNodeIdRef.current = null; + return; + } + const currentId = selectedItems[0]; + if (focusedNodeIdRef.current === currentId) return; + const focusItem = tree.getItemInstance(currentId); + if (!focusItem) return; + + focusItem.setFocused(); + focusedNodeIdRef.current = currentId; + + const draggedNodeId = draggedItemsRef.current[0]?.getId(); + if (!draggedNodeId) { focusItem.scrollTo({ block: 'nearest', inline: 'nearest' }); + return; } - }, [selectedItems, tree, treeItems]); + + const isCurrentRouteNode = currentId === currentRouteNodeId; + if (draggedNodeId !== currentId || isCurrentRouteNode) { + draggedItemsRef.current = []; + } + }, [currentRouteNodeId, selectedItems, tree, treeItems]); useEffect(() => { if (!highlightedTableId || Object.keys(treeItems).length === 0) return; @@ -738,9 +756,8 @@ export const BaseNodeTree = (props: IBaseNodeTreeProps) => { { if (e.key === 'Enter') { const newVal = e.currentTarget.value; @@ -943,6 +960,19 @@ const getItemLevel = (item: ItemInstance) => { return meta.level; }; +const getMaxFolderSubtreeDepth = ( + itemId: string, + treeItems: Record +): number => { + const item = treeItems[itemId]; + if (!item?.children?.length) return 0; + const folderChildren = item.children.filter( + (childId) => treeItems[childId]?.resourceType === BaseNodeResourceType.Folder + ); + if (folderChildren.length === 0) return 0; + return 1 + Math.max(...folderChildren.map((id) => getMaxFolderSubtreeDepth(id, treeItems))); +}; + const checkCanCreateFolder = (item: ItemInstance, maxFolderDepth: number) => { const level = getItemLevel(item); return level < maxFolderDepth - 1; diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx index 6583b06246..eb9c060ad4 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx @@ -1,11 +1,18 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { hasPermission } from '@teable/core'; import { ChevronsLeft, ChevronDown, Database, HelpCircle, Pencil, Share2 } from '@teable/icons'; -import { CollaboratorType, getBaseList, getSharedBase, updateBase } from '@teable/openapi'; +import { + CollaboratorType, + getBaseList, + getSharedBase, + type IBaseV2StatusVo, + updateBase, +} from '@teable/openapi'; import { ReactQueryKeys } from '@teable/sdk/config'; import { useBase } from '@teable/sdk/hooks'; import { useIsReadOnlyPreview } from '@teable/sdk/hooks/use-is-readonly-preview'; import { + Badge, cn, DropdownMenu, DropdownMenuItem, @@ -15,6 +22,10 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, Input, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, DropdownMenuSeparator, } from '@teable/ui-lib'; import { ArrowLeft, Send } from 'lucide-react'; @@ -40,6 +51,8 @@ const BaseDropdownMenu = ({ collaboratorType, currentBaseId, baseName, + isCanary, + v2Status, isBaseShared, disabled, }: { @@ -52,6 +65,8 @@ const BaseDropdownMenu = ({ collaboratorType?: CollaboratorType; currentBaseId: string; baseName: string; + isCanary?: boolean; + v2Status?: IBaseV2StatusVo; isBaseShared: boolean; disabled?: boolean; }) => { @@ -59,6 +74,11 @@ const BaseDropdownMenu = ({ const isCloud = useIsCloud(); const [open, setOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false); + const useV2 = v2Status?.useV2 ?? Boolean(isCanary); + const versionLabel = useV2 ? 'v2' : 'v1'; + const versionTitle = v2Status?.reason + ? `${versionLabel.toUpperCase()}: ${v2Status.reason}` + : versionLabel.toUpperCase(); const isSpaceCollaborator = collaboratorType === CollaboratorType.Space; const { data: spaceBases } = useQuery({ @@ -82,14 +102,29 @@ const BaseDropdownMenu = ({ {children} e.stopPropagation()} > + + + + + + {versionLabel} + + + + {versionTitle} + + -
+
{t('common:actions.backToSpace')}
@@ -303,12 +338,14 @@ export const BaseSidebarHeaderLeft = ({ creditUsage }: { creditUsage?: React.Rea collaboratorType={base.collaboratorType} currentBaseId={base.id} baseName={base.name} + isCanary={base.isCanary} + v2Status={base.v2Status} isBaseShared={isBaseShared} disabled={isReadOnlyPreview} >
{ setOpen(false); setting.setOpen(true); }} - value={t('common:settings.personal.title')} - keywords={[t('common:settings.personal.title')]} + value={t('common:settings.allSetting')} + keywords={[t('common:settings.allSetting')]} >
- {t('common:settings.personal.title')} + {t('common:settings.allSetting')} )} diff --git a/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Components.tsx b/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Components.tsx index 18f517c5c9..f133229292 100644 --- a/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Components.tsx +++ b/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Components.tsx @@ -37,6 +37,7 @@ import { CheckCircle2, Clock, Columns3, + ExternalLink, Info, Loader2, RefreshCcw, @@ -44,6 +45,7 @@ import { Wrench, XCircle, } from 'lucide-react'; +import Link from 'next/link'; import { useTranslation } from 'next-i18next'; import { useEffect, useMemo, useState, type ComponentType } from 'react'; import { @@ -54,6 +56,7 @@ import { getLocalizedRuleDescription, getGroupDisplayName, getGroupDisplayState, + hasExecutableRepairStatements, integrityFilterStatuses, getPhaseText, translateIntegrityMessage, @@ -227,6 +230,14 @@ const inferFieldTypeFromGroup = (group: ResultGroup) => { type ManualRepairSchema = NonNullable['manualRepairSchema']>; type ManualRepairProperty = ManualRepairSchema['properties'][string]; type ManualRepairValues = Record; +type RepairRuleHandler = ( + result: IntegrityResult, + manualRepairValues?: ManualRepairValues +) => Promise; +type RepairRulePreviewHandler = ( + result: IntegrityResult, + manualRepairValues?: ManualRepairValues +) => Promise; const getManualRepairDefaultValues = (manualRepairSchema?: ManualRepairSchema) => { return Object.fromEntries( @@ -436,49 +447,335 @@ const ManualRepairDialog = ({ ); }; +const getPreviewResults = (results: IntegrityResult[]) => + results.filter((result) => result.status !== 'running' && result.status !== 'pending'); + +const canPreviewRepairResult = (result: IntegrityResult) => + result.status === 'error' || result.status === 'warn' || result.status === 'skipped'; + +const getRuleRepairTooltipText = ( + t: Translate, + result: IntegrityResult, + canPreview: boolean, + reason?: string, + description?: string +) => + reason || + description || + (result.repair || canPreview + ? t('table:table.integrity.v2.repairPreviewTooltip') + : t('table:table.integrity.v2.repairUnavailable')); + +const getRepairPreviewDisabledReason = ( + t: Translate, + result: IntegrityResult, + onPreviewRepairRule?: RepairRulePreviewHandler +) => { + if (!canPreviewRepairResult(result)) { + return t('table:table.integrity.v2.repairPreviewUnavailableStatus'); + } + + if (!onPreviewRepairRule) { + return t('table:table.integrity.v2.repairUnavailable'); + } + + if (!result.tableId) { + return t('table:table.integrity.v2.repairPreviewMissingTable'); + } + + return undefined; +}; + +const formatRepairParameters = (parameters?: ReadonlyArray) => { + if (!parameters?.length) { + return undefined; + } + + return JSON.stringify(parameters, null, 2); +}; + +const RepairRulePreviewDialog = ({ + result, + open, + dryRunResults, + isSubmitting, + canConfirm, + onOpenChange, + onConfirm, +}: { + result: IntegrityResult; + open: boolean; + dryRunResults: IntegrityResult[]; + isSubmitting: boolean; + canConfirm: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) => { + const { t } = useTranslation(['table', 'common']); + const previewResults = getPreviewResults(dryRunResults); + const displayResults = previewResults.length ? previewResults : [result]; + const statements = displayResults.flatMap( + (previewResult) => previewResult.details?.statements || [] + ); + const canExecute = canConfirm && statements.length > 0; + + return ( + + + + {t('table:table.integrity.v2.repairPreviewTitle')} + + {t('table:table.integrity.v2.repairPreviewDescription')} + + + +
+ {displayResults.map((previewResult) => { + const localizedMessage = getLocalizedResultMessage(t as Translate, previewResult); + const localizedRuleName = getLocalizedRuleDescription(t as Translate, previewResult); + const repairReason = getLocalizedRepairReason(t as Translate, previewResult); + const repairDescription = getLocalizedRepairDescription(t as Translate, previewResult); + const localizedMissing = getLocalizedDetailItems( + t as Translate, + previewResult.details?.missingItems || previewResult.details?.missing + ); + const localizedExtra = getLocalizedDetailItems( + t as Translate, + previewResult.details?.extraItems || previewResult.details?.extra + ); + + return ( +
+
+ + {localizedRuleName} + +
+ +
+
+
+ {t('table:table.integrity.v2.repairPreviewWhat')} +
+
+ {t('table:table.integrity.v2.repairPreviewTarget', { + fieldName: previewResult.fieldName, + ruleName: localizedRuleName, + })} +
+ {localizedMessage ? ( +
{localizedMessage}
+ ) : null} + {localizedMissing?.length ? ( +
+ {t('table:table.integrity.v2.detailsMissing', { + details: localizedMissing.join(', '), + })} +
+ ) : null} + {localizedExtra?.length ? ( +
+ {t('table:table.integrity.v2.detailsExtra', { + details: localizedExtra.join(', '), + })} +
+ ) : null} +
+ +
+
+ {t('table:table.integrity.v2.repairPreviewPrinciple')} +
+
+ {repairDescription || + repairReason || + t('table:table.integrity.v2.repairPreviewNoPrinciple')} +
+
+
+
+ ); + })} + +
+
+ {t('table:table.integrity.v2.repairPreviewSql')} +
+ {statements.length ? ( +
+ {statements.map((statement, index) => { + const parameters = formatRepairParameters(statement.parameters); + + return ( +
+
+                        {statement.sql}
+                      
+ {parameters ? ( +
+                          
+                            {t('table:table.integrity.v2.repairPreviewParameters')}
+                            {': '}
+                            {parameters}
+                          
+                        
+ ) : null} +
+ ); + })} +
+ ) : ( +
+ {t('table:table.integrity.v2.repairPreviewNoSql')} +
+ )} +
+ + {!canExecute ? ( + + + {statements.length + ? t('table:table.integrity.v2.repairPreviewCannotConfirm') + : t('table:table.integrity.v2.repairPreviewNoSql')} + + + ) : null} +
+ + + + + +
+
+ ); +}; + +const ManualRuleRepairAction = ({ + result, + reason, + description, + onRepairRule, +}: { + result: IntegrityResult; + reason?: string; + description?: string; + onRepairRule: RepairRuleHandler; +}) => { + const { t } = useTranslation(['table']); + + return ( + + + + + + + + +
{reason || t('table:table.integrity.v2.manualRepairNotice')}
+ {description ?
{description}
: null} +
+
+
+ ); +}; + const RuleRepairAction = ({ result, isRunning, isActive, onRepairRule, + onPreviewRepairRule, }: { result: IntegrityResult; isRunning: boolean; isActive: boolean; - onRepairRule?: ( - result: IntegrityResult, - manualRepairValues?: ManualRepairValues - ) => Promise; + onRepairRule?: RepairRuleHandler; + onPreviewRepairRule?: RepairRulePreviewHandler; }) => { const { t } = useTranslation(['table']); - const canRepair = Boolean(result.repair?.available && result.tableId && result.fieldId); + const previewDisabledReason = getRepairPreviewDisabledReason( + t as Translate, + result, + onPreviewRepairRule + ); + const canRepair = Boolean( + result.repair?.available && result.tableId && result.fieldId && onRepairRule + ); const reason = getLocalizedRepairReason(t as Translate, result); const description = getLocalizedRepairDescription(t as Translate, result); + const tooltipReason = getRuleRepairTooltipText( + t as Translate, + result, + canShowPreview, + reason, + description + ); + const [previewOpen, setPreviewOpen] = useState(false); + const [dryRunResults, setDryRunResults] = useState([]); + const [isPreviewLoading, setIsPreviewLoading] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + const canConfirmRepair = canRepair || hasExecutableRepairStatements(dryRunResults); - if (!result.repair) { + if (!canPreviewRepairResult(result) || (!onRepairRule && !onPreviewRepairRule)) { return null; } + const handlePreview = async () => { + if (!onPreviewRepairRule) { + void onRepairRule?.(result); + return; + } + + setIsPreviewLoading(true); + setDryRunResults([]); + try { + const nextDryRunResults = await onPreviewRepairRule(result); + setDryRunResults(nextDryRunResults); + setPreviewOpen(true); + } finally { + setIsPreviewLoading(false); + } + }; + + const handleConfirm = async () => { + if (!canConfirmRepair || !onRepairRule) { + return; + } + + setIsConfirming(true); + try { + const repaired = await onRepairRule(result); + if (repaired) { + setPreviewOpen(false); + } + } finally { + setIsConfirming(false); + } + }; + return (
- {result.repair.mode === 'manual' ? ( - - - - - - - - -
{reason || t('table:table.integrity.v2.manualRepairNotice')}
- {description ?
{description}
: null} -
-
-
+ {result.repair?.mode === 'manual' && onRepairRule ? ( + ) : null} @@ -488,10 +785,10 @@ const RuleRepairAction = ({ size="xs" variant="outline" className="h-7 px-2 text-xs" - disabled={!canRepair || isRunning} - onClick={() => void onRepairRule?.(result)} + disabled={Boolean(previewDisabledReason) || isPreviewLoading} + onClick={() => void handlePreview()} > - {isRunning && isActive ? ( + {(isRunning && isActive) || isPreviewLoading ? ( ) : ( @@ -500,14 +797,23 @@ const RuleRepairAction = ({ - {!canRepair || reason ? ( - -
{reason || t('table:table.integrity.v2.repairUnavailable')}
- {description ?
{description}
: null} -
- ) : null} + +
{previewDisabledReason || tooltipReason}
+ {!previewDisabledReason && reason && description ? ( +
{description}
+ ) : null} +
+ void handleConfirm()} + />
); }; @@ -559,14 +865,13 @@ const RuleResultItem = ({ isRunning, isActive, onRepairRule, + onPreviewRepairRule, }: { result: IntegrityResult; isRunning: boolean; isActive: boolean; - onRepairRule?: ( - result: IntegrityResult, - manualRepairValues?: ManualRepairValues - ) => Promise; + onRepairRule?: RepairRuleHandler; + onPreviewRepairRule?: RepairRulePreviewHandler; }) => { const { t } = useTranslation(['table']); const localizedMessage = getLocalizedResultMessage(t as Translate, result); @@ -609,6 +914,7 @@ const RuleResultItem = ({ isRunning={isRunning} isActive={isActive} onRepairRule={onRepairRule} + onPreviewRepairRule={onPreviewRepairRule} />
{shouldShowMessage ? ( @@ -885,15 +1191,14 @@ const IntegrityGroupCard = ({ isRunning, activeRepairResultId, onRepairRule, + onPreviewRepairRule, nested = false, }: { group: ResultGroup; isRunning: boolean; activeRepairResultId?: string | null; - onRepairRule?: ( - result: IntegrityResult, - manualRepairValues?: ManualRepairValues - ) => Promise; + onRepairRule?: RepairRuleHandler; + onPreviewRepairRule?: RepairRulePreviewHandler; nested?: boolean; }) => { const { t } = useTranslation(['table']); @@ -943,6 +1248,7 @@ const IntegrityGroupCard = ({ isRunning={isRunning} isActive={activeRepairResultId === result.id} onRepairRule={onRepairRule} + onPreviewRepairRule={onPreviewRepairRule} /> ))}
@@ -952,19 +1258,25 @@ const IntegrityGroupCard = ({ const IntegrityTableCard = ({ group, + baseId, isRunning, activeRepairResultId, onRepairRule, + onPreviewRepairRule, }: { group: TableResultGroup; + baseId?: string; isRunning: boolean; activeRepairResultId?: string | null; - onRepairRule?: ( - result: IntegrityResult, - manualRepairValues?: ManualRepairValues - ) => Promise; + onRepairRule?: RepairRuleHandler; + onPreviewRepairRule?: RepairRulePreviewHandler; }) => { const displayState = getGroupDisplayState(group.results); + const tableHref = + (group.baseId || baseId) && group.tableId + ? `/base/${group.baseId || baseId}/table/${group.tableId}` + : undefined; + const tableLabel = group.tableName || group.tableId; return (
@@ -972,9 +1284,17 @@ const IntegrityTableCard = ({
-
- {group.tableName || group.tableId} -
+ {tableHref ? ( + + {tableLabel} + + + ) : ( +
{tableLabel}
+ )} {group.tableId ? (
{group.tableId}
) : null} @@ -991,6 +1311,7 @@ const IntegrityTableCard = ({ isRunning={isRunning} activeRepairResultId={activeRepairResultId} onRepairRule={onRepairRule} + onPreviewRepairRule={onPreviewRepairRule} nested /> ))} @@ -1001,6 +1322,7 @@ const IntegrityTableCard = ({ export const IntegrityResultsPanel = ({ scope, + baseId, tableGroups, groupedResults, hasRun, @@ -1010,8 +1332,10 @@ export const IntegrityResultsPanel = ({ hasFilteredOutAll, activeRepairResultId, onRepairRule, + onPreviewRepairRule, }: { scope: IntegrityScope; + baseId?: string; tableGroups: TableResultGroup[]; groupedResults: ResultGroup[]; hasRun: boolean; @@ -1020,10 +1344,8 @@ export const IntegrityResultsPanel = ({ hasTarget: boolean; hasFilteredOutAll: boolean; activeRepairResultId?: string | null; - onRepairRule?: ( - result: IntegrityResult, - manualRepairValues?: ManualRepairValues - ) => Promise; + onRepairRule?: RepairRuleHandler; + onPreviewRepairRule?: RepairRulePreviewHandler; }) => { const { t } = useTranslation(['table']); const runningText = getPhaseText(t as Translate, phase, 'running'); @@ -1074,9 +1396,11 @@ export const IntegrityResultsPanel = ({ ))}
@@ -1095,6 +1419,7 @@ export const IntegrityResultsPanel = ({ isRunning={isRunning} activeRepairResultId={activeRepairResultId} onRepairRule={onRepairRule} + onPreviewRepairRule={onPreviewRepairRule} nested /> ))} diff --git a/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Dialog.tsx b/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Dialog.tsx index fed4e5c00b..d744d861e0 100644 --- a/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Dialog.tsx +++ b/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Dialog.tsx @@ -234,6 +234,58 @@ export const IntegrityV2Dialog = ({ [stopStream, t] ); + const runRuleRepairDryRun = useCallback( + async (result: IntegrityResult, manualRepairValues?: ManualRepairValues) => { + if (!result.tableId) { + return []; + } + + stopStream(); + const controller = new AbortController(); + abortRef.current = controller; + + setIsRunning(true); + setActiveRepairResultId(result.id); + setStreamError(null); + + const dryRunResults: IntegrityResult[] = []; + + try { + await streamV2TableSchemaIntegrityRepair( + result.tableId, + { + fieldId: result.fieldId || undefined, + ruleId: result.fieldId ? result.ruleId : undefined, + dryRun: true, + targetStatuses: ['warn', 'error'], + manualRepairValues, + }, + { + signal: controller.signal, + onResult: (nextResult) => { + dryRunResults.push(nextResult); + }, + } + ); + } catch (error) { + if (!controller.signal.aborted) { + const message = getErrorMessage(error, t('table:table.integrity.v2.streamError')); + setStreamError(message); + toast.error(message); + } + } finally { + if (abortRef.current === controller) { + abortRef.current = null; + setIsRunning(false); + setActiveRepairResultId(null); + } + } + + return dryRunResults; + }, + [stopStream, t] + ); + useEffect(() => { if (!open) { stopStream(); @@ -337,6 +389,7 @@ export const IntegrityV2Dialog = ({ diff --git a/apps/nextjs-app/src/features/app/blocks/design/components/integrityV2Utils.spec.ts b/apps/nextjs-app/src/features/app/blocks/design/components/integrityV2Utils.spec.ts new file mode 100644 index 0000000000..5a96e418b0 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/design/components/integrityV2Utils.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { hasExecutableRepairStatements, type IntegrityResult } from './integrityV2Utils'; + +const createResult = ( + statements?: NonNullable['statements'] +): IntegrityResult => ({ + id: 'tbl1:fld1:junction_unique:fld1', + baseId: 'bse1', + tableId: 'tbl1', + tableName: 'Table', + fieldId: 'fld1', + fieldName: 'Link', + ruleId: 'junction_unique:fld1', + ruleDescription: 'Junction table unique constraint', + status: 'success', + message: 'Dry run: 1 statements ready', + details: statements ? { statements } : undefined, + required: false, + timestamp: 1, +}); + +describe('hasExecutableRepairStatements', () => { + it('returns true when dry-run results include executable SQL', () => { + expect( + hasExecutableRepairStatements([ + createResult([ + { + sql: 'alter table "bse1"."junction" add constraint "uniq" unique ("a", "b")', + parameters: [], + }, + ]), + ]) + ).toBe(true); + }); + + it('returns false when dry-run results do not include executable SQL', () => { + expect(hasExecutableRepairStatements([createResult()])).toBe(false); + }); +}); diff --git a/apps/nextjs-app/src/features/app/blocks/design/components/integrityV2Utils.ts b/apps/nextjs-app/src/features/app/blocks/design/components/integrityV2Utils.ts index 37d7a16924..5a3c3f2946 100644 --- a/apps/nextjs-app/src/features/app/blocks/design/components/integrityV2Utils.ts +++ b/apps/nextjs-app/src/features/app/blocks/design/components/integrityV2Utils.ts @@ -30,6 +30,7 @@ export type ResultGroup = { }; export type TableResultGroup = { + baseId: string; tableId: string; tableName: string; results: IntegrityResult[]; @@ -190,7 +191,7 @@ export const groupResultsByTable = (results: IntegrityResult[]): TableResultGrou const groups = new Map(); for (const result of results) { - const key = result.tableId || '__unknown_table__'; + const key = `${result.baseId || '__unknown_base__'}:${result.tableId || '__unknown_table__'}`; const existing = groups.get(key); if (existing) { @@ -201,8 +202,9 @@ export const groupResultsByTable = (results: IntegrityResult[]): TableResultGrou groups.set(key, [result]); } - return Array.from(groups.entries()).map(([tableId, tableResults]) => ({ - tableId: tableId === '__unknown_table__' ? '' : tableId, + return Array.from(groups.values()).map((tableResults) => ({ + baseId: tableResults[0]?.baseId || '', + tableId: tableResults[0]?.tableId || '', tableName: tableResults[0]?.tableName || '', results: tableResults, groups: groupResults(tableResults), @@ -480,3 +482,7 @@ export const getLocalizedRepairReason = (t: Translate, result: IntegrityResult) export const getLocalizedRepairDescription = (t: Translate, result: IntegrityResult) => { return translateIntegrityMessage(t, result.repair?.description); }; + +export const hasExecutableRepairStatements = (results: ReadonlyArray) => { + return results.some((result) => Boolean(result.details?.statements?.length)); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/setting/access-token/AccessTokenList.tsx b/apps/nextjs-app/src/features/app/blocks/setting/access-token/AccessTokenList.tsx index 09a631b7c1..4405228bd7 100644 --- a/apps/nextjs-app/src/features/app/blocks/setting/access-token/AccessTokenList.tsx +++ b/apps/nextjs-app/src/features/app/blocks/setting/access-token/AccessTokenList.tsx @@ -130,7 +130,7 @@ export const AccessTokenList = (props: IAccessTokenListProps) => { {scopes .slice(0, 2) - .map((action) => actionStaticMap[action as Action].description) + .map((action) => actionStaticMap[action as Action]?.description ?? action) .join('; ')} {scopesMoreLen ? ` ${t('token:moreScopes', { len: scopesMoreLen })}` : ''} diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx b/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx index 966678c501..e740f3ba55 100644 --- a/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx +++ b/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx @@ -88,7 +88,12 @@ export const GridViewBase = (props: IGridViewProps) => { const isTouchDevice = useIsTouchDevice(); const { setSelection, openStatisticMenu, openGroupHeaderMenu, openHeaderMenu } = useGridViewStore(); - const { columns: originalColumns, cellValue2GridDisplay } = useGridColumns(); + const { setGridRef, searchCursor, highlightedFieldId } = useGridSearchStore(); + const { columns: originalColumns, cellValue2GridDisplay } = useGridColumns( + undefined, + undefined, + highlightedFieldId + ); const { columns, onColumnResize } = useGridColumnResize(originalColumns); const { columnStatistics } = useGridColumnStatistics(columns); const { onColumnOrdered } = useGridColumnOrder(); @@ -97,7 +102,6 @@ export const GridViewBase = (props: IGridViewProps) => { const allFields = useFields({ withHidden: true }); const customIcons = useGridIcons(); const { openTooltip, closeTooltip } = useGridTooltipStore(); - const { setGridRef, searchCursor } = useGridSearchStore(); const buttonClickStatusHook = useButtonClickStatus(tableId, shareId); const prepare = isHydrated && view && columns.length; diff --git a/apps/nextjs-app/src/features/app/blocks/space-setting/SpaceInnerSettingModal.tsx b/apps/nextjs-app/src/features/app/blocks/space-setting/SpaceInnerSettingModal.tsx index f609e72203..9099779632 100644 --- a/apps/nextjs-app/src/features/app/blocks/space-setting/SpaceInnerSettingModal.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space-setting/SpaceInnerSettingModal.tsx @@ -1,42 +1,28 @@ +import { Dialog, DialogContent, DialogTrigger } from '@teable/ui-lib/shadcn'; +import { useCallback, useEffect, useState } from 'react'; import { - Dialog, - DialogContent, - DialogTrigger, - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from '@teable/ui-lib/shadcn'; -import { Settings, Users } from 'lucide-react'; -import { useTranslation } from 'next-i18next'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { spaceConfig } from '@/features/i18n/space.config'; -import { CollaboratorPage } from './collaborator'; -import { GeneralPage } from './general'; + UnifiedSettingDialogContent, + type UnifiedSettingTab, +} from '@/features/app/components/setting/UnifiedSettingDialogContent'; +import { SpaceSettingTab } from './types'; interface ISpaceInnerSettingModalProps { open?: boolean; setOpen?: (open: boolean) => void; - defaultTab?: SettingTab; + defaultTab?: SpaceSettingTab; children: React.ReactNode; } -export enum SettingTab { - General = 'general', - Collaborator = 'collaborator', - Plan = 'plan', -} +export { SpaceSettingTab as SettingTab }; export const SpaceInnerSettingModal = (props: ISpaceInnerSettingModalProps) => { const { children, open: controlledOpen, setOpen: controlledSetOpen, - defaultTab = SettingTab.General, + defaultTab = SpaceSettingTab.General, } = props; - const { t } = useTranslation(spaceConfig.i18nNamespaces); - const [internalOpen, setInternalOpen] = useState(false); const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : internalOpen; @@ -52,58 +38,14 @@ export const SpaceInnerSettingModal = (props: ISpaceInnerSettingModalProps) => { [controlledSetOpen, isControlled, setInternalOpen] ); - const [tab, setTab] = useState(defaultTab); + const [tab, setTab] = useState(defaultTab); + useEffect(() => { if (open) { setTab(defaultTab); } }, [open, defaultTab]); - const tabList = useMemo(() => { - return [ - { - key: SettingTab.General, - name: t('space:spaceSetting.general'), - Icon: Settings, - }, - { - key: SettingTab.Collaborator, - name: t('space:spaceSetting.collaborators'), - Icon: Users, - }, - ]; - }, [t]); - - const content = ( - setTab(value as SettingTab)} - className="flex h-full gap-0 overflow-hidden" - > - - {tabList.map(({ key, name, Icon }) => { - return ( - - - {name} - - ); - })} - - - - - - - - - ); - return ( {children} @@ -111,7 +53,12 @@ export const SpaceInnerSettingModal = (props: ISpaceInnerSettingModalProps) => { className="flex h-[85%] max-h-[85%] max-w-[80%] flex-col gap-0 p-0 transition-[max-width] duration-300" onOpenAutoFocus={(e) => e.preventDefault()} > - {content} + ); diff --git a/apps/nextjs-app/src/features/app/blocks/space-setting/collaborator/CollaboratorPage.tsx b/apps/nextjs-app/src/features/app/blocks/space-setting/collaborator/CollaboratorPage.tsx index 9d7bb8db31..f368f0396f 100644 --- a/apps/nextjs-app/src/features/app/blocks/space-setting/collaborator/CollaboratorPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space-setting/collaborator/CollaboratorPage.tsx @@ -11,21 +11,23 @@ import { Collaborators } from '@/features/app/components/collaborator-manage/spa import { SpaceSettingContainer } from '@/features/app/components/SpaceSettingContainer'; import { spaceConfig } from '@/features/i18n/space.config'; -export const CollaboratorPage = () => { +export const CollaboratorPage = ({ spaceId: spaceIdProp }: { spaceId?: string } = {}) => { const router = useRouter(); const isHydrated = useIsHydrated(); const { t } = useTranslation(spaceConfig.i18nNamespaces); - const spaceId = router.query.spaceId as string; + const spaceId = (spaceIdProp ?? router.query.spaceId) as string; const { data: space } = useQuery({ - queryKey: ReactQueryKeys.space(spaceId), + queryKey: ReactQueryKeys.space(spaceId as string), queryFn: ({ queryKey }) => getSpaceById(queryKey[1]).then((res) => res.data), + enabled: Boolean(spaceId), }); const { data: collaborators } = useQuery({ - queryKey: ReactQueryKeys.spaceCollaboratorList(spaceId, { includeBase: true }), + queryKey: ReactQueryKeys.spaceCollaboratorList(spaceId as string, { includeBase: true }), queryFn: ({ queryKey }) => getSpaceCollaboratorList(queryKey[1], { includeBase: true }).then((res) => res.data), + enabled: Boolean(spaceId), }); return ( @@ -35,7 +37,7 @@ export const CollaboratorPage = () => { }} /> } @@ -44,7 +46,7 @@ export const CollaboratorPage = () => { {isHydrated && !!space && (
diff --git a/apps/nextjs-app/src/features/app/blocks/space-setting/general/GeneralPage.tsx b/apps/nextjs-app/src/features/app/blocks/space-setting/general/GeneralPage.tsx index 3731c19dc4..24749cee9b 100644 --- a/apps/nextjs-app/src/features/app/blocks/space-setting/general/GeneralPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space-setting/general/GeneralPage.tsx @@ -12,17 +12,18 @@ import { DeleteSpaceConfirm } from '@/features/app/components/space/DeleteSpaceC import { SpaceSettingContainer } from '@/features/app/components/SpaceSettingContainer'; import { spaceConfig } from '@/features/i18n/space.config'; -export const GeneralPage = () => { +export const GeneralPage = ({ spaceId: spaceIdProp }: { spaceId?: string } = {}) => { const router = useRouter(); const queryClient = useQueryClient(); const { t } = useTranslation(spaceConfig.i18nNamespaces); - const spaceId = router.query.spaceId as string; + const spaceId = (spaceIdProp ?? router.query.spaceId) as string; const [isEditing, setIsEditing] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false); const { data: space } = useQuery({ queryKey: ReactQueryKeys.space(spaceId), queryFn: ({ queryKey }) => getSpaceById(queryKey[1]).then((res) => res.data), + enabled: Boolean(spaceId), }); const { mutateAsync: updateSpaceMutator } = useMutation({ @@ -88,7 +89,7 @@ export const GeneralPage = () => {
{/* Space name */} -
+
{isEditing ? ( { onBlur={onBlur} onKeyDown={onKeydown} autoFocus - size="lg" className="px-3" /> ) : ( @@ -104,21 +104,19 @@ export const GeneralPage = () => { value={space.name} readOnly onClick={() => hasPermission(space.role, 'space|update') && setIsEditing(true)} - size="lg" className={`px-3 ${hasPermission(space.role, 'space|update') ? 'cursor-pointer' : 'cursor-default'}`} /> )}
{/* Space ID */} -
+
{ {hasPermission(space.role, 'space|delete') && ( - +
+
+
{t('table:view.locked.tip')}
+
+ + +
); diff --git a/apps/nextjs-app/src/features/app/blocks/view/calendar/components/CalendarConfig.tsx b/apps/nextjs-app/src/features/app/blocks/view/calendar/components/CalendarConfig.tsx index 90086ef5a0..efae2d276d 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/calendar/components/CalendarConfig.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/calendar/components/CalendarConfig.tsx @@ -1,6 +1,7 @@ import type { IColorConfig } from '@teable/core'; import { CellValueType, ColorConfigType, Colors, FieldType } from '@teable/core'; -import { useFields, useFieldStaticGetter, useView } from '@teable/sdk/hooks'; +import { ReadOnlyTip } from '@teable/sdk'; +import { useFields, useFieldStaticGetter, usePersonalView, useView } from '@teable/sdk/hooks'; import type { CalendarView } from '@teable/sdk/model'; import { Popover, @@ -28,6 +29,8 @@ export const CalendarConfig: FC = (props) => { const { t } = useTranslation(tableConfig.i18nNamespaces); const fields = useFields({ withHidden: true, withDenied: true }); const fieldStaticGetter = useFieldStaticGetter(); + const { isPersonalView } = usePersonalView(); + const readOnly = Boolean(view?.isLocked && !isPersonalView); const { primaryField, filteredDateFields, filteredSelectFields } = useMemo( () => ({ @@ -43,10 +46,12 @@ export const CalendarConfig: FC = (props) => { ); const onSelectChange = (key: string, value: string) => { + if (readOnly) return; view?.updateOption({ [key]: value }); }; const onColorTypeChange = (type: ColorConfigType) => { + if (readOnly) return; let config: IColorConfig = null; if (type === ColorConfigType.Field) { @@ -62,10 +67,12 @@ export const CalendarConfig: FC = (props) => { }; const onColorChange = (value: string) => { + if (readOnly) return; view?.updateOption({ colorConfig: { type: ColorConfigType.Custom, color: value as Colors } }); }; const onColorFieldIdChange = (value: string) => { + if (readOnly) return; view?.updateOption({ colorConfig: { type: ColorConfigType.Field, color: null, fieldId: value }, }); @@ -98,7 +105,12 @@ export const CalendarConfig: FC = (props) => { return ( {children} - + + {readOnly && } {fields.length > 0 ? ( {dateSelects.map(({ label, key, value }) => ( diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx b/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx index 6f7046f6e8..f9f8f1a4c5 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx @@ -190,7 +190,13 @@ export const GridViewBaseInner: React.FC = ( const taskStatusCollection = useContext(TaskStatusCollectionContext); const { shareId } = useShareContext(); const buttonClickStatusHook = useButtonClickStatus(tableId, shareId); - const { columns: originalColumns, cellValue2GridDisplay } = useGridColumns(); + const { setGridRef, searchCursor, highlightedFieldId, setRecordMap, setFields } = + useGridSearchStore(); + const { columns: originalColumns, cellValue2GridDisplay } = useGridColumns( + undefined, + undefined, + highlightedFieldId + ); const { columns, onColumnResize } = useGridColumnResize(originalColumns); const { columnStatistics } = useGridColumnStatistics(columns); const { onColumnOrdered } = useGridColumnOrder(); @@ -232,7 +238,6 @@ export const GridViewBaseInner: React.FC = ( const realRowCount = rowCount ?? ssrRecords?.length ?? 0; const fieldEditable = permission['field|update']; const { undo, redo } = useUndoRedo(); - const { setGridRef, searchCursor, setRecordMap, setFields } = useGridSearchStore(); const [expandRecord, setExpandRecord] = useState<{ tableId: string; recordId: string }>(); const [newRecords, setNewRecords] = useState(); const [cellErrors, setCellErrors] = useState([]); diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts b/apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts index d416c799e0..3ccbdf527a 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts @@ -372,9 +372,10 @@ export const useSelectionOperation = (props?: { }, []); const groupBy = view?.group; + const visibleFieldIds = useMemo(() => fields.map(({ id }) => id), [fields]); const selectionViewQuery = useMemo( - () => buildSelectionViewQuery({ personalViewCommonQuery }), - [personalViewCommonQuery] + () => buildSelectionViewQuery({ personalViewCommonQuery, visibleFieldIds }), + [personalViewCommonQuery, visibleFieldIds] ); const buildSelectionRequest = useCallback( diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/useGridSearchStore.ts b/apps/nextjs-app/src/features/app/blocks/view/grid/useGridSearchStore.ts index 0fef8f931c..84ef8dda8c 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/useGridSearchStore.ts +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/useGridSearchStore.ts @@ -33,6 +33,8 @@ interface IGridRefState { setGridRef: (ref: React.RefObject) => void; searchCursor: [number, number] | null; setSearchCursor: (cell: [number, number] | null) => void; + highlightedFieldId: string | null; + setHighlightedFieldId: (fieldId: string | null) => void; resetSearchHandler: () => void; setResetSearchHandler: (fn: () => void) => void; recordMap: IRecordIndexMap | null; @@ -48,6 +50,7 @@ interface IGridRefState { export const useGridSearchStore = create((set) => ({ gridRef: null, searchCursor: null, + highlightedFieldId: null, recordMap: null, fields: null, highlightedTableId: null, @@ -77,6 +80,14 @@ export const useGridSearchStore = create((set) => ({ }; }); }, + setHighlightedFieldId: (fieldId: string | null) => { + set((state) => { + return { + ...state, + highlightedFieldId: fieldId, + }; + }); + }, setRecordMap: (recordMap: IRecordIndexMap | null) => { set((state) => { // Notify listeners when recordMap changes diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/utils/selectionViewQuery.spec.ts b/apps/nextjs-app/src/features/app/blocks/view/grid/utils/selectionViewQuery.spec.ts index 90f90da604..f1c2f80e18 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/utils/selectionViewQuery.spec.ts +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/utils/selectionViewQuery.spec.ts @@ -7,6 +7,16 @@ describe('buildSelectionViewQuery', () => { expect(buildSelectionViewQuery({})).toBeUndefined(); }); + it('returns the current visible projection when there is no personal view query', () => { + expect( + buildSelectionViewQuery({ + visibleFieldIds: ['fldVisibleA', 'fldVisibleB'], + }) + ).toEqual({ + projection: ['fldVisibleA', 'fldVisibleB'], + }); + }); + it('returns the full personal view query to keep frontend/backend row order in sync', () => { const filter: NonNullable = { conjunction: 'and', @@ -26,6 +36,23 @@ describe('buildSelectionViewQuery', () => { expect(buildSelectionViewQuery({ personalViewCommonQuery })).toEqual(personalViewCommonQuery); }); + it('uses the live visible projection to keep selection column indexes in sync', () => { + const personalViewCommonQuery = { + ignoreViewQuery: true, + projection: ['fldOldA', 'fldOldB'], + }; + + expect( + buildSelectionViewQuery({ + personalViewCommonQuery, + visibleFieldIds: ['fldCurrentB', 'fldCurrentA'], + }) + ).toEqual({ + ignoreViewQuery: true, + projection: ['fldCurrentB', 'fldCurrentA'], + }); + }); + it('returns full query when filter differs', () => { const personalViewCommonQuery = { ignoreViewQuery: true, diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/utils/selectionViewQuery.ts b/apps/nextjs-app/src/features/app/blocks/view/grid/utils/selectionViewQuery.ts index 89d4bfd641..f8831cd256 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/utils/selectionViewQuery.ts +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/utils/selectionViewQuery.ts @@ -12,12 +12,24 @@ type ISelectionViewQuery = Pick< */ export const buildSelectionViewQuery = ({ personalViewCommonQuery, + visibleFieldIds, }: { personalViewCommonQuery?: ISelectionViewQuery; + visibleFieldIds?: string[]; }): ISelectionViewQuery | undefined => { + const projection = visibleFieldIds?.length + ? visibleFieldIds + : personalViewCommonQuery?.projection; + if (!personalViewCommonQuery) { + if (projection?.length) { + return { projection }; + } return; } - return personalViewCommonQuery; + return { + ...personalViewCommonQuery, + projection, + }; }; diff --git a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GridViewOperators.tsx b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GridViewOperators.tsx index 92aaaf880d..a2f0f9a34f 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GridViewOperators.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GridViewOperators.tsx @@ -8,11 +8,14 @@ import { AlertTriangle, } from '@teable/icons'; import { HideFields, RowHeight, Sort, Group, ViewFilter } from '@teable/sdk'; +import { useFields } from '@teable/sdk/hooks'; import { useView } from '@teable/sdk/hooks/use-view'; import { cn } from '@teable/ui-lib/shadcn'; +import { toast } from '@teable/ui-lib/shadcn/ui/sonner'; import { useTranslation } from 'next-i18next'; import { useEffect, useRef } from 'react'; import { tableConfig } from '@/features/i18n/table.config'; +import { useGridSearchStore } from '../../grid/useGridSearchStore'; import { useToolbarChange } from '../../hooks/useToolbarChange'; import { ToolBarButton } from '../ToolBarButton'; import { useToolBarStore } from './useToolBarStore'; @@ -20,6 +23,9 @@ import { useToolBarStore } from './useToolBarStore'; export const GridViewOperators: React.FC<{ disabled?: boolean }> = (props) => { const { disabled } = props; const view = useView(); + const fields = useFields(); + const allFields = useFields({ withHidden: true, withDenied: true }); + const { gridRef, setHighlightedFieldId } = useGridSearchStore(); const { onFilterChange, onRowHeightChange, @@ -32,6 +38,7 @@ export const GridViewOperators: React.FC<{ disabled?: boolean }> = (props) => { const filterRef = useRef(null); const sortRef = useRef(null); const groupRef = useRef(null); + const highlightTimeoutRef = useRef | null>(null); useEffect(() => { setFilterRef(filterRef); @@ -39,12 +46,39 @@ export const GridViewOperators: React.FC<{ disabled?: boolean }> = (props) => { setGroupRef(groupRef); }, [setFilterRef, setGroupRef, setSortRef]); + useEffect(() => { + return () => { + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current); + } + setHighlightedFieldId(null); + }; + }, [setHighlightedFieldId]); + if (!view) { return
; } return (
- + { + const columnIndex = fields.findIndex(({ id }) => id === field.id); + if (columnIndex === -1) { + const fieldName = allFields.find(({ id }) => id === field.id)?.name ?? field.name; + toast.warning(t('sdk:hidden.notInCurrentView', { fieldName })); + return; + } + gridRef?.current?.scrollToItem([columnIndex, 0]); + setHighlightedFieldId(field.id); + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current); + } + highlightTimeoutRef.current = setTimeout(() => { + setHighlightedFieldId(null); + highlightTimeoutRef.current = null; + }, 1000); + }} + > {(text, isActive) => ( = (props) => { )} - {/* - - - { - // disabled doesn't trigger the tooltip, so wrap div - } -
- - {(text: string, isActive) => ( - - - - )} - -
-
- -

{t('table:toolbar.comingSoon')}

-
-
-
*/} = (props) => const { setCollapsedStackMap } = useKanbanStackCollapsedStore(); const dialogRef = useRef(null); const isReadOnlyPreview = useIsReadOnlyPreview(); + const { isPersonalView } = usePersonalView(); const { stackFieldId, coverFieldId, isCoverFit, isEmptyStackHidden, isFieldNameHidden } = view?.options ?? {}; + const readOnly = Boolean(view?.isLocked && !isPersonalView); const onFieldSelected = async (field: IFieldVo | IFieldInstance) => { + if (readOnly) return; if (field.id === stackFieldId) return; await view?.updateOption({ stackFieldId: field.id }); const localId = generateLocalId(tableId, view?.id); @@ -65,6 +69,7 @@ export const KanbanViewOperators: React.FC<{ disabled?: boolean }> = (props) => }; const onEmptyStackHiddenChange = (checked: boolean) => { + if (readOnly) return; view?.updateOption({ isEmptyStackHidden: checked }); }; @@ -104,6 +109,7 @@ export const KanbanViewOperators: React.FC<{ disabled?: boolean }> = (props) => onEmptyStackHiddenChange(checked)} />
} isCreatable={permission['field|create']} + readOnly={readOnly} selectedFieldId={stackFieldId} onConfirm={onFieldSelected} getCreateBtnText={(fieldName) => ( diff --git a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/hook/useViewConfigurable.ts b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/hook/useViewConfigurable.ts index c60bec5595..ee27da33e0 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/hook/useViewConfigurable.ts +++ b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/hook/useViewConfigurable.ts @@ -1,14 +1,16 @@ import { ShareViewContext } from '@teable/sdk/context'; -import { usePersonalView, useTablePermission } from '@teable/sdk/hooks'; +import { usePersonalView, useTablePermission, useView } from '@teable/sdk/hooks'; import { useContext } from 'react'; export const useViewConfigurable = () => { const permission = useTablePermission(); const { isPersonalView } = usePersonalView(); + const view = useView(); const { shareId } = useContext(ShareViewContext) ?? {}; const isShareView = Boolean(shareId); return { - isViewConfigurable: permission['view|update'] || isPersonalView || isShareView, + isViewConfigurable: + permission['view|update'] || isPersonalView || isShareView || Boolean(view?.isLocked), }; }; diff --git a/apps/nextjs-app/src/features/app/components/SideBarFooter.tsx b/apps/nextjs-app/src/features/app/components/SideBarFooter.tsx index 699808153c..f41638f4d1 100644 --- a/apps/nextjs-app/src/features/app/components/SideBarFooter.tsx +++ b/apps/nextjs-app/src/features/app/components/SideBarFooter.tsx @@ -1,9 +1,10 @@ import { useSession } from '@teable/sdk'; +import { BaseContext } from '@teable/sdk/context'; import { useIsAnonymous, useIsReadOnlyPreview } from '@teable/sdk/hooks'; import { Button } from '@teable/ui-lib/shadcn'; import Link from 'next/link'; import { Trans } from 'next-i18next'; -import React from 'react'; +import React, { useContext } from 'react'; import { TeableLogo } from '@/components/TeableLogo'; import { NotificationsManage } from '@/features/app/components/notifications/NotificationsManage'; import { UserAvatar } from '@/features/app/components/user/UserAvatar'; @@ -17,6 +18,7 @@ import { UserNav } from './user/UserNav'; export const SideBarFooter: React.FC = () => { const { user } = useSession(); + const { base } = useContext(BaseContext); const isAnonymous = useIsAnonymous(); const isReadOnlyPreview = useIsReadOnlyPreview(); const { brandName } = useBrand(); @@ -61,7 +63,7 @@ export const SideBarFooter: React.FC = () => {

- + diff --git a/apps/nextjs-app/src/features/app/components/download-attachments/DownloadContent.tsx b/apps/nextjs-app/src/features/app/components/download-attachments/DownloadContent.tsx index e9b21338f5..b09329277b 100644 --- a/apps/nextjs-app/src/features/app/components/download-attachments/DownloadContent.tsx +++ b/apps/nextjs-app/src/features/app/components/download-attachments/DownloadContent.tsx @@ -15,6 +15,7 @@ import { CommandInput, CommandItem, CommandList, + CommandSeparator, Label, Popover, PopoverContent, @@ -64,7 +65,7 @@ export const DownloadContent = ({ const [downloading, setDownloading] = useState(false); const abortControllerRef = useRef(null); - const { namingFieldId, setNamingFieldId, groupByRow, setGroupByRow } = + const { namingFieldId, setNamingFieldId, noPrefix, setNoPrefix, groupByRow, setGroupByRow } = useColumnDownloadDialogStore(); const allFields = useFields({ withHidden: true, withDenied: true }); const fieldStaticGetter = useFieldStaticGetter(); @@ -96,6 +97,12 @@ export const DownloadContent = ({ [namingFieldId, setNamingFieldId] ); + // Handle "no prefix" option - toggle on/off + const handleNoPrefixSelect = useCallback(() => { + setSelectorOpen(false); + setNoPrefix(!noPrefix); + }, [noPrefix, setNoPrefix]); + // Load preview on mount useEffect(() => { const loadPreview = async () => { @@ -184,6 +191,7 @@ export const DownloadContent = ({ shareId, personalViewCommonQuery, namingField, + noPrefix, groupByRow, abortController, onProgress: updateProgress, @@ -218,6 +226,7 @@ export const DownloadContent = ({ viewId, shareId, namingField, + noPrefix, groupByRow, personalViewCommonQuery, onClose, @@ -299,8 +308,15 @@ export const DownloadContent = ({ className="w-full justify-between dark:bg-[color-mix(in_oklab,white_10%,hsl(var(--background)))]" >
- {namingFieldId ? ( - (() => { + {(() => { + if (noPrefix) { + return ( + + {t('table:download.allAttachments.noPrefixOption')} + + ); + } + if (namingFieldId) { const selectedField = namingFields.find((f) => f.id === namingFieldId); if (!selectedField) return null; const { Icon } = fieldStaticGetter(selectedField.type, { @@ -315,12 +331,13 @@ export const DownloadContent = ({ {selectedField.name} ); - })() - ) : ( - - {t('table:download.allAttachments.selectField')} - - )} + } + return ( + + {t('table:download.allAttachments.selectField')} + + ); + })()}
@@ -330,6 +347,32 @@ export const DownloadContent = ({ {t('common:noResult')} + + +
+ + {t('table:download.allAttachments.noPrefixOption')} + + + {t('table:download.allAttachments.noPrefixOptionDesc')} + +
+ +
+
+ {namingFields.map((field) => { const { Icon } = fieldStaticGetter(field.type, { diff --git a/apps/nextjs-app/src/features/app/components/download-attachments/useDownloadAttachmentsStore.ts b/apps/nextjs-app/src/features/app/components/download-attachments/useDownloadAttachmentsStore.ts index e4a1d5561a..ab65ef8e1f 100644 --- a/apps/nextjs-app/src/features/app/components/download-attachments/useDownloadAttachmentsStore.ts +++ b/apps/nextjs-app/src/features/app/components/download-attachments/useDownloadAttachmentsStore.ts @@ -13,6 +13,7 @@ interface IColumnDownloadDialogState { shareId?: string; personalViewCommonQuery?: IGetRecordsRo; namingFieldId?: string; + noPrefix: boolean; groupByRow: boolean; openDialog: (params: { @@ -25,17 +26,20 @@ interface IColumnDownloadDialogState { }) => void; closeDialog: () => void; setNamingFieldId: (namingFieldId?: string) => void; + setNoPrefix: (noPrefix: boolean) => void; setGroupByRow: (groupByRow: boolean) => void; } export const useColumnDownloadDialogStore = create((set) => ({ open: false, + noPrefix: false, groupByRow: false, openDialog: (params) => set({ open: true, namingFieldId: undefined, // Reset naming field when opening dialog + noPrefix: false, // Reset no-prefix when opening dialog groupByRow: false, // Reset group by row when opening dialog ...params, }), @@ -49,8 +53,10 @@ export const useColumnDownloadDialogStore = create(( shareId: undefined, personalViewCommonQuery: undefined, namingFieldId: undefined, + noPrefix: false, groupByRow: false, }), - setNamingFieldId: (namingFieldId) => set({ namingFieldId }), + setNamingFieldId: (namingFieldId) => set({ namingFieldId, noPrefix: false }), + setNoPrefix: (noPrefix) => set({ noPrefix, namingFieldId: undefined }), setGroupByRow: (groupByRow) => set({ groupByRow }), })); diff --git a/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.spec.tsx b/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.spec.tsx index 6e13791fb6..5915507285 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.spec.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.spec.tsx @@ -18,6 +18,20 @@ vi.mock('./DynamicFieldEditor', () => ({ })); describe('FieldSettingBase', () => { + it('disables save when editing target field is not available yet', () => { + render( + undefined} + onConfirm={() => undefined} + /> + ); + + expect(screen.getByRole('button', { name: 'common:actions.save' })).toBeDisabled(); + }); + it('hydrates local editor state when originField arrives after initial fallback render', () => { const lookupField = { id: 'fldLookup0000000001', diff --git a/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx b/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx index 5469b277db..989e9b22b8 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx @@ -139,6 +139,18 @@ export const FieldSetting = (props: IFieldSetting) => { const autoFillModeRef = useRef(null); const { t } = useTranslation(tableConfig.i18nNamespaces); + const getEditFieldId = () => { + if (operator !== FieldOperator.Edit) { + return undefined; + } + + return props.field?.id; + }; + + const notifyMissingEditField = () => { + toast.error(t('table:field.editor.fieldUnavailable')); + }; + // Fetch field stats (empty/filled count) for AI field dialog const fetchFieldStats = async (fieldId: string) => { if (!tableId) return; @@ -242,9 +254,12 @@ export const FieldSetting = (props: IFieldSetting) => { } if (operator === FieldOperator.Edit) { - const fieldId = props.field?.id; + const fieldId = getEditFieldId(); if (tableId && fieldId) { result = await convertField({ tableId, fieldId, fieldRo: field }); + } else { + notifyMissingEditField(); + return; } } @@ -264,7 +279,13 @@ export const FieldSetting = (props: IFieldSetting) => { const getPlan = async (fieldRo: IFieldRo) => { if (operator === FieldOperator.Edit) { - return await planFieldConvert({ tableId, fieldId: props.field?.id as string, fieldRo }); + const fieldId = getEditFieldId(); + if (!fieldId) { + notifyMissingEditField(); + return; + } + + return await planFieldConvert({ tableId, fieldId, fieldRo }); } return await planFieldCreate({ tableId, fieldRo }); }; @@ -298,6 +319,9 @@ export const FieldSetting = (props: IFieldSetting) => { } const plan = await getPlan(fieldRo); + if (!plan) { + return; + } setFieldRo(fieldRo); setPlan(plan); const estimateTime = plan?.estimateTime || 0; @@ -314,6 +338,9 @@ export const FieldSetting = (props: IFieldSetting) => { autoFillModeRef.current = mode; const plan = await getPlan(fieldRo); + if (!plan) { + return; + } setPlan(plan); const estimateTime = plan?.estimateTime || 0; const linkFieldCount = plan?.linkFieldCount || 0; @@ -392,6 +419,7 @@ export const FieldSettingBase = (props: IFieldSettingBase) => { const [alertVisible, setAlertVisible] = useState(false); const [updateCount, setUpdateCount] = useState(0); const [isSaving, setIsSaving] = useState(false); + const isMissingEditField = operator === FieldOperator.Edit && !originField?.id; useEffect(() => { if (updateCount > 0) { @@ -428,6 +456,11 @@ export const FieldSettingBase = (props: IFieldSettingBase) => { }; const onSave = async () => { + if (isMissingEditField) { + toast.error(t('table:field.editor.fieldUnavailable')); + return; + } + if (operator === FieldOperator.Edit && !updateCount) { onConfirm?.(); return; @@ -523,7 +556,7 @@ export const FieldSettingBase = (props: IFieldSettingBase) => { -
diff --git a/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/AttachmentFieldAiConfig.tsx b/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/AttachmentFieldAiConfig.tsx index 5cdf1e786c..a7922b99d9 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/AttachmentFieldAiConfig.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/AttachmentFieldAiConfig.tsx @@ -3,247 +3,36 @@ import type { IAttachmentFieldAIConfig, IAttachmentFieldCustomizeAIConfig, IAttachmentFieldGenerateImageAIConfig, - IImageResolution, } from '@teable/core'; -import { FieldAIActionType, FieldType, ImageQuality } from '@teable/core'; -import { ChevronDown, ChevronRight, ImageGeneration, Pencil, Settings } from '@teable/icons'; -import { - getAIConfig, - LLMProviderType, - getImageModelConfigByGatewayId, - type IImageModelConfig, -} from '@teable/openapi'; +import { FieldAIActionType, FieldType } from '@teable/core'; +import { ImageGeneration, Pencil } from '@teable/icons'; +import { getAIConfig } from '@teable/openapi'; import { useBaseId } from '@teable/sdk/hooks'; import { Selector } from '@teable/ui-lib/base'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, - Slider, - Textarea, -} from '@teable/ui-lib/shadcn'; +import { Textarea } from '@teable/ui-lib/shadcn'; import { useTranslation } from 'next-i18next'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AIModelSelect } from '@/features/app/blocks/admin/setting/components/ai-config/AiModelSelect'; import { generateModelKeyList, generateGatewayModelKeyList, - parseModelKey, } from '@/features/app/blocks/admin/setting/components/ai-config/utils'; import { RequireCom } from '@/features/app/blocks/setting/components/RequireCom'; import { tableConfig } from '@/features/i18n/table.config'; import type { IFieldEditorRo } from '../type'; -import { FieldSelect, PromptEditorContainer } from './components'; - -// Extended model capabilities for UI rendering -interface IModelCapabilities { - supportsSize: boolean; - supportsQuality: boolean; - supportsCount: boolean; - supportsImageInput: boolean; - supportsSeed: boolean; - supportsStyle: boolean; - /** Supports aspect ratio selection (for multimodal LLMs that use prompt-based control) */ - supportsAspectRatio?: boolean; - /** Supports resolution selection (for multimodal LLMs that use prompt-based control) */ - supportsResolution?: boolean; - supportedSizes?: string[]; - supportedAspectRatios?: string[]; - /** Supported resolution presets (1K, 2K, 4K) */ - supportedResolutions?: IImageResolution[]; - defaultSize?: string; - defaultAspectRatio?: string; - /** Default resolution preset */ - defaultResolution?: IImageResolution; - maxImagesPerCall?: number; - sizeType?: 'size' | 'aspectRatio' | 'both' | 'flexible'; -} - -const DEFAULT_CAPABILITIES: IModelCapabilities = { - supportsSize: true, - supportsQuality: true, - supportsCount: true, - supportsImageInput: false, - supportsSeed: false, - supportsStyle: false, - defaultSize: '1024x1024', -}; - -const MULTIMODAL_LLM_CAPABILITIES: IModelCapabilities = { - supportsSize: false, - supportsQuality: false, - supportsCount: true, - supportsImageInput: true, - supportsSeed: false, - supportsStyle: false, - sizeType: 'flexible', - // Multimodal LLMs support aspect ratio via prompt instructions (no default - let model decide) - supportsAspectRatio: true, - supportedAspectRatios: ['1:1', '16:9', '9:16', '4:3', '3:4', '21:9', '3:2', '2:3'], - // Multimodal LLMs support resolution via prompt instructions (no default - let model decide) - supportsResolution: true, - supportedResolutions: ['1K', '2K', '4K'], -}; - -/** - * Get capabilities for legacy (non-gateway) models based on model name - */ -const getLegacyModelCapabilities = (modelLower: string): IModelCapabilities | null => { - if (modelLower.includes('gemini')) return MULTIMODAL_LLM_CAPABILITIES; - if (modelLower.includes('gpt-image-1')) { - return { - ...DEFAULT_CAPABILITIES, - supportsStyle: true, - supportedSizes: ['1024x1024', '1536x1024', '1024x1536'], - }; - } - if (modelLower.includes('dall-e-3')) { - return { - ...DEFAULT_CAPABILITIES, - supportsCount: false, - supportsSeed: true, - supportsStyle: true, - supportedSizes: ['1024x1024', '1792x1024', '1024x1792'], - maxImagesPerCall: 1, - }; - } - if (modelLower.includes('dall-e-2')) { - return { - ...DEFAULT_CAPABILITIES, - supportsQuality: false, - supportedSizes: ['256x256', '512x512', '1024x1024'], - }; - } - if (modelLower.includes('grok')) { - return { ...DEFAULT_CAPABILITIES, supportsSize: false, supportsQuality: false }; - } - return null; -}; - -/** - * Get model capabilities from the new unified config or fallback to legacy detection - */ -const getModelCapabilities = ( - modelKey?: string, - gatewayModels?: Array<{ id: string; type?: string; tags?: string[] }> -): IModelCapabilities => { - if (!modelKey) return DEFAULT_CAPABILITIES; - - const { type, model } = parseModelKey(modelKey); - const modelLower = model?.toLowerCase() ?? ''; - - // For AI Gateway models, try to get config from unified config - if (type === LLMProviderType.AI_GATEWAY && model) { - const imageConfig = getImageModelConfigByGatewayId(model); - if (imageConfig) return mapImageConfigToCapabilities(imageConfig); - - // Fall back to gateway model metadata - const gatewayModel = gatewayModels?.find((m) => m.id === model); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const gatewayModelType = (gatewayModel as any)?.modelType || (gatewayModel as any)?.type; - const tags = gatewayModel?.tags ?? []; - - if (gatewayModelType === 'language' && tags.includes('image-generation')) { - return MULTIMODAL_LLM_CAPABILITIES; - } - if (gatewayModelType === 'image') { - return DEFAULT_CAPABILITIES; - } - } - - // Legacy detection for non-gateway models - if (type === LLMProviderType.GOOGLE) return MULTIMODAL_LLM_CAPABILITIES; - return getLegacyModelCapabilities(modelLower) ?? DEFAULT_CAPABILITIES; -}; - -/** - * Map IImageModelConfig to IModelCapabilities - */ -const mapImageConfigToCapabilities = (config: IImageModelConfig): IModelCapabilities => { - // Multimodal LLMs (language models with image-generation tag) support aspect ratio via prompt - const isMultimodalLLM = - config.modelType === 'language' && (config.tags?.includes('image-generation') ?? false); - - return { - supportsSize: config.sizeType === 'size' || config.sizeType === 'both', - supportsQuality: config.supportsQuality ?? false, - supportsCount: config.maxImagesPerCall !== 1, - supportsImageInput: isMultimodalLLM, - supportsSeed: config.supportsSeed ?? false, - supportsStyle: config.supportsStyle ?? false, - // Multimodal LLMs support aspect ratio via prompt instructions (no default - let model decide) - supportsAspectRatio: isMultimodalLLM || config.sizeType === 'aspectRatio', - // Multimodal LLMs support resolution via prompt instructions (no default - let model decide) - supportsResolution: isMultimodalLLM, - supportedResolutions: isMultimodalLLM ? ['1K', '2K', '4K'] : undefined, - // For multimodal LLMs, don't set defaults - only use values if explicitly specified - supportedSizes: config.supportedSizes, - supportedAspectRatios: config.supportedAspectRatios, - defaultSize: isMultimodalLLM ? undefined : config.defaultSize, - defaultAspectRatio: isMultimodalLLM ? undefined : config.defaultAspectRatio, - maxImagesPerCall: config.maxImagesPerCall, - sizeType: config.sizeType, - }; -}; - -/** - * Get default settings based on model capabilities - * For multimodal LLMs (Gemini, etc.), don't set defaults for prompt-based controls - let the model decide - */ -const getModelDefaults = ( - capabilities: IModelCapabilities -): Partial => ({ - size: capabilities.supportsSize ? capabilities.defaultSize || '1024x1024' : undefined, - quality: capabilities.supportsQuality ? ImageQuality.Medium : undefined, - n: capabilities.supportsCount ? 1 : undefined, - // Only set aspectRatio/resolution if there's an explicit default (not for multimodal LLMs) - aspectRatio: capabilities.supportsAspectRatio ? capabilities.defaultAspectRatio : undefined, - resolution: capabilities.supportsResolution ? capabilities.defaultResolution : undefined, -}); - -/** - * Calculate settings updates for initial load (only fill missing values) - * For multimodal LLMs, don't auto-fill prompt-based controls (aspectRatio, resolution) - */ -const getInitialLoadUpdates = ( - capabilities: IModelCapabilities, - currentConfig?: IAttachmentFieldGenerateImageAIConfig -): Partial => { - const updates: Partial = {}; - - if (capabilities.supportsSize && !currentConfig?.size && capabilities.defaultSize) { - updates.size = capabilities.defaultSize; - } - if (capabilities.supportsQuality && currentConfig?.quality === undefined) { - updates.quality = ImageQuality.Medium; - } - if (capabilities.supportsCount && !currentConfig?.n) { - updates.n = 1; - } - // Only auto-fill aspectRatio/resolution if there's an explicit default (not for multimodal LLMs) - if ( - capabilities.supportsAspectRatio && - !currentConfig?.aspectRatio && - capabilities.defaultAspectRatio - ) { - updates.aspectRatio = capabilities.defaultAspectRatio; - } - if ( - capabilities.supportsResolution && - !currentConfig?.resolution && - capabilities.defaultResolution - ) { - updates.resolution = capabilities.defaultResolution; - } - - return updates; -}; +import { AdvancedImageSettings, FieldSelect, PromptEditorContainer } from './components'; +import { useImageModelUiState } from './hooks'; interface IAttachmentFieldAiConfigProps { field: Partial; onChange?: (partialField: Partial) => void; } +type IAttachmentAiConfigPatch = Partial< + Omit & + Omit +>; + export const AttachmentFieldAiConfig = (props: IAttachmentFieldAiConfigProps) => { const { field, onChange } = props; const { id, aiConfig } = field; @@ -273,23 +62,27 @@ export const AttachmentFieldAiConfig = (props: IAttachmentFieldAiConfigProps) => ...generateGatewayModelKeyList(gatewayModels), ...generateModelKeyList(llmProviders), ]; - - // Get model capabilities based on the selected model - const modelCapabilities = useMemo( - () => getModelCapabilities(modelKey, gatewayModels), - [modelKey, gatewayModels] - ); - - // Check if there are any advanced options to show - const hasAdvancedOptions = useMemo(() => { - return ( - modelCapabilities.supportsSize || - modelCapabilities.supportsQuality || - modelCapabilities.supportsCount || - modelCapabilities.supportsAspectRatio || - modelCapabilities.supportsResolution - ); - }, [modelCapabilities]); + const generateImageAiConfig = aiConfig as IAttachmentFieldGenerateImageAIConfig | undefined; + + const { + supportsSize, + supportsQuality, + supportsCount, + supportsAspectRatio, + supportsResolution, + supportsImageInput, + hasAdvancedOptions, + imageSizeValues, + aspectRatioValues, + currentSize, + currentQuality, + currentCount, + currentAspectRatio, + currentResolution, + maxCount, + maxImagesPerCall, + getSettingsUpdates, + } = useImageModelUiState(modelKey, gatewayModels ?? [], generateImageAiConfig); const candidates = useMemo(() => { return [ @@ -306,68 +99,16 @@ export const AttachmentFieldAiConfig = (props: IAttachmentFieldAiConfigProps) => ]; }, [t]); - const onConfigChange = useCallback( - ( - key: - | keyof IAttachmentFieldGenerateImageAIConfig - | keyof IAttachmentFieldCustomizeAIConfig - | 'modelKey', - value: unknown - ) => { - switch (key) { - case 'type': - return onChange?.({ aiConfig: { type: value } as IAttachmentFieldAIConfig }); - case 'modelKey': - return onChange?.({ - aiConfig: { ...aiConfig, modelKey: value as string } as IAttachmentFieldAIConfig, - }); - case 'sourceFieldId': - return onChange?.({ - aiConfig: { ...aiConfig, sourceFieldId: value as string } as IAttachmentFieldAIConfig, - }); - case 'size': - return onChange?.({ - aiConfig: { ...aiConfig, size: value as string } as IAttachmentFieldAIConfig, - }); - case 'attachPrompt': - return onChange?.({ - aiConfig: { - ...aiConfig, - attachPrompt: value as string, - } as IAttachmentFieldGenerateImageAIConfig, - }); - case 'n': - return onChange?.({ - aiConfig: { ...aiConfig, n: value as number } as IAttachmentFieldGenerateImageAIConfig, - }); - case 'quality': - return onChange?.({ - aiConfig: { - ...aiConfig, - quality: value as ImageQuality, - } as IAttachmentFieldGenerateImageAIConfig, - }); - case 'aspectRatio': - return onChange?.({ - aiConfig: { - ...aiConfig, - aspectRatio: value as string, - } as IAttachmentFieldGenerateImageAIConfig, - }); - case 'resolution': - return onChange?.({ - aiConfig: { - ...aiConfig, - resolution: value as IImageResolution, - } as IAttachmentFieldGenerateImageAIConfig, - }); - case 'prompt': - return onChange?.({ - aiConfig: { ...aiConfig, prompt: value as string } as IAttachmentFieldCustomizeAIConfig, - }); - default: - throw new Error(`Unsupported key: ${key}`); - } + const setAiConfigType = useCallback( + (nextType: FieldAIActionType) => { + onChange?.({ aiConfig: { type: nextType } as IAttachmentFieldAIConfig }); + }, + [onChange] + ); + + const patchAiConfig = useCallback( + (patch: IAttachmentAiConfigPatch) => { + onChange?.({ aiConfig: { ...(aiConfig ?? {}), ...patch } as IAttachmentFieldAIConfig }); }, [aiConfig, onChange] ); @@ -388,99 +129,17 @@ export const AttachmentFieldAiConfig = (props: IAttachmentFieldAiConfigProps) => // When model changes: reset ALL settings to new model's defaults // On initial load: only fill in missing values - const updates = isModelChanged - ? getModelDefaults(modelCapabilities) - : getInitialLoadUpdates( - modelCapabilities, - currentAiConfig as IAttachmentFieldGenerateImageAIConfig - ); + const updates = getSettingsUpdates( + isModelChanged, + currentAiConfig as IAttachmentFieldGenerateImageAIConfig + ); if (Object.keys(updates).length > 0) { currentOnChange?.({ aiConfig: { ...currentAiConfig, ...updates } as IAttachmentFieldAIConfig, }); } - }, [modelKey, type, modelCapabilities]); - - const imageSizeCandidates = useMemo(() => { - // Use model-specific sizes if available - if (modelCapabilities.supportedSizes?.length) { - return modelCapabilities.supportedSizes.map((size) => ({ id: size, name: size })); - } - - // Default sizes - return [ - { id: '256x256', name: '256x256' }, - { id: '512x512', name: '512x512' }, - { id: '1024x1024', name: '1024x1024' }, - { id: '1536x1024', name: '1536x1024' }, - { id: '1024x1536', name: '1024x1536' }, - { id: '1792x1024', name: '1792x1024' }, - { id: '1024x1792', name: '1024x1792' }, - ]; - }, [modelCapabilities.supportedSizes]); - - const qualityCandidates = useMemo( - () => [ - { id: ImageQuality.Low, name: t('table:field.aiConfig.imageQuality.low') }, - { id: ImageQuality.Medium, name: t('table:field.aiConfig.imageQuality.medium') }, - { id: ImageQuality.High, name: t('table:field.aiConfig.imageQuality.high') }, - ], - [t] - ); - - const aspectRatioCandidates = useMemo(() => { - const autoOption = { id: '', name: t('table:field.aiConfig.auto') }; - // Use model-specific aspect ratios if available - if (modelCapabilities.supportedAspectRatios?.length) { - return [ - autoOption, - ...modelCapabilities.supportedAspectRatios.map((ratio) => ({ - id: ratio, - name: ratio, - })), - ]; - } - // Default aspect ratios for multimodal LLMs - return [ - autoOption, - { id: '1:1', name: '1:1' }, - { id: '16:9', name: '16:9' }, - { id: '9:16', name: '9:16' }, - { id: '4:3', name: '4:3' }, - { id: '3:4', name: '3:4' }, - { id: '21:9', name: '21:9' }, - { id: '3:2', name: '3:2' }, - { id: '2:3', name: '2:3' }, - ]; - }, [modelCapabilities.supportedAspectRatios, t]); - - const resolutionCandidates = useMemo( - () => [ - { id: '', name: t('table:field.aiConfig.auto') }, - { id: '1K', name: t('table:field.aiConfig.resolution.1K') }, - { id: '2K', name: t('table:field.aiConfig.resolution.2K') }, - { id: '4K', name: t('table:field.aiConfig.resolution.4K') }, - ], - [t] - ); - - // Get current values with defaults - const currentSize = - (aiConfig as IAttachmentFieldGenerateImageAIConfig)?.size || - modelCapabilities.defaultSize || - '1024x1024'; - const currentQuality = - (aiConfig as IAttachmentFieldGenerateImageAIConfig)?.quality ?? ImageQuality.Medium; - const currentCount = (aiConfig as IAttachmentFieldGenerateImageAIConfig)?.n || 1; - const maxCount = modelCapabilities.maxImagesPerCall || 10; - // For multimodal LLMs, aspectRatio/resolution can be undefined (let model decide) - const currentAspectRatio = - (aiConfig as IAttachmentFieldGenerateImageAIConfig)?.aspectRatio || - modelCapabilities.defaultAspectRatio; - const currentResolution = - (aiConfig as IAttachmentFieldGenerateImageAIConfig)?.resolution || - modelCapabilities.defaultResolution; + }, [getSettingsUpdates, modelKey, type]); return ( @@ -491,7 +150,7 @@ export const AttachmentFieldAiConfig = (props: IAttachmentFieldAiConfigProps) => placeholder={t('table:field.aiConfig.placeholder.type')} selectedId={type} onChange={(id) => { - onConfigChange('type', id); + setAiConfigType(id as FieldAIActionType); }} candidates={candidates} searchTip={t('sdk:common.search.placeholder')} @@ -510,7 +169,7 @@ export const AttachmentFieldAiConfig = (props: IAttachmentFieldAiConfigProps) => { - onConfigChange('modelKey', newValue); + patchAiConfig({ modelKey: newValue }); }} options={models} className="w-full px-2" @@ -525,15 +184,15 @@ export const AttachmentFieldAiConfig = (props: IAttachmentFieldAiConfigProps) => onConfigChange('prompt', value)} + onChange={(value) => patchAiConfig({ prompt: value })} label={t('table:field.aiConfig.label.prompt')} placeholder={t('table:field.aiConfig.placeholder.prompt')} required={true} isOptionDisabled={(field) => - !modelCapabilities.supportsImageInput && field.type === FieldType.Attachment + !supportsImageInput && field.type === FieldType.Attachment } getDisabledReason={(field) => - !modelCapabilities.supportsImageInput && field.type === FieldType.Attachment + !supportsImageInput && field.type === FieldType.Attachment ? t('table:field.aiConfig.hint.attachmentNotSupported') : undefined } @@ -549,12 +208,12 @@ export const AttachmentFieldAiConfig = (props: IAttachmentFieldAiConfigProps) => onConfigChange('sourceFieldId', fieldId)} + onChange={(fieldId) => patchAiConfig({ sourceFieldId: fieldId })} /> - {modelCapabilities.supportsImageInput && ( + {supportsImageInput && (

{t('table:field.aiConfig.hint.imageInputSupported')}

@@ -569,7 +228,7 @@ export const AttachmentFieldAiConfig = (props: IAttachmentFieldAiConfigProps) => className="w-full" value={(aiConfig as IAttachmentFieldGenerateImageAIConfig)?.attachPrompt || ''} onChange={(e) => { - onConfigChange('attachPrompt', e.target.value); + patchAiConfig({ attachPrompt: e.target.value }); }} />
@@ -578,107 +237,25 @@ export const AttachmentFieldAiConfig = (props: IAttachmentFieldAiConfigProps) => {/* Advanced Settings - Collapsible (shared by both modes) */} {hasAdvancedOptions && ( - - - - - {t('table:field.aiConfig.label.advancedSettings')} - - {advancedOpen ? ( - - ) : ( - - )} - - - {/* Image size */} - {modelCapabilities.supportsSize && ( -
- {t('table:field.aiConfig.label.imageSize')} - onConfigChange('size', id)} - candidates={imageSizeCandidates} - searchTip={t('sdk:common.search.placeholder')} - emptyTip={t('sdk:common.search.empty')} - /> -
- )} - - {/* Image quality */} - {modelCapabilities.supportsQuality && ( -
- {t('table:field.aiConfig.label.imageQuality')} - onConfigChange('quality', id)} - candidates={qualityCandidates} - searchTip={t('sdk:common.search.placeholder')} - emptyTip={t('sdk:common.search.empty')} - /> -
- )} - - {/* Aspect ratio (for multimodal LLMs like Gemini) */} - {modelCapabilities.supportsAspectRatio && ( -
- {t('table:field.aiConfig.label.aspectRatio')} - onConfigChange('aspectRatio', id || undefined)} - candidates={aspectRatioCandidates} - searchTip={t('sdk:common.search.placeholder')} - emptyTip={t('sdk:common.search.empty')} - /> -
- )} - - {/* Resolution (for multimodal LLMs like Gemini) */} - {modelCapabilities.supportsResolution && ( -
- {t('table:field.aiConfig.label.resolution')} - onConfigChange('resolution', id || undefined)} - candidates={resolutionCandidates} - searchTip={t('sdk:common.search.placeholder')} - emptyTip={t('sdk:common.search.empty')} - /> -
- )} - - {/* Image count */} - {modelCapabilities.supportsCount && ( -
- {t('table:field.aiConfig.label.imageCount')} -
- onConfigChange('n', Number(value[0]))} - /> - {currentCount} -
- {modelCapabilities.maxImagesPerCall === 1 && ( -

- {t('table:field.aiConfig.hint.singleImageOnly')} -

- )} -
- )} -
-
+ )} )} diff --git a/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/advanced-image-settings/AdvancedImageSettings.tsx b/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/advanced-image-settings/AdvancedImageSettings.tsx new file mode 100644 index 0000000000..50aa667506 --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/advanced-image-settings/AdvancedImageSettings.tsx @@ -0,0 +1,197 @@ +import type { IAttachmentFieldGenerateImageAIConfig } from '@teable/core'; +import { IMAGE_RESOLUTIONS, ImageQuality } from '@teable/core'; +import { ChevronDown, ChevronRight, Settings } from '@teable/icons'; +import { DEFAULT_ASPECT_RATIO_CANDIDATES } from '@teable/openapi'; +import type { IAspectRatio, IImageSize } from '@teable/openapi'; +import { Selector } from '@teable/ui-lib/base'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger, Slider } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { useMemo } from 'react'; +import { tableConfig } from '@/features/i18n/table.config'; + +type IGenerateImageConfigPatch = Partial>; + +interface IAdvancedImageSettingsProps { + open: boolean; + onOpenChange: (open: boolean) => void; + supportsSize: boolean; + supportsQuality: boolean; + supportsAspectRatio: boolean; + supportsResolution: boolean; + supportsCount: boolean; + imageSizeValues: IImageSize[]; + aspectRatioValues: IAspectRatio[]; + currentSize: string; + currentQuality: ImageQuality; + currentAspectRatio?: string; + currentResolution?: IAttachmentFieldGenerateImageAIConfig['resolution']; + currentCount: number; + maxCount: number; + maxImagesPerCall?: number; + onChange: (patch: IGenerateImageConfigPatch) => void; +} + +export const AdvancedImageSettings = (props: IAdvancedImageSettingsProps) => { + const { + open, + onOpenChange, + supportsSize, + supportsQuality, + supportsAspectRatio, + supportsResolution, + supportsCount, + imageSizeValues, + aspectRatioValues, + currentSize, + currentQuality, + currentAspectRatio, + currentResolution, + currentCount, + maxCount, + maxImagesPerCall, + onChange, + } = props; + const { t } = useTranslation(tableConfig.i18nNamespaces); + + const imageSizeCandidates = useMemo(() => { + return imageSizeValues.map((size) => ({ id: size, name: size })); + }, [imageSizeValues]); + + const qualityCandidates = useMemo( + () => [ + { id: ImageQuality.Low, name: t('table:field.aiConfig.imageQuality.low') }, + { id: ImageQuality.Medium, name: t('table:field.aiConfig.imageQuality.medium') }, + { id: ImageQuality.High, name: t('table:field.aiConfig.imageQuality.high') }, + ], + [t] + ); + + const aspectRatioCandidates = useMemo(() => { + const autoOption = { id: '', name: t('table:field.aiConfig.auto') }; + const ratios = aspectRatioValues.length ? aspectRatioValues : DEFAULT_ASPECT_RATIO_CANDIDATES; + + return [ + autoOption, + ...ratios.map((ratio) => ({ + id: ratio, + name: ratio, + })), + ]; + }, [aspectRatioValues, t]); + + const resolutionCandidates = useMemo( + () => [ + { id: '', name: t('table:field.aiConfig.auto') }, + ...IMAGE_RESOLUTIONS.map((resolution) => ({ + id: resolution, + name: t(`table:field.aiConfig.resolution.${resolution}`), + })), + ], + [t] + ); + + return ( + + + + {t('table:field.aiConfig.label.advancedSettings')} + {open ? : } + + + {supportsSize && ( +
+ {t('table:field.aiConfig.label.imageSize')} + + onChange({ size: id as IAttachmentFieldGenerateImageAIConfig['size'] }) + } + candidates={imageSizeCandidates} + searchTip={t('sdk:common.search.placeholder')} + emptyTip={t('sdk:common.search.empty')} + /> +
+ )} + + {supportsQuality && ( +
+ {t('table:field.aiConfig.label.imageQuality')} + onChange({ quality: id as ImageQuality })} + candidates={qualityCandidates} + searchTip={t('sdk:common.search.placeholder')} + emptyTip={t('sdk:common.search.empty')} + /> +
+ )} + + {supportsAspectRatio && ( +
+ {t('table:field.aiConfig.label.aspectRatio')} + + onChange({ + aspectRatio: (id || + undefined) as IAttachmentFieldGenerateImageAIConfig['aspectRatio'], + }) + } + candidates={aspectRatioCandidates} + searchTip={t('sdk:common.search.placeholder')} + emptyTip={t('sdk:common.search.empty')} + /> +
+ )} + + {supportsResolution && ( +
+ {t('table:field.aiConfig.label.resolution')} + + onChange({ + resolution: (id || + undefined) as IAttachmentFieldGenerateImageAIConfig['resolution'], + }) + } + candidates={resolutionCandidates} + searchTip={t('sdk:common.search.placeholder')} + emptyTip={t('sdk:common.search.empty')} + /> +
+ )} + + {supportsCount && ( +
+ {t('table:field.aiConfig.label.imageCount')} +
+ onChange({ n: Number(value[0]) })} + /> + {currentCount} +
+ {maxImagesPerCall === 1 && ( +

+ {t('table:field.aiConfig.hint.singleImageOnly')} +

+ )} +
+ )} +
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/advanced-image-settings/index.ts b/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/advanced-image-settings/index.ts new file mode 100644 index 0000000000..5e21c75822 --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/advanced-image-settings/index.ts @@ -0,0 +1 @@ +export * from './AdvancedImageSettings'; diff --git a/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/index.ts b/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/index.ts index 6e694ec24a..cc4ae3d048 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/index.ts +++ b/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/index.ts @@ -1,2 +1,3 @@ +export * from './advanced-image-settings'; export * from './field-select'; export * from './prompt-editor'; diff --git a/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/hooks/index.ts b/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/hooks/index.ts new file mode 100644 index 0000000000..00a50850f4 --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/hooks/index.ts @@ -0,0 +1 @@ +export * from './useImageModelUiState'; diff --git a/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/hooks/useImageModelUiState.ts b/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/hooks/useImageModelUiState.ts new file mode 100644 index 0000000000..de0a3fdcd8 --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/hooks/useImageModelUiState.ts @@ -0,0 +1,139 @@ +import type { IAttachmentFieldGenerateImageAIConfig } from '@teable/core'; +import { ImageQuality } from '@teable/core'; +import { + getImageAspectRatioCandidates, + getImageModelConfigByModelKey, + getImageSizeCandidates, + isPromptControlledImageGenerationModel, + supportsImageAspectRatioSelection, + supportsImageCountSelection, + supportsImageInputForImageModel, + supportsImageSizeSelection, +} from '@teable/openapi'; +import type { IImageModelMetadata, IResolvedImageModelConfig } from '@teable/openapi'; +import { useCallback, useMemo } from 'react'; + +const getModelDefaults = ( + resolvedModel?: IResolvedImageModelConfig +): Partial => { + const config = resolvedModel?.config; + if (!config) return {}; + + const isPromptControlledModel = isPromptControlledImageGenerationModel(config); + const sizeCandidates = getImageSizeCandidates(config); + + return { + size: supportsImageSizeSelection(config) ? config.defaultSize ?? sizeCandidates[0] : undefined, + quality: config.supportsQuality ? ImageQuality.Medium : undefined, + n: supportsImageCountSelection(config) ? 1 : undefined, + aspectRatio: + supportsImageAspectRatioSelection(config) && !isPromptControlledModel + ? config.defaultAspectRatio + : undefined, + }; +}; + +const getInitialLoadUpdates = ( + resolvedModel?: IResolvedImageModelConfig, + currentConfig?: IAttachmentFieldGenerateImageAIConfig +): Partial => { + const config = resolvedModel?.config; + if (!config) return {}; + + const updates: Partial = {}; + const isPromptControlledModel = isPromptControlledImageGenerationModel(config); + const sizeCandidates = getImageSizeCandidates(config); + const defaultSize = config.defaultSize ?? sizeCandidates[0]; + + if (supportsImageSizeSelection(config) && !currentConfig?.size && defaultSize) { + updates.size = defaultSize; + } + if (config.supportsQuality && currentConfig?.quality === undefined) { + updates.quality = ImageQuality.Medium; + } + if (supportsImageCountSelection(config) && !currentConfig?.n) { + updates.n = 1; + } + if ( + supportsImageAspectRatioSelection(config) && + !isPromptControlledModel && + !currentConfig?.aspectRatio && + config.defaultAspectRatio + ) { + updates.aspectRatio = config.defaultAspectRatio; + } + + return updates; +}; + +export const useImageModelUiState = ( + modelKey?: string, + gatewayModels: readonly IImageModelMetadata[] = [], + aiConfig?: IAttachmentFieldGenerateImageAIConfig +) => { + const resolvedImageModel = useMemo( + () => getImageModelConfigByModelKey(modelKey, gatewayModels), + [modelKey, gatewayModels] + ); + const imageModelConfig = resolvedImageModel?.config; + const isPromptControlledModel = imageModelConfig + ? isPromptControlledImageGenerationModel(imageModelConfig) + : false; + + const supportsSize = imageModelConfig ? supportsImageSizeSelection(imageModelConfig) : false; + const supportsQuality = imageModelConfig?.supportsQuality ?? false; + const supportsCount = imageModelConfig ? supportsImageCountSelection(imageModelConfig) : false; + const supportsAspectRatio = imageModelConfig + ? supportsImageAspectRatioSelection(imageModelConfig) + : false; + const supportsResolution = isPromptControlledModel; + const supportsImageInput = resolvedImageModel + ? supportsImageInputForImageModel( + resolvedImageModel.config, + resolvedImageModel.modelId, + resolvedImageModel.tags + ) + : false; + const hasAdvancedOptions = + supportsSize || supportsQuality || supportsCount || supportsAspectRatio || supportsResolution; + + const imageSizeValues = useMemo( + () => (imageModelConfig ? getImageSizeCandidates(imageModelConfig) : []), + [imageModelConfig] + ); + const aspectRatioValues = useMemo( + () => (imageModelConfig ? getImageAspectRatioCandidates(imageModelConfig) : []), + [imageModelConfig] + ); + + const getSettingsUpdates = useCallback( + (isModelChanged: boolean, currentConfig?: IAttachmentFieldGenerateImageAIConfig) => { + return isModelChanged + ? getModelDefaults(resolvedImageModel) + : getInitialLoadUpdates(resolvedImageModel, currentConfig); + }, + [resolvedImageModel] + ); + + return { + imageModelConfig, + supportsSize, + supportsQuality, + supportsCount, + supportsAspectRatio, + supportsResolution, + supportsImageInput, + hasAdvancedOptions, + imageSizeValues, + aspectRatioValues, + currentSize: + aiConfig?.size || imageModelConfig?.defaultSize || imageSizeValues[0] || '1024x1024', + currentQuality: aiConfig?.quality ?? ImageQuality.Medium, + currentCount: aiConfig?.n || 1, + currentAspectRatio: aiConfig?.aspectRatio || imageModelConfig?.defaultAspectRatio, + currentResolution: aiConfig?.resolution, + maxCount: imageModelConfig?.maxImagesPerCall || 10, + maxImagesPerCall: imageModelConfig?.maxImagesPerCall, + getSettingsUpdates, + }; +}; diff --git a/apps/nextjs-app/src/features/app/components/oauth/OAuthScope.tsx b/apps/nextjs-app/src/features/app/components/oauth/OAuthScope.tsx index c0706380d5..d07a754cf3 100644 --- a/apps/nextjs-app/src/features/app/components/oauth/OAuthScope.tsx +++ b/apps/nextjs-app/src/features/app/components/oauth/OAuthScope.tsx @@ -34,8 +34,10 @@ export const OAuthScope = (props: { if (!actionStaticMap) { return acc; } + const entry = actionStaticMap[scope as Action]; + if (!entry) return acc; const prefix = scope.split('|')[0] as ActionPrefix; - const scopeDesc = actionStaticMap[scope as Action].description; + const scopeDesc = entry.description; if (acc[prefix]) { acc[prefix].push(scopeDesc); } else { diff --git a/apps/nextjs-app/src/features/app/components/setting/Account.tsx b/apps/nextjs-app/src/features/app/components/setting/Account.tsx index 46880bb6b7..c971d48a97 100644 --- a/apps/nextjs-app/src/features/app/components/setting/Account.tsx +++ b/apps/nextjs-app/src/features/app/components/setting/Account.tsx @@ -104,9 +104,9 @@ export const Account: React.FC = () => { )} -
+
toggleRenameUser(e)} /> diff --git a/apps/nextjs-app/src/features/app/components/setting/SettingDialog.tsx b/apps/nextjs-app/src/features/app/components/setting/SettingDialog.tsx index 522980060f..804cf91cfe 100644 --- a/apps/nextjs-app/src/features/app/components/setting/SettingDialog.tsx +++ b/apps/nextjs-app/src/features/app/components/setting/SettingDialog.tsx @@ -1,131 +1,17 @@ -import { Bell, Key, Link, Lock, Settings, User } from '@teable/icons'; import { useIsTouchDevice } from '@teable/sdk/hooks'; -import { - Dialog, - DialogContent, - Sheet, - SheetContent, - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from '@teable/ui-lib/shadcn'; -import { useTranslation } from 'next-i18next'; -import { useMemo } from 'react'; -import { System } from '@/features/app/components/setting/System'; -import { settingConfig } from '@/features/i18n/setting.config'; -import { Account } from './Account'; -import { Integration } from './integration/Integration'; -import { Notifications } from './Notifications'; -import { OAuthAppSection } from './oauth-app'; -import { PersonalAccessTokenSection } from './personal-access-token'; +import { Dialog, DialogContent, Sheet, SheetContent } from '@teable/ui-lib/shadcn'; +import { UnifiedSettingDialogContent } from './UnifiedSettingDialogContent'; import { SettingTab, useSettingStore } from './useSettingStore'; -export const SettingDialog = () => { - const { t } = useTranslation(settingConfig.i18nNamespaces); +export interface ISettingDialogProps { + spaceId?: string; + includeSpaceSettings?: boolean; +} + +export const SettingDialog = ({ spaceId, includeSpaceSettings = true }: ISettingDialogProps) => { const isTouchDevice = useIsTouchDevice(); const { open, setOpen, tab, setTab } = useSettingStore(); - - const tabList = useMemo(() => { - return [ - { - key: SettingTab.Profile, - name: t('settings.account.tab'), - Icon: User, - }, - { - key: SettingTab.System, - name: t('settings.setting.title'), - Icon: Settings, - }, - { - key: SettingTab.Notifications, - name: t('settings.notify.title'), - Icon: Bell, - }, - { - key: SettingTab.Integration, - name: t('settings.integration.title'), - Icon: Link, - }, - { - key: SettingTab.PersonalAccessToken, - name: t('setting:personalAccessToken'), - Icon: Key, - }, - { - key: SettingTab.OAuthApp, - name: t('setting:oauthApps'), - Icon: Lock, - }, - ]; - }, [t]); - - const content = ( - setTab(value as SettingTab)} - className="flex h-full gap-0 overflow-hidden" - > - - {tabList.map(({ key, name, Icon }) => { - return ( - - - {name} - - ); - })} - - - - - - - - - - - - - - - - - - - - - ); + const activeTab = tab ?? SettingTab.Profile; return ( <> @@ -135,7 +21,14 @@ export const SettingDialog = () => { className="h-5/6 rounded-t-lg px-1 pb-0 pt-4 [&>button]:right-4 [&>button]:top-4 " side="bottom" > - {content} + ) : ( @@ -144,7 +37,14 @@ export const SettingDialog = () => { className="h-4/5 max-h-[80vh] max-w-6xl overflow-hidden p-0 [&>button]:right-4 [&>button]:top-4 " onOpenAutoFocus={(e) => e.preventDefault()} > - {content} + )} diff --git a/apps/nextjs-app/src/features/app/components/setting/SettingTabShell.tsx b/apps/nextjs-app/src/features/app/components/setting/SettingTabShell.tsx index 8ecabe5457..4f26b2d2b7 100644 --- a/apps/nextjs-app/src/features/app/components/setting/SettingTabShell.tsx +++ b/apps/nextjs-app/src/features/app/components/setting/SettingTabShell.tsx @@ -34,17 +34,17 @@ export const SettingTabHeader = ({ return (
-
+
{leading} -
-
{title}
+
+
{title}
{description && ( -
+
{description}
)} @@ -67,16 +67,11 @@ export const SettingTabShell = ({ footerClassName, }: SettingTabShellProps) => { return ( -
+
{header && (
@@ -85,13 +80,15 @@ export const SettingTabShell = ({ )}
{children}
- {footer &&
{footer}
} + {footer && ( +
{footer}
+ )}
); }; diff --git a/apps/nextjs-app/src/features/app/components/setting/System.tsx b/apps/nextjs-app/src/features/app/components/setting/System.tsx index 257faf8c88..038bec451c 100644 --- a/apps/nextjs-app/src/features/app/components/setting/System.tsx +++ b/apps/nextjs-app/src/features/app/components/setting/System.tsx @@ -37,57 +37,57 @@ export const System: React.FC = () => { setTheme(value); }} > -
+
- + {t('settings.setting.light')}
-
+
- + {t('settings.setting.dark')}
-
+
- + {t('settings.setting.system')}
diff --git a/apps/nextjs-app/src/features/app/components/setting/UnifiedSettingDialogContent.tsx b/apps/nextjs-app/src/features/app/components/setting/UnifiedSettingDialogContent.tsx new file mode 100644 index 0000000000..e448183fb3 --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/setting/UnifiedSettingDialogContent.tsx @@ -0,0 +1,314 @@ +import { useQuery } from '@tanstack/react-query'; +import { Role } from '@teable/core'; +import { Bell, Key, Link, Lock, Settings, User } from '@teable/icons'; +import { getSpaceById } from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { useBase, useSession } from '@teable/sdk/hooks'; +import { Tabs, TabsContent, TabsList, TabsTrigger, cn } from '@teable/ui-lib/shadcn'; +import { uniq } from 'lodash'; +import { Settings2, Users } from 'lucide-react'; +import { useParams } from 'next/navigation'; +import { useTranslation } from 'next-i18next'; +import type { ElementType, ReactElement, ReactNode } from 'react'; +import { useEffect, useMemo } from 'react'; +import { CollaboratorPage } from '@/features/app/blocks/space-setting/collaborator'; +import { GeneralPage } from '@/features/app/blocks/space-setting/general'; +import { SpaceSettingTab } from '@/features/app/blocks/space-setting/types'; +import { Account } from '@/features/app/components/setting/Account'; +import { Integration } from '@/features/app/components/setting/integration/Integration'; +import { Notifications } from '@/features/app/components/setting/Notifications'; +import { OAuthAppSection } from '@/features/app/components/setting/oauth-app'; +import { PersonalAccessTokenSection } from '@/features/app/components/setting/personal-access-token'; +import { System } from '@/features/app/components/setting/System'; +import { SettingTab as PersonalSettingTab } from '@/features/app/components/setting/useSettingStore'; +import { SpaceAvatar } from '@/features/app/components/space/SpaceAvatar'; +import { UserAvatar } from '@/features/app/components/user/UserAvatar'; +import { settingConfig } from '@/features/i18n/setting.config'; +import { spaceConfig } from '@/features/i18n/space.config'; + +export type UnifiedSettingKnownTab = PersonalSettingTab | SpaceSettingTab; +export type UnifiedSettingTab = string; + +export interface IUnifiedSettingListItem { + key: UnifiedSettingTab; + name: string; + Icon: ElementType; + badge?: ReactNode; + disabled?: boolean; + content: ReactNode | ((ctx: IUnifiedSettingRenderContext) => ReactNode); + contentClassName?: string; +} + +export interface IUnifiedSettingRenderContext { + onTabChange: (tab: UnifiedSettingTab) => void; + resolvedSpaceId?: string; + showSidebar: boolean; +} + +export interface IUnifiedSettingTriggerOverrides { + badge?: ReactNode; + disabled?: boolean; +} + +interface IUnifiedSettingGroup { + key: 'personal' | 'space'; + title: string; + entity: ReactNode; + tabs: IUnifiedSettingListItem[]; +} + +export interface IUnifiedSettingDialogContentProps { + tab: UnifiedSettingTab; + onTabChange: (tab: UnifiedSettingTab) => void; + entry: 'personal' | 'space'; + defaultTab: UnifiedSettingTab; + contentOnly?: boolean; + spaceId?: string; + includeSpaceSettings?: boolean; + extraPersonalTabs?: IUnifiedSettingListItem[]; + extraSpaceTabs?: IUnifiedSettingListItem[]; + renderTabTrigger?: ( + item: IUnifiedSettingListItem, + ctx: IUnifiedSettingRenderContext, + renderDefaultTrigger: (overrides?: IUnifiedSettingTriggerOverrides) => ReactElement + ) => ReactElement; +} + +export const UnifiedSettingDialogContent = ({ + tab, + onTabChange, + entry, + defaultTab, + contentOnly = false, + spaceId: spaceIdProp, + includeSpaceSettings = true, + extraPersonalTabs, + extraSpaceTabs, + renderTabTrigger, +}: IUnifiedSettingDialogContentProps) => { + const { t } = useTranslation( + uniq([...settingConfig.i18nNamespaces, ...spaceConfig.i18nNamespaces]) + ); + const { user } = useSession(); + const routeParams = useParams<{ spaceId?: string }>(); + const base = useBase() as { spaceId?: string } | undefined; + const resolvedSpaceId = includeSpaceSettings + ? spaceIdProp ?? routeParams?.spaceId ?? base?.spaceId + : undefined; + + const { data: space } = useQuery({ + queryKey: ReactQueryKeys.space(resolvedSpaceId as string), + queryFn: ({ queryKey }) => getSpaceById(queryKey[1] as string).then((res) => res.data), + enabled: Boolean(resolvedSpaceId), + }); + + const canAccessSpaceSettings = includeSpaceSettings && space?.role === Role.Owner; + const isSpaceEntry = entry === 'space' && Boolean(resolvedSpaceId); + const shouldKeepSpaceEntry = isSpaceEntry && !canAccessSpaceSettings; + + const personalTabs = useMemo( + () => [ + { + key: PersonalSettingTab.Profile, + name: t('settings.account.tab'), + Icon: User, + content: , + }, + { + key: PersonalSettingTab.System, + name: t('settings.setting.title'), + Icon: Settings, + content: , + }, + { + key: PersonalSettingTab.Notifications, + name: t('settings.notify.title'), + Icon: Bell, + content: , + }, + { + key: PersonalSettingTab.Integration, + name: t('settings.integration.title'), + Icon: Link, + content: , + }, + { + key: PersonalSettingTab.PersonalAccessToken, + name: t('setting:personalAccessToken'), + Icon: Key, + content: , + }, + { + key: PersonalSettingTab.OAuthApp, + name: t('setting:oauthApps'), + Icon: Lock, + content: , + }, + ...(extraPersonalTabs ?? []), + ], + [extraPersonalTabs, t] + ); + + const spaceTabs = useMemo(() => { + if (!resolvedSpaceId || !canAccessSpaceSettings) { + return []; + } + + return [ + { + key: SpaceSettingTab.General, + name: t('space:spaceSetting.general'), + Icon: Settings2, + content: ({ resolvedSpaceId }) => , + }, + { + key: SpaceSettingTab.Collaborator, + name: t('space:spaceSetting.collaborators'), + Icon: Users, + content: ({ resolvedSpaceId }) => , + }, + ...(extraSpaceTabs ?? []), + ]; + }, [canAccessSpaceSettings, extraSpaceTabs, resolvedSpaceId, t]); + + const orderedGroups = useMemo(() => { + const groups: IUnifiedSettingGroup[] = [ + { + key: 'personal' as const, + title: t('common:settings.personal.title'), + entity: user ? ( +
+ + + {user.name} + +
+ ) : null, + tabs: personalTabs, + }, + { + key: 'space' as const, + title: t('common:noun.space'), + entity: + resolvedSpaceId && space ? ( +
+ + + {space.name} + +
+ ) : null, + tabs: spaceTabs, + }, + ].filter((group) => group.tabs.length > 0); + + if (entry === 'space') { + return groups.sort((a, b) => (a.key === 'space' ? -1 : b.key === 'space' ? 1 : 0)); + } + + return groups.sort((a, b) => (a.key === 'personal' ? -1 : b.key === 'personal' ? 1 : 0)); + }, [entry, personalTabs, resolvedSpaceId, space, spaceTabs, t, user]); + + const showSidebar = !contentOnly && orderedGroups.length > 0; + const availableTabs = useMemo( + () => orderedGroups.flatMap((group) => group.tabs.map(({ key }) => key)), + [orderedGroups] + ); + + useEffect(() => { + if (availableTabs.includes(tab)) { + return; + } + + if (shouldKeepSpaceEntry) { + return; + } + + const fallbackTab = availableTabs.includes(defaultTab) ? defaultTab : availableTabs[0]; + + if (fallbackTab && fallbackTab !== tab) { + onTabChange(fallbackTab); + } + }, [availableTabs, defaultTab, onTabChange, shouldKeepSpaceEntry, tab]); + + const renderContext = useMemo( + () => ({ onTabChange, resolvedSpaceId, showSidebar }), + [onTabChange, resolvedSpaceId, showSidebar] + ); + + const allTabs = useMemo(() => orderedGroups.flatMap((group) => group.tabs), [orderedGroups]); + + if (shouldKeepSpaceEntry) { + return
; + } + + return ( + + {showSidebar && ( + + {orderedGroups.map((group) => ( +
+
+

+ {group.title} +

+ {group.entity} +
+
+ {group.tabs.map((item) => { + const renderDefaultTrigger = ( + overrides?: IUnifiedSettingTriggerOverrides + ): ReactElement => ( + +
+
+ + {item.name} +
+ + {overrides?.badge ?? item.badge} + +
+
+ ); + + return renderTabTrigger + ? renderTabTrigger(item, renderContext, renderDefaultTrigger) + : renderDefaultTrigger(); + })} +
+
+ ))} +
+ )} + + {allTabs.map((item) => ( + spaceTab.key === item.key) + ? cn('mt-0 min-w-0 flex-1 focus-visible:outline-none', { + 'overflow-y-auto overflow-x-hidden': showSidebar, + }) + : 'mt-0 size-full overflow-y-auto overflow-x-hidden') + } + > + {typeof item.content === 'function' ? item.content(renderContext) : item.content} + + ))} +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/components/setting/integration/Integration.tsx b/apps/nextjs-app/src/features/app/components/setting/integration/Integration.tsx index b80a5c9381..7e371445fb 100644 --- a/apps/nextjs-app/src/features/app/components/setting/integration/Integration.tsx +++ b/apps/nextjs-app/src/features/app/components/setting/integration/Integration.tsx @@ -36,7 +36,7 @@ export const Integration = () => { contentClassName="px-0 py-0" > setTab(value as 'user' | 'third-party')} > diff --git a/apps/nextjs-app/src/features/app/components/setting/useSettingStore.ts b/apps/nextjs-app/src/features/app/components/setting/useSettingStore.ts index b51e40439e..9994b09fcb 100644 --- a/apps/nextjs-app/src/features/app/components/setting/useSettingStore.ts +++ b/apps/nextjs-app/src/features/app/components/setting/useSettingStore.ts @@ -11,16 +11,18 @@ export enum SettingTab { LicensePlan = 'license-plan', } +export type SettingDialogTab = string; + interface ISettingState { - tab?: SettingTab; - setTab: (tab: SettingTab) => void; + tab?: SettingDialogTab; + setTab: (tab: SettingDialogTab) => void; open: boolean; - setOpen: (open: boolean, tab?: SettingTab) => void; + setOpen: (open: boolean, tab?: SettingDialogTab) => void; } export const useSettingStore = create((set) => ({ open: false, - setOpen: (open: boolean, tab?: SettingTab) => { + setOpen: (open: boolean, tab?: SettingDialogTab) => { set((state) => { return { ...state, @@ -29,7 +31,7 @@ export const useSettingStore = create((set) => ({ }; }); }, - setTab: (tab: SettingTab) => { + setTab: (tab: SettingDialogTab) => { set((state) => { return { ...state, diff --git a/apps/nextjs-app/src/features/app/hooks/useBaseResource.ts b/apps/nextjs-app/src/features/app/hooks/useBaseResource.ts index d678622545..dd7726e109 100644 --- a/apps/nextjs-app/src/features/app/hooks/useBaseResource.ts +++ b/apps/nextjs-app/src/features/app/hooks/useBaseResource.ts @@ -103,3 +103,14 @@ export function useBaseResource(): IBaseResource { }; }, [baseId, slug]); } + +export function useBaseNodeId(): string | undefined { + const router = useRouter(); + const { slug } = router.query; + return useMemo(() => { + if (!slug || slug.length === 0) { + return; + } + return slug?.[1]; + }, [slug]); +} diff --git a/apps/nextjs-app/src/features/app/utils/download-all-attachments.ts b/apps/nextjs-app/src/features/app/utils/download-all-attachments.ts index bb539019f7..69e76496ba 100644 --- a/apps/nextjs-app/src/features/app/utils/download-all-attachments.ts +++ b/apps/nextjs-app/src/features/app/utils/download-all-attachments.ts @@ -40,6 +40,8 @@ export interface IDownloadAllAttachmentsOptions { shareId?: string; personalViewCommonQuery?: IGetRecordsRo; namingField?: IFieldInstance; + /** When true, keep the original filename without any prefix (collisions resolved via _N suffix) */ + noPrefix?: boolean; groupByRow?: boolean; onProgress?: (progress: IDownloadProgress) => void; abortController?: AbortController; @@ -333,10 +335,20 @@ function generateZipFileName( rowAttachmentCount: number, namingValue?: string, isNamingValueDuplicated?: boolean, - groupByRow?: boolean + groupByRow?: boolean, + noPrefix?: boolean ): string { const hasMultipleInRow = rowAttachmentCount > 1; + // No-prefix mode: keep original filename. groupByRow still wraps multi-attachment rows + // in a row-numbered folder so files within the same row stay together. + if (noPrefix) { + if (groupByRow && hasMultipleInRow) { + return `${getPaddedRowNumber(rowIndex, totalRows)}/${fileName}`; + } + return fileName; + } + // When groupByRow is enabled and row has multiple attachments, use folder structure if (groupByRow && hasMultipleInRow) { const folderName = generateFolderName( @@ -385,6 +397,7 @@ export async function downloadAllAttachments( shareId, personalViewCommonQuery, namingField, + noPrefix, groupByRow, onProgress, abortController, @@ -451,6 +464,9 @@ export async function downloadAllAttachments( let downloadedBytes = 0; let processedFiles = 0; + // Track final zip entry names for noPrefix mode to dedupe cross-row collisions. + const usedZipPaths = new Map(); + // 6. Create zip stream const zip = new Zip((err, chunk, final) => { if (err) { @@ -477,7 +493,7 @@ export async function downloadAllAttachments( } const isNamingValueDuplicated = namingValue ? duplicatedNamingValues.has(namingValue) : false; - const fileName = generateZipFileName( + let fileName = generateZipFileName( rowIndex, attachmentIndex, attachment.name, @@ -485,8 +501,12 @@ export async function downloadAllAttachments( attachmentCountInRow, namingValue, isNamingValueDuplicated, - groupByRow + groupByRow, + noPrefix ); + if (noPrefix) { + fileName = generateUniqueFileName(fileName, usedZipPaths); + } // Skip attachments without valid presignedUrl if (!attachment.presignedUrl) { diff --git a/apps/nextjs-app/src/styles/global.css b/apps/nextjs-app/src/styles/global.css index ad205d5e7a..5f66617d7b 100644 --- a/apps/nextjs-app/src/styles/global.css +++ b/apps/nextjs-app/src/styles/global.css @@ -40,11 +40,11 @@ body { background-color: hsl(var(--muted-foreground)) !important; } -.fc-scrollgrid-section > td { +.fc-scrollgrid-section>td { border-radius: 0px 0px 8px 8px !important; } -.fc-scrollgrid-section > th { +.fc-scrollgrid-section>th { border-radius: 0px 8px 0px 0px !important; } @@ -167,3 +167,21 @@ body { .react-flow__controls-button svg { fill: currentColor !important; } + +@keyframes ui-attention-pulse { + 0% { + box-shadow: inset 0 0 0 0 hsl(var(--primary) / 0); + } + + 35% { + box-shadow: inset 0 0 0 5px hsl(var(--primary)/ 0.15); + } + + 100% { + box-shadow: inset 0 0 0 0 hsl(var(--primary) / 0); + } +} + +.ui-attention-pulse { + animation: ui-attention-pulse 700ms ease-in-out 2; +} \ No newline at end of file diff --git a/packages/common-i18n/src/locales/de/common.json b/packages/common-i18n/src/locales/de/common.json index 18edf2518e..852e674c84 100644 --- a/packages/common-i18n/src/locales/de/common.json +++ b/packages/common-i18n/src/locales/de/common.json @@ -164,8 +164,9 @@ }, "settings": { "title": "Instanzeinstellungen", + "allSetting": "Space- & persönliche Einstellungen", "personal": { - "title": "Persönliche Einstellungen" + "title": "Persönlich" }, "back": "Zurück zum Start", "account": { @@ -885,6 +886,10 @@ "runQuotaExceeded": { "title": "Automatisierung {{name}} hat die maximale monatliche Ausführungsanzahl erreicht", "message": "Die monatlichen Ausführungen für Automatisierung {{name}} sind aufgebraucht. Die Ausführung ist vorübergehend nicht möglich. Bitte upgraden Sie Ihr Abonnement oder kaufen Sie zusätzliche Ausführungen." + }, + "failedSummary": { + "title": "Automatisierung {{name}} ist {{failCount}} Mal fehlgeschlagen", + "message": "Ihre Automatisierung {{name}} hat {{failCount}} aufeinanderfolgende Fehler angesammelt. Öffnen Sie den Ausführungsverlauf, um Details anzuzeigen." } }, "billing": { @@ -1100,9 +1105,9 @@ } }, "changelog": { - "newUpdate": "17. APR UPDATE", - "title": "Claude Opus 4.7 ist jetzt in Teable verfügbar", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "28. APR UPDATE", + "title": "Neuer Trigger: Beim E-Mail-Empfang (IMAP)", + "url": "https://help.teable.ai/en/changelog#apr-28-2026", + "id": "changelog-2026-04-28-new-trigger-when-email-receive-imap" } } diff --git a/packages/common-i18n/src/locales/de/sdk.json b/packages/common-i18n/src/locales/de/sdk.json index 54dd297b79..23ed1f747a 100644 --- a/packages/common-i18n/src/locales/de/sdk.json +++ b/packages/common-i18n/src/locales/de/sdk.json @@ -158,6 +158,7 @@ }, "invalidateSelected": "Unzulässiger Wert", "invalidateSelectedTips": "Der ausgewählte Wert wurde gelöscht, bitte wählen Sie erneut", + "invalidConditionTip": "Diese Filterbedingung ist ungültig und wird ignoriert. Bitte passen Sie den Wert an.", "default": { "empty": "Es werden keine Filterbedingungen angewendet", "placeholder": "Einen Wert eingeben" @@ -264,7 +265,8 @@ "configLabel_other_visible": "{{count}} sichtbare Felder", "showAll": "Alle zeigen", "hideAll": "Alle verstecken", - "primaryKey": "Primäres Feld: Identifiziert Datensätze\nKann nicht ausgeblendet oder gelöscht werden, sichtbar in verknüpften Datensätzen." + "primaryKey": "Primäres Feld: Identifiziert Datensätze\nKann nicht ausgeblendet oder gelöscht werden, sichtbar in verknüpften Datensätzen.", + "notInCurrentView": "Feld „{{fieldName}}“ ist in der aktuellen Ansicht nicht sichtbar und kann nicht angesteuert werden" }, "expandRecord": { "copy": "In die Zwischenablage kopieren", @@ -1121,7 +1123,8 @@ "notAllowedToExecuteSqlWithKeyword": "Ausführung von SQL mit Schlüsselwort {{keyword}} nicht erlaubt", "whiteListCheckError": "Fehler beim Überprüfen des Tabellenzugriffs: {{message}}", "databaseConnectionFailed": "Datenbankverbindung fehlgeschlagen: {{message}}", - "executeQuerySqlFailed": "Ausführung der Abfrage-SQL fehlgeschlagen: {{message}}" + "executeQuerySqlFailed": "Ausführung der Abfrage-SQL fehlgeschlagen: {{message}}", + "sqlSyntaxError": "SQL-Syntaxfehler, bitte überprüfen Sie Ihre Abfrage" }, "permission": { "createRecordWithDeniedFields": "Sie haben keine Berechtigung, Datensätze mit Feldern({{fields}}) zu erstellen", @@ -1223,7 +1226,9 @@ "button": { "clickCountReachedMaxCount": "Anzahl der Schaltflächenklicks hat das Maximum erreicht", "notSupportReset": "Schaltflächenfeld unterstützt kein Zurücksetzen" - } + }, + "primaryCannotBeLookup": "Primärfeld kann nicht als Lookup-Feld konfiguriert werden", + "primaryFieldAlreadyExists": "Tabelle hat bereits ein Primärfeld" }, "view": { "notFound": "Ansicht nicht gefunden", @@ -1438,7 +1443,8 @@ "linkedInPostNotFound": "LinkedIn-Beitrag nicht gefunden: {{postId}}", "linkedInAuthorNotFound": "LinkedIn-Autor nicht gefunden: {{postId}}", "fetchLinkedInUserFailed": "Abrufen des LinkedIn-Benutzers fehlgeschlagen: {{error}}", - "domainAlreadyInUse": "Diese Domain ist bereits an eine andere App gebunden" + "domainAlreadyInUse": "Diese Domain ist bereits an eine andere App gebunden", + "domainReserved": "Subdomain ist reserviert" } } } diff --git a/packages/common-i18n/src/locales/de/table.json b/packages/common-i18n/src/locales/de/table.json index e04c635a26..43055e456a 100644 --- a/packages/common-i18n/src/locales/de/table.json +++ b/packages/common-i18n/src/locales/de/table.json @@ -641,6 +641,11 @@ } } } + }, + "errorType": { + "InvalidPrimaryLookup": "Primärfeld fälschlich als Lookup konfiguriert", + "InvalidPrimaryType": "Primärfeld hat einen nicht unterstützten Typ", + "MissingPrimary": "Tabelle hat kein Primärfeld" } }, "index": { @@ -978,6 +983,7 @@ "expand": "Erweitern", "history": "Verlauf", "close": "Einklappen", + "noModel": "Keine verfügbaren Modelle", "addAttachment": "Anhang hinzufügen", "noHistory": "Keine Chat-Verlauf", "noFoundHistory": "Keine passenden Chat-Verlauf gefunden, starten Sie einen neuen Chat", @@ -995,7 +1001,14 @@ "emptyContext": "Kein Kontext zum Hinzufügen", "selectionRows": "Zeilen {{start}}-{{end}}" }, - "inputPlaceholder": "Nachricht senden...", + "mention": { + "tables": "Tabellen", + "apps": "Apps", + "workflows": "Workflows", + "folders": "Ordner" + }, + "inputPlaceholder": "@ tippen, um Kontext hinzuzufügen", + "inputPlaceholderFiles": "Dateien hier einfügen oder ablegen", "thought": "Denken", "meta": { "input": "Eingabe", @@ -1054,6 +1067,8 @@ "advancedOptions": "Erweiterte Optionen", "namingFieldLabel": "Präfix für Anhangsname", "selectField": "Standard: Anhangsindex", + "noPrefixOption": "Kein Präfix", + "noPrefixOptionDesc": "Originaldateiname beibehalten", "groupByRow": "In Ordner archivieren", "groupByRowTip": "Wenn eine Zeile mehrere Anhänge hat, werden sie im selben Ordner abgelegt; Zeilen mit nur einem Anhang erstellen keinen Ordner." } diff --git a/packages/common-i18n/src/locales/en/common.json b/packages/common-i18n/src/locales/en/common.json index 0ad5a3be4d..dd58dfb182 100644 --- a/packages/common-i18n/src/locales/en/common.json +++ b/packages/common-i18n/src/locales/en/common.json @@ -170,8 +170,9 @@ }, "settings": { "title": "Instance settings", + "allSetting": "Space & personal settings", "personal": { - "title": "Personal settings" + "title": "Personal" }, "templateAdmin": { "title": "Template admin", @@ -1204,6 +1205,10 @@ "runQuotaExceeded": { "title": "Automation {{name}} run failed due to run quota limit", "message": "Your automation {{name}} reached the monthly automation run quota. Please upgrade your plan or purchase additional automation runs." + }, + "failedSummary": { + "title": "Automation {{name}} has failed {{failCount}} times", + "message": "Your automation {{name}} has accumulated {{failCount}} consecutive failures. Open run history to view details." } }, "billing": { @@ -1452,9 +1457,9 @@ } }, "changelog": { - "newUpdate": "APR 17 UPDATE", - "title": "Claude Opus 4.7 is now in Teable", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "APR 28 UPDATE", + "title": "New Trigger: When Email Receive (IMAP)", + "url": "https://help.teable.ai/en/changelog#apr-28-2026", + "id": "changelog-2026-04-28-new-trigger-when-email-receive-imap" } } diff --git a/packages/common-i18n/src/locales/en/sdk.json b/packages/common-i18n/src/locales/en/sdk.json index d9d4cca364..3791e7ed90 100644 --- a/packages/common-i18n/src/locales/en/sdk.json +++ b/packages/common-i18n/src/locales/en/sdk.json @@ -174,6 +174,7 @@ }, "invalidateSelected": "Invalid value", "invalidateSelectedTips": "The selected value has been deleted, please select again", + "invalidConditionTip": "This filter condition is invalid and will be ignored. Please adjust the value.", "default": { "empty": "No filter conditions are applied", "placeholder": "Enter a value" @@ -281,7 +282,8 @@ "configLabel_other_visible": "{{count}} visible fields", "showAll": "Show all", "hideAll": "Hide all", - "primaryKey": "Primary field: Identifies records\nCannot be hidden or deleted, visible in linked records." + "primaryKey": "Primary field: Identifies records\nCannot be hidden or deleted, visible in linked records.", + "notInCurrentView": "Field \"{{fieldName}}\" is not visible in the current view and can't be jumped to" }, "expandRecord": { "copy": "Copy to clipboard", @@ -1147,7 +1149,8 @@ "whiteListCheckError": "An error occurred while checking table access: {{message}}", "databaseConnectionFailed": "Database connection failed: {{message}}", "executeQuerySqlFailed": "Execute query sql failed: {{message}}", - "readOnlyCheckFailed": "Read only check failed: {{message}}" + "readOnlyCheckFailed": "Read only check failed: {{message}}", + "sqlSyntaxError": "SQL syntax error, please check your query" }, "permission": { "createRecordWithDeniedFields": "You don't have permission to create record with fields({{fields}})", @@ -1249,7 +1252,9 @@ "button": { "clickCountReachedMaxCount": "Button click count has reached the maximum limit", "notSupportReset": "Button field does not support reset" - } + }, + "primaryCannotBeLookup": "Primary field cannot be configured as a lookup field", + "primaryFieldAlreadyExists": "Table already has a primary field" }, "view": { "notFound": "View not found", @@ -1447,7 +1452,8 @@ "noFilesInZip": "No files found in ZIP", "zipFileTooLarge": "ZIP file size exceeds 5MB limit", "invalidZip": "Invalid ZIP file", - "domainAlreadyInUse": "This domain is already bound to another app" + "domainAlreadyInUse": "This domain is already bound to another app", + "domainReserved": "Subdomain is reserved" }, "reward": { "notFound": "Reward not found", diff --git a/packages/common-i18n/src/locales/en/table.json b/packages/common-i18n/src/locales/en/table.json index d7fef7adf3..6bab2419d1 100644 --- a/packages/common-i18n/src/locales/en/table.json +++ b/packages/common-i18n/src/locales/en/table.json @@ -256,6 +256,7 @@ "reset": "Reset", "fieldUpdated": "Field has been updated", "fieldCreated": "Field has been created", + "fieldUnavailable": "This field is unavailable. Refresh the table and try again.", "confirmFieldChange": "Confirm Field Change", "areYouSurePerformIt": "Are you sure you want to perform it?", "addDescription": "Add description", @@ -661,6 +662,20 @@ "manualRepairDialogClose": "Close", "manualRepairPreview": "Manual repair setup", "manualRepairPreviewTip": "Manual repair is not submitted yet. This preview shows the options the rule asks the user to choose from.", + "repairPreviewTitle": "Confirm repair details", + "repairPreviewDescription": "These details come from the repair dry run. The repair will only execute after you confirm.", + "repairPreviewTooltip": "Open the dry-run repair plan. The dialog will show the returned SQL, or clearly say when no SQL is available.", + "repairPreviewMissingTable": "This check result does not include a table, so the dry-run repair target cannot be resolved.", + "repairPreviewUnavailableStatus": "This status does not need repair, so a dry-run repair plan cannot be generated.", + "repairPreviewWhat": "What will be repaired", + "repairPreviewTarget": "The \"{{ruleName}}\" rule for field \"{{fieldName}}\"", + "repairPreviewPrinciple": "How the repair works", + "repairPreviewNoPrinciple": "This rule did not return additional repair guidance.", + "repairPreviewSql": "SQL to execute", + "repairPreviewNoSql": "The dry run did not return executable SQL, so automatic repair cannot be run directly.", + "repairPreviewCannotConfirm": "The dry run returned SQL, but this rule cannot be confirmed for automatic repair right now. You can still review the repair plan.", + "repairPreviewParameters": "Parameters", + "repairPreviewConfirm": "Run repair", "checking": "Checking schema...", "repairing": "Repairing schema...", "streamError": "Failed to stream schema integrity results.", @@ -770,6 +785,7 @@ "junctionForeignKeyOrphanRows": "Automatic repair is unavailable because invalid junction rows still exist" }, "description": { + "autoRule": "The repair will execute the schema statements generated by this rule, then re-check the rule to confirm the schema is valid.", "symmetricFieldConflict": "More than one field points at the same symmetric link target. Pick which field should own the two-way relationship before repairing.", "foreignKeyTargetTableMissing": "Check whether the linked table {{targetPhysicalTableName}} was deleted or renamed. Recreate the table, or update/remove the link configuration for \"{{fieldName}}\", then run the check again.", "foreignKeyOrphanRows": "Clean up the invalid linked rows for \"{{fieldName}}\" before adding the foreign key constraint again.", @@ -831,7 +847,10 @@ "ReferenceFieldNotFound": "ReferenceFieldNotFound", "UniqueIndexNotFound": "UniqueIndexNotFound", "EmptyString": "EmptyString", - "InvalidFilterOperator": "InvalidFilterOperator" + "InvalidFilterOperator": "InvalidFilterOperator", + "InvalidPrimaryLookup": "Primary field misconfigured as lookup", + "InvalidPrimaryType": "Primary field has unsupported type", + "MissingPrimary": "Table has no primary field" } }, "index": { @@ -945,6 +964,8 @@ "advancedOptions": "Advanced options", "namingFieldLabel": "Attachment name prefix", "selectField": "Default: attachment index", + "noPrefixOption": "No prefix", + "noPrefixOptionDesc": "Keep the original filename", "groupByRow": "Archive into folders", "groupByRowTip": "When a row has multiple attachments, they will be placed in the same folder; rows with only one attachment will not create a folder." } @@ -1260,9 +1281,12 @@ "expand": "Expand", "history": "History", "close": "Collapse", + "noModel": "No models available", "clearChatConfirmTitle": "Confirm Clear Chat", "clearChatConfirmDesc": "Current chat content will not be saved. Are you sure you want to clear it?", "dontShowAgain": "Don't show again", + "modelSwitchTitle": "Switch model", + "modelSwitchHint": "Currently running on {{previous}}. On next send, the new model takes over and only sees the last 3 turns.", "sandboxExpiry": { "expiresIn": "Agent Computer expires in {{time}}", "reset": "Renew", @@ -1277,7 +1301,7 @@ "expiresSoon": "Agent Computer expiring soon", "resetFailed": "Failed to renew Agent Computer" }, - "addAttachment": "Add Attachment", + "addAttachment": "Add attachment", "noHistory": "No chat history", "noFoundHistory": "No chat history found, please start a new conversation", "timeGroup": { @@ -1294,7 +1318,14 @@ "emptyContext": "No context to add", "selectionRows": "Rows {{start}}-{{end}}" }, - "inputPlaceholder": "Describe what you want to do", + "mention": { + "tables": "Tables", + "apps": "Apps", + "workflows": "Workflows", + "folders": "Folders" + }, + "inputPlaceholder": "Type @ to add context", + "inputPlaceholderFiles": "Paste or drop files here", "thought": "Thinking", "meta": { "input": "Input", @@ -1359,7 +1390,10 @@ }, "retry": { "interrupted": "Response interrupted", - "button": "Retry" + "button": "Retry", + "offline": "You're offline. We'll retry once your connection is back.", + "pausedHidden": "Retry paused — open this tab to reconnect.", + "maxAttemptsReached": "Couldn't reconnect automatically. Please refresh the page manually." }, "guide": { "goToScenario": "Go to scenario {{index}}" diff --git a/packages/common-i18n/src/locales/es/common.json b/packages/common-i18n/src/locales/es/common.json index cb3bb6d93f..f84527c8ce 100644 --- a/packages/common-i18n/src/locales/es/common.json +++ b/packages/common-i18n/src/locales/es/common.json @@ -164,8 +164,9 @@ }, "settings": { "title": "Configuración de instancia", + "allSetting": "Configuración del espacio y personal", "personal": { - "title": "Configuración personal" + "title": "Personal" }, "back": "Volver al inicio", "account": { @@ -888,6 +889,10 @@ "runQuotaExceeded": { "title": "La automatización {{name}} alcanzó el máximo mensual de ejecuciones", "message": "Las ejecuciones mensuales de la automatización {{name}} se han agotado y no puede ejecutarse temporalmente. Actualiza tu suscripción o compra ejecuciones adicionales." + }, + "failedSummary": { + "title": "La automatización {{name}} ha fallado {{failCount}} veces", + "message": "Tu automatización {{name}} ha acumulado {{failCount}} fallos consecutivos. Abre el historial de ejecuciones para ver los detalles." } }, "billing": { @@ -1103,9 +1108,9 @@ } }, "changelog": { - "newUpdate": "ACTUALIZACIÓN 17 ABR", - "title": "Claude Opus 4.7 ya está disponible en Teable", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "ACTUALIZACIÓN 28 ABR", + "title": "Nuevo disparador: Al recibir un correo electrónico (IMAP)", + "url": "https://help.teable.ai/en/changelog#apr-28-2026", + "id": "changelog-2026-04-28-new-trigger-when-email-receive-imap" } } diff --git a/packages/common-i18n/src/locales/es/sdk.json b/packages/common-i18n/src/locales/es/sdk.json index 38952db515..b10e363d39 100644 --- a/packages/common-i18n/src/locales/es/sdk.json +++ b/packages/common-i18n/src/locales/es/sdk.json @@ -158,6 +158,7 @@ }, "invalidateSelected": "Valor no válido", "invalidateSelectedTips": "Se ha eliminado el valor seleccionado, seleccione nuevamente", + "invalidConditionTip": "Esta condición de filtro no es válida y se ignorará. Ajuste el valor.", "default": { "empty": "No se aplican condiciones de filtro", "placeholder": "Ingrese un valor" @@ -264,7 +265,8 @@ "configLabel_other_visible": "{{count}} visible fields", "showAll": "Mostrar todo", "hideAll": "Esconderse", - "primaryKey": "Campo primario: identifica registros\n" + "primaryKey": "Campo primario: identifica registros\n", + "notInCurrentView": "El campo \"{{fieldName}}\" no está visible en la vista actual y no se puede localizar" }, "expandRecord": { "copy": "Copiar al portapapeles", @@ -1121,7 +1123,8 @@ "notAllowedToExecuteSqlWithKeyword": "No se permite ejecutar SQL con la palabra clave {{keyword}}", "whiteListCheckError": "Ocurrió un error al verificar el acceso a la tabla: {{message}}", "databaseConnectionFailed": "Conexión a la base de datos fallida: {{message}}", - "executeQuerySqlFailed": "Error al ejecutar la consulta SQL: {{message}}" + "executeQuerySqlFailed": "Error al ejecutar la consulta SQL: {{message}}", + "sqlSyntaxError": "Error de sintaxis SQL, por favor verifique su consulta" }, "permission": { "createRecordWithDeniedFields": "No tienes permiso para crear registros con campos({{fields}})", @@ -1221,7 +1224,9 @@ "button": { "clickCountReachedMaxCount": "El conteo de clics del botón ha alcanzado el límite máximo", "notSupportReset": "El campo de botón no admite restablecimiento" - } + }, + "primaryCannotBeLookup": "El campo principal no puede configurarse como un campo Lookup", + "primaryFieldAlreadyExists": "La tabla ya tiene un campo principal" }, "view": { "notFound": "Vista no encontrada", @@ -1432,7 +1437,8 @@ "linkedInPostNotFound": "Publicación de LinkedIn no encontrada: {{postId}}", "linkedInAuthorNotFound": "Autor de LinkedIn no encontrado: {{postId}}", "fetchLinkedInUserFailed": "Error al obtener el usuario de LinkedIn: {{error}}", - "domainAlreadyInUse": "Este dominio ya está vinculado a otra aplicación" + "domainAlreadyInUse": "Este dominio ya está vinculado a otra aplicación", + "domainReserved": "Subdominio reservado" } } } diff --git a/packages/common-i18n/src/locales/es/table.json b/packages/common-i18n/src/locales/es/table.json index 81ef28ee15..0c712698c2 100644 --- a/packages/common-i18n/src/locales/es/table.json +++ b/packages/common-i18n/src/locales/es/table.json @@ -638,6 +638,11 @@ } } } + }, + "errorType": { + "InvalidPrimaryLookup": "Campo principal mal configurado como Lookup", + "InvalidPrimaryType": "El campo principal tiene un tipo no admitido", + "MissingPrimary": "La tabla no tiene campo principal" } }, "index": { @@ -971,6 +976,7 @@ "expand": "Expandir", "history": "Historial", "close": "Contraer", + "noModel": "No hay modelos disponibles", "addAttachment": "Añadir archivo adjunto", "noHistory": "No hay historial de chat", "noFoundHistory": "No se encontró historial de chat coincidente, comience una nueva conversación", @@ -988,7 +994,14 @@ "emptyContext": "No hay contexto para agregar", "selectionRows": "Filas {{start}}-{{end}}" }, - "inputPlaceholder": "Enviar mensaje...", + "mention": { + "tables": "Tablas", + "apps": "Aplicaciones", + "workflows": "Flujos de trabajo", + "folders": "Carpetas" + }, + "inputPlaceholder": "Escribe @ para añadir contexto", + "inputPlaceholderFiles": "Pega o suelta archivos aquí", "thought": "Pensando", "meta": { "input": "Entrada", @@ -1047,6 +1060,8 @@ "advancedOptions": "Opciones avanzadas", "namingFieldLabel": "Prefijo del nombre del adjunto", "selectField": "Por defecto: índice de adjunto", + "noPrefixOption": "Sin prefijo", + "noPrefixOptionDesc": "Mantener el nombre original", "groupByRow": "Archivar en carpetas", "groupByRowTip": "Cuando una fila tiene múltiples adjuntos, se colocarán en la misma carpeta; las filas con solo un adjunto no crearán una carpeta." } diff --git a/packages/common-i18n/src/locales/fr/common.json b/packages/common-i18n/src/locales/fr/common.json index 2b0f19db09..26ad147ba4 100644 --- a/packages/common-i18n/src/locales/fr/common.json +++ b/packages/common-i18n/src/locales/fr/common.json @@ -164,8 +164,9 @@ }, "settings": { "title": "Paramètres d'instance", + "allSetting": "Paramètres de l'espace et personnels", "personal": { - "title": "Paramètres personnels" + "title": "Personnel" }, "back": "Retour à l'accueil", "account": { @@ -890,6 +891,10 @@ "runQuotaExceeded": { "title": "L'automatisation {{name}} a atteint le nombre maximal d'exécutions mensuelles", "message": "Le quota mensuel d'exécutions de l'automatisation {{name}} est épuisé. L'exécution est temporairement indisponible. Veuillez mettre à niveau votre abonnement ou acheter des exécutions supplémentaires." + }, + "failedSummary": { + "title": "L'automatisation {{name}} a échoué {{failCount}} fois", + "message": "Votre automatisation {{name}} a cumulé {{failCount}} échecs consécutifs. Ouvrez l'historique des exécutions pour voir les détails." } }, "billing": { @@ -1105,9 +1110,9 @@ } }, "changelog": { - "newUpdate": "MISE À JOUR DU 17 AVR", - "title": "Claude Opus 4.7 est désormais disponible dans Teable", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "MISE À JOUR DU 28 AVR", + "title": "Nouveau déclencheur : À la réception d’un e-mail (IMAP)", + "url": "https://help.teable.ai/en/changelog#apr-28-2026", + "id": "changelog-2026-04-28-new-trigger-when-email-receive-imap" } } diff --git a/packages/common-i18n/src/locales/fr/sdk.json b/packages/common-i18n/src/locales/fr/sdk.json index a78b3ae227..5a73bb25cc 100644 --- a/packages/common-i18n/src/locales/fr/sdk.json +++ b/packages/common-i18n/src/locales/fr/sdk.json @@ -158,6 +158,7 @@ }, "invalidateSelected": "Invalid value", "invalidateSelectedTips": "The selected value has been deleted, please select again", + "invalidConditionTip": "Cette condition de filtre est invalide et sera ignorée. Veuillez ajuster la valeur.", "default": { "empty": "Aucune condition de filtrage appliquée", "placeholder": "Entrez une valeur" @@ -264,7 +265,8 @@ "configLabel_other_visible": "{{count}} champs visibles", "showAll": "Tout afficher", "hideAll": "Tout masquer", - "primaryKey": "Champ principal : utilisé pour identifier les enregistrements, ne peut pas être caché ou supprimé" + "primaryKey": "Champ principal : utilisé pour identifier les enregistrements, ne peut pas être caché ou supprimé", + "notInCurrentView": "Le champ « {{fieldName}} » n'est pas visible dans la vue actuelle et ne peut pas être atteint" }, "expandRecord": { "copy": "Copier dans le presse-papiers", @@ -1121,7 +1123,8 @@ "notAllowedToExecuteSqlWithKeyword": "Non autorisé à exécuter SQL avec le mot-clé {{keyword}}", "whiteListCheckError": "Une erreur s'est produite lors de la vérification de l'accès à la table : {{message}}", "databaseConnectionFailed": "La connexion à la base de données a échoué : {{message}}", - "executeQuerySqlFailed": "L'exécution de la requête SQL a échoué : {{message}}" + "executeQuerySqlFailed": "L'exécution de la requête SQL a échoué : {{message}}", + "sqlSyntaxError": "Erreur de syntaxe SQL, veuillez vérifier votre requête" }, "permission": { "createRecordWithDeniedFields": "Vous n'avez pas la permission de créer des enregistrements avec les champs({{fields}})", @@ -1221,7 +1224,9 @@ "button": { "clickCountReachedMaxCount": "Le nombre de clics sur le bouton a atteint la limite maximale", "notSupportReset": "Le champ de bouton ne prend pas en charge la réinitialisation" - } + }, + "primaryCannotBeLookup": "Le champ principal ne peut pas être configuré comme un champ Lookup", + "primaryFieldAlreadyExists": "La table a déjà un champ principal" }, "view": { "notFound": "Vue introuvable", @@ -1432,7 +1437,8 @@ "linkedInPostNotFound": "Publication LinkedIn non trouvée : {{postId}}", "linkedInAuthorNotFound": "Auteur LinkedIn non trouvé : {{postId}}", "fetchLinkedInUserFailed": "Échec de la récupération de l'utilisateur LinkedIn : {{error}}", - "domainAlreadyInUse": "Ce domaine est déjà lié à une autre application" + "domainAlreadyInUse": "Ce domaine est déjà lié à une autre application", + "domainReserved": "Sous-domaine réservé" } } } diff --git a/packages/common-i18n/src/locales/fr/table.json b/packages/common-i18n/src/locales/fr/table.json index 6bea471b1c..58b46320b4 100644 --- a/packages/common-i18n/src/locales/fr/table.json +++ b/packages/common-i18n/src/locales/fr/table.json @@ -632,6 +632,11 @@ } } } + }, + "errorType": { + "InvalidPrimaryLookup": "Champ principal mal configuré comme Lookup", + "InvalidPrimaryType": "Le champ principal a un type non pris en charge", + "MissingPrimary": "La table n'a pas de champ principal" } }, "index": { @@ -957,6 +962,7 @@ "expand": "Développer", "history": "Historique", "close": "Réduire", + "noModel": "Aucun modèle disponible", "addAttachment": "Ajouter une pièce jointe", "noHistory": "Aucun historique de chat", "noFoundHistory": "Aucun historique de chat trouvé, veuillez commencer une nouvelle conversation", @@ -974,7 +980,14 @@ "emptyContext": "Aucun contexte à ajouter", "selectionRows": "Lignes {{start}}-{{end}}" }, - "inputPlaceholder": "Envoyer un message...", + "mention": { + "tables": "Tables", + "apps": "Applications", + "workflows": "Workflows", + "folders": "Dossiers" + }, + "inputPlaceholder": "Tapez @ pour ajouter du contexte", + "inputPlaceholderFiles": "Collez ou déposez des fichiers ici", "thought": "En train de penser", "meta": { "input": "Entrée", @@ -1033,6 +1046,8 @@ "advancedOptions": "Options avancées", "namingFieldLabel": "Préfixe du nom de pièce jointe", "selectField": "Par défaut: index de pièce jointe", + "noPrefixOption": "Sans préfixe", + "noPrefixOptionDesc": "Conserver le nom de fichier original", "groupByRow": "Archiver dans des dossiers", "groupByRowTip": "Lorsqu'une ligne contient plusieurs pièces jointes, elles seront placées dans le même dossier ; les lignes avec une seule pièce jointe ne créeront pas de dossier." } diff --git a/packages/common-i18n/src/locales/it/common.json b/packages/common-i18n/src/locales/it/common.json index 0a1cbf8dd5..cd96ec63e7 100644 --- a/packages/common-i18n/src/locales/it/common.json +++ b/packages/common-i18n/src/locales/it/common.json @@ -164,8 +164,9 @@ }, "settings": { "title": "Impostazioni dell'istanza", + "allSetting": "Impostazioni dello spazio e personali", "personal": { - "title": "Impostazioni personali" + "title": "Personale" }, "templateAdmin": { "title": "Gestione modelli", @@ -890,6 +891,10 @@ "runQuotaExceeded": { "title": "L'automazione {{name}} ha raggiunto il numero massimo mensile di esecuzioni", "message": "Le esecuzioni mensili dell'automazione {{name}} sono esaurite e l'esecuzione è temporaneamente non disponibile. Aggiorna l'abbonamento o acquista esecuzioni aggiuntive." + }, + "failedSummary": { + "title": "L'automazione {{name}} ha fallito {{failCount}} volte", + "message": "La tua automazione {{name}} ha accumulato {{failCount}} errori consecutivi. Apri la cronologia delle esecuzioni per visualizzare i dettagli." } }, "billing": { @@ -1105,9 +1110,9 @@ } }, "changelog": { - "newUpdate": "AGGIORNAMENTO 17 APR", - "title": "Claude Opus 4.7 è ora disponibile in Teable", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "AGGIORNAMENTO 28 APR", + "title": "Nuovo trigger: Quando si riceve un'email (IMAP)", + "url": "https://help.teable.ai/en/changelog#apr-28-2026", + "id": "changelog-2026-04-28-new-trigger-when-email-receive-imap" } } diff --git a/packages/common-i18n/src/locales/it/sdk.json b/packages/common-i18n/src/locales/it/sdk.json index f7128f8ddd..a77df1f0c2 100644 --- a/packages/common-i18n/src/locales/it/sdk.json +++ b/packages/common-i18n/src/locales/it/sdk.json @@ -158,6 +158,7 @@ }, "invalidateSelected": "Valore non valido", "invalidateSelectedTips": "Il valore selezionato è stato eliminato, per favore seleziona di nuovo", + "invalidConditionTip": "Questa condizione di filtro non è valida e verrà ignorata. Regola il valore.", "default": { "empty": "Nessuna condizione di filtro applicata", "placeholder": "Inserisci un valore" @@ -264,7 +265,8 @@ "configLabel_other_visible": "{{count}} campi visibili", "showAll": "Mostra tutto", "hideAll": "Nascondi tutto", - "primaryKey": "Campo principale: Identifica i record\nnon può essere nascosto o eliminato, visibile nei record collegati." + "primaryKey": "Campo principale: Identifica i record\nnon può essere nascosto o eliminato, visibile nei record collegati.", + "notInCurrentView": "Il campo \"{{fieldName}}\" non è visibile nella vista corrente e non può essere raggiunto" }, "expandRecord": { "copy": "Copia negli appunti", @@ -1121,7 +1123,8 @@ "notAllowedToExecuteSqlWithKeyword": "Non è consentito eseguire SQL con la parola chiave {{keyword}}", "whiteListCheckError": "Si è verificato un errore durante il controllo dell'accesso alla tabella: {{message}}", "databaseConnectionFailed": "Connessione al database fallita: {{message}}", - "executeQuerySqlFailed": "Esecuzione della query SQL fallita: {{message}}" + "executeQuerySqlFailed": "Esecuzione della query SQL fallita: {{message}}", + "sqlSyntaxError": "Errore di sintassi SQL, controlla la tua query" }, "permission": { "createRecordWithDeniedFields": "Non hai il permesso di creare record con campi({{fields}})", @@ -1221,7 +1224,9 @@ "button": { "clickCountReachedMaxCount": "Il conteggio dei clic sul pulsante ha raggiunto il limite massimo", "notSupportReset": "Il campo pulsante non supporta il ripristino" - } + }, + "primaryCannotBeLookup": "Il campo primario non può essere configurato come campo Lookup", + "primaryFieldAlreadyExists": "La tabella ha già un campo primario" }, "view": { "notFound": "Vista non trovata", @@ -1432,7 +1437,8 @@ "linkedInPostNotFound": "Post LinkedIn non trovato: {{postId}}", "linkedInAuthorNotFound": "Autore LinkedIn non trovato: {{postId}}", "fetchLinkedInUserFailed": "Impossibile recuperare l'utente LinkedIn: {{error}}", - "domainAlreadyInUse": "Questo dominio è già associato a un'altra app" + "domainAlreadyInUse": "Questo dominio è già associato a un'altra app", + "domainReserved": "Sottodominio riservato" } } } diff --git a/packages/common-i18n/src/locales/it/table.json b/packages/common-i18n/src/locales/it/table.json index f99cd1d33a..767ba8bbb2 100644 --- a/packages/common-i18n/src/locales/it/table.json +++ b/packages/common-i18n/src/locales/it/table.json @@ -641,6 +641,11 @@ } } } + }, + "errorType": { + "InvalidPrimaryLookup": "Campo primario configurato erroneamente come Lookup", + "InvalidPrimaryType": "Il campo primario ha un tipo non supportato", + "MissingPrimary": "La tabella non ha un campo primario" } }, "index": { @@ -982,6 +987,7 @@ "expand": "Espandi", "history": "Cronologia", "close": "Comprimi", + "noModel": "Nessun modello disponibile", "addAttachment": "Aggiungi allegato", "noHistory": "Nessun chat history", "noFoundHistory": "Nessun chat history trovato, per favore inizia una nuova conversazione", @@ -999,7 +1005,14 @@ "emptyContext": "Nessun contesto da aggiungere", "selectionRows": "Righe {{start}}-{{end}}" }, - "inputPlaceholder": "Invia messaggio...", + "mention": { + "tables": "Tabelle", + "apps": "App", + "workflows": "Workflow", + "folders": "Cartelle" + }, + "inputPlaceholder": "Digita @ per aggiungere contesto", + "inputPlaceholderFiles": "Incolla o trascina file qui", "thought": "Pensando", "meta": { "input": "Input", @@ -1058,6 +1071,8 @@ "advancedOptions": "Opzioni avanzate", "namingFieldLabel": "Prefisso nome allegato", "selectField": "Predefinito: indice allegato", + "noPrefixOption": "Nessun prefisso", + "noPrefixOptionDesc": "Mantieni il nome file originale", "groupByRow": "Archivia in cartelle", "groupByRowTip": "Quando una riga ha più allegati, verranno inseriti nella stessa cartella; le righe con un solo allegato non creeranno una cartella." } diff --git a/packages/common-i18n/src/locales/ja/common.json b/packages/common-i18n/src/locales/ja/common.json index 158a0201b0..c836e3dbc3 100644 --- a/packages/common-i18n/src/locales/ja/common.json +++ b/packages/common-i18n/src/locales/ja/common.json @@ -164,8 +164,9 @@ }, "settings": { "title": "インスタンス設定", + "allSetting": "スペースと個人設定", "personal": { - "title": "個人設定" + "title": "個人" }, "templateAdmin": { "title": "テンプレート管理", @@ -892,6 +893,10 @@ "runQuotaExceeded": { "title": "自動化{{name}}は月間実行回数の上限に達しました", "message": "自動化{{name}}の今月の実行回数を使い切ったため、現在は実行できません。プランをアップグレードするか、追加実行回数を購入してください。" + }, + "failedSummary": { + "title": "自動化{{name}}が{{failCount}}回連続で失敗しました", + "message": "自動化{{name}}が{{failCount}}回連続で失敗しています。実行履歴を開いて詳細をご確認ください。" } }, "billing": { @@ -1107,9 +1112,9 @@ } }, "changelog": { - "newUpdate": "4月17日 アップデート", - "title": "Claude Opus 4.7 が Teable で利用可能に", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "4月28日 アップデート", + "title": "新しいトリガー:メール受信時(IMAP)", + "url": "https://help.teable.ai/en/changelog#apr-28-2026", + "id": "changelog-2026-04-28-new-trigger-when-email-receive-imap" } } diff --git a/packages/common-i18n/src/locales/ja/sdk.json b/packages/common-i18n/src/locales/ja/sdk.json index c954b711e9..661eb99b03 100644 --- a/packages/common-i18n/src/locales/ja/sdk.json +++ b/packages/common-i18n/src/locales/ja/sdk.json @@ -158,6 +158,7 @@ }, "invalidateSelected": "無効な値", "invalidateSelectedTips": "選択した値は削除されました。もう一度選択してください", + "invalidConditionTip": "このフィルター条件は無効なため無視されます。値を調整してください。", "default": { "empty": "フィルター条件は適用されません", "placeholder": "値を入力してください" @@ -264,7 +265,8 @@ "configLabel_other_visible": "{{count}}表示フィールド", "showAll": "すべて表示", "hideAll": "すべて非表示", - "primaryKey": "プライマリフィールド: レコードを識別するために使用され、非表示にしたり削除したりすることはできません" + "primaryKey": "プライマリフィールド: レコードを識別するために使用され、非表示にしたり削除したりすることはできません", + "notInCurrentView": "フィールド「{{fieldName}}」は現在のビューに表示されていないため、移動できません" }, "expandRecord": { "copy": "クリップボードにコピー", @@ -1121,7 +1123,8 @@ "notAllowedToExecuteSqlWithKeyword": "キーワード {{keyword}} を含むSQLの実行は許可されていません", "whiteListCheckError": "テーブルアクセスの確認中にエラーが発生しました: {{message}}", "databaseConnectionFailed": "データベース接続に失敗しました: {{message}}", - "executeQuerySqlFailed": "クエリSQLの実行に失敗しました: {{message}}" + "executeQuerySqlFailed": "クエリSQLの実行に失敗しました: {{message}}", + "sqlSyntaxError": "SQL構文エラーです。クエリを確認してください" }, "permission": { "createRecordWithDeniedFields": "フィールド({{fields}})を含むレコードを作成する権限がありません", @@ -1221,7 +1224,9 @@ "button": { "clickCountReachedMaxCount": "ボタンのクリック数が最大制限に達しました", "notSupportReset": "ボタンはリセットをサポートしていません" - } + }, + "primaryCannotBeLookup": "主フィールドを Lookup フィールドとして設定することはできません", + "primaryFieldAlreadyExists": "テーブルには既に主フィールドがあります" }, "view": { "notFound": "ビューが見つかりません", @@ -1432,7 +1437,8 @@ "linkedInPostNotFound": "LinkedIn投稿が見つかりません: {{postId}}", "linkedInAuthorNotFound": "LinkedIn投稿者が見つかりません: {{postId}}", "fetchLinkedInUserFailed": "LinkedInユーザーの取得に失敗しました: {{error}}", - "domainAlreadyInUse": "このドメインはすでに別のアプリに紐付けられています" + "domainAlreadyInUse": "このドメインはすでに別のアプリに紐付けられています", + "domainReserved": "サブドメインは予約済みです" } } } diff --git a/packages/common-i18n/src/locales/ja/table.json b/packages/common-i18n/src/locales/ja/table.json index acf3f2e5aa..0a4f92dd80 100644 --- a/packages/common-i18n/src/locales/ja/table.json +++ b/packages/common-i18n/src/locales/ja/table.json @@ -632,6 +632,11 @@ } } } + }, + "errorType": { + "InvalidPrimaryLookup": "主フィールドが Lookup として誤設定", + "InvalidPrimaryType": "主フィールドのタイプが非対応", + "MissingPrimary": "テーブルに主フィールドがありません" } }, "index": { @@ -956,6 +961,7 @@ "expand": "展開", "history": "履歴", "close": "折りたたむ", + "noModel": "利用可能なモデルがありません", "addAttachment": "添付ファイルを追加", "noHistory": "チャット履歴がありません", "noFoundHistory": "チャット履歴が見つかりません。新しいチャットを開始してください", @@ -973,7 +979,14 @@ "emptyContext": "追加するコンテキストがありません", "selectionRows": "行 {{start}}-{{end}}" }, - "inputPlaceholder": "メッセージを送信...", + "mention": { + "tables": "テーブル", + "apps": "アプリ", + "workflows": "ワークフロー", + "folders": "フォルダ" + }, + "inputPlaceholder": "@ でコンテキストを追加", + "inputPlaceholderFiles": "ファイルを貼り付けまたはドロップ", "thought": "思考中", "meta": { "input": "入力", @@ -1032,6 +1045,8 @@ "advancedOptions": "詳細オプション", "namingFieldLabel": "添付ファイル名のプレフィックス", "selectField": "デフォルト: 添付ファイル番号", + "noPrefixOption": "プレフィックスなし", + "noPrefixOptionDesc": "元のファイル名を保持", "groupByRow": "フォルダにアーカイブ", "groupByRowTip": "行に複数の添付ファイルがある場合、同じフォルダに配置されます。添付ファイルが1つだけの行はフォルダを作成しません。" } diff --git a/packages/common-i18n/src/locales/ru/common.json b/packages/common-i18n/src/locales/ru/common.json index 39ccb67d8f..df1d8d19c5 100644 --- a/packages/common-i18n/src/locales/ru/common.json +++ b/packages/common-i18n/src/locales/ru/common.json @@ -164,8 +164,9 @@ }, "settings": { "title": "Настройки экземпляра", + "allSetting": "Настройки пространства и личные настройки", "personal": { - "title": "Личные настройки" + "title": "Личное" }, "templateAdmin": { "title": "Управление шаблонами", @@ -848,6 +849,10 @@ "runQuotaExceeded": { "title": "Автоматизация {{name}} достигла максимального месячного лимита запусков", "message": "Месячный лимит запусков для автоматизации {{name}} исчерпан, поэтому сейчас запуск недоступен. Обновите подписку или приобретите дополнительные запуски." + }, + "failedSummary": { + "title": "Автоматизация {{name}} завершилась ошибкой {{failCount}} раз", + "message": "Ваша автоматизация {{name}} накопила {{failCount}} последовательных ошибок. Откройте историю запусков для просмотра деталей." } }, "billing": { @@ -1063,9 +1068,9 @@ } }, "changelog": { - "newUpdate": "ОБНОВЛЕНИЕ 17 АПР", - "title": "Claude Opus 4.7 теперь доступен в Teable", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "ОБНОВЛЕНИЕ 28 АПР", + "title": "Новый триггер: При получении письма (IMAP)", + "url": "https://help.teable.ai/en/changelog#apr-28-2026", + "id": "changelog-2026-04-28-new-trigger-when-email-receive-imap" } } diff --git a/packages/common-i18n/src/locales/ru/sdk.json b/packages/common-i18n/src/locales/ru/sdk.json index 276ab5a3e9..5d854e624b 100644 --- a/packages/common-i18n/src/locales/ru/sdk.json +++ b/packages/common-i18n/src/locales/ru/sdk.json @@ -158,6 +158,7 @@ }, "invalidateSelected": "Неверное значение", "invalidateSelectedTips": "Выбранное значение было удалено, выберите другое", + "invalidConditionTip": "Это условие фильтра недопустимо и будет проигнорировано. Измените значение.", "default": { "empty": "Фильтры не применены", "placeholder": "Введите значение" @@ -264,7 +265,8 @@ "configLabel_other_visible": "{{count}} видимых поля", "showAll": "Показать все", "hideAll": "Скрыть все", - "primaryKey": "Основное поле: Идентифицирует записи\nнельзя скрыть или удалить, видно в связанных записях." + "primaryKey": "Основное поле: Идентифицирует записи\nнельзя скрыть или удалить, видно в связанных записях.", + "notInCurrentView": "Поле «{{fieldName}}» не отображается в текущем представлении, поэтому перейти к нему нельзя" }, "expandRecord": { "copy": "Копировать в буфер обмена", @@ -1121,7 +1123,8 @@ "notAllowedToExecuteSqlWithKeyword": "Не разрешено выполнять SQL с ключевым словом {{keyword}}", "whiteListCheckError": "Произошла ошибка при проверке доступа к таблице: {{message}}", "databaseConnectionFailed": "Не удалось подключиться к базе данных: {{message}}", - "executeQuerySqlFailed": "Не удалось выполнить запрос SQL: {{message}}" + "executeQuerySqlFailed": "Не удалось выполнить запрос SQL: {{message}}", + "sqlSyntaxError": "Ошибка синтаксиса SQL, проверьте ваш запрос" }, "permission": { "createRecordWithDeniedFields": "У вас нет разрешения на создание записей с полями({{fields}})", @@ -1221,7 +1224,9 @@ "button": { "clickCountReachedMaxCount": "Количество нажатий кнопки достигло максимального предела", "notSupportReset": "Кнопка не поддерживает сброс" - } + }, + "primaryCannotBeLookup": "Основное поле не может быть настроено как поле Lookup", + "primaryFieldAlreadyExists": "В таблице уже есть основное поле" }, "view": { "notFound": "Представление не найдено", @@ -1432,7 +1437,8 @@ "linkedInPostNotFound": "Публикация LinkedIn не найдена: {{postId}}", "linkedInAuthorNotFound": "Автор LinkedIn не найден: {{postId}}", "fetchLinkedInUserFailed": "Не удалось получить пользователя LinkedIn: {{error}}", - "domainAlreadyInUse": "Этот домен уже привязан к другому приложению" + "domainAlreadyInUse": "Этот домен уже привязан к другому приложению", + "domainReserved": "Поддомен зарезервирован" } } } diff --git a/packages/common-i18n/src/locales/ru/table.json b/packages/common-i18n/src/locales/ru/table.json index a4a51abbc7..008260144e 100644 --- a/packages/common-i18n/src/locales/ru/table.json +++ b/packages/common-i18n/src/locales/ru/table.json @@ -632,6 +632,11 @@ } } } + }, + "errorType": { + "InvalidPrimaryLookup": "Основное поле неправильно настроено как Lookup", + "InvalidPrimaryType": "Основное поле имеет неподдерживаемый тип", + "MissingPrimary": "В таблице отсутствует основное поле" } }, "index": { @@ -957,6 +962,7 @@ "expand": "Развернуть", "history": "История", "close": "Свернуть", + "noModel": "Нет доступных моделей", "addAttachment": "Добавить вложение", "noHistory": "Нет истории чата", "noFoundHistory": "Нет истории чата, пожалуйста, начните новую беседу", @@ -974,7 +980,14 @@ "emptyContext": "Нет контекста для добавления", "selectionRows": "Строки {{start}}-{{end}}" }, - "inputPlaceholder": "Отправить сообщение...", + "mention": { + "tables": "Таблицы", + "apps": "Приложения", + "workflows": "Рабочие процессы", + "folders": "Папки" + }, + "inputPlaceholder": "Введите @, чтобы добавить контекст", + "inputPlaceholderFiles": "Вставьте или перетащите файлы сюда", "thought": "Мысли", "meta": { "input": "Ввод", @@ -1033,6 +1046,8 @@ "advancedOptions": "Дополнительные параметры", "namingFieldLabel": "Префикс имени вложения", "selectField": "По умолчанию: индекс вложения", + "noPrefixOption": "Без префикса", + "noPrefixOptionDesc": "Сохранить исходное имя файла", "groupByRow": "Архивировать в папки", "groupByRowTip": "Когда в строке несколько вложений, они будут помещены в одну папку; строки с одним вложением не создадут папку." } diff --git a/packages/common-i18n/src/locales/tr/common.json b/packages/common-i18n/src/locales/tr/common.json index 51af08a2bf..7ecfafab78 100644 --- a/packages/common-i18n/src/locales/tr/common.json +++ b/packages/common-i18n/src/locales/tr/common.json @@ -164,8 +164,9 @@ }, "settings": { "title": "Örnek Ayarları", + "allSetting": "Alan ve kişisel ayarlar", "personal": { - "title": "Kişisel ayarlar" + "title": "Kişisel" }, "templateAdmin": { "title": "Şablon yönetimi", @@ -849,6 +850,10 @@ "runQuotaExceeded": { "title": "Otomasyon {{name}} aylık maksimum çalıştırma sayısına ulaştı", "message": "Otomasyon {{name}} için aylık çalıştırma hakkı tükendiği için şu anda çalıştırılamıyor. Aboneliğinizi yükseltin veya ek çalıştırma satın alın." + }, + "failedSummary": { + "title": "Otomasyon {{name}} {{failCount}} kez başarısız oldu", + "message": "Otomasyonunuz {{name}} art arda {{failCount}} kez başarısız oldu. Ayrıntıları görmek için çalıştırma geçmişini açın." } }, "billing": { @@ -1095,9 +1100,9 @@ } }, "changelog": { - "newUpdate": "17 NİS GÜNCELLEMESİ", - "title": "Claude Opus 4.7 artık Teable'da", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "28 NİS GÜNCELLEMESİ", + "title": "Yeni Tetikleyici: E-posta Alındığında (IMAP)", + "url": "https://help.teable.ai/en/changelog#apr-28-2026", + "id": "changelog-2026-04-28-new-trigger-when-email-receive-imap" } } diff --git a/packages/common-i18n/src/locales/tr/sdk.json b/packages/common-i18n/src/locales/tr/sdk.json index 690689a9c0..050e843d1b 100644 --- a/packages/common-i18n/src/locales/tr/sdk.json +++ b/packages/common-i18n/src/locales/tr/sdk.json @@ -158,6 +158,7 @@ }, "invalidateSelected": "Geçersiz değer", "invalidateSelectedTips": "Seçilen değer silindi, lütfen tekrar seçin", + "invalidConditionTip": "Bu filtre koşulu geçersiz ve yok sayılacak. Lütfen değeri ayarlayın.", "default": { "empty": "Filtre koşulu uygulanmadı", "placeholder": "Bir değer girin" @@ -264,7 +265,8 @@ "configLabel_other_visible": "{{count}} görünür alan", "showAll": "Tümünü Göster", "hideAll": "Tümünü Gizle", - "primaryKey": "Birincil alan: Kayıtları tanımlar\ngizlenemez veya silinemez, bağlantılı kayıtlarda görünür." + "primaryKey": "Birincil alan: Kayıtları tanımlar\ngizlenemez veya silinemez, bağlantılı kayıtlarda görünür.", + "notInCurrentView": "\"{{fieldName}}\" alanı mevcut görünümde görünmüyor, bu yüzden alana gidilemiyor" }, "expandRecord": { "copy": "Panoya kopyala", @@ -1121,7 +1123,8 @@ "notAllowedToExecuteSqlWithKeyword": "{{keyword}} anahtar kelimesiyle SQL çalıştırılmasına izin verilmiyor", "whiteListCheckError": "Tablo erişimi kontrol edilirken bir hata oluştu: {{message}}", "databaseConnectionFailed": "Veritabanı bağlantısı başarısız oldu: {{message}}", - "executeQuerySqlFailed": "Sorgu SQL'i çalıştırılamadı: {{message}}" + "executeQuerySqlFailed": "Sorgu SQL'i çalıştırılamadı: {{message}}", + "sqlSyntaxError": "SQL sözdizimi hatası, lütfen sorgunuzu kontrol edin" }, "permission": { "createRecordWithDeniedFields": "Alanları({{fields}}) olan kayıtlar oluşturma izniniz yok", @@ -1221,7 +1224,9 @@ "button": { "clickCountReachedMaxCount": "Düğme tıklama sayısı maksimum sınıra ulaştı", "notSupportReset": "Düğme sıfırlamayı desteklemiyor" - } + }, + "primaryCannotBeLookup": "Birincil alan Lookup alanı olarak yapılandırılamaz", + "primaryFieldAlreadyExists": "Tablonun zaten bir birincil alanı var" }, "view": { "notFound": "Görünüm bulunamadı", @@ -1432,7 +1437,8 @@ "linkedInPostNotFound": "LinkedIn gönderisi bulunamadı: {{postId}}", "linkedInAuthorNotFound": "LinkedIn yazarı bulunamadı: {{postId}}", "fetchLinkedInUserFailed": "LinkedIn kullanıcısı alınamadı: {{error}}", - "domainAlreadyInUse": "Bu alan adı zaten başka bir uygulamaya bağlı" + "domainAlreadyInUse": "Bu alan adı zaten başka bir uygulamaya bağlı", + "domainReserved": "Alt alan adı ayrılmış" } } } diff --git a/packages/common-i18n/src/locales/tr/table.json b/packages/common-i18n/src/locales/tr/table.json index 9e9f81204f..f54f195da4 100644 --- a/packages/common-i18n/src/locales/tr/table.json +++ b/packages/common-i18n/src/locales/tr/table.json @@ -640,6 +640,11 @@ } } } + }, + "errorType": { + "InvalidPrimaryLookup": "Birincil alan Lookup olarak yanlış yapılandırıldı", + "InvalidPrimaryType": "Birincil alanın türü desteklenmiyor", + "MissingPrimary": "Tabloda birincil alan yok" } } }, @@ -878,6 +883,8 @@ "advancedOptions": "Gelişmiş seçenekler", "namingFieldLabel": "Ek adı öneki", "selectField": "Varsayılan: ek dizini", + "noPrefixOption": "Önek yok", + "noPrefixOptionDesc": "Orijinal dosya adını koru", "groupByRow": "Klasörlere arşivle", "groupByRowTip": "Bir satırda birden fazla ek varsa, aynı klasöre yerleştirilir; yalnızca bir eki olan satırlar klasör oluşturmaz." } @@ -951,10 +958,19 @@ "shareNodeTab": "Düğümü paylaş" }, "aiChat": { + "noModel": "Kullanılabilir model yok", "agent": { "askUserQuestion": { "otherPlaceholder": "Veya yanıtınızı yazın…" } - } + }, + "mention": { + "tables": "Tablolar", + "apps": "Uygulamalar", + "workflows": "İş akışları", + "folders": "Klasörler" + }, + "inputPlaceholder": "Bağlam eklemek için @ yazın", + "inputPlaceholderFiles": "Dosyaları buraya yapıştırın veya bırakın" } } diff --git a/packages/common-i18n/src/locales/uk/common.json b/packages/common-i18n/src/locales/uk/common.json index 40eefbb58f..46c0dccc6f 100644 --- a/packages/common-i18n/src/locales/uk/common.json +++ b/packages/common-i18n/src/locales/uk/common.json @@ -151,8 +151,9 @@ }, "settings": { "title": "Приклад налаштувань", + "allSetting": "Налаштування простору й особисті", "personal": { - "title": "Особисті налаштування" + "title": "Особисте" }, "templateAdmin": { "title": "Керування шаблонами", @@ -838,6 +839,10 @@ "runQuotaExceeded": { "title": "Автоматизація {{name}} досягла максимального місячного ліміту запусків", "message": "Місячний ліміт запусків для автоматизації {{name}} вичерпано, тому зараз запуск недоступний. Оновіть підписку або придбайте додаткові запуски." + }, + "failedSummary": { + "title": "Автоматизація {{name}} завершилася помилкою {{failCount}} разів", + "message": "Ваша автоматизація {{name}} накопичила {{failCount}} послідовних помилок. Відкрийте історію запусків, щоб переглянути деталі." } }, "billing": { @@ -1084,9 +1089,9 @@ } }, "changelog": { - "newUpdate": "ОНОВЛЕННЯ 17 КВІТ", - "title": "Claude Opus 4.7 тепер доступний у Teable", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "ОНОВЛЕННЯ 28 КВІТ", + "title": "Новий тригер: При отриманні електронного листа (IMAP)", + "url": "https://help.teable.ai/en/changelog#apr-28-2026", + "id": "changelog-2026-04-28-new-trigger-when-email-receive-imap" } } diff --git a/packages/common-i18n/src/locales/uk/sdk.json b/packages/common-i18n/src/locales/uk/sdk.json index 26aff93b15..28f7be55ad 100644 --- a/packages/common-i18n/src/locales/uk/sdk.json +++ b/packages/common-i18n/src/locales/uk/sdk.json @@ -158,6 +158,7 @@ }, "invalidateSelected": "Недійсне значення", "invalidateSelectedTips": "Вибране значення видалено, виберіть ще раз", + "invalidConditionTip": "Ця умова фільтра недійсна і буде проігнорована. Змініть значення.", "default": { "empty": "Умови фільтрування не застосовуються", "placeholder": "Введіть значення" @@ -264,7 +265,8 @@ "configLabel_other_visible": "{{count}} видимих ​​полів", "showAll": "Показати все", "hideAll": "Приховати все", - "primaryKey": "Основне поле: Ідентифікує записи\nне можна приховати або видалити, видно у зв'язаних записах." + "primaryKey": "Основне поле: Ідентифікує записи\nне можна приховати або видалити, видно у зв'язаних записах.", + "notInCurrentView": "Поле «{{fieldName}}» не відображається в поточному поданні, тому перейти до нього не можна" }, "expandRecord": { "copy": "Копіювати в буфер обміну", @@ -1121,7 +1123,8 @@ "notAllowedToExecuteSqlWithKeyword": "Не дозволено виконувати SQL з ключовим словом {{keyword}}", "whiteListCheckError": "Сталася помилка під час перевірки доступу до таблиці: {{message}}", "databaseConnectionFailed": "Не вдалося підключитися до бази даних: {{message}}", - "executeQuerySqlFailed": "Не вдалося виконати запит SQL: {{message}}" + "executeQuerySqlFailed": "Не вдалося виконати запит SQL: {{message}}", + "sqlSyntaxError": "Помилка синтаксису SQL, перевірте ваш запит" }, "permission": { "createRecordWithDeniedFields": "У вас немає дозволу на створення записів з полями({{fields}})", @@ -1221,7 +1224,9 @@ "button": { "clickCountReachedMaxCount": "Кількість натискань кнопки досягла максимальної межі", "notSupportReset": "Кнопка не підтримує скидання" - } + }, + "primaryCannotBeLookup": "Первинне поле не може бути налаштовано як поле Lookup", + "primaryFieldAlreadyExists": "У таблиці вже є первинне поле" }, "view": { "notFound": "Представлення не знайдено", @@ -1432,7 +1437,8 @@ "linkedInPostNotFound": "Публікацію LinkedIn не знайдено: {{postId}}", "linkedInAuthorNotFound": "Автора LinkedIn не знайдено: {{postId}}", "fetchLinkedInUserFailed": "Не вдалося отримати користувача LinkedIn: {{error}}", - "domainAlreadyInUse": "Цей домен вже прив'язаний до іншого додатка" + "domainAlreadyInUse": "Цей домен вже прив'язаний до іншого додатка", + "domainReserved": "Піддомен зарезервований" } } } diff --git a/packages/common-i18n/src/locales/uk/table.json b/packages/common-i18n/src/locales/uk/table.json index 49fd56f44b..b272eca117 100644 --- a/packages/common-i18n/src/locales/uk/table.json +++ b/packages/common-i18n/src/locales/uk/table.json @@ -641,6 +641,11 @@ } } } + }, + "errorType": { + "InvalidPrimaryLookup": "Первинне поле неправильно налаштовано як Lookup", + "InvalidPrimaryType": "Первинне поле має непідтримуваний тип", + "MissingPrimary": "У таблиці відсутнє первинне поле" } }, "index": { @@ -976,6 +981,7 @@ "expand": "Розгорнути", "history": "Історія", "close": "Згорнути", + "noModel": "Немає доступних моделей", "addAttachment": "Додати вкладення", "noHistory": "Немає історії чату", "noFoundHistory": "Немає історії чату, будь ласка, почати нову розмову", @@ -993,7 +999,14 @@ "emptyContext": "Немає контексту для додавання", "selectionRows": "Рядки {{start}}-{{end}}" }, - "inputPlaceholder": "Відправити повідомлення...", + "mention": { + "tables": "Таблиці", + "apps": "Програми", + "workflows": "Робочі процеси", + "folders": "Папки" + }, + "inputPlaceholder": "Введіть @, щоб додати контекст", + "inputPlaceholderFiles": "Вставте або перетягніть файли сюди", "thought": "Міркування", "meta": { "input": "Вхід", @@ -1052,6 +1065,8 @@ "advancedOptions": "Додаткові параметри", "namingFieldLabel": "Префікс імені вкладення", "selectField": "За замовчуванням: індекс вкладення", + "noPrefixOption": "Без префікса", + "noPrefixOptionDesc": "Зберегти оригінальну назву файлу", "groupByRow": "Архівувати в папки", "groupByRowTip": "Коли рядок має кілька вкладень, вони будуть розміщені в одній папці; рядки з одним вкладенням не створюють папку." } diff --git a/packages/common-i18n/src/locales/zh/common.json b/packages/common-i18n/src/locales/zh/common.json index e39c6e2afb..884d9cf517 100644 --- a/packages/common-i18n/src/locales/zh/common.json +++ b/packages/common-i18n/src/locales/zh/common.json @@ -174,8 +174,9 @@ }, "settings": { "title": "实例设置", + "allSetting": "个人 & 空间设置", "personal": { - "title": "个人设置" + "title": "个人" }, "templateAdmin": { "title": "模板管理", @@ -1201,6 +1202,10 @@ "runQuotaExceeded": { "title": "自动化 {{name}} 已触及每月最大运行次数", "message": "自动化 {{name}} 本月可运行次数已用完,暂时无法继续运行。请升级订阅或购买额外运行次数。" + }, + "failedSummary": { + "title": "自动化 {{name}} 已连续失败 {{failCount}} 次", + "message": "自动化 {{name}} 已累计连续失败 {{failCount}} 次,请前往运行历史查看详情。" } }, "billing": { @@ -1449,9 +1454,9 @@ } }, "changelog": { - "newUpdate": "4月17日 更新", - "title": "Claude Opus 4.7 现已接入 Teable", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "4月28日 更新", + "title": "邮件接收触发器(IMAP)上线", + "url": "https://help.teable.ai/en/changelog#apr-28-2026", + "id": "changelog-2026-04-28-new-trigger-when-email-receive-imap" } } diff --git a/packages/common-i18n/src/locales/zh/sdk.json b/packages/common-i18n/src/locales/zh/sdk.json index 108cd5bba5..1a84dbb2cc 100644 --- a/packages/common-i18n/src/locales/zh/sdk.json +++ b/packages/common-i18n/src/locales/zh/sdk.json @@ -191,6 +191,7 @@ }, "invalidateSelected": "已删除选项", "invalidateSelectedTips": "该选项已被删除,请重新选择", + "invalidConditionTip": "此筛选条件无效,将被忽略。请调整后重试。", "default": { "empty": "当前没有应用任何筛选条件", "placeholder": "请输入" @@ -298,7 +299,8 @@ "configLabel_other_visible": "字段配置({{count}} 显示)", "showAll": "显示全部", "hideAll": "隐藏全部", - "primaryKey": "主字段:用于标识记录,不可隐藏或删除\n其值会在关联记录中显示" + "primaryKey": "主字段:用于标识记录,不可隐藏或删除\n其值会在关联记录中显示", + "notInCurrentView": "字段“{{fieldName}}”不在当前视图中显示,无法跳转" }, "expandRecord": { "copy": "复制到剪贴板", @@ -1139,7 +1141,8 @@ "whiteListCheckError": "检查表访问时发生错误:{{message}}", "databaseConnectionFailed": "数据库连接失败:{{message}}", "executeQuerySqlFailed": "执行查询 SQL 失败:{{message}}", - "readOnlyCheckFailed": "只读检查失败:{{message}}" + "readOnlyCheckFailed": "只读检查失败:{{message}}", + "sqlSyntaxError": "SQL 语法错误,请检查查询语句" }, "permission": { "createRecordWithDeniedFields": "没有权限创建带有字段({{fields}})的记录", @@ -1241,7 +1244,9 @@ "button": { "clickCountReachedMaxCount": "按钮点击次数已达到最大限制", "notSupportReset": "按钮不支持重置" - } + }, + "primaryCannotBeLookup": "主字段不能配置为 Lookup 字段", + "primaryFieldAlreadyExists": "表已经有主字段" }, "view": { "notFound": "视图不存在", @@ -1439,7 +1444,8 @@ "noFilesInZip": "ZIP 文件中没有找到文件", "zipFileTooLarge": "ZIP 文件大小超过 5MB 限制", "invalidZip": "无效的 ZIP 文件", - "domainAlreadyInUse": "该域名已被其他应用绑定" + "domainAlreadyInUse": "该域名已被其他应用绑定", + "domainReserved": "子域名已被保留" }, "reward": { "notFound": "奖励记录不存在", diff --git a/packages/common-i18n/src/locales/zh/table.json b/packages/common-i18n/src/locales/zh/table.json index 93aa89801f..dc03e304ae 100644 --- a/packages/common-i18n/src/locales/zh/table.json +++ b/packages/common-i18n/src/locales/zh/table.json @@ -256,6 +256,7 @@ "reset": "清空", "fieldUpdated": "字段已更新", "fieldCreated": "字段已创建", + "fieldUnavailable": "字段当前不可用,请刷新表格后重试。", "confirmFieldChange": "确认字段变更", "areYouSurePerformIt": "确定要执行此操作吗?", "addDescription": "添加描述", @@ -662,6 +663,20 @@ "manualRepairDialogClose": "关闭", "manualRepairPreview": "手动修复配置", "manualRepairPreviewTip": "当前仅展示规则要求用户提供的修复选项,尚未真正提交执行。", + "repairPreviewTitle": "确认修复内容", + "repairPreviewDescription": "下面是 dry-run 返回的修复说明和将要执行的 SQL。确认后才会真正执行修复。", + "repairPreviewTooltip": "点击查看 dry-run 修复计划,弹窗会显示实际返回的 SQL;如果没有 SQL,也会明确提示。", + "repairPreviewMissingTable": "当前检查结果缺少表信息,无法定位 dry-run 修复目标。", + "repairPreviewUnavailableStatus": "当前状态不需要修复,无法生成 dry-run 修复计划。", + "repairPreviewWhat": "将修复什么", + "repairPreviewTarget": "字段「{{fieldName}}」的「{{ruleName}}」规则", + "repairPreviewPrinciple": "修复原理", + "repairPreviewNoPrinciple": "该规则未返回额外的修复原理说明。", + "repairPreviewSql": "将执行的 SQL", + "repairPreviewNoSql": "dry-run 没有返回可执行 SQL,当前不能直接执行自动修复。", + "repairPreviewCannotConfirm": "dry-run 返回了 SQL,但该规则当前不支持自动修复确认,只能查看修复计划。", + "repairPreviewParameters": "参数", + "repairPreviewConfirm": "确认执行修复", "checking": "正在检查 schema...", "repairing": "正在修复 schema...", "streamError": "Schema 完整性流式请求失败。", @@ -771,6 +786,7 @@ "junctionForeignKeyOrphanRows": "当前无法自动修复:中间表仍存在无效关系行" }, "description": { + "autoRule": "修复会执行该规则生成的 schema 语句,然后重新校验规则,确认 schema 已恢复为正确状态。", "symmetricFieldConflict": "有多个字段同时指向同一个对称关联目标。需要先决定哪一个字段应该保留双向关联关系,再继续修复。", "foreignKeyTargetTableMissing": "请先确认关联目标表 {{targetPhysicalTableName}} 是否已被删除或重命名。恢复目标表后可重新执行修复;如果该关联已经不再需要,请修改或删除字段「{{fieldName}}」的关联配置。", "foreignKeyOrphanRows": "请先清理字段「{{fieldName}}」中指向不存在记录的无效关联数据,然后再重新执行修复。", @@ -832,7 +848,10 @@ "ReferenceFieldNotFound": "引用字段不存在", "UniqueIndexNotFound": "唯一索引不存在", "EmptyString": "存在空字符串单元格", - "InvalidFilterOperator": "无效的筛选操作符" + "InvalidFilterOperator": "无效的筛选操作符", + "InvalidPrimaryLookup": "主字段被错误配置为 Lookup", + "InvalidPrimaryType": "主字段类型不支持", + "MissingPrimary": "表缺少主字段" } }, "index": { @@ -947,6 +966,8 @@ "advancedOptions": "高级选项", "namingFieldLabel": "附件名前缀", "selectField": "默认附件序号", + "noPrefixOption": "无前缀", + "noPrefixOptionDesc": "保留原始文件名", "groupByRow": "归档到文件夹", "groupByRowTip": "同一行有多个附件时,会放入同一个文件夹中;只有一个附件的行不会创建文件夹。" } @@ -1262,9 +1283,12 @@ "expand": "展开", "history": "历史记录", "close": "收起", + "noModel": "没有可用的模型", "clearChatConfirmTitle": "确认清除对话", "clearChatConfirmDesc": "当前对话内容将不会被保存,确定要清除吗?", "dontShowAgain": "不再提醒", + "modelSwitchTitle": "切换模型", + "modelSwitchHint": "本对话当前由 {{previous}} 处理,发送后切换至新模型,仅携带最近 3 轮对话。", "sandboxExpiry": { "expiresIn": "智能体的计算机将在 {{time}} 后到期", "reset": "续期", @@ -1296,7 +1320,14 @@ "emptyContext": "暂无可添加的上下文", "selectionRows": "行 {{start}}-{{end}}" }, - "inputPlaceholder": "描述你想要做什么", + "mention": { + "tables": "数据表", + "apps": "应用", + "workflows": "工作流", + "folders": "文件夹" + }, + "inputPlaceholder": "输入 @ 添加上下文", + "inputPlaceholderFiles": "粘贴或拖入文件", "thought": "深度思考", "meta": { "input": "输入", @@ -1353,7 +1384,10 @@ }, "retry": { "interrupted": "响应已中断", - "button": "重试" + "button": "重试", + "offline": "当前无网络连接,恢复后将自动重试。", + "pausedHidden": "已暂停重试,请切回此标签页以重新连接。", + "maxAttemptsReached": "自动重试未成功,请手动刷新页面。" }, "guide": { "goToScenario": "跳转到场景 {{index}}" diff --git a/packages/core/src/auth/actions.ts b/packages/core/src/auth/actions.ts index 59712d96d2..b5b8b406ad 100644 --- a/packages/core/src/auth/actions.ts +++ b/packages/core/src/auth/actions.ts @@ -134,13 +134,29 @@ export type ActionPrefixMap = { [ActionPrefix.View]: ViewAction[]; [ActionPrefix.Field]: FieldAction[]; [ActionPrefix.Record]: RecordAction[]; + [ActionPrefix.TableRecordHistory]: TableRecordHistoryAction[]; [ActionPrefix.Automation]: AutomationAction[]; [ActionPrefix.App]: AppAction[]; [ActionPrefix.User]: UserAction[]; - [ActionPrefix.TableRecordHistory]: TableRecordHistoryAction[]; [ActionPrefix.Instance]: InstanceAction[]; [ActionPrefix.Enterprise]: EnterpriseAction[]; }; + +export const allActions: readonly Action[] = [ + ...spaceActions, + ...baseActions, + ...tableActions, + ...viewActions, + ...fieldActions, + ...recordActions, + ...tableRecordHistoryActions, + ...automationActions, + ...appActions, + ...userActions, + ...instanceActions, + ...enterpriseActions, +]; + export const actionPrefixMap: ActionPrefixMap = { [ActionPrefix.Space]: [...spaceActions], [ActionPrefix.Base]: [...baseActions], @@ -148,9 +164,9 @@ export const actionPrefixMap: ActionPrefixMap = { [ActionPrefix.View]: [...viewActions], [ActionPrefix.Field]: [...fieldActions], [ActionPrefix.Record]: [...recordActions], + [ActionPrefix.TableRecordHistory]: [...tableRecordHistoryActions], [ActionPrefix.Automation]: [...automationActions], [ActionPrefix.App]: [...appActions], - [ActionPrefix.TableRecordHistory]: [...tableRecordHistoryActions], [ActionPrefix.User]: [...userActions], [ActionPrefix.Instance]: [...instanceActions], [ActionPrefix.Enterprise]: [...enterpriseActions], diff --git a/packages/core/src/models/field/ai-config/attachment.ts b/packages/core/src/models/field/ai-config/attachment.ts index d9880de181..9fcba879e0 100644 --- a/packages/core/src/models/field/ai-config/attachment.ts +++ b/packages/core/src/models/field/ai-config/attachment.ts @@ -48,7 +48,9 @@ export const attachmentFieldAIConfigBaseSchema = commonFieldAIConfig.extend({ // Aspect ratio for multimodal LLMs (Gemini, etc.) - injected into prompt aspectRatio: z .string() - .regex(/^\d+:\d+$/, { message: 'Aspect ratio must be in "width:height" format, e.g., "16:9"' }) + .regex(/^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/, { + message: 'Aspect ratio must be in "width:height" format, e.g., "16:9"', + }) .optional(), // Resolution for multimodal LLMs (1K, 2K, 4K) - injected into prompt resolution: z.enum(IMAGE_RESOLUTIONS).optional(), diff --git a/packages/core/src/models/field/derivate/formula.field.spec.ts b/packages/core/src/models/field/derivate/formula.field.spec.ts index a8e198d220..0b4d367bec 100644 --- a/packages/core/src/models/field/derivate/formula.field.spec.ts +++ b/packages/core/src/models/field/derivate/formula.field.spec.ts @@ -238,6 +238,19 @@ describe('FormulaFieldCore', () => { expect(converted).toBe('{fld123} + 1'); }); + it('should convert localized BLANK comparisons with spaced function calls', () => { + const dependFieldMap = { + fldWeight: { name: '入职体重(kg)' }, + }; + + expect( + FormulaFieldCore.convertExpressionNameToId('{入职体重(kg)} !=BLANK()', dependFieldMap) + ).toBe('{fldWeight} !=BLANK()'); + expect( + FormulaFieldCore.convertExpressionNameToId('{入职体重(kg)} != BLANK()', dependFieldMap) + ).toBe('{fldWeight} != BLANK()'); + }); + it('should return current typed value with field context', () => { expect(FormulaFieldCore.getParsedValueType('2 + 2', {})).toEqual({ cellValueType: CellValueType.Number, diff --git a/packages/core/src/models/view/filter/filter.spec.ts b/packages/core/src/models/view/filter/filter.spec.ts index 0eb837db5f..906eee2192 100644 --- a/packages/core/src/models/view/filter/filter.spec.ts +++ b/packages/core/src/models/view/filter/filter.spec.ts @@ -1,5 +1,6 @@ +import { CellValueType, FieldType } from '../../field/constant'; import type { IFilter } from './filter'; -import { filterSchema } from './filter'; +import { analyzeFilterValidationIssues, filterSchema } from './filter'; describe('Filter Parse', () => { it('should parse single filter', async () => { @@ -115,3 +116,227 @@ describe('Filter Parse', () => { }); }); }); + +describe('analyzeFilterValidationIssues', () => { + const dateFieldId = 'fldDate0000000000'; + const dateReferenceFieldId = 'fldDateRef0000000'; + const numberFieldId = 'fldNumber00000000'; + + const fieldMetaMap = { + [dateFieldId]: { + type: FieldType.Date, + cellValueType: CellValueType.DateTime, + }, + [dateReferenceFieldId]: { + type: FieldType.Date, + cellValueType: CellValueType.DateTime, + }, + [numberFieldId]: { + type: FieldType.Number, + cellValueType: CellValueType.Number, + }, + }; + + it('reports invalid operator for the field type', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: numberFieldId, + operator: 'contains', + value: 'abc', + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 'OPERATOR_NOT_ALLOWED', + fieldId: numberFieldId, + operator: 'contains', + path: [0], + }); + }); + + it('reports nested path for invalid sub-operator mode', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: numberFieldId, + operator: 'is', + value: 1, + }, + { + conjunction: 'or', + filterSet: [ + { + fieldId: dateFieldId, + operator: 'isWithIn', + value: { mode: 'notAMode', exactDate: null, timeZone: 'UTC' } as never, + }, + ], + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 'MODE_NOT_ALLOWED', + fieldId: dateFieldId, + mode: 'notAMode', + path: [1, 0], + }); + }); + + it('reports shape mismatch when isWithIn value is a primitive', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: dateFieldId, + operator: 'isWithIn', + value: 'today' as never, + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 'VALUE_SHAPE_INVALID', + fieldId: dateFieldId, + operator: 'isWithIn', + path: [0], + }); + expect(errors[0].message).toContain('Valid modes:'); + expect(errors[0].message).toContain('pastWeek'); + }); + + it('reports shape mismatch when isBefore value is a plain date string', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: dateFieldId, + operator: 'isBefore', + value: '2026-04-27T00:00:00.000Z' as never, + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 'VALUE_SHAPE_INVALID', + fieldId: dateFieldId, + operator: 'isBefore', + path: [0], + }); + expect(errors[0].message).toContain('Valid modes:'); + expect(errors[0].message).toContain('today'); + }); + + it('reports invalid mode when value is an object with unknown mode', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: dateFieldId, + operator: 'isWithIn', + value: { mode: 'notAMode', exactDate: null, timeZone: 'UTC' } as never, + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 'MODE_NOT_ALLOWED', + fieldId: dateFieldId, + mode: 'notAMode', + path: [0], + }); + }); + + it('treats null value as in-progress, not an error', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: dateFieldId, + operator: 'isWithIn', + value: null, + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toEqual([]); + }); + + it('allows date field reference comparisons without requiring mode', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: dateFieldId, + operator: 'is', + value: { + type: 'field', + fieldId: dateReferenceFieldId, + }, + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toEqual([]); + }); + + it('reports date field reference arrays as invalid date value shape', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: dateFieldId, + operator: 'is', + value: [ + { + type: 'field', + fieldId: dateReferenceFieldId, + }, + ] as never, + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 'VALUE_SHAPE_INVALID', + fieldId: dateFieldId, + operator: 'is', + path: [0], + }); + }); + + it('treats symbol operator as compatible when mapping exists', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: numberFieldId, + isSymbol: true, + operator: '=', + value: 3, + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toEqual([]); + }); +}); diff --git a/packages/core/src/models/view/filter/filter.ts b/packages/core/src/models/view/filter/filter.ts index 39679803e2..47d181292b 100644 --- a/packages/core/src/models/view/filter/filter.ts +++ b/packages/core/src/models/view/filter/filter.ts @@ -1,11 +1,15 @@ import { z } from 'zod'; -import { FieldType } from '../../field/constant'; +import type { CellValueType, FieldType } from '../../field/constant'; import type { IConjunction } from './conjunction'; import { and, conjunctionSchema } from './conjunction'; -import type { IFilterItem } from './filter-item'; -import { filterItemSchema, isFieldReferenceValue } from './filter-item'; -import type { IDateTimeFieldOperator } from './operator'; -import { getValidFilterSubOperators, isWithIn } from './operator'; +import { filterItemSchema, isFieldReferenceValue, type IFilterItem } from './filter-item'; +import type { IDateTimeFieldOperator, IOperator } from './operator'; +import { + getFilterOperatorMapping, + getValidFilterOperators, + getValidFilterSubOperators, + isWithIn, +} from './operator'; export const baseFilterSetSchema = z.object({ conjunction: conjunctionSchema, @@ -129,54 +133,130 @@ export const extractFieldIdsFromFilter = ( }; export interface IFilterValidationError { + code: 'FIELD_NOT_FOUND' | 'OPERATOR_NOT_ALLOWED' | 'MODE_NOT_ALLOWED' | 'VALUE_SHAPE_INVALID'; + path: number[]; fieldId: string; operator: string; mode?: string; message: string; } -/** - * Validate filter operator and mode compatibility - * Returns an array of validation errors if any, empty array if valid - * @param filter - The filter to validate - * @param fieldTypeMap - A map of fieldId to FieldType - */ -export const validateFilterOperatorModeCompatibility = ( +export interface IFilterValidationFieldMeta { + type: FieldType; + cellValueType: CellValueType; + isMultipleCellValue?: boolean; +} + +const normalizeFilterOperator = ( + operator: string, + isSymbol: boolean | undefined, + fieldMeta: IFilterValidationFieldMeta +): IOperator | undefined => { + if (!isSymbol) { + return operator as IOperator; + } + + const operatorMapping = getFilterOperatorMapping(fieldMeta); + return (Object.entries(operatorMapping).find(([, symbol]) => symbol === operator)?.[0] ?? + undefined) as IOperator | undefined; +}; + +const analyzeFilterItemValidationIssues = ( + filterItem: IFilterItem, + path: number[], + fieldMetaMap: Record +): IFilterValidationError[] => { + const { fieldId, operator, value, isSymbol } = filterItem; + const fieldMeta = fieldMetaMap[fieldId]; + if (!fieldMeta) { + return [ + { + code: 'FIELD_NOT_FOUND', + path, + fieldId, + operator, + message: `The field '${fieldId}' was not found and this filter condition will be ignored.`, + }, + ]; + } + + const normalizedOperator = normalizeFilterOperator(operator, isSymbol, fieldMeta); + const validFilterOperators = getValidFilterOperators(fieldMeta); + if (!normalizedOperator || !validFilterOperators.includes(normalizedOperator)) { + return [ + { + code: 'OPERATOR_NOT_ALLOWED', + path, + fieldId, + operator, + message: `The '${operator}' operation provided for '${fieldId}' is invalid. Allowed operators: [${validFilterOperators.join(',')}].`, + }, + ]; + } + + const validFilterSubOperators = getValidFilterSubOperators( + fieldMeta.type, + normalizedOperator as IDateTimeFieldOperator + ); + // Operator without sub-operators (isEmpty / isNotEmpty / ...) has no mode to check. + if (!validFilterSubOperators) return []; + + // null/undefined is treated as "in-progress" — backend drops these silently. + if (value == null) return []; + + // Date operators support comparing against another field directly. + if (isFieldReferenceValue(value)) return []; + + const operatorName = normalizedOperator === isWithIn.value ? 'isWithIn' : normalizedOperator; + // Shape mismatch: operator expects { mode, ... } but value is a primitive/array. + if (typeof value !== 'object' || Array.isArray(value) || !('mode' in (value as object))) { + return [ + { + code: 'VALUE_SHAPE_INVALID', + path, + fieldId, + operator: normalizedOperator, + message: `The '${operatorName}' operation requires an object value with a 'mode' field. Valid modes: [${validFilterSubOperators.join(',')}]. Example: { mode: "${validFilterSubOperators[0]}", timeZone: "UTC" }`, + }, + ]; + } + + const mode = String((value as { mode: unknown }).mode); + if (!validFilterSubOperators.includes(mode as never)) { + return [ + { + code: 'MODE_NOT_ALLOWED', + path, + fieldId, + operator: normalizedOperator, + mode, + message: `The '${operatorName}' operation with mode '${mode}' is invalid. Allowed modes: [${validFilterSubOperators.join(',')}].`, + }, + ]; + } + + return []; +}; + +export const analyzeFilterValidationIssues = ( filter: IFilter | null | undefined, - fieldTypeMap: Record + fieldMetaMap: Record ): IFilterValidationError[] => { if (!filter) return []; const errors: IFilterValidationError[] = []; - const traverse = (filterItem: IFilter | IFilterItem) => { + const traverse = (filterItem: IFilter | IFilterItem, path: number[]) => { if (filterItem && 'fieldId' in filterItem) { - const { fieldId, operator, value } = filterItem; - const fieldType = fieldTypeMap[fieldId]; - - // Only validate date fields with date filter value - if (fieldType === FieldType.Date && value && typeof value === 'object' && 'mode' in value) { - const dateValue = value as { mode: string }; - const validSubOperators = getValidFilterSubOperators( - fieldType, - operator as IDateTimeFieldOperator - ); - - if (validSubOperators && !validSubOperators.includes(dateValue.mode as never)) { - const operatorName = operator === isWithIn.value ? 'isWithIn' : operator; - errors.push({ - fieldId, - operator: operator as string, - mode: dateValue.mode, - message: `The '${operatorName}' operation with mode '${dateValue.mode}' is invalid. Allowed modes: [${validSubOperators.join(',')}]`, - }); - } - } - } else if (filterItem && 'filterSet' in filterItem) { - filterItem.filterSet.forEach((item) => traverse(item)); + errors.push(...analyzeFilterItemValidationIssues(filterItem, path, fieldMetaMap)); + return; + } + + if (filterItem && 'filterSet' in filterItem) { + filterItem.filterSet.forEach((item, index) => traverse(item, [...path, index])); } }; - traverse(filter); + traverse(filter, []); return errors; }; diff --git a/packages/core/src/models/view/filter/operator.ts b/packages/core/src/models/view/filter/operator.ts index 926309c7b8..8c966d3961 100644 --- a/packages/core/src/models/view/filter/operator.ts +++ b/packages/core/src/models/view/filter/operator.ts @@ -2,7 +2,6 @@ import { pick, pullAll, uniq } from 'lodash'; import { z } from 'zod'; import { CellValueType, FieldType } from '../../field/constant'; -import type { FieldCore } from '../../field/field'; export const is = z.literal('is'); export const isNot = z.literal('isNot'); @@ -371,7 +370,11 @@ export const dateTimeFieldValidSubOperatorsByIsWithin = [ nextNumberOfDays.value, ]; -export function getFilterOperatorMapping(field: FieldCore) { +export function getFilterOperatorMapping(field: { + cellValueType: CellValueType; + type: FieldType; + isMultipleCellValue?: boolean; +}) { const validFilterOperators = getValidFilterOperators(field); return pick(mappingOperatorSymbol, validFilterOperators); diff --git a/packages/db-main-prisma/prisma/postgres/migrations/20260424000000_add_base_v2_enabled/migration.sql b/packages/db-main-prisma/prisma/postgres/migrations/20260424000000_add_base_v2_enabled/migration.sql new file mode 100644 index 0000000000..f5c1aa62f5 --- /dev/null +++ b/packages/db-main-prisma/prisma/postgres/migrations/20260424000000_add_base_v2_enabled/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "base" ADD COLUMN "v2_enabled" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/db-main-prisma/prisma/postgres/schema.prisma b/packages/db-main-prisma/prisma/postgres/schema.prisma index 7434c5def6..98a1038267 100644 --- a/packages/db-main-prisma/prisma/postgres/schema.prisma +++ b/packages/db-main-prisma/prisma/postgres/schema.prisma @@ -44,6 +44,7 @@ model Base { order Float icon String? schemaPass String? @map("schema_pass") + v2Enabled Boolean @default(false) @map("v2_enabled") deletedTime DateTime? @map("deleted_time") createdTime DateTime @default(now()) @map("created_time") createdBy String @map("created_by") diff --git a/packages/db-main-prisma/prisma/template.prisma b/packages/db-main-prisma/prisma/template.prisma index 090167512b..d51df76b14 100644 --- a/packages/db-main-prisma/prisma/template.prisma +++ b/packages/db-main-prisma/prisma/template.prisma @@ -44,6 +44,7 @@ model Base { order Float icon String? schemaPass String? @map("schema_pass") + v2Enabled Boolean @default(false) @map("v2_enabled") deletedTime DateTime? @map("deleted_time") createdTime DateTime @default(now()) @map("created_time") createdBy String @map("created_by") diff --git a/packages/icons/src/components/CircleDollarSign.tsx b/packages/icons/src/components/CircleDollarSign.tsx new file mode 100644 index 0000000000..259a358055 --- /dev/null +++ b/packages/icons/src/components/CircleDollarSign.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const CircleDollarSign = (props: SVGProps) => ( + + + +); +export default CircleDollarSign; diff --git a/packages/icons/src/components/IDCard.tsx b/packages/icons/src/components/IDCard.tsx new file mode 100644 index 0000000000..5d3b3afff0 --- /dev/null +++ b/packages/icons/src/components/IDCard.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const IDCard = (props: SVGProps) => ( + + + + + + +); +export default IDCard; diff --git a/packages/icons/src/components/Info.tsx b/packages/icons/src/components/Info.tsx index 58e625e969..c51f121241 100644 --- a/packages/icons/src/components/Info.tsx +++ b/packages/icons/src/components/Info.tsx @@ -10,7 +10,17 @@ const Info = (props: SVGProps) => ( {...props} > + + diff --git a/packages/icons/src/components/Prodia.tsx b/packages/icons/src/components/Prodia.tsx new file mode 100644 index 0000000000..879f92218a --- /dev/null +++ b/packages/icons/src/components/Prodia.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const Prodia = (props: SVGProps) => ( + + + +); +export default Prodia; diff --git a/packages/icons/src/components/Recraft.tsx b/packages/icons/src/components/Recraft.tsx new file mode 100644 index 0000000000..7e8b95d5d3 --- /dev/null +++ b/packages/icons/src/components/Recraft.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const Recraft = (props: SVGProps) => ( + + + + + + + + + + + + +); +export default Recraft; diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index e83f5cb73d..d07ec3f4ff 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -112,8 +112,10 @@ export { default as GoogleLogo } from './components/GoogleLogo'; export { default as Hash } from './components/Hash'; export { default as Heart } from './components/Heart'; export { default as HelpCircle } from './components/HelpCircle'; +export { default as CircleDollarSign } from './components/CircleDollarSign'; export { default as History } from './components/History'; export { default as Home } from './components/Home'; +export { default as IDCard } from './components/IDCard'; export { default as Info } from './components/Info'; export { default as Image } from './components/Image'; export { default as ImageGeneration } from './components/ImageGeneration'; @@ -241,6 +243,8 @@ export { default as Meituan } from './components/Meituan'; export { default as Kwaipilot } from './components/Kwaipilot'; export { default as ArceeAi } from './components/ArceeAi'; export { default as PrimeIntellect } from './components/PrimeIntellect'; +export { default as Prodia } from './components/Prodia'; +export { default as Recraft } from './components/Recraft'; export { default as Morph } from './components/Morph'; export { default as Inception } from './components/Inception'; export { default as Stealth } from './components/Stealth'; diff --git a/packages/openapi/src/access-token/create.ts b/packages/openapi/src/access-token/create.ts index 72e172811d..0f05ab8f67 100644 --- a/packages/openapi/src/access-token/create.ts +++ b/packages/openapi/src/access-token/create.ts @@ -1,3 +1,4 @@ +import { allActions } from '@teable/core'; import { axios } from '../axios'; import { registerRoute } from '../utils'; import { z } from '../zod'; @@ -12,7 +13,7 @@ const isValidDateString = (dateString: string) => { export const createAccessTokenRoSchema = z.object({ name: z.string().min(1), description: z.string().optional(), - scopes: z.array(z.string()).min(1), + scopes: z.array(z.enum(allActions)).min(1), spaceIds: z.array(z.string()).min(1).nullable().optional(), baseIds: z.array(z.string()).min(1).nullable().optional(), hasFullAccess: z.boolean().optional(), @@ -24,7 +25,11 @@ export const createAccessTokenRoSchema = z.object({ .meta({ type: 'string', example: '2024-03-25' }), }); -export type CreateAccessTokenRo = z.infer; +type CreateAccessTokenRoSchema = z.infer; + +export type CreateAccessTokenRo = Omit & { + scopes: string[]; +}; export const createAccessTokenVoSchema = z.object({ id: z.string(), diff --git a/packages/openapi/src/access-token/update.ts b/packages/openapi/src/access-token/update.ts index 4083695734..afb33badde 100644 --- a/packages/openapi/src/access-token/update.ts +++ b/packages/openapi/src/access-token/update.ts @@ -1,3 +1,4 @@ +import { allActions } from '@teable/core'; import { axios } from '../axios'; import { registerRoute, urlBuilder } from '../utils'; import { z } from '../zod'; @@ -7,13 +8,17 @@ export const UPDATE_ACCESS_TOKEN = '/access-token/{id}'; export const updateAccessTokenRoSchema = z.object({ name: z.string(), description: z.string().optional(), - scopes: z.array(z.string()), + scopes: z.array(z.enum(allActions)).min(1), spaceIds: z.array(z.string()).nullable().optional(), baseIds: z.array(z.string()).nullable().optional(), hasFullAccess: z.boolean().optional(), }); -export type UpdateAccessTokenRo = z.infer; +type UpdateAccessTokenRoSchema = z.infer; + +export type UpdateAccessTokenRo = Omit & { + scopes: string[]; +}; export const updateAccessTokenVoSchema = z.object({ id: z.string(), diff --git a/packages/openapi/src/admin/setting/batch-test-llm.ts b/packages/openapi/src/admin/setting/batch-test-llm.ts index 705a766bab..57d5d83943 100644 --- a/packages/openapi/src/admin/setting/batch-test-llm.ts +++ b/packages/openapi/src/admin/setting/batch-test-llm.ts @@ -2,7 +2,8 @@ import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; import { z } from 'zod'; import { axios } from '../../axios'; import { registerRoute } from '../../utils'; -import { llmProviderSchema, chatModelAbilitySchema, LLMProviderType } from './update'; +import { chatModelAbilitySchema } from './model-ability'; +import { LLMProviderType, llmProviderSchema } from './update'; /** * Request schema for batch testing all LLM models diff --git a/packages/openapi/src/admin/setting/gateway-model.ts b/packages/openapi/src/admin/setting/gateway-model.ts new file mode 100644 index 0000000000..757ee39105 --- /dev/null +++ b/packages/openapi/src/admin/setting/gateway-model.ts @@ -0,0 +1,166 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { z } from 'zod'; +import { modelAbilitySchema } from './model-ability'; +import { + legacyRatesSchema, + normalizeGatewayPricing, + pricingSchema, + rawPricingSchema, +} from './pricing'; + +// Gateway model type from API (language, embedding, image) +export const GatewayModelTypeValues = ['language', 'embedding', 'image'] as const; +export type GatewayModelType = (typeof GatewayModelTypeValues)[number]; +export const gatewayModelTypeSchema = z.enum(GatewayModelTypeValues); + +// Known gateway model capability tags from API. +// Tags are external API metadata, so validation accepts any non-empty string for forward compatibility. +export const GatewayModelTagValues = [ + 'reasoning', + 'tool-use', + 'vision', + 'file-input', + 'image-generation', + 'implicit-caching', + 'explicit-caching', + 'web-search', +] as const; +export type KnownGatewayModelTag = (typeof GatewayModelTagValues)[number]; +export type GatewayModelTag = string; +export const gatewayModelTagSchema = z.string().min(1); + +// Gateway model provider (owned_by) from API +export const GatewayModelProviderValues = [ + 'alibaba', + 'amazon', + 'anthropic', + 'arcee-ai', + 'bfl', + 'bytedance', + 'cohere', + 'deepseek', + 'google', + 'inception', + 'kwaipilot', + 'meituan', + 'meta', + 'minimax', + 'mistral', + 'moonshotai', + 'morph', + 'nvidia', + 'openai', + 'perplexity', + 'prime-intellect', + 'prodia', + 'recraft', + 'stealth', + 'vercel', + 'voyage', + 'xai', + 'xiaomi', + 'zai', +] as const; +export type GatewayModelProvider = (typeof GatewayModelProviderValues)[number]; +export const gatewayModelProviderSchema = z.enum(GatewayModelProviderValues); + +// Gateway model default assignment targets +export enum GatewayModelDefaultFor { + CHAT_LG = 'chatLg', + CHAT_MD = 'chatMd', + CHAT_SM = 'chatSm', + AI_FIELD_TEXT = 'aiFieldText', + AI_FIELD_IMAGE = 'aiFieldImage', +} + +// Individual gateway model configuration (admin-maintained) +export const gatewayModelSchema = z.object({ + // modelId used directly with AI Gateway (e.g., "anthropic/claude-sonnet-4") + id: z.string(), + // Display label (e.g., "Claude Sonnet 4") + label: z.string(), + // Whether this model is visible to end users + enabled: z.boolean().default(true), + // Model capabilities (for UI tags and validation) + capabilities: modelAbilitySchema.optional(), + // Pricing in USD (new unified format) + pricing: pricingSchema.optional(), + // @deprecated Legacy rates in credits per 1M tokens - use pricing instead + rates: legacyRatesSchema.optional(), + // Mark as image generation model + isImageModel: z.boolean().optional(), + // Default assignment (which use cases this model is default for) + defaultFor: z.array(z.nativeEnum(GatewayModelDefaultFor)).optional(), + // Last test timestamp + testedAt: z.number().optional(), + // === Metadata from AI Gateway API === + // Provider that owns this model (e.g., "anthropic", "google", "openai") + ownedBy: gatewayModelProviderSchema.optional(), + // Model type from API (e.g., "language", "image") + modelType: gatewayModelTypeSchema.optional(), + // Capability tags from API (e.g., ["image-generation", "vision", "tool-use"]) + tags: z.array(gatewayModelTagSchema).optional(), + // Context window size (input tokens) + contextWindow: z.number().optional(), + // Maximum output tokens + maxTokens: z.number().optional(), + // Model description + description: z.string().optional(), + // Admin-curated i18n description (e.g., "Most capable for ambitious work") + i18nDescription: z + .object({ + en: z.string().optional(), + zh: z.string().optional(), + }) + .optional(), +}); + +export type IGatewayModel = z.infer; + +// Raw API response structure from Vercel AI Gateway (snake_case as returned by API) +export const gatewayApiModelRawSchema = z.object({ + id: z.string(), + name: z.string().optional(), + description: z.string().optional(), + type: gatewayModelTypeSchema.optional(), + tags: z.array(gatewayModelTagSchema).optional(), + context_window: z.number().optional(), + max_tokens: z.number().optional(), + created: z.number().optional(), + owned_by: gatewayModelProviderSchema.optional(), + pricing: rawPricingSchema.optional(), +}); + +export type IGatewayApiModelRaw = z.infer; + +// Gateway API model structure (camelCase, converted from API snake_case) +export const gatewayApiModelSchema = z.object({ + id: z.string(), + name: z.string().optional(), + description: z.string().optional(), + type: gatewayModelTypeSchema.optional(), + tags: z.array(gatewayModelTagSchema).optional(), + contextWindow: z.number().optional(), + maxTokens: z.number().optional(), + created: z.number().optional(), + ownedBy: gatewayModelProviderSchema.optional(), + pricing: pricingSchema.optional(), +}); + +export type IGatewayApiModel = z.infer; + +// Helper function to convert raw API response to camelCase +export function convertGatewayApiModel(raw: IGatewayApiModelRaw): IGatewayApiModel { + return { + id: raw.id, + name: raw.name, + description: raw.description, + type: raw.type, + tags: raw.tags, + contextWindow: raw.context_window, + maxTokens: raw.max_tokens, + created: raw.created, + ownedBy: raw.owned_by, + pricing: normalizeGatewayPricing(raw.pricing), + }; +} diff --git a/packages/openapi/src/admin/setting/get-public.ts b/packages/openapi/src/admin/setting/get-public.ts index d29232abb3..00e4fd8659 100644 --- a/packages/openapi/src/admin/setting/get-public.ts +++ b/packages/openapi/src/admin/setting/get-public.ts @@ -2,8 +2,9 @@ import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; import { z } from 'zod'; import { axios } from '../../axios'; import { registerRoute } from '../../utils'; +import { gatewayModelSchema } from './gateway-model'; import { settingVoSchema } from './get'; -import { chatModelSchema, gatewayModelSchema, llmProviderSchema } from './update'; +import { chatModelSchema, llmProviderSchema } from './update'; export const simpleLLMProviderSchema = llmProviderSchema.pick({ type: true, diff --git a/packages/openapi/src/admin/setting/index.ts b/packages/openapi/src/admin/setting/index.ts index 437970c4ea..44ec6f6833 100644 --- a/packages/openapi/src/admin/setting/index.ts +++ b/packages/openapi/src/admin/setting/index.ts @@ -1,4 +1,7 @@ export * from './key.enum'; +export * from './gateway-model'; +export * from './model-ability'; +export * from './pricing'; export * from './get'; export * from './get-public'; export * from './update'; diff --git a/packages/openapi/src/admin/setting/model-ability.spec.ts b/packages/openapi/src/admin/setting/model-ability.spec.ts new file mode 100644 index 0000000000..de759d389e --- /dev/null +++ b/packages/openapi/src/admin/setting/model-ability.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { getImageModelTagsFromAbility } from './model-ability'; + +const IMAGE_GENERATION_TAG = 'image-generation'; +const VISION_TAG = 'vision'; + +describe('getImageModelTagsFromAbility', () => { + it('derives persisted image capability tags from image test results', () => { + expect( + getImageModelTagsFromAbility( + { + generation: true, + imageToImage: true, + }, + ['tool-use'] + ) + ).toEqual(['tool-use', IMAGE_GENERATION_TAG, VISION_TAG]); + }); + + it('removes stale image-to-image tags when the latest result does not support it', () => { + expect( + getImageModelTagsFromAbility( + { + generation: true, + imageToImage: false, + }, + [IMAGE_GENERATION_TAG, VISION_TAG] + ) + ).toEqual([IMAGE_GENERATION_TAG]); + }); +}); diff --git a/packages/openapi/src/admin/setting/model-ability.ts b/packages/openapi/src/admin/setting/model-ability.ts new file mode 100644 index 0000000000..687079639f --- /dev/null +++ b/packages/openapi/src/admin/setting/model-ability.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; +import type { GatewayModelTag } from './gateway-model'; + +// Detailed ability support with URL and base64 variants +export const abilityDetailSchema = z.object({ + url: z.boolean().optional(), + base64: z.boolean().optional(), +}); + +export type IAbilityDetail = z.infer; + +// Model ability schema for test results +export const modelAbilitySchema = z.object({ + image: z.union([z.boolean(), abilityDetailSchema]).optional(), // vision/image input + pdf: z.union([z.boolean(), abilityDetailSchema]).optional(), // PDF/file input + webSearch: z.boolean().optional(), + toolCall: z.boolean().optional(), // tool/function calling + reasoning: z.boolean().optional(), // extended thinking/reasoning + imageGeneration: z.boolean().optional(), // can generate images +}); + +export type IModelAbility = z.infer; + +// Image model ability schema +export const imageModelAbilitySchema = z.object({ + generation: z.boolean().optional(), // can generate images from text + imageToImage: z.boolean().optional(), // can generate images from image input +}); + +export type IImageModelAbility = z.infer; + +const IMAGE_GENERATION_TAG: GatewayModelTag = 'image-generation'; +const VISION_TAG: GatewayModelTag = 'vision'; +const IMAGE_ABILITY_TAGS = new Set([IMAGE_GENERATION_TAG, VISION_TAG]); + +export const getImageModelTagsFromAbility = ( + imageAbility: IImageModelAbility | undefined, + currentTags: readonly GatewayModelTag[] | undefined +): GatewayModelTag[] | undefined => { + if (!imageAbility) return currentTags ? [...currentTags] : undefined; + + const nextTags = (currentTags ?? []).filter((tag) => !IMAGE_ABILITY_TAGS.has(tag)); + if (imageAbility.generation) { + nextTags.push(IMAGE_GENERATION_TAG); + } + if (imageAbility.imageToImage) { + nextTags.push(VISION_TAG); + } + + return nextTags.length ? nextTags : undefined; +}; + +// chatModelAbilitySchema is same as modelAbilitySchema, for backward compatibility +export const chatModelAbilitySchema = modelAbilitySchema; + +export const chatModelAbilityType = chatModelAbilitySchema.keyof(); + +export type IChatModelAbilityType = z.infer; + +export type IChatModelAbility = z.infer; diff --git a/packages/openapi/src/admin/setting/pricing.spec.ts b/packages/openapi/src/admin/setting/pricing.spec.ts index 73accda60a..7168e885e7 100644 --- a/packages/openapi/src/admin/setting/pricing.spec.ts +++ b/packages/openapi/src/admin/setting/pricing.spec.ts @@ -1,13 +1,13 @@ /* eslint-disable sonarjs/no-duplicate-string */ import { describe, it, expect } from 'vitest'; -import type { IModelPricing, IPricingTier } from './update'; +import type { IModelPricing, IPricingTier } from './pricing'; import { calculateTieredCost, pricingToCredits, pricingToCreditsFromUsage, normalizeGatewayPricing, USD_PER_CREDIT, -} from './update'; +} from './pricing'; describe('pricing', () => { describe('USD_PER_CREDIT', () => { diff --git a/packages/openapi/src/admin/setting/pricing.ts b/packages/openapi/src/admin/setting/pricing.ts new file mode 100644 index 0000000000..14a2ad8d7a --- /dev/null +++ b/packages/openapi/src/admin/setting/pricing.ts @@ -0,0 +1,255 @@ +import { z } from 'zod'; + +// Tiered pricing tier - for volume-based pricing where cost changes at token thresholds +export const pricingTierSchema = z.object({ + cost: z.string(), // USD per token at this tier + min: z.number(), // Tier start (inclusive) + max: z.number().optional(), // Tier end (absent = open-ended last tier) +}); + +export type IPricingTier = z.infer; + +// Unified pricing schema - USD per token (string format, same as Vercel AI Gateway API) +// 100 credits = $1 USD. Credits = totalUSD / USD_PER_CREDIT +export const pricingSchema = z.object({ + // Flat rates (USD per token/unit as string) + input: z.string().optional(), + output: z.string().optional(), + inputCacheRead: z.string().optional(), + inputCacheWrite: z.string().optional(), + reasoning: z.string().optional(), + image: z.string().optional(), + webSearch: z.string().optional(), + + // Tiered pricing (overrides flat rate when present) + inputTiers: z.array(pricingTierSchema).optional(), + outputTiers: z.array(pricingTierSchema).optional(), + inputCacheReadTiers: z.array(pricingTierSchema).optional(), + inputCacheWriteTiers: z.array(pricingTierSchema).optional(), +}); + +export type IModelPricing = z.infer; + +// Legacy rates schema (credits per 1M tokens) - for backward compatibility +// Will be converted to new pricing format when reading +export const legacyRatesSchema = z.object({ + inputRate: z.number().min(0).optional(), + outputRate: z.number().min(0).optional(), + cacheReadRate: z.number().min(0).optional(), + cacheWriteRate: z.number().min(0).optional(), + reasoningRate: z.number().min(0).optional(), + imageRate: z.number().min(0).optional(), + webSearchRate: z.number().min(0).optional(), +}); + +export type ILegacyRates = z.infer; + +// Conversion constants +// 1 credit = $0.01 USD (100 credits = $1) +export const USD_PER_CREDIT = 0.01; +// Legacy rates were in credits per 1M tokens +export const TOKENS_PER_RATE_UNIT = 1_000_000; + +/** + * Calculate cost for tokens using tiered (progressive) pricing. + * Each tier covers a range [min, max). The last tier has no max (open-ended). + */ +export function calculateTieredCost(tokenCount: number, tiers: IPricingTier[]): number { + let totalCost = 0; + for (const tier of tiers) { + if (tokenCount <= tier.min) break; + const tierMax = tier.max ?? Infinity; + const tokensInTier = Math.min(tokenCount, tierMax) - tier.min; + totalCost += tokensInTier * parseFloat(tier.cost); + } + return totalCost; +} + +/** + * Calculate USD cost for a single pricing category. + * Uses tiered pricing if available, otherwise falls back to flat rate. + */ +function categoryUsd( + tokenCount: number | undefined, + flatRate: string | undefined, + tiers: IPricingTier[] | undefined +): number { + if (!tokenCount) return 0; + if (tiers?.length) return calculateTieredCost(tokenCount, tiers); + if (flatRate) return parseFloat(flatRate) * tokenCount; + return 0; +} + +// Convert pricing (USD/token) to credits for billing +// 100 credits = $1 USD +export function pricingToCredits( + pricing: IModelPricing | undefined, + usage: { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + reasoningTokens?: number; + images?: number; + webSearches?: number; + } +): number { + if (!pricing) return 0; + + let totalUsd = 0; + + totalUsd += categoryUsd(usage.inputTokens, pricing.input, pricing.inputTiers); + totalUsd += categoryUsd(usage.outputTokens, pricing.output, pricing.outputTiers); + totalUsd += categoryUsd( + usage.cacheReadTokens, + pricing.inputCacheRead, + pricing.inputCacheReadTiers + ); + totalUsd += categoryUsd( + usage.cacheWriteTokens, + pricing.inputCacheWrite, + pricing.inputCacheWriteTiers + ); + totalUsd += categoryUsd(usage.reasoningTokens, pricing.reasoning, undefined); + + if (pricing.image && usage.images) { + totalUsd += parseFloat(pricing.image) * usage.images; + } + if (pricing.webSearch && usage.webSearches) { + // pricing.webSearch is USD per 1,000 searches + totalUsd += (parseFloat(pricing.webSearch) * usage.webSearches) / 1000; + } + + return totalUsd / USD_PER_CREDIT; +} + +/** + * AI SDK LanguageModelUsage compatible interface + * This is a subset of the AI SDK's LanguageModelUsage type + */ +export interface IAIModelUsage { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + reasoningTokens?: number; + cachedInputTokens?: number; + inputTokenDetails?: { + cacheReadTokens?: number; + cacheWriteTokens?: number; + noCacheTokens?: number; + }; + outputTokenDetails?: { + reasoningTokens?: number; + textTokens?: number; + }; +} + +/** + * Calculate credits from AI SDK LanguageModelUsage + * Supports detailed token breakdown (cached tokens, reasoning tokens, etc.) + * Round up to 2 decimal places + */ +export function pricingToCreditsFromUsage( + pricing: IModelPricing | undefined, + usage: IAIModelUsage +): number { + if (!pricing) return 0; + + // Extract detailed token info + const inputDetails = usage.inputTokenDetails || {}; + const outputDetails = usage.outputTokenDetails || {}; + + // Calculate INPUT token counts (avoid double counting) + // inputTokens = noCacheTokens + cacheReadTokens + const totalInputTokens = usage.inputTokens ?? 0; + const cacheReadTokens = inputDetails.cacheReadTokens ?? usage.cachedInputTokens ?? 0; + const cacheWriteTokens = inputDetails.cacheWriteTokens ?? 0; + const noCacheTokens = + inputDetails.noCacheTokens ?? Math.max(0, totalInputTokens - cacheReadTokens); + + // Calculate OUTPUT token counts (avoid double counting) + // outputTokens = textTokens + reasoningTokens + const totalOutputTokens = usage.outputTokens ?? 0; + const reasoningTokens = outputDetails.reasoningTokens ?? usage.reasoningTokens ?? 0; + const textOutputTokens = + outputDetails.textTokens ?? Math.max(0, totalOutputTokens - reasoningTokens); + + const credits = pricingToCredits(pricing, { + inputTokens: noCacheTokens, + outputTokens: textOutputTokens, + cacheReadTokens, + cacheWriteTokens, + reasoningTokens, + }); + + // Round up to 2 decimal places + return Math.ceil(credits * 100) / 100; +} + +/* eslint-disable @typescript-eslint/naming-convention */ +// Raw pricing schema matching Vercel AI Gateway API response (snake_case) +// @see https://ai-gateway.vercel.sh/v1/models +export const rawPricingSchema = z.object({ + // Flat rates (USD per token/unit as string) + input: z.string().optional(), + output: z.string().optional(), + input_cache_read: z.string().optional(), + input_cache_write: z.string().optional(), + reasoning: z.string().optional(), + image: z.string().optional(), + web_search: z.string().optional(), + + // Tiered pricing (overrides flat rate when present) + input_tiers: z.array(pricingTierSchema).optional(), + output_tiers: z.array(pricingTierSchema).optional(), + input_cache_read_tiers: z.array(pricingTierSchema).optional(), + input_cache_write_tiers: z.array(pricingTierSchema).optional(), +}); +/* eslint-enable @typescript-eslint/naming-convention */ + +export type IRawPricing = z.infer; + +// Field mappings: [snakeCase, camelCase] pairs for pricing normalization +const PRICING_STRING_FIELDS: [string, keyof IModelPricing][] = [ + ['input', 'input'], + ['output', 'output'], + ['reasoning', 'reasoning'], + ['image', 'image'], + ['input_cache_read', 'inputCacheRead'], + ['input_cache_write', 'inputCacheWrite'], + ['web_search', 'webSearch'], +]; + +const PRICING_TIER_FIELDS: [string, keyof IModelPricing][] = [ + ['input_tiers', 'inputTiers'], + ['output_tiers', 'outputTiers'], + ['input_cache_read_tiers', 'inputCacheReadTiers'], + ['input_cache_write_tiers', 'inputCacheWriteTiers'], +]; + +/** + * Normalize a pricing object from any source (gateway API snake_case or admin config camelCase) + * into our canonical camelCase IModelPricing format. + */ +export function normalizeGatewayPricing( + raw: IRawPricing | IModelPricing | Record | undefined +): IModelPricing | undefined { + if (!raw || Object.keys(raw).length === 0) return undefined; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const r = raw as Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pricing: Record = {}; + + for (const [snake, camel] of PRICING_STRING_FIELDS) { + const val = r[snake] ?? (snake !== camel ? r[camel] : undefined); + if (val != null) pricing[camel] = String(val); + } + + for (const [snake, camel] of PRICING_TIER_FIELDS) { + const val = r[snake] ?? (snake !== camel ? r[camel] : undefined); + if (Array.isArray(val)) pricing[camel] = val; + } + + return Object.keys(pricing).length > 0 ? (pricing as IModelPricing) : undefined; +} diff --git a/packages/openapi/src/admin/setting/test-llm.ts b/packages/openapi/src/admin/setting/test-llm.ts index ab7068f4c0..4849f6e9af 100644 --- a/packages/openapi/src/admin/setting/test-llm.ts +++ b/packages/openapi/src/admin/setting/test-llm.ts @@ -2,7 +2,8 @@ import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; import { z } from 'zod'; import { axios } from '../../axios'; import { registerRoute } from '../../utils'; -import { chatModelAbilitySchema, chatModelAbilityType, llmProviderSchema } from './update'; +import { chatModelAbilitySchema, chatModelAbilityType } from './model-ability'; +import { llmProviderSchema } from './update'; export const testLLMRoSchema = llmProviderSchema .omit({ diff --git a/packages/openapi/src/admin/setting/update.spec.ts b/packages/openapi/src/admin/setting/update.spec.ts new file mode 100644 index 0000000000..04920e086c --- /dev/null +++ b/packages/openapi/src/admin/setting/update.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { gatewayApiModelRawSchema, getImageModelTagsFromAbility } from './index'; + +const IMAGE_GENERATION_TAG = 'image-generation'; + +describe('setting index exports', () => { + it('re-exports model ability helpers from the setting barrel', () => { + expect( + getImageModelTagsFromAbility( + { + generation: true, + imageToImage: true, + }, + undefined + ) + ).toEqual([IMAGE_GENERATION_TAG, 'vision']); + }); + + it('accepts current AI Gateway image model providers', () => { + expect( + gatewayApiModelRawSchema.parse({ + id: 'prodia/flux-fast-schnell', + type: 'image', + owned_by: 'prodia', + tags: [IMAGE_GENERATION_TAG], + }).owned_by + ).toBe('prodia'); + expect( + gatewayApiModelRawSchema.parse({ + id: 'recraft/recraft-v4-pro', + type: 'image', + owned_by: 'recraft', + tags: [IMAGE_GENERATION_TAG], + }).owned_by + ).toBe('recraft'); + }); +}); diff --git a/packages/openapi/src/admin/setting/update.ts b/packages/openapi/src/admin/setting/update.ts index 62e403d212..c53cb9eaad 100644 --- a/packages/openapi/src/admin/setting/update.ts +++ b/packages/openapi/src/admin/setting/update.ts @@ -3,6 +3,18 @@ import { z } from 'zod'; import { axios } from '../../axios'; import { mailTransportConfigSchema } from '../../mail'; import { registerRoute } from '../../utils'; +import { + gatewayModelProviderSchema, + gatewayModelSchema, + gatewayModelTagSchema, + gatewayModelTypeSchema, +} from './gateway-model'; +import { + chatModelAbilitySchema, + imageModelAbilitySchema, + modelAbilitySchema, +} from './model-ability'; +import { pricingSchema } from './pricing'; export enum LLMProviderType { OPENAI = 'openai', @@ -27,270 +39,6 @@ export enum LLMProviderType { CLAUDE_CODE = 'claudeCode', } -// Gateway model type from API (language, embedding, image) -export const GatewayModelTypeValues = ['language', 'embedding', 'image'] as const; -export type GatewayModelType = (typeof GatewayModelTypeValues)[number]; -export const gatewayModelTypeSchema = z.enum(GatewayModelTypeValues); - -// Gateway model capability tags from API -export const GatewayModelTagValues = [ - 'reasoning', - 'tool-use', - 'vision', - 'file-input', - 'image-generation', - 'implicit-caching', -] as const; -export type GatewayModelTag = (typeof GatewayModelTagValues)[number]; -export const gatewayModelTagSchema = z.enum(GatewayModelTagValues); - -// Gateway model provider (owned_by) from API -export const GatewayModelProviderValues = [ - 'alibaba', - 'amazon', - 'anthropic', - 'arcee-ai', - 'bfl', - 'bytedance', - 'cohere', - 'deepseek', - 'google', - 'inception', - 'kwaipilot', - 'meituan', - 'meta', - 'minimax', - 'mistral', - 'moonshotai', - 'morph', - 'nvidia', - 'openai', - 'perplexity', - 'prime-intellect', - 'stealth', - 'vercel', - 'voyage', - 'xai', - 'xiaomi', - 'zai', -] as const; -export type GatewayModelProvider = (typeof GatewayModelProviderValues)[number]; -export const gatewayModelProviderSchema = z.enum(GatewayModelProviderValues); - -// Detailed ability support with URL and base64 variants -export const abilityDetailSchema = z.object({ - url: z.boolean().optional(), - base64: z.boolean().optional(), -}); - -export type IAbilityDetail = z.infer; - -// Model ability schema for test results -export const modelAbilitySchema = z.object({ - image: z.union([z.boolean(), abilityDetailSchema]).optional(), // vision/image input - pdf: z.union([z.boolean(), abilityDetailSchema]).optional(), // PDF/file input - webSearch: z.boolean().optional(), - toolCall: z.boolean().optional(), // tool/function calling - reasoning: z.boolean().optional(), // extended thinking/reasoning - imageGeneration: z.boolean().optional(), // can generate images -}); - -export type IModelAbility = z.infer; - -// Image model ability schema -export const imageModelAbilitySchema = z.object({ - generation: z.boolean().optional(), // can generate images from text - imageToImage: z.boolean().optional(), // can generate images from image input -}); - -export type IImageModelAbility = z.infer; - -// Tiered pricing tier - for volume-based pricing where cost changes at token thresholds -export const pricingTierSchema = z.object({ - cost: z.string(), // USD per token at this tier - min: z.number(), // Tier start (inclusive) - max: z.number().optional(), // Tier end (absent = open-ended last tier) -}); - -export type IPricingTier = z.infer; - -// Unified pricing schema - USD per token (string format, same as Vercel AI Gateway API) -// 100 credits = $1 USD. Credits = totalUSD / USD_PER_CREDIT -export const pricingSchema = z.object({ - // Flat rates (USD per token/unit as string) - input: z.string().optional(), - output: z.string().optional(), - inputCacheRead: z.string().optional(), - inputCacheWrite: z.string().optional(), - reasoning: z.string().optional(), - image: z.string().optional(), - webSearch: z.string().optional(), - - // Tiered pricing (overrides flat rate when present) - inputTiers: z.array(pricingTierSchema).optional(), - outputTiers: z.array(pricingTierSchema).optional(), - inputCacheReadTiers: z.array(pricingTierSchema).optional(), - inputCacheWriteTiers: z.array(pricingTierSchema).optional(), -}); - -export type IModelPricing = z.infer; - -// Legacy rates schema (credits per 1M tokens) - for backward compatibility -// Will be converted to new pricing format when reading -export const legacyRatesSchema = z.object({ - inputRate: z.number().min(0).optional(), - outputRate: z.number().min(0).optional(), - cacheReadRate: z.number().min(0).optional(), - cacheWriteRate: z.number().min(0).optional(), - reasoningRate: z.number().min(0).optional(), - imageRate: z.number().min(0).optional(), - webSearchRate: z.number().min(0).optional(), -}); - -export type ILegacyRates = z.infer; - -// Conversion constants -// 1 credit = $0.01 USD (100 credits = $1) -export const USD_PER_CREDIT = 0.01; -// Legacy rates were in credits per 1M tokens -export const TOKENS_PER_RATE_UNIT = 1_000_000; - -/** - * Calculate cost for tokens using tiered (progressive) pricing. - * Each tier covers a range [min, max). The last tier has no max (open-ended). - */ -export function calculateTieredCost(tokenCount: number, tiers: IPricingTier[]): number { - let totalCost = 0; - for (const tier of tiers) { - if (tokenCount <= tier.min) break; - const tierMax = tier.max ?? Infinity; - const tokensInTier = Math.min(tokenCount, tierMax) - tier.min; - totalCost += tokensInTier * parseFloat(tier.cost); - } - return totalCost; -} - -/** - * Calculate USD cost for a single pricing category. - * Uses tiered pricing if available, otherwise falls back to flat rate. - */ -function categoryUsd( - tokenCount: number | undefined, - flatRate: string | undefined, - tiers: IPricingTier[] | undefined -): number { - if (!tokenCount) return 0; - if (tiers?.length) return calculateTieredCost(tokenCount, tiers); - if (flatRate) return parseFloat(flatRate) * tokenCount; - return 0; -} - -// Convert pricing (USD/token) to credits for billing -// 100 credits = $1 USD -export function pricingToCredits( - pricing: IModelPricing | undefined, - usage: { - inputTokens?: number; - outputTokens?: number; - cacheReadTokens?: number; - cacheWriteTokens?: number; - reasoningTokens?: number; - images?: number; - webSearches?: number; - } -): number { - if (!pricing) return 0; - - let totalUsd = 0; - - totalUsd += categoryUsd(usage.inputTokens, pricing.input, pricing.inputTiers); - totalUsd += categoryUsd(usage.outputTokens, pricing.output, pricing.outputTiers); - totalUsd += categoryUsd( - usage.cacheReadTokens, - pricing.inputCacheRead, - pricing.inputCacheReadTiers - ); - totalUsd += categoryUsd( - usage.cacheWriteTokens, - pricing.inputCacheWrite, - pricing.inputCacheWriteTiers - ); - totalUsd += categoryUsd(usage.reasoningTokens, pricing.reasoning, undefined); - - if (pricing.image && usage.images) { - totalUsd += parseFloat(pricing.image) * usage.images; - } - if (pricing.webSearch && usage.webSearches) { - // pricing.webSearch is USD per 1,000 searches - totalUsd += (parseFloat(pricing.webSearch) * usage.webSearches) / 1000; - } - - return totalUsd / USD_PER_CREDIT; -} - -/** - * AI SDK LanguageModelUsage compatible interface - * This is a subset of the AI SDK's LanguageModelUsage type - */ -export interface IAIModelUsage { - inputTokens?: number; - outputTokens?: number; - totalTokens?: number; - reasoningTokens?: number; - cachedInputTokens?: number; - inputTokenDetails?: { - cacheReadTokens?: number; - cacheWriteTokens?: number; - noCacheTokens?: number; - }; - outputTokenDetails?: { - reasoningTokens?: number; - textTokens?: number; - }; -} - -/** - * Calculate credits from AI SDK LanguageModelUsage - * Supports detailed token breakdown (cached tokens, reasoning tokens, etc.) - * Round up to 2 decimal places - */ -export function pricingToCreditsFromUsage( - pricing: IModelPricing | undefined, - usage: IAIModelUsage -): number { - if (!pricing) return 0; - - // Extract detailed token info - const inputDetails = usage.inputTokenDetails || {}; - const outputDetails = usage.outputTokenDetails || {}; - - // Calculate INPUT token counts (avoid double counting) - // inputTokens = noCacheTokens + cacheReadTokens - const totalInputTokens = usage.inputTokens ?? 0; - const cacheReadTokens = inputDetails.cacheReadTokens ?? usage.cachedInputTokens ?? 0; - const cacheWriteTokens = inputDetails.cacheWriteTokens ?? 0; - const noCacheTokens = - inputDetails.noCacheTokens ?? Math.max(0, totalInputTokens - cacheReadTokens); - - // Calculate OUTPUT token counts (avoid double counting) - // outputTokens = textTokens + reasoningTokens - const totalOutputTokens = usage.outputTokens ?? 0; - const reasoningTokens = outputDetails.reasoningTokens ?? usage.reasoningTokens ?? 0; - const textOutputTokens = - outputDetails.textTokens ?? Math.max(0, totalOutputTokens - reasoningTokens); - - const credits = pricingToCredits(pricing, { - inputTokens: noCacheTokens, - outputTokens: textOutputTokens, - cacheReadTokens, - cacheWriteTokens, - reasoningTokens, - }); - - // Round up to 2 decimal places - return Math.ceil(credits * 100) / 100; -} - // Model-specific configuration - unified structure for all model types // Supports both new pricing (USD) and legacy rates (credits/1M) for backward compatibility export const modelConfigSchema = z.object({ @@ -342,15 +90,6 @@ export const llmProviderSchema = z.object({ export type LLMProvider = z.infer; -// chatModelAbilitySchema is same as modelAbilitySchema, for backward compatibility -export const chatModelAbilitySchema = modelAbilitySchema; - -export const chatModelAbilityType = chatModelAbilitySchema.keyof(); - -export type IChatModelAbilityType = z.infer; - -export type IChatModelAbility = z.infer; - export const chatModelSchema = z.object({ lg: z.string().optional(), md: z.string().optional(), @@ -358,179 +97,6 @@ export const chatModelSchema = z.object({ ability: chatModelAbilitySchema.optional(), }); -// Gateway model default assignment targets -export enum GatewayModelDefaultFor { - CHAT_LG = 'chatLg', - CHAT_MD = 'chatMd', - CHAT_SM = 'chatSm', - AI_FIELD_TEXT = 'aiFieldText', - AI_FIELD_IMAGE = 'aiFieldImage', -} - -// Individual gateway model configuration (admin-maintained) -export const gatewayModelSchema = z.object({ - // modelId used directly with AI Gateway (e.g., "anthropic/claude-sonnet-4") - id: z.string(), - // Display label (e.g., "Claude Sonnet 4") - label: z.string(), - // Whether this model is visible to end users - enabled: z.boolean().default(true), - // Model capabilities (for UI tags and validation) - capabilities: modelAbilitySchema.optional(), - // Pricing in USD (new unified format) - pricing: pricingSchema.optional(), - // @deprecated Legacy rates in credits per 1M tokens - use pricing instead - rates: legacyRatesSchema.optional(), - // Mark as image generation model - isImageModel: z.boolean().optional(), - // Default assignment (which use cases this model is default for) - defaultFor: z.array(z.nativeEnum(GatewayModelDefaultFor)).optional(), - // Last test timestamp - testedAt: z.number().optional(), - // === Metadata from AI Gateway API === - // Provider that owns this model (e.g., "anthropic", "google", "openai") - ownedBy: gatewayModelProviderSchema.optional(), - // Model type from API (e.g., "language", "image") - modelType: gatewayModelTypeSchema.optional(), - // Capability tags from API (e.g., ["image-generation", "vision", "tool-use"]) - tags: z.array(gatewayModelTagSchema).optional(), - // Context window size (input tokens) - contextWindow: z.number().optional(), - // Maximum output tokens - maxTokens: z.number().optional(), - // Model description - description: z.string().optional(), - // Admin-curated i18n description (e.g., "Most capable for ambitious work") - i18nDescription: z - .object({ - en: z.string().optional(), - zh: z.string().optional(), - }) - .optional(), -}); - -export type IGatewayModel = z.infer; - -/* eslint-disable @typescript-eslint/naming-convention */ -// Raw pricing schema matching Vercel AI Gateway API response (snake_case) -// @see https://ai-gateway.vercel.sh/v1/models -export const rawPricingSchema = z.object({ - // Flat rates (USD per token/unit as string) - input: z.string().optional(), - output: z.string().optional(), - input_cache_read: z.string().optional(), - input_cache_write: z.string().optional(), - reasoning: z.string().optional(), - image: z.string().optional(), - web_search: z.string().optional(), - - // Tiered pricing (overrides flat rate when present) - input_tiers: z.array(pricingTierSchema).optional(), - output_tiers: z.array(pricingTierSchema).optional(), - input_cache_read_tiers: z.array(pricingTierSchema).optional(), - input_cache_write_tiers: z.array(pricingTierSchema).optional(), -}); - -export type IRawPricing = z.infer; - -// Raw API response structure from Vercel AI Gateway (snake_case as returned by API) -export const gatewayApiModelRawSchema = z.object({ - id: z.string(), - name: z.string().optional(), - description: z.string().optional(), - type: gatewayModelTypeSchema.optional(), - tags: z.array(gatewayModelTagSchema).optional(), - context_window: z.number().optional(), - max_tokens: z.number().optional(), - created: z.number().optional(), - owned_by: gatewayModelProviderSchema.optional(), - pricing: rawPricingSchema.optional(), -}); -/* eslint-enable @typescript-eslint/naming-convention */ - -export type IGatewayApiModelRaw = z.infer; - -// Gateway API model structure (camelCase, converted from API snake_case) -export const gatewayApiModelSchema = z.object({ - id: z.string(), - name: z.string().optional(), - description: z.string().optional(), - type: gatewayModelTypeSchema.optional(), - tags: z.array(gatewayModelTagSchema).optional(), - contextWindow: z.number().optional(), - maxTokens: z.number().optional(), - created: z.number().optional(), - ownedBy: gatewayModelProviderSchema.optional(), - pricing: pricingSchema.optional(), -}); - -export type IGatewayApiModel = z.infer; - -/** - * Normalize a pricing object from any source (gateway API snake_case or admin config camelCase) - * into our canonical camelCase IModelPricing format. - */ -// Field mappings: [snakeCase, camelCase] pairs for pricing normalization -const PRICING_STRING_FIELDS: [string, keyof IModelPricing][] = [ - ['input', 'input'], - ['output', 'output'], - ['reasoning', 'reasoning'], - ['image', 'image'], - ['input_cache_read', 'inputCacheRead'], - ['input_cache_write', 'inputCacheWrite'], - ['web_search', 'webSearch'], -]; - -const PRICING_TIER_FIELDS: [string, keyof IModelPricing][] = [ - ['input_tiers', 'inputTiers'], - ['output_tiers', 'outputTiers'], - ['input_cache_read_tiers', 'inputCacheReadTiers'], - ['input_cache_write_tiers', 'inputCacheWriteTiers'], -]; - -/** - * Normalize a pricing object from any source (gateway API snake_case or admin config camelCase) - * into our canonical camelCase IModelPricing format. - */ -export function normalizeGatewayPricing( - raw: IRawPricing | IModelPricing | Record | undefined -): IModelPricing | undefined { - if (!raw || Object.keys(raw).length === 0) return undefined; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const r = raw as Record; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const pricing: Record = {}; - - for (const [snake, camel] of PRICING_STRING_FIELDS) { - const val = r[snake] ?? (snake !== camel ? r[camel] : undefined); - if (val != null) pricing[camel] = String(val); - } - - for (const [snake, camel] of PRICING_TIER_FIELDS) { - const val = r[snake] ?? (snake !== camel ? r[camel] : undefined); - if (Array.isArray(val)) pricing[camel] = val; - } - - return Object.keys(pricing).length > 0 ? (pricing as IModelPricing) : undefined; -} - -// Helper function to convert raw API response to camelCase -export function convertGatewayApiModel(raw: IGatewayApiModelRaw): IGatewayApiModel { - return { - id: raw.id, - name: raw.name, - description: raw.description, - type: raw.type, - tags: raw.tags, - contextWindow: raw.context_window, - maxTokens: raw.max_tokens, - created: raw.created, - ownedBy: raw.owned_by, - pricing: normalizeGatewayPricing(raw.pricing), - }; -} - // Attachment transfer mode test result for a single mode export const attachmentModeTestResultSchema = z.object({ success: z.boolean(), @@ -693,6 +259,18 @@ export const sandboxAgentModelSchema = z.object({ name: z.string(), }); +export const SANDBOX_AGENT_EFFORT_VALUES = [ + 'auto', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', +] as const; +export const sandboxAgentEffortSchema = z.enum(SANDBOX_AGENT_EFFORT_VALUES); +export type EffortLevel = z.infer; +export const DEFAULT_SANDBOX_AGENT_EFFORT: EffortLevel = 'auto'; + export type ISandboxAgentModel = z.infer; export const sandboxAgentConfigSchema = z.object({ @@ -705,10 +283,7 @@ export const sandboxAgentConfigSchema = z.object({ maxConcurrentChats: z.number().min(1).max(20).default(3).optional(), activeSnapshotId: z.string().optional(), activeAppBuilderSnapshotId: z.string().optional(), - defaultEffort: z - .enum(['auto', 'low', 'medium', 'high', 'xhigh', 'max']) - .default('auto') - .optional(), + defaultEffort: sandboxAgentEffortSchema.default(DEFAULT_SANDBOX_AGENT_EFFORT).optional(), }); export type ISandboxAgentConfig = z.infer; diff --git a/packages/openapi/src/ai/image-generation-input-capability.ts b/packages/openapi/src/ai/image-generation-input-capability.ts new file mode 100644 index 0000000000..a8640a847c --- /dev/null +++ b/packages/openapi/src/ai/image-generation-input-capability.ts @@ -0,0 +1,83 @@ +export enum ImageGenerationInputMode { + None = 'none', + MultimodalMessage = 'multimodal-message', + ImageEditPrompt = 'image-edit-prompt', +} + +export interface IImageGenerationInputCapabilityRule { + provider: string; + modelPrefix: string; + requiredTags: readonly string[]; + mode: ImageGenerationInputMode; +} + +/** + * Provider/model specific rules for image generation models that can consume input images. + * + * AI Gateway currently exposes generic `image-generation` and `vision` tags but + * does not expose a distinct image-to-image/image-edit capability. Keep the + * rule registry here so adding future model families is localized. + */ +export const IMAGE_GENERATION_INPUT_CAPABILITY_RULES: readonly IImageGenerationInputCapabilityRule[] = + [ + { + provider: 'openai', + modelPrefix: 'gpt-image-', + requiredTags: ['image-generation'], + mode: ImageGenerationInputMode.ImageEditPrompt, + }, + { + provider: 'google', + modelPrefix: 'gemini-', + requiredTags: ['image-generation'], + mode: ImageGenerationInputMode.MultimodalMessage, + }, + ]; + +const parseGatewayModelId = (modelId: string): { provider: string; model: string } => { + const [provider, ...modelParts] = modelId.split('/'); + return { + provider, + model: modelParts.join('/'), + }; +}; + +/** + * Resolve how image attachments should be passed to an image generation model. + */ +export function getImageGenerationInputMode( + modelId: string, + tags: readonly string[] = [] +): ImageGenerationInputMode { + const { provider, model } = parseGatewayModelId(modelId); + + const matchedRule = IMAGE_GENERATION_INPUT_CAPABILITY_RULES.find((rule) => { + return ( + rule.provider === provider && + model.startsWith(rule.modelPrefix) && + rule.requiredTags.every((tag) => tags.includes(tag)) + ); + }); + + return matchedRule?.mode ?? ImageGenerationInputMode.None; +} + +/** + * Whether the model can consume input images while generating output images. + */ +export function supportsImageInputForImageGeneration( + modelId: string, + tags: readonly string[] = [] +): boolean { + return getImageGenerationInputMode(modelId, tags) !== ImageGenerationInputMode.None; +} + +/** + * Whether the model should receive image attachments through AI SDK generateImage prompt.images. + */ +export function supportsImageEditPromptForImageGeneration( + modelId: string, + tags: readonly string[] = [] +): boolean { + return getImageGenerationInputMode(modelId, tags) === ImageGenerationInputMode.ImageEditPrompt; +} diff --git a/packages/openapi/src/ai/image-model-catalog.ts b/packages/openapi/src/ai/image-model-catalog.ts new file mode 100644 index 0000000000..cc6554f100 --- /dev/null +++ b/packages/openapi/src/ai/image-model-catalog.ts @@ -0,0 +1,457 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { + BFL_ASPECT_RATIO_PRESETS, + BFL_ASPECT_RATIO_RANGE, + DEEPINFRA_STABILITY_ASPECT_RATIOS, + DIMENSION_RANGE_256_1440_MULTIPLE_32, + FIREWORKS_1024_SIZES, + FIREWORKS_FLUX_ASPECT_RATIOS, + GEMINI_IMAGE_ASPECT_RATIOS, + GOOGLE_IMAGEN_ASPECT_RATIOS, + OPENAI_DALLE2_SIZES, + OPENAI_DALLE3_SIZES, + OPENAI_GPT_IMAGE_2_SIZES, + OPENAI_GPT_IMAGE_SIZES, + REPLICATE_FLUX_SCHNELL_ASPECT_RATIOS, + REPLICATE_RECRAFT_SIZES, + STANDARD_ASPECT_RATIOS, + TOGETHERAI_SQUARE_SIZES, + XAI_GROK_ASPECT_RATIOS, +} from './image-model-dimensions'; +import type { IAspectRatio, IImageSize } from './image-model-dimensions'; +import type { IImageModelConfig } from './image-model-types'; + +const createAspectRatioImageModel = ( + provider: string, + model: string, + displayName: string, + supportedAspectRatios: IAspectRatio[] = STANDARD_ASPECT_RATIOS +): IImageModelConfig => ({ + provider, + model, + displayName, + sizeType: 'aspectRatio', + supportedAspectRatios, + defaultAspectRatio: '1:1', + modelType: 'image', +}); + +const createSizeImageModel = ( + provider: string, + model: string, + displayName: string, + supportedSizes: IImageSize[], + defaultSize: IImageSize = supportedSizes[0] +): IImageModelConfig => ({ + provider, + model, + displayName, + sizeType: 'size', + supportedSizes, + defaultSize, + modelType: 'image', +}); + +const createOpenAIGptImageModel = ( + model: string, + displayName: string, + supportedSizes: IImageSize[] = OPENAI_GPT_IMAGE_SIZES +): IImageModelConfig => ({ + provider: 'openai', + model, + displayName, + sizeType: 'size', + supportedSizes, + defaultSize: '1024x1024', + supportsQuality: true, + supportsStyle: true, + modelType: 'image', + tags: ['image-generation'], +}); + +const createGeminiImageLanguageModel = (model: string, displayName: string): IImageModelConfig => ({ + provider: 'google', + model, + displayName, + sizeType: 'flexible', + supportedAspectRatios: GEMINI_IMAGE_ASPECT_RATIOS, + modelType: 'language', + tags: ['image-generation'], + notes: 'Multimodal LLM with image generation via generateText', +}); + +const createDimensionRangeImageModel = ( + provider: string, + model: string, + displayName: string, + sizeRange: NonNullable +): IImageModelConfig => ({ + provider, + model, + displayName, + sizeType: 'flexible', + sizeRange, + modelType: 'image', +}); + +const createBflImageModel = (model: string, displayName: string): IImageModelConfig => ({ + provider: 'bfl', + model, + displayName, + sizeType: 'aspectRatio', + supportedAspectRatios: BFL_ASPECT_RATIO_PRESETS, + aspectRatioRange: BFL_ASPECT_RATIO_RANGE, + defaultAspectRatio: '1:1', + modelType: 'image', +}); + +const createRecraftImageModel = (model: string, displayName: string): IImageModelConfig => + createSizeImageModel('recraft', model, displayName, REPLICATE_RECRAFT_SIZES); + +/** + * Image model configurations by provider + * Based on: https://ai-sdk.dev/docs/ai-sdk-core/image-generation#image-models + */ +export const IMAGE_MODEL_CONFIGS: IImageModelConfig[] = [ + // xAI Grok + { + provider: 'xai', + model: 'grok-imagine-image', + displayName: 'Grok Imagine Image', + sizeType: 'aspectRatio', + supportedAspectRatios: XAI_GROK_ASPECT_RATIOS, + supportsAutoAspectRatio: true, + defaultAspectRatio: '1:1', + modelType: 'image', + }, + + // OpenAI + createOpenAIGptImageModel('gpt-image-2', 'GPT Image 2', OPENAI_GPT_IMAGE_2_SIZES), + createOpenAIGptImageModel('gpt-image-1.5', 'GPT Image 1.5'), + createOpenAIGptImageModel('gpt-image-1-mini', 'GPT Image 1 Mini'), + createOpenAIGptImageModel('gpt-image-1', 'GPT Image 1'), + { + provider: 'openai', + model: 'dall-e-3', + displayName: 'DALL-E 3', + sizeType: 'size', + supportedSizes: OPENAI_DALLE3_SIZES, + defaultSize: '1024x1024', + maxImagesPerCall: 1, + supportsQuality: true, + supportsStyle: true, + supportsSeed: true, + modelType: 'image', + }, + { + provider: 'openai', + model: 'dall-e-2', + displayName: 'DALL-E 2', + sizeType: 'size', + supportedSizes: OPENAI_DALLE2_SIZES, + defaultSize: '1024x1024', + maxImagesPerCall: 10, + modelType: 'image', + }, + + // Amazon Bedrock + { + provider: 'amazonBedrock', + model: 'amazon.nova-canvas-v1:0', + displayName: 'Amazon Nova Canvas', + sizeType: 'both', + sizeRange: { + min: 320, + max: 4096, + multipleOf: 16, + maxPixels: 4_200_000, + }, + aspectRatioRange: { + min: '1:4', + max: '4:1', + notes: '1:4 to 4:1', + }, + modelType: 'image', + }, + + // Fal + createAspectRatioImageModel('fal', 'fal-ai/flux/dev', 'FLUX Dev'), + createAspectRatioImageModel('fal', 'fal-ai/flux-lora', 'FLUX LoRA'), + createAspectRatioImageModel('fal', 'fal-ai/fast-sdxl', 'Fast SDXL'), + createAspectRatioImageModel('fal', 'fal-ai/flux-pro/v1.1-ultra', 'FLUX Pro 1.1 Ultra'), + createAspectRatioImageModel('fal', 'fal-ai/ideogram/v2', 'Ideogram V2'), + createAspectRatioImageModel('fal', 'fal-ai/recraft-v3', 'Recraft V3'), + createAspectRatioImageModel( + 'fal', + 'fal-ai/stable-diffusion-3.5-large', + 'Stable Diffusion 3.5 Large' + ), + createAspectRatioImageModel('fal', 'fal-ai/hyper-sdxl', 'Hyper SDXL'), + + // DeepInfra + createAspectRatioImageModel( + 'deepinfra', + 'stabilityai/sd3.5', + 'Stable Diffusion 3.5', + DEEPINFRA_STABILITY_ASPECT_RATIOS + ), + createDimensionRangeImageModel( + 'deepinfra', + 'black-forest-labs/FLUX-1.1-pro', + 'FLUX 1.1 Pro', + DIMENSION_RANGE_256_1440_MULTIPLE_32 + ), + createDimensionRangeImageModel( + 'deepinfra', + 'black-forest-labs/FLUX-1-schnell', + 'FLUX 1 Schnell', + DIMENSION_RANGE_256_1440_MULTIPLE_32 + ), + createDimensionRangeImageModel( + 'deepinfra', + 'black-forest-labs/FLUX-1-dev', + 'FLUX 1 Dev', + DIMENSION_RANGE_256_1440_MULTIPLE_32 + ), + createDimensionRangeImageModel( + 'deepinfra', + 'black-forest-labs/FLUX-pro', + 'FLUX Pro', + DIMENSION_RANGE_256_1440_MULTIPLE_32 + ), + createAspectRatioImageModel( + 'deepinfra', + 'stabilityai/sd3.5-medium', + 'Stable Diffusion 3.5 Medium', + DEEPINFRA_STABILITY_ASPECT_RATIOS + ), + createAspectRatioImageModel( + 'deepinfra', + 'stabilityai/sdxl-turbo', + 'SDXL Turbo', + DEEPINFRA_STABILITY_ASPECT_RATIOS + ), + + // Replicate + createAspectRatioImageModel( + 'replicate', + 'black-forest-labs/flux-schnell', + 'FLUX Schnell', + REPLICATE_FLUX_SCHNELL_ASPECT_RATIOS + ), + createSizeImageModel('replicate', 'recraft-ai/recraft-v3', 'Recraft V3', REPLICATE_RECRAFT_SIZES), + + // Google (Multimodal LLMs with image generation capability) + createGeminiImageLanguageModel('gemini-2.5-flash-image', 'Gemini 2.5 Flash Image'), + createGeminiImageLanguageModel( + 'gemini-2.5-flash-image-preview', + 'Gemini 2.5 Flash Image Preview' + ), + createGeminiImageLanguageModel('gemini-3-pro-image', 'Gemini 3 Pro Image'), + createGeminiImageLanguageModel( + 'gemini-3.1-flash-image-preview', + 'Gemini 3.1 Flash Image Preview' + ), + + // Google Imagen + createAspectRatioImageModel( + 'google', + 'imagen-4.0-generate-001', + 'Imagen 4.0', + GOOGLE_IMAGEN_ASPECT_RATIOS + ), + createAspectRatioImageModel( + 'google', + 'imagen-4.0-fast-generate-001', + 'Imagen 4.0 Fast', + GOOGLE_IMAGEN_ASPECT_RATIOS + ), + createAspectRatioImageModel( + 'google', + 'imagen-4.0-ultra-generate-001', + 'Imagen 4.0 Ultra', + GOOGLE_IMAGEN_ASPECT_RATIOS + ), + + // ByteDance + createAspectRatioImageModel('bytedance', 'seedream-4.0', 'Seedream 4.0'), + createAspectRatioImageModel('bytedance', 'seedream-4.5', 'Seedream 4.5'), + createAspectRatioImageModel('bytedance', 'seedream-5.0-lite', 'Seedream 5.0 Lite'), + + // Google Vertex + createAspectRatioImageModel( + 'googleVertex', + 'imagen-4.0-generate-001', + 'Imagen 4.0', + GOOGLE_IMAGEN_ASPECT_RATIOS + ), + createAspectRatioImageModel( + 'googleVertex', + 'imagen-4.0-fast-generate-001', + 'Imagen 4.0 Fast', + GOOGLE_IMAGEN_ASPECT_RATIOS + ), + createAspectRatioImageModel( + 'googleVertex', + 'imagen-4.0-ultra-generate-001', + 'Imagen 4.0 Ultra', + GOOGLE_IMAGEN_ASPECT_RATIOS + ), + createAspectRatioImageModel( + 'googleVertex', + 'imagen-3.0-fast-generate-001', + 'Imagen 3.0 Fast', + GOOGLE_IMAGEN_ASPECT_RATIOS + ), + + // Fireworks + createAspectRatioImageModel( + 'fireworks', + 'accounts/fireworks/models/flux-1-dev-fp8', + 'FLUX 1 Dev FP8', + FIREWORKS_FLUX_ASPECT_RATIOS + ), + createAspectRatioImageModel( + 'fireworks', + 'accounts/fireworks/models/flux-1-schnell-fp8', + 'FLUX 1 Schnell FP8', + FIREWORKS_FLUX_ASPECT_RATIOS + ), + createSizeImageModel( + 'fireworks', + 'accounts/fireworks/models/playground-v2-5-1024px-aesthetic', + 'Playground V2.5 1024px Aesthetic', + FIREWORKS_1024_SIZES, + '1024x1024' + ), + createSizeImageModel( + 'fireworks', + 'accounts/fireworks/models/japanese-stable-diffusion-xl', + 'Japanese Stable Diffusion XL', + FIREWORKS_1024_SIZES, + '1024x1024' + ), + createSizeImageModel( + 'fireworks', + 'accounts/fireworks/models/playground-v2-1024px-aesthetic', + 'Playground V2 1024px Aesthetic', + FIREWORKS_1024_SIZES, + '1024x1024' + ), + createSizeImageModel( + 'fireworks', + 'accounts/fireworks/models/SSD-1B', + 'SSD-1B', + FIREWORKS_1024_SIZES, + '1024x1024' + ), + createSizeImageModel( + 'fireworks', + 'accounts/fireworks/models/stable-diffusion-xl-1024-v1-0', + 'Stable Diffusion XL 1024 v1.0', + FIREWORKS_1024_SIZES, + '1024x1024' + ), + + // Luma + createAspectRatioImageModel('luma', 'photon-1', 'Photon 1'), + createAspectRatioImageModel('luma', 'photon-flash-1', 'Photon Flash 1'), + + // Together.ai + createSizeImageModel( + 'togetherai', + 'stabilityai/stable-diffusion-xl-base-1.0', + 'Stable Diffusion XL Base 1.0', + TOGETHERAI_SQUARE_SIZES, + '1024x1024' + ), + createSizeImageModel( + 'togetherai', + 'black-forest-labs/FLUX.1-dev', + 'FLUX.1 Dev', + TOGETHERAI_SQUARE_SIZES, + '1024x1024' + ), + createSizeImageModel( + 'togetherai', + 'black-forest-labs/FLUX.1-dev-lora', + 'FLUX.1 Dev LoRA', + TOGETHERAI_SQUARE_SIZES, + '1024x1024' + ), + createSizeImageModel( + 'togetherai', + 'black-forest-labs/FLUX.1-schnell', + 'FLUX.1 Schnell', + TOGETHERAI_SQUARE_SIZES, + '1024x1024' + ), + createSizeImageModel( + 'togetherai', + 'black-forest-labs/FLUX.1-canny', + 'FLUX.1 Canny', + TOGETHERAI_SQUARE_SIZES, + '1024x1024' + ), + createSizeImageModel( + 'togetherai', + 'black-forest-labs/FLUX.1-depth', + 'FLUX.1 Depth', + TOGETHERAI_SQUARE_SIZES, + '1024x1024' + ), + createSizeImageModel( + 'togetherai', + 'black-forest-labs/FLUX.1-redux', + 'FLUX.1 Redux', + TOGETHERAI_SQUARE_SIZES, + '1024x1024' + ), + createSizeImageModel( + 'togetherai', + 'black-forest-labs/FLUX.1.1-pro', + 'FLUX.1.1 Pro', + TOGETHERAI_SQUARE_SIZES, + '1024x1024' + ), + createSizeImageModel( + 'togetherai', + 'black-forest-labs/FLUX.1-pro', + 'FLUX.1 Pro', + TOGETHERAI_SQUARE_SIZES, + '1024x1024' + ), + createSizeImageModel( + 'togetherai', + 'black-forest-labs/FLUX.1-schnell-Free', + 'FLUX.1 Schnell Free', + TOGETHERAI_SQUARE_SIZES, + '1024x1024' + ), + + // Black Forest Labs + createBflImageModel('flux-2-flex', 'FLUX.2 Flex'), + createBflImageModel('flux-2-klein-4b', 'FLUX.2 Klein 4B'), + createBflImageModel('flux-2-klein-9b', 'FLUX.2 Klein 9B'), + createBflImageModel('flux-2-max', 'FLUX.2 Max'), + createBflImageModel('flux-2-pro', 'FLUX.2 Pro'), + createBflImageModel('flux-kontext-pro', 'FLUX Kontext Pro'), + createBflImageModel('flux-kontext-max', 'FLUX Kontext Max'), + createBflImageModel('flux-pro-1.1-ultra', 'FLUX Pro 1.1 Ultra'), + createBflImageModel('flux-pro-1.1', 'FLUX Pro 1.1'), + createBflImageModel('flux-pro-1.0-fill', 'FLUX Pro 1.0 Fill'), + + // Prodia + createAspectRatioImageModel( + 'prodia', + 'flux-fast-schnell', + 'Flux Schnell', + REPLICATE_FLUX_SCHNELL_ASPECT_RATIOS + ), + + // Recraft + createRecraftImageModel('recraft-v2', 'Recraft V2'), + createRecraftImageModel('recraft-v3', 'Recraft V3'), + createRecraftImageModel('recraft-v4', 'Recraft V4'), + createRecraftImageModel('recraft-v4-pro', 'Recraft V4 Pro'), +]; diff --git a/packages/openapi/src/ai/image-model-config.spec.ts b/packages/openapi/src/ai/image-model-config.spec.ts new file mode 100644 index 0000000000..24ce31629b --- /dev/null +++ b/packages/openapi/src/ai/image-model-config.spec.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from 'vitest'; +import { + getImageAspectRatioCandidates, + getImageModelConfigByModelKey, + getImageModelConfigByGatewayId, + getImageModelIdFromModelKey, + getImageSizeCandidates, + getKnownImageModelAbility, + isPromptControlledImageGenerationModel, + supportsImageAspectRatioSelection, + supportsImageInputForImageModel, + supportsKnownImageInputForImageModel, + supportsImageSizeSelection, +} from './image-model-config'; + +const GPT_IMAGE_2_MODEL_ID = 'openai/gpt-image-2'; +const GPT_IMAGE_2_MODEL = 'gpt-image-2'; +const GPT_IMAGE_15_MODEL = 'gpt-image-1.5'; +const GPT_IMAGE_MINI_GATEWAY_MODEL_ID = 'openai/gpt-image-1-mini'; +const GPT_IMAGE_2_MODEL_KEY = `aiGateway@${GPT_IMAGE_2_MODEL_ID}@teable`; +const GPT_IMAGE_2_DIRECT_MODEL_KEY = 'openai@gpt-image-2@OpenAI'; +const IMAGE_GENERATION_TAG = 'image-generation'; +const SIZE_1024 = '1024x1024'; + +describe('getImageModelConfigByGatewayId', () => { + it('resolves provider-qualified gateway model IDs', () => { + expect(getImageModelConfigByGatewayId('google/imagen-4.0-generate-001')?.provider).toBe( + 'google' + ); + expect(getImageModelConfigByGatewayId('googleVertex/imagen-4.0-generate-001')?.provider).toBe( + 'googleVertex' + ); + }); + + it('resolves full model IDs that include provider-like path segments', () => { + expect(getImageModelConfigByGatewayId('fal-ai/flux/dev')?.provider).toBe('fal'); + }); + + it('does not guess when a bare model ID is shared by multiple providers', () => { + expect(getImageModelConfigByGatewayId('imagen-4.0-generate-001')).toBeUndefined(); + }); + + it('allows bare model ID fallback only when the catalog has a single matching model', () => { + expect(getImageModelConfigByGatewayId('gpt-image-1')?.provider).toBe('openai'); + }); + + it('resolves newer GPT image model family entries', () => { + expect(getImageModelConfigByGatewayId(GPT_IMAGE_2_MODEL_ID)?.supportedSizes).toEqual([ + SIZE_1024, + '1536x1024', + '1024x1536', + '2048x2048', + '2048x1152', + '3840x2160', + '2160x3840', + ]); + expect(getImageModelConfigByGatewayId('openai/gpt-image-1.5')?.provider).toBe('openai'); + expect(getImageModelConfigByGatewayId('openai/gpt-image-1.5')?.supportedSizes).toEqual([ + SIZE_1024, + '1536x1024', + '1024x1536', + ]); + expect(getImageModelConfigByGatewayId('openai/gpt-image-1-mini')?.provider).toBe('openai'); + }); + + it('derives finite preset candidates from range-only size models', () => { + const config = getImageModelConfigByGatewayId('deepinfra/black-forest-labs/FLUX-1-dev'); + + expect(config).toBeDefined(); + expect(getImageSizeCandidates(config!)).toEqual([ + '256x256', + '512x512', + '768x768', + SIZE_1024, + '1024x768', + '1152x896', + '1216x832', + '1280x1024', + '1344x768', + '768x1344', + '832x1216', + '896x1152', + '1024x1280', + '1024x1344', + ]); + }); + + it('filters range-backed size candidates against provider constraints', () => { + const config = getImageModelConfigByGatewayId('amazonBedrock/amazon.nova-canvas-v1:0'); + + expect(config).toBeDefined(); + expect(getImageSizeCandidates(config!)).not.toContain('256x256'); + expect(getImageSizeCandidates(config!)).toContain('1024x768'); + expect(getImageSizeCandidates(config!)).toContain('2048x1024'); + expect(getImageAspectRatioCandidates(config!)).toContain('1:4'); + expect(getImageAspectRatioCandidates(config!)).toContain('4:1'); + }); + + it('uses explicit product aspect-ratio candidates for Gemini image language models', () => { + const config = getImageModelConfigByGatewayId('google/gemini-3-pro-image'); + + expect(config).toBeDefined(); + expect(config?.defaultAspectRatio).toBeUndefined(); + expect(getImageAspectRatioCandidates(config!)).toEqual([ + '1:1', + '2:3', + '3:2', + '3:4', + '4:3', + '4:5', + '5:4', + '9:16', + '16:9', + '21:9', + ]); + expect(getImageAspectRatioCandidates(config!)).not.toContain('9:21'); + expect(getImageAspectRatioCandidates(config!)).not.toContain('2:1'); + expect(getImageAspectRatioCandidates(config!)).not.toContain('1:2'); + }); + + it('resolves current AI Gateway image-generation catalog entries', () => { + const gatewayModelIds = [ + 'bfl/flux-2-flex', + 'bfl/flux-2-klein-4b', + 'bfl/flux-2-klein-9b', + 'bfl/flux-2-max', + 'bfl/flux-2-pro', + 'bytedance/seedream-4.0', + 'bytedance/seedream-4.5', + 'bytedance/seedream-5.0-lite', + 'google/gemini-3.1-flash-image-preview', + 'prodia/flux-fast-schnell', + 'recraft/recraft-v2', + 'recraft/recraft-v3', + 'recraft/recraft-v4', + 'recraft/recraft-v4-pro', + ]; + + for (const modelId of gatewayModelIds) { + expect(getImageModelConfigByGatewayId(modelId), modelId).toBeDefined(); + } + + expect(getImageModelConfigByGatewayId('google/gemini-3.1-flash-image-preview')?.modelType).toBe( + 'language' + ); + expect(getImageModelConfigByGatewayId('bfl/flux-2-pro')?.modelType).toBe('image'); + expect(getImageModelConfigByGatewayId('recraft/recraft-v4-pro')?.modelType).toBe('image'); + }); +}); + +describe('getImageModelConfigByModelKey', () => { + it('parses model keys consistently for gateway and direct providers', () => { + expect(getImageModelIdFromModelKey(GPT_IMAGE_2_MODEL_KEY)).toBe(GPT_IMAGE_2_MODEL_ID); + expect(getImageModelIdFromModelKey(GPT_IMAGE_2_DIRECT_MODEL_KEY)).toBe(GPT_IMAGE_2_MODEL_ID); + }); + + it('resolves direct provider model keys from the shared catalog', () => { + const resolved = getImageModelConfigByModelKey(GPT_IMAGE_2_DIRECT_MODEL_KEY); + + expect(resolved?.config.provider).toBe('openai'); + expect(getImageSizeCandidates(resolved!.config)).toEqual([ + SIZE_1024, + '1536x1024', + '1024x1536', + '2048x2048', + '2048x1152', + '3840x2160', + '2160x3840', + ]); + expect( + supportsImageInputForImageModel(resolved!.config, resolved!.modelId, resolved!.tags) + ).toBe(true); + }); + + it('resolves gateway catalog models and keeps gateway tags available', () => { + const resolved = getImageModelConfigByModelKey(GPT_IMAGE_2_MODEL_KEY, [ + { + id: GPT_IMAGE_2_MODEL_ID, + modelType: 'image', + tags: [IMAGE_GENERATION_TAG, 'vision'], + }, + ]); + + expect(resolved?.config.model).toBe(GPT_IMAGE_2_MODEL); + expect(resolved?.tags).toEqual([IMAGE_GENERATION_TAG, 'vision']); + expect( + supportsImageInputForImageModel(resolved!.config, resolved!.modelId, resolved!.tags) + ).toBe(true); + }); + + it('falls back to gateway language model metadata for prompt-controlled image generation', () => { + const resolved = getImageModelConfigByModelKey('aiGateway@google/gemini-future-image@teable', [ + { + id: 'google/gemini-future-image', + modelType: 'language', + tags: [IMAGE_GENERATION_TAG], + }, + ]); + + expect(resolved?.config.modelType).toBe('language'); + expect(isPromptControlledImageGenerationModel(resolved!.config)).toBe(true); + expect(supportsImageSizeSelection(resolved!.config)).toBe(false); + expect(supportsImageAspectRatioSelection(resolved!.config)).toBe(true); + }); + + it('falls back to gateway image model metadata with shared default size candidates', () => { + const resolved = getImageModelConfigByModelKey('aiGateway@custom/new-image-model@teable', [ + { + id: 'custom/new-image-model', + modelType: 'image', + }, + ]); + + expect(resolved?.config.modelType).toBe('image'); + expect(supportsImageSizeSelection(resolved!.config)).toBe(true); + expect(getImageSizeCandidates(resolved!.config)).toContain(SIZE_1024); + }); +}); + +describe('supportsKnownImageInputForImageModel', () => { + it('uses the catalog for known direct and gateway image models', () => { + expect(supportsKnownImageInputForImageModel('openai', GPT_IMAGE_2_MODEL)).toBe(true); + expect(supportsKnownImageInputForImageModel('openai', GPT_IMAGE_15_MODEL)).toBe(true); + expect(supportsKnownImageInputForImageModel('aiGateway', GPT_IMAGE_MINI_GATEWAY_MODEL_ID)).toBe( + true + ); + }); + + it('does not infer BYOK providers from bare model names', () => { + expect(supportsKnownImageInputForImageModel('openRouter', GPT_IMAGE_2_MODEL)).toBe(false); + expect(supportsKnownImageInputForImageModel('openaiCompatible', GPT_IMAGE_2_MODEL)).toBe(false); + }); +}); + +describe('getKnownImageModelAbility', () => { + it('derives image generation ability from known catalog image models', () => { + expect(getKnownImageModelAbility('openai', GPT_IMAGE_2_MODEL)).toEqual({ + generation: true, + imageToImage: true, + }); + expect(getKnownImageModelAbility('openai', 'dall-e-3')).toEqual({ + generation: true, + imageToImage: false, + }); + }); + + it('does not infer custom BYOK provider abilities from bare model names', () => { + expect(getKnownImageModelAbility('openRouter', GPT_IMAGE_2_MODEL)).toBeUndefined(); + expect(getKnownImageModelAbility('openaiCompatible', GPT_IMAGE_2_MODEL)).toBeUndefined(); + }); +}); diff --git a/packages/openapi/src/ai/image-model-config.ts b/packages/openapi/src/ai/image-model-config.ts index 62d77ddb81..b2580d6712 100644 --- a/packages/openapi/src/ai/image-model-config.ts +++ b/packages/openapi/src/ai/image-model-config.ts @@ -1,5 +1,40 @@ -/* eslint-disable sonarjs/no-duplicate-string */ import { z } from 'zod'; +import type { IImageModelAbility } from '../admin'; +import { supportsImageInputForImageGeneration } from './image-generation-input-capability'; +import { IMAGE_MODEL_CONFIGS } from './image-model-catalog'; +import { + DEFAULT_IMAGE_SIZE_CANDIDATES, + getImageAspectRatioCandidates, + getImageSizeCandidates, +} from './image-model-dimensions'; +import type { IImageModelConfig } from './image-model-types'; +export { + getImageGenerationInputMode, + ImageGenerationInputMode, + supportsImageEditPromptForImageGeneration, + supportsImageInputForImageGeneration, +} from './image-generation-input-capability'; +export { IMAGE_MODEL_CONFIGS } from './image-model-catalog'; +export { + DEFAULT_ASPECT_RATIO_CANDIDATES, + DEFAULT_IMAGE_SIZE_CANDIDATES, + aspectRatioToSize, + aspectRatioSchema, + getDefaultImageDimension, + getImageAspectRatioCandidates, + getImageSizeCandidates, + imageSizeSchema, + isAspectRatioSupported, + isImageSizeSupported, +} from './image-model-dimensions'; +export type { + IAspectRatio, + IDefaultImageDimensionConfig, + IImageAspectRatioRange, + IImageSize, + IImageSizeRange, +} from './image-model-dimensions'; +export type { IImageModelConfig } from './image-model-types'; /** * Image Model Configuration @@ -8,65 +43,6 @@ import { z } from 'zod'; * This config provides standardized image generation parameters for different providers and models. */ -// Supported image sizes (width x height) -export const imageSizeSchema = z.enum([ - // Common sizes - '256x256', - '512x512', - '768x768', - '1024x1024', - // Wide sizes - '1024x768', - '1152x896', - '1216x832', - '1280x1024', - '1344x768', - '1365x1024', - '1434x1024', - '1536x640', - '1536x1024', - '1707x1024', - '1792x1024', - '1820x1024', - '2048x1024', - // Tall sizes - '768x1344', - '832x1216', - '896x1152', - '640x1536', - '1024x1280', - '1024x1344', - '1024x1365', - '1024x1434', - '1024x1536', - '1024x1707', - '1024x1792', - '1024x1820', - '1024x2048', -]); - -export type IImageSize = z.infer; - -// Supported aspect ratios (width : height) -export const aspectRatioSchema = z.enum([ - '1:1', - '2:3', - '3:2', - '3:4', - '4:3', - '4:5', - '5:4', - '9:16', - '16:9', - '9:21', - '21:9', - '1:9', - '3:7', - '7:3', -]); - -export type IAspectRatio = z.infer; - // Image quality options export const imageQualitySchema = z.enum(['standard', 'hd', 'low', 'medium', 'high', 'ultra']); @@ -77,164 +53,8 @@ export const imageStyleSchema = z.enum(['vivid', 'natural']); export type IImageStyle = z.infer; -/** - * Image model configuration interface - */ -export interface IImageModelConfig { - /** Provider name */ - provider: string; - /** Model ID */ - model: string; - /** Display name */ - displayName?: string; - /** Whether the model uses sizes or aspect ratios */ - sizeType: 'size' | 'aspectRatio' | 'both' | 'flexible'; - /** Supported sizes (if sizeType is 'size' or 'both') */ - supportedSizes?: IImageSize[]; - /** Supported aspect ratios (if sizeType is 'aspectRatio' or 'both') */ - supportedAspectRatios?: IAspectRatio[]; - /** Default size */ - defaultSize?: IImageSize; - /** Default aspect ratio */ - defaultAspectRatio?: IAspectRatio; - /** Maximum images per call */ - maxImagesPerCall?: number; - /** Whether the model supports quality parameter */ - supportsQuality?: boolean; - /** Whether the model supports style parameter */ - supportsStyle?: boolean; - /** Whether the model supports seed parameter */ - supportsSeed?: boolean; - /** Model type: 'image' for pure image models, 'language' for multimodal LLMs */ - modelType: 'image' | 'language'; - /** Tags for additional capabilities */ - tags?: string[]; - /** Additional notes */ - notes?: string; -} - -/** - * Standard aspect ratios used by most models - */ -export const STANDARD_ASPECT_RATIOS: IAspectRatio[] = [ - '1:1', - '3:4', - '4:3', - '9:16', - '16:9', - '9:21', - '21:9', -]; - -/** - * Extended aspect ratios (includes portrait/landscape variants) - */ -export const EXTENDED_ASPECT_RATIOS: IAspectRatio[] = [ - '1:1', - '2:3', - '3:2', - '3:4', - '4:3', - '4:5', - '5:4', - '9:16', - '16:9', - '9:21', - '21:9', -]; - -/** - * Image model configurations by provider - * Based on: https://ai-sdk.dev/docs/ai-sdk-core/image-generation#image-models - */ -export const IMAGE_MODEL_CONFIGS: IImageModelConfig[] = [ - // xAI Grok - { - provider: 'xai', - model: 'grok-2-image', - displayName: 'Grok 2 Image', - sizeType: 'size', - supportedSizes: ['1024x768'], - defaultSize: '1024x768', - modelType: 'image', - }, - - // OpenAI - { - provider: 'openai', - model: 'gpt-image-1', - displayName: 'GPT Image 1', - sizeType: 'size', - supportedSizes: ['1024x1024', '1536x1024', '1024x1536'], - defaultSize: '1024x1024', - supportsQuality: true, - supportsStyle: true, - modelType: 'image', - }, - { - provider: 'openai', - model: 'dall-e-3', - displayName: 'DALL-E 3', - sizeType: 'size', - supportedSizes: ['1024x1024', '1792x1024', '1024x1792'], - defaultSize: '1024x1024', - maxImagesPerCall: 1, - supportsQuality: true, - supportsStyle: true, - supportsSeed: true, - modelType: 'image', - }, - { - provider: 'openai', - model: 'dall-e-2', - displayName: 'DALL-E 2', - sizeType: 'size', - supportedSizes: ['256x256', '512x512', '1024x1024'], - defaultSize: '1024x1024', - maxImagesPerCall: 10, - modelType: 'image', - }, - - // Google (Multimodal LLMs with image generation capability) - { - provider: 'google', - model: 'gemini-2.5-flash-image-preview', - displayName: 'Gemini 2.5 Flash Image Preview', - sizeType: 'flexible', - defaultSize: '1024x1024', - modelType: 'language', - tags: ['image-generation'], - notes: 'Multimodal LLM with image generation via generateText', - }, - { - provider: 'google', - model: 'gemini-3-pro-image', - displayName: 'Gemini 3 Pro Image', - sizeType: 'flexible', - modelType: 'language', - tags: ['image-generation'], - notes: 'Multimodal LLM with image generation via generateText', - }, - // Google Imagen - { - provider: 'google', - model: 'imagen-4.0-generate-001', - displayName: 'Imagen 4.0', - sizeType: 'aspectRatio', - supportedAspectRatios: ['1:1', '3:4', '4:3', '9:16', '16:9'], - defaultAspectRatio: '1:1', - modelType: 'image', - }, - { - provider: 'google', - model: 'imagen-4.0-fast-generate-001', - displayName: 'Imagen 4.0 Fast', - sizeType: 'aspectRatio', - supportedAspectRatios: ['1:1', '3:4', '4:3', '9:16', '16:9'], - defaultAspectRatio: '1:1', - modelType: 'image', - }, -]; +const AI_GATEWAY_MODEL_KEY_TYPE = 'aiGateway'; +const IMAGE_GENERATION_TAG = 'image-generation'; /** * Get image model config by provider and model @@ -246,6 +66,11 @@ export function getImageModelConfig( return IMAGE_MODEL_CONFIGS.find((c) => c.provider === provider && c.model === model); } +const getUniqueImageModelConfigByModel = (model: string): IImageModelConfig | undefined => { + const matches = IMAGE_MODEL_CONFIGS.filter((c) => c.model === model); + return matches.length === 1 ? matches[0] : undefined; +}; + /** * Get image model config by model ID (for gateway models like "google/gemini-2.5-flash-image-preview") */ @@ -254,9 +79,26 @@ export function getImageModelConfigByGatewayId( ): IImageModelConfig | undefined { const [provider, ...modelParts] = gatewayModelId.split('/'); const model = modelParts.join('/'); + + if (!model) { + return getUniqueImageModelConfigByModel(gatewayModelId); + } + + const providerModelMatch = IMAGE_MODEL_CONFIGS.find( + (c) => c.provider === provider && c.model === model + ); + if (providerModelMatch) { + return providerModelMatch; + } + + const fullModelIdMatch = IMAGE_MODEL_CONFIGS.find((c) => c.model === gatewayModelId); + if (fullModelIdMatch) { + return fullModelIdMatch; + } + + // Only fall back to bare model IDs when the catalog has a single owner for that model. return ( - IMAGE_MODEL_CONFIGS.find((c) => c.provider === provider && c.model === model) || - IMAGE_MODEL_CONFIGS.find((c) => c.model === gatewayModelId || c.model === model) + getUniqueImageModelConfigByModel(model) ?? getUniqueImageModelConfigByModel(gatewayModelId) ); } @@ -279,56 +121,200 @@ export function getPureImageModels(): IImageModelConfig[] { */ export function getMultimodalImageModels(): IImageModelConfig[] { return IMAGE_MODEL_CONFIGS.filter( - (c) => c.modelType === 'language' && c.tags?.includes('image-generation') + (c) => c.modelType === 'language' && c.tags?.includes(IMAGE_GENERATION_TAG) ); } /** * Check if a model supports image generation */ -export function supportsImageGeneration(modelType?: string, tags?: string[]): boolean { +export function supportsImageGeneration(modelType?: string, tags?: readonly string[]): boolean { if (modelType === 'image') return true; - if (modelType === 'language' && tags?.includes('image-generation')) return true; + if (modelType === 'language' && tags?.includes(IMAGE_GENERATION_TAG)) return true; return false; } -/** - * Get default size or aspect ratio for a model - */ -export function getDefaultImageDimension(config: IImageModelConfig): { - size?: IImageSize; - aspectRatio?: IAspectRatio; -} { - if (config.defaultSize) { - return { size: config.defaultSize }; +export interface IImageModelMetadata { + id: string; + type?: string; + modelType?: string; + tags?: readonly string[]; +} + +export interface IResolvedImageModelConfig { + config: IImageModelConfig; + modelId: string; + tags: string[]; +} + +const mergeTags = (...tagGroups: Array): string[] => [ + ...new Set(tagGroups.flatMap((tags) => tags ?? [])), +]; + +const splitModelId = (modelId: string): Pick => { + const [provider, ...modelParts] = modelId.split('/'); + const model = modelParts.join('/'); + + if (!provider || !model) { + return { provider: 'custom', model: modelId }; } - if (config.defaultAspectRatio) { - return { aspectRatio: config.defaultAspectRatio }; + + return { provider, model }; +}; + +const createGenericImageModelConfig = ( + modelId: string, + tags: readonly string[] = [] +): IImageModelConfig => ({ + ...splitModelId(modelId), + displayName: modelId, + sizeType: 'size', + supportedSizes: DEFAULT_IMAGE_SIZE_CANDIDATES, + defaultSize: '1024x1024', + supportsQuality: true, + modelType: 'image', + tags: mergeTags(tags), +}); + +const createPromptControlledImageModelConfig = ( + modelId: string, + tags: readonly string[] = [] +): IImageModelConfig => ({ + ...splitModelId(modelId), + displayName: modelId, + sizeType: 'flexible', + modelType: 'language', + tags: mergeTags(tags, [IMAGE_GENERATION_TAG]), +}); + +const getModelTypeFromMetadata = (metadata?: IImageModelMetadata) => + metadata?.modelType ?? metadata?.type; + +const getLegacyImageModelConfig = (modelId: string): IImageModelConfig | undefined => { + if (modelId.toLowerCase().includes('gemini')) { + return createPromptControlledImageModelConfig(modelId); } - return { size: '1024x1024' }; + return undefined; +}; + +export function getImageModelIdFromModelKey(modelKey?: string): string | undefined { + if (!modelKey) return undefined; + + const [type, model] = modelKey.split('@'); + if (!type || !model) return undefined; + + return type === AI_GATEWAY_MODEL_KEY_TYPE ? model : `${type}/${model}`; } -/** - * Convert aspect ratio to approximate size - */ -export function aspectRatioToSize(aspectRatio: IAspectRatio, baseSize = 1024): IImageSize { - const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); - const ratio = widthRatio / heightRatio; - - let width: number; - let height: number; - - if (ratio >= 1) { - width = baseSize; - height = Math.round(baseSize / ratio); - } else { - height = baseSize; - width = Math.round(baseSize * ratio); +export function getImageModelCatalogId(config: Pick) { + return `${config.provider}/${config.model}`; +} + +export function isPromptControlledImageGenerationModel(config: IImageModelConfig): boolean { + return config.modelType === 'language' && (config.tags?.includes(IMAGE_GENERATION_TAG) ?? false); +} + +export function supportsImageSizeSelection(config: IImageModelConfig): boolean { + return ( + config.modelType === 'image' && + ['size', 'both', 'flexible'].includes(config.sizeType) && + getImageSizeCandidates(config).length > 0 + ); +} + +export function supportsImageAspectRatioSelection(config: IImageModelConfig): boolean { + return ( + isPromptControlledImageGenerationModel(config) || + (['aspectRatio', 'both', 'flexible'].includes(config.sizeType) && + getImageAspectRatioCandidates(config).length > 0) + ); +} + +export function supportsImageCountSelection(config: IImageModelConfig): boolean { + return config.maxImagesPerCall !== 1; +} + +export function supportsImageInputForImageModel( + config: IImageModelConfig, + modelId = getImageModelCatalogId(config), + tags: readonly string[] = config.tags ?? [] +): boolean { + return ( + isPromptControlledImageGenerationModel(config) || + supportsImageInputForImageGeneration(modelId, tags) + ); +} + +export function supportsKnownImageInputForImageModel( + providerType: string | undefined, + model: string | undefined +): boolean { + if (!providerType || !model) return false; + + const config = + providerType === AI_GATEWAY_MODEL_KEY_TYPE + ? getImageModelConfigByGatewayId(model) + : getImageModelConfig(providerType, model); + if (!config) return false; + + return supportsImageInputForImageModel(config, getImageModelCatalogId(config), config.tags); +} + +export function getKnownImageModelAbility( + providerType: string | undefined, + model: string | undefined +): IImageModelAbility | undefined { + if (!providerType || !model) return undefined; + + const config = + providerType === AI_GATEWAY_MODEL_KEY_TYPE + ? getImageModelConfigByGatewayId(model) + : getImageModelConfig(providerType, model); + if (!config || !supportsImageGeneration(config.modelType, config.tags)) return undefined; + + return { + generation: true, + imageToImage: supportsImageInputForImageModel( + config, + getImageModelCatalogId(config), + config.tags + ), + }; +} + +export function getImageModelConfigByModelKey( + modelKey?: string, + gatewayModels: readonly IImageModelMetadata[] = [] +): IResolvedImageModelConfig | undefined { + const modelId = getImageModelIdFromModelKey(modelKey); + if (!modelId) return undefined; + + const gatewayModel = gatewayModels.find((model) => model.id === modelId); + const gatewayTags = gatewayModel?.tags ?? []; + const catalogConfig = getImageModelConfigByGatewayId(modelId); + if (catalogConfig) { + const tags = mergeTags(catalogConfig.tags, gatewayTags); + const config = { ...catalogConfig, tags }; + return { config, modelId: getImageModelCatalogId(config), tags }; + } + + const gatewayModelType = getModelTypeFromMetadata(gatewayModel); + if (gatewayModelType === 'language' && gatewayTags.includes(IMAGE_GENERATION_TAG)) { + const config = createPromptControlledImageModelConfig(modelId, gatewayTags); + return { config, modelId: getImageModelCatalogId(config), tags: config.tags ?? [] }; + } + + if (gatewayModelType === 'image') { + const config = createGenericImageModelConfig(modelId, gatewayTags); + return { config, modelId: getImageModelCatalogId(config), tags: config.tags ?? [] }; } - // Round to nearest multiple of 64 (common requirement for image models) - width = Math.round(width / 64) * 64; - height = Math.round(height / 64) * 64; + const legacyConfig = getLegacyImageModelConfig(modelId); + if (!legacyConfig) return undefined; - return `${width}x${height}` as IImageSize; + return { + config: legacyConfig, + modelId: getImageModelCatalogId(legacyConfig), + tags: legacyConfig.tags ?? [], + }; } diff --git a/packages/openapi/src/ai/image-model-dimensions.ts b/packages/openapi/src/ai/image-model-dimensions.ts new file mode 100644 index 0000000000..963837b108 --- /dev/null +++ b/packages/openapi/src/ai/image-model-dimensions.ts @@ -0,0 +1,475 @@ +import { z } from 'zod'; + +/** + * Full set of image-size literals that Teable's image model catalog can reference. + * + * Keep provider/model presets below as subsets of this tuple. Do not add `auto` + * here: `auto` is a provider behavior, represented by omitting the size option. + */ +export const ALL_IMAGE_SIZES = [ + // Square/common sizes. + '256x256', + '512x512', + '768x768', + '1024x1024', + '2048x2048', + // Landscape sizes. + '1024x768', + '1152x896', + '1216x832', + '1280x1024', + '1344x768', + '1365x1024', + '1434x1024', + '1536x640', + '1536x1024', + '1707x1024', + '1792x1024', + '1820x1024', + '2048x1024', + '2048x1152', + '3840x2160', + // Portrait sizes. + '768x1344', + '832x1216', + '896x1152', + '640x1536', + '1024x1280', + '1024x1344', + '1024x1365', + '1024x1434', + '1024x1536', + '1024x1707', + '1024x1792', + '1024x1820', + '1024x2048', + '2160x3840', +] as const; + +export const imageSizeSchema = z.enum(ALL_IMAGE_SIZES); + +export type IImageSize = z.infer; + +/** + * Full set of aspect-ratio literals accepted by catalog presets and range fallbacks. + * + * Keep this list provider-neutral. Provider-specific subsets belong in the preset + * constants below. `auto` is intentionally excluded for the same reason as sizes. + */ +export const ALL_ASPECT_RATIOS = [ + '1:1', + '2:3', + '3:2', + '3:4', + '4:3', + '4:5', + '5:4', + '9:16', + '16:9', + '9:21', + '21:9', + '1:9', + '2:1', + '1:2', + '19.5:9', + '9:19.5', + '20:9', + '9:20', + '1:4', + '4:1', + '3:7', + '7:3', +] as const; + +export const aspectRatioSchema = z.enum(ALL_ASPECT_RATIOS); + +export type IAspectRatio = z.infer; + +export interface IImageSizeRange { + min: number; + max: number; + multipleOf?: number; + maxPixels?: number; + notes?: string; +} + +export interface IImageAspectRatioRange { + min: IAspectRatio; + max: IAspectRatio; + notes?: string; +} + +export interface IDefaultImageDimensionConfig { + defaultSize?: IImageSize; + defaultAspectRatio?: IAspectRatio; +} + +export interface IImageDimensionConstraintConfig { + supportedSizes?: IImageSize[]; + supportedAspectRatios?: IAspectRatio[]; + sizeRange?: IImageSizeRange; + aspectRatioRange?: IImageAspectRatioRange; +} + +const imageSizePattern = /^\d+x\d+$/; +const aspectRatioPattern = /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/; + +/** + * Conservative fallback sizes shown for generic or unknown size-based image models. + */ +export const DEFAULT_IMAGE_SIZE_CANDIDATES = [ + '256x256', + '512x512', + '768x768', + '1024x1024', + '1536x1024', + '1024x1536', + '1792x1024', + '1024x1792', +] satisfies IImageSize[]; + +/** + * Finite UI candidates used when a provider exposes a numeric size range. + * + * The range validator still decides which of these candidates apply to a model. + */ +export const RANGE_IMAGE_SIZE_CANDIDATES = [ + ...DEFAULT_IMAGE_SIZE_CANDIDATES, + '1024x768', + '1152x896', + '1216x832', + '1280x1024', + '1344x768', + '1365x1024', + '1434x1024', + '1536x640', + '1707x1024', + '1820x1024', + '2048x1024', + '768x1344', + '832x1216', + '896x1152', + '640x1536', + '1024x1280', + '1024x1344', + '1024x1365', + '1024x1434', + '1024x1707', + '1024x1820', + '1024x2048', +] satisfies IImageSize[]; + +/** + * Fallback aspect-ratio candidates for range-backed models and generic UI state. + * + * This name is kept for compatibility with existing imports; it is not a model + * default. Specific models should prefer explicit provider presets below. + */ +export const DEFAULT_ASPECT_RATIO_CANDIDATES = [ + '1:1', + '16:9', + '9:16', + '4:3', + '3:4', + '21:9', + '9:21', + '3:2', + '2:3', + '2:1', + '1:2', + '1:4', + '4:1', +] satisfies IAspectRatio[]; + +/** + * Common aspect-ratio preset used by providers where the catalog does not need + * a more specific product subset. + */ +export const STANDARD_ASPECT_RATIOS = [ + '1:1', + '3:4', + '4:3', + '9:16', + '16:9', + '9:21', + '21:9', +] satisfies IAspectRatio[]; + +/** + * Wider common preset that includes additional portrait/landscape pairs. + */ +export const EXTENDED_ASPECT_RATIOS = [ + '1:1', + '2:3', + '3:2', + '3:4', + '4:3', + '4:5', + '5:4', + '9:16', + '16:9', + '9:21', + '21:9', +] satisfies IAspectRatio[]; + +/** + * OpenAI uses concrete size literals for pure image models. `auto` is handled as + * a separate provider option when/if the product exposes it. + */ +export const OPENAI_GPT_IMAGE_SIZES = [ + '1024x1024', + '1536x1024', + '1024x1536', +] satisfies IImageSize[]; +export const OPENAI_GPT_IMAGE_2_SIZES = [ + ...OPENAI_GPT_IMAGE_SIZES, + '2048x2048', + '2048x1152', + '3840x2160', + '2160x3840', +] satisfies IImageSize[]; +export const OPENAI_DALLE3_SIZES = ['1024x1024', '1792x1024', '1024x1792'] satisfies IImageSize[]; +export const OPENAI_DALLE2_SIZES = ['256x256', '512x512', '1024x1024'] satisfies IImageSize[]; + +export const GOOGLE_IMAGEN_ASPECT_RATIOS = [ + '1:1', + '3:4', + '4:3', + '9:16', + '16:9', +] satisfies IAspectRatio[]; + +/** + * Product-facing Gemini subset. + * + * The local AI SDK also accepts extreme ratios such as 1:8, 8:1, 1:4, and 4:1 + * for Gemini imageConfig. They are intentionally omitted until the product needs + * ultra-long images. + */ +export const GEMINI_IMAGE_ASPECT_RATIOS = [ + '1:1', + '2:3', + '3:2', + '3:4', + '4:3', + '4:5', + '5:4', + '9:16', + '16:9', + '21:9', +] satisfies IAspectRatio[]; + +export const XAI_GROK_ASPECT_RATIOS = [ + '1:1', + '16:9', + '9:16', + '4:3', + '3:4', + '3:2', + '2:3', + '2:1', + '1:2', + '19.5:9', + '9:19.5', + '20:9', + '9:20', +] satisfies IAspectRatio[]; + +export const DEEPINFRA_STABILITY_ASPECT_RATIOS = [ + '1:1', + '16:9', + '1:9', + '3:2', + '2:3', + '4:5', + '5:4', + '9:16', + '9:21', +] satisfies IAspectRatio[]; + +/** + * Shared FLUX aspect-ratio preset used by providers that expose the same list. + */ +export const FLUX_ASPECT_RATIOS = [ + '1:1', + '2:3', + '3:2', + '4:5', + '5:4', + '16:9', + '9:16', + '9:21', + '21:9', +] satisfies IAspectRatio[]; + +export const REPLICATE_FLUX_SCHNELL_ASPECT_RATIOS = FLUX_ASPECT_RATIOS; + +export const REPLICATE_RECRAFT_SIZES = [ + '1024x1024', + '1365x1024', + '1024x1365', + '1536x1024', + '1024x1536', + '1820x1024', + '1024x1820', + '1024x2048', + '2048x1024', + '1434x1024', + '1024x1434', + '1024x1280', + '1280x1024', + '1024x1707', + '1707x1024', +] satisfies IImageSize[]; + +export const FIREWORKS_FLUX_ASPECT_RATIOS = FLUX_ASPECT_RATIOS; + +export const FIREWORKS_1024_SIZES = [ + '640x1536', + '768x1344', + '832x1216', + '896x1152', + '1024x1024', + '1152x896', + '1216x832', + '1344x768', + '1536x640', +] satisfies IImageSize[]; + +export const TOGETHERAI_SQUARE_SIZES = ['512x512', '768x768', '1024x1024'] satisfies IImageSize[]; + +/** + * BFL supports both preset ratios and a continuous ratio range. + */ +export const BFL_ASPECT_RATIO_PRESETS = [ + '3:7', + '2:3', + '3:4', + '1:1', + '4:3', + '3:2', + '7:3', +] satisfies IAspectRatio[]; + +export const BFL_ASPECT_RATIO_RANGE = { + min: '3:7', + max: '7:3', + notes: 'From 3:7 (portrait) to 7:3 (landscape)', +} satisfies IImageAspectRatioRange; + +export const DIMENSION_RANGE_256_1440_MULTIPLE_32 = { + min: 256, + max: 1440, + multipleOf: 32, +} satisfies IImageSizeRange; + +export function isImageSizeSupported( + config: IImageDimensionConstraintConfig, + size?: string +): size is `${number}x${number}` { + if (!size || !imageSizePattern.test(size)) return false; + + if (config.supportedSizes?.includes(size as IImageSize)) { + return true; + } + + if (!config.sizeRange) return false; + + const [width, height] = size.split('x').map(Number); + const { min, max, multipleOf, maxPixels } = config.sizeRange; + + if (!width || !height || width < min || height < min || width > max || height > max) { + return false; + } + if (multipleOf && (width % multipleOf !== 0 || height % multipleOf !== 0)) { + return false; + } + if (maxPixels && width * height > maxPixels) { + return false; + } + + return true; +} + +const aspectRatioToNumber = (aspectRatio: string): number | undefined => { + const [width, height] = aspectRatio.split(':').map(Number); + if (!width || !height) return undefined; + return width / height; +}; + +export function isAspectRatioSupported( + config: IImageDimensionConstraintConfig, + aspectRatio?: string +): aspectRatio is `${number}:${number}` { + if (!aspectRatio || !aspectRatioPattern.test(aspectRatio)) return false; + + if (config.supportedAspectRatios?.includes(aspectRatio as IAspectRatio)) { + return true; + } + + if (!config.aspectRatioRange) return false; + + const ratio = aspectRatioToNumber(aspectRatio); + const minRatio = aspectRatioToNumber(config.aspectRatioRange.min); + const maxRatio = aspectRatioToNumber(config.aspectRatioRange.max); + if (!ratio || !minRatio || !maxRatio) return false; + + return ratio >= minRatio && ratio <= maxRatio; +} + +export function getImageSizeCandidates(config: IImageDimensionConstraintConfig): IImageSize[] { + if (config.supportedSizes?.length) return config.supportedSizes; + if (!config.sizeRange) return []; + return RANGE_IMAGE_SIZE_CANDIDATES.filter((size) => isImageSizeSupported(config, size)); +} + +export function getImageAspectRatioCandidates( + config: IImageDimensionConstraintConfig +): IAspectRatio[] { + if (config.supportedAspectRatios?.length) return config.supportedAspectRatios; + if (!config.aspectRatioRange) return []; + return DEFAULT_ASPECT_RATIO_CANDIDATES.filter((aspectRatio) => + isAspectRatioSupported(config, aspectRatio) + ); +} + +/** + * Get default size or aspect ratio for a model-like dimension config. + */ +export function getDefaultImageDimension(config: IDefaultImageDimensionConfig): { + size?: IImageSize; + aspectRatio?: IAspectRatio; +} { + if (config.defaultSize) { + return { size: config.defaultSize }; + } + if (config.defaultAspectRatio) { + return { aspectRatio: config.defaultAspectRatio }; + } + return { size: '1024x1024' }; +} + +/** + * Convert aspect ratio to approximate size. + */ +export function aspectRatioToSize(aspectRatio: IAspectRatio, baseSize = 1024): IImageSize { + const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); + const ratio = widthRatio / heightRatio; + + let width: number; + let height: number; + + if (ratio >= 1) { + width = baseSize; + height = Math.round(baseSize / ratio); + } else { + height = baseSize; + width = Math.round(baseSize * ratio); + } + + // Round to nearest multiple of 64 (common requirement for image models) + width = Math.round(width / 64) * 64; + height = Math.round(height / 64) * 64; + + return `${width}x${height}` as IImageSize; +} diff --git a/packages/openapi/src/ai/image-model-types.ts b/packages/openapi/src/ai/image-model-types.ts new file mode 100644 index 0000000000..07d403165e --- /dev/null +++ b/packages/openapi/src/ai/image-model-types.ts @@ -0,0 +1,48 @@ +import type { + IAspectRatio, + IImageAspectRatioRange, + IImageSize, + IImageSizeRange, +} from './image-model-dimensions'; + +/** + * Image model configuration interface. + */ +export interface IImageModelConfig { + /** Provider name */ + provider: string; + /** Model ID */ + model: string; + /** Display name */ + displayName?: string; + /** Whether the model uses sizes or aspect ratios */ + sizeType: 'size' | 'aspectRatio' | 'both' | 'flexible'; + /** Supported sizes (if sizeType is 'size' or 'both') */ + supportedSizes?: IImageSize[]; + /** Supported aspect ratios (if sizeType is 'aspectRatio' or 'both') */ + supportedAspectRatios?: IAspectRatio[]; + /** Whether the provider supports automatic aspect ratio selection */ + supportsAutoAspectRatio?: boolean; + /** Supported size range for models that accept arbitrary dimensions */ + sizeRange?: IImageSizeRange; + /** Supported aspect ratio range for models that accept arbitrary ratios */ + aspectRatioRange?: IImageAspectRatioRange; + /** Default size */ + defaultSize?: IImageSize; + /** Default aspect ratio */ + defaultAspectRatio?: IAspectRatio; + /** Maximum images per call */ + maxImagesPerCall?: number; + /** Whether the model supports quality parameter */ + supportsQuality?: boolean; + /** Whether the model supports style parameter */ + supportsStyle?: boolean; + /** Whether the model supports seed parameter */ + supportsSeed?: boolean; + /** Model type: 'image' for pure image models, 'language' for multimodal LLMs */ + modelType: 'image' | 'language'; + /** Tags for additional capabilities */ + tags?: string[]; + /** Additional notes */ + notes?: string; +} diff --git a/packages/openapi/src/base/get.ts b/packages/openapi/src/base/get.ts index b72bd32800..b46f674fb2 100644 --- a/packages/openapi/src/base/get.ts +++ b/packages/openapi/src/base/get.ts @@ -7,6 +7,23 @@ import { z } from '../zod'; export const GET_BASE = '/base/{baseId}'; +export const v2ReasonSchema = z.enum([ + 'env_force_v2_all', + 'config_force_v2_all', + 'new_base', + 'header_override', + 'space_feature', + 'unsupported_feature', + 'disabled', + 'feature_not_enabled', + 'no_feature', +]); + +export const baseV2StatusSchema = z.object({ + useV2: z.boolean(), + reason: v2ReasonSchema, +}); + export const getBaseItemSchema = z.object({ id: z.string(), name: z.string(), @@ -33,11 +50,13 @@ export const getBaseItemSchema = z.object({ }) .optional(), isCanary: z.boolean().optional(), + v2Status: baseV2StatusSchema.optional(), isShared: z.boolean().optional(), }); export const getBaseVoSchema = getBaseItemSchema; +export type IBaseV2StatusVo = z.infer; export type IGetBaseVo = z.infer; export const GetBaseRoute: RouteConfig = registerRoute({ diff --git a/packages/openapi/src/integrity/link-check.ts b/packages/openapi/src/integrity/link-check.ts index 5145d48b2b..32abc79861 100644 --- a/packages/openapi/src/integrity/link-check.ts +++ b/packages/openapi/src/integrity/link-check.ts @@ -18,6 +18,9 @@ export enum IntegrityIssueType { UniqueIndexNotFound = 'UniqueIndexNotFound', EmptyString = 'EmptyString', InvalidFilterOperator = 'InvalidFilterOperator', + InvalidPrimaryLookup = 'InvalidPrimaryLookup', + InvalidPrimaryType = 'InvalidPrimaryType', + MissingPrimary = 'MissingPrimary', } // Define the schema for a single issue diff --git a/packages/openapi/src/integrity/schema-v2.ts b/packages/openapi/src/integrity/schema-v2.ts index f4eaa439db..b7dea4ba47 100644 --- a/packages/openapi/src/integrity/schema-v2.ts +++ b/packages/openapi/src/integrity/schema-v2.ts @@ -64,6 +64,14 @@ export const v2SchemaIntegrityDetailsSchema = z.object({ ) .optional(), statementCount: z.number().optional(), + statements: z + .array( + z.object({ + sql: z.string(), + parameters: z.array(z.unknown()).optional(), + }) + ) + .optional(), }); export const v2SchemaIntegrityI18nMessageSchema = z.object({ @@ -138,6 +146,7 @@ export const v2SchemaIntegrityCheckStatusSchema = z.enum([ export const v2SchemaIntegrityCheckResultSchema = z.object({ id: z.string(), + baseId: z.string().optional(), tableId: z.string().optional(), tableName: z.string().optional(), fieldId: z.string(), @@ -174,6 +183,7 @@ export const v2SchemaIntegrityRepairOutcomeSchema = z.enum([ export const v2SchemaIntegrityRepairResultSchema = z.object({ id: z.string(), + baseId: z.string().optional(), tableId: z.string().optional(), tableName: z.string().optional(), fieldId: z.string(), diff --git a/packages/sdk/src/components/ReadOnlyTip.tsx b/packages/sdk/src/components/ReadOnlyTip.tsx index d51260e52c..ca101253b5 100644 --- a/packages/sdk/src/components/ReadOnlyTip.tsx +++ b/packages/sdk/src/components/ReadOnlyTip.tsx @@ -1,4 +1,4 @@ -import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@teable/ui-lib'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@teable/ui-lib'; import { Trans } from '../context/app/i18n'; import { usePersonalView, useView } from '../hooks'; @@ -19,17 +19,16 @@ export const ReadOnlyTip = () => {
- - + + ), }} diff --git a/packages/sdk/src/components/editor/attachment/upload-attachment/AttachmentItem.tsx b/packages/sdk/src/components/editor/attachment/upload-attachment/AttachmentItem.tsx index adea37e567..fbf731baf4 100644 --- a/packages/sdk/src/components/editor/attachment/upload-attachment/AttachmentItem.tsx +++ b/packages/sdk/src/components/editor/attachment/upload-attachment/AttachmentItem.tsx @@ -1,7 +1,7 @@ import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import type { IAttachmentItem } from '@teable/core'; -import { Download, Pencil, X } from '@teable/icons'; +import { Download, X } from '@teable/icons'; import { Button, cn, FilePreviewItem, isImage } from '@teable/ui-lib'; import { useCallback, useEffect, useRef, useState } from 'react'; import { EllipsisFileName } from '../../../upload/EllipsisFileName'; @@ -99,46 +99,35 @@ function AttachmentItem(props: IUploadAttachment) { /> )} - {!readonly && ( +
+ + {formatFileSize(attachment.size)} + - )} -
-
- {!readonly && ( - - )} + {!readonly && ( -
- - {formatFileSize(attachment.size)} - + )}
{isEditing ? ( @@ -161,8 +150,8 @@ function AttachmentItem(props: IUploadAttachment) { ) : (