feat(ai): default and last-used templates per db type

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-02 20:37:39 +08:00
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>
@@ -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");
});
});