Forráskód Böngészése

feat: 上传的技能 zip 改为一次性导入(同名技能可选覆盖或保留,显示版本对比);登记技能源、资料库时可逐级选择服务器目录

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 9 órája
szülő
commit
19edf29fe4

+ 46 - 24
apps/admin/src/components/CustomSkillsPanel.vue

@@ -1,9 +1,10 @@
 <script setup lang="ts">
 // 自定义技能(docs/design/12-custom-skills.md):左侧技能列表与技能源,右侧文件编辑;导入审阅、版本历史与对比用对话框
 import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
-import type { CustomSkillDetailDto, CustomSkillDto, CustomSkillFileDto, FileDiffDto, SkillScanDto, SkillSourceDto, SkillSourceInput } from '@agent-server/shared'
+import type { CustomSkillDetailDto, CustomSkillDto, CustomSkillFileDto, FileDiffDto, SkillScanDto, SkillScanItemDto, SkillSourceDto, SkillSourceInput } from '@agent-server/shared'
 import { admin, errorMessage } from '../lib/api'
 import { formatTime, toast } from '../lib/ui'
+import DirPathInput from './DirPathInput.vue'
 
 // 编辑器按需加载
 const MonacoEditor = defineAsyncComponent(() => import('./MonacoEditor.vue'))
@@ -244,10 +245,18 @@ async function removeSource(s: SkillSourceDto) {
   await load()
 }
 
-const scanResult = ref<{ source: SkillSourceDto; scan: SkillScanDto; selected: Record<string, boolean>; open: Record<string, boolean> } | null>(null)
-function showScan(source: SkillSourceDto, scan: SkillScanDto) {
+/** 扫描结果:技能源(source)或上传的 zip(upload,一次性:同名技能选择覆盖或保留) */
+const scanResult = ref<{
+  source: SkillSourceDto | null; upload: { id: string; name: string } | null; scan: SkillScanDto; selected: Record<string, boolean>; open: Record<string, boolean>
+} | null>(null)
+function showScan(source: SkillSourceDto | null, scan: SkillScanDto, upload: { id: string; name: string } | null = null) {
   const selected = Object.fromEntries(scan.skills.filter(s => s.status === 'new' || s.status === 'changed').map(s => [s.name, true]))
-  scanResult.value = { source, scan, selected, open: {} }
+  scanResult.value = { source, upload, scan, selected, open: {} }
+}
+function closeScan() {
+  const u = scanResult.value?.upload
+  scanResult.value = null
+  if (u) void admin.skillSources.discardUpload(u.id).catch(() => {})
 }
 async function scan(s: SkillSourceDto) {
   const r = await run(`scan:${s.id}`, () => admin.skillSources.scan(s.id))
@@ -260,24 +269,30 @@ async function onZip(e: Event) {
   ;(e.target as HTMLInputElement).value = ''
   if (!file) return
   const r = await run('zip', () => admin.skillSources.upload(file))
-  if (r) {
-    await load()
-    showScan(r.source, r.scan)
-  }
+  if (r) showScan(null, r.scan, { id: r.uploadId, name: r.name })
 }
 async function doImport() {
   const s = scanResult.value!
   const names = Object.entries(s.selected).filter(([, v]) => v).map(([k]) => k)
   if (!names.length) return
-  const r = await run('import', () => admin.skillSources.import(s.source.id, names, s.scan.digest))
+  const r = await run('import', () => (s.upload ? admin.skillSources.importUpload(s.upload.id, names, s.scan.digest) : admin.skillSources.import(s.source!.id, names, s.scan.digest)))
   if (!r) return
-  toast(`已导入并发布:${r.imported.map(x => `${x.name} v${x.version}`).join('、')}`, 'success')
+  toast(`已导入并发布:${r.imported.map(x => `${x.name}${!x.created && s.upload ? '(覆盖)' : ''} v${x.version}`).join('、')}`, 'success')
   scanResult.value = null
   await load()
 }
 const STATUS: Record<string, [string, string]> = {
   new: ['新技能', 'text-success'], changed: ['有变化', 'text-link'], unchanged: ['无变化', 'text-fg-muted'], conflict: ['同名冲突', 'text-error'], invalid: ['不能导入', 'text-error'],
 }
+/** 上传的 zip 中与已有技能同名:显示为"已存在",由管理员选择覆盖或保留 */
+const statusLabel = (s: SkillScanItemDto, upload: boolean): [string, string] =>
+  upload && s.status === 'changed' ? ['已存在同名技能', 'text-warning'] : upload && s.status === 'unchanged' ? ['与已有技能相同', 'text-fg-muted'] : STATUS[s.status]!
+/** 版本对比:SKILL.md 声明的版本号与平台中的版本 */
+function versionLine(s: SkillScanItemDto) {
+  const e = s.existing!
+  const now = `v${e.version}${e.declaredVersion ? `(声明版本 ${e.declaredVersion})` : ''} · ${formatTime(e.updatedAt)} 更新`
+  return `已有:${now};上传的:${s.declaredVersion ? `声明版本 ${s.declaredVersion}` : '未声明版本号'}`
+}
 
 const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`)
 const statusOf = (s: CustomSkillDto) => !s.enabled ? '已停用' : s.publishedVersion === null ? '未发布' : s.publishedVersion < s.latestVersion ? `v${s.publishedVersion} 生效 · 有新版本` : `v${s.publishedVersion} 生效`
@@ -319,17 +334,17 @@ defineExpose({ load })
         </div>
         <div v-for="s in sources" :key="s.id" class="rounded-md border border-solid border-border p-2 flex flex-col gap-1">
           <div class="flex items-center gap-1">
-            <span class="codicon text-[12px]" :class="s.kind === 'upload' ? 'codicon-file-zip' : 'codicon-folder'" />
+            <span class="codicon codicon-folder text-[12px]" />
             <span class="font-500 truncate flex-1">{{ s.name }}</span>
-            <button type="button" class="icon-btn" :title="s.kind === 'upload' ? '查看并导入' : '扫描并同步'" :disabled="!!busy" @click="scan(s)">
+            <button type="button" class="icon-btn" title="扫描并同步" :disabled="!!busy" @click="scan(s)">
               <span class="codicon" :class="busy === `scan:${s.id}` ? 'codicon-loading codicon-modifier-spin' : 'codicon-sync'" />
             </button>
-            <button v-if="s.kind === 'directory'" type="button" class="icon-btn" title="编辑" @click="editSource(s)"><span class="codicon codicon-edit" /></button>
+            <button type="button" class="icon-btn" title="编辑" @click="editSource(s)"><span class="codicon codicon-edit" /></button>
             <button type="button" class="icon-btn" title="删除" @click="removeSource(s)"><span class="codicon codicon-trash" /></button>
           </div>
-          <div v-if="s.path" class="hint font-mono break-all">{{ s.path }}</div>
+          <div class="hint font-mono break-all">{{ s.path }}</div>
           <div class="hint">
-            {{ s.kind === 'upload' ? '上传的 zip' : s.syncMode === 'auto' ? `自动同步(每 ${s.intervalMin} 分钟)` : '手动同步' }}
+            {{ s.syncMode === 'auto' ? `自动同步(每 ${s.intervalMin} 分钟)` : '手动同步' }}
             <template v-if="s.lastSyncAt"> · {{ formatTime(s.lastSyncAt) }}</template>
           </div>
           <div v-if="s.status === 'error'" class="text-error break-all">{{ s.lastError }}</div>
@@ -423,8 +438,8 @@ defineExpose({ load })
         <div><label class="label" for="ss-name">名称</label><input id="ss-name" v-model="sourceDraft.name" class="field" required></div>
         <div>
           <label class="label" for="ss-path">目录(绝对路径)</label>
-          <input id="ss-path" v-model="sourceDraft.path" class="field font-mono" placeholder="/srv/toolbox/skills" required>
-          <div class="hint mt-1">支持插件格式(skills/&lt;名称&gt;/SKILL.md)、技能集合(&lt;名称&gt;/SKILL.md)或单个技能。服务看不到 home 下的目录,需要放到 home 以外或配置绑定挂载。</div>
+          <DirPathInput id="ss-path" v-model="sourceDraft.path" placeholder="/srv/toolbox/skills" skills />
+          <div class="hint mt-1">输入或从下拉框中逐级选择(列出的是服务能看到的目录)。支持插件格式(skills/&lt;名称&gt;/SKILL.md)、技能集合(&lt;名称&gt;/SKILL.md)或单个技能。服务看不到 home 下的目录,需要放到 home 以外或配置绑定挂载。</div>
         </div>
         <div><label class="label" for="ss-desc">说明(可选)</label><input id="ss-desc" v-model="sourceDraft.description" class="field"></div>
         <div class="flex items-center gap-4">
@@ -443,21 +458,28 @@ defineExpose({ load })
     </div>
 
     <!-- 扫描结果 / 导入审阅 -->
-    <div v-if="scanResult" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="scanResult = null">
+    <div v-if="scanResult" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="closeScan">
       <div class="w-[min(760px,94vw)] max-h-[86vh] overflow-y-auto card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">
         <div class="flex items-center gap-2">
-          <span class="font-600 text-[14px] flex-1">{{ scanResult.source.name }}:扫描结果</span>
-          <button type="button" class="icon-btn" title="关闭" @click="scanResult = null"><span class="codicon codicon-close" /></button>
+          <span class="font-600 text-[14px] flex-1">{{ scanResult.upload ? `导入技能包 ${scanResult.upload.name}` : `${scanResult.source!.name}:扫描结果` }}</span>
+          <button type="button" class="icon-btn" title="关闭" @click="closeScan"><span class="codicon codicon-close" /></button>
         </div>
         <div v-if="!scanResult.scan.skills.length" class="hint">没有找到技能(需要包含 SKILL.md 的目录)。</div>
         <div v-for="s in scanResult.scan.skills" :key="s.name" class="rounded-md border border-solid border-border p-2.5 flex flex-col gap-1">
           <div class="flex items-center gap-2">
-            <input v-model="scanResult.selected[s.name]" type="checkbox" :disabled="s.status !== 'new' && s.status !== 'changed'" :aria-label="`导入 ${s.name}`">
+            <input v-if="!(scanResult.upload && s.status === 'changed')" v-model="scanResult.selected[s.name]" type="checkbox" :disabled="s.status !== 'new' && s.status !== 'changed'" :aria-label="`导入 ${s.name}`">
             <span class="font-mono font-500">{{ s.name }}</span>
-            <span class="badge bg-code" :class="STATUS[s.status]![1]">{{ STATUS[s.status]![0] }}</span>
+            <span class="badge bg-code" :class="statusLabel(s, !!scanResult.upload)[1]">{{ statusLabel(s, !!scanResult.upload)[0] }}</span>
             <span class="hint">{{ s.path }} · {{ s.fileCount }} 个文件 · {{ fmtSize(s.size) }}</span>
           </div>
           <div class="hint line-clamp-2">{{ s.description }}</div>
+          <template v-if="scanResult.upload && s.existing && s.status !== 'conflict'">
+            <div class="hint">{{ versionLine(s) }}</div>
+            <div v-if="s.status === 'changed'" class="flex items-center gap-4 pl-0.5">
+              <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="scanResult.selected[s.name]" type="radio" :value="true" :name="`ow-${s.name}`">覆盖(作为新版本发布,旧版本可在版本历史中恢复)</label>
+              <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="scanResult.selected[s.name]" type="radio" :value="false" :name="`ow-${s.name}`">保留原版本</label>
+            </div>
+          </template>
           <div v-if="s.reason" class="text-error">{{ s.reason }}</div>
           <div v-for="w in s.warnings" :key="w" class="text-warning">{{ w }}</div>
           <div v-if="s.status === 'changed' || s.status === 'new'" class="hint">
@@ -474,9 +496,9 @@ defineExpose({ load })
         </div>
         <div v-if="scanResult.scan.missing.length" class="text-warning">来源中已不存在:{{ scanResult.scan.missing.join('、') }}(保留;不需要时可以在左侧删除)</div>
         <div v-if="scanResult.scan.skipped.length" class="hint">跳过的条目:{{ scanResult.scan.skipped.join('、') }}</div>
-        <div class="hint">导入后立即发布为新版本;导入的技能只读,与来源保持一致。</div>
+        <div class="hint">{{ scanResult.upload ? '导入后立即发布;从 zip 导入的技能可以在后台继续编辑。上传的文件在导入或关闭后删除,同一个技能只保留一份。' : '导入后立即发布为新版本;导入的技能只读,与来源保持一致。' }}</div>
         <div class="flex justify-end gap-2">
-          <button type="button" class="btn-secondary" @click="scanResult = null">关闭</button>
+          <button type="button" class="btn-secondary" @click="closeScan">{{ scanResult.upload ? '取消' : '关闭' }}</button>
           <button type="button" class="btn-primary" :disabled="!!busy || !Object.values(scanResult.selected).some(Boolean)" @click="doImport">导入所选</button>
         </div>
       </div>

+ 146 - 0
apps/admin/src/components/DirPathInput.vue

@@ -0,0 +1,146 @@
+<script setup lang="ts">
+// 选择服务器目录(类似 code-server 的打开文件夹,技能源与资料库共用):输入路径时在下拉框中列出当前层级的子目录(服务进程看到的),
+// 选中后进入下一层;↑↓ 选择,Enter / Tab 确认,Esc 关闭
+import { computed, ref, watch } from 'vue'
+import type { ServerDirListDto } from '@agent-server/shared'
+import { admin, errorMessage } from '../lib/api'
+
+const model = defineModel<string>({ required: true })
+/** skills:标出含 SKILL.md 的目录并显示找到的技能数(登记技能源时) */
+defineProps<{ id?: string; placeholder?: string; skills?: boolean }>()
+
+const open = ref(false)
+const active = ref(-1)
+const listing = ref<ServerDirListDto | null>(null)
+const error = ref<string | null>(null)
+const loading = ref(false)
+const cache = new Map<string, ServerDirListDto>()
+const input = ref<HTMLInputElement>()
+
+/** 当前输入拆成"所在目录"和"正在输入的名称" */
+const parts = computed(() => {
+  const v = model.value || '/'
+  const i = v.lastIndexOf('/')
+  if (i < 0) return { dir: v.startsWith('~') ? '~' : '/', prefix: v.startsWith('~') ? '' : v }
+  return { dir: i === 0 ? '/' : v.slice(0, i), prefix: v.slice(i + 1) }
+})
+const matches = computed(() => {
+  const dirs = listing.value?.dirs ?? []
+  const q = parts.value.prefix.toLowerCase()
+  if (!q) return dirs.filter(d => !d.name.startsWith('.'))
+  return [...dirs.filter(d => d.name.toLowerCase().startsWith(q)), ...dirs.filter(d => !d.name.toLowerCase().startsWith(q) && d.name.toLowerCase().includes(q))]
+})
+
+let seq = 0
+let timer: ReturnType<typeof setTimeout> | undefined
+async function load(dir: string) {
+  const hit = cache.get(dir)
+  if (hit) {
+    listing.value = hit
+    error.value = null
+    return
+  }
+  const n = ++seq
+  loading.value = true
+  try {
+    const r = await admin.skillSources.dirs(dir)
+    cache.set(dir, r)
+    if (n === seq) {
+      listing.value = r
+      error.value = null
+    }
+  } catch (err) {
+    if (n === seq) {
+      listing.value = null
+      error.value = errorMessage(err)
+    }
+  } finally {
+    if (n === seq) loading.value = false
+  }
+}
+watch(() => parts.value.dir, dir => {
+  if (!open.value) return
+  clearTimeout(timer)
+  timer = setTimeout(() => void load(dir), 150)
+})
+watch(matches, () => (active.value = matches.value.length && parts.value.prefix ? 0 : -1))
+
+function show() {
+  if (!model.value) model.value = '/'
+  open.value = true
+  void load(parts.value.dir)
+}
+function pick(name: string) {
+  const dir = parts.value.dir
+  model.value = `${dir === '/' ? '' : dir}/${name}/`
+  input.value?.focus()
+}
+function up() {
+  const p = listing.value?.parent
+  if (p) model.value = p === '/' ? '/' : `${p}/`
+  input.value?.focus()
+}
+function onKey(e: KeyboardEvent) {
+  if (!open.value) {
+    if (e.key === 'ArrowDown') show()
+    return
+  }
+  const n = matches.value.length
+  if (e.key === 'ArrowDown' && n) {
+    e.preventDefault()
+    active.value = (active.value + 1) % n
+  } else if (e.key === 'ArrowUp' && n) {
+    e.preventDefault()
+    active.value = (active.value - 1 + n) % n
+  } else if ((e.key === 'Enter' || e.key === 'Tab') && active.value >= 0 && matches.value[active.value]) {
+    e.preventDefault()
+    pick(matches.value[active.value]!.name)
+  } else if (e.key === 'Escape' && open.value) {
+    e.preventDefault()
+    e.stopPropagation()
+    open.value = false
+  }
+}
+function onBlur() {
+  // 点击下拉项时先触发 mousedown,延迟关闭
+  setTimeout(() => {
+    if (document.activeElement !== input.value) open.value = false
+  }, 150)
+}
+
+/** 当前路径就是列出的目录时,显示其中找到的技能数 */
+const here = computed(() => (listing.value && !parts.value.prefix ? listing.value : null))
+</script>
+
+<template>
+  <div class="relative">
+    <input
+      :id="id" ref="input" v-model="model" class="field font-mono w-full" :placeholder="placeholder" autocomplete="off" spellcheck="false" required
+      role="combobox" :aria-expanded="open" aria-autocomplete="list" @focus="show" @input="open || show()" @keydown="onKey" @blur="onBlur"
+    >
+    <div v-if="open" class="absolute left-0 right-0 top-full mt-1 z-50 rounded-md border border-solid border-border bg-input-alt shadow-xl text-[12.5px] overflow-hidden" @mousedown.prevent>
+      <div class="flex items-center gap-1.5 px-2 py-1 border-b border-b-solid border-border text-fg-muted">
+        <button type="button" class="icon-btn" title="上一级" :disabled="!listing?.parent" @click="up"><span class="codicon codicon-arrow-up" /></button>
+        <span class="font-mono truncate flex-1">{{ listing?.path ?? parts.dir }}</span>
+        <span v-if="loading" class="codicon codicon-loading codicon-modifier-spin" />
+      </div>
+      <div class="max-h-64 overflow-y-auto py-1" role="listbox">
+        <div v-if="error" class="px-3 py-1.5 text-error">{{ error }}</div>
+        <div v-else-if="listing && !matches.length" class="px-3 py-1.5 text-fg-muted">{{ parts.prefix ? '没有匹配的目录' : '没有子目录' }}</div>
+        <div
+          v-for="(d, i) in matches" :key="d.name" role="option" :aria-selected="i === active"
+          class="flex items-center gap-1.5 px-3 py-1 cursor-pointer" :class="i === active ? 'bg-active' : 'hover:bg-hover'"
+          @mouseenter="active = i" @click="pick(d.name)"
+        >
+          <span class="codicon codicon-folder text-[13px] text-fg-muted" />
+          <span class="font-mono truncate flex-1">{{ d.name }}</span>
+          <span v-if="skills && d.skill" class="text-[11px] text-success">SKILL.md</span>
+        </div>
+        <div v-if="listing?.truncated" class="px-3 py-1 text-fg-muted">只显示前 {{ listing.dirs.length }} 个目录,请继续输入名称筛选</div>
+      </div>
+    </div>
+    <div v-if="skills && here" class="mt-1 text-[12px]" :class="here.skillCount ? 'text-success' : 'text-fg-muted'">
+      {{ here.skillCount ? `这个目录中找到 ${here.skillCount} 个技能` : '这个目录中没有找到技能' }}
+    </div>
+  </div>
+</template>

+ 5 - 2
apps/admin/src/lib/api.ts

@@ -1,7 +1,7 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 import { AgentClient, BotClient } from '@agent-server/client'
 import type {
-  ApiKeyDto, ApiKeyProfiles, BotDto, BotInput, BotKeyDto, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, SkillScanDto, SkillSourceDto, SkillSourceInput, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolHints, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
+  ApiKeyDto, ApiKeyProfiles, BotDto, BotInput, BotKeyDto, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, ServerDirListDto, SkillScanDto, SkillSourceDto, SkillSourceInput, SkillUploadDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolHints, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
   ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto, TeamCaseDto, TeamCaseRunDto, TeamDto, ScheduleOverviewDto, ScheduleRunDto, ScheduleDto, BackgroundCallDto, BackgroundCallOverviewDto,
 } from '@agent-server/shared'
 
@@ -111,8 +111,11 @@ export const admin = {
     upload: (file: File) => {
       const form = new FormData()
       form.append('file', file, file.name)
-      return req<{ source: SkillSourceDto; scan: SkillScanDto }>('POST', '/skill-sources/upload', form)
+      return req<SkillUploadDto>('POST', '/skill-sources/upload', form)
     },
+    importUpload: (id: string, skills: string[], digest: string) => req<{ imported: { name: string; version: number; created: boolean }[] }>('POST', `/skill-sources/uploads/${id}/import`, { skills, digest }),
+    discardUpload: (id: string) => req<void>('DELETE', `/skill-sources/uploads/${id}`),
+    dirs: (path: string) => req<ServerDirListDto>('GET', `/server-dirs?path=${encodeURIComponent(path)}`),
   },
   mcp: {
     state: () => req<McpStateDto>('GET', '/mcp'),

+ 3 - 2
apps/admin/src/views/KnowledgeView.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { onMounted, ref, watch } from 'vue'
 import type { KnowledgeRootDto, PathCheck } from '@agent-server/shared'
+import DirPathInput from '../components/DirPathInput.vue'
 import PageHeader from '../components/PageHeader.vue'
 import { admin, errorMessage, type KnowledgeInput } from '../lib/api'
 import { toast } from '../lib/ui'
@@ -88,8 +89,8 @@ onMounted(load)
         <div class="font-600">{{ editing.id ? '编辑资料库' : '添加资料库' }}</div>
         <div><label class="label" for="k-name">名称</label><input id="k-name" v-model="editing.name" class="field" placeholder="例如:家庭账单"></div>
         <div>
-          <label class="label" for="k-path">目录路径(服务器上的绝对路径,支持 ~)</label>
-          <input id="k-path" v-model="editing.path" class="field font-mono">
+          <label class="label" for="k-path">目录路径(服务器上的绝对路径,支持 ~;可以从下拉框中逐级选择)</label>
+          <DirPathInput id="k-path" v-model="editing.path" placeholder="/srv/agent-server/knowledge/…" />
           <div v-if="check" class="mt-1 text-[12px] flex items-center gap-1" :class="valid(check) ? (check.message ? 'text-warning' : 'text-success') : 'text-error'">
             <span class="codicon" :class="valid(check) ? 'codicon-pass' : 'codicon-error'" />
             {{ check.message ?? '路径可用' }}

+ 17 - 6
apps/server/src/custom-skills/service.ts

@@ -15,7 +15,7 @@ import type { DB } from '../db'
 import { customSkills, customSkillVersions, profiles, skillSources, type FileMap } from '../db/schema'
 import { AppError, badRequest, conflict, notFound } from '../lib/errors'
 import { newId, nowIso } from '../lib/ids'
-import { frontmatter } from '../lib/skill-files'
+import { declaredVersion, frontmatter } from '../lib/skill-files'
 import type { PluginRef } from '../providers/claude/skills'
 import { BlobStore, changesBetween, checkSkillLimits, isTextPath, LIMITS, mapDigest, NAME_RE, normalizeRelPath } from './store'
 
@@ -273,7 +273,8 @@ export class CustomSkillService {
   // ---- 导入(由技能源调用) ----
 
   /** 导入一份文件清单:新技能直接创建;已从同一来源导入过的生成新版本。导入后立即发布 */
-  importFiles(input: { name: string; files: FileMap; sourceId: string; description: string }): { id: string; version: number; created: boolean } {
+  /** sourceId 为 null 表示上传的 zip:作为可编辑的技能导入,同名技能(不属于任何技能源)覆盖为新版本 */
+  importFiles(input: { name: string; files: FileMap; sourceId: string | null; description: string }): { id: string; version: number; created: boolean } {
     checkSkillLimits(input.files)
     const existing = this.db.select().from(customSkills).where(eq(customSkills.name, input.name)).get()
     if (existing && existing.sourceId !== input.sourceId) throw badRequest(`已存在同名技能 ${input.name},且来源不同`)
@@ -282,22 +283,32 @@ export class CustomSkillService {
       const id = newId('csk')
       this.db.transaction(tx => {
         tx.insert(customSkills).values({
-          id, name: input.name, description: input.description, origin: 'imported', sourceId: input.sourceId, latestVersion: 1, workingFiles: input.files,
+          id, name: input.name, description: input.description, origin: input.sourceId ? 'imported' : 'authored', sourceId: input.sourceId, latestVersion: 1, workingFiles: input.files,
         }).run()
         tx.insert(customSkillVersions).values({ skillId: id, version: 1, reason: 'import', files: input.files, changed: changesBetween({}, input.files) }).run()
       })
       this.publish(id, 1)
       return { id, version: 1, created: true }
     }
-    const v = this.addVersion(existing, input.files, 'sync', input.description)
+    const v = this.addVersion(existing, input.files, input.sourceId ? 'sync' : 'import', input.description)
     this.setWorking(existing.id, input.files)
     this.publish(existing.id, v)
     return { id: existing.id, version: v, created: false }
   }
 
-  latestFiles(name: string): { files: FileMap; sourceId: string | null } | null {
+  latestFiles(name: string): { files: FileMap; sourceId: string | null; sourceName: string | null; version: number; updatedAt: string; unsaved: boolean } | null {
     const r = this.db.select().from(customSkills).where(eq(customSkills.name, name)).get()
-    return r ? { files: this.version(r.id, r.latestVersion).files, sourceId: r.sourceId } : null
+    if (!r) return null
+    const files = this.version(r.id, r.latestVersion).files
+    const sourceName = r.sourceId ? this.db.select({ name: skillSources.name }).from(skillSources).where(eq(skillSources.id, r.sourceId)).get()?.name ?? null : null
+    return { files, sourceId: r.sourceId, sourceName, version: r.latestVersion, updatedAt: r.updatedAt, unsaved: mapDigest(files) !== mapDigest(r.workingFiles) }
+  }
+
+  /** 技能当前版本 SKILL.md 中声明的版本号(frontmatter 的 version 或 metadata.version) */
+  declaredVersion(name: string): string | null {
+    const r = this.db.select().from(customSkills).where(eq(customSkills.name, name)).get()
+    const md = r && this.version(r.id, r.latestVersion).files['SKILL.md']
+    return md ? declaredVersion(this.store.get(md.sha).toString('utf8')) : null
   }
 
   // ---- 版本对比与导出 ----

+ 169 - 49
apps/server/src/custom-skills/sources.ts

@@ -1,32 +1,53 @@
-// 技能源(docs/design/12-custom-skills.md §3.2 / FR-1):服务器目录或上传的 zip。
+// 技能源(docs/design/12-custom-skills.md §3.2 / FR-1):服务器目录;上传的 zip 是一次性导入(不登记为技能源,同名技能可以选择覆盖)。
 // 扫描 → 管理员审阅差异 → 按扫描摘要导入(摘要不一致时拒绝,避免导入未审阅的内容);自动同步定时扫描并导入有变化的技能。
 // 只复制普通文件,跳过符号链接;zip 按实际解压字节数限制大小,拒绝越出解压目录的路径
 import fs from 'node:fs'
 import path from 'node:path'
 import { eq } from 'drizzle-orm'
 import { Unzip, UnzipInflate } from 'fflate'
-import type { AppSettings, SkillScanDto, SkillScanItemDto, SkillSourceDto, SkillSourceInput } from '@agent-server/shared'
+import type { AppSettings, SkillScanDto, SkillScanItemDto, SkillSourceDto, ServerDirListDto, SkillSourceInput, SkillUploadDto } from '@agent-server/shared'
 import { config, skillUploadsDir } from '../config'
 import type { DB } from '../db'
 import { customSkills, skillSources, type FileMap } from '../db/schema'
 import { AppError, badRequest, notFound } from '../lib/errors'
 import { newId, nowIso } from '../lib/ids'
-import { frontmatter } from '../lib/skill-files'
-import { isWithin, realpathLoose } from '../lib/paths'
-import { globalDenyList } from '../services/access-policy'
+import { declaredVersion, frontmatter } from '../lib/skill-files'
+import { expandPath, isWithin, realpathLoose } from '../lib/paths'
+import { globalDenyList, isCredentialPath } from '../services/access-policy'
 import { checkSkillMd, type CustomSkillService } from './service'
 import { changesBetween, checkSkillLimits, LIMITS, listRegularFiles, mapDigest, NAME_RE, normalizeRelPath, safeEntries, sha256, type ScannedFile } from './store'
 
 type SourceRow = typeof skillSources.$inferSelect
 
+/** 扫描对象:登记的服务器目录,或上传后等待确认的 zip(sourceId 为 null) */
+interface Target {
+  id: string
+  name: string
+  root: string
+  sourceId: string | null
+  skipped: string[]
+}
+
+interface Upload {
+  name: string
+  dir: string
+  skipped: string[]
+  createdAt: number
+}
+
 interface ScannedSkill {
   item: SkillScanItemDto
   files: ScannedFile[]
   map: FileMap
 }
 
+/** 上传的 zip 等待确认导入的时间 */
+const UPLOAD_TTL_MS = 3600_000
+/** 选择目录时最多列出的子目录数 */
+const DIR_LIST_MAX = 500
+
 export class SkillSourceService {
-  private uploadSkipped = new Map<string, string[]>()
+  private uploads = new Map<string, Upload>()
   private timer: NodeJS.Timeout | null = null
   private syncing = new Set<string>()
 
@@ -37,9 +58,14 @@ export class SkillSourceService {
     private audit: (summary: string, detail?: unknown) => void,
   ) {}
 
-  /** 自动同步:每分钟检查一次到期的技能源 */
+  /** 自动同步:每分钟检查一次到期的技能源,并清理过期的上传 */
   start() {
-    this.timer = setInterval(() => void this.autoSyncDue().catch(() => {}), 60_000)
+    this.migrateUploadSources()
+    fs.rmSync(skillUploadsDir(), { recursive: true, force: true })
+    this.timer = setInterval(() => {
+      this.sweepUploads()
+      void this.autoSyncDue().catch(() => {})
+    }, 60_000)
     this.timer.unref()
   }
 
@@ -52,8 +78,8 @@ export class SkillSourceService {
     for (const s of this.db.select({ name: customSkills.name, sourceId: customSkills.sourceId }).from(customSkills).all()) {
       if (s.sourceId) bySource.set(s.sourceId, [...(bySource.get(s.sourceId) ?? []), s.name])
     }
-    return this.db.select().from(skillSources).orderBy(skillSources.createdAt).all().map(r => ({
-      id: r.id, name: r.name, description: r.description, kind: r.kind, path: r.kind === 'upload' ? '' : r.path,
+    return this.db.select().from(skillSources).orderBy(skillSources.createdAt).all().filter(r => r.kind === 'directory').map(r => ({
+      id: r.id, name: r.name, description: r.description, kind: 'directory' as const, path: r.path,
       syncMode: r.syncMode, intervalMin: r.intervalMin, lastSyncAt: r.lastSyncAt, status: r.status, lastError: r.lastError,
       skillNames: (bySource.get(r.id) ?? []).sort(), createdAt: r.createdAt,
     }))
@@ -68,8 +94,8 @@ export class SkillSourceService {
   }
 
   update(id: string, input: SkillSourceInput): SkillSourceDto {
-    const r = this.row(id)
-    const data = r.kind === 'upload' ? { ...this.validateMeta(input), path: r.path, syncMode: 'manual' as const } : this.validate(input)
+    this.row(id)
+    const data = this.validate(input)
     this.db.update(skillSources).set({ ...data, updatedAt: nowIso() }).where(eq(skillSources.id, id)).run()
     this.audit(`修改技能源 ${data.name}`)
     return this.get(id)
@@ -78,18 +104,15 @@ export class SkillSourceService {
   /** 删除技能源:从它导入的技能保留,转为可编辑(与来源脱钩) */
   delete(id: string) {
     const r = this.row(id)
-    const imported = this.db.select().from(customSkills).where(eq(customSkills.sourceId, id)).all()
-    for (const s of imported) this.db.update(customSkills).set({ origin: 'authored', sourceId: null, updatedAt: nowIso() }).where(eq(customSkills.id, s.id)).run()
+    const imported = this.detachAll(id)
     this.db.delete(skillSources).where(eq(skillSources.id, id)).run()
-    if (r.kind === 'upload') fs.rmSync(path.join(skillUploadsDir(), id), { recursive: true, force: true })
-    this.uploadSkipped.delete(id)
-    this.audit(`删除技能源 ${r.name}${imported.length ? `(${imported.map(s => s.name).join('、')} 转为可编辑)` : ''}`)
+    this.audit(`删除技能源 ${r.name}${imported.length ? `(${imported.join('、')} 转为可编辑)` : ''}`)
   }
 
   scan(id: string): SkillScanDto {
     const r = this.row(id)
     try {
-      return this.toDto(r, this.scanRow(r))
+      return this.toDto(this.target(r), this.scanTarget(this.target(r)))
     } catch (err) {
       this.db.update(skillSources).set({ status: 'error', lastError: (err as Error).message, updatedAt: nowIso() }).where(eq(skillSources.id, id)).run()
       throw err
@@ -98,24 +121,77 @@ export class SkillSourceService {
 
   /** 按扫描结果导入选中的技能;扫描摘要变化时拒绝 */
   import(id: string, names: string[], digest: string): { imported: { name: string; version: number; created: boolean }[]; scan: SkillScanDto } {
-    const r = this.row(id)
-    const scanned = this.scanRow(r)
+    const t = this.target(this.row(id))
+    const scanned = this.scanTarget(t)
     if (this.digest(scanned) !== digest) throw new AppError(409, 'scan_changed', '来源内容在扫描后发生了变化,请重新扫描并确认')
-    const imported = this.importScanned(r, scanned.skills.filter(s => names.includes(s.item.name)), 'manual')
-    return { imported, scan: this.toDto(this.row(id), this.scanRow(this.row(id))) }
+    const imported = this.importScanned(t, scanned.skills.filter(s => names.includes(s.item.name)), 'manual')
+    return { imported, scan: this.toDto(t, this.scanTarget(t)) }
   }
 
-  /** 上传 zip:解压到数据目录,登记为一次性的技能源,返回扫描结果 */
-  upload(filename: string, data: Buffer): { source: SkillSourceDto; scan: SkillScanDto } {
+  /** 上传 zip:解压到数据目录等待确认,返回扫描结果;不登记为技能源 */
+  upload(filename: string, data: Buffer): SkillUploadDto {
     if (data.length > LIMITS.zipBytes) throw badRequest(`zip 文件不能超过 ${LIMITS.zipBytes / 1048576} MB`)
-    const id = newId('src')
+    this.sweepUploads()
+    const id = newId('upl')
     const dir = path.join(skillUploadsDir(), id)
     const skipped = extractZip(data, dir)
-    this.uploadSkipped.set(id, skipped)
     const name = path.basename(filename || 'upload.zip').slice(0, 100)
-    this.db.insert(skillSources).values({ id, name, description: '', kind: 'upload', path: dir, syncMode: 'manual' }).run()
+    this.uploads.set(id, { name, dir, skipped, createdAt: Date.now() })
     this.audit(`上传技能包 ${name}(${(data.length / 1024).toFixed(1)} KB,sha256 ${sha256(data).slice(0, 16)}…)${skipped.length ? `,跳过 ${skipped.length} 个不安全的条目` : ''}`, { skipped })
-    return { source: this.get(id), scan: this.scan(id) }
+    const t = this.uploadTarget(id)
+    return { uploadId: id, name, scan: this.toDto(t, this.scanTarget(t)) }
+  }
+
+  /** 导入上传的 zip 中选中的技能(新技能,或覆盖同名技能为新版本),完成后删除上传的文件 */
+  importUpload(id: string, names: string[], digest: string): { imported: { name: string; version: number; created: boolean }[] } {
+    const t = this.uploadTarget(id)
+    const scanned = this.scanTarget(t)
+    if (this.digest(scanned) !== digest) throw new AppError(409, 'scan_changed', '技能在审阅后发生了变化,请重新上传并确认')
+    const imported = this.importScanned(t, scanned.skills.filter(s => names.includes(s.item.name)), 'manual')
+    this.discardUpload(id)
+    return { imported }
+  }
+
+  /** 取消上传:删除解压的文件 */
+  discardUpload(id: string) {
+    const u = this.uploads.get(id)
+    if (!u) return
+    this.uploads.delete(id)
+    fs.rmSync(u.dir, { recursive: true, force: true })
+  }
+
+  /** 选择目录(技能源、资料库):列出服务进程看到的子目录(只列目录名,不列文件;登录凭证所在目录不列出);支持 ~ */
+  listDirs(raw: string): ServerDirListDto {
+    const p = raw.trim() || '/'
+    if (!path.isAbsolute(p) && p !== '~' && !p.startsWith('~/')) throw badRequest('路径必须是绝对路径')
+    const real = realpathLoose(expandPath(p))
+    if (!fs.existsSync(real) || !fs.statSync(real).isDirectory()) throw badRequest(`目录不存在或服务看不到:${p}`)
+    if (isCredentialPath(real)) throw badRequest('不能浏览登录凭证所在的目录')
+    let names: fs.Dirent[]
+    try {
+      names = fs.readdirSync(real, { withFileTypes: true })
+    } catch {
+      throw badRequest(`服务没有读取权限:${p}`)
+    }
+    const dirs: ServerDirListDto['dirs'] = []
+    for (const e of names) {
+      const abs = path.join(real, e.name)
+      const isDir = e.isDirectory() || (e.isSymbolicLink() && (() => {
+        try {
+          return fs.statSync(abs).isDirectory()
+        } catch {
+          return false
+        }
+      })())
+      if (!isDir || isCredentialPath(abs)) continue
+      dirs.push({ name: e.name, skill: fs.existsSync(path.join(abs, 'SKILL.md')) })
+    }
+    dirs.sort((a, b) => Number(a.name.startsWith('.')) - Number(b.name.startsWith('.')) || a.name.localeCompare(b.name))
+    let skillCount = 0
+    try {
+      skillCount = detectSkillDirs(real).length
+    } catch {}
+    return { path: real, parent: real === '/' ? null : path.dirname(real), dirs: dirs.slice(0, DIR_LIST_MAX), truncated: dirs.length > DIR_LIST_MAX, skillCount }
   }
 
   // ---- 内部 ----
@@ -128,10 +204,38 @@ export class SkillSourceService {
 
   private row(id: string): SourceRow {
     const r = this.db.select().from(skillSources).where(eq(skillSources.id, id)).get()
-    if (!r) throw notFound('技能源')
+    if (!r || r.kind !== 'directory') throw notFound('技能源')
     return r
   }
 
+  private target(r: SourceRow): Target {
+    return { id: r.id, name: r.name, root: r.path, sourceId: r.id, skipped: [] }
+  }
+
+  private uploadTarget(id: string): Target {
+    const u = this.uploads.get(id)
+    if (!u) throw notFound('上传的技能包(已过期或已处理,请重新上传)')
+    return { id, name: u.name, root: u.dir, sourceId: null, skipped: u.skipped }
+  }
+
+  private detachAll(sourceId: string): string[] {
+    const imported = this.db.select().from(customSkills).where(eq(customSkills.sourceId, sourceId)).all()
+    for (const s of imported) this.db.update(customSkills).set({ origin: 'authored', sourceId: null, updatedAt: nowIso() }).where(eq(customSkills.id, s.id)).run()
+    return imported.map(s => s.name)
+  }
+
+  /** 早期版本把上传的 zip 登记为技能源:删除这些记录,从中导入的技能转为可编辑 */
+  private migrateUploadSources() {
+    for (const r of this.db.select().from(skillSources).where(eq(skillSources.kind, 'upload')).all()) {
+      this.detachAll(r.id)
+      this.db.delete(skillSources).where(eq(skillSources.id, r.id)).run()
+    }
+  }
+
+  private sweepUploads() {
+    for (const [id, u] of this.uploads) if (Date.now() - u.createdAt > UPLOAD_TTL_MS) this.discardUpload(id)
+  }
+
   private validateMeta(input: SkillSourceInput) {
     const name = input.name?.trim()
     if (!name || name.length > 60) throw badRequest('请填写名称(不超过 60 个字)')
@@ -165,24 +269,30 @@ export class SkillSourceService {
     return { ...meta, path: real, syncMode: input.syncMode === 'auto' ? 'auto' as const : 'manual' as const }
   }
 
-  private scanRow(r: SourceRow): { skills: ScannedSkill[]; missing: string[]; skipped: string[] } {
-    if (!fs.existsSync(r.path)) throw badRequest(`来源目录不存在或服务看不到:${r.path}`)
-    const skipped = [...(this.uploadSkipped.get(r.id) ?? [])]
-    const skills = detectSkillDirs(r.path).map(d => this.scanSkill(r, d, skipped))
+  private scanTarget(t: Target): { skills: ScannedSkill[]; missing: string[]; skipped: string[] } {
+    if (!fs.existsSync(t.root)) throw badRequest(`来源目录不存在或服务看不到:${t.root}`)
+    const skipped = [...t.skipped]
+    const skills = detectSkillDirs(t.root).map(d => this.scanSkill(t, d, skipped))
     const found = new Set(skills.map(s => s.item.name))
-    const missing = this.db.select({ name: customSkills.name }).from(customSkills).where(eq(customSkills.sourceId, r.id)).all()
-      .map(s => s.name).filter(n => !found.has(n)).sort()
+    const missing = t.sourceId
+      ? this.db.select({ name: customSkills.name }).from(customSkills).where(eq(customSkills.sourceId, t.sourceId)).all().map(s => s.name).filter(n => !found.has(n)).sort()
+      : []
     return { skills, missing, skipped }
   }
 
-  private scanSkill(r: SourceRow, d: { rel: string; abs: string }, skipped: string[]): ScannedSkill {
+  private scanSkill(t: Target, d: { rel: string; abs: string }, skipped: string[]): ScannedSkill {
     const local: string[] = []
     const files = listRegularFiles(d.abs, local)
     skipped.push(...local.map(x => `${d.rel === '.' ? '' : `${d.rel}/`}${x}`))
     const map: FileMap = {}
-    const base = { path: d.rel, fileCount: files.length, size: files.reduce((n, f) => n + f.size, 0), warnings: [] as string[], changes: { added: [], modified: [], removed: [] } }
+    const base = {
+      path: d.rel, fileCount: files.length, size: files.reduce((n, f) => n + f.size, 0), warnings: [] as string[], changes: { added: [], modified: [], removed: [] },
+      declaredVersion: null as string | null, existing: null as SkillScanItemDto['existing'],
+    }
     const md = files.find(f => f.rel === 'SKILL.md')
-    const fm = md ? frontmatter(fs.readFileSync(md.abs, 'utf8').slice(0, 20_000)) : {}
+    const mdText = md ? fs.readFileSync(md.abs, 'utf8').slice(0, 20_000) : ''
+    const fm = frontmatter(mdText)
+    base.declaredVersion = declaredVersion(mdText)
     const name = fm.name && NAME_RE.test(fm.name) ? fm.name : path.basename(d.rel === '.' ? d.abs : d.rel)
     const invalid = (reason: string): ScannedSkill => ({ item: { ...base, name, description: fm.description ?? '', status: 'invalid', reason }, files, map })
     if (!NAME_RE.test(name)) return invalid(`技能名 ${name} 不符合要求(小写字母、数字、连字符,1-64 位)`)
@@ -209,11 +319,18 @@ export class SkillSourceService {
       return invalid((err as Error).message)
     }
     const existing = this.skills.latestFiles(name)
-    if (existing && existing.sourceId !== r.id) {
-      return { item: { ...base, name, description, status: 'conflict', reason: `已存在同名技能 ${name},且不是从这个来源导入的` }, files, map }
+    if (existing) {
+      base.existing = { version: existing.version, declaredVersion: this.skills.declaredVersion(name), updatedAt: existing.updatedAt, unsaved: existing.unsaved, sourceName: existing.sourceName }
+    }
+    if (existing && existing.sourceId !== t.sourceId) {
+      const reason = t.sourceId
+        ? `已存在同名技能 ${name},且不是从这个来源导入的`
+        : `同名技能 ${name} 来自技能源「${existing.sourceName ?? '未知'}」,请在技能源中更新;也可以先在技能详情中"转为可编辑",再上传覆盖`
+      return { item: { ...base, name, description, status: 'conflict', reason }, files, map }
     }
     const changes = changesBetween(existing?.files ?? {}, map)
     const status = !existing ? 'new' : mapDigest(existing.files) === mapDigest(map) ? 'unchanged' : 'changed'
+    if (status === 'changed' && existing?.unsaved && !t.sourceId) base.warnings.push('这个技能有编辑中、尚未保存为版本的改动,覆盖后这些改动会丢失')
     return { item: { ...base, name, description, status, reason: null, changes }, files, map }
   }
 
@@ -221,11 +338,11 @@ export class SkillSourceService {
     return sha256(JSON.stringify(s.skills.map(x => [x.item.name, x.item.status, mapDigest(x.map)]))).slice(0, 32)
   }
 
-  private toDto(r: SourceRow, s: { skills: ScannedSkill[]; missing: string[]; skipped: string[] }): SkillScanDto {
-    return { sourceId: r.id, digest: this.digest(s), skills: s.skills.map(x => x.item), missing: s.missing, skipped: s.skipped }
+  private toDto(t: Target, s: { skills: ScannedSkill[]; missing: string[]; skipped: string[] }): SkillScanDto {
+    return { sourceId: t.id, digest: this.digest(s), skills: s.skills.map(x => x.item), missing: s.missing, skipped: s.skipped }
   }
 
-  private importScanned(r: SourceRow, selected: ScannedSkill[], mode: 'manual' | 'auto') {
+  private importScanned(t: Target, selected: ScannedSkill[], mode: 'manual' | 'auto') {
     const out: { name: string; version: number; created: boolean }[] = []
     for (const s of selected) {
       if (s.item.status !== 'new' && s.item.status !== 'changed') continue
@@ -236,13 +353,16 @@ export class SkillSourceService {
         if (stored.sha !== s.map[f.rel]!.sha) throw new AppError(409, 'scan_changed', `${s.item.name}/${f.rel} 在导入过程中发生了变化,请重新扫描`)
         map[f.rel] = stored
       }
-      const res = this.skills.importFiles({ name: s.item.name, files: map, sourceId: r.id, description: s.item.description })
+      const res = this.skills.importFiles({ name: s.item.name, files: map, sourceId: t.sourceId, description: s.item.description })
       out.push({ name: s.item.name, version: res.version, created: res.created })
     }
-    const now = nowIso()
-    this.db.update(skillSources).set({ lastSyncAt: now, status: 'ok', lastError: null, updatedAt: now }).where(eq(skillSources.id, r.id)).run()
+    if (t.sourceId) {
+      const now = nowIso()
+      this.db.update(skillSources).set({ lastSyncAt: now, status: 'ok', lastError: null, updatedAt: now }).where(eq(skillSources.id, t.sourceId)).run()
+    }
     if (out.length) {
-      this.audit(`${mode === 'auto' ? '自动同步' : '导入'}技能源 ${r.name}:${out.map(x => `${x.name}${x.created ? '(新)' : ''} v${x.version}`).join('、')}`,
+      const what = t.sourceId ? `${mode === 'auto' ? '自动同步' : '导入'}技能源 ${t.name}` : `导入技能包 ${t.name}`
+      this.audit(`${what}:${out.map(x => `${x.name}${x.created ? '(新)' : t.sourceId ? '' : '(覆盖)'} v${x.version}`).join('、')}`,
         selected.filter(s => out.some(o => o.name === s.item.name)).map(s => ({ name: s.item.name, changes: s.item.changes })))
     }
     return out
@@ -255,9 +375,9 @@ export class SkillSourceService {
       if (r.lastSyncAt && now - Date.parse(r.lastSyncAt) < r.intervalMin * 60_000) continue
       this.syncing.add(r.id)
       try {
-        const scanned = this.scanRow(r)
+        const t = this.target(r)
         // 自动同步只导入新增与变化的技能;来源中已删除的技能不自动删除;同名冲突跳过
-        this.importScanned(r, scanned.skills, 'auto')
+        this.importScanned(t, this.scanTarget(t).skills, 'auto')
       } catch (err) {
         this.db.update(skillSources).set({ lastSyncAt: nowIso(), status: 'error', lastError: (err as Error).message, updatedAt: nowIso() }).where(eq(skillSources.id, r.id)).run()
       } finally {

+ 11 - 1
apps/server/src/http/routes/admin.ts

@@ -319,7 +319,7 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
     })
     g.post<SkillParams>('/custom-skills/:id/detach', async req => app.customSkills.detach(req.params.id))
 
-    // 技能源:服务器目录或上传的 zip
+    // 技能源:服务器目录;上传的 zip 一次性导入
     const sourceSchema = z.object({
       name: z.string(), description: z.string().default(''), path: z.string().default(''),
       syncMode: z.enum(['manual', 'auto']).default('manual'), intervalMin: z.number().default(10),
@@ -347,6 +347,16 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       reply.code(201)
       return app.skillSources.upload(part.filename, data)
     })
+    g.post<SkillParams>('/skill-sources/uploads/:id/import', async req => {
+      const b = parse(z.object({ skills: z.array(z.string()).min(1), digest: z.string() }), req.body)
+      return app.skillSources.importUpload(req.params.id, b.skills, b.digest)
+    })
+    g.delete<SkillParams>('/skill-sources/uploads/:id', async (req, reply) => {
+      app.skillSources.discardUpload(req.params.id)
+      reply.code(204)
+    })
+    // 选择服务器目录(登记技能源、资料库时):只列子目录名
+    g.get<{ Querystring: { path?: string } }>('/server-dirs', async req => app.skillSources.listDirs(req.query.path ?? '/'))
 
     // 第三方技能(skills.sh 目录 → GitHub,两个引擎共用)
     g.get('/community-skills', async () => app.communitySkills.list())

+ 1 - 1
apps/server/src/lib/ids.ts

@@ -1,6 +1,6 @@
 import { ulid } from 'ulid'
 
-export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg' | 'job' | 'task' | 'step' | 'case' | 'sch' | 'bg'
+export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg' | 'job' | 'task' | 'step' | 'case' | 'sch' | 'bg' | 'upl'
 
 export const newId = (prefix: IdPrefix) => `${prefix}_${ulid().toLowerCase()}`
 

+ 7 - 0
apps/server/src/lib/skill-files.ts

@@ -18,3 +18,10 @@ export function frontmatter(md: string): Record<string, string> {
   }
   return out
 }
+
+/** SKILL.md 中声明的版本号:frontmatter 顶层的 version,或 metadata 下的 version */
+export function declaredVersion(md: string): string | null {
+  const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md)?.[1] ?? ''
+  const v = /^version:\s*(.+)$/m.exec(fm) ?? /^metadata:[^\n]*\n(?:[ \t]+[^\n]*\n)*?[ \t]+version:\s*(.+)$/m.exec(fm)
+  return v ? v[1]!.trim().replace(/^["']|["']$/g, '').slice(0, 40) || null : null
+}

+ 67 - 6
apps/server/test/custom-skills.test.ts

@@ -4,9 +4,11 @@ import path from 'node:path'
 import pino from 'pino'
 import { strToU8, unzipSync, zipSync } from 'fflate'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { eq } from 'drizzle-orm'
 import { createApp, type App } from '../src/app'
-import { config, customSkillsDir, customSkillStoreDir } from '../src/config'
+import { config, customSkillsDir, customSkillStoreDir, skillUploadsDir } from '../src/config'
 import { LIMITS, normalizeRelPath } from '../src/custom-skills/store'
+import { customSkills, skillSources } from '../src/db/schema'
 import { resolveSkills } from '../src/providers/claude/skills'
 import { skillsToDisable } from '../src/providers/codex/skills'
 import { checkAccess, resolveAccess } from '../src/services/access-policy'
@@ -108,20 +110,79 @@ describe('自定义技能:目录技能源导入与同步', () => {
 })
 
 describe('自定义技能:zip 上传', () => {
-  it('上传 zip(带顶层目录)导入;不安全路径跳过并审计(C-03)', () => {
+  it('上传 zip(带顶层目录)导入;不安全路径跳过并审计(C-03);不登记为技能源,导入后删除上传的文件', () => {
     const zip = zipSync({
       'pack/delta/SKILL.md': strToU8(md('delta')),
       'pack/delta/templates/report.pptx': new Uint8Array([80, 75, 3, 4, 1, 2, 3]),
       '../evil.md': strToU8('x'),
       '__MACOSX/pack/._delta': strToU8('x'),
     })
-    const { source, scan } = app.skillSources.upload('pack.zip', Buffer.from(zip))
-    expect(source.kind).toBe('upload')
-    expect(scan.skills.map(s => [s.name, s.path, s.fileCount])).toEqual([['delta', 'pack/delta', 2]])
+    const { uploadId, name, scan } = app.skillSources.upload('pack.zip', Buffer.from(zip))
+    expect(name).toBe('pack.zip')
+    expect(scan.skills.map(s => [s.name, s.path, s.fileCount, s.status])).toEqual([['delta', 'pack/delta', 2, 'new']])
     expect(scan.skipped).toEqual(['../evil.md(路径不安全)'])
     expect(audits.some(a => a.includes('上传技能包 pack.zip') && a.includes('跳过 1 个'))).toBe(true)
-    app.skillSources.import(source.id, ['delta'], scan.digest)
+    expect(app.skillSources.list().map(s => s.kind)).not.toContain('upload')
+    expect(app.skillSources.importUpload(uploadId, ['delta'], scan.digest).imported).toEqual([{ name: 'delta', version: 1, created: true }])
     expect([...fs.readFileSync(live('delta', 'templates/report.pptx'))]).toEqual([80, 75, 3, 4, 1, 2, 3])
+    // 从 zip 导入的技能可以编辑,不属于任何技能源
+    expect(app.customSkills.list().find(s => s.name === 'delta')).toMatchObject({ origin: 'authored', sourceId: null })
+    expect(fs.readdirSync(skillUploadsDir())).not.toContain(uploadId)
+    expect(() => app.skillSources.importUpload(uploadId, ['delta'], scan.digest)).toThrow(/重新上传/)
+  })
+
+  it('再次上传同名技能:显示版本对比,可以覆盖为新版本或保留原版本;属于技能源的同名技能不能覆盖', () => {
+    const v2 = (body: string) => zipSync({ 'delta/SKILL.md': strToU8(`---\nname: delta\ndescription: delta 的说明:处理某类业务数据的流程与规范\nmetadata:\n  author: x\n  version: "2.0"\n---\n${body}\n`) })
+    const keep = app.skillSources.upload('delta-v2.zip', Buffer.from(v2('# 第二版')))
+    const item = keep.scan.skills[0]!
+    expect(item).toMatchObject({ status: 'changed', declaredVersion: '2.0', existing: { version: 1, declaredVersion: null, sourceName: null, unsaved: false } })
+    expect(item.changes.modified).toEqual(['SKILL.md'])
+    expect(item.changes.removed).toEqual(['templates/report.pptx'])
+    // 保留原版本:取消上传,技能不变
+    app.skillSources.discardUpload(keep.uploadId)
+    expect(app.customSkills.list().find(s => s.name === 'delta')!.latestVersion).toBe(1)
+
+    // 覆盖:作为新版本发布,旧版本仍在历史中
+    const over = app.skillSources.upload('delta-v2.zip', Buffer.from(v2('# 第二版')))
+    expect(app.skillSources.importUpload(over.uploadId, ['delta'], over.scan.digest).imported).toEqual([{ name: 'delta', version: 2, created: false }])
+    expect(fs.readFileSync(live('delta'), 'utf8')).toContain('# 第二版')
+    expect(app.customSkills.detail(app.customSkills.list().find(s => s.name === 'delta')!.id).versions.map(v => [v.version, v.reason])).toEqual([[2, 'import'], [1, 'import']])
+    expect(audits.some(a => a === '导入技能包 delta-v2.zip:delta(覆盖) v2')).toBe(true)
+
+    // 相同内容:无需导入
+    const same = app.skillSources.upload('delta-v2.zip', Buffer.from(v2('# 第二版')))
+    expect(same.scan.skills[0]).toMatchObject({ status: 'unchanged', existing: { version: 2, declaredVersion: '2.0' } })
+    app.skillSources.discardUpload(same.uploadId)
+
+    // beta 属于目录技能源「业务仓库」
+    const beta = app.skillSources.upload('beta.zip', Buffer.from(zipSync({ 'beta/SKILL.md': strToU8(md('beta')) })))
+    expect(beta.scan.skills[0]).toMatchObject({ status: 'conflict', existing: { sourceName: '业务仓库' } })
+    expect(beta.scan.skills[0]!.reason).toMatch(/技能源「业务仓库」/)
+    app.skillSources.discardUpload(beta.uploadId)
+  })
+
+  it('早期登记为技能源的 zip 上传:启动时删除记录,从中导入的技能转为可编辑', () => {
+    const now = new Date().toISOString()
+    app.db.insert(skillSources).values({ id: 'src_legacy', name: 'old.zip', description: '', kind: 'upload', path: path.join(skillUploadsDir(), 'src_legacy'), syncMode: 'manual' }).run()
+    app.db.update(customSkills).set({ origin: 'imported', sourceId: 'src_legacy', updatedAt: now }).where(eq(customSkills.name, 'delta')).run()
+    app.skillSources.start()
+    app.skillSources.stop()
+    expect(app.db.select().from(skillSources).where(eq(skillSources.id, 'src_legacy')).get()).toBeUndefined()
+    expect(app.customSkills.list().find(s => s.name === 'delta')).toMatchObject({ origin: 'authored', sourceId: null })
+  })
+
+  it('选择目录:只列出子目录,标出含 SKILL.md 的目录与找到的技能数', () => {
+    const root = makeSourceDir()
+    fs.writeFileSync(path.join(root, 'readme.md'), 'x')
+    const top = app.skillSources.listDirs(root)
+    expect(top.dirs).toEqual([{ name: 'skills', skill: false }])
+    expect(top.skillCount).toBe(2)
+    expect(top.parent).toBe(path.dirname(fs.realpathSync(root)))
+    expect(app.skillSources.listDirs(path.join(root, 'skills')).dirs).toEqual([{ name: 'alpha', skill: true }, { name: 'beta', skill: true }])
+    expect(app.skillSources.listDirs('/').parent).toBeNull()
+    expect(app.skillSources.listDirs('~').path).toBe(fs.realpathSync(os.homedir()))
+    expect(() => app.skillSources.listDirs('relative/path')).toThrow(/绝对路径/)
+    expect(() => app.skillSources.listDirs(path.join(root, 'none'))).toThrow(/不存在/)
   })
 
   it('按实际解压字节数限制大小(防压缩炸弹)', () => {

+ 13 - 5
docs/design/12-custom-skills.md

@@ -50,8 +50,8 @@
 
 - 技能 ID:`custom-skills:<name>`,两个引擎一致。
 - 每个技能有一个**来源类型**:
-  - `authored`:后台编写,可在线编辑;
-  - `imported`:从目录或 zip 导入,记录来源;默认只读(避免与来源不一致),可以“转为可编辑”(之后与来源脱钩,不再同步)。
+  - `authored`:后台编写或从上传的 zip 导入,可在线编辑;
+  - `imported`:从目录技能源导入,记录来源;默认只读(避免与来源不一致),可以“转为可编辑”(之后与来源脱钩,不再同步)。
 - 运行时只读 `custom-skills/skills/` 下的生效版本;编辑、导入都先写入版本库,再“发布”为生效版本。这样无论来源如何,加载路径和 ID 都一致。
 
 不采用“直接引用外部目录、实时加载”:外部目录没有插件清单时 Claude 会用目录名作前缀,ID 不稳定;目录内容随时变化,无法审阅和回滚;还要处理符号链接与越权路径。导入快照与第三方技能的做法一致(只复制普通文件、固定版本)。
@@ -61,12 +61,14 @@
 | 字段 | 说明 |
 | --- | --- |
 | `id`、`name`、`description` | 后台显示 |
-| `kind` | `directory`(服务器路径)/ `upload`(zip 上传,一次性) |
+| `kind` | `directory`(服务器路径)。上传的 zip 是一次性导入,不登记为技能源(见 FR-1) |
 | `path` | `directory` 时的绝对路径;可以是插件格式目录(`skills/*/SKILL.md`)、技能集合目录(`*/SKILL.md`)或单个技能目录(含 `SKILL.md`) |
 | `syncMode` | `manual`(默认)/ `auto`(定时检查,有变化时自动导入并发布) |
 | `intervalMin` | 自动同步间隔,默认 10 分钟 |
 | `lastSyncAt`、`lastSyncDigest`、`status`、`lastError` | 同步状态 |
 
+登记时目录可以手动输入,也可以从下拉框中逐级选择:`GET /server-dirs?path=` 列出服务进程看到的子目录(只列目录名;登录凭证所在目录不列出),标出含 `SKILL.md` 的目录,并显示当前目录中按上述规则能找到的技能数。
+
 路径校验与资料库一致(`KnowledgeService` 的检测逻辑可复用):存在、服务进程能看到(受 `ProtectHome` 影响,home 下的路径通常不可见,需要放在 home 以外或绑定挂载)、不命中全局禁止项、不在服务数据目录与会话工作区内。
 
 ## 4. 功能需求
@@ -76,7 +78,10 @@
 - 登记目录后点“扫描”:列出发现的技能(名称、描述、文件数、大小、与当前版本的差异:新增 / 修改 / 删除的文件)。
 - 勾选要导入的技能,确认后导入为新版本并发布。
 - “同步”:重新扫描,只显示有变化的技能和文件差异,确认后导入。`auto` 模式下跳过确认,直接导入并发布,审计记录变化摘要。
-- 上传 zip:解压到临时目录后走同样的扫描 → 确认流程。
+- 上传 zip:一次性导入,不登记为技能源(没有"同步")。解压到临时目录后扫描,确认后导入并删除上传的文件;取消或 1 小时内未确认也删除。
+  - 新技能:导入为可编辑的技能(`authored`),版本原因为 `import`。
+  - 已有同名技能(编写的或之前从 zip 导入的):显示版本对比(平台中的版本号与更新时间、两边 SKILL.md 声明的版本号 `version` / `metadata.version`、文件差异),由管理员选择"覆盖"(作为新版本发布,旧版本可以在版本历史中恢复)或"保留原版本"。同一个技能只保留一份,不会同时存在两个版本。该技能有尚未保存的编辑时提示覆盖后会丢失。
+  - 同名技能属于某个目录技能源:不能覆盖(应在技能源中更新;也可以先"转为可编辑"再上传覆盖)。
 - 来源目录中删除了某个技能:同步时提示“来源中已不存在”,由管理员选择保留或删除,自动同步不自动删除。
 - 导入规则沿用第三方技能:只复制普通文件,跳过符号链接;名称取 SKILL.md frontmatter 的 `name`(缺省用目录名),必须匹配 `^[a-z0-9][a-z0-9-]{0,63}$`;同名技能已存在且来源不同时拒绝导入。
 
@@ -149,7 +154,9 @@
 | GET / POST / PATCH / DELETE | `/skill-sources[/:id]` | 技能源管理 |
 | POST | `/skill-sources/:id/scan` | 扫描,返回技能与差异 |
 | POST | `/skill-sources/:id/import` | `{ skills: [name], scanDigest }` 按扫描结果导入(摘要不一致时拒绝,避免导入未审阅的内容) |
-| POST | `/skill-sources/upload` | 上传 zip,返回扫描结果 |
+| POST | `/skill-sources/upload` | 上传 zip,返回 `{ uploadId, name, scan }` |
+| POST / DELETE | `/skill-sources/uploads/:id/import`、`/skill-sources/uploads/:id` | 导入上传的 zip 中选中的技能(同名技能即覆盖)/ 取消上传 |
+| GET | `/server-dirs?path=` | 选择目录:列出子目录 |
 
 `GET /admin/v1/skills` 的结果中加入 `source: 'custom'` 的技能(`SkillInfo.source` 增加 `custom`)。
 
@@ -197,6 +204,7 @@
 | 草稿 `draftVersion` | 编辑中的改动保存在 `custom_skills.working_files`;"保存草稿"生成未发布的新版本,"保存并发布"同时发布 | 与 Profile 的"内容无变化不产生版本"一致,也便于"放弃改动"与"查看改动" |
 | 停用:运行时不加载 | 停用时整体移除生效目录,启用时按生效版本重新写入 | 两个引擎都只看生效目录,不需要各自过滤 |
 | 删除技能源 | 从它导入的技能保留,转为可编辑 | 草案未规定;避免误删 |
+| 上传的 zip 登记为技能源 | 改为一次性导入:不出现在技能源列表,同名技能选择覆盖或保留;从 zip 导入的技能可编辑 | zip 每次独立上传,没有可以再同步的来源;之前每次上传都留下一个技能源,再次上传同一技能会因"来源不同"无法导入。启动时把早期登记的上传记录删除,从中导入的技能转为可编辑 |
 
 实施中发现并修复的问题(同样影响之前的第三方技能,见 ADR-0022):
 

+ 34 - 2
packages/shared/src/api.ts

@@ -737,8 +737,8 @@ export interface SkillSourceInput {
 
 export interface SkillSourceDto extends SkillSourceInput {
   id: string
-  /** directory 服务器目录;upload 上传的 zip(一次性) */
-  kind: 'directory' | 'upload'
+  /** 服务器目录(上传的 zip 是一次性导入,不登记为技能源) */
+  kind: 'directory'
   lastSyncAt: string | null
   status: 'never' | 'ok' | 'error'
   lastError: string | null
@@ -759,9 +759,22 @@ export interface SkillScanItemDto {
   reason: string | null
   changes: FileChangesDto
   warnings: string[]
+  /** SKILL.md 中声明的版本号(frontmatter 的 version 或 metadata.version) */
+  declaredVersion: string | null
+  /** 已存在的同名技能 */
+  existing: {
+    version: number
+    declaredVersion: string | null
+    updatedAt: string
+    /** 有编辑中、尚未保存为版本的改动(覆盖后丢失) */
+    unsaved: boolean
+    /** 所属技能源;null 表示编写的或 zip 导入的 */
+    sourceName: string | null
+  } | null
 }
 
 export interface SkillScanDto {
+  /** 技能源 ID;上传的 zip 为上传 ID */
   sourceId: string
   /** 导入时回传,扫描结果变化时拒绝导入(避免导入未审阅的内容) */
   digest: string
@@ -772,6 +785,25 @@ export interface SkillScanDto {
   skipped: string[]
 }
 
+/** 上传的 zip:解压后等待确认导入(一次性,导入或取消后删除;1 小时内有效) */
+export interface SkillUploadDto {
+  uploadId: string
+  /** zip 文件名 */
+  name: string
+  scan: SkillScanDto
+}
+
+/** 选择服务器目录:服务进程看到的子目录 */
+export interface ServerDirListDto {
+  /** 解析后的绝对路径 */
+  path: string
+  parent: string | null
+  dirs: { name: string; /** 包含 SKILL.md */ skill: boolean }[]
+  truncated: boolean
+  /** 按技能源的规则在这个目录中找到的技能数 */
+  skillCount: number
+}
+
 export interface FileDiffDto {
   path: string
   status: 'added' | 'removed' | 'modified'