|
|
@@ -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 {
|