feat: support pinyin search in @mention suggestions (#2572)

* feat: support pinyin search in @mention suggestions

Add pinyin matching for Chinese names in the mention suggestion popup.
Users can now search by:
- Full pinyin: 'liyunlong' matches '李云龙'
- Initial letters: 'lyl' matches '李云龙'
- Partial/hybrid: 'liyu' or 'liyunl' matches '李云龙'

Implementation:
- New pinyin-match.ts utility using pinyin-pro library
- Integrated into member, agent, and squad filters in mention-suggestion.tsx
- 21 tests passing (9 unit + 12 integration)

Co-authored-by: multica-agent <github@multica.ai>

* fix: normalize ü→v in pinyin matching for names like 吕布

Enable pinyin-pro's v:true option so 吕→lv instead of lü.
Add test case for 吕布/lvbu matching.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
LinYushen
2026-05-14 12:44:43 +08:00
committed by GitHub
parent f6ac53a967
commit c628958fdd
6 changed files with 171 additions and 3 deletions

View File

@@ -285,4 +285,55 @@ describe("createMentionSuggestion", () => {
const items = result as MentionItem[];
expect(items.filter((i) => i.type === "squad")).toHaveLength(0);
});
it("matches Chinese names by full pinyin", () => {
const qc = fakeQc({
members: [
{ user_id: "u1", name: "Alice", role: "member" },
{ user_id: "u2", name: "李云龙", role: "member" },
],
});
searchIssuesMock.mockReturnValue(new Promise(() => {}));
const config = createMentionSuggestion(qc);
const result = config.items!({ query: "liyunlong", editor: {} as never });
const items = result as MentionItem[];
expect(items.some((i) => i.type === "member" && i.label === "李云龙")).toBe(true);
expect(items.some((i) => i.type === "member" && i.label === "Alice")).toBe(false);
});
it("matches Chinese names by pinyin initials", () => {
const qc = fakeQc({
members: [
{ user_id: "u1", name: "Alice", role: "member" },
{ user_id: "u2", name: "李云龙", role: "member" },
{ user_id: "u3", name: "张大彪", role: "member" },
],
});
searchIssuesMock.mockReturnValue(new Promise(() => {}));
const config = createMentionSuggestion(qc);
const result = config.items!({ query: "lyl", editor: {} as never });
const items = result as MentionItem[];
expect(items.some((i) => i.type === "member" && i.label === "李云龙")).toBe(true);
expect(items.some((i) => i.type === "member" && i.label === "张大彪")).toBe(false);
});
it("matches Chinese agent names by pinyin", () => {
const qc = fakeQc({
members: [{ user_id: "u1", name: "Alice", role: "member" }],
agents: [
{ id: "a1", name: "魏和尚", archived_at: null, visibility: "workspace", owner_id: null },
],
});
searchIssuesMock.mockReturnValue(new Promise(() => {}));
const config = createMentionSuggestion(qc);
const result = config.items!({ query: "whs", editor: {} as never });
const items = result as MentionItem[];
expect(items.some((i) => i.type === "agent" && i.label === "魏和尚")).toBe(true);
});
});

View File

@@ -37,6 +37,7 @@ import {
recordMentionUsage,
sortUserItemsByRecency,
} from "./mention-recency";
import { matchesPinyin } from "./pinyin-match";
// ---------------------------------------------------------------------------
// Types
@@ -407,7 +408,7 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
: [];
const memberItems: MentionItem[] = members
.filter((m) => m.name.toLowerCase().includes(q))
.filter((m) => m.name.toLowerCase().includes(q) || matchesPinyin(m.name, q))
.map((m) => ({
id: m.user_id,
label: m.name,
@@ -418,13 +419,13 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
.filter(
(a) =>
!a.archived_at &&
a.name.toLowerCase().includes(q) &&
(a.name.toLowerCase().includes(q) || matchesPinyin(a.name, q)) &&
canAssignAgentToIssue(a, { userId, role: myRole }).allowed,
)
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
const squadItems: MentionItem[] = squads
.filter((s) => !s.archived_at && s.name.toLowerCase().includes(q))
.filter((s) => !s.archived_at && (s.name.toLowerCase().includes(q) || matchesPinyin(s.name, q)))
.map((s) => ({ id: s.id, label: s.name, type: "squad" as const }));
// Members and agents share a single ranked list — recently mentioned

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { matchesPinyin } from "./pinyin-match";
describe("matchesPinyin", () => {
it("matches full pinyin", () => {
expect(matchesPinyin("李云龙", "liyunlong")).toBe(true);
});
it("matches pinyin initials", () => {
expect(matchesPinyin("李云龙", "lyl")).toBe(true);
});
it("matches partial pinyin prefix", () => {
expect(matchesPinyin("李云龙", "liyu")).toBe(true);
});
it("matches hybrid pinyin (full + initials)", () => {
expect(matchesPinyin("李云龙", "liyunl")).toBe(true);
});
it("does not match unrelated query", () => {
expect(matchesPinyin("李云龙", "zhangsan")).toBe(false);
});
it("returns false for non-Chinese names", () => {
expect(matchesPinyin("Alice", "ali")).toBe(false);
});
it("returns true for empty query", () => {
expect(matchesPinyin("李云龙", "")).toBe(true);
});
it("matches single character pinyin", () => {
expect(matchesPinyin("张大彪", "z")).toBe(true);
expect(matchesPinyin("张大彪", "zdb")).toBe(true);
expect(matchesPinyin("张大彪", "zhangdabiao")).toBe(true);
});
it("matches mixed Chinese/English names", () => {
expect(matchesPinyin("魏和尚", "whs")).toBe(true);
expect(matchesPinyin("魏和尚", "weiheshang")).toBe(true);
});
it("normalizes ü to v for names like 吕布", () => {
expect(matchesPinyin("吕布", "lvbu")).toBe(true);
expect(matchesPinyin("吕布", "lb")).toBe(true);
expect(matchesPinyin("吕布", "lv")).toBe(true);
});
});

View File

@@ -0,0 +1,58 @@
import { pinyin } from "pinyin-pro";
/**
* Check if a query matches a name via pinyin.
* Supports:
* - Full pinyin match: "liyunlong" matches "李云龙"
* - Initial letter abbreviation: "lyl" matches "李云龙"
* - Partial prefix match: "liyu" matches "李云龙"
*/
export function matchesPinyin(name: string, query: string): boolean {
if (!query) return true;
// Only attempt pinyin matching if the name contains Chinese characters
if (!/[\u4e00-\u9fff]/.test(name)) return false;
const q = query.toLowerCase();
// Get full pinyin (no tone, no separator, ü→v for standard input)
const full = pinyin(name, { toneType: "none", type: "array", v: true });
const fullStr = full.join("");
// Full pinyin prefix match: "liyunlong" or "liyun"
if (fullStr.startsWith(q)) return true;
// Initial letters match: "lyl"
const initials = full.map((p) => p[0] || "").join("");
if (initials.startsWith(q)) return true;
// Hybrid match: some chars matched by full pinyin, rest by initials
// e.g. "liyl" matches "李云龙" (li + y + l)
return hybridMatch(full, q);
}
/**
* Hybrid matching: the query can be a mix of full pinyin for some characters
* and initials for others, consumed left-to-right.
* e.g. for ["li", "yun", "long"], query "liyunl" matches (li + yun + l)
*/
function hybridMatch(pinyinArr: string[], query: string): boolean {
return match(pinyinArr, 0, query, 0);
}
function match(arr: string[], ai: number, q: string, qi: number): boolean {
if (qi >= q.length) return true;
if (ai >= arr.length) return false;
const syllable = arr[ai]!;
// Try matching full syllable or any prefix of it
for (let len = syllable.length; len >= 1; len--) {
if (qi + len > q.length) continue;
if (q.substring(qi, qi + len) === syllable.substring(0, len)) {
if (match(arr, ai + 1, q, qi + len)) return true;
}
}
return false;
}

View File

@@ -81,6 +81,7 @@
"lowlight": "^3.3.0",
"mermaid": "catalog:",
"motion": "^12.38.0",
"pinyin-pro": "3.26.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.7.5",
"react-virtuoso": "catalog:",

8
pnpm-lock.yaml generated
View File

@@ -793,6 +793,9 @@ importers:
motion:
specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
pinyin-pro:
specifier: 3.26.0
version: 3.26.0
react:
specifier: 'catalog:'
version: 19.2.3
@@ -6206,6 +6209,9 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pinyin-pro@3.26.0:
resolution: {integrity: sha512-HcBZZb0pvm0/JkPhZHWA5Hqp2cWHXrrW/WrV+OtaYYM+kf35ffvZppIUuGmyuQ7gDr1JDJKMkbEE+GN0wfMoGg==}
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
@@ -13685,6 +13691,8 @@ snapshots:
picomatch@4.0.3: {}
pinyin-pro@3.26.0: {}
pkce-challenge@5.0.1: {}
pkg-types@1.3.1: