diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 5ac0e342e6..728d45f151 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -68,6 +68,9 @@ class AppListQuery(BaseModel): name: str | None = Field(default=None, description="Filter by app name") tag_ids: list[str] | None = Field(default=None, description="Comma-separated tag IDs") is_created_by_me: bool | None = Field(default=None, description="Filter by creator") + sort_by: Literal["created_at", "-created_at", "updated_at", "-updated_at", "name", "-name"] | None = Field( + default=None, description="Sort by field, prefix '-' for descending order" + ) @field_validator("tag_ids", mode="before") @classmethod diff --git a/api/migrations/versions/2026_03_24_1029-18d02aad1f89_add_indexes_for_apps_sorting.py b/api/migrations/versions/2026_03_24_1029-18d02aad1f89_add_indexes_for_apps_sorting.py new file mode 100644 index 0000000000..6610fc1826 --- /dev/null +++ b/api/migrations/versions/2026_03_24_1029-18d02aad1f89_add_indexes_for_apps_sorting.py @@ -0,0 +1,75 @@ +"""add indexes for apps sorting + +Revision ID: 18d02aad1f89 +Revises: 6b5f9f8b1a2c +Create Date: 2026-03-24 10:29:39.502252 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + + +revision = '18d02aad1f89' +down_revision = '6b5f9f8b1a2c' +branch_labels = None +depends_on = None + + +def _is_pg(conn) -> bool: + return conn.dialect.name == "postgresql" + + +def upgrade(): + conn = op.get_bind() + + if _is_pg(conn): + with op.get_context().autocommit_block(): + op.create_index( + "apps_tenant_created_idx", + "apps", + ["tenant_id", "created_at"], + postgresql_concurrently=True, + ) + op.create_index( + "apps_tenant_updated_idx", + "apps", + ["tenant_id", "updated_at"], + postgresql_concurrently=True, + ) + op.create_index( + "apps_tenant_name_idx", + "apps", + ["tenant_id", "name"], + postgresql_concurrently=True, + ) + else: + op.create_index( + "apps_tenant_created_idx", + "apps", + ["tenant_id", "created_at"], + ) + op.create_index( + "apps_tenant_updated_idx", + "apps", + ["tenant_id", "updated_at"], + ) + op.create_index( + "apps_tenant_name_idx", + "apps", + ["tenant_id", "name"], + ) + + +def downgrade(): + conn = op.get_bind() + + if _is_pg(conn): + with op.get_context().autocommit_block(): + op.drop_index("apps_tenant_created_idx", postgresql_concurrently=True) + op.drop_index("apps_tenant_updated_idx", postgresql_concurrently=True) + op.drop_index("apps_tenant_name_idx", postgresql_concurrently=True) + else: + op.drop_index("apps_tenant_created_idx", table_name="apps") + op.drop_index("apps_tenant_updated_idx", table_name="apps") + op.drop_index("apps_tenant_name_idx", table_name="apps") diff --git a/api/services/app_service.py b/api/services/app_service.py index c5d1479a20..240b57b6f6 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -60,7 +60,6 @@ class AppService: name = args["name"][:30] escaped_name = escape_like_pattern(name) filters.append(App.name.ilike(f"%{escaped_name}%", escape="\\")) - # Check if tag_ids is not empty to avoid WHERE false condition if args.get("tag_ids") and len(args["tag_ids"]) > 0: target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, args["tag_ids"]) if target_ids and len(target_ids) > 0: @@ -68,8 +67,19 @@ class AppService: else: return None + sort_by = args.get("sort_by", "-created_at") + order_by_map = { + "created_at": App.created_at.asc(), + "-created_at": App.created_at.desc(), + "updated_at": App.updated_at.asc(), + "-updated_at": App.updated_at.desc(), + "name": App.name.asc(), + "-name": App.name.desc(), + } + order_by = order_by_map.get(sort_by, App.created_at.desc()) + app_models = db.paginate( - sa.select(App).where(*filters).order_by(App.created_at.desc()), + sa.select(App).where(*filters).order_by(order_by), page=args["page"], per_page=args["limit"], error_out=False, diff --git a/web/app/components/apps/hooks/use-apps-query-state.ts b/web/app/components/apps/hooks/use-apps-query-state.ts index ecf7707e8a..7a8dfd3164 100644 --- a/web/app/components/apps/hooks/use-apps-query-state.ts +++ b/web/app/components/apps/hooks/use-apps-query-state.ts @@ -1,10 +1,15 @@ import { parseAsArrayOf, parseAsBoolean, parseAsString, useQueryStates } from 'nuqs' import { useCallback, useMemo } from 'react' +export type AppSortField = 'created_at' | 'updated_at' | 'name' + +export type AppSortBy = `${AppSortField}` | `-${AppSortField}` + type AppsQuery = { tagIDs?: string[] keywords?: string isCreatedByMe?: boolean + sortBy?: AppSortBy } const normalizeKeywords = (value: string | null) => value || undefined @@ -15,6 +20,7 @@ function useAppsQueryState() { tagIDs: parseAsArrayOf(parseAsString, ';'), keywords: parseAsString, isCreatedByMe: parseAsBoolean, + sortBy: parseAsString, }, { history: 'push', @@ -25,7 +31,8 @@ function useAppsQueryState() { tagIDs: urlQuery.tagIDs ?? undefined, keywords: normalizeKeywords(urlQuery.keywords), isCreatedByMe: urlQuery.isCreatedByMe ?? false, - }), [urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs]) + sortBy: (urlQuery.sortBy as AppSortBy) || '-created_at', + }), [urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs, urlQuery.sortBy]) const setQuery = useCallback((next: AppsQuery | ((prev: AppsQuery) => AppsQuery)) => { const buildPatch = (patch: AppsQuery) => { @@ -36,6 +43,8 @@ function useAppsQueryState() { result.keywords = patch.keywords ? patch.keywords : null if ('isCreatedByMe' in patch) result.isCreatedByMe = patch.isCreatedByMe ? true : null + if ('sortBy' in patch) + result.sortBy = patch.sortBy || '-created_at' return result } @@ -44,6 +53,7 @@ function useAppsQueryState() { tagIDs: prev.tagIDs ?? undefined, keywords: normalizeKeywords(prev.keywords), isCreatedByMe: prev.isCreatedByMe ?? false, + sortBy: (prev.sortBy as AppSortBy) || '-created_at', }))) return } diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 0d52bd468c..60ff73cce1 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -1,11 +1,13 @@ 'use client' import type { FC } from 'react' +import type { AppSortBy, AppSortField } from '@/app/components/apps/hooks/use-apps-query-state' import { useDebounceFn } from 'ahooks' import { parseAsStringLiteral, useQueryState } from 'nuqs' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' +import Sort from '@/app/components/base/sort' import TabSliderNew from '@/app/components/base/tab-slider-new' import TagFilter from '@/app/components/base/tag-management/filter' import { useStore as useTagStore } from '@/app/components/base/tag-management/store' @@ -60,8 +62,9 @@ const List: FC = ({ parseAsAppListCategory, ) - const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState() + const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false, sortBy: querySortBy = '-created_at' }, setQuery } = useAppsQueryState() const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe) + const [sortBy, setSortBy] = useState(querySortBy) const [tagFilterValue, setTagFilterValue] = useState(tagIDs) const [searchKeywords, setSearchKeywords] = useState(keywords) const newAppCardRef = useRef(null) @@ -74,6 +77,11 @@ const List: FC = ({ const setTagIDs = useCallback((tagIDs: string[]) => { setQuery(prev => ({ ...prev, tagIDs })) }, [setQuery]) + const handleSortChange = useCallback((value: string) => { + const newSortBy = value as AppSortBy + setSortBy(newSortBy) + setQuery(prev => ({ ...prev, sortBy: newSortBy })) + }, [setQuery]) const handleDSLFileDropped = useCallback((file: File) => { setDroppedDSLFile(file) @@ -92,6 +100,7 @@ const List: FC = ({ name: searchKeywords, tag_ids: tagIDs, is_created_by_me: isCreatedByMe, + sort_by: sortBy, ...(activeTab !== 'all' ? { mode: activeTab } : {}), } @@ -212,6 +221,16 @@ const List: FC = ({ onChange={handleCreatedByMeChange} /> + = ({ -
onSelect(`${order ? '' : '-'}${value}`)}> +
onSelect(`${order ? '' : '-'}${value}`)}> {!order && } {order && }
diff --git a/web/i18n/de-DE/app.json b/web/i18n/de-DE/app.json index 8af6239c47..36ea189619 100644 --- a/web/i18n/de-DE/app.json +++ b/web/i18n/de-DE/app.json @@ -201,6 +201,9 @@ "removeOriginal": "Ursprüngliche App löschen", "roadmap": "Sehen Sie unseren Fahrplan", "showMyCreatedAppsOnly": "Nur meine erstellten Apps anzeigen", + "sortBy.createdAt": "Erstellt am", + "sortBy.name": "App-Name", + "sortBy.updatedAt": "Bearbeitet am", "structOutput.LLMResponse": "LLM-Antwort", "structOutput.configure": "Konfigurieren", "structOutput.modelNotSupported": "Modell nicht unterstützt", diff --git a/web/i18n/en-US/app.json b/web/i18n/en-US/app.json index f399c5961d..deb6090da8 100644 --- a/web/i18n/en-US/app.json +++ b/web/i18n/en-US/app.json @@ -201,6 +201,9 @@ "removeOriginal": "Delete the original app", "roadmap": "See our roadmap", "showMyCreatedAppsOnly": "Created by me", + "sortBy.createdAt": "Created", + "sortBy.name": "AppName", + "sortBy.updatedAt": "Edited", "structOutput.LLMResponse": "LLM Response", "structOutput.configure": "Configure", "structOutput.modelNotSupported": "Model not supported", diff --git a/web/i18n/es-ES/app.json b/web/i18n/es-ES/app.json index 5dece4801f..9890831e53 100644 --- a/web/i18n/es-ES/app.json +++ b/web/i18n/es-ES/app.json @@ -201,6 +201,9 @@ "removeOriginal": "Eliminar la app original", "roadmap": "Ver nuestro plan de desarrollo", "showMyCreatedAppsOnly": "Mostrar solo mis aplicaciones creadas", + "sortBy.createdAt": "Creado", + "sortBy.name": "Nombre de la app", + "sortBy.updatedAt": "Editado", "structOutput.LLMResponse": "Respuesta del LLM", "structOutput.configure": "Configurar", "structOutput.modelNotSupported": "Modelo no soportado", diff --git a/web/i18n/fr-FR/app.json b/web/i18n/fr-FR/app.json index 056aa5be0a..447d4cfa04 100644 --- a/web/i18n/fr-FR/app.json +++ b/web/i18n/fr-FR/app.json @@ -201,6 +201,9 @@ "removeOriginal": "Supprimer l'application d'origine", "roadmap": "Voir notre feuille de route", "showMyCreatedAppsOnly": "Afficher uniquement mes applications créées", + "sortBy.createdAt": "Créé le", + "sortBy.name": "Nom de l'application", + "sortBy.updatedAt": "Modifié le", "structOutput.LLMResponse": "Réponse LLM", "structOutput.configure": "Configurer", "structOutput.modelNotSupported": "Modèle non pris en charge", diff --git a/web/i18n/hi-IN/app.json b/web/i18n/hi-IN/app.json index a6b3bbe446..49677e1fed 100644 --- a/web/i18n/hi-IN/app.json +++ b/web/i18n/hi-IN/app.json @@ -201,6 +201,9 @@ "removeOriginal": "मूल ऐप हटाएँ", "roadmap": "हमारा रोडमैप देखें", "showMyCreatedAppsOnly": "केवल मेरे बनाए गए ऐप्स दिखाएं", + "sortBy.createdAt": "निर्मित", + "sortBy.name": "ऐप का नाम", + "sortBy.updatedAt": "संपादित", "structOutput.LLMResponse": "LLM प्रतिक्रिया", "structOutput.configure": "कॉन्फ़िगर करें", "structOutput.modelNotSupported": "मॉडल का समर्थन नहीं किया गया", diff --git a/web/i18n/it-IT/app.json b/web/i18n/it-IT/app.json index 0364768909..d2dd449e9d 100644 --- a/web/i18n/it-IT/app.json +++ b/web/i18n/it-IT/app.json @@ -201,6 +201,9 @@ "removeOriginal": "Elimina l'app originale", "roadmap": "Vedi la nostra roadmap", "showMyCreatedAppsOnly": "Mostra solo le mie app create", + "sortBy.createdAt": "Creato il", + "sortBy.name": "Nome dell'app", + "sortBy.updatedAt": "Modificato il", "structOutput.LLMResponse": "LLM Risposta", "structOutput.configure": "Configura", "structOutput.modelNotSupported": "Modello non supportato", diff --git a/web/i18n/ja-JP/app.json b/web/i18n/ja-JP/app.json index ca34df1b3f..dcdc6bc652 100644 --- a/web/i18n/ja-JP/app.json +++ b/web/i18n/ja-JP/app.json @@ -201,6 +201,9 @@ "removeOriginal": "元のアプリを削除する", "roadmap": "ロードマップを見る", "showMyCreatedAppsOnly": "自分が作成したアプリ", + "sortBy.createdAt": "作成時間", + "sortBy.name": "アプリ名", + "sortBy.updatedAt": "編集時間", "structOutput.LLMResponse": "LLM のレスポンス", "structOutput.configure": "設定", "structOutput.modelNotSupported": "モデルが対応していません", diff --git a/web/i18n/ko-KR/app.json b/web/i18n/ko-KR/app.json index a13699442b..61b11fc756 100644 --- a/web/i18n/ko-KR/app.json +++ b/web/i18n/ko-KR/app.json @@ -201,6 +201,9 @@ "removeOriginal": "원본 앱 제거하기", "roadmap": "로드맵 보기", "showMyCreatedAppsOnly": "내가 만든 앱만 보기", + "sortBy.createdAt": "생성일", + "sortBy.name": "앱 이름", + "sortBy.updatedAt": "편집일", "structOutput.LLMResponse": "LLM 응답", "structOutput.configure": "설정", "structOutput.modelNotSupported": "모델이 지원되지 않습니다.", diff --git a/web/i18n/pl-PL/app.json b/web/i18n/pl-PL/app.json index 9539db9a58..9c56bd684c 100644 --- a/web/i18n/pl-PL/app.json +++ b/web/i18n/pl-PL/app.json @@ -201,6 +201,9 @@ "removeOriginal": "Usuń oryginalną aplikację", "roadmap": "Zobacz naszą mapę drogową", "showMyCreatedAppsOnly": "Pokaż tylko moje utworzone aplikacje", + "sortBy.createdAt": "Utworzono", + "sortBy.name": "Nazwa aplikacji", + "sortBy.updatedAt": "Edytowano", "structOutput.LLMResponse": "Odpowiedź LLM", "structOutput.configure": "Konfiguruj", "structOutput.modelNotSupported": "Model nie jest obsługiwany", diff --git a/web/i18n/pt-BR/app.json b/web/i18n/pt-BR/app.json index 9d6fd0b52c..9a2960fd80 100644 --- a/web/i18n/pt-BR/app.json +++ b/web/i18n/pt-BR/app.json @@ -201,6 +201,9 @@ "removeOriginal": "Excluir o aplicativo original", "roadmap": "Veja nosso roteiro", "showMyCreatedAppsOnly": "Mostrar apenas meus aplicativos criados", + "sortBy.createdAt": "Criado em", + "sortBy.name": "Nome do aplicativo", + "sortBy.updatedAt": "Editado em", "structOutput.LLMResponse": "Resposta do LLM", "structOutput.configure": "Configurar", "structOutput.modelNotSupported": "Modelo não suportado", diff --git a/web/i18n/ru-RU/app.json b/web/i18n/ru-RU/app.json index 8f275934c2..2e1c4c08b5 100644 --- a/web/i18n/ru-RU/app.json +++ b/web/i18n/ru-RU/app.json @@ -201,6 +201,9 @@ "removeOriginal": "Удалить исходное приложение", "roadmap": "Посмотреть наш roadmap", "showMyCreatedAppsOnly": "Показать только созданные мной приложения", + "sortBy.createdAt": "Создано", + "sortBy.name": "Название приложения", + "sortBy.updatedAt": "Изменено", "structOutput.LLMResponse": "Ответ LLM", "structOutput.configure": "Настроить", "structOutput.modelNotSupported": "Модель не поддерживается", diff --git a/web/i18n/tr-TR/app.json b/web/i18n/tr-TR/app.json index bf17583c47..63e1e1dbed 100644 --- a/web/i18n/tr-TR/app.json +++ b/web/i18n/tr-TR/app.json @@ -201,6 +201,9 @@ "removeOriginal": "Orijinal uygulamayı sil", "roadmap": "Yol haritamızı görün", "showMyCreatedAppsOnly": "Sadece oluşturduğum uygulamaları göster", + "sortBy.createdAt": "Oluşturulma", + "sortBy.name": "Uygulama adı", + "sortBy.updatedAt": "Düzenlenme", "structOutput.LLMResponse": "LLM Yanıtı", "structOutput.configure": "Yapılandır", "structOutput.modelNotSupported": "Model desteklenmiyor", diff --git a/web/i18n/uk-UA/app.json b/web/i18n/uk-UA/app.json index 9633000fea..2a5c6cdabe 100644 --- a/web/i18n/uk-UA/app.json +++ b/web/i18n/uk-UA/app.json @@ -201,6 +201,9 @@ "removeOriginal": "Видалити початковий додаток", "roadmap": "Переглянути наш план розвитку", "showMyCreatedAppsOnly": "Показати лише створені мною додатки", + "sortBy.createdAt": "Створено", + "sortBy.name": "Назва додатку", + "sortBy.updatedAt": "Змінено", "structOutput.LLMResponse": "Відповідь ЛЛМ", "structOutput.configure": "Налаштувати", "structOutput.modelNotSupported": "Модель не підтримується", diff --git a/web/i18n/zh-Hans/app.json b/web/i18n/zh-Hans/app.json index 92c5f15c79..bb129e2fee 100644 --- a/web/i18n/zh-Hans/app.json +++ b/web/i18n/zh-Hans/app.json @@ -201,7 +201,10 @@ "removeOriginal": "删除原应用", "roadmap": "产品路线图", "showMyCreatedAppsOnly": "我创建的", - "structOutput.LLMResponse": "LLM 的响应", + "sortBy.createdAt": "创建时间", + "sortBy.name": "应用名称", + "sortBy.updatedAt": "编辑时间", + "structOutput.LLMResponse": "LLM 回复", "structOutput.configure": "配置", "structOutput.modelNotSupported": "模型不支持", "structOutput.modelNotSupportedTip": "当前模型不支持此功能,将自动降级为提示注入。", diff --git a/web/i18n/zh-Hant/app.json b/web/i18n/zh-Hant/app.json index 0b7e9691a9..4ce194c87f 100644 --- a/web/i18n/zh-Hant/app.json +++ b/web/i18n/zh-Hant/app.json @@ -201,6 +201,9 @@ "removeOriginal": "刪除原應用", "roadmap": "產品路線圖", "showMyCreatedAppsOnly": "我建立的", + "sortBy.createdAt": "建立時間", + "sortBy.name": "應用程式名稱", + "sortBy.updatedAt": "編輯時間", "structOutput.LLMResponse": "LLM 回應", "structOutput.configure": "配置", "structOutput.modelNotSupported": "模型不支持", diff --git a/web/service/use-apps.ts b/web/service/use-apps.ts index f9f63205e4..a475781fe9 100644 --- a/web/service/use-apps.ts +++ b/web/service/use-apps.ts @@ -1,4 +1,5 @@ import type { GeneratorType } from '@/app/components/app/configuration/config/automatic/types' +import type { AppSortBy } from '@/app/components/apps/hooks/use-apps-query-state' import type { ApiKeysListResponse, AppDailyConversationsResponse, @@ -32,6 +33,7 @@ type AppListParams = { mode?: AppModeEnum | 'all' tag_ids?: string[] is_created_by_me?: boolean + sort_by?: AppSortBy } type DateRangeParams = { @@ -57,6 +59,7 @@ const normalizeAppListParams = (params: AppListParams) => { mode, tag_ids, is_created_by_me, + sort_by, } = params const safeMode = allowedModes.has((mode as any)) ? mode : undefined @@ -68,6 +71,7 @@ const normalizeAppListParams = (params: AppListParams) => { ...(safeMode && safeMode !== 'all' ? { mode: safeMode } : {}), ...(tag_ids?.length ? { tag_ids } : {}), ...(is_created_by_me ? { is_created_by_me } : {}), + ...(sort_by ? { sort_by } : {}), } }