mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
Generated
+1
@@ -2052,6 +2052,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml_ng",
|
||||
"sha2 0.10.9",
|
||||
"sys-locale",
|
||||
"sysinfo",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "セキュリティ",
|
||||
|
||||
@@ -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: "보안",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "安全",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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 的执行边界是后端强制的,不只是提示词:
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user