feat: complete search REST route, restore disk sync, and extraFields support across API/MCP
This commit is contained in:
@@ -22,6 +22,26 @@ const STANDARD_FIELDS = new Set([
|
||||
"author", "tags", "createdAt", "updatedAt", "id", "path", "slug",
|
||||
]);
|
||||
|
||||
function parseCustomValue(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "true") return true;
|
||||
if (trimmed === "false") return false;
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed) && !(trimmed.startsWith("0") && trimmed.length > 1 && !trimmed.startsWith("0."))) {
|
||||
const num = Number(trimmed);
|
||||
if (!isNaN(num)) return num;
|
||||
}
|
||||
if ((trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("{") && trimmed.endsWith("}"))) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function splitPayload(body: Record<string, unknown>): {
|
||||
standard: Record<string, unknown>;
|
||||
extraFields: Record<string, unknown>;
|
||||
@@ -29,10 +49,12 @@ function splitPayload(body: Record<string, unknown>): {
|
||||
const standard: Record<string, unknown> = {};
|
||||
const extraFields: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (STANDARD_FIELDS.has(key)) {
|
||||
if (key === "extraFields" && typeof value === "object" && value !== null) {
|
||||
Object.assign(extraFields, value);
|
||||
} else if (STANDARD_FIELDS.has(key)) {
|
||||
standard[key] = value;
|
||||
} else if (value !== undefined && value !== null) {
|
||||
extraFields[key] = value;
|
||||
extraFields[key] = parseCustomValue(value);
|
||||
}
|
||||
}
|
||||
return { standard, extraFields };
|
||||
|
||||
@@ -30,6 +30,26 @@ const STANDARD_FIELDS = new Set([
|
||||
"author", "tags", "createdAt", "updatedAt", "id", "path",
|
||||
]);
|
||||
|
||||
function parseCustomValue(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "true") return true;
|
||||
if (trimmed === "false") return false;
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed) && !(trimmed.startsWith("0") && trimmed.length > 1 && !trimmed.startsWith("0."))) {
|
||||
const num = Number(trimmed);
|
||||
if (!isNaN(num)) return num;
|
||||
}
|
||||
if ((trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("{") && trimmed.endsWith("}"))) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separate a flat payload into standard CRUD fields + extraFields (custom metadata).
|
||||
* The DocForm sends all fields flat — any key not in STANDARD_FIELDS becomes an
|
||||
@@ -42,10 +62,12 @@ function splitPayload(body: Record<string, unknown>): {
|
||||
const standard: Record<string, unknown> = {};
|
||||
const extraFields: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (STANDARD_FIELDS.has(key)) {
|
||||
if (key === "extraFields" && typeof value === "object" && value !== null) {
|
||||
Object.assign(extraFields, value);
|
||||
} else if (STANDARD_FIELDS.has(key)) {
|
||||
standard[key] = value;
|
||||
} else {
|
||||
extraFields[key] = value;
|
||||
} else if (value !== undefined && value !== null) {
|
||||
extraFields[key] = parseCustomValue(value);
|
||||
}
|
||||
}
|
||||
return { standard, extraFields };
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { keywordSearch, hybridSearch, semanticSearch } from "@mcpedia/core";
|
||||
|
||||
// GET /api/search?q=...&mode=hybrid|keyword|semantic
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const q = searchParams.get("q") ?? "";
|
||||
const mode = searchParams.get("mode") ?? "hybrid";
|
||||
const limit = Math.min(Math.max(Number(searchParams.get("limit") ?? 20), 1), 50);
|
||||
|
||||
if (!q.trim()) {
|
||||
return NextResponse.json({ results: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
if (mode === "keyword") {
|
||||
const hits = await keywordSearch(q, limit);
|
||||
return NextResponse.json({
|
||||
results: hits.map((h) => ({
|
||||
slug: h.doc.slug,
|
||||
title: h.doc.title,
|
||||
section: h.doc.section,
|
||||
score: h.rank,
|
||||
snippet: h.snippet,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "semantic") {
|
||||
const hits = await semanticSearch(q, limit);
|
||||
return NextResponse.json({
|
||||
results: hits.map((h) => ({
|
||||
slug: h.slug,
|
||||
title: h.slug.split("/").pop() ?? h.slug,
|
||||
section: h.slug.split("/")[0] ?? "docs",
|
||||
score: h.score,
|
||||
snippet: h.content.slice(0, 160),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Default: hybrid search
|
||||
const hits = await hybridSearch(q, limit);
|
||||
return NextResponse.json({
|
||||
results: hits.map((h) => ({
|
||||
slug: h.doc.slug,
|
||||
title: h.doc.title,
|
||||
section: h.doc.section,
|
||||
score: h.rank,
|
||||
snippet: h.snippet,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Search API error:", err);
|
||||
// Fall back to keyword search if embedding provider fails
|
||||
try {
|
||||
const hits = await keywordSearch(q, limit);
|
||||
return NextResponse.json({
|
||||
results: hits.map((h) => ({
|
||||
slug: h.doc.slug,
|
||||
title: h.doc.title,
|
||||
section: h.doc.section,
|
||||
score: h.rank,
|
||||
snippet: h.snippet,
|
||||
})),
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ results: [], error: "Search failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user