feat(ai): add read-only user skill loader for SKILL.md

Refs: #9363
This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-22 02:24:23 +08:00
1 parent 6af337e67c
commit 34d8811cd3
36 files changed
+1765 -26

No files matched your search

@@ -63,6 +63,9 @@ import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
import { useUserSkillStore } from "@/stores/userSkillStore";
import { buildSelectedSkillChips, capSkillsToCharLimit, removeSkillIds, userSkillSourceOfId } from "@/lib/ai/userSkillSelection";
import { ACTIVE_SKILLS_TOTAL_MAX, type ReadUserSkill, type ReadUserSkillFailure, type UserSkillFailureReason, type UserSkillRootSettings } from "@/types/userSkills";
import { connectionIconType } from "@/lib/connection/connectionPresentation";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
@@ -153,7 +156,7 @@ import { buildAiAgentStepItems, formatToolDurationMs, toolCallStepKey, upsertAge
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender";
import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/ai/aiMarkdown";
import { aiCancelStream, saveAiConversation, saveAiRun, saveAiRunState, loadAiConversations, loadAiRuns, deleteAiConversation, listSchemas, listTables, type AiConversation, type AiRun, type AiRunStatus } from "@/lib/backend/api";
import { aiCancelStream, saveAiConversation, saveAiRun, saveAiRunState, loadAiConversations, loadAiRuns, deleteAiConversation, listSchemas, listTables, readUserSkills, type AiConversation, type AiRun, type AiRunStatus } from "@/lib/backend/api";
import type { AiMessage } from "@/lib/backend/api";
import type { AiConfigItem, AiEffortCapability, AiEffortOption, AiEffortSelection } from "@/types/ai";
import type { ConnectionConfig, QueryTab, SavedSqlFile, TableInfo } from "@/types/database";
@@ -292,6 +295,7 @@ const emit = defineEmits<{
openExplainPlan: [sql: string];
toggleMaximize: [];
close: [];
openSettings: [];
}>();
const prompt = ref("");
@@ -498,6 +502,81 @@ watch(
},
);
// User skills (read-only SKILL.md files). Selection is panel-session
// scope like activeTemplateIds above: closing the panel or restarting clears it,
// conversation switches keep it (prd.md:30).
const userSkillStore = useUserSkillStore();
const selectedSkillIds = ref<string[]>([]);
const showSkillSelector = ref(false);
const skillFailures = ref<ReadUserSkillFailure[]>([]);
// Chips derive from the selection truth, not from the catalog: a skill that
// vanished from discovery must keep a removable chip (prd.md:37) instead of
// silently becoming an id the user can no longer drop.
const selectedSkillChips = computed(() => buildSelectedSkillChips(selectedSkillIds.value, (id) => userSkillStore.metaFor(id)));
// Selector retry-on-open mirrors the template selector above.
watch(showSkillSelector, (open) => {
if (open) void userSkillStore.refresh(skillRootSettings());
});
watch(
() => [settings.desktopSettings?.custom_ai_skill_root_enabled, settings.desktopSettings?.custom_ai_skill_root] as const,
() => {
if (showSkillSelector.value) void userSkillStore.refresh(skillRootSettings());
},
);
function skillRootSettings(): UserSkillRootSettings {
return {
customRootEnabled: settings.desktopSettings?.custom_ai_skill_root_enabled === true,
customRoot: settings.desktopSettings?.custom_ai_skill_root?.trim() || null,
};
}
function toggleSkillSelected(id: string) {
skillFailures.value = [];
selectedSkillIds.value = selectedSkillIds.value.includes(id) ? selectedSkillIds.value.filter((skillId) => skillId !== id) : [...selectedSkillIds.value, id];
}
function removeSelectedSkill(id: string) {
selectedSkillIds.value = removeSkillIds(selectedSkillIds.value, [id]);
skillFailures.value = [];
}
/** Banner Remove: drops every failed skill from the selection, so the next send is not blocked by a stale id. */
function removeFailedSkills() {
selectedSkillIds.value = removeSkillIds(
selectedSkillIds.value,
skillFailures.value.map((failure) => failure.id),
);
skillFailures.value = [];
}
function refreshSkills() {
void userSkillStore.refresh(skillRootSettings());
}
const skillFailureReasonKeys: Record<UserSkillFailureReason, string> = {
not_found: "ai.skillsReasonNotFound",
root_unavailable: "ai.skillsReasonRootUnavailable",
oversized: "ai.skillsReasonOversized",
not_utf8: "ai.skillsReasonNotUtf8",
invalid_frontmatter: "ai.skillsReasonInvalidFrontmatter",
unreadable: "ai.skillsReasonUnreadable",
};
function skillFailureReasonKey(reason: UserSkillFailureReason): string {
return skillFailureReasonKeys[reason];
}
function openSkillRootSettings() {
skillFailures.value = [];
emit("openSettings");
}
function selectedSkillsAgentStep(skills: ReadUserSkill[]): AiAgentStepItem {
const details = skills.map((skill) => `${skill.name} (${t(userSkillSourceOfId(skill.id) === "custom" ? "ai.skillsGroupCustom" : "ai.skillsGroupDefault")})`).join(" · ");
return { key: "selected-skills", labelKey: "ai.agentSteps.skillsLoaded", tone: "success", toolName: skills.map((skill) => skill.name).join(", "), toolResult: details };
}
// Retry store load on selector open if prior init failed (e.g. backend not yet ready at mount)
watch(showTemplateSelector, (open) => {
// Retry load on selector open if prior init failed, then apply pending
@@ -1116,9 +1195,19 @@ const commandOpen = ref(false);
const commandSelectedIndex = ref(0);
const commandStart = ref(0);
const filteredCommands = computed(() => {
/**
* `/skill` is a selector shortcut, not an action: it rides a separate command
* source and never touches `activeAction` or the mode+action picker (prd.md:31).
*/
type AiSlashEntry = { type: "action"; button: AiActionButton } | { type: "skills" };
const filteredCommands = computed<AiSlashEntry[]>(() => {
const query = prompt.value.slice(commandStart.value + 1).toLowerCase();
return actionButtons.value.filter((cmd) => cmd.action.toLowerCase().includes(query) || t(cmd.key).toLowerCase().includes(query));
const entries: AiSlashEntry[] = [...actionButtons.value.map((button): AiSlashEntry => ({ type: "action", button })), { type: "skills" }];
return entries.filter((entry) => {
const haystack = entry.type === "action" ? `${entry.button.action} ${t(entry.button.key)}` : `skill ${t("ai.skillsEntry")}`;
return haystack.toLowerCase().includes(query);
});
});
const AI_SQL_FILE_MENTION_CANDIDATE_LIMIT = 50;
@@ -2486,12 +2575,18 @@ function onPromptKeyup(event: KeyboardEvent) {
refreshMentionState();
}
function selectCommand(command: AiActionButton) {
function selectCommand(command: AiSlashEntry) {
const before = prompt.value.slice(0, commandStart.value);
const after = prompt.value.slice(promptTextareaRef.value?.selectionStart ?? prompt.value.length);
prompt.value = `${before}${after}`.replace(/\s{2,}/g, " ").trim();
commandOpen.value = false;
activeAction.value = command.action;
if (command.type === "skills") {
skillFailures.value = [];
showSkillSelector.value = true;
nextTick(() => promptTextareaRef.value?.focus());
return;
}
activeAction.value = command.button.action;
nextTick(() => {
const textarea = promptTextareaRef.value;
if (textarea) {
@@ -3030,6 +3125,38 @@ async function send() {
}
const generationCanContinue = () => (detachedRun ? !detachedRun.cancelRequested : aiGenerationGuard.isCurrent(myGeneration));
const runIsVisible = () => !detachedRun || (assistantViewMounted && conversationId.value === runConversationId);
// Selected skills are read and validated through the backend registry right
// before the request pipeline starts (after the generation guard: no awaits
// above this point). On any failure no AI request is sent: the failure banner
// appears above the composer while the draft and selection stay untouched
// (prd send-time loading contract).
let sendSkillSnapshot: ReadUserSkill[] | undefined;
if (selectedSkillIds.value.length > 0) {
skillFailures.value = [];
const skillRead = await readUserSkills([...selectedSkillIds.value], skillRootSettings());
if (skillRead.failures.length > 0) {
skillFailures.value = skillRead.failures;
clearPendingWriteGrant();
if (generationCanContinue()) {
if (detachedRun) finishDesktopAiRun(detachedRun, "failed");
if (runIsVisible()) {
isGenerating.value = false;
stopStatusTimer();
generationStatus.value = createGenerationStatus(Date.now());
}
}
resolveDetachedRunSettled();
return;
}
// Skill bodies are only known here, so the combined budget is enforced on
// the read snapshots: overflowing skills are skipped for this send while
// the selection itself stays untouched.
const cappedSkills = capSkillsToCharLimit(skillRead.skills, ACTIVE_SKILLS_TOTAL_MAX);
if (cappedSkills.length < skillRead.skills.length) {
toast(t("ai.skillsTotalTrimmed", { max: ACTIVE_SKILLS_TOTAL_MAX }), 4000);
}
sendSkillSnapshot = cappedSkills;
}
if (!(await promptTemplateStore.ensureLoaded())) {
clearPendingWriteGrant();
if (generationCanContinue()) {
@@ -3068,6 +3195,7 @@ async function send() {
const customPromptContext: CustomPromptContext = {
globalInstructions: promptTemplateStore.globalInstructions,
activeTemplates: [...activeTemplates.value],
...(sendSkillSnapshot?.length ? { selectedSkills: sendSkillSnapshot } : {}),
};
// 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
@@ -3229,6 +3357,9 @@ async function send() {
}
runMessages.push({ role: "assistant", content: "", sourceConnectionName: connection.name });
const assistantIdx = runMessages.length - 1;
if (requestedMode === "agent" && sendSkillSnapshot?.length) {
runMessages[assistantIdx].agentSteps = [selectedSkillsAgentStep(sendSkillSnapshot)];
}
const sessionId = uuid();
if (runIsVisible()) {
currentAssistantMessageIndex = assistantIdx;
@@ -3448,8 +3579,9 @@ async function send() {
statusNow.value = Date.now();
}
// Render agent tool call steps from agent events (fallback when no real-time steps)
if (msg && agentEvents.length > 0 && !msg.agentSteps?.length) {
const steps: AiAgentStepItem[] = [];
const hasRuntimeSteps = msg?.agentSteps?.some((step) => step.key !== "selected-skills") ?? false;
if (msg && agentEvents.length > 0 && !hasRuntimeSteps) {
const steps: AiAgentStepItem[] = [...(msg.agentSteps ?? [])];
agentEvents.forEach((e, index) => {
const step = agentEventToStep(e, index, Date.now());
if (step) upsertAgentStep(steps, step);
@@ -3457,7 +3589,7 @@ async function send() {
if (steps.length) msg.agentSteps = steps;
}
// Fallback: use aiAgentPlan for backward compatibility
if (msg && !msg.agentSteps?.length) {
if (msg && !hasRuntimeSteps) {
const agentPlan = buildAiAgentPlan({
mode: requestedMode,
action: requestedAction,
@@ -3466,7 +3598,7 @@ async function send() {
connection: connection,
database: tab.database,
});
if (msg && requestedMode === "agent") msg.agentSteps = buildAiAgentStepItems(agentPlan);
if (msg && requestedMode === "agent") msg.agentSteps = [...(msg.agentSteps ?? []), ...buildAiAgentStepItems(agentPlan)];
if (agentPlan.handoffSql) emit("requestAutoExecuteSql", agentPlan.handoffSql);
}
if (runIsVisible()) {
@@ -5358,6 +5490,56 @@ async function openExternalUrl(url: string) {
</div>
</PopoverContent>
</Popover>
<!-- Skill selector (read-only user SKILL.md library) -->
<Popover v-model:open="showSkillSelector">
<PopoverTrigger as-child>
<button type="button" class="flex min-w-0 items-center gap-1 rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('ai.skillsEntry')" :title="t('ai.skillsEntry')">
<Layers class="h-3 w-3" />
<span class="truncate">{{ t("ai.skillsEntry") }}</span>
<span v-if="selectedSkillIds.length" class="rounded-sm bg-primary px-1 text-[10px] font-medium text-primary-foreground">{{ selectedSkillIds.length }}</span>
</button>
</PopoverTrigger>
<PopoverContent align="end" class="w-72 gap-0 p-1.5">
<div class="max-h-64 overflow-auto">
<div v-if="userSkillStore.isLoading && !userSkillStore.hasLoadedOnce" class="px-3 py-4 text-center text-xs text-muted-foreground">
{{ t("ai.skillsLoading") }}
</div>
<div v-else-if="userSkillStore.lastError" class="space-y-1 px-3 py-4 text-center text-xs text-muted-foreground">
<div>{{ t("ai.skillsLoadError") }}</div>
<button type="button" class="text-[11px] text-primary hover:underline" @click="refreshSkills">
{{ t("ai.skillsRetry") }}
</button>
</div>
<div v-else-if="userSkillStore.totalCount === 0" class="px-3 py-4 text-center text-xs text-muted-foreground">
{{ t("ai.skillsEmpty") }}
</div>
<template v-else>
<template v-for="group in userSkillStore.groupedSkills" :key="group.source">
<div class="px-2 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{{ t(group.source === "custom" ? "ai.skillsGroupCustom" : "ai.skillsGroupDefault") }}
</div>
<template v-for="skill in group.skills" :key="skill.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="toggleSkillSelected(skill.id)">
<div class="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border" :class="selectedSkillIds.includes(skill.id) ? 'border-primary bg-primary text-primary-foreground' : ''">
<Check v-if="selectedSkillIds.includes(skill.id)" class="h-3 w-3" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="truncate font-medium">{{ skill.name }}</div>
<div class="truncate text-[10px] text-muted-foreground">{{ skill.description }}</div>
</div>
</button>
</template>
</template>
</template>
</div>
<div class="border-t mt-1 px-1 pt-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" :disabled="userSkillStore.isLoading" @click="refreshSkills">
<Loader2 v-if="userSkillStore.isLoading" class="h-3 w-3 animate-spin" />
{{ t("ai.skillsRefresh") }}
</button>
</div>
</PopoverContent>
</Popover>
</div>
<div v-if="mentionOpen" class="absolute bottom-full left-2 right-2 z-20 mb-1 max-h-56 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md">
<div v-if="mentionLoading" class="flex items-center gap-2 px-2 py-2 text-xs text-muted-foreground">
@@ -5394,16 +5576,16 @@ async function openExternalUrl(url: string) {
<div class="max-h-56 overflow-auto p-1">
<button
v-for="(cmd, index) in filteredCommands"
:key="cmd.action"
:key="cmd.type === 'action' ? `action:${cmd.button.action}` : 'skills'"
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted"
:class="{ 'bg-muted': index === commandSelectedIndex }"
@mousedown.prevent="selectCommand(cmd)"
@mouseenter="commandSelectedIndex = index"
>
<component :is="cmd.icon" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="font-medium">/{{ cmd.action }}</span>
<span class="ml-auto text-[11px] text-muted-foreground">{{ t(cmd.key) }}</span>
<component :is="cmd.type === 'action' ? cmd.button.icon : Layers" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="font-medium">/{{ cmd.type === "action" ? cmd.button.action : "skill" }}</span>
<span class="ml-auto text-[11px] text-muted-foreground">{{ t(cmd.type === "action" ? cmd.button.key : "ai.skillsEntry") }}</span>
</button>
</div>
</div>
@@ -5422,6 +5604,23 @@ async function openExternalUrl(url: string) {
<X class="h-3 w-3 shrink-0 text-muted-foreground group-hover:text-foreground" />
</button>
</div>
<div v-if="selectedSkillChips.length" class="mb-1.5 flex flex-wrap gap-1">
<button
v-for="chip in selectedSkillChips"
:key="chip.id"
type="button"
class="group inline-flex max-w-full items-center gap-1 rounded border bg-muted/60 px-1.5 py-0.5 text-[11px] text-foreground/90 hover:bg-muted"
:class="chip.unavailable ? 'border-destructive/50 text-destructive' : 'border-border/80'"
:title="chip.description || chip.name"
@click="removeSelectedSkill(chip.id)"
>
<AlertTriangle v-if="chip.unavailable" class="h-3 w-3 shrink-0" />
<Layers v-else class="h-3 w-3 shrink-0 text-primary" />
<span class="truncate">{{ chip.name }}</span>
<span class="shrink-0 text-[9px] text-muted-foreground">{{ t(chip.source === "custom" ? "ai.skillsGroupCustom" : "ai.skillsGroupDefault") }}</span>
<X class="h-3 w-3 shrink-0 text-muted-foreground group-hover:text-foreground" />
</button>
</div>
<div v-if="selectedCsvAttachments.length || selectedImageAttachments.length" class="mb-1.5 flex max-h-28 flex-wrap gap-1.5 overflow-y-auto pr-0.5">
<AiAttachmentCard
v-for="(attachment, index) in selectedCsvAttachments"
@@ -5455,6 +5654,25 @@ async function openExternalUrl(url: string) {
@remove="removeImageAttachment(index)"
/>
</div>
<div v-if="skillFailures.length" class="mb-1.5 flex items-start gap-1.5 rounded-[7px] border border-destructive/40 bg-destructive/10 px-[9px] py-[5px] text-[11px] text-destructive" role="alert">
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div class="min-w-0 flex-1">
<div class="font-medium">{{ t("ai.skillsSendBlocked") }}</div>
<div v-for="failure in skillFailures" :key="failure.id" class="truncate">{{ userSkillStore.metaFor(failure.id)?.name ?? failure.id }} — {{ t(skillFailureReasonKey(failure.reason)) }}</div>
</div>
<button type="button" class="shrink-0 rounded border border-destructive/40 px-1.5 py-0.5 text-[10px] font-medium hover:bg-destructive/20" @click="send()">
{{ t("ai.skillsRetry") }}
</button>
<button type="button" class="shrink-0 rounded px-1 py-0.5 text-[10px] text-muted-foreground hover:text-foreground" :disabled="userSkillStore.isLoading" @click="refreshSkills">
{{ t("ai.skillsRefresh") }}
</button>
<button v-if="skillFailures.some((failure) => failure.reason === 'root_unavailable')" type="button" class="shrink-0 rounded px-1 py-0.5 text-[10px] text-muted-foreground hover:text-foreground" @click="openSkillRootSettings">
{{ t("ai.skillsOpenSettings") }}
</button>
<button type="button" class="shrink-0 rounded p-0.5 text-muted-foreground hover:text-destructive" :aria-label="t('common.remove')" @click="removeFailedSkills()">
<X class="h-3 w-3" />
</button>
</div>
<div v-if="recoveredDraftActive" class="mb-1.5 flex items-center gap-1.5 rounded-[7px] border border-primary/30 bg-primary/10 px-[9px] py-[5px] text-[11px] text-primary" role="status">
<Clock class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate"> {{ t("ai.recoveredDraftBanner") }} — {{ t("ai.recoveredDraftHint") }} </span>
@@ -18,6 +18,7 @@ import {
Copy,
Download,
ExternalLink,
FolderOpen,
Eye,
Filter,
Globe,
@@ -2791,6 +2792,28 @@ function setSidebarActivation(value: "single" | "double") {
editSidebarActivation.value = value;
}
// Custom AI skill root (read-only SKILL.md discovery; desktop-only, prd 09-21-public-skill-loader).
const customSkillRootDraft = ref("");
watch(
() => settingsStore.desktopSettings.custom_ai_skill_root,
(value) => {
customSkillRootDraft.value = value ?? "";
},
{ immediate: true },
);
async function commitCustomSkillRoot() {
const trimmed = customSkillRootDraft.value.trim();
if ((settingsStore.desktopSettings.custom_ai_skill_root ?? "") === trimmed) return;
await settingsStore.updateDesktopSettings({ custom_ai_skill_root: trimmed || null });
}
async function pickCustomSkillRoot() {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({ directory: true, multiple: false, title: t("settings.aiSkillRoot") });
if (typeof selected !== "string" || !selected) return;
customSkillRootDraft.value = selected;
await settingsStore.updateDesktopSettings({ custom_ai_skill_root: selected });
}
const activeSettingsTab = ref("appearance");
const settingsContentScrollRef = ref<HTMLElement | null>(null);
const isWeb = !isTauriRuntime();
@@ -8942,6 +8965,26 @@ LIMIT 100;</pre
</div>
</div>
<!-- Custom skill directory (list mode, global, desktop-only) -->
<div v-if="aiConfigListMode === 'list' && !isWeb" class="space-y-3">
<Separator />
<div class="settings-item space-y-2 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center justify-between gap-4">
<div class="space-y-1">
<Label for="ai-custom-skill-root">{{ t("settings.aiSkillRoot") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.aiSkillRootDesc") }}</p>
</div>
<Switch id="ai-custom-skill-root" :model-value="settingsStore.desktopSettings.custom_ai_skill_root_enabled === true" @update:model-value="(value) => settingsStore.updateDesktopSettings({ custom_ai_skill_root_enabled: Boolean(value) })" />
</div>
<div class="flex items-center gap-2">
<Input v-model="customSkillRootDraft" :placeholder="t('settings.aiSkillRootPath')" :disabled="settingsStore.desktopSettings.custom_ai_skill_root_enabled !== true" class="h-8 flex-1 font-mono text-xs" @blur="commitCustomSkillRoot" @keydown.enter="commitCustomSkillRoot" />
<Button variant="outline" size="sm" class="h-8 shrink-0 px-2" :disabled="settingsStore.desktopSettings.custom_ai_skill_root_enabled !== true" :aria-label="t('settings.aiSkillRootBrowse')" @click="pickCustomSkillRoot">
<FolderOpen class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
<!-- Max Retries (list mode, global) -->
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
<Separator />