dsh-visualkit 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/LICENSE +26 -0
- package/README.md +427 -2
- package/THIRD_PARTY.md +55 -0
- package/cordis.patch.yml +29 -0
- package/data/assets/README.md +38 -0
- package/data/assets/icons/brands/bilibili.svg +1 -0
- package/data/assets/icons/brands/discord.svg +1 -0
- package/data/assets/icons/brands/neteasecloudmusic.svg +1 -0
- package/data/assets/icons/brands/qq.svg +1 -0
- package/data/assets/icons/brands/sinaweibo.svg +1 -0
- package/data/assets/icons/brands/steam.svg +1 -0
- package/data/assets/icons/brands/telegram.svg +1 -0
- package/data/assets/icons/brands/tiktok.svg +1 -0
- package/data/assets/icons/brands/twitch.svg +1 -0
- package/data/assets/icons/brands/wechat.svg +1 -0
- package/data/assets/icons/brands/x.svg +1 -0
- package/data/assets/icons/brands/youtube.svg +1 -0
- package/data/assets/icons/lucide/bell.svg +1 -0
- package/data/assets/icons/lucide/calendar.svg +1 -0
- package/data/assets/icons/lucide/clock.svg +1 -0
- package/data/assets/icons/lucide/gamepad-2.svg +1 -0
- package/data/assets/icons/lucide/gift.svg +1 -0
- package/data/assets/icons/lucide/heart.svg +1 -0
- package/data/assets/icons/lucide/mic.svg +1 -0
- package/data/assets/icons/lucide/play.svg +1 -0
- package/data/assets/icons/lucide/star.svg +1 -0
- package/data/assets/icons/lucide/video.svg +1 -0
- package/data/assets/icons/phosphor/LICENSE +21 -0
- package/data/assets/icons/phosphor/atom-duotone.svg +1 -0
- package/data/assets/icons/phosphor/binary-duotone.svg +1 -0
- package/data/assets/icons/phosphor/blueprint-duotone.svg +1 -0
- package/data/assets/icons/phosphor/circuitry-duotone.svg +1 -0
- package/data/assets/icons/phosphor/cloud-arrow-up-duotone.svg +1 -0
- package/data/assets/icons/phosphor/code-duotone.svg +1 -0
- package/data/assets/icons/phosphor/compass-duotone.svg +1 -0
- package/data/assets/icons/phosphor/cpu-duotone.svg +1 -0
- package/data/assets/icons/phosphor/cube-duotone.svg +1 -0
- package/data/assets/icons/phosphor/database-duotone.svg +1 -0
- package/data/assets/icons/phosphor/flask-duotone.svg +1 -0
- package/data/assets/icons/phosphor/frame-corners-duotone.svg +1 -0
- package/data/assets/icons/phosphor/function-duotone.svg +1 -0
- package/data/assets/icons/phosphor/hard-drives-duotone.svg +1 -0
- package/data/assets/icons/phosphor/hexagon-duotone.svg +1 -0
- package/data/assets/icons/phosphor/infinity-duotone.svg +1 -0
- package/data/assets/icons/phosphor/lightning-duotone.svg +1 -0
- package/data/assets/icons/phosphor/magic-wand-duotone.svg +1 -0
- package/data/assets/icons/phosphor/moon-stars-duotone.svg +1 -0
- package/data/assets/icons/phosphor/paint-brush-duotone.svg +1 -0
- package/data/assets/icons/phosphor/pen-nib-duotone.svg +1 -0
- package/data/assets/icons/phosphor/planet-duotone.svg +1 -0
- package/data/assets/icons/phosphor/rocket-launch-duotone.svg +1 -0
- package/data/assets/icons/phosphor/ruler-duotone.svg +1 -0
- package/data/assets/icons/phosphor/selection-plus-duotone.svg +1 -0
- package/data/assets/icons/phosphor/shapes-duotone.svg +1 -0
- package/data/assets/icons/phosphor/sparkle-duotone.svg +1 -0
- package/data/assets/icons/phosphor/sun-horizon-duotone.svg +1 -0
- package/data/assets/icons/phosphor/terminal-window-duotone.svg +1 -0
- package/data/assets/icons/phosphor/triangle-duotone.svg +1 -0
- package/data/assets/icons/phosphor/waveform-duotone.svg +1 -0
- package/data/assets/icons/tabler/alarm.svg +1 -0
- package/data/assets/icons/tabler/badge-ad.svg +1 -0
- package/data/assets/icons/tabler/bell.svg +1 -0
- package/data/assets/icons/tabler/bolt.svg +1 -0
- package/data/assets/icons/tabler/brand-bilibili.svg +1 -0
- package/data/assets/icons/tabler/brand-discord.svg +1 -0
- package/data/assets/icons/tabler/brand-qq.svg +1 -0
- package/data/assets/icons/tabler/brand-twitch.svg +1 -0
- package/data/assets/icons/tabler/brand-wechat.svg +1 -0
- package/data/assets/icons/tabler/brand-weibo.svg +1 -0
- package/data/assets/icons/tabler/brand-youtube.svg +1 -0
- package/data/assets/icons/tabler/broadcast.svg +1 -0
- package/data/assets/icons/tabler/calendar-event.svg +1 -0
- package/data/assets/icons/tabler/calendar-week.svg +1 -0
- package/data/assets/icons/tabler/clock-hour-3.svg +1 -0
- package/data/assets/icons/tabler/coin.svg +1 -0
- package/data/assets/icons/tabler/crown.svg +1 -0
- package/data/assets/icons/tabler/currency-yen.svg +1 -0
- package/data/assets/icons/tabler/device-gamepad-2.svg +1 -0
- package/data/assets/icons/tabler/diamond.svg +1 -0
- package/data/assets/icons/tabler/flame.svg +1 -0
- package/data/assets/icons/tabler/gift.svg +1 -0
- package/data/assets/icons/tabler/headphones.svg +1 -0
- package/data/assets/icons/tabler/heart-filled.svg +1 -0
- package/data/assets/icons/tabler/heart.svg +1 -0
- package/data/assets/icons/tabler/live-view.svg +1 -0
- package/data/assets/icons/tabler/microphone.svg +1 -0
- package/data/assets/icons/tabler/mood-smile.svg +1 -0
- package/data/assets/icons/tabler/mood-tongue.svg +1 -0
- package/data/assets/icons/tabler/movie.svg +1 -0
- package/data/assets/icons/tabler/player-play.svg +1 -0
- package/data/assets/icons/tabler/playlist.svg +1 -0
- package/data/assets/icons/tabler/rocket.svg +1 -0
- package/data/assets/icons/tabler/sparkles.svg +1 -0
- package/data/assets/icons/tabler/star-filled.svg +1 -0
- package/data/assets/icons/tabler/star.svg +1 -0
- package/data/assets/icons/tabler/tag.svg +1 -0
- package/data/assets/icons/tabler/thumb-up.svg +1 -0
- package/data/assets/icons/tabler/ticket.svg +1 -0
- package/data/assets/icons/tabler/video.svg +1 -0
- package/data/assets/illustrations/openpeeps/LICENSE +8 -0
- package/data/assets/illustrations/openpeeps/peep-10.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-100.svg +32 -0
- package/data/assets/illustrations/openpeeps/peep-101.svg +25 -0
- package/data/assets/illustrations/openpeeps/peep-102.svg +28 -0
- package/data/assets/illustrations/openpeeps/peep-103.svg +32 -0
- package/data/assets/illustrations/openpeeps/peep-105.svg +25 -0
- package/data/assets/illustrations/openpeeps/peep-12.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-13.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-16.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-17.svg +31 -0
- package/data/assets/illustrations/openpeeps/peep-22.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-25.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-26.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-27.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-30.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-31.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-34.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-38.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-39.svg +31 -0
- package/data/assets/illustrations/openpeeps/peep-4.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-41.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-44.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-47.svg +26 -0
- package/data/assets/illustrations/openpeeps/peep-48.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-50.svg +31 -0
- package/data/assets/illustrations/openpeeps/peep-51.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-53.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-54.svg +26 -0
- package/data/assets/illustrations/openpeeps/peep-55.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-56.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-58.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-61.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-64.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-68.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-69.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-7.svg +31 -0
- package/data/assets/illustrations/openpeeps/peep-72.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-76.svg +31 -0
- package/data/assets/illustrations/openpeeps/peep-77.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-80.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-81.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-83.svg +29 -0
- package/data/assets/manifest.json +1 -0
- package/data/assets/parts//342/206/223/346/212/225/345/275/261.png +0 -0
- package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2711.png +0 -0
- package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2712.png +0 -0
- package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2713.png +0 -0
- package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2714.png +0 -0
- package/data/assets/parts//345/217/214/351/251/254/345/260/276.png +0 -0
- package/data/assets/parts//345/217/214/351/251/254/345/260/2761.png +0 -0
- package/data/assets/parts//345/217/2211.png +0 -0
- package/data/assets/parts//345/217/2212.png +0 -0
- package/data/assets/parts//345/217/2213.png +0 -0
- package/data/assets/parts//345/217/221/345/234/210.png +0 -0
- package/data/assets/parts//345/217/221/345/244/2711.png +0 -0
- package/data/assets/parts//345/217/221/345/244/2712.png +0 -0
- package/data/assets/parts//345/217/243/346/260/264/345/267/276/345/211/215.png +0 -0
- package/data/assets/parts//345/217/243/346/260/264/345/267/276/345/220/216.png +0 -0
- package/data/assets/parts//345/217/263/344/270/212/347/234/274/347/235/221.png +0 -0
- package/data/assets/parts//345/217/263/346/211/213.png +0 -0
- package/data/assets/parts//345/217/263/346/211/2131.png +0 -0
- package/data/assets/parts//345/217/263/347/234/274/347/220/203.png +0 -0
- package/data/assets/parts//345/217/263/347/234/274/347/231/275.png +0 -0
- package/data/assets/parts//345/217/263/350/205/277.png +0 -0
- package/data/assets/parts//345/221/206/346/257/233.png +0 -0
- package/data/assets/parts//345/230/264/345/267/264.png +0 -0
- package/data/assets/parts//345/260/276/345/267/264.png +0 -0
- package/data/assets/parts//345/267/246/344/270/212/347/234/274/347/235/221.png +0 -0
- package/data/assets/parts//345/267/246/345/217/214/347/234/274/347/232/256.png +0 -0
- package/data/assets/parts//345/267/246/346/211/213.png +0 -0
- package/data/assets/parts//345/267/246/346/211/2131.png +0 -0
- package/data/assets/parts//345/267/246/347/234/274/347/220/203.png +0 -0
- package/data/assets/parts//345/267/246/347/234/274/347/231/275.png +0 -0
- package/data/assets/parts//345/267/246/350/205/277.png +0 -0
- package/data/assets/parts//346/212/225/345/275/261.png +0 -0
- package/data/assets/parts//347/234/211/346/257/233.png +0 -0
- package/data/assets/parts//347/234/274/347/275/251.png +0 -0
- package/data/assets/parts//350/200/263/346/234/2651.png +0 -0
- package/data/assets/parts//350/200/263/346/234/2652.png +0 -0
- package/data/assets/parts//350/204/221/345/243/263.png +0 -0
- package/data/assets/parts//350/241/243/346/234/215/345/211/215.png +0 -0
- package/data/assets/parts//350/241/243/346/234/215/345/220/216.png +0 -0
- package/data/assets/parts//350/241/243/346/234/215/351/273/221.png +0 -0
- package/data/assets/parts//350/241/243/346/234/215/351/273/2211.png +0 -0
- package/data/assets/parts//350/272/253/344/275/223.png +0 -0
- package/data/assets/parts//351/223/201/346/235/277.png +0 -0
- package/data/assets/parts//351/273/221/347/234/274/345/234/210.png +0 -0
- package/data/assets/parts//351/273/221/347/234/274/345/234/2102.png +0 -0
- package/data/assets/patterns/checker.svg +1 -0
- package/data/assets/patterns/circles-overlap.svg +1 -0
- package/data/assets/patterns/crosshatch.svg +1 -0
- package/data/assets/patterns/diagonal.svg +1 -0
- package/data/assets/patterns/dots.svg +1 -0
- package/data/assets/patterns/grid.svg +1 -0
- package/data/assets/patterns/halftone.svg +1 -0
- package/data/assets/patterns/plus.svg +1 -0
- package/data/assets/patterns/scales.svg +1 -0
- package/data/assets/patterns/triangles.svg +1 -0
- package/data/assets/patterns/waves.svg +1 -0
- package/data/assets/patterns/zigzag.svg +1 -0
- package/data/assets/stickers/openmoji/1F375.svg +12 -0
- package/data/assets/stickers/openmoji/1F9E8.svg +21 -0
- package/data/assets/stickers/twemoji/1f389.svg +1 -0
- package/data/assets/stickers/twemoji/1f3a4.svg +1 -0
- package/data/assets/stickers/twemoji/1f3a7.svg +1 -0
- package/data/assets/stickers/twemoji/1f3ae.svg +1 -0
- package/data/assets/stickers/twemoji/1f44b.svg +1 -0
- package/data/assets/stickers/twemoji/1f44d.svg +1 -0
- package/data/assets/stickers/twemoji/1f494.svg +1 -0
- package/data/assets/stickers/twemoji/1f4a2.svg +1 -0
- package/data/assets/stickers/twemoji/1f4a8.svg +1 -0
- package/data/assets/stickers/twemoji/1f4b5.svg +1 -0
- package/data/assets/stickers/twemoji/1f4d6.svg +1 -0
- package/data/assets/stickers/twemoji/1f525.svg +1 -0
- package/data/assets/stickers/twemoji/1f600.svg +1 -0
- package/data/assets/stickers/twemoji/1f602.svg +1 -0
- package/data/assets/stickers/twemoji/1f60d.svg +1 -0
- package/data/assets/stickers/twemoji/1f62d.svg +1 -0
- package/data/assets/stickers/twemoji/1f680.svg +1 -0
- package/data/assets/stickers/twemoji/1f979.svg +1 -0
- package/data/assets/stickers/twemoji/1f97a.svg +1 -0
- package/data/assets/stickers/twemoji/26a1.svg +1 -0
- package/data/assets/stickers/twemoji/2705.svg +1 -0
- package/data/assets/stickers/twemoji/274c.svg +1 -0
- package/data/assets/stickers/twemoji/2764.svg +1 -0
- package/data/assets/stickers/twemoji/2b50.svg +1 -0
- package/data/assets/stickers/twemoji_1f525.svg +1 -0
- package/data/assets/stickers/twemoji_1f600.svg +1 -0
- package/data/assets/stickers/twemoji_1f979.svg +1 -0
- package/data/assets/stickers/twemoji_2764.svg +1 -0
- package/data/assets/textures/grain-dark.png +0 -0
- package/data/assets/textures/grain-light.png +0 -0
- package/data/assets/textures/grain-warm.png +0 -0
- package/data/lucide-icons.json +1 -0
- package/data/packs/tabler.icons.json +1 -0
- package/icon.svg +26 -0
- package/lib/assets.js +228 -0
- package/lib/audit.js +284 -0
- package/lib/contrast-render.js +213 -0
- package/lib/css-rewrite.js +379 -0
- package/lib/detect.js +701 -0
- package/lib/diff.js +153 -0
- package/lib/emit.js +557 -0
- package/lib/html-context.js +315 -0
- package/lib/icon-render.js +107 -0
- package/lib/icondb.js +402 -0
- package/lib/image-normalize.js +140 -0
- package/lib/index.js +1289 -0
- package/lib/layouts/common.js +48 -0
- package/lib/layouts/image.js +1010 -0
- package/lib/layouts/index.js +10 -0
- package/lib/layouts/intent.js +66 -0
- package/lib/layouts/metrics-text.js +91 -0
- package/lib/layouts/png.js +75 -0
- package/lib/layouts/rules-contrast.js +575 -0
- package/lib/layouts/rules-punct.js +486 -0
- package/lib/layouts/rules-structure.js +182 -0
- package/lib/layouts/source.js +1961 -0
- package/lib/layouts/svg.js +1204 -0
- package/lib/morphcore.js +139 -0
- package/lib/preview.js +225 -0
- package/lib/render-html.js +255 -0
- package/lib/render.js +486 -0
- package/lib/rules.js +267 -0
- package/lib/shadow.js +83 -0
- package/lib/skip.js +56 -0
- package/lib/style.js +688 -0
- package/lib/svg.js +729 -0
- package/lib/taste-image.js +323 -0
- package/lib/taste.js +219 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +92 -4
- package/vendor/morphicons-adapters.iife.js +903 -0
- package/vendor/morphicons.iife.js +2441 -0
- package/vendor/resvg/LICENSE +373 -0
- package/vendor/resvg/index.d.ts +98 -0
- package/vendor/resvg/index.js +23 -0
- package/vendor/resvg/js-binding.d.ts +52 -0
- package/vendor/resvg/js-binding.js +1 -0
- package/vendor/resvg/package.json +138 -0
- package/vendor/resvg/resvgjs.win32-x64-msvc.node +0 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-visualkit — 视觉设计工具箱(Visual Design Toolkit,独立插件)。
|
|
3
|
+
*
|
|
4
|
+
* 定位:给**工具和框架**,不给风格目录。同一套原语(配色/字体/形状/图案/栅格/动效/交互态)
|
|
5
|
+
* 可以落到任意视觉场景——UI 设计、网页、平面、海报、文本排版——风格由使用者组合出来。
|
|
6
|
+
* 名字选“视觉”的理由:六件工具全部只作用于可见、可量的视觉量,没有一件处理
|
|
7
|
+
* 创意构思/叙事/声音/交互流程逻辑(见 docs/positioning.md)。
|
|
8
|
+
*
|
|
9
|
+
* 由原来的两个插件合并并瘦身而来(2026-11-07):
|
|
10
|
+
* dsh-plugin-uilab icon / morph / ui_taste / design_style / ui_audit
|
|
11
|
+
* dsh-plugin-design-studio design_ping / design_asset / design_render / design_shot /
|
|
12
|
+
* design_composite / design_psd / design_cover / design_krita
|
|
13
|
+
*
|
|
14
|
+
* 合并后 8 个网关工具:
|
|
15
|
+
* icon action=find|emit 图标检索 / 按项目约定出码
|
|
16
|
+
* morph action=check|preview 形变兼容体检 / 动效预览
|
|
17
|
+
* taste_check action=taste|audit AI 味量化体检 / 图标规范巡检
|
|
18
|
+
* layout_check action=ui|svg|image 排版与构图体检
|
|
19
|
+
* design_style format=css|json|tailwind|motion 风格 token + 结构配方 / 交互原语层
|
|
20
|
+
* design_render action=render|composite|asset|html|diff SVG→PNG / 合成 / 素材 / HTML 截图 / 差异
|
|
21
|
+
* html_edit action=scan|rewrite 上下文感知的 HTML 改写
|
|
22
|
+
* css_edit action=scan|snap|scale|leading|radius 机械收敛(默认不写盘)
|
|
23
|
+
*
|
|
24
|
+
* **独立性**:不再 spawn Python、不再依赖外部素材工作区、不再需要 Krita 或
|
|
25
|
+
* Playwright。唯一随包的原生依赖是 vendored 的 resvg(见 vendor/),无系统软件要求。
|
|
26
|
+
* 代价是去掉了需要这些外部程序的能力(Krita 作画 / HTML 截图 / PSD 改件)。
|
|
27
|
+
*
|
|
28
|
+
* @module dsh-visualkit
|
|
29
|
+
*/
|
|
30
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
31
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
32
|
+
import { tmpdir } from "node:os";
|
|
33
|
+
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
34
|
+
|
|
35
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
36
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
37
|
+
|
|
38
|
+
import { getIcon, meta as iconDbMeta, registerPack, search as searchIcons } from "./icondb.js";
|
|
39
|
+
import { STYLES, TARGETS, emit as emitIcon } from "./emit.js";
|
|
40
|
+
import { checkIcon } from "./svg.js";
|
|
41
|
+
import { SPRINGS, writePreview, resolveIconInput } from "./preview.js";
|
|
42
|
+
import { audit } from "./audit.js";
|
|
43
|
+
import { auditImage, auditSource, auditSvg } from "./layouts/index.js";
|
|
44
|
+
import { taste } from "./taste.js";
|
|
45
|
+
import { SKELETONS, SKINS, renderStyle } from "./style.js";
|
|
46
|
+
import { composite, sizeOf } from "./render.js";
|
|
47
|
+
import { locate, locateClass, rewriteByContext } from "./html-context.js";
|
|
48
|
+
import { readAsPng } from "./image-normalize.js";
|
|
49
|
+
import { inspectCss, inspectRadius, mapFontSizes, normalizeLeading, normalizeRadius, snapSpacing } from "./css-rewrite.js";
|
|
50
|
+
import { isSkippableDir } from "./skip.js";
|
|
51
|
+
import { renderSvg } from "./render.js";
|
|
52
|
+
import { asset } from "./assets.js";
|
|
53
|
+
import { renderHtml } from "./render-html.js";
|
|
54
|
+
import { measureRenderedContrast } from "./contrast-render.js";
|
|
55
|
+
import { imageDiff } from "./diff.js";
|
|
56
|
+
|
|
57
|
+
/** Cordis 插件名(loader 诊断用)。 */
|
|
58
|
+
const name = "tool-visualkit";
|
|
59
|
+
/** 依赖的宿主服务:工具注册器与系统提示组装。 */
|
|
60
|
+
const inject = ["tools", "systemPrompt"];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 官方配置契约:导出 Standard Schema(Schemastery)的 Config。
|
|
64
|
+
* 与旧 design-studio 的 14 项配置相比大幅收敛——没有 pythonCommand / studioRoot /
|
|
65
|
+
* nodeCommand / krita* / 各类超时,因为已经没有外部进程要调度。
|
|
66
|
+
*/
|
|
67
|
+
const Config = Schema.object({
|
|
68
|
+
previewDir: Schema.string().default("").description("morph preview 默认输出目录(空 = 系统临时目录 /dsh-visualkit)"),
|
|
69
|
+
findLimit: Schema.number().default(12).min(1).description("icon find / asset find 默认返回条数"),
|
|
70
|
+
maxAuditFiles: Schema.number().default(4000).min(1).description("taste_check audit 扫描文件数上限"),
|
|
71
|
+
maxFindings: Schema.number().default(60).min(1).description("taste_check audit 返回条数上限"),
|
|
72
|
+
iconPacks: Schema.array(Schema.string()).default(["tabler"]).description("装载的图标包(默认 tabler;simple-icons 需手动指定)"),
|
|
73
|
+
// HTML 渲染需要本机浏览器;留空则自动探测 Edge / Chrome / Chromium。
|
|
74
|
+
browserPath: Schema.string().default("").description("action=html 用的浏览器路径(留空自动探测)"),
|
|
75
|
+
// 按需加载:低频工具不默认进上下文。默认 standard 只装 4 个高频工具,
|
|
76
|
+
// 把 morph / design_style 留给需要时显式开启(实测低频:整轮项目开发里
|
|
77
|
+
// design_style 真实使用 0 次、morph 只做几次体检)。
|
|
78
|
+
toolsetProfile: Schema.string().default("standard").description("minimal(生成+渲染)/ standard(+体检)/ all(+形变+风格 token)")
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** 工具名 → 最低档位。内容与 prompt 里的顺序一致。 */
|
|
82
|
+
const TOOL_TIER = {
|
|
83
|
+
icon: "minimal",
|
|
84
|
+
design_render: "minimal",
|
|
85
|
+
layout_check: "standard",
|
|
86
|
+
taste_check: "standard",
|
|
87
|
+
html_edit: "standard",
|
|
88
|
+
css_edit: "standard",
|
|
89
|
+
morph: "all",
|
|
90
|
+
design_style: "all"
|
|
91
|
+
};
|
|
92
|
+
const PROFILE_ORDER = ["minimal", "standard", "all"];
|
|
93
|
+
/** 当前档位是否应注册该工具。未知工具名按 minimal(永远装)。 */
|
|
94
|
+
function shouldRegister(profile, toolName) {
|
|
95
|
+
const need = TOOL_TIER[toolName] ?? "minimal";
|
|
96
|
+
const at = PROFILE_ORDER.indexOf(profile);
|
|
97
|
+
const needAt = PROFILE_ORDER.indexOf(need);
|
|
98
|
+
return at >= needAt;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 去掉 undefined 键:工具返回值必须能 JSON 无损往返(宿主会校验)。 */
|
|
102
|
+
const plain = (value) => JSON.parse(JSON.stringify(value));
|
|
103
|
+
|
|
104
|
+
const SPRING_ENUM = [...SPRINGS];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 提示段:合并原两条(UI 规范 + 平面设计分工),只留「工具分工 + 质量底线 + 风格可协商」。
|
|
108
|
+
* 图标版本、包清单、出码 target、渲染引擎细节都在工具定义里,不重复。
|
|
109
|
+
*/
|
|
110
|
+
// 提示段只放**工具描述里没有**的东西:
|
|
111
|
+
// ① 工具清单——只列名字(每个工具的能力在它自己的 description 里,不重复);
|
|
112
|
+
// ② 质量底线——任何风格都不能碎的硬约束;
|
|
113
|
+
// ③ 反同质化立场——风格是选择,有意偏离写声明而不是不调工具。
|
|
114
|
+
/** 提示段后缀:与工具清单无关的恒定内容。 */
|
|
115
|
+
const SYSTEM_SECTION_TAIL =
|
|
116
|
+
"Quality floor (non-negotiable): no emoji icons, ≥1.6× title/body type jump, 4px grid, full interaction states. Style is a choice — deliberate exceptions go in .design-intent.json, not into the tool call.";
|
|
117
|
+
|
|
118
|
+
/** 注册全部工具与系统提示。 */
|
|
119
|
+
function apply(ctx, config) {
|
|
120
|
+
const iconPacks = Array.isArray(config.iconPacks) ? config.iconPacks : [];
|
|
121
|
+
/** 官方 seam:相对路径先落会话当前目录(exec.agent),缺失才回退 cwd。 */
|
|
122
|
+
async function sessionCwd(exec) {
|
|
123
|
+
try {
|
|
124
|
+
const wd = ctx?.get?.("workingDirectory");
|
|
125
|
+
if (wd !== null && wd !== undefined && exec?.agent) {
|
|
126
|
+
if (typeof wd.ensure === "function") return await wd.ensure(exec.agent, exec.signal);
|
|
127
|
+
if (typeof wd.get === "function" && exec.agent.session) return wd.get(exec.agent.session);
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
try {
|
|
131
|
+
ctx?.logger?.warn?.(`[artkit] workingDirectory 读取失败,回退默认目录:${String(error?.message ?? error)}`);
|
|
132
|
+
} catch {
|
|
133
|
+
/* logger 不可用 */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* 相对路径 → 绝对路径(工具参数统一走会话目录);**不碰内联标记**。
|
|
140
|
+
* 2026-11-07 实测 bug:svg 参数可以传 `<svg …>` 内联字符串,早期实现
|
|
141
|
+
* 会把它当相对路径 join,结果路径里带 `<` → resvg 报 “unknown token at 1:1”。
|
|
142
|
+
*/
|
|
143
|
+
async function resolveArg(value, exec) {
|
|
144
|
+
if (typeof value !== "string" || value.trim() === "") return value;
|
|
145
|
+
if (value.includes("<")) return value;
|
|
146
|
+
if (isAbsolute(value)) return value;
|
|
147
|
+
const cwd = await sessionCwd(exec);
|
|
148
|
+
return join(cwd ?? process.cwd(), value);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const packName of iconPacks) {
|
|
152
|
+
try {
|
|
153
|
+
registerPack(packName);
|
|
154
|
+
} catch {
|
|
155
|
+
// 配置的包不存在:静默跳过(meta().packs 反映实际装载结果)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const dbMeta = iconDbMeta();
|
|
159
|
+
|
|
160
|
+
// 档位解析:非法值直接报错,不静默回落(静默回落会让用户以为 morpht/design_style 坏了)
|
|
161
|
+
const profile = String(config?.toolsetProfile ?? "standard");
|
|
162
|
+
if (!PROFILE_ORDER.includes(profile)) {
|
|
163
|
+
throw new Error(`toolsetProfile must be one of ${PROFILE_ORDER.join(", ")} (got ${JSON.stringify(profile)})`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 提示段只列**当前 scope 真的看得到**的工具:档位降级或宿主侧工具限制后,
|
|
167
|
+
// 这段文字自动跟着变,不会指向不存在的工具。
|
|
168
|
+
const sectionDisposer = ctx.systemPrompt.section({
|
|
169
|
+
name: "tool:artkit",
|
|
170
|
+
order: 2050,
|
|
171
|
+
text: ({ scope }) => {
|
|
172
|
+
const visible = Object.keys(TOOL_TIER).filter((n) => ctx.tools.get(n, scope) !== undefined);
|
|
173
|
+
if (visible.length === 0) return "";
|
|
174
|
+
return `Art toolkit: ${visible.join(", ")}. ${SYSTEM_SECTION_TAIL}`;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
ctx.effect(() => sectionDisposer);
|
|
178
|
+
|
|
179
|
+
/* ---------------------------- icon ---------------------------- */
|
|
180
|
+
async function runIconFind(args) {
|
|
181
|
+
const results = searchIcons(args.query, args.limit ?? config.findLimit);
|
|
182
|
+
return { query: args.query, count: results.length, results };
|
|
183
|
+
}
|
|
184
|
+
async function runIconEmit(args, exec) {
|
|
185
|
+
const out = emitIcon(args.name, args.target, {
|
|
186
|
+
partner: args.partner,
|
|
187
|
+
style: args.style,
|
|
188
|
+
spring: args.spring,
|
|
189
|
+
size: args.size,
|
|
190
|
+
strokeWidth: args.strokeWidth,
|
|
191
|
+
color: args.color,
|
|
192
|
+
color2: args.color2,
|
|
193
|
+
// 正交维度:覆盖 style 预设(传了就以参数为准)
|
|
194
|
+
paint: args.paint,
|
|
195
|
+
caps: args.caps,
|
|
196
|
+
join: args.join,
|
|
197
|
+
container: args.container,
|
|
198
|
+
radius: args.radius,
|
|
199
|
+
inset: args.inset,
|
|
200
|
+
gradient: args.gradient,
|
|
201
|
+
gradientAngle: args.gradientAngle,
|
|
202
|
+
stops: args.stops,
|
|
203
|
+
duoOpacity: args.duoOpacity,
|
|
204
|
+
shadow: args.shadow,
|
|
205
|
+
dash: args.dash,
|
|
206
|
+
fillRule: args.fillRule,
|
|
207
|
+
shapes: args.shapes,
|
|
208
|
+
text: args.text,
|
|
209
|
+
textSize: args.textSize,
|
|
210
|
+
path: args.path
|
|
211
|
+
});
|
|
212
|
+
let writtenTo;
|
|
213
|
+
if (args.outPath !== undefined && args.outPath.trim() !== "") {
|
|
214
|
+
const target = await resolveArg(args.outPath, exec);
|
|
215
|
+
await mkdir(dirname(target), { recursive: true });
|
|
216
|
+
await writeFile(target, out.code, "utf8");
|
|
217
|
+
writtenTo = target;
|
|
218
|
+
}
|
|
219
|
+
// filename: dsh-plugin 的 icon.svg / raw 的建议文件名——不传 outPath 时模型靠它落盘。
|
|
220
|
+
// 之前这里把 emit 内部的 filename 丢了,模型只能从 notes 文字里猜。
|
|
221
|
+
return {
|
|
222
|
+
target: out.target,
|
|
223
|
+
// 总是回填「实际生效的样式」:solid/duotone/badge 可能会换用填充版字形,
|
|
224
|
+
// 模型需要知道它拿到的到底是哪一种。
|
|
225
|
+
...(out.style !== undefined ? { style: out.style } : {}),
|
|
226
|
+
...(out.container !== undefined ? { container: out.container } : {}),
|
|
227
|
+
icon: out.icon,
|
|
228
|
+
code: out.code,
|
|
229
|
+
...(out.filename !== null && out.filename !== undefined ? { filename: out.filename } : {}),
|
|
230
|
+
...(writtenTo !== undefined ? { writtenTo } : {}),
|
|
231
|
+
notes: out.notes
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
ctx.tools.register(
|
|
235
|
+
defineTool({
|
|
236
|
+
name: "icon",
|
|
237
|
+
// 版本号不进常驻描述(模型不需要知道 1.70.0);图标总数保留,它能帮模型判断该搜还是该手写
|
|
238
|
+
description: `Icon toolbox (${dbMeta.iconCount} lucide${dbMeta.packs.length > 0 ? ` + ${dbMeta.packs.map((p) => p.iconCount).join(" + ")} ${dbMeta.packs.map((p) => p.name).join("/")}` : ""}): zh/en search, or emit code.`,
|
|
239
|
+
parameters: {
|
|
240
|
+
action: { type: "string", enum: ["find", "emit"], description: "find | emit.", required: true },
|
|
241
|
+
query: { type: "string", description: "zh/en keywords; aliases match." },
|
|
242
|
+
limit: { type: "number", description: `find: max results (default ${config.findLimit}).` },
|
|
243
|
+
name: { type: "string", description: "icon name; not needed when path/text given." },
|
|
244
|
+
path: { type: "string", description: "draw your own: raw SVG path d= data (24 grid)." },
|
|
245
|
+
text: { type: "string", description: "glyph text (1-4 chars); replaces the glyph." },
|
|
246
|
+
textSize: { type: "number", description: "text size in 24-grid units." },
|
|
247
|
+
target: { type: "string", enum: TARGETS, description: "output convention." },
|
|
248
|
+
style: { type: "string", enum: STYLES, description: "preset start; params below override it." },
|
|
249
|
+
paint: { type: "string", enum: ["stroke", "fill", "both"], description: "both = fill + stroke." },
|
|
250
|
+
caps: { type: "string", enum: ["round", "butt", "square"], description: "join follows automatically." },
|
|
251
|
+
join: { type: "string", enum: ["round", "miter", "bevel"], description: "default follows caps." },
|
|
252
|
+
container: { type: "string", enum: ["none", "rounded", "circle", "squircle"], description: "chip behind glyph; none removes preset." },
|
|
253
|
+
radius: { type: "number", description: "chip corner 0-12." },
|
|
254
|
+
inset: { type: "number", description: "glyph padding 0-0.45." },
|
|
255
|
+
gradient: { type: "string", enum: ["none", "linear", "radial"], description: "gradient type." },
|
|
256
|
+
gradientAngle: { type: "number", description: "angle 0-360." },
|
|
257
|
+
stops: { type: "array", items: { type: "string" }, description: "gradient stops: color or color@0-1; overrides color2." },
|
|
258
|
+
duoOpacity: { type: "number", description: "base layer opacity 0-1." },
|
|
259
|
+
shadow: { type: "string", description: "CSS box-shadow; comma = multi; inset ok." },
|
|
260
|
+
dash: { type: "array", items: { type: "number" }, description: "stroke-dasharray, e.g. [4,2]." },
|
|
261
|
+
fillRule: { type: "string", enum: ["nonzero", "evenodd"], description: "evenodd = multi-subpath holes (rings)." },
|
|
262
|
+
shapes: {
|
|
263
|
+
type: "array",
|
|
264
|
+
description: "extra layers on top of the glyph.",
|
|
265
|
+
items: {
|
|
266
|
+
type: "object",
|
|
267
|
+
additionalProperties: false,
|
|
268
|
+
properties: {
|
|
269
|
+
name: { type: "string", description: "icon name; or use path." },
|
|
270
|
+
path: { type: "string", description: "raw SVG path d= data." },
|
|
271
|
+
scale: { type: "number", description: "0.02-4." },
|
|
272
|
+
dx: { type: "number", description: "offset x, grid units." },
|
|
273
|
+
dy: { type: "number", description: "offset y (negative = up)." },
|
|
274
|
+
rotate: { type: "number", description: "degrees about own center." },
|
|
275
|
+
color: { type: "string", description: "own color." },
|
|
276
|
+
stops: { type: "array", items: { type: "string" }, description: "own gradient: color or color@0-1." },
|
|
277
|
+
gradient: { type: "string", enum: ["linear", "radial"], description: "own gradient type." },
|
|
278
|
+
angle: { type: "number", description: "own gradient angle." },
|
|
279
|
+
fillRule: { type: "string", enum: ["nonzero", "evenodd"], description: "own fill-rule." },
|
|
280
|
+
weight: { type: "number", description: "own stroke width 0.1-12." },
|
|
281
|
+
subtract: { type: "boolean", description: "true = punch a hole." }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
partner: { type: "string", description: "echo partner icon." },
|
|
286
|
+
spring: { type: "string", enum: SPRING_ENUM, description: "echo spring preset." },
|
|
287
|
+
size: { type: "number", description: "24 svg / 16 whale." },
|
|
288
|
+
strokeWidth: { type: "number", description: "weight 0-64." },
|
|
289
|
+
color: { type: "string", description: "any color; both = fill color." },
|
|
290
|
+
color2: { type: "string", description: "gradient end, or glyph color." },
|
|
291
|
+
outPath: { type: "string", description: "also write the file here." }
|
|
292
|
+
},
|
|
293
|
+
output: {
|
|
294
|
+
schema: { type: "object", additionalProperties: true },
|
|
295
|
+
render: (args, value) =>
|
|
296
|
+
args.action === "find"
|
|
297
|
+
? [
|
|
298
|
+
{
|
|
299
|
+
type: "text",
|
|
300
|
+
text:
|
|
301
|
+
`icon library: ${value.results.length} match(es) for "${value.query}"` +
|
|
302
|
+
(value.results.length === 0 ? " — try broader or split keywords." : "") +
|
|
303
|
+
"\n" +
|
|
304
|
+
value.results
|
|
305
|
+
.map(
|
|
306
|
+
(r) =>
|
|
307
|
+
`- ${r.pack === "lucide" ? "" : `[${r.pack}] `}${r.name} (Pascal ${r.pascal}${r.aliases.length > 0 ? `; aliases: ${r.aliases.join(", ")}` : ""}) — ${r.shapes}` +
|
|
308
|
+
(r.variants?.length > 0 ? ` · 可选 ${r.variants.join("/")} 版` : "")
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
.join("\n")
|
|
312
|
+
}
|
|
313
|
+
]
|
|
314
|
+
: [
|
|
315
|
+
{
|
|
316
|
+
type: "text",
|
|
317
|
+
text:
|
|
318
|
+
`icon (emit) [${value.target}${value.style !== undefined ? `:${value.style}` : ""}] ${value.icon.name} (${value.icon.nodeCount} nodes)` +
|
|
319
|
+
(value.writtenTo !== undefined
|
|
320
|
+
? ` → written: ${value.writtenTo}`
|
|
321
|
+
: value.filename !== undefined
|
|
322
|
+
? ` — 建议文件名:${value.filename}`
|
|
323
|
+
: "") +
|
|
324
|
+
`\n\n${value.code}\n` +
|
|
325
|
+
(value.notes.length > 0 ? `\n${value.notes.map((n) => `· ${n}`).join("\n")}` : "")
|
|
326
|
+
}
|
|
327
|
+
]
|
|
328
|
+
},
|
|
329
|
+
isConcurrencySafe: (args) => args?.action === "find",
|
|
330
|
+
// 索图很快,但 emit 要读 JSON + 写文件;给个显式上限,别让宿主用默认值
|
|
331
|
+
timeoutMs: 30000,
|
|
332
|
+
async execute(args, exec) {
|
|
333
|
+
if (args.action === "find") {
|
|
334
|
+
if (typeof args.query !== "string" || args.query.trim() === "") throw new Error("action=find requires query");
|
|
335
|
+
return runIconFind(args);
|
|
336
|
+
}
|
|
337
|
+
if (args.action === "emit") {
|
|
338
|
+
// name 与 text 至少有一样:文字标不需要 name(文字会取代字形)
|
|
339
|
+
const hasName = typeof args.name === "string" && args.name.trim() !== "";
|
|
340
|
+
const hasText = typeof args.text === "string" && args.text.trim() !== "";
|
|
341
|
+
const hasPath = typeof args.path === "string" && args.path.trim() !== "";
|
|
342
|
+
if (!hasName && !hasText && !hasPath) throw new Error("action=emit requires name, text or path");
|
|
343
|
+
if (typeof args.target !== "string") throw new Error("action=emit requires target");
|
|
344
|
+
return runIconEmit(args, exec);
|
|
345
|
+
}
|
|
346
|
+
throw new Error(`unknown action: ${args.action}`);
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
/* ---------------------------- morph ---------------------------- */
|
|
352
|
+
async function runMorphCheck(args, exec) {
|
|
353
|
+
const given = [args.icons, args.file].filter((v) => v !== undefined && v !== null && v !== "" && !(Array.isArray(v) && v.length === 0));
|
|
354
|
+
if (given.length === 0) throw new Error("pass one of icons / file");
|
|
355
|
+
if (given.length > 1) throw new Error("icons / file are mutually exclusive");
|
|
356
|
+
let inputs = [];
|
|
357
|
+
if (args.icons !== undefined) {
|
|
358
|
+
if (args.icons.length === 0 || args.icons.length > 2) throw new Error("icons must contain 1–2 names");
|
|
359
|
+
inputs = args.icons.map((n) => ({ kind: "name", value: n }));
|
|
360
|
+
} else {
|
|
361
|
+
const target = await resolveArg(args.file, exec);
|
|
362
|
+
const { readFileSync, existsSync } = await import("node:fs");
|
|
363
|
+
// 先探存在:裸 ENOENT 只给系统错误码,模型不知道是「路径错了」还是「相对目录没对」
|
|
364
|
+
if (!existsSync(target)) throw new Error(`file not found: ${args.file}(解析为 ${target};相对路径按会话目录解析)`);
|
|
365
|
+
inputs = [{ kind: "file", value: readFileSync(target, "utf8"), source: args.file }];
|
|
366
|
+
}
|
|
367
|
+
const checked = inputs.map(({ kind, value, source }) => {
|
|
368
|
+
const check = checkIcon(value, { nameLookup: kind === "name" });
|
|
369
|
+
const fallbackName = source ?? (kind === "svg" ? "svg-input" : String(value ?? "input"));
|
|
370
|
+
const item = {
|
|
371
|
+
name: check.name ?? fallbackName,
|
|
372
|
+
parsed: check.parsed,
|
|
373
|
+
ok: check.ok,
|
|
374
|
+
nodeCount: check.nodeCount,
|
|
375
|
+
shapes: check.shapes,
|
|
376
|
+
issues: check.issues,
|
|
377
|
+
warnings: check.warnings
|
|
378
|
+
};
|
|
379
|
+
if (check.engine !== undefined) item.engine = check.engine;
|
|
380
|
+
return item;
|
|
381
|
+
});
|
|
382
|
+
let pairNote;
|
|
383
|
+
if (checked.length === 2) {
|
|
384
|
+
pairNote = checked.every((c) => c.parsed)
|
|
385
|
+
? "两个图标均可形变;节点数不同不影响(morphicons 弧长重采样自动对齐)"
|
|
386
|
+
: "存在不可形变的图标,接线前先修复";
|
|
387
|
+
}
|
|
388
|
+
return { ok: checked.every((c) => c.parsed && c.ok), checked, ...(pairNote !== undefined ? { pairNote } : {}) };
|
|
389
|
+
}
|
|
390
|
+
async function runMorphPreview(args, exec) {
|
|
391
|
+
// 相对 .svg 路径按会话目录解析(与 action=check 的 file 保持一致)
|
|
392
|
+
const cwd = await sessionCwd(exec);
|
|
393
|
+
const from = await resolveIconInput(args.from, { cwd });
|
|
394
|
+
const to = await resolveIconInput(args.to, { cwd });
|
|
395
|
+
const outPath = await resolveArg(args.outPath, exec);
|
|
396
|
+
const result = await writePreview({ from, to, spring: args.spring, outPath, previewDir: config.previewDir, cwd: undefined, size: args.size });
|
|
397
|
+
return { ...result, from: { name: from.name }, to: { name: to.name } };
|
|
398
|
+
}
|
|
399
|
+
if (shouldRegister(profile, "morph")) {
|
|
400
|
+
ctx.tools.register(
|
|
401
|
+
defineTool({
|
|
402
|
+
name: "morph",
|
|
403
|
+
description: "Icon morph: verify morphability (seven primitives, flat 24×24) or preview a two-state animation.",
|
|
404
|
+
parameters: {
|
|
405
|
+
action: { type: "string", enum: ["check", "preview"], description: "check | preview.", required: true },
|
|
406
|
+
icons: { type: "array", items: { type: "string" }, description: "1–2 icon names." },
|
|
407
|
+
file: { type: "string", description: ".svg path." },
|
|
408
|
+
from: { type: "string", description: "start: name / .svg / markup." },
|
|
409
|
+
to: { type: "string", description: "target: name / .svg / markup." },
|
|
410
|
+
spring: { type: "string", enum: SPRING_ENUM, description: "preset, default snappy." },
|
|
411
|
+
outPath: { type: "string", description: ".html path (default previewDir)." },
|
|
412
|
+
size: { type: "number", description: "stage px 40–800, default 120." }
|
|
413
|
+
},
|
|
414
|
+
output: {
|
|
415
|
+
schema: { type: "object", additionalProperties: true },
|
|
416
|
+
render: (args, value) =>
|
|
417
|
+
args.action === "check"
|
|
418
|
+
? [
|
|
419
|
+
{
|
|
420
|
+
type: "text",
|
|
421
|
+
text:
|
|
422
|
+
`morph (check): ${value.ok ? "[OK] compatible" : "[FAIL] incompatible"}\n` +
|
|
423
|
+
value.checked
|
|
424
|
+
.map((c) => {
|
|
425
|
+
const label = c.name ?? c.source ?? "icon";
|
|
426
|
+
const state = c.parsed ? `${c.shapes ?? `${c.nodeCount} nodes`}` : "unparsed";
|
|
427
|
+
const engine =
|
|
428
|
+
c.engine === undefined
|
|
429
|
+
? ""
|
|
430
|
+
: c.engine.accepted
|
|
431
|
+
? ` | engine ok${c.engine.regridded ? "(viewBox 已自动 re-grid)" : ""}`
|
|
432
|
+
: ` | engine fail ${c.engine.reason ?? "rejected"}`;
|
|
433
|
+
const issues = [...(c.issues ?? []), ...(c.warnings ?? [])].map((s) => ` — ${s}`).join("");
|
|
434
|
+
return `- ${label}: ${state}${engine}${issues}`;
|
|
435
|
+
})
|
|
436
|
+
.join("\n") +
|
|
437
|
+
(value.pairNote !== undefined ? `\n${value.pairNote}` : "")
|
|
438
|
+
}
|
|
439
|
+
]
|
|
440
|
+
: [
|
|
441
|
+
{
|
|
442
|
+
type: "text",
|
|
443
|
+
text:
|
|
444
|
+
`morph (preview): ${value.from.name} ⇄ ${value.to.name} (spring ${value.spring}) → ${value.path} (${Math.round(value.sizeBytes / 1024)} KB)\n` +
|
|
445
|
+
`自包含离线页:浏览器打开即用(按钮=弹簧动画,拖动条=受控形变)。把路径发给用户即可。`
|
|
446
|
+
}
|
|
447
|
+
]
|
|
448
|
+
},
|
|
449
|
+
timeoutMs: 60000,
|
|
450
|
+
isConcurrencySafe: (args) => args?.action === "check",
|
|
451
|
+
async execute(args, exec) {
|
|
452
|
+
if (args.action === "check") return runMorphCheck(args, exec);
|
|
453
|
+
if (args.action === "preview") {
|
|
454
|
+
if (typeof args.from !== "string" || typeof args.to !== "string") throw new Error("action=preview requires from and to");
|
|
455
|
+
return runMorphPreview(args, exec);
|
|
456
|
+
}
|
|
457
|
+
throw new Error(`unknown action: ${args.action}`);
|
|
458
|
+
}
|
|
459
|
+
})
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/* --------------------------- taste_check -------------------------- */
|
|
464
|
+
// taste(AI 味体检)与 audit(图标规范巡检)合并:两者都是「扫源码判质量」,
|
|
465
|
+
// 只是判据不同(审美诊断 vs 项目契约)。合并后少一个常驻工具定义。
|
|
466
|
+
if (shouldRegister(profile, "taste_check")) {
|
|
467
|
+
ctx.tools.register(
|
|
468
|
+
defineTool({
|
|
469
|
+
name: "taste_check",
|
|
470
|
+
description:
|
|
471
|
+
"AI-look lint for source OR a PNG. taste = AI-look score 0-100 (lower better) with fixes; audit = icon rules (emoji, MorphIcon, ICONS).",
|
|
472
|
+
parameters: {
|
|
473
|
+
action: { type: "string", enum: ["taste", "audit"], description: "taste | audit.", required: true },
|
|
474
|
+
root: { type: "string", description: "Code file, dir, or .png image; abs or session-relative.", required: true },
|
|
475
|
+
profile: { type: "string", enum: ["balanced", "strict", "structure"], description: "taste: strictness preset." },
|
|
476
|
+
allow: { type: "array", items: { type: "string" }, description: "deliberately used rule ids." },
|
|
477
|
+
ignore: { type: "array", items: { type: "string" }, description: "rule ids to skip entirely." },
|
|
478
|
+
preset: { type: "string", enum: ["echo", "colibri", "universal", "auto"], description: "audit rule preset." }
|
|
479
|
+
},
|
|
480
|
+
output: {
|
|
481
|
+
schema: { type: "object", additionalProperties: true },
|
|
482
|
+
render: (args, value) => {
|
|
483
|
+
if (args.action === "audit") {
|
|
484
|
+
return [
|
|
485
|
+
{
|
|
486
|
+
type: "text",
|
|
487
|
+
text:
|
|
488
|
+
`taste_check [audit:${value.preset}] ${value.root}: ${value.filesScanned} file(s) — ${value.counts.error ?? 0} error / ${value.counts.warn ?? 0} warn / ${value.counts.info ?? 0} info` +
|
|
489
|
+
(value.emojiTotal !== undefined ? `(共 ${value.emojiTotal} 个 emoji)` : "") +
|
|
490
|
+
(value.truncated ? " (truncated)" : "") +
|
|
491
|
+
"\n" +
|
|
492
|
+
(value.findings.length === 0
|
|
493
|
+
? "no findings"
|
|
494
|
+
: value.findings.map((f) => `- [${f.severity}] ${f.rule} ${f.file}:${f.line} ${f.message}`).join("\n"))
|
|
495
|
+
}
|
|
496
|
+
];
|
|
497
|
+
}
|
|
498
|
+
const fixLines = value.actions.map((a) => `- [${a.score}] ${a.label} (${a.scope}) — ${a.note}\n → ${a.fix}`).join("\n");
|
|
499
|
+
const spotLines = value.findings
|
|
500
|
+
.slice(0, 8)
|
|
501
|
+
.map((f) => ` ${f.file}:${f.line} ${f.evidence}`)
|
|
502
|
+
.join("\n");
|
|
503
|
+
const advisoryLines = value.advisories.map((a) => `- ${a.label} — ${a.note}\n → ${a.fix}`).join("\n");
|
|
504
|
+
const verdict =
|
|
505
|
+
value.actions.length === 0
|
|
506
|
+
? "quality floor met" +
|
|
507
|
+
(value.grade === "clean" && value.score > 0
|
|
508
|
+
? " — clean range: stop optimizing here. Further score chasing pushes designs toward sameness."
|
|
509
|
+
: "")
|
|
510
|
+
: fixLines + (value.coOccurrence?.text ? `\n ↳ ${value.coOccurrence.text}` : "");
|
|
511
|
+
return [
|
|
512
|
+
{
|
|
513
|
+
type: "text",
|
|
514
|
+
text:
|
|
515
|
+
(value.kind === "image"
|
|
516
|
+
? `taste_check [taste:image] ${value.score}/100 (${value.grade}) — ${value.image.file} ${value.image.width}×${value.image.height}\n`
|
|
517
|
+
: `taste_check [taste] ${value.score}/100 (${value.grade}, profile=${value.profile}) — ${value.filesScanned} file(s), ${value.findingsTotal} finding(s)${value.findingsTruncated ? " (truncated)" : ""}\n`) +
|
|
518
|
+
verdict +
|
|
519
|
+
(spotLines === "" ? "" : `\n hotspots:\n${spotLines}`) +
|
|
520
|
+
(advisoryLines === "" ? "" : `\n style advisories (not scored — your call):\n${advisoryLines}`) +
|
|
521
|
+
(value.exempted.length === 0 ? "" : `\n exempted by declaration: ${value.exempted.join(", ")}`) +
|
|
522
|
+
(value.scopeNote === undefined ? "" : `\n scope: ${value.scopeNote}`) +
|
|
523
|
+
(Array.isArray(value.blindSpots) && value.blindSpots.length > 0
|
|
524
|
+
? `\n blind spots(要查这些请给源码): ${value.blindSpots.join(" / ")}`
|
|
525
|
+
: "")
|
|
526
|
+
}
|
|
527
|
+
];
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
isConcurrencySafe: () => true,
|
|
531
|
+
timeoutMs: 120000,
|
|
532
|
+
async execute(args, exec) {
|
|
533
|
+
const target = await resolveArg(args.root, exec);
|
|
534
|
+
if (args.action === "audit") {
|
|
535
|
+
return audit(target, args.preset ?? "auto", { maxFiles: config.maxAuditFiles, maxFindings: config.maxFindings });
|
|
536
|
+
}
|
|
537
|
+
if (args.action === "taste") {
|
|
538
|
+
return taste(target, { ignore: args.ignore, allow: args.allow, profile: args.profile });
|
|
539
|
+
}
|
|
540
|
+
throw new Error(`unknown action: ${args.action}`);
|
|
541
|
+
}
|
|
542
|
+
})
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/* -------------------------- layout_check ------------------------ */
|
|
547
|
+
// 排版体检三条路径:ui(HTML/CSS 字体系统)/ svg(成品 SVG 的真实文字几何)/
|
|
548
|
+
// image(渲染后 PNG 的构图几何)。共享 0–100 分与统一报告结构(lib/layouts/common.js)。
|
|
549
|
+
const LAYOUT_ACTIONS = ["ui", "svg", "image"];
|
|
550
|
+
async function runLayoutCheck(args, exec) {
|
|
551
|
+
const { existsSync, readFileSync } = await import("node:fs");
|
|
552
|
+
if (args.action === "ui") {
|
|
553
|
+
if (typeof args.root !== "string" || args.root.trim() === "") throw new Error("action=ui 需要 root(HTML/CSS 文件或目录)");
|
|
554
|
+
const target = await resolveArg(args.root, exec);
|
|
555
|
+
if (!existsSync(target)) throw new Error(`path not found: ${target}`);
|
|
556
|
+
const report = await auditSource(target);
|
|
557
|
+
if (args.render !== true) return report;
|
|
558
|
+
// render=true:静态解析算不准的(rgba / CSS 变量 / 渐变底)改用**真实渲染像素**量。
|
|
559
|
+
// 找不到 HTML 入口就如实说做不到,不假装算过。
|
|
560
|
+
const entry = (() => {
|
|
561
|
+
if (statSync(target).isFile()) return target.endsWith(".html") ? target : null;
|
|
562
|
+
const names = readdirSync(target).filter((f) => f.endsWith(".html"));
|
|
563
|
+
if (names.includes("index.html")) return join(target, "index.html");
|
|
564
|
+
return names.length > 0 ? join(target, names[0]) : null;
|
|
565
|
+
})();
|
|
566
|
+
if (entry === null) {
|
|
567
|
+
report.metrics.renderedContrast = { rendered: false, reason: "没有找到 .html 入口(render 只对 HTML 页面有效)" };
|
|
568
|
+
return report;
|
|
569
|
+
}
|
|
570
|
+
const scratch = mkdtempSync(join(tmpdir(), "dsh-contrast-"));
|
|
571
|
+
try {
|
|
572
|
+
const measured = measureRenderedContrast({ htmlPath: entry, outDir: scratch, width: args.width, height: args.height, browserPath: config?.browserPath });
|
|
573
|
+
report.metrics.renderedContrast = measured.rendered ? { rendered: true, samples: measured.samples, bands: measured.bands, worst: measured.worst === null ? null : Math.round(measured.worst.ratio * 100) / 100 } : { rendered: false, reason: measured.reason };
|
|
574
|
+
if (measured.rendered && measured.worst !== null) {
|
|
575
|
+
// 逐种文字配色报:同一种字在不同位置背景不同(渐变),要给出最差那处
|
|
576
|
+
const failing = measured.pairs.filter((p) => p.ratio < 4.5);
|
|
577
|
+
for (const pair of failing) {
|
|
578
|
+
const hard = pair.ratio < 3;
|
|
579
|
+
report.findings.push({
|
|
580
|
+
severity: hard ? "error" : "warn",
|
|
581
|
+
rule: "contrast-effective",
|
|
582
|
+
where: `render:${pair.x},${pair.y}`,
|
|
583
|
+
message:
|
|
584
|
+
`实际渲染对比度不足:${pair.ratio}:1(<4.5 WCAG AA)—— 文字 ${pair.fg} 压在真实背景 ${pair.bg} 上` +
|
|
585
|
+
`(${pair.pixels}px 墨迹)。渐变/半透明/图片底都算在内,静态解析算不出这一项`,
|
|
586
|
+
fix: "提高前景与背景的亮度差:加深文字色、给文字下方垫一层实色、或收窄背景渐变跨度——WCAG AA 要求正文 ≥4.5:1、大字号 ≥3:1"
|
|
587
|
+
});
|
|
588
|
+
report.counts[hard ? "error" : "warn"] += 1;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return report;
|
|
592
|
+
} finally {
|
|
593
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const input = typeof args.input === "string" ? args.input.trim() : "";
|
|
597
|
+
if (input === "") throw new Error(`action=${args.action} 需要 input(SVG 标记 / .svg 路径 / .png 路径)`);
|
|
598
|
+
if (args.action === "svg") {
|
|
599
|
+
// 内联标记没有目录,读不到 .design-intent.json;文件模式从它所在目录读。
|
|
600
|
+
if (input.includes("<")) return auditSvg(input, { name: "inline-svg" });
|
|
601
|
+
const file = await resolveArg(input, exec);
|
|
602
|
+
if (!existsSync(file)) throw new Error(`SVG 文件读不到:${input}(解析为 ${file};相对路径按会话目录)`);
|
|
603
|
+
return auditSvg(readFileSync(file, "utf8"), { name: basename(file), intentDir: dirname(file) });
|
|
604
|
+
}
|
|
605
|
+
// image:PNG / JPEG / WebP(SVG 先用 design_render action=render 渲染)
|
|
606
|
+
const file = await resolveArg(input, exec);
|
|
607
|
+
if (!existsSync(file)) throw new Error(`图片文件读不到:${input}(解析为 ${file};相对路径按会话目录)`);
|
|
608
|
+
if (/\.(?:jpe?g|webp|png)$/i.test(file) === false) {
|
|
609
|
+
throw new Error("action=image 支持 PNG/JPEG/WebP:SVG 先用 design_render action=render 渲染成 PNG,再体检");
|
|
610
|
+
}
|
|
611
|
+
// JPEG/WebP 先转成同尺寸 PNG(与 taste_check 共用 readAsPng,避免两边行为分叉)
|
|
612
|
+
return auditImage(await readAsPng(file), { name: basename(file) });
|
|
613
|
+
}
|
|
614
|
+
if (shouldRegister(profile, "layout_check")) {
|
|
615
|
+
ctx.tools.register(
|
|
616
|
+
defineTool({
|
|
617
|
+
name: "layout_check",
|
|
618
|
+
description:
|
|
619
|
+
"Layout/typography audit 0-100 (lower better). ui = HTML/CSS type system; svg = text geometry; image = PNG/JPEG/WebP composition.",
|
|
620
|
+
parameters: {
|
|
621
|
+
action: { type: "string", enum: LAYOUT_ACTIONS, description: "ui | svg | image.", required: true },
|
|
622
|
+
root: { type: "string", description: "ui: file or directory." },
|
|
623
|
+
render: { type: "boolean", description: "ui: also measure real rendered text contrast." },
|
|
624
|
+
width: { type: "number", description: "render: viewport width px." },
|
|
625
|
+
height: { type: "number", description: "render: viewport height px." },
|
|
626
|
+
input: { type: "string", description: "svg/image: path or inline markup." }
|
|
627
|
+
},
|
|
628
|
+
output: {
|
|
629
|
+
schema: {
|
|
630
|
+
type: "object",
|
|
631
|
+
additionalProperties: true,
|
|
632
|
+
properties: {
|
|
633
|
+
mode: { type: "string" },
|
|
634
|
+
target: { type: "string" },
|
|
635
|
+
score: { type: "number" },
|
|
636
|
+
grade: { type: "string" },
|
|
637
|
+
counts: { type: "object", additionalProperties: true },
|
|
638
|
+
metrics: { type: "object", additionalProperties: true },
|
|
639
|
+
findings: { type: "array", items: { type: "object", additionalProperties: true } }
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
render: (_args, value) => {
|
|
643
|
+
// metrics 里既有数字/数组也有对象(marginsPct/contentBox):对象必须显式序列化,
|
|
644
|
+
// 否则模板字符串会把它们变成 "[object Object]"(输出无损断言会直接挂)。
|
|
645
|
+
const formatMetric = (v) =>
|
|
646
|
+
Array.isArray(v) ? v.join("/") : v !== null && typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
647
|
+
const metricBits = Object.entries(value.metrics ?? {})
|
|
648
|
+
.slice(0, 8)
|
|
649
|
+
.map(([k, v]) => `${k}=${formatMetric(v)}`)
|
|
650
|
+
.join(" · ");
|
|
651
|
+
const items = value.findings
|
|
652
|
+
.slice(0, 10)
|
|
653
|
+
.map((f) => `- [${f.severity}] ${f.rule}${f.where !== undefined ? ` @${f.where}` : ""} ${f.message}\n → ${f.fix}`)
|
|
654
|
+
.join("\n");
|
|
655
|
+
// 版式剖面单独成段:它排在 metrics 的第 12 项,会被上面的 slice(0,8) 截掉,
|
|
656
|
+
// 而且整块 JSON.stringify 出来没法读。这是参照改造时最先要看的东西。
|
|
657
|
+
const profileText = (() => {
|
|
658
|
+
const p = value.metrics?.profile;
|
|
659
|
+
if (p === undefined || p === null) return "";
|
|
660
|
+
const bits = [];
|
|
661
|
+
if (Array.isArray(p.palette) && p.palette.length > 0) {
|
|
662
|
+
bits.push(`主色 ${p.palette.slice(0, 4).map((c) => `${c.hex} ${c.share}%`).join(" ")}`);
|
|
663
|
+
}
|
|
664
|
+
// 单一列带覆盖整图 = 检测失败(常见的成因:渐变背景),当噪音吞掉。
|
|
665
|
+
// 此时卡片那行才是有效信号,两个字段互为替代。
|
|
666
|
+
const cols = Array.isArray(p.columns) ? p.columns : [];
|
|
667
|
+
const colsUseful = !(cols.length === 1 && cols[0].left <= 2 && cols[0].width >= value.metrics?.canvas?.[0] * 0.9);
|
|
668
|
+
if (Array.isArray(p.vEdges) && p.vEdges.length > 0) {
|
|
669
|
+
// 垂直分界线比色带可靠:办公/客户端界面几乎全白,色带分不开栏位,
|
|
670
|
+
// 但栏位之间一定有贯穿全高的分界线。
|
|
671
|
+
bits.push(`栏位分界 x=${p.vEdges.slice(0, 10).join("/")}`);
|
|
672
|
+
}
|
|
673
|
+
if (cols.length > 0 && colsUseful) {
|
|
674
|
+
bits.push(`列带 ${cols.map((c) => `${c.left}~${c.right}`).join(" | ")}`);
|
|
675
|
+
}
|
|
676
|
+
const g = p.cardGrid;
|
|
677
|
+
if (g !== undefined && g !== null && g.count > 0) {
|
|
678
|
+
bits.push(
|
|
679
|
+
`卡片 ${g.count} 张` +
|
|
680
|
+
(g.colStarts.length > 0 ? `(列起点 ${g.colStarts.slice(0, 8).join("/")}` : "(") +
|
|
681
|
+
(g.colGap !== null ? `,列间 ${g.colGap}px` : "") +
|
|
682
|
+
(g.rowGap !== null ? `,行间 ${g.rowGap}px` : "") +
|
|
683
|
+
")"
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
if (p.list !== null && p.list !== undefined) {
|
|
687
|
+
bits.push(`行距 ${p.list.pitch}px(${p.list.count} 行,一致 ${p.list.uniformPct}%${p.list.outlierCount > 0 ? `,${p.list.outlierCount} 行异常` : ""})`);
|
|
688
|
+
} else if (p.rowPitch !== null && p.rowPitch !== undefined) {
|
|
689
|
+
bits.push(`行节奏 ${p.rowPitch}px`);
|
|
690
|
+
}
|
|
691
|
+
return bits.length === 0 ? "" : `\n 版式剖面:${bits.join(" · ")}`;
|
|
692
|
+
})();
|
|
693
|
+
return [
|
|
694
|
+
{
|
|
695
|
+
type: "text",
|
|
696
|
+
text:
|
|
697
|
+
`layout_check [${value.mode}] ${value.target}: ${value.score}/100 (${value.grade}) — ${value.findings.length} finding(s) (${value.counts.error} error / ${value.counts.warn} warn / ${value.counts.info} info)\n` +
|
|
698
|
+
(metricBits === "" ? "" : ` ${metricBits}\n`) +
|
|
699
|
+
profileText +
|
|
700
|
+
(profileText === "" ? "" : "\n") +
|
|
701
|
+
(items === "" ? "✅ no layout issues" : items) +
|
|
702
|
+
(value.findings.length > 10 ? `\n…还有 ${value.findings.length - 10} 条(只显示前 10)` : "")
|
|
703
|
+
}
|
|
704
|
+
];
|
|
705
|
+
}
|
|
706
|
+
},
|
|
707
|
+
isConcurrencySafe: () => true,
|
|
708
|
+
timeoutMs: 120000,
|
|
709
|
+
async execute(args, exec) {
|
|
710
|
+
return runLayoutCheck(args, exec);
|
|
711
|
+
}
|
|
712
|
+
})
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/* -------------------------- design_style ------------------------ */
|
|
717
|
+
if (shouldRegister(profile, "design_style")) {
|
|
718
|
+
ctx.tools.register(
|
|
719
|
+
defineTool({
|
|
720
|
+
name: "design_style",
|
|
721
|
+
description: "Style tokens (skin) + skeleton recipe + ban list (8×6); format=motion gives interaction primitives.",
|
|
722
|
+
parameters: {
|
|
723
|
+
skin: { type: "string", enum: SKINS.map((s) => s.id), description: "visual language (base).", required: true },
|
|
724
|
+
// 说明保持短:完整键表在 README,用错 key 时抛错也会列全(自文档化),
|
|
725
|
+
// 不必把 20 个键名塞进每请求常驻的 schema 里。
|
|
726
|
+
tokens: { type: "string", description: "k=v;k=v: fill/radius/border/shadow/font + mech(pattern/clip/dur/press…)" },
|
|
727
|
+
skeleton: { type: "string", enum: SKELETONS.map((s) => s.id), description: "info structure; mixes with any skin." },
|
|
728
|
+
format: { type: "string", enum: ["css", "json", "tailwind", "motion"], description: "default css; motion = 交互原语层" }
|
|
729
|
+
},
|
|
730
|
+
output: {
|
|
731
|
+
schema: {
|
|
732
|
+
type: "object",
|
|
733
|
+
additionalProperties: true,
|
|
734
|
+
properties: {
|
|
735
|
+
skin: { type: "string" },
|
|
736
|
+
skeleton: { type: "string" },
|
|
737
|
+
label: { type: "string" },
|
|
738
|
+
useWhen: { type: "string" },
|
|
739
|
+
tone: { type: "string" },
|
|
740
|
+
density: { type: "string" },
|
|
741
|
+
code: { type: "string" },
|
|
742
|
+
steps: { type: "array", items: { type: "string" } },
|
|
743
|
+
bans: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
744
|
+
exempt: { type: "array", items: { type: "string" } }
|
|
745
|
+
}
|
|
746
|
+
},
|
|
747
|
+
render: (_args, value) => {
|
|
748
|
+
const groups = { quality: [], style: [], content: [] };
|
|
749
|
+
for (const ban of value.bans) groups[ban.scope]?.push(ban.text);
|
|
750
|
+
const banLines =
|
|
751
|
+
`quality floor(不可协商):\n${groups.quality.map((t) => `- ${t}`).join("\n")}\n\n` +
|
|
752
|
+
`style advice(可声明豁免):\n${groups.style.map((t) => `- ${t}`).join("\n")}\n\n` +
|
|
753
|
+
`content:\n${groups.content.map((t) => `- ${t}`).join("\n")}`;
|
|
754
|
+
return [
|
|
755
|
+
{
|
|
756
|
+
type: "text",
|
|
757
|
+
text:
|
|
758
|
+
`design_style [${value.skin} × ${value.skeleton}] ${value.label} (${value.density}) — ${value.tone}\n` +
|
|
759
|
+
`适用:${value.useWhen}\n\n${value.code}\n\n` +
|
|
760
|
+
`结构配方:\n${value.steps.map((line, i) => `${i + 1}. ${line}`).join("\n")}\n\n` +
|
|
761
|
+
banLines +
|
|
762
|
+
(value.exempt.length === 0 ? "" : `\n\n本皮肤有意偏离:${value.exempt.join(", ")}——写进 .design-intent.json 的 allow 即可不被计分`)
|
|
763
|
+
}
|
|
764
|
+
];
|
|
765
|
+
}
|
|
766
|
+
},
|
|
767
|
+
isConcurrencySafe: () => true,
|
|
768
|
+
timeoutMs: 30000,
|
|
769
|
+
async execute(args) {
|
|
770
|
+
return renderStyle({ skin: args.skin, skeleton: args.skeleton, format: args.format ?? "css", tokens: args.tokens });
|
|
771
|
+
}
|
|
772
|
+
})
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/* ------------------------- design_render ------------------------ */
|
|
777
|
+
ctx.tools.register(
|
|
778
|
+
defineTool({
|
|
779
|
+
name: "design_render",
|
|
780
|
+
description:
|
|
781
|
+
"Render SVG (offline resvg), HTML→PNG (needs local Edge/Chrome), layer composite, asset library.",
|
|
782
|
+
parameters: {
|
|
783
|
+
action: { type: "string", enum: ["render", "composite", "asset", "html", "diff"], description: "render | composite | asset | html | diff.", required: true },
|
|
784
|
+
base: { type: "string", description: "diff: reference image." },
|
|
785
|
+
compare: { type: "string", description: "diff: image to compare against base." },
|
|
786
|
+
slices: { type: "number", description: "diff: column slices for region report (default 4)." },
|
|
787
|
+
align: { type: "boolean", description: "diff: also search best dx/dy shift (ghosting)." },
|
|
788
|
+
html: { type: "string", description: "h: HTML path or inline markup." },
|
|
789
|
+
height: { type: "number", description: "h: viewport height px (default 800)." },
|
|
790
|
+
fullPage: { type: "boolean", description: "h: capture full page height." },
|
|
791
|
+
pageScale: { type: "number", description: "h: device pixel ratio 0.5-4." },
|
|
792
|
+
svg: { type: "string", description: "r: SVG path or markup." },
|
|
793
|
+
png: { type: "string", description: "r: output .png path." },
|
|
794
|
+
width: { type: "number", description: "r: output width px." },
|
|
795
|
+
under: { type: "string", description: "c: base png/svg, or #color (needs canvas)." },
|
|
796
|
+
over: { type: "string", description: "c: overlay png/svg." },
|
|
797
|
+
out: { type: "string", description: "c: output .png path." },
|
|
798
|
+
anchor: { type: "string", enum: ["top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right"], description: "c: layer position, default center." },
|
|
799
|
+
fit: { type: "string", enum: ["contain", "cover", "fill", "none"], description: "c: layer scaling, default contain." },
|
|
800
|
+
padding: { type: "number", description: "c: inset all edges px." },
|
|
801
|
+
rotate: { type: "number", description: "c: rotate about center deg." },
|
|
802
|
+
blend: { type: "string", enum: ["normal", "multiply", "screen", "overlay", "darken", "lighten", "difference"], description: "c: mix-blend-mode." },
|
|
803
|
+
bg: { type: "string", description: "c: flat color behind layer." },
|
|
804
|
+
shadow: { type: "string", description: "c: CSS box-shadow for layer." },
|
|
805
|
+
canvas: { type: "array", items: { type: "number" }, description: "c: [w,h]; needed when under is #color." },
|
|
806
|
+
opacity: { type: "number", description: "c: layer opacity 0-1." },
|
|
807
|
+
scale: { type: "number", description: "c: multiplier 0.05-8." },
|
|
808
|
+
sizes: { type: "array", items: { type: "number" }, description: "r/c: also write these widths." },
|
|
809
|
+
ico: { type: "boolean", description: "r/c: also write .ico (6 frames)." },
|
|
810
|
+
mode: { type: "string", enum: ["find", "inline", "save"], description: "a: find | inline | save." },
|
|
811
|
+
keyword: { type: "string", description: "a: keyword." },
|
|
812
|
+
rel: { type: "string", description: "a: library-relative path." },
|
|
813
|
+
color: { type: "string", description: "a: recolor, e.g. #FF6B35." },
|
|
814
|
+
saveTo: { type: "string", description: "a: save output path." },
|
|
815
|
+
limit: { type: "number", description: "a: max results." }
|
|
816
|
+
},
|
|
817
|
+
output: {
|
|
818
|
+
schema: {
|
|
819
|
+
type: "object",
|
|
820
|
+
additionalProperties: true,
|
|
821
|
+
properties: {
|
|
822
|
+
action: { type: "string" },
|
|
823
|
+
path: { type: "string" },
|
|
824
|
+
width: { type: "number" },
|
|
825
|
+
height: { type: "number" },
|
|
826
|
+
bytes: { type: "number" },
|
|
827
|
+
mode: { type: "string" },
|
|
828
|
+
total: { type: "number" },
|
|
829
|
+
items: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
830
|
+
rel: { type: "string" },
|
|
831
|
+
viewBox: { type: "string" },
|
|
832
|
+
inner: { type: "string" }
|
|
833
|
+
}
|
|
834
|
+
},
|
|
835
|
+
render: (_args, value) => {
|
|
836
|
+
if (value.action === "diff") {
|
|
837
|
+
const worst = [...value.regions].sort((a, b) => b.meanDelta - a.meanDelta).slice(0, 3);
|
|
838
|
+
return [
|
|
839
|
+
{
|
|
840
|
+
type: "text",
|
|
841
|
+
text:
|
|
842
|
+
`diff: 均差 ${value.meanDelta}/255(${value.meanPct}%)· 最大 ${value.maxDelta} · 变化区 ${value.changedPct}%\n` +
|
|
843
|
+
`分区(${value.cols}×${value.rows})均差:` +
|
|
844
|
+
value.regions.map((r) => `${r.col + 1},${r.row + 1}=${r.meanDelta}`).join(" ") +
|
|
845
|
+
`\n最差三区:` +
|
|
846
|
+
worst.map((r) => `第${r.col + 1}列${r.row + 1}行=${r.meanDelta}`).join(" · ") +
|
|
847
|
+
(value.align === undefined
|
|
848
|
+
? ""
|
|
849
|
+
: `\n偏移搜索:最佳平移 dx=${value.align.bestDx} dy=${value.align.bestDy}px → 均差 ${value.align.deltaAtZero} → ${value.align.deltaAtBest}` +
|
|
850
|
+
`(改善 ${value.align.gainPct}%)` +
|
|
851
|
+
(value.align.gainPct > 15 ? "——重影主因是整体错位,先对齐起始位置再改细节" : "——错位不是主因,差异来自结构/内容")) +
|
|
852
|
+
(value.heatmap === undefined ? "" : `\n热力图:${value.heatmap}`)
|
|
853
|
+
}
|
|
854
|
+
];
|
|
855
|
+
}
|
|
856
|
+
if (value.action === "html") {
|
|
857
|
+
const kb = Math.round(value.bytes / 1024);
|
|
858
|
+
return [
|
|
859
|
+
{
|
|
860
|
+
type: "text",
|
|
861
|
+
text:
|
|
862
|
+
`html: ${value.path} (${value.width}×${value.height}` +
|
|
863
|
+
(value.fullPage === true ? ",整页" : "") +
|
|
864
|
+
`, ${kb} KB, ${value.scale}× 像素比) — 浏览器引擎渲染:${value.browser}`
|
|
865
|
+
}
|
|
866
|
+
];
|
|
867
|
+
}
|
|
868
|
+
if (value.action === "render" || value.action === "composite") {
|
|
869
|
+
const kb = Math.round(value.bytes / 1024);
|
|
870
|
+
const extras =
|
|
871
|
+
(value.variants !== undefined ? ` · +${value.variants.length} 个尺寸:${value.variants.map((v) => v.width).join("/")}` : "") +
|
|
872
|
+
(value.ico !== undefined ? " · +.ico" : "");
|
|
873
|
+
return [
|
|
874
|
+
{
|
|
875
|
+
type: "text",
|
|
876
|
+
text:
|
|
877
|
+
`${value.action}: ${value.path} (${value.width}×${value.height}, ${kb} KB)` +
|
|
878
|
+
(value.action === "composite" ? "" : " — 透明底 RGBA 已保留") +
|
|
879
|
+
extras
|
|
880
|
+
}
|
|
881
|
+
];
|
|
882
|
+
}
|
|
883
|
+
if (value.mode === "find") {
|
|
884
|
+
return [
|
|
885
|
+
{
|
|
886
|
+
type: "text",
|
|
887
|
+
text:
|
|
888
|
+
`asset: ${value.total} match(es) for "${value.keyword}"\n` +
|
|
889
|
+
(value.items.length === 0
|
|
890
|
+
? "(换更宽的关键词,或用 asset 分类词:品牌图标 / 图标 / 插画 / 角色部件 / 图案 / 贴纸 / 纹理)"
|
|
891
|
+
: value.items.map((i) => `- ${i.file} [${i.category}] ${i.name} — ${i.license}`).join("\n"))
|
|
892
|
+
}
|
|
893
|
+
];
|
|
894
|
+
}
|
|
895
|
+
if (value.mode === "inline") {
|
|
896
|
+
return [
|
|
897
|
+
{
|
|
898
|
+
type: "text",
|
|
899
|
+
text:
|
|
900
|
+
`asset inline: ${value.rel}${value.name !== undefined ? ` (${value.name})` : ""} viewBox=${value.viewBox}` +
|
|
901
|
+
(value.truncated ? " (truncated)" : "") +
|
|
902
|
+
`\n\n${value.inner}` +
|
|
903
|
+
(value.note !== undefined ? `\n\n· ${value.note}` : "")
|
|
904
|
+
}
|
|
905
|
+
];
|
|
906
|
+
}
|
|
907
|
+
return [{ type: "text", text: `asset saved: ${value.path} (${Math.round(value.bytes / 1024)} KB)${value.color !== null ? ` · 改色 ${value.color}` : ""}` }];
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
isConcurrencySafe: () => true,
|
|
911
|
+
timeoutMs: 120000,
|
|
912
|
+
async execute(args, exec) {
|
|
913
|
+
if (args.action === "render") {
|
|
914
|
+
const svg = await resolveArg(args.svg, exec);
|
|
915
|
+
const png = await resolveArg(args.png, exec);
|
|
916
|
+
const out = await renderSvg({ svg, png, width: args.width, sizes: args.sizes, ico: args.ico });
|
|
917
|
+
return { action: "render", ...out };
|
|
918
|
+
}
|
|
919
|
+
if (args.action === "diff") {
|
|
920
|
+
// 两张图差异:视觉改动完到底好了没有,靠眼睛只能判断“像不像”,
|
|
921
|
+
// 回答不了“这一轮是提升还是回退”。给一个能横比多轮的数字。
|
|
922
|
+
return {
|
|
923
|
+
action: "diff",
|
|
924
|
+
...(await imageDiff({
|
|
925
|
+
base: await resolveArg(args.base, exec),
|
|
926
|
+
compare: await resolveArg(args.compare, exec),
|
|
927
|
+
out: await resolveArg(args.out, exec),
|
|
928
|
+
cols: args.slices,
|
|
929
|
+
rows: args.rows,
|
|
930
|
+
align: args.align
|
|
931
|
+
}))
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
if (args.action === "html") {
|
|
935
|
+
// HTML → PNG:需要本机 Chromium 系浏览器。为什么不能自己做:
|
|
936
|
+
// 自写布局引擎去“验证”用户界面的布局,等于用可能错的尺子量东西;
|
|
937
|
+
// 真实浏览器引擎才是可信参照。探测不到时明确报错,不静默假渲染。
|
|
938
|
+
const htmlArg = await resolveArg(args.html, exec);
|
|
939
|
+
const pageOut = await resolveArg(args.out ?? args.png, exec);
|
|
940
|
+
const page = renderHtml({
|
|
941
|
+
html: htmlArg,
|
|
942
|
+
out: pageOut,
|
|
943
|
+
width: args.width,
|
|
944
|
+
height: args.height,
|
|
945
|
+
scale: args.pageScale,
|
|
946
|
+
fullPage: args.fullPage,
|
|
947
|
+
browserPath: config?.browserPath
|
|
948
|
+
});
|
|
949
|
+
return { action: "html", ...page };
|
|
950
|
+
}
|
|
951
|
+
if (args.action === "composite") {
|
|
952
|
+
// under 可能是纯色(不带路径分隔与扩展名)——纯色不 resolveArg,否则会被拼成路径
|
|
953
|
+
const underRaw = typeof args.under === "string" ? args.under.trim() : args.under;
|
|
954
|
+
const underIsColor = typeof underRaw === "string" && /^(#[0-9a-f]{3,8}|rgba?\(|hsla?\(|[a-z]+$)/i.test(underRaw);
|
|
955
|
+
const out = await composite({
|
|
956
|
+
under: underIsColor ? underRaw : await resolveArg(args.under, exec),
|
|
957
|
+
over: await resolveArg(args.over, exec),
|
|
958
|
+
out: await resolveArg(args.out, exec),
|
|
959
|
+
anchor: args.anchor,
|
|
960
|
+
fit: args.fit,
|
|
961
|
+
padding: args.padding,
|
|
962
|
+
rotate: args.rotate,
|
|
963
|
+
blend: args.blend,
|
|
964
|
+
bg: args.bg,
|
|
965
|
+
shadow: args.shadow,
|
|
966
|
+
canvas: args.canvas,
|
|
967
|
+
opacity: args.opacity,
|
|
968
|
+
scale: args.scale,
|
|
969
|
+
sizes: args.sizes,
|
|
970
|
+
ico: args.ico
|
|
971
|
+
});
|
|
972
|
+
return { action: "composite", ...out };
|
|
973
|
+
}
|
|
974
|
+
if (args.action === "asset") {
|
|
975
|
+
const mode = args.mode ?? "find";
|
|
976
|
+
const result = await asset({
|
|
977
|
+
mode,
|
|
978
|
+
keyword: args.keyword,
|
|
979
|
+
rel: args.rel,
|
|
980
|
+
color: args.color,
|
|
981
|
+
saveTo: await resolveArg(args.saveTo, exec),
|
|
982
|
+
limit: args.limit ?? config.findLimit
|
|
983
|
+
});
|
|
984
|
+
const size = mode === "save" ? sizeOf(result.path) : null;
|
|
985
|
+
return { action: "asset", ...result, ...(size !== null ? { width: size.width, height: size.height } : {}) };
|
|
986
|
+
}
|
|
987
|
+
throw new Error(`unknown action: ${args.action}`);
|
|
988
|
+
}
|
|
989
|
+
})
|
|
990
|
+
);
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
/* --------------------------- html_edit -------------------------- */
|
|
994
|
+
// 为什么单独一个工具:它回答的是「这段文本在 HTML 的哪个位置」——位置决定了能不能改、
|
|
995
|
+
// 改了会不会翻车。实测过的两种翻车:
|
|
996
|
+
// ① 把 emoji 换成 `<svg>` 时它在属性值里 → 标记被当文本显示在页面上;
|
|
997
|
+
// ② 把属性值的结束引号换成 `“` → 属性撑破,后半页错位。
|
|
998
|
+
// 这类活如果只给正则,每个调用方都要重新踩一遍;所以把“上下文分类”做成能力。
|
|
999
|
+
if (shouldRegister(profile, "html_edit")) {
|
|
1000
|
+
ctx.tools.register(
|
|
1001
|
+
defineTool({
|
|
1002
|
+
name: "html_edit",
|
|
1003
|
+
description: "Context-aware HTML rewrite: classify text/attr/script/comment first; markup never leaks into attributes.",
|
|
1004
|
+
parameters: {
|
|
1005
|
+
action: {
|
|
1006
|
+
type: "string",
|
|
1007
|
+
enum: ["scan", "rewrite"],
|
|
1008
|
+
description: "scan = 定位文本出现在哪;rewrite = 按上下文安全替换。",
|
|
1009
|
+
required: true
|
|
1010
|
+
},
|
|
1011
|
+
html: { type: "string", description: "HTML 文件路径或内联标记。", required: true },
|
|
1012
|
+
probe: { type: "string", description: "scan: 要定位的文本(如一个 emoji)。" },
|
|
1013
|
+
kind: {
|
|
1014
|
+
type: "string",
|
|
1015
|
+
enum: ["emoji", "straight-quote", "cjk-punct"],
|
|
1016
|
+
description: "scan: 整类扫描(列清单+上下文),比 probe 更适合批量替换前摸底。"
|
|
1017
|
+
},
|
|
1018
|
+
find: { type: "string", description: "rewrite: 原文(字面量)。" },
|
|
1019
|
+
replace: { type: "string", description: "rewrite: 替换内容;含 <> 时只允许进文本节点。" },
|
|
1020
|
+
pairs: { type: "string", description: "rewrite 批量: 每行 `原文=>替换`,一次过改几十个 (如 emoji 映射表)。" },
|
|
1021
|
+
scope: { type: "string", enum: ["text", "text+attr", "all"], description: "rewrite: 允许改动的上下文,默认 text。" },
|
|
1022
|
+
out: { type: "string", description: "rewrite: 写回路径;省略则只回报。" }
|
|
1023
|
+
},
|
|
1024
|
+
output: {
|
|
1025
|
+
schema: { type: "object", additionalProperties: true },
|
|
1026
|
+
render: (args, value) => {
|
|
1027
|
+
if (value.action === "scan" && value.kind !== undefined) {
|
|
1028
|
+
// 整类扫描:给“能直接换的”和“换了也没用的”两类,直接支撑接下来的批量 rewrite
|
|
1029
|
+
const list = (value.chars ?? []).map((c) => `${c.char}×${c.total}`).join(" ");
|
|
1030
|
+
const dead = value.unconvertible ?? [];
|
|
1031
|
+
return [
|
|
1032
|
+
{
|
|
1033
|
+
type: "text",
|
|
1034
|
+
text:
|
|
1035
|
+
`html_edit [scan:${value.kind}] ${value.target}: ${value.total} 处、${(value.chars ?? []).length} 种 —— ${list}\n` +
|
|
1036
|
+
(dead.length === 0
|
|
1037
|
+
? " 全部出现在文本节点,可直接换标记"
|
|
1038
|
+
: ` ${dead.length} 种只出现在属性/脚本/注释里(换了也没用):${dead.join(" ")}`) +
|
|
1039
|
+
`\n 下一步:把要改的写成 pairs(每行 \`原文=>替换\`),缺映射会当场报出来`
|
|
1040
|
+
}
|
|
1041
|
+
];
|
|
1042
|
+
}
|
|
1043
|
+
if (value.action === "scan") {
|
|
1044
|
+
const groups = Object.entries(value.counts ?? {}).map(([k, n]) => `${k}×${n}`).join("、") || "无";
|
|
1045
|
+
const spots = (value.found ?? []).slice(0, 6).map((f) => ` ${f.line}行 ${f.kind}${f.attr === undefined ? "" : `(${f.attr})`}`).join("\n");
|
|
1046
|
+
return [
|
|
1047
|
+
{
|
|
1048
|
+
type: "text",
|
|
1049
|
+
text:
|
|
1050
|
+
`html_edit [scan] ${value.target}: 「${value.probe}」共 ${value.total} 处 —— ${groups}\n` +
|
|
1051
|
+
` 可安全插入标记的只有 text 那部分;attr 里放标记一定会被当文本显示` +
|
|
1052
|
+
(spots === "" ? "" : `\n${spots}`)
|
|
1053
|
+
}
|
|
1054
|
+
];
|
|
1055
|
+
}
|
|
1056
|
+
const changed = value.changes.length;
|
|
1057
|
+
const refused = value.refused ?? [];
|
|
1058
|
+
const byKind = {};
|
|
1059
|
+
for (const c of value.changes) byKind[c.kind] = (byKind[c.kind] ?? 0) + 1;
|
|
1060
|
+
const detail = refused.slice(0, 5).map((r) => ` ${r.kind}${r.attr === undefined ? "" : `(${r.attr})`}:${r.reason}`).join("\n");
|
|
1061
|
+
return [
|
|
1062
|
+
{
|
|
1063
|
+
type: "text",
|
|
1064
|
+
text:
|
|
1065
|
+
`html_edit [rewrite] ${value.target}: 改 ${changed} 处(${Object.entries(byKind).map(([k, n]) => `${k}×${n}`).join("、") || "—"}),拒绝 ${refused.length} 处` +
|
|
1066
|
+
(value.out === undefined ? "(未写盘)" : ` → ${value.out}`) +
|
|
1067
|
+
(refused.length === 0 ? "" : `\n 被拒绝(这些位置改了会翻车):\n${detail}`) +
|
|
1068
|
+
(value.unmapped.length === 0
|
|
1069
|
+
? ""
|
|
1070
|
+
: `\n ⚠ 映射为空(未改动):${value.unmapped.slice(0, 8).join(" ")}——要删除请写 \`原文=>(del)\``)
|
|
1071
|
+
}
|
|
1072
|
+
];
|
|
1073
|
+
}
|
|
1074
|
+
},
|
|
1075
|
+
isConcurrencySafe: () => true,
|
|
1076
|
+
timeoutMs: 30000,
|
|
1077
|
+
async execute(args) {
|
|
1078
|
+
const raw = String(args.html ?? "");
|
|
1079
|
+
const looksLikePath = /[\\/]|\.html?$/i.test(raw) && !/[<>]/.test(raw);
|
|
1080
|
+
const source = looksLikePath ? await (await import("node:fs/promises")).readFile(raw, "utf8") : raw;
|
|
1081
|
+
const target = looksLikePath ? raw : "(inline)";
|
|
1082
|
+
if (args.action === "scan") {
|
|
1083
|
+
const probe = String(args.probe ?? "");
|
|
1084
|
+
const kind = String(args.kind ?? "");
|
|
1085
|
+
if (probe === "" && kind === "") throw new Error("action=scan 需要 probe 或 kind");
|
|
1086
|
+
if (probe === "") {
|
|
1087
|
+
const result = locateClass(source, kind);
|
|
1088
|
+
return plain({
|
|
1089
|
+
action: "scan",
|
|
1090
|
+
target,
|
|
1091
|
+
kind,
|
|
1092
|
+
total: result.total,
|
|
1093
|
+
chars: result.chars,
|
|
1094
|
+
unconvertible: result.unconvertible.map((c) => c.char)
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
const { found, counts, total } = locate(source, probe);
|
|
1098
|
+
return plain({ action: "scan", target, probe, total, counts, found: found.slice(0, 40) });
|
|
1099
|
+
}
|
|
1100
|
+
const find = String(args.find ?? "");
|
|
1101
|
+
const pairsRaw = String(args.pairs ?? "");
|
|
1102
|
+
const pairs = pairsRaw
|
|
1103
|
+
.split(/\r?\n/)
|
|
1104
|
+
.map((line) => line.trim())
|
|
1105
|
+
.filter((line) => line !== "" && line.includes("=>"))
|
|
1106
|
+
.map((line) => {
|
|
1107
|
+
const at = line.indexOf("=>");
|
|
1108
|
+
const target = line.slice(at + 2).trim();
|
|
1109
|
+
// `原文=>(del)` = 显式删除;`原文=>`(空)= 映射缺失,默认会被拒绝
|
|
1110
|
+
return { find: line.slice(0, at).trim(), replace: target === "(del)" ? null : target };
|
|
1111
|
+
});
|
|
1112
|
+
if (find === "" && pairs.length === 0) throw new Error("action=rewrite 需要 find 或 pairs");
|
|
1113
|
+
const result = rewriteByContext({
|
|
1114
|
+
source,
|
|
1115
|
+
...(pairs.length > 0 ? { pairs } : { find, replace: String(args.replace ?? "") }),
|
|
1116
|
+
scope: args.scope ?? "text"
|
|
1117
|
+
});
|
|
1118
|
+
let out;
|
|
1119
|
+
if (typeof args.out === "string" && args.out !== "") {
|
|
1120
|
+
await mkdir(dirname(args.out), { recursive: true });
|
|
1121
|
+
await writeFile(args.out, result.output, "utf8");
|
|
1122
|
+
out = args.out;
|
|
1123
|
+
}
|
|
1124
|
+
return plain({
|
|
1125
|
+
action: "rewrite",
|
|
1126
|
+
target,
|
|
1127
|
+
...(out === undefined ? {} : { out }),
|
|
1128
|
+
changes: result.changes,
|
|
1129
|
+
refused: result.refused,
|
|
1130
|
+
unmapped: result.unmapped,
|
|
1131
|
+
bytes: Buffer.byteLength(result.output, "utf8")
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
})
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
/* --------------------------- css_edit --------------------------- */
|
|
1141
|
+
// 它回答的是「这些手调数值能不能收敛到一套规范」——和 html_edit(定位+安全替换)
|
|
1142
|
+
// 是不同问题:一个管**位置**,一个管**取值**。存在理由很实在:这一路做真实项目时,
|
|
1143
|
+
// “间距对齐 4px 网格”这件事我手写了三次同样逻辑的脚本。
|
|
1144
|
+
//
|
|
1145
|
+
// 安全默认:**不写盘**。机械改写必须先看改动清单再确认——
|
|
1146
|
+
// `write=true` 才落盘。
|
|
1147
|
+
if (shouldRegister(profile, "css_edit")) {
|
|
1148
|
+
ctx.tools.register(
|
|
1149
|
+
defineTool({
|
|
1150
|
+
name: "css_edit",
|
|
1151
|
+
description: "CSS normalization: snap spacing/fonts/leading/radius. Dry-run default.",
|
|
1152
|
+
parameters: {
|
|
1153
|
+
action: { type: "string", enum: ["scan", "snap", "scale", "leading", "radius"], description: "scan/snap 间距/scale 字号/leading 行高/radius 圆角", required: true },
|
|
1154
|
+
root: { type: "string", description: "CSS / HTML 文件或目录。", required: true },
|
|
1155
|
+
grid: { type: "number", description: "snap: 网格 px(默认 4)。" },
|
|
1156
|
+
scale: { type: "string", description: "scale: 目标阶梯,如 12,14,16,20" },
|
|
1157
|
+
write: { type: "boolean", description: "写回文件(默认只看清单)" }
|
|
1158
|
+
},
|
|
1159
|
+
output: {
|
|
1160
|
+
schema: { type: "object", additionalProperties: true },
|
|
1161
|
+
render: (_args, value) => {
|
|
1162
|
+
if (value.action === "scan") {
|
|
1163
|
+
const off = value.offGrid.slice(0, 8).map((v) => `${v.px}px×${v.count}`).join(" ") || "无";
|
|
1164
|
+
const sizes = value.fontSizes.map((s) => s.px).join(" / ") || "无";
|
|
1165
|
+
// 圆角那行给的是**该不该改**,不是“能改多少”——令牌化项目改散落 px 等于白忙
|
|
1166
|
+
const radius = value.radius;
|
|
1167
|
+
const radiusLine =
|
|
1168
|
+
radius === undefined || radius.total === 0
|
|
1169
|
+
? " 圆角:没有声明"
|
|
1170
|
+
: radius.tokenizedRatio >= 0.5
|
|
1171
|
+
? ` 圆角:已令牌化 ${Math.round(radius.tokenizedRatio * 100)}%(${radius.tokenized}/${radius.total} 用 var())—— 要改就改 :root 的 --radius-* 值,不是改散落 px`
|
|
1172
|
+
: ` 圆角:**未令牌化**(只有 ${radius.tokenized}/${radius.total} 用 var())—— 散落值 ${radius.loosePx.slice(0, 8).map((v) => `${v.px}px×${v.count}`).join(" ") || "无"},可跑 action=radius 收敛`;
|
|
1173
|
+
return [
|
|
1174
|
+
{
|
|
1175
|
+
type: "text",
|
|
1176
|
+
text:
|
|
1177
|
+
`css_edit [scan] ${value.files} 个文件 —— 间距 ${value.spacingTotal} 处,不在 ${value.grid}px 网格的 ${value.offGridCount} 处(${Math.round((value.offGridCount / Math.max(1, value.spacingTotal)) * 100)}%)\n` +
|
|
1178
|
+
` 不在网格:${off}\n 字号 ${value.fontSizes.length} 档:${sizes}\n` +
|
|
1179
|
+
radiusLine
|
|
1180
|
+
}
|
|
1181
|
+
];
|
|
1182
|
+
}
|
|
1183
|
+
const lines = value.perFile
|
|
1184
|
+
.slice(0, 8)
|
|
1185
|
+
.map((f) => ` ${f.file}: ${f.changes} 处(${f.sample.slice(0, 3).join(";")})`)
|
|
1186
|
+
.join("\n");
|
|
1187
|
+
return [
|
|
1188
|
+
{
|
|
1189
|
+
type: "text",
|
|
1190
|
+
text:
|
|
1191
|
+
`css_edit [${value.action}] ${value.files} 个文件,可改 ${value.totalChanges} 处,跳过 ${value.skipped} 处(var/calc/百分比/无法判定)` +
|
|
1192
|
+
(value.written ? " — 已写回" : " — 未写盘(确认后加 write=true)") +
|
|
1193
|
+
(value.totalChanges === 0 ? "" : `\n${lines}`)
|
|
1194
|
+
}
|
|
1195
|
+
];
|
|
1196
|
+
}
|
|
1197
|
+
},
|
|
1198
|
+
isConcurrencySafe: () => true,
|
|
1199
|
+
timeoutMs: 60000,
|
|
1200
|
+
async execute(args, exec) {
|
|
1201
|
+
const input = await resolveArg(String(args.root ?? ""), exec);
|
|
1202
|
+
if (!existsSync(input)) throw new Error(`css_edit 的 root 不存在:${input}`);
|
|
1203
|
+
// 收集文件:单文件直接用,目录只收 .css/.html(含内联 <style>)
|
|
1204
|
+
const filesToScan = [];
|
|
1205
|
+
if (statSync(input).isFile()) filesToScan.push(input);
|
|
1206
|
+
else {
|
|
1207
|
+
const walk = (dir) => {
|
|
1208
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
1209
|
+
if (entry.name.startsWith(".")) continue;
|
|
1210
|
+
const full = join(dir, entry.name);
|
|
1211
|
+
if (entry.isDirectory()) {
|
|
1212
|
+
if (isSkippableDir(entry.name) === false) walk(full);
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
if (/\.(?:css|html?)$/i.test(entry.name)) filesToScan.push(full);
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
walk(input);
|
|
1219
|
+
}
|
|
1220
|
+
if (filesToScan.length === 0) throw new Error(`没有可处理的 CSS/HTML 文件:${input}`);
|
|
1221
|
+
|
|
1222
|
+
if (args.action === "scan") {
|
|
1223
|
+
const merged = { grid: Number.isFinite(args.grid) ? args.grid : 4, spacingTotal: 0, offGridCount: 0, offGrid: [], fontSizes: [] };
|
|
1224
|
+
const sizes = new Map();
|
|
1225
|
+
const offMap = new Map();
|
|
1226
|
+
for (const file of filesToScan) {
|
|
1227
|
+
const info = inspectCss(readFileSync(file, "utf8"), { grid: merged.grid });
|
|
1228
|
+
merged.spacingTotal += info.spacingTotal;
|
|
1229
|
+
merged.offGridCount += info.offGridCount;
|
|
1230
|
+
for (const v of info.offGrid) offMap.set(v.px, (offMap.get(v.px) ?? 0) + v.count);
|
|
1231
|
+
for (const s of info.fontSizes) sizes.set(s.px, (sizes.get(s.px) ?? 0) + s.count);
|
|
1232
|
+
}
|
|
1233
|
+
merged.offGrid = [...offMap.entries()].map(([px, count]) => ({ px, count, onGrid: false })).sort((a, b) => b.count - a.count);
|
|
1234
|
+
merged.fontSizes = [...sizes.entries()].sort((a, b) => a[0] - b[0]).map(([px, count]) => ({ px, count }));
|
|
1235
|
+
|
|
1236
|
+
// 圆角:跨文件汇总(令牌化率要按整个项目算,单文件看不出)
|
|
1237
|
+
const radius = { total: 0, tokenized: 0, preserved: 0, other: 0, values: new Map() };
|
|
1238
|
+
for (const file of filesToScan) {
|
|
1239
|
+
const info = inspectRadius(readFileSync(file, "utf8"));
|
|
1240
|
+
radius.total += info.total;
|
|
1241
|
+
radius.tokenized += info.tokenized;
|
|
1242
|
+
radius.preserved += info.preserved;
|
|
1243
|
+
radius.other += info.other;
|
|
1244
|
+
for (const n of info.px) radius.values.set(n, (radius.values.get(n) ?? 0) + 1);
|
|
1245
|
+
}
|
|
1246
|
+
merged.radius = {
|
|
1247
|
+
total: radius.total,
|
|
1248
|
+
tokenized: radius.tokenized,
|
|
1249
|
+
// 令牌化率:≥0.5 就该去改 --radius-* 值,而不是改散落 px
|
|
1250
|
+
tokenizedRatio: radius.total === 0 ? 0 : Math.round((radius.tokenized / radius.total) * 100) / 100,
|
|
1251
|
+
loosePx: [...radius.values.entries()].sort((a, b) => b[1] - a[1]).map(([px, count]) => ({ px, count }))
|
|
1252
|
+
};
|
|
1253
|
+
return plain({ action: "scan", files: filesToScan.length, ...merged });
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const scale = String(args.scale ?? "").split(/[,\s]+/).map(Number).filter((n) => Number.isFinite(n) && n > 0);
|
|
1257
|
+
if (args.action === "scale" && scale.length < 2) throw new Error("action=scale 需要 scale,至少两档(如 12,14,16,20,28)");
|
|
1258
|
+
const perFile = [];
|
|
1259
|
+
let totalChanges = 0;
|
|
1260
|
+
let skipped = 0;
|
|
1261
|
+
for (const file of filesToScan) {
|
|
1262
|
+
const source = readFileSync(file, "utf8");
|
|
1263
|
+
const result =
|
|
1264
|
+
args.action === "snap"
|
|
1265
|
+
? snapSpacing(source, { grid: args.grid })
|
|
1266
|
+
: args.action === "leading"
|
|
1267
|
+
? normalizeLeading(source)
|
|
1268
|
+
: args.action === "radius"
|
|
1269
|
+
? normalizeRadius(source)
|
|
1270
|
+
: mapFontSizes(source, { scale });
|
|
1271
|
+
totalChanges += result.changes.length;
|
|
1272
|
+
skipped += result.skipped;
|
|
1273
|
+
if (result.changes.length === 0) continue;
|
|
1274
|
+
perFile.push({
|
|
1275
|
+
file: basename(file),
|
|
1276
|
+
changes: result.changes.length,
|
|
1277
|
+
sample: result.changes.slice(0, 4).map((c) => `${c.prop} ${c.from} → ${c.to}`)
|
|
1278
|
+
});
|
|
1279
|
+
if (args.write === true) await writeFile(file, result.output, "utf8");
|
|
1280
|
+
}
|
|
1281
|
+
return plain({ action: args.action, files: filesToScan.length, totalChanges, skipped, written: args.write === true, perFile });
|
|
1282
|
+
}
|
|
1283
|
+
})
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
export { Config, apply, inject, name };
|