From e59e33a7654f572de5de2f907eb1424843f27514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=A9=E7=BF=94=E5=AE=87?= Date: Sun, 1 Mar 2026 12:49:58 +0800 Subject: [PATCH 1/3] feat(models): show archived models with historical requests in registry Models that have been deleted but still have historical completion/embedding records now appear as grayed-out "Archived" entries in the global model registry. Users can click the history button to trace past requests. - Backend: `listUniqueSystemNames()` returns `{ active, archived }` using SQL UNION query on completions/embeddings tables - Frontend: new `ArchivedModelRow` component with archive icon, opacity, and strikethrough styling - i18n: added Archived/ArchivedTooltip keys for en-US and zh-CN Co-Authored-By: Claude Opus 4.6 --- backend/src/db/index.ts | 28 ++++++++- frontend/src/i18n/locales/en-US.json | 2 + frontend/src/i18n/locales/zh-CN.json | 2 + .../pages/settings/models-settings-page.tsx | 61 +++++++++++++++++-- frontend/src/routes/models/registry.tsx | 2 +- 5 files changed, 86 insertions(+), 9 deletions(-) diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index c433623..02d5ee8 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -947,11 +947,14 @@ export async function getModelsWithProviderBySystemName( /** * list unique system names (for global model registry) + * Returns active model names and archived names (deleted but referenced by historical requests) */ export async function listUniqueSystemNames( modelType?: ModelTypeEnumType, -): Promise { +): Promise<{ active: string[]; archived: string[] }> { logger.debug("listUniqueSystemNames", modelType); + + // Get active model names (non-deleted models with non-deleted providers) const r = await db .selectDistinct({ systemName: schema.ModelsTable.systemName }) .from(schema.ModelsTable) @@ -967,7 +970,28 @@ export async function listUniqueSystemNames( ), ) .orderBy(asc(schema.ModelsTable.systemName)); - return r.map((x) => x.systemName); + const active = r.map((x) => x.systemName); + + // Get archived model names: exist in completions/embeddings but not in active models + const archivedResult = await db.execute(sql` + SELECT DISTINCT model AS name FROM ( + SELECT DISTINCT model FROM completions WHERE deleted = false + UNION + SELECT DISTINCT model FROM embeddings WHERE deleted = false + ) AS all_models + WHERE model NOT IN ( + SELECT DISTINCT m.system_name + FROM models m + INNER JOIN providers p ON m.provider_id = p.id + WHERE m.deleted = false AND p.deleted = false + ) + ORDER BY name ASC + `); + const archived = (archivedResult as unknown as { name: string }[]).map( + (x) => x.name, + ); + + return { active, archived }; } /** diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index a1a08fa..4299bf8 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -331,6 +331,8 @@ "pages.models.registry.WeightConfiguration": "Weight Configuration", "pages.models.registry.History": "History", "pages.models.registry.ViewHistory": "View request history", + "pages.models.registry.Archived": "Archived", + "pages.models.registry.ArchivedTooltip": "This model has been deleted but has historical requests", "routes.models.Title": "Models", "routes.models.Description": "Configure model providers and global models.", "routes.models.nav.Providers": "Model Providers", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index ea02d0e..baa9aff 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -332,6 +332,8 @@ "pages.models.registry.WeightConfiguration": "权重配置", "pages.models.registry.History": "历史", "pages.models.registry.ViewHistory": "查看请求历史", + "pages.models.registry.Archived": "已归档", + "pages.models.registry.ArchivedTooltip": "该模型已删除但存在历史请求记录", "routes.models.Title": "模型", "routes.models.Description": "配置模型供应商和全局模型。", "routes.models.nav.Providers": "模型供应商", diff --git a/frontend/src/pages/settings/models-settings-page.tsx b/frontend/src/pages/settings/models-settings-page.tsx index cf8a007..3da296f 100644 --- a/frontend/src/pages/settings/models-settings-page.tsx +++ b/frontend/src/pages/settings/models-settings-page.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' -import { CpuIcon, HistoryIcon } from 'lucide-react' +import { ArchiveIcon, CpuIcon, HistoryIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -25,8 +25,14 @@ interface ModelWithProvider { } } -export function ModelsSettingsPage({ systemNames }: { systemNames: string[] }) { +interface ModelsSettingsPageProps { + activeSystemNames: string[] + archivedSystemNames: string[] +} + +export function ModelsSettingsPage({ activeSystemNames, archivedSystemNames }: ModelsSettingsPageProps) { const { t } = useTranslation() + const hasAny = activeSystemNames.length > 0 || archivedSystemNames.length > 0 return (
@@ -36,7 +42,7 @@ export function ModelsSettingsPage({ systemNames }: { systemNames: string[] }) { {t('pages.models.registry.Description')} - {systemNames.length > 0 ? ( + {hasAny ? ( @@ -47,9 +53,12 @@ export function ModelsSettingsPage({ systemNames }: { systemNames: string[] }) { - {systemNames.map((systemName) => ( + {activeSystemNames.map((systemName) => ( ))} + {archivedSystemNames.map((systemName) => ( + + ))}
) : ( @@ -69,7 +78,7 @@ function ModelRow({ systemName }: { systemName: string }) { const { data: models = [], isLoading } = useQuery({ queryKey: ['models', 'by-system-name', systemName], queryFn: async () => { - const { data, error } = await api.admin.models['by-system-name'][systemName].get() + const { data, error } = await api.admin.models['by-system-name'][encodeURIComponent(systemName)].get() if (error) throw error return data as ModelWithProvider[] }, @@ -157,6 +166,46 @@ function ModelRow({ systemName }: { systemName: string }) { ) } +function ArchivedModelRow({ systemName }: { systemName: string }) { + const { t } = useTranslation() + const navigate = useNavigate() + + const handleHistoryClick = (e: React.MouseEvent) => { + e.stopPropagation() + navigate({ to: '/requests', search: { model: systemName } }) + } + + return ( + + +
+ + {systemName} +
+
+ + - + + + + {t('pages.models.registry.Archived')} + + + + + +
+ ) +} + interface LoadBalancingDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -182,7 +231,7 @@ function LoadBalancingDialog({ open, onOpenChange, systemName, models }: LoadBal const updateWeightsMutation = useMutation({ mutationFn: async (weights: { modelId: number; weight: number }[]) => { - const { error } = await api.admin.models['by-system-name'][systemName].weights.put({ + const { error } = await api.admin.models['by-system-name'][encodeURIComponent(systemName)].weights.put({ weights, }) if (error) throw error diff --git a/frontend/src/routes/models/registry.tsx b/frontend/src/routes/models/registry.tsx index 7baabab..d281591 100644 --- a/frontend/src/routes/models/registry.tsx +++ b/frontend/src/routes/models/registry.tsx @@ -27,5 +27,5 @@ export const Route = createFileRoute('/models/registry')({ function RouteComponent() { const { data } = useSuspenseQuery(systemNamesQueryOptions()) - return + return } From 3eaea5d6f3331865dbe1dfbeca713f201ed62c92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=A9=E7=BF=94=E5=AE=87?= Date: Sun, 1 Mar 2026 13:00:22 +0800 Subject: [PATCH 2/3] fix(frontend): encode systemName in Eden Treaty path params Eden Treaty does not URL-encode dynamic path segments, causing 404 errors for model names containing slashes (e.g. `Qwen/Qwen3-8B`). Wrap all `by-system-name` path params with `encodeURIComponent()`. Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/models/models-registry-table.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/models/models-registry-table.tsx b/frontend/src/pages/models/models-registry-table.tsx index 4d0487e..6c8f67f 100644 --- a/frontend/src/pages/models/models-registry-table.tsx +++ b/frontend/src/pages/models/models-registry-table.tsx @@ -120,7 +120,7 @@ function ModelsBySystemNameTable({ systemName }: { systemName: string }) { const { data: models = [], isLoading } = useQuery({ queryKey: ['models', 'by-system-name', systemName], queryFn: async () => { - const { data, error } = await api.admin.models['by-system-name'][systemName].get() + const { data, error } = await api.admin.models['by-system-name'][encodeURIComponent(systemName)].get() if (error) throw error return data as ModelWithProvider[] }, @@ -128,7 +128,7 @@ function ModelsBySystemNameTable({ systemName }: { systemName: string }) { const updateWeightsMutation = useMutation({ mutationFn: async (weights: { modelId: number; weight: number }[]) => { - const { error } = await api.admin.models['by-system-name'][systemName].weights.put({ + const { error } = await api.admin.models['by-system-name'][encodeURIComponent(systemName)].weights.put({ weights, }) if (error) throw error From 2ad744d4e51dd4d609b9e9adf0dfbec3934b65f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=A9=E7=BF=94=E5=AE=87?= Date: Sun, 1 Mar 2026 15:18:46 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(models):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20archived=20modelType=20filtering=20and=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: archived query now respects `modelType` param and returns `{ systemName, modelType }` per archived entry (instead of plain string) - Backend: simplified SQL by fetching all historical names and filtering in TypeScript, avoiding duplicated active-model logic in NOT IN subquery - Frontend: `ArchivedModelRow` routes to `/embeddings` for embedding models instead of hardcoding `/requests` Co-Authored-By: Claude Opus 4.6 --- backend/src/db/index.ts | 49 ++++++++++++------- .../pages/settings/models-settings-page.tsx | 19 +++++-- 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 02d5ee8..076b4ab 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -951,7 +951,10 @@ export async function getModelsWithProviderBySystemName( */ export async function listUniqueSystemNames( modelType?: ModelTypeEnumType, -): Promise<{ active: string[]; archived: string[] }> { +): Promise<{ + active: string[]; + archived: { systemName: string; modelType: "chat" | "embedding" }[]; +}> { logger.debug("listUniqueSystemNames", modelType); // Get active model names (non-deleted models with non-deleted providers) @@ -971,25 +974,37 @@ export async function listUniqueSystemNames( ) .orderBy(asc(schema.ModelsTable.systemName)); const active = r.map((x) => x.systemName); + const activeSet = new Set(active); // Get archived model names: exist in completions/embeddings but not in active models - const archivedResult = await db.execute(sql` - SELECT DISTINCT model AS name FROM ( - SELECT DISTINCT model FROM completions WHERE deleted = false - UNION - SELECT DISTINCT model FROM embeddings WHERE deleted = false - ) AS all_models - WHERE model NOT IN ( - SELECT DISTINCT m.system_name - FROM models m - INNER JOIN providers p ON m.provider_id = p.id - WHERE m.deleted = false AND p.deleted = false - ) - ORDER BY name ASC + // Each model tagged with its type based on which table it comes from + const historicalResult = await db.execute(sql` + SELECT model AS name, 'chat' AS model_type FROM completions WHERE deleted = false + UNION + SELECT model AS name, 'embedding' AS model_type FROM embeddings WHERE deleted = false `); - const archived = (archivedResult as unknown as { name: string }[]).map( - (x) => x.name, - ); + const historicalRows = historicalResult as unknown as { + name: string; + model_type: "chat" | "embedding"; + }[]; + + // Filter out active models and respect modelType filter + const archivedMap = new Map< + string, + "chat" | "embedding" + >(); + for (const row of historicalRows) { + if (activeSet.has(row.name)) continue; + if (modelType && row.model_type !== modelType) continue; + // If a name appears in both tables, prefer 'chat' (it's the more common case) + if (!archivedMap.has(row.name)) { + archivedMap.set(row.name, row.model_type); + } + } + + const archived = Array.from(archivedMap.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([systemName, mt]) => ({ systemName, modelType: mt })); return { active, archived }; } diff --git a/frontend/src/pages/settings/models-settings-page.tsx b/frontend/src/pages/settings/models-settings-page.tsx index 3da296f..eb1be7f 100644 --- a/frontend/src/pages/settings/models-settings-page.tsx +++ b/frontend/src/pages/settings/models-settings-page.tsx @@ -25,9 +25,14 @@ interface ModelWithProvider { } } +interface ArchivedModel { + systemName: string + modelType: 'chat' | 'embedding' +} + interface ModelsSettingsPageProps { activeSystemNames: string[] - archivedSystemNames: string[] + archivedSystemNames: ArchivedModel[] } export function ModelsSettingsPage({ activeSystemNames, archivedSystemNames }: ModelsSettingsPageProps) { @@ -56,8 +61,8 @@ export function ModelsSettingsPage({ activeSystemNames, archivedSystemNames }: M {activeSystemNames.map((systemName) => ( ))} - {archivedSystemNames.map((systemName) => ( - + {archivedSystemNames.map((item) => ( + ))} @@ -166,13 +171,17 @@ function ModelRow({ systemName }: { systemName: string }) { ) } -function ArchivedModelRow({ systemName }: { systemName: string }) { +function ArchivedModelRow({ systemName, modelType }: { systemName: string; modelType: 'chat' | 'embedding' }) { const { t } = useTranslation() const navigate = useNavigate() const handleHistoryClick = (e: React.MouseEvent) => { e.stopPropagation() - navigate({ to: '/requests', search: { model: systemName } }) + if (modelType === 'embedding') { + navigate({ to: '/embeddings', search: { model: systemName } }) + } else { + navigate({ to: '/requests', search: { model: systemName } }) + } } return (