Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 38 additions & 10 deletions apps/web/modules/ee/unify-feedback/topics-subtopics/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/
import { semanticSearchFeedbackRecords } from "@/modules/hub/service";
import type { SemanticSearchResultItem } from "@/modules/hub/types";

const TOPICS_PREVIEW_LIMIT = 10;
const TOPICS_PREVIEW_PAGE_SIZE = 50;
const SEARCH_CONCURRENCY = 4;

const ZSemanticSearchFeedbackRecordsAction = z.object({
workspaceId: ZId,
query: z.string().trim().min(1).max(500),
limit: z.number().min(1).max(50).optional(),
minScore: z.number().min(0).max(1).optional(),
cursors: z.record(z.string(), z.string()).optional(),
});

export type TTopicsPreviewSearchResult = SemanticSearchResultItem & {
Expand All @@ -29,6 +29,7 @@ export type TTopicsPreviewSearchResult = SemanticSearchResultItem & {

export type TTopicsPreviewSearchActionResult = {
results: TTopicsPreviewSearchResult[];
cursors: Record<string, string>;
unavailable: boolean;
unavailableMessage?: string;
};
Expand Down Expand Up @@ -70,23 +71,31 @@ export const semanticSearchFeedbackRecordsAction = authenticatedActionClient

const directories = await getFeedbackDirectoriesByWorkspaceId(parsedInput.workspaceId);
if (directories.length === 0) {
return { results: [], unavailable: false };
return { results: [], cursors: {}, unavailable: false };
}

const limit = parsedInput.limit ?? TOPICS_PREVIEW_LIMIT;
// On "load more" only re-query directories that returned a cursor on the previous page.
// Filtering against the workspace's directories above also guards against cursor-bag
// tampering — any directory id not in this set is silently dropped.
const targetDirectories = parsedInput.cursors
? directories.filter((d) => Boolean(parsedInput.cursors?.[d.id]))
: directories;

const searches: {
directory: (typeof directories)[number];
result: Awaited<ReturnType<typeof semanticSearchFeedbackRecords>>;
}[] = [];
for (let i = 0; i < directories.length; i += SEARCH_CONCURRENCY) {
const chunk = directories.slice(i, i + SEARCH_CONCURRENCY);
for (let i = 0; i < targetDirectories.length; i += SEARCH_CONCURRENCY) {
const chunk = targetDirectories.slice(i, i + SEARCH_CONCURRENCY);
const chunkResults = await Promise.all(
chunk.map(async (directory) => {
const cursor = parsedInput.cursors?.[directory.id];
const result = await semanticSearchFeedbackRecords({
tenant_id: directory.id,
query: parsedInput.query,
limit,
limit: TOPICS_PREVIEW_PAGE_SIZE,
min_score: parsedInput.minScore,
...(cursor ? { cursor } : {}),
});
return { directory, result };
})
Expand All @@ -102,17 +111,36 @@ export const semanticSearchFeedbackRecordsAction = authenticatedActionClient
}))
);

const nextCursors: Record<string, string> = {};
for (const { directory, result } of searches) {
const nextCursor = result.data?.next_cursor;
if (nextCursor) {
nextCursors[directory.id] = nextCursor;
}
}

// A directory returning 0/503 is a transient outage we want to surface even when
// other directories returned data — otherwise the failing directory silently drops
// out of nextCursors and stays excluded from every subsequent "load more".
const transientOutage = searches.find(({ result }) => {
const status = result.error?.status;
return status === 0 || status === 503;
})?.result.error;

if (successfulResults.length > 0) {
return {
results: successfulResults.toSorted((a, b) => b.score - a.score).slice(0, limit),
unavailable: false,
results: successfulResults.toSorted((a, b) => b.score - a.score),
cursors: nextCursors,
unavailable: Boolean(transientOutage),
...(transientOutage ? { unavailableMessage: transientOutage.message } : {}),
};
}

const firstError = searches.find(({ result }) => result.error)?.result.error;
if (firstError?.status === 0 || firstError?.status === 503) {
return {
results: [],
cursors: {},
unavailable: true,
unavailableMessage: firstError.message,
};
Expand All @@ -122,6 +150,6 @@ export const semanticSearchFeedbackRecordsAction = authenticatedActionClient
throw new Error(firstError.message);
}

return { results: [], unavailable: false };
return { results: [], cursors: {}, unavailable: false };
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,20 @@ export const TopicsSubtopicsPreview = ({
}: Readonly<TopicsSubtopicsPreviewProps>) => {
const { t } = useTranslation();
const [query, setQuery] = useState("");
// The query bound to the current results + cursors. Kept separate from `query` (the
// live input) so that editing the input mid-pagination does not corrupt "load more"
// by submitting a different query against the existing cursors.
const [activeQuery, setActiveQuery] = useState("");
const [results, setResults] = useState<TTopicsPreviewSearchResult[]>([]);
const [cursors, setCursors] = useState<Record<string, string>>({});
const [hasSearched, setHasSearched] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [unavailableMessage, setUnavailableMessage] = useState<string | null>(null);

const hasMore = Object.keys(cursors).length > 0;

const hasDirectories = Object.keys(directoryMap).length > 0;

const exampleSearches = [
Expand All @@ -41,24 +49,26 @@ export const TopicsSubtopicsPreview = ({

const runSearch = async (searchQuery: string) => {
const trimmedQuery = searchQuery.trim();
if (!trimmedQuery || isSearching) return;
if (!trimmedQuery || isSearching || isLoadingMore) return;

setQuery(trimmedQuery);
setActiveQuery(trimmedQuery);
setIsSearching(true);
setHasSearched(true);
setError(null);
setUnavailableMessage(null);
setCursors({});

try {
const response = await semanticSearchFeedbackRecordsAction({
workspaceId,
query: trimmedQuery,
limit: 10,
minScore: 0.7,
});

if (response?.data) {
setResults(response.data.results);
setCursors(response.data.cursors);
setUnavailableMessage(response.data.unavailable ? (response.data.unavailableMessage ?? "") : null);
} else {
setResults([]);
Expand All @@ -77,6 +87,35 @@ export const TopicsSubtopicsPreview = ({
await runSearch(query);
};

const handleLoadMore = async () => {
if (isLoadingMore || isSearching || !hasMore || !activeQuery) return;
setIsLoadingMore(true);

try {
const response = await semanticSearchFeedbackRecordsAction({
workspaceId,
query: activeQuery,
minScore: 0.7,
cursors,
});

if (response?.data) {
const data = response.data;
setResults((prev) => [...prev, ...data.results]);
setCursors(data.cursors);
if (data.unavailable) {
setUnavailableMessage(data.unavailableMessage ?? "");
}
} else {
setError(getFormattedErrorMessage(response) ?? t("workspace.unify.semantic_search_failed"));
}
} catch {
setError(t("workspace.unify.semantic_search_failed"));
} finally {
setIsLoadingMore(false);
}
};

return (
<PageContentWrapper>
<PageHeader pageTitle={t("workspace.unify.feedback_records")}>
Expand Down Expand Up @@ -105,10 +144,13 @@ export const TopicsSubtopicsPreview = ({
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("workspace.unify.semantic_search_placeholder")}
disabled={!hasDirectories || isSearching}
disabled={!hasDirectories || isSearching || isLoadingMore}
aria-label={t("workspace.unify.semantic_search_input_label")}
/>
<Button type="submit" disabled={!query.trim() || !hasDirectories} loading={isSearching}>
<Button
type="submit"
disabled={!query.trim() || !hasDirectories || isLoadingMore}
loading={isSearching}>
<SearchIcon className="size-4" aria-hidden="true" />
{t("workspace.unify.search_feedback")}
</Button>
Expand All @@ -122,7 +164,7 @@ export const TopicsSubtopicsPreview = ({
type="button"
size="sm"
variant="secondary"
disabled={!hasDirectories || isSearching}
disabled={!hasDirectories || isSearching || isLoadingMore}
onClick={() => runSearch(label)}>
{label}
</Button>
Expand Down Expand Up @@ -158,32 +200,47 @@ export const TopicsSubtopicsPreview = ({
)}

{results.length > 0 && (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-200 px-4 py-3">
<p className="text-sm font-medium text-slate-900">
{t("workspace.unify.semantic_search_results_count", { count: results.length })}
</p>
</div>
<div className="divide-y divide-slate-100">
{results.map((result) => (
<div key={`${result.tenant_id}-${result.feedback_record_id}`} className="space-y-2 p-4">
<div className="flex flex-wrap items-center gap-2">
<Badge text={result.directory_name} type="gray" size="tiny" />
<span className="text-xs text-slate-500">
{t("workspace.unify.semantic_search_relevance", {
score: Math.round(result.score * 100),
})}
</span>
<div className="space-y-3">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-200 px-4 py-3">
<p className="text-sm font-medium text-slate-900">
{t("workspace.unify.semantic_search_results_count", { count: results.length })}
</p>
</div>
<div className="divide-y divide-slate-100">
{results.map((result) => (
<div key={`${result.tenant_id}-${result.feedback_record_id}`} className="space-y-2 p-4">
<div className="flex flex-wrap items-center gap-2">
<Badge text={result.directory_name} type="gray" size="tiny" />
<span className="text-xs text-slate-500">
{t("workspace.unify.semantic_search_relevance", {
score: Math.round(result.score * 100),
})}
</span>
</div>
<p className="text-sm font-medium text-slate-900">
{result.field_label || t("workspace.unify.field_label")}
</p>
<p className="whitespace-pre-wrap text-sm text-slate-700">
{result.value_text || t("workspace.unify.semantic_search_missing_text")}
</p>
</div>
<p className="text-sm font-medium text-slate-900">
{result.field_label || t("workspace.unify.field_label")}
</p>
<p className="whitespace-pre-wrap text-sm text-slate-700">
{result.value_text || t("workspace.unify.semantic_search_missing_text")}
</p>
</div>
))}
))}
</div>
</div>

{hasMore && (
<div className="flex justify-center">
<Button
variant="secondary"
size="sm"
onClick={handleLoadMore}
disabled={isLoadingMore || isSearching}
loading={isLoadingMore}>
{t("common.load_more")}
</Button>
</div>
)}
</div>
)}
</div>
Expand Down
Loading