From 59f84120256fd9e21816e35e70781304507b3fd7 Mon Sep 17 00:00:00 2001 From: Rayn <395924181@qq.com> Date: Sat, 12 Sep 2026 20:42:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(slash-command):=20=E6=96=9C=E6=9D=A0?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E5=8C=B9=E9=85=8D=E6=94=B9=E4=B8=BA=E6=89=93?= =?UTF-8?q?=E5=88=86=E5=BC=8F=E6=A8=A1=E7=B3=8A=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增打分式模糊匹配工具,按「名字全等 > 前缀 > 词边界 > 子串 > 缩写子序列 > 描述命中」分级评分 - 名字命中权重恒定高于描述命中,避免描述里碰巧含关键词的 skill 抢占排序 - 斜杠命令菜单过滤由连续子串匹配改为按相关度打分排序,缩写(如 cmp 命中 compact)也能提前匹配 - 空查询时保持命令原始顺序,键盘导航与既有交互不变 - 补充核心契约测试,覆盖前缀优先、缩写命中、描述不抢名字、乱序与无关词不匹配 --- .../slash-command/SlashCommandMenu.tsx | 13 +- src/utils/fuzzyMatch.test.ts | 94 ++++++++++++++ src/utils/fuzzyMatch.ts | 122 ++++++++++++++++++ 3 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 src/utils/fuzzyMatch.test.ts create mode 100644 src/utils/fuzzyMatch.ts diff --git a/src/features/slash-command/SlashCommandMenu.tsx b/src/features/slash-command/SlashCommandMenu.tsx index 640f7a58..1e57aad2 100644 --- a/src/features/slash-command/SlashCommandMenu.tsx +++ b/src/features/slash-command/SlashCommandMenu.tsx @@ -6,6 +6,7 @@ import { useState, useEffect, useLayoutEffect, useRef, useImperativeHandle, forwardRef, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { getCommands, type Command } from '../../api/command' +import { fuzzyScore } from '../../utils/fuzzyMatch' import { apiErrorHandler } from '../../utils' import { scrollItemIntoView } from '../../utils/scrollUtils' @@ -49,10 +50,14 @@ export const SlashCommandMenu = forwardRef { if (!isOpen) return [] - const lowerQuery = query.toLowerCase() - return commands.filter( - cmd => cmd.name.toLowerCase().includes(lowerQuery) || cmd.description?.toLowerCase().includes(lowerQuery), - ) + // 打分式模糊匹配:名字命中(全等>前缀>词边界>子串>缩写)永远压过描述命中, + // 按相关度排序,避免「描述里碰巧有这个词的 skill」把正主挤到后面 + if (!query.trim()) return commands + return commands + .map(cmd => ({ cmd, score: fuzzyScore(query, cmd.name, cmd.description) })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .map(({ cmd }) => cmd) }, [commands, isOpen, query]) const commandColumnWidth = useMemo(() => { const maxCommandLength = commands.reduce((max, cmd) => Math.max(max, cmd.name.length + 1), 0) diff --git a/src/utils/fuzzyMatch.test.ts b/src/utils/fuzzyMatch.test.ts new file mode 100644 index 00000000..93f8db71 --- /dev/null +++ b/src/utils/fuzzyMatch.test.ts @@ -0,0 +1,94 @@ +// ============================================ +// fuzzyMatch 核心契约测试 +// ============================================ + +import { describe, expect, it } from 'vitest' +import { fuzzyScore } from './fuzzyMatch' + +describe('fuzzyScore 分级语义', () => { + it('名字全等得分最高', () => { + const exact = fuzzyScore('compact', 'compact', 'some description containing compact') + const other = fuzzyScore('compact', 'other-cmd', 'some description containing compact') + expect(exact).toBeGreaterThan(other) + }) + + it('名字前缀命中压过描述命中 —— /goa 必须让 goal 排第一', () => { + const goal = fuzzyScore('goa', 'goal', 'Set, show goals') + const reviewWork = fuzzyScore('goa', 'review-work', 'check the goal of post-implementation') + expect(goal).toBeGreaterThan(reviewWork) + expect(reviewWork).toBeGreaterThan(0) // 描述命中仍然可见,但排后面 + }) + + it('名字子串命中压过描述命中 —— /comp 必须让 compact 压过一堆 skill', () => { + const compact = fuzzyScore('comp', 'compact', '通过总结对话历史压缩上下文') + const skillWithCompInDesc = fuzzyScore('comp', 'agent-lane-orchestrator', 'compare components and compose reports') + expect(compact).toBeGreaterThan(skillWithCompInDesc) + }) + + it('词边界命中:stats 能搜到 session-stats', () => { + const score = fuzzyScore('stats', 'session-stats') + expect(score).toBeGreaterThan(0) + const substringOnly = fuzzyScore('stats', 'statistician') + expect(score).toBeGreaterThan(substringOnly) + }) + + it('缩写(子序列)命中:cmp 能搜到 compact —— 打不完整也能找到', () => { + const score = fuzzyScore('cmp', 'compact') + expect(score).toBeGreaterThan(0) + // 更紧凑的匹配得分更高:compact 比 c-o-m-p 分散的词得分高 + const tighter = fuzzyScore('cmp', 'cmp') + expect(tighter).toBeGreaterThan(score) + }) + + it('子序列必须保持字符顺序,乱序不匹配', () => { + expect(fuzzyScore('pmc', 'compact')).toBe(0) + }) + + it('完全无关的内容不匹配', () => { + expect(fuzzyScore('xyz', 'compact')).toBe(0) + expect(fuzzyScore('xyz', 'compact', '关于对话历史的总结')).toBe(0) + }) + + it('空 query 返回 0 分(调用方应视为「全部可见」)', () => { + expect(fuzzyScore('', 'compact')).toBe(0) + expect(fuzzyScore(' ', 'compact')).toBe(0) + }) + + it('描述子序列兜底需要 query 至少 2 个字符', () => { + expect(fuzzyScore('c', 'review-work', 'check components')).toBe(0) + expect(fuzzyScore('co', 'review-work', 'check components')).toBeGreaterThan(0) + }) +}) + +describe('fuzzyScore 排序契约(模拟真实命令面板)', () => { + interface Cmd { + name: string + description?: string + } + + function rank(query: string, cmds: Cmd[]): string[] { + return cmds + .map(c => ({ name: c.name, score: fuzzyScore(query, c.name, c.description) })) + .filter(x => x.score > 0) + .sort((a, b) => b.score - a.score) + .map(x => x.name) + } + + it('场景还原:输入 comp 时 compact 排第一,描述里碰巧有 comp 的 skill 靠后', () => { + const ranked = rank('comp', [ + { name: 'agent-lane-orchestrator', description: 'compare components and reports' }, + { name: 'compact', description: '通过总结对话历史压缩上下文' }, + { name: 'review-work', description: 'post implementation review' }, + ]) + expect(ranked[0]).toBe('compact') + expect(ranked).toContain('agent-lane-orchestrator') + }) + + it('场景还原:输入 goa 时 goal 排在 review-work 前面', () => { + const ranked = rank('goa', [ + { name: 'review-work', description: 'Post-implementation review of the goal' }, + { name: 'goal', description: 'Set, show goals' }, + ]) + expect(ranked[0]).toBe('goal') + }) +}) diff --git a/src/utils/fuzzyMatch.ts b/src/utils/fuzzyMatch.ts new file mode 100644 index 00000000..db5824ff --- /dev/null +++ b/src/utils/fuzzyMatch.ts @@ -0,0 +1,122 @@ +// ============================================ +// fuzzyMatch - 打分式模糊匹配器 +// ============================================ +// 用于命令面板这类「少量条目、需要相关度排序」的场景。 +// 设计参考 VS Code Quick Open / fzf 的分级打分语义: +// 名字命中永远压过描述命中,同级内按打分排序。 + +/** + * 匹配等级(数值越大越相关)。 + * 注意:等级之间的差距设计为远大于同级内的细项加分, + * 保证「名字子串命中」永远排在「描述命中」前面。 + */ +const SCORE_EXACT = 10_000 // 名字全等 +const SCORE_PREFIX = 8_000 // 名字前缀命中 +const SCORE_WORD_BOUNDARY = 6_000 // 名字词边界命中(如 session-stats 中的 "stats") +const SCORE_SUBSTRING = 4_000 // 名字普通子串命中 +const SCORE_SUBSEQUENCE = 2_000 // 名字缩写命中(按序命中所有字符,如 cmp → compact) +const SCORE_DESCRIPTION = 100 // 仅描述命中(名字完全不沾边) + +// 同级内的细项加分,累计上限不会跨级 +const BONUS_PREFIX_START = 300 // query 从名字第一个字符开始(天然前缀) +const BONUS_SHORTER_NAME = 200 // 名字越短越精确(每短一个字符 +2,封顶 200) +const BONUS_CONSECUTIVE = 15 // 子序列匹配时,连续命中的字符越多越相关 +const BONUS_WORD_START = 50 // query 命中在词边界上(- _ . / 空格 后的首字符) +const PENALTY_SUBSEQUENCE_GAP = -5 // 缩写匹配时字符间隔越大越扣分 + +/** 词边界字符:这些字符后的字母视为「词首」 */ +const WORD_SEPARATORS = new Set(['-', '_', '.', '/', ' ', '@', ':']) + +/** + * 计算子序列匹配的得分:query 的每个字符按顺序出现在 target 中。 + * 返回 null 表示不是子序列匹配。 + * + * 统计连续命中数和间隔惩罚,让 "cmp" 对 "compact" 的得分 + * 高于对 "ca...m...p" 这类松散匹配。 + */ +function scoreSubsequence(query: string, target: string): { score: number } | null { + let qi = 0 + let consecutive = 0 + let maxConsecutive = 0 + let gaps = 0 + let prevMatchIndex = -2 + + for (let ti = 0; ti < target.length && qi < query.length; ti++) { + if (target[ti] === query[qi]) { + if (prevMatchIndex === ti - 1) { + consecutive++ + } else { + gaps += prevMatchIndex >= 0 ? ti - prevMatchIndex - 1 : 0 + consecutive = 1 + } + maxConsecutive = Math.max(maxConsecutive, consecutive) + prevMatchIndex = ti + qi++ + } + } + + // 有字符没按序命中 → 匹配失败 + if (qi < query.length) return null + + return { + score: BONUS_CONSECUTIVE * maxConsecutive + PENALTY_SUBSEQUENCE_GAP * gaps, + } +} + +/** + * 纯函数:query 与一个名字/描述对的相关度打分。 + * 返回总分(0 = 无匹配),分数只用于同一次查询内的排序,无绝对含义。 + */ +export function fuzzyScore(query: string, name: string, description?: string): number { + const q = query.toLowerCase().trim() + if (!q) return 0 + + const n = name.toLowerCase() + + // —— 名字匹配(高等级)—— + let nameScore = 0 + + if (n === q) { + nameScore = SCORE_EXACT + } else if (n.startsWith(q)) { + nameScore = SCORE_PREFIX + BONUS_PREFIX_START + // 名字越短,前缀命中越精确 + nameScore += Math.max(0, BONUS_SHORTER_NAME - (n.length - q.length) * 2) + } else { + // 词边界命中:query 出现在某个分隔符之后(如 "stats" → "session-stats") + let wordBoundaryOffset = -1 + for (let i = 1; i <= n.length - q.length; i++) { + if (n.startsWith(q, i) && WORD_SEPARATORS.has(n[i - 1])) { + wordBoundaryOffset = i + break + } + } + + if (wordBoundaryOffset >= 0) { + nameScore = SCORE_WORD_BOUNDARY + BONUS_WORD_START + } else if (n.includes(q)) { + // 普通子串命中:位置越靠前越相关 + nameScore = SCORE_SUBSTRING + Math.max(0, 60 - n.indexOf(q) * 3) + } else { + const sub = scoreSubsequence(q, n) + if (sub) { + nameScore = SCORE_SUBSEQUENCE + sub.score + } + } + } + + if (nameScore > 0) return nameScore + + // —— 描述匹配(最低等级,永远压不过名字命中)—— + // 单字符 query 不走描述匹配:会在所有含该字母的描述里到处命中,噪音太大 + if (description && q.length >= 2) { + const d = description.toLowerCase() + if (d.includes(q)) return SCORE_DESCRIPTION + // 描述子序列兜底,分数略高于纯 includes 以区分强度 + if (scoreSubsequence(q, d)) { + return SCORE_DESCRIPTION + Math.min(50, q.length * 5) + } + } + + return 0 +}