From 2f93dcb587148a0a2d56aaf41811cbe6a190a07c Mon Sep 17 00:00:00 2001 From: HIsheR Date: Tue, 27 Oct 2026 01:53:55 +0800 Subject: [PATCH] =?UTF-8?q?iter(g326):=20ua=20search=20=E8=A1=A5=E5=90=8C?= =?UTF-8?q?=E4=B9=89=E8=AF=8D=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ua/backlog.json | 9 ++++ AGENTS.md | 2 +- packages/tools/cli/bin/ua.js | 52 ++++++++++++++++-- packages/tools/cli/lib/synonyms.mjs | 84 +++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 packages/tools/cli/lib/synonyms.mjs diff --git a/.ua/backlog.json b/.ua/backlog.json index d24387d..cda63e7 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -3320,6 +3320,15 @@ "motivation": "avatar 有 src 时直接渲染 img,加载失败(404 / 网络断开 / 权限过期)会露出浏览器默认碎图标,圆形容器里一块灰裂图比没有头像更难看;image 组件已有 error 兜底,avatar 没跟,同类组件口径不一致。", "acceptance": "① avatar img 注册 error 监听:加载失败就地替换为与无 src 时同款的文字头像(名称首字 + role=img + aria-label),尺寸与语义底色不变;② 正常加载路径零变化(不加多余请求);③ manifest 文档 avatar props/步骤同步补一条兜底说明,verify 双向校验过;④ npm run check 通过。", "notes": "" + }, + { + "id": "g326", + "domain": "tooling", + "status": "open", + "title": "ua search 同义词回退:中文口语词不再搜不到组件", + "motivation": "库内 id 全是英文(modal / select / spinner),而人和 Agent 张口就是「走马灯」「悬浮按钮」「分格输入」。这类词在 id / 标题 / 参数名 / 参数说明 / 步骤五层与配方三层里都没有原文,search 只剩 editDistance ≤ 3 的英文近似——对中文完全无效,于是「没有这个组件」的错误结论被反复得出,实际 carousel / floatButton / pinInput 都在库里。", + "acceptance": "① 新增 lib/synonyms.mjs:中文口语词 → 组件 id 的一组映射,一词只许归一组;② ua search 在五层 + 配方三层全空时按表回退(多词取交集,交集空退并集),命中行标「← 同义词「走马灯」」、页脚注明「按同义词表回退」,正常命中路径零变化不掺水;③ MCP search_components 共用同一份 fallbackBySynonym,返回项 matched.field 为「同义词」,CLI 与 MCP 口径不漂移;④ ua verify 校验表内每个 id 真实存在、同一个词不归两组,组件改名后残留即门禁红;⑤ npm run check 全绿。", + "notes": "" } ] } diff --git a/AGENTS.md b/AGENTS.md index eead145..3ee9557 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ 可用工具:`list_components` / `get_component`(name 支持逗号分隔批量一次取多个;传 `format: "md"` 直接拿 Markdown 片段,批量片段以水平线分隔,等同 `ua info --md`)/ `search_components` / `get_tokens`(返回 --ua-* 令牌清单,避免硬编码颜色间距)/ `list_recipes` / `get_recipe`(取跨组件组合配方:骨架代码 + 要点 + 用到的组件 id,配好再逐个 `get_component` 查参数)。 -2. **CLI**:`node packages/tools/cli/bin/ua.js loop [--json](迭代循环诊断:锁心跳 / 上次留痕 / 待办数) | iter done (迭代收尾:置 done + 写留痕,ts 由本机时钟生成) | iter add <条目json>(补自选题) | list [--json|--components] | search <关键词> | info [--json|--md](name 支持逗号分隔一次查多个;--md 出可粘贴的 Markdown 片段:标题 + 参数表 + 用法 + 示例,批量片段以水平线分隔;--json 单值为对象、多值为数组) | recipes [编号] [--json] | index | docs | style | verify | changelog`(`--json` 给脚本与 Agent 用,不必解析文本);`search` 覆盖 id / 标题 / 参数名 / 参数说明 / 步骤五层 + 配方三层(标题 / 配方组件 / 要点,命中行直接给 `ua recipes <编号>` 指路),命中非 id 标题时会标出命中字段(如「← 参数说明含『处理中』」),`--json` 输出 `{ components, recipes }` 两数组、各项带 `matched` 字段 +2. **CLI**:`node packages/tools/cli/bin/ua.js loop [--json](迭代循环诊断:锁心跳 / 上次留痕 / 待办数) | iter done (迭代收尾:置 done + 写留痕,ts 由本机时钟生成) | iter add <条目json>(补自选题) | list [--json|--components] | search <关键词> | info [--json|--md](name 支持逗号分隔一次查多个;--md 出可粘贴的 Markdown 片段:标题 + 参数表 + 用法 + 示例,批量片段以水平线分隔;--json 单值为对象、多值为数组) | recipes [编号] [--json] | index | docs | style | verify | changelog`(`--json` 给脚本与 Agent 用,不必解析文本);`search` 覆盖 id / 标题 / 参数名 / 参数说明 / 步骤五层 + 配方三层(标题 / 配方组件 / 要点,命中行直接给 `ua recipes <编号>` 指路),命中非 id 标题时会标出命中字段(如「← 参数说明含『处理中』」),五层与配方三层全空时按同义词表(`lib/synonyms.mjs`,中文口语词 → 组件 id)回退一次并标「← 同义词『走马灯』」,`--json` 输出 `{ components, recipes }` 两数组、各项带 `matched` 字段 3. **静态索引**:`.ua/index.json`(机器可读索引)、`llms.txt`(速览)或 **`llms-full.txt`(全量 Agent 文档:逐组件参数 / 回调 / 手把手步骤 / 令牌 / 组合配方,无图形纯文本,喂给 Agent 首选)**。 diff --git a/packages/tools/cli/bin/ua.js b/packages/tools/cli/bin/ua.js index c4908d2..845372c 100644 --- a/packages/tools/cli/bin/ua.js +++ b/packages/tools/cli/bin/ua.js @@ -9,6 +9,7 @@ import { auditContrast, formatReport, PAIRS } from '../lib/contrast-audit.mjs'; import { shellPalette } from '../lib/shell-palette.mjs'; import { auditMotion, formatMotionReport } from '../lib/motion-audit.mjs'; import { auditSurface, formatSurfaceReport, LADDER } from '../lib/surface-audit.mjs'; +import { SYNONYMS, synonymIds } from '../lib/synonyms.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -258,6 +259,20 @@ function matchOf(kw, comp) { return best; } +/*** 同义回退:整组关键词在 id / 标题 / 参数 / 步骤与配方里都落空时,按同义词表换算出候选组件 + * CLI search 与 MCP search_components 共用这一份,避免两处口径漂移;返回空数组表示「回退也无解」 ***/ +function fallbackBySynonym(kws) { + const sets = kws.map((kw) => synonymIds(kw)); + if (sets.some((s) => !s)) return []; // 有词未被同义词表覆盖:整组回退无从谈起 + const all = collectComponents(); + let cands = all.filter((x) => sets.every((s) => s.has(x.comp.id))); // 多词取交集,与搜索的 AND 语义保持一致 + if (!cands.length) { // 交集为空(两个词各指不同组件)退化为并集:说了两个诉求就把相关项都给到 + const union = new Set(sets.flatMap((s) => [...s])); + cands = all.filter((x) => union.has(x.comp.id)); + } + return cands; +} + /*** 按关键词搜组件:多词取最弱命中(AND 语义),并标出该词命中的字段,便于判断为何被召回 ***/ /*** 单个配方对多关键词的命中:标题 3 / 组件 2 / 要点 1,返回最弱档(与组件搜索同一排序口径),未命中返回 null ***/ function recipeMatch(kws, r) { @@ -292,6 +307,10 @@ function cmdSearch(words = [], args = []) { .map((r) => ({ r, m: recipeMatch(kws, r) })) .filter((x) => x.m) .sort((a, b) => b.m.weight - a.m.weight || Number(a.r.id) - Number(b.r.id)); + // 常规两域全空才动用同义词:命中正常时不掺水,免得「搜 select 冒出一堆 drawer」 + if (!hits.length && !recipeHits.length) { + for (const x of fallbackBySynonym(kws)) hits.push({ pkg: x.pkg, comp: x.comp, score: 1, field: '同义词', keyword: kws.join(' ') }); + } if (!hits.length && !recipeHits.length) { const near = nearIds(kws.join('')); console.error(`no match: ${words.join(' ')}${near.length ? `(相近:${near.join(' / ')})` : ''}`); @@ -310,7 +329,7 @@ function cmdSearch(words = [], args = []) { ); } for (const h of hits) { - const why = h.field === 'id' || h.field === '标题' ? '' : ` ← ${h.field}含「${h.keyword}」`; + const why = h.field === '同义词' ? ` ← 同义词「${h.keyword}」` : h.field === 'id' || h.field === '标题' ? '' : ` ← ${h.field}含「${h.keyword}」`; console.log(`${String(h.comp.id).padEnd(16)} ${h.comp.title ?? ''} (${h.pkg.name})${why}`); } for (const { r, m } of recipeHits) { @@ -320,7 +339,8 @@ function cmdSearch(words = [], args = []) { const parts = []; if (hits.length) parts.push(`${hits.length} 个组件`); if (recipeHits.length) parts.push(`${recipeHits.length} 条配方`); - console.log(`\n共 ${parts.join('、')}命中;查参数:ua info <组件id>,看骨架:ua recipes <编号>`); + const viaSyn = hits.length && hits.every((h) => h.field === '同义词'); // 全靠同义回退才算「口语命中」 + console.log(`\n共 ${parts.join('、')}命中${viaSyn ? '(常规字段无命中,按同义词表回退)' : ''};查参数:ua info <组件id>,看骨架:ua recipes <编号>`); } /*** 输出包或组件信息:默认人读摘要,--json 输出原始 JSON;name 支持逗号分隔一次查多个(单值保持原输出结构不破坏脚本) ***/ @@ -910,7 +930,12 @@ function cmdMcp() { .map((r) => ({ r, m: recipeMatch([q], r) })) .filter((x) => x.m) .map(({ r, m }) => ({ id: r.id, title: r.title, components: recipeComps(r), matched: { field: m.label, keyword: m.keyword } })); - if (!pkgs.length && !comps.length && !recs.length) throw new Error(`no match: ${args.query}`); + if (!pkgs.length && !comps.length && !recs.length) { + // 三域全空时与 CLI 同源回退:Agent 常拿到中文口语词(「弹窗」),不兜底就只能反复试错 + const syn = fallbackBySynonym([q]).map(({ pkg, comp }) => ({ package: pkg.name, id: comp.id, title: comp.title, matched: { field: '同义词', keyword: q } })); + if (!syn.length) throw new Error(`no match: ${args.query}`); + return { packages: pkgs, components: syn, recipes: recs }; + } return { packages: pkgs, components: comps, recipes: recs }; } if (name === 'get_tokens') { @@ -1605,6 +1630,24 @@ function checkVisualChecklist() { } /*** 清单一致性校验:组件文档块的 id / props 说明 / demo 锚点必须与代码和演示产物对齐 ***/ +/*** 校验同义词表:目标 id 必须真实存在(改名后残留会让回退指到空气),同一个词不得归两组(歧义会让结果随表序漂移) ***/ +function checkSynonyms(ids) { + const issues = []; + const seen = new Map(); // 词 → 已登记的组件 id(用于报重复) + for (const group of SYNONYMS) { + const tag = `同义词「${group.words[0]}」`; + if (!(group.words ?? []).length) issues.push('同义词表有一组缺少 words'); + if (!(group.ids ?? []).length) issues.push(`${tag}: 缺少 ids`); + for (const id of group.ids ?? []) if (!ids.has(id)) issues.push(`${tag}: 目标组件 ${id} 不在清单里(改名后忘改同义词表)`); + for (const w of group.words ?? []) { + if (seen.has(w)) issues.push(`${tag}: 词「${w}」已归入同义词「${seen.get(w)}」(一词只许一组,否则回退结果随表序变化)`); + seen.set(w, group.words[0]); + } + } + return issues; +} + +/*** 校验清单 ↔ 文档 ↔ 演示一致,任一项不符即退出码 1(口径见 AGENTS.md 「门禁一条龙」) ***/ function cmdVerify() { const issues = []; const pkgs = collectManifests(); // 一次收集,供组件校验与 README 区块校验共用 @@ -1667,6 +1710,7 @@ function cmdVerify() { .flatMap((p) => p.docs?.components ?? []).length; issues.push(...checkCountDocs(skinTotal)); issues.push(...checkReadme(pkgs)); + issues.push(...checkSynonyms(new Set(pkgs.flatMap((p) => (p.docs?.components ?? []).map((c) => c.id))))); // 同义词表指向的组件必须真实存在 checkDarkTokens(issues); checkTokenParity(issues); checkLlmsFull(pkgs, issues); @@ -1678,7 +1722,7 @@ function cmdVerify() { console.error(`${issues.length} violation(s)`); process.exit(1); } - console.log('verify OK (manifest ↔ docs ↔ demo 一致,文档 props ↔ 入参接口双向一致,皮肤无硬编码颜色与未定义令牌、暗色令牌齐全、tokens.json ↔ tokens.css 名字与值双源一致、@keyframes 无复制粘贴的重复帧、滚动容器全在细条规则内,README 组件清单与分组同步,backlog ↔ 留痕一致(编号严格递增、无重复 id、留痕时间线单调且带 +08:00),组合配方结构完整,MCP 工具清单三处一致,视觉回归抽查清单十八组件 × 五维度齐全)'); + console.log('verify OK (manifest ↔ docs ↔ demo 一致,文档 props ↔ 入参接口双向一致,皮肤无硬编码颜色与未定义令牌、暗色令牌齐全、tokens.json ↔ tokens.css 名字与值双源一致、@keyframes 无复制粘贴的重复帧、滚动容器全在细条规则内,README 组件清单与分组同步,backlog ↔ 留痕一致(编号严格递增、无重复 id、留痕时间线单调且带 +08:00),组合配方结构完整,MCP 工具清单三处一致,搜索同义词表目标存在且无歧义,视觉回归抽查清单十八组件 × 五维度齐全)'); } /*** 校验 git 提交信息是否符合 COMMITTING.md 规范(参数为 message 文件路径或直接 message 文本),违规退出码 1 ***/ diff --git a/packages/tools/cli/lib/synonyms.mjs b/packages/tools/cli/lib/synonyms.mjs new file mode 100644 index 0000000..22678e1 --- /dev/null +++ b/packages/tools/cli/lib/synonyms.mjs @@ -0,0 +1,84 @@ +/*** 中文口语词 → 组件 id 同义词表:人(与 Agent)习惯说「弹窗」「下拉」「转圈」,而库内 id 是英文(modal / select / spinner)。 + * search 全部落空时按本表回退一次,避免「库里有组件却搜不到」。ids 必须真实存在于清单(ua verify 校验), + * 同一个词只允许归一组(歧义会让回退结果随表序漂移)。新增只要追加一行即可,无需改命令实现。 ***/ +export const SYNONYMS = [ + { words: ['弹窗', '对话框', '模态框', '模态弹窗'], ids: ['modal', 'popconfirm', 'drawer'] }, + { words: ['抽屉', '侧滑', '侧边面板'], ids: ['drawer'] }, + { words: ['下拉', '下拉框', '选择器', '选择框'], ids: ['select', 'multiSelect', 'cascader', 'treeSelect', 'autoComplete'] }, + { words: ['下拉菜单', '菜单', '导航菜单'], ids: ['menu', 'select'] }, + { words: ['日期', '日期选择', '日历'], ids: ['datePicker', 'calendar'] }, + { words: ['时间', '时刻', '时钟'], ids: ['timePicker', 'relativeTime'] }, + { words: ['开关', '切换开关', '布尔开关'], ids: ['switchButton', 'segmented'] }, + { words: ['单选', '单选框', '单选组'], ids: ['radioGroup', 'select'] }, + { words: ['多选', '复选框', '勾选'], ids: ['checkbox', 'checkboxGroup', 'multiSelect'] }, + { words: ['输入框', '文本框', '输入'], ids: ['textField', 'textarea', 'inputNumber', 'inputTag'] }, + { words: ['表格', '数据表', '表格排序'], ids: ['dataTable'] }, + { words: ['分页', '翻页', '页码'], ids: ['pagination'] }, + { words: ['标签', '小标签', '标记'], ids: ['tag'] }, + { words: ['徽标', '角标', '红点'], ids: ['badge', 'avatarGroup'] }, + { words: ['头像', '用户头像'], ids: ['avatar', 'avatarGroup'] }, + { words: ['气泡', '浮层', '提示浮层'], ids: ['tooltip', 'popover', 'popconfirm'] }, + { words: ['通知', '消息提示', '提示条'], ids: ['toast', 'notification', 'noticeBar', 'alert'] }, + { words: ['加载', '载入', '转圈', '等待'], ids: ['spinner', 'loading', 'progressRing', 'skeleton'] }, + { words: ['骨架屏', '骨架', '占位块'], ids: ['skeleton', 'empty'] }, + { words: ['空态', '空状态', '无数据'], ids: ['empty', 'result'] }, + { words: ['进度', '进度条', '完成度'], ids: ['progress', 'progressRing'] }, + { words: ['步骤', '步骤条', '流程条'], ids: ['stepper', 'timeline'] }, + { words: ['折叠', '手风琴', '折叠面板'], ids: ['accordion', 'tree'] }, + { words: ['树', '树形', '层级树'], ids: ['tree', 'treeSelect', 'cascader'] }, + { words: ['上传', '文件上传', '附件'], ids: ['upload'] }, + { words: ['轮播', '走马灯', '幻灯片'], ids: ['carousel'] }, + { words: ['穿梭', '穿梭框', '转移'], ids: ['transfer'] }, + { words: ['锚点', '目录导航', '页内导航'], ids: ['anchor', 'affix', 'backTop'] }, + { words: ['返回顶部', '回顶'], ids: ['backTop', 'affix'] }, + { words: ['面包屑', '路径导航'], ids: ['breadcrumb'] }, + { words: ['时间线', '时间轴', '事件流'], ids: ['timeline'] }, + { words: ['评分', '星级', '打分'], ids: ['rate'] }, + { words: ['滑块', '滑动条', '拖拽条'], ids: ['slider', 'splitter'] }, + { words: ['分割', '分栏', '左右分栏'], ids: ['splitter', 'grid', 'layout'] }, + { words: ['统计', '数值统计', '指标'], ids: ['statistic', 'inputNumber'] }, + { words: ['倒计时', '剩余时间'], ids: ['countdown', 'relativeTime'] }, + { words: ['复制', '复制按钮', '一键复制'], ids: ['copyButton'] }, + { words: ['快捷键', '按键', '键盘键'], ids: ['kbd'] }, + { words: ['高亮', '关键词高亮', '标红'], ids: ['highlight'] }, + { words: ['搜索', '命令面板', '快捷搜索'], ids: ['commandPalette', 'autoComplete', 'mentions'] }, + { words: ['代码', '代码块', '源码'], ids: ['codeBlock', 'diffView', 'jsonViewer'] }, + { words: ['差异', '对比', '差异对比'], ids: ['diffView'] }, + { words: ['签名', '手写', '签字'], ids: ['signaturePad'] }, + { words: ['热力图', '日历热图', '活跃图'], ids: ['heatmap'] }, + { words: ['折线', '迷你图', '走势'], ids: ['sparkline'] }, + { words: ['水印'], ids: ['watermark'] }, + { words: ['引导', '新手引导', '导览'], ids: ['tour', 'popover'] }, + { words: ['拖拽排序', '排序列表', '拖动'], ids: ['sortableList'] }, + { words: ['瀑布流', '不规则网格', '卡片墙'], ids: ['masonry', 'grid'] }, + { words: ['虚拟列表', '长列表', '海量列表'], ids: ['virtualList', 'list'] }, + { words: ['图片', '图片预览', '大图'], ids: ['image', 'avatar'] }, + { words: ['验证码', '密码框', '分格输入'], ids: ['pinInput'] }, + { words: ['标签输入', '多值输入', '输入标签'], ids: ['inputTag', 'mentions'] }, + { words: ['描述列表', '详情列表', '键值对'], ids: ['descriptions'] }, + { words: ['结果页', '成功页', '失败页'], ids: ['result'] }, + { words: ['卡片', '面板'], ids: ['card'] }, + { words: ['按钮', '操作按钮'], ids: ['button', 'floatButton'] }, + { words: ['悬浮按钮', '浮动按钮', '快速操作'], ids: ['floatButton'] }, + { words: ['排版', '标题', '文本'], ids: ['typography'] }, + { words: ['链接', '超链接', '跳转'], ids: ['link'] }, + { words: ['分段控制器', '分段', '分段控制'], ids: ['segmented', 'tabs'] }, + { words: ['页签', '标签页', '选项卡'], ids: ['tabs'] }, + { words: ['取色', '颜色选择', '调色'], ids: ['colorPicker'] }, + { words: ['布局', '页面框架', '骨架布局'], ids: ['layout', 'grid'] }, + { words: ['间距', '空隙', '间隔'], ids: ['space', 'divider'] }, + { words: ['分割线', '分隔线'], ids: ['divider'] }, + { words: ['二次确认', '确认气泡'], ids: ['popconfirm'] }, + { words: ['自动补全', '联想', '输入联想'], ids: ['autoComplete'] }, + { words: ['树选择', '树形选择'], ids: ['treeSelect'] }, + { words: ['级联', '级联选择', '省市'], ids: ['cascader'] }, + { words: ['筛选', '过滤'], ids: ['dataTable'] }, + { words: ['表单', '表单校验'], ids: ['form', 'textField', 'checkboxGroup'] }, +]; + +/*** 查一个词的同义组件 id 集合;未收录返回 null——与「收录但命中为空」区分,调用方据此判断能否整组回退 ***/ +export function synonymIds(word) { + const w = String(word ?? '').toLowerCase().trim(); + const hit = SYNONYMS.find((g) => g.words.includes(w)); + return hit ? new Set(hit.ids) : null; +}