From 34d8811cd3a015893fa3bd27a71b7fc6354450f0 Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Tue, 22 Sep 2026 02:24:23 +0800 Subject: [PATCH] feat(ai): add read-only user skill loader for SKILL.md Refs: #9363 --- Cargo.lock | 1 + apps/desktop/src/App.vue | 1 + .../src/components/editor/AiAssistant.vue | 244 ++++++- .../editor/EditorSettingsDialog.vue | 43 ++ apps/desktop/src/i18n/locales/az.ts | 22 + apps/desktop/src/i18n/locales/en.ts | 22 + apps/desktop/src/i18n/locales/es.ts | 22 + apps/desktop/src/i18n/locales/it.ts | 22 + apps/desktop/src/i18n/locales/ja.ts | 22 + apps/desktop/src/i18n/locales/ko.ts | 22 + apps/desktop/src/i18n/locales/pt-BR.ts | 22 + apps/desktop/src/i18n/locales/tr.ts | 22 + apps/desktop/src/i18n/locales/zh-CN.ts | 22 + apps/desktop/src/i18n/locales/zh-TW.ts | 22 + .../lib/__tests__/ai/aiSkillsPrompt.spec.ts | 77 +++ .../__tests__/ai/userSkillSelection.spec.ts | 78 +++ apps/desktop/src/lib/ai/ai.ts | 14 +- apps/desktop/src/lib/ai/userSkillSelection.ts | 67 ++ .../userSkillsCapabilityContract.spec.ts | 47 ++ apps/desktop/src/lib/backend/api.ts | 4 + apps/desktop/src/lib/backend/http.ts | 11 + apps/desktop/src/lib/backend/tauri.ts | 11 + .../src/lib/settings/settingsSearch.ts | 1 + apps/desktop/src/stores/settingsStore.ts | 6 + apps/desktop/src/stores/userSkillStore.ts | 72 +++ apps/desktop/src/types/userSkills.ts | 53 ++ crates/dbx-core/src/persistence/storage.rs | 72 +++ docs/content/docs/ai-assistant.cn.mdx | 8 + docs/content/docs/ai-assistant.mdx | 8 + .../app-tests/aiAssistantSendGuard.test.ts | 21 +- .../app-tests/aiAssistantSkillEntry.test.ts | 51 ++ packages/app-tests/userSkillStore.test.ts | 77 +++ src-tauri/Cargo.toml | 1 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/user_skills.rs | 600 ++++++++++++++++++ src-tauri/src/lib.rs | 2 + 36 files changed, 1765 insertions(+), 26 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/ai/aiSkillsPrompt.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/ai/userSkillSelection.spec.ts create mode 100644 apps/desktop/src/lib/ai/userSkillSelection.ts create mode 100644 apps/desktop/src/lib/backend/__tests__/userSkillsCapabilityContract.spec.ts create mode 100644 apps/desktop/src/stores/userSkillStore.ts create mode 100644 apps/desktop/src/types/userSkills.ts create mode 100644 packages/app-tests/aiAssistantSkillEntry.test.ts create mode 100644 packages/app-tests/userSkillStore.test.ts create mode 100644 src-tauri/src/commands/user_skills.rs diff --git a/Cargo.lock b/Cargo.lock index d37dae39e6..5cac4f3726 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2052,6 +2052,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_yaml_ng", "sha2 0.10.9", "sys-locale", "sysinfo", diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 6c18902f71..a826a272d6 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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')" /> diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 601bc4e397..c03e03af30 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -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([]); +const showSkillSelector = ref(false); +const skillFailures = ref([]); +// 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 = { + 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(() => { 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) { + + + + + + +
+
+ {{ t("ai.skillsLoading") }} +
+
+
{{ t("ai.skillsLoadError") }}
+ +
+
+ {{ t("ai.skillsEmpty") }} +
+ +
+
+ +
+
+
@@ -5394,16 +5576,16 @@ async function openExternalUrl(url: string) {
@@ -5422,6 +5604,23 @@ async function openExternalUrl(url: string) {
+
+ +
+
{{ t("ai.recoveredDraftBanner") }} — {{ t("ai.recoveredDraftHint") }} diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 6889c11646..73ac3b668f 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -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(null); const isWeb = !isTauriRuntime(); @@ -8942,6 +8965,26 @@ LIMIT 100;
+ +
+ +
+
+
+ +

{{ t("settings.aiSkillRootDesc") }}

+
+ +
+
+ + +
+
+
+
diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 987f2b162d..095742ad36 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index ac5a5b91d5..fdf48dd9d7 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 6c4ad7ed13..4ba5d9863a 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 7d9b7e5a03..cb69619c93 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index d5a2a86820..17bb8af84a 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -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: "セキュリティ", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 904cc6caaa..6197274aff 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -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: "보안", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 571f9d99aa..28530025c5 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 60512bf7ef..ded4a0936b 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 33894428bd..fdcc45efba 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -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: "安全", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 9d5f439b7a..8f85bf60e1 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -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: "關於我們", diff --git a/apps/desktop/src/lib/__tests__/ai/aiSkillsPrompt.spec.ts b/apps/desktop/src/lib/__tests__/ai/aiSkillsPrompt.spec.ts new file mode 100644 index 0000000000..5eaa66d6de --- /dev/null +++ b/apps/desktop/src/lib/__tests__/ai/aiSkillsPrompt.spec.ts @@ -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 { + 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(''); + expect(prompt).toContain("Always prefix reviews with EXPLAIN checks."); + expect(prompt).toContain(""); + } + }); + + 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"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/ai/userSkillSelection.spec.ts b/apps/desktop/src/lib/__tests__/ai/userSkillSelection.spec.ts new file mode 100644 index 0000000000..edeaaa101e --- /dev/null +++ b/apps/desktop/src/lib/__tests__/ai/userSkillSelection.spec.ts @@ -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 = { + "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); + }); +}); diff --git a/apps/desktop/src/lib/ai/ai.ts b/apps/desktop/src/lib/ai/ai.ts index f5a1a52aa2..e02e9c2303 100644 --- a/apps/desktop/src/lib/ai/ai.ts +++ b/apps/desktop/src/lib/ai/ai.ts @@ -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\n${skill.content}\n`)); + } return [ isZh diff --git a/apps/desktop/src/lib/ai/userSkillSelection.ts b/apps/desktop/src/lib/ai/userSkillSelection.ts new file mode 100644 index 0000000000..1eee395a2b --- /dev/null +++ b/apps/desktop/src/lib/ai/userSkillSelection.ts @@ -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 `-` (`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[] { + 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(); + 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; +} diff --git a/apps/desktop/src/lib/backend/__tests__/userSkillsCapabilityContract.spec.ts b/apps/desktop/src/lib/backend/__tests__/userSkillsCapabilityContract.spec.ts new file mode 100644 index 0000000000..97849b07d2 --- /dev/null +++ b/apps/desktop/src/lib/backend/__tests__/userSkillsCapabilityContract.spec.ts @@ -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"); + }); +}); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 67735fe28e..7848fcb9b4 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -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"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 0d53da1aa5..3c9c912639 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -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 { + throw new Error("AI skills are only available in the desktop app."); +} + +export async function readUserSkills(_ids: string[], _settings: UserSkillRootSettings): Promise { + throw new Error("AI skills are only available in the desktop app."); +} + export async function importAgentDriver(dbType: string, pathOrFile: string | File): Promise { let blob: Blob; let fileName: string; diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 62b9e0c9ce..61ec780df9 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -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 { + return invoke("list_user_skills", { customRootEnabled: settings.customRootEnabled, customRoot: settings.customRoot }); +} + +export async function readUserSkills(ids: string[], settings: UserSkillRootSettings): Promise { + return invoke("read_user_skills", { ids, customRootEnabled: settings.customRootEnabled, customRoot: settings.customRoot }); +} + export async function testConnection(config: ConnectionConfig): Promise { return invokeBackend("test_connection", { config }); } diff --git a/apps/desktop/src/lib/settings/settingsSearch.ts b/apps/desktop/src/lib/settings/settingsSearch.ts index af72e5ff5d..1328123a68 100644 --- a/apps/desktop/src/lib/settings/settingsSearch.ts +++ b/apps/desktop/src/lib/settings/settingsSearch.ts @@ -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" }, diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index ebc3cd4900..86b510207c 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -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 | 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, }; } diff --git a/apps/desktop/src/stores/userSkillStore.ts b/apps/desktop/src/stores/userSkillStore.ts new file mode 100644 index 0000000000..11196e1f85 --- /dev/null +++ b/apps/desktop/src/stores/userSkillStore.ts @@ -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([]); + const customRootSkills = ref([]); + const defaultRootStatus = ref("missing"); + const customRootStatus = ref("missing"); + const isLoading = ref(false); + const lastError = ref(null); + const hasLoadedOnce = ref(false); + + /** Custom-root results sort first when enabled (prd.md:29). */ + const groupedSkills = computed>(() => { + 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 { + 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, + }; +}); diff --git a/apps/desktop/src/types/userSkills.ts b/apps/desktop/src/types/userSkills.ts new file mode 100644 index 0000000000..88fc7e5066 --- /dev/null +++ b/apps/desktop/src/types/userSkills.ts @@ -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; diff --git a/crates/dbx-core/src/persistence/storage.rs b/crates/dbx-core/src/persistence/storage.rs index 73f33ed915..b1d9042f78 100644 --- a/crates/dbx-core/src/persistence/storage.rs +++ b/crates/dbx-core/src/persistence/storage.rs @@ -232,6 +232,10 @@ pub struct DesktopSettings { pub plugin_store_dir: Option, #[serde(default)] pub agent_store_dir: Option, + #[serde(default)] + pub custom_ai_skill_root_enabled: bool, + #[serde(default)] + pub custom_ai_skill_root: Option, #[serde(default = "default_sidebar_table_page_size")] pub sidebar_table_page_size: usize, } @@ -684,6 +688,8 @@ impl Default for DesktopSettings { driver_store_dir: None, plugin_store_dir: None, agent_store_dir: None, + custom_ai_skill_root_enabled: false, + custom_ai_skill_root: None, sidebar_table_page_size: default_sidebar_table_page_size(), } } @@ -2291,6 +2297,18 @@ impl Storage { settings.remove("agent_store_dir"); } } + settings.insert( + "custom_ai_skill_root_enabled".to_string(), + serde_json::Value::Bool(desktop_settings.custom_ai_skill_root_enabled), + ); + match desktop_settings.custom_ai_skill_root.as_ref().filter(|path| !path.trim().is_empty()) { + Some(path) => { + settings.insert("custom_ai_skill_root".to_string(), serde_json::Value::String(path.clone())); + } + None => { + settings.remove("custom_ai_skill_root"); + } + } settings.insert( "sidebar_table_page_size".to_string(), serde_json::Value::Number(serde_json::Number::from(desktop_settings.sidebar_table_page_size)), @@ -2359,6 +2377,16 @@ impl Storage { .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string), + custom_ai_skill_root_enabled: settings + .get("custom_ai_skill_root_enabled") + .and_then(|value| value.as_bool()) + .unwrap_or_else(|| DesktopSettings::default().custom_ai_skill_root_enabled), + custom_ai_skill_root: settings + .get("custom_ai_skill_root") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), sidebar_table_page_size: settings .get("sidebar_table_page_size") .and_then(|value| value.as_u64()) @@ -7769,6 +7797,8 @@ mod tests { driver_store_dir: Some("/tmp/dbx-drivers".to_string()), plugin_store_dir: Some("/tmp/dbx-plugins".to_string()), agent_store_dir: Some("/tmp/dbx-agents".to_string()), + custom_ai_skill_root_enabled: DesktopSettings::default().custom_ai_skill_root_enabled, + custom_ai_skill_root: None, sidebar_table_page_size: DesktopSettings::default().sidebar_table_page_size, }) .await @@ -7790,11 +7820,53 @@ mod tests { driver_store_dir: Some("/tmp/dbx-drivers".to_string()), plugin_store_dir: Some("/tmp/dbx-plugins".to_string()), agent_store_dir: Some("/tmp/dbx-agents".to_string()), + custom_ai_skill_root_enabled: DesktopSettings::default().custom_ai_skill_root_enabled, + custom_ai_skill_root: None, sidebar_table_page_size: DesktopSettings::default().sidebar_table_page_size, } ); } + #[tokio::test] + async fn desktop_settings_roundtrip_custom_ai_skill_root() { + let path = temp_db_path("desktop-settings-custom-ai-skill-root"); + let storage = Storage::open(&path).await.unwrap(); + + storage + .save_desktop_settings(&DesktopSettings { + custom_ai_skill_root_enabled: true, + custom_ai_skill_root: Some("/tmp/dbx-skills".to_string()), + ..DesktopSettings::default() + }) + .await + .unwrap(); + + let settings = storage.load_desktop_settings().await.unwrap(); + assert!(settings.custom_ai_skill_root_enabled); + assert_eq!(settings.custom_ai_skill_root.as_deref(), Some("/tmp/dbx-skills")); + + let raw = storage.load_app_settings_json().await.unwrap(); + assert_eq!(raw.get("custom_ai_skill_root_enabled").and_then(|value| value.as_bool()), Some(true)); + assert_eq!(raw.get("custom_ai_skill_root").and_then(|value| value.as_str()), Some("/tmp/dbx-skills")); + + storage + .save_desktop_settings(&DesktopSettings { + custom_ai_skill_root_enabled: false, + custom_ai_skill_root: Some(" ".to_string()), + ..DesktopSettings::default() + }) + .await + .unwrap(); + + let settings = storage.load_desktop_settings().await.unwrap(); + assert!(!settings.custom_ai_skill_root_enabled); + assert_eq!(settings.custom_ai_skill_root, None); + + let raw = storage.load_app_settings_json().await.unwrap(); + assert_eq!(raw.get("custom_ai_skill_root_enabled").and_then(|value| value.as_bool()), Some(false)); + assert_eq!(raw.get("custom_ai_skill_root"), None); + } + #[tokio::test] async fn desktop_settings_save_removes_legacy_background_preference() { let path = temp_db_path("desktop-settings-remove-legacy-background"); diff --git a/docs/content/docs/ai-assistant.cn.mdx b/docs/content/docs/ai-assistant.cn.mdx index c2ece02c84..2cae6481b1 100644 --- a/docs/content/docs/ai-assistant.cn.mdx +++ b/docs/content/docs/ai-assistant.cn.mdx @@ -114,6 +114,14 @@ AI 上下文可以包含: 模板会进入模型上下文,应避免放入密码、令牌或不必要的业务数据。规则冲突时,模型未必能按预期选择优先级,应保持全局指令和模板简短、明确且互不矛盾。 + +## Skills(SKILL.md) + +Desktop 可以把只读的 [`SKILL.md`](https://developers.openai.com/codex/skills) 规则文件作为补充性指令附加到请求中。技能始终从 `~/.agents/skills` 发现;设置 → AI 中还可以额外启用一个自定义目录。两个目录独立校验,同名技能并列保留并标注来源(`默认目录` / `自定义目录`),自定义目录的技能排在前面。 + +在输入区的 `Skills` 入口(或输入 `/skill`)选择技能,选中项以芯片形式显示在输入框上方,可随时移除。选择的生命周期是一个打开的 AI 面板:切换会话、新建会话都会保留;关闭面板或重启 DBX 即清空。选择不会持久化,DBX 也绝不写入技能文件。 + +技能正文只在发送时从磁盘读取,并当场校验:文件被删除或不可读、frontmatter 无效、不是 UTF-8、超过单文件 1 MiB 上限、符号链接逃逸出所在目录,都会阻断发送,并在输入框上方显示横幅,标明失败的技能并提供重试、刷新、移除或打开设置等恢复操作。未选择任何技能时,提示词与既有行为逐字节一致。Web 端不提供该功能。 ## 写入和生产安全 AI Agent 的执行边界是后端强制的,不只是提示词: diff --git a/docs/content/docs/ai-assistant.mdx b/docs/content/docs/ai-assistant.mdx index 6e78e7ee42..de61c6e1aa 100644 --- a/docs/content/docs/ai-assistant.mdx +++ b/docs/content/docs/ai-assistant.mdx @@ -116,6 +116,14 @@ Templates can also be marked as the **default for one or more database types** ( Templates enter model context. Do not put passwords, tokens, or unnecessary business data in them. Keep rules concise and non-conflicting because models may not resolve contradictory instructions as expected. + +## Skills (SKILL.md) + +Desktop can attach read-only [`SKILL.md`](https://developers.openai.com/codex/skills) rule files to a request as supplementary instructions. Skills are always discovered in `~/.agents/skills`; Settings → AI can additionally enable one custom directory. Each directory is validated independently, same names are kept side by side and labeled by source (`Default directory` / `Custom directory`), and custom-directory skills sort first. + +Select skills from the composer `Skills` entry (or type `/skill`) and remove them from the chips above the input. The selection lives for the lifetime of one open AI panel — conversation switches and new conversations keep it; closing the panel or restarting DBX clears it. Nothing is persisted, and DBX never writes to skill files. + +Skill bodies are read only when a request is sent, directly from disk, and validated then: file deleted or unreadable, invalid frontmatter, non-UTF-8 content, over the 1 MiB per-file limit, or a symlink escaping its root blocks the send with a banner above the composer naming the failed skill and offering Retry, Refresh, Remove, or Open Settings. With no skills selected, prompts are byte-for-byte identical to the pre-feature behavior. Web ships this feature unsupported. ## Writes and Production Safety AI Agent execution boundaries are enforced by the backend, not only by prompting: diff --git a/packages/app-tests/aiAssistantSendGuard.test.ts b/packages/app-tests/aiAssistantSendGuard.test.ts index ed73048ce9..103f1ef104 100644 --- a/packages/app-tests/aiAssistantSendGuard.test.ts +++ b/packages/app-tests/aiAssistantSendGuard.test.ts @@ -32,11 +32,7 @@ function scriptSetupContent(): string { function functionBody(script: string, signature: string): string { const sigIndex = script.indexOf(signature); assert.notEqual(sigIndex, -1, `expected to find ${signature}`); - assert.equal( - script.indexOf(signature, sigIndex + signature.length), - -1, - `${signature} should be unique`, - ); + assert.equal(script.indexOf(signature, sigIndex + signature.length), -1, `${signature} should be unique`); const openBrace = script.indexOf("{", sigIndex + signature.length); assert.notEqual(openBrace, -1, "function body should open with a brace"); @@ -72,11 +68,15 @@ test("send() acquires the guard synchronously before the first await", () => { assert.ok(entryCheck < guardSet, "isGenerating check must precede the guard set"); assert.ok(guardSet < firstAwait, "guard must be set before the first await (no await between check and set)"); - // The first suspension point is specifically the prompt-template load — the - // await the reviewer flagged as happening before the guard existed. + // The first suspension point is the send-time skill read or the prompt-template + // load — both run after the guard and both are pipeline-entry awaits whose + // failure paths release the guard state explicitly (see the early returns + // after `aiGenerationGuard.begin()`). const ensureLoadedAwait = body.indexOf("await promptTemplateStore.ensureLoaded()"); assert.notEqual(ensureLoadedAwait, -1, "send() should await promptTemplateStore.ensureLoaded()"); - assert.equal(firstAwait, ensureLoadedAwait, "ensureLoaded() must be the first await in send()"); + const skillReadAwait = body.indexOf("await readUserSkills([...selectedSkillIds.value], skillRootSettings())"); + const pipelineEntryAwaits = [ensureLoadedAwait, skillReadAwait].filter((index) => index >= 0); + assert.ok(pipelineEntryAwaits.includes(firstAwait), "the first await in send() must be the skill read or the prompt-template load"); }); test("send() never leaks the guard on an early return after acquiring it", () => { @@ -93,10 +93,7 @@ test("send() never leaks the guard on an early return after acquiring it", () => for (const match of returnsAfterGuard) { const preceding = tail.slice(0, match.index); - assert.ok( - preceding.includes("isGenerating.value = false"), - "any early return after the guard is acquired must reset isGenerating first", - ); + assert.ok(preceding.includes("isGenerating.value = false"), "any early return after the guard is acquired must reset isGenerating first"); } }); diff --git a/packages/app-tests/aiAssistantSkillEntry.test.ts b/packages/app-tests/aiAssistantSkillEntry.test.ts new file mode 100644 index 0000000000..3f7a96551d --- /dev/null +++ b/packages/app-tests/aiAssistantSkillEntry.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "vitest"; + +// AiAssistant.vue has no mounted-DOM harness in this repo (no @vue/test-utils), +// so this pins the /skill contract on the compiled SFC source, the same way +// aiAssistantComposerLayout.test.ts pins the composer layout. +const source = readFileSync(new URL("../../apps/desktop/src/components/editor/AiAssistant.vue", import.meta.url), "utf8"); + +function section(startMarker: string, endMarker: string): string { + const start = source.indexOf(startMarker); + assert.notEqual(start, -1, `missing marker: ${startMarker}`); + const end = source.indexOf(endMarker, start + startMarker.length); + assert.notEqual(end, -1, `missing end marker: ${endMarker}`); + return source.slice(start, end); +} + +test("/skill rides its own command source instead of the action lists", () => { + const commands = section("const filteredCommands = computed", "const AI_SQL_FILE_MENTION_CANDIDATE_LIMIT"); + assert.ok(commands.includes('{ type: "skills" }'), "filteredCommands must merge a dedicated skills entry"); + assert.ok(commands.includes('type: "action"'), "action entries must stay in the same command list"); + + // The built-in action arrays must not gain a skill entry: they also feed the + // mode+action picker, which would then offer /skill as a selectable action. + for (const marker of ["/** Ask-mode actions: SQL-producing, never auto-run. */", "/** Agent-mode actions: task-oriented, drive tool use and real results. */"]) { + const list = section(marker, "];"); + assert.ok(!/skill/i.test(list.replace(/AiActionButton/g, "")), `action list must not contain a skill entry: ${marker}`); + } +}); + +test("selecting /skill never changes the built-in action", () => { + const body = section("function selectCommand(command: AiSlashEntry)", "\nfunction insertMention"); + const skillsBranch = body.indexOf('command.type === "skills"'); + const actionAssign = body.indexOf("activeAction.value = command.button.action"); + assert.notEqual(skillsBranch, -1, "selectCommand must branch on the skills entry"); + assert.notEqual(actionAssign, -1, "selectCommand must still assign the chosen action"); + assert.ok(skillsBranch < actionAssign, "the skills branch must be evaluated before any action assignment"); + + const branch = body.slice(skillsBranch, actionAssign); + assert.ok(branch.includes("showSkillSelector.value = true"), "the skills branch must open the selector"); + assert.ok(branch.includes("return"), "the skills branch must return before the action assignment"); + assert.ok(!branch.includes("activeAction"), "the skills branch must not touch activeAction"); +}); + +test("the skills selector only ever lists metadata", () => { + const selector = section('', 'v-if="mentionOpen"'); + assert.ok(selector.length > 0); + assert.ok(selector.includes("userSkillStore.groupedSkills"), "the selector renders the metadata catalog"); + assert.ok(!selector.includes("readUserSkills"), "the selector must never read skill bodies"); + assert.ok(selector.includes("refreshSkills"), "the selector exposes Refresh"); +}); diff --git a/packages/app-tests/userSkillStore.test.ts b/packages/app-tests/userSkillStore.test.ts new file mode 100644 index 0000000000..932430d9da --- /dev/null +++ b/packages/app-tests/userSkillStore.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { beforeEach, test, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import type { UserSkillRootSettings, UserSkillsListResult } from "../../apps/desktop/src/types/userSkills"; + +const apiMock = vi.hoisted(() => ({ + listUserSkills: vi.fn(), + readUserSkills: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => apiMock); + +import { useUserSkillStore } from "../../apps/desktop/src/stores/userSkillStore.ts"; + +const rootSettings: UserSkillRootSettings = { customRootEnabled: true, customRoot: "D:/Team/dbx-skills" }; + +function listing(status: "ok" | "missing" | "invalid", skills: Array<{ id: string; name: string; description: string }>) { + return { status, skills }; +} + +beforeEach(() => { + setActivePinia(createPinia()); + apiMock.listUserSkills.mockReset(); + apiMock.readUserSkills.mockReset(); +}); + +test("refresh groups custom root first and tracks statuses", async () => { + apiMock.listUserSkills.mockResolvedValueOnce({ + defaultRoot: listing("ok", [ + { id: "d-2", name: "Zeta", description: "z" }, + { id: "d-1", name: "Alpha", description: "a" }, + ]), + customRoot: listing("ok", [{ id: "c-1", name: "Team Rules", description: "t" }]), + } satisfies UserSkillsListResult); + + const store = useUserSkillStore(); + assert.equal(await store.refresh(rootSettings), true); + assert.deepEqual( + store.groupedSkills.map((group) => group.source), + ["custom", "default"], + ); + assert.equal(store.totalCount, 3); + assert.equal(store.metaFor("c-1")?.name, "Team Rules"); + assert.equal(store.defaultRootStatus, "ok"); + assert.equal(store.customRootStatus, "ok"); + assert.equal(apiMock.listUserSkills.mock.calls[0][0], rootSettings); +}); + +test("absent default root and disabled custom root yield empty groups, not errors", async () => { + apiMock.listUserSkills.mockResolvedValueOnce({ + defaultRoot: listing("missing", []), + customRoot: null, + } satisfies UserSkillsListResult); + + const store = useUserSkillStore(); + assert.equal(await store.refresh({ customRootEnabled: false, customRoot: null }), true); + assert.equal(store.totalCount, 0); + assert.deepEqual(store.groupedSkills, []); + assert.equal(store.lastError, null); + assert.equal(store.hasLoadedOnce, true); +}); + +test("refresh failure keeps prior catalog and records retryable error", async () => { + apiMock.listUserSkills.mockResolvedValueOnce({ + defaultRoot: listing("ok", [{ id: "d-1", name: "Alpha", description: "a" }]), + customRoot: null, + } satisfies UserSkillsListResult); + const store = useUserSkillStore(); + await store.refresh(rootSettings); + + apiMock.listUserSkills.mockRejectedValueOnce(new Error("backend unavailable")); + assert.equal(await store.refresh(rootSettings), false); + assert.equal(store.lastError, "backend unavailable"); + // Prior catalog remains so chips keep their display metadata. + assert.equal(store.totalCount, 1); + assert.equal(store.metaFor("d-1")?.name, "Alpha"); +}); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 32ff69c3fc..4170dd176b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ tauri-build = { version = "2.5.6", features = [] } [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_yaml_ng = "0.10" base64 = "0.22" log = "0.4" tauri = { version = "2.10.3", features = ["tray-icon", "image-png"] } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a7661ac180..aa9ec5cef9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -69,6 +69,7 @@ pub mod tunnel_profiles; pub mod update; mod update_cache; mod update_portable; +pub mod user_skills; pub mod vector_cmd; pub mod window_controls; pub mod xlsx_export; diff --git a/src-tauri/src/commands/user_skills.rs b/src-tauri/src/commands/user_skills.rs new file mode 100644 index 0000000000..da3c55c52f --- /dev/null +++ b/src-tauri/src/commands/user_skills.rs @@ -0,0 +1,600 @@ +//! Read-only discovery and reading of user-level SKILL.md skills. +//! +//! Two roots are scanned: the default user-level root (HOME/.agents/skills) and +//! an optional user-configured custom root (Settings > AI). The service is +//! strictly read-only: there is no create/write/delete API. Skills are +//! identified by deterministic opaque ids derived from the root source and the +//! canonical relative skill directory name, so ids survive Refresh (prd.md:34). +//! Absolute paths never leave this module (prd.md:33). + +use std::collections::HashSet; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub const SKILL_FILE_NAME: &str = "SKILL.md"; +/// Hard per-file safety limit: a larger SKILL.md is invalid and omitted from +/// discovery, and blocks the send when selected (prd.md:26/:37). +pub const MAX_SKILL_FILE_BYTES: u64 = 1_048_576; +/// Send-time reads take at most 1 MiB + 1 byte so a file that grows after the +/// metadata check is rejected instead of loaded unboundedly (prd.md:38). +const READ_LIMIT_BYTES: u64 = MAX_SKILL_FILE_BYTES + 1; +/// The opening frontmatter block must close within the first 64 KiB. +const FRONTMATTER_SCAN_BYTES: usize = 64 * 1024; + +const DEFAULT_ROOT_PREFIX: &str = "d"; +const CUSTOM_ROOT_PREFIX: &str = "c"; + +const ROOT_STATUS_OK: &str = "ok"; +const ROOT_STATUS_MISSING: &str = "missing"; +const ROOT_STATUS_INVALID: &str = "invalid"; + +/// Bounded failure vocabulary. Underlying I/O detail (which can contain +/// absolute paths or OS-specific text) is deliberately discarded rather than +/// forwarded, so nothing path-shaped reaches the frontend (prd.md:33). +const REASON_NOT_FOUND: &str = "not_found"; +const REASON_ROOT_UNAVAILABLE: &str = "root_unavailable"; +const REASON_OVERSIZED: &str = "oversized"; +const REASON_NOT_UTF8: &str = "not_utf8"; +const REASON_INVALID_FRONTMATTER: &str = "invalid_frontmatter"; +const REASON_UNREADABLE: &str = "unreadable"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UserSkillMeta { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UserSkillRootListing { + /// One of "ok" | "missing" | "invalid". + pub status: String, + pub skills: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UserSkillsListResult { + pub default_root: UserSkillRootListing, + pub custom_root: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadUserSkill { + pub id: String, + pub name: String, + pub description: String, + pub content: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadUserSkillFailure { + pub id: String, + /// One of "not_found" | "root_unavailable" | "oversized" | "not_utf8" | "invalid_frontmatter" | "unreadable". + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UserSkillsReadResult { + pub skills: Vec, + pub failures: Vec, +} + +/// Lists skill metadata for both roots. The default root is always scanned; +/// the custom root only when it is enabled in Settings (prd.md:27). An absent +/// root yields an empty listing rather than an error (prd.md:35). +#[tauri::command] +pub async fn list_user_skills( + custom_root_enabled: bool, + custom_root: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || list_user_skills_blocking(custom_root_enabled, custom_root.as_deref())) + .await + .map_err(|error| error.to_string()) +} + +/// Reads the currently selected skills by id. Every id is resolved only +/// through re-derivation over a fresh scan of the owning root, so a deleted, +/// moved, or root-escaping skill fails instead of silently resolving to a +/// stale path (prd.md:37). +#[tauri::command] +pub async fn read_user_skills( + ids: Vec, + custom_root_enabled: bool, + custom_root: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + read_user_skills_blocking(&ids, custom_root_enabled, custom_root.as_deref()) + }) + .await + .map_err(|error| error.to_string()) +} + +fn list_user_skills_blocking(custom_root_enabled: bool, custom_root: Option<&str>) -> UserSkillsListResult { + let default_root = match default_skills_root() { + Some(path) => root_listing(&path, DEFAULT_ROOT_PREFIX), + None => empty_listing(ROOT_STATUS_MISSING), + }; + let custom_root = if custom_root_enabled { + enabled_custom_root(custom_root).map(|path| root_listing(&path, CUSTOM_ROOT_PREFIX)) + } else { + None + }; + UserSkillsListResult { default_root, custom_root } +} + +fn read_user_skills_blocking( + ids: &[String], + custom_root_enabled: bool, + custom_root: Option<&str>, +) -> UserSkillsReadResult { + let default_root = + default_skills_root().and_then(|path| std::fs::canonicalize(&path).ok()).filter(|path| path.is_dir()); + let custom_root = if custom_root_enabled { enabled_custom_root(custom_root) } else { None }; + read_user_skills_from(ids, default_root.as_deref(), custom_root.as_deref()) +} + +/// Root resolution is injected so tests can exercise both roots without +/// depending on the developer home directory. +fn read_user_skills_from( + ids: &[String], + default_root: Option<&Path>, + custom_root: Option<&Path>, +) -> UserSkillsReadResult { + let mut skills = Vec::new(); + let mut failures = Vec::new(); + let mut seen_ids: HashSet<&str> = HashSet::new(); + for id in ids { + // A repeated id must not inject the same skill text twice. + if !seen_ids.insert(id.as_str()) { + continue; + } + let prefix = match id.split_once('-') { + Some((prefix, _)) if prefix == DEFAULT_ROOT_PREFIX || prefix == CUSTOM_ROOT_PREFIX => prefix, + _ => { + failures.push(ReadUserSkillFailure { id: id.clone(), reason: REASON_NOT_FOUND.to_string() }); + continue; + } + }; + let root = match prefix { + DEFAULT_ROOT_PREFIX => default_root, + _ => custom_root, + }; + let Some(root) = root else { + failures.push(ReadUserSkillFailure { id: id.clone(), reason: REASON_ROOT_UNAVAILABLE.to_string() }); + continue; + }; + match read_skill_from_root(root, prefix, id) { + Ok(skill) => skills.push(skill), + Err(reason) => failures.push(ReadUserSkillFailure { id: id.clone(), reason }), + } + } + UserSkillsReadResult { skills, failures } +} + +fn enabled_custom_root(custom_root: Option<&str>) -> Option { + let trimmed = custom_root.map(str::trim).filter(|value| !value.is_empty())?; + let canonical = std::fs::canonicalize(trimmed).ok()?; + canonical.is_dir().then_some(canonical) +} + +fn default_skills_root() -> Option { + Some(home_dir()?.join(".agents").join("skills")) +} + +fn home_dir() -> Option { + std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).ok().map(PathBuf::from) +} + +fn empty_listing(status: &str) -> UserSkillRootListing { + UserSkillRootListing { status: status.to_string(), skills: Vec::new() } +} + +fn root_listing(path: &Path, prefix: &str) -> UserSkillRootListing { + match std::fs::canonicalize(path) { + Ok(canonical) if canonical.is_dir() => { + UserSkillRootListing { status: ROOT_STATUS_OK.to_string(), skills: scan_root(&canonical, prefix) } + } + Ok(_) => empty_listing(ROOT_STATUS_INVALID), + Err(_) => empty_listing(ROOT_STATUS_MISSING), + } +} + +/// Scans the direct children of a canonical root. Symlink policy (prd.md:40): +/// an entry is only discoverable when its canonical target stays inside the +/// root, so a symlink that escapes the root is invisible to discovery. +fn scan_root(canonical_root: &Path, prefix: &str) -> Vec { + let mut skills = Vec::new(); + let Ok(entries) = std::fs::read_dir(canonical_root) else { return skills }; + for entry in entries.flatten() { + let dir_name = entry.file_name().to_string_lossy().to_string(); + if dir_name.starts_with('.') { + continue; + } + let Ok(canonical_dir) = entry.path().canonicalize() else { continue }; + if !canonical_dir.starts_with(canonical_root) || !canonical_dir.is_dir() { + continue; + } + let skill_file = canonical_dir.join(SKILL_FILE_NAME); + let Ok(canonical_skill_file) = skill_file.canonicalize() else { continue }; + // A skill directory may be linked within the allowed root, but its + // entry file must obey the same containment rule. Otherwise a normal + // directory could hide a SKILL.md symlink to an arbitrary local file. + if !canonical_skill_file.starts_with(canonical_root) { + continue; + } + let Some((name, description)) = discover_skill_metadata(&canonical_skill_file) else { continue }; + skills.push(UserSkillMeta { id: skill_id(prefix, &dir_name), name, description }); + } + skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.id.cmp(&b.id))); + skills +} + +/// Listing reads only the head of the file: the size gate plus a bounded +/// prefix read are enough to parse the opening frontmatter block +/// (prd.md:26 - "parse their frontmatter according to the public convention"). +/// The body is never read here (prd.md:56). +fn discover_skill_metadata(path: &Path) -> Option<(String, String)> { + let stat = std::fs::metadata(path).ok()?; + if !stat.is_file() || stat.len() > MAX_SKILL_FILE_BYTES { + return None; + } + let file = std::fs::File::open(path).ok()?; + let mut bytes = Vec::new(); + file.take(FRONTMATTER_SCAN_BYTES as u64).read_to_end(&mut bytes).ok()?; + parse_frontmatter(decode_prefix(&bytes, stat.len())?) +} + +/// Decodes the bounded listing prefix strictly, so an unreadable file is not +/// offered as a choice (it would always fail at send time). A multi-byte +/// character cut by the prefix read is not an encoding error: when the file +/// continues past the prefix, the trailing partial character is trimmed. The +/// bytes beyond the prefix stay unvalidated here and are checked at send time. +fn decode_prefix(bytes: &[u8], file_len: u64) -> Option<&str> { + match std::str::from_utf8(bytes) { + Ok(text) => Some(text), + Err(error) if error.error_len().is_none() && file_len > bytes.len() as u64 => { + std::str::from_utf8(&bytes[..error.valid_up_to()]).ok() + } + Err(_) => None, + } +} + +/// Reads and fully validates one selected skill file. The metadata size check +/// is followed by a 1 MiB + 1 byte bounded read, so a file that grows between +/// the two is rejected as oversized instead of loaded unboundedly (prd.md:38). +fn read_skill_from_root(canonical_root: &Path, prefix: &str, id: &str) -> Result { + let entries = std::fs::read_dir(canonical_root).map_err(|_| REASON_NOT_FOUND.to_string())?; + for entry in entries.flatten() { + let dir_name = entry.file_name().to_string_lossy().to_string(); + if dir_name.starts_with('.') || skill_id(prefix, &dir_name) != id { + continue; + } + let canonical_dir = entry.path().canonicalize().map_err(|_| REASON_NOT_FOUND.to_string())?; + if !canonical_dir.starts_with(canonical_root) || !canonical_dir.is_dir() { + return Err(REASON_NOT_FOUND.to_string()); + } + let skill_file = canonical_dir.join(SKILL_FILE_NAME); + let canonical_skill_file = skill_file.canonicalize().map_err(|_| REASON_NOT_FOUND.to_string())?; + // Re-check the entry file on every send, not just its parent directory: + // another process may have swapped SKILL.md for an escaping symlink + // after it was discovered. + if !canonical_skill_file.starts_with(canonical_root) { + return Err(REASON_NOT_FOUND.to_string()); + } + return read_skill_file(&canonical_skill_file, id); + } + Err(REASON_NOT_FOUND.to_string()) +} + +fn read_skill_file(path: &Path, id: &str) -> Result { + let stat = std::fs::metadata(path).map_err(|_| REASON_NOT_FOUND.to_string())?; + if !stat.is_file() { + return Err(REASON_NOT_FOUND.to_string()); + } + if stat.len() > MAX_SKILL_FILE_BYTES { + return Err(REASON_OVERSIZED.to_string()); + } + let file = std::fs::File::open(path).map_err(|_| REASON_UNREADABLE.to_string())?; + let mut bytes = Vec::new(); + file.take(READ_LIMIT_BYTES).read_to_end(&mut bytes).map_err(|_| REASON_UNREADABLE.to_string())?; + if bytes.len() as u64 > MAX_SKILL_FILE_BYTES { + return Err(REASON_OVERSIZED.to_string()); + } + let text = String::from_utf8(bytes).map_err(|_| REASON_NOT_UTF8.to_string())?; + let (name, description) = parse_frontmatter(&text).ok_or_else(|| REASON_INVALID_FRONTMATTER.to_string())?; + Ok(ReadUserSkill { id: id.to_string(), name, description, content: text }) +} + +/// Deterministic opaque id: root source prefix plus a truncated SHA-256 of the +/// canonical relative skill directory name. Stable across Refresh and restarts +/// of the registry (prd.md:34). The id carries no path material to the client. +fn skill_id(prefix: &str, dir_name: &str) -> String { + let digest = Sha256::digest(dir_name.as_bytes()); + let mut id = String::from(prefix); + id.push('-'); + for byte in &digest[..8] { + id.push_str(&format!("{:02x}", byte)); + } + id +} + +/// Parses the opening YAML frontmatter block per the public SKILL.md +/// convention: required non-empty name and description, unknown keys ignored. +/// Returns None when the block is missing, unclosed, oversized, or lacks a +/// required field (prd.md:26/:52). +fn parse_frontmatter(text: &str) -> Option<(String, String)> { + let text = text.strip_prefix("\u{feff}").unwrap_or(text); + let mut lines = text.lines(); + if lines.next()?.trim_end() != "---" { + return None; + } + let mut block = String::new(); + let mut closed = false; + for line in lines { + if line.trim_end() == "---" { + closed = true; + break; + } + block.push_str(line); + block.push('\n'); + if block.len() > FRONTMATTER_SCAN_BYTES { + return None; + } + } + if !closed { + return None; + } + let frontmatter: SkillFrontmatter = serde_yaml_ng::from_str(&block).ok()?; + let name = frontmatter.name?.trim().to_string(); + let description = frontmatter.description?.trim().to_string(); + if name.is_empty() || description.is_empty() { + return None; + } + Some((name, description)) +} + +#[derive(Debug, Deserialize)] +struct SkillFrontmatter { + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_BODY: &str = "---\nname: sql-review\ndescription: Team SQL review rules\n---\n\nFollow the rules.\n"; + + fn temp_skills_root(tag: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("dbx-user-skills-{tag}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + root + } + + fn write_skill(root: &Path, dir: &str, body: &str) -> PathBuf { + let dir = root.join(dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(SKILL_FILE_NAME), body).unwrap(); + dir + } + + fn set_file_len(skill_dir: &Path, len: u64) { + let file = std::fs::OpenOptions::new().write(true).open(skill_dir.join(SKILL_FILE_NAME)).unwrap(); + file.set_len(len).unwrap(); + } + + #[test] + fn parses_frontmatter_and_ignores_extra_keys() { + let (name, description) = + parse_frontmatter("---\nname: \"sql-review\"\ndescription: Team rules\nmetadata:\n short: x\nallowed-tools: [read]\n---\n\nBody.\n").unwrap(); + assert_eq!(name, "sql-review"); + assert_eq!(description, "Team rules"); + } + + #[test] + fn frontmatter_must_open_and_close_at_the_top() { + assert!(parse_frontmatter("name: x\ndescription: y\n").is_none()); + assert!(parse_frontmatter("---\nname: x\ndescription: y\n").is_none()); + assert!(parse_frontmatter("body first\n---\nname: x\ndescription: y\n---\n").is_none()); + } + + #[test] + fn frontmatter_requires_name_and_description() { + assert!(parse_frontmatter("---\ndescription: only desc\n---\n").is_none()); + assert!(parse_frontmatter("---\nname: only name\n---\n").is_none()); + assert!(parse_frontmatter("---\nname:\ndescription: y\n---\n").is_none()); + assert!(parse_frontmatter("---\nname: x\ndescription: \n---\n").is_none()); + } + + #[test] + fn skill_ids_are_deterministic_and_root_scoped() { + assert_eq!(skill_id("d", "alpha"), skill_id("d", "alpha")); + assert_ne!(skill_id("d", "alpha"), skill_id("c", "alpha")); + assert_ne!(skill_id("d", "alpha"), skill_id("d", "beta")); + assert!(skill_id("d", "alpha").starts_with("d-")); + assert_eq!(skill_id("d", "alpha").len(), 18); + } + + #[test] + fn scan_lists_valid_skills_and_omits_others() { + let root = temp_skills_root("scan"); + write_skill(&root, "alpha", VALID_BODY); + write_skill(&root, "zulu", VALID_BODY); + write_skill(&root, "nodesc", "---\nname: nodesc\n---\nbody\n"); + write_skill(&root, ".hidden", VALID_BODY); + let nested = root.join("nested"); + std::fs::create_dir_all(nested.join("deep")).unwrap(); + std::fs::write(nested.join("deep").join(SKILL_FILE_NAME), VALID_BODY).unwrap(); + let oversized = write_skill(&root, "oversized", VALID_BODY); + set_file_len(&oversized, MAX_SKILL_FILE_BYTES + 1); + let canonical = std::fs::canonicalize(&root).unwrap(); + let skills = scan_root(&canonical, "d"); + let names: Vec = skills.iter().map(|skill| skill.name.clone()).collect(); + assert_eq!(names, vec!["sql-review", "sql-review"]); + assert_ne!(skills[0].id, skills[1].id); + assert!(skills.iter().all(|skill| skill.id.starts_with("d-"))); + } + + #[test] + fn root_listing_reports_missing_and_invalid() { + let root = temp_skills_root("listing"); + write_skill(&root, "alpha", VALID_BODY); + let missing = root_listing(&root.join("does-not-exist"), "d"); + assert_eq!(missing.status, "missing"); + assert!(missing.skills.is_empty()); + let file_marker = root.join("plain-file.txt"); + std::fs::write(&file_marker, "x").unwrap(); + assert_eq!(root_listing(&file_marker, "d").status, "invalid"); + assert_eq!(root_listing(&root, "d").status, "ok"); + } + + #[test] + fn read_returns_full_content_and_enforces_the_one_mib_boundary() { + let root = temp_skills_root("read"); + let exact = write_skill(&root, "exact", VALID_BODY); + set_file_len(&exact, MAX_SKILL_FILE_BYTES); + let toobig = write_skill(&root, "toobig", VALID_BODY); + set_file_len(&toobig, MAX_SKILL_FILE_BYTES + 1); + let canonical = std::fs::canonicalize(&root).unwrap(); + let exact_skill = read_skill_from_root(&canonical, "d", &skill_id("d", "exact")).unwrap(); + assert_eq!(exact_skill.name, "sql-review"); + assert_eq!(exact_skill.content.len(), MAX_SKILL_FILE_BYTES as usize); + assert_eq!(read_skill_from_root(&canonical, "d", &skill_id("d", "toobig")).unwrap_err(), "oversized"); + } + + #[test] + fn read_reports_not_found_for_missing_and_root_unavailable_for_disabled_roots() { + let root = temp_skills_root("missing"); + let skill_dir = write_skill(&root, "gone", VALID_BODY); + let canonical = std::fs::canonicalize(&root).unwrap(); + let id = skill_id("d", "gone"); + assert!(read_skill_from_root(&canonical, "d", &id).is_ok()); + std::fs::remove_dir_all(skill_dir).unwrap(); + assert_eq!(read_skill_from_root(&canonical, "d", &id).unwrap_err(), "not_found"); + + let custom = temp_skills_root("custom"); + write_skill(&custom, "team", VALID_BODY); + let custom_id = skill_id("c", "team"); + let disabled = + read_user_skills_blocking(std::slice::from_ref(&custom_id), false, Some(custom.to_str().unwrap())); + assert_eq!(disabled.failures.len(), 1); + assert_eq!(disabled.failures[0].reason, "root_unavailable"); + let vanished = read_user_skills_blocking(&[custom_id], true, Some(custom.join("nope").to_str().unwrap())); + assert_eq!(vanished.failures[0].reason, "root_unavailable"); + } + + #[test] + fn read_preserves_request_order_and_collects_failures() { + // Both roots are injected: a `d-` id resolved through the real home + // directory would make this test depend on the machine it runs on. + let root = temp_skills_root("order"); + write_skill(&root, "keep", VALID_BODY); + std::fs::write(root.join("plain.txt"), "x").unwrap(); + let canonical = std::fs::canonicalize(&root).unwrap(); + let result = read_user_skills_from( + &["d-bogus".to_string(), skill_id("d", "keep"), skill_id("d", "never-existed")], + Some(&canonical), + None, + ); + assert_eq!(result.skills.len(), 1); + assert_eq!(result.skills[0].id, skill_id("d", "keep")); + assert_eq!(result.failures.len(), 2); + assert_eq!(result.failures[0].id, "d-bogus"); + assert_eq!(result.failures[0].reason, "not_found"); + assert_eq!(result.failures[1].reason, "not_found"); + } + + #[test] + fn read_skips_duplicate_ids() { + let root = temp_skills_root("dedupe"); + write_skill(&root, "once", VALID_BODY); + let canonical = std::fs::canonicalize(&root).unwrap(); + let id = skill_id("d", "once"); + let result = read_user_skills_from(&[id.clone(), id.clone(), id], Some(&canonical), None); + assert_eq!(result.skills.len(), 1); + assert!(result.failures.is_empty()); + // Deduplication must not swallow a failure: a repeated bad id is + // still reported exactly once. + let duplicated_bad = ["d-bogus".to_string(), "d-bogus".to_string()]; + let failed = read_user_skills_from(&duplicated_bad, Some(&canonical), None); + assert!(failed.skills.is_empty()); + assert_eq!(failed.failures.len(), 1); + assert_eq!(failed.failures[0].id, "d-bogus"); + } + + #[test] + fn listing_omits_a_file_that_is_not_valid_utf8() { + let root = temp_skills_root("invalid-utf8"); + let dir = root.join("broken"); + std::fs::create_dir_all(&dir).unwrap(); + let mut bytes = b"---\nname: br".to_vec(); + bytes.push(0xFF); + bytes.extend_from_slice(b"oken\ndescription: desc\n---\n\nBody.\n"); + std::fs::write(dir.join(SKILL_FILE_NAME), &bytes).unwrap(); + let canonical = std::fs::canonicalize(&root).unwrap(); + assert!(scan_root(&canonical, "d").is_empty()); + assert_eq!(read_skill_from_root(&canonical, "d", &skill_id("d", "broken")).unwrap_err(), "not_utf8"); + } + + #[test] + fn listing_tolerates_a_multibyte_character_cut_by_the_prefix_read() { + let root = temp_skills_root("prefix-cut"); + let frontmatter = "---\nname: cut\ndescription: boundary\n---\n"; + let mut body = String::from(frontmatter); + body.push_str(&"a".repeat(FRONTMATTER_SCAN_BYTES - frontmatter.len() - 1)); + body.push('\u{e9}'); + body.push_str(&"b".repeat(64)); + write_skill(&root, "cut", &body); + let canonical = std::fs::canonicalize(&root).unwrap(); + let skills = scan_root(&canonical, "d"); + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "cut"); + } + + #[cfg(unix)] + #[test] + fn symlink_policy_allows_inside_root_only() { + use std::os::unix::fs::symlink; + let root = temp_skills_root("links"); + write_skill(&root, "real", VALID_BODY); + symlink(root.join("real"), root.join("inside")).unwrap(); + let outside = temp_skills_root("outside"); + write_skill(&outside, "escapee", VALID_BODY); + symlink(outside.join("escapee"), root.join("escape")).unwrap(); + let canonical = std::fs::canonicalize(&root).unwrap(); + let skills = scan_root(&canonical, "d"); + let ids: Vec = skills.iter().map(|skill| skill.id.clone()).collect(); + assert!(ids.contains(&skill_id("d", "inside"))); + assert!(!ids.contains(&skill_id("d", "escape"))); + } + + #[cfg(unix)] + #[test] + fn skill_file_symlink_cannot_escape_the_root() { + use std::os::unix::fs::symlink; + + let root = temp_skills_root("file-link"); + let escape_dir = write_skill(&root, "file-escape", VALID_BODY); + let outside = temp_skills_root("file-link-outside"); + let outside_file = outside.join("SKILL.md"); + std::fs::write(&outside_file, VALID_BODY).unwrap(); + std::fs::remove_file(escape_dir.join(SKILL_FILE_NAME)).unwrap(); + symlink(&outside_file, escape_dir.join(SKILL_FILE_NAME)).unwrap(); + + let canonical = std::fs::canonicalize(&root).unwrap(); + let id = skill_id("d", "file-escape"); + assert!(!scan_root(&canonical, "d").iter().any(|skill| skill.id == id)); + assert_eq!(read_skill_from_root(&canonical, "d", &id).unwrap_err(), REASON_NOT_FOUND); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 107aa09f38..73b259fd17 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1790,6 +1790,8 @@ pub fn run() { commands::prompt_template::delete_prompt_template, commands::prompt_template::get_ai_global_custom_instructions, commands::prompt_template::set_ai_global_custom_instructions, + commands::user_skills::list_user_skills, + commands::user_skills::read_user_skills, commands::app_settings::load_desktop_settings, commands::app_settings::save_desktop_settings, commands::app_settings::load_max_agent_turns,