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
@@ -1,16 +1,20 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Menu } from "@base-ui/react/menu";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
import type { ColDef, ICellRendererParams } from "ag-grid-community";
import { KeyRound, Link2, Pencil, RefreshCw, Save } from "lucide-react";
import { ChevronDown, KeyRound, Link2, Pencil, RefreshCw, Save, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { ApiError, apiErrorMessage } from "../../api/client";
import {
consolidateCatalogDescriptions,
listCatalogColumns,
startDescriptionGenerationRun,
updateCatalogColumnMetadata,
type CatalogColumn,
type CatalogTable,
type DescriptionGenerationRun,
} from "../../api/catalog-databases";
import type { DatabaseNavigationState } from "./model";
@@ -18,6 +22,9 @@ interface Props {
databaseId: string;
table: CatalogTable;
canManage: boolean;
selectedMetadataModel: string | null;
descriptionGenerationActive: boolean;
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onSync: () => void;
}
@@ -48,7 +55,16 @@ function ActionCell({ data, context }: ICellRendererParams<CatalogColumn, unknow
);
}
export function DatabaseColumns({ databaseId, table, canManage, onNavigationStateChange, onSync }: Props) {
export function DatabaseColumns({
databaseId,
table,
canManage,
selectedMetadataModel,
descriptionGenerationActive,
onDescriptionGenerationRunStarted,
onNavigationStateChange,
onSync,
}: Props) {
const queryClient = useQueryClient();
const queryKey = ["catalog-columns", databaseId, table.id] as const;
const { data = [], isLoading, isFetching, refetch } = useQuery({
@@ -57,6 +73,7 @@ export function DatabaseColumns({ databaseId, table, canManage, onNavigationStat
retry: false,
});
const [search, setSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [description, setDescription] = useState("");
const [generatedDescription, setGeneratedDescription] = useState("");
@@ -65,6 +82,7 @@ export function DatabaseColumns({ databaseId, table, canManage, onNavigationStat
const [stale, setStale] = useState(false);
const [staleBannerOpen, setStaleBannerOpen] = useState(true);
const [busy, setBusy] = useState(false);
const gridRef = useRef<AgGridReact<CatalogColumn>>(null);
const originRef = useRef<HTMLButtonElement | null>(null);
const active = editingId ? data.find((column) => column.id === editingId) : undefined;
const fingerprint = JSON.stringify([description, generatedDescription]);
@@ -131,6 +149,34 @@ export function DatabaseColumns({ databaseId, table, canManage, onNavigationStat
setStaleBannerOpen(true);
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); }
};
const consolidateDescriptions = async () => {
setBusy(true);
try {
const counts = await consolidateCatalogDescriptions(databaseId, "columns", selectedIds);
await queryClient.invalidateQueries({ queryKey, exact: true });
gridRef.current?.api.deselectAll();
setSelectedIds([]);
toast.success(`Copied ${counts.copied} description${counts.copied === 1 ? "" : "s"}; skipped ${counts.skipped}`);
} catch (error) {
toast.error(apiErrorMessage(error));
} finally { setBusy(false); }
};
const generateDescriptions = async () => {
if (selectedIds.length === 0 || !selectedMetadataModel) return;
setBusy(true);
try {
const run = await startDescriptionGenerationRun(
databaseId,
selectedMetadataModel,
"selected_columns",
selectedIds,
);
onDescriptionGenerationRunStarted(run);
toast.success(`Description generation started for ${selectedIds.length} column${selectedIds.length === 1 ? "" : "s"}`);
} catch (error) {
toast.error(apiErrorMessage(error));
} finally { setBusy(false); }
};
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
@@ -176,15 +222,62 @@ export function DatabaseColumns({ databaseId, table, canManage, onNavigationStat
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
<span className="thot-label whitespace-nowrap">Catalog columns</span>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" disabled={!canManage || busy} onClick={onSync}><RefreshCw />Sync columns</Button>
{selectedIds.length > 0 ? (
<>
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
<Menu.Root>
<Menu.Trigger className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy}
onClick={() => void generateDescriptions()}
>
Generate {selectedIds.length === 1 ? "description" : "descriptions"}
</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canManage || busy}
onClick={() => void consolidateDescriptions()}
>
Move generated description to Description
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button>
</>
) : (
<>
<span className="thot-label whitespace-nowrap">Catalog columns</span>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" disabled={!canManage || busy} onClick={onSync}><RefreshCw />Sync columns</Button>
</>
)}
</div>
<div className="relative min-h-[280px] flex-1">
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
<AgGridReact<CatalogColumn> rowData={data} columnDefs={columns} context={context} loading={isLoading} quickFilterText={search} defaultColDef={{ sortable: true, filter: true, resizable: true }} getRowId={({ data: row }) => row.id} rowHeight={44} headerHeight={38} animateRows={false} overlayNoRowsTemplate="No columns synchronized for this table." />
<AgGridReact<CatalogColumn>
ref={gridRef}
rowData={data}
columnDefs={columns}
context={context}
loading={isLoading}
quickFilterText={search}
defaultColDef={{ sortable: true, filter: true, resizable: true }}
getRowId={({ data: row }) => row.id}
rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }}
onSelectionChanged={({ api }) => setSelectedIds(api.getSelectedRows().map((column) => column.id))}
rowHeight={44}
headerHeight={38}
animateRows={false}
overlayNoRowsTemplate="No columns synchronized for this table."
/>
</div>
</div>
</div>