feat(ai): add custom prompt templates

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-07-22 13:43:39 +08:00
1 parent 01fde8879f
commit 1fb3e2816b
58 files changed
+2381 -502

No files matched your search

@@ -47,15 +47,17 @@ import { useSettingsStore, AI_PROVIDER_PRESETS, normalizeAiConfig } from "@/stor
import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
import { connectionIconType } from "@/lib/connection/connectionPresentation";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ConnectionGroupBadge from "@/components/connection/ConnectionGroupBadge.vue";
import { useQueryStore } from "@/stores/queryStore";
import { useToast } from "@/composables/useToast";
import { useNavigationTargets } from "@/composables/useNavigationTargets";
import { buildAiContext, runAgentStream, isVectorDbType, isValidActionForMode, defaultActionForMode, type AiAction, type AiAssistantMode, type AiSqlFileContext } from "@/lib/ai/ai";
import { buildAiContext, runAgentStream, isVectorDbType, isValidActionForMode, defaultActionForMode, type AiAction, type AiAssistantMode, type AiSqlFileContext, type CustomPromptContext } from "@/lib/ai/ai";
import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering";
import { normalizeClaudeCodeReasoningLevel } from "@/lib/ai/aiModelEffort";
import { ACTIVE_TEMPLATES_TOTAL_MAX, promptTemplateCharacterCount } from "@/types/promptTemplate";
import type { AgentEvent } from "@/lib/backend/tauri";
import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan";
@@ -85,6 +87,7 @@ const { t } = useI18n();
const settings = useSettingsStore();
const connectionStore = useConnectionStore();
const savedSqlStore = useSavedSqlStore();
const promptTemplateStore = usePromptTemplateStore();
const queryStore = useQueryStore();
const { openTableTarget } = useNavigationTargets({
showFieldLineageDialog: ref(false),
@@ -148,6 +151,67 @@ const currentSessionId = ref("");
const conversationId = ref("");
const conversations = ref<AiConversation[]>([]);
const showConversationList = ref(false);
const showTemplateSelector = ref(false);
// Prompt template selection (panel-session scope)
const activeTemplateIds = ref<string[]>([]);
const activeTemplates = computed(() => promptTemplateStore.templates.filter((t) => activeTemplateIds.value.includes(t.id)));
watch(
() => promptTemplateStore.templates,
(templates) => {
const availableIds = new Set(templates.map((template) => template.id));
activeTemplateIds.value = activeTemplateIds.value.filter((id) => availableIds.has(id));
},
);
// 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();
});
// Reset template selection when the user switches to a different connection or database —
// a new database context warrants a fresh selection of scenario templates.
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 ?? ""}`,
() => {
activeTemplateIds.value = [];
},
);
function toggleTemplateId(id: string) {
if (activeTemplateIds.value.includes(id)) {
activeTemplateIds.value = activeTemplateIds.value.filter((tid) => tid !== id);
} else {
// Check total content limit
const tpl = promptTemplateStore.templates.find((t) => t.id === id);
if (tpl) {
const currentTotal = activeTemplates.value.reduce((sum, template) => sum + promptTemplateCharacterCount(template.content), 0);
if (currentTotal + promptTemplateCharacterCount(tpl.content) > ACTIVE_TEMPLATES_TOTAL_MAX) {
toast(t("ai.templateSelectorTooLong", { max: ACTIVE_TEMPLATES_TOTAL_MAX }), 4000);
return;
}
}
activeTemplateIds.value = [...activeTemplateIds.value, id];
}
}
function deselectAllTemplates() {
activeTemplateIds.value = [];
}
const templateSelectorLabel = computed(() => {
if (!promptTemplateStore.isLoaded) return t("ai.templateSelectorLoading");
const count = activeTemplates.value.length;
if (count === 0) return t("ai.templateSelectorNone");
const name = activeTemplates.value[0].name;
if (count === 1) return name;
return `${name} +${count - 1}`;
});
const promptTextareaRef = ref<HTMLTextAreaElement | null>(null);
const shouldAutoScroll = ref(true);
const userPausedAutoScroll = ref(false);
@@ -1393,6 +1457,21 @@ async function send() {
toast(t("ai.noConfig"));
return;
}
// Acquire the send guard before the first async operation so two rapid
// submissions cannot both pass the initial isGenerating check and then
// resume into concurrent agent runs.
isGenerating.value = true;
if (!(await promptTemplateStore.ensureLoaded())) {
isGenerating.value = false;
toast(t("ai.customInstructionsLoadFailed"), 5000);
return;
}
// Snapshot the selected custom prompts at send time so later async context loading
// cannot change the instructions for an already-submitted request.
const customPromptContext: CustomPromptContext = {
globalInstructions: promptTemplateStore.globalInstructions,
activeTemplates: [...activeTemplates.value],
};
const selectedTableMentions = [...selectedMentions.value];
const selectedSqlFiles = [...selectedSqlFileMentions.value];
@@ -1417,7 +1496,6 @@ async function send() {
// Agent confirmation cannot grant autonomous writes while the active database is production.
const allowWriteSql = requestedMode === "agent" && allowWriteSqlForNextRun && !productionContext.value.active;
allowWriteSqlForNextRun = false;
isGenerating.value = true;
messages.value.push({ role: "assistant", content: "" });
const assistantIdx = messages.value.length - 1;
const sessionId = uuid();
@@ -1475,6 +1553,7 @@ async function send() {
scrollToBottom();
},
sessionId,
customPromptContext,
);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
@@ -2120,6 +2199,44 @@ async function openExternalUrl(url: string) {
@keydown="onPromptKeydown"
/>
<div class="flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
<!-- Template selector -->
<Popover v-model:open="showTemplateSelector">
<PopoverTrigger as-child>
<button type="button" class="flex shrink-0 items-center gap-1 whitespace-nowrap rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground">
<FileCode class="h-3 w-3" />
{{ t("ai.templateSelectorLabel", { label: templateSelectorLabel }) }}
<svg class="h-3 w-3 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-64 gap-0 p-1.5">
<div class="max-h-64 overflow-auto">
<div v-if="!promptTemplateStore.isLoaded" class="px-3 py-4 text-center text-xs text-muted-foreground">
{{ t("ai.templateSelectorLoading") }}
</div>
<div v-else-if="promptTemplateStore.templates.length === 0" class="px-3 py-4 text-center text-xs text-muted-foreground">
{{ t("ai.templateSelectorEmpty") }}
</div>
<template v-else>
<template v-for="tpl in promptTemplateStore.templates" :key="tpl.id">
<button type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs hover:bg-muted" @click="toggleTemplateId(tpl.id)">
<div class="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border" :class="activeTemplateIds.includes(tpl.id) ? 'border-primary bg-primary text-primary-foreground' : ''">
<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="text-[10px] text-muted-foreground truncate">{{ tpl.content.slice(0, 60) }}</div>
</div>
</button>
</template>
</template>
</div>
<div v-if="promptTemplateStore.isLoaded && promptTemplateStore.templates.length > 0" class="border-t mt-1 pt-1 px-1">
<button type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground" @click="deselectAllTemplates">
{{ t("ai.templateSelectorDeselectAll") }}
</button>
</div>
</PopoverContent>
</Popover>
<LightDropdown
v-model="assistantMode"
:items="assistantModeItems"
@@ -113,6 +113,7 @@ import { APP_THEME_PALETTES, type AppThemeAppearance, type AppThemeMode, type Ap
import { editorSettingsDraftChanged, editorSettingsDraftFromSettings, editorSettingsPatchFromDraft, normalizeTableOpenPageSizeDraft, type EditorSettingsDraft } from "@/lib/settings/editorSettingsDraft";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { currentLocale, setLocale, type Locale } from "@/i18n";
import { LOCALE_OPTIONS } from "@/lib/app/localeOptions";
@@ -122,12 +123,15 @@ import { DEFAULT_UI_FONT_FAMILY, SYSTEM_UI_FONT_FAMILY } from "@/lib/app/appFont
import { buildAppSupportInfoRows, formatAppSupportInfoForClipboard, type AppSupportInfoLabels } from "@/lib/app/supportInfo";
import { DateTimePatterns, normalizeSupportedDateTimePattern } from "@/lib/dataGrid/columnFormatter";
import { MAX_RESULT_PAGE_SIZE, MIN_RESULT_PAGE_SIZE } from "@/lib/dataGrid/paginationPageSize";
import type { PromptTemplate } from "@/types/promptTemplate";
import { GLOBAL_INSTRUCTIONS_MAX, PROMPT_TEMPLATE_CONTENT_MAX, PROMPT_TEMPLATE_NAME_MAX, promptTemplateCharacterCount } from "@/types/promptTemplate";
const { t } = useI18n();
const { toast } = useToast();
const settingsStore = useSettingsStore();
const connectionStore = useConnectionStore();
const savedSqlStore = useSavedSqlStore();
const promptTemplateStore = usePromptTemplateStore();
const tunnelProfileStore = useTunnelProfileStore();
const { isDark, themeMode, themePalette, setThemeMode, setThemePalette } = useTheme();
@@ -1967,9 +1971,15 @@ watch(snippetProvider, (provider) => {
void refreshSnippetTokenStatus();
});
watch(activeSettingsTab, (tab) => {
watch(activeSettingsTab, async (tab) => {
if (tab === "mcp" && !mcpStatus.value && !mcpStatusLoading.value) void refreshMcpStatus();
if (tab === "ai" && aiIsCliProvider.value) void ensureCliMcpStatus();
if (tab === "ai") {
// Await completion so we don't snapshot an empty default when the store
// is still loading its first payload (init via App.vue is fire-and-forget).
await promptTemplateStore.ensureLoaded();
editGlobalInstructions.value = promptTemplateStore.globalInstructions;
}
if (tab === "about" && !appSupportInfo.value) void refreshAppSupportInfo();
if (tab === "appearance") {
checkLayoutDescTruncation();
@@ -1977,6 +1987,18 @@ watch(activeSettingsTab, (tab) => {
}
});
// If the store finishes loading while the AI tab is already open (e.g. a retry
// from another entry point succeeded after the tab-switch snapshot saw a failed
// load), backfill the textarea — but never clobber text the user already typed.
watch(
() => promptTemplateStore.isLoaded,
(loaded) => {
if (loaded && activeSettingsTab.value === "ai" && !editGlobalInstructions.value) {
editGlobalInstructions.value = promptTemplateStore.globalInstructions;
}
},
);
onMounted(() => {
void refreshWebDavPasswordStatus();
checkLayoutDescTruncation();
@@ -2026,6 +2048,116 @@ async function changePassword() {
// ---------- AI Settings ----------
// Global Custom Instructions
const editGlobalInstructions = ref("");
const globalInstructionsSaving = ref(false);
// Prompt Templates Management
const templateEditing = ref<PromptTemplate | null>(null);
const templateFormOpen = ref(false);
const templateForm = ref<{ name: string; content: string }>({ name: "", content: "" });
const templateFormIsNew = ref(true);
const templateSaving = ref(false);
const templateDeleteConfirm = ref<PromptTemplate | null>(null);
const templateDeleteConfirmOpen = ref(false);
watch(templateDeleteConfirm, (val) => {
templateDeleteConfirmOpen.value = val !== null;
});
watch(templateDeleteConfirmOpen, (val) => {
if (!val) templateDeleteConfirm.value = null;
});
function openNewTemplate() {
templateForm.value = { name: "", content: "" };
templateFormIsNew.value = true;
templateEditing.value = null;
templateFormOpen.value = true;
}
function openEditTemplate(tpl: PromptTemplate) {
templateForm.value = { name: tpl.name, content: tpl.content };
templateFormIsNew.value = false;
templateEditing.value = tpl;
templateFormOpen.value = true;
}
function closeTemplateForm() {
templateForm.value = { name: "", content: "" };
templateEditing.value = null;
templateFormOpen.value = false;
}
function templateNameValid(): boolean {
return templateForm.value.name.trim().length > 0;
}
function templateContentValid(): boolean {
return templateForm.value.content.trim().length > 0;
}
function templateNameTooLong(): boolean {
return promptTemplateCharacterCount(templateForm.value.name) > PROMPT_TEMPLATE_NAME_MAX;
}
function templateContentTooLong(): boolean {
return promptTemplateCharacterCount(templateForm.value.content) > PROMPT_TEMPLATE_CONTENT_MAX;
}
function localNameDuplicate(): boolean {
const name = templateForm.value.name.trim();
if (!name) return false;
const existingId = templateEditing.value?.id ?? "";
return promptTemplateStore.templates.some((t) => t.id !== existingId && t.name.toLowerCase() === name.toLowerCase());
}
function templateSaveDisabled(): boolean {
return templateSaving.value || !templateNameValid() || !templateContentValid() || templateNameTooLong() || templateContentTooLong();
}
async function saveTemplateForm() {
if (templateSaveDisabled()) return;
templateSaving.value = true;
try {
const id = templateEditing.value?.id ?? uuid();
await promptTemplateStore.save(id, templateForm.value.name.trim(), templateForm.value.content.trim());
closeTemplateForm();
toast(t("ai.promptTemplateSaved"));
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
templateSaving.value = false;
}
}
async function confirmDeleteTemplate(tpl: PromptTemplate) {
try {
await promptTemplateStore.remove(tpl.id);
toast(t("ai.promptTemplateDeleted"));
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
templateDeleteConfirm.value = null;
}
}
async function saveGlobalInstructions() {
if (promptTemplateCharacterCount(editGlobalInstructions.value) > GLOBAL_INSTRUCTIONS_MAX) return;
globalInstructionsSaving.value = true;
try {
await promptTemplateStore.saveGlobalInstructions(editGlobalInstructions.value);
toast(t("ai.globalInstructionsSaved"));
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
globalInstructionsSaving.value = false;
}
}
function globalInstructionsTooLong(): boolean {
return promptTemplateCharacterCount(editGlobalInstructions.value) > GLOBAL_INSTRUCTIONS_MAX;
}
// AI Config Delete Confirmation
const aiDeleteConfirmOpen = ref(false);
const aiDeleteConfigId = ref<string | null>(null);
@@ -4568,6 +4700,92 @@ onUnmounted(cleanupPreviewEditor);
</div>
</div>
<!-- Global Custom Instructions (list mode) -->
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
<Separator />
<div>
<h3 class="text-sm font-medium">{{ t("ai.globalInstructions") }}</h3>
<p class="text-xs text-muted-foreground">{{ t("ai.globalInstructionsDescription") }}</p>
</div>
<textarea v-model="editGlobalInstructions" class="w-full rounded-md border bg-background px-3 py-2 text-xs font-mono resize-y min-h-[80px]" rows="4" :placeholder="t('ai.globalInstructionsDescription')"></textarea>
<div class="flex items-center justify-between">
<span class="text-xs" :class="globalInstructionsTooLong() ? 'text-destructive' : 'text-muted-foreground'">
{{ t("ai.globalInstructionsCharCount", { count: promptTemplateCharacterCount(editGlobalInstructions), max: GLOBAL_INSTRUCTIONS_MAX }) }}
</span>
<Button type="button" size="sm" :disabled="!promptTemplateStore.isLoaded || globalInstructionsTooLong() || globalInstructionsSaving" @click="saveGlobalInstructions">
{{ globalInstructionsSaving ? t("common.processing") : t("ai.globalInstructionsSave") }}
</Button>
</div>
</div>
<!-- Prompt Templates Management (list mode) -->
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
<Separator />
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium">{{ t("ai.promptTemplates") }}</h3>
<p class="text-xs text-muted-foreground">{{ t("ai.promptTemplatesDescription") }}</p>
</div>
<Button v-if="!templateFormOpen" type="button" size="sm" @click="openNewTemplate">
<Plus class="mr-1 h-3.5 w-3.5" />
{{ t("ai.promptTemplateNew") }}
</Button>
</div>
<!-- Template list -->
<div v-if="!templateFormOpen && promptTemplateStore.templates.length > 0" class="space-y-1.5">
<div v-for="tpl in promptTemplateStore.templates" :key="tpl.id" class="flex items-center justify-between rounded-md border p-3">
<div class="min-w-0 flex-1">
<div class="text-sm font-medium truncate">{{ tpl.name }}</div>
<div class="text-xs text-muted-foreground truncate">{{ tpl.content.slice(0, 100) }}{{ tpl.content.length > 100 ? "..." : "" }}</div>
</div>
<div class="flex items-center gap-1 shrink-0 ml-2">
<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>
</div>
</div>
<div v-if="!templateFormOpen && promptTemplateStore.templates.length === 0" class="rounded-md border border-dashed p-6 text-center">
<p class="text-sm text-muted-foreground">{{ t("ai.promptTemplateNoTemplates") }}</p>
</div>
<!-- Template Edit Form -->
<div v-if="templateFormOpen" class="rounded-md border p-4 space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">{{ templateFormIsNew ? t("ai.promptTemplateNew") : t("ai.promptTemplateEdit") }}</h4>
<Button type="button" variant="ghost" size="sm" @click="closeTemplateForm">
<X class="h-3.5 w-3.5" />
</Button>
</div>
<div>
<Label class="text-xs">{{ t("ai.promptTemplateName") }}</Label>
<Input v-model="templateForm.name" class="mt-1 h-8 text-xs" :placeholder="t('ai.promptTemplateNamePlaceholder')" />
<div class="flex justify-between mt-0.5">
<p v-if="templateNameTooLong()" class="text-xs text-destructive">{{ t("ai.promptTemplateNameTooLong", { max: PROMPT_TEMPLATE_NAME_MAX }) }}</p>
<p v-else-if="localNameDuplicate()" class="text-xs text-destructive">{{ t("ai.promptTemplateNameExists", { name: templateForm.name.trim() }) }}</p>
<span v-else></span>
<span class="text-xs" :class="templateNameTooLong() ? 'text-destructive' : 'text-muted-foreground'">{{ promptTemplateCharacterCount(templateForm.name) }}/{{ PROMPT_TEMPLATE_NAME_MAX }}</span>
</div>
</div>
<div>
<Label class="text-xs">{{ t("ai.promptTemplateContent") }}</Label>
<textarea v-model="templateForm.content" class="mt-1 w-full rounded-md border bg-background px-3 py-2 text-xs font-mono resize-y min-h-[80px]" rows="4" :placeholder="t('ai.promptTemplateContentPlaceholder')"></textarea>
<div class="flex justify-between mt-0.5">
<p v-if="templateContentTooLong()" class="text-xs text-destructive">{{ t("ai.promptTemplateTooLong", { max: PROMPT_TEMPLATE_CONTENT_MAX }) }}</p>
<span v-else></span>
<span class="text-xs" :class="templateContentTooLong() ? 'text-destructive' : 'text-muted-foreground'">{{ promptTemplateCharacterCount(templateForm.content) }}/{{ PROMPT_TEMPLATE_CONTENT_MAX }}</span>
</div>
</div>
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" @click="closeTemplateForm">{{ t("common.cancel") }}</Button>
<Button type="button" size="sm" :disabled="templateSaveDisabled()" @click="saveTemplateForm">
{{ templateSaving ? t("common.processing") : t("common.save") }}
</Button>
</div>
</div>
</div>
<!-- Config Edit View -->
<div v-else class="space-y-4">
<div class="flex items-center justify-between">
@@ -5431,6 +5649,13 @@ onUnmounted(cleanupPreviewEditor);
<!-- AI Config Delete Confirmation -->
<DangerConfirmDialog v-model:open="aiDeleteConfirmOpen" :title="t('ai.deleteConfigTitle')" :message="t('ai.deleteConfigConfirm')" :confirm-label="t('common.delete')" @confirm="aiConfirmDeleteConfig" />
<DangerConfirmDialog
v-model:open="templateDeleteConfirmOpen"
:title="t('ai.promptTemplateDeleteTitle')"
:message="t('ai.promptTemplateDeleteConfirm', { name: templateDeleteConfirm?.name ?? '' })"
:confirm-label="t('common.delete')"
@confirm="templateDeleteConfirm && confirmDeleteTemplate(templateDeleteConfirm)"
/>
</component>
</template>