mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
49
packages/views/editor/extensions/pinyin-match.test.ts
Normal file
49
packages/views/editor/extensions/pinyin-match.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
58
packages/views/editor/extensions/pinyin-match.ts
Normal file
58
packages/views/editor/extensions/pinyin-match.ts
Normal 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;
|
||||
}
|
||||
@@ -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
8
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user