From c228a41f1bf5911442871e62544c1a579e36530e Mon Sep 17 00:00:00 2001 From: huaiju Date: Sat, 21 Feb 2026 22:42:51 +0800 Subject: [PATCH 01/43] feat: add backend list, detail, and related APIs for skills --- app/controller/skills.js | 26 +++ app/router.js | 7 + app/service/skills.js | 354 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 387 insertions(+) create mode 100644 app/controller/skills.js create mode 100644 app/service/skills.js diff --git a/app/controller/skills.js b/app/controller/skills.js new file mode 100644 index 0000000..bfcf7f3 --- /dev/null +++ b/app/controller/skills.js @@ -0,0 +1,26 @@ +const Controller = require('egg').Controller; + +class SkillsController extends Controller { + async getSkillList() { + const { app, ctx } = this; + const params = ctx.query; + const data = await ctx.service.skills.querySkillList(params); + ctx.body = app.utils.response(true, data); + } + + async getSkillDetail() { + const { app, ctx } = this; + const { slug } = ctx.query; + const data = await ctx.service.skills.getSkillDetail(slug); + ctx.body = app.utils.response(true, data); + } + + async getRelatedSkills() { + const { app, ctx } = this; + const { slug, limit = 6 } = ctx.query; + const data = await ctx.service.skills.getRelatedSkills(slug, limit); + ctx.body = app.utils.response(true, data); + } +} + +module.exports = SkillsController; diff --git a/app/router.js b/app/router.js index 5622be6..cf9b7df 100644 --- a/app/router.js +++ b/app/router.js @@ -146,6 +146,13 @@ module.exports = (app) => { app.post('/api/mcp-servers/health/:serverId', app.controller.mcp.checkMCPServerHealth); app.post('/api/mcp-servers/health/all', app.controller.mcp.checkAllMCPServersHealth); + /** + * Skills 市场 + */ + app.get('/api/skills/list', app.controller.skills.getSkillList); + app.get('/api/skills/detail', app.controller.skills.getSkillDetail); + app.get('/api/skills/related', app.controller.skills.getRelatedSkills); + // io.of('/').route('getShellCommand', io.controller.home.getShellCommand) // 暂时close Terminal相关功能 // io.of('/').route('loginServer', io.controller.home.loginServer) diff --git a/app/service/skills.js b/app/service/skills.js new file mode 100644 index 0000000..e6cddb5 --- /dev/null +++ b/app/service/skills.js @@ -0,0 +1,354 @@ +const Service = require('egg').Service; +const fs = require('fs'); +const path = require('path'); + +const CACHE_TTL_MS = 60 * 1000; +const MAX_FILE_LIST_COUNT = 300; + +class SkillsService extends Service { + constructor(ctx) { + super(ctx); + this.skillCache = null; + } + + getSkillRootDirs() { + const homeDir = process.env.HOME || ''; + const codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex'); + const agentHome = path.join(homeDir, '.agents'); + return [path.join(codexHome, 'skills'), path.join(agentHome, 'skills')]; + } + + isCacheValid() { + return ( + this.skillCache && + this.skillCache.loadedAt && + Date.now() - this.skillCache.loadedAt < CACHE_TTL_MS + ); + } + + async ensureSkillCache() { + if (this.isCacheValid()) return this.skillCache; + + const rootDirs = this.getSkillRootDirs(); + const skills = []; + + rootDirs.forEach((rootDir) => { + if (!fs.existsSync(rootDir)) return; + const skillFiles = this.findSkillFiles(rootDir); + skillFiles.forEach((skillFilePath) => { + try { + const skill = this.parseSkillMeta(skillFilePath, rootDir); + if (skill) { + skills.push(skill); + } + } catch (error) { + this.ctx.logger.warn( + `[skills] 解析技能失败: ${skillFilePath}, ${error.message}` + ); + } + }); + }); + + const categories = Array.from(new Set(skills.map((item) => item.category))).sort(); + this.skillCache = { + loadedAt: Date.now(), + skills, + categories, + }; + return this.skillCache; + } + + findSkillFiles(rootDir) { + const result = []; + const stack = [rootDir]; + + while (stack.length > 0) { + const currentDir = stack.pop(); + let entries = []; + try { + entries = fs.readdirSync(currentDir, { withFileTypes: true }); + } catch (error) { + continue; + } + + entries.forEach((entry) => { + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === '.git') return; + stack.push(fullPath); + return; + } + if (entry.isFile() && entry.name === 'SKILL.md') { + result.push(fullPath); + } + }); + } + + return result; + } + + parseSkillMeta(skillFilePath, rootDir) { + const content = fs.readFileSync(skillFilePath, 'utf8'); + const stat = fs.statSync(skillFilePath); + const skillDir = path.dirname(skillFilePath); + const relativeDir = path.relative(rootDir, skillDir); + const slug = relativeDir.split(path.sep).join('-').toLowerCase(); + const frontmatter = this.parseFrontmatter(content); + + const packageJsonPath = path.join(skillDir, 'package.json'); + let packageJson = {}; + if (fs.existsSync(packageJsonPath)) { + try { + packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + } catch (error) { + packageJson = {}; + } + } + + const metaJsonPath = path.join(skillDir, '_meta.json'); + let metaJson = {}; + if (fs.existsSync(metaJsonPath)) { + try { + metaJson = JSON.parse(fs.readFileSync(metaJsonPath, 'utf8')); + } catch (error) { + metaJson = {}; + } + } + + const name = frontmatter.name || path.basename(skillDir); + const description = frontmatter.description || this.extractDescription(content); + const tags = this.parseArrayLike(frontmatter.tags); + const allowedTools = this.parseArrayLike(frontmatter['allowed-tools']); + const stars = Number(metaJson.stars || metaJson.star || metaJson.github_stars || 0); + + const installCommand = this.getInstallCommand({ + sourceRepo: this.extractSourceRepo(packageJson), + name, + skillDir, + }); + + return { + slug, + name, + description, + category: this.getCategoryFromRelativePath(relativeDir), + tags, + allowedTools, + stars: Number.isNaN(stars) ? 0 : stars, + updatedAt: stat.mtime.toISOString(), + sourceRepo: this.extractSourceRepo(packageJson), + sourcePath: skillDir, + skillFilePath, + installCommand, + }; + } + + parseFrontmatter(content) { + const result = {}; + if (!content.startsWith('---')) return result; + const endIndex = content.indexOf('\n---', 3); + if (endIndex === -1) return result; + + const frontmatterText = content.slice(3, endIndex).trim(); + const lines = frontmatterText.split('\n'); + lines.forEach((line) => { + const match = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/); + if (!match) return; + const key = match[1]; + let value = match[2].trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + result[key] = value; + }); + return result; + } + + extractDescription(content) { + const stripped = content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#') && !line.startsWith('---')); + return stripped[0] || ''; + } + + parseArrayLike(value) { + if (!value) return []; + if (Array.isArray(value)) return value; + if (typeof value !== 'string') return []; + const trimmed = value.trim(); + + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + try { + const normalized = trimmed.replace(/'/g, '"'); + const parsed = JSON.parse(normalized); + return Array.isArray(parsed) ? parsed.map((item) => String(item)) : []; + } catch (error) { + return trimmed + .slice(1, -1) + .split(',') + .map((item) => item.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean); + } + } + + return trimmed + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + } + + getCategoryFromRelativePath(relativeDir) { + const parts = relativeDir.split(path.sep).filter(Boolean); + if (parts.length === 0) return '未分类'; + if (parts.length === 1) return '通用'; + return parts[0]; + } + + extractSourceRepo(packageJson = {}) { + if (!packageJson.repository) return ''; + if (typeof packageJson.repository === 'string') return packageJson.repository; + return packageJson.repository.url || ''; + } + + getInstallCommand({ sourceRepo, name, skillDir }) { + if (sourceRepo) { + return `npx skills add ${sourceRepo} --skill "${name}"`; + } + return `mkdir -p "$CODEX_HOME/skills" && cp -R "${skillDir}" "$CODEX_HOME/skills/${name}"`; + } + + getSkillList(params = {}) { + const { + keyword = '', + sortBy = 'stars', + category = '', + pageNum = 1, + pageSize = 20, + } = params; + const safePageNum = Math.max(parseInt(pageNum, 10) || 1, 1); + const safePageSize = Math.max(parseInt(pageSize, 10) || 20, 1); + + const { skills, categories } = this.skillCache; + let list = [...skills]; + + if (keyword) { + const value = String(keyword).toLowerCase(); + list = list.filter( + (item) => + item.name.toLowerCase().includes(value) || + item.description.toLowerCase().includes(value) || + item.sourceRepo.toLowerCase().includes(value) || + item.tags.some((tag) => tag.toLowerCase().includes(value)) + ); + } + + if (category) { + list = list.filter((item) => item.category === category); + } + + list.sort((a, b) => { + if (sortBy === 'recent') { + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + } + if (b.stars !== a.stars) return b.stars - a.stars; + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }); + + const total = list.length; + const offset = (safePageNum - 1) * safePageSize; + const pageList = list.slice(offset, offset + safePageSize); + + return { + list: pageList, + total, + pageNum: safePageNum, + pageSize: safePageSize, + categories, + }; + } + + async querySkillList(params = {}) { + await this.ensureSkillCache(); + return this.getSkillList(params); + } + + async getSkillDetail(slug) { + await this.ensureSkillCache(); + const skill = this.skillCache.skills.find((item) => item.slug === slug); + if (!skill) { + this.ctx.throw(404, '技能不存在'); + } + + const content = fs.readFileSync(skill.skillFilePath, 'utf8'); + const fileList = this.listSkillFiles(skill.sourcePath); + + return { + ...skill, + skillMd: content, + fileList, + }; + } + + listSkillFiles(skillDir) { + const files = []; + const stack = [skillDir]; + while (stack.length > 0) { + const currentDir = stack.pop(); + let entries = []; + try { + entries = fs.readdirSync(currentDir, { withFileTypes: true }); + } catch (error) { + continue; + } + + entries.forEach((entry) => { + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + return; + } + if (!entry.isFile()) return; + const relativePath = path.relative(skillDir, fullPath).split(path.sep).join('/'); + files.push(relativePath); + }); + + if (files.length >= MAX_FILE_LIST_COUNT) break; + } + + return files.sort(); + } + + async getRelatedSkills(slug, limit = 6) { + await this.ensureSkillCache(); + const target = this.skillCache.skills.find((item) => item.slug === slug); + if (!target) { + this.ctx.throw(404, '技能不存在'); + } + + const targetTags = new Set((target.tags || []).map((item) => item.toLowerCase())); + const related = this.skillCache.skills + .filter((item) => item.slug !== slug) + .map((item) => { + const itemTags = (item.tags || []).map((tag) => tag.toLowerCase()); + const overlap = itemTags.filter((tag) => targetTags.has(tag)).length; + const categoryScore = item.category === target.category ? 3 : 0; + const score = overlap * 10 + categoryScore + Math.min(item.stars, 10); + return { ...item, _score: score }; + }) + .filter((item) => item._score > 0) + .sort((a, b) => b._score - a._score || b.stars - a.stars) + .slice(0, parseInt(limit, 10) || 6) + .map((item) => { + const { _score, ...rest } = item; + return rest; + }); + + return related; + } +} + +module.exports = SkillsService; From d6669d0c85194a5ddc8c2433b004776ad1636816 Mon Sep 17 00:00:00 2001 From: huaiju Date: Sat, 21 Feb 2026 22:44:11 +0800 Subject: [PATCH 02/43] feat: wire skills nav entry and frontend routes --- app/web/api/url.ts | 19 +++++++++++++++++++ app/web/layouts/header/header.tsx | 7 +++++++ app/web/pages/skills/detail/index.tsx | 12 ++++++++++++ app/web/pages/skills/index.tsx | 12 ++++++++++++ app/web/router/index.ts | 10 ++++++++++ 5 files changed, 60 insertions(+) create mode 100644 app/web/pages/skills/detail/index.tsx create mode 100644 app/web/pages/skills/index.tsx diff --git a/app/web/api/url.ts b/app/web/api/url.ts index 1f6cb9b..6c6c58b 100644 --- a/app/web/api/url.ts +++ b/app/web/api/url.ts @@ -337,4 +337,23 @@ export default { method: 'post', url: '/api/mcp-servers/sync-info', }, + + /** + * Skills 市场 + */ + // 获取 Skills 列表 + getSkillList: { + method: 'get', + url: '/api/skills/list', + }, + // 获取 Skill 详情 + getSkillDetail: { + method: 'get', + url: '/api/skills/detail', + }, + // 获取相关 Skills + getRelatedSkills: { + method: 'get', + url: '/api/skills/related', + }, }; diff --git a/app/web/layouts/header/header.tsx b/app/web/layouts/header/header.tsx index 7e200f6..41849e1 100644 --- a/app/web/layouts/header/header.tsx +++ b/app/web/layouts/header/header.tsx @@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; import { AppstoreOutlined, + BookOutlined, CloudOutlined, CloudServerOutlined, DesktopOutlined, @@ -49,6 +50,12 @@ const navMenuList: any = [ 'mcp-server-inspector', ], }, + { + name: 'Skills', + path: '/page/skills', + icon: , + routers: ['skills'], + }, { name: '主机管理', path: '/page/host-management', diff --git a/app/web/pages/skills/detail/index.tsx b/app/web/pages/skills/detail/index.tsx new file mode 100644 index 0000000..dee4588 --- /dev/null +++ b/app/web/pages/skills/detail/index.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Empty } from 'antd'; + +const SkillDetail: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default SkillDetail; diff --git a/app/web/pages/skills/index.tsx b/app/web/pages/skills/index.tsx new file mode 100644 index 0000000..3b64825 --- /dev/null +++ b/app/web/pages/skills/index.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Empty } from 'antd'; + +const SkillsMarket: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default SkillsMarket; diff --git a/app/web/router/index.ts b/app/web/router/index.ts index 443b577..fdbe1d1 100644 --- a/app/web/router/index.ts +++ b/app/web/router/index.ts @@ -20,6 +20,8 @@ import McpServerInspector from '@/pages/mcpServer/inspector'; import McpServerManagement from '@/pages/mcpServer/management'; import McpServerMarket from '@/pages/mcpServer/mcpMarket'; import McpServerRegistryCenter from '@/pages/mcpServer/registryCenter'; +import SkillDetail from '@/pages/skills/detail'; +import SkillsMarket from '@/pages/skills'; // 代理服务 import ProxyServer from '@/pages/proxyServer'; // hosts列表 @@ -112,6 +114,14 @@ const routes: any = [ path: `${urlPrefix}/mcp-server-management`, component: McpServerManagement, }, + { + path: `${urlPrefix}/skills/:slug`, + component: SkillDetail, + }, + { + path: `${urlPrefix}/skills`, + component: SkillsMarket, + }, { path: '*', component: NotFound, From 2a33b6cccdc0b9a68b50a73861ab70d4d7af98e0 Mon Sep 17 00:00:00 2001 From: huaiju Date: Sat, 21 Feb 2026 22:45:33 +0800 Subject: [PATCH 03/43] feat: build skills list page with filters, sorting, and cards --- app/web/pages/skills/index.tsx | 223 +++++++++++++++++++++++++++++++- app/web/pages/skills/style.scss | 97 ++++++++++++++ app/web/pages/skills/types.ts | 21 +++ 3 files changed, 336 insertions(+), 5 deletions(-) create mode 100644 app/web/pages/skills/style.scss create mode 100644 app/web/pages/skills/types.ts diff --git a/app/web/pages/skills/index.tsx b/app/web/pages/skills/index.tsx index 3b64825..36163e9 100644 --- a/app/web/pages/skills/index.tsx +++ b/app/web/pages/skills/index.tsx @@ -1,10 +1,223 @@ -import React from 'react'; -import { Empty } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { + CopyOutlined, + EyeOutlined, + FilterOutlined, + SearchOutlined, + StarOutlined, +} from '@ant-design/icons'; +import { + Button, + Card, + Col, + Divider, + Empty, + Input, + Pagination, + Row, + Select, + Space, + Spin, + Tag, + Typography, + message, +} from 'antd'; + +import { API } from '@/api'; +import { copyToClipboard } from '@/utils/copyUtils'; +import { SkillItem, SkillListResponse } from './types'; +import './style.scss'; + +const { Search } = Input; +const { Option } = Select; +const { Paragraph, Text } = Typography; + +const SkillsMarket: React.FC = ({ history }) => { + const [loading, setLoading] = useState(false); + const [skills, setSkills] = useState([]); + const [categories, setCategories] = useState([]); + const [total, setTotal] = useState(0); + const [query, setQuery] = useState({ + keyword: '', + sortBy: 'stars', + category: '', + pageNum: 1, + pageSize: 12, + }); + + const fetchSkills = async (nextQuery = query) => { + setLoading(true); + try { + const response = await API.getSkillList(nextQuery); + if (response.success) { + const data: SkillListResponse = response.data; + setSkills(data.list || []); + setCategories(data.categories || []); + setTotal(data.total || 0); + } else { + message.error(response.msg || '获取 Skills 列表失败'); + } + } catch (error) { + message.error('获取 Skills 列表失败'); + console.error('获取 Skills 列表失败:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchSkills(query); + }, []); + + const updateQueryAndFetch = (patch: Partial) => { + const next = { ...query, ...patch }; + setQuery(next); + fetchSkills(next); + }; + + const handleOpenDetail = (slug: string) => { + history.push(`/page/skills/${slug}`); + }; -const SkillsMarket: React.FC = () => { return ( -
- +
+
+
+

Skills 市场

+

发现、筛选并导入本地可用的 Skills 能力

+
+
+ +
+ } + onChange={(e) => setQuery({ ...query, keyword: e.target.value })} + onSearch={(value) => updateQueryAndFetch({ keyword: value, pageNum: 1 })} + /> + + + + +
+ + + + + {skills.length === 0 ? ( + + ) : ( + <> + + {skills.map((skill) => ( + + handleOpenDetail(skill.slug)} + > +
+ {skill.name} + + {skill.stars || 0} + +
+ + {skill.description || '暂无描述'} + +
+ 来源: + + {skill.sourceRepo || skill.sourcePath} + +
+
+ 更新: + + {new Date(skill.updatedAt).toLocaleDateString( + 'zh-CN' + )} + +
+
+ {skill.category || '未分类'} + {skill.tags.slice(0, 3).map((tag) => ( + {tag} + ))} +
+
+ + +
+
+ + ))} +
+ +
+ + updateQueryAndFetch({ pageNum: page, pageSize: pageSize || 12 }) + } + onShowSizeChange={(_, size) => + updateQueryAndFetch({ pageNum: 1, pageSize: size }) + } + /> +
+ + )} +
); }; diff --git a/app/web/pages/skills/style.scss b/app/web/pages/skills/style.scss new file mode 100644 index 0000000..50ad184 --- /dev/null +++ b/app/web/pages/skills/style.scss @@ -0,0 +1,97 @@ +.page-skills { + padding: 20px 24px; + + .skills-header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .title-group { + .page-title { + margin: 0; + color: #1f2d3d; + font-size: 28px; + font-weight: 700; + } + + .page-subtitle { + margin: 8px 0 0; + color: #667085; + } + } + + .search-filter-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-top: 18px; + + .keyword-search { + width: 620px; + max-width: 100%; + } + } + + .skill-card { + height: 100%; + border-radius: 10px; + border: 1px solid #edf2f7; + + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + } + + .skill-name { + color: #1f2d3d; + font-size: 16px; + font-weight: 600; + } + + .meta-stars { + color: #e6a23c; + font-weight: 600; + } + + .skill-desc { + min-height: 66px; + margin-bottom: 12px; + color: #475467; + } + + .meta-row { + display: flex; + align-items: center; + margin-bottom: 8px; + gap: 8px; + + .meta-value { + flex: 1; + color: #344054; + } + } + + .tag-row { + min-height: 28px; + margin-bottom: 10px; + } + + .action-row { + display: flex; + align-items: center; + justify-content: flex-start; + margin-top: 6px; + gap: 8px; + } + } + + .pagination-wrap { + display: flex; + justify-content: flex-end; + margin-top: 20px; + } +} diff --git a/app/web/pages/skills/types.ts b/app/web/pages/skills/types.ts new file mode 100644 index 0000000..6c2d872 --- /dev/null +++ b/app/web/pages/skills/types.ts @@ -0,0 +1,21 @@ +export interface SkillItem { + slug: string; + name: string; + description: string; + category: string; + tags: string[]; + allowedTools: string[]; + stars: number; + updatedAt: string; + sourceRepo: string; + sourcePath: string; + installCommand: string; +} + +export interface SkillListResponse { + list: SkillItem[]; + total: number; + pageNum: number; + pageSize: number; + categories: string[]; +} From d00db5b56206309598720d43049e7e7881fcf9b2 Mon Sep 17 00:00:00 2001 From: huaiju Date: Sat, 21 Feb 2026 22:47:00 +0800 Subject: [PATCH 04/43] feat: build skills detail page with SKILL.md file list and related items --- app/web/pages/skills/detail/index.tsx | 224 ++++++++++++++++++++++++- app/web/pages/skills/detail/style.scss | 51 ++++++ app/web/pages/skills/types.ts | 5 + 3 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 app/web/pages/skills/detail/style.scss diff --git a/app/web/pages/skills/detail/index.tsx b/app/web/pages/skills/detail/index.tsx index dee4588..89317fe 100644 --- a/app/web/pages/skills/detail/index.tsx +++ b/app/web/pages/skills/detail/index.tsx @@ -1,10 +1,224 @@ -import React from 'react'; -import { Empty } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { + ArrowLeftOutlined, + CopyOutlined, + EyeOutlined, + FolderOpenOutlined, + ProfileOutlined, + ReadOutlined, + StarOutlined, +} from '@ant-design/icons'; +import { Button, Card, Col, Divider, Empty, List, Row, Space, Spin, Tabs, Tag, Typography } from 'antd'; + +import { API } from '@/api'; +import MarkdownRenderer from '@/components/markdownRenderer'; +import { copyToClipboard } from '@/utils/copyUtils'; +import { SkillDetail as SkillDetailType, SkillItem } from '../types'; +import './style.scss'; + +const { Title, Text, Paragraph } = Typography; +const { TabPane } = Tabs; + +const SkillDetail: React.FC = ({ history, match }) => { + const { slug } = match.params; + const [loading, setLoading] = useState(true); + const [detail, setDetail] = useState(null); + const [related, setRelated] = useState([]); + + const fetchDetail = async () => { + setLoading(true); + try { + const [detailRes, relatedRes] = await Promise.all([ + API.getSkillDetail({ slug }), + API.getRelatedSkills({ slug, limit: 6 }), + ]); + + if (detailRes.success) { + setDetail(detailRes.data); + } else { + setDetail(null); + } + + if (relatedRes.success) { + setRelated(relatedRes.data || []); + } + } catch (error) { + console.error('获取 Skill 详情失败:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchDetail(); + }, [slug]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!detail) { + return ( +
+ + + +
+ ); + } -const SkillDetail: React.FC = () => { return ( -
- +
+
+ +
+ {detail.name} + + {detail.description || '暂无描述'} + +
+ + + +
+ + + + + Stars: {detail.stars || 0} + + 分类: {detail.category || '未分类'} + 更新: {new Date(detail.updatedAt).toLocaleString('zh-CN')} + 来源: {detail.sourceRepo || detail.sourcePath} + +
+ {(detail.tags || []).map((tag) => ( + {tag} + ))} +
+
+ + + + + 概览 + + } + > + + + 技能名称: + {detail.name} + + + 技能描述: + {detail.description || '-'} + + + 可用工具: + {(detail.allowedTools && detail.allowedTools.length > 0 + ? detail.allowedTools.join(', ') + : '未声明') || '未声明'} + + + 安装命令: + + + {detail.installCommand} + + + + + + + SKILL.md + + } + > + + + + + + + + 文件列表 + + } + > + + ( + + {file} + + )} + /> + + + + + + 相关技能 + + } + > + {related.length === 0 ? ( + + ) : ( + + {related.map((item) => ( + + history.push(`/page/skills/${item.slug}`)} + > +
{item.name}
+ + {item.description || '暂无描述'} + + + {item.stars || 0} + +
+ + ))} +
+ )} +
+
+ +
); }; diff --git a/app/web/pages/skills/detail/style.scss b/app/web/pages/skills/detail/style.scss new file mode 100644 index 0000000..cae0127 --- /dev/null +++ b/app/web/pages/skills/detail/style.scss @@ -0,0 +1,51 @@ +.page-skill-detail { + padding: 20px 24px; + + &.loading-wrap { + display: flex; + justify-content: center; + align-items: center; + min-height: 420px; + } + + .detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + margin-bottom: 16px; + + .back-btn { + margin-top: 4px; + } + + .title-group { + flex: 1; + min-width: 0; + } + } + + .meta-card { + margin-bottom: 16px; + + .tags-wrap { + margin-top: 12px; + } + } + + .markdown-card { + max-height: 70vh; + overflow: auto; + } + + .related-card { + height: 100%; + + .related-title { + margin-bottom: 8px; + color: #1f2d3d; + font-size: 16px; + font-weight: 600; + } + } +} diff --git a/app/web/pages/skills/types.ts b/app/web/pages/skills/types.ts index 6c2d872..43bdedd 100644 --- a/app/web/pages/skills/types.ts +++ b/app/web/pages/skills/types.ts @@ -19,3 +19,8 @@ export interface SkillListResponse { pageSize: number; categories: string[]; } + +export interface SkillDetail extends SkillItem { + fileList: string[]; + skillMd: string; +} From caebe7c25c09d437873dbb2a291d235856038855 Mon Sep 17 00:00:00 2001 From: huaiju Date: Mon, 23 Feb 2026 09:37:07 +0800 Subject: [PATCH 05/43] feat: add skills file content API with path safety checks --- app/controller/skills.js | 7 +++ app/router.js | 1 + app/service/skills.js | 122 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/app/controller/skills.js b/app/controller/skills.js index bfcf7f3..c436bee 100644 --- a/app/controller/skills.js +++ b/app/controller/skills.js @@ -21,6 +21,13 @@ class SkillsController extends Controller { const data = await ctx.service.skills.getRelatedSkills(slug, limit); ctx.body = app.utils.response(true, data); } + + async getSkillFileContent() { + const { app, ctx } = this; + const { slug, path: filePath } = ctx.query; + const data = await ctx.service.skills.getSkillFileContent(slug, filePath); + ctx.body = app.utils.response(true, data); + } } module.exports = SkillsController; diff --git a/app/router.js b/app/router.js index cf9b7df..a23bf26 100644 --- a/app/router.js +++ b/app/router.js @@ -152,6 +152,7 @@ module.exports = (app) => { app.get('/api/skills/list', app.controller.skills.getSkillList); app.get('/api/skills/detail', app.controller.skills.getSkillDetail); app.get('/api/skills/related', app.controller.skills.getRelatedSkills); + app.get('/api/skills/file-content', app.controller.skills.getSkillFileContent); // io.of('/').route('getShellCommand', io.controller.home.getShellCommand) // 暂时close Terminal相关功能 diff --git a/app/service/skills.js b/app/service/skills.js index e6cddb5..b255b9f 100644 --- a/app/service/skills.js +++ b/app/service/skills.js @@ -4,6 +4,43 @@ const path = require('path'); const CACHE_TTL_MS = 60 * 1000; const MAX_FILE_LIST_COUNT = 300; +const MAX_FILE_CONTENT_SIZE = 2 * 1024 * 1024; + +const EXTENSION_LANGUAGE_MAP = { + '.md': 'markdown', + '.markdown': 'markdown', + '.js': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.jsx': 'javascript', + '.ts': 'typescript', + '.tsx': 'typescript', + '.json': 'json', + '.yml': 'yaml', + '.yaml': 'yaml', + '.html': 'html', + '.css': 'css', + '.scss': 'scss', + '.less': 'less', + '.sh': 'bash', + '.bash': 'bash', + '.zsh': 'bash', + '.py': 'python', + '.go': 'go', + '.java': 'java', + '.kt': 'kotlin', + '.rb': 'ruby', + '.php': 'php', + '.rs': 'rust', + '.swift': 'swift', + '.xml': 'xml', + '.sql': 'sql', + '.toml': 'toml', + '.ini': 'ini', + '.conf': 'ini', + '.txt': 'text', + '.log': 'text', +}; class SkillsService extends Service { constructor(ctx) { @@ -278,10 +315,7 @@ class SkillsService extends Service { async getSkillDetail(slug) { await this.ensureSkillCache(); - const skill = this.skillCache.skills.find((item) => item.slug === slug); - if (!skill) { - this.ctx.throw(404, '技能不存在'); - } + const skill = this.getSkillBySlug(slug); const content = fs.readFileSync(skill.skillFilePath, 'utf8'); const fileList = this.listSkillFiles(skill.sourcePath); @@ -293,6 +327,46 @@ class SkillsService extends Service { }; } + async getSkillFileContent(slug, filePath) { + await this.ensureSkillCache(); + const skill = this.getSkillBySlug(slug); + const normalizedPath = this.normalizeRelativePath(filePath); + const rootPath = path.resolve(skill.sourcePath); + const targetPath = path.resolve(rootPath, normalizedPath); + + if (!this.isPathInsideRoot(rootPath, targetPath)) { + this.ctx.throw(400, '非法文件路径'); + } + + if (!fs.existsSync(targetPath)) { + this.ctx.throw(404, '文件不存在'); + } + + const stat = fs.statSync(targetPath); + if (!stat.isFile()) { + this.ctx.throw(400, '仅支持读取文件内容'); + } + + if (stat.size > MAX_FILE_CONTENT_SIZE) { + this.ctx.throw(413, '文件过大,无法在线预览'); + } + + const fileBuffer = fs.readFileSync(targetPath); + const isBinary = this.isLikelyBinary(fileBuffer); + const extension = path.extname(normalizedPath).toLowerCase(); + + return { + slug: skill.slug, + path: normalizedPath, + language: EXTENSION_LANGUAGE_MAP[extension] || 'text', + size: stat.size, + readonly: true, + isBinary, + encoding: isBinary ? 'base64' : 'utf8', + content: isBinary ? fileBuffer.toString('base64') : fileBuffer.toString('utf8'), + }; + } + listSkillFiles(skillDir) { const files = []; const stack = [skillDir]; @@ -322,6 +396,46 @@ class SkillsService extends Service { return files.sort(); } + getSkillBySlug(slug) { + const skill = this.skillCache.skills.find((item) => item.slug === slug); + if (!skill) { + this.ctx.throw(404, '技能不存在'); + } + return skill; + } + + normalizeRelativePath(filePath) { + const value = String(filePath || '').trim(); + if (!value) { + this.ctx.throw(400, '缺少文件路径'); + } + + const normalized = path + .normalize(value) + .replace(/\\/g, '/') + .replace(/^\/+/, ''); + + if (!normalized || normalized === '.' || normalized.startsWith('..')) { + this.ctx.throw(400, '非法文件路径'); + } + + return normalized; + } + + isPathInsideRoot(rootPath, targetPath) { + if (targetPath === rootPath) return false; + return targetPath.startsWith(`${rootPath}${path.sep}`); + } + + isLikelyBinary(buffer) { + if (!buffer || buffer.length === 0) return false; + const sampleLength = Math.min(buffer.length, 1024); + for (let i = 0; i < sampleLength; i += 1) { + if (buffer[i] === 0) return true; + } + return false; + } + async getRelatedSkills(slug, limit = 6) { await this.ensureSkillCache(); const target = this.skillCache.skills.find((item) => item.slug === slug); From 982027c4d7b81735b068f795be1f5c06c0ed5f22 Mon Sep 17 00:00:00 2001 From: huaiju Date: Mon, 23 Feb 2026 09:38:03 +0800 Subject: [PATCH 06/43] feat: add skills zip download API for wget --- app/controller/skills.js | 9 +++++++ app/router.js | 1 + app/service/skills.js | 57 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/app/controller/skills.js b/app/controller/skills.js index c436bee..d1f0cab 100644 --- a/app/controller/skills.js +++ b/app/controller/skills.js @@ -28,6 +28,15 @@ class SkillsController extends Controller { const data = await ctx.service.skills.getSkillFileContent(slug, filePath); ctx.body = app.utils.response(true, data); } + + async downloadSkillArchive() { + const { ctx } = this; + const { slug } = ctx.query; + const { fileName, content } = await ctx.service.skills.getSkillArchive(slug); + ctx.set('Content-Type', 'application/zip'); + ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`); + ctx.body = content; + } } module.exports = SkillsController; diff --git a/app/router.js b/app/router.js index a23bf26..a85585f 100644 --- a/app/router.js +++ b/app/router.js @@ -153,6 +153,7 @@ module.exports = (app) => { app.get('/api/skills/detail', app.controller.skills.getSkillDetail); app.get('/api/skills/related', app.controller.skills.getRelatedSkills); app.get('/api/skills/file-content', app.controller.skills.getSkillFileContent); + app.get('/api/skills/download', app.controller.skills.downloadSkillArchive); // io.of('/').route('getShellCommand', io.controller.home.getShellCommand) // 暂时close Terminal相关功能 diff --git a/app/service/skills.js b/app/service/skills.js index b255b9f..a5214ca 100644 --- a/app/service/skills.js +++ b/app/service/skills.js @@ -1,4 +1,5 @@ const Service = require('egg').Service; +const AdmZip = require('adm-zip'); const fs = require('fs'); const path = require('path'); @@ -367,6 +368,25 @@ class SkillsService extends Service { }; } + async getSkillArchive(slug) { + await this.ensureSkillCache(); + const skill = this.getSkillBySlug(slug); + const files = this.collectSkillFiles(skill.sourcePath); + const zip = new AdmZip(); + const rootFolder = this.sanitizeFileName(skill.name || skill.slug || 'skill'); + + files.forEach((relativePath) => { + const absolutePath = path.resolve(skill.sourcePath, relativePath); + const content = fs.readFileSync(absolutePath); + zip.addFile(path.posix.join(rootFolder, relativePath), content); + }); + + return { + fileName: `${rootFolder}.zip`, + content: zip.toBuffer(), + }; + } + listSkillFiles(skillDir) { const files = []; const stack = [skillDir]; @@ -396,6 +416,35 @@ class SkillsService extends Service { return files.sort(); } + collectSkillFiles(skillDir, maxCount = 2000) { + const files = []; + const stack = [skillDir]; + while (stack.length > 0) { + const currentDir = stack.pop(); + let entries = []; + try { + entries = fs.readdirSync(currentDir, { withFileTypes: true }); + } catch (error) { + continue; + } + + entries.forEach((entry) => { + if (entry.name === '.git' || entry.name === 'node_modules') return; + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + return; + } + if (!entry.isFile()) return; + files.push(path.relative(skillDir, fullPath).split(path.sep).join('/')); + }); + + if (files.length >= maxCount) break; + } + + return files.sort(); + } + getSkillBySlug(slug) { const skill = this.skillCache.skills.find((item) => item.slug === slug); if (!skill) { @@ -436,6 +485,14 @@ class SkillsService extends Service { return false; } + sanitizeFileName(fileName) { + return String(fileName || 'skill') + .trim() + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase(); + } + async getRelatedSkills(slug, limit = 6) { await this.ensureSkillCache(); const target = this.skillCache.skills.find((item) => item.slug === slug); From da284b1427ee156ff7b9212b588b5de2d6451684 Mon Sep 17 00:00:00 2001 From: huaiju Date: Mon, 23 Feb 2026 09:40:43 +0800 Subject: [PATCH 07/43] feat: redesign skills detail page with file tree and linked preview --- app/web/api/url.ts | 5 + app/web/pages/skills/detail/index.tsx | 401 ++++++++++++++++--------- app/web/pages/skills/detail/style.scss | 49 ++- app/web/pages/skills/types.ts | 11 + 4 files changed, 315 insertions(+), 151 deletions(-) diff --git a/app/web/api/url.ts b/app/web/api/url.ts index 6c6c58b..996f25a 100644 --- a/app/web/api/url.ts +++ b/app/web/api/url.ts @@ -356,4 +356,9 @@ export default { method: 'get', url: '/api/skills/related', }, + // 获取 Skill 文件内容 + getSkillFileContent: { + method: 'get', + url: '/api/skills/file-content', + }, }; diff --git a/app/web/pages/skills/detail/index.tsx b/app/web/pages/skills/detail/index.tsx index 89317fe..d0424f2 100644 --- a/app/web/pages/skills/detail/index.tsx +++ b/app/web/pages/skills/detail/index.tsx @@ -1,57 +1,209 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import SyntaxHighlighter from 'react-syntax-highlighter'; +import { atomOneLight } from 'react-syntax-highlighter/dist/cjs/styles/hljs'; import { ArrowLeftOutlined, - CopyOutlined, - EyeOutlined, FolderOpenOutlined, - ProfileOutlined, ReadOutlined, StarOutlined, } from '@ant-design/icons'; -import { Button, Card, Col, Divider, Empty, List, Row, Space, Spin, Tabs, Tag, Typography } from 'antd'; +import { Button, Card, Col, Empty, Row, Space, Spin, Tag, Tree, Typography } from 'antd'; +import type { DataNode } from 'antd/lib/tree'; import { API } from '@/api'; import MarkdownRenderer from '@/components/markdownRenderer'; -import { copyToClipboard } from '@/utils/copyUtils'; -import { SkillDetail as SkillDetailType, SkillItem } from '../types'; +import { SkillDetail as SkillDetailType, SkillFileContent, SkillItem } from '../types'; import './style.scss'; const { Title, Text, Paragraph } = Typography; -const { TabPane } = Tabs; + +interface SkillTreeNode extends DataNode { + children?: SkillTreeNode[]; + isFile?: boolean; +} + +const formatFileSize = (size = 0) => { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / 1024 / 1024).toFixed(1)} MB`; +}; + +const sortTreeNodes = (nodes: SkillTreeNode[]) => { + nodes.sort((a, b) => { + const aIsLeaf = Boolean(a.isLeaf); + const bIsLeaf = Boolean(b.isLeaf); + if (aIsLeaf !== bIsLeaf) return aIsLeaf ? 1 : -1; + return String(a.title).localeCompare(String(b.title)); + }); + nodes.forEach((node) => { + if (node.children && node.children.length > 0) { + sortTreeNodes(node.children); + } + }); +}; + +const buildFileTreeData = (fileList: string[]): SkillTreeNode[] => { + const treeData: SkillTreeNode[] = []; + + fileList.forEach((filePath) => { + const segments = filePath.split('/').filter(Boolean); + let currentNodes = treeData; + let currentPath = ''; + + segments.forEach((segment, index) => { + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + const isLeaf = index === segments.length - 1; + let node = currentNodes.find((item) => item.key === currentPath); + + if (!node) { + node = { + key: currentPath, + title: segment, + isLeaf, + isFile: isLeaf, + children: isLeaf ? undefined : [], + }; + currentNodes.push(node); + } + + if (!isLeaf) { + node.children = node.children || []; + currentNodes = node.children; + } + }); + }); + + sortTreeNodes(treeData); + return treeData; +}; const SkillDetail: React.FC = ({ history, match }) => { const { slug } = match.params; const [loading, setLoading] = useState(true); + const [fileLoading, setFileLoading] = useState(false); const [detail, setDetail] = useState(null); const [related, setRelated] = useState([]); + const [selectedFilePath, setSelectedFilePath] = useState(''); + const [fileContent, setFileContent] = useState(null); - const fetchDetail = async () => { - setLoading(true); - try { - const [detailRes, relatedRes] = await Promise.all([ - API.getSkillDetail({ slug }), - API.getRelatedSkills({ slug, limit: 6 }), - ]); - - if (detailRes.success) { - setDetail(detailRes.data); - } else { - setDetail(null); + const fileTreeData = useMemo(() => buildFileTreeData(detail?.fileList || []), [detail?.fileList]); + + useEffect(() => { + let cancelled = false; + + const loadDetail = async () => { + setLoading(true); + try { + const [detailRes, relatedRes] = await Promise.all([ + API.getSkillDetail({ slug }), + API.getRelatedSkills({ slug, limit: 6 }), + ]); + + if (cancelled) return; + + if (detailRes.success) { + const detailData = detailRes.data as SkillDetailType; + setDetail(detailData); + const defaultFile = detailData.fileList.includes('SKILL.md') + ? 'SKILL.md' + : detailData.fileList[0] || ''; + setSelectedFilePath(defaultFile); + } else { + setDetail(null); + setSelectedFilePath(''); + } + + if (relatedRes.success) { + setRelated(relatedRes.data || []); + } + } catch (error) { + console.error('获取 Skill 详情失败:', error); + } finally { + if (!cancelled) { + setLoading(false); + } } + }; - if (relatedRes.success) { - setRelated(relatedRes.data || []); + loadDetail(); + + return () => { + cancelled = true; + }; + }, [slug]); + + useEffect(() => { + if (!selectedFilePath) { + setFileContent(null); + return; + } + + let cancelled = false; + const loadFileContent = async () => { + setFileLoading(true); + try { + const response = await API.getSkillFileContent({ + slug, + path: selectedFilePath, + }); + if (!cancelled) { + if (response.success) { + setFileContent(response.data as SkillFileContent); + } else { + setFileContent(null); + } + } + } catch (error) { + console.error('获取文件内容失败:', error); + if (!cancelled) { + setFileContent(null); + } + } finally { + if (!cancelled) { + setFileLoading(false); + } } - } catch (error) { - console.error('获取 Skill 详情失败:', error); - } finally { - setLoading(false); + }; + + loadFileContent(); + + return () => { + cancelled = true; + }; + }, [slug, selectedFilePath]); + + const renderFileViewer = () => { + if (fileLoading) { + return ( +
+ +
+ ); } - }; - useEffect(() => { - fetchDetail(); - }, [slug]); + if (!fileContent) { + return ; + } + + if (fileContent.isBinary) { + return ; + } + + if (fileContent.language === 'markdown') { + return ; + } + + return ( + + {fileContent.content || ''} + + ); + }; if (loading) { return ( @@ -87,14 +239,6 @@ const SkillDetail: React.FC = ({ history, match }) => { {detail.description || '暂无描述'}
- - -
@@ -113,112 +257,85 @@ const SkillDetail: React.FC = ({ history, match }) => { - - - - 概览 - - } - > - - - 技能名称: - {detail.name} - - - 技能描述: - {detail.description || '-'} - - - 可用工具: - {(detail.allowedTools && detail.allowedTools.length > 0 - ? detail.allowedTools.join(', ') - : '未声明') || '未声明'} - - - 安装命令: - - - {detail.installCommand} - - - - - - - SKILL.md - - } - > - - + + + + + 文件浏览 + + } + bodyStyle={{ padding: '8px 0' }} + > + {fileTreeData.length === 0 ? ( + + ) : ( + { + if (!info.node.isLeaf) return; + const targetPath = String(keys[0] || ''); + if (targetPath) { + setSelectedFilePath(targetPath); + } + }} + /> + )} - - - - - 文件列表 - - } - > - - ( - - {file} - - )} - /> + + + + + + {selectedFilePath || '文件预览'} + + } + extra={ + fileContent ? ( + + {fileContent.language} + {formatFileSize(fileContent.size)} + + ) : null + } + > + {renderFileViewer()} - - - - - 相关技能 - - } - > - {related.length === 0 ? ( - - ) : ( - - {related.map((item) => ( - - history.push(`/page/skills/${item.slug}`)} - > -
{item.name}
- - {item.description || '暂无描述'} - - - {item.stars || 0} - -
- - ))} -
- )} -
-
- - + + + + + {related.length === 0 ? ( + + ) : ( + + {related.map((item) => ( + + history.push(`/page/skills/${item.slug}`)} + > +
{item.name}
+ + {item.description || '暂无描述'} + + + {item.stars || 0} + +
+ + ))} +
+ )} +
); }; diff --git a/app/web/pages/skills/detail/style.scss b/app/web/pages/skills/detail/style.scss index cae0127..2a1e346 100644 --- a/app/web/pages/skills/detail/style.scss +++ b/app/web/pages/skills/detail/style.scss @@ -33,19 +33,50 @@ } } - .markdown-card { - max-height: 70vh; - overflow: auto; + .detail-main-row { + margin-bottom: 16px; } - .related-card { + .file-tree-card { height: 100%; + min-height: 560px; + + .ant-card-body { + max-height: 500px; + overflow: auto; + } + + .ant-tree { + padding: 4px 8px 8px; + } + } + + .file-viewer-card { + min-height: 560px; + + .ant-card-body { + max-height: 500px; + overflow: auto; + } + } + + .file-viewer-loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 420px; + } + + .related-card-list { + .related-item-card { + height: 100%; - .related-title { - margin-bottom: 8px; - color: #1f2d3d; - font-size: 16px; - font-weight: 600; + .related-title { + margin-bottom: 8px; + color: #1f2d3d; + font-size: 16px; + font-weight: 600; + } } } } diff --git a/app/web/pages/skills/types.ts b/app/web/pages/skills/types.ts index 43bdedd..4438b56 100644 --- a/app/web/pages/skills/types.ts +++ b/app/web/pages/skills/types.ts @@ -24,3 +24,14 @@ export interface SkillDetail extends SkillItem { fileList: string[]; skillMd: string; } + +export interface SkillFileContent { + slug: string; + path: string; + language: string; + size: number; + readonly: boolean; + isBinary: boolean; + encoding: 'utf8' | 'base64'; + content: string; +} From 7ab8c747312be46d02784f93cfdb8d99a008d807 Mon Sep 17 00:00:00 2001 From: huaiju Date: Mon, 23 Feb 2026 09:43:49 +0800 Subject: [PATCH 08/43] feat: add conversion panel and wget download on skills detail page --- app/web/api/url.ts | 5 + app/web/pages/skills/detail/index.tsx | 164 +++++++++++++++++++++++-- app/web/pages/skills/detail/style.scss | 56 +++++---- 3 files changed, 194 insertions(+), 31 deletions(-) diff --git a/app/web/api/url.ts b/app/web/api/url.ts index 996f25a..53ac624 100644 --- a/app/web/api/url.ts +++ b/app/web/api/url.ts @@ -361,4 +361,9 @@ export default { method: 'get', url: '/api/skills/file-content', }, + // 下载 Skill 目录压缩包 + downloadSkillArchive: { + method: 'get', + url: '/api/skills/download', + }, }; diff --git a/app/web/pages/skills/detail/index.tsx b/app/web/pages/skills/detail/index.tsx index d0424f2..4b859de 100644 --- a/app/web/pages/skills/detail/index.tsx +++ b/app/web/pages/skills/detail/index.tsx @@ -3,15 +3,22 @@ import SyntaxHighlighter from 'react-syntax-highlighter'; import { atomOneLight } from 'react-syntax-highlighter/dist/cjs/styles/hljs'; import { ArrowLeftOutlined, + CopyOutlined, + DownloadOutlined, FolderOpenOutlined, + HeartFilled, + HeartOutlined, + LinkOutlined, ReadOutlined, + ShareAltOutlined, StarOutlined, } from '@ant-design/icons'; -import { Button, Card, Col, Empty, Row, Space, Spin, Tag, Tree, Typography } from 'antd'; +import { Button, Card, Col, Empty, Radio, Row, Space, Spin, Tag, Tree, Typography } from 'antd'; import type { DataNode } from 'antd/lib/tree'; import { API } from '@/api'; import MarkdownRenderer from '@/components/markdownRenderer'; +import { copyToClipboard } from '@/utils/copyUtils'; import { SkillDetail as SkillDetailType, SkillFileContent, SkillItem } from '../types'; import './style.scss'; @@ -19,9 +26,10 @@ const { Title, Text, Paragraph } = Typography; interface SkillTreeNode extends DataNode { children?: SkillTreeNode[]; - isFile?: boolean; } +type InstallRuntime = 'npx' | 'bunx' | 'pnpm'; + const formatFileSize = (size = 0) => { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; @@ -60,7 +68,6 @@ const buildFileTreeData = (fileList: string[]): SkillTreeNode[] => { key: currentPath, title: segment, isLeaf, - isFile: isLeaf, children: isLeaf ? undefined : [], }; currentNodes.push(node); @@ -77,6 +84,43 @@ const buildFileTreeData = (fileList: string[]): SkillTreeNode[] => { return treeData; }; +const normalizeSourceUrl = (sourceRepo: string) => { + if (!sourceRepo) return ''; + const normalized = sourceRepo.replace(/^git\+/, '').trim(); + const sshMatch = normalized.match(/^git@([^:]+):(.+?)(?:\.git)?$/); + if (sshMatch) { + return `https://${sshMatch[1]}/${sshMatch[2]}`; + } + if (/^https?:\/\//.test(normalized)) { + return normalized.replace(/\.git$/, ''); + } + return ''; +}; + +const getInstallArgs = (detail: SkillDetailType) => { + const installCommand = detail.installCommand || ''; + const addPattern = /(?:npx|bunx|pnpm\s+dlx)\s+skills\s+add\s+(.+)$/i; + const addMatch = installCommand.match(addPattern); + if (addMatch && addMatch[1]) { + return addMatch[1].trim(); + } + if (detail.sourceRepo) { + return `${detail.sourceRepo} --skill "${detail.name}"`; + } + return ''; +}; + +const getInstallCommandByRuntime = ( + runtime: InstallRuntime, + detail: SkillDetailType, + installArgs: string +) => { + if (!installArgs) return detail.installCommand || ''; + if (runtime === 'bunx') return `bunx skills add ${installArgs}`; + if (runtime === 'pnpm') return `pnpm dlx skills add ${installArgs}`; + return `npx skills add ${installArgs}`; +}; + const SkillDetail: React.FC = ({ history, match }) => { const { slug } = match.params; const [loading, setLoading] = useState(true); @@ -85,8 +129,30 @@ const SkillDetail: React.FC = ({ history, match }) => { const [related, setRelated] = useState([]); const [selectedFilePath, setSelectedFilePath] = useState(''); const [fileContent, setFileContent] = useState(null); + const [installRuntime, setInstallRuntime] = useState('npx'); + const [favorited, setFavorited] = useState(false); const fileTreeData = useMemo(() => buildFileTreeData(detail?.fileList || []), [detail?.fileList]); + const sourceUrl = useMemo(() => normalizeSourceUrl(detail?.sourceRepo || ''), [detail?.sourceRepo]); + const installArgs = useMemo(() => (detail ? getInstallArgs(detail) : ''), [detail]); + const installCommand = useMemo(() => { + if (!detail) return ''; + return getInstallCommandByRuntime(installRuntime, detail, installArgs); + }, [detail, installArgs, installRuntime]); + + const downloadPath = useMemo(() => `/api/skills/download?slug=${encodeURIComponent(slug)}`, [slug]); + const archiveFileName = useMemo(() => { + const rawName = detail?.name || slug || 'skill'; + const normalized = rawName + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, ''); + return `${normalized || 'skill'}.zip`; + }, [detail?.name, slug]); + const wgetCommand = useMemo(() => { + if (typeof window === 'undefined') return ''; + return `wget "${window.location.origin}${downloadPath}" -O ${archiveFileName}`; + }, [archiveFileName, downloadPath]); useEffect(() => { let cancelled = false; @@ -197,7 +263,7 @@ const SkillDetail: React.FC = ({ history, match }) => { {fileContent.content || ''} @@ -258,7 +324,7 @@ const SkillDetail: React.FC = ({ history, match }) => { - + = ({ history, match }) => { 文件浏览 } - bodyStyle={{ padding: '8px 0' }} + bodyStyle={{ padding: '8px 0', maxHeight: 280, overflow: 'auto' }} > {fileTreeData.length === 0 ? ( @@ -286,9 +352,7 @@ const SkillDetail: React.FC = ({ history, match }) => { /> )} - - = ({ history, match }) => { {renderFileViewer()} + + + skills.sh}> + setInstallRuntime(event.target.value)} + buttonStyle="solid" + className="runtime-switch" + > + npx + bunx + pnpm + +
+ {installCommand || '暂无可用安装命令'} +
+
+ + man}> +
+ +
+ {wgetCommand || `wget "${downloadPath}" -O ${archiveFileName}`} +
+ + 下载完整技能目录,包含 SKILL.md 与所有相关文件 + +
+
+ + + + + + + + +
diff --git a/app/web/pages/skills/detail/style.scss b/app/web/pages/skills/detail/style.scss index 2a1e346..be65bab 100644 --- a/app/web/pages/skills/detail/style.scss +++ b/app/web/pages/skills/detail/style.scss @@ -1,79 +1,89 @@ .page-skill-detail { padding: 20px 24px; - &.loading-wrap { display: flex; justify-content: center; align-items: center; min-height: 420px; } - .detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 16px; - .back-btn { margin-top: 4px; } - .title-group { flex: 1; min-width: 0; } } - .meta-card { margin-bottom: 16px; - .tags-wrap { margin-top: 12px; } } - .detail-main-row { margin-bottom: 16px; } - .file-tree-card { - height: 100%; - min-height: 560px; - - .ant-card-body { - max-height: 500px; - overflow: auto; - } - + margin-bottom: 16px; .ant-tree { padding: 4px 8px 8px; } } - .file-viewer-card { min-height: 560px; - .ant-card-body { - max-height: 500px; + max-height: 640px; overflow: auto; } } - .file-viewer-loading { display: flex; justify-content: center; align-items: center; min-height: 420px; } - + .action-card { + margin-bottom: 16px; + &:last-child { + margin-bottom: 0; + } + .runtime-switch { + margin-bottom: 12px; + } + .download-buttons { + display: flex; + flex-direction: column; + gap: 12px; + } + } + .command-block { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + border-radius: 6px; + border: 1px solid #E5E7EB; + background: #F8FAFC; + code { + flex: 1; + white-space: pre-wrap; + word-break: break-all; + color: #1F2D3D; + } + } .related-card-list { .related-item-card { height: 100%; - .related-title { margin-bottom: 8px; - color: #1f2d3d; + color: #1F2D3D; font-size: 16px; font-weight: 600; } From 9f5fe23cf79e94d9e9de0e27bf2da5869b6dbd2c Mon Sep 17 00:00:00 2001 From: huaiju Date: Mon, 23 Feb 2026 10:10:09 +0800 Subject: [PATCH 09/43] feat: add skills import entry on list page and import API --- app/controller/skills.js | 7 +++ app/router.js | 1 + app/service/skills.js | 101 +++++++++++++++++++++++++++++++ app/web/api/url.ts | 5 ++ app/web/pages/skills/index.tsx | 102 +++++++++++++++++++++++++++----- app/web/pages/skills/style.scss | 26 +++----- 6 files changed, 209 insertions(+), 33 deletions(-) diff --git a/app/controller/skills.js b/app/controller/skills.js index d1f0cab..8f269a5 100644 --- a/app/controller/skills.js +++ b/app/controller/skills.js @@ -37,6 +37,13 @@ class SkillsController extends Controller { ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`); ctx.body = content; } + + async importSkill() { + const { app, ctx } = this; + const params = ctx.request.body || {}; + const data = await ctx.service.skills.importSkill(params); + ctx.body = app.utils.response(true, data); + } } module.exports = SkillsController; diff --git a/app/router.js b/app/router.js index a85585f..738f763 100644 --- a/app/router.js +++ b/app/router.js @@ -154,6 +154,7 @@ module.exports = (app) => { app.get('/api/skills/related', app.controller.skills.getRelatedSkills); app.get('/api/skills/file-content', app.controller.skills.getSkillFileContent); app.get('/api/skills/download', app.controller.skills.downloadSkillArchive); + app.post('/api/skills/import', app.controller.skills.importSkill); // io.of('/').route('getShellCommand', io.controller.home.getShellCommand) // 暂时close Terminal相关功能 diff --git a/app/service/skills.js b/app/service/skills.js index a5214ca..06fd514 100644 --- a/app/service/skills.js +++ b/app/service/skills.js @@ -1,11 +1,13 @@ const Service = require('egg').Service; const AdmZip = require('adm-zip'); +const { spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); const CACHE_TTL_MS = 60 * 1000; const MAX_FILE_LIST_COUNT = 300; const MAX_FILE_CONTENT_SIZE = 2 * 1024 * 1024; +const IMPORT_TIMEOUT_MS = 60 * 1000; const EXTENSION_LANGUAGE_MAP = { '.md': 'markdown', @@ -520,6 +522,105 @@ class SkillsService extends Service { return related; } + + async importSkill(params = {}) { + const source = String(params.source || '').trim(); + const skillName = String(params.skillName || '').trim(); + if (!source) { + this.ctx.throw(400, '缺少导入来源地址'); + } + + await this.ensureSkillCache(); + const beforeSlugs = new Set(this.skillCache.skills.map((item) => item.slug)); + const args = [ '-y', 'skills@latest', 'add', source, '-g', '--agent', 'codex', '--copy', '--yes' ]; + if (skillName) { + args.push('--skill', skillName); + } + + let commandResult = null; + try { + commandResult = await this.runCommand('npx', args, IMPORT_TIMEOUT_MS); + } catch (error) { + this.ctx.throw(500, `导入失败: ${error.message}`); + } + + this.skillCache = null; + await this.ensureSkillCache(); + const importedSkills = this.skillCache.skills + .filter((item) => !beforeSlugs.has(item.slug)) + .map((item) => ({ + slug: item.slug, + name: item.name, + sourceRepo: item.sourceRepo, + sourcePath: item.sourcePath, + })); + + return { + source, + skillName, + importedCount: importedSkills.length, + importedSkills, + command: `npx ${args.join(' ')}`, + logs: { + stdout: this.trimCommandOutput(commandResult.stdout), + stderr: this.trimCommandOutput(commandResult.stderr), + }, + }; + } + + runCommand(command, args = [], timeout = IMPORT_TIMEOUT_MS) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: process.cwd(), + env: process.env, + }); + let stdout = ''; + let stderr = ''; + let timedOut = false; + + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + }, timeout); + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + child.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + + child.on('close', (code) => { + clearTimeout(timer); + if (timedOut) { + reject(new Error(`导入命令执行超时(${timeout}ms)`)); + return; + } + + if (code !== 0) { + const detail = this.trimCommandOutput(stderr || stdout); + reject(new Error(detail || `命令退出码: ${code}`)); + return; + } + + resolve({ stdout, stderr }); + }); + }); + } + + trimCommandOutput(content = '') { + const value = String(content || '').trim(); + if (!value) return ''; + const maxLength = 3000; + if (value.length <= maxLength) return value; + return value.slice(value.length - maxLength); + } } module.exports = SkillsService; diff --git a/app/web/api/url.ts b/app/web/api/url.ts index 53ac624..c986ecc 100644 --- a/app/web/api/url.ts +++ b/app/web/api/url.ts @@ -366,4 +366,9 @@ export default { method: 'get', url: '/api/skills/download', }, + // 导入 Skill + importSkill: { + method: 'post', + url: '/api/skills/import', + }, }; diff --git a/app/web/pages/skills/index.tsx b/app/web/pages/skills/index.tsx index 36163e9..922196d 100644 --- a/app/web/pages/skills/index.tsx +++ b/app/web/pages/skills/index.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { CopyOutlined, EyeOutlined, FilterOutlined, + ImportOutlined, SearchOutlined, StarOutlined, } from '@ant-design/icons'; @@ -12,7 +13,10 @@ import { Col, Divider, Empty, + Form, Input, + message, + Modal, Pagination, Row, Select, @@ -20,7 +24,6 @@ import { Spin, Tag, Typography, - message, } from 'antd'; import { API } from '@/api'; @@ -31,21 +34,25 @@ import './style.scss'; const { Search } = Input; const { Option } = Select; const { Paragraph, Text } = Typography; +const INITIAL_QUERY = { + keyword: '', + sortBy: 'stars', + category: '', + pageNum: 1, + pageSize: 12, +}; const SkillsMarket: React.FC = ({ history }) => { const [loading, setLoading] = useState(false); const [skills, setSkills] = useState([]); const [categories, setCategories] = useState([]); const [total, setTotal] = useState(0); - const [query, setQuery] = useState({ - keyword: '', - sortBy: 'stars', - category: '', - pageNum: 1, - pageSize: 12, - }); - - const fetchSkills = async (nextQuery = query) => { + const [importVisible, setImportVisible] = useState(false); + const [importing, setImporting] = useState(false); + const [importForm] = Form.useForm(); + const [query, setQuery] = useState(INITIAL_QUERY); + + const fetchSkills = useCallback(async (nextQuery) => { setLoading(true); try { const response = await API.getSkillList(nextQuery); @@ -63,22 +70,59 @@ const SkillsMarket: React.FC = ({ history }) => { } finally { setLoading(false); } - }; + }, []); useEffect(() => { fetchSkills(query); - }, []); + }, [fetchSkills, query]); const updateQueryAndFetch = (patch: Partial) => { const next = { ...query, ...patch }; setQuery(next); - fetchSkills(next); }; const handleOpenDetail = (slug: string) => { history.push(`/page/skills/${slug}`); }; + const openImportModal = () => { + setImportVisible(true); + }; + + const closeImportModal = () => { + if (importing) return; + setImportVisible(false); + importForm.resetFields(); + }; + + const handleImportSkill = async () => { + try { + const values = await importForm.validateFields(); + setImporting(true); + const response = await API.importSkill(values); + if (!response.success) { + message.error(response.msg || '导入失败'); + return; + } + + const importedCount = Number(response.data?.importedCount || 0); + if (importedCount > 0) { + message.success(`导入成功,新增 ${importedCount} 个技能`); + } else { + message.success('导入完成,技能可能已存在'); + } + setImportVisible(false); + importForm.resetFields(); + fetchSkills({ ...query }); + } catch (error) { + if (error?.errorFields) return; + message.error('导入失败,请检查来源地址或网络权限'); + console.error('导入 Skill 失败:', error); + } finally { + setImporting(false); + } + }; + return (
@@ -99,6 +143,9 @@ const SkillsMarket: React.FC = ({ history }) => { onSearch={(value) => updateQueryAndFetch({ keyword: value, pageNum: 1 })} /> + + + + + + + + 示例:`https://github.com/openclaw/openclaw/tree/main/skills/himalaya` + +
); }; diff --git a/app/web/pages/skills/style.scss b/app/web/pages/skills/style.scss index 50ad184..d9f1704 100644 --- a/app/web/pages/skills/style.scss +++ b/app/web/pages/skills/style.scss @@ -1,85 +1,74 @@ .page-skills { padding: 20px 24px; - .skills-header { display: flex; align-items: center; justify-content: space-between; } - .title-group { .page-title { margin: 0; - color: #1f2d3d; + color: #1F2D3D; font-size: 28px; font-weight: 700; } - .page-subtitle { margin: 8px 0 0; color: #667085; } } - .search-filter-row { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 18px; - .keyword-search { width: 620px; max-width: 100%; } + .import-btn { + min-width: 100px; + } } - .skill-card { height: 100%; border-radius: 10px; - border: 1px solid #edf2f7; - + border: 1px solid #EDF2F7; .card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } - .skill-name { - color: #1f2d3d; + color: #1F2D3D; font-size: 16px; font-weight: 600; } - .meta-stars { - color: #e6a23c; + color: #E6A23C; font-weight: 600; } - .skill-desc { min-height: 66px; margin-bottom: 12px; color: #475467; } - .meta-row { display: flex; align-items: center; margin-bottom: 8px; gap: 8px; - .meta-value { flex: 1; color: #344054; } } - .tag-row { min-height: 28px; margin-bottom: 10px; } - .action-row { display: flex; align-items: center; @@ -88,7 +77,6 @@ gap: 8px; } } - .pagination-wrap { display: flex; justify-content: flex-end; From 032d645818588cc137c8c27acc5d300ca4bed81f Mon Sep 17 00:00:00 2001 From: huaiju Date: Mon, 23 Feb 2026 10:21:30 +0800 Subject: [PATCH 10/43] fix: parse skills source from skill-lock and generate remote install command --- app/service/skills.js | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/app/service/skills.js b/app/service/skills.js index 06fd514..a2ece3b 100644 --- a/app/service/skills.js +++ b/app/service/skills.js @@ -49,6 +49,7 @@ class SkillsService extends Service { constructor(ctx) { super(ctx); this.skillCache = null; + this.skillSourceCache = {}; } getSkillRootDirs() { @@ -75,9 +76,10 @@ class SkillsService extends Service { rootDirs.forEach((rootDir) => { if (!fs.existsSync(rootDir)) return; const skillFiles = this.findSkillFiles(rootDir); + const sourceMap = this.getSkillSourceMapByRoot(rootDir); skillFiles.forEach((skillFilePath) => { try { - const skill = this.parseSkillMeta(skillFilePath, rootDir); + const skill = this.parseSkillMeta(skillFilePath, rootDir, sourceMap); if (skill) { skills.push(skill); } @@ -127,7 +129,7 @@ class SkillsService extends Service { return result; } - parseSkillMeta(skillFilePath, rootDir) { + parseSkillMeta(skillFilePath, rootDir, sourceMap = {}) { const content = fs.readFileSync(skillFilePath, 'utf8'); const stat = fs.statSync(skillFilePath); const skillDir = path.dirname(skillFilePath); @@ -160,9 +162,14 @@ class SkillsService extends Service { const tags = this.parseArrayLike(frontmatter.tags); const allowedTools = this.parseArrayLike(frontmatter['allowed-tools']); const stars = Number(metaJson.stars || metaJson.star || metaJson.github_stars || 0); + const skillDirName = path.basename(skillDir); + const sourceRepo = + this.extractSourceRepo(packageJson) || + String(metaJson.sourceRepo || '').trim() || + String(sourceMap[skillDirName] || sourceMap[name] || '').trim(); const installCommand = this.getInstallCommand({ - sourceRepo: this.extractSourceRepo(packageJson), + sourceRepo, name, skillDir, }); @@ -176,7 +183,7 @@ class SkillsService extends Service { allowedTools, stars: Number.isNaN(stars) ? 0 : stars, updatedAt: stat.mtime.toISOString(), - sourceRepo: this.extractSourceRepo(packageJson), + sourceRepo, sourcePath: skillDir, skillFilePath, installCommand, @@ -621,6 +628,33 @@ class SkillsService extends Service { if (value.length <= maxLength) return value; return value.slice(value.length - maxLength); } + + getSkillSourceMapByRoot(rootDir) { + if (this.skillSourceCache[rootDir]) { + return this.skillSourceCache[rootDir]; + } + + const sourceMap = {}; + const parentDir = path.dirname(rootDir); + const lockFilePath = path.join(parentDir, '.skill-lock.json'); + if (fs.existsSync(lockFilePath)) { + try { + const lockData = JSON.parse(fs.readFileSync(lockFilePath, 'utf8')); + const skills = lockData && lockData.skills ? lockData.skills : {}; + Object.keys(skills).forEach((skillName) => { + const sourceUrl = skills[skillName] && skills[skillName].sourceUrl; + if (sourceUrl) { + sourceMap[skillName] = String(sourceUrl); + } + }); + } catch (error) { + this.ctx.logger.warn(`[skills] 读取锁文件失败: ${lockFilePath}, ${error.message}`); + } + } + + this.skillSourceCache[rootDir] = sourceMap; + return sourceMap; + } } module.exports = SkillsService; From 101dae2f0c4c798c3acd937429359e49ba981bb9 Mon Sep 17 00:00:00 2001 From: huaiju Date: Mon, 23 Feb 2026 10:31:00 +0800 Subject: [PATCH 11/43] feat: parse skills frontmatter as structured table --- app/web/pages/skills/detail/index.tsx | 100 ++++++++++++++++++++++++- app/web/pages/skills/detail/style.scss | 47 ++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/app/web/pages/skills/detail/index.tsx b/app/web/pages/skills/detail/index.tsx index 4b859de..f9e6241 100644 --- a/app/web/pages/skills/detail/index.tsx +++ b/app/web/pages/skills/detail/index.tsx @@ -30,6 +30,11 @@ interface SkillTreeNode extends DataNode { type InstallRuntime = 'npx' | 'bunx' | 'pnpm'; +interface FrontmatterItem { + key: string; + value: string; +} + const formatFileSize = (size = 0) => { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; @@ -121,6 +126,64 @@ const getInstallCommandByRuntime = ( return `npx skills add ${installArgs}`; }; +const normalizeFrontmatterValue = (value: string) => { + const trimmed = String(value || '').trim(); + if (!trimmed) return '-'; + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +}; + +const parseMarkdownFrontmatter = (markdown = ''): { frontmatter: FrontmatterItem[]; body: string } => { + const content = String(markdown || ''); + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); + if (!match) { + return { + frontmatter: [], + body: content, + }; + } + + const block = match[1] || ''; + const lines = block.split(/\r?\n/); + const frontmatter: FrontmatterItem[] = []; + let currentKey = ''; + let currentValueLines: string[] = []; + + const pushCurrent = () => { + if (!currentKey) return; + const value = normalizeFrontmatterValue(currentValueLines.join('\n')); + frontmatter.push({ key: currentKey, value }); + }; + + lines.forEach((line) => { + const keyMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/); + const isTopLevelKey = Boolean(keyMatch) && !/^\s/.test(line); + + if (isTopLevelKey && keyMatch) { + pushCurrent(); + currentKey = keyMatch[1]; + currentValueLines = [keyMatch[2] || '']; + return; + } + + if (currentKey) { + currentValueLines.push(line); + } + }); + + pushCurrent(); + + return { + frontmatter, + body: content.slice(match[0].length), + }; +}; + const SkillDetail: React.FC = ({ history, match }) => { const { slug } = match.params; const [loading, setLoading] = useState(true); @@ -256,7 +319,42 @@ const SkillDetail: React.FC = ({ history, match }) => { } if (fileContent.language === 'markdown') { - return ; + const parsedMarkdown = parseMarkdownFrontmatter(fileContent.content || ''); + return ( +
+ {parsedMarkdown.frontmatter.length > 0 ? ( +
+ + + {parsedMarkdown.frontmatter.map((item) => { + const isCodeStyle = + item.value.includes('\n') || + item.value.startsWith('{') || + item.value.startsWith('['); + return ( + + + + + ); + })} + +
{item.key} + {isCodeStyle ? ( +
+                                                            {item.value}
+                                                        
+ ) : ( + {item.value} + )} +
+
+ ) : null} + {parsedMarkdown.body.trim() ? ( + + ) : null} +
+ ); } return ( diff --git a/app/web/pages/skills/detail/style.scss b/app/web/pages/skills/detail/style.scss index be65bab..96d70d3 100644 --- a/app/web/pages/skills/detail/style.scss +++ b/app/web/pages/skills/detail/style.scss @@ -42,6 +42,53 @@ overflow: auto; } } + .markdown-file-viewer { + .frontmatter-table-wrap { + margin-bottom: 16px; + border: 1px solid #F1DFD7; + border-radius: 8px; + overflow: hidden; + background: #FEFAF8; + } + .frontmatter-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + th, + td { + padding: 14px 16px; + vertical-align: top; + border-bottom: 1px solid #F1DFD7; + } + tr:last-child { + th, + td { + border-bottom: none; + } + } + th { + width: 180px; + color: #C56F4E; + font-size: 18px; + font-weight: 600; + text-align: left; + background: rgba(230, 200, 186, 0.16); + } + td { + font-size: 16px; + color: #2A3441; + line-height: 1.75; + word-break: break-word; + } + .frontmatter-code { + margin: 0; + font-size: 14px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + } + } + } .file-viewer-loading { display: flex; justify-content: center; From 1e642791805a04988408a39ccae4b22f02310729 Mon Sep 17 00:00:00 2001 From: huaiju Date: Mon, 23 Feb 2026 10:34:08 +0800 Subject: [PATCH 12/43] style: align skills frontmatter table with project visual style --- app/web/pages/skills/detail/style.scss | 40 +++++++++++++++++++------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/app/web/pages/skills/detail/style.scss b/app/web/pages/skills/detail/style.scss index 96d70d3..2a015fc 100644 --- a/app/web/pages/skills/detail/style.scss +++ b/app/web/pages/skills/detail/style.scss @@ -45,20 +45,31 @@ .markdown-file-viewer { .frontmatter-table-wrap { margin-bottom: 16px; - border: 1px solid #F1DFD7; - border-radius: 8px; + border: 1px solid #E5EDF6; + border-radius: 10px; overflow: hidden; - background: #FEFAF8; + background: #FFF; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.04); } .frontmatter-table { width: 100%; border-collapse: collapse; table-layout: fixed; + position: relative; + &::before { + content: ""; + position: absolute; + left: 0; + top: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #2F7BFF 0%, #56A1FF 100%); + } th, td { - padding: 14px 16px; + padding: 14px 18px; vertical-align: top; - border-bottom: 1px solid #F1DFD7; + border-bottom: 1px solid #EEF3F8; } tr:last-child { th, @@ -67,18 +78,20 @@ } } th { - width: 180px; - color: #C56F4E; - font-size: 18px; + width: 176px; + color: #2D4A6A; + font-size: 15px; font-weight: 600; text-align: left; - background: rgba(230, 200, 186, 0.16); + background: linear-gradient(180deg, #F7FAFF 0%, #F2F7FF 100%); + border-right: 1px solid #E8EEF7; } td { font-size: 16px; - color: #2A3441; - line-height: 1.75; + color: #344054; + line-height: 1.7; word-break: break-word; + background: #FFF; } .frontmatter-code { margin: 0; @@ -86,6 +99,11 @@ line-height: 1.55; white-space: pre-wrap; word-break: break-word; + color: #1F2D3D; + background: #F7FAFC; + border: 1px solid #E4EAF1; + border-radius: 8px; + padding: 10px 12px; } } } From 7fc50a4a5fe84310b92a7cbb9337db862fc0e84a Mon Sep 17 00:00:00 2001 From: huaiju Date: Mon, 23 Feb 2026 20:44:02 +0800 Subject: [PATCH 13/43] feat: use platform category and tags from skills import metadata --- app/service/skills.js | 84 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/app/service/skills.js b/app/service/skills.js index a2ece3b..fd27d04 100644 --- a/app/service/skills.js +++ b/app/service/skills.js @@ -8,6 +8,18 @@ const CACHE_TTL_MS = 60 * 1000; const MAX_FILE_LIST_COUNT = 300; const MAX_FILE_CONTENT_SIZE = 2 * 1024 * 1024; const IMPORT_TIMEOUT_MS = 60 * 1000; +const MAX_PLATFORM_TAGS = 5; +const MAX_TAG_LENGTH = 20; +const SKILL_CATEGORY_OPTIONS = [ + '通用', + '前端', + '后端', + '数据与AI', + '运维与系统', + '工程效率', + '安全', + '其他', +]; const EXTENSION_LANGUAGE_MAP = { '.md': 'markdown', @@ -52,6 +64,10 @@ class SkillsService extends Service { this.skillSourceCache = {}; } + getSkillCategoryOptions() { + return [ ...SKILL_CATEGORY_OPTIONS ]; + } + getSkillRootDirs() { const homeDir = process.env.HOME || ''; const codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex'); @@ -91,7 +107,7 @@ class SkillsService extends Service { }); }); - const categories = Array.from(new Set(skills.map((item) => item.category))).sort(); + const categories = this.getSkillCategoryOptions(); this.skillCache = { loadedAt: Date.now(), skills, @@ -159,7 +175,9 @@ class SkillsService extends Service { const name = frontmatter.name || path.basename(skillDir); const description = frontmatter.description || this.extractDescription(content); - const tags = this.parseArrayLike(frontmatter.tags); + const categoryByPath = this.getCategoryFromRelativePath(relativeDir); + const category = this.normalizeCategory(metaJson.category || categoryByPath); + const tags = this.normalizePlatformTags(metaJson.platformTags || metaJson.tags); const allowedTools = this.parseArrayLike(frontmatter['allowed-tools']); const stars = Number(metaJson.stars || metaJson.star || metaJson.github_stars || 0); const skillDirName = path.basename(skillDir); @@ -178,7 +196,7 @@ class SkillsService extends Service { slug, name, description, - category: this.getCategoryFromRelativePath(relativeDir), + category, tags, allowedTools, stars: Number.isNaN(stars) ? 0 : stars, @@ -255,6 +273,24 @@ class SkillsService extends Service { return parts[0]; } + normalizeCategory(rawCategory) { + const category = String(rawCategory || '').trim(); + if (!category) return '通用'; + if (SKILL_CATEGORY_OPTIONS.includes(category)) { + return category; + } + return '其他'; + } + + normalizePlatformTags(rawTags) { + const values = this.parseArrayLike(rawTags) + .map((item) => String(item || '').trim()) + .map((item) => item.replace(/\s+/g, ' ')) + .filter(Boolean) + .map((item) => item.slice(0, MAX_TAG_LENGTH)); + return Array.from(new Set(values)).slice(0, MAX_PLATFORM_TAGS); + } + extractSourceRepo(packageJson = {}) { if (!packageJson.repository) return ''; if (typeof packageJson.repository === 'string') return packageJson.repository; @@ -523,7 +559,8 @@ class SkillsService extends Service { .sort((a, b) => b._score - a._score || b.stars - a.stars) .slice(0, parseInt(limit, 10) || 6) .map((item) => { - const { _score, ...rest } = item; + const rest = { ...item }; + delete rest._score; return rest; }); @@ -533,6 +570,8 @@ class SkillsService extends Service { async importSkill(params = {}) { const source = String(params.source || '').trim(); const skillName = String(params.skillName || '').trim(); + const category = this.normalizeCategory(params.category); + const tags = this.normalizePlatformTags(params.tags); if (!source) { this.ctx.throw(400, '缺少导入来源地址'); } @@ -562,9 +601,20 @@ class SkillsService extends Service { sourcePath: item.sourcePath, })); + if (importedSkills.length > 0) { + this.persistSkillMeta(importedSkills, { + category, + tags, + }); + this.skillCache = null; + await this.ensureSkillCache(); + } + return { source, skillName, + category, + tags, importedCount: importedSkills.length, importedSkills, command: `npx ${args.join(' ')}`, @@ -575,6 +625,32 @@ class SkillsService extends Service { }; } + persistSkillMeta(skills = [], meta = {}) { + const nextCategory = this.normalizeCategory(meta.category); + const nextTags = this.normalizePlatformTags(meta.tags); + skills.forEach((item) => { + const skillDir = item.sourcePath; + if (!skillDir || !fs.existsSync(skillDir)) return; + const metaFilePath = path.join(skillDir, '_meta.json'); + let currentMeta = {}; + if (fs.existsSync(metaFilePath)) { + try { + currentMeta = JSON.parse(fs.readFileSync(metaFilePath, 'utf8')); + } catch (error) { + currentMeta = {}; + } + } + + const nextMeta = { + ...currentMeta, + category: nextCategory, + platformTags: nextTags, + tags: nextTags, + }; + fs.writeFileSync(metaFilePath, `${JSON.stringify(nextMeta, null, 2)}\n`, 'utf8'); + }); + } + runCommand(command, args = [], timeout = IMPORT_TIMEOUT_MS) { return new Promise((resolve, reject) => { const child = spawn(command, args, { From 83481934cc76add31273ba06aef92dc8b96e4b0f Mon Sep 17 00:00:00 2001 From: huaiju Date: Mon, 23 Feb 2026 20:44:44 +0800 Subject: [PATCH 14/43] feat: add fixed category and custom tags in skills import form --- app/web/pages/skills/index.tsx | 51 ++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/app/web/pages/skills/index.tsx b/app/web/pages/skills/index.tsx index 922196d..34085eb 100644 --- a/app/web/pages/skills/index.tsx +++ b/app/web/pages/skills/index.tsx @@ -34,6 +34,16 @@ import './style.scss'; const { Search } = Input; const { Option } = Select; const { Paragraph, Text } = Typography; +const FIXED_CATEGORY_OPTIONS = [ + '通用', + '前端', + '后端', + '数据与AI', + '运维与系统', + '工程效率', + '安全', + '其他', +]; const INITIAL_QUERY = { keyword: '', sortBy: 'stars', @@ -87,6 +97,10 @@ const SkillsMarket: React.FC = ({ history }) => { const openImportModal = () => { setImportVisible(true); + importForm.setFieldsValue({ + category: '通用', + tags: [], + }); }; const closeImportModal = () => { @@ -287,6 +301,43 @@ const SkillsMarket: React.FC = ({ history }) => { + + + + { + if (!Array.isArray(value)) return Promise.resolve(); + if (value.length > 5) { + return Promise.reject(new Error('标签最多 5 个')); + } + return Promise.resolve(); + }, + }, + ]} + > + + + {importMode === 'file' && ( + + false} + onChange={(info) => setUploadFiles(info.fileList || [])} + > + + + + )} @@ -339,9 +400,15 @@ const SkillsMarket: React.FC = ({ history }) => { /> - - 示例:`https://github.com/openclaw/openclaw/tree/main/skills/himalaya` - + {importMode === 'source' ? ( + + 示例:`https://github.com/openclaw/openclaw/tree/main/skills/himalaya` + + ) : ( + + 提示:`.skill` 本质是 zip 包,内部应包含 `技能目录/SKILL.md` + + )}
); diff --git a/app/web/router/index.ts b/app/web/router/index.ts index fdbe1d1..1039107 100644 --- a/app/web/router/index.ts +++ b/app/web/router/index.ts @@ -1,10 +1,9 @@ import BasicLayout from '@/layouts/basicLayout'; +import Loadable from 'react-loadable'; // 文章订阅管理 import ArticleSubscriptionList from '@/pages/articleSubscription'; // 配置中心 import ConfigCenter from '@/pages/configCenter'; -// 配置详情 -import ConfigDetail from '@/pages/configDetail'; // 环境管理 import EnvManagement from '@/pages/envManagement'; import NotFound from '@/pages/exception/404'; @@ -26,12 +25,20 @@ import SkillsMarket from '@/pages/skills'; import ProxyServer from '@/pages/proxyServer'; // hosts列表 import SwitchHostsList from '@/pages/switchHosts'; -// hosts编辑 -import SwitchHostsEdit from '@/pages/switchHosts/editHosts'; import TagsManagement from '@/pages/tagsManagement'; // 工具箱 import Toolbox from '@/pages/toolbox'; +const ConfigDetail = Loadable({ + loader: () => import('@/pages/configDetail'), + loading: () => null, +}); + +const SwitchHostsEdit = Loadable({ + loader: () => import('@/pages/switchHosts/editHosts'), + loading: () => null, +}); + const urlPrefix = '/page'; const routes: any = [ { diff --git a/config/config.default.js b/config/config.default.js index 64c2f9f..2fc20d7 100644 --- a/config/config.default.js +++ b/config/config.default.js @@ -38,6 +38,11 @@ module.exports = (app) => { owner: 'dtux-kangaroo', configRepositoryName: 'ko-config', }; + exports.skills = { + gitlabToken: process.env.GITLAB_TOKEN || '', + githubToken: process.env.GITHUB_TOKEN || '', + gitlabHostWhitelist: ['gitlab.prod.dtstack.cn'], + }; exports.middleware = ['access']; @@ -58,7 +63,7 @@ module.exports = (app) => { // 文件上传 fileSize: '200mb', mode: 'file', // 使用文件模式,直接保存到临时文件 - fileExtensions: ['.zip', '.tar', '.gz', '.tgz'], // 允许的文件扩展名 + fileExtensions: ['.zip', '.tar', '.gz', '.tgz', '.skill'], // 允许的文件扩展名 tmpdir: path.join(app.baseDir, 'cache/uploads'), // 临时文件目录 fields: 100, // 允许的最多字段数量 cleanSchedule: { @@ -71,6 +76,7 @@ module.exports = (app) => { '.tar', '.gz', '.tgz', + '.skill', ], }; From 716fc99c41f855cab5aff8e991011b2191942ebd Mon Sep 17 00:00:00 2001 From: huaiju Date: Wed, 25 Feb 2026 21:57:56 +0800 Subject: [PATCH 27/43] fix: speed up subpath import via sparse clone fallback --- app/service/skills.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/app/service/skills.js b/app/service/skills.js index 25b3893..1480afd 100644 --- a/app/service/skills.js +++ b/app/service/skills.js @@ -832,6 +832,41 @@ class SkillsService extends Service { const env = this.buildCommandEnv({ remoteUrl: parsedSource.cloneUrl }); const authArgs = this.getGitAuthPrefixArgs(parsedSource.cloneUrl); + const hasSubpath = Boolean(String(parsedSource.subpath || '').trim()); + + // 子目录导入优先使用 sparse clone,避免大仓库全量 clone 导致长时间卡顿。 + if (hasSubpath) { + const sparseCloneArgs = [ ...authArgs, 'clone', '--depth', '1', '--filter=blob:none', '--sparse' ]; + if (parsedSource.ref) { + sparseCloneArgs.push('--branch', parsedSource.ref); + } + sparseCloneArgs.push(parsedSource.cloneUrl, targetDir); + + try { + await this.runCommand('git', sparseCloneArgs, GIT_COMMAND_TIMEOUT_MS, process.cwd(), env); + await this.runCommand( + 'git', + [ '-C', targetDir, 'sparse-checkout', 'set', parsedSource.subpath ], + GIT_COMMAND_TIMEOUT_MS, + process.cwd(), + env + ); + return; + } catch (error) { + this.ctx.logger.warn(`[skills] sparse clone 失败,回退普通 clone: ${error.message}`); + if (fs.existsSync(targetDir)) { + try { + fs.rmSync(targetDir, { recursive: true, force: true }); + } catch (cleanupError) { + this.ctx.logger.warn( + `[skills] sparse clone 回退清理目录失败: ${targetDir}, ${cleanupError.message}` + ); + } + } + fs.mkdirSync(targetDir, { recursive: true }); + } + } + const cloneArgs = [ ...authArgs, 'clone', '--depth', '1' ]; if (parsedSource.ref) { cloneArgs.push('--branch', parsedSource.ref); From b7ab8dabe4b954d1e4ae9e3f6f26f1a09419bf99 Mon Sep 17 00:00:00 2001 From: huaiju Date: Sat, 21 Mar 2026 17:23:05 +0800 Subject: [PATCH 28/43] feat(skills): add install-meta api for cli install protocol --- app/controller/skills.js | 7 ++++ app/router.js | 1 + app/service/skills.js | 84 ++++++++++++++++++++++++++++++++++++++++ env.json | 2 +- 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/app/controller/skills.js b/app/controller/skills.js index 35c8754..e0041e3 100644 --- a/app/controller/skills.js +++ b/app/controller/skills.js @@ -39,6 +39,13 @@ class SkillsController extends Controller { ctx.body = content; } + async getSkillInstallMeta() { + const { app, ctx } = this; + const { slug } = ctx.query; + const data = await ctx.service.skills.getInstallMeta(slug); + ctx.body = app.utils.response(true, data); + } + async importSkill() { const { app, ctx } = this; const params = ctx.request.body || {}; diff --git a/app/router.js b/app/router.js index 9f9df62..99d4f59 100644 --- a/app/router.js +++ b/app/router.js @@ -153,6 +153,7 @@ module.exports = (app) => { app.get('/api/skills/detail', app.controller.skills.getSkillDetail); app.get('/api/skills/related', app.controller.skills.getRelatedSkills); app.get('/api/skills/file-content', app.controller.skills.getSkillFileContent); + app.get('/api/skills/install-meta', app.controller.skills.getSkillInstallMeta); app.get('/api/skills/download', app.controller.skills.downloadSkillArchive); app.post('/api/skills/import', app.controller.skills.importSkill); app.post('/api/skills/import-file', app.controller.skills.importSkillFile); diff --git a/app/service/skills.js b/app/service/skills.js index 1480afd..54a25a1 100644 --- a/app/service/skills.js +++ b/app/service/skills.js @@ -1,5 +1,6 @@ const Service = require('egg').Service; const AdmZip = require('adm-zip'); +const crypto = require('crypto'); const { spawn } = require('child_process'); const fs = require('fs'); const os = require('os'); @@ -17,6 +18,7 @@ const MAX_TAG_LENGTH = 20; const DISCOVER_DEPTH_LIMIT = 2; const SKILLS_ROOT_DISCOVER_DEPTH_LIMIT = 8; const DISCOVER_MAX_DIR_COUNT = 3000; +const SKILL_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const SKILL_CATEGORY_OPTIONS = [ '通用', @@ -353,6 +355,88 @@ class SkillsService extends Service { }; } + validateSkillSlug(slug) { + const value = String(slug || '').trim(); + if (!value) { + this.ctx.throw(400, '缺少技能标识'); + } + if (!SKILL_SLUG_PATTERN.test(value)) { + this.ctx.throw(400, '非法技能标识'); + } + return value; + } + + buildSkillDownloadUrl(slug) { + const encodedSlug = encodeURIComponent(slug); + const { protocol, host } = this.ctx; + if (protocol && host) { + return `${protocol}://${host}/api/skills/download?slug=${encodedSlug}`; + } + return `/api/skills/download?slug=${encodedSlug}`; + } + + async getSkillPackageInstallability(skillId) { + const { SkillsFile } = this.app.model; + const rows = await SkillsFile.findAll({ + where: { + skill_id: skillId, + is_delete: 0, + }, + attributes: [ 'file_path' ], + order: [ [ 'file_path', 'ASC' ] ], + limit: MAX_FILE_LIST_COUNT, + }); + + if (rows.length === 0) { + return { + installable: false, + reason: 'skill package has no files', + }; + } + + const hasSkillMd = rows.some((row) => path.basename(row.file_path).toLowerCase() === 'skill.md'); + if (!hasSkillMd) { + return { + installable: false, + reason: 'skill package missing SKILL.md', + }; + } + + return { + installable: true, + reason: '', + }; + } + + async getInstallMeta(slug) { + await this.ensureSkillCache(); + const safeSlug = this.validateSkillSlug(slug); + const skill = this.getSkillBySlug(safeSlug); + const { installable, reason } = await this.getSkillPackageInstallability(skill.id); + const downloadUrl = this.buildSkillDownloadUrl(skill.slug); + + let sha256 = ''; + if (installable) { + const { content } = await this.getSkillArchive(skill.slug); + sha256 = crypto.createHash('sha256').update(content).digest('hex'); + } + + return { + slug: skill.slug, + name: skill.name, + downloadUrl, + packageType: 'zip', + packageVersion: 'v1', + packageRootMode: 'find-skill-md', + installDirName: skill.slug, + version: '', + sha256, + sourceRepo: skill.sourceRepo || '', + installable, + reason, + }; + } + decodeStoredFileContent(content, isBinary) { if (!content) return Buffer.from(''); if (isBinary) { diff --git a/env.json b/env.json index 144c691..ab3e9ae 100644 --- a/env.json +++ b/env.json @@ -8,7 +8,7 @@ "mysql": { "prod": {} }, - "mcpDeployDir": "/opt/doraemon/mcp-server/", + "mcpDeployDir": "/tmp/doraemon/mcp-server/", "mcpEndpointPort": 7005, "mcpInspectorWebPort": 7003, "mcpInspectorServerPort": 7004, From 11ae9f03ddaceb5aa132a67705ca8586729e55e3 Mon Sep 17 00:00:00 2001 From: huaiju Date: Sat, 21 Mar 2026 17:44:30 +0800 Subject: [PATCH 29/43] feat(cli): implement doraemon-skills install/list wp2 --- bin/doraemon-skills | 3 + package.json | 3 + scripts/doraemon-skills.js | 279 +++++++++++++++++++++++++++++++++++++ 3 files changed, 285 insertions(+) create mode 100755 bin/doraemon-skills create mode 100755 scripts/doraemon-skills.js diff --git a/bin/doraemon-skills b/bin/doraemon-skills new file mode 100755 index 0000000..8758d89 --- /dev/null +++ b/bin/doraemon-skills @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +require('../scripts/doraemon-skills'); diff --git a/package.json b/package.json index 9b10a06..364ec64 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,9 @@ "log": "conventional-changelog --config ./node_modules/vue-cli-plugin-commitlint/lib/log -i CHANGELOG.md -s -r 0", "cz": "npm run log && git add . && git cz" }, + "bin": { + "doraemon-skills": "bin/doraemon-skills" + }, "dependencies": { "@ant-design/icons": "4.5.0", "@modelcontextprotocol/sdk": "^1.25.2", diff --git a/scripts/doraemon-skills.js b/scripts/doraemon-skills.js new file mode 100755 index 0000000..0f75585 --- /dev/null +++ b/scripts/doraemon-skills.js @@ -0,0 +1,279 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const AdmZip = require('adm-zip'); +const fetch = require('node-fetch'); + +function fail(message) { + throw new Error(message); +} + +function normalizeServerUrl(server) { + if (!server) { + fail('Server is required. Provide --server or set DORAEMON_SKILLS_SERVER.'); + } + try { + const parsed = new URL(server); + return parsed.toString(); + } catch (error) { + fail(`Invalid server URL: ${server}`); + } +} + +function parseArgs(argv) { + const [command, ...rest] = argv; + const options = {}; + const positionals = []; + + for (let i = 0; i < rest.length; i++) { + const token = rest[i]; + if (token === '--server' || token === '--dir') { + const value = rest[i + 1]; + if (!value || value.startsWith('--')) { + fail(`Missing value for ${token}`); + } + options[token.slice(2)] = value; + i += 1; + continue; + } + if (token.startsWith('--')) { + fail(`Unknown option: ${token}`); + } + positionals.push(token); + } + + return { + command, + positionals, + options, + }; +} + +function resolveInstallRoot(dirOption) { + const root = dirOption || './skills'; + return path.resolve(process.cwd(), root); +} + +function resolveServer(options) { + const value = options.server || process.env.DORAEMON_SKILLS_SERVER; + return normalizeServerUrl(value); +} + +function parseResponsePayload(payload) { + if (!payload || typeof payload !== 'object') { + fail('Invalid server response payload.'); + } + if (payload.success !== true) { + const message = payload.message || 'Request failed.'; + fail(message); + } + return payload.data; +} + +async function readJsonResponse(response) { + const raw = await response.text(); + let payload = null; + try { + payload = raw ? JSON.parse(raw) : null; + } catch (error) { + if (!response.ok) { + fail(`Request failed with status ${response.status}.`); + } + fail('Invalid JSON response.'); + } + return payload; +} + +async function requestInstallMeta(server, slug) { + const metaUrl = new URL('/api/skills/install-meta', server); + metaUrl.searchParams.set('slug', slug); + + let response; + try { + response = await fetch(metaUrl.toString()); + } catch (error) { + fail(`Failed to request install-meta: ${error.message}`); + } + + const payload = await readJsonResponse(response); + if (!response.ok) { + const message = payload && payload.message ? payload.message : `Request failed with status ${response.status}.`; + fail(message); + } + + return parseResponsePayload(payload); +} + +function findSkillRootBySkillMd(baseDir) { + const queue = [baseDir]; + const skillMdDirs = []; + + while (queue.length) { + const current = queue.shift(); + const entries = fs.readdirSync(current, { withFileTypes: true }); + let hasSkillMd = false; + + for (const entry of entries) { + if (entry.isFile() && entry.name.toLowerCase() === 'skill.md') { + hasSkillMd = true; + break; + } + } + if (hasSkillMd) { + skillMdDirs.push(current); + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + queue.push(path.join(current, entry.name)); + } + } + + if (skillMdDirs.length === 0) { + fail('Invalid package: SKILL.md not found.'); + } + + skillMdDirs.sort((a, b) => { + const depthDiff = a.split(path.sep).length - b.split(path.sep).length; + if (depthDiff !== 0) { + return depthDiff; + } + return a.localeCompare(b); + }); + + return skillMdDirs[0]; +} + +function ensureZipBuffer(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 2) { + fail('Download failed: package is not a zip archive.'); + } + if (buffer[0] !== 0x50 || buffer[1] !== 0x4b) { + fail('Download failed: package is not a zip archive.'); + } +} + +async function downloadArchive(downloadUrl) { + let response; + try { + response = await fetch(downloadUrl); + } catch (error) { + fail(`Failed to download package: ${error.message}`); + } + + if (!response.ok) { + fail(`Failed to download package: HTTP ${response.status}`); + } + + const buffer = await response.buffer(); + ensureZipBuffer(buffer); + return buffer; +} + +function extractArchive(buffer, tempDir) { + try { + const zip = new AdmZip(buffer); + zip.extractAllTo(tempDir, true); + } catch (error) { + fail(`Failed to extract zip: ${error.message}`); + } +} + +function installFromSkillRoot(skillRoot, targetDir) { + if (fs.existsSync(targetDir)) { + fail(`Target directory already exists: ${targetDir}`); + } + fs.mkdirSync(path.dirname(targetDir), { recursive: true }); + fs.cpSync(skillRoot, targetDir, { recursive: true }); +} + +async function runInstall(positionals, options) { + const slug = positionals[0]; + if (!slug) { + fail('Usage: doraemon-skills install [--server ] [--dir ]'); + } + + const server = resolveServer(options); + const installRoot = resolveInstallRoot(options.dir); + const meta = await requestInstallMeta(server, slug); + + if (meta.installable === false) { + fail(`Skill is not installable: ${meta.reason || 'unknown reason'}`); + } + if (meta.packageType !== 'zip') { + fail(`Unsupported packageType: ${meta.packageType}`); + } + if (meta.packageRootMode !== 'find-skill-md') { + fail(`Unsupported packageRootMode: ${meta.packageRootMode}`); + } + if (!meta.downloadUrl) { + fail('install-meta missing downloadUrl.'); + } + if (!meta.installDirName) { + fail('install-meta missing installDirName.'); + } + + const downloadUrl = new URL(meta.downloadUrl, server).toString(); + const targetDir = path.resolve(installRoot, meta.installDirName); + + console.log(`Downloading: ${downloadUrl}`); + const buffer = await downloadArchive(downloadUrl); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'doraemon-skills-')); + try { + extractArchive(buffer, tempDir); + const skillRoot = findSkillRootBySkillMd(tempDir); + installFromSkillRoot(skillRoot, targetDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + + console.log(`Installed: ${meta.slug || slug} -> ${targetDir}`); +} + +function runList(options) { + const installRoot = resolveInstallRoot(options.dir); + if (!fs.existsSync(installRoot)) { + return; + } + + const names = fs + .readdirSync(installRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b)); + + for (const name of names) { + console.log(name); + } +} + +async function main() { + const argv = process.argv.slice(2); + const { command, positionals, options } = parseArgs(argv); + + if (!command) { + fail('Usage: doraemon-skills ...'); + } + + if (command === 'install') { + await runInstall(positionals, options); + return; + } + if (command === 'list') { + runList(options); + return; + } + + fail(`Unknown command: ${command}`); +} + +main().catch((error) => { + console.error(`Error: ${error.message}`); + process.exitCode = 1; +}); From 26f30d483884a41c3d249c76517d832a93ebe4ce Mon Sep 17 00:00:00 2001 From: huaiju Date: Sat, 21 Mar 2026 17:55:26 +0800 Subject: [PATCH 30/43] feat(skills): modalize detail and add cli install panel --- app/web/api/url.ts | 5 + .../skills/detail/SkillDetailContent.tsx | 735 ++++++++++++++++++ app/web/pages/skills/detail/index.tsx | 581 +------------- app/web/pages/skills/detail/style.scss | 8 + app/web/pages/skills/index.tsx | 43 +- app/web/pages/skills/style.scss | 13 + app/web/pages/skills/types.ts | 15 + 7 files changed, 823 insertions(+), 577 deletions(-) create mode 100644 app/web/pages/skills/detail/SkillDetailContent.tsx diff --git a/app/web/api/url.ts b/app/web/api/url.ts index 33cb838..3cae9e4 100644 --- a/app/web/api/url.ts +++ b/app/web/api/url.ts @@ -361,6 +361,11 @@ export default { method: 'get', url: '/api/skills/file-content', }, + // 获取 Skill 安装元信息 + getSkillInstallMeta: { + method: 'get', + url: '/api/skills/install-meta', + }, // 下载 Skill 目录压缩包 downloadSkillArchive: { method: 'get', diff --git a/app/web/pages/skills/detail/SkillDetailContent.tsx b/app/web/pages/skills/detail/SkillDetailContent.tsx new file mode 100644 index 0000000..cb54edb --- /dev/null +++ b/app/web/pages/skills/detail/SkillDetailContent.tsx @@ -0,0 +1,735 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import SyntaxHighlighter from 'react-syntax-highlighter'; +import { atomOneLight } from 'react-syntax-highlighter/dist/cjs/styles/hljs'; +import { + ArrowLeftOutlined, + CopyOutlined, + DownloadOutlined, + FolderOpenOutlined, + LinkOutlined, + ReadOutlined, + ShareAltOutlined, + StarOutlined, +} from '@ant-design/icons'; +import { + Button, + Card, + Col, + Empty, + Row, + Space, + Spin, + Tabs, + Tag, + Tree, + Typography, +} from 'antd'; +import type { DataNode } from 'antd/lib/tree'; + +import { API } from '@/api'; +import MarkdownRenderer from '@/components/markdownRenderer'; +import { copyToClipboard } from '@/utils/copyUtils'; +import { SkillDetail, SkillFileContent, SkillInstallMeta, SkillItem } from '../types'; +import './style.scss'; + +const { Title, Text, Paragraph } = Typography; +const { TabPane } = Tabs; + +interface SkillTreeNode extends DataNode { + children?: SkillTreeNode[]; +} + +interface FrontmatterItem { + key: string; + value: string; +} + +interface SkillDetailContentProps { + slug: string; + mode?: 'page' | 'modal'; + history?: any; + onClose?: () => void; + onOpenSkill?: (nextSlug: string) => void; +} + +const formatFileSize = (size = 0) => { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / 1024 / 1024).toFixed(1)} MB`; +}; + +const sortTreeNodes = (nodes: SkillTreeNode[]) => { + nodes.sort((a, b) => { + const aIsLeaf = Boolean(a.isLeaf); + const bIsLeaf = Boolean(b.isLeaf); + if (aIsLeaf !== bIsLeaf) return aIsLeaf ? 1 : -1; + return String(a.title).localeCompare(String(b.title)); + }); + nodes.forEach((node) => { + if (node.children && node.children.length > 0) { + sortTreeNodes(node.children); + } + }); +}; + +const buildFileTreeData = (fileList: string[]): SkillTreeNode[] => { + const treeData: SkillTreeNode[] = []; + + fileList.forEach((filePath) => { + const segments = filePath.split('/').filter(Boolean); + let currentNodes = treeData; + let currentPath = ''; + + segments.forEach((segment, index) => { + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + const isLeaf = index === segments.length - 1; + let node = currentNodes.find((item) => item.key === currentPath); + + if (!node) { + node = { + key: currentPath, + title: segment, + isLeaf, + children: isLeaf ? undefined : [], + }; + currentNodes.push(node); + } + + if (!isLeaf) { + node.children = node.children || []; + currentNodes = node.children; + } + }); + }); + + sortTreeNodes(treeData); + return treeData; +}; + +const normalizeSourceUrl = (sourceRepo: string) => { + if (!sourceRepo) return ''; + const normalized = sourceRepo.replace(/^git\+/, '').trim(); + const sshMatch = normalized.match(/^git@([^:]+):(.+?)(?:\.git)?$/); + if (sshMatch) { + return `https://${sshMatch[1]}/${sshMatch[2]}`; + } + if (/^https?:\/\//.test(normalized)) { + return normalized.replace(/\.git$/, ''); + } + return ''; +}; + +const normalizeFrontmatterValue = (value: string) => { + const trimmed = String(value || '').trim(); + if (!trimmed) return '-'; + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +}; + +const parseMarkdownFrontmatter = (markdown = ''): { frontmatter: FrontmatterItem[]; body: string } => { + const content = String(markdown || ''); + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); + if (!match) { + return { + frontmatter: [], + body: content, + }; + } + + const block = match[1] || ''; + const lines = block.split(/\r?\n/); + const frontmatter: FrontmatterItem[] = []; + let currentKey = ''; + let currentValueLines: string[] = []; + + const pushCurrent = () => { + if (!currentKey) return; + const value = normalizeFrontmatterValue(currentValueLines.join('\n')); + frontmatter.push({ key: currentKey, value }); + }; + + lines.forEach((line) => { + const keyMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/); + const isTopLevelKey = Boolean(keyMatch) && !/^\s/.test(line); + + if (isTopLevelKey && keyMatch) { + pushCurrent(); + currentKey = keyMatch[1]; + currentValueLines = [keyMatch[2] || '']; + return; + } + + if (currentKey) { + currentValueLines.push(line); + } + }); + + pushCurrent(); + + return { + frontmatter, + body: content.slice(match[0].length), + }; +}; + +const formatDownloadCommand = (downloadUrl = '', fileName = 'skill.zip') => { + if (!downloadUrl) return ''; + return `curl -L "${downloadUrl}" -o ${fileName}`; +}; + +const SkillDetailContent: React.FC = ({ + slug, + mode = 'page', + history, + onClose, + onOpenSkill, +}) => { + const [loading, setLoading] = useState(true); + const [fileLoading, setFileLoading] = useState(false); + const [detail, setDetail] = useState(null); + const [installMeta, setInstallMeta] = useState(null); + const [related, setRelated] = useState([]); + const [selectedFilePath, setSelectedFilePath] = useState(''); + const [fileContent, setFileContent] = useState(null); + + const isModal = mode === 'modal'; + const fileTreeData = useMemo(() => buildFileTreeData(detail?.fileList || []), [detail?.fileList]); + const sourceUrl = useMemo(() => normalizeSourceUrl(detail?.sourceRepo || ''), [detail?.sourceRepo]); + const downloadPath = useMemo(() => `/api/skills/download?slug=${encodeURIComponent(slug)}`, [slug]); + const deepLinkPath = useMemo(() => `/page/skills/${encodeURIComponent(slug)}`, [slug]); + const currentOrigin = useMemo(() => { + if (typeof window === 'undefined') return ''; + return window.location.origin; + }, []); + const deepLinkUrl = useMemo(() => { + if (!currentOrigin) return deepLinkPath; + return `${currentOrigin}${deepLinkPath}`; + }, [currentOrigin, deepLinkPath]); + const serverArg = currentOrigin || ''; + const skillInstallCommand = useMemo( + () => `doraemon-skills install ${slug} --server ${serverArg}`, + [slug, serverArg] + ); + const cliInstallPlaceholderCommand = + '# 待提供:Doraemon CLI 安装脚本 URL(例如 curl -fsSL <...> | bash)'; + const archiveFileName = useMemo(() => { + const rawName = detail?.name || slug || 'skill'; + const normalized = rawName + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, ''); + return `${normalized || 'skill'}.zip`; + }, [detail?.name, slug]); + const downloadCommand = useMemo(() => { + if (installMeta?.downloadUrl) { + return formatDownloadCommand(installMeta.downloadUrl, archiveFileName); + } + if (!currentOrigin) { + return `curl -L "${downloadPath}" -o ${archiveFileName}`; + } + return formatDownloadCommand(`${currentOrigin}${downloadPath}`, archiveFileName); + }, [archiveFileName, currentOrigin, downloadPath, installMeta?.downloadUrl]); + const agentInstruction = useMemo( + () => + [ + '请先检查 Doraemon CLI 是否已安装(例如执行 doraemon-skills --help)。', + '若未安装,请先执行 Human 区的 Doraemon CLI 安装步骤(当前安装脚本 URL 待提供)。', + `安装当前技能:${skillInstallCommand}`, + '若安装目录不明确,请先询问用户,可使用 --dir 指定目标目录。', + ].join('\n'), + [skillInstallCommand] + ); + const isInstallable = Boolean(installMeta?.installable); + const installUnavailableReason = installMeta?.reason || 'install-meta 暂不可用'; + + useEffect(() => { + let cancelled = false; + + const loadDetail = async () => { + setLoading(true); + try { + const [detailRes, relatedRes, installMetaRes] = await Promise.all([ + API.getSkillDetail({ slug }), + API.getRelatedSkills({ slug, limit: 6 }), + API.getSkillInstallMeta({ slug }), + ]); + + if (cancelled) return; + + if (detailRes.success) { + const detailData = detailRes.data as SkillDetail; + setDetail(detailData); + const defaultFile = detailData.fileList.includes('SKILL.md') + ? 'SKILL.md' + : detailData.fileList[0] || ''; + setSelectedFilePath(defaultFile); + } else { + setDetail(null); + setSelectedFilePath(''); + } + + if (relatedRes.success) { + setRelated(relatedRes.data || []); + } else { + setRelated([]); + } + + if (installMetaRes.success) { + setInstallMeta(installMetaRes.data as SkillInstallMeta); + } else { + setInstallMeta(null); + } + } catch (error) { + console.error('获取 Skill 详情失败:', error); + if (!cancelled) { + setDetail(null); + setRelated([]); + setInstallMeta(null); + setSelectedFilePath(''); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + loadDetail(); + + return () => { + cancelled = true; + }; + }, [slug]); + + useEffect(() => { + if (!selectedFilePath) { + setFileContent(null); + return; + } + + let cancelled = false; + const loadFileContent = async () => { + setFileLoading(true); + try { + const response = await API.getSkillFileContent({ + slug, + path: selectedFilePath, + }); + if (!cancelled) { + if (response.success) { + setFileContent(response.data as SkillFileContent); + } else { + setFileContent(null); + } + } + } catch (error) { + console.error('获取文件内容失败:', error); + if (!cancelled) { + setFileContent(null); + } + } finally { + if (!cancelled) { + setFileLoading(false); + } + } + }; + + loadFileContent(); + + return () => { + cancelled = true; + }; + }, [slug, selectedFilePath]); + + const renderFileViewer = () => { + if (fileLoading) { + return ( +
+ +
+ ); + } + + if (!fileContent) { + return ; + } + + if (fileContent.isBinary) { + return ; + } + + if (fileContent.language === 'markdown') { + const parsedMarkdown = parseMarkdownFrontmatter(fileContent.content || ''); + return ( +
+ {parsedMarkdown.frontmatter.length > 0 ? ( +
+ + + {parsedMarkdown.frontmatter.map((item) => { + const isCodeStyle = + item.value.includes('\n') || + item.value.startsWith('{') || + item.value.startsWith('['); + return ( + + + + + ); + })} + +
{item.key} + {isCodeStyle ? ( +
+                                                            {item.value}
+                                                        
+ ) : ( + {item.value} + )} +
+
+ ) : null} + {parsedMarkdown.body.trim() ? ( + + ) : null} +
+ ); + } + + return ( + + {fileContent.content || ''} + + ); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!detail) { + return ( +
+ + {isModal ? ( + + ) : ( + + )} + +
+ ); + } + + return ( +
+
+ {!isModal ? ( + + ) : null} +
+ {detail.name} + + {detail.description || '暂无描述'} + + slug: {detail.slug} +
+
+ + + + + Stars: {detail.stars || 0} + + 分类: {detail.category || '未分类'} + 更新: {new Date(detail.updatedAt).toLocaleString('zh-CN')} + 来源: {detail.sourceRepo || detail.sourcePath} + +
+ {(detail.tags || []).map((tag) => ( + {tag} + ))} +
+
+ + + + + + 文件浏览 + + } + bodyStyle={{ padding: '8px 0', maxHeight: 280, overflow: 'auto' }} + > + {fileTreeData.length === 0 ? ( + + ) : ( + { + if (!info.node.isLeaf) return; + const targetPath = String(keys[0] || ''); + if (targetPath) { + setSelectedFilePath(targetPath); + } + }} + /> + )} + + + + + {selectedFilePath || '文件预览'} + + } + extra={ + fileContent ? ( + + {fileContent.language} + {formatFileSize(fileContent.size)} + + ) : null + } + > + {renderFileViewer()} + + + + + + + + {isInstallable ? ( +
+ 先安装 Doraemon CLI(脚本地址当前待提供) +
+ {cliInstallPlaceholderCommand} +
+ 安装当前技能 +
+ {skillInstallCommand} +
+
+ ) : ( +
+ 当前技能暂不支持 Doraemon CLI 直接安装 + + {`原因:${installUnavailableReason}`} + +
+ {downloadCommand} +
+ + 请下载 zip 后手动解压到目标 skills 目录,再按本地流程接入。 + +
+ )} +
+ + {isInstallable ? ( +
+ 复制以下指令给 Agent 执行 +
+ {agentInstruction} +
+
+ ) : ( +
+ Agent 请走降级路径 +
+ + {[ + '当前 skill 不支持 doraemon-skills 直接安装。', + `请先下载 zip:${downloadCommand}`, + `原因:${installUnavailableReason}`, + '然后手动解压并确认技能目录结构(需包含 SKILL.md)。', + ].join('\n')} + +
+
+ )} +
+
+
+ + manual}> +
+ +
+ {downloadCommand} +
+ + 下载完整技能目录,包含 SKILL.md 与所有相关文件 + +
+
+ + + + + + + + +
+ + + {related.length === 0 ? ( + + ) : ( + + {related.map((item) => ( + + { + if (isModal && onOpenSkill) { + onOpenSkill(item.slug); + return; + } + if (history) { + history.push(`/page/skills/${item.slug}`); + } + }} + > +
{item.name}
+ + {item.description || '暂无描述'} + + + {item.stars || 0} + +
+ + ))} +
+ )} +
+
+ ); +}; + +export default SkillDetailContent; diff --git a/app/web/pages/skills/detail/index.tsx b/app/web/pages/skills/detail/index.tsx index 7080008..ca12c8c 100644 --- a/app/web/pages/skills/detail/index.tsx +++ b/app/web/pages/skills/detail/index.tsx @@ -1,581 +1,10 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import SyntaxHighlighter from 'react-syntax-highlighter'; -import { atomOneLight } from 'react-syntax-highlighter/dist/cjs/styles/hljs'; -import { - ArrowLeftOutlined, - CopyOutlined, - DownloadOutlined, - FolderOpenOutlined, - LinkOutlined, - ReadOutlined, - ShareAltOutlined, - StarOutlined, -} from '@ant-design/icons'; -import { Button, Card, Col, Empty, Radio, Row, Space, Spin, Tag, Tree, Typography } from 'antd'; -import type { DataNode } from 'antd/lib/tree'; +import React from 'react'; -import { API } from '@/api'; -import MarkdownRenderer from '@/components/markdownRenderer'; -import { copyToClipboard } from '@/utils/copyUtils'; -import { SkillDetail as SkillDetailType, SkillFileContent, SkillItem } from '../types'; -import './style.scss'; +import SkillDetailContent from './SkillDetailContent'; -const { Title, Text, Paragraph } = Typography; - -interface SkillTreeNode extends DataNode { - children?: SkillTreeNode[]; -} - -type InstallRuntime = 'npx' | 'bunx' | 'pnpm'; - -interface FrontmatterItem { - key: string; - value: string; -} - -const formatFileSize = (size = 0) => { - if (size < 1024) return `${size} B`; - if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; - return `${(size / 1024 / 1024).toFixed(1)} MB`; -}; - -const sortTreeNodes = (nodes: SkillTreeNode[]) => { - nodes.sort((a, b) => { - const aIsLeaf = Boolean(a.isLeaf); - const bIsLeaf = Boolean(b.isLeaf); - if (aIsLeaf !== bIsLeaf) return aIsLeaf ? 1 : -1; - return String(a.title).localeCompare(String(b.title)); - }); - nodes.forEach((node) => { - if (node.children && node.children.length > 0) { - sortTreeNodes(node.children); - } - }); -}; - -const buildFileTreeData = (fileList: string[]): SkillTreeNode[] => { - const treeData: SkillTreeNode[] = []; - - fileList.forEach((filePath) => { - const segments = filePath.split('/').filter(Boolean); - let currentNodes = treeData; - let currentPath = ''; - - segments.forEach((segment, index) => { - currentPath = currentPath ? `${currentPath}/${segment}` : segment; - const isLeaf = index === segments.length - 1; - let node = currentNodes.find((item) => item.key === currentPath); - - if (!node) { - node = { - key: currentPath, - title: segment, - isLeaf, - children: isLeaf ? undefined : [], - }; - currentNodes.push(node); - } - - if (!isLeaf) { - node.children = node.children || []; - currentNodes = node.children; - } - }); - }); - - sortTreeNodes(treeData); - return treeData; -}; - -const normalizeSourceUrl = (sourceRepo: string) => { - if (!sourceRepo) return ''; - const normalized = sourceRepo.replace(/^git\+/, '').trim(); - const sshMatch = normalized.match(/^git@([^:]+):(.+?)(?:\.git)?$/); - if (sshMatch) { - return `https://${sshMatch[1]}/${sshMatch[2]}`; - } - if (/^https?:\/\//.test(normalized)) { - return normalized.replace(/\.git$/, ''); - } - return ''; -}; - -const getInstallArgs = (detail: SkillDetailType) => { - const installCommand = detail.installCommand || ''; - const addPattern = /(?:npx|bunx|pnpm\s+dlx)\s+skills\s+add\s+(.+)$/i; - const addMatch = installCommand.match(addPattern); - if (addMatch && addMatch[1]) { - return addMatch[1].trim(); - } - if (detail.sourceRepo) { - return `${detail.sourceRepo} --skill "${detail.name}"`; - } - return ''; -}; - -const getInstallCommandByRuntime = ( - runtime: InstallRuntime, - detail: SkillDetailType, - installArgs: string -) => { - if (!installArgs) return detail.installCommand || ''; - if (runtime === 'bunx') return `bunx skills add ${installArgs}`; - if (runtime === 'pnpm') return `pnpm dlx skills add ${installArgs}`; - return `npx skills add ${installArgs}`; -}; - -const normalizeFrontmatterValue = (value: string) => { - const trimmed = String(value || '').trim(); - if (!trimmed) return '-'; - if ( - (trimmed.startsWith('"') && trimmed.endsWith('"')) || - (trimmed.startsWith("'") && trimmed.endsWith("'")) - ) { - return trimmed.slice(1, -1); - } - return trimmed; -}; - -const parseMarkdownFrontmatter = (markdown = ''): { frontmatter: FrontmatterItem[]; body: string } => { - const content = String(markdown || ''); - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); - if (!match) { - return { - frontmatter: [], - body: content, - }; - } - - const block = match[1] || ''; - const lines = block.split(/\r?\n/); - const frontmatter: FrontmatterItem[] = []; - let currentKey = ''; - let currentValueLines: string[] = []; - - const pushCurrent = () => { - if (!currentKey) return; - const value = normalizeFrontmatterValue(currentValueLines.join('\n')); - frontmatter.push({ key: currentKey, value }); - }; - - lines.forEach((line) => { - const keyMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/); - const isTopLevelKey = Boolean(keyMatch) && !/^\s/.test(line); - - if (isTopLevelKey && keyMatch) { - pushCurrent(); - currentKey = keyMatch[1]; - currentValueLines = [keyMatch[2] || '']; - return; - } - - if (currentKey) { - currentValueLines.push(line); - } - }); - - pushCurrent(); - - return { - frontmatter, - body: content.slice(match[0].length), - }; -}; - -const SkillDetail: React.FC = ({ history, match }) => { +const SkillDetailPage: React.FC = ({ history, match }) => { const { slug } = match.params; - const [loading, setLoading] = useState(true); - const [fileLoading, setFileLoading] = useState(false); - const [detail, setDetail] = useState(null); - const [related, setRelated] = useState([]); - const [selectedFilePath, setSelectedFilePath] = useState(''); - const [fileContent, setFileContent] = useState(null); - const [installRuntime, setInstallRuntime] = useState('npx'); - - const fileTreeData = useMemo(() => buildFileTreeData(detail?.fileList || []), [detail?.fileList]); - const sourceUrl = useMemo(() => normalizeSourceUrl(detail?.sourceRepo || ''), [detail?.sourceRepo]); - const installArgs = useMemo(() => (detail ? getInstallArgs(detail) : ''), [detail]); - const installCommand = useMemo(() => { - if (!detail) return ''; - return getInstallCommandByRuntime(installRuntime, detail, installArgs); - }, [detail, installArgs, installRuntime]); - - const downloadPath = useMemo(() => `/api/skills/download?slug=${encodeURIComponent(slug)}`, [slug]); - const archiveFileName = useMemo(() => { - const rawName = detail?.name || slug || 'skill'; - const normalized = rawName - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, '-') - .replace(/^-+|-+$/g, ''); - return `${normalized || 'skill'}.zip`; - }, [detail?.name, slug]); - const wgetCommand = useMemo(() => { - if (typeof window === 'undefined') return ''; - return `wget "${window.location.origin}${downloadPath}" -O ${archiveFileName}`; - }, [archiveFileName, downloadPath]); - - useEffect(() => { - let cancelled = false; - - const loadDetail = async () => { - setLoading(true); - try { - const [detailRes, relatedRes] = await Promise.all([ - API.getSkillDetail({ slug }), - API.getRelatedSkills({ slug, limit: 6 }), - ]); - - if (cancelled) return; - - if (detailRes.success) { - const detailData = detailRes.data as SkillDetailType; - setDetail(detailData); - const defaultFile = detailData.fileList.includes('SKILL.md') - ? 'SKILL.md' - : detailData.fileList[0] || ''; - setSelectedFilePath(defaultFile); - } else { - setDetail(null); - setSelectedFilePath(''); - } - - if (relatedRes.success) { - setRelated(relatedRes.data || []); - } - } catch (error) { - console.error('获取 Skill 详情失败:', error); - } finally { - if (!cancelled) { - setLoading(false); - } - } - }; - - loadDetail(); - - return () => { - cancelled = true; - }; - }, [slug]); - - useEffect(() => { - if (!selectedFilePath) { - setFileContent(null); - return; - } - - let cancelled = false; - const loadFileContent = async () => { - setFileLoading(true); - try { - const response = await API.getSkillFileContent({ - slug, - path: selectedFilePath, - }); - if (!cancelled) { - if (response.success) { - setFileContent(response.data as SkillFileContent); - } else { - setFileContent(null); - } - } - } catch (error) { - console.error('获取文件内容失败:', error); - if (!cancelled) { - setFileContent(null); - } - } finally { - if (!cancelled) { - setFileLoading(false); - } - } - }; - - loadFileContent(); - - return () => { - cancelled = true; - }; - }, [slug, selectedFilePath]); - - const renderFileViewer = () => { - if (fileLoading) { - return ( -
- -
- ); - } - - if (!fileContent) { - return ; - } - - if (fileContent.isBinary) { - return ; - } - - if (fileContent.language === 'markdown') { - const parsedMarkdown = parseMarkdownFrontmatter(fileContent.content || ''); - return ( -
- {parsedMarkdown.frontmatter.length > 0 ? ( -
- - - {parsedMarkdown.frontmatter.map((item) => { - const isCodeStyle = - item.value.includes('\n') || - item.value.startsWith('{') || - item.value.startsWith('['); - return ( - - - - - ); - })} - -
{item.key} - {isCodeStyle ? ( -
-                                                            {item.value}
-                                                        
- ) : ( - {item.value} - )} -
-
- ) : null} - {parsedMarkdown.body.trim() ? ( - - ) : null} -
- ); - } - - return ( - - {fileContent.content || ''} - - ); - }; - - if (loading) { - return ( -
- -
- ); - } - - if (!detail) { - return ( -
- - - -
- ); - } - - return ( -
-
- -
- {detail.name} - - {detail.description || '暂无描述'} - -
-
- - - - - Stars: {detail.stars || 0} - - 分类: {detail.category || '未分类'} - 更新: {new Date(detail.updatedAt).toLocaleString('zh-CN')} - 来源: {detail.sourceRepo || detail.sourcePath} - -
- {(detail.tags || []).map((tag) => ( - {tag} - ))} -
-
- - - - - - 文件浏览 - - } - bodyStyle={{ padding: '8px 0', maxHeight: 280, overflow: 'auto' }} - > - {fileTreeData.length === 0 ? ( - - ) : ( - { - if (!info.node.isLeaf) return; - const targetPath = String(keys[0] || ''); - if (targetPath) { - setSelectedFilePath(targetPath); - } - }} - /> - )} - - - - - {selectedFilePath || '文件预览'} - - } - extra={ - fileContent ? ( - - {fileContent.language} - {formatFileSize(fileContent.size)} - - ) : null - } - > - {renderFileViewer()} - - - - - skills.sh}> - setInstallRuntime(event.target.value)} - buttonStyle="solid" - className="runtime-switch" - > - npx - bunx - pnpm - -
- {installCommand || '暂无可用安装命令'} -
-
- - man}> -
- -
- {wgetCommand || `wget "${downloadPath}" -O ${archiveFileName}`} -
- - 下载完整技能目录,包含 SKILL.md 与所有相关文件 - -
-
- - - - - - - - -
- - - {related.length === 0 ? ( - - ) : ( - - {related.map((item) => ( - - history.push(`/page/skills/${item.slug}`)} - > -
{item.name}
- - {item.description || '暂无描述'} - - - {item.stars || 0} - -
- - ))} -
- )} -
-
- ); + return ; }; -export default SkillDetail; +export default SkillDetailPage; diff --git a/app/web/pages/skills/detail/style.scss b/app/web/pages/skills/detail/style.scss index 1d6543e..1b2402f 100644 --- a/app/web/pages/skills/detail/style.scss +++ b/app/web/pages/skills/detail/style.scss @@ -1,5 +1,8 @@ .page-skill-detail { padding: 20px 24px; + &.modal-skill-detail { + padding-top: 8px; + } &.loading-wrap { display: flex; justify-content: center; @@ -121,6 +124,11 @@ .runtime-switch { margin-bottom: 12px; } + .install-tab-panel { + display: flex; + flex-direction: column; + gap: 10px; + } .download-buttons { display: flex; flex-direction: column; diff --git a/app/web/pages/skills/index.tsx b/app/web/pages/skills/index.tsx index 4c14972..9a93f97 100644 --- a/app/web/pages/skills/index.tsx +++ b/app/web/pages/skills/index.tsx @@ -31,6 +31,7 @@ import { import { API } from '@/api'; import { copyToClipboard } from '@/utils/copyUtils'; +import SkillDetailContent from './detail/SkillDetailContent'; import { SkillItem, SkillListResponse } from './types'; import './style.scss'; @@ -66,6 +67,8 @@ const SkillsMarket: React.FC = ({ history }) => { const [uploadFiles, setUploadFiles] = useState([]); const [importForm] = Form.useForm(); const [query, setQuery] = useState(INITIAL_QUERY); + const [detailVisible, setDetailVisible] = useState(false); + const [activeDetailSlug, setActiveDetailSlug] = useState(''); const fetchSkills = useCallback(async (nextQuery) => { setLoading(true); @@ -97,7 +100,17 @@ const SkillsMarket: React.FC = ({ history }) => { }; const handleOpenDetail = (slug: string) => { - history.push(`/page/skills/${slug}`); + setActiveDetailSlug(slug); + setDetailVisible(true); + }; + + const handleCloseDetailModal = () => { + setDetailVisible(false); + }; + + const handleOpenDetailPage = () => { + if (!activeDetailSlug) return; + history.push(`/page/skills/${activeDetailSlug}`); }; const openImportModal = () => { @@ -308,6 +321,34 @@ const SkillsMarket: React.FC = ({ history }) => { )} + + {activeDetailSlug ? ( +
+
+ +
+ setActiveDetailSlug(nextSlug)} + /> +
+ ) : null} +
+ Date: Sun, 22 Mar 2026 13:06:23 +0800 Subject: [PATCH 31/43] feat(skills): add installKey-based cli install flow --- app/controller/skills.js | 4 +- app/service/skills.js | 122 +++++++++-- scripts/doraemon-skills-lib.js | 313 ++++++++++++++++++++++++++++ scripts/doraemon-skills.js | 275 +----------------------- test/doraemon-skills-config.test.js | 38 ++++ test/skills-install-key.test.js | 91 ++++++++ 6 files changed, 554 insertions(+), 289 deletions(-) create mode 100644 scripts/doraemon-skills-lib.js create mode 100644 test/doraemon-skills-config.test.js create mode 100644 test/skills-install-key.test.js diff --git a/app/controller/skills.js b/app/controller/skills.js index e0041e3..8424990 100644 --- a/app/controller/skills.js +++ b/app/controller/skills.js @@ -41,8 +41,8 @@ class SkillsController extends Controller { async getSkillInstallMeta() { const { app, ctx } = this; - const { slug } = ctx.query; - const data = await ctx.service.skills.getInstallMeta(slug); + const identifier = ctx.query.installKey || ctx.query.slug; + const data = await ctx.service.skills.getInstallMeta(identifier); ctx.body = app.utils.response(true, data); } diff --git a/app/service/skills.js b/app/service/skills.js index 54a25a1..710c82b 100644 --- a/app/service/skills.js +++ b/app/service/skills.js @@ -20,6 +20,89 @@ const SKILLS_ROOT_DISCOVER_DEPTH_LIMIT = 8; const DISCOVER_MAX_DIR_COUNT = 3000; const SKILL_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +function sanitizeInstallKeySegment(value) { + return String(value || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/-{2,}/g, '-'); +} + +function createInstallKeyCandidates(skill = {}) { + const candidates = []; + const pushCandidate = (value) => { + const normalized = sanitizeInstallKeySegment(value); + if (normalized && !candidates.includes(normalized)) { + candidates.push(normalized); + } + }; + + pushCandidate(skill.name); + + const sourcePath = String(skill.sourcePath || '').trim().replace(/\\/g, '/'); + if (sourcePath) { + const segments = sourcePath.split('/').filter(Boolean); + if (segments.length > 0) { + pushCandidate(segments[segments.length - 1]); + } + } + + pushCandidate(skill.slug); + pushCandidate('skill'); + return candidates; +} + +function createInstallKeyMap(skills = []) { + const bySlug = new Map(); + const byInstallKey = new Map(); + const counts = new Map(); + const list = skills.map((skill) => { + const candidates = createInstallKeyCandidates(skill); + let installKey = candidates.find((candidate) => !byInstallKey.has(candidate)) || ''; + + if (!installKey) { + const baseKey = candidates[0] || 'skill'; + const nextCount = (counts.get(baseKey) || 1) + 1; + counts.set(baseKey, nextCount); + installKey = `${baseKey}-${nextCount}`; + while (byInstallKey.has(installKey)) { + const currentCount = (counts.get(baseKey) || nextCount) + 1; + counts.set(baseKey, currentCount); + installKey = `${baseKey}-${currentCount}`; + } + } else { + const baseKey = candidates[0] || installKey; + counts.set(baseKey, Math.max(counts.get(baseKey) || 1, 1)); + } + + const normalizedSkill = { + ...skill, + installKey, + }; + bySlug.set(normalizedSkill.slug, normalizedSkill); + byInstallKey.set(installKey, normalizedSkill); + return normalizedSkill; + }); + + return { + list, + bySlug, + byInstallKey, + }; +} + +function resolveSkillIdentifier(identifier, indexes = {}) { + const value = String(identifier || '').trim(); + if (!value) return null; + if (indexes.bySlug instanceof Map && indexes.bySlug.has(value)) { + return indexes.bySlug.get(value); + } + if (indexes.byInstallKey instanceof Map && indexes.byInstallKey.has(value)) { + return indexes.byInstallKey.get(value); + } + return null; +} + const SKILL_CATEGORY_OPTIONS = [ '通用', '前端', @@ -142,6 +225,7 @@ class SkillsService extends Service { toPublicSkill(skill) { return { slug: skill.slug, + installKey: skill.installKey || skill.slug, name: skill.name, description: skill.description, category: skill.category, @@ -160,6 +244,7 @@ class SkillsService extends Service { id: row.id, sourceId: row.source_id, slug: row.slug, + installKey: row.slug, name: row.name || '', description: row.description || '', category: row.category || '通用', @@ -190,13 +275,15 @@ class SkillsService extends Service { ], }); - const skills = rows.map((row) => this.toSkillDto(row)); + const rawSkills = rows.map((row) => this.toSkillDto(row)); + const installKeyMap = createInstallKeyMap(rawSkills); const categories = this.getSkillCategoryOptions(); this.skillCache = { loadedAt: Date.now(), - skills, + skills: installKeyMap.list, categories, - bySlug: new Map(skills.map((item) => [ item.slug, item ])), + bySlug: installKeyMap.bySlug, + byInstallKey: installKeyMap.byInstallKey, }; return this.skillCache; @@ -257,13 +344,17 @@ class SkillsService extends Service { return this.getSkillList(params); } - getSkillBySlug(slug) { - const value = String(slug || '').trim(); + getSkillByIdentifier(identifier) { + const value = String(identifier || '').trim(); if (!value) { this.ctx.throw(400, '缺少技能标识'); } - const skill = this.skillCache.bySlug.get(value); + if (!SKILL_SLUG_PATTERN.test(value)) { + this.ctx.throw(400, '非法技能标识'); + } + + const skill = resolveSkillIdentifier(value, this.skillCache); if (!skill) { this.ctx.throw(404, '技能不存在'); } @@ -273,7 +364,7 @@ class SkillsService extends Service { async getSkillDetail(slug) { await this.ensureSkillCache(); - const skill = this.getSkillBySlug(slug); + const skill = this.getSkillByIdentifier(slug); const { SkillsFile } = this.app.model; const rows = await SkillsFile.findAll({ @@ -295,7 +386,7 @@ class SkillsService extends Service { async getSkillFileContent(slug, filePath) { await this.ensureSkillCache(); - const skill = this.getSkillBySlug(slug); + const skill = this.getSkillByIdentifier(slug); const normalizedPath = this.normalizeRelativePath(filePath); const { SkillsFile } = this.app.model; @@ -329,7 +420,7 @@ class SkillsService extends Service { async getSkillArchive(slug) { await this.ensureSkillCache(); - const skill = this.getSkillBySlug(slug); + const skill = this.getSkillByIdentifier(slug); const { SkillsFile } = this.app.model; const rows = await SkillsFile.findAll({ where: { @@ -355,7 +446,7 @@ class SkillsService extends Service { }; } - validateSkillSlug(slug) { + validateSkillIdentifier(slug) { const value = String(slug || '').trim(); if (!value) { this.ctx.throw(400, '缺少技能标识'); @@ -410,8 +501,8 @@ class SkillsService extends Service { async getInstallMeta(slug) { await this.ensureSkillCache(); - const safeSlug = this.validateSkillSlug(slug); - const skill = this.getSkillBySlug(safeSlug); + const safeIdentifier = this.validateSkillIdentifier(slug); + const skill = this.getSkillByIdentifier(safeIdentifier); const { installable, reason } = await this.getSkillPackageInstallability(skill.id); const downloadUrl = this.buildSkillDownloadUrl(skill.slug); @@ -423,12 +514,13 @@ class SkillsService extends Service { return { slug: skill.slug, + installKey: skill.installKey || skill.slug, name: skill.name, downloadUrl, packageType: 'zip', packageVersion: 'v1', packageRootMode: 'find-skill-md', - installDirName: skill.slug, + installDirName: skill.installKey || skill.slug, version: '', sha256, sourceRepo: skill.sourceRepo || '', @@ -525,7 +617,7 @@ class SkillsService extends Service { async getRelatedSkills(slug, limit = 6) { await this.ensureSkillCache(); - const target = this.skillCache.bySlug.get(String(slug || '').trim()); + const target = this.getSkillByIdentifier(slug); if (!target) { this.ctx.throw(404, '技能不存在'); } @@ -1873,3 +1965,5 @@ class SkillsService extends Service { } module.exports = SkillsService; +module.exports.createInstallKeyMap = createInstallKeyMap; +module.exports.resolveSkillIdentifier = resolveSkillIdentifier; diff --git a/scripts/doraemon-skills-lib.js b/scripts/doraemon-skills-lib.js new file mode 100644 index 0000000..92749c7 --- /dev/null +++ b/scripts/doraemon-skills-lib.js @@ -0,0 +1,313 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const AdmZip = require('adm-zip'); +const fetch = require('node-fetch'); + +function fail(message) { + throw new Error(message); +} + +function normalizeServerUrl(server) { + if (!server) { + fail('Server is required. Provide --server , set DORAEMON_SKILLS_SERVER, or configure ~/.doraemon/skills.json.'); + } + try { + const parsed = new URL(server); + return parsed.toString(); + } catch (error) { + fail(`Invalid server URL: ${server}`); + } +} + +function parseArgs(argv) { + const [command, ...rest] = argv; + const options = {}; + const positionals = []; + + for (let i = 0; i < rest.length; i++) { + const token = rest[i]; + if (token === '--server' || token === '--dir') { + const value = rest[i + 1]; + if (!value || value.startsWith('--')) { + fail(`Missing value for ${token}`); + } + options[token.slice(2)] = value; + i += 1; + continue; + } + if (token.startsWith('--')) { + fail(`Unknown option: ${token}`); + } + positionals.push(token); + } + + return { + command, + positionals, + options, + }; +} + +function resolveInstallRoot(dirOption) { + const root = dirOption || './skills'; + return path.resolve(process.cwd(), root); +} + +function readBootstrapConfigServer({ homedir = os.homedir } = {}) { + const configPath = path.join(homedir(), '.doraemon', 'skills.json'); + if (!fs.existsSync(configPath)) { + return ''; + } + + try { + const payload = JSON.parse(fs.readFileSync(configPath, 'utf8')); + return String(payload && payload.server ? payload.server : '').trim(); + } catch (error) { + fail(`Invalid bootstrap config: ${configPath}`); + } +} + +function resolveServer(options = {}, { env = process.env, homedir = os.homedir } = {}) { + const value = options.server || env.DORAEMON_SKILLS_SERVER || readBootstrapConfigServer({ homedir }); + return normalizeServerUrl(value); +} + +function parseResponsePayload(payload) { + if (!payload || typeof payload !== 'object') { + fail('Invalid server response payload.'); + } + if (payload.success !== true) { + const message = payload.message || payload.msg || 'Request failed.'; + fail(message); + } + return payload.data; +} + +async function readJsonResponse(response) { + const raw = await response.text(); + let payload = null; + try { + payload = raw ? JSON.parse(raw) : null; + } catch (error) { + if (!response.ok) { + fail(`Request failed with status ${response.status}.`); + } + fail('Invalid JSON response.'); + } + return payload; +} + +function buildInstallMetaUrl(server, installKey) { + const metaUrl = new URL('/api/skills/install-meta', server); + metaUrl.searchParams.set('installKey', installKey); + return metaUrl; +} + +async function requestInstallMeta(server, installKey) { + const metaUrl = buildInstallMetaUrl(server, installKey); + + let response; + try { + response = await fetch(metaUrl.toString()); + } catch (error) { + fail(`Failed to request install-meta: ${error.message}`); + } + + const payload = await readJsonResponse(response); + if (!response.ok) { + const message = payload && (payload.message || payload.msg) + ? payload.message || payload.msg + : `Request failed with status ${response.status}.`; + fail(message); + } + + return parseResponsePayload(payload); +} + +function findSkillRootBySkillMd(baseDir) { + const queue = [baseDir]; + const skillMdDirs = []; + + while (queue.length) { + const current = queue.shift(); + const entries = fs.readdirSync(current, { withFileTypes: true }); + let hasSkillMd = false; + + for (const entry of entries) { + if (entry.isFile() && entry.name.toLowerCase() === 'skill.md') { + hasSkillMd = true; + break; + } + } + if (hasSkillMd) { + skillMdDirs.push(current); + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + queue.push(path.join(current, entry.name)); + } + } + + if (skillMdDirs.length === 0) { + fail('Invalid package: SKILL.md not found.'); + } + + skillMdDirs.sort((a, b) => { + const depthDiff = a.split(path.sep).length - b.split(path.sep).length; + if (depthDiff !== 0) { + return depthDiff; + } + return a.localeCompare(b); + }); + + return skillMdDirs[0]; +} + +function ensureZipBuffer(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 2) { + fail('Download failed: package is not a zip archive.'); + } + if (buffer[0] !== 0x50 || buffer[1] !== 0x4b) { + fail('Download failed: package is not a zip archive.'); + } +} + +async function downloadArchive(downloadUrl) { + let response; + try { + response = await fetch(downloadUrl); + } catch (error) { + fail(`Failed to download package: ${error.message}`); + } + + if (!response.ok) { + fail(`Failed to download package: HTTP ${response.status}`); + } + + const buffer = await response.buffer(); + ensureZipBuffer(buffer); + return buffer; +} + +function extractArchive(buffer, tempDir) { + try { + const zip = new AdmZip(buffer); + zip.extractAllTo(tempDir, true); + } catch (error) { + fail(`Failed to extract zip: ${error.message}`); + } +} + +function installFromSkillRoot(skillRoot, targetDir) { + if (fs.existsSync(targetDir)) { + fail(`Target directory already exists: ${targetDir}`); + } + fs.mkdirSync(path.dirname(targetDir), { recursive: true }); + fs.cpSync(skillRoot, targetDir, { recursive: true }); +} + +async function runInstall(positionals, options) { + const installKey = positionals[0]; + if (!installKey) { + fail('Usage: doraemon-skills install [--server ] [--dir ]'); + } + + const server = resolveServer(options); + const installRoot = resolveInstallRoot(options.dir); + const meta = await requestInstallMeta(server, installKey); + + if (meta.installable === false) { + fail(`Skill is not installable: ${meta.reason || 'unknown reason'}`); + } + if (meta.packageType !== 'zip') { + fail(`Unsupported packageType: ${meta.packageType}`); + } + if (meta.packageRootMode !== 'find-skill-md') { + fail(`Unsupported packageRootMode: ${meta.packageRootMode}`); + } + if (!meta.downloadUrl) { + fail('install-meta missing downloadUrl.'); + } + if (!meta.installDirName) { + fail('install-meta missing installDirName.'); + } + + const downloadUrl = new URL(meta.downloadUrl, server).toString(); + const targetDir = path.resolve(installRoot, meta.installDirName); + + console.log(`Downloading: ${downloadUrl}`); + const buffer = await downloadArchive(downloadUrl); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'doraemon-skills-')); + try { + extractArchive(buffer, tempDir); + const skillRoot = findSkillRootBySkillMd(tempDir); + installFromSkillRoot(skillRoot, targetDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + + console.log(`Installed: ${meta.installKey || installKey} -> ${targetDir}`); +} + +function runList(options) { + const installRoot = resolveInstallRoot(options.dir); + if (!fs.existsSync(installRoot)) { + return; + } + + const names = fs + .readdirSync(installRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b)); + + for (const name of names) { + console.log(name); + } +} + +async function main(argv = process.argv.slice(2)) { + const { command, positionals, options } = parseArgs(argv); + + if (!command) { + fail('Usage: doraemon-skills ...'); + } + + if (command === 'install') { + await runInstall(positionals, options); + return; + } + if (command === 'list') { + runList(options); + return; + } + + fail(`Unknown command: ${command}`); +} + +module.exports = { + buildInstallMetaUrl, + downloadArchive, + ensureZipBuffer, + extractArchive, + fail, + findSkillRootBySkillMd, + installFromSkillRoot, + main, + normalizeServerUrl, + parseArgs, + parseResponsePayload, + readBootstrapConfigServer, + readJsonResponse, + requestInstallMeta, + resolveInstallRoot, + resolveServer, + runInstall, + runList, +}; diff --git a/scripts/doraemon-skills.js b/scripts/doraemon-skills.js index 0f75585..66ebc08 100755 --- a/scripts/doraemon-skills.js +++ b/scripts/doraemon-skills.js @@ -1,279 +1,8 @@ #!/usr/bin/env node -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const AdmZip = require('adm-zip'); -const fetch = require('node-fetch'); +const cli = require('./doraemon-skills-lib'); -function fail(message) { - throw new Error(message); -} - -function normalizeServerUrl(server) { - if (!server) { - fail('Server is required. Provide --server or set DORAEMON_SKILLS_SERVER.'); - } - try { - const parsed = new URL(server); - return parsed.toString(); - } catch (error) { - fail(`Invalid server URL: ${server}`); - } -} - -function parseArgs(argv) { - const [command, ...rest] = argv; - const options = {}; - const positionals = []; - - for (let i = 0; i < rest.length; i++) { - const token = rest[i]; - if (token === '--server' || token === '--dir') { - const value = rest[i + 1]; - if (!value || value.startsWith('--')) { - fail(`Missing value for ${token}`); - } - options[token.slice(2)] = value; - i += 1; - continue; - } - if (token.startsWith('--')) { - fail(`Unknown option: ${token}`); - } - positionals.push(token); - } - - return { - command, - positionals, - options, - }; -} - -function resolveInstallRoot(dirOption) { - const root = dirOption || './skills'; - return path.resolve(process.cwd(), root); -} - -function resolveServer(options) { - const value = options.server || process.env.DORAEMON_SKILLS_SERVER; - return normalizeServerUrl(value); -} - -function parseResponsePayload(payload) { - if (!payload || typeof payload !== 'object') { - fail('Invalid server response payload.'); - } - if (payload.success !== true) { - const message = payload.message || 'Request failed.'; - fail(message); - } - return payload.data; -} - -async function readJsonResponse(response) { - const raw = await response.text(); - let payload = null; - try { - payload = raw ? JSON.parse(raw) : null; - } catch (error) { - if (!response.ok) { - fail(`Request failed with status ${response.status}.`); - } - fail('Invalid JSON response.'); - } - return payload; -} - -async function requestInstallMeta(server, slug) { - const metaUrl = new URL('/api/skills/install-meta', server); - metaUrl.searchParams.set('slug', slug); - - let response; - try { - response = await fetch(metaUrl.toString()); - } catch (error) { - fail(`Failed to request install-meta: ${error.message}`); - } - - const payload = await readJsonResponse(response); - if (!response.ok) { - const message = payload && payload.message ? payload.message : `Request failed with status ${response.status}.`; - fail(message); - } - - return parseResponsePayload(payload); -} - -function findSkillRootBySkillMd(baseDir) { - const queue = [baseDir]; - const skillMdDirs = []; - - while (queue.length) { - const current = queue.shift(); - const entries = fs.readdirSync(current, { withFileTypes: true }); - let hasSkillMd = false; - - for (const entry of entries) { - if (entry.isFile() && entry.name.toLowerCase() === 'skill.md') { - hasSkillMd = true; - break; - } - } - if (hasSkillMd) { - skillMdDirs.push(current); - continue; - } - - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - queue.push(path.join(current, entry.name)); - } - } - - if (skillMdDirs.length === 0) { - fail('Invalid package: SKILL.md not found.'); - } - - skillMdDirs.sort((a, b) => { - const depthDiff = a.split(path.sep).length - b.split(path.sep).length; - if (depthDiff !== 0) { - return depthDiff; - } - return a.localeCompare(b); - }); - - return skillMdDirs[0]; -} - -function ensureZipBuffer(buffer) { - if (!Buffer.isBuffer(buffer) || buffer.length < 2) { - fail('Download failed: package is not a zip archive.'); - } - if (buffer[0] !== 0x50 || buffer[1] !== 0x4b) { - fail('Download failed: package is not a zip archive.'); - } -} - -async function downloadArchive(downloadUrl) { - let response; - try { - response = await fetch(downloadUrl); - } catch (error) { - fail(`Failed to download package: ${error.message}`); - } - - if (!response.ok) { - fail(`Failed to download package: HTTP ${response.status}`); - } - - const buffer = await response.buffer(); - ensureZipBuffer(buffer); - return buffer; -} - -function extractArchive(buffer, tempDir) { - try { - const zip = new AdmZip(buffer); - zip.extractAllTo(tempDir, true); - } catch (error) { - fail(`Failed to extract zip: ${error.message}`); - } -} - -function installFromSkillRoot(skillRoot, targetDir) { - if (fs.existsSync(targetDir)) { - fail(`Target directory already exists: ${targetDir}`); - } - fs.mkdirSync(path.dirname(targetDir), { recursive: true }); - fs.cpSync(skillRoot, targetDir, { recursive: true }); -} - -async function runInstall(positionals, options) { - const slug = positionals[0]; - if (!slug) { - fail('Usage: doraemon-skills install [--server ] [--dir ]'); - } - - const server = resolveServer(options); - const installRoot = resolveInstallRoot(options.dir); - const meta = await requestInstallMeta(server, slug); - - if (meta.installable === false) { - fail(`Skill is not installable: ${meta.reason || 'unknown reason'}`); - } - if (meta.packageType !== 'zip') { - fail(`Unsupported packageType: ${meta.packageType}`); - } - if (meta.packageRootMode !== 'find-skill-md') { - fail(`Unsupported packageRootMode: ${meta.packageRootMode}`); - } - if (!meta.downloadUrl) { - fail('install-meta missing downloadUrl.'); - } - if (!meta.installDirName) { - fail('install-meta missing installDirName.'); - } - - const downloadUrl = new URL(meta.downloadUrl, server).toString(); - const targetDir = path.resolve(installRoot, meta.installDirName); - - console.log(`Downloading: ${downloadUrl}`); - const buffer = await downloadArchive(downloadUrl); - - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'doraemon-skills-')); - try { - extractArchive(buffer, tempDir); - const skillRoot = findSkillRootBySkillMd(tempDir); - installFromSkillRoot(skillRoot, targetDir); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - - console.log(`Installed: ${meta.slug || slug} -> ${targetDir}`); -} - -function runList(options) { - const installRoot = resolveInstallRoot(options.dir); - if (!fs.existsSync(installRoot)) { - return; - } - - const names = fs - .readdirSync(installRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort((a, b) => a.localeCompare(b)); - - for (const name of names) { - console.log(name); - } -} - -async function main() { - const argv = process.argv.slice(2); - const { command, positionals, options } = parseArgs(argv); - - if (!command) { - fail('Usage: doraemon-skills ...'); - } - - if (command === 'install') { - await runInstall(positionals, options); - return; - } - if (command === 'list') { - runList(options); - return; - } - - fail(`Unknown command: ${command}`); -} - -main().catch((error) => { +cli.main().catch((error) => { console.error(`Error: ${error.message}`); process.exitCode = 1; }); diff --git a/test/doraemon-skills-config.test.js b/test/doraemon-skills-config.test.js new file mode 100644 index 0000000..417b31e --- /dev/null +++ b/test/doraemon-skills-config.test.js @@ -0,0 +1,38 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const cliLib = require('../scripts/doraemon-skills-lib'); + +test('resolveServer falls back to bootstrap-written config when option and env are absent', () => { + assert.equal(typeof cliLib.resolveServer, 'function'); + assert.equal(typeof cliLib.readBootstrapConfigServer, 'function'); + + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'doraemon-cli-home-')); + const configDir = path.join(tempHome, '.doraemon'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'skills.json'), + JSON.stringify({ server: 'https://doraemon.example.com' }), + 'utf8' + ); + + const server = cliLib.resolveServer({}, { + env: {}, + homedir: () => tempHome, + }); + + assert.equal(server, 'https://doraemon.example.com/'); +}); + +test('buildInstallMetaUrl queries install-meta with installKey instead of exposing slug semantics', () => { + assert.equal(typeof cliLib.buildInstallMetaUrl, 'function'); + + const metaUrl = cliLib.buildInstallMetaUrl('https://doraemon.example.com', 'skill-creator'); + + assert.equal(metaUrl.toString(), 'https://doraemon.example.com/api/skills/install-meta?installKey=skill-creator'); + assert.equal(metaUrl.searchParams.get('installKey'), 'skill-creator'); + assert.equal(metaUrl.searchParams.get('slug'), null); +}); diff --git a/test/skills-install-key.test.js b/test/skills-install-key.test.js new file mode 100644 index 0000000..77e09c0 --- /dev/null +++ b/test/skills-install-key.test.js @@ -0,0 +1,91 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const skillsModule = require('../app/service/skills'); +const SkillsService = skillsModule; + +test('createInstallKeyMap derives user-facing install keys and keeps them unique', () => { + assert.equal(typeof skillsModule.createInstallKeyMap, 'function'); + + const result = skillsModule.createInstallKeyMap([ + { + slug: 'upload-skill-creator-default-skill-creator', + name: 'skill-creator', + sourcePath: 'skills/skill-creator', + }, + { + slug: 'upload-skill-creator-default-skill-creator-2', + name: 'skill creator', + sourcePath: 'skills/skill-creator-alt', + }, + ]); + + assert.equal(result.bySlug.get('upload-skill-creator-default-skill-creator').installKey, 'skill-creator'); + assert.equal(result.bySlug.get('upload-skill-creator-default-skill-creator-2').installKey, 'skill-creator-alt'); + assert.equal(result.byInstallKey.get('skill-creator').slug, 'upload-skill-creator-default-skill-creator'); + assert.equal(result.byInstallKey.get('skill-creator-alt').slug, 'upload-skill-creator-default-skill-creator-2'); +}); + +test('resolveSkillIdentifier accepts installKey without exposing internal slug', () => { + assert.equal(typeof skillsModule.resolveSkillIdentifier, 'function'); + + const skill = skillsModule.resolveSkillIdentifier('skill-creator', { + bySlug: new Map([ + [ + 'upload-skill-creator-default-skill-creator', + { + slug: 'upload-skill-creator-default-skill-creator', + installKey: 'skill-creator', + }, + ], + ]), + byInstallKey: new Map([ + [ + 'skill-creator', + { + slug: 'upload-skill-creator-default-skill-creator', + installKey: 'skill-creator', + }, + ], + ]), + }); + + assert.equal(skill.slug, 'upload-skill-creator-default-skill-creator'); + assert.equal(skill.installKey, 'skill-creator'); +}); + +test('getInstallMeta returns installKey and installDirName aligned to user-facing identifier', async () => { + const service = Object.create(SkillsService.prototype); + service.ctx = { + throw(status, message) { + const error = new Error(message); + error.status = status; + throw error; + }, + }; + service.skillCache = { + bySlug: new Map(), + byInstallKey: new Map(), + }; + service.ensureSkillCache = async () => {}; + service.getSkillPackageInstallability = async () => ({ installable: true, reason: '' }); + service.getSkillArchive = async () => ({ content: Buffer.from('zip-content') }); + service.buildSkillDownloadUrl = (slug) => `https://doraemon.test/api/skills/download?slug=${slug}`; + + const skill = { + id: 1, + slug: 'upload-skill-creator-default-skill-creator', + installKey: 'skill-creator', + name: 'skill-creator', + sourceRepo: '', + }; + service.skillCache.bySlug.set(skill.slug, skill); + service.skillCache.byInstallKey.set(skill.installKey, skill); + + const meta = await service.getInstallMeta('skill-creator'); + + assert.equal(meta.slug, 'upload-skill-creator-default-skill-creator'); + assert.equal(meta.installKey, 'skill-creator'); + assert.equal(meta.installDirName, 'skill-creator'); + assert.equal(meta.downloadUrl, 'https://doraemon.test/api/skills/download?slug=upload-skill-creator-default-skill-creator'); +}); From 635195323bf1611ec700a60008b02ab7d86aa102 Mon Sep 17 00:00:00 2001 From: huaiju Date: Sun, 22 Mar 2026 13:06:41 +0800 Subject: [PATCH 32/43] feat(skills): split lightweight install modal from detail route --- .../skills/detail/SkillDetailContent.tsx | 478 ++++++++++-------- .../detail/SkillSummaryModalContent.tsx | 371 ++++++++++++++ app/web/pages/skills/detail/index.tsx | 2 +- app/web/pages/skills/detail/style.scss | 325 ++++++++++-- app/web/pages/skills/detail/summaryModal.scss | 340 +++++++++++++ app/web/pages/skills/index.tsx | 51 +- app/web/pages/skills/style.scss | 52 +- app/web/pages/skills/types.ts | 2 + app/web/utils/http.ts | 3 +- 9 files changed, 1327 insertions(+), 297 deletions(-) create mode 100644 app/web/pages/skills/detail/SkillSummaryModalContent.tsx create mode 100644 app/web/pages/skills/detail/summaryModal.scss diff --git a/app/web/pages/skills/detail/SkillDetailContent.tsx b/app/web/pages/skills/detail/SkillDetailContent.tsx index cb54edb..58ad383 100644 --- a/app/web/pages/skills/detail/SkillDetailContent.tsx +++ b/app/web/pages/skills/detail/SkillDetailContent.tsx @@ -11,19 +11,7 @@ import { ShareAltOutlined, StarOutlined, } from '@ant-design/icons'; -import { - Button, - Card, - Col, - Empty, - Row, - Space, - Spin, - Tabs, - Tag, - Tree, - Typography, -} from 'antd'; +import { Button, Card, Col, Empty, Row, Space, Spin, Tabs, Tag, Tree, Typography } from 'antd'; import type { DataNode } from 'antd/lib/tree'; import { API } from '@/api'; @@ -46,10 +34,7 @@ interface FrontmatterItem { interface SkillDetailContentProps { slug: string; - mode?: 'page' | 'modal'; - history?: any; - onClose?: () => void; - onOpenSkill?: (nextSlug: string) => void; + history: any; } const formatFileSize = (size = 0) => { @@ -131,7 +116,9 @@ const normalizeFrontmatterValue = (value: string) => { return trimmed; }; -const parseMarkdownFrontmatter = (markdown = ''): { frontmatter: FrontmatterItem[]; body: string } => { +const parseMarkdownFrontmatter = ( + markdown = '' +): { frontmatter: FrontmatterItem[]; body: string } => { const content = String(markdown || ''); const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); if (!match) { @@ -182,13 +169,7 @@ const formatDownloadCommand = (downloadUrl = '', fileName = 'skill.zip') => { return `curl -L "${downloadUrl}" -o ${fileName}`; }; -const SkillDetailContent: React.FC = ({ - slug, - mode = 'page', - history, - onClose, - onOpenSkill, -}) => { +const SkillDetailContent: React.FC = ({ slug, history }) => { const [loading, setLoading] = useState(true); const [fileLoading, setFileLoading] = useState(false); const [detail, setDetail] = useState(null); @@ -197,11 +178,20 @@ const SkillDetailContent: React.FC = ({ const [selectedFilePath, setSelectedFilePath] = useState(''); const [fileContent, setFileContent] = useState(null); - const isModal = mode === 'modal'; - const fileTreeData = useMemo(() => buildFileTreeData(detail?.fileList || []), [detail?.fileList]); - const sourceUrl = useMemo(() => normalizeSourceUrl(detail?.sourceRepo || ''), [detail?.sourceRepo]); - const downloadPath = useMemo(() => `/api/skills/download?slug=${encodeURIComponent(slug)}`, [slug]); + const fileTreeData = useMemo( + () => buildFileTreeData(detail?.fileList || []), + [detail?.fileList] + ); + const sourceUrl = useMemo( + () => normalizeSourceUrl(detail?.sourceRepo || ''), + [detail?.sourceRepo] + ); + const downloadPath = useMemo( + () => `/api/skills/download?slug=${encodeURIComponent(slug)}`, + [slug] + ); const deepLinkPath = useMemo(() => `/page/skills/${encodeURIComponent(slug)}`, [slug]); + const installKey = installMeta?.installKey || detail?.installKey || slug; const currentOrigin = useMemo(() => { if (typeof window === 'undefined') return ''; return window.location.origin; @@ -210,10 +200,9 @@ const SkillDetailContent: React.FC = ({ if (!currentOrigin) return deepLinkPath; return `${currentOrigin}${deepLinkPath}`; }, [currentOrigin, deepLinkPath]); - const serverArg = currentOrigin || ''; const skillInstallCommand = useMemo( - () => `doraemon-skills install ${slug} --server ${serverArg}`, - [slug, serverArg] + () => `doraemon-skills install ${installKey}`, + [installKey] ); const cliInstallPlaceholderCommand = '# 待提供:Doraemon CLI 安装脚本 URL(例如 curl -fsSL <...> | bash)'; @@ -238,9 +227,9 @@ const SkillDetailContent: React.FC = ({ () => [ '请先检查 Doraemon CLI 是否已安装(例如执行 doraemon-skills --help)。', - '若未安装,请先执行 Human 区的 Doraemon CLI 安装步骤(当前安装脚本 URL 待提供)。', + '若未安装,请先执行 Human 区的 Doraemon CLI 安装步骤。', `安装当前技能:${skillInstallCommand}`, - '若安装目录不明确,请先询问用户,可使用 --dir 指定目标目录。', + '若已安装,则直接执行上面的技能安装命令。', ].join('\n'), [skillInstallCommand] ); @@ -253,14 +242,15 @@ const SkillDetailContent: React.FC = ({ const loadDetail = async () => { setLoading(true); try { - const [detailRes, relatedRes, installMetaRes] = await Promise.all([ + const [detailRes, relatedRes] = await Promise.all([ API.getSkillDetail({ slug }), API.getRelatedSkills({ slug, limit: 6 }), - API.getSkillInstallMeta({ slug }), ]); if (cancelled) return; + let nextInstallMeta: SkillInstallMeta | null = null; + if (detailRes.success) { const detailData = detailRes.data as SkillDetail; setDetail(detailData); @@ -268,6 +258,13 @@ const SkillDetailContent: React.FC = ({ ? 'SKILL.md' : detailData.fileList[0] || ''; setSelectedFilePath(defaultFile); + + const installMetaRes = await API.getSkillInstallMeta({ + installKey: detailData.installKey || slug, + }); + if (!cancelled && installMetaRes.success) { + nextInstallMeta = installMetaRes.data as SkillInstallMeta; + } } else { setDetail(null); setSelectedFilePath(''); @@ -279,11 +276,7 @@ const SkillDetailContent: React.FC = ({ setRelated([]); } - if (installMetaRes.success) { - setInstallMeta(installMetaRes.data as SkillInstallMeta); - } else { - setInstallMeta(null); - } + setInstallMeta(nextInstallMeta); } catch (error) { console.error('获取 Skill 详情失败:', error); if (!cancelled) { @@ -414,9 +407,40 @@ const SkillDetailContent: React.FC = ({ ); }; + const renderInstallCommandCard = ({ + title, + description, + command, + copyMessage, + disabled = false, + }: { + title: string; + description?: string; + command: string; + copyMessage: string; + disabled?: boolean; + }) => ( +
+
+ {title} + {description ? {description} : null} +
+
+ {command || '暂无可复制命令'} +
+
+ ); + if (loading) { return ( -
+
); @@ -424,22 +448,50 @@ const SkillDetailContent: React.FC = ({ if (!detail) { return ( -
+
- {isModal ? ( - - ) : ( - - )} +
); } + const detailTags = (detail.tags || []).filter((tag) => tag && tag !== detail.category); + const detailSource = detail.sourceRepo || detail.sourcePath || '-'; + const detailUpdatedAt = detail.updatedAt + ? new Date(detail.updatedAt).toLocaleString('zh-CN') + : '-'; + const agentFallbackInstruction = [ + '当前 skill 不支持 doraemon-skills 直接安装。', + `请先下载 zip:${downloadCommand}`, + `原因:${installUnavailableReason}`, + '然后手动解压并确认技能目录结构(需包含 SKILL.md)。', + ].join('\n'); + const detailMetaItems = [ + { + label: 'Stars', + value: String(detail.stars || 0), + className: 'is-accent', + }, + { + label: '分类', + value: detail.category || '未分类', + }, + { + label: '最近更新', + value: detailUpdatedAt, + }, + { + label: '来源', + value: detailSource, + className: 'is-wide', + }, + ]; + return ( -
-
- {!isModal ? ( +
+
+
- ) : null} -
- {detail.name} - - {detail.description || '暂无描述'} - - slug: {detail.slug} -
-
- - - - Stars: {detail.stars || 0} - - 分类: {detail.category || '未分类'} - 更新: {new Date(detail.updatedAt).toLocaleString('zh-CN')} - 来源: {detail.sourceRepo || detail.sourcePath} - -
- {(detail.tags || []).map((tag) => ( - {tag} - ))} +
+ {detail.category || '未分类'} + 安装标识 · {installKey} +
+ +
+
+ {detail.name} + + {detail.description || '暂无描述'} + +
+ + + + + +
+ +
+ {detailMetaItems.map((item) => ( +
+ {item.label} +
+ {item.label === 'Stars' ? ( + <> + {item.value} + + ) : ( + item.value + )} +
+
+ ))} +
+ + {detailTags.length > 0 ? ( +
+ {detailTags.map((tag) => ( + {tag} + ))} +
+ ) : null}
-
+ + +
+
+ 安装方式 + + {isInstallable + ? '默认展示 Agent 安装路径,并始终基于 installKey 生成用户可见命令。' + : '当前来源暂不支持 Doraemon CLI 直装,保留手动下载与解压的降级路径。'} + +
+ + {isInstallable ? 'CLI 可安装' : '需手动接入'} + +
+ + + +
+ {isInstallable ? ( + <> + + 先确认 Doraemon CLI 可用,再把下面整段提示发给 Agent + 执行。 + + {renderInstallCommandCard({ + title: '发给 Agent 的安装提示', + description: '包含 CLI 检查与技能安装两步说明', + command: agentInstruction, + copyMessage: 'Agent 指令已复制到剪贴板', + })} + + ) : ( + <> + 当前 skill 需要 Agent 走下载降级路径 + {renderInstallCommandCard({ + title: '发给 Agent 的降级说明', + description: installUnavailableReason, + command: agentFallbackInstruction, + copyMessage: '降级指令已复制到剪贴板', + })} + + )} +
+
+ +
+ {isInstallable ? ( + <> + {renderInstallCommandCard({ + title: '先安装 Doraemon CLI', + description: + '当前项目仍使用占位说明,等待统一安装脚本地址补齐', + command: cliInstallPlaceholderCommand, + copyMessage: 'CLI 安装命令已复制到剪贴板', + })} + {renderInstallCommandCard({ + title: '安装当前技能', + description: '复制后可直接在终端执行', + command: skillInstallCommand, + copyMessage: '技能安装命令已复制到剪贴板', + })} + + ) : ( + <> + 当前技能暂不支持 Doraemon CLI 直接安装 + + 原因:{installUnavailableReason} + + {renderInstallCommandCard({ + title: '手动下载命令', + description: '下载 zip 后手动解压到目标 skills 目录', + command: downloadCommand, + copyMessage: '下载命令已复制到剪贴板', + disabled: !downloadCommand, + })} + + )} +
+
+
+
+
@@ -525,124 +700,11 @@ const SkillDetailContent: React.FC = ({ - - - - {isInstallable ? ( -
- 先安装 Doraemon CLI(脚本地址当前待提供) -
- {cliInstallPlaceholderCommand} -
- 安装当前技能 -
- {skillInstallCommand} -
-
- ) : ( -
- 当前技能暂不支持 Doraemon CLI 直接安装 - - {`原因:${installUnavailableReason}`} - -
- {downloadCommand} -
- - 请下载 zip 后手动解压到目标 skills 目录,再按本地流程接入。 - -
- )} -
- - {isInstallable ? ( -
- 复制以下指令给 Agent 执行 -
- {agentInstruction} -
-
- ) : ( -
- Agent 请走降级路径 -
- - {[ - '当前 skill 不支持 doraemon-skills 直接安装。', - `请先下载 zip:${downloadCommand}`, - `原因:${installUnavailableReason}`, - '然后手动解压并确认技能目录结构(需包含 SKILL.md)。', - ].join('\n')} - -
-
- )} -
-
-
- - manual}> + manual} + >
- - - - - - -
@@ -705,15 +745,7 @@ const SkillDetailContent: React.FC = ({ { - if (isModal && onOpenSkill) { - onOpenSkill(item.slug); - return; - } - if (history) { - history.push(`/page/skills/${item.slug}`); - } - }} + onClick={() => history.push(`/page/skills/${item.slug}`)} >
{item.name}
diff --git a/app/web/pages/skills/detail/SkillSummaryModalContent.tsx b/app/web/pages/skills/detail/SkillSummaryModalContent.tsx new file mode 100644 index 0000000..f412f45 --- /dev/null +++ b/app/web/pages/skills/detail/SkillSummaryModalContent.tsx @@ -0,0 +1,371 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { CopyOutlined, LinkOutlined, ShareAltOutlined, StarOutlined } from '@ant-design/icons'; +import { Button, Empty, Spin, Tabs, Tag, Typography } from 'antd'; + +import { API } from '@/api'; +import { copyToClipboard } from '@/utils/copyUtils'; +import { SkillDetail, SkillInstallMeta } from '../types'; +import './summaryModal.scss'; + +const { Paragraph, Text, Title } = Typography; +const { TabPane } = Tabs; + +interface SkillSummaryModalContentProps { + slug: string; + history: any; +} + +const normalizeSourceUrl = (sourceRepo: string) => { + if (!sourceRepo) return ''; + const normalized = sourceRepo.replace(/^git\+/, '').trim(); + const sshMatch = normalized.match(/^git@([^:]+):(.+?)(?:\.git)?$/); + if (sshMatch) { + return `https://${sshMatch[1]}/${sshMatch[2]}`; + } + if (/^https?:\/\//.test(normalized)) { + return normalized.replace(/\.git$/, ''); + } + return ''; +}; + +const formatDownloadCommand = (downloadUrl = '', fileName = 'skill.zip') => { + if (!downloadUrl) return ''; + return `curl -L "${downloadUrl}" -o ${fileName}`; +}; + +const SkillSummaryModalContent: React.FC = ({ slug, history }) => { + const [loading, setLoading] = useState(true); + const [detail, setDetail] = useState(null); + const [installMeta, setInstallMeta] = useState(null); + + const sourceUrl = useMemo( + () => normalizeSourceUrl(detail?.sourceRepo || ''), + [detail?.sourceRepo] + ); + const deepLinkPath = useMemo(() => `/page/skills/${encodeURIComponent(slug)}`, [slug]); + const downloadPath = useMemo( + () => `/api/skills/download?slug=${encodeURIComponent(slug)}`, + [slug] + ); + const installKey = installMeta?.installKey || detail?.installKey || slug; + const currentOrigin = useMemo(() => { + if (typeof window === 'undefined') return ''; + return window.location.origin; + }, []); + const deepLinkUrl = useMemo(() => { + if (!currentOrigin) return deepLinkPath; + return `${currentOrigin}${deepLinkPath}`; + }, [currentOrigin, deepLinkPath]); + const skillInstallCommand = useMemo( + () => `doraemon-skills install ${installKey}`, + [installKey] + ); + const cliInstallPlaceholderCommand = + '# 待提供:Doraemon CLI 安装脚本 URL(例如 curl -fsSL <...> | bash)'; + const archiveFileName = useMemo(() => { + const rawName = detail?.name || slug || 'skill'; + const normalized = rawName + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, ''); + return `${normalized || 'skill'}.zip`; + }, [detail?.name, slug]); + const downloadCommand = useMemo(() => { + if (installMeta?.downloadUrl) { + return formatDownloadCommand(installMeta.downloadUrl, archiveFileName); + } + if (!currentOrigin) { + return `curl -L "${downloadPath}" -o ${archiveFileName}`; + } + return formatDownloadCommand(`${currentOrigin}${downloadPath}`, archiveFileName); + }, [archiveFileName, currentOrigin, downloadPath, installMeta?.downloadUrl]); + const agentInstruction = useMemo( + () => + [ + '请先检查 Doraemon CLI 是否已安装(例如执行 doraemon-skills --help)。', + '若未安装,请先执行 Human 区的 Doraemon CLI 安装步骤。', + `安装当前技能:${skillInstallCommand}`, + '若已安装,则直接执行上面的技能安装命令。', + ].join('\n'), + [skillInstallCommand] + ); + const isInstallable = Boolean(installMeta?.installable); + const installUnavailableReason = installMeta?.reason || 'install-meta 暂不可用'; + + useEffect(() => { + let cancelled = false; + + const loadDetail = async () => { + setLoading(true); + try { + const detailRes = await API.getSkillDetail({ slug }); + if (cancelled) return; + + if (!detailRes.success) { + setDetail(null); + setInstallMeta(null); + return; + } + + const detailData = detailRes.data as SkillDetail; + setDetail(detailData); + + const installMetaRes = await API.getSkillInstallMeta({ + installKey: detailData.installKey || slug, + }); + if (!cancelled) { + setInstallMeta( + installMetaRes.success ? (installMetaRes.data as SkillInstallMeta) : null + ); + } + } catch (error) { + console.error('获取 Skill 弹窗详情失败:', error); + if (!cancelled) { + setDetail(null); + setInstallMeta(null); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + loadDetail(); + + return () => { + cancelled = true; + }; + }, [slug]); + + const renderInstallCommandCard = ({ + title, + description, + command, + copyMessage, + disabled = false, + }: { + title: string; + description?: string; + command: string; + copyMessage: string; + disabled?: boolean; + }) => ( +
+
+ {title} + {description ? {description} : null} +
+
+ {command || '暂无可复制命令'} +
+
+ ); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!detail) { + return ( +
+ +
+ ); + } + + const detailTags = (detail.tags || []).filter((tag) => tag && tag !== detail.category); + const detailSource = detail.sourceRepo || detail.sourcePath || '-'; + const detailUpdatedAt = detail.updatedAt + ? new Date(detail.updatedAt).toLocaleString('zh-CN') + : '-'; + const agentFallbackInstruction = [ + '当前 skill 不支持 doraemon-skills 直接安装。', + `请先下载 zip:${downloadCommand}`, + `原因:${installUnavailableReason}`, + '然后手动解压并确认技能目录结构(需包含 SKILL.md)。', + ].join('\n'); + const detailMetaItems = [ + { + label: 'Stars', + value: String(detail.stars || 0), + className: 'is-accent', + }, + { + label: '最近更新', + value: detailUpdatedAt, + }, + { + label: '来源', + value: detailSource, + className: 'is-wide', + }, + ]; + + return ( +
+
+
+
+
+ {detail.category || '未分类'} + 安装标识 · {installKey} +
+ +
+
+ {detail.name} + + {detail.description || '暂无描述'} + +
+ +
+ 快捷操作 +
+ + + +
+
+
+
+ +
+ 快速概览 +
+ {detailMetaItems.map((item) => ( +
+ {item.label} +
+ {item.label === 'Stars' ? ( + <> + {item.value} + + ) : ( + item.value + )} +
+
+ ))} +
+
+ + {detailTags.length > 0 ? ( +
+ 标签 +
+ {detailTags.map((tag) => ( + {tag} + ))} +
+
+ ) : null} +
+ +
+
+
+ 安装决策 + + {isInstallable + ? '弹窗只保留安装前所需的核心说明,全部命令继续基于 installKey 生成。' + : '当前来源暂不支持 Doraemon CLI 直装,保留下载与手动接入的降级路径。'} + +
+ + {isInstallable ? 'CLI 可安装' : '需手动接入'} + +
+ + + +
+ {isInstallable + ? renderInstallCommandCard({ + title: '发给 Agent 的安装提示', + description: '包含 CLI 检查与技能安装两步说明', + command: agentInstruction, + copyMessage: 'Agent 指令已复制到剪贴板', + }) + : renderInstallCommandCard({ + title: '发给 Agent 的降级说明', + description: installUnavailableReason, + command: agentFallbackInstruction, + copyMessage: '降级指令已复制到剪贴板', + })} +
+
+ +
+ {isInstallable ? ( + <> + {renderInstallCommandCard({ + title: '先安装 Doraemon CLI', + description: + '当前项目仍使用占位说明,等待统一安装脚本地址补齐', + command: cliInstallPlaceholderCommand, + copyMessage: 'CLI 安装命令已复制到剪贴板', + })} + {renderInstallCommandCard({ + title: '安装当前技能', + description: '复制后可直接在终端执行', + command: skillInstallCommand, + copyMessage: '技能安装命令已复制到剪贴板', + })} + + ) : ( + renderInstallCommandCard({ + title: '手动下载命令', + description: `原因:${installUnavailableReason}`, + command: downloadCommand, + copyMessage: '下载命令已复制到剪贴板', + disabled: !downloadCommand, + }) + )} +
+
+
+
+
+
+ ); +}; + +export default SkillSummaryModalContent; diff --git a/app/web/pages/skills/detail/index.tsx b/app/web/pages/skills/detail/index.tsx index ca12c8c..03cbc65 100644 --- a/app/web/pages/skills/detail/index.tsx +++ b/app/web/pages/skills/detail/index.tsx @@ -4,7 +4,7 @@ import SkillDetailContent from './SkillDetailContent'; const SkillDetailPage: React.FC = ({ history, match }) => { const { slug } = match.params; - return ; + return ; }; export default SkillDetailPage; diff --git a/app/web/pages/skills/detail/style.scss b/app/web/pages/skills/detail/style.scss index 1b2402f..6597545 100644 --- a/app/web/pages/skills/detail/style.scss +++ b/app/web/pages/skills/detail/style.scss @@ -1,45 +1,235 @@ .page-skill-detail { padding: 20px 24px; - &.modal-skill-detail { - padding-top: 8px; - } + background: linear-gradient(180deg, #F5F8FC 0%, #F8FAFC 100%); &.loading-wrap { display: flex; justify-content: center; align-items: center; min-height: 420px; } - .detail-header { + .detail-hero { + display: grid; + grid-template-columns: minmax(0, 1.65fr) minmax(320px, 1fr); + gap: 20px; + margin-bottom: 20px; + } + .detail-hero-main, + .install-overview-card, + .file-tree-card, + .file-viewer-card, + .action-card, + .related-card-list { + border: 1px solid #E4ECF5; + border-radius: 18px; + background: #FFF; + box-shadow: 0 16px 36px rgba(31, 45, 61, 0.06); + } + .detail-hero-main { + padding: 24px; + background: linear-gradient(145deg, #FFF 0%, #F5F9FF 100%); + } + .back-btn { + margin-bottom: 18px; + border-radius: 10px; + border-color: #D8E4F1; + color: #35506B; + background: #FFF; + } + .hero-kicker-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-bottom: 18px; + } + .install-key-chip { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid #D6E3F1; + background: #F5F9FF; + color: #42607B; + font-size: 13px; + font-weight: 500; + } + .hero-title-row { display: flex; - align-items: flex-start; justify-content: space-between; - gap: 14px; - margin-bottom: 16px; - .back-btn { - margin-top: 4px; - } + gap: 20px; + margin-bottom: 18px; .title-group { flex: 1; min-width: 0; } + .ant-typography { + margin-bottom: 0; + } + h2.ant-typography { + margin-bottom: 12px; + color: #1F2D3D; + font-size: 32px; + line-height: 1.2; + } } - .meta-card { - margin-bottom: 16px; - .tags-wrap { - margin-top: 12px; + .hero-description { + max-width: 760px; + margin-bottom: 0; + color: #5C7086; + font-size: 15px; + line-height: 1.75; + } + .hero-action-group { + justify-content: flex-end; + align-self: flex-start; + max-width: 320px; + .ant-btn { + border-radius: 10px; + } + } + .hero-meta-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + } + .hero-meta-card { + padding: 16px 18px; + border-radius: 14px; + border: 1px solid #E7EEF6; + background: linear-gradient(180deg, #FFF 0%, #F7FAFD 100%); + min-width: 0; + &.is-accent { + background: linear-gradient(135deg, #EAF3FF 0%, #F7FAFF 100%); + border-color: #D7E6FA; + } + &.is-wide { + grid-column: span 2; + } + } + .hero-meta-label { + display: block; + margin-bottom: 8px; + color: #8A9AAE; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + } + .hero-meta-value { + color: #23384D; + font-size: 15px; + font-weight: 600; + line-height: 1.6; + word-break: break-word; + .anticon { + margin-right: 6px; + color: #D9A441; + } + } + .hero-tag-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; + } + .install-overview-card { + padding: 24px; + overflow: hidden; + .ant-card-body { + padding: 0; + } + } + .install-card-header { + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + } + .install-card-title { + display: block; + margin-bottom: 8px; + color: #1F2D3D; + font-size: 20px; + font-weight: 700; + } + .install-card-caption { + margin-bottom: 0; + color: #60748A; + line-height: 1.7; + } + .install-status-tag { + display: inline-flex; + align-items: center; + height: 32px; + padding: 0 14px; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + white-space: nowrap; + &.is-ready { + color: #1F5FA6; + background: #E8F2FF; + border: 1px solid #CFE0F7; + } + &.is-fallback { + color: #9A6414; + background: #FFF5E8; + border: 1px solid #F4DFC0; + } + } + .install-tabs { + .ant-tabs-nav { + margin-bottom: 16px; + } + .ant-tabs-tab { + padding: 10px 0; + font-weight: 600; } + .ant-tabs-ink-bar { + height: 3px; + border-radius: 999px; + background: #2F7BFF; + } + } + .install-tab-panel { + display: flex; + flex-direction: column; + gap: 12px; + } + .install-command-card { + padding: 14px; + border: 1px solid #E6EDF5; + border-radius: 14px; + background: #F7FAFD; + } + .install-command-header { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; + } + .install-command-title { + color: #29415A; + font-size: 14px; + font-weight: 600; } .detail-main-row { - margin-bottom: 16px; + margin-bottom: 20px; } .file-tree-card { margin-bottom: 16px; + .ant-card-head { + border-bottom-color: #EEF3F8; + } .ant-tree { - padding: 4px 8px 8px; + padding: 4px 10px 10px; } } .file-viewer-card { min-height: 560px; + .ant-card-head { + border-bottom-color: #EEF3F8; + } .ant-card-body { max-height: 640px; overflow: auto; @@ -49,10 +239,10 @@ .frontmatter-table-wrap { margin-bottom: 16px; border: 1px solid #E5EDF6; - border-radius: 10px; + border-radius: 14px; overflow: hidden; background: #FFF; - box-shadow: 0 4px 12px rgba(15, 23, 42, 0.04); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05); } .frontmatter-table { width: 100%; @@ -98,15 +288,15 @@ } .frontmatter-code { margin: 0; + padding: 10px 12px; + border: 1px solid #E4EAF1; + border-radius: 8px; + background: #F7FAFC; + color: #1F2D3D; font-size: 14px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; - color: #1F2D3D; - background: #F7FAFC; - border: 1px solid #E4EAF1; - border-radius: 8px; - padding: 10px 12px; } } } @@ -121,44 +311,49 @@ &:last-child { margin-bottom: 0; } - .runtime-switch { - margin-bottom: 12px; - } - .install-tab-panel { - display: flex; - flex-direction: column; - gap: 10px; + .ant-card-head { + border-bottom-color: #EEF3F8; } - .download-buttons { - display: flex; - flex-direction: column; - gap: 12px; + } + .download-card { + .ant-card-extra { + color: #7B8EA3; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; } } + .download-buttons { + display: flex; + flex-direction: column; + gap: 12px; + } .command-block { display: flex; align-items: stretch; justify-content: space-between; - gap: 8px; - padding: 10px 12px; - border-radius: 6px; - border: 1px solid #E5E7EB; - background: #F8FAFC; + gap: 10px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid #E2EAF3; + background: #FFF; code { flex: 1; + color: #1F2D3D; white-space: pre-wrap; word-break: break-all; - color: #1F2D3D; + font-size: 13px; + line-height: 1.7; } .command-copy-btn { flex: 0 0 auto; - align-self: center; + align-self: flex-start; min-width: 40px; height: 40px; border: 1px solid #D7E1EC; - border-radius: 8px; + border-radius: 10px; background: #FFF; - color: #1F2D3D; + color: #2E4A66; &:hover, &:focus { color: #2F7BFF; @@ -172,8 +367,19 @@ } } .related-card-list { + .ant-card-head { + border-bottom-color: #EEF3F8; + } .related-item-card { height: 100%; + border-radius: 14px; + border: 1px solid #E7EDF5; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; + &:hover { + border-color: #BFD6F2; + box-shadow: 0 12px 24px rgba(31, 45, 61, 0.08); + transform: translateY(-2px); + } .related-title { margin-bottom: 8px; color: #1F2D3D; @@ -182,4 +388,37 @@ } } } + + @media (max-width: 1100px) { + .detail-hero { + grid-template-columns: 1fr; + } + .hero-title-row { + flex-direction: column; + } + .hero-action-group { + max-width: none; + justify-content: flex-start; + } + } + + @media (max-width: 768px) { + padding: 16px; + .detail-hero-main, + .install-overview-card { + padding: 18px; + } + .hero-meta-grid { + grid-template-columns: 1fr; + } + .hero-meta-card.is-wide { + grid-column: span 1; + } + .command-block { + flex-direction: column; + .command-copy-btn { + align-self: flex-end; + } + } + } } diff --git a/app/web/pages/skills/detail/summaryModal.scss b/app/web/pages/skills/detail/summaryModal.scss new file mode 100644 index 0000000..293bc6a --- /dev/null +++ b/app/web/pages/skills/detail/summaryModal.scss @@ -0,0 +1,340 @@ +.skill-summary-modal { + padding: 24px; + background: transparent; + &.skill-summary-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 420px; + } + .skill-summary-shell { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(320px, 1fr); + gap: 22px; + } + .summary-hero-card, + .summary-install-card { + border: 1px solid #E4ECF5; + border-radius: 22px; + background: #FFF; + box-shadow: 0 18px 36px rgba(31, 45, 61, 0.08); + } + .summary-hero-card { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; + background: linear-gradient(180deg, #FFF 0%, #F7FAFD 100%); + } + .summary-hero-main, + .summary-hero-section { + border: 1px solid #E7EEF6; + border-radius: 18px; + background: linear-gradient(145deg, #FFF 0%, #F5F9FF 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); + } + .summary-hero-main { + padding: 24px; + } + .summary-hero-section { + padding: 16px 18px 18px; + } + .summary-tags-section { + background: linear-gradient(180deg, #FFF 0%, #F9FBFE 100%); + } + .summary-section-label { + display: inline-flex; + align-items: center; + margin-bottom: 12px; + color: #6E849C; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + } + .summary-kicker-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-bottom: 18px; + } + .summary-install-key { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid #D6E3F1; + background: #F5F9FF; + color: #42607B; + font-size: 13px; + font-weight: 500; + } + .summary-title-row { + display: flex; + align-items: flex-start; + gap: 20px; + } + .summary-title-group { + flex: 1; + min-width: 0; + .ant-typography { + margin-bottom: 0; + } + h2.ant-typography { + margin-bottom: 12px; + color: #1F2D3D; + font-size: 30px; + line-height: 1.2; + } + } + .summary-description { + max-width: 700px; + margin-bottom: 0; + color: #5C7086; + font-size: 15px; + line-height: 1.75; + } + .summary-action-panel { + flex: 0 0 214px; + padding: 14px; + border: 1px solid #DEE8F3; + border-radius: 16px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, #F3F8FE 100%); + } + .summary-actions { + display: flex; + flex-direction: column; + gap: 10px; + .ant-btn { + width: 100%; + height: 36px; + border-radius: 10px; + } + } + .summary-meta-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + } + .summary-meta-card { + padding: 16px 18px; + border-radius: 14px; + border: 1px solid #E7EEF6; + background: linear-gradient(180deg, #FFF 0%, #F7FAFD 100%); + min-width: 0; + &.is-accent { + background: linear-gradient(135deg, #EAF3FF 0%, #F7FAFF 100%); + border-color: #D7E6FA; + } + &.is-wide { + grid-column: span 2; + } + } + .summary-meta-label { + display: block; + margin-bottom: 8px; + color: #8A9AAE; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + } + .summary-meta-value { + color: #23384D; + font-size: 15px; + font-weight: 600; + line-height: 1.6; + word-break: break-word; + .anticon { + margin-right: 6px; + color: #D9A441; + } + } + .summary-tag-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + .ant-tag { + margin-right: 0; + padding: 4px 10px; + border-radius: 999px; + border-color: #DBE6F2; + color: #48627B; + background: #F7FAFD; + } + } + .summary-install-card { + padding: 24px; + .ant-tabs-nav { + margin-bottom: 16px; + } + .ant-tabs-tab { + padding: 10px 0; + font-weight: 600; + } + .ant-tabs-ink-bar { + height: 3px; + border-radius: 999px; + background: #2F7BFF; + } + } + .summary-install-header { + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + } + .summary-install-title { + display: block; + margin-bottom: 8px; + color: #1F2D3D; + font-size: 20px; + font-weight: 700; + } + .summary-install-caption { + margin-bottom: 0; + color: #60748A; + line-height: 1.7; + } + .summary-status-tag { + display: inline-flex; + align-items: center; + height: 32px; + padding: 0 14px; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + white-space: nowrap; + &.is-ready { + color: #1F5FA6; + background: #E8F2FF; + border: 1px solid #CFE0F7; + } + &.is-fallback { + color: #9A6414; + background: #FFF5E8; + border: 1px solid #F4DFC0; + } + } + .summary-install-panel { + display: flex; + flex-direction: column; + gap: 12px; + } + .summary-command-card { + padding: 16px; + border: 1px solid #E6EDF5; + border-radius: 16px; + background: linear-gradient(180deg, #F8FBFE 0%, #F3F8FD 100%); + } + .summary-command-header { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; + .ant-typography-secondary { + color: #62778E; + } + } + .summary-command-title { + color: #29415A; + font-size: 14px; + font-weight: 600; + } + .summary-command-block { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 58px; + padding: 8px 8px 8px 14px; + border-radius: 13px; + border: 1px solid #E2EAF3; + background: rgba(255, 255, 255, 0.98); + code { + flex: 1; + color: #1F2D3D; + white-space: pre-wrap; + word-break: break-all; + font-size: 13px; + line-height: 1.65; + } + } + .summary-command-copy-btn { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + min-width: 34px; + height: 34px; + padding: 0; + border: 1px solid #D7E1EC; + border-radius: 9px; + background: linear-gradient(180deg, #FFF 0%, #F6FAFE 100%); + color: #2E4A66; + box-shadow: 0 4px 10px rgba(31, 45, 61, 0.06); + &:hover, + &:focus { + color: #2F7BFF; + border-color: #2F7BFF; + background: #F4F9FF; + } + &[disabled] { + border-color: #E5E7EB; + background: #F8FAFC; + } + } + + @media (max-width: 1100px) { + .skill-summary-shell { + grid-template-columns: 1fr; + } + .summary-title-row { + flex-direction: column; + } + .summary-action-panel { + flex-basis: auto; + width: 100%; + } + .summary-actions { + flex-flow: row wrap; + .ant-btn { + width: auto; + min-width: 156px; + } + } + } + + @media (max-width: 768px) { + padding: 16px; + .summary-hero-card, + .summary-install-card { + padding: 18px; + } + .summary-meta-grid { + grid-template-columns: 1fr; + } + .summary-meta-card.is-wide { + grid-column: span 1; + } + .summary-actions { + flex-direction: column; + align-items: stretch; + .ant-btn { + width: 100%; + } + } + .summary-install-header, + .summary-install-header { + flex-direction: column; + } + .summary-command-block { + grid-template-columns: 1fr; + } + .summary-command-copy-btn { + justify-self: end; + } + } +} diff --git a/app/web/pages/skills/index.tsx b/app/web/pages/skills/index.tsx index 9a93f97..7b64f62 100644 --- a/app/web/pages/skills/index.tsx +++ b/app/web/pages/skills/index.tsx @@ -31,7 +31,7 @@ import { import { API } from '@/api'; import { copyToClipboard } from '@/utils/copyUtils'; -import SkillDetailContent from './detail/SkillDetailContent'; +import SkillSummaryModalContent from './detail/SkillSummaryModalContent'; import { SkillItem, SkillListResponse } from './types'; import './style.scss'; @@ -104,13 +104,15 @@ const SkillsMarket: React.FC = ({ history }) => { setDetailVisible(true); }; - const handleCloseDetailModal = () => { - setDetailVisible(false); + const handleNavigateToDetail = (slug: string) => { + history.push(`/page/skills/${slug}`); }; - const handleOpenDetailPage = () => { - if (!activeDetailSlug) return; - history.push(`/page/skills/${activeDetailSlug}`); + const buildSkillInstallCommand = (installKey: string) => + `doraemon-skills install ${installKey}`; + + const handleCloseDetailModal = () => { + setDetailVisible(false); }; const openImportModal = () => { @@ -198,7 +200,11 @@ const SkillsMarket: React.FC = ({ history }) => { onSearch={(value) => updateQueryAndFetch({ keyword: value, pageNum: 1 })} /> - diff --git a/app/web/pages/skills/style.scss b/app/web/pages/skills/style.scss index a36817a..00022a5 100644 --- a/app/web/pages/skills/style.scss +++ b/app/web/pages/skills/style.scss @@ -41,6 +41,16 @@ height: 100%; border-radius: 10px; border: 1px solid #EDF2F7; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; + &:hover, + &:focus { + border-color: #C8DAEE; + box-shadow: 0 14px 32px rgba(31, 45, 61, 0.08); + transform: translateY(-2px); + } + &:focus { + outline: none; + } .ant-card-body { height: 100%; display: flex; @@ -104,14 +114,44 @@ } .skill-detail-modal { + .ant-modal-content { + overflow: hidden; + border-radius: 24px; + box-shadow: 0 24px 60px rgba(31, 45, 61, 0.16); + } + .ant-modal-close { + top: 16px; + right: 16px; + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + line-height: 1; + border: 1px solid #DAE5F0; + border-radius: 50%; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, #F2F7FD 100%); + box-shadow: 0 8px 18px rgba(31, 45, 61, 0.08); + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; + &:hover { + border-color: #BFD3EA; + box-shadow: 0 10px 20px rgba(31, 45, 61, 0.12); + transform: translateY(-1px); + } + } + .ant-modal-close-x { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + font-size: 14px; + color: #48627B; + } .ant-modal-body { - background: #F8FAFC; + background: linear-gradient(180deg, #F4F8FC 0%, #F8FAFC 100%); } .skill-detail-modal-inner { - .skill-detail-modal-actions { - display: flex; - justify-content: flex-end; - padding: 8px 16px 0; - } + padding: 0; } } diff --git a/app/web/pages/skills/types.ts b/app/web/pages/skills/types.ts index b171197..0b09644 100644 --- a/app/web/pages/skills/types.ts +++ b/app/web/pages/skills/types.ts @@ -1,5 +1,6 @@ export interface SkillItem { slug: string; + installKey: string; name: string; description: string; category: string; @@ -38,6 +39,7 @@ export interface SkillFileContent { export interface SkillInstallMeta { slug: string; + installKey: string; name: string; downloadUrl: string; packageType: string; diff --git a/app/web/utils/http.ts b/app/web/utils/http.ts index ec2c23b..6b957fd 100644 --- a/app/web/utils/http.ts +++ b/app/web/utils/http.ts @@ -76,7 +76,8 @@ class Http { .then(authAfterRes) .catch((err: any) => { console.error('错误信息:', JSON.stringify(err)); - this.handleExcept(err); // 开发环境可讲此方法注视 + this.handleExcept(err); + throw err; // 传播错误,让调用方能 catch 到 }); } handleExcept(e: any) { From 93a7d0780f15da90cbe04edb8d10d41b9801fc97 Mon Sep 17 00:00:00 2001 From: huaiju Date: Sun, 22 Mar 2026 19:09:03 +0800 Subject: [PATCH 33/43] feat(skills): support editing metadata and archive updates --- app/controller/skills.js | 37 +- app/model/skills_item.js | 6 + app/router.js | 3 +- app/service/skills.js | 262 +++++- app/web/api/url.ts | 17 +- .../skills/detail/SkillDetailContent.tsx | 850 +++++++++++------- app/web/pages/skills/index.tsx | 397 +++++--- app/web/pages/skills/style.scss | 40 +- app/web/pages/skills/types.ts | 1 + 9 files changed, 1087 insertions(+), 526 deletions(-) diff --git a/app/controller/skills.js b/app/controller/skills.js index 8424990..480ad99 100644 --- a/app/controller/skills.js +++ b/app/controller/skills.js @@ -46,13 +46,6 @@ class SkillsController extends Controller { ctx.body = app.utils.response(true, data); } - async importSkill() { - const { app, ctx } = this; - const params = ctx.request.body || {}; - const data = await ctx.service.skills.importSkill(params); - ctx.body = app.utils.response(true, data); - } - async importSkillFile() { const { app, ctx } = this; const params = ctx.request.body || {}; @@ -80,6 +73,36 @@ class SkillsController extends Controller { } } } + + async updateSkill() { + const { app, ctx } = this; + const params = ctx.request.body || {}; + const files = ctx.request.files + ? Array.isArray(ctx.request.files) + ? ctx.request.files + : [ ctx.request.files ] + : []; + const file = files[0] || null; + + try { + const data = await ctx.service.skills.updateSkill(params, file); + ctx.body = app.utils.response(true, data, '更新成功'); + } finally { + if (file?.filepath && fs.existsSync(file.filepath)) { + try { + fs.unlinkSync(file.filepath); + } catch (error) { + ctx.logger.warn(`[skills] 清理更新上传文件失败: ${error.message}`); + } + } + } + } + + async deleteSkill() { + const { app, ctx } = this; + const data = await ctx.service.skills.deleteSkill(ctx.request.body || {}); + ctx.body = app.utils.response(true, data, '删除成功'); + } } module.exports = SkillsController; diff --git a/app/model/skills_item.js b/app/model/skills_item.js index 26fc209..fcbd1b7 100644 --- a/app/model/skills_item.js +++ b/app/model/skills_item.js @@ -32,6 +32,12 @@ module.exports = (app) => { allowNull: false, defaultValue: '通用', }, + version: { + type: STRING(128), + allowNull: false, + defaultValue: '', + comment: '技能版本号', + }, tags: { type: TEXT('long'), comment: 'JSON字符串数组', diff --git a/app/router.js b/app/router.js index 99d4f59..0e2646d 100644 --- a/app/router.js +++ b/app/router.js @@ -155,8 +155,9 @@ module.exports = (app) => { app.get('/api/skills/file-content', app.controller.skills.getSkillFileContent); app.get('/api/skills/install-meta', app.controller.skills.getSkillInstallMeta); app.get('/api/skills/download', app.controller.skills.downloadSkillArchive); - app.post('/api/skills/import', app.controller.skills.importSkill); app.post('/api/skills/import-file', app.controller.skills.importSkillFile); + app.post('/api/skills/update', app.controller.skills.updateSkill); + app.post('/api/skills/delete', app.controller.skills.deleteSkill); // io.of('/').route('getShellCommand', io.controller.home.getShellCommand) // 暂时close Terminal相关功能 diff --git a/app/service/skills.js b/app/service/skills.js index 710c82b..07fb3ea 100644 --- a/app/service/skills.js +++ b/app/service/skills.js @@ -199,6 +199,7 @@ class SkillsService extends Service { await SkillsSource.sync(); await SkillsItem.sync(); await SkillsFile.sync(); + await this.ensureSkillsItemVersionColumn(); this.storageReady = true; })(); @@ -209,6 +210,19 @@ class SkillsService extends Service { } } + async ensureSkillsItemVersionColumn() { + const queryInterface = this.app.model.getQueryInterface(); + const table = await queryInterface.describeTable('skills_items'); + if (table.version) return; + + await queryInterface.addColumn('skills_items', 'version', { + type: this.app.Sequelize.STRING(128), + allowNull: false, + defaultValue: '', + comment: '技能版本号', + }); + } + parseJsonArray(value) { if (!value) return []; if (Array.isArray(value)) return value; @@ -229,6 +243,7 @@ class SkillsService extends Service { name: skill.name, description: skill.description, category: skill.category, + version: skill.version || '', tags: skill.tags, allowedTools: skill.allowedTools, stars: skill.stars, @@ -248,6 +263,7 @@ class SkillsService extends Service { name: row.name || '', description: row.description || '', category: row.category || '通用', + version: row.version || '', tags: this.parseJsonArray(row.tags), allowedTools: this.parseJsonArray(row.allowed_tools), stars: Number(row.stars) || 0, @@ -521,7 +537,7 @@ class SkillsService extends Service { packageVersion: 'v1', packageRootMode: 'find-skill-md', installDirName: skill.installKey || skill.slug, - version: '', + version: skill.version || '', sha256, sourceRepo: skill.sourceRepo || '', installable, @@ -1284,6 +1300,7 @@ class SkillsService extends Service { const name = String(frontmatter.name || path.basename(skillDir)).trim() || path.basename(skillDir); const description = String(frontmatter.description || this.extractDescription(body)).trim() || this.extractDescription(content); + const version = String(frontmatter.version || '').trim(); const allowedTools = this.parseArrayLike( frontmatter['allowed-tools'] || frontmatter.allowedTools || frontmatter.allowed_tools ); @@ -1301,6 +1318,7 @@ class SkillsService extends Service { name, description, category, + version, tags, allowedTools, updatedAt: stat.mtime, @@ -1454,8 +1472,8 @@ class SkillsService extends Service { if (!fileName || !filePath) { this.ctx.throw(400, '上传文件无效'); } - if (!/\.skill$/i.test(fileName)) { - this.ctx.throw(400, '仅支持上传 .skill 文件'); + if (!/\.zip$/i.test(fileName)) { + this.ctx.throw(400, '仅支持上传 .zip 文件'); } if (!fs.existsSync(filePath)) { this.ctx.throw(400, '上传文件不存在或已失效'); @@ -1475,22 +1493,21 @@ class SkillsService extends Service { const zip = new AdmZip(filePath); zip.extractAllTo(tempDir, true); } catch (error) { - this.ctx.throw(400, `解析 .skill 文件失败: ${error.message}`); + this.ctx.throw(400, `解析 .zip 文件失败: ${error.message}`); } const discoveredSkillDirs = this.discoverSkillDirs(tempDir); if (discoveredSkillDirs.length === 0) { - this.ctx.throw(400, '.skill 包内未发现有效技能(缺少 SKILL.md)'); + this.ctx.throw(400, '.zip 包内未发现有效技能(缺少 SKILL.md)'); } - let skillRecords = discoveredSkillDirs.map((skillDir) => - this.prepareSkillRecord(skillDir, tempDir, parsedSource, category, tags) - ); - - skillRecords = this.filterSkillByName(skillRecords, skillName); - if (skillRecords.length === 0) { - this.ctx.throw(400, '未匹配到指定技能,请检查 skillName 是否正确'); - } + const skillRecords = discoveredSkillDirs.map((skillDir) => { + const record = this.prepareSkillRecord(skillDir, tempDir, parsedSource, category, tags); + if (skillName) { + record.name = skillName; + } + return record; + }); const importedSkills = await this.persistSkillsForSource( sourceRecord.id, @@ -1548,6 +1565,224 @@ class SkillsService extends Service { } } + extractSkillRecordsFromZip(filePath, parsedSource, category, tags, skillName = '') { + let tempDir = ''; + + try { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-upload-')); + + try { + const zip = new AdmZip(filePath); + zip.extractAllTo(tempDir, true); + } catch (error) { + this.ctx.throw(400, `解析 .zip 文件失败: ${error.message}`); + } + + const discoveredSkillDirs = this.discoverSkillDirs(tempDir); + if (discoveredSkillDirs.length === 0) { + this.ctx.throw(400, '.zip 包内未发现有效技能(缺少 SKILL.md)'); + } + + return discoveredSkillDirs.map((skillDir) => { + const record = this.prepareSkillRecord(skillDir, tempDir, parsedSource, category, tags); + if (skillName) { + record.name = skillName; + } + return record; + }); + } finally { + if (tempDir && fs.existsSync(tempDir)) { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch (error) { + this.ctx.logger.warn(`[skills] 清理上传解压目录失败: ${tempDir}, ${error.message}`); + } + } + } + } + + validateVersion(value) { + return String(value || '').trim().slice(0, 128); + } + + async updateSkill(params = {}, file) { + const slug = this.validateSkillIdentifier(params.slug); + const name = String(params.name || '').trim(); + const category = this.normalizeCategory(params.category); + const version = this.validateVersion(params.version); + const tags = this.normalizePlatformTags(params.tags); + + await this.ensureSkillCache(); + const currentSkill = this.getSkillByIdentifier(slug); + await this.ensureStorageReady(); + + if (!name) { + this.ctx.throw(400, '技能名称不能为空'); + } + + const hasZipUpload = Boolean(file?.filename && file?.filepath); + if (hasZipUpload) { + if (!/\.zip$/i.test(String(file.filename || ''))) { + this.ctx.throw(400, '仅支持上传 .zip 文件'); + } + if (!fs.existsSync(String(file.filepath || ''))) { + this.ctx.throw(400, '上传文件不存在或已失效'); + } + } + + const { SkillsItem, SkillsFile } = this.app.model; + + const result = await this.app.model.transaction(async (transaction) => { + const itemRow = await SkillsItem.findOne({ + where: { + id: currentSkill.id, + is_delete: 0, + }, + transaction, + }); + + if (!itemRow) { + this.ctx.throw(404, '技能不存在'); + } + + const payload = { + name, + category, + version, + tags: JSON.stringify(tags || []), + }; + + if (!hasZipUpload) { + await itemRow.update(payload, { transaction }); + return { + slug, + updated: true, + replacedArchive: false, + }; + } + + const parsedSource = this.buildUploadSourceMeta(file.filename); + const skillRecords = this.extractSkillRecordsFromZip( + file.filepath, + parsedSource, + category, + tags.length > 0 ? tags : currentSkill.tags || [], + name + ); + + if (skillRecords.length !== 1) { + this.ctx.throw(400, '编辑时上传的 .zip 包必须且只能包含一个技能目录'); + } + + const nextRecord = skillRecords[0]; + nextRecord.name = name; + nextRecord.category = category; + nextRecord.version = version; + nextRecord.sourceRepo = currentSkill.sourceRepo || nextRecord.sourceRepo; + nextRecord.sourcePath = currentSkill.sourcePath || nextRecord.sourcePath; + nextRecord.tags = tags.length > 0 ? tags : currentSkill.tags || []; + nextRecord.installCommand = + currentSkill.installCommand || + this.getInstallCommand({ + sourceRepo: nextRecord.sourceRepo, + sourceUrl: parsedSource.sourceUrl, + name, + }); + + await itemRow.update( + { + ...payload, + description: nextRecord.description, + allowed_tools: JSON.stringify(nextRecord.allowedTools || []), + updated_at_remote: nextRecord.updatedAt, + source_repo: nextRecord.sourceRepo, + source_path: nextRecord.sourcePath, + skill_md: nextRecord.skillMd, + install_command: nextRecord.installCommand, + file_count: nextRecord.files.length, + }, + { transaction } + ); + + await SkillsFile.destroy({ + where: { + skill_id: itemRow.id, + }, + transaction, + }); + + if (nextRecord.files.length > 0) { + await SkillsFile.bulkCreate( + nextRecord.files.map((fileItem) => ({ + skill_id: itemRow.id, + file_path: fileItem.filePath, + language: fileItem.language, + size: fileItem.size, + is_binary: fileItem.isBinary ? 1 : 0, + encoding: fileItem.encoding, + content: fileItem.content, + updated_at_remote: fileItem.updatedAt, + is_delete: 0, + })), + { transaction } + ); + } + + return { + slug, + updated: true, + replacedArchive: true, + }; + }); + + this.invalidateCache(); + await this.ensureSkillCache(); + return result; + } + + async deleteSkill(params = {}) { + const slug = this.validateSkillIdentifier(params.slug); + await this.ensureSkillCache(); + const skill = this.getSkillByIdentifier(slug); + await this.ensureStorageReady(); + + const { SkillsItem, SkillsFile } = this.app.model; + + await this.app.model.transaction(async (transaction) => { + await SkillsItem.update( + { + is_delete: 1, + }, + { + where: { + id: skill.id, + }, + transaction, + } + ); + + await SkillsFile.update( + { + is_delete: 1, + }, + { + where: { + skill_id: skill.id, + }, + transaction, + } + ); + }); + + this.invalidateCache(); + await this.ensureSkillCache(); + + return { + slug, + deleted: true, + }; + } + async persistSkillsForSource(sourceId, sourceMeta, skillRecords = []) { const { SkillsItem, SkillsFile } = this.app.model; const { Op } = this.app.Sequelize; @@ -1601,6 +1836,7 @@ class SkillsService extends Service { name: record.name, description: record.description, category: record.category, + version: record.version || '', tags: JSON.stringify(record.tags || []), allowed_tools: JSON.stringify(record.allowedTools || []), stars: resolvedStars, diff --git a/app/web/api/url.ts b/app/web/api/url.ts index 3cae9e4..151a191 100644 --- a/app/web/api/url.ts +++ b/app/web/api/url.ts @@ -371,14 +371,19 @@ export default { method: 'get', url: '/api/skills/download', }, - // 导入 Skill - importSkill: { - method: 'post', - url: '/api/skills/import', - }, - // 上传 .skill 文件导入 + // 上传 .zip 文件导入 importSkillFile: { method: 'postForm', url: '/api/skills/import-file', }, + // 编辑 Skill + updateSkill: { + method: 'postForm', + url: '/api/skills/update', + }, + // 删除 Skill + deleteSkill: { + method: 'post', + url: '/api/skills/delete', + }, }; diff --git a/app/web/pages/skills/detail/SkillDetailContent.tsx b/app/web/pages/skills/detail/SkillDetailContent.tsx index 58ad383..f0255f0 100644 --- a/app/web/pages/skills/detail/SkillDetailContent.tsx +++ b/app/web/pages/skills/detail/SkillDetailContent.tsx @@ -1,27 +1,29 @@ import React, { useEffect, useMemo, useState } from 'react'; +import { ArrowLeftOutlined, QuestionCircleOutlined } from '@ant-design/icons'; import SyntaxHighlighter from 'react-syntax-highlighter'; import { atomOneLight } from 'react-syntax-highlighter/dist/cjs/styles/hljs'; -import { - ArrowLeftOutlined, - CopyOutlined, - DownloadOutlined, - FolderOpenOutlined, - LinkOutlined, - ReadOutlined, - ShareAltOutlined, - StarOutlined, -} from '@ant-design/icons'; -import { Button, Card, Col, Empty, Row, Space, Spin, Tabs, Tag, Tree, Typography } from 'antd'; +import { Button, Empty, Spin, Tree, Typography } from 'antd'; import type { DataNode } from 'antd/lib/tree'; import { API } from '@/api'; +import agentIcon from '@/asset/images/skills-detail-figma/agent.svg'; +import chevronDownIcon from '@/asset/images/skills-detail-figma/chevron-down.svg'; +import chevronRightIcon from '@/asset/images/skills-detail-figma/chevron-right.svg'; +import contributorOne from '@/asset/images/skills-detail-figma/contributor-1.png'; +import contributorTwo from '@/asset/images/skills-detail-figma/contributor-2.png'; +import copyDarkIcon from '@/asset/images/skills-detail-figma/copy-dark.svg'; +import downloadIcon from '@/asset/images/skills-detail-figma/download.svg'; +import emptyRelatedIcon from '@/asset/images/skills-detail-figma/empty-related.svg'; +import fileDocIcon from '@/asset/images/skills-detail-figma/file-doc.svg'; +import folderOpenBlueIcon from '@/asset/images/skills-detail-figma/folder-open-blue.svg'; +import heroSkillIcon from '@/asset/images/skills-detail-figma/hero-skill.svg'; +import humanIcon from '@/asset/images/skills-detail-figma/human.svg'; import MarkdownRenderer from '@/components/markdownRenderer'; import { copyToClipboard } from '@/utils/copyUtils'; import { SkillDetail, SkillFileContent, SkillInstallMeta, SkillItem } from '../types'; import './style.scss'; const { Title, Text, Paragraph } = Typography; -const { TabPane } = Tabs; interface SkillTreeNode extends DataNode { children?: SkillTreeNode[]; @@ -37,6 +39,32 @@ interface SkillDetailContentProps { history: any; } +interface FigmaIconProps { + src: string; + className?: string; + alt?: string; +} + +type InstallPanelKey = 'agent' | 'human' | null; + +const relatedSkillIconUrls = [ + 'http://localhost:3845/assets/7abff42ee9b5b6b17c3f0b4350bc40d2918871d6.svg', + 'http://localhost:3845/assets/b2b47e5541ef2b4c45ad4fb4cd42a7290d758d19.svg', + 'http://localhost:3845/assets/46261c14a19d03261c20a3df79aa4c0ed3b263ab.svg', +]; + +const relatedSkillShellClasses = ['is-blue', 'is-green', 'is-orange']; +const browseMarketArrowIcon = 'http://localhost:3845/assets/d1b40a8f52f64c4290b2006b356fc8b61c18d6fc.svg'; + +const FigmaIcon: React.FC = ({ src, className = '', alt = '' }) => ( + +); + const formatFileSize = (size = 0) => { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; @@ -50,6 +78,7 @@ const sortTreeNodes = (nodes: SkillTreeNode[]) => { if (aIsLeaf !== bIsLeaf) return aIsLeaf ? 1 : -1; return String(a.title).localeCompare(String(b.title)); }); + nodes.forEach((node) => { if (node.children && node.children.length > 0) { sortTreeNodes(node.children); @@ -136,8 +165,10 @@ const parseMarkdownFrontmatter = ( const pushCurrent = () => { if (!currentKey) return; - const value = normalizeFrontmatterValue(currentValueLines.join('\n')); - frontmatter.push({ key: currentKey, value }); + frontmatter.push({ + key: currentKey, + value: normalizeFrontmatterValue(currentValueLines.join('\n')), + }); }; lines.forEach((line) => { @@ -169,14 +200,23 @@ const formatDownloadCommand = (downloadUrl = '', fileName = 'skill.zip') => { return `curl -L "${downloadUrl}" -o ${fileName}`; }; +const formatCompactDate = (value?: string) => { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; +}; + const SkillDetailContent: React.FC = ({ slug, history }) => { const [loading, setLoading] = useState(true); const [fileLoading, setFileLoading] = useState(false); const [detail, setDetail] = useState(null); const [installMeta, setInstallMeta] = useState(null); const [related, setRelated] = useState([]); + const [uiSelectedFilePath, setUiSelectedFilePath] = useState(''); const [selectedFilePath, setSelectedFilePath] = useState(''); const [fileContent, setFileContent] = useState(null); + const [activeInstallPanel, setActiveInstallPanel] = useState('agent'); const fileTreeData = useMemo( () => buildFileTreeData(detail?.fileList || []), @@ -190,22 +230,18 @@ const SkillDetailContent: React.FC = ({ slug, history } () => `/api/skills/download?slug=${encodeURIComponent(slug)}`, [slug] ); - const deepLinkPath = useMemo(() => `/page/skills/${encodeURIComponent(slug)}`, [slug]); const installKey = installMeta?.installKey || detail?.installKey || slug; const currentOrigin = useMemo(() => { if (typeof window === 'undefined') return ''; return window.location.origin; }, []); - const deepLinkUrl = useMemo(() => { - if (!currentOrigin) return deepLinkPath; - return `${currentOrigin}${deepLinkPath}`; - }, [currentOrigin, deepLinkPath]); const skillInstallCommand = useMemo( () => `doraemon-skills install ${installKey}`, [installKey] ); + const browseMarketPath = '/page/skills'; const cliInstallPlaceholderCommand = - '# 待提供:Doraemon CLI 安装脚本 URL(例如 curl -fsSL <...> | bash)'; + '# 待补齐 Doraemon CLI 安装脚本 URL,例如 curl -fsSL | bash'; const archiveFileName = useMemo(() => { const rawName = detail?.name || slug || 'skill'; const normalized = rawName @@ -223,18 +259,47 @@ const SkillDetailContent: React.FC = ({ slug, history } } return formatDownloadCommand(`${currentOrigin}${downloadPath}`, archiveFileName); }, [archiveFileName, currentOrigin, downloadPath, installMeta?.downloadUrl]); - const agentInstruction = useMemo( - () => - [ - '请先检查 Doraemon CLI 是否已安装(例如执行 doraemon-skills --help)。', - '若未安装,请先执行 Human 区的 Doraemon CLI 安装步骤。', - `安装当前技能:${skillInstallCommand}`, - '若已安装,则直接执行上面的技能安装命令。', - ].join('\n'), - [skillInstallCommand] + const frontmatterInfo = useMemo( + () => parseMarkdownFrontmatter(detail?.skillMd || ''), + [detail?.skillMd] + ); + const heroMetaItems = useMemo( + () => [ + { + label: '分类', + value: detail?.category || '通用', + }, + { + label: '最近更新', + value: formatCompactDate(detail?.updatedAt), + }, + ], + [detail?.category, detail?.updatedAt] ); const isInstallable = Boolean(installMeta?.installable); - const installUnavailableReason = installMeta?.reason || 'install-meta 暂不可用'; + const manualDownloadUrl = installMeta?.downloadUrl || downloadPath; + const agentTerminalCommand = isInstallable ? skillInstallCommand : downloadCommand; + const heroSummary = useMemo(() => { + const rawText = (detail?.description || '').replace(/\s+/g, ' ').trim(); + if (!rawText) return '暂无描述'; + const sentence = rawText.split(/(?<=[.!?。!?])/)[0]?.trim() || rawText; + return sentence; + }, [detail?.description]); + const handleSelectFile = (nextPath: string) => { + if (!nextPath || nextPath === uiSelectedFilePath) return; + setUiSelectedFilePath(nextPath); + setSelectedFilePath(nextPath); + setFileContent(null); + setFileLoading(true); + }; + + useEffect(() => { + setUiSelectedFilePath(''); + setSelectedFilePath(''); + setFileContent(null); + setFileLoading(false); + setInstallMeta(null); + }, [slug]); useEffect(() => { let cancelled = false; @@ -257,7 +322,10 @@ const SkillDetailContent: React.FC = ({ slug, history } const defaultFile = detailData.fileList.includes('SKILL.md') ? 'SKILL.md' : detailData.fileList[0] || ''; + setUiSelectedFilePath(defaultFile); setSelectedFilePath(defaultFile); + setFileContent(null); + setFileLoading(Boolean(defaultFile)); const installMetaRes = await API.getSkillInstallMeta({ installKey: detailData.installKey || slug, @@ -267,15 +335,11 @@ const SkillDetailContent: React.FC = ({ slug, history } } } else { setDetail(null); + setUiSelectedFilePath(''); setSelectedFilePath(''); } - if (relatedRes.success) { - setRelated(relatedRes.data || []); - } else { - setRelated([]); - } - + setRelated(relatedRes.success ? relatedRes.data || [] : []); setInstallMeta(nextInstallMeta); } catch (error) { console.error('获取 Skill 详情失败:', error); @@ -283,6 +347,7 @@ const SkillDetailContent: React.FC = ({ slug, history } setDetail(null); setRelated([]); setInstallMeta(null); + setUiSelectedFilePath(''); setSelectedFilePath(''); } } finally { @@ -306,6 +371,7 @@ const SkillDetailContent: React.FC = ({ slug, history } } let cancelled = false; + const loadFileContent = async () => { setFileLoading(true); try { @@ -314,11 +380,7 @@ const SkillDetailContent: React.FC = ({ slug, history } path: selectedFilePath, }); if (!cancelled) { - if (response.success) { - setFileContent(response.data as SkillFileContent); - } else { - setFileContent(null); - } + setFileContent(response.success ? (response.data as SkillFileContent) : null); } } catch (error) { console.error('获取文件内容失败:', error); @@ -337,7 +399,7 @@ const SkillDetailContent: React.FC = ({ slug, history } return () => { cancelled = true; }; - }, [slug, selectedFilePath]); + }, [selectedFilePath, slug]); const renderFileViewer = () => { if (fileLoading) { @@ -399,7 +461,7 @@ const SkillDetailContent: React.FC = ({ slug, history } {fileContent.content || ''} @@ -407,32 +469,42 @@ const SkillDetailContent: React.FC = ({ slug, history } ); }; - const renderInstallCommandCard = ({ - title, - description, - command, - copyMessage, - disabled = false, - }: { - title: string; - description?: string; - command: string; - copyMessage: string; - disabled?: boolean; - }) => ( -
-
- {title} - {description ? {description} : null} + const renderInlineCommand = (command: string, copyMessage: string, compact = false) => ( +
+
+
+ {command || '暂无可复制命令'} +
+
+
+ ); + + const renderTerminalCommand = (command: string, copyMessage: string) => ( +
+
+
+ + + +
+ BASH
-
+
+ $ {command || '暂无可复制命令'}
@@ -448,318 +520,406 @@ const SkillDetailContent: React.FC = ({ slug, history } if (!detail) { return ( -
- - +
+ +
); } - const detailTags = (detail.tags || []).filter((tag) => tag && tag !== detail.category); - const detailSource = detail.sourceRepo || detail.sourcePath || '-'; - const detailUpdatedAt = detail.updatedAt - ? new Date(detail.updatedAt).toLocaleString('zh-CN') - : '-'; - const agentFallbackInstruction = [ - '当前 skill 不支持 doraemon-skills 直接安装。', - `请先下载 zip:${downloadCommand}`, - `原因:${installUnavailableReason}`, - '然后手动解压并确认技能目录结构(需包含 SKILL.md)。', - ].join('\n'); - const detailMetaItems = [ - { - label: 'Stars', - value: String(detail.stars || 0), - className: 'is-accent', - }, - { - label: '分类', - value: detail.category || '未分类', - }, - { - label: '最近更新', - value: detailUpdatedAt, - }, - { - label: '来源', - value: detailSource, - className: 'is-wide', - }, - ]; - return (
-
-
- - -
- {detail.category || '未分类'} - 安装标识 · {installKey} +
+ + +
+
+
+
+ +
+ {detail.name} + {heroSummary} +
+
-
- {detailMetaItems.map((item) => ( -
- {item.label} -
- {item.label === 'Stars' ? ( - <> - {item.value} - - ) : ( - item.value - )} +
+
+ + {detail.stars || 0}
+
- ))} -
+
- {detailTags.length > 0 ? ( -
- {detailTags.map((tag) => ( - {tag} +
+ {heroMetaItems.map((item) => ( +
+ {item.label} + {item.value} +
))}
- ) : null} -
- -
-
- 安装方式 - - {isInstallable - ? '默认展示 Agent 安装路径,并始终基于 installKey 生成用户可见命令。' - : '当前来源暂不支持 Doraemon CLI 直装,保留手动下载与解压的降级路径。'} - -
- - {isInstallable ? 'CLI 可安装' : '需手动接入'} - -
+
- - -
- {isInstallable ? ( - <> - - 先确认 Doraemon CLI 可用,再把下面整段提示发给 Agent - 执行。 - - {renderInstallCommandCard({ - title: '发给 Agent 的安装提示', - description: '包含 CLI 检查与技能安装两步说明', - command: agentInstruction, - copyMessage: 'Agent 指令已复制到剪贴板', - })} - - ) : ( - <> - 当前 skill 需要 Agent 走下载降级路径 - {renderInstallCommandCard({ - title: '发给 Agent 的降级说明', - description: installUnavailableReason, - command: agentFallbackInstruction, - copyMessage: '降级指令已复制到剪贴板', - })} - - )} +
+
+
+ + {uiSelectedFilePath || 'SKILL.md'}
- - -
- {isInstallable ? ( - <> - {renderInstallCommandCard({ - title: '先安装 Doraemon CLI', - description: - '当前项目仍使用占位说明,等待统一安装脚本地址补齐', - command: cliInstallPlaceholderCommand, - copyMessage: 'CLI 安装命令已复制到剪贴板', - })} - {renderInstallCommandCard({ - title: '安装当前技能', - description: '复制后可直接在终端执行', - command: skillInstallCommand, - copyMessage: '技能安装命令已复制到剪贴板', - })} - - ) : ( - <> - 当前技能暂不支持 Doraemon CLI 直接安装 - - 原因:{installUnavailableReason} - - {renderInstallCommandCard({ - title: '手动下载命令', - description: '下载 zip 后手动解压到目标 skills 目录', - command: downloadCommand, - copyMessage: '下载命令已复制到剪贴板', - disabled: !downloadCommand, - })} - - )} +
+ {fileLoading ? ( + + + + ) : null} + + +
- - - -
+
- - - - - 文件浏览 - - } - bodyStyle={{ padding: '8px 0', maxHeight: 280, overflow: 'auto' }} - > - {fileTreeData.length === 0 ? ( - - ) : ( - { - if (!info.node.isLeaf) return; - const targetPath = String(keys[0] || ''); - if (targetPath) { - setSelectedFilePath(targetPath); +
+
+ {renderFileViewer()} +
+
+
+
+ + +
); }; diff --git a/app/web/pages/skills/index.tsx b/app/web/pages/skills/index.tsx index 7b64f62..c4f045c 100644 --- a/app/web/pages/skills/index.tsx +++ b/app/web/pages/skills/index.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { - CopyOutlined, - EyeOutlined, + DeleteOutlined, FilterOutlined, ImportOutlined, SearchOutlined, @@ -19,7 +18,6 @@ import { message, Modal, Pagination, - Radio, Row, Select, Space, @@ -30,8 +28,6 @@ import { } from 'antd'; import { API } from '@/api'; -import { copyToClipboard } from '@/utils/copyUtils'; -import SkillSummaryModalContent from './detail/SkillSummaryModalContent'; import { SkillItem, SkillListResponse } from './types'; import './style.scss'; @@ -56,6 +52,38 @@ const INITIAL_QUERY = { pageSize: 12, }; +const EditIcon = () => ( + +); + const SkillsMarket: React.FC = ({ history }) => { const [loading, setLoading] = useState(false); const [skills, setSkills] = useState([]); @@ -63,12 +91,14 @@ const SkillsMarket: React.FC = ({ history }) => { const [total, setTotal] = useState(0); const [importVisible, setImportVisible] = useState(false); const [importing, setImporting] = useState(false); - const [importMode, setImportMode] = useState<'source' | 'file'>('source'); const [uploadFiles, setUploadFiles] = useState([]); + const [editVisible, setEditVisible] = useState(false); + const [editing, setEditing] = useState(false); + const [editUploadFiles, setEditUploadFiles] = useState([]); + const [editingSkill, setEditingSkill] = useState(null); const [importForm] = Form.useForm(); + const [editForm] = Form.useForm(); const [query, setQuery] = useState(INITIAL_QUERY); - const [detailVisible, setDetailVisible] = useState(false); - const [activeDetailSlug, setActiveDetailSlug] = useState(''); const fetchSkills = useCallback(async (nextQuery) => { setLoading(true); @@ -99,61 +129,58 @@ const SkillsMarket: React.FC = ({ history }) => { setQuery(next); }; - const handleOpenDetail = (slug: string) => { - setActiveDetailSlug(slug); - setDetailVisible(true); - }; - - const handleNavigateToDetail = (slug: string) => { - history.push(`/page/skills/${slug}`); - }; - - const buildSkillInstallCommand = (installKey: string) => - `doraemon-skills install ${installKey}`; - - const handleCloseDetailModal = () => { - setDetailVisible(false); - }; - const openImportModal = () => { setImportVisible(true); - setImportMode('source'); setUploadFiles([]); importForm.setFieldsValue({ category: '通用', tags: [], - source: '', }); }; const closeImportModal = () => { if (importing) return; setImportVisible(false); - setImportMode('source'); setUploadFiles([]); importForm.resetFields(); }; + const openEditModal = (skill: SkillItem) => { + setEditingSkill(skill); + setEditVisible(true); + setEditUploadFiles([]); + editForm.setFieldsValue({ + name: skill.name, + category: skill.category || '通用', + tags: skill.tags || [], + version: skill.version || '', + }); + }; + + const closeEditModal = () => { + if (editing) return; + setEditVisible(false); + setEditingSkill(null); + setEditUploadFiles([]); + editForm.resetFields(); + }; + const handleImportSkill = async () => { try { - const values = await importForm.validateFields(); + await importForm.validateFields(); setImporting(true); - let response = null; - if (importMode === 'file') { - const targetFile = uploadFiles[0]?.originFileObj; - if (!targetFile) { - message.error('请先选择 .skill 文件'); - return; - } - response = await API.importSkillFile({ - file: targetFile, - skillName: values.skillName || '', - category: values.category, - tags: JSON.stringify(values.tags || []), - }); - } else { - response = await API.importSkill(values); + const targetFile = uploadFiles[0]?.originFileObj; + if (!targetFile) { + message.error('请先选择 .zip 文件'); + return; } + const values = importForm.getFieldsValue(); + const response = await API.importSkillFile({ + file: targetFile, + skillName: values.skillName || '', + category: values.category, + tags: JSON.stringify(values.tags || []), + }); if (!response.success) { message.error(response.msg || '导入失败'); @@ -167,19 +194,73 @@ const SkillsMarket: React.FC = ({ history }) => { message.success('导入完成,技能可能已存在'); } setImportVisible(false); - setImportMode('source'); setUploadFiles([]); importForm.resetFields(); fetchSkills({ ...query }); } catch (error) { if (error?.errorFields) return; - message.error('导入失败,请检查来源地址或网络权限'); + message.error('导入失败,请检查文件或网络权限'); console.error('导入 Skill 失败:', error); } finally { setImporting(false); } }; + const handleUpdateSkill = async () => { + if (!editingSkill) return; + + try { + const values = await editForm.validateFields(); + setEditing(true); + const targetFile = editUploadFiles[0]?.originFileObj; + const response = await API.updateSkill({ + slug: editingSkill.slug, + name: values.name, + category: values.category, + tags: JSON.stringify(values.tags || []), + version: values.version || '', + file: targetFile, + }); + + if (!response.success) { + message.error(response.msg || '更新失败'); + return; + } + + message.success(targetFile ? '技能已更新并替换 zip 内容' : '技能信息已更新'); + setEditVisible(false); + setEditingSkill(null); + setEditUploadFiles([]); + editForm.resetFields(); + fetchSkills({ ...query }); + } catch (error) { + if (error?.errorFields) return; + message.error('更新失败,请稍后重试'); + console.error('更新 Skill 失败:', error); + } finally { + setEditing(false); + } + }; + + const handleDeleteSkill = (skill: SkillItem) => { + Modal.confirm({ + title: `确认删除「${skill.name}」?`, + content: '删除后该技能将不再出现在列表和详情页中。', + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + const response = await API.deleteSkill({ slug: skill.slug }); + if (!response.success) { + message.error(response.msg || '删除失败'); + return; + } + message.success('删除成功'); + fetchSkills({ ...query }); + }, + }); + }; + return (
@@ -247,21 +328,26 @@ const SkillsMarket: React.FC = ({ history }) => { handleNavigateToDetail(skill.slug)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handleNavigateToDetail(skill.slug); - } - }} - role="button" - tabIndex={0} + onClick={() => history.push(`/page/skills/${skill.slug}`)} >
{skill.name} - - {skill.stars || 0} - +
+ + + {skill.stars || 0} + +
{skill.description || '暂无描述'} @@ -286,33 +372,6 @@ const SkillsMarket: React.FC = ({ history }) => { {tag} ))}
-
- - -
))} @@ -337,23 +396,6 @@ const SkillsMarket: React.FC = ({ history }) => { )} - - {activeDetailSlug ? ( -
- -
- ) : null} -
- = ({ history }) => { destroyOnClose >
- - setImportMode(event.target.value)} + + false} + onChange={(info) => setUploadFiles(info.fileList || [])} > - 来源地址 - 上传 .skill 文件 - + + + + + - + {FIXED_CATEGORY_OPTIONS.map((item) => ( + + ))} + + + { + if (!Array.isArray(value)) return Promise.resolve(); + if (value.length > 5) { + return Promise.reject(new Error('标签最多 5 个')); + } + return Promise.resolve(); + }, + }, + ]} + > + + 删除 + + + + + +
+ } + > + + + + + + false} + onChange={(info) => setEditUploadFiles(info.fileList || [])} + > + + + - {importMode === 'source' ? ( - - 示例:`https://github.com/openclaw/openclaw/tree/main/skills/himalaya` - - ) : ( - - 提示:`.skill` 本质是 zip 包,内部应包含 `技能目录/SKILL.md` - - )}
); diff --git a/app/web/pages/skills/style.scss b/app/web/pages/skills/style.scss index 00022a5..96c63ad 100644 --- a/app/web/pages/skills/style.scss +++ b/app/web/pages/skills/style.scss @@ -62,11 +62,36 @@ justify-content: space-between; margin-bottom: 10px; } + .card-header-actions { + display: inline-flex; + align-items: center; + gap: 10px; + } .skill-name { color: #1F2D3D; font-size: 16px; font-weight: 600; } + .card-edit-trigger { + height: 20px; + width: 20px; + min-width: 20px; + padding: 0; + color: #667085; + font-size: 12px; + line-height: 1; + border: none; + background: transparent; + box-shadow: none; + display: inline-flex; + align-items: center; + justify-content: center; + } + .card-edit-trigger:hover, + .card-edit-trigger:focus { + color: #2F7CF6; + background: transparent; + } .meta-stars { color: #E6A23C; font-weight: 600; @@ -97,14 +122,6 @@ min-height: 28px; margin-bottom: 10px; } - .action-row { - display: flex; - align-items: center; - justify-content: flex-start; - margin-top: auto; - padding-top: 6px; - gap: 8px; - } } .pagination-wrap { display: flex; @@ -155,3 +172,10 @@ padding: 0; } } + +.skill-edit-modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} diff --git a/app/web/pages/skills/types.ts b/app/web/pages/skills/types.ts index 0b09644..8556cf4 100644 --- a/app/web/pages/skills/types.ts +++ b/app/web/pages/skills/types.ts @@ -4,6 +4,7 @@ export interface SkillItem { name: string; description: string; category: string; + version: string; tags: string[]; allowedTools: string[]; stars: number; From 0f072db42f15509667792e16cfa5103d89c35381 Mon Sep 17 00:00:00 2001 From: huaiju Date: Sun, 22 Mar 2026 19:13:35 +0800 Subject: [PATCH 34/43] feat(skills): add figma-based detail page assets and layout styles --- .../font/material-symbols/inter-latin.woff2 | Bin 0 -> 48432 bytes .../font/material-symbols/manrope-latin.woff2 | Bin 0 -> 24576 bytes .../material-symbols-outlined-subset.woff2 | Bin 0 -> 2940 bytes .../material-symbols-outlined-variable.woff2 | 1 + .../material-symbols-outlined.woff2 | 11 + .../images/skills-detail-figma/agent.svg | 5 + .../images/skills-detail-figma/article.svg | 5 + .../skills-detail-figma/check-circle.svg | 3 + .../skills-detail-figma/chevron-down.svg | 5 + .../skills-detail-figma/chevron-right.svg | 5 + .../skills-detail-figma/contributor-1.png | Bin 0 -> 515 bytes .../skills-detail-figma/contributor-2.png | Bin 0 -> 430 bytes .../images/skills-detail-figma/copy-dark.svg | 5 + .../images/skills-detail-figma/copy-light.svg | 5 + .../images/skills-detail-figma/download.svg | 5 + .../skills-detail-figma/empty-related.svg | 5 + .../images/skills-detail-figma/file-doc.svg | 5 + .../skills-detail-figma/folder-open-arrow.svg | 5 + .../skills-detail-figma/folder-open-blue.svg | 5 + .../asset/images/skills-detail-figma/fork.svg | 5 + .../images/skills-detail-figma/hero-skill.svg | 5 + .../images/skills-detail-figma/human.svg | 5 + .../skills-detail-figma/star-outline.svg | 5 + app/web/layouts/basicLayout/index.tsx | 7 +- app/web/layouts/basicLayout/style.scss | 26 +- app/web/pages/skills/detail/stitchAssets.scss | 50 + app/web/pages/skills/detail/style.scss | 1381 +++++++++++++---- app/web/scss/reset.scss | 3 + 28 files changed, 1249 insertions(+), 308 deletions(-) create mode 100644 app/web/asset/font/material-symbols/inter-latin.woff2 create mode 100644 app/web/asset/font/material-symbols/manrope-latin.woff2 create mode 100644 app/web/asset/font/material-symbols/material-symbols-outlined-subset.woff2 create mode 100644 app/web/asset/font/material-symbols/material-symbols-outlined-variable.woff2 create mode 100644 app/web/asset/font/material-symbols/material-symbols-outlined.woff2 create mode 100644 app/web/asset/images/skills-detail-figma/agent.svg create mode 100644 app/web/asset/images/skills-detail-figma/article.svg create mode 100644 app/web/asset/images/skills-detail-figma/check-circle.svg create mode 100644 app/web/asset/images/skills-detail-figma/chevron-down.svg create mode 100644 app/web/asset/images/skills-detail-figma/chevron-right.svg create mode 100644 app/web/asset/images/skills-detail-figma/contributor-1.png create mode 100644 app/web/asset/images/skills-detail-figma/contributor-2.png create mode 100644 app/web/asset/images/skills-detail-figma/copy-dark.svg create mode 100644 app/web/asset/images/skills-detail-figma/copy-light.svg create mode 100644 app/web/asset/images/skills-detail-figma/download.svg create mode 100644 app/web/asset/images/skills-detail-figma/empty-related.svg create mode 100644 app/web/asset/images/skills-detail-figma/file-doc.svg create mode 100644 app/web/asset/images/skills-detail-figma/folder-open-arrow.svg create mode 100644 app/web/asset/images/skills-detail-figma/folder-open-blue.svg create mode 100644 app/web/asset/images/skills-detail-figma/fork.svg create mode 100644 app/web/asset/images/skills-detail-figma/hero-skill.svg create mode 100644 app/web/asset/images/skills-detail-figma/human.svg create mode 100644 app/web/asset/images/skills-detail-figma/star-outline.svg create mode 100644 app/web/pages/skills/detail/stitchAssets.scss diff --git a/app/web/asset/font/material-symbols/inter-latin.woff2 b/app/web/asset/font/material-symbols/inter-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..91dc3e8529921cabc74fb1dacf5253260b9c8cca GIT binary patch literal 48432 zcmY(pV~j9N&?P#yZF}Y!+qP}nwr$(CZQHhOoBMwEX0yB1om6tFQuU*fuFlDElM`hG z00j8YdguXA{&Rr)5C8z(ZT~;^KluM^SfRSufsMjAEB1Wissf5p7JM@QIbAS)J|$%V ze7h7;dk{x{3ufM;)ITr$|tL}W$duF9x_5B7VJm)`iQwa^c3fdC0 zaR1$AtF%E-yr-wx`YZSqcojv`q_UpEnJ$m!DyaZ-bIrDcr6n$5O*Ow{2(*2nkq{Dx zFVS|4cf=72=gujxqF^aI+7y|?MFZsnJZ`ipQ6q7`AU9B}Qbf#{7Vu zYgugrpEip+v5?okK>VuU$>*|i(H*^e1;2M&c)->x@ZofE2_|c}yQH&$&iCJK5|tg` zgsQO>h~0xBbNwvf<;Xi~szO7b5!nPHMMqUWu&f;dMEtHH$Q36#W`+=xhEw$ZXw#t{PG>zB<}tYXepA3qgR}BuW3hnGnoQgn1RW+ z8DDER$by~nW&cq3x~CyOLiUtUkEP$w@_xLx&PYE*TvbSxyZ z5%K8Pz@b6Y*TA{ia`XwBNB?}GnQPtPF==_Qmd6*)I5;1d-r=TFwZ~dam4bw>I#q}?- zI#0v?RhOF|5@W8Ps1?C^12x4#sIW1EJsu0_gNm-(el6bQ^%^6Ul|B$3e+>&cf*%8V z_2cD>VRpb516~EN@WZRq%0HXrjcLe|#y!GCYqdJ{z&sme`AkUp@3Z7-p!3|QB>QsI zlxJ`EwWn+V3mC3Sv(g3f4IUR}ZcV{LZ97PjP4PYqdD)X1Pu*!ARwj>F!*6nqxTc#6&?>MF^wG~pFZ~3dxI`C zUD%hB#q~!NYe~5*IMWn_OVWU>jKqz<+uYCT#or$FC=m$UA7_B^npT1{4YfXM#jrsgQTjz3%c$s^dc^Vyd;UJ00iCm+G%xoQn6+Ttll7W z1cC{II2a+uJQ#t~0B3Lb!)MA^#`WvXr)d7Jd&9%F42J{+5fKtem?Xys!X+}TrL; z*CaKDCQQ)^a)261L=a+(^NA(3GLl$Hw-tXwS<~ra$TnWVPavV|=WX6C;Ere-F+@J0 zNj^nv-e|5M?K5A16C)6Wo|(}WZaTz??1teMsEPWq>{$RlXsw!+F3D49|G(AW| zU-l%7LkHsYZjm0{DyYH&BxSX>-+4Je(rt$#9H0s~O32z0%ewrab_LeJR!H7fB&ZS5 z9?Bk~o{=Y=aB#7GeZ!dT2t&aWvU5VOOFs|1z>gl+8i|S6>SM6kH>JHcM?$Jwa zx9fAa<&Dm-U;C;$hOhnGz{KY{@k#N-DAsQ(&%M362S!@XFVW$on>s~~*2D2bDfTM# zs^IFu%tofRw7OJZ>Ex+5e<2bdfT`Vmvd;&*Q$2C9mn$F4EZ`I7*q=+f}OFXQO9CZ@pi(vP&spbw&xro^5yo53wjSQ4j2F=U#4B0 zs78qkTQEU6Y{c~K{o5X62zi28g26g<>LFxNfAGnJ!ce-fvGO9+#0=CBv|s^iT2+if zn_vO!rob8wfdCf_7ZpU`4hunF0Wos^Yj5=8;Kw~8Zs5dX7e8bQ`qmog)SUkeR$iWL znSyI{hSn=lPXD;L;x{Eqz853rZW9NBjGfi94X~x4BhX=u982%?tX|YyqQN*GVvtmF zrWK&l zj*LC@OU)ak_qDy3 zllo(dZ~7qb%|NOA+Q(sIXx432i?(BYLv9NyqN;O!L$)g4aT_^T2=AnU2gN|`cWeZG z^CW|*)~kgjZ@n_6=8SPKNFd3=gj#y9wLnc5kF`umYb3UmMASQC7_WwjIHqyyu}{Wy zopAOs;z1N$00FM`$NGb_jdS1oK6s$zMkzb$BwWlNaYx-$Q{UISN3Rd&r;;A2u< zU@}IET2O^5zFfTtl`c*?5yI4ylHM}}&BHo>pt);i{sNgN51Qyz-Eut4(1(}+b{G#JQdPcgjiz9rT^?^WB6L@{yfRK z%uf{)o_CzBIjFWu^JCxqZnsI(uLxUk4LR$S>`h+CV6%jV;p!XXg~qTuRGSa7uG-vD z!N0s7N|lGZP}b~Y7Y?*Xb*nox?rh6rLY#zk_fKP#1QVfNQp`;xN8kW!h)~(#1>OiR;_0uH!DbSQLDa} z?1bW2nhbWf-Z9r7y{|SfQ9xh@HVkG>nDiJ7T9A5;SKcc~cJ7~tyFdD!wr8U}xICbUouOlMAYr+q!#U_@KXX2=sNw5bFwi?ecL zp~ed;)lN9`OTYIgJHqxAtPJpYw*h>H{$Bvv=5HaZ%Z$mE0yS!~ zG97Fiw~rY39T2E0g@Vo%k|^Y-W*jZtV2u`%WQo)d3uT1mYN%1Qw=ZI{6^74_(`JGK z5XmfgQ%}gCGlbJxW)!;TYZHOq?cqEFi0gcLYwLCE=$YMMT|b0)kCH^y?G+i=j{H}o z4A(gp&pr0t%vINopABn5x!Vnc5w^YKc&!GsfdHKx;0vdhRe05y1VPledt_e&lQlX^ zp9DzkeC!&PAIYk;V@bn0Ha3zA{De9EC+^|ZJyGA;`PhEUZYX-s3 zBuxMEOGWa~j5MV?#|&|WzUAcxQ~}hziO~%1L#C|Z93#|(J-YL3eF7uyyG@Q4{8}v( z@b43})T7^A|A6|AaEW}q<`eP?XX~TgR%pJtdQY(3gpfPON}@L8TcsHzx6KL59XHk! zrMe;EV92mYZQ?H-RwoU~Fhy*a1c$2vtQ+S(}8^Q!53iYAQxb+UlW#5+9k;7+f= z54AY#NpClT2}_}GuFQ1C7x(?|3*weUmyna_7_~_;ZnN7z4d%6J6_vAWd(UeJHXAGs zRaU|nmh6ucH?3ADS zmwuU!Dh+>oD$BEQ`enWn!rko^riq_Ovz z&3y8D!{`pm^<=|gU|U8{dOmSxX`Zv`tbB4@2b3B-e+4VHG5+LGv7+Oox;3 zS}H;#Q|$t8piZb7mpr|)TP(Z|DEyp)JX@@ZJ?r)gKXvd4=?k3~GIp0WJ>e6s)Zz;Q zCcoGCXoXE2K2ZCYZ;=RvZ0hyCVB>*IX8J$^U3_nThU9JS-*T(_33=ZCy-m{wm0ei8 z#X8-&r!a(Lj`td2CBfw)WXfwFmyCu0BcV6mh1ur`X&4*`CZVBQ46vUY{l*U^=xR&; zp^(SX-DV=<&e83-C?ZAmwQzMt$lpHEsj?zWMmvl9T9|8g>-qL9tf|yd-WVP?Uxp@+ zRkSwV@zhbs(xb|(<<@TM#JOBhLon!iUbmF~^Wtso3&9Yro(tIHl*u|`gj|C{P{@2D zSk$;a!fE4?UFZ3xESloa2F)1w&L*i9InE8P6CSh{XHoB(=(`{+4DphmT*R43dCkB` z$6gBq`7w!E(n=iV8VumB_4oayYRNCjiw^$x&9jOX+`F)Sb^B-ple>~MT{=1g{`Yn3 zGsgX)34XsOk)LLQRS3^qn>$!n#)5PXW`YfmFM2gM^l>E z=Z-kcv?;B!O^U4lkP7!8gxIy`7Zt{9)JnDiCRzVr^I(nvNLuCC06@FT2q0R#1bR> z*>ij0tD@<AtjzuyB*~&-r7*!JS+=ou)7BbvIs_4jjD&dvtMk`xirZ4T zU)TMVYkr5jWgF?^vmZqJ&snxL)5`*5CcyMG|LXK7rp#Y<8&5y%H z;=oT=DYh6*A;|Zukj~tozf_!JqjV&Znb4otgzRAH%aN_6K{&Tsa;l?}GTKZj$d8o- zJ>?Kcc`;4FXa=>kBqTk{KnyHkE&3EOB}_>8gim7Hk({w{p1<)?o^&eUcFLU!*3;Y% zfM%fYu*Fp;?1GrkRRMuR#nmZ<1azDb;DNG_g{m$F9H~zb2be8Hww`7@b}V$5gKtVQ zj|n=8p17bt%0Gr5^hjR=U?CW9%DosoKo(gb6Ex{;T?K#)p5so>RKNu7U^-Wq#oGgbzFe>n`JHHgqBx>1h) zvZ=L+#Wt;X{ultNbnei3jFt`dsa+#jx|+_5wOCqSIoeQF18y$b@RBgy83+!&cD0Hs zY}rjkBlsaL^(2fmXbzSNMh@-NiXyhXoF&LCQ5pl@p(hwx?WaKr#09>BT`(>@9S_l< z0Uv5XL)M%=IE0W!wFR!?v9b4RGIZHlMdlNN6cW@2#flcgmEp#IcAA5c)NI6+{(8~h zBPqh4?v@Nez4hCRkJ6Eo$0_&C(l=tN>Mu~St0eq%C4n1)5``d4FdJ>}`ze~Gcs9OI zf8iO=q;Cl_YL1!xBMAdE6+CqV$b5sXhvLhnF}=<0xl1bXlMViwkggBQLZuDc7#KdCpy zx|@{$*IfUFS0azmH?Q>}4(02fO(cbk{h&&~9stSQO%bxS?db8c=g#R)l>)o*o~Rjy zuLol+B^-xO0#oH;S*HZGtfIhEm&n3pBJqqund_$s>~*%>MD7SUaf|_3z|nm>cE?A2 z6jlbK)&wD9t?&sy%#xhJ14XJHlp}uYZ#`ZYiv#k|M;0kYGs#?o;4SvfO;Bu{8Wsfs z3&{BgThjA9rI^1N^`>rN}1zw7^ zc-?#*;yu#wM8&);7k3%nwY!G-T2pTNZ#=G?{|Yr4`i@K56t)*+mPH(hDY!}IMsVwLPDS1#l#j(n)o?an?lP+9~HS50V}AvcfFu z>iAs~ccf>TzfAMklhfG8$Zt{=spg(d-4&_NE84+f;b%9!Rp(-|kym58MR-Ee)^Ejo zG*xx{Z&QSiBQtq0RR{q2?uF$<#2_Lu94|i+uDAIYr@7}JcdH%!ME~9=ts|tiujm!X zAu5*xr}xGg%%-2^43F0~3_azT9-oe#xj9X$=b%J^k`@?81!t9v;KXlx-CG)@qoix@ zNLx?UBY>`8zN#@QM`dDb?~M=^g2@-_5|M$?HpL&*hl~IkF101ppFd`YHBGHHfXwi3%yJd19$$-H`!FIW+=!BJRfvLgG)g&{Z~PG zVc+Z++CGz=yTlaWjb7-WY!0^rx@CY39ak^P`;@I!V%@~91o?e4p4`_;ie%um{iz`( zK+R1-30jW#H&XEkWF1qw_2bL$`BHPpAaS+VwZ+=WTol)cWKm7P4X-cIgIYU#J?#^^ zag?e#&TtfvoaK{eKrPn3xGwQ)ju87`l1sn#NeJe{ zAliZw6#cKrjsw4C05km^kCuG&b1Ma9B<8goxKW#lnayS`;v0h%!p^L!xeHMz&@jQi zE$|yPz{8)5z)wI`edk5&nMt&zxBqyydX5CGnOnmG?ve8ZCC0cUjl22>B`MgT)IA!2 zl>fjQtu*_z{=m<1VfBhd4h7x8lUZuGz~{T+$*k4aJR<}CLirq54^t|%cjPGyP1Y(U zbap-f(m)yN3axIKtY_=AIWnTpWc~x*o9GVpM$XrPHfE zMuyQb?5Bxr9iI8x7WR7VQ`oySQ%I5X*{peDb@eA>y-0oftQ~D3HLP@<4zx7{zx;wn ze)YCmMtYz%j%~nGnZwHQeF8D+H-)Oj-+QXB6!(qkk&I!NFA4m@U;EpyF1crihZR6m zF+hq|?wC-5zYpes)7xyTyj}ubbsozkk6_5%F55l1G>(3z@<(<~JS?K=pLoetr3URq?b8>8Nc)u-Ex^6DfdG_8}=!vq` zoC%SdBWhQ@)#q(j&d=uRl3L&xZfq`4)_bkidvqU`tHsDzwc$~xN7|;;zsiY3{o!DM zn@>8{N5Wp-LON{NA5;ik`HzlP`ZP;M<+#X4VR%^&Y%OYbHu%Jx9}u?<$LL(2L|nPH zTD1z)Y#wT+`lF{dH=hUq_12CA!)8XQtiHrC1 zFe=g$Q8Df#!)n|^h{A0fy;BP+*`SCKJCW|i`ZB%-J5hG?HBEj%{g3Ji$nwDV} zWz-YuTVbwJG*~C3SEjd$IP`fk9U7rHQ9YVhB1Yrh*7Y}-aj{ZX6?y-3^|Q*hZb9BS zV6Z}2p+OLe4LW951lMFbB>1AUfOkVjMS5!QWdHQZJOD5s0e8A4x;QCaG@zMgf z;`s1+)st^!8rzy9ZB4qK4aR}I->lUQdve1A)kW{*xbOXSXo|aEKJs}qOzCpmER2t& z+gz-*Qte*}qM_;l9S%vwlR+RuqQd0(%VoOY&S0@PF?yR$STGoD2a7G3Oq&qmMcVgq z(f?_{4>`>l9v+$j2S@M|DU$! z35$HvB+eci#1Hgl8hbtT1RIZDN}PulAt}Dep8&f(Ux{ONk1BHe7VXgee52hDmp z4_f0H#e|R&!FiEDh*XFqnc|Bx)h3!CK5K7R?4@5pH`3YDc!dg%fBXQs39?gbY_+e zUr6h$bww}{O0;D};}SbSjz+!3=zu{Rwp0DA(&w`*i&I1OtM>Saj1M$RSzA zjSx!0nN*xeoQdg7YO{mZPSYvIxgTii<_ROI>R59L{!;<+-hRW6%o&7h*wJK7BbHpl zB6C+#cqsnV0SM9gAco>NB5)y53Kq^_Nh5G!L+#xkW7`f$5Fx`Y;|2CODWYcd8rOCo ziO*xn-|-gNn2tRI=8Rg^t!VtX5d9Ve$&qrXSUCg64e&jVj5z-L`Tg0!{}rXsj*u*3 zN~duxUNm+LQ7lLkc{= z(;_c(oTCdX5O_H7@1&s3an)F<*VJqxV zn&W;Ol&b4~?PJ?L9m|I6>2>q@cNFHui&f{O7%ARIyG9@Go9ul{*L{AY&dXR&aW#u_ z2LTeXakj?ecNnYRR^IxGvX}D>!TGML<(>9|JEYObHP&>gIh~4JjzOv-u7yWbC)e5w zNZF=F_Qgf%)xHPY;}7Ai>)-v3QHq|QARkd!C@fZ;0`%pPTloRYuJ5qDLaQ#Ol zpG*-sUk2a9z3edV881+ru7)?vPryE`s9R7TrnoGovF-kVOd^e!OGP9yxooj$CZ0uW zR4N>iL?V;QA+>6~;dDHm(pBC4F*&Q3uU|0e5sC5-oDfQYf&Vp5I2n>*qEfuSog*T> zxzW3{rXt)!PGzi7Ey?~)+Y2@g`;#>c`{JI9#aNgE4wXvU!PsC(BpQWRFu9z;P&6u+ zKq0F`)fEDsO+(3xQPt%fp*riSDn=X$F+oQ(&`97oSK?ZVA#@!@HKl~kSMzXD zseb#z2=`8yoNMJ`*16Rw=S~p4D;U9iB{S~8I6Ym_0i4PYRVq=HwC9-AR9h!30|OZg z0K{XYIOKOxdn|L*S4BHiR=m>EB(j?RA>AMq(Da`5QMO|y*{)mu%&M+ltBPK{Bxqm)33m!#roCti4`y9xE%M8ta zPrkg>bPo0eLykCZVRbn>Mm>I+EIWY^za)@^9D7KWnk&u|X$6C)%99u`3bHpBtT~+L zB1$}L(HrX$f;#<;3aqSWCk{lg#*zdj60jKrD+WP?Ub{EXPPAbMr@tWpHbISVCY5`sP%A-q25MNkAa#+Sm&i z^k3q$D>mpqa>pdPm05m)s5C1rM3m-kEV;12R1-~ZlxCW6u)=&3D(Q95M0m7;Rgf%5 zbFg#gY$3TuQ5dETZ#$B)Qyv=>Q##@VT4Q|PmUeEW;i4Q$8GT(3iv%Fd_Lt$P<_=3% zoubU{)=0`EhNRPl^erc=0{~ppxbG*u%mGO;dv@LV_UaP3f>H8TDVW6Z}Bd z=fg$pa3v4^$tML69BTENZGSiZxP_jz@zq=rr7ou#yD!;YX`jRRS5Yt$P+gxuK!!-7 zI~W>2UbOC(+j&Ox#2pnBbH73_IK1|zS36RPwL1xALVxbiI|R^o)-g#0w@9o>Qs0~=t z52ogvs6-jo7L;hk%q854za*paI+QwuI1{7LO2OII0*wxs$WRMpkjGLgF;k@L-hz#`iWksNMISG&!et(;dA>-K zPBE9`D(seZHmu``hP}eKZ)nyucD6Xg0&;fp6Oaq1aa2RWFdsECKhmZtlLX`8y+Tp! zAJ!^}@Nk^AvZAcSSqT&CutFjlzv_u^5~jM#pOQc;@dmx#Ca?hs+~w1(l;%dvi4ukpbpxSND>m@D~_m6{H<$u&VldQpf{I zi722bM&;B>Hudb>f*7^lEa4bNKrjjKRkZB#m(}^Qu zYcYojnZ$a1Lqb;3!1IS?eXmk{uT_4BU%e+UlzUU7Z{AC4z5`;z1fOFMVy?wrbhk`- zkHKwcjp!U}n6Y2ai=9chXXb+eDXd)+B9moin;)<=9iJ)x{H$180$OTZO{jJ6G*i_V z3I&nWwC|!6e~sEx`KQ0r%)1*zaUJ57O9(O$gYm2lk0J@(-2*^5r)D=?Gw3?zHQ+KD z7od=LZ;;M3t!pPlv23$gQq%wCZ}6gyT!yC4QmQ$87^kT!8dRnVZb%|3y{i>_o0NbE zs@MO7aSt`0^D+MjMa;`ErJy*%uOurC8Y@PS1;SlY5Qj03%>$=o+BAw6WODBDPh3({ z_p!*Z$ZNHvsA${$Bc-B}M6P5LJl}^L&Rr4Ki+vnfMg*5oF?<+CQE}?L7dJnC(99r` z5toU-X$lEp?y^?SD;7(pW@>6mA^`-DUs*{- zM3mBkla-N?p>ZpjB%TNYz^|gBA}nkxoJb<^fA0SyfhO?%KY{;}K>l|ShL7Qk%xNVnRVEY&?NhBip=aj5~tA^uw%MEk=?-^~kH%NXzE-(UFOjF5=cRlk(hJMMY9Q zZVe!gXBSTnoRAPu08+L3zYYKZL*TOD)s%5Ghwes@hzjzN@w%WFYFNKvDn*_xm8vv` zDGWTB$`kFSQbFz92Frv&`va3y4nWrF1+nVMEVSQ8$Y0PRzI(N*73nKv&lU-va8(IE7>dNaL;n6?;7OP3ulGm6;<5wD7aQy&$GvTqJ-jc0c<)|c;qp=Z zB;Z~qPb7o_iyKP>0vH(P0+4tb-&YWd0AOnxhd&qw@PkBxc!FZVudh{6Per=fZ6)J! z==JcqdLz_{7V1;ccdEOVxZ?T_8%9xf*k#tH;mITuS)tz~{gGR4t zFmE_)Nu^L}OwF`3y*5oV=c!*8cDSW}{VYTZ#y0zPVzn!O^+eRS5XFs=Tt90N5(XJk zo3T{psFaUIaT11P38n5H(@#)HMC>2NpwQ;;$gdqN)|po|VNB8&2dD-c6)xpBl0_mc zn~+5|4#~VGis1iGjgFg6DVwZAqrerRIe`P?&r6Gw7=ICl)|npNY+I`u3N zA@u3%?S?cB=)+@!t@lS~@8y_>|9r&UkMkyb?|J1PS@NwZ3$ofXPDEi&i0z!oWvv$F zPs^Lt8umID99NdUUC(%z-iZdTL4M$<7UD*@5p$U_b+=m`{GBlim~h5|U1^`cJnVk& zXm(-cwGCZH9rXXb)k2iNHc7y^)NR z_ulGd7D{wiAcJCF70(S>SPZ9G=uRLxWz5nrLoZT%`-eV*`vR-3?kcS5A8XcoDa?xe zRQ@i;R4baUFjA~6lNF&|Oa6hKKE>kAR7$BBq2CR+I@RUxVQb} zNn0>PuWe(#GC+}1b&;&*1mSXxPllqssyPs$^`Uo_t|Qnr@MSV!>Elq@v$N z)OGD3T!B2P!f{83uH&@ihsFJ@-h_tVY`NBjiZ3N%ruKbmU2q4*N32lYpp2nE^7UN0 z23_hm{A_HA_1liMFHG(^#Tz;so$Y9gnp5ao4Ca8#b+mmp17UszJO#Yja3~m5eprK% z6$;7P>+YOR61`uDSmS?<$sB@^$55((J=jn6?l`!MBYHKcx@XSOaC1$gh!BA!q$CQ= zX2NDghQ^h}C2xlWgBu)%UgfpWFap%HDmRf8yTVgh7O{h8?9sr(?prtS{0dX?9?=q$ ziy?|(h=M=#Wr&EOwLBYHUygIrHmI!6g+|4s%Ok_fVe>fPZzir%Jteam;q}KMRY)8q zxK6|dfJfOd1571Zi~_>*v+wD>eaIE;DvScHw1F{^{co23^IHP3ltzX%I2baafO{O@ zEV{wi8_SK-#g*g=JP}zwpAF@uxc|E@o#|1o+kfn$a@%KB|Cb%zc&?*z&$#ib`|dzYojiJv#;QN@StgQ4e>940_4gFg4 zd@aP;uG8a-&n-1OXO`!Bfq2slwflU03a+>IS%z(o=P#is&+$IW)pw@!Zt}0;hOgm_ zyqQnsLn+PI^lk3sNA~HO?a=7HDx2|+VT?%1uO zQ&KMM_0M~k9t@K%`#xPZYc^A5`iFyDFP^TD7GZC~OU!}6BBE2dA=|^^ucVNAmztOQ zo#k5Pe5GWKX_a`jY&U-=x?RUp&B5>J_fL0L=sy0l){xlqDPs)J#?Ru{XjgO$^)Ka~ z<-7B|vq)l*#0IWKa+BodJqESzq%YTHZl&XZ=k>>pul#j?AQrsr#ru1K96wELRh~z< zW=@Lz*ALLRO=c*A3C21N^pw;jXV`jgnq}-PQ-rUHL1yj*_BPRvLB(mZz$06<#f;olIMD(3UV-v9&w7C;VNv zxti$28lX6GHj|F-=YLI8-%y3-lHwaWB#7YQLcC%588h#H#orfXJz?}RbCcoZ{>lHLz>>spK{99^ceb`{3#d@2u@eYWeKB zl{nix_#U1E=M63xjVEI|3AJO5#z?)zpwbAJ^8+qGGEV&ub6264G_$%zuqWyP^=+N^ z1Gj#a3|01H*iYKFT<$XTa_zYEKkU=A8kckWr7WPktQ+r}GfVQVZK|46IZUc30VrWD zLl`0CvF*HRmef2h%}ULg{(IquXd_4K$j&;PAJdi;2PW6PP*c%zOM{XI|6kXv4RA7| ze=9qGEZ|fRsD90rKPfy|6e|D?RI;yB>ZY`%=~)7qXNBVqc$qMDINFWlR#G2tSxqJnT5$BggnUV@u`j}JrKHA!R z*$sxG`|0A_e=%st{Di-SVPU3u+F1!pkPjwo1}s<@ETO^VtQdBf4BZ++eK|c6 zc4-@ETwA@s)Iiui1H?3&99xDbX}OOlA#oS)&a0W_gVP)Huv(j~Nmm)+4PW0>uTCcq zl&;<%7)D`C7bnYG8WuA=tb|6>Dg2IJtwjk-{ZYtSb!oecg=5<;5kU|}R%1363FF-1 z(@jKo(v#fs>X|5?Qi$c4?p@3M^Vi!k}k4qYt zMG-MILBJ2`;alRt;M~JSMsgDlfx#v zrhExS-U>J$@vS@e##fpWmFcVa{)naRoHR6QfR?0dZH(YhEQxNK$!PpSsO-eq#V3$v!NxNo=(TDm5tV zFzQ)Gs#=%z|e@ zyHcCy_!zngjxfe(YI)7*w*}PgtidK)uY!LJ>c9GL8YwqPUsLSJry=9VLWOzYk(m#) z0^2r8i3m-%g%jYOtVn^ZH0M&TvVJHl+)uJ7MJ=$548s+>m;&Qj4rCvR|BMfdF{Bw} zD$R2%Wrk5qpz1{?xxYzU#b8q4R-(1{mBeK~5bd6riSAU)%YolL5vqLUUbXj7Z!1Gr z+uhlCz@1HixTt-+-pkpY!{`XeO;MzeAqC6(^0daA`Wm(?$CoI9=xQVb%6 zQ)!a7#=6%{Rce>R4tlkan5`^vJVfM4EU}NQ<1F*FIAU$_95bU3v^cF942w}=-N$~* z!O3G#1S3h4-_Ub`Wm|rDlNJuX8%u`*uw zrpN&Kid&J!*oIOI7uY%u@#21tuxBpCwsKTApTO2(y9r-594MSE$O;S_l-xi{fD-lF zofmdn+v63t{Ma4OQ=Hh&+an%tVXhU~RaG1E6GeEp;*I(4y)X#<`%qEBe*z_ynTf1*G4gNaSz_i4kp>f0?oc0E1~E?X+Rz)9vpWL#-41jQ*+1} z6BYO;C8vINBsatA?m5b8>S5X$3smJbh-XDWXd8VP>*D;v?^xcIcrxwT+<_EHp}>ZY zwQ%;#o#$+Gu9eT#Sf6>S96y@*sb%M z{J0yMx#5;Cn^HHye)U!&=Er}`(L&bau_;nQcDfLJ5o)5S!ZaU`DZG29a*sxdj=*F6 zc$0Y}roxN^xy2w_D2*iu0Z>9dH9+rnLYRy?Jym-&psx-cs;i|ADeYx0<6D->Z!UVi zfj7LqE{~_+Bo)i&twz&N5$LXr>9OL(>es#1m_i1sM*rw%erk`VRJEUAr`_KVHm2U5 z?hkEnKK!Q>A+kFg7b=I9p0t89Nd z=~SZj7`mlKEWJM{`*2HZU}Loxso$qL&oG?2^9uZn2jP_LH$OY`D*_=eBF{t{=;Knp zPLGov99i>-2U+Y=r7HZg#CLxvSgLF>J98Mw+Plt4xjGUee0T#3P|<@G3a2g~_E+T0c}g~qhWf{)g^Q25 z5AFYRG27kmsBrKuJhjrp(z)~J$S+73un%P7n;ww!Wbgi4kafZzc8~M*$`y>4X(eU= zLZ8|_uuw{_?wSU-MdRFHGz?PLWNI`J)a0zF0UI+A&2<@w@B_8UCfunfy8wS9`ZzBc z%Ic$8WxvxMyMF+WGxf~?*w#H8K%8fuJvE^6i#~s51W1syx>mLJ|3(}HXW1^%T9OfG zK1BBYZ>wv4#XiYlqy%Y9`ta50TQy!9?UMHVKT@#-z`UN+z4pd!+;+bH1)oa{t?&k)H3LjkEyjq%_~pW|(_>rh33%fXZGvb61`@ zS17^g6(r_yfJPpE zes<*SFV99kU%7GQ)_-{G*jnIby4gKmS?vwGzP}%YJ?8 zm|s>C*V6&D<(#zdL@(@n`_tOtOdpk3$y&f0b+M1yOL}$Rz=RX9Wt0;F(yUBYX2)GF z*pSwsTHnG=m(3}IdyjB7Ao8H<&~{hip8Zy)Ewx*8;hf=&Y{(~j)EE`L1HwMIowx1G zmYR^G63;a3b$v3KtWO`qS01rZY`bJ*@neH}L#g%FE(>lY} zl{F))V6J;sFRZQ@Yzu*S-M&0VfBz$32QRH39y#3pqD5;pJ7rQYercahK(xls^gaoF zXefAMHWxhmz&MpREYY+vk<9`DvZ1_w+laPPgFgD+10PP%#SMc0*+(uDbYGzE`l zaqokYsS~5knpP~zz!##b+lYhtI+fG&w6F)fz5Lv_Ip*c<`s>>`83o-izO)aOR>mMQ zh2|K>-uZ~}gW%SI2#fLDm`r||MtqWlWjlP zG=d6$-0i&j0EPK))Dk3Wg)y*w*Xy%7aMw+`an14iAA5*iot~#0SolxS7;OD{?zrAMv4Lr~3Z3F~;RHzp3 zERWZh<3e%q=@F^_tGg#PQ4)%K8TU-X+1KpjOnhadJsTqk7kuac(j$mXS=p)uf=q8r z@oVUZZYK4pUlG+$Njn}wg?xC;&p8uR6aE7q1fivM5hz-!?UI#f=ZJlSzxAVbczFv9 z8_|Ui2FDR_*-bvdke*aXC}jrhNiW)tEc;4_t6y%#M@n)c=}cufp1f}Y5g_d-nGQ`x zv>vEsB%i7IXtvuP%3+(MlX|inX#_p?^aRO8>?E)|iriKV#gLJ)akLy?@W@R*$=8x+ zw2+4nv$>o^hXT}OiipOoB)?jao!vmg>#^y$hzyy8?_H=a?`pZH+j9)2Lj4u>k06SlRo#KW#HwPfQza^slCFR-35n89)Ho2lOt~~#4W2!@hdIGK()XB>o&udiHQo^kN@lzPQ*4> zbDo^0WOrM0q7~S&dWlrpmPs+cIB`lKLnGP;^kk?Tr^0xF00Oebr;eU-SeM^sq?kHT zEF{MT5q|RVJt&95W!(drOZtVxvYeofG$Wo?4$rKcFgAU-I~2>WLKoCMF;u*rp49&L z)M6>9!sAkdch65(w3{xx<~8TbEF!{`t_;6mk>$u+3h;}?D#CP2CSaZIsnfe*T8(GK zsmms#a!e#%ZoRqqj;XGrlxf6~%;1wZ7sOykiM%)kR*(4&+K4pgrcUe({Tj@|ME&WvoXB~hBreZFR)ZPrWD-|68sm-va{so4k?I*|w$Cr@H z{FKM_OOW`>z30uT340H2k1X3Inp;W|yh@u-WCV^nU+*c}78YZ7M!dNY2d`9O*dM-3TNOgXE4CI|M*otOl>xvp1Ta zM{(#K{uprp2Hyeav^;;nBMhfMcF;+msagMR^wI{36tq0pW{Il%A~ z9azYgtT89@-v68Iht746MO!a{xTF%!2*Wu5L!ECVbZ7c7p$+QnzO!@~AR9N<^+O@%_fvJBV|<}Qd*yKB&1My#0Bcm`KGb_r!zfe zKi;j^bM-Miu5Q=i)SCC+e>ln`>hNC z(}Bf;D~sbiD-62~Fg;0n zi2kF;M|L@O_Xm6rPKmyEWeW@IqjfksNm&`s#%9`LjaXXSzN@Fo~CvipEK`p;Fk)))Nk%eXPENU&B zE43oNW38^4eV$cgxm}dMC^ytjE4}Q+j&&LM6JlM>>Sqd27INBe9aeo$^H5pYuNpRn zqyb{kBA_zBajtnJtC+JF!P4{qO`itaal~fwA&NR^{w&^|GtPVp5l!H}n6Q zx34S`G=|WsHY*M9w_7Sc{-~~ND5Xuv=}b#y9&h2NVAU|bC`^MAusYjB;I_Zi)BYdN z^;v(O7szz(`dnUhNh_xG)#S4sw=X?tT+)=3&OMNsYj9pPb0wOprM!dNnT|F2v_7%6 z`qJm}6X&zqkaRVJ!!dLaMPg%@W8g4o>JDVq3PYQSS5hdLLR~zIZFEc_z9VCm6^S6$ z*?1$Xa(=hw^1e(ujpiLz5id zvT>)Na0Oc$#TSKZ(8=uMts+rMtANufN@X6m@Ie=Be6_rve>QkudcIoJMKw@{Ml*fq zl~qxO(H3&8Uim6DgSNhteJ;O3XMS1cbX|J3=5v|fu@v#~;QTHF5UY(IG43K3rqjoR zb2k5YP>R^^Sk9+q>Cz^f+D-@y>z;-7kg*iMKxT?f*onoo18`U~J#kY^-u9_)+(z;izKq@d)Y zDwEODtQDYM_YeFYM1v8;sVoF9ID`pDL`eCC(U^cw;xAx>42hwectV&Z>^jd=6rW4x zw+pcmDnZ66|JSoIWHpY2Q&c9fg#$!WJ!Q3g@;K&7a^byrBK;)|+(y;-@fz> zf=-W?dIW-oqSIh4cS((Hbu-s&L@yI`T90UQ`Gpf0)^;|+j%Ouf{ZPI_9UqU(E{w*; z6@(FTY}qR|m73|RB2D#5cHV4>Hg8-K`&%i|J4d*a|A7A#UFMw)uoGE4tUnJQ98#)? z#(0Q1HXglZ=95jU(!80?k-jv7cdh`tX^5~?^9Npt2hiCnGFdlGJyXnyBOf)xoD?Cv z*=fV`>Z>4qpWs4aScF5>(N&{}Lpw9wa(C2Zl(heY+N=7<;G=y&FtGLH$6)I#AAbNg z?t52TSFTym28Abj<6m$bl;s1y_Uo_9l3zd3vcCM5rhohpUi;q1dG6i$d!OddH58~} zT2B$FWqYq#qBPIdq@9rCk5$GqJwnxp-hy zl(M2v2gXXF?p*mbL&>Jmgp6zY^0IZkdedYy7V8GDx0qUS>w&gn+j(7=U#h)#4_~F9 z>G!m(zPXgExc2}A;$1zq{^6FTz=7yF!1&|r8v}=wqvNA?onH@+4g=QzcMHQmzA)4} z1mac$0bf+(Am$pWX|< zCalD^ZRO>6%T7Pj0Su_qjXoOq^;*T0ulId5l>tv(yVZOJHV6*SuS2V>-bbxnGgIy+ z@aWrcO__aqQ0dk&>ICCcuCnbr?!?okfj4!ROk00cM-}43fvD53g>vNbld~%9&&kNqJJdvF6 za84vF>6{4Exe-B!MdJGiHyo^}<2A%GmF#qx^lT7kJ7asM-PiBhVEL3}z6=}J2lb?s zh}!+BQDv_3M2s>ecu2LwWMUJ|`t@0K59oEA~9AH55Ck_UMi4XHPy(j zUCOVlT+YwAR%0?U>`mXwk<($-3`S&4K7+$9sTwAumhqhfWPIUz&ei7aPf(uE5iZmR zdjyIBPR``_y{;##ww_g!SjyH8qf_rMG@gJ!5TybXFGyy??R}+HKCZ7*ZoJaMX-Xy|I-Ep^+~YRzt3(PbjZcU!mQrxmK4=6+$!NWI zerRC!TtH`+pO++6CX?jxNKxTrVx*7TAPOp$MJQvFViht1$`<-ILUZRwh%*`|$qtr} zgsxZ?+TR0r`L_GO&zyq^Y?Pln{)4}PCwwYs^5piHu=`;J*fzJif@A;dU$F-~EfGf$ zB*KSEE;O`vE!5_EIisT}(o|Mlw_@9(1>ghanY}UM+%347{do~2dS*gsxI95bt)SAf z;=_V@2rNDmerOmJq~vwSi8I>c896AdG=*)qb+NrNgN763_s4@m*mmQ%R0ZnU-RCy$ z-R*zU)z$y>&fUJv?m8;Dt-g`mLB)T{J=)MfZUyqJprCW-cdY%@*RKci0w*XjmKzdE zjtq_X)qT3KN3YVIs7Zr(GKFQy>3M29d_8!R&8Z2YCzA(qnVw;>3`7WwfTeK_+-Phf zET}d-kPnGC70*gFMN)YsNpacHcw*WcJKheqjGi@K17_(>nBcrM%$dz9|)j# z1p#ditsIU^3p^c;4G4^5P!bY(T+FToWs7Hw_GUfqz=dtE#r+Vf28Y$s=v1AV09x50 zii26qq%)>Y;aX>^kgAQNOYM-opQ6GeDd~FsP9JGB60gGG${3n~^Oou)E-xvb!9zlk zK2!L-ByF&B6AptIwX2pRfl7PAsT$Vg1UWEpmJ z;bH{iz}yd=+#Xmp;eXKEt`fuQ`;@PdMuT9EC?0gi}A*CC0Hao zIWd9815~45l^^G+)>jL<$7>o6+G^vhqxD<29k}wpO zf0S5C{{_U+Ba7lk4m2Q3$%m13M*~rqW#3yBrPN=@W#F3~!Qa~#1wiV7Umw)3ue3Dc zCida^r1WYwx{*z1V;UZLVo4Pn)i9|?H&%V$sHP_5V(93&JURuLho)l^b5WQN%090q zHgZGKE}4t6?Kb>qf!sL)0Y>T{*9g6K6x~0oZw+V#CbJg=@A_e}^8wQ#gO!k8%JfCK zwUgZ3PLM0C(%4FnY~i1ps8k#8c~=*ia^b7pYzOvsf2$!YfA)5^W7ledh?K<^v85aD zmFrUWfp4Tl*{-NHS_jG3zW46#DF(oOl3$~)S$Vp9dY(9Rez2MehA@^m@#P>1CNO78 zMeHoLh*Ypz&vp#C0IMOZfK=$vunMitH5Em8`M?*;VgXMhLCLTxB};#HJXgRjVsTLS;XHan!b9P0mrA-49v}#BoBSm3aRowaMKjaSazc>{R2$+l+O0MCV{Je z*4-^xhiYOuSnq8dDKh0&Xa3#b5PjGIE<-K>o7m-l-z3QwP2M~ch`rS%m-a5M&M{^E zt#t%{_$4$c5gTi7cL0PVJ{E&G*1C1b#El8Ga^XXNEqr7;l)1KU1~MEkq+A;6^Mt?Yy6P>x$6fO%#_g+-I93UWv;VYtW<> zMb8dAE?-KX+6lO5M80d(Hd;^;^L-}Xvh>LHC3IjbtF~B84aUOmWop}Nk05wB`O3ss zC-kq7erTn6{i`>3nNc)N*SBwdHaf=soSVZMzQdTcA83w10Du5HU|vZzE)TAU4A-KL zYw!$rqWo$Q-vLmqgBg4EJ1oZ4S3Li{@TnVj0eaOsssPg(+AmoMwn6^WLnwSVo@$5$ zGV;PEZz8`7$Baq&WyPJ)#2sKub)a1$G;AB(z#HSWeIr}a=HJBei;P3w^GjtpHw zk)sS8@XmBoSIxb;zqppqaSg2EPJT7l^C!8EAGh7WC3L}B>U`mp)QRHkU!Pd=v7<`9 zgfHZq8ot&KIl-d&q0BODEiO#~j`7F298{0YYXspaAZ5VgIk<*Mxf54#9UA#NTwoq$ z@qw6biO{GT;CLTahVi>%?fAgN;u9qhzR=8UV6K@7 zikVrS3hsbnu7}^b4te|?j>UW9eLMjDwjcRvZa@!bcJaaOven3uTRD(2ttC@(a19x; z9k?}R$Eea$Ljp3YHF%h~17f)j_S#yMrZR9$8G`YX$v{-=*!>%-2*In;+TZ*DWIJK= z7mFSDx*{MNJ>rnjo`CXV!*)VdV!M@8a5a~uDPc19bn2sZ1X${i1P7_66f0D^YU04I9G$O3 z<<@@(F}PmWKdt4hc|f#=JNfcz9e>(q(A~OqjFPRPU{Jm?>Ss4B-+<%@RUIHxRV%B3 z^CwHtlPpikd8NqMKDXEzQzk4{JHZ#W2cy+H!mD1{V>Ip=Cen63 zBV#rxiz@I`&QgU@DzX~Imb9DA-}9f0tCzI*j${NM@gGoTaO&^CKPGNPzkn^UcRLmX z9l)bOG7Z)|3h4$m&a3Ag8Z80FD!S=d@`!~o)t+?wSumy<(3pO2(u}W~+2Ju(k;Q7( z*xG9Zm8dm&cP@E#;h<4Q<%-{iJaOK6Qe@)1j?}l3-g6| z)dX=hULfTu0syBPnY1=ZdJQEOS`-6RiIWjL>9^1n&@c+IYL^j2Bn%GB74Zt(Ey!Hy zuQYs;PXj<=8Z#gh4yaY8$^x#2HLwTP!a-wyo|NGMJcx&|7!P9!9>Jq{3`_Aimf?v^ z`EjrnIuP$8;VEr7NHr$e4lSm_vWKue__FoTNtjw4I@PPwb<(q@6?|Z^0p7rU4?NW9 zktUBl@l>;C9ArH2umu>&`-y~k4FG+9-_8%#ob>2v@BPH-SgQNVkGo%I)l~Jvx!dn! zm&DGF$4A%R*a$lRogd?S!TNBaYWXj+4gkPQ%{_wL8m`uLS7?i;RcrEST3M4_3(6 z;}+qg(qVAq>NBJSoI^fDgs@V*PXXS?-}{n3Pjb& zODdx-GJe`nGHYy74s2EFl_4z)rW=lNQXs zH)I2-FJ3mC>}r33%;1$w3uxzx_V!f&=bNgT28gOvfKL4&f@W9+dUcyxvHuV7dHt)3 zU_Xkv9zQ`;JniBl5_{)Tt8FmfKd66Whe3*v`ch~MUX@t_JRvAQ2-u8$7U<5Cpsh=F z`ICq<)l4pcc9x|=aEV(e%jr4LRv_8#gyeKQ^&`Z2zq5*VfotlaSCc~}!Pgnpis&Pj zphHezd*#`nQz5;Zk|0Y8*6d(}_H{7~9GtM0ZqPwGo;H?+1oSHUU+7L->7|qgLVS8T zeMRSj_n|*RJM(ktg+0lY-c6N5AB(Ywz7Mmr)biPUcpET)bz~1Es?1sf5j=|rxtJT(C{2!FxO9?2PZ$twbt;l;j z(sq70CJx{M2lamp!Lq*Y2lNqk%;f%r#}-ks(*He;x$5T+H0WZ(^0^ti)vWOT!L9;3 z^IQs;AEiJ)X2bNTJi#}*g3>MUtlAlVskZ_E=H6aoNxmQE%enc`tOxYTWeGdZ;`QHV zXfO%$unHUTitWC&(+M=x(`ZuqdZSFuAcn2(#a+_vZS6oOd!WlY+I0i0w^A14pD(9z z+Gl?5Ps19ZO_qLTt;Axiw3WZKtA1rjW8~B@zyfml1zl;_SyD)^Ch7H?H39>xYm zm2+aSez0w@dvJ7!saKg79ee%QXU9$r^$(Mc9J&YE&U-iS#BjrK$8hf!$jq?DSO>G7 z$bT#UlL7<#NcQO`pFa8K$yoEBLx_&8c0dM1{TE0cXYOGn}iOJKT~pGtTTR zI?k=*wsP-t(>sxzgs11NI@`s2w2P51ZJg7%vT;M>tHs-kzbXE!_$dD(znS03&+JAE zK0#hnRnwM|_e#Dh`K#op;G(dwd0O-0=I={)m7WmR3)@5iQDMu}mdcjLT3%>*qijdn z_hq}vYDAaC@z&DTU&|jHl{p%%Y;1dJ+55|pYI*yj_Q%_QUw&};S(!_gBb(4Mt7An+ zRmYYUzpXg5;=DX0FYcV)xw!N3&O0L{@p6p7k`fb&LRc92eX7w6& z>#3e!*BVvtuf0Xk#^(z<)ME9f4cP8Z%_;4~fiE`JXfJ8owcXnE>5M%^I<3y7i|UF8 zXACYM+%TBh^v0%b!CSo$k_{<>=pbfDE2In351E84L+Oax{sQ=es2i*_#frdj9 zp=78KnhVuHYoSM>=b+Qj73c%QOZpF9quzJY_d}D&?}gdPAMG#lFZKV-9+TOQ)Ww#1;3l0lL1~Y>t!Rp}J z;IqNw!Iy&HxBp=OKiP(OhlGXTLqs73A^MQvko%6$9Dh6QLxVyUp-rKWoNt`pIR9|j zhV2PE92OiF7e)#bg%yMy3%l=j3Ri~TasTDPM=V8djSP#-jx@puFez*%$}P$-Dm%&) zbt5|v&WDdhJ4OdY4(Kf*R~yW-sA{NvzpQwf(7zRfw5csp>cV&&!^6(b|thzH?!;m_e8;lFilKRrK_L|QAOSr#CoK_gpu1@$TN;$Go9=Qu@bFyjD3Tzs&sh$$OrBvGTpj ze{Soefew%aD0l!u8~_rjRjy>iFTwU1M_PWMF_#`cHkp9Id~T7V1a@iscnS&0QJOf`jfcfb*x(=E9eSnT?+Yyn-pPj&}BX`#YoXYHP^;AO!hri)8z$JzuThB>d;3 zoUf=m3KfL?qkoQp-^7Rq|nA-o!HBLzrr4H+WJ$!1l{ef#ft;VDKGY+sSiRWW=v0lc|n99s5 zMxW$j1ZulTxdg5@ba`J<`>}DZ5g5}n<)pa|Xx{++xvGE)SZ!##2X$GqW{yM^)|Jf92}M#RbOtq9&r(g)I4JTwMVgSM2RB%a%LTP*S1}=>Og2IW zI`_(0qFH4Nm%jnmOTQucXwAVc#fFa2m0o!e6z^boOgJb&=zouvX{rHP0R1v8O^mw;|OgD83*Tm}a zEVb7Yo;4J~GBX?7OJ2dfl1DHnc@f7-2H+Vh?{8L%d-StKC8lN`?BT386D$qjfrW$} zEThh{jQU?{c4v$635eBEuG=7%AO7bFJ~uNeh;d77o@NR@{>hq`cek9d{u#ST zg^nKa_hvtCNN5)0|ITp+m!%WLnM zmFC{Nv2NdOU;=-ZPcKJ;eiEkp{FUfob`52M<_wW2O0ElFwH?<(?e$3v^PS^_2NI)J zj6GT~!$m-+P0Mh163r5rd_I~-OsAmA`YYNt3Aq^I1^iH8k9~SOoFv)fbL%b&?U$7cC!rIGdsMh5Bdu#4 z-2r{Q0Kt0@*aBOtK(#tv4=nozdfPu0y7>3Ewo)9?&ArKoV^_kEggAHG;aIp}3`Q*wJ(qWC(?8(lptEGdEO-GwsCdcQcfu7E`!Y$Oz zVSKQ>I=xVlKnNijDe|JK0H0@HS)Sk_jE76em6^#v$@Q`TFK^gf&#Q$_FRv%==jtFM zPoBlz9aR0*uM2-DdKH=wJ#=@ms15R3LF*0?R%6~hqP6bjQkV`i_9IlQH@&hW77Xya zG|sR5v4+=-h`O>c!1^TiXi32_=YRkngE1i#r~fIt z{<#|H^ztHlX@xIpH%D~;cg{=ewFZX*dw1z}Us>Cze3}A22d{9AO#- zH=ne)pp@(uY)e2uq)Y4VhUSJlP-bT1eo1{iAbAN}OCH4rlK1di$=`5Dz*HwSt}Q04 z<-k3^YeWPGuY9B#J8o6;qBF*39&8CuO*S@^C-cDZiV`2%r3Wto06)DuMP(P5iUA^$ zN}=uKb*_SUoruhw+4*k7N`7}|TrQ{1++l_PBG3(m2PDH#Gm=4F?vR;TGWWH53Do_i zK%QYY>ZvIsb!j?1GmZgH11-RYK)Y4ZTYFoIgvm}NCH?HliHW!qgS|b#3ACf}0kP*3 zC|)*B&vuTU$zG5R0b+g3yxz`~@l{V=IGg>ovfr#F+}iWikD$%?QcUS+ zi)%nn5V>K4#$WO~ce0}54pZDwNu*FCqNbAw*BtF@Ij3fIU|t^DL>CUcfhMiCLU;8F zI)-L&11f4Myhd#m4+CdEaTkT}iw!lZO{PKG8$Hvb%Llgj}C(g;=I8 zya+nE!)9=o0lDiZ>-OCR>)>SzL({{G$@0G1oIKR1=o{fu4{71uEm)nn>wwO<#e{4K z9?`*qEw9j~At7h}8&+gH=*e9q2r?Mv-Y2BIt)JlfZQ)2aQ2G{TPtA9)8@`j_zN3fmwXdK4((l2v3Byo87I}XMvo^UmXhuO0e=#pns*!Lo=2%`qdVU4!2d0@rf zTQvOVf!tUG@j=`A%9X|SOUb5qkJY@ET56@MN-YIXo$)l+_)ew!Q-XVKWlyP@t}Hvx71I0u6MoruV;C`J;=0P&js$ z!w55_YsTCynm}zz%SsWL4VXS-+!BGVa5>NXAv%*nhin*;ORUyUb@Et4Qim*DcRT$! z-a%E~Z%_dJjz04a{9y%L@V`zc4zw}C1*$eN5U?plLnOfPq&~p_pd1z)XG-|#Q$4PJ zUH?aOCedqlT8ta36$;qmV#q(kUI#SS5^L!jFPhBl2+*M8 zmkUP@8i6L2IXi2WiQL|%%%$?aswX6E+CuQmV8u$xw3`UN?A{tcVHg~Y1O&kK4nS;e z*&05wuK1-n^;=v(3n~3f38F^8+Zo!RIFCM)7WLrmixOg=KY#HH>Xw$2^N6kIxr}g- zgE|hjBFfL&tHA4}XH?@;_3!KeoYhX_ONf?;NG$e*v(GU=UtGlJ=r08iG(0QO7w4c> zZQg-fS}cSJ%${F8Q_}@zkyGa=R1X_=w{mn5i^3xTD<3xRRaIcZNOt+`RSG(pD~7SZ zjM?~kI3*Vm+$lcv=FN!wY||BjL6`lPg?k_&WsC$)wuTA`&Iy3M=))bF0Rmo~RJW1R zy{9V;lsj3jv!{sltEQOd2!Q0$7W>=F5-&e&#!$*b*BIs;^kMLt*_IztqnAL9f|0A+H2=J z@l=d1^|=#H9x@kFk1_hme0+M3$Xlmn{XY7~?24Z9&lyzoLA`@~p-qgS1wt}O-eSkgo6a@>| z&o3zPNS4DatyBj>6LOhZ5li22Q0z~P?p|mvI5A4^@Mlg)meFKaX3K(`48GLi^20$G zlgplIL1)BEMmrlIRBUGBet_UvuXfJZ?*6^jd>1+cjCLr)AH%Vlm<9)>Kg%#SkE=#Ho)vCBPvOCn+ zGtqutvl$-BgN3PMA0NK&1mj~9I}}m0F+1-5_J(mrn0o?Qw(cAYa|?$6uBWB;r1iqr z_k);sBoJiOdAt}s5?rg2`fA8!s#_g!O%fb~I4%mE@J?q$AZ_E(ZpcyCD$r-WYWT{Q zl&8MU?D76pFN~LLiB(h)&@LB}zPH+Au@pF!O)yKVRgZ{t3O@8_f^1#jUZza0l1bI# zLaL2ox#mBx6?=Grzj;;X)p%(gn@D9W5o~{#9_nbF_XqpP7n0uwc8Lm0xnSI*=VAjl z<1jnm2PlIlZJBD@y!(t^?+vCeNF%k6g;{a@68Ja0&bVc zN8_2ZO10}bFe|I&_&@*drH9n<46Gzdd)95d4f5*BA?lN3<&X&YrO`CgPm8=Zx850( z5(13pE;)GYsQlVo8p}YwU?R}AdUf8uAf&9fPF^eco!2kH@yg)$F|XNB29@oqsI;9{ zyo3)cmD}~pBnG(z`;rLLi-(|WL|g~!liogE(z%F<`u+gR;6;Db;3cb~QxG(TPjsqf zqOmU%KiIYI??Dvo#k@wX%IUURcc1-@+3ipoZW}u`&hW0z!3!DK$0~CZYvp zz(t$snAQ~y45r?%oVrhWVgmeo^NFS-VB@M1*4|w{T-S0dOoh1_bH7%vw1hrXyiG{T zEw-+jV_T*UnFXaeJm+Y+8cYyZB*p}AKIkTfB_|zA_`15fYN6C_;!}lPytcX=2?(@E z29$;gIt{+g;STwyysxj84FlK7?H{*gI(1g}OueV;9F*^D zm>ZsY{jCM33e~8CzHS8MLeUa;#C2@RAF)XCD!wPV7f-02V^*;HpnHq<++pUy8s`mj z@Ks?3NFV~a%7w4&?Dy(K=w;Jxf@7dxHpB!`U(YpRRbUN`uLiVq>HY~DeSSbuh-7Bv zD2x2uM6D^a8l6F+JNo%J0VHRp_%bwPM8Pk>rwNYvp*7`KogIY+b%CyUsY9*T{ny-R zsDe$Ee475|wkmeDPURDA{&hyh{TV5sN5_leezExf$;BrTQZw(Nym{a_&>`ykL0kUn zK2iDYquQDtYK@5=2*6)ZF4?3ZC2Kl2s`33 zwpa9|qbw~cZSE^SrIdsIoNY>4Hq)h|wl;lEh_4i6owoEog#XZuzNl&c*BUScNvLV|6@1xaPTbIQ`3}kPcDAH1 zR!IJfS;+zXM6wx=k<7;O02Y`WGo6)|E{lRqsb$IY7vRf@*j9FtWnE1&D8V>mse8HC z;~OZFNS(dzGvKaKT4k*;%D80O6T-pZPTv)Atx{E_2M>t2%O$**=7L??i+Yo4nkN>$ zD!4o5WkbOPo{H3wBTA+pABN94Cx^|r;b#a_0W-~`ra&P}lWHjFfBug6`fjbWUySSQ zR80-;ejJ>iI|=772B^wtUenU{Ra^Thw8y*J<%@6jG9=U8`da>Bda;CwkU6Xcbr3>& zEE!Dk@z7O|`$MdgfP+?1c&k=?S~A&fQ@6_ACLO>GcqwXqu<@;%qJk}n)OaY0A;AC^ zum;^NF3?7S8MIB5^hah%2Dliks8+`6M_IqI!Mw(A6=zhZ`6=ea~9B0;0L|FaL5i%2@M1{Fat(x zY{}UXsy1S-tMc#JhECDmp~d#3Ni2lU=)tonsQcwCefDWBy&e4Un!{(%n^5eByN7>9 zpFv#0VlDdKtJ|PNU?s0Vs~S^09K$LCD_3pSGS}Onfoe0vt=N(TTW)jIm#~74-kMLK z0OqVxySk}}c{M~gRwy^~JGy#DyzInjFd1|;L#JUAvr>$ni56m`1wSGo=~A!adGyCh-WGpDR1CBv{hg zPI8}#ldZjq1fr;7yj{@vTwcIE@jiBdvKE21ZitZe-3O_QabYXxIWxgB$=CReukk5xC^XBVHU- zcB{6pQTi?B>C6r#lI?zsM)zDUsP$&904l@I;AJ7kk5H&j!k&2cIIoXULIG`DoRmOb&UV7}Q|<`2Ve%lZf|cAVlN z0@_@yW8r!-!&bjP8edT}Y1@C{5}rQrvl4zMx2Nv%&!7@E>P&ADis5W@R@Wuq8g}X2 z8UvCq=(B&(IcR(dYu&C{eQZIc0Bzg|*arb4G`k9Ma%nj^y)TLW)@;7!l)Qw{divN> zbe|X>@K@q0iRRgAX2tLUm21it3=HR<<6LOC%u&`L4H(WY^3X#Wb(Qins~mqs*=JX9 z8U8PeH?f|Bv2;Xl$l?P%ahP)>qKf{V%R=vQG0#@dD9U zffpwvNLcq*e`Ca!?L21WSs=_dp0xYS0jU=r9jbj_vOTvS(dsp_SN%(4sxG=|xZz_n zgVQt7Lk`l9lsYUZv9u@2Y4R2pz^DOzJc7eLxid$oAbuevs;F#Zpwa9g<4C0*qCrlD zdO%$)brE)QDv*NOoNi)BvOIQELtluq?P7Scy*p3cB^ON;f}Suk9q7aKtW(osXj3qP z)7yyOR6DEQ=W-gqxwBF2ars0)tfup1X4h`S14#*e9F&G-GkPd5O@k^INTl-d6b-Wv zszw@jZ9>RLiPP8txiB8q$Mj95n&v8YU{cVXWGq9H!U^M9EW4_s`;2qi?LJB47<+fe zVYz<3jzyGl>WIn$W45}tbA(*jVZd48SIDBA^6_YXn<7+n*SF5w5vHOY9D8;<#8Z|n z^_eMOuJqPur~y&?2pGFDh$v?u#FSMOs9cZ)KTJU#nk>67&?OXzHLok>4aJwDwkK|p z&w0tr#<(N}KUmHU2g6a`4MW7bR8ln8^Hh31j++Dqut5RV-zZ}a#|M+%0Njt5lCJol z{tL_*V{I*+%I70vA_DrOafFsdY?PYYRH+6W;J!cp8`R0&ItIV!|MXLDOmhwBzfQ8i@;z`d z>h5>wc%duRW5-ge9{i#CZw>ygqMqx1%g!tF(GM=8Qc@BP<(Dcr{u^}S0*qxbZ89VD zSgpoR7~x_-ALBpDqpkdM`OTqOU+2f$BVytOI|YlKbDTm{7>~5z0!7>|;x9 zB6H_*<4s3s1ovXrH0{=?-l1>f5g|&Hz~ZTc&19aak?RPM0fQNVV#>{$5YkCw%|n#? z`fXl@-~Yb$G^6A1yOBq46OcgHU=To4-VFc?K9USmEuDRYW5=XF6zrqx(y9M7J6mTZ z8AaE^qQ9UP$nEw&ldM-9qltv~KO&S?&o-yBuk{$*etEwOug8)mR!P|(T0;O`CVmW(=<0|*I0BouaaX$)I?+!~?*pc&y8^YWy6nOwmzK_Cgs z&-L!G55m#v*1J*ne>nId&KP3ZuD|K-3Fo9c*~VdtTgbX}(q;ov{rDrDE*@xW{ zW2P_kGm$yk%xC3yAAsa^4joYTu2dz`;{oH;V`Q)VX9`)d-1OPO#R5DZywo2EWD!iC zCiH*B>$5I1H>^fmH4MvOV$`J7X(y*s+e& z*Bnsb;LOCjYxh>Un7$pwp4U&<4|_r(El(wzsuzXH3G0exz;s|>=DKD)1N2N} zN1UdkBDv8-lVY_?jjr(H5Q1s7z`ByRdtqhq7#4?4}}o2@&Wi}}^D0X0GBcg_WO0$IL6+22I- z(682QEDKUF-Mven0L)i+9wIwY-}SDR7nk390(7wRdYbn9QvgOd@>jJzl>M8V&n01} z73QJp`2cnt*9W{hjfm9GN1bLJ+x&o7G}*$Daka@U5cD4#3p%dHnyb}F6VciHwm=XV z?V*FPhYGc!^56_(oHQ-yS#&6Xz(N4y7%na%Tu`>6ejTTP_b@|EhB*;EQbbq z9n%;{@Hn;;YV|B}!BLV_w|mUo3_YZV$-TeCc8uOiio_wksnY&y9xz&ONcpFAX}VS> zXv}O!M=d`=pt*_X{_H@oZD0tgr-D(pd8#Z@?x!jwtV|6H?;fA1?&4 zF7F>`6Rz6SBgNrT`Td|!opX{oEjLroA6&0K_}A(J4fKX-qO1{g-N{o*+cg&(vtft9 zG?7^Se`r}~PWY-(69?Wn<@Do@Av?MvgC70>#`W_XbUJ1Tofh$5iX0Z_fDH+qc?a7= zd6H=YZ~p~rt1I=uk;jh>jU8=)MRXQ+2z}_mkI})PuJ1-Qp~od z&Ht&3aI{b71{68Q$0qwe5L|sAWRt-NApO7g{HOvf124bhISe*d6r6>506hOih?npo zL_#A{X!~Q-w^`H9Uq(DXpNv+fX=$rHDW5)Rp9qBA2=1B z?q0k>@gN0h%Fo@&?!a+oTsz#O1HT%Bp05%x!K&uah(0uSjAlr|++ZB_u2;M;^a{l= zhnDMfTt_reR%Vp5_UY(@AsuCyGEdXl+=s>Tto>RE9hF3ueB63sSfR9_Icn}yv zoglb-eAaftwKyDXstt5*;Ps z_hQ$%Tf4KYWPPy>(q!GNp>2w6PIw@6VsV|uV$rxCv>06vVWF%&B!KW7%=g-2=yZ2N zjeF#oH-msoa5|cYmUwJHw9&F#sKk>Pc5EHw*TIGD;`7^!>;*sS-`_a^H4fn1t~~^H zhchotLW91YabP{xg7OVk{h#u9b{VMuG#l_#j!%Lzguzl(gnbs=)@-rc=T7cES0k|EzTjHv>k zGvwTfa*!cwzcn}C4zPyLv#!SvJhSMVTk4BC^~-%_Ax=XleIfWR(4LpC)m5nHCvv?j z!G%#T2IIIVD%CXBY{9F1segcO`FDi87T#!U*{J^3<5wz-WZ0>#2f>j5eyunLky<_` z2;rWt)bb3hO`9f}Usbs2Xdr=oikAkXcoHMRzjB~M2PM+YlbQ|UL70m2zQtUU0ICa9 z9DLkvo}0~vWe^d>hZ|=vG`%b>#1}!cc)n7Vy+lD$(u>tX`KW!Y4N<~YC1g%dW-Rym z_+9oBD>OPW-T8)RVJn5!*i&GI$6zG5*xsn%m?kj0u4>+B(5uxl*s7yyi;`!JETvb1 z-~cWq&ck-sf?Eu`o6&k-rvu20GI>gFuXSZ^bSnge9KR8geey*@73{FE1`~V>eet%f zKT#j|W1*+7bN!nyz;g@xdLkYUCediQXHuwx!dgyaZfv_%TeltT*^LPc0b-Rw+n}J`_{j5WONb~JC+cT*m4j7J~yFV_*Rc{UrGvQjL_DtD(#P;2WI0I;UxN6 z>(Eeuhmr`_pn~P?HO4UsTi|nJ++9dy9ztbyyREKRA_5a!#I`{MNixu!AlfIm);>kV zBx!r-KphdD<+`>Y#zwiwVd?z(4yr(X&^%HscMEdJDJzG7b1PZTu?cT_t|-RJ}a6+-T7SMJ2<{QtzT!bVE8p~ymj z{bC5MSj`a$nvP^Rd0e!Tftd`Y$<+#x`eDN1<+12o1|sZGl`zf5uAq5TmB~q+hu(lk ziF0eF$@|`D#SJ!=cpP4_iJxok zDw_{wjiXeB^TbAjQe?~QPhD%aP0F~f(CYkt+hioG<+EbiYqQN>*)5Yf`yS~#AvF5` z|DQK9eZ$6bTua&!xea1U3{*XI(TWxhfm1KxhpcbWUdig+8+v5D%fN_AI?lm5P?*j= zAtLS`w>puac_rR9!U%S|VPchmq2(2-wohvo@Seb$y>naa6ZS67?mNGr!PjA?m1;PT z4$ugc1=5@OhuN4{>Y#gD(^Y_|G$KQ2rY6K~Zso`*7x??wpnuZsEeyrot{UV+g`pnZ z|P!Q-%8Ghz1$p6l}V|q6G>tr&S{vFDjj@O4@paCsG>fKKB zX>Iu_lSxr$xa$B*o#LzGIMsrGJ0ys|ngK#7Fz|wOXq^H;0m`lcJH-dCJ`WvW7R0vN z2#l#iUvHm=0DdQcv>{JADCivxz2NVO^_swTgm+VjmjVM++-AceqF9Zvq^1Q(KsmGe z09O1R=0Ii6I$5n(u`fF@0>de^FIG6YZsdT1hlkUv)q<`@Jq0hvX*HO-x%*&SEb+_) zIRoSS!*2a2Oo|n|W4)|n^3eqh)5urolkow#sZ3?BSy@MkJ~4c38|NNZJ#>-o zHI`g)?-o%Fr$P{S63&`; zII$M}4w_jBmj&k@%}-kowQ#t$?ZJf(am^E7+0jN9S$oxN(F6X0x8OTN*ghtn4f!ZZ zQ(LqOvHOzwUwoQpg(^RKqaN}TwURC&_wWsemarKGQ%B_U5u6q^Y^!Yf(b0A!CIJg= zFxz?TlQ6*-ewRaIVcg{Ss~WTP#KH7N^N#%1rw(bFKMi2I<;0M zCFV}Rk-crz%QB_Dk+vSPm*S+&irdVw#YJW|ohG0;bN%mWJ_FILI>M!<{&t5+MMK~R zl^tG?k4O;$a0XQ4opgZKXb(|BZ>1ouV7d|rX@4i}hmBI6!OX^9vNd;m5nNw(^?O4k zGa~laK6%pbvn<40jvr+%-eqBc!_lLnR!`>%i0X{*P%o*pgS{v#6v_>4f?N$dJm<6E zlK4Fz9|Gf{)ob5@b?XEG5M@yGUNUX|GRNNR`3ywvp|vPr+6{h5USGw_X3xrd9?LR! zzyO3VCm@{U`q75&EvreNV@{92l#N607`{h+Qi{>u3(x|m*dgwr52#7rB3~71W@nKAt2%}Bf+93+-DSw0`lXh#Xr}(^aK6{aCbX%16cZ|p z#b(<$aDt117$1>3QZf9cAqA{-(wO`9&rFyQWZUd{v~7O)YzDRXAr!HDBQ4J1n#5cV zlDT(r5;?8ktJyI3+A`hEWr*CRSSnvuP26Lv$P6o>TBmM}9~BM2)*jVXrtUbV{^d257~M+dLI>qiiOcO+N8JA~PuUOm25{E| zE3F(bF-c9whv#)$6KTWa_{zslFu`$-Ft4>;0<&S7LX|U+p$k9gcExu4!-!cq#?&)z zWI#yUNwhgfKm(upcCx#%tSDA{C07|EI1rrD(vmbfEP;;G4zvuqWeNe>(WOr~iRfI0 z;aC{?%a#Fm6=ybjJ}C3*MgBmu{dM5doiiwv(Gww5wEHHGIg$7YuvOUM`NXdOG_qdt z^*a|AX6x~Z867CMCm`v^jA2;3B|*s8S2f&0`@gJyaw#ft@n3zltjw3d@ya7wcXugqF0sk3w~Tv_du|I z%^_yyj=NdU$ZsR3?7BKtE(zNcPj^3nq-$!;S*U65<@6HC$^xV2`svL%n)Ho{1Mr%ayAWY1gz zn>l_4+NZ>GHNv5UhbiIA?og=&QU^$I@Br!D6#6 zb@0{`2MszyXu9xx0J;O#){VS0R0SR2_Z-xqF2nWv&Ym3}tuddn`-dwOKuMgQb?)3{ zgO%^hnJZF7SN?s~^A`C^!n85}n!ok6Mcoe=y(#<=GULkVU)6TC4TY7kkg{r+j1x@! zh&YNMey2ru`adg|)$5Unx))gxb4{0bY?W6%ayn8evcDZNUbRNtv!?bBlZ}GgD8%#I zpI?`H=D?=usW$J1LGbh!=~KVQuE4qv-7Wn6&~KwhU;8cf=mhCTmc!}DiWG5blRFR$ zxJ_zK#xfABjN>%MgVb;dg*LBMB=SU;p?DaN%K#&N^8%Mk3B%Aw0V}2JfV1-GF66dd z`KA|PV-Q+Z1Pnm!FeN1=0#IcE2PxA0{5kk^Ei%{QQS$Q_;;`{i{h@s>p}8Q(_HPR0 zGBJR}(Uv`+a5b6_0M8@dbbJB;QM~2D77H?i zAHok!p#UNTMPvQYN+ury3*cZUc6~;ntd4E+5s?7mZw)>fLn<{U*nj{Y#nSE52pG)> zzlO4ssz|v@0L$m9u6sbvo1>or*0osjWD3gN1?4r%&wE;{&ax^lH<=pPX(Boc8Zp=7 zTWA2kPedJXnUX7|($C_MdJ$|d80b(BXo!u@wZH}baFj?ieBMq%zcL)$o`Ni_5};-_ zboCfyK1x%{HM;Zx zWOImwE=~K$IQ}Xc){p5&LW^#5Q>9|+7?1~08#%f80#KVkID{0RGqyylIVnxcT8Mh}pVcv4HlY6raI>Nj|Nm6~LI2V}r?xqIr&oLZ|k0TmgsD z?u-(eLto6(S>I)tV-7lZq{p_XLV(L*!vR8y5QT|g!tH2Le- zHD_rUv4d{R?jM}s9wCPKGZz(@N%ljk0s`;?@$pf?S!T24t;(;#s3FIJ5qW2D@Vf?C zfa#t)`9|*nr_;-~`~f&wlr{WvIA&?`+v^iSU~vrF9Y*G9a%xYWo>|(QSViLdl5?b-!(X%| zPbCfwzJjRF%vtuYiA*8uM=%%tR@$aMjf--1c{VXJks#w9~{H^evc#Jo$miG4DX%ix$1YGyoeqi zHVi2j;4iGJSN@tTHheqUd+v_^-7ty+=G9l({R3;rhHu`C1Ie^wN*^Hy<;)XMNVF12 zD6Y#COp8aG|E5BtQAVY)VIFpb1fyB$?wNN-@QpL6d5=TuP+Y6kO9WU=hV!kd)tz_Z zg}tmS7E6P5SokrsM?K-PIwGap)<_JTJF`cwo-|d#=}g9vqn@qbC74Zf(Mc1XhCu?2 z0)#jh^(;G(8siry|GCn&Y;z8LXu>NwraP|!10mssWXVHpe~qewHD6hM@d5I@(DoMr zcNo3;959L1p-n)C(2t-6MPKUii9pX*M#91z+;Mm&EeEj4%FNE5qQj!OP?_djzLFVt z`p(sTa1vgEhfa{Eq0LQDE?#q}-K_*OgpUW`sktR8Y`LXzUbuGjo!w`TzIFlp8-y9P zTn4u^9Mdz8A?Jh3Bcs(&f+N6B(}$n64)NUB`XhhJlqGR{e_F5&0 zf+Ar|sH>r3h>@S6iUMd2HF+JI;dZKH*NFo|VoG5E&QMCHwwbB#+|g@TAr^B+xISvs zdV4CV1DP<-PC1_MX+Bh-31`Y>WDGH&Vc;v&`>4#eL@bzmnjT?)qgKZYj8h`-wNi=& z;vfN#eUUuhW{Z`}mVxGP2|`VhsndFB(~jga@kdkyJ`hL-aCLQ*HIhu8i2u$0Z;?m( z9d7hpERK?G-!6Ny^&t~a(b=r?AFZp$`FyJ)k=$T-ay2p!7Hsd1>`TPdMD_n*KEIs2 zEUctZ=$uzizu*TcV<7lGT_ORxB-oUK3Zew$b=F`>oTf<-5ZU2am)wW)=zV$~jjw`% ziZ{Xjfc+YULR7e3Bm`=R<4Q5M`vOQY(N9hnC9sc_+E|mu4`z%JJu`l7PMi-zuWiWbXg2H z;r@+M(bb<#rr?FWc!$-n+VBo*v4TE#nzoGg$t^l-p>b;o(#H`3S+L+=CXzAx5ke&+ zxP*c8x6xiZ=*(S%HGklG?a^eMDn?Mxy1B0MOm-h5Ji2rG)L-UEz~^xp zp|0`Y~p`%A)0m38f5*nx5TL6aS zU8o@gNe50`c20m<9VY~xPvc_$!3RRC0yA9?hvUQ;N;a?sK@jUjOWd|WAu6PR`aUR@ zNbn&y?y?ZBxX|1^m*^W7s3pBc>v1rbwGXRN5)6hMi@_jBg#UudKijC}TVc|z!}wtp zzMh?k9E8}hRE{jdp<=tPMPfkM>L~XtUa43k@*&Vy-{SkBq+>)ZfOmZNa{bZIUItN3P zDl3sc7wAYSOGp(`3!iIFfO6Y7e0U)ms~>5`d|FIhhbC<6s{?pR76`NlW3Vp-IJKTk z8@0L@?a>^jeg6qmUCIu(oU*%OsoOsSqi=J-ALt?*V8h#F9OPx^`AVfCj3D#X2=vz$ zOvoTyxoZI*k2Rc$tVY*pGnDxcuoS*&U0rD1<;ysvOQTZz2T>x%9XY(ikYxP6++cPa zW7+}B5AB_fy+JF6oAl<7r|b>MJM!D2H|QAA#w8R5_#MkyN8CE;q9G=Z0Fv9eeK*5=FTmw!rjJdR2gfqYsbla20DYm%b(qf&Re z*?rf3X>a2YsjH`YOYe7p(^Me7*7L{4mf6F*d?J<~Dkt`1t-kxZlv?Y#HcS)t6NCS&t z5z~47zaEizyFNA37?8GP{DUCuTX{UDDE1al zAxz|Z4X9C&bWMWjXQ+wDR#9kCDnv@)jW8>hy4|3r_se(tgj!@X+K3wO=N3lgV3vFpi@>QiTaRvYwk3gr=6tqDk402_V5b za5N`pSZyllZ|Z==0+e1P#ls&8KsNZY(x#D8gyDqg|3cfLa$e|5IL2B1AmQ?{vA$d^ z3?q?iq~Cf*bA49);T_)n_;Lf*5w;p)^97NCH!9=|;Yo~DFd=9u%g!rd@G%2LqXZTc zoYS_|(G+SpQPtaY42qKDNx=bw@i5Hh(4~4qc9HbU2G@`b&}^VJz3pVVsgwm`5T-=R$a&f!P&F1YSYi)Y{~sXi7)`z@8!jCY{Uy7wg@?+B!v*_c`tBf*x7>xFpufD zcDtpbZyVBcjk4tJx_)>;rOfUL&r_a!#7~Acr-Nv|0KP1{7o>d4*cb_b<@*sv=~jU} zdBRK-6*G~J#jr49Kgn@CpDnO#zLKVH8*Xj%1W#Ac65azDsklOAYm&{$gQE1p8JI2w zMGGRA1Z4}81H?)6Mta@p48{;VM*avI7kFcR3_){TGpF2>91TGq%hGF7h8&;={c-2A z%mdrn2EK2wShA=9@7O@V1)9_Kp_^hS&w5S;jJ8nd)9f2Gb-s;$jK5H_p9Qn?#Oz_0azeqULBc;b*QmQd zgEH;)cwzftjeLRB6XOtcB(%js@mupBqw zmhW3bJuD}T7rH>8P68pjUBbj2&MpawXE!Gha>6Kj z;w{7B>`$A(NRTY2#Zq5?L3Q?RkB4QCk!FFhAXyK1{zPS`hTv&<$_UjE%stu*CtT6`^SX|PGu6t?jncmjf1h=KQ`7cceqTJV>mXG#=@+| z$v?3(8Ll8l>FjGULz&xEGBElfq$!=QPL#js5-9P5IpB|@MQk@Dws!D2!f4u^6T zr>*Ja<_pbVFmBp_^J(Ae1U$NCg5OV(Pl8Y!oO+uUCoa zz6?b~=%rFVbyVVXE(xTnYF}vQZCz?_=OsUT(&L$06s61rN=8&9WpgMMFshl!xny3$ zMBVSB)(My+z-RR4VKJA3{twYvDGEhAn`|ob7>tCiEBkA8DLDRYsL&-EvP@1|54QN^ zE?2lQU}TV8(P+Fh&uqgBl3Y&GjJ^OfOUt7USSnRF2_GpP&|8pJOtsPIm?)uBiK0%i zO#w^MGBUMZ!3LZ~#ibs-kk9GiCS{(&lL{t%KP}f_b%gd%*$FP7be`9DhJ>6N?Nph4h_8voW&n_uQ-)GN69o7D_7`#ON#^uUB_{7l@Tfl$}48UbuQc!tWM`d1| z7VU3fEBM2O46+4|M(I8V>iH|fVQcN8y1Eb&-YqtfRS5-`eth2of2EKr6O1vUQJH06 zjL0dWS)9kRim2cR5>C_z%efoRTcZ z0m(JERk8y6Bsb#`$wfSNvEDBZACp{$4@oY+ut#}t^rHMlwipX9-Fxfg?w)@fOuO`7 z5bn$<5gQeM&D~4i;MT7@{feFd3~F%v;zMfKXmL?ZcY<5u zSAoZIJvmU6f{l1qm`nV4fC6^HS2@`qBWaR-QtoLTA8)GFM-!I^EJPdLw?H5N-;3Rq z6CjlKqnT|~U_g3^6JdSKlc0*KbDJ2Z6ikls=zg`uW&l`N)3qtPcXA~SL&Tt zuU2;_9XdgaS<>zg@hvPOK>CX`N)TA!pEm1<)(LR5pT%i*S3|}pGtITD zrY32ci*7E7a^OwjQkfByZQVTM9s^3CC~?!t0h;c@HA`c8F{Y)!%6cblxlK>Cu&DIn z=M0Ar2fBQ|czJW{Z2lljhi&N1H}yq(i#8ot49H^SDz7)!98fn?kM#yFHLCnDlr)wd z(k2}r_mnqFZPdO{PPb?@QRGvy)CQWg7a_Oh*MjoQTbNaamf&;M)M@PY;Gu{OH6kVu)W_zasv=TtYtsc; z09-zlq$KTmY^0``YOO%j>!>u+c{ z3cX-9J6BdVEq zrY1&-(RelLJ7~;Kk78)IVpuKQL_obEiknjTY&Gos2f!k~(kg>+vQ%|sXq2mI8S0R+ zC&2R5U@c4Vc2MX>AfGeIjUIsPkb;+-n!yzvgs0J>$bX+)#<$o=N&0*R)Y^(!xOY1nDLNcglRv_m!yJ)lU45v84Bs@FURZCkA@FvP2>K#vx#Sb&Q(m*j9c zK@OurOPoE@`qK`sR$4in>+fM>SaruwJ01p6CWRfYY2$DM5;-2Ow*02FPKSC_z-<$n|3~ z1&Xu$X@WoX`vj%rA0?1OpukC-D6@TDV$l5=2(5>|Z}~jBZ72rlK<}j9TK{`YpXH74 z)`GBr?2YOZfo3CMq|>=B@P)RQJ#fI0%Ebwk*J%XOk@p+cmb{W=h5nx~@N25zdKZ91 zV0t)K9Ed}lF?ZX-i-wd#ZfB!Bc_gW)km=hBNa#I}6_F^xWW_1;54jewQ}EU^E z@2CM;azdaabV}Kd4shk?9c8lRIkfIX4@NXW1y9;RKb)ARnUP>oGbf|62vQzX8^P;H z=|h$i{e!Gsyx_^3)5k+`PFJ^4XsnADyDmawU2Kf=zW>-Yum$Z38(8-G`U|)u$SN1C z$hDS*Vna!SXg6BOxz^e~P0kOaBqS)bU%0I(5XK8PRBV5AyO{vCIS=msuL`}#Y5*wc zk;%nG!gMWYn=?C)=^ZSR)UWplKL#QE$h9+#V;hc&{XFw00yO>=v?lOl>&w#Z+GrnR z$6-4>X{RbX<&>WK=<(W&=93OFM76l%qVAApp=#ap6gdjyIsXaE!qAIO<5yyOq@Gxe zUy!-DvNIn8lJ2<37WwPc1)-k@z=DQEqIHEMdgOBIw)$kyu_;6l^@^@9)Jo4i+jJ;L zamF}(I!udA%=&ygv+M0DQIFN@9fIV$0G8Co>cT4ciuHQs3Tt&@VZWc}Q%q$?0Bkw2&k zek5X`=Xj8~0)0kJU|#*O7%k)pRS=&d;K&JxjUA~Hs|qI+y|nwbijQ5*v2`gDIYk>! zQUZZR#CHKuDq~v8G_TG-h}O=#@8`WIEANV|x=)HN+^n{RW?yEv`!wy#_-7XN>z7d? zxpz;vuaQ5IL)!4PRvp!ZP-}Fjei2pyx|2iSR1b&I-YV+0zEmFDvYR?dnJqOEcZ1~yGi--0AFrc`Zh*U?#{ z;D#{p4-d|$NeM6!vvr2VVb;C8*PT|N;ks|}ci-N8vP~|4#S2vMnDngkXBD^avpoIY z|NV_=c?2cy{Mnov{K)Z^Tkc*1IeGll?qgTkrpg z8P;`|{{fx?DgpvpKR~0t!W$dc!0K_p?YodNGMb3%889rQerw#<=Y&l}RpYcQ?@d+D zsJ0I!9y4d{u>(kI;4>Lm>lRgI0%IC;=h8(A;ZHM01r z-6su)RLKMvf<~xugVcrqb6()Ts4|yff_9v+dCI8geJ%B37_wgOW)Ryr;d^NNTydk) zLg(izIYyP=DAso@f={@l*k?#rtwF`EF#_6FGGP(XYec$XoyO8b*6%A0`6bIytx#(1 z#EMh!)LQacM=Ts~s%f_KlmADj-n+0`L$P|;+7@H!BY&@|SSgZoIjsmFvLdO)Kl6Tx zd#?*Vp^5n^?k&aQRkEpJpYqmVq54xwbgX5p)*X@u9`y!=tco(LePn;TX6+Ctv#rW$ zzsqX%=Crr69$iD2Z8yAve_%JPg0rv(s zu$%4XtjzK;x8?ho#M$M?x9RE^g$;ly(B=fp)5wZSRMUqVYO;Y)()6axd@tTMzcvYl zE=N!@o|hFy{L^JuEtW)6L>raV6fsa#!WVcA*8&^h&c#vY-rLLS2K93yn-#1a{4>Y@ zc}Wwd@^(UG9i*)e0rj;>IU%N2nAJ8Uw`N(T$lTf}rFK}5#vP`1nN$v%P=1QaJ3vzP z5?B6`TJ6i=n?kBBOse*lbF->Ckbl6YT(FU*F*VA$#OH?-<1G`${IU~ANSeITQZ&k; zcei9EtJ0l~W(Tk9m0_Y^={PUS#^sh=~zLnWzPrr zz}g1z2-(hD62O;L#~66q=3&E!V-)L6s@#q+%LBIp7Xq6C=L3%c-))gf(hBJzt2(6( zz^~mwv=1NCmd&!;8EmT+nS8PS-#hgao)DA~Vu{3Jpj)h(nH6KtBbpGv-BI+-NR&;@ zgfyAW6pxCVY34ba8QyxES>~Rr(g>FiKm>_{Xm-ch+3Z0fr+GM$^f8_>6XZC z6WcrjC$ia_oTk}_hEQ zVofVeUAZjt_&0TSkXs^1=2l&)w7=zAm88|FnT^XVG9et2)HG8k+VvJ#C^dBSG96{% zIw#q5^ALKIt8a=Q`Epm6TW+3Y^XvL#sggqr^~Xnb3wl;sV7xs@Zlz%&Tjv64qGgLk ztIi)S3e8tibK@~`!;Q8;YHB*>O>*e*F;|_#D6Zuw&7rwSQ-$9bc64mBG1?~#S-ZV*2BYJwd#V$72v(rynu42nmn^Z}aw=jk;lx7Y$bBiBy_f(hN zQFld{Pjg1xUfb*g1rDi<0K(oJl}@X5%iOEzNkUg#6a!T1%~PW;IViqp zpKfsGa@jsfL;L-3btfhMbKAcuy zWy8h%^w(#|ml36;iAWGgj<4#tREKo(V~g zZ=y#sB9JCk`USY8gyU>+=_dd61(G8A%}kLQ=>>${=aJS}>xe9?tWIuu$;N-pU*bF+{NlJv2n`ajmcr%sdh1L0o1;UWNfRIc3D0U#P$;-B{aucE2u zT52sph<1#vqr@b}(nvImU3*KFE>m0WbzuENZl?FfkvZ#}&FQSG?s`-^PlQqBcQ;&5 zZ+-L`IK{GmSh?n+OMY>ZH5*D&ZQ5P%v)O-BuD=A1G}_+wwO=WJe)Muro_qz?JES;c zjoV^VCOX)pzcba}305?`Sn(1iOO?J9A^O+z`s-Yb;-oJPSx&B0`+Yb-W{JWjB-1FX zx}oWJq!1-pQ8nE#E!%NDTs(XNLLy=kQZjNlMaXg@6@r?EmX4l*k%^gwm5rT)lZ%^2 zmh_KUt?G7X*YLPDX;zf_xU5uZfNWujGDg`t9(vSMqS`quLn%Q<$zmI|Ko#U%s#0~7 z5)x#8Lh7?Dipo0*@V)0K>ng*YFs%2Y z^h5o5f^&lHsQFl@WT;;t*-9{C9r>?UZiN6aeEOAd~sq9UJatrh!SW+^UJflDg) zd`xTLe5}ngJF-ejRV>4uFt{FJq{t^oaQpW@#*fs2UBZ8##pU52pt3iQ59dJTD0E$Z zOQ`2XtP^v}O7`Z0J{x6^avEiytj&7kZ1a(n+SU7_3-2xMx(*?#T}&+x^7+cd*TIG| z5fv^<5q!35^f+iV&6r6=k zQjy#T)$yU%H>BjD6K&Jtwz6Oqb|(kln2&e$Dw;w28mglq_&M`E@n{Xk*Wml4s2qJh z${cVN9ILd}jw2L{DLR<<_>dye`o&^8g|*f9k4El8-dUS@`&QZJCmA2A$4aTJeO*NE z_Pmp~aTj2X2 z43^}<7665C|EI85(bPD2k4%80Il`KA3%cU-zKQQy#p|4KRRpyPzW<< z8~_CD6fgh)2LPaZ09wEwK!OT91r2hKP5p7>-i&d$KR@G?xW|*oi__VI@+%Eq2x8^P z8NyV4YPa6)8l(qAo_R$hpD4q-REdV@M0mOAo9>TU9BA*WF#S*0ER9+=E=sqYz;hmO zJ7%rm>2Z@+)NR>B{c_iV4h=-cJq%LEqKW$&RvfcxlGL>yj0PT-7|SM_LXbS>n_;!i zzp=V}^s-yi#kOLlOjuJsMgOUAX6>SG?7G(I8T(%3Wb(xJr@(u%Y^O2qv9J5tM9vYP z&!uPaDys0Lp}y3aH;9+y*iK@*3XVr%H|`DLS?l1>_Umx3v+A2z#(u)Hm#f;FNWqS( Tp~gD%=1~4;zp%;UV+#NP+I>lo literal 0 HcmV?d00001 diff --git a/app/web/asset/font/material-symbols/manrope-latin.woff2 b/app/web/asset/font/material-symbols/manrope-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..9d7abb17d323bff647995a94222b92b26a0fc93d GIT binary patch literal 24576 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 v%=2?SKkNhLp=^Ephf+&9*XZhQ=N%1 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 + + + + Error 404 (Not Found)!!1 + + +

404. That’s an error. +

The requested URL /s/materialsymbolsoutlined/v36/syl0-zNym6YjUgnMfxKof8KX6U8S8bf9IH0Q.woff2 was not found on this server. That’s all we know. diff --git a/app/web/asset/images/skills-detail-figma/agent.svg b/app/web/asset/images/skills-detail-figma/agent.svg new file mode 100644 index 0000000..6e87e61 --- /dev/null +++ b/app/web/asset/images/skills-detail-figma/agent.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/web/asset/images/skills-detail-figma/article.svg b/app/web/asset/images/skills-detail-figma/article.svg new file mode 100644 index 0000000..4ba9b73 --- /dev/null +++ b/app/web/asset/images/skills-detail-figma/article.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/web/asset/images/skills-detail-figma/check-circle.svg b/app/web/asset/images/skills-detail-figma/check-circle.svg new file mode 100644 index 0000000..f55efdf --- /dev/null +++ b/app/web/asset/images/skills-detail-figma/check-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/web/asset/images/skills-detail-figma/chevron-down.svg b/app/web/asset/images/skills-detail-figma/chevron-down.svg new file mode 100644 index 0000000..a6d357e --- /dev/null +++ b/app/web/asset/images/skills-detail-figma/chevron-down.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/web/asset/images/skills-detail-figma/chevron-right.svg b/app/web/asset/images/skills-detail-figma/chevron-right.svg new file mode 100644 index 0000000..79a120a --- /dev/null +++ b/app/web/asset/images/skills-detail-figma/chevron-right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/web/asset/images/skills-detail-figma/contributor-1.png b/app/web/asset/images/skills-detail-figma/contributor-1.png new file mode 100644 index 0000000000000000000000000000000000000000..f2ced189a7efefa5a1d0db14e15a58bb978dea83 GIT binary patch literal 515 zcmex=XX?bZN(pJ+`RaVtgb1~J| zH+AuIvUBo_4-JhkFD#!k=OV}uMv4tN#30DQV8CF&%&5e`B*@4t$oT&VgERvJ6B9Ed z0}^0jWM*MwX9Y@1FfuSRqR25YGPAP$zs0}<)WRgdECAH@`MAN(3-@w2UvN{IIcZDT z;vT81r;abXvr^EgMTT`IGtd|n0iZS(MizuMK+S^8EUZETY>GyO!b*k)KqEPTHZj^W z1T1tnSeUrW^7|CY!phhy5g&DuS@yI}eX+l(`*p+}yLF3v%^Np42Y-vH{gRDLM!AF)`Uc4E6=Be(nrU|PK{`}T7fA*B@sf|XX?bZN(pJ+`RaVtgb1~J| zH+AuIvUBo_4-JhkFD#!k=OV}uMv4tN#30DQV8CF&%&5e`B*@4t$oT&Vg9HO32rwW4 zW=0k^po}<%1OqDz^Z#26Jj{#?OajaTKrLUJdmb<_uM=;}5|&DzxqzV_s83OVfti7k znS~i{BqI~E5QCs2i?E`Bf?*)g1P-8ujP?xQrTAtlc4-GK*rQr{>0r<*K-!-Sd3=1)1vq3?LT^GBN;NfCPjXnG^)U#xgMijXml; r_1b2jLTNz;MkH4NH3%~bDHt*fUIeRU2B~dx?_|5kYaV_BX!K10#jjqH literal 0 HcmV?d00001 diff --git a/app/web/asset/images/skills-detail-figma/copy-dark.svg b/app/web/asset/images/skills-detail-figma/copy-dark.svg new file mode 100644 index 0000000..5047be1 --- /dev/null +++ b/app/web/asset/images/skills-detail-figma/copy-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/web/asset/images/skills-detail-figma/copy-light.svg b/app/web/asset/images/skills-detail-figma/copy-light.svg new file mode 100644 index 0000000..99b4a20 --- /dev/null +++ b/app/web/asset/images/skills-detail-figma/copy-light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/web/asset/images/skills-detail-figma/download.svg b/app/web/asset/images/skills-detail-figma/download.svg new file mode 100644 index 0000000..a294100 --- /dev/null +++ b/app/web/asset/images/skills-detail-figma/download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/web/asset/images/skills-detail-figma/empty-related.svg b/app/web/asset/images/skills-detail-figma/empty-related.svg new file mode 100644 index 0000000..782d86d --- /dev/null +++ b/app/web/asset/images/skills-detail-figma/empty-related.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/web/asset/images/skills-detail-figma/file-doc.svg b/app/web/asset/images/skills-detail-figma/file-doc.svg new file mode 100644 index 0000000..1cbed43 --- /dev/null +++ b/app/web/asset/images/skills-detail-figma/file-doc.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/web/asset/images/skills-detail-figma/folder-open-arrow.svg b/app/web/asset/images/skills-detail-figma/folder-open-arrow.svg new file mode 100644 index 0000000..a393de4 --- /dev/null +++ b/app/web/asset/images/skills-detail-figma/folder-open-arrow.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/web/asset/images/skills-detail-figma/folder-open-blue.svg b/app/web/asset/images/skills-detail-figma/folder-open-blue.svg new file mode 100644 index 0000000..01b59ac --- /dev/null +++ b/app/web/asset/images/skills-detail-figma/folder-open-blue.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/web/asset/images/skills-detail-figma/fork.svg b/app/web/asset/images/skills-detail-figma/fork.svg new file mode 100644 index 0000000..3d7fb8b --- /dev/null +++ b/app/web/asset/images/skills-detail-figma/fork.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/web/asset/images/skills-detail-figma/hero-skill.svg b/app/web/asset/images/skills-detail-figma/hero-skill.svg new file mode 100644 index 0000000..a27e658 --- /dev/null +++ b/app/web/asset/images/skills-detail-figma/hero-skill.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/web/asset/images/skills-detail-figma/human.svg b/app/web/asset/images/skills-detail-figma/human.svg new file mode 100644 index 0000000..475e7c7 --- /dev/null +++ b/app/web/asset/images/skills-detail-figma/human.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/web/asset/images/skills-detail-figma/star-outline.svg b/app/web/asset/images/skills-detail-figma/star-outline.svg new file mode 100644 index 0000000..d8fa2bf --- /dev/null +++ b/app/web/asset/images/skills-detail-figma/star-outline.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/web/layouts/basicLayout/index.tsx b/app/web/layouts/basicLayout/index.tsx index 068cb99..1e79f7f 100644 --- a/app/web/layouts/basicLayout/index.tsx +++ b/app/web/layouts/basicLayout/index.tsx @@ -11,6 +11,7 @@ const { Content } = Layout; const BasicLayout = (props: any) => { const { className, route, location } = props; const { pathname } = location; + const isSkillDetailPage = /^\/page\/skills\/[^/]+$/.test(pathname); // 如果弹出过哆啦A梦 Chrome 插件的弹框,则后续不再弹出 React.useEffect(() => { @@ -39,7 +40,11 @@ const BasicLayout = (props: any) => { }, [pathname]); return ( - +

{renderRoutes(route.routes)}
diff --git a/app/web/layouts/basicLayout/style.scss b/app/web/layouts/basicLayout/style.scss index 9e466b6..c2fc1dc 100644 --- a/app/web/layouts/basicLayout/style.scss +++ b/app/web/layouts/basicLayout/style.scss @@ -1,26 +1,32 @@ .layout-basic { height: 100vh; padding-top: 64px; + box-sizing: border-box; + .logo_img { width: 30px; height: 30px; margin: 0 8px 8px 12px; } + .system-title { font-size: 21px; font-weight: 700; color: #FFF; } + .local-ip { color: #FFF; font-size: 16px; } + .main-content { height: 100%; display: flex; flex: 1; background: #F2F7FA; } + .context_container { flex: 1; min-height: 0; @@ -28,4 +34,22 @@ width: 100%; overflow: auto; } -} + + &.is-skill-detail-layout { + overflow: hidden; + + .main-content { + flex: 1; + height: 0; + min-height: 0; + background: #F2F7FA; + overflow: hidden; + } + + .context_container { + height: 100%; + padding: 0; + overflow: hidden; + } + } +} \ No newline at end of file diff --git a/app/web/pages/skills/detail/stitchAssets.scss b/app/web/pages/skills/detail/stitchAssets.scss new file mode 100644 index 0000000..b5b2ee0 --- /dev/null +++ b/app/web/pages/skills/detail/stitchAssets.scss @@ -0,0 +1,50 @@ +@font-face { + font-family: 'SkillDetailMaterialSymbols'; + font-style: normal; + font-weight: 400; + font-display: block; + src: url('../../../asset/font/material-symbols/material-symbols-outlined-subset.woff2') + format('woff2'); +} + +@font-face { + font-family: 'SkillDetailInter'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('../../../asset/font/material-symbols/inter-latin.woff2') format('woff2'); +} + +@font-face { + font-family: 'SkillDetailManrope'; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url('../../../asset/font/material-symbols/manrope-latin.woff2') format('woff2'); +} + +.page-skill-detail { + --skill-detail-font-body: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', + -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --skill-detail-font-display: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', + -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --skill-detail-font-icon: 'SkillDetailMaterialSymbols'; +} + +.skill-detail-material-symbol { + font-family: var(--skill-detail-font-icon); + font-style: normal; + font-weight: 400; + font-size: 20px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + direction: ltr; + -webkit-font-feature-settings: 'liga'; + -webkit-font-smoothing: antialiased; + font-feature-settings: 'liga'; +} diff --git a/app/web/pages/skills/detail/style.scss b/app/web/pages/skills/detail/style.scss index 6597545..bfbef91 100644 --- a/app/web/pages/skills/detail/style.scss +++ b/app/web/pages/skills/detail/style.scss @@ -1,424 +1,1193 @@ +@import './stitchAssets.scss'; + .page-skill-detail { - padding: 20px 24px; - background: linear-gradient(180deg, #F5F8FC 0%, #F8FAFC 100%); - &.loading-wrap { + height: 100%; + min-height: 0; + overflow: hidden; + background: #f7f9fb; + font-family: var(--skill-detail-font-body); + color: #2a3439; + + .skill-detail-figma-icon { + display: inline-block; + object-fit: contain; + flex: 0 0 auto; + + &.is-hero { + width: 25px; + height: 25px; + } + + &.is-tree-node { + width: 12px; + height: 12px; + } + + &.is-option-icon { + width: 22px; + height: 19px; + } + + &.is-agent-icon { + width: 22px; + height: 19px; + } + + &.is-human-icon { + width: 16px; + height: 16px; + } + + &.is-chevron { + width: 7px; + height: 5px; + } + + &.is-chevron-right { + width: 5px; + height: 7px; + } + + &.is-copy-dark { + width: 10px; + height: 12px; + } + + &.is-download { + width: 10px; + height: 10px; + } + + &.is-article { + width: 11px; + height: 11px; + } + + &.is-empty-related { + width: 22px; + height: 28px; + } + + &.is-related-skill-icon { + width: 18px; + height: 18px; + } + + &.is-browse-market-arrow { + width: 9px; + height: 9px; + } + } + + &.loading-wrap, + &.page-skill-detail-empty { display: flex; - justify-content: center; align-items: center; - min-height: 420px; + justify-content: center; } - .detail-hero { + + .skill-detail-shell { + height: 100%; + min-height: 0; display: grid; - grid-template-columns: minmax(0, 1.65fr) minmax(320px, 1fr); - gap: 20px; - margin-bottom: 20px; - } - .detail-hero-main, - .install-overview-card, - .file-tree-card, - .file-viewer-card, - .action-card, - .related-card-list { - border: 1px solid #E4ECF5; - border-radius: 18px; - background: #FFF; - box-shadow: 0 16px 36px rgba(31, 45, 61, 0.06); - } - .detail-hero-main { - padding: 24px; - background: linear-gradient(145deg, #FFF 0%, #F5F9FF 100%); + grid-template-columns: 256px minmax(0, 1fr) 320px; + overflow: hidden; } - .back-btn { - margin-bottom: 18px; - border-radius: 10px; - border-color: #D8E4F1; - color: #35506B; - background: #FFF; + + .detail-left-sidebar, + .detail-right-sidebar { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; } - .hero-kicker-row { + + .detail-main-column { + height: 100%; + min-height: 0; display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px; - margin-bottom: 18px; + flex-direction: column; + overflow: auto; } - .install-key-chip { - display: inline-flex; - align-items: center; - min-height: 28px; - padding: 0 12px; - border-radius: 999px; - border: 1px solid #D6E3F1; - background: #F5F9FF; - color: #42607B; - font-size: 13px; - font-weight: 500; - } - .hero-title-row { + + .detail-left-sidebar { + background: #f1f5f9; + border-right: 1px solid rgba(226, 232, 240, 0.7); + } + + .sidebar-head { + padding: 16px; + border-bottom: 1px solid rgba(226, 232, 240, 0.7); display: flex; - justify-content: space-between; - gap: 20px; - margin-bottom: 18px; - .title-group { + align-items: center; + gap: 8px; + + .back-btn { + padding: 4px 8px; + height: auto; + background: transparent; + border: none; + + &:hover { + background: transparent; + } + } + + .sidebar-title-group { flex: 1; - min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; } - .ant-typography { - margin-bottom: 0; + + span, + strong { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; } - h2.ant-typography { - margin-bottom: 12px; - color: #1F2D3D; - font-size: 32px; - line-height: 1.2; + + span { + color: #566166; } - } - .hero-description { - max-width: 760px; - margin-bottom: 0; - color: #5C7086; - font-size: 15px; - line-height: 1.75; - } - .hero-action-group { - justify-content: flex-end; - align-self: flex-start; - max-width: 320px; - .ant-btn { - border-radius: 10px; + + strong { + color: #2563eb; } } - .hero-meta-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; + + .sidebar-tree-wrap { + flex: 1; + min-height: 0; + overflow: auto; + padding: 8px; + + .ant-empty { + margin-top: 48px; + } + + .ant-tree { + background: transparent; + font-size: 12px; + } + + .ant-tree-treenode { + width: 100%; + } + + .ant-tree-switcher { + color: #64748b; + flex: 0 0 auto; + } + + .ant-tree-node-content-wrapper { + min-height: 28px; + padding: 0; + width: 100%; + flex: 1 1 auto; + min-width: 0; + + &:hover { + background: transparent; + } + } + + .ant-tree-title { + display: block; + width: 100%; + } + + .ant-tree-node-content-wrapper.ant-tree-node-selected, + .ant-tree-treenode-selected > .ant-tree-node-content-wrapper { + background: transparent; + } } - .hero-meta-card { - padding: 16px 18px; - border-radius: 14px; - border: 1px solid #E7EEF6; - background: linear-gradient(180deg, #FFF 0%, #F7FAFD 100%); - min-width: 0; - &.is-accent { - background: linear-gradient(135deg, #EAF3FF 0%, #F7FAFF 100%); - border-color: #D7E6FA; + + .explorer-tree-item { + width: 100%; + min-height: 28px; + padding: 6px 8px; + border-left: 2px solid transparent; + border-radius: 2px; + display: flex; + align-items: center; + gap: 8px; + color: #64748b; + line-height: 16px; + cursor: pointer; + user-select: none; + + &.is-selected { + background: rgba(37, 99, 235, 0.08); + border-left-color: #2563eb; + color: #2563eb; + font-weight: 600; } - &.is-wide { - grid-column: span 2; + + &:focus-visible { + outline: 1px solid rgba(37, 99, 235, 0.28); + outline-offset: 1px; } } - .hero-meta-label { - display: block; - margin-bottom: 8px; - color: #8A9AAE; - font-size: 12px; - letter-spacing: 0.08em; - text-transform: uppercase; + + .sidebar-foot { + padding: 16px; + border-top: 1px solid rgba(226, 232, 240, 0.7); + display: flex; + flex-direction: column; + gap: 8px; } - .hero-meta-value { - color: #23384D; - font-size: 15px; + + .install-primary-btn { + height: 34px; + border-radius: 4px; + font-size: 12px; font-weight: 600; - line-height: 1.6; - word-break: break-word; - .anticon { - margin-right: 6px; - color: #D9A441; - } + background: #1658d5; + border-color: #1658d5; } - .hero-tag-list { - display: flex; - flex-wrap: wrap; + + .sidebar-help-btn { + padding: 8px 6px; + border: 0; + background: transparent; + color: #64748b; + display: inline-flex; + align-items: center; gap: 8px; - margin-top: 16px; + cursor: pointer; } - .install-overview-card { - padding: 24px; + + .detail-main-column { + min-width: 0; + min-height: 0; + padding: 12px 20px 20px; + display: flex; + flex-direction: column; + gap: 16px; overflow: hidden; - .ant-card-body { - padding: 0; - } + align-items: stretch; } - .install-card-header { + + .detail-hero-main { + width: 100%; + padding: 16px 24px; + min-height: 182px; + border-radius: 8px; + border: 1px solid rgba(169, 180, 185, 0.1); + background: #fff; + box-shadow: 0 0 0 1px rgba(169, 180, 185, 0.04); display: flex; + flex-direction: column; justify-content: space-between; - gap: 16px; - margin-bottom: 18px; + gap: 10px; } - .install-card-title { - display: block; - margin-bottom: 8px; - color: #1F2D3D; - font-size: 20px; - font-weight: 700; + + .hero-head-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; } - .install-card-caption { - margin-bottom: 0; - color: #60748A; - line-height: 1.7; + + .hero-title-block { + min-width: 0; + display: flex; + align-items: flex-start; + gap: 20px; + flex: 1; } - .install-status-tag { + + .skill-title-icon { + width: 64px; + height: 64px; + border-radius: 8px; + background: #dbe1ff; display: inline-flex; align-items: center; + justify-content: center; + flex: 0 0 auto; + } + + .hero-copy { + min-width: 0; + + h2.ant-typography { + margin-bottom: 4px; + color: #2a3439; + font-size: 24px; + font-family: var(--skill-detail-font-display); + font-weight: 700; + letter-spacing: -0.025em; + line-height: 32px; + } + } + + .hero-description { + max-width: 760px; + margin-bottom: 0; + color: #566166; + font-size: 14px; + line-height: 24px; + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + } + + .hero-actions { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + } + + .hero-stat-chip { + min-width: 54px; height: 32px; padding: 0 14px; - border-radius: 999px; - font-size: 13px; + border-radius: 4px; + background: #e8eff3; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + color: #2a3439; + font-size: 14px; + } + + .hero-fork-btn { + height: 32px; + padding: 0 16px; + border-radius: 4px; font-weight: 600; - white-space: nowrap; - &.is-ready { - color: #1F5FA6; - background: #E8F2FF; - border: 1px solid #CFE0F7; - } - &.is-fallback { - color: #9A6414; - background: #FFF5E8; - border: 1px solid #F4DFC0; - } + background: #0053db; + border-color: #0053db; } - .install-tabs { - .ant-tabs-nav { - margin-bottom: 16px; - } - .ant-tabs-tab { - padding: 10px 0; + + .hero-meta-row { + padding-top: 14px; + border-top: 1px solid #f1f5f9; + display: flex; + align-items: center; + gap: 32px; + flex-wrap: nowrap; + } + + .hero-meta-item { + display: flex; + flex-direction: column; + gap: 2px; + + span { + color: #566166; + font-size: 10px; font-weight: 600; + letter-spacing: 0.1em; } - .ant-tabs-ink-bar { - height: 3px; - border-radius: 999px; - background: #2F7BFF; + + strong { + color: #2a3439; + font-size: 14px; + line-height: 20px; } } - .install-tab-panel { + + .document-panel { + min-height: 0; + flex: 1; + width: 100%; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + background: #e8eff3; display: flex; flex-direction: column; - gap: 12px; } - .install-command-card { - padding: 14px; - border: 1px solid #E6EDF5; - border-radius: 14px; - background: #F7FAFD; - } - .install-command-header { + + .document-toolbar { + height: 44px; + padding: 0 16px; + background: #e1e9ee; display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 10px; + align-items: center; + justify-content: space-between; + gap: 12px; + flex: 0 0 auto; } - .install-command-title { - color: #29415A; - font-size: 14px; + + .document-toolbar-left { + display: inline-flex; + align-items: center; + gap: 8px; + color: #566166; + font-size: 12px; font-weight: 600; + letter-spacing: 0.03em; } - .detail-main-row { - margin-bottom: 20px; - } - .file-tree-card { - margin-bottom: 16px; - .ant-card-head { - border-bottom-color: #EEF3F8; - } - .ant-tree { - padding: 4px 10px 10px; + + .document-toolbar-right { + display: inline-flex; + align-items: center; + gap: 4px; + + span { + width: 10px; + height: 10px; + border-radius: 999px; + background: #cbd5e1; } } - .file-viewer-card { - min-height: 560px; - .ant-card-head { - border-bottom-color: #EEF3F8; - } - .ant-card-body { - max-height: 640px; - overflow: auto; + + .document-loading-indicator { + width: auto !important; + height: auto !important; + border-radius: 0 !important; + background: transparent !important; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 8px; + + .ant-spin { + color: #2563eb; } } + + .document-scroll-area { + min-height: 0; + flex: 1; + overflow: auto; + background: #fff; + padding: 32px; + } + + .document-content-shell { + position: relative; + min-height: 100%; + } + + .file-viewer-loading { + min-height: 360px; + display: flex; + align-items: center; + justify-content: center; + } + .markdown-file-viewer { + .markdown-renderer { + color: #566166; + font-size: 16px; + line-height: 1.7; + } + + h1, + h2, + h3 { + color: #2a3439; + font-family: var(--skill-detail-font-display); + } + .frontmatter-table-wrap { - margin-bottom: 16px; - border: 1px solid #E5EDF6; - border-radius: 14px; + margin-bottom: 24px; + border: 1px solid #e2e8f0; + border-radius: 8px; overflow: hidden; - background: #FFF; - box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05); } + .frontmatter-table { width: 100%; border-collapse: collapse; - table-layout: fixed; - position: relative; - &::before { - content: ""; - position: absolute; - left: 0; - top: 0; - right: 0; - height: 3px; - background: linear-gradient(90deg, #2F7BFF 0%, #56A1FF 100%); - } + th, td { - padding: 14px 18px; + padding: 14px 16px; + border-bottom: 1px solid #eef2f7; vertical-align: top; - border-bottom: 1px solid #EEF3F8; } - tr:last-child { - th, - td { - border-bottom: none; - } + + tr:last-child th, + tr:last-child td { + border-bottom: 0; } + th { - width: 176px; - color: #2D4A6A; - font-size: 15px; + width: 180px; + background: #f8fafc; + color: #2d4a6a; + font-size: 14px; font-weight: 600; text-align: left; - background: linear-gradient(180deg, #F7FAFF 0%, #F2F7FF 100%); - border-right: 1px solid #E8EEF7; } + td { - font-size: 16px; + background: #fff; color: #344054; - line-height: 1.7; - word-break: break-word; - background: #FFF; - } - .frontmatter-code { - margin: 0; - padding: 10px 12px; - border: 1px solid #E4EAF1; - border-radius: 8px; - background: #F7FAFC; - color: #1F2D3D; font-size: 14px; - line-height: 1.55; - white-space: pre-wrap; + line-height: 1.7; word-break: break-word; } } + + .frontmatter-code { + margin: 0; + padding: 10px 12px; + border-radius: 6px; + background: #f8fafc; + white-space: pre-wrap; + } } + .file-viewer-loading { + min-height: 320px; display: flex; - justify-content: center; align-items: center; - min-height: 420px; + justify-content: center; + } + + .detail-right-sidebar { + padding: 12px 16px 16px; + border-left: 1px solid rgba(226, 232, 240, 0.7); + background: #f8fafc; + overflow-y: auto; + overflow-x: hidden; + gap: 12px; + align-items: stretch; + + > * { + width: 100%; + min-width: 0; + } } - .action-card { - margin-bottom: 16px; - &:last-child { - margin-bottom: 0; + + .sidebar-section-title { + margin-bottom: 8px; + color: #566166; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.14em; + } + + .install-option-card { + border: 1px solid #dbe5f0; + border-radius: 8px; + background: #fff; + overflow: hidden; + + &.is-active { + border-color: #2563eb; } - .ant-card-head { - border-bottom-color: #EEF3F8; + + &.is-collapsed { + margin-top: 8px; } } - .download-card { - .ant-card-extra { - color: #7B8EA3; - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.06em; + + .install-option-trigger { + width: 100%; + min-height: 55px; + padding: 12px 16px; + border: 0; + background: #fff; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + cursor: pointer; + } + + .install-option-trigger:hover { + background: #fbfdff; + } + + .install-option-meta { + display: flex; + align-items: center; + gap: 12px; + text-align: left; + min-width: 0; + flex: 1; + } + + .install-option-icon-shell { + width: 40px; + height: 40px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + + &.is-agent { + background: rgba(0, 83, 219, 0.1); } + + &.is-human { + background: #f0fdfa; + } + } + + .install-option-title { + color: #2a3439; + font-size: 14px; + font-weight: 600; + font-family: var(--skill-detail-font-display); + line-height: 1; + } + + .install-option-description { + color: #64748b; + font-size: 11px; + line-height: 15px; } - .download-buttons { + + .install-option-meta > div { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + .install-option-body { + display: grid; + grid-template-rows: 0fr; + padding: 0 12px; + opacity: 0; + transition: + grid-template-rows 180ms ease, + padding 180ms ease, + opacity 140ms ease; + + &.is-open { + grid-template-rows: 1fr; + padding: 4px 12px 12px; + opacity: 1; + } + + &.is-closed { + pointer-events: none; + } + } + + .install-option-body-inner { + min-height: 0; + overflow: hidden; display: flex; flex-direction: column; gap: 12px; } - .command-block { + + .human-command-card { + display: flex; + flex-direction: column; + gap: 8px; + } + + .human-command-title { + color: #566166; + font-size: 12px; + font-weight: 600; + line-height: 16px; + } + + .download-panel, + .related-panel, + .meta-panel { + padding-top: 4px; + } + + .download-btn { + height: 28px; + margin-bottom: 12px; + padding: 0 12px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + background: #0f172a; + border-color: #0f172a; + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + + &:hover, + &:focus { + background: #1b2438; + border-color: #1b2438; + color: #fff; + } + + &:active { + background: #0b1220; + border-color: #0b1220; + color: #fff; + } + } + + .install-panel, + .download-panel, + .related-panel, + .meta-panel { + flex: 0 0 auto; + } + + .related-panel { + min-height: 0; + display: flex; + flex-direction: column; + flex: 1 1 auto; + width: 100%; + min-width: 0; + overflow: hidden; + } + + .command-surface { + border-radius: 6px; + overflow: hidden; + + &.is-dark { + background: #111827; + color: #f8fafc; + } + + &.is-light { + border: 1px solid #e2e8f0; + background: #eef3f7; + color: #475569; + } + + &.is-terminal { + border-radius: 4px; + } + + code { + display: block; + padding: 0 14px 12px; + font-size: 12px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; + } + + p { + margin: 0; + padding: 0 14px 12px; + color: inherit; + font-size: 12px; + line-height: 1.6; + opacity: 0.8; + } + } + + .command-surface-inline { + min-height: 39px; display: flex; align-items: stretch; justify-content: space-between; - gap: 10px; - padding: 12px 14px; - border-radius: 12px; - border: 1px solid #E2EAF3; - background: #FFF; - code { + gap: 8px; + padding: 8px 10px; + + .command-inline-code-wrap { flex: 1; - color: #1F2D3D; - white-space: pre-wrap; - word-break: break-all; - font-size: 13px; - line-height: 1.7; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + width: 0; + height: 0; + display: none; + } } - .command-copy-btn { - flex: 0 0 auto; - align-self: flex-start; - min-width: 40px; - height: 40px; - border: 1px solid #D7E1EC; - border-radius: 10px; - background: #FFF; - color: #2E4A66; + + code { + display: inline-block; + min-width: max-content; + padding: 3px 2px; + white-space: nowrap; + line-height: 22px; + } + } + + .command-surface.is-compact { + .command-surface-inline { + align-items: center; + } + + .command-inline-code-wrap { + padding: 2px 0; + } + } + + .terminal-head { + height: 25px; + padding: 0 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + align-items: center; + justify-content: space-between; + } + + .terminal-dots { + display: inline-flex; + align-items: center; + gap: 6px; + + span { + width: 8px; + height: 8px; + border-radius: 999px; + background: #cbd5e1; + } + } + + .terminal-label { + color: rgba(255, 255, 255, 0.46); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.08em; + } + + .terminal-body { + min-height: 57px; + padding: 11px 12px 12px; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: start; + column-gap: 14px; + + .terminal-prompt { + color: #34d399; + font-size: 12px; + line-height: 16px; + font-family: 'SFMono-Regular', Consolas, monospace; + margin-top: 2px; + } + + code { + padding: 0; + color: #e2e8f0; + line-height: 16px; + word-break: break-word; + margin-top: 1px; + } + } + + .command-copy-btn { + color: inherit; + width: 18px; + height: 20px; + min-width: 18px; + padding: 0; + border: 1px solid transparent; + border-radius: 4px; + align-self: center; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: none; + + .ant-btn-icon { + margin: 0; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + } + + &:hover, + &:focus { + color: inherit; + box-shadow: none; + } + + &.is-terminal-copy { + border-color: transparent; + background: transparent; + &:hover, &:focus { - color: #2F7BFF; - border-color: #2F7BFF; - background: #F4F9FF; + background: rgba(255, 255, 255, 0.08); + border-color: transparent; } - &[disabled] { - border-color: #E5E7EB; - background: #F8FAFC; + } + + &.is-inline-copy { + border-color: rgba(203, 213, 225, 0.88); + background: #f8fafc; + + &:hover, + &:focus { + background: #f1f5f9; + border-color: rgba(148, 163, 184, 0.72); } } } - .related-card-list { - .ant-card-head { - border-bottom-color: #EEF3F8; + + .related-empty-state { + min-height: 112px; + border: 1px dashed #cbd5e1; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: #94a3b8; + font-size: 12px; + text-align: center; + } + + .related-list { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: 16px; + overflow-y: auto; + overflow-x: hidden; + padding-right: 4px; + width: 100%; + min-width: 0; + } + + .related-item { + width: 100%; + min-width: 0; + padding: 0; + border: 0; + background: transparent; + text-align: left; + display: flex; + align-items: flex-start; + gap: 12px; + cursor: pointer; + white-space: normal; + + &:hover, + &:focus { + background: transparent; } - .related-item-card { - height: 100%; - border-radius: 14px; - border: 1px solid #E7EDF5; - transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; - &:hover { - border-color: #BFD6F2; - box-shadow: 0 12px 24px rgba(31, 45, 61, 0.08); - transform: translateY(-2px); - } - .related-title { - margin-bottom: 8px; - color: #1F2D3D; - font-size: 16px; - font-weight: 600; - } + + .related-item-copy { + min-width: 0; + flex: 1 1 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + } + + strong { + display: block; + width: 100%; + color: #2a3439; + font-size: 12px; + font-weight: 500; + line-height: 16px; + word-break: break-word; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .related-item-description { + display: block; + width: 100%; + color: #64748b; + font-size: 10px; + line-height: 16.25px; + word-break: break-word; + white-space: normal; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; } } - @media (max-width: 1100px) { - .detail-hero { - grid-template-columns: 1fr; + .related-item-icon-shell { + width: 40px; + height: 40px; + border-radius: 4px; + border: 1px solid transparent; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + align-self: center; + transform: translateY(6px); + + &.is-blue { + border-color: rgba(219, 234, 254, 0.5); + background-image: linear-gradient( + 135deg, + rgba(59, 130, 246, 0.1) 0%, + rgba(99, 102, 241, 0.2) 100% + ); } - .hero-title-row { - flex-direction: column; + + &.is-green { + border-color: rgba(209, 250, 229, 0.5); + background-image: linear-gradient( + 135deg, + rgba(16, 185, 129, 0.1) 0%, + rgba(20, 184, 166, 0.2) 100% + ); } - .hero-action-group { - max-width: none; - justify-content: flex-start; + + &.is-orange { + border-color: rgba(254, 243, 199, 0.5); + background-image: linear-gradient( + 135deg, + rgba(245, 158, 11, 0.1) 0%, + rgba(249, 115, 22, 0.2) 100% + ); } } - @media (max-width: 768px) { - padding: 16px; + .browse-market-link { + margin-top: 16px; + padding: 0; + border: 0; + background: transparent; + display: inline-flex; + align-items: center; + gap: 4px; + color: #0053db; + font-size: 10px; + line-height: 15px; + cursor: pointer; + + &:hover, + &:focus { + background: transparent; + color: #0053db; + } + } + + .meta-panel { + margin-top: auto; + flex: 0 0 auto; + padding-top: 20px; + border-top: 1px solid #e2e8f0; + display: flex; + flex-direction: column; + gap: 10px; + } + + .meta-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: #64748b; + font-size: 10px; + + strong { + color: #2a3439; + font-weight: 600; + } + } + + .contributors-stack { + display: inline-flex; + align-items: center; + + img, + span { + width: 16px; + height: 16px; + border-radius: 999px; + border: 1px solid #fff; + margin-left: -4px; + } + + img:first-child, + span:first-child { + margin-left: 0; + } + + span { + background: #e2e8f0; + color: #566166; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 8px; + } + } + + @media (max-width: 1200px) { + .skill-detail-shell { + grid-template-columns: 220px minmax(0, 1fr) 300px; + } + + .detail-main-column { + padding: 24px; + } + } + + @media (max-width: 1200px) { + .detail-main-column { + padding: 24px; + } + .detail-hero-main, - .install-overview-card { - padding: 18px; + .document-panel { + width: 100%; } - .hero-meta-grid { + } + + @media (max-width: 960px) { + height: auto; + overflow: auto; + + .skill-detail-shell { + height: auto; grid-template-columns: 1fr; } - .hero-meta-card.is-wide { - grid-column: span 1; + + .detail-left-sidebar, + .detail-right-sidebar { + border: 0; + } + + .detail-main-column { + overflow: visible; + align-items: stretch; } - .command-block { + + .document-panel { + min-height: 640px; + } + } + + @media (max-width: 768px) { + .detail-main-column { + padding: 16px; + } + + .hero-head-row { flex-direction: column; - .command-copy-btn { - align-self: flex-end; - } + } + + .hero-title-block { + width: 100%; + } + + .document-scroll-area { + padding: 20px 16px; + } + + .hero-meta-row { + gap: 20px; + flex-wrap: wrap; } } } diff --git a/app/web/scss/reset.scss b/app/web/scss/reset.scss index 2725a4f..c48c346 100644 --- a/app/web/scss/reset.scss +++ b/app/web/scss/reset.scss @@ -2,6 +2,9 @@ html, body, #app { height: 100%; + margin: 0; + padding: 0; + overflow: hidden; } iframe { From 383f3087711be6902359a1729de5cefca85967e4 Mon Sep 17 00:00:00 2001 From: huaiju Date: Sun, 22 Mar 2026 20:04:26 +0800 Subject: [PATCH 35/43] feat(skills): add like/star functionality with IP tracking --- app/controller/skillLike.js | 29 ++++ app/model/skill_like.js | 41 +++++ app/router.js | 3 + app/service/skillLike.js | 112 ++++++++++++ app/web/api/url.ts | 15 ++ .../skills/detail/SkillDetailContent.tsx | 67 ++++++-- app/web/pages/skills/detail/style.scss | 159 +++++++++++++++--- 7 files changed, 387 insertions(+), 39 deletions(-) create mode 100644 app/controller/skillLike.js create mode 100644 app/model/skill_like.js create mode 100644 app/service/skillLike.js diff --git a/app/controller/skillLike.js b/app/controller/skillLike.js new file mode 100644 index 0000000..1d8e41e --- /dev/null +++ b/app/controller/skillLike.js @@ -0,0 +1,29 @@ +const Controller = require('egg').Controller; + +class SkillLikeController extends Controller { + async like() { + const { app, ctx } = this; + const { slug } = ctx.request.body || {}; + const ip = ctx.service.skillLike.resolveClientIp(); + const data = await ctx.service.skillLike.like(slug, ip); + ctx.body = app.utils.response(true, data); + } + + async unlike() { + const { app, ctx } = this; + const { slug } = ctx.request.body || {}; + const ip = ctx.service.skillLike.resolveClientIp(); + const data = await ctx.service.skillLike.unlike(slug, ip); + ctx.body = app.utils.response(true, data); + } + + async getLikeStatus() { + const { app, ctx } = this; + const { slug } = ctx.query || {}; + const ip = ctx.service.skillLike.resolveClientIp(); + const data = await ctx.service.skillLike.getLikeStatus(slug, ip); + ctx.body = app.utils.response(true, data); + } +} + +module.exports = SkillLikeController; diff --git a/app/model/skill_like.js b/app/model/skill_like.js new file mode 100644 index 0000000..00a605d --- /dev/null +++ b/app/model/skill_like.js @@ -0,0 +1,41 @@ +module.exports = (app) => { + const { INTEGER, STRING, DATE } = app.Sequelize; + + const SkillLike = app.model.define( + 'skill_like', + { + id: { + type: INTEGER, + primaryKey: true, + autoIncrement: true, + }, + skill_id: { + type: INTEGER, + allowNull: false, + comment: '技能ID', + }, + ip: { + type: STRING(64), + allowNull: false, + comment: '点赞用户IP', + }, + created_at: { + type: DATE, + allowNull: false, + defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }, + { + freezeTableName: true, + tableName: 'skill_likes', + timestamps: false, + indexes: [ + { fields: ['skill_id', 'ip'], unique: true }, + { fields: ['skill_id'] }, + { fields: ['ip'] }, + ], + } + ); + + return SkillLike; +}; diff --git a/app/router.js b/app/router.js index 0e2646d..2ce1e9b 100644 --- a/app/router.js +++ b/app/router.js @@ -158,6 +158,9 @@ module.exports = (app) => { app.post('/api/skills/import-file', app.controller.skills.importSkillFile); app.post('/api/skills/update', app.controller.skills.updateSkill); app.post('/api/skills/delete', app.controller.skills.deleteSkill); + app.post('/api/skills/like', app.controller.skillLike.like); + app.post('/api/skills/unlike', app.controller.skillLike.unlike); + app.get('/api/skills/like-status', app.controller.skillLike.getLikeStatus); // io.of('/').route('getShellCommand', io.controller.home.getShellCommand) // 暂时close Terminal相关功能 diff --git a/app/service/skillLike.js b/app/service/skillLike.js new file mode 100644 index 0000000..35a5c3b --- /dev/null +++ b/app/service/skillLike.js @@ -0,0 +1,112 @@ +const Service = require('egg').Service; + +class SkillLikeService extends Service { + async ensureStorageReady() { + const { SkillLike, SkillsItem } = this.app.model; + if (!SkillLike || !SkillsItem) { + this.ctx.throw(500, 'SkillLike 数据模型未加载'); + } + await SkillLike.sync(); + } + + resolveClientIp() { + const headers = this.ctx.request.headers; + const ip = headers['x-forwarded-for']?.split(',')[0]?.trim() + || headers['x-real-ip'] + || this.ctx.ip + || ''; + return String(ip).trim(); + } + + async getSkillBySlug(slug) { + const { SkillsItem } = this.app.model; + const skill = await SkillsItem.findOne({ + where: { slug, is_delete: 0 }, + }); + return skill; + } + + async like(skillSlug, ip) { + await this.ensureStorageReady(); + const { SkillLike } = this.app.model; + + const skill = await this.getSkillBySlug(skillSlug); + if (!skill) { + this.ctx.throw(404, '技能不存在'); + } + + const existing = await SkillLike.findOne({ + where: { skill_id: skill.id, ip }, + }); + + if (existing) { + return { liked: true, message: '已经点赞过了' }; + } + + await SkillLike.create({ + skill_id: skill.id, + ip, + }); + + const likeCount = await SkillLike.count({ + where: { skill_id: skill.id }, + }); + + await skill.update({ stars: likeCount }); + + return { liked: true, likeCount }; + } + + async unlike(skillSlug, ip) { + await this.ensureStorageReady(); + const { SkillLike } = this.app.model; + + const skill = await this.getSkillBySlug(skillSlug); + if (!skill) { + this.ctx.throw(404, '技能不存在'); + } + + const existing = await SkillLike.findOne({ + where: { skill_id: skill.id, ip }, + }); + + if (!existing) { + return { liked: false, message: '还未点赞' }; + } + + await existing.destroy(); + + const likeCount = await SkillLike.count({ + where: { skill_id: skill.id }, + }); + + await skill.update({ stars: likeCount }); + + return { liked: false, likeCount }; + } + + async getLikeStatus(skillSlug, ip) { + await this.ensureStorageReady(); + const { SkillLike } = this.app.model; + + const skill = await this.getSkillBySlug(skillSlug); + if (!skill) { + this.ctx.throw(404, '技能不存在'); + } + + const existing = await SkillLike.findOne({ + where: { skill_id: skill.id, ip }, + }); + + const likeCount = await SkillLike.count({ + where: { skill_id: skill.id }, + }); + + return { + liked: !!existing, + likeCount, + }; + } +} + +module.exports = SkillLikeService; diff --git a/app/web/api/url.ts b/app/web/api/url.ts index 151a191..1c7986d 100644 --- a/app/web/api/url.ts +++ b/app/web/api/url.ts @@ -386,4 +386,19 @@ export default { method: 'post', url: '/api/skills/delete', }, + // 点赞 + likeSkill: { + method: 'post', + url: '/api/skills/like', + }, + // 取消点赞 + unlikeSkill: { + method: 'post', + url: '/api/skills/unlike', + }, + // 获取点赞状态 + getSkillLikeStatus: { + method: 'get', + url: '/api/skills/like-status', + }, }; diff --git a/app/web/pages/skills/detail/SkillDetailContent.tsx b/app/web/pages/skills/detail/SkillDetailContent.tsx index f0255f0..6cd644c 100644 --- a/app/web/pages/skills/detail/SkillDetailContent.tsx +++ b/app/web/pages/skills/detail/SkillDetailContent.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { ArrowLeftOutlined, QuestionCircleOutlined } from '@ant-design/icons'; +import { ArrowLeftOutlined, DislikeOutlined, LikeOutlined, QuestionCircleOutlined, StarOutlined } from '@ant-design/icons'; import SyntaxHighlighter from 'react-syntax-highlighter'; import { atomOneLight } from 'react-syntax-highlighter/dist/cjs/styles/hljs'; import { Button, Empty, Spin, Tree, Typography } from 'antd'; @@ -217,6 +217,8 @@ const SkillDetailContent: React.FC = ({ slug, history } const [selectedFilePath, setSelectedFilePath] = useState(''); const [fileContent, setFileContent] = useState(null); const [activeInstallPanel, setActiveInstallPanel] = useState('agent'); + const [likeStatus, setLikeStatus] = useState({ liked: false, likeCount: 0 }); + const [likeLoading, setLikeLoading] = useState(false); const fileTreeData = useMemo( () => buildFileTreeData(detail?.fileList || []), @@ -293,6 +295,42 @@ const SkillDetailContent: React.FC = ({ slug, history } setFileLoading(true); }; + const fetchLikeStatus = async () => { + try { + const res = await API.getSkillLikeStatus({ slug }); + if (res.success) { + setLikeStatus({ + liked: res.data.liked, + likeCount: res.data.likeCount, + }); + } + } catch (error) { + console.error('获取点赞状态失败:', error); + } + }; + + const handleLike = async () => { + if (likeLoading) return; + setLikeLoading(true); + try { + if (likeStatus.liked) { + const res = await API.unlikeSkill({ slug }); + if (res.success) { + setLikeStatus({ liked: false, likeCount: res.data.likeCount }); + } + } else { + const res = await API.likeSkill({ slug }); + if (res.success) { + setLikeStatus({ liked: true, likeCount: res.data.likeCount }); + } + } + } catch (error) { + console.error('点赞操作失败:', error); + } finally { + setLikeLoading(false); + } + }; + useEffect(() => { setUiSelectedFilePath(''); setSelectedFilePath(''); @@ -358,6 +396,7 @@ const SkillDetailContent: React.FC = ({ slug, history } }; loadDetail(); + fetchLikeStatus(); return () => { cancelled = true; @@ -589,7 +628,8 @@ const SkillDetailContent: React.FC = ({ slug, history }
-
- - {detail.stars || 0} -
@@ -686,7 +724,10 @@ const SkillDetailContent: React.FC = ({ slug, history }
{detail.name} - {heroSummary} + + {heroSummary} +
@@ -693,7 +703,6 @@ const SkillDetailContent: React.FC = ({ slug, history }
))}
-
@@ -715,9 +724,7 @@ const SkillDetailContent: React.FC = ({ slug, history }
-
- {renderFileViewer()} -
+
{renderFileViewer()}
@@ -757,9 +764,7 @@ const SkillDetailContent: React.FC = ({ slug, history }
智能体
- {isInstallable - ? '自动化安装' - : '下载后安装'} + {isInstallable ? '自动化安装' : '下载后安装'}
@@ -786,7 +791,7 @@ const SkillDetailContent: React.FC = ({ slug, history } agentTerminalCommand, isInstallable ? 'Agent 安装命令已复制到剪贴板' - : '下载命令已复制到剪贴板', + : '下载命令已复制到剪贴板' )}
@@ -819,9 +824,7 @@ const SkillDetailContent: React.FC = ({ slug, history }
手动安装
-
- 手动配置 -
+
手动配置
= ({ slug, history } >
-
先安装 Doraemon CLI
+
+ 先安装 Doraemon CLI +
{renderInlineCommand( cliInstallPlaceholderCommand, 'CLI 安装命令已复制到剪贴板', @@ -944,7 +949,9 @@ const SkillDetailContent: React.FC = ({ slug, history }
仓库大小 - {fileContent ? formatFileSize(fileContent.size) : '1.2 MB'} + + {fileContent ? formatFileSize(fileContent.size) : '1.2 MB'} +
近 30 天下载 diff --git a/app/web/pages/skills/detail/SkillSummaryModalContent.tsx b/app/web/pages/skills/detail/SkillSummaryModalContent.tsx index f412f45..afecf96 100644 --- a/app/web/pages/skills/detail/SkillSummaryModalContent.tsx +++ b/app/web/pages/skills/detail/SkillSummaryModalContent.tsx @@ -234,16 +234,16 @@ const SkillSummaryModalContent: React.FC = ({ slu
快捷操作
-