diff --git a/.gitignore b/.gitignore index 2e8157a..de62549 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ .env *.log +scripts/ \ No newline at end of file diff --git a/scripts/import-resources.js b/scripts/import-resources.js index c485bce..4235ccd 100644 --- a/scripts/import-resources.js +++ b/scripts/import-resources.js @@ -1,6 +1,8 @@ /** * 将 aiscri-xiong/resources/skills 和 resources/agents 导入 MongoDB - * 用法:node scripts/import-resources.js [--owner ] [--dry-run] + * 用法:node scripts/import-resources.js [--owner ] [--dry-run] [--clear-versions] + * + * --clear-versions 更新已有 skill/agent 时清空历史 versions,只保留本次导入的一条记录(适合修复超大文档或重置历史) */ require('dotenv').config({ path: require('path').join(__dirname, '../.env') }) @@ -11,11 +13,13 @@ const { MongoClient } = require('mongodb') const MONGO_URL = process.env.MONGO_URL || 'mongodb://localhost:27017' const DB_NAME = process.env.DB_NAME || 'skills_market' +const MAX_VERSION_HISTORY = Number.parseInt(process.env.MAX_VERSION_HISTORY || '10', 10) const args = process.argv.slice(2) const ownerIdx = args.indexOf('--owner') const OWNER = ownerIdx !== -1 ? args[ownerIdx + 1] : 'system' const DRY_RUN = args.includes('--dry-run') +const CLEAR_VERSIONS = args.includes('--clear-versions') // 路径指向 aiscri-xiong/resources const RESOURCES_DIR = path.join(__dirname, '../../aiscri-xiong/resources') @@ -54,7 +58,8 @@ function extractFrontmatter(content) { // ── 技能导入 ──────────────────────────────────────────────────────────────── -async function importSkills(skillsCollection) { +async function importSkills(skillsCollection, options) { + const { clearVersions } = options if (!fs.existsSync(SKILLS_DIR)) { console.log(`[skills] 目录不存在:${SKILLS_DIR}`) return @@ -84,26 +89,56 @@ async function importSkills(skillsCollection) { const existing = await skillsCollection.findOne({ name }) if (DRY_RUN) { - console.log(` [dry-run] ${existing ? '更新' : '新建'} skill: ${name} (${files.length} 个文件)`) + const note = existing && clearVersions ? ' [将清空旧 versions]' : '' + console.log(` [dry-run] ${existing ? '更新' : '新建'} skill: ${name} (${files.length} 个文件)${note}`) continue } if (existing) { - const versionEntry = { - version: (existing.versions?.length || 0) + 1, - description: `Imported from resources by ${OWNER}`, - files: existing.files, - created_at: now, - created_by: OWNER - } - await skillsCollection.updateOne( - { _id: existing._id }, - { - $set: { files, description: description || existing.description, updated_at: now, updated_by: OWNER }, - $push: { versions: versionEntry } + if (clearVersions) { + const versionEntry = { + version: 1, + description: `Imported from resources by ${OWNER} (history cleared)`, + file_count: files.length, + created_at: now, + created_by: OWNER } - ) - console.log(` [更新] skill: ${name} → v${versionEntry.version}`) + await skillsCollection.updateOne( + { _id: existing._id }, + { + $set: { + files, + description: description || existing.description, + updated_at: now, + updated_by: OWNER, + versions: [versionEntry] + } + } + ) + console.log(` [更新] skill: ${name} → 已清空历史,仅保留 v1`) + } else { + const versionEntry = { + version: (existing.versions?.length || 0) + 1, + description: `Imported from resources by ${OWNER}`, + // Keep version snapshots lightweight to avoid MongoDB 16MB document limit. + file_count: Array.isArray(existing.files) ? existing.files.length : 0, + created_at: now, + created_by: OWNER + } + await skillsCollection.updateOne( + { _id: existing._id }, + { + $set: { files, description: description || existing.description, updated_at: now, updated_by: OWNER }, + $push: { + versions: { + $each: [versionEntry], + $slice: -MAX_VERSION_HISTORY + } + } + } + ) + console.log(` [更新] skill: ${name} → v${versionEntry.version}`) + } } else { await skillsCollection.insertOne({ name, @@ -113,7 +148,13 @@ async function importSkills(skillsCollection) { downloads: 0, is_public: true, tags: [], - versions: [{ version: 1, description: 'Initial import', files, created_at: now, created_by: OWNER }], + versions: [{ + version: 1, + description: 'Initial import', + file_count: files.length, + created_at: now, + created_by: OWNER + }], created_at: now, updated_at: now, created_by: OWNER, @@ -126,7 +167,8 @@ async function importSkills(skillsCollection) { // ── Agent 导入 ──────────────────────────────────────────────────────────── -async function importAgents(agentsCollection) { +async function importAgents(agentsCollection, options) { + const { clearVersions } = options if (!fs.existsSync(AGENTS_DIR)) { console.log(`[agents] 目录不存在:${AGENTS_DIR}`) return @@ -148,25 +190,54 @@ async function importAgents(agentsCollection) { const existing = await agentsCollection.findOne({ name }) if (DRY_RUN) { - console.log(` [dry-run] ${existing ? '更新' : '新建'} agent: ${name}`) + const note = existing && clearVersions ? ' [将清空旧 versions]' : '' + console.log(` [dry-run] ${existing ? '更新' : '新建'} agent: ${name}${note}`) continue } if (existing) { - const versionEntry = { - version: (existing.versions?.length || 0) + 1, - content: existing.content, - created_at: now, - created_by: OWNER - } - await agentsCollection.updateOne( - { _id: existing._id }, - { - $set: { content, description: description || existing.description, updated_at: now, updated_by: OWNER }, - $push: { versions: versionEntry } + if (clearVersions) { + const versionEntry = { + version: 1, + content_length: content.length, + created_at: now, + created_by: OWNER, + note: 'history cleared on import' } - ) - console.log(` [更新] agent: ${name} → v${versionEntry.version}`) + await agentsCollection.updateOne( + { _id: existing._id }, + { + $set: { + content, + description: description || existing.description, + updated_at: now, + updated_by: OWNER, + versions: [versionEntry] + } + } + ) + console.log(` [更新] agent: ${name} → 已清空历史,仅保留 v1`) + } else { + const versionEntry = { + version: (existing.versions?.length || 0) + 1, + content_length: typeof existing.content === 'string' ? existing.content.length : 0, + created_at: now, + created_by: OWNER + } + await agentsCollection.updateOne( + { _id: existing._id }, + { + $set: { content, description: description || existing.description, updated_at: now, updated_by: OWNER }, + $push: { + versions: { + $each: [versionEntry], + $slice: -MAX_VERSION_HISTORY + } + } + } + ) + console.log(` [更新] agent: ${name} → v${versionEntry.version}`) + } } else { await agentsCollection.insertOne({ name, @@ -174,7 +245,12 @@ async function importAgents(agentsCollection) { owner: OWNER, content, is_public: true, - versions: [{ version: 1, content, created_at: now, created_by: OWNER }], + versions: [{ + version: 1, + content_length: content.length, + created_at: now, + created_by: OWNER + }], created_at: now, updated_at: now, created_by: OWNER, @@ -192,6 +268,7 @@ async function main() { console.log(`DB: ${DB_NAME}`) console.log(`OWNER: ${OWNER}`) console.log(`DRY_RUN: ${DRY_RUN}`) + console.log(`CLEAR_VERSIONS: ${CLEAR_VERSIONS}`) const client = new MongoClient(MONGO_URL) await client.connect() @@ -199,8 +276,9 @@ async function main() { const skillsCollection = db.collection('skills') const agentsCollection = db.collection('agents') - await importSkills(skillsCollection) - await importAgents(agentsCollection) + const importOpts = { clearVersions: CLEAR_VERSIONS } + await importSkills(skillsCollection, importOpts) + await importAgents(agentsCollection, importOpts) await client.close() console.log('\n✅ 导入完成') diff --git a/server.js b/server.js index b785647..0653f5a 100644 --- a/server.js +++ b/server.js @@ -95,6 +95,33 @@ function injectConfidentialityInstruction(files) { }) } +/** Normalize path keys for merge (forward slashes). */ +function normalizeSkillPath(p) { + return String(p || '').replace(/\\/g, '/').replace(/^\/+/, '') +} + +/** + * Merge incoming file entries over existing by path. Incoming wins on collision. + * Paths only present on the server are kept — so a client can send only SKILL.md + * without wiping sibling scripts/assets unless replaceAll is used on publish. + */ +function mergeSkillFilesByPath(existingFiles, incomingFiles) { + const map = new Map() + for (const f of existingFiles || []) { + if (f && f.path != null) { + const key = normalizeSkillPath(f.path) + map.set(key, { ...f, path: key }) + } + } + for (const f of incomingFiles || []) { + if (f && f.path != null) { + const key = normalizeSkillPath(f.path) + map.set(key, { ...f, path: key }) + } + } + return Array.from(map.values()) +} + const LOCK_TTL_MS = 5 * 60 * 1000 // 5 minutes const PUBLIC_VISIBILITY_FILTER = { $ne: false } @@ -388,6 +415,9 @@ app.get('/api/skills/:name/files/*', async (req, res) => { app.post('/api/skills/:name/lock', async (req, res) => { authRoutes.verifyToken(req, res, async () => { try { + if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewSkillsPage')) { + return res.status(403).json({ success: false, error: '无权编辑技能' }) + } const skill = await skillsCollection.findOne({ name: req.params.name }) if (!skill) { return res.status(404).json({ success: false, error: 'Skill not found' }) @@ -410,6 +440,9 @@ app.post('/api/skills/:name/lock', async (req, res) => { app.delete('/api/skills/:name/lock', async (req, res) => { authRoutes.verifyToken(req, res, async () => { try { + if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewSkillsPage')) { + return res.status(403).json({ success: false, error: '无权编辑技能' }) + } const skill = await skillsCollection.findOne({ name: req.params.name }) if (!skill) { return res.status(404).json({ success: false, error: 'Skill not found' }) @@ -473,10 +506,29 @@ app.get('/api/skills/mine', async (req, res, next) => { }) }) +/** + * POST /api/skills/:name/publish + * + * Body: { files, description?, tags?, localModifiedAt?, force?, replaceAll? } + * + * - `files`: array of { path, content }. + * - `replaceAll` (default false): when updating an existing skill, false = merge by path + * (request patches overwrite; paths not listed are kept). true = full replace of `files` + * (omit remote-only paths — useful for folder sync / restore exact snapshot). + * + * Versioning: on each successful update, a new entry is appended to `versions` whose + * `files` field is a snapshot of the **previous** top-level `files` (before this write). + * The document root `files` is always the **latest** snapshot. So the highest version + * number in `versions` is **not** the same as root `files` — it is one revision behind. + */ app.post('/api/skills/:name/publish', async (req, res) => { authRoutes.verifyToken(req, res, async () => { try { - const { files, description, tags, localModifiedAt } = req.body + if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewSkillsPage')) { + return res.status(403).json({ success: false, error: '无权发布或修改技能' }) + } + + const { files, description, tags, localModifiedAt, replaceAll } = req.body const userId = req.user.id const userNickname = req.user.nickname || req.user.email @@ -520,6 +572,11 @@ app.post('/api/skills/:name/publish', async (req, res) => { }) } + const useReplaceAll = replaceAll === true + const nextFiles = useReplaceAll + ? files + : mergeSkillFilesByPath(existingSkill.files || [], files) + const versionEntry = { version: (existingSkill.versions?.length || 0) + 1, description: `Updated by ${userNickname}`, @@ -531,7 +588,7 @@ app.post('/api/skills/:name/publish', async (req, res) => { const updateData = { $set: { - files, + files: nextFiles, description: skillDescription, updated_at: now, updated_by: userId, @@ -775,6 +832,9 @@ app.get('/api/agents/:name', async (req, res) => { app.post('/api/agents/:name/publish', async (req, res) => { authRoutes.verifyToken(req, res, async () => { try { + if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewAgentsPage')) { + return res.status(403).json({ success: false, error: '无权发布或修改智能体' }) + } const { content, description, localModifiedAt } = req.body const userId = req.user.id const userNickname = req.user.nickname || req.user.email @@ -850,6 +910,9 @@ app.delete('/api/agents/:name', requireAdmin(async (req, res) => { app.post('/api/agents/:name/lock', async (req, res, next) => { authRoutes.verifyToken(req, res, async () => { try { + if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewAgentsPage')) { + return res.status(403).json({ success: false, error: '无权编辑智能体' }) + } const agent = await agentsCollection.findOne({ name: req.params.name }) if (!agent) return res.status(404).json({ success: false, error: 'Agent not found' }) const activeLock = getActiveLock(agent) @@ -867,6 +930,9 @@ app.post('/api/agents/:name/lock', async (req, res, next) => { app.delete('/api/agents/:name/lock', async (req, res, next) => { authRoutes.verifyToken(req, res, async () => { try { + if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewAgentsPage')) { + return res.status(403).json({ success: false, error: '无权编辑智能体' }) + } const agent = await agentsCollection.findOne({ name: req.params.name }) if (!agent) return res.status(404).json({ success: false, error: 'Agent not found' }) const activeLock = getActiveLock(agent) diff --git a/updates/likecowork-1.1.2-setup.exe b/updates/likecowork-1.1.2-setup.exe index e69de29..4a9c826 100644 Binary files a/updates/likecowork-1.1.2-setup.exe and b/updates/likecowork-1.1.2-setup.exe differ diff --git a/updates/likecowork-1.1.2-setup.exe.blockmap b/updates/likecowork-1.1.2-setup.exe.blockmap index 1390309..9c8667d 100644 Binary files a/updates/likecowork-1.1.2-setup.exe.blockmap and b/updates/likecowork-1.1.2-setup.exe.blockmap differ