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

Refs: #9363
This commit is contained in:
Abeautifulsnow
2026-09-22 02:24:23 +08:00
committed by GitHub
parent 6af337e67c
commit 34d8811cd3
36 changed files with 1765 additions and 26 deletions
Generated
+1
View File
@@ -2052,6 +2052,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"serde_yaml_ng",
"sha2 0.10.9",
"sys-locale",
"sysinfo",
+1
View File
@@ -4334,6 +4334,7 @@ onUnmounted(() => {
@execute-redis-command="(command: string) => routeAiRedisCommand(command, true)"
@open-explain-plan="onAiOpenExplainPlan"
@toggle-maximize="toggleAiPanelMaximized"
@open-settings="activateSettingsPage"
@close="closeRightSidebarPanel('ai')"
/>
</div>
@@ -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 />
+22
View File
@@ -2986,6 +2986,7 @@ export default withEnglishFallback({
executeSafe: "Təhlükəsizlik yoxlamasından keçdi · İcra edildi",
executeBlocked: "Təhlükəsizlik yoxlaması ilə bloklandı",
contextCompacted: "Kontekst sıxlaşdırıldı",
skillsLoaded: "Skills yükləndi",
},
agentStepTitles: {
riskCheck: "Risk yoxlaması: {action} · {category} · {environment} · {reasons}",
@@ -3239,6 +3240,23 @@ export default withEnglishFallback({
templateSelectorLabel: "Şablonlar: {label}",
templateSelectorDeselectAll: "Bütün seçimləri ləğv et",
templateSelectorLoading: "Yüklənir...",
skillsEntry: "Skills",
skillsLoading: "Skills yüklənir…",
skillsEmpty: "Hələ skill tapılmadı",
skillsRefresh: "Siyahını yenilə",
skillsLoadError: "Skills yüklənmədi",
skillsRetry: "Yenidən cəhd et",
skillsOpenSettings: "Parametrləri aç",
skillsSendBlocked: "Sorğu göndərilmədi",
skillsGroupDefault: "Standart kataloq",
skillsGroupCustom: "Xüsusi kataloq",
skillsReasonNotFound: "Skill qovluğu və ya SKILL.md artık mövcud deyil",
skillsReasonRootUnavailable: "Mənbə kataloğu əlçatmazdır (deaktiv edilib, köçürülüb və ya silinib)",
skillsReasonOversized: "SKILL.md 1 MiB həddini aşır",
skillsReasonNotUtf8: "SKILL.md artık etibarlı UTF-8 deyil",
skillsReasonInvalidFrontmatter: "SKILL.md frontmatter-də name/description yoxdur və ya format yanlışdır",
skillsReasonUnreadable: "SKILL.md oxuna bilmir",
skillsTotalTrimmed: "Bəzi seçilmiş bacarıqlar birgə məzmun həddini ({max} simvol) aşdığı üçün bu göndərmədə ötürüldü.",
templateSelectorEmpty: "Şablon yoxdur. Əlavə etmək üçün İdarə et düyməsinə klikləyin.",
templateSelectorTooLong: "Seçilmiş şablonlar ümumi məzmun limitini ({max} simvol) aşır. Davam etmək üçün bəzilərinin seçimini ləğv edin.",
templateSetDefault: "Standart olaraq təyin et",
@@ -6996,6 +7014,10 @@ export default withEnglishFallback({
snippetsTab: "Kod parçaları",
syncTab: "Sinxronlaşdırma",
aiTab: "AI",
aiSkillRoot: "Xüsusi skill kataloğu",
aiSkillRootDesc: "Əlavə bir SKILL.md skill kataloqunu tarayır. ~/.agents/skills həmişə taranır.",
aiSkillRootPath: "Kataloq yolu",
aiSkillRootBrowse: "Göz at",
mcpTab: "MCP",
jdbcTab: "JDBC drayverləri",
securityTab: "Təhlükəsizlik",
+22
View File
@@ -3180,6 +3180,7 @@ export default {
executeSafe: "Safety check passed · Executed",
executeBlocked: "Blocked by safety check",
contextCompacted: "Context compressed",
skillsLoaded: "Skills loaded",
},
agentStepTitles: {
riskCheck: "Risk check: {action} · {category} · {environment} · {reasons}",
@@ -3433,6 +3434,23 @@ export default {
templateSelectorLabel: "Templates: {label}",
templateSelectorDeselectAll: "Deselect All",
templateSelectorLoading: "Loading...",
skillsEntry: "Skills",
skillsLoading: "Loading skills…",
skillsEmpty: "No skills discovered yet",
skillsRefresh: "Refresh",
skillsLoadError: "Could not load skills",
skillsRetry: "Retry",
skillsOpenSettings: "Open Settings",
skillsSendBlocked: "Request not sent",
skillsGroupDefault: "Default directory",
skillsGroupCustom: "Custom directory",
skillsReasonNotFound: "The skill folder or SKILL.md no longer exists",
skillsReasonRootUnavailable: "Its source directory is unavailable (disabled, moved, or deleted)",
skillsReasonOversized: "SKILL.md exceeds the 1 MiB size limit",
skillsReasonNotUtf8: "SKILL.md is no longer valid UTF-8",
skillsReasonInvalidFrontmatter: "SKILL.md frontmatter is missing name/description or is malformed",
skillsReasonUnreadable: "SKILL.md could not be read",
skillsTotalTrimmed: "Some selected skills exceed the combined content limit ({max} characters) and were skipped for this send.",
templateSelectorEmpty: "No templates. Click Manage to add.",
templateSelectorTooLong: "Selected templates exceed the total content limit ({max} characters). Deselect some to continue.",
templateSetDefault: "Set as default",
@@ -7476,6 +7494,10 @@ export default {
snippetsTab: "Snippets",
syncTab: "Sync",
aiTab: "AI",
aiSkillRoot: "Custom skill directory",
aiSkillRootDesc: "Scan one extra directory for SKILL.md skills. The ~/.agents/skills directory is always scanned.",
aiSkillRootPath: "Directory path",
aiSkillRootBrowse: "Browse",
mcpTab: "MCP",
jdbcTab: "JDBC Drivers",
securityTab: "Security",
+22
View File
@@ -3205,6 +3205,23 @@ export default withEnglishFallback({
templateSelectorLabel: "Plantillas: {label}",
templateSelectorDeselectAll: "Deseleccionar todas",
templateSelectorLoading: "Cargando...",
skillsEntry: "Skills",
skillsLoading: "Cargando skills…",
skillsEmpty: "Aún no se han detectado skills",
skillsRefresh: "Actualizar lista",
skillsLoadError: "No se pudieron cargar las skills",
skillsRetry: "Reintentar",
skillsOpenSettings: "Abrir configuración",
skillsSendBlocked: "Solicitud no enviada",
skillsGroupDefault: "Directorio predeterminado",
skillsGroupCustom: "Directorio personalizado",
skillsReasonNotFound: "La carpeta de la skill o SKILL.md ya no existe",
skillsReasonRootUnavailable: "Su directorio de origen no está disponible (desactivado, movido o eliminado)",
skillsReasonOversized: "SKILL.md supera el límite de 1 MiB",
skillsReasonNotUtf8: "SKILL.md ya no es UTF-8 válido",
skillsReasonInvalidFrontmatter: "El frontmatter de SKILL.md no tiene name/description o no es válido",
skillsReasonUnreadable: "No se pudo leer SKILL.md",
skillsTotalTrimmed: "Algunas skills seleccionadas superan el límite combinado de contenido ({max} caracteres) y se omitieron en este envío.",
templateSelectorEmpty: "Sin plantillas. Haz clic en Gestionar para añadir.",
templateSelectorTooLong: "Las plantillas seleccionadas superan el límite total de contenido ({max} caracteres). Deselecciona algunas para continuar.",
templateSetDefault: "Establecer como predeterminada",
@@ -3254,6 +3271,7 @@ export default withEnglishFallback({
executeSafe: "Verificación de seguridad superada · Ejecutado",
executeBlocked: "Bloqueado por verificación de seguridad",
contextCompacted: "Contexto comprimido",
skillsLoaded: "Skills cargados",
},
agentStepTitles: {
riskCheck: "Verificación de riesgo: {action} · {category} · {environment} · {reasons}",
@@ -6877,6 +6895,10 @@ export default withEnglishFallback({
snippetsTab: "Fragmentos",
syncTab: "Sync",
aiTab: "IA",
aiSkillRoot: "Directorio de skills personalizado",
aiSkillRootDesc: "Analiza un directorio adicional de skills SKILL.md. ~/.agents/skills siempre se analiza.",
aiSkillRootPath: "Ruta del directorio",
aiSkillRootBrowse: "Examinar",
mcpTab: "MCP",
jdbcTab: "Drivers JDBC",
securityTab: "Seguridad",
+22
View File
@@ -3098,6 +3098,23 @@ export default withEnglishFallback({
templateSelectorLabel: "Modelli: {label}",
templateSelectorDeselectAll: "Deseleziona tutto",
templateSelectorLoading: "Caricamento...",
skillsEntry: "Skills",
skillsLoading: "Caricamento skill…",
skillsEmpty: "Nessuna skill trovata",
skillsRefresh: "Aggiorna elenco",
skillsLoadError: "Impossibile caricare le skill",
skillsRetry: "Riprova",
skillsOpenSettings: "Apri impostazioni",
skillsSendBlocked: "Richiesta non inviata",
skillsGroupDefault: "Directory predefinita",
skillsGroupCustom: "Directory personalizzata",
skillsReasonNotFound: "La cartella della skill o SKILL.md non esiste più",
skillsReasonRootUnavailable: "La directory di origine non è disponibile (disabilitata, spostata o eliminata)",
skillsReasonOversized: "SKILL.md supera il limite di 1 MiB",
skillsReasonNotUtf8: "SKILL.md non è più UTF-8 valido",
skillsReasonInvalidFrontmatter: "Il frontmatter di SKILL.md non ha name/description o non è valido",
skillsReasonUnreadable: "Impossibile leggere SKILL.md",
skillsTotalTrimmed: "Alcune skill selezionate superano il limite combinato di contenuto ({max} caratteri) e sono state saltate in questo invio.",
templateSelectorEmpty: "Nessun modello. Clicca Gestisci per aggiungerne.",
templateSelectorTooLong: "I modelli selezionati superano il limite totale di contenuto ({max} caratteri). Deseleziona alcuni per continuare.",
templateSetDefault: "Imposta come predefinito",
@@ -3147,6 +3164,7 @@ export default withEnglishFallback({
executeSafe: "Controllo di sicurezza superato · Eseguito",
executeBlocked: "Bloccato dal controllo di sicurezza",
contextCompacted: "Contesto compresso",
skillsLoaded: "Skills caricati",
},
agentStepTitles: {
riskCheck: "Controllo rischi: {action} · {category} · {environment} · {reasons}",
@@ -6876,6 +6894,10 @@ export default withEnglishFallback({
snippetsTab: "Snippet",
syncTab: "Sincronizzazione",
aiTab: "AI",
aiSkillRoot: "Directory skill personalizzata",
aiSkillRootDesc: "Analizza una directory aggiuntiva di skill SKILL.md. ~/.agents/skills viene sempre analizzata.",
aiSkillRootPath: "Percorso directory",
aiSkillRootBrowse: "Sfoglia",
mcpTab: "MCP",
jdbcTab: "Driver JDBC",
securityTab: "Sicurezza",
+22
View File
@@ -3239,6 +3239,23 @@ export default withEnglishFallback({
templateSelectorLabel: "テンプレート: {label}",
templateSelectorDeselectAll: "すべて解除",
templateSelectorLoading: "読み込み中...",
skillsEntry: "Skills",
skillsLoading: "Skills を読み込み中…",
skillsEmpty: "利用可能な Skills がありません",
skillsRefresh: "一覧を更新",
skillsLoadError: "Skills の読み込みに失敗しました",
skillsRetry: "再試行",
skillsOpenSettings: "設定を開く",
skillsSendBlocked: "リクエストは送信されませんでした",
skillsGroupDefault: "既定ディレクトリ",
skillsGroupCustom: "カスタムディレクトリ",
skillsReasonNotFound: "Skill フォルダーまたは SKILL.md が存在しません",
skillsReasonRootUnavailable: "ソースディレクトリを利用できません(無効・移動・削除)",
skillsReasonOversized: "SKILL.md が 1 MiB のサイズ上限を超えています",
skillsReasonNotUtf8: "SKILL.md は有効な UTF-8 ではありません",
skillsReasonInvalidFrontmatter: "SKILL.md の frontmatter に name/description がないか形式が不正です",
skillsReasonUnreadable: "SKILL.md を読み取れませんでした",
skillsTotalTrimmed: "選択した一部のスキルが合計上限({max} 文字)を超えたため、今回の送信ではスキップされました。",
templateSelectorEmpty: "テンプレートがありません。「管理」をクリックして追加してください。",
templateSelectorTooLong: "選択されたテンプレートの合計が上限({max}文字)を超えています。選択を解除してください。",
templateSetDefault: "デフォルトに設定",
@@ -3288,6 +3305,7 @@ export default withEnglishFallback({
executeSafe: "安全チェック通過 · 実行済み",
executeBlocked: "安全チェックによりブロック",
contextCompacted: "コンテキストを圧縮しました",
skillsLoaded: "Skills を読み込みました",
},
agentStepTitles: {
riskCheck: "リスクチェック: {action} · {category} · {environment} · {reasons}",
@@ -6913,6 +6931,10 @@ export default withEnglishFallback({
snippetsTab: "スニペット",
syncTab: "同期",
aiTab: "AI",
aiSkillRoot: "カスタム Skill ディレクトリ",
aiSkillRootDesc: "SKILL.md スキルのディレクトリを追加で 1 件スキャンします。~/.agents/skills は常にスキャンされます。",
aiSkillRootPath: "ディレクトリパス",
aiSkillRootBrowse: "参照",
mcpTab: "MCP",
jdbcTab: "JDBCドライバー",
securityTab: "セキュリティ",
+22
View File
@@ -2977,6 +2977,7 @@ export default withEnglishFallback({
executeSafe: "안전성 검사 통과 · 실행됨",
executeBlocked: "안전성 검사로 차단됨",
contextCompacted: "컨텍스트 압축됨",
skillsLoaded: "Skills 로드됨",
},
agentStepTitles: {
riskCheck: "위험 검사: {action} · {category} · {environment} · {reasons}",
@@ -3224,6 +3225,23 @@ export default withEnglishFallback({
templateSelectorLabel: "템플릿: {label}",
templateSelectorDeselectAll: "모두 선택 해제",
templateSelectorLoading: "불러오는 중...",
skillsEntry: "Skills",
skillsLoading: "Skills 을(를) 로드하는 중…",
skillsEmpty: "사용 가능한 Skills 이(가) 없습니다",
skillsRefresh: "목록 새로 고침",
skillsLoadError: "Skills 로드 실패",
skillsRetry: "다시 시도",
skillsOpenSettings: "설정 열기",
skillsSendBlocked: "요청이 전송되지 않았습니다",
skillsGroupDefault: "기본 디렉터리",
skillsGroupCustom: "사용자 지정 디렉터리",
skillsReasonNotFound: "Skill 폴더 또는 SKILL.md 이(가) 더 이상 존재하지 않습니다",
skillsReasonRootUnavailable: "소스 디렉터리를 사용할 수 없습니다(비활성화, 이동 또는 삭제됨)",
skillsReasonOversized: "SKILL.md 이(가) 1 MiB 크기 제한을 초과합니다",
skillsReasonNotUtf8: "SKILL.md 이(가) 더 이상 유효한 UTF-8 이(가) 아닙니다",
skillsReasonInvalidFrontmatter: "SKILL.md frontmatter 에 name/description 이(가) 없거나 형식이 잘못되었습니다",
skillsReasonUnreadable: "SKILL.md 을(를) 읽을 수 없습니다",
skillsTotalTrimmed: "선택한 일부 스킬이 결합 콘텐츠 한도({max}자)를 초과하여 이번 전송에서 건너뛰었습니다.",
templateSelectorEmpty: "템플릿이 없습니다. 추가하려면 관리를 클릭하세요.",
templateSelectorTooLong: "선택한 템플릿이 전체 내용 한도를 초과합니다 ({max}자). 계속하려면 일부를 선택 해제하세요.",
templateSetDefault: "기본값으로 설정",
@@ -6646,6 +6664,10 @@ export default withEnglishFallback({
snippetsTab: "스니펫",
syncTab: "동기화",
aiTab: "AI",
aiSkillRoot: "사용자 지정 Skill 디렉터리",
aiSkillRootDesc: "SKILL.md 스킬 디렉터리를 하나 더 스캔합니다. ~/.agents/skills 는 항상 스캔됩니다.",
aiSkillRootPath: "디렉터리 경로",
aiSkillRootBrowse: "찾아보기",
mcpTab: "MCP",
jdbcTab: "JDBC 드라이버",
securityTab: "보안",
+22
View File
@@ -3206,6 +3206,23 @@ export default withEnglishFallback({
templateSelectorLabel: "Modelos: {label}",
templateSelectorDeselectAll: "Desmarcar todos",
templateSelectorLoading: "Carregando...",
skillsEntry: "Skills",
skillsLoading: "Carregando skills…",
skillsEmpty: "Nenhuma skill encontrada ainda",
skillsRefresh: "Atualizar lista",
skillsLoadError: "Falha ao carregar skills",
skillsRetry: "Tentar novamente",
skillsOpenSettings: "Abrir configurações",
skillsSendBlocked: "Solicitação não enviada",
skillsGroupDefault: "Diretório padrão",
skillsGroupCustom: "Diretório personalizado",
skillsReasonNotFound: "A pasta da skill ou o SKILL.md não existe mais",
skillsReasonRootUnavailable: "O diretório de origem está indisponível (desativado, movido ou excluído)",
skillsReasonOversized: "SKILL.md excede o limite de 1 MiB",
skillsReasonNotUtf8: "SKILL.md não é mais UTF-8 válido",
skillsReasonInvalidFrontmatter: "O frontmatter do SKILL.md está sem name/description ou é inválido",
skillsReasonUnreadable: "Não foi possível ler o SKILL.md",
skillsTotalTrimmed: "Algumas skills selecionadas excedem o limite combinado de conteúdo ({max} caracteres) e foram ignoradas neste envio.",
templateSelectorEmpty: "Nenhum modelo. Clique em Gerenciar para adicionar.",
templateSelectorTooLong: "Os modelos selecionados excedem o limite total de conteúdo ({max} caracteres). Desmarque alguns para continuar.",
templateSetDefault: "Definir como padrão",
@@ -3255,6 +3272,7 @@ export default withEnglishFallback({
executeSafe: "Verificação de segurança aprovada · Executado",
executeBlocked: "Bloqueado pela verificação de segurança",
contextCompacted: "Contexto compactado",
skillsLoaded: "Skills carregados",
},
agentStepTitles: {
riskCheck: "Verificação de risco: {action} · {category} · {environment} · {reasons}",
@@ -6878,6 +6896,10 @@ export default withEnglishFallback({
snippetsTab: "Snippets",
syncTab: "Sincronização",
aiTab: "IA",
aiSkillRoot: "Diretório de skills personalizado",
aiSkillRootDesc: "Examina um diretório adicional de skills SKILL.md. ~/.agents/skills é sempre examinado.",
aiSkillRootPath: "Caminho do diretório",
aiSkillRootBrowse: "Procurar",
mcpTab: "MCP",
jdbcTab: "Drivers JDBC",
securityTab: "Segurança",
+22
View File
@@ -2962,6 +2962,7 @@ export default withEnglishFallback({
executeSafe: "Güvenlik denetimi geçildi · Çalıştırıldı",
executeBlocked: "Güvenlik denetimi tarafından engellendi",
contextCompacted: "Bağlam sıkıştırıldı",
skillsLoaded: "Skills yüklendi",
},
agentStepTitles: {
riskCheck: "Risk denetimi: {action} · {category} · {environment} · {reasons}",
@@ -3212,6 +3213,23 @@ export default withEnglishFallback({
templateSelectorLabel: "Şablonlar: {label}",
templateSelectorDeselectAll: "Tüm Seçimleri Kaldır",
templateSelectorLoading: "Yükleniyor...",
skillsEntry: "Skills",
skillsLoading: "Skills yükleniyor…",
skillsEmpty: "Henüz skill bulunamadı",
skillsRefresh: "Listeyi yenile",
skillsLoadError: "Skills yüklenemedi",
skillsRetry: "Tekrar dene",
skillsOpenSettings: "Ayarları aç",
skillsSendBlocked: "İstek gönderilmedi",
skillsGroupDefault: "Varsayılan dizin",
skillsGroupCustom: "Özel dizin",
skillsReasonNotFound: "Skill klasörü veya SKILL.md artık mevcut değil",
skillsReasonRootUnavailable: "Kaynak dizini kullanılamıyor (devre dışı, taşınmış veya silinmiş)",
skillsReasonOversized: "SKILL.md 1 MiB sınırını aşıyor",
skillsReasonNotUtf8: "SKILL.md artık geçerli UTF-8 değil",
skillsReasonInvalidFrontmatter: "SKILL.md frontmatter içinde name/description eksik veya biçim geçersiz",
skillsReasonUnreadable: "SKILL.md okunamadı",
skillsTotalTrimmed: "Bazı seçili beceriler birleşik içerik sınırını ({max} karakter) aştığı için bu gönderimde atlandı.",
templateSelectorEmpty: "Şablon yok. Eklemek için Yönet'e tıklayın.",
templateSelectorTooLong: "Seçili şablonlar toplam içerik sınırını aşıyor ({max} karakter). Devam etmek için bazılarının seçimini kaldırın.",
templateSetDefault: "Varsayılan yap",
@@ -6878,6 +6896,10 @@ export default withEnglishFallback({
snippetsTab: "Parçacıklar",
syncTab: "Eşitle",
aiTab: "Yapay Zekâ",
aiSkillRoot: "Özel skill dizini",
aiSkillRootDesc: "Bir SKILL.md skill dizinini daha tarar. ~/.agents/skills her zaman taranır.",
aiSkillRootPath: "Dizin yolu",
aiSkillRootBrowse: "Gözat",
mcpTab: "MCP",
jdbcTab: "JDBC Sürücüleri",
securityTab: "Güvenlik",
+22
View File
@@ -3092,6 +3092,7 @@ export default withEnglishFallback({
executeSafe: "安全检查通过 · 已执行",
executeBlocked: "安全检查拦截",
contextCompacted: "上下文已压缩",
skillsLoaded: "已加载上下文 Skills",
},
agentStepTitles: {
riskCheck: "风险检查:{action} · {category} · {environment} · {reasons}",
@@ -3345,6 +3346,23 @@ export default withEnglishFallback({
templateSelectorLabel: "模板: {label}",
templateSelectorDeselectAll: "全不选",
templateSelectorLoading: "加载中...",
skillsEntry: "Skills",
skillsLoading: "正在加载 Skills…",
skillsEmpty: "未发现可用 Skill",
skillsRefresh: "刷新列表",
skillsLoadError: "Skills 加载失败",
skillsRetry: "重试",
skillsOpenSettings: "打开设置",
skillsSendBlocked: "请求未发送",
skillsGroupDefault: "默认目录",
skillsGroupCustom: "自定义目录",
skillsReasonNotFound: "Skill 目录或 SKILL.md 已不存在",
skillsReasonRootUnavailable: "来源目录不可用(已禁用、移动或删除)",
skillsReasonOversized: "SKILL.md 超过 1 MiB 大小限制",
skillsReasonNotUtf8: "SKILL.md 已不是有效的 UTF-8 编码",
skillsReasonInvalidFrontmatter: "SKILL.md frontmatter 缺少 name/description 或格式无效",
skillsReasonUnreadable: "无法读取 SKILL.md",
skillsTotalTrimmed: "部分已选 Skills 合计超出内容上限(最多 {max} 字符),本次发送已跳过。",
templateSelectorEmpty: "暂无模板,请点击管理添加。",
templateSelectorTooLong: "已选模板内容合计超过限制(最多 {max} 字符),请取消部分勾选。",
templateSetDefault: "设为默认",
@@ -7448,6 +7466,10 @@ export default withEnglishFallback({
snippetsTab: "代码片段",
syncTab: "同步",
aiTab: "AI",
aiSkillRoot: "自定义 Skill 目录",
aiSkillRootDesc: "额外扫描一个 SKILL.md 技能目录。~/.agents/skills 目录始终会被扫描。",
aiSkillRootPath: "目录路径",
aiSkillRootBrowse: "浏览",
mcpTab: "MCP",
jdbcTab: "JDBC 驱动",
securityTab: "安全",
+22
View File
@@ -3097,6 +3097,23 @@ export default withEnglishFallback({
templateSelectorLabel: "範本: {label}",
templateSelectorDeselectAll: "取消全選",
templateSelectorLoading: "載入中...",
skillsEntry: "Skills",
skillsLoading: "正在載入 Skills…",
skillsEmpty: "未發現可用 Skill",
skillsRefresh: "重新整理清單",
skillsLoadError: "Skills 載入失敗",
skillsRetry: "重試",
skillsOpenSettings: "開啟設定",
skillsSendBlocked: "請求未送出",
skillsGroupDefault: "預設目錄",
skillsGroupCustom: "自訂目錄",
skillsReasonNotFound: "Skill 目錄或 SKILL.md 已不存在",
skillsReasonRootUnavailable: "來源目錄不可用(已停用、移動或刪除)",
skillsReasonOversized: "SKILL.md 超過 1 MiB 大小限制",
skillsReasonNotUtf8: "SKILL.md 已不是有效的 UTF-8 編碼",
skillsReasonInvalidFrontmatter: "SKILL.md frontmatter 缺少 name/description 或格式無效",
skillsReasonUnreadable: "無法讀取 SKILL.md",
skillsTotalTrimmed: "部分已選 Skills 合計超出內容上限(最多 {max} 字元),本次傳送已略過。",
templateSelectorEmpty: "尚無範本,請點擊管理以新增。",
templateSelectorTooLong: "已選範本內容合計超過限制(最多 {max} 字元),請取消部分選取。",
templateSetDefault: "設為預設",
@@ -3121,6 +3138,7 @@ export default withEnglishFallback({
executeSafe: "安全檢查通過 · 已執行",
executeBlocked: "安全檢查攔截",
contextCompacted: "上下文已壓縮",
skillsLoaded: "已載入上下文 Skills",
},
agentStepTitles: {
riskCheck: "風險檢查:{action} · {category} · {environment} · {reasons}",
@@ -6160,6 +6178,10 @@ export default withEnglishFallback({
snippetsTab: "程式碼片段",
syncTab: "同步",
aiTab: "AI",
aiSkillRoot: "自訂 Skill 目錄",
aiSkillRootDesc: "額外掃描一個 SKILL.md 技能目錄。~/.agents/skills 目錄始終會被掃描。",
aiSkillRootPath: "目錄路徑",
aiSkillRootBrowse: "瀏覽",
jdbcTab: "JDBC 驅動程式",
securityTab: "安全",
aboutTab: "關於我們",
@@ -0,0 +1,77 @@
import { beforeAll, describe, expect, it } from "vitest";
import { buildSystemPrompt, type AiContext, type CustomPromptContext } from "@/lib/ai/ai";
import { setLocale } from "@/i18n";
import type { ReadUserSkill } from "@/types/userSkills";
function context(overrides: Partial<AiContext> = {}): AiContext {
return {
connectionId: "conn-1",
connectionName: "Postgres",
databaseType: "postgres",
database: "app",
currentSql: "",
tables: [],
sqlFiles: [],
csvFiles: [],
truncated: false,
...overrides,
};
}
const skill: ReadUserSkill = {
id: "d-abc123",
name: "SQL Review",
description: "Team SQL review rules",
content: "Always prefix reviews with EXPLAIN checks.",
};
// buildSystemPrompt picks zh/en copy via currentLocale(); pin to en so the
// English-string assertions are deterministic regardless of the host OS locale.
beforeAll(async () => {
await setLocale("en");
});
describe("selected skills prompt injection", () => {
it("injects selected skills into the SQL, Redis, and vector branches", () => {
for (const ctx of [context(), context({ databaseType: "redis", connectionName: "Redis", database: "8" }), context({ databaseType: "qdrant", connectionName: "Qdrant", database: "vec" })]) {
const prompt = buildSystemPrompt("general", ctx, "ask", { selectedSkills: [skill] });
expect(prompt).toContain("## Selected Skills (supplementary)");
expect(prompt).toContain("### Skill: SQL Review");
expect(prompt).toContain('<ai-skill id="d-abc123">');
expect(prompt).toContain("Always prefix reviews with EXPLAIN checks.");
expect(prompt).toContain("</ai-skill>");
}
});
it("injects the zh header for zh locale", async () => {
await setLocale("zh-CN");
try {
const prompt = buildSystemPrompt("general", context(), "ask", { selectedSkills: [skill] });
expect(prompt).toContain("## 用户选择的 Skills(补充性)");
expect(prompt).toContain("按原样注入");
} finally {
await setLocale("en");
}
});
it("leaves prompts byte-for-byte unchanged when no skills are selected", () => {
// AC: with no selected skills, every prompt branch is byte-identical to the
// pre-feature baseline. Absent field, empty array, and undefined custom all
// must agree exactly.
const baseline = buildSystemPrompt("general", context(), "ask");
const withGlobals: CustomPromptContext = { globalInstructions: "Global rule." };
const withEmptySkills: CustomPromptContext = { globalInstructions: "Global rule.", selectedSkills: [] };
for (const ctx of [context(), context({ databaseType: "redis", connectionName: "Redis", database: "8" }), context({ databaseType: "milvus", connectionName: "Milvus", database: "vec" })]) {
expect(buildSystemPrompt("general", ctx, "ask")).toBe(buildSystemPrompt("general", ctx, "ask", undefined));
expect(buildSystemPrompt("general", ctx, "ask", withEmptySkills)).toBe(buildSystemPrompt("general", ctx, "ask", withGlobals));
expect(buildSystemPrompt("general", ctx, "ask", withEmptySkills)).not.toBe(baseline); // globals still apply
}
});
it("treats blank-content skills as absent", () => {
const blank: ReadUserSkill = { ...skill, content: " " };
const withBlank = buildSystemPrompt("general", context(), "ask", { selectedSkills: [blank] });
expect(withBlank).toBe(buildSystemPrompt("general", context(), "ask"));
expect(withBlank).not.toContain("ai-skill");
});
});
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { buildSelectedSkillChips, capSkillsToCharLimit, removeSkillIds, userSkillSourceOfId } from "@/lib/ai/userSkillSelection";
import { ACTIVE_SKILLS_TOTAL_MAX, type ReadUserSkill, type UserSkillMeta } from "@/types/userSkills";
const catalog: Record<string, UserSkillMeta> = {
"d-aaa": { id: "d-aaa", name: "SQL Review", description: "review rules" },
"c-bbb": { id: "c-bbb", name: "Team Rules", description: "team rules" },
};
const lookup = (id: string): UserSkillMeta | undefined => catalog[id];
function skill(id: string, content: string): ReadUserSkill {
return { id, name: id, description: "", content };
}
describe("selected skill chips", () => {
it("keeps a chip for every selected id, including vanished ones", () => {
// Regression anchor for "unavailable skills have a visible recovery action":
// `d-gone` is absent from the catalog (deleted after selection + refresh) and
// must still render a removable chip rather than silently disappearing.
const chips = buildSelectedSkillChips(["d-aaa", "d-gone", "c-bbb"], lookup);
expect(chips.map((chip) => chip.id)).toEqual(["d-aaa", "d-gone", "c-bbb"]);
expect(chips[1]).toEqual({ id: "d-gone", name: "d-gone", description: "", source: "default", unavailable: true });
expect(chips[0].unavailable).toBe(false);
expect(chips[0].name).toBe("SQL Review");
});
it("labels the source from the id prefix so a vanished skill keeps its origin", () => {
expect(userSkillSourceOfId("c-bbb")).toBe("custom");
expect(userSkillSourceOfId("d-aaa")).toBe("default");
const chips = buildSelectedSkillChips(["c-gone"], lookup);
expect(chips[0].source).toBe("custom");
expect(chips[0].unavailable).toBe(true);
});
it("handles an empty selection", () => {
expect(buildSelectedSkillChips([], lookup)).toEqual([]);
});
});
describe("removeSkillIds", () => {
it("drops the given ids and preserves the remaining order", () => {
expect(removeSkillIds(["a", "b", "c"], ["b"])).toEqual(["a", "c"]);
expect(removeSkillIds(["a", "b", "c"], ["a", "c"])).toEqual(["b"]);
});
it("is a no-op for unknown ids and empty removals", () => {
const ids = ["a", "b"];
expect(removeSkillIds(ids, [])).toEqual(ids);
expect(removeSkillIds(ids, ["zzz"])).toEqual(ids);
expect(removeSkillIds(ids, ["a", "a"])).toEqual(["b"]);
});
});
describe("capSkillsToCharLimit", () => {
it("keeps leading skills that fit the combined budget and skips overflowing ones", () => {
// Regression anchor for "selected skills cannot grow the system prompt
// without bound": the first skill fills the budget, the oversized second
// is skipped, and the smaller third still fits.
const big = "x".repeat(60);
const capped = capSkillsToCharLimit([skill("d-1", "a".repeat(30)), skill("d-2", big), skill("d-3", "b".repeat(10))], 50);
expect(capped.map((s) => s.id)).toEqual(["d-1", "d-3"]);
});
it("drops a single skill whose content alone exceeds the budget", () => {
expect(capSkillsToCharLimit([skill("d-1", "x".repeat(ACTIVE_SKILLS_TOTAL_MAX + 1))], ACTIVE_SKILLS_TOTAL_MAX)).toEqual([]);
expect(capSkillsToCharLimit([skill("d-1", "x".repeat(ACTIVE_SKILLS_TOTAL_MAX))], ACTIVE_SKILLS_TOTAL_MAX).map((s) => s.id)).toEqual(["d-1"]);
});
it("preserves selection order and dedupes repeated ids", () => {
const capped = capSkillsToCharLimit([skill("d-1", "a"), skill("d-2", "b"), skill("d-1", "a")], 10);
expect(capped.map((s) => s.id)).toEqual(["d-1", "d-2"]);
});
it("keeps everything under the real budget for realistic sizes", () => {
const skills = Array.from({ length: 8 }, (_, i) => skill(`d-${i}`, "y".repeat(2000)));
expect(capSkillsToCharLimit(skills, ACTIVE_SKILLS_TOTAL_MAX).length).toBe(8);
});
});
+13 -1
View File
@@ -3,6 +3,7 @@ import type { AiAssistantMode } from "@/types/ai";
import { uuid } from "@/lib/common/utils";
import type { ColumnInfo, ConnectionConfig, DatabaseType, ForeignKeyInfo, IndexInfo, QueryResult, QueryTab } from "@/types/database";
import type { PromptTemplate } from "@/types/promptTemplate";
import type { ReadUserSkill } from "@/types/userSkills";
import * as api from "@/lib/backend/api";
import { currentLocale, type Locale } from "@/i18n";
import { aiTableMentionKey, type AiTableMention } from "@/lib/ai/aiTableMentions";
@@ -161,16 +162,27 @@ export interface AiNamespaceSelection {
export interface CustomPromptContext {
globalInstructions?: string;
activeTemplates?: PromptTemplate[];
/** Selected read-only SKILL.md snapshots resolved at send time (09-21-public-skill-loader). */
selectedSkills?: ReadUserSkill[];
}
function buildCustomInstructionLines(custom: CustomPromptContext | undefined, isZh: boolean): string[] {
const global = custom?.globalInstructions?.trim() ?? "";
const templates = (custom?.activeTemplates ?? []).filter((t) => t.content.trim());
if (!global && templates.length === 0) return [];
const skills = (custom?.selectedSkills ?? []).filter((skill) => skill.content.trim());
if (!global && templates.length === 0 && skills.length === 0) return [];
const parts: string[] = [];
if (global) parts.push(global);
parts.push(...templates.map((t) => `### ${t.name}\n${t.content}`));
if (skills.length > 0) {
parts.push(
isZh
? "## 用户选择的 Skills(补充性)\n以下为用户显式选择的外部 SKILL.md 规则文件,按原样注入;上方核心安全及方言规则优先级更高。"
: "## Selected Skills (supplementary)\nThe following external SKILL.md rule files were explicitly selected by the user and are injected as-is. Core safety and dialect rules above take precedence.",
);
parts.push(...skills.map((skill) => `### Skill: ${skill.name}\n<ai-skill id="${skill.id}">\n${skill.content}\n</ai-skill>`));
}
return [
isZh
@@ -0,0 +1,67 @@
import type { ReadUserSkill, UserSkillMeta } from "@/types/userSkills";
import { promptTemplateCharacterCount } from "@/types/promptTemplate";
/** A selected skill rendered as a chip, whether or not it is still discoverable. */
export interface SelectedSkillChip {
id: string;
name: string;
description: string;
source: "custom" | "default";
/** True when the catalog no longer lists this id (deleted, or its root went away). */
unavailable: boolean;
}
/**
* The backend mints ids as `<root>-<digest>` (`c-` custom root, `d-` default
* root). Deriving the source from that prefix instead of from the catalog keeps
* a selected skill labeled and removable after it disappears from discovery.
*/
export function userSkillSourceOfId(id: string): "custom" | "default" {
return id.startsWith("c-") ? "custom" : "default";
}
/**
* Every selected id keeps a chip: a skill that vanished from discovery would
* otherwise lose its only remove affordance and block every later send. The
* fallback label is the opaque id, paired with `unavailable` for styling.
*/
export function buildSelectedSkillChips(ids: readonly string[], lookup: (id: string) => UserSkillMeta | undefined): SelectedSkillChip[] {
return ids.map((id) => {
const meta = lookup(id);
return {
id,
name: meta?.name ?? id,
description: meta?.description ?? "",
source: userSkillSourceOfId(id),
unavailable: !meta,
};
});
}
/** Order-preserving removal, shared by the chip close button and the failure banner. */
export function removeSkillIds(ids: readonly string[], removed: Iterable<string>): string[] {
const dropped = new Set(removed);
return ids.filter((id) => !dropped.has(id));
}
/**
* Keep the leading skills that fit the combined character budget for one
* request. A skill whose content would push the total past maxTotal is
* skipped, but a later smaller skill still fits (same budget semantics as
* capTemplateIdsToCharLimit for templates). Selection order is preserved so
* the injected set matches the chips top to bottom.
*/
export function capSkillsToCharLimit(skills: readonly ReadUserSkill[], maxTotal: number): ReadUserSkill[] {
const kept: ReadUserSkill[] = [];
const seen = new Set<string>();
let total = 0;
for (const skill of skills) {
if (seen.has(skill.id)) continue;
seen.add(skill.id);
const size = promptTemplateCharacterCount(skill.content);
if (total + size > maxTotal) continue;
total += size;
kept.push(skill);
}
return kept;
}
@@ -0,0 +1,47 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const tauri = readFileSync(new URL("../tauri.ts", import.meta.url), "utf8");
const http = readFileSync(new URL("../http.ts", import.meta.url), "utf8");
const api = readFileSync(new URL("../api.ts", import.meta.url), "utf8");
const tauriRegistry = readFileSync(new URL("../../../../../../src-tauri/src/lib.rs", import.meta.url), "utf8");
const webRegistry = readFileSync(new URL("../../../../../../crates/dbx-web/src/main.rs", import.meta.url), "utf8");
const commandModuleSource = readFileSync(new URL("../../../../../../src-tauri/src/commands/user_skills.rs", import.meta.url), "utf8");
function functionBody(source: string, operation: string): string {
const start = source.indexOf(`export async function ${operation}(`);
expect(start, `${operation} transport function`).toBeGreaterThanOrEqual(0);
const end = source.indexOf("\nexport async function ", start + 1);
return source.slice(start, end === -1 ? source.length : end);
}
describe("user skills transport contract", () => {
it("routes list/read through the desktop Tauri backend", () => {
expect(functionBody(tauri, "listUserSkills")).toContain('invoke("list_user_skills"');
expect(functionBody(tauri, "readUserSkills")).toContain('invoke("read_user_skills"');
expect(tauriRegistry).toContain("commands::user_skills::list_user_skills,");
expect(tauriRegistry).toContain("commands::user_skills::read_user_skills,");
expect(api).toContain('listUserSkills = forward("listUserSkills")');
expect(api).toContain('readUserSkills = forward("readUserSkills")');
});
it("keeps the skill service strictly read-only outside its tests", () => {
const production = commandModuleSource.slice(0, commandModuleSource.indexOf("#[cfg(test)]"));
expect(production).not.toContain("fs::write");
expect(production).not.toContain("fs::remove");
expect(production).not.toContain("fs::create_dir");
expect(production).not.toContain("OpenOptions");
});
it("keeps the Web transport explicitly unsupported without adding HTTP routes", () => {
for (const operation of ["listUserSkills", "readUserSkills"]) {
const body = functionBody(http, operation);
expect(body).toContain("only available in the desktop app");
expect(body).not.toContain("post(");
expect(body).not.toContain("fetch(");
}
expect(webRegistry).not.toContain("user_skills");
expect(webRegistry).not.toContain("list_user_skills");
expect(webRegistry).not.toContain("read_user_skills");
});
});
+4
View File
@@ -415,6 +415,10 @@ export const deletePromptTemplate = forward("deletePromptTemplate");
export const getAiGlobalCustomInstructions = forward("getAiGlobalCustomInstructions");
export const setAiGlobalCustomInstructions = forward("setAiGlobalCustomInstructions");
// User Skills (read-only Codex-compatible SKILL.md library)
export const listUserSkills = forward("listUserSkills");
export const readUserSkills = forward("readUserSkills");
// System
export const listSystemFonts = forward("listSystemFonts");
export const listSshConfigHosts = forward("listSshConfigHosts");
+11
View File
@@ -1,5 +1,6 @@
import type { MongoDumpFormat, MongoDumpSourceInput, MongoDumpCatalog, MongoRestoreSourcePreview, MongoDatabaseDumpRequest, MongoDatabaseRestoreRequest, MongoDatabaseDumpProgress } from "./mongodbDumpTypes";
import type { MongoRestoreUpload, MongoSourceReadOptions } from "./mongodbDumpTypes";
import type { UserSkillRootSettings, UserSkillsListResult, UserSkillsReadResult } from "@/types/userSkills";
import type {
ConnectionConfig,
ConnectionTestResult,
@@ -303,6 +304,8 @@ const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
driver_store_dir: null,
plugin_store_dir: null,
agent_store_dir: null,
custom_ai_skill_root_enabled: false,
custom_ai_skill_root: null,
sidebar_table_page_size: 1000,
};
@@ -894,6 +897,14 @@ export async function exportAgentsOffline(_path: string, _driverKeys: string[]):
throw new Error("Offline Agent package export is only available in the desktop app.");
}
export async function listUserSkills(_settings: UserSkillRootSettings): Promise<UserSkillsListResult> {
throw new Error("AI skills are only available in the desktop app.");
}
export async function readUserSkills(_ids: string[], _settings: UserSkillRootSettings): Promise<UserSkillsReadResult> {
throw new Error("AI skills are only available in the desktop app.");
}
export async function importAgentDriver(dbType: string, pathOrFile: string | File): Promise<void> {
let blob: Blob;
let fileName: string;
+11
View File
@@ -1,6 +1,7 @@
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
import type { MongoDumpFormat, MongoDumpSourceInput, MongoDumpCatalog, MongoRestoreSourcePreview, MongoDatabaseDumpRequest, MongoDatabaseRestoreRequest, MongoDatabaseDumpProgress } from "./mongodbDumpTypes";
import type { MongoRestoreUpload, MongoSourceReadOptions } from "./mongodbDumpTypes";
import type { UserSkillRootSettings, UserSkillsListResult, UserSkillsReadResult } from "@/types/userSkills";
import { assertUpdateAllowsCommand } from "@/lib/app/updatePreparation";
import { collectBrowserSupportInfo } from "@/lib/app/supportInfo";
// Re-exported below so the HTTP transport shares one definition; imported here
@@ -290,6 +291,8 @@ export interface DesktopSettings {
driver_store_dir?: string | null;
plugin_store_dir?: string | null;
agent_store_dir?: string | null;
custom_ai_skill_root_enabled?: boolean | null;
custom_ai_skill_root?: string | null;
sidebar_table_page_size?: number | null;
}
@@ -1246,6 +1249,14 @@ export async function setAiGlobalCustomInstructions(content: string): Promise<vo
return invoke("set_ai_global_custom_instructions", { content });
}
export async function listUserSkills(settings: UserSkillRootSettings): Promise<UserSkillsListResult> {
return invoke("list_user_skills", { customRootEnabled: settings.customRootEnabled, customRoot: settings.customRoot });
}
export async function readUserSkills(ids: string[], settings: UserSkillRootSettings): Promise<UserSkillsReadResult> {
return invoke("read_user_skills", { ids, customRootEnabled: settings.customRootEnabled, customRoot: settings.customRoot });
}
export async function testConnection(config: ConnectionConfig): Promise<string> {
return invokeBackend("test_connection", { config });
}
@@ -267,6 +267,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
{ id: "ai-agent-turn-limit", category: "ai", titleKey: "ai.maxAgentTurns", descriptionKey: "ai.maxAgentTurnsDescription", targetId: "ai" },
{ id: "ai-global-retries", category: "ai", titleKey: "ai.maxRetriesGlobal", descriptionKey: "ai.maxRetriesGlobalDescription", targetId: "ai" },
{ id: "ai-global-instructions", category: "ai", titleKey: "ai.globalInstructions", descriptionKey: "ai.globalInstructionsDescription", targetId: "ai" },
{ id: "ai-custom-skill-root", category: "ai", titleKey: "settings.aiSkillRoot", descriptionKey: "settings.aiSkillRootDesc", targetId: "ai", visible: desktopOnly },
{ id: "mcp", category: "mcp", titleKey: "settings.mcpTitle", descriptionKey: "settings.mcpDescription", targetId: "mcp" },
{ id: "mcp-bin-path", category: "mcp", titleKey: "settings.mcpBinPath", targetId: "mcp" },
{ id: "mcp-permissions", category: "mcp", titleKey: "settings.mcpExecutionMode", descriptionKey: "settings.mcpExecutionModeDescription", targetId: "mcp" },
+6
View File
@@ -51,6 +51,8 @@ export interface DesktopSettings {
driver_store_dir?: string | null;
plugin_store_dir?: string | null;
agent_store_dir?: string | null;
custom_ai_skill_root_enabled?: boolean | null;
custom_ai_skill_root?: string | null;
sidebar_table_page_size?: number | null;
}
@@ -119,6 +121,8 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
driver_store_dir: null,
plugin_store_dir: null,
agent_store_dir: null,
custom_ai_skill_root_enabled: false,
custom_ai_skill_root: null,
sidebar_table_page_size: DEFAULT_SIDEBAR_TABLE_PAGE_SIZE,
};
@@ -219,6 +223,8 @@ export function normalizeDesktopSettings(settings: Partial<DesktopSettings> | nu
driver_store_dir: settings?.driver_store_dir?.trim() || DEFAULT_DESKTOP_SETTINGS.driver_store_dir,
plugin_store_dir: settings?.plugin_store_dir?.trim() || DEFAULT_DESKTOP_SETTINGS.plugin_store_dir,
agent_store_dir: settings?.agent_store_dir?.trim() || DEFAULT_DESKTOP_SETTINGS.agent_store_dir,
custom_ai_skill_root_enabled: settings?.custom_ai_skill_root_enabled ?? DEFAULT_DESKTOP_SETTINGS.custom_ai_skill_root_enabled,
custom_ai_skill_root: settings?.custom_ai_skill_root?.trim() || DEFAULT_DESKTOP_SETTINGS.custom_ai_skill_root,
sidebar_table_page_size: sidebarTablePageSize,
};
}
+72
View File
@@ -0,0 +1,72 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import * as api from "@/lib/backend/api";
import type { UserSkillMeta, UserSkillRootListing, UserSkillRootSettings } from "@/types/userSkills";
type UserSkillRootStatus = UserSkillRootListing["status"];
/**
* Metadata-only catalog of discoverable skills for the AI panel selector.
* Selection state does NOT live here: it is panel-session scoped inside
* AiAssistant (like activeTemplateIds), so closing the panel clears it while
* the catalog cache survives. This store never holds skill bodies - bodies
* are read once per send through the backend (prd.md:36/:56).
*/
export const useUserSkillStore = defineStore("userSkillStore", () => {
const defaultRootSkills = ref<UserSkillMeta[]>([]);
const customRootSkills = ref<UserSkillMeta[]>([]);
const defaultRootStatus = ref<UserSkillRootStatus>("missing");
const customRootStatus = ref<UserSkillRootStatus>("missing");
const isLoading = ref(false);
const lastError = ref<string | null>(null);
const hasLoadedOnce = ref(false);
/** Custom-root results sort first when enabled (prd.md:29). */
const groupedSkills = computed<Array<{ source: "custom" | "default"; skills: UserSkillMeta[] }>>(() => {
const groups: Array<{ source: "custom" | "default"; skills: UserSkillMeta[] }> = [];
if (customRootSkills.value.length > 0) groups.push({ source: "custom", skills: customRootSkills.value });
if (defaultRootSkills.value.length > 0) groups.push({ source: "default", skills: defaultRootSkills.value });
return groups;
});
const totalCount = computed(() => defaultRootSkills.value.length + customRootSkills.value.length);
async function refresh(rootSettings: UserSkillRootSettings): Promise<boolean> {
isLoading.value = true;
lastError.value = null;
try {
const result = await api.listUserSkills(rootSettings);
defaultRootSkills.value = result.defaultRoot.skills;
defaultRootStatus.value = result.defaultRoot.status;
customRootSkills.value = result.customRoot?.skills ?? [];
customRootStatus.value = result.customRoot?.status ?? "missing";
hasLoadedOnce.value = true;
return true;
} catch (error) {
// Keep failures retryable; the selector shows lastError with a Retry action.
lastError.value = error instanceof Error ? error.message : String(error);
return false;
} finally {
isLoading.value = false;
}
}
/** Display metadata lookup for selected ids, including stale ids whose skill has since vanished. */
function metaFor(id: string): UserSkillMeta | undefined {
return defaultRootSkills.value.find((skill) => skill.id === id) ?? customRootSkills.value.find((skill) => skill.id === id);
}
return {
defaultRootSkills,
customRootSkills,
defaultRootStatus,
customRootStatus,
isLoading,
lastError,
hasLoadedOnce,
groupedSkills,
totalCount,
refresh,
metaFor,
};
});
+53
View File
@@ -0,0 +1,53 @@
/**
* Read-only user-level SKILL.md skills (prd 09-21-public-skill-loader).
* Ids are opaque and deterministic (root source + canonical relative skill path);
* absolute filesystem paths never cross this boundary.
*/
export interface UserSkillMeta {
id: string;
name: string;
description: string;
}
export interface UserSkillRootListing {
status: "ok" | "missing" | "invalid";
skills: UserSkillMeta[];
}
export interface UserSkillsListResult {
defaultRoot: UserSkillRootListing;
customRoot: UserSkillRootListing | null;
}
export interface ReadUserSkill {
id: string;
name: string;
description: string;
content: string;
}
export type UserSkillFailureReason = "not_found" | "root_unavailable" | "oversized" | "not_utf8" | "invalid_frontmatter" | "unreadable";
export interface ReadUserSkillFailure {
id: string;
reason: UserSkillFailureReason;
}
export interface UserSkillsReadResult {
skills: ReadUserSkill[];
failures: ReadUserSkillFailure[];
}
export interface UserSkillRootSettings {
customRootEnabled: boolean;
customRoot: string | null;
}
/**
* Combined character budget for skills injected into one AI request. Skill
* bodies are only known after the send-time read (the catalog is metadata
* only), so the budget is enforced on the read snapshots with the same
* semantics as the template budget.
*/
export const ACTIVE_SKILLS_TOTAL_MAX = 65536;
@@ -232,6 +232,10 @@ pub struct DesktopSettings {
pub plugin_store_dir: Option<String>,
#[serde(default)]
pub agent_store_dir: Option<String>,
#[serde(default)]
pub custom_ai_skill_root_enabled: bool,
#[serde(default)]
pub custom_ai_skill_root: Option<String>,
#[serde(default = "default_sidebar_table_page_size")]
pub sidebar_table_page_size: usize,
}
@@ -684,6 +688,8 @@ impl Default for DesktopSettings {
driver_store_dir: None,
plugin_store_dir: None,
agent_store_dir: None,
custom_ai_skill_root_enabled: false,
custom_ai_skill_root: None,
sidebar_table_page_size: default_sidebar_table_page_size(),
}
}
@@ -2291,6 +2297,18 @@ impl Storage {
settings.remove("agent_store_dir");
}
}
settings.insert(
"custom_ai_skill_root_enabled".to_string(),
serde_json::Value::Bool(desktop_settings.custom_ai_skill_root_enabled),
);
match desktop_settings.custom_ai_skill_root.as_ref().filter(|path| !path.trim().is_empty()) {
Some(path) => {
settings.insert("custom_ai_skill_root".to_string(), serde_json::Value::String(path.clone()));
}
None => {
settings.remove("custom_ai_skill_root");
}
}
settings.insert(
"sidebar_table_page_size".to_string(),
serde_json::Value::Number(serde_json::Number::from(desktop_settings.sidebar_table_page_size)),
@@ -2359,6 +2377,16 @@ impl Storage {
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string),
custom_ai_skill_root_enabled: settings
.get("custom_ai_skill_root_enabled")
.and_then(|value| value.as_bool())
.unwrap_or_else(|| DesktopSettings::default().custom_ai_skill_root_enabled),
custom_ai_skill_root: settings
.get("custom_ai_skill_root")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string),
sidebar_table_page_size: settings
.get("sidebar_table_page_size")
.and_then(|value| value.as_u64())
@@ -7769,6 +7797,8 @@ mod tests {
driver_store_dir: Some("/tmp/dbx-drivers".to_string()),
plugin_store_dir: Some("/tmp/dbx-plugins".to_string()),
agent_store_dir: Some("/tmp/dbx-agents".to_string()),
custom_ai_skill_root_enabled: DesktopSettings::default().custom_ai_skill_root_enabled,
custom_ai_skill_root: None,
sidebar_table_page_size: DesktopSettings::default().sidebar_table_page_size,
})
.await
@@ -7790,11 +7820,53 @@ mod tests {
driver_store_dir: Some("/tmp/dbx-drivers".to_string()),
plugin_store_dir: Some("/tmp/dbx-plugins".to_string()),
agent_store_dir: Some("/tmp/dbx-agents".to_string()),
custom_ai_skill_root_enabled: DesktopSettings::default().custom_ai_skill_root_enabled,
custom_ai_skill_root: None,
sidebar_table_page_size: DesktopSettings::default().sidebar_table_page_size,
}
);
}
#[tokio::test]
async fn desktop_settings_roundtrip_custom_ai_skill_root() {
let path = temp_db_path("desktop-settings-custom-ai-skill-root");
let storage = Storage::open(&path).await.unwrap();
storage
.save_desktop_settings(&DesktopSettings {
custom_ai_skill_root_enabled: true,
custom_ai_skill_root: Some("/tmp/dbx-skills".to_string()),
..DesktopSettings::default()
})
.await
.unwrap();
let settings = storage.load_desktop_settings().await.unwrap();
assert!(settings.custom_ai_skill_root_enabled);
assert_eq!(settings.custom_ai_skill_root.as_deref(), Some("/tmp/dbx-skills"));
let raw = storage.load_app_settings_json().await.unwrap();
assert_eq!(raw.get("custom_ai_skill_root_enabled").and_then(|value| value.as_bool()), Some(true));
assert_eq!(raw.get("custom_ai_skill_root").and_then(|value| value.as_str()), Some("/tmp/dbx-skills"));
storage
.save_desktop_settings(&DesktopSettings {
custom_ai_skill_root_enabled: false,
custom_ai_skill_root: Some(" ".to_string()),
..DesktopSettings::default()
})
.await
.unwrap();
let settings = storage.load_desktop_settings().await.unwrap();
assert!(!settings.custom_ai_skill_root_enabled);
assert_eq!(settings.custom_ai_skill_root, None);
let raw = storage.load_app_settings_json().await.unwrap();
assert_eq!(raw.get("custom_ai_skill_root_enabled").and_then(|value| value.as_bool()), Some(false));
assert_eq!(raw.get("custom_ai_skill_root"), None);
}
#[tokio::test]
async fn desktop_settings_save_removes_legacy_background_preference() {
let path = temp_db_path("desktop-settings-remove-legacy-background");
+8
View File
@@ -114,6 +114,14 @@ AI 上下文可以包含:
模板会进入模型上下文,应避免放入密码、令牌或不必要的业务数据。规则冲突时,模型未必能按预期选择优先级,应保持全局指令和模板简短、明确且互不矛盾。
## Skills(SKILL.md)
Desktop 可以把只读的 [`SKILL.md`](https://developers.openai.com/codex/skills) 规则文件作为补充性指令附加到请求中。技能始终从 `~/.agents/skills` 发现;设置 → AI 中还可以额外启用一个自定义目录。两个目录独立校验,同名技能并列保留并标注来源(`默认目录` / `自定义目录`),自定义目录的技能排在前面。
在输入区的 `Skills` 入口(或输入 `/skill`)选择技能,选中项以芯片形式显示在输入框上方,可随时移除。选择的生命周期是一个打开的 AI 面板:切换会话、新建会话都会保留;关闭面板或重启 DBX 即清空。选择不会持久化,DBX 也绝不写入技能文件。
技能正文只在发送时从磁盘读取,并当场校验:文件被删除或不可读、frontmatter 无效、不是 UTF-8、超过单文件 1 MiB 上限、符号链接逃逸出所在目录,都会阻断发送,并在输入框上方显示横幅,标明失败的技能并提供重试、刷新、移除或打开设置等恢复操作。未选择任何技能时,提示词与既有行为逐字节一致。Web 端不提供该功能。
## 写入和生产安全
AI Agent 的执行边界是后端强制的,不只是提示词:
+8
View File
@@ -116,6 +116,14 @@ Templates can also be marked as the **default for one or more database types** (
Templates enter model context. Do not put passwords, tokens, or unnecessary business data in them. Keep rules concise and non-conflicting because models may not resolve contradictory instructions as expected.
## Skills (SKILL.md)
Desktop can attach read-only [`SKILL.md`](https://developers.openai.com/codex/skills) rule files to a request as supplementary instructions. Skills are always discovered in `~/.agents/skills`; Settings → AI can additionally enable one custom directory. Each directory is validated independently, same names are kept side by side and labeled by source (`Default directory` / `Custom directory`), and custom-directory skills sort first.
Select skills from the composer `Skills` entry (or type `/skill`) and remove them from the chips above the input. The selection lives for the lifetime of one open AI panel — conversation switches and new conversations keep it; closing the panel or restarting DBX clears it. Nothing is persisted, and DBX never writes to skill files.
Skill bodies are read only when a request is sent, directly from disk, and validated then: file deleted or unreadable, invalid frontmatter, non-UTF-8 content, over the 1 MiB per-file limit, or a symlink escaping its root blocks the send with a banner above the composer naming the failed skill and offering Retry, Refresh, Remove, or Open Settings. With no skills selected, prompts are byte-for-byte identical to the pre-feature behavior. Web ships this feature unsupported.
## Writes and Production Safety
AI Agent execution boundaries are enforced by the backend, not only by prompting:
+9 -12
View File
@@ -32,11 +32,7 @@ function scriptSetupContent(): string {
function functionBody(script: string, signature: string): string {
const sigIndex = script.indexOf(signature);
assert.notEqual(sigIndex, -1, `expected to find ${signature}`);
assert.equal(
script.indexOf(signature, sigIndex + signature.length),
-1,
`${signature} should be unique`,
);
assert.equal(script.indexOf(signature, sigIndex + signature.length), -1, `${signature} should be unique`);
const openBrace = script.indexOf("{", sigIndex + signature.length);
assert.notEqual(openBrace, -1, "function body should open with a brace");
@@ -72,11 +68,15 @@ test("send() acquires the guard synchronously before the first await", () => {
assert.ok(entryCheck < guardSet, "isGenerating check must precede the guard set");
assert.ok(guardSet < firstAwait, "guard must be set before the first await (no await between check and set)");
// The first suspension point is specifically the prompt-template load — the
// await the reviewer flagged as happening before the guard existed.
// The first suspension point is the send-time skill read or the prompt-template
// load — both run after the guard and both are pipeline-entry awaits whose
// failure paths release the guard state explicitly (see the early returns
// after `aiGenerationGuard.begin()`).
const ensureLoadedAwait = body.indexOf("await promptTemplateStore.ensureLoaded()");
assert.notEqual(ensureLoadedAwait, -1, "send() should await promptTemplateStore.ensureLoaded()");
assert.equal(firstAwait, ensureLoadedAwait, "ensureLoaded() must be the first await in send()");
const skillReadAwait = body.indexOf("await readUserSkills([...selectedSkillIds.value], skillRootSettings())");
const pipelineEntryAwaits = [ensureLoadedAwait, skillReadAwait].filter((index) => index >= 0);
assert.ok(pipelineEntryAwaits.includes(firstAwait), "the first await in send() must be the skill read or the prompt-template load");
});
test("send() never leaks the guard on an early return after acquiring it", () => {
@@ -93,10 +93,7 @@ test("send() never leaks the guard on an early return after acquiring it", () =>
for (const match of returnsAfterGuard) {
const preceding = tail.slice(0, match.index);
assert.ok(
preceding.includes("isGenerating.value = false"),
"any early return after the guard is acquired must reset isGenerating first",
);
assert.ok(preceding.includes("isGenerating.value = false"), "any early return after the guard is acquired must reset isGenerating first");
}
});
+51
View File
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "vitest";
// AiAssistant.vue has no mounted-DOM harness in this repo (no @vue/test-utils),
// so this pins the /skill contract on the compiled SFC source, the same way
// aiAssistantComposerLayout.test.ts pins the composer layout.
const source = readFileSync(new URL("../../apps/desktop/src/components/editor/AiAssistant.vue", import.meta.url), "utf8");
function section(startMarker: string, endMarker: string): string {
const start = source.indexOf(startMarker);
assert.notEqual(start, -1, `missing marker: ${startMarker}`);
const end = source.indexOf(endMarker, start + startMarker.length);
assert.notEqual(end, -1, `missing end marker: ${endMarker}`);
return source.slice(start, end);
}
test("/skill rides its own command source instead of the action lists", () => {
const commands = section("const filteredCommands = computed", "const AI_SQL_FILE_MENTION_CANDIDATE_LIMIT");
assert.ok(commands.includes('{ type: "skills" }'), "filteredCommands must merge a dedicated skills entry");
assert.ok(commands.includes('type: "action"'), "action entries must stay in the same command list");
// The built-in action arrays must not gain a skill entry: they also feed the
// mode+action picker, which would then offer /skill as a selectable action.
for (const marker of ["/** Ask-mode actions: SQL-producing, never auto-run. */", "/** Agent-mode actions: task-oriented, drive tool use and real results. */"]) {
const list = section(marker, "];");
assert.ok(!/skill/i.test(list.replace(/AiActionButton/g, "")), `action list must not contain a skill entry: ${marker}`);
}
});
test("selecting /skill never changes the built-in action", () => {
const body = section("function selectCommand(command: AiSlashEntry)", "\nfunction insertMention");
const skillsBranch = body.indexOf('command.type === "skills"');
const actionAssign = body.indexOf("activeAction.value = command.button.action");
assert.notEqual(skillsBranch, -1, "selectCommand must branch on the skills entry");
assert.notEqual(actionAssign, -1, "selectCommand must still assign the chosen action");
assert.ok(skillsBranch < actionAssign, "the skills branch must be evaluated before any action assignment");
const branch = body.slice(skillsBranch, actionAssign);
assert.ok(branch.includes("showSkillSelector.value = true"), "the skills branch must open the selector");
assert.ok(branch.includes("return"), "the skills branch must return before the action assignment");
assert.ok(!branch.includes("activeAction"), "the skills branch must not touch activeAction");
});
test("the skills selector only ever lists metadata", () => {
const selector = section('<Popover v-model:open="showSkillSelector">', 'v-if="mentionOpen"');
assert.ok(selector.length > 0);
assert.ok(selector.includes("userSkillStore.groupedSkills"), "the selector renders the metadata catalog");
assert.ok(!selector.includes("readUserSkills"), "the selector must never read skill bodies");
assert.ok(selector.includes("refreshSkills"), "the selector exposes Refresh");
});
+77
View File
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import { beforeEach, test, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import type { UserSkillRootSettings, UserSkillsListResult } from "../../apps/desktop/src/types/userSkills";
const apiMock = vi.hoisted(() => ({
listUserSkills: vi.fn(),
readUserSkills: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => apiMock);
import { useUserSkillStore } from "../../apps/desktop/src/stores/userSkillStore.ts";
const rootSettings: UserSkillRootSettings = { customRootEnabled: true, customRoot: "D:/Team/dbx-skills" };
function listing(status: "ok" | "missing" | "invalid", skills: Array<{ id: string; name: string; description: string }>) {
return { status, skills };
}
beforeEach(() => {
setActivePinia(createPinia());
apiMock.listUserSkills.mockReset();
apiMock.readUserSkills.mockReset();
});
test("refresh groups custom root first and tracks statuses", async () => {
apiMock.listUserSkills.mockResolvedValueOnce({
defaultRoot: listing("ok", [
{ id: "d-2", name: "Zeta", description: "z" },
{ id: "d-1", name: "Alpha", description: "a" },
]),
customRoot: listing("ok", [{ id: "c-1", name: "Team Rules", description: "t" }]),
} satisfies UserSkillsListResult);
const store = useUserSkillStore();
assert.equal(await store.refresh(rootSettings), true);
assert.deepEqual(
store.groupedSkills.map((group) => group.source),
["custom", "default"],
);
assert.equal(store.totalCount, 3);
assert.equal(store.metaFor("c-1")?.name, "Team Rules");
assert.equal(store.defaultRootStatus, "ok");
assert.equal(store.customRootStatus, "ok");
assert.equal(apiMock.listUserSkills.mock.calls[0][0], rootSettings);
});
test("absent default root and disabled custom root yield empty groups, not errors", async () => {
apiMock.listUserSkills.mockResolvedValueOnce({
defaultRoot: listing("missing", []),
customRoot: null,
} satisfies UserSkillsListResult);
const store = useUserSkillStore();
assert.equal(await store.refresh({ customRootEnabled: false, customRoot: null }), true);
assert.equal(store.totalCount, 0);
assert.deepEqual(store.groupedSkills, []);
assert.equal(store.lastError, null);
assert.equal(store.hasLoadedOnce, true);
});
test("refresh failure keeps prior catalog and records retryable error", async () => {
apiMock.listUserSkills.mockResolvedValueOnce({
defaultRoot: listing("ok", [{ id: "d-1", name: "Alpha", description: "a" }]),
customRoot: null,
} satisfies UserSkillsListResult);
const store = useUserSkillStore();
await store.refresh(rootSettings);
apiMock.listUserSkills.mockRejectedValueOnce(new Error("backend unavailable"));
assert.equal(await store.refresh(rootSettings), false);
assert.equal(store.lastError, "backend unavailable");
// Prior catalog remains so chips keep their display metadata.
assert.equal(store.totalCount, 1);
assert.equal(store.metaFor("d-1")?.name, "Alpha");
});
+1
View File
@@ -29,6 +29,7 @@ tauri-build = { version = "2.5.6", features = [] }
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml_ng = "0.10"
base64 = "0.22"
log = "0.4"
tauri = { version = "2.10.3", features = ["tray-icon", "image-png"] }
+1
View File
@@ -69,6 +69,7 @@ pub mod tunnel_profiles;
pub mod update;
mod update_cache;
mod update_portable;
pub mod user_skills;
pub mod vector_cmd;
pub mod window_controls;
pub mod xlsx_export;
+600
View File
@@ -0,0 +1,600 @@
//! Read-only discovery and reading of user-level SKILL.md skills.
//!
//! Two roots are scanned: the default user-level root (HOME/.agents/skills) and
//! an optional user-configured custom root (Settings > AI). The service is
//! strictly read-only: there is no create/write/delete API. Skills are
//! identified by deterministic opaque ids derived from the root source and the
//! canonical relative skill directory name, so ids survive Refresh (prd.md:34).
//! Absolute paths never leave this module (prd.md:33).
use std::collections::HashSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const SKILL_FILE_NAME: &str = "SKILL.md";
/// Hard per-file safety limit: a larger SKILL.md is invalid and omitted from
/// discovery, and blocks the send when selected (prd.md:26/:37).
pub const MAX_SKILL_FILE_BYTES: u64 = 1_048_576;
/// Send-time reads take at most 1 MiB + 1 byte so a file that grows after the
/// metadata check is rejected instead of loaded unboundedly (prd.md:38).
const READ_LIMIT_BYTES: u64 = MAX_SKILL_FILE_BYTES + 1;
/// The opening frontmatter block must close within the first 64 KiB.
const FRONTMATTER_SCAN_BYTES: usize = 64 * 1024;
const DEFAULT_ROOT_PREFIX: &str = "d";
const CUSTOM_ROOT_PREFIX: &str = "c";
const ROOT_STATUS_OK: &str = "ok";
const ROOT_STATUS_MISSING: &str = "missing";
const ROOT_STATUS_INVALID: &str = "invalid";
/// Bounded failure vocabulary. Underlying I/O detail (which can contain
/// absolute paths or OS-specific text) is deliberately discarded rather than
/// forwarded, so nothing path-shaped reaches the frontend (prd.md:33).
const REASON_NOT_FOUND: &str = "not_found";
const REASON_ROOT_UNAVAILABLE: &str = "root_unavailable";
const REASON_OVERSIZED: &str = "oversized";
const REASON_NOT_UTF8: &str = "not_utf8";
const REASON_INVALID_FRONTMATTER: &str = "invalid_frontmatter";
const REASON_UNREADABLE: &str = "unreadable";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UserSkillMeta {
pub id: String,
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UserSkillRootListing {
/// One of "ok" | "missing" | "invalid".
pub status: String,
pub skills: Vec<UserSkillMeta>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UserSkillsListResult {
pub default_root: UserSkillRootListing,
pub custom_root: Option<UserSkillRootListing>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadUserSkill {
pub id: String,
pub name: String,
pub description: String,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadUserSkillFailure {
pub id: String,
/// One of "not_found" | "root_unavailable" | "oversized" | "not_utf8" | "invalid_frontmatter" | "unreadable".
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UserSkillsReadResult {
pub skills: Vec<ReadUserSkill>,
pub failures: Vec<ReadUserSkillFailure>,
}
/// Lists skill metadata for both roots. The default root is always scanned;
/// the custom root only when it is enabled in Settings (prd.md:27). An absent
/// root yields an empty listing rather than an error (prd.md:35).
#[tauri::command]
pub async fn list_user_skills(
custom_root_enabled: bool,
custom_root: Option<String>,
) -> Result<UserSkillsListResult, String> {
tauri::async_runtime::spawn_blocking(move || list_user_skills_blocking(custom_root_enabled, custom_root.as_deref()))
.await
.map_err(|error| error.to_string())
}
/// Reads the currently selected skills by id. Every id is resolved only
/// through re-derivation over a fresh scan of the owning root, so a deleted,
/// moved, or root-escaping skill fails instead of silently resolving to a
/// stale path (prd.md:37).
#[tauri::command]
pub async fn read_user_skills(
ids: Vec<String>,
custom_root_enabled: bool,
custom_root: Option<String>,
) -> Result<UserSkillsReadResult, String> {
tauri::async_runtime::spawn_blocking(move || {
read_user_skills_blocking(&ids, custom_root_enabled, custom_root.as_deref())
})
.await
.map_err(|error| error.to_string())
}
fn list_user_skills_blocking(custom_root_enabled: bool, custom_root: Option<&str>) -> UserSkillsListResult {
let default_root = match default_skills_root() {
Some(path) => root_listing(&path, DEFAULT_ROOT_PREFIX),
None => empty_listing(ROOT_STATUS_MISSING),
};
let custom_root = if custom_root_enabled {
enabled_custom_root(custom_root).map(|path| root_listing(&path, CUSTOM_ROOT_PREFIX))
} else {
None
};
UserSkillsListResult { default_root, custom_root }
}
fn read_user_skills_blocking(
ids: &[String],
custom_root_enabled: bool,
custom_root: Option<&str>,
) -> UserSkillsReadResult {
let default_root =
default_skills_root().and_then(|path| std::fs::canonicalize(&path).ok()).filter(|path| path.is_dir());
let custom_root = if custom_root_enabled { enabled_custom_root(custom_root) } else { None };
read_user_skills_from(ids, default_root.as_deref(), custom_root.as_deref())
}
/// Root resolution is injected so tests can exercise both roots without
/// depending on the developer home directory.
fn read_user_skills_from(
ids: &[String],
default_root: Option<&Path>,
custom_root: Option<&Path>,
) -> UserSkillsReadResult {
let mut skills = Vec::new();
let mut failures = Vec::new();
let mut seen_ids: HashSet<&str> = HashSet::new();
for id in ids {
// A repeated id must not inject the same skill text twice.
if !seen_ids.insert(id.as_str()) {
continue;
}
let prefix = match id.split_once('-') {
Some((prefix, _)) if prefix == DEFAULT_ROOT_PREFIX || prefix == CUSTOM_ROOT_PREFIX => prefix,
_ => {
failures.push(ReadUserSkillFailure { id: id.clone(), reason: REASON_NOT_FOUND.to_string() });
continue;
}
};
let root = match prefix {
DEFAULT_ROOT_PREFIX => default_root,
_ => custom_root,
};
let Some(root) = root else {
failures.push(ReadUserSkillFailure { id: id.clone(), reason: REASON_ROOT_UNAVAILABLE.to_string() });
continue;
};
match read_skill_from_root(root, prefix, id) {
Ok(skill) => skills.push(skill),
Err(reason) => failures.push(ReadUserSkillFailure { id: id.clone(), reason }),
}
}
UserSkillsReadResult { skills, failures }
}
fn enabled_custom_root(custom_root: Option<&str>) -> Option<PathBuf> {
let trimmed = custom_root.map(str::trim).filter(|value| !value.is_empty())?;
let canonical = std::fs::canonicalize(trimmed).ok()?;
canonical.is_dir().then_some(canonical)
}
fn default_skills_root() -> Option<PathBuf> {
Some(home_dir()?.join(".agents").join("skills"))
}
fn home_dir() -> Option<PathBuf> {
std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).ok().map(PathBuf::from)
}
fn empty_listing(status: &str) -> UserSkillRootListing {
UserSkillRootListing { status: status.to_string(), skills: Vec::new() }
}
fn root_listing(path: &Path, prefix: &str) -> UserSkillRootListing {
match std::fs::canonicalize(path) {
Ok(canonical) if canonical.is_dir() => {
UserSkillRootListing { status: ROOT_STATUS_OK.to_string(), skills: scan_root(&canonical, prefix) }
}
Ok(_) => empty_listing(ROOT_STATUS_INVALID),
Err(_) => empty_listing(ROOT_STATUS_MISSING),
}
}
/// Scans the direct children of a canonical root. Symlink policy (prd.md:40):
/// an entry is only discoverable when its canonical target stays inside the
/// root, so a symlink that escapes the root is invisible to discovery.
fn scan_root(canonical_root: &Path, prefix: &str) -> Vec<UserSkillMeta> {
let mut skills = Vec::new();
let Ok(entries) = std::fs::read_dir(canonical_root) else { return skills };
for entry in entries.flatten() {
let dir_name = entry.file_name().to_string_lossy().to_string();
if dir_name.starts_with('.') {
continue;
}
let Ok(canonical_dir) = entry.path().canonicalize() else { continue };
if !canonical_dir.starts_with(canonical_root) || !canonical_dir.is_dir() {
continue;
}
let skill_file = canonical_dir.join(SKILL_FILE_NAME);
let Ok(canonical_skill_file) = skill_file.canonicalize() else { continue };
// A skill directory may be linked within the allowed root, but its
// entry file must obey the same containment rule. Otherwise a normal
// directory could hide a SKILL.md symlink to an arbitrary local file.
if !canonical_skill_file.starts_with(canonical_root) {
continue;
}
let Some((name, description)) = discover_skill_metadata(&canonical_skill_file) else { continue };
skills.push(UserSkillMeta { id: skill_id(prefix, &dir_name), name, description });
}
skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.id.cmp(&b.id)));
skills
}
/// Listing reads only the head of the file: the size gate plus a bounded
/// prefix read are enough to parse the opening frontmatter block
/// (prd.md:26 - "parse their frontmatter according to the public convention").
/// The body is never read here (prd.md:56).
fn discover_skill_metadata(path: &Path) -> Option<(String, String)> {
let stat = std::fs::metadata(path).ok()?;
if !stat.is_file() || stat.len() > MAX_SKILL_FILE_BYTES {
return None;
}
let file = std::fs::File::open(path).ok()?;
let mut bytes = Vec::new();
file.take(FRONTMATTER_SCAN_BYTES as u64).read_to_end(&mut bytes).ok()?;
parse_frontmatter(decode_prefix(&bytes, stat.len())?)
}
/// Decodes the bounded listing prefix strictly, so an unreadable file is not
/// offered as a choice (it would always fail at send time). A multi-byte
/// character cut by the prefix read is not an encoding error: when the file
/// continues past the prefix, the trailing partial character is trimmed. The
/// bytes beyond the prefix stay unvalidated here and are checked at send time.
fn decode_prefix(bytes: &[u8], file_len: u64) -> Option<&str> {
match std::str::from_utf8(bytes) {
Ok(text) => Some(text),
Err(error) if error.error_len().is_none() && file_len > bytes.len() as u64 => {
std::str::from_utf8(&bytes[..error.valid_up_to()]).ok()
}
Err(_) => None,
}
}
/// Reads and fully validates one selected skill file. The metadata size check
/// is followed by a 1 MiB + 1 byte bounded read, so a file that grows between
/// the two is rejected as oversized instead of loaded unboundedly (prd.md:38).
fn read_skill_from_root(canonical_root: &Path, prefix: &str, id: &str) -> Result<ReadUserSkill, String> {
let entries = std::fs::read_dir(canonical_root).map_err(|_| REASON_NOT_FOUND.to_string())?;
for entry in entries.flatten() {
let dir_name = entry.file_name().to_string_lossy().to_string();
if dir_name.starts_with('.') || skill_id(prefix, &dir_name) != id {
continue;
}
let canonical_dir = entry.path().canonicalize().map_err(|_| REASON_NOT_FOUND.to_string())?;
if !canonical_dir.starts_with(canonical_root) || !canonical_dir.is_dir() {
return Err(REASON_NOT_FOUND.to_string());
}
let skill_file = canonical_dir.join(SKILL_FILE_NAME);
let canonical_skill_file = skill_file.canonicalize().map_err(|_| REASON_NOT_FOUND.to_string())?;
// Re-check the entry file on every send, not just its parent directory:
// another process may have swapped SKILL.md for an escaping symlink
// after it was discovered.
if !canonical_skill_file.starts_with(canonical_root) {
return Err(REASON_NOT_FOUND.to_string());
}
return read_skill_file(&canonical_skill_file, id);
}
Err(REASON_NOT_FOUND.to_string())
}
fn read_skill_file(path: &Path, id: &str) -> Result<ReadUserSkill, String> {
let stat = std::fs::metadata(path).map_err(|_| REASON_NOT_FOUND.to_string())?;
if !stat.is_file() {
return Err(REASON_NOT_FOUND.to_string());
}
if stat.len() > MAX_SKILL_FILE_BYTES {
return Err(REASON_OVERSIZED.to_string());
}
let file = std::fs::File::open(path).map_err(|_| REASON_UNREADABLE.to_string())?;
let mut bytes = Vec::new();
file.take(READ_LIMIT_BYTES).read_to_end(&mut bytes).map_err(|_| REASON_UNREADABLE.to_string())?;
if bytes.len() as u64 > MAX_SKILL_FILE_BYTES {
return Err(REASON_OVERSIZED.to_string());
}
let text = String::from_utf8(bytes).map_err(|_| REASON_NOT_UTF8.to_string())?;
let (name, description) = parse_frontmatter(&text).ok_or_else(|| REASON_INVALID_FRONTMATTER.to_string())?;
Ok(ReadUserSkill { id: id.to_string(), name, description, content: text })
}
/// Deterministic opaque id: root source prefix plus a truncated SHA-256 of the
/// canonical relative skill directory name. Stable across Refresh and restarts
/// of the registry (prd.md:34). The id carries no path material to the client.
fn skill_id(prefix: &str, dir_name: &str) -> String {
let digest = Sha256::digest(dir_name.as_bytes());
let mut id = String::from(prefix);
id.push('-');
for byte in &digest[..8] {
id.push_str(&format!("{:02x}", byte));
}
id
}
/// Parses the opening YAML frontmatter block per the public SKILL.md
/// convention: required non-empty name and description, unknown keys ignored.
/// Returns None when the block is missing, unclosed, oversized, or lacks a
/// required field (prd.md:26/:52).
fn parse_frontmatter(text: &str) -> Option<(String, String)> {
let text = text.strip_prefix("\u{feff}").unwrap_or(text);
let mut lines = text.lines();
if lines.next()?.trim_end() != "---" {
return None;
}
let mut block = String::new();
let mut closed = false;
for line in lines {
if line.trim_end() == "---" {
closed = true;
break;
}
block.push_str(line);
block.push('\n');
if block.len() > FRONTMATTER_SCAN_BYTES {
return None;
}
}
if !closed {
return None;
}
let frontmatter: SkillFrontmatter = serde_yaml_ng::from_str(&block).ok()?;
let name = frontmatter.name?.trim().to_string();
let description = frontmatter.description?.trim().to_string();
if name.is_empty() || description.is_empty() {
return None;
}
Some((name, description))
}
#[derive(Debug, Deserialize)]
struct SkillFrontmatter {
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
const VALID_BODY: &str = "---\nname: sql-review\ndescription: Team SQL review rules\n---\n\nFollow the rules.\n";
fn temp_skills_root(tag: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("dbx-user-skills-{tag}-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
root
}
fn write_skill(root: &Path, dir: &str, body: &str) -> PathBuf {
let dir = root.join(dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(SKILL_FILE_NAME), body).unwrap();
dir
}
fn set_file_len(skill_dir: &Path, len: u64) {
let file = std::fs::OpenOptions::new().write(true).open(skill_dir.join(SKILL_FILE_NAME)).unwrap();
file.set_len(len).unwrap();
}
#[test]
fn parses_frontmatter_and_ignores_extra_keys() {
let (name, description) =
parse_frontmatter("---\nname: \"sql-review\"\ndescription: Team rules\nmetadata:\n short: x\nallowed-tools: [read]\n---\n\nBody.\n").unwrap();
assert_eq!(name, "sql-review");
assert_eq!(description, "Team rules");
}
#[test]
fn frontmatter_must_open_and_close_at_the_top() {
assert!(parse_frontmatter("name: x\ndescription: y\n").is_none());
assert!(parse_frontmatter("---\nname: x\ndescription: y\n").is_none());
assert!(parse_frontmatter("body first\n---\nname: x\ndescription: y\n---\n").is_none());
}
#[test]
fn frontmatter_requires_name_and_description() {
assert!(parse_frontmatter("---\ndescription: only desc\n---\n").is_none());
assert!(parse_frontmatter("---\nname: only name\n---\n").is_none());
assert!(parse_frontmatter("---\nname:\ndescription: y\n---\n").is_none());
assert!(parse_frontmatter("---\nname: x\ndescription: \n---\n").is_none());
}
#[test]
fn skill_ids_are_deterministic_and_root_scoped() {
assert_eq!(skill_id("d", "alpha"), skill_id("d", "alpha"));
assert_ne!(skill_id("d", "alpha"), skill_id("c", "alpha"));
assert_ne!(skill_id("d", "alpha"), skill_id("d", "beta"));
assert!(skill_id("d", "alpha").starts_with("d-"));
assert_eq!(skill_id("d", "alpha").len(), 18);
}
#[test]
fn scan_lists_valid_skills_and_omits_others() {
let root = temp_skills_root("scan");
write_skill(&root, "alpha", VALID_BODY);
write_skill(&root, "zulu", VALID_BODY);
write_skill(&root, "nodesc", "---\nname: nodesc\n---\nbody\n");
write_skill(&root, ".hidden", VALID_BODY);
let nested = root.join("nested");
std::fs::create_dir_all(nested.join("deep")).unwrap();
std::fs::write(nested.join("deep").join(SKILL_FILE_NAME), VALID_BODY).unwrap();
let oversized = write_skill(&root, "oversized", VALID_BODY);
set_file_len(&oversized, MAX_SKILL_FILE_BYTES + 1);
let canonical = std::fs::canonicalize(&root).unwrap();
let skills = scan_root(&canonical, "d");
let names: Vec<String> = skills.iter().map(|skill| skill.name.clone()).collect();
assert_eq!(names, vec!["sql-review", "sql-review"]);
assert_ne!(skills[0].id, skills[1].id);
assert!(skills.iter().all(|skill| skill.id.starts_with("d-")));
}
#[test]
fn root_listing_reports_missing_and_invalid() {
let root = temp_skills_root("listing");
write_skill(&root, "alpha", VALID_BODY);
let missing = root_listing(&root.join("does-not-exist"), "d");
assert_eq!(missing.status, "missing");
assert!(missing.skills.is_empty());
let file_marker = root.join("plain-file.txt");
std::fs::write(&file_marker, "x").unwrap();
assert_eq!(root_listing(&file_marker, "d").status, "invalid");
assert_eq!(root_listing(&root, "d").status, "ok");
}
#[test]
fn read_returns_full_content_and_enforces_the_one_mib_boundary() {
let root = temp_skills_root("read");
let exact = write_skill(&root, "exact", VALID_BODY);
set_file_len(&exact, MAX_SKILL_FILE_BYTES);
let toobig = write_skill(&root, "toobig", VALID_BODY);
set_file_len(&toobig, MAX_SKILL_FILE_BYTES + 1);
let canonical = std::fs::canonicalize(&root).unwrap();
let exact_skill = read_skill_from_root(&canonical, "d", &skill_id("d", "exact")).unwrap();
assert_eq!(exact_skill.name, "sql-review");
assert_eq!(exact_skill.content.len(), MAX_SKILL_FILE_BYTES as usize);
assert_eq!(read_skill_from_root(&canonical, "d", &skill_id("d", "toobig")).unwrap_err(), "oversized");
}
#[test]
fn read_reports_not_found_for_missing_and_root_unavailable_for_disabled_roots() {
let root = temp_skills_root("missing");
let skill_dir = write_skill(&root, "gone", VALID_BODY);
let canonical = std::fs::canonicalize(&root).unwrap();
let id = skill_id("d", "gone");
assert!(read_skill_from_root(&canonical, "d", &id).is_ok());
std::fs::remove_dir_all(skill_dir).unwrap();
assert_eq!(read_skill_from_root(&canonical, "d", &id).unwrap_err(), "not_found");
let custom = temp_skills_root("custom");
write_skill(&custom, "team", VALID_BODY);
let custom_id = skill_id("c", "team");
let disabled =
read_user_skills_blocking(std::slice::from_ref(&custom_id), false, Some(custom.to_str().unwrap()));
assert_eq!(disabled.failures.len(), 1);
assert_eq!(disabled.failures[0].reason, "root_unavailable");
let vanished = read_user_skills_blocking(&[custom_id], true, Some(custom.join("nope").to_str().unwrap()));
assert_eq!(vanished.failures[0].reason, "root_unavailable");
}
#[test]
fn read_preserves_request_order_and_collects_failures() {
// Both roots are injected: a `d-` id resolved through the real home
// directory would make this test depend on the machine it runs on.
let root = temp_skills_root("order");
write_skill(&root, "keep", VALID_BODY);
std::fs::write(root.join("plain.txt"), "x").unwrap();
let canonical = std::fs::canonicalize(&root).unwrap();
let result = read_user_skills_from(
&["d-bogus".to_string(), skill_id("d", "keep"), skill_id("d", "never-existed")],
Some(&canonical),
None,
);
assert_eq!(result.skills.len(), 1);
assert_eq!(result.skills[0].id, skill_id("d", "keep"));
assert_eq!(result.failures.len(), 2);
assert_eq!(result.failures[0].id, "d-bogus");
assert_eq!(result.failures[0].reason, "not_found");
assert_eq!(result.failures[1].reason, "not_found");
}
#[test]
fn read_skips_duplicate_ids() {
let root = temp_skills_root("dedupe");
write_skill(&root, "once", VALID_BODY);
let canonical = std::fs::canonicalize(&root).unwrap();
let id = skill_id("d", "once");
let result = read_user_skills_from(&[id.clone(), id.clone(), id], Some(&canonical), None);
assert_eq!(result.skills.len(), 1);
assert!(result.failures.is_empty());
// Deduplication must not swallow a failure: a repeated bad id is
// still reported exactly once.
let duplicated_bad = ["d-bogus".to_string(), "d-bogus".to_string()];
let failed = read_user_skills_from(&duplicated_bad, Some(&canonical), None);
assert!(failed.skills.is_empty());
assert_eq!(failed.failures.len(), 1);
assert_eq!(failed.failures[0].id, "d-bogus");
}
#[test]
fn listing_omits_a_file_that_is_not_valid_utf8() {
let root = temp_skills_root("invalid-utf8");
let dir = root.join("broken");
std::fs::create_dir_all(&dir).unwrap();
let mut bytes = b"---\nname: br".to_vec();
bytes.push(0xFF);
bytes.extend_from_slice(b"oken\ndescription: desc\n---\n\nBody.\n");
std::fs::write(dir.join(SKILL_FILE_NAME), &bytes).unwrap();
let canonical = std::fs::canonicalize(&root).unwrap();
assert!(scan_root(&canonical, "d").is_empty());
assert_eq!(read_skill_from_root(&canonical, "d", &skill_id("d", "broken")).unwrap_err(), "not_utf8");
}
#[test]
fn listing_tolerates_a_multibyte_character_cut_by_the_prefix_read() {
let root = temp_skills_root("prefix-cut");
let frontmatter = "---\nname: cut\ndescription: boundary\n---\n";
let mut body = String::from(frontmatter);
body.push_str(&"a".repeat(FRONTMATTER_SCAN_BYTES - frontmatter.len() - 1));
body.push('\u{e9}');
body.push_str(&"b".repeat(64));
write_skill(&root, "cut", &body);
let canonical = std::fs::canonicalize(&root).unwrap();
let skills = scan_root(&canonical, "d");
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "cut");
}
#[cfg(unix)]
#[test]
fn symlink_policy_allows_inside_root_only() {
use std::os::unix::fs::symlink;
let root = temp_skills_root("links");
write_skill(&root, "real", VALID_BODY);
symlink(root.join("real"), root.join("inside")).unwrap();
let outside = temp_skills_root("outside");
write_skill(&outside, "escapee", VALID_BODY);
symlink(outside.join("escapee"), root.join("escape")).unwrap();
let canonical = std::fs::canonicalize(&root).unwrap();
let skills = scan_root(&canonical, "d");
let ids: Vec<String> = skills.iter().map(|skill| skill.id.clone()).collect();
assert!(ids.contains(&skill_id("d", "inside")));
assert!(!ids.contains(&skill_id("d", "escape")));
}
#[cfg(unix)]
#[test]
fn skill_file_symlink_cannot_escape_the_root() {
use std::os::unix::fs::symlink;
let root = temp_skills_root("file-link");
let escape_dir = write_skill(&root, "file-escape", VALID_BODY);
let outside = temp_skills_root("file-link-outside");
let outside_file = outside.join("SKILL.md");
std::fs::write(&outside_file, VALID_BODY).unwrap();
std::fs::remove_file(escape_dir.join(SKILL_FILE_NAME)).unwrap();
symlink(&outside_file, escape_dir.join(SKILL_FILE_NAME)).unwrap();
let canonical = std::fs::canonicalize(&root).unwrap();
let id = skill_id("d", "file-escape");
assert!(!scan_root(&canonical, "d").iter().any(|skill| skill.id == id));
assert_eq!(read_skill_from_root(&canonical, "d", &id).unwrap_err(), REASON_NOT_FOUND);
}
}
+2
View File
@@ -1790,6 +1790,8 @@ pub fn run() {
commands::prompt_template::delete_prompt_template,
commands::prompt_template::get_ai_global_custom_instructions,
commands::prompt_template::set_ai_global_custom_instructions,
commands::user_skills::list_user_skills,
commands::user_skills::read_user_skills,
commands::app_settings::load_desktop_settings,
commands::app_settings::save_desktop_settings,
commands::app_settings::load_max_agent_turns,