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

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

No files matched your search

+1
View File
@@ -4334,6 +4334,7 @@ onUnmounted(() => {
@execute-redis-command="(command: string) => routeAiRedisCommand(command, true)"
@open-explain-plan="onAiOpenExplainPlan"
@toggle-maximize="toggleAiPanelMaximized"
@open-settings="activateSettingsPage"
@close="closeRightSidebarPanel('ai')"
/>
</div>
@@ -63,6 +63,9 @@ import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
import { useUserSkillStore } from "@/stores/userSkillStore";
import { buildSelectedSkillChips, capSkillsToCharLimit, removeSkillIds, userSkillSourceOfId } from "@/lib/ai/userSkillSelection";
import { ACTIVE_SKILLS_TOTAL_MAX, type ReadUserSkill, type ReadUserSkillFailure, type UserSkillFailureReason, type UserSkillRootSettings } from "@/types/userSkills";
import { connectionIconType } from "@/lib/connection/connectionPresentation";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
@@ -153,7 +156,7 @@ import { buildAiAgentStepItems, formatToolDurationMs, toolCallStepKey, upsertAge
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender";
import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/ai/aiMarkdown";
import { aiCancelStream, saveAiConversation, saveAiRun, saveAiRunState, loadAiConversations, loadAiRuns, deleteAiConversation, listSchemas, listTables, type AiConversation, type AiRun, type AiRunStatus } from "@/lib/backend/api";
import { aiCancelStream, saveAiConversation, saveAiRun, saveAiRunState, loadAiConversations, loadAiRuns, deleteAiConversation, listSchemas, listTables, readUserSkills, type AiConversation, type AiRun, type AiRunStatus } from "@/lib/backend/api";
import type { AiMessage } from "@/lib/backend/api";
import type { AiConfigItem, AiEffortCapability, AiEffortOption, AiEffortSelection } from "@/types/ai";
import type { ConnectionConfig, QueryTab, SavedSqlFile, TableInfo } from "@/types/database";
@@ -292,6 +295,7 @@ const emit = defineEmits<{
openExplainPlan: [sql: string];
toggleMaximize: [];
close: [];
openSettings: [];
}>();
const prompt = ref("");
@@ -498,6 +502,81 @@ watch(
},
);
// User skills (read-only SKILL.md files). Selection is panel-session
// scope like activeTemplateIds above: closing the panel or restarting clears it,
// conversation switches keep it (prd.md:30).
const userSkillStore = useUserSkillStore();
const selectedSkillIds = ref<string[]>([]);
const showSkillSelector = ref(false);
const skillFailures = ref<ReadUserSkillFailure[]>([]);
// Chips derive from the selection truth, not from the catalog: a skill that
// vanished from discovery must keep a removable chip (prd.md:37) instead of
// silently becoming an id the user can no longer drop.
const selectedSkillChips = computed(() => buildSelectedSkillChips(selectedSkillIds.value, (id) => userSkillStore.metaFor(id)));
// Selector retry-on-open mirrors the template selector above.
watch(showSkillSelector, (open) => {
if (open) void userSkillStore.refresh(skillRootSettings());
});
watch(
() => [settings.desktopSettings?.custom_ai_skill_root_enabled, settings.desktopSettings?.custom_ai_skill_root] as const,
() => {
if (showSkillSelector.value) void userSkillStore.refresh(skillRootSettings());
},
);
function skillRootSettings(): UserSkillRootSettings {
return {
customRootEnabled: settings.desktopSettings?.custom_ai_skill_root_enabled === true,
customRoot: settings.desktopSettings?.custom_ai_skill_root?.trim() || null,
};
}
function toggleSkillSelected(id: string) {
skillFailures.value = [];
selectedSkillIds.value = selectedSkillIds.value.includes(id) ? selectedSkillIds.value.filter((skillId) => skillId !== id) : [...selectedSkillIds.value, id];
}
function removeSelectedSkill(id: string) {
selectedSkillIds.value = removeSkillIds(selectedSkillIds.value, [id]);
skillFailures.value = [];
}
/** Banner Remove: drops every failed skill from the selection, so the next send is not blocked by a stale id. */
function removeFailedSkills() {
selectedSkillIds.value = removeSkillIds(
selectedSkillIds.value,
skillFailures.value.map((failure) => failure.id),
);
skillFailures.value = [];
}
function refreshSkills() {
void userSkillStore.refresh(skillRootSettings());
}
const skillFailureReasonKeys: Record<UserSkillFailureReason, string> = {
not_found: "ai.skillsReasonNotFound",
root_unavailable: "ai.skillsReasonRootUnavailable",
oversized: "ai.skillsReasonOversized",
not_utf8: "ai.skillsReasonNotUtf8",
invalid_frontmatter: "ai.skillsReasonInvalidFrontmatter",
unreadable: "ai.skillsReasonUnreadable",
};
function skillFailureReasonKey(reason: UserSkillFailureReason): string {
return skillFailureReasonKeys[reason];
}
function openSkillRootSettings() {
skillFailures.value = [];
emit("openSettings");
}
function selectedSkillsAgentStep(skills: ReadUserSkill[]): AiAgentStepItem {
const details = skills.map((skill) => `${skill.name} (${t(userSkillSourceOfId(skill.id) === "custom" ? "ai.skillsGroupCustom" : "ai.skillsGroupDefault")})`).join(" · ");
return { key: "selected-skills", labelKey: "ai.agentSteps.skillsLoaded", tone: "success", toolName: skills.map((skill) => skill.name).join(", "), toolResult: details };
}
// Retry store load on selector open if prior init failed (e.g. backend not yet ready at mount)
watch(showTemplateSelector, (open) => {
// Retry load on selector open if prior init failed, then apply pending
@@ -1116,9 +1195,19 @@ const commandOpen = ref(false);
const commandSelectedIndex = ref(0);
const commandStart = ref(0);
const filteredCommands = computed(() => {
/**
* `/skill` is a selector shortcut, not an action: it rides a separate command
* source and never touches `activeAction` or the mode+action picker (prd.md:31).
*/
type AiSlashEntry = { type: "action"; button: AiActionButton } | { type: "skills" };
const filteredCommands = computed<AiSlashEntry[]>(() => {
const query = prompt.value.slice(commandStart.value + 1).toLowerCase();
return actionButtons.value.filter((cmd) => cmd.action.toLowerCase().includes(query) || t(cmd.key).toLowerCase().includes(query));
const entries: AiSlashEntry[] = [...actionButtons.value.map((button): AiSlashEntry => ({ type: "action", button })), { type: "skills" }];
return entries.filter((entry) => {
const haystack = entry.type === "action" ? `${entry.button.action} ${t(entry.button.key)}` : `skill ${t("ai.skillsEntry")}`;
return haystack.toLowerCase().includes(query);
});
});
const AI_SQL_FILE_MENTION_CANDIDATE_LIMIT = 50;
@@ -2486,12 +2575,18 @@ function onPromptKeyup(event: KeyboardEvent) {
refreshMentionState();
}
function selectCommand(command: AiActionButton) {
function selectCommand(command: AiSlashEntry) {
const before = prompt.value.slice(0, commandStart.value);
const after = prompt.value.slice(promptTextareaRef.value?.selectionStart ?? prompt.value.length);
prompt.value = `${before}${after}`.replace(/\s{2,}/g, " ").trim();
commandOpen.value = false;
activeAction.value = command.action;
if (command.type === "skills") {
skillFailures.value = [];
showSkillSelector.value = true;
nextTick(() => promptTextareaRef.value?.focus());
return;
}
activeAction.value = command.button.action;
nextTick(() => {
const textarea = promptTextareaRef.value;
if (textarea) {
@@ -3030,6 +3125,38 @@ async function send() {
}
const generationCanContinue = () => (detachedRun ? !detachedRun.cancelRequested : aiGenerationGuard.isCurrent(myGeneration));
const runIsVisible = () => !detachedRun || (assistantViewMounted && conversationId.value === runConversationId);
// Selected skills are read and validated through the backend registry right
// before the request pipeline starts (after the generation guard: no awaits
// above this point). On any failure no AI request is sent: the failure banner
// appears above the composer while the draft and selection stay untouched
// (prd send-time loading contract).
let sendSkillSnapshot: ReadUserSkill[] | undefined;
if (selectedSkillIds.value.length > 0) {
skillFailures.value = [];
const skillRead = await readUserSkills([...selectedSkillIds.value], skillRootSettings());
if (skillRead.failures.length > 0) {
skillFailures.value = skillRead.failures;
clearPendingWriteGrant();
if (generationCanContinue()) {
if (detachedRun) finishDesktopAiRun(detachedRun, "failed");
if (runIsVisible()) {
isGenerating.value = false;
stopStatusTimer();
generationStatus.value = createGenerationStatus(Date.now());
}
}
resolveDetachedRunSettled();
return;
}
// Skill bodies are only known here, so the combined budget is enforced on
// the read snapshots: overflowing skills are skipped for this send while
// the selection itself stays untouched.
const cappedSkills = capSkillsToCharLimit(skillRead.skills, ACTIVE_SKILLS_TOTAL_MAX);
if (cappedSkills.length < skillRead.skills.length) {
toast(t("ai.skillsTotalTrimmed", { max: ACTIVE_SKILLS_TOTAL_MAX }), 4000);
}
sendSkillSnapshot = cappedSkills;
}
if (!(await promptTemplateStore.ensureLoaded())) {
clearPendingWriteGrant();
if (generationCanContinue()) {
@@ -3068,6 +3195,7 @@ async function send() {
const customPromptContext: CustomPromptContext = {
globalInstructions: promptTemplateStore.globalInstructions,
activeTemplates: [...activeTemplates.value],
...(sendSkillSnapshot?.length ? { selectedSkills: sendSkillSnapshot } : {}),
};
// Remember what was actually sent for this db_type so panels opened later can
// restore it when no explicit per-db_type defaults are configured. This runs
@@ -3229,6 +3357,9 @@ async function send() {
}
runMessages.push({ role: "assistant", content: "", sourceConnectionName: connection.name });
const assistantIdx = runMessages.length - 1;
if (requestedMode === "agent" && sendSkillSnapshot?.length) {
runMessages[assistantIdx].agentSteps = [selectedSkillsAgentStep(sendSkillSnapshot)];
}
const sessionId = uuid();
if (runIsVisible()) {
currentAssistantMessageIndex = assistantIdx;
@@ -3448,8 +3579,9 @@ async function send() {
statusNow.value = Date.now();
}
// Render agent tool call steps from agent events (fallback when no real-time steps)
if (msg && agentEvents.length > 0 && !msg.agentSteps?.length) {
const steps: AiAgentStepItem[] = [];
const hasRuntimeSteps = msg?.agentSteps?.some((step) => step.key !== "selected-skills") ?? false;
if (msg && agentEvents.length > 0 && !hasRuntimeSteps) {
const steps: AiAgentStepItem[] = [...(msg.agentSteps ?? [])];
agentEvents.forEach((e, index) => {
const step = agentEventToStep(e, index, Date.now());
if (step) upsertAgentStep(steps, step);
@@ -3457,7 +3589,7 @@ async function send() {
if (steps.length) msg.agentSteps = steps;
}
// Fallback: use aiAgentPlan for backward compatibility
if (msg && !msg.agentSteps?.length) {
if (msg && !hasRuntimeSteps) {
const agentPlan = buildAiAgentPlan({
mode: requestedMode,
action: requestedAction,
@@ -3466,7 +3598,7 @@ async function send() {
connection: connection,
database: tab.database,
});
if (msg && requestedMode === "agent") msg.agentSteps = buildAiAgentStepItems(agentPlan);
if (msg && requestedMode === "agent") msg.agentSteps = [...(msg.agentSteps ?? []), ...buildAiAgentStepItems(agentPlan)];
if (agentPlan.handoffSql) emit("requestAutoExecuteSql", agentPlan.handoffSql);
}
if (runIsVisible()) {
@@ -5358,6 +5490,56 @@ async function openExternalUrl(url: string) {
</div>
</PopoverContent>
</Popover>
<!-- Skill selector (read-only user SKILL.md library) -->
<Popover v-model:open="showSkillSelector">
<PopoverTrigger as-child>
<button type="button" class="flex min-w-0 items-center gap-1 rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('ai.skillsEntry')" :title="t('ai.skillsEntry')">
<Layers class="h-3 w-3" />
<span class="truncate">{{ t("ai.skillsEntry") }}</span>
<span v-if="selectedSkillIds.length" class="rounded-sm bg-primary px-1 text-[10px] font-medium text-primary-foreground">{{ selectedSkillIds.length }}</span>
</button>
</PopoverTrigger>
<PopoverContent align="end" class="w-72 gap-0 p-1.5">
<div class="max-h-64 overflow-auto">
<div v-if="userSkillStore.isLoading && !userSkillStore.hasLoadedOnce" class="px-3 py-4 text-center text-xs text-muted-foreground">
{{ t("ai.skillsLoading") }}
</div>
<div v-else-if="userSkillStore.lastError" class="space-y-1 px-3 py-4 text-center text-xs text-muted-foreground">
<div>{{ t("ai.skillsLoadError") }}</div>
<button type="button" class="text-[11px] text-primary hover:underline" @click="refreshSkills">
{{ t("ai.skillsRetry") }}
</button>
</div>
<div v-else-if="userSkillStore.totalCount === 0" class="px-3 py-4 text-center text-xs text-muted-foreground">
{{ t("ai.skillsEmpty") }}
</div>
<template v-else>
<template v-for="group in userSkillStore.groupedSkills" :key="group.source">
<div class="px-2 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{{ t(group.source === "custom" ? "ai.skillsGroupCustom" : "ai.skillsGroupDefault") }}
</div>
<template v-for="skill in group.skills" :key="skill.id">
<button type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs hover:bg-muted" @click="toggleSkillSelected(skill.id)">
<div class="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border" :class="selectedSkillIds.includes(skill.id) ? 'border-primary bg-primary text-primary-foreground' : ''">
<Check v-if="selectedSkillIds.includes(skill.id)" class="h-3 w-3" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="truncate font-medium">{{ skill.name }}</div>
<div class="truncate text-[10px] text-muted-foreground">{{ skill.description }}</div>
</div>
</button>
</template>
</template>
</template>
</div>
<div class="border-t mt-1 px-1 pt-1">
<button type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground" :disabled="userSkillStore.isLoading" @click="refreshSkills">
<Loader2 v-if="userSkillStore.isLoading" class="h-3 w-3 animate-spin" />
{{ t("ai.skillsRefresh") }}
</button>
</div>
</PopoverContent>
</Popover>
</div>
<div v-if="mentionOpen" class="absolute bottom-full left-2 right-2 z-20 mb-1 max-h-56 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md">
<div v-if="mentionLoading" class="flex items-center gap-2 px-2 py-2 text-xs text-muted-foreground">
@@ -5394,16 +5576,16 @@ async function openExternalUrl(url: string) {
<div class="max-h-56 overflow-auto p-1">
<button
v-for="(cmd, index) in filteredCommands"
:key="cmd.action"
:key="cmd.type === 'action' ? `action:${cmd.button.action}` : 'skills'"
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted"
:class="{ 'bg-muted': index === commandSelectedIndex }"
@mousedown.prevent="selectCommand(cmd)"
@mouseenter="commandSelectedIndex = index"
>
<component :is="cmd.icon" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="font-medium">/{{ cmd.action }}</span>
<span class="ml-auto text-[11px] text-muted-foreground">{{ t(cmd.key) }}</span>
<component :is="cmd.type === 'action' ? cmd.button.icon : Layers" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="font-medium">/{{ cmd.type === "action" ? cmd.button.action : "skill" }}</span>
<span class="ml-auto text-[11px] text-muted-foreground">{{ t(cmd.type === "action" ? cmd.button.key : "ai.skillsEntry") }}</span>
</button>
</div>
</div>
@@ -5422,6 +5604,23 @@ async function openExternalUrl(url: string) {
<X class="h-3 w-3 shrink-0 text-muted-foreground group-hover:text-foreground" />
</button>
</div>
<div v-if="selectedSkillChips.length" class="mb-1.5 flex flex-wrap gap-1">
<button
v-for="chip in selectedSkillChips"
:key="chip.id"
type="button"
class="group inline-flex max-w-full items-center gap-1 rounded border bg-muted/60 px-1.5 py-0.5 text-[11px] text-foreground/90 hover:bg-muted"
:class="chip.unavailable ? 'border-destructive/50 text-destructive' : 'border-border/80'"
:title="chip.description || chip.name"
@click="removeSelectedSkill(chip.id)"
>
<AlertTriangle v-if="chip.unavailable" class="h-3 w-3 shrink-0" />
<Layers v-else class="h-3 w-3 shrink-0 text-primary" />
<span class="truncate">{{ chip.name }}</span>
<span class="shrink-0 text-[9px] text-muted-foreground">{{ t(chip.source === "custom" ? "ai.skillsGroupCustom" : "ai.skillsGroupDefault") }}</span>
<X class="h-3 w-3 shrink-0 text-muted-foreground group-hover:text-foreground" />
</button>
</div>
<div v-if="selectedCsvAttachments.length || selectedImageAttachments.length" class="mb-1.5 flex max-h-28 flex-wrap gap-1.5 overflow-y-auto pr-0.5">
<AiAttachmentCard
v-for="(attachment, index) in selectedCsvAttachments"
@@ -5455,6 +5654,25 @@ async function openExternalUrl(url: string) {
@remove="removeImageAttachment(index)"
/>
</div>
<div v-if="skillFailures.length" class="mb-1.5 flex items-start gap-1.5 rounded-[7px] border border-destructive/40 bg-destructive/10 px-[9px] py-[5px] text-[11px] text-destructive" role="alert">
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div class="min-w-0 flex-1">
<div class="font-medium">{{ t("ai.skillsSendBlocked") }}</div>
<div v-for="failure in skillFailures" :key="failure.id" class="truncate">{{ userSkillStore.metaFor(failure.id)?.name ?? failure.id }} — {{ t(skillFailureReasonKey(failure.reason)) }}</div>
</div>
<button type="button" class="shrink-0 rounded border border-destructive/40 px-1.5 py-0.5 text-[10px] font-medium hover:bg-destructive/20" @click="send()">
{{ t("ai.skillsRetry") }}
</button>
<button type="button" class="shrink-0 rounded px-1 py-0.5 text-[10px] text-muted-foreground hover:text-foreground" :disabled="userSkillStore.isLoading" @click="refreshSkills">
{{ t("ai.skillsRefresh") }}
</button>
<button v-if="skillFailures.some((failure) => failure.reason === 'root_unavailable')" type="button" class="shrink-0 rounded px-1 py-0.5 text-[10px] text-muted-foreground hover:text-foreground" @click="openSkillRootSettings">
{{ t("ai.skillsOpenSettings") }}
</button>
<button type="button" class="shrink-0 rounded p-0.5 text-muted-foreground hover:text-destructive" :aria-label="t('common.remove')" @click="removeFailedSkills()">
<X class="h-3 w-3" />
</button>
</div>
<div v-if="recoveredDraftActive" class="mb-1.5 flex items-center gap-1.5 rounded-[7px] border border-primary/30 bg-primary/10 px-[9px] py-[5px] text-[11px] text-primary" role="status">
<Clock class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate"> {{ t("ai.recoveredDraftBanner") }} — {{ t("ai.recoveredDraftHint") }} </span>
@@ -18,6 +18,7 @@ import {
Copy,
Download,
ExternalLink,
FolderOpen,
Eye,
Filter,
Globe,
@@ -2791,6 +2792,28 @@ function setSidebarActivation(value: "single" | "double") {
editSidebarActivation.value = value;
}
// Custom AI skill root (read-only SKILL.md discovery; desktop-only, prd 09-21-public-skill-loader).
const customSkillRootDraft = ref("");
watch(
() => settingsStore.desktopSettings.custom_ai_skill_root,
(value) => {
customSkillRootDraft.value = value ?? "";
},
{ immediate: true },
);
async function commitCustomSkillRoot() {
const trimmed = customSkillRootDraft.value.trim();
if ((settingsStore.desktopSettings.custom_ai_skill_root ?? "") === trimmed) return;
await settingsStore.updateDesktopSettings({ custom_ai_skill_root: trimmed || null });
}
async function pickCustomSkillRoot() {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({ directory: true, multiple: false, title: t("settings.aiSkillRoot") });
if (typeof selected !== "string" || !selected) return;
customSkillRootDraft.value = selected;
await settingsStore.updateDesktopSettings({ custom_ai_skill_root: selected });
}
const activeSettingsTab = ref("appearance");
const settingsContentScrollRef = ref<HTMLElement | null>(null);
const isWeb = !isTauriRuntime();
@@ -8942,6 +8965,26 @@ LIMIT 100;</pre
</div>
</div>
<!-- Custom skill directory (list mode, global, desktop-only) -->
<div v-if="aiConfigListMode === 'list' && !isWeb" class="space-y-3">
<Separator />
<div class="settings-item space-y-2 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center justify-between gap-4">
<div class="space-y-1">
<Label for="ai-custom-skill-root">{{ t("settings.aiSkillRoot") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.aiSkillRootDesc") }}</p>
</div>
<Switch id="ai-custom-skill-root" :model-value="settingsStore.desktopSettings.custom_ai_skill_root_enabled === true" @update:model-value="(value) => settingsStore.updateDesktopSettings({ custom_ai_skill_root_enabled: Boolean(value) })" />
</div>
<div class="flex items-center gap-2">
<Input v-model="customSkillRootDraft" :placeholder="t('settings.aiSkillRootPath')" :disabled="settingsStore.desktopSettings.custom_ai_skill_root_enabled !== true" class="h-8 flex-1 font-mono text-xs" @blur="commitCustomSkillRoot" @keydown.enter="commitCustomSkillRoot" />
<Button variant="outline" size="sm" class="h-8 shrink-0 px-2" :disabled="settingsStore.desktopSettings.custom_ai_skill_root_enabled !== true" :aria-label="t('settings.aiSkillRootBrowse')" @click="pickCustomSkillRoot">
<FolderOpen class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
<!-- Max Retries (list mode, global) -->
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
<Separator />
+22
View File
@@ -2986,6 +2986,7 @@ export default withEnglishFallback({
executeSafe: "Təhlükəsizlik yoxlamasından keçdi · İcra edildi",
executeBlocked: "Təhlükəsizlik yoxlaması ilə bloklandı",
contextCompacted: "Kontekst sıxlaşdırıldı",
skillsLoaded: "Skills yükləndi",
},
agentStepTitles: {
riskCheck: "Risk yoxlaması: {action} · {category} · {environment} · {reasons}",
@@ -3239,6 +3240,23 @@ export default withEnglishFallback({
templateSelectorLabel: "Şablonlar: {label}",
templateSelectorDeselectAll: "Bütün seçimləri ləğv et",
templateSelectorLoading: "Yüklənir...",
skillsEntry: "Skills",
skillsLoading: "Skills yüklənir…",
skillsEmpty: "Hələ skill tapılmadı",
skillsRefresh: "Siyahını yenilə",
skillsLoadError: "Skills yüklənmədi",
skillsRetry: "Yenidən cəhd et",
skillsOpenSettings: "Parametrləri aç",
skillsSendBlocked: "Sorğu göndərilmədi",
skillsGroupDefault: "Standart kataloq",
skillsGroupCustom: "Xüsusi kataloq",
skillsReasonNotFound: "Skill qovluğu və ya SKILL.md artık mövcud deyil",
skillsReasonRootUnavailable: "Mənbə kataloğu əlçatmazdır (deaktiv edilib, köçürülüb və ya silinib)",
skillsReasonOversized: "SKILL.md 1 MiB həddini aşır",
skillsReasonNotUtf8: "SKILL.md artık etibarlı UTF-8 deyil",
skillsReasonInvalidFrontmatter: "SKILL.md frontmatter-də name/description yoxdur və ya format yanlışdır",
skillsReasonUnreadable: "SKILL.md oxuna bilmir",
skillsTotalTrimmed: "Bəzi seçilmiş bacarıqlar birgə məzmun həddini ({max} simvol) aşdığı üçün bu göndərmədə ötürüldü.",
templateSelectorEmpty: "Şablon yoxdur. Əlavə etmək üçün İdarə et düyməsinə klikləyin.",
templateSelectorTooLong: "Seçilmiş şablonlar ümumi məzmun limitini ({max} simvol) aşır. Davam etmək üçün bəzilərinin seçimini ləğv edin.",
templateSetDefault: "Standart olaraq təyin et",
@@ -6996,6 +7014,10 @@ export default withEnglishFallback({
snippetsTab: "Kod parçaları",
syncTab: "Sinxronlaşdırma",
aiTab: "AI",
aiSkillRoot: "Xüsusi skill kataloğu",
aiSkillRootDesc: "Əlavə bir SKILL.md skill kataloqunu tarayır. ~/.agents/skills həmişə taranır.",
aiSkillRootPath: "Kataloq yolu",
aiSkillRootBrowse: "Göz at",
mcpTab: "MCP",
jdbcTab: "JDBC drayverləri",
securityTab: "Təhlükəsizlik",
+22
View File
@@ -3180,6 +3180,7 @@ export default {
executeSafe: "Safety check passed · Executed",
executeBlocked: "Blocked by safety check",
contextCompacted: "Context compressed",
skillsLoaded: "Skills loaded",
},
agentStepTitles: {
riskCheck: "Risk check: {action} · {category} · {environment} · {reasons}",
@@ -3433,6 +3434,23 @@ export default {
templateSelectorLabel: "Templates: {label}",
templateSelectorDeselectAll: "Deselect All",
templateSelectorLoading: "Loading...",
skillsEntry: "Skills",
skillsLoading: "Loading skills…",
skillsEmpty: "No skills discovered yet",
skillsRefresh: "Refresh",
skillsLoadError: "Could not load skills",
skillsRetry: "Retry",
skillsOpenSettings: "Open Settings",
skillsSendBlocked: "Request not sent",
skillsGroupDefault: "Default directory",
skillsGroupCustom: "Custom directory",
skillsReasonNotFound: "The skill folder or SKILL.md no longer exists",
skillsReasonRootUnavailable: "Its source directory is unavailable (disabled, moved, or deleted)",
skillsReasonOversized: "SKILL.md exceeds the 1 MiB size limit",
skillsReasonNotUtf8: "SKILL.md is no longer valid UTF-8",
skillsReasonInvalidFrontmatter: "SKILL.md frontmatter is missing name/description or is malformed",
skillsReasonUnreadable: "SKILL.md could not be read",
skillsTotalTrimmed: "Some selected skills exceed the combined content limit ({max} characters) and were skipped for this send.",
templateSelectorEmpty: "No templates. Click Manage to add.",
templateSelectorTooLong: "Selected templates exceed the total content limit ({max} characters). Deselect some to continue.",
templateSetDefault: "Set as default",
@@ -7476,6 +7494,10 @@ export default {
snippetsTab: "Snippets",
syncTab: "Sync",
aiTab: "AI",
aiSkillRoot: "Custom skill directory",
aiSkillRootDesc: "Scan one extra directory for SKILL.md skills. The ~/.agents/skills directory is always scanned.",
aiSkillRootPath: "Directory path",
aiSkillRootBrowse: "Browse",
mcpTab: "MCP",
jdbcTab: "JDBC Drivers",
securityTab: "Security",
+22
View File
@@ -3205,6 +3205,23 @@ export default withEnglishFallback({
templateSelectorLabel: "Plantillas: {label}",
templateSelectorDeselectAll: "Deseleccionar todas",
templateSelectorLoading: "Cargando...",
skillsEntry: "Skills",
skillsLoading: "Cargando skills…",
skillsEmpty: "Aún no se han detectado skills",
skillsRefresh: "Actualizar lista",
skillsLoadError: "No se pudieron cargar las skills",
skillsRetry: "Reintentar",
skillsOpenSettings: "Abrir configuración",
skillsSendBlocked: "Solicitud no enviada",
skillsGroupDefault: "Directorio predeterminado",
skillsGroupCustom: "Directorio personalizado",
skillsReasonNotFound: "La carpeta de la skill o SKILL.md ya no existe",
skillsReasonRootUnavailable: "Su directorio de origen no está disponible (desactivado, movido o eliminado)",
skillsReasonOversized: "SKILL.md supera el límite de 1 MiB",
skillsReasonNotUtf8: "SKILL.md ya no es UTF-8 válido",
skillsReasonInvalidFrontmatter: "El frontmatter de SKILL.md no tiene name/description o no es válido",
skillsReasonUnreadable: "No se pudo leer SKILL.md",
skillsTotalTrimmed: "Algunas skills seleccionadas superan el límite combinado de contenido ({max} caracteres) y se omitieron en este envío.",
templateSelectorEmpty: "Sin plantillas. Haz clic en Gestionar para añadir.",
templateSelectorTooLong: "Las plantillas seleccionadas superan el límite total de contenido ({max} caracteres). Deselecciona algunas para continuar.",
templateSetDefault: "Establecer como predeterminada",
@@ -3254,6 +3271,7 @@ export default withEnglishFallback({
executeSafe: "Verificación de seguridad superada · Ejecutado",
executeBlocked: "Bloqueado por verificación de seguridad",
contextCompacted: "Contexto comprimido",
skillsLoaded: "Skills cargados",
},
agentStepTitles: {
riskCheck: "Verificación de riesgo: {action} · {category} · {environment} · {reasons}",
@@ -6877,6 +6895,10 @@ export default withEnglishFallback({
snippetsTab: "Fragmentos",
syncTab: "Sync",
aiTab: "IA",
aiSkillRoot: "Directorio de skills personalizado",
aiSkillRootDesc: "Analiza un directorio adicional de skills SKILL.md. ~/.agents/skills siempre se analiza.",
aiSkillRootPath: "Ruta del directorio",
aiSkillRootBrowse: "Examinar",
mcpTab: "MCP",
jdbcTab: "Drivers JDBC",
securityTab: "Seguridad",
+22
View File
@@ -3098,6 +3098,23 @@ export default withEnglishFallback({
templateSelectorLabel: "Modelli: {label}",
templateSelectorDeselectAll: "Deseleziona tutto",
templateSelectorLoading: "Caricamento...",
skillsEntry: "Skills",
skillsLoading: "Caricamento skill…",
skillsEmpty: "Nessuna skill trovata",
skillsRefresh: "Aggiorna elenco",
skillsLoadError: "Impossibile caricare le skill",
skillsRetry: "Riprova",
skillsOpenSettings: "Apri impostazioni",
skillsSendBlocked: "Richiesta non inviata",
skillsGroupDefault: "Directory predefinita",
skillsGroupCustom: "Directory personalizzata",
skillsReasonNotFound: "La cartella della skill o SKILL.md non esiste più",
skillsReasonRootUnavailable: "La directory di origine non è disponibile (disabilitata, spostata o eliminata)",
skillsReasonOversized: "SKILL.md supera il limite di 1 MiB",
skillsReasonNotUtf8: "SKILL.md non è più UTF-8 valido",
skillsReasonInvalidFrontmatter: "Il frontmatter di SKILL.md non ha name/description o non è valido",
skillsReasonUnreadable: "Impossibile leggere SKILL.md",
skillsTotalTrimmed: "Alcune skill selezionate superano il limite combinato di contenuto ({max} caratteri) e sono state saltate in questo invio.",
templateSelectorEmpty: "Nessun modello. Clicca Gestisci per aggiungerne.",
templateSelectorTooLong: "I modelli selezionati superano il limite totale di contenuto ({max} caratteri). Deseleziona alcuni per continuare.",
templateSetDefault: "Imposta come predefinito",
@@ -3147,6 +3164,7 @@ export default withEnglishFallback({
executeSafe: "Controllo di sicurezza superato · Eseguito",
executeBlocked: "Bloccato dal controllo di sicurezza",
contextCompacted: "Contesto compresso",
skillsLoaded: "Skills caricati",
},
agentStepTitles: {
riskCheck: "Controllo rischi: {action} · {category} · {environment} · {reasons}",
@@ -6876,6 +6894,10 @@ export default withEnglishFallback({
snippetsTab: "Snippet",
syncTab: "Sincronizzazione",
aiTab: "AI",
aiSkillRoot: "Directory skill personalizzata",
aiSkillRootDesc: "Analizza una directory aggiuntiva di skill SKILL.md. ~/.agents/skills viene sempre analizzata.",
aiSkillRootPath: "Percorso directory",
aiSkillRootBrowse: "Sfoglia",
mcpTab: "MCP",
jdbcTab: "Driver JDBC",
securityTab: "Sicurezza",
+22
View File
@@ -3239,6 +3239,23 @@ export default withEnglishFallback({
templateSelectorLabel: "テンプレート: {label}",
templateSelectorDeselectAll: "すべて解除",
templateSelectorLoading: "読み込み中...",
skillsEntry: "Skills",
skillsLoading: "Skills を読み込み中…",
skillsEmpty: "利用可能な Skills がありません",
skillsRefresh: "一覧を更新",
skillsLoadError: "Skills の読み込みに失敗しました",
skillsRetry: "再試行",
skillsOpenSettings: "設定を開く",
skillsSendBlocked: "リクエストは送信されませんでした",
skillsGroupDefault: "既定ディレクトリ",
skillsGroupCustom: "カスタムディレクトリ",
skillsReasonNotFound: "Skill フォルダーまたは SKILL.md が存在しません",
skillsReasonRootUnavailable: "ソースディレクトリを利用できません(無効・移動・削除)",
skillsReasonOversized: "SKILL.md が 1 MiB のサイズ上限を超えています",
skillsReasonNotUtf8: "SKILL.md は有効な UTF-8 ではありません",
skillsReasonInvalidFrontmatter: "SKILL.md の frontmatter に name/description がないか形式が不正です",
skillsReasonUnreadable: "SKILL.md を読み取れませんでした",
skillsTotalTrimmed: "選択した一部のスキルが合計上限({max} 文字)を超えたため、今回の送信ではスキップされました。",
templateSelectorEmpty: "テンプレートがありません。「管理」をクリックして追加してください。",
templateSelectorTooLong: "選択されたテンプレートの合計が上限({max}文字)を超えています。選択を解除してください。",
templateSetDefault: "デフォルトに設定",
@@ -3288,6 +3305,7 @@ export default withEnglishFallback({
executeSafe: "安全チェック通過 · 実行済み",
executeBlocked: "安全チェックによりブロック",
contextCompacted: "コンテキストを圧縮しました",
skillsLoaded: "Skills を読み込みました",
},
agentStepTitles: {
riskCheck: "リスクチェック: {action} · {category} · {environment} · {reasons}",
@@ -6913,6 +6931,10 @@ export default withEnglishFallback({
snippetsTab: "スニペット",
syncTab: "同期",
aiTab: "AI",
aiSkillRoot: "カスタム Skill ディレクトリ",
aiSkillRootDesc: "SKILL.md スキルのディレクトリを追加で 1 件スキャンします。~/.agents/skills は常にスキャンされます。",
aiSkillRootPath: "ディレクトリパス",
aiSkillRootBrowse: "参照",
mcpTab: "MCP",
jdbcTab: "JDBCドライバー",
securityTab: "セキュリティ",
+22
View File
@@ -2977,6 +2977,7 @@ export default withEnglishFallback({
executeSafe: "안전성 검사 통과 · 실행됨",
executeBlocked: "안전성 검사로 차단됨",
contextCompacted: "컨텍스트 압축됨",
skillsLoaded: "Skills 로드됨",
},
agentStepTitles: {
riskCheck: "위험 검사: {action} · {category} · {environment} · {reasons}",
@@ -3224,6 +3225,23 @@ export default withEnglishFallback({
templateSelectorLabel: "템플릿: {label}",
templateSelectorDeselectAll: "모두 선택 해제",
templateSelectorLoading: "불러오는 중...",
skillsEntry: "Skills",
skillsLoading: "Skills 을(를) 로드하는 중…",
skillsEmpty: "사용 가능한 Skills 이(가) 없습니다",
skillsRefresh: "목록 새로 고침",
skillsLoadError: "Skills 로드 실패",
skillsRetry: "다시 시도",
skillsOpenSettings: "설정 열기",
skillsSendBlocked: "요청이 전송되지 않았습니다",
skillsGroupDefault: "기본 디렉터리",
skillsGroupCustom: "사용자 지정 디렉터리",
skillsReasonNotFound: "Skill 폴더 또는 SKILL.md 이(가) 더 이상 존재하지 않습니다",
skillsReasonRootUnavailable: "소스 디렉터리를 사용할 수 없습니다(비활성화, 이동 또는 삭제됨)",
skillsReasonOversized: "SKILL.md 이(가) 1 MiB 크기 제한을 초과합니다",
skillsReasonNotUtf8: "SKILL.md 이(가) 더 이상 유효한 UTF-8 이(가) 아닙니다",
skillsReasonInvalidFrontmatter: "SKILL.md frontmatter 에 name/description 이(가) 없거나 형식이 잘못되었습니다",
skillsReasonUnreadable: "SKILL.md 을(를) 읽을 수 없습니다",
skillsTotalTrimmed: "선택한 일부 스킬이 결합 콘텐츠 한도({max}자)를 초과하여 이번 전송에서 건너뛰었습니다.",
templateSelectorEmpty: "템플릿이 없습니다. 추가하려면 관리를 클릭하세요.",
templateSelectorTooLong: "선택한 템플릿이 전체 내용 한도를 초과합니다 ({max}자). 계속하려면 일부를 선택 해제하세요.",
templateSetDefault: "기본값으로 설정",
@@ -6646,6 +6664,10 @@ export default withEnglishFallback({
snippetsTab: "스니펫",
syncTab: "동기화",
aiTab: "AI",
aiSkillRoot: "사용자 지정 Skill 디렉터리",
aiSkillRootDesc: "SKILL.md 스킬 디렉터리를 하나 더 스캔합니다. ~/.agents/skills 는 항상 스캔됩니다.",
aiSkillRootPath: "디렉터리 경로",
aiSkillRootBrowse: "찾아보기",
mcpTab: "MCP",
jdbcTab: "JDBC 드라이버",
securityTab: "보안",
+22
View File
@@ -3206,6 +3206,23 @@ export default withEnglishFallback({
templateSelectorLabel: "Modelos: {label}",
templateSelectorDeselectAll: "Desmarcar todos",
templateSelectorLoading: "Carregando...",
skillsEntry: "Skills",
skillsLoading: "Carregando skills…",
skillsEmpty: "Nenhuma skill encontrada ainda",
skillsRefresh: "Atualizar lista",
skillsLoadError: "Falha ao carregar skills",
skillsRetry: "Tentar novamente",
skillsOpenSettings: "Abrir configurações",
skillsSendBlocked: "Solicitação não enviada",
skillsGroupDefault: "Diretório padrão",
skillsGroupCustom: "Diretório personalizado",
skillsReasonNotFound: "A pasta da skill ou o SKILL.md não existe mais",
skillsReasonRootUnavailable: "O diretório de origem está indisponível (desativado, movido ou excluído)",
skillsReasonOversized: "SKILL.md excede o limite de 1 MiB",
skillsReasonNotUtf8: "SKILL.md não é mais UTF-8 válido",
skillsReasonInvalidFrontmatter: "O frontmatter do SKILL.md está sem name/description ou é inválido",
skillsReasonUnreadable: "Não foi possível ler o SKILL.md",
skillsTotalTrimmed: "Algumas skills selecionadas excedem o limite combinado de conteúdo ({max} caracteres) e foram ignoradas neste envio.",
templateSelectorEmpty: "Nenhum modelo. Clique em Gerenciar para adicionar.",
templateSelectorTooLong: "Os modelos selecionados excedem o limite total de conteúdo ({max} caracteres). Desmarque alguns para continuar.",
templateSetDefault: "Definir como padrão",
@@ -3255,6 +3272,7 @@ export default withEnglishFallback({
executeSafe: "Verificação de segurança aprovada · Executado",
executeBlocked: "Bloqueado pela verificação de segurança",
contextCompacted: "Contexto compactado",
skillsLoaded: "Skills carregados",
},
agentStepTitles: {
riskCheck: "Verificação de risco: {action} · {category} · {environment} · {reasons}",
@@ -6878,6 +6896,10 @@ export default withEnglishFallback({
snippetsTab: "Snippets",
syncTab: "Sincronização",
aiTab: "IA",
aiSkillRoot: "Diretório de skills personalizado",
aiSkillRootDesc: "Examina um diretório adicional de skills SKILL.md. ~/.agents/skills é sempre examinado.",
aiSkillRootPath: "Caminho do diretório",
aiSkillRootBrowse: "Procurar",
mcpTab: "MCP",
jdbcTab: "Drivers JDBC",
securityTab: "Segurança",
+22
View File
@@ -2962,6 +2962,7 @@ export default withEnglishFallback({
executeSafe: "Güvenlik denetimi geçildi · Çalıştırıldı",
executeBlocked: "Güvenlik denetimi tarafından engellendi",
contextCompacted: "Bağlam sıkıştırıldı",
skillsLoaded: "Skills yüklendi",
},
agentStepTitles: {
riskCheck: "Risk denetimi: {action} · {category} · {environment} · {reasons}",
@@ -3212,6 +3213,23 @@ export default withEnglishFallback({
templateSelectorLabel: "Şablonlar: {label}",
templateSelectorDeselectAll: "Tüm Seçimleri Kaldır",
templateSelectorLoading: "Yükleniyor...",
skillsEntry: "Skills",
skillsLoading: "Skills yükleniyor…",
skillsEmpty: "Henüz skill bulunamadı",
skillsRefresh: "Listeyi yenile",
skillsLoadError: "Skills yüklenemedi",
skillsRetry: "Tekrar dene",
skillsOpenSettings: "Ayarları aç",
skillsSendBlocked: "İstek gönderilmedi",
skillsGroupDefault: "Varsayılan dizin",
skillsGroupCustom: "Özel dizin",
skillsReasonNotFound: "Skill klasörü veya SKILL.md artık mevcut değil",
skillsReasonRootUnavailable: "Kaynak dizini kullanılamıyor (devre dışı, taşınmış veya silinmiş)",
skillsReasonOversized: "SKILL.md 1 MiB sınırını aşıyor",
skillsReasonNotUtf8: "SKILL.md artık geçerli UTF-8 değil",
skillsReasonInvalidFrontmatter: "SKILL.md frontmatter içinde name/description eksik veya biçim geçersiz",
skillsReasonUnreadable: "SKILL.md okunamadı",
skillsTotalTrimmed: "Bazı seçili beceriler birleşik içerik sınırını ({max} karakter) aştığı için bu gönderimde atlandı.",
templateSelectorEmpty: "Şablon yok. Eklemek için Yönet'e tıklayın.",
templateSelectorTooLong: "Seçili şablonlar toplam içerik sınırını aşıyor ({max} karakter). Devam etmek için bazılarının seçimini kaldırın.",
templateSetDefault: "Varsayılan yap",
@@ -6878,6 +6896,10 @@ export default withEnglishFallback({
snippetsTab: "Parçacıklar",
syncTab: "Eşitle",
aiTab: "Yapay Zekâ",
aiSkillRoot: "Özel skill dizini",
aiSkillRootDesc: "Bir SKILL.md skill dizinini daha tarar. ~/.agents/skills her zaman taranır.",
aiSkillRootPath: "Dizin yolu",
aiSkillRootBrowse: "Gözat",
mcpTab: "MCP",
jdbcTab: "JDBC Sürücüleri",
securityTab: "Güvenlik",
+22
View File
@@ -3092,6 +3092,7 @@ export default withEnglishFallback({
executeSafe: "安全检查通过 · 已执行",
executeBlocked: "安全检查拦截",
contextCompacted: "上下文已压缩",
skillsLoaded: "已加载上下文 Skills",
},
agentStepTitles: {
riskCheck: "风险检查:{action} · {category} · {environment} · {reasons}",
@@ -3345,6 +3346,23 @@ export default withEnglishFallback({
templateSelectorLabel: "模板: {label}",
templateSelectorDeselectAll: "全不选",
templateSelectorLoading: "加载中...",
skillsEntry: "Skills",
skillsLoading: "正在加载 Skills…",
skillsEmpty: "未发现可用 Skill",
skillsRefresh: "刷新列表",
skillsLoadError: "Skills 加载失败",
skillsRetry: "重试",
skillsOpenSettings: "打开设置",
skillsSendBlocked: "请求未发送",
skillsGroupDefault: "默认目录",
skillsGroupCustom: "自定义目录",
skillsReasonNotFound: "Skill 目录或 SKILL.md 已不存在",
skillsReasonRootUnavailable: "来源目录不可用(已禁用、移动或删除)",
skillsReasonOversized: "SKILL.md 超过 1 MiB 大小限制",
skillsReasonNotUtf8: "SKILL.md 已不是有效的 UTF-8 编码",
skillsReasonInvalidFrontmatter: "SKILL.md frontmatter 缺少 name/description 或格式无效",
skillsReasonUnreadable: "无法读取 SKILL.md",
skillsTotalTrimmed: "部分已选 Skills 合计超出内容上限(最多 {max} 字符),本次发送已跳过。",
templateSelectorEmpty: "暂无模板,请点击管理添加。",
templateSelectorTooLong: "已选模板内容合计超过限制(最多 {max} 字符),请取消部分勾选。",
templateSetDefault: "设为默认",
@@ -7448,6 +7466,10 @@ export default withEnglishFallback({
snippetsTab: "代码片段",
syncTab: "同步",
aiTab: "AI",
aiSkillRoot: "自定义 Skill 目录",
aiSkillRootDesc: "额外扫描一个 SKILL.md 技能目录。~/.agents/skills 目录始终会被扫描。",
aiSkillRootPath: "目录路径",
aiSkillRootBrowse: "浏览",
mcpTab: "MCP",
jdbcTab: "JDBC 驱动",
securityTab: "安全",
+22
View File
@@ -3097,6 +3097,23 @@ export default withEnglishFallback({
templateSelectorLabel: "範本: {label}",
templateSelectorDeselectAll: "取消全選",
templateSelectorLoading: "載入中...",
skillsEntry: "Skills",
skillsLoading: "正在載入 Skills…",
skillsEmpty: "未發現可用 Skill",
skillsRefresh: "重新整理清單",
skillsLoadError: "Skills 載入失敗",
skillsRetry: "重試",
skillsOpenSettings: "開啟設定",
skillsSendBlocked: "請求未送出",
skillsGroupDefault: "預設目錄",
skillsGroupCustom: "自訂目錄",
skillsReasonNotFound: "Skill 目錄或 SKILL.md 已不存在",
skillsReasonRootUnavailable: "來源目錄不可用(已停用、移動或刪除)",
skillsReasonOversized: "SKILL.md 超過 1 MiB 大小限制",
skillsReasonNotUtf8: "SKILL.md 已不是有效的 UTF-8 編碼",
skillsReasonInvalidFrontmatter: "SKILL.md frontmatter 缺少 name/description 或格式無效",
skillsReasonUnreadable: "無法讀取 SKILL.md",
skillsTotalTrimmed: "部分已選 Skills 合計超出內容上限(最多 {max} 字元),本次傳送已略過。",
templateSelectorEmpty: "尚無範本,請點擊管理以新增。",
templateSelectorTooLong: "已選範本內容合計超過限制(最多 {max} 字元),請取消部分選取。",
templateSetDefault: "設為預設",
@@ -3121,6 +3138,7 @@ export default withEnglishFallback({
executeSafe: "安全檢查通過 · 已執行",
executeBlocked: "安全檢查攔截",
contextCompacted: "上下文已壓縮",
skillsLoaded: "已載入上下文 Skills",
},
agentStepTitles: {
riskCheck: "風險檢查:{action} · {category} · {environment} · {reasons}",
@@ -6160,6 +6178,10 @@ export default withEnglishFallback({
snippetsTab: "程式碼片段",
syncTab: "同步",
aiTab: "AI",
aiSkillRoot: "自訂 Skill 目錄",
aiSkillRootDesc: "額外掃描一個 SKILL.md 技能目錄。~/.agents/skills 目錄始終會被掃描。",
aiSkillRootPath: "目錄路徑",
aiSkillRootBrowse: "瀏覽",
jdbcTab: "JDBC 驅動程式",
securityTab: "安全",
aboutTab: "關於我們",
@@ -0,0 +1,77 @@
import { beforeAll, describe, expect, it } from "vitest";
import { buildSystemPrompt, type AiContext, type CustomPromptContext } from "@/lib/ai/ai";
import { setLocale } from "@/i18n";
import type { ReadUserSkill } from "@/types/userSkills";
function context(overrides: Partial<AiContext> = {}): AiContext {
return {
connectionId: "conn-1",
connectionName: "Postgres",
databaseType: "postgres",
database: "app",
currentSql: "",
tables: [],
sqlFiles: [],
csvFiles: [],
truncated: false,
...overrides,
};
}
const skill: ReadUserSkill = {
id: "d-abc123",
name: "SQL Review",
description: "Team SQL review rules",
content: "Always prefix reviews with EXPLAIN checks.",
};
// buildSystemPrompt picks zh/en copy via currentLocale(); pin to en so the
// English-string assertions are deterministic regardless of the host OS locale.
beforeAll(async () => {
await setLocale("en");
});
describe("selected skills prompt injection", () => {
it("injects selected skills into the SQL, Redis, and vector branches", () => {
for (const ctx of [context(), context({ databaseType: "redis", connectionName: "Redis", database: "8" }), context({ databaseType: "qdrant", connectionName: "Qdrant", database: "vec" })]) {
const prompt = buildSystemPrompt("general", ctx, "ask", { selectedSkills: [skill] });
expect(prompt).toContain("## Selected Skills (supplementary)");
expect(prompt).toContain("### Skill: SQL Review");
expect(prompt).toContain('<ai-skill id="d-abc123">');
expect(prompt).toContain("Always prefix reviews with EXPLAIN checks.");
expect(prompt).toContain("</ai-skill>");
}
});
it("injects the zh header for zh locale", async () => {
await setLocale("zh-CN");
try {
const prompt = buildSystemPrompt("general", context(), "ask", { selectedSkills: [skill] });
expect(prompt).toContain("## 用户选择的 Skills(补充性)");
expect(prompt).toContain("按原样注入");
} finally {
await setLocale("en");
}
});
it("leaves prompts byte-for-byte unchanged when no skills are selected", () => {
// AC: with no selected skills, every prompt branch is byte-identical to the
// pre-feature baseline. Absent field, empty array, and undefined custom all
// must agree exactly.
const baseline = buildSystemPrompt("general", context(), "ask");
const withGlobals: CustomPromptContext = { globalInstructions: "Global rule." };
const withEmptySkills: CustomPromptContext = { globalInstructions: "Global rule.", selectedSkills: [] };
for (const ctx of [context(), context({ databaseType: "redis", connectionName: "Redis", database: "8" }), context({ databaseType: "milvus", connectionName: "Milvus", database: "vec" })]) {
expect(buildSystemPrompt("general", ctx, "ask")).toBe(buildSystemPrompt("general", ctx, "ask", undefined));
expect(buildSystemPrompt("general", ctx, "ask", withEmptySkills)).toBe(buildSystemPrompt("general", ctx, "ask", withGlobals));
expect(buildSystemPrompt("general", ctx, "ask", withEmptySkills)).not.toBe(baseline); // globals still apply
}
});
it("treats blank-content skills as absent", () => {
const blank: ReadUserSkill = { ...skill, content: " " };
const withBlank = buildSystemPrompt("general", context(), "ask", { selectedSkills: [blank] });
expect(withBlank).toBe(buildSystemPrompt("general", context(), "ask"));
expect(withBlank).not.toContain("ai-skill");
});
});
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { buildSelectedSkillChips, capSkillsToCharLimit, removeSkillIds, userSkillSourceOfId } from "@/lib/ai/userSkillSelection";
import { ACTIVE_SKILLS_TOTAL_MAX, type ReadUserSkill, type UserSkillMeta } from "@/types/userSkills";
const catalog: Record<string, UserSkillMeta> = {
"d-aaa": { id: "d-aaa", name: "SQL Review", description: "review rules" },
"c-bbb": { id: "c-bbb", name: "Team Rules", description: "team rules" },
};
const lookup = (id: string): UserSkillMeta | undefined => catalog[id];
function skill(id: string, content: string): ReadUserSkill {
return { id, name: id, description: "", content };
}
describe("selected skill chips", () => {
it("keeps a chip for every selected id, including vanished ones", () => {
// Regression anchor for "unavailable skills have a visible recovery action":
// `d-gone` is absent from the catalog (deleted after selection + refresh) and
// must still render a removable chip rather than silently disappearing.
const chips = buildSelectedSkillChips(["d-aaa", "d-gone", "c-bbb"], lookup);
expect(chips.map((chip) => chip.id)).toEqual(["d-aaa", "d-gone", "c-bbb"]);
expect(chips[1]).toEqual({ id: "d-gone", name: "d-gone", description: "", source: "default", unavailable: true });
expect(chips[0].unavailable).toBe(false);
expect(chips[0].name).toBe("SQL Review");
});
it("labels the source from the id prefix so a vanished skill keeps its origin", () => {
expect(userSkillSourceOfId("c-bbb")).toBe("custom");
expect(userSkillSourceOfId("d-aaa")).toBe("default");
const chips = buildSelectedSkillChips(["c-gone"], lookup);
expect(chips[0].source).toBe("custom");
expect(chips[0].unavailable).toBe(true);
});
it("handles an empty selection", () => {
expect(buildSelectedSkillChips([], lookup)).toEqual([]);
});
});
describe("removeSkillIds", () => {
it("drops the given ids and preserves the remaining order", () => {
expect(removeSkillIds(["a", "b", "c"], ["b"])).toEqual(["a", "c"]);
expect(removeSkillIds(["a", "b", "c"], ["a", "c"])).toEqual(["b"]);
});
it("is a no-op for unknown ids and empty removals", () => {
const ids = ["a", "b"];
expect(removeSkillIds(ids, [])).toEqual(ids);
expect(removeSkillIds(ids, ["zzz"])).toEqual(ids);
expect(removeSkillIds(ids, ["a", "a"])).toEqual(["b"]);
});
});
describe("capSkillsToCharLimit", () => {
it("keeps leading skills that fit the combined budget and skips overflowing ones", () => {
// Regression anchor for "selected skills cannot grow the system prompt
// without bound": the first skill fills the budget, the oversized second
// is skipped, and the smaller third still fits.
const big = "x".repeat(60);
const capped = capSkillsToCharLimit([skill("d-1", "a".repeat(30)), skill("d-2", big), skill("d-3", "b".repeat(10))], 50);
expect(capped.map((s) => s.id)).toEqual(["d-1", "d-3"]);
});
it("drops a single skill whose content alone exceeds the budget", () => {
expect(capSkillsToCharLimit([skill("d-1", "x".repeat(ACTIVE_SKILLS_TOTAL_MAX + 1))], ACTIVE_SKILLS_TOTAL_MAX)).toEqual([]);
expect(capSkillsToCharLimit([skill("d-1", "x".repeat(ACTIVE_SKILLS_TOTAL_MAX))], ACTIVE_SKILLS_TOTAL_MAX).map((s) => s.id)).toEqual(["d-1"]);
});
it("preserves selection order and dedupes repeated ids", () => {
const capped = capSkillsToCharLimit([skill("d-1", "a"), skill("d-2", "b"), skill("d-1", "a")], 10);
expect(capped.map((s) => s.id)).toEqual(["d-1", "d-2"]);
});
it("keeps everything under the real budget for realistic sizes", () => {
const skills = Array.from({ length: 8 }, (_, i) => skill(`d-${i}`, "y".repeat(2000)));
expect(capSkillsToCharLimit(skills, ACTIVE_SKILLS_TOTAL_MAX).length).toBe(8);
});
});
+13 -1
View File
@@ -3,6 +3,7 @@ import type { AiAssistantMode } from "@/types/ai";
import { uuid } from "@/lib/common/utils";
import type { ColumnInfo, ConnectionConfig, DatabaseType, ForeignKeyInfo, IndexInfo, QueryResult, QueryTab } from "@/types/database";
import type { PromptTemplate } from "@/types/promptTemplate";
import type { ReadUserSkill } from "@/types/userSkills";
import * as api from "@/lib/backend/api";
import { currentLocale, type Locale } from "@/i18n";
import { aiTableMentionKey, type AiTableMention } from "@/lib/ai/aiTableMentions";
@@ -161,16 +162,27 @@ export interface AiNamespaceSelection {
export interface CustomPromptContext {
globalInstructions?: string;
activeTemplates?: PromptTemplate[];
/** Selected read-only SKILL.md snapshots resolved at send time (09-21-public-skill-loader). */
selectedSkills?: ReadUserSkill[];
}
function buildCustomInstructionLines(custom: CustomPromptContext | undefined, isZh: boolean): string[] {
const global = custom?.globalInstructions?.trim() ?? "";
const templates = (custom?.activeTemplates ?? []).filter((t) => t.content.trim());
if (!global && templates.length === 0) return [];
const skills = (custom?.selectedSkills ?? []).filter((skill) => skill.content.trim());
if (!global && templates.length === 0 && skills.length === 0) return [];
const parts: string[] = [];
if (global) parts.push(global);
parts.push(...templates.map((t) => `### ${t.name}\n${t.content}`));
if (skills.length > 0) {
parts.push(
isZh
? "## 用户选择的 Skills(补充性)\n以下为用户显式选择的外部 SKILL.md 规则文件,按原样注入;上方核心安全及方言规则优先级更高。"
: "## Selected Skills (supplementary)\nThe following external SKILL.md rule files were explicitly selected by the user and are injected as-is. Core safety and dialect rules above take precedence.",
);
parts.push(...skills.map((skill) => `### Skill: ${skill.name}\n<ai-skill id="${skill.id}">\n${skill.content}\n</ai-skill>`));
}
return [
isZh
@@ -0,0 +1,67 @@
import type { ReadUserSkill, UserSkillMeta } from "@/types/userSkills";
import { promptTemplateCharacterCount } from "@/types/promptTemplate";
/** A selected skill rendered as a chip, whether or not it is still discoverable. */
export interface SelectedSkillChip {
id: string;
name: string;
description: string;
source: "custom" | "default";
/** True when the catalog no longer lists this id (deleted, or its root went away). */
unavailable: boolean;
}
/**
* The backend mints ids as `<root>-<digest>` (`c-` custom root, `d-` default
* root). Deriving the source from that prefix instead of from the catalog keeps
* a selected skill labeled and removable after it disappears from discovery.
*/
export function userSkillSourceOfId(id: string): "custom" | "default" {
return id.startsWith("c-") ? "custom" : "default";
}
/**
* Every selected id keeps a chip: a skill that vanished from discovery would
* otherwise lose its only remove affordance and block every later send. The
* fallback label is the opaque id, paired with `unavailable` for styling.
*/
export function buildSelectedSkillChips(ids: readonly string[], lookup: (id: string) => UserSkillMeta | undefined): SelectedSkillChip[] {
return ids.map((id) => {
const meta = lookup(id);
return {
id,
name: meta?.name ?? id,
description: meta?.description ?? "",
source: userSkillSourceOfId(id),
unavailable: !meta,
};
});
}
/** Order-preserving removal, shared by the chip close button and the failure banner. */
export function removeSkillIds(ids: readonly string[], removed: Iterable<string>): string[] {
const dropped = new Set(removed);
return ids.filter((id) => !dropped.has(id));
}
/**
* Keep the leading skills that fit the combined character budget for one
* request. A skill whose content would push the total past maxTotal is
* skipped, but a later smaller skill still fits (same budget semantics as
* capTemplateIdsToCharLimit for templates). Selection order is preserved so
* the injected set matches the chips top to bottom.
*/
export function capSkillsToCharLimit(skills: readonly ReadUserSkill[], maxTotal: number): ReadUserSkill[] {
const kept: ReadUserSkill[] = [];
const seen = new Set<string>();
let total = 0;
for (const skill of skills) {
if (seen.has(skill.id)) continue;
seen.add(skill.id);
const size = promptTemplateCharacterCount(skill.content);
if (total + size > maxTotal) continue;
total += size;
kept.push(skill);
}
return kept;
}
@@ -0,0 +1,47 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const tauri = readFileSync(new URL("../tauri.ts", import.meta.url), "utf8");
const http = readFileSync(new URL("../http.ts", import.meta.url), "utf8");
const api = readFileSync(new URL("../api.ts", import.meta.url), "utf8");
const tauriRegistry = readFileSync(new URL("../../../../../../src-tauri/src/lib.rs", import.meta.url), "utf8");
const webRegistry = readFileSync(new URL("../../../../../../crates/dbx-web/src/main.rs", import.meta.url), "utf8");
const commandModuleSource = readFileSync(new URL("../../../../../../src-tauri/src/commands/user_skills.rs", import.meta.url), "utf8");
function functionBody(source: string, operation: string): string {
const start = source.indexOf(`export async function ${operation}(`);
expect(start, `${operation} transport function`).toBeGreaterThanOrEqual(0);
const end = source.indexOf("\nexport async function ", start + 1);
return source.slice(start, end === -1 ? source.length : end);
}
describe("user skills transport contract", () => {
it("routes list/read through the desktop Tauri backend", () => {
expect(functionBody(tauri, "listUserSkills")).toContain('invoke("list_user_skills"');
expect(functionBody(tauri, "readUserSkills")).toContain('invoke("read_user_skills"');
expect(tauriRegistry).toContain("commands::user_skills::list_user_skills,");
expect(tauriRegistry).toContain("commands::user_skills::read_user_skills,");
expect(api).toContain('listUserSkills = forward("listUserSkills")');
expect(api).toContain('readUserSkills = forward("readUserSkills")');
});
it("keeps the skill service strictly read-only outside its tests", () => {
const production = commandModuleSource.slice(0, commandModuleSource.indexOf("#[cfg(test)]"));
expect(production).not.toContain("fs::write");
expect(production).not.toContain("fs::remove");
expect(production).not.toContain("fs::create_dir");
expect(production).not.toContain("OpenOptions");
});
it("keeps the Web transport explicitly unsupported without adding HTTP routes", () => {
for (const operation of ["listUserSkills", "readUserSkills"]) {
const body = functionBody(http, operation);
expect(body).toContain("only available in the desktop app");
expect(body).not.toContain("post(");
expect(body).not.toContain("fetch(");
}
expect(webRegistry).not.toContain("user_skills");
expect(webRegistry).not.toContain("list_user_skills");
expect(webRegistry).not.toContain("read_user_skills");
});
});
+4
View File
@@ -415,6 +415,10 @@ export const deletePromptTemplate = forward("deletePromptTemplate");
export const getAiGlobalCustomInstructions = forward("getAiGlobalCustomInstructions");
export const setAiGlobalCustomInstructions = forward("setAiGlobalCustomInstructions");
// User Skills (read-only Codex-compatible SKILL.md library)
export const listUserSkills = forward("listUserSkills");
export const readUserSkills = forward("readUserSkills");
// System
export const listSystemFonts = forward("listSystemFonts");
export const listSshConfigHosts = forward("listSshConfigHosts");
+11
View File
@@ -1,5 +1,6 @@
import type { MongoDumpFormat, MongoDumpSourceInput, MongoDumpCatalog, MongoRestoreSourcePreview, MongoDatabaseDumpRequest, MongoDatabaseRestoreRequest, MongoDatabaseDumpProgress } from "./mongodbDumpTypes";
import type { MongoRestoreUpload, MongoSourceReadOptions } from "./mongodbDumpTypes";
import type { UserSkillRootSettings, UserSkillsListResult, UserSkillsReadResult } from "@/types/userSkills";
import type {
ConnectionConfig,
ConnectionTestResult,
@@ -303,6 +304,8 @@ const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
driver_store_dir: null,
plugin_store_dir: null,
agent_store_dir: null,
custom_ai_skill_root_enabled: false,
custom_ai_skill_root: null,
sidebar_table_page_size: 1000,
};
@@ -894,6 +897,14 @@ export async function exportAgentsOffline(_path: string, _driverKeys: string[]):
throw new Error("Offline Agent package export is only available in the desktop app.");
}
export async function listUserSkills(_settings: UserSkillRootSettings): Promise<UserSkillsListResult> {
throw new Error("AI skills are only available in the desktop app.");
}
export async function readUserSkills(_ids: string[], _settings: UserSkillRootSettings): Promise<UserSkillsReadResult> {
throw new Error("AI skills are only available in the desktop app.");
}
export async function importAgentDriver(dbType: string, pathOrFile: string | File): Promise<void> {
let blob: Blob;
let fileName: string;
+11
View File
@@ -1,6 +1,7 @@
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
import type { MongoDumpFormat, MongoDumpSourceInput, MongoDumpCatalog, MongoRestoreSourcePreview, MongoDatabaseDumpRequest, MongoDatabaseRestoreRequest, MongoDatabaseDumpProgress } from "./mongodbDumpTypes";
import type { MongoRestoreUpload, MongoSourceReadOptions } from "./mongodbDumpTypes";
import type { UserSkillRootSettings, UserSkillsListResult, UserSkillsReadResult } from "@/types/userSkills";
import { assertUpdateAllowsCommand } from "@/lib/app/updatePreparation";
import { collectBrowserSupportInfo } from "@/lib/app/supportInfo";
// Re-exported below so the HTTP transport shares one definition; imported here
@@ -290,6 +291,8 @@ export interface DesktopSettings {
driver_store_dir?: string | null;
plugin_store_dir?: string | null;
agent_store_dir?: string | null;
custom_ai_skill_root_enabled?: boolean | null;
custom_ai_skill_root?: string | null;
sidebar_table_page_size?: number | null;
}
@@ -1246,6 +1249,14 @@ export async function setAiGlobalCustomInstructions(content: string): Promise<vo
return invoke("set_ai_global_custom_instructions", { content });
}
export async function listUserSkills(settings: UserSkillRootSettings): Promise<UserSkillsListResult> {
return invoke("list_user_skills", { customRootEnabled: settings.customRootEnabled, customRoot: settings.customRoot });
}
export async function readUserSkills(ids: string[], settings: UserSkillRootSettings): Promise<UserSkillsReadResult> {
return invoke("read_user_skills", { ids, customRootEnabled: settings.customRootEnabled, customRoot: settings.customRoot });
}
export async function testConnection(config: ConnectionConfig): Promise<string> {
return invokeBackend("test_connection", { config });
}
@@ -267,6 +267,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
{ id: "ai-agent-turn-limit", category: "ai", titleKey: "ai.maxAgentTurns", descriptionKey: "ai.maxAgentTurnsDescription", targetId: "ai" },
{ id: "ai-global-retries", category: "ai", titleKey: "ai.maxRetriesGlobal", descriptionKey: "ai.maxRetriesGlobalDescription", targetId: "ai" },
{ id: "ai-global-instructions", category: "ai", titleKey: "ai.globalInstructions", descriptionKey: "ai.globalInstructionsDescription", targetId: "ai" },
{ id: "ai-custom-skill-root", category: "ai", titleKey: "settings.aiSkillRoot", descriptionKey: "settings.aiSkillRootDesc", targetId: "ai", visible: desktopOnly },
{ id: "mcp", category: "mcp", titleKey: "settings.mcpTitle", descriptionKey: "settings.mcpDescription", targetId: "mcp" },
{ id: "mcp-bin-path", category: "mcp", titleKey: "settings.mcpBinPath", targetId: "mcp" },
{ id: "mcp-permissions", category: "mcp", titleKey: "settings.mcpExecutionMode", descriptionKey: "settings.mcpExecutionModeDescription", targetId: "mcp" },
+6
View File
@@ -51,6 +51,8 @@ export interface DesktopSettings {
driver_store_dir?: string | null;
plugin_store_dir?: string | null;
agent_store_dir?: string | null;
custom_ai_skill_root_enabled?: boolean | null;
custom_ai_skill_root?: string | null;
sidebar_table_page_size?: number | null;
}
@@ -119,6 +121,8 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
driver_store_dir: null,
plugin_store_dir: null,
agent_store_dir: null,
custom_ai_skill_root_enabled: false,
custom_ai_skill_root: null,
sidebar_table_page_size: DEFAULT_SIDEBAR_TABLE_PAGE_SIZE,
};
@@ -219,6 +223,8 @@ export function normalizeDesktopSettings(settings: Partial<DesktopSettings> | nu
driver_store_dir: settings?.driver_store_dir?.trim() || DEFAULT_DESKTOP_SETTINGS.driver_store_dir,
plugin_store_dir: settings?.plugin_store_dir?.trim() || DEFAULT_DESKTOP_SETTINGS.plugin_store_dir,
agent_store_dir: settings?.agent_store_dir?.trim() || DEFAULT_DESKTOP_SETTINGS.agent_store_dir,
custom_ai_skill_root_enabled: settings?.custom_ai_skill_root_enabled ?? DEFAULT_DESKTOP_SETTINGS.custom_ai_skill_root_enabled,
custom_ai_skill_root: settings?.custom_ai_skill_root?.trim() || DEFAULT_DESKTOP_SETTINGS.custom_ai_skill_root,
sidebar_table_page_size: sidebarTablePageSize,
};
}
+72
View File
@@ -0,0 +1,72 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import * as api from "@/lib/backend/api";
import type { UserSkillMeta, UserSkillRootListing, UserSkillRootSettings } from "@/types/userSkills";
type UserSkillRootStatus = UserSkillRootListing["status"];
/**
* Metadata-only catalog of discoverable skills for the AI panel selector.
* Selection state does NOT live here: it is panel-session scoped inside
* AiAssistant (like activeTemplateIds), so closing the panel clears it while
* the catalog cache survives. This store never holds skill bodies - bodies
* are read once per send through the backend (prd.md:36/:56).
*/
export const useUserSkillStore = defineStore("userSkillStore", () => {
const defaultRootSkills = ref<UserSkillMeta[]>([]);
const customRootSkills = ref<UserSkillMeta[]>([]);
const defaultRootStatus = ref<UserSkillRootStatus>("missing");
const customRootStatus = ref<UserSkillRootStatus>("missing");
const isLoading = ref(false);
const lastError = ref<string | null>(null);
const hasLoadedOnce = ref(false);
/** Custom-root results sort first when enabled (prd.md:29). */
const groupedSkills = computed<Array<{ source: "custom" | "default"; skills: UserSkillMeta[] }>>(() => {
const groups: Array<{ source: "custom" | "default"; skills: UserSkillMeta[] }> = [];
if (customRootSkills.value.length > 0) groups.push({ source: "custom", skills: customRootSkills.value });
if (defaultRootSkills.value.length > 0) groups.push({ source: "default", skills: defaultRootSkills.value });
return groups;
});
const totalCount = computed(() => defaultRootSkills.value.length + customRootSkills.value.length);
async function refresh(rootSettings: UserSkillRootSettings): Promise<boolean> {
isLoading.value = true;
lastError.value = null;
try {
const result = await api.listUserSkills(rootSettings);
defaultRootSkills.value = result.defaultRoot.skills;
defaultRootStatus.value = result.defaultRoot.status;
customRootSkills.value = result.customRoot?.skills ?? [];
customRootStatus.value = result.customRoot?.status ?? "missing";
hasLoadedOnce.value = true;
return true;
} catch (error) {
// Keep failures retryable; the selector shows lastError with a Retry action.
lastError.value = error instanceof Error ? error.message : String(error);
return false;
} finally {
isLoading.value = false;
}
}
/** Display metadata lookup for selected ids, including stale ids whose skill has since vanished. */
function metaFor(id: string): UserSkillMeta | undefined {
return defaultRootSkills.value.find((skill) => skill.id === id) ?? customRootSkills.value.find((skill) => skill.id === id);
}
return {
defaultRootSkills,
customRootSkills,
defaultRootStatus,
customRootStatus,
isLoading,
lastError,
hasLoadedOnce,
groupedSkills,
totalCount,
refresh,
metaFor,
};
});
+53
View File
@@ -0,0 +1,53 @@
/**
* Read-only user-level SKILL.md skills (prd 09-21-public-skill-loader).
* Ids are opaque and deterministic (root source + canonical relative skill path);
* absolute filesystem paths never cross this boundary.
*/
export interface UserSkillMeta {
id: string;
name: string;
description: string;
}
export interface UserSkillRootListing {
status: "ok" | "missing" | "invalid";
skills: UserSkillMeta[];
}
export interface UserSkillsListResult {
defaultRoot: UserSkillRootListing;
customRoot: UserSkillRootListing | null;
}
export interface ReadUserSkill {
id: string;
name: string;
description: string;
content: string;
}
export type UserSkillFailureReason = "not_found" | "root_unavailable" | "oversized" | "not_utf8" | "invalid_frontmatter" | "unreadable";
export interface ReadUserSkillFailure {
id: string;
reason: UserSkillFailureReason;
}
export interface UserSkillsReadResult {
skills: ReadUserSkill[];
failures: ReadUserSkillFailure[];
}
export interface UserSkillRootSettings {
customRootEnabled: boolean;
customRoot: string | null;
}
/**
* Combined character budget for skills injected into one AI request. Skill
* bodies are only known after the send-time read (the catalog is metadata
* only), so the budget is enforced on the read snapshots with the same
* semantics as the template budget.
*/
export const ACTIVE_SKILLS_TOTAL_MAX = 65536;