feat(object-browser): add table Add to AI context menu entry

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-10 11:59:20 +08:00
1 parent 182b6f3d8d
commit 464f2f33c5
8 files changed
+221 -1

No files matched your search

@@ -42,6 +42,7 @@ import {
Search,
ScrollText,
ShieldCheck,
Sparkles,
Square,
Table,
Table2,
@@ -70,7 +71,7 @@ import * as api from "@/lib/backend/api";
import type { ColumnInfo, ConnectionConfig, ConstraintInfo, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database";
import { sortTablesByFkDependency, type TableWithFk } from "@/lib/table/tableDependencySort";
import { isSchemaAware, supportsTableVacuum, supportsTransfer } from "@/lib/database/databaseCapabilities";
import { supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/database/databaseFeatureSupport";
import { supportsAiAssistantContext, supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/database/databaseFeatureSupport";
import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, objectListSchemaForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { getTableMetadataCapabilities, type TableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
import { constraintsForConstraintsTab } from "@/lib/table/constraintPresentation";
@@ -188,6 +189,7 @@ const emit = defineEmits<{
schemaChange: [schema: string | undefined];
viewportChange: [viewport: ObjectBrowserViewport];
searchChange: [query: string];
addToAi: [tables: Array<{ name: string; schema?: string }>];
}>();
const { t } = useI18n();
@@ -3108,6 +3110,22 @@ function isSelectedBatchTableContext(item: ObjectBrowserRow): boolean {
return item.type === "TABLE" && selectedTableCount.value > 1 && selectedTableIds.value.has(item.id);
}
function addToAiMenuItem(item: ObjectBrowserRow): ContextMenuItem {
const useBatch = isSelectedBatchTableContext(item);
const count = selectedTableCount.value;
// Schema stays per-row: the consumer (App.vue addToAi) resolves the final
// schema with its own fallback chain (table.schema || tab.schema — no
// database fallback, mirroring the sidebar tree path). ObjectBrowser is a
// single-schema view, but keeping row-level schemas makes the payload honest
// and future-proof if multi-schema selection ever appears.
const targets = useBatch ? selectedTableRows.value.map((row) => ({ name: row.name, schema: row.schema })) : [{ name: item.name, schema: item.schema }];
return {
label: useBatch ? t("contextMenu.addToAiMultiple", { count }) : t("contextMenu.addToAi"),
action: () => emit("addToAi", targets),
icon: Sparkles,
};
}
function selectedBatchTableCountLabel(key: "batchDrop" | "batchTruncate" | "batchEmpty"): string {
return t(`contextMenu.${key}`, { count: selectedTableCount.value });
}
@@ -3117,6 +3135,7 @@ function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
return [
{ label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 },
{ label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare },
...(supportsAiAssistantContext(effectiveDatabaseType.value) ? [addToAiMenuItem(item)] : []),
{ label: "", separator: true },
exportDataSubmenu(item),
{ label: "", separator: true },
@@ -3160,6 +3179,7 @@ function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
...(canOpenStructureEditor.value ? [{ label: t("contextMenu.editStructure"), action: () => openStructureEditor(item), icon: PencilRuler }] : []),
...(canRename(item) ? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }] : []),
{ label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare },
...(supportsAiAssistantContext(effectiveDatabaseType.value) ? [addToAiMenuItem(item)] : []),
...(canOpenDiagram.value ? [{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network }] : []),
...(canOpenTableImport.value ? [{ label: t("contextMenu.importData"), action: () => openTableImport(item), icon: Download }] : []),
{ label: t("dataCompare.title"), action: () => openDataCompare(item), icon: ArrowRightLeft },
@@ -0,0 +1,59 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
function functionBody(name: string): string {
const signature = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\([^)]*\\)\\s*(?::\\s*[^\\{]+)?\\{`, "m").exec(source);
if (!signature) throw new Error(`Missing function ${name}`);
const bodyStart = signature.index + signature[0].length;
let depth = 1;
for (let index = bodyStart; index < source.length; index += 1) {
if (source[index] === "{") depth += 1;
else if (source[index] === "}") depth -= 1;
if (depth === 0) return source.slice(bodyStart, index);
}
throw new Error(`Unclosed function ${name}`);
}
describe("ObjectBrowser add to AI", () => {
it("declares the addToAi emit for table context hand-off", () => {
const emitDeclaration = source.slice(source.indexOf("const emit = defineEmits"), source.indexOf(">();", source.indexOf("const emit = defineEmits")));
expect(emitDeclaration).toContain("addToAi");
});
it("adds an 'Add to AI' menu item after 'New Query' in the table menu", () => {
const tableMenu = functionBody("getTableMenuItems");
const newQueryIndex = tableMenu.indexOf('t("contextMenu.newQuery")');
const addToAiIndex = tableMenu.indexOf("addToAiMenuItem(item)");
expect(newQueryIndex).toBeGreaterThan(-1);
expect(addToAiIndex).toBeGreaterThan(newQueryIndex);
});
it("guards the menu item behind supportsAiAssistantContext", () => {
const tableMenu = functionBody("getTableMenuItems");
expect(tableMenu).toContain("supportsAiAssistantContext(effectiveDatabaseType.value)");
expect(tableMenu).toContain("[addToAiMenuItem(item)]");
});
it("offers 'Add to AI' in the VictoriaMetrics early-return branch too", () => {
const tableMenu = functionBody("getTableMenuItems");
// The VM branch is the first block inside getTableMenuItems; verify it guards
// the same AI entry so ObjectBrowser stays consistent with the sidebar tree.
const vmBranchStart = tableMenu.indexOf("if (isVictoriaMetrics.value)");
const vmBranchEnd = tableMenu.indexOf("const useBatchActions", vmBranchStart);
const vmBranch = tableMenu.slice(vmBranchStart, vmBranchEnd);
expect(vmBranch).toContain("supportsAiAssistantContext(effectiveDatabaseType.value)");
expect(vmBranch).toContain("[addToAiMenuItem(item)]");
expect(vmBranch.indexOf("addToAiMenuItem(item)")).toBeGreaterThan(vmBranch.indexOf('t("contextMenu.newQuery")'));
});
it("emits selected tables (batch) or the single table with per-row schema", () => {
const addToAi = functionBody("addToAiMenuItem");
expect(addToAi).toContain("selectedTableRows.value.map((row) => ({ name: row.name, schema: row.schema }))");
expect(addToAi).toContain('emit("addToAi", targets)');
expect(addToAi).toContain('t("contextMenu.addToAiMultiple", { count })');
expect(addToAi).toContain('t("contextMenu.addToAi")');
});
});