1 parent
6af337e67c
commit
34d8811cd3
36 files changed
+1765
-26
No files matched your search
@@ -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;
|
||||
Reference in new issue
Block a user