feat: add AI catalog description generation

This commit is contained in:
Codex
2026-08-29 16:42:56 +02:00
parent b0afba81ca
commit 376dd5a09d
76 changed files with 14860 additions and 102 deletions
+160 -16
View File
@@ -6,7 +6,7 @@ import {
useState,
} from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, RefreshCw } from "lucide-react";
import { History, Plus, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { ApiError, apiErrorMessage } from "../api/client";
@@ -15,8 +15,11 @@ import {
deleteCatalogDatabase,
deleteCatalogDatabaseMetadata,
listCatalogDatabases,
listDescriptionGenerationRuns,
listMetadataGenerationModels,
replaceCatalogDatabaseSecrets,
startCatalogSync,
startDescriptionGenerationRun,
testCatalogDatabase,
updateCatalogDatabase,
type CatalogDatabase,
@@ -26,12 +29,15 @@ import {
type CatalogSyncRun,
type DatabaseBinding,
type DatabaseTransport,
type DescriptionGenerationRun,
} from "../api/catalog-databases";
import { DatabaseGrid } from "./database-management/DatabaseGrid";
import { DatabaseForm } from "./database-management/DatabaseForm";
import { DatabaseTables } from "./database-management/DatabaseTables";
import { DatabaseRelationships } from "./database-management/DatabaseRelationships";
import { CatalogSyncDrawer } from "./database-management/CatalogSyncDrawer";
import { MetadataGenerationModelSelector } from "./database-management/MetadataGenerationModelSelector";
import { DescriptionGenerationDrawer } from "./database-management/DescriptionGenerationDrawer";
import {
configurationFingerprint,
configurationFromDraft,
@@ -46,6 +52,8 @@ import {
} from "./database-management/model";
const DATABASE_QUERY_KEY = ["catalog-databases"] as const;
const DESCRIPTION_GENERATION_HISTORY_QUERY_KEY = ["description-generation-runs", 50] as const;
const DESCRIPTION_GENERATION_ACTIVE_STATUSES = ["queued", "running"] as const;
const SYNC_STARTED_MESSAGES: Record<CatalogSyncScope, string> = {
tables: "Table synchronization started",
columns: "Column synchronization started",
@@ -68,6 +76,12 @@ function isStaleError(error: unknown): boolean {
return error instanceof ApiError && error.code === "database_stale";
}
function isDescriptionGenerationActive(run?: DescriptionGenerationRun | null): boolean {
return Boolean(run && DESCRIPTION_GENERATION_ACTIVE_STATUSES.some(
(status) => status === run.status,
));
}
export function DatabaseManagementPage({
canManage,
canManageSecrets,
@@ -86,8 +100,31 @@ export function DatabaseManagementPage({
retry: false,
});
const rows = data ?? [];
const metadataModelsQuery = useQuery({
queryKey: ["metadata-generation-models"],
queryFn: listMetadataGenerationModels,
retry: false,
});
const metadataModels = metadataModelsQuery.data?.models ?? [];
const configuredMetadataDefault = metadataModelsQuery.data?.default ?? "";
const defaultMetadataModel = configuredMetadataDefault
&& metadataModels.some((model) => model.id === configuredMetadataDefault)
? configuredMetadataDefault
: "";
const descriptionGenerationRunsQuery = useQuery({
queryKey: DESCRIPTION_GENERATION_HISTORY_QUERY_KEY,
queryFn: () => listDescriptionGenerationRuns(50),
enabled: canManage,
retry: false,
refetchInterval: 5_000,
});
const descriptionGenerationRuns = descriptionGenerationRunsQuery.data ?? [];
const observedActiveDescriptionGenerationRun = descriptionGenerationRuns.find(
(run) => isDescriptionGenerationActive(run),
);
const [screen, setScreen] = useState<DatabaseScreen>({ kind: "list" });
const [selectedMetadataModel, setSelectedMetadataModel] = useState("");
const [draft, setDraft] = useState<DatabaseFormDraft | null>(null);
const [baseline, setBaseline] = useState("");
const [formSource, setFormSource] = useState<FormSource | null>(null);
@@ -102,11 +139,17 @@ export function DatabaseManagementPage({
});
const [activeSyncRun, setActiveSyncRun] = useState<CatalogSyncRun | null>(null);
const [syncDrawerOpen, setSyncDrawerOpen] = useState(false);
const [activeDescriptionGenerationRun, setActiveDescriptionGenerationRun] = useState<DescriptionGenerationRun | null>(null);
const [descriptionGenerationDrawerOpen, setDescriptionGenerationDrawerOpen] = useState(false);
const originRef = useRef<HTMLElement | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const formHeadingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
setSelectedMetadataModel(defaultMetadataModel);
}, [defaultMetadataModel]);
const activeRow = screen.kind === "list"
? undefined
: rows.find((row) => row.workspaceId === screen.workspaceId);
@@ -556,6 +599,36 @@ export function DatabaseManagementPage({
setSyncDrawerOpen(true);
}, [updateTrackedSyncRun]);
const rememberDescriptionGenerationRun = useCallback((run: DescriptionGenerationRun) => {
setActiveDescriptionGenerationRun(run);
queryClient.setQueryData<DescriptionGenerationRun[]>(
DESCRIPTION_GENERATION_HISTORY_QUERY_KEY,
(current = []) => [run, ...current.filter((item) => item.id !== run.id)],
);
setDescriptionGenerationDrawerOpen(true);
}, [queryClient]);
const openDescriptionGenerationHistory = useCallback(() => {
const run = observedActiveDescriptionGenerationRun
?? descriptionGenerationRuns[0]
?? activeDescriptionGenerationRun;
if (!run) return;
setActiveDescriptionGenerationRun(run);
setDescriptionGenerationDrawerOpen(true);
}, [activeDescriptionGenerationRun, descriptionGenerationRuns, observedActiveDescriptionGenerationRun]);
const descriptionGenerationTerminated = useCallback(async (run: DescriptionGenerationRun) => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: ["catalog-tables", run.databaseId],
exact: true,
}),
queryClient.invalidateQueries({
queryKey: ["catalog-columns", run.databaseId],
}),
]);
}, [queryClient]);
const openSync = useCallback((row?: CatalogDatabase) => {
const run = row?.activeSyncRun ?? activeSyncRun;
if (!run) return;
@@ -590,6 +663,26 @@ export function DatabaseManagementPage({
}
}, [queryClient, rememberSyncRun]);
const generateDatabaseDescriptions = useCallback(async (
selected: CatalogDatabase[],
scope: "all" | "missing",
) => {
const database = selected.length === 1 ? selected[0] : undefined;
if (!database?.id || !selectedMetadataModel) return;
try {
const run = await startDescriptionGenerationRun(
database.id,
selectedMetadataModel,
scope,
);
rememberDescriptionGenerationRun(run);
toast.success(`${scope === "all" ? "Generate All" : "Generate Missing"} started for ${database.workspaceName}`);
} catch (error) {
toast.error(apiErrorMessage(error));
throw error;
}
}, [rememberDescriptionGenerationRun, selectedMetadataModel]);
const deleteSelectedMetadata = useCallback(async (
selected: CatalogDatabase[],
target: CatalogDatabaseMetadataDeleteTarget,
@@ -640,6 +733,16 @@ export function DatabaseManagementPage({
: activeRow?.activeSyncRun && ["queued", "running", "awaiting_confirmation", "applying"].includes(activeRow.activeSyncRun.state)
? activeRow.activeSyncRun
: undefined;
const selectedMetadataModelAvailable = Boolean(
selectedMetadataModel
&& metadataModels.some((model) => model.id === selectedMetadataModel),
);
const descriptionGenerationActive = isDescriptionGenerationActive(
observedActiveDescriptionGenerationRun ?? activeDescriptionGenerationRun,
);
const descriptionGenerationHistoryAvailable = Boolean(
descriptionGenerationRuns.length > 0 || activeDescriptionGenerationRun,
);
return (
<main aria-label="Database management" className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
@@ -651,21 +754,46 @@ export function DatabaseManagementPage({
One database configuration for each repository workspace.
</p>
</div>
{screen.kind === "list" ? (
<div className="flex items-center gap-2">
<Button type="button" variant="outline" disabled={isFetching} onClick={() => void refreshList()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} /> Refresh
</Button>
<Button
type="button"
disabled={!canManage || availableWorkspaces.length === 0}
title={availableWorkspaces.length === 0 ? "Every available workspace is already configured" : undefined}
onClick={(event) => addDatabase(event.currentTarget)}
>
<Plus /> Add database
</Button>
</div>
) : null}
<div className="flex flex-wrap items-end justify-end gap-3">
<MetadataGenerationModelSelector
canManage={canManage}
data={metadataModelsQuery.data}
isError={metadataModelsQuery.isError}
isLoading={metadataModelsQuery.isLoading}
selectedModel={selectedMetadataModel}
onSelectedModelChange={setSelectedMetadataModel}
/>
<Button
type="button"
variant="outline"
aria-label={observedActiveDescriptionGenerationRun
? "Observe active description generation"
: "View description generation history"}
disabled={!canManage || !descriptionGenerationHistoryAvailable}
title={!descriptionGenerationHistoryAvailable && !descriptionGenerationRunsQuery.isLoading
? "No description generation runs yet"
: undefined}
onClick={openDescriptionGenerationHistory}
>
<History />
{observedActiveDescriptionGenerationRun ? "Active run" : "Run history"}
</Button>
{screen.kind === "list" ? (
<div className="flex items-center gap-2">
<Button type="button" variant="outline" disabled={isFetching} onClick={() => void refreshList()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} /> Refresh
</Button>
<Button
type="button"
disabled={!canManage || availableWorkspaces.length === 0}
title={availableWorkspaces.length === 0 ? "Every available workspace is already configured" : undefined}
onClick={(event) => addDatabase(event.currentTarget)}
>
<Plus /> Add database
</Button>
</div>
) : null}
</div>
</header>
<div className="relative flex min-h-0 flex-1">
@@ -701,6 +829,9 @@ export function DatabaseManagementPage({
onOpenSync={openSync}
onTestSelected={testSelected}
onSyncSelected={syncSelected}
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
descriptionGenerationActive={descriptionGenerationActive}
onGenerateDescriptions={generateDatabaseDescriptions}
onDeleteMetadataSelected={deleteSelectedMetadata}
/>
)}
@@ -746,12 +877,15 @@ export function DatabaseManagementPage({
database={activeRow}
canManage={canManage}
activeRun={currentActiveRun}
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
descriptionGenerationActive={descriptionGenerationActive}
onBackToDatabases={showList}
onOpenOverview={() => openOverview(activeRow)}
onOpenRelationships={() => openRelationships(activeRow)}
onNavigationStateChange={setTablesNavigationState}
onRunStarted={rememberSyncRun}
onOpenSync={() => openSync(activeRow)}
onDescriptionGenerationRunStarted={rememberDescriptionGenerationRun}
/>
) : null}
@@ -776,6 +910,16 @@ export function DatabaseManagementPage({
onRunUpdate={updateTrackedSyncRun}
onCatalogChanged={() => void catalogChanged()}
/>
<DescriptionGenerationDrawer
open={descriptionGenerationDrawerOpen}
run={activeDescriptionGenerationRun}
modelLabel={metadataModels.find(
(model) => model.id === activeDescriptionGenerationRun?.modelId,
)?.label ?? activeDescriptionGenerationRun?.modelId ?? ""}
onClose={() => setDescriptionGenerationDrawerOpen(false)}
onRunUpdate={setActiveDescriptionGenerationRun}
onTerminal={(run) => void descriptionGenerationTerminated(run)}
/>
</main>
);
}