feat(doc): introduce Document View at /doc/[...path] with split content/outline, tags, history, and ≤500KB diff tab; wire navigation from browse/search to doc view
This commit is contained in:
parent
aa318d0af2
commit
01c47558db
@ -36,11 +36,11 @@ export async function POST(req: Request) {
|
|||||||
if (semantic && embeddingsEnabled) {
|
if (semantic && embeddingsEnabled) {
|
||||||
// Compute query vector and run hybrid search
|
// Compute query vector and run hybrid search
|
||||||
const vector = await getEmbeddingForText(q);
|
const vector = await getEmbeddingForText(q);
|
||||||
return hybridSearch(q, vector, filters, { page, perPage, sort, alpha: 0.6 });
|
return hybridSearch(q, vector, filters, { page, perPage, sort, alpha: 0.6, withFacets: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: pure BM25
|
// Fallback: pure BM25
|
||||||
return bm25Search(q, filters, { page, perPage, sort });
|
return bm25Search(q, filters, { page, perPage, sort, withFacets: true });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
280
src/app/doc/[...path]/page.tsx
Normal file
280
src/app/doc/[...path]/page.tsx
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import CodeMirror from "@uiw/react-codemirror";
|
||||||
|
import { markdown } from "@codemirror/lang-markdown";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { MarkdownEditor } from "@/components/editor/markdown-editor";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { TagsDialog } from "@/components/files/tags-dialog";
|
||||||
|
import { Breadcrumbs } from "@/components/navigation/breadcrumbs";
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: { path?: string[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchContent(path: string) {
|
||||||
|
const url = new URL("/api/files/content", window.location.origin);
|
||||||
|
url.searchParams.set("path", path);
|
||||||
|
const res = await fetch(url.toString(), { cache: "no-store" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const payload = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(payload?.message || `Failed to load content (${res.status})`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as { ok: boolean; content?: string; mimeType?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTagHistory(path: string) {
|
||||||
|
const url = new URL("/api/files/tags/history", window.location.origin);
|
||||||
|
url.searchParams.set("path", path);
|
||||||
|
const res = await fetch(url.toString(), { cache: "no-store" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const payload = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(payload?.message || `Failed to load tag history (${res.status})`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as { ok: boolean; history?: Array<{ id: string; path: string; actor?: string; action: string; from?: string[]; to: string[]; at: string }> };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOutline(md?: string) {
|
||||||
|
if (!md) return [] as Array<{ level: number; text: string; line: number }>;
|
||||||
|
const lines = md.split(/\r?\n/);
|
||||||
|
const out: Array<{ level: number; text: string; line: number }> = [];
|
||||||
|
lines.forEach((ln, i) => {
|
||||||
|
const m = /^(#{1,6})\s+(.*)$/.exec(ln);
|
||||||
|
if (m) {
|
||||||
|
out.push({ level: m[1].length, text: m[2].trim(), line: i });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simpleUnifiedDiff(a: string, b: string) {
|
||||||
|
// naive line-by-line diff for small files
|
||||||
|
const aLines = a.split(/\r?\n/);
|
||||||
|
const bLines = b.split(/\r?\n/);
|
||||||
|
const max = Math.max(aLines.length, bLines.length);
|
||||||
|
const rows: Array<{ type: "ctx" | "del" | "add"; text: string }> = [];
|
||||||
|
for (let i = 0; i < max; i++) {
|
||||||
|
const A = aLines[i];
|
||||||
|
const B = bLines[i];
|
||||||
|
if (A === B) {
|
||||||
|
if (A !== undefined) rows.push({ type: "ctx", text: A });
|
||||||
|
} else {
|
||||||
|
if (A !== undefined) rows.push({ type: "del", text: A });
|
||||||
|
if (B !== undefined) rows.push({ type: "add", text: B });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocPage({ params }: PageProps) {
|
||||||
|
const path = "/" + (params.path?.join("/") ?? "");
|
||||||
|
const [activeTab, setActiveTab] = React.useState<"content" | "versions" | "diff" | "tags" | "history">("content");
|
||||||
|
|
||||||
|
const [editorOpen, setEditorOpen] = React.useState(false);
|
||||||
|
const [tagsOpen, setTagsOpen] = React.useState(false);
|
||||||
|
|
||||||
|
// Content
|
||||||
|
const contentQuery = useQuery({
|
||||||
|
queryKey: ["doc-content", path],
|
||||||
|
queryFn: () => fetchContent(path),
|
||||||
|
});
|
||||||
|
|
||||||
|
const outline = React.useMemo(() => buildOutline(contentQuery.data?.content || ""), [contentQuery.data?.content]);
|
||||||
|
|
||||||
|
// Diff
|
||||||
|
const [comparePath, setComparePath] = React.useState("");
|
||||||
|
const [diffRows, setDiffRows] = React.useState<Array<{ type: "ctx" | "del" | "add"; text: string }> | null>(null);
|
||||||
|
const [diffError, setDiffError] = React.useState<string | null>(null);
|
||||||
|
const [diffLoading, setDiffLoading] = React.useState(false);
|
||||||
|
|
||||||
|
async function runDiff() {
|
||||||
|
try {
|
||||||
|
setDiffError(null);
|
||||||
|
setDiffLoading(true);
|
||||||
|
if (!contentQuery.data?.content) throw new Error("Primary document not loaded");
|
||||||
|
const primary = contentQuery.data.content;
|
||||||
|
const primaryBytes = new TextEncoder().encode(primary).length;
|
||||||
|
const res = await fetchContent(comparePath);
|
||||||
|
const other = res.content ?? "";
|
||||||
|
const otherBytes = new TextEncoder().encode(other).length;
|
||||||
|
if (primaryBytes > 500_000 || otherBytes > 500_000) {
|
||||||
|
throw new Error("Diff is limited to files ≤ 500KB each");
|
||||||
|
}
|
||||||
|
const rows = simpleUnifiedDiff(other, primary); // show changes from other -> current
|
||||||
|
setDiffRows(rows);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
setDiffError(msg);
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
setDiffLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// History
|
||||||
|
const historyQuery = useQuery({
|
||||||
|
queryKey: ["doc-tag-history", path],
|
||||||
|
queryFn: () => fetchTagHistory(path),
|
||||||
|
enabled: activeTab === "history",
|
||||||
|
});
|
||||||
|
|
||||||
|
function downloadCurrent() {
|
||||||
|
const url = new URL("/api/files/download", window.location.origin);
|
||||||
|
url.searchParams.set("path", path);
|
||||||
|
window.open(url.toString(), "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col">
|
||||||
|
<header className="border-b p-3 flex items-center gap-3">
|
||||||
|
<Breadcrumbs path={path} onNavigate={() => {}} />
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => contentQuery.refetch()}>
|
||||||
|
{contentQuery.isFetching ? "Refreshing..." : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setEditorOpen(true)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" onClick={downloadCurrent}>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b px-3 py-2 flex items-center gap-2">
|
||||||
|
{(["content", "versions", "diff", "tags", "history"] as const).map((t) => (
|
||||||
|
<Button
|
||||||
|
key={t}
|
||||||
|
variant={activeTab === t ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActiveTab(t)}
|
||||||
|
>
|
||||||
|
{t === "content" ? "Content" : t[0].toUpperCase() + t.slice(1)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{activeTab === "content" ? (
|
||||||
|
<div className="grid grid-cols-[1fr_280px] gap-3 p-3 h-full">
|
||||||
|
<div className="border rounded-md overflow-hidden">
|
||||||
|
<CodeMirror
|
||||||
|
value={contentQuery.data?.content ?? ""}
|
||||||
|
height="calc(100vh - 200px)"
|
||||||
|
extensions={[markdown()]}
|
||||||
|
editable={false}
|
||||||
|
theme="dark"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<aside className="border rounded-md p-2">
|
||||||
|
<div className="text-sm font-semibold mb-2">Outline</div>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
{outline.length === 0 ? <div className="text-muted-foreground">No headings</div> : null}
|
||||||
|
{outline.map((h, idx) => (
|
||||||
|
<div key={`${h.text}-${idx}`} style={{ paddingLeft: (h.level - 1) * 12 }}>
|
||||||
|
• {h.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === "versions" ? (
|
||||||
|
<div className="p-3 text-sm text-muted-foreground">
|
||||||
|
Versions are not yet integrated with Nextcloud for this MVP. Use Download to retrieve content or maintain your own revisions. This tab will be wired to Nextcloud versions when available.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === "diff" ? (
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Compare with path</label>
|
||||||
|
<Input
|
||||||
|
placeholder="/remote.php/dav/files/admin/path/to/other.md"
|
||||||
|
value={comparePath}
|
||||||
|
onChange={(e) => setComparePath(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={runDiff} disabled={diffLoading || !comparePath}>
|
||||||
|
{diffLoading ? "Loading…" : "Load & Diff"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{diffError ? <div className="text-sm text-destructive">{diffError}</div> : null}
|
||||||
|
{diffRows ? (
|
||||||
|
<pre className="text-xs border rounded-md p-2 overflow-auto max-h-[60vh]">
|
||||||
|
{diffRows.map((r, i) => {
|
||||||
|
const prefix = r.type === "ctx" ? " " : r.type === "del" ? "-" : "+";
|
||||||
|
const cls =
|
||||||
|
r.type === "ctx"
|
||||||
|
? ""
|
||||||
|
: r.type === "del"
|
||||||
|
? "text-red-500"
|
||||||
|
: "text-green-500";
|
||||||
|
return (
|
||||||
|
<div key={i} className={cls}>
|
||||||
|
{prefix} {r.text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === "tags" ? (
|
||||||
|
<div className="p-3">
|
||||||
|
<Button onClick={() => setTagsOpen(true)}>Manage Tags</Button>
|
||||||
|
<TagsDialog
|
||||||
|
path={path}
|
||||||
|
open={tagsOpen}
|
||||||
|
onOpenChange={(v) => setTagsOpen(v)}
|
||||||
|
initialTags={[]}
|
||||||
|
onSaved={() => {
|
||||||
|
toast.success("Tags updated");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === "history" ? (
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="text-sm font-semibold mb-2">Tag History</div>
|
||||||
|
{historyQuery.isLoading ? <div className="text-sm text-muted-foreground">Loading…</div> : null}
|
||||||
|
{historyQuery.data?.history && historyQuery.data.history.length > 0 ? (
|
||||||
|
<div className="text-sm space-y-2">
|
||||||
|
{historyQuery.data.history.map((h) => (
|
||||||
|
<div key={h.id} className="border rounded-md p-2">
|
||||||
|
<div className="text-xs text-muted-foreground">{new Date(h.at).toLocaleString()} • {h.actor ?? "unknown"}</div>
|
||||||
|
<div className="mt-1">Tags: {(h.to ?? []).join(", ")}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">No tag history</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor dialog */}
|
||||||
|
<Dialog open={editorOpen} onOpenChange={(v) => setEditorOpen(v)}>
|
||||||
|
<DialogContent className="max-w-4xl h-[80vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Document</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="h-[calc(80vh-60px)]">
|
||||||
|
{editorOpen ? <MarkdownEditor path={path} /> : null}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { SidebarTree } from "@/components/sidebar/sidebar-tree";
|
import { SidebarTree } from "@/components/sidebar/sidebar-tree";
|
||||||
import { Breadcrumbs } from "@/components/navigation/breadcrumbs";
|
import { Breadcrumbs } from "@/components/navigation/breadcrumbs";
|
||||||
import { FileTable, type FileRow } from "@/components/files/file-table";
|
import { FileTable, type FileRow } from "@/components/files/file-table";
|
||||||
@ -65,6 +66,7 @@ async function executeSearch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [path, setPath] = React.useState<string | undefined>(undefined);
|
const [path, setPath] = React.useState<string | undefined>(undefined);
|
||||||
const [page, setPage] = React.useState(1);
|
const [page, setPage] = React.useState(1);
|
||||||
@ -119,9 +121,20 @@ export default function Home() {
|
|||||||
window.open(url.toString(), "_blank", "noopener,noreferrer");
|
window.open(url.toString(), "_blank", "noopener,noreferrer");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function encodeDocPath(p: string) {
|
||||||
|
// Convert an absolute path like "/a/b c/d.md" -> "/doc/a/b%20c/d.md"
|
||||||
|
const segments = p.replace(/^\/+/, "").split("/");
|
||||||
|
const encoded = segments.map((s) => encodeURIComponent(s)).join("/");
|
||||||
|
return `/doc/${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToDoc(p: string) {
|
||||||
|
router.push(encodeDocPath(p));
|
||||||
|
}
|
||||||
|
|
||||||
function handleOpen(item: FileRow) {
|
function handleOpen(item: FileRow) {
|
||||||
// Placeholder for preview behavior; for now, download
|
// Navigate to the Document View for the selected file
|
||||||
handleDownload(item);
|
navigateToDoc(item.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEdit(item: FileRow) {
|
function handleEdit(item: FileRow) {
|
||||||
@ -298,7 +311,7 @@ export default function Home() {
|
|||||||
window.open(url.toString(), "_blank", "noopener,noreferrer");
|
window.open(url.toString(), "_blank", "noopener,noreferrer");
|
||||||
}
|
}
|
||||||
function openByPath(p: string) {
|
function openByPath(p: string) {
|
||||||
downloadByPath(p);
|
navigateToDoc(p);
|
||||||
}
|
}
|
||||||
function tagsByPath(p: string) {
|
function tagsByPath(p: string) {
|
||||||
setTagsPath(p);
|
setTagsPath(p);
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import * as Sentry from "@sentry/nextjs";
|
|||||||
import { Client } from "@elastic/elasticsearch";
|
import { Client } from "@elastic/elasticsearch";
|
||||||
import type { estypes } from "@elastic/elasticsearch";
|
import type { estypes } from "@elastic/elasticsearch";
|
||||||
import { env, getEsAuth, embeddingsEnabled } from "@/lib/env";
|
import { env, getEsAuth, embeddingsEnabled } from "@/lib/env";
|
||||||
import type { FacetFilters, SearchResult, SortMode } from "@/types/search";
|
import type { FacetFilters, SearchResult, SortMode, FacetResult } from "@/types/search";
|
||||||
import type { IndexDocument } from "@/types/ingest";
|
import type { IndexDocument } from "@/types/ingest";
|
||||||
|
|
||||||
let _client: Client | null = null;
|
let _client: Client | null = null;
|
||||||
@ -96,6 +96,85 @@ function sortFromMode(sort: SortMode | undefined) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build facet aggregations for ES query
|
||||||
|
function buildFacetAggs(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
types: {
|
||||||
|
terms: {
|
||||||
|
field: "mimeType",
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
terms: {
|
||||||
|
field: "owner",
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
terms: {
|
||||||
|
field: "tags",
|
||||||
|
size: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type TermsAgg = { buckets?: Array<{ key: string | number; doc_count: number }> };
|
||||||
|
type AggsShape = { types?: TermsAgg; owner?: TermsAgg; tags?: TermsAgg };
|
||||||
|
|
||||||
|
function toFacetResult(aggs?: AggsShape): FacetResult | undefined {
|
||||||
|
if (!aggs) return undefined;
|
||||||
|
const map = (buckets?: TermsAgg["buckets"]) =>
|
||||||
|
buckets?.map((b) => ({ key: String(b.key), count: b.doc_count })) ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
types: map(aggs.types?.buckets),
|
||||||
|
owner: map(aggs.owner?.buckets),
|
||||||
|
tags: map(aggs.tags?.buckets),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeFacets(
|
||||||
|
q: string,
|
||||||
|
filters: FacetFilters = {},
|
||||||
|
): Promise<FacetResult | undefined> {
|
||||||
|
const client = getEsClient();
|
||||||
|
const bool = filtersToBool(filters);
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
size: 0,
|
||||||
|
query: {
|
||||||
|
bool: {
|
||||||
|
...bool,
|
||||||
|
must: [
|
||||||
|
...(bool.must || []),
|
||||||
|
q
|
||||||
|
? {
|
||||||
|
multi_match: {
|
||||||
|
query: q,
|
||||||
|
type: "best_fields",
|
||||||
|
fields: ["name^4", "nickname^3", "title^3", "tags^2", "content^1", "path.text^0.5"],
|
||||||
|
operator: "and",
|
||||||
|
fuzziness: "AUTO",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { match_all: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aggs: buildFacetAggs(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await client.search({
|
||||||
|
index: env.ELASTICSEARCH_ALIAS || env.ELASTICSEARCH_INDEX,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const aggs = res.aggregations as unknown as AggsShape | undefined;
|
||||||
|
return toFacetResult(aggs);
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureIndex(options?: { recreate?: boolean }) {
|
export async function ensureIndex(options?: { recreate?: boolean }) {
|
||||||
const span = Sentry.startSpan(
|
const span = Sentry.startSpan(
|
||||||
{ op: "db.query", name: "ES ensureIndex" },
|
{ op: "db.query", name: "ES ensureIndex" },
|
||||||
@ -309,7 +388,7 @@ function toSearchResult(
|
|||||||
export async function bm25Search(
|
export async function bm25Search(
|
||||||
q: string,
|
q: string,
|
||||||
filters: FacetFilters = {},
|
filters: FacetFilters = {},
|
||||||
opts: { page?: number; perPage?: number; sort?: SortMode } = {},
|
opts: { page?: number; perPage?: number; sort?: SortMode; withFacets?: boolean } = {},
|
||||||
): Promise<SearchResult> {
|
): Promise<SearchResult> {
|
||||||
return Sentry.startSpan(
|
return Sentry.startSpan(
|
||||||
{ op: "db.query", name: "ES bm25Search" },
|
{ op: "db.query", name: "ES bm25Search" },
|
||||||
@ -382,7 +461,12 @@ export async function bm25Search(
|
|||||||
highlight?: Record<string, string[]>;
|
highlight?: Record<string, string[]>;
|
||||||
}>,
|
}>,
|
||||||
);
|
);
|
||||||
return toSearchResult(hits, total, res.took ?? 0);
|
const base = toSearchResult(hits, total, res.took ?? 0);
|
||||||
|
if (opts.withFacets) {
|
||||||
|
const facets = await computeFacets(q, filters);
|
||||||
|
return { ...base, facets };
|
||||||
|
}
|
||||||
|
return base;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -448,7 +532,7 @@ export async function hybridSearch(
|
|||||||
q: string,
|
q: string,
|
||||||
vector: number[] | undefined,
|
vector: number[] | undefined,
|
||||||
filters: FacetFilters = {},
|
filters: FacetFilters = {},
|
||||||
opts: { page?: number; perPage?: number; sort?: SortMode; alpha?: number } = {},
|
opts: { page?: number; perPage?: number; sort?: SortMode; alpha?: number; withFacets?: boolean } = {},
|
||||||
): Promise<SearchResult> {
|
): Promise<SearchResult> {
|
||||||
const alpha = Math.max(0, Math.min(1, opts.alpha ?? 0.5));
|
const alpha = Math.max(0, Math.min(1, opts.alpha ?? 0.5));
|
||||||
const page = Math.max(1, opts.page ?? 1);
|
const page = Math.max(1, opts.page ?? 1);
|
||||||
@ -519,11 +603,23 @@ export async function hybridSearch(
|
|||||||
const pageStart = (page - 1) * perPage;
|
const pageStart = (page - 1) * perPage;
|
||||||
const pageItems = blended.slice(pageStart, pageStart + perPage);
|
const pageItems = blended.slice(pageStart, pageStart + perPage);
|
||||||
|
|
||||||
return {
|
const result: SearchResult = {
|
||||||
total: blended.length,
|
total: blended.length,
|
||||||
tookMs: 0,
|
tookMs: 0,
|
||||||
hits: pageItems,
|
hits: pageItems.map((h) => ({
|
||||||
|
doc: h.doc as unknown as DocType,
|
||||||
|
score: h.score,
|
||||||
|
bm25Score: h.bm25Score,
|
||||||
|
vectorScore: h.vectorScore,
|
||||||
|
highlights: h.highlights,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (opts.withFacets) {
|
||||||
|
const facets = await computeFacets(q, filters);
|
||||||
|
return { ...result, facets };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,14 @@ export type Highlights = {
|
|||||||
content?: string[];
|
content?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FacetBucket = { key: string; count: number };
|
||||||
|
|
||||||
|
export type FacetResult = {
|
||||||
|
types?: FacetBucket[];
|
||||||
|
owner?: FacetBucket[];
|
||||||
|
tags?: FacetBucket[];
|
||||||
|
};
|
||||||
|
|
||||||
export interface SearchQuery {
|
export interface SearchQuery {
|
||||||
q: string;
|
q: string;
|
||||||
filters?: FacetFilters;
|
filters?: FacetFilters;
|
||||||
@ -41,4 +49,5 @@ export interface SearchResult {
|
|||||||
total: number;
|
total: number;
|
||||||
hits: SearchHit[];
|
hits: SearchHit[];
|
||||||
tookMs: number;
|
tookMs: number;
|
||||||
|
facets?: FacetResult;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user