mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
fix(web): harden search relevance UX (#2206)
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
co-authored by
Patrick Erichsen
parent
cccef81a3e
commit
9ab92e5847
@@ -23,6 +23,9 @@
|
||||
|
||||
### Fixes
|
||||
|
||||
- Web/API: keep search results limited to items with match evidence, preserve
|
||||
trust and popularity as tie-breakers, and show `N+` counts without exact
|
||||
count queries (#2206) (thanks @vyctorbrzezowski).
|
||||
- API: return `400` for invalid known public package filters and invalid skill
|
||||
list sort values, while continuing to ignore unknown query parameters (#2184).
|
||||
- API/docs: document v1 plain-text error responses and expose owner metadata in
|
||||
|
||||
@@ -3989,6 +3989,45 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(runQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("plugins search sorts by rank tier before score without exposing rank metadata", async () => {
|
||||
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
|
||||
if (args.family === "code-plugin") {
|
||||
return [
|
||||
{
|
||||
score: 20,
|
||||
rankTier: 3,
|
||||
package: makeCatalogItem("summary-plugin", { family: "code-plugin", updatedAt: 100 }),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (args.family === "bundle-plugin") {
|
||||
return [
|
||||
{
|
||||
score: 10,
|
||||
rankTier: 1,
|
||||
package: makeCatalogItem("name-plugin", { family: "bundle-plugin", updatedAt: 50 }),
|
||||
},
|
||||
];
|
||||
}
|
||||
throw new Error(`unexpected family ${String(args.family)}`);
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.pluginsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/plugins/search?q=plugin&limit=2"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.results.map((entry: { package: { name: string } }) => entry.package.name)).toEqual([
|
||||
"name-plugin",
|
||||
"summary-plugin",
|
||||
]);
|
||||
expect(body.results[0]).not.toHaveProperty("rankTier");
|
||||
expect(body.results[0]).not.toHaveProperty("matchReason");
|
||||
});
|
||||
|
||||
it("packages list forwards viewerUserId for authenticated private package browsing", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const runQuery = vi.fn().mockResolvedValue({ page: [], isDone: true, continueCursor: "" });
|
||||
|
||||
@@ -69,7 +69,6 @@ const apiRefs = api as unknown as {
|
||||
};
|
||||
skills: {
|
||||
listPackageCatalogPage: unknown;
|
||||
searchPackageCatalogPublic: unknown;
|
||||
getBySlug: unknown;
|
||||
listVersionsPage: unknown;
|
||||
getVersionBySkillAndVersion: unknown;
|
||||
@@ -116,6 +115,7 @@ const internalRefs = internal as unknown as {
|
||||
};
|
||||
skills: {
|
||||
getSkillBySlugInternal: unknown;
|
||||
searchPackageCatalogForHttpInternal: unknown;
|
||||
getVersionByIdInternal: unknown;
|
||||
getVersionBySkillAndVersionInternal: unknown;
|
||||
};
|
||||
@@ -691,7 +691,11 @@ type CatalogListItem = {
|
||||
verificationTier?: string | null;
|
||||
};
|
||||
|
||||
type CatalogSearchEntry = { score: number; package: CatalogListItem };
|
||||
type CatalogSearchEntry = {
|
||||
score: number;
|
||||
rankTier?: number;
|
||||
package: CatalogListItem;
|
||||
};
|
||||
|
||||
type CatalogSourceCursorState = {
|
||||
cursor: string | null;
|
||||
@@ -892,12 +896,20 @@ function compareCatalogItems(a: CatalogListItem, b: CatalogListItem) {
|
||||
|
||||
function compareCatalogSearchEntries(a: CatalogSearchEntry, b: CatalogSearchEntry) {
|
||||
return (
|
||||
(a.rankTier ?? Number.POSITIVE_INFINITY) - (b.rankTier ?? Number.POSITIVE_INFINITY) ||
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
compareCatalogItems(a.package, b.package)
|
||||
);
|
||||
}
|
||||
|
||||
function toPublicCatalogSearchEntry(entry: CatalogSearchEntry) {
|
||||
return {
|
||||
score: entry.score,
|
||||
package: entry.package,
|
||||
};
|
||||
}
|
||||
|
||||
async function searchPackageCatalog(
|
||||
ctx: ActionCtx,
|
||||
args: {
|
||||
@@ -2155,7 +2167,7 @@ async function searchPackages(
|
||||
if (family === "skill") {
|
||||
results = await runQueryRef<CatalogSearchEntry[]>(
|
||||
ctx,
|
||||
apiRefs.skills.searchPackageCatalogPublic,
|
||||
internalRefs.skills.searchPackageCatalogForHttpInternal,
|
||||
{
|
||||
query: queryText,
|
||||
limit,
|
||||
@@ -2222,15 +2234,19 @@ async function searchPackages(
|
||||
category,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
}),
|
||||
runQueryRef<CatalogSearchEntry[]>(ctx, apiRefs.skills.searchPackageCatalogPublic, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel: channelParam.value,
|
||||
isOfficial: isOfficial.value,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode: executesCode.value,
|
||||
capabilityTag,
|
||||
}),
|
||||
runQueryRef<CatalogSearchEntry[]>(
|
||||
ctx,
|
||||
internalRefs.skills.searchPackageCatalogForHttpInternal,
|
||||
{
|
||||
query: queryText,
|
||||
limit,
|
||||
channel: channelParam.value,
|
||||
isOfficial: isOfficial.value,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode: executesCode.value,
|
||||
capabilityTag,
|
||||
},
|
||||
),
|
||||
]);
|
||||
const seen = new Set<string>();
|
||||
results = [...packageResults, ...skillResults]
|
||||
@@ -2243,7 +2259,7 @@ async function searchPackages(
|
||||
.sort(compareCatalogSearchEntries)
|
||||
.slice(0, limit);
|
||||
}
|
||||
return json({ results }, 200, rate.headers);
|
||||
return json({ results: results.map(toPublicCatalogSearchEntry) }, 200, rate.headers);
|
||||
}
|
||||
|
||||
export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __test, matchesExactTokens, tokenize } from "./searchText";
|
||||
import {
|
||||
__test,
|
||||
matchesExactTokens,
|
||||
matchesExploratoryTokenPrefixes,
|
||||
tokenize,
|
||||
} from "./searchText";
|
||||
|
||||
describe("searchText", () => {
|
||||
it("tokenize lowercases and splits on punctuation", () => {
|
||||
@@ -45,6 +50,18 @@ describe("searchText", () => {
|
||||
expect(matchesExactTokens(["token"], [" ", null, undefined])).toBe(false);
|
||||
});
|
||||
|
||||
it("requires every query token to meet the exploratory minimum", () => {
|
||||
expect(matchesExploratoryTokenPrefixes(tokenize("postgres"), ["Postgres database"], 3)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(matchesExploratoryTokenPrefixes(tokenize("ai postgres"), ["Postgres database"], 3)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(matchesExploratoryTokenPrefixes(tokenize("pg database"), ["Database tools"], 3)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("normalize uses lowercase", () => {
|
||||
expect(__test.normalize("AbC")).toBe("abc");
|
||||
});
|
||||
|
||||
@@ -138,17 +138,48 @@ export function matchesExactTokens(
|
||||
queryTokens: string[],
|
||||
parts: Array<string | null | undefined>,
|
||||
): boolean {
|
||||
if (queryTokens.length === 0) return false;
|
||||
return matchesTokenPrefixes(queryTokens, parts);
|
||||
}
|
||||
|
||||
export function matchesTokenPrefixes(
|
||||
queryTokens: string[],
|
||||
parts: Array<string | null | undefined>,
|
||||
options: { minQueryTokenLength?: number } = {},
|
||||
): boolean {
|
||||
const minQueryTokenLength = options.minQueryTokenLength ?? 1;
|
||||
const eligibleQueryTokens = queryTokens.filter((token) => token.length >= minQueryTokenLength);
|
||||
if (eligibleQueryTokens.length === 0) return false;
|
||||
const text = parts.filter((part) => Boolean(part?.trim())).join(" ");
|
||||
if (!text) return false;
|
||||
const textTokens = tokenize(text);
|
||||
if (textTokens.length === 0) return false;
|
||||
// Require every query token to prefix-match so partial matches do not crowd out better results.
|
||||
return queryTokens.every((queryToken) =>
|
||||
// Require every eligible query token to prefix-match so partial matches do not crowd out better results.
|
||||
return eligibleQueryTokens.every((queryToken) =>
|
||||
textTokens.some((textToken) => textToken.startsWith(queryToken)),
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesExploratoryTokenPrefixes(
|
||||
queryTokens: string[],
|
||||
parts: Array<string | null | undefined>,
|
||||
minQueryTokenLength: number,
|
||||
): boolean {
|
||||
if (queryTokens.length === 0) return false;
|
||||
if (!queryTokens.every((token) => token.length >= minQueryTokenLength)) return false;
|
||||
return matchesTokenPrefixes(queryTokens, parts, { minQueryTokenLength });
|
||||
}
|
||||
|
||||
export function matchesAllTokens(
|
||||
queryTokens: string[],
|
||||
candidateTokens: string[],
|
||||
matcher: (candidate: string, query: string) => boolean,
|
||||
) {
|
||||
if (queryTokens.length === 0 || candidateTokens.length === 0) return false;
|
||||
return queryTokens.every((queryToken) =>
|
||||
candidateTokens.some((candidateToken) => matcher(candidateToken, queryToken)),
|
||||
);
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
normalize,
|
||||
detectCJKLanguage,
|
||||
|
||||
@@ -1824,6 +1824,136 @@ describe("packages public queries", () => {
|
||||
expect(take).toHaveBeenCalledWith(50);
|
||||
});
|
||||
|
||||
it("does not let official status make unrelated packages eligible for search", async () => {
|
||||
const { ctx } = makeDigestCtx({
|
||||
pages: [
|
||||
{
|
||||
page: [
|
||||
makeDigest("openclaw-nostr", {
|
||||
displayName: "OpenClaw Nostr",
|
||||
isOfficial: true,
|
||||
summary: "Protocol integration.",
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: "zzzznonexistentquery123",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not treat punctuation-only queries as package matches", async () => {
|
||||
const { ctx } = makeDigestCtx({
|
||||
pages: [
|
||||
{
|
||||
page: [
|
||||
makeDigest("openclaw-nostr", {
|
||||
isOfficial: true,
|
||||
runtimeId: "openclaw.nostr",
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: ".",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not match short queries through arbitrary summary substrings", async () => {
|
||||
const { ctx } = makeDigestCtx({
|
||||
pages: [
|
||||
{
|
||||
page: [
|
||||
makeDigest("local-tools", {
|
||||
summary: "Available helper tools.",
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: "ai",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not drop short tokens from exploratory package matches", async () => {
|
||||
const { ctx } = makeDigestCtx({
|
||||
pages: [
|
||||
{
|
||||
page: [
|
||||
makeDigest("database-tools", {
|
||||
summary: "Postgres database helper.",
|
||||
capabilityTags: ["postgres"],
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: "ai postgres",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("orders lexical matches before summary-only matches without exposing rank metadata", async () => {
|
||||
const { ctx } = makeDigestCtx({
|
||||
pages: [
|
||||
{
|
||||
page: [
|
||||
makeDigest("official-helper", {
|
||||
displayName: "Official Helper",
|
||||
isOfficial: true,
|
||||
summary: "Ghost CMS integration.",
|
||||
updatedAt: 100,
|
||||
}),
|
||||
makeDigest("ghost-tools", {
|
||||
displayName: "Ghost Tools",
|
||||
isOfficial: false,
|
||||
summary: "CMS helper.",
|
||||
updatedAt: 1,
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: "ghost",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["ghost-tools", "official-helper"]);
|
||||
expect(result[0]).not.toHaveProperty("rankTier");
|
||||
expect(result[0]).not.toHaveProperty("matchReason");
|
||||
});
|
||||
|
||||
it("allows org collaborators to search their private packages", async () => {
|
||||
const { ctx } = makeDigestCtx({
|
||||
capabilityPages: [
|
||||
|
||||
+105
-43
@@ -80,7 +80,7 @@ import {
|
||||
MAX_PUBLISH_TOTAL_BYTES,
|
||||
} from "./lib/publishLimits";
|
||||
import { MAX_ACTIVE_REPORTS_PER_USER, MAX_REPORT_REASON_LENGTH } from "./lib/reporting";
|
||||
import { tokenize } from "./lib/searchText";
|
||||
import { matchesAllTokens, matchesExploratoryTokenPrefixes, tokenize } from "./lib/searchText";
|
||||
import { hashSkillFiles } from "./lib/skills";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
|
||||
@@ -975,31 +975,102 @@ async function getOptionalViewerUserId(ctx: QueryCtx | MutationCtx) {
|
||||
return await getOptionalActiveAuthUserId(ctx);
|
||||
}
|
||||
|
||||
function packageSearchScore(digest: PackageDigestLike, queryText: string) {
|
||||
const EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH = 3;
|
||||
|
||||
type PackageSearchMatch = {
|
||||
rankTier: number;
|
||||
score: number;
|
||||
};
|
||||
|
||||
function packageSearchMatch(
|
||||
digest: PackageDigestLike,
|
||||
queryText: string,
|
||||
): PackageSearchMatch | null {
|
||||
const needle = queryText.toLowerCase();
|
||||
const queryTokens = tokenize(queryText);
|
||||
if (queryTokens.length === 0) return null;
|
||||
const normalized = digest.normalizedName.toLowerCase();
|
||||
const display = digest.displayName.toLowerCase();
|
||||
const runtimeId = digest.runtimeId?.toLowerCase() ?? "";
|
||||
const summary = (digest.summary ?? "").toLowerCase();
|
||||
const nameTokens = tokenize(normalized);
|
||||
const displayTokens = tokenize(display);
|
||||
const runtimeTokens = tokenize(runtimeId);
|
||||
let score = 0;
|
||||
if (normalized === needle) score += 200;
|
||||
else if (normalized.startsWith(needle)) score += 120;
|
||||
else if (normalized.includes(needle)) score += 80;
|
||||
let rankTier = Number.POSITIVE_INFINITY;
|
||||
|
||||
if (display === needle) score += 150;
|
||||
else if (display.startsWith(needle)) score += 70;
|
||||
else if (display.includes(needle)) score += 40;
|
||||
const setMatch = (tier: number, boost: number) => {
|
||||
score += boost;
|
||||
rankTier = Math.min(rankTier, tier);
|
||||
};
|
||||
|
||||
if (runtimeId === needle) score += 180;
|
||||
else if (runtimeId.startsWith(needle)) score += 90;
|
||||
else if (runtimeId.includes(needle)) score += 45;
|
||||
if (normalized === needle) setMatch(0, 200);
|
||||
else if (normalized.startsWith(needle)) setMatch(1, 120);
|
||||
else if (normalized.includes(needle)) setMatch(1, 80);
|
||||
|
||||
if (summary.includes(needle)) score += 20;
|
||||
if ((digest.capabilityTags ?? []).some((entry) => entry.toLowerCase().includes(needle))) {
|
||||
score += 12;
|
||||
if (display === needle) setMatch(0, 150);
|
||||
else if (display.startsWith(needle)) setMatch(1, 70);
|
||||
else if (display.includes(needle)) setMatch(1, 40);
|
||||
|
||||
if (runtimeId === needle) setMatch(0, 180);
|
||||
else if (runtimeId.startsWith(needle)) setMatch(1, 90);
|
||||
else if (runtimeId.includes(needle)) setMatch(1, 45);
|
||||
|
||||
if (
|
||||
matchesAllTokens(
|
||||
queryTokens,
|
||||
[...nameTokens, ...displayTokens, ...runtimeTokens],
|
||||
(a, b) => a === b,
|
||||
)
|
||||
) {
|
||||
setMatch(1, 65);
|
||||
} else if (
|
||||
matchesAllTokens(queryTokens, [...nameTokens, ...displayTokens, ...runtimeTokens], (a, b) =>
|
||||
a.startsWith(b),
|
||||
)
|
||||
) {
|
||||
setMatch(1, 35);
|
||||
}
|
||||
if (digest.isOfficial) score += 5;
|
||||
return score;
|
||||
|
||||
if (
|
||||
matchesExploratoryTokenPrefixes(
|
||||
queryTokens,
|
||||
[digest.summary],
|
||||
EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH,
|
||||
)
|
||||
) {
|
||||
setMatch(3, 20);
|
||||
}
|
||||
if (
|
||||
matchesExploratoryTokenPrefixes(
|
||||
queryTokens,
|
||||
digest.capabilityTags ?? [],
|
||||
EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH,
|
||||
)
|
||||
) {
|
||||
setMatch(2, 12);
|
||||
}
|
||||
if (!Number.isFinite(rankTier)) return null;
|
||||
return { rankTier, score };
|
||||
}
|
||||
|
||||
function comparePackageSearchMatches<
|
||||
T extends PackageSearchMatch & { package: Pick<PackageDigestLike, "isOfficial" | "updatedAt"> },
|
||||
>(a: T, b: T) {
|
||||
return (
|
||||
a.rankTier - b.rankTier ||
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
b.package.updatedAt - a.package.updatedAt
|
||||
);
|
||||
}
|
||||
|
||||
function toPublicPackageSearchEntry(
|
||||
entry: PackageSearchMatch & { package: PublicPackageListItem },
|
||||
) {
|
||||
return {
|
||||
score: entry.score,
|
||||
package: entry.package,
|
||||
};
|
||||
}
|
||||
|
||||
function prefixUpperBound(value: string) {
|
||||
@@ -2153,7 +2224,7 @@ export const searchPublic = query({
|
||||
category: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
return await searchPackagesImpl(ctx, args);
|
||||
return (await searchPackagesImpl(ctx, args)).map(toPublicPackageSearchEntry);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2206,20 +2277,18 @@ async function searchPackagesImpl(
|
||||
if (args.highlightedOnly) {
|
||||
const digests = await fetchHighlightedPackageDigests(ctx, args);
|
||||
return digests
|
||||
.map((digest) => ({
|
||||
score: packageSearchScore(digest, queryText),
|
||||
package: digest,
|
||||
}))
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
b.package.updatedAt - a.package.updatedAt,
|
||||
.map((digest) => {
|
||||
const match = packageSearchMatch(digest, queryText);
|
||||
return match ? { ...match, package: digest } : null;
|
||||
})
|
||||
.filter((entry): entry is PackageSearchMatch & { package: PackageDigestLike } =>
|
||||
Boolean(entry),
|
||||
)
|
||||
.sort(comparePackageSearchMatches)
|
||||
.slice(0, targetCount)
|
||||
.map((entry) => ({
|
||||
score: entry.score,
|
||||
rankTier: entry.rankTier,
|
||||
package: toPublicPackageListItem(entry.package),
|
||||
}));
|
||||
}
|
||||
@@ -2248,7 +2317,7 @@ async function searchPackagesImpl(
|
||||
isOfficial: args.isOfficial,
|
||||
executesCode: args.executesCode,
|
||||
});
|
||||
const matches: Array<{ score: number; package: PublicPackageListItem }> = [];
|
||||
const matches: Array<PackageSearchMatch & { package: PublicPackageListItem }> = [];
|
||||
const seen = new Set<string>();
|
||||
const directDigests =
|
||||
args.capabilityTag || args.category
|
||||
@@ -2257,11 +2326,11 @@ async function searchPackagesImpl(
|
||||
for (const digest of directDigests) {
|
||||
if (!(await canViewPackage(digest))) continue;
|
||||
if (!digestMatchesSearchFilters(digest, args)) continue;
|
||||
const score = packageSearchScore(digest, queryText);
|
||||
if (score <= 0 || seen.has(digest.packageId)) continue;
|
||||
const match = packageSearchMatch(digest, queryText);
|
||||
if (!match || seen.has(digest.packageId)) continue;
|
||||
seen.add(digest.packageId);
|
||||
matches.push({
|
||||
score,
|
||||
...match,
|
||||
package: toPublicPackageListItem(digest),
|
||||
});
|
||||
}
|
||||
@@ -2275,25 +2344,18 @@ async function searchPackagesImpl(
|
||||
for (const digest of digests) {
|
||||
if (!(await canViewPackage(digest))) continue;
|
||||
if (!digestMatchesSearchFilters(digest, args)) continue;
|
||||
const score = packageSearchScore(digest, queryText);
|
||||
if (score <= 0 || seen.has(digest.packageId)) continue;
|
||||
const match = packageSearchMatch(digest, queryText);
|
||||
if (!match || seen.has(digest.packageId)) continue;
|
||||
seen.add(digest.packageId);
|
||||
matches.push({
|
||||
score,
|
||||
...match,
|
||||
package: toPublicPackageListItem(digest),
|
||||
});
|
||||
if (matches.length >= targetCount) break;
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
b.package.updatedAt - a.package.updatedAt,
|
||||
)
|
||||
.slice(0, targetCount);
|
||||
return matches.sort(comparePackageSearchMatches).slice(0, targetCount);
|
||||
}
|
||||
|
||||
export const getPackageByNameInternal = internalQuery({
|
||||
|
||||
@@ -579,6 +579,87 @@ describe("search helpers", () => {
|
||||
expect(result.some((entry) => entry.skill.slug === "antigravity-image-generator")).toBe(true);
|
||||
});
|
||||
|
||||
it("orders lexical name matches above summary-only matches before popularity", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
const exactName = {
|
||||
skill: makePublicSkill({
|
||||
id: "skills:postgres",
|
||||
slug: "postgres",
|
||||
displayName: "Postgres",
|
||||
downloads: 0,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
};
|
||||
const summaryOnly = {
|
||||
skill: {
|
||||
...makePublicSkill({
|
||||
id: "skills:database-tools",
|
||||
slug: "database-tools",
|
||||
displayName: "Database Tools",
|
||||
downloads: 1_000_000_000,
|
||||
}),
|
||||
summary: "Postgres database helper.",
|
||||
},
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
};
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null) // getExactSkillSlugMatch
|
||||
.mockResolvedValueOnce([]) // directPrefixSkillMatches
|
||||
.mockResolvedValueOnce([summaryOnly, exactName]); // lexicalFallbackSkills
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "postgres", limit: 2 },
|
||||
);
|
||||
|
||||
expect(result.map((entry) => entry.skill.slug)).toEqual(["postgres", "database-tools"]);
|
||||
expect(result[0]).not.toHaveProperty("rankTier");
|
||||
expect(result[0]).not.toHaveProperty("matchReason");
|
||||
});
|
||||
|
||||
it("does not let vector recall make short summary-only skills eligible", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
const summaryOnly = {
|
||||
embeddingId: "skillEmbeddings:ai",
|
||||
skill: {
|
||||
...makePublicSkill({
|
||||
id: "skills:ai-summary",
|
||||
slug: "general-helper",
|
||||
displayName: "General Helper",
|
||||
downloads: 1_000,
|
||||
}),
|
||||
summary: "AI helper for teams.",
|
||||
},
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
};
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null) // getExactSkillSlugMatch
|
||||
.mockResolvedValueOnce([]) // directPrefixSkillMatches
|
||||
.mockResolvedValueOnce([summaryOnly]) // hydrateResults
|
||||
.mockResolvedValueOnce([]); // lexicalFallbackSkills
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([{ _id: "skillEmbeddings:ai", _score: 0.99 }]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "ai", limit: 10 },
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("always includes an exact slug match even when vector exact matches already fill the limit", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
|
||||
+81
-18
@@ -8,7 +8,12 @@ import { generateEmbedding } from "./lib/embeddings";
|
||||
import type { HydratableSkill, PublicPublisher } from "./lib/public";
|
||||
import { toPublicPublisher, toPublicSkill, toPublicSoul } from "./lib/public";
|
||||
import { getOwnerPublisher } from "./lib/publishers";
|
||||
import { matchesExactTokens, tokenize } from "./lib/searchText";
|
||||
import {
|
||||
matchesAllTokens,
|
||||
matchesExactTokens,
|
||||
matchesExploratoryTokenPrefixes,
|
||||
tokenize,
|
||||
} from "./lib/searchText";
|
||||
import { SKILL_CAPABILITY_TAGS } from "./lib/skillCapabilityTags";
|
||||
import { isSkillSuspicious } from "./lib/skillSafety";
|
||||
import {
|
||||
@@ -50,7 +55,17 @@ type SkillSearchEntry = {
|
||||
owner: PublicPublisher | null;
|
||||
};
|
||||
|
||||
type SearchResult = SkillSearchEntry & { score: number };
|
||||
type SearchMatch = {
|
||||
rankTier: number;
|
||||
};
|
||||
|
||||
type SearchResult = SkillSearchEntry &
|
||||
SearchMatch & {
|
||||
score: number;
|
||||
};
|
||||
type PublicSearchResult = SkillSearchEntry & {
|
||||
score: number;
|
||||
};
|
||||
|
||||
const EXACT_SLUG_BOOST = 2.5;
|
||||
const SLUG_TOKEN_BOOST = 1.4;
|
||||
@@ -66,6 +81,7 @@ const MAX_DIRECT_SKILL_SEARCH_CANDIDATES = 100;
|
||||
const MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES = 40;
|
||||
const MIN_VECTOR_SEARCH_CANDIDATES = 50;
|
||||
const MAX_VECTOR_SEARCH_CANDIDATES = 128;
|
||||
const EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH = 3;
|
||||
const SKILL_CAPABILITY_TAG_SET = new Set<string>(SKILL_CAPABILITY_TAGS);
|
||||
|
||||
function getNextCandidateLimit(current: number, max: number) {
|
||||
@@ -73,17 +89,6 @@ function getNextCandidateLimit(current: number, max: number) {
|
||||
return next > current ? next : null;
|
||||
}
|
||||
|
||||
function matchesAllTokens(
|
||||
queryTokens: string[],
|
||||
candidateTokens: string[],
|
||||
matcher: (candidate: string, query: string) => boolean,
|
||||
) {
|
||||
if (queryTokens.length === 0 || candidateTokens.length === 0) return false;
|
||||
return queryTokens.every((queryToken) =>
|
||||
candidateTokens.some((candidateToken) => matcher(candidateToken, queryToken)),
|
||||
);
|
||||
}
|
||||
|
||||
function getLexicalBoost(queryTokens: string[], displayName: string, slug: string) {
|
||||
const slugTokens = tokenize(slug);
|
||||
const nameTokens = tokenize(displayName);
|
||||
@@ -123,6 +128,54 @@ function scoreSkillResult(
|
||||
return vectorScore + lexicalBoost + popularityBoost;
|
||||
}
|
||||
|
||||
function classifySkillMatch(
|
||||
query: string,
|
||||
queryTokens: string[],
|
||||
skill: Pick<HydratableSkill, "displayName" | "slug" | "summary" | "capabilityTags">,
|
||||
): SearchMatch | null {
|
||||
const needle = query.toLowerCase();
|
||||
const normalizedSlugQuery = queryTokens.join("-");
|
||||
const slug = skill.slug.toLowerCase();
|
||||
const display = skill.displayName.toLowerCase();
|
||||
const slugTokens = tokenize(slug);
|
||||
const displayTokens = tokenize(display);
|
||||
|
||||
if (slug === normalizedSlugQuery || slug === needle || display === needle) {
|
||||
return { rankTier: 0 };
|
||||
}
|
||||
if (slug.startsWith(normalizedSlugQuery) || slug.startsWith(needle)) {
|
||||
return { rankTier: 1 };
|
||||
}
|
||||
if (display.startsWith(needle)) {
|
||||
return { rankTier: 1 };
|
||||
}
|
||||
if (matchesAllTokens(queryTokens, [...slugTokens, ...displayTokens], (a, b) => a === b)) {
|
||||
return { rankTier: 1 };
|
||||
}
|
||||
if (matchesAllTokens(queryTokens, [...slugTokens, ...displayTokens], (a, b) => a.startsWith(b))) {
|
||||
return { rankTier: 1 };
|
||||
}
|
||||
if (
|
||||
matchesExploratoryTokenPrefixes(
|
||||
queryTokens,
|
||||
skill.capabilityTags ?? [],
|
||||
EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH,
|
||||
)
|
||||
) {
|
||||
return { rankTier: 2 };
|
||||
}
|
||||
if (
|
||||
matchesExploratoryTokenPrefixes(
|
||||
queryTokens,
|
||||
[skill.summary],
|
||||
EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH,
|
||||
)
|
||||
) {
|
||||
return { rankTier: 3 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearchEntry[]) {
|
||||
if (fallback.length === 0) return primary;
|
||||
const out = [...primary];
|
||||
@@ -163,7 +216,7 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SearchResult[]> => {
|
||||
handler: async (ctx, args): Promise<PublicSearchResult[]> => {
|
||||
const query = args.query.trim();
|
||||
if (!query) return [];
|
||||
if (args.capabilityTag && !SKILL_CAPABILITY_TAG_SET.has(args.capabilityTag)) return [];
|
||||
@@ -284,11 +337,14 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
})) as SkillSearchEntry[]);
|
||||
const mergedMatches = mergeUniqueBySkillId(primaryMatches, fallbackMatches);
|
||||
|
||||
return mergedMatches
|
||||
.map((entry) => {
|
||||
const rankedMatches = mergedMatches
|
||||
.map((entry): SearchResult | null => {
|
||||
const vectorScore = entry.embeddingId ? (scoreById.get(entry.embeddingId) ?? 0) : 0;
|
||||
const match = classifySkillMatch(query, queryTokens, entry.skill);
|
||||
if (!match) return null;
|
||||
return {
|
||||
...entry,
|
||||
...match,
|
||||
score: scoreSkillResult(
|
||||
queryTokens,
|
||||
vectorScore,
|
||||
@@ -298,9 +354,15 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.skill)
|
||||
.sort((a, b) => b.score - a.score || b.skill.stats.downloads - a.skill.stats.downloads)
|
||||
.filter((entry): entry is SearchResult => Boolean(entry?.skill))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.rankTier - b.rankTier ||
|
||||
b.score - a.score ||
|
||||
b.skill.stats.downloads - a.skill.stats.downloads,
|
||||
)
|
||||
.slice(0, limit);
|
||||
return rankedMatches.map(({ rankTier: _rankTier, ...entry }) => entry);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -897,6 +959,7 @@ export const __test = {
|
||||
matchesAllTokens,
|
||||
getLexicalBoost,
|
||||
scoreSkillResult,
|
||||
classifySkillMatch,
|
||||
mergeUniqueBySkillId,
|
||||
mergeUniqueBySoulId,
|
||||
};
|
||||
|
||||
@@ -218,6 +218,112 @@ describe("skills package catalog queries", () => {
|
||||
expect(result[0]?.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not let official status make unrelated skills eligible for package search", async () => {
|
||||
const result = await searchPackageCatalogPublicHandler(
|
||||
makeCtx([
|
||||
{
|
||||
page: [
|
||||
makeDigest("official-skill", {
|
||||
badges: { official: { byUserId: "users:admin", at: 1 } },
|
||||
displayName: "Official Skill",
|
||||
summary: "General integration.",
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
]),
|
||||
{
|
||||
query: "zzzznonexistentquery123",
|
||||
limit: 5,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns skill package match metadata and orders name matches before summary matches", async () => {
|
||||
const result = await searchPackageCatalogPublicHandler(
|
||||
makeCtx([
|
||||
{
|
||||
page: [
|
||||
makeDigest("official-helper", {
|
||||
badges: { official: { byUserId: "users:admin", at: 1 } },
|
||||
displayName: "Official Helper",
|
||||
summary: "Ghost CMS integration.",
|
||||
updatedAt: 100,
|
||||
}),
|
||||
makeDigest("ghost-tools", {
|
||||
displayName: "Ghost Tools",
|
||||
summary: "CMS helper.",
|
||||
updatedAt: 1,
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
]),
|
||||
{
|
||||
query: "ghost",
|
||||
limit: 5,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["ghost-tools", "official-helper"]);
|
||||
expect(result[0]).not.toHaveProperty("rankTier");
|
||||
expect(result[0]).not.toHaveProperty("matchReason");
|
||||
});
|
||||
|
||||
it("uses capability tags as skill package search evidence", async () => {
|
||||
const result = await searchPackageCatalogPublicHandler(
|
||||
makeCtx([
|
||||
{
|
||||
page: [
|
||||
makeDigest("wallet-helper", {
|
||||
displayName: "Wallet Helper",
|
||||
summary: "Payment helper.",
|
||||
capabilityTags: ["crypto", "requires-wallet"],
|
||||
}),
|
||||
makeDigest("weather"),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
]),
|
||||
{
|
||||
query: "crypto",
|
||||
limit: 5,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["wallet-helper"]);
|
||||
expect(result[0]).not.toHaveProperty("rankTier");
|
||||
});
|
||||
|
||||
it("does not drop short tokens from exploratory skill package matches", async () => {
|
||||
const result = await searchPackageCatalogPublicHandler(
|
||||
makeCtx([
|
||||
{
|
||||
page: [
|
||||
makeDigest("database-tools", {
|
||||
displayName: "Database Tools",
|
||||
summary: "Postgres database helper.",
|
||||
capabilityTags: ["postgres"],
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
]),
|
||||
{
|
||||
query: "ai postgres",
|
||||
limit: 5,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters skills by capability tag", async () => {
|
||||
const result = await listPackageCatalogPageHandler(
|
||||
makeCtx([
|
||||
|
||||
+157
-65
@@ -97,6 +97,7 @@ import {
|
||||
reserveSlugForHardDeleteFinalize,
|
||||
upsertReservedSlugForRightfulOwner,
|
||||
} from "./lib/reservedSlugs";
|
||||
import { matchesAllTokens, matchesExploratoryTokenPrefixes, tokenize } from "./lib/searchText";
|
||||
import { SKILL_CAPABILITY_TAGS } from "./lib/skillCapabilityTags";
|
||||
import { normalizeSkillIconValue } from "./lib/skillIcon";
|
||||
import {
|
||||
@@ -4671,23 +4672,81 @@ function toPublicSkillCatalogItem(digest: Doc<"skillSearchDigest">): PublicSkill
|
||||
};
|
||||
}
|
||||
|
||||
function scoreSkillCatalogResult(digest: Doc<"skillSearchDigest">, queryText: string) {
|
||||
const EXPLORATORY_SKILL_CATALOG_SEARCH_MIN_TOKEN_LENGTH = 3;
|
||||
|
||||
type SkillCatalogSearchMatch = {
|
||||
rankTier: number;
|
||||
score: number;
|
||||
};
|
||||
|
||||
function skillCatalogSearchMatch(
|
||||
digest: Doc<"skillSearchDigest">,
|
||||
queryText: string,
|
||||
): SkillCatalogSearchMatch | null {
|
||||
const needle = queryText.toLowerCase();
|
||||
const queryTokens = tokenize(queryText);
|
||||
if (queryTokens.length === 0) return null;
|
||||
const slug = digest.slug.toLowerCase();
|
||||
const display = digest.displayName.toLowerCase();
|
||||
const summary = (digest.summary ?? "").toLowerCase();
|
||||
const slugTokens = tokenize(slug);
|
||||
const displayTokens = tokenize(display);
|
||||
let score = 0;
|
||||
if (slug === needle) score += 200;
|
||||
else if (slug.startsWith(needle)) score += 120;
|
||||
else if (slug.includes(needle)) score += 80;
|
||||
let rankTier = Number.POSITIVE_INFINITY;
|
||||
|
||||
if (display === needle) score += 150;
|
||||
else if (display.startsWith(needle)) score += 70;
|
||||
else if (display.includes(needle)) score += 40;
|
||||
const setMatch = (tier: number, boost: number) => {
|
||||
score += boost;
|
||||
rankTier = Math.min(rankTier, tier);
|
||||
};
|
||||
|
||||
if (summary.includes(needle)) score += 20;
|
||||
if (isSkillCatalogOfficial(digest)) score += 5;
|
||||
return score;
|
||||
if (slug === needle) setMatch(0, 200);
|
||||
else if (slug.startsWith(needle)) setMatch(1, 120);
|
||||
else if (slug.includes(needle)) setMatch(1, 80);
|
||||
|
||||
if (display === needle) setMatch(0, 150);
|
||||
else if (display.startsWith(needle)) setMatch(1, 70);
|
||||
else if (display.includes(needle)) setMatch(1, 40);
|
||||
|
||||
if (matchesAllTokens(queryTokens, [...slugTokens, ...displayTokens], (a, b) => a === b)) {
|
||||
setMatch(1, 65);
|
||||
} else if (
|
||||
matchesAllTokens(queryTokens, [...slugTokens, ...displayTokens], (a, b) => a.startsWith(b))
|
||||
) {
|
||||
setMatch(1, 35);
|
||||
}
|
||||
|
||||
if (
|
||||
matchesExploratoryTokenPrefixes(
|
||||
queryTokens,
|
||||
digest.capabilityTags ?? [],
|
||||
EXPLORATORY_SKILL_CATALOG_SEARCH_MIN_TOKEN_LENGTH,
|
||||
)
|
||||
) {
|
||||
setMatch(2, 12);
|
||||
}
|
||||
if (
|
||||
matchesExploratoryTokenPrefixes(
|
||||
queryTokens,
|
||||
[digest.summary],
|
||||
EXPLORATORY_SKILL_CATALOG_SEARCH_MIN_TOKEN_LENGTH,
|
||||
)
|
||||
) {
|
||||
setMatch(3, 20);
|
||||
}
|
||||
if (!Number.isFinite(rankTier)) return null;
|
||||
return { rankTier, score };
|
||||
}
|
||||
|
||||
function compareSkillCatalogSearchMatches<
|
||||
T extends SkillCatalogSearchMatch & {
|
||||
package: Pick<PublicSkillCatalogItem, "isOfficial" | "updatedAt">;
|
||||
},
|
||||
>(a: T, b: T) {
|
||||
return (
|
||||
a.rankTier - b.rankTier ||
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
b.package.updatedAt - a.package.updatedAt
|
||||
);
|
||||
}
|
||||
|
||||
function isKnownSkillCapabilityTag(tag: string | undefined) {
|
||||
@@ -4785,6 +4844,76 @@ export const listPackageCatalogPage = query({
|
||||
},
|
||||
});
|
||||
|
||||
type SkillPackageCatalogSearchArgs = {
|
||||
query: string;
|
||||
limit?: number;
|
||||
channel?: "official" | "community" | "private";
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
};
|
||||
|
||||
async function searchPackageCatalogImpl(ctx: QueryCtx, args: SkillPackageCatalogSearchArgs) {
|
||||
const queryText = args.query.trim().toLowerCase();
|
||||
if (!queryText) return [];
|
||||
if (args.capabilityTag && !isKnownSkillCapabilityTag(args.capabilityTag)) return [];
|
||||
if (args.channel === "private" || args.executesCode === true) return [];
|
||||
|
||||
const targetCount = Math.max(1, Math.min(args.limit ?? 20, 100));
|
||||
const matches: Array<SkillCatalogSearchMatch & { package: PublicSkillCatalogItem }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const exactSkill = await resolveSkillBySlugOrAlias(ctx, queryText);
|
||||
if (exactSkill.skill) {
|
||||
const exactDigest = await ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", exactSkill.skill!._id))
|
||||
.unique();
|
||||
if (exactDigest && skillCatalogMatchesFilters(exactDigest, args)) {
|
||||
const match = skillCatalogSearchMatch(exactDigest, queryText);
|
||||
if (match) {
|
||||
seen.add(exactDigest.skillId);
|
||||
matches.push({
|
||||
...match,
|
||||
package: toPublicSkillCatalogItem(exactDigest),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length < targetCount) {
|
||||
const pageSize = Math.min(MAX_SKILL_CATALOG_SEARCH_PAGE_SIZE, Math.max(targetCount * 5, 50));
|
||||
const page = await ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
|
||||
.order("desc")
|
||||
.paginate({ cursor: null, numItems: pageSize });
|
||||
|
||||
for (const digest of page.page) {
|
||||
if (!skillCatalogMatchesFilters(digest, args)) continue;
|
||||
const match = skillCatalogSearchMatch(digest, queryText);
|
||||
if (!match || seen.has(digest.skillId)) continue;
|
||||
seen.add(digest.skillId);
|
||||
matches.push({
|
||||
...match,
|
||||
package: toPublicSkillCatalogItem(digest),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches.sort(compareSkillCatalogSearchMatches).slice(0, targetCount);
|
||||
}
|
||||
|
||||
function toPublicSkillCatalogSearchEntry(
|
||||
entry: SkillCatalogSearchMatch & { package: PublicSkillCatalogItem },
|
||||
) {
|
||||
return {
|
||||
score: entry.score,
|
||||
package: entry.package,
|
||||
};
|
||||
}
|
||||
|
||||
export const searchPackageCatalogPublic = query({
|
||||
args: {
|
||||
query: v.string(),
|
||||
@@ -4798,61 +4927,24 @@ export const searchPackageCatalogPublic = query({
|
||||
capabilityTag: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const queryText = args.query.trim().toLowerCase();
|
||||
if (!queryText) return [];
|
||||
if (args.capabilityTag && !isKnownSkillCapabilityTag(args.capabilityTag)) return [];
|
||||
if (args.channel === "private" || args.executesCode === true) return [];
|
||||
return (await searchPackageCatalogImpl(ctx, args)).map(toPublicSkillCatalogSearchEntry);
|
||||
},
|
||||
});
|
||||
|
||||
const targetCount = Math.max(1, Math.min(args.limit ?? 20, 100));
|
||||
const matches: Array<{ score: number; package: PublicSkillCatalogItem }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const exactSkill = await resolveSkillBySlugOrAlias(ctx, queryText);
|
||||
if (exactSkill.skill) {
|
||||
const exactDigest = await ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", exactSkill.skill!._id))
|
||||
.unique();
|
||||
if (exactDigest && skillCatalogMatchesFilters(exactDigest, args)) {
|
||||
const exactScore = scoreSkillCatalogResult(exactDigest, queryText);
|
||||
if (exactScore > 0) {
|
||||
seen.add(exactDigest.skillId);
|
||||
matches.push({
|
||||
score: exactScore,
|
||||
package: toPublicSkillCatalogItem(exactDigest),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length < targetCount) {
|
||||
const pageSize = Math.min(MAX_SKILL_CATALOG_SEARCH_PAGE_SIZE, Math.max(targetCount * 5, 50));
|
||||
const page = await ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
|
||||
.order("desc")
|
||||
.paginate({ cursor: null, numItems: pageSize });
|
||||
|
||||
for (const digest of page.page) {
|
||||
if (!skillCatalogMatchesFilters(digest, args)) continue;
|
||||
const score = scoreSkillCatalogResult(digest, queryText);
|
||||
if (score <= 0 || seen.has(digest.skillId)) continue;
|
||||
seen.add(digest.skillId);
|
||||
matches.push({
|
||||
score,
|
||||
package: toPublicSkillCatalogItem(digest),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
b.package.updatedAt - a.package.updatedAt,
|
||||
)
|
||||
.slice(0, targetCount);
|
||||
export const searchPackageCatalogForHttpInternal = internalQuery({
|
||||
args: {
|
||||
query: v.string(),
|
||||
limit: v.optional(v.number()),
|
||||
channel: v.optional(
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
return await searchPackageCatalogImpl(ctx, args);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Search Relevance Contract
|
||||
|
||||
ClawHub search is a retrieval surface, not a browse fallback. A package, plugin, or skill can appear as a search match only when the query has evidence against that item:
|
||||
|
||||
- exact, prefix, or substring match in a navigational field such as name, slug, display name, normalized package name, or runtime id;
|
||||
- token-prefix match in exploratory fields such as summary or capability tags, using a minimum query-token length for every query token to avoid short-query noise.
|
||||
|
||||
Trust and business signals are not relevance signals. `official`, verification tier, security status, downloads, stars, installs, highlighting, and recency may break ties between already eligible matches or appear as filters/badges, but they must not make an otherwise unrelated item eligible for search.
|
||||
|
||||
Search ranking should be lexicographic before it is numeric:
|
||||
|
||||
1. exact full field match in name, slug, normalized package name, or runtime id;
|
||||
2. lexical field match in name, slug, normalized package name, display name, or runtime id;
|
||||
3. capability or tag match;
|
||||
4. summary match;
|
||||
|
||||
Numeric scores, trust state, popularity, and recency may order results inside those broad tiers, but must not move a weaker tier above a stronger tier.
|
||||
|
||||
The same contract applies across `/search`, the header typeahead, package/plugin catalog search, and skill-as-package catalog search.
|
||||
|
||||
Search result counts in the web UI should describe what is known from the current request. Do not label a page-size-limited result length as a total corpus count. Prefer `N+`, "shown", or no count unless an indexed/materialized total is available.
|
||||
@@ -59,6 +59,8 @@ const defaultUnifiedSearchResult = {
|
||||
],
|
||||
skillCount: 1,
|
||||
pluginCount: 1,
|
||||
skillHasMore: false,
|
||||
pluginHasMore: false,
|
||||
isSearching: false,
|
||||
};
|
||||
|
||||
@@ -319,8 +321,15 @@ describe("Header", () => {
|
||||
const typeahead = screen.getByRole("listbox");
|
||||
expect(within(typeahead).getByText("Skills")).toBeTruthy();
|
||||
expect(screen.getByText("Weather Skill")).toBeTruthy();
|
||||
expect(screen.getByText("@local / weather")).toBeTruthy();
|
||||
expect(within(typeahead).getByText("Plugins")).toBeTruthy();
|
||||
expect(screen.getByText("Weather Plugin")).toBeTruthy();
|
||||
expect(input.getAttribute("role")).toBe("combobox");
|
||||
expect(input.getAttribute("aria-autocomplete")).toBe("list");
|
||||
expect(input.getAttribute("aria-expanded")).toBe("true");
|
||||
const activeDescendant = input.getAttribute("aria-activedescendant");
|
||||
expect(activeDescendant).toBeTruthy();
|
||||
expect(document.getElementById(activeDescendant ?? "")).toBeTruthy();
|
||||
expect(within(typeahead).queryByText("Publishers")).toBeNull();
|
||||
expect(within(typeahead).queryByText('See user results for "weather"')).toBeNull();
|
||||
|
||||
@@ -379,6 +388,8 @@ describe("Header", () => {
|
||||
pluginResults: [],
|
||||
skillCount: 0,
|
||||
pluginCount: 0,
|
||||
skillHasMore: false,
|
||||
pluginHasMore: false,
|
||||
isSearching: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ describe("search route", () => {
|
||||
pluginResults: [],
|
||||
skillCount: 0,
|
||||
pluginCount: 0,
|
||||
skillHasMore: false,
|
||||
pluginHasMore: false,
|
||||
isSearching: false,
|
||||
});
|
||||
});
|
||||
@@ -95,6 +97,8 @@ describe("search route", () => {
|
||||
pluginResults: [{ type: "plugin", plugin: { name: "github-plugin" } }],
|
||||
skillCount: 0,
|
||||
pluginCount: 3,
|
||||
skillHasMore: false,
|
||||
pluginHasMore: false,
|
||||
isSearching: false,
|
||||
});
|
||||
const route = await loadRoute();
|
||||
@@ -123,6 +127,122 @@ describe("search route", () => {
|
||||
});
|
||||
|
||||
it("can request more results from global search", async () => {
|
||||
searchMock = { q: "weather", type: "skills" };
|
||||
const skills = Array.from({ length: 25 }, (_, index) => ({
|
||||
type: "skill",
|
||||
skill: {
|
||||
_id: `skill-${index}`,
|
||||
slug: `weather-${index}`,
|
||||
displayName: `Weather ${index}`,
|
||||
ownerUserId: "users:1",
|
||||
stats: { downloads: 0, stars: 0 },
|
||||
updatedAt: 1,
|
||||
createdAt: 1,
|
||||
},
|
||||
ownerHandle: "clawhub",
|
||||
score: 1,
|
||||
}));
|
||||
useUnifiedSearchMock.mockReturnValue({
|
||||
results: skills,
|
||||
skillResults: skills,
|
||||
pluginResults: [],
|
||||
skillCount: 25,
|
||||
pluginCount: 0,
|
||||
skillHasMore: true,
|
||||
pluginHasMore: false,
|
||||
isSearching: false,
|
||||
});
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(useUnifiedSearchMock).toHaveBeenCalledWith("weather", "all", {
|
||||
limits: { skills: 25, plugins: 25 },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
|
||||
expect(useUnifiedSearchMock).toHaveBeenCalledWith("weather", "all", {
|
||||
limits: { skills: 50, plugins: 50 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps inactive tab counts honest while rendering the active tab", async () => {
|
||||
searchMock = { q: "weather", type: "skills" };
|
||||
useUnifiedSearchMock.mockReturnValue({
|
||||
results: [
|
||||
{
|
||||
type: "skill",
|
||||
skill: {
|
||||
_id: "skill-weather",
|
||||
slug: "weather",
|
||||
displayName: "Weather",
|
||||
ownerUserId: "users:1",
|
||||
stats: { downloads: 0, stars: 0 },
|
||||
updatedAt: 1,
|
||||
createdAt: 1,
|
||||
},
|
||||
ownerHandle: "clawhub",
|
||||
score: 1,
|
||||
},
|
||||
{ type: "plugin", plugin: { name: "weather-plugin" } },
|
||||
],
|
||||
skillResults: [
|
||||
{
|
||||
type: "skill",
|
||||
skill: {
|
||||
_id: "skill-weather",
|
||||
slug: "weather",
|
||||
displayName: "Weather",
|
||||
ownerUserId: "users:1",
|
||||
stats: { downloads: 0, stars: 0 },
|
||||
updatedAt: 1,
|
||||
createdAt: 1,
|
||||
},
|
||||
ownerHandle: "clawhub",
|
||||
score: 1,
|
||||
},
|
||||
],
|
||||
pluginResults: [{ type: "plugin", plugin: { name: "weather-plugin" } }],
|
||||
skillCount: 1,
|
||||
pluginCount: 1,
|
||||
skillHasMore: false,
|
||||
pluginHasMore: false,
|
||||
isSearching: false,
|
||||
});
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "All 2" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Plugins 1" })).toBeTruthy();
|
||||
expect(screen.getByText("weather")).toBeTruthy();
|
||||
expect(screen.queryByText("weather-plugin")).toBeNull();
|
||||
});
|
||||
|
||||
it("marks tab counts as partial when more results are available", async () => {
|
||||
useUnifiedSearchMock.mockReturnValue({
|
||||
results: [],
|
||||
skillResults: [],
|
||||
pluginResults: [{ type: "plugin", plugin: { name: "github-plugin" } }],
|
||||
skillCount: 0,
|
||||
pluginCount: 25,
|
||||
skillHasMore: false,
|
||||
pluginHasMore: true,
|
||||
isSearching: false,
|
||||
});
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "All 25+" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Plugins 25+" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show load more only because the current page is full", async () => {
|
||||
searchMock = { q: "weather", type: "skills" };
|
||||
useUnifiedSearchMock.mockReturnValue({
|
||||
results: Array.from({ length: 25 }, (_, index) => ({
|
||||
@@ -143,6 +263,8 @@ describe("search route", () => {
|
||||
pluginResults: [],
|
||||
skillCount: 25,
|
||||
pluginCount: 0,
|
||||
skillHasMore: false,
|
||||
pluginHasMore: false,
|
||||
isSearching: false,
|
||||
});
|
||||
const route = await loadRoute();
|
||||
@@ -150,15 +272,49 @@ describe("search route", () => {
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(useUnifiedSearchMock).toHaveBeenLastCalledWith("weather", "all", {
|
||||
limits: { skills: 25, plugins: 25 },
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
it("links to skills browse when search has no matches", async () => {
|
||||
searchMock = { q: "zzzz", type: "skills" };
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
expect(useUnifiedSearchMock).toHaveBeenLastCalledWith("weather", "all", {
|
||||
limits: { skills: 50, plugins: 50 },
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByText('No matches for "zzzz"')).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Show all skills" }).getAttribute("href")).toBe(
|
||||
"/skills",
|
||||
);
|
||||
expect(screen.queryByRole("link", { name: "Show all plugins" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Search all types" })).toBeNull();
|
||||
});
|
||||
|
||||
it("offers all-types recovery when the active type is empty but another type matched", async () => {
|
||||
searchMock = { q: "weather", type: "skills" };
|
||||
useUnifiedSearchMock.mockReturnValue({
|
||||
results: [{ type: "plugin", plugin: { name: "weather-plugin" } }],
|
||||
skillResults: [],
|
||||
pluginResults: [{ type: "plugin", plugin: { name: "weather-plugin" } }],
|
||||
skillCount: 0,
|
||||
pluginCount: 1,
|
||||
skillHasMore: false,
|
||||
pluginHasMore: false,
|
||||
isSearching: false,
|
||||
});
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Search all types" }));
|
||||
expect(navigateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/search",
|
||||
replace: true,
|
||||
search: expect.objectContaining({ type: undefined }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes only result limits to unified search", async () => {
|
||||
|
||||
@@ -110,9 +110,7 @@ export default function Header() {
|
||||
const showTypeahead = !isSoulMode && typeaheadOpen && trimmedNavSearchQuery.length > 0;
|
||||
const {
|
||||
skillResults,
|
||||
skillCount,
|
||||
pluginResults,
|
||||
pluginCount,
|
||||
isSearching: typeaheadSearching,
|
||||
} = useUnifiedSearch(navSearchQuery, "all", {
|
||||
debounceMs: 180,
|
||||
@@ -125,7 +123,7 @@ export default function Header() {
|
||||
for (const result of skillResults) {
|
||||
items.push({ kind: "skill", key: `skill-${result.skill._id}`, result });
|
||||
}
|
||||
if (skillCount > 0) {
|
||||
if (skillResults.length > 0) {
|
||||
items.push({
|
||||
kind: "footer",
|
||||
key: "footer-skills",
|
||||
@@ -136,7 +134,7 @@ export default function Header() {
|
||||
for (const result of pluginResults) {
|
||||
items.push({ kind: "plugin", key: `plugin-${result.plugin.name}`, result });
|
||||
}
|
||||
if (pluginCount > 0) {
|
||||
if (pluginResults.length > 0) {
|
||||
items.push({
|
||||
kind: "footer",
|
||||
key: "footer-plugins",
|
||||
@@ -145,12 +143,20 @@ export default function Header() {
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [pluginCount, pluginResults, showTypeahead, skillCount, skillResults, trimmedNavSearchQuery]);
|
||||
}, [pluginResults, showTypeahead, skillResults, trimmedNavSearchQuery]);
|
||||
const activeTypeaheadItem = showTypeahead ? typeaheadItems[typeaheadActiveIndex] : undefined;
|
||||
const activeTypeaheadId = activeTypeaheadItem
|
||||
? getTypeaheadOptionId(activeTypeaheadItem)
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
setTypeaheadActiveIndex(0);
|
||||
}, [trimmedNavSearchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setTypeaheadActiveIndex((index) => Math.min(index, Math.max(typeaheadItems.length - 1, 0)));
|
||||
}, [typeaheadItems.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!typeaheadOpen) return () => {};
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
@@ -370,6 +376,7 @@ export default function Header() {
|
||||
<input
|
||||
className="navbar-search-input"
|
||||
type="search"
|
||||
role="combobox"
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills and plugins"}
|
||||
value={navSearchQuery}
|
||||
onChange={(e) => {
|
||||
@@ -379,8 +386,10 @@ export default function Header() {
|
||||
onFocus={() => setTypeaheadOpen(true)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
aria-label="Search"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={showTypeahead}
|
||||
aria-controls="navbar-search-typeahead"
|
||||
aria-activedescendant={activeTypeaheadId}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</form>
|
||||
@@ -630,7 +639,12 @@ function SearchTypeahead({
|
||||
const hasMatches = skillItems.length > 0 || pluginItems.length > 0;
|
||||
|
||||
return (
|
||||
<div className="navbar-search-typeahead" id="navbar-search-typeahead" role="listbox">
|
||||
<div
|
||||
className="navbar-search-typeahead"
|
||||
id="navbar-search-typeahead"
|
||||
role="listbox"
|
||||
aria-label="Search suggestions"
|
||||
>
|
||||
<TypeaheadSection
|
||||
activeIndex={activeIndex}
|
||||
items={items}
|
||||
@@ -721,6 +735,7 @@ function TypeaheadRow({
|
||||
const body = getTypeaheadRowBody(item);
|
||||
return (
|
||||
<button
|
||||
id={getTypeaheadOptionId(item)}
|
||||
className={`navbar-search-typeahead-row${active ? " is-active" : ""}${item.kind === "footer" ? " is-footer" : ""}`}
|
||||
type="button"
|
||||
role="option"
|
||||
@@ -739,6 +754,10 @@ function TypeaheadRow({
|
||||
);
|
||||
}
|
||||
|
||||
function getTypeaheadOptionId(item: TypeaheadItem) {
|
||||
return `navbar-search-typeahead-${item.key.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||||
}
|
||||
|
||||
function getTypeaheadRowBody(item: TypeaheadItem) {
|
||||
if (item.kind === "skill") {
|
||||
const owner = item.result.ownerHandle ? `@${item.result.ownerHandle}` : "Skill";
|
||||
@@ -749,12 +768,13 @@ function getTypeaheadRowBody(item: TypeaheadItem) {
|
||||
};
|
||||
}
|
||||
if (item.kind === "plugin") {
|
||||
const owner = item.result.plugin.ownerHandle
|
||||
? `@${item.result.plugin.ownerHandle} / ${item.result.plugin.name}`
|
||||
: item.result.plugin.name;
|
||||
return {
|
||||
icon: "P",
|
||||
title: item.result.plugin.displayName,
|
||||
meta: item.result.plugin.ownerHandle
|
||||
? `@${item.result.plugin.ownerHandle} / ${item.result.plugin.name}`
|
||||
: item.result.plugin.name,
|
||||
meta: owner,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -539,6 +539,36 @@ describe("fetchPluginCatalog", () => {
|
||||
expect(url.searchParams.has("sort")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores malformed plugin search entries defensively", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
results: [
|
||||
null,
|
||||
{
|
||||
score: 4,
|
||||
package: {
|
||||
name: "bundle-demo",
|
||||
displayName: "Bundle Demo",
|
||||
family: "bundle-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchPluginCatalog({ q: "demo" });
|
||||
|
||||
expect(result.items.map((item) => item.name)).toEqual(["bundle-demo"]);
|
||||
});
|
||||
|
||||
it("keeps relevance as the implicit plugins search sort", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
|
||||
+12
-9
@@ -353,10 +353,12 @@ export async function fetchPackages(params: {
|
||||
}
|
||||
if (params.capabilityTag) url.searchParams.set("capabilityTag", params.capabilityTag);
|
||||
if (params.category) url.searchParams.set("category", params.category);
|
||||
return await fetchJson<{ results: Array<{ score: number; package: PackageListItem }> }>(
|
||||
url,
|
||||
params.signal,
|
||||
);
|
||||
return await fetchJson<{
|
||||
results: Array<{
|
||||
score: number;
|
||||
package: PackageListItem;
|
||||
}>;
|
||||
}>(url, params.signal);
|
||||
}
|
||||
|
||||
const route =
|
||||
@@ -409,7 +411,7 @@ export async function fetchPluginCatalog(params: {
|
||||
});
|
||||
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
|
||||
return {
|
||||
items: response.results.map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
|
||||
items: response.results.map((entry) => entry?.package).filter(Boolean),
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
@@ -434,12 +436,13 @@ export async function fetchPluginCatalog(params: {
|
||||
}
|
||||
if (params.category) url.searchParams.set("category", params.category);
|
||||
const response = await fetchJson<{
|
||||
results?: Array<{ score: number; package: PackageListItem }>;
|
||||
results?: Array<{
|
||||
score: number;
|
||||
package: PackageListItem;
|
||||
}>;
|
||||
}>(url, params.signal);
|
||||
return {
|
||||
items: (response?.results ?? [])
|
||||
.map((entry) => entry?.package)
|
||||
.filter(Boolean) as PackageListItem[],
|
||||
items: (response?.results ?? []).map((entry) => entry?.package).filter(Boolean),
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useUnifiedSearch } from "./useUnifiedSearch";
|
||||
|
||||
const { searchSkillsMock, fetchPluginCatalogMock } = vi.hoisted(() => ({
|
||||
searchSkillsMock: vi.fn(),
|
||||
fetchPluginCatalogMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useAction: () => searchSkillsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./packageApi", () => ({
|
||||
fetchPluginCatalog: (...args: unknown[]) => fetchPluginCatalogMock(...args),
|
||||
}));
|
||||
|
||||
function makeSkill(slug: string) {
|
||||
return {
|
||||
skill: {
|
||||
_id: `skills:${slug}`,
|
||||
slug,
|
||||
displayName: slug,
|
||||
ownerUserId: "users:owner",
|
||||
stats: { downloads: 0, stars: 0 },
|
||||
updatedAt: 1,
|
||||
createdAt: 1,
|
||||
},
|
||||
ownerHandle: "owner",
|
||||
score: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function makePlugin(name: string) {
|
||||
return {
|
||||
name,
|
||||
displayName: name,
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useUnifiedSearch", () => {
|
||||
beforeEach(() => {
|
||||
searchSkillsMock.mockReset();
|
||||
fetchPluginCatalogMock.mockReset();
|
||||
});
|
||||
|
||||
it("requests one extra result and exposes hasMore without inflating counts", async () => {
|
||||
searchSkillsMock.mockResolvedValue([makeSkill("one"), makeSkill("two"), makeSkill("three")]);
|
||||
fetchPluginCatalogMock.mockResolvedValue({
|
||||
items: [makePlugin("one-plugin"), makePlugin("two-plugin"), makePlugin("three-plugin")],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useUnifiedSearch("ghost", "all", {
|
||||
debounceMs: 0,
|
||||
limits: { skills: 2, plugins: 2 },
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.skillCount).toBe(2);
|
||||
expect(result.current.pluginCount).toBe(2);
|
||||
});
|
||||
|
||||
expect(searchSkillsMock).toHaveBeenCalledWith({
|
||||
query: "ghost",
|
||||
limit: 3,
|
||||
});
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ q: "ghost", limit: 3 }),
|
||||
);
|
||||
expect(result.current.skillResults.map((entry) => entry.skill.slug)).toEqual(["one", "two"]);
|
||||
expect(result.current.pluginResults.map((entry) => entry.plugin.name)).toEqual([
|
||||
"one-plugin",
|
||||
"two-plugin",
|
||||
]);
|
||||
expect(result.current.skillHasMore).toBe(true);
|
||||
expect(result.current.pluginHasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("caps requested limits at the backend search maximum", async () => {
|
||||
searchSkillsMock.mockResolvedValue([]);
|
||||
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null });
|
||||
|
||||
renderHook(() =>
|
||||
useUnifiedSearch("ghost", "all", {
|
||||
debounceMs: 0,
|
||||
limits: { skills: 150, plugins: 150 },
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(searchSkillsMock).toHaveBeenCalled();
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(searchSkillsMock).toHaveBeenCalledWith({
|
||||
query: "ghost",
|
||||
limit: 101,
|
||||
});
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ q: "ghost", limit: 101 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { api } from "../../convex/_generated/api";
|
||||
import { fetchPluginCatalog, type PackageListItem } from "./packageApi";
|
||||
|
||||
export type UnifiedSearchType = "all" | "skills" | "plugins";
|
||||
const MAX_UNIFIED_SEARCH_LIMIT = 100;
|
||||
|
||||
export type UnifiedSkillResult = {
|
||||
type: "skill";
|
||||
@@ -49,12 +50,17 @@ export function useUnifiedSearch(
|
||||
const [pluginResults, setPluginResults] = useState<UnifiedPluginResult[]>([]);
|
||||
const [skillCount, setSkillCount] = useState(0);
|
||||
const [pluginCount, setPluginCount] = useState(0);
|
||||
const [skillHasMore, setSkillHasMore] = useState(false);
|
||||
const [pluginHasMore, setPluginHasMore] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const requestRef = useRef(0);
|
||||
const debounceMs = options.debounceMs ?? 300;
|
||||
const enabled = options.enabled ?? true;
|
||||
const skillLimit = options.limits?.skills ?? 25;
|
||||
const pluginLimit = options.limits?.plugins ?? 25;
|
||||
const skillLimit = Math.max(0, Math.min(options.limits?.skills ?? 25, MAX_UNIFIED_SEARCH_LIMIT));
|
||||
const pluginLimit = Math.max(
|
||||
0,
|
||||
Math.min(options.limits?.plugins ?? 25, MAX_UNIFIED_SEARCH_LIMIT),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
@@ -65,6 +71,8 @@ export function useUnifiedSearch(
|
||||
setPluginResults([]);
|
||||
setSkillCount(0);
|
||||
setPluginCount(0);
|
||||
setSkillHasMore(false);
|
||||
setPluginHasMore(false);
|
||||
setIsSearching(false);
|
||||
return () => {};
|
||||
}
|
||||
@@ -83,14 +91,14 @@ export function useUnifiedSearch(
|
||||
if (activeType === "all" || activeType === "skills") {
|
||||
promises[0] = searchSkills({
|
||||
query: trimmed,
|
||||
limit: skillLimit,
|
||||
limit: skillLimit + 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeType === "all" || activeType === "plugins") {
|
||||
promises[1] = fetchPluginCatalog({
|
||||
q: trimmed,
|
||||
limit: pluginLimit,
|
||||
limit: pluginLimit + 1,
|
||||
signal: controller.signal,
|
||||
});
|
||||
}
|
||||
@@ -102,7 +110,7 @@ export function useUnifiedSearch(
|
||||
const skillsRaw = settled[0].status === "fulfilled" ? settled[0].value : null;
|
||||
const pluginsRaw = settled[1].status === "fulfilled" ? settled[1].value : null;
|
||||
|
||||
const nextSkillResults: UnifiedSkillResult[] = (
|
||||
const skillMatches: UnifiedSkillResult[] = (
|
||||
(skillsRaw as Array<{
|
||||
skill: UnifiedSkillResult["skill"];
|
||||
ownerHandle: string | null;
|
||||
@@ -114,16 +122,20 @@ export function useUnifiedSearch(
|
||||
ownerHandle: entry.ownerHandle,
|
||||
score: entry.score,
|
||||
}));
|
||||
const nextSkillResults = skillMatches.slice(0, skillLimit);
|
||||
|
||||
const nextPluginResults: UnifiedPluginResult[] = (
|
||||
const pluginMatches: UnifiedPluginResult[] = (
|
||||
(pluginsRaw as { items: PackageListItem[] })?.items ?? []
|
||||
).map((item) => ({
|
||||
type: "plugin" as const,
|
||||
plugin: item,
|
||||
}));
|
||||
const nextPluginResults = pluginMatches.slice(0, pluginLimit);
|
||||
|
||||
setSkillCount(nextSkillResults.length);
|
||||
setPluginCount(nextPluginResults.length);
|
||||
setSkillHasMore(skillMatches.length > skillLimit);
|
||||
setPluginHasMore(pluginMatches.length > pluginLimit);
|
||||
setSkillResults(nextSkillResults);
|
||||
setPluginResults(nextPluginResults);
|
||||
|
||||
@@ -145,6 +157,8 @@ export function useUnifiedSearch(
|
||||
setPluginResults([]);
|
||||
setSkillCount(0);
|
||||
setPluginCount(0);
|
||||
setSkillHasMore(false);
|
||||
setPluginHasMore(false);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestRef.current) {
|
||||
@@ -161,5 +175,14 @@ export function useUnifiedSearch(
|
||||
};
|
||||
}, [query, activeType, searchSkills, debounceMs, enabled, skillLimit, pluginLimit]);
|
||||
|
||||
return { results, skillResults, pluginResults, skillCount, pluginCount, isSearching };
|
||||
return {
|
||||
results,
|
||||
skillResults,
|
||||
pluginResults,
|
||||
skillCount,
|
||||
pluginCount,
|
||||
skillHasMore,
|
||||
pluginHasMore,
|
||||
isSearching,
|
||||
};
|
||||
}
|
||||
|
||||
+119
-22
@@ -44,8 +44,12 @@ function UnifiedSearchPage() {
|
||||
|
||||
const {
|
||||
results: allResults,
|
||||
skillResults,
|
||||
pluginResults,
|
||||
skillCount,
|
||||
pluginCount,
|
||||
skillHasMore,
|
||||
pluginHasMore,
|
||||
isSearching,
|
||||
} = useUnifiedSearch(search.q ?? "", "all", {
|
||||
limits: {
|
||||
@@ -53,18 +57,18 @@ function UnifiedSearchPage() {
|
||||
plugins: resultLimit,
|
||||
},
|
||||
});
|
||||
const results =
|
||||
activeType === "all"
|
||||
? allResults
|
||||
: allResults.filter((item) => item.type === (activeType === "skills" ? "skill" : "plugin"));
|
||||
const results: Array<UnifiedSkillResult | UnifiedPluginResult> =
|
||||
activeType === "all" ? allResults : activeType === "skills" ? skillResults : pluginResults;
|
||||
const showSearchCounts = Boolean(search.q);
|
||||
const allCount = skillCount + pluginCount;
|
||||
const allHasMore = skillHasMore || pluginHasMore;
|
||||
const canLoadMore =
|
||||
search.q &&
|
||||
!isSearching &&
|
||||
((activeType === "all" && (skillCount >= resultLimit || pluginCount >= resultLimit)) ||
|
||||
(activeType === "skills" && skillCount >= resultLimit) ||
|
||||
(activeType === "plugins" && pluginCount >= resultLimit));
|
||||
((activeType === "all" && allHasMore) ||
|
||||
(activeType === "skills" && skillHasMore) ||
|
||||
(activeType === "plugins" && pluginHasMore));
|
||||
const hasOtherTypeMatches = activeType !== "all" && allCount > 0;
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -139,14 +143,20 @@ function UnifiedSearchPage() {
|
||||
type="button"
|
||||
onClick={() => setType("all")}
|
||||
>
|
||||
All {showSearchCounts ? <span className="search-tab-count">{allCount}</span> : null}
|
||||
All{" "}
|
||||
{showSearchCounts ? (
|
||||
<span className="search-tab-count">{formatSearchCount(allCount, allHasMore)}</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
className={`search-tab${activeType === "skills" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setType("skills")}
|
||||
>
|
||||
Skills {showSearchCounts ? <span className="search-tab-count">{skillCount}</span> : null}
|
||||
Skills{" "}
|
||||
{showSearchCounts ? (
|
||||
<span className="search-tab-count">{formatSearchCount(skillCount, skillHasMore)}</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
className={`search-tab${activeType === "plugins" ? " is-active" : ""}`}
|
||||
@@ -154,7 +164,11 @@ function UnifiedSearchPage() {
|
||||
onClick={() => setType("plugins")}
|
||||
>
|
||||
Plugins{" "}
|
||||
{showSearchCounts ? <span className="search-tab-count">{pluginCount}</span> : null}
|
||||
{showSearchCounts ? (
|
||||
<span className="search-tab-count">
|
||||
{formatSearchCount(pluginCount, pluginHasMore)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -167,20 +181,48 @@ function UnifiedSearchPage() {
|
||||
<p className="text-ink-soft">Enter a search term to find skills and plugins</p>
|
||||
</Card>
|
||||
) : results.length === 0 ? (
|
||||
<Card className="text-center p-10">
|
||||
<p className="text-ink-soft">No results found for "{search.q}"</p>
|
||||
</Card>
|
||||
<SearchEmptyState
|
||||
activeType={activeType}
|
||||
hasOtherTypeMatches={hasOtherTypeMatches}
|
||||
onSearchAllTypes={() => setType("all")}
|
||||
query={search.q}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="results-list">
|
||||
{results.map((item) =>
|
||||
item.type === "skill" ? (
|
||||
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
|
||||
) : (
|
||||
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{activeType === "all" ? (
|
||||
<div className="search-results-sections">
|
||||
{skillResults.length > 0 ? (
|
||||
<SearchResultSection
|
||||
countLabel={formatSearchCount(skillCount, skillHasMore)}
|
||||
title="Skills"
|
||||
>
|
||||
{skillResults.map((item) => (
|
||||
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
|
||||
))}
|
||||
</SearchResultSection>
|
||||
) : null}
|
||||
{pluginResults.length > 0 ? (
|
||||
<SearchResultSection
|
||||
countLabel={formatSearchCount(pluginCount, pluginHasMore)}
|
||||
title="Plugins"
|
||||
>
|
||||
{pluginResults.map((item) => (
|
||||
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
|
||||
))}
|
||||
</SearchResultSection>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="results-list">
|
||||
{results.map((item) =>
|
||||
item.type === "skill" ? (
|
||||
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
|
||||
) : (
|
||||
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canLoadMore ? (
|
||||
<div className="search-load-more">
|
||||
<button
|
||||
@@ -198,6 +240,61 @@ function UnifiedSearchPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function SearchEmptyState({
|
||||
activeType,
|
||||
hasOtherTypeMatches,
|
||||
onSearchAllTypes,
|
||||
query,
|
||||
}: {
|
||||
activeType: UnifiedSearchType;
|
||||
hasOtherTypeMatches: boolean;
|
||||
onSearchAllTypes: () => void;
|
||||
query: string;
|
||||
}) {
|
||||
const browseHref = activeType === "plugins" ? "/plugins" : "/skills";
|
||||
const browseLabel = activeType === "plugins" ? "Show all plugins" : "Show all skills";
|
||||
|
||||
return (
|
||||
<Card className="search-empty-state">
|
||||
<p className="search-empty-title">No matches for "{query}"</p>
|
||||
<div className="search-empty-actions">
|
||||
{hasOtherTypeMatches ? (
|
||||
<button type="button" className="search-empty-action" onClick={onSearchAllTypes}>
|
||||
Search all types
|
||||
</button>
|
||||
) : null}
|
||||
<a className="search-empty-action" href={browseHref}>
|
||||
{browseLabel}
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSearchCount(count: number, hasMore: boolean) {
|
||||
return hasMore ? `${count}+` : String(count);
|
||||
}
|
||||
|
||||
function SearchResultSection({
|
||||
children,
|
||||
countLabel,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
countLabel: string;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="search-results-section" aria-label={title}>
|
||||
<div className="search-results-section-header">
|
||||
<h2 className="search-results-section-title">{title}</h2>
|
||||
<span className="search-results-section-count">{countLabel}</span>
|
||||
</div>
|
||||
<div className="results-list">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillResultRow({ result }: { result: UnifiedSkillResult }) {
|
||||
const skill = result.skill as unknown as PublicSkill;
|
||||
return <SkillListItem skill={skill} ownerHandle={result.ownerHandle} />;
|
||||
|
||||
@@ -10578,6 +10578,78 @@ a.agentic-risk-finding-title:focus-visible {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.search-results-sections {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.search-results-section {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.search-results-section-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.search-results-section-title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.search-results-section-count {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.search-empty-state {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-empty-title {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.search-empty-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.search-empty-action {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.search-empty-action:hover {
|
||||
border-color: color-mix(in srgb, var(--ink) 18%, var(--line));
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
a.search-empty-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.search-load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
Reference in New Issue
Block a user