feat(ai): default and last-used templates per db type
This commit is contained in:
1 parent
d92e119464
commit
91f309f6c7
20 files changed
+647
-12
No files matched your search
@@ -38,6 +38,7 @@ import {
|
||||
Table2,
|
||||
Play,
|
||||
Square,
|
||||
Star,
|
||||
Trash2,
|
||||
Terminal,
|
||||
Wand2,
|
||||
@@ -73,6 +74,7 @@ import {
|
||||
resolveAiDatabaseTarget,
|
||||
resolveAiNamespaceSelection,
|
||||
resolveDefaultAiSchema,
|
||||
aiDatabaseTypeForConnection,
|
||||
aiSchemaSelectionSupported,
|
||||
runAgentStream,
|
||||
isVectorDbType,
|
||||
@@ -135,6 +137,8 @@ import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering";
|
||||
import { effortSelectionEquals, runtimeEffortFromPreference } from "@/lib/ai/aiEffortPreference";
|
||||
import { useAiModelCatalog } from "@/composables/useAiModelCatalog";
|
||||
import { ACTIVE_TEMPLATES_TOTAL_MAX, promptTemplateCharacterCount } from "@/types/promptTemplate";
|
||||
import { capTemplateIdsToCharLimit, resolveAutoTemplateIds, resolveDefaultTemplateIds } from "@/lib/ai/promptTemplateDefaults";
|
||||
import { databaseManifestEntry } from "@/lib/database/databaseDriverManifest";
|
||||
|
||||
import type { AgentEvent } from "@/lib/backend/tauri";
|
||||
import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan";
|
||||
@@ -438,19 +442,70 @@ watch(
|
||||
|
||||
// Retry store load on selector open if prior init failed (e.g. backend not yet ready at mount)
|
||||
watch(showTemplateSelector, (open) => {
|
||||
if (open) void promptTemplateStore.ensureLoaded();
|
||||
// Retry load on selector open if prior init failed, then apply pending
|
||||
// per-db_type defaults that were skipped when the initial load failed.
|
||||
if (open) void promptTemplateStore.ensureLoaded().then(() => void maybeApplyAutoTemplates());
|
||||
});
|
||||
|
||||
// Reset template selection when the user switches to a different connection,
|
||||
// database, or schema — a new namespace context warrants a fresh selection.
|
||||
// Auto-apply per-db_type prompt template defaults once both the AI selection
|
||||
// (which carries the defaults) and the template list have loaded. Only this
|
||||
// one-time resolution and the namespace watcher below write the selection
|
||||
// implicitly; manual edits in between are never overwritten.
|
||||
// Template defaults/last-used are keyed by the connection's effective AI
|
||||
// database type (gbase → mysql/informix, doris-over-mysql protocol, jdbc →
|
||||
// inferred dialect) so resolution matches the dialect the AI pipeline and
|
||||
// prompt selection actually use — the same axis aiDatabaseTypeForConnection
|
||||
// established for schema selection.
|
||||
const templateDbType = computed(() => (props.connection ? aiDatabaseTypeForConnection(props.connection) : undefined));
|
||||
const aiTemplateNamespaceKey = computed(() => `${props.connection?.id ?? ""}::${props.tab?.database ?? ""}::${props.tab?.schema ?? ""}`);
|
||||
let autoTemplatesInitialized = false;
|
||||
function applyResolvedTemplateIds(ids: string[]) {
|
||||
activeTemplateIds.value = capTemplateIdsToCharLimit(ids, promptTemplateStore.templates, ACTIVE_TEMPLATES_TOTAL_MAX);
|
||||
}
|
||||
async function maybeApplyAutoTemplates() {
|
||||
if (autoTemplatesInitialized || !settings.isAiConfigLoaded) return;
|
||||
const namespaceAtStart = aiTemplateNamespaceKey.value;
|
||||
if (!(await promptTemplateStore.ensureLoaded())) return;
|
||||
autoTemplatesInitialized = true;
|
||||
// A namespace switch during the await already ran the defaults-only
|
||||
// resolution below; applying defaults-or-last-used now would violate the
|
||||
// switch contract and could overwrite a manual selection made meanwhile.
|
||||
if (namespaceAtStart !== aiTemplateNamespaceKey.value) return;
|
||||
// Panel-open resolution: explicit defaults, else the db_type's last-used.
|
||||
applyResolvedTemplateIds(
|
||||
resolveAutoTemplateIds({
|
||||
dbType: templateDbType.value,
|
||||
defaultTemplatesByDbType: settings.aiDefaultTemplatesByDbType,
|
||||
lastUsedTemplatesByDbType: settings.aiLastUsedTemplatesByDbType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
watch(
|
||||
// Return a stable primitive key: a fresh array literal is never Object.is-equal to the
|
||||
// previous one, so a getter returning `[id, database]` fires on every dependency
|
||||
// invalidation (e.g. the 30s backup scheduler replacing connection objects) even when the
|
||||
// id/database values are unchanged — spuriously clearing the selection mid agent-run.
|
||||
() => `${props.connection?.id ?? ""}::${props.tab?.database ?? ""}::${props.tab?.schema ?? ""}`,
|
||||
() => settings.isAiConfigLoaded,
|
||||
() => void maybeApplyAutoTemplates(),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Reset template selection when the user switches to a different connection,
|
||||
// database, or schema — a new namespace context warrants a fresh selection:
|
||||
// the new db_type's default templates when the user configured any (explicit
|
||||
// opt-in), otherwise empty, preserving the pre-defaults clear-on-switch
|
||||
// contract. Last-used templates are intentionally not restored here: that
|
||||
// would silently re-select templates on every namespace switch.
|
||||
watch(
|
||||
// The key is a stable primitive string (see aiTemplateNamespaceKey): a fresh
|
||||
// array literal is never Object.is-equal to the previous one, so a getter
|
||||
// returning `[id, database]` fires on every dependency invalidation (e.g.
|
||||
// the 30s backup scheduler replacing connection objects) even when the
|
||||
// id/database values are unchanged — spuriously clearing the selection mid
|
||||
// agent-run.
|
||||
aiTemplateNamespaceKey,
|
||||
() => {
|
||||
activeTemplateIds.value = [];
|
||||
if (!settings.isAiConfigLoaded || !promptTemplateStore.isLoaded) {
|
||||
activeTemplateIds.value = [];
|
||||
return;
|
||||
}
|
||||
applyResolvedTemplateIds(resolveDefaultTemplateIds(templateDbType.value, settings.aiDefaultTemplatesByDbType));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -489,6 +544,14 @@ const templateSelectorTriggerLabel = computed(() => {
|
||||
}
|
||||
return templateSelectorLabel.value;
|
||||
});
|
||||
const currentDbType = computed(() => templateDbType.value);
|
||||
function isDefaultTemplateForCurrentDb(id: string): boolean {
|
||||
const dbType = currentDbType.value;
|
||||
return !!dbType && (settings.aiDefaultTemplatesByDbType[dbType]?.includes(id) ?? false);
|
||||
}
|
||||
function currentDbTypeLabel(): string {
|
||||
return currentDbType.value ? (databaseManifestEntry(currentDbType.value)?.label ?? currentDbType.value) : "";
|
||||
}
|
||||
const promptTextareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const csvFileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const shouldAutoScroll = ref(true);
|
||||
@@ -2823,6 +2886,13 @@ async function send() {
|
||||
globalInstructions: promptTemplateStore.globalInstructions,
|
||||
activeTemplates: [...activeTemplates.value],
|
||||
};
|
||||
// Remember what was actually sent for this db_type so panels opened later can
|
||||
// restore it when no explicit per-db_type defaults are configured. This runs
|
||||
// for empty selections too: the store clears the remembered entry so a
|
||||
// deselected-everything send is not resurrected on the next panel open.
|
||||
if (templateDbType.value) {
|
||||
settings.recordLastUsedTemplates(templateDbType.value, [...activeTemplateIds.value]);
|
||||
}
|
||||
|
||||
const selectedTableMentions = auto ? [] : [...selectedMentions.value];
|
||||
const selectedSqlFiles = auto ? [] : [...selectedSqlFileMentions.value];
|
||||
@@ -4846,7 +4916,10 @@ async function openExternalUrl(url: string) {
|
||||
<Check v-if="activeTemplateIds.includes(tpl.id)" class="h-3 w-3" />
|
||||
</div>
|
||||
<div class="flex-1 truncate text-left">
|
||||
<div class="font-medium">{{ tpl.name }}</div>
|
||||
<div class="flex items-center gap-1 font-medium">
|
||||
<span class="truncate">{{ tpl.name }}</span>
|
||||
<Star v-if="isDefaultTemplateForCurrentDb(tpl.id)" class="h-3 w-3 shrink-0 text-amber-500" :title="t('ai.templateDefaultBadgeTitle', { type: currentDbTypeLabel() })" />
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground truncate">{{ tpl.content.slice(0, 60) }}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
Search,
|
||||
Settings,
|
||||
Sun,
|
||||
Star,
|
||||
SunMoon,
|
||||
Terminal,
|
||||
Trash2,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
} from "@lucide/vue";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import PasswordInput from "@/components/ui/PasswordInput.vue";
|
||||
@@ -207,6 +209,7 @@ import { MAX_QUERY_RESULT_MAX_ROWS } from "@/lib/dataGrid/queryResultRowLimit";
|
||||
import type { PromptTemplate } from "@/types/promptTemplate";
|
||||
import { GLOBAL_INSTRUCTIONS_MAX, PROMPT_TEMPLATE_CONTENT_MAX, PROMPT_TEMPLATE_NAME_MAX, promptTemplateCharacterCount } from "@/types/promptTemplate";
|
||||
import { METADATA_CACHE_HARD_MAX_MEMORY_MB, METADATA_CACHE_MIN_MEMORY_MB, normalizeMetadataCacheMemoryMb } from "@/lib/metadata/metadataRuntimeCache";
|
||||
import { databaseManifestEntry, manifestDatabaseTypes } from "@/lib/database/databaseDriverManifest";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
@@ -3353,6 +3356,8 @@ async function saveTemplateForm() {
|
||||
async function confirmDeleteTemplate(tpl: PromptTemplate) {
|
||||
try {
|
||||
await promptTemplateStore.remove(tpl.id);
|
||||
// Keep the per-db_type default/last-used records free of the deleted id.
|
||||
settingsStore.removeTemplateFromDefaultAndLastUsed(tpl.id);
|
||||
toast(t("ai.promptTemplateDeleted"));
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
@@ -3361,6 +3366,29 @@ async function confirmDeleteTemplate(tpl: PromptTemplate) {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-db_type default templates (issue #7649): a template can be marked as the
|
||||
// auto-applied default for any database type; the AI panel resolves these on
|
||||
// mount/namespace switch. Stored in the AI chat selection, not on the template row.
|
||||
const templateDefaultsOpenId = ref("");
|
||||
const dbTypeOptions = manifestDatabaseTypes();
|
||||
function defaultDbTypesForTemplate(templateId: string): string[] {
|
||||
return Object.entries(settingsStore.aiDefaultTemplatesByDbType)
|
||||
.filter(([, ids]) => ids.includes(templateId))
|
||||
.map(([dbType]) => dbType)
|
||||
.sort();
|
||||
}
|
||||
function templateHasDefault(dbType: string, templateId: string): boolean {
|
||||
return settingsStore.aiDefaultTemplatesByDbType[dbType]?.includes(templateId) ?? false;
|
||||
}
|
||||
function toggleTemplateDefault(dbType: string, templateId: string) {
|
||||
const current = settingsStore.aiDefaultTemplatesByDbType[dbType] ?? [];
|
||||
const next = current.includes(templateId) ? current.filter((id) => id !== templateId) : [...current, templateId];
|
||||
settingsStore.setDefaultTemplatesForDbType(dbType, next);
|
||||
}
|
||||
function dbTypeLabel(dbType: string): string {
|
||||
return databaseManifestEntry(dbType as DatabaseType)?.label ?? dbType;
|
||||
}
|
||||
|
||||
async function saveGlobalInstructions() {
|
||||
if (promptTemplateCharacterCount(editGlobalInstructions.value) > GLOBAL_INSTRUCTIONS_MAX) return;
|
||||
globalInstructionsSaving.value = true;
|
||||
@@ -7324,8 +7352,34 @@ onUnmounted(() => {
|
||||
{{ tpl.name }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground truncate">{{ tpl.content.slice(0, 100) }}{{ tpl.content.length > 100 ? "..." : "" }}</div>
|
||||
<div v-if="defaultDbTypesForTemplate(tpl.id).length > 0" class="mt-1 flex flex-wrap gap-1">
|
||||
<Badge v-for="dbType in defaultDbTypesForTemplate(tpl.id)" :key="dbType" variant="secondary" class="px-1.5 py-0 text-[10px]">
|
||||
{{ dbTypeLabel(dbType) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0 ml-2">
|
||||
<Popover :open="templateDefaultsOpenId === tpl.id" @update:open="(open) => (templateDefaultsOpenId = open ? tpl.id : '')">
|
||||
<PopoverTrigger as-child>
|
||||
<Button type="button" size="sm" variant="ghost" :class="defaultDbTypesForTemplate(tpl.id).length > 0 ? 'text-amber-500' : ''" :title="t('ai.templateSetDefault')" :aria-label="t('ai.templateSetDefault')">
|
||||
<Star class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-60 p-2">
|
||||
<p class="mb-1 px-1 text-xs font-medium">{{ t("ai.templateDefaultsTitle") }}</p>
|
||||
<div class="max-h-48 overflow-auto">
|
||||
<button v-for="dbType in dbTypeOptions" :key="dbType" type="button" class="flex w-full items-center gap-2 rounded-sm px-1 py-1 text-xs hover:bg-muted" @click="toggleTemplateDefault(dbType, tpl.id)">
|
||||
<div class="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border" :class="templateHasDefault(dbType, tpl.id) ? 'border-primary bg-primary text-primary-foreground' : ''">
|
||||
<Check v-if="templateHasDefault(dbType, tpl.id)" class="h-3 w-3" />
|
||||
</div>
|
||||
{{ dbTypeLabel(dbType) }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="defaultDbTypesForTemplate(tpl.id).length === 0" class="mt-1 px-1 text-[10px] text-muted-foreground">
|
||||
{{ t("ai.templateDefaultsEmpty") }}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button type="button" size="sm" variant="ghost" @click="openEditTemplate(tpl)">{{ t("common.edit") }}</Button>
|
||||
<Button type="button" size="sm" variant="ghost" class="text-destructive" @click="templateDeleteConfirm = tpl">{{ t("common.delete") }}</Button>
|
||||
</div>
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Regression coverage for https://github.com/t8y2/dbx/issues/7649: per-db_type
|
||||
// prompt template defaults + last-used fallback. The resolution rules live in
|
||||
// lib/ai/promptTemplateDefaults.ts (unit-tested separately); this file pins
|
||||
// that AiAssistant.vue wires them into the panel lifecycle:
|
||||
// - auto-apply once when the AI selection + templates are loaded,
|
||||
// - re-resolve on connection/database/schema switch (defaults only opt-in),
|
||||
// - record what was actually sent as the db_type's last-used templates.
|
||||
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("AI assistant applies per-db_type prompt template defaults", () => {
|
||||
it("panel-open resolution uses defaults-else-last-used through the tested helper and enforces the char cap", () => {
|
||||
const maybeBody = source.slice(source.indexOf("async function maybeApplyAutoTemplates()"), source.indexOf("// Reset template selection when the user switches"));
|
||||
expect(maybeBody).toContain("resolveAutoTemplateIds({");
|
||||
expect(maybeBody).toContain("defaultTemplatesByDbType: settings.aiDefaultTemplatesByDbType");
|
||||
expect(maybeBody).toContain("lastUsedTemplatesByDbType: settings.aiLastUsedTemplatesByDbType");
|
||||
expect(maybeBody).toContain("applyResolvedTemplateIds(");
|
||||
// The char cap lives in the shared apply helper both paths funnel through.
|
||||
const applyBody = source.slice(source.indexOf("function applyResolvedTemplateIds("), source.indexOf("async function maybeApplyAutoTemplates()"));
|
||||
expect(applyBody).toContain("capTemplateIdsToCharLimit(ids, promptTemplateStore.templates, ACTIVE_TEMPLATES_TOTAL_MAX)");
|
||||
});
|
||||
|
||||
it("auto-apply waits for the AI selection load, runs once, and skips failed template loads", () => {
|
||||
const maybeBody = source.slice(source.indexOf("async function maybeApplyAutoTemplates()"), source.indexOf("// Reset template selection when the user switches"));
|
||||
expect(maybeBody).toContain("if (autoTemplatesInitialized || !settings.isAiConfigLoaded) return;");
|
||||
expect(maybeBody).toContain("if (!(await promptTemplateStore.ensureLoaded())) return;");
|
||||
expect(maybeBody).toContain("autoTemplatesInitialized = true;");
|
||||
expect(maybeBody).toContain("applyResolvedTemplateIds(");
|
||||
});
|
||||
|
||||
it("namespace switch re-resolves defaults only when both stores are loaded, otherwise clears", () => {
|
||||
const watcherBody = source.slice(source.indexOf("aiTemplateNamespaceKey,"), source.indexOf("function toggleTemplateId("));
|
||||
expect(watcherBody).toContain("if (!settings.isAiConfigLoaded || !promptTemplateStore.isLoaded) {");
|
||||
expect(watcherBody).toContain("activeTemplateIds.value = [];");
|
||||
// The switch path must resolve through the defaults-only helper; the
|
||||
// mount resolver (defaults-else-last-used) would resurrect cleared
|
||||
// selections. Pin the exact call so an indirect last-used fallback
|
||||
// cannot hide behind a shared helper again.
|
||||
expect(watcherBody).toContain("resolveDefaultTemplateIds(templateDbType.value, settings.aiDefaultTemplatesByDbType)");
|
||||
expect(watcherBody).not.toContain("resolveAutoTemplateIds");
|
||||
expect(watcherBody).not.toContain("lastUsedTemplatesByDbType");
|
||||
});
|
||||
|
||||
it("keys defaults, last-used, and the badge by the effective AI database type", () => {
|
||||
// Same axis aiDatabaseTypeForConnection established for schema selection:
|
||||
// gbase → mysql, doris-over-mysql protocol → doris, jdbc → inferred
|
||||
// dialect. Raw db_type keying would never match for those connections.
|
||||
expect(source).toContain("const templateDbType = computed(() => (props.connection ? aiDatabaseTypeForConnection(props.connection) : undefined));");
|
||||
expect(source).toContain("aiDatabaseTypeForConnection");
|
||||
});
|
||||
|
||||
it("panel-open resolution bails when the namespace switched during the load await", () => {
|
||||
const maybeBody = source.slice(source.indexOf("async function maybeApplyAutoTemplates"), source.indexOf("watch(", source.indexOf("async function maybeApplyAutoTemplates")));
|
||||
expect(maybeBody).toContain("const namespaceAtStart = aiTemplateNamespaceKey.value;");
|
||||
expect(maybeBody).toContain("if (namespaceAtStart !== aiTemplateNamespaceKey.value) return;");
|
||||
});
|
||||
|
||||
it("selector-open load retry also applies pending defaults", () => {
|
||||
const watcherBody = source.slice(source.indexOf("watch(showTemplateSelector"), source.indexOf("// Auto-apply per-db_type"));
|
||||
expect(watcherBody).toContain("void promptTemplateStore.ensureLoaded().then(() => void maybeApplyAutoTemplates());");
|
||||
});
|
||||
|
||||
it("send records the sent templates as the db_type's last-used selection", () => {
|
||||
const sendIdx = source.indexOf("activeTemplates: [...activeTemplates.value],");
|
||||
const recordIdx = source.indexOf("settings.recordLastUsedTemplates(templateDbType.value, [...activeTemplateIds.value]);");
|
||||
expect(recordIdx).toBeGreaterThan(sendIdx);
|
||||
// No length guard: an empty selection must also reach the store so the
|
||||
// remembered entry is cleared instead of resurrecting old templates.
|
||||
const guard = source.slice(sendIdx, recordIdx);
|
||||
expect(guard).toContain("if (templateDbType.value) {");
|
||||
expect(guard).not.toContain("activeTemplates.value.length > 0");
|
||||
});
|
||||
|
||||
it("the template selector marks defaults for the current connection db_type", () => {
|
||||
expect(source).toContain('v-if="isDefaultTemplateForCurrentDb(tpl.id)"');
|
||||
expect(source).toContain("settings.aiDefaultTemplatesByDbType[dbType]?.includes(id) ?? false");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user