Skip to content

Commit f177dc3

Browse files
author
linyuan.yang
committed
支持 zip 安装 skill
1 parent 2a2b9d7 commit f177dc3

7 files changed

Lines changed: 186 additions & 5 deletions

File tree

packages/chat-ui/src/components/MessageList.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,16 +301,21 @@ function openThink(thinkId: string) {
301301
gap: 6px;
302302
margin-bottom: 4px;
303303
}
304+
.msg-bubble.human .msg-role-bar {
305+
justify-content: flex-end;
306+
}
304307
.msg-role {
305308
font-size: 10px;
306309
font-weight: 600;
307310
text-transform: uppercase;
308311
letter-spacing: 0.04em;
309312
color: var(--chatui-fg-secondary);
313+
white-space: nowrap;
310314
}
311315
.msg-time {
312316
font-size: 10px;
313317
color: var(--chatui-fg-secondary);
318+
white-space: nowrap;
314319
}
315320
316321
/* Markdown content */

packages/sbot/src/Server/HttpServer.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { AgentRunner } from '../Agent/AgentRunner';
1212
import { globalAgentToolService, refreshGlobalAgentToolService, refreshBuiltinTools, BuiltinProvider } from '../Agent/GlobalAgentToolService';
1313
import { globalSkillService, refreshGlobalSkillService, getSkillsDirsMap } from '../Agent/GlobalSkillService';
1414
import { SkillHubService } from '../SkillHub';
15+
import { installSkillFromZip } from '../SkillHub/bundle';
1516
import axios from 'axios';
1617
import { AgentStoreService } from '../AgentStore';
1718
import { LoggerService, log4js } from '../Core/LoggerService';
@@ -979,13 +980,30 @@ class HttpServer {
979980
return result;
980981
}));
981982

983+
app.post('/api/skill-hub/install-zip', express.raw({ type: 'application/zip', limit: '20mb' }), api(async req => {
984+
const overwrite = req.query.overwrite === 'true';
985+
const buf = req.body as Buffer;
986+
if (!buf?.length) { const e: any = new Error('Missing zip body'); e.status = 400; throw e; }
987+
const result = installSkillFromZip(buf, config.getSkillsPath(), overwrite);
988+
refreshGlobalSkillService();
989+
return result;
990+
}));
991+
982992
// ── Agent Skill Hub ──
983993
app.post('/api/agents/:agentName/skill-hub/install', api(async req => {
984994
const agentName = req.params.agentName as string;
985995
const { url, overwrite = false }: { url: string; overwrite: boolean } = req.body;
986996
if (!url?.trim()) { const e: any = new Error('Missing url'); e.status = 400; throw e; }
987997
return await this.skillHubService.installSkill(url.trim(), config.getAgentSkillsPath(agentName), { overwrite });
988998
}));
999+
1000+
app.post('/api/agents/:agentName/skill-hub/install-zip', express.raw({ type: 'application/zip', limit: '20mb' }), api(async req => {
1001+
const agentName = req.params.agentName as string;
1002+
const overwrite = req.query.overwrite === 'true';
1003+
const buf = req.body as Buffer;
1004+
if (!buf?.length) { const e: any = new Error('Missing zip body'); e.status = 400; throw e; }
1005+
return installSkillFromZip(buf, config.getAgentSkillsPath(agentName), overwrite);
1006+
}));
9891007
}
9901008

9911009
// ===== Agent Store =====

packages/sbot/src/SkillHub/bundle.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from 'fs';
22
import path from 'path';
3+
import AdmZip from 'adm-zip';
34
import type { HubSkillResult, SkillHubProvider } from './types';
45

56
export interface Bundle {
@@ -58,6 +59,71 @@ export function writeSkillToDisk(bundle: Bundle, targetDir: string, overwrite: b
5859
return skillDir;
5960
}
6061

62+
export interface ZipInstallResult {
63+
name: string;
64+
path: string;
65+
}
66+
67+
function parseSkillName(content: string): string {
68+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
69+
const nameMatch = match?.[1].match(/^name\s*:\s*(.+)$/m);
70+
return nameMatch ? nameMatch[1].trim().replace(/^['"]|['"]$/g, '') : '';
71+
}
72+
73+
export function installSkillFromZip(buf: Buffer, targetDir: string, overwrite: boolean): ZipInstallResult[] {
74+
const zip = new AdmZip(buf);
75+
const entries = zip.getEntries();
76+
77+
const skillMdEntries = entries.filter(e => !e.isDirectory && e.entryName.endsWith('SKILL.md'));
78+
if (!skillMdEntries.length) throw new Error('zip 中未找到 SKILL.md');
79+
80+
// Sort by depth so shallower skills are processed first
81+
skillMdEntries.sort((a, b) => a.entryName.split('/').length - b.entryName.split('/').length);
82+
83+
// Each SKILL.md defines a skill rooted at its parent directory
84+
const skillRoots = skillMdEntries.map(e => {
85+
const idx = e.entryName.lastIndexOf('SKILL.md');
86+
return e.entryName.slice(0, idx); // '' for root, or 'some/path/'
87+
});
88+
89+
const results: ZipInstallResult[] = [];
90+
91+
for (let i = 0; i < skillMdEntries.length; i++) {
92+
const prefix = skillRoots[i];
93+
const dirName = prefix ? prefix.replace(/\/$/, '').split('/').pop()! : '';
94+
const name = dirName || parseSkillName(skillMdEntries[i].getData().toString('utf-8'));
95+
if (!name) throw new Error(`无法确定 skill 名称: ${skillMdEntries[i].entryName}`);
96+
97+
// Collect files under this prefix but not under a deeper skill root
98+
const skillEntries = entries.filter(e => {
99+
if (e.isDirectory) return false;
100+
if (!e.entryName.startsWith(prefix)) return false;
101+
const rel = e.entryName.slice(prefix.length);
102+
if (!rel) return false;
103+
// Exclude files that belong to a nested skill
104+
return !skillRoots.some((other, j) => j !== i && other.startsWith(prefix) && other !== prefix && e.entryName.startsWith(other));
105+
});
106+
107+
const skillDir = path.join(targetDir, name);
108+
if (fs.existsSync(skillDir)) {
109+
if (!overwrite) throw new Error(`Skill '${name}' 已存在,启用覆盖以替换`);
110+
fs.rmSync(skillDir, { recursive: true, force: true });
111+
}
112+
fs.mkdirSync(skillDir, { recursive: true });
113+
114+
for (const entry of skillEntries) {
115+
const rel = entry.entryName.slice(prefix.length);
116+
const fullPath = path.join(skillDir, rel);
117+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
118+
fs.writeFileSync(fullPath, entry.getData());
119+
}
120+
121+
results.push({ name, path: skillDir });
122+
}
123+
124+
return results;
125+
}
126+
61127
export function mapToHubResults(items: any[], provider: SkillHubProvider): HubSkillResult[] {
62128
return items
63129
.filter(item => item && typeof item === 'object')

packages/sbot/src/index.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const program = new Command();
2929
program
3030
.name('sbot')
3131
.description(config.pkg.description)
32-
.version(config.pkg.version, '-v, --version')
32+
.option('-v, --version', '显示版本号并检查更新')
3333
.option('-p, --port <port>', 'HTTP server port')
3434
.option('-d, --daemon', '后台运行');
3535

@@ -144,7 +144,20 @@ program
144144

145145
// 默认行为:启动服务
146146
program
147-
.action(async (options: { port?: string; daemon?: boolean }) => {
147+
.action(async (options: { port?: string; daemon?: boolean; version?: boolean }) => {
148+
if (options.version) {
149+
const currentVer = config.pkg.version;
150+
console.log(`sbot v${currentVer}`);
151+
try {
152+
const release = await fetchLatestRelease();
153+
if (release && compareSemver(currentVer, release.tag) < 0) {
154+
console.log(`最新版: ${release.tag}, 可通过 npm install -g ${NPM_PACKAGE}@latest 升级`);
155+
} else if (release) {
156+
console.log('已是最新版本');
157+
}
158+
} catch {}
159+
return;
160+
}
148161
if (options.daemon) {
149162
const args = process.argv.slice(2).filter(a => a !== '-d' && a !== '--daemon');
150163
const child = spawn(process.execPath, [__filename, ...args], {

packages/website/src/components/SkillHubModal.vue

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
2-
import { ref } from 'vue'
2+
import { ref, computed } from 'vue'
33
import { useI18n } from 'vue-i18n'
4+
import axios from 'axios'
45
import { apiFetch } from '@/api'
56
import { useToast } from '@/composables/useToast'
67
import { BADGE_CLAWHUB, BADGE_SKILLSSH } from '@/utils/badges'
@@ -34,7 +35,7 @@ const { show } = useToast()
3435
3536
// ── Hub state ────────────────────────────────────────────────────
3637
const visible = ref(false)
37-
const hubTab = ref<'url' | 'search'>('search')
38+
const hubTab = ref<'url' | 'search' | 'zip'>('search')
3839
const hubQuery = ref('')
3940
const hubResults = ref<HubSkillResult[]>([])
4041
const hubSearching = ref(false)
@@ -45,6 +46,13 @@ const hubUrlInput = ref('')
4546
const hubUrlInstalling = ref(false)
4647
const hubUrlOverwrite = ref(false)
4748
49+
// Zip install
50+
const zipFiles = ref<File[]>([])
51+
const zipInstalling = ref(false)
52+
const zipOverwrite = ref(false)
53+
const zipResults = ref<{ name: string; ok: boolean; msg: string }[]>([])
54+
const zipInstallUrl = computed(() => props.installApiUrl.replace(/\/install$/, '/install-zip'))
55+
4856
// Install confirm
4957
const showInstall = ref(false)
5058
const installing = ref(false)
@@ -58,9 +66,44 @@ function open() {
5866
hubSearched.value = false
5967
hubUrlInput.value = ''
6068
hubUrlOverwrite.value = false
69+
zipFiles.value = []
70+
zipOverwrite.value = false
71+
zipResults.value = []
6172
visible.value = true
6273
}
6374
75+
function onZipFilesChange(e: Event) {
76+
const input = e.target as HTMLInputElement
77+
zipFiles.value = input.files ? Array.from(input.files) : []
78+
zipResults.value = []
79+
}
80+
81+
async function installZips() {
82+
if (!zipFiles.value.length) return
83+
zipInstalling.value = true
84+
zipResults.value = []
85+
const ow = zipOverwrite.value ? '?overwrite=true' : ''
86+
let anyOk = false
87+
for (const file of zipFiles.value) {
88+
try {
89+
const buf = await file.arrayBuffer()
90+
const res = await axios.post(zipInstallUrl.value + ow, buf, {
91+
headers: { 'Content-Type': 'application/zip' },
92+
})
93+
const items: { name: string }[] = Array.isArray(res.data?.data) ? res.data.data : [res.data?.data ?? { name: file.name }]
94+
for (const item of items) {
95+
zipResults.value.push({ name: item.name, ok: true, msg: '安装成功' })
96+
}
97+
anyOk = true
98+
} catch (e: any) {
99+
const msg = e.response?.data?.message || e.response?.data?.error || e.message
100+
zipResults.value.push({ name: file.name, ok: false, msg })
101+
}
102+
}
103+
zipInstalling.value = false
104+
if (anyOk) emit('installed')
105+
}
106+
64107
async function hubSearch() {
65108
if (!hubQuery.value.trim()) return
66109
hubSearching.value = true
@@ -138,7 +181,7 @@ defineExpose({ open })
138181
<!-- Tabs -->
139182
<div style="display:flex;border-bottom:1px solid #e2e8f0;flex-shrink:0;padding:0 20px">
140183
<button
141-
v-for="tab in ([{key:'search',label:t('skills.search_tab')},{key:'url',label:t('skills.url_install_tab')}] as const)"
184+
v-for="tab in ([{key:'search',label:t('skills.search_tab')},{key:'url',label:t('skills.url_install_tab')},{key:'zip',label:t('skills.zip_install_tab')}] as const)"
142185
:key="tab.key"
143186
@click="hubTab = tab.key"
144187
style="padding:10px 16px;border:none;background:none;cursor:pointer;font-size:13px;font-weight:500;border-bottom:2px solid transparent;margin-bottom:-1px"
@@ -168,6 +211,40 @@ defineExpose({ open })
168211
</div>
169212
</template>
170213

214+
<!-- Tab: ZIP install -->
215+
<template v-else-if="hubTab === 'zip'">
216+
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center">
217+
<input
218+
type="file"
219+
accept=".zip"
220+
multiple
221+
@change="onZipFilesChange"
222+
style="flex:1"
223+
/>
224+
<button class="btn-primary" :disabled="zipInstalling || !zipFiles.length" @click="installZips">
225+
{{ zipInstalling ? t('common.loading') : '安装' }}
226+
</button>
227+
</div>
228+
<label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer;margin-bottom:12px">
229+
<input type="checkbox" v-model="zipOverwrite" /> {{ t('skills.override') }}
230+
</label>
231+
<div v-if="zipFiles.length && !zipResults.length" style="font-size:13px;color:#64748b">
232+
已选择 {{ zipFiles.length }} 个文件
233+
</div>
234+
<div v-if="zipResults.length" style="margin-top:8px">
235+
<div
236+
v-for="(r, i) in zipResults" :key="i"
237+
style="padding:8px 12px;border-radius:6px;margin-bottom:6px;font-size:13px"
238+
:style="r.ok ? 'background:#f0fdf4;color:#166534' : 'background:#fef2f2;color:#991b1b'"
239+
>
240+
<strong>{{ r.name }}</strong>: {{ r.msg }}
241+
</div>
242+
</div>
243+
<div v-if="!zipFiles.length && !zipResults.length" style="margin-top:16px;padding:12px 14px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;font-size:12px;color:#64748b;line-height:1.7">
244+
选择包含 SKILL.md 的 .zip 文件,支持多选批量安装
245+
</div>
246+
</template>
247+
171248
<!-- Tab: Search -->
172249
<template v-else>
173250
<div style="display:flex;gap:8px;margin-bottom:16px;flex-shrink:0">

packages/website/src/i18n/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ export default {
361361
search_placeholder: 'Search skill name or description...',
362362
hub_title: 'Skill Hub',
363363
url_install_tab: 'URL Install',
364+
zip_install_tab: 'ZIP Install',
364365
search_tab: 'Search',
365366
url_placeholder: 'Enter URL to install (e.g. https://skills.sh/owner/repo/skill)',
366367
search_placeholder_hub: 'Search skills (e.g. code-review, web-scraper)',

packages/website/src/i18n/zh.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ export default {
361361
search_placeholder: '搜索技能名称或描述...',
362362
hub_title: '技能中心',
363363
url_install_tab: 'URL 安装',
364+
zip_install_tab: 'ZIP 安装',
364365
search_tab: '搜索',
365366
url_placeholder: '输入 URL 安装(如 https://skills.sh/owner/repo/skill)',
366367
search_placeholder_hub: '搜索技能(如 code-review、web-scraper)',

0 commit comments

Comments
 (0)