From efb91796fb3dd03b73a6bf22ff614375ee10df9b Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 1 May 2026 09:52:12 +0800 Subject: [PATCH] feat(skill): pixelarticons icon library + picker component --- .../skill/runtime/SkillPackageResolver.java | 14 +- .../vip/mate/skill/service/SkillService.java | 31 +- mateclaw-ui/package.json | 1 + mateclaw-ui/pnpm-lock.yaml | 40 ++ .../src/components/common/SkillIcon.vue | 130 ++++ .../src/components/common/SkillIconPicker.vue | 641 ++++++++++++++++++ .../src/composables/usePixelarticons.ts | 76 +++ mateclaw-ui/src/i18n/locales/en-US.ts | 18 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 18 + mateclaw-ui/src/views/SkillMarket.vue | 166 ++++- 10 files changed, 1124 insertions(+), 11 deletions(-) create mode 100644 mateclaw-ui/src/components/common/SkillIcon.vue create mode 100644 mateclaw-ui/src/components/common/SkillIconPicker.vue create mode 100644 mateclaw-ui/src/composables/usePixelarticons.ts diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java index 58ad648d..2386c59b 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java @@ -175,6 +175,15 @@ public class SkillPackageResolver { // RFC-090 §14.6 — column projection from manifest (SoT). // Snapshot pre-projection values so we know which legacy columns // need a row-level update. Skipped when the manifest is null. + // + // Icon is a special case: the UI lets users pick a custom icon + // (emoji / pixelarticons / URL) per skill. If we unconditionally + // re-project the manifest icon every resolve, the user's pick + // gets silently reverted as soon as the runtime cache refreshes. + // So we only seed the icon from manifest when the row's icon is + // empty — meaning user-set icons stick, and clearing the icon + // ("no icon" in the picker) explicitly opts back into the + // manifest default on the next resolve. String newSkillType = entity.getSkillType(); String newIcon = entity.getIcon(); String newVersion = entity.getVersion(); @@ -182,7 +191,10 @@ public class SkillPackageResolver { if (resolved.getManifest() != null) { SkillManifest m = resolved.getManifest(); if (m.getType() != null && !m.getType().isBlank()) newSkillType = m.getType(); - if (m.getIcon() != null && !m.getIcon().isBlank()) newIcon = m.getIcon(); + boolean rowIconBlank = entity.getIcon() == null || entity.getIcon().isBlank(); + if (rowIconBlank && m.getIcon() != null && !m.getIcon().isBlank()) { + newIcon = m.getIcon(); + } if (m.getVersion() != null && !m.getVersion().isBlank()) newVersion = m.getVersion(); if (m.getAuthor() != null && !m.getAuthor().isBlank()) newAuthor = m.getAuthor(); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java index 035cf65d..96b8dda5 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java @@ -234,22 +234,45 @@ public class SkillService { /** * 更新技能 - * 内置技能只允许修改 enabled、configJson、description + *

+ * 内置技能允许修改的字段集合: + *

+ * 仍不允许:name / version / author / skillType / builtin —— 这些是身份字段, + * 改动会破坏绑定与解析。 */ public SkillEntity updateSkill(SkillEntity skill) { SkillEntity existing = getSkill(skill.getId()); if (Boolean.TRUE.equals(existing.getBuiltin())) { - // 内置技能:只允许修改有限字段 + // Functional fields existing.setEnabled(skill.getEnabled() != null ? skill.getEnabled() : existing.getEnabled()); existing.setConfigJson(skill.getConfigJson()); existing.setDescription(skill.getDescription() != null ? skill.getDescription() : existing.getDescription()); - // builtin skill 也允许更新 skillContent(用于维护 fallback 内容) if (skill.getSkillContent() != null) { existing.setSkillContent(skill.getSkillContent()); } + // Display overrides — pure frontend-facing, no runtime impact. + // Treat blank-string as an explicit "clear" (so the picker's + // "no icon" path reverts the row icon back to the manifest + // projection on the next resolve). + if (skill.getIcon() != null) existing.setIcon(skill.getIcon()); + if (skill.getNameZh() != null) existing.setNameZh(skill.getNameZh()); + if (skill.getNameEn() != null) existing.setNameEn(skill.getNameEn()); + if (skill.getTags() != null) existing.setTags(skill.getTags()); + skillMapper.updateById(existing); - log.info("Updated builtin skill (limited): {}", existing.getName()); + log.info("Updated builtin skill (display overrides allowed): {}", existing.getName()); // builtin skill 也同步 workspace SKILL.md syncSkillContentToWorkspace(existing); diff --git a/mateclaw-ui/package.json b/mateclaw-ui/package.json index ec458b71..335560fc 100644 --- a/mateclaw-ui/package.json +++ b/mateclaw-ui/package.json @@ -23,6 +23,7 @@ "marked-highlight": "^2.2.3", "mermaid": "^11.14.0", "pinia": "^3.0.1", + "pixelarticons": "2.1.0", "vue": "^3.5.13", "vue-i18n": "9.14.4", "vue-router": "^4.5.0" diff --git a/mateclaw-ui/pnpm-lock.yaml b/mateclaw-ui/pnpm-lock.yaml index 594d7f60..45df5c01 100644 --- a/mateclaw-ui/pnpm-lock.yaml +++ b/mateclaw-ui/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: pinia: specifier: ^3.0.1 version: 3.0.4(typescript@5.7.3)(vue@3.5.31(typescript@5.7.3)) + pixelarticons: + specifier: 2.1.0 + version: 2.1.0(react@19.2.5) vue: specifier: ^3.5.13 version: 3.5.31(typescript@5.7.3) @@ -430,66 +433,79 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} @@ -562,24 +578,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -1466,24 +1486,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1644,6 +1668,12 @@ packages: typescript: optional: true + pixelarticons@2.1.0: + resolution: {integrity: sha512-t03/DsFadIEU7SCwmi4mcnYgZMCKPawvvPt+KM9bW5nC4TcNFatCD196Qm9IT/5bIIbSCGjmjAoMrTZKxRBdJw==} + hasBin: true + peerDependencies: + react: '>=16' + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -1676,6 +1706,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3412,6 +3446,10 @@ snapshots: optionalDependencies: typescript: 5.7.3 + pixelarticons@2.1.0(react@19.2.5): + dependencies: + react: 19.2.5 + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -3444,6 +3482,8 @@ snapshots: punycode@2.3.1: {} + react@19.2.5: {} + resolve-from@4.0.0: {} rfdc@1.4.1: {} diff --git a/mateclaw-ui/src/components/common/SkillIcon.vue b/mateclaw-ui/src/components/common/SkillIcon.vue new file mode 100644 index 00000000..4d281b01 --- /dev/null +++ b/mateclaw-ui/src/components/common/SkillIcon.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/mateclaw-ui/src/components/common/SkillIconPicker.vue b/mateclaw-ui/src/components/common/SkillIconPicker.vue new file mode 100644 index 00000000..bab82d30 --- /dev/null +++ b/mateclaw-ui/src/components/common/SkillIconPicker.vue @@ -0,0 +1,641 @@ + + + + + diff --git a/mateclaw-ui/src/composables/usePixelarticons.ts b/mateclaw-ui/src/composables/usePixelarticons.ts new file mode 100644 index 00000000..8046745e --- /dev/null +++ b/mateclaw-ui/src/composables/usePixelarticons.ts @@ -0,0 +1,76 @@ +/** + * pixelarticons (npm `pixelarticons`) — SVG icon loader. + * + * The package ships ~400 icons each with a "sharp" twin variant (~800 + * SVGs total). We surface the regular set only — the sharp variants are + * a stylistic alternative that doubles bundle size for marginal value + * in our context. Each SVG is ~150B, so the regular set lands around + * 60KB raw / ~15KB gzipped — small enough to bundle eagerly so the + * picker can search/grid without lazy-loading round trips. + * + * Icon scheme: we encode a chosen pixelart icon as the string `pi:name` + * (kebab-case). The {@link SkillIcon} renderer detects this prefix and + * inlines the SVG at render time. + */ + +// Eager glob into node_modules — Vite resolves `?raw` text imports for +// SVGs the same as any other asset. We exclude the `*-sharp.svg` files +// at the glob level so they never enter the bundle. +const modules = import.meta.glob( + '../../node_modules/pixelarticons/svg/!(*-sharp).svg', + { query: '?raw', import: 'default', eager: true }, +) + +/** + * Map from kebab-case icon name → raw SVG markup. + * Built once at module load; ~400 entries. + */ +export const pixelartIcons: Record = (() => { + const out: Record = {} + for (const [path, raw] of Object.entries(modules)) { + const name = path.split('/').pop()!.replace(/\.svg$/, '') + out[name] = raw + } + return out +})() + +/** + * Sorted list of icon names — drives the picker grid. Cached because the + * picker reads it on every keystroke (search filter). + */ +export const pixelartIconNames: string[] = Object.keys(pixelartIcons).sort() + +/** + * Encode a pixelart icon name into the persisted icon string. + * Symmetric with {@link parseIconValue}. + */ +export function encodePixelartIcon(name: string): string { + return `pi:${name}` +} + +export type ParsedIcon = + | { kind: 'pixelart'; name: string; svg: string | null } + | { kind: 'url'; url: string } + | { kind: 'emoji'; value: string } + | { kind: 'empty' } + +/** + * Decode a stored icon string into a renderable shape. Returns + * {@code kind: 'empty'} for null/blank so callers can branch on a + * single discriminator. + */ +export function parseIconValue(value: string | null | undefined): ParsedIcon { + if (!value || !value.trim()) return { kind: 'empty' } + const v = value.trim() + if (v.startsWith('pi:')) { + const name = v.slice(3) + return { kind: 'pixelart', name, svg: pixelartIcons[name] ?? null } + } + if (v.startsWith('http://') || v.startsWith('https://')) { + return { kind: 'url', url: v } + } + // Anything else falls through as an emoji / text glyph. We don't + // validate "is this really an emoji" — letting the user pick any + // unicode glyph is intentionally flexible. + return { kind: 'emoji', value: v } +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index fbbff619..6773df81 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -45,6 +45,24 @@ export default { jumpTo: 'Go to', pageSuffix: '', }, + iconPicker: { + title: 'Pick an icon', + tabPixelart: 'Pixelart', + tabEmoji: 'Emoji', + tabUrl: 'Image URL', + search: 'Search by name…', + empty: 'No icons match', + preview: 'Preview', + none: 'No icon', + apply: 'Apply', + emojiHint: 'Type or paste any emoji / text glyph.', + emojiPlaceholder: '🎬 / 📊 / any symbol', + urlHint: 'Use a hosted image (PNG / SVG / JPG) as the icon.', + urlPlaceholder: 'https://example.com/icon.svg', + pixelartCount: '{n} pixelart icons by pixelarticons (MIT)', + pickerOpen: 'Open icon picker', + reprojectionWarning: 'Heads up: if SKILL.md frontmatter declares `icon`, your override will be overwritten on next resolve — edit the body to make it permanent.', + }, }, auth: { changePassword: 'Change Password', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 94bfe1e8..288c61d5 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -45,6 +45,24 @@ export default { jumpTo: '前往', pageSuffix: '页', }, + iconPicker: { + title: '选择图标', + tabPixelart: '像素图标', + tabEmoji: '表情符号', + tabUrl: '图片链接', + search: '搜索图标名…', + empty: '没有匹配的图标', + preview: '预览', + none: '不设图标', + apply: '应用', + emojiHint: '直接输入或粘贴一个 emoji / 文字符号。', + emojiPlaceholder: '🎬 / 📊 / 任意符号', + urlHint: '使用线上图片作为图标(PNG / SVG / JPG)。', + urlPlaceholder: 'https://example.com/icon.svg', + pixelartCount: '共 {n} 个像素图标,由 pixelarticons 提供(MIT)', + pickerOpen: '打开图标选择器', + reprojectionWarning: '提示:若 SKILL.md frontmatter 已声明 icon,下次解析会覆盖此处的修改 — 在正文里改才永久。', + }, }, auth: { changePassword: '修改密码', diff --git a/mateclaw-ui/src/views/SkillMarket.vue b/mateclaw-ui/src/views/SkillMarket.vue index caf4859a..47b9c453 100644 --- a/mateclaw-ui/src/views/SkillMarket.vue +++ b/mateclaw-ui/src/views/SkillMarket.vue @@ -80,7 +80,11 @@ >
- {{ skill.icon || getSkillIcon(skill.skillType) }} +

{{ resolveSkillName(skill) }}

@@ -187,7 +191,9 @@
- {{ detailSkill.icon || '🛠️' }} + + +

{{ resolveSkillName(detailSkill) }}

@@ -283,7 +289,6 @@

slug{{ detailSkill.name }} {{ t('skills.fields.type') }}{{ detailSkill.skillType || '—' }} - {{ t('skills.fields.icon') }}{{ detailSkill.icon }} {{ t('skills.fields.version') }}v{{ detailSkill.version }} {{ t('skills.fields.author') }}{{ detailSkill.author }}
@@ -316,6 +321,28 @@

{{ t('skills.detail.virtualReadonly') }}

{{ t('skills.detail.displayOverridesHint') }}

+ +
+ +
+ {{ t('skills.fields.icon') }} + + {{ editingIdentity ? editForm.icon : detailSkill.icon }} + + {{ t('common.iconPicker.none') }} +
+ +
+
{{ t('skills.fields.nameZh') }}
{{ detailSkill.nameZh || '—' }}
{{ t('skills.fields.nameEn') }}
{{ detailSkill.nameEn || '—' }}
@@ -340,6 +367,9 @@
+

+ {{ t('common.iconPicker.reprojectionWarning') }} +

@@ -545,6 +575,24 @@
+ + +
@@ -586,6 +641,8 @@ import type { Skill, SkillRuntimeStatus, SkillSecurityFinding } from '@/types/in import ImportHubDialog from '@/components/skill/ImportHubDialog.vue' import PreflightInstallDialog from '@/components/skill/PreflightInstallDialog.vue' import McPagination from '@/components/common/McPagination.vue' +import SkillIcon from '@/components/common/SkillIcon.vue' +import SkillIconPicker from '@/components/common/SkillIconPicker.vue' import { mcConfirm } from '@/components/common/useConfirm' import { useSkillName } from '@/composables/useSkillName' @@ -645,7 +702,25 @@ const editForm = ref<{ nameEn: string description: string tags: string -}>({ nameZh: '', nameEn: '', description: '', tags: '' }) + icon: string +}>({ nameZh: '', nameEn: '', description: '', tags: '', icon: '' }) + +/** Icon picker visibility — shared between the create modal and the + * drawer Display section. We only ever have one picker open at a time. */ +const iconPickerVisible = ref(false) +/** Routes the picker's apply event to either the create form or the + * drawer's identity edit, depending on who opened it. */ +type IconPickerTarget = 'create' | 'edit' +const iconPickerTarget = ref('edit') + +/** Whether the underlying SKILL.md frontmatter has a declared `icon`. + * When true, the resolver will overwrite a row-level icon override on + * the next resolve — we surface a warning so the user knows their + * edit may be ephemeral and should be made in the body instead. */ +const manifestDeclaresIcon = computed(() => { + const m = detailManifest.value as { icon?: string } | null + return !!(m && typeof m.icon === 'string' && m.icon.trim()) +}) const editBodyForm = ref<{ skillContent: string; sourceCode: string }>({ skillContent: '', sourceCode: '', @@ -653,7 +728,7 @@ const editBodyForm = ref<{ skillContent: string; sourceCode: string }>({ /** New-skill modal — pared down to the two questions that *must* be answered * at creation time. Everything else is filled in via the drawer. */ -const newForm = ref<{ name: string; description: string }>({ name: '', description: '' }) +const newForm = ref<{ name: string; description: string; icon: string }>({ name: '', description: '', icon: '' }) /** Virtual MCP-derived skills synthesize their id from * {@link McpSkillBridge#VIRTUAL_ID_BASE} (= 9e18). The DB update path @@ -898,7 +973,7 @@ async function loadRuntimeStatus() { } function openCreateModal() { - newForm.value = { name: '', description: '' } + newForm.value = { name: '', description: '', icon: '' } showModal.value = true } @@ -917,6 +992,7 @@ async function createSkillFromModal() { const payload = { name: newForm.value.name.trim(), description: newForm.value.description.trim(), + icon: newForm.value.icon || undefined, skillType: 'dynamic', version: '1.0.0', enabled: true, @@ -952,10 +1028,24 @@ function startEditIdentity() { nameEn: s.nameEn || '', description: s.description || '', tags: s.tags || '', + icon: s.icon || '', } editingIdentity.value = true } +function openIconPickerFor(target: IconPickerTarget) { + iconPickerTarget.value = target + iconPickerVisible.value = true +} + +function onIconPicked(value: string) { + if (iconPickerTarget.value === 'edit') { + editForm.value.icon = value + } else { + newForm.value.icon = value + } +} + function cancelEditIdentity() { editingIdentity.value = false } @@ -974,6 +1064,13 @@ async function saveIdentity() { nameEn: editForm.value.nameEn, description: editForm.value.description, tags: editForm.value.tags, + // Icon is technically a manifest-projected field, but the + // resolver only overwrites when the manifest declares one + // (SkillPackageResolver.java:185). Sending it here lets users + // override icons for skills whose SKILL.md has no `icon:` and + // — for skills that do declare it — the warning above tells + // them to expect re-projection. + icon: editForm.value.icon, } const res: any = await skillApi.update(detailSkill.value.id, payload) const updated: Skill | undefined = res?.data @@ -1922,6 +2019,63 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); } .identity-grid { grid-template-columns: 1fr; } } +/* Icon row — preview tile + label + picker button. Used in the drawer + * Display section and the create modal so both flows look identical. */ +.identity-icon-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 12px; + background: rgba(123, 88, 67, 0.05); + margin-bottom: 12px; +} +:global(html.dark .identity-icon-row) { + background: rgba(255, 255, 255, 0.04); +} +.identity-icon-row--create { + margin: 4px 0 16px; +} +.identity-icon-preview { + background: rgba(255, 255, 255, 0.7); + border-radius: 10px; + padding: 4px; + box-sizing: content-box; + flex-shrink: 0; +} +:global(html.dark .identity-icon-preview) { + background: rgba(255, 255, 255, 0.08); +} +.identity-icon-meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} +.identity-icon-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--mc-text-tertiary); +} +.identity-icon-value { + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + font-size: 12px; + color: var(--mc-text-primary); + background: transparent; + padding: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.identity-icon-empty { + font-size: 12px; + font-style: italic; + color: var(--mc-text-tertiary); +} + /* ============================================================ * MateClaw frosted-glass drawer * Mirrors Settings/Models/AddProviderDrawer.vue so the skill